图片 17

头部压缩技术介绍,复杂单页应用的数据层设计

复杂单页应用的数据层设计

2017/01/11 · JavaScript
·
单页应用

原文出处: 徐飞   

很多人看到这个标题的时候,会产生一些怀疑:

什么是“数据层”?前端需要数据层吗?

可以说,绝大部分场景下,前端是不需要数据层的,如果业务场景出现了一些特殊的需求,尤其是为了无刷新,很可能会催生这方面的需要。

我们来看几个场景,再结合场景所产生的一些诉求,探讨可行的实现方式。

HTTP/2 头部压缩技术介绍

2016/04/13 · 基础技术 ·
HTTP/2

本文作者: 伯乐在线 –
JerryQu
。未经作者许可,禁止转载!
欢迎加入伯乐在线 专栏作者。

我们知道,HTTP/2 协议由两个 RFC 组成:一个是 RFC
7540,描述了 HTTP/2
协议本身;一个是 RFC
7541,描述了 HTTP/2
协议中使用的头部压缩技术。本文将通过实际案例带领大家详细地认识 HTTP/2
头部压缩这门技术。

CSS布局奇技淫巧:宽度自适应

2016/11/03 · CSS ·
自适应

原文出处:
无双   

css这个东西,说难不难,说容易也不容易。我觉得最重要的还是经验的积累,正所谓的不积硅步,无以至千里。这一系列文章讲述几种css特殊布局的实现,也当作为自己做个备忘吧。

首先讲的是三列布局,左右两列宽度固定,中间一列宽度自适应

这个很好实现,左右两列分别左浮动和右浮动并给一个固定宽度,中间不浮动,也不设定宽度。这样基本就可以了。但为了兼容IE还必须做些工作。

看下代码结构:

图片 1

效果为:

图片 2

中间列要不要设置margin-left和margin-right ?

注意,中间那列需要把左右两个外边距分别设为左右两列的宽度,否则会有些问题。如下:

在谷歌、火狐等标准浏览器下是这样的(包括IE8+):

图片 3

而在IE6、IE7中是这样的(图是在IE6下截的)

图片 4

我们可以看到中间那列子元素的margin-left或margin-right的起点是不一致的,在IE6、IE7中,即使不给中间列设定margin-left和margin-right,它的子元素的左右外边距的起点仍然是在左右两列宽的的基础上的,就像是有margin-left和margin-right一样。所以为了各浏览器保持一致,中间那列还是设一个margin-left和margin-right为好。

IE6中的3px间隙bug

在上图的ie6截图中,我们看到各列之间有一条3px的间隔,这是只有IE6才有的问题。

如果中间那列的margin-left和margin-right都为0的话,则只要把左列的margin-right设为-3px,右列的margin-left设为-3px就行了。

但如果把中间列的margin-left和margin-right分别为左右两列的宽度时(上面已经说了,这也是必须这样做的),即使把左列的margin-right设为-3px,右列的margin-left设为-3px也还是没有效果。这时候还得把中间列的margin-left设为左列宽度-3px,margin-right设为右列宽度-3px才行。如下:

图片 5

最终的代码

XHTML

