1
\$\begingroup\$

As a part of guice-context-scopes library for building custom Guice Scopes, I have a class named InjectionContext (code below), that stores scoped objects in a Map indexed by com.google.inject.Keys.
On top of this library I have a library that provides websocket Scopes (and also properly re-implements Servlet Scopes), that has a class HttpSessionContext (also attached below) extending InjectionContext, that basically stores objects scoped to its HttpSession.
In a clustered environment, as per Servlet spec, attributes of an HttpSession that are Serializable, must be replicated/persisted when the given HttpSession is replicated/persisted. My design goal was to provide similar behavior for objects scoped to HttpSessions (stored in their HttpSessionContexts). As com.google.inject.Key is not Serializable and scoped objects also may not all be so, I've implemented custom writeObject/readObject methods in InjectionContext, that filter scoped objects and transform/restore the filtered state into/from a Serializable form.

To complicate things further, the Servlet spec (in the same section as above) allows custom serialization mechanisms to be used, that are not required to call writeObject/readObject, so I've provided prepareForSerialization/restoreAfterDeserialization() methods, that may be called via a HttpSessionActivationListener in case of custom serialization mechanisms.

This is the first time that I've ever had to "actively" deal with serialization (I mean in another way than assuming, that it will just work after putting implements Serializable and serialVersionUIDs wherever necessary), so I would be grateful if someone had a look at this to verify if I'm doing this correctly. So far it seems to work ok on Jetty (that uses standard Java Serialization) and I've written some simple integration tests to ensure this (also attached below).

This class comes from the latest 9.5 version of guice-context-scopes:

// Copyright 2021 Piotr Morgwai Kotarbinski, Licensed under the Apache License, Version 2.0
package pl.morgwai.base.guice.scopes;

import java.io.*;
import java.lang.annotation.Annotation;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

import com.google.inject.*;

import static pl.morgwai.base.guice.scopes.InjectionContext.Null.NULL;



/**
 * Stores objects {@link com.google.inject.Scope scoped} to the context of some
 * processing/call/request/session, such as an RPC, a servlet request processing, a session
 * combining several calls etc.
 * Each concrete subclass corresponds to a specific type of events and each instance corresponds to
 * a single such event. For example an instance of {@code HttpRequestContext} may correspond to the
 * processing of a single HTTP request. Creation of instances must be hooked at the beginning of a
 * given processing: for example in Java Servlet environment, a {@code HttpRequestContext} may be
 * created in a {@code Filter}.
 * <p>
 * Note: most {@code Context} classes should rather extend {@link TrackableContext} subclass instead
 * of this one. The main exception are {@code Context} types that are
 * {@link InducedContextScope induced by other Contexts}.</p>
 * <p>
 * Subclasses usually add properties and methods specific to their types, like
 * their call's arguments, a reference to their event objects etc.</p>
 * <p>
 * Multiple threads may run within the same {@code Context} and access or remove the same scoped
 * objects as the state is backed by a {@link ConcurrentMap}. Nevertheless, concurrently accessed
 * scoped objects themself must be thread-safe or accessing them must be properly synchronized.</p>
 * <p>
 * During the standard {@link Serializable Java serialization}, non-serializable scoped objects will
 * be filtered out and the remaining part will be properly serialized.<br/>
 * Methods {@link #prepareForSerialization()} and {@link #restoreAfterDeserialization()} are
 * provided for other serialization mechanisms.<br/>
 * The serialization is <b>not</b> thread-safe, so a {@code Context} that is being serialized must
 * not be accessed by other threads in any way during the process.</p>
 */
public abstract class InjectionContext implements Serializable {



    private transient ConcurrentMap<Key<?>, Object> scopedObjects = new ConcurrentHashMap<>();



    /**
     * Removes the object given by {@code key} from this {@code Context}.
     * This forces production of a new instance during the next
     * {@link #produceIfAbsent(Key, Provider) provisioning} within the
     * {@link ContextScope Scope of this Context}. This is useful if the currently stored instance
     * is no longer usable (for example a timed-out connection, expired token, etc).<br/>
     * If there's no object stored under {@code key} in this {@code Context}, this method has no
     * effect.
     * <p>
     * <b>Note:</b> If multiple threads run within the same context, care must be taken to prevent
     * some of them from retaining the old stale instances.</p>
     */
    public void removeScopedObject(Key<?> key) {
        scopedObjects.remove(key);
    }



