はじめに
Spring Boot+Reactの構成を考えた際にThymeleafをごりごり書きたくなかったのでSSRできないかということでやってみました。
参考
以下のレポジトリとREADMEの記事を参考にしました。
https://github.com/dtanzer/react-graalvm-springboot
プロジェクト構成
Spring InitializrでJava8,Gradleの構成で作成したプロジェクトにフロントエンド用のReactのプロジェクトを相乗りさせています。 フロントエンド用のソースコードはclientディレクトリに配置しておりWebpackで生成したjsファイルはsrc/resources/static/js/pages配下に配置しSpring Bootで読み込みこみます。
.
├── build.gradle
├── client
│ ├── components
│ │ └── Navigation.jsx
│ ├── init.js
│ ├── on-server.js
│ └── pages
│ ├── index.jsx
│ └── list.jsx
├── gradle
│ └── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── package-lock.json
├── package.json
├── settings.gradle
├── src
│ └── main
│ ├── java
│ │ └── com
│ │ └── example
│ │ └── springreactssr
│ │ ├── AbstractController.java
│ │ ├── IndexController.java
│ │ ├── Item.java
│ │ ├── ItemApiController.java
│ │ ├── ItemApiService.java
│ │ ├── ItemServerApi.java
│ │ ├── ListController.java
│ │ ├── NewItem.java
│ │ ├── ServerApi.java
│ │ └── SpringReactSsrApplication.java
│ └── resources
│ ├── application.properties
│ ├── static
│ │ └── js
│ │ └── pages
│ │ ├── index.js
│ │ └── list.js
│ └── templates
│ └── frame.html
└── webpack.config.js
フロントエンドの構成
ひとまず最低限のWebpack構成で作成。とりあえずChromeで実行できればよいのでBabelではJSXの変換だけ行う。
MPA構成を考えているのでcreate-react-appは使用せずに作成しています。
package.json
{
...,
"scripts": {
"build": "webpack"
},
"devDependencies": {
"@babel/core": "^7.14.6",
"@babel/preset-react": "^7.14.5",
"babel-loader": "^8.2.2",
"webpack": "^5.44.0",
"webpack-cli": "^4.7.2"
},
"dependencies": {
"glob": "^7.1.7",
"react": "^17.0.2",
"react-dom": "^17.0.2"
}
}
webpack.config.js
const path = require('path')
const glob = require('glob')
module.exports = {
mode: 'production',
entry: () => {
const entries = {};
glob.sync('./client/pages/**/*.jsx').forEach(file => {
const name = file.replace('./client', '').replace('.jsx', '');
entries[name] = path.resolve(file);
});
return entries;
},
output: {
path: path.resolve('src/main/resources/static/js'),
filename: "[name].js",
globalObject: "this"
},
module: {
rules: [
{
test: /\.(js|jsx)$/,
use: [
{
loader: 'babel-loader',
options: {
presets: [
[
'@babel/preset-react',
{
runtime: 'automatic'
}
]
]
}
}
]
}
]
},
resolve: {
extensions: [".js", ".jsx"]
},
target: ["web", "es6"]
};
参考の内容とほとんど変更はありませんが、サーバーで実行する際はisServer=trueをセットして処理を分岐させるのと、サーバーではReactDOMServer.renderToString
、クライアントではReactDOM.hydrate
を実行してSSRを実現します。
参考の内容だと画面描画されたあと再レンダリングされSSRの意味がなくなっているような気がしているので少し修正しています。
initScriptに初期データをセットし、 onServer関数で取得できるデータがサーバーで実行した際とクライアントで実行した際に同じになるように修正しています。
client/pages/list.jsx
import React, {useState} from 'react'
import {onServer} from "../on-server";
import {initialize} from "../init";
import Navigation from "../components/Navigation";
const List = () => {
const [initialList, initScript] = onServer(api => api.getList(), [], 'app.list');
const [list, setList] = useState(initialList)
const [newItem, setNewItem] = useState('')
const fetchList = async () => {
const response = await window.fetch('/api/list')
setList(await response.json())
}
const addNewItem = async () => {
await (window.fetch('/api/add', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({content: newItem})
}))
await fetchList()
setNewItem('')
}
return (
<div>
<Navigation/>
<h1>List</h1>
<ul>{list.map(i => <li key={i.id}>{i.content}</li>)}</ul>
<div>
<input type="text" value={newItem} onChange={e => setNewItem(e.target.value)}/>
<button onClick={addNewItem}>Add</button>
</div>
{initScript}
</div>
)
}
initialize(List);
client/init.js
import React from 'react'
import ReactDOM from 'react-dom'
import ReactDOMServer from 'react-dom/server'
const anyWindow = window;
export const initialize = (Root) => {
anyWindow.renderApp = () => {
ReactDOM.hydrate(<Root/>, document.getElementById('root'));
}
anyWindow.renderAppOnServer = () => {
return ReactDOMServer.renderToString(<Root/>);
}
}
client/on-server.js
import React from 'react'
export function onServer(callback, defaultValue, valueIdentifier) {
const anyWindow = window
// サーバーで実行した場合。
if (anyWindow.isServer) {
const jsonValue = callback(anyWindow.api);
const sanitizedJson = jsonValue
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/</g, '<')
.replace(/>/g, '>')
const scriptContent = `
<script>
if(!window.serverData) { window.serverData = {} }
window.serverData['${valueIdentifier}'] = JSON.parse("${sanitizedJson}".replace(/</g, '<').replace(/>/g, '>'))
</script>
`
const initScript = <div dangerouslySetInnerHTML={{__html: scriptContent}}></div>
return [JSON.parse(jsonValue), initScript]
}
// クライアントで実行した場合
if (anyWindow.serverData) {
const value = anyWindow.serverData[valueIdentifier]
anyWindow.serverData[valueIdentifier] = undefined
if (value) {
return [value, undefined];
}
}
return [defaultValue, undefined]
}
サーバーサイド側の構成
参考の内容からほぼコピペみたいなものですがMPA構成の想定のため呼び出し側からjsを使用するように変更しています。他にも細々変更しています。 springも最小構成でthymeleafだけ追加しています。色々いじれるように最終的にthymeleafにセットして返却するようにしています。
build.gradle
plugins {
id 'org.springframework.boot' version '2.5.2'
id 'io.spring.dependency-management' version '1.0.11.RELEASE'
id 'java'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '1.8'
repositories {
mavenCentral()
}
dependencies {
implementation "org.graalvm.sdk:graal-sdk:21.1.0"
implementation "org.graalvm.js:js:21.1.0"
implementation "org.graalvm.js:js-scriptengine:21.1.0"
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-web'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
test {
useJUnitPlatform()
}
package com.example.springreactssr;
import com.oracle.truffle.js.scriptengine.GraalJSScriptEngine;
import org.graalvm.polyglot.Context;
import org.graalvm.polyglot.HostAccess;
import javax.script.ScriptException;
import javax.servlet.http.HttpServletRequest;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.Objects;
import java.util.StringJoiner;
import java.util.stream.Collectors;
public abstract class AbstractController {
public String render(HttpServletRequest request, String scriptPath, ServerApi serverApi) throws ScriptException, IOException {
GraalJSScriptEngine engine = this.initializeEngine(request, scriptPath, serverApi);
StringJoiner joiner = new StringJoiner("");
joiner.add("<div id=\"root\">");
joiner.add(engine.eval("window.renderAppOnServer()").toString());
joiner.add("</div>\n");
joiner.add(String.format("<script src=\"%s\"></script>\n", scriptPath.replace("/static", "")));
joiner.add("<script type=\"module\">function renderWhenAvailable() {window.renderApp ? window.renderApp() : window.setTimeout(renderWhenAvailable, 100)}renderWhenAvailable()</script>");
return joiner.toString();
}
private String readFile(String path) throws IOException {
InputStream in = getClass().getResource(path).openStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(in));
System.out.println(path + " loaded.");
return reader.lines().collect(Collectors.joining());
}
private GraalJSScriptEngine initializeEngine(HttpServletRequest request, String scriptPath, ServerApi serverApi) throws ScriptException, IOException {
GraalJSScriptEngine engine = GraalJSScriptEngine.create(
null,
Context
.newBuilder("js")
.allowHostAccess(HostAccess.ALL)
.allowHostClassLookup(s -> true)
);
engine.eval(String.format("window = { location: { hostname: 'localhost' }, isServer: true, requestUrl: \"%s\" }", request.getRequestURI()));
if (!Objects.isNull(serverApi)) {
engine.put("api", serverApi);
engine.eval("window.api = api");
}
engine.eval("navigator = {}");
engine.eval(this.readFile(scriptPath));
return engine;
}
}
package com.example.springreactssr;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import javax.script.ScriptException;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
@Controller
public class ListController extends AbstractController {
private final ItemServerApi itemServerApi;
public ListController(ItemServerApi itemServerApi) {
this.itemServerApi = itemServerApi;
}
@GetMapping("/list")
public String index(HttpServletRequest request, Model model) throws ScriptException, IOException {
String body = this.render(request, "/static/js/pages/list.js", this.itemServerApi);
model.addAttribute("title", "List");
model.addAttribute("body", body);
return "frame";
}
}
<!doctype html>
<html lang="ja" xmlns:th="http://www.thmeleaf.org">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title th:text="${title}"></title>
</head>
<body>
<div th:remove="tag" th:utext="${body}"></div>
</body>
</html>
まとめ
思っていた最低限のことはできるようになったので満足です。初回アクセス時だけレスポンスに時間がかかっていたのでそこだけ気になりました。
まあ、この構成では本番では使用しないかなと思いますので一旦はいいかと思っています。
全体のソースコードはGithubにあげていますので気になりましたら見てください。