このページの本文へ

Goならわかるシステムプログラミング 第6回

GoでたたくTCPソケット(前編)

2016年11月30日 12時00分更新

文● 渋川よしき、編集● 鹿野桂一郎

  • この記事をはてなブックマークに追加
  • 本文印刷

Go言語を使って低レベルプログラミングの世界を体験する連載、 今回からは実用的なアプリケーション開発にも役に立つであろうGo言語の低レベルなAPIを紹介していきます。 最初の題材は、ネットワークを利用したアプリケーションで特によく使われるソケット通信です。

とはいえ、ふだんのプログラミングで直接ソケットを扱う機会は多くないでしょう。 そこで今回の記事では、WebでおなじみのHTTPを例に、Goでどのようにソケットを活用するかを見ていきます。 HTTPを通してGo言語でソケットを利用するための技を学んでいきましょう。

プロトコルとレイヤー

まずはネットワーク通信の基本についてのお話です。

通信を行うためには、送信側と受信側で通信のルールを共有する必要があります。 このルールのことを「プロトコル」(通信規約)と呼びます。

プロトコルは、通常、役割に応じていくつかを組み合わせて使います。 情報処理試験などを受けたことがある人は、「OSI 7階層モデル」とか「TCP/IP参照モデル」という言葉を聞いたことがあるでしょう。 これらは、ネットワーク通信を実現するためのさまざまな機能を階層(レイヤー)に分け、それぞれのレイヤーを担うプロトコルを規定したものです。

インターネット通信で採用されているのはTCP/IP参照モデルです。 TCP/IP参照モデルは次のようなレイヤー分割になっています。

レイヤーの名称 代表的なプロトコル
アプリケーション層 HTTP
トランスポート層 TCP/UDP/QUIC
インターネット層 IP
リンク層 Wi-Fi、イーサネット

このうち、アプリケーションを作るために気にする必要があるのはトランスポート層よりも上のレイヤーだけです。 実際のインターネット通信では、ケーブルや無線を通してIPパケットの形でデータがやり取りされますが、 アプリケーションで直接IPパケットを作ったりするわけではありません。 HTTPやTCPのレベルで決められているルールに従って通信をすれば、それより下のレイヤーで必要になる詳細を気にすることなく、 ネットワークの向こう側にあるアプリケーションとやり取りができるわけです。

Go言語では、HTTP、TCP、UDPについて組み込みの機能が提供されています1。 実用的なアプリケーションでは、それらの機能を使って、自分のアプリケーションに必要なプロトコルを実装していくことになります。

HTTPとその上のプロトコルたち

今回の記事の目標は、ネットワークを扱うプログラムにとって低レベルなソケットをGo言語から使ってみることですが、近年では低レベルなソケットを知らなくても使える便利なライブラリやフレームワークを利用することが増えています。 そのため、上位レイヤーであるアプリケーション層に関して知らないといけない話も少なくありません。

現代のインターネットでもっともよく利用されているアプリケーション層のプロトコルは、ウェブで利用されているHTTPでしょう。 そこで、まずはHTTPの仕組みを説明したあと、その上位のレイヤーの話をいくつかピックアップして紹介します。

HTTPの基本

HTTPには、HTTP/1.0とHTTP/1.1、さらにはHTTP/2というバージョンがありますが、ここではHTTP/1.0を例に基本的なやり取りを説明します。

HTTPでは、クライアントからのリクエストと、それに対するサーバのレスポンスが規定されています。 HTTP/1.0では、クライアントはサーバに次のような内容をテキストで送信します。

メソッド パス HTTP/1.0
ヘッダ1: ヘッダーの値
ヘッダ2: ヘッダーの値
(空行)
リクエストボディ(あれば)

HTTPではサーバにリクエストする要件の種類があらかじめいくつか定めれています。 これはHTTPメソッドと呼ばれます。 リクエストで一行目の先頭にくるのがHTTPメソッドです。 HTTPメソッドとしてはGETやPOSTなどがあります。

