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的有效时长
  • InitApihttp 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启动前。
nametyperemark
fileVARCHAR(255)脚本名,1640988000_init.go
appliedINTEGER执行时间

_admins

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

nametyperemark
idTEXT唯一id
avatarINTEGER0
emailTEXTadmin@xxx.com
tokenKeyTEXTxB5k2B9VNhcszD4KWfsZ
passwordHashTEXTxB5k2B9VNhcszD4KWfsZ
lastResetSentAtTEXT 
createdTEXT 
updatedTEXT 

_collections

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

nametyperemark
idTEXT唯一id。 6b934vm328xcfbb
systemBOOLEAN0。 system 表不允许修改name等字段
typeTEXTauth、base
nameTEXT对应的db name
schemaJSON字段数据,不包括系统字段。[{"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}}]
indexesJSON索引
listRuleTEXTlist规则
viewRuleTEXTview规则
createRuleTEXTcreate规则,@request.auth.id != "" && @request.auth.id = http://er.id/ && text != ""
updateRuleTEXTupdate规则,@request.auth.id = http://er.id/
deleteRuleTEXTdelete规则,@request.auth.id = http://er.id/
optionsJSON属性字段,{"allowEmailAuth":true,"allowOAuth2Auth":true,"allowUsernameAuth":true,"exceptEmailDomains":null,"manageRule":null,"minPasswordLength":8,"onlyEmailDomains":null,"requireEmail":false}
createdTEXT 
updatedTEXT 

_params

  • key: settings:value为存储json序列化后的setting对象(各种鉴权相关的秘钥)
nametyperemark
idTEXT唯一id
keyTEXTlike ‘settings’
valueJSONlike ‘{"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
createdTEXT 
updatedTEXT 

_externalAuths

nametype
idTEXT
collectionIdTEXT
recordIdTEXT
providerTEXT
providerIdTEXT
createdTEXT
updatedTEXT

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

user

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

nametype
avatarTEXT
createdTEXT
emailTEXT
emailVisibilityBOOLEAN
idTEXT
lastResetSentAtTEXT
lastVerificationSentAtTEXT
nameTEXT
passwordHashTEXT
tokenKeyTEXT
updatedTEXT
usernameTEXT
verifiedBOOLEAN

日志DB

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

_requests

访问日志

nametyperemark
idTEXTxedcw4qe8v3qitr
urlTEXT/api/collections/pb_users_auth/records?page=1&perPage=1&filter=&fields=id
methodTEXTGET
statusINTEGER200
authTEXTadmin
remoteIpTEXT127.0.0.1
refererTEXThttp://127.0.0.1:8090/_/
userAgentTEXTMozilla/5.0
metaJSON{}
createdTEXT 
updatedTEXT 
userIpTEXT127.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