2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Pythonでマインクラフトサーバーを書き直してみるよ(ソケット通信と同時接続)

Posted at

はじめに

こんにちは。マイクラサーバーを1から書き直してみる開発日記2日目です。(とはいえ前回の記事を書いた同日なのですが...)
今回はPython標準のsocketモジュールを使ってTCP/IPプロトコル経由で同時接続を受け付ける為のサーバーリスナーを作ります。
本当はマイクラのパケットプロトコルの定義までやりたかったんですけど長くなりそうなので...

開発環境

CPythonが動くならどこでもいいのですが、私は以下の環境で開発しています。
OS: MacOS Sequoia 15.3.2 (Macbook Pro 2020)
CPU: 2 GHz Quad-Core Intel Core i5
Memory: 16 GB 3733 MHz LPDDR4X
Python: 3.12 (仮想環境はvenv)

開発環境の構築

まずは開発環境を構築します。実質一日目ですね
開発環境といっても、Pythonはすでに入っている前提なので、ファイル階層と仮想環境を作る以外にやることはありませんが。
pyncraftがルートディレクトリです。ここでプログラムを実行します。
$ python -m core
このコマンドで全て動くようにしておきます。
今回の内容はサーバーソケットとリスナーの構築なのでnetworkingパッケージを用意します。
さらにログも出したいのでlogger.pyも入れておきます。

pyncraft
├─ core
│  ├─ __main__.py
│  ├─ logger.py
├─ networking
   ├─ __init__.py

まずはこのloggerモジュールから準備していきます。
無難にPython標準のloggingモジュールを使います。

import logging
import threading
import os

class Logger(logging.Logger):

    def __init__(self):
        # ログ保存先ディレクトリを作成(既に存在していてもOK)
        os.makedirs('resources/logs', exist_ok=True)

        # 名前付きloggerを取得(または新規作成)
        self.logger = logging.getLogger('file_and_console')
        self.logger.setLevel(logging.DEBUG)  # ログレベルをDEBUGに設定

        # コンソール出力用のハンドラを作成
        console_handler = logging.StreamHandler()
        console_handler.setLevel(logging.DEBUG)

        # ファイル出力用のハンドラを作成
        file_handler = logging.FileHandler('resources/logs/app.log')
        file_handler.setLevel(logging.DEBUG)

        # ログのフォーマットを定義
        formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
        console_handler.setFormatter(formatter)
        file_handler.setFormatter(formatter)

        # コンソールとファイルの両方にハンドラを追加
        self.logger.addHandler(console_handler)
        self.logger.addHandler(file_handler)

    def info(self, msg, log_thread=True):
        # スレッド名を付けて info ログを出力(オプションで付加)
        self.logger.info(f'{"[" + threading.current_thread().name + "] " if log_thread else ""}{msg}')

    def debug(self, msg):
        # スレッド名付きの debug ログを出力
        self.logger.debug(f'[{threading.current_thread().name}] {msg}')

    def error(self, msg):
        # スレッド名付きの error ログを出力
        self.logger.error(f'[{threading.current_thread().name}] {msg}')

    def warning(self, msg):
        # スレッド名付きの warning ログを出力
        self.logger.warning(f'[{threading.current_thread().name}] {msg}')

    def critical(self, msg):
        # スレッド名付きの critical ログを出力
        self.logger.critical(f'[{threading.current_thread().name}] {msg}')

    def exception(self, msg):
        # スレッド名付きの例外ログ(スタックトレース付き)を出力
        self.logger.exception(f'[{threading.current_thread().name}] {msg}')

# グローバルで使える logger インスタンスを作成
logger = Logger()

ちょっとファイル出力周りが雑なような気もしますが今の所はコンソールにフォーマットされたログが出力できればいいので詳しい調整は後々することとします。

次に__main__.pyを書いていきます。

import networking
from core.logger import logger

_version = '0.0.1'

def main():
    logger.info("Pyncraft is running version " + _version)

    # 後ほど実装するサーバーソケットを開く関数
    #networking.start_server()

if __name__ == "__main__":
    main()

まずはこれだけで動かしてみます。
あ、仮想環境のセッティングも忘れずに。
$ python -m venv .venv && source .venv/bin/activate

pyncraft
├─ .venv
├─ core
│  ├─ __main__.py
│  ├─ logger.py
├─ networking
   ├─ __init__.py

$ python -m core

2025-07-14 20:52:18,705 - INFO - [MainThread] Pyncraft is running version 0.0.1

