上节回顾
TCP socket(核心:要掌握的两个类,Serversocket,socket)
回显服务器(无法支持多个客户端并发执行)
多线程回显服务器(针对每个连接(每个客户端)创建一个线程)
线程池回显服务器(避免频繁创建/销毁线程)
接着上一篇五层协议继续写.
import java.io.IOException; import java.net.DatagramPacket; import java.net.DatagramSocket; import java.net.SocketException; public class CalcServer { private DatagramSocket socket = null; public CalcServer(int port) throws SocketException { socket = new DatagramSocket(port); } public void start() throws IOException { System.out.println("服务器启动!"); while (true) { // 1. 读取请求并解析 DatagramPacket requestPacket = new DatagramPacket(new byte[4096], 4096); socket.receive(requestPacket); String request = new String(requestPacket.getData(), 0, requestPacket.getLength()); // 2. 根据请求计算响应 String response = process(request); // 3. 把响应写回到客户端 DatagramPacket responsePacket = new DatagramPacket(response.getBytes(), response.getBytes().length, requestPacket.getSocketAddress()); socket.send(responsePacket); // 4. 打印日志 String log = String.format("[%s:%d] req: %s; resp: %s", requestPacket.getAddress().toString(), requestPacket.getPort(), request, response); System.out.println(log); } } // process 内部就要按照咱们约定好的自定协议来进行具体的处理! private String process(String request) { // 1. 把 request 还原成操作数和运算符 String[] tokens = request.split(";"); if (tokens.length != 3) { return "[请求格式出错!]"; } int num1 = Integer.parseInt(tokens[0]); int num2 = Integer.parseInt(tokens[1]); String operator = tokens[2]; // 2. 进行具体的运算了 int result = 0; // 完全可以换成 switch if (operator.equals("+")) { result = num1 + num2; } else if (operator.equals("-")) { result = num1 - num2; } else if (operator.equals("*")) { result = num1 * num2; } else if (operator.equals("/")) { result = num1 / num2; } else { return "[请求格式出错! 操作符不支持!]"; } return result + ""; } public static void main(String[] args) throws IOException { CalcServer server = new CalcServer(9090); server.start(); } }
客户端代码实现
import java.io.IOException; import java.net.*; import java.util.Scanner; public class CalcClient { private DatagramSocket socket = null; private String serverIp; private int serverPort; public CalcClient(String serverIp, int serverPort) throws SocketException { this.serverIp = serverIp; this.serverPort = serverPort; this.socket = new DatagramSocket(); } public void start() throws IOException { Scanner scanner = new Scanner(System.in); while (true) { // 1. 让用户进行输入 System.out.println("请输入操作数 num1: "); int num1 = scanner.nextInt(); System.out.println("请输入操作数 num2: "); int num2 = scanner.nextInt(); System.out.println("请输入运算符(+ - * /): "); String operator = scanner.next(); // 2. 构造并发送请求 String request = num1 + ";" + num2 + ";" + operator; DatagramPacket requestPacket = new DatagramPacket(request.getBytes(), request.getBytes().length, InetAddress.getByName(serverIp), serverPort); socket.send(requestPacket); // 3. 尝试读取服务器的响应 DatagramPacket responsePacket = new DatagramPacket(new byte[4096], 4096); socket.receive(responsePacket); String response = new String(responsePacket.getData(), 0, responsePacket.getLength()); // 4. 显示这个结果 System.out.println("计算结果为: " + response); } } public static void main(String[] args) throws IOException { CalcClient client = new CalcClient("127.0.0.1", 9090); client.start(); } }
所谓的自定义协议,一定是开发之前,就要约定好的
开发的过程中,就需要让客户端和服务器之间们都能够严格遵守协议约定好的格式.
此处约定格式的方式有很多种,当前咱们是使用一个最简单粗暴的方式来约定(直接实用文本+分隔符)
如果解决简单问题,那还行,如果是复杂问题,就难搞了.
如果是复杂问题:假设传输的请求和响应中,各自有几十个字段....有的字段可能是"可选的"(可有可无)
实际开发中,如何来约定自定义协议呢?
除了刚才这种简单粗暴的文本+分隔符的方式,还有那些更好的方式?
大体分成两类:
1.文本格式(把请求和响应当成字符串来处理,处理的基本单位是字符)
文本格式常见的方式:xml,json...
2.二进制格式(把请求响应当成二进制数据处理,处理的基本单位是字符)
二进制方式:protobuffer,thift.....
xml:
格式化组织数据的方式.
针对上面刚写的场景使用xml来设置协议大概样子:
请求:
10
10
+ 响应:
30 xml:把数据组成了一个结构化的数据
整个xml是由"标签"构成的
标签,是成对出现的
形如
开始标签 结束标签开始标签和结束标签之间的东西就是值
这种格式,其实很常见,不仅仅可以用于自定义协议(不仅仅可以用于网络传输)
咱们很多Java中涉及到的配置之类的,也经常会使用xml这样的格式来组织.
json也是非常有特点的格式
请求:
{
num1: 10,
num2: 20,
operator: "+"
}
响应:
{
result: 30
}
这里的json是键值对结构.键和值之间,使用 : 分割,键值对之间,使用 逗号 分割.
整体最外面包含一个{}
格式:也是能够结构化的组织数据
json也是有一些配套的的第三方库来帮助我们构造和解析
自定义协议,其实是一个很简单的事情.
只要约定好请求和响应是详细即可.(越详细越好,要把各种细节都交代到,能够很好的表示当前的信息)
咱们可以自己来约定格式,也可以基于xmlhejson来约定何时,还可以通过一些其他的二进制的方式来约定格式......
传输层
负责端对端的数据传输
只考虑起点和终点,不考虑中间过程
传输层由于是操作系统内核实现的,因此谈到的传输层协议,一般都是指现成的一些协议.很少会涉及"自定制"
UDP,TCP都是属于传输层的协议.
UDP
1.无连接
2.不可靠
3.面向数据报
4.全双工
TCP
1.有连接
2.可靠
3.面向字节流
4.全双工
有连接:socket创建好了之后,还需要建立连接,连接建立完了,在通过accept获取到连接才能进行读写数据
无连接:socket创建好之后就可以立即尝试读写数据了
面向数据报:读写数据都是以DatagramPacket为单位进行的
面向字节流:读写数据直接以byte[]为单位.
全双工:一个socket既能读,也能写
传输层的概念
端口号
端口号的用途:表示一个进程,就可以区分出当前收到的数据要交给哪个进程来处理.
举例:
当我们开发广告的时候,
首先会让服务器提供一个"业务端口"
通过这个端口,提供一些广告搜索服务.(上游客户端,就可以通过这个端口来请求获取到广告数据)
其次还会让服务器提供一个"调试端口"
服务器运行过程中,其实涉及到很多很多的数据.有时候为了定位一些问题,就需要查看到这些内存数据.通过这个调试端口给服务器发送一些调试请求,于是服务器就能返回一些对应的结果.
为什么这么麻烦,直接拿调试器,来个断点啥的不就行了吗?
如果拿调试器断住程序,此时这整个进程是处在一个"阻塞"的状态中,这就意味着这个服务器就无法响应正常的业务需求了.
通常情况下,两个进程无法绑定掉同一个端口号!!
有的特殊情况下,可以做到!
在Linux中,
先让进程,绑定一个端口,接下来,通过fork这个系统调用,把进程的PCB复制一份,得到一个新的,
"子进程"
由于端口是关联在socket上,而socket是一个文件,这个文件在文件描述符表中.
而文件描述符表又是PCB的一部分
fork复制PCB,也就把文件描述符表给继承下来了.也就顺带的把这样的端口号的关联关系也给继承过来......
这种场景在Java中基本不会涉及.....
端口号是一个整数.
是一个两个字节的整数
0~65535(没有负数)
这么多端口我们能随便用嘛?
其实也不是,在这些端口里面有些端口咱们程序猿可以随便用,有些不能随便用.
0-1023这些端口,称为"知名端口"
当前已经有很多现成的应用层协议了.
就给这些现成的应用层协议,已经分配了一些端口号了
举例:
80 一般就是给HTTP使用
22 一般给SSH使用
21 一般给FTP使用
23 一般给telnet使用
443 一般给HTTPS使用
.......
针对这些知名端口号,咱们在实际开发的时候也不一定非得要严格遵守.
例如:
tomcat,也是以一个HTTP服务器,但是它使用的默认端口是8080,而不是80.
但是咱们自己写的一些服务器,最好不要使用知名端口号
另外的一些系统上,比如linux,如果进程要绑定知名端口号,往往需要管理员权限.
咱们自己写个服务器,使用哪个端口,随你喜欢,只要尽量避开知名端口号,并且在65535范围之内即可.
UDP协议
要想了解好UDP协议必须得
理解协议报文格式.
第一个字段16位(bit位)源端口号 (相当于发件人的姓名)
第二个字段16位目的端口号(相当于收件人姓名)
第三个字段16位UDP长度(长度指的是整个UDP数据报的长度(报头+载荷),使用两个字节的数据来表示.单位是字节)
2个字节能表示的数据范围:
0-65535 byte
一个UDP数据报,最大就是64KB
64K这个长度是长还是短?
在现代互联网看起来64K太小了.
198x,199x那个时代,64K就不小了.
在实际开发中,如果使用UDP来传输数据,一定要警惕大的报文.
如果报文长度超过64K,此时就可能丢失一部分数据.
第四个字段校验和(网络上传输的数据,是可能会出现一些问题的.网络上的数据本质都是一些0/1 bit流.这些bit流都是通过光信号或者电信号来表示的.如果传输过程中,收到一些干扰,就容易出现"比特翻转情况,也就是(0变1,1变0)")
校验和其实就是为了验证,看当前的数据是否出现问题了.
校验和也是一种信息上的冗余.
校验和也不一定100%的就能进行校验
如果校验和正确,也不能确保数据一定对.
但是如果校验和不正确,能说明数据一定是错的
校验和更多的用处,是"证伪".
校验和往往是根据原始数据的内容来生成的.不同的内容,生成校验和也就不一样.
这个时候,一旦数据发生了变化,校验和也就不一样了.
就可以通过校验和来判定当前的数据是否发生了变化了.