Python で Socket 通信 (TCP/UDP サーバ

現在 TCP/UDP でデバイスと接続する IoT モバイルアプリのプロジェクトをしています。

コロナ対策で在宅推奨にはなってるんですが借用している機材を持ち帰るわけにも行かないので、動作確認用のシミュレータを作りました。その覚書です。


## Environment

### Server

- OSX 10.14.5
- python 3.7


### Client

- Android 10.0
- Kotlin 1.3.50


クライアント (モバイルアプリ) が UDP ブロードキャストでサーチした後、見つかったデバイスに対して TCP 接続してデータが取得できるまでを目指します。

- ざっくり図

![mermaid.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308397/0ccafd7a-f011-cd27-576e-a1f7340bd427.png)


今回はシミュレータ（サーバ）作成のためクライアント側は割愛します。。。


## Python の Socket 通信

Python で TCP/UDP 通信をする時は socket ライブラリを使います。


### ソケットファミリー

socket で通信するときは以下の組み合わせで通信方法が決まります。


- アドレスファミリー


|アドレスファミリ|方式|
|:-:|:-:|
|AF_INET|IPv4|
|AF_INET6|IPv6|


- ソケットタイプ

|ソケットタイプ|方式|
|:-:|:-:|
|SOCK_STREAM|TCP|
|SOCK_DGRAM|UDP|


`AF_INET + SOCK_STREAM` にすると IPv4 + TCP 通信
という事になります。
今回は IPv4 + TCP / IPv4 + UDP を使います。


### UDP

まずは UDP サーバを作成します。
UDP ブロードキャストで飛んできたパケットに対してレスポンスを返すところまで。

```udp_server.py
import socket

# ブロードキャストするときは 255.255.255.255 ではなく空文字
HOST = ''
PORT = 60000

with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
    s.bind((HOST, PORT))
    while True:
        try:
            msg, addr = s.recvfrom(1024)
            s.sendto('Hello World!'.encode('utf-8'), addr)

        except KeyboardInterrupt:
            print('Interrupted.')
            break

        except socket.error:
            print('Has error occurred.')
            pass
```


クライアントに対して Hello World! がレスポンスされていれば成功です。


### TCP

TCP サーバも作成します。
UDP ができたら通信方法を変えるだけです。

```tcp_server.py
import socket

PORT = 60001

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    # ホストに割当たっている IP を指定する
    host = socket.gethostname()
    s.bind((socket.gethostbyname(host), PORT))

    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    s.listen(1)

    while True:
        try:
            client, addr = s.accept()

            # バッファサイズ 100 byte
            data = client.recv(100)
            print(f'data: {data}, addr: {addr}')

            client.sendall("Hello World!".encode('utf-8'))

        except KeyboardInterrupt:
            print('Interrupted.')
            break

        except socket.error:
            print('Has error occurred.')
            pass
```

同じく Hellor World! がレスポンスされていれば成功です。


### XML からデータを返す

やりたい事はクライアントからのリクエストに対して対応したレスポンスを返す TCP/UDP サーバの作成なので、xml から取得したパラメータを返す様にしたいと思います。
Python で xml を扱うには `xml.etree.ElementTree` を使います。
xml の詳細な使い方はまた別途・・・・

```res.xml
<?xml version="1.0"?>
<data>
  <res>Hello World!</res>
</data>
```

```tcp_server.py
import socket
import xml.etree.ElementTree as ET

PORT = 60001
RES_XML = 'xml/res.xml'

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    # ホストに割当たっている IP を指定する
    host = socket.gethostname()
    s.bind((socket.gethostbyname(host), PORT))

    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    s.listen(1)

    while True:
        try:
            client, addr = s.accept()

            # バッファサイズ 100 byte
            data = client.recv(100)
            print(f'data: {data}, addr: {addr}')

            # XML から抽出したデータでレスポンスのバイナリを作成
            b = bytearray()
            root = ET.parse(RES_XML).getroot()
            res = root.find('res').text.encode('utf-8')
            b.extend(res)

            client.sendall(b)

        except KeyboardInterrupt:
            print('Interrupted.')
            break

        except socket.error:
            print('Has error occurred.')
            pass
```

これで xml ファイルを書き換えたら即時反映されてレスポンスが変わる様になりました。
udp_server, tcp_server を同時に立ち上げたら晴れて在宅ワーカーです😎


## Docker で配布

自分だけ在宅すると批判が殺到するのでプロジェクトメンバにも同じ環境を提供できる様に docker-compose で立ち上げれる様にしました。


### Environment

- Docker 

```
Client: Docker Engine - Community
 Version:           19.03.8
 API version:       1.40
 Go version:        go1.12.17
 Git commit:        afacb8b
 Built:             Wed Mar 11 01:21:11 2020
 OS/Arch:           darwin/amd64
 Experimental:      false

Server: Docker Engine - Community
 Engine:
  Version:          19.03.8
  API version:      1.40 (minimum version 1.12)
  Go version:       go1.12.17
  Git commit:       afacb8b
  Built:            Wed Mar 11 01:29:16 2020
  OS/Arch:          linux/amd64
  Experimental:     false
 containerd:
  Version:          v1.2.13
  GitCommit:        7ad184331fa3e55e52b890ea95e65ba581ae3429
 runc:
  Version:          1.0.0-rc10
  GitCommit:        dc9208a3303feef5b3839f4323d9beb36df0a9dd
 docker-init:
  Version:          0.18.0
  GitCommit:        fec3683
```

- docker-compose

```
docker-compose version 1.25.4, build 8d51620a
docker-py version: 4.1.0
CPython version: 3.7.5
OpenSSL version: OpenSSL 1.1.1d  10 Sep 2019
```


### docker-compose で立ち上げる

階層はこんな感じで作成していきます。

```
socket
├── docker-compose.yml
├── tcp/
│      ├── Dockerfile
│      ├── tcp_server.py
│      └── xml/
│      └── res.xml
└── udp/
        ├── Dockerfile
        └── udp_server.py
```


まずは Dockerfile を作ります。

```Dockerfile
FROM python:3.7.4

ARG project_dir=/socket/

WORKDIR $project_dir
```


続いて docker-compose.yml を作ります。

```docker-compose.yml
version: '3.3'

services:
  udp:
    build: ./udp/.
    volumes:
      - ./udp:/socket
    ports:
      - 60000:60000/udp
    restart: 'no'
    command: python udp_server.py
  tcp:
    build: ./tcp/.
    volumes:
      - ./tcp:/socket
    ports:
      - 60001:60001
    restart: 'no'
    command: python tcp_server.py
```

ホストの port をコンテナに割り当てる事でサーバがクライアントと通信できるようになります。
これで `docker-compose up` で TCP/UDP サーバが立ち上がりクライアントとの疎通ができました。
みんなハッピー👆👆😄👆👆

---

Docker クソザコナメクジ過ぎてコピペしかできない。。。
もっとこうするべきだ等々あれば教えて下さい。。。


## 参考

[socket --- 低水準ネットワークインターフェイス — Python 3.8.2 ドキュメント](https://docs.python.org/ja/3/library/socket.html)

[pythonでsocket通信を勉強しよう - Qiita](https://qiita.com/__init__/items/5c89fa5b37b8c5ed32a4)

[PythonでEcho Server／Clientを書いてみる - CLOVER🍀](https://kazuhira-r.hatenablog.com/entry/2019/05/04/221923)