From bd3c2d86a335811a880436ee1765ca5380afdc5e Mon Sep 17 00:00:00 2001 From: Toshiaki Maki Date: Tue, 9 Apr 2024 09:39:25 +0900 Subject: [PATCH] Avoid concurrent access to JavaScript objects --- .../com/example/post/ssr/ReactRenderer.java | 114 +++++++++++------- .../example/post/ssr/SsrControllerTest.java | 2 +- 2 files changed, 74 insertions(+), 42 deletions(-) diff --git a/src/main/java/com/example/post/ssr/ReactRenderer.java b/src/main/java/com/example/post/ssr/ReactRenderer.java index 45e53fe..acd7080 100644 --- a/src/main/java/com/example/post/ssr/ReactRenderer.java +++ b/src/main/java/com/example/post/ssr/ReactRenderer.java @@ -8,8 +8,12 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import com.fasterxml.jackson.databind.ObjectMapper; import org.graalvm.polyglot.Context; @@ -19,6 +23,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.core.NamedInheritableThreadLocal; import org.springframework.core.NativeDetector; import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.FileSystemResource; @@ -31,50 +36,35 @@ @Component public class ReactRenderer implements AutoCloseable { - private final Context context; - - private final Value render; + private final ThreadLocal renderHolder = new NamedInheritableThreadLocal<>("React Render"); private final String template; private final ObjectMapper objectMapper; + private static final ConcurrentMap fileMap = new ConcurrentHashMap<>(); + + private final List contexts = new ArrayList<>(); + private static final Logger log = LoggerFactory.getLogger(ReactRenderer.class); public ReactRenderer(ObjectMapper objectMapper) throws IOException { this.objectMapper = objectMapper; - this.context = Context.newBuilder("js") - .allowIO(IOAccess.ALL) - .allowExperimentalOptions(true) - .option("js.esm-eval-returns-exports", "true") - .option("js.commonjs-require", "true") - .option("js.commonjs-require-cwd", getRoot("polyfill").getAbsolutePath()) - .option("js.commonjs-core-modules-replacements", - "stream:stream-browserify,util:fastestsmallesttextencoderdecoder,buffer:buffer/") - .build(); - this.context.eval("js", """ - globalThis.Buffer = require('buffer').Buffer; - globalThis.URL = require('whatwg-url-without-unicode').URL; - globalThis.process = { - env: { - NODE_ENV: 'production' - } - }; - globalThis.document = {}; - global = globalThis; - """); - Path code = Paths.get(getRoot("server").getAbsolutePath(), "main-server.js"); - Source source = Source.newBuilder("js", code.toFile()).mimeType("application/javascript+module").build(); - Value exports = this.context.eval(source); - this.render = exports.getMember("render"); this.template = Files.readString(Paths.get(getRoot("META-INF/resources").getAbsolutePath(), "index.html")); - ; + // pre-computing + getRoot("polyfill"); + getRoot("server"); } public String render(String url, Map input) { + Value render = this.renderHolder.get(); + if (render == null) { + render = createRender(); + renderHolder.set(render); + } try { String s = this.objectMapper.writeValueAsString(input); - Value executed = this.render.execute(url, s); + Value executed = render.execute(url, s); Value head = executed.getMember("head"); Value html = executed.getMember("html"); return this.template @@ -89,21 +79,63 @@ public String render(String url, Map input) { } } - static File getRoot(String root) throws IOException { - if (NativeDetector.inNativeImage()) { - // in native image - return copyResources(root).toFile(); - } - ClassPathResource resource = new ClassPathResource(root); - if (resource.getURL().toString().startsWith("jar:")) { - // in jar file - return new FileSystemResource("./target/classes/" + root).getFile(); + Value createRender() { + log.trace("createRender"); + try { + Context context = Context.newBuilder("js") + .allowIO(IOAccess.ALL) + .allowExperimentalOptions(true) + .option("js.esm-eval-returns-exports", "true") + .option("js.commonjs-require", "true") + .option("js.commonjs-require-cwd", getRoot("polyfill").getAbsolutePath()) + .option("js.commonjs-core-modules-replacements", + "stream:stream-browserify,util:fastestsmallesttextencoderdecoder,buffer:buffer/") + .build(); + context.eval("js", """ + globalThis.Buffer = require('buffer').Buffer; + globalThis.URL = require('whatwg-url-without-unicode').URL; + globalThis.process = { + env: { + NODE_ENV: 'production' + } + }; + globalThis.document = {}; + global = globalThis; + """); + Path code = Paths.get(getRoot("server").getAbsolutePath(), "main-server.js"); + Source source = Source.newBuilder("js", code.toFile()).mimeType("application/javascript+module").build(); + Value exports = context.eval(source); + this.contexts.add(context); + return exports.getMember("render"); } - else { - return resource.getFile(); + catch (IOException e) { + throw new UncheckedIOException(e); } } + static File getRoot(String root) { + return fileMap.computeIfAbsent(root, key -> { + log.trace("computing getRoot({})", key); + try { + if (NativeDetector.inNativeImage()) { + // in native image + return copyResources(root).toFile(); + } + ClassPathResource resource = new ClassPathResource(root); + if (resource.getURL().toString().startsWith("jar:")) { + // in jar file + return new FileSystemResource("./target/classes/" + root).getFile(); + } + else { + return resource.getFile(); + } + } + catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + } + private static Path copyResources(String root) { try { Path baseDir = Files.createTempDirectory("copied-"); @@ -140,7 +172,7 @@ private static Path copyResources(String root) { @Override public void close() { - this.context.close(); + this.contexts.forEach(Context::close); } } diff --git a/src/test/java/com/example/post/ssr/SsrControllerTest.java b/src/test/java/com/example/post/ssr/SsrControllerTest.java index 5725213..3f459b5 100644 --- a/src/test/java/com/example/post/ssr/SsrControllerTest.java +++ b/src/test/java/com/example/post/ssr/SsrControllerTest.java @@ -21,7 +21,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.BDDMockito.given; -@WebMvcTest(SsrController.class) +@WebMvcTest(controllers = SsrController.class, properties = "logging.level.com.example=trace") @Import(ReactRenderer.class) class SsrControllerTest {