Chromeと自作サーバーで通信してみる
前章までで、Chromeを使ってApacheの提供するWebサービス(It works!
)を利用することができました。
しかし、ChromeとApacheの通信は全て自動的に行われ、ChromeからApacheにどのようなリクエストが送られたのか、ApacheからChromeにどのようなレスポンスが送られたのかは分からないままでした。
そこで、Apacheを自作のサーバーに置き換えてみて、Chromeからどのようなリクエストがやってくるのか観察してみましょう。
下図の状態を目指します。
TCPサーバーを作る
まず初めに、 TCPサーバー を作ってみます。
「えっ? WebサーバーじゃなくてTCPサーバー? なにそれ?」
と思われる方もいらっしゃるかもしれませんが、詳しくはこの後説明していきますので、しばらくお付き合いください。
現時点では、WebサーバーはTCPサーバーの進化系である
ということだけ覚えておいてください。
正しくは、TCPサーバーのうち、特定の機能を実装したものをWebサーバーと呼ぶ
というのが正確でして、まずはTCPサーバーなるものを作り、それに機能を追加していってWebサーバーに進化させようという目論見です。
まずはいきなりソースコード
TCPサーバーってなんぞや、というのは後ほど説明することにして、まずはいきなりソースコードを書いてみましょう。
はじめに、場所はどこでもいい(Documentsやホームディレクトリなど)ので本書の勉強用のディレクトリを1つ作ってください。
(以降、本書ではこの勉強用ディレクトリをstudy/
とします)
そして、下記が ブラウザからのリクエストを受け取ってファイル(server_recv.txt)に書き出すプログラム tcpserver.py
です。
study/
の中に作成してください。
study/tcpserver.py
import socket
class TCPServer:
"""
TCP通信を行うサーバーを表すクラス
"""
def serve(self):
"""
サーバーを起動する
"""
print("=== サーバーを起動します ===")
try:
# socketを生成
server_socket = socket.socket()
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
# socketをlocalhostのポート8080番に割り当てる
server_socket.bind(("localhost", 8080))
server_socket.listen(10)
# 外部からの接続を待ち、接続があったらコネクションを確立する
print("=== クライアントからの接続を待ちます ===")
(client_socket, address) = server_socket.accept()
print(f"=== クライアントとの接続が完了しました remote_address: {address} ===")
# クライアントから送られてきたデータを取得する
request = client_socket.recv(4096)
# クライアントから送られてきたデータをファイルに書き出す
with open("server_recv.txt", "wb") as f:
f.write(request)
# 返事は特に返さず、通信を終了させる
client_socket.close()
finally:
print("=== サーバーを停止します。 ===")
if __name__ == '__main__':
server = TCPServer()
server.serve()
実際に動かしてみる
説明の前に、まずはこのTCPサーバーを動かしてみましょう。
TCPサーバーを起動する
コンソールでstudy
ディレクトリまで移動し、tcpserver.py
を実行します。
$ python tcpserver.py
=== サーバーを起動します ===
=== クライアントからの接続を待ちます ===
このように表示されれば起動成功です。
このコンソールのタブは、閉じないように気をつけてください。
Chromeからリクエストを送る
次に、ChromeのURLバーにhttp//localhost:8080
と入力してEnterを押してください。
エラー画面が表示されるはずです。
今回は、 ポート番号を8080番にして サーバーを起動していることに注意してください。
Webサーバーは普通80番なんですが、私達のプログラムはまだへなちょこでWebサーバーといえるシロモノではありません。
また、自分のマシンでまともなWebサーバー(例えばApacheなど)を起動したいときに、ポートがかぶってしまうと面倒です。
ですので、well known port(0~1023番)は開けておいて、他のportでこっそり実験を行いましょう。
ちなみに、8080という数字に意味はありません。なんとなく80に似ているので、適当に選びました。
(筆者はMacOSをダークモードにしているので画面が全体的に黒いですが、気にしないでください。ダークモード、オススメです)
リクエスト内容を確認する
ここで、study
ディレクトリに移動すると、server_recv.txt
というファイルが作成されているはずです。
このファイルに下記のような内容が書き込まれていれば成功です。
(細かい内容はChromeのバージョンによって異なるため、大体同じであればOKです)
study/server_recv.txt
GET / HTTP/1.1
Host: localhost:8080
Connection: keep-alive
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.111 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate, br
Accept-Language: ja-JP,ja;q=0.9,en-US;q=0.8,en;q=0.7
これが、ChromeがWebサーバーへ送っていたリクエストの本体です。
私達は、単にhttp://localhost:8080
という内容しかブラウザには伝えてないんですが、ブラウザは他にもこんなに色々な情報を自動で生成して、こんなフォーマットでWebサーバーへ送ってくれていたんですね
TCPサーバーのログを確認する
最後に、初めにTCPサーバーを起動したコンソールのタブをもう一度見てみましょう。
次にような行が追加で出力されていないでしょうか?
=== クライアントとの接続が完了しました remote_address: ('127.0.0.1', 59788) ===
=== サーバーを停止します。 ===
これは、TCPサーバーがブラウザと正常に接続できたこと、処理が完了したことを示すログです。
通信の完了を(ブラウザ側からだけでなく)サーバー側からも確認できました。
TCPサーバーとは?
さて、ソースコードを説明する前に、まず TCPサーバー について簡単に説明しておきます。
TCPについてはエンジニアとしてはできるだけ知っておいたほうがいいですが、Webアプリケーションを作るだけであれば必須ではないので、良くわからないなと思った方はきっちり理解する必要はありません。
TCPサーバーとは、 TCP(Transmission Control Protocol) で通信を行うサーバーのことです。
TCPは、データの 送り方 を決める約束事の一種で、特に 「漏れなく順序良く送る」 ための約束事です。
URLのprotocol(http/https)のくだりでも「送り方」という話が出てきてややこしいかもしれませんが、インターネット通信においては、一口に「送り方」といっても様々な観点があります。
httpの送り方を選ぶか、httpsの送り方を選ぶかでは、「暗号化して送るかどうか?」という観点で差がありました。
TCPは、「情報の順序性、確実性を担保するか?」という観点での約束事です。
例えば、とあるマシンXから、とあるマシンYへ向けてあいうえお
というメッセージを送り出した時のことを考えます。
インターネットの世界では、これがマシンYに届いたときにあいうえお
という形で届くことは、当たり前ではありません。
あるデータをインターネットで送り出す時、データは細切れのパケットという単位に分割され、ばらばらに配送されます。
これが途中で欠落すると、マシンYにはあいえお
と届くかもしれませんし、途中で配送の遅延が発生するとえあういお
と届くかもしれません。
なので、あいうえお
と送って、あいうえお
と届くためには工夫が必要になります。
詳細は割愛しますが、TCPではこの完全性と順序性(=漏れなく順序良く)を担保するために、データを送る側はパケットに番号を割り振って、データを受け取る側はパケットを受け取るたびに受け取った番号を返事する、という協調動作を行います。
例)
マシンX: 1:あ
2:い
3:う
4:え
5(最後):お
を送るよ!
マシンY: 1を受け取ったよ!
2を受け取ったよ!
5(最後)を受け取ったよ!
3と4が届いてないから、もう一度おくってくれる?
マシンX: 3:う
4:え
を送るよ!
マシンY: 3を受け取ったよ!
4を受け取ったよ!
~ Fin ~
といった具合です。
この協調動作を行うためには、単にマシンXが分割してデータを送るだけではだめですし、単にマシンYが返事をするだけでもいけません。
「こうやって送る」「受け取ったらこうする」という約束事を お互いが守って 初めて通信がうまくいくのです。
そして、ブラウザとWebサーバーの通信(HTTP通信)では、「お互いにTCPに従ってデータをやりとりする」と世界的に決まっているので、ブラウザはTCPの約束事に従ってデータを送ってきます。
ですので、サーバー側もTCPのルールに従ってデータを受け取ってあげる必要があります。
Webサーバーを作るには、まずはTCPのルールに従ってデータをやりとりできるサーバー、 TCPサーバー を作る必要があるのです。
コラム: TCP "ではない" もの
「データをパケットという細かい単位に分割して送る」ということを前提としたとき、「漏れなく順序良く送る」ための約束事にわざわざTCPという名前がついているということは、「漏れなく順序よく送らない」約束事もあるということです。
このようなプロトコル(=約束事)として、 UDP(User Datagram Protocol) というものがあります。
これは、送る側は順序も漏れも一切気にせず、単にパケットを送りつける方式です。
当然、すでに説明したとおり、あいえお
や、えあういお
と届く可能性があります。
しかし、受け取る側が返事をしなくて良かったり、コネクションの確立を行わないため、通信が高速です。
また、情報の品質を担保するための余計な情報も付け加えないため、データが軽量になります。
このようなプロトコルは、例えば動画ストリーミングサービスなどで使われることがあります。
Youtubeなどの動画を見る時、たしかに動画の全フレームが漏れなく届く必要はありません。
途中で欠落したり、遅延してしまった数フレームは飛ばしてしまっても動画としては十分視聴できます。
逆に、1フレーム分のデータが届かないばっかりに、その1フレームが届くのを待って動画が止まってしまっては、ユーザー体験はかえって悪くなるでしょう。
(ただし、順序性を担保する仕組みは別途必要です)
このUDPとTCPという仕組みはどちらも非常によくできており、通信品質の観点ではこの2プロトコルがデファクトスタンダードとなり、他のプロトコルはほとんど使われていません。
ソースコードの解説
では、ソースコードに戻って意味を解説していこうと思います。
プログラムのエントリーポイント
このプログラムをコンソールから起動したとき、最初に読み込まれるのはファイル最下部の
if __name__ == '__main__':
server = TCPServer()
server.serve()
の部分です。
TCPServer
クラスをインスタンス化し、serve()
メソッドを呼び出します。
これがサーバーを起動させるメソッドで、これによって8080番ポートを見張り続けるプログラムが起動します。
TCPServerクラス
では、次にTCPServerクラスを見てみましょう。
class TCPServer:
"""
TCP通信を行うサーバーを表すクラス
"""
def serve(self):
# ...
このクラスは名前の通りTCPサーバーを表すクラスで、メソッドはserveが定義されているのみです。
serve()メソッド
ここが一番難しいところですが、上から順番に説明していきます。
開始と終了
def serve(self):
"""
サーバーを起動する
"""
print("=== サーバーを起動します ===")
try:
# ...
finally:
print("=== サーバーを停止します。 ===")
serve()
が実行されると、サーバーの起動ログをコンソールに出したあと、try
文を実行します。
そして、実行中に例外が出た場合も、出なかった場合も、最後にはサーバーの終了ログをコンソールに出してから処理を終了します。
ちなみに、例外処理はしていない(catch
文を書いてない)ので、例外が出た場合はプログラムは異常終了することになります。
手抜きですが、最初はこんなもんでいいのです。
ログ出力ちゃんとやってるだけでも立派なもんです。
次に、try
文の中にいきます。
socketインスタンスの生成
# socketを生成
server_socket = socket.socket()
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
socket
は、pythonに組み込みのライブラリで、pythonプログラムが他のマシン(あるいは自分のマシン)とインターネット上で通信をするためのライブラリです。
このライブラリを使うと、TCP通信が簡単にできるようになります。
(「パケットごとにデータを受け取ったら返事をする」という約束事を、自動的にやってくれる)
TCP通信を自力で実装するのはかなり難しいですし、さすがに本書の目的からは大きく反れてしまうことになるので、ライブラリを使うことにさせてください。
ちなみにこのsocketというのはもともとC言語で書かれたライブラリで、色々な言語(PHPやRubyなど)に同名のライブラリとして組み込まれています。
名前の通り、pythonプログラムと外部のプログラムの通信の受け口となるようなイメージで使えるようになっています。
(具体的な使い方はこの後説明します。)
socket.socket()
とすると、デフォルトではTCP通信を行うためのsocketインスタンスを生成します。
setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
については、説明が詳細に立ち入ってしまうため、おまじないだと思っておいてください。
socketはデフォルト設定だと、プログラムが終了してもしばらくportを掴んだまま離さず、連続してプログラムが起動できなくなってしまうため、設定を変更しています。
ポートの割り当て
# socketをlocalhostのポート8080番に割り当てる
server_socket.bind(("localhost", 8080))
server_socket.listen(10)
先程生成したsocketインスタンスに対して、.bind()
メソッドと.listen()
メソッドを呼び出しています。
bind()
は、実行中のpythonプログラムと、マシンのportを紐付けるメソッドです。
listen()
は、socketがbind済みのポートを実際にプログラムに割り当てるメソッドです。
イメージとしては以下のようなことをしています。
bind(("localhost", 8080))
listen(10)
listenの引数の10は、このサーバーがいくつのクライアントと同時に通信を行えるかを制限する数字です。
ここでは適当に10にしましたが、自分で遊ぶだけならもっと少なくても良いぐらいだと思います。
クライアントからの接続を受け付ける
# 外部からの接続を待ち、接続があったらコネクションを確立する
print("=== クライアントからの接続を待ちます ===")
(client_socket, address) = server_socket.accept()
print(f"=== クライアントとの接続が完了しました remote_address: {address} ===")
ブロッキングな処理(プログラムを停止させる処理)に慣れていない方は、ここで気をつけてください。
socketインスタンスに対して.accept()
メソッドを呼び出すと、その時点で接続待ちのクライアントがいればその接続を受け付けますが、いない場合は クライアントから新しく接続要求が来るまでプログラムが一時停止します。
いずれの場合にしろ、.accept()
メソッドが完了した時にはクライアントとの接続の受付が完了します。
(このことを、 コネクションが確立する 、といいます)
そして、返り値として クライアントとの接続が確立された新しいsocketインスタンス と、接続したクライアントのaddressが返されます。
コラム: ブラウザのポート
クライアントのaddressとは、クライアントのhostとportのタプルになっています。
接続完了後のログの内容を見てみると、
remote_address: ('127.0.0.1', 59788)
と書かれていることが分かります。
このことから、ブラウザはIPアドレス'127.0.0.1'の59788
ポートから通信を行ってきていることが分かります。
IPアドレス127.0.0.1
はlocalhost
と同じ意味で、ブラウザが起動しているマシンが自分のPCであることを意味しています。当たり前ですね。
そして、59788
ポートについてですが、おそらく皆さんのPCでは違う数字が表示されていると思います。
これは、ブラウザが通信をするたびにランダムにポートを選び通信を開始するからです。
よく考えてみればブラウザはWebサーバーにリクエストを送った後、インターネットを通じて返事を受け取らなければいけないので、ブラウザもポートを割り当てておかないといけないのです。
しかし、Webサーバーとの通信はちょこっと往復すれば終わりですし、ブラウザは見知らぬ誰かからの通信を待つわけでもないため固定の番号を持つ必要はありません。
そのため、通信のたびに適当なポートを見繕って通信を開始し、通信が終了したらそのポートを開放するような仕組みになっているのです。
クライアントからリクエストを受け取る
接続が完了したので、次はクライアントからリクエストを受け取ります。
# クライアントから送られてきたデータを取得する
request = client_socket.recv(4096)
# クライアントから送られてきたデータをファイルに書き出す
with open("server_recv.txt", "wb") as f:
f.write(request)
接続済みのsocketインスタンスに対して.recv()
メソッドを実行すると、コネクションを通じてクライアントが送ってきたデータをbytes
型で取得します。
このとき、acceptメソッドと同様に、すでにクライアントが送ってきたデータが溜まっていればそれを直ちに全て取得しますが、溜まっていなければプログラムは停止してデータが新しく送られてくるのを待ってしまうことに気をつけてください。
また、引数に与えるintはネットワークバッファ(到着したデータをためておくところ)から一回で取得するバイト数を表しており、例えば4096を指定すると4096バイトずつデータを取得します。
ただし、recv()
メソッドの呼び出し1回につき最大4096バイトしか受け取れないという意味ではありません。
recv()
メソッドは呼び出した時点で溜まっているデータを、4096バイトずつ繰り返し取得し、全て取得します。
なので、この数字はよほどシビアに最適化する必要がないかぎり、あまり気にする必要はありません。
今回のケースでは、ブラウザはコネクションが確立すると直ちにメッセージを送って来ますので、このrecv()
はほとんど待たされることはなくすぐに処理が完了し、データを受け取ることができます。
コラム: non-blockingなsocket
.accept()
や.recv()
メソッドが、プログラムをブロック(停止)してしまうと、困るケースというのもあります。
「今接続待ちのクライアントがいたら対応したいけど、接続待ちがいないならその間に別の処理を実行しておきたい」
などといったケースです。
その場合は、socketインスタンスに対して
socket.setblocking(False)
としてあげると、プログラムを停止しなくなります。
ただし、接続待ちクライアントや受け取り済みメッセージが存在しない場合はtimeout
例外が発生するので、例外処理をお忘れなく。
取得したデータは、すぐにserver_recv.txt
というファイルへ書き込みます。
接続の切断
最後に、通信の終了のご挨拶です。
# 返事は特に返さず、通信を終了させる
client_socket.close()
TCPのルールでは、接続を切断する際に、「これで終わりだよ」という合図を送ることになっています。
それがこの.close()
メソッドです
これを送らなかったからといって、私たちのプログラムがなにか困るわけではないのですが、相手方は困ってしまうかもしれません。
電話でいうと、「じゃあ電話切るね、バイバイ」と言わずにガチャ切りしてしまうようなものです。
切ったほうは気になりませんが、相手はもしかしたら「電話切る直前にアレを伝えよう・・・」と思っていることがあるかもしれません。
ブラウザとWebサーバーの通信に限って言えば、全世界の全てのWebサーバーが礼儀正しく実装されているとは限りませんし、ガチャ切りされた程度でエラーになっていては商売になりませんので、ほとんどのブラウザはこの.close()
を呼ばなくても無難に対応はしてくれます。
しかし、TCP通信はブラウザとWebサーバーだけに使われる通信でもありませんし、今後のためにも「TCP通信を終了するときはcloseをする」という習慣をつけておくのが良いでしょう。
おわり
さて、本章は以上になります。
低レベル(低レイヤー?)な機能を操作することが多く、たった十数行のソースコードですが、読むのは大変だったかもしれません。
しかし、逆に十数行でもうブラウザと(一方向ですが)通信ができるプログラムが書けてしまいました。
また、Chromeが送ってきた呪文のような、それでいて整列したリクエストも見ることができました。
これがHTTPヘッダーと呼ばれるものだということをご存知の方もいらっしゃるとは思いますが、本書の仕立てではその言葉が出て来るのはもう少し先です。
知らないフリをして読み進めてください。
次章では、この呪文リクエストの意味は説明しないまま、全く同じリクエストを今度は自作クライアントからApacheへ送ってみます。
意味など分からなくても、同じものをApacheに送れば、なにか返事が返ってくるはずです。
楽しみですね〜