9. ソケット通信#

ソケット通信は、コンピュータ間の通信をプログラムするための基本的な手段である。 本章では、ソケット通信の概念と実際の動作を説明する。

9.1. TCP/IP(おさらい)#

通信プロトコル とは、通信をおこなうための約束事である。 どのような通信であれ、意味のある伝達をするためには、送り手と受け手の間であらかじめルールを決めておく必要がある。

../../_images/protocol.png

ひとくちに約束事と言っても、アプリケーションごとに固有の約束事もあれば、共通して必要となる約束事もある。 そうした整理をするために、通信技術は 階層化 されている。 理論的なモデルとしては OSI参照モデル (Open System Interaction Model) が知られているが、インターネットで実際に用いられているのはより簡略化した TCP/IPモデル である。

../../_images/layer.png

通信をするためには、相手を識別する情報が必要である。 TCP/IPモデルで用いられる主要な識別情報を下表に示す。

階層

主な識別情報

アプリケーション層

ドメイン名

www.hit-u.ac.jp

トランスポート層

ポート番号

80

インターネット層

IPアドレス

192.168.1.1

ネットワークインタフェース層

MACアドレス

02:E2:36:7A:8D:15

ここではTCP/IPの細部にまでは踏み込まないが、ざっくりした理解としては、 IPアドレスで通信相手の場所を特定し、ポート番号で通信相手の中のアプリケーションを特定する と考えれば良い。

MACアドレスは、IPアドレスが指し示す場所にいる機器の名前のようなものである。言わば、IPアドレスが住所、MACアドレスが居住者の氏名に相当する。

ドメイン名は、IPアドレスを人間にとってわかりやすい形に置き換えたものである。

../../_images/domain-ip-mac-port.png

練習1

自身のPCのIPアドレスとMACアドレスを確認してみよう。 ターミナルを起動し、以下のコマンドを実行すれば良い。

  • Windowsの場合: ipconfig -all

  • Macの場合: ifconfig

ネットワークインタフェースが複数ある場合、それぞれのアドレス等の情報が表示される。

練習2

PythonでIPアドレスとMACアドレスを取得してみよう。 <各自作成>となっている箇所を埋めてプログラムを完成させること。

import uuid
import socket

def get_mac():
    # MACアドレスを10進整数の形で取得(例:98765432109876)
    mac_int = uuid.getnode()

    # 16進数に変換した上で、2桁ずつコロン区切りにする(例:59:D3:9E:7F:3B:34)
    <各自作成>

    return mac_addr

def get_ip():
    ip = socket.gethostbyname(socket.gethostname())
    return ip

print(f"MAC Address: {get_mac()}")
print(f"IP Address: {get_ip()}")

※上記プログラムでは、ネットワークインタフェースの指定はできない。特定のインタフェースの情報を取得したい場合は追加の工夫が必要である。

グローバルIPとプライベートIP#

ここで、IPアドレスについて少し補足しておこう。 IPアドレスには グローバルIPアドレスプライベートIPアドレス がある。 グローバルIPアドレスは、インターネット上の場所を表すために使われ、原則として同じIPアドレスが複数端末に割り当てられることは無い。 プライベートIPアドレスは、LAN (Local Area Network) 内でのみ使われるIPアドレスである。LAN内では一意であるが、他のLANには同じIPアドレスの端末が存在し得る。 プライベートIPアドレスを持つ端末が、LAN外と通信する際は、ルータがグローバルIPアドレスとプライベートIPアドレスの変換を担う(NAT, NAPT)。

プライベートIPアドレスを使うことで、有限なグローバルIPアドレスの浪費を抑えることができる。 また、LAN内の端末が直接インターネット上にさらされることを防ぐセキュリティ面の利点もある。

IPアドレス(v4)は32bitであり、各byteを10進数で表記してピリオドで区切るのが一般的な表記方法である。 例:192.168.1.1 ( = 11000000.10101000.00000001.00000001) プライベートIPアドレスとして使えるのは以下の範囲と定められている。

  • 10.0.0.0 ~ 10.255.255.255

  • 172.16.0.0 ~ 172.31.255.255

  • 192.168.0.0 ~ 192.168.255.255

