Pythonでファイル受信/送信プログラムを作る【ソケット通信】

533, 2022-08-12

目次

Pythonでファイル受信/送信プログラムを作る

今回はPythonのソケット通信でファイル受信/送信プログラムを作ります。
プログラムの仕様としては安全なLAN内で個人での使用のみを前提にした設計とします。
つまり色々な人がいるインターネットには公開できません。
今回のプログラムはインターネットには公開しないでください。

ファイル受信サーバーとファイル送信クライアントの2つのプログラムを作ります。
サーバーではクライアントから送られてきたリクエストを処理し、クライアントが送信したファイルをreceivedフォルダ以下に保存します。
今回はどちらもCUIなプログラムにします。

動作風景

↓は画像ファイルをクライアントからサーバーに送っているところです。

クライアント側↓。

$ python client.py img.png
sending...
done

サーバー側↓。

receive worker socket is ready

recv: accept...
saved received/img.png
recv: accept...

プログラムを使うにはサーバーとクライアントをそれぞれ起動しておく必要があります。

リクエストの仕様

クライアントからサーバーにリクエスト(要求)を送るわけです。
このリクエストはつまり「これからファイルを送信するからちゃんと受信してね」という要求です。

この要求のフォーマットは今回はJSONを使いたいと思います。
要求のJSONの構造は↓のようになります。

{
    'method': メソッド(POST, etc),
    'filename': 送信するファイル名,
    'filesize': 送信するファイルのバイト数,
}

↑のフォーマットのJSONをバイト列にエンコードしてクライアントからサーバー側に送信します。
サーバー側はmethodPOSTの要求を受けたらファイル受信を開始します。

クライアントは↑の要求をサーバーに送ったら続けてユーザーが指定したファイルを開いてバイト列にします。
そしてこのバイト列をサーバーに送信します。

サーバーはファイルのバイト列を受信し、受信が完了したらfilenameのファイル名でreceivedフォルダ以下にファイルを保存します。
受信が完了したかどうかは受信中のデータのバイト数がfilesizeに達したかどうかで判断します。

今回はメソッドはPOSTだけ実装しますが、GETなども実装すればサーバーからファイルを受信するというプログラムにすることも出来ると思います。

ソースコード全文

今回作成するプログラムのソースコードは↓の2つです。

server.pyがサーバープログラムでclient.pyがクライアントプログラムです。
サーバーの解説から行いたいと思います。

サーバーのソースコード解説

まず必要なモジュールをインポートしておきます。
今回使うモジュールは↓の通りです。

import socket
import time
import json
import os
import sys

socketがソケット通信に使うモジュール。
jsonがJSONのパースに使うモジュールです。

PORT番号の設定

ソケット通信で必要なものは↓の2つです。

  • アドレス

  • ポート番号

今回はlocalhostのみで動作させるのでアドレスはlocalhost固定です。
これはsocket.gethostname()で取得できます。

ポート番号は1234にします。

PORT = 1234

これはサーバーとクライアントで共通のポート番号を使う必要があります。
ポートはアプリのデータの出入り口で、これが違っていると通信できません。

独自例外

エラー処理のために独自例外を作っておきます。

class UnsupportedMethod(RuntimeError):
    pass


class InvalidData(ValueError):
    pass


class ValidationError(ValueError):
    pass

UnsupportedMethodはサポートされていないメソッドだった場合に送出される例外です。
InvalidDataは要求のデータが不正だった場合に送出します。
ValidationErrorはファイル名のバリデーション(検証)に失敗したら送出されます。

Serverクラス

今回はプログラムのほとんどをServerというクラスに書きます。
Serverのイニシャライザではinit_recv_worker_sock()を呼び出しています。
このメソッドはソケットを初期化するメソッドです。

class Server:
    def __init__(self):
        self.init_recv_worker_sock()

init_recv_worker_sock()

このメソッドではソケットを初期化します。
ソケットの初期化はサーバーとクライアントで違っています。

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

        # ソケットをアドレスとポートに紐づける
        self.recv_worker_sock.bind(
            (socket.gethostname(), PORT),
        )

        # ソケットをパッシブソケット(接続待ちソケット)にする
        self.recv_worker_sock.listen()

        self.log('receive worker socket is ready\n')

まず最初にsocket.socket()でソケットを作ります。
引数にはsocket.AF_INETsocket.SOCK_STREAMを指定します。
こうするとIPv4でTCPなソケットになります。

TCPとはデータの送受信に信頼性がある通信のことです。
これと対になるのはUDPなどがあります。

bind()でソケットをアドレスとポートに紐づけます。

それからlisten()でソケットをパッシブソケットにします。
パッシブソケットとは接続待ちソケットのことです。
つまり「受信するぞ!」という意気込みを持ったソケットです。

log()

log()はログを出力するメソッドです。
内容的にはprint()を使っているだけです。

    def log(self, msg):
        print(msg)

