pocketbase / pocketbase 源码分析

功能介绍

pocketbase 是一个开源的开箱即用的后端服务(库),使用它可以快速搭建一个典型的后台服务,支持简单的CRUD操作,同时也支持权限控制、关联查询、插件化等特性,包括:

CRUD基础能力

  • 大量后台服务的要求就是基于DB的CRUD操作,比如创建一个自定义字段的表格,以及对应的增删改啥。pocketbase提供了一个管理台页面支持快速创建数据表(collection),指定字段名称及字段类型等。后台使用sqlite作为数据engine。
  • 业务方可以通过RESTFUL接口(可以理解为普通用户)实现基础的CRUD操作,当然也可以直接在管理台配置(admin用户)。
  • 权限校验:创建、更新、删除、查询等可以灵活配置规则,比如要求删除记录的用户必须与记录中的用户id保持一致、查询数据(ListRule)时当前用户必须已登录或者属于某个集合等。此功能可以实现比较精细化的权限控制,保护数据安全。

用户管理

  • 除数据表外,还支持用户表的创建。具有同一批用户权限的用户可以存储在同一个用户表中,同一个表中的用户具有类似的权限以及认证方式。
  • 支持通过名称/mail+密码方式认证:用于获取api访问的token。需要admin用户先配置好用户信息。
  • 支持oauth方式认证:提供了各种auth-providers,业务方可以基于pocketbase快速封装实现第三方授权。注意pocketbase没有提供统一的client_id、client_secret等,需要业务方自行申请。

其他

  • 提供了十一类hook,供业务方灵活干预提供了入口。比如对auth、CRUD操作,在实际执行前后都可以针对结果进行干预。
  • 支持数据备份&恢复
  • 支持日志持久化
  • web管理台等等

源码分析

migrate命令

PocketBase自带内置数据库和数据迁移工具,使您能够版本控制数据库结构,以编程方式创建集合,初始化默认设置以及/或运行任何只需要执行一次的操作。

bootstrap

引导阶段,bootstrap完成之后才会进入subcommand的逻辑,比如web server启动。

  • 执行hook :onBeforeBootstrap *hook.Hook[*BootstrapEvent]
  • 引导操作执行,包括db、日志db连接初始化、数据目录创建等。
    • DB相关的触发器,在createDaoWithHooks中初始化,执行DB相关操作时执行
    • 存在两个db链接,一个用于并发,一个用于非并发
    • 日志与数据使用不同的数据,也会占用两个链接。
  • 执行hook :onAfterBootstrap *hook.Hook[*BootstrapEvent]

api server启动

  • migration操作:包括数据、日志表创建、写入初始记录
  • 加载app setting:之前的配置会在 _params 中存储一份,加载合并当前默认配置(不直接使用db配置是基于可能有新增配置考虑)。setting中的内容主要包括两类:
    • meta配置:包括
      • Application(name、url)
      • Mail settings(邮箱地址、邮件模板)
      • Files storage(S3或本地文件)
      • 备份配置(s3配置、调度周期等),数据备份都考虑到功能里了,不得不说功能很齐全。
    • 鉴权配置:
      • AuthProvider:OAuth2相关的providers配置,各类第三方配置略有差异
      • Token options:各类token的有效时长
  • InitApi:http server初始化,创建echo server实例,注册system、app路由以及中间件
    • 替换了echo的解析解析器JSONSerializer,使用go-json做了一层封装。应该是嫌默认的json效率问题,感觉过度设计了。系统瓶颈应该不在echo server的json解析吧。
    • 路由基础配置:定制url解析策略等
    • 加载Pre中间件:LoadAuthContext,JWT鉴权逻辑处理
      • 解析header中的Authorization,判断用户身份
      • admin:校验token是否过期,根据id查找admin对应记录。有效则写入context供后续API使用。key:admin, value:models.Admin记录
      • authRecord:对auth collection类型的表的鉴权方式。有效则写入context后后续API使用,key:authRecord,value:models.Record(collectionid对应的auth表)记录。根据collectionid、recordid查找到的对应auth表中的记录
    • 加载中间件:middleware中的Recover、Secure,处理异常、安全类问题。
    • 自定义异常处理函数echo.HTTPErrorHandler
      • 执行hook :onBeforeApiError *hook.Hook[*ApiErrorEvent]。可以指定错误返回格式及内容。
      • 返回标准错误格式:code、apiError。如果在之前的hook中已经返回过了则跳过。
      • 执行hook :OnAfterApiError() *hook.Hook[*ApiErrorEvent]。可用于记录错误日志等。
    • 添加静态资源UI路由:ui/dist 目录
    • API路由分组,统一增加RequestInfo处理?
    • API下增加子分组路由,包括settings、admins、collections、“/collections/:collection”、auth相关、files、realtime、logs、health、backups。分析API功能时详细介绍
  • 启用cors中间件
  • 域名处理,包括证书解析(如果有)
  • 执行hook :onBeforeServe *hook.Hook[*ServeEvent]
  • 注册hook:注册http.shutdown到onTerminate,退出时http需要gracefully shutdown。
  • 调用httpserver.ListenAndServe(),分别启动http和https server(如果有)。