また、HTTPでは改行が区切り文字と決められています。 第4回で触れたように、読み込みは内容を分析しながら行う必要があるためコードが複雑になり、その分だけ処理が遅くなりがちです。 そこで現在のHTTPでは、リクエストの解析が簡単に行えるように、改行を区切り文字と決めているのです2

サーバは上記のようなリクエストを受け取ると、指定されたパスに格納されているリソースを次のような形式のレスポンスとして返します。

HTTP/1.0 200 OK
ヘッダ1: ヘッダーの値
ヘッダ2: ヘッダーの値
(空行)
サーバレスポンス

1行目にある「200」という数字は、レスポンスの種類を表すコードです。 これもHTTPの仕様で定められていて、レスポンスの成功を表すコードが200です。

HTTPは、もともとは情報交換を目的としてドキュメントを送受信するためのプロトコルでしたが、 インターネットブームとともにソフトウェアの専門家や研究者以外の多くの人に使われるようになりました。 今ではなくてはならないインフラになっています。

しかし、用途が広がったことでHTTPに対して要求される機能も増えました。 フォームを使って情報を送信できるようになったり、TLS3で通信の暗号を守る機能が入ったり、部分的なコンテンツをサーバから取得するXMLHttpRequestやFetch APIが入ったり、WebWorkerでマルチスレッド機能が入ったり、ServiceWorkerでオフライン動作もできるアプリケーション化が進んだりして現在に至っています。 魔改造に魔改造が加えられた結果、現在ではかなり複雑な仕組みになっています。

HTTP/2では通信内容がバイナリ化されて高速化しました。 ただし、ブラウザから見た通信内容の意味(セマンティクス)はHTTP/1.1と変化はありません。 規格上も、HTTP/2の規格はバイナリ表現の紹介に限定されています。 ここで紹介したのは古き良き1.0/1.1ですが、通信プロトコルとしての基本は今でも同じです4

RPC

RPC(Remote Procedure Calling)は、サーバが用意しているさまざまな機能を、ローカルコンピュータ上にある関数のように簡単に呼び出そう、という仕組みです。 10年以上前から、XML-RPCやJSON-RPCといった形で、HTTP上のプロトコルとして利用されてきました。 「引数を渡して実行し、返り値を受け取る」という関数呼び出しを、そのままインターネット上で実現します。

XML-RPCはRubyPythonPHPでは標準ライブラリとして提供されています。 Go言語でもJSON-RPCが標準ライブラリとして提供されています。

JSON-RPCでは、プロトコルバージョン("jsonrpc"キーの値)、メソッド("method")、引数("params")、送受信の対応を取るためのIDの4項目を使ってリクエストを送信します。 レスポンスのデータ構造はメソッドと引数の代わりに返り値("result")を設定します。 あとは決まったURLにHTTPのPOSTメソッドで送信するだけです。

// 送信側
{"jsonrpc": "2.0", "method": "subtract", "params": [42, 23], "id": 1}
// 受信側
{"jsonrpc": "2.0", "result": 19, "id": 1}

REST

ウェブサービスでは、すべてを階層化されたリソース(ファイルのようなもの)とみなし、URLを使ってそれらのリソースを取得したり投稿したりするAPIが採用されることが増えています。 このようなAPIにより、サーバ・クライアント間の通信をシンプルなファイルサーバのような考え方に集約するスタイルは、REST(Representational State Transfer)と呼ばれています。 RESTはHTTPのルールを最大限取り入れたプロトコルだといえます。

RESTの思想にしたがうシステムのことを「RESTful」といいます5。 最近では、RESTfulの究極形態(第4形態)として位置づけられる「HATEOAS」という考え方も広まりつつあります。 サーバからのHTTPレスポンスに「リンク」情報を入れ、そこから賢いクライアントプログラムが自律的にデータ探索をして情報を見つけられる世界を実現しようというものです。 人間がウェブサイトを見るときはページ内のリンクをたどって関連ページをたどっていきますが、その考え方を取り入れたRESTがHATEOASだといえるでしょう。

