网络通信

本章介绍一下如何使用asio进行网络通信, 介绍tcp与udp的区别,并手动实现一个加密连接的http 代理服务器,最后比较一下tcp与udp的差异.

IO模型

本项目的网络通信功能建立在cpp语言里使用最广的网络库Asio之上,此网络库同时支持了阻塞网络模型与异步网络模型:

  1. 阻塞 blocking ,当前线程发出IO请求后阻塞在等待IO就绪,然后再发去数据复制请求,然后再阻塞在等待数据拷贝完成;
  2. 异步 asynchronouse,线程提交IO请求之后直接返回,系统在执行完IO请求并复制到用户提供的数据区之后再通知完成 这两种网络模型在代码编写时有很大的差别,这里我们用一个非常简单的Echo网络程序来说明一下他们之间的差异:

同步阻塞通信模型

首先我们用asio构造一个同步通信的Echo客户端,这个客户端的负责读取一行用户提供的输入,传输到服务器上,然后等待服务器发回这段输入,接收完数据之后就退出。

const int max_length = 1024;
int main(int argc, char* argv[])
{
	try
	{
		if (argc != 3)
		{
		// 命令行需要传递两个参数 第一个为服务器的ip 第二个为服务器的端口
			std::cerr << "Usage: blocking_tcp_echo_client <host> <port>\n";
			return 1;
		}

	// 构造一个asio的执行环境
		asio::io_context io_context;
	// 使用执行环境构造一个tcp的socket
		tcp::socket s(io_context);
	// resolver负责解析服务器 作用是将 类似于 www.baidu.com:80这样的网址 解析到对应的ip地址和tcp端口
		tcp::resolver resolver(io_context);
	// 将构造的socket连接到指定的服务器
		asio::connect(s, resolver.resolve(argv[1], argv[2]));

		std::cout << "Enter message: ";
		char request[max_length];
		std::cin.getline(request, max_length);
		size_t request_length = std::strlen(request);
	// 将输入的string 构造一个buffer 然后通过之前构造的socket 将这个buffer里的所有数据发送到连接到的服务器
	// 发送期间 当前程序阻塞 直到发送完成或者报错
		asio::write(s, asio::buffer(request, request_length));

		char reply[max_length];
	// 构造一个读取数据的buffer 然后等待服务器发送数据过来
		size_t reply_length = asio::read(s,
				asio::buffer(reply, request_length));
		std::cout << "Reply is: ";
	// 输出服务器发送过来的信息
		std::cout.write(reply, reply_length);
		std::cout << "\n";
	}
	catch (std::exception& e)
	{
	// asio 提供的resolve connect read write等操作都是通过系统提供的相关接口执行的 
	// 如果这些接口返回了错误, asio则会将这些错误转换为异常 抛出
		std::cerr << "Exception: " << e.what() << "\n";
	}

	return 0;
}

对应的Echo同步服务器则需要在特定Tcp端口上开启监听,等待客户端进行连接,读取数据之后再往客户端发回去:

const int max_length = 1024;

void session(tcp::socket sock)
{
	
	try
	{
	// 当一个客户端连接过来的时候 开启这个无限循环
		for (;;)
		{
			char data[max_length];

			std::error_code error;
		// 这里调用read_some来读取客户端连接发送过来的数据到data构造的buffer, 
		// 如果调用出错 则将错误码写入error参数
		// 调用成功则返回读取的字节数量
			size_t length = sock.read_some(asio::buffer(data), error);
			if (error == asio::error::eof)
				break; // Connection closed cleanly by peer.
			else if (error)
				throw std::system_error(error); // Some other error.
		// 将读取的数据再写回客户端
			asio::write(sock, asio::buffer(data, length));
		}
	}
	catch (std::exception& e)
	{
	// 为了避免一个客户端连接出异常导致服务器崩溃 这里使用try将异常打印出来 然后结束循环
		std::cerr << "Exception in thread: " << e.what() << "\n";
	}
}

void server(asio::io_context& io_context, unsigned short port)
{
	// 这里的tcp:v4()返回的就是本机所有IPV4地址 等价于0.0.0.0
	// 这里构造一个tcp::acceptor的监听结构 开启对localhost:port的端口监听
	tcp::acceptor a(io_context, tcp::endpoint(tcp::v4(), port));
	for (;;)
	{
	// 这里的a.accept是一个阻塞调用 当客户端连接到此服务器时
	// 返回此连接对应的socket 
	// accept返回后会构造一个thread 来执行session(socket)这个函数
	// thread构造好之后 开启另外一个线程进行执行 同时detach 避免阻塞当前线程
		std::thread(session, a.accept()).detach();
	// 有多少个同时活动的客户端连接 就会有多少个额外线程
	}
}

