0%

组合(composition)、接口(interface)、委托(delegation)

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
public interface Flyable {
void fly();
}

public interface Tweetable {
void tweet();
}

public interface EggLayable {
void layEgg();
}

public class FlyAbility implements Flyable {

@Override
public void fly() {

}
}

public class TweetAbility implements Tweetable {

@Override
public void tweet() {

}
}

public class EggLayability implements EggLayable {

@Override
public void layEgg() {

}
}

public class Ostrich implements Tweetable, EggLayable {
private final TweetAbility tweetAbility = new TweetAbility(); // composition
private final EggLayability eggLayability = new EggLayability();


@Override
public void tweet() {
tweetAbility.tweet(); // delegation
}

@Override
public void layEgg() {
eggLayability.layEgg();
}
}

我们知道继承主要有三个作用:表示is-a关系,支持多态继承,代码复用。而这三个作用都可以通过其他技术手段来达成。比如is-a关系,我们可以通过组合和接口的behave-like关系来替代;多态特性我们可以利用接口来实现;代码复用我们可以通过组合和委托来实现。所以,从理论上来讲,通过组合、接口、委托三个技术手段,我们完全可以替换掉继承,在项目中不用或者少用继承关系,特别是一些复杂的继承关系。

如何判断该用组合还是继承?

尽管我们鼓励多用组合少用继承,但组合也并不是完美的,继承也并非一无是处。从上面的例子来看,继承改写成组合意味着要做更细粒度的类的拆分。这也就意味着,我们要定义更多的类和接口。类和接口的增多也就或多或少地增加代码的复杂程度和维护成本。所以,在实际的项目开发中,我们还是要根据具体的情况,来具体选择该用继承还是组合。

如果类之间的继承结构稳定(不会轻易改变),继承层次比较浅(比如,最多有两层继承关系),继承关系不复杂,我们就可以大胆地使用继承。反之,系统越不稳定,继承层次很深,继承关系复杂,我们就尽量使用组合来替代继承。

除此之外,还有一些设计模式会固定使用继承或者组合。比如,装饰者模式(decorator pattern),策略模式(strategy pattern)、组合模式(composite pattern)等都使用了组合关系,而模板模式(template pattern)使用了继承关系。

前面我们讲到继承可以实现代码复用。利用继承特性,我们把相同的属性和方法,抽取出来,定义到父类中。族类复用父类中的属性和方法,达到代码复用的目的。但是,有的时候,从业务含义上,A类和B类并不一定具有继承关系。比如,Crawler类和PageAnalyzer类,它们都用到了URL拼接和分割的功能,但并不具有继承关系(既不是父子关系,也不是兄弟关系)。仅仅为了代码复用,生硬的抽象出一个父类出来,会影响到代码的可读性。如果不熟悉背后设计思路的同时,发现Crawler类和PageAnalyzer类继承同一个父类,而父类中定义的却只是URL相关的操作,会觉得这个代码写得莫名其妙,理解不了。这个时候,使用组合就更加合理、更加灵活。具体的代码实现如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Url {
// omit attributes and methods
}

public class Crawler {
private final Url url; //composition

public Crawler() {
this.url = new Url();
}
// ...
}

public class PageAnalyzer {
private final Url url; // composition

public PageAnalyzer() {
this.url = new Url();
}
// ...
}

