Repository navigation
feat: GraalVM Native Image support - #91
Conversation
Phase 0 — MemoryStore: remove unnecessary Java serialization round-trip;
session values are stored directly as Serializable in the in-process map,
eliminating ObjectOutputStream/CustomObjectInputStream overhead.
Phase 1 — Routing: change the 'controller' key in OptionMap from Class<?>
to String (FQCN) so GraalVM never needs Class.forName() per request.
Add Routable#getControllerMethodName / setControllerMethodName.
RoutingMiddleware resolves the class via Class.forName once per route match
and writes both the Method and the 'FQCN#action' string key to the request.
Routes#generate() now accepts either Class<?> or String for the controller
option for backward compatibility.
Phase 2 — kotowari-graalvm module: new module with GraalVM Feature API
- KotowariFeature: generates KotowariDispatcher (reflection-free dispatch
via Class File API) at build time; registers all controller classes and
action methods for native image reflection.
- NativeControllerInvokerMiddleware: uses KotowariDispatcher.dispatch()
instead of LambdaMetafactory; reads controllerMethodName string key.
- RouteRegistry: static holder for Routes; lets KotowariFeature discover
routes at build time without a factory class.
Phase 2b — EnkanFeature: generates ComponentBinder<T> hidden class nestmates
at build time (direct putfield / invokevirtual, no setAccessible).
NativeComponentInjector delegates to registered binders before falling back
to the standard ComponentInjector.
Phase 3 — native-image.properties enables both Features automatically;
reflect-config.json covers mixin interfaces and ParameterInjector impls.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ures Use native-image.properties --features= registration instead, which is the supported mechanism in GraalVM 23+. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds GraalVM Native Image support for Kotowari/Enkan by reducing runtime reflection usage and adjusting routing metadata to be native-image friendly.
Changes:
- Switch routing
"controller"option fromClass<?>toString(FQCN) and update URL generation/routing middleware accordingly (with partial backwards-compat inRoutes.generate()). - Simplify
MemoryStoreto store session values directly in-memory (no serialization roundtrip). - Introduce new
kotowari-graalvmmodule with native-image configuration, build-time bytecode generation features, and native dispatcher/injector implementations.
Reviewed changes
Copilot reviewed 18 out of 18 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
| pom.xml | Adds the new kotowari-graalvm module to the multi-module build. |
| enkan-web/src/main/java/enkan/middleware/session/MemoryStore.java | Removes serialization; stores Serializable values directly in the in-process map. |
| enkan-core/src/main/java/enkan/data/Routable.java | Adds getControllerMethodName() / setControllerMethodName() extension accessors. |
| kotowari/src/main/java/kotowari/routing/factory/RoutingCondition.java | Stores controller constraint as FQCN String instead of Class<?>. |
| kotowari/src/main/java/kotowari/routing/Routes.java | Updates generation to accept Class<?> or FQCN String controller values. |
| kotowari/src/main/java/kotowari/routing/Route.java | Updates matching logic to compare controller names (String) rather than Class<?>. |
| kotowari/src/main/java/kotowari/middleware/RoutingMiddleware.java | Resolves controller from FQCN and populates Routable with method + dispatch key. |
| kotowari/src/test/java/kotowari/routing/RoutesTest.java | Updates expectations for "controller" value to be FQCN String. |
| kotowari-graalvm/pom.xml | New module POM with GraalVM SDK dependency. |
| kotowari-graalvm/src/main/java/kotowari/graalvm/KotowariFeature.java | New GraalVM Feature that generates a dispatcher class at build time. |
| kotowari-graalvm/src/main/java/kotowari/graalvm/NativeControllerInvokerMiddleware.java | New invoker middleware delegating dispatch to the generated dispatcher. |
| kotowari-graalvm/src/main/java/kotowari/graalvm/RouteRegistry.java | Static registry to provide Routes to the feature at build time. |
| kotowari-graalvm/src/main/java/enkan/graalvm/EnkanFeature.java | New GraalVM Feature that generates component binders at build time. |
| kotowari-graalvm/src/main/java/enkan/graalvm/NativeComponentInjector.java | Injector that prefers generated binders with fallback to reflection. |
| kotowari-graalvm/src/main/java/enkan/graalvm/NativeComponentRegistry.java | Registry for generated binders. |
| kotowari-graalvm/src/main/java/enkan/graalvm/ComponentBinder.java | Interface for generated binder implementations. |
| kotowari-graalvm/src/main/resources/** | Adds native-image args and reflection configuration. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| code.ldc(i); | ||
| code.aaload(); | ||
| ClassDesc paramDesc = ClassDesc.of(params[i].getType().getName()); | ||
| code.checkcast(paramDesc); | ||
| } |
There was a problem hiding this comment.
Fixed: now uses ClassDesc.ofDescriptor(type.descriptorString()) via toClassDesc() for reference/array types. For primitive parameters: checkcast to wrapper + emitUnbox() call. For primitive returns: emitBox() before areturn.
| throw new MisconfigurationException("kotowari.DISPATCHER_NOT_FOUND", | ||
| "KotowariDispatcher", e); |
There was a problem hiding this comment.
Fixed: changed to kotowari.CONTROLLER_METHOD_NOT_FOUND (for dispatcher not found) and core.MISSING_IMPLEMENTATION (for missing Routable) — both keys exist in the bundled properties.
| cb.aload(1); // Map arg | ||
| cb.ldc(lookupKey); | ||
| cb.invokeinterface(mapDesc, "get", | ||
| MethodTypeDesc.of(CD_Object, CD_Object)); | ||
| cb.checkcast(fieldTypeDesc); |
There was a problem hiding this comment.
Fixed: EnkanFeature now generates binders only for @nAmed @Inject fields from the concrete class (not superclasses). Field owner in putfield uses f.getDeclaringClass(). Unnamed @Inject fields and components without a no-arg constructor are handled by the fallback ComponentInjector path in NativeComponentInjector.
| Class<?> c = clazz; | ||
| while (c != null && c != Object.class) { | ||
| for (Field f : c.getDeclaredFields()) { | ||
| if (f.getAnnotation(Inject.class) != null) { | ||
| result.add(f); | ||
| } | ||
| } | ||
| c = c.getSuperclass(); |
There was a problem hiding this comment.
Fixed: EnkanFeature now generates binders only for @nAmed @Inject fields from the concrete class (not superclasses). Field owner in putfield uses f.getDeclaringClass(). Unnamed @Inject fields and components without a no-arg constructor are handled by the fallback ComponentInjector path in NativeComponentInjector.
| cb.checkcast(fieldTypeDesc); | ||
| cb.putfield(componentDesc, f.getName(), fieldTypeDesc); | ||
| } |
There was a problem hiding this comment.
Fixed: EnkanFeature now generates binders only for @nAmed @Inject fields from the concrete class (not superclasses). Field owner in putfield uses f.getDeclaringClass(). Unnamed @Inject fields and components without a no-arg constructor are handled by the fallback ComponentInjector path in NativeComponentInjector.
| // Create instance via default constructor | ||
| cb.new_(componentDesc); | ||
| cb.dup(); | ||
| cb.invokespecial(componentDesc, INIT_NAME, MethodTypeDesc.of(CD_void)); | ||
| cb.astore(2); // store component instance at local var 2 |
There was a problem hiding this comment.
Fixed: EnkanFeature now generates binders only for @nAmed @Inject fields from the concrete class (not superclasses). Field owner in putfield uses f.getDeclaringClass(). Unnamed @Inject fields and components without a no-arg constructor are handled by the fallback ComponentInjector path in NativeComponentInjector.
| } else { | ||
| throw new MisconfigurationException("kotowari.ROUTING_GENERATION"); | ||
| } |
| if (action == null) { | ||
| throw new MisconfigurationException("kotowari.ROUTING_GENERATION"); | ||
| } |
| @Override | ||
| public void beforeAnalysis(BeforeAnalysisAccess access) { | ||
| Routes routes = resolveRoutes(access); | ||
| if (routes == null) { | ||
| return; |
There was a problem hiding this comment.
Acknowledged. Unit tests for the generated dispatcher/binder would require the GraalVM SDK on the test classpath, which is a provided/optional dependency. A separate test module or integration test profile is planned as a follow-up once the build infrastructure supports it.
| @Override | ||
| public <NNREQ, NNRES> RES handle(HttpRequest request, MiddlewareChain<Void, Void, NNREQ, NNRES> next) { | ||
| if (!(request instanceof Routable routable)) { | ||
| throw new MisconfigurationException("kotowari.MISSING_IMPLEMENTATION", Routable.class); |
There was a problem hiding this comment.
Fixed: changed to kotowari.CONTROLLER_METHOD_NOT_FOUND (for dispatcher not found) and core.MISSING_IMPLEMENTATION (for missing Routable) — both keys exist in the bundled properties.
- KotowariFeature: fix primitive parameter unboxing and return value boxing in generated dispatcher bytecode; use ClassDesc.ofDescriptor() for array types - EnkanFeature: restrict @Inject field injection to @nAmed fields only (unnamed fields resolved by type-scan in ComponentInjector fallback); use field.getDeclaringClass() as putfield owner to avoid verifier errors for inherited fields - NativeControllerInvokerMiddleware: use core.MISSING_IMPLEMENTATION and kotowari.CONTROLLER_METHOD_NOT_FOUND (existing keys) instead of undefined kotowari-prefixed codes - Routes.generate(): replace bare kotowari.ROUTING_GENERATION throws (missing args) with core.INVALID_ARGUMENT for null controller/action Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds initial GraalVM Native Image support to Kotowari/Enkan by shifting routing/controller metadata to string-based identifiers and introducing a new kotowari-graalvm module that generates reflection-free dispatch/injection helpers at image build time.
Changes:
- Introduces
kotowari-graalvmmodule with GraalVMFeatures for build-time generation (dispatcher + component binders) and native-image configuration resources. - Updates Kotowari routing so recognized
"controller"is stored asString(FQCN); updates route generation to accept bothClass<?>andStringfor backward compatibility. - Simplifies
MemoryStoreto storeSerializablevalues directly (removing Java serialization roundtrips).
Reviewed changes
Copilot reviewed 18 out of 18 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| pom.xml | Adds kotowari-graalvm to the multi-module build. |
| enkan-web/src/main/java/enkan/middleware/session/MemoryStore.java | Removes serialization and stores session/conversation objects directly in-memory. |
| enkan-core/src/main/java/enkan/data/Routable.java | Adds controllerMethodName extension accessors for native dispatcher keying. |
| kotowari/src/main/java/kotowari/routing/factory/RoutingCondition.java | Stores "controller" constraint as controller FQCN string. |
| kotowari/src/main/java/kotowari/routing/Route.java | Switches controller matching constraint from Class<?> to FQCN String. |
| kotowari/src/main/java/kotowari/routing/Routes.java | Makes generate() accept controller as Class<?> or String (FQCN) and updates validation/errors. |
| kotowari/src/main/java/kotowari/middleware/RoutingMiddleware.java | Resolves controller class from FQCN string and sets controllerMethodName on the request. |
| kotowari/src/test/java/kotowari/routing/RoutesTest.java | Updates routing assertions to expect controller FQCN strings. |
| kotowari-graalvm/pom.xml | Defines the new GraalVM support module and its dependencies. |
| kotowari-graalvm/src/main/java/kotowari/graalvm/RouteRegistry.java | Adds build-time registry for supplying compiled Routes to GraalVM analysis. |
| kotowari-graalvm/src/main/java/kotowari/graalvm/KotowariFeature.java | Generates KotowariDispatcher at image build time and registers controller reflection. |
| kotowari-graalvm/src/main/java/kotowari/graalvm/NativeControllerInvokerMiddleware.java | Uses generated dispatcher key (FQCN#action) for native controller invocation. |
| kotowari-graalvm/src/main/java/enkan/graalvm/ComponentBinder.java | Defines binder interface for generated, reflection-free component instantiation/injection. |
| kotowari-graalvm/src/main/java/enkan/graalvm/NativeComponentRegistry.java | Stores generated binders for lookup at runtime. |
| kotowari-graalvm/src/main/java/enkan/graalvm/NativeComponentInjector.java | Uses binders first, falls back to reflection-based ComponentInjector otherwise. |
| kotowari-graalvm/src/main/java/enkan/graalvm/EnkanFeature.java | Generates and registers component binders during native-image build analysis. |
| kotowari-graalvm/src/main/resources/.../native-image.properties | Registers GraalVM Features for native-image builds. |
| kotowari-graalvm/src/main/resources/.../reflect-config.json | Adds baseline reflection config for mixins and parameter injectors. |
Comments suppressed due to low confidence (1)
enkan-web/src/main/java/enkan/middleware/session/MemoryStore.java:22
- MemoryStore now stores the session/conversation value object directly in the ConcurrentHashMap (no serialization/copy). This changes semantics versus the previous deep-copy behavior: concurrent requests for the same session key can now share and mutate the same Session instance (Session is backed by a mutable HashMap), leading to cross-request data races. Consider restoring a defensive copy on read/write (e.g., serialization-based clone, or a dedicated copy strategy for known value types like Session/ConversationState) to preserve thread-safety/isolation expectations for a session store.
private record Entry(Serializable value, long expiresAt) {}
private final ConcurrentHashMap<String, Entry> sessionMap = new ConcurrentHashMap<>();
private long ttlSeconds = DEFAULT_TTL_SECONDS;
private final ScheduledExecutorService purgeScheduler;
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| RuntimeReflection.register(ctrl); | ||
| RuntimeReflection.registerAllConstructors(ctrl); | ||
| for (Method m : ctrl.getDeclaredMethods()) { | ||
| if (m.getName().equals(entry.actionName())) { | ||
| RuntimeReflection.register(m); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Fixed: registerControllerReflection() now calls registerAllDeclaredFields() and registerAllDeclaredMethods() for each controller class, covering @Inject fields and @PostConstruct methods needed by ComponentInjector at runtime.
| // Create instance via default constructor | ||
| cb.new_(componentDesc); | ||
| cb.dup(); | ||
| cb.invokespecial(componentDesc, INIT_NAME, MethodTypeDesc.of(CD_void)); | ||
| cb.astore(2); // store component instance at local var 2 |
There was a problem hiding this comment.
Fixed: generateAndRegisterBinder() now checks for an accessible no-arg constructor first (getDeclaredConstructor()) and skips registration with a log message if absent, letting NativeComponentInjector fall back to ComponentInjector for those classes.
| Object controllerValue = options.get("controller"); | ||
| Class<?> controller; | ||
| if (controllerValue instanceof Class<?> c) { | ||
| controller = c; | ||
| merged.put("controller", c.getName()); | ||
| } else if (controllerValue instanceof String name) { | ||
| try { | ||
| controller = Class.forName(name, false, | ||
| Thread.currentThread().getContextClassLoader()); | ||
| } catch (ClassNotFoundException e) { | ||
| throw new MisconfigurationException("core.CLASS_NOT_FOUND", name, e); | ||
| } | ||
| } else { | ||
| throw new MisconfigurationException("core.INVALID_ARGUMENT", | ||
| "controller", controllerValue != null ? controllerValue : "null"); | ||
| } |
There was a problem hiding this comment.
Fixed: added generateWithStringController() test covering Routes.generate() with a FQCN String key, and generateWithInvalidController() covering the new error branches (invalid controller type, null action).
There was a problem hiding this comment.
Fixed: added GenerateTest.java with generateWithStringController() (covers FQCN String path) and generateWithInvalidController() (covers new MisconfigurationException branches for invalid controller type and null action). These were added in commit b42d4ba.
| registerControllerReflection(access, entries); | ||
|
|
||
| byte[] dispatcherBytes = generateDispatcher(entries); | ||
| access.registerAsUsed(defineDispatcherClass(dispatcherBytes)); |
There was a problem hiding this comment.
Fixed: after defineDispatcherClass(), KotowariFeature now calls RuntimeReflection.register(dispatcherClass) and RuntimeReflection.register(dispatcherClass.getMethod("dispatch", ...)) so the reflective lookup in NativeControllerInvokerMiddleware works in native images.
There was a problem hiding this comment.
Fixed in commit b42d4ba: KotowariFeature.beforeAnalysis() now calls RuntimeReflection.register(dispatcherClass) and RuntimeReflection.register(dispatcherClass.getMethod("dispatch", String.class, Object.class, Object[].class)) immediately after defining the class, so the reflective lookup in NativeControllerInvokerMiddleware.getDispatchMethod() is guaranteed to work at native image runtime.
- KotowariFeature: register all declared fields and methods of each controller class so ComponentInjector can wire @Inject/@PostConstruct in native mode; also register the generated KotowariDispatcher class and its dispatch() method for reflection so NativeControllerInvokerMiddleware can resolve it at runtime - EnkanFeature: skip binder generation for classes without an accessible no-arg constructor; they fall back to ComponentInjector - GenerateTest: add tests for Routes.generate() with String controller key and for the new error branches (invalid controller type, missing action) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds first-class GraalVM Native Image support for Kotowari/Enkan by shifting routing metadata to reflection-friendly strings, introducing build-time generated dispatch/injection infrastructure, and simplifying the in-memory session store to avoid Java serialization round-trips.
Changes:
- Add new
kotowari-graalvmmodule with GraalVM Features for build-time dispatcher generation and reflection-free component injection. - Change routing
"controller"option fromClass<?>to FQCNStringacross routing, and update tests accordingly (while keepingRoutes.generate()compatible withClass<?>input). - Simplify
MemoryStoreto storeSerializablevalues directly instead of serializing tobyte[].
Reviewed changes
Copilot reviewed 19 out of 19 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| pom.xml | Adds kotowari-graalvm module to the multi-module build. |
| kotowari/src/test/java/kotowari/routing/RoutesTest.java | Updates routing assertions to expect controller FQCN strings. |
| kotowari/src/test/java/kotowari/routing/GenerateTest.java | Adds coverage for URL generation using string controllers + invalid controller inputs. |
| kotowari/src/main/java/kotowari/routing/factory/RoutingCondition.java | Emits controller constraint as FQCN String rather than Class<?>. |
| kotowari/src/main/java/kotowari/routing/Routes.java | Accepts Class<?> or String controller values for generate(); throws misconfiguration errors on invalid inputs. |
| kotowari/src/main/java/kotowari/routing/Route.java | Stores controller requirement as a String and matches via controller.getName(). |
| kotowari/src/main/java/kotowari/middleware/RoutingMiddleware.java | Resolves controller class from FQCN string, sets routable controller/method, and stores dispatch key for native dispatch. |
| kotowari-graalvm/src/main/resources/.../reflect-config.json | Adds reflection config for mixin interfaces and parameter injectors. |
| kotowari-graalvm/src/main/resources/.../native-image.properties | Registers GraalVM Features used during native-image builds. |
| kotowari-graalvm/src/main/java/kotowari/graalvm/RouteRegistry.java | Provides build-time route registry hook for dispatcher generation. |
| kotowari-graalvm/src/main/java/kotowari/graalvm/NativeControllerInvokerMiddleware.java | Adds native-friendly controller invocation path using generated dispatcher. |
| kotowari-graalvm/src/main/java/kotowari/graalvm/KotowariFeature.java | Generates KotowariDispatcher at build time and registers controller reflection metadata. |
| kotowari-graalvm/src/main/java/enkan/graalvm/NativeComponentRegistry.java | Static registry for generated component binders. |
| kotowari-graalvm/src/main/java/enkan/graalvm/NativeComponentInjector.java | Uses generated binders when present; falls back to reflection-based injection otherwise. |
| kotowari-graalvm/src/main/java/enkan/graalvm/EnkanFeature.java | Generates binder bytecode for selected components at native-image build time. |
| kotowari-graalvm/src/main/java/enkan/graalvm/ComponentBinder.java | Defines binder interface used by generated injection code. |
| kotowari-graalvm/pom.xml | Introduces the new module’s Maven configuration and dependencies. |
| enkan-web/src/main/java/enkan/middleware/session/MemoryStore.java | Removes serialization roundtrip; stores Serializable values directly. |
| enkan-core/src/main/java/enkan/data/Routable.java | Adds controllerMethodName extension accessors for native dispatch keying. |
Comments suppressed due to low confidence (1)
kotowari/src/main/java/kotowari/middleware/RoutingMiddleware.java:119
- core.CLASS_NOT_FOUND solution text includes a classpath placeholder ({1}). This call passes only the class name + cause, so the solution message will not include the classpath and may be misleading. Consider adding ReflectionUtils.getClasspathString() as an extra argument (consistent with ReflectionUtils.tryReflection).
Class<?> ctrlClass = Class.forName(arguments.get(0).toString(), true,
Thread.currentThread().getContextClassLoader());
return routes.generate(UrlRewriter.urlFor(ctrlClass, arguments.get(1).toString()).options());
} catch (ClassNotFoundException e) {
throw new MisconfigurationException("core.CLASS_NOT_FOUND", arguments.getFirst().toString(), e);
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| try { | ||
| controller = Class.forName(name, false, | ||
| Thread.currentThread().getContextClassLoader()); | ||
| } catch (ClassNotFoundException e) { | ||
| throw new MisconfigurationException("core.CLASS_NOT_FOUND", name, e); | ||
| } |
| Class<?> ctrlClass = Class.forName(entry.controllerClassName()); | ||
| // Find the action method | ||
| Method actionMethod = null; | ||
| for (Method m : ctrlClass.getDeclaredMethods()) { | ||
| if (m.getName().equals(entry.actionName()) && Modifier.isPublic(m.getModifiers())) { | ||
| actionMethod = m; | ||
| break; | ||
| } | ||
| } |
| /** | ||
| * GraalVM {@link Feature} that generates reflection-free {@link ComponentBinder} classes | ||
| * at native image build time for each component class registered in | ||
| * {@link NativeComponentRegistry}. | ||
| * | ||
| * <p>Component classes are discovered via the system property | ||
| * {@code enkan.component.classes} (comma-separated fully-qualified class names). | ||
| * |
|
|
||
| default String getControllerMethodName() { | ||
| return getExtension("controllerMethodName"); | ||
| } | ||
|
|
||
| default void setControllerMethodName(String name) { | ||
| setExtension("controllerMethodName", name); | ||
| } |
| public String write(String key, Serializable value) { | ||
| if (key == null) { | ||
| key = UUID.randomUUID().toString(); | ||
| } | ||
|
|
||
| try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); | ||
| ObjectOutputStream oos = new ObjectOutputStream(baos)) { | ||
| oos.writeObject(value); | ||
| long expiresAt = System.currentTimeMillis() + ttlSeconds * 1000L; | ||
| sessionMap.put(key, new Entry(baos.toByteArray(), expiresAt)); | ||
| } catch (IOException ex) { | ||
| throw new FalteringEnvironmentException(ex); | ||
| } | ||
|
|
||
| long expiresAt = System.currentTimeMillis() + ttlSeconds * 1000L; | ||
| sessionMap.put(key, new Entry(value, expiresAt)); | ||
| return key; |
| String controllerClassName = routing.getString("controller"); | ||
| try { | ||
| controllerClass = Class.forName(controllerClassName, true, | ||
| Thread.currentThread().getContextClassLoader()); | ||
| } catch (ClassNotFoundException e) { | ||
| throw new MisconfigurationException("core.CLASS_NOT_FOUND", controllerClassName, e); | ||
| } |
| controllerClass = Class.forName(controllerClassName, true, | ||
| Thread.currentThread().getContextClassLoader()); |
- EnkanFeature: remove @PostConstruct call from generated binder bytecode; unnamed @Inject fields and @PostConstruct are now handled by NativeComponentInjector after bind() returns, preventing silent null fields. - EnkanFeature: fix ClassDesc for field types using ofDescriptor(descriptorString()) instead of ClassDesc.of(getName()), which correctly handles array types. - EnkanFeature: remove unused findPostConstruct helper and dead imports. - NativeComponentInjector: after binder handles @nAmed @Inject fields, inject remaining unnamed @Inject fields via super.injectField(), then call postConstruct(). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…-ready) Minimal GraalVM Native Image example with in-memory Todo API: - NativeSystemFactory: UndertowComponent + JacksonBeansConverter - NativeApplicationFactory: /todos CRUD routes + NativeControllerInvokerMiddleware + RouteRegistry.register() for KotowariFeature build-time discovery - TodoController: in-memory ConcurrentHashMap-backed CRUD - native-maven-plugin profile for one-command native build - RoutingMiddleware: fix effectively-final capture in computeIfAbsent lambda Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ed-classes MixinUtils.createFactory() calls MethodHandles.Lookup.defineClass() at runtime to generate DefaultHttpRequest$Mixin<N> subclasses. This fails in native images because runtime class definition is not permitted. Fix: - MixinUtils.createFactory(): target Java 17 class-file format (major 69→61) so GraalVM's bundled ASM can parse the bytes; store bytes in `generatedClassBytes` before defineClass so they can be retrieved externally. - GenerateMixinConfig (new): standalone Maven exec entry point that builds the app middleware stack, triggers createFactory via createRequest(), then writes target/agent-extracted-predefined-classes/<hash>.classdata and target/predefined-classes-config.json. - KotowariFeature.pregenerateMixinClasses(): calls buildApp()+createRequest() at build time; handles LinkageError gracefully when the class is already predefined; registers every $Mixin class with the native-image analyser. - NativeApplicationFactory: extract static routes() and buildApp() methods so KotowariFeature can invoke them without a ComponentInjector. - kotowari-graalvm-example/pom.xml: add exec:java execution in prepare-package phase to run GenerateMixinConfig; pass -H:+UnlockExperimentalVMOptions -H:PredefinedClassesConfigurationFiles to native-image; remove -H:+RuntimeClassLoading workaround. - reflect-config.json: add Todo model for Jackson serialization. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The default allowedTypes is Set.of("text/html"), which caused 406 Not
Acceptable for all requests in the pure JSON API example. Set it to
Set.of("application/json") in both buildApp() and create() so the JSON
SerDes pipeline is selected correctly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Covers prerequisites, project structure, API endpoints, JVM run, native image build steps (including why predefined-classes are needed), smoke test commands, and how KotowariFeature/EnkanFeature work. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds GraalVM Native Image support for Kotowari/Enkan by introducing a native-focused module (features + generated dispatch/injection), adjusting routing metadata to avoid Class<?> storage, and simplifying in-memory session storage to avoid serialization.
Changes:
- Switch routing
"controller"option fromClass<?>to FQCNString, and extendRoutes.generate()to accept bothClass<?>andString. - Add
kotowari-graalvmmodule (GraalVMFeatures, reflection-free dispatcher generation, native controller invoker, native component injection helpers). - Add
kotowari-graalvm-examplemodule showing an end-to-end native-image build setup (including predefined mixin classes workflow).
Reviewed changes
Copilot reviewed 34 out of 34 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| pom.xml | Adds new kotowari-graalvm and kotowari-graalvm-example modules to the multi-module build. |
| kotowari/src/test/java/kotowari/routing/RoutesTest.java | Updates routing recognition assertions to expect controller FQCN strings. |
| kotowari/src/test/java/kotowari/routing/GenerateTest.java | Adds coverage for URL generation with string controller values and invalid inputs. |
| kotowari/src/main/java/kotowari/routing/factory/RoutingCondition.java | Writes "controller" as FQCN string into route options. |
| kotowari/src/main/java/kotowari/routing/Routes.java | Accepts controller as Class<?> or String in generate(); validates inputs. |
| kotowari/src/main/java/kotowari/routing/Route.java | Changes controller constraint matching to compare against controller FQCN string. |
| kotowari/src/main/java/kotowari/middleware/RoutingMiddleware.java | Converts recognized controller FQCN back to Class<?>; stores dispatch key on request. |
| kotowari-graalvm/src/main/resources/.../reflect-config.json | Adds baseline reflection registrations needed by native image runtime. |
| kotowari-graalvm/src/main/resources/.../native-image.properties | Configures native-image to enable Enkan/Kotowari GraalVM Features. |
| kotowari-graalvm/src/main/java/kotowari/graalvm/RouteRegistry.java | Adds build-time route registry for feature discovery. |
| kotowari-graalvm/src/main/java/kotowari/graalvm/NativeControllerInvokerMiddleware.java | Dispatches via generated dispatcher rather than LambdaMetafactory. |
| kotowari-graalvm/src/main/java/kotowari/graalvm/KotowariFeature.java | Generates dispatcher bytecode and performs build-time registrations (controllers + mixins). |
| kotowari-graalvm/src/main/java/enkan/graalvm/NativeComponentRegistry.java | Stores generated component binders for runtime lookup. |
| kotowari-graalvm/src/main/java/enkan/graalvm/NativeComponentInjector.java | Uses binders for @Named @Inject field injection with reflection fallback. |
| kotowari-graalvm/src/main/java/enkan/graalvm/EnkanFeature.java | Generates binders at build time using Class File API and registers them. |
| kotowari-graalvm/src/main/java/enkan/graalvm/ComponentBinder.java | Defines the binder interface used by generated injectors. |
| kotowari-graalvm/pom.xml | Adds the new kotowari-graalvm module build configuration and dependencies. |
| kotowari-graalvm-example/src/main/resources/.../resource-config.json | Provides resource inclusions for the native example build. |
| kotowari-graalvm-example/src/main/resources/.../reflect-config.json | Registers example factory and model reflection needs. |
| kotowari-graalvm-example/src/main/resources/.../reachability-metadata.json | Adds reachability metadata for native-image compatibility in the example. |
| kotowari-graalvm-example/src/main/resources/.../native-image.properties | Provides example native-image args (routes factory property). |
| kotowari-graalvm-example/src/main/java/kotowari/example/graalvm/model/Todo.java | Example record model for JSON API. |
| kotowari-graalvm-example/src/main/java/kotowari/example/graalvm/jaxrs/JsonBodyWriter.java | Example JAX-RS JSON writer using Jackson. |
| kotowari-graalvm-example/src/main/java/kotowari/example/graalvm/jaxrs/JsonBodyReader.java | Example JAX-RS JSON reader using Jackson. |
| kotowari-graalvm-example/src/main/java/kotowari/example/graalvm/controller/TodoController.java | Example controller actions used for native dispatcher generation. |
| kotowari-graalvm-example/src/main/java/kotowari/example/graalvm/NativeSystemFactory.java | Example EnkanSystem wiring for Undertow + Jackson + app. |
| kotowari-graalvm-example/src/main/java/kotowari/example/graalvm/NativeMain.java | Example entry point for JVM/native execution. |
| kotowari-graalvm-example/src/main/java/kotowari/example/graalvm/NativeApplicationFactory.java | Example middleware stack and route definitions; registers routes for build-time. |
| kotowari-graalvm-example/src/main/java/kotowari/example/graalvm/GenerateMixinConfig.java | Generates predefined-classes config for mixin classes prior to native-image. |
| kotowari-graalvm-example/pom.xml | Adds native build profile and exec steps for predefined-classes generation. |
| kotowari-graalvm-example/README.md | Documents how to build/run the GraalVM native example and features. |
| enkan-web/src/main/java/enkan/middleware/session/MemoryStore.java | Removes serialize/deserialize roundtrip and stores session values directly. |
| enkan-core/src/main/java/enkan/util/MixinUtils.java | Captures generated mixin class bytes and targets Java 17 classfile version for predefined-classes. |
| enkan-core/src/main/java/enkan/data/Routable.java | Adds controller method key storage to support native dispatcher lookup. |
Comments suppressed due to low confidence (1)
kotowari/src/main/java/kotowari/middleware/RoutingMiddleware.java:120
- Same as above:
core.CLASS_NOT_FOUNDexpects(className, classpathString, cause). Passing only the class name + Throwable results in an incorrect solution message. AddReflectionUtils.getClasspathString()(or equivalent) as the second argument so the user sees the classpath when URL generation fails.
Class<?> ctrlClass = Class.forName(arguments.get(0).toString(), true,
Thread.currentThread().getContextClassLoader());
return routes.generate(UrlRewriter.urlFor(ctrlClass, arguments.get(1).toString()).options());
} catch (ClassNotFoundException e) {
throw new MisconfigurationException("core.CLASS_NOT_FOUND", arguments.getFirst().toString(), e);
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| throw new MisconfigurationException("core.INVALID_ARGUMENT", | ||
| "controller", controllerValue != null ? controllerValue : "null"); | ||
| } |
| if (controller == null || action == null) { | ||
| throw new MisconfigurationException("kotowari.ROUTING_GENERATION"); | ||
| if (action == null) { | ||
| throw new MisconfigurationException("core.INVALID_ARGUMENT", "action", "null"); |
| Class<?> ctrlClass = Class.forName(entry.controllerClassName()); | ||
| // Find the action method | ||
| Method actionMethod = null; | ||
| for (Method m : ctrlClass.getDeclaredMethods()) { | ||
| if (m.getName().equals(entry.actionName()) && Modifier.isPublic(m.getModifiers())) { | ||
| actionMethod = m; | ||
| break; | ||
| } | ||
| } |
| String baseClassName = "enkan.data.DefaultHttpRequest"; | ||
| for (long i = 1; i <= 20; i++) { | ||
| String mixinName = baseClassName + "$Mixin" + i; | ||
| try { | ||
| Class<?> mixinClass = Class.forName(mixinName); | ||
| RuntimeReflection.register(mixinClass); | ||
| RuntimeReflection.registerAllConstructors(mixinClass); | ||
| access.registerAsUsed(mixinClass); | ||
| System.out.println("[KotowariFeature] Registered mixin class: " + mixinName); | ||
| } catch (ClassNotFoundException e) { | ||
| break; | ||
| } | ||
| } |
| try { | ||
| controller = Class.forName(name, false, | ||
| Thread.currentThread().getContextClassLoader()); | ||
| } catch (ClassNotFoundException e) { | ||
| throw new MisconfigurationException("core.CLASS_NOT_FOUND", name, e); | ||
| } |
| String controllerClassName = routing.getString("controller"); | ||
| try { | ||
| controllerClass = Class.forName(controllerClassName, true, | ||
| Thread.currentThread().getContextClassLoader()); | ||
| } catch (ClassNotFoundException e) { | ||
| throw new MisconfigurationException("core.CLASS_NOT_FOUND", controllerClassName, e); | ||
| } |
1. Eliminate Method.invoke on hot path: introduce KotowariDispatcherInvoker interface and NativeDispatcherRegistry; KotowariFeature instantiates the generated dispatcher and registers it; NativeControllerInvokerMiddleware calls invoker.dispatch() directly (invokeinterface, no reflection). 2. Fix pregenerateMixinClasses() blind scan: iterate MixinUtils.generatedClassBytes.keySet() instead of polling $Mixin1..$Mixin20, avoiding missed classes when the AtomicLong counter does not start at 1. 3. Fix EnkanFeature.collectNamedInjectFields(): walk getSuperclass() up to SystemComponent so inherited @nAmed @Inject fields are included in the generated binder bytecode. 4. Trim reachability-metadata.json: remove 83 over-captured agent entries (com.sun.*, sun.*, JMX, JFR, logging frameworks, primitive arrays) that are fragile across GraalVM versions; retain only application-specific and Undertow/XNIO/Jackson/slf4j entries. 5. Remove unnecessary extends SystemComponent<T> bound from NativeComponentRegistry.register(); ComponentBinder<T> only needs T. 6. Add KotowariFeatureTest: 6 tests covering extractEntries(), the generated dispatcher (no-arg, request-arg, unknown-key), and NativeDispatcherRegistry. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
MisconfigurationException("core.CLASS_NOT_FOUND") expects (className,
classpathString, cause). The urlFor lambda was only passing className + cause,
leaving the {1} placeholder blank in the solution message. Add
ReflectionUtils.getClasspathString() as the second argument, consistent with
ReflectionUtils.tryReflection.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Split the monolithic resource-config.json in kotowari-graalvm-example into per-module files so application authors no longer need to copy framework resource entries manually: - enkan-core: misconfiguration.properties bundle (MergeableResourceBundle) - enkan-component-undertow: Undertow/XNIO/JBoss service files and version properties - kotowari: JAX-RS MessageBodyReader/Writer service files (SerDesMiddleware) kotowari-graalvm-example retains only truly app-specific entries: slf4j provider, validation provider, simplelogger.properties, and the JFR view.ini needed by the example's JVM flags. GraalVM automatically merges all META-INF/native-image/**/*.json files from the classpath, so no extra build configuration is required in appli 10BC8 cations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Application authors no longer need to copy Undertow/Jackson/enkan/kotowari reflection entries. Each module now ships its own native-image metadata: - enkan-component-undertow: Undertow, XNIO, JBoss Threads reflection entries (loggers, server internals, Headers/Methods/Protocols fields) and java.lang primitive wrapper TYPE fields required by Undertow JNI layer - enkan-component-jackson: Jackson SPI array types (Deserializers[], Serializers[], etc.) needed by the ServiceLoader path - kotowari-graalvm/reflect-config.json: enkan/kotowari middleware classes, mixin interfaces, ParameterInjector implementations, SerDesMiddleware, NativeControllerInvokerMiddleware — everything the framework itself needs kotowari-graalvm-example now contains only the 4 app-specific entries: NativeApplicationFactory, NativeSystemFactory, TodoController, Todo. reflect-config.json in the example is now empty (entries moved to reachability-metadata.json to avoid duplication). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace the predefined-classes mechanism with a build-time class file approach: - MixinUtils.createFactory(): add factoryCache (ConcurrentHashMap) so the Supplier is built once and reused; add LinkageError fallback in buildFactory() to load an already-compiled mixin class via Class.forName instead of failing when defineClass() is rejected in native image - GenerateMixinConfig: write generated $Mixin class bytes to target/classes/ as ordinary .class files so the shade plugin includes them in the fat JAR; native-image compiles them normally — no predefined-classes mechanism required - pom.xml (kotowari-graalvm-example): remove -H:PredefinedClassesConfigurationFiles from native-image buildArgs; update exec plugin comment - KotowariFeature.pregenerateMixinClasses(): remove LinkageError suppression branch; registering mixin class for reflection still done after createRequest() succeeds - native-image.properties: add --initialize-at-build-time for MixinUtils, NativeDispatcherRegistry, and KotowariDispatcher so build- time static state (factoryCache, invoker) is preserved in image heap - reachability-metadata.json (example): add allDeclaredMethods: true to Todo so Jackson can serialize record accessor methods Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1. Eliminate Method.invoke on hot path: introduce KotowariDispatcherInvoker interface and NativeDispatcherRegistry; KotowariFeature instantiates the generated dispatcher and registers it; NativeControllerInvokerMiddleware calls invoker.dispatch() directly (invokeinterface, no reflection). 2. Fix pregenerateMixinClasses() blind scan: iterate MixinUtils.generatedClassBytes.keySet() instead of polling $Mixin1..$Mixin20, avoiding missed classes when the AtomicLong counter does not start at 1. 3. Fix EnkanFeature.collectNamedInjectFields(): walk getSuperclass() up to SystemComponent so inherited @nAmed @Inject fields are included in the generated binder bytecode. 4. Trim reachability-metadata.json: remove 83 over-captured agent entries (com.sun.*, sun.*, JMX, JFR, logging frameworks, primitive arrays) that are fragile across GraalVM versions; retain only application-specific and Undertow/XNIO/Jackson/slf4j entries. 5. Remove unnecessary extends SystemComponent<T> bound from NativeComponentRegistry.register(); ComponentBinder<T> only needs T. 6. Add KotowariFeatureTest: 6 tests covering extractEntries(), the generated dispatcher (no-arg, request-arg, unknown-key), and NativeDispatcherRegistry. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Update MixinUtils.generatedClassBytes Javadoc to describe the target/classes/ approach (remove stale predefined-classes reference) - Add Routes.getRouteList() and Route.constraints() public accessors; rewrite KotowariFeature.extractEntries() to use them instead of getDeclaredField/setAccessible, removing the hidden coupling to internals - Remove volatile from NativeDispatcherRegistry.invoker — the field is frozen in the native image heap via --initialize-at-build-time and is never written at runtime; add Javadoc explaining the rationale - Document in NativeComponentInjector Javadoc that unnamed @Inject fields still require reflection registration (allDeclaredFields or RuntimeReflection.registerAllFields) in native image - Fix reflect-config.json: use "type" key (consistent with reachability- metadata.json format) and add allDeclaredConstructors to ApplicationComponent - Add MemoryStoreTest covering write/read, TTL expiry, and the new reference-sharing semantics (storedReferenceIsShared test) - Add EnkanFeatureTest covering generateBinderBytecode, hidden class field injection, and collectNamedInjectFields exclusion logic; make generateBinderBytecode and collectNamedInjectFields package-private to enable direct testing without reflection - Fix README: update build steps to describe target/classes/ approach and --initialize-at-build-time instead of predefined-classes mechanism Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Summary
MemoryStore— session values are now stored directly in the in-processConcurrentHashMap(noObjectOutputStream/CustomObjectInputStreamroundtrip)"controller"routing key fromClass<?>toString(FQCN) inRoutingCondition/Route/RoutingMiddleware; addgetControllerMethodName()/setControllerMethodName()toRoutable;Routes.generate()now accepts bothClass<?>andStringfor backwards compatibilitykotowari-graalvmmodule with:RouteRegistry— static holder for build-time Routes discoveryKotowariFeature— GraalVM Feature that generates reflection-freeKotowariDispatchervia Class File API and registers all controller classes/methods for runtime reflectionNativeControllerInvokerMiddleware— dispatches via generatedKotowariDispatcher.dispatch(String, Object, Object[])instead ofLambdaMetafactoryEnkanFeature— GraalVM Feature that generatesComponentBinder<T>implementations via Class File API for each component classNativeComponentInjector/NativeComponentRegistry— reflection-free component injection using generated bindersnative-image.propertiesandreflect-config.jsonfor mixin interfaces and parameter injectorsTest plan
mvn test -pl enkan-core,enkan-web,kotowari— all tests pass (routing tests updated to expect String controller key)mvn compile -pl kotowari-graalvm— new module compiles cleanly🤖 Generated with Claude Code