Domain Name System#

IPアドレスは人間にとってはとっつきづらいため、代替表現として ドメイン名 が用いられる。 たとえば、一橋大学公式ウェブサイトをホストしているサーバ(ウェブサーバ)のドメイン名は www.hit-u.ac.jp である。 より正確には、www がホスト名、 hit-u.ac.jp がドメイン名、 www.hit-u.ac.jp はFQDN (Fully Qualified Domain Name) という。

ドメイン名とIPアドレスの対応関係を管理する仕組みを DNS (Domain Name System) という。 DNSサーバに問い合わせることで、あるドメイン名と対応するIPアドレスを調べたり、逆にIPアドレスと対応するドメイン名を調べたりすることができる。

練習3

ターミナルを起動し、以下のコマンドを使って、任意のドメイン名と対応するIPアドレスを調べてみよう。

  • nslookup <FQDN>

ドメイン名の例:www.amazon.co.jp, www.google.co.jp, www.cao.go.jp

練習4

Pythonでドメイン名からIPアドレスを取得してみよう。 「Python DNS」等で検索し、get_ip関数を各自完成させること。

import socket
import sys

def get_ip(domain):
    <各自作成>

if __name__ == "__main__":
    domain = input("Enter FQDN: ")
    print(f"IP Address: {get_ip(domain)}")

ポート番号#

前述のとおり、ポート番号は通信相手の中のアプリケーションを特定するために用いられる。 16bitの整数値であり、0~65535までの番号がある。 このうち、0~1023はWell Known Ports / System Portsと呼ばれ、対応するアプリケーション層プロトコルがあらかじめ定められている。

ポート番号

アプリケーション層プロトコル

トランスポート層プロトコル

25

SMTP (メール送信)

TCP

53

DNS

UDP

80

HTTP

TCP

110

POP3 (メール受信)

TCP

143

IMAP (メール受信)

TCP

443

HTTPS

TCP

ウェブ閲覧では、多くの場合、ウェブサーバの443番ポートにアクセスしていることになる。 なお、本来、ウェブサイトのURLにはポート番号が付記される。

https//:<host>:<port>/<path>

しかし、プロトコルのデフォルトポート番号である場合には省略することができ(ブラウザが補完してくれる)、ウェブ閲覧時にはほとんどのケースで省略されている。 試しに、HTTPS接続のウェブサイトURLに、ポート番号443を付け足してブラウザで読み込んでみよう。 問題なく表示されるはずである。

【例】 https://www.hit-u.ac.jp:443/

実際の通信の流れ#

ここまでのおさらいを兼ねて、ウェブアクセス時の通信の流れを考えてみよう。

クライアント側(PC、スマホ等)

  1. ウェブブラウザにて、URLを指定してアクセス

  2. URLからドメイン名を抽出し、DNSサーバに問い合わせてIPアドレスを取得

  3. 上記IPアドレス宛にHTTPリクエストメッセージを送信

    • このメッセージには、宛先IPアドレスの他に、 宛先ポート番号、送信元IPアドレス、送信元ポート番号 が含まれる。

    • 宛先ポート番号:URLで指定されている番号。省略されている場合はデフォルト番号(HTTPSなら443)

    • 送信元IPアドレス:サーバがレスポンスを送る際の宛先情報となる

    • 送信元ポート番号:サーバからのレスポンスを受け取る、PC内のアプリケーション(ブラウザ)を示す番号

  4. (宛先IPアドレスをたよりに、メッセージがウェブサーバへ届く)

  5. ウェブサーバからHTTPレスポンスメッセージを受信

  6. レスポンスに含まれるコンテンツ(HTMLファイル等)をレンダリングしてブラウザ上に表示

