走在JS上的全栈之路(二)(1/2)

2,400 阅读15分钟

(这是一个系列文章:预计会有三期,第一期会以同构构建前后端应用为主,第二期会以 GraphQL 和 MySQL 为主,第三期会以 Docker 配合线上部署报警为主)

作者: 赵玮龙

重要声明: 从此不再以 AMC 团队名称发布文章,原因不详述,所有文章和后续文章将由个人维护,如果你对我的文章感兴趣,也请继续支持和关注,再次声明-个人还是会保持更新和最新以及前沿技术的踩坑,不仅仅局限于前端领域!

可能你也发现题目出现了1/2,因为如果介绍 GraphQL 和 MySQL 一起,容易忽略掉中间的很多细节过程,还有篇幅本身问题,我准备把他们拆开来说,我仔细想了下,我先从前端的角度看 GraphQL 如何耦合到我们的项目中,看看它能为我们带来什么并且解决了什么问题(虽然拆开说,篇幅还是非常长的,希望各位感兴趣的同学可以先点赞保存~~~慢慢看),再然后我们看看 node 端如何从数据库层面支持 GraphQL,还是保留学习的心态~ 虚心向大家学习并且给自己的学习过程留下一些印记。


正片的分界线

什么是GraphQL,为什么我们会需要它

先来阐述下什么是GraphQL

A query language for your API GraphQL is a query language for APIs and a runtime for fulfilling those queries with your existing data. GraphQL provides a complete and understandable description of the data in your API, gives clients the power to ask for exactly what they need and nothing more, makes it easier to evolve APIs over time, and enables powerful developer tools.

这是官网上对他的解释,一种专门为 API 设计的查询语言: 一种满足你已有数据查询需求的 runtime,没有冗余并且精确的查询你需要的数据,也能让 API 更易于维护和演进。

我仔细回想日常中开发遇到的那些觉得很麻烦的问题:

  • 首先是每次开发需求开始状态都需要都 mock 的数据,根据后端提供的接口文档去生成自己的 mock 数据无论是公司已有的 mock server 工具还是自己的 mock server 或者是第三的 mock server 这让开发变得繁琐,原因是在开发中我需要不停的查询后端的接口文档制定自己的 mock 数据并且,联调中还要会因为字段等等与后端不一致再次更改。
  • 每次请求资源的时候,经常会遇到各种串行·并行请求,核心原因可能是因为后端领域服务或者是数据库层面的原因,这样会导致我们在得到我们需求的资源过程变得非常复杂。
  • 由于上面的问题或者哪怕是单个接口我们往往也需要把接口返回的数据 normalize 化,当然就算你不 normalize 也需要处理返回数据拿到你真正映射到 UI 的 data。(后端返回数据往往不是我们真正想要的,或者说不是全部我们都需要的。)
  • 根据 RESTful 请求也就意味着我们需要很多接口,或者说是起很多接口名称定位资源并且资源定位未必准确,这样不仅仅浪费IO次数也会产生很多其实没必要的网络请求。

当然基于上面的问题我也知道现在各个公司本身也有自己 BFF 方案,针对前端做一定的优化。确实解决了上面一些问题。但是再后退一步说如果就 RESTful 本身的问题来思考的化,其实 GraphQL 解决的就是 RESTful 本身解决不了的问题。 我们都知道:

REST -> Representational State Transfer Resources 意味着单一资源无论是一张图片一个文件等等对应唯一的资源定位符

那么问题其实就在这里,往往随着现在前端界面的复杂化,我们需要的资源往往不是单一资源了。那么这种架构本身也确实会有它的短板。

对比之下我们看看为什么可能会需要GraphQL

先明确一个概念GraphQL是基于SDL -> Schema Definition Language 熟悉数据库的同学可能对于schema概念比较熟悉,其实我们也可以根据这个名称去思考它本身Graph(图),图的概念本身就是你的data树形结构。

我们看一下官网首页的例子:

# 描述的数据schema:
type Project {
  name: String
  tagline: String
  contributors: [User]
}

# 你的请求数据:
{
  project(name: "GraphQL") {
    tagline
  }
}

# 你得到的数据:
{
  "project": {
    "tagline": "A query language for APIs"
  }
}

从上面的例子我们思考下,如果每个数据本身都定义 schema 好处有两点:

  • 这看起来是不是更加像天然的接口文档
  • 每个字段都有自己的 scalar(类型),这点对于js本身弱类型来说是个极好的消息。

既然解决了声明 schema 和接口文档问题,那它能不能解决多个 IO 请求和复用一个资源定位uri定位所有资源的问题呢?

