单文件管理 typeDef 与 resolver
如下,在单文件中定义 ObjectType 与其在 Query 以及 Mutation 中对应的查询。并把 typeDef 与 resolver 集中管理。
const typeDef = gql` type Todo @sql { id: ID! }
extend type Query { todos: [Todo!] }
extend type Mutation { createTodo: TODO! }`
const resolver: IResolverObject<any, AppContext> = { Todo: { user () {} }, Query: { todos () {} }, Mutation: { createTodo () {} }}按需取数据库字段
使用 @findOption 可以按需查询,并注入到 resolver 函数中的 info.attributes 字段
type Query { users: [User!] @findOption}
query USERS { users { id name }}function users ({}, {}, { models }, { attributes }: any) { return models.User.findAll({ attributes })}分页
对列表添加 page 以及 pageSize 参数来进行分页
type User { id: ID! todos ( page: Int = 1 pageSize: Int = 10 ): [Todo!] @findOption}
query TODOS { todos (page: 1, pageSize: 10) { id name }}数据库层解决 N+1 查询问题
使用 dataloader-sequelize 解决数据库查询的 batch 问题
当使用以下查询时,会出现 N+1 查询问题
{ users (page: 1, pageSize: 3) { id todos { id name } }}如果不做优化,生成的 SQL 如下
select id from users limit 3
select id, name from todo where user_id = 1select id, name from todo where user_id = 2select id, name from todo where user_id = 3而使用 dataloader 解决 N+1 问题后,会大大减少 SQL 语句的条数,生成的 SQL 如下
select id from users limit 3
select id, name, user_id from todo where user_id in (1, 2, 3)注意 Batch 请求后需要返回
user_id字段,为了重新分组
N+1 Query 优化后问题
当有如下所示多级分页查询时,N+1 优化失效,所以应避免多级分页操作
此处只能在客户端避免多层分页查询,而当有恶意查询时会加大服务器压力。可以使用以下的 Hash Query 避免此类问题,同时也在生产环境禁掉
introspection
{ users (page: 1, pageSize: 3) { id todos (page: 1, pageSize: 3) { id name } }}select id from users limit 3
select id, name from todo where user_id = 1 limit 3select id, name from todo where user_id = 2 limit 3select id, name from todo where user_id = 3 limit 3使用 DataLoader 解决 N+1 查询问题
使用 ID/Hash 代替 Query
TODO 需要客户端配合
当 Query 越来越大时,http 所传输的请求体积越来越大,严重影响应用的性能,此时可以把 Query 映射成 hash。
当请求体变小时,此时可以替代使用 GET 请求,方便缓存。
我发现掘金的 GraphQL Query 已由 ID 替代
使用 consul 管理配置
project 代表本项目在 consul 中对应的 key。项目将会拉取该 key 对应的配置并与本地的 config/project.ts 做 Object.assign 操作。
dependencies 代表本项目所依赖的配置,如数据库,缓存以及用户服务等的配置,项目将会在 consul 上拉取依赖配置。
项目最终生成的配置为 AppConfig 标识。
export const project = 'todo'export const dependencies = ['redis', 'pg']用户认证
使用 @auth 指令表示该资源受限,需要用户登录,roles 表示只有特定角色才能访问受限资源
directive @auth( # USER, ADMIN 可以自定义 roles: [String]) on FIELD_DEFINITION
type Query { authInfo: Int @auth}以下是相关代码
function visitFieldDefinition (field: GraphQLField<any, AppContext>) { const { resolve = defaultFieldResolver } = field const { roles } = this.args // const roles: UserRole[] = ['USER', 'ADMIN'] field.resolve = async (root, args, ctx, info) => { if (!ctx.user) { throw new AuthenticationError('Unauthorized') } if (roles && !roles.includes(ctx.user.role)) { throw new ForbiddenError('Forbidden') } return resolve.call(this, root, args, ctx, info) }}jwt 与白名单
jwt 与 token 更新
当用户认证成功时,检查其 token 有效期,如果剩余一半时间,则生成新的 token 并赋值到响应头中。
用户角色验证
日志
为 graphql,sql,redis 以及一些重要信息(如 user) 添加日志,并设置标签
export const apiLogger = createLogger('api')export const dbLogger = createLogger('db')export const redisLogger = createLogger('redis')export const logger = createLogger('common')为日志添加 requestId (sessionId)
为日志添加 requestId 方便追踪 bug 以及检测性能问题
const requestId = format((info) => { info.requestId = session.get('requestId') return info})使用 Async/Await 处理异常
结构化异常信息
结构化 API 异常信息,其中 extensions.code 代表异常错误码,方便调试以及前端使用。extensions.exception 代表原始异常,堆栈以及详细信息。注意在生产环境需要屏蔽掉 extensions.exception
$ curl 'https://todo.xiange.tech/graphql' -H 'Content-Type: application/json' --data-binary '{"query":"{\n dbError\n}"}'{ "errors": [ { "message": "column User.a does not exist", "locations": [ { "line": 2, "column": 3 } ], "path": [ "dbError" ], "extensions": { "code": "SequelizeDatabaseError", "exception": { "name": "SequelizeDatabaseError", "original": { "name": "error", "length": 104, "severity": "ERROR", "code": "42703", "position": "57", "file": "parse_relation.c", "line": "3293", "routine": "errorMissingColumn", "sql": "SELECT count(*) AS \"count\" FROM \"users\" AS \"User\" WHERE \"User\".\"a\" = 3;" }, "sql": "SELECT count(*) AS \"count\" FROM \"users\" AS \"User\" WHERE \"User\".\"a\" = 3;", "stacktrace": [ "SequelizeDatabaseError: column User.a does not exist", " at Query.formatError (/code/node_modules/sequelize/lib/dialects/postgres/query.js:354:16)", ] } } } ], "data": { "dbError": null }}在生产环境屏蔽掉异常堆栈
避免把原始异常以及堆栈信息暴露在生产环境
{ "errors": [ { "message": "column User.a does not exist", "locations": [ { "line": 2, "column": 3 } ], "path": [ "dbError" ], "extensions": { "code": "SequelizeDatabaseError" } } ], "data": { "dbError": null }}返回合适的状态码
异常报警
根据异常的 code 对异常进行严重等级分类,并上报监控系统。这里监控系统采用的 sentry
// lib/error.ts:formatErrorlet code: string = _.get(error, 'extensions.code', 'Error')let info: anylet level = Severity.Error
if (isAxiosError(originalError)) { code = `Request${originalError.code}`} else if (isJoiValidationError(originalError)) { code = 'JoiValidationError' info = originalError.details} else if (isSequelizeError(originalError)) { code = originalError.name if (isUniqueConstraintError(originalError)) { info = originalError.fields level = Severity.Warning }} else if (isApolloError(originalError)){ level = originalError.level || Severity.Warning} else if (isError(originalError)) { code = _.get(originalError, 'code', originalError.name) level = Severity.Fatal}
Sentry.withScope(scope => { scope.setTag('code', code) scope.setLevel(level) scope.setExtras(formatError) Sentry.captureException(originalError || error)})健康检查
在 k8s 上根据健康检查监控应用状态,当应用发生异常时可以及时响应并解决
$ curl http://todo.xiange.tech/.well-known/apollo/server-health{"status":"pass"}filebeat & ELK
通过 filebeat 把日志文件发送到 elk 日志系统,方便日后分析以及辅助 debug
监控
在日志系统中监控 SQL 慢查询以及耗时 API 的日志,并实时邮件通知 (可以考虑钉钉)
参数校验
使用 Joi 做参数校验
function createUser ({}, { name, email, password }, { models, utils }) { Joi.assert(email, Joi.string().email())}
function createTodo ({}, { todo }, { models, utils }) { Joi.validate(todo, Joi.object().keys({ name: Joi.string().min(1), }))}服务端渲染
npm scripts
npm startnpm testnpm run dev