系列文章目录
【网络编程】网络编程中的基本概念及java实现udp、tcp客户端服务器程序(万字博文)
【网络原理】udp协议的报文结构 及 校验和字段的错误检测机制(crc算法、md5算法)
文章目录
一、什么是网络编程?
网络编程,指网络上的主机,通过不同的进程,以编程的方式实现网络通信(或称网络数据传输)。
即便是同一个主机,只要是不同进程,基于网络来传输数据,也属于网络编程。但是,我们的目的是提供网络上的不同主机,基于网络来传输数据资源。
网络编程,本质上就是学习“传输层”给“应用层”提供的api,通过代码的形式,把数据交给传输层,进一步的通过层层封装,就可以把数据通过网卡发送出去了。
二、网络编程中的基本概念
1. 客户端和服务器
客户端:主动发起通信的一方,称为客户端.
服务器:被动接受的一方,称为服务器,可以提供对外服务.
同一个程序在不同场景中,可能是客户端也可能是服务器(服务器可能还需要主动向别的服务器发起通信,此时的服务器相对于被发起通信的服务器来说,就是客户端).
2. 请求和响应
请求(request):客户端给服务器发送数据。
响应(response):服务器给客户端返回数据。
一般来说,获取一个网络资源,涉及到两次网络数据传输:
- 第一次:请求数据的发送.
- 第二次:响应数据的发送.
就比如在快餐店点一份炒饭:
先要发起请求:点一份炒饭;再有快餐店提供的对于响应:提供一份炒饭。
三、socket套接字
前面说过,要想进行网络编程,需要使用的系统api,本质上是由传输层提供的。
传输层涉及到的主要协议有两个:
- 流套接字:tcp(传输控制协议)
- 数据报套接字:udp(用户数据报协议)
tcp的特点:
- 有连接(类似于打电话,需要接通才能通信)
- 可靠传输(尽可能完成数据传输,起码可以知道当前这个数据对方是否接收到了)
- 面向字节流(此处字节流和文件中的字节流完全一致,网络中传输数据的基本单位就是字节)
- 全双工(一个信道,可以双向通信)
udp的特点:
- 无连接(类似于发微信/短信,直接发送过去)
- 不可靠传输(没有确认、重传机制;如果因为网络故障等原因,数据无法发到对方,udp协议层也不会给应用层返回任何错误信息)
- 面向数据报(每次传输的基本单位是一个数据报(由一系列的字节构成的)特定的结构)
- 全双工(一个信道,可以双向通信)
udp数据报套接字编程
udp socket api的使用
1. datagramsocket
datagramsocket 是 udp socket(套接字),用于发送和接收udp数据报
构造方法:
重要方法:
2. datagrampacket
datagrampacket 是 udp socket(套接字)发送和接收的数据报(每次发送接收数据的基本单位)
构造方法:
3. udp回显客户端服务器程序
通过这个程序,了解 socket api 的使用,和典型的客户端服务器基本工作流程。
对于服务器,需要指定端口号来创建 socket (类似于饭店,需要指定具体位置),主要流程如下:
- 接收客户端请求,并解析
- 根据请求,计算出响应(回显服务器,则是直接将请求的数据返回)
- 将响应写回给客户端
注意事项详见代码注释:
import java.io.ioexception;
import java.net.datagrampacket;
import java.net.datagramsocket;
import java.net.socketexception;
//udp回显服务器
public class udpechoserver {
//udp套接字
private datagramsocket socket;
public udpechoserver(int port) throws socketexception {
socket = new datagramsocket(port); //服务器:指定端口号创建
}
public void start() throws ioexception {
system.out.println("服务器启动");
while (true) {
//1.接收客户端的请求,并解析
datagrampacket requestserver = new datagrampacket(new byte[4096], 4096);
socket.receive(requestserver);
//2.根据请求,计算出响应
string request = new string(requestserver.getdata(), 0, requestserver.getlength());
string response = process(request);
//3.将响应写回给客户端(需要指定发送到的ip地址及端口号)
datagrampacket responseserver = new datagrampacket(
response.getbytes(), response.getbytes().length, requestserver.getsocketaddress());
socket.send(responseserver);
//打印日志
system.out.printf("[%s:%d] request:%s response:%s\n",
responseserver.getaddress(), responseserver.getport(), request, response);
}
}
//根据请求计算响应(由于是回显程序,直接返回请求的内容)
public string process(string request) {
return request;
}
public static void main(string[] args) throws ioexception {
udpechoserver udpechoserver = new udpechoserver(9090);
udpechoserver.start();
}
}
对于客户端,服务器的端口号可以由系统随机分配,但需要知道服务器的ip地址及端口号(去饭店吃饭,需要知道饭店的地址及具体是哪个门店),主要流程如下:
- 客户端读取用户请求
- 构造请求的数据报,并发送到服务器(此时就需要指定服务器的ip地址及端口号)
- 读取服务器的响应,并解析出响应的内容
- 输出服务器的响应
import java.io.ioexception;
import java.net.*;
import java.util.scanner;
//udp回显客户端
public class udpechoclient {
private datagramsocket socket;
private string address;
private int port;
//客户端需要知道服务器的ip地址及端口号
public udpechoclient(string address, int port) throws socketexception {
this.address = address;
this.port = port;
socket = new datagramsocket(); //服务器:随机端口号创建
}
public void start() throws ioexception {
system.out.println("客户端启动");
scanner in = new scanner(system.in);
while (true) {
system.out.print("-> ");
if (!in.hasnext()) {
break;
}
//1.控制台读取请求内容
string request = in.next();
//2.构造请求的数据报,并发送到服务器(需要指定目的ip地址和目的端口号发送请求)
datagrampacket requestpacket = new datagrampacket(
request.getbytes(), request.getbytes().length, inetaddress.getbyname(address), port);
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 {
udpechoclient udpechoclient = new udpechoclient("127.0.0.1", 9090);
udpechoclient.start();
}
}
先运行服务器,再运行客户端,看程序的执行效果:
- 可以看到,服务器端能够正确接收到请求并作出响应,并打印出日志(客户端ip,客户端端口号,请求内容,响应内容)
- 客户端也能够正确发送请求,并正确解析服务器端返回的响应
这个程序并不能直接做到“跨主机通信”,因为这台主机可能不能直接访问到另一台主机(nat机制)。但是可以通过以下手段实现“跨主机通信”:
- 将服务器端程序打成 jar 包
- 把 jar 包传到云服务器上,并运行
经过这样的操作,其他主机通过运行上述的客户端程序,就能够发起通信了。
4. udp字典客户端服务器程序
基于上述回显服务器,还可以实现出一些其他带有一点业务逻辑的服务器。
改进成一个“字典服务器”,英译汉的效果。请求是一个英文单词,响应返回对应的中文翻译。
主要逻辑其实和回显服务器基本一致,唯一的区别就在于,服务器端将客户端请求的数据,计算成响应的方式不一致。回显服务器是直接返回客户端请求的数据,这里的字典服务器则是英译汉效果。
而上述代码中,这个根据请求数据计算响应数据的操作,是通过process方法实现的。因此只需要让这个字典服务器继承回显服务器,并重写process方法即可。这里英译汉的业务逻辑通过打表的方式实现。
import java.io.ioexception;
import java.net.socketexception;
import java.util.hashmap;
import java.util.map;
//udp字典服务器
public class udpdictserver extends udpechoserver {
map<string, string> map;
public udpdictserver(int port) throws socketexception {
super(port);
map = new hashmap<>();
map.put("cat", "小猫");
map.put("dog", "小狗");
map.put("animal", "动物");
}
//通过重写 计算响应的process方法,达成 英->汉 的效果
@override
public string process(string request) {
return map.getordefault(request, "找不到该单词");
}
public static void main(string[] args) throws ioexception {
udpdictserver udpdictserver = new udpdictserver(9090);
udpdictserver.start();
}
}
先运行字典服务器,再运行回显客户端(这里客户端是通用的,因为回显客户端只进行发送请求和接收响应并解析的操作),看程序的执行效果:
- 同样,服务器端能够正确接收到请求、解析请求,并计算出响应、写回给客户端,并打印出日志(客户端ip,客户端端口号,请求内容,响应内容)
- 客户端也能够正确发送请求,并正确解析服务器端返回的响应
tcp流套接字编程
1. serversocket
serversocket 类是创建tcp服务器端socket的api. (只能给服务器端使用)
构造方法:
重要方法:
2. socket
socket 类用于创建客户端 socket,或服务器端中接收到客户端建立连接(accept方法)的请求后,返回的服务端socket. (服务器端和客户端都能使用)
构造方法:
重要方法:
3. tcp回显客户端服务器程序
使用tcp协议实现回显客户端服务器程序。与udp协议实现的最大区别是,tcp是有连接的,和打电话一样,需要一方(客户端)拨号,一方(服务器)接通,因此tcp协议首要操作就是等待客户端连接。
和udp回显服务器一样,对于这里的服务器,同样需要指定端口号创建tcp服务器端socket,即serversocket。
- 服务器启动后,就需要监听当前绑定端口(accept方法),等待客户端连接。
- 当成功建立连接后,会返回一个socket对象。这个对象保存了对端信息,即客户端信息,可以用来接收和发送数据(tcp是面向字节流的,通过这个socket对象获取对应输入流和输出流,通过输入输出流进行对 socket 的读写,达成接收和发送数据的功能)。
后续流程和ucp回显服务器一致。此处由于每有一个客户端连接,就会有一个clientsocket,这里消耗的socket会越来越多,因此每当一个客户端连接结束,就需要释放这个clientsocket。
import java.io.ioexception;
import java.io.inputstream;
import java.io.outputstream;
import java.io.printwriter;
import java.net.serversocket;
import java.net.socket;
import java.util.scanner;
//tcp回显服务器
public class tcpechoserver {
private serversocket serversocket;
public tcpechoserver(int port) throws ioexception {
//指定服务器端口号,创建一个serversocket
serversocket = new serversocket(port);
}
public void start() throws ioexception {
system.out.println("服务器启动!");
while (true) {
//监听当前绑定的端口,等待客户端连接 连接后,返回一个socket,里面保存客户端(对端)信息
socket clientsocket = serversocket.accept();
processconnection(clientsocket);
}
}
private void processconnection(socket clientsocket) throws ioexception {
system.out.printf("[%s:%d] 客户端上线\n", clientsocket.getinetaddress(), clientsocket.getport());
try (inputstream inputstream = clientsocket.getinputstream();
outputstream outputstream = clientsocket.getoutputstream()) {
while (true) {
//1.读取客户端请求的数据
//利用scanner读取客户端输入的信息
scanner scanner = new scanner(inputstream);
if (!scanner.hasnext()) {
system.out.printf("[%s:%d] 客户端下线\n",
clientsocket.getinetaddress(), clientsocket.getport());
break;
}
//这里的next()需要遇到\n才停止,因此需要对端写入的时候,要同时写入\n换行符
string request = scanner.next();
//2.解析请求的数据,并计算出响应
string response = process(request);
//3.将响应写回到客户端
//outputstream.write(response.getbytes(), 0, response.getbytes().length);
printwriter writer = new printwriter(outputstream);
writer.println(response);
writer.flush();
//打印日志
system.out.printf("[%s:%d] request:%s response:%s\n",
clientsocket.getinetaddress(), clientsocket.getport(), request, response);
}
} catch (ioexception e) {
throw new runtimeexception(e);
} finally {
clientsocket.close();
}
}
//回显服务器,直接返回原数据
public string process(string request) {
return request;
}
public static void main(string[] args) throws ioexception {
tcpechoserver tcpechoserver = new tcpechoserver(9090);
tcpechoserver.start();
}
}
对于客户端,需要指定服务器的ip和端口号建立连接。使用 socket(string host, int port) 创建socket的时候,就开始发起与对应服务器建立连接的请求了。
主要流程和udp回显客户端程序的流程也基本一致,只需要注意请求和响应数据的方式是不同的,是通过操作输入输出流完成的即可。
import java.io.ioexception;
import java.io.inputstream;
import java.io.outputstream;
import java.io.printwriter;
import java.net.inetaddress;
import java.net.socket;
import java.util.scanner;
//tcp回显客户端
public class tcpechoclient {
private socket clientsocket;
//需要指定服务器的ip和端口号
public tcpechoclient(string serveraddress, int serverport) throws ioexception {
//与对应客户端建立连接
clientsocket = new socket(inetaddress.getbyname(serveraddress), serverport);
}
public void start() {
system.out.println("客户端启动!");
try (scanner scannerconsole = new scanner(system.in);
inputstream inputstream = clientsocket.getinputstream();
outputstream outputstream = clientsocket.getoutputstream()) {
while (true) {
//1.用户从控制台输入数据
system.out.print("-> ");
string request = scannerconsole.next();
//2.将该数据作为请求,发送给服务器
//outputstream.write(request.getbytes(), 0, request.getbytes().length);
//outputstream.write('\n');
printwriter writer = new printwriter(outputstream);
writer.println(request);
writer.flush(); //刷新缓冲区,确保数据发送出去
//3.读取服务器的响应,并解析响应的内容
scanner scannernetwork = new scanner(inputstream);
string response = scannernetwork.next();
//4.将响应输出到控制台
system.out.println(response);
}
} catch (ioexception e) {
throw new runtimeexception(e);
}
}
public static void main(string[] args) throws ioexception {
tcpechoclient tcpechoclient = new tcpechoclient("127.0.0.1", 9090);
tcpechoclient.start();
}
}
先运行服务器,再运行客户端,看执行效果:
可以看到,服务器和客户端都能满足我们的需求,但这里其实还存在一个问题。
当我们开启多个客户端想要进行连接通信时,只有第一个连接到的客户端才能正确通信,其他的客户端是没有反应的。要想某个客户端能正常通信,只有当其他客户端都下线(结束程序),这个客户端才能接收到响应数据。
可以看到,此处的这个客户端并没有正确通信,当另一个客户端下线之后,该客户端此前发送的数据又正常请求并响应了。
分析过程:
- 第一个客户端连上服务器之后,服务器就会从accept这里返回(解除阻塞),然后进入到processconnection方法中.
- 接下来服务器就会在processconnection循环处理客户端的请求,只有当客户端退出之后,连接结束,才会退出循环.
- 而服务器在循环处理客户端请求的时候,第二个客户端发起连接请求,而服务器这里并不能执行到accept。因此并不能成功连接,只有当客户端退出,才会执行回到accept进行连接.
第二个客户端之前发的请求为什么能被立即处理?
- 当前tcp在内核中,每个 socket 都是有缓冲区的。客户端发送的数据通过客户端代码,已经写入到服务器的缓冲区了,这里数据确实发送了,只不过数据在服务器的接收缓冲区中。
- 一旦第一个客户端退出,回到第一层循环,执行accept连接操作,后续processconnection方法里的 next 就能把之前缓冲区的内容给读出来。
解决上述问题的核心思路就是使用多线程:
- 单个线程,无法既能给客户端提供服务,又能去快速执行到第二次 accept 方法进行连接。
- 通过引入多线程,让主线程只负责执行 accept。每次有一个客户端连接上来,就分配一个新的线程,由新的线程负责给客户端提供服务。
由于这里不涉及多个线程修改共享变量,因此没有线程安全问题,我们只需要改动 start 方法即可。
4. 服务器引入多线程
//多线程
public void start() throws ioexception {
system.out.println("服务器启动!");
while (true) {
//监听当前绑定的端口,等待客户端连接 连接后,返回一个socket,里面保存客户端(对端)信息
socket clientsocket = serversocket.accept();
thread t = new thread(() -> {
try {
processconnection(clientsocket);
} catch (ioexception e) {
throw new runtimeexception(e);
}
});
t.start();
}
}
通过引入多线程,这里的服务器就能支持多个客户端同时与其通信了。
上述问题,不是tcp引起的,而是代码两次循环嵌套引起的,udp服务器,就是只有一层循环,因此不会有这个问题。
而这个多线程版本同样还有一些问题:
- 每有一个客户端连接,就会创建一个新的线程,每当这个客户端结束,就要销毁这个线程。
- 如果客户端比较多,并且频繁连接、关闭,就会使服务器频繁创建和销毁线程。
前面讲过,线程池解决的就是线程频繁创建和销毁的问题,因此,这里的优化方案就是使用线程池。
5. 服务器引入线程池
public void start() throws ioexception {
system.out.println("服务器启动!");
executorservice threadpool = executors.newcachedthreadpool();
while (true) {
//监听当前绑定的端口,等待客户端连接 连接后,返回一个socket,里面保存客户端(对端)信息
socket clientsocket = serversocket.accept();
threadpool.submit(new runnable() {
@override
public void run() {
try {
processconnection(clientsocket);
} catch (ioexception e) {
throw new runtimeexception(e);
}
}
});
}
}
线程开销问题解决了。但是,如果当前的场景使线程频繁创建,但是不销毁呢?
- 这种情况下,如果继续使用多线程/线程池,就会导致当前服务器积累大量的线程,此时,对于服务器的负担是非常重的。
要解决这个新问题,还可以引入其他的方案:
- 协程:轻量级线程,本质上还是一个线程,用户态可以通过手动调度的方式让这一个线程“并发”执行多个任务。
- io 多路复用:系统内核级别的机制,本质上是让一个线程同时去负责处理多个socket。
6. tcp字典客户端服务器程序
同udp字典客户端服务器程序:
import java.io.ioexception;
import java.util.hashmap;
import java.util.map;
//tcp字典服务器
public class tcpdictserver extends tcpechoserver {
map<string, string> map;
public tcpdictserver(int port) throws ioexception {
super(port);
map = new hashmap<>();
map.put("cat", "小猫");
map.put("dog", "小狗");
map.put("animal", "动物");
}
@override
public string process(string request) {
return map.getordefault(request, "未找到该单词");
}
public static void main(string[] args) throws ioexception {
tcpdictserver tcpdictserver = new tcpdictserver(9090);
tcpdictserver.start();
}
}
发表评论