{"id":21863185,"url":"https://github.com/shfshanyue/apollo-server-starter","last_synced_at":"2025-07-25T09:03:41.504Z","repository":{"id":42232440,"uuid":"195134828","full_name":"shfshanyue/apollo-server-starter","owner":"shfshanyue","description":"使用 apollo-server 做一个脚手架","archived":false,"fork":false,"pushed_at":"2023-03-07T03:54:47.000Z","size":1133,"stargazers_count":54,"open_issues_count":10,"forks_count":11,"subscribers_count":2,"default_branch":"master","last_synced_at":"2025-03-28T08:11:14.730Z","etag":null,"topics":["apollo-server","graphql","typescript"],"latest_commit_sha":null,"homepage":"","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/shfshanyue.png","metadata":{"files":{"readme":"Readme.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null}},"created_at":"2019-07-03T22:38:24.000Z","updated_at":"2024-12-11T22:20:10.000Z","dependencies_parsed_at":"2023-02-07T22:31:20.478Z","dependency_job_id":null,"html_url":"https://github.com/shfshanyue/apollo-server-starter","commit_stats":null,"previous_names":[],"tags_count":0,"template":true,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/shfshanyue%2Fapollo-server-starter","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/shfshanyue%2Fapollo-server-starter/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/shfshanyue%2Fapollo-server-starter/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/shfshanyue%2Fapollo-server-starter/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/shfshanyue","download_url":"https://codeload.github.com/shfshanyue/apollo-server-starter/tar.gz/refs/heads/master","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":248950188,"owners_count":21188218,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["apollo-server","graphql","typescript"],"created_at":"2024-11-28T03:20:26.031Z","updated_at":"2025-04-14T19:45:02.303Z","avatar_url":"https://github.com/shfshanyue.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# GraphQL Starter\n\n使用 [apollo-server](https://github.com/apollographql/apollo-server) 帮助你快速开发 [GraphQL](https://github.com/graphql/graphql-js)。\n\n## 准备条件\n\n+ `docker`，你需要使用它先启动 redis 与 postgres\n+ `redis`/`postgres`，如果你有数据库，则可以使用现成的数据库，而无需 `docker` 部署及启动\n\n## 快速开始\n\n如果没有现成的数据库，需要准备数据库 db/redis 的环境 (使用 docker，你需要有 docker 环境)，使用命令 `npm run env:db`。\n\n``` bash\n$ git clone git@github.com:shfshanyue/apollo-server-starter.git\n$ cd apollo-server-starter\n\n# 如果没有现成的数据库，准备数据库 db/redis 的环境 (使用 docker)\n# 该命令通过 docker-compose -f db.compose.yml up，搭建本地数据库\n# redis6: https://hub.docker.com/_/redis\n# postgres14: https://hub.docker.com/_/postgres\n$ npm run env:db\n\n# 配置环境变量\n$ cp .env.example .env\n\n# 迁移数据库\n$ npm run migrate\n\n# 开始开发\n$ npm run dev\n```\n\n## 技术栈\n\n+ [GraphQL.js](https://github.com/graphql/graphql-js), [apollo-server](https://github.com/apollographql/apollo-server), [koa](https://github.com/koajs/koa), [DataLoader](https://github.com/graphql/dataloader) —- API 层\n+ `PostgresSQL`, `Redis`, [ioredis](https://github.com/luin/ioredis), [sequelize](https://github.com/sequelize/sequelize), [lru-cache](https://github.com/isaacs/node-lru-cache)  -- 存储\n+ [TypeScript](https://github.com/zhongsp/TypeScript), [sequelize-typescript](https://github.com/RobinBuschmann/sequelize-typescript) -- ts 支持\n+ `Joi`, `consul`, [node-consul](https://github.com/silas/node-consul#readme), [winston](https://github.com/winstonjs/winston), [sentry](https://github.com/getsentry/sentry-javascript) -- 校验，配置，日志与报警\n+ `Docker`, `docker-compose`, `gitlab-CI`, `traefik`, `kubernetes` -- 部署\n\n## 目录结构\n\n``` bash\n.\n├── config                  # 配置文件\n│   ├── db.js               # 数据库配置文件，主要被 sequelize-cli 使用\n│   └── index.ts            # 关于本项目的配置，包括数据库，redis，主要从环境变量中读取\n├── db                      # 关于 db\n│   ├── index.ts            # 关于数据库的配置以及日志 (sequelize)\n│   ├── migrations/         # 关于数据库的迁移脚本\n│   └── models/             # 关于数据库的 Model (typescript-sequelize)\n├── lib                     # 关于 lib\n│   ├── error.ts            # 异常的结构化与自定义异常\n│   ├── logger.ts           # 关于日志的配置 (winston)\n│   ├── redis.ts            # 关于 redis 的配置以及日志 (ioredis)\n│   ├── sentry.ts           # 关于 sentry 的配置以及初始化\n│   └── session.ts          # 关于 CLS 的控制\n├── logs                    # 日志，自动生成\n│   ├── api.log             # graphql 的日志\n│   ├── common.log          # 通用日志\n│   ├── db.log              # 关于数据库 `SQL` 的日志\n│   └── redis.log           # 关于 redis 执行语句的日志\n├── middlewares             # KOA 中间件\n│   ├── auth.ts             # 认证，解析出 user\n│   ├── context.ts          # requestId，以及一些列上下文打进日志以及 Sentry\n│   └── index.ts            # 导出所有中间件\n├── scripts                 # 脚本\n│   └── createSchema.ts     # 自动生成 graphql schema 与数据库 schema 的脚本\n├── src                     # 关于 graphql 的一系列\n│   ├── index.ts            # graphql typeDefs \u0026 resolvers\n│   ├── directives/         # graphql directives\n│   ├── resolvers/          # graphql resolvers (Mutation \u0026 Query)\n│   ├── scalars/            # graphql scalars\n│   └── utils.ts            # graphql 的辅助函数\n├── .env.example            # 数据库与redis的配置，以及一些敏感数据\n├── Dockerfile              # Dockerfile\n├── db.docker-compose.yml   # 数据库环境准备\n├── docker-compose.yml      # docker-compose\n├── package-lock.json       # pakcage-lock.json\n├── package.json            # package.json\n├── tsconfig.json           # 关于 ts 的配置\n├── index.ts                # 服务入口\n└── type.ts                 # typescript 支持\n```\n\n### 关于数据库的操作\n\n``` bash\nnpm run migrate:new     # 生成新的迁移文件\nnpm run migrate         # 执行迁移文件\nnpm run migrate:undo    # 撤销执行的迁移文件\n```\n\n### 自动生成 resolve 与 数据库 model\n\n``` bash\n$ npm run schema hello   # 生成 Hello.ts\n```\n\n### 查看日志\n\n``` bash\n# 查看数据库的日志\n$ npm run log:db\n\n# 查看 graphql 的日志\n$ LOG=query npm run log\n```\n\n## 开发指南\n\n### 单文件管理 `typeDef` 与 `resolver`\n\n如下，在单文件中定义 `ObjectType` 与其在 `Query` 以及 `Mutation` 中对应的查询。并把 `typeDef` 与 `resolver` 集中管理。\n\n``` typescript\n// src/resolvers/Todo.ts\nconst typeDef = gql`\n  type Todo @sql {\n    id: ID!\n  }\n\n  extend type Query {\n    todos: [Todo!]\n  }\n\n  extend type Mutation {\n    createTodo: TODO!\n  }\n`\n\nconst resolver: IResolverObject\u003cany, AppContext\u003e = {\n  Todo: {\n    user () {}\n  },\n  Query: {\n    todos () {}\n  },\n  Mutation: {\n    createTodo () {}\n  }\n}\n```\n\n### 按需取数据库字段\n\n使用 `@findOption` 可以按需查询，并注入到 `resolver` 函数中的 `info.attributes` 字段\n\n``` gql\ntype Query {\n  users: [User!] @findOption\n}\n\nquery USERS {\n  users {\n    id\n    name\n  }\n}\n```\n\n``` typescript\nfunction users ({}, {}, { models }, { attributes }: any) {\n  return models.User.findAll({\n    attributes\n  })\n}\n```\n\n### 分页\n\n对列表添加 `page` 以及 `pageSize` 参数来进行分页\n\n``` graphql\ntype User {\n  id: ID!\n  todos (\n    page: Int = 1\n    pageSize: Int = 10\n  ): [Todo!] @findOption\n}\n\nquery TODOS {\n  todos (page: 1, pageSize: 10) {\n    id\n    name\n  }\n}\n```\n\n\n### 数据库层解决 N+1 查询问题\n\n使用 [dataloader-sequelize](https://github.com/mickhansen/dataloader-sequelize) 解决数据库查询的 batch 问题\n\n当使用以下查询时，会出现 N+1 查询问题\n\n``` gql\n{\n  users (page: 1, pageSize: 3) {\n    id\n    todos {\n      id\n      name\n    }\n  }\n}\n```\n\n如果不做优化，生成的 `SQL` 如下\n\n``` sql\nselect id from users limit 3\n\nselect id, name from todo where user_id = 1\nselect id, name from todo where user_id = 2\nselect id, name from todo where user_id = 3\n```\n\n而使用 `dataloader` 解决 N+1 问题后，会大大减少 `SQL` 语句的条数，生成的 `SQL` 如下\n\n``` sql\nselect id from users limit 3\n\nselect id, name, user_id from todo where user_id in (1, 2, 3)\n```\n\n\u003e 注意 Batch 请求后需要返回 `user_id` 字段，为了重新分组\n\n### N+1 Query 优化后问题\n\n当有如下所示多级分页查询时，N+1 优化失效，所以应避免多级分页操作\n\n\u003e 此处只能在客户端避免多层分页查询，而当有恶意查询时会加大服务器压力。可以使用以下的 Hash Query 避免此类问题，同时也在生产环境禁掉 `introspection`\n\n``` gql\n{\n  users (page: 1, pageSize: 3) {\n    id\n    todos (page: 1, pageSize: 3) {\n      id\n      name\n    }\n  }\n}\n```\n\n``` sql\nselect id from users limit 3\n\nselect id, name from todo where user_id = 1 limit 3\nselect id, name from todo where user_id = 2 limit 3\nselect id, name from todo where user_id = 3 limit 3\n```\n\n### 使用 DataLoader 解决 N+1 查询问题\n\n### 使用 ID/Hash 代替 Query\n\n**TODO**\n**需要客户端配合**\n\n当 `Query` 越来越大时，http 所传输的请求体积越来越大，严重影响应用的性能，此时可以把 `Query` 映射成 `hash`。\n\n当请求体变小时，此时可以替代使用 `GET` 请求，方便缓存。\n\n**我发现掘金的 GraphQL Query 已由 ID 替代**\n\n### 使用 `consul` 管理配置\n\n`project` 代表本项目在 `consul` 中对应的 `key`。项目将会拉取该 `key` 对应的配置并与本地的 `config/project.ts` 做 `Object.assign` 操作。\n`dependencies` 代表本项目所依赖的配置，如数据库，缓存以及用户服务等的配置，项目将会在 `consul` 上拉取依赖配置。\n\n项目最终生成的配置为 `AppConfig` 标识。\n\n``` typescript\n// config/consul.ts\nexport const project = 'todo'\nexport const dependencies = ['redis', 'pg']\n```\n\n### 用户认证\n\n使用 `@auth` 指令表示该资源受限，需要用户登录，`roles` 表示只有特定角色才能访问受限资源\n\n``` graphql\ndirective @auth(\n  # USER, ADMIN 可以自定义\n  roles: [String]\n) on FIELD_DEFINITION\n\ntype Query {\n  authInfo: Int @auth\n}\n```\n\n以下是相关代码\n\n``` typescript\n// src/directives/auth.ts\nfunction visitFieldDefinition (field: GraphQLField\u003cany, AppContext\u003e) {\n  const { resolve = defaultFieldResolver } = field\n  const { roles } = this.args\n  // const roles: UserRole[] = ['USER', 'ADMIN']\n  field.resolve = async (root, args, ctx, info) =\u003e {\n    if (!ctx.user) {\n      throw new AuthenticationError('Unauthorized')\n    }\n    if (roles \u0026\u0026 !roles.includes(ctx.user.role)) {\n      throw new ForbiddenError('Forbidden')\n    }\n    return resolve.call(this, root, args, ctx, info)\n  }\n}\n```\n\n### jwt 与白名单\n\n### jwt 与 token 更新\n\n当用户认证成功时，检查其 token 有效期，如果剩余一半时间，则生成新的 token 并赋值到响应头中。\n\n### 用户角色验证\n\n### 日志\n\n为 `graphql`，`sql`，`redis` 以及一些重要信息(如 user) 添加日志，并设置标签\n\n``` typescript\n// lib/logger.ts\nexport const apiLogger = createLogger('api')\nexport const dbLogger = createLogger('db')\nexport const redisLogger = createLogger('redis')\nexport const logger = createLogger('common')\n```\n\n### 为日志添加 requestId (sessionId)\n\n为日志添加 `requestId` 方便追踪 bug 以及检测性能问题\n\n``` typescript\n// lib/logger.ts\nconst requestId = format((info) =\u003e {\n  info.requestId = session.get('requestId')\n  return info\n})\n```\n\n### 结构化异常信息\n\n结构化 API 异常信息，其中 `extensions.code` 代表异常错误码，方便调试以及前端使用。`extensions.exception` 代表原始异常，堆栈以及详细信息。注意在生产环境需要屏蔽掉 `extensions.exception`\n\n``` bash\n$ curl 'https://todo.xiange.tech/graphql' -H 'Content-Type: application/json' --data-binary '{\"query\":\"{\\n  dbError\\n}\"}'\n{\n  \"errors\": [\n    {\n      \"message\": \"column User.a does not exist\",\n      \"locations\": [\n        {\n          \"line\": 2,\n          \"column\": 3\n        }\n      ],\n      \"path\": [\n        \"dbError\"\n      ],\n      \"extensions\": {\n        \"code\": \"SequelizeDatabaseError\",\n        \"exception\": {\n          \"name\": \"SequelizeDatabaseError\",\n          \"original\": {\n            \"name\": \"error\",\n            \"length\": 104,\n            \"severity\": \"ERROR\",\n            \"code\": \"42703\",\n            \"position\": \"57\",\n            \"file\": \"parse_relation.c\",\n            \"line\": \"3293\",\n            \"routine\": \"errorMissingColumn\",\n            \"sql\": \"SELECT count(*) AS \\\"count\\\" FROM \\\"users\\\" AS \\\"User\\\" WHERE \\\"User\\\".\\\"a\\\" = 3;\"\n          },\n          \"sql\": \"SELECT count(*) AS \\\"count\\\" FROM \\\"users\\\" AS \\\"User\\\" WHERE \\\"User\\\".\\\"a\\\" = 3;\",\n          \"stacktrace\": [\n            \"SequelizeDatabaseError: column User.a does not exist\",\n            \"    at Query.formatError (/code/node_modules/sequelize/lib/dialects/postgres/query.js:354:16)\",\n          ]\n        }\n      }\n    }\n  ],\n  \"data\": {\n    \"dbError\": null\n  }\n}\n```\n\n### 在生产环境屏蔽掉异常堆栈以及详细信息\n\n避免把原始异常以及堆栈信息暴露在生产环境\n\n``` typescript\n{\n  \"errors\": [\n    {\n      \"message\": \"column User.a does not exist\",\n      \"locations\": [\n        {\n          \"line\": 2,\n          \"column\": 3\n        }\n      ],\n      \"path\": [\n        \"dbError\"\n      ],\n      \"extensions\": {\n        \"code\": \"SequelizeDatabaseError\"\n      }\n    }\n  ],\n  \"data\": {\n    \"dbError\": null\n  }\n}\n```\n\n### 异常报警\n\n根据异常的 `code` 对异常进行严重等级分类，并上报监控系统。这里监控系统采用的 `sentry`\n\n``` typescript\n// lib/error.ts:formatError\nlet code: string = _.get(error, 'extensions.code', 'Error')\nlet info: any\nlet level = Severity.Error\n\nif (isAxiosError(originalError)) {\n  code = `Request${originalError.code}`\n} else if (isJoiValidationError(originalError)) {\n  code = 'JoiValidationError'\n  info = originalError.details\n} else if (isSequelizeError(originalError)) {\n  code = originalError.name\n  if (isUniqueConstraintError(originalError)) {\n    info = originalError.fields\n    level = Severity.Warning\n  }\n} else if (isApolloError(originalError)){\n  level = originalError.level || Severity.Warning\n} else if (isError(originalError)) {\n  code = _.get(originalError, 'code', originalError.name)\n  level = Severity.Fatal\n}\n\nSentry.withScope(scope =\u003e {\n  scope.setTag('code', code)\n  scope.setLevel(level)\n  scope.setExtras(formatError)\n  Sentry.captureException(originalError || error)\n})\n```\n\n### 健康检查\n\n在 `k8s` 上根据健康检查监控应用状态，当应用发生异常时可以及时响应并解决\n\n``` bash\n$ curl http://todo.xiange.tech/.well-known/apollo/server-health\n{\"status\":\"pass\"}\n```\n\n## filebeat \u0026 ELK\n\n通过 `filebeat` 把日志文件发送到 `elk` 日志系统，方便日后分析以及辅助 debug\n\n### 监控\n\n在日志系统中监控 SQL 慢查询以及耗时 API 的日志，并实时邮件通知 (可以考虑钉钉)\n\n### 参数校验\n\n使用 [Joi](https://github.com/hapijs/joi) 做参数校验\n\n``` javascript\nfunction createUser ({}, { name, email, password }, { models, utils }) {\n  Joi.assert(email, Joi.string().email())\n}\n\nfunction createTodo ({}, { todo }, { models, utils }) {\n  Joi.validate(todo, Joi.object().keys({\n    name: Joi.string().min(1),\n  }))\n}\n```\n\n### 服务端渲染\n\n### npm scripts\n\n+ `npm start`\n+ `npm test`\n+ `npm run dev`\n\n### 使用 CI 加强代码质量\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fshfshanyue%2Fapollo-server-starter","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fshfshanyue%2Fapollo-server-starter","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fshfshanyue%2Fapollo-server-starter/lists"}