小结与回顾

  1. 为什么不推荐使用继承?

    继承是面向对象的四大特性之一,用来表示类之间的is-a关系,可以解决代码复用的问题。虽然继承有诸多作用,但继承层次过深、过复杂,也会影响到代码的可维护性。在这种情况下,我们应该尽量少用,甚至不用继承。

  2. 组合相比继承有哪些优势?

    继承主要有三个作用:表示is-a关系,支持多态特性,代码复用。而这三个作用都可以通过组合、接口、委托三个技术手段来达成。除此之外,利用组合还能解决层次过深、过复杂的继承关系影响代码可维护性的问题。

  3. 如何判断该用组合还是继承?(尽量使用接口、组合和委托代替继承,不要使用继承)

    尽管我们鼓励多用组合少用继承,但组合也并不是完美的,继承也并非一无是处。在实际的项目开发中,我们还是要根据具体的情况,来选择该用继承还是组合。如果类之间的继承结构稳定,层次比较浅,关系不复杂,我们就可以大胆地使用继承。反之,我们就尽量使用组合来替代继承。除此之外,还有一些设计模式、特殊的应用场景,会固定使用继承或者组合。

    (新的编程语言让接口+组合+委托变得容易,例如Kotlin就有专门的语法糖支持,消除了很多模板代码。)

    (接口+组合+委托符合矢量化思想,那就是将物体特征分成不同的维度,每个维度独立变化。继承则是将物体分裂,抽取共性,处理共性,操作的灵活性大打折扣,毕竟现实中的物体特征多,共性少。)

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
// 抽象类
public abstract class Logger {
private final String name;
private final boolean enabled;
private Level minPermittedLevel;

public Logger(String name, boolean enabled, Level minPermittedLevel) {
this.name = name;
this.enabled = enabled;
this.minPermittedLeve = minPermittedLevel;
}

public void log(Level level, String message) {
boolean loggable = enabled && (minPermittedLevel.intValue() <= level.intValue());
if (!loggable) return;
doLog(level, message);
}

protected abstract void doLog(Level level, String message);
}

// 抽象类的子类
public class FileLogger extends Logger {
public Writer fileWriter;

public FileLogger(String name, boolean enabled, Level minPermittedLevel, String filepath) throws IOException {
super(name, enabled, minPermittedLevel);
this.fileWriter = new FileWriter(filepath);
}

@Override
protected void doLog(Level level, String message) {
// 格式化level和message,输出到日志文件
// fileWriter.write(...);
}
}

public class MessageQueueLogger extends Logger {
private final MessageQueueClient msgQueueClient;

public MessageQueueLogger(String name, boolean enabled, Level minPermittedLevel, MessageQueueClient msgQueueClient) {
super(anem, enabled, minPermittedLevel);
this.msgQueueClient = msgQueueClient;
}

@Override
protected void doLog(Level level, String message) {
// 格式化level和message,输出到消息中间件
// msgQueueClient.send(...)

}
}
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
// 接口
public interface Filter {
void doFilter(RpcRequest req) throws RpcException;
}

// 接口实现类:鉴权过滤器
public class AuthenticationFilter implements Filter {

@Override
public void doFilter(RpcRequest req) throws RpcException {
// ...鉴权逻辑...
}
}

// 接口实现类:限流过滤器
public class RateLimitFilter implements Filter {

@Override
public void doFilter(RpcRequest req) throws RpcException {
// ...限流逻辑...
}
}

// 过滤器使用demo
public class Application {
// filters.add(new AuthenticationFilter());
// filters.add(new RateLimitFilter());
private final List<Filter> filters = new ArrayList<>();

public void handleRpcRequest(RpcRequest req) {
try {
for (Filter filter : filters) {
filter.doFilter(req);
}
} catch (RpcException e) {
// ...处理过滤结果...
}
// ...省略其他处理逻辑...
}
}

抽象类实际上就是类,只不过是一种特殊的类,这种类不能被实例化为对象,只能被子类继承。我们知道,继承关系是一种is-a的关系,那抽象类既然属于类是,也表示一种is-a的关系。相对于抽象类的is-a关系来说,接口表示一种has-a关系,表示具有某些工呢过。对于接口,有一个更加形象的叫法,那就是协议(contract)。

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
public class Logger {
private final String name;
private final boolean enabled;
private final Level minPermittedLevel;

public Logger(String name, boolean enabled, Level minPermittedLevel) {
// 构造函数不变
}

public boolean isLoggable(Level level) {
boolean loggable = enabled $$(minPermittedLevel.intValue() <= level.intValue());
return loggable;
}
}


// 子类:输出日志到文件
public class FileLogger extends Logger {
private Writer fileWriter;

public FileLogger(String name, boolean enabled, Level minPermittedLevel, String filepath) {
// 构造函数不变
}

public void log(Level level, String message) {
if (!isLoggable(level)) return;
// 格式化level和message,输出到日志文件
fileWriter.write(...)
}
}

