Pull to refresh

Создание Custom Scope в JEE и Spring

Reading time 12 min
Views 13K
Scope определяет жизненный цикл объекта. Например, java-бин (далее просто бин) определённый в RequestScope создается при получении http запроса и освобождается при завершении данного запроса. В JEE и в Spring есть возможность создавать свой собственный scope. Т.е. мы можем создавать объекты со своим собственным жизненным циклом — они будут создаваться по какому либо нашему событию и также уничтожаться. В JEE за это отвечает спецификация CDI (Context and Dependency Injection) и на самом деле там уже есть один подобный встроенный scope. Это ConversationScope. У нас есть API и аннотации для начала и окончания conversation. Если мы их не используем, то по-умолчанию ConversationScope ведет себя как RequestScope. Для отслеживания conversation каждого отдельного клиента используется специальный conversationId, который обычно добавляется как параметр http запроса. Но такой подход не работает для веб-сервисов. А в Spring вообще нет ничего подобного. Но в моей практике заказчик попросил сделать веб-сервис, который бы использовал одно и то же физическое соединение к внешней системе для нескольких последовательных вызовов. Также надо было хранить некоторое количество дополнительных данных. Т.е. надо было сохранять некое состояние (объект с соединением и данными) на определённый промежуток времени, такой аналог conversation scope для веб-сервиса. Можно, конечно, сохранить этот объект в Мар, где ключом будет наш аналог conversationId, а Мар положить в ServleContext и доставать это всё из методов веб-сервиса. Но это неудобно. Гораздо удобнее, когда сам сервер будет инжектить нам наш объект по заданному conversationId. Поэтому, сделаем свой scope, который будет работать с SOAP веб-сервисом. Сам по себе веб-сервис не может принадлежать какому-либо scope, но наш бин, который мы будем инжектить в веб-сервис, будет принадлежать нашему scope.

Создание CustomScope для Spring и JEE практически одинаково. Для примера рассмотрим создание следующего приложения: у нашего веб-сервиса будет метод, который активизирует наш scope и возвращает sessionId (аналог conversationId). Затем, используя данный id мы вызовем метод, который сохранит данные в нашем scope. Потом мы вызовем метод, который эти данные прочитает, а потом закроем scope. Архитектура в обоих случаях одинаковая:
  • Создается и регистрируется класс ответственный за создание бинов нашего scope.
  • Создается SOAP Handler, который перехватывает параметр sessionId и устанавливает состояние scope для текущего потока.
  • Создается веб-сервис, который содержит методы для активации и деактивации scope
.
В Spring для создания веб-сервиса будем использовать Apache CXF, чтобы было минимум отличий от JEE.

Создание класса контекста scope.


Контекст предназначен для генерации/хранения/деактивации бинов нашего scope. Каждая сессия в нашем скопе идентифицируется специальным id, который хранится в ThreadLocal переменной. Контекст читает этот id возвращает экземпляры бинов соответствующих текущей сессии, которые хранятся локально в объекте класса Map. Т.е. у каждого потока sessionId будет иметь свое значение и контекст будет возвращать соответствующие экземпляры бинов. Соответственно, контекст содержит методы для активации и деактивации сессии. Теперь об этом более подробно. Помимо методов для активации и декативации в JEE нам надо реализовать интерфейс javax.enterprise.context.spi.Context, в Spring — org.springframework.beans.factory.config.Scope. Эти интерфейсы похожи, поэтому и реализации тоже очень похожи. Для JEE сделаем класс WsContext, для Spring — WsScope. Они состоят из следующих частей:
Хранения бинов сессии

в JEE:

private static class InstanceInfo<T> {
    public CreationalContext<T> ctx;
    public T instance;
}
    
private Map<String, Map<Contextual, InstanceInfo>> instances = 
    new HashMap<SessionId,  Map<Contextual, InstanceInfo>>();

