image

Для создания изоморфных приложений на React обычно используется Node.js в качестве серверной части. Но, если сервер пишется на Java, то не стоит отказываться от изоморфного приложения: в Java входит встроенный javascript движок (Nashorn), который вполне справится с серверным рендерингом HTML с помощью React.

Код приложения, демонстрирующего серверный рендеринг React с сервером на Java, находится на GitHub. В статье буду рассмотрены:

  • Сервер на Java в стиле микросервиса на основе Netty и JAX-RS (в реализации Resteasy) для обработки web-запросов, с возможностью запуска в Docker.
  • Dependency Injection с использованием библиотеки CDI (в реализации Weld SE).
  • Сборка javascript бандла с помощью Webpack 2.
  • Настройка редеринга HTML на сервере с помощью React.
  • Запуск отладки с поддержкой «горячей» перезагрузки страниц и стилей с использованием Webpack dev server.

Сервер на Java


Рассмотрим создание сервера на Java в стиле микросервиса (самодостаточный запускаемый jar, не требующий использования каких-либо сервлет-контейнеров). В качестве библиотеки для управления зависимостями будем использовать стандарт CDI (Contexts and Dependency Injection), который пришел из мира Java EE, но вполне может использоваться в приложениях Java SE. Реализация CDI — Weld SE — это мощная и отлично документированная библиотека для управления зависимостями. Для CDI существует множество биндингов к другим библиотекам, например, в приложении используются CDI биндинги для JAX-RS и Netty. Достаточно в каталоге src/main/resources/META-INF создать файл beans.xml (декларация, что этот модуль поддерживает CDI), разметить классы стандартными атрибутами, инициализировать контейнер и можно инжектить зависимости. Классы, помеченные специальными аннотациями зарегистрируются автоматически (доступна и ручная регистрация).

// Стартовый метод.

public static void main(String[] args) {
    // Лог JUL переводится на логирование в SLF4J.
    SLF4JBridgeHandler.removeHandlersForRootLogger();
    SLF4JBridgeHandler.install();
     
    LOG.info("Start application");
     
    // Создание CDI контейнера http://weld.cdi-spec.org/
    final Weld weld = new Weld();
    // Завершаем сами.
    weld.property(Weld.SHUTDOWN_HOOK_SYSTEM_PROPERTY, false);      
    final WeldContainer container = weld.initialize();
     
    // Создание Netty сервера для обслуживания запросов через JAX-RS, который работает с CDI контейнером.
    final CdiNettyJaxrsServer nettyServer = new CdiNettyJaxrsServer();
     
    ...............
     
    // Запуск web сервера.
    nettyServer.start();
     
    ..............
     
    // Ожидание сигнала TERM для корректного завершения.
    try {
        final CountDownLatch shutdownSignal = new CountDownLatch(1);
        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            shutdownSignal.countDown();
        }));       
 
        try {
            shutdownSignal.await();
        } catch (InterruptedException e) {
        }  
    } finally {        
        // Останов сервера и CDI контейнера.
        nettyServer.stop();
        container.shutdown();
         
        LOG.info("Application shutdown");
         
        SLF4JBridgeHandler.uninstall();
    }
}

// Класс сервиса, который доступен для "впрыскивания" в другие классы

@ApplicationScoped
public class IncrementService {
         
    ..............
}

// Подключение зависимостей

@NoCache
@Path("/")
@RequestScoped
@Produces(MediaType.TEXT_HTML + ";charset=utf-8")
public class RootResource {
 
    /**
     * Подключение зависимости {@link IncrementService}.
     */
    @Inject
    private IncrementService incrementService;
     
    ..............
}

Для тестирования классов с CDI зависимостями используется расширение для JUnit от Arquillian.

Модульный тест
/**
 * Тест для {@link IncrementResource}.
 */
@RunWith(Arquillian.class)
public class IncrementResourceTest {
     
    @Inject
    private IncrementResource incrementResource;
     
