まだプログラマーですが何か?

プログラマーネタとアスリートネタ中心。たまに作成したウェブサービス関連の話も http://twitter.com/dotnsf

タグ:http

Java で(Web)アプリケーションから REST API を実行する時など、HTTP のクライアント機能を java.net.* から作るのは面倒です。現実的にはなんらかのライブラリを使うことになると思います。

そんな場合によく使われるのが Apache HTTP Client だと思ってます。2017/Mar/16 現在の最新バージョンは 4.5.3 でした。モジュールはこちらからダウンロードできます:
https://hc.apache.org/downloads.cgi

2017031501


上記サイトの HttpClient カテゴリから 4.5.3.zip と書かれたリンクをクリックすると 4.5.3 のバイナリが zip アーカイブとして取得できます(ファイル名は httpcomponents-client-4.5.3-bin.zip)。ダウンロードした zip ファイルを展開し、lib フォルダから jar ファイル群を取り出します(今回のサンプルで最低限必要なのは以下の6ファイルです):
  • commons-codec-1.4.jar
  • commons-logging-1.2.jar
  • httpclient-4.5.3.jar
  • httpclient-cache-4.5.3.jar
  • httpcore-4.4.6.jar
  • httpmime-4.5.3.jar

Eclipse 等で Java のウェブアプリケーションプロジェクトを作成し、lib フォルダ(Webcontent/WEB-INF/lib など)に上記作業で取り出した jar ファイル群をまとめてコピーしておきます。これで準備完了:
2017031502


では実際にこれらのモジュールを使って HTTP アクセスを実現するプログラムを書いて実行してみます。今回はスタンドアロンに HTTP GET を実行する、こんなプログラムにしてみます:
import org.apache.http.HttpEntity;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.util.EntityUtils;

public class HttpClient1 {
  public static void main(String[] args) {
    // TODO Auto-generated method stub
    String url = "https://www.ibm.com/developerworks/jp/"; //. HTTP GET する URL

    try{
      CloseableHttpClient client = HttpClients.createDefault();
      HttpGet get = new HttpGet( url );
      CloseableHttpResponse response = client.execute( get );
      int sc = response.getStatusLine().getStatusCode(); //. 200 の想定
      HttpEntity entity = response.getEntity();
      String html = EntityUtils.toString( entity, "UTF-8" );
      System.out.println( html ); //. 取得結果をコンソールへ
      client.close();
    }catch( Exception e ){
      e.printStackTrace();
    }
  }
}

