BT

最新技術を追い求めるデベロッパのための情報コミュニティ

寄稿

Topics

地域を選ぶ

InfoQ ホームページ アーティクル ETagを使ってSpringとHibernateの転送量と負荷を削減する

ETagを使ってSpringとHibernateの転送量と負荷を削減する

イントロダクション

最近のRESTスタイルのアプリケーションアーキテクチャに対する興味のうねりは、WEBの設計のエレガントさを浮き彫りにしました。私たちは今、WEBアーキテクチャ(英語)の背後に内在しているスケーラビリティと柔軟性を理解し始め、そのパラダイムをより一層受け入れるための道を探しています。この記事では、WEBアプリケーション開発者が利用可能だがあまり名の知られていない機能のひとつである「ETagレスポンスヘッダ」というちょっとしたテクニックを見ていき、それをどのようにSpringフレームワークを用いた動的WEBアプリケーションと統合させ、アプリケーションのパフォーマンスとスケーラビリティを向上させるのか考えてみます。

私たちが用いるSpringアプリケーションはPetClinicアプリケーションに基づいています。ダウンロードしたファイルの中には、必要な設定をどのように追加するかが記されたドキュメントと、自分自身でそれを試してみるためのソースコードが含まれています。

「ETag」とは何か?

HTTPプロトコル仕様はETagを「要求されたバージョンのための実体値」と定義しています(http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html のセクション14.19を参照)。別の言い方をすれば、ETagとはWEBリソースに関連付け可能なトークンです。WEBリソースとは一般的にWEBページのことを指しますが、JSONやXMLのドキュメントでもかまいません。サーバはもっぱらリソースを指し示すトークンを算出し、HTTPレスポンスヘッダを使ってクライアントにそれを転送する役目を果たします。

ETagがパフォーマンスを向上させる仕組みとは?

ETagは、クライアント(ブラウザなど)のキャッシュを有効利用するために、経験豊富なサーバ開発者がGETリクエストの「If-None-Match」ヘッダと共に利用します。サーバは、最初に生成したETagを用いて、以降そのページに変更があったかどうかを判断することができます。原則としては、クライアントはトークンをサーバに送り返すことで、自分のもっているキャッシュが有効かどうかをサーバに確認します。

プロセスは次のようになります。

  1. クライアントがページAをリクエストする
  2. サーバがページA用のETagをつけてページAを返す
  3. クライアントはページをレンダリングし、ETagと共にそれをキャッシュする
  4. クライアントが再度ページAをリクエストする。その際、最後のリクエストでサーバから返ってきたETagを一緒に渡す
  5. サーバはETagを調べ、クライアントの最後のリクエストからページに変更がないと判断すると、空のボディと共に304(Not Modified)のレスポンスを返す