<!DOCTYPE html> <html> <head> <meta charset=”utf-8″
/> <title>宽度自适应布局</title> <style> body, div
{ margin:0; padding:0; } div { height:200px; color:#F00; } .left {
float:left; width:100px; background:#00f; _margin-right:-3px; } .right
{ float:right; width:100px; background:#0f0; _margin-left:-3px; }
.center { background:#333; margin:0 100px; _margin:0 97px; }
</style> </head> <body> <div
class=”left”>我是left</div> <div
class=”right”>我是right</div> <div
class=”center”>我是center</div> </body> </html>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>宽度自适应布局</title>
<style>
    body, div {
        margin:0;
        padding:0;
    }
    div {
        height:200px;
        color:#F00;
    }
    .left {
        float:left;
        width:100px;
        background:#00f;
        _margin-right:-3px;
    }
    .right {
        float:right;
        width:100px;
        background:#0f0;
        _margin-left:-3px;
    }
    .center {
        background:#333;
        margin:0 100px;
        _margin:0 97px;
    }
</style>
</head>
<body>
<div class="left">我是left</div>
<div class="right">我是right</div>
<div class="center">我是center</div>
</body>
</html>

两列布局,一列宽度固定,另一列自适应布局也是这个道理的。

1 赞 3 收藏
评论

图片 6

视图间的数据共享

所谓共享,指的是:

同一份数据被多处视图使用,并且要保持一定程度的同步。

如果一个业务场景中,不存在视图之间的数据复用,可以考虑使用端到端组件。

什么是端到端组件呢?

我们看一个示例,在很多地方都会碰到选择城市、地区的组件。这个组件对外的接口其实很简单,就是选中的项。但这时候我们会有一个问题:

这个组件需要的省市区域数据,是由这个组件自己去查询,还是使用这个组件的业务去查好了传给这个组件?

两者当然是各有利弊的,前一种,它把查询逻辑封装在自己内部,对使用者更加有利,调用方只需这么写:

XHTML

<RegionSelector
selected=“callback(region)”></RegionSelector>

1
<RegionSelector selected=“callback(region)”></RegionSelector>

外部只需实现一个响应取值事件的东西就可以了,用起来非常简便。这样的一个组件,就被称为端到端组件,因为它独自打通了从视图到后端的整个通道。

这么看来,端到端组件非常美好,因为它对使用者太便利了,我们简直应当拥抱它,放弃其他所有。

端到端组件示意图:

A | B | C ——— Server

1
2
3
A | B | C
———
Server

可惜并非如此,选择哪种组件实现方式,是要看业务场景的。如果在一个高度集成的视图中,刚才这个组件同时出现了多次,就有些尴尬了。

尴尬的地方在哪里呢?首先是同样的查询请求被触发了多次,造成了冗余请求,因为这些组件互相不知道对方的存在,当然有几个就会查几份数据。这其实是个小事,但如果同时还存在修改这些数据的组件,就麻烦了。

比如说:在选择某个实体的时候,发现之前漏了配置,于是点击“立刻配置”,新增了一条,然后回来继续原流程。

例如,买东西填地址的时候,发现想要的地址不在列表中,于是点击弹出新增,在不打断原流程的情况下,插入了新数据,并且可以选择。

这个地方的麻烦之处在于:

组件A的多个实例都是纯查询的,查询的是ModelA这样的数据,而组件B对ModelA作修改,它当然可以把自己的那块界面更新到最新数据,但是这么多A的实例怎么办,它们里面都是老数据,谁来更新它们,怎么更新?

这个问题为什么很值得说呢,因为如果没有一个良好的数据层抽象,你要做这个事情,一个业务上的选择和会有两个技术上的选择:

  • 引导用户自己刷新界面
  • 在新增完成的地方,写死一段逻辑,往查询组件中加数据
  • 发一个自定义业务事件,让查询组件自己响应这个事件,更新数据

这三者都有缺点:

  • 引导用户刷新界面这个,在技术上是比较偷懒的,可能体验未必好。
  • 写死逻辑这个,倒置了依赖顺序,导致代码产生了反向耦合,以后再来几个要更新的地方,这里代码改得会很痛苦,而且,我一个配置的地方,为什么要管你后续增加的那些查询界面?
  • 自定义业务事件这个,耦合是减少了,却让查询组件自己的逻辑膨胀了不少,如果要监听多种消息,并且合并数据,可能这里更复杂,能否有一种比较简化的方式?

所以,从这个角度看,我们需要一层东西,垫在整个组件层下方,这一层需要能够把查询和更新做好抽象,并且让视图组件使用起来尽可能简单。

另外,如果多个视图组件之间的数据存在时序关系,不提取出来整体作控制的话,也很难去维护这样的代码。

添加了数据层之后的整体关系如图:

A | B | C ———— 前端的数据层 ———— Server

1
2
3
4
5
A | B | C
————
前端的数据层
————
  Server

那么,视图访问数据层的接口会是什么样?

我们考虑耦合的问题。如果要减少耦合,很必然的就是这么一种形式:

  • 变更的数据产生某种消息
  • 使用者订阅这个消息,做一些后续处理

因此,数据层应当尽可能对外提供类似订阅方式的接口。

为什么要压缩

在 HTTP/1 中,HTTP 请求和响应都是由「状态行、请求 /
响应头部、消息主体」三部分组成。一般而言,消息主体都会经过 gzip
压缩,或者本身传输的就是压缩过后的二进制文件(例如图片、音频),但状态行和头部却没有经过任何压缩,直接以纯文本传输。

随着 Web 功能越来越复杂,每个页面产生的请求数也越来越多,根据 HTTP
Archive
的统计,当前平均每个页面都会产生上百个请求。越来越多的请求导致消耗在头部的流量越来越多,尤其是每次都要传输
UserAgent、Cookie 这类不会频繁变动的内容,完全是一种浪费。

以下是我随手打开的一个页面的抓包结果。可以看到,传输头部的网络开销超过
100kb,比 HTML 还多:

图片 7

下面是其中一个请求的明细。可以看到,为了获得 58
字节的数据,在头部传输上花费了好几倍的流量:

图片 8

HTTP/1
时代,为了减少头部消耗的流量,有很多优化方案可以尝试,例如合并请求、启用
Cookie-Free
域名等等,但是这些方案或多或少会引入一些新的问题,这里不展开讨论。

服务端推送

如果要引入服务端推送,怎么调整?

考虑一个典型场景,WebIM,如果要在浏览器中实现这么一个东西,通常会引入WebSocket作更新的推送。

对于一个聊天窗口而言,它的数据有几个来源:

  • 初始查询
  • 本机发起的更新(发送一条聊天数据)
  • 其他人发起的更新,由WebSocket推送过来
视图展示的数据 := 初始查询的数据 + 本机发起的更新 + 推送的更新

<table>
<colgroup>
<col style="width: 50%" />
<col style="width: 50%" />
</colgroup>
<tbody>
<tr class="odd">
<td><div class="crayon-nums-content" style="font-size: 13px !important; line-height: 15px !important;">
<div class="crayon-num" data-line="crayon-5b8f4b62cb7b7061328078-1">
1
</div>
</div></td>
<td><div class="crayon-pre" style="font-size: 13px !important; line-height: 15px !important; -moz-tab-size:4; -o-tab-size:4; -webkit-tab-size:4; tab-size:4;">
<div id="crayon-5b8f4b62cb7b7061328078-1" class="crayon-line">
视图展示的数据 := 初始查询的数据 + 本机发起的更新 + 推送的更新
</div>
</div></td>
</tr>
</tbody>
</table>

这里,至少有两种编程方式。

查询数据的时候,我们使用类似Promise的方式:

JavaScript

getListData().then(data => { // 处理数据 })

1
2
3
getListData().then(data => {
  // 处理数据
})

而响应WebSocket的时候,用类似事件响应的方式:

JavaScript

ws.on(‘data’, data => { // 处理数据 })

1
2
3
ws.on(‘data’, data => {
  // 处理数据
})

这意味着,如果没有比较好的统一,视图组件里至少需要通过这两种方式来处理数据,添加到列表中。

如果这个场景再跟上一节提到的多视图共享结合起来,就更复杂了,可能很多视图里都要同时写这两种处理。

所以,从这个角度看,我们需要有一层东西,能够把拉取和推送统一封装起来,屏蔽它们的差异。

压缩后的效果

接下来我将使用访问本博客的抓包记录来说明 HTTP/2
头部压缩带来的变化。如何使用 Wireshark 对 HTTPS
网站进行抓包并解密,请看我的这篇文章。

首先直接上图。下图选中的 Stream 是首次访问本站,浏览器发出的请求头:

图片 9

从图片中可以看到这个 HEADERS 流的长度是 206 个字节,而解码后的头部长度有
451 个字节。由此可见,压缩后的头部大小减少了一半多。

然而这就是全部吗?再上一张图。下图选中的 Stream
是点击本站链接后,浏览器发出的请求头:

图片 10

可以看到这一次,HEADERS 流的长度只有 49 个字节,但是解码后的头部长度却有
470 个字节。这一次,压缩后的头部大小几乎只有原始大小的 1/10。

为什么前后两次差距这么大呢?我们把两次的头部信息展开,查看同一个字段两次传输所占用的字节数:

图片 11

图片 12

对比后可以发现,第二次的请求头部之所以非常小,是因为大部分键值对只占用了一个字节。尤其是
UserAgent、Cookie
这样的头部,首次请求中需要占用很多字节,后续请求中都只需要一个字节。

缓存的使用

如果说我们的业务里,有一些数据是通过WebSocket把更新都同步过来,这些数据在前端就始终是可信的,在后续使用的时候,可以作一些复用。

比如说:

在一个项目中,项目所有成员都已经查询过,数据全在本地,而且变更有WebSocket推送来保证。这时候如果要新建一条任务,想要从项目成员中指派任务的执行人员,可以不必再发起查询,而是直接用之前的数据,这样选择界面就可以更流畅地出现。

这时候,从视图角度看,它需要解决一个问题:

  • 如果要获取的数据未有缓存,它需要产生一个请求,这个调用过程就是异步的
  • 如果要获取的数据已有缓存,它可以直接从缓存中返回,这个调用过程就是同步的

如果我们有一个数据层,我们至少期望它能够把同步和异步的差异屏蔽掉,否则要使用两种代码来调用。通常,我们是使用Promise来做这种差异封装的:

JavaScript

function getDataP() : Promise<T> { if (data) { return
Promise.resolve(data) } else { return fetch(url) } }

1
2
3
4
5
6
7
function getDataP() : Promise<T> {
  if (data) {
    return Promise.resolve(data)
  } else {
    return fetch(url)
  }
}

这样,使用者可以用相同的编程方式去获取数据,无需关心内部的差异。

技术原理

下面这张截图,取自 Google 的性能专家 Ilya Grigorik 在 Velocity 2015 • SC
会议中分享的「HTTP/2 is here, let’s
optimize!」,非常直观地描述了
HTTP/2 中头部压缩的原理:

图片 13

我再用通俗的语言解释下,头部压缩需要在支持 HTTP/2 的浏览器和服务端之间:

  • 维护一份相同的静态字典(Static
    Table),包含常见的头部名称,以及特别常见的头部名称与值的组合;
  • 维护一份相同的动态字典(Dynamic Table),可以动态地添加内容;
  • 支持基于静态哈夫曼码表的哈夫曼编码(Huffman Coding);

静态字典的作用有两个:1)对于完全匹配的头部键值对,例如
:method: GET,可以直接使用一个字符表示;2)对于头部名称可以匹配的键值对,例如
cookie: xxxxxxx,可以将名称使用一个字符表示。HTTP/2
中的静态字典如下(以下只截取了部分,完整表格在这里):

Index Header Name Header Value
1 :authority
2 :method GET
3 :method POST
4 :path /
5 :path /index.html
6 :scheme http
7 :scheme https
8 :status 200
32 cookie
60 via
61 www-authenticate

同时,浏览器可以告知服务端,将 cookie: xxxxxxx
添加到动态字典中,这样后续整个键值对就可以使用一个字符表示了。类似的,服务端也可以更新对方的动态字典。需要注意的是,动态字典上下文有关,需要为每个
HTTP/2 连接维护不同的字典。

使用字典可以极大地提升压缩效果,其中静态字典在首次请求中就可以使用。对于静态、动态字典中不存在的内容,还可以使用哈夫曼编码来减小体积。HTTP/2
使用了一份静态哈夫曼码表(详见),也需要内置在客户端和服务端之中。

这里顺便说一下,HTTP/1 的状态行信息(Method、Path、Status 等),在
HTTP/2
中被拆成键值对放入头部(冒号开头的那些),同样可以享受到字典和哈夫曼压缩。另外,HTTP/2
中所有头部名称必须小写。

数据的聚合

很多时候,视图上需要的数据与数据库存储的形态并不完全相同,在数据库中,我们总是倾向于储存更原子化的数据,并且建立一些关联,这样,从这种数据想要变成视图需要的格式,免不了需要一些聚合过程。

通常我们指的聚合有这么几种:

  • 在服务端先聚合数据,然后再把这些数据与视图模板聚合,形成HTML,整体输出,这个过程也称为服务端渲染
  • 在服务端只聚合数据,然后把这些数据返回到前端,再生成界面
  • 服务端只提供原子化的数据接口,前端根据自己的需要,请求若干个接口获得数据,聚合成视图需要的格式,再生成界面

大部分传统应用在服务端聚合数据,通过数据库的关联,直接查询出聚合数据,或者在Web服务接口的地方,聚合多个底层服务接口。

我们需要考虑自己应用的特点来决定前端数据层的设计方案。有的情况下,后端返回细粒度的接口会比聚合更合适,因为有的场景下,我们需要细粒度的数据更新,前端需要知道数据之间的变更联动关系。

所以,很多场景下,我们可以考虑在后端用GraphQL之类的方式来聚合数据,或者在前端用类似Linq的方式聚合数据。但是,注意到如果这种聚合关系要跟WebSocket推送产生关联,就会比较复杂。

我们拿一个场景来看,假设有一个界面,长得像新浪微博的Feed流。对于一条Feed而言,它可能来自几个实体:

Feed消息本身

JavaScript

class Feed { content: string creator: UserId tags: TagId[] }

1
2
3
4
5
class Feed {
  content: string
  creator: UserId
  tags: TagId[]
}

Feed被打的标签

JavaScript

class Tag { id: TagId content: string }

1
2
3
4
class Tag {
  id: TagId
  content: string
}

人员

JavaScript

class User { id: UserId name: string avatar: string }

1
2
3
4
5
class User {
  id: UserId
  name: string
  avatar: string
}

如果我们的需求跟微博一样,肯定还是会选择第一种聚合方式,也就是服务端渲染。但是,如果我们的业务场景中,存在大量的细粒度更新,就比较有意思了。

比如说,如果我们修改一个标签的名称,就要把关联的Feed上的标签也刷新,如果之前我们把数据聚合成了这样:

JavaScript

class ComposedFeed { content: string creator: User tags: Tag[] }

1
2
3
4
5
class ComposedFeed {
  content: string
  creator: User
  tags: Tag[]
}

就会导致无法反向查找聚合后的结果,从中筛选出需要更新的东西。如果我们能够保存这个变更路径,就比较方便了。所以,在存在大量细粒度更新的情况下,服务端API零散化,前端负责聚合数据就比较合适了。

当然这样会带来一个问题,那就是请求数量增加很多。对此,我们可以变通一下:

做物理聚合,不做逻辑聚合。

这段话怎么理解呢?

我们仍然可以在一个接口中一次获取所需的各种数据,只是这种数据格式可能是:

JavaScript

{ feed: Feed tags: Tags[] user: User }

1
2
3
4
5
{
  feed: Feed
  tags: Tags[]
  user: User
}

不做深度聚合,只是简单地包装一下。

在这个场景中,我们对数据层的诉求是:建立数据之间的关联关系。

实现细节

了解了 HTTP/2 头部压缩的基本原理,最后我们来看一下具体的实现细节。HTTP/2
的头部键值对有以下这些情况:

1)整个头部键值对都在字典中

JavaScript

0 1 2 3 4 5 6 7 +—+—+—+—+—+—+—+—+ | 1 | Index (7+) |
+—+—————————+

1
2
3
4
5
  0   1   2   3   4   5   6   7
+—+—+—+—+—+—+—+—+
| 1 |        Index (7+)         |
+—+—————————+
 

这是最简单的情况,使用一个字节就可以表示这个头部了,最左一位固定为
1,之后七位存放键值对在静态或动态字典中的索引。例如下图中,头部索引值为
2(0000010),在静态字典中查询可得 :method: GET

图片 14

2)头部名称在字典中,更新动态字典

JavaScript

0 1 2 3 4 5 6 7 +—+—+—+—+—+—+—+—+ | 0 | 1 | Index (6+) |
+—+—+———————–+ | H | Value Length (7+) |
+—+—————————+ | Value String (Length octets) |
+——————————-+

1
2
3
4
5
6
7
8
9
  0   1   2   3   4   5   6   7
+—+—+—+—+—+—+—+—+
| 0 | 1 |      Index (6+)       |
+—+—+———————–+
| H |     Value Length (7+)     |
+—+—————————+
| Value String (Length octets)  |
+——————————-+
 

对于这种情况,首先需要使用一个字节表示头部名称:左两位固定为
01,之后六位存放头部名称在静态或动态字典中的索引。接下来的一个字节第一位
H 表示头部值是否使用了哈夫曼编码,剩余七位表示头部值的长度 L,后续 L
个字节就是头部值的具体内容了。例如下图中索引值为
32(100000),在静态字典中查询可得
cookie;头部值使用了哈夫曼编码(1),长度是 28(0011100);接下来的 28
个字节是 cookie 的值,将其进行哈夫曼解码就能得到具体内容。

图片 15

客户端或服务端看到这种格式的头部键值对,会将其添加到自己的动态字典中。后续传输这样的内容,就符合第
1 种情况了。

3)头部名称不在字典中,更新动态字典

JavaScript

0 1 2 3 4 5 6 7 +—+—+—+—+—+—+—+—+ | 0 | 1 | 0 |
+—+—+———————–+ | H | Name Length (7+) |
+—+—————————+ | Name String (Length octets) |
+—+—————————+ | H | Value Length (7+) |
+—+—————————+ | Value String (Length octets) |
+——————————-+

1
2
3
4
5
6
7
8
9
10
11
12
13
  0   1   2   3   4   5   6   7
+—+—+—+—+—+—+—+—+
| 0 | 1 |           0           |
+—+—+———————–+
| H |     Name Length (7+)      |
+—+—————————+
|  Name String (Length octets)  |
+—+—————————+
| H |     Value Length (7+)     |
+—+—————————+
| Value String (Length octets)  |
+——————————-+
 

这种情况与第 2
种情况类似,只是由于头部名称不在字典中,所以第一个字节固定为
01000000;接着申明名称是否使用哈夫曼编码及长度,并放上名称的具体内容;再申明值是否使用哈夫曼编码及长度,最后放上值的具体内容。例如下图中名称的长度是
5(0000101),值的长度是
6(0000110)。对其具体内容进行哈夫曼解码后,可得 pragma: no-cache

图片 16

客户端或服务端看到这种格式的头部键值对,会将其添加到自己的动态字典中。后续传输这样的内容,就符合第
1 种情况了。

4)头部名称在字典中,不允许更新动态字典

JavaScript

0 1 2 3 4 5 6 7 +—+—+—+—+—+—+—+—+ | 0 | 0 | 0 | 1 |
Index (4+) | +—+—+———————–+ | H | Value Length (7+) |
+—+—————————+ | Value String (Length octets) |
+——————————-+

1
2
3
4
5
6
7
8
9
  0   1   2   3   4   5   6   7
+—+—+—+—+—+—+—+—+
| 0 | 0 | 0 | 1 |  Index (4+)   |
+—+—+———————–+
| H |     Value Length (7+)     |
+—+—————————+
| Value String (Length octets)  |
+——————————-+
 

这种情况与第 2 种情况非常类似,唯一不同之处是:第一个字节左四位固定为
0001,只剩下四位来存放索引了,如下图:

图片 17

这里需要介绍另外一个知识点:对整数的解码。上图中第一个字节为
00011111,并不代表头部名称的索引为 15(1111)。第一个字节去掉固定的
0001,只剩四位可用,将位数用 N 表示,它只能用来表示小于「2 ^ N – 1 =
15」的整数 I。对于 I,需要按照以下规则求值(RFC 7541
中的伪代码,via):

JavaScript

if I < 2 ^ N – 1, return I # I 小于 2 ^ N – 1 时,直接返回 else M =
0 repeat B = next octet # 让 B 等于下一个八位 I = I + (B & 127) *
2 ^ M # I = I + (B 低七位 * 2 ^ M) M = M + 7 while B & 128 == 128
# B 最高位 = 1 时继续,否则返回 I return I

1
2
3
4
5
6
7
8
9
10
if I &lt; 2 ^ N – 1, return I         # I 小于 2 ^ N – 1 时,直接返回
else
    M = 0
    repeat
        B = next octet             # 让 B 等于下一个八位
        I = I + (B &amp; 127) * 2 ^ M  # I = I + (B 低七位 * 2 ^ M)
        M = M + 7
    while B &amp; 128 == 128           # B 最高位 = 1 时继续,否则返回 I
    return I
 

对于上图中的数据,按照这个规则算出索引值为 32(00011111 00010001,15 +
17),代表 cookie。需要注意的是,协议中所有写成(N+)的数字,例如
Index (4+)、Name Length (7+),都需要按照这个规则来编码和解码。

这种格式的头部键值对,不允许被添加到动态字典中(但可以使用哈夫曼编码)。对于一些非常敏感的头部,比如用来认证的
Cookie,这么做可以提高安全性。

5)头部名称不在字典中,不允许更新动态字典