首先复用一个资源定位 uri 定位所有资源肯定是没问题的,前面我们提到过既然是你的请求数据结构决定返回数据结构。那么无论你发出什么样的请求都会有相同的映射,服务端是不需要根据uri知道你具体请求什么信息了,而是通过你请求的格式(图)来判断是时候祭出官方的资源了:

我们还是借用官网的例子来看下:

# 你的请求资源可能涵盖之前RESTful的许多个接口或者是一个特别大的json数据
# 你可能在怀疑那如果RESTful一个接口也能返回下面的数据岂不是也很完美,没错可是如果我跟你说我可能需要的homeWorld 里的数据是特定的name 和climate呢?我们还需要去url上传参数,并且实际情况是后端往往觉得这样的东西我返回给你全部,你自己去拿就好啦。
{
  hero {
    name
    friends {
      name
      homeWorld {
        name
        climate
      }
      species {
        name
        lifespan
        origin {
          name
        }
      }
    }
  }
}

# 对应的schema

type Query {
  hero: Character
}

type Character {
  name: String
  friends: [Character]
  homeWorld: Planet
  species: Species
}

type Planet {
  name: String
  climate: String
}

type Species {
  name: String
  lifespan: Int
  origin: Planet
}

针对于拿特定数据这个问题为了更好的 (data=>UI),我看到一篇文章说代替之前 redux 的使用经验特别的好推荐给大家。

我一直觉得这个对话框特别的有说服力:

下面我们来在我们的项目中实践下GraphQL

改造上一篇中的ssr

我们先不要一口吃个胖子,先来一步步的改造之前的 ssr 耦合 GraphQL 看看这东西是怎么玩的。我们的目的是利用 mock 的数据打通前后端流程。

先介绍下我们用到的工具,直接使用 GraphQL 会有一些难度,所以我们采用 Apollo 提供的一些工具:

  • graphql-tag
  • apollo-client
  • apollo-server-koa
  • graphql-tools
  • apollo-cache-inmemory
  • react-apollo

我们会在后面的使用中提到他们的一部分使用方式,当然最好·最全的使用方式是阅读官方文档

既然我们提到我们不再需要各种url去定义一个资源本身,意味着我们只需要一个接口全部搞定(我并没有删掉之前代码而是禁掉,方便大家观察区别):

// apollo模块替代redux
import { ApolloProvider, getDataFromTree } from 'react-apollo';
import { InMemoryCache } from 'apollo-cache-inmemory';
import { SchemaLink } from 'apollo-link-schema';
import { ApolloClient } from 'apollo-client';

// apollo grahql操作模块
import { makeExecutableSchema } from 'graphql-tools';
import { graphqlKoa } from 'apollo-server-koa';

// redux
// const { Provider } = require('react-redux');
// const getStore = require('../common/store').default;

// api前缀
const apiPrefix = '/api';

// 引入schema
let typeDefs;
const pathName = './server/schema.graphql';

if (typeof pathName === 'string' && pathName.endsWith('graphql')) {
  const schemaPath = path.resolve(pathName);
  typeDefs = importSchema(schemaPath);
};

// resolvers
let links = [{
  id: 'link-0',
  url: 'www.howtographql.com',
  description: 'Love GraphQL'
},
{
  id: 'link-002',
  url: 'www.howtographql.com',
  description: 'Love GraphQL'
}];

let idCount = links.length;

const resolvers = {
  Query: {
    info: () => `respect all, fear none!`,
    feed: () => links,
    name: () =>  `赵玮龙`,
    age: () =>  29
  },
  Mutation: {
    post: (root, args) => {
      const link = {
        id: `link-${idCount++}`,
        description: args.description,
        url: args.url,
      }
      links.push(link)
      return link
    },
    deleteLink: (root, args) => {
      return links.filter(item => item.id !== args.id)
    }
  }
}

// 生成schema
const schema = makeExecutableSchema({
  typeDefs,
  resolvers
})