run()

サーバーを起動するときにどこから起動するかと言うとそれはrun()メソッドです。
サーバーが初期化されたら次にrun()メソッドが呼ばれます。

    def run(self):
        while True:
            time.sleep(1)

            # クライアントからの接続を受け付ける
            self.log('recv: accept...')
            client_sock, addr = self.recv_worker_sock.accept()

            # 受信開始
            try:
                self.receive(client_sock)
            except (
                ConnectionResetError,
                BrokenPipeError,
                socket.timeout,
                UnsupportedMethod,
                ValidationError,
                InvalidData,
            ) as e:
                print('Error:', e, file=sys.stderr)

run()に入ったら無限ループになります。
time.sleep(1)でループごとに一秒だけスリープします。
これがないとビジーループ(と言ってもaccept()で止まりますが)になってしまうので注意です。

self.recv_worker_sock.accept()でクライアントから接続があった場合にクライアントと接続を確立します。
client_sockがクライアントと接続済みのソケットです。
以降の処理はこのclient_sockを使ってクライアントと通信を行います。

receive()を呼び出して受信処理を実行します。
例外がたくさん飛んでくるので全部補足しておき、画面にエラーとして出力しておきましょう。

ConnectionResetErrorはソケットの接続が切れたときに飛んできます。
BrokenPipeErrorはパイプが壊れたら飛んできます。
socket.timeoutはソケットの受信がタイムアウトしたら飛んできます。
他の例外は先ほど開設した通りです。

receive()

receive()で受信処理を実行します。

    def receive(self, sock):
        # JSONリクエストを受信する
        try:
            data = sock.recv(1024)
        except (
            ConnectionResetError,
            BrokenPipeError,
            socket.timeout,
        ) as e:
            raise e

        sdata = data.decode()  # バイト列を文字列に変換
        d = json.loads(sdata)  # 文字列(JSON)を辞書に変換

        self.receive_json(sock, d)  # 受信処理を続ける

引数のsockclient_sockのことです。
これのrecv()メソッドを呼び出してクライアントから受信を行います。
受信では1024バイトを上限にして受信しています。

プロトコルでは最初にクライアントから要求が送られてくるのでこれをJSONとしてパースします。
パースしたらreceive_json()を呼び出して受信処理を継続します。

receive_json()

receive_json()ではメソッドを解析します。

    def receive_json(self, sock, d):
        # methodがPOSTだったら受信を続行する
        method = d.get('method', '')
        if method == 'POST':
            self.receive_post(sock, d)
        else:
            raise UnsupportedMethod(f'invalid method "{method}"')

メソッドがPOSTだったらreceive_post()を呼び出します。

receive_post()

receive_post()ではファイルデータをクライアントから受信します。

    def receive_post(self, sock, d):
        # ファイル名とファイルサイズを取得
        fname = d.get('filename', None)
        fsize = d.get('filesize', None)
        if fname is None or fsize is None:
            raise InvalidData('invalid post data')

        # ファイル本体の受信開始
        bdata = b''
        while len(bdata) < fsize:
            try:
                bdata += sock.recv(1024)
            except (
                ConnectionResetError,  # 接続が切れた
                BrokenPipeError,  # パイプが壊れた
                socket.timeout,  # タイムアウトになった
            ) as e:
                raise e

        self.save_data(fname, bdata)  # ファイルデータを保存する

最初にJSON辞書のfilenamefilesizeを参照して変数にしておきます。
fname, fsize

それから受信データがfsizeに達するまでクライアントからrecv()で受信します。
受信したバイト列データはbdataに繋げて保存していきます。
このbdataがファイルの本体データです。

1024バイトを上限にrecv()していますので実際にはループが回りながらbdataのサイズが大きくなっていく感じです。

データを受信したらsave_data()でデータをファイルに保存します。

save_data()

save_data()では受信したファイルデータをサーバー側のファイルシステムに保存します。

    def save_data(self, fname, bdata):
        # ファイル名をセキュリティのために検証する
        self.validate_fname(fname)

        # received/ディレクトリを作っておく
        dirname = 'received'
        if not os.path.exists(dirname):
            os.mkdir(dirname)

        # ファイル名とreceivedディレクトリを合成
        path = os.path.join(dirname, fname)

        # ファイルを開いてファイルデータを書き込み
        with open(path, 'wb') as fout:
            fout.write(bdata)

        self.log(f'saved {path}')

最初にfname(ファイル名)をバリデーションします。
今回のサーバーはログイン機能などもないためセキュリティ的には脆弱、というか完全オープンです。
しかしfnameのバリデーションは行っておきます。
fnameに変な文字列が含まれていたらサーバー側にダメージが出る可能性があるからです。

それからreceivedディレクトリ、フォルダを作成しておきます。
receivedディレクトリが存在しなかったら作成します。