指定した URL(上記の場合は https://www.ibm.com/developerworks/jp/")に HTTP でアクセスして、GET した結果をコンソールに出力する、というものです。この内容を記述したファイル(HttpClient1.java)を Eclipse から実行します:
2017031503


で、指定した URL  の HTML が取得できることを確認します。HTTP GET は呼び出すだけなのでシンプルですね:
2017031504


アクセス先として HTML のようなテキストではなく、画像のようなバイナリデータの場合は以下のように byte 配列として結果を取得します(HTTP リクエストヘッダを設定する例も加えています):
import org.apache.http.HttpEntity;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.util.EntityUtils;

public class HttpClient2 {
  public static void main(String[] args) {
    // TODO Auto-generated method stub
    String url = "https://dw1.s81c.com/developerworks/i/f-ii-ibmbluemix.png"; //. HTTP GET する URL

    try{
      CloseableHttpClient client = HttpClients.createDefault();
      HttpGet get = new HttpGet( url );
      get.addHeader( "User-Agent", "MyBot/1.0" );  //. HTTP リクエストヘッダの設定
      CloseableHttpResponse response = client.execute( get );
      int sc = response.getStatusLine().getStatusCode(); //. 200 の想定
      HttpEntity entity = response.getEntity();
      byte[] img = EntityUtils.toByteArray( entity );
      System.out.println( "" + img.length ); //. 取得結果をコンソールへ
      client.close();
    }catch( Exception e ){
      e.printStackTrace();
    }
  }
}

一方、HTTP POST の場合も同様ですが、GET の時との違いとしてポストデータを送信する必要もあります。以下はテキスト情報とファイルのアップロードを同時に(Multipart で)送信する場合の例です:
import java.io.File;
import java.io.FileInputStream;

import org.apache.http.HttpEntity;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.entity.ContentType;
import org.apache.http.entity.mime.MultipartEntityBuilder;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.util.EntityUtils;

public class HttpClient3 {
  public static void main(String[] args) {
    // TODO Auto-generated method stub
    String url = "https://xxx.com/posturl"; //. HTTP POST する URL

    try{
      CloseableHttpClient client = HttpClients.createDefault();
      HttpPost post = new HttpPost( url );

//. 文字情報2つとファイル1つをポスト MultipartEntityBuilder builder = MultipartEntityBuilder.create(); builder.addTextBody( "name", "K.Kimura", ContentType.TEXT_PLAIN ); builder.addTextBody( "email", "dotnsf@jp.ibm.com", ContentType.TEXT_PLAIN ); File f = new File( "./logo.png" ); builder.addBinaryBody( "image_file", new FileInputStream( f ), ContentType.APPLICATION_OCTET_STREAM, f.getName() ); HttpEntity multipart = builder.build(); post.setEntity( multipart ); CloseableHttpResponse response = client.execute( post ); int sc = response.getStatusLine().getStatusCode(); //. 200 の想定 HttpEntity entity = response.getEntity(); String html = EntityUtils.toString( entity, "UTF-8" ); System.out.println( html ); //. 取得結果をコンソールへ client.close(); }catch( Exception e ){ e.printStackTrace(); } } }

PUT や DELETE の場合も同様に。

とある REST API を使っていて気付いたこと/考えさせられたことをまとめてみました。明快な結論や提案があるわけではなく、グダグダに感じられる内容かもしれないので、あらかじめご了承ください。


そのとある REST API を使ったウェブアプリケーションを作って運用している中で、おかしな挙動に気付くことがありました。以前は問題なく動いていたのに、あるタイミングで実行すると期待通りに動かない、という「まあまあよくある」ケースです。自分のケースでは動かないというよりも、タイムアウトを起こすような挙動になっていました。ただ REST API そのものが止まっている様子はない、というケースでした。

自分のソースコードでは一応エラーハンドリングはしていたつもりでしたが、REST API がエラーを起こしている様子もなく、原因究明に時間を要するものでした。結論としては自分のアプリケーションコードを見ていてもよく分からず、REST API のユニットテストのようなツールを動かした結果気付くことがありました。

それがこちら:
20170207


・・・わかるでしょうか? REST API を実行したレスポンス本文が Response Body に、ステータスコードが Response Code に記述されています。これによるとレスポンス本文は
{
  "status": "ERROR",
  "statusInfo": "daily-transaction-limit-exceeded"
}

となっていて、"daily-transaction-limit-exceeded" が原因のエラーが発生している、という内容でした。この API には Daily Transaction Limit(1日で使える回数の上限)が決められていて、その上限に達したのでもう実行できない、という内容です。これに関してはそういう条件で使っている API なので、なるほど、エラーの原因はわかりました。

問題はこの REST API を実行したステータスコードが 200 になっている点です。HTTP ステータスコードの分類とその意味についてはウィキペディアなどを参照していただきたいのですが、簡単にいうとこんな感じで分類されています:
コード意味考えられる原因など
2xx(200番台)成功 -
4xx(400番台)クライアントエラー認証が必要、アクセス権がない、URLが間違っている、タイムアウト、、、
5xx(500番台)サーバーエラーアプリケーションエラー、不正なゲートウェイが利用されている、、、


200 番台は成功。400 番台と 500 番台がエラーで、それぞれクライアント側のエラーなのか、サーバー側のエラーなのかを分類しています。

で、今回のケースですが、実行結果はエラーなのに 200 番のステータスコードが返されているのでした。自分のプログラムコードの中では「200 が返ってきたら成功」と決めつけて実装していたため、このようなケースに対処できていなかったのでした。


ここまでが実際に目の当たりにしたエラーとその原因でした。以下はこの件に関して自分が考えたことです。


自分を弁護する意味で「えー、でもそれっておかしくない? エラーなんだからスタータスコードは 400 番台なり 500 番台で返ってくるべきでは?」という考えもないわけではありません。ただ今回のケースではこれも難しいような、つまり 200 番のステータスコードがあながち間違ってはいないような気もしています。

その理由として、まず「これはクライアントエラーなのか?それともサーバーエラーなのか?どちらかに分類できるのか?」という問題です。これに関してはどちらとも言えるし、どちらとも言えないと思っています。

実は 402 番のステータスコードは "Payment Required" 、つまり(お金を払わないといけないんだけど)払ってないエラー、と定義されています。が、実際には定義だけで実装されていない、つまり将来のための定義とされているのでした。また厳密には支払い契約を結んで使っているわけではなく、「この条件で1日○○回使える」というルールの下で利用しているだけなので、402 番に(クライアントエラーに)該当するエラーであるとは考えにくいのです。

※本来の意味とは違いますが、「権限がない」に該当するのではないかと言われると、まあ・・・ という考え方もあるとは思います。ただそれをクライアントエラーとして返してもクライアント側はどうにもできないので、やはり 400 番台エラーには該当しないと思います。

ではサーバーエラーなのか?というと、これも該当しないと思います。プログラミングにミスがあったわけではなく、「実行したら、『実行できない』という結果が返ってきた」のは、正しく実行された(結果が期待通りではなかった)とも言えます。そう考えると、そもそも今回の件は REST API レベルではエラーですらないとも言えます。


そう考えると、今のように API を組み合わせてアプリケーションを作ることが珍しくない環境においては、200 番のステータスコードが返ってきてもエラーの可能性を疑ってコーディングする必要があるのかも、と思うようになりました。このケースであれば実装側が工夫すれば(というか、そもそもちゃんと色んなケースを想定してエラーハンドリングしていれば)防げるものです。

そういう意味でもいい反省の機会でした。 でも今後は同様のケースを想定した「成功でもエラーでもないステータス」が出て来る可能性もありますよね。。


前回の続きです:
CentOS に Go をインストールする


Go の実行環境が導入できたので、テンプレートエンジンを使ってみます。まずは HTML テンプレートを作成しておきます。base.html というファイル名で以下の内容を作成/保存します:
{{define "base"}}<!doctype html>
<html>
<head>
<meta charset="utf-8"/>
<title>{{.Title}}</title>
</head>
<body>
<div class="content">
<h1>{{.Title}}</h1>
<hr/>
<div>
{{.Body|safehtml}}
</div>
</div>
</body>
</html>{{end}}

↑ほぼ HTML ですが、赤字で書いた部分が変数になります。変数名としては Title と Body の2つがあり、その中身はこの後の Go アプリの中で定義して渡します。また全体を "base" という名前で定義しています(青字部分)。


次にこのテンプレートを使って画面を表示する Go のウェブアプリを作成します(http2.go というファイル名で、base.html と同じディレクトリに作成します):
package main

import(
  "net/http"
  "html/template"
)

func viewHandler( w http.ResponseWriter, req *http.Request ){
  funcMap := template.FuncMap{
    "safehtml": func(text string) template.HTML { return template.HTML(text) },
  }
  templates := template.Must(template.New("").Funcs(funcMap).ParseFiles("base.html"))
  dat := struct {
    Title string
    Body string
  }{
    Title: req.FormValue( "title" ),
    Body: req.FormValue( "body" ),
  }
  err := templates.ExecuteTemplate(w, "base", dat)
  if err != nil {
    http.Error( w, err.Error(), http.StatusInternalServerError )
  }
}

func main(){
  http.HandleFunc( "/", viewHandler )
  http.ListenAndServe( ":8080", nil )
}

基本形は前回の Go ウェブアプリとほぼ同じですが、まず html/template を使う宣言をして、テンプレートに上記で作成した(同じディレクトリ上にある) base.html を使う宣言をしています。

またテンプレート内の変数 Title, Body を、それぞれ title, body という URL パラメータを受け取って定義するようにしています。

この状態で http2.go を実行します:
# go run http2.go


そしてウェブブラウザでこのサーバーの 8080 番ポートのルートパスに、パラメータ title と body を指定してアクセスすると、指定した内容がウェブページの一部になって表示されるはずです:
2016042602


これで Go でもテンプレートエンジンっぽいこともできました。

StrongLoopLoopBack を使うと、データベースのモデルを定義するだけで CRUD の REST API を生成し、OpenAPI(Swagger) スタイルのドキュメントと併せて簡単に公開できます。この辺りについては以前のブログエントリを参照してください:
CentOS に StrongLoop をインストールする


ところで、LoopBack を使って公開された API にはパラメータで挙動を指定できるものもあります。例えばモデルの一覧を取得する GET リクエストでは一覧を絞り込むためのクエリーを指定したり、取得結果の数やオフセットを指定することも可能です:
2016020601


そのための方法を紹介します。例えば items というテーブルに対して LoopBack で CRUD の API を作成したと仮定します。items の一覧を取得するには以下の様な URL に対する HTTP リクエストを GET で実行することになります(XX.XX.XX.XX は LoopBack が動いているサーバー):
http://XX.XX.XX.XX/api/items/

さて、一覧の検索条件(例えば id < 100)を指定する場合、SQL ではこのように指定することになります:
> select * from items where id < 100;

この条件を指定して上記の HTTP GET リクエストを実行するには、つまり id < 100 のものだけの一覧を API で取得するには、以下の様なパラメータを指定して実行します:
http://XX.XX.XX.XX/api/items?filter[where][id][lt]=100

filter という名前に [where] 句が指定され、更に条件である [id] が [lt] (Less Than) で 100 である、という条件が指定されていることになります。


一方、一覧の検索結果の取得数(例えば10件)を指定する場合、SQL(MySQL) ではこのように指定します:
> select * from items limit 10;

この条件を指定して HTTP GET リクエストを実行するには、以下の様なパラメータを指定して実行します:
http://XX.XX.XX.XX/api/items?filter[limit]=10

filter という名前に [limit] 句が指定され、その値が 10 である、という条件が指定されていることになります。なんとなくコツが分かってきましたか?

ちなみに一覧の検索結果の取得数を 50 件目からの 10 件、とするには SQL(MySQL) ではこのように指定します:
> select * from items limit 50, 10;

limit 句を指定し、オフセットが 0 以外の場合は limit 数の前にオフセット数を指定します。これを API のパラメータで指定するには以下のようにします:
http://XX.XX.XX.XX/api/items?filter[limit]=10&filter[offset]=50

クエリーや結果取得の条件は同時に指定することができます。例えば「 id < 100 のものをオフセット 50 で10 件取得」するのであれば、API ではこのように指定します:
http://XX.XX.XX.XX/api/items?filter[where][id][lt]=100&filter[limit]=10&filter[offset]=50

filter の使い方、なんとなくコツがわかってきましたか? 使っているデータベースの種類は何で、そのデータベースでは SQL ではどういった指定になるか、をイメージできるとわかりやすいです。


このパラメータに関する API ドキュメントはこちら:
https://docs.strongloop.com/display/public/LB/Where+filter
 

ウェブサーバー(HTTP サーバー)を使う場合、なんらかのアクセスログを残すことになります。例えば Apache HTTPD の場合、デフォルトでアクセスログは /var/log/httpd/access_log に残ります。そのフォーマットは /etc/httpd/conf/httpd.conf 内で設定/カスタマイズできます。
2015012200


デフォルトの httpd.conf では、以下の様なログフォーマットが指定されています:
LogFormat "%h %l %u %t \"%r\" %>s %b common

暗号のような記述ですが、左から順にこのような意味があります:
 %h : 接続元のリモートホスト名(IPアドレス)
 %l : クライアント識別子(取得できない場合は - )
 %u : 認証時のユーザー名(取得できない場合は - )
 %t : 時刻
 %r : リクエストの最初の行の値
 %s : レスポンスステータス
 %b : 送信バイト数(0バイトの場合は - )

このログフォーマットであれば、以下の様なログが取得できることになります:
192.168.1.101 - - [20/Jan/2015:11:32:29 +0900] "GET / HTTP/1.1" 200 -


最初の値がアクセス元のホストになります。つまりこれで(プロクシが使われている場合はプロクシのアドレスになりますが)どこからのアクセスがあったのか、という情報が分かります。


ところが、このアクセス元ホストが分からなくなる場合があります。それは HTTP サーバーがロードバランサ経由で使われていた場合です。サーバーの負荷軽減などの目的で、サーバーを複数台構成にしてロードバランサ経由で利用する、というケースは珍しくないと思います(クラウド環境によっては1台構成でもロードバランサ経由になることもあります)。ただその場合、この設定だとアクセス元ホストは必ずロードバランサの IP アドレスになってしまい、結果として常に同じホストからのアクセスがあったとログに記録されることになります。

これを避けるには httpd.conf をカスタマイズする必要があります。具体的にはこんな感じに:
LogFormat "%{X-Forwarded-For}i %l %u %t \"%r\" %>s %b common


%h の代わりに %{X-Forwarded-For}i という記述に変えました。この意味ですが、まず %{XXX}i という記述は「リクエストヘッダに含まれるヘッダー名 XXX の値」です。つまりこの記述は「リクエストヘッダに含まれる X-Forwarded-For ヘッダの値」をログに書き出すよう指示していることになります。

そしてリクエストヘッダの X-Forwarded-For ですが、これはプロクシやロードバランサなどのキャッシングサーバーに接続するクライアントの送信元 IP アドレスです。つまりキャッシングサーバーから見た時の %h の値がこのヘッダ値に入ってくるので、その値を取り出してログに記録すればよい、ということになるわけです。 そこで上記のような設定をすることでロードバランサ経由であっても、元のクライアントの IP アドレスを記録できるようになるのでした。

なお、この方法は HTTP プロトコルに限って有効です。HTTPS では暗号化によって HTTP ヘッダに送信元 IP アドレスが記録できるかどうか、ロードバランサによって変わってきてしまいます。お使いのロードバランサが HTTPS をサポートしているか、サポートしていない場合の対応方法などはお使いのクラウド業者に問い合わせる必要があると思っています。



 

このページのトップヘ