java.net.URIで扱えるホスト名はRFC 2396 (RFC 3986じゃない・・)

今日ちょっとハマってURLについて少しだけ調べたんですが、`java.net.URI`はRFC 3986ではなくRFC 2396準拠なんですね。そして、ハマった原因はRFC 2396とRFC 3986の違いにあったのです :weary:

* http://www.ietf.org/rfc/rfc2396.txt
* http://www.ietf.org/rfc/rfc3986.txt
* https://docs.oracle.com/javase/jp/8/docs/api/java/net/URI.html

# 環境

* Java SE 8
* Spring Boot 1.3.5.RELEASE (Tomcat 8.0)
* Nginx 1.9.?


# 違いは・・・

細かい違いはRFCをみていただくとして・・・わたしがハマったのはホスト名のところです。
RFC 2396では、

```
host          = hostname | IPv4address
# 以降、IPアドレス系の定義は省略します
hostname      = *( domainlabel "." ) toplabel [ "." ]
domainlabel   = alphanum | alphanum *( alphanum | "-" ) alphanum
toplabel      = alpha | alpha *( alphanum | "-" ) alphanum
```

（「半角英数字」「"-"」「"."」のみ）だったのが、RFC 3986では、

```
host        = IP-literal / IPv4address / reg-name
# 以降、IPアドレス系の定義は省略します
reg-name    = *( unreserved / pct-encoded / sub-delims )
unreserved  = ALPHA / DIGIT / "-" / "." / "_" / "~"
pct-encoded = "%" HEXDIG HEXDIG
sub-delims  = "!" / "$" / "&" / "'" / "(" / ")"
                  / "*" / "+" / "," / ";" / "="
```

になっています。だいぶ扱える文字が増えています。

# ハマったこと・・・

いま携わっている案件では、Webサーバが「Nginx」、アプリがSpring Boot(組み込みTomcat)という構成になっていて、NginxからSpring Bootにリクエストをプロキシする際に、upstreamディレクティブを使っていました。（Nginxつかったことなくて、実は今日初めて知ったのですが・・・ :sweat_smile: ）
設定的には・・・以下のような感じになってました。（関係ないところは省いてます+実際はlocalhostじゃありません）

```text:nginx.conf
http {
    upstream spring_boot {
        server localhost:8080;
    }
    server {
        listen       18080;
        server_name  localhost;
        location /spring-boot/ {
            proxy_pass http://spring_boot/;
        }
    }
}
```

この状態でNginxにリクエスト(`http://localhost:18080/spring-boot/`)を送ると、Spring Boot側にプロキシされるリクエスト(`http://localhost:8080/`)の**Hostヘッダーは「spring_boot」（upstream名）になります**。Servlet APIの`HttpServletRequest#getRequestURL`メソッドは、Hostヘッダーを参照してURLを返却するため、このケースだと「`http://spring_boot`」(RFC 2396でホスト名として扱えない文字「_」がある状態・・・)になるわけです。

で、実際にハマったところは・・・
Spring Frameworkは`org.springframework.web.util.UriComponentsBuilder`というURLを扱うユーティリティを提供しており、そのクラスの`fromHttpRequest`メソッドの処理中で`HttpServletRequest#getRequestURL`メソッドから取得したURL文字列を指定して`java.net.URI`のインスタンスを作成しています。結果としてどうなるかというと、`URI#getHost()`から`null`が返却されるため、ホスト名を取得することができません。

# 原因は・・・

Java標準の`URI`クラスで扱えない文字が含まれるURLを渡していることが直接的な原因ですが、おおもとの原因はNginx側の設定でした。ただし・・・Spring Framework側に全く問題がないかというと、必ずしもそうではないかも・・・。同じクラスにある「URI文字列を渡すメソッド(`UriComponentsBuilder#fromUriString(String) or fromHttpUrl()`)」では、RFC 3986をサポートしているようなので・・・・。＋Servlet API自体は`java.net.URI`に依存していないので、Spring Frameworkの実装がこのエラーを生んでいる気もするな。。。

# 対策は・・・

まず「**ホスト名はRFC 2396に準拠する！！！**」 これが一番大事。upstream名もRFC 2396に準拠しておくのが無難。

そして・・・
Hostヘッダがupstreamの名前になるのは基本ダメだと思われるので、Nginxにアクセスする際に指定するホスト名を引き継ぐようにするのがよさげな気がしています。HostヘッダーにNginxにアクセスする際に指定したホスト名を引き継ぐ場合は、以下のように`proxy_set_header`ディレクティブを使います。

```
location /spring-boot/ {
    proxy_pass http://spring-boot/;
    proxy_set_header host $host;
}
```

なお、`UriComponentsBuilder#fromHttpRequest(HttpRequest)`は、「Forwarded」や「X-Forwarded-Host」「X-Forwarded-Port」「X-Forwarded-Proto」ヘッダがあるとそちらからスキーマ、ホスト名、ポート番号を取得する仕組みになっているので、これらのヘッダを活用する方法もあります。

# 既知の発生条件は・・・

この事象は必ず発生するわけではありません。既知の発生条件は以下のとおりです。

* リクエストにOriginヘッダーがあるとCORS(Cross-Origin Resource Sharing)関連の処理が動き、その処理の中で`UriComponentsBuilder#fromHttpRequest`メソッドを使用しており、後続処理でホスト名を参照しているところで`NullpointerException`が発生します。

```text:スタックトレース（抜粋）
...
2016-05-25 04:46:48.890 ERROR 84097 --- [io-8080-exec-10] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is java.lang.NullPointerException] with root cause

java.lang.NullPointerException: null
	at org.springframework.web.util.WebUtils.isSameOrigin(WebUtils.java:816) ~[spring-web-4.2.6.RELEASE.jar:4.2.6.RELEASE]
	at org.springframework.web.cors.DefaultCorsProcessor.processRequest(DefaultCorsProcessor.java:76) ~[spring-web-4.2.6.RELEASE.jar:4.2.6.RELEASE]
	at org.springframework.web.servlet.handler.AbstractHandlerMapping$CorsInterceptor.preHandle(AbstractHandlerMapping.java:503) ~[spring-webmvc-4.2.6.RELEASE.jar:4.2.6.RELEASE]
	at org.springframework.web.servlet.HandlerExecutionChain.applyPreHandle(HandlerExecutionChain.java:134) ~[spring-webmvc-4.2.6.RELEASE.jar:4.2.6.RELEASE]
	at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:956) ~[spring-webmvc-4.2.6.RELEASE.jar:4.2.6.RELEASE]
	at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:895) ~[spring-webmvc-4.2.6.RELEASE.jar:4.2.6.RELEASE]
	at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:967) ~[spring-webmvc-4.2.6.RELEASE.jar:4.2.6.RELEASE]
	at org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:858) ~[spring-webmvc-4.2.6.RELEASE.jar:4.2.6.RELEASE]
	at javax.servlet.http.HttpServlet.service(HttpServlet.java:622) ~[tomcat-embed-core-8.0.33.jar:8.0.33]
	at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:843) ~[spring-webmvc-4.2.6.RELEASE.jar:4.2.6.RELEASE]
...
```

# まとめ

特にまとめることはありませんが、Nginx + Spring Boot(Spring MVC)を組み合わせる場合は、今回投稿した内容を頭の片隅にいれておくとよいかもしれません。

# 補足

**2016/5/26**
[CORS(Cross-Origin Resource Sharing)関連の処理でエラーになる件](http://qiita.com/kazuki43zoo/items/43abfb72ddf0f1096fa7)は、Spring Frameworkのバグでした・・・ :sweat_smile: