React全家桶与前端单元测试艺术

TL;DR——什么是好的单元测试?

其实我是个标题党,单元测试根本没有“艺术”可言。

好的测试来自于好的代码,如果说有艺术,那也是代码的艺术。

注:以下“测试”一词,如非特指均为单元测试。

 

单元测试的好坏在于“单元”而不在“测试”。如果一个系统毫无单元可言,那就没法进行单元测试,几乎只能用Selenium做大量的E2E测试,其成本和稳定性可想而知。科学的单元划分可以让你摆脱mock,减少依赖,提高并行度,不依赖实现/易重构,提高测试对业务的覆盖率,以及易学易用,大幅减少测试代码。

最好的单元是返回简单数据结构的函数:函数是最基本的抽象,可大可小,不需要mock,只依靠传参。简单数据结构可以判等。 最好的测试工具是Assert.Equal这种的:只是判等。判等容易,判断发生了什么很难。你可以看到后面对于DOM和异步操作这些和副作用相关的例子都靠判等测试。把作用幂等于数据,拿到数据就一定发生作用,然后再测数据,是一个基本思路。

以上是你以前学习测试第一天就会的内容,所以不存在门槛。

为什么不谈TDD?

首先,TDD肯定是有价值的(价值大小不论)。反对TDD的原因一般比较明显,对于TDD是否带来正收益不确定(动机不足)。 某些项目质量要求很高,预算宽绰,TDD势在必行。某些项目比较紧急,或者并非关键或无长期维护计划,TDD理由就不充分。

为什么谈测试?

因为测试难。

第一难学,第二难写。写测试是个挺困难的活,要在测试里正确重演业务要费好大劲,只能靠反复练习。虽然这些测试在某些项目中是值得的,但是可能并不适合其他某些项目的基本情况。

测试难,就代表训练成本高,生产成本也高,收益就下降。要提高采用TDD的动机,与其说服别人,不如从简化测试开始。