    /**
     * @return Настроенный бандл, который будет использоваться для разрешения зависимостей CDI.
     */
    @Deployment
    public static JavaArchive createDeployment() {
        return ShrinkWrap.create(JavaArchive.class)
            .addClass(IncrementResource.class)
            .addAsManifestResource(EmptyAsset.INSTANCE, "beans.xml");
    }      
 
    @Test
    public void getATest() {
        final Map<String, Integer> response = incrementResource.getA();
         
        assertNotNull(response.get("value"));
        assertEquals(Integer.valueOf(1), response.get("value"));
    }
     
    ..............
     
    /**
     * Возвращает мок для {@link IncrementService}. Используется аннотация RequestScoped:
     * Arquillian использует ее для создание отдельного объекта для каждого теста.
     * @return Мок для {@link IncrementService}.
     */
    @Produces
    @RequestScoped
    public IncrementService getIncrementService() {
        final IncrementService service = mock(IncrementService.class);
        when(service.getA()).thenReturn(1);
        when(service.incrementA()).thenReturn(2);
        when(service.getB()).thenReturn(2);
        when(service.incrementB()).thenReturn(3);
        return service;
    }      
}


Обработку web запросов настроим через встроенный web-сервер — Netty. Для написания функций — обработчиков будем использовать другой стандарт, также пришедший из Java EE, JAX-RS. В качестве реализации стандарта JAX-RS выберем библиотеку Resteasy. Для соединения Netty, CDI и Resteasy используется модуль resteasy-netty4-cdi. JAX-RS настраивается с помощью класса наследника javax.ws.rs.core.Application. Обычно в нем регистрируются обработчики запросов и другие JAX-RS компоненты. При использовании CDI и Resteasy достаточно указать, что в качестве компонентов JAX-RS будут использоваться зарегистрированные в CDI обработчики запросов (помеченные аннотацией JAX-RS: Path) и другие компоненты JAX-RS, которые называются провайдерами (помеченные аннотацией JAX-RS: Provider). Более подробно о Resteasy можно узнать из документации.

Netty и JAX-RS Application
public static void main(String[] args) {
    ...............
     
    // Создание Netty сервера для обслуживания запросов через JAX-RS, который работает с CDI контейнером.
    // Для JAX-RS используется библиотека Resteasy http://resteasy.jboss.org/
    final CdiNettyJaxrsServer nettyServer = new CdiNettyJaxrsServer();
     
    // Настройка Netty (адрес и порт).
    final String host = configuration.getString(
            AppConfiguration.WEBSERVER_HOST, AppConfiguration.WEBSERVER_HOST_DEFAULT);
    nettyServer.setHostname(host);
    final int port = configuration.getInt(
            AppConfiguration.WEBSERVER_PORT, AppConfiguration.WEBSERVER_PORT_DEFAULT);
    nettyServer.setPort(port);
     
    // Настройка JAX-RS.
     
    final ResteasyDeployment deployment = nettyServer.getDeployment();
    // Регистрации фабрики классов для JAX-RS (обработчики запросов и провайдеры).
    deployment.setInjectorFactoryClass(CdiInjectorFactory.class.getName());
    // Регистрация класса, который нужен JAX-RS для получения информации об обработчиках запросов и провайдеров.
    deployment.setApplicationClass(ReactReduxIsomorphicExampleApplication.class.getName());
     
    // Запуск web сервера.
    nettyServer.start();
 
    ...............
}
 
/**
 * Класс с информацией об обработчиках запросов и провайдерах для JAX-RS
 */
@ApplicationScoped
@ApplicationPath("/")
public class ReactReduxIsomorphicExampleApplication extends Application {
 
    /**
     * Подключается расширение CDI для Resteasy.
     */
    @Inject
    private ResteasyCdiExtension extension;
 
    /**
     * @return Список классов обработчиков запросов и провайдеров для JAX-RS.
     */
    @Override
    @SuppressWarnings("unchecked")
    public Set<Class<?>> getClasses() {
        final Set<Class<?>> result = new HashSet<>();
 
        // Из расширения CDI для Resteasy берется информация об обработчиках запросов JAX-RS.
        result.addAll((Collection<? extends Class<?>>) (Object)extension.getResources());
        // Из расширения CDI для Resteasy берется информация о провайдерах JAX-RS.     
        result.addAll((Collection<? extends Class<?>>) (Object)extension.getProviders());
        return result;
    }
}