JavaScript

0 1 2 3 4 5 6 7 +—+—+—+—+—+—+—+—+ | 0 | 0 | 0 | 1 | 0 |
+—+—+———————–+ | H | Name Length (7+) |
+—+—————————+ | Name String (Length octets) |
+—+—————————+ | H | Value Length (7+) |
+—+—————————+ | Value String (Length octets) |
+——————————-+

1
2
3
4
5
6
7
8
9
10
11
12
13
  0   1   2   3   4   5   6   7
+—+—+—+—+—+—+—+—+
| 0 | 0 | 0 | 1 |       0       |
+—+—+———————–+
| H |     Name Length (7+)      |
+—+—————————+
|  Name String (Length octets)  |
+—+—————————+
| H |     Value Length (7+)     |
+—+—————————+
| Value String (Length octets)  |
+——————————-+
 

这种情况与第 3 种情况非常类似,唯一不同之处是:第一个字节固定为
00010000。这种情况比较少见,没有截图,各位可以脑补。同样,这种格式的头部键值对,也不允许被添加到动态字典中,只能使用哈夫曼编码来减少体积。

实际上,协议中还规定了与 4、5 非常类似的另外两种格式:将 4、5
格式中的第一个字节第四位由 1 改为 0
即可。它表示「本次不更新动态词典」,而 4、5
表示「绝对不允许更新动态词典」。区别不是很大,这里略过。