(图片来自:http://t.cn/Rpw9WKg

为什么谈前端测试?

一般项目都是后端测试覆盖率高,同时后端套路也比较固定。测RESTful API粒度足够大,可以很好地避开实现并且覆盖业务。同时RESTful API一般也正好对应Web框架的Action handler,在这里同时它粒度也足够小,刚好可以直接调用而不启动真的Web server,使得测试最大程度并行化。所以这样测试收益总是最高的,争议很小。

前端不说套路不固定,测不测都有待商榷。因为前端流派不统一,资源不规则,边界也不清晰,有渲染又有点业务,有导航有请求,很多团队不测试/测Model/测Component/测E2E,五花八门。 但得益于JavaScript本身,前端测试其实是可以非常高效的。

下面你可以看到各种极简极快的测试工具和测试方式,并且它们完全可以贯穿开发始终,而非仅给Hello World体量项目准备的,你可以在很大的全家桶项目中完全机械地套用这些方法。(机械也是极限的一部分,你不应该在使用工具过程中面临太多抉择,而应当专注于将业务翻译成测试)。

为什么谈React全家桶?

前端从每周刷新一个框架,稳定到了Angular, React, Vue3个主流框架并存的阶段。网络中争论这三个框架盖的楼已经可以绕太阳系了。根据盖的各种大楼看来,现在哪个更优秀还没个定论。不过具体到单元测试方面,得益于Virtual DOM本身和模块化设计(不然全家桶白叫了),React全家桶明显更优秀些。

测试工具

我们本篇中的测试有三个目标:学得快,写得快,跑得快

(图片来自:http://t.cn/RpwCke3

平台上Selenium, Phantom, Chrome, 包括Karma都比较重,最好的测试框架就是直接跑在node上的。本着极限编程的原则,我们将测试本身和测试环境尽可能简化,以达到加快测试速度,最终反馈到开发速度的目的。

我们使用AVA进行测试,它非常简洁,速度非常快,和mocha不同,它默认会启动多线程并发测试。因此我们的测试必须减少共享状态来提高并发能力,不然就会出现意想不到的错误。安装和运行:

yarn add ava
ava --watch

这样可以运行并watch测试。改变代码测试结果会立刻改变,你也可以看到友善的错误信息,以及expected和actual之间的diff。写下第一段测试:

import test from 'ava'

test(t => {
  t.is(1 + 1, 2)
})

除了is方法以外,我们还会用到deepEqual和true方法。好,你现在已经完全会用AVA了。其他的功能我们完全不关心。

Redux测试 (Model测试)

Redux就是用一堆Reducer函数来reduce所有事件用来做全局Store的状态机(FSM)。用源码本身介绍它甚至比用上一小段文字介绍还快:

const createStore = reducer => {
  let state, listeners = []

  const dispatch = action => {
    state = reducer(state, action)
    listeners.forEach(listeners => listeners())
  }

  return {
    getState() { return state },
    subscribe(listener) {
      listeners.push(listener)
      return () => { listeners = listeners.filter(l => l !== listener)}
    },
    dispatch,
  }
}

这是一个简化版的代码,去掉了抛错等等细节,但功能是完整的。把你自己写的reducer扔进去,然后可以发事件来使其更新,你还可以订阅它来拿状态。有点像Event Sourcing,以消息而非调用来处理逻辑,更新和订阅的逻辑不在一起(事件是写模型,各种view就是多个读模型)。

reducer几乎包括了我们所有前端业务的核心,测好它就测了大半。它们全都是(State, Action) => nextState形式的纯函数,无异步操作,用swtich case来模拟模式匹配来处理事件。比如用喜闻乐见的简陋版的栈停车场举例:

export const parkingLot = (state = [], action) => {
  switch (action.type) {
    case 'parkingLot/PARK':
      return [action.car, ...state]
    case 'parkingLot/PICK':
      const [_, ...rest] = state
      return rest
    default: return state
  }
}

Reducer是这么用的:

const store = createStore(parkingLot)
store.subscribe(() => renderMyView(store.getState()))
store.dispatch({ type: 'parkingLot/PARK' })

好,现在你又理解了Redux。那我们可以看看怎么测试上面的parkingLot reducer了:

test('parking lot', t => {
  const initial = parkingLot(undefined, {})
  t.deepEqual(initial, [], 'should be empty when init')

  const parked = parkingLot(initial, { type: 'parkingLot/PARK', car: 'Tesla Model S' })
  t.deepEqual(parked, ['Tesla Model S'], 'should park Model S in lot')

  const picked = parkingLot(parked, { type: 'parkingLot/PICK' })
  t.deepEqual(picked, [], 'should remove the car')
})

它就是你第一天学测试就会写的那种测试。这些测试不受任何上下文影响,是幂等的。试着把那几个const声明的state挪到任何地方,你都可以发现测试还是正确的,这和我们平常小心翼翼分离各个测试case,并用beforeEach和afterEach重置截然不同。

(图片来自:http://t.cn/RpwS3AK

测试Reducer是非常机械的,你不需要问自己“我到底应该测哪些东西”,只需要机械地测试初始state和每个switch case就好了。(小秘密:redux-devtools写完实现,在浏览器里打开,反过来还可以自动生成各种框架的测试代码,粘贴回来就行了。推荐不写测试的项目尝试下,反正白送的测试……而且跟你写的没两样)

随着业务变得复杂,当state树变大时,我们可以将reducer结构继续往下抽,并继续传递事件,函数没有this,重构起来比普通OO要简单得多,就不赘述了。这时候测试还是完全一样的,这种树形结构保证了我们能最大限度地覆盖一个bounded context—也就是root reducer。

另外更好的方式是用t.is(断言引用相同)而非t.deepEqual。但是JavaScript对象本身是可变的,引入immutable.js可以让你只用t.is测试,不过immutable的API有点别扭,不展开了。

组件测试 (View测试)

React是一个View library,它干的活就是DOM domain里的两个事:渲染和捕获事件。我们在这里依然从简,只用stateless component这个子集,虽然在用到生命周期方法的时候需要用一下class,但绝大多数时候应该只用stateless component。

它以Virtual DOM的形式封装了恶心的浏览器基础设施,让我们以函数和数据结构来描述组件,所以和大部分框架不同,我们的测试依然可以在node上并行运行。如果用Karma + Chrome真正地渲染测试,你会发现共享一个浏览器实例的测试非常慢,几乎无法watch测试,因此我们的TDD cycle就会变得不那么流畅了。

最基本的就是state => UI这种纯函数组件:

const Greeter = ({ name }) => <p>Greetings {name}!</p>

使用的时候就像HTML一样传递attribute就可以了。

render(<Greeter name="React"/>, document.body)

最简单的测试还是判等,我们用一个叫jsx-test-helpers的库来帮我们渲染:

import { renderJSX, JSX } from 'jsx-test-helpers'

const Paragraph = ({ children }) => <p>{children}</p>
const Greeter = ({ name }) => <Paragraph>Greetings {name}!</Paragraph>

test('Greeter', t => {
  t.is(renderJSX(<Greeter name="React"/>), 
       JSX(<Paragraph>Greetings React!</Paragraph>), 
       'should render greeting text with name')
})

这里我多加了一层叫做Paragraph的组件,它的作用仅仅是传递给p标签,children这个prop表示XML标签传进来的子元素。多加这层Paragraph是为了展示renderJSX只向下渲染了一层,而非最终需要渲染的p标签。这样我们在View上的测试粒度就会变得更小,成本更低,速度更快。

(图片来自:http://t.cn/RpwYskG

View不像业务本身那么稳定,细粒度低成本的快速测试更划算些,这也是为什么我们的View都只是接受参数渲染,这样你只用测很少的case就能保证View可以正确渲染。假如你的FSM Model有M种可能性,View显示的逻辑有N种,如果将两个集成在一起测试可能就需要M×N种Path,如果分开测就有M+N种。View和Model的边界清晰时,你的Model测试不容易被更困难的View测试干扰,View测试也减少了混沌程度,需要测试的情形就减少了。

我们的组件不应该只有渲染,还有事件,比如我们封装个TextField组件:

const TextField = ({ label, onChange }) => <label>
  {label}
  <input type="text" onChange={onChange} />
</label>

当然我们还可以判等,只要onChange函数引用相同就好了。

test('TextField', t => {
  const onChange = () => {}
  const actual = renderJSX(<TextField label="Email" onChange={onChange} />)
  const expected = JSX(<label>
    Email
    <input type="text" onChange={onChange}/>
  </label>)
  t.is(actual, expected)
})

当然有时候你的组件更复杂些,测试时并不关心组件是不是完全按你想要的样子渲染,可能你想像jQuery一样选择什么,触发什么。这样可以用更主流的enzyme来测试:

import {shallow} from 'enzyme'
import sinon from 'sinon'

test('TextField with enzyme', t => {
  const onChange = sinon.spy()
  const wrapper = shallow(<TextField label="Email" onChange={onChange} />)
  t.true(wrapper.contains(<label>Email</label>), 'should render label')

  const event = { target: { value: 'foo@bar.com' } }
  wrapper.find('input').simulate('change', event)
  t.true(onChange.calledWith(event))
})

这里用的shallow顾名思义,也是向下渲染一层。此外我们还用了spy,这样测试就变得有点复杂了,丢掉了我们之前声明式的优雅,所以组件还是小一点、一下测完比较好。

还不够快?Facebook就觉得不够快,他们觉得View测试成本比较浪费,干脆搞了个Snapshot测试——意思就是照个像,只断言它不变。下次谁改了别的地方不小心影响到这里,就会挂掉,如果无意的就修好,如果有意的话和git一样commit一下就修好了:

import render from 'react-test-renderer'

test('Greeter', t => {
  const tree = render.create(<Greeter name="React"/>).toJSON()
  t.snapshot(tree, 'should not change')
})

当你修改Greeter的时候,测试就会挂掉,这时候运行:

ava --update-snapshots

就好了。Facebook自家的Jest对snapshot的支持更好,当snapshot不匹配时按个y/n就完事了,够快了吧。要有更快的可能就是不测了……

小结

这节里我们展示了3种测试View的不同方式,它们都比传统框架更简单更快速。我们的思路还是以判等为主,但不同于Model,粒度越大越好。View测试粒度越小越好,足够小、足够幂等之后,其实不用测试你也可以发现组件总是按照预期工作。相比之下MVVM天然有一种让View和Model粒度拟合的倾向,很容易让测试变得既难测又缺乏价值。

异步Effect测试

这算个续集……异步操作不复杂的项目可以无视这段,可以选择性不测。

React先解决了恶心的DOM问题,把Model的问题留下了。然后Redux把同步逻辑解决了,其实前端还留下异步操作的大问题没有解决。这种类似“Unix只做一件事”的哲学是React全家桶的根基。我们用一个叫做Redux-saga的库来展现全家桶的异步测试怎么写,Redux模仿的目标是Elm architecture,但是简化掉了Elm的作用模型,只保留了同步模型,Redux-saga其实就是把Elm的作用模型又拿回来了。

Saga是一种worker模式,很早之前在Java社区就存在了。Redux-saga抽象出来多种通用的作用比如call / takeEvery等等,然后有了这些作用,我们又可以愉快地判等了。比如:

import { takeEvery, put, call, fork, cancel } from 'redux-saga/effects'

function *account() {
  yield call(takeEvery, 'login/REQUESTED', login)
}
function *login({ name, password }) {
  try {
    const { token } = yield call(fetch, '/login', { method: 'POST', body: { name, password } })
    yield put({ type: 'login/SUCCEEDED', token })
  }
  catch (error) {
    yield put ({ type: 'login/FAILED', error })
  }
}

这段代码乍看起来很丑,这是因为它把程序里所有异步操作全都集中在自己身上了。其他部分都可以开心地发同步事件了,此外有了Saga之后Redux终于有了“用事件触发事件”的机制了,只用redux,应用复杂到一定程度你一定会想这个问题的。

这是个最普通的API处理saga,一个account worker看到每个’login/REQUESTED’就会forward给login worker(takeEvery),让它继续管下面的事。然后login worker拿到消息就会去发请求(call),之后傻傻地等着回复,或者是出错。最后它会发出和结果相关的事件。用这个方式你可以轻松解决疯狂难度的异步问题。

test('account saga', t => {
  const gen = account()
  t.deepEqual(gen.next().value, call(takeEvery, 'login/REQUESTED', login))
})

test('login saga', t => {
  const gen = login({ name: 'John', password: 'super-secret-123'})

  const request = gen.next().value
  t.deepEqual(request, call(fetch, '/login', { method: 'POST', body: { name: 'John', password: 'super-secret-123'} }))
  const response = gen.next({ token: 'non-human-readable-token' }).value
  t.deepEqual(response, put({ type: 'login/SUCCEEDED', token: 'non-human-readable-token' }))
  const failure = gen.throw('You code just exploded!').value
  t.deepEqual(failure, put({ type: 'login/FAILED', error: 'You code just exploded!'}))
})

你看我们的测试连异步操作都还可以无耻地判等。call就是以某些参数调用某个函数,put就是发事件。

可以试着把fetch覆盖成空函数,你可以发现实际上副作用根本没发生,“fetch到底是个啥”对测试一点影响都没有。你可能发现了,其实saga就是用数据结构表示作用,而不着急执行,在这里又走回幂等的老路了。这和React Virtual DOM的思路异曲同工。

结语

首先是文章开头提到的TL;DR的内容。函数是个好东西,测函数不等同“测1+1=2”这种没营养的单元,函数是可以包含很大上下文的。这种输入输出的模型既简单又有效。

我们消灭了mock,减少了依赖,并发了测试,加快了速度,降低了门槛,减少了测试路径等等。如果你的React项目原来在TDD的边缘摇摆不定,现在是时候入一发这种唯快不破了。

全家桶让Model/View/Async这三者之间的边界变得清晰,任由业务变更,它们之间的职责是不会互相替代的,这样你测它们的时候才更容易。后端之所以测试稳定是因为有API。所以想让前端好测也是一样的思路。

文中好多次提到“幂等”这个概念,幂等可以让你减少测试的case,写代码更有底气。抛开测试不谈,代码幂等的地方越多,程序越可控可预期。其实仔细思考一下我们的实际项目,大部分业务都是非常确定的,并没有什么随机因素。为什么最后还是会出现很多随机现象呢?

声明优于命令,描述发生什么、想要什么比亲自指导具体步骤好。

消息机制优于调用机制。Smalltalk > Simula。其实RESTful API一定程度上也是消息。简单的对象直接互相作用是完全没问题的,人作为复杂对象主要通过语言媒介来交流,听到内容思考其中的含义,而不是靠肢体接触,或者像连体婴儿那样共享器官。所以才有一句俗语叫“你的对象都想成长为Actor”。

从View的几种测试里我们也可以看到,测试并不是只有测或者不测这两种选择,我们老提测试金字塔,意思是测试可多可少,不同层级的测试保持正金字塔形状比较健康,像今天我们说的就可以大幅加宽你测试金字塔的底座。所以你的项目有可能测试过少,也可能测试过度,所以时间可以动态调整。

没用全家桶的项目可以把“大Model小View”的思想拿走,这样更容易于专注价值。尽量抽出Model层,不要把逻辑写在VM里,看那样似省事,行数在测试里都还回来了。


更多精彩洞见,请关注微信公众号:思特沃克

Share

解读GraphQL(三):Relay—面向未来的前端框架

这篇文章是这个系列的尾声,也是我们可以一窥GraphQL、Relay生态究竟可以为我们提供怎样优势的时候了。

我们都知道,一项技术的普及,很大程度来源于旧工具解决某些新问题过于棘手的问题。我们看到前端迅猛发展的过程中,社区逐渐开始抛弃jQuery和Angular——因为当我们的场景越来越复杂时,它们就会开始失控,让我们面临越来越多的维护和性能问题。即使对于现在流行的React + Redux而言,这个方案依然有很多不便之处,但我们的前端发展还没有那么快,所以它们遭遇的痛点有限,社区也没有大规模抛弃它们。但这并不能说明这些痛点不存在,而是我们的前端应用还没发展那么快,因此也不是非得用更好的技术才能实现我们的需求。

然而前端逐渐趋于复杂的趋势还没有结束,Facebook的场景更复杂苛刻,因此在我们之前就遭遇了即将到来的问题。Relay是他们比Flux更进一步的解决方案,它把我们不需要关注的地方全都抽象到了Relay的代码库中,解决了绝大多数让我们头疼的前端问题。

这不代表你现在就可以开始采用

鉴于Relay现在并非主流解决方案,并且它的API也未经简化,有时候看起来难以理解,因此并不推荐在普通场景直接使用:

  • 与第一次接触React类似,这个lib非常奇怪,和之前用的框架完全不同,文档还特别难读。时至今日,大家反而发现React的API远比Angular少且简单。所以其实学习曲线分为两部分:一是你是否熟悉类似的解决方案,二是这个方案本来简单与否——Angular有更多更复杂的指令和依赖注入系统。其实Relay的心智模型要比Redux还简单,而后者经过社区的反复安利现在已经非常主流了。现在React/Redux在我们这期雷达的框架象限中已经在4个Adopt中占2个名额了。
  • 虽然Relay本身概念不是特别复杂,但它的API实现非常不符合直觉。这和刚出现Flux时Facebook官方放出的Flux库类似,API又臭又长,完全不知道在说什么。直到Redux的出现,用更简洁的API实现了等价功能才被广泛接受,Relay也类似,社区也在逐渐出现其他GraphQL客户端。
  • Relay解决的痛点存在于各种SPA和移动端开发中,但对于现在的主流场景还不算致命,随着时间的推移,这很可能会改变。

本文的主要价值在于我们可以看到这些被Facebook仔细总结过的问题,当在自己的项目中看到这些问题可能会引发更多的思考。因为大多数时候,部分问题会贯穿客户端开发的始终,另外一部分问题则是进一步扩展之后的坑,这些可能会是宝贵的经验。

开始之前:

其实我一年之前试用过一下Relay,像Apollo这些简单的lib其实大部分跟着Code迅速敲一边就知道是怎么回事了。然而对于Relay,由于它封装了太多的内容,导致Relay文档的第一篇Tutorial看起来不知所云,完全没有帮助,于是就没继续看。

前一阵和豪门的某个同事聊到这个奇怪的lib时,他也认为好像写文档的喝多了。于是当天我就回顾了一下Relay的文档,当我跳过Code部分,看到文档后面分别从GraphQL和Relay的出发点来叙述各种细节时,事情才开始make sense起来。起初想自己写一篇关于Relay的文章,最后发现其实还是用自己的话和自己的例子叙述完全相同的内容,于是这篇其实主要算搬运。所以初衷是这样的:

我知道有非常多的人跟我一样看了第一页就跑了,所以把更有价值后两页内容搬运过来(为了辅助说明,有部分增删改)。 对于字太多不看党:这里有一个图文并茂的拟人版Relay生动叙述了主旨思想,很有意思。相信很多人已经看过FluxRedux版了。 另外,如果觉得下面的cache机制不容易理解——可以把Relay的cache看做文明系列,或者魔兽争霸之类的战争迷雾。也就是你没探地图的时候是黑的,这时候你没cache过,但是你走开的时候虽然不是黑的,但你的cache可能是过期的,等你过去又update了。自认为比喻得十分恰当……

从GraphQL的角度出发

GraphQL允许客户端在一次请求中准确声明视图所有所需的数据。与传统的REST方式相比,它的请求效率要更高,而且也避免了服务端API代码之间雷同的问题。更重要的是,GraphQL将服务端开发与客户端开发彻底解耦,客户端视图的变动完全不会影响到服务端的代码结构。

请求数据

我们现在有一个请求stories列表的需求,除此之外我们还要请求每个story的详情。我们用面向资源的严格REST可能会这么做:

// Fetch the list of story IDs but not their details:rest.get('/stories').then(stories =>
  // This resolves to a list of items with linked resources:
  // `[ { href: "http://.../story/1" }, ... ]`
  Promise.all(stories.map(story =>
    rest.get(story.href) // Follow the links
  ))
).then(stories => {
  // This resolves to a list of story items:
  // `[ { id: "...", text: "..." } ]`
  console.log(stories);
});

这种做法会出现n+1次HTTP请求,更别提对数据库的请求了:1次用来获取列表,n次用来获取每个story详情。用GraphQL,我们不用专门维护一个同时获取stories和stoy信息的API,同时一次请求所有数据:

graphql.get(`query { stories { id, text } }`).then(
  stories => {
    // A list of story items:
    // `[ { id: "...", text: "..." } ]`
    console.log(stories);
  }
);

这个请求有两点好处:

  • 所有的数据都一次请求完毕
  • 服务端和客户端完全解耦:客户端声明自己需要的数据,而不依赖服务端来编写正确的数据结构。

客户端缓存

反复重新获取数据会让应用变得很慢。比如,当你跳转到获取一个stories列表和每个story详情的页面意味着我们需要重新请求整个列表。我们现在用国际统一方式来解决:缓存。在严格的REST系统里我们会将返回的数据,以uri为单位缓存,也就是每个特定的uri缓存一次:

var _cache = new Map();
rest.get = uri => {
  if (!_cache.has(uri)) {
    _cache.set(uri, fetch(uri));
  }
  return _cache.get(uri);
};

这种缓存策略也可以对GraphQL生效,最简单的方式就是和REST一样,以query为单位来缓存:

var _cache = new Map();
graphql.get = queryText => {
  if (!_cache.has(queryText)) {
    _cache.set(queryText, fetchGraphQL(queryText));
  }
  return _cache.get(queryText);
};

现在我们的新请求就会立刻返回缓存的结果了。这对于提高性能来说是很有效的方式,但这回引起数据一致性问题。

缓存一致性

用GraphQL的时候,我们的Query之间其实经常会有交集,也就是重复的部分。但我们上面的办法没办法cache掉这些交集,因为我们的cache是以url或者单个query为单位的。比如说我们发这么个请求,请求所有stories:

query { stories { id, text, likeCount } }

然后再重新请求下其中的一个story,假设这时候它的likeCount可能已经增加了:

query { story(id: "123") { id, text, likeCount } }

现在我们通过不同的方式可以获得不同的likeCount,如果你用上面的query会拿到旧的count,如果你用下面的query会拿到新一点的count。

将图缓存起来

其实正确cache GraphQL请求的方式应该是将query的嵌套结构打平,以获得一个称为records的集合。Relay内置实现了从ID到records以map形式缓存的过程。每个record也可以用一个link指向其他的record用以描述一个有向有环图,这个link是一个可以从顶层map开始搜寻的特殊数据结构。用这个办法,我们即使通过不同的方式请求也会获取到相同的cache结果。如下有个请求story下text和作者名字的请求:

query {
  story(id: "1") {
    text,
    author {
      name
    }
  }
}

上述请求的返回值可能是这样的:

    query: {
  story: {
     text: "Relay is open-source!",
     author: {
       name: "Jan"
     }
  }
}

即便返回值是嵌套的,Relay也会把它拍扁,变成records。所以对上面那个返回,Relay会这么cache:

   Map {
  // `story(id: "1")`
  1: Map {
    text: 'Relay is open-source!',
    author: Link(2),
  },
  // `story.author`
  2: Map {
    name: 'Jan',
  },
};

这只是一个最简单的例子:实际使用起来Relay还会处理一对多和分页的场景。

使用缓存

当我们接收到数据时我们会把它写进缓存里,当我们在缓存中找到我们要的我们就直接读取(概念上跟_cache.has(key)是类似的,只不过GraphQL是从图里面读cache)。

写入缓存

写入缓存要先遍历返回的GraphQL结构,然后返回一个拍平的cache records。直觉上这个办法很直接,但这只对很简单的query有效。思考一下这么个query: user(id: "456") { photo(size: 32) { uri } } 我们应该怎么存这个photo?光用photo当cache键会导致不同参数之间的冲突(比如再缓存一个photo(size: 64) {...})。当我们分页时也有同样的问题:如果我们用stories(first: 10, offset: 10)获取第11个到20个story的时候,返回结果应该拼接到缓存列表内(而不是单独缓存,想象一下你在列表新加了一条数据,如果按照传统page/{n}的API方式处理,所有的分页都是错位的,因此缓存必须全部失效,因此正确做法应该是缓存一个列表,这样和页数就完全无关了)。 因此所有拍平的GraphQL缓存结构应当包含参数信息然后并排放在一起。比如说刚才的photo就应该缓存在photo_size(32)这种键下来算上参数一起来唯一标识它自己。

读取缓存

如果要读取缓存的话我们的query就要遍历各个field。其实这样做听起来起来和我们在GraphQL服务端做的事情完全相同。读取缓存其实跟构建一个服务端可执行的GraphQL一样,只是变成了一个更简单的特例:

  • 因为我们是按特定规则存进去的,因此我们不用像在服务端那样声明自己的resolver,这个规则是自动的。
  • 因为这个cache graph在本地,所以请求cache是同步的:不然我们cache了,不然就没cache,没有请求的中间状态。

Relay内部实现了许多种query遍历的方式:比如它可以自己读cache,也可以接受服务端的response。比如说,当我们发出一个query的时候,Relay会自动遍历并Diff,然后决定哪些field没被获取过(跟React diff Virtual DOM树的原理是类似的)。这样我们就可以减少我们query的实际请求数量,甚至如果所有的field都被cache过Relay就直接不发请求了。假如我们首页的某个组件会先发这么一个请求:

query { viewer { name, company } }

比如我们进入下一页时,可能是个人中心页面,我们的某个组件会请求更具体的信息:

query { viewer { name, company, email, phone } }

当你打开Network面板的时候,你会发现Relay只请求了:

query { viewer { email, phone } }

更新缓存

我们可以发现,Relay这种扁平的cache池在缓存互相重叠的各种query时是不会重复的。无论你怎么请求数据,一个具体record只可能缓存在同一个地方。我们回顾一下上面缓存不一致的小例子:

query { stories { id, text, likeCount } }

当我们cache这个query返回的数据时,每个stories列表中的每个story都会存成一个record,然后stories这个字段会Link到所有的上述record上。于是我们接着请求了其中的某个story:

query { story(id: "123") { id, text, likeCount } }

当Relay拍平这个query返回的数据时,Relay就能根据每个实体身上的id(GraphQL的ID类型跟UUID类似,是全局唯一,自动分配的,只不过默认是Base64的),找到这个已经存在的123 record并更新,而不是去新建一个。于是所有新的query如果要请求likeCount都一定会拿到这个新的cache结果。

数据/视图一致性

一个扁平的cache结构保证了缓存一致性。但对于View来说呢?我们知道React会相应数据变化,渲染最新数据。我们现在考虑一个渲染story内容、评论以及作者名字和照片的场景,比如这就是我们要发的GraphQL query:

query {
      node(id: "1") {
        text,
        author { name, photo },
        comments {
          text,
          author { name, photo }
        }
      }
    }

当你发出这么个query,Relay会缓存成这样(你能发现story和comment都会指向同一个author):

// Note: This is pseudo-code for `Map` initialization to make the structure// more obvious.Map {
      // `story(id: "1")`
      1: Map {
        author: Link(2),
        comments: [Link(3)],
      },
      // `story.author`
      2: Map {
        name: 'Yuzhi',
        photo: 'http://.../photo1.jpg',
      },
      // `story.comments[0]`
      3: Map {
        author: Link(2),
      },
    }

这个作者自己占了个沙发——也是挺常见的情形。接下来,假如这个作者改了自己的photo,然后其他组件又要去获取这个更新的话,以下是唯一会变动的地方:

Map {
      ...
      2: Map {
        ...
        photo: 'http://.../photo2.jpg',
      },
    }

photo的值已经变了,于是Relay就去修改record 2,仅此而已。cache其他的地方完全没变。但很明显,我们的view需要相应这种变化:这个story作者的photo,和他自己占沙发的photo都要更新。熟悉React的人的第一反应就是”上immutable.js”,因为当你变化节点的时候,整棵树的引用就会产生变化(类似Java有常量池的字符串)——但我们看看这样会发生什么:

ImmutableMap {
      1: ImmutableMap {/* same as before */}
      2: ImmutableMap {
        ... // other fields unchanged
        photo: 'http://.../photo2.jpg',
      },
      3: ImmutableMap {/* same as before */}
    }

如果我们把2换成一个新的immutable record,我们会得到一个全新的cache对象。但我们已经拍平了这个缓存结构,这时候因为13并没变,因此他们的Link就失效了。

实现视图一致性

面对这个扁平cache,要更新视图其实有多种方法可以选择。Relay的办法是自动订阅每个view指向cache的ID集合,也就是每个特定引用的集合。在上面那个例子中,story的view会订阅story(1),author(2)和comments(3和其他comments)。当有新数据写入cache时,Relay会找出所有被影响的ID,然后只会通知那些订阅这个ID的所有view。这样就仅会通知重绘那些需要重绘的,而不需要重绘的就会无视这个update(实现原理是Relay container安全高效地override了shouldComponentUpdate)。如果没有这种设计的话,任何一个小变化都会导致所有view重绘。 注意除了更新以外,所有的写入cache的操作也会影响view,因为写入仅仅是另一种形式的更新。

Mutations

我们现在已经了解query的过程,也知道view是如何更新的了。但我们还没谈到写入操作。在GraphQL里,我们把写入操作成为mutation。我们可以把它当做有副作用的query。比如下面就是个用current user来like一下某个story的mutation:

// Give a human-readable name and define the types of the inputs,// in this case the id of the story to mark as liked.mutation StoryLike($storyID: String) {
       // Call the mutation field and trigger its side effects
       storyLike(storyID: $storyID) {
         // Define fields to re-fetch after the mutation completes
         likeCount
       }
    }

但mutation很可能导致我们的某些query结果产生变化。这时候你可能会问:为什么服务端就不能直接告诉我哪些字段已经变化了呢?其实这个问题非常复杂。GraphQL只是一个可以封装任何存储系统(或者仅仅将多种资源整合起来)的抽象层,它可以实现在任何语言上。更重要的是,GraphQL的核心目的是产生客户端渲染视图所需的数据结构

我们发现其实GraphQL schema经常和你的database schema经常有本质上的区别。简单地说,数据变化可能意味着你的database变化,也可能意味着你产品可见的数据(比如GraphQL)的变化。最恰当的例子就是隐私:你要在客户端获取一个age字段,在database里会有一大堆字段来控制UAC来决定你是不是能看见这个age(比如是不是好友,这个人的age是不是公开的,有没有屏蔽请求方之类的字段)。

所以考虑到现实约束,GraphQL客户端还是得声明mutation之后哪些query可能会变化。但是我们怎么声明呢?Facebook在实现Relay的过程中考虑过几种方式,我们按顺序看一下,就容易理解为什么Relay如此设计了:

  • 方案1:重新请求所有曾经query过的数据。即使一个非常小的变化也会重新请求所有数据,这样非常低效。
  • 方案2:重新请求正在被view渲染的数据。这样快了一点,但没被显示的数据就没被更新,当你切换view的时候就又不能保证数据一致性了。
  • 方案3:我们将可能变化的query声明出来,然后重新请求这些数据。我们将其称为fat-query。其实这也很低效,因为一般来说我们只会展示部分fat-query要请求的数据,这样我还没请求过的数据fat-query会浪费感情地帮我也获取了。
  • 方案4(也就是Relay采用的):重新获取fat-query和cache过的所有字段的交集。除了cache数据本身,Relay也会在cache中标记这条数据是被哪条query请求来的。我们将其称为tracked queries。Relay用这种求交集的方法,就总可以精确地请求仅需要更新的字段了。

数据获取的API

我们已经看过Relay底层是如何封装数据缓存的了。我们现在退一步看看我们应该如何获取数据:

  • 从一个嵌套的view结构中找到所有要请求的字段。
  • 处理所有异步请求,并赋值给应用内的状态。
  • 处理网络错误。
  • 遇到错误之后重试。
  • 在query或mutation后更新本地cache。
  • 还要串行请求mutation防止race conditions。
  • 有时还要在服务器返回之前直接update视图,让用户感觉延迟更低(这称为 optimistically update)。我们发现基于命令式API的传统数据请求流程会让开发人员处理太多不必要的复杂性。拿optimistically update来说,其实要做的事情很明显:当用户点击”like”按钮时,直接将按钮变成”liked”然后发请求到服务端。但这实现起来就经常很复杂了。命令式的做法通常要求我们实现以下步骤:找到相应的view然后控制状态按下button,然后开始发送网络请求,如果挂掉还要重试,如果重试不成功再显示错误等等。对于数据获取这个过程来说也一样:其实声明我们需要什么数据就很大程度上直接决定了如何以及何时获取。这时候我们就可以来看看Relay的上层的声明式设计了。

从Relay的角度出发

Relay的数据获取方式其实很大程度来自于React的使用经验。具体地说,React将前端复杂的接口变成了可复用的组件,这允许开发者专注开发应用中被解耦出来的一部分。更重要的是,所有这些组件都是声明式的:它允许开发者仅声明在某种状态下组件应该渲染成什么样子,完全不管这个组件是如何被渲染的。不同于以前直接在DOM上赋值修改数据,React接受描述UI的数据结构来帮你渲染。我们来看一些场景来理解这种思想在Relay理是怎么体现的。

为视图获取数据

在客户端中,绝大多数情况下你都要做的事情是:为一个嵌套的view获取所有它需要的数据,在数据获取前可能需要一个进度条,然后获取到数据之后开始显示。一个解决方案是让根组件获取数据然后传递给子组件。然而,这种做法会引入耦合:每次你修改子组件的时候都要修改所有用到渲染这个组件的父组件。这种耦合意味着更多的bug和更慢的开发速度。最终我们发现这种做法并不会让你在React这种组件模型中收益:这种数据依赖放进组件内部才比较自然。 另外一个看起来很靠谱的办法是调用render()钩子时开始请求数据。这也是通常的做法,我们渲染一个stories组件,然后请求stories,然后获取完之后开始显示具体的story,最后再去请求所有的story详情。这听起来还行,但是问题是这个请求是分段的:先得渲染出来一层组件,才知道下一步应该请求什么,然后再渲染,再去请求。这会导致请求和渲染交替串行,最后渲染过程变得极慢无比。因此我们需要提前或者静态地知道我们要获取什么数据。 最终Relay采用了静态方法:组件的query都是静态成员,因此可以立刻找齐所有query,用来描述整个要渲染的组件树需要什么数据。但这种方式需要我们有办法把所有的query结合成query树,这样才能一次加载出所有数据,因此这也是Relay需要GraphQL来支持的核心原因——每组件内部query之间要可以结合成完整的query,而不是拿某部分query去直接调用某个请求API。

数据组件(即Container)

Relay允许开发者像React一样创建一个数据container来为组件提供数据,而这种容器实质上就是在外面包一层组件而已。一个现实问题是React组件的目的是为了复用,因此我们的container也得跟着可复用才行。举例来说,一个组件必须要可以渲染任何Story数据。至于渲染的具体内容,取决于交给组件的数据:。在Relay里面对等的概念就是fragment——一个声明某个GraphQL类型需要获取什么内容的具名query片段,比如我们给Story的fragment长这样:

fragment on Story {
  text
  author {
    name
    photo
  }
}

然后我们就可以把这段fragment放进Story作为组件的数据container了:

// Plain React component.// Usage: `<Story story={ ... } />`class Story extends React.Component { ... } // "Higher-order" component that wraps `<Story>`var StoryContainer = Relay.createContainer(Story, {
  fragments: {
    // Define a fragment with a name matching the `story` prop expected above
    story: () => Relay.QL`
      fragment on Story {
        text,
        author { ... }
      }
    `
  }
})

