Serverless 框架本地开发实战:DynamoDB 离线支持示例(serverless-offline + serverless-dynamodb)

原创2026-10-13 18:05:211,919 阅读
文章标签:示例工程

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)

逐步解释如下:

  1. 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)。
  2. serverless dynamodb install:下载并安装 DynamoDB 本地版(Local JAR)。如果希望改用 Docker 运行一个持久化的 DynamoDB,则跳过此步,另开终端执行 cd ./dynamodb && docker-compose up -d(两种方式二选一,下文详解)。
  3. serverless offline start:启动本地 API 网关模拟器(默认监听 http://localhost:3000)与本地 DynamoDB 服务端(默认监听 http://localhost:8000)。
  4. 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;

从源码结构可以提炼出它的两个核心设计:

  1. IS_OFFLINE 环境变量由 serverless-offline 在本地运行时注入。当该变量存在时,客户端改用:
    • endpoint: 'http://localhost:8000' 指向本地 DynamoDB;
    • credentials 使用占位凭证 DEFAULT_ACCESS_KEY / DEFAULT_SECRET——本地实例不校验真实密钥,只要格式合法即可;
    • region: 'localhost' 同样只是一个占位值。
  2. 未设置 IS_OFFLINE 时,clientOptions 保持空对象,DynamoDBClient 会走默认凭证链(环境变量、IAM 角色等)连接真实 AWS 上的 DynamoDB。也就是说,同一份代码无需改动即可完成本地与云端的切换,这是该示例最具复用价值的部分。

此外,客户端通过 DynamoDBDocumentClient.from() 封装,屏蔽了 DynamoDB 原生的属性类型包装(如 { S: "value" }),使上层代码可以像操作普通 JavaScript 对象一样读写数据。

CRUD 函数源码剖析

五个 handler 全部基于 @aws-sdk/lib-dynamodb 的 Command 对象 + 共享 client 实现,逐一解读如下。

创建 Todo(create.js)

todos/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)

todos/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)

todos/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)

todos/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)

todos/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 保障云端数据不被误删。两个示例配合使用,即可形成「本地开发 → 云端部署」的完整工作流。

常见问题与注意事项

  1. Docker 端口冲突:若先启动 docker-compose up -d(占用 8000),再执行 serverless offline start 时插件默认会尝试再起一个实例。此时应保留 serverless.yml 中 start.noStart: true;若没有外部 DynamoDB 运行,则可按 README 注释提示移除该配置项。
  2. IS_OFFLINE 只在本地生效:云端部署时 serverless-offline 不会注入该变量,客户端自动走 AWS 凭证链连接真实 DynamoDB,因此不要在生产环境依赖 DEFAULT_ACCESS_KEY 占位凭证。
  3. migrate 必须在 offline 启动后执行:serverless dynamodb migrate 需要本地 DynamoDB 已在 8000 端口监听,顺序错乱会导致建表失败。
  4. 保留字与导出名:delete 不能用作函数声明名,示例通过 del + export { del as delete } 规避,改造该文件时不要破坏 handler: todos/delete.delete 的引用。
  5. 内存模式数据不持久:inMemory: true 下重启服务后数据清空,需要持久化数据时应切换到 Docker 方案。

小结

本示例以极小的代码量展示了 Serverless 本地开发的最佳实践组合:serverless-offline 承担网关模拟、serverless-dynamodb / Docker 承担数据存储、IS_OFFLINE 环境变量承担环境切换、offline/migrations 承担离线建表。理解这套模式后,你可以快速把任意「Lambda + API Gateway + DynamoDB」服务改造成可本地调试的形态,大幅缩短开发反馈回路。相关源码与配置均可直接在仓库目录 aws-node-http-api-dynamodb-local 中查阅与运行。

登录后查看全文
examples