Dunwu Blog

大道至简,知易行难

深入理解 Java 注解

本文内容基于 JDK8。注解是 JDK5 引入的,后续 JDK 版本扩展了一些内容,本文中没有明确指明版本的注解都是 JDK5 就已经支持的注解。

简介

注解的形式

Java 中,注解是以 @ 字符开始的修饰符。如下:

1
2
@Override
void mySuperMethod() { ... }

注解可以包含命名或未命名的属性,并且这些属性有值。

1
2
3
4
5
@Author(
name = "Benjamin Franklin",
date = "3/27/2003"
)
class MyClass() { ... }

如果只有一个名为 value 的属性,那么名称可以省略,如:

1
2
@SuppressWarnings("unchecked")
void myMethod() { ... }

如果注解没有属性,则称为标记注解。如:@Override

什么是注解

从本质上来说,注解是一种标签,其实质上可以视为一种特殊的注释,如果没有解析它的代码,它并不比普通注释强。

解析一个注解往往有两种形式:

  • 编译期直接的扫描 - 编译器的扫描指的是编译器在对 java 代码编译字节码的过程中会检测到某个类或者方法被一些注解修饰,这时它就会对于这些注解进行某些处理。这种情况只适用于 JDK 内置的注解类。
  • 运行期的反射 - 如果要自定义注解,Java 编译器无法识别并处理这个注解,它只能根据该注解的作用范围来选择是否编译进字节码文件。如果要处理注解,必须利用反射技术,识别该注解以及它所携带的信息,然后做相应的处理。

注解的作用

注解有许多用途:

  • 编译器信息 - 编译器可以使用注解来检测错误或抑制警告。
  • 编译时和部署时的处理 - 程序可以处理注解信息以生成代码,XML 文件等。
  • 运行时处理 - 可以在运行时检查某些注解并处理。

作为 Java 程序员,多多少少都曾经历过被各种配置文件(xml、properties)支配的恐惧。过多的配置文件会使得项目难以维护。个人认为,使用注解以减少配置文件或代码,是注解最大的用处。

注解的代价

凡事有得必有失,注解技术同样如此。使用注解也有一定的代价:

  • 显然,它是一种侵入式编程,那么,自然就存在着增加程序耦合度的问题。
  • 自定义注解的处理需要在运行时,通过反射技术来获取属性。如果注解所修饰的元素是类的非 public 成员,也可以通过反射获取。这就违背了面向对象的封装性。
  • 注解所产生的问题,相对而言,更难以 debug 或定位。

但是,正所谓瑕不掩瑜,注解所付出的代价,相较于它提供的功能而言,还是可以接受的。

注解的应用范围

注解可以应用于类、字段、方法和其他程序元素的声明。

JDK8 开始,注解的应用范围进一步扩大,以下是新的应用范围:

类实例初始化表达式:

1
new @Interned MyObject();

类型转换:

1
myString = (@NonNull String) str;

实现接口的声明:

1
2
class UnmodifiableList<T> implements
@Readonly List<@Readonly T> {}

抛出异常声明:

1
2
void monitorTemperature()
throws @Critical TemperatureException {}

内置注解

JDK 中内置了以下注解:

  • @Override
  • @Deprecated
  • @SuppressWarnnings
  • @SafeVarargs(JDK7 引入)
  • @FunctionalInterface(JDK8 引入)

@Override

@Override 用于表明被修饰方法覆写了父类的方法。

如果试图使用 @Override 标记一个实际上并没有覆写父类的方法时,java 编译器会告警。

@Override 示例:

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
public class OverrideAnnotationDemo {

static class Person {
public String getName() {
return "getName";
}
}


static class Man extends Person {
@Override
public String getName() {
return "override getName";
}

/**
* 放开下面的注释,编译时会告警
*/
/*
@Override
public String getName2() {
return "override getName2";
}
*/
}

public static void main(String[] args) {
Person per = new Man();
System.out.println(per.getName());
}
}

@Deprecated

@Deprecated 用于标明被修饰的类或类成员、类方法已经废弃、过时,不建议使用。

@Deprecated 有一定的延续性:如果我们在代码中通过继承或者覆盖的方式使用了过时的类或类成员,即使子类或子方法没有标记为 @Deprecated,但编译器仍然会告警。

🔔 注意: @Deprecated 这个注解类型和 javadoc 中的 @deprecated 这个 tag 是有区别的:前者是 java 编译器识别的;而后者是被 javadoc 工具所识别用来生成文档(包含程序成员为什么已经过时、它应当如何被禁止或者替代的描述)。

@Deprecated 示例:

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
public class DeprecatedAnnotationDemo {
static class DeprecatedField {
@Deprecated
public static final String DEPRECATED_FIELD = "DeprecatedField";
}


static class DeprecatedMethod {
@Deprecated
public String print() {
return "DeprecatedMethod";
}
}


@Deprecated
static class DeprecatedClass {
public String print() {
return "DeprecatedClass";
}
}

public static void main(String[] args) {
System.out.println(DeprecatedField.DEPRECATED_FIELD);

DeprecatedMethod dm = new DeprecatedMethod();
System.out.println(dm.print());


DeprecatedClass dc = new DeprecatedClass();
System.out.println(dc.print());
}
}
//Output:
//DeprecatedField
//DeprecatedMethod
//DeprecatedClass

@SuppressWarnnings

@SuppressWarnings 用于关闭对类、方法、成员编译时产生的特定警告。

@SuppressWarning 不是一个标记注解。它有一个类型为 String[] 的数组成员,这个数组中存储的是要关闭的告警类型。对于 javac 编译器来讲,对 -Xlint 选项有效的警告名也同样对 @SuppressWarings 有效,同时编译器会忽略掉无法识别的警告名。

@SuppressWarning 示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@SuppressWarnings({"rawtypes", "unchecked"})
public class SuppressWarningsAnnotationDemo {
static class SuppressDemo<T> {
private T value;

public T getValue() {
return this.value;
}

public void setValue(T var) {
this.value = var;
}
}

@SuppressWarnings({"deprecation"})
public static void main(String[] args) {
SuppressDemo d = new SuppressDemo();
d.setValue("南京");
System.out.println("地名:" + d.getValue());
}
}

@SuppressWarnings 注解的常见参数值的简单说明:

  • deprecation - 使用了不赞成使用的类或方法时的警告;
  • unchecked - 执行了未检查的转换时的警告,例如当使用集合时没有用泛型 (Generics) 来指定集合保存的类型;
  • fallthrough - 当 Switch 程序块直接通往下一种情况而没有 Break 时的警告;
  • path - 在类路径、源文件路径等中有不存在的路径时的警告;
  • serial - 当在可序列化的类上缺少 serialVersionUID 定义时的警告;
  • finally - 任何 finally 子句不能正常完成时的警告;
  • all - 所有的警告。
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
@SuppressWarnings({"uncheck", "deprecation"})
public class InternalAnnotationDemo {

/**
* @SuppressWarnings 标记消除当前类的告警信息
*/
@SuppressWarnings({"deprecation"})
static class A {
public void method1() {
System.out.println("call method1");
}

/**
* @Deprecated 标记当前方法为废弃方法,不建议使用
*/
@Deprecated
public void method2() {
System.out.println("call method2");
}
}

/**
* @Deprecated 标记当前类为废弃类,不建议使用
*/
@Deprecated
static class B extends A {
/**
* @Override 标记显示指明当前方法覆写了父类或接口的方法
*/
@Override
public void method1() { }
}

public static void main(String[] args) {
A obj = new B();
obj.method1();
obj.method2();
}
}

@SafeVarargs

@SafeVarargs 在 JDK7 中引入。

@SafeVarargs 的作用是:告诉编译器,在可变长参数中的泛型是类型安全的。可变长参数是使用数组存储的,而数组和泛型不能很好的混合使用。

简单的说,数组元素的数据类型在编译和运行时都是确定的,而泛型的数据类型只有在运行时才能确定下来。因此,当把一个泛型存储到数组中时,编译器在编译阶段无法确认数据类型是否匹配,因此会给出警告信息;即如果泛型的真实数据类型无法和参数数组的类型匹配,会导致 ClassCastException 异常。

@SafeVarargs 注解使用范围:

  • @SafeVarargs 注解可以用于构造方法。
  • @SafeVarargs 注解可以用于 staticfinal 方法。

@SafeVarargs 示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class SafeVarargsAnnotationDemo {
/**
* 此方法实际上并不安全,不使用此注解,编译时会告警
*/
@SafeVarargs
static void wrongMethod(List<String>... stringLists) {
Object[] array = stringLists;
List<Integer> tmpList = Arrays.asList(42);
array[0] = tmpList; // 语法错误,但是编译不告警
String s = stringLists[0].get(0); // 运行时报 ClassCastException
}

public static void main(String[] args) {
List<String> list = new ArrayList<>();
list.add("A");
list.add("B");

List<String> list2 = new ArrayList<>();
list.add("1");
list.add("2");

wrongMethod(list, list2);
}
}

以上代码,如果不使用 @SafeVarargs ,编译时会告警

1
2
[WARNING] /D:/Codes/ZP/Java/javacore/codes/basics/src/main/java/io/github/dunwu/javacore/annotation/SafeVarargsAnnotationDemo.java: 某些输入文件使用了未经检查或不安全的操作。
[WARNING] /D:/Codes/ZP/Java/javacore/codes/basics/src/main/java/io/github/dunwu/javacore/annotation/SafeVarargsAnnotationDemo.java: 有关详细信息, 请使用 -Xlint:unchecked 重新编译。

@FunctionalInterface

@FunctionalInterface 在 JDK8 引入。

@FunctionalInterface 用于指示被修饰的接口是函数式接口。

需要注意的是,如果一个接口符合”函数式接口”定义,不加 @FunctionalInterface 也没关系;但如果编写的不是函数式接口,却使用 @FunctionInterface,那么编译器会报错。

什么是函数式接口?

函数式接口(Functional Interface)就是一个有且仅有一个抽象方法,但是可以有多个非抽象方法的接口。函数式接口可以被隐式转换为 lambda 表达式。

函数式接口的特点:

  • 接口有且只能有个一个抽象方法(抽象方法只有方法定义,没有方法体)。
  • 不能在接口中覆写 Object 类中的 public 方法(写了编译器也会报错)。
  • 允许有 default 实现方法。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class FunctionalInterfaceAnnotationDemo {

@FunctionalInterface
public interface Func1<T> {
void printMessage(T message);
}

/**
* @FunctionalInterface 修饰的接口中定义两个抽象方法,编译时会报错
* @param <T>
*/
/*@FunctionalInterface
public interface Func2<T> {
void printMessage(T message);
void printMessage2(T message);
}*/

public static void main(String[] args) {
Func1 func1 = message -> System.out.println(message);
func1.printMessage("Hello");
func1.printMessage(100);
}
}

元注解

JDK 中虽然内置了几个注解,但这远远不能满足开发过程中遇到的千变万化的需求。所以我们需要自定义注解,而这就需要用到元注解。

元注解的作用就是用于定义其它的注解

Java 中提供了以下元注解类型:

  • @Retention
  • @Target
  • @Documented
  • @Inherited(JDK8 引入)
  • @Repeatable(JDK8 引入)

这些类型和它们所支持的类在 java.lang.annotation 包中可以找到。下面我们看一下每个元注解的作用和相应分参数的使用说明。

@Retention

@Retention 指明了注解的保留级别。

@Retention 源码:

1
2
3
4
5
6
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.ANNOTATION_TYPE)
public @interface Retention {
RetentionPolicy value();
}

RetentionPolicy 是一个枚举类型,它定义了被 @Retention 修饰的注解所支持的保留级别:

  • RetentionPolicy.SOURCE - 标记的注解仅在源文件中有效,编译器会忽略。
  • RetentionPolicy.CLASS - 标记的注解在 class 文件中有效,JVM 会忽略。
  • RetentionPolicy.RUNTIME - 标记的注解在运行时有效。

@Retention 示例:

1
2
3
4
5
6
7
8
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Column {
public String name() default "fieldName";
public String setFuncName() default "setField";
public String getFuncName() default "getField";
public boolean defaultDBValue() default false;
}

@Documented

@Documented 表示无论何时使用指定的注解,都应使用 Javadoc(默认情况下,注释不包含在 Javadoc 中)。更多内容可以参考:Javadoc tools page

@Documented 示例:

1
2
3
4
5
6
7
8
9
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Column {
public String name() default "fieldName";
public String setFuncName() default "setField";
public String getFuncName() default "getField";
public boolean defaultDBValue() default false;
}

@Target

@Target 指定注解可以修饰的元素类型。

@Target 源码:

1
2
3
4
5
6
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.ANNOTATION_TYPE)
public @interface Target {
ElementType[] value();
}

ElementType 是一个枚举类型,它定义了被 @Target 修饰的注解可以应用的范围:

  • ElementType.ANNOTATION_TYPE - 标记的注解可以应用于注解类型。
  • ElementType.CONSTRUCTOR - 标记的注解可以应用于构造函数。
  • ElementType.FIELD - 标记的注解可以应用于字段或属性。
  • ElementType.LOCAL_VARIABLE - 标记的注解可以应用于局部变量。
  • ElementType.METHOD - 标记的注解可以应用于方法。
  • ElementType.PACKAGE - 标记的注解可以应用于包声明。
  • ElementType.PARAMETER - 标记的注解可以应用于方法的参数。
  • ElementType.TYPE - 标记的注解可以应用于类的任何元素。

@Target 示例:

1
2
3
4
5
6
7
8
9
10
11
@Target(ElementType.TYPE)
public @interface Table {
/**
* 数据表名称注解,默认值为类名称
* @return
*/
public String tableName() default "className";
}

@Target(ElementType.FIELD)
public @interface NoDBColumn {}

@Inherited

@Inherited 表示注解类型可以被继承(默认情况下不是这样)

表示自动继承注解类型。 如果注解类型声明中存在 @Inherited 元注解,则注解所修饰类的所有子类都将会继承此注解。

🔔 注意:@Inherited 注解类型是被标注过的类的子类所继承。类并不从它所实现的接口继承注解,方法并不从它所覆写的方法继承注解。

此外,当 @Inherited 类型标注的注解的 @RetentionRetentionPolicy.RUNTIME,则反射 API 增强了这种继承性。如果我们使用 java.lang.reflect 去查询一个 @Inherited 类型的注解时,反射代码检查将展开工作:检查类和其父类,直到发现指定的注解类型被发现,或者到达类继承结构的顶层。

1
2
3
4
5
6
@Inherited
public @interface Greeting {
public enum FontColor{ BULE,RED,GREEN};
String name();
FontColor fontColor() default FontColor.GREEN;
}

@Repeatable

@Repeatable 表示注解可以重复使用。

