受信と送信を並行に行えるクライアントの作成【Python, Tkinter, socket】

139, 2020-12-21

目次

サーバー/クライアントを同時に起動する

いわゆるクライアント/サーバーのモデルによるソケット通信では、サーバー用のアプリ(プロセス)とクライアント用のアプリが分かれています。
これらはサーバーによる送受信とクライアントによる送受信で、サーバーとクライアント間で双方向に通信を実現します。

これはWebサーバーとブラウザの関係です。

今回はこの「サーバー」と「クライアント」を1つのアプリで同時に起動したらどうなるのか? というのをやってみました。
つまり1つのアプリ内でWebサーバーとブラウザを起動するわけです。

こうすると何が起こるのかと言うと、1つのアプリ内でサーバーとクライアントを並行して起動できるようになります。
このアプリの発展としてはP2Pクライアントなどがあげられます(まだ作ったことは無いですが)。
手持ちのファイルをサーバー側で送信しながら、クライアントで別のホストのファイルを受信するといった具合です。

設計

サーバー/クライアントを内蔵するアプリなので、CUIは不便なことが予想されます。
端末上で並行してサーバーの通信とクライアントの通信を表示するには「Curses」ライブラリを使うなどの工夫が必要になります。

今回はCUIではなくGUIで実装することにしました。
実装に使ったGUIライブラリはPythonの標準ライブラリである「Tkinter」です。
これによる外観は↓のようになります。

【0139】ss00.png

↑の絵はアプリを2つ起動している所です。
1つのアプリではポート1234にサーバーを公開し、ポート2234にクライアントを接続しています。
もう1つのアプリではポート2234にサーバーを公開し、ポート1234にクライアントを接続しています。

アプリの起動直後はソケットは作成されておらず、ポート番号を入力して「Connect port」ボタンをクリックするとソケットが作成されて通信が確立されます。
このときの注意点として、サーバーが先にポートに公開されている必要があります。サーバーが公開されていない場合はクライアントをポートに接続しても意味がありません。

このような設計にすることで1つのアプリでサーバーとクライアントを同時に起動することが出来ます。
アプリの内部ではソケットを2つ持っていて、それぞれサーバー用とクライアント用です。
並行処理はスレッドを使っています。

コード全文

↓が今回のアプリのコード全文です。

import tkinter as tk
from tkinter import ttk
import sys
import threading
import time
import socket


class Server(tk.Frame):
    def __init__(self, parent):
        super().__init__(parent)

        self.sock = None

        self.row1 = tk.Frame(self)
        self.row1.pack(side=tk.TOP, expand=True, fill=tk.BOTH)

        self.text = tk.Text(self.row1, width=50, height=10)
        self.text.pack(side=tk.LEFT, expand=True, fill=tk.BOTH)
        self.text.insert('1.0', 'Server is ready')

        self.row2 = tk.Frame(self)
        self.row2.pack(side=tk.TOP, expand=True, fill=tk.BOTH)

        self.port = ttk.Entry(self.row2)
        self.port.pack(side=tk.LEFT, expand=True, fill=tk.X)

        self.connect_port_btn = ttk.Button(self.row2, text='Connect port', command=self.handle_click_connect_port_btn)
        self.connect_port_btn.pack(side=tk.LEFT)

        self.thread = threading.Thread(target=self.worker, daemon=True)
        self.thread.start()

    def handle_click_connect_port_btn(self):
        port = int(self.port.get())

        if self.sock:
            self.sock.close()

        self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.sock.bind((socket.gethostname(), port))
        self.sock.listen()

    def client_worker(self, args):
        cliesock = args[0]

        while True:
            data = cliesock.recv(1024)
            data = data.decode()
            self.text.insert('1.0', data + '\n')

            cliesock.send(b'ok')

    def worker(self):
        while True:
            time.sleep(1)
            if not self.sock:
                continue

            self.text.insert('1.0', 'accept...\n')
            cliesock = self.sock.accept()

            th = threading.Thread(
                target=self.client_worker,
                args=(cliesock, ),
                daemon=True,
            )
            th.start()


class Client(tk.Frame):
    def __init__(self, parent):
        super().__init__(parent)

        self.sock = None

        self.row1 = tk.Frame(self)
        self.row1.pack(side=tk.TOP, expand=True, fill=tk.BOTH)

        self.text = tk.Text(self.row1, width=50, height=10)
        self.text.pack(side=tk.LEFT, expand=True, fill=tk.BOTH)
        self.text.insert('1.0', 'Client is ready')

        self.row2 = tk.Frame(self)
        self.row2.pack(side=tk.TOP, expand=True, fill=tk.BOTH)

        self.port = ttk.Entry(self.row2)
        self.port.pack(side=tk.LEFT, expand=True, fill=tk.X)

        self.connect_port_btn = ttk.Button(self.row2, text='Connect port', command=self.handle_click_connect_port_btn)
        self.connect_port_btn.pack(side=tk.LEFT)

        self.row3 = tk.Frame(self)
        self.row3.pack(side=tk.TOP, expand=True, fill=tk.BOTH)

        self.message = ttk.Entry(self.row3)
        self.message.pack(side=tk.LEFT, expand=True, fill=tk.X)

        self.send_message_btn = ttk.Button(self.row3, text='Send', command=self.handle_click_send_message_btn)
        self.send_message_btn.pack(side=tk.LEFT)

    def handle_click_send_message_btn(self):
        if not self.sock:
            self.text.insert('1.0', 'not connected\n')
            return

        msg = self.message.get()
        msg = msg.encode()
        self.sock.send(msg)

        data = self.sock.recv(1024)
        s = data.decode()
        self.text.insert('1.0', s + '\n')

    def handle_click_connect_port_btn(self):
        port = int(self.port.get())

        if self.sock:
            self.sock.close()

        self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.sock.connect((socket.gethostname(), port))
        self.text.insert('1.0', 'connected\n')


