Egg + TypeScript 项目接入 egg-mongoose:从类型声明到 CRUD 的完整教程
1. Egg TypeScript 项目里 egg-mongoose 到底解决什么问题如果你正在写 Egg TypeScript 的后端又想让 MongoDB 的读写像调用本地方法一样顺手那 egg-mongoose 就是那个把两者粘起来的插件。它做的事情很朴素在 Egg 启动时帮你建立 MongoDB 连接把连接实例挂到app.mongoose上再让app/model目录下的文件自动被加载成 Model。你不需要在每个 Service 里手动mongoose.connect也不用担心连接池被重复创建。适合谁适合已经用npm init egg --typets初始化过项目、手里有一个能连通的 MongoDB本地 Docker 或云端都行、并且希望 Model 和 Service 分层清晰的人。如果你还在用 JavaScript 写 Egg这篇的 TS 类型声明部分同样能帮你把this.ctx.model.User的提示补全只是写法要换成对应的.d.ts。我试过在一个订单系统里直接裸用 mongoose结果是每个 Service 都要 import 一次连接热重载时连接数还会涨。换成 egg-mongoose 后连接生命周期交给框架管Service 只关心查询逻辑代码量少了一截。下面按“初始化插件 → 补类型声明 → 定义 Model 与 Service → 本地 CRUD 验证”的顺序走一遍每一步都给可复制的配置。先明确一个检索词Egg TypeScript 集成 egg-mongoose 的完整配置与 CRUD 验证。这篇文章就是围绕它展开从config/plugin.ts到typings再到一次真实的本地请求。在开始之前确认三件事Node.js 建议 14.x 以上MongoDB 已运行本地mongodb://127.0.0.1:27017或云端连接串项目已用 TS 模板初始化。如果 MongoDB 还没起用 Docker 最快docker pull mongo:latest docker run -d --name egg-mongo -p 27017:27017 mongo:latest启动后用 Navicat 或mongosh能连上127.0.0.1:27017就说明服务正常。这一步别跳过后面连接失败十有八九是这里没起。2. 安装 egg-mongoose 并补齐 TypeScript 类型声明装插件本身一条命令npm i egg-mongoose --save但 TypeScript 项目里光装插件不够。Egg 的 TS 模板默认不认识app.mongoose、app.model、ctx.model这些属性编辑器会飘红。所以第二步是补类型声明。先启用插件。config/plugin.ts里加上// config/plugin.ts import { EggPlugin } from egg; const plugin: EggPlugin { mongoose: { enable: true, package: egg-mongoose, }, }; export default plugin;注意 TS 模板导出的是default不是module.exports。如果你从旧教程复制了module.exports {...}在 TS 项目里会报类型不匹配改成上面这样。接着配置连接。config/config.default.ts// config/config.default.ts import { EggAppConfig, EggAppInfo, PowerPartial } from egg; export default (appInfo: EggAppInfo) { const config {} as PowerPartialEggAppConfig; config.mongoose { client: { url: mongodb://127.0.0.1:27017/egg_mongoose_demo, options: { useNewUrlParser: true, useUnifiedTopology: true, }, }, }; return { ...config, }; };这里config.mongoose在默认类型里是没有的所以下一步必须补声明否则config.mongoose这行就报错。在项目根目录建typings/index.d.tsTS 模板通常已有typings目录写入// typings/index.d.ts import mongoose from mongoose; import { EggAppConfig } from egg; declare module egg { interface EggAppConfig { mongoose: { client?: { url: string; options?: mongoose.ConnectOptions; }; clients?: Recordstring, { url: string; options?: mongoose.ConnectOptions; }; }; } interface Application { mongoose: typeof mongoose; model: { User: mongoose.Modelany; Order: mongoose.Modelany; }; } interface Context { model: { User: mongoose.Modelany; Order: mongoose.Modelany; }; } }这段声明做了三件事让config.mongoose合法让app.mongoose有 mongoose 的类型让app.model.User和ctx.model.User在 Service/Controller 里有提示。Modelany里的any后面可以换成具体的接口类型比如IUser提示会更精确。如果你用多数据库配置改成clientsconfig.mongoose { clients: { db1: { url: mongodb://127.0.0.1:27017/db1, options: { useNewUrlParser: true, useUnifiedTopology: true }, }, db2: { url: mongodb://127.0.0.1:27017/db2, options: { useNewUrlParser: true, useUnifiedTopology: true }, }, }, };多库时 Model 里要用app.mongooseDB.get(db1)拿连接这个后面 Model 部分会写。补完声明后重启一次 TS 服务VS Code 里CtrlShiftP→ Restart TS Serverapp.mongoose的飘红应该消失。这一步是 TS 项目最容易卡住的地方很多人装完插件发现类型报错就以为插件不兼容其实只是声明没补。3. 定义 Model 与 Service 分层可复制的配置片段Egg 约定 Model 放app/modelService 放app/service。egg-mongoose 会自动加载app/model下的文件文件名即 Model 名首字母大写。先建用户模型app/model/user.ts// app/model/user.ts import { Application } from egg; export default (app: Application) { const mongoose app.mongoose; const Schema mongoose.Schema; const UserSchema new Schema({ username: { type: String, required: true, unique: true }, password: { type: String, required: true }, status: { type: Number, default: 1 }, createdAt: { type: Date, default: Date.now }, }); return mongoose.model(User, UserSchema, user); };第三个参数user指定集合名。不写的话 mongoose 会按users复数化容易和已有集合对不上建议显式写。订单模型app/model/order.ts// app/model/order.ts import { Application } from egg; export default (app: Application) { const mongoose app.mongoose; const Schema mongoose.Schema; const OrderSchema new Schema({ order_id: { type: String, required: true }, uid: { type: Number }, total_price: { type: Number }, createdAt: { type: Date, default: Date.now }, }); return mongoose.model(Order, OrderSchema, order); };多数据库场景下Model 要指定连接// app/model/user.ts多库版 import { Application } from egg; export default (app: Application) { const mongoose app.mongoose; const Schema mongoose.Schema; const conn app.mongooseDB.get(db1); const UserSchema new Schema({ username: { type: String, required: true }, password: { type: String, required: true }, }); return conn.model(User, UserSchema); };Service 层app/service/user.ts把 CRUD 收在这里// app/service/user.ts import { Service } from egg; export default class UserService extends Service { async getUserList(page 1, pageSize 10) { const skip (page - 1) * pageSize; return this.ctx.model.User.find({}) .sort({ createdAt: -1 }) .skip(skip) .limit(pageSize); } async addUser(userData: { username: string; password: string; status?: number }) { const user new this.ctx.model.User(userData); return user.save(); } async updateUser(id: string, updateData: Recordstring, unknown) { return this.ctx.model.User.updateOne({ _id: id }, { $set: updateData }); } async deleteUser(id: string) { return this.ctx.model.User.deleteOne({ _id: id }); } }Controller 层app/controller/user.ts// app/controller/user.ts import { Controller } from egg; export default class UserController extends Controller { async index() { const { ctx } this; const { page 1, pageSize 10 } ctx.query; const userList await ctx.service.user.getUserList(Number(page), Number(pageSize)); ctx.body { code: 200, data: userList, msg: 获取用户列表成功 }; } async create() { const { ctx } this; const result await ctx.service.user.addUser(ctx.request.body); ctx.body { code: 200, data: result, msg: 添加用户成功 }; } async update() { const { ctx } this; const { id } ctx.params; const result await ctx.service.user.updateUser(id, ctx.request.body); ctx.body { code: 200, data: result, msg: 更新用户成功 }; } async destroy() { const { ctx } this; const { id } ctx.params; const result await ctx.service.user.deleteUser(id); ctx.body { code: 200, data: result, msg: 删除用户成功 }; } }路由app/router.ts// app/router.ts import { Application } from egg; export default (app: Application) { const { router, controller } app; router.get(/users, controller.user.index); router.post(/users, controller.user.create); router.put(/users/:id, controller.user.update); router.delete(/users/:id, controller.user.destroy); };这里有个坑Egg 默认开启 CSRF 保护POST/PUT/DELETE 请求不带 token 会返回 403。开发阶段可以临时关掉在config/config.default.ts里加config.security { csrf: { enable: false, }, };生产环境别这么干应该走ctx.csrf拿 token 再带上。或者针对单条路由关router.post(/users, controller.user.create, { csrf: false });配置片段到这里就齐了config/plugin.ts启用插件config/config.default.ts配连接typings/index.d.ts补类型app/model定义 Schemaapp/service收 CRUDapp/router.ts暴露接口。这套结构的好处是 Controller 只做参数转发Service 只做数据操作Model 只做结构定义职责不混。4. 本地启动与 CRUD 请求验证类型提示是否真的生效配置写完启动项目npm run dev看到egg started on http://127.0.0.1:7001就说明连接和插件加载都过了。如果启动时报Cannot read property Schema of undefined说明app.mongoose没挂上回去检查config/plugin.ts是否 enable以及typings是否补了Application.mongoose。先测新增。用 curlcurl -X POST http://127.0.0.1:7001/users \ -H Content-Type: application/json \ -d {username:john_doe1,password:password1231,status:1}返回类似{ code: 200, data: { _id: 665f1a2b3c4d5e6f7a8b9c0d, username: john_doe1, password: password1231, status: 1, createdAt: 2024-06-04T10:00:00.000Z, __v: 0 }, msg: 添加用户成功 }拿到_id后测查询curl http://127.0.0.1:7001/users应该返回刚才那条记录。再测更新curl -X PUT http://127.0.0.1:7001/users/665f1a2b3c4d5e6f7a8b9c0d \ -H Content-Type: application/json \ -d {status:0}返回modifiedCount: 1说明更新成功。最后测删除curl -X DELETE http://127.0.0.1:7001/users/665f1a2b3c4d5e6f7a8b9c0d返回deletedCount: 1即删除成功。类型提示是否生效看两个地方在app/service/user.ts里敲this.ctx.model.User.时编辑器应该弹出find、findOne、updateOne等方法敲this.ctx.model.User.find({时参数类型应该是FilterQuery。如果没提示说明typings/index.d.ts没被 TS 识别检查tsconfig.json的include是否包含typings/**/*.d.ts。分页查询也顺手验一下curl http://127.0.0.1:7001/users?page1pageSize5Service 里的skip和limit会生效返回条数不超过 5。关联查询用aggregate的$lookup在 Controller 里直接调const orderResult await ctx.model.Order.aggregate([ { $lookup: { from: order_item, localField: order_id, foreignField: order_id, as: items, }, }, { $match: { total_price: { $gte: 90 } } }, ]);from写集合名不是 Model 名。$match放在$lookup后面可以过滤结果放在前面能减少关联数据量按场景选。到这里一次完整的本地 CRUD 请求就验证完了连接建立、Model 加载、Service 调用、类型提示、增删改查全部走通。5. 常见报错排查401、local proxy failed、reading choices 与 OAuth接入过程中最容易撞上的几类报错逐个说清楚。MongooseServerSelectionError: connect ECONNREFUSED 127.0.0.1:27017。这是连接被拒不是代码问题。先确认 MongoDB 容器在跑docker ps看egg-mongo是否 Up。如果容器在跑还报这个检查config.mongoose.client.url里的端口和容器映射端口是否一致。Docker 默认映射27017:27017如果你改过宿主机端口url 也要跟着改。Cannot read property Schema of undefined。app.mongoose没挂上。三个检查点config/plugin.ts里mongoose.enable是否为truepackage是否为egg-mongoosetypings/index.d.ts里是否声明了Application.mongoose。TS 项目里如果只补了Context.model没补Application.mongooseModel 文件里app.mongoose就是 undefined。401 Unauthorized / local proxy failed。如果你在 Service 里调用了外部 API比如模型服务401 通常是 Key 没带或带错。检查请求头里的Authorization是否为Bearer 你的KeyKey 是否过期。local proxy failed一般是本地代理配置和实际网络环境不匹配检查HTTP_PROXY/HTTPS_PROXY环境变量是否指向了一个不可用的地址清掉再试。这类外部调用建议把 Base URL、Key、Model ID 三件套写全缺一个都会 401。reading choices of undefined。这个报错常见于调用模型接口后解析响应体时response.choices为 undefined。原因通常是响应体不是预期的 JSON 结构可能是错误信息被当成正常响应返回了。排查方法先把原始响应console.log出来看status和body。如果是 4xx/5xx先解决状态码问题如果是 200 但结构不对检查请求的model参数是否拼错或者接口版本是否匹配。OAuth 相关报错。如果你用 OAuth 方式接入外部服务报invalid_grant或redirect_uri_mismatch检查回调地址是否和注册时填的一致token 是否已过期。OAuth 的 token 有有效期过期后要重新走授权流程不能复用旧 token。CSRF token missing。POST/PUT/DELETE 返回 403body 里提示missing csrf token。开发阶段在config.security.csrf.enable设为false或者请求时带上ctx.csrf返回的 token。生产环境必须带 token别关。Model 找不到this.ctx.model.User is not a constructor。app/model/user.ts文件名和mongoose.model(User, ...)的第一个参数要对应。文件名user.ts对应User如果写成users.ts但 model 名是User加载会失败。另外确认app/model目录在tsconfig.json的编译范围内。类型提示不生效。this.ctx.model.User没有方法提示。检查typings/index.d.ts是否在tsconfig.json的include里改完声明后重启 TS Server确认egg的版本和types/egg匹配。如果用的是egg-mongoose自带的类型可能和手写声明冲突以手写声明为准。多数据库时 model 挂错连接。app.mongooseDB.get(db1)返回 undefined说明config.mongoose.clients里没有db1或者拼写不一致。检查配置里的 key 和get的参数是否完全一致大小写敏感。这些报错里连接类和类型类占大多数。连接类看 Docker 和 url类型类看typings和tsconfig。把这两块理顺剩下的就是业务逻辑问题。6. 从本地验证到长期编码把接入流程固化下来本地 CRUD 跑通只是第一步。真正在项目里用还有几件事值得做。第一把typings/index.d.ts里的Modelany换成具体接口。比如定义IUserexport interface IUser { username: string; password: string; status: number; createdAt: Date; }然后model.User: mongoose.ModelIUser。这样find返回的文档就有字段提示updateOne的$set也会校验字段名写错字段编译期就能发现。第二Service 层加错误处理。现在addUser里user.save()如果 username 重复会抛E11000 duplicate key error直接冒到 Controller 会返回 500。在 Service 里 try-catch转成业务错误码async addUser(userData: { username: string; password: string }) { try { const user new this.ctx.model.User(userData); return await user.save(); } catch (err: any) { if (err.code 11000) { this.ctx.throw(409, 用户名已存在); } throw err; } }第三敏感信息走环境变量。config.mongoose.client.url里的密码不要硬编码用process.env.MONGO_URLurl: process.env.MONGO_URL || mongodb://127.0.0.1:27017/egg_mongoose_demo,本地开发用默认值线上注入环境变量。第四常用查询字段加索引。username已经unique: true自带唯一索引。如果经常按status过滤在 Schema 里加index: true或者用UserSchema.index({ status: 1 })。索引在数据量大时对查询速度影响明显。第五分页查询的skip在深分页时性能会下降。数据量上万后skip(10000).limit(10)会扫描前 10000 条。可以改用基于_id的游标分页find({ _id: { $gt: lastId } }).limit(10)性能稳定。如果你后续要接模型服务做 AI 能力比如给订单加智能分类外部调用的 Base URL、Key、Model ID 三件套要配全Key 走环境变量别写进代码。模型对话调试可以用网页版快速验证参数长期跑编码任务或 Agent 则适合用 Coding Plan 这类按量方案避免每次手动换 Key。接入文档里对配置项和错误码有更细的说明遇到本文没覆盖的报错可以去查。API Keys 管理页可以创建和轮换 Key建议给不同环境用不同的 Key方便排查和回收。整套流程走下来核心就三块插件启用和连接配置、类型声明补齐、Model/Service 分层。把这三块固化到项目模板里下次新项目直接复制十分钟就能跑通 CRUD。真正花时间的不是配置本身而是遇到报错时知道去哪查——连接问题看 Docker 和 url类型问题看 typings 和 tsconfig业务问题看 Service 的 try-catch。