以 Spring @Scheduled 为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Target({ElementType.METHOD, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Schedules {
Scheduled[] value();
}

@Target({ElementType.METHOD, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Repeatable(Schedules.class)
public @interface Scheduled {
// ...
}

应用示例:

1
2
3
4
5
6
public class TaskRunner {

@Scheduled("0 0/15 * * * ?")
@Scheduled("0 0 12 * ?")
public void task1() {}
}

自定义注解

使用 @interface 自定义注解时,自动继承了 java.lang.annotation.Annotation 接口,由编译程序自动完成其他细节。在定义注解时,不能继承其他的注解或接口。@interface 用来声明一个注解,其中的每一个方法实际上是声明了一个配置参数。方法的名称就是参数的名称,返回值类型就是参数的类型(返回值类型只能是基本类型、Class、String、enum)。可以通过 default 来声明参数的默认值。

这里,我会通过实现一个名为 RegexValid 的正则校验注解工具来展示自定义注解的全步骤。

注解的定义

注解的语法格式如下:

1
public @interface 注解名 {定义体}

我们来定义一个注解:

1
2
3
4
@Documented
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface RegexValid {}

说明:

通过上一节对于元注解 @Target@Retention@Documented 的说明,这里就很容易理解了。

  • 上面的代码中定义了一个名为 @RegexValid 的注解。
  • @Documented 表示 @RegexValid 应该使用 javadoc。
  • @Target({ElementType.FIELD, ElementType.PARAMETER}) 表示 @RegexValid 可以在类成员或方法参数上修饰。
  • @Retention(RetentionPolicy.RUNTIME) 表示 @RegexValid 在运行时有效。

此时,我们已经定义了一个没有任何属性的注解,如果到此为止,它仅仅是一个标记注解。作为正则工具,没有属性可什么也做不了。接下来,我们将为它添加注解属性。

注解属性

注解属性的语法形式如下:

1
[访问级别修饰符] [数据类型] 名称() default 默认值;

例如,我们要定义在注解中定义一个名为 value 的字符串属性,其默认值为空字符串,访问级别为默认级别,那么应该定义如下:

1
String value() default "";

🔔 注意:**在注解中,我们定义属性时,属性名后面需要加 ()**。

定义注解属性有以下要点:

  • 注解属性只能使用 public 或默认访问级别(即不指定访问级别修饰符)修饰

  • 注解属性的数据类型有限制要求。支持的数据类型如下:

    • 所有基本数据类型(byte、char、short、int、long、float、double、boolean)
    • String 类型
    • Class 类
    • enum 类型
    • Annotation 类型
    • 以上所有类型的数组
  • 注解属性必须有确定的值,建议指定默认值。注解属性只能通过指定默认值或使用注解时指定属性值,相较之下,指定默认值的方式更为可靠。注解属性如果是引用类型,不可以为 null。这个约束使得注解处理器很难判断注解属性是默认值,或是使用注解时所指定的属性值。为此,我们设置默认值时,一般会定义一些特殊的值,例如空字符串或者负数。

  • 如果注解中只有一个属性值,最好将其命名为 value。因为,指定属性名为 value,在使用注解时,指定 value 的值可以不指定属性名称。

1
2
3
// 这两种方式效果相同
@RegexValid("^((\\+)?86\\s*)?((13[0-9])|(15([0-3]|[5-9]))|(18[0,2,5-9]))\\d{8}$")
@RegexValid(value = "^((\\+)?86\\s*)?((13[0-9])|(15([0-3]|[5-9]))|(18[0,2,5-9]))\\d{8}$")

示例:

了解了注解属性的定义要点,让我们来为 @RegexValid 注解定义几个属性。

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
@Documented
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface RegexValid {
enum Policy {
// @formatter:off
EMPTY(null),
DATE("^(?:(?!0000)[0-9]{4}([-/.]?)(?:(?:0?[1-9]|1[0-2])\\1(?:0?[1-9]|1[0-9]|2[0-8])|(?:0?[13-9]|1[0-2])\\1"
+ "(?:29|30)|(?:0?[13578]|1[02])\\1(?:31))|(?:[0-9]{2}(?:0[48]|[2468][048]|[13579][26])|"
+ "(?:0[48]|[2468][048]|[13579][26])00)([-/.]?)0?2\\2(?:29))$"),
MAIL("^[A-Za-z0-9](([_\\.\\-]?[a-zA-Z0-9]+)*)@([A-Za-z0-9]+)(([\\.\\-]?[a-zA-Z0-9]+)*)\\.([A-Za-z]{2,})$");
// @formatter:on

private String policy;

Policy(String policy) {
this.policy = policy;
}

public String getPolicy() {
return policy;
}
}

String value() default "";
Policy policy() default Policy.EMPTY;
}

说明:

在上面的示例代码中,我们定义了两个注解属性:String 类型的 value 属性和 Policy 枚举类型的 policy 属性。Policy 枚举中定义了几个默认的正则表达式,这是为了直接使用这几个常用表达式去正则校验。考虑到,我们可能需要自己传入一些自定义正则表达式去校验其他场景,所以定义了 value 属性,允许使用者传入正则表达式。

至此,@RegexValid 的声明已经结束。但是,程序仍不知道如何处理 @RegexValid 这个注解。我们还需要定义注解处理器。

注解处理器

如果没有用来读取注解的方法和工作,那么注解也就不会比注释更有用处了。使用注解的过程中,很重要的一部分就是创建于使用注解处理器。JDK5 扩展了反射机制的 API,以帮助程序员快速的构造自定义注解处理器。

java.lang.annotation.Annotation 是一个接口,程序可以通过反射来获取指定程序元素的注解对象,然后通过注解对象来获取注解里面的元数据

Annotation 接口源码如下:

1
2
3
4
5
6
7
8
9
public interface Annotation {
boolean equals(Object obj);

int hashCode();

String toString();

Class<? extends Annotation> annotationType();
}

除此之外,Java 中支持注解处理器接口 java.lang.reflect.AnnotatedElement ,该接口代表程序中可以接受注解的程序元素,该接口主要有如下几个实现类:

  • Class - 类定义
  • Constructor - 构造器定义
  • Field - 累的成员变量定义
  • Method - 类的方法定义
  • Package - 类的包定义

java.lang.reflect 包下主要包含一些实现反射功能的工具类。实际上,java.lang.reflect 包所有提供的反射 API 扩充了读取运行时注解信息的能力。当一个注解类型被定义为运行时的注解后,该注解才能是运行时可见,当 class 文件被装载时被保存在 class 文件中的注解才会被虚拟机读取。
AnnotatedElement 接口是所有程序元素(Class、Method 和 Constructor)的父接口,所以程序通过反射获取了某个类的AnnotatedElement 对象之后,程序就可以调用该对象的如下四个个方法来访问注解信息:

  • getAnnotation - 返回该程序元素上存在的、指定类型的注解,如果该类型注解不存在,则返回 null。
  • getAnnotations - 返回该程序元素上存在的所有注解。
  • isAnnotationPresent - 判断该程序元素上是否包含指定类型的注解,存在则返回 true,否则返回 false。
  • getDeclaredAnnotations - 返回直接存在于此元素上的所有注释。与此接口中的其他方法不同,该方法将忽略继承的注释。(如果没有注释直接存在于此元素上,则返回长度为零的一个数组。)该方法的调用者可以随意修改返回的数组;这不会对其他调用者返回的数组产生任何影响。

了解了以上内容,让我们来实现 @RegexValid 的注解处理器:

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
import java.lang.reflect.Field;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class RegexValidUtil {
public static boolean check(Object obj) throws Exception {
boolean result = true;
StringBuilder sb = new StringBuilder();
Field[] fields = obj.getClass().getDeclaredFields();
for (Field field : fields) {
// 判断成员是否被 @RegexValid 注解所修饰
if (field.isAnnotationPresent(RegexValid.class)) {
RegexValid valid = field.getAnnotation(RegexValid.class);

// 如果 value 为空字符串,说明没有注入自定义正则表达式,改用 policy 属性
String value = valid.value();
if ("".equals(value)) {
RegexValid.Policy policy = valid.policy();
value = policy.getPolicy();
}

// 通过设置 setAccessible(true) 来访问私有成员
field.setAccessible(true);
Object fieldObj = null;
try {
fieldObj = field.get(obj);
} catch (IllegalAccessException e) {
e.printStackTrace();
}
if (fieldObj == null) {
sb.append("\n")
.append(String.format("%s 类中的 %s 字段不能为空!", obj.getClass().getName(), field.getName()));
result = false;
} else {
if (fieldObj instanceof String) {
String text = (String) fieldObj;
Pattern p = Pattern.compile(value);
Matcher m = p.matcher(text);
result = m.matches();
if (!result) {
sb.append("\n").append(String.format("%s 不是合法的 %s !", text, field.getName()));
}
} else {
sb.append("\n").append(
String.format("%s 类中的 %s 字段不是字符串类型,不能使用此注解校验!", obj.getClass().getName(), field.getName()));
result = false;
}
}
}
}

if (sb.length() > 0) {
throw new Exception(sb.toString());
}
return result;
}
}

说明:

以上示例中的注解处理器,执行步骤如下:

  1. 通过 getDeclaredFields 反射方法获取传入对象的所有成员。
  2. 遍历成员,使用 isAnnotationPresent 判断成员是否被指定注解所修饰,如果不是,直接跳过。
  3. 如果成员被注解所修饰,通过 RegexValid valid = field.getAnnotation(RegexValid.class); 这样的形式获取,注解实例化对象,然后,就可以使用 valid.value()valid.policy() 这样的形式获取注解中设定的属性值。
  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
public class RegexValidDemo {
static class User {
private String name;
@RegexValid(policy = RegexValid.Policy.DATE)
private String date;
@RegexValid(policy = RegexValid.Policy.MAIL)
private String mail;
@RegexValid("^((\\+)?86\\s*)?((13[0-9])|(15([0-3]|[5-9]))|(18[0,2,5-9]))\\d{8}$")
private String phone;

public User(String name, String date, String mail, String phone) {
this.name = name;
this.date = date;
this.mail = mail;
this.phone = phone;
}

@Override
public String toString() {
return "User{" + "name='" + name + '\'' + ", date='" + date + '\'' + ", mail='" + mail + '\'' + ", phone='"
+ phone + '\'' + '}';
}
}

static void printDate(@RegexValid(policy = RegexValid.Policy.DATE) String date){
System.out.println(date);
}

public static void main(String[] args) throws Exception {
User user = new User("Tom", "1990-01-31", "xxx@163.com", "18612341234");
User user2 = new User("Jack", "2019-02-29", "sadhgs", "183xxxxxxxx");
if (RegexValidUtil.check(user)) {
System.out.println(user + "正则校验通过");
}
if (RegexValidUtil.check(user2)) {
System.out.println(user2 + "正则校验通过");
}
}
}

小结

img

img

img

img

参考资料

JDK8 入门指南

JDK8 升级常见问题章节是我个人的经验整理。其他内容基本翻译自 java8-tutorial

📦 本文以及示例源码已归档在 javacore

关键词:StreamlambdaOptional@FunctionalInterface

Default Methods for Interfaces(接口的默认方法)

Java 8 使我们能够通过使用 default 关键字将非抽象方法实现添加到接口。这个功能也被称为虚拟扩展方法。

这是我们的第一个例子:

1
2
3
4
5
6
7
interface Formula {
double calculate(int a);

default double sqrt(int a) {
return Math.sqrt(a);
}
}

除了抽象方法 calculate ,接口 Formula 还定义了默认方法 sqrt。具体类只需要执行抽象方法计算。默认的方法 sqrt 可以用于开箱即用。

1
2
3
4
5
6
7
8
9
Formula formula = new Formula() {
@Override
public double calculate(int a) {
return sqrt(a * 100);
}
};

formula.calculate(100); // 100.0
formula.sqrt(16); // 4.0

Formula 被实现为一个匿名对象。代码非常冗长:用于 sqrt(a * 100) 这样简单的计算的 6 行代码。正如我们将在下一节中看到的,在 Java 8 中实现单个方法对象有更好的方法。

Lambda expressions(Lambda 表达式)

让我们从一个简单的例子来说明如何在以前版本的 Java 中对字符串列表进行排序:

1
2
3
4
5
6
7
8
List<String> names = Arrays.asList("peter", "anna", "mike", "xenia");

Collections.sort(names, new Comparator<String>() {
@Override
public int compare(String a, String b) {
return b.compareTo(a);
}
});

静态工具方法 Collections.sort 为了对指定的列表进行排序,接受一个列表和一个比较器。您会发现自己经常需要创建匿名比较器并将其传递给排序方法。

Java 8 使用更简短的 lambda 表达式来避免常常创建匿名对象的问题:

1
2
3
Collections.sort(names, (String a, String b) -> {
return b.compareTo(a);
});

如您所见,这段代码比上段代码简洁很多。但是,还可以更加简洁:

1
Collections.sort(names, (String a, String b) -> b.compareTo(a));

这行代码中,你省去了花括号 {} 和 return 关键字。但是,这还不算完,它还可以再进一步简洁:

1
names.sort((a, b) -> b.compareTo(a));

列表现在有一个 sort 方法。此外,java 编译器知道参数类型,所以你可以不指定入参的数据类型。让我们深入探讨如何使用 lambda 表达式。

Functional Interfaces(函数接口)

lambda 表达式如何适应 Java 的类型系统?每个 lambda 对应一个由接口指定的类型。一个所谓的函数接口必须包含一个抽象方法声明。该类型的每个 lambda 表达式都将与此抽象方法匹配。由于默认方法不是抽象的,所以你可以自由地添加默认方法到你的函数接口。

只要保证接口仅包含一个抽象方法,就可以使用任意的接口作为 lambda 表达式。为确保您的接口符合要求,您应该添加 @FunctionalInterface 注解。编译器注意到这个注解后,一旦您尝试在接口中添加第二个抽象方法声明,编译器就会抛出编译器错误。

示例:

1
2
3
4
@FunctionalInterface
interface Converter<F, T> {
T convert(F from);
}
1
2
3
Converter<String, Integer> converter = (from) -> Integer.valueOf(from);
Integer converted = converter.convert("123");
System.out.println(converted); // 123

请记住,如果 @FunctionalInterface 注解被省略,代码也是有效的。

Method and Constructor References(方法和构造器引用)

上面的示例代码可以通过使用静态方法引用进一步简化:

1
2
3
Converter<String, Integer> converter = Integer::valueOf;
Integer converted = converter.convert("123");
System.out.println(converted); // 123

Java 8 允许您通过 :: 关键字传递方法或构造函数的引用。上面的例子展示了如何引用一个静态方法。但是我们也可以引用对象方法:

1
2
3
4
5
class Something {
String startsWith(String s) {
return String.valueOf(s.charAt(0));
}
}
1
2
3
4
Something something = new Something();
Converter<String, String> converter = something::startsWith;
String converted = converter.convert("Java");
System.out.println(converted); // "J"

我们来观察一下 :: 关键字是如何作用于构造器的。首先,我们定义一个有多个构造器的示例类。

1
2
3
4
5
6
7
8
9
10
11
class Person {
String firstName;
String lastName;

Person() {}

Person(String firstName, String lastName) {
this.firstName = firstName;
this.lastName = lastName;
}
}

接着,我们指定一个用于创建 Person 对象的 PersonFactory 接口。

1
2
3
interface PersonFactory<P extends Person> {
P create(String firstName, String lastName);
}

我们不是手动实现工厂,而是通过构造引用将所有东西粘合在一起:

1
2
PersonFactory<Person> personFactory = Person::new;
Person person = personFactory.create("Peter", "Parker");

我们通过 Person::new 来创建一个 Person 构造器的引用。Java 编译器会根据PersonFactory.create 的签名自动匹配正确的构造器。

Lambda Scopes(Lambda 作用域)

从 lambda 表达式访问外部作用域变量与匿名对象非常相似。您可以访问本地外部作用域的常量以及实例的成员变量和静态变量。

Accessing local variables(访问本地变量)

我们可以访问 lambda 表达式作用域外部的常量:

1
2
3
4
5
final int num = 1;
Converter<Integer, String> stringConverter =
(from) -> String.valueOf(from + num);

stringConverter.convert(2); // 3

不同于匿名对象的是:这个变量 num 不是一定要被 final 修饰。下面的代码一样合法:

1
2
3
4
5
int num = 1;
Converter<Integer, String> stringConverter =
(from) -> String.valueOf(from + num);

stringConverter.convert(2); // 3

但是,num 必须是隐式常量的。下面的代码不能编译通过:

1
2
3
4
int num = 1;
Converter<Integer, String> stringConverter =
(from) -> String.valueOf(from + num);
num = 3;

此外,在 lambda 表达式中对 num 做写操作也是被禁止的。

Accessing fields and static variables(访问成员变量和静态变量)

与局部变量相比,我们既可以在 lambda 表达式中读写实例的成员变量,也可以读写实例的静态变量。这种行为在匿名对象中是众所周知的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Lambda4 {
static int outerStaticNum;
int outerNum;

void testScopes() {
Converter<Integer, String> stringConverter1 = (from) -> {
outerNum = 23;
return String.valueOf(from);
};

Converter<Integer, String> stringConverter2 = (from) -> {
outerStaticNum = 72;
return String.valueOf(from);
};
}
}

Accessing Default Interface Methods(访问默认的接口方法)

还记得第一节的 formula 例子吗? Formula 接口定义了一个默认方法 sqrt,它可以被每个 formula 实例(包括匿名对象)访问。这个特性不适用于 lambda 表达式。

默认方法不能被 lambda 表达式访问。下面的代码不能编译通过:

1
Formula formula = (a) -> sqrt(a * 100);

Built-in Functional Interfaces(内置函数接口)

JDK 1.8 API 包含许多内置的功能接口。它们中的一些在较早的 Java 版本(比如 ComparatorRunnable)中是众所周知的。这些现有的接口通过 @FunctionalInterfaceannotation 注解被扩展为支持 Lambda。

但是,Java 8 API 也提供了不少新的函数接口。其中一些新接口在 Google Guava 库中是众所周知的。即使您熟悉这个库,也应该密切关注如何通过一些有用的方法扩展来扩展这些接口。

Predicates

Predicate 是只有一个参数的布尔值函数。该接口包含各种默认方法,用于将谓词组合成复杂的逻辑术语(与、或、非)

1
2
3
4
5
6
7
8
9
10
Predicate<String> predicate = (s) -> s.length() > 0;

predicate.test("foo"); // true
predicate.negate().test("foo"); // false

Predicate<Boolean> nonNull = Objects::nonNull;
Predicate<Boolean> isNull = Objects::isNull;

Predicate<String> isEmpty = String::isEmpty;
Predicate<String> isNotEmpty = isEmpty.negate();

Functions

Function 接受一个参数并产生一个结果。可以使用默认方法将多个函数链接在一起(compose、andThen)。

1
2
3
4
Function<String, Integer> toInteger = Integer::valueOf;
Function<String, String> backToString = toInteger.andThen(String::valueOf);

backToString.apply("123"); // "123"

Suppliers

Supplier 产生一个泛型结果。与 Function 不同,Supplier 不接受参数。

1
2
Supplier<Person> personSupplier = Person::new;
personSupplier.get(); // new Person

Consumers

Consumer 表示要在一个输入参数上执行的操作。

1
2
Consumer<Person> greeter = (p) -> System.out.println("Hello, " + p.firstName);
greeter.accept(new Person("Luke", "Skywalker"));

Comparators

比较器在老版本的 Java 中是众所周知的。 Java 8 为接口添加了各种默认方法。

1
2
3
4
5
6
7
Comparator<Person> comparator = (p1, p2) -> p1.firstName.compareTo(p2.firstName);

Person p1 = new Person("John", "Doe");
Person p2 = new Person("Alice", "Wonderland");

comparator.compare(p1, p2); // > 0
comparator.reversed().compare(p1, p2); // < 0

Optionals

Optional 不是功能性接口,而是防止 NullPointerException 的好工具。这是下一节的一个重要概念,所以让我们快速看看 Optional 是如何工作的。

Optional 是一个简单的容器,其值可以是 null 或非 null。想想一个可能返回一个非空结果的方法,但有时候什么都不返回。不是返回 null,而是返回 Java 8 中的 Optional

1
2
3
4
5
6
7
Optional<String> optional = Optional.of("bam");

optional.isPresent(); // true
optional.get(); // "bam"
optional.orElse("fallback"); // "bam"

optional.ifPresent((s) -> System.out.println(s.charAt(0))); // "b"

Streams

java.util.Stream 表示可以在其上执行一个或多个操作的元素序列。流操作是中间或终端。当终端操作返回一个特定类型的结果时,中间操作返回流本身,所以你可以链接多个方法调用。流在源上创建,例如一个 java.util.Collection 像列表或集合(不支持映射)。流操作既可以按顺序执行,也可以并行执行。

流是非常强大的,所以,我写了一个独立的 Java8 Streams 教程您还应该查看 Sequent,将其作为 Web 的类似库。

我们先来看看顺序流如何工作。首先,我们以字符串列表的形式创建一个示例源代码:

1
2
3
4
5
6
7
8
9
List<String> stringCollection = new ArrayList<>();
stringCollection.add("ddd2");
stringCollection.add("aaa2");
stringCollection.add("bbb1");
stringCollection.add("aaa1");
stringCollection.add("bbb3");
stringCollection.add("ccc");
stringCollection.add("bbb2");
stringCollection.add("ddd1");

Java 8 中的集合已被扩展,因此您可以通过调用 Collection.stream()Collection.parallelStream() 来简单地创建流。以下各节介绍最常见的流操作。

Filter

过滤器接受一个谓词来过滤流的所有元素。这个操作是中间的,使我们能够调用另一个流操作(forEach)的结果。 ForEach 接受一个消费者被执行的过滤流中的每个元素。 ForEach 是一个终端操作。它是无效的,所以我们不能调用另一个流操作。

1
2
3
4
5
6
stringCollection
.stream()
.filter((s) -> s.startsWith("a"))
.forEach(System.out::println);

// "aaa2", "aaa1"

Sorted

排序是一个中间操作,返回流的排序视图。元素按自然顺序排序,除非您传递自定义比较器。

1
2
3
4
5
6
7
stringCollection
.stream()
.sorted()
.filter((s) -> s.startsWith("a"))
.forEach(System.out::println);

// "aaa1", "aaa2"

请记住,排序只会创建流的排序视图,而不会操纵支持的集合的排序。 stringCollection 的排序是不变的:

1
2
System.out.println(stringCollection);
// ddd2, aaa2, bbb1, aaa1, bbb3, ccc, bbb2, ddd1

Map

中间操作映射通过给定函数将每个元素转换为另一个对象。以下示例将每个字符串转换为大写字母字符串。但是您也可以使用 map 将每个对象转换为另一种类型。结果流的泛型类型取决于您传递给 map 的函数的泛型类型。

1
2
3
4
5
6
7
stringCollection
.stream()
.map(String::toUpperCase)
.sorted((a, b) -> b.compareTo(a))
.forEach(System.out::println);

// "DDD2", "DDD1", "CCC", "BBB3", "BBB2", "AAA2", "AAA1"

Match

可以使用各种匹配操作来检查某个谓词是否与流匹配。所有这些操作都是终端并返回布尔结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
boolean anyStartsWithA =
stringCollection
.stream()
.anyMatch((s) -> s.startsWith("a"));

System.out.println(anyStartsWithA); // true

boolean allStartsWithA =
stringCollection
.stream()
.allMatch((s) -> s.startsWith("a"));

System.out.println(allStartsWithA); // false

boolean noneStartsWithZ =
stringCollection
.stream()
.noneMatch((s) -> s.startsWith("z"));

System.out.println(noneStartsWithZ); // true

Count

Count 是一个终端操作,返回流中元素的个数。

1
2
3
4
5
6
7
long startsWithB =
stringCollection
.stream()
.filter((s) -> s.startsWith("b"))
.count();

System.out.println(startsWithB); // 3

Reduce

该终端操作使用给定的功能对流的元素进行缩减。结果是一个 Optional 持有缩小后的值。

1
2
3
4
5
6
7
8
Optional<String> reduced =
stringCollection
.stream()
.sorted()
.reduce((s1, s2) -> s1 + "#" + s2);

reduced.ifPresent(System.out::println);
// "aaa1##aaa2##bbb1##bbb2##bbb3##ccc##ddd1##ddd2"

Parallel Streams

如上所述,流可以是顺序的也可以是并行的。顺序流上的操作在单个线程上执行,而并行流上的操作在多个线程上同时执行。

以下示例演示了通过使用并行流提高性能是多么容易。

首先,我们创建一个较大的独特元素的列表:

1
2
3
4
5
6
int max = 1000000;
List<String> values = new ArrayList<>(max);
for (int i = 0; i < max; i++) {
UUID uuid = UUID.randomUUID();
values.add(uuid.toString());
}

现在我们测量对这个集合进行排序所花费的时间。

Sequential Sort

1
2
3
4
5
6
7
8
9
10
11
long t0 = System.nanoTime();

long count = values.stream().sorted().count();
System.out.println(count);

long t1 = System.nanoTime();

long millis = TimeUnit.NANOSECONDS.toMillis(t1 - t0);
System.out.println(String.format("sequential sort took: %d ms", millis));

// sequential sort took: 899 ms

Parallel Sort

1
2
3
4
5
6
7
8
9
10
11
long t0 = System.nanoTime();

long count = values.parallelStream().sorted().count();
System.out.println(count);

long t1 = System.nanoTime();

long millis = TimeUnit.NANOSECONDS.toMillis(t1 - t0);
System.out.println(String.format("parallel sort took: %d ms", millis));

// parallel sort took: 472 ms

如你所见,两个代码段差不多,但是并行排序快了近 50%。你所需做的仅仅是将 stream() 改为 parallelStream()

Maps

如前所述,map 不直接支持流。Map 接口本身没有可用的 stream() 方法,但是你可以通过 map.keySet().stream()map.values().stream()map.entrySet().stream() 创建指定的流。

此外,map 支持各种新的、有用的方法来处理常见任务。

1
2
3
4
5
6
7
Map<Integer, String> map = new HashMap<>();

for (int i = 0; i < 10; i++) {
map.putIfAbsent(i, "val" + i);
}

map.forEach((id, val) -> System.out.println(val));

上面的代码应该是自我解释的:putIfAbsent 阻止我们写入额外的空值检查;forEach 接受消费者为 map 的每个值实现操作。

这个例子展示了如何利用函数来计算 map 上的代码:

1
2
3
4
5
6
7
8
9
10
11
map.computeIfPresent(3, (num, val) -> val + num);
map.get(3); // val33

map.computeIfPresent(9, (num, val) -> null);
map.containsKey(9); // false

map.computeIfAbsent(23, num -> "val" + num);
map.containsKey(23); // true

map.computeIfAbsent(3, num -> "bam");
map.get(3); // val33

接下来,我们学习如何删除给定键的条目,只有当前键映射到给定值时:

1
2
3
4
5
map.remove(3, "val3");
map.get(3); // val33

map.remove(3, "val33");
map.get(3); // null

另一个有用方法:

1
map.getOrDefault(42, "not found");  // not found

合并一个 map 的 entry 很简单:

1
2
3
4
5
map.merge(9, "val9", (value, newValue) -> value.concat(newValue));
map.get(9); // val9

map.merge(9, "concat", (value, newValue) -> value.concat(newValue));
map.get(9); // val9concat

如果不存在该键的条目,合并或者将键/值放入 map 中;否则将调用合并函数来更改现有值。

Date API

Java 8 在 java.time 包下新增了一个全新的日期和时间 API。新的日期 API 与 Joda-Time 库相似,但不一样。以下示例涵盖了此新 API 的最重要部分。

Clock

Clock 提供对当前日期和时间的访问。Clock 知道一个时区,可以使用它来代替 System.currentTimeMillis() ,获取从 Unix EPOCH 开始的以毫秒为单位的当前时间。时间线上的某一时刻也由类 Instant 表示。 Instants 可以用来创建遗留的 java.util.Date 对象。

1
2
3
4
5
Clock clock = Clock.systemDefaultZone();
long millis = clock.millis();

Instant instant = clock.instant();
Date legacyDate = Date.from(instant); // legacy java.util.Date

Timezones

时区由 ZoneId 表示。他们可以很容易地通过静态工厂方法访问。时区定义了某一时刻和当地日期、时间之间转换的重要偏移量。

1
2
3
4
5
6
7
8
9
10
System.out.println(ZoneId.getAvailableZoneIds());
// prints all available timezone ids

ZoneId zone1 = ZoneId.of("Europe/Berlin");
ZoneId zone2 = ZoneId.of("Brazil/East");
System.out.println(zone1.getRules());
System.out.println(zone2.getRules());

// ZoneRules[currentStandardOffset=+01:00]
// ZoneRules[currentStandardOffset=-03:00]

LocalTime

LocalTime 代表没有时区的时间,例如晚上 10 点或 17:30:15。以下示例为上面定义的时区创建两个本地时间。然后我们比较两次,并计算两次之间的小时和分钟的差异。

1
2
3
4
5
6
7
8
9
10
LocalTime now1 = LocalTime.now(zone1);
LocalTime now2 = LocalTime.now(zone2);

System.out.println(now1.isBefore(now2)); // false

long hoursBetween = ChronoUnit.HOURS.between(now1, now2);
long minutesBetween = ChronoUnit.MINUTES.between(now1, now2);

System.out.println(hoursBetween); // -3
System.out.println(minutesBetween); // -239

LocalTime 带有各种工厂方法,以简化新实例的创建,包括解析时间字符串。

1
2
3
4
5
6
7
8
9
10
LocalTime late = LocalTime.of(23, 59, 59);
System.out.println(late); // 23:59:59

DateTimeFormatter germanFormatter =
DateTimeFormatter
.ofLocalizedTime(FormatStyle.SHORT)
.withLocale(Locale.GERMAN);

LocalTime leetTime = LocalTime.parse("13:37", germanFormatter);
System.out.println(leetTime); // 13:37

LocalDate

LocalDate 表示不同的日期,例如:2014 年 3 月 11 日。它是不可变的,并且与 LocalTime 完全类似。该示例演示如何通过加减日、月或年来计算新日期。请记住,每个操作都会返回一个新的实例。

1
2
3
4
5
6
7
LocalDate today = LocalDate.now();
LocalDate tomorrow = today.plus(1, ChronoUnit.DAYS);
LocalDate yesterday = tomorrow.minusDays(2);

LocalDate independenceDay = LocalDate.of(2014, Month.JULY, 4);
DayOfWeek dayOfWeek = independenceDay.getDayOfWeek();
System.out.println(dayOfWeek); // FRIDAY

从一个字符串中解析出 LocalDate 对象,和解析 LocalTime 一样的简单:

1
2
3
4
5
6
7
DateTimeFormatter germanFormatter =
DateTimeFormatter
.ofLocalizedDate(FormatStyle.MEDIUM)
.withLocale(Locale.GERMAN);

LocalDate xmas = LocalDate.parse("24.12.2014", germanFormatter);
System.out.println(xmas); // 2014-12-24

LocalDateTime

LocalDateTime 表示日期时间。它将日期和时间组合成一个实例。 LocalDateTime 是不可变的,其作用类似于 LocalTimeLocalDate。我们可以利用方法去获取日期时间中某个单位的值。

1
2
3
4
5
6
7
8
9
10
LocalDateTime sylvester = LocalDateTime.of(2014, Month.DECEMBER, 31, 23, 59, 59);

DayOfWeek dayOfWeek = sylvester.getDayOfWeek();
System.out.println(dayOfWeek); // WEDNESDAY

Month month = sylvester.getMonth();
System.out.println(month); // DECEMBER

long minuteOfDay = sylvester.getLong(ChronoField.MINUTE_OF_DAY);
System.out.println(minuteOfDay); // 1439

通过一个时区的附加信息可以转为一个实例。这个实例很容易转为java.util.Date 类型。

1
2
3
4
5
6
Instant instant = sylvester
.atZone(ZoneId.systemDefault())
.toInstant();

Date legacyDate = Date.from(instant);
System.out.println(legacyDate); // Wed Dec 31 23:59:59 CET 2014

日期时间的格式化类似于 Date 或 Time。我们可以使用自定义模式创建格式化程序,而不是使用预定义的格式。

1
2
3
4
5
6
7
DateTimeFormatter formatter =
DateTimeFormatter
.ofPattern("MMM dd, yyyy - HH:mm");

LocalDateTime parsed = LocalDateTime.parse("Nov 03, 2014 - 07:13", formatter);
String string = formatter.format(parsed);
System.out.println(string); // Nov 03, 2014 - 07:13

不同于 java.text.NumberFormatDateTimeFormatter 是不可变且线程安全的

更多关于日期格式化的内容可以参考这里.

Annotations

Java 8 中的注释是可重复的。让我们直接看一个例子来解决这个问题。

首先,我们定义一个包含实际注释数组的外层注释:

1
2
3
4
5
6
7
8
@interface Hints {
Hint[] value();
}

@Repeatable(Hints.class)
@interface Hint {
String value();
}

Java8 允许我们通过使用 @Repeatable 注解来引入多个同类型的注解。

Variant 1: 使用容器注解 (老套路)

1
2
@Hints({@Hint("hint1"), @Hint("hint2")})
class Person {}

Variant 2: 使用 repeatable 注解 (新套路)

1
2
3
@Hint("hint1")
@Hint("hint2")
class Person {}

使用场景 2,Java 编译器隐式地设置了 @Hints 注解。

这对于通过反射来读取注解信息很重要。

1
2
3
4
5
6
7
8
Hint hint = Person.class.getAnnotation(Hint.class);
System.out.println(hint); // null

Hints hints1 = Person.class.getAnnotation(Hints.class);
System.out.println(hints1.value().length); // 2

Hint[] hints2 = Person.class.getAnnotationsByType(Hint.class);
System.out.println(hints2.length); // 2

尽管,我们从没有在 Person 类上声明 @Hints 注解,但是仍可以通过getAnnotation(Hints.class) 读取它。然而,更便利的方式是 getAnnotationsByType ,它可以直接访问所有 @Hint 注解。

此外,Java 8 中的注释使用扩展了两个新的目标:

1
2
@Target({ElementType.TYPE_PARAMETER, ElementType.TYPE_USE})
@interface MyAnnotation {}

水平线以上为 java8-tutorial 翻译内容。


JDK8 升级常见问题

JDK8 发布很久了,它提供了许多吸引人的新特性,能够提高编程效率。

如果是新的项目,使用 JDK8 当然是最好的选择。但是,对于一些老的项目,升级到 JDK8 则存在一些兼容性问题,是否升级需要酌情考虑。

近期,我在工作中遇到一个任务,将部门所有项目的 JDK 版本升级到 1.8 (老版本大多是 1.6)。在这个过程中,遇到一些问题点,并结合在网上看到的坑,在这里总结一下。

Intellij 中的 JDK 环境设置

Settings

点击 File > Settings > Java Compiler

Project bytecode version 选择 1.8

点击 File > Settings > Build Tools > Maven > Importing

选择 JDK for importer 为 1.8

Projcet Settings

Project SDK 选择 1.8

Application

如果 web 应用的启动方式为 Application ,需要修改 JRE

点击 Run/Debug Configurations > Configuration

选择 JRE 为 1.8

Linux 环境修改

修改环境变量

修改 /etc/profile 中的 JAVA_HOME,设置 为 jdk8 所在路径。

修改后,执行 source /etc/profile 生效。

编译、发布脚本中如果有 export JAVA_HOME ,需要注意,需要使用 jdk8 的路径。

修改 maven

settings.xml 中 profile 的激活条件如果是 jdk,需要修改一下 jdk 版本

1
2
3
<activation>
<jdk>1.8</jdk> <!-- 修改为 1.8 -->
</activation>

修改 server

修改 server 中的 javac 版本,以 resin 为例:

修改 resin 配置文件中的 javac 参数。

1
<javac compiler="internal" args="-source 1.8"/>

sun.* 包缺失问题

JDK8 不再提供 sun.* 包供开发者使用,因为这些接口不是公共接口,不能保证在所有 Java 兼容的平台上工作。

使用了这些 API 的程序如果要升级到 JDK 1.8 需要寻求替代方案。

虽然,也可以自己导入包含 sun.* 接口 jar 包到 classpath 目录,但这不是一个好的做法。

需要详细了解为什么不要使用 sun.* ,可以参考官方文档:Why Developers Should Not Write Programs That Call ‘sun’ Packages

默认安全策略修改

升级后估计有些小伙伴在使用不安全算法时可能会发生错误,so,支持不安全算法还是有必要的

找到$JAVA_HOME 下 jre/lib/security/java.security ,将禁用的算法设置为空:jdk.certpath.disabledAlgorithms=

JVM 参数调整

在 jdk8 中,PermSize 相关的参数已经不被使用:

1
2
3
4
5
6
7
-XX:MaxPermSize=size

Sets the maximum permanent generation space size (in bytes). This option was deprecated in JDK 8, and superseded by the -XX:MaxMetaspaceSize option.

-XX:PermSize=size

Sets the space (in bytes) allocated to the permanent generation that triggers a garbage collection if it is exceeded. This option was deprecated un JDK 8, and superseded by the -XX:MetaspaceSize option.

JDK8 中再也没有 PermGen 了。其中的某些部分,如被 intern 的字符串,在 JDK7 中已经移到了普通堆里。其余结构在 JDK8 中会被移到称作“Metaspace”的本机内存区中,该区域在默认情况下会自动生长,也会被垃圾回收。它有两个标记:MetaspaceSize 和 MaxMetaspaceSize。

-XX:MetaspaceSize=size

Sets the size of the allocated class metadata space that will trigger a garbage collection the first time it is exceeded. This threshold for a garbage collection is increased or decreased depending on the amount of metadata used. The default size depends on the platform.

-XX:MaxMetaspaceSize=size

Sets the maximum amount of native memory that can be allocated for class metadata. By default, the size is not limited. The amount of metadata for an application depends on the application itself, other running applications, and the amount of memory available on the system.

以下示例显示如何将类类元数据的上限设置为 256 MB:

XX:MaxMetaspaceSize=256m

字节码问题

ASM 5.0 beta 开始支持 JDK8

字节码错误

1
2
Caused by: java.io.IOException: invalid constant type: 15
at javassist.bytecode.ConstPool.readOne(ConstPool.java:1113)
  • 查找组件用到了 mvel,mvel 为了提高效率进行了字节码优化,正好碰上 JDK8 死穴,所以需要升级。
1
2
3
4
5
<dependency>
<groupId>org.mvel</groupId>
<artifactId>mvel2</artifactId>
<version>2.2.7.Final</version>
</dependency>
  • javassist
1
2
3
4
5
<dependency>
<groupId>org.javassist</groupId>
<artifactId>javassist</artifactId>
<version>3.18.1-GA</version>
</dependency>

注意

有些部署工具不会删除旧版本 jar 包,所以可以尝试手动删除老版本 jar 包。

http://asm.ow2.org/history.html

Java 连接 redis 启动报错 Error redis clients jedis HostAndPort cant resolve localhost address

错误环境:
本地 window 开发环境没有问题。上到 Linux 环境,启动出现问题。
错误信息:
Error redis clients jedis HostAndPort cant resolve localhost address

解决办法:

(1)查看 Linux 系统的主机名

1
2
# hostname
template

(2)查看/etc/hosts 文件中是否有 127.0.0.1 对应主机名,如果没有则添加

Resin 容器指定 JDK 1.8

如果 resin 容器原来版本低于 JDK1.8,运行 JDK 1.8 编译的 web app 时,可能会提示错误:

1
java.lang.UnsupportedClassVersionError: PR/Sort : Unsupported major.minor version 52.0

解决方法就是,使用 JDK 1.8 要重新编译一下。然后,我在部署时出现过编译后仍报错的情况,重启一下服务器后,问题解决,不知是什么原因。

1
2
./configure --prefix=/usr/local/resin  --with-java=/usr/local/jdk1.8.0_121
make & make install

参考资料

Java 编程规范

编程规范就是 Java 开发的最佳实践。帮助开发人员少走弯路。

Effective Java

  • 第 2 章 创建、销毁对象
    • 第 1 条:考虑用静态工厂方法代替构造器
    • 第 2 条:遇到多个构造器参数时要考虑用构建器
    • 第 3 条:用私有构造器或者枚举类型强化 Singleton 属性
    • 第 4 条:通过私有构造器强化不可实例化的能力
    • 第 5 条:避免创建不必要的对象
    • 第 6 条:消除过期的对象引用
    • 第 7 条:避免使用终结方法
  • 第 3 章 对于所有对象都通用的方法
    • 第 8 条:覆盖 equals 时请遵守通用约定
    • 第 9 条:覆盖 equals 时总要覆盖 hashCode
    • 第 10 条:始终要覆盖 toString
    • 第 11 条:谨慎地覆盖 clone
    • 第 12 条:考虑实现 Comparable 接口
  • 第 4 章 类和接口
    • 第 13 条:使类和成员的可访问性最小化
    • 第 14 条:在公有类中使用访问方法而非公有域
    • 第 15 条:使可变性最小化
    • 第 16 条:复合优先于继承
    • 第 17 条:要么为继承而设计,并提供文档说明,要么就禁止继承
    • 第 18 条:接口优于抽象类
    • 第 19 条:接口只用于定义类型
    • 第 20 条:类层次优于标签类
    • 第 21 条:用函数对象表示策略
    • 第 22 条:优先考虑静态成员类
  • 第 5 章 泛型
    • 第 23 条:请不要在新代码中使用原生态类型
    • 第 24 条:消除非受检警告
    • 第 25 条:列表优先于数组
    • 第 26 条:优先考虑泛型
    • 第 27 条:优先考虑泛型方法
    • 第 28 条:利用有限制通配符来提升 API 的灵活性
    • 第 29 条:优先考虑类型安全的异构容器
  • 第 6 章 枚举和注解
    • 第 30 条:用 enum 代替 int 常量
    • 第 31 条:用实例域代替序数
    • 第 32 条:用 EnumSet 代替位域
    • 第 33 条:用 EnumMap 代替序数索引
    • 第 34 条:用接口模拟可伸缩的枚举
    • 第 35 条:注解优先于命名模式
    • 第 36 条:坚持使用 Override 注解
    • 第 37 条:用标记接口定义类型
  • 第 7 章 方法
    • 第 38 条:检查参数的有效性
    • 第 39 条:必要时进行保护性拷贝
    • 第 40 条:谨慎设计方法签名
    • 第 41 条:慎用重载
    • 第 42 条:慎用可变参数
    • 第 43 条:返回零长度的数组或者集合,而不是:null
    • 第 44 条:为所有导出的 API 元素编写文档注释
  • 第 8 章 通用程序设计
    • 第 45 条:将局部变量的作用域最小化
    • 第 46 条:for-each 循环优先于传统的 for 循环
    • 第 47 条:了解和使用类库
    • 第 48 条:如果需要精确的答案,请避免使用 float 和 double
    • 第 49 条:基本类型优先于装箱基本类型
    • 第 50 条:如果其他类型更适合,则尽量避免使用字符串
    • 第 51 条:当心字符串连接的性能
    • 第 52 条:通过接口引用对象
    • 第 53 条:接口优先于反射机制
    • 第 54 条:谨慎地使用本地方法
    • 第 55 条:谨慎地进行优化
    • 第 56 条:遵守普遍接受的命名惯例
  • 第 9 章 异常
    • 第 57 条:只针对异常的情况才使用异常
    • 第 58 条:对可恢复的情况使用受检异常,对编程错误使用运行时异常
    • 第 59 条:避免不必要地使用受检的异常
    • 第 60 条:优先使用标准的异常
    • 第 61 条:抛出与抽象相对应的异常
    • 第 62 条:每个方法抛出的异常都要有文档
    • 第 63 条:在细节消息中包含能捕获失败的信息
    • 第 64 条:努力使失败保持原子性
    • 第 65 条:不要忽略异常
  • 第 10 章 并发
    • 第 66 条:同步访问共享的可变数据
    • 第 67 条:避免过度同步
    • 第 68 条:executor 和 task 优先干线程
    • 第 69 条:并发工具优先于 wait 和 notify
    • 第 70 条:线程安全性的文档化
    • 第 71 条:慎用延迟初始化
    • 第 72 条:不要依赖于线程调度器
    • 第 73 条:避免使用线程组
  • 第 11 章 序列化
    • 第 74 条:谨慎地实现 Serializable 接口
    • 第 75 条:考虑使用自定义的序列化形式
    • 第 76 条:保护性地编写 readObject 方法
    • 第 77 条:对于实例控制,枚举类型优先于 readResolve
    • 第 78 条:考虑用序列化代理代替序列化实例

资源

代码工程规范

软件项目开发规范。

项目结构

以下为项目根目录下的文件和目录的组织结构:

目录

codes - 代码目录。
configurations - 配置目录。一般存放项目相关的配置文件。如 maven 的 settings.xml,nginx 的 nginx.conf 等。
demos - 示例目录。
docs - 文档目录。
libs - 第三方库文件。
scripts - 脚本目录。一般存放用于启动、构建项目的可执行脚本文件。
packages - 打包文件目录。Java 项目中可能是 jar、war 等;前端项目中可能是 zip、rar 等;电子书项目中可能是 pdf 等。

文件

.gitignore - git 忽略规则。
.gitattributes - git 属性规则。
.editorconfig - 编辑器书写规则。
README.md - 项目说明文件。
LICENSE - 开源协议。如果项目是开源文件,需要添加。

命名规则

目录名

目录名必须使用半角字符,不得使用全角字符。这也意味着,中文不能用于文件名。

目录名建议只使用小写字母,不使用大写字母。

1
2
不佳: Test
正确: test

目录名可以使用数字,但不应该是首字符。

1
2
不佳: 1-demo
正确: demo1

目录名包含多个单词时,单词之间建议使用半角的连词线(-)分隔。

1
2
不佳: common_demo
正确: common-demo

文件名

文档的文件名不得含有空格。

文件名必须使用半角字符,不得使用全角字符。这也意味着,中文不能用于文件名。

1
2
错误: 名词解释.md
正确: glossary.md

文件名建议只使用小写字母,不使用大写字母。

1
2
错误:TroubleShooting.md
正确:troubleshooting.md

为了醒目,某些说明文件的文件名,可以使用大写字母,比如READMELICENSE

一些约定俗成的习惯可以保持传统写法,如:Java 的文件名一般使用驼峰命名法,且首字母大写;配置文件 applicationContext.xml ;React 中的 JSX 组件文件名一般使用驼峰命名法,且首字母大写等。

文件名包含多个单词时,单词之间建议使用半角的连词线(-)分隔。

1
2
不佳:advanced_usage.md
正确:advanced-usage.md

Java 日志规范

这里基于阿里巴巴 Java 开发手册日志规约章节,结合自己的开发经验做了一些增删和调整。

  1. 【强制】应用中不可直接使用日志系统(Log4j、Logback)中的 API,而应依赖使用日志框架 SLF4J 中的 API,使用门面模式的日志框架,有利于维护和各个类的日志处理方式统一。
1
2
3
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
private static final Logger logger = LoggerFactory.getLogger(Abc.class);
  1. 【强制】日志文件推荐至少保存 30 天,因为有些异常具备以“周”为频次发生的特点。

  2. 【强制】应用中的扩展日志(如打点、临时监控、访问日志等)命名方式:appName_logType_logName.log。logType:日志类型,推荐分类有 stats/desc/monitor/visit 等;logName:日志描述。这种命名的好处:通过文件名就可知道日志文件属于什么应用,什么类型,什么目的,也有利于归类查找。

正例:mppserver 应用中单独监控时区转换异常,如:mppserver_monitor_timeZoneConvert.log

说明:推荐对日志进行分类,如将错误日志和业务日志分开存放,便于开发人员查看,也便于通过日志对系统进行及时监控。

  1. 【强制】对 trace/debug/info 级别的日志输出,必须使用条件输出形式或者使用占位符的方式。

说明:logger.debug("Processing trade with id: " + id + " and symbol: " + symbol); 如果日志级别是 warn,上述日志不会打印,但是会执行字符串拼接操作,如果 symbol 是对象,会执行 toString()方法,浪费了系统资源,执行了上述操作,最终日志却没有打印。

正例:(条件)

1
2
3
if (logger.isDebugEnabled()) {
logger.debug("Processing trade with id: " + id + " and symbol: " + symbol);
}

正例:(占位符)

1
logger.debug("Processing trade with id: {} and symbol : {} ", id, symbol);
  1. 【强制】避免重复打印日志,浪费磁盘空间。务必在 log4j.xmllogback.xml 中设置 additivity=false

正例

1
<logger name="com.taobao.dubbo.config" additivity="false">
  1. 【强制】异常信息应该包括两类信息:案发现场信息和异常堆栈信息。如果不处理,那么通过关键字 throws 往上抛出。

正例:logger.error(各类参数或者对象 toString + “_“ + e.getMessage(), e);

  1. 【强制】日志格式遵循如下格式:

打印出的日志信息如:

1
2
3
4
5
2018-03-29 15:06:57.277 [javalib] [main] [TRACE] i.g.dunwu.javalib.log.LogbackDemo#main - 这是一条 trace 日志记录
2018-03-29 15:06:57.282 [javalib] [main] [DEBUG] i.g.dunwu.javalib.log.LogbackDemo#main - 这是一条 debug 日志记录
2018-03-29 15:06:57.282 [javalib] [main] [INFO] i.g.dunwu.javalib.log.LogbackDemo#main - 这是一条 info 日志记录
2018-03-29 15:06:57.282 [javalib] [main] [WARN] i.g.dunwu.javalib.log.LogbackDemo#main - 这是一条 warn 日志记录
2018-03-29 15:06:57.282 [javalib] [main] [ERROR] i.g.dunwu.javalib.log.LogbackDemo#main - 这是一条 error 日志记录
  1. 【参考】slf4j 支持的日志级别,按照级别从低到高,分别为:trace < debug < info < warn < error

建议只使用 debug < info < warn < error 四个级别。

  • error 日志级别只记录系统逻辑出错、异常等重要的错误信息。如非必要,请不要在此场景打出 error 级别。
  • warn 日志级别记录用户输入参数错误的情况,避免用户投诉时,无所适从。
  • info 日志级别记录业务逻辑中一些重要步骤信息。
  • debug 日志级别记录一些用于调试的信息。
  1. 【参考】有一些第三方框架或库的日志对于排查问题具有一定的帮助,如 Spring、Dubbo、Mybatis 等。这些框架所使用的日志库未必和本项目一样,为了避免出现日志无法输出的问题,请引入对应的桥接 jar 包。

参考资料

如何优雅的玩转 Git

Git 简介

Git 是什么

Git 是一个开源的分布式版本控制系统。

Git 和其它版本控制系统(包括 Subversion 和近似工具)的主要差别在于 Git 对待数据的方式。 从概念上来说,其它大部分系统以文件变更列表的方式存储信息,而 Git 是把数据看作是对小型文件系统的一系列快照。

什么是版本控制

版本控制是一种记录一个或若干文件内容变化,以便将来查阅特定版本修订情况的系统。

集中化的版本控制系统

介绍分布式版本控制系统前,有必要先了解一下传统的集中式版本控制系统。

集中化的版本控制系统,诸如 CVS,Subversion 等,都有一个单一的集中管理的服务器,保存所有文件的修订版本,而协同工作的人们都通过客户端连到这台服务器,取出最新的文件或者提交更新。

这么做最显而易见的缺点是中央服务器的单点故障。如果宕机一小时,那么在这一小时内,谁都无法提交更新,也就无法协同工作。要是中央服务器的磁盘发生故障,碰巧没做备份,或者备份不够及时,就会有丢失数据的风险。最坏的情况是彻底丢失整个项目的所有历史更改记录。

img

分布式版本控制系统

分布式版本控制系统的客户端并不只提取最新版本的文件快照,而是把代码仓库完整地镜像下来。这么一来,任何一处协同工作用的服务器发生故障,事后都可以用任何一个镜像出来的本地仓库恢复。因为每一次的提取操作,实际上都是一次对代码仓库的完整备份。

img

为什么使用 Git

Git 是分布式的。这是 Git 和其它非分布式的版本控制系统(例如 svn,cvs 等),最核心的区别。分布式带来以下好处:

  • 工作时不需要联网 - 首先,分布式版本控制系统根本没有“中央服务器”,每个人的电脑上都是一个完整的版本库,这样,你工作的时候,就不需要联网了,因为版本库就在你自己的电脑上。既然每个人电脑上都有一个完整的版本库,那多个人如何协作呢?比方说你在自己电脑上改了文件 A,你的同事也在他的电脑上改了文件 A,这时,你们俩之间只需把各自的修改推送给对方,就可以互相看到对方的修改了。
  • 更加安全
    • 集中式版本控制系统,一旦中央服务器出了问题,所有人都无法工作。
    • 分布式版本控制系统,每个人电脑中都有完整的版本库,所以某人的机器挂了,并不影响其它人。

Git 的工作原理

个人认为,对于 Git 这个版本工具,再不了解原理的情况下,直接去学习命令行,可能会一头雾水。所以,本文特意将原理放在命令使用章节之前讲解。

版本库

当你一个项目到本地或创建一个 git 项目,项目目录下会有一个隐藏的 .git 子目录。这个目录是 git 用来跟踪管理版本库的,如果不熟悉其工作机制,千万不要手动修改。

img

  • hooks 目录:包含客户端或服务端的钩子脚本(hook scripts)
  • info 目录:包含一个全局性排除(global exclude)文件, 用以放置那些不希望被记录在 .gitignore 文件中的忽略模式(ignored patterns)。
  • objects 目录:存储所有数据内容。
  • refs 目录:存储指向数据(分支、远程仓库和标签等)的提交对象的指针
  • HEAD 文件:指向目前被检出的分支。
  • index 文件保存暂存区信息。
  • config 文件:包含项目特有的配置选项。
  • description 文件:description 文件仅供 GitWeb 程序使用,我们无需关心。

哈希值

Git 中所有数据在存储前都计算校验和,然后以校验和来引用。 这意味着不可能在 Git 不知情时更改任何文件内容或目录内容。 这个功能构筑在 Git 底层,是 Git 的关键组件。 若你在传送过程中丢失信息或损坏文件,Git 就能发现。

Git 计算校验和的使用 SHA-1 哈希算法。 这是一个由 40 个十六进制字符(0-9 和 a-f)组成字符串,基于 Git 中文件的内容或目录结构计算出来。 SHA-1 哈希值看起来是这样:

1
24b9da6552252987aa493b52f8696cd6d3b00373

Git 中使用这种哈希值的情况很多,你将经常看到这种哈希值。 实际上,Git 数据库中保存的信息都是以文件内容的哈希值来索引,而不是文件名。

文件状态

在 GIt 中,你的文件可能会处于三种状态之一:

  • 已修改(modified) - 已修改表示修改了文件,但还没保存到数据库中。
  • 已暂存(staged) - 已暂存表示对一个已修改文件的当前版本做了标记,使之包含在下次提交的快照中。
  • 已提交(committed) - 已提交表示数据已经安全的保存在本地数据库中。

工作区域

与文件状态对应的,不同状态的文件在 Git 中处于不同的工作区域。

  • 工作区(working) - 当你 git clone 一个项目到本地,相当于在本地克隆了项目的一个副本。工作区是对项目的某个版本独立提取出来的内容。 这些从 Git 仓库的压缩数据库中提取出来的文件,放在磁盘上供你使用或修改。
  • 暂存区(staging) - 暂存区是一个文件,保存了下次将提交的文件列表信息,一般在 Git 仓库目录中。 有时候也被称作`‘索引’’,不过一般说法还是叫暂存区。
  • 本地仓库(local) - 提交更新,找到暂存区域的文件,将快照永久性存储到 Git 本地仓库。
  • 远程仓库(remote) - 以上几个工作区都是在本地。为了让别人可以看到你的修改,你需要将你的更新推送到远程仓库。同理,如果你想同步别人的修改,你需要从远程仓库拉取更新。

img

分支管理

Git Flow

Git Flow 应该是目前流传最广的 Git 分支管理策略。Git Flow 围绕的核心点是版本发布(release),它适用于迭代版本较长的项目。

img

详细内容,可以参考这篇文章:Git 在团队中的最佳实践–如何正确使用 Git Flow

Git Flow 常用分支:

  • master - 主线分支
  • develop - 开发分支
  • feature - 特性分支
  • release - 发布分支
  • hotfix - 问题修复分支

Git Flow 工作流程

2.1. 主干分支

img

主干分支有两个,它们是伴随着项目生命周期长期存在的分支。

  • master - 这个分支对应发布到生产环境的代码。这个分支只允许从其他分支合入代码,不能在这个分支直接修改。所有在 master 分支上的 Commit 都应该打 Tag。
  • develop - 这个分支包含所有要发布到下一个 release 的代码,这个分支主要是从其他分支合入代码,比如 feature 分支。

2.2. feature 分支

这个分支主要是用来开发一个新的功能,一旦开发完成,我们合并回 develop 分支进入下一个 release。feature 分支开发结束后,必须合并回 develop 分支, 合并完分支后一般会删点这个 feature 分支,但是我们也可以保留。

img

2.3. release 分支

release 分支基于 develop 分支创建,创建后,我们可以在这个 release 分支上进行测试,修复 Bug 等工作。同时,其它开发人员可以基于它开发新的 feature (记住:一旦创建了 release 分支之后不要从 develop 分支上合并新的改动到 release 分支)。

发布 release 分支时,合并 release 到 master 和 develop, 同时在 master 分支上打个 Tag 记住 release 版本号,然后可以删除 release 分支了。

2.4. hotfix 分支

当出现线上 bug 时,也意味着 master 存在 Bug。这时,我们需要基于 master 创建一个 hotfix 分支,在此分支上完成 bug 修复。修复后,我们应该将此分支合并回 master 和 develop 分支,同时在 master 上打一个 tag。所以,hotfix 的改动会进入下一个 release。

img

2.5. 如何应用 Git Flow

在实际开发中,如何具体落地 Git Flow 流程呢?

git 提供了 git flow 命令来手动管理,但是比较麻烦,所以还是建议使用 Git Flow 的 GUI 工具。比如:SourceTreeVScode 的 GitFlow 插件、Intellij 的 GitFlow 插件等。

想了解更详细的 Git Flow 介绍,可以参考:

A Successful Git Branching Model

Git 在团队中的最佳实践–如何正确使用 Git Flow

Github Flow

对于简单且迭代频繁的项目来说,Git Flow 可能有些太复杂了。这时,可以考虑 Github Flow。

在 Github Flow 策略中,所有分支都是基于 master 创建。在 Feature 或 Bugfix 分支中完成工作后,将其合入 master,然后继续迭代。

img

想了解更详细的 Github Flow 介绍,可以参考:GitHub Flow

Git Commit 规范

Git 每次提交代码,都要写 Commit message(提交说明),否则就不允许提交。

好的 Commit message 可以让人一眼就明白提交者修改了什么内容,有什么影响;而不好的 Commit message 写了和没写一样,甚至还可能误导别人。

先来看下图中不好的 Commit message 范例,从提交信息完全看不出来修改了什么。

img

再来一张较好的 Commit message 范例,每次提交的是什么内容,做了什么一目了然。

img

Commit message 的作用

从前面,我们不难看出,完善的 Commit message 非常有利于项目维护。即时是个人维护的项目,时间久了,可能也会忘记当初自己改了什么。

Commit message 的作用还不仅仅是理解历史信息,它的主要作用如下:

(1)提供易于理解的历史信息,方便检索

(2)可以过滤某些 commit(比如文档改动),便于快速查找信息。

(3)可以直接从 commit 生成 Change log。

Commit message 的规范

开源社区有很多 Commit message 的规范,个人推荐使用 Angular Git Commit 规范,这是目前使用最广的写法,比较合理和系统化,并且有配套的工具。

它主要有以下组成部分:

  • 标题行:必填, 描述主要修改类型和内容
  • 主题内容:描述为什么修改, 做了什么样的修改, 以及开发的思路等等
  • 页脚注释:放 Breaking Changes 或 Closed Issues

常用的修改项

  • type:commit 的类型
  • feat:新特性
  • fix:修改问题
  • refactor:代码重构
  • docs:文档修改
  • style:代码格式修改, 注意不是 css 修改
  • test:测试用例修改
  • chore:其他修改, 比如构建流程, 依赖管理.
  • scope:commit 影响的范围, 比如:route, component, utils, build…
  • subject:commit 的概述
  • body:commit 具体修改内容, 可以分为多行
  • footer:一些备注, 通常是 BREAKING CHANGE 或修复的 bug 的链接

Git Commit Template

Intellij 中有集成 Angular Git Commit 规范 的插件,可以帮助我们快速创建符合 Angular Git Commit 规范 的 Git Commit Message。

其使用步骤如下:

第一步,安装插件

img

第二步,提交代码时,按照模板填写 commit message

img

生成 Change log

如果你的所有 Commit 都符合 Angular Git Commit 规范,那么发布新版本时,就可以用脚本自动生成 Change log。

生成的文档包括以下三个部分。

  • New features
  • Bug fixes
  • Breaking changes.

每个部分都会罗列相关的 commit ,并且有指向这些 commit 的链接。当然,生成的文档允许手动修改,所以发布前,你还可以添加其他内容。

conventional-changelog 就是生成 Change log 的工具,运行下面的命令即可。

1
2
3
$ npm install -g conventional-changelog
$ cd my-project
$ conventional-changelog -p angular -i CHANGELOG.md -w

上面命令不会覆盖以前的 Change log,只会在CHANGELOG.md的头部加上自从上次发布以来的变动。

如果你想生成所有发布的 Change log,要改为运行下面的命令。

1
$ conventional-changelog -p angular -i CHANGELOG.md -w -r 0

为了方便使用,可以将其写入package.jsonscripts字段。

1
2
3
4
5
{
"scripts": {
"changelog": "conventional-changelog -p angular -i CHANGELOG.md -w -r 0"
}
}

以后,直接运行下面的命令即可。

1
$ npm run changelog

Git 奇技淫巧

生成 SSH 公钥

许多 Git 服务器都使用 SSH 公钥进行认证。 为了向 Git 服务器提供 SSH 公钥,如果某系统用户尚未拥有密钥,必须事先为其生成一份。 这个过程在所有操作系统上都是相似的。 首先,你需要确认自己是否已经拥有密钥。 默认情况下,用户的 SSH 密钥存储在其 ~/.ssh 目录下。 进入该目录并列出其中内容,你便可以快速确认自己是否已拥有密钥:

1
2
3
4
$ cd ~/.ssh
$ ls
authorized_keys2 id_dsa known_hosts
config id_dsa.pub

我们需要寻找一对以 id_dsaid_rsa 命名的文件,其中一个带有 .pub 扩展名。 .pub 文件是你的公钥,另一个则是私钥。 如果找不到这样的文件(或者根本没有 .ssh 目录),你可以通过运行 ssh-keygen 程序来创建它们。在 Linux/Mac 系统中,ssh-keygen 随 SSH 软件包提供;在 Windows 上,该程序包含于 MSysGit 软件包中。

1
2
3
4
5
6
7
8
9
10
$ ssh-keygen
Generating public/private rsa key pair.
Enter file in which to save the key (/home/schacon/.ssh/id_rsa):
Created directory '/home/schacon/.ssh'.
Enter passphrase (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in /home/schacon/.ssh/id_rsa.
Your public key has been saved in /home/schacon/.ssh/id_rsa.pub.
The key fingerprint is:
d0:82:24:8e:d7:f1:bb:9b:33:53:96:93:49:da:9b:e3 schacon@mylaptop.local

首先 ssh-keygen 会确认密钥的存储位置(默认是 .ssh/id_rsa),然后它会要求你输入两次密钥口令。如果你不想在使用密钥时输入口令,将其留空即可。

现在,进行了上述操作的用户需要将各自的公钥发送给任意一个 Git 服务器管理员(假设服务器正在使用基于公钥的 SSH 验证设置)。 他们所要做的就是复制各自的 .pub 文件内容,并将其通过邮件发送。 公钥看起来是这样的:

1
2
3
4
5
6
7
$ cat ~/.ssh/id_rsa.pub
ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAklOUpkDHrfHY17SbrmTIpNLTGK9Tjom/BWDSU
GPl+nafzlHDTYW7hdI4yZ5ew18JH4JW9jbhUFrviQzM7xlELEVf4h9lFX5QVkbPppSwg0cda3
Pbv7kOdJ/MTyBlWXFCR+HAo3FXRitBqxiX1nKhXpHAZsMciLq8V6RjsNAQwdsdMFvSlVK/7XA
t3FaoJoAsncM1Q9x5+3V0Ww68/eIFmb1zuUFljQJKprrX88XypNDvjYNby6vw/Pb0rwert/En
mZ+AW4OZPnTPI89ZPmVMLuayrD2cE86Z/il8b+gw3r3+1nKatmIkjn2so1d01QraTlMqVSsbx
NrRFi9wrf+M7Q== schacon@mylaptop.local

在你的 Github 账户中,依次点击 Settings > SSH and GPG keys > New SSH key

然后,将上面生成的公钥内容粘贴到 Key 编辑框并保存。至此大功告成。

后面,你在克隆你的 Github 项目时使用 SSH 方式即可。

如果觉得我的讲解还不够细致,可以参考:adding-a-new-ssh-key-to-your-github-account

使用 .gitignore 忽略不必提交内容

.gitignore 文件可能从字面含义也不难猜出:这个文件里配置的文件或目录,会自动被 git 所忽略,不纳入版本控制。

在日常开发中,我们的项目经常会产生一些临时文件,如编译 Java 产生的 *.class 文件,又或是 IDE 自动生成的隐藏目录(Intellij 的 .idea 目录、Eclipse 的 .settings 目录等)等等。这些文件或目录实在没必要纳入版本管理。在这种场景下,你就需要用到 .gitignore 配置来过滤这些文件或目录。

.gitignore 配置的规则很简单,也没什么可说的,看几个例子,自然就明白了。

【示例】一份 Java 的 .gitignore

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# Compiled class file
*.class

# Log file
*.log

# BlueJ files
*.ctxt

# Mobile Tools for Java (J2ME)
.mtj.tmp/

# Package Files #
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar

# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*

【推荐】这里推荐一个 Github 的开源项目:gitignore,在这里,你可以找到很多常用的 .gitignore 模板,如:Java、Nodejs、C++ 的 .gitignore 模板等等。

使用 .gitattributes 解决 LF 和 CRLF 问题

你有没有在和多人协同开发时遇到过以下烦恼?

开发者们分别使用不同的操作系统进行开发,有的人用 Windows,有的人用 Linux/MacOS。众所周知,不同操作系统默认的文件结尾行是不同的:在 Windows 上默认的是回车换行(Carriage Return Line Feed, CRLF),然而,在 Linux/MacOS 上则是换行(Line Feed, LF)。这就可能导致这种情况:明明文件内容一模一样,但是版本比对时仍然存在版本差异。

那么如何解决这个问题呢?Git 提供了 .gitattributes 配置文件,它允许使用者指定由 git 使用的文件和路径的属性。

在 Git 库中,一个普通文本文件的行尾默认是 LF。对于工作目录,除了 text 属性之外,还可以设置 eol 属性或 core.eol 配置变量。

.gitattributes 文件中,可以用 text 属性指定某类文件或目录下的文件,控制它的行结束标准化。当一个文本文件被标准化时,它的行尾将在存储库中转换为 LF。要控制工作目录中使用的行结束风格,请使用单个文件的eol属性和所有文本文件的 core.eol 配置变量。

【示例】一份 .gitattributes 示例

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
* text=auto eol=lf

*.txt text
*.java text
*.scala text
*.groovy text
*.gradle text
*.properties text

# unix style
*.sh text eol=lf

# win style
*.bat text eol=crlf

# binary
*.jar binary
*.war binary
*.zip binary
*.tar binary
*.tar.gz binary
*.gz binary
*.apk binary
*.bin binary
*.exe binary

【推荐】这里推荐一个 Github 的开源项目:gitignore,在这里,你可以找到很多常用的 .gitignore 模板,如:Java、Nodejs、C++ 的 .gitignore 模板等等。

同时提交代码到不同的远程仓库

如果,你在不同的 Git 远程仓库中维护同一个项目,你可能会有这样的需求:能不能一次提交,同时 push 到多个远程仓库中呢?

这个可以有,解决方案如下:

比如,我有一个 blog 项目,同时维护在 Github 和 Gitee 上。

(1)首先,在 Github 和 Gitee 上配置本地的 ssh 公钥(如果是 Gitlab,也同样如此),这样中央仓库就能识别本地。

生成 SSH 公钥的方法,请参考上文的 “生成 SSH 公钥” 章节。

(2)进入 git 项目的隐藏目录 .git,打开 config 文件,参考下面配置进行编辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[core]
repositoryformatversion = 0
filemode = false
bare = false
logallrefupdates = true
symlinks = false
ignorecase = true
[remote "origin"]
url = git@github.com:dunwu/blog.git
url = git@gitee.com:turnon/blog.git
fetch = +refs/heads/*:refs/remotes/origin/*
[branch "master"]
remote = origin
merge = refs/heads/master
[user]
name = dunwu
email = forbreak@163.com

重点在于 remote "origin",同时配置了两个 url。配置后,一旦触发 push 远程仓库的动作,就会同时推送提交记录到配置的远程仓库。

Github Issue 和 Gitlab Issue

开发软件,Bug 再所难免,出现问题不可怕,可怕的是放任不管;所以,优秀的软件项目,都应该管理好问题追踪。软件的使用者,在使用中,可能会遇到形形色色的问题,难以解决,需要向维护者寻求帮助。

问题追踪如此重要,所以各种代码托管平台都会提供 Issue 维护机制,如 Github Issue 和 Gitlab Issue。

如果一个项目的开源社区很活跃,在没有任何约束的前提下,提问肯定是五花八门的,让维护者难以招架。

其实,提问也是一门艺术,如果提问者的问题长篇大幅,言不达意,让别人难以理解,就很难得到有效帮助。关于如何高效的提问,推荐参考 提问的智慧 这篇文章,作者整理的非常好。

作为开发者,你不能期望所有提问者都是训练有素的提问者。所以,使用规范化的 Issue 模板来引导提问者提问,可以大大减轻开发者的负担。

Github Issue 模板

如何在 Github Issue 平台上创建 Issue 模板呢?方法如下:

(1)在仓库根目录创建新目录 .github

(2)在 .github 目录中添加 ISSUE_TEMPLATE 目录,在其中添加的 md 文件都会被 Github 自动识,并将其作为 issue 的默认模板。

示例,下面是携程 apollo 的一个 Issue 模板,要求提问者填充 bug 描述、复现步骤、期望、截图、日志等细节。

img

更多模板:Github issue_templates 模板

Gitlab Issue 模板

如何在 Gitlab Issue 平台上创建 Issue 模板呢?方法如下:

(1)在仓库根目录创建新目录 .gitlab

(2)在 .gitlab 目录中添加 issue_templates 目录,在其中添加的 md 文件都会被 Gitlab 自动识,并将其作为 issue 的默认模板。

img

更多模板:Gitlab 官方 issue_templates 模板

Git Hook

在执行提交代码(git commit),推送代码(git push)等行为时,我们可能希望做一些代码检查性工作,例如:代码 lint 检查、代码格式化等。当检查发现代码存在问题时,就拒绝代码提交,从而保证项目质量。

Git 提供了 Git Hook 机制,允许使用者在特定的重要动作发生时触发自定义脚本。有两类钩子:客户端钩子和服务器端钩子。客户端钩子由诸如提交和合并等操作所触发调用,而服务器端钩子作用于诸如接收被推送的提交这样的联网操作。钩子都被存储在 Git 项目目录下的 .git/hooks 子目录中。Git 在这个目录下放置了一些示例,这些示例的名字都是以 .sample 结尾,如果想启用它们,得先移除这个后缀。

常用的客户端钩子:

  • pre-commit 钩子:在提交信息前运行。 它用于检查即将提交的快照,例如,检查是否有所遗漏,确保测试运行,以及核查代码。 如果该钩子以非零值退出,Git 将放弃此次提交,不过你可以用 git commit --no-verify 来绕过这个环节。 你可以利用该钩子,来检查代码风格是否一致(运行类似 lint 的程序)、尾随空白字符是否存在(自带的钩子就是这么做的),或新方法的文档是否适当。
  • prepare-commit-msg 钩子:在启动提交信息编辑器之前,默认信息被创建之后运行。 它允许你编辑提交者所看到的默认信息。 该钩子接收一些选项:存有当前提交信息的文件的路径、提交类型和修补提交的提交的 SHA-1 校验。 它对一般的提交来说并没有什么用;然而对那些会自动产生默认信息的提交,如提交信息模板、合并提交、压缩提交和修订提交等非常实用。 你可以结合提交模板来使用它,动态地插入信息。
  • commit-msg 钩子:接收一个参数,此参数即上文提到的,存有当前提交信息的临时文件的路径。 如果该钩子脚本以非零值退出,Git 将放弃提交,因此,可以用来在提交通过前验证项目状态或提交信息。 在本章的最后一节,我们将展示如何使用该钩子来核对提交信息是否遵循指定的模板。
  • post-commit 钩子:在整个提交过程完成后运行。它不接收任何参数,但你可以很容易地通过运行 git log -1 HEAD 来获得最后一次的提交信息。 该钩子一般用于通知之类的事情。
  • pre-push 钩子:会在 git push 运行期间, 更新了远程引用但尚未传送对象时被调用。 它接受远程分支的名字和位置作为参数,同时从标准输入中读取一系列待更新的引用。 你可以在推送开始之前,用它验证对引用的更新操作(一个非零的退出码将终止推送过程)。

Javascript 应用 Git Hook

想在 JavaScript 应用中使用 Git Hook,推荐使用 husky ,可以很方便的编写钩子处理命令。

使用方法很简单,先安装 husky

1
npm i -D husky

然后,在 package.json 中添加配置:

1
2
3
4
5
6
7
8
9
10
11
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"src/**/*.{js,vue}": [
"eslint --fix",
"git add"
]
},

以上配置的作用是,当提交代码前( pre-commit ),先执行 lint-staged

lint-staged 中执行的动作是,对 src 目录的所有 js、vue 文件进行 eslint 检查,并尝试修复。如果修复后没有问题,就 git add 添加修改后的文件;如果修复失败,则拒绝提交代码。

参考资料

UML 快速入门

UML 简介

UML 图类型

UML 图类型如下图所示:

结构式建模图

结构式建模图(Structure diagrams)强调的是系统式的建模。结构图定义了一个模型的静态架构。它们通常被用来对那些构成模型的‘要素’建模,诸如:类,对象,接口和物理组件。另外,它们也被用来对元素间关联和依赖关系进行建模。

行为式建模图

行为式建模图(Behavior diagrams)强调系统模型中触发的事。行为图用来记录在一个模型内部,随时间的变化,模型执行的交互变化和瞬间的状态;并跟踪系统在真实环境下如何表现,以及观察系统对一个操作或事件的反应,以及它的结果。

UML 概念

UML 从来源中使用相当多的概念。我们将之定义于统一建模语言术语汇表。下面仅列代表性的概念。

  • 对于结构而言 - 执行者,属性,类,元件,接口,对象,包。
  • 对于行为而言 - 活动(UML),事件(UML),消息(UML),方法(UML),操作(UML),状态(UML),用例(UML)。
  • 对于关系而言 - 聚合,关联,组合,相依,广义化(or 继承)。
  • 其他概念
    • 构造型—这规范符号应用到的模型
    • 多重性—多重性标记法与资料库建模基数对应,例如:1, 0..1, 1..*

UML 工具

UML 工具非常多,到底哪种工具好,真的是仁者见仁智者见智。这里列举一些我接触过的 UML 工具:

亿图

国内开发的、收费的绘图工具。图形模板、素材非常全面,样式也很精美,可以导出为 word、pdf、图片。

亿图官网

Visio

Office 的绘图工具,特点是简单、清晰。

Visio 官网

StarUML

样式精美,功能全面的 UML 工具。

StarUML 官网

Astah

样式不错,功能全面的绘图工具。

Astah 官网

ArgoUML

UML 工具。

ArgoUML 官网

ProcessOn

在线绘图工具,特点是简洁、清晰。

ProcessOn 官网

drawio

开源的在线绘图工具,特点是简洁、清晰。

drawio 官网

参考资料

计算机网络之传输层

网络层只把分组发送到目的主机,但是真正通信的并不是主机而是主机中的进程。传输层提供了进程间的逻辑通信,传输层向高层用户屏蔽了下面网络层的核心细节,使应用程序看起来像是在两个传输层实体之间有一条端到端的逻辑通信信道。

UDP 和 TCP 的特点

  • 用户数据报协议 UDP(User Datagram Protocol)是无连接的,尽最大可能交付,没有拥塞控制,面向报文(对于应用程序传下来的报文不合并也不拆分,只是添加 UDP 首部),支持一对一、一对多、多对一和多对多的交互通信。

  • 传输控制协议 TCP(Transmission Control Protocol)是面向连接的,提供可靠交付,有流量控制,拥塞控制,提供全双工通信,面向字节流(把应用层传下来的报文看成字节流,把字节流组织成大小不等的数据块),每一条 TCP 连接只能是点对点的(一对一)。

UDP 首部格式

img

首部字段只有 8 个字节,包括源端口、目的端口、长度、检验和。12 字节的伪首部是为了计算检验和临时添加的。

TCP 首部格式

img

  • 序号 :用于对字节流进行编号,例如序号为 301,表示第一个字节的编号为 301,如果携带的数据长度为 100 字节,那么下一个报文段的序号应为 401。

  • 确认号 :期望收到的下一个报文段的序号。例如 B 正确收到 A 发送来的一个报文段,序号为 501,携带的数据长度为 200 字节,因此 B 期望下一个报文段的序号为 701,B 发送给 A 的确认报文段中确认号就为 701。

  • 数据偏移 :指的是数据部分距离报文段起始处的偏移量,实际上指的是首部的长度。

  • 确认 ACK :当 ACK=1 时确认号字段有效,否则无效。TCP 规定,在连接建立后所有传送的报文段都必须把 ACK 置 1。

  • 同步 SYN :在连接建立时用来同步序号。当 SYN=1,ACK=0 时表示这是一个连接请求报文段。若对方同意建立连接,则响应报文中 SYN=1,ACK=1。

  • 终止 FIN :用来释放一个连接,当 FIN=1 时,表示此报文段的发送方的数据已发送完毕,并要求释放连接。

  • 窗口 :窗口值作为接收方让发送方设置其发送窗口的依据。之所以要有这个限制,是因为接收方的数据缓存空间是有限的。

TCP 的三次握手

img

假设 A 为客户端,B 为服务器端。

  • 首先 B 处于 LISTEN(监听)状态,等待客户的连接请求。

  • A 向 B 发送连接请求报文,SYN=1,ACK=0,选择一个初始的序号 x。

  • B 收到连接请求报文,如果同意建立连接,则向 A 发送连接确认报文,SYN=1,ACK=1,确认号为 x+1,同时也选择一个初始的序号 y。

  • A 收到 B 的连接确认报文后,还要向 B 发出确认,确认号为 y+1,序号为 x+1。

  • B 收到 A 的确认后,连接建立。

三次握手的原因

第三次握手是为了防止失效的连接请求到达服务器,让服务器错误打开连接。

客户端发送的连接请求如果在网络中滞留,那么就会隔很长一段时间才能收到服务器端发回的连接确认。客户端等待一个超时重传时间之后,就会重新请求连接。但是这个滞留的连接请求最后还是会到达服务器,如果不进行三次握手,那么服务器就会打开两个连接。如果有第三次握手,客户端会忽略服务器之后发送的对滞留连接请求的连接确认,不进行第三次握手,因此就不会再次打开连接。

TCP 的四次挥手

img

以下描述不讨论序号和确认号,因为序号和确认号的规则比较简单。并且不讨论 ACK,因为 ACK 在连接建立之后都为 1。

  • A 发送连接释放报文,FIN=1。

  • B 收到之后发出确认,此时 TCP 属于半关闭状态,B 能向 A 发送数据但是 A 不能向 B 发送数据。

  • 当 B 不再需要连接时,发送连接释放报文,FIN=1。

  • A 收到后发出确认,进入 TIME-WAIT 状态,等待 2 MSL(最大报文存活时间)后释放连接。

  • B 收到 A 的确认后释放连接。

四次挥手的原因

客户端发送了 FIN 连接释放报文之后,服务器收到了这个报文,就进入了 CLOSE-WAIT 状态。这个状态是为了让服务器端发送还未传送完毕的数据,传送完毕之后,服务器会发送 FIN 连接释放报文。

TIME_WAIT

客户端接收到服务器端的 FIN 报文后进入此状态,此时并不是直接进入 CLOSED 状态,还需要等待一个时间计时器设置的时间 2MSL。这么做有两个理由:

  • 确保最后一个确认报文能够到达。如果 B 没收到 A 发送来的确认报文,那么就会重新发送连接释放请求报文,A 等待一段时间就是为了处理这种情况的发生。

  • 等待一段时间是为了让本连接持续时间内所产生的所有报文都从网络中消失,使得下一个新的连接不会出现旧的连接请求报文。

TCP 可靠传输

TCP 使用超时重传来实现可靠传输:如果一个已经发送的报文段在超时时间内没有收到确认,那么就重传这个报文段。

一个报文段从发送再到接收到确认所经过的时间称为往返时间 RTT,加权平均往返时间 RTTs 计算如下:

img

其中,0 ≤ a < 1,RTTs 随着 a 的增加更容易受到 RTT 的影响。

超时时间 RTO 应该略大于 RTTs,TCP 使用的超时时间计算如下:

img

其中 RTTd 为偏差的加权平均值。

TCP 滑动窗口

img

窗口是缓存的一部分,用来暂时存放字节流。发送方和接收方各有一个窗口,接收方通过 TCP 报文段中的窗口字段告诉发送方自己的窗口大小,发送方根据这个值和其它信息设置自己的窗口大小。

发送窗口内的字节都允许被发送,接收窗口内的字节都允许被接收。如果发送窗口左部的字节已经发送并且收到了确认,那么就将发送窗口向右滑动一定距离,直到左部第一个字节不是已发送并且已确认的状态;接收窗口的滑动类似,接收窗口左部字节已经发送确认并交付主机,就向右滑动接收窗口。

接收窗口只会对窗口内最后一个按序到达的字节进行确认,例如接收窗口已经收到的字节为 {31, 34, 35},其中 {31} 按序到达,而 {34, 35} 就不是,因此只对字节 31 进行确认。发送方得到一个字节的确认之后,就知道这个字节之前的所有字节都已经被接收。

TCP 流量控制

流量控制是为了控制发送方发送速率,保证接收方来得及接收。

接收方发送的确认报文中的窗口字段可以用来控制发送方窗口大小,从而影响发送方的发送速率。将窗口字段设置为 0,则发送方不能发送数据。

TCP 拥塞控制

如果网络出现拥塞,分组将会丢失,此时发送方会继续重传,从而导致网络拥塞程度更高。因此当出现拥塞时,应当控制发送方的速率。这一点和流量控制很像,但是出发点不同。流量控制是为了让接收方能来得及接收,而拥塞控制是为了降低整个网络的拥塞程度。

img

TCP 主要通过四个算法来进行拥塞控制:慢开始、拥塞避免、快重传、快恢复。

发送方需要维护一个叫做拥塞窗口(cwnd)的状态变量,注意拥塞窗口与发送方窗口的区别:拥塞窗口只是一个状态变量,实际决定发送方能发送多少数据的是发送方窗口。

为了便于讨论,做如下假设:

  • 接收方有足够大的接收缓存,因此不会发生流量控制;
  • 虽然 TCP 的窗口基于字节,但是这里设窗口的大小单位为报文段。

img

慢开始与拥塞避免

发送的最初执行慢开始,令 cwnd = 1,发送方只能发送 1 个报文段;当收到确认后,将 cwnd 加倍,因此之后发送方能够发送的报文段数量为:2、4、8 …

注意到慢开始每个轮次都将 cwnd 加倍,这样会让 cwnd 增长速度非常快,从而使得发送方发送的速度增长速度过快,网络拥塞的可能性也就更高。设置一个慢开始门限 ssthresh,当 cwnd >= ssthresh 时,进入拥塞避免,每个轮次只将 cwnd 加 1。

如果出现了超时,则令 ssthresh = cwnd / 2,然后重新执行慢开始。

快重传与快恢复

在接收方,要求每次接收到报文段都应该对最后一个已收到的有序报文段进行确认。例如已经接收到 M1 和 M2,此时收到 M4,应当发送对 M2 的确认。

在发送方,如果收到三个重复确认,那么可以知道下一个报文段丢失,此时执行快重传,立即重传下一个报文段。例如收到三个 M2,则 M3 丢失,立即重传 M3

在这种情况下,只是丢失个别报文段,而不是网络拥塞。因此执行快恢复,令 ssthresh = cwnd / 2 ,cwnd = ssthresh,注意到此时直接进入拥塞避免。

慢开始和快恢复的快慢指的是 cwnd 的设定值,而不是 cwnd 的增长速率。慢开始 cwnd 设定为 1,而快恢复 cwnd 设定为 ssthresh。

img

计算机网络之应用层

HTTP

img

超文本传输协议(英语:HyperText Transfer Protocol,缩写:HTTP)是一种用于分布式、协作式和超媒体信息系统的应用层协议。HTTP 是万维网的数据通信的基础。

设计 HTTP 最初的目的是为了提供一种发布和接收 HTML 页面的方法。通过 HTTP 或者 HTTPS 协议请求的资源由统一资源标识符(Uniform Resource Identifiers,URI)来标识。

安全套接字层超文本传输协议 HTTPS 为了数据传输的安全,HTTPS 在 HTTP 的基础上加入了 SSL 协议,SSL 依靠证书来验证服务器的身份,并为浏览器和服务器之间的通信加密。

:point_right: 扩展阅读:HTTP

DNS

域名服务器 DNS 是一个分布式数据库,提供了主机名和 IP 地址之间相互转换的服务。这里的分布式数据库是指,每个站点只保留它自己的那部分数据。

img

域名具有层次结构,从上到下依次为:根域名、顶级域名、二级域名。

img

DNS 可以使用 UDP 或者 TCP 进行传输,使用的端口号都为 53。大多数情况下 DNS 使用 UDP 进行传输,这就要求域名解析器和域名服务器都必须自己处理超时和重传来保证可靠性。在两种情况下会使用 TCP 进行传输:

  • 如果返回的响应超过的 512 字节(UDP 最大只支持 512 字节的数据)。
  • 区域传送(区域传送是主域名服务器向辅助域名服务器传送变化的那部分数据)。

:point_right: 扩展阅读:DNS

FTP

文件传送协议 FTP 使用 TCP 进行连接,它需要两个连接来传送一个文件:

  • 控制连接:服务器打开端口号 21 等待客户端的连接,客户端主动建立连接后,使用这个连接将客户端的命令传送给服务器,并传回服务器的应答。
  • 数据连接:用来传送一个文件数据。

根据数据连接是否是服务器端主动建立,FTP 有主动和被动两种模式:

img

  • 主动模式:服务器端主动建立数据连接,其中服务器端的端口号为 20,客户端的端口号随机,但是必须大于 1024,因为 0~1023 是熟知端口号。
  • 被动模式:客户端主动建立数据连接,其中客户端的端口号由客户端自己指定,服务器端的端口号随机。

主动模式要求客户端开放端口号给服务器端,需要去配置客户端的防火墙。被动模式只需要服务器端开放端口号即可,无需客户端配置防火墙。但是被动模式会导致服务器端的安全性减弱,因为开放了过多的端口号。

FTPS 是一种对常用的文件传输协议(FTP)添加传输层安全(TLS)和安全套接层(SSL)加密协议支持的扩展协议。

DHCP

动态主机配置协议 DHCP (Dynamic Host Configuration Protocol) 提供了即插即用的连网方式,用户不再需要去手动配置 IP 地址等信息。

DHCP 配置的内容不仅是 IP 地址,还包括子网掩码、网关 IP 地址。

DHCP 工作过程如下:

  1. 客户端发送 Discover 报文,该报文的目的地址为 255.255.255.255:67,源地址为 0.0.0.0:68,被放入 UDP 中,该报文被广播到同一个子网的所有主机上。如果客户端和 DHCP 服务器不在同一个子网,就需要使用中继代理。
  2. DHCP 服务器收到 Discover 报文之后,发送 Offer 报文给客户端,该报文包含了客户端所需要的信息。因为客户端可能收到多个 DHCP 服务器提供的信息,因此客户端需要进行选择。
  3. 如果客户端选择了某个 DHCP 服务器提供的信息,那么就发送 Request 报文给该 DHCP 服务器。
  4. DHCP 服务器发送 Ack 报文,表示客户端此时可以使用提供给它的信息。

img

TELNET

远程登录协议 TELNET 用于登录到远程主机上,并且远程主机上的输出也会返回。

TELNET 可以适应许多计算机和操作系统的差异,例如不同操作系统系统的换行符定义。

电子邮件协议

一个电子邮件系统由三部分组成:用户代理、邮件服务器以及邮件协议。

邮件协议包含发送协议和读取协议,发送协议常用 SMTP,读取协议常用 POP3 和 IMAP。

SMTP

SMTP 只能发送 ASCII 码,而互联网邮件扩充 MIME 可以发送二进制文件。MIME 并没有改动或者取代 SMTP,而是增加邮件主体的结构,定义了非 ASCII 码的编码规则。

POP3

POP3 的特点是只要用户从服务器上读取了邮件,就把该邮件删除。

IMAP

IMAP 协议中客户端和服务器上的邮件保持同步,如果不手动删除邮件,那么服务器上的邮件也不会被删除。IMAP 这种做法可以让用户随时随地去访问服务器上的邮件。

常用端口

应用 应用层协议 端口号 传输层协议 备注
域名解析 DNS 53 UDP/TCP 长度超过 512 字节时使用 TCP
动态主机配置协议 DHCP 67/68 UDP
简单网络管理协议 SNMP 161/162 UDP
文件传送协议 FTP 20/21 TCP 控制连接 21,数据连接 20
远程终端协议 TELNET 23 TCP
超文本传送协议 HTTP 80 TCP
简单邮件传送协议 SMTP 25 TCP
邮件读取协议 POP3 110 TCP
网际报文存取协议 IMAP 143 TCP

Web 页面请求过程

DHCP 配置主机信息

  • 假设主机最开始没有 IP 地址以及其它信息,那么就需要先使用 DHCP 来获取。
  • 主机生成一个 DHCP 请求报文,并将这个报文放入具有目的端口 67 和源端口 68 的 UDP 报文段中。
  • 该报文段则被放入在一个具有广播 IP 目的地址(255.255.255.255) 和源 IP 地址(0.0.0.0)的 IP 数据报中。
  • 该数据报则被放置在 MAC 帧中,该帧具有目的地址 FF:FF:FF:FF:FF:FF,将广播到与交换机连接的所有设备。
  • 连接在交换机的 DHCP 服务器收到广播帧之后,不断地向上分解得到 IP 数据报、UDP 报文段、DHCP 请求报文,之后生成 DHCP ACK 报文,该报文包含以下信息:IP 地址、DNS 服务器的 IP 地址、默认网关路由器的 IP 地址和子网掩码。该报文被放入 UDP 报文段中,UDP 报文段有被放入 IP 数据报中,最后放入 MAC 帧中。
  • 该帧的目的地址是请求主机的 MAC 地址,因为交换机具有自学习能力,之前主机发送了广播帧之后就记录了 MAC 地址到其转发接口的交换表项,因此现在交换机就可以直接知道应该向哪个接口发送该帧。
  • 主机收到该帧后,不断分解得到 DHCP 报文。之后就配置它的 IP 地址、子网掩码和 DNS 服务器的 IP 地址,并在其 IP 转发表中安装默认网关。

ARP 解析 MAC 地址

  • 主机通过浏览器生成一个 TCP 套接字,套接字向 HTTP 服务器发送 HTTP 请求。为了生成该套接字,主机需要知道网站的域名对应的 IP 地址。
  • 主机生成一个 DNS 查询报文,该报文具有 53 号端口,因为 DNS 服务器的端口号是 53。
  • 该 DNS 查询报文被放入目的地址为 DNS 服务器 IP 地址的 IP 数据报中。
  • 该 IP 数据报被放入一个以太网帧中,该帧将发送到网关路由器。
  • DHCP 过程只知道网关路由器的 IP 地址,为了获取网关路由器的 MAC 地址,需要使用 ARP 协议。
  • 主机生成一个包含目的地址为网关路由器 IP 地址的 ARP 查询报文,将该 ARP 查询报文放入一个具有广播目的地址(FF:FF:FF:FF:FF:FF)的以太网帧中,并向交换机发送该以太网帧,交换机将该帧转发给所有的连接设备,包括网关路由器。
  • 网关路由器接收到该帧后,不断向上分解得到 ARP 报文,发现其中的 IP 地址与其接口的 IP 地址匹配,因此就发送一个 ARP 回答报文,包含了它的 MAC 地址,发回给主机。

DNS 解析域名

  • 知道了网关路由器的 MAC 地址之后,就可以继续 DNS 的解析过程了。
  • 网关路由器接收到包含 DNS 查询报文的以太网帧后,抽取出 IP 数据报,并根据转发表决定该 IP 数据报应该转发的路由器。
  • 因为路由器具有内部网关协议(RIP、OSPF)和外部网关协议(BGP)这两种路由选择协议,因此路由表中已经配置了网关路由器到达 DNS 服务器的路由表项。
  • 到达 DNS 服务器之后,DNS 服务器抽取出 DNS 查询报文,并在 DNS 数据库中查找待解析的域名。
  • 找到 DNS 记录之后,发送 DNS 回答报文,将该回答报文放入 UDP 报文段中,然后放入 IP 数据报中,通过路由器反向转发回网关路由器,并经过以太网交换机到达主机。

HTTP 请求页面

  • 有了 HTTP 服务器的 IP 地址之后,主机就能够生成 TCP 套接字,该套接字将用于向 Web 服务器发送 HTTP GET 报文。
  • 在生成 TCP 套接字之前,必须先与 HTTP 服务器进行三次握手来建立连接。生成一个具有目的端口 80 的 TCP SYN 报文段,并向 HTTP 服务器发送该报文段。
  • HTTP 服务器收到该报文段之后,生成 TCP SYN ACK 报文段,发回给主机。
  • 连接建立之后,浏览器生成 HTTP GET 报文,并交付给 HTTP 服务器。
  • HTTP 服务器从 TCP 套接字读取 HTTP GET 报文,生成一个 HTTP 响应报文,将 Web 页面内容放入报文主体中,发回给主机。
  • 浏览器收到 HTTP 响应报文后,抽取出 Web 页面内容,之后进行渲染,显示 Web 页面。

计算机网络之网络层

网络层(network layer) - 为分组交换网上的不同主机提供通信服务。在发送数据时,网络层把运输层产生的报文段或用户数据报封装成分组或包进行传送。

  • 主要协议:IPICMP
  • 数据单元:IP 数据报(packet)。
  • 典型设备:网关、路由器。

概述

网络层向上只提供简单灵活的、无连接的、尽最大努力交付的数据报服务。网络层不提供服务质量的承诺,不保证分组交付的时限,所传送的分组可能出错、丢失、重复和失序。进程间通信的可靠性由运输层负责。

IP 协议

网际协议 IP (Internet Protocol) 定义了三种功能:

  1. IP 定义了在 TCP/IP 互联网上数据传送的基本单元和数据格式。
  2. IP 软件完成路由选择功能,选择数据传送的路径。
  3. IP 包含了一组不可靠分组传送的规则,指明了分组处理、差错信息发生以及分组的规则。

相关协议

与 IP 协议配套使用的还有三个协议:

  • 地址解析协议 ARP(Address Resolution Protocol)
  • 网际控制报文协议 ICMP(Internet Control Message Protocol)
  • 网际组管理协议 IGMP(Internet Group Management Protocol)

img

分类的 IP 地址

IP 地址的编址方式经历了三个历史阶段:

  • 分类
  • 子网划分
  • 无分类

分类

由两部分组成,网络号和主机号,其中不同分类具有不同的网络号长度,并且是固定的。

1
IP 地址 ::= {< 网络号 >, < 主机号 >}

img

子网划分

通过在主机号字段中拿一部分作为子网号,把两级 IP 地址划分为三级 IP 地址。

1
IP 地址 ::= {< 网络号 >, < 子网号 >, < 主机号 >}

要使用子网,必须配置子网掩码。一个 B 类地址的默认子网掩码为 255.255.0.0,如果 B 类地址的子网占两个比特,那么子网掩码为 11111111 11111111 11000000 00000000,也就是 255.255.192.0。

注意,外部网络看不到子网的存在。

无分类

无分类编址 CIDR 消除了传统 A 类、B 类和 C 类地址以及划分子网的概念,使用网络前缀和主机号来对 IP 地址进行编码,网络前缀的长度可以根据需要变化。

1
IP 地址 ::= {< 网络前缀号 >, < 主机号 >}

CIDR 的记法上采用在 IP 地址后面加上网络前缀长度的方法,例如 128.14.35.7/20 表示前 20 位为网络前缀。

CIDR 的地址掩码可以继续称为子网掩码,子网掩码首 1 长度为网络前缀的长度。

一个 CIDR 地址块中有很多地址,一个 CIDR 表示的网络就可以表示原来的很多个网络,并且在路由表中只需要一个路由就可以代替原来的多个路由,减少了路由表项的数量。把这种通过使用网络前缀来减少路由表项的方式称为路由聚合,也称为 构成超网

在路由表中的项目由“网络前缀”和“下一跳地址”组成,在查找时可能会得到不止一个匹配结果,应当采用最长前缀匹配来确定应该匹配哪一个。

IP 地址与物理地址

img

  • 物理地址是数据链路层和物理层使用的地址。
  • IP 地址是网络层和以上各层使用的地址,是一种逻辑地址。

img

IP 数据报格式

img

  • 版本 - 有 4(IPv4)和 6(IPv6)两个值。
  • 首部长度 - 占 4 位,因此最大十进制数值为 15。值为 1 表示的是 1 个 32 位字的长度,也就是 4 字节。因为首部固定长度为 20 字节,因此该值最小为 5。如果可选字段的长度不是 4 字节的整数倍,就用尾部的填充部分来填充。
  • 区分服务 - 用来获得更好的服务,一般情况下不使用。
  • 总长度 - 包括首部长度和数据部分长度。占 16 位,因此数据报的最大长度为 2 16 - 1 = 65535 字节。
  • 生存时间 - TTL,它的存在是为了防止无法交付的数据报在互联网中不断兜圈子。以路由器跳数为单位,当 TTL 为 0 时就丢弃数据报。
  • 协议 - 指出携带的数据应该上交给哪个协议进行处理,例如 ICMP、TCP、UDP 等。
  • 首部检验和 - 因为数据报每经过一个路由器,都要重新计算检验和,因此检验和不包含数据部分可以减少计算的工作量。
  • 标识 - 在数据报长度过长从而发生分片的情况下,相同数据报的不同分片具有相同的标识符。
  • 片偏移 - 和标识符一起,用于发生分片的情况。片偏移的单位为 8 字节。

img

地址解析协议 ARP

网络层实现主机之间的通信,而链路层实现具体每段链路之间的通信。因此在通信过程中,IP 数据报的源地址和目的地址始终不变,而 MAC 地址随着链路的改变而改变。

img

ARP 实现由 IP 地址得到 MAC 地址。

img

每个主机都有一个 ARP 高速缓存,里面有本局域网上的各主机和路由器的 IP 地址到 MAC 地址的映射表。

如果主机 A 知道主机 B 的 IP 地址,但是 ARP 高速缓存中没有该 IP 地址到 MAC 地址的映射,此时主机 A 通过广播的方式发送 ARP 请求分组,主机 B 收到该请求后会发送 ARP 响应分组给主机 A 告知其 MAC 地址,随后主机 A 向其高速缓存中写入主机 B 的 IP 地址到 MAC 地址的映射。

img

网际控制报文协议 ICMP

ICMP 是为了更有效地转发 IP 数据报和提高交付成功的机会。它封装在 IP 数据报中,但是不属于高层协议。

img

ICMP 报文分为差错报告报文和询问报文。

img

Ping

Ping 是 ICMP 的一个重要应用,主要用来测试两台主机之间的连通性。

Ping 的原理是通过向目的主机发送 ICMP Echo 请求报文,目的主机收到之后会发送 Echo 回答报文。Ping 会根据时间和成功响应的次数估算出数据包往返时间以及丢包率。

Traceroute

Traceroute 是 ICMP 的另一个应用,用来跟踪一个分组从源点到终点的路径。

Traceroute 发送的 IP 数据报封装的是无法交付的 UDP 用户数据报,并由目的主机发送终点不可达差错报告报文。

  • 源主机向目的主机发送一连串的 IP 数据报。第一个数据报 P1 的生存时间 TTL 设置为 1,当 P1 到达路径上的第一个路由器 R1 时,R1 收下它并把 TTL 减 1,此时 TTL 等于 0,R1 就把 P1 丢弃,并向源主机发送一个 ICMP 时间超过差错报告报文;
  • 源主机接着发送第二个数据报 P2,并把 TTL 设置为 2。P2 先到达 R1,R1 收下后把 TTL 减 1 再转发给 R2,R2 收下后也把 TTL 减 1,由于此时 TTL 等于 0,R2 就丢弃 P2,并向源主机发送一个 ICMP 时间超过差错报文。
  • 不断执行这样的步骤,直到最后一个数据报刚刚到达目的主机,主机不转发数据报,也不把 TTL 值减 1。但是因为数据报封装的是无法交付的 UDP,因此目的主机要向源主机发送 ICMP 终点不可达差错报告报文。
  • 之后源主机知道了到达目的主机所经过的路由器 IP 地址以及到达每个路由器的往返时间。

虚拟专用网 VPN

由于 IP 地址的紧缺,一个机构能申请到的 IP 地址数往往远小于本机构所拥有的主机数。并且一个机构并不需要把所有的主机接入到外部的互联网中,机构内的计算机可以使用仅在本机构有效的 IP 地址(专用地址)。

有三个专用地址块:

  • 10.0.0.0 ~ 10.255.255.255
  • 172.16.0.0 ~ 172.31.255.255
  • 192.168.0.0 ~ 192.168.255.255

VPN 使用公用的互联网作为本机构各专用网之间的通信载体。专用指机构内的主机只与本机构内的其它主机通信;虚拟指好像是,而实际上并不是,它有经过公用的互联网。

下图中,场所 A 和 B 的通信经过互联网,如果场所 A 的主机 X 要和另一个场所 B 的主机 Y 通信,IP 数据报的源地址是 10.1.0.1,目的地址是 10.2.0.3。数据报先发送到与互联网相连的路由器 R1,R1 对内部数据进行加密,然后重新加上数据报的首部,源地址是路由器 R1 的全球地址 125.1.2.3,目的地址是路由器 R2 的全球地址 194.4.5.6。路由器 R2 收到数据报后将数据部分进行解密,恢复原来的数据报,此时目的地址为 10.2.0.3,就交付给 Y。

img

网络地址转换 NAT

专用网内部的主机使用本地 IP 地址又想和互联网上的主机通信时,可以使用 NAT 来将本地 IP 转换为全球 IP。

在以前,NAT 将本地 IP 和全球 IP 一一对应,这种方式下拥有 n 个全球 IP 地址的专用网内最多只可以同时有 n 台主机接入互联网。为了更有效地利用全球 IP 地址,现在常用的 NAT 转换表把传输层的端口号也用上了,使得多个专用网内部的主机共用一个全球 IP 地址。使用端口号的 NAT 也叫做网络地址与端口转换 NAPT。

路由器的结构

路由器从功能上可以划分为:路由选择和分组转发。

分组转发结构由三个部分组成:交换结构、一组输入端口和一组输出端口。

img

路由器分组转发流程

  • 从数据报的首部提取目的主机的 IP 地址 D,得到目的网络地址 N。
  • 若 N 就是与此路由器直接相连的某个网络地址,则进行直接交付;
  • 若路由表中有目的地址为 D 的特定主机路由,则把数据报传送给表中所指明的下一跳路由器;
  • 若路由表中有到达网络 N 的路由,则把数据报传送给路由表中所指明的下一跳路由器;
  • 若路由表中有一个默认路由,则把数据报传送给路由表中所指明的默认路由器;
  • 报告转发分组出错。

img

路由选择协议

路由选择协议都是自适应的,能随着网络通信量和拓扑结构的变化而自适应地进行调整。

互联网可以划分为许多较小的自治系统 AS,一个 AS 可以使用一种和别的 AS 不同的路由选择协议。

可以把路由选择协议划分为两大类:

  • 自治系统内部的路由选择:RIP 和 OSPF
  • 自治系统间的路由选择:BGP

内部网关协议 RIP

RIP 是一种基于距离向量的路由选择协议。距离是指跳数,直接相连的路由器跳数为 1。跳数最多为 15,超过 15 表示不可达。

RIP 按固定的时间间隔仅和相邻路由器交换自己的路由表,经过若干次交换之后,所有路由器最终会知道到达本自治系统中任何一个网络的最短距离和下一跳路由器地址。

距离向量算法:

  • 对地址为 X 的相邻路由器发来的 RIP 报文,先修改报文中的所有项目,把下一跳字段中的地址改为 X,并把所有的距离字段加 1;
  • 对修改后的 RIP 报文中的每一个项目,进行以下步骤:
  • 若原来的路由表中没有目的网络 N,则把该项目添加到路由表中;
  • 否则:若下一跳路由器地址是 X,则把收到的项目替换原来路由表中的项目;否则:若收到的项目中的距离 d 小于路由表中的距离,则进行更新(例如原始路由表项为 Net2, 5, P,新表项为 Net2, 4, X,则更新);否则什么也不做。
  • 若 3 分钟还没有收到相邻路由器的更新路由表,则把该相邻路由器标为不可达,即把距离置为 16。

RIP 协议实现简单,开销小。但是 RIP 能使用的最大距离为 15,限制了网络的规模。并且当网络出现故障时,要经过比较长的时间才能将此消息传送到所有路由器。

内部网关协议 OSPF

开放最短路径优先 OSPF,是为了克服 RIP 的缺点而开发出来的。

开放表示 OSPF 不受某一家厂商控制,而是公开发表的;最短路径优先表示使用了 Dijkstra 提出的最短路径算法 SPF。

OSPF 具有以下特点:

  • 向本自治系统中的所有路由器发送信息,这种方法是洪泛法。
  • 发送的信息就是与相邻路由器的链路状态,链路状态包括与哪些路由器相连以及链路的度量,度量用费用、距离、时延、带宽等来表示。
  • 只有当链路状态发生变化时,路由器才会发送信息。

所有路由器都具有全网的拓扑结构图,并且是一致的。相比于 RIP,OSPF 的更新过程收敛的很快。

外部网关协议 BGP

BGP(Border Gateway Protocol,边界网关协议)

AS 之间的路由选择很困难,主要是由于:

  • 互联网规模很大;
  • 各个 AS 内部使用不同的路由选择协议,无法准确定义路径的度量;
  • AS 之间的路由选择必须考虑有关的策略,比如有些 AS 不愿意让其它 AS 经过。

BGP 只能寻找一条比较好的路由,而不是最佳路由。

每个 AS 都必须配置 BGP 发言人,通过在两个相邻 BGP 发言人之间建立 TCP 连接来交换路由信息。

img

计算机网络之数据链路层

数据链路层(Data Link Layer) - 网络层针对的还是主机之间的数据传输服务,而主机之间可以有很多链路,链路层协议就是为同一链路的主机提供数据传输服务。数据链路层把网络层传下来的分组封装成帧。

  • 主要协议:PPPCSMA/CD 等。
  • 数据单元:帧(frame)。
  • 典型设备:二层交换机、网桥、网卡。

简介

  • 链路是从一个节点到相邻节点的一段物理线路,数据链路则是在链路的基础上增加了一些必要的硬件(网络适配器)和软件(协议)。
  • 数据链路层三个基本问题:封装成帧、透明传输、差错检测。
  • 数据链路层有两种信道类型:点对点信道(主要使用 PPP)和广播信道(主要使用 CSMA/CD)。
  • 以太网 MAC 层的地址。
  • 适配器、转发器、集线器、网桥、以太网交换机的作用及使用场合。

基本问题

封装成帧

为网络层传下来的 IP 数据报添加首部和尾部,用于标记帧的开始和结束。

为了提高传输效率,应该让数据部分长度尽可能大于首部和尾部。但是,每种链路层协议都限制了帧的数据部分长度上线——最大传送单元 MTU(Maximum Transfer Unit)

img

透明传输

透明表示:某一个实际存在的事物看起来好像不存在一样。

帧使用首部和尾部进行定界,如果帧的数据部分含有和首部尾部相同的内容,那么帧的开始和结束位置就会被错误的判定。需要在数据部分出现首部尾部相同的内容前面插入转义字符。如果数据部分出现转义字符,那么就在转义字符前面再加个转义字符。在接收端进行处理之后可以还原出原始数据。这个过程透明传输的内容是转义字符,用户察觉不到转义字符的存在。

img

差错检测

目前数据链路层广泛使用了循环冗余检验 CRC(Cyclic redundancy check)来检查比特差错。

点对点信道

点对点信道使用一对一的点对点通信方式。

对于点对点的链路,点对点协议 PPP(Point-to-Point Protocol)是使用最广泛的数据链路层协议。

PPP 协议

互联网用户通常都要连接到某个 ISP 之后才能接入到互联网,PPP 协议是用户计算机和 ISP 进行通信时所使用的数据链路层协议。

img

PPP(点到点协议)是为在同等单元之间传输数据包这样的简单链路设计的链路层协议。这种链路提供全双工操作,并按照顺序传递数据包。设计目的主要是用来通过拨号或专线方式建立点对点连接发送数据,使其成为各种主机、网桥和路由器之间简单连接的一种共通的解决方案。

img

PPP 的帧格式:

  • F 字段为帧的定界符
  • A 和 C 字段暂时没有意义
  • FCS 字段是使用 CRC 的检验序列
  • 信息部分的长度不超过 1500

广播信道

广播信道(broadcast channel)是通过广播的方式传输信息的信息通道。

所有的节点都在同一个广播信道上发送数据,因此需要有专门的控制方法进行协调,避免发生冲突(冲突也叫碰撞)。

主要有两种控制方法进行协调,一个是使用信道复用技术,一是使用 CSMA/CD 协议。

CSMA/CD 协议

CSMA/CD(Carrier Sense Multiple Access with Collision Detection)即带冲突检测的载波监听多路访问技术(载波监听多点接入/碰撞检测)。

  • 多点接入 :说明这是总线型网络,许多计算机以多点接入的方式连接在一根总线上。
  • 载波监听 :每个主机都必须不停地监听信道。发送前监听,如果忙则等待,如果空闲则发送。
  • 碰撞检测 :即边发送边检测。若检测到信道有干扰信号,则表示产生了碰撞,于是就要停止发送数据,计算出退避等待时间,然后使用 CSMA 方法继续尝试发送。计算退避等待时间采用的是二进制指数退避算法

局域网

局域网 LAN(Local Area Network)是指在某一区域内由多台计算机互联成的计算机组。

局域网的拓扑结构通常为总线型和环型。

局域网技术主要有:以太网、令牌环网、FDDI 网和无线局域网等。

以太网

以太网(Ethernet)是一种星型拓扑结构局域网。

以太网是目前应用最广泛的局域网。

以太网使用 CSMA/CD 协议。

MAC 地址

MAC 地址(Media Access Control Address),也称为以太网地址或物理地址,它是一个用来确认网上设备位置的地址。

MAC 地址长度为 6 字节(48 位),用于唯一标识网络适配器(网卡)。

一台主机拥有多少个网络适配器就有多少个 MAC 地址。

img

设备

适配器

网络适配器一般指网卡。

网卡是工作在链路层的网络组件,是局域网中连接计算机和传输介质的接口,不仅能实现与局域网传输介质之间的物理连接和电信号匹配,还涉及帧的发送与接收、帧的封装与拆封、介质访问控制、数据的编码与解码以及数据缓存的功能等。

网卡和局域网之间的通信是通过电缆或双绞线以串行传输方式进行的。而网卡和计算机之间的通信则是通过计算机主板上的 I/O 总线以并行传输方式进行。

集线器

集线器(Hub)的主要功能是对接收到的信号进行再生整形放大,以扩大网络的传输距离,同时把所有节点集中在以它为中心的节点上。

使用集线器可以在物理层扩展以太网。

网桥

网桥(Bridge)是早期的两端口二层网络设备,用来连接不同网段。网桥的两个端口分别有一条独立的交换信道,不是共享一条背板总线,可隔离冲突域。网桥比集线器(Hub)性能更好,集线器上各端口都是共享同一条背板总线的。后来,网桥被具有更多端口、同时也可隔离冲突域的交换机(Switch)所取代。

以太网交换机

以太网交换机是基于以太网传输数据的交换机,以太网采用共享总线型传输媒体方式的局域网。以太网交换机的结构是每个端口都直接与主机相连,并且一般都工作在全双工方式。交换机能同时连通许多对端口,使每一对相互通信的主机都能像独占通信媒体那样,进行无冲突地传输数据。

  • 以太网交换机的每个端口都直接与主机相连,并且一般都工作在全双工方式。
  • 交换机能同时连通许多对的端口,使每一对相互通信的主机都能像独占通信媒体那样,进行无冲突地传输数据。
  • 用户独占传输媒体的带宽,若一个接口到主机的带宽是 10Mbit 每秒,那么有 10 个接口的交换机的总容量是 100Mbit 每秒。这是交换机的最大优点。