PythonソケットによるTCP通信入門

Python

PythonソケットによるTCP通信入門

Python 標準の通信ライブラリである socket を活用して、簡易チャットソフトを開発してみます。

チャットソフトを実際に開発していく中で、非同期処理や例外処理、TCP 通信するプログラムを段階的に解説していきます。

Python の公式ドキュメントだけだと通信の流れがつかみにくいという人には、とても刺さる内容になっていると思います。

はじめに

Pythonのセットアップ

Python でソケット通信をする場合、特別な準備が必要ないので導入がめっちゃ楽です。

https://www.python.org/downloads/

上記の公式サイトから Python をインストールしたら準備 ok です。標準機能でソケット通信のライブラリが組み込まれている上、もちろんマルチプラットフォームで動作します。

※本記事は Python 3.9.7 で確認しています。

サーバーのおさらい

サーバーって『性能が高いマシン』というイメージや『スーパーコンピューターみたいな巨大なマシン』のようなモノだと、なんとなく思ってしまっていませんか?

決して間違いというわけではないのですが、誤解があります。

サーバーは『利用者からの要求に対して、適切なデータや処理結果を提供するプログラムのこと』です。

例えば、メールサーバーはメール送受信機能の提供を、ウェブサーバーはウェブページの提供を、ウォーターサーバーはお水の提供をしますね。

サーバーはユーザーに対して何らかのサービス(機能)を提供する役割だと認識しておけば、この記事の内容も読みやすくなります。

オススメの書籍

ネットワークエンジニア界では定番書籍…と私は思っています。

この書籍では『ネットワークそのもの』の概念や考え方をガッツリ網羅しています。なので、当記事で紹介する『ソケット通信』や『TCP』『UDP』は、書籍の中でほんの一握りの内容です。

ネットワーク技術の情報を仕入れるにあたっては、強くオススメしたい書籍です。

こちらは、Python でのソケット通信に関する書籍です。

実際に、Python でプログラミングする際に補助となる役割を果たしてくれます。Python の公式ドキュメントでは情報が足りないという人には、丁度よい書籍というボリュームです。

段階別ソケットプログラム解説

ソケット通信は、機能を提供するサーバー側のプログラムと、要求を出したり受け取ったりするクライアント側のプログラム両方をそれぞれ実装していくことなります。

また、ソケット通信では『TCP』と『UDP』という二つの通信方式があります。TCP は UDP よりも転送速度が遅い反面、データ伝送の安定性が高いという特徴があります。

今回は、チャット(文字データのやり取り)をしたいので、速度より安定性重視の TCP 方式で実装していきます。

サーバーへ単発通信

サーバーへ単発通信

クライアントからサーバーに任意の文字列データを送信してみます。事始めです。

極小のプログラムにしているので、まずは通信の雰囲気をつかんでみてください。

サーバー側

# ソケットライブラリ取り込み
import socket

# サーバーIPとポート番号
IPADDR = "127.0.0.1"
PORT = 49152

# AF_INET:IPv4形式でソケット作成(省略可)
sock_sv = socket.socket(socket.AF_INET)
# IPアドレスとポート番号でバインド、タプルで指定
sock_sv.bind((IPADDR, PORT))
# サーバー有効化
sock_sv.listen()

# 接続・受信の無限ループ
while True:
    # クライアントの接続受付
    sock_cl, addr = sock_sv.accept()
    # ソケットから byte 形式でデータ受信
    data = sock_cl.recv(1024)
    print(data.decode("utf-8"))
    # クライアントのソケットを閉じる
    sock_cl.close()

クライアント側

# ソケットライブラリ取り込み
import socket

# サーバーIPとポート番号
IPADDR = "127.0.0.1"
PORT = 49152

# ソケット作成
sock = socket.socket(socket.AF_INET)
# サーバーへ接続
sock.connect((IPADDR, PORT))

# byte 形式でデータ送信
sock.send("hello".encode("utf-8"))

解説

  1. サーバーを稼働させ、クライアントの接続待ちへ
  2. クライアントを稼働させ、サーバーへ接続試行
  3. サーバーがクライアントとの接続を確立
  4. クライアントがサーバーへ文字データを送信
  5. サーバーがクライアントからのデータを受信