// 路由
module.exports = function(app, options={}) {
  // 页面router设置
  app.get(`${staticPrefix}/*`, async (ctx, next) => {
    // graphql接口设置
    const client = new ApolloClient({
      link: new SchemaLink({ schema }),
      ssrMode: true,
      connectToDevTools: true,
      cache: new InMemoryCache(),
    })

    const helmet = Helmet.renderStatic();
    const context = {};
    options.title = helmet.title;

    // restful api redux数据源
    // const store = getStore();
    // const promises = routes.map(
    //   route => {
    //     const match = matchPath(ctx.path, route);
    //     if (match) {
    //       let serverFetch = route.component.loadData
    //       return serverFetch(store.dispatch)
    //     }
    //   }
    // )

    // const serverStream = await Promise.all(promises)
    // .then(
    //   () => {
    //     return ReactDOMServer.renderToNodeStream(
    //       <Provider store={store}>
    //         <StaticRouter
    //           location={ctx.url}
    //           context={context}
    //           >
    //           <App/>
    //         </StaticRouter>
    //       </Provider>
    //     );
    //   }
    // );

    // graphql提取数据并且渲染dom
    const Html = (
      <ApolloProvider client={client}>
        <StaticRouter
          location={ctx.url}
          context={context}
          >
          <App/>
        </StaticRouter>
      </ApolloProvider>
    );
    const serverStream = await getDataFromTree(Html).then(() => ReactDOMServer.renderToNodeStream(Html));
    // console.log(serverStream.readable);
    await streamToPromise(serverStream).then(
      (data) => {
        options.body = data.toString();
        if (context.status === 301 && context.url) {
          ctx.status = 301;
          ctx.redirect(context.url);
          return ;
        }
        // 把store.getState()替换成client.extract()
        if (context.status === 404) {
          ctx.status = 404;
          ctx.body = renderFullPage(options, client.extract());
          return ;
        }
        ctx.status = 200;
        ctx.set({
          'Content-Type': 'text/html; charset=utf-8'
        });
        ctx.body = renderFullPage(options, client.extract());
    })
    // console.log(serverStream instanceof Stream);
    await next();
  });

  // api路由
  // app.get(`${apiPrefix}/user/info`, async(ctx, next) => {
  //   ctx.body = {
  //     code: 10000,
  //     msg: '',
  //     data: {
  //       name: '赵玮龙',
  //       age: 29,
  //     }
  //   }
  //   await next();
  // });
  //
  // app.get(`${apiPrefix}/home/info`, async(ctx, next) => {
  //   ctx.body = {
  //     code: 10000,
  //     msg: '',
  //     data: {
  //       title: '你要的网站',
  //       content: '那些年我想过的女孩~',
  //     }
  //   }
  //   await next();
  // });

  // 设置调试GraphQL-playground
  app.all('/graphql/playground', koaPlayground({
      endpoint: '/graphql',
    })
  );

  // GraphQl api
  app.all('/graphql', graphqlKoa({ schema }));
}

先来看下路由方面我们声明了两个路由:

  • /graphql(用于请求数据的接口)
  • /graphql/playground(graphql实现请求界面)

我们看到根据 ssr 本身的原理,我们把 INITIAL_STATE 换成了GraphQL的数据,这正是我们后面会说道的利用 GraphQL 代替 redux 的方案 聚焦下三个问题。

  • schema (随着业务的发展我们会把 schema 分出去单独成文件,当然如果你的编辑器支持 graphql 语法,你当然更希望以 .graphql 文件结尾然后拥有IDE的功能)
  • resolvers (随着数据库的加入我们下篇文章会说如何做 ORM 映射,这里先是 mock 数据)
  • ApolloClient (替代掉 createStore)

schema 本身我们希望它写在单独的文件中,例如 .graphql 中,做到拆分逻辑,但是目前 node 还不支持这个结尾文件名,我们用的第三方库,当然自己做也并不难,就是利用 fs 读出 utf8 编码的字符串就行。

import { importSchema } from 'graphql-import';
let typeDefs;
const pathName = './server/schema.graphql';

if (typeof pathName === 'string' && pathName.endsWith('graphql')) {
  const schemaPath = path.resolve(pathName);
  typeDefs = importSchema(schemaPath);
};

再然后看看 schema:

type Query {
  info: String
  """
  the list of Posts by this author
  """
  # Link实例拿到root声明,每一个field都需要到它上层申明
  feed: [Link!]!
  name: String!
  age: Int!
}

type Link {
  id: ID!
  description: String!
  url: String!
}

type Mutation {
  post(url: String!, description: String!): Link!
  deleteLink(id: ID!): [Link!]!
}

# interface Character  {
#   id: ID!
#   name: String!
#   role: Int!
# }
#
# type Master implements Character {
#
# }

resolvers 主要解决的是 schema 声明的字段处理方式,每个字段都有自己的 function 这个本身不难理解

但是你会发现,根据你的请求是 query 或者 mutation 会有参数或者一些 resolvers 中互相共享的参数等,这就是这个函数本身的一些参数:

  • root 相当于当前字段的父级字段信息。
  • args: 字段本身的参数。
  • context: resolver之间本身的共享对象。
  • info: 你的 schema AST 语法树

主要前三个参数会是经常用到的。

