Spring boot で作るAPIと React のビルドを共存させたい

Spring boot では Webシステムをmain起動のスタンドアロンJavaプログラムのように起動して試せる点が便利である。ところでこうしたサーバーサイドのWeb APIを作るようなケースで、Reactのようなコンパイルを伴うWebクライアントと連携して動作するようなものを開発するとき、どのようなプロジェクトのディレクトリ構成でどのようなビルド、どのような実行をすると便利にやれるのだろうか?

IDEの有償プラグインがこうした開発を統合してくれるのかもしれないが(試用して回ったわけではないのでよくわからない)ここではギョームで普通に用いられているEclpise IDEVisual Studio Codeの組み合わせで考えてみたい。(本稿ではIDEは主題ではないので、任意のIDEで検討してもらいたい)

Spring の フォルダ

SpringのQuick Start に従い、デモプロジェクトを作成し、Eclipse側でMavenプロジェクトとしてインポートすると以下のような構成になっている。

f:id:Nagise:20200627145159p:plain
Spring boot の構成

ここで、DemoApplicationを実行するとSpring bootが内臓のTomcatを起動させ、 src/main/webapp 以下(図のオレンジの矢印の部分)が http://localhost:8080/ 以下のパスでアクセスできるようになる。
加えて、@GetMapping("/hello") のアノテーション定義に則って http://localhost:8080/hello にアクセスすると、そのアノテーションがついているhelloメソッドを呼び出し、その戻り値をhttpのレスポンスとして返すように動く。

React の フォルダ

create-react-app を使ってシンプルなReactのサンプルを作成すると、以下のような構成になっている。

f:id:Nagise:20200627145306p:plain
React の構成

npm run build でコンパイルすると、html や JavaScript が動く形で build ディレクトリに作成される(図のオレンジの矢印部分)。Webアプリケーションとしてサーバーに配置するのはこのbuild部分ということになる。

双方の都合

サーバーサイドのWeb APIと、コンパイルして生成されるWeb クライアントを複合して動かす場合に、上記のような2系統のビルドをどうにか両立させないといけない。

最終的にTomcatのようなWebサーバーに配備する場合であれば、ビルドスクリプトによってReactのビルドを動かし、生成物を Spring boot 側の webapp 以下の配備予定パスに複製、そののち、 Spring boot 側をwarファイルにパッケージングしてwarファイルをWebサーバーに配備、という手順を踏むことになるのだろう。しかし、開発段階からこれをやるのは面倒だし、開発時にはクライアント側もサーバー側も双方ともいじりながら開発することもあるから、両開発環境でビルドがしたい。(逆に、完全に分業がされているならば、この2系統ビルドで頭を悩まさなくていい)

この2系統ビルドとその実行を便利にしたい、というのが今回のテーマである。

方法はいろいろあるのだろうが、今回は Spring boot をハックして Tomcat の Resources Component機能を利用してデバッグ環境のTomcatがSpring boot の src/main/webapp ディレクトリと、Reactのbuildディレクトリの双方を参照するように設定してみたい。

Tomcatによるリソースの読み込み

あまり知られていないかもしれないが、Tomcatにはファイルリソースを読み込むにあたって、ファイルを読み替えることができる。

日本語で解説してくれている記事は少ないが、 webapps配下でないディレクトリにリソースを置く - DukeLab では例が挙げられている。
Tomcatがリソースを読み込む際に PreResources の設定があれば、先にそちらを読みに行き、そこにリソースがなければ通常のwebappフォルダ、そこにもなければ PostResources の設定の場所を読みに行く。これによって、設定ファイルなどをwarファイルの外に分離したりすることができる。

TomcatThe Resources Component というドキュメントに詳細が記載されている。
この機能を用いる際には注意点がある。Javaプログラム中で設定ファイル等にアクセスする際には java.io.Fileクラスなどを用いてはいけない。 javax.servlet.ServletContext の getResource() などのメソッドを用いてファイルリソースを取得することを徹底しなくてはならない。HTMLなどの静的ファイルにだけ用いる分にはこのあたりはあまり気にする必要はない。

この Resources Component機能の設定は古典的には Tomcat本体のserver.xmlを編集することになる。このserver.xmlは、Tomcatがwarファイルをどのように配備するかといった設定を記載するファイル。しかし、warファイル側でこのデフォルト設定みたいなのを書いておきたいみたいな要件があり、 warファイルの /META-INF/context.xml を用意しておくと、配備時にこの設定が生かされるようになった。

これを Spring boot の内蔵Tomcatに対してどう設定するかという話なのだが、/META-INF/context.xml を用意しても内蔵Tomcatには効かない。なにか方法はないかと探していたら
SpringBootで組み込みTomcatの設定 という記事が目についた。Spring boot では TomcatServletWebServerFactory内蔵Tomcatの設定を行えるらしい