送信したらクライアントはプロセスを終了しますが、起動すれば何回も送信はできます。

サーバーとクライアントのプログラムが別々に動くので、流れを追うのに最初は慣れが必要かもしれません。

Visual Studio Code などのツールを用意しておけば、デバッグ環境的にも心強いですよ。

ちなみに、『127.0.0.1』はループバックアドレスといって、自分が送出したデータや信号をそのまま自分へ送り返す特別なアドレスです。今回みたいに、開発の初期段階はデータの送出先をループバックにしておくことで、デバッグしやすい環境で進められます。

なので、最終的にはループバックアドレスではなく、本番サーバーのIPアドレスに変更することになります。

問題点

# 接続・受信の無限ループ
while True:
    # クライアントの接続受付
    sock_cl, addr = sock_sv.accept()
    # データ受信
    data = sock_cl.recv(1024)
    print(data.decode("utf-8"))
    # クライアントのソケットを閉じる
    sock_cl.close()

現状、サーバー側のプログラムは、以下の一連の動作を1サイクルとして処理しています。

  1. クライアントの接続を確立
  2. データの受信
  3. クライアントの接続をクローズ

つまり、このプログラムのままでは、クライアントが接続してから1回しかデータが受信できないのが問題というワケです。

一つずつ問題を解決していきましょう!

クライアントから反復受信

クライアントから反復受信

1つのクライアントから何度も受信ができるようにプログラムを修正していきます。

サーバー側

import socket

IPADDR = "127.0.0.1"
PORT = 49152

sock_sv = socket.socket(socket.AF_INET)
sock_sv.bind((IPADDR, PORT))
sock_sv.listen()

# クライアントの接続受付
sock_cl, addr = sock_sv.accept()

while True:
    # データ受信
    data = sock_cl.recv(1024)
    print(data.decode("utf-8"))

クライアント側

import socket

IPADDR = "127.0.0.1"
PORT = 49152

sock = socket.socket(socket.AF_INET)
sock.connect((IPADDR, PORT))

# 送信無限ループ
while True:
    # 任意の文字を入力
    data = input("> ")
    # サーバーに送信
    sock.send(data.encode("utf-8"))

解説

# 送信無限ループ
while True:
    # 任意の文字を入力
    data = input("> ")
    # サーバーに送信
    sock.send(data.encode("utf-8"))

任意の文字データをサーバーに送信するために、クライアント側では input 関数を使っています。

その処理を無限ループにすることで、何度もサーバーにデータが送れるようクライアントのプログラムを変更しています。

while True:
    # データ受信
    data = sock_cl.recv(1024)
    print(data.decode("utf-8"))

一方サーバー側は、クライアントのデータ受信部分を無限ループにしつつ、ソケットのクローズ処理を一旦省いた状態です。

問題点

# クライアントの接続受付
sock_cl, addr = sock_sv.accept()

while True:
    # データ受信
    data = sock_cl.recv(1024)
    print(data.decode("utf-8"))

今回の問題点は明白です。

先着1位のクライアントからのみデータが受信できる状態で、他のクライアントが一切接続できなくなっているのが問題です。

接続確立の accept メソッドと、データ受信の recv メソッドは、どちらも処理がブロック状態になるため、このままでは互いの処理を共存させることができません。

次はこの問題を解決していきます。

全クライアントから反復受信

全クライアントから反復受信

すべてのクライアントからデータを受信しつつ、新規クライアントの接続もできるようにしていきます。

サーバー側

import socket
# スレッドライブラリ取り込み
import threading

IPADDR = "127.0.0.1"
PORT = 49152

sock_sv = socket.socket(socket.AF_INET)
sock_sv.bind((IPADDR, PORT))
sock_sv.listen()

# データ受信ループ関数
def recv_client(sock, addr):
    while True:
        data = sock.recv(1024)
        print(data.decode("utf-8"))

# クライアント接続ループ
while True:
    # クライアントの接続受付
    sock_cl, addr = sock_sv.accept()
    # スレッドクラスのインスタンス化
    thread = threading.Thread(target=recv_client, args=(sock_cl, addr))
    # スレッド処理開始
    thread.start()

クライアント側

import socket

IPADDR = "127.0.0.1"
PORT = 49152