渲染

在React中,渲染需要两个东西:一个是你要渲染的组件,另外一个是你要把这个组件要渲染进的root DOM节点。渲染Relay container也类似:我们需要渲染一个要渲染的container,还有一个root query。所以类似ReactDOM.render(component, domNode),Relay有<Relay.Renderer Container={…} queryConfig={…}。所以container就是我们要渲染的东西,而queryConfig就是我们要请求的东西:

 ReactDOM.render(
  <Relay.Renderer
    Container={StoryContainer}
    queryConfig={{
      queries: {
        story: () => Relay.QL`
          query {
            node(id: "123") /* our `Story` fragment will be added here */
          }
        `
      },
    }}
  />,
  rootEl
)

Relay.Renderer可以将所有query合并,并与cache池做diff,然后请求所有没cache的信息,之后更新cache,最后渲染StoryContainer。默认加载过程中不显示内容,不过你也可以为这个Renderer添加一个render属性来渲染一个loading组件。正如React可以让你避免和DOM打交道一样,Relay也可以帮你避免手动管理网络请求。

数据封装

组件之间有隐式依赖很常见。举例说,会用到一些不一定会被获取到的数据——这些数据大多被其他部分获取,比如。当我们修改然后去除部分请求逻辑时,会直接挂掉。这种bug,特别是在大项目里很难快速发现,因为只有渲染所有用到这个组件的地方之后你才发现它挂掉了。手动和自动化测试能帮你的其实很有限,因为它还是会反复break,对于这种问题最好的办法是让框架来帮你从根源上避免这些问题,让它不可能出现。

