Vue.js и слоистая архитектура: вынесение бизнес-логики в сервисы

    Когда нужно сделать код в проекте гибким и удобным, на помощь приходит разделение архитектуры на несколько слоев. Рассмотрим подробнее этот подход и альтернативы, а также поделимся рекомендациями, которые могут быть полезны как начинающим, так и опытным разработчикам Vue.js, React.js, Angular. 

    В старые времена, когда JQuery только появился, а о фреймворках для серверных языков лишь читали в редких новостях, веб-приложения реализовывали целиком на серверных языках. Зачастую для этого использовали модель MVC (Model-View-Controller): контроллер (controller) принимал запросы, отвечал за бизнес-логику и модели (model) и передавал данные в представление (view), которое рисовало HTML. 

    Объектно-ориентированное программирование (ООП) на тот момент только начинало формироваться, поэтому разработчики зачастую интуитивно решали, где и какой код надо писать. Таким образом, в мире разработки зародилось такое понятие, как «Божественные объекты», которые первоначально отвечали практически за всю работу отдельных частей системы. Например, если в системе была сущность «Пользователь», то создавался класс User и в нем писалась вся логика, так или иначе связанная с пользователями. Без разбиения на какие-то ещё файлы. И если приложение было большим, то такой класс мог содержать тысячи строк кода.

    Затем появились первые фреймворки, работать с ними стало удобнее, но они не учили, как правильно заложить структуру, архитектуру проекта. И разработчики продолжали писать тысячи строк кода в контроллерах новомодных фреймворков.

    1. Выход есть

    Как известно, Vue.js, React.js и прочие подобные фреймворки основаны на компонентах. То есть, по большому счету, приложение состоит из множества компонентов, которые могут заключать в себе и бизнес-логику и представление и много чего еще. Таким образом, разработчики во многих проектах пишут всю логику в компонентах и эти компоненты, как правило, начинают напоминать те самые божественные классы из прошлого. То есть, если компонент описывает какую-то крупную часть функционала с большим количеством (возможно сложной) логики, то вся эта логика и остается в компоненте. Появляются десятки методов и тысячи строк кода. А если учесть то, что, например, во Vue.js еще есть такие понятия как computed, watch, mounted, created, то логику пишут еще и во все эти части компонента. В итоге, чтобы найти какую-то часть кода, отвечающую за клик по кнопке, надо перелистать десяток экранов js-кода, бегая между methods, computed и прочими частями компонента.

    Примерно в 2008 году, применительно к backend, была предложена “слоистая” архитектура. Основная идея этой архитектуры заключается в том, что весь код приложения следует разбивать на определенные слои, которые выполняют определенную работу и не очень знают о других слоях.

    С подобным разбиением приложение становится намного проще поддерживать, писать тесты, искать ответственные зоны и вообще читать код.

    Вот о таком разбиении кода на слои и пойдет речь, но уже применительно к frontend-фреймворкам, таким как Vue.js, React.js и прочим. 

    Изначальная теория “слоистой” архитектуры, применительно к backend, имеет много ограничений и правил. Идея же этой статьи в том, чтобы перенять именно разбиение кодовой базы на слои. Схематично ее можно изобразить примерно так.

    2. Создание удобной архитектуры приложения

    Рассмотрим пример, в котором вся логика находится в одном компоненте.

    2.1. Логика в компоненте

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

    methods: {
        duplicateCollage (collage) {
          this.$store.dispatch('updateCollage', { id: collage.id, isDuplicating: true })
          dataService.duplicateCollage(collage, false)
            .then(duplicate => {
              this.$store.dispatch('updateCollage', { id: collage.id, isDuplicating: false })
            })
            .catch(() => {
              this.$store.dispatch('updateCollage', { id: collage.id, isDuplicating: false })
              this.$store.dispatch('errorsSet', { api: `We couldn't duplicate collage. Please, try again later.` })
            })
        },
        deleteCollage (collage, index) {
          this.$store.dispatch('updateCollage', { id: collage.id, isDeleting: true })
          photosApi.deleteUserCollage(collage)
            .then(() => {
              this.$store.dispatch('updateCollage', {
                id: collage.id,
                isDeleting: false,
                isDeleted: true
              })
              this.$store.dispatch('setUserCollages', { total: this.userCollages.total - 1 })
              this.$store.dispatch('updateCollage', {
                id: collage.id,
                deletingTimer: setTimeout(() => {
                  this.$store.dispatch('updateCollage', { id: collage.id, deletingTimer: null })
                  this.$store.dispatch('setUserCollages', { items: this.userCollages.items.filter(userCollage => userCollage.id !== collage.id) })
     
                  // If there is no one collages left - show templates
                  if (!this.$store.state.editor.userCollages.total) {
                    this.currentTabName = this.TAB_TEMPLATES
                  }
                }, 3000)
              })
            })
        },
        restoreCollage (collage) {
          clearTimeout(collage.deletingTimer)
          photosApi.saveUserCollage({ collage: { deleted: false } }, collage.id)
            .then(() => {
              this.$store.dispatch('updateCollage', {
                id: collage.id,
                deletingTimer: null,
                isDeleted: false
              })
              this.$store.dispatch('setUserCollages', { total: this.userCollages.total + 1 })
            })
        }
    }

    2.2. Создание слоя сервисов для бизнес-логики

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

    Один из классических способов хоть какого-то разбиения логики – это деление на сущности. Например, почти всегда в проекте есть сущность Пользователь или, как в описываемом примере, Коллаж. Таким образом, можно создать папку services и в ней – файлы user.js и collage.js. Такие файлы могут быть статическими классами или просто возвращать функции. Главное – чтобы вся бизнес-логика, связанная с сущностью, была в этом файле.

    services
      |_collage.js
      |_user.js

    В сервис collage.js следует поместить логику дублирования, восстановления и удаления коллажей.

    export default class Collage {
      static delete (collage) {
        // ЛОГИКА УДАЛЕНИЯ КОЛЛАЖА
      }
     
      static restore (collage) {
        // ЛОГИКА ВОССТАНОВЛЕНИЯ  КОЛЛАЖА
      }
     
      static duplicate (collage, changeUrl = true) {
        // ЛОГИКА ДУБЛИРОВАНИЯ КОЛЛАЖА
      }
    }

    2.3. Использование сервисов в компоненте

    Тогда в компоненте надо будет лишь вызвать соответствующие функции сервиса.

    methods: {
      duplicateCollage (collage) {
        CollageService.duplicate(collage, false)
      },
      deleteCollage (collage) {
        CollageService.delete(collage)
      },
      restoreCollage (collage) {
        CollageService.restore(collage)
      }
    }

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

    Также многие разработчики на пути к удобной архитектуре выносят вызовы методов API в отдельный файл (файлы). Это как раз создание слоя вызовов API, которое также приводит к удобству и структурированности кода.

    import axios from '@/plugins/axios'
     
    export default class Api {
     
      static login (email, password) {
        return axios.post('auth/login', { email, password })
          .then(response => response.data)
      }
     
      static logout () {
        return axios.post('auth/logout')
      }
     
      static getCollages () {
        return axios.get('/collages')
          .then(response => response.data)
      }
      
      static deleteCollage (collage) {
        return axios.delete(`/collage/${collage.id}`)
          .then(response => response.data)
      }
      
      static createCollage (collage) {
        return axios.post(`/collage/${collage.id}`)
          .then(response => response.data)
      }
    }

    3. Что и куда выносить?

    На вопрос, что же именно и куда выносить, однозначно ответить невозможно. Как вариант, можно разбить код на три условные части: бизнес-логика, логика и представление.

    Бизнес-логика – это все то, что описано в требованиях к приложению. Например, ТЗ, документации, дизайны. То есть все то, что напрямую относится к предметной области приложения. Примером может быть метод UserService.login() или ListService.sort(). Для бизнес-логики можно создать сервисный слой с сервисами.

    Логика – это тот код, который не имеет прямого отношения к предметной области приложения и его бизнес-логике. Например, создание уникальной строки или поиск некоего объекта в массиве. Для логики можно создать слой хэлперов: например, папку helpers и в ней файлы string.js, converter.js и прочие.

    Представление – все то, что непосредственно связано с компонентом и его шаблоном. Например, изменение реактивных свойств, изменение состояний и прочее. Этот код пишется непосредственно в компонентах (methods, computed, watch и так далее).

    login (email, password) {
      this.isLoading = true
      userService.login(email, password)
        .then(user => {
          this.user = user
          this.isLoading = false
        })
    }

    Далее в компонентах надо будет вызывать сервисы, а сервисы будут использовать хэлперы. Такой подход предоставит нам легкие, маленькие и простые компоненты, а вся логика будет находиться в логически понятных файлах.

    Если же сервисы или хэлперы начнут разрастаться, то сущности всегда можно разделить на другие сущности. К примеру, если у пользователя в приложении маленький функционал в 3-5 методов и пара методов про заказы пользователя, то разработчик может вынести всю эту бизнес-логику в сервис user.js. Если же у сервиса пользователя сотни строк кода, то можно все, что относится к заказам, вынести в сервис order.js.

    4. От простого к сложному

    В идеале можно сделать архитектуру на ООП, в которой будут, помимо сервисов, еще и модели. Это классы, описывающие сущности приложения. Те же User или Collage. Но использоваться они будут вместо обычных объектов данных.

    Рассмотрим список пользователей.

    Классический способ вывода ФИО пользователей выглядит так.

    <template>
    <div class="users">
      <div
        v-for="user in users"
        class="user"
      >
        {{ getUserFio(user) }}
      </div>
    </div>
    </template>
     
    <script>
    import axios from '@/plugins/axios'
     
    export default {
      data () {
        return {
          users: []
        }
      },
      mounted () {
        this.getList()
      },
      methods: {
        getList() {
          axios.get('/users')
            .then(response => this.users = response.data)
        },
        getUserFio (user) {
          return `${user.last_name} ${user.first_name} ${user.third_name}`
        }
      }
    }
    </script>

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

    Для начала следует создать модель Пользователь.

    export default class User {
      constructor (data = {}) {
        this.firstName = data.first_name
        this.secondName = data.second_name
        this.thirdName = data.third_name
      }
     
      getFio () {
        return `${this.firstName} ${this.secondName} ${this.thirdName}`
      }
    }

    Далее следует импортировать эту модель в компонент.

    import UserModel from '@/models/user'

    С помощью сервиса получить список пользователей и преобразовать каждый объект в массиве в объект класса (модели) User.

    methods: {
       getList() {
         const users = userService.getList()
         users.forEach(user => {
           this.users.push(new UserModel(user))
         })
       },

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

    <template>
    <div class="users">
      <div
        v-for="user in users"
        class="user"
      >
        {{ user.getFio() }}
      </div>
    </div>
    </template>

    К вопросу о том, какую логику выносить в модели, а какую в сервисы. Можно всю логику поместить в сервисы, а в моделях вызывать сервисы. А можно в моделях хранить логику, относящуюся непосредственно к сущности модели (тот же getFio()), а логику работы с массивами сущностей хранить в сервисах (тот же getList()). Как будет удобнее.

    5. Заключение

    Если в проекте большое количество логики хранится в компонентах, есть риск сделать их трудночитаемыми и осложнить дальнейшее переиспользование логики. В таких случаях можно ввести “слои” для вынесения этой логики: например, слой сервисов для бизнес-логики, слой хэлперов для остальной логики. Внутри компонента стоит оставить ту логику, которая относится непосредственно к нему и его шаблону.

    Также для удобства можно создать слои для операций с сессиями, перехватчиками (interceptors) api, глобальными обработчиками ошибок – смотря что вам будет удобно. Таким образом, вы сделаете компоненты маленькими и простыми, а логика будет храниться там, где ее легко будет найти и переиспользовать в любом месте проекта.

    Спасибо за внимание! Будем рады ответить на ваши вопросы. 

    SimbirSoft
    Лидер в разработке современных ИТ-решений на заказ

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

      0

      Отличная архитектура со статик сервисами.

        +1

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

        С классами внутри состояния компонента будут две проблемы: неожиданные потери реактивности, два источника правды (состояние компонента и состояние всех классов).

        Если уж идти полностью по пути классов то фабрика тут кажется будет смотреться куда удачнее.

        `this.users = await User.getList()`

        Где getById сразу вернёт объекты UserModel.

          0
          В статье лишь описан пример, и для формирования ФИО чаще используется computed, да, но это не меняет идеи вынесения функционала в сущность, а не разделения по компонентам.

          С подобной архитектурой реализован не один проект, проблем с реактивностью и состоянием не наблюдалось. По сути, этот подход ничего не меняет в плане обработки и хранения данных, больше именно про удобство.
          +2
          <user :user="user" />
          и не надо никаких классов и конвертаций. В конце концов компоненты для того и существуют.

          ну и в глаза бросились статичные методы сервиса (а как же this из оригинала, что с ним делать). И getList который так то вернет promise и все сломается. Ну да еще мапить лучше без мутаций, ну да ладно, это мелочи
            0
            Не совсем понятно, чем <user :user=«user» /> отличается от предложенного решения. Переменная user может быть и простым объектом и объектом класса при передаче в компонент.
            Какие именно проблемы с this вам встречались и каком «оригинале» речь? Статический класс аналогичен файлу, возвращающему функции. this в нем используется для работы со статическими же свойствами и методами.
              0
              Не совсем понятно, чем <user :user=«user» /> отличается от предложенного решения.

              соединение имени происходит внутри компонента. Это сразу дает следующие приемущества в сравнении с вашим примером — не надо использовать дополнительный класс и мапить в него, в будущем изменения можно вносить централизованно (например добавить линк на пользователей или добавить иконку / стили).

              Какие именно проблемы с this вам встречались

              Вы просто до конца реализуйте этот пример
              static delete (collage) {
              // ЛОГИКА УДАЛЕНИЯ КОЛЛАЖА
              }
                0
                соединение имени происходит внутри компонента. Это сразу дает следующие приемущества в сравнении с вашим примером — не надо использовать дополнительный класс и мапить в него, в будущем изменения можно вносить централизованно (например добавить линк на пользователей или добавить иконку / стили).


                Функционал получения ФИО может понадобиться не только при выводе пользователя на экран в конкретном виде (что и делает ваш вариант), но и в других компонентах и в JavaScript-коде. При этом в вашем случае придется дублировать функционал получения ФИО. Если же использовать предложенную архитектуру, то и в шаблонах и в JavaScript-коде всегда будет объект пользователя со всем необходимым функционалом.

                Если пользователь выводится одинаково более чем в одном месте, то надо создавать такой <user :user=«user» /> чтобы соблюсти правило DRY. Но в самом компоненте все равно надо брать ФИО из центрального источника (та же модель пользователя), чтобы это можно было использовать повсеместно, а не только в этом компоненте.

                Вы просто до конца реализуйте этот пример
                static delete (collage) {
                // ЛОГИКА УДАЛЕНИЯ КОЛЛАЖА
                }

                Как вариант:
                static delete (collage) {
                    this.api.deleteCollage(collage)
                      .then(() => {
                        this.vuex.dispatch('common/setCollages', { items: collages.items.filter(userCollage => userCollage.id !== collage.id) })
                      })
                  }
                
                  0
                  Про DRY можно поспорить, но это скорее всего бессмыслено.

                  Что касается примера. Оба this в данном случае указывают на класс сервиса (даже не на экземпляр). JS гибкий язык, можно конечно вызвать метод с контекстом компонента (чего не было в примере выше), но это еще то извращение. Хотя внедрять api и vuex еще бОльшее извращение (но тут на любителя извращений)

                  Думаю использование mixin-ов — наиболее близкий путь к вашей идее если судить по имплементации. Хотя саму идею сервисов можно реализовать иначе. А делать как вы не стоит — оно или не будет работать или это будет ужасный код.
            +1
            Супер статья. Очень доступно объяснено что за что отвечает. Всегда интересуют паттерны, как можно по адекватному разделять код со стороны клиента, потому что зачастую вместо масштабируемого проекта получается просто каша. Вот только разделение сущностей, как мне кажется, требует серьезного опыта.
            +2
            Автор текста не знает что ООП было уже более чем зрелой и вовсю использовавшейся парадигмой примерно лет за 30 до появления jQuery?
              0
              Имелось в виду не зарождение самого ООП, а его использование. По-настоящему и глубоко ООП и сейчас применяют нечасто, а в указанное время встречались проекты, где команды знали про ООП и даже использовали классы, но не с целью ООП, а как хранилища для тысяч строк кода.
                +1
                По-настоящему, если человек не знает ООП, то использовать он его не сможет.
                О сможет писать классы, наследование делать, даже какие-то паттерны осмысленно использовать, но это не обязательно будет ООП.
                ООП это дофига делов, это как христианство — вроде уже почти 2000 лет существуют его идеи, но до сих пор большинство «христиан» его так и не поняли. Не поняли главную идею. У них мозги иначе устроены, они требуют от Боженьки всяких благ, как язычники, думают что он им обязан их предоставлять.
                Есть одно важное принципиальное отличие ООП архитектуры от процедурного стиля.
                В ООП не существует управляющего алгоритма, оно основано на возникающем поведении совокупности взаимодействующих объектов. То есть мы описываем поведение объектов, а они уже начинают передавать друг другу сообщения. И в целом система начинает работать, причем сложность работы такой системы может быть на порядки выше того что способен понять человеческий разум. Мы не программируем алгоритм системы, а инкапсулируем алгоритмы исключительно в объектах. Между собой же они общаются самостоятельно. Как-то так…
              +1
              Разделение бизнес-логики и компонентов давным давно принято делать с помощью Vuex (или самописного стора на Vue 3).
              Схема предельно простая — компонент на клик по кнопке (или на другое действие) дергает стор, стор дергает апишку (которая как раз и есть вынесенный отдельно «сервис»), стор исполняет всю бизнес логику и создает ошибку если что-то пошло не так, компонент ее если что ловит и показывает пользователю информацию о ней.
              Модели же с помощью typescript живут как отдельные классы/интерфейсы и используются просто как обертки для типов.
              А создание сервисов, фабрик и прочего — удел java бэкендеров, откуда это и пошло. Хватит придумывать архитектурные велосипеды, все уже придумано)
                0

                Да, но vuex, на мой взгляд, какой-то громоздкий.

                Но возможно я просто избалован сервисами на ангуларе.

                  0

                  Думаю, для туду-листов архитектурные решения не особо важны, а для сколько-нибудь больших проектов без vuex уже почти никак, если не хотите плодить лапшу.

                  С ангуларом знаком очень посредственно, но на вью рекомендую использовать его всю экосистему :)

                    0

                    Ну а куда деваться?

                    Он конечно громоздкий, но хорошо работает

                    Отдельный плюс - крутой дебаггер через расширение:)

                  0
                  Состояние/стор/хранилище создано для хранения данных, а не кода — во втором случае его использование выглядит не очень оправданным. Логических сущностей, которым не нужно хранение данных в состоянии, может быть много. Тогда состояние будет просто хранить код без операций с данными состояния?
                  +1

                  А я недавно узнал про Vue ORM и мне кажется он решает кучу проблем. Сам пока не щупал, но выглядит круто

                    0
                    Действительно, привлекательный плагин, выводящий работу со сложными и взаимосвязанными данными на новый уровень. Но проблему запутанности кода при большом количестве бизнес-логики не решает. Все равно желательно будет создавать сервисы или альтернативы для структуризации кода.
                    0

                    Решение, конечно предложено тривиальное, но рабочее. Статические сервисы не самый плохой вариант:)

                      0

                      На языке который слабо предназначен для исполнения хоть чуть чуть более сложной логики, в интерпретаторе который не сильно то предназначен для исполнения серьезного объема кода вы пытаетесь писать код словно на с++, делфи или с#. Я не понимаю вам что мало тормозов в вебе? Эти бесконечно жрущие и тормозные поделки. Вот за что спасибо майнерам так это за то что сделали новое железо менее доступным, может хоть чуть чуть начнут оптимизировать по в отсутсвие новых игрушек.

                        0

                        В старые времена...

                        Лолшто? Вы видимо из параллельной вселенной, ибо в нашей всё то, что вы написали после этих слов -- полный бред. После фразы про ООП не смог дольше читать.

                          0
                          Рекомендую еще посмотреть в сторону Composition API, которое добавили в 3 версии Vue: v3.vuejs.org/guide/composition-api-introduction.html#why-composition-api

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

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