Здесь instances — это Map, где ключом является id сессии, а значанием Map бинов этой сессии. Но просто ссылки на бин нам недостаточно. При деактивации бина CDI надо знать контекст, в котором данный бин был создан, поэтому и исползуется класс InstanceInfo, в ктором ctx – контекст, а instance – бин. Ключом в Мар бинов является объект Contextual.
Contextual – это интерфейс, используемый CDI для создаения и удаления бинов. Грубо говоря, CDI опереирует не нашими конкретными бинами типа T, а конкретными реализациями Contextual (Bean, Decorator, Interceptor)
В Spring:

private Map<String, Map<String, Object>> instances = new HashMap<String, Map<String, Object>>();

Как видно, Spring оперирует объектами напрямую.

Установка текущей сессии.

Как уже говорилось выше, id текущей сессии хранится в ThreadLocal переменной. В Springи JEE это делается одинаково.
private final ThreadLocal<String> currentSessionId = new ThreadLocal<String>() {
        protected String initialValue() {
            return null;
        }
};

public String getCurrentSessionId() {
    return currentSessionId.get();
}

public void setCurrentSessionId(String currentSessionId) {
    this.currentSessionId.set(currentSessionId);
}


Активация сессии

Также одинаково для JEE и Spring. Здесь мы просто создаем пустую Map для id сессии.
public void activate(String sessionId) {
    Map<Contextual, InstanceInfo> map = new HashMap<Contextual, InstanceInfo>();
    instances.put(sessionId, map);
    this.currentSessionId.set(sessionId);
}

В JEE дополнительно требуется реализация метода для проверки активности контекста, JEE вызывает этот метод перед обращением к контексту:
@Override
public boolean isActive() {
    String id = currentSessionId.get();
    return instances.containsKey(id);
}


Деактивация сессии

В JEE

public void deactivate() {
    String id = currentSessionId.get();
    Map<Contextual, InstanceInfo> map = instances.get(id);
    if (map == null) {
        throw new RuntimeException("WsScope with id =" + id + " doesn't exist");
    }
    Set<Contextual> keySet = map.keySet();
    for (Contextual contextual : keySet) {
        InstanceInfo instanceInfo = map.get(contextual);
        contextual.destroy(instanceInfo.instance, instanceInfo.ctx);
    }
    currentSessionId.set(null);
    instances.remove(id);
}

Здесь мы просим JEE удалить все бины, которые были созданы в нашей сессии. Под удалением понимается вызов метода с аннотацией @PreDestroy и делание бина доступным для garbage collector. JEE гарантирует, что другие бины, которые были заинжекчены в наши, будут корректно удалены при необходимости.

В Spring

Всё примерно точно также:
public void deactivate() {
    String id = currentSessionId.get();
    Thread currentThread = Thread.currentThread();
    Map<String, Object> map = instances.get(id);
    if (map == null) {
        throw new RuntimeException("WsScope with id =" + id + " doesn't exist");
    }
    Map<String, Object> objectsMap = instances.get(id);
    Set<String> keySet = objectsMap.keySet();
    for (String name : keySet) {
        remove(name);
    }
    instances.remove(id);
    currentSessionId.set(null);
}

В отличии от JEE, в Spring нам надо реализовать метод remove для удаления бинов. Этот метод объявлен в интерфейсе Scope, но вызывать его мы должны сами.
public Object remove(String name) {
    String sessionId = currentSessionId.get();
    if (sessionId == null) {
        throw new RuntimeException("WsScope is inactive");
    }
    Map<String, Object> map = instances.get(sessionId);
    if (map == null) {
        throw new RuntimeException("WsScope is inactive");
    }
    Runnable runnable = destructionCollbacks.get(name);
    Thread t = new Thread(runnable);
    t.start();
    return map.remove(name);
}

destructionCallbacks определен следующим образом:
private Map<String, Runnable> destructionCollbacks = new HashMap<>();

Эта Мар инициализируется в другом методе из интерфейса Scope
public void registerDestructionCallback(String name, Runnable callback) {
    destructionCollbacks.put(name, callback);
}

