阅读 1340

GraphQL的前后端实践

GraphQL是这两年兴起的一种查询语言,国内一些比较潮的公司正在使用,它解决了Rest接口方式的一些问题,同时也带来了一些新的问题。对于我们底层程序员来说,学就对了,万一用上了呢。

框架选择

graphql在各种语言,各种框架都有对应的实现,可以查看官网根据情况选择适合自己的实现,概念上都是一致的。本文更着重于实际代码,理论部分请结合官网教程观看。

本文使用nodejs作为开发语言,使用express作为服务器,展示graphql的简单搭建过程,并逐步添加mysql,typescript,type-graphql,typeorm的支持。这个过程是渐进的,如果你不喜欢(学不动)某个部分,跳过就好。注意本文携带大量私货,未必是最佳实践,如果有错误,请评论指出,共同学习,谢谢

快速实现

首先我们先快速实现一个graphql的服务器

mkdir graphqldemo;
cd graphqldemo;
npm init -yes;
npm i express apollo-server-express;
复制代码

然后创建一个index.js

const express = require("express");
const { ApolloServer } = require("apollo-server-express");

const PORT = 4000;
const app = express();

const box = {
  width: 100,
  height: 200,
  weight: "100g",
  color: "white"
}

const typeDefs = [`
"""
一个盒子模型
"""
type Box{
  """
  这是盒子的宽度
  """
  width:Int,
  height:Int,
  color:String
}

type Query {
  getBox: Box
}

type Mutation{
  setWidth(width:Int):Box
}

schema {
  query: Query,
  mutation: Mutation
}`]

const resolvers = {
  Query: {
    getBox(_) {
      return box;
    }
  },
  Mutation: {
    setWidth(_, { width }) {
      box.width = width;
      return box
    }
  }
};


const server = new ApolloServer({
  typeDefs,
  resolvers
});
server.applyMiddleware({ app });

app.listen(PORT, () =>
  console.log(
    `🚀 Server ready at http://localhost:${PORT}${server.graphqlPath}`
  )
);
复制代码

然后运行

node index.js
复制代码

等出现成功提示后就可以在浏览器打开 http://localhost:4000/graphql,就可以看到graphql提供的playground

点击右侧的docs,就能查看到我们设定的type和对应的数据类型,还有我们写的注释,这其实是一份很完备的接口文档了。

代码分析

刚刚我们使用express和Apollo Server实现了一个最简单的graphql服务器(Apollo Server是graphql规范的一个实现)。

const server = new ApolloServer({
  typeDefs,
  resolvers
});
复制代码

在new一个ApolloServer的时候,传入了两个参数,一个typeDefs和一个resolvers。typeDefs是一个字符串或者字符串数组,里面的内容是我们定义的schema,而resolvers是schema的实现,也就是typeDefs里的Query和Mutation,注意所有的schema都要实现之后程序才能启动。

也可以只传入一个schema参数来new ApolloServer,使用buildSchema方法可以将typeDefs和resolvers生成schema(schema这个概念在graphql中到处出现,不要搞混了)。

resolver的返回值需要符合定义的类型,否则会报错。在ApolloServer中,也可以返回对应类型的Promise。

server.applyMiddleware({ app });
复制代码

这一行将Apollo作为Express的一个中间件

const box = {
  width: 100,
  height: 200,
  weight: "100g",
  color: "white"
}
复制代码

声明一个盒子,作为数据源。graphql并不在意数据是从哪里来的,可以从普通变量,数据库,redis,甚至http请求中获取,只要这个数据的结构能符合定义即可。现在我们向服务器请求一下这个box

graphql一共有三种操作类型,query、mutation 或 subscription,这里演示一下query、mutation

query

query是graphql中的查询操作,在playground左侧输入

query {
  getBox {
    width
    height
    color
  }
}
复制代码

点击按钮,可以在右边获得返回值

{
  "data": {
    "getBox": {
      "width": 100,
      "height": 200,
      "color": "white"
    }
  }
}
复制代码

我们可以随意减少getBox里的字段(至少有一个),比如只要width

