hoshimure-47’s blog

プログラミング修業中の人

Webアプリ開発の本を買ったらソケットプログラミングさせられた話

情報系の大学院に行きたいなあと思っている今日この頃です。

タイトルは少々語弊がありますが、記事の内容はタイトルの通りです。ふと「Webアプリ作ろう!」と思って本を買いました。

この本ではWebアプリケーションの作り方を理解するために、Webサーバを作るとこから始めてくれます。”Webサーバを作る”といってもLinuxマシンにApacheをインストールしてという意味ではなく、ApacheのようなWebサーバのプログラムを作ることで、Webの動作原理から丁寧に解説してくれます。そのためにまず、TCP通信の基礎であるソケットプログラミングからはじめようといった流れになっています。

いやいや遠回りしすぎなんじゃと思う方もいらっしゃるでしょうが、実はそんなこともなくて、実際僕がこの本を買おうと思ったのもLINEのAPIを使って遊ぼうと思っていたらHTTPの処理あたりで詰まってしまってどうにもならなくなってしまったからです。今は素晴らしい時代で、簡単そうなフレームワークやツールを使えば単純なWebアプリケーションくらいなら誰でもすぐ作れますが、少しややこしいことをしようとすると上手くいかなくなって、根本を理解していないと自分一人では抜け出せないという状況に陥りがちです。

そうならないためにも、基礎からしっかりお勉強しておきたいところです。

今回の記事の流れはこんな感じになっています。

まず実際にTCPの規約に沿ったサーバとクライアントのプログラムを作って、それらのプログラムを使ってHTTPリクエストとHTTPレスポンスでは何が吐き出されているのかを確認していきたいと思います。

TCPサーバ/クライアントを作る

Webサーバ動作の理解のためにまず、ブラウザ(クライアント)とWebサーバをつなぐネットワークについて学びます。今回はインターネットで一般的に用いられているTCPプロトコルで通信を行うサーバのプログラムとクライアントのプログラムを作ります。

プロトコルとは、TCPとはという方にはまずこちらをご一読
【初心者向けに大体わかる】TCP/IPとは?

クライアントサーバシステムについてよく知らない人は検索してみてください。

この本は基本的にJavaを使いますが、僕は馴染みのあるC言語で書きました。一応、この本にもC言語のサンプルプログラムは載っていますが、解説やコメントがあまりなかったので記事に載せることにしました。

実際に行う動作の概念図がこちらです。
f:id:hoshimure-47:20170705015358p:plain
f:id:hoshimure-47:20170705015409p:plain

TCPの通信方式は電話に例えられるように、お互いの接続を確認してから通信を行うコネクション型という通信方式です。

サーバとクライアントを介する仮想的な窓口としてソケットというものを使います。ソケットという言葉は、豆電球のカポってはめる部分を指すことが多いですが、イメージとしては「統一された規格の部品をはめ込むもの」という認識でいいと思います。詳しくはその他の文献を参照してください。


プログラムのコードはこんな感じです。まずはサーバの動きをする方です。
今回、ポート番号を8001で指定しています。他のサービスと被っていなければどんな番号でもいいんですけど、本に合わせて設定しました。

// tcp_server.c
#define _POSIX_C_SOURCE 1
#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <string.h>
#include <sys/uio.h>

int main(int argc, char **argv)
{
  int sock;
  struct sockaddr_in addr;
  int fd;
  FILE *socket_fp;
  FILE *file_out_fp;
  FILE *file_in_fp;
  int ch;

  memset(&addr, 0, sizeof(addr));
  addr.sin_family = AF_INET;
  addr.sin_port = htons(8001);
  addr.sin_addr.s_addr = htonl(INADDR_ANY);

  if((sock = socket(AF_INET, SOCK_STREAM, 0)) < 0){
    perror("socket_error");
    return -1;
  }
  else {
    printf("ソケットを生成しました\n");
  }

  if((ch = bind(sock, (struct sockaddr*)&addr, sizeof(addr))) < 0){
    perror("bind_error");
    return -1;
  }
  else {
    printf("サーバの準備ができました\n");
  }

  if((ch = listen(sock, 5)) < 0){
    perror("listen_error");
    return -1;
  }
  else {
    printf("クライアントからの接続を待っています\n");
  }

  if((fd = accept(sock, NULL, NULL)) < 0)
  {
    perror("accept_error");
    return -1;
  }
  else {
    printf("クライアントを接続しました\n");
  }

  socket_fp = fdopen(fd, "r+");
  file_out_fp = fopen("server_recv.txt", "w");
  while((ch = fgetc(socket_fp)) != 0) {
    fputc(ch, file_out_fp);
  }
  fclose(file_out_fp);
  printf("クライアントからのデータを受け取りました\n");

  file_in_fp = fopen("server_send.txt", "r");
  while((ch = fgetc(file_in_fp)) != EOF){
    fputc(ch, socket_fp);
  }
  fclose(file_in_fp);
  fclose(socket_fp);
  printf("クライアントにデータを送信しました\n");
  printf("通信が正常に終了しました\n");

  return 0;
}