功能API

/settings group:

  • group中间件:ActivityLogger(记录日志表 requests )、RequireAdminAuth(检查context是否存在鉴权key——admin)
  • GET(“”, api.list) :将setting中配置脱敏后给出(仅页面展示使用),并执行hook :OnSettingsListRequest
  • PATCH(“”, api.set) :设置setting并更新到DB _params 持久化。执行 update相关的hooks :OnSettingsBeforeUpdateRequest、OnSettingsAfterUpdateRequest
  • POST(“/test/s3”, api.testS3) :验证setting中s3相关配置(file storage、backups)可用性。
  • POST(“/test/email”, api.testEmail) :验证setting中邮箱可用性。并指向相关hooks
  • POST(“/apple/generate-client-secret”, api.generateAppleClientSecret) :验证apple授权相关参数可用性(clientId、teamId、keyId、privateKey、duration)

/admins group

  • group中间件:ActivityLogger(记录日志表 requests ),只有部分接口需要Admin鉴权(JWT token)
  • POST(“/auth-with-password”, api.authWithPassword) :管理员登录。检查 _admins 是否存在匹配的记录,并执行相关hooks:OnAdminXXXAuthWithPasswordRequest。
    • 返回:admin JWT Token、脱敏admin身份信息
  • POST(“/request-password-reset”, api.requestPasswordReset) :请求admin密码重置。发送相关邮件给到admin用户确认。并执行相关hooks:OnAdminXXXRequestPasswordResetRequest
    • 安全措施:此处无token鉴权,但是会有发送频控,避免恶意请求影响;url中带有token信息,避免伪造
    • URL组成:host + /_/#/confirm-password-reset/ + JWT token,token中包含 id、type、email以以及生成token信息(setting.AdminPasswordResetToken)
    • 更新 lastResetSentAt ,目的是?
    • 发送邮件使用的系统工具sendmail
  • POST(“/confirm-password-reset”, api.confirmPasswordReset) :对应上面的改密执行,应该是用户收到页面后打开页面、输入新密码会发起调用。
  • POST(“/auth-refresh”, api.authRefresh, RequireAdminAuth()) :admin JWT token票据刷新,验证老票据返回新票据跟登录返回结构一致。
  • GET(“”, api.list, RequireAdminAuth()) :拉取列表
  • POST(“”, api.create, RequireAdminAuthOnlyIfAny(app)) :创建admin账号。首次创建不需要admin token
  • GET(“/:id”, api.view, RequireAdminAuth()) :根据id查询admin model信息
  • PATCH(“/:id”, api.update, RequireAdminAuth()) :UI改密码请求
  • DELETE(“/:id”, api.delete, RequireAdminAuth()) :删除admin,只有一个admin账号时不允许删除