Все статические файлы (бандлы javascript, css, картинки) разместим в classpath (src/main/resources/webapp), они поместятся в результирующий jar файл. Для доступа к таким файлам используется обработчик URL вида {fileName:.*}.{ext}, который загружает файл из classpath и отдает клиенту.

Обработчик запросов к статике
/**
 * Обработчик запросов к статическим файлам.
 * <p>Запросом статического файла считается любой запрос вида {filename}.{ext}</p>
 */
@Path("/")
@RequestScoped
public class StaticFilesResource {
     
    private final static Date START_DATE = DateUtils.setMilliseconds(new Date(), 0);
     
    @Inject
    private Configuration configuration;
 
    /**
     * Обработчик запросов к статическим файлам. Файлы отдаются из classpath.
     * @param fileName Имя файла с путем.
     * @param ext Расширение файла.
     * @param uriInfo URL запроса, получается из контекста запроса.
     * @param request Данные текущего запроса.
     * @return Ответ с контентом запрошенного файла или ошибкой 404 - не найдено.
     * @throws Exception Ошибка выполнения запроса.
     */
    @GET
    @Path("{fileName:.*}.{ext}")
    public Response getAsset(
            @PathParam("fileName") String fileName,
            @PathParam("ext") String ext,
            @Context UriInfo uriInfo,
            @Context Request request)
                    throws Exception {
        if(StringUtils.contains(fileName, "nomin") || StringUtils.contains(fileName, "server")) {          
            // Неминифицированные версии не возвращаем.
            return Response.status(Response.Status.NOT_FOUND)
                    .build();          
        }
         
        // Проверка ifModifiedSince запроса. Поскольку файлы отдаются из classpath,
        // то временем изменения файла считаем запуск приложения.
        final ResponseBuilder builder =
                request.evaluatePreconditions(START_DATE);
        if (builder != null) {
            // Файл не изменился.
            return builder.build();
        }
         
        // Полный путь к файлу в classpath.
        final String fileFullName =
                "webapp/static/" + fileName + "." + ext;
        // Контент файла.
        final InputStream resourceStream =
                ResourceUtilities.getResourceStream(fileFullName);
        if(resourceStream != null) {       
            // Файл есть, получаем настройки кеширования на клиенте.
            final String cacheControl = configuration.getString(
                    AppConfiguration.WEBSERVER_HOST, AppConfiguration.WEBSERVER_HOST_DEFAULT);
            // Отправляем ответ с контентом файла.
            return Response.ok(resourceStream)
                    .type(URLConnection.guessContentTypeFromName(fileFullName))
                    .cacheControl(CacheControl.valueOf(cacheControl))
                    .lastModified(START_DATE)
                    .build();
        }
 
        // Файл не найден.
        return Response.status(Response.Status.NOT_FOUND)
                .build();
    }  
}


Серверный рендеринг HTML на React


Для сборки бандлов при построении Java приложения можно использовать maven плагин frontend-maven-plugin. Он самостоятельно загружает и локально сохраняет NodeJs нужной версии, строит бандлы с помощью webpack. Достаточно запускать обычное построение Java проекта командой mvn (либо в IDE, которая поддерживает интеграцию с maven). Клиентский javascript, стили, package.json, файл конфигурации webpack разместим в каталоге src/main/frontend, результирующий бандл в src/main/resources/webapp/static/assets.