query {
  getBox {
    width
  }
}
复制代码

可以看到返回值里只有width属性了。

{
  "data": {
    "getBox": {
      "width": 100
    }
  }
}
复制代码

graphql在这里解决了传统接口模式中一个问题,就是后端在向前端传输数据的过程中,会传递很多无效字段,无效字段过多会影响传输效率,前端可以主动获取自己所需的字段。

另一方面,后端的DAO层的一些字段从安全角度也是不应该传递给前端的,在上文的这个例子里,box的weight属性可以理解为一个前端不应可见的字段,因为在graphql中没有被定义,所以被自动过滤了,前端无法查询到。传统后端解决这个问题的方案是在DAO层之上引入一个DTO层。

mutation

mutation代表对数据源会产生副作用的操作,在playground中输入

mutation {
  setWidth(width: 108) {
    width
    height
    color
  }
}
复制代码

得到结果

{
  "data": {
    "setWidth": {
      "width": 108,
      "height": 200,
      "color": "white"
    }
  }
}
复制代码

可以看到box的width已经被更新到108了。注意,query和mutation都可以发起多个,服务器内部会顺序执行,但是query和mutation不能同时使用,下面是一个多个mutation的例子,query同理

mutation {
  m1:setWidth(width: 108) {
    width
  }
  m2:setWidth(width: 99) {
    width
  }
}

复制代码

返回值

{
  "data": {
    "m1": {
      "width": 108
    },
    "m2": {
      "width": 99
    }
  }
}
复制代码

因为setWidth重复使用了两次,重名了,所以我们使用m1、m2作为别名(Aliases),语法如上,非常简单。

传参

刚刚的mutation我们直接在语句里写了参数,因为语句本身是字符串不利于组合,同时也不适合传递复杂的参数,所以我们需要定义参数。点击playground左下的Query Variables,在这里可以声明参数,注意需要是标准json格式

{
  "length": 128
}
复制代码

同时将语句改为

mutation($length: Int) {
  setWidth(width: $length) {
    width
  }
}
复制代码

在length前加一个$就能在语句中使用了,可以查看一下浏览器控制台的请求有什么变化

稍微复杂一点

我们再看一点复杂的模型,现在给盒子里装点随机的小球,将数据源修改为如下形式

class Ball {
  constructor() {
    this.size = ((Math.random() * 10) | 0) + 5;
    this.color = ["black", "red", "white", "blue"][(Math.random() * 4) | 0];
  }
}
const box = {
  width: 100,
  height: 200,
  weight: "100g",
  color: "white",
  balls: new Array(10).fill().map(n => new Ball())
}
复制代码

然后在typeDefs中增加一个类型,并且修改box的类型

type Box{
  width:Int,
  height:Int,
  color:String,
  balls:[Ball]
}
type Ball{
  size:Int,
  color:String
}
复制代码

重启服务,进行一次查询

query {
  getBox {
    width
    balls {
      size
      color
    }
  }
}
复制代码

结果

{
  "data": {
    "getBox": {
      "width": 100,
      "balls": [
        {
          "size": 5,
          "color": "black"
        },
        //...
      ]
    }
  }
}
复制代码

似乎没有报错,不过这种情况并不符合graphql设计的本意。graphql的数据一层应该只携带本层的信息,想象一下这个需求,我需要box和box里所有color为red的球。正确做法如下,先修改box让他有参数

type Box{
  width:Int,
  height:Int,
  color:String,
  balls(color:String):[Ball]
}
复制代码

然后在resolvers里添加一个Box,注意resolver的第一个参数parent指向的是他的父元素也就是box,这一点很重要,如果有复数的盒子,需要这个参数判断返回哪个盒子里的球

const resolvers = {
  Query: {
    getBox(_) {
      return box
    },
  },
  Mutation: {
    setWidth(_, { width }) {
      box.width = width;
      return box
    }
  },
  Box: {
    balls(parent, { color }) {
      return color ? box.balls.filter(ball => ball.color === color) : box.balls
    }
  }
};
复制代码