しかし、APIドキュメントを探してみてもResources Componentの設定は行えなさそうだ。さて、どうする……?

WebResourceRoot

Spring boot の TomcatServletWebServerFactory のソースコードを読み込んでいると、org.apache.catalina.WebResourceRoot というTomcatのクラスの createWebResourceSet というメソッドを呼んでいる箇所が気になった。

	URL url = new URL(resource);
	String path = "/META-INF/resources";
	this.context.getResources().createWebResourceSet(ResourceSetType.RESOURCE_JAR, "/", url, path);

前後を読むとこれが直接の該当機能というわけではないのだが、この createWebResourceSet メソッドこそが求める Tomcat の Resources Component 機能であるようだ。WebResourceRoot は interface なので Tomcat のソースを辿り実装クラス StandardRoot を見つける。このソースを見るとこれが探していた機能であることを確信する。これを Spring boot で Tomcat を設定する TomcatServletWebServerFactory でうまく呼び出せれば……?

import org.apache.catalina.Container;
import org.apache.catalina.Host;
import org.apache.catalina.WebResourceRoot;
import org.apache.catalina.WebResourceRoot.ResourceSetType;
import org.apache.catalina.core.StandardContext;
import org.apache.catalina.startup.Tomcat;
import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory;
import org.springframework.boot.web.embedded.tomcat.TomcatWebServer;
import org.springframework.boot.web.server.WebServer;
import org.springframework.boot.web.servlet.ServletContextInitializer;
import org.springframework.boot.web.servlet.server.ServletWebServerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class TomcatConfigration {
    @Bean
    public ServletWebServerFactory serverSettings() {
        TomcatServletWebServerFactory factory = new MyTomcatServletWebServerFactory();
        return factory;
    }

    static class MyTomcatServletWebServerFactory extends TomcatServletWebServerFactory {
    	@Override
    	public WebServer getWebServer(ServletContextInitializer... initializers) {
    		TomcatWebServer ret = (TomcatWebServer) super.getWebServer(initializers);

    		Tomcat tomcat = ret.getTomcat();
    		Host host = tomcat.getHost();
    		Container[] findChildren = host.findChildren();
    		StandardContext context = (StandardContext) findChildren[0];
    		WebResourceRoot webResourceRoot = context.getResources();
    		webResourceRoot.createWebResourceSet(ResourceSetType.PRE,
    				"/", "C:\\work\\react\\demo\\react-demo\\build", null,  "/");
    		return ret;
    	}
    }
}

単純にWebResourceRootを得て素直にcreateWebResourceSet()を呼び出せなかったため工夫する必要があった。

TomcatServletWebServerFactory を継承したクラスをつくり、getWebServer()をオーバーライドしてsuper.getWebServer(initializers)で作成した TomcatWebServer オブジェクトから WebResourceRoot を引っ張り出し、createWebResourceSet() に react側のbuildディレクトリを指定するようにしてみた。

f:id:Nagise:20200627151041p:plain
Spring boot と React の共存


このように、Spring boot の動的なAPIと、React が Spring boot の内臓Tomcatによって同時させることができた。
URL がともに localhost:8080 となっている点に注目いただきたい。

まとめ

  • Spring boot の内蔵 Tomcat に設定を加えることで、外部のファイルリソースを組み合わせることができた
  • Reactのような別系統のビルドが必要な環境と組み合わせて開発する際に便利

よりよい工夫などあれば、トラックバックして頂けると嬉しい。

追記

Reactに限って言えば、React側のサーバーにproxyを指定してやることで、Spring boot側のAPIを同一ドメインで呼び出せるようにすることができるようだ。

React開発時には、APIサーバーとReactアプリサーバーを別にして、プロキシを使うというベスト・プラクティス

本来静的コンテンツであるところの HTML + JavaScript の動作を確認するためにWebサーバーを立てているわけだが(npm startで起動させているアレである)そこのProxyを通して本来の動的WebサーバーであるところのTomcatを呼び出している。僕がサーバーサイドエンジニアだからか、この方式は少し不思議な構成という気がする。


Vue.js でも同様のアプローチをとっているようだ。

[Vue]devServerでフロント開発中、別ポートで起動しているバックサーバーにリクエストを送る方法 - Qiita


本記事のアプローチはこれらとは逆のような感じになるが、React や Vue.js に限らず、汎用に用いることができる方式であるし、なんとなれば、React と Vue.js の混在にでも対応できる。(そんなことをしたいわけでもないが)

また、proxyがどのような通信方式をサポートしているかという点にも不安があって、例えば WebSocket を持ちいたAPIサーバーなどに対応できているのか?などなど、サーバーアプリ屋としてはサーバーをメインに据えたい気持ちがいろいろとある。

しかし、視点を変えて、分業体制とかでクライアントサイドを主体としている開発者側からはproxy方式の方が便利なのかもしれない。