基于PocketBasev0.22.2

PocketBase 允许使用 Go 和 JavaScript 扩展其服务器端能力,主要是为了在提供核心功能的同时,兼顾性能、控制力与开发便捷性、灵活性。

PocketBase 内置了一个 JavaScript 引擎,它作为现有 Go API 的可插拔封装层。这意味着开发者可以使用 JavaScript 编写服务器端逻辑,而无需学习 Go 语言。

由于 JavaScript 虚拟机(VM)镜像了 Go API,即使未来项目遇到性能瓶颈或需要更精细的控制,开发者也可以逐步从 JavaScript 迁移到 Go,而无需进行大量代码更改。

基于 Go 扩展 PocketBase

PocketBase 底层使用 echo v5 作为 web 框架,在扩展 PocketBase 的路由时使用的也是 echo 的 API 。

1
2
3
mkdir custom-pocketbase && cd $_ # 新建一个开发目录
go mod init custom_pocketbase # 初始化 Go 项目
vim main.go # 编辑 main.go 文件,加入以下代码,可按需修改

main.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
package main

import (
"log"
"os"
"path/filepath"
"strings"
"time"

"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/plugins/ghupdate"
"github.com/pocketbase/pocketbase/plugins/jsvm"
"github.com/pocketbase/pocketbase/plugins/migratecmd"
)

func main() {
app := pocketbase.New()

// ---------------------------------------------------------------
// 可选的插件标志
// ---------------------------------------------------------------

var hooksDir string // 使用 JavaScript 扩展 Pocketbase 时 js 代码存放的目录
app.RootCmd.PersistentFlags().StringVar(
&hooksDir,
"hooksDir",
"",
"the directory with the JS app hooks",
)

var hooksWatch bool // 热重启:当修改 js 代码后时候重新启动应用
app.RootCmd.PersistentFlags().BoolVar(
&hooksWatch,
"hooksWatch",
true,
"auto restart the app on pb_hooks file change",
)

var hooksPool int // 启动的 js 运行时池数量,增加池大小可能会在高并发场景中提高性能,但也会增加内存使用量
app.RootCmd.PersistentFlags().IntVar(
&hooksPool,
"hooksPool",
25,
"the total prewarm goja.Runtime instances for the JS app hooks execution",
)

var migrationsDir string // 数据库迁移目录
app.RootCmd.PersistentFlags().StringVar(
&migrationsDir,
"migrationsDir",
"",
"the directory with the user defined migrations",
)

var automigrate bool // 时候启用数据库自动迁移功能
app.RootCmd.PersistentFlags().BoolVar(
&automigrate,
"automigrate",
true,
"enable/disable auto migrations",
)

var publicDir string // 静态文件存放目录
app.RootCmd.PersistentFlags().StringVar(
&publicDir,
"publicDir",
defaultPublicDir(),
"the directory to serve static files",
)

var indexFallback bool // 主要针对单页应用(SPA)程序路由启用 history 模式时的空路由回滚至 `index.html`
app.RootCmd.PersistentFlags().BoolVar(
&indexFallback,
"indexFallback",
true,
"fallback the request to index.html on missing static path (eg. when pretty urls are used with SPA)",
)

var queryTimeout int // 数据库查询超时时间
app.RootCmd.PersistentFlags().IntVar(
&queryTimeout,
"queryTimeout",
30,
"the default SELECT queries timeout in seconds",
)

app.RootCmd.ParseFlags(os.Args[1:])

// ---------------------------------------------------------------
// 插件和钩子
// ---------------------------------------------------------------

// 加载 jsvm (js 虚拟机)
jsvm.MustRegister(app, jsvm.Config{
MigrationsDir: migrationsDir,
HooksDir: hooksDir,
HooksWatch: hooksWatch,
HooksPoolSize: hooksPool,
})

// 注册数据库迁移指令
migratecmd.MustRegister(app, app.RootCmd, migratecmd.Config{
TemplateLang: migratecmd.TemplateLangJS,
Automigrate: automigrate,
Dir: migrationsDir,
})

// 从 github 检查更新 PocketBase 版本
ghupdate.MustRegister(app, app.RootCmd, ghupdate.Config{})

app.OnAfterBootstrap().PreAdd(func(e *core.BootstrapEvent) error {
app.Dao().ModelQueryTimeout = time.Duration(queryTimeout) * time.Second
return nil
})

app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
// 部署静态资源
e.Router.GET("/*", apis.StaticDirectoryHandler(os.DirFS(publicDir), indexFallback))
return nil
})

if err := app.Start(); err != nil {
log.Fatal(err)
}
}

// 设置默认 `pb_public` 目录相对于可执行文件的位置
func defaultPublicDir() string {
if strings.HasPrefix(os.Args[0], os.TempDir()) {
// most likely ran with go run
return "./pb_public"
}

return filepath.Join(os.Args[0], "../pb_public")
}

编辑完 main.go 后,开始打包构建应用

