第 8 章:Service 层的诞生(硬编码版)
问题
上一章统一错误体系跑通了,crud 里的 throw 全部换成了 BadRequest/Forbidden 等带状态码的错误类。现在回到横切需求的问题------controller 直接调 crud 三层跑通了,但写了几个业务模块后,四个横切需求砸过来:
- 软删除:查询自动加
deleted_at IS NULL,删除改为更新deleted_at = now() - 审计打戳:create 自动填
created_at/created_by,update 自动填updated_at/updated_by - 操作日志:写操作后异步记一条日志
- 数据权限:不同角色看到不同范围的数据
三个地方都试过了,都不行:
- Koa 中间件在 HTTP 层,拿不到 crud 内部的 where,改不了查询条件
- crud 里硬编码,crud 不再纯净,每张表的 crud 代码都不一样
- controller 里写,5 个接口 × N 张表 = 爆炸
那就在 crud 和 controller 之间插一层吧。
第一版:硬编码 service
先不管什么 enhancer、插拔、工厂。就是一个 service.js,里面硬编码三个横切逻辑:软删除、审计、操作日志。能跑就行。
js
function createService(crud) {
// ── 三个硬编码的横切函数 ──────────────────
// 软删除:读操作自动加 deleted_at: null
const applySoftDelete = async (where) => {
// 先用简单判断:表里有 deleted_at 列就自动加
const schema = await crud.getSchema()
if ("deleted_at" in schema.names) {
return { deleted_at: null, ...where }
}
return where
}
// 审计打戳:写操作自动填时间戳
const applyAudit = (data, action) => {
const now = new Date().toISOString()
if (action === "create") {
return { ...data, created_at: now, updated_at: now }
}
if (action === "update") {
return { ...data, updated_at: now }
}
return data
}
// 操作日志:写操作后异步记(先硬编码写 console.log)
const logOp = async (action, args) => {
console.log(`[OPLOG] ${action} on ${crud.table}:`, args)
}
// ── 对外方法 ──────────────────
return {
async find(opts = {}) {
const where = await applySoftDelete(opts)
return await crud.find(where)
},
async findOne(where, fields) {
const finalWhere = await applySoftDelete(where)
return await crud.findOne(finalWhere, fields)
},
async create(data) {
const finalData = applyAudit(data, "create")
const result = await crud.create(finalData)
await logOp("CREATE", [finalData])
return result
},
async update(where, data) {
const finalWhere = await applySoftDelete(where)
const finalData = applyAudit(data, "update")
const result = await crud.update(finalWhere, finalData)
await logOp("UPDATE", [finalWhere, finalData])
return result
},
async remove(where) {
// 软删除拦截:如果表里有 deleted_at,就改成 update
const schema = await crud.getSchema()
if ("deleted_at" in schema.names) {
const finalWhere = await applySoftDelete(where)
const result = await crud.update(finalWhere, {
deleted_at: new Date().toISOString()
})
await logOp("SOFT_REMOVE", [finalWhere])
return result
}
// 没有 deleted_at,物理删除
const finalWhere = await applySoftDelete(where)
const result = await crud.remove(finalWhere)
await logOp("REMOVE", [finalWhere])
return result
}
}
}
就这么多。没有设计模式,没有抽象层,就是三个硬编码的函数塞进 service 里。
controller 这时候改成调 service 而不是 crud:
js
function createController(service) {
return {
list: async (ctx) => {
ctx.body = { data: await service.find(ctx.query) }
},
detail: async (ctx) => {
ctx.body = { data: await service.findOne(ctx.params.id) }
},
create: async (ctx) => {
ctx.body = { data: await service.create(ctx.request.body) }
},
update: async (ctx) => {
ctx.body = { data: await service.update(ctx.params.id, ctx.request.body) }
},
remove: async (ctx) => {
ctx.body = { data: await service.remove(ctx.params.id) }
}
}
}
三层变成了:crud(纯净数据访问)→ service(横切编排)→ controller(HTTP 适配)。
跑通标志
js
const knex = require("knex")({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true })
async function main() {
await knex.schema.createTable("orders", (t) => {
t.increments("id").primary()
t.string("status")
t.dateTime("deleted_at")
t.dateTime("created_at")
t.dateTime("updated_at")
})
const crud = createCrud(knex, "orders")
const service = createService(crud)
// create 自动填审计时间
const o = await service.create({ status: "paid" })
console.log("created_at:", o.created_at) // 应该有值
// find 自动过滤 deleted_at
await crud.update({ id: o.id }, { deleted_at: new Date().toISOString() })
const list = await service.find()
console.log("软删过滤后数量:", list.length) // 0
// remove 被拦截成软删
const o2 = await service.create({ status: "shipped" })
await service.remove(o2.id)
const list2 = await crud.find({ deleted_at: null })
console.log("物理删数量:", list2.length) // 1(o 还在,o2 被软删了)
}
main()
跑通就说明 service 层的方向是对的。
跑通之后的痛点
现在 service 里硬编码了三个横切。但我马上发现了新问题:
- 每个表都要软删吗? 有些表(比如字典表)不需要软删除。但现在
applySoftDelete里虽然有 schema 探测,service 工厂没有开关让我关掉它。 - 每个表都要审计吗? 有些表是系统表,不需要
created_at。同理,没有开关。 - 数据权限还没加。这是最复杂的横切------要按当前用户的角色决定 WHERE 条件(行级)和 SELECT 字段(列级)。一旦加进去,service 文件会变得更长。
- 硬编码的横切顺序是固定的 。如果某天我想在软删之前加一个条件(比如租户隔离),就得改动
applySoftDelete的调用位置,或者再硬编码一个函数插进去。 - 审计要填
created_by(当前用户 ID) ,操作日志要记 IP。但 service 层里拿不到 Koa 的 ctx------service 是被 controller 调的,controller 有 ctx,但没往下传。
问题 1-4 指向同一个方向:横切逻辑应该可插拔、有接口约定、能编排顺序。下一章先解决这个,把硬编码改成 enhancer 插拔模式。
问题 5 更棘手:service 层在 Koa 中间件链里,ctx 怎么透传到 service 深处------这是插拔之后紧接着要解决的。