明白了头部压缩的技术细节,理论上可以很轻松写出 HTTP/2
头部解码工具了。我比较懒,直接找来 node-http2 中的
compressor.js
验证一下:

JavaScript

var Decompressor = require(‘./compressor’).Decompressor; var testLog =
require(‘bunyan’).createLogger({name: ‘test’}); var decompressor = new
Decompressor(testLog, ‘REQUEST’); var buffer = new
Buffer(‘820481634188353daded6ae43d3f877abdd07f66a281b0dae053fad0321aa49d13fda992a49685340c8a6adca7e28102e10fda9677b8d05707f6a62293a9d810020004015309ac2ca7f2c3415c1f53b0497ca589d34d1f43aeba0c41a4c7a98f33a69a3fdf9a68fa1d75d0620d263d4c79a68fbed00177febe58f9fbed00177b518b2d4b70ddf45abefb4005db901f1184ef034eff609cb60725034f48e1561c8469669f081678ae3eb3afba465f7cb234db9f4085aec1cd48ff86a8eb10649cbf’,
‘hex’); console.log(decompressor.decompress(buffer));
decompressor._table.forEach(function(row, index) { console.log(index +
1, row[0], row[1]); });

1
2
3
4
5
6
7
8
9
10
11
12
13
var Decompressor = require(‘./compressor’).Decompressor;
 
