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

в 7:01, , рубрики: java, javascript, maven, open source, React, ReactJS, redux, restful api, spring, spring boot, Блог компании «Альфа-Банк»

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

Про 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, заполняем форму для генерации бойлерплейта, как на скриншоте.

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

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

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

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

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

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

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

У меня тут представлено всё необходимое для работы 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 JS + Spring Boot - 4

Самым интересным для нас тут будет файл 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";
   }
}

Объект html будет внедрён в шаблон 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. Буквально через пару секунд загрузки откроется тестовый пример.

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

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

Всё остальное содержимое нашего бэкэнда – это типичный спринговый 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

Всем спасибо!!!
Изоморфное приложение React JS + Spring Boot - 6

Автор: Владимир

Источник

Поделиться

* - обязательные к заполнению поля