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

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


  • 首页

  • 归档

  • 搜索

Java8 Lambda表达式详解手册及实例

发表于 2019-10-13

先贩卖一下焦虑,Java8发于2014年3月18日,距离现在已经快6年了,如果你对Java8的新特性还没有应用,甚至还一无所知,那你真得关注公众号“程序新视界”,好好系列的学习一下Java8的新特性。Lambda表达式已经在新框架中普通使用了,如果你对Lambda还一无所知,真得认真学习一下本篇文章了。

现在进入正题Java8的Lambda,首先看一下发音 ([ˈlæmdə])表达式。注意该词的发音,b是不发音的,da发[də]音。

为什么要引入Lambda表达式

简单的来说,引入Lambda就是为了简化代码,允许把函数作为一个方法的参数传递进方法中。如果有JavaScript的编程经验,马上会想到这不就是闭包吗。是的,Lambda表达式也可以称作Java中的闭包。

先回顾一下Java8以前,如果想把某个接口的实现类作为参数传递给一个方法会怎么做?要么创建一个类实现该接口,然后new出一个对象,在调用方法时传递进去,要么使用匿名类,可以精简一些代码。以创建一个线程并打印一行日志为例,使用匿名函数写法如下:

1
2
3
4
5
6
复制代码new Thread(new Runnable() {
@Override
public void run() {
System.out.println("欢迎关注公众号:程序新视界");
}
}).start();

在java8以前,使用匿名函数已经算是很简洁的写法了,再来看看使用Lambda表达式,上面的代码会变成什么样子。

1
复制代码new Thread(() -> System.out.println("欢迎关注公众号:程序新视界")).start();

是不是简洁到爆!

我们都知道java是面向对象的编程语言,除了部分简单数据类型,万物皆对象。因此,在Java中定义函数或方法都离不开对象,也就意味着很难直接将方法或函数像参数一样传递,而Java8中的Lambda表达式的出现解决了这个问题。

Lambda表达式使得Java拥有了函数式编程的能力,但在Java中Lambda表达式是对象,它必须依附于一类特别的对象类型——函数式接口(functional interface),后面详细讲解。

Lambda表达式简介

Lambda表达式是一种匿名函数(对Java而言这并不完全准确),通俗的说,它是没有声明的方法,即没有访问修饰符、返回值声明和名字的方法。使用Lambda表达式的好处很明显就是可以使代码变的更加简洁紧凑。

Lambda表达式的使用场景与匿名类的使用场景几乎一致,都是在某个功能(方法)只使用一次的时候。

Lambda表达式语法结构

Lambda表达式通常使用(param)->(body)语法书写,基本格式如下:

1
2
3
4
5
6
7
8
9
10
11
12
复制代码//没有参数
() -> body

// 1个参数
(param) -> body
// 或
(param) ->{ body; }

// 多个参数
(param1, param2...) -> { body }
// 或
(type1 param1, type2 param2...) -> { body }

常见的Lambda表达式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
复制代码// 无参数,返回值为字符串“公众号:程序新视界”
() -> "公众号:程序新视界";

// 1个String参数,直接打印结果
(System.out::println);
// 或
(String s) -> System.out.print(s)

// 1个参数(数字),返回2倍值
x -> 2 * x;

// 2个参数(数字),返回差值
(x, y) -> x – y

// 2个int型整数,返回和值
(int x, int y) -> x + y

对照上面的示例,我们再总结一下Lambda表达式的结构:

  • Lambda表达式可以有0~n个参数。
  • 参数类型可以显式声明,也可以让编译器从上下文自动推断类型。如(int x)和(x)是等价的。
  • 多个参数用小括号括起来,逗号分隔。一个参数可以不用括号。
  • 没有参数用空括号表示。
  • Lambda表达式的正文可以包含零条,一条或多条语句,如果有返回值则必须包含返回值语句。如果只有一条可省略大括号。如果有一条以上则必须包含在大括号(代码块)中。

函数式接口

函数式接口(Functional Interface)是Java8对一类特殊类型的接口的称呼。这类接口只定义了唯一的抽象方法的接口(除了隐含的Object对象的公共方法),因此最开始也就做SAM类型的接口(Single Abstract Method)。

比如上面示例中的java.lang.Runnable就是一种函数式接口,在其内部只定义了一个void run()的抽象方法,同时在该接口上注解了@FunctionalInterface。

1
2
3
4
复制代码@FunctionalInterface
public interface Runnable {
public abstract void run();
}

@FunctionalInterface注解是用来表示该接口要符合函数式接口的规范,除了隐含的Object对象的公共方法以外只可有一个抽象方法。当然,如果某个接口只定义一个抽象方法,不使用该注解也是可以使用Lambda表达式的,但是没有该注解的约束,后期可能会新增其他的抽象方法,导致已经使用Lambda表达式的地方出错。使用@FunctionalInterface从编译层面解决了可能的错误。

比如当注解@FunctionalInterface之后,写两个抽象方法在接口内,会出现以下提示:

1
复制代码Multiple non-overriding abstract methods found in interface com.secbro2.lambda.NoParamInterface

通过函数式接口我们也可以得出一个简单的结论:可使用Lambda表达式的接口,只能有一个抽象方法(除了隐含的Object对象的公共方法)。

注意此处的方法限制为抽象方法,如果接口内有其他静态方法则不会受限制。

方法引用,双冒号操作

[方法引用]的格式是,类名::方法名。

像如ClassName::methodName或者objectName::methodName的表达式,我们把它叫做方法引用(Method Reference),通常用在Lambda表达中。

看一下示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
复制代码// 无参数情况
NoParamInterface paramInterface2 = ()-> new HashMap<>();
// 可替换为
NoParamInterface paramInterface1 = HashMap::new;

// 一个参数情况
OneParamInterface oneParamInterface1 = (String string) -> System.out.print(string);
// 可替换为
OneParamInterface oneParamInterface2 = (System.out::println);

// 两个参数情况
Comparator c = (Computer c1, Computer c2) -> c1.getAge().compareTo(c2.getAge());
// 可替换为
Comparator c = (c1, c2) -> c1.getAge().compareTo(c2.getAge());
// 进一步可替换为
Comparator c = Comparator.comparing(Computer::getAge);

再比如我们用函数式接口java.util.function.Function来实现一个String转Integer的功能,可以如下写法:

1
2
复制代码Function<String, Integer> function = Integer::parseInt;
Integer num = function.apply("1");

根据Function接口的定义Function,其中T表示传入类型,R表示返回类型。具体就是实现了Function的apply方法,在其方法内调用了Integer.parseInt方法。

通过上面的讲解,基本的语法已经完成,以下内容通过实例来逐一演示在不同的场景下如何使用。

Runnable线程初始化示例

Runnable线程初始化是比较典型的应用场景。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
复制代码// 匿名函类写法
new Thread(new Runnable() {
@Override
public void run() {
System.out.println("欢迎关注公众号:程序新视界");
}
}).start();

// lambda表达式写法
new Thread(() -> System.out.println("欢迎关注公众号:程序新视界")).start();

// lambda表达式 如果方法体内有多行代码需要带大括号
new Thread(() -> {
System.out.println("欢迎关注公众号");
System.out.println("程序新视界");
}).start();

通常都会把lambda表达式内部变量的名字起得短一些,这样能使代码更简短。

事件处理示例

Swing API编程中经常会用到的事件监听。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
复制代码// 匿名函类写法
JButton follow = new JButton("关注");
follow.addActionListener(new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
System.out.println("已关注公众号:程序新视界");
}
});

// lambda表达式写法
follow.addActionListener((e) -> System.out.println("已关注公众号:程序新视界"));

// lambda表达式写法
follow.addActionListener((e) -> {
System.out.println("已关注公众号");
System.out.println("程序新视界");
});

列表遍历输出示例

传统遍历一个List,基本上都使用for循环来遍历,Java8之后List拥有了forEach方法,可配合lambda表达式写出更加简洁的方法。

1
2
3
4
5
6
7
8
9
10
11
复制代码List<String> list = Arrays.asList("欢迎","关注","程序新视界");

// 传统遍历
for(String str : list){
System.out.println(str);
}

// lambda表达式写法
list.forEach(str -> System.out.println(str));
// lambda表达式写法
list.forEach(System.out::println);

函数式接口示例

在上面的例子中已经看到函数式接口java.util.function.Function的使用,在java.util.function包下中还有其他的类,用来支持Java的函数式编程。比如通过Predicate函数式接口以及lambda表达式,可以向API方法添加逻辑,用更少的代码支持更多的动态行为。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
复制代码@Test
public void testPredicate() {

List<String> list = Arrays.asList("欢迎", "关注", "程序新视界");

filter(list, (str) -> ("程序新视界".equals(str)));

filter(list, (str) -> (((String) str).length() == 5));

}

public static void filter(List<String> list, Predicate condition) {
for (String content : list) {
if (condition.test(content)) {
System.out.println("符合条件的内容:" + content);
}
}
}

其中filter方法中的写法还可以进一步简化:

1
2
3
4
5
复制代码list.stream().filter((content) -> condition.test(content)).forEach((content) ->System.out.println("符合条件的内容:" + content));

list.stream().filter(condition::test).forEach((content) ->System.out.println("符合条件的内容:" + content));

list.stream().filter(condition).forEach((content) ->System.out.println("符合条件的内容:" + content));

如果不需要“符合条件的内容:”字符串的拼接,还能够进一步简化:

1
复制代码list.stream().filter(condition).forEach(System.out::println);

如果将调用filter方法的判断条件也写在一起,test方法中的内容可以通过一行代码来实现:

1
复制代码list.stream().filter((str) -> ("程序新视界".equals(str))).forEach(System.out::println);

如果需要同时满足两个条件或满足其中一个即可,Predicate可以将这样的多个条件合并成一个。

1
2
3
4
复制代码Predicate start = (str) -> (((String) str).startsWith("程序"));
Predicate len = (str) -> (((String) str).length() == 5);

list.stream().filter(start.and(len)).forEach(System.out::println);

Stream相关示例

在《JAVA8 STREAM新特性详解及实战》一文中已经讲解了Stream的使用。你是否发现Stream的使用都离不开Lambda表达式。是的,所有Stream的操作必须以Lambda表达式为参数。

以Stream的map方法为例:

1
2
复制代码Stream.of("a","b","c").map(item -> item.toUpperCase()).forEach(System.out::println);
Stream.of("a","b","c").map(String::toUpperCase).forEach(System.out::println);

更多的使用实例可参看Stream的《JAVA8 STREAM新特性详解及实战》一文。

Lambda表达式与匿名类的区别

  • 关键词的区别:对于匿名类,关键词this指向匿名类,而对于Lambda表达式,关键词this指向包围Lambda表达式的类的外部类,也就是说跟表达式外面使用this表达的意思是一样。
  • 编译方式:Java编译器编译Lambda表达式时,会将其转换为类的私有方法,再进行动态绑定,通过invokedynamic指令进行调用。而匿名内部类仍然是一个类,编译时编译器会自动为该类取名并生成class文件。

其中第一条,以Spring Boot中ServletWebServerApplicationContext类的一段源码作为示例:

1
2
3
4
5
6
7
8
9
10
11
12
复制代码private org.springframework.boot.web.servlet.ServletContextInitializer getSelfInitializer() {
return this::selfInitialize;
}

private void selfInitialize(ServletContext servletContext) throws ServletException {
prepareWebApplicationContext(servletContext);
registerApplicationScope(servletContext);
WebApplicationContextUtils.registerEnvironmentBeans(getBeanFactory(),servletContext);
for (ServletContextInitializer beans : getServletContextInitializerBeans()) {
beans.onStartup(servletContext);
}
}

其中,这里的this指向的就是getSelfInitializer方法所在的类。

小结

至此,Java8 Lambda表达式的基本使用已经讲解完毕,最关键的还是要勤加练习,达到熟能生巧的使用。当然,刚开始可能需要一个适应期,在此期间可以把本篇文章收藏当做一个手册拿来参考。

原文链接:《Java8 Lambda表达式详解手册及实例》

程序新视界:精彩和成长都不容错过
csdn-微信公众号

本文转载自: 掘金

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

Tomcat 遇到的使用功能总结 Tomcat 相关文章

发表于 2019-10-12

Tomcat

配置jdk

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
复制代码
java -version
1、若已安装,显示如下
java version "1.8.0_74"
Java(TM) SE Runtime Environment (build 1.8.0_74-b02)
Java HotSpot(TM) 64-Bit Server VM (build 25.74-b02, mixed mode)

安装在哪呢?
# which java
/usr/local/src/java/jdk1.8.0_74/bin/java

配置java环境变量
vi /etc/profile
在最后加入以下内容
export JAVA_HOME=/usr/local/src/java/jdk1.8.0_74
export PATH=$JAVA_HOME/bin:$PATH
export CLASSPATH=.:$JAVA_HOME/lib/dt.jar:$JAVA_HOME/lib/tools.jar
让/etc/profile文件修改后立即生效
source /etc/profile

2、若未安装,下载对应的jdk,在/usr/local/java目录下
wget ...
tar -zxvf jdk-8u151-linux-x64.tar.gz //解压jak

配置java环境变量
vi /etc/profile
在最后加入以下内容
export JAVA_HOME=/usr/local/java/jdk1.8.0_151
export PATH=$JAVA_HOME/bin:$PATH
export CLASSPATH=.:$JAVA_HOME/lib/dt.jar:$JAVA_HOME/lib/tools.jar
让/etc/profile文件修改后立即生效
source /etc/profile
测试
java -version

单Tomcat配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
复制代码在/usr/local/tomcat目录下

# cd /usr/local/tomcat
# wget https://mirrors.cnnic.cn/apache/tomcat/tomcat-8/v8.5.46/bin/apache-tomcat-8.5.46.tar.gz
# tar xzf apache-tomcat-8.5.46.tar.gz

重命名
# mv apache-tomcat-8.5.46 tomcat-8080-chefu

启动
在 /usr/local/tomcat/tomcat-8080-chefu/bin 下
# ./startup.sh
测试
# ps -ef | grep tomcat
# curl http://localhost:8080

局域网内无法访问,可能未打开8080端口
# firewall-cmd --permanent --zone=public --add-port=8080/tcp
# firewall-cmd --reload
用浏览器访问:http://192.168.1.179:8080

多Tomcat配置,一个Tomcat发布一个项目

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
复制代码1、
在/usr/local/tomcat目录下
解压出一个新的tomcat
# tar xzf apache-tomcat-8.5.46.tar.gz
# mv apache-tomcat-8.5.46 tomcat-8081-student

# tar xzf apache-tomcat-8.5.46.tar.gz
# mv apache-tomcat-8.5.46 tomcat-8082-shop

2、
修改配置文件 # vi /etc/profile
在文件末尾处放入下面的配置
CATALINA_1_BASE=/usr/local/tomcat/tomcat-8080-chefu
CATALINA_1_HOME=/usr/local/tomcat/tomcat-8080-chefu
TOMCAT_1_HOME=/usr/local/tomcat/tomcat-8080-chefu
export CATALINA_1_BASE CATALINA_1_HOME TOMCAT_1_HOME

