# 单文件管理 typeDefresolver

如下,在单文件中定义 ObjectType 与其在 Query 以及 Mutation 中对应的查询。并把 typeDefresolver 集中管理。

// src/resolvers/Todo.ts
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 = 1
select id, name from todo where user_id = 2
select 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 3
select id, name from todo where user_id = 2 limit 3
select 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.tsObject.assign 操作。 dependencies 代表本项目所依赖的配置,如数据库,缓存以及用户服务等的配置,项目将会在 consul 上拉取依赖配置。

项目最终生成的配置为 AppConfig 标识。

// config/consul.ts
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
}

以下是相关代码

// src/directives/auth.ts
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 并赋值到响应头中。

# 用户角色验证

# 日志

graphqlsqlredis 以及一些重要信息(如 user) 添加日志,并设置标签

// lib/logger.ts
export const apiLogger = createLogger('api')
export const dbLogger = createLogger('db')
export const redisLogger = createLogger('redis')
export const logger = createLogger('common')

# 为日志添加 requestId (sessionId)

为日志添加 requestId 方便追踪 bug 以及检测性能问题

// lib/logger.ts
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:formatError
let code: string = _.get(error, 'extensions.code', 'Error')
let info: any
let 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 start
  • npm test
  • npm run dev

# 使用 CI 加强代码质量

Last Updated: 10/27/2019, 12:29:57 PM