Payload on Workers:完全运行在Cloudflare技术栈上的全功能CMS
隐藏在无数网站管理员登录界面背后的是互联网的无名英雄:内容管理系统(CMS)。这个看似基础的软件用于起草和发布博客文章、组织媒体资源、管理用户档案,并在各种令人眼花缭乱的使用场景中执行无数其他任务。在这个类别中,一个突出的项目是名为Payload的活跃开源项目,它在GitHub上拥有超过35,000颗星,最近被Figma收购。
今天,我们很高兴展示Payload团队的新模板,只需单击一次即可将全功能CMS部署到Cloudflare的平台:只需单击“Deploy to Cloudflare”按钮即可生成完全配置的Payload实例,包括与Cloudflare D1和R2的绑定。
幕后:Cloudflare TV的Payload实例
无服务器设计
大多数CMS设计为在传统服务器上24/7运行。这意味着您需要配置硬件或虚拟机、安装CMS软件和依赖项、管理端口和防火墙,并应对持续的维护和扩展挑战。
这带来了显著的操作开销,如果您的服务器需要处理高流量(或峰值流量),成本可能会很高。更糟糕的是,无论您是否有活跃用户,您都需要为服务器付费。
Cloudflare Workers的超能力之一是您的应用程序和数据可以24/7访问,而无需服务器一直运行。当人们使用您的应用程序时,它会在最近的Cloudflare服务器上启动。当您的用户睡觉时,Worker会关闭,您无需为未使用的计算付费。
在Workers上运行Payload,您将获得传统CMS的所有优势——完全可配置的资源管理、自定义webhook、社区插件库、版本历史——所有这些都以无服务器形式提供。我们一直在我们的24/7视频平台Cloudflare TV上试用Payload-on-Workers模板,将其作为新技术的测试平台。
数据库集成
对于我们的初始方法,我们开始使用官方的@payloadcms/db-postgres适配器将Payload连接到外部Postgres数据库。由于Workers支持node-postgres包,一切基本上都能直接工作。由于连接无法在请求之间共享,我们只需禁用连接池:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
import { buildConfig } from 'payload'
import { postgresAdapter } from '@payloadcms/db-postgres'
export default buildConfig({
...
db: postgresAdapter({
pool: {
connectionString: process.env.DATABASE_URI,
max: 1,
min: 0,
idleTimeoutMillis: 1,
},
}),
...
});
|
当然,禁用连接池会增加整体延迟,因为每个请求都需要首先与数据库建立新连接。为了解决这个问题,我们在其前面放置了Hyperdrive,它不仅通过在Cloudflare网络中建立到数据库服务器的隧道来维护连接池,还添加了查询缓存,显著提高了性能。
使用D1的数据库
Postgres工作后,我们接下来寻求添加对D1的支持,这是Cloudflare基于SQLite构建的托管无服务器数据库。
Payload不直接支持D1,但通过@payloadcms/db-sqlite适配器支持SQLite,该适配器使用Drizzle ORM和libSQL。幸运的是,Drizzle也支持D1,因此我们决定基于SQLite适配器为D1构建自定义适配器。
D1和libSQL之间的主要区别在于结果对象,因此我们构建了一个小方法将D1的结果映射到libSQL期望的格式:
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
|
export const execute: Execute<any> = function execute({ db, drizzle, raw, sql: statement }) {
const executeFrom = (db ?? drizzle)!
const mapToLibSql = (query: SQLiteRaw<D1Result<unknown>>) => {
const execute = query.execute
query.execute = async () => {
const result: D1Result = await execute()
const resultLibSQL: Omit<ResultSet, 'toJSON'> = {
columns: undefined,
columnTypes: undefined,
lastInsertRowid: BigInt(result.meta.last_row_id),
rows: result.results as any[],
rowsAffected: result.meta.rows_written,
}
return Object.assign(result, resultLibSQL)
}
return query
}
if (raw) {
const result = mapToLibSql(executeFrom.run(sql.raw(raw)))
return result
} else {
const result = mapToLibSql(executeFrom.run(statement!))
return result
}
}
|
除此之外,只需将D1绑定直接传递到Drizzle的构造函数中即可使其工作。
使用R2的媒体存储
Payload通过@payloadcms/storage-s3包提供官方的S3存储适配器。R2与S3兼容,这意味着我们可以使用官方适配器,但类似于数据库,我们希望使用R2绑定而不是创建API令牌。
因此,我们决定也为R2构建自定义存储适配器。这个相对简单,因为绑定已经处理了大部分工作:
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
|
import type { Adapter } from '@payloadcms/plugin-cloud-storage/types'
import path from 'path'
const isMiniflare = process.env.NODE_ENV === 'development';
export const r2Storage: (bucket: R2Bucket) => Adapter = (bucket) => ({ prefix = '' }) => {
const key = (filename: string) => path.posix.join(prefix, filename)
return {
name: 'r2',
handleDelete: ({ filename }) => bucket.delete(key(filename)),
handleUpload: async ({ file }) => {
const buffer = isMiniflare ? new Blob([file.buffer]) : file.buffer
await bucket.put(key(file.filename), buffer)
},
staticHandler: async (req, { params }) => {
// 我们无法将Headers实例发送到Miniflare
const obj = await bucket?.get(key(params.filename), { range: isMiniflare ? undefined : req.headers })
if (obj?.body == undefined) return new Response(null, { status: 404 })
const headers = new Headers()
if (!isMiniflare) obj.writeHttpMetadata(headers)
return obj.etag === (req.headers.get('etag') || req.headers.get('if-none-match'))
? new Response(null, { headers, status: 304 })
: new Response(obj.body, { headers, status: 200 })
},
}
}
|
部署
有了数据库和存储适配器,我们能够成功启动Payload实例,完全运行在Cloudflare的开发者平台上。
空白模板包含一个简单的数据库,只有两个表,一个用于媒体,另一个用于用户。在此模板中,可以注册、创建新用户和上传媒体文件。然后,通过修改Payload的配置,可以轻松扩展额外的集合、关系和自定义字段。
使用读取副本的性能优化
默认情况下,D1放置在单个位置,可通过位置提示进行自定义。由于Payload作为Worker部署,请求可能来自世界任何地方,因此在连接到数据库时延迟会各不相同。
为了解决这个问题,我们可以利用D1的全局读取复制,它在全球部署多个只读副本。为了选择正确的副本并确保顺序一致性,D1使用会话,需要传递一个书签。
Drizzle尚不支持D1会话,但我们仍然可以使用"first-primary"类型的会话,其中第一个查询将始终命中主实例,后续查询可能命中其中一个副本。更新适配器以使用副本只需更新Drizzle初始化以直接传递D1会话:
1
2
|
this.drizzle = drizzle(this.binding.withSession("first-primary"),
{ logger, schema: this.schema });
|
经过这个简单的更改,我们立即看到了延迟改进,当连接到位于北美东部的数据库时,来自全球请求的P50 wall-time减少了60%。读取副本,顾名思义,仅影响只读查询,因此任何写入操作将始终转发到主实例,但对于我们的使用场景,读取占大部分流量。
指标 |
无读取副本 |
启用读取副本 |
改进 |
P50 |
300ms |
120ms |
-60% |
P90 |
480ms |
250ms |
-48% |
P99 |
760ms |
550ms |
-28% |
由于我们将依赖Payload来管理Cloudflare TV庞大的内容库,我们处于很好的位置来大规模测试它,并将继续提交包含优化和改进的PR。