Pythonでチャットシステムを作る【Tkinter, socket】

482, 2022-05-27

目次

Pythonでチャットシステムを作る

PythonのTkinterとsocketを使ってチャットシステムを作ります。
チャットシステムの構成はサーバーとクライアントがそれぞれ1つずつのアプリです。

ですのでアプリを2つ作ることになります。
どちらもTkinterを使ってウィンドウなどを表示するGUIアプリにします。

ソケット通信の基礎がわかればチャットを作るのはそれほど難しくはありません。
この記事で解説していきます。

関連記事


チャットシステムの動作風景

今回作成するチャットシステムの動作風景は↓になります。

tkchat ss

画像はサーバーアプリ1つとクライアントアプリを2つ起動しているところです。

システムの構成

チャットシステムの構成はまずサーバーアプリを用意します。
そしてサーバーを起動してクライアントアプリからサーバーに接続します。
そしてソケット通信で相互に通信を行って、チャットのメッセージをクライアント側に表示します。

  • サーバーアプリ(Tkinter + socket)

  • クライアントアプリ(Tkinter + socket)

このような通信モデルはクライアントサーバー型と言われています。
クライアントがサーバーに何かリクエストを出して、サーバーがこれに応えるという感じです。
これはWebサーバーとブラウザがわかりやすい例になります。

今回作るチャットシステムも基本的にはこのクライアントサーバー型になります。
普通のソケット通信と違うのは、ソケットの数です。
今回は各アプリそれぞれにソケットを2つ以上持たせます。
それらのソケットは↓になります。

  • メッセージ送受信用ソケット

  • ブロードキャスト用ソケット

メッセージ送受信用ソケットとは、クライアントからサーバーにメッセージを送信するソケットです。
サーバー側ではソケットの接続を待ち受けて、新しくクライアントが接続したらクライアントからメッセージを受信するスレッドを立ち上げます。
そしてコネクション(接続)が成立している間、サーバーはクライアントからメッセージを読み込みます。

ブロードキャスト用ソケットとは、サーバーが複数の各クライアントにメッセージをブロードキャストするソケットです。
ブロードキャストとは要はサーバーが受信したメッセージをクライアント全員に投げることを言います。
このブロードキャストの機能によってサーバーに接続しているクライアントが他のクライアントのメッセージをサーバー経由で受信できるようになります。

サーバーはブロードキャスト用のソケットを接続してきたクライアントの数だけ保持します。
サーバーがメッセージ送受信用ソケットから作成されたクライアントと接続しているソケットからメッセージを受信したら、この複数のブロードキャスト用ソケットにメッセージをsend()します。

このような構成にすることでチャットシステムの設計が非常にシンプルになります。
これを1つのソケットだけでメッセージ受信とブロードキャストをやろうとすると、途端に複雑になります。
かなり複雑になってしまうのであまりおすすめできません。

ちなみに今回の構成がチャットシステムにおいて一般的かどうかは筆者は知りません。
あらかじめご了承ください。

コード全文

コードはサーバーアプリ(server.py)とクライアントアプリ(client.py)の2つになります。
コードはどちらもMITライセンス(無保証、何か起きても著作者に責任なし)になります。
↓がサーバーアプリのコードです。

import tkinter as tk
from tkinter import ttk
from tkinter.scrolledtext import ScrolledText
import socket
import threading
import time


# ここのポート番号はサーバーとクライアントで同じものを使用する
# サーバーとクライアントで使用するポート番号が違うと接続エラーになる
MESSAGE_PORT = 1234
BROADCAST_PORT = 2234