int main(int argc, char* argv[])
{
	try
	{
		if (argc != 2)
		{
		// 
			std::cerr << "Usage: blocking_tcp_echo_server <port>\n";
			return 1;
		}
	// asio的网络功能依赖于io_context作为执行环境
		asio::io_context io_context;

		server(io_context, std::atoi(argv[1]));
	}
	catch (std::exception& e)
	{
		std::cerr << "Exception: " << e.what() << "\n";
	}

	return 0;
}

从上述的Echo代码样例可以看出,同步网络编程是从逻辑结构上来说非常简单的,这里的read, write基本可以等价于在执行cin, cout, 函数返回时即可认为对应数据操作已经完成。但是这种逻辑上的简单也有其附加的代价,这几个接口的调用期间,所在线程是完全阻塞住的,无法执行其他任务。客户端程序能够接受这种阻塞,但是对于服务器来说,这样的阻塞是不可接受的,因为服务器要同时服务多个客户端。所以这里服务器每次接收到一个客户端连接时,都会创建一个额外的线程来处理这个客户端连接的所有逻辑。创建线程是一个消耗很大的函数,线程太多也会让系统的线程调度器负担增大。因此生产环境面向并发的网络程序基本不会采用阻塞的网络通信模型。

异步通信模型

由于异步通信模型会导致代码量增加很多,因此这里只提供异步的Echo服务器端的代码展示,上一节中的同步阻塞客户端仍然可以连接到新的异步服务器。在使用异步的监听服务器时,我们使用一个session的结构来管理一个客户端连接, 而不是上一节中给每个客户端连接分配一个线程。

	class session
	: public std::enable_shared_from_this<session>
{
public:
	session(tcp::socket socket)
		: socket_(std::move(socket))
	{
	}

	void start()
	{
		do_read();
	}

private:
	void do_read()
	{
		auto self(shared_from_this());
	// 这里提供一个buffer 然后在对应的socket上发起一个异步读取的操作
	// async_read_some这个操作会立即返回 等到socket接收到一些数据或报错的时候 
	// 才执行我们提供的lambda函数
		socket_.async_read_some(asio::buffer(data_, max_length),
				[this, self](std::error_code ec, std::size_t length)
				{
					if (!ec)
					{
						do_write(length);
					}
				});
	}

	void do_write(std::size_t length)
	{
		auto self(shared_from_this());
	// 这里提供一个buffer 然后在对应的socket上发起一个异步发送的操作
	// async_write这个操作会立即返回 等到socket发送完buffer指定的数据或报错的时候 
	// 才执行我们提供的lambda函数
		socket_.async_write(asio::buffer(data_, length),
				[this, self](std::error_code ec, std::size_t /*length*/)
				{
					if (!ec)
					{
			// 如果没有报错 则继续执行do_read
						do_read();
					}
				});
	}

	tcp::socket socket_;
	enum { max_length = 1024 };
	char data_[max_length];
};

相对于之前同步阻塞的数据收发接口,新的异步收发接口都有一个async_的前缀,同时函数都有一个额外参数来接受一个能转换为std::function<void(std::error_code, std::size_t)>类型的回调函数。这些异步收发接口调用之后会立即返回,不会去等待对应的操作执行结束,只是往asio::io_context发起这个操作并注册这个操作执行结束(包括失败)时的回调函数。在开启多线程处理同一个asio:io_context的情况下,这个回调函数的不保证在发起对应操作的线程上执行,因此逻辑层需要自己处理好回调函数的多线程数据读写问题。值得注意的是这个session结构继承自std::enable_shared_from_this<T>,这个enable_shared_from_this父类的存在导致session的实例必须通过std::make_shared的形式进行创建。同时这个父类还提供了shared_from_this()接口来获取当前this指针对应的shared_ptr<session>。每次发起一个异步操作时我们都通过auto self(shared_from_this())来构造当前实例的一个shared_ptr,然后传递到异步函数的回调lambda中。这样就可以保证异步函数回调时session的生命周期仍然是有效的,对应的由data_构造的buffer也是有效的。

