Webアプリ開発の本を買ったらソケットプログラミングさせられた話
情報系の大学院に行きたいなあと思っている今日この頃です。
タイトルは少々語弊がありますが、記事の内容はタイトルの通りです。ふと「Webアプリ作ろう!」と思って本を買いました。
Webサーバを作りながら学ぶ 基礎からのWebアプリケーション開発入門 (Software Design plus)
- 作者: 前橋和弥
- 出版社/メーカー: 技術評論社
- 発売日: 2016/06/07
- メディア: 大型本
- この商品を含むブログ (1件) を見る
この本ではWebアプリケーションの作り方を理解するために、Webサーバを作るとこから始めてくれます。”Webサーバを作る”といってもLinuxマシンにApacheをインストールしてという意味ではなく、ApacheのようなWebサーバのプログラムを作ることで、Webの動作原理から丁寧に解説してくれます。そのためにまず、TCP通信の基礎であるソケットプログラミングからはじめようといった流れになっています。
いやいや遠回りしすぎなんじゃと思う方もいらっしゃるでしょうが、実はそんなこともなくて、実際僕がこの本を買おうと思ったのもLINEのAPIを使って遊ぼうと思っていたらHTTPの処理あたりで詰まってしまってどうにもならなくなってしまったからです。今は素晴らしい時代で、簡単そうなフレームワークやツールを使えば単純なWebアプリケーションくらいなら誰でもすぐ作れますが、少しややこしいことをしようとすると上手くいかなくなって、根本を理解していないと自分一人では抜け出せないという状況に陥りがちです。
そうならないためにも、基礎からしっかりお勉強しておきたいところです。
今回の記事の流れはこんな感じになっています。
- TCPサーバ/クライアントを作る
- Webブラウザから、TCPサーバにアクセスしてHTTPリクエストを見る
- TCPクライアントからWebサーバにアクセスしてHTTPレスポンスを見る
- 分かったこと
- ソースコード(コメント付き)
まず実際にTCPの規約に沿ったサーバとクライアントのプログラムを作って、それらのプログラムを使ってHTTPリクエストとHTTPレスポンスでは何が吐き出されているのかを確認していきたいと思います。
TCPサーバ/クライアントを作る
Webサーバ動作の理解のためにまず、ブラウザ(クライアント)とWebサーバをつなぐネットワークについて学びます。今回はインターネットで一般的に用いられているTCPプロトコルで通信を行うサーバのプログラムとクライアントのプログラムを作ります。
プロトコルとは、TCPとはという方にはまずこちらをご一読
【初心者向けに大体わかる】TCP/IPとは?
クライアントサーバシステムについてよく知らない人は検索してみてください。
この本は基本的にJavaを使いますが、僕は馴染みのあるC言語で書きました。一応、この本にもC言語のサンプルプログラムは載っていますが、解説やコメントがあまりなかったので記事に載せることにしました。
実際に行う動作の概念図がこちらです。
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ステップでいけるみたいですねー。
いやこれだけじゃ何のことかわからんぜって方のために、気持ち悪いくらいコメントついてる僕の勉強用ソースも末尾に載せておきます。
実行する前にtcp_server.cのあるフォルダにserver_send.txtという名前のファイル、tcp_client.cのあるフォルダにclient_sendという名前の送信用ファイルをそれぞれ置いておきます。ファイルがないとエラーがでます。
それから、tcp_server.cの実行中にtcp_client.cを実行すると
と、通信しているような感じが出てます。
実際にやってみると「TCPがコネクション型のプロトコルってそういう意味だったのか」とか、「今までネットワークって難しく思ってたけど案外単純」みたいな気持ちになりました。
とりあえずクライアントとサーバはこんな感じで表現します。意外とC言語でもサーバ実装とかできるんですね。そうですよね、元々はほとんどCで書かれてたんですから当然ですよね。
Webブラウザから、TCPサーバにアクセスしてHTTPリクエストを見る
私たちが実際に使っているWebブラウザは、Webサーバに対して一体どのようなリクエストを送っているのかというところを見てみます。
具体的には、先程作ったTCPサーバのプログラム実行中に普段使っているWebブラウザ(Google Chrome、IE、Firefoxなど)から
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レスポンスを見る
先程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_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; }