从 AWS PDK 迁移
本指南将引导您完成将 AWS PDK 项目迁移至 Nx AWS 插件的示例,并提供相关主题的通用指导。
相较于 PDK,迁移至 Nx AWS 插件具有以下优势:
- 更快的构建速度
- 更易用的界面与 CLI
- 支持氛围编程(试试我们的 MCP 服务器!)
- 更现代化的技术栈
- 本地 API 与网站开发
- 更高的控制权(可修改预置文件以适应需求)
- 更多功能!
示例迁移:购物清单应用
Section titled “示例迁移:购物清单应用”本指南将以 PDK 教程中的购物清单应用 作为迁移目标项目。若需跟随操作,请先按教程创建目标项目。
该购物清单应用包含以下 PDK 项目类型:
MonorepoTsProjectTypeSafeApiProjectCloudscapeReactTsWebsiteProjectInfrastructureTsProject
首先,为新建项目创建工作区。相较于原地迁移,此方法能获得最整洁的最终结果。创建 Nx 工作区相当于使用 PDK 的 MonorepoTsProject:
npx create-nx-workspace@21.4.1 shopping-list --pm=pnpm --preset=@aws/nx-plugin@0.50.0 --iacProvider=CDK --ci=skip --aiAgentsnpx create-nx-workspace@21.4.1 shopping-list --pm=yarn --preset=@aws/nx-plugin@0.50.0 --iacProvider=CDK --ci=skip --aiAgentsnpx create-nx-workspace@21.4.1 shopping-list --pm=npm --preset=@aws/nx-plugin@0.50.0 --iacProvider=CDK --ci=skip --aiAgentsnpx create-nx-workspace@21.4.1 shopping-list --pm=bun --preset=@aws/nx-plugin@0.50.0 --iacProvider=CDK --ci=skip --aiAgents在您常用的 IDE 中打开命令生成的 shopping-list 目录。
迁移 API
Section titled “迁移 API”购物清单应用中的 TypeSafeApiProject 使用了:
- Smithy 作为建模语言
- TypeScript 实现操作逻辑
- TypeScript 钩子生成器与 React 网站集成
因此我们可以使用 ts#smithy-api 生成器 来提供等效功能。
生成 TypeScript Smithy API
Section titled “生成 TypeScript Smithy API”运行 ts#smithy-api 生成器 在 packages/api 中创建 API 项目:
- 安装 Nx Console VSCode Plugin 如果您尚未安装
- 在VSCode中打开Nx控制台
- 点击
Generate (UI)在"Common Nx Commands"部分 - 搜索
@aws/nx-plugin - ts#smithy-api - 填写必需参数
- name: api
- namespace: com.aws
- auth: IAM
- 点击
Generate
pnpm nx g @aws/nx-plugin:ts#smithy-api --name=api --namespace=com.aws --auth=IAM --no-interactiveyarn nx g @aws/nx-plugin:ts#smithy-api --name=api --namespace=com.aws --auth=IAM --no-interactivenpx nx g @aws/nx-plugin:ts#smithy-api --name=api --namespace=com.aws --auth=IAM --no-interactivebunx nx g @aws/nx-plugin:ts#smithy-api --name=api --namespace=com.aws --auth=IAM --no-interactive您还可以执行试运行以查看哪些文件会被更改
pnpm nx g @aws/nx-plugin:ts#smithy-api --name=api --namespace=com.aws --auth=IAM --no-interactive --dry-runyarn nx g @aws/nx-plugin:ts#smithy-api --name=api --namespace=com.aws --auth=IAM --no-interactive --dry-runnpx nx g @aws/nx-plugin:ts#smithy-api --name=api --namespace=com.aws --auth=IAM --no-interactive --dry-runbunx nx g @aws/nx-plugin:ts#smithy-api --name=api --namespace=com.aws --auth=IAM --no-interactive --dry-run这会生成 model 和 backend 项目。model 包含 Smithy 模型,backend 包含服务端实现。
后端使用 Smithy TypeScript 服务端生成器,下文将详细探讨。
迁移 Smithy 模型
Section titled “迁移 Smithy 模型”建立基础结构后,按以下步骤迁移模型:
-
删除
packages/api/model/src中的示例文件 -
将 PDK 项目
packages/api/model/src/main/smithy的模型复制到新项目的packages/api/model/src目录 -
更新
smithy-build.json中的服务名称和命名空间以匹配 PDK 应用:smithy-build.json "plugins": {"openapi": {"service": "com.aws#MyApi",... -
更新
main.smithy中的服务,添加ValidationException错误,使用 Smithy TypeScript 服务端 SDK 时需要此错误:main.smithy use smithy.framework#ValidationException/// 我的购物清单 API@restJson1service MyApi {version: "1.0"operations: [GetShoppingListsPutShoppingListDeleteShoppingList]errors: [BadRequestErrorNotAuthorizedErrorInternalFailureErrorValidationException]} -
在
packages/api/model/src创建extensions.smithy文件,定义一个特性为生成的客户端提供分页信息:extensions.smithy $version: "2"namespace com.awsuse smithy.openapi#specificationExtension@trait@specificationExtension(as: "x-cursor")structure cursor {inputToken: Stringenabled: Boolean} -
在
get-shopping-lists.smithy的GetShoppingLists操作添加新的@cursor特性:operations/get-shopping-lists.smithy @readonly@http(method: "GET", uri: "/shopping-list")@paginated(inputToken: "nextToken", outputToken: "nextToken", pageSize: "pageSize", items: "shoppingLists")@cursor(inputToken: "nextToken")@handler(language: "typescript")operation GetShoppingLists {input := with [PaginatedInputMixin] {@httpQuery("shoppingListId")shoppingListId: ShoppingListId}如果使用 Nx Plugin for AWS 提供的客户端生成器(通过
api-connection生成器),任何@paginated操作也应使用@cursor。 -
最后,移除所有操作的
@handler特性,因为 Nx Plugin for AWS 不支持此特性。使用ts#smithy-api时,我们不需要此特性生成的自动 Lambda 函数 CDK 构造和打包目标,因为我们为所有 Lambda 函数使用单个打包。
此时,让我们运行构建来检查模型变更,并确保有一些生成的服务端代码可用。后端项目(@shopping-list/api)会有一些失败,但我们接下来会解决这些问题。
pnpm nx run-many --target buildyarn nx run-many --target buildnpx nx run-many --target buildbunx nx run-many --target build迁移 Lambda 处理程序
Section titled “迁移 Lambda 处理程序”api/backend 项目相当于 Type Safe API 的 api/handlers/typescript 项目。
Type Safe API 和 ts#smithy-api 生成器的主要区别之一是,处理程序使用 Smithy TypeScript 服务端生成器 实现,而非 Type Safe API 自己生成的处理程序封装器(位于 api/generated/typescript/runtime 项目中)。
购物清单应用的 Lambda 处理程序依赖 @aws-sdk/client-dynamodb 包,让我们先安装它:
pnpm add -w @aws-sdk/client-dynamodbyarn add @aws-sdk/client-dynamodbnpm install --legacy-peer-deps @aws-sdk/client-dynamodbbun install @aws-sdk/client-dynamodb然后,将 PDK 项目的 handlers/src/dynamo-client.ts 文件复制到 backend/src/operations,使其可供处理程序使用。
迁移处理程序可遵循以下步骤:
-
将处理程序从 PDK 项目的
packages/api/handlers/typescript/src目录复制到新项目的packages/api/backend/src/operations目录。 -
移除
my-api-typescript-runtime导入,改为从生成的 TypeScript 服务端 SDK 导入操作类型以及ServiceContext,例如:import {deleteShoppingListHandler,DeleteShoppingListChainedHandlerFunction,INTERCEPTORS,Response,LoggingInterceptor,} from 'myapi-typescript-runtime';import { DeleteShoppingList as DeleteShoppingListOperation } from '../generated/ssdk/index.js';import { ServiceContext } from '../context.js'; -
删除处理程序封装器导出
export const handler = deleteShoppingListHandler(...INTERCEPTORS,deleteShoppingList,); -
更新操作处理程序的签名以使用 SSDK:
export const deleteShoppingList: DeleteShoppingListChainedHandlerFunction = async (request) => {export const DeleteShoppingList: DeleteShoppingListOperation<ServiceContext> = async (input, ctx) => { -
用
ctx.logger替换LoggingInterceptor的使用(也适用于指标和追踪拦截器):LoggingInterceptor.getLogger(request).info('...');ctx.logger.info('...'); -
更新输入参数的引用。由于 SSDK 提供的类型与 Smithy 模型完全匹配(而不是将路径/查询/头参数与主体参数分开分组),相应更新所有输入引用:
const shoppingListId = request.input.requestParameters.shoppingListId;const shoppingListId = input.shoppingListId; -
移除
Response的使用。在 SSDK 中我们只返回普通对象。return Response.success({ shoppingListId });return { shoppingListId };我们也不再抛出或返回
Response,而是抛出 SSDK 生成的错误:throw Response.badRequest({ message: 'oh no' });return Response.badRequest({ message: 'oh no' });import { BadRequestError } from '../generated/ssdk/index.js';throw new BadRequestError({ message: 'oh no' }); -
更新所有导入使用 ESM 语法,即为相对导入添加
.js扩展名。 -
在
service.ts中添加操作service.ts import { ServiceContext } from './context.js';import { MyApiService } from './generated/ssdk/index.js';import { DeleteShoppingList } from './operations/delete-shopping-list.js';import { GetShoppingLists } from './operations/get-shopping-lists.js';import { PutShoppingList } from './operations/put-shopping-list.js';// 在此注册操作到服务export const Service: MyApiService<ServiceContext> = {PutShoppingList,GetShoppingLists,DeleteShoppingList,};
购物清单处理程序迁移
删除购物清单
import { DeleteItemCommand } from '@aws-sdk/client-dynamodb';import { deleteShoppingListHandler, DeleteShoppingListChainedHandlerFunction, INTERCEPTORS, Response, LoggingInterceptor,} from 'myapi-typescript-runtime';import { ddbClient } from './dynamo-client';
/** * Type-safe handler for the DeleteShoppingList operation */export const deleteShoppingList: DeleteShoppingListChainedHandlerFunction = async (request) => { LoggingInterceptor.getLogger(request).info( 'Start DeleteShoppingList Operation', );
const shoppingListId = request.input.requestParameters.shoppingListId; await ddbClient.send( new DeleteItemCommand({ TableName: 'shopping_list', Key: { shoppingListId: { S: shoppingListId, }, }, }), );
return Response.success({ shoppingListId, });};
/** * Entry point for the AWS Lambda handler for the DeleteShoppingList operation. * The deleteShoppingListHandler method wraps the type-safe handler and manages marshalling inputs and outputs */export const handler = deleteShoppingListHandler( ...INTERCEPTORS, deleteShoppingList,);import { DeleteItemCommand } from '@aws-sdk/client-dynamodb';import { ddbClient } from './dynamo-client.js';import { DeleteShoppingList as DeleteShoppingListOperation } from '../generated/ssdk/index.js';import { ServiceContext } from '../context.js';
/** * Type-safe handler for the DeleteShoppingList operation */export const DeleteShoppingList: DeleteShoppingListOperation<ServiceContext> = async (input, ctx) => { ctx.logger.info( 'Start DeleteShoppingList Operation', );
const shoppingListId = input.shoppingListId; await ddbClient.send( new DeleteItemCommand({ TableName: 'shopping_list', Key: { shoppingListId: { S: shoppingListId!, }, }, }), );
return { shoppingListId, };};获取购物清单
import { DynamoDBClient, QueryCommand, QueryCommandInput, ScanCommand, ScanCommandInput } from '@aws-sdk/client-dynamodb';import { getShoppingListsHandler, GetShoppingListsChainedHandlerFunction, INTERCEPTORS, Response, LoggingInterceptor, ShoppingList,} from 'myapi-typescript-runtime';import { ddbClient } from './dynamo-client';
/** * Type-safe handler for the GetShoppingLists operation */export const getShoppingLists: GetShoppingListsChainedHandlerFunction = async (request) => { LoggingInterceptor.getLogger(request).info('Start GetShoppingLists Operation');
const nextToken = request.input.requestParameters.nextToken; const pageSize = request.input.requestParameters.pageSize; const shoppingListId = request.input.requestParameters.shoppingListId; const commandInput: ScanCommandInput | QueryCommandInput = { TableName: 'shopping_list', ConsistentRead: true, Limit: pageSize, ExclusiveStartKey: nextToken ? fromToken(nextToken) : undefined, ...(shoppingListId ? { KeyConditionExpression: 'shoppingListId = :shoppingListId', ExpressionAttributeValues: { ':shoppingListId': { S: request.input.requestParameters.shoppingListId!, }, }, } : {}), }; const response = await ddbClient.send(shoppingListId ? new QueryCommand(commandInput) : new ScanCommand(commandInput));
return Response.success({ shoppingLists: (response.Items || []) .map<ShoppingList>(item => ({ shoppingListId: item.shoppingListId.S!, name: item.name.S!, shoppingItems: JSON.parse(item.shoppingItems.S || '[]'), })), nextToken: response.LastEvaluatedKey ? toToken(response.LastEvaluatedKey) : undefined, });};
/** * Decode a stringified token * @param token a token passed to the paginated request */const fromToken = <T>(token?: string): T | undefined => token ? (JSON.parse(Buffer.from(decodeURIComponent(token), 'base64').toString()) as T) : undefined;
/** * Encode pagination details into an opaque stringified token * @param paginationToken pagination token details */const toToken = <T>(paginationToken?: T): string | undefined => paginationToken ? encodeURIComponent(Buffer.from(JSON.stringify(paginationToken)).toString('base64')) : undefined;
/** * Entry point for the AWS Lambda handler for the GetShoppingLists operation. * The getShoppingListsHandler method wraps the type-safe handler and manages marshalling inputs and outputs */export const handler = getShoppingListsHandler(...INTERCEPTORS, getShoppingLists);import { QueryCommand, QueryCommandInput, ScanCommand, ScanCommandInput } from '@aws-sdk/client-dynamodb';import { ddbClient } from './dynamo-client.js';import { GetShoppingLists as GetShoppingListsOperation, ShoppingList } from '../generated/ssdk/index.js';import { ServiceContext } from '../context.js';
/** * Type-safe handler for the GetShoppingLists operation */export const GetShoppingLists: GetShoppingListsOperation<ServiceContext> = async (input, ctx) => { ctx.logger.info('Start GetShoppingLists Operation');
const nextToken = input.nextToken; const pageSize = input.pageSize; const shoppingListId = input.shoppingListId; const commandInput: ScanCommandInput | QueryCommandInput = { TableName: 'shopping_list', ConsistentRead: true, Limit: pageSize, ExclusiveStartKey: nextToken ? fromToken(nextToken) : undefined, ...(shoppingListId ? { KeyConditionExpression: 'shoppingListId = :shoppingListId', ExpressionAttributeValues: { ':shoppingListId': { S: input.shoppingListId!, }, }, } : {}), }; const response = await ddbClient.send(shoppingListId ? new QueryCommand(commandInput) : new ScanCommand(commandInput));
return { shoppingLists: (response.Items || []) .map<ShoppingList>(item => ({ shoppingListId: item.shoppingListId.S!, name: item.name.S!, shoppingItems: JSON.parse(item.shoppingItems.S || '[]'), })), nextToken: response.LastEvaluatedKey ? toToken(response.LastEvaluatedKey) : undefined, };};
/** * Decode a stringified token * @param token a token passed to the paginated request */const fromToken = <T>(token?: string): T | undefined => token ? (JSON.parse(Buffer.from(decodeURIComponent(token), 'base64').toString()) as T) : undefined;
/** * Encode pagination details into an opaque stringified token * @param paginationToken pagination token details */const toToken = <T>(paginationToken?: T): string | undefined => paginationToken ? encodeURIComponent(Buffer.from(JSON.stringify(paginationToken)).toString('base64')) : undefined;添加购物清单
import { randomUUID } from 'crypto';import { DynamoDBClient, PutItemCommand } from '@aws-sdk/client-dynamodb';import { putShoppingListHandler, PutShoppingListChainedHandlerFunction, INTERCEPTORS, Response, LoggingInterceptor,} from 'myapi-typescript-runtime';import { ddbClient } from './dynamo-client';
/** * Type-safe handler for the PutShoppingList operation */export const putShoppingList: PutShoppingListChainedHandlerFunction = async (request) => { LoggingInterceptor.getLogger(request).info('Start PutShoppingList Operation');
const shoppingListId = request.input.body.shoppingListId ?? randomUUID(); await ddbClient.send(new PutItemCommand({ TableName: 'shopping_list', Item: { shoppingListId: { S: shoppingListId, }, name: { S: request.input.body.name, }, shoppingItems: { S: JSON.stringify(request.input.body.shoppingItems || []), }, }, }));
return Response.success({ shoppingListId, });};
/** * Entry point for the AWS Lambda handler for the PutShoppingList operation. * The putShoppingListHandler method wraps the type-safe handler and manages marshalling inputs and outputs */export const handler = putShoppingListHandler(...INTERCEPTORS, putShoppingList);import { randomUUID } from 'crypto';import { PutItemCommand } from '@aws-sdk/client-dynamodb';import { ddbClient } from './dynamo-client.js';import { PutShoppingList as PutShoppingListOperation } from '../generated/ssdk/index.js';import { ServiceContext } from '../context.js';
/** * Type-safe handler for the PutShoppingList operation */export const PutShoppingList: PutShoppingListOperation<ServiceContext> = async (input, ctx) => { ctx.logger.info('Start PutShoppingList Operation');
const shoppingListId = input.shoppingListId ?? randomUUID(); await ddbClient.send(new PutItemCommand({ TableName: 'shopping_list', Item: { shoppingListId: { S: shoppingListId, }, name: { S: input.name!, }, shoppingItems: { S: JSON.stringify(input.shoppingItems || []), }, }, }));
return { shoppingListId, };};我们最初生成 Smithy API 项目时使用名称 api,因为我们希望将其添加到 packages/api 以与 PDK 项目保持一致。由于我们的 Smithy API 现在定义的是 service MyApi 而不是 service Api,我们需要将所有 getApiServiceHandler 实例更新为 getMyApiServiceHandler。
在 handler.ts 中进行此更改:
import { getApiServiceHandler } from './generated/ssdk/index.js'; import { getMyApiServiceHandler } from './generated/ssdk/index.js';
process.env.POWERTOOLS_METRICS_NAMESPACE = 'Api';process.env.POWERTOOLS_SERVICE_NAME = 'Api';
const tracer = new Tracer();const logger = new Logger();const metrics = new Metrics();
const serviceHandler = getApiServiceHandler(Service); const serviceHandler = getMyApiServiceHandler(Service);在 local-server.ts 中进行此更改:
import { getApiServiceHandler } from './generated/ssdk/index.js';import { getMyApiServiceHandler } from './generated/ssdk/index.js';
const PORT = 3001;
const tracer = new Tracer();const logger = new Logger();const metrics = new Metrics();
const serviceHandler = getApiServiceHandler(Service);const serviceHandler = getMyApiServiceHandler(Service);此外,更新 packages/api/backend/project.json,将 metadata.apiName 更新为 my-api:
"metadata": { "generator": "ts#smithy-api", "apiName": "api", "apiName": "my-api", "auth": "IAM", "modelProject": "@shopping-list/api-model", "ports": [3001] },现在可以构建项目来检查迁移是否成功:
pnpm nx run-many --target buildyarn nx run-many --target buildnpx nx run-many --target buildbunx nx run-many --target build购物清单应用中使用的CloudscapeReactTsWebsiteProject配置了一个内置CloudScape和Cognito身份验证的React网站。
该项目类型基于现已弃用的create-react-app。在本迁移指南中,我们将使用ts#react-website生成器,它采用了更现代且受支持的技术栈——Vite。
作为迁移的一部分,我们还将从PDK配置的React Router迁移到TanStack Router,这将为网站路由增加额外的类型安全性。
生成React网站
Section titled “生成React网站”运行ts#react-website生成器在packages/website目录下创建网站项目:
- 安装 Nx Console VSCode Plugin 如果您尚未安装
- 在VSCode中打开Nx控制台
- 点击
Generate (UI)在"Common Nx Commands"部分 - 搜索
@aws/nx-plugin - ts#react-website - 填写必需参数
- name: website
- 点击
Generate
pnpm nx g @aws/nx-plugin:ts#react-website --name=website --no-interactiveyarn nx g @aws/nx-plugin:ts#react-website --name=website --no-interactivenpx nx g @aws/nx-plugin:ts#react-website --name=website --no-interactivebunx nx g @aws/nx-plugin:ts#react-website --name=website --no-interactive您还可以执行试运行以查看哪些文件会被更改
pnpm nx g @aws/nx-plugin:ts#react-website --name=website --no-interactive --dry-runyarn nx g @aws/nx-plugin:ts#react-website --name=website --no-interactive --dry-runnpx nx g @aws/nx-plugin:ts#react-website --name=website --no-interactive --dry-runbunx nx g @aws/nx-plugin:ts#react-website --name=website --no-interactive --dry-run添加Cognito身份验证
Section titled “添加Cognito身份验证”上述React网站生成器默认不包含Cognito身份验证(与CloudscapeReactTsWebsiteProject不同),需要通过ts#react-website#auth生成器显式添加:
- 安装 Nx Console VSCode Plugin 如果您尚未安装
- 在VSCode中打开Nx控制台
- 点击
Generate (UI)在"Common Nx Commands"部分 - 搜索
@aws/nx-plugin - ts#react-website#auth - 填写必需参数
- project: website
- cognitoDomain: shopping-list
- 点击
Generate
pnpm nx g @aws/nx-plugin:ts#react-website#auth --project=website --cognitoDomain=shopping-list --no-interactiveyarn nx g @aws/nx-plugin:ts#react-website#auth --project=website --cognitoDomain=shopping-list --no-interactivenpx nx g @aws/nx-plugin:ts#react-website#auth --project=website --cognitoDomain=shopping-list --no-interactivebunx nx g @aws/nx-plugin:ts#react-website#auth --project=website --cognitoDomain=shopping-list --no-interactive您还可以执行试运行以查看哪些文件会被更改
pnpm nx g @aws/nx-plugin:ts#react-website#auth --project=website --cognitoDomain=shopping-list --no-interactive --dry-runyarn nx g @aws/nx-plugin:ts#react-website#auth --project=website --cognitoDomain=shopping-list --no-interactive --dry-runnpx nx g @aws/nx-plugin:ts#react-website#auth --project=website --cognitoDomain=shopping-list --no-interactive --dry-runbunx nx g @aws/nx-plugin:ts#react-website#auth --project=website --cognitoDomain=shopping-list --no-interactive --dry-run这会添加管理重定向逻辑的React组件以确保用户通过Cognito托管UI登录,同时在packages/common/constructs目录下添加名为UserIdentity的CDK构造用于部署Cognito资源。
连接网站与API
Section titled “连接网站与API”在PDK中,通过传递项目实例来触发集成代码生成。在Nx Plugin for AWS中,API集成通过connection生成器实现。运行以下命令使网站能够调用Smithy API:
- 安装 Nx Console VSCode Plugin 如果您尚未安装
- 在VSCode中打开Nx控制台
- 点击
Generate (UI)在"Common Nx Commands"部分 - 搜索
@aws/nx-plugin - connection - 填写必需参数
- sourceProject: website
- targetProject: api
- 点击
Generate
pnpm nx g @aws/nx-plugin:connection --sourceProject=website --targetProject=api --no-interactiveyarn nx g @aws/nx-plugin:connection --sourceProject=website --targetProject=api --no-interactivenpx nx g @aws/nx-plugin:connection --sourceProject=website --targetProject=api --no-interactivebunx nx g @aws/nx-plugin:connection --sourceProject=website --targetProject=api --no-interactive您还可以执行试运行以查看哪些文件会被更改
pnpm nx g @aws/nx-plugin:connection --sourceProject=website --targetProject=api --no-interactive --dry-runyarn nx g @aws/nx-plugin:connection --sourceProject=website --targetProject=api --no-interactive --dry-runnpx nx g @aws/nx-plugin:connection --sourceProject=website --targetProject=api --no-interactive --dry-runbunx nx g @aws/nx-plugin:connection --sourceProject=website --targetProject=api --no-interactive --dry-run这将生成必要的客户端提供程序与构建目标,使网站可以通过生成的TypeScript客户端调用API。
添加AWS Northstar依赖
Section titled “添加AWS Northstar依赖”由于CloudscapeReactTsWebsiteProject自动包含@aws-northstar/ui依赖,需手动添加:
pnpm add -w @aws-northstar/uiyarn add @aws-northstar/uinpm install --legacy-peer-deps @aws-northstar/uibun install @aws-northstar/ui迁移组件与页面
Section titled “迁移组件与页面”购物清单应用包含CreateItem组件及ShoppingList、ShoppingLists两个页面。迁移时需调整以适应TanStack Router和Nx Plugin for AWS的TypeScript客户端代码生成。
-
将PDK项目中的
packages/website/src/components/CreateItem/index.tsx复制到新项目相同位置 -
将
packages/website/src/pages/ShoppingLists/index.tsx复制为packages/website/src/routes/index.tsx(因ShoppingLists是首页且使用文件路由) -
将
packages/website/src/pages/ShoppingList/index.tsx复制为packages/website/src/routes/$shoppingListId.tsx(对应/:shoppingListId路由)
迁移后IDE中可能出现构建错误,需进行后续调整。
从React Router迁移到TanStack Router
Section titled “从React Router迁移到TanStack Router”使用文件路由时,可通过本地开发服务器自动生成路由配置。启动本地网站服务:
pnpm nx serve-local websiteyarn nx serve-local websitenpx nx serve-local websitebunx nx serve-local website服务启动后(网站端口4200,Smithy API端口3001),按以下步骤迁移路由:
-
添加
createFileRoute注册路由:import { createFileRoute } from "@tanstack/react-router";...export default ShoppingLists;export const Route = createFileRoute('/')({component: ShoppingLists,});import { createFileRoute } from "@tanstack/react-router";...export default ShoppingList;export const Route = createFileRoute('/$shoppingListId')({component: ShoppingList,});保存文件后,
createFileRoute调用的类型错误将消失。 -
替换
useNavigate钩子。更新导入:
import { useNavigate } from 'react-router-dom';import { useNavigate } from '@tanstack/react-router';更新
navigate方法调用(由useNavigate返回)以传入类型安全的路由:navigate(`/${cell.shoppingListId}`);navigate({to: '/$shoppingListId',params: { shoppingListId: cell.shoppingListId },}); -
替换
useParams钩子。移除导入:
import { useParams } from 'react-router-dom';使用上面创建的
Route提供的钩子更新useParams调用。现在这些都是类型安全的!const { shoppingListId } = useParams();const { shoppingListId } = Route.useParams();
修复组件导入
Section titled “修复组件导入”由于路由文件在文件树中的嵌套层级不如PDK项目深,需要修复routes/index.tsx和routes/$shoppingListId.tsx中CreateItem的导入:
import CreateItem from "../../components/CreateItem";import CreateItem from "../components/CreateItem";AppLayoutContext在新项目中的位置也略有不同:
import { AppLayoutContext } from "../../layouts/App";import { AppLayoutContext } from "../components/AppLayout";迁移至新生成的TypeScript客户端
Section titled “迁移至新生成的TypeScript客户端”我们越来越接近了!接下来,需要迁移到Nx Plugin for AWS生成的TypeScript客户端,相比Type Safe API有一些改进。按照以下步骤操作:
-
导入新生成的客户端和类型而非旧的,例如:
import {ShoppingList,usePutShoppingList,useDeleteShoppingList,useGetShoppingLists,} from "myapi-typescript-react-query-hooks";import { ShoppingList } from "../generated/my-api/types.gen";import { useMyApi } from "../hooks/useMyApi";import { useInfiniteQuery, useMutation } from "@tanstack/react-query";注意
routes/$shoppingListId.tsx将ShoppingList类型导入为_ShoppingList- 在该文件中应该同样操作,但从types.gen导入。另外注意我们直接从
@tanstack/react-query导入相关钩子,因为生成的客户端提供生成TanStack query钩子选项的方法,而不是钩子包装器。 -
实例化新的TanStack Query钩子,例如:
const getShoppingLists = useGetShoppingLists({ pageSize: PAGE_SIZE });const putShoppingList = usePutShoppingList();const deleteShoppingList = useDeleteShoppingList();const api = useMyApi();const getShoppingLists = useInfiniteQuery(api.getShoppingLists.infiniteQueryOptions({ pageSize: PAGE_SIZE },{ getNextPageParam: (p) => p.nextToken },),);const putShoppingList = useMutation(api.putShoppingList.mutationOptions());const deleteShoppingList = useMutation(api.deleteShoppingList.mutationOptions(),); -
移除请求体中接受参数的操作调用的
<operation>RequestContent包装器:await putShoppingList.mutateAsync({putShoppingListRequestContent: {name: item,},});
从TanStack Query v4迁移至v5
Section titled “从TanStack Query v4迁移至v5”由于PDK使用的TanStack Query v4与connection生成器添加的v5之间的差异,还需要修复一些错误:
-
将mutation的
isLoading替换为isPending,例如:putShoppingList.isLoadingputShoppingList.isPending -
购物清单应用使用了
@aws-northstar/ui的InfiniteQueryTable,它期望TanStack Query v4的类型。这实际上也适用于v5的无限查询,因此我们可以直接抑制类型错误:<InfiniteQueryTablequery={getShoppingLists}query={getShoppingLists as any}
访问本地网站
Section titled “访问本地网站”现在可以访问本地网站:http://localhost:4200/
由于所有内容都已迁移,网站应该可以加载了!因为购物清单应用除了API、网站和身份验证外,仅依赖DynamoDB表 - 如果您在区域内有一个名为shopping_list的DynamoDB表,并且本地AWS凭证可以访问它,网站将完全可用!
如果没有,没关系,我们接下来将迁移基础设施。
购物清单页面迁移
购物清单列表页
/* eslint-disable @typescript-eslint/no-floating-promises */import { InfiniteQueryTable } from "@aws-northstar/ui/components";import { Button, Header, Link, SpaceBetween, TableProps,} from "@cloudscape-design/components";import { ShoppingList, usePutShoppingList, useDeleteShoppingList, useGetShoppingLists,} from "myapi-typescript-react-query-hooks";import { useContext, useEffect, useMemo, useState } from "react";import { useNavigate } from "react-router-dom";import CreateItem from "../../components/CreateItem";import { AppLayoutContext } from "../../layouts/App";
const PAGE_SIZE = 50;
/** * Component to render the ShoppingLists "/" route. */const ShoppingLists: React.FC = () => { const [visibleModal, setVisibleModal] = useState(false); const [selectedShoppingList, setSelectedShoppingList] = useState< ShoppingList[] >([]); const getShoppingLists = useGetShoppingLists({ pageSize: PAGE_SIZE }); const putShoppingList = usePutShoppingList(); const deleteShoppingList = useDeleteShoppingList(); const navigate = useNavigate(); const { setAppLayoutProps } = useContext(AppLayoutContext);
useEffect(() => { setAppLayoutProps({ contentType: "table", }); }, [setAppLayoutProps]);
const columnDefinitions = useMemo< TableProps.ColumnDefinition<ShoppingList>[] >( () => [ { id: "shoppingListId", isRowHeader: true, header: "Shopping List Id", cell: (cell) => ( <Link href={`/${cell.shoppingListId}`} onFollow={(e) => { e.preventDefault(); navigate(`/${cell.shoppingListId}`); }} > {cell.shoppingListId} </Link> ), }, { id: "name", header: "Name", cell: (cell) => cell.name, }, { id: "shoppingItems", header: "Shopping Items", cell: (cell) => `${cell.shoppingItems?.length || 0} Items.`, }, ], [navigate], );
return ( <> <CreateItem title="Create Shopping List" callback={async (item) => { await putShoppingList.mutateAsync({ putShoppingListRequestContent: { name: item, }, }); getShoppingLists.refetch(); }} isLoading={putShoppingList.isLoading} visibleModal={visibleModal} setVisibleModal={setVisibleModal} /> <InfiniteQueryTable query={getShoppingLists} itemsKey="shoppingLists" pageSize={PAGE_SIZE} selectionType="single" stickyHeader={true} selectedItems={selectedShoppingList} onSelectionChange={(e) => setSelectedShoppingList(e.detail.selectedItems) } header={ <Header variant="awsui-h1-sticky" actions={ <SpaceBetween size="xs" direction="horizontal"> <Button loading={deleteShoppingList.isLoading} data-testid="header-btn-delete" disabled={selectedShoppingList.length === 0} onClick={async () => { await deleteShoppingList.mutateAsync({ shoppingListId: selectedShoppingList![0].shoppingListId, }); setSelectedShoppingList([]); getShoppingLists.refetch(); }} > Delete </Button> <Button data-testid="header-btn-create" variant="primary" onClick={() => setVisibleModal(true)} > Create Shopping List </Button> </SpaceBetween> } > Shopping Lists </Header> } variant="full-page" columnDefinitions={columnDefinitions} /> </> );};
export default ShoppingLists;/* eslint-disable @typescript-eslint/no-floating-promises */import { InfiniteQueryTable } from "@aws-northstar/ui/components";import { Button, Header, Link, SpaceBetween, TableProps,} from "@cloudscape-design/components";import { useContext, useEffect, useMemo, useState } from "react";import { useNavigate } from "@tanstack/react-router";import CreateItem from "../components/CreateItem";import { AppLayoutContext } from "../components/AppLayout";import { createFileRoute } from "@tanstack/react-router";import { ShoppingList } from "../generated/my-api/types.gen";import { useMyApi } from "../hooks/useMyApi";import { useInfiniteQuery, useMutation } from "@tanstack/react-query";
const PAGE_SIZE = 50;
/** * Component to render the ShoppingLists "/" route. */const ShoppingLists: React.FC = () => { const [visibleModal, setVisibleModal] = useState(false); const [selectedShoppingList, setSelectedShoppingList] = useState< ShoppingList[] >([]); const api = useMyApi(); const getShoppingLists = useInfiniteQuery( api.getShoppingLists.infiniteQueryOptions( { pageSize: PAGE_SIZE }, { getNextPageParam: (res) => res.nextToken }, ), ); const putShoppingList = useMutation(api.putShoppingList.mutationOptions()); const deleteShoppingList = useMutation( api.deleteShoppingList.mutationOptions(), ); const navigate = useNavigate(); const { setAppLayoutProps } = useContext(AppLayoutContext);
useEffect(() => { setAppLayoutProps({ contentType: "table", }); }, [setAppLayoutProps]);
const columnDefinitions = useMemo< TableProps.ColumnDefinition<ShoppingList>[] >( () => [ { id: "shoppingListId", isRowHeader: true, header: "Shopping List Id", cell: (cell) => ( <Link href={`/${cell.shoppingListId}`} onFollow={(e) => { e.preventDefault(); navigate({ to: '/$shoppingListId', params: { shoppingListId: cell.shoppingListId },}); }} > {cell.shoppingListId} </Link> ), }, { id: "name", header: "Name", cell: (cell) => cell.name, }, { id: "shoppingItems", header: "Shopping Items", cell: (cell) => `${cell.shoppingItems?.length || 0} Items.`, }, ], [navigate], );
return ( <> <CreateItem title="Create Shopping List" callback={async (item) => { await putShoppingList.mutateAsync({ name: item, }); getShoppingLists.refetch(); }} isLoading={putShoppingList.isPending} visibleModal={visibleModal} setVisibleModal={setVisibleModal} /> <InfiniteQueryTable query={getShoppingLists as any} itemsKey="shoppingLists" pageSize={PAGE_SIZE} selectionType="single" stickyHeader={true} selectedItems={selectedShoppingList} onSelectionChange={(e) => setSelectedShoppingList(e.detail.selectedItems) } header={ <Header variant="awsui-h1-sticky" actions={ <SpaceBetween size="xs" direction="horizontal"> <Button loading={deleteShoppingList.isPending} data-testid="header-btn-delete" disabled={selectedShoppingList.length === 0} onClick={async () => { await deleteShoppingList.mutateAsync({ shoppingListId: selectedShoppingList![0].shoppingListId, }); setSelectedShoppingList([]); getShoppingLists.refetch(); }} > Delete </Button> <Button data-testid="header-btn-create" variant="primary" onClick={() => setVisibleModal(true)} > Create Shopping List </Button> </SpaceBetween> } > Shopping Lists </Header> } variant="full-page" columnDefinitions={columnDefinitions} /> </> );};
export const Route = createFileRoute('/')({ component: ShoppingLists,});单个购物清单页
/* eslint-disable @typescript-eslint/no-floating-promises */import { Board, BoardItem, BoardProps,} from "@cloudscape-design/board-components";import { Button, Container, ContentLayout, Header, SpaceBetween, Spinner,} from "@cloudscape-design/components";import { ShoppingList as _ShoppingList, usePutShoppingList, useGetShoppingLists,} from "myapi-typescript-react-query-hooks";import { useEffect, useState } from "react";import { useParams } from "react-router-dom";import CreateItem from "../../components/CreateItem";
type ListItem = { name: string };
/** * Component to render a singular Shopping List "/:shoppingListId" route. */const ShoppingList: React.FC = () => { const { shoppingListId } = useParams(); const [visibleModal, setVisibleModal] = useState(false); const getShoppingLists = useGetShoppingLists({ shoppingListId }); const putShoppingList = usePutShoppingList(); const shoppingList: _ShoppingList | undefined = getShoppingLists.data?.pages[0].shoppingLists[0]!; const [shoppingItems, setShoppingItems] = useState<BoardProps.Item<ListItem>[]>();
useEffect(() => { setShoppingItems( shoppingList?.shoppingItems?.map((i) => ({ id: i, definition: { minColumnSpan: 4 }, data: { name: i }, })), ); }, [shoppingList?.shoppingItems]);
return ( <ContentLayout header={ <Header variant="awsui-h1-sticky" actions={ <SpaceBetween size="xs" direction="horizontal"> <Button data-testid="header-btn-create" variant="primary" onClick={() => setVisibleModal(true)} > Add Item </Button> </SpaceBetween> } > Shopping list: {shoppingList?.name} </Header> } > <CreateItem isLoading={false} title="Add Item" callback={async (item) => { const items = [ ...(shoppingItems || []), { id: item, definition: { minColumnSpan: 4 }, data: { name: item }, }, ]; setShoppingItems(items); putShoppingList.mutate({ putShoppingListRequestContent: { name: shoppingList.name, shoppingListId: shoppingList.shoppingListId, shoppingItems: items.map((i) => i.data.name), }, }); }} visibleModal={visibleModal} setVisibleModal={setVisibleModal} /> <Container> {!shoppingList ? ( <Spinner /> ) : ( <Board<ListItem> onItemsChange={(event) => { const items = event.detail.items as BoardProps.Item<ListItem>[]; setShoppingItems(items); putShoppingList.mutate({ putShoppingListRequestContent: { name: shoppingList.name, shoppingListId: shoppingList.shoppingListId, shoppingItems: items.map((i) => i.data.name), }, }); }} items={shoppingItems || []} renderItem={(item, actions) => ( <BoardItem header={item.data.name} settings={ <Button iconName="close" variant="icon" onClick={actions.removeItem} /> } i18nStrings={{ dragHandleAriaLabel: "Drag handle", dragHandleAriaDescription: "Use Space or Enter to activate drag, arrow keys to move, Space or Enter to submit, or Escape to discard.", resizeHandleAriaLabel: "Resize handle", resizeHandleAriaDescription: "Use Space or Enter to activate resize, arrow keys to move, Space or Enter to submit, or Escape to discard.", }} /> )} i18nStrings={{ liveAnnouncementDndCommitted: () => "", liveAnnouncementDndDiscarded: () => "", liveAnnouncementDndItemInserted: () => "", liveAnnouncementDndItemReordered: () => "", liveAnnouncementDndItemResized: () => "", liveAnnouncementDndStarted: () => "", liveAnnouncementItemRemoved: () => "", navigationAriaLabel: "", navigationItemAriaLabel: () => "", }} empty={<></>} /> )} </Container> </ContentLayout> );};
export default ShoppingList;// routes/$shoppingListId.tsx/* eslint-disable @typescript-eslint/no-floating-promises */import { Board, BoardItem, BoardProps,} from "@cloudscape-design/board-components";import { Button, Container, ContentLayout, Header, SpaceBetween, Spinner,} from "@cloudscape-design/components";import { useEffect, useState } from "react";import CreateItem from "../components/CreateItem";import { createFileRoute } from "@tanstack/react-router";import { useMyApi } from "../hooks/useMyApi";import { useInfiniteQuery, useMutation } from "@tanstack/react-query";import { ShoppingList as _ShoppingList } from "../generated/my-api/types.gen";
type ListItem = { name: string };
/** * Component to render a singular Shopping List "/:shoppingListId" route. */const ShoppingList: React.FC = () => { const { shoppingListId } = Route.useParams(); const [visibleModal, setVisibleModal] = useState(false); const api = useMyApi(); const getShoppingLists = useInfiniteQuery( api.getShoppingLists.infiniteQueryOptions( { shoppingListId }, { getNextPageParam: (p) => p.nextToken }, ), ); const putShoppingList = useMutation(api.putShoppingList.mutationOptions()); const shoppingList: _ShoppingList | undefined = getShoppingLists.data?.pages?.[0]?.shoppingLists?.[0]; const [shoppingItems, setShoppingItems] = useState<BoardProps.Item<ListItem>[]>();
useEffect(() => { setShoppingItems( shoppingList?.shoppingItems?.map((i) => ({ id: i, definition: { minColumnSpan: 4 }, data: { name: i }, })), ); }, [shoppingList?.shoppingItems]);
return ( <ContentLayout header={ <Header variant="awsui-h1-sticky" actions={ <SpaceBetween size="xs" direction="horizontal"> <Button data-testid="header-btn-create" variant="primary" onClick={() => setVisibleModal(true)} > Add Item </Button> </SpaceBetween> } > Shopping list: {shoppingList?.name} </Header> } > <CreateItem isLoading={false} title="Add Item" callback={async (item) => { const items = [ ...(shoppingItems || []), { id: item, definition: { minColumnSpan: 4 }, data: { name: item }, }, ]; setShoppingItems(items); putShoppingList.mutate({ name: shoppingList?.name ?? 'my list', shoppingListId: shoppingList?.shoppingListId, shoppingItems: items.map((i) => i.data.name), }); }} visibleModal={visibleModal} setVisibleModal={setVisibleModal} /> <Container> {!shoppingList ? ( <Spinner /> ) : ( <Board<ListItem> onItemsChange={(event) => { const items = event.detail.items as BoardProps.Item<ListItem>[]; setShoppingItems(items); putShoppingList.mutate({ name: shoppingList.name, shoppingListId: shoppingList.shoppingListId, shoppingItems: items.map((i) => i.data.name), }); }} items={shoppingItems || []} renderItem={(item, actions) => ( <BoardItem header={item.data.name} settings={ <Button iconName="close" variant="icon" onClick={actions.removeItem} /> } i18nStrings={{ dragHandleAriaLabel: "Drag handle", dragHandleAriaDescription: "Use Space or Enter to activate drag, arrow keys to move, Space or Enter to submit, or Escape to discard.", resizeHandleAriaLabel: "Resize handle", resizeHandleAriaDescription: "Use Space or Enter to activate resize, arrow keys to move, Space or Enter to submit, or Escape to discard.", }} /> )} i18nStrings={{ liveAnnouncementDndCommitted: () => "", liveAnnouncementDndDiscarded: () => "", liveAnnouncementDndItemInserted: () => "", liveAnnouncementDndItemReordered: () => "", liveAnnouncementDndItemResized: () => "", liveAnnouncementDndStarted: () => "", liveAnnouncementItemRemoved: () => "", navigationAriaLabel: "", navigationItemAriaLabel: () => "", }} empty={<></>} /> )} </Container> </ContentLayout> );};
export const Route = createFileRoute('/$shoppingListId')({ component: ShoppingList,});迁移基础设施
Section titled “迁移基础设施”我们购物清单应用需要迁移的最后一个项目是InfrastructureTsProject。这是一个TypeScript CDK项目,对应的Nx Plugin for AWS等效生成器是ts#infra生成器。
与Projen项目类似,PDK还提供了这些项目所依赖的CDK构造。我们也将从这些CDK构造迁移购物清单应用,转而使用Nx Plugin for AWS生成的构造。
生成TypeScript CDK基础设施项目
Section titled “生成TypeScript CDK基础设施项目”运行ts#infra生成器在packages/infra目录下创建基础设施项目:
- 安装 Nx Console VSCode Plugin 如果您尚未安装
- 在VSCode中打开Nx控制台
- 点击
Generate (UI)在"Common Nx Commands"部分 - 搜索
@aws/nx-plugin - ts#infra - 填写必需参数
- name: infra
- 点击
Generate
pnpm nx g @aws/nx-plugin:ts#infra --name=infra --no-interactiveyarn nx g @aws/nx-plugin:ts#infra --name=infra --no-interactivenpx nx g @aws/nx-plugin:ts#infra --name=infra --no-interactivebunx nx g @aws/nx-plugin:ts#infra --name=infra --no-interactive您还可以执行试运行以查看哪些文件会被更改
pnpm nx g @aws/nx-plugin:ts#infra --name=infra --no-interactive --dry-runyarn nx g @aws/nx-plugin:ts#infra --name=infra --no-interactive --dry-runnpx nx g @aws/nx-plugin:ts#infra --name=infra --no-interactive --dry-runbunx nx g @aws/nx-plugin:ts#infra --name=infra --no-interactive --dry-run迁移CDK基础设施
Section titled “迁移CDK基础设施”PDK购物清单应用在CDK应用栈中实例化了以下构造:
- 存储购物清单的DynamoDB表
DatabaseConstruct - 从PDK直接导入Cognito资源的
UserIdentity - 部署Smithy API的
MyApi,使用生成的TypeScript CDK构造实现类型安全集成,底层依赖PDK的TypeSafeRestApiCDK构造 - 部署网站的
Website,封装了PDK的StaticWebsiteCDK构造
接下来我们将逐一迁移这些构造。
将PDK购物清单应用中的packages/infra/src/stacks/application-stack.ts复制到新项目的相同位置。您会看到一些TypeScript错误,我们将在后续步骤中解决。
复制数据库构造
Section titled “复制数据库构造”PDK购物清单应用在packages/src/constructs/database.ts中有一个Database构造。将其复制到新项目的相同位置。
由于Nx Plugin for AWS使用Checkov进行安全检查(比PDK Nag更严格),我们需要添加抑制规则:
import { suppressRules } from ':shopping-list/common-constructs';...suppressRules( this.shoppingListTable, ['CKV_AWS_28', 'CKV_AWS_119'], 'Backup and KMS key not required for this project',);在application-stack.ts中更新DatabaseConstruct的导入语法为ESM格式:
import { DatabaseConstruct } from '../constructs/database';import { DatabaseConstruct } from '../constructs/database.js';迁移用户身份构造
Section titled “迁移用户身份构造”UserIdentity构造通常只需调整导入路径即可直接替换:
import { UserIdentity } from "@aws/pdk/identity";import { UserIdentity } from ':shopping-list/common-constructs';...const userIdentity = new UserIdentity(this, `${id}UserIdentity`);注意新的UserIdentity构造底层直接使用aws-cdk-lib中的构造,而PDK使用的是@aws-cdk/aws-cognito-identitypool-alpha。
迁移API构造
Section titled “迁移API构造”PDK购物清单应用在constructs/apis/myapi.ts中有一个构造,用于实例化从Smithy模型生成的Type Safe API CDK构造。
由于PDK项目使用@handler特性,还生成了Lambda函数CDK构造。Nx Plugin for AWS通过更简洁灵活的方式实现类型安全集成——仅生成最小化的”元数据”,由packages/common/constructs/src/app/apis/api.ts通用处理。更多细节请参考ts#smithy-api生成器指南。
迁移步骤如下:
-
在
application-stack.ts中实例化Api构造stacks/application-stack.ts import { MyApi } from "../constructs/apis/myapi";import { Api } from ':shopping-list/common-constructs';...const myapi = new MyApi(this, "MyApi", {databaseConstruct,userIdentity,});const api = new Api(this, 'MyApi', {integrations: Api.defaultIntegrations(this).build(),});这里使用
Api.defaultIntegrations(this).build()默认会为API中的每个操作创建Lambda函数,与原有myapi.ts的行为一致。 -
授予Lambda函数访问DynamoDB表的权限
在PDK应用中,
DatabaseConstruct被传入MyApi并管理权限。现在直接在application-stack.ts中通过Api构造的integrations属性实现:stacks/application-stack.ts // 授予Lambda函数细粒度Dynamo访问权限databaseConstruct.shoppingListTable.grantReadData(api.integrations.getShoppingLists.handler,);[api.integrations.putShoppingList.handler,api.integrations.deleteShoppingList.handler,].forEach((f) => databaseConstruct.shoppingListTable.grantWriteData(f)); -
授予认证用户调用API的权限
在PDK的
myapi.ts中,认证用户被授予API调用权限。现在在应用栈中实现等效操作:stacks/application-stack.ts api.grantInvokeAccess(userIdentity.identityPool.authenticatedRole);
迁移网站构造
Section titled “迁移网站构造”最后,将packages/common/constructs/src/app/static-websites/website.ts中的Website构造添加到application-stack.ts,替代原有PDK项目中的packages/infra/src/constructs/websites/website.ts。
import { Website } from "../constructs/websites/website";import { Website } from ':shopping-list/common-constructs';...new Website(this, "Website", { userIdentity, myapi,});new Website(this, 'Website');注意不再传递身份或API参数——Nx Plugin for AWS的构造会自行管理运行时配置,UserIdentity和Api注册必要值,Website负责部署到静态网站的/runtime-config.json。
完成所有代码迁移后,现在可以构建项目:
pnpm nx run-many --target buildyarn nx run-many --target buildnpx nx run-many --target buildbunx nx run-many --target build现在我们已经完成了代码库的完全迁移,接下来可以着手部署。此时有两种路径可供选择。
全新资源(简单方案)
Section titled “全新资源(简单方案)”最简单的处理方式是将其视为一个全新的应用,即通过全新的 DynamoDB 表和 Cognito 用户池”重新开始”——但会丢失所有用户及其购物清单。采用此方案只需:
-
删除名为
shopping_list的 DynamoDB 表 -
部署新应用:
Terminal window pnpm nx deploy infra shopping-list-infra-sandbox/*Terminal window yarn nx deploy infra shopping-list-infra-sandbox/*Terminal window npx nx deploy infra shopping-list-infra-sandbox/*Terminal window bunx nx deploy infra shopping-list-infra-sandbox/*
🎉 大功告成!🎉
无中断迁移现有有状态资源(复杂方案)
Section titled “无中断迁移现有有状态资源(复杂方案)”实际场景中,更可能需要将现有 AWS 资源迁移至新代码库管理,同时避免用户服务中断。
对于购物清单应用,需重点关注的有状态资源是存储用户购物清单的 DynamoDB 表和包含注册用户信息的 Cognito 用户池。我们的核心策略是保留这两个关键资源并将其迁移至新堆栈管理,然后更新 DNS 指向新网站(及面向客户的 API)。
-
更新新应用以引用需保留的现有资源
针对购物清单应用的 DynamoDB 表:
constructs/database.ts this.shoppingListTable = new Table(this, 'ShoppingList', {...this.shoppingListTable = Table.fromTableName(this,'ShoppingList','shopping_list',);针对 Cognito 用户池:
packages/common/constructs/src/core/user-identity.ts this.userPool = this.createUserPool();this.userPool = UserPool.fromUserPoolId(this,'UserPool','<your-user-pool-id>',); -
构建并部署新应用:
Terminal window pnpm nx run-many --target buildTerminal window yarn nx run-many --target buildTerminal window npx nx run-many --target buildTerminal window bunx nx run-many --target buildTerminal window pnpm nx deploy infra shopping-list-infra-sandbox/*Terminal window yarn nx deploy infra shopping-list-infra-sandbox/*Terminal window npx nx deploy infra shopping-list-infra-sandbox/*Terminal window bunx nx deploy infra shopping-list-infra-sandbox/*此时新应用已部署完成,引用现有资源但尚未接收流量。
-
执行完整集成测试确保新应用功能正常。对于购物清单应用,需验证登录及购物清单的增删改查功能。
-
还原新应用中引用现有资源的修改(暂不部署):
constructs/database.ts this.shoppingListTable = new Table(this, 'ShoppingList', {...this.shoppingListTable = Table.fromTableName(this,'ShoppingList','shopping_list',);针对 Cognito 用户池:
packages/common/constructs/src/core/user-identity.ts this.userPool = this.createUserPool();this.userPool = UserPool.fromUserPoolId(this,'UserPool','<your-user-pool-id>',);随后执行构建:
Terminal window pnpm nx run-many --target buildTerminal window yarn nx run-many --target buildTerminal window npx nx run-many --target buildTerminal window bunx nx run-many --target build -
在新应用的
packages/infra目录执行cdk import查看需导入的资源:新应用 cd packages/infrapnpm exec cdk import shopping-list-infra-sandbox/Application --force按回车逐步确认。由于资源仍由旧堆栈管理,导入会失败——这符合预期,此步骤仅用于确认需保留的资源。输出示例如下:
Terminal window shopping-list-infra-sandbox/Application/ApplicationUserIdentity/UserPool/smsRole/Resource (AWS::IAM::Role): 输入 RoleName(留空跳过)shopping-list-infra-sandbox/Application/ApplicationUserIdentity/UserPool/Resource (AWS::Cognito::UserPool): 输入 UserPoolId(留空跳过)shopping-list-infra-sandbox/Application/Database/ShoppingList/Resource (AWS::DynamoDB::Table): 使用 TableName=shopping_list 导入 (y/n) y这表明实际需导入 3 个资源至新堆栈。
-
更新旧 PDK 项目,为上步发现的资源设置
RemovalPolicy为RETAIN。当前 DynamoDB 表和用户池默认保留策略,但需为发现的 SMS 角色更新策略:application-stack.ts const userIdentity = new UserIdentity(this, `${id}UserIdentity`, {userPool,});const smsRole = userIdentity.userPool.node.findAll().filter(c => CfnResource.isCfnResource(c) &&c.node.path.includes('/smsRole/'))[0] as CfnResource;smsRole.applyRemovalPolicy(RemovalPolicy.RETAIN); -
部署 PDK 项目以应用保留策略:
PDK 应用 cd packages/infranpx projen deploy -
查看 CloudFormation 控制台,记录
cdk import步骤提示的信息:- 用户池 ID,如
us-west-2_XXXXX - SMS 角色名称,如
infra-sandbox-UserIdentityUserPoolsmsRoleXXXXXX
- 用户池 ID,如
-
更新 PDK 项目以引用现有资源:
constructs/database.ts this.shoppingListTable = new Table(this, 'ShoppingList', {...this.shoppingListTable = Table.fromTableName(this,'ShoppingList','shopping_list',);针对 Cognito 用户池:
application-stack.ts const userPool = UserPool.fromUserPoolId(this,'UserPool','<your-user-pool-id>',);const userIdentity = new UserIdentity(this, `${id}UserIdentity`, {// PDK 构造接受 UserPool 而非 IUserPool,此处仍有效!userPool: userPool as any,}); -
再次部署 PDK 项目,使资源脱离原 CloudFormation 堆栈管理:
PDK 应用 cd packages/infranpx projen deploy -
资源解除托管后,在新应用中执行
cdk import完成实际导入:新应用 cd packages/infrapnpm exec cdk import shopping-list-infra-sandbox/Application --force输入记录的值,导入应成功完成。
-
再次部署新应用以确保对现有资源(现由新堆栈管理)的变更生效:
Terminal window pnpm nx deploy infra shopping-list-infra-sandbox/*Terminal window yarn nx deploy infra shopping-list-infra-sandbox/*Terminal window npx nx deploy infra shopping-list-infra-sandbox/*Terminal window bunx nx deploy infra shopping-list-infra-sandbox/* -
再次全面测试新应用
-
更新 DNS 记录指向新网站(及 API 如需要)
建议采用 Route53 加权路由逐步切换流量。初始阶段将部分请求导向新应用,通过监控指标逐步增加新应用权重,直至旧 PDK 应用无流量。
若未配置 DNS 且使用自动生成域名,可通过 CloudFront HTTP 源站或 API Gateway HTTP 集成代理请求。
-
监控 PDK 应用指标确认无流量后,销毁旧 CloudFormation 堆栈:
Terminal window cd packages/infranpx projen destroy
虽然过程复杂,但我们成功实现了用户的无缝迁移!🎉🎉🎉
现在,我们已获得 Nx Plugin for AWS 相较于 PDK 的新优势:
- 更快的构建速度
- 本地 API 开发支持
- 更友好的编码体验(试试我们的 MCP 服务!)
- 更直观的类型安全客户端/服务端代码
- 以及更多!
本节针对示例迁移未覆盖的 PDK 功能提供指导。
总体而言,从 PDK 迁移时建议从 Nx 工作区开始新项目(因其与 PDK Monorepo 相似),并推荐使用我们的生成器作为构建新类型的基础。
npx create-nx-workspace@22.5.1 my-project --pm=pnpm --preset=@aws/nx-plugin --ci=skip --aiAgentsnpx create-nx-workspace@22.5.1 my-project --pm=yarn --preset=@aws/nx-plugin --ci=skip --aiAgentsnpx create-nx-workspace@22.5.1 my-project --pm=npm --preset=@aws/nx-plugin --ci=skip --aiAgentsnpx create-nx-workspace@22.5.1 my-project --pm=bun --preset=@aws/nx-plugin --ci=skip --aiAgentsCDK Graph
Section titled “CDK Graph”CDK Graph 可构建连接CDK资源的图谱,并提供两个插件:
CDK Graph Diagram Plugin 能够根据CDK基础设施生成AWS架构图。
若需类似的确定性方法,CDK-Dia 是可行的替代方案。
随着生成式AI的进步,许多基础模型已能根据CDK基础设施生成高质量图表。我们推荐尝试 AWS Diagram MCP Server。通过这篇博客可获取详细操作指南。
威胁建模插件
Section titled “威胁建模插件”CDK Graph Threat Composer Plugin 能够基于CDK代码生成初始的Threat Composer威胁模型。
该插件通过筛选包含示例威胁的基础威胁模型,并根据应用栈使用的资源进行过滤来运作。
若对这些示例威胁感兴趣,可复制并过滤基础威胁模型,或将其作为上下文辅助基础模型生成类似模型。
AWS Arch
Section titled “AWS Arch”AWS Arch 为 CDK Graph 提供了 CloudFormation 资源与其对应架构图标的映射关系。
有关图标资源,请参考 AWS Architecture Icons 页面。Diagrams 也提供了一种通过代码构建架构图的方式。
如果您需要直接使用这些资源,建议您 fork 该项目并自行维护!
PDK 提供了一个 PDKPipelineProject,它用于设置 CDK 基础设施项目,并利用了一个封装了部分 CDK Pipelines 资源的 CDK 构造体。
若需从此迁移,您可以直接使用 CDK Pipelines 的构造体。然而在实际操作中,采用如 GitHub Actions 或 GitLab CI/CD 这类工具可能更为简便。您只需定义 CDK Stages 并直接运行对应阶段的部署命令即可。
PDK Nag
Section titled “PDK Nag”PDK Nag 封装了 CDK Nag,并提供了一套专门用于构建原型的安全规则。
如需从 PDK Nag 迁移,请直接使用 CDK Nag。若需要相同的规则集,您可以通过此文档创建自己的”规则包”。
类型安全 API
Section titled “类型安全 API”上述迁移示例涵盖了 Type Safe API 中最常用的组件,但其他功能的迁移细节如下所述。
使用 OpenAPI 建模的 API
Section titled “使用 OpenAPI 建模的 API”Nx Plugin for AWS 支持使用 Smithy 建模的 API,但不支持直接使用 OpenAPI 建模的 API。ts#smithy-api 生成器是一个良好的起点,您可以在此基础上进行修改。您可以在model项目的src文件夹中定义 OpenAPI 规范而非 Smithy,并修改build.Dockerfile以使用您选择的代码生成工具来生成客户端/服务端代码(如果这些工具未在 NPM 上提供)。如果所需工具已在 NPM 上,您可以直接将其作为开发依赖安装到 Nx 工作区,并作为 Nx 构建目标直接调用。
对于使用 OpenAPI 建模的类型安全后端,可以考虑使用 OpenAPI Generator 服务端生成器。这些生成器不会直接生成适用于 AWS Lambda 的代码,但您可以使用 AWS Lambda Web Adapter 来弥合这一差距。
对于 TypeScript 客户端,可以使用 ts#react-website 生成器和 connection 生成器,结合示例 ts#smithy-api 来了解客户端的生成与网站集成方式。这会配置通过调用我们的 open-api#ts-client 或 open-api#ts-hooks 生成器来生成客户端的构建目标。您可以通过指向 OpenAPI 规范来自行使用这些生成器。
对于其他语言,也可以查看 OpenAPI Generator 中的生成器是否符合需求。
您还可以使用 ts#nx-generator 生成器构建定制生成器。参考该生成器的文档了解如何从 OpenAPI 生成代码的细节。可以使用 Nx Plugin for AWS 的模板作为起点,甚至参考 PDK 代码库的模板获取更多灵感,注意模板操作的数据结构与 Nx Plugin for AWS 略有不同。
使用 TypeSpec 建模的 API
Section titled “使用 TypeSpec 建模的 API”对于 TypeSpec,上述 OpenAPI 部分同样适用。您可以从生成 ts#smithy-api 开始,将 TypeSpec 编译器和 OpenAPI 包安装到 Nx 工作区,并更新模型项目的compile目标以运行tsp compile,确保其将 OpenAPI 规范输出到dist目录。
推荐方法是使用 TypeSpec HTTP Server generator for JavaScript 生成服务端代码,因为这直接作用于 TypeSpec 模型。
您可以使用 AWS Lambda Web Adapter 在 AWS Lambda 上运行生成的服务端。
也可以使用上述任何 OpenAPI 选项。
TypeSpec 为 Type Safe API 支持的三种语言提供了自己的客户端代码生成器:
由于 TypeSpec 可编译为 OpenAPI,上述 OpenAPI 部分同样适用。
使用 Smithy 建模的 API
Section titled “使用 Smithy 建模的 API”上述迁移示例概述了如何迁移到使用 ts#smithy-api 生成器。本节涵盖 Python 和 Java 后端及客户端的选项。
Smithy 的 Java 代码生成器。该生成器包含 Java 服务端生成器以及适配器以在 AWS Lambda 上运行生成的 Java 服务端。
Smithy 没有 Python 的服务端生成器,因此需要通过 OpenAPI。请参考上述使用 OpenAPI 建模的 API 部分获取可能的选项。
Smithy 的 Java 代码生成器。该生成器包含 Java 客户端生成器。
对于 Python 客户端,可查看 Smithy Python。
对于 TypeScript,查看 Smithy TypeScript,或采用与 ts#smithy-api 相同的通过 OpenAPI 的方法(我们选择此方法是为了通过 TanStack Query hooks 在 tRPC、FastAPI 和 Smithy API 之间保持一致性)。
Smithy 模型库
Section titled “Smithy 模型库”Type Safe API 提供了一个名为 SmithyShapeLibraryProject 的 Projen 项目类型,用于配置包含可被多个基于 Smithy 的 API 复用的 Smithy 模型的项目。
最直接的实现方式如下:
-
使用
smithy#project生成器创建模型库:- 安装 Nx Console VSCode Plugin 如果您尚未安装
- 在VSCode中打开Nx控制台
- 点击
Generate (UI)在"Common Nx Commands"部分 - 搜索
@aws/nx-plugin - smithy#project - 填写必需参数
- 点击
Generate
Terminal window pnpm nx g @aws/nx-plugin:smithy#projectTerminal window yarn nx g @aws/nx-plugin:smithy#projectTerminal window npx nx g @aws/nx-plugin:smithy#projectTerminal window bunx nx g @aws/nx-plugin:smithy#project为
serviceName选项指定任意名称,因为我们将移除service模型。 -
将
src中的默认模型替换为您要定义的模型 -
更新
smithy-build.json以移除plugins和未使用的 Maven 依赖 -
将
build.Dockerfile替换为最小化构建步骤:build.Dockerfile FROM public.ecr.aws/docker/library/node:24 AS builder# 输出目录RUN mkdir /out# 安装 Smithy CLI# https://smithy.io/2.0/guides/smithy-cli/cli_installation.htmlWORKDIR /smithyARG TARGETPLATFORMRUN if [ "$TARGETPLATFORM" = "linux/arm64" ]; then ARCH="aarch64"; else ARCH="x86_64"; fi && \mkdir -p smithy-install/smithy && \curl -L https://github.com/smithy-lang/smithy/releases/download/1.61.0/smithy-cli-linux-$ARCH.zip -o smithy-install/smithy-cli-linux-$ARCH.zip && \unzip -qo smithy-install/smithy-cli-linux-$ARCH.zip -d smithy-install && \mv smithy-install/smithy-cli-linux-$ARCH/* smithy-install/smithyRUN smithy-install/smithy/install# 复制项目文件COPY smithy-build.json .COPY src src# 使用 Maven 缓存挂载进行 Smithy 构建RUN --mount=type=cache,target=/root/.m2/repository,id=maven-cache \smithy buildRUN cp -r build/* /out/# 导出 /out 目录FROM scratch AS exportCOPY --from=builder /out /
在您的服务模型项目中进行以下更改以使用模型库:
-
更新
project.json中的compile目标,添加工作区作为构建上下文,并添加对模型库build目标的依赖project.json {"cache": true,"outputs": ["{workspaceRoot}/dist/{projectRoot}/build"],"executor": "nx:run-commands","options": {"commands": ["rimraf dist/packages/api/model/build","make-dir dist/packages/api/model/build","docker build --build-context workspace=. -f packages/api/model/build.Dockerfile --target export --output type=local,dest=dist/packages/api/model/build packages/api/model"],"parallel": false,"cwd": "{workspaceRoot}"},"dependsOn": ["@my-project/shapes:build"]} -
更新
build.Dockerfile以从模型库复制src目录。例如,假设模型库位于packages/shapes:build.Dockerfile # 复制项目文件COPY smithy-build.json .COPY src srcCOPY --from=workspace packages/shapes/src shapes -
更新
smithy-build.json将模型目录添加到其sources中:smithy-build.json {"version": "1.0","sources": ["src/", "shapes/"],"plugins": {...}
Type Safe API 提供了以下默认拦截器:
- 使用 Powertools for AWS Lambda 的日志、跟踪和指标拦截器
- 用于处理未捕获异常的 try-catch 拦截器
- 用于返回 CORS 头部的 CORS 拦截器
ts#smithy-api 生成器通过 Middy 使用 Powertools for AWS Lambda 实现日志、跟踪和指标。try-catch 拦截器的行为内置于 Smithy TypeScript SSDK 中,CORS 头部在 handler.ts 中添加。
对于任何语言的日志、跟踪和指标拦截器,直接使用 Powertools for AWS Lambda。
对于迁移自定义拦截器,推荐使用以下库:
- TypeScript - Middy
- Python - Powertools for AWS Lambda 中间件工厂
- Java - 使用 aws-lambda-java-libs 在业务逻辑前后插入方法,或考虑使用 AspectJ 通过注解构建中间件。
Type Safe API 使用 Redocly CLI 生成文档。迁移后可以轻松添加到现有项目中。
-
安装 Redocly CLI
Terminal window pnpm add -Dw @redocly/cliTerminal window yarn add -D @redocly/cliTerminal window npm install --legacy-peer-deps -D @redocly/cliTerminal window bun install -D @redocly/cli -
使用
redocly build-docs向model项目添加文档生成目标,例如:model/project.json {..."documentation": {"cache": true,"outputs": ["{workspaceRoot}/dist/{projectRoot}/documentation"],"executor": "nx:run-commands","options": {"command": "redocly build-docs dist/packages/api/model/build/openapi/openapi.json --output=dist/packages/api/model/documentation/index.html","cwd": "{workspaceRoot}"},"dependsOn": ["compile"]}}
也可以考虑 OpenAPI Generator 文档生成器。
Type Safe API 在其生成的基础设施包中为您生成模拟。
可以迁移到基于 JSON Schema 生成模拟数据的 JSON Schema Faker。这可以直接作用于 OpenAPI 规范,并具有CLI 工具,可作为 model 项目构建的一部分运行。
可以更新 CDK 基础设施以读取 JSON Schema Faker 生成的 JSON 文件,并根据生成的 metadata.gen.ts(假设您使用了 ts#smithy-api 生成器)返回适当的 API Gateway MockIntegration。
混合语言后端
Section titled “混合语言后端”Type Safe API 支持使用多种语言混合实现后端 API。这也可以通过在使用 CDK 实例化 API 构造时提供集成”覆盖”来实现:
const pythonLambdaHandler = new Function(this, 'PythonImplementation', { runtime: Runtime.PYTHON_3_12, ...});
new MyApi(this, 'MyApi', { integrations: Api.defaultIntegrations(this) .withOverrides({ echo: { integration: new LambdaIntegration(pythonLambdaHandler), handler: pythonLambdaHandler, }, }) .build(),});如果使用 ts#smithy-api 和 TypeScript 服务端 SDK,需要为服务”桩”化以通过编译,例如:
export const Service: ApiService<ServiceContext> = { ... Echo: () => { throw new Error(`Not Implemented`); },};由于 Type Safe API 在底层使用了 SpecRestApi 构造,因此基于 OpenAPI 规范为请求体添加了原生 API Gateway 验证。
使用 ts#smithy-api 生成器时,验证由服务端 SDK 自身执行。大多数服务端生成器也是如此。
如果需要实现原生 API Gateway 验证,可以通过修改 packages/common/constructs/src/core/api/rest-api.ts 来从 OpenAPI 规范中读取每个操作请求体的相关 JSON schema。
WebSocket API
Section titled “WebSocket API”目前没有从 Type Safe API 使用 API Gateway 和 Lambda 的模型驱动 WebSocket API 的直接迁移路径。但本节旨在提供一些思路。
考虑使用 AsyncAPI 代替 OpenAPI 或 TypeSpec 来建模 API,因为其专为异步 API 设计。AsyncAPI NodeJS 模板可以生成 Node WebSocket 后端,您可以在 ECS 上托管。
也可以考虑将 AppSync Events 用于基础设施,并配合 Powertools。这篇博客文章值得一读!
另一个选项是在 AppSync 上使用带有 WebSocket 的 GraphQL API,我们有一个 GitHub issue 可供支持!参考 AppSync 开发者指南获取详情和示例项目链接。
也可以考虑自行开发代码生成器来解释与 Type Safe API 相同的供应商扩展。参考使用 OpenAPI 建模的 API 部分了解构建自定义 OpenAPI 代码生成器的细节。可以在此处找到 Type Safe API 用于 API Gateway WebSocket API Lambda 处理程序的模板,客户端模板在此处。
也可以考虑迁移到使用 ts#trpc-api 生成器来使用 tRPC。截至撰写本文时,我们尚未支持订阅/流式传输,如需此功能请在GitHub issue 中添加 +1。
Smithy 是协议无关的,但尚未支持 WebSocket 协议,参考此 GitHub issue。
使用 Python 或 Java 的基础设施
Section titled “使用 Python 或 Java 的基础设施”当前版本的 Nx Plugin for AWS 暂不支持使用 Python 和 Java 编写的 PDK 兼容型 CDK 基础设施。
建议的解决方案是:将您的 CDK 基础设施迁移至 TypeScript,或使用我们的生成器并将通用构造包迁移至目标语言。您可以使用生成式 AI 加速此类迁移,例如 Amazon Q CLI。通过 AI 代理进行迭代迁移,直至生成的 CloudFormation 模板完全一致。
此原则同样适用于 Type Safe API 生成的 Python 或 Java 基础设施——您可以转换通用构造包中的 rest-api.ts 构造,并为目标语言实现简单的元数据生成器(参考 基于 OpenAPI 建模的 API 章节)。
对于 Python 项目,您可以使用 py#project 生成器 作为基础项目来添加 CDK 代码(迁移 cdk.json 文件并添加相关构建目标)。Java 项目可使用 Nx 的 @nx/gradle 插件,Maven 项目可使用 @jnxplus/nx-maven。
Projen 的使用
Section titled “Projen 的使用”PDK 构建于 Projen 之上。Projen 与 Nx Generators 存在根本性差异,这意味着虽然技术上可以结合使用,但这可能形成反模式。Projen 将项目文件作为代码管理,使其无法直接修改,而 Nx 生成器则一次性生成项目文件后,代码可自由修改。
若希望继续使用 Projen,可自行实现所需的 Projen 项目类型。要遵循 AWS 的 Nx 插件模式,可运行我们的生成器或查阅 GitHub 上的源码,了解目标项目类型的构建方式,并利用 Projen 的原语实现相关部分。