既然服务端定义好了数据,我们可以通过之前的 /graphql/playground 访问数据看看能否得到想要的结果 我们发现这里还有我们之前定义的全部 schema 这个文档查询简直是太方便啦!

至于客户端代码,我们还用 react 来耦合 graphql

import React from 'react';
import Helmet from 'react-helmet';
import PropTypes from 'prop-types';

// import { connect } from 'react-redux';
import { withRouter } from 'react-router'

import { ApolloConsumer, Query } from 'react-apollo';
import gql from 'graphql-tag';

// actions
// import {
//   userInfoAction,
//   homeInfoAction
// } from '../actions/userAction';

// selector
// import {
//   selectQueryDataFromUser,
// } from '../reducers/entities'
//
// const mapStateToProps = (state, ownProps) => {
//   const userInfo = selectQueryDataFromUser(state)
//   return {
//     ...userInfo,
//   }
// };
//
// const mapDispatchToProps = {
//   userInfoAction,
//   homeInfoAction
// };
//
// @connect(mapStateToProps, mapDispatchToProps)

const GET_INFO_AUTH = gql`
{
  info
  feed {
    id
    url
    description
  }
  name
  age
}
`
class Home extends React.Component {
  // static loadData(dispatch) {
  //   return Promise.all([dispatch(userInfoAction()), dispatch(homeInfoAction())])
  // }

  static defaultProps = {
    name: '',
    age: null,
  }

  render() {
    const {
      name,
      age,
    } = this.props
    return (
      <React.Fragment>
        <Helmet>
          <title>主页</title>
        </Helmet>
        <h1>{name}</h1>
        <h2>{age}</h2>
      </React.Fragment>
    );
  }
}

export default withRouter(
  () => (
    <Query
      query={GET_INFO_AUTH}
      >
      {
        ({ loading, error, data }) => {
          if (loading) return "loading..."
          if (error) return  `Error! {error.message}`
          return (
            <Home
              age={data.age}
              name={data.name}
              />
          )
        }
      }
    </Query>
  )
);

这里有一个叫 Query 的高阶组件,当然也有 Mutation,具体你可以查阅官方文档。 我们会发现这个高阶组件把 fetch 包裹起来暴露给我们需要的 data, loading 之类的数据供我们渲染 UI。

如何替代 redux 做数据管理

相关 redux 本身的概念和它解决了哪些问题,如果你看兴趣可以看这里,当然我们这里探讨的是利用 GraphQL 去替代 redux。我们从上面的结构化·精确请求能发现,如果我们能直接请求需要 UI 渲染的数据,就会省去很多处理数据和 normalize 化的过程,但是还有一个主要的问题没有解决,就是除去 server data 以外,还有很多本地的 data 处理,比如按钮展示隐藏 boolean,或者说本地的 data 和 server data 关联的问题,这就是为什么在 redux 中我们会把他们放在一起管理,那么 GraphQL 如果能解决这个问题并且也有一个全局唯一类似于 store 一样的数据源,这样我们就不需要 mobx·redux 之类的数据管理库了,很幸运的是 Apollo 确实帮我们这么做了,下面我们来介绍下这个功能。既然用到本地的数据,最合适的例子还是大家熟悉的 TodoList(在 home 页添加这个):

// 我们新建一个todoForm 的文件写我们的todoList组件
import React from 'react';
import { graphql, compose } from 'react-apollo';
import { withState } from 'recompose';
import {
  addTodoMutation,
  clearTodoMutation,
  todoQuery,
} from '../../client/queries';

const TodoForm = ({
  currentTodos,
  addTodoMutation,
  clearTodoMutation,
  inputText,
  handleText,
}) => (
  <div>
    <input
      value={inputText}
      onChange={(e) => handleText(e.target.value)}
      />
    <ul>
    {
      currentTodos.map((item, index) => (<li key={index}>{item}</li>))
    }
    </ul>
    <button 
      onClick={() => {
        addTodoMutation({ variables: { item: inputText } })
        handleText('')
    }}>
      Add
    </button>
    <button 
      onClick={(e) => clearTodoMutation()}
    >
      clearAll
    </button>
  </div>
)

const maptodoQueryProps = {
  props: ({ ownProps, data: { currentTodos = [] } }) => ({
    ...ownProps,
    currentTodos,
  }),
};

export default compose(
  graphql(todoQuery, maptodoQueryProps),
  graphql(addTodoMutation, { name: 'addTodoMutation' }),
  graphql(clearTodoMutation, { name: 'clearTodoMutation' }),
  withState('inputText', 'handleText', ''),
)(TodoForm)

// queries.js
import gql from 'graphql-tag';

