使用UDP进行windows平台下的socket网络编程,本人在vs 2019编译器上开发,语言C++,希望能为大家提供一些参考。
首先建立两个项目,分别称之为客户端和服务端,唯一的区别是服务端需要绑定IP和端口,客户端指明服务端的IP和端口,这样就可以通信啦。
首先是写一个简单的客户端发送字符串到服务端的程序,因为比较简单,直接上代码,其中的注释很详细,有不明白的小伙伴可以评论区或者私信~
字符串收发
UDP_Client.cpp
#include <stdio.h>
#include <tchar.h>
#include <iostream>
#include <WinSock2.h>
#include <Windows.h>
#include <WS2tcpip.h>
#include <string>
using namespace std;
#pragma comment(lib, "ws2_32.lib")
int _tmain1(int argc, _TCHAR* argv[])
{
cout << "hello world" << endl;
WSAData wsd; //初始化信息
SOCKET soSend; //发送到的目的SOCKET
int nRet = 0;
int dwSendSize = 0;
const int SIZEOFBUF = 65500;
char recvBuf[SIZEOFBUF];
SOCKADDR_IN serverAddr{}; //服务器socket地址
//启动Winsock
if (WSAStartup(MAKEWORD(2, 2), &wsd) != 0) {/*进行WinSocket的初始化,
windows 初始化socket网络库,申请2,2的版本,windows socket编程必须先初始化。*/
cout << "WSAStartup Error = " << WSAGetLastError() << endl;
return 0;
}
else {
cout << "WSAStartup Success" << endl;
}
//创建socket
//AF_INET 协议族:决定了要用ipv4地址(32位的)与端口号(16位的)的组合
//SOCK_DGRAM -- UDP类型,不保证数据接收的顺序,非可靠连接;
soSend = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
if (soSend == SOCKET_ERROR) {
cout << "socket Error = " << WSAGetLastError() << endl;
return 1;
}
else {
cout << "socket Success" << endl;
}
//设置端口号
int nPort = 5150; // 服务器的端口号
serverAddr.sin_family = AF_INET;
serverAddr.sin_port = htons(nPort);
//serverAddr.sin_addr.s_addr = inet_addr("127.0.0.1");
inet_pton(AF_INET, "127.0.0.1", (void*)&serverAddr.sin_addr.s_addr);
/*
for (int i = 0; i < 30; i++) {
//开始发送数据
//发送数据到指定的IP地址和端口
string* desc = new string("123 mutouren");
const char* cc = (desc->append(to_string(i).c_str())).c_str();
nRet = sendto(soSend, cc, strlen(cc), 0, (SOCKADDR*)&serverAddr, sizeof(SOCKADDR));
if (nRet == SOCKET_ERROR || nRet < 0) {
cout << "sendto Error " << WSAGetLastError() << endl;
break;
}
else {
cout << "sendto Success!!" << nRet << endl;
}
}
*/
while (1) {
// 不断输入一些字符串,回车发送
printf("input: ");
scanf_s("%s", recvBuf, sizeof(recvBuf));
//发送数据到指定的IP地址和端口
nRet = sendto(soSend, recvBuf, strlen(recvBuf)+1, 0, (SOCKADDR*)&serverAddr, sizeof(SOCKADDR));
if (nRet == SOCKET_ERROR || nRet < 0) {
cout << "sendto Error " << WSAGetLastError() << endl;
break;
}
else {
cout << "sendto Success!!" << nRet << endl;
}
}
//关闭socket连接
closesocket(soSend);
//清理
WSACleanup();
return 0;
}
服务端的功能也很简单,就是使用while不断等待数据到来复制到本地分配的地址空间中,然后解析客户端的IP地址、端口号、和字符串信息。
UDP_Server.cpp
#include<stdio.h>
#include<tchar.h>
#include <iostream>
#include <WinSock2.h>
#include <Windows.h>
#include <WS2tcpip.h>
using namespace std;
#pragma comment(lib, "ws2_32.lib")
int _tmain1(int argc, _TCHAR* argv[])//_tmain,要加#include <tchar.h>才能用
{
WSAData wsd; //初始化信息
SOCKET soRecv; //接收的SOCKET
char* pszRecv = NULL; //接收数据的数据缓冲区指针
int nRet = 0; //接收数据大小
int dwSendSize = 0;
// MTU以太网数据帧的长度在46-1500字节之间,1500:链路层的最大传输单元
// 单个UDP传输的最大内容为1472字节(1500-20-8)
// 网络中标准UDP值为576字节,最好在编程中将数据长度控制在548字节以内
int SIZEOFBUF = 65536; //接收数组大小
int nPort = 5150; //设置本机服务的端口号
SOCKADDR_IN siRemote{}, siLocal{}; //远程发送机地址和本机接收机地址
//启动Winsock
if (WSAStartup(MAKEWORD(2, 2), &wsd) != 0) {
cout << "WSAStartup Error = " << WSAGetLastError() << endl;
return 0;
}
else {
cout << "start Success" << endl;
}
siLocal.sin_family = AF_INET;
siLocal.sin_port = htons(nPort);
//siLocal.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");
//和上一行一样
inet_pton(AF_INET, "127.0.0.1", (void*)&siLocal.sin_addr.s_addr);
//创建socket
soRecv = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
if (soRecv == SOCKET_ERROR) {
cout << "socket Error = " << WSAGetLastError() << endl;
closesocket(soRecv);
WSACleanup();
return 1;
}
else {
cout << "socket Success" << endl;
}
char on = 1;
// 设置端口复用
setsockopt(soRecv, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
// 绑定本地地址到socket
if (bind(soRecv, (SOCKADDR*)&siLocal, sizeof(siLocal)) == SOCKET_ERROR) {
cout << "bind Error = " << WSAGetLastError() << endl;
closesocket(soRecv);
WSACleanup();
return 0;
}
else {
cout << "bind Success" << endl;
}
//申请内存
pszRecv = new char[SIZEOFBUF];
if (pszRecv == NULL) {
cout << "pszRecv new char Error " << endl;
return 0;
}
else {
cout << "pszRecv new char Success" << endl;
}
// 一直等待数据
while(true){
dwSendSize = sizeof(siRemote);
cout << "...开始等待数据..." << endl;
memset(pszRecv, 0, SIZEOFBUF);
//开始接受数据
nRet = recvfrom(soRecv, pszRecv, SIZEOFBUF, 0, (SOCKADDR*)&siRemote, &dwSendSize);
if (nRet == SOCKET_ERROR) {
cout << "recvfrom Error " << WSAGetLastError() << endl;
continue;
}
else if (nRet == 0) {
cout << "recvfrom Error " << WSAGetLastError() << endl;
continue;
}
else {
pszRecv[nRet] = '\0';
char sendBuf[20] = { '\0' };
inet_ntop(AF_INET, (void*)&siRemote.sin_addr, sendBuf, 16);
cout << "收到数据大小: " << nRet << " IP地址: " << sendBuf << " 端口号: " << siRemote.sin_port << " 数据: " << pszRecv << endl;
}
}
//关闭socket连接
closesocket(soRecv);
delete[] pszRecv;
//清理
WSACleanup();
system("pause");
return 0;
}
运行示例
文件上传
接下来使用UDP进行客户端和服务端之间的文件上传。可以支持各种文件类型,包括txt等文本文件,jpg等图像文件,mov等视频文件。
使用流程很简单,我们先后启动服务端和客户端,然后在客户端输入一个文件名(完整绝对路径或者和客户端代码同文件夹下的文件名),回车,就可以把存在于客户端上的该文件上传到服务器当前目录中去。类似一般的文件上传,如果带有GUI的话,操作就变为弹出窗口,用户点击选择文件,然后点击上传啦。
首先看一下服务端,在服务端一共开启两个线程,主线程首先接收客户端发来的hello消息,用来解析记录客户端的信息,接着while循环一直等待用户输入字符串,然后发往客户端去。另外一个线程用来循环等待recv接收客户端发来的消息,通过检测每次收到的数据包的前四个字节,判断id是1还是2,如果是1代表接收的是文件数据,如果是2代表接受的是文件名和文件大小。我们设计的逻辑是,客户端在发送文件数据之前,先发送一个存有id为2、文件名、文件大小的数据包,接着将文件数据按照固定1024个字节依次发送给服务端。
我们知道UDP是基于数据报的协议,不可靠不保证数据的有序性,那么我们怎么把乱序的数据合成完整的文件呢?这里我们自己设置索引进行排序,只要保证发送端按照顺序向后读取字节,服务端即使收到乱序的数据包,也能按照索引恢复。
服务端首先收到文件信息,接着根据文件大小创建内存空间,根据文件名新建文件,等待数据的接收操作。等客户端发送的数据到来之后(id为1),就把数据按照index索引存放到正确的位置。如何判断文件是否接收完毕呢?我们使用变量receivedlen记录已经接收的数据大小,当此变量和文件大小相等的时候,我们把内存数据写入到事先新建的文件中去。
UDP_Server2.cpp
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<stdio.h>
#include<tchar.h>
#include <iostream>
#include <WinSock2.h>
#include <Windows.h>
#include <WS2tcpip.h>
using namespace std;
struct FileData {
int id; // 用于服务端接受发送文件的类型
int index; // 卡车的索引
char filedata[1024]; // 卡车的数据
};
SOCKET soRecv; //接收的SOCKET
DWORD WINAPI ThreadFunc(LPVOID p);
void startServer(SOCKET soRecv);
BOOL WINAPI CtrlFun(DWORD dwType);
BOOL WINAPI CtrlFun(DWORD dwType) {
switch (dwType)
{
case CTRL_CLOSE_EVENT:
printf("关闭socket,退出");
closesocket(soRecv);
WSACleanup();
default:
break;
}
return FALSE;
}
int main() {
SetConsoleCtrlHandler(CtrlFun, TRUE);
WSAData wsd; //初始化信息
// MTU以太网数据帧的长度在46-1500字节之间,1500:链路层的最大传输单元
// 单个UDP传输的最大内容为1472字节(1500-20-8)
// 网络中标准UDP值为576字节,最好在编程中将数据长度控制在548字节以内
int SIZEOFBUF = 65536; //接收数组大小
int nPort = 5150; //设置本机服务的端口号
SOCKADDR_IN siLocal{}; //远程发送机地址和本机接收机地址
//启动Winsock
if (WSAStartup(MAKEWORD(2, 2), &wsd) != 0) {
cout << "WSAStartup Error = " << WSAGetLastError() << endl;
return 0;
}
else {
cout << "开始winsock成功" << endl;
}
//创建socket
soRecv = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
if (soRecv == SOCKET_ERROR) {
cout << "socket创建失败 = " << WSAGetLastError() << endl;
closesocket(soRecv);
WSACleanup();
return 1;
}
else {
cout << "socket创建成功" << endl;
}
char on = 1;
// 设置端口复用
setsockopt(soRecv, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
int nRecvBuf = 4 * 1024 * 1024;//设置为4M
setsockopt(soRecv, SOL_SOCKET, SO_RCVBUF, (const char*)&nRecvBuf, sizeof(int));
//设置为非阻塞模式
/*
int imode = 1;
int rev = ioctlsocket(soRecv, FIONBIO, (u_long*)&imode);
if (rev == SOCKET_ERROR)
{
printf("ioctlsocket failed!");
closesocket(soRecv);
WSACleanup();
return -1;
}
*/
siLocal.sin_family = AF_INET;
siLocal.sin_port = htons(nPort);
//siLocal.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");
//和上一行一样
inet_pton(AF_INET, "127.0.0.1", (void*)&siLocal.sin_addr.s_addr);
//绑定本地地址到socket
if (bind(soRecv, (SOCKADDR*)&siLocal, sizeof(siLocal)) == SOCKET_ERROR) {
cout << "绑定错误 = " << WSAGetLastError() << endl;
closesocket(soRecv);
WSACleanup();
return 0;
}
else {
cout << "绑定成功" << endl;
}
startServer(soRecv);
return 0;
}
void startServer(SOCKET soRecv) {
char recvbuf[1024] = { 0 };
SOCKADDR_IN siClient{};
memset(recvbuf, 0, sizeof(recvbuf));
int cli_addr_size = sizeof(siClient);
int bytes = 0;
// 设置非阻塞,这句话就会越过去
// 开始接受数据,并把客户端信息填入到siClient中,下面的sendTo才能正确发送
bytes = recvfrom(soRecv, recvbuf, sizeof(recvbuf), 0, (SOCKADDR*)&siClient, &cli_addr_size);
if (bytes < 0) perror("recvFrom error");
printf("客户端对服务器说: %s\n", recvbuf);
// 创建一个线程
HANDLE hThread;
DWORD threadId;
hThread = CreateThread(NULL, 0, ThreadFunc, (LPVOID)soRecv, 0, &threadId); // 创建线程
char sendbuf[1024] = { 0 };
while (1) {
memset(sendbuf, 0, sizeof(sendbuf));
scanf_s("%s", sendbuf, sizeof(sendbuf));
bytes = sendto(soRecv, sendbuf, strlen(sendbuf)+1, 0, (SOCKADDR*)&siClient, sizeof(SOCKADDR));
if (bytes == SOCKET_ERROR || bytes < 0) {
cout << "sendto Error " << WSAGetLastError() << endl;
break;
}
else {
cout << "sendto Success!!大小:" << bytes << endl;
}
}
}
DWORD WINAPI ThreadFunc(LPVOID p)
{
int soRecv = (int)p;
printf("服务端等待接收数据\n");
char recvbuf[1400] = { 0 };
int bytes = 0; //接收数据大小
int dwSendSize = 0;
// 文件句柄
FILE* openfd = nullptr;
SOCKADDR_IN siRemote{};
// 文件数据的地址空间
char* pRecvData = NULL; //接收数据的数据缓冲区指针
fd_set rfd; // 描述符集 这个将用来测试有没有一个可用的连接
struct timeval timeout;
timeout.tv_sec = 0; //等下select用到这个
timeout.tv_usec = 0; //timeout设置为0,可以理解为非阻塞
int selectRcv;
while (1) {
// UDP数据接收
/*
FD_ZERO(&rfd); //总是这样先清空一个描述符集
FD_SET(soRecv, &rfd); //把sock放入要测试的描述符集
selectRcv = select(soRecv + 1, &rfd, 0, 0, &timeout); //检查该套接字是否可读
if (selectRcv < 0)
cout << "服务端监听失败" << GetLastError() << endl;
if (selectRcv > 0)
{
*/
memset(recvbuf, 0, sizeof(recvbuf));
dwSendSize = sizeof(siRemote);
//开始接受数据
bytes = recvfrom(soRecv, recvbuf, sizeof(recvbuf), 0, (SOCKADDR*)&siRemote, &dwSendSize);
if (bytes == SOCKET_ERROR || bytes == 0) {
cout << "recvfrom Error " << WSAGetLastError() << endl;
continue;
}
else if (bytes == 9) {
cout << "服务端接收到: " << recvbuf << endl;
continue;
}
else {
//recvbuf[bytes] = '\0';
//char sendBuf[20] = { '\0' };
//inet_ntop(AF_INET, (void*)&siRemote.sin_addr, sendBuf, 16);
//cout << "收到数据大小: " << bytes << " IP地址: " << sendBuf << " 端口号: " << siRemote.sin_port << " 数据: " << recvbuf << endl;
}
// 接收到文件名
char filename[64] = { 0 };
// 接收到文件大小
static int filesize = 0;
// 已经接收的文件大小
static int receivedlen = 0;
int id = *(int*)recvbuf;
//printf("拆分id: %d\n", id);
switch (id)
{
case 1: // 运送数据
{
FileData* Fdd = (FileData*)recvbuf;
//printf("Fdd->id: %d Fdd->index: %d\n", Fdd->id, Fdd->index);
// 将卡车的物品放到指定位置
memcpy(pRecvData + (Fdd->index) * 1024, Fdd->filedata, bytes - sizeof(int) * 2);
receivedlen += bytes - sizeof(int) * 2;
}
break;
case 2: // 运送文件的大小和文件名
{
memcpy(&filesize, recvbuf + sizeof(int), sizeof(int));
strncpy_s(filename, recvbuf + sizeof(int) * 2, 256);
pRecvData = (char*)malloc(sizeof(char) * filesize);
// 清空堆空间的数据
//memset(pRecvData, 0, sizeof(pRecvData));
printf("客户端发送的文件信息: 文件名称: %s, 文件大小: %d\n", filename, filesize);
fopen_s(&openfd, filename, "wb");
if (openfd == NULL) perror("create error");
}
break;
default:
printf("服务端接收错误");
break;
}
// 当文件接受完了,需要关闭文件
if (receivedlen == filesize && receivedlen != 0) {
// 数据接受完毕
printf("服务端数据接受完毕\n");
// 讲所有数据写入文件
fwrite(pRecvData, sizeof(char), receivedlen, openfd);
// 文件接受完了需要关闭文件
fclose(openfd);
receivedlen = 0;
}
/*}
else {
//返回值为0,表示超时
//cout << "超时" << endl;
}
*/
}
free(pRecvData);
pRecvData = NULL;
}
客户端的逻辑也相似,同样是开两个线程,主线程用来发送文件数据,另一个线程用来接收服务端反馈的消息。
客户端需要先给服务器发送一个hello消息,以便让服务端解析出这个客户端。程序开始后命令行等待用户输入文件名,先计算文件大小和拼接文件名和后缀,然后发送第一个数据包,里面是id为2,文件名,和文件大小。接着读取用户选择上传的那个文件,按照字节顺序发送,每次1032个字节,直到文件末尾。
UDP_Client.cpp
#include <stdio.h>
#include <tchar.h>
#include <iostream>
#include <WinSock2.h>
#include <Windows.h>
#include <WS2tcpip.h>
#include <string>
using namespace std;
const int SIZEOFBUF = 1024;
int getFileLength(const char* filename);
DWORD WINAPI ThreadFunc(LPVOID p);
int sock_init();
struct FileData {
int id; // 用于服务端接受发送文件的类型
int index; // 卡车的索引
char filedata[SIZEOFBUF]; // 卡车的数据
};
FileData Fdd;
#pragma comment(lib, "ws2_32.lib")
int _tmain(int argc, _TCHAR* argv[])
{
int soSend = sock_init();
//关闭socket连接
closesocket(soSend);
//清理
WSACleanup();
return 0;
}
int sock_init() {
cout << "hello world" << endl;
WSAData wsd; //初始化信息
SOCKET soSend; //发送到的目的SOCKET
int nRet = 0;
int dwSendSize = 0;
//char recvBuf[SIZEOFBUF];
SOCKADDR_IN serverAddr{}; //服务器socket地址
//启动Winsock
if (WSAStartup(MAKEWORD(2, 2), &wsd) != 0) {/*进行WinSocket的初始化,
windows 初始化socket网络库,申请2,2的版本,windows socket编程必须先初始化。*/
cout << "WSAStartup Error = " << WSAGetLastError() << endl;
return 0;
}
else {
cout << "WSAStartup启动成功" << endl;
}
//创建socket
//AF_INET 协议族:决定了要用ipv4地址(32位的)与端口号(16位的)的组合
//SOCK_DGRAM -- UDP类型,不保证数据接收的顺序,非可靠连接;
soSend = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
if (soSend == SOCKET_ERROR) {
cout << "socket Error = " << WSAGetLastError() << endl;
return 1;
}
else {
cout << "socket新建成功" << endl;
}
//设置端口号
int nPort = 5150; // 服务器的端口号
serverAddr.sin_family = AF_INET;
serverAddr.sin_port = htons(nPort);
//serverAddr.sin_addr.s_addr = inet_addr("127.0.0.1");
inet_pton(AF_INET, "127.0.0.1", (void*)&serverAddr.sin_addr.s_addr);
// 针对丢包问题,要么减小流量,要么换tcp协议传输,要么做丢包重传的工作
int nRecvBuf = 4 * 1024 * 1024;//设置为32K
setsockopt(soSend, SOL_SOCKET, SO_SNDBUF, (const char*)&nRecvBuf, sizeof(int));
// 服务器端设置非阻塞接收,下面开启了接收线程,如果不先sendto,下面的接收函数里面的recvFrom就一直非阻塞跳出
sendto(soSend, "say hello", strlen("say hello"), 0, (SOCKADDR*)&serverAddr, sizeof(SOCKADDR));
// 创建一个线程,创建完了之后,线程函数开始执行,且与主线程同时执行
HANDLE hThread;
DWORD threadId;
hThread = CreateThread(NULL, 0, ThreadFunc, (LPVOID)soSend, 0, &threadId); // 创建线程
// 发送数据
char filename[256] = { 0 };
char fileinfo[1024] = { 0 };
int filesize = 0;
int fileId = 2;
while (1) {
printf("\ninput: ");
scanf_s("%s", filename, sizeof(filename));
filesize = getFileLength(filename);
printf("即将发送的文件大小%d\n", filesize);
if (filesize == -1) break;
fileId = 2;
// 内存拷贝,将id拷贝到缓冲区的前4个字节
memcpy(fileinfo, &fileId, sizeof(int));
// 内存拷贝,将文件大小拷贝到缓冲区的前4个字节之后
memcpy(fileinfo + sizeof(int), &filesize, sizeof(int));
char drive[5];
char dir[100];
char tfileName[100];
char suffix[10];
_splitpath_s(filename, drive, dir, tfileName, suffix);
strcat_s(tfileName, suffix);
printf("发送服务端的文件名拼为:%s\n", tfileName);
// 将文件的名称放到缓冲区
memcpy(fileinfo + sizeof(int) * 2, tfileName, strlen(tfileName) + 1);
// 将文件id,大小和文件名称一起发送给服务器
int ret = sendto(soSend, fileinfo, sizeof(int) * 2 + strlen(tfileName)+1, 0, (SOCKADDR*)&serverAddr, sizeof(SOCKADDR));
printf("文件名%s已发送, 大小:%d\n", tfileName, ret);
// 发送文件中的数据
FILE* pFile;
fopen_s(&pFile, filename, "rb");
if (pFile == NULL) {
perror("打开文件错误");
return soSend;
}
int bytes = 0;
int index = 0;
while ((bytes = fread_s(Fdd.filedata, sizeof(Fdd.filedata), sizeof(char), sizeof(Fdd.filedata), pFile)) != 0)
{
//printf("读取字节数bytes:%d\n", bytes);
// 卡车的类型
Fdd.id = 1;
// 卡车索引号
Fdd.index = index++;
//发送数据到指定的IP地址和端口
nRet = sendto(soSend, (char*)&Fdd, bytes+sizeof(int)*2, 0, (SOCKADDR*)&serverAddr, sizeof(SOCKADDR));
if (nRet == SOCKET_ERROR || nRet < 0) {
cout << "发送数据错误" << WSAGetLastError() << endl;
break;
}
else {
//cout << "发送成功,数据大小" << nRet << endl;
}
Sleep(5);
}
fclose(pFile);
printf("客户端文件发送完毕\n");
}
return soSend;
}
int getFileLength(const char* filename) {
FILE* fd;
fopen_s(&fd, filename, "r");
if (fd == NULL) {
perror("打开文件名失败: ");
return -1;
}
// 获取文件长度
struct stat statbuf;
stat(filename, &statbuf);
int size = statbuf.st_size;
fclose(fd);
return size;
}
//获取文件大小
int filelength(FILE* fp)
{
int num;
fseek(fp, 0, SEEK_END);
num = ftell(fp);
fseek(fp, 0, SEEK_SET);
return num;
}
DWORD WINAPI ThreadFunc(LPVOID p)
{
int sockfd = (int)p;
printf("我是子线程, pid = %d\n", GetCurrentThreadId()); //输出子线程pid
char recvBuf[SIZEOFBUF] = { 0 };
while (1) {
recvfrom(sockfd, recvBuf, SIZEOFBUF, 0, NULL, 0);
printf("服务器对客户端说%s\n", recvBuf);
}
return 0;
}
运行示例
当开多个客户端的时候,同样可以上传文件,但是服务端返回信息的时候,只有第一次连接到服务端的客户端能收到消息。这是因为只有在第一次接收到客户端的hello信息的时候siClient才内赋值,后面就只会往这个客户端地址发消息。
至此,此次使用udp进行简单操作的实践完毕,接下来可能还会深入研究加入线程池、保证不丢包、超时重传等功能,如果有感兴趣的小伙伴可以找我共同学习交流。