Как я заметил, callback, который нам передает Spring, удаляет только объект, имя которого было передано в registerDestructionCallback. Объекты заинжекченные в данный объект, в отличии от JEE, не удаляются. Т.е. Надо быть осторожными с инжектом в бины из custom scope в Spring.

Создание бинов

В JEE

Для этого используются методы
public <T> T get(Contextual<T> contextual), и  
public <T> T get(Contextual<T> contextual, CreationalContext<T> creationalContext)

Первый используется для возвращения уже созданного объекта, сохраненного в кеше. Если этот метод вернул null, то вызывается второй, который уже производит создание нового экземпляра бина.
@Override
public <T> T get(Contextual<T> contextual) {
    Map<Contextual,InstanceInfo> map = instances.get(currentSessionId.get());
    if (map == null) {
        return null;
    }
    InstanceInfo<T> info = map.get(contextual);
    if (info == null) {
        return null;
    }
    return info.instance;
}

@Override
public <T> T get(Contextual<T> contextual, CreationalContext<T> creationalContext) {
    T instance = contextual.create(creationalContext);
    InstanceInfo<T> info = new InstanceInfo<T>();
    info.ctx = creationalContext;
    info.instance = instance;
    Map<Contextual, InstanceInfo> map = nstances.get(currentSessionId.get());
    if (map == null) {
        map= new HashMap<Contextual, Context.InstanceInfo>();
        instances.put(currentSessionId.get(), map);
    }
    map.put(contextual, info);
    return instance;
} 

В Spring

В Spring есть похожие методы get и resolveContextualObject. resolveContextualObject не упоминаются в документации Spring по созданию custom scope. Установка брейкпоинтов и запуск в дебаггере показала, что этот метод даже не вызывается. Гугл показал, что обычно этот метод не реализуется, т.е. возвращает null. Но мы всё равно его реализуем и вызовем сами из метода get. Это сделает get более читабельным.
public Object get(String name, ObjectFactory<?> objectFactory) {
    Object object = resolveContextualObject(name);
    if (object != null) {
        return object;
    }
    String sessionId = currentSessionId.get();
    if (sessionId == null) {
        throw new RuntimeException("WsScope is inactive");
    }
    Map<String, Object> map = instances.get(sessionId);
    if (map == null) {
        throw new RuntimeException("WsScope is inactive");
    }
    object = objectFactory.getObject();
    map.put(name, object);
    return object;
}

public Object resolveContextualObject(String name) {
    String sessionId = currentSessionId.get();
    if (sessionId == null) {
        return null;
    }
    Map<String, Object> map = instances.get(sessionId);
    if (map == null) {
        return null;
    }
    Object object = map.get(name);
    return object;
}

Также в org.springframework.beans.factory.config.Scope есть ещё один такой же невызываемый метод: public String getConversationId(). Этот метод опциональный, но в нашем случае, согласно javadoc, у нас есть всё необходимое для его реализации.
public String getConversationId() {
    return currentSessionId.get();
}


Определение scope

В JEE