HATEOASの原則に従ったAPIを採用していウェブるサービスとしてはGitHubがあります。 GitHubにおける「issue」は、リポジトリ所有者・リポジトリ・issueという階層でリソースが構造化されています。 次のURLにブラウザでアクセスしてみてください。

中にissueの情報が含まれていますが、それ以外にもたくさんのURL情報が含まれていることがわかります。 これらのURLをページング情報として扱うのがHATEOASの一般的な方法です。 実際、ブラウザの開発者ツールで見てみると、HTTPのレスポンスに次のようなヘッダーが含まれています。

Link: <https://api.github.com/repositories/28710753/issues?page=2>; rel="next",
      <https://api.github.com/repositories/28710753/issues?page=20>; rel="last"

GraphQL

RESTfulとは多少違う路線で、RPCベースのGraphQLにも注目が集まっています。 これはFacebook社が提唱しているアプリケーション層のプロトコルです。

アプリケーションのすべての情報がきれいな階層構造にマッピングできるわけではありません。 FacebookなどのSNSではグループを作ることができますが、グループに所属しているユーザ、ユーザが所属しているグループなど、リソース表現は複数考えられます。 属性が増えれば増えるほど、それらの表現は複雑になっていきます。 GraphQLは、複数の属性から構成されている要素をピンポイントで取得するためのクエリー言語として機能します。 GraphQLで要素を取得するには、JavaScriptに似た構文({})を使って階層を表し、( )を使って制約を指定します。

GraphQLを使った具体的なサービスの事例として筆者が現時点で把握しているものはありませんが、次のサイトで既存のREST APIをラップしてGraphQLによりアクセスできるサービスを提供しています。

GraphQLもHTTP上で利用できるアプリケーション層のプロトコルです。 HTTPのGETメソッドで扱うときは、GraphQLの文章をそのままクエリーとして付与して送信します。 POSTで送るときは、等価なJSONに変換してから送信します。

プロトコルにおけるシステムコールともいえるRFC

インターネット上で扱われる通信プロトコルのほとんどは、IETFという組織が取りまとめているRFCという規格書で定義されています。

HTTPはもちろん、ここでRFC化されています。 XML-RPCJSON-RPCはRFC化されていませんが、XML-RPCを内包したプロトコルがあったりはします(RFC-3529)。 RESTは、それ自体はRFC化されていませんがRESTの提唱者はHTTP/1.1の著者の1人ですし、HTTP/1.1をまず理解すべきと述べています。 GraphQLは現在RFC化を目指しています。

RFCでは、通信がOSや機器の違いを超えてきちんと行えるかどうかという、通信規約としての面に重点が置かれます。 そのため、プロトコルを扱うコードを実装しようとしたり、プロトコルの仕様を調べようとすると、RFCに突き当たることがよくあります。 本連載ではプログラムの低レイヤーとしてOSの提供する機能にフォーカスしていますが、通信を行うプログラムにおいては、このRFCこそがシステムコールにあたるレイヤーになります。 原文はすべて英語ということもあり、最初は読みづらいと思いますが、書き方もルール化されていて、辞書をいくら調べても解釈に苦しむジョークもなく、文章としては読みやすいほうです。 興味のある方はぜひRFCのリーディングにも挑戦してみてください。 新しいGoogle翻訳との相性も良いとのことです。

ソケットとは

インターネットを利用するアプリケーションを作ろうというとき、直接コーディングに影響がありそうなのは、前節で紹介したようなアプリケーション層のプロトコルでしょう。 Go言語にもHTTPを扱う機能が組み込まれているので、そのAPIを使うことで、HTTPやその上のプロトコルを利用したアプリケーションを開発できます。

では、HTTPそのものはどのような仕組みで下位のレイヤーを使っているのでしょうか。 現在、ほとんどのOSでは、アプリケーション層からトランスポート層のプロトコルを利用するときのAPIとしてソケットという仕組みを利用しています。

一般に、他のアプリケーションとの通信のことをプロセス間通信(IPC:Inter Process Communication)と呼びます。 OSには、シグナル、メッセージキュー、パイプ、共有メモリなど、数多くのプロセス間通信機能が用意されています。 ソケットも、そのようなプロセス間通信の一種です。 ソケットが他のプロセス間通信と少し違うのは、アドレスとポート番号が分かればローカルのコンピュータ内だけではなく外部のコンピュータとも通信が行える点です。