var testLog = require(‘bunyan’).createLogger({name: ‘test’});
var decompressor = new Decompressor(testLog, ‘REQUEST’);
 
var buffer = new Buffer(‘820481634188353daded6ae43d3f877abdd07f66a281b0dae053fad0321aa49d13fda992a49685340c8a6adca7e28102e10fda9677b8d05707f6a62293a9d810020004015309ac2ca7f2c3415c1f53b0497ca589d34d1f43aeba0c41a4c7a98f33a69a3fdf9a68fa1d75d0620d263d4c79a68fbed00177febe58f9fbed00177b518b2d4b70ddf45abefb4005db901f1184ef034eff609cb60725034f48e1561c8469669f081678ae3eb3afba465f7cb234db9f4085aec1cd48ff86a8eb10649cbf’, ‘hex’);
 
console.log(decompressor.decompress(buffer));
 
decompressor._table.forEach(function(row, index) {
    console.log(index + 1, row[0], row[1]);
});
 

头部原始数据来自于本文第三张截图,运行结果如下(静态字典只截取了一部分):

JavaScript

{ ‘:method’: ‘GET’, ‘:path’: ‘/’, ‘:authority’: ‘imququ.com’, ‘:scheme’:
‘https’, ‘user-agent’: ‘Mozilla/5.0 (Macintosh; Intel Mac OS X 10.11;
rv:41.0) Gecko/20100101 Firefox/41.0’, accept:
‘text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8’,
‘accept-language’: ‘en-US,en;q=0.5’, ‘accept-encoding’: ‘gzip, deflate’,
cookie: ‘v=47; u=6f048d6e-adc4-4910-8e69-797c399ed456’, pragma:
‘no-cache’ } 1 ‘:authority’ ” 2 ‘:method’ ‘GET’ 3 ‘:method’ ‘POST’ 4
‘:path’ ‘/’ 5 ‘:path’ ‘/index.html’ 6 ‘:scheme’ ‘http’ 7 ‘:scheme’
‘https’ 8 ‘:status’ ‘200’ … … 32 ‘cookie’ ” … … 60 ‘via’ ” 61
‘www-authenticate’ ” 62 ‘pragma’ ‘no-cache’ 63 ‘cookie’
‘u=6f048d6e-adc4-4910-8e69-797c399ed456’ 64 ‘accept-language’
‘en-US,en;q=0.5’ 65 ‘accept’
‘text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8’ 66
‘user-agent’ ‘Mozilla/5.0 (Macintosh; Intel Mac OS X 10.11; rv:41.0)
Gecko/20100101 Firefox/41.0’ 67 ‘:authority’ ‘imququ.com’

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
{ ‘:method’: ‘GET’,
  ‘:path’: ‘/’,
  ‘:authority’: ‘imququ.com’,
  ‘:scheme’: ‘https’,
  ‘user-agent’: ‘Mozilla/5.0 (Macintosh; Intel Mac OS X 10.11; rv:41.0) Gecko/20100101 Firefox/41.0’,
  accept: ‘text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8’,
  ‘accept-language’: ‘en-US,en;q=0.5’,
  ‘accept-encoding’: ‘gzip, deflate’,
  cookie: ‘v=47; u=6f048d6e-adc4-4910-8e69-797c399ed456’,
  pragma: ‘no-cache’ }
