如果Python中没有一个可以实现您想要的协议的模块,或者您想修改或扩展一个已经存在的Python模块的时候,本章的知识是非常有用的。

2.1 理解socket

socket是曹卓系统中I/O系统的延伸部分,它使进程和机器之间的通信成为可能。

一个socket允许您使用标准的操作系统和其他的计算机,以及您自己的机器上的不同进程来通信。

2.2 建立socket

对于一个客户端来说,建立一个socket需要两个步骤。首先您需要建立一个实际的socket对象。其次,您需要把它连接到远程服务器上。

在建立socket对象的时候,您需要告诉系统两件事情:通信类型和协议家族。通信类型指明了用什么协议来传输数据。协议家族则定义数据如何被传输。

本书中,大部分的通信协议是AF_INET(对应IPv4)。协议家族一般是表示TCP通信的SOCK_STREAM或表示UDP通信的SOCK_DGRAM。对于TCP通信,建立一个socket连接,一般用类似这样的代码:

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

连接socket,您一般需要提供一个tuple,它包含远程主机名或IP地址和远程端口。连接一个socket一般用类似这样的代码:

s.connect(("www.example.com", 80))

下面建立一个连接并马上终止。是一个具有完整功能的例子。

#!/usr/bin/env python3
# Basic Connection Example - Chapter 2 - connect.py

import socket

print("Creating socket.....")
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
print("build socket done")
print("Connecting to remote host...")
s.connect(("www.baidu.com", 80))
print("Connecion done.")

C语言的connect()函数需要远程机器的IP地址。在Python中,socket对象的connect函数会根据需要利用DNS把域名自动转换为IP地址。但是对端口号则不是这样。

2.2.1 寻找端口号

大多数操作系统都会附带提供一份已知服务器端口号的列表。Python的socket库包含一个getservbyname()函数,它可以自动查询。在UNIX系统中,可以在/etc/services目录下找到这个列表。

为了查询这个列表,您需要两个参数:协议名和端口名。下面是对前面程序的修改它使用端口名而不是端口号。

#!/usr/bin/env python3
# Basic Connection Example - Chapter 2 - connect.py

import socket

print("Creating socket.....")
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
print("build socket done")
print("Looking up port number...")
port = socket.getservbyname('http', 'tcp')
print("port number is found %s" % port)
print("Connecting to remote host...")
s.connect(("www.baidu.com", 80))
print("Connecion done.")

在这个例子中,用到了TCP,所以字符'tcp'被传送给socket.getservbyname()。如果使用UDP,您应该使用"udp"来代替。

2.2.2 从socket获取信息

一旦建立了一个socket连接,您就可以从它那里得到一些有用的信息。下面的例子演示了这些功能:

#!/usr/bin/env python3
# Information Example - Chapter 2 - connect3.py

import socket

print("Creating socket.....")
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
print("build socket done")
print("Looking up port number...")
port = socket.getservbyname('http', 'tcp')
print("port number is found %s" % port)
print("Connecting to remote host on port %d..." % port)
s.connect(("www.baidu.com", port))
print("Connecion done.")

print("Connection from", s.getsockname())
print("Connection to", s.getpeername())

getsockname()会返回您本身的IP地址和端口号,getpeername()会返回远程机器的IP地址和端口号。对客户端来说,端口号是操作系统分配的(也许是随机的),所以您也许会发现端口号每次都不一样。

2.3 利用socket通信

利用socket来发送和接收数据,Python提供了两种方法:socket对象和文件类对象。

socket对象提供了操作系统的send()、sendto()、recv()和recvfrom()调用的接口。文件类对象提供了read()、write()和readline()这些更典型的Python接口。

文件类对象一般只对TCP连接工作的很好,对UDP连接反而不是很好。

2.4 处理错误

在网络通信的时候,很多时候会产生不能忽略的错误。

2.4.1 socket异常

下面的例子演示了当处理socket对象时,如何捕获一个普通的异常。这个例子需要3个命令行的参数:一个是想要连接的主机名,一个是服务器上的端口号或名字,一个是想从服务器请求的文件。程序将连接上服务器,针对所请求的文件名字发送一个简单的HTTP请求,显示结果。在整个过程中,它将尝试处理各种类型潜在的错误。

#!/usr/bin/env python3
# Information Example - Chapter 2 - connect3.py

import socket,sys

host = sys.argv[1]
textport = sys.argv[2]
filename = sys.argv[3]
print("Creating socket.....")
try:
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
except socket.error as e:
    print("Strange error creating socket %s" % e)

#Try parsing it as a numberic port number
try:
    port = int(textport)
