Pythonによるネットワークプログラミングの初歩


Pythonによるネットワークプログラミングの初歩についてまとめました。
本内容は具体的な実装方法の内容となっており、前提となるTCP/IPなどの
ネットワークの基礎には触れませんのでご注意ください。
また、プログラミング自体が初学者なので、python言語の文法等については
忘備録も兼ねて必要だと思われる点に関しては説明しています。

初めに低レベルのソケット通信に必要なsocketモジュールを解説した後、
ソケットプログラミングの実例をいくつか紹介します。
最終的に簡単なサーバ・クライアント間のTPC通信の実装を目標とします。 動作環境:Ubuntu 22.04.2 LTS / Python 3.10.12

1. socketモジュールとは

本モジュールはソケットインターフェイスへのアクセスを提供します。
いくつかの挙動はオペレーティングシステムのソケットAPIを呼び出しているためプラットフォームに依存します。
ソケットオブジェクトを作成するにはsocketメソッドを用います。

sock = socket.socket (socket_family, socket_type)

socket_familyには以下のものが含まれます。

  • AF_UNIX: Unixドメインソケット通信

  • AF_INET: IPv4インターネットプロトコル

  • AF_INET6: IPv6 インターネットプロトコル

socket_typeには以下のものが含まれます。

  • SOCK_STREAM: TCPソケット

  • SOCK_DGRAM: UDPソケット

socketモジュールについて詳しく知りたい方は下記のオンラインドキュメントとソースコードを参照してください。

https://docs.python.org/ja/3.13/library/socket.html#

https://github.com/python/cpython/blob/3.13/Lib/socket.py

[モジュールとは?]
構成する文や定義を拡張子が.pyというファイルにまとめたもので
他のファイルから利用するにはimport文を使います。
import文によって新しい名前空間が作成され、インポートしたファイルに
関連するすべての文をその名前空間上で実行します。

インポート後にその名前空間を参照するにはsocket.socket()のように
接頭辞としてモジュール名をつけます。import文でモジュールをインポートするとき、Pythonはsysモジュールのpath変数に指定したディレクトリを検索します。以下、sys.pathの出力結果です。

>>> import sys
>>> sys.path
['', '/usr/lib/python310.zip', '/usr/lib/python3.10', '/usr/lib/python3.10/lib-dynload', '/usr/local/lib/python3.10/dist-packages', '/usr/lib/python3/dist-packages']

2. socketオブジェクトをつくる

socketオブジェクトをつくって簡単なポートスキャナを実装し、その動作を見てみます。

#!/usr/bin/python3

import socket

ip ='127.0.0.1' 
portlist = [21,80] 
for port in portlist: 
    sock= socket.socket(socket.AF_INET,socket.SOCK_STREAM) 
    result = sock.connect_ex((ip,port)) 
    print(port,":",result) 
    sock.close()

【実行結果】
root@ubu22:/home/masa/note# ./port_scan.py
21 : 111
80 : 0

上記のスクリプトはsocketメソッドを用いてIPv4のTCPのソケットオブジェクトを作成して、127.0.0.1(localhost)のTCPポート21,80に対してそれぞれ接続を試みて結果(result)を返します。
ポート80は接続可能なポートなので、0を返しています。
一方のポート21に対する結果111は何を意味しているのか分かりません。

そこで結果(result)を返すsock.connect_exメソッドについて確認してみます。
help関数でsocketモジュールに関するヘルプを表示すると、このメソッドの説明が見つかりました。

>>>import socket
>>>help(socket)
省略
| A socket object represents one endpoint of a network connection.
|
| Methods of socket objects (keyword arguments not allowed):
|
| _accept() -- accept connection, returning new socket fd and client address
| bind(addr) -- bind the socket to a local address
| close() -- close the socket
| connect(addr) -- connect the socket to a remote address
| connect_ex(addr) -- connect, return an error code instead of an exception

「接続して例外を挙げる代わりにエラーコードを返す」と読み取れます。
代わりにconnectメソッドを用いた場合の実行結果を確認してみるとConnection refusedとしてエラーコード 111 を返したことが分かりました。

root@ubu22:/home/masa/note# ./port_scan.py
Traceback (most recent call last):
File "/home/masa/note/./port_scan.py", line 9, in <module>
result = sock.connect((ip,port))
ConnectionRefusedError: [Errno 111] Connection refused

3. socketエラーをハンドリングする

上述のように接続に問題があった場合も正しくハンドリングできる下記のようなスクリプトを作成してみます。

#!/usr/bin/python3

import sys 
import socket

if len(sys.argv[1:]) != 3: 
    print("ex: ./manage_err.py hostname port timeout(sec)") 
    sys.exit(0)

