出于改变世界的责任感
某天看一眼 idea 列表,就随手挑个大胆的想法,准备实现一把。
大胆的想法配大胆的技术,awesome~~~
下面我就来介绍一下,前后端结合 GraphQL 来进行开发的具体实践,并搭配一套相对舒适的开发工具链(自动化代码生成、完善语法提示)
前言
GraphQL 是一门语言规范,本身不提供具体实现。在实际开发中需要自己选择适合的第三方实现。
项目涉及前后端,一家人要整整齐齐,在全局上选取了 apollo 作为具体实现
作为工具类文章,我在下面列出了涉及到的主要模块,用于索引。可以直接使用 command + f
搜索关键词来快速定位,节选自己感兴趣的部分进行阅读。
前端,重点介绍 GrahpQL 工具链的选配(vscode 插件、graphql 代码自动生成工具),如何进行舒适的开发。
后端,则重点介绍 type-graphql 与 typeorm 的整体使用,以贴代码为主。
在最后,我会谈谈在实践中发现的问题,我对于 GraphQL 现阶段的看法。以及你真的需要 GraphQL 吗?
本文不是从零开始,如果遇到困难,或需要完整源码,可以在评论区留言,改日更新到文章内。
- 前端
- next
- @apollo/client
- @graphql-codegen/cli
- 后端
- apollo-server-express
- type-graphql
- typeorm
- typedi
- 开发工具(vscode)
- VSCode GraphQL(插件)
前端
前端使用 @apollo/client 来进行数据获取,搭配 graphql-codegen 进行代码自动生成,以及 VSCode GraphQL 插件的语法提示
那么 GraphQL 查询语句 怎么写 ?写在哪里?怎么在业务中去用?
又到展示我高超的画图技巧的环节,下面让我来画个图说明流程
在上面的开发流程中,每个阶段我们都能获取完整的类型提示,从 graphql 提示,到 ts 类型提示。
首先是 vscode 插件,直接在 vscode 插件中心搜索 graphql,第一项就是我们需要使用的插件
根据说明,在项目根目录配置 .graphqlconfig.yml
# 此处也可以直接使用 schema: 'http://localhost:4000/api/graphql' 配置为可访问的graphql服务
schemaPath: "./server/schema.graphql"
includes: ["**/*.{graphql,gql,ts,tsx,js}"] # 将为那些文件提供语法提示
extensions:
endpoints:
default: http://localhost:4000/api/graphql # 配置后可以直接进行 test 测试
配置好后,如果代码提示没有生效,可以尝试 shift + command + p
唤出菜单,选择restart 重启插件
下面编写查询语句,具体语法参考 官网
~/query.graphql
fragment knodeFields on Knode {
id
name
parentId
gitDir
readme
gitOwner
gitRepo
}
query getKnodes {
knodes {
...knodeFields
}
}
query getKnode($id: String!) {
knode(id: $id) {
...knodeFields
}
}
mutation addKnode($data: AddKnode) {
addKnode(data: $data) {
...knodeFields
}
}
安装代码自动生成工具
npm i @graphql-codegen/cli @graphql-codegen/introspection @graphql-codegen/near-operation-file-preset @graphql-codegen/typescript @graphql-codegen/typescript-operations @graphql-codegen/typescript-react-apollo
在根目录配置 codegen.yml
overwrite: true
schema: "./server/schema.graphql" # 可以提供可用的 graphql 服务
documents: "./genapi/**/*.graphql" # 你编写的 graphql 查询语句
generates:
genapi/index.tsx: # 生成文件的输出路径
plugins:
- typescript
- typescript-operations
- typescript-react-apollo
# generates: # 这里提供了一种,就近生成代码的配置,比如 a/query.graphql 生成 a/query.gen.tsx
# types.ts:
# plugins:
# - typescript
# pages:
# documents: "./pages/**/*.graphql"
# preset: near-operation-file
# presetConfig:
# extension: .gen.tsx
# baseTypesPath: ../types.ts
# plugins:
# - typescript-operations
# - typescript-react-apollo
graphql-codegen --config codegen.yml -w
开始生成、启动监听
接着看看生成后的代码长啥样,根据配置,我的生成路径在 genapi/index.tsx
import { gql } from '@apollo/client';
import * as Apollo from '@apollo/client';
export type Maybe<T> = T | null;
...略
export type Knode = {
__typename?: 'Knode';
gitDir?: Maybe<Scalars['String']>;
gitOwner?: Maybe<Scalars['String']>;
gitRepo?: Maybe<Scalars['String']>;
id?: Maybe<Scalars['String']>;
name?: Maybe<Scalars['String']>;
parentId?: Maybe<Scalars['String']>;
readme?: Maybe<Scalars['String']>;
};
export type Mutation = {
__typename?: 'Mutation';
updateKnode?: Maybe<Knode>;
};
...略
/**
*
* @example
* const [updateKnodeMutation, { data, loading, error }] = useUpdateKnodeMutation({
* variables: {
* id: // value for 'id'
* data: // value for 'data'
* },
* });
*/
export function useUpdateKnodeMutation(baseOptions?: Apollo.MutationHookOptions<UpdateKnodeMutation, UpdateKnodeMutationVariables>) {
return Apollo.useMutation<UpdateKnodeMutation, UpdateKnodeMutationVariables>(UpdateKnodeDocument, baseOptions);
}
export type UpdateKnodeMutationHookResult = ReturnType<typeof useUpdateKnodeMutation>;
export type UpdateKnodeMutationResult = Apollo.MutationResult<UpdateKnodeMutation>;
export type UpdateKnodeMutationOptions = Apollo.BaseMutationOptions<UpdateKnodeMutation, UpdateKnodeMutationVariables>;
export const GetKelementsDocument = gql`
query getKelements($knodeId: String, $date: DateTime, $searchText: String, $hasKnode: Boolean = false) {
kelements(knodeId: $knodeId, date: $date, searchText: $searchText) {
...kelementFields
knode @include(if: $hasKnode) {
...knodeFields
}
}
}
${KelementFieldsFragmentDoc}
${KnodeFieldsFragmentDoc}`;
...略
超酷,直接基于我们的原始查询语句,生成了 apollo react hook 组件。
来看看怎么用,如下
import { useUpdateKnodeMutation, useGetKnodesQuery } from "genapi"; // 引入
export default function Home() {
const { data, loading, error, refetch } = useGetKnodesQuery();
const [updateKnode] = useUpdateKnodeMutation();
return (
<div
onClick={async () => {
await updateKnode({
variables: {
id: '1',
data: {
name: '老王',
parentId: '2',
},
},
});
refetch();
}}
>
name: {data.knodes[0].name}
</div>
);
}
简单贴图说明一下这超酷的开发过程
在工具链的选择上,尝试过一些其他方案,也在下面简单列一下。
1.以字符串的形式将 GraphQL 直接写在 js/ts 业务代码中,利用插件给予提示
缺点:提示不完善、与业务代码耦合重、无法得到完整的 ts 类型推断
2.直接写 GraphQL 查询语句,通过graphql-tag/loader
增加 webpack 支持,最后直接导入到业务代码中进行使用
缺点:本质还是导入字符串,无法得到完整的 ts 类型推断
后端
项目入口 app.ts,使用 apollo-server-express
提供基础服务
import { schema } from "./apollo/schema";
import { ApolloServer } from "apollo-server-express";
import * as express from "express";
const app = express();
// app.use(async (req, res, next) => {
// await dbConnect(); // 可以使用 express 写一些插件,用于提供自定义服务
// next();
// });
app.listen({ port: 4000 }, () =>
console.log(` Server ready at http://localhost:4000/api/graphql`)
);
const server = new ApolloServer({
schema,
});
server.applyMiddleware({
app,
path: "/api/graphql",
cors: {
origin: "http://localhost:3000",
credentials: true,
allowedHeaders: ["Content-Type", "Authorization"],
},
});
~/apollo/schema.ts,使用type-graphql
构建服务所需的schema
import "reflect-metadata";
import { buildSchemaSync } from "type-graphql";
import * as path from 'path'
export const schema = buildSchemaSync({
// resolvers: [KnodeResolver],
resolvers: [path.resolve(__dirname, "./**/index.ts")], // 支持多种形式来载入rsolvers
// 项目启动时,输出一份 schema.graphql 文件到目录,我们后面工具链会用到
emitSchemaFile: "schema.graphql",
nullableByDefault: true,
// authChecker, // 可以在这配置 type-graphql 默认提供的鉴权服务
// container: Container, // 依赖注入相关
// globalMiddlewares: [auth], // 配置全局中间件
});
resolvers 部分目录结构为
├── knode
│ ├── index.ts
│ ├── dto.ts
│ └── model.ts
~/apollo/knode/index.ts
import { Resolver, Query, Mutation, Arg, Authorized, Ctx } from "type-graphql";
import { Knode } from "./model";
import { AddKnode, UpdateKnode } from "./dto";
import { Service } from "typedi";
// @Service() // 依赖注入
@Resolver(() => Knode)
export default class KnodeResolver {
// constructor(private readonly gitService: GitService) {} // 注入服务
// @Authorized() // 鉴权
@Query(() => Knode)
async knode(@Arg("id") id: string) {
return Knode.findOne(id);
}
@Mutation(() => Knode)
async addKnode(@Arg("data") data: AddKnode, @Ctx() { uid }) { // uid是利用express插件机制注入的
// await this.gitService.createGitdir(uid, data.gitDir); 调用注入的服务
const knode = Knode.create(data); // Knode 同时是typeorm的实例,所以可以直接使用
knode.uid = uid;
await knode.save();
return knode;
}
@Mutation(() => Knode)
async updateKnode(@Arg("id") id: string, @Arg("data") data: UpdateKnode) {
const knode = await Knode.findOne({ where: { id } });
Object.assign(knode, data);
const newKnode = await knode.save();
return newKnode;
}
@Mutation(() => Boolean)
async delKnode(@Arg("id") id: string) {
const book = await Knode.findOne({ where: { id } });
await book.remove();
return true;
}
}
补充一下,如果期望不进行返回值的校验,可以自己提供类型
import { GraphQLScalarType } from "graphql";
export const AnyType = new GraphQLScalarType({
name: "any",
description: "any type",
parseValue(value: any) {
return value; // value from the client input variables
},
serialize(value: any) {
return value; // value sent to the client
},
parseLiteral(ast) {
return null;
},
});
定义后就可以,@Mutation(()=> AnyType)
~/apollo/knode/dto.ts 提供 Mutation 的字段校验
import { InputType, Field } from "type-graphql";
import { IsNotEmpty } from "class-validator";
@InputType()
export class AddKnode {
@Field()
@IsNotEmpty()
name: string;
@Field()
parentId: string;
@Field()
gitDir: string;
@Field()
readme: string;
}
@InputType()
export class UpdateKnode {
@Field()
name: string;
@Field()
parentId: string;
@Field()
gitDir: string;
@Field()
readme: string;
}
~/apollo/knode/model.ts 直接将 typeorm 与 graphql 统一定义
import { Entity, PrimaryGeneratedColumn, Column, BaseEntity } from "typeorm";
import { ObjectType, Field } from "type-graphql";
@Entity()
@ObjectType()
export class Knode extends BaseEntity { // 直接继承 BaseEntity,便于使用
@Field()
@PrimaryGeneratedColumn("uuid")
id: string;
@Field()
@Column()
uid: string;
@Field()
@Column()
name: string;
@Field()
@Column({ name: "parent_id" })
parentId: string;
@Field()
@Column("text")
readme: string;
@Field()
@Column({ nullable: true })
gitDir?: string;
...
}
补充一下,关于 typeorm 的配置
import { getConnectionManager } from "typeorm";
import config from "config";
export const dbConnect = async () => {
const connectionManager = getConnectionManager();
if (connectionManager.has("default")) {
const connection = connectionManager.get("default");
if (!connection.isConnected) {
await connection.connect();
}
return connection;
} else {
return connectionManager
.create({
...config.db,
entities: ["apollo/**/model.ts"],
migrations: [],
} as any)
.connect();
}
};
// 参考
// const config.db = {
// type: "mysql",
// host: "localhost",
// port: 3306,
// username: "root",
// password: ".Jqz1996",
// database: "wuku",
// synchronize: true,
// logging: false,
// }
补充二,一个有意思的点,起初是我打算直接在 next.js 提供的 api 路由内使用 typeorm,然后遇到问题。typeorm 如何在热更新的情况下使用?可以参考下面,每次热更新检查是否已存在实例
function entitiesChanged(prevEntities: any[], newEntities: any[]): boolean {
if (prevEntities.length !== newEntities.length) return true;
for (let i = 0; i < prevEntities.length; i++) {
if (prevEntities[i] !== newEntities[i]) return true;
}
return false;
}
async function updateConnectionEntities(
connection: Connection,
entities: any[]
) {
if (!entitiesChanged(connection.options.entities, entities)) return;
(connection.options as any).entities = entities;
(connection as any).buildMetadatas();
if (connection.options.synchronize) {
await connection.synchronize();
}
}
export async function ensureConnection() {
const connectionManager = getConnectionManager();
if (connectionManager.has("default")) {
const connection = connectionManager.get("default");
if (!connection.isConnected) {
await connection.connect();
}
if (process.env.NODE_ENV !== "production") {
await updateConnectionEntities(connection, Object.values(entities));
}
return connection;
} else {
return connectionManager
.create({
...config.db,
entities: Object.values(entities),
migrations: [],
} as any)
.connect();
}
}
以上,后端部分也已经介绍完毕。
如果你运行了项目,应该会看到项目目录下,多出了一个 schema.graphql
文件,内容可能类似下面的。
这是 type-graphql 自动生成的,主要用于“看”,或者提供给其他工具链使用,可以用于代码提示或生成(当然,也可以直接使用 graphql 服务,启动服务同样可以提供支持)
# -----------------------------------------------
# !!! THIS FILE WAS GENERATED BY TYPE-GRAPHQL !!!
# !!! DO NOT MODIFY THIS FILE BY YOURSELF !!!
# -----------------------------------------------
...省略
input AddKnode {
gitDir: String
name: String
parentId: String
readme: String
}
input UpdateKnode {
gitDir: String
name: String
parentId: String
readme: String
}
"""any type"""
scalar any
"""
The javascript `Date` as string. Type represents date and time as the ISO Date string.
"""
scalar DateTime
type Knode {
gitDir: String
gitOwner: String
gitRepo: String
id: String
name: String
parentId: String
readme: String
uid: String
}
type Mutation {
addKnode(data: AddKnode): Knode
delKnode(id: String): Boolean
updateKnode(data: UpdateKnode, id: String): Knode
}
type Query {
knode(id: String): Knode
knodes: [Knode]
}
最后
终于到最后了
来谈谈 GraphQL 实际使用的感受
- 工具链不完善,官方没有提供合适的最佳实践,所以花了大量时间在工具链的打造上。并且这个流程目前并不完善,依旧有致命缺陷(你发现了吗)。
- 复杂度过高,给人以黑盒的感觉、不直观,例如 apollo 的缓存机制,虽然照着文档理解了一番,但使用过程中依旧会遇到特殊问题。
- 资料缺失,整体社区给人以割裂感。使用的 apollo type-graphql 虽然已经是主流,但依旧会遇到各种难解、无解的问题。
- GraphQL schema 不容易设计,无法利用原有经验,实际开发中会发现,shema 很难设计好,与 restful 相比,缺乏社区实践,让你无法应用原有的经验来快速开发。
- 后端工作量远大于前端,后端工作量远大于前端,例如会遇到 n + 1 性能问题,需要耗费更多的精力来开发。除非打钱,否则后端不会同意使用GraphQL的。
- 极大的增加了系统复杂度,虽然上面提过了复杂度过高,这里再强调一遍,例如解析过程中出现特殊异常,问题极难定位。
- 迁移成本高,整体架构与restful不兼容,迁移有工作量。
- 学习成本高,使用初期会耗费大量时间在语法与填坑上,然后开发过程也同样是一个填坑的过程,总能遇到千奇百怪的问题。
说到这里,很难想象,我当初竟然是出于懒得写接口,快速开发 demo 的想法来使用GraphQL。。。我现在的心情是后悔,及其后悔,非常后悔。
以至于产生了写这篇文章的动力,我计划放弃在后续的 demo 开发中使用 graphql,为了有始有终,写下这篇文章,用于总结。
最后的问题,我也在这里回答下
你真的需要使用 GraphQL吗 ?(仅以个人角度出发,大公司或技术积累强的团队忽视即可)
- 如果是为了快速开发 demo,放弃使用 GraphQL 的念头!
- 如果是准备用于公司商业项目,放弃使用 GraphQL 的念头!
- 学习目的,欢迎踩坑!
- 内部项目,可以试错,欢迎踩坑!
- 已有技术积累,欢迎踩坑!
以上
欢迎点赞收藏:)