// subclass:output message to message middleware
public class MessageQueueLogger extends Logger {
private MessageQueueClient msgQueueClient;

public MessageQueueLogger(String name, boolean enabled, Level minPermittedLevel, MessageQueueClient msgQueueClient) {
// 构造函数不变
}

public void log(Level level, String message) {
if (!isLoggable()) return;
// formatting level and message, output to message middleware
msgQueueClient.send(...)
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Logger {
// ...省略部分代码...
public void log(Level level, String mesage) { // do nothing... }
}
public class FileLogger extends Logger {
// ...省略部分代码...
@Override
public void log(Level level, String mesage) {
if (!isLoggable()) return;
// 格式化level和message,输出到日志文件
fileWriter.write(...);
}
}
public class MessageQueueLogger extends Logger {
// ...省略部分代码...
@Override
public void log(Level level, String mesage) {
if (!isLoggable()) return;
// 格式化level和message,输出到消息中间件
msgQueueClient.send(...);
}
}

Logger中并没有定义log()方法。可能会想,在Logger父类中,定义一个空的log()方法,让子类重写父类的log()方法,实现自己的记录日志的逻辑,不就可以了吗?这个设计思路能用,但是,它显然没有之前通过抽象类的实现思路优雅。

抽象类更多的是为了代码复用,而接口就更侧重于解耦。接口是对行为的一种抽象,相当于一组协议或者契约,你可以联想类比一下API接口。调用者只需要关注抽象的接口,不需要了解具体的实现,具体的实现代码对调用者透明。接口实现了约定和实现相分离,可以降低代码间的耦合性,提高代码的可扩展性。

实际上,借口是一个比抽象类应用更加广泛、更加重要的知识点。比如,我们经常提到的“基于接口而非实现编程”,就是一条几乎天天会用到,并且能极大地提高代码的灵活性、扩展性的设计思想。

如何模拟抽象类和接口两个语法概念?

在前面举的例子中,我们使用Java的接口语法实现了一个Filter过滤器。不过,如果你熟悉的是C++这种编程语言,你可能会说,C++只有抽象类,并没有接口,那从代码实现的角度来说,是不是就无法实现Filter的设计思路了呢?

实际上,我们可以通过抽象类来模拟接口。怎么来模拟呢?

我们先来回忆一下接口的定义:接口中没有成员变量,只有方法声明,没有方法实现,实现接口的类必须实现接口中的所有方法。只要满足这样几点,从设计的角度上来说,我们就可以把它叫做接口。实际上,要满足接口的这些语法特性并不难。在下面这段C++代码中,我们就用抽象类模拟了一个接口(下面这段代码实际上是策略模式中的一段代码)。

1
2
3
4
5
6
7
8
9
class Strategy { // 用抽象类模拟接口
public:
~Strategy();

virtual void algorithm() = 0;

protected:
Strategy();
};

抽象类Strategy没有定义任何属性,并且所有的方法都声明为virtual类型(等同于Java中的abstract关键字),这样,所有的方法都不能有代码实现,并且所有集成这个抽象类的子类,都要实现这些方法。从语法特性上来看,这个抽象类就相当于一个接口。

不过,如果你熟悉的既不是Java,也不是C++,而是现在比较流行的动态编程语言,比如Python、Ruby等,你可能还会有疑问:在这些动态语言中,不仅没有接口的概念,也没有类似abstract,virtual这样的关键字来定义抽象类,那该如何实现上面讲到的Filter、Logger的设计思路呢?实际上,除了用抽象类来模拟接口之外,我们还可以用普通类来模拟接口。具体的Java代码实现如下所示。

1
2
3
4
5
6
7
8
public class MockInterface {
protected MockInterface() {
}

public void funcA() {
throw new MethodUnSuportedException();
}
}

我们知道类中的方法必须包含实现,这个不符合接口的定义。但是,我们可以让类中的方法报出MethodUnSupportedException异常,来模拟不包含实现的接口,并且能强迫子类在继承父类的时候,都去主动实现父类的方法,否则就会在运行时抛出异常。那又如何避免这个类被实例化呢?实际上很简单,我们只需要将这个类的构造函数声明为protected访问权限就可以了。

对动态编程语言来说,还有一种对接口支持的策略就是duck-typing,Go也是这样支持接口的。

如果我们要表示一种is-a的关系,并且是为了解决代码复用的问题,我们就用抽象类;如果我们要表示一种has-a关系,并且是为了解决抽象而非代码服用的问题,我们就可以使用接口。

从类的继承层次上来看,抽象类是一种自下而上的设计思路,先有子类的代码复用,然后再抽象成上层的父类(也就是抽象类)。而接口正好相反,它是一种自上而下的设计思路。我们在编程的时候,一般都是先设计接口,再去考虑具体的实现。

小结

  1. 抽象类和接口的语法特性

    抽象类不允许被实例化,只能被继承。它可以包含属性和方法。方法既可以包含代码实现,也可以不包含代码实现。不包含代码实现的方法叫做抽象方法。子类继承抽象类,必须实现抽象类中的所有抽象方法。接口不能包含属性,只能声明方法,方法不能包含代码实现。类实现接口的时候,必须实现接口中声明的所有方法。

  2. 抽象类和接口存在的意义

    抽象类是对成员变量和方法的抽象,是一种is-a关系,是为了解决代码复用问题。接口仅仅是对方法的抽象,是一种has-a关系,表示具有某一组行为特性,是为了解决解耦问题,隔离接口和具体的实现,提高代码的扩展性。

  3. 抽象类和接口的应用场景区别

    什么时候该用抽象类?什么时候该用接口?实际上,判断的标准很简单。如果要表示一种is-a的关系,并且是为了解决代码服用问题,我们就用抽象类;如果要表示一种has-a关系,并且是为了解决抽象而代码复用问题,那我们就用接口。

关于计算机的相关系统,至少要掌握三个系统的基础知识,一个是操作系统,一个是网络系统,还有一个是数据库系统。它们分别代表着计算机基础构架的三大件——计算、存储、网络。

我不放弃 爱的勇气

我不怀疑 会有真心

我要我住一个最美的梦

给未来的自己

封装(Encapsulation)

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
import java.math.BigDecimal;

public class Wallet {
private final String id;
private final long createTime;
private final BigDecimal balance;
private long balanceLastModifiedTime;

//...省略其他属性...
public Wallet() {
this.id = IdGenerater.getInstance().generate();
this.createTime = System.currentTimeMillis();
this.balance = BigDecimal.ZERO;
this.balanceLastModifiedTime = System.currentTimeMillis();
}

// 注意:下面对get方法做了代码折叠,是为了减少代码所占文章的篇幅
public String getId() {
return this.id;
}

public long getCreateTime() {
return createTime;
}

public BigDecimal getBalance() {
return balance;
}

public long getBalanceLastModifiedTime() {
return balanceLastModifiedTime;
}

public void increaseBalance(BigDecimal increasedAmount) {
if (increasedAmount.compareTo(BigDecimal.ZERO) < 0) {
throw new InvalidAmountException("...");
}
this.balance.add(increasedAmount);
this.balanceLastModifiedTime = System.currentTimeMillis();
}

public void decreaseBalance(BigDecimal decreasedAmount) {
if (decreasedAmount.compareTo(BigDecimal.ZERO) < 0) {
throw new InvalidAmountException("...");
}
if (decreasedAmount.compareTo(this.balance) > 0) {
throw new InsufficientAmountException("...");
}
this.balance.substract(decreasedAmount);
this.balanceLastModifiedTime = System.currentTimeMillis();
}
}

抽象(Abstraction)

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.awt.*;

public interface IPictureStorage {
void savePicture(Picture picture);

Image getPicture(String pictureId);

void deletePicture(String pictureId);

void modifyMetaInfo(String pictureId, PictureMetaInfo metaInfo);
}

public class PictureStorage implements IPictureStorage {
// ...省略其他属性...

@Override
public void savePicture(Picture picture) {

}

@Override
public Image getPicture(String pictureId) {
return null;
}

@Override
public void deletePicture(String pictureId) {

}

@Override
public void modifyMetaInfo(String pictureId, PictureMetaInfo metaInfo) {

}
}

继承(Inheritance)

继承的概念很好理解,也很容易使用。不过,过度使用继承,继承层次过深过复杂,就会导致代码可读性、可维护性变差。为了了解一个类的功能,我们不仅需要查看这个类的代码,还需要按照继承关系一层一层地往上查看“父类、父类的父类……”的代码。还有,子类和父类高度耦合,修改父类的代码,会直接影响到子类。

所以,继承这个特性也是一个非常有争议的特性。很多人觉得继承是一种反模式。我们应该尽量少用,甚至不用。

多态(Polymorphism)

多态是指,子类可以替换父类,在实际的代码运行过程中,调用子类的方法实现。

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
public class DynamicArray {
private static final int DEFAULT_CAPACITY = 10;
protected int size = 0;
protected int capacity = DEFAULT_CAPACITY;
protected Integer[] elements = new Integer[DEFAULT_CAPACITY];

public int size() {
return this.size;
}

public Integer get(int index) {
return elements[index];
}

// ...省略n多方法...
public void add(Integer e) {
ensureCapacity();
elements[size++] = e;
}

protected void ensureCapacity() {
// ...如果数组满了就扩容...代码省略...
}

}

public class SortedDynamicArray extends DynamicArray {
@Override
public void add(Integer e) {
ensureCapacity();
int i;
for (i = size - 1; i >= 0; --i) { // 保证数组中的数据有序(插入排序)
if (elements[i] > e) {
elements[i + 1] = elements[i];
} else {
break;
}
}
elements[i + 1] = e;
++size;
}
}

public class Example {
public static void test(DynamicArray dynamicArray) {
dynamicArray.add(5);
dynamicArray.add(1);
dynamicArray.add(3);
for (int i = 0; i < dynamicArray.size(); ++i) {
System.out.println(dynamicArray.get(i));
}
}

public static void main(String[] args) {
DynamicArray dynamicArray = new SortedDynamicArray();
test(dynamicArray); // 打印结果:1、3、5
}
}

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
import java.util.LinkedList;

public interface Iterator {
boolean hasNext();

String next();

String remove();
}

public class Array implements Iterator {
private String[] data;


@Override
public boolean hasNext() {
return false;
}

@Override
public String next() {
return null;
}

@Override
public String remove() {
return null;
}
// ...省略其他方法...
}

public class Demo {
private static void print(Iterator iterator) {
while (iterator.hasNext()) {
System.out.println(iterator.next());
}
}

public static void main(String[] args) {
Iterator arrayIterator = new Array();
print(arrayIterator);

Iterator linkedListIterator = new LinkedList();
print(linkedListIterator);
}
}
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
# duck-typing.py


class Logger:
def record(self):
print("I write a log info file.")


class DB:
def record(self):
print("I insert data into db.")


def test(recorder):
recorder.record()


def main():
logger = Logger()
db = DB()
test(logger)
test(db)


if __name__ == '__main__':
main()

多态特性能提高代码的可扩展性和复用性。为什么这么说呢?我们回过头去看讲解多态特性的时候,举的第二个代码实例(Iterator 的例子)。

在那个例子中,我们利用多态的特性,仅用一个 print() 函数就可以实现遍历打印不同类型(Array、LinkedList)集合的数据。当再增加一种要遍历打印的类型的时候,比如 HashMap,我们只需让 HashMap 实现 Iterator 接口,重新实现自己的 hasNext()、next() 等方法就可以了,完全不需要改动 print() 函数的代码。所以说,多态提高了代码的可扩展性。

如果我们不使用多态特性,我们就无法将不同的集合类型(Array、LinkedList)传递给相同的函数(print(Iterator iterator) 函数)。我们需要针对每种要遍历打印的集合,分别实现不同的 print() 函数,比如针对 Array,我们要实现 print(Array array) 函数,针对 LinkedList,我们要实现 print(LinkedList linkedList) 函数。而利用多态特性,我们只需要实现一个 print() 函数的打印逻辑,就能应对各种集合数据的打印操作,这显然提高了代码的复用性。

除此之外,多态也是很多设计模式、设计原则、编程技巧的代码实现基础,比如策略模式、基于接口而非实现编程、依赖倒置原则、里式替换原则、利用多态去掉冗长的 if-else 语句等等。关于这点,在学习后面的章节中,你慢慢会有更深的体会。

小结

  1. 关于封装特性

    封装也叫做信息隐藏或者数据访问保护。类通过暴露有限的访问接口,授权外部仅能通过类提供的方式来访问内部信息或者数据。它需要编程语言提供权限访问控制语法来支持,例如Java中的private、protected、public关键字。封装特性存在的意义,一方面是保护数据不被随意修改,提高代码的可维护性;另一方面是仅暴露有限的必要接口,提高类的易用性。

  2. 关于抽象特性

    封装主要讲如何隐藏信息、保护数据,那抽象就是将如何隐藏方法的具体实现,让使用者只需要关心方法提供了哪些功能,不需要知道这些功能是如何实现的。抽象可以通过接口类或者抽象类来实现,但也并不需要特殊的语言机制来支持。抽象存在的意义,一方面是提高代码的可扩展性、维护性,修改实现不需要改变定义,减少代码的改动范围;另一方面,它也是处理复杂系统的有效手段,能有效地过滤掉不必要关注的信息。

  3. 关于继承特性

    继承是用来表示类之间的is-a关系,分为两种模式:单继承和多继承。单继承表示一个子类只继承一个父类,多继承表示一个子类可以继承多个父类。为了实现继承这个特性,编程语言需要提供特殊的语法机制来支持。继承主要是用来解决代码复用的问题。

  4. 关于多态特性

    多态是指子类可以替换父类,在实际的代码运行过程中,调用子类的方法实现。多态这种特性也需要编程语言提供特殊的语法机制来实现,比如继承、接口类、duck-typing。多态可以提高代码的扩展性和复用性,是很多设计模式、设计原则、编程技巧的代码实现基础。

    ## 封装 What:隐藏信息,保护数据访问。 How:暴露有限接口和属性,需要编程语言提供访问控制的语法。 Why:提高代码可维护性;降低接口复杂度,提高类的易用性。

    ##抽象 What: 隐藏具体实现,使用者只需关心功能,无需关心实现。 How: 通过接口类或者抽象类实现,特殊语法机制非必须。 Why: 提高代码的扩展性、维护性;降低复杂度,减少细节负担。

    ##继承 What: 表示 is-a 关系,分为单继承和多继承。 How: 需要编程语言提供特殊语法机制。例如 Java 的 “extends”,C++ 的 “:” 。 Why: 解决代码复用问题。

    ##多态 What: 子类替换父类,在运行时调用子类的实现。 How: 需要编程语言提供特殊的语法机制。比如继承、接口类、duck-typing。 Why: 提高代码扩展性和复用性。

    3W 模型的关键在于 Why,没有 Why,其它两个就没有存在的意义。从四大特性可以看出,面向对象的终极目的只有一个:可维护性。易扩展、易复用,降低复杂度等等都属于可维护性的实现方式。

HTTP协议全称是超文本传输协议,超文本也就是对应着我们的一个资源的一种表述,就是我们服务器端一个URI对应的一个页面,我们把它传输到客户端进行渲染显示,那么rest架构呢,也就是我们HTTP协议设计时所遵循的架构,它也是在描述资源状态的一种转移。由于我们一种资源对应着许多种状态,所以我们的客户端在接受我们资源表述的转移时需要进行一种协商。比如:一个来自中国的用户,在他的浏览器访问一个页面时可能得到的是一个中文页面。但是一个其他国家的用户,在用浏览器访问同一个URL时呢,获得的页面可能是用他的本国语言描述的。那么接下来这篇文章中,我们将介绍内容协商是怎样进行的。

内容协商

每个URI指向的资源可以是任何事物,可以由多种不同的表述,例如一份文档可以有不同语言的翻译、不同的媒体格式、可以针对不同的浏览器提供不同的压缩编码等。(同一个URL可能会有很多种语言,比如法语、德语的不同的页面,也可以不同的方式比如说我们可以展示一个html的在页面上显示的格式,也可以直接展示为一个pdf的文档,那么由于我们的浏览器不同,浏览器所支持的压缩算法也是不一样的,而我们的html这种文本语言本身是有很大的压缩空间的,所以,我们既可以以gzip,也可以以br这种压缩方式。所以这就是一个URL对应着资源的许多种不同的表述。)

内容协商的两种方式

  • Proactive主动式内容协商:
    • 指由客户端先在请求头部提出需要的表述形式,而服务器根据这些请求头部提供特定的representation表述(这种主动式内容协商会有一个问题,就是服务器端可能会相对武断,因为它可能拿不到足够的信息)
  • Reactive响应式内容协商:
    • 指服务器返回300 Multiple Choices 或者 406 Not Acceptable,由客户端选择一种表述URI使用(再发起相应的请求)

Proactive主动式内容协商

我们的请求中除了传递URL描述对应的资源以外,还要告诉我接受那种格式(Accept:text/*),我希望那种语言(accept-Language: en),接受哪种压缩的方式(Accept-Encoding:br, gzip ; q = 0.8)(Content-Type:text/html Content-Language: en Content-Encoding: br)

Reactive响应式内容协商

服务器返回了一个列表,列表中是它认为合适的一种表述,通过300这样的响应码返回给客户端,而客户端对这个list中自行决定了一个比较合适的新的一个URL,再次访问服务器端,服务器端给到正确的表述。(有一个问题,RFC规范中没有明确的告诉Client应该依据怎样的规则,所以导致各大浏览器无法按照统一的策略去选择合适的响应表述给用户。所以响应式内容协商相对是很少使用的。)

常见的协商要素(一)

  • 质量因子q:内容的质量、可接受类型的优先级(非常常见,有两种主要的表达方式,第一个是表示内容的质量,我们现在可以想见,如果我们发起请求去获取一张图片,如果这张图片是一张高清图片的缩略图,让用户快速浏览用的,那么我们就可以做非常高的压缩比,那么这个时候的质量因子,就可以比较小。那么如果这个时候我们描述的是一张医学上的图片,那么这个图片中,我们的质量因子就要设的比较大,因为我们不能容忍对这张图片做大幅度的压缩,以使得我们损失了大量的细节。第二个则是表示我们可接受类型的优先级。
  • 媒体资源的MIME类型及质量因子
    • Accept:text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
    • Accept:text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3

常见的协商要素(二)

  • 字符编码:由于UTF-8格式广为使用,Accept-Charset已被废弃
    • Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.7
  • 内容编码:主要指压缩算法
    • Accept-Encoding: gzip,deflate,br
  • 表述语言
    • Accept-Language:zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7
    • Accept-Language:zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2

国际化与本地化

  • internationalization(i18n,i和n间有18个字符)
    • 指设计软件时,在不同的国家、地区可以不做逻辑实现层面的修改便能够以不同的语言显示
  • localization(l10n,l和n间有10个字符)
    • 指内容协商时,根据请求中的语言及区域信息,选择特定的语言作为资源表述

资源表述的元数据头部

  • 媒体类型、编码
    • content-type: text/html; charset=utf-8
  • 内容编码
    • content-encoding: gzip
  • 语言
    • Content-Language: de-DE, en-CA

小结

内容协商将决定服务器端生成不同的HTTP包体传输给客户端。下一篇文章将介绍HTTP消息包体的传输方式。

在HTTP消息传输过程中,会经历会多正向代理服务器和反向代理服务器,那么这些代理服务器在转发消息时,会涉及到一些HTTP头部,这篇文章中,将会介绍这些HTTP头部。包括由于存在这些代理服务器,所以客户端与源服务器之间有许多条TCP连接,那么有一些HTTP头部是用于将客户端的实际IP地址,传递给服务器做相关用途的。

客户端与源服务器间存在多个代理

rest架构图,客户端与源服务器之间存在许多代理,而客户端每经过一个4层传输层以上的代理就建立一条新的TCP连接,而TCP连接中的源端的IP地址,只是这个TCP连接中的地址。比如说origin server,如果想基于客户端的IP地址来做负载均衡,或者是做限速限流,那么通过TCP连接是拿不到的。因为它只能拿到Gateway的IP地址。那么我们该怎么样传递客户端的IP地址到源服务器呢?我们可以通过一个HTTP头部。

问题:如何传递IP地址?

  1. TCP连接四元组(src ip, src port, dst ip, dst port)
  2. HTTP头部X-Forwarded-For用于传递IP
  3. HTTP头部X-Real-IP用于传递用户IP(非RFC规范中的,比如nginx就经常会使用X-Real-IP来传递用户的IP)
  4. 网络中存在许多反向代理(前提)

用户 内网IP: 192.168.0.x

ADSL 运营商公网IP:115.204.33.1

正向代理:IP地址 1.1.1.1 X-Forwarded-For:115.204.33.1 X-Real-IP: 115.204.33.1

CDN IP地址: 2.2.2.2 X-Forwarded-For:115.204.33.1 1.1.1.1 X-Real-IP: 115.204.33.1

反向代理 用户地址:115.204.33.1 remote_addr变量:2.2.2.2

消息的转发

  • Max-Forwards头部
    • 限制Proxy代理服务器的最大转发次数,仅对TRACE/OPTIONS方法有效(以防止过长的代理服务器的转发)
    • Max-Forwards=1*DIGIT (ABNF中的定义是1个数字)
  • Via头部
    • 指明经过的代理服务器名称及版本
    • Via = 1#(received-protocol RWS received-by [ RWS comment ])
      • received-protocol = [ protocol-name "/" ] protocol-version
      • reveived-by = ( uri-host [ ":" port ]) / pseudonym
      • pseudonym = token
  • Cache-Control:no-transform(还有很多其他的用途)
    • 禁止代理服务器修改响应包体(为了防止代理服务器修改服务器发向客户端的响应中的包体内容,那么HTTP规范又引入了一个新的Header字段叫做Cache-Control)

小结

这篇文章讨论了HTTP请求在历经重重的代理服务器后,在HTTP header的头部中能够反馈给我们的信息。特别是对于在服务器端获取到客户端的原始IP地址是非常有用的。

在上一篇文章中,我们介绍了客户端如何与服务器建立起连接,那么这篇文章中,将介绍服务器接收到连接以后,又怎样把HTTP消息进行路由和处理的常规流程。这里重点将介绍HOST这个请求头部。

HOST头部

  • HOST = uri-host [":" port] (ABNF对它的定义)
    • HTTP/1.1规范要求,不传递Host头部则返回400错误响应码(为什么HTTP/1.1规范要加入这样一个要求呢?因为在HTTP/1.0这个版本中,是没有HOST头部的。因为HTTP/1.0所在的上世纪90年代中,域名相对是比较少的,每一个服务器的ip地址仅对应一个域名,所以当用户已经对你的服务器建立起连接以后,你是不需要考虑匹配那个域名对应的服务的。但是后来我们发现,HTTP域名众多,但是ip地址相对比较少,所以我们引入了HOST头部。)
    • 为防止HTTP/1.0时代的陈旧的代理服务器还在我们的网络中,发向正向代理的请求request-target必须以absolute-form形式出现
      • request-line = method SP request-target SP HTTP-version CRLF
      • absolute-form = absolute-URI
        • absolute-URI = scheme ":" hier-part ["?" query ]

规范和实现间是有差距的

  • 关于Host头部:https://tools.ietf.org/html/rfc7230#section-5.4
    • A cliend MUST send a Host header field in all HTTP/1.1 request messages.
    • A server MUST respond with a 400 (Bad Request) status code to any HTTP/1.1 request message that lacks a Host header field and to any request message that contains more than one Host header field or a Host header field with an invalid field-value.

Host头部与消息的路由

(这里我们以典型的nginx来处理HOST头部的流程,来演示大部分的Web服务器在建立好TCP连接以后,究竟是怎样来寻找消息的处理模块的)

  1. 建立TCP连接
    • 确定服务器的ip地址
  2. 接收请求
  3. 寻找虚拟主机
    • 匹配Host头部域名(请求行URI中,可能拿到了absolute-form,也就是绝对形式中因为这里可以取到域名,或者从Header中的Host头部也可以取到域名,拿到这个域名以后,就会和这台Web服务器所支持的所有域名进行匹配,匹配选中以后,就会选中相应的模块进行处理)
  4. 寻找URI的处理代码(接着进行第二步的路由匹配,就是按照URI中的Path路径,一一匹配相应的代码,找到处理请求的代码,然后开始访问相应的资源)
    • 匹配URI
  5. 执行处理请求的代码
    • 访问资源
  6. 生成HTTP响应
    1. 各中间件基于PF架构串行修改响应
  7. 发送HTTP响应
  8. 记录访问日志

小结

这篇文章中介绍了HOST头部,以及如何基于HOST进行消息的路由,这里我们所介绍的服务器根据HOST头部路由的流程,与大多数Web服务器相似,对我们具有参考价值。