1.4 ソフトウェア
ネットワークアーキテクチャとプロトコル仕様は欠かすことのできない要素である。しかし、設計図だけではインターネットの驚異的な成功を十分に説明できない。インターネットに接続されるコンピューターの個数は (正確な値を求めるのは難しいものの) 三十年以上にわたって指数的に増加してきた。インターネットのユーザー数は 2018 年末時点で 41 億人 (世界人口の約半分) と推計されている。
何がインターネットの成功を説明するだろうか? もちろん成功に寄与した要因は数多く存在する (優れたアーキテクチャもその一つだ) が、インターネットの爆発的な成功を可能にした要因の一つに、インターネットの機能のかなりの部分が汎用コンピューターで実行されるソフトウェアによって提供される事実がある。この事実がなぜ重要かと言えば、新しい機能を「ちょっとしたプログラミングの問題」を解くことで簡単に追加できるようになるからである。結果として、新しいアプリケーションやサービスがとてつもないペースで生まれていった。
これに関連する要因として、一般向けマシンで利用できる計算力の大きな向上がある。コンピューターネットワークはデジタル音声サンプルやデジタル画像など任意の種類の情報を転送する能力を理論的にはずっと持っていたものの、送受信するデータを使ってコンピューターが何か有用なことができなければ、この能力も無駄になってしまう。現代のほぼ全てのコンピューターはデジタルの音声あるいは映像を非常に満足いくスピードと解像度で再生できる。
本書の第一版が出版されてから今までの間に、ネットワークを利用するアプリケーションの開発は誰もが行う活動となり、かつてのように少数の専門家のためだけにある仕事ではなくなった。この変化に関係する要因は多くある。開発を簡単にするツール、あるいはスマートフォン向けアプリケーションといった新しい市場の誕生などがその例である。
強調しておきたいのは、ネットワークソフトウェアの実装方法に関する知識がコンピューターネットワークを理解する上で不可欠であること、そして IP のような低レベルプロトコルの実装をあなたが課せられることはおそらくないものの、アプリケーションレベルのプロトコル ── 想像を絶する富と名声をもたらす神出鬼没な「キラーアプリケーション」の土台 ── を実装する理由が見つかる可能性はかなり高いということである。まずは本節で、インターネットの上にネットワークアプリケーションを実装するときに直面する問題をいくつか紹介する。通常、そういったプログラムはアプリケーションである (ユーザーと対話する) と同時にプロトコルの実装でもある (ネットワークを通してピアと通信する)。
1.4.1 ソケット API
ネットワークアプリケーションの実装では、ネットワークが公開するインターフェースが出発地点となる。多くのプロトコル (特にプロトコルスタックで上の方に位置するもの) はソフトウェアとして実装されるので、ほぼ全てのコンピューターシステムはネットワークプロトコルをオペレーティングシステムの一部として実装している。そのため「ネットワークが公開するインターフェース」とは、実際には OS が自身のネットワークサブシステムに提供するインターフェースである場合が多い。このインターフェースをネットワークの API (application programming interface) と呼ぶ。
それぞれのオペレーティングシステムには独自のネットワーク API を定義する自由がある (多くは実際に定義してきた) ものの、時が経つにつれて一部の API が広くサポートされるように、つまり、ネイティブシステム以外の多くのオペレーティングシステムに移植されるようになった。その API とはバークレー版 Unix が最初に提供したソケット API (socket API) であり、現在では事実上全ての有名なオペレーティングシステムでサポートされている。言語固有のインターフェース (例えば Java や Python のソケットライブラリ) でもソケット API が基礎として使われる。本書ではコードサンプルに Linux と C を利用する。Linux はオープンソースであり、C はネットワークの内部を実装するときに使われる言語であり続けているためだ (加えて C では低レベルの詳細が隠されないので、内部の考え方を理解する上でも役立つ)。
ソケット API を説明する前に、二つの関心を分離しておくことが重要となる。それぞれのプロトコルは何らかのサービスの集合を提供し、API はそういったサービスを特定のコンピューターシステム上で起動するための構文を提供する。そして API によって定義される操作とオブジェクトの具体的な集合を、プロトコルによって定義されるサービスの抽象的な集合に関連付けるのが API の実装である。インターフェースを上手く定義すれば、同じ構文で異なるプロトコルのサービスを起動できる可能性もある。そういった一般性は間違いなくソケット API の目標ではあるものの、その目標が達成されているとは言い難い。
ソケット API はアプリケーションの爆発的進化を可能にした
ソケット API の重要性は計り知れない。インターネットの上で実行されるアプリケーションとインターネットの実装詳細の境界はソケット API によって定義される。詳細に定義された安定的なインターフェースをソケット API が提供した結果として、インターネットアプリケーションの開発は数十億ドルの産業へと爆発的な進化を遂げた。慎ましいクライアント/サーバーパラダイムから始まり、メールやファイル転送、あるいはリモートログインといった単純なアプリケーションプログラムが生まれ、今では絶え間なく供給され続けるクラウドアプリケーションに誰でもスマートフォンからアクセスできる。
本節ではソケットを開いてサーバープログラムとメッセージを交換するクライアントプログラムの単純さをもう一度見つめることで基礎的な考え方を説明する。ただし、現代のリッチなソフトウェアエコシステムはソケット API の上に層を築いている。この層には例えばスケーラブルなアプリケーションを実装する障壁を下げる数多くのクラウドベースツールが含まれる。クラウドとネットワークの関係については各章の終わりの「視点」の節でまた考える。
ソケット API で重要な抽象化は、驚くことではないが、ソケット (socket) である。ローカルのアプリケーションプロセスをネットワークに取り付ける場所がソケットだと考えるとよい。ソケット API はソケットの作成、ソケットとネットワークの関連付け、ソケットを通したメッセージの送受信、そしてソケットのクローズ (終了) の操作を定義する。議論を簡単にするため、ここではソケットで TCP を使う方法を示す。
最初のステップはソケットの作成であり、次の操作で行える:
int socket(int domain, int type, int protocol);
この操作が三つの引数を取る理由は、下位のプロトコルスイートがどんなものであってもサポートできるようソケット API が一般的に設計されているためである。具体的に見ていくと、引数 domain
には利用するプロトコルファミリー (似たプロトコルの集合) を指定する: PF_INET
はインターネットファミリーを表し、PF_UNIX
は Unix のパイプ機能を、PF_PACKET
はネットワークインターフェースへの直接のアクセス (つまり TCP/IP プロトコルスタックを迂回したアクセス) をそれぞれ表す。引数 type
が表すのは通信の意味論であり、SOCK_STREAM
は TCP のようなストリーム指向の通信を、SOCK_DGRAM
は UDP のようなメッセージ指向の通信を表す。
次のステップはプログラムがクライアントなのかサーバーなのかによって変わる。サーバーマシンでは、アプリケーションプロセスが受動的オープンを行う ── サーバーは「接続を受け付ける準備ができました」と言うだけで、自分から接続を確立することはない。この処理は次の三つの操作で行われる:
int bind(int socket, struct sockaddr *address, int addr_len);
int listen(int socket, int backlog);
int accept(int socket, struct sockaddr *address, int *addr_len);
bind
操作は、その名前が示すように、新しく作られた socket
を指定された address
に束縛する。この address
はローカルの参加者 (サーバー) のネットワークアドレスを表す。インターネットプロトコルを使うときは、address
はサーバーの IP アドレスとポート番号の両方を含む構造体となる。ポート番号は間接的にプロセスを特定するときに利用される数字であり、逆多重化鍵の一種と言える。ポート番号には提供されるサービス固有の良く知られた番号が使われる場合が多い: 例えば普通ウェブサーバーはポート 80 に接続を受け付ける。よく知られたポート番号をウェルノウンポート番号 (well-known port number) と呼ぶ。
続く listen
操作は指定された socket
に対して接続待ちでいられる接続の個数を定義する。最後に、accept
操作が受動的オープンを行う。accept
はブロックする操作であり、リモートの参加者が接続を確立するまで値を返さない。リモートとの接続が確立すると、accept
はちょうど確立された接続を表す新しいソケットを返し、address
引数にはリモートの参加者のアドレスが格納される。accept
が返った後も引数に渡したソケットは有効であり、受動的オープンを続けることに注意してほしい。そのソケットを使って再度 accept
を呼び出すことができる。
クライアントマシンでは、アプリケーションプロセスが能動的オープンを行う。具体的には、クライアントは通信を行いたい相手を次の操作でオペレーティングシステムに伝える:
int connect(int socket, struct sockaddr *address, int addr_len);
connect
は TCP が接続を確立するまで返らないので、connect
が返ったらアプリケーションはデータの送信を初めて構わない。引数 address
にはリモートの参加者のアドレスが含まれる。実際のコードでは、クライアントはリモートの参加者のアドレスだけを指定してローカルの情報はシステムに埋めさせる場合が多い。通常サーバーはウェルノウンポート番号にメッセージを listen
するのに対して、クライアントは使われるポート番号をまず気にしない。その場合はオペレーティングシステムに使っていない番号を選ばせることができる。
接続が確立したら、アプリケーションプロセスは次の二つの操作でデータの送受信を行う:
int send(int socket, char *message, int msg_len, int flags);
int recv(int socket, char *buffer, int buf_len, int flags);
send
操作は与えられた message
を指定された socket
に送信し、recv
操作は指定された socket
からのメッセージを buffer
に受け取る。両方が取る flags
引数は操作の詳細を制御するためにある。
1.4.2 クライアント/サーバーの例
続いてソケット API を利用して TCP 接続越しにメッセージを送る簡単なクライアント/サーバープログラムの例を示す。このプログラムでは Linux が持つソケット API 以外のネットワークユーティリティを使っているので、それについても出てきたときに説明する。これから書くアプリケーションでは、クライアントはユーザーが入力したテキストをサーバーが実行されるマシンに送信し、サーバーは受け取ったテキストを表示する。これは Linux が持つインスタントメッセージアプリケーション talk
を単純にしたバージョンと言える。
クライアント
クライアント側から始める。このプログラムはリモートマシンの名前を引数に取り、その名前を Linux のユーティリティ関数 gethostbyname
でリモートホストの IP アドレスに変換する。次にソケット API が受け付けるアドレス構造体 sin
を構築する。この構造体に設定した AF_INET
は socket
を使ってインターネットに接続することを表す。ポート番号 (SERVER_PORT
) の 5432
は特定のインターネットサービスに関連付けられていない数字を選択している。最後に socket
と connect
を呼び出して接続のセットアップが完了する。接続が確立されると connect
が返り、クライアントはメインループに入る。メインループではテキストを標準入力から読み、それをソケットに書き込む。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netdb.h>
#define SERVER_PORT 5432
#define MAX_LINE 256
int main(int argc, char * argv[]) {
if (argc != 2) {
fprintf(stderr, "usage: simplex-talk host\n");
exit(1);
}
char *host = argv[1];
/* ホスト名をピアの IP アドレスに変換する */
struct hostent *hp = gethostbyname(host);
if (!hp) {
fprintf(stderr, "simplex-talk: unknown host: %s\n", host);
exit(1);
}
/* アドレスを表す構造体を構築する */
struct sockaddr_in sin;
memset((char *)&sin, 0, sizeof(sin));
sin.sin_family = AF_INET;
memcpy((char *)&sin.sin_addr, hp->h_addr, hp->h_length);
sin.sin_port = htons(SERVER_PORT);
/* 能動的なオープン */
int s = socket(PF_INET, SOCK_STREAM, 0);
if (s < 0) {
perror("simplex-talk: socket");
exit(1);
}
if (connect(s, (struct sockaddr *)&sin, sizeof(sin)) < 0) {
perror("simplex-talk: connect");
close(s);
exit(1);
}
/* メインループ: 一行のテキストを読み込んで、送信する */
char buf[MAX_LINE];
int len;
while (fgets(buf, sizeof(buf), stdin)) {
buf[MAX_LINE-1] = '\0';
len = strlen(buf) + 1;
send(s, buf, len, 0);
}
}
サーバー
サーバーも同程度に難しくない。最初に独自のポート番号 SERVER_PORT
を使ってアドレス構造体を構築する。このとき IP アドレスを指定しないことで、このプログラムはローカルホストの任意の IP アドレスにおける接続を受け入れるとオペレーティングシステムに伝えている。続いて受動的オープンのための準備がある: ソケットを作成し、ローカルアドレスに束縛し、接続待ちでいられる接続の最大個数を設定する。最後にメインループでリモートホストが接続を試みるのを待ち、接続を受けた場合はそこからメッセージを受け取って出力している。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netdb.h>
#define SERVER_PORT 5432
#define MAX_PENDING 5
#define MAX_LINE 256
int main(void) {
/* アドレスを表す構造体を構築する */
struct sockaddr_in sin;
memset((char *)&sin, 0, sizeof(sin));
sin.sin_family = AF_INET;
sin.sin_addr.s_addr = INADDR_ANY;
sin.sin_port = htons(SERVER_PORT);
/* 受動的なオープンをセットアップする */
int s = socket(PF_INET, SOCK_STREAM, 0);
if (s < 0) {
perror("simplex-talk: socket");
exit(1);
}
if (bind(s, (struct sockaddr *)&sin, sizeof(sin)) < 0) {
perror("simplex-talk: bind");
exit(1);
}
listen(s, MAX_PENDING);
/* 接続を待ち、メッセージを読んで出力する */
while (1) {
int addr_len;
int new_s = accept(s, (struct sockaddr *)&sin, &addr_len);
if (new_s < 0) {
perror("simplex-talk: accept");
exit(1);
}
char buf[MAX_LINE];
int buf_len;
while (buf_len = recv(new_s, buf, sizeof(buf), 0)) {
fputs(buf, stdout);
}
close(new_s);
}
}