1
2
3
go mod tidy # 清理/安装依赖
go run . serve # 直接运行程序
go build # 打包构建可执行文件

运行程序,执行 ./custom_pocketbase serve ,步骤与 PocketBase 安装配置一样。

PocketBase 的 6 个应用级事件钩子

OnBeforeBootstrap

OnBeforeBootstrap 钩子在主应用程序资源(如:数据库连接、初始设置加载)初始化之前触发。

  • 适合 执行一些数据库连接前的配置,或者加载一些全局的、不依赖于网络请求的配置。
  • 不适合 执行耗时过长或可能阻塞应用程序启动的操作。

OnAfterBootstrap

OnAfterBootstrap 钩子在主应用程序资源(如:数据库连接、初始设置加载)初始化之后触发。

OnBeforeServe

OnBeforeServe 钩子在内部路由器(echo)开始提供服务之前触发。

  • 适合 调整 echo 选项、附加新路由或者路由中间件,这是定义自定义 API 端点或修改现有请求处理流程的理想时机。

OnBeforeApiError

OnBeforeApiError 钩子在向客户端发送错误 API 响应之前触发,允许你进一步修改错误数据或返回一个完全不同的 API 响应。

OnAfterApiError

OnAfterApiError 钩子在向客户端发送错误 API 响应之后立即触发。它可用于将最终的 API 错误记录到外部服务中。

OnTerminate

OnTerminate 钩子在应用程序终止过程中触发(例如,收到 SIGTERM 信号时)。

  • 适合 执行清理操作,如关闭文件句柄、释放资源或保存临时状态。
  • 不适合 需要保证完成或需要大量处理时间的操作,因为应用程序可能会在不等待钩子完成的情况下突然终止。

基于 JavaScript 扩展 PocketBase

要使用 JavaScript 扩展 PocketBase,需要先有一个 PocketBase 可执行文件,可以从 Github Release 下载,也可以按照上面 基于 Go 扩展 PocketBase 自定义后(也许你就不需要 JS 了:p)构建一个可执行文件。

在 PocketBase 可执行文件旁边创建 pb_hooks 目录,在 pb_hooks 目录内创建 *.pb.js 文件,如:

pb_hooks/main.pb.js

1
2
3
4
5
6
7
8
9
routerAdd("GET", "/hello/:name", (c) => {
let name = c.pathParam("name")

return c.json(200, { "message": "Hello " + name })
})

onModelAfterUpdate((e) => {
console.log("user updated...", e.model.get("email"))
}, "users")

运行程序,执行:./pocketbase serve

对于大部分功能,JavaScript API 源自 Go API,但存在两个主要区别:

  1. 命名约定转换:Go 中导出的方法、字段名称改称半驼峰形式。如:app.Dao().FindRecordById("example", "RECORD_ID") 变成 $app.dao().findRecordById("example", "RECORD_ID")
  2. 异常处理机制:异常在 JavaScript 中会作为常规的 JavaScript 异常被抛出,而不是像 Go 那样作为返回值返回。

全局对象

一些常用的全局对象:

  • __hooks:应用 pb_hooks 的绝对路径。
  • $app: 当前运行的 PocketBase 应用实例。
  • $apis.*:API 路由帮助函数和中间件。
  • $os.*:系统层级的操作(如:删除目录、执行 shell 命令等)。
  • $security.*: 底层的帮助函数,用于创建解析 JTW、生成随机数、AES 加密等。
  • 更多:JSVM 文档

TypeScript 类型声明与代码补全

PocketBase 会在运行目录下的 pb_data 目录下生成 types.d.ts 文件,可在支持 TypeScript LSP 的编辑器/IDE 中指向该类型声明文件来获取相关的文档提示和代码补全。

1
2
3
4
5
/// <reference path="../pb_data/types.d.ts" />

onAfterBootstrap((e) => {
console.log("App initialized!")
})

注意事项与限制

处理器作用域

每个处理器函数(钩子、路由、中间件等)都被序列化并在其 独立的上下文 中作为一个单独的“程序”执行。这意味着你无法访问在处理器作用域之外声明的自定义变量和函数。例如,以下代码不会打印出 test

1
2
3
4
5
const name = "test"

onAfterBootstrap((e) => {
console.log(name) // <-- 在这个处理器中,`name` 将会变成 `undefined`。
})

解决方案可以通过模块化来解决,当前只支持 CJS 模块化,ESM 模块化方式需用过编译转化后才可用。

pb_hooks/utils.js

1
2
3
4
5
module.exports = {
hello: (name) => {
console.log("Hello " + name)
}
}

pb_hooks/main.pb.js

1
2
3
4
5
onAfterBootstrap((e) => {
const utils = require(`${__hooks}/utils.js`)

utils.hello("world")
})

模块载入

可以通过指定具体的模块文件路径或者模块名来加载模块,搜索模块的规则如下:

  • 在当前工作目录下搜索
  • 在任意的 node_modules 目录下搜索
  • 最近的 package.json 文件的父级目录下的 node_modules 目录