はじめに
こんにちは。マイクラサーバーを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
モジュール内で、ConnectionListener
とConnection
という名前にしました。
まずは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
Connection
もConnectionListener
と同じように別スレッドで動かす為、一通りのライフサイクル用関数とロジックを入れておきました。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
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をド忘れしていたので次回までに実装しておきます