news 2026/9/28 2:42:43

Midway 函数式 CRUD 指南:用 `defineCrudRoutes()` 在 `defineApi()` 中快速生成标准 REST 接口

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Midway 函数式 CRUD 指南:用 `defineCrudRoutes()` 在 `defineApi()` 中快速生成标准 REST 接口
  • 后端
  • 微服务
  • 云原生

【免费下载链接】midway

🍔 A Node.js Serverless Framework for front-end/full-stack developers. Build the application for next decade. Works on AWS, Alibaba Cloud, Tencent Cloud and traditional VM/Container. Super easy integrate with React and Vue. 🌈

项目地址:https://gitcode.com/gh_mirrors/mi/midway
点击查看免费下载

导读

本文围绕 Midway 仓库中 functional-crud 规格文档 展开,介绍函数式 CRUD 路由工厂defineCrudRoutes()的设计目标、使用方式与底层实现。它解决的核心问题是:在 Midway 函数式路由风格(defineApi())下,如何在不回退到 class controller、不依赖@Crud()装饰器的情况下,直接生成标准 CRUD 接口。读完本文,你将掌握defineCrudRoutes()的完整配置项、与自定义业务路由的合并方法、它与 class-based CRUD 共享的底层核心,以及查询协议、DTO 校验与错误语义等实战细节。


一、为什么需要函数式 CRUD

Midway 提供了两种 Web 开发风格:传统的基于装饰器与 class controller 的风格,以及函数式路由风格。CRUD 组件(@midwayjs/crud)最早以@Crud()装饰器形态提供声明式资源注册,但在函数式路由风格下,用户面临一个缺口:要么为每个资源手写 CRUD handler,要么回退到 class controller。

functional-crud 规格 正是为填补这一缺口而定义:系统需要提供函数式 CRUD 路由工厂,使用户在 Midway 函数式路由风格中无需回退到 class controller 即可暴露标准 CRUD 接口。

该规格明确了三条核心要求:

  1. 函数式 CRUD 路由工厂:调用defineCrudRoutes({ model, service, dto, query })即可获得可用于生成标准 CRUD 路由的函数式定义,无需定义 class controller 或使用@Crud()。
  2. 共享 CRUD 核心:函数式 CRUD 与 class-based CRUD 复用同一套 CRUD core,而不是复制一套平行实现,确保查询协议、DTO 绑定、错误语义与删除策略保持一致。
  3. 最小公开 API 面:defineCrudRoutes()输出的应是可被defineApi()消费或合并的 route map,避免引入与defineApi()并列的另一套函数式注册协议。

二、最小 API 面:defineCrudRoutes()的入口与输出形态

2.1 从固定二级路径导入

规格明确要求:函数式 CRUD 的入口必须从@midwayjs/crud/functional导入,而不是从@midwayjs/crud主入口导出。这样做的目的是保持主入口 API 面的稳定,把函数式能力作为独立、可选的二级入口提供给用户。

import { defineCrudRoutes } from '@midwayjs/crud/functional';

2.2 输出的是 route factory,而不是注册对象

规格中最关键的设计约束是:defineCrudRoutes()返回一个接收api并产出 route map 的工厂函数,而不是一个独立的注册对象。这一设计避免了让用户学习另一套与defineApi()并列的函数式注册协议。

从源码看,这一契约被精确定义在 interface.ts 中:

export type FunctionalCrudRouteFactory<T = any> = ( api: FunctionalApiBuilder ) => Record<string, FunctionalRouteBuilder | FunctionalRouteDefinition> & { __entityType__?: T; };

其中FunctionalApiBuilder是对defineApi()回调中api对象的最小抽象:

export interface FunctionalApiBuilder { get(path?: string): FunctionalRouteBuilder; post(path?: string): FunctionalRouteBuilder; patch(path?: string): FunctionalRouteBuilder; put(path?: string): FunctionalRouteBuilder; delete(path?: string): FunctionalRouteBuilder; }