サーバ側(ウェブサーバ)

  1. クライアントからHTTPリクエストメッセージを受信

  2. 宛先ポート番号(443)から、サーバ内の該当するプログラムを特定

    • プログラムの例:Apache HTTP Server

  3. 当該プログラムがメッセージを解釈し、HTTPレスポンスメッセージを生成

  4. 生成したレスポンスメッセージを、クライアント宛に返送

    • 返送時の宛先IPアドレス・ポート番号には、クライアントからのメッセージの送信元IPアドレス・ポート番号を用いる

  5. (宛先IPアドレスをたよりに、メッセージがクライアントへ届く)

重要な点は、双方向の通信のために 宛先IPアドレス、宛先ポート番号、送信元IPアドレス、送信元ポート番号 の4点セットが必要ということである。

../../_images/example-http.png

9.2. ソケット#

ソケット (Socket) とは、TCP/IP通信をおこなうプログラムを簡単に作成できるようにするために、OSが提供する仕組みである。 TCP/IP通信では、様々な処理が必要となる。 たとえば、トランスポート層にTCPプロトコルを用いる場合、再送制御(※1)や輻輳制御(※2)を行う必要がある。 こうした複雑な処理をあらかじめ実装し、通信プログラムの開発を手軽におこなえるようにしたものがソケットであり、多くのOSがソケットを提供している。

※1:通信パケットが欠落した場合にリカバリーする仕組み ※2:通信経路が混雑している場合に通信量を抑える仕組み

ソケット通信では、IPアドレスとポート番号を指定することで、 通信相手に対するデータの投げ込み口(インタフェース)が形成 され、これを用いて簡単にTCP/IP通信を行うことができる。 下図のように「自身のIPアドレス」「自身のポート番号」「相手のIPアドレス」「相手のポート番号」の組み合わせごとにひとつのソケットが形成される。

../../_images/socket.png

なお、ソケット通信において接続要求を送り出す側を クライアント 、待ち受ける側を サーバ と呼ぶ(ことが多い)。

2.1 Echoサーバの実装#

まずは、簡単なソケット通信プログラムを動かしてみよう。 ここでは、クライアントがサーバへテキストメッセージを送り、サーバは受け取ったメッセージをそのままクライアントへ送り返すという通信を考える。 このような振る舞いをするサーバをEchoサーバと呼ぶ。

Pythonではsocketモジュールを用いてソケット通信を行うことができる。 まず、以下はクライアントのプログラムである。

import socket

def send_echo(host, port):
    client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

    # サーバへ接続(ソケットを形成)
    client.connect((host, port))
    print(f"Connected to {host}:{port}")

    # メッセージを送信
    message = input("Enter message to send: ")
    client.sendall(message.encode("utf-8"))

    # サーバからの応答を受信
    data = client.recv(2048)
    print(f"Received: {data.decode("utf-8")}")

    client.close()
    print("Connection closed")

# サーバのIPアドレスあるいはドメイン
host = "localhost"

# サーバのポート番号
port = 50000

send_echo(host, port)

なお、 localhost は自分自身を表す特殊なホスト名である。 同様に、IPアドレス 127.0.0.1 も自分自身を表す。(localhostを127.0.0.1で置き換えても同じ動作となる) 上記プログラムでは、ひとまず同じコンピュータ内でサーバプログラムとクライアントプログラムを動作させることを想定し、通信相手としてlocalhostを指定している。

クライアント側のポート番号は、ソケット形成時に自動で選択される。 特定のポート番号を使うよう、プログラム内で指定することも可能である。

続いて、以下はサーバ側のプログラムである。

import socket

def start_echo_server(host, port):
    server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

    # 指定したIP・ポートで待ち受け(リッスン)開始
    server.bind((host, port))
    server.listen()
    print(f"Server started at {host}:{port}")
    
    # クライアントからの接続を受け付け
    client_socket, client_address = server.accept()
    print(f"Connected by {client_address}")

    # クライアントからのメッセージを受信
    data = client_socket.recv(2048)
    print(f"Received: {data.decode("utf-8")}")

    client_socket.sendall(data)
    client_socket.close()        

    print(f"Disconnected from {client_address}")
    
    server.close()
    print("Server stopped")

