Serverless 框架本地开发实战:DynamoDB 离线支持示例(serverless-offline + serverless-dynamodb)
Serverless 框架本地开发实战:DynamoDB 离线支持示例(serverless-offline + serverless-dynamodb)
导读
本示例(aws-node-http-api-dynamodb-local)演示了如何在不部署到 AWS 的前提下,通过 serverless-offline 与 serverless-dynamodb 两个插件在本地完整运行一个基于 Lambda + API Gateway HTTP API + DynamoDB 的 Todo 管理服务:本地模拟 HTTP API 网关路由、本地启动 DynamoDB 实例、离线执行表结构迁移,并提供创建(Create)、列表(List)、查询(Get)、更新(Update)、删除(Delete)五类完整的 Todo 操作。读完本文,你将掌握 Serverless Framework v4 项目「本地零成本联调」的完整配置方法、IS_OFFLINE 环境变量驱动的双环境数据库连接模式,以及 Docker 与插件两种本地 DynamoDB 的启动方式。
示例概览:为什么需要「离线支持」
在常规的 Serverless 开发流程中,验证一段 Lambda 代码通常需要执行 serverless deploy 将整个服务部署到 AWS,才能通过真实的 API Gateway 端点调用函数。这既耗时,也增加了调试成本。本示例的目的(见 README.md)是:
Test your service locally, without having to deploy it first.(在本地测试服务,无需先行部署。)
它提供与云端版本 aws-node-http-api-dynamodb 几乎一致的 HTTP API 与 DynamoDB 存储语义,但所有组件都跑在本机:
serverless-offline:在本地模拟 API Gateway HTTP API 的路由分发,把serverless.yml中声明的httpApi事件映射到http://localhost:3000;serverless-dynamodb:提供本地 DynamoDB 实例。它是现已归档的serverless-dynamodb-local的维护分支(README 原文如此表述),支持自动下载 DynamoDB 本地 JAR、启动服务端、导入迁移文件。
两者配合,开发者可以像在生产环境一样调用 CRUD 接口,但所有请求都发生在 localhost,实现「秒级启动、零成本、可断点调试」的本地开发闭环。
项目结构与文件一览
aws-node-http-api-dynamodb-local/
├── dynamodb/ # Docker 版本地 DynamoDB 的持久化方案
│ ├── Dockerfile # 基于 amazon/dynamodb-local 镜像定制
│ └── docker-compose.yml # 一键启动容器,映射 8000 端口
├── offline/
│ └── migrations/
│ └── todos.json # 离线表结构迁移文件(导入 DynamoDB 建表)
├── todos/ # 每个 CRUD 操作一个独立文件
│ ├── create.js
│ ├── delete.js
│ ├── dynamodb.js # 数据库客户端封装(核心:离线/云端双模式)
│ ├── get.js
│ ├── list.js
│ └── update.js
├── package.json # ESM 项目,声明两个离线插件与 AWS SDK
├── serverless.yml # 服务配置:插件、函数、路由、DynamoDB 表
└── README.md
每个 todos/*.js 文件对应一个独立导出的 Lambda handler,职责单一:create 负责写入、list 负责扫描、get/update/delete 负责按 id 操作单条记录。这种「一个资源一个目录、一个操作一个文件」的组织方式,便于后续在同一服务中扩展 users、notes 等其他资源。
环境准备与本地安装
按 README.md 的 Setup 说明,安装依赖并启动本地环境需要以下步骤:
npm install
serverless dynamodb install (or to use a persistent docker dynamodb instead, open a new terminal: cd ./dynamodb && docker-compose up -d)
serverless offline start
serverless dynamodb migrate (this imports schema)
逐步解释如下:
npm install:安装运行时依赖与插件。从 package.json 可以看到,本项目为 ESM 模块("type": "module"),运行时依赖 AWS SDK 的@aws-sdk/client-dynamodb与@aws-sdk/lib-dynamodb(^3.700.0),开发依赖包括serverless-dynamodb(^0.2.58,即本地 DynamoDB 插件)与serverless-offline(^14.7.4)。serverless dynamodb install:下载并安装 DynamoDB 本地版(Local JAR)。如果希望改用 Docker 运行一个持久化的 DynamoDB,则跳过此步,另开终端执行cd ./dynamodb && docker-compose up -d(两种方式二选一,下文详解)。serverless offline start:启动本地 API 网关模拟器(默认监听http://localhost:3000)与本地 DynamoDB 服务端(默认监听http://localhost:8000)。serverless dynamodb migrate:导入offline/migrations目录下的 schema 文件完成建表,等价于离线环境下的serverless deploy建表动作。
提示:在 README 的命令顺序中,
serverless offline start会长期占用当前终端;如果你的 DynamoDB 走 Docker 方案,需要另开一个终端单独启动容器,这是文档明确标注的操作方式。
关键配置解析:serverless.yml
serverless.yml 是整个离线体系的核心,逐段拆解如下。
服务声明与插件注册
service: serverless-http-api-dynamodb-local
frameworkVersion: '4'
plugins:
- serverless-dynamodb
- serverless-offline
service名称同时参与 DynamoDB 表名的拼接;frameworkVersion: '4'表示本项目面向 Serverless Framework v4;plugins中先声明serverless-dynamodb、再声明serverless-offline,两个插件共同完成「本地数据库 + 本地网关」的组合。
custom.dynamodb:本地数据库行为控制
custom:
dynamodb:
stages:
- dev
start:
port: 8000
inMemory: true
migrate: true
# Comment if you don't have a DynamoDB running locally
noStart: true
migration:
dir: offline/migrations
| 配置项 | 取值 | 含义 |
|---|---|---|
stages |
- dev |
仅在 dev 阶段启用本地 DynamoDB 插件逻辑 |
start.port |
8000 |
本地 DynamoDB 服务端监听端口,与代码中的连接端点一致 |
start.inMemory |
true |
以内存模式启动,数据不落盘(适合快速测试) |
start.migrate |
true |
启动时自动执行迁移建表 |
start.noStart |
true |
若本机已有 DynamoDB 在运行(如 Docker 容器),则不重复启动进程;注释掉该项则会由插件自行拉起 |
migration.dir |
offline/migrations |
迁移文件所在目录 |
需要特别留意 noStart 与 Docker 方案的配合:使用 docker-compose up -d 启动持久化 DynamoDB 时,本机 8000 端口已有实例,保留 noStart: true 可避免插件再启动一个内存实例造成端口冲突。
provider:运行时、环境变量与最小权限
provider:
name: aws
runtime: nodejs24.x
architecture: arm64
environment:
DYNAMODB_TABLE: ${self:service}-${sls:stage}
httpApi:
cors: true
iam:
role:
statements:
- Effect: Allow
Action:
- dynamodb:Query
- dynamodb:Scan
- dynamodb:GetItem
- dynamodb:PutItem
- dynamodb:UpdateItem
- dynamodb:DeleteItem
Resource: !GetAtt TodosDynamoDbTable.Arn
runtime: nodejs24.x+architecture: arm64:Lambda 运行时与架构按当前仓库实际配置为准;DYNAMODB_TABLE通过${self:service}-${sls:stage}动态拼接,在dev阶段即为serverless-http-api-dynamodb-local-dev,与 offline/migrations/todos.json 中的TableName保持一致;httpApi.cors: true:为 HTTP API 开启 CORS,方便浏览器端直连调试;- IAM 策略仅授予该服务需要的 6 个 DynamoDB 动作,且
Resource通过!GetAtt TodosDynamoDbTable.Arn限定在本服务自己的表上,体现最小权限原则。
functions:HTTP API 事件路由
functions:
create:
handler: todos/create.create
events:
- httpApi:
path: /todos
method: post
list:
handler: todos/list.list
events:
- httpApi:
path: /todos
method: get
get:
handler: todos/get.get
events:
- httpApi:
path: /todos/{id}
method: get
update:
handler: todos/update.update
events:
- httpApi:
path: /todos/{id}
method: put
delete:
handler: todos/delete.delete
events:
- httpApi:
path: /todos/{id}
method: delete
五个函数构成完整的 REST 风格资源:POST /todos、GET /todos、GET /todos/{id}、PUT /todos/{id}、DELETE /todos/{id}。handler 的 目录/文件.导出名 写法与源码一一对应,例如 todos/delete.delete 指向 todos/delete.js 中导出的 delete 函数。
resources:DynamoDB 表定义
resources:
Resources:
TodosDynamoDbTable:
Type: AWS::DynamoDB::Table
DeletionPolicy: Retain
Properties:
AttributeDefinitions:
- AttributeName: id
AttributeType: S
KeySchema:
- AttributeName: id
KeyType: HASH
BillingMode: PAY_PER_REQUEST
TableName: ${self:provider.environment.DYNAMODB_TABLE}
表结构非常简单:以 id(字符串类型)作为分区键(HASH),BillingMode: PAY_PER_REQUEST 按需计费、无需手动管理读写容量。DeletionPolicy: Retain 表示即使删除 CloudFormation 栈也保留数据表。注意:这里定义的 CloudFormation 表资源用于云端部署;离线环境的建表则走 offline/migrations 迁移文件(见后文)。
本地 DynamoDB 的两种启动方案
README 提供了两种本地数据库方案,按需二选一:
方案一:serverless-dynamodb 插件(内存模式,默认)
执行 serverless dynamodb install 下载本地 JAR 后,配合 custom.dynamodb.start 中的 inMemory: true,由 serverless offline start 一并拉起,数据仅存在于进程内存,适合快速、可重复的测试场景。
方案二:Docker 持久化方案
若希望本地数据在服务重启后仍然保留,可另开终端:
cd ./dynamodb && docker-compose up -d
docker-compose.yml 定义了:
services:
dynamodb:
build:
context: .
dockerfile: Dockerfile
ports:
- 8000:8000
volumes:
- aws-http-api-dynamodb:/home/dynamodblocal/db
配套的 Dockerfile 基于官方 amazon/dynamodb-local 镜像构建,关键点是:
CMD ["-jar", "DynamoDBLocal.jar", "-dbPath", "./db", "-sharedDb"]
-dbPath ./db:把数据库文件写入容器内/home/dynamodblocal/db;-sharedDb:以共享数据库模式启动,所有表共用一个文件;- 通过命名卷
aws-http-api-dynamodb挂载到该目录,实现容器重建后数据不丢失。
采用该方案时,务必保留 serverless.yml 中 start.noStart: true(若本机已有 DynamoDB 运行,则不重复启动),同时 README 也注明:若你本机没有正在运行的 DynamoDB,可以把该项注释掉,让插件代为启动。
双环境数据库客户端:dynamodb.js 与 IS_OFFLINE
todos/dynamodb.js 是整个服务连接数据库的唯一入口,也是「本地离线、云端在线」双模式切换的关键:
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
let clientOptions = {};
// connect to local DB if running offline
if (process.env.IS_OFFLINE) {
clientOptions = {
region: 'localhost',
endpoint: 'http://localhost:8000',
credentials: {
accessKeyId: 'DEFAULT_ACCESS_KEY',
secretAccessKey: 'DEFAULT_SECRET',
},
};
}
const client = DynamoDBDocumentClient.from(new DynamoDBClient(clientOptions));
export default client;
从源码结构可以提炼出它的两个核心设计:
IS_OFFLINE环境变量由serverless-offline在本地运行时注入。当该变量存在时,客户端改用:endpoint: 'http://localhost:8000'指向本地 DynamoDB;credentials使用占位凭证DEFAULT_ACCESS_KEY/DEFAULT_SECRET——本地实例不校验真实密钥,只要格式合法即可;region: 'localhost'同样只是一个占位值。
- 未设置
IS_OFFLINE时,clientOptions保持空对象,DynamoDBClient会走默认凭证链(环境变量、IAM 角色等)连接真实 AWS 上的 DynamoDB。也就是说,同一份代码无需改动即可完成本地与云端的切换,这是该示例最具复用价值的部分。
此外,客户端通过 DynamoDBDocumentClient.from() 封装,屏蔽了 DynamoDB 原生的属性类型包装(如 { S: "value" }),使上层代码可以像操作普通 JavaScript 对象一样读写数据。
CRUD 函数源码剖析
五个 handler 全部基于 @aws-sdk/lib-dynamodb 的 Command 对象 + 共享 client 实现,逐一解读如下。
创建 Todo(create.js)
export const create = async (event) => {
const data = JSON.parse(event.body ?? '{}');
if (typeof data.text !== 'string') {
return {
statusCode: 400,
headers: { 'Content-Type': 'text/plain' },
body: "Couldn't create the todo item.",
};
}
const timestamp = Date.now();
const item = {
id: randomUUID(),
text: data.text,
checked: false,
createdAt: timestamp,
updatedAt: timestamp,
};
await dynamodb.send(new PutCommand({ TableName: process.env.DYNAMODB_TABLE, Item: item }));
return { statusCode: 200, body: JSON.stringify(item) };
};
要点:
- 使用 Node.js 内置
crypto.randomUUID()生成id,无需额外依赖; - 校验
data.text必须是字符串,否则返回400; createdAt/updatedAt均取Date.now()毫秒时间戳;- 通过
PutCommand写入process.env.DYNAMODB_TABLE指定的表(该环境变量在serverless.yml中定义)。
列出所有 Todo(list.js)
export const list = async () => {
const result = await dynamodb.send(new ScanCommand({ TableName: process.env.DYNAMODB_TABLE }));
return { statusCode: 200, body: JSON.stringify(result.Items) };
};
使用 ScanCommand 全表扫描并返回 Items 数组。注意:Scan 在数据量大时开销较高,生产环境建议改用 Query + 索引,本示例为演示用途保持最简实现。
查询单个 Todo(get.js)
export const get = async (event) => {
const result = await dynamodb.send(
new GetCommand({
TableName: process.env.DYNAMODB_TABLE,
Key: { id: event.pathParameters.id },
})
);
return { statusCode: 200, body: JSON.stringify(result.Item) };
};
通过 event.pathParameters.id 取出 URL 路径中的 {id} 参数,构造主键发起 GetCommand。这与 serverless.yml 中 path: /todos/{id} 的路由声明严格对应。
更新 Todo(update.js)
export const update = async (event) => {
const data = JSON.parse(event.body ?? '{}');
if (typeof data.text !== 'string' || typeof data.checked !== 'boolean') {
return {
statusCode: 400,
headers: { 'Content-Type': 'text/plain' },
body: "Couldn't update the todo item.",
};
}
const result = await dynamodb.send(
new UpdateCommand({
TableName: process.env.DYNAMODB_TABLE,
Key: { id: event.pathParameters.id },
ExpressionAttributeNames: { '#todo_text': 'text' },
ExpressionAttributeValues: {
':text': data.text,
':checked': data.checked,
':updatedAt': Date.now(),
},
UpdateExpression: 'SET #todo_text = :text, checked = :checked, updatedAt = :updatedAt',
ReturnValues: 'ALL_NEW',
})
);
return { statusCode: 200, body: JSON.stringify(result.Attributes) };
};
要点:
- 校验
text为字符串且checked为布尔值; UpdateExpression使用占位符表达式更新三个字段;#todo_text是属性名占位符(text在 DynamoDB 中属于保留字风险词,因此用ExpressionAttributeNames转义);ReturnValues: 'ALL_NEW'让更新操作直接返回更新后的完整条目,响应体无需二次查询。
删除 Todo(delete.js)
// `delete` is a reserved word, so the handler function is declared as `del`
// and re-exported under the `delete` name for the serverless.yml handler reference.
const del = async (event) => {
await dynamodb.send(
new DeleteCommand({
TableName: process.env.DYNAMODB_TABLE,
Key: { id: event.pathParameters.id },
})
);
return { statusCode: 200, body: JSON.stringify({}) };
};
export { del as delete };
一个值得注意的工程细节:delete 是 JavaScript 的保留字,不能直接作为函数名声明,因此源码先定义 del,再通过 export { del as delete } 重导出,从而保证 serverless.yml 中 handler: todos/delete.delete 的引用名不变。删除成功仅返回空对象 {},与 README 所述「No output」一致。
离线迁移:offline/migrations/todos.json
serverless dynamodb migrate 依赖的建表 schema 位于 offline/migrations/todos.json:
{
"Table": {
"TableName": "serverless-http-api-dynamodb-local-dev",
"KeySchema": [
{
"AttributeName": "id",
"KeyType": "HASH"
}
],
"AttributeDefinitions": [
{
"AttributeName": "id",
"AttributeType": "S"
}
],
"ProvisionedThroughput": {
"ReadCapacityUnits": 1,
"WriteCapacityUnits": 1
}
}
}
TableName与serverless.yml中${self:service}-${sls:stage}在dev阶段的拼接结果完全一致,这是 migrate 能正确建表的前提;- 主键结构与云端 CloudFormation 表定义保持一致:
id为字符串分区键; - 本地迁移使用
ProvisionedThroughput(读写各 1 容量单位)即可满足离线测试需求,与云端表的PAY_PER_REQUEST计费模式并不冲突——两者服务于不同环境。
本地 API 使用指南(curl 实测命令)
启动 serverless offline start 后,服务默认监听 http://localhost:3000。以下是 README 提供的完整 CRUD 调用方式与示例输出。
创建一条 Todo
curl -X POST -H "Content-Type:application/json" http://localhost:3000/todos --data '{ "text": "Learn Serverless" }'
示例返回:
{"id":"3ac1f668-8b45-4b1a-9c1e-1e2f6f9d8c2a","text":"Learn Serverless","checked":false,"createdAt":1479138570824,"updatedAt":1479138570824}
可见新条目默认 checked: false,createdAt 与 updatedAt 相同(均为创建时刻)。
列出所有 Todos
curl -H "Content-Type:application/json" http://localhost:3000/todos
示例输出:
[{"text":"Deploy my first service","id":"ac90feaa11e6-9ede-afdfa051af86","checked":true,"updatedAt":1479139961304},{"text":"Learn Serverless","id":"206793aa11e6-9ede-afdfa051af86","createdAt":1479139943241,"checked":false,"updatedAt":1479139943241}]
查询单条 Todo
# Replace the <id> part with a real id from your todos table
curl -H "Content-Type:application/json" http://localhost:3000/todos/<id>
示例返回:
{"text":"Learn Serverless","id":"ee6490d0-aa11e6-9ede-afdfa051af86","createdAt":1479138570824,"checked":false,"updatedAt":1479138570824}
更新一条 Todo
# Replace the <id> part with a real id from your todos table
curl -X PUT -H "Content-Type:application/json" http://localhost:3000/todos/<id> --data '{ "text": "Learn Serverless", "checked": true }'
示例返回(注意 checked 已变为 true、updatedAt 已刷新,且 createdAt 保持不变,验证了 UpdateExpression 只更新三个字段):
{"text":"Learn Serverless","id":"ee6490d0-aa11e6-9ede-afdfa051af86","createdAt":1479138570824,"checked":true,"updatedAt":1479138570824}
删除一条 Todo
# Replace the <id> part with a real id from your todos table
curl -X DELETE -H "Content-Type:application/json" http://localhost:3000/todos/<id>
无输出(函数返回空对象 {})。
与云端版本 aws-node-http-api-dynamodb 的对比
本示例是 aws-node-http-api-dynamodb 的离线孪生版本,两者在业务逻辑上几乎一致:相同的五个 httpApi 路由、相同的 DYNAMODB_TABLE 命名约定、相同的表结构(id HASH + PAY_PER_REQUEST)。核心差异在于:
| 维度 | aws-node-http-api-dynamodb | aws-node-http-api-dynamodb-local |
|---|---|---|
| 主要流程 | npm install → serverless deploy |
npm install → serverless offline start |
| 数据库 | 部署时由 CloudFormation 创建真实 DynamoDB 表 | 由 serverless-dynamodb 插件或 Docker 提供本地实例 |
| 建表方式 | CloudFormation 资源(resources 段) |
serverless dynamodb migrate 导入 offline/migrations |
| 调用地址 | AWS 分配的 execute-api HTTPS 端点 |
http://localhost:3000 |
| 适用场景 | 联调、发布、验收 | 本地开发、测试、CI 快速验证 |
此外,云端版本的表使用 BillingMode: PAY_PER_REQUEST,无需手动管理读写容量、按用量计费;离线版本无需关心计费,但同样可以通过 DeletionPolicy: Retain 保障云端数据不被误删。两个示例配合使用,即可形成「本地开发 → 云端部署」的完整工作流。
常见问题与注意事项
- Docker 端口冲突:若先启动
docker-compose up -d(占用 8000),再执行serverless offline start时插件默认会尝试再起一个实例。此时应保留serverless.yml中start.noStart: true;若没有外部 DynamoDB 运行,则可按 README 注释提示移除该配置项。 IS_OFFLINE只在本地生效:云端部署时serverless-offline不会注入该变量,客户端自动走 AWS 凭证链连接真实 DynamoDB,因此不要在生产环境依赖DEFAULT_ACCESS_KEY占位凭证。- migrate 必须在 offline 启动后执行:
serverless dynamodb migrate需要本地 DynamoDB 已在 8000 端口监听,顺序错乱会导致建表失败。 - 保留字与导出名:
delete不能用作函数声明名,示例通过del+export { del as delete }规避,改造该文件时不要破坏handler: todos/delete.delete的引用。 - 内存模式数据不持久:
inMemory: true下重启服务后数据清空,需要持久化数据时应切换到 Docker 方案。
小结
本示例以极小的代码量展示了 Serverless 本地开发的最佳实践组合:serverless-offline 承担网关模拟、serverless-dynamodb / Docker 承担数据存储、IS_OFFLINE 环境变量承担环境切换、offline/migrations 承担离线建表。理解这套模式后,你可以快速把任意「Lambda + API Gateway + DynamoDB」服务改造成可本地调试的形态,大幅缩短开发反馈回路。相关源码与配置均可直接在仓库目录 aws-node-http-api-dynamodb-local 中查阅与运行。