1 ‘:authority’ ”
2 ‘:method’ ‘GET’
3 ‘:method’ ‘POST’
4 ‘:path’ ‘/’
5 ‘:path’ ‘/index.html’
6 ‘:scheme’ ‘http’
7 ‘:scheme’ ‘https’
8 ‘:status’ ‘200’
… …
32 ‘cookie’ ”
… …
60 ‘via’ ”
61 ‘www-authenticate’ ”
62 ‘pragma’ ‘no-cache’
63 ‘cookie’ ‘u=6f048d6e-adc4-4910-8e69-797c399ed456’
64 ‘accept-language’ ‘en-US,en;q=0.5’
65 ‘accept’ ‘text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8’
66 ‘user-agent’ ‘Mozilla/5.0 (Macintosh; Intel Mac OS X 10.11; rv:41.0) Gecko/20100101 Firefox/41.0’
67 ‘:authority’ ‘imququ.com’
 

可以看到,这段从 Wireshark
拷出来的头部数据可以正常解码,动态字典也得到了更新(62 – 67)。

综合场景

以上,我们述及四种典型的对前端数据层有诉求的场景,如果存在更复杂的情况,兼有这些情况,又当如何?

Teambition的场景正是这么一种情况,它的产品特点如下:

  • 大部分交互都以对话框的形式展现,在视图的不同位置,存在大量的共享数据,以任务信息为例,一条任务数据对应渲染的视图可能会有20个这样的数量级。
  • 全业务都存在WebSocket推送,把相关用户(比如处于同一项目中)的一切变更都发送到前端,并实时展示
  • 很强调无刷新,提供一种类似桌面软件的交互体验