# メッセージを待ち受けるサーバのIPアドレスあるいはドメイン
host = "localhost"
# メッセージを待ち受けるサーバのポート番号
port = 50000

start_echo_server(host, port)

実際にプログラムを動かしてみよう。 先に echo_server.py を動かし、その後、 echo_client.py を実行する。 ターミナルを2つ立ち上げて、それぞれで実行すれば良い。

練習5

上記のEchoサーバとクライアントのプログラムは、1往復のメッセージで終了してしまう。 連続して複数のメッセージをやりとりできるよう、作り変えてみよう。 双方のプログラムを無限ループとしておき、特定のメッセージ(たとえば「exit」)を入力するとループを抜けるようにすれば良い。

※留意点 受信待ち状態のプログラムをターミナルから強制終了する場合は、キーボードからCtrl-Cを入力する。 ただし、本章執筆時点において、少なくともWindowsの場合、受信待ち状態ではCtrl-Cによる強制終了ができない場合がある。 以下のように、settimeoutで受信待ちを一定時間ごとにタイムアウトさせる(その後、continueで再び受信待ちする)ようにすれば、Ctrl-Cが効くようになる。

server.settimeout(1)

while True:
	try:
		client_socket, client_address = server.accept()
	except socket.timeout:
		continue

練習6

サーバ側について、あるクライアントとの通信が終了したあとも、次のクライアントからの接続を引き続き待ち受けることができるように、プログラムをさらに改良してみよう。

ソケット通信の状態遷移#

ここで、ソケット通信の動作フローを確認しておこう。 下図はPythonのsocketモジュールを前提としたフローである。 まずクライアントはsocket()でソケットを作成し、connect()でサーバへ接続要求を行う。 サーバ側はsocket()でソケットを作成した後、bind()で通信を待ち受けるIPアドレス・ポート番号にソケットを紐づけ、listen()で待受を開始する。そして、クライアントからの接続要求をaccept()で受け付ける。

../../_images/socket-flow.png

関数

主な引数

引数の説明

socket()

family

接続先情報の形式。デフォルト値は AF_INET(IPv4)

type

ソケットの種類。使いたいトランスポート層プロトコルに合わせる。
デフォルト値は SOCK_STREAM(TCP)。
UDPの場合はSOCK_DGRAMを指定

connect()

address

接続先情報(IPアドレス+ポート番号)

bind()

address

接続先情報(IPアドレス+ポート番号)

send()/sendall()

bytes

送信するデータのバイト列

recv()

bufsize

バッファサイズ(一度に受信するデータの最大サイズ)

関数およびその引数の詳細については公式ドキュメントを参照のこと。

2.3 HTTP GET#

第3回で学んだHTTPリクエストを、ソケット通信で(敢えて)実装してみよう。

ソケット通信を行うためには、少なくとも 通信相手のアドレス(FQDN, IPアドレス等)とポート番号が必要 である。 HTTPリクエストの場合、ポート番号はデフォルトで80と決まっているので、後はアドレスがわかれば良い。

ポート番号の節でも触れたように、URLはいくつかの部品から構成されている。 <protocol>//:<host>:<port>/<path>?<query-strings>

URLの例

プロトコル

ホスト

ポート

パス

クエリストリング

http://www.example.com/foo/index.html

http

www.example.com

(80)

/foo/index.html

無し

https://www.google.co.jp/search?q=foo

https

www.google.co.jp

(443)

/search

q=foo

URLからホスト部分を取り出せば、ソケット通信の送信先アドレスとして使うことができる。 URLを分解する処理は、urllibに含まれるurlparseモジュールで簡単に行うことができる。

from urllib.parse import urlparse

url = "http://www.example.com/foo/index.html"
parsed_url = urlparse(url)
host = parsed_url.netloc
path = parsed_url.path