class Server(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title('TkChat Server')

        # スクロールバー付きのテキストエリア
        self.text = ScrolledText(self)
        self.text.pack(side=tk.TOP, expand=True, fill=tk.BOTH)

        # ソケットの初期化
        self.init_recv_worker_sock()
        self.init_broadcast_worker_sock()

        # これにブロードキャスト先のソケットが保存される
        self.broadcast_socks = []

        # メッセージ受信用スレッド
        self.recv_worker_thread = threading.Thread(target=self.recv_worker, daemon=True)
        self.recv_worker_thread.start()

        # ブロードキャスト用スレッド
        self.broadcast_worker_thread = threading.Thread(target=self.broadcast_worker, daemon=True)
        self.broadcast_worker_thread.start()

    def log(self, msg):
        self.text.insert(tk.END, msg)  # 末尾に挿入
        self.text.see(tk.END)  # 末尾を見る

    def init_recv_worker_sock(self):
        # TCPソケットを作る
        self.recv_worker_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

        # ソケットにアドレスを割り当てる
        self.recv_worker_sock.bind((socket.gethostname(), MESSAGE_PORT))

        # ソケットを接続待ちソケット(passive socket)にする
        self.recv_worker_sock.listen()
        self.log('receive worker socket is ready\n')

    def init_broadcast_worker_sock(self):
        self.broadcast_worker_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.broadcast_worker_sock.bind((socket.gethostname(), BROADCAST_PORT))
        self.broadcast_worker_sock.listen()
        self.log('broadcast worker socket is ready\n')

    def recv_worker(self):
        # メッセージ受信用
        # クライアントから接続を受け付ける

        while True:
            time.sleep(1)

            # 接続を受付けてソケットを作成する
            self.log('recv: accept...\n')
            client_sock, addr = self.recv_worker_sock.accept()
            client_sock.settimeout(10)  # タイムアウト設定

            # 接続されたソケットを渡してワーカースレッドを起動
            th = threading.Thread(
                target=self.recv_client_worker,
                args=(client_sock, ),
                daemon=True,
            )
            th.start()
            self.log('accept new client socket for receive\n')

    def broadcast_worker(self):
        # ブロードキャスト用
        # クライアントから接続を受け付ける

        while True:
            time.sleep(1)

            self.log('broadcast: accept...\n')
            client_sock, addr = self.broadcast_worker_sock.accept()

            # ブロードキャスト用のタイムアウトは短めに設定
            # 長くブロックすると接続切れのソケットがあった場合に
            # ブロードキャストが途中で止まる場合がある
            client_sock.settimeout(3) 

            # 接続してきたクライアントのソケットをリストに追加する
            # このリストを参照してブロードキャストを行う
            self.broadcast_socks.append(client_sock)

            self.log('accept new client socket for broadcast\n')

    def recv_client_worker(self, client_sock):
        # メッセージ受信用
        # 接続済みのクライアントとのソケットを使ってメッセージを受信する

        while True:
            time.sleep(1)

            # 1024バイトまでデータを受信する
            try:
                msg = client_sock.recv(1024)
            except ConnectionResetError:
                # 接続が切れた
                self.remove_broadcast_socket(client_sock)
                break
            except socket.timeout:
                # タイムアウト
                continue

            msg = msg.decode()  # バイト列を文字列に変換
            self.log(f'msg: {msg}\n')

            # 各クライアントへ受信したメッセージをブロードキャスト
            self.broadcast(msg)

    def remove_broadcast_socket(self, sock):
        # sockをbroadcast_socksから除外する
        self.broadcast_socks = list(
            filter(lambda s: id(s) != id(sock), self.broadcast_socks)
        )

    def broadcast(self, msg):
        # メッセージを各クライアントへブロードキャストする
        msg = msg.encode()

        for sock in self.broadcast_socks:
            try:
                sock.send(msg)
            except (ConnectionResetError, socket.timeout):
                continue

        self.log('done broadcast message\n')


if __name__ == '__main__':
    server = Server()
    server.mainloop()

↓がクライアントアプリのコードです。

import tkinter as tk
from tkinter import ttk
from tkinter.scrolledtext import ScrolledText
import socket
import threading
import time


MESSAGE_PORT = 1234
BROADCAST_PORT = 2234


class Client(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title('TkChat Client')

        self.text = ScrolledText(self)
        self.text.pack(side=tk.TOP, expand=True, fill=tk.BOTH)

        self.msg_row = tk.Frame(self)
        self.msg_row.pack(side=tk.TOP, expand=True, fill=tk.X)

        # メッセージ入力用のエントリー(入力欄)
        self.msg = ttk.Entry(self.msg_row)
        self.msg.pack(side=tk.LEFT, expand=True, fill=tk.X)

        # メッセージ送信ボタン
        self.send_msg_btn = ttk.Button(
            self.msg_row,
            text='Send',
            command=self.send_msg,  # クリックするとこれが呼ばれる
        )
        self.send_msg_btn.pack(side=tk.LEFT)

        # ソケットの初期化
        self.init_send_sock()
        self.init_broadcast_sock()

        # ブロードキャスト受信用のワーカースレッドを起動
        self.broadcast_worker_thread = threading.Thread(target=self.broadcast_worker, daemon=True)
        self.broadcast_worker_thread.start()

    def log(self, msg):
        self.text.insert(tk.END, msg)
        self.text.see(tk.END)

    def init_send_sock(self):
        # TCPソケットを作成
        self.send_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

        # アドレスに接続する
        self.send_sock.connect((socket.gethostname(), MESSAGE_PORT))

        self.log('send socket connected\n')

    def init_broadcast_sock(self):
        self.broadcast_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.broadcast_sock.connect((socket.gethostname(), BROADCAST_PORT))
        self.log('broadcast socket connected\n')

    def send_msg(self):
        # メッセージを送信する
        msg = self.msg.get()  # エントリーから文字列を得る
        msg = msg.encode()  # 文字列をバイト列に変換
        self.send_sock.send(msg)  # サーバーに送信

    def broadcast_worker(self):
        # サーバーからブロードキャストを受信する

        while True:
            time.sleep(1)

            data = self.broadcast_sock.recv(1024)
            data = data.decode()
            self.log(f'{data}\n')


if __name__ == '__main__':
    client = Client()
    client.mainloop()

サーバーアプリのコードの解説

サーバーアプリはTkinterで作ります。
Serverクラスはtk.Tkを継承したウィンドウアプリです。

class Server(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title('TkChat Server')

通信で使用するソケットのポート番号はグローバル変数にしています。
MESSAGE_PORTメッセージ送受信用ポート
BROADCAST_PORTブロードキャスト用ポートになります
このポート番号はクライアント側と同じものを使う必要があります。
ポート番号が合ってないと通信ができません。

# ここのポート番号はサーバーとクライアントで同じものを使用する
# サーバーとクライアントで使用するポート番号が違うと接続エラーになる
MESSAGE_PORT = 1234
BROADCAST_PORT = 2234

__init__()メソッド内で初期化を行います。
サーバーのログを表示するテキストエリアをScrolledTextで作成します。

        # スクロールバー付きのテキストエリア
        self.text = ScrolledText(self)
        self.text.pack(side=tk.TOP, expand=True, fill=tk.BOTH)

それからソケットを初期化します。
ここで初期化するソケットはどちらもクライアントから接続を待ち受けるサーバー用ソケットです。
これらのソケットでクライアントとの接続を確立し、クライアントと繋がっているソケットを新しく作成します。
そして実際の通信はその新しく作成したソケットで行います。

        # ソケットの初期化
        self.init_recv_worker_sock()
        self.init_broadcast_worker_sock()

今回使うソケットはTCPソケットです。これのほかにはUDPソケットなどがあります。
TCPはコネクションに信頼性があり、データをストリームとして送受信できます。
サーバーのソケットは↓のようにbind()でアドレス(ホスト名とポート番号)に接続します。
そしてlisten()でパッシブソケットにします。
こうするとこのソケットがクライアントからの接続を受け入れるようになります。

    def init_recv_worker_sock(self):
        # TCPソケットを作る
        self.recv_worker_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

        # ソケットにアドレスを割り当てる
        self.recv_worker_sock.bind((socket.gethostname(), MESSAGE_PORT))

        # ソケットを接続待ちソケット(passive socket)にする
        self.recv_worker_sock.listen()
        self.log('receive worker socket is ready\n')

    def init_broadcast_worker_sock(self):
        self.broadcast_worker_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.broadcast_worker_sock.bind((socket.gethostname(), BROADCAST_PORT))
        self.broadcast_worker_sock.listen()
        self.log('broadcast worker socket is ready\n')

__init__()では次にスレッドを作成してスタートさせます。
これらのスレッド先ほど作成したサーバー用ソケットを並行して処理するスレッドです。
並行処理にはスレッド(threading)を使っています。
メッセージ受信用ではrecv_worker()を関数として指定して、ブロードキャスト用ではbroadcast_worker()を関数として指定しています。

        # メッセージ受信用スレッド
        self.recv_worker_thread = threading.Thread(target=self.recv_worker, daemon=True)
        self.recv_worker_thread.start()

        # ブロードキャスト用スレッド
        self.broadcast_worker_thread = threading.Thread(target=self.broadcast_worker, daemon=True)
        self.broadcast_worker_thread.start()

recv_workerはクライアントから接続を受け付けます。
このワーカーで確立されたクライアントとのコネクションはメッセージの送受信用になります。
ソケットをaccept()してコネクションを確立したらスレッドでrecv_client_worker()を起動します。
このときこの関数に作成したソケットを渡しておきます。

    def recv_worker(self):
        # メッセージ受信用
        # クライアントから接続を受け付ける

        while True:
            time.sleep(1)

            # 接続を受付けてソケットを作成する
            self.log('recv: accept...\n')
            client_sock, addr = self.recv_worker_sock.accept()
            client_sock.settimeout(10)  # タイムアウト設定

            # 接続されたソケットを渡してワーカースレッドを起動
            th = threading.Thread(
                target=self.recv_client_worker,
                args=(client_sock, ),
                daemon=True,
            )
            th.start()
            self.log('accept new client socket for receive\n')

ブロードキャスト用のワーカーでもクライアントから接続を待ち受けます。
クライアントと接続を確立したら(ソケットをacceptしたら)そのソケットをbroadcast_socksというリストに追加します。
このリストはメッセージのブロードキャスト時に使用されます。
このワーカーではソケットを作成して保存するだけになります。

    def broadcast_worker(self):
        # ブロードキャスト用
        # クライアントから接続を受け付ける

        while True:
            time.sleep(1)

            self.log('broadcast: accept...\n')
            client_sock, addr = self.broadcast_worker_sock.accept()

            # ブロードキャスト用のタイムアウトは短めに設定
            # 長くブロックすると接続切れのソケットがあった場合に
            # ブロードキャストが途中で止まる場合がある
            client_sock.settimeout(3) 

            # 接続してきたクライアントのソケットをリストに追加する
            # このリストを参照してブロードキャストを行う
            self.broadcast_socks.append(client_sock)

            self.log('accept new client socket for broadcast\n')

recv_client_worker()では接続が確立したソケットからメッセージを読み込みます。
接続が切れた場合はremove_broadcast_socket()broadcast_socksリストから接続が切れたソケットを除去します。
タイムアウトした場合はループを1回スルーします。

受信したメッセージをログに出力し、そしてブロードキャストします。
このブロードキャストで接続が確立している各クライアントにメッセージが配信されます。

    def recv_client_worker(self, client_sock):
        # メッセージ受信用
        # 接続済みのクライアントとのソケットを使ってメッセージを受信する

        while True:
            time.sleep(1)

            # 1024バイトまでデータを受信する
            try:
                msg = client_sock.recv(1024)
            except ConnectionResetError:
                # 接続が切れた
                self.remove_broadcast_socket(client_sock)
                break
            except socket.timeout:
                # タイムアウト
                continue

            msg = msg.decode()  # バイト列を文字列に変換
            self.log(f'msg: {msg}\n')

            # 各クライアントへ受信したメッセージをブロードキャスト
            self.broadcast(msg)

broadcast()ではbroadcast_socksリストに含まれるソケットに対してメッセージをsend()します。
接続切れやタイムアウトしているソケットはスルーします。

    def broadcast(self, msg):
        # メッセージを各クライアントへブロードキャストする
        msg = msg.encode()

        for sock in self.broadcast_socks:
            try:
                sock.send(msg)
            except (ConnectionResetError, socket.timeout):
                continue

        self.log('done broadcast message\n')

ログはテキストエリアに出力します。
log()というメソッドで行います。
insert()でテキストエリアにメッセージを挿入。
see()でスクロールを下に移動させます。

    def log(self, msg):
        self.text.insert(tk.END, msg)  # 末尾に挿入
        self.text.see(tk.END)  # 末尾を見る

作成したServerクラスをオブジェクトにしてmainloop()を実行します。
こうするとウィンドウが表示されます。
if __name__ == '__main__':は端末からスクリプトを実行するとTrueになります。

if __name__ == '__main__':
    server = Server()
    server.mainloop()

クライアントアプリのコードの解説

クライアントアプリもサーバーと同様にtk.Tkを継承して作ります。

class Client(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title('TkChat Client')

__init__()で初期化を行います。
初期化ではメッセージ入力用のエントリーと、メッセージ送信用のボタンを作って配置します。
それからソケットを初期化してブロードキャストの受信用のスレッドを1つ起動します。

        self.text = ScrolledText(self)
        self.text.pack(side=tk.TOP, expand=True, fill=tk.BOTH)

        self.msg_row = tk.Frame(self)
        self.msg_row.pack(side=tk.TOP, expand=True, fill=tk.X)

        # メッセージ入力用のエントリー(入力欄)
        self.msg = ttk.Entry(self.msg_row)
        self.msg.pack(side=tk.LEFT, expand=True, fill=tk.X)

        # メッセージ送信ボタン
        self.send_msg_btn = ttk.Button(
            self.msg_row,
            text='Send',
            command=self.send_msg,  # クリックするとこれが呼ばれる
        )
        self.send_msg_btn.pack(side=tk.LEFT)

        # ソケットの初期化
        self.init_send_sock()
        self.init_broadcast_sock()

        # ブロードキャスト受信用のワーカースレッドを起動
        self.broadcast_worker_thread = threading.Thread(target=self.broadcast_worker, daemon=True)
        self.broadcast_worker_thread.start()

ソケットの初期化ではソケットを作成し、そしてconnect()で接続を行うだけです。

    def init_send_sock(self):
        # TCPソケットを作成
        self.send_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

        # アドレスに接続する
        self.send_sock.connect((socket.gethostname(), MESSAGE_PORT))

        self.log('send socket connected\n')

    def init_broadcast_sock(self):
        self.broadcast_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.broadcast_sock.connect((socket.gethostname(), BROADCAST_PORT))
        self.log('broadcast socket connected\n')

Sendボタンがクリックされるとsend_msg()が実行されます。
これはソケットを使ってサーバー側に入力されたメッセージを送信します。

    def send_msg(self):
        # メッセージを送信する
        msg = self.msg.get()  # エントリーから文字列を得る
        msg = msg.encode()  # 文字列をバイト列に変換
        self.send_sock.send(msg)  # サーバーに送信

ブロードキャストの受信用のワーカーではサーバーからブロードキャストを受信します。
ブロードキャストと言ってもただのバイト列です。
ログ(という名のチャットエリア)に受信したデータを出力します。

    def broadcast_worker(self):
        # サーバーからブロードキャストを受信する

        while True:
            time.sleep(1)

            data = self.broadcast_sock.recv(1024)
            data = data.decode()
            self.log(f'{data}\n')

Clientクラスをオブジェクトしてウィンドウを起動します。

if __name__ == '__main__':
    client = Client()
    client.mainloop()

チャットシステムの実行方法

チャットシステムを実際に使うには、まずサーバーアプリのコードをserver.pyで保存します。
それからクライアントアプリのコードをclient.pyで保存します。

Pythonでserver.pyを実行してサーバーアプリを起動します。
ウィンドウが現れてログが表示されます。

それからclient.pyを実行します。
クライアントアプリは2つ以上起動しておくと動作確認がはかどると思います。

接続が確立されたらクライアントアプリのメッセージ入力欄にメッセージを書き、Sendボタンをマウスでクリックします。
そうするとメッセージが各クライアントのログに表示されます。
メッセージはブロードキャストされるので複数のクライアントで同じメッセージを受信できます。

おわりに

今回はチャットシステムをPythonのTkinterとsocketで作ってみました。
チャットは実用的なアプリですが、作り方はけっこう未知的だと思います。
この記事が何かの参考になれば幸いです。

(^ _ ^)

チャットでおしゃべりしよう

(・ v ・)

ROMですまんね



この記事のアンケートを送信する