except ValueError:
    #That didn't work, so it's probably a protocol name
    #Look it up instead
    try:
        port = socket.getservbyname(textport, 'tcp')
    except socket.error as e:
        print("Couldn't find your port:%s" % e)
        sys.exit(1)
try:
    s.connect((host, port))
except socket.gaierror as e:
    print("Address-related error connecting to server:%s" % e)
    sys.exit(1)
except socket.error as e:
    print("Connection error:%s"%e)
    sys.exit(1)
try:
    s.sendall("GET %s HTTP/1.0\r\n\r\n" % filename)
except socket.error as e:
    print("Error sending data:%s" % e)
    sys.exit(1)
while True:
    try:
        buf = s.recv(2048)
    except socket.error as e:
        print("Error receiving data: %s" % e)
        sys.exit(1)
    if not len(buf):
        break
    sys.stdout.write(buf.decode())

Python的socket模块实际上定义了4中可能出现的异常:

  • 与一般I/O和通信问题有关的socket.error;
  • 与查询地址信息有关的socket.gaierror;
  • 与其他地址错误有关的socket.herror(和C语言中的h_errno相关)
  • 与在一个socket调用settimeout()后,处理超时有关的socket.timeout

注意connect()的调用。既然程序可以解决把主机名转换成IP地址的问题,您实际上会看到两种错误:如果主机名不对则会产生socket.gaierror,如果连接远程主机有问题则会产生socket.error。

2.4.2 遗漏的错误

这个程序中的错误处理有一个问题。有时候通信出了问题,但是却没有产生异常,因为没有从操作系统传回错误。

在客户端连接服务器写客户端请求的这段时间里,如果远程服务器断开连接,就会出现这种问题。在这个例子中,后面对recv的调用就接收不到数据(因为服务器已经关闭了连接),程序会成功终止。这是误解最多的地方。

对于很多操作系统来说,有时候在网络上发送数据的调用会在远程服务器确保已经收到信息之前返回。因此,很有可能一个来自对sendall()成功调用返回的数据,事实上永远都没有被收到。

为了解决这个问题,一旦结束写操作,您应该立刻调用shutdown()函数。这样就会强制清除缓存里面的内容,同时如果有任何问题就会产生异常。

接下来的例子扩展了前面的例子,并演示了一个使用shutdown()来确保服务器完全收到请求的简单方法。

#!/usr/bin/env python3
# Information Example - Chapter 2 - connect3.py

import socket, sys, time

host = sys.argv[1]
textport = sys.argv[2]
filename = sys.argv[3]
print("Creating socket.....")
try:
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
except socket.error as e:
    print("Strange error creating socket %s" % e)
    sys.exit(1)
# Try parsing it as a numberic port number
try:
    port = int(textport)
except ValueError:
    # That didn't work, so it's probably a protocol name
    # Look it up instead
    try:
        port = socket.getservbyname(textport, 'tcp')
    except socket.error as e:
        print("Couldn't find your port:%s" % e)
        sys.exit(1)
try:
    s.connect((host, port))
except socket.gaierror as e:
    print("Address-related error connecting to server:%s" % e)
    sys.exit(1)
except socket.error as e:
    print("Connection error:%s" % e)
    sys.exit(1)
print("sleeping......")

try:
    s.sendall("GET %s HTTP/1.0\r\n\r\n" % filename)
except socket.error as e:
    print("Error sending data:%s" % e)
    sys.exit(1)

try:
    s.shutdown(1)
except socket.error as e:
    print("Error sending data (detected by shutdown):%s" % e)
    sys.exit(1)
while True:
    try:
        buf = s.recv(2048)
    except socket.error as e:
        print("Error receiving data: %s" % e)
        sys.exit(1)
    if not len(buf):
        break
    sys.stdout.write(buf.decode())

即使您一直在为write()产生的异常准备解决方法,事实上在您的测试当中,这些异常都是由shutdown()引起的,因为有些操作系统是在不同的时间产生异常。sendall()函数立刻返回,但是shutdown()会一直等待,直到它能给您返回一个精确的退出代码。

请牢记,数据只有在您调用了shutdown()函数之后才能确保被发送。

2.4.3 文件类对象引起的错误

makefile()函数可以从socket得到一个文件类对象。实际上,这个文件类对象调用实际的socket,所以由文件类对象产生的异常和socket自己的send()和recv()函数产生的是一样的。

#!/usr/bin/env python3
# Error Handling Example With Shutdown and File-like objects --Chapter 2
# shutdownfile.py

import socket, sys, time