sock = socket.socket(socket.AF_INET)
sock.connect((IPADDR, PORT))

# 送信無限ループ
while True:
    # 任意の文字を入力
    data = input("> ")
    # サーバーに送信
    sock.send(data.encode("utf-8"))

解説

# スレッドクラスのインスタンス化
thread = threading.Thread(target=recv_client, args=(sock_cl, addr))
# スレッド処理開始
thread.start()

クライアントの接続待ちはメインスレッドで行い、データの受信待ちはサブスレッドで行うようにしました。

クライアントのデータを受信する処理がメインスレッドから独立しているので、クライアント接続待ち中でもデータが受信でき、データ受信中にも新規クライアントが接続できます。

このように複数のタスクが並行して動作する形態のことを『非同期処理』といいます。非同期処理をするために threading ライブラリを活用しています。

『○○されるまで待つ』という処理のブロックが発生する性質上、通信においては非同期処理を上手に用いる技術も求められます。

問題点

サーバーとクライアントが接続されているときに、クライアント側のプロセスを終了させると、サーバー側で例外が発生してしまいます。

逆に、サーバー側のプロセスを先に終了した場合でも、クライアント側で例外が発生します。

本来、例外が出てしまう時点で正しく実装できていない証なのですが、それでも例外は起きるものです。発生してしまった例外をしっかり消化する対応も組み込んでいきましょう。

ソケット通信の切断対応

ソケット通信の切断対応

何らかの事由によって、サーバーやクライアントが終了してしまった際の対処です。

クライアントの切断によって、サーバー側が異常終了してしまう問題を解決します。

サーバー側

import socket
# スレッドライブラリ取り込み
import threading

IPADDR = "127.0.0.1"
PORT = 49152

sock_sv = socket.socket(socket.AF_INET)
sock_sv.bind((IPADDR, PORT))
sock_sv.listen()

# データ受信ループ関数
def recv_client(sock, addr):
    while True:
        try:
            data = sock.recv(1024)
            # 受信データ0バイト時は接続終了
            if data == b"":
                break
            print(data.decode("utf-8"))
        # 切断時の例外を捕捉したら終了
        except ConnectionResetError:
            break
    
    # クライアントをクローズ処理
    sock.shutdown(socket.SHUT_RDWR)
    sock.close()


# クライアント接続ループ
while True:
    # クライアントの接続受付
    sock_cl, addr = sock_sv.accept()
    # スレッドクラスのインスタンス化
    thread = threading.Thread(target=recv_client, args=(sock_cl, addr))
    # スレッド処理開始
    thread.start()

クライアント側

import socket

IPADDR = "127.0.0.1"
PORT = 49152

sock = socket.socket(socket.AF_INET)
sock.connect((IPADDR, PORT))

while True:
    # 任意の文字を入力
    data = input("> ")
    # exitを切断用コマンドとしておく
    if data == "exit":
        break
    else:
        try:
            sock.send(data.encode("utf-8"))
        except ConnectionResetError:
            break

# 送受信の切断
sock.shutdown(socket.SHUT_RDWR)
# ソケットクローズ
sock.close()

解説

# 切断時の例外を捕捉したら終了
except ConnectionResetError:

クライアントやサーバーのプロセスが、×ボタンや SIGINT シグナルなどによって強制終了されたとき、その対象と送受信を実行すると例外が発生します。

今回のように、例外が発生してもアプリケーションが続行できる(しなければならない)場合、例外を捕捉して終了処理を行ったのち、稼働が続行できるようにするべきですよね。

# 送受信の切断
sock.shutdown(socket.SHUT_RDWR)
sock.close()
# 受信データ0バイト時は接続終了
data = sock.recv(1024)
if data == b"":

shudown メソッドを使うことで、相手に対して通信を終了することを明示的に伝達することができます。引数は、「受信のみ」「送信のみ」「送受信」の通信を終了したいのかのオプションです。

shutdown を呼び出すと、受信側は 0 バイトのデータ受信が行われ、これで EOF の検知ができるというワケです。大きな違いは、通信切断の検知が例外処理ではないところです!つまるところ、通信を終了する際には、shutdown を呼び出すのが socket ライブラリの意図するフローというわけです。