アプリケーション間のインターネット通信も、このソケットを通じて行います。 たとえば通常のブラウザを利用したHTTP通信では、サーバのTCPポート80番に対して、ソケットを使ったプロセス間通信を行います。

ここでは、Go言語に組み込まれているTCPの機能(net.Conn)だけを使って、HTTPによる通信を実現してみましょう。

ソケット通信の基本構造

どんなソケット通信も、基本となる構成は次のような形態です。

  • サーバ:ソケットを開いて待ち受ける
  • クライアント:開いているソケットに接続し、通信を行う

Go言語の場合、サーバが呼ぶのはListen()メソッド、クライアントが呼ぶのはDial()メソッドというAPIの命名ルールが決まっており、ソケット通信でも同様です。

通信の手順はプロトコルによって異なります。 一方的な送信しかできないUDPのようなプロトコルもあれば、接続時にサーバがクライアントを認知(Accept())して双方向にやり取りができるようになるTCPやUnixドメインソケットなどのプロトコルもあります。

Go言語におけるTCPソケットを使った通信のライフサイクルは次の図のようにまとめられます (この図ではサーバから通信を切断していますが、クライアントから切断することもできます)。

net.Connは、io.Readerio.Writerio.Closerにタイムアウトを設定するメソッドを追加したインタフェースで、通信のための共通インタフェースとして定義されています。

通信が確立できると、送信側、受信側の両方に、相手との通信を行うnet.Connインタフェースを満たすオブジェクトが渡ってきます。 以降はこのオブジェクトを使って通信を行います。

TCPのクライアントコードは次のようになります。

conn, err := net.Dial("tcp", "localhost:8080")
if err != nil {
  panic(err)
}
// connを使った読み書き

サーバ側の最低限のコードは次の通りです。

ln, err := net.Listen("tcp", ":8080")
if err != nil {
  panic(err)
}
conn, err := ln.Accept()
if err != nil {
  // handle error
}
// connを使った読み書き

これでサーバとして応答はできるものの、このコードだと一度アクセスされたら終了してしまいます。 そのため実用的なコードがこのような形になることはありません。

Go言語を使ってサーバーを実装する人が期待することは、「実装が簡単な割に秒間に処理できるレスポンス数が極めて高い」ことでしょう。 1つのリクエストの処理中も他のリクエストを受け付けたり、CPUが許す限り並列でタスクをこなしたいですよね。 これらのニーズを満たす現実的な最小限のサーバは次のようなコードになります。

ln, err := net.Listen("tcp", ":8080")
if err != nil {
  panic(err)
}
// 一度で終了しないためにAccept()を何度も繰り返し呼ぶ
for {
  conn, err := ln.Accept()
  if err != nil {
    // handle error
  }
  // 1リクエスト処理中に他のリクエストのAccept()が行えるように
  // Goroutineを使って非同期にレスポンスを処理する
  go func() {
    // connを使った読み書き
  }()
}

Go言語でHTTPサーバを実装する

Go言語でTCPソケットを使った通信は可能になりましたが、肝心なのは、このサーバで何をするかです。 クライアントから送信されたテキストをそのまま返すだけでもネットワークプログラムの勉強にはなりますが、せっかく前節でHTTPの基本を紹介したので、ここではHTTPサーバを書いてみましょう。

実際にGo言語でHTTPのコードを作成するときは、net/http以下の高機能なAPIを使います。 低レベルなnetパッケージのAPIを直接触って通信を行うことはほとんどありませんが、ここではソケット通信の実例として低レベルの機能を使ってHTTPサーバを書いていきます。

TCPソケットを使ったHTTPサーバ

Go言語のソケットを使ってHTTP/1.0相当の送受信を実現してみましょう。 まずはサーバコードです。

