一、实验目的

        通过本实验,学习采用Socket(套接字)设计简单的网络数据收发程序,理解应用数据包是如何通过传输层进行传送的。

二、实验内容

        Socket(套接字)是一种抽象层,应用程序通过它来发送和接收数据,就像应用程序打开一个文件句柄,将数据读写到稳定的存储器上一样。一个socket允许应用程序添加到网络中,并与处于同一个网络中的其他应用程序进行通信。一台计算机上的应用程序向socket写入的信息能够被另一台计算机上的另一个应用程序读取,反之亦然。

        不同类型的socket与不同类型的底层协议族以及同一协议族中的不同协议栈相关联。现在TCP/IP协议族中的主要socket类型为流套接字(sockets sockets)和数据报套接字(datagram sockets)。流套接字将TCP作为其端对端协议(底层使用IP协议),提供了一个可信赖的字节流服务。一个TCP/IP流套接字代表了TCP连接的一端。数据报套接字使用UDP协议(底层同样使用IP协议),提供了一个"尽力而为"(best-effort)的数据报服务,应用程序可以通过它发送最长65500字节的个人信息。一个TCP/IP套接字由一个互联网地址,一个端对端协议(TCP或UDP协议)以及一个端口号唯一确定。

2.1 采用TCP进行数据发送的简单程序

2.2 采用UDP进行数据发送的简单程序

2.3 多线程\线程池对比

2.4 一个简单的chat程序

三、实验步骤

        使用python语言进行网络数据收发程序的实现。在本机建立客户端和服务器程序,服务器IP使用127.0.0.1,服务器端口使用9999。

1.采用TCP进行数据发送的简单程序

(1)客户端

        客户端使用socket()和close()来创建和关闭套接字

        使用connect()向目标的地址和端口发出建立连接的请求

        建立连接成功之后就会进入recv()和send()中进行接收数据和发送数据的操作。

Python代码:

#!/usr/bin/env python

# -*- coding:utf-8 -*-

字符编码使用UTF-8编码。

import socket

导入Python标准库中的socket模块,其提供了网络通信的基本功能

ip_port = ('127.0.0.1',9999)

创建一个包含服务器IP地址和端口号的元组。

sk = socket.socket()

创建一个新的套接字对象sk:socket()是构造函数,默认参数是:socket(family=AF_INET, type=SOCK_STREAM,proto=0, fileno=None)

family: 指定套接字的地址族,常用的是 AF_INET(IPv4 地址)或 AF_INET6(IPv6 地址)。

type: 指定套接字的类型,常用的是 SOCK_STREAM(流套接字,用于TCP协议)或 SOCK_DGRAM(数据报套接字,用于UDP协议)。

proto: 指定协议,通常为0,表示使用默认协议。

fileno: 文件描述符,通常为None,表示不使用文件描述符

sk.connect(ip_port)

使用connect连接到指定服务器,即'127.0.0.1' 的 9999 端口

sk.sendall('请求占领地球'.encode())

使用sendall发送消息到服务器;字符串被编码为字节流,因为sendall接收字节数据;

sendall 和 send 的区别:

Send:返回值是实际发送的字节数。

适用于需要控制每次发送的数据量,并在发送完毕后检查是否所有数据都已发送成功

Sendall:一直尝试发送全部数据,直到全部发送完成或发生错误。返回值是 None。

适用于确保一次性将所有数据发送完毕,而不需要检查是否所有数据都已发送成功。

server_reply = sk.recv(1024)

使用recv从服务器接收消息,最多1024字节。

print (server_reply.decode())

将接收的字节解码为字符串后输出

sk.close()

关闭连接,释放资源

#socket client

(2)服务器端

        服务端使用 socket()创建套接字,通过bind()方法绑定端口,然后使用listen()对端口进行阻塞式地监听,等待客户端发来建立连接的请求。

        当接收到建立连接的请求时,使用accept()方法接受客户端的连接请求,此后进入recv()和send()不断进行接收数据和发送数据的操作。

        最后,使用close()关闭套接字终止程序,不过服务端程序一般不会主动进行关闭。

Python代码:

#!/usr/bin/env python

