Redwood Service 层实战:业务逻辑、Service Validations 校验与 LRU 缓存全解
Redwood Service 层实战:业务逻辑、Service Validations 校验与 LRU 缓存全解
【免费下载链接】redwoodRedwoodGraphQL项目地址: https://gitcode.com/gh_mirrors/re/redwood
业务逻辑散落在 resolver、组件和定时任务里,往往是 Redwood 项目腐化的起点。本文带你把校验、复用、缓存三件事一次性收敛进 Redwood 的 Service 层:读完后你能独立写出带字段级校验、可被多种入口复用、且缓存可安全失效的 Service。
场景引入:为什么业务逻辑必须住进 Service
客户端代码可以随便改
给电商系统加一张"满 100 减 20"的优惠券。最省事的写法是在商品页里判断order.total > 100就自动减掉 20。但只要有人用 DevTools 把总价改成 1,或者干脆绕过界面直接构造请求,这个判断就形同虚设。客户端代码对全世界可见、可改,任何"只在前端判一遍"的规则都不算规则——校验与落库必须在 Service 里再走一遍完整流程。
一个请求的完整路线
默认情况下客户端通过 GraphQL 拿到数据。浏览器发请求,GraphQL 层把它交给对应的 resolver 执行——而在 Redwood 里,这些 resolver 就是你在api/src/services里写的 Service 函数:
Browser → GraphQL → Service
上图展示了数据在组件、GraphQL 层与后端之间流转的整体思路,理解这条链路是后面所有讨论的前提。
它就是个普通函数
Service 本质上是普通函数。这意味着它既能当 GraphQL resolver,也能被别的 Service、serverless functions、api 侧任何地方 import 来用。
那能在 web 侧直接 import 吗?不能,原因有二:一是 Redwood 的构建流程目前不支持把 api 侧代码打进 web bundle;二是更根本的——Service 通常要读写数据库,而 Prisma 这类数据库客户端在浏览器里根本跑不起来。退一步讲,就算能跑,把执行 SQL 等价命令的能力交给一个随时可能被篡改的环境,等于把数据库钥匙挂在大门上。
Service 与 serverless 之间的复用
Service 可以互相调用。以"扣减库存"为例:下单流程要扣、订单取消要回补,这两个方向都依赖同一个库存函数——让扣减/回补逻辑只存在于这一处,所有需要它的地方都来复用,而不是各写一份。
Serverless functions 也可以直接 import Service 来干活。容易混淆的是它们也叫 functions,但设计目标是运行在 serverless 环境里:代码执行完任务就被关闭。值得一提的是,你的 GraphQL 端点本身就是一个 serverless function,所以"GraphQL 能调 Service"本质上和"serverless function 调 Service"是同一件事,第三方 webhook 触发的回调、定时任务同样可以复用同一份业务实现。
用 Service Validations 给数据入库前把关
先分清它和 Validator Directives 的分工
Redwood 里有两个容易混淆的校验机制。Validator Directives 从 v0.37 引入,回答的问题是"这个登录用户有没有资格读/改这条数据",管的是访问权;Service Validations 运行在 GraphQL 之外的 Service 层,回答的是"这份数据本身格式对不对",最常见的位置是写库之前。
两者天然互补:Directive 把住"谁可以碰",Service Validations 把住"碰的数据长什么样"。
validate() 做字段级静态校验
validate()是静态校验的主入口,从@redwoodjs/api导入(validations.ts 中实际导出validate、validateWithSync、validateWith、validateUniqueness四个函数,另有一组继承自ServiceValidationError的专用错误类)。它有二参数与三参数两种形式:第一个参数永远是被校验的值,最后一个参数是规则对象;可选的第二个参数是字段名,用于生成默认错误消息。
// 二参数形式:validate(value, validations) validate(input.coupon, { absence: { message: 'Coupon code must be empty' }, }) // 三参数形式:validate(value, name, validations) validate(input.phone, 'Phone Number', { presence: true, length: { min: 2, max: 255 }, })要点:规则对象里可以混用"简单形式"和"带 message 的嵌套形式";只要部分规则有自定义消息,就必须用三参数形式——没写 message 的规则要靠字段名生成默认消息。
下面 10 个校验器是规则对象的全部合法键,源码实现集中在 validations.ts 的 VALIDATORS:
validate(input.coupon, 'Coupon', { absence: true }) // 必须不存在(null/undefined) validate(input.subscribe, 'Subscribe', { acceptance: true }) // 必须被接受 validate(input.email, 'Email', { email: true }) // 邮箱格式 validate(input.level, 'Level', { exclusion: ['vip'] }) // 不能等于列表中任一项 validate(input.ticket, 'Ticket', { format: /^[A-Z]{4}-\d{4}$/ }) // 匹配正则 validate(input.channel, 'Channel', { inclusion: ['web', 'app'] }) // 必须等于列表中某一项 validate(input.memo, 'Memo', { length: { between: [2, 100] } }) // 字符串长度 validate(input.price, 'Price', { numericality: { positive: true } }) // 数字条件 validate(input.sku, 'SKU', { presence: true }) // 必须存在 validate(input.price, 'Price', { custom: { with: () => { /* throw */ } } })各校验器的关键选项与默认值:
- absence:值必须是
null或undefined。选项allowEmptyString(默认false):设为true时空字符串也算"不存在";message自定义失败消息。 - acceptance:值必须是
true,或落在in数组里。简单形式(true)下允许列表默认只有[true];in可扩展为[true, 'true', 1, '1'];message自定义。 - email:用宽松正则
/^[^@\s]+@[^.\s]+\.[^\s]+$/比对,大致要求"非空白非@字符 +@+ 非空白非.字符 +.+ 非空白结尾"。官方完整邮箱正则有 6300 字符左右,这里够用;有偏好请改用format。 - exclusion:值不能等于
in列表中任何一项。选项caseSensitive(默认true,为false时列表与值统一toLowerCase()再比对)、message。 - inclusion:与 exclusion 相反,值必须等于
in中某一项;同样支持caseSensitive(默认true)与message。 - format:
pattern指定正则。注意:对象形式下若漏传pattern,源码会直接抛FormatValidationError("No pattern for format validation"),而不是放行。 - length:
min/max/equal/between([min, max]数组的简写)四选一或组合;message支持${name}、${min}、${max}插值。 - numericality:值不是
number类型会先抛TypeNumericalityValidationError,所以它天然兼任"必须是数字"。选项:integer、lessThan、lessThanOrEqual、greaterThan、greaterThanOrEqual、equal、otherThan、even、odd、positive、negative;message可对lessThan、lessThanOrEqual、greaterThan、greaterThanOrEqual、equal、otherThan做插值。 - presence:值不能是
null/undefined。allowNull(默认false)、allowUndefined(默认false)、allowEmptyString(默认true)——默认配置下空字符串能通过 presence,这是表单场景里最容易踩的坑。 - custom:
with传一个"应抛错或无返回"的函数。它捕获你抛出的任何异常,优先使用你给的message,否则用异常对象的.message或抛出的字符串本身。
💡 提示:插值消息里出现${name}、${min}这类占位符时,务必用单引号或双引号的普通字符串,不能用反引号——模板字符串会在赋值时就把占位符插掉,而那时min、max还没准备好。
一个把多种规则混在一起的例子,带消息与不带消息的规则并存,所以采用三参数形式:
import { validate } from '@redwoodjs/api' export const createOrder = ({ input }) => { validate(input.receiver, 'Receiver', { presence: { allowEmptyString: false }, length: { between: [2, 30] }, exclusion: { in: ['Anonymous'], message: 'Please enter a real name', }, }) // ...业务逻辑 }要点:exclusion展示了嵌套对象形式,其余规则靠'Receiver'这个字段名生成默认消息。
validateWithSync() 与 validateWith():动态校验
有些校验依赖登录态或数据库,写不成静态规则。validateWithSync()接收一个函数,函数内有问题就抛错,否则什么都不做:
import { validateWithSync, validateWith } from '@redwoodjs/api' export const createOrder = async ({ input, context }) => { validateWithSync(() => { if (input.channel === 'vip' && !context.currentUser.roles.includes('admin')) { throw new Error('Only admins can create VIP channel orders') } }) await validateWith(async () => { const left = await db.stock.findUnique({ where: { sku: input.sku }, select: { stock: true }, }) if (left.stock < input.quantity) { throw `Not enough stock for ${input.sku}` } }) }要点:validateWithSync处理同步逻辑;validateWith支持 async 函数,调用时必须await。两者都会捕获内部抛出的任意异常(字符串或Error),重新包装为ServiceValidationError再抛出。
这一步是整个机制的关键。自己随手抛的普通错误穿过 GraphQL 时会因为安全考虑被隐藏(消息可能泄露源码等敏感信息),客户端只能看到 "Something went wrong";而Service Validations 抛出的错误在白名单里,会被视为"安全错误"原样透传给浏览器,前端才能展示给用户。
validateUniqueness():在事务里完成「查重 + 写入」
参数共四个:
- 模型访问器名字——即你在 Prisma 上调用
db.xxx时的那个xxx(如'order'); - 要检查唯一性的字段/值对象,如
{ code: input.code },还可以携带下面两个特殊键; - (可选)选项对象:
message自定义失败消息、db指定事务与回调使用的 PrismaClient 实例; - 确认唯一后执行的回调,收到一个事务
PrismaClient。
行为等价于下面这段事务代码($scope追加进AND,$self追加进NOT):
await db.$transaction(async (tx) => { const found = await tx.order.findFirst({ where: { AND: [{ code: input.code }, ...scope], NOT: [self] }, }) if (found) { throw new ServiceValidationError('code must be unique') } return tx.order.create({ data: input }) })基本用法——下单前确保邀请码没被占用:
import { validateUniqueness } from '@redwoodjs/api' export const createOrder = ({ input }) => { return validateUniqueness('order', { code: input.code }, (db) => { return db.order.create({ data: input }) }) }传第三个参数选项时可以自定义消息或指定数据库实例:
return validateUniqueness( 'order', { code: input.code }, { message: 'This invite code is already used' }, (db) => db.order.create({ data: input }), )💡 提示:如果你在src/lib/db里覆盖了DATABASE_URL实例化 PrismaClient,就必须通过{ db }传进去;否则函数内部会用原生new PrismaClient()执行回调,绕开你 schema 之外的任何自定义配置。
必须return回调的执行结果以及外层validateUniqueness()调用本身,否则 GraphQL resolver 拿不到任何东西返回给客户端。
$self——更新时排除自己。默认用法下,更新操作会撞上"数据库里已经存在这条记录"而误报不唯一——占用的正是它自己。此时传入$self标识当前记录,检查会将其排除:
export const updateOrder = (id, input) => { return validateUniqueness( 'order', { code: input.code, $self: { id } }, (db) => db.order.update({ where: { id }, data: input }), ) }$scope——缩小检查范围。有时唯一性只需要在子集内成立:同一公司内订单备注唯一、不同公司之间可以重名。用$scope追加限定条件:
return validateUniqueness( 'order', { memo: input.memo, $scope: { companyId: context.currentUser.companyId } }, (db) => db.order.create({ data: input }), )💡 提示:数据库已有 UNIQUE INDEX 为什么还要它?一是你可能改不动遗留 schema;二是理念问题——数据校验是业务逻辑,应该住在 Service 里;三是 Prisma 撞唯一索引抛出的错误会被 GraphQL 吞掉、无法报告给用户,而validateUniqueness()的异常在白名单内。
前端如何展示校验错误
用 scaffold 生成的页面会在保存失败时自动展示对应错误。手写页面则解构useMutation()的error自行渲染,FormError 这类 form helpers 会分担一部分工作:
import { Form, FormError, Label, TextField, Submit } from '@redwoodjs/forms' import { useMutation } from '@redwoodjs/web' const CREATE_ORDER = gql` mutation CreateOrderMutation($input: OrderInput!) { createOrder(input: $input) { id } } ` export const OrderPage = () => { const [create, { loading, error }] = useMutation(CREATE_ORDER) return ( <Form onSubmit={(data) => create({ variables: { input: data } })}> <FormError error={error} /> <Label name="phone">Phone</Label> <TextField name="phone" /> <Submit disabled={loading}>Submit</Submit> </Form> ) }能按字段定位错误,背后是 errors.ts 的 ServiceValidationError:它把消息构造成 Apollo Server 风格的BAD_USER_INPUT错误码,并附上properties.messages扩展,FormError据此在对应输入框下方渲染消息。
给 Service 加一层 LRU 缓存
先判断值不值得缓存
缓存的本质是一大块键/值存储:拿唯一的 key 去换 value。LRU(最近最少使用)策略意味着你不用手动过期或更新——找到 key 就读已有值,找不到就执行计算再写入;缓存涨到内存/磁盘上限时,软件自己淘汰最旧、最少用的条目腾空间。数据变化频繁的东西别缓存;计算开销大且结果低频变化的 Service(比如综合销量、停留时长、社交热度算出的"热门商品榜",聚合可能耗时数秒)才是理想候选。
💡 提示:GraphQL 层也能用 Envelop 插件做响应缓存,但缓存放 Service 层"更低",Service 互调、serverless function 调用时同样受益,粒度也更好控制。
两个核心 API:cache() 与 cacheFindMany()
createCache()返回四个成员:cache、cacheFindMany、deleteCacheKey,以及原始客户端cacheClient——需要批量写入等高级操作时可以直接拿它调底层库:
import { cacheClient } from 'src/lib/cache' await cacheClient.MSET(`order-${id}`, JSON.stringify(order), `receipt-${id}`, JSON.stringify(order))cache()手动缓存任意数据,key 可以是字符串或数组(数组按-拼接,见 formatCacheKey),第三个参数对象可传expires(过期秒数):
// 永久缓存 return cache('hot-products', () => db.product.findMany()) // 缓存 1 小时 return cache('hot-products', () => db.product.findMany(), { expires: 3600 }) // 数组 key return cache(['orders', orderId, updatedAt.getTime()], () => db.order.findUnique({ where: { id: orderId } }))要点:cache()返回 Promise,需要数据继续参与后续逻辑就await;仅作 resolver 时直接return即可。
cacheFindMany()专门缓存findMany()结果:只要集合中任何一条记录变化,缓存整体失效重建。实现上是先按你给的conditions执行一次findFirst(),以updatedAt降序取出最新记录,用它的id加updatedAt.getTime()拼成真正 key,再缓存完整查询;如果模型没有id/updatedAt字段(抛PrismaClientValidationError),或一条记录都查不到,就直接执行findMany()不缓存。
// 第三个参数把 where 等 findMany 参数放进 conditions,expires 与它平级 return cacheFindMany('customers', db.customer, { conditions: { where: { vip: true } }, expires: 86400, })要点:每次调用固定付出一次轻量findFirst()的代价,换取其余时间跳过昂贵的findMany();同样返回 Promise,按需await。
设计一个能正确失效的缓存 key
给缓存 key 起名,难就难在要回答:什么变了就必须换 key?演进路径值得走一遍。
- 只用主键:
41443。唯一了,但用户表里同样可能有个 41443——你以为取的是用户,拿到的是商品。 - 加类型前缀:
product-41443与user-41443不再撞号。但商品描述变了怎么办?缓存只有"读已有 key"或"建新 key",没有"更新"。 - 把会变的字段塞进 key:
product-41443-${description}之类。问题是 key 有长度上限(Memcached 约 250 字节)。整对象做哈希虽然可行,但代码难看且哈希计算本身有开销。 updatedAt方案(推荐基线):给每个模型加updatedAt列,Prisma 更新行时自动刷时间戳。key 写成product-${id}-${updatedAt.getTime()}——getTime()让时间戳变成干净的整数而非 Date 字符串。任何一列变化 key 就变,缓存自动失效。- 加版本号:
v1-product-${id}-${updatedAt}。UI 改版多展示了 SKU 字段、或模型加了新列不触发updatedAt时,把v1递增为v2即可手动刷新所有 key。 - 全局
prefix:createCache()里统一加前缀(如alpha),单键变成alpha-v1-product-...,相当于全局一键失效所有缓存的兜底开关。 - 用户维度:推荐位这类个性化数据把用户唯一信息编进 key:
recommended-${context.currentUser.id}。前提是用户会反复访问展示相同内容的页面,否则缓存可能不划算。 - 纯过期兜底:能接受"一小时内的变化用户晚点看到",就直接
expires: 3600,key 怎么起都不重要了。 - 列名哈希:模型本身变化(加列)时,把字段名列表做 MD5 编进 key,列增删哈希即变。代价是得先查一条记录才知道列名——按索引列取单条记录极快,通常远小于被缓存的计算开销。
💡 提示:key 可能对过多变化过敏——比如views浏览量字段一直在变,但用户界面根本不展示它,缓存就被反复打碎。可以把这类列拆到独立表里,让主表只保留你真正关心缓存的数据。
安装与配置
Redwood 提供 setup 命令(setup cache 命令),生成api/src/lib/cache.js并预置基本初始化,支持memcached与redis两种后端:
yarn rw setup cache memcached yarn rw setup cache redis生成的文件(memcached 版):
import { createCache, InMemoryClient, MemcachedClient } from '@redwoodjs/api/cache' import { logger } from './logger' const memJsFormattedLogger = { log: (msg: string) => logger.error(msg), } export let client: InMemoryClient | MemcachedClient if (process.env.NODE_ENV === 'test') { client = new InMemoryClient() } else { try { client = new MemcachedClient(process.env.CACHE_HOST, { logger: memJsFormattedLogger, }) } catch (e) { logger.error(`Could not connect to cache: ${e.message}`) } } export const { cache, cacheFindMany, cacheClient, deleteCacheKey } = createCache(client, { logger, timeout: 500, })要点:test 环境自动切到InMemoryClient,单元测试不需要真的起缓存服务器;模板里硬编码的主机地址,环境稳定后换成 ENV 变量。
createCache()接受四个选项(cache/index.ts):
logger:Redwood logger 实例。默认null,但想看到任何缓存行为反馈就必须传——命中记[Cache] HIT key '...',未命中并写入记[Cache] MISS '...', SET N bytes,出错记[Cache] Error GET/SET/DEL '...'。注意有两处 logger:一处包成 MemJS 期望的格式传给MemcachedClient(Redis 同理),让底层库能上报错误;另一处传给 Redwood 的缓存代码本身。timeout:get/set 等待缓存服务器响应的上限(毫秒),超时后放弃缓存、直接执行原函数返回结果。文档默认值写的是500,但源码实际是options?.timeout || 1000——未显式设置时为1000 毫秒,setup 模板则显式传了500。prefix:全局 key 前缀,默认null。已带相同前缀的 key 不会被重复添加(源码会先匹配^prefix-再决定拼接)。fields:模型不用id/updatedAt这两个名字时映射真实字段,如fields: { id: 'post_id', updatedAt: 'updated_at' };只改一个名字也必须两个属性同时给。默认{ id: 'id', updatedAt: 'updatedAt' }。
deleteCacheKey() 显式失效
有些 key 天然无法自动失效:只按用户 id 缓存用户资料,id永远不变,其他字段更新后缓存却永不过期。这时在更新入口手动删 key:
import { cache, deleteCacheKey } from 'src/lib/cache' export const user = ({ id }) => { return cache(`user-${id}`, () => db.user.findUnique({ where: { id } })) } export const updateUser = async ({ id, input }) => { await deleteCacheKey(`user-${id}`) return db.user.update({ where: { id }, data: input }) }警告:显式删 key 容易踩坑。如果另一个 Service 也在更新用户,或者还有个 Service 同时刷新了组织及其全部子用户,那些入口没调deleteCacheKey()就会留下脏数据。更稳的准则是把失效条件编进 key(updatedAt、版本号),无论谁更新数据都会自然变化,而不是追着每一个写入点补删除调用。
只存字符串,也能扩展
缓存机制只能存字符串,读写都要过JSON.parse(JSON.stringify(...))的往返(serialize 实现)。后果:真正的Date实例回来时是"2022-08-24T17:50:05.679Z"这样的字符串,需要自行重建;函数无法序列化,缓存不了;二进制 BLOB 有损坏风险,慎用。大多数 Prisma 查询结果可以正常往返,留意日期和二进制字段即可。
Memcached 或 Redis 都不合适时,可以继承 BaseClient 自己写客户端:构造函数接收你需要的参数,实现三个抽象方法——get(key)(命中返回数据、否则null)、set(key, value, { expires })(value此时是 JS 对象,存之前JSON.stringify())、del(key)(返回布尔),再按需实现connect()/disconnect()(Memcached 客户端在 GET 超时时会调disconnect()重连)。接口就这么多。
💡 提示:官方测试在 packages/api/src/cache/tests/ 覆盖了cache、cacheFindMany、deleteCacheKey与断连恢复等场景,InMemoryClient还额外提供contents、isCached等测试断言辅助方法,可照着装自己的用例。
带走一份 Checklist
- 业务逻辑、校验、缓存全部收敛进 Service 函数,一处实现多处复用;客户端传来的值一律视为不可信,落库前在 Service 复核。
- 写库入口开头先
validate()做静态字段校验(10 个校验器按规则对象选键);依赖登录态或数据库计数的逻辑用validateWithSync()/validateWith()(后者await)包起来,避免错误被 GraphQL 吞掉。 - "查重 + 写入"用
validateUniqueness()放进事务,return回调;更新现有记录传$self排除自己,子集内唯一传$scope,消息与自定义 PrismaClient 走第三个参数选项。 - 只有"计算开销大 + 结果低频变化"的 Service 才上缓存;key 编码失效条件:
updatedAt、版本号、全局prefix,必要时列名哈希。 createCache时传logger,否则 HIT/MISS 全无声;留意timeout文档值 500 与源码默认 1000 毫秒的差异,fields改字段名时必须成对提供。- 缓存内容要能过 JSON 往返:
Date取回来是字符串需重建,函数与二进制数据缓不了;key 用字符串或数组均可,数组按-拼接。 - 需要立刻失效就
deleteCacheKey(),但动手前确认所有更新入口都会调它;拿不准时优先把失效条件编进 key。
【免费下载链接】redwoodRedwoodGraphQL项目地址: https://gitcode.com/gh_mirrors/re/redwood
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考