CATALINA_2_BASE=/usr/local/tomcat/tomcat-8081-student
CATALINA_2_HOME=/usr/local/tomcat/tomcat-8081-student
TOMCAT_2_HOME=/usr/local/tomcat/tomcat-8081-student
export CATALINA_2_BASE CATALINA_2_HOME TOMCAT_2_HOME

CATALINA_3_BASE=/usr/local/tomcat/tomcat-8082-shop
CATALINA_3_HOME=/usr/local/tomcat/tomcat-8082-shop
TOMCAT_3_HOME=/usr/local/tomcat/tomcat-8082-shop
export CATALINA_3_BASE CATALINA_3_HOME TOMCAT_3_HOME

# source /etc/profile

3、
修改tomcat配置
在 bin/catalina.sh 中增加内容:
export CATALINA_BASE=$CATALINA_?_BASE
export CATALINA_HOME=$CATALINA_?_HOME

# vi /usr/local/tomcat/tomcat-8080-chefu/bin/catalina.sh
export CATALINA_BASE=$CATALINA_1_BASE
export CATALINA_HOME=$CATALINA_1_HOME
# vi /usr/local/tomcat/tomcat-8081-student/bin/catalina.sh
export CATALINA_BASE=$CATALINA_2_BASE
export CATALINA_HOME=$CATALINA_2_HOME
# vi /usr/local/tomcat/tomcat-8082-shop/bin/catalina.sh
export CATALINA_BASE=$CATALINA_3_BASE
export CATALINA_HOME=$CATALINA_3_HOME

4、
conf/server.xml 中更改端口号,需要的有下面三处:
//tomcat关闭端口
(1)<Server port="8005" shutdown="SHUTDOWN">
//tomcat默认的端口8080 URIEncoding防止中文乱码
(2)<Connector port="8080" protocol="HTTP/1.1"
connectionTimeout="20000"
redirectPort="8443"
URIEncoding="UTF-8"/>
// apache+tomcat模式时访问tomcat的端口
(3) <Connector port="8011" protocol="AJP/1.3" redirectPort="8443" />
注意:由于搭建集群主机这几处的端口号不能与另外的tomcat重复

# vi /usr/local/tomcat/tomcat-8080-chefu/conf/server.xml
8000 8080 8010
# vi /usr/local/tomcat/tomcat-8081-student/conf/server.xml
8001 8081 8011
# vi /usr/local/tomcat/tomcat-8082-shop/conf/server.xml
8002 8082 8012

# firewall-cmd --permanent --zone=public --add-port=8080/tcp
-- 8081 8082
# firewall-cmd --reload

Tomcat 配置及优化

配置讲解

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
复制代码
1 目录结构
/bin:脚本文件目录。
/common/lib:存放所有web项目都可以访问的公共jar包(使用Common类加载器加载)。
/conf:存放配置文件,最重要的是server.xml。
/logs:存放日志文件。
/server/webapps:来管理Tomcat-web服务用的。仅对TOMCAT可见,对所有的WEB APP都不可见(使用Catalina类加载器加载)。
/shared/lib:仅对所有WEB APP可见,对TOMCAT不可见(使用Shared类加载器加载)。
/temp:Tomcat运行时候存放临时文件用的。
/webapps:web应用发布目录。
/work:Tomcat把各种由jsp生成的servlet文件放在这个目录下。删除后,启动时会自动创建。

2 配置文件
server.xml:主要的配置文件。
web.xml:缺省的web app配置,WEB-INF/web.xml会覆盖该配置。
context.xml:不清楚跟server.xml里面的context是否有关系。

server.xml配置
- server标签
port:指定一个端口,这个端口负责监听关闭tomcat的请求。
shutdown:指定向端口发送的命令字符串。

-- service标签
name:指定service的名字。

--- Executor 配置连接数
maxThreads:Tomcat使用线程来处理接收的每个请求。这个值表示Tomcat可创建的最大的线程数。
acceptCount:指定当所有可以使用的处理请求的线程数都被使用时,可以放到处理队列中的请求数,超过这个数的请求将不予处理。
minSpareThreads:Tomcat初始化时创建的线程数。
maxSpareThreads:一旦创建的线程超过这个值,Tomcat就会关闭不再需要的socket线程。
enableLookups:是否反查域名,取值为:true或false。为了提高处理能力,应设置为false
connectionTimeout:网络连接超时,单位:毫秒。设置为0表示永不超时,这样设置有隐患的。默认可设置为20000毫秒。

--- Connector(表示客户端和service之间的连接)标签
port:指定服务器端要创建的端口号,并在这个端口监听来自客户端的请求。
minProcessors:服务器启动时创建的处理请求的线程数。
maxProcessors:最大可以创建的处理请求的线程数。
enableLookups:如果为true,则可以通过调用request.getRemoteHost()进行DNS查询来得到远程客户端的实际主机名,若为false则不进行DNS查询,而是返回其ip地址。
redirectPort:指定服务器正在处理http请求时收到了一个SSL传输请求后重定向的端口号。
acceptCount:指定当所有可以使用的处理请求的线程数都被使用时,可以放到处理队列中的请求数,超过这个数的请求将不予处理。
connectionTimeout:指定超时的时间数(以毫秒为单位)。

--- Engine(表示指定service中的请求处理机,接收和处理来自Connector的请求)标签
defaultHost:指定缺省的处理请求的主机名,它至少与其中的一个host元素的name属性值是一样的。

---- host(表示一个虚拟主机)标签
name:指定主机名。
appBase:应用程序基本目录,即存放应用程序的目录。
unpackWARs:如果为true,则tomcat会自动将WAR文件解压,否则不解压,直接从WAR文件中运行应用程序。

---- Realm(表示存放用户名,密码及role的数据库)标签
className:指定Realm使用的类名,此类必须实现org.apache.catalina.Realm接口。

---- Valve标签
className:指定Valve使用的类名,如用org.apache.catalina.valves.AccessLogValve类可以记录应用程序的访问信息。
directory:指定log文件存放的位置。
pattern:有两个值,common方式记录远程主机名或ip地址,用户名,日期,第一行请求的字符串,HTTP响应代码,发送的字节数。combined方式比common方式记录的值更多。

优化总结

一、tomcat8 内存优化

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
复制代码/bin/catalina.sh
catalina.sh文件配置如下:
#add java opts
JAVA_OPTS="-server -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=1024m -Xms512m -Xmx1024m -XX:MaxNewSize=256m"

-server:启用jdk的server版本。
-Xms:虚拟机初始化时的最小堆内存。默认是物理内存的1/64
-Xmx:虚拟机可使用的最大堆内存。默认是物理内存的1/4
-XX:PermSize:设置非堆内存初始值,默认是物理内存的1/64。
-XX:MaxNewSize:新生代占整个堆内存的最大值。
-XX:MaxPermSize:Perm(俗称方法区)占整个堆内存的最大值,也称内存最大永久保留区域。
1)错误提示:java.lang.OutOfMemoryError:Java heap space
Tomcat默认可以使用的内存为128MB,在较大型的应用项目中,这点内存是不够的,有可能导致系统无法运行。
常见的问题是报Tomcat内存溢出错误,Outof Memory(系统内存不足)的异常,从而导致客户端显示500错误,
一般调整Tomcat的-Xms和-Xmx即可解决问题,通常将-Xms和-Xmx设置成一样,
堆的最大值设置为物理可用内存的最大值的80%。

set JAVA_OPTS=-Xms512m-Xmx1024M

2)错误提示:java.lang.OutOfMemoryError: PermGen space
PermGenspace的全称是Permanent Generationspace,是指内存的永久保存区域,
这块内存主要是被JVM存放Class和Meta信息的,Class在被Loader时就会被放到PermGenspace中,
它和存放类实例(Instance)的Heap区域不同,GC(Garbage Collection)不会在主程序运行期对PermGenspace进行清理,
所以如果你的应用中有很CLASS的话,就很可能出现PermGen space错误,
这种错误常见在web服务器对JSP进行precompile的时候。如果你的WEB APP下都用了大量的第三方jar,
其大小超过了jvm默认的大小(4M)那么就会产生此错误信息了。解决方法:

setJAVA_OPTS=-XX:PermSize=128M

3)在使用-Xms和-Xmx调整tomcat的堆大小时,还需要考虑垃圾回收机制。
如果系统花费很多的时间收集垃圾,请减小堆大小。
一次完全的垃圾收集应该不超过3-5秒。如果垃圾收集成为瓶颈,那么需要指定代的大小,
检查垃圾收集的详细输出,研究垃圾收集参数对性能的影响。
一般说来,你应该使用物理内存的 80% 作为堆大小。
当增加处理器时,记得增加内存,因为分配可以并行进行,而垃圾收集不是并行的。

二、更改server.xml配置

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
复制代码1、将<Server port="8005" shutdown="SHUTDOWN">SHUTDOWN修改为其他一些字符串。否则就容易被人给停止掉了。存疑?

2、访问日志 <Valve>不要注释;默认没有注释

3、Executor
<!--
<Executor name="tomcatThreadPool" namePrefix="catalina-exec-"
maxThreads="150" minSpareThreads="4"/>
-->
优化为:未完成

<Executor name="tomcatThreadPool"
namePrefix="catalina-exec-"
maxThreads="500"
maxIdleTime="60000"
prestartminSpareThreads="true"
minSpareThreads="30" />

3、Connector
<Connector port="8080" protocol="HTTP/1.1"
connectionTimeout="20000"
redirectPort="8443" />

优化为:未完成
<Connector executor ="tomcatThreadPool"
port="8080"
protocol="org.apache.coyote.http11.Http11Nio2Protocol"
connectionTimeout="20000"
maxConnections="10000"
redirectPort="8443"
acceptCount="1500"/>

其中:
• maxThreads:tomcat可用于请求处理的最大线程数,默认是200
• minSpareThreads:tomcat初始线程数,即最小空闲线程数
• maxSpareThreads:tomcat最大空闲线程数,超过的会被关闭
• acceptCount:当所有可以使用的处理请求的线程数都被使用时,可以放到处理队列中的请求数,超过这个数的请求将不予处理.默认100

三、APR模式 – 未整理 未实验

生成环境下的Tomcat 8.0.36 在CentOS7下安装和配置apr

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
复制代码tomcat自带tomcat-native.war.gz
# cd /usr/local/tomcat/tomcat-8081-student/bin/
# tar xzfv tomcat-native.tar.gz
# cd tomcat-native-1.1.32-src/jni/native
./configure --with-apr=/usr/bin/apr-1-config
make && make install
#注意最新版本的tomcat自带tomcat-native.war.gz,不过其版本相对于yum安装的apr过高,configure的时候会报错。

解决:yum remove apr apr-devel –y,卸载yum安装的apr和apr-devel,下载最新版本的apr源码包,编译安装;或者下载低版本的tomcat-native编译安装

安装成功后还需要对tomcat设置环境变量,方法是在catalina.sh文件中增加1行:

CATALINA_OPTS="-Djava.library.path=/usr/local/apr/lib"
#apr下载地址:http://apr.apache.org/download.cgi

#tomcat-native下载地址:http://tomcat.apache.org/download-native.cgi

修改8080端对应的conf/server.xml

protocol="org.apache.coyote.http11.Http11AprProtocol"

<Connector executor="tomcatThreadPool"
port="8080"
protocol="org.apache.coyote.http11.Http11AprProtocol"
connectionTimeout="20000"
enableLookups="false"
redirectPort="8443"
URIEncoding="UTF-8" />
PS:启动以后查看日志 显示如下表示开启 apr 模式

Sep 19, 2016 3:46:21 PM org.apache.coyote.AbstractProtocol start
INFO: Starting ProtocolHandler ["http-apr-8081"]

数据库连接池与数据源

www.jianshu.com/p/854da460a…
www.doc88.com/p-999965820…

Tomcat 发布项目流程

1)直接部署到webapps目录下面访问。

tomcat的默认测试页面是放在webapps下面,这个其实是在server.xml文件中配置的,如下所示:

1
复制代码  <Host name="localhost"  appBase="webapps" unpackWARs="true" autoDeploy="true"> </Host>

webapps文件夹主要用于web应用程序部署,比如你可以把你的应用程序包,如war文件拷到该目录下,容器会自动部署。

ex:http://ip:8080/carService/…

2)修改conf/server.xml文件。在Host标签中加入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
复制代码<Context path="/project-name" docBase="project-name绝对路径" debug="0" reloadable="true"/>
http://ip:8080/project-name/...
docBase:web项目主目录
path:浏览器访问时的路径名 可以将path=""
reloadble:设定项目有改动时,tomcat是否重新加载该项目

ex:<Context path="/haha" docBase="/usr/local/tomcat/tomcat-8080-chefu/webapps/carService" debug="0" reloadable="true"/>
http://192.168.1.179:8081/haha/

ex:<Context path="/aaa" docBase="/home/webapps/carService" debug="0" reloadable="true"/>
http://192.168.1.179:8081/aaa/

ex:<Context path="" docBase="/home/webapps/carService" debug="0" reloadable="true"/>
http://192.168.1.179:8081/

3)当项目没有放在webapps目录下时

1
2
3
4
5
6
7
8
9
10
11
12
复制代码进入到\conf\Catalina\localhost 目录,新建一个 项目名.xml 文件,如 webProject.xml
里面加入
<Context docBase="project-name绝对路径" debug="0" reloadable="true" />
注意:这里的path属性不需要设置,设置了也不会起作用的。

在浏览器输入路径:localhost:8080/xml文件名/访问的文件名
localhost:8080/webProject/...

创建另一个xml文件 ,例如:ROOT.xml ,指向另一个项目
<Context docBase="project-name2绝对路径" debug="0" reloadable="true"/>
这样默认访问的主目录就被修改过了
localhost:8080/ROOT/...

相关文章

centos7.4安装jdk1.8及tomcat8.5

tomcat常用配置详解和优化方法

tomcat 性能优化(主要)

Tomcat调优总结(Tomcat自身优化、Linux内核优化、JVM优化)

TODO:Tomcat服务配置与性能优化

本文转载自: 掘金

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

打造属于你的业务规则引擎

发表于 2019-10-12

规则引擎由推理引擎发展而来,是一种嵌入在应用程序中的组件,实现了将业务决策从应用程序代码中分离出来,并使用预定义的语义模块编写业务决策。接受数据输入,解释业务规则,并根据规则做出业务决策。

介绍

规则引擎能够将业务决策逻辑从系统逻辑中抽离出来,使两种逻辑独立于彼此而变化,这样可以明显降低两种逻辑的维护成本。

比如说在物联网平台中,连接的设备种类繁多,数据格式,数据类型不统一,但又要面临接入新设备的需求,不能说每接入一种设备,都要写一套设备数据处理的逻辑,然后升级发布系统的功能,设备处理逻辑可以写,但要能最低限度的影响平台的功能。这个时候,我们最好需要一套规则引擎来灵活的处理各种设备的数据。