В JEE нам нужна аннотация, которой мы будем помечать объекты, которые мы хотим создавать в нашем scope.
@Target({ElementType.TYPE, ElementType.METHOD, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@NormalScope
public @interface WsScope {
}

И ещё нам осталось связать наш контекст с нашим scope. Для этого в контексте есть специальный метод:
@Override
public Class<? extends Annotation> getScope() {
    return WsScope.class;
} 

В Spring

В Spring scope определяется просто именем, которое дается ему при регистрации, как это делается будет описано ниже.

Регистрация контекста (scope)


В JEE

В JEE контекст регистрируется при помощи механизма CDI Extension. Сначала надо создать класс, реализующий Extension и перекрыть метод
public void afterBeanDiscovery(@Observes AfterBeanDiscovery abd, BeanManager bm)

В нем контекст создается и регистрируется:
context = new WsContext();
abd.addContext(context);

Класс extension регистрируется в простом текстовом файле /META-INF/services/javax.enterprise.inject.spi.Extension. Надо просто прописать полное имя класса extension в этом файле.
Наш класс Extension полностью:

public class WsExtension implements Extension {
    private WsContext context;

    public WsContext getContext() {
        return context;
    }

    public void afterBeanDiscovery(@Observes AfterBeanDiscovery abd, BeanManager bm) {
        context = new WsContext();
        abd.addContext(context);
    }
}


В Spring

В Spring есть несколько способов регистрации scope. В нашем случае используем файл конфигурации
<bean class="org.springframework.beans.factory.config.CustomScopeConfigurer">
    <property name="scopes">
        <map>
            <entry key="WsScope">
                <bean class="com.dataart.customscope.spring.context.WsScope" />
            </entry>
        </map>
    </property>
</bean>

В данном случае наш контекст определен как спринговый бин со scope Singleton.

Сохранение ссылки на контекст


Чтобы вызывать методы активации и закрытия scope нам надо иметь ссылку на наш контекст.
В JEE

В JEE как видно из предыдущего пункта, мы сохранили ссылку на контекст в классе WsExtension. Этот класс можно инжектить в любой другой объект, хоть он и не принадлежит ни одному из встроенных scope. Но инжектить непосредственно WsContext удобнее чем Extension. Для этого сделаем класс Producer:
public class WsContextProducer {
    @Inject
    private WsExtension ext;
    
    @Produces
    public WsContext getContext() {
        return ext.getContext();
    }
}

Но наш класс контекста сам по себе удовлетворяет требованиям manged bean и JEE может инжектить его в другие бины со scope Default (при каждом инжекте будет создаваться новый экземпляр). Получилось, что мы сделали конфликт - CDI может создать WsScope двумя способами: default и через Producer. А нам надо инжектить наш контекст, который мы создали в экстеншене, т.е. через Producer. Поэтому нам надо сделать так, чтобы CDI не воспринимал наш контекст как бин. В JEE7 для этого есть аннотация @Vetoed. Т.е. наш контекст выглядит так:
@Vetoed
public class WsContext implements Context {...}

Теперь мы можем инжектить наш контекст куда хотим при помощи такого кода:
@Inject
private WsContext context; 

В Spring

Т.к. мы определили scope как спринговый бин, то мы можем инжектить его как обычно:
@Autowired
private WsScope scope;


Использование нашего scope


Веб-сервис, который хочет работать в режиме сессии передает id сессии в параметре ws-session-id. Все запросы от нашего веб-сервиса обрабатываются специальным хендлером, который читает данный id и устанавливает его в наш контекст для текущего потока. Т.е. для данного потока наш контекст становится активным. Если id нет, или это id не находится в нашем контексте (не был активирован), то при попытке получить объект из нашего контекста сервером будет выброшено исключение. Для активации id в контексте, нам надо вызвать метод activate() нашего контекста. Он сгенерирует id, активирует его и вернет клиенту. Для этого мы сделаем в веб-сервисе метод, который вызовет этот метод. Для деактивации сделаем аналогично с методом deactivate(). В веб-сервис мы инжектим сервис (простой бин WsService) который создан в нашем scope. Этот сервис и содержит состояние между различными вызовами методов веб-сервиса. Т.е. в зависимости от id сессии в наш веб-сервис будут попадать различные экземпляры сервиса, соответствующие данному id.
В JEE

@WsScope
public class WsService {
...
}

Код веб-сервиса:
@WebService()
@HandlerChain(file = "wshandler.xml", name = "")
public class WsScopeTest {
    private static int id = 0;

    @Inject
    private WsContext context;

    @Inject
    private WsService srv;
    
    @WebMethod()
    public String startWsScope() {
        String sessionId = String.valueOf(id++);
        context.activate(sessionId);
        return sessionId;
    }

    @WebMethod()
    public void endWsScope(@WebParam(name = "ws-session-id") String sessionId) {
        context.deactivate();
    }

    @WebMethod()
    public void setName(@WebParam(name = "ws-session-id") String sessionId,
        @WebParam(name = "name")String name) {
        srv.setName(name);
    }

    @WebMethod()
    public String sayHello(@WebParam(name = "ws-session-id") String sessionId) {
        return srv.hello();
    }
}

Код хэндлера:
public class WsCdiSoapHandler implements SOAPHandler<SOAPMessageContext> {
    private static final Logger LOGGER = Logger.getLogger(WsCdiSoapHandler.class.getName());

    @Inject
    private WsContext context;

    @Override
    public void close(MessageContext ctx) {
    }

    @Override
    public boolean handleFault(SOAPMessageContext ctx) {
        return true;
    }

    @Override
    public boolean handleMessage(SOAPMessageContext ctx) {
        Boolean outbound = (Boolean) ctx.get(MessageContext.MESSAGE_OUTBOUND_PROPERTY);
        SOAPMessage message = ctx.getMessage();
        SOAPBody soapBody;
        try {
            soapBody = message.getSOAPBody();
        } catch (SOAPException e) {
            e.printStackTrace();
            return false;
        }
        String methodName = null;
        NodeList nodes = soapBody.getChildNodes();
        methodName = findMethodName(methodName, nodes);
        if (outbound) {
            LOGGER.fine("[OUT] " + methodName.replace("Response", ""));
            return true;
        }
        LOGGER.fine("[IN] " + methodName);
        String sessionId = findSessionId(nodes);
        context.setCurrentSessionId(sessionId);
        LOGGER.fine("Handler. Id=" + sessionId);
        return true;
    }

    private String findMethodName(String methodName, NodeList nodes) {
        for (int i = 0; i < nodes.getLength(); i++) {
            Node node = nodes.item(i);
            if (Node.ELEMENT_NODE == node.getNodeType()) {
                methodName = node.getLocalName();
            }
        }
        return methodName;
    }

    private String findSessionId(NodeList nodes) {
        for (int i = 0; i < nodes.getLength(); i++) {
            Node node = nodes.item(i);
            if ("ws-session-id".equals(node.getLocalName())) {
                Node firstChild = node.getFirstChild();
                if (firstChild == null) {
                    return null;
                }
                return firstChild.getNodeValue();
            }
            NodeList childNodes = node.getChildNodes();
            String id = findSessionId(childNodes);
            if (id != null) {
                return id;
            }
        }
        return null;
    }

    @Override
    public Set<QName> getHeaders() {
        return null;
    }
}

В Spring

В Spring код практически такой же. Только вместо @Inject используется @Autowired, по другому определяется сервис и по-другому подключается веб-сервис и хендлер.
Определение сервиса:
@Service
@Scope(value = "WsScope", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class WsService {
...
}

Обратите внимание - proxyMode = ScopedProxyMode.TARGET_CLASS обязательно! Дело в том, что нам нельзя инжектить прямую ссылку на наш сервис, т.к. экземпляр веб-сервиса один, а экземпляров сервиса много. И нам нужен прокси объект, через которой мы будем получать ссылку на соответсвующий сервис.
Регистрация веб-сервиса и хендлера:
<jaxws:endpoint id="testWsService" implementor="#testWS" 	address="/WsTest" publish="true">
		<jaxws:handlers>
			<bean class="com.dataart.customscope.spring.context.WsSoapHandler"></bean>
		</jaxws:handlers>
</jaxws:endpoint>
<bean id="testWS" class="com.dataart.customscope.spring.testapp.WsTest"></bean>

Благодаря тому, что сервис и хендлер определены как спринговые бины, @Autowired в них работает.

Заключение



Как мы можем видеть создать custom scope в JEE и Spring достаточно просто и практически одинаково. Соответсвующие интерфейсы во многом сходны. Только в JEE, на мой взгляд, реализация более целостная - все методы понятно для чего и понятно когда вызываются, и более надёжная - JEE обеспецивает удаление всей иерархии заинжекченных объектов.
Tags:
Hubs:
+12
Comments 3
Comments Comments 3

Articles