pocketbase / pocketbase 源码分析
October 23, 2023
功能介绍
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的有效时长
- meta配置:包括
- 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记录表以及索引。
Loading...
- 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 != ""
- 规则验证的实现细节没有细看。
Loading...
- 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
collection相关的鉴权操作,所以此处的collection仅针对type为Auth的collection,其他type会被拦截 。
- 功能:auth类型的collection是用来记录API访问用户以及认证后的票据(类型为TypeAuthRecord)分发的。每个collection表下的用户认证方式可以按需指定,支持三类:
- usernamePassword、emailPassword:根据用户名称/email + 密码的方式验证
- authProviders:Auth providers下全部已开启的认证方式。
- 关于TypeAuthRecord票据组成:认证通过会下发票据信息,票据由几个核心字段加密而成:auth表信息 + 用户(记录)信息 + 系统设置(系统秘钥、生效时长)
Loading...
- group中间件:ActivityLogger(记录日志表 requests )、LoadCollectionContext(校验collection有效并写入context供子路由处理)
- GET("/auth-methods", api.authMethods) :下发collection支持的auth方式以及配置。oauth provider会下发oauth认证的跳转地址(基于golang.org/x/oauth2 实现)
Loading...
- POST("/auth-refresh", api.authRefresh, RequireSameContextRecordAuth()) :票据刷新,即使用当前票据中的**TypeAuthRecord**信息重新生成一份新的票据。 ps:会校验票据中的auth collection与当前url中collection是否一致。保持权限一
- POST("/auth-with-oauth2", api.authWithOAuth2) :oauth授权,使用第三方code换取pocketbase的token。 oauth流程: 1. 应用页面需要提供一个第三方登录页面,供用户选择需要的第三方登录方式以及对应的授权地址。 **`auth-methods`** 接口会下发已经开启了的第三方认证方式以及跳转地址,页面仅需要UI展示即可。实现可以参考[官方文档](https://pocketbase.io/docs/authentication#oauth2-integration) 2. 用户点击某第三方授权,会跳转到对应的第三方认证服务器授权页面。同时链接中会携带授权同意的redirect_url 3. 用户点击同意,授权成功,第三方认证服务器302重定向到应用服务器的redirect_url页面,同时会携带第三方授权的code,供服务器换取票据(校验合法性) 4. 应用页面实现第三方redirect_url页面,调用authWithOauth2接口将code换取为pocketbase的票据,然后继续完成其他业务逻辑(比如拉取数据、跳转其他页面等)。 5. authWithOauth2接口中会完成将第三方认证服务器的code换取access_token、拉取用户信息等逻辑
Loading...
- POST("/auth-with-password", api.authWithPassword) :校验collection中的用户identify(可以为username或者email之一)以及pwd,通过则下发用户信息以及类型为TypeAuthRecord的JWT token,用于后续的record访问。
Loading...
只分析了核心的几类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 |
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","hideControls"%3Afalse,"senderName"%3A"Support","senderAddress"%3A"support@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 |
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来使用。
See all postsSee all posts