Apache JServプロトコル バージョン1.3

Dan Milstein, danmil@shore.net, 2000年12月

このドキュメントでは、Apache JServプロトコル バージョン1.3 (以後はajp13)について説明します。 現在、プロトコルの動作方法についてのドキュメントはまったく存在しません。 このドキュメントは、mod_jkの保守をおこなう人たちや、プロトコルを他に(たとえば、Jakarta 4.xに)移植したい人たちがより楽に作業できるようにするために、この問題を改善しようとする試みです。

私は誰?
私は、このプロトコルの設計者の一人ではありません - 私はGal Shachorがオリジナルの設計者だったと信じています。 このドキュメントのすべての情報は、私がTomcat 3.xのコードで見つけた実際の実装から得ています。 私は、このドキュメントは非常に便利だと思いますが、完全に正確かどうかについては保証できません。 また、私は設計方針の決定の理由についても知りません。 私ができることは、ある選択枝に対して可能性のある事実を提供することだけですが、それらは単なる私の推測にすぎません。 一般的に、Shachorが書いたC言語のコードは非常にきれいで、(ほとんどドキュメント化されていませんが)理解しやすいものです。 私がすでにJava言語のコードを書き直したので、比較的読みやすくなったと思います。

設計目標
Gal Shachorがjakarta-devメーリングリストに投稿した電子メールによると、mod_jk(とajp13)の最初の目標は、mod_jservajp12を以下のように拡張することでした(私は、WebサーバとServletコンテナ間の通信に関連した目標を取り込んだだけです)。

  1. 性能の向上 (特に速度)。

  2. SSLサポートを追加して、isSecure()getScheme()が機能的に正しく動作するようにします。 Servletで、リクエスト属性として、クライアントの認証と暗号アルゴリズムが利用できるようになります。

概要
ajp13プロトコルは、パケット指向です。 より読みやすいプレーンテキスト形式ではなく、たぶん性能向上のためにバイナリ形式を選択しています。 Webサーバは、ServletコンテナとTCPコネクション上で通信します。 ソケット作成の無駄な過程を削減するために、WebサーバはServletコンテナに対して永続的なTCPコネクションを保持して、複数のリクエスト/レスポンスサイクルの間コネクションを再利用しようとします。

いったんある特定のリクエストにコネクションを割り当てたら、リクエスト処理サイクルが終了するまでは他に使用することはありません。 つまり、リクエストは、コネクション上で多重化されることはありません。 これによって、一度に複数のコネクションをオープンできるにもかかわらず、コネクションのどちらかをより単純なコードにすることができます。

いったんWebサーバがServletコンテナに対するコネクションをオープンしたら、コネクションは以下の状態のどれかの状態になります。

いったんコネクションがある特定のリクエストを処理するために割り当てられたら、基本的なリクエスト情報 (たとえば、HTTPヘッダなど)は、コネクション上を高度に圧縮された形式(たとえば、共通の文字列は整数でエンコードされます)で送信されます。 この詳しいフォーマットを、この後のリクエストパケット構造で説明します。 リクエストに対してボディ部が存在する場合(content-length > 0)には、すぐ後に別のパケットで送信されます。

この時点では、たぶんServletコンテナはリクエスト処理を開始する用意ができています。 その場合には、以下のメッセージをWebサーバに返信することができます。

各メッセージは、異なるフォーマットのデータパケットで実現します。 詳しくは、以下で述べるレスポンスパケット構造を参照してください。



基本パケット構造

このプロトコルは、XDRの特徴を少し受け継いでいますが、多くの点で異なっています(たとえば、4バイトのアライメントがないことです)。

バイトオーダー : 私はバイトのエンディアンについてはよく知りません。 私は、リトルエンディアンだと思いますが、それはXDRがそうだからです。 さらに、(C言語側のコードがそうであるように)私はsys/socketライブラリが自動的に処理してくれると予想しています。 socketコールについて誰かもっと詳しく知っている人が参加してくれればすばらしいと思います。

このプロトコルには、byte, boolean, integer, stringという4つのデータ型があります。

Byte
1バイトの値です。

Boolean
1バイトの値であり、1 = true, 0 = falseです。 他の0以外の値をtrueに使用した場合には(すなわち、C言語風です)、ある環境では正しく動作しても、別の環境では正しく動作しないかもしれません。