host = sys.argv[1]
textport = sys.argv[2]
filename = sys.argv[3]
print("Creating socket.....")
try:
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
except socket.error as e:
    print("Strange error creating socket %s" % e)
    sys.exit(1)
# Try parsing it as a numberic port number
try:
    port = int(textport)
except ValueError:
    # That didn't work, so it's probably a protocol name
    # Look it up instead
    try:
        port = socket.getservbyname(textport, 'tcp')
    except socket.error as e:
        print("Couldn't find your port:%s" % e)
        sys.exit(1)
try:
    s.connect((host, port))
except socket.gaierror as e:
    print("Address-related error connecting to server:%s" % e)
    sys.exit(1)
except socket.error as e:
    print("Connection error:%s" % e)
    sys.exit(1)
fd = s.makefile('rwb', 0)
print("sleeping")
time.sleep(10)
print("Continuing")

try:
    fd.write("GET %s HTTP/1.0\r\n\r\n" % filename)
except socket.error as e:
    print("Error sending data:%s" % e)
    sys.exit(1)

try:
    fd.flush()
except socket.error as e:
    print("Error sending data(detected by flush):%s" % e)
    sys.exit(1)

try:
    s.shutdown(1)
    s.close()
except socket.error as e:
    print("Error sending data(detected by shutdown):%s" % e)
    sys.exit(1)

while True:
    try:
        buf = fd.read(2048)
    except socket.error as e:
        print("Error recerving data:%s" % e)
        sys.exit(1)
    if not len(buf):
        break
    sys.stdout.write(buf)

这里有两点需要指出。首先,请注意对flush()的调用。从技术层面上来看,因为对makefile()的调用聪明的没有指定缓冲器(buffer),所以这个调用不是必须的,但是如果您因为某些原因而使用了缓冲器,则需要调用。因为它会在下面调用send(),而且还会产生异常。其次,需要注意的是,即使是调用了makefile(),也要保存socket对象的代码。makefile()返回的对象并不提供一个对shutdown()的调用,所以您必须保存原始的socket对象并使用它。

2.5 使用UDP

UDP通信几乎不使用文件类对象,因为它们往往不能为数据如何发送和接受提供足够的控制。让我们来介绍一个基本的UDP客户端:

# -*- coding:utf-8 -*-
#UDP Example - Chapter 2 - udp.py

import socket, sys

host = sys.argv[1]
textport = sys.argv[2]

s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
    port = int(textport)
except ValueError:
    #That didn't work.Look it up instead.
    port = socket.getservbyname(textport, 'udp')

s.connect((host, port))
print("Enter data to transmit:")
data = sys.stdin.readline().strip()
s.sendall(data)
print("Looking for replies;press Ctrl-C or Ctrl-Break to stop.")
while True:
    buf = s.recv(2048)
    if not len(buf):
        break
    sys.stdout.write(buf)

这段程序发送一个UDP信息包,接受一个UDP信息包,并继续等待其他的。最后,它被Ctrl-C终止,这导致了KeyboardInterrupt。

让我们来看看它和TCP客户端的区别:

  • 请注意当socket被建立的时候,程序调用的是SOCK_DGRAM,而不是SOCK_STREAM;
  • 对socket.getservbyname()的调用寻找的是UDP端口号,而不是TCP的。一个端口号对协议来说是特殊的,所以即使TCP使用119端口,一个完全不同的UDP应用程序也可以使用同一个端口。
  • 程序没有办法探测到服务器什么时候发送完数据。这是因为其实这里没有实际的连接。对connect()的调用只是初始化了一些内在参数。同时,服务器也许不会返回任何数据,或者数据也许在传输过程中丢失,程序并没有智能地判断出这个问题。因此,当您结束等待传来的信息包时,您必须按下Ctrl-C。

有时,使用UDP可以根本不用调用connect()。这里有个示范:

# -*- coding:utf-8 -*-
# UDP Connectionless Example - Chapter 2 - udptime.py

import socket, sys, struct, time

hostname = 'time.nist.gov'
port = 37

host = socket.gethostbyname(hostname)
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.sendto('', (host, port))
print("Looking for replies;press Ctrl-C to stop.")
buf = s.recvfrom(2048)[0]
if len(buf) != 4:
    print("Wrong-sized reply %d:%s" % (len(buf), buf))
    sys.exit(1)

secs = struct.unpack("!I", buf)[0]
secs -= 2208988800
print(time.ctime(int(secs)))

这个程序是一个RFC868上定义的简单时间协议的示范。通过调用sendto(),程序向time.nist.gov上的服务器发送了一个空字符串。注意这里调用connect()。