比如说:

当一条任务变更的时候,无论你处于视图的什么状态,需要把这20种可能的地方去做同步。

当任务的标签变更的时候,需要把标签信息也查找出来,进行实时变更。

甚至:

  • 如果某个用户更改了自己的头像,而他的头像被到处使用了?
  • 如果当前用户被移除了与所操作对象的关联关系,导致权限变更,按钮禁用状态改变了?
  • 如果别人修改了当前用户的身份,在管理员和普通成员之间作了变化,视图怎么自动变化?

当然这些问题都是可以从产品角度权衡的,但是本文主要考虑的还是如果产品角度不放弃对某些极致体验的追求,从技术角度如何更容易地去做。

我们来分析一下整个业务场景:

  • 存在全业务的细粒度变更推送 => 需要在前端聚合数据
  • 前端聚合 => 数据的组合链路长
  • 视图大量共享数据 => 数据变更的分发路径多

这就是我们得到的一个大致认识。

总结

在进行 HTTP/2
网站性能优化时很重要一点是「使用尽可能少的连接数」,本文提到的头部压缩是其中一个很重要的原因:同一个连接上产生的请求和响应越多,动态字典积累得越全,头部压缩效果也就越好。所以,针对
HTTP/2 网站,最佳实践是不要合并资源,不要散列域名。

默认情况下,浏览器会针对这些情况使用同一个连接:

  • 同一域名下的资源;
  • 不同域名下的资源,但是满足两个条件:1)解析到同一个
    IP;2)使用同一个证书;

上面第一点容易理解,第二点则很容易被忽略。实际上 Google
已经这么做了,Google 一系列网站都共用了同一个证书,可以这样验证:

JavaScript

