はじめに(全体の流れ)
どうも,授業が始まらず家でニートしている大学生です.今はニートで暇だし簡易的なサーバーでも作って見ようかなと思い,サーバー構築するまで寝れないという制限を自らに課し,1日で出来る範囲で実際に作ってみました.初心者なので1から作るとさすがに1日では出来そうになく,今回はPythonでsocketserverという標準のサーバー構築用ライブラリ(というかフレームワーク)を使って実装してみました.サーバー立ち上げまでの流れは以下のとおりです.
- どのようなデータを通信するか考える
- データ送受信のプロトコルを考える
- 考えたプロトコルのパーサーを作る
- リクエストハンドラを作る
- 実行ファイル(サーバーを立ち上げるファイル)を作る
記事を投稿した経緯
実際にこの「サーバー構築チャレンジ」を行っているときはこのような記事を書くことは考えていませんでした.しかし今回このように記事として投稿している理由は,Pythonを使ったサーバーの構築方法について解説している記事があまりにも少ないと感じたからです.あったとしてもWebサーバーで,具体的な応用方法などは書いていません.そこで今回は謎の使命感のもと,自身の「サーバー構築チャレンジ」の備忘録も兼ねつつ,読者の方に以下の点に重点をおきPythonを使ったサーバー構築の手順をお伝えします.(チャレンジを行う前の僕のように)「サーバーってなんか難しそう」と思っている方はぜひご参考ください.
- サーバーを自作する際の流れ
- socketserverライブラリの使い方
注意点
はじめに注意しておきますが,実装するサーバーはTCPサーバーです.HTTPサーバーではないのでご注意ください.また,ここでのプロトコルは通信のプロトコルというよりは,データに対する取り決めという意味でのプロトコルであり,今回作ったプロトコルは洗練されていませんし,データの暗号化なども行っていないので,LAN内で遊ぶ程度のものであることをご留意ください.
それでは実装!
と行きたいところですが,その前にソケット通信についての軽いおさらいだけしておきましょう.既にご存知の方は飛ばしても大丈夫です.
ソケット通信の気持ち
ここではソケット通信の気持ちについて軽く触れます.あくまで気持ちなので正確ではないかもしれません.詳細はこの記事やこの記事をご覧になってください.また実装時は公式ドキュメントもご参照ください.
ソケット通信の肝は「ソケット」
ソケット通信はその名の通り,通信を「ソケット」というもので抽象化したものです.みなさんはソケットというとどのようなものを思い浮かべるでしょうか.有名な話ですが,日本では「コンセント」の名前でよく知られる電源プラグの差込口は英語圏では通じず,代わりに「ソケット」と呼んだりするそうです(別の言葉もあるそうですが).つまりソケットとは私たちのよく知るコンセントと似たイメージで英語圏では使われるわけです.この「現実のソケット」には以下のような特徴があります.
- 発電所から伝送されてきた電力を利用する末端
- 内部の仕組みがわからなくてもプラグを挿せば利用できる
このような特徴は今回扱う「ソフトウェアのソケット」にも備わっています.現実のソケットと異なる点は,ソフトウェアにおけるソケットはリソース(家庭の場合は電力,ソフトウェアの場合はデータ)を一方的に受け取るだけでなく,双方向的に授受できるという点です.ソケット通信ではソケットからデータを取り出すこともできれば,データをぶち込むことで相手側のソケットにデータを送ることもできます.その際,通信における下のレイヤーでの処理は気にしなくても通信できるというのも注目すべき点です.つまりどのようにデータを送っているか,という点をあまり気にせず利用することができるわけです(もちろん現実のソケットでもそうですが,とんでもない使い方をすると危険です).
簡単な実装例
さて,ソケット通信では「ソケットにデータを入れる」という感覚で簡単に送信ができるということがわかりました.ここでソフトウェアのソケットの注意点を一つ述べておきます.ソフトウェアのソケットは使い終わったら閉じることを忘れないようにしましょう.しっかり閉じないとサーバー側のスレッドを拘束する原因になってしまう可能性があるからです.その点に注意しながら,以下では簡単な実装をしていきます.ここでは,クライアント側から送られてきたテキストデータをサーバー側でただ標準出力へ出力するシステムの実装例を示します.
サーバー側
まずはサーバー側です.サーバー側では
- アドレスをバインド
- クライアントを受け付ける
- 受け入れソケットを処理する
という流れになります.では実装例です.
import socket
HOST = "localhost"
PORT = 8000
ADDRESS = (HOST, PORT)
with socket.socket() as ssock:
ssock.bind(ADDRESS) # アドレスをバインド
ssock.listen(1) # クライアント受け入れ開始
conn, addr = ssock.accept() # 受け入れソケットの取得
# 以下,受け入れソケットの処理
with conn:
while True:
frag = conn.recv(1024) # ソケットからデータを最大1024バイト取り出す
if not frag: break
print(frag.decode())
ここで使ったメソッドの簡単な説明は後述します.詳しくは公式ドキュメントで確認してください.補足ですが,listenメソッドを呼び出すとクライアント側からのリクエストが来るまでずっと待機します.acceptメソッドはbindメソッド,listenメソッド呼出し後でなければ呼び出すことはできません.また,受け入れソケットはサーバー側でクライアントを受け入れたときに,クライアントと通信をするために生成されるソケットです.初めにサーバー側のアドレスをバインドしたソケットとは別物なので注意してください.
クライアント側
クライアント側ではサーバーのようにアドレスをバインドする必要はありません.その代わりサーバー側のアドレスに向かって接続要求を出します.接続後はソケットにデータをぶち込めばOKです.では実装例です.
import socket
HOST = "localhost"
PORT = 8000
ADDRESS = (HOST, PORT)
with socket.socket() as sock:
sock.settimeout(10) # タイムアウトを10秒に設定
sock.connect(ADDRESS) # サーバー側のアドレスを指定して接続要求
sock.sendall(b"Hello World!!") # ソケットにデータを入れる
ソケットの開け閉め
先ほど,ソケットは使い終わったら閉じると述べましたが,閉じるためにはsocket.close()メソッドを使います.ただし閉め忘れが起きたり,ライフサイクルがよくわからなくなることがあるため,それらを防止するためにwith文を用いています.これが出来るのはsocket.socket()関数がsocketオブジェクトを生成するファクトリ関数であり,socketオブジェクトがコンテキストマネージャーであるからです.同じような役割を果たす関数に標準のopen()関数がありますが,使うときの感覚は同じです.ちなみに,サーバー側の実装例でconnという受け入れソケットに対してもwith文を用いていますが,これもconnがソケットオブジェクトであるからです.
今回使うメソッド
今回扱う基本的なsocketオブジェクトのインスタンスメソッドを下表に示します.ただし下表におけるaddressは上での例でのように(HOST, PORT)のタプルであることに注意してください.他にも沢山メソッドはありますが,今回はソケット通信に関してはそこまで複雑なことはしませんので割愛させていただきます.詳しくは公式ドキュメントへ.
メソッド名 | 返り値の型 | 概要 |
---|---|---|
accept() | (socket, tuple) | 接続を受け付けて受け入れソケットと接続元のアドレスを返す (サーバー側) |
bind(address) | None | ソケットをaddressにバインドする (サーバー側) |
close() | None | ソケットを閉じられたものとしてマーク (≒ 閉じる) |
connect(address) | None | addressで指定されるソケットに接続する |
listen([backlog]) | None | サーバーを有効にして接続を受け付ける (サーバー側) |
recv(bufsize) | bytes | bufsizeで指定したバイト数の受信データを返す |
send(bytes) | int | bytesで指定したデータを送信し,送信したバイト数を返す |
sendall(bytes) | None | bytesで指定した全データを送信する |
上で述べたメソッドの説明には足らない部分が多いため,実際に使用する場合は公式ドキュメントを必ず参照してください.ちなみに,sendメソッドとsendallメソッドの違いはバッファリングに起因するもので,単にsendメソッドを使っただけでは全てのデータが完全に接続先に送られていない場合がありますが,sendallメソッドを使えば全データを送信することが出来ます(つまりflushしています).
本題へ
ここまで,ソケット通信について簡単な例を取り上げ説明してきました.これからサーバーを作っていきますが,どんなに複雑になろうと上で述べたソケット通信という土俵の上でごちゃごちゃしているに過ぎません.その点を常に頭の片隅に置いて,本題であるサーバーの実装に移りましょう.
プロトコルを作る
この章では「はじめに」で述べた全体の流れの1,2に相当する部分を扱っていきます.この章は少々退屈に感じてしまうかもしれません.しかしサーバーの役割がクライアントと円滑なデータの授受であるということを認識すれば,データ授受のプロトコルは必然的に必要になってきます.今回は仮想的なサービスを考え,それに適したプロトコルを作成していきます.
なぜプロトコルが必要なのか
さて,先ほど見たソケット通信の例にはデータの送受信において特別な取り決めは見受けられませんでした.強いて言うなら「送られてきた全てのデータをそのまま文字列に変換する」というのが取り決めとも言えるでしょう.ですが,それだと文字列限定になってしまいますし,構造化されたデータ(JSONやXMLなど)を送るには単純すぎて自由度が低いです.また,画像などのバイナリデータを扱うことはできません.データを送るからには何かしらの目的があるわけですので,その目的にあった整理されたデータを送りたいものです.
ではどのようにすれば規則正しく整理されたデータを送ることが出来るでしょうか.答えは簡単で,既に作られた規則(プロトコル)を用いてデータを送受信するか,自分でそのプロトコルを作るかのどちらかです.今回はその後者の方法をとります.その主な理由は,今回はそこまで複雑なプロトコルを必要としていないからです.ただ,WEBサーバーなどを作りたい場合はクライアントがブラウザという制限があり,HTTPというプロトコルを利用する場合が大多数だと思いますので,利用するべきか自作するべきかはそのケースをじっくり考えて選択するべきでしょう.
まずはデータの種類を考える
今回は画像サービスを扱うシステムを作ってみたいと思います.最近ディープラーニングでの画像認識サービスが盛んなので,今どきの題材でしょう.この場合,クライアントとサーバーの関係は以下のようになると考えられます.
- クライアント - 画像を提供しサービスを利用するユーザー
- サーバー - 画像を受け取りサービスを提供する企業
このような場合,画像をただ送るだけでは柔軟なサービスを展開することは難しくなってしまうでしょう.なので,今回はJSONファイル(もしくはそれと同等な形式)と複数の画像データを送受信することにします.JSONを送ることでユーザーに対するテキストデータや画像データに対する説明などを整理された形で送ることが可能になります.
ヘッダーの追加
ここまでの話を整理しておきましょう.送るデータは,
- JSONファイル
- 複数の画像ファイル
の2種類です.ただし,データの大きさは任意です.データの大きさを任意にすることを担保するために,データにヘッダーを付け加えることにしましょう.ヘッダーでは受信したデータを解析するために必要なデータを付け加えます.
データの大きさが任意であることは解析に支障をきたします.データの大きさがわかっていれば,データをどこからどこまで読めばいいかわかりますが,データの大きさが不明となると読み込む位置がわからず,一つでも読み込む位置が間違っていればデータが破損する危険性もあります.これに対処するために,今回はヘッダーにJSONのデータの大きさと各画像のデータの大きさを加えることにしましょう.そしてヘッダーの大きさを128バイトに固定します.サーバー側でもクライアント側でもヘッダーの大きさは128バイトであると決めておけば,受け取ったデータの頭から128バイトは必ずヘッダーであると認識することができます.それ以下のJSONと画像データはそのヘッダー情報に基づいて解析すればいいわけです.ヘッダーを128バイトに固定することによって起こる不具合としては,画像の量が多すぎたり容量が大きすぎたりするのが原因でヘッダーに記述すべき情報が128バイトを超えてしまうという問題が考えられます.その場合はヘッダーの容量を(例えば512バイトくらいに)大きくするか,特別な記号を決めておいてそれをヘッダーの終わりの合図にするなどの対処をする必要があります.
データのプロパティの追加
ヘッダーまででデータの送受信は問題なく行えそうですが,最後に今後のサービスの複雑化を見越してデータのプロパティを付け加えることにします.ここでいうデータのプロパティとは言葉のとおりデータの性質のことです.今回は送られてきたデータが何のデータであるかは判別するためのアルファベット1文字(1バイト)の記号をプロパティとします.このプロパティを用いて解析方法をシフトすることができるようにします.ただし,プロトコルがカオスになることを避けるため,「プロパティが異なっていてもヘッダーの構造は常に同じになる」と約束しておきます.今回は以下の2つのプロパティしか使いません.必要に応じてプロパティを増やすことは可能です.
プロパティ | 意味 |
---|---|
P | クライアント側からの画像付きデータ |
C | データの受け取りに対するサーバー側からのレスポンス |
上表のプロパティPが今回の主役です.JSONと画像データの両方を一度に送ります.プロパティCは主にサーバーがクライアントからデータを受け取ったことをクライアントに知らせるためのレスポンス用に使います.JSONデータは送りますが画像データは必要ないので送らないことにします.この他にもいろいろなサービスを展開するために追加のプロパティを考えることはできますが,複雑化を防ぐためこの記事ではこれくらいで留めておきます.
総括
以上で述べた内容から,サーバー側・クライアント側の双方で送受信されるデータの構造は順に以下のようになります.
- データのプロパティ(1バイト)
- ヘッダー(128バイト)
- JSON (大きさ任意)
- 画像 (複数可能・大きさ任意)
※プロパティPのみ
となります.この4つの要素をつなぎ合わせることで今回のプロトコルに則ったデータをなります.
ここまで今回用いるプロトコルについて話してきましたが,プロトコル自体は実装されることはありません.プロトコルは単なる取り決めであって,実装はプロトコルに基づいたデータのパーサー(データを解析するもの)として行います.それでは次章では本章で取り決めたプロトコルをパーサーとして実装していきます.
パーサーを作る
データの解析は主に受信側で行われる操作ですが,一方で送信側ではもととなるJSONや画像データを受信側が解析できる形に整形してあげる必要があることがわかります.つまりソフトウェア用語を拝借するなら,送信側ではデータをエンコード,受信側ではエンコードされたデータをデコードする必要があるわけです.今回はこのパーサーをエンコード機能とデコード機能を備えたユーティリティクラスとして実装します(名前はコーデックのほうがふさわしいかもしれませんが).では,実装に移りましょう.
エンコード
まずはエンコード側から作っていきましょう.JSONと画像データを取り出しながらそのデータの大きさをヘッダーとしてまとめて,最後にすべての要素を組み合わせる流れになります.以下に実装例を示します.
import json
from typing import *
class Parser:
SIZE_HEADER:int = 128
SEPARATOR:bytes = b"$"
@classmethod
def encode(cls, prop:str, js:dict, *imgs:Tuple[str])-> bytes:
bufsizes = [] # JSONと画像データの大きさを格納するリスト
# json
content = bytearray(json.dumps(info).encode())
bufsizes.append(len(content))
# images(imgsに何も指定されなければスルー)
for img in imgs:
with open(img, "rb") as f:
img_buf = f.read()
bufsizes.append(len(img_buf))
content.extend(img_buf)
# header
# - - - - - - - - - - - - - - - - - -
# (json size)$(img1 size)$(img2 size)$...$$$$$$$$$$$$$$$$$$ # (128 bytes)
# - - - - - - - - - - - - - - - - - -
header:bytearray = bytearray()
for size in bufsizes:
header.extend(str(size).encode() + Parser.SEPARATER)
while len(header) < Parser.SIZE_HEADER:
header.extend(Parser.SEPARATER)
# プロパティ,ヘッダー,コンテンツ(JSON + 画像)を結合
d = bytearray(prop.encode())
d.extend(header)
d.extend(content)
return bytes(d)
簡単に上記のコードを説明すると,まず入力としてプロパティ1文字とJSONに変換可能な表現である辞書型変数 js ,そして画像データのパスを可変長引数で受け取ります.JSONデータの加工では辞書型 js を json.dumps() 関数を使って文字列表現に変換し,さらにencodeメソッドを使ってUTF-8でbytes形に変換しています.画像データの処理では引数ではパスを受け取ることから,画像ファイルを開き,バイナリデータを読み込んだ上で処理を行っています.最後にヘッダー部分の処理を行い,コンテンツ部分と合わせれば終了です.ただ,ヘッダーでプロトコルに則りJSONと各画像データの大きさを含めるようにしていますが,各数値の間に**$を挟むようにしており,128バイトに満たない場合は残りのデータを全て$**で埋めるようにしています.ちなみに,ヘッダーの「128バイトを超えてしまう問題」はクラス変数のSIZE_HEADERを編集すれば解決できます.
デコード
次に上でエンコードしたデータをデコードする機能を実装していきましょう.エンコードと対称的な実装をしたいものですが,今回はそうはせずエンコードとは少し異なった実装をしていこうと思います.そのほうが見通しが良く管理もしやすいためです.
具体的にどのように実装するかというと,
- ヘッダーのパーサーメソッドを作る
- 各プロパティ別にデコードメソッドを作る
- decodeメソッドを作り内部で1で作った適切なデコードメソッドへデータを渡す
ここではdecodeメソッドはプロパティに対するハンドラ(適切に処理するもの)であり,その内部にはデータ本体に対する処理は記述しません.その代わり各プロパティ別に個別のデコードメソッドを作成し,decodeメソッドでは内部で受け取ったプロパティと対応するデコードメソッドに受信データを渡し処理させます.このように実装することで,今回は2つのプロパティがサービスの拡大に伴いだんだん増えていったとしても,その新たなプロパティに対応するデコードメソッドを新しく用意し,decodeメソッドで分岐を1つ増やすだけで事足ります.文面だけでは実感が湧かないと思うので,実際のコードを見てみましょう.
class Parser:
SIZE_HEADER = 128
SEPARATER = b"$"
PROPERTY_POST = "P"
PROPERTY_CONFIRM = "C"
@classmethod
def encode(cls, prop:str, js:dict, *imgs:Tuple[str])-> bytes:
"""省略"""
# 実際にデコードする際にはこのメソッドしか使わない
@classmethod
def decode(cls, d:Union[bytes, bytearray])-> Tuple[str, dict, Optional[tuple]]:
# 各要素の分解
d = bytearray(d) if isinstance(d, bytes) else d
prop = chr(d.pop(0))
bufcounts = Parser.parse_header(d[:Parser.SIZE_HEADER])
content = d[Parser.SIZE_HEADER:]
# プロパティに応じてハンドリングする
if prop == Parser.PROPERTY_POST:
return Parser.decode_post(content, bufcounts)
elif prop == Parser.PROPERTY_CONFIRM:
return Parser.decode_res(content, bufcounts)
# ヘッダーのパーサーメソッド
@staticmethod
def parse_header(header:Union[bytes, bytearray])-> Tuple[int]:
# bytes → list
bufcounts:list = []
size:list = []
for char in header.decode():
if char == Parser.SEPARATER.decode():
if len(size) == 0: continue
bufcounts.append(int("".join(size)))
size = []
else:
size.append(char)
# データの大きさを累計に直す
for i in range(1, len(bufcounts)):
bufcounts[i] += bufcounts[i - 1]
return tuple(bufcounts)
# プロパティ"P"用のデコードメソッド
@staticmethod
def decode_post(content:Union[bytes, bytearray], bufcounts:Tuple[int])-> Tuple[str, dict, Optional[tuple]]:
js:dict = json.loads(content[:bufcounts[0]])
imgs:list = []
for i in range(1, len(bufcounts)):
imgs.append(content[bufcounts[i - 1]:bufcounts[i]])
return Parser.PROPERTY_POST, js, tuple(imgs)
# プロパティ"C"用のデコードメソッド
@staticmethod
def decode_res(content:Union[bytes, bytearray], bufcounts:Tuple[int])-> Tuple[str, dict, Optional[tuple]]:
return Parser.PROPERTY_CONFIRM, json.loads(content[:bufcounts[0]]), None
やや粗い実装となっている点はご容赦ください.ポイントとなる地点ではコメントを振ってるので流れ自体は掴めると思います.補足説明として2点述べておきます.1つ目はparse_headerメソッドです.前章でプロパティが違えどヘッダーの構造は同一にすると述べましたが,上の実装ではその性質からヘッダーの解析部分を一つのメソッドに分離しています.ヘッダーの解析の分離にはヘッダーが固定長である点も一役買っています.2つ目の補足事項はヘッダー情報をリストに変換した後,リストの各要素を累計の数値に変換している点です.この理由は,ヘッダー情報から実際にデータを解析する際にはデータの長さよりデータの頭とお尻のオフセット(インデックス)を利用するからです.これはdecode_postメソッドのループ内を見るとわかるでしょう.
コーディング的にはここが山場で,これ以降はそこまで複雑にはなりません.この記事ではコーディングより,実装の流れを掴むことに重きを置いているので,文章を読み込んでまず流れを掴んでからコードを見直すとスッキリ読めるかなと思います(それでも読みづらければスミマセン).
総括
本章では前章で作ったプロトコルに則って送信データをエンコードしたり,受信データをデコードするメソッドを備えたユーティリティクラスであるParserクラスを実装しました.Pythonには関数オブジェクトがあるためそちらでまとめてもよかったのですが,今回はパーサーが1つのオブジェクトであることを強調するためにクラスとして実装しました.また,ユーザーに対してのインターフェースはencodeメソッドとdecodeメソッドが基本で,この2つはクラスメソッドにしました.他の表舞台に出てこないメソッドについてはstaticメソッドにしました.この2つのメソッドの種類の違いは使用上ほとんどありませんが,区別しないと気持ちが悪かったので区別しました.次章からはここで実装したパーサークラスを単なるツールとして次のステップであるリクエストハンドラを作っていきます.
リクエストハンドラを作る
前章まででデータがソケットに入る前の加工とソケットから出てきた直後の加工の準備が整いました.データをまともに扱えるようになったサーバーの残る主な処理は,デコードし終わったデータを扱ってサービスを展開する処理です.この処理は目的によって様々であり,みなさんの目的に合わせて個人で実装していけばよいでしょう.しかし今回はPython標準のsocketserverライブラリを利用するので,このライブラリの要求する手順にある程度従いながら実装する必要があります.この章ではその手順について簡単に説明するとともに,実際に簡単なリクエストハンドラを作っていきます.
socketserverライブラリ
ということでまずは座学です.socketserverライブラリの使い方を学びましょう.このライブラリには2つの主要なクラスがあります.1つはServerクラスです.もう1つはRequestHandlerクラスです.この2つのクラスを理解することで,このライブラリを利用することができるようになります.
ServerオブジェクトとRequestHandlerオブジェクト
まずは上記2つのクラスの関係性を理解しておきましょう.2つのクラスのオブジェクトの特徴は,ServerオブジェクトがRequestHandlerクラスを内部に含むという点です.Serverオブジェクト生成時に以下のようにサーバーアドレス(socket生成時と同様のもの)とRequestHandlerクラスを指定する必要があります:
server = MyServer(address, MyRequestHandler)
引数で指定したRequestHandlerクラスはServerオブジェクトのインスタンス変数RequestHandlerClassに入っています.そのインスタンス変数の説明を公式ドキュメントから拝借します:
RequestHandlerClass
ユーザが提供する要求処理クラスです; 要求ごとにこのクラスのインスタンスが生成されます。
なるほど.RequestHandlerオブジェクトはその名の通りクライアントからのリクエストに対処するためだけのオブジェクトであり,そのインスタンスはクライアントからのリクエストがあるとServerオブジェクトの管理下で生成されるようです.リクエストの具体的な処理がRequestHandlerオブジェクトに委ねられていることが分かったので,RequestHandlerクラスについてもう少し掘り下げてみましょう.
RequestHandlerクラス
socketserverライブラリにはRequestHandlerクラスとして以下の3つのクラスが用意されています.
- BaseRequestHandler
- StreamRequestHandler
- DatagramRequestHandler
下2つのクラスはBaseRequestHandlerクラスをそれぞれ継承しています.この3つのクラスはそれぞれ3つの共通するメソッドを持っています:
メソッド名 | 説明 |
---|---|
setup() | handleメソッドより前に実行され,各種初期化が行われる |
handle() | リクエストに対する処理を実装する |
finish() | handleメソッド終了後に実行され,クリーンアップ処理が行われる |
この3つのメソッドでクライアントからのリクエストに対処しなければなりません.ただし,肝心のhandleメソッドはデフォルトでは実装を持ちません(BaseRequestHandlerクラスはsetupもfinishも実装を持ちません).つまり何かしらの処理をさせたければオーバーライドする必要があります.ライブラリ側からはただメソッド実行の順番とメソッド名が提供されているだけです.加えてこの3つのメソッドはそもそも引数をとりませんので,オーバーライドするときには注意が必要です.
では,この3つのメソッドを使ってどのようにリクエストハンドリングをするのでしょうか.最初僕はこのメソッドの説明だけ書かれたドキュメントを見て目が点になりましたが,次の3つのインスタンス変数の存在を知ってようやく実装のイメージが湧きました.
インスタンス変数名 | 説明 |
---|---|
request | リクエストに対する受け入れソケット(Socketオブジェクト) |
client_address | リクエストを送ったクライアントのアドレス |
server | 自分(RequestHandlerオブジェクト)を生成したServerオブジェクト |
まとめると,「上の3つのインスタンス変数がRequestHandlerオブジェクト生成時に生成されるので,これらを駆使して先ほどの3つのメソッド内でやりたいこと実装してくれ」というのがこのライブラリの思惑だったわけです.初期化をコンストラクタではなくsetupメソッドで行えというのも少し納得できた気がします.ちなみにStreamRequestHandlerオブジェクトとDatagramRequestHandlerオブジェクトには追加でrfile変数とwfile変数があるらしく,それぞれクライアントとの通信におけるio.BufferedIOBaseの読み取り用インターフェースと書き込み用インターフェースを備えているらしいです.データをファイルを扱う要領で操作できるのは便利ですね.ただ,この説明だけだとStreamRequestHandlerとDatagramRequestHandlerは何が違うんだ,と思われるかもしれませんが,その謎はすぐに解けます.
Serverクラス
RequestHandlerオブジェクトをラップするServerオブジェクトは非常に優秀で,おそらくこのオブジェクトを利用できることがsocketserverライブラリの最大の魅力だと思います.アドレスとRequestHandlerクラスを与えるだけでサーバーに必要な基本的な処理のほとんどを内部で行ってくれます.拡張することでもっと応用的な使い方をすることができるのですが,今回は用意されている既存のクラスを利用することにします.
socketserverライブラリにはServerクラスとして合計11個のクラスが定義されています.
- BaseServer
- TCPServer(BaseServer)
- UDPServer(TCPServer)
- UnixStreamServer(TCPServer)
- UnixDatagramServer(UDPServer)
- ForkingMixIn
- ThreadingMixIn
- ForkingTCPServer(ForkingMixIn, TCPServer)
- ForkingUDPServer(ForkingMixIn, UDPServer)
- ThreadingTCPServer(ThreadingMixIn, TCPServer)
- ThreadingUDPServer(ThreadingMixIn, UDPServer)
※()内は継承元
おそらく実用的なのは上のリストの下4つのServerクラスじゃないかなと思います.Forking...クラスとThreading...クラスは,それぞれ無印クラスのマルチプロセス,マルチスレッドバージョンです.ちなみに先ほどのStreamRequestHandlerとDatagramRequestHandlerの違いの謎ですが,rfile変数とwfile変数を使いたい場合にTCPServerを使うならStreamRequestHandlerを,UDPServerを使うならDatagramRequestHandlerをRequestHandlerとして使わなくてはならないそうです.
これ以上のクラスの説明は(詳しいとは言い難いが)公式ドキュメントに譲ります.今回はTCPServerクラスを使うことにします.
socketserverライブラリのまとめ
そこまで詳しくないくせに「まとめ」というのはおこがましいですが,あんまり長いと良くないのでここらへんでまとめさせていただきます.そのまとめを公式ドキュメントさんにしてもらうことにします(この部分はさすがに訳してほしかった).
To implement a service, you must derive a class from BaseRequestHandler and redefine its handle() method. You can then run various versions of the service by combining one of the server classes with your request handler class.
(意訳)サーバーを実装するためにはBaseRequestHandlerクラスを継承しhandleメソッドをオーバーライドする必要があります.その後,Serverクラスのうちの1つとそのRequestHandlerクラスを組み合わせることで,様々なサービスを実行できるようになります.
これが全てですね.一にも二にもリクエストハンドラを作れということです.これはすなわちユーザーの主な負担はリクエストハンドラのhandleメソッドの実装ということになります.
実際にリクエストハンドラを作っていく
前置きが凄まじく長くなってしまいましたが,そのかいもあってみなさんはもうsocketserverの使い方をおおむね把握できたと思いますので(そう信じたい),説明は短めにリクエストハンドラを作っていきましょう.
今回実装してみるサービスは,サーバー側ではクライアントから受け取ったデータをただ決まったディレクトリ内に保存するというものです.クライアントとインタラクティブでないのがちょっとあれですが目をつぶりましょう.実装の前にディレクトリ構造だけ定めておきます:
test_server/
├ clients/
| ├ 00000000/
| | └ 2020.04.30/
| | ├ info.json
| | └ 0001.png
| └ 00000001/
├ parser.py
├ handler.py
└ launch.py
クライアントから送られてくるデータは全てclientsディレクトリに置きます.clientsディレクトリではまず8桁のクライアントのIDを設け,このIDで分割し,その日の日付を使ってさらに分割させていくことにします.現実では,1日に複数回サービスを利用することを想定してもっとしっかりした構成を作るべきだと思いますが,今回はこの構成で妥協することにします.test_serverディレクトリ直下には,3つのPythonファイルを置くことにします.parser.pyは前章ですでに実装しました.この章ではhandler.pyを実装します.そしてlaunch.pyを次章で実装すれば,ようやくこの長ったらしい記事は終わりを迎えます.では,ひとまずhandler.pyの実装です.BaseRequestHandlerクラスを継承し,必要なメソッドをオーバーライドしていきます.
from socketserver import BaseRequestHandler
from datetime import date
from typing import *
import sys
import os
from parser import Parser
def get_date_stamp()-> str:
return date.today().strftime("%Y.%m.%d")
# 画像ファイルの名前用
def int2string(x:int, digit:int):
x = str(x)
rest = digit - len(x)
if rest < 0:
raise ValueError("The argument digit should be no less than the digit of the x.")
return "0" * rest + x
class Handler(BaseRequestHandler):
DIR_CLIENTS = os.path.abspath(os.path.join(os.path.dirname(__file__), "clients"))
EXTENSION_PNG = ".png"
PROPERTY_POST = "P"
PROPERTY_CONFIRM = "C"
# ディレクトリのパスを取得
@classmethod
def mkdir_clients(cls, id:str)-> str:
path = os.path.join(cls.DIR_CLIENTS, id, get_date_stamp())
if not os.path.isdir(path):
os.makedirs(path)
return path
# クライアントのアドレスだけ表示
def setup(self):
print("connected: ", self.client_address)
# 一番重要
def handle(self):
# データの取得
data = bytearray()
while True:
d = self.request.recv(1024)
if not d: break
data.extend(d)
# デコード
prop, js, imgs = Parser.decode(data)
# ハンドリング
if prop == PROPERTY_POST:
self.handle_post(js, imgs)
elif prop == PROPERTY_CONFIRM:
self.handle_confirm(js)
# プロパティ"P"用のハンドラメソッド
def handle_post(self, js:dict, imgs:Tuple[bytes])-> None:
dirpath = Handler.mkdir_clients(js["ID"]) # あらかじめIDのキーを含むように決めておく
# 画像の保存
for i, img in enumerate(imgs):
imgpath = os.path.join(dirpath, int2string(i, 4) + Handler.EXTENSION_PNG)
with open(imgpath, "wb") as f:
f.write(img)
# プロパティ"C"用のハンドラメソッド
def handle_confirm(self, js:dict)-> None:
"""サーバー側送信専用のプロパティであるためパス"""
pass
今回はBaseRequestHandlerを継承しsetupメソッドとhandleメソッドをオーバーライドしました.setupメソッドとfinishメソッドの実装の仕方によってはより複雑な処理も可能になるでしょう.また,handleメソッドはParserクラスのdecodeメソッドと同じ要領でプロパティに対してハンドリングを行い,具体的な処理は対応するメソッドに譲る形になっています.
総括
この章では主にPython標準のsocketserverライブラリの使い方について説明し,最後にリクエストハンドラの実装例を紹介しました.残るはServerオブジェクトの取り扱いのみです.socketserverライブラリの説明の際にも述べましたが,Serverオブジェクトは特に手を加えなくてもサーバーの立ち上げは可能です.もうゴールは目の前まで来ています.
実行ファイルを作る
さて,いよいよサーバーを立ち上げましょう.今回は前章で述べたServerクラスの中からTCPServerクラスを使います.サーバー立ち上げの実装は非常にシンプルです.
from socketserver import TCPServer
import sys
from handler import Handler
SERVER_ADDRESS = ("localhost", 8000)
with TCPServer(SERVER_ADDRESS, Handler) as server:
try:
server.serve_forever()
except KeyboardInterrupt:
sys.exit(-1)
これだけです.素晴らしいですよね.惚れてしまいそうです(嘘です).あとはこのファイルを実行すればOKです.
$ python launch.py
既にParserクラスでencodeメソッドを実装していますので,あとはクライアント側で適当にJSONファイルと画像ファイル(拡張子に注意)を用意して遊ぶことができます.
おわりに
正直サーバーは実装してみたいという気持ちは前々からありましたが,なかなか行動に移せずにいました.しかし今回socketserverライブラリの存在を知って意外と簡単に出来てしまうものだなあと感じました.やはりアイディアから実装までのスピード感はPythonがピカイチです.初心者でも1日で出来てしまうほどとは便利すぎですね(この記事を書くのには2日かかったのは内緒です).
今回は素人(Python歴4ヵ月程度)の丸出しのガバガバなプロトコルを使ったり,大量データに対しては全く考慮されていなかったり,画像の種類についても考えていなかったりと,少々危ない実装になってしまいましたが,この機に本番レベルで実装できるようにもっと勉強しようと思うきっかけになりました.言葉足らずな箇所や誤った記述があるかもしれませんので,お気づきの点がありましたらコメントをくださるとありがたいです.今回は図などを作らなかったので後日思い立ったら説明用の図を追加してみようと思います.
この記事が,みなさんの休日のお遊びにお役に立てば幸いです.
付録
クライアント側のテストコードです.launch.pyを実行後(サーバー立ち上げ後)に実行すると通信を楽しめます.ただし,JSONファイルには"ID"という変数(キー)が必要なのでご注意ください.ご参考までに.
import socket
import json
from parser import Parser # 本記事で実装したものです
SERVER_ADDRESS = ("localhost", 8000) # 適宜変更してください
PATH_JSON = "test.json" # 適宜変更してください
PATH_IMG = "test.png" # 適宜変更してください,拡張子に注意
with open(PATH_JSON, "rt") as f:
js = json.load(f)
with socket.socket() as sock:
sock.settimeout(10)
sock.connect(SERVER_ADDRESS)
sock.sendall(Parser.encode("P", js, PATH_IMG))