设计

思路

已经知道了为什么要做规则引擎,要怎么处理呢,参考QLExpress使用说明文章:www.jianshu.com/p/c1fa9c4a0…

QLExpress是一种Java的规则引擎,可以动态的执行脚本,并且可以绑定一些我们写好的Java函数作为动态执行脚本的操作。那么我们将数据处理中最通用的内容封装为一个个QLExpress的组件,在数据处理的时候直接拿来使用即可。其实我们可以考虑只是数据的处理,可能会有哪些组件:

  • 获取数据点位(属性)组件,以便快速的获取数据中的某些想要的部分
  • 反馈组件,在规则执行结束后调用系统中的功能,通知业务规则执行完毕
  • 协议组件,可以有http,tcp,udp等,在组件中封装调用地址,数据包,请求地址
  • JSON、XML数据序列化组件,也许会产生新的数据,需要再处理一次

我觉得上面这几个组件可以满足大部分的数据场景了,有来源,有处理,有反馈。当然在业务复杂的情况下,可能会不断的增加新的组件,但是每次增加要想一想,这个组件真的有需要吗?

另外因为QLExpress已经支持动态脚本了,具有一定编程语言的特性,足够的灵活处理,封装我们的组件只是为了那些更加通用,常用的场景,而最通用的东西一般不会很多。

image-20191012074002421

提供功能

  • 默认的规则组件,以及使用说明
  • 规则的增删改查接口
  • 规则执行接口
  • 额外暂不考虑

大体流程

1 整理好系统中最常用的功能,进行封装,将最常用的功能以某种方式(配置文件,动态加载),注册为QLExpress的某个操作符上。如下

1
2
3
4
5
6
7
8
复制代码runner.addFunctionOfClassMethod("取绝对值", Math.class.getName(), "abs",
new String[] { "double" }, null);
runner.addFunctionOfClassMethod("转换为大写", BeanExample.class.getName(),
"upper", new String[] { "String" }, null);

runner.addFunctionOfServiceMethod("打印", System.out, "println",new String[] { "String" }, null);
runner.addFunctionOfServiceMethod("contains", new BeanExample(), "anyContains",
new Class[] { String.class, String.class }, null);

2 在不同的业务场景下,编写QLExpress语句,可以利用上一步注册到QLExpres上的功能简化语句。虽然是动态脚本但是要足够简单,业务要做到不用写几句代码就能满足这次需求,如果要写太多的语法,那就有点不太友好了。

3 在每个编写的规则上,提供规则执行测试脚本,数据隔离防止产生脏数据,校验规则脚本能否正确执行。

4 将规则引擎的功能集成到平台上。

最后

QLExpress脚本引擎被广泛应用在阿里的电商业务场景中,支持常见的编程语法,足够强大,在QLExpress基础之上打造自己的业务规则引擎。

参考:
QLExpress项目
QLExpress使用说明

本文转载自: 掘金

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

Puppeteer 用来做爬虫太 Low 了!但用在这里很合

发表于 2019-10-11

文章作者:「夜幕团队 NightTeam」 - 张冶青

润色、校对:「夜幕团队 NightTeam」 - Loco

前言

自动化测试对于软件开发来说是一个很重要也很方便的东西,但是自动化测试工具除了能用来做测试以外,还能被用来做一些模拟人类操作的事情,所以一些 E2E 自动化测试工具(例如:Selenium、Puppeteer、Appium)因为其强大的模拟功能,经常还被爬虫工程师们用来抓取数据。

网上有很多将自动化测试工具作为爬虫的抓取教程,不过仅仅都限于如何获取数据,而我们知道这些基于浏览器的解决方案都有较大的性能开销,而且效率不高,并不是爬虫的最佳选择。

本篇文章将介绍自动化测试工具的另一种用法,也就是用来自动化一些人工操作。我们使用的工具是谷歌开发并开源的测试框架 Puppeteer ,它会操作 Chromium (谷歌开发的开源浏览器)来完成自动化。我们将一步一步介绍如何利用 Puppeteer 在掘金上自动发布文章。

自动化测试工具的原理

自动化测试工具的原理是通过程式化地操作浏览器,与其进行模拟交互(例如点击、打字、导航等等)来控制要抓取的网页。自动化测试工具通常也能获取网页的 DOM 或 HTML,因此也可以轻松的获取网页数据。

此外,对于一些动态网站来说,JS 动态渲染的数据通常不能轻松获取,而自动化测试工具则可以轻松的做到,因为它是将 HTML 输入浏览器里运行的。

Puppeteer 简介

这里摘抄 Puppeteer 的 Github 主页上的定义(英文)。

Puppeteer is a Node library which provides a high-level API to control Chrome or Chromium over the DevTools Protocol. Puppeteer runs headless by default, but can be configured to run full (non-headless) Chrome or Chromium.

翻译过来大致是: Puppeteer 是一个 Node.js 库,提供了高级 API 来控制 Chrome 或 Chromium (通过开发工具协议); Puppeteer 默认的运行模式是无头的,但是可以被配置成非无头的模式。

Loco注:无头指的是不显示浏览器的GUI,是为了提升性能而设计的,因为渲染图像是一件很消耗资源的事情。

以下是 Puppeteer 可以做的事情:

  • 生成截图和页面 PDF ;
  • 抓取单页应用,产生预渲染内容(即 SSR ,服务端渲染);
  • 自动化表单提交、 UI 测试、键盘输入等等;
  • 创建一个最新的、自动化的测试环境;
  • 捕获网站的时间线来帮助诊断性能问题;
  • 测试 Chrome 插件;
  • …

Puppeteer 安装

安装 Puppeteer 并不难,只需要保证你的环境上安装了 Node.js 以及能够运行 NPM。

由于官方的安装教程没有考虑到已经安装了 Chromium 的情况,我们这里使用一个第三方库 puppeteer-chromium-resolver,它能够自定义化 Puppeteer 以及管理 Chromium 的下载情况。

运行以下命令安装 Puppeteer:

npm install puppeteer-chromium-resolver --save

puppeteer-chromium-resolver 的详细用法请参照官网:www.npmjs.com/package/pup…。

Puppeteer 常用命令

Puppeteer 的官方API文档是 pptr.dev/ ,文档里有详细的 Puppeteer 的开放接口,可以进行参考,这里我们只列出一些常用的接口命令。

生成/关闭浏览器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
复制代码// 引入puppeteer-chromium-resolver
const PCR = require('puppeteer-chromium-resolver')

// 生成PCR实例
const pcr = await PCR({
revision: '',
detectionPath: '',
folderName: '.chromium-browser-snapshots',
hosts: ['https://storage.googleapis.com', 'https://npm.taobao.org/mirrors'],
retry: 3,
silent: false
})

// 生成浏览器
const browser = await pcr.puppeteer.launch({...})

// 关闭浏览器
await browser.close()

生成页面

1
复制代码const page = await browser.newPage()

导航

1
复制代码await page.goto('https://baidu.com')

等待

1
复制代码await page.waitFor(3000)
1
复制代码await page.goto('https://baidu.com')

获取页面元素

1
复制代码const el = await page.$(selector)

点击元素

1
复制代码await el.click()

输入内容

1
复制代码await el.type(text)

执行Console代码(重点)

1
2
3
4
复制代码const res = await page.evaluate((arg1, arg2, arg3) => {
// anything frontend
return 'frontend awesome'
}, arg1, arg2, arg3)

这应该是 Puppeteer 中最强大的 API 了。任何熟悉前端技术的开发者都应该了解 Chrome 开发者工具中的 Console,任何 JS 的代码都可以在这里被运行,其中包括点击事件、获取元素、增删改元素等等。我们的自动发文程序将大量用到这个 API 。

可以看到 evaluate 方法可以接受一些参数,并作为回调函数中的参数作用在前端代码中。这让我们可以将后端的任何数据注入到前端 DOM 中,例如文章标题和文章内容等等。

另外,回调函数中的返回值可以作为 evaluate 的返回值,赋值给 res,这经常被用作数据抓取。

注意,上面的这些代码都用了 await 这个关键字,这其实是 ES7 中的 async/await 新语法,是 ES6 的 Promise 的语法糖,让异步代码更容易阅读和理解。如果对 async/await 不理解的同学,可以参考这篇文章:juejin.cn/post/684490…。

Puppeteer 实战:在掘金上自动发布文章

常言说:Talk is cheap, show me the code。

下面,我们将用一个自动发文章的例子来展示 Puppeteer 的功能。本文中用来作为示例的平台是掘金。

为什么选择掘金呢?这是因为掘金的登录并不像其他某些网站(例如 CSDN )要求输入验证码(这会增大复杂度),只要求输入账户名和密码就可以登录了。

为了方便新手理解,我们将从爬虫基本结构开始讲解。(限于篇幅考虑,我们将略过浏览器和页面的初始化,只挑重点讲解)

基础结构

为了让爬虫显得不那么乱七八糟,我们将发布文章的各个步骤抽离了出来,形成了一个基类(因为我们可能不止掘金一个平台要抓取,使用面向对象的思想编写代码的话,其他平台只需要继承基类就可以了)。

这个爬虫基类大致的结构如下:

我们不用理解所有的方法,只需要知道我们启动的入口是 run 这个方法就好了。

所有方法都加上了 async,表示这个方法将返回 Promise,如果需要以同步的形式调用,必须加上 await 这个关键字。

run 方法的内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
复制代码  async run() {
// 初始化
await this.init()

if (this.task.authType === constants.authType.LOGIN) {
// 登陆
await this.login()
} else {
// 使用Cookie
await this.setCookies()
}

// 导航至编辑器
await this.goToEditor()

// 输入编辑器内容
await this.inputEditor()

// 发布文章
await this.publish()

// 关闭浏览器
await this.browser.close()
}

可以看到,爬虫将首先初始化,完成一些基础配置;然后根据任务的验证类别(authType )来决定是否采用登录或 Cookie 的方式来通过网站验证(本文只考虑登录验证的情况);接下来就是导航至编辑器,然后输入编辑器内容;接着,发布文章;最后关闭浏览器,发布任务完成。

登录

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
复制代码  async login() {
logger.info(`logging in... navigating to ${this.urls.login}`)
await this.page.goto(this.urls.login)
let errNum = 0
while (errNum < 10) {
try {
await this.page.waitFor(1000)
const elUsername = await this.page.$(this.loginSel.username)
const elPassword = await this.page.$(this.loginSel.password)
const elSubmit = await this.page.$(this.loginSel.submit)
await elUsername.type(this.platform.username)
await elPassword.type(this.platform.password)
await elSubmit.click()
await this.page.waitFor(3000)
break
} catch (e) {
errNum++
}
}

// 查看是否登陆成功
this.status.loggedIn = errNum !== 10

if (this.status.loggedIn) {
logger.info('Logged in')
}
}

掘金的登录地址是 juejin.im/login,我们先将浏…

这里我们循环 10 次,尝试输入用户名和密码,如果 10 次都失败了,就设置登录状态为 false;反之,则设置为 true。

接着,我们用到了 page.$(selector) 和 el.type(text) 这两个 API ,分别用于获取元素和输入内容。而最后的 elSubmit.click() 是提交表单的操作。

编辑文章

这里我们略过了跳转到文章编辑器的步骤,因为这个很简单,只需要调用 page.goto(url) 就可以了,后面会贴出源码地址供大家参考。

输入编辑器的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
复制代码  async inputEditor() {
logger.info(`input editor title and content`)
// 输入标题
await this.page.evaluate(this.inputTitle, this.article, this.editorSel, this.task)
await this.page.waitFor(3000)

// 输入内容
await this.page.evaluate(this.inputContent, this.article, this.editorSel)
await this.page.waitFor(3000)

// 输入脚注
await this.page.evaluate(this.inputFooter, this.article, this.editorSel)
await this.page.waitFor(3000)

await this.page.waitFor(10000)

// 后续处理
await this.afterInputEditor()
}

首先输入标题,调用了 page.evaluate 这个前端执行函数,传入 this.inputTitle 输入标题这个回调函数,以及其他参数;接着同样的原理,调用输入内容回调函数;然后是输入脚注;最后,调用后续处理函数。


下面我们详细看看 this.inputTitle 这个函数:

1
2
3
4
5
6
7
复制代码  async inputTitle(article, editorSel, task) {
const el = document.querySelector(editorSel.title)
el.focus()
el.select()
document.execCommand('delete', false)
document.execCommand('insertText', false, task.title || article.title)
}

我们首先通过前端的公开接口 document.querySelector(selector) 获取标题的元素,为了防止标题有 placeholder,我们用 el.focus()(获取焦点)、el.select()(全选)、document.execCommand('delete', false)(删除)来删除已有的 placeholder。然后我们通过 document.execCommand('insertText', false, text) 来输入标题内容。

接下来,是输入内容,代码如下(它的原理与输入标题类似):

1
2
3
4
5
6
7
复制代码  async inputContent(article, editorSel) {
const el = document.querySelector(editorSel.content)
el.focus()
el.select()
document.execCommand('delete', false)
document.execCommand('insertText', false, article.content)
}

有人可能会问,为什么不用 el.type(text) 来输入内容,反而要大费周章的用 document.execCommand 来实现输入呢?

这里我们不用前者的原因,是因为它是完全模拟人的敲打键盘操作的,这样会破坏已有的内容格式。而如果用后者的话,可以一次性的将内容输入进来。

我们在基类 BaseSpider 中预留了一个方法来完成选择分类、标签等操作,在继承后的类 JuejinSpider 中是这样的:

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
复制代码    async afterInputEditor() {
// 点击发布文章
const elPubBtn = await this.page.$('.publish-popup')
await elPubBtn.click()
await this.page.waitFor(5000)

// 选择类别
await this.page.evaluate((task) => {
document.querySelectorAll('.category-list > .item').forEach(el => {
if (el.textContent === task.category) {
el.click()
}
})
}, this.task)
await this.page.waitFor(5000)

// 选择标签
const elTagInput = await this.page.$('.tag-input > input')
await elTagInput.type(this.task.tag)
await this.page.waitFor(5000)
await this.page.evaluate(() => {
document.querySelector('.suggested-tag-list > .tag:nth-child(1)').click()
})
await this.page.waitFor(5000)
}

发布

发布操作相对来说比较简单了,只需要点击发布的那个按钮就可以了。代码如下:

1
2
3
4
5
6
7
8
9
10
复制代码  async publish() {
logger.info(`publishing article`)
// 发布文章
const elPub = await this.page.$(this.editorSel.publish)
await elPub.click()
await this.page.waitFor(10000)

// 后续处理
await this.afterPublish()
}

this.afterPublish 是用来处理验证发文状态和获取发布 URL 的,这里限于篇幅不详细介绍了。

源码

当然,本篇文章由于篇幅原因,介绍的并不是所有的自动发文功能,如果你想了解更多,可以发送消息【掘金自动发文】到我们的微信公众号【NightTeam】获取源码地址。