動きましたね。
次でnetworking.start_server()を実装するのでmain()にあるこのコメントは外しておきます。

networkingパッケージの準備

次にnetworkingパッケージにサーバーソケットを開閉するための関数を用意します。
以下はnetworking/__init__.pyの内容です。

from networking.connection import ConnectionListener

# シングルトンの定義
_listener = None

def start_server():
    global _listener
    _listener = ConnectionListener()
    # サーバーソケットを開く
    _listener.start_server()

def stop_server():
    global _listener
    # サーバーソケットを閉じる
    _listener.stop_server()

わざわざ関数に書くのはシングルトンを作るためです。これで故意でない限り、複数のリスナークラス(後述)の生成を防ごうという訳です。
networkingパッケージにconnectionモジュールを追加しました。これの説明もこの直後。

サーバーソケットを開く

まずは外部からの接続を監視するサーバーソケットを開きます。
また、今回はリスナーを作るので、このサーバーソケットを管理するためのリスナークラスと各クライアントとの接続を代表するクラスを定義します。これらのクラスはconnectionモジュール内で、ConnectionListenerConnectionという名前にしました。
まずはConnectionListenerから書いていきます。

import socket
import threading
from typing import List

from core.logger import logger

class ConnectionListener:
    
    def __init__(self):
        # 接続リストとロックの初期化
        self.connections: List[Connection] = []
        self.connection_list_lock = threading.Lock()
        # サーバー停止イベントとスレッド管理用の変数
        self.server_stop_event = threading.Event()
        self.server_thread = None
        self.server = None

    def _listen_connection(self):
        logger.info('Listening for connections...')
        while not self.server_stop_event.is_set():
            try:
                # クライアントの接続を待機
                client, addr = self.server.accept()
            except socket.timeout:
                # タイムアウトでループ継続(シャットダウン検出用)
                continue
            # 新しいconnectionオブジェクトを作成してスレッド開始
            con = Connection(client, addr, self)
            con.start()
            # スレッドセーフなリスト改変
            with self.connection_list_lock:
                self.connections.append(con)
        # self.server_stop_eventがフラグを立てたらサーバーソケットを閉じる
        logger.info('Connection listener is shutting down...')
        # 接続中のクライアントをシャットダウン
        with self.connection_list_lock:
            active_connections = list(self.connections)
        for connection in active_connections:
            connection: Connection = connection
            connection.interrupt()
        for connection in active_connections:
            connection.join()
        
        self.server.close()
        logger.info('Terminating listener')

    def start_server(self):
        logger.info('Starting server...')
        address = '0.0.0.0'
        port = 25565
        
        # ソケットの作成とバインド
        self.server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        self.server.bind((address, port))
        self.server.listen(1)
        self.server.settimeout(1.0)  # タイムアウトで停止フラグをチェックできるようにする
        # リスナースレッドを開始
        self.server_thread = threading.Thread(target=self._listen_connection, name='ConnectionListener')
        self.server_thread.start()
        logger.info('Server started!')
    
    def stop_server(self):
        if not self.server_thread:
            logger.warning(f'Connection listener not started.')
            return
        # 停止フラグを立ててスレッドを待機
        self.server_stop_event.set()
        self.server_thread.join()

ConnectionListenerのオブジェクトを生成し、ConnectionListener.start_server() ConnectionListener.stop_server()を呼ぶことでサーバーソケットの開閉ができる仕組みです。(開閉といっても、一度閉じたらもう開きませんが) 少し語弊のある名前ですが、ここで言うserverとはマイクラサーバーの事ではなくサーバーソケットの事です。

このリスナーを別スレッドで動かすことによって、メインスレッドを妨害することなく常に接続を監視しています。

同時接続を受け付ける

クライアントからの接続があると、リスナーはConnectionオブジェクトを生成します。それ以降のコミュニケーションを委託する形でConnectionスレッドを作り、リスナーのリストにしまっています。(スレッド作りすぎかな?でもI/Oバウンドだから問題ないはず...)
つまり、Connectionのスレッドは各クライアントとサーバーとの接続を並列して処理する仕組みですね。
そしてそのConnectionクラスが以下です。