Python の公式ドキュメントでも、ソケットインスタンスのガベージコレクションに頼らないで、明示的に close しましょうと語られています。

非同期通信をするために複数のスレッドが稼働することになるので、通信切断ハンドリングをミスって、暴走しているスレッドが無駄なリソースを食い続けることにならないよう実装していきましょう。

問題点

サーバーが稼働していないときにクライアントを起動したりすると、これまた例外が送出されます。

というように、通信技術は性質上エラーが発生しやすい分野です。

この程度の規模では、エラーの再現と修正も簡単なので、直しやすいウチに色んな例外を経験して、対策しておくことが大事ですね。

ブロードキャスト対応

ブロードキャスト対応

ネットワークに参加しているすべての機器にデータを送信することを『ブロードキャスト』といいます。

Aさんの発言を、BさんやCさんに到達できるようにしていく実装です。

サーバー側

import socket
import threading

IPADDR = "127.0.0.1"
PORT = 49152

sock_sv = socket.socket(socket.AF_INET)
sock_sv.bind((IPADDR, PORT))
sock_sv.listen()

# クライアントのリスト
client_list = []

def recv_client(sock, addr):
    while True:
        try:
            data = sock.recv(1024)
            if data == b"":
                break

            print("$ say client:{}".format(addr))

            # 受信データを全クライアントに送信
            for client in client_list:
                client[0].send(data)

        except ConnectionResetError:
            break

    # クライアントリストから削除
    client_list.remove((sock, addr))
    print("- close client:{}".format(addr))

    sock.shutdown(socket.SHUT_RDWR)
    sock.close()

# クライアント接続待ちループ
while True:
    sock_cl, addr = sock_sv.accept()
    # クライアントをリストに追加
    client_list.append((sock_cl, addr))
    print("+ join client:{}".format(addr))

    thread = threading.Thread(target=recv_client, args=(sock_cl, addr))
    thread.start()

クライアント側

import socket
import threading

IPADDR = "127.0.0.1"
PORT = 49152

sock = socket.socket(socket.AF_INET)
sock.connect((IPADDR, PORT))

# データ受信関数
def recv_data(sock):
    while True:
        try:
            data = sock.recv(1024)
            if data == b"":
                break
            print(data.decode("utf-8"))
        except ConnectionResetError:
            break

    sock.shutdown(socket.SHUT_RDWR)
    sock.close()

# データ受信をサブスレッドで実行
thread = threading.Thread(target=recv_data, args=(sock,))
thread.start()

# データ入力ループ
while True:
    data = input("> ")
    if data == "exit":
        break
    else:
        try:
            sock.send(data.encode("utf-8"))
        except ConnectionResetError:
            break

sock.shutdown(socket.SHUT_RDWR)
sock.close()

解説

# 受信データを全クライアントに送信
for client in client_list:
    client[0].send(data)

ブロードキャストの実装自体は単純で、サーバー側でクライアントのリストを作って、受信したデータをそのまま全クライアントに一斉送信しているだけです。

なので、クライアントが接続時にはリストへ追加し、切断時にはリストから削除する処理を加えています。

また、クライアント側も送信されてきたデータをリアルタイムで表示するために、非同期で受信するよう処理を追加しています。(input 関数が処理をブロックするため。)

問題点

動作的に目立った問題はありませんが、課題は残っています。

  • ツールの利便性
  • コードの可読性

ブロードキャストの実装によってチャット自体は機能として成立していますが、CLI ではツールとして使い勝手がよいとは言えないですね。

また、コードも徐々に大きくなってきました。再利用できる処理や、抽象化したい処理がハッキリしてきましたね。クラス設計が得意な人は、この時点でカプセル化を進めたくなってくる頃だと思います。

クライアントのGUI化

クライアントのGUI化

通信自体はうまく動いているので、あとは GUI のプログラムを上から乗せるだけです。

※GUI のプログラムは本記事では詳しく触れません。

サーバー側

import socket
import threading

IPADDR = "127.0.0.1"
PORT = 49152

sock_sv = socket.socket(socket.AF_INET)
sock_sv.bind((IPADDR, PORT))
sock_sv.listen()

# クライアントのリスト
client_list = []