    /**
     * Provides the scoped object given by {@code key}.
     * If there already is an instance scoped to this {@code Context}, it is returned immediately.
     * Otherwise, a new instance is first obtained from {@code producer}, stored for subsequent
     * calls and returned.
     */
    protected <T> T produceIfAbsent(Key<T> key, Provider<T> producer) {
        final var stored = scopedObjects.computeIfAbsent(
            key,
            (ignored) -> {
                final var fresh = producer.get();
                return fresh == null ? NULL : fresh;
            }
        );
        @SuppressWarnings("unchecked")
        final var result = (T) (stored == NULL ? null : stored);
        return result;
    }

    enum Null { NULL };



    /**
     * Filled with the {@link Serializable} part of {@link #scopedObjects} content right before
     * serialization occurs.
     */
    private ArrayList<SerializableScopedObjectEntry> serializableScopedObjectEntries;



    /**
     * Data of {@link Key} and the corresponding object from {@link #scopedObjects} in a
     * {@link Serializable} form.
     */
    static class SerializableScopedObjectEntry implements Serializable {

        final Type type;
        final String annotationTypeName;
        final Annotation annotation;
        final Serializable scopedObject;

        SerializableScopedObjectEntry(
            Type type,
            String annotationTypeName,
            Annotation annotation,
            Serializable scopedObject
        ) {
            this.type = type;
            this.annotationTypeName = annotationTypeName;
            this.annotation = annotation;
            this.scopedObject = scopedObject;
        }

        private static final long serialVersionUID = 7633750187480552805L;
    }



    /**
     * Picks {@link Serializable} objects scoped to this {@code Context} from its {@code transient}
     * state and stores them into a fully {@link Serializable} private {@code List} of entries.
     * This method is called automatically during the standard Java serialization. It may be called
     * manually if some other serialization mechanism is used.
     * <p>
     * This method is safe to call several times between the most recent modification of this
     * {@code Context}'s state and the actual serialization in case it is unknown whether the
     * standard Java serialization or some other mechanism will be used.</p>
     * <p>
     * It must be ensured, that no other {@code Threads} may access a given {@code Context} between
     * the call to this method and the actual serialization.</p>
     */
    protected void prepareForSerialization() {
        if (serializationTestBuffer == null) serializationTestBuffer = new ByteArrayOutputStream();
        final var serializableScopedObjectEntries =
                new ArrayList<SerializableScopedObjectEntry>(scopedObjects.size());
        try (
            final var serializationTestStream = new ObjectOutputStream(serializationTestBuffer);
        ) {
            for (var scopedObjectEntry: scopedObjects.entrySet()) {
                final var key = scopedObjectEntry.getKey();
                final var scopedObject = scopedObjectEntry.getValue();

                // omit entries of non-Serializable objects
                if ( !(scopedObject instanceof Serializable)) continue;

                // test if the object actually serializes
                try {
                    serializationTestStream.writeObject(scopedObject);
                } catch (IOException e) {
                    continue;
                }

                // add SerializableScopedObjectEntry for the given scopedObjectEntry
                serializableScopedObjectEntries.add(new SerializableScopedObjectEntry(
                    key.getTypeLiteral().getType(),
                    key.getAnnotationType() != null ? key.getAnnotationType().getName() : null,
                    key.getAnnotation(),
                    (Serializable) scopedObject
                ));
            }
        } catch (IOException ignored) {
        } finally {
            serializationTestBuffer.reset();  // reuse the buffer during the next call
        }
        this.serializableScopedObjectEntries = serializableScopedObjectEntries;
    }

    transient ByteArrayOutputStream serializationTestBuffer;



    private void writeObject(ObjectOutputStream serializedObjects) throws IOException {
        prepareForSerialization();
        serializedObjects.defaultWriteObject();
    }