有了这个session结构去管理客户端连接之后,服务器的监听逻辑如下:

class server
{
public:
	server(asio::io_context& io_context, short port)
		: acceptor_(io_context, tcp::endpoint(tcp::v4(), port))
	{
		do_accept();
	}

private:
	void do_accept()
	{
		acceptor_.async_accept(
				[this](std::error_code ec, tcp::socket socket)
				{
					if (!ec)
					{
						std::make_shared<session>(std::move(socket))->start();
					}

					do_accept();
				});
	}

	tcp::acceptor acceptor_;
};

int main(int argc, char* argv[])
{
	try
	{
		if (argc != 2)
		{
			std::cerr << "Usage: async_tcp_echo_server <port>\n";
			return 1;
		}

		asio::io_context io_context;

		server s(io_context, std::atoi(argv[1]));

		io_context.run();
	}
	catch (std::exception& e)
	{
		std::cerr << "Exception: " << e.what() << "\n";
	}

	return 0;
}

这里使用一个server结构体去管理监听,核心逻辑就在do_accept里,内部发起一个async_accept的异步监听操作,每次一个新的客户端连接上来的时候都会执行这个监听函数的回调函数。回调函数负责使用make_sharedacceptor创建的新socket构造出一个新的session,并立即启动这个session。此时server并没有保存sessionshared_ptr,这个session的生命周期完全由session内部逻辑去管理,所以session内每个异步操作的回调lambda都需要去捕获这个session的一个shared_ptr以维持引用计数。

TCP的封包与解包

上面的Echo程序是一个使用Asio编写的非常简单的TCP网络通信例子,在这个例子中服务器接收到任意字节数量的数据之后并不做任何逻辑处理直接往回转发。但是在真正有意义的通信程序中,这种无视传输内容的通信是不存在的。在业务层看来,客户端与服务器之间通信传输的是一个个业务层的数据包,由于业务逻辑的不同会导致不同的数据包的大小各不一样。而TCP协议是基于数据流的协议,它的write接口发送的是一段字节,他的read接口获取也是一段字节,这些字节片段基本不可能业务层传递过来的数据包Packet一一对应。

每次read接口读取过来的一段数据就是多个包按序组成的字节流的一部分,这段数据可能不足以包含一个包,也可能包含多个包。所以在TCP接收到数据到将Packet传递给业务逻辑处理中间,我们需要执行一个解包的过程。解包首先要明确的是每一个Packet的边界,即获取下一个完整数据包的大小。为了达到此目的,我们一般在接收到业务层发送一个Packet字节片段的请求时,构造一个新的Packet,这个新Packet的前四个字节用原始Packet的大小进行填充,然后再拼接原始Packet的所有字节到这四个字节之后。这样处理之后,TCP网络解包的流程就大概等价于下面的过程:

#define MAX_PACKET_SZ  65536
char buffer[MAX_PACKET_SZ];
std::function<void(const char* , std::uint32_t)> packet_callback;
std::uint32_t buffer_begin = 0;
asio::tcp::socket tcp_socket;
while(true)
{
	//不断的读取socket的数据
	std::uint32_t read_sz = tcp_socket.read_some(asio::buffer(buffer + buffer_begin, MAX_PACKET_SZ - buffer_begin));
	buffer_begin += read_sz;
	while(buffer_begin > sizeof(std::uint32_t)>)
	{
		// 当读取了头部的四个字节之后 我们就知道了当前packet的大小
		std::uint32_t cur_packet_sz = 0;
		std::copy(buffer, buffer + sizeof(std::uint32_t), reinterpret_cast<char*>(&cur_packet_sz));
		assert(cur_packet_sz + 4 < MAX_PACKET_SZ>);
		if(buffer_begin >= cur_packet_sz + sizeof(std::uint32_t))
		{
			// 如果当前buffer中的数据长度已经大于等于packet的所需大小 可以将此packet向业务层传递
			packet_callback(buffer + sizeof(std::uint32_t), cur_packet_sz);
			// 从buffer中删除这个已经处理的packet相关的数据
			std::copy(buffer + sizeof(std::uint32_t) + cur_packet_sz, buffer + buffer_begin, buffer);
			buffer_begin -= sizeof(std::uint32_t) + cur_packet_sz;
			// 这里会执行下一层的while去尝试处理下一个packet 因为当前buffer里面可能有多个packet
		}
		else
		{
			// 剩下的数据不足以拼装成一个packet 等待后续数据
			break;
		}
	}
}