# -*- coding:utf-8 -*-

import socket

ip_port = ('127.0.0.1',9999)

sk = socket.socket()

导入模块socket;

创建元组ip_port,包括服务器的IP地址和端口号;

创建一个新的套接字对象sk

sk.bind(ip_port)

将套接字绑定到指定的IP地址和端口;

sk.listen(5)

设置为被动监听模式,最大连接数为5;

listen后的套接字sk只负责接收客户端连接请求,不能收发消息,收发消息使用返回新套接字conn来完成

while True:

print('server waiting...')

循环,持续监听客户端的连接

conn,addr = sk.accept()

等待客户端的连接请求,接收到一个元组:

返回一个新的套接字对象conn用于与客户端通信;addr中为客户端的IP地址和端口号

client_data = conn.recv(1024)

print(client_data.decode())

使用recv从客户端接收最多1024字节的消息,并输出

conn.sendall('不要回答,不要回答,不要回答'.encode())

使用sendall发送消息到客户端;字符串被编码为字节流

conn.close()

#socket server

关闭连接,释放资源

(3)运行结果

① 应当先运行服务器(反例)

② 正确运行结果

  • 服务器端-先运行等待

  • 客户端-连接通信

  • 服务器-处理回复

(4)发现问题与解决

问题
  • 客户端:只发送一条固定的信息;
  • 服务器端:也只能发送一条固定的信息。
②解决
  • 客户端:加入input,使得发送的信息可人为变动;while循环,一旦接收到‘exit’信息就主动退出。

  • 服务器端:也使用input,可人为变动响应的信息。

测试结果:
  • 客户端

  • 服务器端:

2.采用UDP进行数据发送的简单程序

(1)客户端

Python代码:

import socket

ip_port = ('127.0.0.1',9999)

sk=socket.socket(socket.AF_INET,

socket.SOCK_DGRAM,0)

与TCP不同,创建套接字sk的第二个参数为socket.SOCK_DGRAM,表示基于UDP的数据报式socket通信

while True:

    inp = input('数据:').strip()

    if inp == 'exit':

        break

.strip() 方法去除开头和结尾的指定字符,默认位空格字符。

sk.sendto(inp.encode(),ip_port)

print('client waiting……')

与TCP的.sendall('*'.encode())不同,UDP发送数据inp外,还需要带上ip_port(目的IP地址和目的端口)

    data,addr=sk.recvfrom(1024)

    print(str('接受响应信息为:'+data.decode()))

与TCP的.sk.recv(1024)不同,UDP使用recvfrom,其中参数都是要接受的字节大小,而后者接收数据外,还接收addr(发送放的IP地址和端口号)

sk.close()

#UDP Demo

(2)服务器端

Python代码:

import socket

ip_port = ('127.0.0.1',9999)

sk = socket.socket(socket.AF_INET,socket.SOCK_DGRAM,0)

sk.bind(ip_port)

while True:

    print('server waiting……')

    data,addr = sk.recvfrom(1024)

    print (str('接收到的客户端信息:'+data.decode()))

    message=input('输入服务器端要发送:')

    sk.sendto(message.encode(),addr)

sk.close()

(3) 运行结果

  • 客户端

  • 服务器端

(4)TCP与UDP

  • tcp中的bind:
    1. 服务器:需要绑定,否则客户端找不到这个服务器。
    2. 客户端:不绑定,因为是主动链接服务器,所以只要确定好服务器的ip、port等信息就好,本地客户端可以随机。
  • tcp中的accept与listen、close:
    1. 服务器中通过listen可以将socket创建出来的主动套接字变为被动的:当一个tcp客户端连接服务器时,服务器端会有1个新的套接字,这个套接字用来标记这个客户端,单独为这个客户端服务。关闭listen后的套接字意味着被动套接字关闭了,会导致新的客户端不能够链接服务器,但是之前已经链接成功的客户端正常通信。
    2. accept返回的新套接字是标记这个新客户端的。 关闭accept返回的套接字意味着这个客户端已经服务完毕。
    3. 客户端的套接字调用close后,服务器端会recv堵塞,并且返回的长度为0,因此服务器可以通过返回数据的长度来区别客户端是否已经下线。