/collections group

  • group中间件:ActivityLogger(记录日志表 requests )、RequireAdminAuth(检查context是否存在鉴权key——admin)
  • GET(“”, api.list) :根据查询条件筛选_collections 表,返回对应记录。仅限于基础字段:”id”, “created”, “updated”, “name”, “system”, “type”
  • POST(“”, api.create) :写入 _collections 表,同时创建对应的collection记录表以及索引。
-- 创建blog collection对应的sql操作:
INSERT INTO `_collections` (`createRule`, `created`, `deleteRule`, `id`, `indexes`, `listRule`,
`name`, `options`, `schema`, `system`, `type`, `updateRule`, `updated`, `viewRule`) VALUES
('@request.auth.id != "" && @request.auth.id = author.id && content != ""', '2023-10-23 03:23:41.925Z',
'@request.auth.id = author.id', 'jpwc2dgtpo6vkzn', '[]', <nil>, 'blog', '{}',
'[{"system":false,"id":"46c15g7j","name":"content","type":"text","required":false,"presentable":false,"unique":false,"options":{"min":null,"max":null,"pattern":""}},{"system":false,"id":"nmjc7pn8","name":"visited","type":"number","required":false,"presentable":false,"unique":false,"options":{"min":null,"max":null,"noDecimal":false}},{"system":false,"id":"28qca65i","name":"author","type":"relation","required":false,"presentable":false,"unique":false,"options":{"collectionId":"_pb_users_auth_","cascadeDelete":false,"minSelect":null,"maxSelect":1,"displayFields":null}}]',
false, 'base', '@request.auth.id = author.id', '2023-10-23 03:23:41.925Z', <nil>);

CREATE TABLE `blog` (`author` TEXT DEFAULT '' NOT NULL, `content` TEXT DEFAULT '' NOT NULL,
`created` TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL,
`id` TEXT PRIMARY KEY DEFAULT ('r'||lower(hex(randomblob(7)))) NOT NULL,
`updated` TEXT DEFAULT (strftime('%Y-%m-%d %H:%M:%fZ')) NOT NULL,
`visited` NUMERIC DEFAULT 0 NOT NULL)
  • GET(“/:collection”, api.view) :返回collection记录详情,包括各collection的schema、rule等信息。export collection时使用。
  • PATCH(“/:collection”, api.update) :更新,与create逻辑基本一致。
  • DELETE(“/:collection”, api.delete) :删除。system表、与其他存在relation field关系时不能删除(relation field在collection.schema中有关联id)。
  • PUT(“/import”, api.bulkImport) :import collection

/collections/:collection

CRUD action, collection相关的CRUD操作

  • group中间件:ActivityLogger(记录日志表 requests )、LoadCollectionContext(校验collection有效并写入context供子路由处理)
  • GET(“/records”, api.list, LoadCollectionContext(app)): 按输入sql filter拉取collection数据。
    • 校验输入规则合法性:部分filter规则仅root可用。admins are allowed to query everything
    • 如果ListRule为空,只允许admin用户list
    • 如果ListRule非空,根据ListRule执行过滤、查询逻辑。现网可以设置需要登录即可访问:@request.auth.id != “”
    • 规则验证的实现细节没有细看。
// 设置ListRule为需要验证登录态(@request.auth.id != "" )后使用**TypeAuthRecord类型的票据访问:**
// curl -H "Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8iLCJleHAiOjE2OTkyNTMyNzksImlkIjoiaDM1MDg5c3V1emE2M3F2IiwidHlwZSI6ImF1dGhSZWNvcmQifQ.YPPHpcqgC1FQ7Dg02JJiWyLOxYddLmX4VcVnCYpYjgc" "http://9.134.79.104:8080/api/collections/jpwc2dgtpo6vkzn/records"
{
"page": 1,
"perPage": 30,
"totalItems": 1,
"totalPages": 1,
"items": [
{
"author": "h35089suuza63qv",
"collectionId": "jpwc2dgtpo6vkzn",
"collectionName": "blog",
"content": "content preparing",
"created": "2023-10-23 03:30:12.380Z",
"id": "kb4bz73f1oolzs3",
"updated": "2023-10-23 03:30:12.380Z",
"visited": 10
}
]
}
  • GET(“/records/:id”, api.view, LoadCollectionContext(app)) :
  • POST(“/records”, api.create, LoadCollectionContext(app, models.CollectionTypeBase, models.CollectionTypeAuth)): insert
    • 生成requestInfo,用于规则校验
    • 如果CreateRule为空,只允许admin用户写入
    • 非admin用户,校验CreateRule规则
      • 解析request数据,用于规则匹配时的数据提取
      • 根据CreateRule构造expr,使用了github.com/ganigeorgiev/fexpr生成AST
      • 规则验证:实现太绕,没细看。初步看是使用了基于dbx的封装来构造filter后实现。实现有点复杂,感觉可以使用 expr 之类比较通用成熟的方案扩展。
    • 插入数据以及相关触发器逻辑