Настройка fronend-maven-plugin
<plugin>
    <groupId>com.github.eirslett</groupId>
    <artifactId>frontend-maven-plugin</artifactId>
    <configuration>
        <nodeVersion>v${node.version}</nodeVersion>
        <npmVersion>${npm.version}</npmVersion>
        <installDirectory>${basedir}/src/main/frontend</installDirectory>
        <workingDirectory>${basedir}/src/main/frontend</workingDirectory>
    </configuration>
    <executions>
        <!-- Установка nodejs и npm заданной версии. -->
        <execution>
            <id>nodeInstall</id>
            <goals>
                <goal>install-node-and-npm</goal>
            </goals>
        </execution>     
        <!-- Установка зависимостей npm из src/main/frontend/package.json. -->
        <execution>
            <id>npmInstall</id>
            <goals>
                <goal>npm</goal>
            </goals>                      
        </execution>
        <!-- Сборка скриптов с помощью webpack. -->
        <execution>
                <id>webpackBuild</id>
                <goals>
                    <goal>webpack</goal>
                </goals>
                <configuration>
                    <skip>${webpack.skip}</skip>
                    <arguments>${webpack.arguments}</arguments>
                    <srcdir>${basedir}/src/main/frontend/app</srcdir>
                    <outputdir>${basedir}/src/main/resources/webapp/static/assets</outputdir>
                    <triggerfiles>
                        <triggerfile>${basedir}/src/main/frontend/webpack.config.js</triggerfile>
                        <triggerfile>${basedir}/src/main/frontend/package.json</triggerfile>
                    </triggerfiles>
                </configuration>
            </execution>
    </executions>                    
</plugin>


Чтобы настроить собственный генератор HTML страниц в JAX-RS нужно создать какой нибудь класс, создать для него обработчик с аннотаций Provider, реализующий интерфейс javax.ws.rs.ext.MessageBodyWriter, и возвращать его в качестве ответа обработчика web-запроса.
Серверный рендеринг осуществляется с помощью встроенного в Java javascript движка — Nashorn. Это однопоточный скриптовый движок: для обработки нескольких одновременных запросов требуется использовать несколько кешрованных экземпляров движка, для каждого запроса берется свободный экземпляр, выполняется рендеринг HTML, затем он возвращается обратно в пул (Apache Commons Pool 2).

/**
 * Данные для отображения web-страницы.
 */
public class ViewResult {
     
    private final String template;
         
    private final Map<String, Object> viewData = new HashMap<>();
     
    private final Map<String, Object> reduxInitialState = new HashMap<>();
 
    ..............
}
 
/**
 * Обработка данных страницы, заполненных в {@link ViewResult} и отправка HTML.
 * <p>
 *  Если в конфигурации включено использование React в качестве движка для рендеринга HTML (React Isomorphic),
 *  то в шаблон страницы включается контент, сформированный с помощью React.
 * </p>
 */
@Provider
@ApplicationScoped
public class ViewResultBodyWriter implements MessageBodyWriter<ViewResult> {
     
    ..............
     
    private ObjectPool<AbstractScriptEngine> enginePool = null;
         
    @PostConstruct
    public void initialize() {
        // Получение настроек рендеринга.
        final boolean useIsomorphicRender = configuration.getBoolean(
                AppConfiguration.WEBSERVER_ISOMORPHIC, AppConfiguration.WEBSERVER_ISOMORPHIC_DEFAULT);         
        final int minIdleScriptEngines = configuration.getInt(
                AppConfiguration.WEBSERVER_MIN_IDLE_SCRIPT_ENGINES, AppConfiguration.WEBSERVER_MIN_IDLE_SCRIPT_ENGINES_DEFAULT);           
         
        LOG.info("Isomorphic render: {}", useIsomorphicRender);
         
        if(useIsomorphicRender) {
            // Если будет использоваться рендеринг React на сервере, то создается пул
            // javascript движков. Javascript однопоточный,
            // поэтому для каждого запроса используется свой экземпляр настроенного движка javascript.
            final GenericObjectPoolConfig config = new GenericObjectPoolConfig();
            config.setMinIdle(minIdleScriptEngines);       
            enginePool = new GenericObjectPool<AbstractScriptEngine>(new ScriptEngineFactory(), config);
        }
    }
     
    @PreDestroy
    public void destroy() {
        if(enginePool != null) {
            enginePool.close();
        }      
    }  
     
    ..............
 