上面的代码只是为了大概解释一下TCP接收端的解包过程,真正的生产环境代码不能直接这么写,主要有如下两个问题:

  1. 这里预先设置了单Packet的最大大小为65536也就是64k,大于此大小的Packet将会触发Assert,但实际的业务逻辑中以M为单位的Packet也是可能有的。解决的方法有两种:采用动态大小的Buffer,或者发送端将大的业务包拆分为多个连续的合适大小小包,对应的接收端处理小包拼接逻辑。
  2. 这里每次处理完一个Packet之后会将剩余的数据重新CopyBuffer的开头,这里其实可以修改为只要剩余的Buffer能够容纳下一个完整的Packet就不需要执行Copy,这样就可以节省很多内存拷贝的时间。

游戏网络通信中的UDP

UDP与TCP之间的差异

前面一节所展示的客户端与服务器之间的通信使用的是基于TCPSocketTCP是建立于IP网络层协议之上一种面向连接的、可靠的、基于字节流的传输层协议。为了实现可靠性,TCP实现了流量拥塞控制功能。建立在IP网络层协议之上还有一种知名的传输层协议UDP,他是一种无连接的面向数据包的不可靠传输层协议。即UDP在发送数据之前不需要走类似于TCP的三次握手,只要知道目标的ip:port就可以直接调用sendto接口来发送一段二进制数据。然后sendto接口只负责尝试将这段数据发送到系统的发送缓冲区,如果缓冲区满了甚至可以无声无息的丢弃这段数据。然后系统底层为UDP实现的socket可读通知是保证一个完整的UDP包被接收了,即一次recvfrom一定等价于一次sendto。但是反过来一次sendto不一定等价于一次recvfrom,因为这个被发送的包数据可能在传输的过程中被丢弃。同时recvfrom的顺序不一定等于sendto的顺序,这样就会导致包的乱序到达。业务层使用recvfrom接收udp包的时候需要传入bufbufsize,就是接收空间和接收空间大小。如果这个bufsize小于udp包的大小,那么只能接收到这个udp包的前bufsize个字节,剩下的部分会被直接被丢弃,再次执行recvfrom的时候处理的已经是第二个包了。所以bufsize要适配组包时的单Packet大小上限,一般来说这个大小上限都会设置为ipMTU大小,这样避免单UDP包在传输的时候被拆分为多UDP包,多包引发丢包的概率比单包丢失概率大很多。

在互联网业务中网络通信使用的传输层协议基本全是TCP,主要是因为其可靠性可以减少上层逻辑复杂度。而在游戏业,随着手机游戏的发展,UDP协议在那些要求低延迟的游戏品类里逐渐成为了主流选择。因为手机游戏所处的网络环境比PC环境复杂的多,网络的接入主要是移动网络或者Wifi,这两种网络相对于PC游戏常用的有线网络来说稳定性降低了很多,随着手机物理位置的移动会随机的触发丢包。在使用TCP协议时,如果遇到丢包,则TCP协议会认为此时的网络信道出现了阻塞,因此会触发TCP协议的拥塞避免,此时会将TCP协议的发送窗口减半,同时发送速率降低到一个最大报文段Maximun Segment Size,开始慢启动流程。

tcp传输阻塞控制

如果出现连续的多个丢包,则会导致TCP协议进行多次减半发送窗口,这样会导致丢包后的发送速率急剧下降,服务器端收到的后续数据包的延迟急剧增大。在网络游戏中,客户端延迟是玩家游戏体验的极其重要的一环,在动作类和FPS类游戏中大于100ms的延迟会导致游戏体验基本为0!而随机性的延迟飙升更容易触发玩家的愤怒,引发各种恶评。丢包引发的延迟飙升是TCP的内在机制决定的,无法从业务逻辑层绕过。所以这类低延迟要求的游戏很多都从TCP切换到了UDP,因为UDP并没有带流量控制功能。但是切换到UDP又会导致TCP所带的数据可靠性丧失,因为UDP并没有TCPACK与超时重传机制。为了避免影响上层业务逻辑对网络的处理,一般来说这类游戏会基于UDP实现一个带ACK与超时重传机制的可靠UDP协议。

KCP:可靠UDP的一种实现