3.多线程\线程池对比

(1)多线程

每次建立起一个新的连接时,就使用threading模块创建一个新的线程,向新的线程中传入该客户端套接字的信息,并保持通信,同时该线程需要通过thread.setDaemon(True)设置为守护主线程。

①客户端

import socket

import threading

ip_port=('127.0.0.1',9999)

def fun(x):

    sk=socket.socket()

    sk.connect(ip_port)

    print('客户端 '+str(x) +' 连接请求成功')

    sk.send(str('特种兵 '+str(x) +' 号请求占领').encode())

    print('客户端 '+str(x) + ' 发送信息成功')

    data=sk.recv(1024).decode()

    print('客户端 ' + str(x) + ' 接收响应信息:'+data)

    sk.close()

    print('客户端 ' + str(x) + ' 断开连接成功')

客户端编号为x;

创建客户端套接字;

连接指定服务器;

发送信息到服务器;

接收响应信息;

关闭连接;

for i in range(3):

thread=threading.Thread(target=fun,args=(i,))

    thread.start()

循环创建3个线程;

创建线程对象thread,指定目标函数为fun,传递参数为i

②服务器端

import socket

import threading

ip_port=('127.0.0.1',9999)

def fun(conn,addr):

    data=conn.recv(1024).decode()

    print('服务器接收信息来自:\t' + str(addr)+' ;内容为:'+data)

    conn.send('允许占领'.encode())

    print('服务器回复信息向:\t' + str(addr))

    conn.close()

    print('服务器与客户端:\t' + str(addr)+' 的连接关闭')

Conn-客户端套接字对象,addr-客户端地址

从客户端接收的信息;

向客户端回复信息;

关闭连接;

if __name__ == '__main__':

    sk=socket.socket()

    sk.bind(ip_port)

    sk.listen(2)

    print('server waiting……')

如果当前模块被直接运行,而非被导入,则执行下面的代码块;

让某些代码块只在当前模块被直接执行时运行,而在模块被导入时不运行。

while True:

    conn,addr=sk.accept()

print(f'客户端:\t\t{addr}连接成功')

thread=threading.Thread(target=fun,args=(conn,addr),daemon=True)

    thread.start()

循环,等待客户端连接

创建新线程对象,daemon设置为守护线程

③运行结果

  • 客户端:

  • 服务器端

(2)线程池

        使用 ThreadPoolExecutor 来实例化线程池对象。传入max_workers参数来设置线程池中最多能同时运行的线程数目。

        使用 submit 函数来提交线程需要执行的任务(函数名和参数)到线程池中,并返回该任务的抽象对象。submit() 不是阻塞的,而是立即返回。通过 submit 函数返回的任务抽象对象,能够使用其 done() 方法判断任务是否结束。

        通过ThreadPoolExecutor()创建了一个最大工作线程数为10的线程池。

        若将服务器端线程池最大工作线程数设置为比客户端请求线程总数小,由于线程池只会维护最大工作线程数的线程进行工作,因此,当线程池的线程已满时,后到的任务需要排队等待线程池对其进行工作调度。

客户端

        和多线程的客户端完成相同的功能,所以线程池的客户端代码与多线程的相同。

服务器端

import socket

from concurrent.futures import ThreadPoolExecutor

ip_port=('127.0.0.1',9999)

从 concurrent.futures 模块导入 ThreadPoolExecutor 类,用于创建线程池。

def fun(conn,addr):

    data=conn.recv(1024).decode()

    print('服务器接收信息来自:\t' + str(addr)+' ;内容为:'+data)

    conn.send('允许占领'.encode())

    print('服务器回复信息向:\t' + str(addr))

    conn.close()

    print('服务器与客户端:\t' + str(addr)+' 的连接关闭')

该部分与多线程方式相同

if __name__ == '__main__':

    sk=socket.socket()

    sk.bind(ip_port)

    sk.listen(128)

    print('server waiting……')

pool=ThreadPoolExecutor(max_workers=10)

创建一个具有最多 10 个工作线程的线程池。

while True:

    conn,addr=sk.accept()

    pool.submit(fun,conn,addr)

    print(f'客户端:\t\t{addr}连接成功')