$ openssl s_client -connect google.com:443 |openssl x509 -noout -text |
grep DNS depth=2 C = US, O = GeoTrust Inc., CN = GeoTrust Global CA
verify error:num=20:unable to get local issuer certificate verify
return:0 DNS:*.google.com, DNS:*.android.com,
DNS:*.appengine.google.com, DNS:*.cloud.google.com,
DNS:*.google-analytics.com, DNS:*.google.ca, DNS:*.google.cl,
DNS:*.google.co.in, DNS:*.google.co.jp, DNS:*.google.co.uk,
DNS:*.google.com.ar, DNS:*.google.com.au, DNS:*.google.com.br,
DNS:*.google.com.co, DNS:*.google.com.mx, DNS:*.google.com.tr,
DNS:*.google.com.vn, DNS:*.google.de, DNS:*.google.es,
DNS:*.google.fr, DNS:*.google.hu, DNS:*.google.it, DNS:*.google.nl,
DNS:*.google.pl, DNS:*.google.pt, DNS:*.googleadapis.com,
DNS:*.googleapis.cn, DNS:*.googlecommerce.com, DNS:*.googlevideo.com,
DNS:*.gstatic.cn, DNS:*.gstatic.com, DNS:*.gvt1.com, DNS:*.gvt2.com,
DNS:*.metric.gstatic.com, DNS:*.urchin.com, DNS:*.url.google.com,
DNS:*.youtube-nocookie.com, DNS:*.youtube.com,
DNS:*.youtubeeducation.com, DNS:*.ytimg.com, DNS:android.com,
DNS:g.co, DNS:goo.gl, DNS:google-analytics.com, DNS:google.com,
DNS:googlecommerce.com, DNS:urchin.com, DNS:youtu.be, DNS:youtube.com,
DNS:youtubeeducation.com

1
2
3
4
5
6
7
$ openssl s_client -connect google.com:443 |openssl x509 -noout -text | grep DNS
 
depth=2 C = US, O = GeoTrust Inc., CN = GeoTrust Global CA
verify error:num=20:unable to get local issuer certificate
verify return:0
                DNS:*.google.com, DNS:*.android.com, DNS:*.appengine.google.com, DNS:*.cloud.google.com, DNS:*.google-analytics.com, DNS:*.google.ca, DNS:*.google.cl, DNS:*.google.co.in, DNS:*.google.co.jp, DNS:*.google.co.uk, DNS:*.google.com.ar, DNS:*.google.com.au, DNS:*.google.com.br, DNS:*.google.com.co, DNS:*.google.com.mx, DNS:*.google.com.tr, DNS:*.google.com.vn, DNS:*.google.de, DNS:*.google.es, DNS:*.google.fr, DNS:*.google.hu, DNS:*.google.it, DNS:*.google.nl, DNS:*.google.pl, DNS:*.google.pt, DNS:*.googleadapis.com, DNS:*.googleapis.cn, DNS:*.googlecommerce.com, DNS:*.googlevideo.com, DNS:*.gstatic.cn, DNS:*.gstatic.com, DNS:*.gvt1.com, DNS:*.gvt2.com, DNS:*.metric.gstatic.com, DNS:*.urchin.com, DNS:*.url.google.com, DNS:*.youtube-nocookie.com, DNS:*.youtube.com, DNS:*.youtubeeducation.com, DNS:*.ytimg.com, DNS:android.com, DNS:g.co, DNS:goo.gl, DNS:google-analytics.com, DNS:google.com, DNS:googlecommerce.com, DNS:urchin.com, DNS:youtu.be, DNS:youtube.com, DNS:youtubeeducation.com
 

使用多域名加上相同的 IP 和证书部署 Web 服务有特殊的意义:让支持 HTTP/2
的终端只建立一个连接,用上 HTTP/2 协议带来的各种好处;而只支持 HTTP/1.1
的终端则会建立多个连接,达到同时更多并发请求的目的。这在 HTTP/2
完全普及前也是一个不错的选择。

本文就写到这里,希望能给对 HTTP/2
感兴趣的同学带来帮助,也欢迎大家继续关注本博客的「HTTP/2
专题」。

打赏支持我写出更多好文章,谢谢!

打赏作者

技术诉求

以上,我们介绍了业务场景,分析了技术特点。假设我们要为这么一种复杂场景设计数据层,它要提供怎样的接口,才能让视图使用起来简便呢?

从视图角度出发,我们有这样的诉求:

  • 类似订阅的使用方式(只被上层依赖,无反向链路)。这个来源于多视图对同一业务数据的共享,如果不是类似订阅的方式,职责就反转了,对维护不利
  • 查询和推送的统一。这个来源于WebSocket的使用。
  • 同步与异步的统一。这个来源于缓存的使用。
  • 灵活的可组合性。这个来源于细粒度数据的前端聚合。

根据这些,我们可用的技术选型是什么呢?

打赏支持我写出更多好文章,谢谢!

任选一种支付方式

图片 18
图片 19

1 赞 3 收藏
评论

发表评论

电子邮件地址不会被公开。 必填项已用*标注

标签:, , ,
网站地图xml地图