KCP是一个快速可靠协议,能以比 TCP浪费10%-20%的带宽的代价,换取平均延迟降低30%-40%,且最大延迟降低三倍的传输效果。纯算法实现,并不负责底层协议(如UDP)的收发。需要使用者自己定义下层数据的发送方式,以 callback的方式提供给 KCP。连时钟都需要外部传递进来,内部不会有任何一次系统调用。

KCP力求在保证可靠性的情况下提高传输速度。KCP没有规定下层传输协议,作为一个逻辑层协议它也可以运行在TCP之上。但通常使用UDP来实现,因为TCP自带的拥塞控制会导致上层的KCP丧失所有意义。 KCP内部通过如下几个机制来实现快速可靠:

  1. RTO不翻倍。RTO(Retransmission-TimeOut)即重传超时时间,TCP的超时计算是RTO*2,而KCP的超时计算是RTO*1.5,也就是说假如连续丢同一个包3次,TCP第3次重传是RTO*8,而KCP则是RTO*3.375,意味着可以更快地重新传输数据。

  2. 更优的ACK机制 TCP在连续ARQ(自动重传请求,Automatic Repeat-reQuest)协议中,不会将一连串的每个数据都响应一次,而是延迟发送ACK,通知对端此包之前的所有包都已经收到,目的是为了充分利用带宽,但是这样会计算出较大的RTT时间,延长了丢包时的判断过程。KCP在连续ACK的基础上,还可以对不连续的包进行ACKKCPACK是否延迟发送可以调节,当配置了非延迟ACK时,收到数据立即响应。

  3. 选择性重传 TCP中实现了连续ARQ协议,再配合累计确认重传数据,只不过重传时需要将最小序号丢失的以后所有的数据都要重传;而KCP能够对不连续的包进行ACK,这样发送端就可以单独对所有已发出但未ACK的数据单独做计数,故而只需要重传真正丢失的数据。

  4. 非退让流控 TCP在发生丢包时会将发送窗口减半,但KCP不做处理,这样对其他做传输的服务是不公平的,如果网络真的拥堵,KCP如此将导致网络里增加更多的未被收到的数据(更多的丢包),牺牲了带宽利用率

KCP拥有上述多个优点,但是使用KCP实现一个可靠的UDP还是需要一些工作量的。

在应用层通过kcp_send 发送数据,KCP 会把用户数据拆分 KCP 数据包,通过 kcp_output 再以 UDP 包的方式发送出去。具体细节上可以拆分为如下几步:

  1. 创建KCP对象,这里需要一个表示会话编号的整数conv,代表session的标识符,通信双方需要保证使用的标识符一致,这部分一般通过先在两端建立一个TCP连接,在这个TCP连接中商定好对应的conv之后,再创建对应的KCP对象 。创建的接口如下
	void* user;
	ikcpcb *kcp = ikcp_create(conv, user);

这里的user是一个void*,用来配合KCP的相关回调函数来使用。

  1. 设置发送回调函数,作为KCP下层协议的输出函数,KCP需要发送数据时会调用此函数。
// buf/len 表示缓存和长度。
// user 指针为 kcp 对象创建时传入的值,用于区别多个 KCP 对象。
int udp_output(const char *buf, int len, ikcpcb *kcp, void *user) {
		....
}
// 设置回调函数。
ikcp_setoutput(kcp, udp_output);
  1. 调用ikcp_send来发送数据,注意这里只是把数据放到KCP内部的发送缓冲区,不一定会触发之前设置好的发送回调
int ikcp_send(ikcpcb *kcp, const char *buffer, int len);
  1. 循环调用ikcp_update,来更新KCP的内部状态,检查是否需要发送或者超时重传。
ikcp_update(kcp, millisec);

在应用层通过底层网络库提供的UDP接收数据功能收到任意数据之后,调用ikcp_input将接收的数据拷贝到KCP的内部接收缓冲区,然后调用ikcp_recv来检查是否接收到一个应用层可以处理的包。

ikcp_input(kcp, received_udp_packet, received_udp_size);
int recv_packet_sz = ikcp_recv(ikcpcb *kcp, char *buffer, int len);
if(recv_packet_sz > 0)
{
	on_recv(buffer, recv_packet_sz);
}