記事の以降の部分では、Spring MVCを使ってSpringフレームワーク上に構築したWEBアプリケーションでETagを活用する二つのアプローチに言及します。一つ目は、レンダリングされたビューのMD5チェックサムを使って生成したETagを、Servlet2.3のフィルタを使って適用します(”浅い”ETagのインプリメーション(英語))。二つ目のアプローチでは、より洗練された方法を使ってビューが利用するモデルの変更を追跡し、ETagの妥当性を判断します("深い"ETag"のインプリメーション(英語))。私たちはSpring MVCを使っていますが、このテクニックはどのMVC型WEBフレームワークにも適用することができます。

次に進む前に、ここで提示されているテクニックがページの動的生成のパフォーマンスを向上させることを意図している、ということを心に留めておくことが重要です。既存の最適化テクニックも、全体的な最適化やアプリケーションのパフォーマンスプロファイル解析のチューニングの一部として捉えられるべきです(サイドバーを参照)。

徹底したWEBキャッシング

この記事では、動的生成されたページのHTTPを用いたキャッシュ技術を主に扱います。WEBアプリケーションのパフォーマンス向上を考えるときは、全体的で徹底したアプローチをとらなければなりません。この目的を達成するには、HTTPリクエストが通過するレイヤについて理解し、ネックになっていると考えられる場所に応じた技術を適用するのが重要です。例えば下に挙げるような技術です。
  • Apacheをサーブレットコンテナのフロントとして利用し、画像やJavaScriptのような静的ファイルのハンドリングを行う。FileETagディレクティブを用いてETagレスポンスヘッダを生成する。
  • 複数ファイルをひとつにまとめたり、ホワイトスペースを圧縮したりといったJavaScriptファイルの最適化テクニックを利用する。
  • GZipとCache-Controlヘッダの活用。
  • Springフレームワークを使ったアプリケーションで、どこが問題点となっているか調べるために、JamonPerformanceMonitorInterceptorの利用を検討する。
  • ORMツールにおいて、オブジェクトが頻繁にデータベースから再構築されないようにキャッシュメカニズムをフル活用できているかどうか確認する。クエリキャッシュ機能の使い方を知ることには時間を費やすだけの価値がある。
  • データベースからのデータ取得の総量を確実に最小化する。特に巨大なリストはページに分割し、各ページがリストの小さなサブセットをリクエストすべきである。
  • HTTPセッションの中身を最小化する。これはメモリを節約し、アプリケーション層をクラスタリングする際の役に立つ。
  • データベースプロファイリングツールを使って、クエリ時にどのインデックスが使われているか、更新時にテーブル全体にロックがかかっていないか、確認する。

もちろん、パフォーマンス最適化の至言「二度測ってから切れ」が適用されます。おっと、いや、これは大工の格言でしたが、しかし私たちにも同様に役立ちます。

Content Body ETag フィルタ

私たちが見る最初のアプローチは、ページコンテンツすなわちMVCのViewに基づくETagトークンを生成するサーブレットフィルタの作成です。一見したところ、このアプローチで得られるパフォーマンスの向上は直感的でないように思えるかもしれません。私たちは相変わらずページを生成しなければなりませんし、それにトークンを生成する計算処理が加わります。しかし、このアイデアは転送量を減少させるためのものなのです。クライアントが地球の反対側からアクセスしている時など待ち時間の大きな状況で、このアプローチは特に有益です。私は、東京のオフィスで利用されるアプリケーションをニューヨークのサーバにホスティングしているケースで350msに及ぶ待ち時間を確認しました。同時にアクセスしているユーザの数にもよりますが、これは重大なボトルネックになる可能性があります。

コード

トークンを生成するために用いるテクニックは、ページの内容からMD5ハッシュ値を計算することをベースにしています。これはレスポンスのラッパーを作成することで実現します。ラッパーは生成されたコンテンツをバイト配列として保持し、フィルタチェインの処理の完了後、その配列のMD5ハッシュ値を使ってトークンを計算します。

doFilterメソッドの実装を次に示します。

 public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException,
ServletException {
HttpServletRequest servletRequest = (HttpServletRequest) req;
HttpServletResponse servletResponse = (HttpServletResponse) res;

ByteArrayOutputStream baos = new ByteArrayOutputStream();
ETagResponseWrapper wrappedResponse = new ETagResponseWrapper(servletResponse, baos);
chain.doFilter(servletRequest, wrappedResponse);

byte[] bytes = baos.toByteArray();

String token = '"' + ETagComputeUtils.getMd5Digest(bytes) + '"';
servletResponse.setHeader("ETag", token); // always store the ETag in the header

String previousToken = servletRequest.getHeader("If-None-Match");
if (previousToken != null && previousToken.equals(token)) { // compare previous token with current one
logger.debug("ETag match: returning 304 Not Modified");
servletResponse.sendError(HttpServletResponse.SC_NOT_MODIFIED);
// use the same date we sent when we created the ETag the first time through
servletResponse.setHeader("Last-Modified", servletRequest.getHeader("If-Modified-Since"));
} else { // first time through - set last modified time to now
Calendar cal = Calendar.getInstance();
cal.set(Calendar.MILLISECOND, 0);
Date lastModified = cal.getTime();
servletResponse.setDateHeader("Last-Modified", lastModified.getTime());

logger.debug("Writing body content");
servletResponse.setContentLength(bytes.length);
ServletOutputStream sos = servletResponse.getOutputStream();
sos.write(bytes);
sos.flush();
sos.close();
}
}
リスト1: ETagContentFilter.doFilter

Last-Modifiedヘッダも設定していることに気付くと思います。これは、ETagヘッダを理解しないクライアントの要求を満たすようなコンテンツをサーバが生成するのによい形式であると考えられます。

サンプルコードはETagComputeUtilsというユーティリティクラスを使ってオブジェクトを表わすバイト配列を生成し、MD5ダイジェストのロジックをハンドリングします。MD5ハッシュコードの計算にはjavax.securityパッケージのMessageDigestクラスを使用しました。

public static byte[] serialize(Object obj) throws IOException {
byte[] byteArray = null;
ByteArrayOutputStream baos = null;
ObjectOutputStream out = null;
try {
// These objects are closed in the finally.
baos = new ByteArrayOutputStream();
out = new ObjectOutputStream(baos);
out.writeObject(obj);
byteArray = baos.toByteArray();
} finally {
if (out != null) {
out.close();
}
}
return byteArray;
}

public static String getMd5Digest(byte[] bytes) {
MessageDigest md;
try {
md = MessageDigest.getInstance("MD5");
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("MD5 cryptographic algorithm is not available.", e);
}
byte[] messageDigest = md.digest(bytes);
BigInteger number = new BigInteger(1, messageDigest);
// prepend a zero to get a "proper" MD5 hash value
StringBuffer sb = new StringBuffer('0');
sb.append(number.toString(16));
return sb.toString();
}
リスト2: ETagComputeUtils

そのフィルタを通常通りweb.xmlに登録します。

    
<filter>
<filter-name>ETag Content Filter</filter-name>
<filter-class>org.springframework.samples.petclinic.web.ETagContentFilter</filter-class>
</filter>

<filter-mapping>
<filter-name>ETag Content Filter</filter-name>
<url-pattern>/*.htm</url-pattern>
</filter-mapping>
  リスト3: Configuration of the filter in web.xml

各.htmファイルは、クライアントの最後のリクエストからページが変更されていなければボディが空のHTTPレスポンスを返すETagContentFilterを使ってフィルタリングされます。

ここで示したアプローチは一部のタイプのページには有用ですが、次に挙げるように2~3の問題点があります。

  • サーバ上でページがレンダリングされた後、それがクライアントに送られる前に、ETagの値は計算される。もしETagの値がマッチすれば、レンダリングされたページはクライアントに送られないので、モデルのためにデータを引っぱってくる必要は実はない。
  • フッター部分に日付と時間を表示するといったことを行うページでは、内容が実際には変更されていなくても、毎回異なっているということになってしまう。

次のセクションでは、問題に向けたもう一つのアプローチを見ていきます。このアプローチでは、ページ構築の基礎となるデータについてより理解を深めることで、上に挙げた制限の一部を克服します。


ETagインターセプタ

Spring MVCのHTTPリクエスト処理パイプラインは、コントローラがリクエストを処理する前にInterceptorをプラグインする機能をもっています。ページの構築に利用するデータが変更されていないことがわかったときにそれ以上の処理を避けることができるようETagの比較ロジックを埋め込むなら、ここは理想的な場所です。

ポイントは、ページを構成するデータが変更されたかどうかをどうやって知るかです。この記事のために、私はHibernateイベントリスナ経由で新規登録、更新および削除処理の履歴を保持するシンプルなModifiedObjectTrackerクラスを作成しました。このクラスはアプリケーションのViewごとに固有のカウンタと、HibernateのエンティティがどのViewの内容に影響を及ぼすかを示すマップを保持します。POJOが変更されると、そのエンティティを利用しているViewのカウンタをインクリメントします。そのカウンタをETagとして利用することで、クライアントがそれを送り返してきたときに、ページの背後にあるオブジェクトのうちの一つが変更されたかどうかを知ることができるのです。

コード

まずはModifiedObjectTrackerのコードです。

public interface ModifiedObjectTracker {
void notifyModified(> String entity);
}

とてもシンプルだと思いませんか? 実装はもう少し興味深い内容になっています。エンティティが変更されると、その変更に影響を受ける全てのViewのカウンタを更新します。

public void notifyModified(String entity) {
// entityViewMap is a map of entity -> list of view names
List views = getEntityViewMap().get(entity);

if (views == null) {
return; // no views are configured for this entity
}

synchronized (counts) {
for (String view : views) {
Integer count = counts.get(view);
counts.put(view, ++count);
}
}
}

「変更」とは、新規登録、更新、削除のことです。ここでは削除処理のためのハンドラのコードを示します(Hibernate3用のLocalSessionFactoryBeanにイベントリスナとして設定)。

public class DeleteHandler extends DefaultDeleteEventListener {
private ModifiedObjectTracker tracker;

public void onDelete(DeleteEvent event) throws HibernateException {
getModifiedObjectTracker().notifyModified(event.getEntityName());
}

public ModifiedObjectTracker getModifiedObjectTracker() {
return tracker;
}
public void setModifiedObjectTracker(ModifiedObjectTracker tracker) {
this.tracker = tracker;
}
}

ModifiedObjectTrackerはSpringの設定を通じてDeleteHandlerに注入されます。新規生成および更新されたPOJOを扱うSaveOrUpdateHandlerも同じようにします。

もしクライアントが現在有効なETag値を返してきたら(これは最後のリクエストからページ内容が変更されていないことを意味します)、パフォーマンスの向上を実現するため、それ以上の処理は行われないようにしたいと考えるでしょう。Spring MVCではHandlerInterceptorAdaptorを使用して次のようにpreHandleメソッドをオーバーライドすることが可能です。

public final boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws
ServletException, IOException {
String method = request.getMethod();
if (!"GET".equals(method))
return true;

String previousToken = request.getHeader("If-None-Match");
String token = getTokenFactory().getToken(request);

// compare previous token with current one
if ((token != null) && (previousToken != null && previousToken.equals('"' + token + '"'))) {
response.sendError(HttpServletResponse.SC_NOT_MODIFIED);
// re-use original last modified timestamp
response.setHeader("Last-Modified", request.getHeader("If-Modified-Since"))
return false; // no further processing required
}

// set header for the next time the client calls
if (token != null) {
response.setHeader("ETag", '"' + token + '"');

// first time through - set last modified time to now
Calendar cal = Calendar.getInstance();
cal.set(Calendar.MILLISECOND, 0);
Date lastModified = cal.getTime();
response.setDateHeader("Last-Modified", lastModified.getTime());
}

return true;
}

まずはGETリクエストを処理しているということを確認します(PUTメソッドでETagを用いると更新の衝突を検出することができますが、その話はこの記事の範囲外です)。トークンが最後に送り返したものと一致すれば、304 Not Modifiedを返し、リクエスト処理の残りはスキップします。そうでなければクライアントの次のリクエストのための準備としてETagレスポンスヘッダをセットします。

トークンを生成するロジックは異なる実装をプラグインできるようにインターフェースを使って抽象化していることに気付く思います。インターフェースは一つのメソッドをもっています。

public interface ETagTokenFactory {
String getToken(HttpServletRequest request);
}

記載するコードを最小限に抑えるため、ここでもシンプルな実装であるSampleTokenFactoryがETagTokenFactoryの役割を果たします。この実装は、リクエストされたURIのカウンタを単純に返すことでトークンを生成しています。

public String getToken(HttpServletRequest request) {
String view = request.getRequestURI();
Integer count = counts.get(view);
if (count == null) {
return null;
}

return count.toString();
}

これで完成です。

通信

これで、私たちの作成したインターセプタは、変更がない場合のデータ収集やViewのレンダリングに費やされるサイクルを全て回避してくれるでしょう。ここで、HTTPヘッダを覗いて(LiveHTTPHeadersを使います)、裏側で何が起きているのか確認してみたいと思います。ダウンロードには、インターセプタを設定してowner.htmのETagを有効にする方法を書いたドキュメントが含まれています。

以下で作成する最初のリクエストは、ユーザがこのページをすでに閲覧済みであることを示しています。

----------------------------------------------------------  
http://localhost:8080/petclinic/owner.htm?ownerId=10

GET /petclinic/owner.htm?ownerId=10 HTTP/1.1
Host: localhost:8080
User-Agent: Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.8.1.4) Gecko/20070515 Firefox/2.0.0.4
Accept: text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.7
Keep-Alive: 300
Connection: keep-alive
Cookie: JSESSIONID=13D2E0CB63897F4EDB56639E46D2BBD8
X-lori-time-1: 1182364348062
If-Modified-Since: Wed, 20 Jun 2007 18:29:03 GMT
If-None-Match: "-1"

HTTP/1.x 304 Not Modified
Server: Apache-Coyote/1.1
Date: Wed, 20 Jun 2007 18:32:30 GMT

次に変更を加え、ETagが変更されるかどうか確認します。以下のとおり、このオーナーにペットを追加します。

----------------------------------------------------------
http://localhost:8080/petclinic/addPet.htm?ownerId=10

GET /petclinic/addPet.htm?ownerId=10 HTTP/1.1
Host: localhost:8080
User-Agent: Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.8.1.4) Gecko/20070515 Firefox/2.0.0.4
Accept: text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.7
Keep-Alive: 300
Connection: keep-alive
Referer: http://localhost:8080/petclinic/owner.htm?ownerId=10
Cookie: JSESSIONID=13D2E0CB63897F4EDB56639E46D2BBD8
X-lori-time-1: 1182364356265

HTTP/1.x 200 OK
Server: Apache-Coyote/1.1
Pragma: No-cache
Expires: Thu, 01 Jan 1970 00:00:00 GMT
Cache-Control: no-cache, no-store
Content-Type: text/html;charset=ISO-8859-1
Content-Language: en-US
Content-Length: 2174
Date: Wed, 20 Jun 2007 18:32:57 GMT
----------------------------------------------------------
http://localhost:8080/petclinic/addPet.htm?ownerId=10

POST /petclinic/addPet.htm?ownerId=10 HTTP/1.1
Host: localhost:8080
User-Agent: Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.8.1.4) Gecko/20070515 Firefox/2.0.0.4
Accept: text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.7
Keep-Alive: 300
Connection: keep-alive
Referer: http://localhost:8080/petclinic/addPet.htm?ownerId=10
Cookie: JSESSIONID=13D2E0CB63897F4EDB56639E46D2BBD8
X-lori-time-1: 1182364402968
Content-Type: application/x-www-form-urlencoded
Content-Length: 40
name=Noddy&birthDate=1000-11-11&typeId=5
HTTP/1.x 302 Moved Temporarily
Server: Apache-Coyote/1.1
Pragma: No-cache
Expires: Thu, 01 Jan 1970 00:00:00 GMT
Cache-Control: no-cache, no-store
Location: http://localhost:8080/petclinic/owner.htm?ownerId=10
Content-Language: en-US
Content-Length: 0
Date: Wed, 20 Jun 2007 18:33:23 GMT

addPet.htmにはETagを認識するような設定は一切していませんので、ヘッダはセットされていません。ここで、再びIDが10のオーナーについて問い合わせます。今度はETagが1になっていることに注目してください。

----------------------------------------------------------
http://localhost:8080/petclinic/owner.htm?ownerId=10

GET /petclinic/owner.htm?ownerId=10 HTTP/1.1
Host: localhost:8080
User-Agent: Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.8.1.4) Gecko/20070515 Firefox/2.0.0.4
Accept: text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.7
Keep-Alive: 300
Connection: keep-alive
Referer: http://localhost:8080/petclinic/addPet.htm?ownerId=10
Cookie: JSESSIONID=13D2E0CB63897F4EDB56639E46D2BBD8
X-lori-time-1: 1182364403109
If-Modified-Since: Wed, 20 Jun 2007 18:29:03 GMT
If-None-Match: "-1"

HTTP/1.x 200 OK
Server: Apache-Coyote/1.1
Etag: "1"
Last-Modified: Wed, 20 Jun 2007 18:33:36 GMT
Content-Type: text/html;charset=ISO-8859-1
Content-Language: en-US
Content-Length: 4317
Date: Wed, 20 Jun 2007 18:33:45 GMT

最後にもう一度、IDが10のオーナーについて問い合わせます。今度はETagが効力を発揮し、304 Not Modifiedが返ってきます。

----------------------------------------------------------
http://localhost:8080/petclinic/owner.htm?ownerId=10

GET /petclinic/owner.htm?ownerId=10 HTTP/1.1
Host: localhost:8080
User-Agent: Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.8.1.4) Gecko/20070515 Firefox/2.0.0.4
Accept: text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.7
Keep-Alive: 300
Connection: keep-alive
Cookie: JSESSIONID=13D2E0CB63897F4EDB56639E46D2BBD8
X-lori-time-1: 1182364493500
If-Modified-Since: Wed, 20 Jun 2007 18:33:36 GMT
If-None-Match: "1"

HTTP/1.x 304 Not Modified
Server: Apache-Coyote/1.1
Date: Wed, 20 Jun 2007 18:34:55 GMT

HTTPのキャッシュ機能を生かすことで転送量と計算サイクルを節約することができました。

注意: このサンプルでは、例えばオブジェクトIDを単位とするようなより粒度の細かいオブジェクトの変更をトラッキングすることで、大きな効果をあげることができました。ですが、Viewによって利用される互いに関連をもった複数のオブジェクトが変更されるケースでは、効果はアプリケーションで使用されるデータモデルの全体的な設計に大きく依存します。今回の(ModifiedObjectTrackerの)実装は説明のためのものであり、更なる探求のためのアイデアを提供することを意図したものです。実運用での利用を意図したものではありません(一例を挙げると、クラスタでの利用には適さないでしょう)。更なる検討をするなら、一つの選択肢は、データベーストリガーを使って変更をトラッキングし、そのトリガーが書き込むテーブルにアクセスするインターセプタを作成することでしょう。


最後に

ETagを用いて転送量と処理時間を削減する二つのアプローチを見てきました。この記事が、皆さんの現在のそして今後のWEBプロジェクトのための思索のヒントを、そして裏側で使われているETagレスポンスヘッダに対する正しい理解を提供することができれば幸いです。

「もし私がより遠くを見ていたとしたら、それは私が巨人たちの肩に乗っていたからだ」というアイザック・ニュートンの有名な言葉があります。それと同様に、RESTスタイルのアプリケーションは、その中核において、簡素さを目的とした優れたソフトウェアデザインであり、車輪の再発明ではありません。WEBアプリケーションに使われるRESTスタイルアーキテクチャの主な材料がより一層利用され理解されることは、アプリケーション開発の主流にとってよい動きであると、私は信じています。そして、私の将来のプロジェクトにおいてそれが一層活用されることを心から願っています。


著者について

Gavin TerrillはBPS Incのチーフ・テクノロジ・オフィサーです。彼はエンタープライズJavaアプリケーションを専門とし、20年以上の間ソフトウェア開発に携わってきましたが、いまだに自分がもっているTRS-80を捨てようとしません。余暇にはセーリング、釣り、ギターを楽しみ、高級赤ワインをがぶ飲みします(順番は別にこのとおりでなくてもいいです)。


謝辞

同僚であるPatrick BourkeとErick Dovaleに感謝したいと思います。彼らはこの記事についてフィードバックを寄せてくれました。

コードとドキュメントはこちらからダウンロードできます(zipファイル)。

この記事に星をつける

おすすめ度
スタイル

BT