def recv_client(sock, addr):
    while True:
        try:
            data = sock.recv(1024)
            if data == b"":
                break

            print("$ say client:{}".format(addr))

            # 受信データを全クライアントに送信
            for client in client_list:
                client[0].send(data)

        except ConnectionResetError:
            break

    # クライアントリストから削除
    client_list.remove((sock, addr))
    print("- close client:{}".format(addr))

    sock.shutdown(socket.SHUT_RDWR)
    sock.close()

# クライアント接続待ちループ
while True:
    sock_cl, addr = sock_sv.accept()
    # クライアントをリストに追加
    client_list.append((sock_cl, addr))
    print("+ join client:{}".format(addr))

    thread = threading.Thread(target=recv_client, args=(sock_cl, addr))
    thread.start()

クライアント側

import socket
import threading
import tkinter

# -- tkinter による GUI の初期化
root = tkinter.Tk()
root.title("nayutari chat")
root.geometry("400x300")

scrl_frame = tkinter.Frame(root)
scrl_frame.pack()

listbox = tkinter.Listbox(scrl_frame, width=40, height=15)
listbox.pack(side=tkinter.LEFT)

scroll_bar = tkinter.Scrollbar(scrl_frame, command=listbox.yview)
scroll_bar.pack(side=tkinter.RIGHT, fill=tkinter.Y)

listbox.config(yscrollcommand=scroll_bar.set)

input_frame = tkinter.Frame(root)
input_frame.pack()

textbox = tkinter.Entry(input_frame)
textbox.pack(side=tkinter.LEFT)

button = tkinter.Button(input_frame, text="send")
button.pack(side=tkinter.RIGHT)

# データ受信時のコールバック関数
def on_recv_data(data):
    listbox.insert(tkinter.END, data)
    listbox.yview_moveto(1)

# -- 通信まわりの初期化
IPADDR = "127.0.0.1"
PORT = 49152

sock = socket.socket(socket.AF_INET)
sock.connect((IPADDR, PORT))

def recv_loop(sock, on_recv_func):
    while True:
        try:
            data = sock.recv(1024)
            if data == b"":
                break
            # 受信コールバック呼び出し
            on_recv_func(data.decode("utf-8"))
        except ConnectionResetError:
            break

    sock.shutdown(socket.SHUT_RDWR)
    sock.close()

thread = threading.Thread(target=recv_loop, args=(sock, on_recv_data))
thread.start()

# 送信ボタンクリック時のコールバック
def on_send_click(sock):
    data = textbox.get()
    sock.send(data.encode("utf-8"))
    textbox.delete(0, tkinter.END)

button.configure(command=lambda:on_send_click(sock))

root.mainloop()

sock.shutdown(socket.SHUT_RDWR)
sock.close()

解説

import tkinter
# ~
root = tkinter.Tk()
# ~
root.mainloop()

tkinter ライブラリを使えば、GUI の実装も Python の標準機能だけで実装できます。tkinter の詳細は Python の公式ドキュメントをあさってください。

クライアント側に GUI のプログラムが追加されただけで、中身はほとんど変えていません。そして、サーバー側のプログラムは一切変更していません。

  • ボタンが押されたらサーバーにデータを送信
  • データを受信したらリストボックスに追加

input 関数を Entry ウィジェットに代え、print 関数を Listbox ウィジェットに代えただけです。通信処理が実行されるきっかけが変わっただけですね。

ちなみに、input 関数がなくなることで、メインスレッドがブロックされることもなくなったので、効率よく非同期処理することができるようになっています。

問題点

本格的にチャットツールとして仕上げていくなら、以下の要素もほしくなってきます。

  • 発言者の名前や参加者リストの管理
  • 途中参加時に過去トークの同期
  • 音楽や動画データの共有
  • ログの保存
  • などなど

今回は通信プロトコルとして TCP を採用してデータのやり取りをしました。

もし、動画や音楽などのコンテンツをリアルタイムで共有したい場合、今度は速度が優先になってきます。そうなると、UDP 方式で通信するプログラムが必要になりますね。

通信方式が違えば、処理の仕方も変わってきます。ただ、この記事を理解する頃には、通信という分野の苦手意識も少なくなっていると思うので、ぜひ理解を深めていってください。

以上、『PythonソケットによるTCP通信入門』でした。

Python