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 的 VALIDATORSvalidate(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。formatpattern指定正则。注意对象形式下若漏传pattern源码会直接抛FormatValidationErrorNo pattern for format validation而不是放行。lengthmin/max/equal/between[min, max]数组的简写四选一或组合message支持${name}、${min}、${max}插值。numericality值不是number类型会先抛TypeNumericalityValidationError所以它天然兼任必须是数字。选项integer、lessThan、lessThanOrEqual、greaterThan、greaterThanOrEqual、equal、otherThan、even、odd、positive、negativemessage可对lessThan、lessThanOrEqual、greaterThan、greaterThanOrEqual、equal、otherThan做插值。presence值不能是null/undefined。allowNull默认false、allowUndefined默认false、allowEmptyString默认true——默认配置下空字符串能通过 presence这是表单场景里最容易踩的坑。customwith传一个应抛错或无返回的函数。它捕获你抛出的任何异常优先使用你给的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追加进NOTawait 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 namephonePhone/Label TextField namephone / 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 调用时同样受益粒度也更好控制。两个核心 APIcache() 与 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 参数放进 conditionsexpires 与它平级 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没有更新。把会变的字段塞进 keyproduct-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。全局prefixcreateCache()里统一加前缀如alpha单键变成alpha-v1-product-...相当于全局一键失效所有缓存的兜底开关。用户维度推荐位这类个性化数据把用户唯一信息编进 keyrecommended-${context.currentUser.id}。前提是用户会反复访问展示相同内容的页面否则缓存可能不划算。纯过期兜底能接受一小时内的变化用户晚点看到就直接expires: 3600key 怎么起都不重要了。列名哈希模型本身变化加列时把字段名列表做 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.tsloggerRedwood logger 实例。默认null但想看到任何缓存行为反馈就必须传——命中记[Cache] HIT key ...未命中并写入记[Cache] MISS ..., SET N bytes出错记[Cache] Error GET/SET/DEL ...。注意有两处 logger一处包成 MemJS 期望的格式传给MemcachedClientRedis 同理让底层库能上报错误另一处传给 Redwood 的缓存代码本身。timeoutget/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永远不变其他字段更新后缓存却永不过期。这时在更新入口手动删 keyimport { 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()就会留下脏数据。更稳的准则是把失效条件编进 keyupdatedAt、版本号无论谁更新数据都会自然变化而不是追着每一个写入点补删除调用。只存字符串也能扩展缓存机制只能存字符串读写都要过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),仅供参考