Любой клиент-серверный проект подразумевает четкое разделение кодовой базы на 2 части (иногда больше) — клиентскую и серверную. Зачастую, каждая такая часть оформляется в виде отдельного независимого проекта, поддерживаемого своей командой девелоперов.
В этой статье я предлагаю критически посмотреть на стандартное жесткое разделение кода на бэкенд и фронтенд. И рассмотрим альтернативу, где в коде нет четкой грани между клиентом и сервером.
Основной минус стандартного разделения проекта на 2 части — это размывание бизнес-логики между клиентом и сервером. Мы редактируем данные в форме в браузере, верифицируем их в клиентском коде и отправляем на деревню дедушке (на сервер). Сервер — это уже другой проект. Там тоже нужно проверить корректность поступивших данных (т.е. продублировать функциональность клиента), сделать какие-то дополнительные манипуляции (сохранить в базе, отправить e-mail и т.д.).
Таким образом, что бы проследить весь путь информации от формы в браузере до базы данных на сервере нам придется копаться в двух разноплановых системах. Если в команде разделены роли и за бэкенд и фронтенд отвечают разные специалисты, возникают дополнительные организационные проблемы связанные с их синхронизацией.
Предположим, что мы можем описать весь путь данных от формы на клиенте до базы на сервере в одной модели. В коде это может выглядеть примерно так (код не рабочий):
Таким образом, вся бизнес-логика модели у нас перед глазами. Поддерживать такой код проще. Вот плюсы, которые может принести совмещение клиент-серверных методов в одной модели:
Последний пункт раскрою подробнее. Представим обычное клиент-серверное приложение в виде такой схемы:
Вася отвечает за фронтенд, Федя — за бэкенд. Линия разграничения ответственности проходит горизонтально. Эта схема имеет недостатки любой вертикальной структуры — она сложно масштабируется и имеет низкую отказоустойчивость. Если проект расширяется вам придется делать довольно сложный выбор: кого усилить Васю или Федю? Или если заболел или уволился Федя, Вася не сможет его заменить.
Предлагаемый здесь подход позволяет развернуть линию разграничения ответственности на 90 градусов и превратить вертикальную архитектуру в горизонтальную.
Такая архитектура гораздо проще масштабируется и более отказоустойчивая т.к. Вася и Федя становятся взаимозаменяемыми.
В теории выглядит неплохо, попробуем реализовать все это на практике, не растеряв по дороге все то, что дает нам раздельное существование клиента и сервера.
Нам совершенно не обязательно в продуктиве иметь интегрированный клиент-сервер. Напротив, такое решение было бы крайне вредным со всех точек зрения. Задача состоит в том, что бы в процессе разработки у нас была единая кодовая база для моделей данных для бэкенда и фронтенда, но на выходе получались бы независимые клиент и сервер. В этом случае мы получим все преимущества стандартного подхода и приобретем удобства в разработке и поддержке проекта, перечисленные выше.
Уже довольно давно экспериментирую с интеграцией клиента и сервера в одном файле. Основной проблемой до недавнего времени было то, что в стандартном JS подключение сторонних модулей на клиенте и сервере происходило слишком по-разному: require(...) в node.js, на клиенте всякая AJAX-магия. Все поменялось с появлением ES-модулей. В современных браузерах «import» поддерживается уже давно. Node.js немного отстает в этом плане и ES-модули поддерживаются только с включенным флагом "--experimental-modules". Есть надежда, что в обозримом будущем модули заработают «из коробки» и в node.js. Кроме того, вряд ли что-то сильно поменяется, т.к. в браузерах эта функциональность уже давно работает по-умолчанию. Думаю, уже сейчас можно использовать ES-модули не только на клиентской но и на серверной стороне (если у вас есть контр-аргументы на этот счет, напишите в комментариях).
Схема решения выглядит так:
Проект содержит три основных каталога:
protected — бэкенд;
public — фронтенд;
shared — общие клиент-серверные модели.
Отдельный процесс-наблюдатель (observer) следит за файлами в каталоге shared и при любых изменениях создает версии измененного файла отдельно для клиента и отдельно для сервера (в каталогах protected/shared и public/shared).
Рассмотрим пример простенького real-time мессенджера. Нам понадобится свежий node.js (у меня версия 11.0.0) и Redis (их установка тут не рассматривается).
Склонируем пример:
Установим и запустим процесс-наблюдатель (observer на схеме):
Если все в порядке, наблюдатель запустит веб-сервер и начнет мониторить изменения файлов в каталогах shared и protected. При изменениях в shared создаются соответствующие версии моделей данных для клиента и для сервера. При изменениях в protected наблюдатель автоматически перезапустит веб-сервер.
Посмотреть работоспособность мессенджера можно в браузере перейдя по ссылке
(token и user произвольные). Для эмуляции нескольких пользователей откройте эту же страницу в другом браузере указав другие token и user.
Теперь немного кода.
protected/server.mjs
Это обычный express-сервер, здесь нет ничего интересного. Расширение «mjs» нужно для ES-модулей в node.js. Для единообразия, будем использовать это расширение и для клиента.
public/index.html
Для примера я использую на клиенте Vue, но сути это не меняет. Вместо Vue может быть что угодно, где можно выделить модель данных в отдельный класс (knockout, angular).
public/main.mjs
main.mjs — это скрипт, связывающий модели данных с соответствующими представлениями. Для упрощения кода примера представления для списка активных пользователей и ленты сообщений встроены прямо в index.html
shared/messages/model/dataModel.mjs
Эти несколько методов реализуют всю функциональность отправки и приема сообщений в режиме реального времени. Директивы !#client и !#server указывают процессу-наблюдателю какой метод для какой части (клиент или сервер) предназначен. Если перед определением метода нет этих директив, такой метод доступен и на клиенте и на сервере. Слэши комментария перед директивой не обязательны и существуют только для того, что бы стандартная IDE не ругалось на ошибки в синтаксисе.
В первой строке в пути используется подстановка &root. При генерации клиентской и серверной версий &root будет заменен на относительный путь к каталогам public и protected соответственно.
Еще важный момент: из клиентского метода можно вызвать только тот серверный метод, название которого начинается с "$":
Это сделано по соображениям безопасности: извне можно обратиться только к специально-предназначенным для этого методам.
Давайте посмотрим на версии моделей данных которые наблюдатель (observer) сгенерировал для клиента и сервера.
Клиент (public/shared/messages/model/dataModel.mjs)
На клиентской стороне модель является потомком класса Vue (через Base.mjs). Таким образом, вы можете работать с ней как с обычной моделью данных Vue. Наблюдатель добавил в клиентскую версию модели метод __getFilePath__ который возвращает путь к файлу класса и заменил код серверного метода $sendMessage на конструкцию, которая, по-сути, через механизм rpc вызовет нужный нам метод на сервере (__runSharedFunction определен в родительском классе).
Сервер (protected/shared/messages/model/dataModel.mjs)
В серверной версии так же добавлен метод __getFilePath__ и удалены клиентские методы отмеченные директивой !#client
В обеих сгенерированных версиях модели все удаленные строки заменяются на пустые. Это сделано для того, что бы по сообщению об ошибках отладчика можно было легко найти проблемную строку в исходном коде модели.
Когда нам нужно вызвать на клиенте какой-то серверный метод, просто делаем это.
Если вызов в рамках одной модели, тут все просто:
Можно «дернуть» другую модель:
В обратную сторону, т.е. позвать на сервере какой-нибудь клиентский метод, не работает. Технически это реализуемо, но с практической точки зрения лишено смысла, т.к. сервер один, а клиентов много. Если нам нужно на сервере инициировать какие-то действия на клиенте используем событийный механизм:
Метод fireEvent принимает 3 параметра: название события, кому оно адресовано и данные. Адресата можно задать несколькими способами: ключевое слово «all» — событие будет разослано всем пользователям или в массиве перечислить токены сессии тех клиентов, которым адресуется событие.
Событие не привязано к конкретному инстансу класса модели данных и сработают обработчики во всех экземплярах класса, в котором был вызван fireEvent.
Монолитность клиент-серверных моделей в предлагаемой реализации, на первый взгляд, должна накладывать существенные ограничения на возможности горизонтального масштабирования серверной части. Но это не так: технически сервер не зависит от клиента. Вы можете скопировать каталог «public» куда угодно и отдавать его содержимое через любой другой веб-сервер (nginx, apache и т.д.).
Серверную часть можно легко расширять запуская новые экземпляры бэкенда. Для взаимодействия отдельных экземпляров используется Redis и система очередей Kue.
В реальных проектах одним серверным API могут пользоваться разноплановые клиенты — веб-сайты, мобильные приложения, сторонние сервисы. В предложенном решении все это доступно без каких либо дополнительных танцев. Под капотом вызова серверных методов находится старый добрый rpc. Сам веб-сервер — это классическое express-приложение. Достаточно добавить туда обертку для роутов с вызовом нужных методов тех-же моделей данных.
Предлагаемый в статье подход не претендует на какие-то революционные изменения в клиент-серверных приложениях. Он, только, добавляет немного комфорта в процесс разработки, позволяя сосредоточится на бизнес-логике собранной в одном месте.
Этот проект экспериментальный, пишите в комментариях, стоит ли, на ваш взгляд, продолжить этот эксперимент.
В этой статье я предлагаю критически посмотреть на стандартное жесткое разделение кода на бэкенд и фронтенд. И рассмотрим альтернативу, где в коде нет четкой грани между клиентом и сервером.
Минусы стандартного подхода
Основной минус стандартного разделения проекта на 2 части — это размывание бизнес-логики между клиентом и сервером. Мы редактируем данные в форме в браузере, верифицируем их в клиентском коде и отправляем на деревню дедушке (на сервер). Сервер — это уже другой проект. Там тоже нужно проверить корректность поступивших данных (т.е. продублировать функциональность клиента), сделать какие-то дополнительные манипуляции (сохранить в базе, отправить e-mail и т.д.).
Таким образом, что бы проследить весь путь информации от формы в браузере до базы данных на сервере нам придется копаться в двух разноплановых системах. Если в команде разделены роли и за бэкенд и фронтенд отвечают разные специалисты, возникают дополнительные организационные проблемы связанные с их синхронизацией.
Давайте помечтаем
Предположим, что мы можем описать весь путь данных от формы на клиенте до базы на сервере в одной модели. В коде это может выглядеть примерно так (код не рабочий):
class MyDataModel {
// метод используется на клиентской и на серверной стороне
verifyData(data) {
// проверка данных
....
return true;
}
// вызывается на клиенте при сабмите формы
client saveData(data) {
if(this.verifyData(data))
this.writeDataToDb(data)
else
consol.log('error')
}
// серверный метод. Сохраняем данные в БД
server writeDataToDb(data) {
if(this.verifyData(data))
this.db.insert(data)
else
consol.log('error')
}
}
Таким образом, вся бизнес-логика модели у нас перед глазами. Поддерживать такой код проще. Вот плюсы, которые может принести совмещение клиент-серверных методов в одной модели:
- Бизнес-логика сконцентрирована в одном месте, нет необходимости разделять ее между клиентом и сервером.
- Можно легко переносить функциональность от сервера к клиенту или от клиента к серверу в процессе развития проекта.
- Нет необходимости дублировать одинаковые методы для бэкенда и фронтенда.
- Единый набор тестов для всей бизнес-логики проекта.
- Замена горизонтальных линий разграничения ответственности в проекте на вертикальные.
Последний пункт раскрою подробнее. Представим обычное клиент-серверное приложение в виде такой схемы:
Вася отвечает за фронтенд, Федя — за бэкенд. Линия разграничения ответственности проходит горизонтально. Эта схема имеет недостатки любой вертикальной структуры — она сложно масштабируется и имеет низкую отказоустойчивость. Если проект расширяется вам придется делать довольно сложный выбор: кого усилить Васю или Федю? Или если заболел или уволился Федя, Вася не сможет его заменить.
Предлагаемый здесь подход позволяет развернуть линию разграничения ответственности на 90 градусов и превратить вертикальную архитектуру в горизонтальную.
Такая архитектура гораздо проще масштабируется и более отказоустойчивая т.к. Вася и Федя становятся взаимозаменяемыми.
В теории выглядит неплохо, попробуем реализовать все это на практике, не растеряв по дороге все то, что дает нам раздельное существование клиента и сервера.
Постановка задачи
Нам совершенно не обязательно в продуктиве иметь интегрированный клиент-сервер. Напротив, такое решение было бы крайне вредным со всех точек зрения. Задача состоит в том, что бы в процессе разработки у нас была единая кодовая база для моделей данных для бэкенда и фронтенда, но на выходе получались бы независимые клиент и сервер. В этом случае мы получим все преимущества стандартного подхода и приобретем удобства в разработке и поддержке проекта, перечисленные выше.
Решение
Уже довольно давно экспериментирую с интеграцией клиента и сервера в одном файле. Основной проблемой до недавнего времени было то, что в стандартном JS подключение сторонних модулей на клиенте и сервере происходило слишком по-разному: require(...) в node.js, на клиенте всякая AJAX-магия. Все поменялось с появлением ES-модулей. В современных браузерах «import» поддерживается уже давно. Node.js немного отстает в этом плане и ES-модули поддерживаются только с включенным флагом "--experimental-modules". Есть надежда, что в обозримом будущем модули заработают «из коробки» и в node.js. Кроме того, вряд ли что-то сильно поменяется, т.к. в браузерах эта функциональность уже давно работает по-умолчанию. Думаю, уже сейчас можно использовать ES-модули не только на клиентской но и на серверной стороне (если у вас есть контр-аргументы на этот счет, напишите в комментариях).
Схема решения выглядит так:
Проект содержит три основных каталога:
protected — бэкенд;
public — фронтенд;
shared — общие клиент-серверные модели.
Отдельный процесс-наблюдатель (observer) следит за файлами в каталоге shared и при любых изменениях создает версии измененного файла отдельно для клиента и отдельно для сервера (в каталогах protected/shared и public/shared).
Реализация
Рассмотрим пример простенького real-time мессенджера. Нам понадобится свежий node.js (у меня версия 11.0.0) и Redis (их установка тут не рассматривается).
Склонируем пример:
git clone https://github.com/Kolbaskin/both-example
cd ./both-example
npm i
Установим и запустим процесс-наблюдатель (observer на схеме):
npm i both-js -g
both ./index.mjs
Если все в порядке, наблюдатель запустит веб-сервер и начнет мониторить изменения файлов в каталогах shared и protected. При изменениях в shared создаются соответствующие версии моделей данных для клиента и для сервера. При изменениях в protected наблюдатель автоматически перезапустит веб-сервер.
Посмотреть работоспособность мессенджера можно в браузере перейдя по ссылке
http://localhost:3000/index.html?token=123&user=Vasya
(token и user произвольные). Для эмуляции нескольких пользователей откройте эту же страницу в другом браузере указав другие token и user.
Теперь немного кода.
Веб-сервер
protected/server.mjs
import express from 'express';
import bodyParser from 'body-parser';
// веб-сокеты используется в качестве транспорта
// для клиент-серверного взаимодействия
import wsServer from './lib/wsServer.mjs';
const app = express();
// запускаем сервер веб-сокетов
wsServer(app);
// добавим mime для mjs
express.static.mime.define({'application/javascript': ['js','mjs']});
app.use( bodyParser.json() );
app.use(bodyParser.urlencoded({ extended: true }));
// статический контент отдаем из каталога public
app.use(express.static('public'));
const server = app.listen(3000, () => {
console.log('server is running at %s', server.address().port);
});
Это обычный express-сервер, здесь нет ничего интересного. Расширение «mjs» нужно для ES-модулей в node.js. Для единообразия, будем использовать это расширение и для клиента.
Клиент
public/index.html
<!DOCTYPE html>
<html lang="en">
<head>
...
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script src="/main.mjs" type="module"></script>
</head>
<body>
...
<ul id="users">
<li v-for="user in users"> {{ user.name }} ({{user.id}}) </li>
</ul>
<div id="messages">
<div>
<input type="text" v-model="msg" />
<button v-on:click="sendMessage()">Отправить</button>
</div>
<ul>
<li v-for="message in messages">[{{ message.date }}] <strong>{{ message.text }}</strong></li>
</ul>
</div>
</body>
</html>
Для примера я использую на клиенте Vue, но сути это не меняет. Вместо Vue может быть что угодно, где можно выделить модель данных в отдельный класс (knockout, angular).
public/main.mjs
// импортируем класс для работы с веб-сокетом
import ws from "/lib/Ws.mjs";
// модель данных для работы с сообщениями
import Messages from "./shared/messages/model/dataModel.mjs";
// модель данных пользователей
import Users from "./shared/users/model/dataModel.mjs";
// подключаем веб-сокет (на проект нам достаточно одного коннекта)
window.WS = new ws({
token: new URLSearchParams(document.location.search).get("token"),
user: new URLSearchParams(document.location.search).get("user")
});
// связываем модель данных сообщений с представлением
new Messages({
el: '#messages'
})
// связываем модель данных пользователей с представлением
new Users({
el: '#users'
})
main.mjs — это скрипт, связывающий модели данных с соответствующими представлениями. Для упрощения кода примера представления для списка активных пользователей и ленты сообщений встроены прямо в index.html
Модель данных
shared/messages/model/dataModel.mjs
// импортируем базовый класс
// базовые классы для клиентской и серверной частей содержат разные методы,
// но называются одинаково
import Base from '@root/lib/Base.mjs';
export default class dataModel extends Base {
//!#client
constructor(attr) {
attr.data = {
msg: '',
messages: []
}
super(attr);
// подписываемся на новые сообщения
this.on('newmessage', (data) => {
this.messages.push(data)
})
}
//!#client
async sendMessage(e) {
//отправляем сообщение на сервер
await this.$sendMessage(this.msg);
this.msg = '';
}
//!#server
async $sendMessage(text) {
// генерируем событие newmessage для всех подключенных пользователей
this.fireEvent('newmessage', 'all', {
date: new Date(),
text
})
return true;
}
}
Эти несколько методов реализуют всю функциональность отправки и приема сообщений в режиме реального времени. Директивы !#client и !#server указывают процессу-наблюдателю какой метод для какой части (клиент или сервер) предназначен. Если перед определением метода нет этих директив, такой метод доступен и на клиенте и на сервере. Слэши комментария перед директивой не обязательны и существуют только для того, что бы стандартная IDE не ругалось на ошибки в синтаксисе.
В первой строке в пути используется подстановка &root. При генерации клиентской и серверной версий &root будет заменен на относительный путь к каталогам public и protected соответственно.
Еще важный момент: из клиентского метода можно вызвать только тот серверный метод, название которого начинается с "$":
...
//отправляем сообщение на сервер
async sendMessage(e) {
await this.$sendMessage(this.msg); <- вызываем серверный метод
this.msg = '';
}
...
Это сделано по соображениям безопасности: извне можно обратиться только к специально-предназначенным для этого методам.
Давайте посмотрим на версии моделей данных которые наблюдатель (observer) сгенерировал для клиента и сервера.
Клиент (public/shared/messages/model/dataModel.mjs)
import Base from '/lib/Base.mjs';
export default class dataModel extends Base { __getFilePath__() {return "messages/model/dataModel.mjs"}
//
constructor(attr) {
attr.data = {
msg: '',
messages: []
}
super(attr);
// подписываемся на новые сообщения
this.on('newmessage', (data) => {
this.messages.push(data)
})
}
//
async sendMessage(e) {
//отправляем сообщение на сервер
await this.$sendMessage(this.msg);
this.msg = '';
}
//
...
async $sendMessage() {return await this.__runSharedFunction("$sendMessage",arguments)}
}
На клиентской стороне модель является потомком класса Vue (через Base.mjs). Таким образом, вы можете работать с ней как с обычной моделью данных Vue. Наблюдатель добавил в клиентскую версию модели метод __getFilePath__ который возвращает путь к файлу класса и заменил код серверного метода $sendMessage на конструкцию, которая, по-сути, через механизм rpc вызовет нужный нам метод на сервере (__runSharedFunction определен в родительском классе).
Сервер (protected/shared/messages/model/dataModel.mjs)
import Base from '../../lib/Base.mjs';
export default class dataModel extends Base { __getFilePath__() {return "messages/model/dataModel.mjs"}
... куча пустых строк вместо клиентских методов ...
//
async $sendMessage(text) {
// генерируем событие newmessage для всех подключенных пользователей
this.fireEvent('newmessage', 'all', {
date: new Date(),
text
})
return true;
}
}
В серверной версии так же добавлен метод __getFilePath__ и удалены клиентские методы отмеченные директивой !#client
В обеих сгенерированных версиях модели все удаленные строки заменяются на пустые. Это сделано для того, что бы по сообщению об ошибках отладчика можно было легко найти проблемную строку в исходном коде модели.
Взаимодействие клиента и сервера
Когда нам нужно вызвать на клиенте какой-то серверный метод, просто делаем это.
Если вызов в рамках одной модели, тут все просто:
...
!#client
async sendMessage(e) {
await this.$sendMessage(this.msg);
this.msg = '';
}
!#server
async $sendMessage(msg) {
// что-то делаем на сервере
}
...
Можно «дернуть» другую модель:
import dataModel from "/shared/messages/model/dataModel.mjs";
var msg = new dataModel();
msg.$sendMessage('blah-blah-blah');
В обратную сторону, т.е. позвать на сервере какой-нибудь клиентский метод, не работает. Технически это реализуемо, но с практической точки зрения лишено смысла, т.к. сервер один, а клиентов много. Если нам нужно на сервере инициировать какие-то действия на клиенте используем событийный механизм:
// импортируем базовый класс
...
//!#client
constructor(attr) {
....
// на клиентской стороне подписываемся на событие "newmessage"
this.on('newmessage', (data) => {
this.messages.push(data)
})
}
//!#server
async $sendMessage(text) {
// генерируем на сервере событие newmessage для всех подключенных пользователей
this.fireEvent('newmessage', 'all', {
date: new Date(),
text
})
return true;
}
...
Метод fireEvent принимает 3 параметра: название события, кому оно адресовано и данные. Адресата можно задать несколькими способами: ключевое слово «all» — событие будет разослано всем пользователям или в массиве перечислить токены сессии тех клиентов, которым адресуется событие.
Событие не привязано к конкретному инстансу класса модели данных и сработают обработчики во всех экземплярах класса, в котором был вызван fireEvent.
Горизонтальное масштабирование бэкенда
Монолитность клиент-серверных моделей в предлагаемой реализации, на первый взгляд, должна накладывать существенные ограничения на возможности горизонтального масштабирования серверной части. Но это не так: технически сервер не зависит от клиента. Вы можете скопировать каталог «public» куда угодно и отдавать его содержимое через любой другой веб-сервер (nginx, apache и т.д.).
Серверную часть можно легко расширять запуская новые экземпляры бэкенда. Для взаимодействия отдельных экземпляров используется Redis и система очередей Kue.
API и разные клиенты к одному бэкенду
В реальных проектах одним серверным API могут пользоваться разноплановые клиенты — веб-сайты, мобильные приложения, сторонние сервисы. В предложенном решении все это доступно без каких либо дополнительных танцев. Под капотом вызова серверных методов находится старый добрый rpc. Сам веб-сервер — это классическое express-приложение. Достаточно добавить туда обертку для роутов с вызовом нужных методов тех-же моделей данных.
Post scriptum
Предлагаемый в статье подход не претендует на какие-то революционные изменения в клиент-серверных приложениях. Он, только, добавляет немного комфорта в процесс разработки, позволяя сосредоточится на бизнес-логике собранной в одном месте.
Этот проект экспериментальный, пишите в комментариях, стоит ли, на ваш взгляд, продолжить этот эксперимент.