package main
import (
  "bufio"
  "fmt"
  "io/ioutil"
  "net"
  "net/http"
  "net/http/httputil"
  "strings"
)
func main() {
  listener, err := net.Listen("tcp", "localhost:8888")
  if err != nil {
    panic(err)
  }
  fmt.Println("Server is running at localhost:8888")
  for {
    conn, err := listener.Accept()
    if err != nil {
      panic(err)
    }
    go func() {
      fmt.Printf("Accept %v\n", conn.RemoteAddr())
      // リクエストを読み込む
      request, err := http.ReadRequest(
                        bufio.NewReader(conn))
      if err != nil {
        panic(err)
      }
      dump, err := httputil.DumpRequest(request, true)
      if err != nil {
        panic(err)
      }
      fmt.Println(string(dump))
      // レスポンスを書き込む
      response := http.Response{
        StatusCode: 200,
        ProtoMajor: 1,
        ProtoMinor: 0,
        Body:       ioutil.NopCloser(
                      strings.NewReader("Hello World\n")),
      }
      response.Write(conn)
      conn.Close()
    }()
  }
}

少し長いですが、基本構成は先ほど紹介したソケット通信そのままです。 go func()の中は非同期実行されます。

このサーバを起動して、同じマシンからcurlコマンドやウェブブラザを使ってlocalhost:8888にアクセスしてみてください。 画面に"Hello World"と表示されたでしょうか?

開発者ツールなどでレスポンスの内容を見ると、次のようなログが出力されているはずです(User-Agentの情報はクライアントによって異なります)。

Accept 127.0.0.1:54017
GET / HTTP/1.1
Host: localhost:8888
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Encoding: gzip, deflate, sdch, br
Accept-Language: en-US,en;q=0.8,ja;q=0.6
Connection: keep-alive
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/54.0.2840.98 Safari/537.36

それではサーバのコードを解説していきましょう。

まずはクライアントから送られてきたリクエストの読み込みです。 自分でテキストを解析してもいいのですが、http.ReadRequest()関数を使ってHTTPリクエストのヘッダー、メソッド、パスなどの情報を切り出しています。

読み込んだリクエストは、httpuitl.DumpRequest()関数を使って取り出しています。 この関数はhttputil以下にある便利なデバッグ用の関数です。 ここまでで、io.Readerからバイト列を読み込んで分析してデバッグ出力に出す、という処理を行っています。

次は、HTTPリクエストを送信してくれたクライアント向けにレスポンスを生成するコードです。 これにはhttp.Response構造体を使います。 http.Response構造体はWrite()メソッドを持っているので、作成したレスポンスのコンテンツをio.Writerに直接書き込むことができます。

サーバのコードでやっていることは以上です。 それぞれの処理はそれほど難しくはないでしょう。Go言語なら生のTCPソケットを使ったウェブサーバを作るのは難しくありません。 ただし、第2回のインターネットアクセスで紹介したコードよりはだいぶ長くなってしまいました。

TCPソケットを使ったHTTPクライアント

HTTPクライアントも作ってみましょう。

package main
import (
  "bufio"
  "fmt"
  "net"
  "net/http"
  "net/http/httputil"
)
func main() {
  conn, err := net.Dial("tcp", "localhost:8888")
  if err != nil {
    panic(err)
  }
  request, err := http.NewRequest(
                    "GET", "http://localhost:8888", nil)
  if err != nil {
    panic(err)
  }
  request.Write(conn)
  response, err := http.ReadResponse(
                     bufio.NewReader(conn), request)
  if err != nil {
    panic(err)
  }
  dump, err := httputil.DumpResponse(response, true)
  if err != nil {
    panic(err)
  }
  fmt.Println(string(dump))
}

実はソケットを使った最短コードは連載の第2回の記事で紹介済みです。

conn, err := net.Dial("tcp", "ascii.jp:80")
if err != nil {
  panic(err)
}
conn.Write([]byte("GET / HTTP/1.0\r\nHost: ascii.jp\r\n\r\n"))
io.Copy(os.Stdout, conn)

こちらの最短コードでは、HTTPのリクエストは文字列を直接書き込む構成にしていました。 今回の記事で書き直したクライアントの実装では、サーバの実装と同様に、リクエストとレスポンスのテキストは標準ライブラリを使って構成しています。