    /**
     * Restores the state of this {@code Context} from the deserialized data in the private
     * {@code List} that was filled before serialization with {@link #prepareForSerialization()}.
     * This method is called automatically during the standard Java deserialization. It may be
     * called manually if some other deserialization mechanism is used.
     * <p>
     * This method is idempotent between the actual deserialization and the next modification of
     * this {@code Context}'s state or an invocation of {@link #prepareForSerialization()}, so it is
     * safe to call it manually right after deserialization if it is unknown whether the standard
     * Java deserialization or some other mechanism was used.</p>
     */
    protected void restoreAfterDeserialization() throws ClassNotFoundException {
        if (serializableScopedObjectEntries == null) return;
        scopedObjects = new ConcurrentHashMap<>();
        for (var deserializedEntry: serializableScopedObjectEntries) {
            scopedObjects.put(constructKey(deserializedEntry), deserializedEntry.scopedObject);
        }
        serializableScopedObjectEntries = null;
    }

    static Key<?> constructKey(SerializableScopedObjectEntry deserializedEntry)
            throws ClassNotFoundException {
        final var type = deserializedEntry.type;
        final var annotationTypeName = deserializedEntry.annotationTypeName;
        final var annotation = deserializedEntry.annotation;
        final Key<?> key;
        if (annotationTypeName != null && annotation == null) {
            @SuppressWarnings("unchecked")
            final var annotationClass =
                    (Class<? extends Annotation>) Class.forName(annotationTypeName);
            key = Key.get(type, annotationClass);
        } else if (annotation != null) {
            key = Key.get(type, annotation);
        } else {
            key = Key.get(type);
        }
        return key;
    }



    private void readObject(ObjectInputStream serializedObjects)
            throws IOException, ClassNotFoundException {
        serializedObjects.defaultReadObject();
        restoreAfterDeserialization();
    }



    private static final long serialVersionUID = 2497090317063335154L;
}

Here is the extending HttpSessionContext from version 14.3 of the websocket&servlet Scopes lib (Jakarta flavour):

// Copyright 2021 Piotr Morgwai Kotarbinski, Licensed under the Apache License, Version 2.0
package pl.morgwai.base.servlet.guice.scopes;

import jakarta.servlet.ServletContext;
import jakarta.servlet.ServletContextEvent;
import jakarta.servlet.http.*;

import pl.morgwai.base.guice.scopes.InjectionContext;



/**
 * Context of an {@link HttpSession}.
 * <p>
 * <b>NOTE:</b> If the servlet container being used, uses mechanism other than the standard
 * {@link java.io.Serializable Java Serialization} to persist/replicate {@link HttpSession}s, then
 * a {@link ServletContext#setInitParameter(String, String) deployment init-param} named as the
 * value of {@link #CUSTOM_SERIALIZATION_PARAM}, must be set to {@code "true"} either in
 * {@code web.xml} or programmatically before any request is served (for example in
 * {@link jakarta.servlet.ServletContextListener#contextInitialized(ServletContextEvent)}).</p>
 * @see ServletModule#httpSessionScope corresponding Scope
 */
public class HttpSessionContext extends InjectionContext implements HttpSessionActivationListener {



    public HttpSession getSession() { return session; }
    private transient HttpSession session;

    /** See {@link #CUSTOM_SERIALIZATION_PARAM}. */
    public static final String CUSTOM_SERIALIZATION_PARAM_SUFFIX = ".customSerialization";
    /**
     * Name of the {@link ServletContext#setInitParameter(String, String) deployment init-param}
     * indicating that the servlet container uses serialization mechanism other than the
     * {@link java.io.Serializable standard Java Serialization} to persist/replicate
     * {@link HttpSession}s.
     * The value is a concatenation of
     * {@link Class#getName() the fully qualified name of this class} and
     * {@value #CUSTOM_SERIALIZATION_PARAM_SUFFIX}.
     */
    public static final String CUSTOM_SERIALIZATION_PARAM =
            HttpSessionContext.class.getName() + CUSTOM_SERIALIZATION_PARAM_SUFFIX;
    final boolean customSerialization;



    HttpSessionContext(HttpSession session) {
        this.session = session;
        this.customSerialization = Boolean.parseBoolean(
                session.getServletContext().getInitParameter(CUSTOM_SERIALIZATION_PARAM));
    }



    /**
     * Creates {@link HttpSessionContext}s for newly created {@link HttpSession}s.
     * Registered in {@link GuiceServletContextListener}.
     */
    public static class SessionContextCreator implements HttpSessionListener {

        @Override
        public void sessionCreated(HttpSessionEvent creation) {
            final var session = creation.getSession();
            session.setAttribute(
                HttpSessionContext.class.getName(),
                new HttpSessionContext(session)
            );
        }
    }