我们可以看到Relay container会自动保证所有数据获取到之后再渲染。此外,container也带来了数据封装的好处。Relay只允许组件声明和自己相关fragment内的字段。所以一个组件获取了一个Story的text,另外一个获取了author,他们之间的数据是不可见的。事实上,甚至父级组件都不能知道子级到底请求了什么数据:如果知道的话,这又破坏了封装性。

在此基础上,Relay还更进一步:它在内部隐式地通过props验证你是不是真获取到需要的数据了。如果渲染了但没加入后者的fragment,Relay会警告你后者没有拿到本应拿到的数据。当有其他组件也请求完全相同的数据时,Relay甚至能提醒你检查你是不是在不知情的情况下又重复造了一个雷同的组件。这些检查可以有效帮助你避免在组件化开发中绝大多数的Code smell。

小结

GraphQL提供了强大、高效并与客户端解耦的工具链。Relay提供了完全声明式的数据同步机制(和Apollo不同,Mutation通过fat-query和cache池交集的办法完成了声明式API)。通过将请求什么怎么完全分离的方式,Relay给开发者默认保证了绝大部分的健壮性,透明性和性能优化。它也会让React的组件化设计更进一步。虽然React,Relay和GraphQL每一个都非常强大,但这三者的结合为开发者提供了快速开发优质可扩展的完整UI解决方案