总结

本篇文章介绍了如何使用 Puppeteer 来操作 Chromium 浏览器在掘金上发布文章。

很多人用 Puppeteer 来抓取数据,但我们认为这种效率较低,而且开销较大,不适合大规模抓取。

相反, Puppeteer 更适合做一些自动化的工作,例如操作浏览器发布文章、发布帖子、提交表单等等。

Puppeteer 自动化工具很类似 RPA(Robotic Process Automation),都是自动化一些繁琐的、重复性的工作,只不过后者不仅限于浏览器,其范围(Scope)是基于整个操作系统的,功能更强大,但是开销也更大。

Puppeteer 作为相对轻量级的自动化工具,很适合用来做一些网页自动化操作作业。本文介绍的 Puppeteer 实战内容也是开源一文多发平台项目 ArtiPub 的一部分,有兴趣的同学可以去尝试一下。


夜幕团队成立于 2019 年,团队包括崔庆才、周子淇、陈祥安、唐轶飞、冯威、蔡晋、戴煌金、张冶青和韦世东。

涉猎的编程语言包括但不限于 Python、Rust、C++、Go,领域涵盖爬虫、深度学习、服务研发、对象存储等。团队非正亦非邪,只做认为对的事情,请大家小心。

本篇文章由一文多发平台ArtiPub自动发布

本文转载自: 掘金

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

从零开始利用JPA与SHARDING-JDBC动态划分月表

发表于 2019-10-11

开始

从零开始利用spring-data-jpa与sharding-jdbc进行动态月表,直接上手。

需求说明

数据量按照分片键(入库时间)进入对应的月表,查询时根据分片键的值查询指定表;但是每次查询都必须带上分片键,这就不是很友好,所以另外后面也有说明在没有指定分片键时如何查询最近的两个月。

前期准备