Integer
0から2^16 (32768)までの範囲の数値です。 2バイトを上位バイトを先にして格納します。

String
可変サイズの文字列です (長さは2^16に制限されています)。 先頭の2バイトの長さの直後に、文字列(末尾に'\0'を含んでいます)をエンコードします。 ただし、エンコード長は、strlenのように末尾の'\0'を含まないことに注意してください。 これはJava言語側では少々混乱の元になっていて、これらのターミネータを読み飛ばすために、一見奇妙に見えるインクリメント演算子がコード中にちらばっています。 Servletコンテナが返信する文字列を読む時には、'\0'文字で終了しておけば、C言語のコードで一つのバッファに書き込んで、その参照を渡すことができるので、コピーが不要になりますから、私はこれはC言語コードをより効率的にするためにおこなったと信じています。 '\0'がない場合には、C言語のコードでは、C言語の文字列の仕様に従うために、一度コピーしなければいけません。

パケットサイズ
多くのコードによると、最大パケットサイズは8 * 1024 バイト (8K) です。 パケットの実際の長さは、ヘッダ部にエンコードされます。

パケットのヘッダ
サーバからコンテナに送信されるパケットは、0x1234で開始します。 コンテナからサーバに送られるパケットは、ABで開始します (つまり、ASCIIコードのAの直後にASCIIコードのBがきます)。 この最初の2バイトの直後に、送信データの長さの整数がきます(この前に説明したようにエンコードされています)。 つまり、論理的な最大データサイズは2^16ですが、実際の最大値は8Kに制限されています。

パケットフォーマット (サーバ->コンテナ)
バイト 0 1 2 3 4...(n+3)
内容 0x12 0x34 データ長 (n) データ

パケットフォーマット (コンテナ->サーバ)
バイト 0 1 2 3 4...(n+3)
内容 A B データ長 (n) データ

大部分のパケットでは、送信データの最初の1バイトにメッセージのタイプがエンコードされています。 この例外は、サーバからコンテナに送信されるリクエスト内容のパケットです - これらは標準パケットヘッダ (0x1234の後にパケット長)を付加して送信されますが、その直後にプレフィクスコードはありません(これは私は間違いのように思います)。 Webサーバは、以下のようなメッセージをServletコンテナに送信することができます。

コード パケットのタイプ 意味
2 Forward Request その直後のデータを用いてリクエスト処理サイクルを開始します。
7 Shutdown Webサーバが、コンテナに自分自身を停止させるように依頼します。

Servletコンテナは、Webサーバに以下のようなメッセージのタイプを送信することができます。

コード パケットのタイプ 意味
3 Send Body Chunk ServletコンテナからWebサーバにメッセージボディのチャンクを送信します(そして、たぶんブラウザに対して送信されます)。
4 Send Headers ServletコンテナからWebサーバに、レスポンスヘッダを送信します(そして、たぶんブラウザに対して送信されます)。
5 End Response レスポンス(さらに、リクエスト処理サイクル)の最後をマークします。
6 Get Body Chunk まだリクエストがすべて転送されていない場合には、さらにデータを要求します。

上記のメッセージは、それぞれ別の内部構造を持っていますので、この後に説明します。



リクエストパケット構造

サーバからコンテナに送信されるタイプ"Forward Request"のメッセージは、以下の通りです。

AJP13_FORWARD_REQUEST :=
    prefix_code      2
    method           (byte)
    protocol         (string)
    req_uri          (string)
    remote_addr      (string)
    remote_host      (string)
    server_name      (string)
    server_port      (integer)
    is_ssl           (boolean)
    num_headers      (integer)
    request_headers *(req_header_name req_header_value)

    ?context       (byte string)
    ?servlet_path  (byte string)
    ?remote_user   (byte string)
    ?auth_type     (byte string)
    ?query_string  (byte string)
    ?jvm_route     (byte string)
    ?ssl_cert      (byte string)
    ?ssl_cipher    (byte string)
    ?ssl_session   (byte string)

    ?attributes   *(attribute_name attribute_value)
    request_terminator (byte)

req_header_name := 
    sc_req_header_name | (string)  [この解析方法については、この後を参照してください]

sc_req_header_name := 0xA0 (byte)

req_header_value := (string)

attribute_name := (string)

attribute_value := (string)