    /** Returns the {@code Context} of {@code session}. */
    public static HttpSessionContext of(HttpSession session) {
        return (HttpSessionContext) session.getAttribute(HttpSessionContext.class.getName());
    }



    /**
     * Calls {@link #prepareForSerialization()} if
     * {@link jakarta.servlet.ServletContext#getInitParameter(String) init-param} named
     * {@link #CUSTOM_SERIALIZATION_PARAM} is {@code "true"}.
     */
    @Override
    public void sessionWillPassivate(HttpSessionEvent serialization) {
        if (customSerialization) prepareForSerialization();
    }



    /**
     * Calls {@link #restoreAfterDeserialization()} if
     * {@link jakarta.servlet.ServletContext#getInitParameter(String) init-param} named
     * {@link #CUSTOM_SERIALIZATION_PARAM} is {@code true}.
     */
    @Override
    public void sessionDidActivate(HttpSessionEvent deserialization) {
        if (session != null) return;
        session = deserialization.getSession();
        if (customSerialization) {
            try {
                restoreAfterDeserialization();
            } catch (ClassNotFoundException e) {
                throw new RuntimeException(e);
            }
        }
    }



    private static final long serialVersionUID = -3482947070671038422L;
}

Here is the mentioned integration test, that creates 2 Jetty nodes, scopes some stuff to HttpSessionContext and checks if it's preserved after moving the HttpSession between nodes (also from version 14.3) :

// Copyright 2023 Piotr Morgwai Kotarbinski, Licensed under the Apache License, Version 2.0
package pl.morgwai.base.servlet.guice.scopes.tests;

import java.io.File;
import java.net.CookieManager;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse.BodyHandlers;
import java.time.Duration;
import java.util.Map;
import java.util.Properties;
import java.util.logging.ConsoleHandler;

import org.eclipse.jetty.server.session.*;
import org.eclipse.jetty.server.session.JDBCSessionDataStore.SessionTableSchema;
import org.junit.*;
import org.junit.rules.TemporaryFolder;
import pl.morgwai.base.servlet.guice.scopes.tests.jetty.JettyNode;

import static java.util.logging.Level.*;

import static org.junit.Assert.assertEquals;
import static pl.morgwai.base.jul.JulConfigurator.*;
import static pl.morgwai.base.servlet.guice.scopes.tests.jetty.JettyNode.*;



public class HttpSessionContextReplicationTests {



    static final String NODE1_ID = "node1";
    static final String NODE2_ID = "node2";
    JettyNode node1;
    JettyNode node2;

    static final String URL_PREFIX = "http://localhost:";
    CookieManager cookieManager;
    HttpClient httpClient;

    @Rule
    public TemporaryFolder temporaryFolder = new TemporaryFolder();



    @Before
    public void setup() {
        cookieManager = new CookieManager();
        httpClient = HttpClient.newBuilder().cookieHandler(cookieManager).build();
    }



    @After
    public void shutdown() throws Exception {
        if (node1 != null) {
            if ( !node1.isStopped()) node1.stop();
            node1.join();
            node1.destroy();
        }
        if (node2 != null) {
            if ( !node2.isStopped()) node2.stop();
            node2.join();
            node2.destroy();
        }
    }



    void testHttpSessionContextReplication(
        SessionDataStore store1,
        SessionDataStore store2,
        boolean startNode2rightAway,
        boolean customSerialization
    ) throws Exception {
        node1 = new JettyNode(0, NODE1_ID, store1, customSerialization);
        if (startNode2rightAway) {
            // FileSessionDataStore's files may be accessed by only 1 node process at a time, so
            // the start of node2 needs to be delayed in such case, otherwise start it right away
            node2 = new JettyNode(0, NODE2_ID, store2, customSerialization);
        }
        final var url1 = URL_PREFIX + node1.getPort() + APP_PATH + NODE_INFO_SERVLET_PATH;
        final var request1 = HttpRequest.newBuilder(URI.create(url1))
            .GET()
            .timeout(Duration.ofSeconds(2))
            .build();
        final var responseNode1 = new Properties();
        responseNode1.load(httpClient.send(request1, BodyHandlers.ofInputStream()).body());
        node1.stop();

        if (node2 == null) node2 = new JettyNode(0, NODE2_ID, store2, customSerialization);
        final var url2 = URL_PREFIX + node2.getPort() + APP_PATH + NODE_INFO_SERVLET_PATH;
        final var request2 = HttpRequest.newBuilder(URI.create(url2))
            .GET()
            .timeout(Duration.ofSeconds(2))
            .build();
        final var responseNode2 = new Properties();
        responseNode2.load(httpClient.send(request2, BodyHandlers.ofInputStream()).body());
        assertEquals(
            "session should be replicated between nodes",
            responseNode1.getProperty(SESSION_ID_PROPERTY),
            responseNode2.getProperty(SESSION_ID_PROPERTY)
        );
        assertEquals("serializable session attribute should be replicated",
                NODE1_ID, responseNode2.getProperty(SESSION_NODE_ID_PROPERTY));
        assertEquals("serializable session-scoped object should be replicated",
                NODE1_ID, responseNode2.getProperty(CONTEXT_NODE_ID_PROPERTY));
        assertEquals("non-serializable session-scoped object should NOT be replicated",
                NODE2_ID, responseNode2.getProperty(NON_SERIALIZABLE_CONTEXT_NODE_ID_PROPERTY));
    }