解读GraphQL(一): WHAT & WHY

解读GraphQL(二): 使用Apollo Data构建GraphQL应用


更多精彩懂见,请关注微信公众号:思特沃克

Share

解读GraphQL(二): 使用Apollo Data构建GraphQL应用

今天的主角是Apollo Data,它提供了一套与GraphQL相关的服务端/客户端工具,大幅简化GraphQL的开发。并且Apollo client分别有React(Native),Angular2,IOS,以及正在开发的Android版本。如果你所使用的平台不在上述范围内,Apollo client也有原生JavaScript版本可用。它内部基于Redux编写,因此你可以用非常科幻的Redux devtools检测应用状态,然而你并不需要了解Redux也可以使用Apollo。Talk is cheap,第一篇已经解释了很多主要概念,Apollo也非常简单。因此这篇将以Code为主。
文章较长,开头先部分引用下结尾来提取一个TL;DR版本来说明主旨思想:

我们以完全声明式的代码完成了query和渲染:也就是说我们代码上只关心What,只字不提When和How。这会极大地提高代码的可维护性,简化开发,因为状态和赋值总是万恶之源,更别提在JavaScript环境下必须大量使用异步操作的情形。在不牺牲SPA复杂业务逻辑的情况下,通过GraphQL,我们可以把前端的开发工作重新简化成编写类似编写HTML的简单活动。

Task0:GraphQL Hello World

我们就先遵循惯例,通过GraphQL Hello一下:

const { graphql, buildSchema } = require('graphql') 
const schema = buildSchema(`   
    type Query {     
        hello: String   
    } 
`) 
const resolver = { hello: () => 'Hello GraphQL!' } 
const query = '{ hello }' 
graphql(schema, query, resolver).then(console.log.bind(console))

首先我们定义了一个schema,一个schema的顶层要有一个Query类型,我们的所有查询都是从这层开始的,Query类型里面可以嵌套我们要查询的其他字段。我们用一个schema,一条查询以及一个Resolver查询出来一个{ data: { hello: 'Hello GraphQL!' } }的结果。这个例子主要为了展示GraphQL虽然主要用在HTTP场景中,但它其实可以并不关心协议,你可以将其应用在任何地方。

Task1: Setup一个能用的React Webpack环境

今天我们会用到和GraphQL结合最自然的React client。不过众所周知,我们处在一个JavaScript fatigue的时代,setup一个JavaScript项目有时候过于复杂了。本打算以shell这种最简单的方式setup一下,结果发现光shell本身就快占了一页……于是还是做了个仅有我们需要功能的最简Boilerplate

git clone https://github.com/namelos/react-minimal-boilerplatecd react-minimal-boilerplate npm install npm start

然后打开localhost:3000这样我们就能看到React渲染的页面了。

Task2: 构建一个Hello World GraphQL Server

先安装依赖,其中graphql是GraphQL的js核心实现;graphql-tools是Apollo组写的一些工具,让我们可以用简单的GraphQL Schema而不是复杂的代码构建我们的GraphQL server;而graphql-server-express顾名思义就是给express用的server中间件了:

npm install graphql graphql-tools graphql-server-express --save mkdir server && touch server/schema.js

然后我们接着在刚创建的schema.js写入以下内容,由于我没给server端setup babel,因此只能用commonjs require了。

/* server/schema.js */

const { makeExecutableSchema } = require('graphql-tools') 
const Schema = ` 
type Query {   
    hello(name: String!): String 
  } 
  `
const Resolvers = {   
    Query: {     
        hello: (_, {name}) => `Hello, ${name}`  
    }
} 
    module.exports = makeExecutableSchema({   
        typeDefs: Schema,   
        resolvers: Resolvers 
    })

这一段其实很类似第一段没服务器的版本,我们提供一个Schema,然后提供了实际的Resolvers,把它们合并起来就可以接受查询了。其中,这个Schema规定了hello这个查询,接受一个必填String参数(!的意思是必填,和许多语言的?正相反,GraphQL类型默认是可空的,只有加!的时候才是不可空的)。
之后修改下server.js,加上这两行和它们需要require的东西。我们在这里将GraphQL放到了/graphql的endpoint上,graphql-server-express默认接受POST请求,所以要给express再加上个body-parser。此外我们还在graphiql的endpoint加上了我们的开发工具graphiql,并指向/graphql

/* server.js */
const bodyParser = require('body-parser')
const { graphqlConnect, graphiqlExpress } = require('graphql-server-express')
// ...
app.use('/graphql', bodyParser.json(), graphqlConnect({ schema: Schema })) 

