Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
В одном из наших проектов, мы использовали IPC (inter-process communication) на сокетах. Довольно большой проект, торгового бота, где были множество модулей которые взаимодействовали друг с другом. По мере роста сложности стал вопрос о мониторинге, что происходит в микросервисах. Мы решили создать свое приложение для отслеживания, потока данных на всего двух библиотеках react и redoor. Я хотел бы поделиться с вами нашим подходом.
Микросервисы обмениваются между собой JSON объектами, с двумя полями: имя и данные. Имя - это идентификатор какому сервису предназначается объект и поле данные - полезная нагрузка. Пример:
{ name:'ticket_delete', data:{id:1} }
Поскольку сервис довольно сырой и протоколы менялись каждую неделю, так что мониторинг должен быть максимально простым и модульным. Соответственно, в приложении каждый модуль должен отображать предназначаемые ему данные и так добавляя, удаляя данные мы должны получить набор независимых модулей для мониторинга процессов в микросервисах.
И так начнем. Для примера сделаем простейшее приложение и веб сервер. Приложение будет состоять из трех модулей. На картинке они обозначены пунктирными линиями. Таймер, статистика и кнопки управления статистикой.
Создадим простой Web Socket сервер.
/** src/ws_server/echo_server.js */
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8888 });
function sendToAll( data) {
let str = JSON.stringify(data);
wss.clients.forEach(function each(client) {
client.send(str);
});
}
// Отправляем данные каждую секунду
setInterval(e=>{
let d = new Date();
let H = d.getHours();
let m = ('0'+d.getMinutes()).substr(-2);
let s = ('0'+d.getSeconds()).substr(-2);
let time_str = `${H}:${m}:${s}`;
sendToAll({name:'timer', data:{time_str}});
},1000);
Сервер каждую секунду формирует строку с датой и отправляет всем подключившимся клиентам. Открываем консоль и запускаем сервер:
node src/ws_server/echo_server.js
Теперь перейдем к проекту приложения. Для сборки и отладки будем использовать rollup конфигурация ниже.
rollup.config.js
import serve from 'rollup-plugin-serve';
import babel from '@rollup/plugin-babel';
import { nodeResolve } from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import hmr from 'rollup-plugin-hot'
import postcss from 'rollup-plugin-postcss';
import autoprefixer from 'autoprefixer'
import replace from '@rollup/plugin-replace';
const browsers = [ "last 2 years", "> 0.1%", "not dead"]
let is_production = process.env.BUILD === 'production';
const replace_cfg = {
'process.env.NODE_ENV': JSON.stringify( is_production ? 'production' : 'development' ),
preventAssignment:false,
}
const babel_cfg = {
babelrc: false,
presets: [
[
"@babel/preset-env",
{
targets: {
browsers: browsers
},
}
],
"@babel/preset-react"
],
exclude: 'node_modules/**',
plugins: [
"@babel/plugin-proposal-class-properties",
["@babel/plugin-transform-runtime", {
"regenerator": true
}],
[ "transform-react-jsx" ]
],
babelHelpers: 'runtime'
}
const cfg = {
input: [
'src/main.js',
],
output: {
dir:'dist',
format: 'iife',
sourcemap: true,
exports: 'named',
},
inlineDynamicImports: true,
plugins: [
replace(replace_cfg),
babel(babel_cfg),
postcss({
plugins: [
autoprefixer({
overrideBrowserslist: browsers
}),
]
}),
commonjs({
sourceMap: true,
}),
nodeResolve({
browser: true,
jsnext: true,
module: false,
}),
serve({
open: false,
host: 'localhost',
port: 3000,
}),
],
} ;
export default cfg;
Точка входа нашего проекта main.js
создадим его.
/** src/main.js */
import React, { createElement, Component, createContext } from 'react';
import ReactDOM from 'react-dom';
import {Connect, Provider} from './store'
import Timer from './Timer/Timer'
const Main = () => (
<Provider>
<h1>ws stats</h1>
<Timer/>
</Provider>
);
const root = document.body.appendChild(document.createElement("DIV"));
ReactDOM.render(<Main />, root);
Теперь создадим стор для нашего проекта
/** src/store.js */
import React, { createElement, Component, createContext } from 'react';
import createStoreFactory from 'redoor';
import * as actionsWS from './actionsWS'
import * as actionsTimer from './Timer/actionsTimer'
const createStore = createStoreFactory({Component, createContext, createElement});
const { Provider, Connect } = createStore(
[
actionsWS, // websocket actions
actionsTimer, // Timer actions
]
);
export { Provider, Connect };
Прежде чем создавать модуль таймера нам надо получать данные от сервера. Создадим акшнес файл для работы с сокетом.
/** src/actionsWS.js */
export const __module_name = 'actionsWS'
let __emit;
// получаем функцию emit от redoor
export const bindStateMethods = (getState, setState, emit) => {
__emit = emit
};
// подключаемся к серверу
let wss = new WebSocket('ws://localhost:8888')
// получаем все сообщения от сервера и отправляем их в поток redoor
wss.onmessage = (msg) => {
let d = JSON.parse(msg.data);
__emit(d.name, d.data);
}
Здесь надо остановиться поподробнее. Наши сервисы отправляют данные в виде объекта с полями: имя и данные. В библиотеке redoor можно так же создавать потоки событий в которые мы просто передаем данные и имя. Выглядит это примерно так:
+------+
| emit | --- events --+--------------+----- ... ------+------------->
+------+ | | |
v v v
+----------+ +----------+ +----------+
| actions1 | | actions2 | ... | actionsN |
+----------+ +----------+ +----------+
Таким образом каждый модуль имеет возможность "слушать" свои события и по надобности и чужие тоже.
Теперь создадим собственно сам модуль таймера. В папке Timer
создадим два файла Timer.js
и actionsTimer.js
/** src/Timer/Timer.js */
import React from 'react';
import {Connect} from '../store'
import s from './Timer.module.css'
const Timer = ({timer_str}) => <div className={s.root}>
{timer_str}
</div>
export default Connect(Timer);
Здесь все просто, таймер берет из глобального стейта timer_str
который обновляется в actionsTimer.js
. Функция Connect
подключает модуль к redoor.
/** src/Timer/actionsTimer.js */
export const __module_name = 'actionsTimer'
let __setState;
// получаем метод для обновления стейта
export const bindStateMethods = (getState, setState) => {
__setState = setState;
};
// инициализируем переменную таймера
export const initState = {
timer_str:''
}
// "слушаем" поток событий нам нужен "timer"
export const listen = (name,data) =>{
name === 'timer' && updateTimer(data);
}
// обновляем стейт
function updateTimer(data) {
__setState({timer_str:data.time_str})
}
В акшес файле, мы "слушаем" событие timer
таймера (функция listen
) и как только оно будет получено обновляем стейт и выводим строку с данными.
Подробнее о функциях redoor:
__module_name
- зарезервированная переменная нужна просто для отладки она сообщает в какой модуль входят акшенсы.
bindStateMethods
- функция для получения setState
, поскольку данные приходят асинхронно нам надо получить в локальных переменных функцию обновления стейта.
initState
- функция или объект инициализации данных модуля в нашем случае это timer_str
listen
- функция в которую приходят все события сгенерированные redoor.
Готово. Запускаем компиляцию и открываем браузер по адресу http://localhost:3000
npx rollup -c rollup.config.js --watch
Должны появиться часики с временем. Перейдём к более сложному. По аналогии с таймером добавим еще модуль статистики. Для начала добавим новый генератор данных в echo_server.js
/** src/ws_server/echo_server.js */
...
let g_interval = 1;
// Данные статистики
setInterval(e=>{
let stats_array = [];
for(let i=0;i<30;i++) {
stats_array.push((Math.random()*(i*g_interval))|0);
}
let data = {
stats_array
}
sendToAll({name:'stats', data});
},500);
...
И добавим модуль в проект. Для этого создадим папку Stats
в которой создадим Stats.js
и actionsStats.js
/** src/Stats/Stats.js */
import React from 'react';
import {Connect} from '../store'
import s from './Stats.module.css'
const Bar = ({h})=><div className={s.bar} style={{height:`${h}`px}}>
{h}
</div>
const Stats = ({stats_array})=><div className={s.root}>
<div className={s.bars}>
{stats_array.map((it,v)=><Bar key={v} h={it} />)}
</div>
</div>
export default Connect(Stats);
/** src/Stats/actionsStats.js */
export const __module_name = 'actionsStats'
let __setState = null;
export const bindStateMethods = (getState, setState, emit) => {
__setState = setState;
}
export const initState = {
stats_array:[],
}
export const listen = (name,data) =>{
name === 'stats' && updateStats(data);
}
function updateStats(data) {
__setState({
stats_array:data.stats_array,
})
}
и подключаем новый модуль к стору
/** src/store.js */
...
import * as actionsStats from './Stats/actionsStats'
const { Provider, Connect } = createStore(
[
actionsWS,
actionsTimer,
actionsStats //<-- модуль Stats
]
);
...
В итоге мы должны получить это:
Как видите модуль Stats
принципиально не отличается от модуля Timer
, только отображение не строки, а массива данных. Что если мы хотим не только получать данные, но и отправлять их на сервер? Добавим управление статистикой.
В нашем примере переменная g_interval это угловой коэффициент наклона нормировки случайной величины. Попробуем ей управлять с нашего приложения.
Добавим пару кнопок к графику статистики. Плюс будет увеличвать значение interval
минус уменьшать.
/** src/Stats/Stats.js */
...
import Buttons from './Buttons' // импортируем модуль
...
const Stats = ({cxRun, stats_array})=><div className={s.root}>
<div className={s.bars}>
{stats_array.map((it,v)=><Bar key={v} h={it} />)}
</div>
<Buttons/> {/*Модуль кнопочки*/}
</div>
...
И сам модуль с кнопочками
/** src/Stats/Buttons.js */
import React from 'react';
import {Connect} from '../store'
import s from './Stats.module.css'
const DATA_INTERVAL_PLUS = {
name:'change_interval',
interval:1
}
const DATA_INTERVAL_MINUS = {
name:'change_interval',
interval:-1
}
const Buttons = ({cxEmit, interval})=><div className={s.root}>
<div className={s.btns}>
<button onClick={e=>cxEmit('ws_send',DATA_INTERVAL_PLUS)}>
plus
</button>
<div className={s.len}>interval:{interval}</div>
<button onClick={e=>cxEmit('ws_send',DATA_INTERVAL_MINUS)}>
minus
</button>
</div>
</div>
export default Connect(Buttons);
Получаем панель с кнопочками:
И модифицируем actionsWS.js
/** src/actionsWS.js */
...
let wss = new WebSocket('ws://localhost:8888')
wss.onmessage = (msg) => {
let d = JSON.parse(msg.data);
__emit(d.name, d.data);
}
// "слушаем" событие отправить данные на сервер
export const listen = (name,data) => {
name === 'ws_send' && sendMsg(data);
}
// отправляем данные
function sendMsg(msg) {
wss.send(JSON.stringify(msg))
}
Здесь мы в модуле Buttons.js
воспользовались встроенной функции (cxEmit
) создания события в библиотеке redoor. Событие ws_send
"слушает" модуль actionsWS.js
. Полезная нагрузка data
- это два объекта: DATA_INTERVAL_PLUS
и DATA_INTERVAL_MINUS
. Таким образам если нажать кнопку плюс на сервер будет отправлен объект { name:'change_interval', interval:1 }
На сервере добавляем
/** src/ws_server/echo_server.js */
...
wss.on('connection', function onConnect(ws) {
// "слушаем" приложение на событие "change_interval"
// от модуля Buttons.js
ws.on('message', function incoming(data) {
let d = JSON.parse(data);
d.name === 'change_interval' && change_interval(d);
});
});
let g_interval = 1;
// меняем интервал
function change_interval(data) {
g_interval += data.interval;
// создаем событие, что интервал изменен
sendToAll({name:'interval_changed', data:{interval:g_interval}});
}
...
И последний штрих необходимо отразить изменение интервала в модуле Buttons.js. Для этого в actionsStats.js начнём слушать событие "interval_changed
" и обновлять переменную interval
/** src/Stats/actionsStats.js */
...
export const initState = {
stats_array:[],
interval:1 // добавляем переменную интервал
}
export const listen = (name,data) =>{
name === 'stats' && updateStats(data);
// "слушаем" событие обновления интервала
name === 'interval_changed' && updateInterval(data);
}
// обнавляем интервал
function updateInterval(data) {
__setState({
interval:data.interval,
})
}
function updateStats(data) {
__setState({
stats_array:data.stats_array,
})
}
Итак, мы получили три независимых модуля, где каждый модуль следит только за своим событием и отображает только его. Что довольно удобно когда еще не ясна до конца структура и протоколы на этапе прототипирования. Надо только добавить, что поскольку все события имеют сквозную структуру то надо четко придерживаться шаблона создания события мы для себя выбрали такую: (MODULEN AME)_(FUNCTION NAME)_(VAR NAME)
.
Надеюсь было полезно. Исходные коды проекта, как обычно, на гитхабе.