1.通信协议
1.1 HTTP/HTTPS
HTTP是超文本传输协议,用于web服务器传输超文本到本地浏览器的协议
我们现在使用的普遍为HTTP1.1
- HTTP是应用层协议
- HTTP是无状态的协议(无状态:指一次操作,不会存储数据。有状态:能够保存数据)
- HTTPS是HTTP的安全版(在HTTP上增加了SSL或者TLS协议层)
- HTTP端口号一般为80
- HTTPS端口号一般为443
1.1.1 HTTPS工作原理及流程
- 客户端浏览器向服务器发送SSL/TLS协议的版本号、加密算法的种类、产生的随机数、以及其他所需的信息(第一次握手)
- 服务器从客户端支持的加密算法中选择一组加密算法与Hash算法,并把自己的证书(包含网址、加密公钥、证书颁发机构)也发给客户端(第二次握手)
- 浏览器获取服务器证书后验证证书中的网址是否与正在访问的地址一致,通过浏览器会显示一个小锁头,否则,提示证书不受信
- 客户端浏览器生成一串随机数并用服务器传来的公钥进行加密,再使用约定的hash算法计算握手消息,发送到服务器(第三次握手)
- 服务器接收到消息后用自己的私钥进行解密,并用散列算法验证。这样双方都有了此次通信的密钥
- 服务器再使用密钥加密一段握手消息,返回给浏览器。(第四次握手)
- 浏览器用密钥解密,并用散列算法验证,确定算法和密钥
1.1.2 HTTP请求/响应模型
- 客户端向服务器发送一个请求(请求头中包含请求方法、uri、协议版本、请求修饰符、客户信息等)
- 服务器以一个状态行作为响应(包含消息协议版本、成功/失败编码、服务器信息、实体元信息以及一些实体内容)
1.1.3 HTTP请求/响应流程
- 三次握手建立连接
- 客户端发送请求到服务器
- 服务器端接收到请求报文后对报文进行解析,组装成一定格式的响应报文,返回给客户端
- 客户端浏览器接收到响应报文后通过浏览器内核对其进行解析,按照一定外观显示
1.1.4 HTTP请求报文
HTTP请求报文由三个部分组成:
- 请求行
- 请求方法字段
- URL字段
- HTTP协议版本字段
- 请求头
- 请求体(一般在post方法使用)
请求方法分为以下几种
- GET
- POST
- DELETE
- HEAD
- OPTIONS
- PUT
- TRACE
常见的就是post和get方式
请求头部常见的典型属性
- User-Agent:客户端请求的浏览器类型
- Accept:浏览器可以接受识别的媒体类型
- Host:供客户端访问的那台主机的主机名和端口号
- Cookie:用于传输客户端的cookie到服务器
- Referer:这个请求从那个URL过来的
- CacheControl:通过对这个属性可以对缓存进行控制
1.1.5 HTTP响应报文
HTTP请求报文由三个部分组成:
- 响应行
- 状态码
- 描述
- HTTP协议版本字段
- 响应头
- Cache-Control:服务器通过该属性告诉客户端如何对响应内容进行缓存。比如值为max-age=600。表示对响应内容缓存600秒。这个过程中,客户端再次访问同一个资源则直接从客户端缓存中取出,不需要向服务器获取
- Location:用于网页重定向
- Set-Cookie:利用这个属性服务器可对客户端的Cookie进行设置
- 响应体
2 套接字通信
套接字通信是应用层与TCP/IP协议族通信的中间抽象层,它是一组接口。应用层通过调用这些接口发送和接收数据(一般由操作系统提供或JVM自己实现)。使用套接字通信可以简单地实现应用程序在网络上的通信
一台机器上的应用向套接字中写入信息,建立连接的另一台机器就能够读取到
套接字分为两种类型:
- 流套接字 —— TCP协议
- 数据报套接字 —— UDP协议
一个TCP/IP套接字由一个互联网地址、一个协议及一个端口号唯一确定
套接字抽象层封装了对报文协议解析的复杂处理过程,仅提供简单的接口供用户使用
JAVA提供了一些接口来进行socket的使用
2.1 单播通信
单个网络节点与单个网络节点之间的通信就称为单播通信
服务端代码示例
//服务端
Public class SocketServer {
public static void main(String[] args) {
ServerSocket serverSocket = null;
try {
serverSocket = new ServerSocket(8888);
Socket socket = serverSocket.accept();
DataOutputStream dos = new DataOutputStream(socket
.getOutputStream());
DataInputStream dis = new DataInputStream(socket.getInputStream());
System.out.println("服务器接收到客户端的连接请求:" + dis.readUTF());
dos.writeUTF("接受连接请求,连接成功!");
socket.close();
serverSocket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
首先,绑定本地8888端口然后调用accept()方法进行阻塞,等待客户端的连接,一旦有连接到来就创建一个套接字并返回。接着,获取输入/输出流,输入流用于获取客户端传输的数据,而输出流则用来向客户端响应发送数据,处理完后关闭套接字。为了简化代码,这里完成一次响应后便把ServerSocket关闭。
客户端代码示例
public class SocketClient {
public static void main(String[] args) {
Socket socket = null;
try {
socket = new Socket("localhost",8888);
DataOutputStream dos = new DataOutputStream(socket
.getOutputStream());
DataInputStream dis = new DataInputStream(socket.getInputStream()
);
dos.writeUTF("我是客户端,请求连接!");
System.out.println(dis.readUTF());
socket.close();
} catch (UnknownHostException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
}
服务器端的8888端口已经处于监听状态,客户端如果要与之通信,只须简单地先指定服务器端IP与端口号以实例化一个套接字,然后获取套接字的输出流与输入流。输出流用于向服务器发送数据,输入流用于读取服务器发送过来的数据。交互处理完后关闭套接字。
2.2 组播通信
组播通信是为了优化单播通信某些场景下的不足。例如,一份数据要从某台主机发送到其余若干台主机上,这时如果还是使用单播通信模式,数据必须依次发送给其他若干台主机。单播通信的一个特点就是有多少台主机就要发送多少次,当主机的数量越来越大时可能会导致网络阻塞。此外,这种传送方式效率极低。于是引入了组播通信的概念。
下面是JDK的组播代码示例
节点1,指定组播地址为228.0.0.4,端口号为8000。节点1通过调用MulticastSocket的JoinGroup 方法申请将节点1加入到组播队伍中,接着使用一个无限循环往组里发“Hello from node1”消息,这是为了方便节点2加入后接收节点1的消息。需要说明的是,组播通信是通过DatagramPacket对象发送消息的,调用MulticastSocket的Send方法即可把消息发送出去。为了缩减例子长度,这里省去了退出组及关闭套接字的一些操作
public class Node1{
private static int port = 8000;
private static String address = "228.0.0.4";
public static void main(String[] args) throws Exception {
try {
InetAddress group = InetAddress.getByName(address);
MulticastSocket mss = null;
mss = new MulticastSocket(port);
mss.joinGroup(group);
while (true) {
String message = "Hello from node1";
byte[] buffer = message.getBytes();
DatagramPacket dp = new DatagramPacket(buffer, buffer.length,
group, port);
mss.send(dp);
Thread.sleep(1000);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
节点2,指定同样的组播地址与端口,以申请加入与节点1相同的组播组。接着通过循环不断接收从其他节点发送的消息,通过MulticastSocket的Receive方法可读取消息,将不断接收到从节点1发送的消息“receive from node1:Hello from node1”。当然,节点2也可以向组播组发送消息,因为每个节点都是等同的,只要其他节点对组播消息进行接收。如果你还想增加其他节点,尽管申请加入组播组,所有节点都可以接收、发送消息。
public class Node2 {
private static int port = 8000;
private static String address = "228.0.0.4";
public static void main(String[] args) throws Exception {
InetAddress group = InetAddress.getByName(address);
MulticastSocket msr = null;
try {
msr = new MulticastSocket(port);
msr.joinGroup(group);
byte[] buffer = new byte[1024];
while (true) {
DatagramPacket dp = new DatagramPacket(buffer, buffer.length);
msr.receive(dp);
String s = new String(dp.getData(), 0, dp.getLength());
System.out.println("receive from node1:"+s);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
2.3 广播通信
下面是代码示例
在接收端,监听8888端口,一旦接收到广播消息则输出消息。
public class BroadCastReceiver {
public static void main(String[] args) {
try {
DatagramSocket ds = new DatagramSocket(8888);
byte[] buf = new byte[5];
DatagramPacket dp = new DatagramPacket(buf, buf.length);
ds.receive(dp);
System.out.println(new String(buf));
} catch (Exception e) {
e.printStackTrace();
}
}
}
在发送端,所属的网段为192.168.0,子网掩码为255.255.255.0,所以广播地址为192.168.0.255,然后往该网络中所有机器的8888端口发送“hello”消息,接收端将接收到此消息。
public class BroadCastSender {
public static void main(String[] args) {
try {
InetAddress ip = InetAddress.getByName("192.168.0.255");
DatagramSocket ds = new DatagramSocket();
String str = "hello";
DatagramPacket dp = new DatagramPacket(str.getBytes(),
str.getBytes().length, ip, 8888);
ds.send(dp);
ds.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
3.服务器模型
这里的服务器模型指的是服务器对I/O的处理模型
3.1 单线程阻塞I/O模型
这种模型同时只能处理一个客户端访问,并在I/O上是阻塞的。整个过程相当于一问一答
3.2 多线程阻塞I/O模型
相对与单线程阻塞I/O模型,该模型支持对多个客户端并发响应
多线程阻塞I/O模型通过引入多线程确实提高了服务器端的并发处理能力,但每个连接都需要一个线程负责I/O操作。当连接数量较多时可能导致机器线程数量太多,而这些线程大多数时间却处于等待状态,造成极大的资源浪费。
3.3 单线程非阻塞I/O模型
该模型在调用读取/写入接口立即返回,而不会进入阻塞状态。
该模式非阻塞的功能基于对连接事件的检测,也就是说,如果检测到哪些连接完成了读写,服务器再去操作,而不是对方没有完成读写的情况下去阻塞等待。下面是三种检测方式:
3.3.1 应用程序遍历套接字
客户端向服务端请求连接时,服务端会保存一个套接字连接列表。然后服务端有个线程循环对列表中的套接字进行尝试读写,失败则下一循环继续尝试,不会阻塞。
优点:非阻塞,提升了处理能力
缺点:因为需要不断遍历套接字列表,即使是空闲时也会占用较多CPU资源,不适合实际使用
3.3.2 内核遍历套接字的事件检测
将遍历的任务交给操作系统内核,将遍历结果组织成事件列表返回应用层处理。比如,系统内核每次将结果组织成两种表,一张读表一张写表,上面可读的标为1不可读标为0,写一样。应用层只需要每次内核传回列表时候=根据表示的标识进行读写操作即可。
优点:将遍历工作移到内核层,提高了检测效率
缺点:需要将列表复制到应用层,如果表很大活跃的连接少,意味着每次复制都会有大量无效的数据需要复制。浪费了资源
3.3.3 内核基于回调的事件检测
内核中的每个套接字都对应一个回调函数,每当用户发送数据时,内核从网卡中接收数据后就会调用回调函数。通过在回调函数中维护事件列表。我们可以减少很多无意义的操作,从而提升效率。但是如果表很大活跃的连接少,这样依旧也会有很多无效的数据需要复制。因此,基于回调的方式中还有一种方式就是修改读写两张表,维护一张总表,然后当触发回调函数时,将可用的连接放入读写两张表中。这样就不需要复制无意义的数据了。
对于java来说,非阻塞I/O的实现完全基于操作系统内核的非阻塞I/O。它将操作系统的非阻塞I/O的差异屏蔽并提供统一API。JDK会为我们选择非阻塞I/O的实现方式。在Linux中,支持epoll( I/O event notification facility)的情况下,JDK会epoll实现非阻塞I/O,而这种非阻塞方式就是基于内核回调的检测方式中的而第二种
3.4 多线程非阻塞I/O模型
为了发挥多核CPU的能力,我们可以在单线程非阻塞模型的基础上对连接进行非组管理,每个线程负责对于组内的连接。
3.4.1 单线程Reactor模式
最经典的多线程非阻塞I/O方式时Reactor模式。Reactor将服务器端的整个处理过程分成若干事件(接收事件、读时间、写事件、执行事件等),然后通过事件检测机制将事件发给不同的处理器处理。整个过程中只要有待处理的事件存在,Reactor就不会阻塞,会一直处理。效率很高。前面所述指的是单线程Reactor模型,可以参考下图
3.4.2 多线程Reactor模型1
在耗时的process处理器中引入线程池
3.4.3 多线程Reactor模型2
使用多Reactor实例,一个Reactor对应一个线程。
客户端的连接接收工作统一交给一个accept实例负责,然后分配给所有Refactor
文章内容参考Tomcat内核设计剖析