使用线程池提交任务,将 fun 函数和参数 conn、addr 提交给线程池执行。

​​​​​​​③运行结果

  • 客户端:

  • 服务器:

(3)对比

  • 创建时刻:
    1. 线程池:在程序运行开始,创建好的n个线程,并且这n个线程挂起等待任务的到来。所以在效率上相对于多线程会高很多。
    2. 多线程:在任务到来时进行创建,然后执行任务。
  • 回收线程:
    1. 线程池:线程执行完之后不会回收线程,会继续将线程放在等待队列中;
    2. 多线程:程序在每次任务完成后会回收该线程。
  • 性能:
    1. 线程池也在高并发的情况下有着较好的性能;不容易挂掉。
    2. 多线程在创建线程数较多的情况下,很容易挂掉。

4.一个简单的chat程序(能互传文件)

(1)客户端

#!/usr/bin/env python

#coding:utf-8

import socket

import sys

import os

ip_port = ('127.0.0.1',9999)

sk = socket.socket()

sk.connect(ip_port)

container = {'key':'','data':''}

文件路径的剪切

while True:

    path = input('path:')

    file_name = os.path.basename(path)

    file_size=os.stat(path).st_size   Informf=(file_name+'|'+str(file_size))

    sk.send(Informf.encode())

    sk.recv(1024)

send_size = 0

    f= open(path,'rb')

    Flag = True

客户端输入要上传文件的路径;

根据路径获取文件名,查不到会报错退出;

获取文件大小;

先获取文件名和大小,并打包起来;

向服务端发送文件名和文件大小:

为了防止粘包,发送之后,等待服务端收到,直到从服务端接受一个信号(说明已经收到)

以读取二进制文件的形式打开路径下的文件;

 while Flag:

        if send_size + 1024 >file_size:

        data=f.read(file_size-send_size)

            Flag = False

        else:

            data = f.read(1024)

            send_size+=1024

        sk.send(data)

    f.close()

通过语句sk.recv(1024),指定了缓冲区的大小为1024字节,因此需要对于文件循环读取,直到文件的数据填满了一个缓冲区,此时将缓冲区数据发送出去,继续读取下一部分文件;或是当缓冲区未填满,而文件读取完毕,此时应当将这个未满的缓冲区发送给服务器。

sk.close()

#FTP上传文件(客户端)

​​​​​​​(2)服务器端

#!/usr/bin/env python

#coding:utf-8

import socketserver

import os   

class MyServer(socketserver.BaseRequestHandler):

    def handle(self):

        base_path = 'd:\\temp'

        conn = self.request

        print ('connected...')

 while True:

pre_data = conn.recv(1024).decode()

        file_name,file_size = pre_data.split('|')

                conn.sendall('nothing'.encode())           

        recv_size = 0

    file_dir=os.path.join(base_path,file_name)

        f = open(file_dir,'wb')

        Flag = True

服务器的解包:获取请求方法、文件名、文件大小

防止粘包:给客户端发送一个信号;

已经接收文件的大小;

上传文件路径拼接

while Flag:

        if int(file_size)>recv_size:

            data = conn.recv(1024)

            recv_size+=len(data)

            f.write(data)

        else:

            recv_size = 0

            Flag = False

未上传完毕:

最多接收1024,可能接收的小于1024

写入文件;

上传完毕,退出循环;

        print('upload successed.')

        f.close()

instance=socketserver.ThreadingTCPServer(('127.0.0.1',9999),MyServer)

instance.serve_forever()

#FTP上传文件(服务端)

​​​​​​​(3)运行结果

①测试发送文件:

  • 发送前:

  • 发送:​​​​​​​
    • ​​​​​​​客户端

  • 服务器端

  • 发送后

②测试接收文件

  • 客户端

  • 服务器端:

四、实验总结

        通过这次实验,我学习了通过socket编程实现tcp、udp的通信,还有多线程与线程池的设计,并了解了二者的区别。

        最后的chat程序设计较为复杂,发送文件与接收文件的编程还涉及文件读取等知识,但是文件存取的路径比较固定,这个部分还有待改进。

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