Бесшовный клиент-сервер

    Любой клиент-серверный проект подразумевает четкое разделение кодовой базы на 2 части (иногда больше) — клиентскую и серверную. Зачастую, каждая такая часть оформляется в виде отдельного независимого проекта, поддерживаемого своей командой девелоперов.

    В этой статье я предлагаю критически посмотреть на стандартное жесткое разделение кода на бэкенд и фронтенд. И рассмотрим альтернативу, где в коде нет четкой грани между клиентом и сервером.



    Минусы стандартного подхода


    Основной минус стандартного разделения проекта на 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')
        }
        
    }
    

    Таким образом, вся бизнес-логика модели у нас перед глазами. Поддерживать такой код проще. Вот плюсы, которые может принести совмещение клиент-серверных методов в одной модели:

    1. Бизнес-логика сконцентрирована в одном месте, нет необходимости разделять ее между клиентом и сервером.
    2. Можно легко переносить функциональность от сервера к клиенту или от клиента к серверу в процессе развития проекта.
    3. Нет необходимости дублировать одинаковые методы для бэкенда и фронтенда.
    4. Единый набор тестов для всей бизнес-логики проекта.
    5. Замена горизонтальных линий разграничения ответственности в проекте на вертикальные.

    Последний пункт раскрою подробнее. Представим обычное клиент-серверное приложение в виде такой схемы:



    Вася отвечает за фронтенд, Федя — за бэкенд. Линия разграничения ответственности проходит горизонтально. Эта схема имеет недостатки любой вертикальной структуры — она сложно масштабируется и имеет низкую отказоустойчивость. Если проект расширяется вам придется делать довольно сложный выбор: кого усилить Васю или Федю? Или если заболел или уволился Федя, Вася не сможет его заменить.

    Предлагаемый здесь подход позволяет развернуть линию разграничения ответственности на 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


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

    Этот проект экспериментальный, пишите в комментариях, стоит ли, на ваш взгляд, продолжить этот эксперимент.
    Поделиться публикацией

    Комментарии 15

      +2
      Это больно читать.
      броузере

      денных для бакэнда

        0
        Спасибо, поправил
        –3
        Больше, больше таких гениальных статей на Хабре!!!

        image
          0
          Во-первых, всё это имеет достаточно условный смысл при условии, что клиент и сервер пишется на одном языке.

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

          Если так не хочется пилить отдельную валидацию на клиенте, то самым правильным вариантом будет валидация на сервере.
            0
            Во-первых, всё это имеет достаточно условный смысл при условии, что клиент и сервер пишется на одном языке.

            да, речь идет только о js, я это в тегах отметил.

            Во-вторых, та же валидация на сервере может быть более сложной, нежели на клиенте.

            Серверная валидация, как правило, или такая-же как клиентская или расширяет ее. Если это не так, то возможны казусы. Недавно здесь была статья, как человек хакал госуслуги из-за разной валидации в бакенде и фронте. Так что без дубляжа не обойтись.

            Но это одна из возможностей. Интересной фишкой, на мой взгляд, может быть упрощение перемещения функцональности от клиента к серверу и обратно. Например, у вас работает какой-то сложный алгоритм обработки данных на серверной стороне. В какой-то момент вы решили перенести этот алгоритм или его часть на клиентскую сторону (что бы не переплачивать за AWS, например). В моем случае сделать это легче. Ну и тестировать всю бизнес-логику, когда она сконцентрирована в одном месте тоже проще.
            0
            В 1С уже все придумали
              0
              Я ждал, что будет такой комментарий))
              0

              Идея очень похожа на Meteor.js.


              Про него, к сожалению, слышал только негативные отзывы, что он плохо масштабируется и не гибкий совсем. Интересно, чем эта затея кончится у вас.

                0
                Да, идея использовать один код на клиенте и сервере не нова. Очень хотелось сделать так, что бы не было завязки на какой-то конкретный фрэймворк. В моем случае на сервере стандартный express (не сложно и что-то другое подключить), на клиенте, так же, может быть что угодно.
                0
                Ещё можешь посмотреть в сторону haxe. Отлично собирается в js, и богатый функционал для написания макросов. Я год назад делал похожую библиотеку, только вместо server function writeDataToDb использовал function srv_writeDataToDb. При компиляции в эту функцию добавлялась проверка на то, что этот фрагмент выполняется на сервере, иначе нужно упаковывать все аргументы функции в пакет и отправить на сервер.
                  0
                  haxe — отдельный язык. Тут уже упоминали 1С. Вопрос в том, на сколько реально найти спецов на haxe? Особенно, в свете загибания флэша. Все-таки, js-программеров несравненно больше.
                  0
                  бакэнд

                  Я приношу извинения, но читая это слово, я думаю о чём угодно, даже о бакенбардах — только не о backend. Обычно пишут "бэкенд". Советую исправить, иначе кому-то будет больно, как первому комментатору(

                    0
                    Не нужно извинений, душевное состояние чувствительных читателей важнее) Поправил
                    0
                    Хм… очень спорно…

                    Пытался придумать «плюсы», но в голову лезут одни «минусы».

                    «Зависимости» на фронтэнде и бэкэнде имеют свою специфику.
                    Придется пилить костыли для DI.

                    Какой-то части серверного кода противопоказано находиться на клиенте.
                    Значит его как-то придется всеравно «делить» на серверный и клиентский.

                    и еще куча минусов если копнуть по глубже…

                    Если уж попытаться объединить бэкэнд и фронтэнд, то наверное таким способом:
                    1. Специализированная структура проекта, позволяющая без больших заморочек «разделить» проект на клиент и сервер.
                    2.Специальная система сборки проекта, которая его и «поделит» на клиент и сервер.

                    Но все равно сомневаюсь, что плюсы такого подхода перевесят минусы.
                      0
                      Я исходил из того, что у клиента и сервера общие только модели данных. Остальное у каждого свое. Структуры файлов внутри каталогов protected и public так же могут быть специфическими.

                      «Зависимости» на фронтэнде и бэкэнде имеют свою специфику.

                      Если Вы имеете ввиду синтаксис подключения зависимостей, то ES-модули для того и придуманы, что бы унифицировать клиент и сервер в этом вопросе.
                      Если речь идет о зависимостях которые используются только на серверной стороне или только на клиентской, то с ними поступаем ровно так же как с клиентскими и серверными методами, т.е. отмечаем соответствующими директивами:
                      !#server
                      import db from "pg";
                      ....
                      

                      Если уж попытаться объединить бэкэнд и фронтэнд, то наверное таким способом:
                      1. Специализированная структура проекта, позволяющая без больших заморочек «разделить» проект на клиент и сервер.
                      2.Специальная система сборки проекта, которая его и «поделит» на клиент и сервер.

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

                      Но все равно сомневаюсь, что плюсы такого подхода перевесят минусы.

                      Насчет минусов. Я не думаю, что подобный проект на проде будет чем-то отличаться от обычного, изначально разделенного на бакенд и фронтенд.
                      Насчет плюсов. Конкретно этот подход я еще не пробовал в реальных проектах. Но нечто подобное на базе ExtJs использую уже несколько лет (у меня в профиле есть пара статей на эту тему). Чисто по опыту, могу сказать, что когда бизнес-логика модуля под рукой и не нужно прыгать между клиентом и сервером, как минимум, это удобно.

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

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