    @Override
    public void writeTo(
            ViewResult t,
            Class<?> type,
            Type genericType,
            Annotation[] annotations,
            MediaType mediaType,
            MultivaluedMap<String, Object> httpHeaders,
            OutputStream entityStream)
                    throws IOException, WebApplicationException {
 
        ..............
         
        if(enginePool != null && t.getUseIsomorphic()) {
            // Используется React на сервере.
            try {
                // Из пула достается свободный движок javascript.
                final AbstractScriptEngine scriptEngine = enginePool.borrowObject();
                try {
                    // URL текущего запроса, нужен react-router для определения какую страницу рендерить.
                    final String uri = uriInfo.getPath() +
                            (uriInfo.getRequestUri().getQuery() != null
                                ? (String) ("?" + uriInfo.getRequestUri().getQuery())
                                : StringUtils.EMPTY);
                    // Выполнение серверного рендеринга React.
                    final String htmlContent =
                            (String)((Invocable)scriptEngine).invokeFunction(
                                    "renderHtml", uri, initialStateJson);
                 
                    // Возврат освободившегося движка в пул.
                    enginePool.returnObject(scriptEngine);
                     
                    viewData.put(HTML_CONTENT_KEY, htmlContent);
                } catch (Throwable e) {
                    enginePool.invalidateObject(scriptEngine);
                     
                    throw e;
                }
            } catch (Exception e) {
                throw new WebApplicationException(e);
            }
        } else {
            viewData.put(HTML_CONTENT_KEY, StringUtils.EMPTY);
        }      
         
        // Наполнение HTML шаблона данными.
        final String pageContent =
                StrSubstitutor.replace(templateContent, viewData);
        entityStream.write(pageContent.getBytes(StandardCharsets.UTF_8));
    }
     
    /**
     * Фабрика для создания и настройки движка javascript.
     */
    private static class ScriptEngineFactory extends BasePooledObjectFactory<AbstractScriptEngine> {
 
        @Override
        public AbstractScriptEngine create()
                throws Exception {
            LOG.info("Create new script engine");
             
            // Используем nashorn в качестве javascript движка.
            final AbstractScriptEngine scriptEngine =
                    (AbstractScriptEngine) new ScriptEngineManager().getEngineByName("nashorn");
            try(final InputStreamReader polyfillReader =
                    ResourceUtilities.getResourceTextReader(WEBAPP_ROOT + "server-polyfill.js");   
                final InputStreamReader serverReader =
                    ResourceUtilities.getResourceTextReader(WEBAPP_ROOT + "static/assets/server.js")) {
                // Исполнение скрипта с некоторыми функциями, которых нет в nashorn, потому что он не исполняется в браузере.
                scriptEngine.eval(polyfillReader);
                // Регистрация функции, которая будет рендерить HTML на сервере с помощью React.
                scriptEngine.eval(serverReader);
            }
             
            // Запуск функции инициализации.
            ((Invocable)scriptEngine).invokeFunction(
                    "initializeEngine", ResourceUtilities.class.getName());
 
            return scriptEngine;
        }
 
        @Override
        public PooledObject<AbstractScriptEngine> wrap(AbstractScriptEngine obj) {
            return new DefaultPooledObject<AbstractScriptEngine>(obj);
        }
    }  
}

Движок исполняет Javascript версии ECMAScript 5.1 и не поддерживает загрузку модулей, поэтому серверный скрипт, как и клиентский, соберем в бандлы с помощью webpack. Серверный бандл и клиентский бандл строятся на основе общей кодовой базы, но имеют разные точки входа. По какой-то причине Nashorn не может исполнять минимизированый бандл (собираемый webpack с ключом --optimize-minimize) — падает с ошибкой, поэтому на стороне сервера нужно исполнять неминимизированный бандл. Для построения обоих типов бандлов одновременно можно использовать плагин к Webpack: unminified-webpack-plugin.