そしてreceivedfnameを合成して保存先のパスにします。

あとはファイルを開いて受信データを書き込みます。

validate_fname()

fnameのバリデーションでは↓のコードを使います。

    def validate_fname(self, fname):
        # ファイル名に以下の文字列が含まれていたらエラーにする
        if '..' in fname or \
           '/' in fname or \
           '\\' in fname:
            raise ValidationError(f'invalid file name "{fname}"')

これはつまり../file.txtとか/tmp/file.txtなどのファイル名を拒絶するコードです。
なぜこのようなコードにするのかと言うと、実はこれらの../がファイル名に含まれているとサーバーが意図しない場所にファイルを保存してしまう危険があるのです。

たとえば悪意のあるクライアントが/tmp/file.txtというファイル名を送ってきたとします。
そうするとサーバーはこのファイル名を使ってファイルの保存先パスを合成します。
もし/tmp/file.txtにファイルが保存されたらサーバー側の/tmp以下に意図しないファイルが作成されてしまいます。
これがウィルスだったらと考えるとぞっとしますよね。

あとは..を禁止するのはディレクトリトラバーサルを防ぐためです。
これはディレクトリを..でたどって親ディレクトリのパスを参照するという攻撃です。
これも危険なので..を禁止します。

このvalidate_fname()は単純な関数ですがこれがあるとないとではかなりサーバーの安全性が変わります。
ユーザーの入力データは一切信用しない、これが基本です。
(といってもファイルデータはノーバリデーションで保存してますが)

クライアントのソースコード解説

client.pyの解説です。
まず必要なモジュールをインポートしておきます。

import socket
import os
import sys
import json

PORT番号の設定

ポート番号はサーバーと一致させておきます。

PORT = 1234

main()

クライアントプログラムはmain関数から始まります。
コマンドライン引数(sys.argv)を解析して、コマンドライン引数が無かった場合はusage()関数を実行します。
sys.argv2より少なかったらコマンドライン引数が空の状態です。
コマンドライン引数がある場合はそれをファイル名としてsend_file()に渡します。

def main():
    # コマンドライン引数が空ならusageを表示
    if len(sys.argv) < 2:
        usage()
        sys.exit(0)

    # コマンドライン引数のファイル名を取ってファイル送信
    fname = sys.argv[1]
    send_file(fname)


if __name__ == '__main__':
    main()

if __name__ == '__main__':はスクリプトが端末から実行されたら真になるif文です。
これは別になくてもいいですが一応付けておきます。

usage()

usage()関数はプログラムの使い方を表示する関数です。

def usage():
    print('Usage: client.py [filename]')

今回のクライアントはコマンドラインからファイル名を指定して実行します。
たとえば↓のようにです。

$ python client.py image.png

↑の場合はimage.pngが送信するファイル名です。

send_file()

send_file()でファイルを送信します。

def send_file(fname):
    # ファイルを開いて読み込む
    with open(fname, 'rb') as fin:
        data = fin.read()

    # データを1024バイトごとに分割する
    chunks = [data[i:i+1024] for i in range(0, len(data), 1024)]

    # ソケットを作って接続する
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.connect((socket.gethostname(), PORT))

    print('sending...')

    # JSONリクエストを送る
    d = json.dumps({
        'method': 'POST',
        'filename': os.path.basename(fname),
        'filesize': len(data),
    })
    d = d.encode()  # 文字列をバイト列に変換
    sock.send(d)  # 送信

    # ファイルデータをサーバーに送信する
    for chunk in chunks:
        sock.send(chunk)

    print('done')

open()でファイルを開いてそれをdataに読み込みます。
そしてdata1024バイトごとに分割してchunksにします。
このchunksにする作業は必要ないかもしれません。
そのままdataを送信してもたぶんうまくいくと思います。

socket.socket()でソケットを開きます。
socket.AF_INETsocket.SOCK_STREAMを指定しておきますがこれはサーバー側と同じです。

それからconnect()を呼び出してアドレスとポートにソケットを接続します。
この段階でサーバー側にaccept()されます。

接続が確立したら最初にJSONリクエストを送ります。
メソッドとファイル名とファイルサイズを辞書にしてjson.dumps()でJSONフォーマットの文字列にします。
ちなみにfilenamefnameのベース名にしておきます。
これはディレクトリなどを除外した純粋なファイル名です。

JSONリクエストをsend()で送信したら次にchunksを送信します。
これが全部送信されたら送信完了。
サーバー側にファイルが保存されます。

容量が重いファイルは時間差がありますのでサーバーの動作を見るようにしてください。

おわりに

今回はPythonでファイル受信/送信プログラムを作ってみました。
ファイルの送受信が可能になると色々なソフトウェアに応用できそうですね。

(^ _ ^)

ファイル送信!

(・ v ・)

ファイル受信!



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