request_terminator := 0xFF
だからといって、重要なヘッダがすべてあるわけではありませんが、"content-olength"はコンテナが別のパケットをすぐに要求するかどうかを決定します。

上記の詳細は以下の通りです。

request_prefix
すべてのリクエストで、この値は2になります。 詳しくは上記のプレフィックスコードを参照してください。

method
HTTPメソッドを、以下のように1バイトにエンコードしています。

OPTIONS      1
GET          2
HEAD         3
POST         4
PUT          5
DELETE       6
TRACE        7
PROPFIND     8
PROPPATCH    9
MKCOL       10
COPY        11
MOVE        12
LOCK        13
UNLOCK      14
ACL         15
  

protocol, req_uri, remote_addr, remote_host, server_name, server_port, is_ssl
これらについては、名前を見れば簡単にわかるでしょう。 これらのすべてが必要で、リクエストごとに送信されます。

ヘッダ
最初に、ヘッダの数がエンコードされます。 次に、ヘッダ名と値の組の集合が続きます。 一般的なヘッダ名は、容量を節約するために整数としてエンコードします。 ヘッダ名が基本ヘッダのリストにない場合には、普通に(最初に長さがついた文字列として)エンコードされます。 一般的なヘッダのリストとそのコードは、以下に示します(大文字・小文字を区別します)。
accept               0xA001
accept-charset       0xA002
accept-encoding      0xA003
accept-language      0xA004
authorization        0xA005
connection           0xA006
content-type         0xA007
content-length       0xA008
cookie               0xA009    
cookie2              0xA00A
host                 0xA00B
pragma               0xA00C
referer              0xA00D
user-agent           0xA00E
  
これを読み込むJava言語のコードでは、まず最初の2バイトの整数を読み込んで、 MSB (Most Significant Byte)が'0xA0'の場合には、2番目のバイトをヘッダ名の配列に対するインデックスである整数と見なします。 最初の1バイトが'0xA0'でない場合には、2バイトの整数が文字列の長さを表していると見なして、それを読み込みます。

これは、0x9999 (==0xA000 - 1)より長いヘッダ名が存在しないことを仮定すれば動作しますが、これはやや独断的ですが、きわめて妥当でしょう。(もし、あなたが私のようにcookieの仕様と、どのくらいの長さのヘッダを得ることができるかについて考え始めたとしても、恐れることはありません - というのは、これはヘッダの名前の制限であって、ヘッダのの制限ではないからです。 まともに管理されていない巨大なヘッダ名についてHTTP仕様が定義することは、とてもありえません。)

注意: content-lengthヘッダは非常に重要です。 このヘッダが存在して、0以外の値をとる場合には、コンテナはリクエストが(たとえば、POSTリクエストのように)ボディ部を持っていて、ボディ部を得るために個別のパケットをインプットストリームから読み込むことを仮定しなければいけません。

必須ではない情報
?が先頭についた属性(例, ?context)のリストのすべては必須ではありません。 それぞれについて、属性のタイプを示す1バイトコードが定義されていて、文字列をこの値に変換します。 これらのヘッダは、(C言語のコードは常に以下の順序で送信するのですが)任意の順序で送信できます。 必須ではない属性のリストの最後を知らせるために、特別な終了コードを送信します。 バイトコードのリストは以下の通りです。
context            1 [現在はまだ実装されていません]
servlet_path       2 [現在はまだ実装されていません]
remote_user        3
auth_type          4
query_string       5
jvm_route          6
ssl_cert           7
ssl_cipher         8
ssl_session        9

req_attribute      10 

terminator         0xFF
   
contextservlet_pathは、現在はC言語のコードでは設定されませんが、Java言語のコードの大部分では、これらのフィールドが送信されても完全に無視されます (そして、これらのコードの後に文字列が送信された場合には、実際に失敗します)。 私はこれがバグなのか、未実装の仕様なのか、それとも単に昔のコードの痕跡なのかは知りませんが、コネクションの両側で実装されていません。

remote_userauth_typeは、どうもHTTPレベルの認証を対応していて、リモートのユーザのユーザ名とその本人確認をおこなうために使用した認証のタイプ(例、Basicダイジェスト)のようです。 私はパスワードも一緒に送信されるのかはよく知りませんし、HTTP認証についてはまったく知りません。

query_stringssl_certssl_cipherssl_sessionは、HTTPとHTTPSの相当する部分に対応しているようです。