对应的入口实现在 functional/index.ts 中,仅有十几行代码,直接委托给 route builder:

export function defineCrudRoutes<T = any>( options: CrudOptions | FunctionalCrudOptions ): FunctionalCrudRouteFactory<T> { return buildFunctionalCrudRoutes<T>(options); }

这体现了规格中「系统不要求用户学习另一套与defineApi()并列的函数式注册协议」的要求:工厂函数本身不注册任何东西,只有被调用(传入api)后才产出路由 map。


三、与defineApi()协同:CRUD 与自定义业务路由并存

3.1 基本用法

函数式 CRUD 的典型用法是在defineApi('/prefix', api => ({ ... }))中展开defineCrudRoutes()的结果,并让 CRUD 默认路由与同一defineApi()中的自定义业务路由并存:

import { defineApi } from '@midwayjs/hooks'; // 函数式路由入口 import { defineCrudRoutes } from '@midwayjs/crud/functional'; import { User } from './entity/user'; import { UserService } from './service/user'; export default defineApi('/api/user', api => { // 展开 CRUD 默认路由 const crudRoutes = defineCrudRoutes({ model: User, service: UserService, }); return { ...crudRoutes(api), // 自定义业务路由与 CRUD 默认路由并存 resetPassword: api.post('/:id/reset-password').handle(async ({ params }) => { // 自定义逻辑 return { ok: true }; }), }; });

3.2 测试用例佐证

仓库中的 functional.test.ts 直接验证了这一协同场景:构造一个模拟apibuilder,展开crudRoutes(api)后追加自定义的resetPassword路由,断言最终路由集合为['list', 'detail', 'create', 'update', 'delete', 'resetPassword'],且自定义路由的method为post、path为/:id/reset-password。这正是规格中「用户可在同一函数式路由对象中组合标准 CRUD 与非标准动作」的落地验证。

3.3 组合原理:route map 合并

crudRoutes(api)返回的是以路由名为 key 的 route map(list、detail、create、update、delete),因此在对象字面量中通过...crudRoutes(api)展开即可与手写路由合并。整个流程中用户没有接触任何独立的路由注册中心——函数式 CRUD 的结果直接依附现有 functional routing 生命周期,满足规格中「系统不要求为函数式 CRUD 建立独立的路由注册中心」的要求。


四、共享 CRUD Core:函数式与 class-based 复用同一套核心

规格强调:函数式 CRUD 与 class-based CRUD 必须复用同一套 CRUD core,而不是复制平行实现。源码验证了这一设计——functional/routeBuilder.ts 中的createFunctionalCrudRouteMap()直接复用了 class-based 路线上的两个关键函数:

  • buildCrudRoutes(options):生成默认路由表(来自 routeBuilder.ts);
  • createCrudRouteHandler(route.name, { [CRUD_SERVICE_KEY]: service }, options):创建运行时 handler,转发到绑定的 CRUD service。