现在可以使用查询查出所有的颜色为red的球

query {
  getBox {
    width
    balls (color:"red"){
      size
      color
    }
  }
}
复制代码

如果没有参数,就是全部的球。graphql这么设计的好处是,可以在数据库查询中,少写很多的join,坏处是更多的查询次数

前端使用

在使用http请求graphql服务器时的载体仍然是json,所以即使不使用任何特殊的库也可以与graphql服务器通信

axios

先用比较经典的axios来试一下,创建一个html文件

  <script src="https://cdn.bootcss.com/axios/0.19.0/axios.min.js"></script>
  <script>
    const query = `query($color:String) {
      getBox {
        width
        balls (color:$color){
          size
          color
        }
      }
    }`;
    const variables = {
      color: "red"
    };
    axios
      .post("http://localhost:4000/graphql", {
        query,
        variables
      })
      .then(res => {
        console.log("res: ", res);
      });
  </script>
复制代码

另外GET也是完全合法的

 axios.get("http://localhost:4000/graphql", {
        params: { query, variables }
      })
复制代码

或者直接访问

http://localhost:4000/graphql?query=query($color:String){getBox{width,balls(color:$color){size,color}}}&variables={"color":"red"}
复制代码

相比传统方式,graphql的特点就是返回值可预测,而且因为地址、请求方式和参数名固定,封装起来更简单。

现在看一下专业的客户端是这么做的,既然服务端使用了apollo-server,那客户端就看一下apollo-client怎么做的apollo-client官网。因为提供了错误处理,数据缓存,错误处理等等,配置项稍显复杂,官方提供了一个apollo-boost的东西简化了配置。我们可以自己对照官方实现一个简化版,深入学习一下。

客户端实现

私货警告

以下内容在react16.8+的hooks API和typescript下实现,模仿官方包的api设计,去掉了缓存等功能。缓存可以说是apollo提供的核心功能了,但为了缓存增加了巨量的代码,并不适合学习。 首先我们创建一个新的react工程

create-react-app graphql-client --typescript
复制代码

接下来我们要参考官方包实现以下几个使用频率最高的模块(超级精简版):ApolloClient、ApolloProvider、useQuery、Query

ApolloClient

入参包括uri,fetchOptions等,实际就是一个http请求库,这部分省点事直接用axios替代吧。注意官方实例,使用了从graphql-tag导出的gql方法处理graphql字符串,包括server端也有这个方法,它的作用是将字符串转换成ast,方便检查编写schema文件时出现的错误,本文中都省略掉了,都直接使用字符串。

import Axios, { AxiosInstance } from "axios";

type config = {
  uri: string;
};

class Client {
  constructor({ uri }: config) {
    this.uri = uri;
    this.axios = Axios.create();
  }
  private uri: string;
  private axios: AxiosInstance;
  query({ query, variables }: { query: string; variables: any }) {
    return this.axios.post(this.uri, { query, variables });
  }
}
复制代码

ApolloProvider

这个组件看得出是将client作为一个context提供给下文,使用createContext即可完成这个组件

interface ProviderProps {
  client: Client;
}

const graphqlContext: React.Context<{
  client: Client;
}> = React.createContext(null as any);

const GraphProvider: React.FC<ProviderProps> = ({ client, children }) => {
  return (
    <graphqlContext.Provider value={{ client }}>
      {children}
    </graphqlContext.Provider>
  );
};
复制代码

useQuery

因为graphql的入参固定,所以创建一个hook很容易。这里使用了一个泛型T去定义预期返回值的类型,官方包在这里还使用了第二个泛型来确定variables参数的类型。

import { useState, useContext, useEffect, Dispatch } from "react";

const useQuery = <T = any>(query: string, variables?: any) => {
  const { client } = useContext(graphqlContext);
  const [data, setData]: [T, Dispatch<T>] = useState(null as any);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null as any);
  useEffect(() => {
    setLoading(true);
    setError(null);
    client
      .query({ query, variables })
      .then(res => {
        setData(res.data.data);
        setLoading(false);
      })
      .catch(err => {
        setError(err);
        setLoading(false);
      });
  }, [query, variables, client]);
  return { data, loading, error };
};
复制代码