特别值得注意的是KCP并没有考虑线程安全,所以应用层需要自己处理对同一个KCP对应的多线程互斥访问。对于KCP连接的客户端来说,这种互斥访问可以很方便的使用asio::strand来解决。但是对于使用KCP的监听服务器而言,它同时维护着多个客户端连接对应的KCP对象,不仅需要考虑每个单独的KCP对象的多线程互斥访问,同时还要考虑多个KCP的并发数据发送问题。因为监听服务器只有一个asio::udp::socket对象,不像之前的TCP服务器对于每个连接都构造一个asio::tcp::socket对象。同一个socket对象上的read, send, async_read, async_send都是不能并行化的,同时一个异步操作完成之前不能再发起同类型异步操作。对于KCP服务器而言主要要处理的就是多个KCP对象的异步发送。所以这里推荐使用一个带std::mutex的线程安全队列来存储所有的发送请求。


class kcp_acceptor
{
	std::unordered_map<asio::ip::udp::endpoint, std::shared_ptr<kcp_socket_wrapper>> m_client_connections;
	std::queue<std::pair<asio::ip::udp::endpoint, std::shared_ptr<std::string>>> m_send_queues;
	std::mutex m_send_queue_lock;
	std::shared_ptr<asio::ip::udp::socket> m_socket;

	// 对外暴露的数据发送接口
	void do_send(asio::ip::udp::endpoint send_to_endpoint, std::string_view data)
	{
		auto temp_data = std::make_shared<std::string>(data.data(),data.size());
		std::lock_guard<std::mutex> temp_lock(m_send_queue_lock);
		m_send_queues.push(std::make_pair(send_to_endpoint, temp_data));
		if (m_send_queues.size() == 1)
		{
			auto cur_front = m_send_queues.front();
			m_socket->async_send_to(asio::buffer(*cur_front.second), cur_front.first, [this](const std::error_code&, size_t)
				{
					after_send();
				});
		}
	}
	void after_send()
	{
		std::shared_ptr<kcp_socket_wrapper> temp_kcp_wrapper;
		{
			std::lock_guard<std::mutex> temp_lock(m_send_queue_lock);
			if (m_send_queues.empty())
			{
				return;
			}
			
			auto cur_front_endpoint = m_send_queues.front().first;
			auto temp_iter = m_client_connections.find(cur_front_endpoint);
			if (temp_iter != m_client_connections.end())
			{
				temp_kcp_wrapper = temp_iter->second;
			}
		}
		if(temp_kcp_wrapper)
		{
			temp_kcp_wrapper->check_write_finish();
		}
		{
			std::lock_guard<std::mutex> temp_lock(m_send_queue_lock);
			m_send_queues.pop();
			if (m_send_queues.empty())
			{
				return;
			}
			auto cur_front = m_send_queues.front();
			m_socket->async_send_to(asio::buffer(*cur_front.second), cur_front.first, [this](const std::error_code&, size_t)
				{
					after_send();
				});
		}
		
	}
}

连接层加密

客户端和服务器之间进行网络通信时,有权访问网络的人员可以监视所有流量并检查客户端和服务器之间发送或接收的数据。如果连接之间传递的数据都是明文,则居心叵测的人员可以通过监听连接流量的方式来获取用户登录的账号信息以及通过解析数据包的形式来发送恶意指令并获利。为了避免明文传输数据,使得传输的数据不可读,我们需要将连接进行加密。

加密又分为两种:对称加密和非对称加密。对称加密里的加密和解密key是一样的,而非对称加密则分为公钥和私钥之分,两个密钥的内容不同,公钥公开给其他使用当前加密服务的人员使用,私钥则自己存储,一份数据经过公钥加密后可以通过私钥解密,同样的经过私钥加密之后可以通过公钥解密。这种公钥分享系统也叫做PKIPublic Key Infrastructure

由于非对称加密的复杂度一般远远大于对称加密的复杂度, 所以实际使用时一般是首先通过公钥加密系统来握手,同时商定对称加密的key, 之后的处理都走对称加密流程。这样既确保了对称密钥的私密性,又加大了数据处理的速度。

非对称加密与对称加密混合使用流程

具体实现上我们可以使用openssl这个加密库来进行流量的加密传输,非对称加密系统采取RSA, 对称加密系统则采用AES,服务提供者将RSA生成的公钥发布到网络,生成的私钥保留在服务器。此时客户端与服务器之间的通信流程参考浏览器访问https网页的通信流程:

浏览器tls流程