print(host)
print(path)

これでソケット通信を開始するために必要な情報は得ることができた。 後はHTTPリクエストメッセージを作成してウェブサーバに送信すれば、レスポンスとしてHTML等が返ってくるはずである。

HTTP GETメッセージの(ミニマムな)フォーマットは、以下のとおりである。半角スペースの有無に注意してほしい。

GET <パス> HTTP/1.1<改行(\r\n)>
Host: <ホスト><改行(\r\n)>
<改行(\r\n)>

各行の行末には、改行コードとしてCR (Carriage Return) とLF (Line Feed) を入れる必要がある。PythonではCRは\r、LFは\nである。(使用例:print("Hello, world!\r\n")) また、末尾に空行を設ける必要がある。HTTPメッセージはヘッダとボディから成り、これらの境目を明示するために空行がある。ただし、GETメッセージには通常はボディが存在しないので、空行で終わる形となる。

HTTPのフォーマットに関するより詳細な情報については、IETFが定めたHTTP/1.1の仕様書(RFC2616)や、各種参考書等を参考にされたい。

練習7

HTTP GETを行う以下のプログラムを完成させて、動作を確認しよう。 message = に続く箇所を、上記のミニマムなフォーマットを参考にして埋めれば良い。

import socket
from urllib.parse import urlparse

def http_get(url):
    parsed_url = urlparse(url)
    host = parsed_url.netloc
    path = parsed_url.path
    if path == "":
        path = "/"

    client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    client.connect((host, 80))
    client.settimeout(3)

    message = <各自作成>

    client.sendall(message.encode("utf-8"))

    response = b""
    while True:
        try:
            data = client.recv(2048)
        except socket.timeout:
            break
        response += data
        if len(data) < 1:
            break

    client.close()

    return response.decode("utf-8")

response = http_get("http://www.example.com/")
print(response)

TCPとUDP#

ここまでのソケット通信プログラムは、いずれも トランスポート層プロトコル として TCP を用いるものであった。 トランスポート層の代表的なプロトコルとしては他に UDP が存在する。

TCP はコネクション型のプロトコルである。 すなわち、通信開始時には3-wayハンドシェイクと呼ばれる仕組みによりまず接続可否を確認し、 通信する2者間でコネクション(セッション)を確立 してからデータのやりとりを行う。 また、ネットワーク上でパケットが紛失(パケットロス)した場合に 再送 を行ったり、通信経路が混雑している場合には 輻輳制御 を、受信者の受信処理が追いつかない場合には フロー制御 を行う。

../../_images/tcp.png

UDP はコネクションレス型であり、接続可否の確認などはなく、 いきなりデータを送りつける 。 また、TCPのような再送や通信制御はおこなわない。

TCPのメリットは 信頼性の高さ であり、UDPのメリットは処理がシンプルであるがゆえの 高速性 である。 UDPは、映像や音声等、リアルタイム性が求められ、かつ(ごく)一部のデータが欠損しても大きな問題とならない通信で用いられる。 データの欠損が許容されない場合にはTCPを用いることが一般的であるが、UDPを用いた上で独自の再送制御等を行うプロトコルも存在する(QUIC等)。

../../_images/image-text.png

以下に、UDPを用いるechoサーバとクライアントのプログラムを示す。 UDPを用いる場合、socket()関数呼び出し時にSOCK_STREAMの代わりにSOCK_DGRAMを指定する。 また、コネクションレス型であるので、connect()やlisten()は使用しない。

サーバ

import socket

def start_echo_server(host, port):
    server = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    server.bind((host, port))
    
    data, addr = server.recvfrom(2048)
    print(f"Received: {data.decode('utf-8')} from {addr}")

    server.sendto(data, addr)
    server.close()

host = "localhost"
port = 50000
start_echo_server(host, port)

クライアント

import socket

def send_echo(host, port):
    client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)

    message = input("Enter message to send: ")
    client.sendto(message.encode("utf-8"), (host, port))

    data, addr = client.recvfrom(2048)
    print(f"Received: {data.decode('utf-8')} from {addr}")

    client.close()