    @Test
    public void testHttpSessionContextReplicationWithFileStoreAndStandardSerialization()
            throws Exception {
        testHttpSessionContextReplicationWithFileStore(false);
    }

    @Test
    public void testHttpSessionContextReplicationWithFileStoreAndCustomSerialization()
            throws Exception {
        testHttpSessionContextReplicationWithFileStore(true);
    }

    void testHttpSessionContextReplicationWithFileStore(boolean customSerialization)
            throws Exception {
        final var sessionFolder = temporaryFolder.getRoot();
        testHttpSessionContextReplication(
            createFileSessionStore(sessionFolder),
            createFileSessionStore(sessionFolder),
            false,
            customSerialization
        );
    }

    SessionDataStore createFileSessionStore(File sessionFolder) {
        final var sessionStore = new FileSessionDataStore();
        sessionStore.setStoreDir(sessionFolder);
        sessionStore.setGracePeriodSec(10);
        sessionStore.setSavePeriodSec(0);
        return sessionStore;
    }



    @Test
    public void testHttpSessionContextReplicationWithJdbcStoreAndStandardSerialization()
            throws Exception {
        testHttpSessionContextReplicationWithJdbcStore(false);
    }

    @Test
    public void testHttpSessionContextReplicationWithJdbcStoreAndCustomSerialization()
            throws Exception {
        testHttpSessionContextReplicationWithJdbcStore(true);
    }

    void testHttpSessionContextReplicationWithJdbcStore(boolean customSerialization)
            throws Exception {
        final var adaptor = new DatabaseAdaptor();
        adaptor.setDriverInfo(
            org.h2.Driver.class.getName(),
            "jdbc:h2:mem:servlet-scopes-test-sessions;DB_CLOSE_DELAY=-1"
        );
        testHttpSessionContextReplication(
            createJdbcSessionStore(adaptor),
            createJdbcSessionStore(adaptor),
            true,
            customSerialization
        );
    }

    SessionDataStore createJdbcSessionStore(DatabaseAdaptor adaptor) {
        final var schema = new SessionTableSchema();
        final var sessionStore = new JDBCSessionDataStore();
        sessionStore.setDatabaseAdaptor(adaptor);
        sessionStore.setSessionTableSchema(schema);
        sessionStore.setGracePeriodSec(10);
        sessionStore.setSavePeriodSec(0);
        return sessionStore;
    }



    @BeforeClass
    public static void setupLogging() {
        addOrReplaceLoggingConfigProperties(Map.of(
            "pl.morgwai.level", SEVERE.toString(),
            LEVEL_SUFFIX, WARNING.toString(),
            ConsoleHandler.class.getName() + LEVEL_SUFFIX, FINEST.toString()
        ));
        overrideLogLevelsWithSystemProperties("pl.morgwai");
    }
}

Finally, the test JettyNode used by the above tests (v 14.3 consistently). It has just 1 Servlet implemented by the inner class NodeInfoServlet. NodeInfoServlet obtains from the HttpSession's attribute and from its HttpSessionContext, Strings that are output to the HttpServletResponse in Properties format together with the id of the HttpSession. If no Strings are stored yet, the Servlet stores there the id of the JettyNode it runs on (so the first node accessed by the above test stores its id and then the above test verifies if the second node also serves the ids of the first node).