HTTP/1.1のKeep-Aliveに対応させる

HTTP/1.0をシンプルに実装した前節のコードでは、1セットの通信が終わるたびに通信が切れてしまいます。

HTTP/1.1ではKeep-Aliveが規格に入りました。 Keep-Aliveを使うことで、HTTP/1.0のように1つのメッセージごとに切断するのではなく、しばらくの間はTCP接続のセッションを維持して使いまわします。 TCPでは、セッションを接続するのに1.5 RTT(ラウンドトリップタイム:1往復の通信で1RTT)の時間がかかります。切断にも1.5 RTTの時間がかかります。 物理的な距離や回線速度などで1 RTTの時間は変わりますが、RTTが多ければ通信速度に直接の影響を与えます。 一度の送信(送信と確認の返信で1 RTT)につき1.5 + 1.5 = 3 RTTのオーバーヘッドがあれば、実行速度は単純に1/4です。 Keep-Aliveを使えば、この分のオーバーヘッドがなくせるため、速度の低下を防げます。

Keep-Alive対応のHTTPサーバ

長いコードなので前回のコードと重複しているところは省きます。 Accept()を呼び出した後、goに渡している関数の内部だけを紹介します。 importには"io""time"を追加してください。

go func() {
  fmt.Printf("Accept %v\n", conn.RemoteAddr())
  // Accept後のソケットで何度も応答を返すためにループ
  for {
    // タイムアウトを設定
    conn.SetReadDeadline(time.Now().Add(5 * time.Second))
    // リクエストを読み込む
    request, err := http.ReadRequest(bufio.NewReader(conn))
    if err != nil {
      // タイムアウトもしくはソケットクローズ時は終了
      // それ以外はエラーにする
      neterr, ok := err.(net.Error) // ダウンキャスト
      if ok && neterr.Timeout() {
        fmt.Println("Timeout")
        break
      } else if err == io.EOF {
        break
      }
      panic(err)
    }
    // リクエストを表示
    dump, err := httputil.DumpRequest(request, true)
    if err != nil {
      panic(err)
    }
    fmt.Println(string(dump))
    content := "Hello World\n"
    // レスポンスを書き込む
    // HTTP/1.1かつ、ContentLengthの設定が必要
    response := http.Response{
      StatusCode:    200,
      ProtoMajor:    1,
      ProtoMinor:    1,
      ContentLength: int64(len(content)),
      Body:          ioutil.NopCloser(
                       strings.NewReader(content)),
    }
    response.Write(conn)
  }
  conn.Close()
}()

このコードで重要なのは、Accept()を受信した後にforループがある点です。 これにより、コネクションが張られた後に何度もリクエストを受けられるようにしています。

タイムアウトの設定も重要です。これを設定しておくと、通信がしばらくないとタイムアウトのエラーでRead()の呼び出しを終了します。 設定しなければ相手からレスポンスがあるまでずっとブロックし続けます。 ここでは現在時刻プラス5秒を設定しています。

タイムアウトは、標準のerrorインタフェースの上位互換であるnet.Errorインタフェースの構造体から取得できます。 net.Connbufio.Readerでラップして、それをhttp.ReadRequest()関数に渡しています。 タイムアウト時のエラーはnet.Connが生成しますが、それ以外のio.Readerは最初に発生したエラーをそのまま伝搬します。 そのため、errorからダウンキャストを行うことでタイムアウトかどうかを判断できます。

それ以降はほぼ一緒ですが、唯一異なるのが最後のhttp.Responseの初期化処理です。まず、HTTPのバージョンを1.1になるように設定しています。 送信するデータのバイト長が書き込まれている点もポイントです。 Go言語のResponse.Write()は、HTTP/1.1より前もしくは長さが分からない場合はConnection: closeヘッダーを付与してしまいます。 複数のレスポンスを取り扱うには、明確にそれぞれのレスポンスが区切れる必要があります。

Keep-Alive対応のHTTPクライアント

クライアント側もKeep-Alive対応しましょう。