app.use('/graphiql', graphiqlExpress({ endpointURL: '/graphql' })

app.get('*', (req, res) => res.sendFile(path.join(__dirname, 'index.html')))
// ...

这时候打开localhost:3000/graphiql,熟悉的界面又出来了。恭喜,你已经完成一个GraphQL server,快试一下查询吧:

  query ($someName: String!) {     
          hello(name: $someName)  
  }

这个query是什么?以前顶层不都是一个大括号嘛?其实顶层大括号就是query开头的简写。后面我们在query上增加了一个($someName: String!)的意思是增加一个query变量,作为整个query的参数使用,比如我下面就交给了hello的name参数,当然显而易见的是它们的类型必须相同。我们还可以看到GraphiQL的左下角有个很猥琐的Query Variables可以让你输入为Query准备的变量,于是我们输入:

{"someName":  "GraphQL"}

跟预期的一样,返回{ "data": { "hello": "Hello GraphQL" } }。但是,你可能会发现,我们在GraphiQL中并不需要这个变量就能查询到结果,那这个变量到底是干什么用的呢?其实它是让客户端可以动态改变的参数,在下面我们会看到如何使用它。

Task3: 在客户端展示我们的Hello GraphQL!

老样子,接着安装客户端package。apollo-client是通用的js client,而react-apollo是给react使用的binding,最后graphql-tag是让我们生成query的小工具。不像其他React库设置那么繁琐,我们仅仅在组件顶层把一个默认指向/graphql的client交给一个Provider组件,整个应用的所有组件就都有了自动获取数据的能力:

npm install apollo-client react-apollo graphql-tag --save

将client/index.js修改成以下内容:

```
import React from 'react'
import { render } from 'react-dom'
import Apollo from 'apollo-client'
import { ApolloProvider, graphql } from 'react-apollo'
import gql from 'graphql-tag'

const client = new Apollo() 
const HelloComponent = ({ data }) => <h1>{data.hello}!</h1>
const HelloQuery = gql`   
    query ($someName: String!){     
        hello(name: $someName)   
    } 
    `        
const Hello = graphql(HelloQuery)(HelloComponent)  
render(   
    <ApolloProvider client={client}>    
    <Hello someName="GraphQL"/>   
    </ApolloProvider>, document.querySelector('#app'))
    ```

刷新页面,我们就能看见渲染出来的Hello, GraphQL!了。我们刚刚增加的query变量被注入了查询,除了将常量当做变量之外,更多时候我们将变量声明为一个回调函数,这样我们就更容易在组件内控制这个查询参数了。我们把套在组件外面,并为组件提供数据的查询成为Container。当组件被渲染时,query就会被调用来自动从服务端同步数据。 在这里graphql是一个科里化函数,你可以看到连续两个括号。这样做的好处是你可以将一个query container放在多个组件上面,用来多处显示这些数据,也不用重复查询。 在这个例子里,我们可以看到我们并没有管理发送请求,获取数据以及数据怎么被注入React组件这些小事,而仅仅声明一个查询,然后把查询和组件绑定到一起,Apollo就自动帮我们完成了这些事情。这样,我们跳过了异步请求和状态赋值这些坑,直接以声明的形式完成了组件渲染。

Task4: Todo MVC

我们现在完成了一个最简应用,在这个基础上,我们已经熟悉了基本API。但要继续展示更多的概念,我们需要稍微复杂点的的应用,于是我们再造一遍TodoMVC……为了简单我们只放进来展示和Add的功能。 于是我们修改下schema:

type Todo {   
    id: Int!   
    text: String
}  
type Query {   
    todos: [Todo]   
    todo(id: Int!): Todo 
}  
type Mutation {   
addTodo(text: String!): Todo 
}

这个schema定义了Todo类型,todos和todo这两个查询,还有一个叫做addTodo的Mutation,这就算是我们给前端的契约,你也可以用这个schema生成json Contract来给IDE或者测试使用。再来修改下resolver:

    const generateId = (() => {   let id = 0  return () => id++ })() 
    const todos = [] 
    const Resolvers = {   
        Query: {     todos: () => 
        todos,     
        todo: (_, {id}) => todos.find(todo => todo.id == id)   },   
    Mutation: {     
        addTodo: (_, {text}) => {       
        let todo = { id: generateId(), text }       
        todos.push(todo)       
        return todo     
      }   
       }
     }

我们现在以最简单的形式完成了这几个API。上面两段很简单,只是个id generator和一个被当做DB的todos。在现实的场景里,你可以很容易地接入一个真的DB进来。
这时候我们发现schema和resolver是有很简单的对应关系的,就好像我们平常用MVC框架里面的route和action类似,只不过对于复杂的查询,不同resolver可能被执行很多次。这样我们的代码粒度就更细,也更简单——每个resolver只用做一件非常具体的事情,而怎么组装则是在高层的query和graphql本身包办了,这样是不是很单一职责+依赖倒置?并且在resolver中我们不用关心request到底是什么,只接受参数,然后返回和schema一致的类型就好了。如果类型不对,根据这个字段可空与否,会返回null或直接报错。
比起上面写过的的resolvers,下面多了一个传说中的Mutation:我们平常用Query来返回数据,用Mutation来写,实现上其实没很大区别,值得注意的是graphql没办法约束你的query不能产生副作用,因此要注意不要在query中篡改数据。这是一种明显的读写分离的思想,并且对于客户端来说,进行读取任何并不会影响其他视图状态,但进行写入就可能出现数据不同步的情况,要单独处理,所以我们要尽量分开它们。 这时候我们重启下server让代码生效(node server代码在内存跑,里要重启才能生效,客户端直接刷新就好),然后试一下插入数据:

mutation {   
    addTodo(text: "Finish my first GraphQL app") {     
        id  
    }
}

然后再查询一下:

{   
    query {    
        todos {      
            text    
        }  
    }
}

我们看到mutation也是有返回类型的,我们的mutation直接返回Todo类型,这样方便我们mutate成功之后在客户端更新数据。
现在我们的server已经能正常工作了,让我们来看看主角客户端(在GraphQL的世界里客户端总是甲方……),由于我们已经插入过数据了,没重启服务器可以直接先做展示。让我们先从我们的基础组件开始,因为它最简单:

const Todo = ({ todo: { id, text } }) => <li>{id}: {text}</li>  
Todo.fragments = {   
    todo: gql`     
        fragment Todo on Todo {       
            id       
            text     
        }   
    `
 }

这个组件本身简单得不能再简单了,实质就是一个无instance返回Virtual DOM对象的函数,没啥好说的,这也是很多人喜欢React的原因,你可以将界面简单地以组件树的形式编写出来。
而下面的查询就有点奇怪了,这个fragment是啥呢?其实它是我们GraphQL query的组件。怎么用我们继续往下看:

const TodosComponent = ({ data }) => <ul>   
{ data.todos && data.todos.map((todo, i) => <Todo todo={todo} />) } </ul>  
const TodosQuery = gql`   
    query {     
        todos {       
            ...Todo    
    }   
}   
${Todo.fragments.todo} 
`
const Todos = graphql(TodosQuery)(TodosComponent)

然后再修改下render就可以正常展示了:

render(<ApolloProvider client={client}>          
            <Todos />        
        </ApolloProvider>, document.querySelector('#app'))

这里又回到我们熟悉的场景了:graphql接受一个query和一个组件。我们只是用字符串插值将Todo的query合并到了Todos的查询中,fragment就好像graphql的对象碎片,可以用跟esNext一样的…语法来展开这个类型。这个query的结果还是发一个query去获取整个todos,但是todo里面的内容正如Todo组件自己声明的query一样,获取id和text
这样,我们就把query也组件化了,并且这种组件化的组织形式和组件本身是同构的:每有一个类型对应的组件,就将对这个类型的query和这个组件对应起来,这样我们就获得了一个有点像著名的7层协议抽象分层的组件化系统:
<Todos />这个组件下面要渲染<Todo />这个子组件,它只应该知道要查询所有的todos,并将每条todo交给<Todo />这个组件,至于todo里面是什么,它完全不关心,这是<Todo />自己应该关心的事情。

Co-location
在以往的React特别是Flux / Redux开发中,比较恼人的地方就是你总要将许多许多东西从父级向子级传递——这就导致了组件之间虽然是明显分层的,但它们之间还是必须向对方暴露API,而这种API在视图上是非常不稳定的。这是实际的React开发中每天都要反复发生的事情。 在React中的组件化设计中,由于组件粒度经常比MVVM更细,因此递归嵌套的风格占据了核心地位,以至于在其他领域工作良好的Mixins也被当做反模式——因为Mixins并不是嵌套的,因此官方在新的API中移除Mixins,推荐使用Higher Order Component嵌套来解决组合问题。但是这也带来的嵌套过深传递繁琐的问题。在GraphQL客户端中,我们将query和组件以同样的形式嵌套,这样就彻底避免了嵌套需要多层传递的问题。 这种概念被Facebook成为co-location:在客户端一个有数据要显示的组件,必定在服务端中大多有一个实体对应:你所渲染的<Todo id="1" text="foo" />在服务端就有一个Todo(id: 1, text: "foo")的数据存在。因此,很大程度上我们要简化我们的开发,目的就是拉近二者的距离,避免不必要的弯路。 我们也可以在这里抽空对比一下基于URL的RESTful方案。对于大部分的应用,我们的Todo list可能要对应某个具体用户。于是我们先要请求用户资源,然后再去分别获取Todo资源。当然Todo是个很小的东西,可能就内联到todos中了。我们可以换做考虑article这些大一点的内容。我们可能需要多次请求,因为它们可能是不同资源:

GET user/12345/articles GET article/{articleId}

这种请求可能比想象的要长,因为我们必须等待第一个请求返回才知道第二个请求的参数。 和React只有一个render入口,root component计算出整个Virtual DOM渲染一样——root query也会递归地集齐所有碎片,然后一次发出查询,这种场景可能就是:

{   
    user(id: 12345) {     
        articles {       
            title       
            content       
            comments {         
                user {           
                name         
                }         
                content       
            }     
        }   
    } 
}

考虑以上场景,用简单的url表示,不然要分资源做多个不同的API,不然就要写一些很奇怪的back-end for front-end api了。 如果为GraphQL的这种模式找个类似的REST替代品的话,我们再看下这种co-location如果在REST上使用的情况,相信很多同学都跟我做过一样的事,即便用的不是React:

@url(articleId => `article/${articleId}`)
class Article extends Component {
// ...
}

其实这样写也不是不可以,我们某个组件对应某一种资源,但是因为URL表现力有限,所以API还是很容易Break,也容易重复请求。其实这样写很容易坑自己,但展示了一种良好的声明式思想……换做GraphQL由于你可以决定返回的结构,这种用法就不再是个反模式了。

Task5: 说好的Mutation呢

我们完成了查询,这时候该做点修改了:

const AddTodoComponent = ({ mutate }) => {   
    let input   
    const handleSubmit = e => {     
        e.preventDefault()     
        mutate({variables: {text: input.value}})   
    }   
    return <form onSubmit={ handleSubmit }>    
    Enter todo here: <input type="text" ref={ el => input = el }/>   
    </form>} const AddTodoMutation = gql`   
    mutation addTodo($text: String!){     
        addTodo(text: $text) {       
            text       
            id     
        }   
    } 
` 
const AddTodo = graphql(AddTodoMutation)(AddTodoComponent)

这个组件也很直接,一个输入框,回车就发一个mutation请求。

render(<ApolloProvider client={client}>         
        <div>            
            <Todos />           
            <AddTodo />          
        </div>       
    </ApolloProvider>, document.querySelector('#app'))

然后我们看看效果……好像哪里不太对。我们的确成功Update了状态,但mutation的返回值并没有按想象地一样添加到渲染的列表中,必须刷新才能看到结果,太坑了。
我们现在有两种办法:

  • mutate是一个promise,我们可以直接.then(concateToTodoList)这样直接把数据手动set进去。这跟我们平常开发SPA一样,在服务端和客户端Duplicate很多逻辑,难以维护。
  • 把AddTodo嵌套进Todos里,拿走Todos的data属性,然后.then(() => data.refetch())这样可以让todos重新获取它的todos query,至少这样不用再前端重写这种逻辑了:
     const TodosComponent = ({ data }) => <div>   
         <ul>{ data.todos && data.todos.map((todo, i) => <Todo todo={todo} key={i} />) }</ul>  
         <AddTodo data={data} /> 
       </div>
    // ... 
    const AddTodoComponent = ({ mutate, data }) => {   
           let input   
           const handleSubmit = e => {     
               e.preventDefault()     
               mutate({variables: {text: input.value}})       
               .then(_ => data.refetch())   
           } 
       // ... 
       }
    

但这只是最简单的情况,我的AddTodo恰巧和Todos相关。换个更复杂的场景,比如我们的这个TodoList只是页面中的一小部分,而页面中顶部的状态栏中有Todo数量的统计。这时我AddTodo一下,我就要把应用最外层顶部状态栏的的data引用也获取到……再或者这个操作会影响应用中很多组件的状态——这时候很多人会想到Redux,但它要求你要在前端维护一套store,也就是维护前端的逻辑,又得在前端Duplicate代码。那么,我们可不可以发出这个mutation之后,既不在前端获取数据之后手动维护状态插入数据,又不用手动调用其他组件的refetch()呢?
对于这种懒到家的设想,Apollo这个简单易用的库还没那么聪明能帮我们简单实现。这时候我们需要Facebook自己家的Relay来帮忙了,相比Apollo这种简单的容易用代码展示的库,当我们谈到Relay会带来更多的概念。

小结

本篇的代码都在这里,篇幅有限,光靠上面的代码拼接起来可能有些困难。这个库上手难度比较低,有兴趣的同学可以尝试下。 我们在本篇看到了GraphQL客户端有能力一个查询查到大量不相干的数据,这可以将并行乃至串行的请求合并为一个请求,这可以大幅提升响应速度。

我们还看到了GraphQL客户端的设计思路:Co-location——我们将组件与query类型以同样的方式组织,避免了大量无用的传递代码。并且我们以完全声明式的代码完成了query和渲染:也就是说我们代码上只关心What,只字不提When和How。这会极大地提高代码的可维护性,简化开发,因为状态和赋值总是万恶之源,更别提在JavaScript环境下必须大量使用异步操作的情形。在不牺牲SPA复杂逻辑的情况下,通过GraphQL,我们可以把前端的开发工作重新简化成编写类似编写HTML的简单活动。我们也看到这种优雅的风格并不能很好地在mutation这种写入操作上重现。至于如何在mutation上维持这种风格,这就有待我们对Cache,query和服务器推送等内容进行更深入的讨论了。

解读GraphQL(一): WHAT & WHY

解读GraphQL(三):Relay—面向未来的前端框架


更多精彩洞见,请关注微信公众号:思特沃克

Share

解读GraphQL(一): WHAT & WHY

今天我们解读一下2016年11月期技术雷达中的GraphQL,它位于语言象限,处于评估阶段,编号整100,非常方便查找……这项技术比较有意思。对我来说,技术雷达中通常有两种典型技术:

  • 第一种,像Apache Kafka这样的,一看就感觉很牛,然后哇地赞叹一下,但因为离项目场景太远,大概看看热闹就过去了。
  • 第二种,典型就是ECMAScript 2017这种,早晚要用并且应用广泛,但说真的,好像也没啥可介绍的……

GraphQL和这两种都不太一样——它用来构建我们Web前端/移动客户端的API,这个覆盖面就广泛了。不管你是前端/后端/还是移动端开发,都跑不了和API打交道。然而,GraphQL本身非常激进,和我们现在的API形式大相径庭,足够搞个大新闻了。废话不多说,我们进入正题吧。

GraphQL是什么?

GraphQL是Facebook推出的一个查询语言,可能和听起来不同,它并不是一个数据库查询语言,而是你可以用任何其他语言实现的一个用于查询的抽象层。通常你可以通过GraphQL让你的客户端请求有权决定获取的数据结构,也可以通过GraphQL获得更好的多版本API兼容性。并且与大多数查询语言不同的是,GraphQL是一个静态类型的查询语言,这意味着你可以通过GraphQL获得更强大更安全的开发体验。

Facebook自2012年就已经在移动端上使用GraphQL了,只是去年才将其开源。除了GraphQL之外,市面上也有许多类似的方案:比如Netflix的Falcor,以及ClojureScript编写的om.next,还有om.next的灵感来源Datomic等等。

让我们先来看个例子:

{  
   user(id: 3500401) {
     id,
     name,
     isViewerFriend,
     profilePicture(size: 50) {
       uri,
       width,
       height
     }
   }
}

这条查询很直接——它请求了某个ID下面的id、name以及isViewer状态,同时还请求了她特定尺寸的头像和头像的信息。GraphQL的query很像JSON,而JSON也是我们实现GraphQL的返回值:

{
   "user": 3500401,
   "name": "Jing Chen",
   "isViewerFriend": true,
   "profilePicture": {
     "url": "http://someurl.cdn/pic.jpg",
     "width": 50,
     "height": 50
   }
}

这很符合直觉:你发出去的请求中的结构基本就是你将要获得的JSON,并且你也可以像函数一样传参来影响返回值。此外GraphQL并不关心传输协议,你可以将GraphQL放在Get请求的URL里,或者任何你能想到的方式:查询实质上只是普通字符串。

为什么要选择GraphQL?

没听说过GraphQL的话可能看到上面的解释还是不清楚,虽然这东西看起来很神奇,但是它是干什么用的?用在哪?好吧,那现在告诉你,GraphQL的核心目标就是取代RESTful API

REST是一种古老的面向服务端和客户端(CS)的架构风格,并不是一项特定的技术。它定义了一系列严格的构建API的原则,用简单的方式描述资源,并认为大部分时候违背这些原则会让软件的扩展性受限。随着服务端SOA和客户端Ajax的崛起,通信扩展问题变得越来越重要,REST得以广泛被运用。在MicroService逐渐流行的今天,RESTful API已经成为主流。如今,随着前端和移动端的迅猛发展,REST也面临严峻挑战。

所以,REST有什么问题?

REST本身作为一种对资源的建模,它的扩展性其实并无特殊问题。我们对于REST的指责经常并不来源于它本身,而来自于它不能解决的问题:诸如性能优化和页面展示的资源分类等等。

我们可以列举REST问题的几个表现——之所以用“表现”来形容,是因为它们都指向同一个问题——在为客户端实现RESTful API过程中性能、页面等等导致的折中设计和REST本身可扩展性之间不可调和的矛盾。注意,以下问题主要针对Web前端/移动端的API,并非Service之间的API——问题来自视图和网络速度。

1.资源分类导致性能受限

在前端我们很少遇到运行效率问题,效率问题主要来自网络请求——一次HTTP请求的代价非常高昂,特别是对移动端来说。如果我们遵循REST的风格,我们就要将各种资源分门别类用不同的API来表示。而在客户端中我们经常需要一次请求多种资源。这时候我们就要编写许多API来为不同的页面合并这些API。很多时候,我们写的这些API并不是一个特定资源,但我们还得用URL来表示它们。

此外,当我们选择不合并资源时,性能损耗经常比我们想象的严重:如果合并完全无关的资源,不合并时也只是两个并发的请求,返回的时间大多只取决于更慢的API。而更常见的情况是,资源之间有映射关系:比如我们经常要先请求某个user信息,然后等这个API返回之后再渲染这个user名下的articles。于是我们再去请求不同的article。这时候,我们发现请求之间甚至不是并行的,而是串行的。而我们现实中的前端应用,因为视图设计中常见层叠的资源,所以也经常会有这种多次串行的请求,这会导致我们的网络请求时间成倍增长。

2.在现代场景中难于维护

虽然REST的目标是易于维护和扩展,但在Web前端/客户端领域,它的表现并没有想象得那么好。我们经常说最明显的Code smell就是重复。许多时候,我们要让API适应视图,但我们都知道,这种API仅被客户端消费,与服务端代码耦合是非常不合理的。随着前端/移动端的兴起,我们经常还要为多种客户端编写多种API。这些API代码既类似又无聊,并且也要在客户端修改时一起修改——仅前端和后端的重复我们可以让IDE查找,然而这种散落在前后端的契约则很容易遗漏。不仅如此,如果这个API是Public API,一点小改动就要修改版本。

3.缺乏约束

RESTful API通过URL表述资源,它本身是无类型的。现在,随着技术的发展,我们已经有许多非常强大的静态类型语言,它们有非常强大的开发工具来帮助我们检查错误。而在我们系统的API边界,这些重量级的强大工具却无能为力。随着Micro service越来越流行、系统中的边界越来越多,静态类型能捕获的错误则越来越少。有时候我们用Scala编写的应用在遭遇API错误时不能不说是一种讽刺……

应对这种情形,我们则要花费额外的努力来维护契约测试,还要小心翼翼地对应Service之间的版本依赖,因为对REST来说,不同版本之间的兼容能力非常弱小。

4.严格,抽象,但并不能解决客户端问题:

我们经常可以在网上看到互相指责的文章和讨论,基本上都是一方列举出自己使用RESTful API遇到的实际问题,而另一方认为前者实际应用违反了哪条REST原则,因此不是RESTful API。这种情形十分典型,后者说的也正确,但这并没有实际帮助——付出高昂的性能和开发代价来维护严格的RESTful是不现实的。此外,在实现之前反被要求反复思考资源之间的关系的方式也不够敏捷。

更糟的是,我们都很难简短准确地跟他人解释REST到底是什么,我没看过Roy Fielding的论文,其实我自己也不清楚。这类似Java大行其道时的设计模式:有用,但太玄学,用起来也不自然,最关键的是不能被代码有效地抽象。然而最终我们发现大部分设计模式在某些语言里要么被彻底消灭,要么就变得很自然,让你感觉不到了。而解决REST问题可能也类似:不再纠结教条,彻底换一种思路。

那么,GraphQL好在哪?

比起苍白的讨论而言,直接使用它可能更有说服力。几个月前,Github宣布他们打算拥抱GraphQL:

We’ve often heard that our REST API was an inspiration for other companies; countless tutorials refer to our endpoints. Today, we’re excited to announce our biggest change to the API since we snubbed XML in favor of JSON: we’re making the GitHub API available through GraphQL.

我们可以试用Github放出的GraphQL API,虽然这个API还在Early Access阶段,但我们可以用它直接探索GraphQL和我们熟悉的API查询方式的区别。Github在他们的网页里内嵌了一个GraphiQL——Facebook提供的GraphQL开发工具,它本质上是一个React组件,通过它,我们可以不构建代码直接阅读查询文档,调试我们的查询。(注:使用需要登录Github并授权)

1.需求驱动

我们可以先在GraphiQL里尝试在左边输入下面这段查询,按Ctrl Enter就可以在右边得到自己的名字和公司了:

{
       viewer {
         name
         company
       }
    }

如果观察Network请求的话会发现,无论进行什么查询,请求都是指向同一个endpoint的POST请求。你也可以加上其他字段,或者删掉字段试试看结果会怎样。

GraphQL用来构建客户端API,但它并不关心视图,也不关心服务的到底是什么客户端。至于请求什么数据,数据怎么组织,全都是客户端说了算——这也是为什么要实现一个查询语言的原因:有了查询语言,你就可以精确描述你想要的了,移动端可能只获取文章标题,而Web端则希望可以预览部分内容。这被Facebook描述为Demand Driven。

除了减少构建无聊CRUD API之外,另一个明显的好处是,对于大部分前后端分离的项目,客户端开发人员可以独立修改页面的展现形式。对于经常需要探索创意的创业公司来说,这降低了迭代成本,而对于前后端分离的大型项目而言,则减少了沟通成本。

2.一次请求复杂数据

这次我们一次请求多种资源,大意就是同时查询你前10个followers的名字,和他们前5个repository的名字(要是觉得还不复杂,你可以嵌套地查repository的follower的repository的follower……这样循环下去):

{
       viewer {
         followers(first: 10) {
           edges {
             node {
               name
               repositories(first: 5) {
                 edges {
                   node {
                     name
                   }
                 }
               }
             }
           }
         }
       }
    }

上面提到了传统方式会导致串行请求,这对性能的损耗是十分严重的。而GraphQL的意思,顾名思义——就是图查询语言。不同于平常的请求,实现GraphQL的服务端接收到请求后,虽然还是HTTP的一次请求,但是会根据查询的结构递归地根据查询来调用各项资源的Resolver(可以不太恰当地类比为Controller action),最后拼回一张JSON Graph返回给客户端。因此你可以在一次查询中轻松表述诸如“表弟的七大姑的二侄子的小姨子叫啥来着,多大岁数,有没有对象”这种复杂的关系。

3.静态类型

可能你已经注意到了,你输入的查询都有自动补全和类型纠错功能,这归功于GraphQL的静态类型系统。你可以在定义GraphQL Schema时添加更多的类型来描述不同的资源。在GraphiQL的右边有个“Docs”面板,点开可以看到各种类型的签名和描述,每种类型可以继续点击查看详情。你可以在完全没文档的情况下,仅通过它很快理解所有API。

其实这个“Docs”并不用手动编写,它完全根据服务端代码自动生成。而这个面板本身信息的来源,也只不过是GraphQL查询本身,这被Facebook称为自省(Introspection)。你可以打开Network,刷新页面查看GraphiQL是如何查询所有类型信息的。比如我们可以尝试:

    {
       __type(name: "Repository") {
         description
       }
       __schema {
         types {
           name
         }
       }
    }

这样就会返回Repository这个type到底是个啥,以及我想知道服务端所有的type。

对于编程语言来说,拥有强大的静态类型是很常见的。对于查询语言,却不是很多见。在这点上GraphQL有点像RPC,可以生成GraphQL Schema来作为服务端和客户端间的契约。一般这个过程也会自动生成JSON schema来方便你做其他的契约测试。Intellij IDEA还有一个非常完善的GraphQL插件,当你服务端Schema有Breaking Change时,客户端代码就会报错,有些编译插件也会产生编译时错误。

4.兼容多版本

由于客户端可以决定请求的内容,服务端也可以不删除废弃的字段,而仅仅加入@Deprecated注解,这样客户端查询时只会被Warning。这样做的结果就是不同客户端和不同Service之间的版本依赖也变得非常宽松了(注:这段代码是用来在服务端定义Schema而不是查询的,所以不能在GraphiQL中用):

type Film {
       title: String
       episode: Int
       releaseDate: String
       openingCrawl: String
       director: String @deprecated
       directedBy: Person
    }

5.Mutation

我们说RESTful API经常说CRUD这几个动作,也就是说光查询不行,还得能向服务端写数据。当然,GraphQL的核心功能之一就是Mutation,也就是实现CUD这些非只读操作。比如如下的查询可以让我们创建一个新Repository并返回这个新Repository的ID(很可惜,这个API似乎有问题,Github会匹配不到你自己的ownerId):

mutation {
       createProject(
        input:{
          name:"test repository",
          ownerId:"MDQ6VXNlcjEwMTkzNDA1"
        }
       ) {
         clientMutationId
         project {
           id
         }
       }
    }

GraphQL潜在的问题?

虽然GraphQL看起来很酷炫,但是也有些地方要注意。

1.服务端优化

由于是查询本身被解析成图,递归地取值,因此可能会存在服务器性能隐患。特别是对SQL来说,现在非常容易大量出现N+1的情形。因此,现在GraphQL大多还都用在NoSQL上。但是由于整个请求还是在一次HTTP请求中完成的,理论上我们也有Batch为一个查询的能力,就像许多ORM有一些惰性特性,可以将多个查询过滤语句合并成一条查询一样。Facebook正在研究如何让GraphQL更好地Batch问题比如dataloader,社区也有一些不错的实现。这些库不仅在尝试解决这些问题,而且也揭示了GraphQL作为API抽象层本身,可以在普通Web场景下和ORM结合的能力——通过代码可以将这两层抽象到一起,这很可能让我们可以自己轻易搭建一个GraphQL BAAS(Back-end as a service)。现在已经有很多平台提供这种服务了,比如scapholdreindexgraphcool等,也有Graffiti这种与Mongo ODM直接结合的类库。

2.安全问题

虽然GraphQL给客户端提供了强大的查询能力,但这也意味着有被客户端滥用的风险。如果不使用某些限制过大的查询,反复请求一条Load出所有Github用户的查询可能会让他们的服务器直接挂掉,GraphQL提高了被DDoS的风险。因此在使用GraphQL的过程中,我们要对安全问题更加重视。

3.需要重新思考Cache策略

REST虽然会引起一些性能问题,但它也以HTTP Cache的方式解决了很多性能问题。而对于多变的GraphQL操作来说,Cache就变成一个需要深入讨论的话题了。然而这种Cache策略就要交给客户端来完成了。

然后呢?

因此,接下来的文章会深入讨论基于GraphQL的多种类库以及不同客户端。最终我们也可以在这些类库上看到在现代组件化趋于主流之后,我们的通信应该怎么与组件化设计有效结合。与此同时,随着客户端越来越复杂,我们应该如何同步服务端状态,如何管理缓存等等。

其中一个是Meteor团队推出的Apollo Data,它提供了一系列的服务端以及客户端工具来简化GraphQL的开发,容易上手并且支持Android、IOS、React(Native)以及Angular2。而另外一个更复杂但更强大的选择则是Facebook自己推出的Relay,它支持React(Native),提供了类似Virtual DOM的Diff算法来Diff Cache,可以自动精确管理数据来解决Cache问题。

我们将会从Apollo开始,感受GraphQL究竟是如何工作的,之后我们也会看到这个方案中的一些问题,而这些问题会将我们引向更深层的Web客户端问题——最终我们会探讨Relay,一个更完整的解决方案,从中我们可以看到Facebook对Web客户端的未来有着怎样的思考。

解读GraphQL(二): 使用Apollo Data构建GraphQL应用

解读GraphQL(三):Relay—面向未来的前端框架


更多精彩洞见,请关注微信公众号:思特沃克

Share