到这里就可以使用封装后的组件了 首先是App.tsx

const client = new Client({
  uri: "http://localhost:4000/graphql"
});
const App: React.FC = () => {
  return (
    <Provider client={client}>
      <Home></Home>
    </Provider>
  );
};
复制代码

然后是Home.tsx

interface ResData {
  getBox: {
    width: number;
    balls: { size: number; color: string }[];
  };
}
const query = `
  query {
    getBox {
      width
      balls (color:"red"){
        size
        color
      }
    }
  }`;
const Home: FC = () => {
  const { data, loading, error } = useQuery<ResData>(query);
  if (loading) return <div>loading</div>;
  if (error) return <div>{error}</div>;
  return (
    <div>
      <h2>{data.getBox.width}</h2>
      <ul>
        {data.getBox.balls.map(n => (
          <li>
            size:{n.size} color:{n.color}
          </li>
        ))}
      </ul>
    </div>
  );
}
复制代码

因为获取的数据是可预测的,所以在写出查询语句的同时完成类型文件。如果到现在编码正确,你的react项目上已经可以看到效果了

Query

该组件在创建hook之后就非常容易了,一笔带过

interface queryProps {
  query: string;
  variables?: any;
  children: React.FC<{ data: any; loading: boolean; error: any }>;
}
const Query: React.FC<queryProps> = ({ query, variables, children }) => {
  const { data, loading, error } = useQuery(query, variables);
  return children({ data, loading, error });
};
复制代码

总结

对前端来说,应用graphql并不是难点,只要能写出正确的查询语句,必然能得到正确的查询结果,难点可能是融合进现有项目,使用typescript等工具,加速开发效率。改造的难度依然在后端,想到我们之前的后端太过简陋,现在来优化一下吧,顺便补齐后端非常重要的身份认证等功能

后端目录结构优化

后端一直以来我们都在一个文件里写,随着模型变得复杂,代码开始臃肿了,同时在字符串里写schema也挺别扭,最好能写到单独的graphql/gql文件里去,这样还能有编辑器提供的格式化功能(我使用的是vscode中的Apollo GraphQL插件)。

我在这里的处理是将typeDefs拆分成对应的graphql文件,resolvers也进行文件拆分,然后使用文件扫描器自动依赖。

现在创建一个typeDefs文件夹,然后创建一个index.graphql文件,将原来的typeDefs字符串复制进去。

在同一个目录创建一个ball.gql文件,将index.graphql文件中Ball相关的定义剪贴进去。

接下来创建一个util.js,写一个代码扫描器,因为需要获取的就是字符串,所以直接用fs模块读取文件就行了

const fs = require("fs");
const path = require("path");

function requireAllGql(dir, parentArray) {
  let arr = [];
  let files = fs.readdirSync(dir);
  for (let f of files) {
    let p = path.join(dir, f);
    let stat = fs.statSync(p);
    if (stat.isDirectory()) {
      requireAllGql(p, arr);
    } else {
      let extname = path.extname(p);
      if (extname === ".gql" || extname === ".graphql") {
        let text = fs.readFileSync(p).toString();
        if (!parentArray) {
          arr.push(text);
        } else {
          parentArray.push(text);
        }
      }
    }
  }
  return arr;
}
module.exports = {
  requireAllGql
};
复制代码

这样index.js里的typeRefs就可以改成这样

const { requireAllGql } = require('./utils.js')
const path = require("path")
const typeDefs = requireAllGql(path.resolve(__dirname, './typeDefs'))
复制代码

用同样的方式解决resolver,不过要先创建一个dataSource.js,将Ball和box移到这个文件里,然后创建一个resolvers文件夹,然后创建一个query.js文件,一个mutation.js文件,一个box文件(一般根据功能模块分文件,这里是个例子)。比如现在query.js就是这样

const { box } = require('../dataSource.js')
exports.default = {
  Query: {
    getBox(_) {
      return box
    }
  }
}
复制代码

