8000 feat: GraalVM Native Image support by kawasima · Pull Request #91 · enkan/enkan · GitHub
[go: up one dir, main page]

Skip to content

feat: GraalVM Native Image support - #91

Merged
kawasima merged 14 commits into
developfrom
feature/graalvm-native-image
Mar 14, 2026
Merged

kawasima merged 14 commits into
developfrom
feature/graalvm-native-image

Conversation

@kawasima
@kawasima kawasima commented Mar 14, 2026 •
Copy link
Copy Markdown
Collaborator

Summary

  • Phase 0: Remove unnecessary serialization from MemoryStore — session values are now stored directly in the in-process ConcurrentHashMap (no ObjectOutputStream/CustomObjectInputStream roundtrip)
  • Phase 1: Change "controller" routing key from Class<?> to String (FQCN) in RoutingCondition/Route/RoutingMiddleware; add getControllerMethodName()/setControllerMethodName() to Routable; Routes.generate() now accepts both Class<?> and String for backwards compatibility
  • Phase 2: New kotowari-graalvm module with:
    • RouteRegistry — static holder for build-time Routes discovery
    • KotowariFeature — GraalVM Feature that generates reflection-free KotowariDispatcher via Class File API and registers all controller classes/methods for runtime reflection
    • NativeControllerInvokerMiddleware — dispatches via generated KotowariDispatcher.dispatch(String, Object, Object[]) instead of LambdaMetafactory
    • EnkanFeature — GraalVM Feature that generates ComponentBinder<T> implementations via Class File API for each component class
    • NativeComponentInjector / NativeComponentRegistry — reflection-free component injection using generated binders
    • native-image.properties and reflect-config.json for mixin interfaces and parameter injectors

Test 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
  • End-to-end native image build with a kotowari-example application (requires GraalVM installation; not part of CI yet)

🤖 Generated with Claude Code

kawasima and others added 2 commits March 14, 2026 14:33
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>
Copilot AI left a comment
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 from Class<?> to String (FQCN) and update URL generation/routing middleware accordingly (with partial backwards-compat in Routes.generate()).
  • Simplify MemoryStore to store session values directly in-memory (no serialization roundtrip).
  • Introduce new kotowari-graalvm module 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.

Comment on lines +195 to +199
code.ldc(i);
code.aaload();
ClassDesc paramDesc = ClassDesc.of(params[i].getType().getName());
code.checkcast(paramDesc);
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +67 to +68
throw new MisconfigurationException("kotowari.DISPATCHER_NOT_FOUND",
"KotowariDispatcher", e);
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +132 to +136
cb.aload(1); // Map arg
cb.ldc(lookupKey);
cb.invokeinterface(mapDesc, "get",
MethodTypeDesc.of(CD_Object, CD_Object));
cb.checkcast(fieldTypeDesc);
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +156 to +163
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();
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +136 to +138
cb.checkcast(fieldTypeDesc);
cb.putfield(componentDesc, f.getName(), fieldTypeDesc);
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +120 to +124
// 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
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +73 to +75
} else {
throw new MisconfigurationException("kotowari.ROUTING_GENERATION");
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit ffd44de.

Comment on lines +78 to 80
if (action == null) {
throw new MisconfigurationException("kotowari.ROUTING_GENERATION");
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit ffd44de.

Comment on lines +50 to +54
@Override
public void beforeAnalysis(BeforeAnalysisAccess access) {
Routes routes = resolveRoutes(access);
if (routes == null) {
return;
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
10BC8
Copilot AI left a comment
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-graalvm module with GraalVM Features for build-time generation (dispatcher + component binders) and native-image configuration resources.
  • Updates Kotowari routing so recognized "controller" is stored as String (FQCN); updates route generation to accept both Class<?> and String for backward compatibility.
  • Simplifies MemoryStore to store Serializable values 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.

Comment on lines +122 to +129
RuntimeReflection.register(ctrl);
RuntimeReflection.registerAllConstructors(ctrl);
for (Method m : ctrl.getDeclaredMethods()) {
if (m.getName().equals(entry.actionName())) {
RuntimeReflection.register(m);
}
}
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: registerControllerReflection() now calls registerAllDeclaredFields() and registerAllDeclaredMethods() for each controller class, covering @Inject fields and @PostConstruct methods needed by ComponentInjector at runtime.

Comment on lines +121 to +125
// 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
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +61 to +76
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");
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: added generateWithStringController() test covering Routes.generate() with a FQCN String key, and generateWithInvalidController() covering the new error branches (invalid controller type, null action).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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));
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Copilot AI left a comment
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-graalvm module with GraalVM Features for build-time dispatcher generation and reflection-free component injection.
  • Change routing "controller" option from Class<?> to FQCN String across routing, and update tests accordingly (while keeping Routes.generate() compatible with Class<?> input).
  • Simplify MemoryStore to store Serializable values directly instead of serializing to byte[].

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.

Comment on lines +67 to +72
try {
controller = Class.forName(name, false,
Thread.currentThread().getContextClassLoader());
} catch (ClassNotFoundException e) {
throw new MisconfigurationException("core.CLASS_NOT_FOUND", name, e);
}
Comment on lines +172 to +180
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;
}
}
Comment on lines +22 to +29
/**
* 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).
*
Comment on lines +25 to +32

default String getControllerMethodName() {
return getExtension("controllerMethodName");
}

default void setControllerMethodName(String name) {
setExtension("controllerMethodName", name);
}
Comment on lines 60 to 66
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;
Comment on lines +57 to +63
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);
}
Comment on lines +59 to +60
10BC8
controllerClass = Class.forName(controllerClassName, true,
Thread.currentThread().getContextClassLoader());
kawasima and others added 5 commits March 14, 2026 15:39
- 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>
Copilot AI left a comment
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 from Class<?> to FQCN String, and extend Routes.generate() to accept both Class<?> and String.
  • Add kotowari-graalvm module (GraalVM Features, reflection-free dispatcher generation, native controller invoker, native component injection helpers).
  • Add kotowari-graalvm-example module 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_FOUND expects (className, classpathString, cause). Passing only the class name + Throwable results in an incorrect solution message. Add ReflectionUtils.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.

Comment on lines +74 to +76
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");
Comment on lines +241 to +249
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;
}
}
Comment on lines +127 to +139
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;
}
}
Comment on lines +67 to +72
try {
controller = Class.forName(name, false,
Thread.currentThread().getContextClassLoader());
} catch (ClassNotFoundException e) {
throw new MisconfigurationException("core.CLASS_NOT_FOUND", name, e);
}
Comment on lines +57 to +63
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);
}
kawasima and others added 5 commits March 14, 2026 17:28
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>
@kawasima
kawasima merged commit 75f995f into develop Mar 14, 2026
kawasima added a commit that referenced this pull request Mar 14, 2026
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>
kawasima added a commit that referenced this pull request Mar 14, 2026
- 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

0