class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title('Server and Client')
        self.resizable(width=False, height=False)
        self.server = Server(self)
        self.server.pack(side=tk.LEFT)
        self.client = Client(self)
        self.client.pack(side=tk.LEFT)


def main():
    app = App()
    app.mainloop()
    return 0


main()

コードのライセンスはMITです。
コードの解説を順に行います。

main関数

アプリのエントリーポイントはmain関数です。
main関数内ではAppクラスをオブジェクトにしてmainloopを呼び出しています。
Appクラスはtkinter.Tkクラスを継承したクラスで、mainloopを呼び出すことでウィンドウを起動することが出来ます。

Appクラス

Appクラスは非常にシンプルな作りです。
__init__メソッド内ではタイトルの設定とウィンドウのリサイズの固定、それからサーバーとクライアントの作成と配置を行っています。
Serverクラスはサーバーを表現するウィジェットです。
Clientクラスはクライアントを表現するウィジェットです。

Serverクラス

Serverクラスの__init__メソッド内ではウィジェットの作成と配置、それからスレッドの起動を行っています。
ウィジェットはログを表示するとテキストウィジェットと、ポート番号を入力するエントリー、それからポートに接続するボタンです。

ポート番号を入力して「Connect port」ボタンをクリックすると、handle_click_connect_port_btnメソッドが呼び出されます。
このメソッド内ではソケットの作成とバインド、それから待ち受けを行います。
バインド先は自ホストに限られますが、ポート番号は入力された番号を使います。

    def handle_click_connect_port_btn(self):
        port = int(self.port.get())

        if self.sock:
            self.sock.close()

        self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.sock.bind((socket.gethostname(), port))
        self.sock.listen()

Serverクラスは起動すると内部でスレッドを1つ作ります。このスレッドはworkerメソッドを呼び出しています。
workerメソッド内では1秒間隔でソケットの有無をチェックし、ソケットが有効ならそのソケットからacceptしてクライアントの接続を待ち受けます。
クライアントから接続があったら新しくスレッドを作り、client_workerメソッドを呼び出します。

    def worker(self):
        while True:
            time.sleep(1)
            if not self.sock:
                continue

            self.text.insert('1.0', 'accept...\n')
            cliesock = self.sock.accept()

            th = threading.Thread(
                target=self.client_worker,
                args=(cliesock, ),
                daemon=True,
            )
            th.start()

client_workerメソッド内では無限ループでクライアントからのデータの受信とその応答を行います。
受信したデータはテキストエリアにログとして残します。

    def client_worker(self, args):
        cliesock = args[0]

        while True:
            data = cliesock.recv(1024)
            data = data.decode()
            self.text.insert('1.0', data + '\n')

            cliesock.send(b'ok')

Clientクラス

Clientクラスでは__init__メソッド内でウィジェットの作成と配置を行います。
Serverクラスと違うのはメッセージの送信エリアがあることです。

ポート番号を入力し「Connect port」ボタンをクリックすると、handle_click_connect_port_btnメソッドが呼び出されます。
このメソッド内ではソケットの作成と接続を行います。
接続先のホスト名は自ホストに限られますが、ポート番号はServerと同様に指定することが出来ます。

    def handle_click_connect_port_btn(self):
        port = int(self.port.get())

        if self.sock:
            self.sock.close()

        self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.sock.connect((socket.gethostname(), port))
        self.text.insert('1.0', 'connected\n')

メッセージを入力し、「Send」ボタンをクリックすると接続しているソケットにデータを送信します。

    def handle_click_send_message_btn(self):
        if not self.sock:
            self.text.insert('1.0', 'not connected\n')
            return

        msg = self.message.get()
        msg = msg.encode()
        self.sock.send(msg)

        data = self.sock.recv(1024)
        s = data.decode()
        self.text.insert('1.0', s + '\n')

Serverと違ってこれはクライアントなので、先に送信を行っています。
そのあとにサーバーからレスポンスを受信しています。
受信したデータはテキストエリアにログとして残します。
Serverは通信に成功すると「ok」と返してくるので、クライアントのテキストエリアには「ok」が蓄積されていきます。

おわりに

サーバーとクライアントを同時に起動できるということは、チャットのような双方向のコミュニケーションが可能になるということになります。
双方向に通信が可能になるといろいろなソフトウェアの開発に応用できますね。