host = str(sys.argv[1]) 
port = int(sys.argv[2]) 
out = int(sys.argv[3])

sock = socket.socket(socket.AF_INET,socket.SOCK_STREAM) 
sock.settimeout(out)

try: 
    sock.connect((host,port)) 
    print(sock) 
except socket.timeout as e:
    print(f"unable to reach {host}:{port} in {out} seconds : {e}") 
    sys.exit(1) 
except socket.gaierror as e: 
    print(f"address-related error occurred : {e}") 
    sys.exit(1) 
except socket.error as e: 
    print(f"generic socket error occurred : {e}") 
    sys.exit(1) 
finally: 
    sock.close()

今度のスクリプトは3つの引数、ホスト名、ポート番号、接続不可時のタイムアウト値を渡して実行します。
sys.argsはスクリプト実行時に渡される引数が格納されるリストで、sys.argv[0]のスクリプト自身のファイル名を除いたリストの要素数をlen関数で取得して、要素数が3つでなければスクリプトを終了します。
以下、3つの実行結果を説明します。

【実行結果:接続成功】
root@ubu22:/home/masa/note# ./manage_err.py www.google.com 80 12
<socket.socket fd=3, family=AddressFamily.AF_INET, type=SocketKind.SOCK_STREAM, proto=0, laddr=('192.168.1.100', 57078), raddr=('142.251.222.36', 80)>

ホスト:www.google.com、ポート:80への接続を試みて接続が成功したのでソケットオブジェクトを出力しています。

【実行結果:タイムアウト発生】
root@ubu22:/home/masa/note# ./manage_err.py www.google.com 8080 12
unable to reach www.google.com:8080 in 12 seconds : timed out

ホスト:www.google.com、ポート:8080への接続を試みて時間内の接続が失敗したのでexcept socket.timeoutで補足されてエラーメッセージを出力しています。
接続試行時の実際のパケットを合わせて見ると、名前解決を行った後にsynパケットを送っていることが分かります。
synパケットの再送間隔は1, 2, 4秒後で行われている事が分かりますが、スクリプトではタイムアウト値を設定しただけなのでこの再送間隔はプラットフォームに従った処理が実行されているようです。

root@ubu22:/home/masa# tcpdump -ni eth0 tcp port 8080 or udp port 53
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on eth0, link-type EN10MB (Ethernet), snapshot length 262144 bytes
02:29:55.242850 IP 192.168.1.100.46324 > 192.168.1.1.53: 27860+ [1au] A? www.google.com. (43)
02:29:55.254446 IP 192.168.1.1.53 > 192.168.1.100.46324: 27860 1/0/1 A 142.251.222.36 (59)
02:29:55.255377 IP 192.168.1.100.54540 > 142.251.222.36.8080: Flags [S], seq 4151200113, win 64240, options [mss 1460,sackOK,TS val 53018943 ecr 0,nop,wscale 7], length 0
02:29:56.278856 IP 192.168.1.100.54540 > 142.251.222.36.8080: Flags [S], seq 4151200113, win 64240, options [mss 1460,sackOK,TS val 53019966 ecr 0,nop,wscale 7], length 0
02:29:58.294498 IP 192.168.1.100.54540 > 142.251.222.36.8080: Flags [S], seq 4151200113, win 64240, options [mss 1460,sackOK,TS val 53021982 ecr 0,nop,wscale 7], length 0
02:30:02.486523 IP 192.168.1.100.54540 > 142.251.222.36.8080: Flags [S], seq 4151200113, win 64240, options [mss 1460,sackOK,TS val 53026174 ecr 0,nop,wscale 7], length 0

【実行結果:名前解決失敗】
root@ubu22:/home/masa/note# ./manage_err.py www.google.come 80 12
address-related error occurred : [Errno -2] Name or service not known

ホスト:www.google.come、ポート:80への接続を試みてホストの名前解決に失敗したのでexcept socket.gaierrorで補足されてエラーメッセージを出力しています。接続試行時の実際のパケットを合わせて見ると、名前解決に失敗していることが分かります。

root@ubu22:/home/masa# tcpdump -ni eth0 tcp port 80 or udp port 53
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on eth0, link-type EN10MB (Ethernet), snapshot length 262144 bytes
02:28:16.740309 IP 192.168.1.100.60724 > 192.168.1.1.53: 48579+ [1au] A? www.google.come. (44)
02:28:16.753226 IP 192.168.1.1.53 > 192.168.1.100.60724: 48579 NXDomain 0/1/1 (119)
02:28:16.753768 IP 192.168.1.100.60724 > 192.168.1.1.53: 48579+ A? www.google.come. (33)
02:28:16.766848 IP 192.168.1.1.53 > 192.168.1.100.60724: 48579 NXDomain 0/1/0 (108)
02:28:16.768833 IP 192.168.1.100.48563 > 192.168.1.1.53: 38827+ [1au] A? www.google.come. (44)
02:28:16.780534 IP 192.168.1.1.53 > 192.168.1.100.48563: 38827 NXDomain 0/1/1 (119)