for (const route of buildCrudRoutes(options)) { // ... routes[route.name] = builder.handle(async ({ input, ctx }: any) => { const service = await requestContext.getAsync(options.service as any); return createCrudRouteHandler(route.name, { [CRUD_SERVICE_KEY]: service }, options)({ params: input?.params ?? ctx?.params ?? {}, query: input?.query ?? ctx?.query ?? {}, body: input?.body ?? ctx?.request?.body, ctx, }); }); }

这意味着无论用户使用@Crud()还是defineCrudRoutes(),两种暴露方式最终都调用同一套CrudService<T>契约(list、findOne、create、update、delete),默认的查询协议、DTO 绑定、错误语义和删除策略保持一致——这正是规格中「共享同一 CRUD service 语义」的实现依据。

4.1 service 的获取方式

在函数式场景中,options.service是一个 class 构造器(例如UserService),运行时通过请求上下文的 IoC 容器解析得到实例:

const requestContext = ctx?.requestContext; if (!requestContext?.getAsync || !options.service) { throw new CrudConfigError( 'Functional CRUD routes require ctx.requestContext and options.service' ); } const service = await requestContext.getAsync(options.service as any);

如果缺少ctx.requestContext或options.service,系统会直接抛出CrudConfigError,而不是静默失败——规格与测试(functional.test.ts中handler({ ctx: {} })断言抛出CrudConfigError)均验证了这一行为。


五、完整配置项:复用CrudOptions

规格要求函数式 CRUD 尽量复用 class-based 的CrudOptions配置,仅在确有必要时增加少量扩展字段。当前实现中FunctionalCrudOptions = CrudOptions(见 interface.ts),即函数式场景没有引入额外配置字段。完整配置结构如下:

export interface CrudOptions { model: new (...args: any[]) => any; // 实体模型 service?: new (...args: any[]) => CrudServiceAdapter<any>; // CRUD service id?: string; // 主键字段名,默认 'id' dto?: { create?: new (...args: any[]) => any; // 创建请求体 DTO update?: new (...args: any[]) => any; // 更新请求体 DTO replace?: new (...args: any[]) => any; // 整体替换 DTO query?: new (...args: any[]) => any; // 查询 DTO }; routes?: { only?: CrudRouteName[]; // 只保留指定路由 exclude?: CrudRouteName[]; // 排除指定路由 overrides?: Partial<Record<CrudRouteName, CrudRouteOverride>>; }; query?: { maxLimit?: number; // 分页上限 defaultLimit?: number; // 默认分页大小 sortable?: string[]; // 可排序字段白名单 filterable?: string[]; // 可过滤字段白名单 searchable?: string[]; // 可搜索字段白名单 join?: string[]; // 可关联展开白名单 defaultSort?: CrudSort[]; // 默认排序 }; serialize?: { get?: new (...args: any[]) => any; // 详情响应序列化 list?: new (...args: any[]) => any; // 列表响应序列化 create?: new (...args: any[]) => any; update?: new (...args: any[]) => any; }; delete?: { mode?: 'hard' | 'soft'; // 删除策略 }; }

一个贴合实际的示例:

defineCrudRoutes({ model: User, service: UserService, query: { defaultLimit: 10, maxLimit: 100, sortable: ['createdAt', 'name'], filterable: ['status', 'role'], searchable: ['name', 'email'], defaultSort: [{ field: 'createdAt', order: 'DESC' }], }, delete: { mode: 'soft' }, })

六、默认路由矩阵与路由裁剪

6.1 稳定的默认路由矩阵

routeBuilder.ts 中定义了完整的默认路由表:

const DEFAULT_ROUTE_DEFINITIONS: Record<CrudRouteName, CrudRouteDefinition> = { list: { name: 'list', method: 'GET', path: '/' }, detail: { name: 'detail', method: 'GET', path: '/:id' }, create: { name: 'create', method: 'POST', path: '/' }, update: { name: 'update', method: 'PATCH', path: '/:id' }, replace: { name: 'replace', method: 'PUT', path: '/:id' }, delete: { name: 'delete', method: 'DELETE', path: '/:id' }, createMany: { name: 'createMany', method: 'POST', path: '/bulk' }, deleteMany: { name: 'deleteMany', method: 'DELETE', path: '/bulk' }, };

其中getEnabledCrudRoutes()定义了默认启用集为list / detail / create / update / delete五个资源路由,HTTP 方法与路径分别映射为GET /、GET /:id、POST /、PATCH /:id、DELETE /:id。首阶段仅支持单一路径参数:id(单主键优先),不要求支持复合主键路由模板。

6.2 通过配置裁剪

routes.only与routes.exclude用于限制可用路由:指定only时只注册列表中的路由,否则使用默认五路由集合并剔除exclude中列出的路由。被排除的默认路由不再暴露 HTTP 入口。

// 只暴露只读接口 defineCrudRoutes({ model: User, service: UserService, routes: { only: ['list', 'detail'] }, });

6.3 运行时 handler 的分发逻辑

createCrudRouteHandler()是函数式与 class-based 共用的运行时核心,按路由名分发到 service 方法:

  • list:先做校验,parseCrudQuery(payload.query, options)解析查询参数后调用service.list();
  • detail:parseCrudId()解析:id后调用service.findOne(),实体不存在时抛出CrudNotFoundError(404);
  • create/update/replace:校验后分别调用对应 service 方法;
  • delete:直接调用service.delete()。

由于该 handler 同时被createCrudControllerMethod()(class-based 路径)与函数式 CRUD 复用,两种风格在请求处理层的语义天然一致。


七、统一查询协议:分页、排序、过滤与关联

7.1CrudQuery与稳定分页结构

客户端对列表接口传入page、limit、sort、filter、search、join、fields等参数后,系统解析为统一的CrudQuery:

export interface CrudQuery { page: number; limit: number; sort: CrudSort[]; filters: CrudFilter[]; search?: string; joins?: string[]; fields?: string[]; }

列表结果采用稳定的分页对象而非裸数组:CrudPageResult<T>包含data与meta,其中meta至少包含page、limit、total、pageCount、hasNext、hasPrev六个字段(详见 interface.ts 中CrudPageMeta定义)。limit行为受资源声明的defaultLimit与maxLimit约束。

7.2 filter operator 白名单

首阶段仅支持 8 种过滤操作符,对未支持的 operator 返回 400 错误:

export type CrudFilterOperator = | 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'like';

7.3 URL 参数格式约定

  • sort、filter、join使用重复 query key表达多值;
  • fields使用逗号分隔字符串表达字段集合;
  • 不要求深层嵌套对象 query 语法;
  • 首阶段join仅支持一层关系名,包含.的多层路径返回 400 错误;
  • search采用固定 OR 模糊匹配语义,每个字段的基础匹配与likeoperator 一致;若资源未声明searchable,传入search直接返回 400 错误,不会忽略参数继续执行。

7.4 白名单与非法片段均返回 400

未在白名单中的sort、filter或join字段,以及格式非法的sort/filter片段,系统均返回 400 错误,且错误信息明确指出被拒绝的字段与原因,不会静默忽略。这在 query.test.ts 等测试中均有覆盖。


八、DTO 校验、序列化与 Swagger

8.1 DTO 驱动的校验

dto.create与dto.update分别驱动POST与PATCH路由的请求体验证,校验失败行为与现有 validation 组件保持一致;dto.query绑定列表查询。更新 DTO 可通过PartialDto()等派生工具复用创建 DTO 的元数据,无需手写重复校验规则。

默认路由的 DTO 绑定规则固定为:create绑定dto.create、update绑定dto.update、list绑定dto.query。响应形态约定为:list返回分页对象,detail/create/update返回单资源对象,delete返回空响应。校验逻辑实现在 validation.ts。

8.2 响应序列化

声明serialize配置后,CRUD 路由响应按对应 DTO 或序列化模型输出;未声明时保持与现有 handler 返回值一致的默认序列化行为。

8.3 Swagger 可见性

启用@midwayjs/swagger时,自动生成的 CRUD 路由会被纳入 Swagger 文档,可区分列表、详情、创建、更新、删除等操作;声明 query 规则和 DTO 后,文档中包含对应的 query 参数、路径参数和请求体模型,且文档模型与实际运行时约束保持一致(相关实现见 swagger.ts,测试覆盖见 validation-swagger.test.ts)。


九、可预测的错误语义

自动生成的 CRUD 路由提供统一、可预测的错误语义:

场景响应
详情、更新或删除访问不存在的资源主键404(CrudNotFoundError)
非法分页、排序、过滤或 join 参数400,错误载荷包含字段级原因
底层 ORM/数据库抛出可识别的约束异常通过适配层映射为稳定的上层异常,不直接暴露底层驱动细节
函数式场景缺少ctx.requestContext或options.service启动/调用阶段直接抛出CrudConfigError

错误类型定义见 error.ts。


十、扩展点与当前边界

10.1 服务层按方法粒度覆写

用户可继承官方 CRUD service 基类(如TypeOrmCrudService<T>、SequelizeCrudService<T>、MongooseCrudService<T>)并覆写单个方法(如create),该资源只替换对应数据访问逻辑,其他未覆写方法继续使用默认 CRUD 行为。业务层也可以在普通 Service 中组合 CRUD service 与其他领域服务,构建非标准资源流程。

10.2 删除策略

TypeORM 适配器默认执行硬删除;若需软删除,通过delete.mode = 'soft'显式开启,此时默认list与detail查询不再返回已软删数据。若底层 ORM 适配器或实体不具备软删除能力,系统返回明确错误而非静默降级为硬删除。

10.3 鉴权与中间件

CRUD 路由上可继续挂载现有 Guard、Middleware 或其他 Web 装饰器,自动生成的 CRUD 路由仍参与现有请求处理链,不要求用户改用独立的鉴权模型。

10.4 函数式特有的 fallback builder

functional/routeBuilder.ts 中还实现了一个createFallbackBuilder():当传入的api对象缺少某个 HTTP method 方法(如api.post不存在)时,自动构造一个最小 builder 兜底,保证 route map 仍能产出带method、path、handler的路由定义。这一细节使defineCrudRoutes()对自定义或简化的api实现具备更好的兼容性(测试见functional.test.ts中的 fallback 用例)。

10.5 当前边界

  • 首阶段默认路由仅支持单主键:id,不要求支持复合主键路由模板;
  • 首阶段join仅支持一层关系;
  • 其他 ORM(如 MikroORM、Leoric)通过实现相同 CRUD service 契约接入,现有 CRUD API 无需改变(仓库中已存在mikro、mongoose、sequelize、typeorm四套适配器目录,见 packages/crud/src)。

十一、小结

defineCrudRoutes()是 Midway 函数式路由与 CRUD 组件之间的桥梁:

  • 它从@midwayjs/crud/functional导入,返回可被defineApi()消费的 route factory,最小化 API 面;
  • 它在运行时复用buildCrudRoutes()与createCrudRouteHandler(),与 class-based@Crud()共享同一套 CRUD core 语义;
  • 它完整继承CrudOptions配置体系,支持路由裁剪、查询白名单、DTO 校验、软删除与 Swagger 集成;
  • 它通过...crudRoutes(api)展开方式与自定义业务路由自然并存,让函数式风格下的资源接口开发保持与手写路由一致的体验。

对于希望在函数式路由风格下快速搭建标准 REST 资源接口的 Midway 开发者,defineCrudRoutes()提供了「零 class controller、零@Crud()」的轻量方案,且其行为与 class-based CRUD 完全对齐,切换风格无需重新学习一套协议。

  • 后端
  • 微服务
  • 云原生

【免费下载链接】midway

🍔 A Node.js Serverless Framework for front-end/full-stack developers. Build the application for next decade. Works on AWS, Alibaba Cloud, Tencent Cloud and traditional VM/Container. Super easy integrate with React and Vue. 🌈

项目地址:https://gitcode.com/gh_mirrors/mi/midway
点击查看免费下载
上一篇:从0到1部署Laguna-M.1-nvfp4:硬件要求、环境配置与常见问题解决
下一篇:终极指南:如何快速安装和管理Pock Touch Bar小部件

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/28 2:40:10

树莓派+Pixhawk:无人机自主巡航与视觉精准降落实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/28 2:37:26

Hi3516CV610平台YOLOv8全流程部署实战:从训练到板端优化

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/28 2:37:19

嵌入式OTA服务实战:从固件交付到商业化落地

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华