クライアントから受け取るファイルでは、EOFという概念が使えないみたいなので、クライアントはファイルの末尾に0を送るようにしています。

次にクライアントの動きをする方です。

// tcp_client.c
#define _POSIX_C_SOURCE 1
#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <string.h>
#include <sys/uio.h>
#include <netdb.h>

int main(int argc, char **argv)
{
  int sock;
  struct sockaddr_in addr;
  struct hostent *host;
  FILE *socket_fp;
  FILE *file_out_fp;
  FILE *file_in_fp;
  int ch;

  memset(&addr, 9, sizeof(addr));
  addr.sin_family = AF_INET;
  addr.sin_port = htons(8001);

  host = gethostbyname("localhost");
  memcpy(&addr.sin_addr, host->h_addr_list[0], sizeof(addr.sin_addr));

  if((sock = socket(AF_INET, SOCK_STREAM, 0)) < 0){
    perror("socket_error");
    return -1;
  }
  else {
    printf("ソケットを生成しました\n");
  }

  if(connect(sock, (struct sockaddr*)&addr, sizeof(struct sockaddr_in)) < 0){
    perror("connect_error");
    return -1;
  }
  else {
    printf("サーバと接続されました\n");
  }

  socket_fp = fdopen(sock, "r+");
  file_in_fp = fopen("client_send.txt", "r");
  while ((ch = fgetc(file_in_fp)) != EOF) {
    fputc(ch, socket_fp);
  }
  fclose(file_in_fp);
  fputc(0, socket_fp);
  printf("サーバにデータを送信しました\n");

  file_out_fp = fopen("client_recv.txt", "w");
  while ((ch = fgetc(socket_fp)) != EOF) {
    fputc(ch, file_out_fp);
  }
  fclose(file_out_fp);
  printf("サーバからのデータを受け取りました\n");

  return 0;
}

重要な手続きを抜き出すと下の図のような流れとなっております。クライアントの方は2ステップでいけるみたいですねー。

f:id:hoshimure-47:20170705015413p:plain

いやこれだけじゃ何のことかわからんぜって方のために、気持ち悪いくらいコメントついてる僕の勉強用ソースも末尾に載せておきます。

実行する前にtcp_server.cのあるフォルダにserver_send.txtという名前のファイル、tcp_client.cのあるフォルダにclient_sendという名前の送信用ファイルをそれぞれ置いておきます。ファイルがないとエラーがでます。

それから、tcp_server.cの実行中にtcp_client.cを実行すると
f:id:hoshimure-47:20170704023125p:plain
と、通信しているような感じが出てます。

実際にやってみると「TCPがコネクション型のプロトコルってそういう意味だったのか」とか、「今までネットワークって難しく思ってたけど案外単純」みたいな気持ちになりました。

とりあえずクライアントとサーバはこんな感じで表現します。意外とC言語でもサーバ実装とかできるんですね。そうですよね、元々はほとんどCで書かれてたんですから当然ですよね。

Webブラウザから、TCPサーバにアクセスしてHTTPリクエストを見る

私たちが実際に使っているWebブラウザは、Webサーバに対して一体どのようなリクエストを送っているのかというところを見てみます。

具体的には、先程作ったTCPサーバのプログラム実行中に普段使っているWebブラウザ(Google ChromeIEFirefoxなど)から

http://localhost:8001/index.html

にアクセスして、Webサーバに送られてきたserver_recv.txtの中身を見るという感じです。