[例外とは?]
例外はプログラムの正常な制御フローから外れたことを意味して、Pythonでは例外が発生した際には次のような構文で例外処理を行います。

try:
 例外が発生する可能性がある処理
except 捕捉したい例外クラス
 例外が発生したときの処理
else:
 try節で例外が発生しなかった場合のみに実行される処理
finally:
 例外の発生有無に関わらず必ず実行される処理

本スクリプトの例外処理に"as e"という指定がありますが、これは例外オブジェクトを一時変数で受け取って出力しています。print関数内のfはf-string(フォーマット済み文字列リテラル)で{}には変数だけでなく、
計算式も書けて評価されます。

except socket.timeout as e:
print(f"unable to reach {host}:{port} in {out} seconds : {e}")
sys.exit(1)

本スクリプトでは実際にsocket.timeout、socket.gaierrorを捕捉しましたが、各例外の正確な定義についてはオンラインドキュメント(https://docs.python.org/ja/3.13/library/socket.html#exceptions)に説明がありますので参照してください。

4. サーバ・クライアント間のTCP通信を実装する

#!/usr/bin/python3

import socket

ip = "127.0.0.1" 
port = 9998

svr_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
svr_sock.bind((ip,port))
svr_sock.listen(10)

print("[*] Server Listening on %s:%d" % (ip,port))

new_sock,addr = svr_sock.accept()
new_sock.send("I am the server accepting connections...".encode())

print("[*] Accepted connection from: %s:%d" % (addr[0],addr[1]))

def handle_client(client_socket): 
    msg = client_socket.recv(1024) 
    print("[*] Received message from client %s" %msg) 
    client_socket.send(bytes("ACK","utf-8"))

while True: 
    try: 
        handle_client(new_sock) 
    except (ConnectionResetError) as e: 
        print(f"Client issued 'quit': {e}") 
        break 
    except KeyboardInterrupt: 
        print (f"'Crtl+C' Pressed") 
        break

svr_sock.close()

TCPサーバの実装はsocketの作成、バインド(bind)、受付開始(listen)が一連の流れとなります。
上記の作成したsvr_sockに対してsvr_sock.bind((ip,port))としてバインドして、最後にsvr_sock.listen(10)として受付を開始します。数字は最大で受付できる接続数を設定します。
クライアントからの接続はacceptメソッドで受取ります。本メソッドはクライアントソケットとクライアントアドレスを返すので、そのクライアントソケットを用いてクライアントとの通信を行います。クライアントアドレスはIPアドレスとポートのタプルなのでaddr[0]、addr[1]としてそれぞれ取得してprint関数で下記のように出力しています。

print("[*] Accepted connection from: %s:%d" % (addr[0],addr[1]))

ここで print関数内の% は演算子(モジュロ)と呼ばれ、format % valuesの形式で指定のformatはvaluesの要素にて置き換わります。下記のオンラインドキュメントをご参照ください。

https://docs.python.org/ja/3/library/stdtypes.html#printf-style-string-formatting

次はクライアントソケットを引数とした関数handle_clientを作成して、whlieの無限ループを用いてデータを受信したら、受信した内容を表示して応答メッセージを返します。このループはクライアントから接続終了、もしくはCrtl+Cでサーバを終了することで抜けます。

#!/usr/bin/python3

import socket

host="127.0.0.1" 
port = 9998

try: 
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 
    sock.connect((host, port)) 
    print('Connected to host '+str(host)+' in port: '+str(port)) 
    msg = sock.recv(1024) 
    print("Message received from the server %s" %msg) 
    while True: 
        message = input("Enter your message > ") 
        sock.send(bytes(message.encode('utf-8'))) 
        if message== "quit": 
            break 
except socket.errno as error: 
    print("Socket error ", error) 
finally: 
    sock.close()

TCPクライアントの実装はこれまでに見てきたとおり、socketを作成し、connectメソッドでサーバへ接続します。接続が完了すると接続した旨のメッセージを出力した後にサーバから受け取った応答メッセージを出力します。その後はwhlieの無限ループを用いてコンソールから入力文字列の受取を待ちます。
サーバとの通信は入力文字列にquitと入力することでループから抜けて、最後にソケットをクローズします。

(終わり)


いいなと思ったら応援しよう!