host = "localhost"
port = 50000
send_echo(host, port)

9.3. マルチスレッド#

ソケット通信と併用することがある技術として マルチスレッド についても説明しておこう。 マルチスレッドは、複数の処理を(見かけ上)同時に実行する仕組みである。

通常、プログラムは命令文の記述順序に沿って逐次的に処理される。 前の処理が終わらなければ、次の処理は開始されない。 recv() のように、イベント(ここではメッセージの受信)が発生するまで待つような処理があると、それ以降の処理も待たされることになる( ブロッキング という)。

これに対し、ブロッキングを避けるために、処理の流れを複数に分岐させることをマルチスレッドと呼ぶ。 スレッドとは、ある一連の処理(コンピュータに対する命令)の流れである。

../../_images/multi-threading.png

上図は人間にとってわかりやすいイメージであるが、コンピュータの内部ではどうなっているかというと、下図のように CPUの利用枠(CPU時間)を細切れにして各処理に割り当てる ようなことが行われる(時分割処理)。

../../_images/time-division.png

これにより、処理Aが終わっていなくとも、処理Bが進むことになり、「見かけ上」同時に実行されているように映る。 CPUのコアが複数ある場合には、実際に同時に実行されるケースもあるが、Pythonの場合は複数スレッドが同時実行されることを避ける仕組み(Global Interpreter Lock)が採用されているため、あくまでも見かけ上の同時実行が基本となる。

いずれにせよ、マルチスレッドを用いることで、人間からの見え方としては 複数の処理を同時並行で実行 することが可能となる。 (分散システムに興味がある人は、「並列処理」と「並行処理」や、「マルチスレッド」と「マルチプロセス」の違いを調べてみよう)

Pythonでは、threadingモジュールを用いてマルチスレッドのプログラムを作成することができる。 以下はサンプルプログラムである。 「5秒間隔で整数値をインクリメントしながら表示」する処理を行いつつ、並行して「テキストを入力すると小文字を大文字に変換したものを表示」する処理を行う。 また、 "exit" と入力するとプログラムを終了する。(タイミングによっては5秒弱待たされる)

import threading
import time

flag = True

def upper_case():
    global flag
    while flag:
        text = input("")
        if text == "exit":
            flag = False
            break
        print(text.upper())

my_thread = threading.Thread(target=upper_case)
my_thread.start()

count = 0
while flag:
    print(count)
    count += 1
    time.sleep(5)

my_thread.join()

実行例

../../_images/multi-threading-sample.png

9.4. 練習問題#

以下の仕様を満たすチャットサーバを実装しなさい。

  • 複数のクライアントから接続が可能

  • 各クライアントから受信したメッセージは、以下のように処理

    • 初回のメッセージは、そのクライアントのユーザ名として扱う

    • 2回目以降のメッセージは、冒頭に「<ユーザ名>: 」を付け加えた上で、接続している全クライアントに転送(送信元クライアントを含む)

また、以下の仕様を満たすチャットクライアントを実装しなさい。

  • プログラム起動後、ユーザ名を入力

  • サーバに接続し、上記ユーザ名を送信

  • それ以降は、以下の動作

    • テキストを入力するとサーバに送信

    • サーバからテキストを受信したら画面に表示

    • "exit" と入力したら切断

トランスポート層プロトコルとしてはTCPを用いること。 サーバにおいて複数クライアントからの接続を並行して処理し、またクライアントにおいて入力と表示を並行して処理するためには、マルチスレッドを用いると良い。

完成イメージ ※このとおりでなくとも良い

../../_images/chat.png

9.5. 参考文献#

  • 『マスタリングTCP/IP入門編 第6版』井上直也ほか著, オーム社, ISBN: 978-4-274-22447-8

  • 『ポートとソケットがわかればインターネットがわかる』小川晃通著, 技術評論社, ISBN: 978-4-7741-8570-5