// Copyright 2023 Piotr Morgwai Kotarbinski, Licensed under the Apache License, Version 2.0
package pl.morgwai.base.servlet.guice.scopes.tests.jetty;

import java.io.File;
import java.io.IOException;
import java.net.InetAddress;
import java.util.*;

import jakarta.servlet.http.*;

import com.google.inject.*;
import com.google.inject.Module;
import com.google.inject.name.Named;
import com.google.inject.name.Names;
import org.eclipse.jetty.server.NetworkConnector;
import org.eclipse.jetty.server.session.*;
import org.eclipse.jetty.servlet.ServletContextHandler;
import pl.morgwai.base.servlet.guice.scopes.GuiceServletContextListener;
import pl.morgwai.base.servlet.guice.scopes.HttpSessionContext;



/**
 * Jetty server using {@link SessionDataStore HttpSession replication}, with a
 * {@link NodeInfoServlet Servlet}, that outputs some data
 * {@link HttpSession#setAttribute(String, Object) stored} and {@link HttpSessionContext scoped} to
 * {@link HttpSession}s.
 */
public class JettyNode extends org.eclipse.jetty.server.Server {



    public static final String APP_PATH = "/test";
    public static final String NODE_INFO_SERVLET_PATH = "/nodeInfo";
    public static final String NODE_ID_ATTRIBUTE = "nodeId";

    public static final String SESSION_ID_PROPERTY = "sessionId";
    public static final String SESSION_NODE_ID_PROPERTY = "sessionStoredNodeId";
    public static final String CONTEXT_NODE_ID_PROPERTY = "contextStoredNodeId";
    public static final String NON_SERIALIZABLE_CONTEXT_NODE_ID_PROPERTY =
            "nonSerializableContextStoredNodeId";

    public int getPort() { return port; }
    final int port;



    public JettyNode(
        int port,
        String nodeId,
        SessionDataStore sessionStore,
        boolean customSerialization
    ) throws Exception {
        super(port);
        new DefaultSessionIdManager(this).setWorkerName(nodeId);

        final var testAppHandler = new ServletContextHandler(ServletContextHandler.SESSIONS);
        testAppHandler.setDisplayName("testApp");
        testAppHandler.setContextPath(APP_PATH);
        testAppHandler.setInitParameter(NODE_ID_ATTRIBUTE, nodeId);
        testAppHandler.setInitParameter(
            HttpSessionContext.CUSTOM_SERIALIZATION_PARAM,
            String.valueOf(customSerialization)
        );
        testAppHandler.addEventListener(new ServletContextListener());
        setHandler(testAppHandler);

        final var sessionCache = new DefaultSessionCache(testAppHandler.getSessionHandler());
        sessionCache.setEvictionPolicy(60);
        sessionCache.setFlushOnResponseCommit(true);
        sessionCache.setSessionDataStore(sessionStore);
        testAppHandler.getSessionHandler().setSessionCache(sessionCache);

        start();
        this.port = Arrays.stream(getConnectors())
            .filter(NetworkConnector.class::isInstance)
            .map(NetworkConnector.class::cast)
            .map(NetworkConnector::getLocalPort)
            .findFirst()
            .orElseThrow();
    }



    public static class ServletContextListener extends GuiceServletContextListener {



        @Override protected LinkedList<Module> configureInjections() {
            final var modules = new LinkedList<Module>();
            modules.add((binder) -> {
                binder.bind(NonSerializableObject.class)
                    .toProvider(() -> new NonSerializableObject(
                            appDeployment.getInitParameter(NODE_ID_ATTRIBUTE)))
                    .in(httpSessionScope);
                binder.bind(String.class)
                    .annotatedWith(Names.named(NODE_ID_ATTRIBUTE))
                    .toProvider(() -> appDeployment.getInitParameter(NODE_ID_ATTRIBUTE))
                    .in(httpSessionScope);
            });
            return modules;
        }



        @Override protected void configureServletsFiltersEndpoints() throws Exception {
            addServlet(
                NodeInfoServlet.class.getSimpleName(),
                NodeInfoServlet.class,
                NODE_INFO_SERVLET_PATH
            );
        }
    }



    public static class NonSerializableObject {
        public final String value;
        public NonSerializableObject(String value) { this.value = value; }
    }