送られてくるHTTPリクエストヘッダと呼ばれるファイルは、ファイルの最後が2回の改行が続くので、終端を示すためにtcp_server.cの該当部分を変更します。
HTTPでは改行コードがCR+LFという形式らしいので、少し力技っぽいですが10,13という値が2回続いたら終了するようにしました。

  socket_fp = fdopen(fd, "r+");
  file_out_fp = fopen("server_recv.txt", "w");
  int ch2 = 0, ch3 = 0, ch4 = 0;
  while((ch = fgetc(socket_fp)) != EOF) {
    fputc(ch, file_out_fp);
    if(ch == 10 && ch2 == 13 && ch3 == 10 && ch4 == 13){
      printf("HTTPレスポンスヘッダの終端です\n");
      break;
    }
    ch4 = ch3;
    ch3 = ch2;
    ch2 = ch;
  }
  fclose(file_out_fp);
  printf("クライアントからのデータを受け取りました\n");

tcp_server.cを書き換えて、実行して、Webブラウザからhttp://localhost:8001/index.htmlに接続を試みると、server_recv.txtにHTTPリクエストの内容が表示されています。

GET /index.html HTTP/1.1
Host: localhost:8001
Connection: keep-alive
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux armv7l) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.84 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Encoding: gzip, deflate, sdch, br
Accept-Language: ja,en-US;q=0.8,en;q=0.6

Webブラウザがサーバに対してどういうリクエストを投げかけているかが分かりました。

逆にApacheなどのWebサーバプログラムはどういった処理をしているのか見てみます。

TCPクライアントからWebサーバにアクセスしてHTTPレスポンスを見る

先にApacheをインストールしておきます。

先程server_recv.txtとして受け取ったファイルを、そのままclient_send.txtにコピーしてWebサーバ(Apache)に投げてみよう、ということをします。

まずserver_recv.txtでは8001となっていた箇所を、client_send.txtではHTTPデフォルトのポート番号の80に変更します。

で、クライアント側のプログラムもそれに対応する部分を変更します。また終了を表すことにしていた0もApache相手には必要ないのでコメントアウトしておきます。

  addr.sin_port = htons(8001);
  → addr.sin_port = htons(80);

  fputc(0, socket_fp);
  → //fputc(0, socket_fp);

このプログラムを実行すると

HTTP/1.1 400 Bad Request
Date: Mon, 03 Jul 2017 12:25:29 GMT
Server: Apache/2.4.10 (Raspbian)
Content-Length: 303
Connection: close
Content-Type: text/html; charset=iso-8859-1

<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html><head>
<title>400 Bad Request</title>
</head><body>
<h1>Bad Request</h1>
<p>Your browser sent a request that this server could not understand.<br />
</p>
<hr>
<address>Apache/2.4.10 (Raspbian) Server at 127.0.0.1 Port 80</address>
</body></html>

WebサーバからのHTTPレスポンスが返されます。なんか400 Bad Requestでクライアントエラーになってますが、今回は表示させることが目的なのでスルーすることにします。

分かったこと

  • TCPの具体的な手続きとソケットの概念
  • HTTPレスポンス、リクエストの内容

Webアプリ開発のための第一歩です。頑張ります。

ソースコード(コメント付き)

勉強用のコメント付きコードです。いないとは思いますがやりたい人がいたら参考までに。

// tcp_server.c

// まずPOSIXは、UNIXをはじめとする異なるOS実装に共通のAPIを定め、移植性の高いソフトウェア開発を簡易化することを目的としてIEEEが策定したAPI規格
// 内容は、カーネルへのC言語のインターフェイスであるシステムコールや、プロセス環境、ファイルとディレクトリ、システムデータベース、アーカイブフォーマットなど
// つまりPOSIXはOSの規格

// If you define this macro to a value greater than or equal to 1,
// then the functionality from the 1990 edition of the POSIX.1 standard (IEEE Standard 1003.1-1990) is made available.
// つまり_POSIX_C_SOURCEを1以上と定義することでPOSIX.1に準拠したプログラムを作れる
// このプログラムでは、glibcでfdopenを使うために定義されている(らしい)
#define _POSIX_C_SOURCE 1
// 標準入出力ライブラリ
#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <string.h>
#include <sys/uio.h>