/collections/:collection auth record action

collection相关的鉴权操作,所以此处的collection仅针对type为Auth的collection,其他type会被拦截 。

  • 功能:auth类型的collection是用来记录API访问用户以及认证后的票据(类型为TypeAuthRecord)分发的。每个collection表下的用户认证方式可以按需指定,支持三类:
    • usernamePassword、emailPassword:根据用户名称/email + 密码的方式验证
    • authProviders:Auth providers下全部已开启的认证方式。
  • 关于TypeAuthRecord票据组成:认证通过会下发票据信息,票据由几个核心字段加密而成:auth表信息 + 用户(记录)信息 + 系统设置(系统秘钥、生效时长)
// NewRecordAuthToken generates and returns a new auth record authentication token.
func NewRecordAuthToken(app core.App, record *models.Record) (string, error) {
if !record.Collection().IsAuth() {
return "", errors.New("The record is not from an auth collection.")
}

return security.NewJWT(
jwt.MapClaims{
"id": record.Id, // auth表中对应记录id(及用户信息的id)
"type": TypeAuthRecord, // 票据类型
"collectionId": record.Collection().Id, // auth表id
},
(record.TokenKey() + app.Settings().RecordAuthToken.Secret), // 秘钥由两部分构成:用户秘钥+系统秘钥
app.Settings().RecordAuthToken.Duration, // 票据时长
)
}
  • group中间件:ActivityLogger(记录日志表 requests )、LoadCollectionContext(校验collection有效并写入context供子路由处理)
  • GET(“/auth-methods”, api.authMethods) :下发collection支持的auth方式以及配置。oauth provider会下发oauth认证的跳转地址(基于golang.org/x/oauth2 实现)
{
"usernamePassword": true,
"emailPassword": true,
"authProviders": [
{
"name": "github",
"state": "u5dEfS7wMgAnE3bX0LqYbVgrpreRNa",
"codeVerifier": "BRTh0BmOy4kt1ChR1iuq0yZJVoxxFYNGjywfSThmHQ6",
"codeChallenge": "MtlPH_gmXT1rh-wxPO8LTuMqsqgsHJiYZRJj06XC9ck",
"codeChallengeMethod": "S256",
"authUrl": "https://github.com/login/oauth/authorize?client_id=xxx\u0026code_challenge=MtlPH_gmXT1rh-wxPO8LTuMqsqgsHJiYZRJj06XC9ck\u0026code_challenge_method=S256\u0026response_type=code\u0026scope=read%3Auser+user%3Aemail\u0026state=u5dEfS7wMgAnE3bX0LqYbVgrpreRNa\u0026redirect_uri="
}
]
}
  • POST(“/auth-refresh”, api.authRefresh, RequireSameContextRecordAuth()) :票据刷新,即使用当前票据中的TypeAuthRecord信息重新生成一份新的票据。

    • ps:会校验票据中的auth collection与当前url中collection是否一致。保持权限一致。
  • POST(“/auth-with-oauth2”, api.authWithOAuth2) :oauth授权,使用第三方code换取pocketbase的token。

    • oauth流程

    oauth

    1. 应用页面需要提供一个第三方登录页面,供用户选择需要的第三方登录方式以及对应的授权地址。 auth-methods 接口会下发已经开启了的第三方认证方式以及跳转地址,页面仅需要UI展示即可。实现可以参考官方文档
    2. 用户点击某第三方授权,会跳转到对应的第三方认证服务器授权页面。同时链接中会携带授权同意的redirect_url
    3. 用户点击同意,授权成功,第三方认证服务器302重定向到应用服务器的redirect_url页面,同时会携带第三方授权的code,供服务器换取票据(校验合法性)
    4. 应用页面实现第三方redirect_url页面,调用authWithOauth2接口将code换取为pocketbase的票据,然后继续完成其他业务逻辑(比如拉取数据、跳转其他页面等)。
    5. authWithOauth2接口中会完成将第三方认证服务器的code换取access_token、拉取用户信息等逻辑