其余略过。再在utils.js创建一个resolver扫描器,每个文件的默认导出都是一个普通对象,所以处理起来并不复杂

function requireAllResolvers(dir, parentArray) {
  let arr = [];
  let files = fs.readdirSync(dir);
  for (let f of files) {
    let p = path.join(dir, f);
    let stat = fs.statSync(p);
    if (stat.isDirectory()) {
      requireAllResolvers(p, arr);
    } else {
      let extname = path.extname(p);
      if (extname === ".js" || extname === ".ts") {
        let resolver = require(p).default;
        if (!parentArray) {
          arr.push(resolver);
        } else {
          parentArray.push(resolver);
        }
      }
    }
  }
  return arr;
}
复制代码

同理可以搞定index文件里的resolvers

const resolvers = requireAllResolvers(path.resolve(__dirname, './resolvers'))
复制代码

Apollo会帮我们把数组内的内容进行merge,所以我们只要保证每个文件里的内容符合格式即可。如果一切顺利的话,项目仍然可以正确运行,并没有什么改变,但是却可以在这基础上横向扩展了。

数据库

对于一个web服务来说,数据应该储存在专门的数据库中,比如mysql、redis等,此处以常用的mysql为例,看看graphql在跟数据库结合时有什么不同。还以之前的盒子小球为例,创建一个数据库。

SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
DROP TABLE IF EXISTS `t_ball`;
CREATE TABLE `t_ball` (
  `id` int(10) NOT NULL,
  `size` int(255) DEFAULT NULL,
  `color` varchar(255) DEFAULT NULL,
  `boxId` int(10) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
BEGIN;
INSERT INTO `t_ball` VALUES (1, 5, 'red', 1);
INSERT INTO `t_ball` VALUES (2, 6, 'blue', 1);
INSERT INTO `t_ball` VALUES (3, 7, 'white', 2);
INSERT INTO `t_ball` VALUES (4, 8, 'black', 2);
COMMIT;
DROP TABLE IF EXISTS `t_box`;
CREATE TABLE `t_box` (
  `id` int(10) NOT NULL AUTO_INCREMENT,
  `width` int(255) DEFAULT NULL,
  `height` int(255) DEFAULT NULL,
  `color` varchar(255) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4;
BEGIN;
INSERT INTO `t_box` VALUES (1, 100, 100, 'red');
INSERT INTO `t_box` VALUES (2, 200, 200, 'blue');
COMMIT;

SET FOREIGN_KEY_CHECKS = 1;
复制代码

回到express项目,因为模型有点细微的变化,加入了主键id,所以gql文件中的schema需要加入id,下面列出需要修改的schema

type Box {
  id: Int
  width: Int
  height: Int
  color: String
  balls(color: String): [Ball]
}
type Ball {
  id: Int
  size: Int
  color: String
}
type Query {
  getBox: [Box]
}
type Mutation {
  setWidth(width: Int, id: Int): Box
}
复制代码

在项目里增加mysql的包

yarn add mysql
复制代码

建立连接池,将查询简单封装,就不引入DAO层了,毕竟一共没几句sql

const mysql = require('mysql')

const pool = mysql.createPool({
  host: '127.0.0.1',
  user: 'root',
  password: 'password',
  database: 'graphqldemo',
  port: 3306
})

const query = (sql, params) => {
  return new Promise((res, rej) => {
    pool.getConnection(function (err, connection) {
      connection.query(sql, params, function (err, result) {
        if (err) {
          rej(err);
        } else {
          res(result);
        }
        connection.release();
      });
    });
  })
}
复制代码

在resolver中引入sql前需要知道resolver的四个参数。第一个参数parent,是当前元素的父元素,顶级的schema的父元素称为root,大部分教程中用_代替。第二个参数是params,也就是查询参数。第三个参数是config,其中有一个参数dataSources我们过会儿需要用到。第四个参数是context,它的入参是express的Request和Response,可以用来传入身份信息,进行鉴权等操作。

我们把封装好的query函数放进这个dataSources。在index.js中修改

const server = new ApolloServer({
  typeDefs,
  resolvers,
  dataSources: () => ({
    query
  })
});
复制代码

接着就可以修改resolvers,先实现第一个getBox,因为现在不止一个盒子,所以返回的是一个数组,schema已经进行了修改

  Query: {
    getBox(_, __, { dataSources: { query } }) {
      return query('select * from t_box')
    }
  }
复制代码

query返回的是一个Promise,Apollo是支持这种写法的

然后完成Box的balls,我们需要从parent中拿到父元素的id

  Box: {
    balls(parent, { color }, { dataSources: { query } }) {
      return query('select * from t_ball where box_id=? and color=?', [parent.id, color])
    }
  }
复制代码

最后还有一个Mutation需要修改,schema中的定义返回的是被修改后的box,所以需要两条sql来完成这个部分

  Mutation: {
    async setWidth(_, { width, id }, { dataSources: { query } }) {
      await query('update t_box set width=? where id=?', [width, id])
      return query('select * from t_box where id=?', [id]).then(res => res[0])
    }
  }
复制代码

到这里,基本已经完成了一个graphql项目的基础,在此基础上横向扩展就能够完成一个简单的项目。另外,正式的项目中,还是需要DAO层来管理数据,否则重构会教你做人的。

typescript & type-graphql

迎合潮流,我们需要typescript的加持,否则怎么写都会被认为是玩具。但是我们思考一个问题,typescript的类型和graphql都是对模型的描述,基本一致,只在语法上有一些区别,能不能通用呢。官方提供了相关的API实现这个需求,但是语法并不简洁,type-graphql也许是更好的选择。

先导入typescript和type-graphql,还有之前用到的包的描述文件,另外type-graphql扫描注解用到了reflect-metadata这个还未进入标准的特性,所以需要引入这个包

yarn add typescript type-graphql reflect-metadata @types/mysql @types/express
复制代码

typescript老规矩,先写tsconfig.json,大概有以下内容就差不多了

{
  "compilerOptions": {
    "target": "es6",
    "module": "commonjs",
    "lib": ["es6", "es7", "esnext", "esnext.asynciterable"],
    "noImplicitAny": false,
    "moduleResolution": "node",
    "baseUrl": ".",
    "esModuleInterop": true,
    "inlineSourceMap": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "watch": true
  }
}
复制代码

然后把所有require改成import,后缀名js改成ts就行了如果有报错就写个any

运行项目我们使用ts-node,全局安装ts-node之后执行ts-node index.ts即可启动。正式项目我们可以使用pm2指定解释器或者将项目编译成js来运行。然后我们将type-graphql引入项目。

非常遗憾的是引入type-graphql后代码结构发生大改,除了数据库相关的内容基本可以重写了,graphql文件也不需要了。先建立一个models文件夹,新增两个文件Box.ts和Ball.ts

import { ObjectType, Field } from "type-graphql";

@ObjectType()
export default class Ball {
  @Field()
  id: number;

  @Field()
  size: number;

  @Field()
  color: string;
  
  boxId: number;
}
复制代码
import { ObjectType, Field, Int } from "type-graphql";
import Ball from "./Ball";

@ObjectType({ description: "这是盒子模型" })
export default class Box {
  @Field(type => Int)
  id: number;

  @Field(type => Int, { nullable: true, description: "这是宽度" })
  width: number;

  @Field(type => Int)
  height: number;

  @Field()
  color: string;

  @Field(() => [Ball])
  balls: Ball[];
}
复制代码

ObjectType注解代表这个类是graphql中的对象类型,而被Field注解的属性就是定义到graphql中的属性。Field的第一个参数是个函数用来表示类型,函数的入参没有意义,写type是为了语义化,返回值是类型(typescript的数字类型是number,但graphql的数字类型分为Int和Float,如果不指定为Int,typegraphql默认number为Float);第二个参数是配置项,nullable默认为false,这里可以改为true,description是注释

然后修改一下index.ts,引入graphql之前的东西基本都不要了,就保留数据库的query方法,另外query方法不放到dataSources中了,放到Context中

import "reflect-metadata";
import express from "express";
import { ApolloServer } from "apollo-server-express";
import path from "path";
import query from "./db";
import { buildSchema } from "type-graphql";

const PORT = 4000;
const app = express();
app.use(express.static("public"));

buildSchema({
  resolvers: [path.resolve(__dirname, "./resolvers/*.resolver.ts")]
}).then(schema => {
  const server = new ApolloServer({
    schema,
    context: () => ({
      query
    })
  });
  server.applyMiddleware({ app });

  app.listen(PORT, () =>
    console.log(
      `🚀 Server ready at http://localhost:${PORT}${server.graphqlPath}`
    )
  );
});
复制代码

buildSchema是异步的,所以ApolloServer启动要放在这之后,之前提到过ApolloServer需要提供typeDefs和resolvers两个参数或一个schema参数。看buildSchema这个函数的入参,就知道我们还有resolvers没有改造,在resolvers文件夹下新建一个Box.resolver.ts,把之前的三个resolver改造一下

import {
  Resolver,
  Query,
  Arg,
  Mutation,
  Ctx,
  FieldResolver,
  Root
} from "type-graphql";
import Box from "../models/Box";
import Ball from "../models/Ball";

@Resolver(Box)
export default class BoxResolver {
  @Query(returns => [Box])
  getBox(@Ctx() { query }) {
    return query("select * from t_box");
  }

  @FieldResolver(returns => [Ball])
  balls(@Root() box: Box, @Ctx() { query }, @Arg("color") color: string) {
    return query("select * from t_ball where boxId=? and color=?", [
      box.id,
      color
    ]);
  }

  @Mutation(returns => Box)
  async setWidth(
    @Arg("width") width: number,
    @Arg("id") id: number,
    @Ctx() { query }
  ) {
    await query("update t_box set width=? where id=?", [width, id]);
    return query("select * from t_box where id=?", [id]).then(res => res[0]);
  }
}

复制代码

简单说一下几个注解的意思。Query和Mutation代表是这两个基础类型下的resolver,参数是个函数,表示预期的返回类型;FieldResolver与类注解Resolver关联,代表Box这个对象类型下的字段balls的resolver;Arg注解的是参数,第一个参数是入参的参数名,它还有第二个参数,可以配置nullable;Root注解的参数是父元素,在balls方法中拿到了父盒子的id;Ctx注解的是Context,从中取到了apolloserver中context的query方法。

到这里,不需要手写graphql文件,依然完成了一个graphql服务器,而且typeDefs和resolvers结合到了一起,不用担心漏写了。并且我们的程序已经大变样,不深入学习一番已经看不懂了,恭喜你离建立技术护城河更进一步。

type-graphql有个内置的权限管理,有兴趣的话可以看看Authorized注解

typeorm

ORM是否要引入项目,主要还是看项目需求。这里使用typeorm,它在写法上与typegraphql非常契合,因为他可以直接复用typegraphql中创建的model,是typegraphql在数据层上非常好的一个实现方式。typeorm本身内容很多,可以单独写一篇文章,本文只介绍与graphql有关的部分,先引入typeorm

yarn add typeorm
复制代码

然后在项目根目录创建一个ormconfig.json,输入数据库配置

{
  "type": "mysql",
  "host": "127.0.0.1",
  "port": 3306,
  "username": "root",
  "password": "password",
  "database": "graphqldemo",
  "synchronize": false,
  "logging": false,
  "entities": ["./models/*.ts"]
}
复制代码

其中type是数据库类型,typeorm支持MySQL、MariaDB、Postgres、SQLite、Oracle、MongoDB等多种数据库。synchronize如果为true,typeorm会根据模型自动建表,如果模型有修改还会对表结构进行修改(外键等原因会导致修改失败项目无法启动,需要手动干预或者使用typeorm中的migrations)。logging为true会在控制台打印自动生成的sql语句。

先修改index.ts

import { createConnection } from "typeorm";
//····在app.listen之前添加
  createConnection().then(() => {
    app.listen(PORT, () =>
      console.log(
        `🚀 Server ready at http://localhost:${PORT}${server.graphqlPath}`
      )
    );
  });
//····
复制代码

跟typegraphql的buildSchema一样,createConnection也是个异步的promise,所以为了防止一些意外情况,app.listen的操作要在这两个过程之后。如果已经建立了ormconfig.json文件,createConnection会自动读取其中的配置,否则需要将其作为参数填进去。

然后修改models,之后models可以同时在type-graphql中表示类型,也可以在typeorm中作为数据实体,一模两吃

import { ObjectType, Field, Int } from "type-graphql";
import Box from "./Box";
import {
  Column,
  ManyToOne,
  Entity,
  BaseEntity,
  PrimaryGeneratedColumn
} from "typeorm";

@Entity({ name: "t_ball" })
@ObjectType()
export default class Ball extends BaseEntity {
  @PrimaryGeneratedColumn()
  @Field(type => Int)
  id: number;

  @Column()
  @Field(type => Int)
  size: number;

  @Column({ type: "varchar", length: 255 })
  @Field()
  color: string;

  @Column()
  boxId: number;

  @ManyToOne(type => Box)
  box: Box;
}
复制代码
import { ObjectType, Field, Int } from "type-graphql";
import Ball from "./Ball";
import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  OneToMany,
  BaseEntity
} from "typeorm";

@Entity({ name: "t_box" })
@ObjectType({ description: "这是盒子模型" })
export default class Box extends BaseEntity {
  @PrimaryGeneratedColumn()
  @Field(type => Int)
  id: number;

  @Column()
  @Field(type => Int, { nullable: true, description: "这是宽度" })
  width: number;

  @Column()
  @Field(type => Int)
  height: number;

  @Column({ type: "varchar", length: 255 })
  @Field()
  color: string;

  @OneToMany(type => Ball, ball => ball.box)
  @Field(() => [Ball])
  balls: Ball[];
}
复制代码

首先将类继承自typeorm中的BaseEntity,并且类上增加了一个Entity注解,参数的配置项中加一个name可以指定表名;Column注解这个属性是数据库的一列,参数可以指定具体的类型和长度;PrimaryGeneratedColumn注解代表这是一个自增主键;OneToMany是个特殊的注解,用来描述实体直接的relations,一共包括OneToMany,ManyToOne,ManyToMany三种,具体用法说来话长,请自行摸索。这样就和之前建立的数据库对应了,感兴趣的可以使用typeorm的自动建表功能看看有什么不同。

现在我们可以抛弃简陋封装的query方法,直接使用typeorm提供的数据获取方式

import {
  Resolver,
  Query,
  Arg,
  Mutation,
  FieldResolver,
  Root,
  Int
} from "type-graphql";
import Box from "../models/Box";
import Ball from "../models/Ball";

@Resolver(Box)
export default class BoxResolver {
  @Query(returns => [Box])
  getBox() {
    return Box.find();
  }

  @FieldResolver(returns => [Ball])
  balls(@Root() box: Box, @Arg("color", { nullable: true }) color: string) {
    return Ball.find({ boxId: box.id, color });
  }

  @Mutation(returns => Box)
  async setWidth(
    @Arg("width", type => Int) width: number,
    @Arg("id", type => Int) id: number
  ) {
    let box = await Box.findOne({ id });
    box.width = width;
    return box.save();
  }
}
复制代码

整个程序实现非常的优雅,并且很难懂~。typeorm的内容非常多,如果对其他的orm有经验,上手还是很快的。另外如果真的有typeorm写不出来的sql,该手写就手写吧~

总结

这篇文章我很早就开始写了,但是摊子铺的太大,所以一直写不完主要是打怪物猎人冰原。文中选取的模型也非常简单,但是基本完成了一个graphql服务器的框架,当然也留下很多内容没有讲,比如非常重要的标量类型和输入类型。因为本人主业是前端,会一点java后端,所以如果文章中出现概念性错误,请评论指出,共同进步。