class Connection:

    def __init__(self, client: socket.socket, address, listener: ConnectionListener):
        self.client = client
        self.listener_thread = None
        self.connection_stop_event = threading.Event()
        self.connections_list = listener.connections
        self.lock = listener.connection_list_lock

    def start(self):
        if self.listener_thread:
            logger.warning('Listener already set')
        self.listener_thread = threading.Thread(target=self._handle_connection, name=f'ConnectionListener-{len(self.connections_list)}')
        self.listener_thread.start()   

    def _handle_connection(self):

        self.client.settimeout(0.1) 
        while not self.connection_stop_event.is_set():

            # socketから直接バッファを読む
            try:
                data = self.client.recv(1024)
            except socket.timeout:
                data = None

            # recv()は接続が切れると空のバイト(b'')を返す
            if data == b'':
                logger.info(f'Client {self.client.getpeername()} disconnected.')
                break
            
            # ここでクライアント行きのパケットを送信
            if data is None:
                continue

            # ここでクライアントからのパケットを処理
            print(str(data))

        # self.connection_stop_eventがフラグを立てたらコネクションを切る
        logger.debug('Connection is shutting down...')
        self.client.close()
        self.close()
    

    def interrupt(self):
        # 接続停止を通知
        self.connection_stop_event.set()

    def join(self):
        # スレッドの終了を待機
        self.listener_thread.join()

    def close(self):
        # 接続リストから削除してソケットを閉じる
        with self.lock:
            self.connections_list.remove(self)
        self.client.close()

    def get_connection(self):
        return self.client

ConnectionConnectionListenerと同じように別スレッドで動かす為、一通りのライフサイクル用関数とロジックを入れておきました。Connectionオブジェクトが生成され、ConnectionListenerからクライアントソケットを受け取り、Connection.start()で別スレッドを立ち上げそれ以降の通信を、メインスレッドを妨害する事なく実行し続けます。コネクションを閉じる時はConnection.interrupt() -> Connection.join()を順番に呼んでスレッドを安全に閉じる仕組みです。何だかJavaみたいですか?

Connectionで重要になる関数が_handle_connection()です。コメントにも残しましたが、それぞれのコードブロックでクライアント行き、サーバー行きのパケットを処理します。今回はまだsocketから直接バイナリを読んでいますが、パケットの定義と一緒にパケットインプット/アウトプットストリームとしてwrapするつもりです。
パケットの定義は次回の記事で行います。

(ちなみにsocketから直接バッファを読む時は普通、selectモジュールを使うのが無難です。)

実際にマイクラからPingしてみる

あ、先に言っておきますが今回実装したのはサーバーソケットとリスナーだけなのでPingは受け取れこそしますが返すことができません。なのでサーバーが受け取ったパケットをそのままバイナリ文字列としてコンソールにログしています。とりあえずサーバーが別マシンからの接続を受け付けられればいいので。

サーバーを起動します。
$ python -m core

2025-07-14 23:55:51,593 - INFO - [MainThread] Pyncraft is running version 0.0.1
2025-07-14 23:55:51,594 - INFO - [MainThread] Starting server...
2025-07-14 23:55:51,594 - INFO - [ConnectionListener] Listening for connections...
2025-07-14 23:55:51,594 - INFO - [MainThread] Server started!

サーバーのIPを取得します。
$ ipconfig getifaddr en0

172.20.10.3

このIPにマイクラから接続します。
image.png

2025-07-14 23:55:51,593 - INFO - [MainThread] Pyncraft is running version 0.0.1
2025-07-14 23:55:51,594 - INFO - [MainThread] Starting server...
2025-07-14 23:55:51,594 - INFO - [ConnectionListener] Listening for connections...
2025-07-14 23:55:51,594 - INFO - [MainThread] Server started!
b'\x12\x00\x84\x06\x0b172.20.10.3c\xdd\x02'
b'\x1c\x00\nkitsui_zzz\xcb@\x86|P#H\xf9\x90\xc7!}\x87\xdcjD'
2025-07-14 23:57:55,317 - INFO - [ConnectionListener-0] Client ('172.20.10.13', 34302) disconnected.
2025-07-14 23:57:55,317 - DEBUG - [ConnectionListener-0] Connection is shutting down...

何かを受け取ったようですね。これがマイクラのHandshakeパケットです。具体的にはLogin Intentでサーバーからのリスポンスを待っていた筈です。
そして一定時間応答が無かったのでタイムアウトしたようです。

まとめ

  • サーバーソケットはConnectionListenerクラスの別スレッド内で常に接続を監視している
  • その後のクライアントとの接続はConnectionクラスが引き継ぐ
  • Connectionクラスも別スレッドで動くため同時接続が可能である
  • Ctrl + CでプログラムをKillした際のclean upをド忘れしていたので次回までに実装しておきます
2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?