// 这里的@写法是directives,可以查看上面的官方文档
const todoQuery = gql`
  query GetTodo {
    currentTodos @client
  }
`;

const clearTodoMutation = gql`
  mutation ClearTodo {
    clearTodo @client
  }
`;

const addTodoMutation = gql`
  mutation addTodo($item: String) {
    addTodo(item: $item) @client
  }
`;

export {
  todoQuery,
  clearTodoMutation,
  addTodoMutation,
}

看下效果:

(右边的 chrome 插件是 apollo)

我们先看下几个问题:

  • compose 里一堆奇怪的东西是干嘛的。
  • maptodoQueryProps 是什么
  • graphql() 是什么鬼。。

第一个问题:

不知道大家有没有在写react的时候,习惯 stateless components 的形式呢? 我个人比较偏爱这种写法,当然啦它也有自己的不足,就是没有 state 和生命周期,但是人们肯定不会放弃使用它们,甚至有人想的更加极致就是代码里暴露都是这种 FP 风格的写法,于是就有了recompose,如果你有兴趣可以研究它的文档使用下。这里不是这次的重点,我们带过,其实为了实现你的 UI 层的抽离,比如把逻辑层抽离在 HOC 高阶组件里,比如上面你看到的 withState 就是一个高阶组件,声明的 state 和相应的 function,你可能会好奇问什么要这样写呢?

// 我们设想下如果我们采用 Mutation 和 Query 组件嵌套的模式避免不了出现下面的形式(是不是感觉有点像回调地狱呢?):
<Mutation>
  {
    ...
    <Query>
      {
        ...
      }
    </Query>
    ...
  }
</Mutation>

// recompose也提供了组合多个高阶组件的模式 compose, 当然 apollo 也有(相当于a(b(c())))

compose(a, b, c)

// 这样的代码看起来会不会舒服很多呢?

第二个问题:

maptodoQueryProps 是什么? 用过 react-redux 的同学肯定熟悉 mapStateToProps 和 mapDispatchToProps 这两个函数,这里没有 dispatch 的概念,但是作者也是深受之前这个库的影响,想把 mutation, query data 也通过这种模式有一个 props 的映射。当然这里不止是 props 一个 key 具体可以参考这里,所以其实是把 props.data(query) 和 props.mutation(mutation) 分别按照自己对于 props 的需求映射到 UI 组件上(是不是很像 selector)。

第三个问题:

这里是我们主要要解释的,大家一定好奇,这个 todoList 逻辑呢?我们的reducer 去哪啦?

import {
  todoQuery,
} from './queries';

const todoDefaults = {
  currentTodos: []
};

const addTodoResolver = (_obj, { item }, { cache }) => {
  const { currentTodos } = cache.readQuery({ query: todoQuery });
  const updatedTodos = currentTodos.concat(item);
  
  cache.writeQuery({
    query: todoQuery, 
    data: { currentTodos: updatedTodos }
  });
  return null;
};

const clearTodoResolver = (_obj, _args, { cache }) => {
  cache.writeQuery({
    query: todoQuery,
    data: todoDefaults
  });
  return null;
};

export {
  addTodoResolver,
  clearTodoResolver,
  todoDefaults,
}

还记得我们前面说 apollo-server 里的 resolver 处理 schema 相应字段的逻辑吗?这里的概念基本类似,apollo 还是利用 resolver 去处理字段级别的逻辑,你可能会问这不是 reducer 的概念,没错这里完全不是 redux 的理念,而是对于 AST 语法树的一种处理而已(所以这里也没有强迫你去用 pure function 处理, 并且强调 reducer 的可组合拆分性,这是我觉得非常难过的地方,它失去了 redux 核心理念,换来一堆我根本就不想学的 api 和参数,哎。这个 apollo 在我认为就是 api 太多,本人之所以一直很欣赏 react+redux 解决方案,就因为灵活度很高并且 api 很少,这种做法也算是抽离了逻辑层吧)

这里有4个api,这里有详细的文档,这4个 api 分别操作 query 和 fragment,但是就我个人而言真的没有 reducer 容易理解并且灵活性强,期待你们的看法!

我们会随着项目深入继续说一些 GraphQL 的概念和使用方法,也希望感兴趣的你可以留言交流。这里面东西确实是很多,坑也很多,所以没有涉及到的地方,我们以后还是开个专题来讨论下 GraphQL 很多缓存策略包括 redies 使用以及如何鉴权的方案(项目后面会涉及到部分,但是并不全面,敬请期待!)

因为这次代码改动量比较大,我还是把源码放在这里,希望大家不要觉得我耍流氓只说不放源码! (如果你喜欢的话给个 star 吧!)