Изоморфное приложение React JS + Spring Boot



    Про ReactJs, Java, Spring, рендеринг, Virtual DOM, Redux и прочие подобные вещи уже существует очень много всевозможных статей и практических наработок, поэтому я не буду в них углубляться.

    Я не замерял производительность этой конструкции. Те кому интересно, могут провести свои личные тесты и сравнить например с NodeJS.

    Я не особо заморачивался на стиле и качестве кода, так что извиняйте, кому не придётся по душе =)

    Цель моей работы просто заставить работать воедино такие вещи как ReactJS + Redux + WebPack + Java/Spring.

    Перед тем как писать свою статью, я воспользовался поиском и обнаружил существующий аналогичный пример. Но, почитав комментарии, я заметил, что некоторые желают увидеть подобный образец изоморфного приложения, но работающий со Spring.

    С радостью исполняю желание читателей.

    Архитектура моего примера будет содержать следующие компоненты:

    FrontEnd:

    • Npm (сборка нужных зависимостей для фронта);
    • ReactJS (отображение UI);
    • Redux (однонаправленая передача данных);
    • WebPack (сборка бандла JS + CSS).

    BackEnd:

    • Java 8 (версия JVM);
    • Maven (сборка бэкэнда);
    • SpringBoot (собственно фрэймворк для организации сервисов);
    • Thymeleaf (шаблонизатор для вывода html).

    Да, чуть не забыл — во время демонстрации работы UI-части я использовал open source библиотеку arui-feather, которая была любезно предоставлена коллегами из Альфа-Лаборатории. Библиотека содержит массу всевозможных UI-компонентов с предустановленной логикой работы.

    Итак, на чём всё это дело крутить? А если честно – на чём хотите. Tomcat EE, JBoss, WildFly. Лично я использую WildFly.

    Плюсы такого подхода:

    • Все нужные файлы лежат в одном проекте, на выходе, соответственно, получим монолитное приложение;
    • Один адрес, один порт и один сервер приложений / сервлет контейнер для приложения;
    • Лёгкость и удобство инфраструктуры. По сути нам нужен всего один сервер приложений;
    • Сборка бэка проекта через одну утилиту (например, maven), которая в свою очередь будет вызывать сборку фронта через npm. Итого запускается лишь одна утилита.
    • Никаких проблем с обращениями на эндпойнты. (Иногда, когда фронт крутится отдельно и обращается на открытое API, может понадобиться дополнительная настройка CORS).
    • Здесь не используется node.js

    Часть первая – генерация бойлерплейта


    Итак, начнём – заходим на http://start.spring.io, заполняем форму для генерации бойлерплейта, как на скриншоте.



    Из обязательных зависимостей нам понадобятся Web для создания бэкэндов и HTML-шаблонизатор Thymeleaf. Библиотеку Lombok можете поставить по желанию.

    Затем я нажимаю на Generate Project и скачиваю zip-архив с бойлерплейтом. Затем распаковываю архив в свободное место на диске и импортирую бойлерплейт в среду разработки Intellij IDEA.

    Часть вторая – структура проекта


    Весь проект, как несложно догадаться, будет состоять из двух частей: Frontend (одноимённая папка) и Backend (src/main/java).

    Структура фронтовой части представлена на картинке чуть ниже:



    У меня тут представлено всё необходимое для работы ReactJS + Redux + WebPack.

    Разберём всё по порядку:

    index.jsx — это точка входа в наше приложение:

    import React from 'react'
    import { render } from 'react-dom'
    import { renderToString } from 'react-dom/server'
    import thunk from 'redux-thunk'
    import { Provider } from 'react-redux'
    
    import { createStore, applyMiddleware } from 'redux'
    import testmiddleware from './middlewares/testmiddleware'
    import promise from 'redux-promise'
    
    import reduxReset from 'redux-reset'
    import reducers from './reducers/reducers'
    
    import App from './components/app/app'
    
    const MIDDLEWARES = [
       thunk,
       promise,
       testmiddleware
    ];
    
    if (typeof window !== 'undefined' && typeof document !== 'undefined' && typeof document.createElement === 'function') {
    
       window.renderClient = (state) => {
    
           let store = applyMiddleware(...MIDDLEWARES)(createStore)(reducers, state, reduxReset());
    
           store.subscribe(() => console.log(store.getState()));
    
           render (
               <Provider store={ store }>
                   <App />
               </Provider>,
               document.getElementById ('root')
           );
       }
    } else {
       global.renderServer = (state) => {
    
           let store = applyMiddleware(...MIDDLEWARES)(createStore)(reducers, state, reduxReset());
    
           store.subscribe(() => console.log(store.getState()));
    
           return renderToString (
               <Provider store={ store }>
                   <App />
               </Provider>
           )
       }
    }
    

    Здесь содержатся две основные функции — renderClient() и renderServer().

    Функция renderClient() будет отвечать за логику работы фронтовой части, после того как наше изоморфное приложение полностью отрендерится сервером. Чтобы это произошло, вначале вызовется renderServer();

    app.jsx — корневой компонент, который будет отвечать за монтирование и отображение других компонентов.

    import React from 'react'
    
    import AppTitle from 'arui-feather/app-title'
    import AppContent from 'arui-feather/app-content'
    import AuthForm from '../authform/authform'
    import Footer from 'arui-feather/footer'
    import Header from 'arui-feather/header'
    import Heading from 'arui-feather/heading'
    import Page from 'arui-feather/page'
    
    class App extends React.Component {
    
       render() {
           return (
               <Page header={ <Header />} footer={<Footer />} >
                   <AppTitle>
                       <Heading>Тестовый пример</Heading>
                   </AppTitle>
                   <AppContent>
                       <AuthForm/>
                   </AppContent>
               </Page>
           );
       }
    }
    
    export default App;
    

    authform.jsx — компонент формы, с которой мы будем отправлять запросы на наш сервер.

    import React from 'react'
    
    import { bindActionCreators } from 'redux'
    import {Field, formValueSelector, reduxForm } from 'redux-form'
    import { connect } from 'react-redux'
    
    import { makeTestAction } from '../../actions/testaction'
    
    import Button from 'arui-feather/button'
    import Form from 'arui-feather/form'
    import FormField from 'arui-feather/form-field'
    import Input from 'arui-feather/input'
    import Label from 'arui-feather/label'
    
    import { inputField } from '../../utils/componentFactory'
    
    let formConfig = {
        form: 'testForm'
    };
    
    let foundStatus = "";
    
    const selector = formValueSelector('testForm');
    
    function mapStateToProps(state) {
        return {
            phoneField: selector(state, 'phoneNumber'),
            statusResp: state.testRed.respResult,
            answer: state.testRed.answerReceived
        };
    }
    
    function mapDispatchToProps(dispatch) {
        return bindActionCreators( { makeTestAction }, dispatch )
    }
    
    @reduxForm(formConfig)
    @connect(mapStateToProps, mapDispatchToProps)
    class AuthForm extends React.Component {
    
        render() {
            return (
                <div>
                    <Form noValidate={ true } onSubmit={ this.props.makeTestAction }>
    
                        <FormField key='phoneNumber'>
                            <Field name='phoneNumber' placeholder='Укажите номер телефона в формате 79001234567' component={ inputField } size='m' />
                        </FormField>
    
                        <FormField view='line'>
                            <Button width='available' view='extra' size='m' type='submit'>
                                Продолжить
                            </Button>
                        </FormField>
    
                        { this.renderFinalResult() }
    
                    </Form>
                </div>
            );
        }
    
        renderFinalResult() {
    
            foundStatus = this.props.statusResp;
    
            return (this.props.answer === true ) &&
                <div>
                    <FormField view='line' width='400px' label={ <Label size='m'>Статус проверки номера</Label> }>
                        <Input size='m' width='available' value={ foundStatus } />
                    </FormField>
                </div>
        }
    }
    
    export default AuthForm;
    

    Когда мы инициируем отправку формы onSubmit={ this.props.makeTestAction }, мы создаём запрос на исполнение действия ( dispatch ) под названием makeTestAction.

    testaction.js:

    import { TEST_ACTION, TEST_START, TEST_SUCCESS, TEST_FAILRULE } from '../constants/actions';
    
    export function makeTestAction() {
    
       return {
           type: TEST_ACTION,
           actions: [ TEST_START, TEST_SUCCESS, TEST_FAILRULE ]
       }
    }
    

    Далее в дело вступает компонент middleware. Наш testmiddleware заточен на то, чтобы реагировать каждый раз, как будет иницировано TEST_ACTION.

    testmiddleware.js:

    import { TEST_ACTION } from '../constants/actions'
    import superagent from 'superagent'
    
    const testmiddleware = store => next => action => {
    
       if (action.type !== TEST_ACTION) {
           return next(action);
       }
    
       const [ startAction, successAction, failureAction] = action.actions;
       const fieldName = action.fieldName;
    
       let state = store.getState();
    
       let dataFetch = state.form.testForm.values;
    
       if (!action.value) {
           store.dispatch({
               type: successAction,
               fieldName,
               payload:[]
           });
       }
    
       store.dispatch({
           type: startAction,
           fieldName
       });
    
       superagent
           .get('/testep')
           .set('Content-Type', 'text/html; charset=utf-8')
           .query(dataFetch)
           .timeout(10000)
           .end((error, res) => {
               if (!error && res.ok) {
                   store.dispatch({
                       type: successAction,
                       fieldName,
                       payload: JSON.parse(res.text)
                   });
               } else {
                   console.log("ERROR!!!");
               }
           });
       return 1;
    };
    
    export default testmiddleware;
    

    Здесь я с помощью аддона superagent посылаю get-запрос на эндпойнт “/testep” и, если запрос находит эндпойнт и приходит ответ, то я кладу ответ в нашу store в виде переменной payload и инициирую successAction.

    testreducer.js — наш единственный reducer стоит наготове и ждёт, когда же наконец будет иницировано successAction:

    import { TEST_SUCCESS } from '../constants/actions'
    
    let initialState = {};
    
    export default function testReducer(state = initialState, action) {
    
       if (action.type === TEST_SUCCESS) {
           return {
               ...state,
               respResult: action.payload.name,
               answerReceived: true
           };
       }
    
       return {
           ...state,
           answerReceived: false
       }
    }
    

    Как только это происходит, то в store записывается результат нашего payload и отдаётся на наш UI в виде переменной statusResp.

    Вот, собственно, и всё, что касается работы нашего фронтэнда.

    Что касается бэкэнда, то тут всё гораздо проще. У нас будет самый обыкновенный REST-сервис, по самой стандартной схеме:



    Самым интересным для нас тут будет файл React.java

    package ru.alfabank.ef.configurations;
    
    import jdk.nashorn.api.scripting.NashornScriptEngine;
    
    import org.springframework.beans.factory.annotation.Value;
    import org.springframework.core.io.Resource;
    import org.springframework.stereotype.Component;
    
    import javax.script.ScriptEngineManager;
    import javax.script.ScriptException;
    
    import java.io.IOException;
    
    
    @Component
    public class React {
    
       @Value(value = "classpath:static/nashorn-polyfill.js")
       private Resource nashornPolyfillFile;
    
       @Value(value = "classpath:static/bundle.js")
       private Resource bundleJsFile;
    
       public String renderEntryPoint() throws ScriptException, IOException {
    
           NashornScriptEngine nashornScriptEngine = getNashornScriptEngine();
    
           try {
               Object html = nashornScriptEngine.invokeFunction("renderServer");
               return String.valueOf(html);
           } catch (Exception e) {
               throw new IllegalStateException("Error! Failed to render react component!", e);
           }
    
       }
    
       private NashornScriptEngine getNashornScriptEngine() throws ScriptException, IOException {
    
           NashornScriptEngine nashornScriptEngine = (NashornScriptEngine) new ScriptEngineManager().getEngineByName ("nashorn");
    
           nashornScriptEngine.eval ("load ('" + nashornPolyfillFile.getFile().getCanonicalPath() + "')");
           nashornScriptEngine.eval ("load ('" + bundleJsFile.getFile().getCanonicalPath() + "')");
    
           return nashornScriptEngine;
       }
    }
    

    Этот файл ответственен за зачитывание файлика bundle.js, который является сжатым складом для всех наших скриптов и UI-компонентов, а также за вызов функции renderServer(), которая однократно инициирует рендеринг нашего приложения на сервере, перед тем как далее логика работы нашего приложения будет продолжать работать на клиенте путём срабатывания функции renderClient() (см. файл index.jsx).

    Также наш файл возвращает html-страницу. Как только отработает главный контроллер:

    package ru.alfabank.ef.controllers;
    
    import com.fasterxml.jackson.core.JsonProcessingException;
    
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.http.MediaType;
    import org.springframework.stereotype.Controller;
    import org.springframework.ui.Model;
    import org.springframework.web.bind.annotation.RequestMapping;
    
    import ru.alfabank.ef.configurations.React;
    
    import javax.script.ScriptException;
    import java.io.FileNotFoundException;
    
    @Controller
    public class MainController {
    
       private final React react;
    
       @Autowired
       public MainController(React react) {
           this.react = react;
       }
    
       @RequestMapping(value = "/", produces = MediaType.TEXT_HTML_VALUE)
       public String mainPage(Model model) throws JsonProcessingException, ScriptException, FileNotFoundException {
    
           String renderedHTML = react.renderEntryPoint();
    
           model.addAttribute("content", renderedHTML);
    
           return "index";
       }
    }
    
    

    Объект model будет внедрён в страницу (шаблон) index.html через атрибут content для того, чтобы отработать на сервере, прежде чем начать отрабатывать на клиенте.

    Шаблон index.html:

    <!DOCTYPE html>
    <html lang="en" xmlns:th="http://www.thymeleaf.org">
       <head>
           <meta charset="UTF-8" />
           <title>SpringBoot & React | Progressive Webapp Demo</title>
           <link rel="stylesheet" href="/styles.css" />
       </head>
       <body>
    
           <div id="root" th:utext="${content}"></div>
    
           <script src="/bundle.js"></script>
           <script th:inline="javascript">
               window.renderClient();
           </script>
    
       </body>
    </html>

    Часть третья – сборка и запуск


    Клонируем наш репозиторий, набираем команду

    mvn clean install

    Ждём, когда соберется наш проект, и затем кидаем готовый test.war файл на JBoss, WildFly или Tomcat EE.

    После того как артефакт успешно задеплоится, открываем браузер и набираем localhost:8080. Буквально через пару секунд загрузки откроется тестовый пример.

    На скрине фрагмент формы:



    Всё остальное содержимое нашего бэкэнда – это типичный спринговый REST-сервис по стандартному шаблону.

    Когда вы укажете номер в нужном формате, то вам вернётся ответ — ОК.
    Если же номер будет указан неверно, то вы получите ERROR, от нашего REST-сервиса.

    Вот, собственно, и всё.

    В планах на будущее – перенести всё это дело на gradle, прикрутить горячую перезагрузку фронта, docker-контейнер ну и само собой покрыть всё это дело тестами.

    Все интересующие файлы вы можете скачать по ссылке из репозитория:

    bitbucket.org/serpentcross/alfabank-ef-test

    Материалы, которые я использовал при разработке примера:

    — Библиотека ARUI-Feather: alfa-laboratory.github.io/arui-feather/styleguide
    — Redux Form: redux.js.org
    — Создание изоморфных приложений: winterbe.com/posts/2015/02/16/isomorphic-react-webapps-on-the-jvm
    — Генератор проектов Spring: start.spring.io
    — Пример изоморфного приложения: github.com/synyx/springboot-reactjs-demo

    Всем спасибо!!!

    Альфа-Банк 235,95
    Компания
    Поделиться публикацией
    Комментарии 13
    • +2
      А как же «Make jar, not war»?
      • +3
        Что касается jar — да, можно конечно всё делать на embedded tomcat, который поставляется со Spring Boot, вот только я не смог к сожалению разобраться с classpath. Потому что при упаковке в jar, начинают сыпаться пути к js-файлам nashorn-polyfill и bundle.js =(

        Если вы сможете с этим разобраться, то буду вам признателен) А так по сути — мне например легче в продакшене крутить всё это дело в виде war =)
        • –1
          Кончено если в продакшене уже настоен готовый сервер, то war удобнее. Просто спринг дает отличную возможность быстро стартануть прокт. И для таких примеров jar был бы в самый раз.

          Я бы хотел узнать про ренедеринг html на стороне сервера, в каких случаях это имеет смысл?

          P.S. При сборке в JAR у вас проблемы не с classpath, а в вызове getCanonicalPath() в файле React.java
          Если сделать так, то все заработает:
          Код
                  nashornScriptEngine.eval("load('"+nashornPolyfillFile.getURL()+"')");
                  nashornScriptEngine.eval("load('"+bundleJsFile.getURL()+"')");
          

          Еще пришлось добавить версию в spring-boot-starter-thymeleaf, мавен почему-то ругался…
          • +2
            «Я бы хотел узнать про ренедеринг html на стороне сервера, в каких случаях это имеет смысл?»

            Ну как бе десятки тысяч IT-продуктов, которые разрабатываются на java с использованием vaadin, gwt, JSF, плюс всякие продукты на PHP да и тот же nodejs с reactjs. Это всё создано для того, чтобы выдавать html с сервера) Так что думаю смысла более чем достаточно =)
          • 0

            Вот мой проект react + spring boot, фронтом вообще со стороны явы не управляю, с этим прекрасно справляется frontend-maven-plugin. Может будет полезно.

            • 0
              А вы вообще видели что из себя представляет этот плагин? Это просто отдельный вызов и старт инстанса node js. Моя цель была — избавиться от node js как от лишнего звена в архитектуре.
              • 0
                Ну у этого подхода есть один маленький плюс, — можно фронт запустить вручную и править его на горячую. Если настроить еще и CORS, то править фронт можно будет на горячую, даже при включенном джавовском бекэнде.
        • –4
          С подключением! Уже 2 года все кому надо в курсе. Еще недавно 9 Java вышла
          • +3
            Вы о чём? Кому надо? О чём в курсе? Или вам просто позлословить захотелось? Тут не место для этого.
            • –3
              Мой комментарий — ответ на КДПВ.
          • 0
            Все конечно хорошо, видел похожие демки в 2015, но воз и ныне там. На продакш это нельзя — на каждый запрос создать движок nashornа, грузить и компилировать скрипты это же долго и кажется десяток лет назад так и работал php?
            • 0
              Ой, да в одной только Java (как и в других подобных языках) много разных механизмов, которые существенно замедляют работу кода:
              — try/catch;
              — switch/case;
              — reflection;
              — создание потоков.

              И что теперь? На Java не писать?))) Более того, запрос на каждого пользователя происходит однократно, далее вступает в работу клиентская часть. Ничего страшного.
              • +1
                Я проверял, после первой инициализации повторно теже функции выполняются быстрее.
                Но, все равно были тесты, что движок V8 намного быстрее nashornа

              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

              Самое читаемое