    /**
     * {@link #doGet(HttpServletRequest, HttpServletResponse) GET} outputs to
     * {@link HttpServletResponse} in
     * {@link Properties#store(java.io.OutputStream, String) Properties format} the following data:
     * <ul>
     *   <li>{@link HttpSession#getId()} at {@link #SESSION_ID_PROPERTY}</li>
     *   <li>
     *     {@link #NODE_ID_ATTRIBUTE}
     *     {@link HttpSession#getAttribute(String) session attribuute} at
     *     {@link #SESSION_NODE_ID_PROPERTY}.<br/>
     *     If the {@link HttpSession#isNew() HttpSession is new}, then {@code "null"} is output and
     *     the {@link HttpSession#setAttribute(String, Object) attribute is set} for subsequent
     *     requests to the value of {@link #NODE_ID_ATTRIBUTE}
     *     {@link jakarta.servlet.ServletContext#getInitParameter(String) deployment init-param}
     *     containing the id of the {@link JettyNode} serving the current request.
     *   </li>
     *   <li>
     *     {@link HttpSessionContext HttpSession-scoped} value of {@link #NODE_ID_ATTRIBUTE}
     *     {@link jakarta.servlet.ServletContext#getInitParameter(String) deployment init-param}
     *     at {@link #CONTEXT_NODE_ID_PROPERTY}.
     *   </li>
     *   <li>
     *     {@link HttpSessionContext HttpSession-scoped} value of {@link #NODE_ID_ATTRIBUTE}
     *     {@link jakarta.servlet.ServletContext#getInitParameter(String) deployment init-param}
     *     wrapped with a {@link NonSerializableObject} at
     *     {@link #NON_SERIALIZABLE_CONTEXT_NODE_ID_PROPERTY}.
     *   </li>
     * </ul>
     */
    public static class NodeInfoServlet extends HttpServlet {

        @Inject @Named(NODE_ID_ATTRIBUTE) Provider<String> nodeIdProvider;
        @Inject Provider<NonSerializableObject> nonSerializableNodeIdProvider;



        @Override protected void doGet(HttpServletRequest request, HttpServletResponse response)
                throws IOException {
            final var session = request.getSession();
            final var nodeId = request.getServletContext().getInitParameter(NODE_ID_ATTRIBUTE);
            final String sessionStoredNodeId;
            if (session.isNew()) {
                sessionStoredNodeId = null;
                session.setAttribute(NODE_ID_ATTRIBUTE, nodeId);
            } else {
                sessionStoredNodeId = (String) session.getAttribute(NODE_ID_ATTRIBUTE);
            }
            final var outputData = new Properties();
            outputData.setProperty(SESSION_ID_PROPERTY, session.getId());
            outputData.setProperty(SESSION_NODE_ID_PROPERTY, String.valueOf(sessionStoredNodeId));
            outputData.setProperty(CONTEXT_NODE_ID_PROPERTY, nodeIdProvider.get());
            outputData.setProperty(
                NON_SERIALIZABLE_CONTEXT_NODE_ID_PROPERTY,
                nonSerializableNodeIdProvider.get().value
            );
            try (
                final var output = response.getOutputStream();
            ) {
                outputData.store(output, null);
            }
        }
    }



    public static void main(String[] args) throws Exception {
        int port;
        try {
            port = Integer.parseInt(args[0]);
        } catch (Exception e) {
            port = DEFAULT_PORT;
        }

        final var nodeId = (args.length > 2)
                ? args[2]
                : InetAddress.getLocalHost().getHostName() + (
                    (port != 0)
                            ? ":" + port
                            : "_pid" + ProcessHandle.current().pid()
                );

        final var sessionFolder = (args.length > 2)
                ? args[2] : ("/tmp/" + JettyNode.class.getPackageName() + ".sessions");
        final var sessionStore = new FileSessionDataStore();
        sessionStore.setStoreDir(new File(sessionFolder));
        sessionStore.setGracePeriodSec(10);
        sessionStore.setSavePeriodSec(0);

        final var customSerialization = args.length > 3 && Boolean.parseBoolean(args[3]);

        final var node = new JettyNode(port, nodeId, sessionStore, customSerialization);
        node.setStopAtShutdown(true);
        node.join();
        node.destroy();
        System.out.println("exiting, bye!");
    }

    public static final int DEFAULT_PORT = 8080;
}
\$\endgroup\$

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.