jvm_routeは、私が理解している限りでは、sticky sessionをサポートするために - つまり複数の付加分散サーバが存在する場合に、ユーザのセッションとある特定のTomcatインスタンスを関連付けるために使用しているようです。 私は詳しいことについては知りません。

この基本属性のリスト以外にも、他の多くの属性をreq_attributeコード (10)を用いて送信します。 属性名と値を表す文字列のペアは、このコードの後に直接送信されます。 環境変数は、このメソッドを用いて渡します。

最後に、すべての属性を送信した後に、属性のターミネータとして、0xFFを送信します。 これは、属性のリストの終了とともに、リクエストパケット全体の終了を知らせます。

サーバは、shutdownパケットも送信することができます。 基本的なセキュリティを保持するために、実際にはコンテナはリクエストが運用しているマシンと同じマシンから送信されてくる場合にのみ、終了します。



レスポンスパケット構造

コンテナがサーバに返信することができるメッセージです。

AJP13_SEND_BODY_CHUNK := 
  prefix_code   3
  chunk_length  (integer)
  chunk        *(byte)


AJP13_SEND_HEADERS :=
  prefix_code       4
  http_status_code  (integer)
  http_status_msg   (string)
  num_headers       (integer)
  response_headers *(res_header_name header_value)

res_header_name := 
    sc_res_header_name | (string)   [この解析方法については、この後を参照してください]

sc_res_header_name := 0xA0 (byte)

header_value := (string)

AJP13_END_RESPONSE :=
  prefix_code       5
  reuse             (boolean)


AJP13_GET_BODY_CHUNK :=
  prefix_code       6
  requested_length  (integer)
詳細は以下の通りです。
Send Body Chunk
チャンクは基本的にバイナリデータで、ブラウザに直接返送されます。

Send Headers
ステータスコードとメッセージは、通常のHTTPの定義に従います (例, "200"と"OK")。 レスポンスヘッダ名は、リクエストヘッダ名と同じ方法でエンコードします。 コードと文字列を区別する方法についての詳しい説明は、上記を参照してください。 共通のヘッダのコードは、以下の通りです。
Content-Type         0xA001
Content-Language     0xA002
Content-Length       0xA003 
Date                 0xA004
Last-Modified        0xA005
Location             0xA006
Set-Cookie           0xA007
Set-Cookie2          0xA008
Servlet-Engine       0xA009
Status               0xA00A
WWW-Authenticate     0xA00B
コードや文字列のヘッダ名の後に、ヘッダの値が直接エンコードされます。

End Response
このリクエスト処理サイクルの終了を知らせます。 reuseフラグがtrue (==1)の場合には、このTCPコネクションを新しく到着するリクエストを処理するために使用できます。 reuseがfalse (実際のC言語のコードでは1以外の値です)の場合には、このコネクションをクローズしなければいけません。

Get Body Chunk
(リクエストのボディ部が非常に大きくて、送信された最初のパケット内に収まらなかった場合に)コンテナがリクエストにより多くのデータを要求します。 サーバは、ボディ部のパケットを、request_lengthと、最大送信ボディサイズ (XXX)、そしてリクエストボディから送信されて実際に残っている値の中の最小値に相当する両のデータを返信します。

ボディ部中にそれ以上データがない場合(すなわち、Servletコンテナがボディ部の最後を越えて読み込もうとした場合)には、サーバは送信データ長が0の"空の"パケットを返信します。


私の疑問点

リクエストヘッダの合計サイズが最大パケットサイズを越えた時に、何が起るでしょうか? 8K以上の場合には、リクエストヘッダの二番目のパケットを送信するための対策が何もされていません(確かめていませんが、私はレスポンスヘッダはうまく処理できると思います)。 私は、リクエストヘッダの初期集合に入っている8K以上のデータを取得する方法が存在するかどうかは知りませんが、たぶん存在するでしょう(長いSSL情報を持った長いCookieと多量の環境変数を組み合わせれば、簡単に8Kを越えるでしょう)。 私は、このような場合にヘッダが送信できるかを試す前に、コネクタが落ちるのではないかと思いますが、確かめたわけではありません。

認証についてはどうなのでしょうか? Webサーバとコンテナの間の認証があるようには見えません。 これについては、私は潜在的な危険を感じてます。