ProxyServletでリバースプロキシを作った話

この記事は[リンク情報システム](https://www.lis.co.jp/)の2018年[アドベントカレンダー](https://qiita.com/advent-calendar/2018/lis)のリレー記事です。
engineer.hanzomon のグループメンバによってリレーされます。
（リンク情報システムのFacebookは[こちら](https://ja-jp.facebook.com/lis.co.jp/)から）
#はじめに
アドベントカレンダー13日目担当の@shinevillageです。主にJavaによるWebアプリの開発を担当しています。
今回は、とあるお仕事を担当した際、テスト用ツールとして、Javaでリバースプロキシを作った話をします。
#なぜ作ったのか？
「リバースプロキシ」というと、どちらかというと、インフラ系の方々の方がなじみ深いイメージのあるものですが、Web開発者にとっても、時々欲しくなる場合があります。（[「そもそもリバースプロキシって何？」という方はこちら](http://www.atmarkit.co.jp/ait/articles/1608/25/news034.html)）

私の場合は、以下の構成のアプリのテストを行うにあたり、クロスドメイン問題が勃発したため、欲しくなったという経緯です。
![qiita1.png](https://qiita-image-store.s3.amazonaws.com/0/317778/c3314bd4-2536-d80c-dfce-3c377688202a.png)

リバースプロキシの構築には、ApacheやNginxを使うのが最もポピュラーであるかと思われますが、私の場合、悲しいことに**「フリーソフトのexeインストールは禁止。自分で書いたスクリプト等はまあ許してやろう」**的な環境でお仕事をしており、上記のソフトは使えなかったため、アプリを書いている言語であるJavaで、テスト用のリバースプロキシを書きました。
#使ったもの
- Java 1.8　　　　　　　　　　　　（選定理由：お仕事で使っているため）
- [Smiley's HTTP Proxy Servlet 1.10](https://github.com/mitre/HTTP-Proxy-Servlet)　（選定理由：使い勝手が良いため）
- [Spring Boot 1.5.14](https://spring.io/projects/spring-boot)　　　　　　　（選定理由：実行可能warがサクッと作れるため）

#構成
Smiley's HTTP Proxy Servletでは、プロキシサーバーの役割を果たすサーブレット（ProxyServlet）を提供します。
そのうち **URITemplateProxyServlet** を使うと、クエリ文字列で転送先（ホスト名、ポート番号、コンテキストパス）を動的に制御することができます。
リクエストされたURLに応じたクエリ文字列を、サーブレットフィルタで設定してやることによって、リバースプロキシの動作を再現します。
![seq.png](https://qiita-image-store.s3.amazonaws.com/0/317778/09f92c37-95c7-cf2b-c3ed-7d8ee198a146.png)


#実装
Mavenを使ってビルドを行います。

```xml:pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>riverse-proxy</groupId>
  <artifactId>riverse-proxy</artifactId>
  <version>1.0</version>
  <packaging>war</packaging>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>1.5.14.RELEASE</version>
    </parent>
    <properties>
        <project.build.sourceEncoding>Windows-31j</project.build.sourceEncoding>
        <project.reporting.outputEncoding>Windows-31j</project.reporting.outputEncoding>
        <maven.compile.source>1.8</maven.compile.source>
        <maven.compile.target>1.8</maven.compile.target>
        <java.version>1.8</java.version>
        <spring.version>4.3.18.RELEASE</spring.version>
    </properties>
    <dependencies>
        <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.mitre.dsmiley.httpproxy</groupId>
            <artifactId>smiley-http-proxy-servlet</artifactId>
            <version>1.10</version>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>
```

次はメインクラス。ここでサーブレットフィルタ、ProxyServletの登録を行います。

```Java:Main.java

@SpringBootApplication
public class Main extends SpringBootServletInitializer implements WebApplicationInitializer {

  /**
   * 単独起動時のエントリーポイント.
   */
  public static void main(String[] args) throws Throwable {
    SpringApplicationBuilder builder = new SpringApplicationBuilder(Main.class);
    builder.run();
  }
  
  /**
   * サーブレットコンテナ起動時のエントリーポイント.
   */
  @Override
  protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
    return application.sources(Main.class);
  }

  /**
   * {@see org.springframework.web.filter.HiddenHttpMethodFilter} の無効化.
   * 
   * URITemplateProxyServletは、{@link ServletRequest#getInputStream} を使うため、
   * フィルタ側でリクエストパラメータへのアクセスが行われるとプロクキサーブレット側で、
   * リクエストパラメータの取得が出来なくなる。
   * そのため、本ツールでは、HiddenHttpMethodFilterで提供する機能は不用のためフィルタを無効にする。
   */
  @Bean
  public FilterRegistrationBean hiddenHttpMethodFilterRegistration(HiddenHttpMethodFilter filter) {
    FilterRegistrationBean registration = new FilterRegistrationBean(filter);
    registration.setFilter(filter);
    registration.setEnabled(false);
    return registration;
  }

  /**
   * {@see org.springframework.web.filter.HttpPutFormContentFilter} の無効化.
   * 
   * URITemplateProxyServletは、{@link ServletRequest#getInputStream} を使うため、
   * フィルタ側でリクエストパラメータへのアクセスが行われるとプロキシサーブレット側で、
   * リクエストパラメータの取得が出来なくなる。
   * そのため、本ツールでは、HttpPutFormContentFilterで提供する機能は不用のためフィルタを無効にする。
   */
  @Bean
  public FilterRegistrationBean httpPutFormContentFilterRegistration(HttpPutFormContentFilter filter) {
    FilterRegistrationBean registration = new FilterRegistrationBean(filter);
    registration.setFilter(filter);
    registration.setEnabled(false);
    return registration;
  }
  
  /**
   * WebアプリA用サーブレットフィルタの登録.
   */
  @Bean
  public FilterRegistrationBean applicationARiverseProxyFilterRegistration() {
    Filter filter = new AAppRiverseProxyFilter();
    FilterRegistrationBean registration = new FilterRegistrationBean(filter);
    registration.setOrder(FilterRegistrationBean.REQUEST_WRAPPER_FILTER_MAX_ORDER);
    registration.setUrlPatterns(Arrays.asList("/webapp-a/*"));
    registration.setDispatcherTypes(EnumSet.of(DispatcherType.REQUEST));
    return registration;
  }

  /**
   * WebアプリB用サーブレットフィルタの登録.
   */
  @Bean
  public FilterRegistrationBean applicationBRiverseProxyFilterRegistration() {
    Filter filter = new BAppRiverseProxyFilter();
    FilterRegistrationBean registration = new FilterRegistrationBean(filter);
    registration.setOrder(FilterRegistrationBean.REQUEST_WRAPPER_FILTER_MAX_ORDER);
    registration.setUrlPatterns(Arrays.asList("/webapp-b/*"));
    registration.setDispatcherTypes(EnumSet.of(DispatcherType.REQUEST));
    return registration;
  }
  
  /**
   * プロキシサーブレットの登録.
   * @see https://github.com/mitre/HTTP-Proxy-Servlet
   */
  @Bean
  public ServletRegistrationBean riverseProxyServletRegistration() {
    HttpServlet servlet = new URITemplateProxyServlet();
    ServletRegistrationBean registration = new ServletRegistrationBean(servlet);
    registration.addInitParameter("preserveHost", "true");
    registration.addInitParameter("preserveCookies", "true");
    registration.addInitParameter("http.protocol.handle-redirects", "true");
    registration.addInitParameter("http.socket.timeout", "300000");
    registration.addInitParameter("http.read.timeout", "300000");
    registration.addInitParameter("targetUri", "http://{__proxyHost}/{__proxyContextRoot}");
    registration.setUrlMappings(Arrays.asList("/webapp-a/*", "/webapp-b/*"));
    return registration;
  }
}
```


次はサーブレットフィルタ。主なふるまいは抽象クラスとして定義し、サブクラス側で転送先に依存する処理を記載します。

```Java:AbstractRiverseProxyFilter.java
/**
 * リバースプロキシフィルタ.
 * {@see org.mitre.dsmiley.httpproxy.URITemplateProxyServlet} を
 * リバースプロキシとして動作させるため、リクエスト、レスポンスのカスタマイズを行います。
 */
abstract public class AbstractRiverseProxyFilter implements Filter {
  
  /**
   * 転送先ホストの取得
   */
  abstract protected String getTransfarHost();

  /**
   * 転送先コンテキストルートの取得
   */
  abstract protected String getTransfarContextRoot();
  
  @Override
  public void doFilter(ServletRequest _request, ServletResponse _response,
            FilterChain filterChain) throws IOException, ServletException {

    HttpServletRequest  request  = (HttpServletRequest)  _request;

    // {@see org.mitre.dsmiley.httpproxy.URITemplateProxyServlet} に
    // 転送先を指定するパラメータを、クエリ文字列上へ追記
    StringBuilder routingQuery = new StringBuilder();
    String query = request.getQueryString();
    if (!StringUtils.isEmpty(query)) {
      routingQuery.append(query);
      routingQuery.append("&");
    }
    routingQuery.append(String.format("__proxyHost=%s&__proxyContextRoot=%s", 
        this.getTransfarHost(), this.getTransfarContextRoot()));

    // サーブレットコンテナのデフォルトエンコーディングが設定されることを抑止する
    _response.setCharacterEncoding(null);

    // リクエストオブジェクトにラッパーをかぶせ、プロキシサーブレットへ引き渡す
    RequestRewriteWrapper wrapRequest 
       = new RequestRewriteWrapper(request, routingQuery.toString());
    filterChain.doFilter(wrapRequest, _response);
  }

  @Override
  public void init(FilterConfig filterConfig) throws ServletException {}

  @Override
  public void destroy() {}

  /**
   * クエリ文字列を書き換えるためのリクエストラッパー
   */
  private static class RequestRewriteWrapper extends HttpServletRequestWrapper {

    private final String queryString;

    public RequestRewriteWrapper(HttpServletRequest request, String query) {
      super(request);
      this.queryString = query;
    }

    /**
     * {@link HttpServletRequest#getQueryString()} のラッパー.
     * 転送先が追記されたクエリ文字列を返す。
     */
    @Override
    public String getQueryString() {
      return this.queryString;
    }
  }
}
```

```Java:AAppRiverseProxyFilter.java
/**
 * WebアプリA用リバースプロキシフィルタ.
 */
public class AAppRiverseProxyFilter extends AbstractRiverseProxyFilter {

  @Override
  protected String getTransfarHost() {
    return "xxx.xxx.xxx.1";
  }

  @Override
  protected String getTransfarContextRoot() {
    return "webapp-a";
  }
}
```

```Java:BAppRiverseProxyFilter.java
/**
 * WebアプリB用リバースプロキシフィルタ.
 */
public class BAppRiverseProxyFilter extends AbstractRiverseProxyFilter {

  @Override
  protected String getTransfarHost() {
    return "xxx.xxx.xxx.2";
  }

  @Override
  protected String getTransfarContextRoot() {
    return "webapp-b";
  }
}
```

#実行
以下のコマンドで起動します。

```
$ mvn spring-boot:run
```

ツール起動後、ブラウザから「http\://localhost:8080/webapp-a/」でWebアプリAへ、
「http\://localhost:8080/webapp-b/」でWebアプリBへアクセス出来るようになります。

#最後に
本記事の内容は、作ったツールのうち基本的な部分のみ抜粋して記載してます。
実際にツールを作る場合、転送先の情報は上記サンプルのような、ソースコードに書くのではなく、
```@Value```アノテーション等の仕組みを使い、取ってくるようにすると良いでしょう。

ネットを漁っても、リバースプロキシをJavaで書く記事があまりなかったので、今回書いてみました。
本記事のリバースプロキシは、あくまで**「テスト用のツール」**であるため、本番環境には、きちんとした製品を使いましょう。<(_ _)>
***
明日は14日目。@modestさんの記事となります。