开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

设计模式 外观模式及典型应用

发表于 2018-09-16

前言

本文的主要内容:

  • 介绍外观模式
  • 示例
    • 自己泡茶
    • 到茶馆喝茶
  • 外观模式总结
  • 外观模式的典型应用
    • spring JDBC 中的外观模式
    • Mybatis中的外观模式
    • Tomcat 中的外观模式
    • SLF4J 中的外观模式

外观模式

外观模式是一种使用频率非常高的结构型设计模式,它通过引入一个外观角色来简化客户端与子系统之间的交互,为复杂的子系统调用提供一个统一的入口,降低子系统与客户端的耦合度,且客户端调用非常方便。

外观模式又称为门面模式,它是一种对象结构型模式。外观模式是迪米特法则的一种具体实现,通过引入一个新的外观角色可以降低原有系统的复杂度,同时降低客户类与子系统的耦合度。

外观模式包含如下两个角色:

Facade(外观角色):在客户端可以调用它的方法,在外观角色中可以知道相关的(一个或者多个)子系统的功能和责任;在正常情况下,它将所有从客户端发来的请求委派到相应的子系统去,传递给相应的子系统对象处理。

SubSystem(子系统角色):在软件系统中可以有一个或者多个子系统角色,每一个子系统可以不是一个单独的类,而是一个类的集合,它实现子系统的功能;每一个子系统都可以被客户端直接调用,或者被外观角色调用,它处理由外观类传过来的请求;子系统并不知道外观的存在,对于子系统而言,外观角色仅仅是另外一个客户端而已。

外观模式的目的不是给予子系统添加新的功能接口,而是为了让外部减少与子系统内多个模块的交互,松散耦合,从而让外部能够更简单地使用子系统。

外观模式的本质是:封装交互,简化调用。

示例

泡茶需要水 Water

1
2
3
4
5
6
7
8
9
复制代码public class Water {
private int temperature; // 温度
private int capacity; // 容量
public Water() {
this.temperature = 0;
this.capacity = 10;
}
// 省略...
}

泡茶需要茶叶 TeaLeaf

1
2
3
4
复制代码public class TeaLeaf {
private String teaName;
// 省略...
}

烧水需要用水壶烧,将水加热

1
2
3
4
5
6
7
8
复制代码public class KettleService {
public void waterBurning(String who, Water water, int burnTime) {
// 烧水,计算最终温度
int finalTermperature = Math.min(100, water.getTemperature() + burnTime * 20);
water.setTemperature(finalTermperature);
System.out.println(who + " 使用水壶烧水,最终水温为 " + finalTermperature);
}
}

泡茶,将烧好的水与茶叶进行冲泡,最终得到一杯茶水

1
2
3
4
5
6
7
复制代码public class TeasetService {
public Teawater makeTeaWater(String who, Water water, TeaLeaf teaLeaf) {
String teawater = "一杯容量为 " + water.getCapacity() + ", 温度为 " + water.getTemperature() + " 的" + teaLeaf.getTeaName() + "茶水";
System.out.println(who + " 泡了" + teawater);
return new Teawater(teawater);
}
}

人喝茶水

1
2
3
4
5
6
7
8
9
复制代码public class Man {
private String name;
public Man(String name) {
this.name = name;
}
public void drink(Teawater teawater) {
System.out.println(name + " 喝了" + teawater.getTeaWater());
}
}

自己泡茶喝

张三、李四各自泡茶喝,各自都需要准备茶具、茶叶、水,各自还要完成烧水、泡茶等操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
复制代码public class Main {
public static void main(String[] args) {
Man zhangsan = new Man("张三");
KettleService kettleService1 = new KettleService();
TeasetService teasetService1 = new TeasetService();
Water water1 = new Water();
TeaLeaf teaLeaf1 = new TeaLeaf("西湖龙井");
kettleService1.waterBurning(zhangsan.getName(), water1, 4);
Teawater teawater1 = teasetService1.makeTeaWater(zhangsan.getName(), water1, teaLeaf1);
zhangsan.drink(teawater1);
System.out.println();

Man lisi = new Man("李四");
KettleService kettleService2 = new KettleService();
TeasetService teasetService2 = new TeasetService();
Water water2 = new Water(10, 15);
TeaLeaf teaLeaf2 = new TeaLeaf("碧螺春");
kettleService2.waterBurning(lisi.getName(), water2, 4);
Teawater teawater2 = teasetService2.makeTeaWater(lisi.getName(), water2, teaLeaf2);
lisi.drink(teawater2);
}
}

输出为

1
2
3
4
5
6
7
复制代码张三 使用水壶烧水,最终水温为 80
张三 泡了一杯容量为 10, 温度为 80 的西湖龙井茶水
张三 喝了一杯容量为 10, 温度为 80 的西湖龙井茶水

李四 使用水壶烧水,最终水温为 90
李四 泡了一杯容量为 15, 温度为 90 的碧螺春茶水
李四 喝了一杯容量为 15, 温度为 90 的碧螺春茶水

自己泡茶喝模式图

自己泡茶喝模式图

到茶馆喝茶

茶馆,茶馆有不同的套餐

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
复制代码public class TeaHouseFacade {
private String name;
private TeasetService teasetService;
private KettleService kettleService;

public TeaHouseFacade(String name) {
this.name = name;
this.teasetService = new TeasetService();
this.kettleService = new KettleService();
}

public Teawater makeTea(int teaNumber) {
switch (teaNumber) {
case 1:
Water water1 = new Water();
TeaLeaf teaLeaf1 = new TeaLeaf("西湖龙井");
kettleService.waterBurning(this.name, water1, 4);
Teawater teawater1 = teasetService.makeTeaWater(this.name, water1, teaLeaf1);
return teawater1;
case 2:
Water water2 = new Water(10, 15);
TeaLeaf teaLeaf2 = new TeaLeaf("碧螺春");
kettleService.waterBurning(this.name, water2, 4);
Teawater teawater2 = teasetService.makeTeaWater(this.name, water2, teaLeaf2);
return teawater2;
default:
Water water3 = new Water();
TeaLeaf teaLeaf3 = new TeaLeaf("招牌乌龙");
kettleService.waterBurning(this.name, water3, 5);
Teawater teawater3 = teasetService.makeTeaWater(this.name, water3, teaLeaf3);
return teawater3;
}
}
}

张三和李四点茶,只需要告诉茶馆套餐编号即可,水、茶叶由茶馆准备,烧水泡茶的操作由茶馆统一完成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
复制代码public class Test {
public static void main(String[] args) {
TeaHouseFacade teaHouseFacade = new TeaHouseFacade("老舍茶馆");

Man zhangsan = new Man("张三");
Teawater teawater = teaHouseFacade.makeTea(1);
zhangsan.drink(teawater);
System.out.println();

Man lisi = new Man("李四");
Teawater teawater1 = teaHouseFacade.makeTea(2);
lisi.drink(teawater1);
}
}

输出为

1
2
3
4
5
6
7
复制代码老舍茶馆 使用水壶烧水,最终水温为 80
老舍茶馆 泡了一杯容量为 10, 温度为 80 的西湖龙井茶水
张三 喝了一杯容量为 10, 温度为 80 的西湖龙井茶水

老舍茶馆 使用水壶烧水,最终水温为 90
老舍茶馆 泡了一杯容量为 15, 温度为 90 的碧螺春茶水
李四 喝了一杯容量为 15, 温度为 90 的碧螺春茶水

到茶馆喝茶模式图

到茶馆喝茶模式图

外观模式总结

外观模式的主要优点如下:

  • 它对客户端屏蔽了子系统组件,减少了客户端所需处理的对象数目,并使得子系统使用起来更加容易。通过引入外观模式,客户端代码将变得很简单,与之关联的对象也很少。
  • 它实现了子系统与客户端之间的松耦合关系,这使得子系统的变化不会影响到调用它的客户端,只需要调整外观类即可。
  • 一个子系统的修改对其他子系统没有任何影响,而且子系统内部变化也不会影响到外观对象。

外观模式的主要缺点如下:

  • 不能很好地限制客户端直接使用子系统类,如果对客户端访问子系统类做太多的限制则减少了可变性和灵活性。
  • 如果设计不当,增加新的子系统可能需要修改外观类的源代码,违背了开闭原则。

适用场景:

  • 当要为访问一系列复杂的子系统提供一个简单入口时可以使用外观模式。
  • 客户端程序与多个子系统之间存在很大的依赖性。引入外观类可以将子系统与客户端解耦,从而提高子系统的独立性和可移植性。
  • 在层次化结构中,可以使用外观模式定义系统中每一层的入口,层与层之间不直接产生联系,而通过外观类建立联系,降低层之间的耦合度。

源码分析外观模式的典型应用

spring jdbc中的外观模式

查看 org.springframework.jdbc.support.JdbcUtils

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
复制代码public abstract class JdbcUtils {
public static void closeConnection(Connection con) {
if (con != null) {
try {
con.close();
}
catch (SQLException ex) {
logger.debug("Could not close JDBC Connection", ex);
}
catch (Throwable ex) {
// We don't trust the JDBC driver: It might throw RuntimeException or Error.
logger.debug("Unexpected exception on closing JDBC Connection", ex);
}
}
}

public static Object getResultSetValue(ResultSet rs, int index, Class<?> requiredType) throws SQLException {
if (requiredType == null) {
return getResultSetValue(rs, index);
}

Object value = null;
boolean wasNullCheck = false;

// Explicitly extract typed value, as far as possible.
if (String.class.equals(requiredType)) {
value = rs.getString(index);
}
else if (boolean.class.equals(requiredType) || Boolean.class.equals(requiredType)) {
value = rs.getBoolean(index);
wasNullCheck = true;
}
else if (byte.class.equals(requiredType) || Byte.class.equals(requiredType)) {
value = rs.getByte(index);
wasNullCheck = true;
}
else if (short.class.equals(requiredType) || Short.class.equals(requiredType)) {
value = rs.getShort(index);
wasNullCheck = true;
}
else if (int.class.equals(requiredType) || Integer.class.equals(requiredType)) {
value = rs.getInt(index);
wasNullCheck = true;
}
else if (long.class.equals(requiredType) || Long.class.equals(requiredType)) {
value = rs.getLong(index);
wasNullCheck = true;
}
else if (float.class.equals(requiredType) || Float.class.equals(requiredType)) {
value = rs.getFloat(index);
wasNullCheck = true;
}
else if (double.class.equals(requiredType) || Double.class.equals(requiredType) ||
Number.class.equals(requiredType)) {
value = rs.getDouble(index);
wasNullCheck = true;
}
else if (byte[].class.equals(requiredType)) {
value = rs.getBytes(index);
}
else if (java.sql.Date.class.equals(requiredType)) {
value = rs.getDate(index);
}
else if (java.sql.Time.class.equals(requiredType)) {
value = rs.getTime(index);
}
else if (java.sql.Timestamp.class.equals(requiredType) || java.util.Date.class.equals(requiredType)) {
value = rs.getTimestamp(index);
}
else if (BigDecimal.class.equals(requiredType)) {
value = rs.getBigDecimal(index);
}
else if (Blob.class.equals(requiredType)) {
value = rs.getBlob(index);
}
else if (Clob.class.equals(requiredType)) {
value = rs.getClob(index);
}
else {
// Some unknown type desired -> rely on getObject.
value = getResultSetValue(rs, index);
}

if (wasNullCheck && value != null && rs.wasNull()) {
value = null;
}
return value;
}
// ...省略...
}

该工具类主要是对原生的 jdbc 进行了封装

Mybatis中的外观模式

查看 org.apache.ibatis.session.Configuration 类中以 new 开头的方法

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
复制代码public class Configuration {
public Executor newExecutor(Transaction transaction, ExecutorType executorType) {
executorType = executorType == null ? defaultExecutorType : executorType;
executorType = executorType == null ? ExecutorType.SIMPLE : executorType;
Executor executor;
if (ExecutorType.BATCH == executorType) {
executor = new BatchExecutor(this, transaction);
} else if (ExecutorType.REUSE == executorType) {
executor = new ReuseExecutor(this, transaction);
} else {
executor = new SimpleExecutor(this, transaction);
}
if (cacheEnabled) {
executor = new CachingExecutor(executor);
}
executor = (Executor) interceptorChain.pluginAll(executor);
return executor;
}

public ResultSetHandler newResultSetHandler(Executor executor, MappedStatement mappedStatement, RowBounds rowBounds, ParameterHandler parameterHandler,
ResultHandler resultHandler, BoundSql boundSql) {
ResultSetHandler resultSetHandler = new DefaultResultSetHandler(executor, mappedStatement, parameterHandler, resultHandler, boundSql, rowBounds);
resultSetHandler = (ResultSetHandler) interceptorChain.pluginAll(resultSetHandler);
return resultSetHandler;
}
// ...省略...
}

该类主要对一些创建对象的操作进行封装

Tomcat 中的外观模式

Tomcat 源码中大量使用了很多外观模式

Tomcat中的外观模式

org.apache.catalina.connector.Request 和 org.apache.catalina.connector.RequestFacade 这两个类都实现了 HttpServletRequest 接口

在 Request 中调用 getRequest() 实际获取的是 RequestFacade 的对象

1
2
3
4
5
6
7
8
复制代码protected RequestFacade facade = null;

public HttpServletRequest getRequest() {
if (facade == null) {
facade = new RequestFacade(this);
}
return facade;
}

在 RequestFacade 中再对认为是子系统的操作进行封装

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码public class RequestFacade implements HttpServletRequest {
/**
* The wrapped request.
*/
protected Request request = null;

@Override
public Object getAttribute(String name) {
if (request == null) {
throw new IllegalStateException(sm.getString("requestFacade.nullRequest"));
}
return request.getAttribute(name);
}
// ...省略...
}

SLF4J 中的外观模式

SLF4J 是简单的日志外观模式框架,抽象了各种日志框架例如 Logback、Log4j、Commons-logging 和 JDK 自带的 logging 实现接口。它使得用户可以在部署时使用自己想要的日志框架。

SLF4J 没有替代任何日志框架,它仅仅是标准日志框架的外观模式。如果在类路径下除了 SLF4J 再没有任何日志框架,那么默认状态是在控制台输出日志。

日志处理框架 Logback 是 Log4j 的改进版本,原生支持SLF4J(因为是同一作者开发的),因此 Logback+SLF4J 的组合是日志框架的最佳选择,比 SLF4J+其它日志框架 的组合要快一些。而且Logback的配置可以是XML或Groovy代码。

SLF4J 的 helloworld 如下:

1
2
3
4
5
6
7
8
9
复制代码import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class HelloWorld {
public static void main(String[] args) {
Logger logger = LoggerFactory.getLogger(HelloWorld.class);
logger.info("Hello World");
}
}

下图为 SLF4J 与日志处理框架的绑定调用关系

SLF4J与日志处理框架的绑定调用关系

应用层调用 slf4j-api.jar,slf4j-api.jar 再根据所绑定的日志处理框架调用不同的 jar 包进行处理

参考:

刘伟:设计模式Java版

慕课网java设计模式精讲 Debug 方式+内存分析

Java日志框架:slf4j作用及其实现原理

推荐阅读

设计模式 | 简单工厂模式及典型应用

设计模式 | 工厂方法模式及典型应用

设计模式 | 抽象工厂模式及典型应用

设计模式 | 建造者模式及典型应用

设计模式 | 原型模式及典型应用

点击[阅读原文]可访问我的个人博客:laijianfeng.org

关注【小旋锋】微信公众号

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

安利一个惊艳的红楼梦可视化作品

发表于 2018-09-14

更新:《左手读红楼梦,右手写BUG,闲快活》一文对该数据集进行了分析挖掘,加了许多红楼梦的内容,以及几个书里的黄段子,逃。代码开源在:DesertsX/gulius-projects

直接上图,安利下这个关于红楼梦的可视化作品网址在此:InteractiveGraph/example1。要是有最近在读《红楼梦》的朋友,可以对照着来看,想来是很棒的体验。

在此关系图谱中,粉红色节点代表红楼梦中出现的人物,主要角色用了1987版红楼梦部分演员的剧照,点击每个节点能看到人物的介绍;黄色节点为书中出现过的主要地点;蓝色节点为书中主要的情节、事件,同样点击后能看到情节概述,不过不是原文内容。古柳虽然很久没看红楼梦原书和87版电视剧了,但这些还是门儿清的。


网页右上角提供了一些可选的按钮,其中第四个是展示节点间关系用的,对小说不了解、或初读的读者可能会有帮助,比我们那年月自己看书或一些读者搜网上单纯的罗列人物图谱要直观的多。比如宝玉的母亲、干娘、妻子、同宗、哥哥、仆人等等,越看越觉得这背后的数据集真的是厉害…..
更多细节大家可自行探索,古柳当初看到时就觉得很惊艳,作为一个“伪”红迷,看到这么棒的项目,幻想着要是能哪天自己复现出来,也是“死而无憾”了。幸运的是,这个项目所有代码也开源在了GitHub - InteractiveGraph。

在README_CN.md文件里介绍了具体实现细节,还是很详细的,哪怕里面很多技术没接触过,也能有个方向。不过,技术有了,用到的数据格式又是怎么样的呢?假如想迁移到其他小说、其他文本内容上又该怎么准备数据呢?

带着这个疑惑找到了dist/examples/honglou.json文件,简单的摘录开头部分数据。categories定义了上面关系图谱里节点类型;translator代码跳过(==);data 处开始到最后5000多行就是各类所有节点的数据了,显示event事件的数据格式样例。

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
40
41
42
43
44
45
复制代码{
"categories": {
"person": "人物",
"event": "事件",
"location": "地点"
},
"translator": {
"nodes": function (node) {
//set description
if (node.description === undefined) {
var description = "<p align=center>";
if (node.image !== undefined) {
description += "<img src='" + node.image + "' width=150/><br>";
}
description += "<b>" + node.label + "</b>" + "[" + node.id + "]";
description += "</p>";
if (node.info !== undefined) {
description += "<p align=left>" + node.info + "</p>";
} else {
if (node.title !== undefined)
description += "<p align=left>" + node.title + "</p>";
}
node.description = description;
}
},
},
"data": {
"nodes": [{
"label": "共读西厢",
"value": 2,
"id": 3779,
"categories": [
"event"
],
"info": "宝玉到沁芳桥边桃花底下看《西厢记》,正准备将落花送进池中,黛玉说她早已准备了一个花冢,正来葬花。黛玉发现《西厢记》,宝玉借书中词句,向黛玉表白。黛玉觉得冒犯了自己尊严,引起口角,宝玉赔礼讨饶,黛玉也借《西厢记》词句,嘲笑了宝玉。于是两人收拾落花,葬到花冢里去。"
},
{
"label": "林如海捐馆扬州城",
"value": 4,
"id": 3780,
"categories": [
"event"
],
"info": "林如海考中探花后,迁为兰台寺大夫,钦点为扬州巡盐御史。后身染重病于九月初三日巳时而亡。"
},

《小戏骨红楼梦》之宝黛共读西厢:

地点节点数据样式:

1
2
3
4
5
6
7
8
9
复制代码{
"label": "潇湘馆",
"value": 3,
"id": 3838,
"categories": [
"location"
],
"info": "黛玉的居所。黛玉作诗的笔名就潇湘妃子,这是曹雪芹对黛玉这个人物的赞美。"
},

《小戏骨红楼梦》之宝钗

人物节点数据:

1
2
3
4
5
6
7
8
9
10
复制代码{
"label": "王熙凤",
"value": 25,
"image": "./images/photo/王熙凤.jpg",
"id": 4041,
"categories": [
"person"
],
"info": "金陵十二钗之九,来自四大家族之王家,王夫人的内侄女,贾琏之妻。她精明强干,深得贾母和王夫人的信任,成为荣国府的管家奶奶,她为人处事圆滑周到,图财害命的事也干过不少,在前80回里她支持宝黛爱情。"
},

《小戏骨红楼梦》之宝黛美如画:


最后是所有节点所代表的实体之间的关系:

1
2
3
4
5
6
复制代码{
"id": 3324,
"label": "仆人",
"from": 3876,
"to": 4103
},

最近也接触了些依存句法分析、信息提取等NLP的内容,但理论归理论,真要用来提取小说里进行命名实体识别、实体关系提取、事件抽取等等还是差得远,以后日后能复现这一项目。

最后再放张87版红楼梦的剧照,虽然真的觉得对小戏骨的红楼梦的喜好要超过前者了。逃……

欢迎关注公众号“牛衣古柳”(ID:Deserts-X)哈!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

【java面试】Spring的IOC是啥?有什么好处? 设计

发表于 2018-09-14

本文转载自知乎问题回答:Spring IoC有什么好处?
作者: Sevenvidia

设计模式7大原则

为什么会有人说设计模式已死呢,因为spring这些框架帮你做好了类和对象的管理,让你写代码的时候只专注于你实现的功能,而不是设计。先来看看设计模式的7大原则:

  • 开放-封闭原则
  • 单一职责原则
  • 依赖倒转原则
  • 最小知识原则
  • 接口隔离原则
  • 合成/聚合复用原则
  • 里氏代换原则,任何基类可以出现的地方,子类一定可以出现

依赖倒置

假设我们设计一辆汽车:先设计轮子,然后根据轮子大小设计底盘,接着根据底盘设计车身,最后根据车身设计好整个汽车。这里就出现了一个“依赖”关系:汽车依赖车身,车身依赖底盘,底盘依赖轮子。

这样的设计看起来没问题,但是可维护性却很低。假设设计完工之后,上司却突然说根据市场需求的变动,要我们把车子的轮子设计都改大一码。这下我们就蛋疼了:因为我们是根据轮子的尺寸设计的底盘,轮子的尺寸一改,底盘的设计就得修改;同样因为我们是根据底盘设计的车身,那么车身也得改,同理汽车设计也得改——整个设计几乎都得改!我们现在换一种思路。我们先设计汽车的大概样子,然后根据汽车的样子来设计车身,根据车身来设计底盘,最后根据底盘来设计轮子。这时候,依赖关系就倒置过来了:轮子依赖底盘, 底盘依赖车身, 车身依赖汽车。

这时候,上司再说要改动轮子的设计,我们就只需要改动轮子的设计,而不需要动底盘,车身,汽车的设计了。这就是依赖倒置原则——把原本的高层建筑依赖底层建筑“倒置”过来,变成底层建筑依赖高层建筑。高层建筑决定需要什么,底层去实现这样的需求,但是高层并不用管底层是怎么实现的。这样就不会出现前面的“牵一发动全身”的情况。

控制反转(Inversion of Control)

就是依赖倒置原则的一种代码设计的思路。具体采用的方法就是所谓的依赖注入(Dependency Injection)。其实这些概念初次接触都会感到云里雾里的。说穿了,这几种概念的关系大概如下:

为了理解这几个概念,我们还是用上面汽车的例子。只不过这次换成代码。我们先定义四个Class,车,车身,底盘,轮胎。然后初始化这辆车,最后跑这辆车。代码结构如下:

这样,就相当于上面第一个例子,上层建筑依赖下层建筑——每一个类的构造函数都直接调用了底层代码的构造函数。假设我们需要改动一下轮胎(Tire)类,把它的尺寸变成动态的,而不是一直都是30。我们需要这样改:

由于我们修改了轮胎的定义,为了让整个程序正常运行,我们需要做以下改动:

由此我们可以看到,仅仅是为了修改轮胎的构造函数,这种设计却需要修改整个上层所有类的构造函数!在软件工程中,这样的设计几乎是不可维护的——在实际工程项目中,有的类可能会是几千个类的底层,如果每次修改这个类,我们都要修改所有以它作为依赖的类,那软件的维护成本就太高了。所以我们需要进行控制反转(IoC),及上层控制下层,而不是下层控制着上层。我们用依赖注入(Dependency Injection)这种方式来实现控制反转。所谓依赖注入,就是把底层类作为参数传入上层类,实现上层类对下层类的“控制”。这里我们用构造方法传递的依赖注入方式重新写车类的定义:

这里我们再把轮胎尺寸变成动态的,同样为了让整个系统顺利运行,我们需要做如下修改:

看到没?这里我只需要修改轮胎类就行了,不用修改其他任何上层类。这显然是更容易维护的代码。不仅如此,在实际的工程中,这种设计模式还有利于不同组的协同合作和单元测试:比如开发这四个类的分别是四个不同的组,那么只要定义好了接口,四个不同的组可以同时进行开发而不相互受限制;而对于单元测试,如果我们要写Car类的单元测试,就只需要Mock一下Framework类传入Car就行了,而不用把Framework, Bottom, Tire全部new一遍再来构造Car。这里我们是采用的构造函数传入的方式进行的依赖注入。其实还有另外两种方法:Setter传递和接口传递。这里就不多讲了,核心思路都是一样的,都是为了实现控制反转。

控制反转容器(IoC Container)

其实上面的例子中,对车类进行初始化的那段代码发生的地方,就是控制反转容器。

显然你也应该观察到了,因为采用了依赖注入,在初始化的过程中就不可避免的会写大量的new。这里IoC容器就解决了这个问题。这个容器可以自动对你的代码进行初始化,你只需要维护一个Configuration(可以是xml可以是一段代码),而不用每次初始化一辆车都要亲手去写那一大段初始化的代码。这是引入IoC Container的第一个好处。IoC Container的第二个好处是:我们在创建实例的时候不需要了解其中的细节。在上面的例子中,我们自己手动创建一个车instance时候,是从底层往上层new的:

这个过程中,我们需要了解整个Car/Framework/Bottom/Tire类构造函数是怎么定义的,才能一步一步new/注入。而IoC Container在进行这个工作的时候是反过来的,它先从最上层开始往下找依赖关系,到达最底层之后再往上一步一步new(有点像深度优先遍历):

IoC Container可以直接隐藏具体的创建实例的细节.

这是我看到的说控制反转最清楚的文章,大家理解的时候不要在乎这些框架,而是这个设计本身,所以从设计模式的原则讲起,下面继续讲讲spring的一些实战,以及简单的造两个小轮子。

相关阅读

  • 【Leetcode】67. 二进制求和
  • 【工程】在线诊断系统设计与实现
  • 技术文章汇总
  • 【HTTP】分布式session的管理
    扫码关注.jpg

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

优雅实现延时任务之Redis篇

发表于 2018-09-13

什么是延时任务

延时任务,顾名思义,就是延迟一段时间后才执行的任务。举个例子,假设我们有个发布资讯的功能,运营需要在每天早上7点准时发布资讯,但是早上7点大家都还没上班,这个时候就可以使用延时任务来实现资讯的延时发布了。只要在前一天下班前指定第二天要发送资讯的时间,到了第二天指定的时间点资讯就能准时发出去了。如果大家有运营过公众号,就会知道公众号后台也有文章定时发送的功能。总而言之,延时任务的使用还是很广泛的。关于延时任务的实现方式,我知道的就不下于3种,后面会逐一介绍,今天就讲下如何用redis实现延时任务。

延时任务的特点

在介绍具体方案之前,我们不妨先想一下要实现一个延时系统,有哪些内容是必须存储下来的(这里的存储不一定是指持久化,也可以是放在内存中,取决于延时任务的重要程度)。首先要存储的就是任务的描述。假如你要处理的延时任务是延时发布资讯,那么你至少要存储资讯的id吧。另外,如果你有多种任务类型,比如:延时推送消息、延时清洗数据等等,那么你还需要存储任务的类型。以上总总,都归属于任务描述。除此之外,你还必须存储任务执行的时间点吧,一般来说就是时间戳。此外,我们还需要根据任务的执行时间进行排序,因为延时任务队列里的任务可能会有很多,只有到了时间点的任务才应该被执行,所以必须支持对任务执行时间进行排序。

使用Redis实现延时任务

以上就是一个延迟任务系统必须具备的要素了。回到redis,有什么数据结构可以既存储任务描述,又能存储任务执行时间,还能根据任务执行时间进行排序呢?想来想去,似乎只有Sorted Set了。我们可以把任务的描述序列化成字符串,放在Sorted Set的value中,然后把任务的执行时间戳作为score,利用Sorted Set天然的排序特性,执行时刻越早的会排在越前面。这样一来,我们只要开一个或多个定时线程,每隔一段时间去查一下这个Sorted Set中score小于或等于当前时间戳的元素(这可以通过zrangebyscore命令实现),然后再执行元素对应的任务即可。当然,执行完任务后,还要将元素从Sorted Set中删除,避免任务重复执行。如果是多个线程去轮询这个Sorted Set,还有考虑并发问题,假如说一个任务到期了,也被多个线程拿到了,这个时候必须保证只有一个线程能执行这个任务,这可以通过zrem命令来实现,只有删除成功了,才能执行任务,这样就能保证任务不被多个任务重复执行了。

接下来看代码。首先看下项目结构:

一共4个类:Constants类定义了Redis key相关的常量。DelayTaskConsumer是延时任务的消费者,这个类负责从Redis拉取到期的任务,并封装了任务消费的逻辑。DelayTaskProducer则是延时任务的生产者,主要用于将延时任务放到Redis中。RedisClient则是Redis客户端的工具类。

最主要的类就是DelayTaskConsumer和DelayTaskProducer了。

我们先来看下生产者DelayTaskProducer:

1
2
3
4
5
6
7
8
9
10
11
12
复制代码public class DelayTaskProducer {

public void produce(String newsId,long timeStamp){
Jedis client = RedisClient.getClient();
try {
client.zadd(Constants.DELAY_TASK_QUEUE,timeStamp,newsId);
}finally {
client.close();
}
}

}

代码很简单,就是将任务描述(为了方便,这里只存储资讯的id)和任务执行的时间戳放到Redis的Sorted Set中。

接下来是延时任务的消费者DelayTaskConsumer:

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
复制代码public class DelayTaskConsumer {

private ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor();

public void start(){
scheduledExecutorService.scheduleWithFixedDelay(new DelayTaskHandler(),1,1, TimeUnit.SECONDS);
}

public static class DelayTaskHandler implements Runnable{

@Override
public void run() {
Jedis client = RedisClient.getClient();
try {
Set<String> ids = client.zrangeByScore(Constants.DELAY_TASK_QUEUE, 0, System.currentTimeMillis(),
0, 1);
if(ids==null||ids.isEmpty()){
return;
}
for(String id:ids){
Long count = client.zrem(Constants.DELAY_TASK_QUEUE, id);
if(count!=null&&count==1){
System.out.println(MessageFormat.format("发布资讯。id - {0} , timeStamp - {1} , " +
"threadName - {2}",id,System.currentTimeMillis(),Thread.currentThread().getName()));
}
}
}finally {
client.close();
}
}
}
}

首先看start方法。在这个方法里面我们利用Java的ScheduledExecutorService开了一个调度线程池,这个线程池会每隔1秒钟调度DelayTaskHandler中的run方法。

DelayTaskHandler类就是具体的调度逻辑了。主要有2个步骤,一个是从Redis Sorted Set中拉取到期的延时任务,另一个是执行到期的延时任务。拉取到期的延时任务是通过zrangeByScore命令实现的,处理多线程并发问题是通过zrem命令实现的。代码不复杂,这里就不多做解释了。

接下来测试一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
复制代码public class DelayTaskTest {

public static void main(String[] args) {
DelayTaskProducer producer=new DelayTaskProducer();
long now=new Date().getTime();
System.out.println(MessageFormat.format("start time - {0}",now));
producer.produce("1",now+ TimeUnit.SECONDS.toMillis(5));
producer.produce("2",now+TimeUnit.SECONDS.toMillis(10));
producer.produce("3",now+ TimeUnit.SECONDS.toMillis(15));
producer.produce("4",now+TimeUnit.SECONDS.toMillis(20));
for(int i=0;i<10;i++){
new DelayTaskConsumer().start();
}
}

}

我们首先生产了4个延时任务,执行时间分别是程序开始运行后的5秒、10秒、15秒、20秒,然后启动了10个消费者去消费延时任务。运行效果如下:

可以看到,任务确实能够在相应的时间点左右被执行,不过有少许时间误差,这个是因为我们拉取到期任务是通过定时任务拉取而不是实时推送的,而且拉取任务时有一部分网络开销,再者,我们的任务处理逻辑是同步处理的,需要上一次的任务处理完,才能拉取下一批任务,这些因素都会造成延时任务的执行时间产生偏差。

总结

以上就是通过Redis实现延时任务的思路了。这里提供的只是一个最简单的版本,实际上还有很多地方可以优化。比如,我们可以把任务的处理逻辑再放到单独的线程池中去执行,这样的话任务消费者只需要负责任务的调度就可以了,好处就是可以减少任务执行时间偏差。还有就是,这里为了方便,任务的描述存储的只是任务的id,如果有多种不同类型的任务,像前面说的发送资讯任务和推送消息任务,那么就要通过额外存储任务的类型来进行区分,或者使用不同的Sorted Set来存放延时任务了。

除此之外,上面的例子每次拉取延时任务时,只拉取1个,如果说某一个时刻要处理的任务数非常多,那么会有一部分任务延迟比较严重,这里可以优化成每次拉取不止1个到期的任务,比如说10个,然后再逐个进行处理,这样的话可以极大地提升调度效率,因为如果是使用上面的方法,拉取10个任务需要10次调度,每次间隔1秒,总共需要10秒才能把10个任务拉取完,如果改成一次拉取10个,只需要1次就能完成了,效率提升还是挺大的。

当然还可以从另一个角度来优化。大家看上面的代码,当拉取到待执行任务时,就直接执行任务,任务执行完该线程也就退出了,但是这个时候,队列里可能还有很多待执行的任务(因为我们拉取任务时,限制了拉取的数量),所以其实在这里可以使用循环,当拉取不到待执行任务时,才结束调度,当有任务时,执行完还有顺便查询下有没有堆积的任务,直到没有堆积任务了才结束线程。

最后一个需要考虑的地方是,上面的代码并没有对任务执行失败的情况进行处理,也就是说如果某个任务执行失败了,那么连重试的机会都没有。因此,在生产环境使用时,还需要考虑任务处理失败的情况。有一个简单的方法是在任务处理时捕获异常,当在处理过程中出现异常时,就将该任务再放回Redis Sorted中,或者由当前线程再重试处理。不过这样做的话,任务的时效性就不能保证了,有可能本来定在早上7点执行的任务,因为失败重试的原因,延迟到7点10分才执行完成,这个要根据业务来进行权衡,比如可以在任务描述中增加重试次数或者是否允许重试的字段,这样在任务执行失败时,就能根据不同的任务采取不同的补偿措施了。

那么使用redis实现延时任务有什么优缺点呢?优点就是可以满足吞吐量。缺点则是存在任务丢失的风险(当redis实例挂了的时候)。因此,如果对性能要求比较高,同时又能容忍少数情况下任务的丢失,那么可以使用这种方式来实现。

关注公众号,查看更多优质原创文章。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

看完这个,Java IO从此不在难

发表于 2018-09-11

1、IO体系

Java IO 体系看起来类很多,感觉很复杂,但其实是 IO 涉及的因素太多了。在设计 IO 相关的类时,编写者也不是从同一个方面考虑的,所以会给人一种很乱的感觉,并且还有设计模式的使用,更加难以使用这些 IO 类,所以特地对 Java 的 IO 做一个总结。

IO 类设计出来,肯定是为了解决 IO 相关的操作的,想一想哪里会有 IO 操作?网络、磁盘。网络操作相关的类是在 java.net 包下,不在本文的总结范围内。提到磁盘,你可能会想到文件,文件操作在 IO 中是比较典型的操作。在 Java 中引入了 “流” 的概念,它表示任何有能力产生数据源或有能力接收数据源的对象。数据源可以想象成水源,海水、河水、湖水、一杯水等等。数据传输可以想象为水的运输,古代有用桶运水,用竹管运水的,现在有钢管运水,不同的运输方式对应不同的运输特性。

从数据来源或者说是操作对象角度看,IO 类可以分为:

  • 1、文件(file):FileInputStream、FileOutputStream、FileReader、FileWriter
  • 2、数组([]):
    • 2.1、字节数组(byte[]):ByteArrayInputStream、ByteArrayOutputStream
    • 2.2、字符数组(char[]):CharArrayReader、CharArrayWriter
  • 3、管道操作:PipedInputStream、PipedOutputStream、PipedReader、PipedWriter
  • 4、基本数据类型:DataInputStream、DataOutputStream
  • 5、缓冲操作:BufferedInputStream、BufferedOutputStream、BufferedReader、BufferedWriter
  • 6、打印:PrintStream、PrintWriter
  • 7、对象序列化反序列化:ObjectInputStream、ObjectOutputStream
  • 8、转换:InputStreamReader、OutputStreWriter
  • 9、字符串(String)Java8中已废弃:StringBufferInputStream、StringBufferOutputStream、StringReader、StringWriter

数据源节点也可以再进行二次处理,使数据更加容易使用,所以还可以划分成节点流和处理流,这里涉及到设计模式,后面会有专门的文章说。

按操作对象划分.jpg

从数据传输方式或者说是运输方式角度看,可以将 IO 类分为:

  • 1、字节流
  • 2、字符流

字节流是以一个字节单位来运输的,比如一杯一杯的取水。而字符流是以多个字节来运输的,比如一桶一桶的取水,一桶水又可以分为几杯水。

字节流和字符流的区别:

字节流读取单个字节,字符流读取单个字符(一个字符根据编码的不同,对应的字节也不同,如 UTF-8 编码是 3 个字节,中文编码是 2 个字节。)字节流用来处理二进制文件(图片、MP3、视频文件),字符流用来处理文本文件(可以看做是特殊的二进制文件,使用了某种编码,人可以阅读)。简而言之,字节是个计算机看的,字符才是给人看的。

字节流和字符流的划分可以看下面这张图。

按字节和字符划分.png

不可否认,Java IO 相关的类确实很多,但我们并不是所有的类都会用到,我们常用的也就是文件相关的几个类,如文件最基本的读写类 File 开头的、文件读写带缓冲区的类 Buffered 开头的类,对象序列化反序列化相关的类 Object 开头的类。

2、IO类和相关方法

IO 类虽然很多,但最基本的是 4 个抽象类:InputStream、OutputStream、Reader、Writer。最基本的方法也就是一个读 read() 方法、一个写 write() 方法。方法具体的实现还是要看继承这 4 个抽象类的子类,毕竟我们平时使用的也是子类对象。这些类中的一些方法都是(Native)本地方法、所以并没有 Java 源代码,这里给出笔者觉得不错的 Java IO 源码分析 传送门,按照上面这个思路看,先看子类基本方法,然后在看看子类中还新增了那些方法,相信你也可以看懂的,我这里就只对上后面说的常用的类进行总结。

先来看 InputStream 和 OutStream 中的方法简介,因为都是抽象类、大都是抽象方法、所以就不贴源码喽!注意这里的读取和写入,其实就是获取(输入)数据和输出数据。

InputStream 类

方法 方法介绍
public abstract int read() 读取数据
public int read(byte b[]) 将读取到的数据放在 byte 数组中,该方法实际上是根据下面的方法实现的,off 为 0,len 为数组的长度
public int read(byte b[], int off, int len) 从第 off 位置读取 len 长度字节的数据放到 byte 数组中,流是以 -1 来判断是否读取结束的(注意这里读取的虽然是一个字节,但是返回的却是 int 类型 4 个字节,这里当然是有原因,这里就不再细说了,推荐这篇文章,链接)
public long skip(long n) 跳过指定个数的字节不读取,想想看电影跳过片头片尾
public int available() 返回可读的字节数量
public void close() 读取完,关闭流,释放资源
public synchronized void mark(int readlimit) 标记读取位置,下次还可以从这里开始读取,使用前要看当前流是否支持,可以使用 markSupport() 方法判断
public synchronized void reset() 重置读取位置为上次 mark 标记的位置
public boolean markSupported() 判断当前流是否支持标记流,和上面两个方法配套使用

OutputStream 类

方法 方法介绍
public abstract void write(int b) 写入一个字节,可以看到这里的参数是一个 int 类型,对应上面的读方法,int 类型的 32 位,只有低 8 位才写入,高 24 位将舍弃。
public void write(byte b[]) 将数组中的所有字节写入,和上面对应的 read() 方法类似,实际调用的也是下面的方法。
public void write(byte b[], int off, int len) 将 byte 数组从 off 位置开始,len 长度的字节写入
public void flush() 强制刷新,将缓冲中的数据写入
public void close() 关闭输出流,流被关闭后就不能再输出数据了

再来看 Reader 和 Writer 类中的方法,你会发现和上面两个抽象基类中的方法很像。

Reader 类

方法 方法介绍
public int read(java.nio.CharBuffer target) 读取字节到字符缓存中
public int read() 读取单个字符
public int read(char cbuf[]) 读取字符到指定的 char 数组中
abstract public int read(char cbuf[], int off, int len) 从 off 位置读取 len 长度的字符到 char 数组中
public long skip(long n) 跳过指定长度的字符数量
public boolean ready() 和上面的 available() 方法类似
public boolean markSupported() 判断当前流是否支持标记流
public void mark(int readAheadLimit) 标记读取位置,下次还可以从这里开始读取,使用前要看当前流是否支持,可以使用 markSupport() 方法判断
public void reset() 重置读取位置为上次 mark 标记的位置
abstract public void close() 关闭流释放相关资源

Writer 类

方法 方法介绍
public void write(int c) 写入一个字符
public void write(char cbuf[]) 写入一个字符数组
abstract public void write(char cbuf[], int off, int len) 从字符数组的 off 位置写入 len 数量的字符
public void write(String str) 写入一个字符串
public void write(String str, int off, int len) 从字符串的 off 位置写入 len 数量的字符
public Writer append(CharSequence csq) 追加吸入一个字符序列
public Writer append(CharSequence csq, int start, int end) 追加写入一个字符序列的一部分,从 start 位置开始,end 位置结束
public Writer append(char c) 追加写入一个 16 位的字符
abstract public void flush() 强制刷新,将缓冲中的数据写入
abstract public void close() 关闭输出流,流被关闭后就不能再输出数据了

下面我们就直接使用他们的子类,在使用中再介绍下面没有的新方法。

1、读取控制台中的输入

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
复制代码import java.io.*;

public class IOTest {
public static void main(String[] args) throws IOException {
// 三个测试方法
// test01();
// test02();
test03();
}

public static void test01() throws IOException {
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(System.in));
System.out.println("请输入一个字符");
char c;
c = (char) bufferedReader.read();
System.out.println("你输入的字符为"+c);
}

public static void test02() throws IOException {
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(System.in));
System.out.println("请输入一个字符,按 q 键结束");
char c;
do {
c = (char) bufferedReader.read();
System.out.println("你输入的字符为"+c);
} while (c != 'q');
}

public static void test03() throws IOException {
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(System.in));
System.out.println("请输入一行字符");
String str = bufferedReader.readLine();
System.out.println("你输入的字符为" + str);
}
}

至于控制台的输出,我们其实一直都在使用呢,System.out.println() ,out 其实是 PrintStream 类对象的引用,PrintStream 类中当然也有 write() 方法,但是我们更常用 print() 方法和 println() 方法,因为这两个方法可以输出的内容种类更多,比如一个打印一个对象,实际调用的对象的 toString() 方法。

2、二进制文件的写入和读取

注意这里文件的路径,可以根据自己情况改一下,虽然这里的文件后缀是txt,但该文件却是一个二进制文件,并不能直接查看。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
复制代码@Test
public void test04() throws IOException {
byte[] bytes = {12,21,34,11,21};
FileOutputStream fileOutputStream = new FileOutputStream(new File("").getAbsolutePath()+"/io/test.txt");
// 写入二进制文件,直接打开会出现乱码
fileOutputStream.write(bytes);
fileOutputStream.close();
}

@Test
public void test05() throws IOException {
FileInputStream fileInputStream = new FileInputStream(new File("").getAbsolutePath()+"/io/test.txt");
int c;
// 读取写入的二进制文件,输出字节数组
while ((c = fileInputStream.read()) != -1) {
System.out.print(c);
}
}

3、文本文件的写入和读取

write() 方法和 append() 方法并不是像方法名那样,一个是覆盖内容,一个是追加内容,append() 内部也是 write() 方法实现的,也非说区别,也就是 append() 方法可以直接写 null,而 write() 方法需要把 null 当成一个字符串写入,所以两者并无本质的区别。需要注意的是这里并没有指定文件编码,可能会出现乱码的问题。

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
40
41
42
43
44
复制代码@Test
public void test06() throws IOException {
FileWriter fileWriter = new FileWriter(new File("").getAbsolutePath()+"/io/test.txt");
fileWriter.write("Hello,world!\n欢迎来到 java 世界\n");
fileWriter.write("不会覆盖文件原本的内容\n");
// fileWriter.write(null); 不能直接写入 null
fileWriter.append("并不是追加一行内容,不要被方法名迷惑\n");
fileWriter.append(null);
fileWriter.flush();
System.out.println("文件的默认编码为" + fileWriter.getEncoding());
fileWriter.close();
}

@Test
public void test07() throws IOException {
FileWriter fileWriter = new FileWriter(new File("").getAbsolutePath()+"/io/test.txt", false); // 关闭追加模式,变为覆盖模式
fileWriter.write("Hello,world!欢迎来到 java 世界\n");
fileWriter.write("我来覆盖文件原本的内容");
fileWriter.append("我是下一行");
fileWriter.flush();
System.out.println("文件的默认编码为" + fileWriter.getEncoding());
fileWriter.close();
}

@Test
public void test08() throws IOException {
FileReader fileReader = new FileReader(new File("").getAbsolutePath()+"/io/test.txt");
BufferedReader bufferedReader = new BufferedReader(fileReader);
String str;
while ((str = bufferedReader.readLine()) != null) {
System.out.println(str);
}
fileReader.close();
bufferedReader.close();
}

@Test
public void test09() throws IOException {
FileReader fileReader = new FileReader(new File("").getAbsolutePath()+"/io/test.txt");
int c;
while ((c = fileReader.read()) != -1) {
System.out.print((char) c);
}
}

使用字节流和字符流的转换类 InputStreamReader 和 OutputStreamWriter 可以指定文件的编码,使用 Buffer 相关的类来读取文件的每一行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
复制代码@Test
public void test10() throws IOException {
FileOutputStream fileOutputStream = new FileOutputStream(new File("").getAbsolutePath()+"/io/test2.txt");
OutputStreamWriter outputStreamWriter = new OutputStreamWriter(fileOutputStream, "GBK"); // 使用 GBK 编码文件
outputStreamWriter.write("Hello,world!\n欢迎来到 java 世界\n");
outputStreamWriter.append("另外一行内容");
outputStreamWriter.flush();
System.out.println("文件的编码为" + outputStreamWriter.getEncoding());
outputStreamWriter.close();
fileOutputStream.close();
}

@Test
public void test11() throws IOException {
FileInputStream fileInputStream = new FileInputStream(new File("").getAbsolutePath()+"/io/test2.txt");
InputStreamReader inputStreamReader = new InputStreamReader(fileInputStream, "GBK"); // 使用 GBK 解码文件
BufferedReader bufferedReader = new BufferedReader(inputStreamReader);
String str;
while ((str = bufferedReader.readLine()) != null) {
System.out.println(str);
}
bufferedReader.close();
inputStreamReader.close();
}

4、复制文件

这里笔者做了一些测试,不使用缓冲对文件复制时间的影响,文件的复制实质还是文件的读写。缓冲流是处理流,是对节点流的装饰。

注:这里的时间是在我这台华硕笔记本上测试得到的,只是为了说明使用缓冲对文件的读写有好处。

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
复制代码@Test
public void test12() throws IOException {
// 输入和输出都使用缓冲流
FileInputStream in = new FileInputStream("E:\\视频资料\\大数据原理与应用\\1.1大数据时代.mp4");
BufferedInputStream inBuffer = new BufferedInputStream(in);
FileOutputStream out = new FileOutputStream("1.1大数据时代.mp4");
BufferedOutputStream outBuffer = new BufferedOutputStream(out);
int len = 0;
byte[] bs = new byte[1024];
long begin = System.currentTimeMillis();
while ((len = inBuffer.read(bs)) != -1) {
outBuffer.write(bs, 0, len);
}
System.out.println("复制文件所需的时间:" + (System.currentTimeMillis() - begin)); // 平均时间约 200 多毫秒
inBuffer.close();
in.close();
outBuffer.close();
out.close();
}


@Test
public void test13() throws IOException {
// 只有输入使用缓冲流
FileInputStream in = new FileInputStream("E:\\视频资料\\大数据原理与应用\\1.1大数据时代.mp4");
BufferedInputStream inBuffer = new BufferedInputStream(in);
FileOutputStream out = new FileOutputStream("1.1大数据时代.mp4");
int len = 0;
byte[] bs = new byte[1024];
long begin = System.currentTimeMillis();
while ((len = inBuffer.read(bs)) != -1) {
out.write(bs, 0, len);
}
System.out.println("复制文件所需时间:" + (System.currentTimeMillis() - begin)); // 平均时间约 500 多毫秒
inBuffer.close();
in.close();
out.close();
}

@Test
public void test14() throws IOException {
// 输入和输出都不使用缓冲流
FileInputStream in = new FileInputStream("E:\\视频资料\\大数据原理与应用\\1.1大数据时代.mp4");
FileOutputStream out = new FileOutputStream("1.1大数据时代.mp4");
int len = 0;
byte[] bs = new byte[1024];
long begin = System.currentTimeMillis();
while ((len = in.read(bs)) != -1) {
out.write(bs, 0, len);
}
System.out.println("复制文件所需时间:" + (System.currentTimeMillis() - begin)); // 平均时间 700 多毫秒
in.close();
out.close();
}

@Test
public void test15() throws IOException {
// 不使用缓冲
FileInputStream in = new FileInputStream("E:\\视频资料\\大数据原理与应用\\1.1大数据时代.mp4");
FileOutputStream out = new FileOutputStream("1.1大数据时代.mp4");
int len = 0;
long begin = System.currentTimeMillis();
while ((len = in.read()) != -1) {
out.write(len);
}
System.out.println("复制文件所需时间:" + (System.currentTimeMillis() - begin)); // 平均时间约 160000 毫秒,约 2 分多钟
in.close();
out.close();
}

关于序列化和反序列化的内容,这里给出我之前写的博客,传送门。
总结:Java IO 类很多,但是把握住整个体系,掌握关键的方法,学习起来就会轻松很多,看完这篇文章,你是否觉得 Java IO 并没有你想的那么难呢?欢迎你在下方留言,和我们讨论。

欢迎关注下方的微信公众号哦,另外还有各种学习资料免费分享!

编程心路

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

构建你的第一个 Nodejs 微服务

发表于 2018-09-11

微服务是一个自包含的独立单元,跟其他的微服务共同组成一个大型应用。通过把应用拆分成小单元,每个单元都能独立部署和扩展,也能由不同的团队用不同的编程语言开发,还能独立测试。

micro 是一个很小的(大约100行代码)模块,它让我们用 Node.js 写微服务变得轻松有趣。它很容易使用,而且非常快。无论你之前是否用过 Node.js,看完这篇文章你就能写自己的微服务了!

上手准备

上手操作仅需两个小步骤,首先需要安装 micro:

1
复制代码npm install -g micro

这里选择全局安装是为了确保我们能使用micro 命令。如果你知道如何使用 npm scripts,你可以随意使用它们。

第二步就是新建一个存放微服务的文件 index.js:

1
复制代码touch index.js

初始步骤

这个 index.js 文件需要导出一个函数, micro 会把连接的请求和响应对象传给它:

1
复制代码module.exports = function (request, response) {  // 微服务逻辑代码}

我们用到 micro的最主要的方法是 send,用它可以向客户端发送响应。我们先require 它,并发送一个简单的“Hello World”,无论请求是什么:

1
复制代码const { send } = require('micro')module.exports = function (request, response) {  send(response, 200, 'Hello World! 👋')}

send 第一个参数是要发送的响应,第二个参数是 HTTP 状态码,第三个参数是响应内容(可以是JOSN)。

启动微服务只需要一个命令:

1
复制代码$ micro index.js Ready! Listening on http://0.0.0.0:3000

用浏览器打开这个页面,你会看到:

构建你的第一个 Node.js 微服务

做点有用的

前面做的有点枯燥,我们来做点有用的东西!我们想做个能记录指定路径被请求的次数的微服务。也就是当 /foo 第一次被请求时,返回 1,再一次请求时返回2,等等。

我们首先需要知道请求 URL 的pathname。从 request.url获得 URL,然后用 Node.js 核心库的url 模块(无需另外安装)解析它。

引入url模块并用它从 URL 解析获得 pathname:

1
复制代码const { send } = require('micro')const url = require('url')module.exports = function (request, response) {  const { pathname } = url.parse(request.url)  console.log(pathname)  send(response, 200, 'Hello World! 👋')}

重启微服务(按 CTRL+C,然后再次输入 micro index.js)试试看。请求localhost:3000/foo 会在控制台输出 /foo,请求localhost:3000/bar 输出 /bar。

有了 pathname,最后一步就是保存这个路径被请求的次数了。创建一个全局对象visits,用来保存所有访问记录:

1
复制代码const { send } = require('micro')const url = require('url')const visits = {}module.exports = function (request, response) {  const { pathname } = url.parse(request.url)  send(response, 200, 'Hello World! 👋')}

每次请求到达的时候检查visits[pathname]是否存在。如果存在,就把访问次数递增并返回结果给客户端。否则就把它设置为 1并把它返回给客户端。

1
复制代码const { send } = require('micro')const url = require('url')const visits = {}module.exports = function (request, response) {  const { pathname } = url.parse(request.url)  if (visits[pathname]) {    visits[pathname] = visits[pathname] + 1  } else {    visits[pathname] = 1  }  send(response, 200, `This page has ${visits[pathname]} visits!`)}

再次重启服务,在浏览器打开localhost:3000/foo并刷新几次。你会看到:

记录访问次数

这基本上是我在几个小时内构建 micro-analytics 的方法。核心概念是一样的,只是多了一些功能。一旦弄清楚在做的东西,实现的代码还是挺简单的。

持久化数据

你可能也注意到了,每当我们重启服务的时候,数据都被删除了。我们并没有把访问数据保存到数据库,只是放在内存里。让我们解决这个问题!

我们将使用 level 持久化数据,它是一个基于文件的键值存储器。 micro内置对 async/await 的支持,这让异步代码更加优美。问题在于, level 是基于回调函数而不是 Promise的。😕

像往常一样,npm 里有我们需要的模块。 Forbes Lindesay 开发了 then-levelup,它允许我们通过 promise 的方式使用level 。如果不太理解,不用担心,很快你就能知道它是什么了!

先安装这些模块:

1
复制代码npm install level then-levelup

为了创建数据库,我们先引入level,然后指定数据库的存储位置,存储内容为JSON格式。我们用 then-levelup 导出的方法 promisify包住这个数据库,然后导出一个 async 函数而不是普通函数,以便能使用 await 关键字:

1
复制代码const { send } = require('micro')const url = require('url')const level = require('level')const promisify = require('then-levelup')const db = promisify(level('visits.db', {  valueEncoding: 'json'}))module.exports = async function (request, response) {  /* ... */}

对于数据库我们需要的两个方法是,db.put(key, value) 用来保存数据(等效于 visits[pathname] = x),db.get(key)用来获取数据(等效于 const x = visits[pathname])。

首先,我们想知道在数据库里是否存在该路径的访问记录。通过 db.get(pathname) 来实现,并用 await 关键字等待完成:

1
复制代码module.exports = async function (request, response) {  const { pathname } = url.parse(request.url)  const currentVisits = await db.get(pathname)}

如果不加上 await, currentVisits 就被赋值为一个 Promise,函数会继续执行,我们也就得不到数据库返回的值了——这不是我们想要的结果!

与之前相反,如果当前没有访问记录,db.get 会抛出一个 “NotFoundError” 异常。我们要用 try/catch 块来捕获,并用 db.put 设置初始值为1:

1
复制代码/* ... */module.exports = async function (request, response) {  const { pathname } = url.parse(request.url)  try {    const currentVisits = await db.get(pathname)  } catch (error) {    if (error.notFound) await db.put(pathname, 1)  }}

继续完成它,如果已经有访问记录,我们需要增加访问次数并发送响应:

1
复制代码/* ... */module.exports = async function (request, response) {  const { pathname } = url.parse(request.url)  try {    const currentVisits = await db.get(pathname)    await db.put(pathname, currentVisits + 1)  } catch (error) {    if (error.notFound) await db.put(pathname, 1)  }  send(response, 200, `This page has ${await db.get(pathname)} visits!`)}

这就是我们要做的所有事情!现在,页面的访问记录已经持久化保存到 vists.db 文件里了,服务重启也不受影响。试着重启服务,打开几次 localhost:3000/foo ,然后再重启服务,再访问同一个页面。你会发现之前的访问次数还在,尽管已经重启服务了。

恭喜你,在10分钟内就建立了一个页面计数器! 🎉

这就是 Node.js 中小型的、集中的模块的强大功能。无需折腾基础组件,我们只要专注于应用开发。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

可能是把 ZooKeeper 概念讲的最清楚的一篇文章

发表于 2018-09-11

该文已加入开源文档:JavaGuide(一份涵盖大部分Java程序员所需要掌握的核心知识)。地址:github.com/Snailclimb/….

前言

相信大家对 ZooKeeper 应该不算陌生。但是你真的了解 ZooKeeper 是个什么东西吗?如果别人/面试官让你给他讲讲 ZooKeeper 是个什么东西,你能回答到什么地步呢?

我本人曾经使用过 ZooKeeper 作为 Dubbo 的注册中心,另外在搭建 solr 集群的时候,我使用到了 ZooKeeper 作为 solr 集群的管理工具。前几天,总结项目经验的时候,我突然问自己 ZooKeeper 到底是个什么东西?想了半天,脑海中只是简单的能浮现出几句话:“①Zookeeper 可以被用作注册中心。 ②Zookeeper 是 Hadoop 生态系统的一员;③构建 Zookeeper 集群的时候,使用的服务器最好是奇数台。” 可见,我对于 Zookeeper 的理解仅仅是停留在了表面。

所以,通过本文,希望带大家稍微详细的了解一下 ZooKeeper 。如果没有学过 ZooKeeper ,那么本文将会是你进入 ZooKeeper 大门的垫脚砖。如果你已经接触过 ZooKeeper ,那么本文将带你回顾一下 ZooKeeper 的一些基础概念。

最后,本文只涉及 ZooKeeper 的一些概念,并不涉及 ZooKeeper 的使用以及 ZooKeeper 集群的搭建。 网上有介绍 ZooKeeper 的使用以及搭建 ZooKeeper 集群的文章,大家有需要可以自行查阅。

一 什么是 ZooKeeper

ZooKeeper 的由来

下面这段内容摘自《从Paxos到Zookeeper 》第四章第一节的某段内容,推荐大家阅读以下:

Zookeeper最早起源于雅虎研究院的一个研究小组。在当时,研究人员发现,在雅虎内部很多大型系统基本都需要依赖一个类似的系统来进行分布式协调,但是这些系统往往都存在分布式单点问题。所以,雅虎的开发人员就试图开发一个通用的无单点问题的分布式协调框架,以便让开发人员将精力集中在处理业务逻辑上。

关于“ZooKeeper”这个项目的名字,其实也有一段趣闻。在立项初期,考虑到之前内部很多项目都是使用动物的名字来命名的(例如著名的Pig项目),雅虎的工程师希望给这个项目也取一个动物的名字。时任研究院的首席科学家RaghuRamakrishnan开玩笑地说:“在这样下去,我们这儿就变成动物园了!”此话一出,大家纷纷表示就叫动物园管理员吧一一一因为各个以动物命名的分布式组件放在一起,雅虎的整个分布式系统看上去就像一个大型的动物园了,而Zookeeper正好要用来进行分布式环境的协调一一于是,Zookeeper的名字也就由此诞生了。

1.1 ZooKeeper 概览

ZooKeeper 是一个开源的分布式协调服务,ZooKeeper框架最初是在“Yahoo!”上构建的,用于以简单而稳健的方式访问他们的应用程序。 后来,Apache ZooKeeper成为Hadoop,HBase和其他分布式框架使用的有组织服务的标准。 例如,Apache HBase使用ZooKeeper跟踪分布式数据的状态。ZooKeeper 的设计目标是将那些复杂且容易出错的分布式一致性服务封装起来,构成一个高效可靠的原语集,并以一系列简单易用的接口提供给用户使用。

原语: 操作系统或计算机网络用语范畴。是由若干条指令组成的,用于完成一定功能的一个过程。具有不可分割性·即原语的执行必须是连续的,在执行过程中不允许被中断。

ZooKeeper 是一个典型的分布式数据一致性解决方案,分布式应用程序可以基于 ZooKeeper 实现诸如数据发布/订阅、负载均衡、命名服务、分布式协调/通知、集群管理、Master 选举、分布式锁和分布式队列等功能。

Zookeeper 一个最常用的使用场景就是用于担任服务生产者和服务消费者的注册中心。 服务生产者将自己提供的服务注册到Zookeeper中心,服务的消费者在进行服务调用的时候先到Zookeeper中查找服务,获取到服务生产者的详细信息之后,再去调用服务生产者的内容与数据。如下图所示,在 Dubbo架构中 Zookeeper 就担任了注册中心这一角色。

Dubbo

1.2 结合个人使用情况的讲一下 ZooKeeper

在我自己做过的项目中,主要使用到了 ZooKeeper 作为 Dubbo 的注册中心(Dubbo 官方推荐使用 ZooKeeper注册中心)。另外在搭建 solr 集群的时候,我使用 ZooKeeper 作为 solr 集群的管理工具。这时,ZooKeeper 主要提供下面几个功能:1、集群管理:容错、负载均衡。2、配置文件的集中管理3、集群的入口。

我个人觉得在使用 ZooKeeper 的时候,最好是使用 集群版的 ZooKeeper 而不是单机版的。官网给出的架构图就描述的是一个集群版的 ZooKeeper 。通常 3 台服务器就可以构成一个 ZooKeeper 集群了。

为什么最好使用奇数台服务器构成 ZooKeeper 集群?

我们知道在Zookeeper中 Leader 选举算法采用了Zab协议。Zab核心思想是当多数 Server 写成功,则任务数据写成功。

①如果有3个Server,则最多允许1个Server 挂掉。

②如果有4个Server,则同样最多允许1个Server挂掉。

既然3个或者4个Server,同样最多允许1个Server挂掉,那么它们的可靠性是一样的,所以选择奇数个ZooKeeper Server即可,这里选择3个Server。

二 关于 ZooKeeper 的一些重要概念

2.1 重要概念总结

  • ZooKeeper 本身就是一个分布式程序(只要半数以上节点存活,ZooKeeper 就能正常服务)。
  • 为了保证高可用,最好是以集群形态来部署 ZooKeeper,这样只要集群中大部分机器是可用的(能够容忍一定的机器故障),那么 ZooKeeper 本身仍然是可用的。
  • ZooKeeper 将数据保存在内存中,这也就保证了 高吞吐量和低延迟(但是内存限制了能够存储的容量不太大,此限制也是保持znode中存储的数据量较小的进一步原因)。
  • ZooKeeper 是高性能的。 在“读”多于“写”的应用程序中尤其地高性能,因为“写”会导致所有的服务器间同步状态。(“读”多于“写”是协调服务的典型场景。)
  • ZooKeeper有临时节点的概念。 当创建临时节点的客户端会话一直保持活动,瞬时节点就一直存在。而当会话终结时,瞬时节点被删除。持久节点是指一旦这个ZNode被创建了,除非主动进行ZNode的移除操作,否则这个ZNode将一直保存在Zookeeper上。
  • ZooKeeper 底层其实只提供了两个功能:①管理(存储、读取)用户程序提交的数据;②为用户程序提交数据节点监听服务。

下面关于会话(Session)、 Znode、版本、Watcher、ACL概念的总结都在《从Paxos到Zookeeper 》第四章第一节以及第七章第八节有提到,感兴趣的可以看看!

2.2 会话(Session)

Session 指的是 ZooKeeper 服务器与客户端会话。在 ZooKeeper 中,一个客户端连接是指客户端和服务器之间的一个 TCP 长连接。客户端启动的时候,首先会与服务器建立一个 TCP 连接,从第一次连接建立开始,客户端会话的生命周期也开始了。通过这个连接,客户端能够通过心跳检测与服务器保持有效的会话,也能够向Zookeeper服务器发送请求并接受响应,同时还能够通过该连接接收来自服务器的Watch事件通知。 Session的sessionTimeout值用来设置一个客户端会话的超时时间。当由于服务器压力太大、网络故障或是客户端主动断开连接等各种原因导致客户端连接断开时,只要在sessionTimeout规定的时间内能够重新连接上集群中任意一台服务器,那么之前创建的会话仍然有效。

在为客户端创建会话之前,服务端首先会为每个客户端都分配一个sessionID。由于 sessionID 是 Zookeeper 会话的一个重要标识,许多与会话相关的运行机制都是基于这个 sessionID 的,因此,无论是哪台服务器为客户端分配的 sessionID,都务必保证全局唯一。

2.3 Znode

在谈到分布式的时候,我们通常说的“节点”是指组成集群的每一台机器。然而,在Zookeeper中,“节点”分为两类,第一类同样是指构成集群的机器,我们称之为机器节点;第二类则是指数据模型中的数据单元,我们称之为数据节点一一ZNode。

Zookeeper将所有数据存储在内存中,数据模型是一棵树(Znode Tree),由斜杠(/)的进行分割的路径,就是一个Znode,例如/foo/path1。每个上都会保存自己的数据内容,同时还会保存一系列属性信息。

在Zookeeper中,node可以分为持久节点和临时节点两类。所谓持久节点是指一旦这个ZNode被创建了,除非主动进行ZNode的移除操作,否则这个ZNode将一直保存在Zookeeper上。而临时节点就不一样了,它的生命周期和客户端会话绑定,一旦客户端会话失效,那么这个客户端创建的所有临时节点都会被移除。另外,ZooKeeper还允许用户为每个节点添加一个特殊的属性:SEQUENTIAL.一旦节点被标记上这个属性,那么在这个节点被创建的时候,Zookeeper会自动在其节点名后面追加上一个整型数字,这个整型数字是一个由父节点维护的自增数字。

2.4 版本

在前面我们已经提到,Zookeeper 的每个 ZNode 上都会存储数据,对应于每个ZNode,Zookeeper 都会为其维护一个叫作 Stat 的数据结构,Stat中记录了这个 ZNode 的三个数据版本,分别是version(当前ZNode的版本)、cversion(当前ZNode子节点的版本)和 cversion(当前ZNode的ACL版本)。

2.5 Watcher

Watcher(事件监听器),是Zookeeper中的一个很重要的特性。Zookeeper允许用户在指定节点上注册一些Watcher,并且在一些特定事件触发的时候,ZooKeeper服务端会将事件通知到感兴趣的客户端上去,该机制是Zookeeper实现分布式协调服务的重要特性。

2.6 ACL

Zookeeper采用ACL(AccessControlLists)策略来进行权限控制,类似于 UNIX 文件系统的权限控制。Zookeeper 定义了如下5种权限。

其中尤其需要注意的是,CREATE和DELETE这两种权限都是针对子节点的权限控制。

三 ZooKeeper 特点

  • 顺序一致性: 从同一客户端发起的事务请求,最终将会严格地按照顺序被应用到 ZooKeeper 中去。
  • 原子性: 所有事务请求的处理结果在整个集群中所有机器上的应用情况是一致的,也就是说,要么整个集群中所有的机器都成功应用了某一个事务,要么都没有应用。
  • 单一系统映像 : 无论客户端连到哪一个 ZooKeeper 服务器上,其看到的服务端数据模型都是一致的。
  • 可靠性: 一旦一次更改请求被应用,更改的结果就会被持久化,直到被下一次更改覆盖。

四 ZooKeeper 设计目标

4.1 简单的数据模型

ZooKeeper 允许分布式进程通过共享的层次结构命名空间进行相互协调,这与标准文件系统类似。 名称空间由 ZooKeeper 中的数据寄存器组成 - 称为znode,这些类似于文件和目录。 与为存储设计的典型文件系统不同,ZooKeeper数据保存在内存中,这意味着ZooKeeper可以实现高吞吐量和低延迟。

4.2 可构建集群

为了保证高可用,最好是以集群形态来部署 ZooKeeper,这样只要集群中大部分机器是可用的(能够容忍一定的机器故障),那么zookeeper本身仍然是可用的。 客户端在使用 ZooKeeper 时,需要知道集群机器列表,通过与集群中的某一台机器建立 TCP 连接来使用服务,客户端使用这个TCP链接来发送请求、获取结果、获取监听事件以及发送心跳包。如果这个连接异常断开了,客户端可以连接到另外的机器上。

ZooKeeper 官方提供的架构图:

上图中每一个Server代表一个安装Zookeeper服务的服务器。组成 ZooKeeper 服务的服务器都会在内存中维护当前的服务器状态,并且每台服务器之间都互相保持着通信。集群间通过 Zab 协议(Zookeeper Atomic Broadcast)来保持数据的一致性。

4.3 顺序访问

对于来自客户端的每个更新请求,ZooKeeper 都会分配一个全局唯一的递增编号,这个编号反应了所有事务操作的先后顺序,应用程序可以使用 ZooKeeper 这个特性来实现更高层次的同步原语。 这个编号也叫做时间戳——zxid(Zookeeper Transaction Id)

4.4 高性能

ZooKeeper 是高性能的。 在“读”多于“写”的应用程序中尤其地高性能,因为“写”会导致所有的服务器间同步状态。(“读”多于“写”是协调服务的典型场景。)

五 ZooKeeper 集群角色介绍

最典型集群模式: Master/Slave 模式(主备模式)。在这种模式中,通常 Master服务器作为主服务器提供写服务,其他的 Slave 服务器从服务器通过异步复制的方式获取 Master 服务器最新的数据提供读服务。

但是,在 ZooKeeper 中没有选择传统的 Master/Slave 概念,而是引入了Leader、Follower 和 Observer 三种角色。如下图所示

ZooKeeper 集群中的所有机器通过一个 Leader 选举过程来选定一台称为 “Leader” 的机器,Leader 既可以为客户端提供写服务又能提供读服务。除了 Leader 外,Follower 和 Observer 都只能提供读服务。Follower 和 Observer 唯一的区别在于 Observer 机器不参与 Leader 的选举过程,也不参与写操作的“过半写成功”策略,因此 Observer 机器可以在不影响写性能的情况下提升集群的读性能。

六 ZooKeeper &ZAB 协议&Paxos算法

6.1 ZAB 协议&Paxos算法

Paxos 算法应该可以说是 ZooKeeper 的灵魂了。但是,ZooKeeper 并没有完全采用 Paxos算法 ,而是使用 ZAB 协议作为其保证数据一致性的核心算法。另外,在ZooKeeper的官方文档中也指出,ZAB协议并不像 Paxos 算法那样,是一种通用的分布式一致性算法,它是一种特别为Zookeeper设计的崩溃可恢复的原子消息广播算法。

6.2 ZAB 协议介绍

ZAB(ZooKeeper Atomic Broadcast 原子广播) 协议是为分布式协调服务 ZooKeeper 专门设计的一种支持崩溃恢复的原子广播协议。 在 ZooKeeper 中,主要依赖 ZAB 协议来实现分布式数据一致性,基于该协议,ZooKeeper 实现了一种主备模式的系统架构来保持集群中各个副本之间的数据一致性。

6.3 ZAB 协议两种基本的模式:崩溃恢复和消息广播

ZAB协议包括两种基本的模式,分别是 崩溃恢复和消息广播。当整个服务框架在启动过程中,或是当 Leader 服务器出现网络中断、崩溃退出与重启等异常情况时,ZAB 协议就会进人恢复模式并选举产生新的Leader服务器。当选举产生了新的 Leader 服务器,同时集群中已经有过半的机器与该Leader服务器完成了状态同步之后,ZAB协议就会退出恢复模式。其中,所谓的状态同步是指数据同步,用来保证集群中存在过半的机器能够和Leader服务器的数据状态保持一致。

当集群中已经有过半的Follower服务器完成了和Leader服务器的状态同步,那么整个服务框架就可以进人消息广播模式了。 当一台同样遵守ZAB协议的服务器启动后加人到集群中时,如果此时集群中已经存在一个Leader服务器在负责进行消息广播,那么新加人的服务器就会自觉地进人数据恢复模式:找到Leader所在的服务器,并与其进行数据同步,然后一起参与到消息广播流程中去。正如上文介绍中所说的,ZooKeeper设计成只允许唯一的一个Leader服务器来进行事务请求的处理。Leader服务器在接收到客户端的事务请求后,会生成对应的事务提案并发起一轮广播协议;而如果集群中的其他机器接收到客户端的事务请求,那么这些非Leader服务器会首先将这个事务请求转发给Leader服务器。

关于 ZAB 协议&Paxos算法 需要讲和理解的东西太多了,说实话,笔主到现在不太清楚这俩兄弟的具体原理和实现过程。推荐阅读下面两篇文章:

  • 图解 Paxos 一致性协议:

http://blog.xiaohansong.com/2016/09/30/Paxos/

  • Zookeeper ZAB 协议分析:

http://blog.xiaohansong.com/2016/08/25/zab/

关于如何使用 zookeeper 实现分布式锁,可以查看下面这篇文章:

  • Zookeeper ZAB 协议分析:

https://blog.csdn.net/qiangcuo6087/article/details/79067136

六 总结

通过阅读本文,想必大家已从 ①ZooKeeper的由来。 -> ②ZooKeeper 到底是什么 。-> ③ ZooKeeper 的一些重要概念(会话(Session)、 Znode、版本、Watcher、ACL)-> ④ZooKeeper 的特点。 -> ⑤ZooKeeper 的设计目标。-> ⑥ ZooKeeper 集群角色介绍 (Leader、Follower 和 Observer 三种角色)-> ⑦ZooKeeper &ZAB 协议&Paxos算法。 这七点了解了 ZooKeeper 。

参考

  • 《从Paxos到Zookeeper 》
  • https://cwiki.apache.org/confluence/display/ZOOKEEPER/ProjectDescription
  • https://cwiki.apache.org/confluence/display/ZOOKEEPER/Index
  • https://www.cnblogs.com/raphael5200/p/5285583.html
  • https://zhuanlan.zhihu.com/p/30024403

往期精彩回顾最最最常见的Java面试题总结——第一周

最最最常见的Java面试题总结——第二周

Java 基础知识30问

这几道 Java 集合框架面试题在面试中几乎必问

如果不会这几道多线程基础题,请自觉面壁

温馨提示

如果你喜欢本文,请分享到朋友圈,想要获得更多信息,请点击下方二维码关注我。

好文!必须点赞

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Vuex 源码深度解析 Vuex 思想 Vuex 解析 最后

发表于 2018-09-10

该文章内容节选自团队的开源项目 InterviewMap。项目目前内容包含了 JS、网络、浏览器相关、小程序、性能优化、安全、框架、Git、数据结构、算法等内容,无论是基础还是进阶,亦或是源码解读,你都能在本图谱中得到满意的答案,希望这个面试图谱能够帮助到大家更好的准备面试。

Vuex 思想

在解读源码之前,先来简单了解下 Vuex 的思想。

Vuex 全局维护着一个对象,使用到了单例设计模式。在这个全局对象中,所有属性都是响应式的,任意属性进行了改变,都会造成使用到该属性的组件进行更新。并且只能通过 commit 的方式改变状态,实现了单向数据流模式。

Vuex 解析

Vuex 安装

在看接下来的内容前,推荐本地 clone 一份 Vuex 源码对照着看,便于理解。

在使用 Vuex 之前,我们都需要调用 Vue.use(Vuex) 。在调用 use 的过程中,Vue 会调用到 Vuex 的 install 函数

install 函数作用很简单

  • 确保 Vuex 只安装一次
  • 混入 beforeCreate 钩子函数,可以在组件中使用 this.$store
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
复制代码export function install (_Vue) {
// 确保 Vuex 只安装一次
if (Vue && _Vue === Vue) {
if (process.env.NODE_ENV !== 'production') {
console.error(
'[vuex] already installed. Vue.use(Vuex) should be called only once.'
)
}
return
}
Vue = _Vue
applyMixin(Vue)
}

// applyMixin
export default function (Vue) {
// 获得 Vue 版本号
const version = Number(Vue.version.split('.')[0])
// Vue 2.0 以上会混入 beforeCreate 函数
if (version >= 2) {
Vue.mixin({ beforeCreate: vuexInit })
} else {
// ...
}
// 作用很简单,就是能让我们在组件中
// 使用到 this.$store
function vuexInit () {
const options = this.$options
if (options.store) {
this.$store = typeof options.store === 'function'
? options.store()
: options.store
} else if (options.parent && options.parent.$store) {
this.$store = options.parent.$store
}
}
}

Vuex 初始化

this._modules

本小节内容主要解析如何初始化 this._modules

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
40
41
复制代码export class Store {
constructor (options = {}) {
// 引入 Vue 的方式,自动安装
if (!Vue && typeof window !== 'undefined' && window.Vue) {
install(window.Vue)
}
// 在开发环境中断言
if (process.env.NODE_ENV !== 'production') {
assert(Vue, `must call Vue.use(Vuex) before creating a store instance.`)
assert(typeof Promise !== 'undefined', `vuex requires a Promise polyfill in this browser.`)
assert(this instanceof Store, `store must be called with the new operator.`)
}
// 获取 options 中的属性
const {
plugins = [],
strict = false
} = options

// store 内部的状态,重点关注 this._modules
this._committing = false
this._actions = Object.create(null)
this._actionSubscribers = []
this._mutations = Object.create(null)
this._wrappedGetters = Object.create(null)
this._modules = new ModuleCollection(options)
this._modulesNamespaceMap = Object.create(null)
this._subscribers = []
this._watcherVM = new Vue()


const store = this
const { dispatch, commit } = this
// bind 以下两个函数上 this 上
// 便于 this.$store.dispatch
this.dispatch = function boundDispatch (type, payload) {
return dispatch.call(store, type, payload)
}
this.commit = function boundCommit (type, payload, options) {
return commit.call(store, type, payload, options)
}
}

接下来看 this._modules 的过程,以 以下代码为例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
复制代码const moduleA = {
state: { ... },
mutations: { ... },
actions: { ... },
getters: { ... }
}

const moduleB = {
state: { ... },
mutations: { ... },
actions: { ... }
}

const store = new Vuex.Store({
state: { ... },
modules: {
a: moduleA,
b: moduleB
}
})

对于以上代码,store 可以看成 root 。在第一次执行时,会初始化一个 rootModule,然后判断 root 中是否存在 modules 属性,然后递归注册 module 。对于 child 来说,会获取到他所属的 parent, 然后在 parent 中添加 module 。

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
40
41
42
43
44
45
46
复制代码export default class ModuleCollection {
constructor (rawRootModule) {
// register root module (Vuex.Store options)
this.register([], rawRootModule, false)
}
register (path, rawModule, runtime = true) {
// 开发环境断言
if (process.env.NODE_ENV !== 'production') {
assertRawModule(path, rawModule)
}
// 初始化 Module
const newModule = new Module(rawModule, runtime)
// 对于第一次初始化 ModuleCollection 时
// 会走第一个 if 条件,因为当前是 root
if (path.length === 0) {
this.root = newModule
} else {
// 获取当前 Module 的 parent
const parent = this.get(path.slice(0, -1))
// 添加 child,第一个参数是
// 当前 Module 的 key 值
parent.addChild(path[path.length - 1], newModule)
}

// 递归注册
if (rawModule.modules) {
forEachValue(rawModule.modules, (rawChildModule, key) => {
this.register(path.concat(key), rawChildModule, runtime)
})
}
}
}

export default class Module {
constructor (rawModule, runtime) {
this.runtime = runtime
// 用于存储 children
this._children = Object.create(null)
// 用于存储原始的 rawModule
this._rawModule = rawModule
const rawState = rawModule.state

// 用于存储 state
this.state = (typeof rawState === 'function' ? rawState() : rawState) || {}
}
}

installModule

接下来看 installModule 的实现

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
复制代码// installModule(this, state, [], this._modules.root)
function installModule (store, rootState, path, module, hot) {
// 判断是否为 rootModule
const isRoot = !path.length
// 获取 namespace,root 没有 namespace
// 对于 modules: {a: moduleA} 来说
// namespace = 'a/'
const namespace = store._modules.getNamespace(path)

// 为 namespace 缓存 module
if (module.namespaced) {
store._modulesNamespaceMap[namespace] = module
}

// 设置 state
if (!isRoot && !hot) {
// 以下逻辑就是给 store.state 添加属性
// 根据模块添加
// state: { xxx: 1, a: {...}, b: {...} }
const parentState = getNestedState(rootState, path.slice(0, -1))
const moduleName = path[path.length - 1]
store._withCommit(() => {
Vue.set(parentState, moduleName, module.state)
})
}
// 该方法其实是在重写 dispatch 和 commit 函数
// 你是否有疑问模块中的 dispatch 和 commit
// 是如何找到对应模块中的函数的
// 假如模块 A 中有一个名为 add 的 mutation
// 通过 makeLocalContext 函数,会将 add 变成
// a/add,这样就可以找到模块 A 中对应函数了
const local = module.context = makeLocalContext(store, namespace, path)

// 以下几个函数遍历,都是在
// 注册模块中的 mutation、action 和 getter
// 假如模块 A 中有名为 add 的 mutation 函数
// 在注册过程中会变成 a/add
module.forEachMutation((mutation, key) => {
const namespacedType = namespace + key
registerMutation(store, namespacedType, mutation, local)
})

module.forEachAction((action, key) => {
const type = action.root ? key : namespace + key
const handler = action.handler || action
registerAction(store, type, handler, local)
})

// 这里会生成一个 _wrappedGetters 属性
// 用于缓存 getter,便于下次使用
module.forEachGetter((getter, key) => {
const namespacedType = namespace + key
registerGetter(store, namespacedType, getter, local)
})

// 递归安装模块
module.forEachChild((child, key) => {
installModule(store, rootState, path.concat(key), child, hot)
})
}

resetStoreVM

接下来看 resetStoreVM 的实现,该属性实现了状态的响应式,并且将 _wrappedGetters 作为 computed 属性。

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
40
41
复制代码// resetStoreVM(this, state)
function resetStoreVM (store, state, hot) {
const oldVm = store._vm

// 设置 getters 属性
store.getters = {}
const wrappedGetters = store._wrappedGetters
const computed = {}
// 遍历 _wrappedGetters 属性
forEachValue(wrappedGetters, (fn, key) => {
// 给 computed 对象添加属性
computed[key] = () => fn(store)
// 重写 get 方法
// store.getters.xx 其实是访问了
// store._vm[xx]
// 也就是 computed 中的属性
Object.defineProperty(store.getters, key, {
get: () => store._vm[key],
enumerable: true // for local getters
})
})

// 使用 Vue 来保存 state 树
// 同时也让 state 变成响应式
const silent = Vue.config.silent
Vue.config.silent = true
// 当访问 store.state 时
// 其实是访问了 store._vm._data.?state
store._vm = new Vue({
data: {
?state: state
},
computed
})
Vue.config.silent = silent

// 确保只能通过 commit 的方式改变状态
if (store.strict) {
enableStrictMode(store)
}
}

常用 API

commit 解析

如果需要改变状态的话,一般都会使用 commit 去操作,接下来让我们来看看 commit 是如何实现状态的改变的

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
复制代码commit(_type, _payload, _options) {
// 检查传入的参数
const { type, payload, options } = unifyObjectStyle(
_type,
_payload,
_options
)

const mutation = { type, payload }
// 找到对应的 mutation 函数
const entry = this._mutations[type]
// 判断是否找到
if (!entry) {
if (process.env.NODE_ENV !== 'production') {
console.error(`[vuex] unknown mutation type: ${type}`)
}
return
}
// _withCommit 函数将 _committing
// 设置为 TRUE,保证在 strict 模式下
// 只能 commit 改变状态
this._withCommit(() => {
entry.forEach(function commitIterator(handler) {
// entry.push(function wrappedMutationHandler(payload) {
// handler.call(store, local.state, payload)
// })
// handle 就是 wrappedMutationHandler 函数
// wrappedMutationHandler 内部就是调用
// 对于的 mutation 函数
handler(payload)
})
})
// 执行订阅函数
this._subscribers.forEach(sub => sub(mutation, this.state))
}

dispatch 解析

如果需要异步改变状态,就需要通过 dispatch 的方式去实现。在 dispatch 调用的 commit 函数都是重写过的,会找到模块内的 mutation 函数。

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
复制代码dispatch(_type, _payload) {
// 检查传入的参数
const { type, payload } = unifyObjectStyle(_type, _payload)

const action = { type, payload }
// 找到对于的 action 函数
const entry = this._actions[type]
// 判断是否找到
if (!entry) {
if (process.env.NODE_ENV !== 'production') {
console.error(`[vuex] unknown action type: ${type}`)
}
return
}
// 触发订阅函数
this._actionSubscribers.forEach(sub => sub(action, this.state))

// 在注册 action 的时候,会将函数返回值
// 处理成 promise,当 promise 全部
// resolve 后,就会执行 Promise.all
// 里的函数
return entry.length > 1
? Promise.all(entry.map(handler => handler(payload)))
: entry[0](payload)
}

各种语法糖

在组件中,如果想正常使用 Vuex 的功能,经常需要这样调用 this.$store.state.xxx 的方式,引来了很多的不便。为此,Vuex 引入了语法糖的功能,让我们可以通过简单的方式来实现上述的功能。以下以 mapState 为例,其他的几个 map 都是差不多的原理,就不一一解析了。

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
40
41
42
43
44
45
46
47
48
复制代码function normalizeNamespace(fn) {
return (namespace, map) => {
// 函数作用很简单
// 根据参数生成 namespace
if (typeof namespace !== 'string') {
map = namespace
namespace = ''
} else if (namespace.charAt(namespace.length - 1) !== '/') {
namespace += '/'
}
return fn(namespace, map)
}
}
// 执行 mapState 就是执行
// normalizeNamespace 返回的函数
export const mapState = normalizeNamespace((namespace, states) => {
const res = {}
// normalizeMap([1, 2, 3]) => [ { key: 1, val: 1 }, { key: 2, val: 2 }, { key: 3, val: 3 } ]
// normalizeMap({a: 1, b: 2, c: 3}) => [ { key: 'a', val: 1 }, { key: 'b', val: 2 }, { key: 'c', val: 3 } ]
// function normalizeMap(map) {
// return Array.isArray(map)
// ? map.map(key => ({ key, val: key }))
// : Object.keys(map).map(key => ({ key, val: map[key] }))
// }
// states 参数可以参入数组或者对象类型
normalizeMap(states).forEach(({ key, val }) => {
res[key] = function mappedState() {
let state = this.$store.state
let getters = this.$store.getters
if (namespace) {
// 获得对应的模块
const module = getModuleByNamespace(this.$store, 'mapState', namespace)
if (!module) {
return
}
state = module.context.state
getters = module.context.getters
}
// 返回 State
return typeof val === 'function'
? val.call(this, state, getters)
: state[val]
}
// mark vuex getter for devtools
res[key].vuex = true
})
return res
})

最后

以上是 Vue 的源码解析,虽然 Vuex 的整体代码并不多,但是却是个值得阅读的项目。如果你在阅读的过程中有什么疑问或者发现了我的错误,欢迎在评论中讨论。

如果你想学习到更多的前端知识、面试技巧或者一些我个人的感悟,可以关注我的公众号一起学习

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

一文读懂什么是Java中的自动拆装箱

发表于 2018-09-04

本文主要介绍Java中的自动拆箱与自动装箱的有关知识。

基本数据类型

基本类型,或者叫做内置类型,是Java中不同于类(Class)的特殊类型。它们是我们编程中使用最频繁的类型。

Java是一种强类型语言,第一次申明变量必须说明数据类型,第一次变量赋值称为变量的初始化。

Java基本类型共有八种,基本类型可以分为三类:

字符类型char

布尔类型boolean

数值类型byte、short、int、long、float、double。

数值类型又可以分为整数类型byte、short、int、long和浮点数类型float、double。

Java中的数值类型不存在无符号的,它们的取值范围是固定的,不会随着机器硬件环境或者操作系统的改变而改变。

实际上,Java中还存在另外一种基本类型void,它也有对应的包装类 java.lang.Void,不过我们无法直接对它们进行操作。

基本数据类型有什么好处

我们都知道在Java语言中,new一个对象是存储在堆里的,我们通过栈中的引用来使用这些对象;所以,对象本身来说是比较消耗资源的。

对于经常用到的类型,如int等,如果我们每次使用这种变量的时候都需要new一个Java对象的话,就会比较笨重。所以,和C++一样,Java提供了基本数据类型,这种数据的变量不需要使用new创建,他们不会在堆上创建,而是直接在栈内存中存储,因此会更加高效。

整型的取值范围

Java中的整型主要包含byte、short、int和long这四种,表示的数字范围也是从小到大的,之所以表示范围不同主要和他们存储数据时所占的字节数有关。

先来个简答的科普,1字节=8位(bit)。java中的整型属于有符号数。

先来看计算中8bit可以表示的数字:

1
2
复制代码最小值:10000000 (-128)(-2^7)
最大值:01111111(127)(2^7-1)

整型的这几个类型中,

  • byte:byte用1个字节来存储,范围为-128(-2^7)到127(2^7-1),在变量初始化的时候,byte类型的默认值为0。
  • short:short用2个字节存储,范围为-32,768 (-2^15)到32,767 (2^15-1),在变量初始化的时候,short类型的默认值为0,一般情况下,因为Java本身转型的原因,可以直接写为0。
  • int:int用4个字节存储,范围为-2,147,483,648 (-2^31)到2,147,483,647 (2^31-1),在变量初始化的时候,int类型的默认值为0。
  • long:long用8个字节存储,范围为-9,223,372,036,854,775,808 (-2^63)到9,223,372,036, 854,775,807 (2^63-1),在变量初始化的时候,long类型的默认值为0L或0l,也可直接写为0。

超出范围怎么办

上面说过了,整型中,每个类型都有一定的表示范围,但是,在程序中有些计算会导致超出表示范围,即溢出。如以下代码:

1
2
3
4
5
复制代码    int i = Integer.MAX_VALUE;
int j = Integer.MAX_VALUE;

int k = i + j;
System.out.println("i (" + i + ") + j (" + j + ") = k (" + k + ")");

输出结果:i (2147483647) + j (2147483647) = k (-2)

**这就是发生了溢出,溢出的时候并不会抛异常,也没有任何提示。**所以,在程序中,使用同类型的数据进行运算的时候,一定要注意数据溢出的问题。

包装类型

Java语言是一个面向对象的语言,但是Java中的基本数据类型却是不面向对象的,这在实际使用时存在很多的不便,为了解决这个不足,在设计类时为每个基本数据类型设计了一个对应的类进行代表,这样八个和基本数据类型对应的类统称为包装类(Wrapper Class)。

包装类均位于java.lang包,包装类和基本数据类型的对应关系如下表所示

基本数据类型 包装类
byte Byte
boolean Boolean
short Short
char Character
int Integer
long Long
float Float
double Double

在这八个类名中,除了Integer和Character类以后,其它六个类的类名和基本数据类型一致,只是类名的第一个字母大写即可。

为什么需要包装类

很多人会有疑问,既然Java中为了提高效率,提供了八种基本数据类型,为什么还要提供包装类呢?

这个问题,其实前面已经有了答案,因为Java是一种面向对象语言,很多地方都需要使用对象而不是基本数据类型。比如,在集合类中,我们是无法将int 、double等类型放进去的。因为集合的容器要求元素是Object类型。

为了让基本类型也具有对象的特征,就出现了包装类型,它相当于将基本类型“包装起来”,使得它具有了对象的性质,并且为其添加了属性和方法,丰富了基本类型的操作。

拆箱与装箱

那么,有了基本数据类型和包装类,肯定有些时候要在他们之间进行转换。比如把一个基本数据类型的int转换成一个包装类型的Integer对象。

我们认为包装类是对基本类型的包装,所以,把基本数据类型转换成包装类的过程就是打包装,英文对应于boxing,中文翻译为装箱。

反之,把包装类转换成基本数据类型的过程就是拆包装,英文对应于unboxing,中文翻译为拆箱。

在Java SE5之前,要进行装箱,可以通过以下代码:

1
复制代码Integer i = new Integer(10);

自动拆箱与自动装箱

在Java SE5中,为了减少开发人员的工作,Java提供了自动拆箱与自动装箱功能。

自动装箱: 就是将基本数据类型自动转换成对应的包装类。

自动拆箱:就是将包装类自动转换成对应的基本数据类型。

1
2
复制代码Integer i =10;  //自动装箱
int b= i; //自动拆箱

Integer i=10 可以替代 Integer i = new Integer(10);,这就是因为Java帮我们提供了自动装箱的功能,不需要开发者手动去new一个Integer对象。

自动装箱与自动拆箱的实现原理

既然Java提供了自动拆装箱的能力,那么,我们就来看一下,到底是什么原理,Java是如何实现的自动拆装箱功能。

我们有以下自动拆装箱的代码:

1
2
3
4
复制代码public static  void main(String[]args){
Integer integer=1; //装箱
int i=integer; //拆箱
}

对以上代码进行反编译后可以得到以下代码:

1
2
3
4
复制代码public static  void main(String[]args){
Integer integer=Integer.valueOf(1);
int i=integer.intValue();
}

从上面反编译后的代码可以看出,int的自动装箱都是通过Integer.valueOf()方法来实现的,Integer的自动拆箱都是通过integer.intValue来实现的。如果读者感兴趣,可以试着将八种类型都反编译一遍 ,你会发现以下规律:

自动装箱都是通过包装类的valueOf()方法来实现的.自动拆箱都是通过包装类对象的xxxValue()来实现的。

哪些地方会自动拆装箱

我们了解过原理之后,在来看一下,什么情况下,Java会帮我们进行自动拆装箱。前面提到的变量的初始化和赋值的场景就不介绍了,那是最简单的也最容易理解的。

我们主要来看一下,那些可能被忽略的场景。

场景一、将基本数据类型放入集合类

我们知道,Java中的集合类只能接收对象类型,那么以下代码为什么会不报错呢?

1
2
3
4
复制代码List<Integer> li = new ArrayList<>();
for (int i = 1; i < 50; i ++){
li.add(i);
}

将上面代码进行反编译,可以得到以下代码:

1
2
3
4
复制代码List<Integer> li = new ArrayList<>();
for (int i = 1; i < 50; i += 2){
li.add(Integer.valueOf(i));
}

以上,我们可以得出结论,当我们把基本数据类型放入集合类中的时候,会进行自动装箱。

场景二、包装类型和基本类型的大小比较

有没有人想过,当我们对Integer对象与基本类型进行大小比较的时候,实际上比较的是什么内容呢?看以下代码:

1
2
3
4
复制代码    Integer a=1;
System.out.println(a==1?"等于":"不等于");
Boolean bool=false;
System.out.println(bool?"真":"假");

对以上代码进行反编译,得到以下代码:

1
2
3
4
复制代码    Integer a=1;
System.out.println(a.intValue()==1?"等于":"不等于");
Boolean bool=false;
System.out.println(bool.booleanValue?"真":"假");

可以看到,包装类与基本数据类型进行比较运算,是先将包装类进行拆箱成基本数据类型,然后进行比较的。

场景三、包装类型的运算

有没有人想过,当我们对Integer对象进行四则运算的时候,是如何进行的呢?看以下代码:

1
2
3
4
复制代码    Integer i = 10;
Integer j = 20;

System.out.println(i+j);

反编译后代码如下:

1
2
3
复制代码    Integer i = Integer.valueOf(10);
Integer j = Integer.valueOf(20);
System.out.println(i.intValue() + j.intValue());

我们发现,两个包装类型之间的运算,会被自动拆箱成基本类型进行。

场景四、三目运算符的使用

这是很多人不知道的一个场景,作者也是一次线上的血淋淋的Bug发生后才了解到的一种案例。看一个简单的三目运算符的代码:

1
2
3
4
复制代码boolean flag = true;
Integer i = 0;
int j = 1;
int k = flag ? i : j;

很多人不知道,其实在int k = flag ? i : j;这一行,会发生自动拆箱。反编译后代码如下:

1
2
3
4
5
复制代码boolean flag = true;
Integer i = Integer.valueOf(0);
int j = 1;
int k = flag ? i.intValue() : j;
System.out.println(k);

这其实是三目运算符的语法规范。当第二,第三位操作数分别为基本类型和对象时,其中的对象就会拆箱为基本类型进行操作。

因为例子中,flag ? i : j;片段中,第二段的i是一个包装类型的对象,而第三段的j是一个基本类型,所以会对包装类进行自动拆箱。如果这个时候i的值为null,那么久会发生NPE。(自动拆箱导致空指针异常)

场景五、函数参数与返回值

这个比较容易理解,直接上代码了:

1
2
3
4
5
6
7
8
复制代码//自动拆箱
public int getNum1(Integer num) {
return num;
}
//自动装箱
public Integer getNum2(int num) {
return num;
}

自动拆装箱与缓存

Java SE的自动拆装箱还提供了一个和缓存有关的功能,我们先来看以下代码,猜测一下输出结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
复制代码public static void main(String... strings) {

Integer integer1 = 3;
Integer integer2 = 3;

if (integer1 == integer2)
System.out.println("integer1 == integer2");
else
System.out.println("integer1 != integer2");

Integer integer3 = 300;
Integer integer4 = 300;

if (integer3 == integer4)
System.out.println("integer3 == integer4");
else
System.out.println("integer3 != integer4");

}

我们普遍认为上面的两个判断的结果都是false。虽然比较的值是相等的,但是由于比较的是对象,而对象的引用不一样,所以会认为两个if判断都是false的。在Java中,==比较的是对象应用,而equals比较的是值。所以,在这个例子中,不同的对象有不同的引用,所以在进行比较的时候都将返回false。奇怪的是,这里两个类似的if条件判断返回不同的布尔值。

上面这段代码真正的输出结果:

1
2
复制代码integer1 == integer2
integer3 != integer4

原因就和Integer中的缓存机制有关。在Java 5中,在Integer的操作上引入了一个新功能来节省内存和提高性能。整型对象通过使用相同的对象引用实现了缓存和重用。

适用于整数值区间-128 至 +127。

只适用于自动装箱。使用构造函数创建对象不适用。

具体的代码实现可以阅读Java中整型的缓存机制一文,这里不再阐述。

我们只需要知道,当需要进行自动装箱时,如果数字在-128至127之间时,会直接使用缓存中的对象,而不是重新创建一个对象。

其中的javadoc详细的说明了缓存支持-128到127之间的自动装箱过程。最大值127可以通过-XX:AutoBoxCacheMax=size修改。

实际上这个功能在Java 5中引入的时候,范围是固定的-128 至 +127。后来在Java 6中,可以通过java.lang.Integer.IntegerCache.high设置最大值。

这使我们可以根据应用程序的实际情况灵活地调整来提高性能。到底是什么原因选择这个-128到127范围呢?因为这个范围的数字是最被广泛使用的。 在程序中,第一次使用Integer的时候也需要一定的额外时间来初始化这个缓存。

在Boxing Conversion部分的Java语言规范(JLS)规定如下:

如果一个变量p的值是:

1
2
3
4
5
复制代码-128至127之间的整数(§3.10.1)

true 和 false的布尔值 (§3.10.3)

‘\u0000’至 ‘\u007f’之间的字符(§3.10.4)

范围内的时,将p包装成a和b两个对象时,可以直接使用a==b判断a和b的值是否相等。

自动拆装箱带来的问题

当然,自动拆装箱是一个很好的功能,大大节省了开发人员的精力,不再需要关心到底什么时候需要拆装箱。但是,他也会引入一些问题。

包装对象的数值比较,不能简单的使用==,虽然-128到127之间的数字可以,但是这个范围之外还是需要使用equals比较。

前面提到,有些场景会进行自动拆装箱,同时也说过,由于自动拆箱,如果包装类对象为null,那么自动拆箱时就有可能抛出NPE。

如果一个for循环中有大量拆装箱操作,会浪费很多资源。

参考资料

Java的自动拆装箱

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

HTTP2 详解

发表于 2018-08-31

原文地址: blog.wangriyu.wang/2018/05-HTT…

维基百科关于 HTTP/2 的介绍,可以看下定义和发展历史:

Wiki

RFC 7540 定义了 HTTP/2 的协议规范和细节,本文的细节主要来自此文档,建议先看一遍本文,再回过头来照着协议大致过一遍 RFC,如果想深入某些细节再仔细翻看 RFC

RFC7540

Why use it ?

HTTP/1.1 存在的问题:

1、TCP 连接数限制

对于同一个域名,浏览器最多只能同时创建 6~8 个 TCP 连接 (不同浏览器不一样)。为了解决数量限制,出现了 域名分片 技术,其实就是资源分域,将资源放在不同域名下 (比如二级子域名下),这样就可以针对不同域名创建连接并请求,以一种讨巧的方式突破限制,但是滥用此技术也会造成很多问题,比如每个 TCP 连接本身需要经过 DNS 查询、三步握手、慢启动等,还占用额外的 CPU 和内存,对于服务器来说过多连接也容易造成网络拥挤、交通阻塞等,对于移动端来说问题更明显,可以参考这篇文章: Why Domain Sharding is Bad News for Mobile Performance and Users

image

image

在图中可以看到新建了六个 TCP 连接,每次新建连接 DNS 解析需要时间(几 ms 到几百 ms 不等)、TCP 慢启动也需要时间、TLS 握手又要时间,而且后续请求都要等待队列调度

2、线头阻塞 (Head Of Line Blocking) 问题

每个 TCP 连接同时只能处理一个请求 - 响应,浏览器按 FIFO 原则处理请求,如果上一个响应没返回,后续请求 - 响应都会受阻。为了解决此问题,出现了 管线化 - pipelining 技术,但是管线化存在诸多问题,比如第一个响应慢还是会阻塞后续响应、服务器为了按序返回相应需要缓存多个响应占用更多资源、浏览器中途断连重试服务器可能得重新处理多个请求、还有必须客户端 - 代理 - 服务器都支持管线化

3、Header 内容多,而且每次请求 Header 不会变化太多,没有相应的压缩传输优化方案

4、为了尽可能减少请求数,需要做合并文件、雪碧图、资源内联等优化工作,但是这无疑造成了单个请求内容变大延迟变高的问题,且内嵌的资源不能有效地使用缓存机制

5、明文传输不安全

HTTP2 的优势:

1、二进制分帧层 (Binary Framing Layer)

帧是数据传输的最小单位,以二进制传输代替原本的明文传输,原本的报文消息被划分为更小的数据帧:

image

h1 和 h2 的报文对比:

image

image

图中 h2 的报文是重组解析过后的,可以发现一些头字段发生了变化,而且所有头字段均小写

strict-transport-security: max-age=63072000; includeSubdomains 字段是服务器开启 HSTS 策略,让浏览器强制使用 HTTPS 进行通信,可以减少重定向造成的额外请求和会话劫持的风险

服务器开启 HSTS 的方法是: 以 nginx 为例,在相应站点的 server 模块中添加 add_header Strict-Transport-Security "max-age=63072000; includeSubdomains" always; 即可

在 Chrome 中可以打开 chrome://net-internals/#hsts 进入浏览器的 HSTS 管理界面,可以增加 / 删除 / 查询 HSTS 记录,比如下图:

image

在 HSTS 有效期内且 TLS 证书仍有效,浏览器访问 blog.wangriyu.wang 会自动加上 https:// ,而不需要做一次查询重定向到 https

关于帧详见: How does it work ?- 帧

2、多路复用 (MultiPlexing)

在一个 TCP 连接上,我们可以向对方不断发送帧,每帧的 stream identifier 的标明这一帧属于哪个流,然后在对方接收时,根据 stream identifier 拼接每个流的所有帧组成一整块数据。
把 HTTP/1.1 每个请求都当作一个流,那么多个请求变成多个流,请求响应数据分成多个帧,不同流中的帧交错地发送给对方,这就是 HTTP/2 中的多路复用。

流的概念实现了单连接上多请求 - 响应并行,解决了线头阻塞的问题,减少了 TCP 连接数量和 TCP 连接慢启动造成的问题

所以 http2 对于同一域名只需要创建一个连接,而不是像 http/1.1 那样创建 6~8 个连接:

image

image

关于流详见: How does it work ?- 流

3、服务端推送 (Server Push)

浏览器发送一个请求,服务器主动向浏览器推送与这个请求相关的资源,这样浏览器就不用发起后续请求。

Server-Push 主要是针对资源内联做出的优化,相较于 http/1.1 资源内联的优势:

  • 客户端可以缓存推送的资源
  • 客户端可以拒收推送过来的资源
  • 推送资源可以由不同页面共享
  • 服务器可以按照优先级推送资源

关于服务端推送详见: How does it work ?- Server-Push

4、Header 压缩 (HPACK)

使用 HPACK 算法来压缩首部内容

关于 HPACK 详见: How does it work ?- HPACK

5、应用层的重置连接

对于 HTTP/1 来说,是通过设置 tcp segment 里的 reset flag 来通知对端关闭连接的。这种方式会直接断开连接,下次再发请求就必须重新建立连接。HTTP/2 引入 RST_STREAM 类型的 frame,可以在不断开连接的前提下取消某个 request 的 stream,表现更好。

6、请求优先级设置

HTTP/2 里的每个 stream 都可以设置依赖 (Dependency) 和权重,可以按依赖树分配优先级,解决了关键请求被阻塞的问题

7、流量控制

每个 http2 流都拥有自己的公示的流量窗口,它可以限制另一端发送数据。对于每个流来说,两端都必须告诉对方自己还有足够的空间来处理新的数据,而在该窗口被扩大前,另一端只被允许发送这么多数据。

关于流量控制详见: How does it work ?- 流量控制

8、HTTP/1 的几种优化可以弃用

合并文件、内联资源、雪碧图、域名分片对于 HTTP/2 来说是不必要的,使用 h2 尽可能将资源细粒化,文件分解地尽可能散,不用担心请求数多

How does it work ?

帧 - Frame

帧的结构

所有帧都是一个固定的 9 字节头部 (payload 之前) 跟一个指定长度的负载 (payload):

1
2
3
4
5
6
7
8
9
复制代码+-----------------------------------------------+
| Length (24) |
+---------------+---------------+---------------+
| Type (8) | Flags (8) |
+-+-------------+---------------+-------------------------------+
|R| Stream Identifier (31) |
+=+=============================================================+
| Frame Payload (0...) ...
+---------------------------------------------------------------+
  • Length 代表整个 frame 的长度,用一个 24 位无符号整数表示。除非接收者在 SETTINGS_MAX_FRAME_SIZE 设置了更大的值 (大小可以是 2^14(16384) 字节到 2^24-1(16777215) 字节之间的任意值),否则数据长度不应超过 2^14(16384) 字节。头部的 9 字节不算在这个长度里
  • Type 定义 frame 的类型,用 8 bits 表示。帧类型决定了帧主体的格式和语义,如果 type 为 unknown 应该忽略或抛弃。
  • Flags 是为帧类型相关而预留的布尔标识。标识对于不同的帧类型赋予了不同的语义。如果该标识对于某种帧类型没有定义语义,则它必须被忽略且发送的时候应该赋值为 (0x0)
  • R 是一个保留的比特位。这个比特的语义没有定义,发送时它必须被设置为 (0x0), 接收时需要忽略。
  • Stream Identifier 用作流控制,用 31 位无符号整数表示。客户端建立的 sid 必须为奇数,服务端建立的 sid 必须为偶数,值 (0x0) 保留给与整个连接相关联的帧 (连接控制消息),而不是单个流
  • Frame Payload 是主体内容,由帧类型决定

共分为十种类型的帧:

  • HEADERS: 报头帧 (type=0x1),用来打开一个流或者携带一个首部块片段
  • DATA: 数据帧 (type=0x0),装填主体信息,可以用一个或多个 DATA 帧来返回一个请求的响应主体
  • PRIORITY: 优先级帧 (type=0x2),指定发送者建议的流优先级,可以在任何流状态下发送 PRIORITY 帧,包括空闲 (idle) 和关闭 (closed) 的流
  • RST_STREAM: 流终止帧 (type=0x3),用来请求取消一个流,或者表示发生了一个错误,payload 带有一个 32 位无符号整数的错误码 (Error Codes),不能在处于空闲 (idle) 状态的流上发送 RST_STREAM 帧
  • SETTINGS: 设置帧 (type=0x4),设置此 连接 的参数,作用于整个连接
  • PUSH_PROMISE: 推送帧 (type=0x5),服务端推送,客户端可以返回一个 RST_STREAM 帧来选择拒绝推送的流
  • PING: PING 帧 (type=0x6),判断一个空闲的连接是否仍然可用,也可以测量最小往返时间 (RTT)
  • GOAWAY: GOWAY 帧 (type=0x7),用于发起关闭连接的请求,或者警示严重错误。GOAWAY 会停止接收新流,并且关闭连接前会处理完先前建立的流
  • WINDOW_UPDATE: 窗口更新帧 (type=0x8),用于执行流量控制功能,可以作用在单独某个流上 (指定具体 Stream Identifier) 也可以作用整个连接 (Stream Identifier 为 0x0),只有 DATA 帧受流量控制影响。初始化流量窗口后,发送多少负载,流量窗口就减少多少,如果流量窗口不足就无法发送,WINDOW_UPDATE 帧可以增加流量窗口大小
  • CONTINUATION: 延续帧 (type=0x9),用于继续传送首部块片段序列,见 首部的压缩与解压缩

DATA 帧格式

1
2
3
4
5
6
7
复制代码 +---------------+
|Pad Length? (8)|
+---------------+-----------------------------------------------+
| Data (*) ...
+---------------------------------------------------------------+
| Padding (*) ...
+---------------------------------------------------------------+
  • Pad Length: ? 表示此字段的出现时有条件的,需要设置相应标识 (set flag),指定 Padding 长度,存在则代表 PADDING flag 被设置
  • Data: 传递的数据,其长度上限等于帧的 payload 长度减去其他出现的字段长度
  • Padding: 填充字节,没有具体语义,发送时必须设为 0,作用是混淆报文长度,与 TLS 中 CBC 块加密类似,详见 httpwg.org/specs/rfc75…

DATA 帧有如下标识 (flags):

  • END_STREAM: bit 0 设为 1 代表当前流的最后一帧
  • PADDED: bit 3 设为 1 代表存在 Padding

例子:

image

image

image

HEADERS 帧格式

1
2
3
4
5
6
7
8
9
10
11
复制代码 +---------------+
|Pad Length? (8)|
+-+-------------+-----------------------------------------------+
|E| Stream Dependency? (31) |
+-+-------------+-----------------------------------------------+
| Weight? (8) |
+-+-------------+-----------------------------------------------+
| Header Block Fragment (*) ...
+---------------------------------------------------------------+
| Padding (*) ...
+---------------------------------------------------------------+
  • Pad Length: 指定 Padding 长度,存在则代表 PADDING flag 被设置
  • E: 一个比特位声明流的依赖性是否是排他的,存在则代表 PRIORITY flag 被设置
  • Stream Dependency: 指定一个 stream identifier,代表当前流所依赖的流的 id,存在则代表 PRIORITY flag 被设置
  • Weight: 一个无符号 8 为整数,代表当前流的优先级权重值 (1~256),存在则代表 PRIORITY flag 被设置
  • Header Block Fragment: header 块片段
  • Padding: 填充字节,没有具体语义,作用与 DATA 的 Padding 一样,存在则代表 PADDING flag 被设置

HEADERS 帧有以下标识 (flags):

  • END_STREAM: bit 0 设为 1 代表当前 header 块是发送的最后一块,但是带有 END_STREAM 标识的 HEADERS 帧后面还可以跟 CONTINUATION 帧 (这里可以把 CONTINUATION 看作 HEADERS 的一部分)
  • END_HEADERS: bit 2 设为 1 代表 header 块结束
  • PADDED: bit 3 设为 1 代表 Pad 被设置,存在 Pad Length 和 Padding
  • PRIORITY: bit 5 设为 1 表示存在 Exclusive Flag (E), Stream Dependency, 和 Weight

例子:

image

image

首部的压缩与解压缩

HTTP/2 里的首部字段也是一个键具有一个或多个值。这些首部字段用于 HTTP 请求和响应消息,也用于服务端推送操作。

首部列表 (Header List) 是零个或多个首部字段 (Header Field) 的集合。当通过连接传送时,首部列表通过压缩算法(即下文 HPACK) 序列化成首部块 (Header Block)。然后,序列化的首部块又被划分成一个或多个叫做首部块片段 (Header Block Fragment) 的字节序列,并通过 HEADERS、PUSH_PROMISE,或者 CONTINUATION 帧进行有效负载传送。

Cookie 首部字段需要 HTTP 映射特殊对待,见 8.1.2.5. Compressing the Cookie Header Field

一个完整的首部块有两种可能

  • 一个 HEADERS 帧或 PUSH_PROMISE 帧加上设置 END_HEADERS flag
  • 一个未设置 END_HEADERS flag 的 HEADERS 帧或 PUSH_PROMISE 帧,加上多个 CONTINUATION 帧,其中最后一个 CONTINUATION 帧设置 END_HEADERS flag

必须将首部块作为连续的帧序列传送,不能插入任何其他类型或其他流的帧。尾帧设置 END_HEADERS 标识代表首部块结束,这让首部块在逻辑上等价于一个单独的帧。接收端连接片段重组首部块,然后解压首部块重建首部列表。

image

SETTINGS 帧格式

httpwg.org/specs/rfc75…

一个 SETTINGS 帧的 payload 由零个或多个参数组成,每个参数的形式如下:

1
2
3
4
5
复制代码 +-------------------------------+
| Identifier (16) |
+-------------------------------+-------------------------------+
| Value (32) |
+---------------------------------------------------------------+
  • Identifier: 代表参数类型,比如 SETTINGS_HEADER_TABLE_SIZE 是 0x1
  • Value: 相应参数的值

在建立连接开始时双方都要发送 SETTINGS 帧以表明自己期许对方应做的配置,对方接收后同意配置参数便返回带有 ACK 标识的空 SETTINGS 帧表示确认,而且连接后任意时刻任意一方也都可能再发送 SETTINGS 帧调整,SETTINGS 帧中的参数会被最新接收到的参数覆盖

SETTINGS 帧作用于整个连接,而不是某个流,而且 SETTINGS 帧的 stream identifier 必须是 0x0,否则接收方会认为错误 (PROTOCOL_ERROR)。

SETTINGS 帧包含以下参数:

  • SETTINGS_HEADER_TABLE_SIZE (0x1): 用于解析 Header block 的 Header 压缩表的大小,初始值是 4096 字节
  • SETTINGS_ENABLE_PUSH (0x2): 可以关闭 Server Push,该值初始为 1,表示允许服务端推送功能
  • SETTINGS_MAX_CONCURRENT_STREAMS (0x3): 代表发送端允许接收端创建的最大流数目
  • SETTINGS_INITIAL_WINDOW_SIZE (0x4): 指明发送端所有流的流量控制窗口的初始大小,会影响所有流,该初始值是 2^16 - 1(65535) 字节,最大值是 2^31 - 1,如果超出最大值则会返回 FLOW_CONTROL_ERROR
  • SETTINGS_MAX_FRAME_SIZE (0x5): 指明发送端允许接收的最大帧负载的字节数,初始值是 2^14(16384) 字节,如果该值不在初始值 (2^14) 和最大值 (2^24 - 1) 之间,返回 PROTOCOL_ERROR
  • SETTINGS_MAX_HEADER_LIST_SIZE (0x6): 通知对端,发送端准备接收的首部列表大小的最大字节数。该值是基于未压缩的首部域大小,包括名称和值的字节长度,外加每个首部域的 32 字节的开销

SETTINGS 帧有以下标识 (flags):

  • ACK: bit 0 设为 1 代表已接收到对方的 SETTINGS 请求并同意设置,设置此标志的 SETTINGS 帧 payload 必须为空

例子:

image

实际抓包会发现 HTTP2 请求创建连接发送 SETTINGS 帧初始化前还有一个 Magic 帧 (建立 HTTP/2 请求的前言)。

在 HTTP/2 中,要求两端都要发送一个连接前言,作为对所使用协议的最终确认,并确定 HTTP/2 连接的初始设置,客户端和服务端各自发送不同的连接前言。

客户端的前言内容 (对应上图中编号 23 的帧) 包含一个内容为 PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n 的序列加上一个可以为空的 SETTINGS 帧,在收到 101(Switching Protocols) 响应 (代表 upgrade 成功) 后发送,或者作为 TLS 连接的第一个传输的应用数据。如果在预先知道服务端支持 HTTP/2 的情况下启用 HTTP/2 连接,客户端连接前言在连接建立时发送。

服务端的前言 (对应上图中编号 26 的帧) 包含一个可以为空的 SETTINGS 帧,在建立 HTTP/2 连接后作为第一帧发送。详见 HTTP/2 Connection Preface

发送完前言后双方都得向对方发送带有 ACK 标识的 SETTINGS 帧表示确认,对应上图中编号 29 和 31 的帧。

请求站点的全部帧序列,帧后面的数字代表所属流的 id,最后以 GOAWAY 帧关闭连接:

image

GOAWAY 帧带有最大的那个流标识符 (比如图中第 29 帧是最大流),对于发送方来说会继续处理完不大于此数字的流,然后再真正关闭连接

流 - Stream

流只是一个逻辑上的概念,代表 HTTP/2 连接中在客户端和服务器之间交换的独立双向帧序列,每个帧的 Stream Identifier 字段指明了它属于哪个流。

流有以下特性:

  • 单个 h2 连接可以包含多个并发的流,两端之间可以交叉发送不同流的帧
  • 流可以由客户端或服务器来单方面地建立和使用,或者共享
  • 流可以由任一方关闭
  • 帧在流上发送的顺序非常重要,最后接收方会把相同 Stream Identifier (同一个流) 的帧重新组装成完整消息报文

流的状态

image

注意图中的 send 和 recv 对象是指端点,不是指当前的流

idle

所有流以“空闲”状态开始。在这种状态下,没有任何帧的交换

其状态转换:

  • 发送或者接收一个 HEADERS 帧会使空闲 idle 流变成打开 open 状态,其中 HEADERS 帧的 Stream Identifier 字段指明了流 id。同样的 HEADERS 帧(带有 END_STREAM )也可以使一个流立即进入 half-closed 状态。
  • 服务端必须在一个打开 open 或者半关闭 (远端) half-closed(remote) 状态的流 (由客户端发起的) 上发送 PUSH_PROMISE 帧,其中 PUSH_PROMISE 帧的 Promised Stream ID 字段指定了一个预示的新流 (由服务端发起),
    • 在服务端该新流会由空闲 idle 状态进入被保留的 (本地) reserved(local) 状态
    • 在客户端该新流会由空闲 idle 状态进入被保留的 (远端) reserved(remote) 状态

在 3.2 - Starting HTTP/2 for “http” URIs 中介绍了一种特殊情况:

客户端发起一个 HTTP/1.1 请求,请求带有 Upgrade 机制,想创建 h2c 连接,服务端同意升级返回 101 响应。
升级之前发送的 HTTP/1.1 请求被分配一个流标识符 0x1,并被赋予默认优先级值。从客户端到服务端这个流 1 隐式地转为 “half-closed” 状态,因为作为 HTTP/1.1 请求它已经完成了。HTTP/2 连接开始后,流 1 用于响应。详细过程可以看下文的 HTTP/2 的协议协商机制

此状态下接收到 HEADERS 和 PRIORITY 以外的帧被视为 PROTOCOL_ERROR

状态图中 send PP 和 recv PP 是指连接的双方端点发送或接收了 PUSH_PROMISE,不是指某个空闲流发送或接收了 PUSH_PROMISE,是 PUSH_PROMISE 的出现促使一个预示的流从 idle 状态转为 reserved

在下文 Server-Push 中会详细介绍服务端推送的内容和 PUSH_PROMISE 的使用情形

reserved (local) / reserved (remote)

PUSH_PROMISE 预示的流由 idle 状态进入此状态,代表准备进行 Server push

其状态转换:

  • PUSH_PROMISE 帧预示的流的响应以 HEADERS 帧开始,这会立即将该流在服务端置于半关闭 (远端) half-closed(remote) 状态,在客户端置于半关闭 (本地) half-closed(local) 状态,最后以携带 END_STREAM 的帧结束,这会将流置于关闭 closed 状态
  • 任一端点都可以发送 RST_STREAM 帧来终止这个流,其状态由 reserved 转为 closed

reserved(local) 状态下的流不能发送 HEADERS、RST_STREAM、PRIORITY 以外的帧,接收到 RST_STREAM、PRIORITY、WINDOW_UPDATE 以外的帧被视为 PROTOCOL_ERROR

reserved(remote) 状态下的流不能发送 RST_STREAM、WINDOW_UPDATE、PRIORITY 以外的帧,接收到 HEADERS、RST_STREAM、PRIORITY 以外的帧被视为 PROTOCOL_ERROR

open

处于 open 状态的流可以被两个对端用来发送任何类型的帧

其状态转换:

  • 任一端都可以发送带有 END_STREAM 标识的帧,发送方会转入 half-closed(local) 状态;接收方会转入 half-closed(remote) 状态
  • 任一端都可以发送 RST_STREAM 帧,这会使流立即进入 closed 状态
half-closed (local)

流是双向的,半关闭表示这个流单向关闭了,local 代表本端到对端的方向关闭了,remote 代表对端到本端的方向关闭了

此状态下的流不能发送 WINDOW_UPDATE、PRIORITY、RST_STREAM 以外的帧

当此状态下的流收到带有 END_STREAM 标识的帧或者任一方发送 RST_STREAM 帧,会转为 closed 状态

此状态下的流收到的 PRIORITY 帧用以调整流的依赖关系顺序,可以看下文的流优先级

half-closed (remote)

此状态下的流不会被对端用于发送帧,执行流量控制的端点不再有义务维护接收方的流控制窗口。

一个端点在此状态的流上接收到 WINDOW_UPDATE、PRIORITY、RST_STREAM 以外的帧,应该响应一个 STREAM_CLOSED 流错误

此状态下的流可以被端点用于发送任意类型的帧,且此状态下该端点仍会观察流级别的流控制的限制

当此状态下的流发送带有 END_STREAM 标识的帧或者任一方发送 RST_STREAM 帧,会转为 closed 状态

closed

代表流已关闭

此状态下的流不能发送 PRIORITY 以外的帧,发送 PRIORITY 帧是调整那些依赖这个已关闭的流的流优先级,端点都应该处理 PRIORITY 帧,尽管如果该流从依赖关系树中移除了也可以忽略优先级帧

此状态下在收到带有 END_STREAM 标识的 DATA 或 HEADERS 帧后的一小段时间内 (period) 仍可能接收到 WINDOW_UPDATE 或 RST_STREAM 帧,因为在远程对端接收并处理 RST_STREAM 或带有 END_STREAM 标志的帧之前,它可能会发送这些类型的帧。但是端点必须忽略接收到的 WINDOW_UPDATE 或 RST_STREAM

如果一个流发送了 RST_STREAM 帧后转入此状态,而对端接收到 RST_STREAM 帧时可能已经发送了或者处在发送队列中,这些帧是不可撤销的,发送 RST_STREAM 帧的端点必须忽略这些帧。

一个端点可以限制 period 的长短,在 period 内接受的帧会忽略,超出 period 的帧被视为错误。

一个端点发送了 RST_STREAM 帧后接收到流控制帧(比如 DATA),仍会计入流量窗口,即使这些帧会被忽略,因为对端肯定是在接收到 RST_STREAM 帧前发送的流控制帧,对端会认为流控制已生效

一个端点可能会在发送了 RST_STREAM 帧后收到 PUSH_PROMISE 帧,即便预示的流已经被重置 (reset),PUSH_PROMISE 帧也能使预示流变成 reserved 状态。因此,需要 RST_STREAM 来关闭一个不想要的预示流。

PRIORITY 帧可以被任意状态的流发送和接收,未知类型的帧会被忽略

流状态的转换

下面看两个例子来理解流状态:

image

(1)、Server 在 Client 发起的一个流上发送 PUSH_PROMISE 帧,其 Promised Stream ID 指定一个预示流用于后续推送,send PP 后这个预示流在服务端从 idle 状态转为 reserve(local) 状态,客户端 recv PP 后这个流从 idle 状态转为 reserve(remote) 状态

(2)(3)、此时预示流处于保留状态,客户端如果选择拒绝接受推送,可以发送 RST 帧关闭这个流;服务端如果此时出问题了也可以发送 RST 帧取消推送。不管哪一方发送或接收到 RST,此状态都转为 closed

(4)、没有出现重置说明推送仍有效,则服务端开始推送,首先发送的肯定是响应的 HEADERS 首部块,此时流状态转为半关闭 half-closed(remote);客户端接收到 HEADERS 后流状态转为半关闭 half-closed(local)

(5)(6)、半关闭状态下的流应该还会继续推送诸如 DATA 帧、CONTINUATION 帧这样的数据帧,如果这个过程碰到任一方发起重置,则流会关闭进入 closed 状态

(7)、如果一切顺利,资源随着数据帧响应完毕,最后一帧会带上 END_STREAM 标识代表这个流结束了,此时流转为 closed 状态

image

(1)、客户端发起请求,首先发送一个 HEADERS 帧,其 Stream Identifier 创建一个新流,此流从 idle 状态转为 open 状态

(2)(3)、如果客户端取消请求可以发送 RST 帧,服务端出错也可以发送 RST 帧,不管哪一方接收或发送 RST,流关闭进入 closed 状态;

(4)、如果请求结束(END_STREAM),流转为半关闭状态。假如是 GET 请求,一般 HEADERS 帧就是最后一帧,send H 后流会立即进入半关闭状态。假如是 POST 请求,待数据传完,最后一帧带上 END_STREAM 标识,流转为半关闭

(5)(6)、客户端半关闭后服务端开始返回响应,此时任一方接收或发送 RST,流关闭;

(7)、如果一切顺利,等待响应结束(END_STREAM),流关闭

流的标识符

流 ID 是 31 位无符号整数,客户端发起的流必须是奇数,服务端发起的流必须是偶数,0x0 保留为连接控制消息不能用于建立新流。

HTTP/1.1 Upgrade to HTTP/2 时响应的流 ID 是 0x1,在升级完成之后,流 0x1 在客户端会转为 half-closed (local) 状态,因此这种情况下客户端不能用 0x1 初始化一个流

新建立的流的 ID 必须大于所有已使用过的数字,接收到一个错误大小的 ID 应该返回 PROTOCOL_ERROR 响应

使用一个新流时隐式地关闭了对端发起的 ID 小于当前流的且处于 idle 状态的流,比如一个流发送一个 HEADERS 帧打开了 ID 为 7 的流,但还从未向 ID 为 5 的流发送过帧,则流 0x5 会在 0x7 发送完或接收完第一帧后转为 closed 状态

一个连接内的流 ID 不能重用

流的优先级

客户端可以通过 HEADERS 帧的 PRIORITY 信息指定一个新建立流的优先级,其他期间也可以发送 PRIORITY 帧调整流优先级

设置优先级的目的是为了让端点表达它所期望对端在并发的多个流之间如何分配资源的行为。更重要的是,当发送容量有限时,可以使用优先级来选择用于发送帧的流。

流可以被标记为依赖其他流,所依赖的流完成后再处理当前流。每个依赖 (dependency) 后都跟着一个权重 (weight),这一数字是用来确定依赖于相同的流的可分配可用资源的相对比例

流依赖(Stream Dependencies)

每个流都可以显示地依赖另一个流,包含依赖关系表示优先将资源分配给指定的流(上层节点)而不是依赖流

一个不依赖于其他流的流会指定 stream dependency 为 0x0 值,因为不存在的 0x0 流代表依赖树的根

一个依赖于其他流的流叫做依赖流,被依赖的流是当前流的父级。如果被依赖的流不在当前依赖树中(比如状态为 idle 的流),被依赖的流会使用一个默认优先级

当依赖一个流时,该流会添加进父级的依赖关系中,共享相同父级的依赖流不会相对于彼此进行排序,比如 B 和 C 依赖 A,新添加一个依赖流 D,BCD 的顺序是不固定的:

1
2
3
复制代码    A                 A
/ \ ==> /|\
B C B D C

独占标识 (exclusive) 允许插入一个新层级(新的依赖关系),独占标识导致该流成为父级的唯一依赖流,而其他依赖流变为其子级,比如同样插入一个新依赖流 E (带有 exclusive):

1
2
3
4
5
复制代码                      A
A |
/|\ ==> E
B D C /|\
B D C

在依赖关系树中,只有当一个依赖流所依赖的所有流(父级最高为 0x0 的链)被关闭或者无法继续在上面执行,这个依赖流才应该被分配资源

依赖权重

所有依赖流都会分配一个 1~256 权重值

相同父级的依赖流按权重比例分配资源,比如流 B 依赖于 A 且权重值为 4,流 C 依赖于 A 且权重值为 12,当 A 不再执行时,B 理论上能分配的资源只有 C 的三分之一

优先级调整 (Reprioritization)

使用 PRIORITY 帧可以调整流优先级

PRIORITY 帧内容与 HEADERS 帧的优先级模块相同:

1
2
3
4
5
复制代码 +-+-------------------------------------------------------------+
|E| Stream Dependency (31) |
+-+-------------+-----------------------------------------------+
| Weight (8) |
+-+-------------+
  • 如果父级重新设置了优先级,则依赖流会随其父级流一起移动。若调整优先级的流带有独占标识,会导致新的父流的所有子级依赖于这个流
  • 如果一个流调整为依赖自己的一个子级,则这个将被依赖的子级首先移至调整流的父级之下(即同一层),再移动那个调整流的整棵子树,移动的依赖关系保持其权重

看下面这个例子: 第一个图是初始关系树,现在 A 要调整为依赖 D,根据第二点,现将 D 移至 x 之下,再把 A 调整为 D 的子树(图 3),如果 A 调整时带有独占标识根据第一点 F 也归为 A 子级(图 4)

1
2
3
4
5
6
7
8
9
10
复制代码    x                x                x                 x
| / \ | |
A D A D D
/ \ / / \ / \ |
B C ==> F B C ==> F A OR A
/ \ | / \ /|\
D E E B C B C F
| | |
F E E
(intermediate) (non-exclusive) (exclusive)
流优先级的状态管理

当一个流从依赖树中移除,它的子级可以调整为依赖被关闭流的父级(应该就是连接上一层节点),新的依赖权重将根据关闭流的权重以及流自身的权重重新计算。

从依赖树中移除流会导致某些优先级信息丢失。资源在具有相同父级的流之间共享,这意味着如果这个集合中的某个流关闭或者阻塞,任何空闲容量将分配给最近的相邻流。然而,如果此集合的共有依赖(即父级节点)从树中移除,这些子流将与更上一层的流共享资源

一个例子: 流 A 和流 B 依赖相同父级节点,而流 C 和流 D 都依赖 A,在移除流 A 之前的一段时间内,A 和 D 都无法执行(可能任务阻塞了),则 C 会分配到 A 的所有资源;
如果 A 被移除出树了,A 的权重按比重新计算分配给 C 和 D,此时 D 仍旧阻塞,C 分配的资源相较之前变少了。对于同等的初始权重,C 获取到的可用资源是三分之一而不是二分之一(为什么是三分之一?文档中没有说明细节,权重如何重新分配也不太清楚,下面是按我的理解解释的)

X 的资源为 1,ABCD 初始权重均为 16,*号代表节点当前不可用,图一中 C 和 B 各占一半资源,而 A 移除后 CD 的权重重新分配变为 8,所以图二中 C 和 B 占比变为 1:2,R(C) 变为 1/3

1
2
3
4
5
6
7
8
9
10
11
12
复制代码          X(v:1.0)               X(v:1.0)
/ \ /|\
/ \ / | \
*A B ==> / | \
(w:16) (w:16) / | \
/ \ C *D B
/ \ (w:8)(w:8)(w:16)
C *D
(w:16) (w:16)


R(C)=16/(16+16)=1/2 ==> R(C)=8/(8+16)=1/3

可能向一个流创建依赖关系的优先级信息还在传输中,那个流就已经关闭了。如果一个依赖流的依赖指向没有相关优先级信息(即父节点无效),则这个依赖流会分配默认优先级,这可能会造成不理想的优先级,因为给流分配了不在预期的优先级。

为了避免上述问题,一个端点应该在流关闭后的一段时间内保留流的优先级调整状态信息,此状态保留时间越长,流被分配错误的或者默认的优先级可能性越低。

类似地,处于“空闲”状态的流可以被分配优先级或成为其他流的父节点。这允许在依赖关系树中创建分组节点,从而实现更灵活的优先级表达式。空闲流以默认优先级开始

流优先级状态信息的保留可能增加终端的负担,因此这种状态可以被限制。终端可能根据负荷来决定保留的额外的状态的数目;在高负荷下,可以丢弃额外的优先级状态来限制资源的任务。在极端情况下,终端甚至可以丢弃激活或者保留状态流的优先级信息。如果使用了固定的限制,终端应当至少保留跟 SETTINGS_MAX_CONCURRENT_STREAMS 设置一样大小的流状态

默认优先级

所有流都是初始为非独占地依赖于流 0x0。

Pushed 流初始依赖于相关的流(见 Server-Push)。

以上两种情况,流的权重都指定为 16。

Server-Push

PUSH_PROMISE 帧格式

1
2
3
4
5
6
7
8
9
复制代码 +---------------+
|Pad Length? (8)|
+-+-------------+-----------------------------------------------+
|R| Promised Stream ID (31) |
+-+-----------------------------+-------------------------------+
| Header Block Fragment (*) ...
+---------------------------------------------------------------+
| Padding (*) ...
+---------------------------------------------------------------+
  • Pad Length: 指定 Padding 长度,存在则代表 PADDING flag 被设置
  • R: 保留的1bit位
  • Promised Stream ID: 31 位的无符号整数,代表 PUSH_PROMISE 帧保留的流,对于发送者来说该流标识符必须是可用于下一个流的有效值
  • Header Block Fragment: 包含请求首部域的首部块片段
  • Padding: 填充字节,没有具体语义,作用与 DATA 的 Padding 一样,存在则代表 PADDING flag 被设置

PUSH_PROMISE 帧有以下标识 (flags):

  • END_HEADERS: bit 2 置 1 代表 header 块结束
  • PADDED: bit 3 置 1 代表 Pad 被设置,存在 Pad Length 和 Padding

Push 的过程

结合上文关于 Server-Push 的流状态转换

PUSH_PROMISE 帧只能在对端(客户端)发起的且流状态为 open 或者 half-closed (remote) 的流上发送

PUSH_PROMISE 帧准备推送的响应总是和来自于客户端的请求相关联。服务端在该请求所在的流上发送 PUSH_PROMISE 帧。PUSH_PROMISE 帧包含一个 Promised Stream ID,该流标识符是从服务端可用的流标识符里选出来的。

如果服务端收到了一个对文档的请求,该文档包含内嵌的指向多个图片文件的链接,且服务端选择向客户端推送那些额外的图片,那么在发送包含图片链接的 DATA 帧之前发送 PUSH_PROMISE 帧可以确保客户端在发现内嵌的链接之前,能够知道有一个资源将要被推送过来。同样地,如果服务端准备推送被首部块引用的响应 (比如,在 Link 首部字段 里的),在发送首部块之前发送一个 PUSH_PROMISE 帧,可以确保客户端不再请求那些资源

一旦客户端收到了 PUSH_PROMISE 帧,并选择接收被推送的响应,客户端就不应该为准备推送的响应发起任何请求,直到预示的流被关闭以后。

image

image

注意图中推送的四个资源各预示了一个流 (Promised Stream ID),而发送 PUSH_PROMISE 帧的还是在客户端发起的请求流 (Stream Identifier = 1) 上,客户端收到 PUSH_PROMISE 帧并选择接收便不会对这四个资源发起请求,之后服务端会发起预示的流然后推送资源相关的响应

不管出于什么原因,如果客户端决定不再从服务端接收准备推送的响应,或者如果服务端花费了太长时间准备发送被预示的响应,客户端可以发送一个 RST_STREAM 帧,该帧可以使用 CANCEL 或者 REFUSED_STEAM 码,并引用被推送的流标识符。

nginx 配置 Server-Push

server-push 需要服务端设置,并不是说浏览器发起请求,与此请求相关的资源服务端就会自动推送

以 nginx 为例,从版本 1.13.9 开始正式支持 hppt2 serverpush 功能,

在相应 server 或 location 模块中加入 http2_push 字段加上相对路径的文件即可在请求该资源时推送相关资源,比如我的博客设置如下,访问首页时有四个文件会由服务器主动推送过去而不需要客户端请求:

1
2
3
4
5
6
7
8
9
10
复制代码  server_name  blog.wangriyu.wang;
root /blog;
index index.html index.htm;

location = /index.html {
http2_push /css/style.css;
http2_push /js/main.js;
http2_push /img/yule.jpg;
http2_push /img/avatar.jpg;
}

通过浏览器控制台可以查看 Push 响应:

image

也可以用 nghttp 测试 push 响应 (* 号代表是服务端推送的):

image

上面 http2_push 的设置适合静态资源,服务端事先知道哪些文件是客户端需要的,然后选择性推送

假如是后台应用动态生成的文件(比如 json 文件),服务器事先不知道要推送什么,可以用 Link 响应头来做自动推送

在 server 模块中添加 http2_push_preload on;

1
2
3
4
5
复制代码  server_name  blog.wangriyu.wang;
root /blog;
index index.html index.htm;

http2_push_preload on;

然后设置响应头 (add_header) 或者后台程序生成数据文件返回时带上响应头 Link 标签,比如

1
复制代码Link: </style.css>; as=style; rel=preload, </main.js>; as=script; rel=preload, </image.jpg>; as=image; rel=preload

nginx 会根据 Link 响应头主动推送这些资源

更多nginx 官方介绍见 Introducing HTTP/2 Server Push with NGINX 1.13.9

Server-Push 潜在的问题

看了这篇文章 HTTP/2 中的 Server Push 讨论,发现 Server-Push 有个潜在的问题

Server-Push 满足条件时便会发起推送,可是客户端已经有缓存了想发送 RST 拒收,而服务器在收到 RST 之前已经推送资源了,虽然这部分推送无效但是肯定会占用带宽

比如我上面博客关于 http2_push 的配置,我每次打开首页服务器都会推送那四个文件,而实际上浏览器知道自己有缓存使用的也是本地缓存,也就是说本地缓存未失效的期间内,服务器的 Server-Push 只是起到了占用带宽的作用

当然实际上对我的小站点来说影响并不大,但是如果网站需要大量推送的话,需要考虑并测试 Server-Push 是否会影响用户的后续访问

另外服务端可以设置 Cookie 或者 Session 记录访问时间,然后之后的访问判断是否需要 Push;还有就是客户端可以限制 PUSH 流的数目,也可以设置一个很低的流量窗口来限制 PUSH 发送的数据大小

至于哪些资源需要推送,在《web 性能权威指南》中就提到几种策略,比如 Apache 的 mod_spdy 能够识别 X-Associated-Content 首部,当中列出了希望服务器推送的资源;另外网上有人已经做了基于 Referer 首部的中间件来处理 Server-Push;或者服务端能更智能的识别文档,根据当前流量决定是否推送或者推送那些资源。相信以后会有更多关于 Server-Push 的实现和应用

流量控制

多路复用的流会竞争 TCP 资源,进而导致流被阻塞。流控制机制确保同一连接上的流不会相互干扰。流量控制作用于单个流或整个连接。HTTP/2 通过使用 WINDOW_UPDATE 帧来提供流量控制。

流控制具有以下特征:

  • 流量控制是特定于连接的。两种级别的流量控制都位于单跳的端点之间,而不是整个端到端的路径。比如 server 前面有一个 front-end proxy 如 Nginx,这时就会有两个 connection,browser-Nginx, Nginx—server,flow control 分别作用于两个 connection。详情见: How is HTTP/2 hop-by-hop flow control accomplished? - stackoverflow
  • 流量控制是基于 WINDOW_UPDATE 帧的。接收方公布自己打算在每个流以及整个连接上分别接收多少字节。这是一个以信用为基础的方案。
  • 流量控制是有方向的,由接收者全面控制。接收方可以为每个流和整个连接设置任意的窗口大小。发送方必须尊重接收方设置的流量控制限制。客户方、服务端和中间代理作为接收方时都独立地公布各自的流量控制窗口,作为发送方时都遵守对端的流量控制设置。
  • 无论是新流还是整个连接,流量控制窗口的初始值是 65535 字节。
  • 帧的类型决定了流量控制是否适用于帧。目前,只有 DATA 帧会受流量控制影响,所有其它类型的帧并不消耗流量控制窗口的空间。这保证了重要的控制帧不会被流量控制阻塞。
  • 流量控制不能被禁用。
  • HTTP/2 只定义了 WINDOW_UPDATE 帧的格式和语义,并没有规定接收方如何决定何时发送帧、发送什么样的值,也没有规定发送方如何选择发送包。具体实现可以选择任何满足需求的算法。

WINDOW_UPDATE 帧格式

1
2
3
复制代码+-+-------------------------------------------------------------+
|R| Window Size Increment (31) |
+-+-------------------------------------------------------------+

Window Size Increment 表示除了现有的流量控制窗口之外,发送端还可以传送的字节数。取值范围是 1 到 2^31 - 1 字节。

WINDOW_UPDATE 帧可以是针对一个流或者是针对整个连接的。如果是前者,WINDOW_UPDATE 帧的流标识符指明了受影响的流;如果是后者,流标识符为 0 表示作用于整个连接。

流量控制功能只适用于被标识的、受流量控制影响的帧。文档定义的帧类型中,只有 DATA 帧受流量控制影响。除非接收端不能再分配资源去处理这些帧,否则不受流量控制影响的帧必须被接收并处理。如果接收端不能再接收帧了,可以响应一个 FLOW_CONTROL_ERROR 类型的流错误或者连接错误。

WINDOW_UPDATE 可以由发送过带有 END_STREAM 标志的帧的对端发送。这意味着接收端可能会在 half-closed (remote) 或者 closed 状态的流上收到 WINDOW_UPDATE 帧,接收端不能将其当做错误。

流量控制窗口

流量控制窗口是一个简单的整数值,指出了准许发送端传送的数据的字节数。窗口值衡量了接收端的缓存能力。

除非将其当做连接错误,否则当接收端收到 DATA 帧时,必须总是从流量控制窗口中减掉其长度(不包括帧头的长度,而且两个级别的控制窗口都要减)。即使帧有错误,这也是有必要的,因为发送端已经将该帧计入流量控制窗口,如果接收端没有这样做,发送端和接收端的流量控制窗口就会不一致。

发送端不能发送受流量控制影响的、其长度超出接收端告知的两种级别的流量控制窗口可用空间的帧。即使这两种级别的流量控制窗口都没有可用空间了,也可以发送长度为 0、设置了 END_STREAM 标志的帧(即空的 DATA 帧)。

当帧的接收端消耗了数据并释放了流量控制窗口的空间时,可以发送一个 WINDOW_UPDATE 帧。对于流级别和连接级别的流量控制窗口,需要分别发送 WINDOW_UPDATE 帧。

新建连接时,流和连接的初始窗口大小都是 2^16 - 1(65535) 字节。可以通过设置连接前言中 SETTINGS 帧的 SETTINGS_INITIAL_WINDOW_SIZE 参数改变流的初始窗口大小,这会作用于所有流。而连接的初始窗口大小不能改,但可以用 WINDOW_UPDATE 帧来改变流量控制窗口,这是为什么连接前言往往带有一个 WINDOW_UPDATE 帧的原因。

除了改变还未激活的流的流量控制窗口外,SETTIGNS 帧还可以改变已活跃的流 (处于 open 或 half-closed (remote) 状态的流)的初始流量控制窗口的大小。也就是说,当 SETTINGS_INITIAL_WINDOW_SIZE 的值变化时,接收端必须调整它所维护的所有流的流量控制窗口的值,不管是之前就打开的流还是尚未打开的流。

改变 SETTINGS_INITIAL_WINDOW_SIZE 可能引发流量控制窗口的可用空间变成负值。发送端必须追踪负的流量控制窗口,并且直到它收到了使流量控制窗口变成正值的 WINDOW_UPDATE 帧,才能发送新的 DATA 帧。

例如,如果连接一建立客户端就立即发送 60KB 的数据,而服务端却将初始窗口大小设置为 16KB,那么客户端一收到 SETTINGS 帧,就会将可用的流量控制窗口重新计算为 -44KB。客户端保持负的流量控制窗口,直到 WINDOW_UPDATE 帧将窗口值恢复为正值,客户端才可以继续发送数据。

如果改变 SETTINGS_INITIAL_WINDOW_SIZE 导致流量控制窗口超出了最大值,一端必须 将其当做类型为 FLOW_CONTROL_ERROR 的连接错误

如果接收端希望使用比当前值小的流量控制窗口,可以发送一个新的 SETTINGS 帧。但是,接收端必须准备好接收超出该窗口值的数据,因为可能在收到 SETTIGNS 帧之前,发送端已经发送了超出该较小窗口值的数据。

合理使用流控制

流量控制的定义是用来保护端点在资源约束条件下的操作。例如,一个代理需要在很多连接之间共享内存,也有可能有缓慢的上游连接和快速的下游连接。流量控制解决了接收方无法在一个流上处理数据,但仍希望继续处理同一连接中的其他流的情况。

不需要此功能的部署可以通告最大大小 (2^31 - 1) 的流量控制窗口,并且可以通过在收到任何数据时发送 WINDOW_UPDATE 帧来维护此窗口大小保持不变。这可以有效禁用接受方的流控制。相反地,发送方总是受控于接收方通告的流控制窗口的限制。

资源约束下(例如内存)的调度可以使用流量来限制一个对端可以消耗的内存量。需要注意的是如果在不知道带宽延迟积的时候启用流量控制可能导致无法最优的利用可用的网络资源 (RFC1323)。

即便是对当前的网络延迟乘积有充分的认识,流量控制的实现也可能很复杂。当使用流量控制时,接收端必须及时地从 TCP 接收缓冲区读取数据。这样做可能导致在一些例如 WINDOW_UPDATE 的关键帧在 HTTP/2 不可用时导致死锁。但是流量控制可以保证约束资源能在不需要减少连接利用的情况下得到保护。

HTTP/2 的协议协商机制

非加密下的协商 - h2c

客户端使用 HTTP Upgrade 机制请求升级,HTTP2-Settings 首部字段是一个专用于连接的首部字段,它包含管理 HTTP/2 连接的参数(使用 Base64 编码),其前提是假设服务端会接受升级请求

1
2
3
4
5
复制代码 GET / HTTP/1.1
Host: server.example.com
Connection: Upgrade, HTTP2-Settings
Upgrade: h2c
HTTP2-Settings: <base64url encoding of HTTP/2 SETTINGS payload>

服务器如果支持 http/2 并同意升级,则转换协议,否则忽略

1
2
3
复制代码HTTP/1.1 101 Switching Protocols
Connection: Upgrade
Upgrade: h2c

此时潜在的存在一个流 0x1,客户端上这个流在完成 h1 请求后便转为 half-closed 状态,服务端会用这个流返回响应

image

image

image

注意图中第一个响应所在的流是 0x1,与上文所说的一致

目前浏览器只支持 TLS 加密下的 HTTP/2 通信,所以上述情况在浏览器中目前是不可能碰到的,图中显示的是 nghttp 客户端发起的请求

加密的协商机制 - h2

TLS 加密中在 Client-Hello 和 Server-Hello 的过程中通过 ALPN 进行协议协商

image

应用层协议协商在 TLS 握手第一步的扩展中,Client Hello 中客户端指定 ALPN Next Protocol 为 h2 或者 http/1.1 说明客户端支持的协议

image

服务端如果在 Server Hello 中选择 h2 扩展,说明协商协议为 h2,后续请求响应跟着变化;如果服务端未设置 http/2 或者不支持 h2,则继续用 http/1.1 通信

分析实例

image

196: TLS 握手第一步 Client Hello,开始协议协商,且此处带上了 Session Ticket

200: Server Hello 同意使用 h2,而且客户端的会话票证有效,恢复会话,握手成功

202: 客户端也恢复会话,开始加密后续消息

205: 服务端发起一个连接前言 (SETTINGS),SETTINGS 帧中设置了最大并行流数量、初始窗口大小、最大帧长度,然后 (WINDOW_UPDATE) 扩大窗口大小

310: 客户端也发送一个连接前言 Magic,并初始化设置 (SETTINGS),SETTINGS 帧中设置了 HEADER TABLE 大小、初始窗口大小、最大并行流数量,然后 (WINDOW_UPDATE) 扩大窗口大小

311: 客户端发送完连接前言后可以立即跟上一个请求,GET / (HEADERS[1]),而且这个 HEADERS 帧还带有 END_STREAM,这会使流 1 从 idle 状态立即转为 half-closed(local) 状态 (open 是中间态)

image

311: 此消息中还包含一个客户端发送给服务端的带 ACK 的 SETTINGS 帧

312: 服务端也响应带 ACK 的 SETTINGS 帧

321: 服务端在流 1 (此时状态为 half-closed(remote)) 上发送了四个 PUSH_PROMISE 帧,它们分别保留了流 2、4、6、8 用于后续推送,

image

321: 此消息中还返回了上面请求的响应 (HEADERS - DATA),最后 DATA 带上 END_STREAM,流 1 从 half-closed 转为 closed

329: 调整流优先级,依赖关系: 8 -> 6 -> 4 -> 2 -> 1 (都带有独占标志,而且权重均为 110)

image

342: 流 1 关闭后,流 2 得到分配资源,服务器开始推送,数据由两个 DATA 帧返回

344: 流 2 结束,开始推送流 4

356: 调整依赖关系

image

1
2
3
4
5
6
7
8
9
复制代码  1         1         1         1(w: 110)
| | | |
2 2 2 2(w: 110)
| | | |
4 ==> 4 ==> 6 ==> 6(w: 147)
| | | |
6 8 4 8(w: 147)
| | | |
8 6 8 4(w: 110)

367、369、372: 推送 6 和 8 的流数据

377: 发起一个请求,打开流 3,其中客户端发起的请求都是依赖流 0x0

之后都是同样的套路完成请求 - 响应,最后以 GOAWAY 帧关闭连接结束

HPACK 算法

image

上图来自 Ilya Grigorik 的 PPT - HTTP/2 is here, let’s optimize!

可以清楚地看到 HTTP2 头部使用的也是键值对形式的值,而且 HTTP1 当中的请求行以及状态行也被分割成键值对,还有所有键都是小写,不同于 HTTP1。除此之外,还有一个包含静态索引表和动态索引表的索引空间,实际传输时会把头部键值表压缩,使用的算法即 HPACK,其原理就是匹配当前连接存在的索引空间,若某个键值已存在,则用相应的索引代替首部条目,比如 “:method: GET” 可以匹配到静态索引中的 index 2,传输时只需要传输一个包含 2 的字节即可;若索引空间中不存在,则用字符编码传输,字符编码可以选择哈夫曼编码,然后分情况判断是否需要存入动态索引表中

索引表

静态索引

静态索引表是固定的,对于客户端服务端都一样,目前协议商定的静态索引包含 61 个键值,详见 Static Table Definition - RFC 7541

比如前几个如下

索引 字段值 键值
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
动态索引

动态索引表是一个 FIFO 队列维护的有空间限制的表,里面含有非静态表的索引。
动态索引表是需要连接双方维护的,其内容基于连接上下文,一个 HTTP2 连接有且仅有一份动态表。
当一个首部匹配不到索引时,可以选择把它插入动态索引表中,下次同名的值就可能会在表中查到索引并替换。
但是并非所有首部键值都会存入动态索引,因为动态索引表是有空间限制的,最大值由 SETTING 帧中的 SETTINGS_HEADER_TABLE_SIZE (默认 4096 字节) 设置

  • 如何计算动态索引表的大小 (Table Size):

大小均以字节为单位,动态索引表的大小等于所有条目大小之和,每个条目的大小 = 字段长度 + 键值长度 + 32

这个额外的 32 字节是预估的条目开销,比如一个条目使用了两个 64-bit 指针分别指向字段和键值,并使用两个 64-bit 整数来记录字段和键值的引用次数

golang 实现也是加上了 32: golang.org/x/net/http2…

SETTING 帧规定了动态表的最大大小,但编码器可以另外选择一个比 SETTINGS_HEADER_TABLE_SIZE 小的值作为动态表的有效负载量

  • 如何更新动态索引表的最大容量

修改最大动态表容量可以发送一个 dynamic table size update 信号来更改:

1
2
3
复制代码+---+---+---+---+---+---+---+---+
| 0 | 0 | 1 | Max size (5+) |
+---+---------------------------+

前缀 001 代表此字节为 dynamic table size update 信号,后面使用 N=5 的整数编码方法表示新的最大动态表容量(不能超过 SETTINGS_HEADER_TABLE_SIZE),其计算方法下文会介绍。

需要注意的是这个信号必须在首部块发送之前或者两个首部块传输的间隔发送,可以通过发送一个 Max size 为 0 的更新信号来清空现有动态表

  • 动态索引表什么时候需要驱逐条目
  1. 每当出现表大小更新的信号时,需要判断并驱逐队尾的条目,即旧的索引,直到当前大小小于等于新的容量
  2. 每当插入新条目时,需要判断并驱逐队尾的条目,直到当前大小小于等于容量。这个情形下插入一个比 Max size 还大的新条目不会视作错误,但其结果是会清空动态索引表

关于动态索引表如何管理的,推荐看下 golang 的实现: golang.org/x/net/http2…,通过代码能更明白这个过程

索引地址空间

由静态索引表和动态索引表可以组成一个索引地址空间:

1
2
3
4
5
6
7
8
复制代码  <----------  Index Address Space ---------->
<-- Static Table --> <-- Dynamic Table -->
+---+-----------+---+ +---+-----------+---+
| 1 | ... | s | |s+1| ... |s+k|
+---+-----------+---+ +---+-----------+---+
⍋ |
| ⍒
Insertion Point Dropping Point

目前 s 就是 61,而有新键值要插入动态索引表时,从 index 62 开始插入队列,所以动态索引表中索引从小到大依次存着从新到旧的键值

编码类型表示

HPACK 编码使用两种原始类型: 无符号可变长度整数和八位字节表示的字符串,相应地规定了以下两种编码方式

整数编码

一个整数编码可以用于表示字段索引值、首部条目索引值或者字符串长度。
一个整数编码含两部分: 一个前缀字节和可选的后跟字节序列,只有前缀字节不足以表达整数值时才需要后跟字节,前缀字节中可用比特位 N 是整数编码的一个参数

比如下面所示的是一个 N=5 的整数编码(前三比特用于其他标识),如果我们要编码的整数值小于 2^N - 1,直接用一个前缀字节表示即可,比如 10 就用 ???01010 表示

1
2
3
复制代码+---+---+---+---+---+---+---+---+
| ? | ? | ? | Value |
+---+---+---+-------------------+

如果要编码的整数值 X 大于等于 2^N - 1,前缀字节的可用比特位都设成 1,然后把 X 减去 2^N - 1 得到值 R,并用一个或多个字节序列表示 R,字节序列中每个字节的最高有效位 (msb) 用于表示是否结束,msb 设为 0 时代表是最后一个字节。具体编码看下面的伪代码和例子

1
2
3
4
5
6
7
8
9
复制代码+---+---+---+---+---+---+---+---+
| ? | ? | ? | 1 1 1 1 1 |
+---+---+---+-------------------+
| 1 | Value-(2^N-1) LSB |
+---+---------------------------+
...
+---+---------------------------+
| 0 | Value-(2^N-1) MSB |
+---+---------------------------+

编码:

1
2
3
4
5
6
7
8
复制代码if I < 2^N - 1, encode I on N bits
else
encode (2^N - 1) on N bits
I = I - (2^N - 1)
while I >= 128
encode (I % 128 + 128) on 8 bits
I = I / 128
encode I on 8 bits

解码:

1
2
3
4
5
6
7
8
9
10
复制代码decode I from the next N bits
if I < 2^N - 1, return I
else
M = 0
repeat
B = next octet
I = I + (B & 127) * 2^M
M = M + 7
while B & 128 == 128
return I

比如使用 N=5 的整数编码表示 1337:

1337 大于 31 (2^5 - 1), 将前缀字节后五位填满 1

I = 1337 - (2^5 - 1) = 1306

I 仍然大于 128, I % 128 = 26, 26 + 128 = 154

154 二进制编码: 10011010, 这即是第一个后跟字节

I = 1306 / 128 = 10, I 小于 128, 循环结束

将 I 编码成二进制: 00001010, 这即是最后一个字节

1
2
3
4
5
复制代码+---+---+---+---+---+---+---+---+
| X | X | X | 1 | 1 | 1 | 1 | 1 | Prefix = 31, I = 1306
| 1 | 0 | 0 | 1 | 1 | 0 | 1 | 0 | 1306 >= 128, encode(154), I=1306/128=10
| 0 | 0 | 0 | 0 | 1 | 0 | 1 | 0 | 10 < 128, encode(10), done
+---+---+---+---+---+---+---+---+

解码时读取第一个字节,发现后五位 (11111) 对应的值 I 等于 31(>= 2^N - 1),说明还有后跟字节;令 M=0,继续读下一个字节 B,I = I + (B & 127) * 2^M = 31 + 26 * 1 = 57,M = M + 7 = 7,最高有效位为 1,表示字节序列未结束,B 指向下一个字节;I = I + (B & 127) * 2^M = 57 + 10 * 128 = 1337,最高有效位为 0,表示字节码结束,返回 I

这里也可以这样处理 1306: 1306 = 0x51a = (0101 0001 1010)B,将 bit 序列从低到高按 7 个一组分组,则有第一组 001 1010,第二组 000 1010,加上最高有效位 0/1 便与上面的后跟字节对应

字符编码

一个字符串可能代表 Header 条目的字段或者键值。字符编码使用字节序列表示,要么直接使用字符的八位字节码要么使用哈夫曼编码。

1
2
3
4
5
复制代码+---+---+---+---+---+---+---+---+
| H | String Length (7+) |
+---+---------------------------+
| String Data (Length octets) |
+-------------------------------+
  • H: 一个比特位表示是否使用哈夫曼编码
  • String Length: 代表字节序列长度,即 String Data 的长度,使用 N=7 的整数编码方式表示
  • String Data: 字符串的八位字节码序列表示,如果 H 为 0,则此处就是原字符的八位字节码表示;如果 H 为 1,则此处为原字符的哈夫曼编码

RFC 7541 给出了一份字符的哈夫曼编码表: Huffman Code,这是基于大量 HTTP 首部数据生成的哈夫曼编码。

  • 当中第一列 (sym) 表示要编码的字符,最后的特殊字符 “EOS” 代表字符串结束
  • 第二列 (code as bits) 是二进制哈夫曼编码,向最高有效位对齐
  • 第三列 (code as hex) 是十六进制哈夫曼编码,向最低有效位对齐
  • 最后一列 (len) 代表编码长度,单位 bit

使用哈夫曼编码可能存在编码不是整字节的,会在后面填充 1 使其变成整字节

比如下面的例子:

Literal Header Field with Incremental Indexing - Indexed Name

:authority: blog.wangriyu.wang 首部对应的编码为:

1
复制代码41 8e 8e 83 cc bf 81 d5    35 86 f5 6a fe 07 54 df

Literal Header Field with Incremental Indexing — Indexed Name 的编码格式见下文

41 (0100 0001) 表示字段存在索引值 1,即对应静态表中第一项 :authority

8e (1000 1110) 最高有效位为 1 表示键值使用哈夫曼编码,000 1110 表示字节序列长度为 14

后面 8e 83 cc bf 81 d5 35 86 f5 6a fe 07 54 df 是一段哈夫曼编码序列

由哈夫曼编码表可知 100011 -> ‘b’, 101000 -> ‘l’, 00111 -> ‘o’, 100110 -> ‘g’, 010111 -> ‘.’, 1111000 -> ‘w’, 00011 -> ‘a’, 101010 -> ‘n’, 100110 -> ‘g’, 101100 -> ‘r’, 00110 -> ‘i’, 1111010 -> ‘y’, 101101 -> ‘u’

1
2
3
4
5
6
7
8
9
10
复制代码8e 83 cc bf 81 d5 35 86 f5 6a fe 07 54 df
|
⍒
1000 1110 1000 0011 1100 1100 1011 1111 1000 0001 1101 0101 0011 0101 1000 0110 1111 0101 0110 1010 1111 1110 0000 0111 0101 0100 1101 1111
|
⍒
100011 101000 00111 100110 010111 1111000 00011 101010 100110 101100 00110 1111010 101101 010111 1111000 00011 101010 100110 11111
|
⍒
blog.wangriyu.wang 最后 11111 用于填充

二进制编码

现在开始是 HPACK 真正的编解码规范

已索引首部条目表示 (Indexed Header Field Representation)
  • Indexed Header Field

以 1 开始为标识,能在索引空间匹配到索引的首部会替换成这种形式,后面的 index 使用上述的整数编码方式且 N = 7。
比如 :method: GET 可以用 0x82,即 10000010 表示

1
2
3
复制代码+---+---+---+---+---+---+---+---+
| 1 | Index (7+) |
+---+---------------------------+

Indexed Header Field

未索引文字首部条目表示 (Literal Header Field Representation)

尚未被索引的首部有三种表示形式,第一种会添加进索引,第二种对于当前跳来说不会添加进索引,第三种绝对不被允许添加进索引

  1. 会添加索引的文字首部 (Literal Header Field with Incremental Indexing)

以 01 开始为标识,此首部会加入到解码后的首部列表 (Header List) 中并且会把它作为新条目插入到动态索引表中

  • Literal Header Field with Incremental Indexing — Indexed Name

如果字段已经存在索引,但键值未被索引,比如首部 :authority: blog.wangriyu.wang 的字段 :authority 已存在索引但键值 blog.wangriyu.wang 不存在索引,则会替换成如下形式 (index 使用 N=6 的整数编码表示)

1
2
3
4
5
6
7
复制代码+---+---+---+---+---+---+---+---+
| 0 | 1 | Index (6+) |
+---+---+-----------------------+
| H | Value Length (7+) |
+---+---------------------------+
| Value String (Length octets) |
+-------------------------------+

Literal Header Field with Incremental Indexing - Indexed Name

  • Literal Header Field with Incremental Indexing — New Name

如果字段和键值均未被索引,比如 upgrade-insecure-requests: 1,则会替换成如下形式

1
2
3
4
5
6
7
8
9
10
11
复制代码+---+---+---+---+---+---+---+---+
| 0 | 1 | 0 |
+---+---+-----------------------+
| H | Name Length (7+) |
+---+---------------------------+
| Name String (Length octets) |
+---+---------------------------+
| H | Value Length (7+) |
+---+---------------------------+
| Value String (Length octets) |
+-------------------------------+

Literal Header Field with Incremental Indexing — New Name

  1. 不添加索引的首部 (Literal Header Field without Indexing)

以 0000 开始为标识,此首部会加入到解码后的首部列表中,但不会插入到动态索引表中

  • Literal Header Field without Indexing — Indexed Name

如果字段已经存在索引,但键值未被索引,则会替换成如下形式 (index 使用 N=4 的整数编码表示)

1
2
3
4
5
6
7
复制代码+---+---+---+---+---+---+---+---+
| 0 | 0 | 0 | 0 | Index (4+) |
+---+---+-----------------------+
| H | Value Length (7+) |
+---+---------------------------+
| Value String (Length octets) |
+-------------------------------+

Literal Header Field without Indexing - Indexed Name

  • Literal Header Field without Indexing — New Name

如果字段和键值均未被索引,则会替换成如下形式。比如 strict-transport-security: max-age=63072000; includeSubdomains

1
2
3
4
5
6
7
8
9
10
11
复制代码+---+---+---+---+---+---+---+---+
| 0 | 0 | 0 | 0 | 0 |
+---+---+-----------------------+
| H | Name Length (7+) |
+---+---------------------------+
| Name String (Length octets) |
+---+---------------------------+
| H | Value Length (7+) |
+---+---------------------------+
| Value String (Length octets) |
+-------------------------------+

Literal Header Field without Indexing - New Name

  1. 绝对不添加索引的首部 (Literal Header Field Never Indexed)

这与上一种首部类似,只是标识为 0001,首部也是会添加进解码后的首部列表中但不会插入动态更新表。

区别在于这类首部发出是什么格式表示,接收也是一样的格式,作用于每一跳 (hop),如果中间通过代理,代理必须原样转发不能另行编码。

而上一种首部只是作用当前跳,通过代理后可能会被重新编码

golang 实现中使用一个 Sensitive 标明哪些字段是绝对不添加索引的: golang.org/x/net/http2…

RFC 文档中详细说明了这么做的原因: Never-Indexed Literals

表示形式除了标识其他都跟上一种首部一样:

  • Literal Header Field Never Indexed — Indexed Name
1
2
3
4
5
6
7
复制代码+---+---+---+---+---+---+---+---+
| 0 | 0 | 0 | 1 | Index (4+) |
+---+---+-----------------------+
| H | Value Length (7+) |
+---+---------------------------+
| Value String (Length octets) |
+-------------------------------+
  • Literal Header Field Never Indexed — New Name
1
2
3
4
5
6
7
8
9
10
11
复制代码+---+---+---+---+---+---+---+---+
| 0 | 0 | 0 | 1 | 0 |
+---+---+-----------------------+
| H | Name Length (7+) |
+---+---------------------------+
| Name String (Length octets) |
+---+---------------------------+
| H | Value Length (7+) |
+---+---------------------------+
| Value String (Length octets) |
+-------------------------------+
动态表最大容量更新 (Dynamic Table Size Update)

以 001 开始为标识,作用前面已经提过

1
2
3
复制代码+---+---+---+---+---+---+---+---+
| 0 | 0 | 1 | Max size (5+) |
+---+---------------------------+

Literal Header Field without Indexing - Indexed Name

可以发送 Max Size 为 0 的更新来清空动态索引表

Literal Header Field without Indexing - Indexed Name

实例

RFC 中给出了很多实例 Examples - RFC 7541,推荐看一遍加深理解

What then ?

HTTP/2 演示

http2.akamai.com/demo

http2.golang.org/

网站启用 h2 的前后对比,使用 WebPageTest 做的测试,第一张是 h1,第二张是 h2:

image

image

使用 HTTP/2 建议

nginx 开启 HTTP2 只需在相应的 HTTPS 设置后加上 http2 即可

1
2
复制代码listen [::]:443 ssl http2 ipv6only=on;
listen 443 ssl http2;

以下几点是 HTTP/1 和 HTTP/2 都同样适用的

1、开启压缩

配置 gzip 等可以使传输内容更小,传输速度更快

例如 nginx 可以再 http 模块中加入以下字段,其他字段和详细解释可以谷歌

1
2
3
4
5
6
复制代码    gzip  on; // 开启
gzip_min_length 1k;
gzip_comp_level 1; // 压缩级别
gzip_types text/plain application/javascript application/x-javascript application/octet-stream application/json text/css application/xml text/javascript application/x-httpd-php image/jpeg image/gif image/png font/ttf font/otf image/svg+xml; // 需要压缩的文件类型
gzip_vary on;
gzip_disable "MSIE [1-6]\.";

2、使用缓存

给静态资源设置一个缓存期是非常有必要的,关于缓存见另一篇博文 HTTP Message

例如 nginx 在 server 模块中添加以下字段可以设置缓存时间

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
复制代码 location ~* ^.+\.(ico|gif|jpg|jpeg|png|moc|mtn|mp3|mp4|mov)$ {
access_log off;
expires 30d;
}

location ~* ^.+\.(css|js|txt|xml|swf|wav|json)$ {
access_log off;
expires 5d;
}

location ~* ^.+\.(html|htm)$ {
expires 24h;
}

location ~* ^.+\.(eot|ttf|otf|woff|svg)$ {
access_log off;
expires 30d;
}

3、CDN 加速

CDN 的好处是就近访问,延迟低,访问快

4、减少 DNS 查询

每个域名都需要 DNS 查询,一般需要几毫秒到几百毫秒,移动环境下会更慢。DNS 解析完成之前,请求会被阻塞。减少 DNS 查询也是优化项之一

浏览器的 DNS Prefetching 技术也是一种优化手段

5、减少重定向

重定向可能引入新的 DNS 查询、新的 TCP 连接以及新的 HTTP 请求,所以减少重定向也很重要。

浏览器基本都会缓存通过 301 Moved Permanently 指定的跳转,所以对于永久性跳转,可以考虑使用状态码 301。对于启用了 HTTPS 的网站,配置 HSTS 策略,也可以减少从 HTTP 到 HTTPS 的重定向

但以下几点就不推荐在 HTTP/2 中用了

1、域名分片

HTTP/2 对于同一域名使用一个 TCP 连接足矣,过多 TCP 连接浪费资源而且效果不见得一定好

而且资源分域会破坏 HTTP/2 的优先级特性,还会降低头部压缩效果

2、资源合并

资源合并会不利于缓存机制,而且单文件过大对于 HTTP/2 的传输不好,尽量做到细粒化更有利于 HTTP/2 传输

3、资源内联

HTTP/2 支持 Server-Push,相比较内联优势更大效果更好

而且内联的资源不能有效缓存

如果有共用,多页面内联也会造成浪费

HTTP/2 最佳实践

使用 HTTP/2 尽可能用最少的连接,因为同一个连接上产生的请求和响应越多,动态字典积累得越全,头部压缩效果也就越好,而且多路复用效率高,不会像多连接那样造成资源浪费

为此需要注意以下两点:

  • 同一域名下的资源使用同一个连接,这是 HTTP/2 的特性
  • 不同域名下的资源,如果满足能解析到同一 IP 或者使用的是同一个证书(比如泛域名证书),HTTP/2 可以合并多个连接

所以使用相同的 IP 和证书部署 Web 服务是目前最好的选择,因为这让支持 HTTP/2 的终端可以复用同一个连接,实现 HTTP/2 协议带来的好处;而只支持 HTTP/1.1 的终端则会不同域名建立不同连接,达到同时更多并发请求的目的

比如 Google 一系列网站都是用的同一个证书:

image

但是这好像也会造成一个问题,我使用 nginx 搭建的 webserver,有三个虚拟主机,它们共用一套证书,其中两个我显示地配置了 http2,而剩下一个我并没有配置 http2,结果我访问未配置 http2 的站点时也变成了 http2。

大图片传输碰到的问题

先比较一下 h1 和 h2 的页面加载时间,图中绿色代表发起请求收到响应等待负载的时间,蓝色代表下载负载的时间:

image

image

可以发现 h2 加载时间还比 h1 慢一点,特别是碰到大图片时差别更明显

这篇文章对不同场景下 h1 和 h2 加载图片做了测试: Real–world HTTP/2: 400gb of images per day

其结果是:

  • 对一个典型的富图像,延迟限制 (latency–bound) 的界面来说。使用一个高速,低延迟的连接,视觉完成度 (visual completion) 平均会快 5%。
  • 对一个图像极其多,带宽限制 (bandwidth–bound) 的页面来说。使用同样的连接,视觉完成度平均将会慢 5–10%,但页面的整体加载时间实际是减少了,因为得益于连接延迟少。
  • 一个高延迟,低速度的连接(比如移动端的慢速 3G) 会对页面的视觉完成造成极大的延迟,但 h2 的视觉完成度明显更高更好。

在所有的测试中,都可以看到: h2 使整体页面的加载速度提高了,并且在初次绘制 (initial render) 上做的更好,虽然第二种情况中视觉完成度略微下降,但总体效果还是好的

视觉完成度下降的原因是因为没有 HTTP/1.x 同时连接数量的限制,h2 可以同时发起多张图片的请求,服务器可以同时响应图片的负载,可以从下面的动图中看到

image

一旦图片下载完成,浏览器就会绘制出它们,然而,小图片下载后会渲染地更快,但是如果一个大图片恰好是初始的视图,那就会花费较长的时间加载,延迟视觉上的完成度。

chrome bug

上面的动图是在 Safari 上的测试结果,图片最后都下载成功了,而我在 Chrome 上测试时后面的部分图片直接挂了,都报 ERR_SPDY_PROTOCOL_ERROR 错误,而且是百分百复现

image

去看了下 ERR_SPDY_PROTOCOL_ERROR 出在哪,发现是 Server reset stream,应该是哪出错了导致流提前终止

image

然后再研究了一下 HTTP/2 的帧序列,发出的请求都在 629 号消息中响应成功了,但是返回的数据帧只有流 15 上的,实际收到的图片又不止流 15 对应的图片,这是为什么?

image

后面我继续测试发现连续请求几张大图片,虽然 HEADERS 帧都打开的是不同的流,返回的响应的 HEADERS 帧也还是对应前面的流 ID,但是响应的 DATA 帧都是从第一个打开的流上返回的。

如果是小图片的话,一个请求响应过后这个流就关闭了,下一张小图是在其自己对应的流上返回的。只有连续几张大图会出现上述情形,这个机制很奇怪,我暂时还没有找到解释的文档。

至于 chrome 为什么出错呢,看一下 TCP 报文就会发现所有数据在一个连接上发送,到后面 TCP 包会出现各种问题,丢包、重传、失序、重包等等,不清楚 Safari 是否也是这样,因为 wireshark 只能解 chrome 的包解不了 Safari 的包

image

《web 性能权威指南》中提及 HTTP/2 中一个 TCP 可能会造成的问题:
虽然消除了 HTTP 队首阻塞现象,但 TCP 层次上仍存在队首阻塞问题;如果 TCP 窗口缩放被禁用,那带宽延迟积效应可能会限制连接的吞吐量;丢包时 TCP 拥塞窗口会缩小;

TCP 是一方面原因,还有另一方面应该是浏览器策略问题,估计也是 chrome bug,对比两张动图你会发现,safari 接收负载是轮流接收,我们几个接收一点然后换几个人接收,直到所有都接受完;而 chrome 则是按顺序接收,这个接收完才轮到下一个接收,结果后面的图片可能长时间未响应就挂了。

使用渐进式图片

渐进式 jpg 代替普通 jpg 有利于提高视觉完成度,而且文件更小:

输入 convert --version 看看是否已安装 ImageMagic,如果没有先安装: Mac 可以用 brew install imagemagick,Centos 可以用 yum install imagemagick

检测是否为 progressive jpeg,如果输出 None 说明不是 progressive jpeg;如果输出 JPEG 说明是 progressive jpeg:

1
复制代码$ identify -verbose filename.jpg | grep Interlace

将 basic jpeg 转换成 progressive jpeg,interlace 参数:

1
2
3
4
复制代码$ convert -strip -interlace Plane source.jpg destination.jpg // 还可以指定质量 -quality 90

// 批量处理
$ for i in ./*.jpg; do convert -strip -interlace Plane $i $i; done

也可以转换 PNG 和 GIF,但是我试过 convert -strip -interlace Plane source.png destination.png 但转换后的图片往往会更大,不推荐这么用,可以 convert source.png destination.jpg

ImageMagic 还有很多强大的功能

1
2
3
4
5
6
复制代码// 图片缩放
$ convert -resize 50%x50% source.jpg destination.jpg
// 图片格式转换
$ convert source.jpg destination.png
// 配合 find 命令,将当前目录下大于 100kb 的图片按 75% 质量进行压缩
$ find -E . -iregex '.*\.(jpg|png|bmp)' -size +100k -exec convert -strip +profile “*” -quality 75 {} {} \;

png 压缩推荐使用 pngquant

另外 photoshop 保存图片时也可以设置渐进或交错:

渐进式图片:选择图片格式为 JPEG => 选中“连续”

交错式图片:选择图片格式为 PNG/GIF => 选中“交错”

SPDY 与 HTTP2 的关系

SPDY 是 HTTP2 的前身,大部分特性与 HTTP2 保持一致,包括服务器端推送,多路复用和帧作为传输的最小单位。但 SPDY 与 HTTP2 也有一些实现上的不同,比如 SPDY 的头部压缩使用的是 DEFLATE 算法,而 HTTP2 使用的是 HPACK 算法,压缩率更高。

QUIC 协议

Google 的 QUIC(Quick UDP Internet Connections) 协议,继承了 SPDY 的特点。QUIC 是一个 UDP 版的 TCP + TLS + HTTP/2 替代实现。

QUIC 可以创建更低延迟的连接,并且也像 HTTP/2 一样,通过仅仅阻塞部分流解决了包裹丢失这个问题,让连接在不同网络上建立变得更简单 - 这其实正是 MPTCP 想去解决的问题。

QUIC 现在还只有 Google 的 Chrome 和它后台服务器上的实现,虽然有第三方库 libquic,但这些代码仍然很难在其他地方被复用。该协议也被 IETF 通信工作组引入了草案。

Caddy: 基于 Go 语言开发的 Web Server, 对 HTTP/2 和 HTTPS 有着良好的支持,也开始支持 QUIC 协议 (试验性)

推荐工具

  • Chrome 插件: HTTP/2 and SPDY indicator

如果你访问的站点开启了 HTTP/2,图标会亮起,而且点击会进入 chrome 内置的 HTTP/2 监视工具

  • 命令行工具: nghttp2

C 语言实现的 HTTP/2,可以用它调试 HTTP/2 请求

直接 brew install nghttp2 就可以安装,安装好后输入 nghttp -nv https://nghttp2.org 就可以查看 h2 请求

image

  • 除 nghttp2 外还可以用 h2i 测试 http2: github.com/golang/net/…
  • 还可以用 wireshark 解 h2 的包,不过得设置浏览器提供的对称协商密钥或者服务器提供的私钥,具体方法看此文: 使用 Wireshark 调试 HTTP/2 流量

如果无法解包看一下 sslkeylog.log 文件有没有写入数据,如果没有数据说明浏览器打开方式不对,得用命令行打开浏览器,这样才能让浏览器读取环境变量然后向 sslkeylog 写入密钥,另外此方法好像支持谷歌浏览器和火狐,对 Safari 无效

如果 sslkeylog.log 有数据,wireshark 还是无法解包,打开设置的 SSL 选项重新选择一下文件试试,如果还是不行也用命令行打开 Wireshark

一次不行多试几次

  • h2o: 优化的 HTTP Server,对 HTTP/2 的支持性做的比较好

References

  • HTTP/2: 新的机遇与挑战
  • HTTP2 is here, let’s optimize!
  • JerryQu’s Blog
  • http2 explained
  • NGINX HTTP2 White Paper
  • HTTP/2 Push: The details
  • 《web 性能权威指南》

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…885886887…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%