При первом запросе любой страницы, либо если нет свободного экземпляра движка, сделаем инициализацию нового экземпляра. Процесс инициализации состоит из создания экземпляра Nashorn и исполнения в нем серверных скриптов, загружаемых из classpath. Nashorn не реализует несколько обычных javascript функций, таких как setInterval, setTimeout, поэтому нужно подключать простейший скрипт-polyfill. Затем загружается непосредственно код, который формирует HTML страницы (так же как и на клиенте). Этот процесс не очень быстрый, на достаточно мощном компьютере занимает пару секунд, таким образом нужен кеш экземпляров движков.

Полифил для Nashorn
// Инициализация объекта global для javascript библиотек.
var global = this;
 
// Инициализация объекта window для javascript библиотек, которые написаны не совсем правильно,
// они думают что всегда исполняются в браузере.
var window = this;
 
// Инициализация объекта ведения логов, в Nashorn нет console.
var console = {
    error: print,
    debug: print,
    warn: print,
    log: print
};
 
// В Nashorn нет setTimeout, выполняем callback - на сервере сразу требуется ответ.
function setTimeout(func, delay) {
    func();
    return 0;
};
function clearTimeout() {  
};
 
// В Nashorn нет setInterval, выполняем callback - на сервере сразу требуется ответ.
function setInterval(func, delay) {
    func();
    return 0;
};
function clearInterval() { 
};


Рендеринг HTML на уже проинициализированном движке происходит гораздо быстрее. Для получения HTML, сформированного React, напишем функцию renderHtml, которую поместим в серверную точку входа (src\server.jsx). В эту функцию передается текущий URL, для обработки его с помощью react-router, и начальное состояние redux для запрошенной страницы (в виде JSON). То же самое состояние для redux, в виде JSON, помещается на страницу в переменную window.INITIAL_STATE. Это необходимо для того, чтобы дерево элементов, построенное React на клиенте, совпадало с HTML, сформированном на сервере.

Серверная точка входа js бандла:

 
/**
 * Выполнение рендеринга HTML с помощью React.
 * @param  {String} url              URL ткущего запроса.
 * @param  {String} initialStateJson Начальное состояние для Redux в сиде строки с JSON.
 * @return {String}                  HTML, сформированный React.
 */
renderHtml = function renderHtml(url, initialStateJson) {
  // Парсинг JSON начального состояния для Redux.
  const initialState = JSON.parse(initialStateJson)
  // Обработка истории переходов для react-router (обработка проиходит в памяти).
  const history = createMemoryHistory()
  // Создание хранилища Redux на основе текущего состояния, переданного в функцию.
  const store = configureStore(initialState, history, true)
  // Объект для записи в него результат рендеринга.
  const htmlContent = {}
 
  global.INITIAL_STATE = initialState
 
  // Эмуляция перехода на страницу с заданным URL с помощью react-router.
  match({
    routes: routes({history}),
    location: url
  }, (error, redirectLocation, renderProps) => {
    if (error) {
      throw error
    }
 
    // Рендеринг HTML текущей страницы с помощью React.
    htmlContent.result = ReactDOMServer.renderToString(
      <AppContainer>
        <Provider store={store}>
          <RouterContext {...renderProps}/>
        </Provider>
      </AppContainer>
    )
  })
 
  return htmlContent.result
}

Клиентская точка входа js бандла:

// Создание хранилища Redux.
const store = configureStore(initialState, history, false)
// Элемент в который нужно вставлять HTML, сформированный React.
const contentElement = document.getElementById("content")
 
// Выполнение рендеринга HTML с помощью React.
ReactDOM.render(<App store={store} history={history}/>, contentElement)

Поддержка «горячей» перезагрузки HTML/стилей


Для удобства разработки клиентской части можно настроить webpack dev server с поддержкой «горячей» перезагрузки изменившихся страниц или стилей. Разработчик запускает приложение, запускает webpack dev server на другом порту (например, настроив в package.json команду npm run debug) и получает возможность в большинстве случаев не обновлять измененные страницы — изменения применяются на лету, это касается как HTML кода, так и кода стилей. Для этого в браузере нужно перейти по ранее настроенному адресу webpack dev сервера. Сервер строит бандлы на лету, остальные запросы проксирует к приложению.