int main(int argc, char **argv)
{
  int sock;
  // sockaddr_in構造体:<netinet/in.h>にある。
  // 簡単にいうと、プロセスが持つソケットにプロセス間通信ができるように OS上のアドレスを割り当てるための手続き書類を作成するための構造体
  // もっと簡単にいうと、Internet用のソケットのアドレスを指定したり、逆にソケットの アドレスを調べたりするときに使う
  // もともとsockaddr構造体という汎用的構造体があって、UNIX LOCALに特化したsockaddr_unとInternetに特化したsockaddr_inがある
  /* sockaddr_inの定義はこんな感じ
  struct sockaddr_in {
 	  u_char	sin_len;            // この構造体のサイズ、u_charはunsigned charの意味でその他も同様
	  u_char	sin_family;         // とりあえずAF_INET指定しておこう
	  u_short	sin_port;           // マシンのポート番号
	  struct	in_addr sin_addr;   // マシンのIPアドレス(IPv4)
	  char	sin_zero[8];          //
  };

  struct in_addr {
	  u_int32_t s_addr;
  };
  */
  /* sin_familyはこんな感じで指定する
  ちなみにAF = Address Family PF = Protocol Familyの略らしい。違いは分からん。
  1.AF_INET:ARPAインターネットプロトコル
  2.AF_UNIX:UNIXファイルシステムドメイン
  3.AF_ISO:ISO標準プロトコル
  4.AF_NS:XeroxNetworkSystemsプロトコル
  5.AF_IPX:NovellIPXプロトコル
  6.AF_APPLETALK:AppletalkDDP
  7.PF_INET:IPv4 AF_INETとほぼ同義
  8.PF_INET6:IPv6
  9.PF_IPX:IPX - Novell プロトコル
  10.PF_NETLINK:カーネル・ユーザ・デバイス
  11.PF_X25:ITU-T X.25 / ISO-8208 プロトコル
  12.PF_AX25:アマチュア無線 AX.25 プロトコル
  13.PF_ATMPVC:生の ATM PVC にアクセスする
  14.PF_APPLETALK:アップルトーク
  15.PF_PACKET:低レベルのパケットインターフェース
  */
  struct sockaddr_in addr;
  // fd = file descriptor
  int fd;
  FILE *socket_fp;
  FILE *file_out_fp;
  FILE *file_in_fp;
  int ch;

  // void * memset( void *str , int chr , size_t len ):<string.h>にある
  // strの先頭からlenバイト分だけchrをセット
  // ここではaddrの長さだけ0で初期化している
  // 古いサイトや文献だとbzeroという関数で実装されているが、これは廃止される(された?)のであまり使わない方がいい(らしい)
  memset(&addr, 0, sizeof(addr));
  addr.sin_family = AF_INET;
  // htonsとhtonlについて
  // htons = host to network short、htonl = host to network longの略
  // 現在の多くのPCはリトルエンディアン方式で、ネットワークはインターネット黎明期の名残でビッグエンディアン方式が標準
  // この問題を解決するための関数がhtonsとhtonlで、逆(ntohs、ntohl)もある
  addr.sin_port = htons(8001);
  addr.sin_addr.s_addr = htonl(INADDR_ANY);
  // htonl内に特定のアドレスを書くと、そのアドレスの要求だけを受け付ける。INADDR_ANYでどのアドレスからの要求でも受け付けるようになる

  // int socket(int domain, int type, int protocol);
  // 第1引数はプロトコルファミリと呼ばれるやつ。結局何なのかよくわかってない
  // 第2引数は通信方式を指定。SOCK_STREAMは順双方向のバイトストリーム、TCP/IPではこれを用いる
  // UDP/IPではSOCK_DGRAM、IPではSOCK_RAW。今回はTCP/IPなのでSOCK_STREAM
  // 第3引数は使用するプロトコルで、0を指定すると自動で設定してくれるっぽい
  // 成功すると新しいソケットのファイルディスクリプタを返し、失敗すると-1を返す
  // つまり新しいソケットを作ってくれるということらしい。できなければIPPROTO_TCPなど自分で指定する
  // sock = socket(AF_INET, SOCK_STREAM, 0);
  if((sock = socket(AF_INET, SOCK_STREAM, 0)) < 0){
    perror("socket_error");
    return -1;
  }
  else {
    printf("ソケットを生成しました\n");
  }

  // int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  // bind関数の定義的に第2引数はstruct sockaddrのポインタなので、キャストする
  // この関数はサーバ側で利用するIPアドレスとポート番号を利用する準備をしてる
  // "bind"は、"結び付ける、紐つける"といった意味があることからソケットとアドレスを紐付ける役割をしてると解釈した
  // ch = bind(sock, (struct sockaddr*)&addr, sizeof(addr));

  if((ch = bind(sock, (struct sockaddr*)&addr, sizeof(addr))) < 0){
    perror("bind_error");
    return -1;
  }
  else { 
    printf("サーバの準備ができました\n");
  }

  // int listen(int sockfd, int backlog);
  // listen関数はsockfdで指定されるソケットを接続待ちソケットとして印づける
  // backlogは最大で何個のクライアントを待たせることができるか、という待ち行列の長さを表す。とりあえずお試しなので5くらいでいいでしょといった感じ
  // 成功した場合には0、失敗なら-1が返される

  if((ch = listen(sock, 5)) < 0){
    perror("listen_error");
    return -1;
  }
  else {
    printf("クライアントからの接続を待っています\n");
  }

  if((fd = accept(sock, NULL, NULL)) < 0)
  {
    perror("accept_error");
    return -1;
  }
  else { 
    printf("クライアントを接続しました\n");
  }

  socket_fp = fdopen(fd, "r+");
  file_out_fp = fopen("server_recv.txt", "w");
  while((ch = fgetc(socket_fp)) != 0) {
    fputc(ch, file_out_fp);
  }
  fclose(file_out_fp);
  printf("クライアントからのデータを受け取りました\n");

  file_in_fp = fopen("server_send.txt", "r");
  while((ch = fgetc(file_in_fp)) != EOF){
    fputc(ch, socket_fp);
  }
  fclose(file_in_fp);
  fclose(socket_fp);
  printf("クライアントにデータを送信しました\n");

  printf("通信が正常に終了しました\n");

  return 0;
}
// tcp_client.c
#define _POSIX_C_SOURCE 1
#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <string.h>
#include <sys/uio.h>
#include <netdb.h>