建表语句

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
复制代码-- 逻辑表,每个月表都根据逻辑表生成
CREATE TABLE `EXAMPLE` (
`ID` bigint(36) NOT NULL AUTO_INCREMENT,
`NAME` varchar(255) NOT NULL,
`CREATED` datetime(3) DEFAULT NULL,
`UPDATED` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`ID`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 月表
CREATE TABLE `EXAMPLE_201909` (
`ID` bigint(36) NOT NULL AUTO_INCREMENT,
`NAME` varchar(255) NOT NULL,
`CREATED` datetime(3) DEFAULT NULL,
`UPDATED` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`ID`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `EXAMPLE_201910` (
`ID` bigint(36) NOT NULL AUTO_INCREMENT,
`NAME` varchar(255) NOT NULL,
`CREATED` datetime(3) DEFAULT NULL,
`UPDATED` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`ID`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

实体类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
复制代码@Entity
@Data
@Table(name = "EXAMPLE")
public class Example implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "ID")
private String id;
@Column(name = "NAME")
private String name;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss.SSS", timezone = "GMT+8")
@Column(name = "CREATED")
private Date created;
@Column(name = "UPDATED", insertable = false, updatable = false)
private Date updated;
}

repo

1
2
3
4
5
6
7
8
9
复制代码import java.util.Date;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import com.test.sharding.entity.Example;

public interface ExampleRepo extends JpaRepository<Example, Long>, JpaSpecificationExecutor<Example> {
List<Example> findByCreatedBetween(Date start, Date end);
}

Maven依赖

经过测试,支持springboot 2.0.X+与1.5.X+。

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
复制代码		<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>io.shardingsphere</groupId>
<artifactId>sharding-jdbc-spring-boot-starter</artifactId>
<version>3.0.0</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>4.6.7</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>1.1.20</version>
</dependency>

分片算法实现

由于选择的分片策略是StandardShardingStrategy(在后面的配置文件中会配置),所以需要试下下面两个分片算法:

  • 精确分片算法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
复制代码import java.util.Collection;
import java.util.Date;
import cn.hutool.core.date.DateUtil;
import io.shardingsphere.api.algorithm.sharding.PreciseShardingValue;
import io.shardingsphere.api.algorithm.sharding.standard.PreciseShardingAlgorithm;

public class MyPreciseShardingAlgorithm implements PreciseShardingAlgorithm<Date> {
// 可以优化为全局变量
private static String yearAndMonth = "yyyyMM";

@Override
public String doSharding(Collection<String> availableTargetNames, PreciseShardingValue<Date> shardingValue) {
StringBuffer tableName = new StringBuffer();
tableName.append(shardingValue.getLogicTableName()).append("_")
.append(DateUtil.format(shardingValue.getValue(), yearAndMonth));
return tableName.toString();
}
}
  • 范围分片算法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
复制代码public class TimeRangeShardingAlgorithm implements RangeShardingAlgorithm<Date> {
private static String yearAndMonth = "yyyyMM";
/**
* 只查询最近两个月的数据
*/
@Override
public Collection<String> doSharding(Collection<String> availableTargetNames, RangeShardingValue<Date> shardingValue) {
Collection<String> result = new LinkedHashSet<String>();
Range<Date> range = shardingValue.getValueRange();
// 获取范围
String end = DateUtil.format(range.lowerEndpoint(), yearAndMonth);
// 获取前一个月
String start = DateUtil.format(range.upperEndpoint(), yearAndMonth);
result.add(shardingValue.getLogicTableName() + "_" + start);
if (!end.equals(start)) {
result.add(shardingValue.getLogicTableName() + "_" + end);
}
return result;
}

}

application.yml配置

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
复制代码spring:
datasource: # 可有可无,在配置了sharding之后,默认只会有sharding数据源生效
type: com.alibaba.druid.pool.DruidDataSource
url: jdbc:mysql://localhost:3306/ddssss
username: root
password: ppppppp
tomcat:
initial-size: 5
driver-class-name: com.mysql.jdbc.Driver
jpa:
database: mysql
sharding:
jdbc:
datasource:
names: month-0 # 数据源名称
month-0:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql://localhost:3306/ddssss
username: root
password: ppppppp
type: com.alibaba.druid.pool.DruidDataSource
config:
sharding:
tables:
month: # 表名
key-generator-column-name: id # 主键名称
table-strategy:
standard:
sharding-column: ccreated # 分片键
precise-algorithm-class-name: com.example.sharding.config.MyPreciseShardingAlgorithm # 实现类的完全限定类名
range-algorithm-class-name: com.example.sharding.config.MyRangeShardingAlgorithm # 实现类的完全限定类名
props:
sql.show: true # 是否显示SQL ,默认为false

测试

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
复制代码
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import javax.persistence.criteria.Predicate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Component;
import com.alibaba.fastjson.JSONObject;
import com.test.sharding.entity.Example;
import com.test.sharding.repository.ExampleRepo;
import cn.hutool.core.date.DateUtil;
import lombok.extern.slf4j.Slf4j;

@Component
@Slf4j
public class StartRunner implements CommandLineRunner {
@Autowired
ExampleRepo exampleRepo;

@Override
public void run(String... args) throws Exception {
log.info("==============init===================");
Example example = new Example();
example.setName("我的名字");
example.setCreated(new Date());
exampleRepo.save(example);
log.info("example:{}", JSONObject.toJSONString(example));
// 普通条件查询
List<Example> list = exampleRepo.findAll(org.springframework.data.domain.Example.<Example>of(example));
log.info("normal list :{}", JSONObject.toJSONString(list));
// 动态条件查询
Example condtion = new Example();
condtion.setCreated(example.getCreated());
list = exampleRepo.findAll(getIdSpecification(condtion));
log.info("dynamic list :{}", JSONObject.toJSONString(list));
// 范围查询
Date end = new Date();
list = exampleRepo.findByCreatedBetween(DateUtil.lastMonth()
.toJdkDate(), end);
log.info("range select list :{}", JSONObject.toJSONString(list));
}

protected Specification<Example> getIdSpecification(final Example condtion) {
return (root, query, cb) -> {
List<Predicate> list = new ArrayList<>();
list.add(cb.equal(root.<Date>get("created"), condtion.getCreated()));
Predicate[] predicates = new Predicate[list.size()];
query.where(list.toArray(predicates));
return query.getRestriction();
};
}
}

启动后就会看到日志如下:

数据库:

  • 表:

  • 数据

后记

虽然这样实现了基于时间的动态划分月表查询与插入,但在实际使用中却还有着许多小问题,比如:save方法在指定了主键的情况下依然会进行INSERT而不是UPDATE、查询时必须带上分片键、还需要手动创建后续的月表。

针对这三个问题,需要做进一步的优化。

问题产生的原因

  1. 为什么save方法在指定了主键的情况下依然会进行INSERT而不是UPDATE

JPA的SAVE在指定的主键不为空时会先去表里查询该主键是否存在,但是这样查询的条件是只有主键而没有分片键的,Sharding-JDBC的策略是在没有指定分片键时会去查询所有的分片表。

但是这里就是有一个误区,Sharding-JDBC主动查询所有的分片表指的是固定分片的情况。比如这里有另外一张表,根据ID奇偶分片,分出来有两张表。那么所有的数据都会在者两张表中,我们在配置的时候也是直接配置者两张表。

对于我们现在的需求来说就不适用,因为我们的分表规则是根据时间来的,每年每月都有一张新表,所以对于没有指定分片键值得查询,Sharding-JDBC默认值查询了逻辑表。此时返回空,JPA就会认为该主键没有数据,所以对应的SQL是INSERT而不是UPDATE。

  1. 为什么查询时必须带上分片键

理由和上述是一样的,Sharding-JDBC在没有指定分片键时值查询了逻辑表。

  1. 还需要手动创建后续的月表

首先,每个月都需要创建对应的月表这个是肯定的,当然也可以直接一次性县创建几年的表,但我感觉没意义,这种重复的事情应该让程序来做,定时创建月表。

解决方案

针对问题1与问题2,我直接重写Sharding-JDBC的路由规则,可以完美解决。

  • 重写路由规则

需要修改类io.shardingsphere.core.routing.type.standard.StandardRoutingEngine的routeTables方法,并且声明了一个静态变量记录需要分表的逻辑表,具体代码如下:

1
2
3
4
5
复制代码// 时间格式化
private static String yearAndMonth = "yyyyMM";
// 保存需要分表的逻辑表
private static final Set<String> needRoutTables = new HashSet<>(
Lists.newArrayList("EXAMPLE"));
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
复制代码	private Collection<DataNode> routeTables(final TableRule tableRule, final String routedDataSource,
final List<ShardingValue> tableShardingValues) {
Collection<String> availableTargetTables = tableRule.getActualTableNames(routedDataSource);
// 路由表,根据分表算法得到,动态分表时如果条件里没有分片键则返回逻辑表,本文是:EXAMPLE
Collection<String> routedTables = new LinkedHashSet<>(tableShardingValues.isEmpty() ? availableTargetTables
: shardingRule.getTableShardingStrategy(tableRule)
.doSharding(availableTargetTables, tableShardingValues));
// 如果得到的路由表只有一个,因为大于2的情况都应该是制定了分片键的(分表是不建议联表查询的)
if (routedTables.size() <= 1) {
// 得到逻辑表名
String routeTable = routedTables.iterator()
.next();
// 判断是否需要分表,true代表需要分表
if (needRoutTables.contains(routeTable)) {
// 移除逻辑表
routedTables.remove(routeTable);
Date now = new Date();
// 月份后缀,默认最近两个月
String nowSuffix = DateUtil.format(now, yearAndMonth);
String lastMonthSuffix = DateUtil.format(DateUtil.lastMonth(), yearAndMonth);
routedTables.add(routeTable + "_" + nowSuffix);
routedTables.add(routeTable + "_" + lastMonthSuffix);
}
}
Preconditions.checkState(!routedTables.isEmpty(), "no table route info");
Collection<DataNode> result = new LinkedList<>();
for (String each : routedTables) {
result.add(new DataNode(routedDataSource, each));
}
return result;
}

针对问题3,利用程序定时建表,我这里没有选择通用的建表语句:

1
2
3
4
5
6
7
8
复制代码-- ****** 日期,在程序里动态替换
CREATE TABLE `EXAMPLE_******` (
`ID` bigint(36) NOT NULL AUTO_INCREMENT,
`NAME` varchar(255) NOT NULL,
`CREATED` datetime(3) DEFAULT NULL,
`UPDATED` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`ID`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

主要原因有以下两点

  1. 在一般的项目里的表字段一般都不会这么少,建表语句会很长
  2. 而且后期的维护也不好,对于表任何改动都需要在程序里也需要维护

我选择了根据模板来创建表,SQL如下:

1
2
复制代码-- ****** 日期,在程序里动态替换
CREATE TABLE IF NOT EXISTS `EXAMPLE_******` LIKE `EXAMPLE`

这样的好处就是建表语句相对精简、不需要关心表结构了,一切从模板新建月表。但是这也引出了一个新的问题,Sharding-JDBC不支持这样的语法。所以又需要修改源代码重写一下拦截规则。具体就是类io.shardingsphere.core.parsing.parser.sql.ddl.create.table.AbstractCreateTableParser的parse方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
复制代码	public final DDLStatement parse() {
lexerEngine.skipAll(getSkippedKeywordsBetweenCreateIndexAndKeyword());
lexerEngine.skipAll(getSkippedKeywordsBetweenCreateAndKeyword());
CreateTableStatement result = new CreateTableStatement();
if (lexerEngine.skipIfEqual(DefaultKeyword.TABLE)) {
lexerEngine.skipAll(getSkippedKeywordsBetweenCreateTableAndTableName());
} else {
throw new SQLParsingException("Can't support other CREATE grammar unless CREATE TABLE.");
}
tableReferencesClauseParser.parseSingleTableWithoutAlias(result);
// 注释掉这个命令
// lexerEngine.accept(Symbol.LEFT_PAREN);
do {
parseCreateDefinition(result);
} while (lexerEngine.skipIfEqual(Symbol.COMMA));
// 注释掉这个命令
// lexerEngine.accept(Symbol.RIGHT_PAREN);
return result;
}

总结

到此一个完整的动态划分月表就已经完成了,整体来说还比较简单,真正有一点难度的是在于遇到问题时对于源码的分析,能够合理的根据自身的业务需求去实现自己的分表逻辑。

本文转载自: 掘金

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

一直使用AtomicInteger?试一试FiledUpda

发表于 2019-10-10
  1. 背景

在进入正题之前,这里先提出一个问题,如何在多线程中去对一个数字进行+1操作?这个问题非常简单,哪怕是Java的初学者都能回答上来,使用AtomicXXX,比如有一个int类型的自加,那么你可以使用AtomicInteger 代替int类型进行自加。

1
2
复制代码 AtomicInteger atomicInteger = new AtomicInteger();
atomicInteger.addAndGet(1);

如上面的代码所示,使用addAndGet即可保证多线程中相加,具体原理在底层使用的是CAS,这里就不展开细讲。基本上AtomicXXX能满足我们的所有需求,直到前几天一个群友(ID:皮摩)问了我一个问题,他发现在很多开源框架中,例如Netty中的AbstractReferenceCountedByteBuf 类中定义了一个refCntUpdater:

1
2
3
4
5
6
7
8
9
10
复制代码    private static final AtomicIntegerFieldUpdater<AbstractReferenceCountedByteBuf> refCntUpdater;

static {
AtomicIntegerFieldUpdater<AbstractReferenceCountedByteBuf> updater =
PlatformDependent.newAtomicIntegerFieldUpdater(AbstractReferenceCountedByteBuf.class, "refCnt");
if (updater == null) {
updater = AtomicIntegerFieldUpdater.newUpdater(AbstractReferenceCountedByteBuf.class, "refCnt");
}
refCntUpdater = updater;
}

refCntUpdater 是Netty用来记录ByteBuf被引用的次数,会出现并发的操作,比如增加一个引用关系,减少一个引用关系,其retain方法,实现了refCntUpdater的自增:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码    private ByteBuf retain0(int increment) {
for (;;) {
int refCnt = this.refCnt;
final int nextCnt = refCnt + increment;

// Ensure we not resurrect (which means the refCnt was 0) and also that we encountered an overflow.
if (nextCnt <= increment) {
throw new IllegalReferenceCountException(refCnt, increment);
}
if (refCntUpdater.compareAndSet(this, refCnt, nextCnt)) {
break;
}
}
return this;
}

俗话说有因必有果,netty多费力气做这些事必然是有自己的原因的,接下来就进入我们的正题。

2.Atomic field updater

在java.util.concurrent.atomic包中有很多原子类,比如AtomicInteger,AtomicLong,LongAdder等已经是大家熟知的常用类,在这个包中还有三个类在jdk1.5中都存在了,但是经常被大家忽略,这就是fieldUpdater:

  • AtomicIntegerFieldUpdater
  • AtomicLongFieldUpdater
  • AtomicReferenceFieldUpdater

这个在代码中不经常会有,但是有时候可以作为性能优化的工具出场,一般在下面两种情况会使用它:

  • 你想通过正常的引用使用volatile的,比如直接在类中调用this.variable,但是你也想时不时的使用一下CAS操作或者原子自增操作,那么你可以使用fieldUpdater。
  • 当你使用AtomicXXX的时候,其引用Atomic的对象有多个的时候,你可以使用fieldUpdater节约内存开销。

2.1 正常引用volatile变量

一般有两种情况需要正常引用:

  1. 当代码中引入已经正常引用,但是这个时候需要新增一个CAS的需求,我们可以将其替换AtomicXXX对象,但是之前的调用都得换成.get()和.set()方法,这样做会增加不少的工作量,并且还需要大量的回归测试。
  2. 代码更加容易理解,在BufferedInputStream中,有一个buf数组用来表示内部缓冲区,它也是一个volatile数组,在BufferedInputStream中大多数时候只需要正常的使用这个数组缓冲区即可,在一些特殊的情况下,比如close的时候需要使用compareAndSet,我们可以使用AtomicReference,我觉得这样做有点乱,使用fieldUpdater来说更加容易理解,
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
复制代码    protected volatile byte buf[];


private static final
AtomicReferenceFieldUpdater<BufferedInputStream, byte[]> bufUpdater =
AtomicReferenceFieldUpdater.newUpdater
(BufferedInputStream.class, byte[].class, "buf");

public void close() throws IOException {
byte[] buffer;
while ( (buffer = buf) != null) {
if (bufUpdater.compareAndSet(this, buffer, null)) {
InputStream input = in;
in = null;
if (input != null)
input.close();
return;
}
// Else retry in case a new buf was CASed in fill()
}
}

2.2 节约内存

之前说过在很多开源框架中都能看见fieldUpdater的身影,其实大部分的情况都是为了节约内存,为什么其会节约内存呢?

我们首先来看看AtomicInteger类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
复制代码public class AtomicInteger extends Number implements java.io.Serializable {
private static final long serialVersionUID = 6214790243416807050L;

// setup to use Unsafe.compareAndSwapInt for updates
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;

static {
try {
valueOffset = unsafe.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}

private volatile int value;
}

在AtomicInteger成员变量只有一个int value,似乎好像并没有多出内存,但是我们的AtomicInteger是一个对象,一个对象的正确计算应该是 对象头 + 数据大小,在64位机器上AtomicInteger对象占用内存如下:

  • 关闭指针压缩: 16(对象头)+4(实例数据)=20不是8的倍数,因此需要对齐填充 16+4+4(padding)=24
  • 开启指针压缩(-XX:+UseCompressedOop): 12+4=16已经是8的倍数了,不需要再padding。

由于我们的AtomicInteger是一个对象,还需要被引用,那么真实的占用为:

  • 关闭指针压缩:24 + 8 = 32
  • 开启指针压缩: 16 + 4 = 20

而fieldUpdater是staic final类型并不会占用我们对象的内存,所以使用fieldUpdater的话可以近似认为只用了4字节,这个再未关闭指针压缩的情况下节约了7倍,关闭的情况下节约了4倍,这个在少量对象的情况下可能不明显,当我们对象有几十万,几百万,或者几千万的时候,节约的可能就是几十M,几百M,甚至几个G。

比如在netty中的AbstractReferenceCountedByteBuf,熟悉netty的同学都知道netty是自己管理内存的,所有的ByteBuf都会继承AbstractReferenceCountedByteBuf,在netty中ByteBuf会被大量的创建,netty使用fieldUpdater用于节约内存。

在阿里开源的数据库连接池druid中也有同样的体现,早在2012的一个pr中,就有优化内存的comment:

,在druid中,有很多统计数据对象,这些对象通常会以秒级创建,分钟级创建新的,druid通过fieldUpdater节约了大量内存:

3.最后

AtomicFieldUpdater的确在我们平时使用比较少,但是其也值得我们去了解,有时候在特殊的场景下的确可以作为奇技淫巧。

如果大家觉得这篇文章对你有帮助,你的关注和转发是对我最大的支持,O(∩_∩)O:

本篇文章由一文多发平台ArtiPub自动发布

本文转载自: 掘金

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

MongoDB(五)-- 副本集(replica Set)

发表于 2019-10-09

一、副本集介绍

搭建副本集是为了实现mongodb高可用。

img

Mongodb(M)表示主节点,Mongodb(S)表示备节点,Mongodb(A)表示仲裁节点。主备节点存储数据,仲裁节点不存储数据。客户端同时连接主节点与备节点,不连接仲裁节点。

仲裁节点是一种特殊的节点,它本身并不存储数据,主要的作用是决定哪一个备节点在主节点挂掉之后提升为主节点,所以客户端不需要连接此节点。

在MongoDB副本集中,主节点负责处理客户端的读写请求,备份节点则负责映射主节点的数据。

备份节点的工作原理过程可以大致描述为,备份节点定期轮询主节点上的数据操作,然后对自己的数据副本进行这些操作,从而保证跟主节点的数据同步。至于主节点上的所有 数据库状态改变 的操作,都会存放在一张特定的系统表中。备份节点则是根据这些数据进行自己的数据更新。

上面提到的 数据库状态改变 的操作,称为oplog(operation log,主节点操作记录)。oplog存储在local数据库的”oplog.rs”表中。副本集中备份节点异步的从主节点同步oplog,然后重新执行它记录的操作,以此达到了数据同步的作用。

Oplog注意点:

  • Oplog的大小是固定的,当集合被填满的时候,新的插入的文档会覆盖老的文档。
  • Oplog同步数据

初始化:这个过程发生在当副本集中创建一个新的数据库或其中某个节点刚从宕机中恢复,或者向副本集中添加新的成员的时候,默认的,副本集中的节点会从离它最近的节点复制oplog来同步数据,这个最近的节点可以是primary也可以是拥有最新oplog副本的secondary节点。

二、搭建有仲裁节点的副本集

1.进入到/usr/java中,新建mongodbRepliSet文件夹,然后在 mongodbRepliSet 文件夹中新建 3个节点,先 mkdir node1。

2.进入到 node1中,新建 data 和 log文件夹,即 mkdir data log,然后进入到 data中,mkdir db。

3.拷贝mongodb的配置文件到 node1中,

cp /usr/java/mongoNode/mongodb.conf /usr/java/mongodbRepliSet/node1/mongodb.conf

4.修改配置文件,vim mongodb.conf

1
2
3
4
5
6
7
复制代码dbpath=/usr/java/mongodbRepliSet/node1/data//db
logpath=/usr/java/mongodbRepliSet/node1/log/mongodb.log
logappend=true
fork=true
bind_ip=192.168.80.128
port=27017
replSet=JoeSet # 3个节点的这个配置要一样,表示在一个副本集中

5.拷贝 node1 整个文件夹,名为 node2 和 node3

1
2
复制代码cp -r node1 node2
cp -r node1 node3

6.修改 node2 和 node3 文件夹中的mongodb.conf,修改 dbpath、logpath 和 port(27018、27019)。

7.配置 临时的环境变量,export PATH=/usr/java/mongodb/bin:$PATH

8.查看临时的环境变量是否配置成功:echo $PATH

9.启动3个节点,分别进入 node1 node2和node3中,以配置文件方式启动:mongod –config mongodb.conf

10.客户端连接node1,mongo –host 192.168.80.128 –port 27017

11.上述配置 没有指定哪一个是master、slave、仲裁节点,所以需要执行下 副本集的初始化,执行:

rs.initiate({“_id”:”JoeSet”,members:[{“_id”:1,”host”:”192.168.80.128:27017”,priority:3},{“_id”:2,”host”:”192.168.80.128:27018”, priority:9},{“_id”:3,”host”:”192.168.80.128:27019”,arbiterOnly:true}]})。

  1. “_id”: 副本集的名称
  2. “members”: 副本集的服务器列表
  3. “_id”: 服务器的唯一ID
  4. “host”: 服务器主机
  5. “priority”: 是优先级,默认为1,优先级0为被动节点,不能成为活跃节点。优先级不位0则按照有大到小选出活跃节点。
  6. “arbiterOnly”: 仲裁节点,只参与投票,不接收数据,也不能成为活跃节点。

img

执行完 初始化命令后,会变为上图所示。

注意,可能出现的错误:

1
2
3
4
5
6
复制代码> rs.initiate({"_id":"JoeSet",members:[{"_id":1,"host":"192.168.80.128:27017",priority:3},{"_id":2,"host":"192.168.80.128:27018", priority:9},{"_id":4,"host":"192.168.80.128:27019",arbiterOnly:true}]})
{
"ok" : 0,
"errmsg" : "This node, 192.168.80.128:27019, with _id 4 is not electable under the new configuration version 1 for replica set JoeSet",
"code" : 93
}

错误原因,是因为客户端连接了 27019,而27019 又是仲裁节点,所以出现了这个错误。解决方法,客户端连接 其他节点,不连接 设置为仲裁的节点。

12.执行:rs.status(),查看状态。

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
复制代码JoeSet:OTHER> rs.status()
{
"set" : "JoeSet",
"date" : ISODate("2017-07-26T22:09:44.940Z"),
"myState" : 2,
"members" : [
{
"_id" : 1,
"name" : "192.168.80.128:27017",
"health" : 1,
"state" : 2,
"stateStr" : "SECONDARY",&emsp;&emsp;&emsp;&emsp;&emsp;&emsp;&emsp;&emsp;# SECONDARY 表示从节点
"uptime" : 38,
"optime" : Timestamp(1501106974, 1),
"optimeDate" : ISODate("2017-07-26T22:09:34Z"),
"configVersion" : 1,
"self" : true
},
{
"_id" : 2,
"name" : "192.168.80.128:27018",
"health" : 1,
"state" : 1,
"stateStr" : "PRIMARY",&emsp;&emsp;&emsp;&emsp;&emsp;&emsp;&emsp;&emsp;# PRIMARY 表示主节点
"uptime" : 10,
"optime" : Timestamp(1501106974, 1),
"optimeDate" : ISODate("2017-07-26T22:09:34Z"),
"lastHeartbeat" : ISODate("2017-07-26T22:09:44.560Z"),
"lastHeartbeatRecv" : ISODate("2017-07-26T22:09:44.579Z"),
"pingMs" : 1,
"electionTime" : Timestamp(1501106981, 1),
"electionDate" : ISODate("2017-07-26T22:09:41Z"),
"configVersion" : 1
},
{
"_id" : 3,
"name" : "192.168.80.128:27019",
"health" : 1,
"state" : 7,
"stateStr" : "ARBITER",&emsp;&emsp;&emsp;&emsp;&emsp;&emsp;&emsp;&emsp;# ARBITER 表示仲裁节点
"uptime" : 10,
"lastHeartbeat" : ISODate("2017-07-26T22:09:44.559Z"),
"lastHeartbeatRecv" : ISODate("2017-07-26T22:09:44.585Z"),
"pingMs" : 0,
"configVersion" : 1
}
],
"ok" : 1
}

13.为了验证副本集搭建成功,我们 在node2(主节点) 中插入几条数据,然后在 node1 (从节点)中查看,因为 上面配置了 node2 为主节点, node1为从节点,node3为仲裁节点。

  1. 在node2中:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码JoeSet:PRIMARY> show dbs
local 0.078GB
JoeSet:PRIMARY> use testdb
switched to db testdb
JoeSet:PRIMARY> db.createCollection("testCon")
{ "ok" : 1 }
JoeSet:PRIMARY> show collections
system.indexes
testCon
JoeSet:PRIMARY> for(var i=0; i<3;i++) db.testCon.insert({name:"Joe",index:i})
WriteResult({ "nInserted" : 1 })
JoeSet:PRIMARY> db.testCon.find()
{ "_id" : ObjectId("597a553d7db09ad9f77f1353"), "name" : "Joe", "index" : 0 }
{ "_id" : ObjectId("597a553d7db09ad9f77f1354"), "name" : "Joe", "index" : 1 }
{ "_id" : ObjectId("597a553d7db09ad9f77f1355"), "name" : "Joe", "index" : 2 }
  1. 在node1中:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
复制代码JoeSet:SECONDARY> show dbs
2017-07-27T14:06:14.672-0700 E QUERY Error: listDatabases failed:{ "note" : "from execCommand", "ok" : 0, "errmsg" : "not master" }
at Error (<anonymous>)
at Mongo.getDBs (src/mongo/shell/mongo.js:47:15)
at shellHelper.show (src/mongo/shell/utils.js:630:33)
at shellHelper (src/mongo/shell/utils.js:524:36)
at (shellhelp2):1:1 at src/mongo/shell/mongo.js:47
JoeSet:SECONDARY> rs.slaveOk()
JoeSet:SECONDARY> show dbs
local 0.078GB
testdb 0.078GB
JoeSet:SECONDARY> use testdb
switched to db testdb
JoeSet:SECONDARY> show collections
system.indexes
testCon
JoeSet:SECONDARY> db.testCon.find()
{ "_id" : ObjectId("597a553d7db09ad9f77f1353"), "name" : "Joe", "index" : 0 }
{ "_id" : ObjectId("597a553d7db09ad9f77f1354"), "name" : "Joe", "index" : 1 }
{ "_id" : ObjectId("597a553d7db09ad9f77f1355"), "name" : "Joe", "index" : 2 }

在node1(从节点)中 可以查看到 node2(主节点)插入的数据,说明 副本集搭建成功。

注意:从节点中是不允许执行写操作的。

三、模拟主节点故障及恢复

  1. 模拟node2(主节点)挂了,kiil 掉node2(主节点)的进程
  2. 客户端连接 node1(从节点):mongo –host 192.168.80.128 –port 27017
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
复制代码JoeSet:PRIMARY> rs.status()
{
"set" : "JoeSet",
"date" : ISODate("2017-07-27T21:20:45.629Z"),
"myState" : 1,
"members" : [
{
"_id" : 1,
"name" : "192.168.80.128:27017",
"health" : 1,
"state" : 1,
"stateStr" : "PRIMARY",
"uptime" : 4209,
"optime" : Timestamp(1501189437, 10),
"optimeDate" : ISODate("2017-07-27T21:03:57Z"),
"electionTime" : Timestamp(1501190187, 1),
"electionDate" : ISODate("2017-07-27T21:16:27Z"),
"configVersion" : 1,
"self" : true
},
{
"_id" : 2,
"name" : "192.168.80.128:27018",
"health" : 0,
"state" : 8,
"stateStr" : "(not reachable/healthy)",
"uptime" : 0,
"optime" : Timestamp(0, 0),
"optimeDate" : ISODate("1970-01-01T00:00:00Z"),
"lastHeartbeat" : ISODate("2017-07-27T21:20:43.773Z"),
"lastHeartbeatRecv" : ISODate("2017-07-27T21:16:24.292Z"),
"pingMs" : 1,
"lastHeartbeatMessage" : "Failed attempt to connect to 192.168.80.128:27018; couldn't connect to server 192.168.80.128:27018 (192.168.80.128), connection attempt failed",
"configVersion" : -1
},
{
"_id" : 3,
"name" : "192.168.80.128:27019",
"health" : 1,
"state" : 7,
"stateStr" : "ARBITER",
"uptime" : 4191,
"lastHeartbeat" : ISODate("2017-07-27T21:20:45.475Z"),
"lastHeartbeatRecv" : ISODate("2017-07-27T21:20:44.876Z"),
"pingMs" : 0,
"configVersion" : 1
}
],
"ok" : 1
}

发现原来的从节点(node1)变为了 主节点,而原来的主节点显示的状态是 不可达、不健康的。这对于整个副本集的使用是没有影响的。

  1. 重新启动node2,即 原来的主节点。如果启动node2失败,就删除掉 db文件夹下的mongod.lock文件。
  2. 客户端连接node1,mongo –host 192.168.80.128 –port 27017,发现node2 重新启动后,node1 又变为 从节点,而 node2又变为 原来的主节点,这是因为 仲裁节点 和 设置的优先级的原因。
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
复制代码JoeSet:SECONDARY> rs.status()
{
"set" : "JoeSet",
"date" : ISODate("2017-07-27T21:31:06.197Z"),
"myState" : 2,
"members" : [
{
"_id" : 1,
"name" : "192.168.80.128:27017",
"health" : 1,
"state" : 2,
"stateStr" : "SECONDARY",
"uptime" : 4830,
"optime" : Timestamp(1501189437, 10),
"optimeDate" : ISODate("2017-07-27T21:03:57Z"),
"configVersion" : 1,
"self" : true
},
{
"_id" : 2,
"name" : "192.168.80.128:27018",
"health" : 1,
"state" : 1,
"stateStr" : "PRIMARY",
"uptime" : 87,
"optime" : Timestamp(1501189437, 10),
"optimeDate" : ISODate("2017-07-27T21:03:57Z"),
"lastHeartbeat" : ISODate("2017-07-27T21:31:05.209Z"),
"lastHeartbeatRecv" : ISODate("2017-07-27T21:31:05.506Z"),
"pingMs" : 0,
"electionTime" : Timestamp(1501190981, 1),
"electionDate" : ISODate("2017-07-27T21:29:41Z"),
"configVersion" : 1
},
{
"_id" : 3,
"name" : "192.168.80.128:27019",
"health" : 1,
"state" : 7,
"stateStr" : "ARBITER",
"uptime" : 4811,
"lastHeartbeat" : ISODate("2017-07-27T21:31:06.085Z"),
"lastHeartbeatRecv" : ISODate("2017-07-27T21:31:05.633Z"),
"pingMs" : 0,
"configVersion" : 1
}
],
"ok" : 1
}

当仲裁节点挂掉后,达不到高可用了,即当 主节点挂了后,从节点在没有仲裁节点的情况下,不会切换为 主节点了。所以,推荐使用没有仲裁节点的副本集,如下。

四、搭建没有仲裁节点的副本集,推荐使用

1.复制node1 为 node6、node7、node8:

1
2
3
复制代码cp  -r  node1   node6
cp -r node1 node7
cp -r node1 node8

2.清空node6、node7、node8的db文件夹 和 log 文件夹

1
2
复制代码rm -rf data/db/*
rm -rf log/*

3.修改node6、node7、node8 的datapath、logpath和port、replSet

node6 的port 为 27021,node7 的port 为 27022,node8 的port 为 27023,replSet 为 XbqSet

4.启动三个节点,分别进入 对应的node文件夹中:

mongod –config mongodb.conf

5.连接node6节点(27021):mongo –host 192.168.80.128 –port 27021

6.执行初始化命令:rs.initiate({“_id”:”XbqSet”,members:[{“_id”:1,”host”:”192.168.80.128:27021”},{“_id”:2,”host”:”192.168.80.128:27022”},{“_id”:3,”host”:”192.168.80.128:27023” }]})

7.查看状态,发现有一个主节点,两个从节点。

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
复制代码XbqSet:PRIMARY> rs.status()
{
"set" : "XbqSet",
"date" : ISODate("2017-07-27T22:02:28.188Z"),
"myState" : 1,
"members" : [
{
"_id" : 1,
"name" : "192.168.80.128:27021",
"health" : 1,
"state" : 1,
"stateStr" : "PRIMARY",
"uptime" : 483,
"optime" : Timestamp(1501192872, 1),
"optimeDate" : ISODate("2017-07-27T22:01:12Z"),
"electionTime" : Timestamp(1501192876, 1),
"electionDate" : ISODate("2017-07-27T22:01:16Z"),
"configVersion" : 1,
"self" : true
},
{
"_id" : 2,
"name" : "192.168.80.128:27022",
"health" : 1,
"state" : 2,
"stateStr" : "SECONDARY",
"uptime" : 75,
"optime" : Timestamp(1501192872, 1),
"optimeDate" : ISODate("2017-07-27T22:01:12Z"),
"lastHeartbeat" : ISODate("2017-07-27T22:02:26.973Z"),
"lastHeartbeatRecv" : ISODate("2017-07-27T22:02:26.973Z"),
"pingMs" : 0,
"configVersion" : 1
},
{
"_id" : 3,
"name" : "192.168.80.128:27023",
"health" : 1,
"state" : 2,
"stateStr" : "SECONDARY",
"uptime" : 75,
"optime" : Timestamp(1501192872, 1),
"optimeDate" : ISODate("2017-07-27T22:01:12Z"),
"lastHeartbeat" : ISODate("2017-07-27T22:02:26.973Z"),
"lastHeartbeatRecv" : ISODate("2017-07-27T22:02:26.973Z"),
"pingMs" : 0,
"configVersion" : 1
}
],
"ok" : 1
}

8.kill 掉node6,即端口 27021

9.然后连接到node7上:mongo –host 192.168.80.128 –port 27022,查看状态,发现node7为从节点,node8为主节点了,原来的node6 不可达、不健康的。

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
复制代码XbqSet:SECONDARY> rs.status()
{
"set" : "XbqSet",
"date" : ISODate("2017-07-27T22:08:24.894Z"),
"myState" : 2,
"members" : [
{
"_id" : 1,
"name" : "192.168.80.128:27021",
"health" : 0,
"state" : 8,
"stateStr" : "(not reachable/healthy)",
"uptime" : 0,
"optime" : Timestamp(0, 0),
"optimeDate" : ISODate("1970-01-01T00:00:00Z"),
"lastHeartbeat" : ISODate("2017-07-27T22:08:23.748Z"),
"lastHeartbeatRecv" : ISODate("2017-07-27T22:07:43.464Z"),
"pingMs" : 0,
"lastHeartbeatMessage" : "Failed attempt to connect to 192.168.80.128:27021; couldn't connect to server 192.168.80.128:27021 (192.168.80.128), connection attempt failed",
"configVersion" : -1
},
{
"_id" : 2,
"name" : "192.168.80.128:27022",
"health" : 1,
"state" : 2,
"stateStr" : "SECONDARY",
"uptime" : 833,
"optime" : Timestamp(1501192872, 1),
"optimeDate" : ISODate("2017-07-27T22:01:12Z"),
"configVersion" : 1,
"self" : true
},
{
"_id" : 3,
"name" : "192.168.80.128:27023",
"health" : 1,
"state" : 1,
"stateStr" : "PRIMARY",
"uptime" : 431,
"optime" : Timestamp(1501192872, 1),
"optimeDate" : ISODate("2017-07-27T22:01:12Z"),
"lastHeartbeat" : ISODate("2017-07-27T22:08:23.409Z"),
"lastHeartbeatRecv" : ISODate("2017-07-27T22:08:23.521Z"),
"pingMs" : 0,
"electionTime" : Timestamp(1501193266, 1),
"electionDate" : ISODate("2017-07-27T22:07:46Z"),
"configVersion" : 1
}
],
"ok" : 1
}

10.重新启动node6:mongod –config mongodb.conf

11.继续连接node7:mongo –host 192.168.80.128 –port 27022,查看状态,原来的node6 变为了 从节点。

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
复制代码XbqSet:SECONDARY> rs.status()
{
"set" : "XbqSet",
"date" : ISODate("2017-07-27T22:12:41.275Z"),
"myState" : 2,
"members" : [
{
"_id" : 1,
"name" : "192.168.80.128:27021",
"health" : 1,
"state" : 2,
"stateStr" : "SECONDARY",
"uptime" : 54,
"optime" : Timestamp(1501192872, 1),
"optimeDate" : ISODate("2017-07-27T22:01:12Z"),
"lastHeartbeat" : ISODate("2017-07-27T22:12:40.382Z"),
"lastHeartbeatRecv" : ISODate("2017-07-27T22:12:41.213Z"),
"pingMs" : 0,
"configVersion" : 1
},
{
"_id" : 2,
"name" : "192.168.80.128:27022",
"health" : 1,
"state" : 2,
"stateStr" : "SECONDARY",
"uptime" : 1090,
"optime" : Timestamp(1501192872, 1),
"optimeDate" : ISODate("2017-07-27T22:01:12Z"),
"configVersion" : 1,
"self" : true
},
{
"_id" : 3,
"name" : "192.168.80.128:27023",
"health" : 1,
"state" : 1,
"stateStr" : "PRIMARY",
"uptime" : 688,
"optime" : Timestamp(1501192872, 1),
"optimeDate" : ISODate("2017-07-27T22:01:12Z"),
"lastHeartbeat" : ISODate("2017-07-27T22:12:39.673Z"),
"lastHeartbeatRecv" : ISODate("2017-07-27T22:12:39.820Z"),
"pingMs" : 0,
"electionTime" : Timestamp(1501193266, 1),
"electionDate" : ISODate("2017-07-27T22:07:46Z"),
"configVersion" : 1
}
],
"ok" : 1
}

五、增加、删除节点

1.cp -r node8 node10,修改node10的datapath、logpath的port,并将port 改为 27024,启动 node10。

2.进入到主节点中,mongo –host 192.168.80.128 –port 27023,一定要在主节点中进行操作。

3.增加节点:rs.add(“192.168.80.128:27024”)

1
2
复制代码XbqSet:PRIMARY> rs.add("192.168.80.128:27024")
{ "ok" : 1 }

4.查看状态,rs.status(),发现新增加的节点 为从节点,即 SECONDARY。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
复制代码XbqSet:PRIMARY> rs.status()
{
"set" : "XbqSet",
"date" : ISODate("2017-07-29T14:31:44.872Z"),
"myState" : 1,
"members" : [
{
"_id" : 1,
"name" : "192.168.80.128:27021",
"health" : 1,
"state" : 2,
"stateStr" : "SECONDARY",
"uptime" : 630,
"optime" : Timestamp(1501338697, 1),
"optimeDate" : ISODate("2017-07-29T14:31:37Z"),
"lastHeartbeat" : ISODate("2017-07-29T14:31:43.555Z"),
"lastHeartbeatRecv" : ISODate("2017-07-29T14:31:43.457Z"),
"pingMs" : 0,
"configVersion" : 2
},
{
"_id" : 2,
"name" : "192.168.80.128:27022",
"health" : 1,
"state" : 2,
"stateStr" : "SECONDARY",
"uptime" : 1854,
"optime" : Timestamp(1501338697, 1),
"optimeDate" : ISODate("2017-07-29T14:31:37Z"),
"lastHeartbeat" : ISODate("2017-07-29T14:31:43.555Z"),
"lastHeartbeatRecv" : ISODate("2017-07-29T14:31:43.186Z"),
"pingMs" : 0,
"configVersion" : 2
},
{
"_id" : 3,
"name" : "192.168.80.128:27023",
"health" : 1,
"state" : 1,
"stateStr" : "PRIMARY",
"uptime" : 1953,
"optime" : Timestamp(1501338697, 1),
"optimeDate" : ISODate("2017-07-29T14:31:37Z"),
"electionTime" : Timestamp(1501338022, 1),
"electionDate" : ISODate("2017-07-29T14:20:22Z"),
"configVersion" : 2,
"self" : true
},
{
"_id" : 4,
"name" : "192.168.80.128:27024",
"health" : 1,
"state" : 2,
"stateStr" : "SECONDARY",
"uptime" : 7,
"optime" : Timestamp(1501338697, 1),
"optimeDate" : ISODate("2017-07-29T14:31:37Z"),
"lastHeartbeat" : ISODate("2017-07-29T14:31:43.566Z"),
"lastHeartbeatRecv" : ISODate("2017-07-29T14:31:43.579Z"),
"pingMs" : 6,
"configVersion" : 2
}
],
"ok" : 1
}

5.删除节点:rs.remove(hostportstr)

1
2
复制代码XbqSet:PRIMARY> rs.remove("192.168.80.128:27024")
{ "ok" : 1 }

查看状态,发现 端口为27024的节点 没有了。

欢迎关注我的公众号,第一时间接收最新文章~ 搜索公众号: 码咖 或者 扫描下方二维码:
img

本文转载自: 掘金

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

Java 爬虫遇到需要登录的网站,该怎么办?

发表于 2019-10-09

这是 Java 网络爬虫系列博文的第二篇,在上一篇 Java 网络爬虫,就是这么的简单 中,我们简单的学习了一下如何利用 Java 进行网络爬虫。在这一篇中我们将简单的聊一聊在网络爬虫时,遇到需要登录的网站,我们该怎么办?

在做爬虫时,遇到需要登陆的问题也比较常见,比如写脚本抢票之类的,但凡需要个人信息的都需要登陆,对于这类问题主要有两种解决方式:一种方式是手动设置 cookie ,就是先在网站上面登录,复制登陆后的 cookies ,在爬虫程序中手动设置 HTTP 请求中的 Cookie 属性,这种方式适用于采集频次不高、采集周期短,因为 cookie 会失效,如果长期采集的话就需要频繁设置 cookie,这不是一种可行的办法,第二种方式就是使用程序模拟登陆,通过模拟登陆获取到 cookies,这种方式适用于长期采集该网站,因为每次采集都会先登陆,这样就不需要担心 cookie 过期的问题。

为了能让大家更好的理解这两种方式的运用,我以获取豆瓣个人主页昵称为例,分别用这两种方式来获取需要登陆后才能看到的信息。获取信息如下图所示:

获取图片中的缺心眼那叫单纯,这个信息显然是需要登陆后才能看到的,这就符合我们的主题啦。接下来分别用上面两种办法来解决这个问题。

手动设置 cookie

手动设置 cookie 的方式,这种方式比较简单,我们只需要在豆瓣网上登陆,登陆成功后就可以获取到带有用户信息的cookie,豆瓣网登录链接:https://accounts.douban.com/passport/login。如下图所示:

图中的这个 cookie 就携带了用户信息,我们只需要在请求时携带这个 cookie 就可以查看到需要登陆后才能查看到的信息。我们用 Jsoup 来模拟一下手动设置 cookie 方式,具体代码如下:

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
复制代码/**
* 手动设置 cookies
* 先从网站上登录,然后查看 request headers 里面的 cookies
* @param url
* @throws IOException
*/
public void setCookies(String url) throws IOException {

Document document = Jsoup.connect(url)
// 手动设置cookies
.header("Cookie", "your cookies")
.get();
//
if (document != null) {
// 获取豆瓣昵称节点
Element element = document.select(".info h1").first();
if (element == null) {
System.out.println("没有找到 .info h1 标签");
return;
}
// 取出豆瓣节点昵称
String userName = element.ownText();
System.out.println("豆瓣我的网名为:" + userName);
} else {
System.out.println("出错啦!!!!!");
}
}

从代码中可以看出跟不需要登陆的网站没什么区别,只是多了一个.header("Cookie", "your cookies"),我们把浏览器中的 cookie 复制到这里即可,编写 main 方法

1
2
3
4
复制代码public static void main(String[] args) throws Exception {
// 个人中心url
String user_info_url = "https://www.douban.com/people/150968577/";
new CrawleLogin().setCookies(user_info_url);

运行 main 得到结果如下:

可以看出我们成功获取到了缺心眼那叫单纯,这说明我们设置的 cookie 是有效的,成功的拿到了需要登陆的数据。这种方式是真的比较简单,唯一的不足就是需要频繁的更换 cookie,因为 cookie 会失效,这让你使用起来就不是很爽啦。

模拟登陆方式

模拟登陆的方式可以解决手动设置 cookie 方式的不足之处,但同时也引入了比较复杂的问题,现在的验证码形形色色、五花八门,很多都富有挑战性,比如在一堆图片中操作某类图片,这个还是非常有难度,不是随便就能够编写出来。所以对于使用哪种方式这个就需要开发者自己去衡量利弊啦。今天我们用到的豆瓣网,在登陆的时候就没有验证码,对于这种没有验证码的还是比较简单的,关于模拟登陆方式最重要的就是找到真正的登陆请求、登陆需要的参数。 这个我们就只能取巧了,我们先在登陆界面输入错误的账号密码,这样页面将不会跳转,所以我们就能够轻而易举的找到登陆请求。我来演示一下豆瓣网登陆查找登陆链接,我们在登陆界面输入错误的用户名和密码,点击登陆后,在 network 查看发起的请求链接,如下图所示:

从 network 中我们可以查看到豆瓣网的登陆链接为https://accounts.douban.com/j/mobile/login/basic,需要的参数有五个,具体参数查看图中的 Form Data,有了这些之后,我们就能够构造请求模拟登陆啦。登陆后进行后续操作,接下来我们就用 Jsoup 来模拟登陆到获取豆瓣主页昵称,具体代码如下:

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
复制代码/**
* Jsoup 模拟登录豆瓣 访问个人中心
* 在豆瓣登录时先输入一个错误的账号密码,查看到登录所需要的参数
* 先构造登录请求参数,成功后获取到cookies
* 设置request cookies,再次请求
* @param loginUrl 登录url
* @param userInfoUrl 个人中心url
* @throws IOException
*/
public void jsoupLogin(String loginUrl,String userInfoUrl) throws IOException {

// 构造登陆参数
Map<String,String> data = new HashMap<>();
data.put("name","your_account");
data.put("password","your_password");
data.put("remember","false");
data.put("ticket","");
data.put("ck","");
Connection.Response login = Jsoup.connect(loginUrl)
.ignoreContentType(true) // 忽略类型验证
.followRedirects(false) // 禁止重定向
.postDataCharset("utf-8")
.header("Upgrade-Insecure-Requests","1")
.header("Accept","application/json")
.header("Content-Type","application/x-www-form-urlencoded")
.header("X-Requested-With","XMLHttpRequest")
.header("User-Agent","Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36")
.data(data)
.method(Connection.Method.POST)
.execute();
login.charset("UTF-8");
// login 中已经获取到登录成功之后的cookies
// 构造访问个人中心的请求
Document document = Jsoup.connect(userInfoUrl)
// 取出login对象里面的cookies
.cookies(login.cookies())
.get();
if (document != null) {
Element element = document.select(".info h1").first();
if (element == null) {
System.out.println("没有找到 .info h1 标签");
return;
}
String userName = element.ownText();
System.out.println("豆瓣我的网名为:" + userName);
} else {
System.out.println("出错啦!!!!!");
}
}

这段代码分两段,前一段是模拟登陆,后一段是解析豆瓣主页,在这段代码中发起了两次请求,第一次请求是模拟登陆获取到 cookie,第二次请求时携带第一次模拟登陆后获取的cookie,这样也可以访问需要登陆的页面,修改 main 方法

1
2
3
4
5
6
7
8
9
10
复制代码public static void main(String[] args) throws Exception {
// 个人中心url
String user_info_url = "https://www.douban.com/people/150968577/";

// 登陆接口
String login_url = "https://accounts.douban.com/j/mobile/login/basic";

// new CrawleLogin().setCookies(user_info_url);
new CrawleLogin().jsoupLogin(login_url,user_info_url);
}

运行 main 方法,得到如下结果:

模拟登陆的方式也成功的获取到了网名缺心眼那叫单纯,虽然这已经是最简单的模拟登陆啦,从代码量上就可以看出它比设置 cookie 要复杂很多,对于其他有验证码的登陆,我就不在这里介绍了,第一是我在这方面也没什么经验,第二是这个实现起来比较复杂,会涉及到一些算法和一些辅助工具的使用,有兴趣的朋友可以参考崔庆才老师的博客研究研究。模拟登陆写起来虽然比较复杂,但是只要你编写好之后,你就能够一劳永逸,如果你需要长期采集需要登陆的信息,这个还是值得你的做的。
除了使用 jsoup 模拟登陆外,我们还可以使用 httpclient 模拟登陆,httpclient 模拟登陆没有 Jsoup 那么复杂,因为 httpclient 能够像浏览器一样保存 session 会话,这样登陆之后就保存下了 cookie ,在同一个 httpclient 内请求就会带上 cookie 啦。httpclient 模拟登陆代码如下:

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
复制代码/**
* httpclient 的方式模拟登录豆瓣
* httpclient 跟jsoup差不多,不同的地方在于 httpclient 有session的概念
* 在同一个httpclient 内不需要设置cookies ,会默认缓存下来
* @param loginUrl
* @param userInfoUrl
*/
public void httpClientLogin(String loginUrl,String userInfoUrl) throws Exception{

CloseableHttpClient httpclient = HttpClients.createDefault();
HttpUriRequest login = RequestBuilder.post()
.setUri(new URI(loginUrl))// 登陆url
.setHeader("Upgrade-Insecure-Requests","1")
.setHeader("Accept","application/json")
.setHeader("Content-Type","application/x-www-form-urlencoded")
.setHeader("X-Requested-With","XMLHttpRequest")
.setHeader("User-Agent","Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36")
// 设置账号信息
.addParameter("name","your_account")
.addParameter("password","your_password")
.addParameter("remember","false")
.addParameter("ticket","")
.addParameter("ck","")
.build();
// 模拟登陆
CloseableHttpResponse response = httpclient.execute(login);
if (response.getStatusLine().getStatusCode() == 200){
// 构造访问个人中心请求
HttpGet httpGet = new HttpGet(userInfoUrl);
CloseableHttpResponse user_response = httpclient.execute(httpGet);
HttpEntity entity = user_response.getEntity();
//
String body = EntityUtils.toString(entity, "utf-8");

// 偷个懒,直接判断 缺心眼那叫单纯 是否存在字符串中
System.out.println("缺心眼那叫单纯是否查找到?"+(body.contains("缺心眼那叫单纯")));
}else {
System.out.println("httpclient 模拟登录豆瓣失败了!!!!");
}
}

运行这段代码,返回的结果也是 true。

有关 Java 爬虫遇到登陆问题就聊得差不多啦,来总结一下:对于爬虫遇到登陆问题有两种解决办法,一种是手动设置cookie,这种方式适用于短暂性采集或者一次性采集,成本较低。另一种方式是模拟登陆的方式,这种方式适用于长期采集的网站,因为模拟登陆的代价还是蛮高的,特别是一些变态的验证码,好处就是能够让你一劳永逸

以上就是 Java 爬虫时遇到登陆问题相关知识分享,希望对你有所帮助,下一篇是关于爬虫是遇到数据异步加载的问题。如果你对爬虫感兴趣,不妨关注一波,相互学习,相互进步

源代码:源代码

文章不足之处,望大家多多指点,共同学习,共同进步

最后

打个小广告,欢迎扫码关注微信公众号:「平头哥的技术博文」,一起进步吧。

平头哥的技术博文

本文转载自: 掘金

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

你需要具备这些条件才能更好的学习Spring Securit

发表于 2019-10-08

前言

web应用达到生产需要就必须有安全控制。java web领域经常提及的两大开源框架主要有两种选择Spring Security和Apache Shiro 。所以学习这两种框架也是java开发者提高水平的必经之路。从今天开始连续一段时间内,研究一下Spring Security。如果想学习的同学可以关注一下公众号:Felordcn 或者通过https://felord.cn来及时获取相关的干货。

Spring Security 和Apache Shiro

相对于Apache Shiro,Spring Security提供了更多的诸如LDAP、OAuth2.0、ACL、Kerberos、SAML、SSO、OpenID等诸多的安全认证、鉴权协议,可以按需引用。对认证/鉴权更加灵活,粒度更细。可以结合你自己的业务场景进行更加合理的定制化开发。在最新的Spring Security 5.x中更是提供了响应式应用(reactive application)提供了安全控制支持。从语言上来讲,支持使用kotlin、groovy进行开发。

Spring Security因为是利用了Spring IOC 和AOP的特性而无法脱离Spring独立存在。而Apache Shiro可以独立存在。但是Java Web领域Spring可以说是事实上的J2EE规范。使用Java技术栈很少能脱离Spring。也因为功能强大Spring Security被认为非常重,这是不对的。认真学习之后会发现其实也就是那么回事。两种框架都是非常优秀的安全框架,根据实际需要做技术选型。如果你要学习这两种安全框架就必须熟悉一下一些相对专业的概念。

认证/鉴权

这两个概念英文分别为authentication/authorization 。是不是特别容易混淆。无论你选择Apache Shiro 或者 Spring Security 都需要熟悉这两个概念。其实简单来说认证(authentication)就是为了证明你是谁,比如你输入账号密码证明你是用户名为Felordcn的用户。而授权(authorization)是通过认证后的用户所绑定的角色等凭证来证明你可以做什么 。打一个现实中的例子。十一长假大家远行都要乘坐交通工具,现在坐车实名制,也就是说你坐车需要两件东西:身份证和车票 。身份证是为了证明你确实是你,这就是 authentication;而车票是为了证明你张三确实买了票可以上车,这就是 authorization。这个例子从另一方面也证明了。如果只有认证没有授权,认证就没有意义。如果没有认证,授权就无法赋予真正的可信任的用户。两者是同时存在的。

过滤器链

对于servlet web应用来说,想要通用的安全控制最好莫过于使用Servlet Filter 。 过滤器责任链(关于责任链可以通过https://www.felord.cn/chainpattern.html 来了解)来组成一系列的过滤策略,不同的条件的请求进入不同的过滤器进行各自的处理逻辑。我们可以对这些Filter 进行排列组合以满足我们的实际业务需要。

RBAC模型

RBAC 是基于角色的访问控制(Role-Based Access Control )的简称。在 RBAC 中,权限与角色相关联,用户通过成为适当角色的成员而得到这些角色的权限。这就极大地简化了权限的管理。这样管理都是层级相互依赖的,权限赋予给角色,而把角色又赋予用户,这样的权限设计很清楚,管理起来很方便。当你拥有某个角色以后,你自然继承了该角色的所有功能。对你的一些操作限制不需要直接与你进行沟通,只需要操作你拥有的角色。比如你在公司既是一个java程序员又是一个前端程序员,那么你不但要当sqlboy还要当页面仔。如果有一天经理说了前端负责测试工作,好了你又承担了测试任务。

其他一些概念

比如其它一些常见的安全策略、攻击方式。比如 反向代理、网关、壁垒机这种偏运维的知识;CSRF(Cross-site request forgery)跨站请求伪造 、XSS(跨站脚本攻击)也需要了解一些。对于一些上面提到的什么OAuth2.0之类的协议也最好研究一下。当然这些不是必须的。

总结

本文粗略的简述了Spring Security 和Apache Shiro的一些异同。以及学习它们的一些前置条件。如果你不满足这些条件学习起来可能比较吃力。所以本文的作用是为你学习预热,做一些准备工作,避免新入门的同学陷入迷途。也希望大家多多支持,多多关注。

关注公众号:Felordcn,获取更多资讯

本文转载自: 掘金

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

面试官,不要再问我三次握手和四次挥手

发表于 2019-10-08

该文章长期维护版:yuanrengu.com/2020/77eef7…

三次握手和四次挥手是各个公司常见的考点,也具有一定的水平区分度,也被一些面试官作为热身题。很多小伙伴说这个问题刚开始回答的挺好,但是后面越回答越冒冷汗,最后就歇菜了。

见过比较典型的面试场景是这样的:

面试官:请介绍下三次握手

求职者:第一次握手就是客户端给服务器端发送一个报文,第二次就是服务器收到报文之后,会应答一个报文给客户端,第三次握手就是客户端收到报文后再给服务器发送一个报文,三次握手就成功了。

面试官:然后呢?

求职者:这就是三次握手的过程,很简单的。

面试官:。。。。。。

(番外篇:一首凉凉送给你)

记住猿人谷一句话:面试时越简单的问题,一般就是隐藏着比较大的坑,一般都是需要将问题扩展的。上面求职者的回答不对吗?当然对,但距离面试官的期望可能还有点距离。

希望大家能带着如下问题进行阅读,收获会更大。

  1. 请画出三次握手和四次挥手的示意图
  2. 为什么连接的时候是三次握手?
  3. 什么是半连接队列?
  4. ISN(Initial Sequence Number)是固定的吗?
  5. 三次握手过程中可以携带数据吗?
  6. 如果第三次握手丢失了,客户端服务端会如何处理?
  7. SYN攻击是什么?
  8. 挥手为什么需要四次?
  9. 四次挥手释放连接时,等待2MSL的意义?

三次握手和四次挥手.png

  1. 三次握手

三次握手(Three-way Handshake)其实就是指建立一个TCP连接时,需要客户端和服务器总共发送3个包。进行三次握手的主要作用就是为了确认双方的接收能力和发送能力是否正常、指定自己的初始化序列号为后面的可靠性传送做准备。实质上其实就是连接服务器指定端口,建立TCP连接,并同步连接双方的序列号和确认号,交换TCP窗口大小信息。

刚开始客户端处于 Closed 的状态,服务端处于 Listen 状态。
进行三次握手:

  • 第一次握手:客户端给服务端发一个 SYN 报文,并指明客户端的初始化序列号 ISN(c)。此时客户端处于 SYN_SEND 状态。

首部的同步位SYN=1,初始序号seq=x,SYN=1的报文段不能携带数据,但要消耗掉一个序号。

  • 第二次握手:服务器收到客户端的 SYN 报文之后,会以自己的 SYN 报文作为应答,并且也是指定了自己的初始化序列号 ISN(s)。同时会把客户端的 ISN + 1 作为ACK 的值,表示自己已经收到了客户端的 SYN,此时服务器处于 SYN_RCVD 的状态。

在确认报文段中SYN=1,ACK=1,确认号ack=x+1,初始序号seq=y。

  • 第三次握手:客户端收到 SYN 报文之后,会发送一个 ACK 报文,当然,也是一样把服务器的 ISN + 1 作为 ACK 的值,表示已经收到了服务端的 SYN 报文,此时客户端处于 ESTABLISHED 状态。服务器收到 ACK 报文之后,也处于 ESTABLISHED 状态,此时,双方已建立起了连接。

确认报文段ACK=1,确认号ack=y+1,序号seq=x+1(初始为seq=x,第二个报文段所以要+1),ACK报文段可以携带数据,不携带数据则不消耗序号。

发送第一个SYN的一端将执行主动打开(active open),接收这个SYN并发回下一个SYN的另一端执行被动打开(passive open)。

在socket编程中,客户端执行connect()时,将触发三次握手。

三次握手.png

1.1 为什么需要三次握手,两次不行吗?

弄清这个问题,我们需要先弄明白三次握手的目的是什么,能不能只用两次握手来达到同样的目的。

  • 第一次握手:客户端发送网络包,服务端收到了。
    这样服务端就能得出结论:客户端的发送能力、服务端的接收能力是正常的。
  • 第二次握手:服务端发包,客户端收到了。
    这样客户端就能得出结论:服务端的接收、发送能力,客户端的接收、发送能力是正常的。不过此时服务器并不能确认客户端的接收能力是否正常。
  • 第三次握手:客户端发包,服务端收到了。
    这样服务端就能得出结论:客户端的接收、发送能力正常,服务器自己的发送、接收能力也正常。

因此,需要三次握手才能确认双方的接收与发送能力是否正常。

试想如果是用两次握手,则会出现下面这种情况:

如客户端发出连接请求,但因连接请求报文丢失而未收到确认,于是客户端再重传一次连接请求。后来收到了确认,建立了连接。数据传输完毕后,就释放了连接,客户端共发出了两个连接请求报文段,其中第一个丢失,第二个到达了服务端,但是第一个丢失的报文段只是在某些网络结点长时间滞留了,延误到连接释放以后的某个时间才到达服务端,此时服务端误认为客户端又发出一次新的连接请求,于是就向客户端发出确认报文段,同意建立连接,不采用三次握手,只要服务端发出确认,就建立新的连接了,此时客户端忽略服务端发来的确认,也不发送数据,则服务端一致等待客户端发送数据,浪费资源。

1.2 什么是半连接队列?

服务器第一次收到客户端的 SYN 之后,就会处于 SYN_RCVD 状态,此时双方还没有完全建立其连接,服务器会把此种状态下请求连接放在一个队列里,我们把这种队列称之为半连接队列。

当然还有一个全连接队列,就是已经完成三次握手,建立起连接的就会放在全连接队列中。如果队列满了就有可能会出现丢包现象。

这里在补充一点关于SYN-ACK 重传次数的问题:
服务器发送完SYN-ACK包,如果未收到客户确认包,服务器进行首次重传,等待一段时间仍未收到客户确认包,进行第二次重传。如果重传次数超过系统规定的最大重传次数,系统将该连接信息从半连接队列中删除。
注意,每次重传等待的时间不一定相同,一般会是指数增长,例如间隔时间为 1s,2s,4s,8s……

1.3 ISN(Initial Sequence Number)是固定的吗?

当一端为建立连接而发送它的SYN时,它为连接选择一个初始序号。ISN随时间而变化,因此每个连接都将具有不同的ISN。ISN可以看作是一个32比特的计数器,每4ms加1 。这样选择序号的目的在于防止在网络中被延迟的分组在以后又被传送,而导致某个连接的一方对它做错误的解释。

三次握手的其中一个重要功能是客户端和服务端交换 ISN(Initial Sequence Number),以便让对方知道接下来接收数据的时候如何按序列号组装数据。如果 ISN 是固定的,攻击者很容易猜出后续的确认号,因此 ISN 是动态生成的。

1.4 三次握手过程中可以携带数据吗?

其实第三次握手的时候,是可以携带数据的。但是,第一次、第二次握手不可以携带数据

为什么这样呢?大家可以想一个问题,假如第一次握手可以携带数据的话,如果有人要恶意攻击服务器,那他每次都在第一次握手中的 SYN 报文中放入大量的数据。因为攻击者根本就不理服务器的接收、发送能力是否正常,然后疯狂着重复发 SYN 报文的话,这会让服务器花费很多时间、内存空间来接收这些报文。

也就是说,第一次握手不可以放数据,其中一个简单的原因就是会让服务器更加容易受到攻击了。而对于第三次的话,此时客户端已经处于 ESTABLISHED 状态。对于客户端来说,他已经建立起连接了,并且也已经知道服务器的接收、发送能力是正常的了,所以能携带数据也没啥毛病。

1.5 SYN攻击是什么?

服务器端的资源分配是在二次握手时分配的,而客户端的资源是在完成三次握手时分配的,所以服务器容易受到SYN洪泛攻击。SYN攻击就是Client在短时间内伪造大量不存在的IP地址,并向Server不断地发送SYN包,Server则回复确认包,并等待Client确认,由于源地址不存在,因此Server需要不断重发直至超时,这些伪造的SYN包将长时间占用未连接队列,导致正常的SYN请求因为队列满而被丢弃,从而引起网络拥塞甚至系统瘫痪。SYN 攻击是一种典型的 DoS/DDoS 攻击。

检测 SYN 攻击非常的方便,当你在服务器上看到大量的半连接状态时,特别是源IP地址是随机的,基本上可以断定这是一次SYN攻击。在 Linux/Unix 上可以使用系统自带的 netstats 命令来检测 SYN 攻击。

1
复制代码netstat -n -p TCP | grep SYN_RECV

常见的防御 SYN 攻击的方法有如下几种:

  • 缩短超时(SYN Timeout)时间
  • 增加最大半连接数
  • 过滤网关防护
  • SYN cookies技术
  1. 四次挥手

建立一个连接需要三次握手,而终止一个连接要经过四次挥手(也有将四次挥手叫做四次握手的)。这由TCP的半关闭(half-close)造成的。所谓的半关闭,其实就是TCP提供了连接的一端在结束它的发送后还能接收来自另一端数据的能力。

TCP 的连接的拆除需要发送四个包,因此称为四次挥手(Four-way handshake),客户端或服务器均可主动发起挥手动作。

刚开始双方都处于 ESTABLISHED 状态,假如是客户端先发起关闭请求。四次挥手的过程如下:

  • 第一次挥手:客户端发送一个 FIN 报文,报文中会指定一个序列号。此时客户端处于 FIN_WAIT1 状态。
    即发出连接释放报文段(FIN=1,序号seq=u),并停止再发送数据,主动关闭TCP连接,进入FIN_WAIT1(终止等待1)状态,等待服务端的确认。
  • 第二次挥手:服务端收到 FIN 之后,会发送 ACK 报文,且把客户端的序列号值 +1 作为 ACK 报文的序列号值,表明已经收到客户端的报文了,此时服务端处于 CLOSE_WAIT 状态。
    即服务端收到连接释放报文段后即发出确认报文段(ACK=1,确认号ack=u+1,序号seq=v),服务端进入CLOSE_WAIT(关闭等待)状态,此时的TCP处于半关闭状态,客户端到服务端的连接释放。客户端收到服务端的确认后,进入FIN_WAIT2(终止等待2)状态,等待服务端发出的连接释放报文段。
  • 第三次挥手:如果服务端也想断开连接了,和客户端的第一次挥手一样,发给 FIN 报文,且指定一个序列号。此时服务端处于 LAST_ACK 的状态。
    即服务端没有要向客户端发出的数据,服务端发出连接释放报文段(FIN=1,ACK=1,序号seq=w,确认号ack=u+1),服务端进入LAST_ACK(最后确认)状态,等待客户端的确认。
  • 第四次挥手:客户端收到 FIN 之后,一样发送一个 ACK 报文作为应答,且把服务端的序列号值 +1 作为自己 ACK 报文的序列号值,此时客户端处于 TIME_WAIT 状态。需要过一阵子以确保服务端收到自己的 ACK 报文之后才会进入 CLOSED 状态,服务端收到 ACK 报文之后,就处于关闭连接了,处于 CLOSED 状态。
    即客户端收到服务端的连接释放报文段后,对此发出确认报文段(ACK=1,seq=u+1,ack=w+1),客户端进入TIME_WAIT(时间等待)状态。此时TCP未释放掉,需要经过时间等待计时器设置的时间2MSL后,客户端才进入CLOSED状态。

收到一个FIN只意味着在这一方向上没有数据流动。客户端执行主动关闭并进入TIME_WAIT是正常的,服务端通常执行被动关闭,不会进入TIME_WAIT状态。

在socket编程中,任何一方执行close()操作即可产生挥手操作。

image.png

2.1 挥手为什么需要四次?

因为当服务端收到客户端的SYN连接请求报文后,可以直接发送SYN+ACK报文。其中ACK报文是用来应答的,SYN报文是用来同步的。但是关闭连接时,当服务端收到FIN报文时,很可能并不会立即关闭SOCKET,所以只能先回复一个ACK报文,告诉客户端,”你发的FIN报文我收到了”。只有等到我服务端所有的报文都发送完了,我才能发送FIN报文,因此不能一起发送。故需要四次挥手。

2.2 2MSL等待状态

TIME_WAIT状态也成为2MSL等待状态。每个具体TCP实现必须选择一个报文段最大生存时间MSL(Maximum Segment Lifetime),它是任何报文段被丢弃前在网络内的最长时间。这个时间是有限的,因为TCP报文段以IP数据报在网络内传输,而IP数据报则有限制其生存时间的TTL字段。

对一个具体实现所给定的MSL值,处理的原则是:当TCP执行一个主动关闭,并发回最后一个ACK,该连接必须在TIME_WAIT状态停留的时间为2倍的MSL。这样可让TCP再次发送最后的ACK以防这个ACK丢失(另一端超时并重发最后的FIN)。

这种2MSL等待的另一个结果是这个TCP连接在2MSL等待期间,定义这个连接的插口(客户的IP地址和端口号,服务器的IP地址和端口号)不能再被使用。这个连接只能在2MSL结束后才能再被使用。

2.3 四次挥手释放连接时,等待2MSL的意义?

MSL是Maximum Segment Lifetime的英文缩写,可译为“最长报文段寿命”,它是任何报文在网络上存在的最长时间,超过这个时间报文将被丢弃。

为了保证客户端发送的最后一个ACK报文段能够到达服务器。因为这个ACK有可能丢失,从而导致处在LAST-ACK状态的服务器收不到对FIN-ACK的确认报文。服务器会超时重传这个FIN-ACK,接着客户端再重传一次确认,重新启动时间等待计时器。最后客户端和服务器都能正常的关闭。假设客户端不等待2MSL,而是在发送完ACK之后直接释放关闭,一但这个ACK丢失的话,服务器就无法正常的进入关闭连接状态。

两个理由:

  1. 保证客户端发送的最后一个ACK报文段能够到达服务端。
    这个ACK报文段有可能丢失,使得处于LAST-ACK状态的B收不到对已发送的FIN+ACK报文段的确认,服务端超时重传FIN+ACK报文段,而客户端能在2MSL时间内收到这个重传的FIN+ACK报文段,接着客户端重传一次确认,重新启动2MSL计时器,最后客户端和服务端都进入到CLOSED状态,若客户端在TIME-WAIT状态不等待一段时间,而是发送完ACK报文段后立即释放连接,则无法收到服务端重传的FIN+ACK报文段,所以不会再发送一次确认报文段,则服务端无法正常进入到CLOSED状态。
  2. 防止“已失效的连接请求报文段”出现在本连接中。
    客户端在发送完最后一个ACK报文段后,再经过2MSL,就可以使本连接持续的时间内所产生的所有报文段都从网络中消失,使下一个新的连接中不会出现这种旧的连接请求报文段。

2.4 为什么TIME_WAIT状态需要经过2MSL才能返回到CLOSE状态?

理论上,四个报文都发送完毕,就可以直接进入CLOSE状态了,但是可能网络是不可靠的,有可能最后一个ACK丢失。所以TIME_WAIT状态就是用来重发可能丢失的ACK报文。

  1. 总结

《TCP/IP详解 卷1:协议》有一张TCP状态变迁图,很具有代表性,有助于大家理解三次握手和四次挥手的状态变化。如下图所示,粗的实线箭头表示正常的客户端状态变迁,粗的虚线箭头表示正常的服务器状态变迁。

TCP状态变迁图.jpg

以后面试官再问你三次握手和四次挥手,直接把这一篇文章丢给他就可以了,他想问的都在这里。

参考:《TCP/IP详解 卷1:协议》

本文转载自: 掘金

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

1…854855856…956

开发者博客

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