package.json:
{
  "name": "java-react-redux-isomorphic-example",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "debug": "cross-env DEBUG=true APP_PORT=8080 PROXY_PORT=8081 webpack-dev-server --hot --colors --inline",
    "build": "webpack",
    "build:debug": "webpack -p"
  }
}

Для настройки «горячей» перезагрузки нужно выполнить действия, описанные ниже.

В файле настроек webpack:

  • В devtools указать module-source-map либо module-eval-source-map. При включенном module-source-map, отладочная информация включается в тело модуля — в этом случае сработают точки останова при общей перезагрузке страницы, но, при изменении страничек в средствах отладки Chrome, появляются дубли модулей, каждый со своей версией. Если включить module-eval-source-map, то не будет появления дублей, правда точки останова при общей перезагрузке страницы не будут срабатывать.

     devtool: isHot
       // Инструменты отладки при "горячей" перезагрузке.
       ? "module-source-map" // "module-eval-source-map"
       // Инструменты отладки в production.
       : "source-map"
    

  • В devServer настроить отладочный сервер webpack: установить флаг «горячей» перезагрузки, указать порт сервера и указать настройки проксирования запросов к приложению.

      // Настройки сервера бандлов для разработки.
      devServer: {
        // Горячая перезагрузка.
        hot: true,
        // Порт сервера.
        port: proxyPort,
        // Сервер бандлов работает как прокси к основному приложения.
        proxy: {
          "*": `http://localhost:${appPort}`
        }
      }
    

  • В entry для точки входа клиентского скрипта подключить модуль — медиатор: react-hot-loader/patch.

      entry: {
        // Бандл для клиентского скрипта.
        main: ["es6-promise", "babel-polyfill"]
          .concat(isHot
            // Если используется "горячая" перезагрузка - требуется медиатор.
            ? ["react-hot-loader/patch"]
            // Стартовый скрипт клиентского скрипта.
            : [])
          .concat(["./src/main.jsx"]),
        // Бандл для рендеринга на стороне сервера.
        [isProduction ? "server.min" : "server"]:
          ["es6-promise", "babel-polyfill", "./src/server.jsx"]
      }
    

  • В output в настройке publicPath указать полный URL webpack dev сервера.

      output: {
        // Путь для бандлов.
        path: Path.join(__dirname, "../resources/webapp/static/assets/"),
        publicPath: isHot
          // Сервер разработчика с "горячей" перезагрузкой (требуется задавать полный путь).
          ? `http://localhost:${proxyPort}/assets/`
          : "/assets/",
        filename: "[name].js",
        chunkFilename: "[name].js"
      }
    

  • В настройках загрузчика babel подключить плагины для поддержки «горячей» перезагрузки: syntax-dynamic-import и react-hot-loader/babel.

      {
            // Загрузчик JavaScript (Babel).
            test: /\.(js|jsx)?$/,
            exclude: /(node_modules)/,
            use: [
              {
                loader: isHot
                  // Для "гарячей" перезагрузки требуется настроить babel.
                  ? "babel-loader?plugins[]=syntax-dynamic-import,plugins[]=react-hot-loader/babel"
                  : "babel-loader"
              }
            ]
          }
    

  • В настройках загрузчика стилей указать использования загрузчика style-loader. В этом случае стили будут инлайнится в javascript код. При отключенной «горячей» перезагрузки стилей (например в production) используется формирование бандла стилей с помощью extract-text-webpack-plugin.

     {
            // Загрузчик стилей CSS.
            test: /\.css$/,
            use: isHot
            // При использовании "горячей" перезагрузки стили помещаются в бандл с JavaScript кодом.
              ? ["style-loader"].concat(cssStyles)
              // В production - стили это отдельный бандл.
              : ExtractTextPlugin.extract({use: cssStyles, publicPath: "../assets/"})
          }
    

  • Подключить плагин Webpack.NamedModulesPlugin для формирования именованных модулей.

В клиентской точке входа в приложение вставить обработчик обновления модуля. Обработчик загружает обновленный модуль и запускает процесс рендеринга HTML с помощью React.

// Выполнение рендеринга HTML с помощью React.
ReactDOM.render(<App store={store} history={history}/>, contentElement)
 