int main(int argc, char **argv)
{
  int sock;
  struct sockaddr_in addr;
  // hostentはマシンのIPアドレスなどの情報を調べる際に使う構造体でnetdb.hにある
  /*
  struct hostent {
	   char  *h_name;            // ホストの正式名称
	   char  **h_aliases;        // 別名リスト(マシンの別名が存在すればここに入る)
	   int   h_addrtype;         // ホストアドレスのタイプ (AF_INET6 など)
	   int   h_length;           // アドレスの長さ
	   char  **h_addr_list;      // NULL で終わるアドレスのリスト(普通0番目だけ使われる)
  };
  */
  struct hostent *host;
  FILE *socket_fp;
  FILE *file_out_fp;
  FILE *file_in_fp;
  int ch;

  memset(&addr, 9, sizeof(addr));
  addr.sin_family = AF_INET;
  addr.sin_port = htons(8001);

  // gethostbyname(name)は、ホスト名nameに対応した構造体hostentを返す
  // nameには、ホスト名の他、IPv4アドレスIPv6アドレスも指定できる(らしい)
  host = gethostbyname("localhost");
  // void *memcpy(void *buf1, const void *buf2, size_t n);
  // buf2の先頭からn文字分のアドレスをbuf1のアドレスにコピー
  // h_addr_list[0]に入ってるアドレスをsin_addrにコピーする
  // つまりマシンのアドレスをソケットに渡している
  memcpy(&addr.sin_addr, host->h_addr_list[0], sizeof(addr.sin_addr));

  if((sock = socket(AF_INET, SOCK_STREAM, 0)) < 0){
    perror("socket_error");
    return -1;
  }
  else {
    printf("ソケットを生成しました\n");
  }

  // int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  // ファイルディスクリプタsockfdが参照しているソケットをaddrで指定されたアドレスに接続する。
  // addrlen 引き数は addr の大きさを示す。
  if(connect(sock, (struct sockaddr*)&addr, sizeof(struct sockaddr_in)) < 0){
    perror("connect_error");
    return -1;
  }
  else {
    printf("サーバと接続されました\n");
  }

  socket_fp = fdopen(sock, "r+");
  file_in_fp = fopen("client_send.txt", "r");
  while ((ch = fgetc(file_in_fp)) != EOF) {
    fputc(ch, socket_fp);
  }
  fclose(file_in_fp);
  fputc(0, socket_fp);
  printf("サーバにデータを送信しました\n");

  file_out_fp = fopen("client_recv.txt", "w");
  while ((ch = fgetc(socket_fp)) != EOF) {
    fputc(ch, file_out_fp);
  }
  fclose(file_out_fp);
  printf("サーバからのデータを受け取りました\n");

  return 0;
}