// fetch token
token, err := provider.FetchToken(
form.Code,
oauth2.SetAuthURLParam("code_verifier", form.CodeVerifier),
)
if err != nil {
return nil, nil, err
}

// fetch external auth user
authUser, err := provider.FetchAuthUser(token)
if err != nil {
return nil, nil, err
}
  • POST(“/auth-with-password”, api.authWithPassword) :校验collection中的用户identify(可以为username或者email之一)以及pwd,通过则下发用户信息以及类型为TypeAuthRecord的JWT token,用于后续的record访问。
{
"record": {
"avatar": "",
"collectionId": "_pb_users_auth_",
"collectionName": "users",
"created": "2023-10-23 03:04:51.408Z",
"email": "",
"emailVisibility": false,
"id": "h35089suuza63qv",
"name": "anonyName",
"updated": "2023-10-23 06:06:03.434Z",
"username": "anony",
"verified": false
},
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8iLCJleHAiOjE2OTkyNTA3NjcsImlkIjoiaDM1MDg5c3V1emE2M3F2IiwidHlwZSI6ImF1dGhSZWNvcmQifQ.hu30XhgOEpl8XH40YWAlkBLoCEwJ8_AdOhh_bZm7Rd8"
}

只分析了核心的几类API的核心接口,其他API功能比较多,就不一一分析了。

DB结构

分为数据DB和日志DB两个数据库。

数据DB

_migrations

迁移记录表

  • 一些前置的建表、数据初始化操作执行完毕之后会写入migration记录(包括文件名、执行时间),避免重复执行。
  • 其他表都是通过migration操作建立的。启动时migration/目录以及log子目录下的go代码都会被执行一遍,里面包含了各个表的初始化(up)、销毁(down)操作相关的sql。每份文件中都有一个init操作,将对应up/down注册到AppMigrations list中。等待runMigrations()统一调度顺序执行。
  • runMigrations()的具体执行时机为api.Serve中,即为http server启动前。
name type remark
file VARCHAR(255) 脚本名,1640988000_init.go
applied INTEGER 执行时间

_admins

存储system_setting中的管理员记录,允许多条。

name type remark
id TEXT 唯一id
avatar INTEGER 0
email TEXT admin@xxx.com
tokenKey TEXT xB5k2B9VNhcszD4KWfsZ
passwordHash TEXT xB5k2B9VNhcszD4KWfsZ
lastResetSentAt TEXT
created TEXT
updated TEXT

_collections

collection描述表,每个collection都会有一条记录以及对应的表