package main
import (
  "bufio"
  "fmt"
  "net"
  "net/http"
  "net/http/httputil"
  "strings"
)
func main() {
  sendMessages := []string{
    "ASCII",
    "PROGRAMMING",
    "PLUS",
  }
  current := 0
  var conn net.Conn = nil
  // リトライ用にループで全体を囲う
  for {
    var err error
    // まだコネクションを張ってない / エラーでリトライ時はDialから行う
    if conn == nil {
      conn, err = net.Dial("tcp", "localhost:8888")
      if err != nil {
        panic(err)
      }
      fmt.Printf("Access: %d\n", current)
    }
    // POSTで文字列を送るリクエストを作成
    request, err := http.NewRequest(
                      "POST",
                      "http://localhost:8888",
                      strings.NewReader(sendMessages[current]))
    if err != nil {
      panic(err)
    }
    err = request.Write(conn)
    if err != nil {
      panic(err)
    }
    // サーバから読み込む。タイムアウトはここでエラーになるのでリトライ
    response, err := http.ReadResponse(
                       bufio.NewReader(conn), request)
    if err != nil {
      fmt.Println("Retry")
      conn = nil
      continue
    }
    // 結果を表示
    dump, err := httputil.DumpResponse(response, true)
    if err != nil {
      panic(err)
    }
    fmt.Println(string(dump))
    // 全部送信完了していれば終了
    current++
    if current == len(sendMessages) {
      break
    }
  }
}

クライアントでは、簡単化のため、送信メッセージをあらかじめ配列に入れておいてその送信が終わったら終了というコードになっています。 それでも最初のコードよりもだいぶ複雑になりました。

サーバ同様、一度通信を開始したソケットはなるべく再利用します。 サーバ側と異なるのは、通信の起点はソケットなので、セッションが切れた場合の再接続はクライアント側にあるという点です。 切れた場合はnet.Conn型の変数を一度クリアして再試行するようになっています。

まとめと次回予告

今回は、HTTPを通してTCPソケットによる通信の基礎をさらいました。 ソケット通信の紹介というと、送った内容をそのまま返すエコーサーバを使った説明がほとんどでしょう。 しかしこの記事では、読者の多くにとってよりなじみ深いHTTPによる通信を実装してみました。

Go言語では、ソケットもまた、第2回の記事から何度となく登場しているio.Writerです。 さらにHTTPプロトコルのテキストをio.Writerに直接読み書きする機能(Response.Write()Request.Write())が提供されています。 これらの機能を使うことで、素朴なHTTP/1.0からKeep-Aliveに対応したHTTP/1.1まで、HTTPの歴史の変化を追いかけてみました。

基本的にHTTPの歴史は、「高速化」と「セキュリティ強化」という2つのベクトルに沿って進んできました。 そんなHTTPの機能をプロトコルレベルで見ると、ソケット通信の実践的な手法がいろいろと学べます。 自分で通信処理をソケットから作るような場合も、HTTPの実装を参考にすることで、どこでどのようにループを行い、タイムアウトをどのように扱い、どのような機能セットを用意すればいいか想像しやすくなるはずです。 実際、プロトコルの読み書きをする関数やメソッドを作ってしまったら、今回紹介したコードのResponse.Write()Request.Write()呼び出し部分だけが入れ替わって基本構成はほとんど変わらないコードになると思います。

次回も引き続きHTTPを題材にしてTCPソケットについて紹介する予定です。


  1. あまり使うことはないと思いますが、UNIX系OSであればIPのレイヤーについてもプログラマから利用できるようなAPIが用意されています。

  2. 昔はヘッダー行の途中で改行を許可していたりして簡単ではありませんでした。

  3. 当初はSSLと呼ばれていて、今でも慣習的にSSLが使われていますが、例えるならApple社のMacをマッキントッシュと呼ぶようなものです。

  4. HTTPは今は2層に分離したプロトコルといえるでしょう。

  5. 単純化には欠点もあって、RESTではトランザクションを記述することはできません。

カテゴリートップへ

この連載の記事
ピックアップ