if (module.hot) {
  // Поддержка "горячей" перезагрузки компонентов.
  module.hot.accept("./containers/app", () => {
    const app = require("./containers/app").default
 
    ReactDOM.render(app({store, history}), contentElement)
  })
}

В модуле, где создается хранилище redux, вставить обработчик обновления модуля. Этот обработчик загружает обновленные redux-преобразователи и подменяет ими старые преобразователи.

const store = createStore(reducers, initialState, applyMiddleware(...middleware))
 
  if (module.hot) {
    // Поддержка "горячей" перезагрузки Redux-преобразователей.
    module.hot.accept("./reducers", () => {
      const nextRootReducer = require("./reducers")
 
      store.replaceReducer(nextRootReducer)
    })
  }
 
  return store

В самом приложении на Java нужно отключить построения бандлов через frontend-maven-plugin и использование серверного рендеринга React: теперь за построение бандлов скриптов и стилей начинает отвечать webpack dev server, он делает это очень быстро и в памяти, процессор и диск не будут нагружаться перестроением бандлов. Для отключения пересборки с помощью frontend-maven-plugin и серверного рендеринга React можно предусмотреть профиль maven: frontendDevelopment (его можно включить в IDE, которая поддерживает интеграцию с maven). При необходимости, бандлы пересобираются вручную в любой момент с помощью webpack.
Поделиться с друзьями
-->

Комментарии (7)


  1. Urgen
    27.04.2017 13:11

    Почему не использовали spring boot? Там же и CDI, и контейнер сервлетов, и возможность сделать REST API идут из коробки.


    1. complit-s
      27.04.2017 13:22
      +4

      Вопрос понятен и ожидаем, хотелось показать, что подобное можно сделать не используя какие либо фреймворки типа Spring Boot. И да, тут не используются сервлеты вообще. К тому же для соединения Netty и Weld требуется всего несколько строчек кода. Получается легковесное и быстрое приложение. Кстати один из плюсов Java — обилие библиотек и необязательность использования одного какого-то фреймворка: свой фреймворк можно собрать самому.


      1. Urgen
        27.04.2017 15:13

        по сервлетам — я имел ввиду netty, который тут используется как контейнер сервлетов.


  1. mystdeim
    27.04.2017 13:54

    Супер, мне понравилось. За CdiNettyJaxrsServer отдельное спасибо, действительно, можно легко прилепить рест-интерфейс к чему угодно таким способом и должно быть легче и быстрее спринга. Что посоветуете для безопасности?


    1. complit-s
      27.04.2017 14:21

      Есть https://shiro.apache.org/, она большая, со множеством возможностей. Но и порог входа туда повыше. Если что не большое, то можно прикрутить JSON Web Token (https://jwt.io/). Вот реализации для Java: https://github.com/jwtk/jjwt и https://github.com/auth0/java-jwt. Они соединяются с JAX-RS с помощью ContainerRequestFilter. Ну и конечно можно использовать OAuth, какую нибудь реализацию для Java.


  1. l2cri
    28.04.2017 15:07

    неужели нельзя отказаться от серверного рендеринга приложений? Ведь они нужны только для SEO оптимизации. Неужели нельзя придумать как отдавать json роботу c нужными тегами и все… Зачем роботу вся эта Html разметка?


    1. complit-s
      28.04.2017 15:17

      Можно конечно, например для админки он не нужен. Что касается SEO, Google вроде как запускает javascript, но по отзывам там далеко не все работает, как хотелось бы. Вот статья на Хабре — Опыт перехода сайта на Single Page Application с упором на SEO. Насчет JSON, ну пока нет стандартов и поисковики не умеют так.

      Есть правда еще один аспект — с помощью серверного рендеринга ускоряется общая загрузка страницы: клиент получает HTML, грузит скрипт, затем React не рендерит на клиенте второй раз, а просто вешает обработчики на DOM события. В большинстве случаев даже не нужно вешать крутилку, пока ждем скрипта — клиент уже получил контент, он может его смотреть.