name type remark
id TEXT 唯一id。 6b934vm328xcfbb
system BOOLEAN 0。 system 表不允许修改name等字段
type TEXT auth、base
name TEXT 对应的db name
schema JSON 字段数据,不包括系统字段。[{“system”:false,”id”:”users_name”,”name”:”name”,”type”:”text”,”required”:false,”presentable”:false,”unique”:false,”options”:{“min”:null,”max”:null,”pattern”:””}},{“system”:false,”id”:”users_avatar”,”name”:”avatar”,”type”:”file”,”required”:false,”presentable”:false,”unique”:false,”options”:{“maxSelect”:1,”maxSize”:5242880,”mimeTypes”:[“image/jpeg”,”image/png”,”image/svg+xml”,”image/gif”,”image/webp”],”thumbs”:null,”protected”:false}}]
indexes JSON 索引
listRule TEXT list规则
viewRule TEXT view规则
createRule TEXT create规则,@request.auth.id != “” && @request.auth.id = http://er.id/ && text != “”
updateRule TEXT update规则,@request.auth.id = http://er.id/
deleteRule TEXT delete规则,@request.auth.id = http://er.id/
options JSON 属性字段,{“allowEmailAuth”:true,”allowOAuth2Auth”:true,”allowUsernameAuth”:true,”exceptEmailDomains”:null,”manageRule”:null,”minPasswordLength”:8,”onlyEmailDomains”:null,”requireEmail”:false}
created TEXT
updated TEXT

_params

  • key: settings:value为存储json序列化后的setting对象(各种鉴权相关的秘钥)
name type remark
id TEXT 唯一id
key TEXT like ‘settings’
value JSON like ‘{“meta”:{“appName”:”Acme”,”appUrl”:”http://localhost:8090%22,%22hideControls%22%3Afalse,%22senderName%22%3A%22Support%22,%22senderAddress%22%3A%22support@example.com/","verificationTemplate":{"body":"\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eThank you for joining us at {APP_NAME}.\u003c/p\u003e\n\u003cp\u003eClick on the button below to verify your email address.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class="btn" href="{’ xxxxxxx
created TEXT
updated TEXT

_externalAuths

name type
id TEXT
collectionId TEXT
recordId TEXT
provider TEXT
providerId TEXT
created TEXT
updated TEXT

每个collection都会新建一个表。以用户表 user 为例:

user

默认系统用户表(user collection),migration初始化时默认创建

name type
avatar TEXT
created TEXT
email TEXT
emailVisibility BOOLEAN
id TEXT
lastResetSentAt TEXT
lastVerificationSentAt TEXT
name TEXT
passwordHash TEXT
tokenKey TEXT
updated TEXT
username TEXT
verified BOOLEAN

日志DB

  • 也有对应的migration表,与数据DB中的表格式一致。以记录对应的初始化操作。用于记录日志目录(migration/log)下的脚本文件执行时间。

_requests

访问日志

name type remark
id TEXT xedcw4qe8v3qitr
url TEXT /api/collections/pb_users_auth/records?page=1&perPage=1&filter=&fields=id
method TEXT GET
status INTEGER 200
auth TEXT admin
remoteIp TEXT 127.0.0.1
referer TEXT http://127.0.0.1:8090/_/
userAgent TEXT Mozilla/5.0
meta JSON {}
created TEXT
updated TEXT
userIp TEXT 127.0.0.1

总结

优势

  • 代码功能完善,各种hooks等能灵活扩展。DB结构、数据也可以灵活变更,另外还有许多功能比如js支持、邮件、定时任务等等,代码量相当大,就没有一一细看了。
  • 安全性考虑很完善。DB存储(如_admin)不存储原始秘钥、API返回脱敏处理、cors支持等考虑比较完善。
  • 文档细致,相关API都有详细的介绍,也有js lib来支持API调用,降低了上手难度。

不足

  • 功能太大而全了,作者想什么功能都加上。感觉不如把一些核心需求解决好,比如支持其他db来横向拓展、优化性能等。
  • 作者应该是前端初审,风格上类似上很多js类似的写法,比如使用大量的callback来处理调用链。简单调用链就顺序执行就好了,代码阅读起来不用太绕。
  • 一些lib的选择上有优化的空间。比如validator,很老且不好用,应该使用其他更简化的方案代理,比如https://github.com/go-playground/validator

总之作为一款开箱即用的后台服务,还是可以满足很多场景的,考虑扩展性等个人不会考虑来当做现网的正式服务来使用,但是如果定位是用来快速搭建产品demo、实现开发的数据mock感觉还是挺合适的。毕竟不用写代码就能实现简单的CRUD还是非常方便的。另外对oauth授权的处理也有一定的参考价值,感觉可以进一步提取作为独立的lib来使用。