Организация типовых модулей во Vuex +9


Как мы организовали Vuex-сторы и победили копипасту


Vuex — это официальная библиотека для управления состоянием приложений, разработанная специально для фреймворка Vue.js.


Vuex реализует паттерн управления состоянием, который служит централизованным хранилищем данных для всех компонентов приложения.


По мере роста приложения такое хранилище разрастается и данные приложения оказываются помещены в один большой объект.


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


Работая над проектом CloudBlue Connect и создавая очередной модуль, мы поймали себя на мысли, что пишем один и тот же шаблонный код снова и снова, меняя лишь эндпоинт:


  1. Репозиторий, в которой содержится логика взаимодействия с бекендом;
  2. Модуль для Vuex, который работает с репозиторием;
  3. Юнит-тесты для репозиториев и модулей.

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


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


Для этого мы создали фабрику типовых модулей Vuex, сократившую практически до нуля написание нового кода для взаимодействия с бекендом и хранилищем (стором).


Создание фабрики модулей Vuex


1. Базовый репозиторий


Базовый репозиторий BaseRepository унифицирует работу с бекендом через REST API. Так как это обычные CRUD-операции, я не буду подробно останавливаться на реализации, а лишь заострю внимание на паре основных моментов.


При создании экземпляра класса нужно указать название ресурса и, опционально, версию API.
На основе их будет формироваться эндпоинт, к которому будем дальше обращаться (например: /v1/users).


Ещё стоит обратить внимание на два вспомогательных метода:
query — всего навсего отвечает за выполнение запросов.


class BaseRepository {
    constructor(entity, version = 'v1') {
        this.entity = entity;
        this.version = version;
    }

    get endpoint() {
        return `/${this.version}/${this.entity}`;
    }

    async query({
        method = 'GET',
        nestedEndpoint = '',
        urlParameters = {},
        queryParameters = {},
        data = undefined,
        headers = {},
    }) {
        const url = parameterize(`${this.endpoint}${nestedEndpoint}`, urlParameters);

        const result = await axios({
            method,
            url,
            headers,
            data,
            params: queryParameters,
        });

        return result;
    }

    ...
}

getTotal — получает полное количество элементов.
В данном случае мы выполняем запрос на получение коллекции и смотрим на заголовок Content-Range, в котором содержится полное количество элементов: Content-Range: <unit> <range-start>-<range-end>/<size>.


// getContentRangeSize :: String -> Integer
// getContentRangeSize :: "Content-Range: items 0-137/138" -> 138
const getContentRangeSize = header => +/(\w+) (\d+)-(\d+)\/(\d+)/g.exec(header)[4];

...

async getTotal(urlParameters, queryParameters = {}) {
    const { headers } = await this.query({
        queryParameters: { ...queryParameters, limit: 1 },
        urlParameters,
    });

    if (!headers['Content-Range']) {
        throw new Error('Content-Range header is missing');
    }

    return getContentRangeSize(headers['Content-Range']);
}

Также класс содержит основные методы для работы:


  • listAll — получить всю коллекцию;
  • list — частично получить коллекцию (с пагинацией);
  • get — получить объект;
  • create — создать объект;
  • update — обновить объект;
  • delete — удалить объект.

Все методы просты: они отправляют соответствующий запрос на сервер и возвращают нужный результат.


Отдельно поясню метод listAll, который получает все имеющиеся элементы. Сперва с помощью метода getTotal, описанного выше, получаем количество доступных элементов. Затем загружаем элементы пачками по chunkSize и объединяем их в одну коллекцию.


Конечно же, реализация получения всех элементов может быть иной.


BaseRepository.js
import axios from 'axios';

// parameterize :: replace substring in string by template
// parameterize :: Object -> String -> String
// parameterize :: {userId: '123'} -> '/users/:userId/activate' -> '/users/123/activate'
const parameterize = (url, urlParameters) => Object.entries(urlParameters)
  .reduce(
    (a, [key, value]) => a.replace(`:${key}`, value), 
    url,
  );

// responsesToCollection :: Array -> Array
// responsesToCollection :: [{data: [1, 2]}, {data: [3, 4]}] -> [1, 2, 3, 4]
const responsesToCollection = responses => responses.reduce((a, v) => a.concat(v.data), []);

// getContentRangeSize :: String -> Integer
// getContentRangeSize :: "Content-Range: items 0-137/138" -> 138
const getContentRangeSize = header => +/(\w+) (\d+)-(\d+)\/(\d+)/g.exec(header)[4];

// getCollectionAndTotal :: Object -> Object
// getCollectionAndTotal :: { data, headers } -> { collection, total }
const getCollectionAndTotal = ({ data, headers }) => ({
  collection: data,
  total: headers['Content-Range'] && getContentRangeSize(headers['Content-Range']),
})

export default class BaseRepository {
  constructor(entity, version = 'v1') {
    this.entity = entity;
    this.version = version;
  }

  get endpoint() {
    return `/${this.version}/${this.entity}`;
  }

  async query({
    method = 'GET',
    nestedEndpoint = '',
    urlParameters = {},
    queryParameters = {},
    data = undefined,
    headers = {},
  }) {
    const url = parameterize(`${this.endpoint}${nestedEndpoint}`, urlParameters);

    const result = await axios({
      method,
      url,
      headers,
      data,
      params: queryParameters,
    });

    return result;
  }

  async getTotal(urlParameters, queryParameters = {}) {
    const { headers } = await this.query({ 
      queryParameters: { ...queryParameters, limit: 1 },
      urlParameters,
    });

    if (!headers['Content-Range']) {
      throw new Error('Content-Range header is missing');
    }

    return getContentRangeSize(headers['Content-Range']);
  }

  async list(queryParameters, urlParameters) {
    const result = await this.query({ urlParameters, queryParameters });

    return { 
      ...getCollectionAndTotal(result),
      params: queryParameters,
    };
  }

  async listAll(queryParameters = {}, urlParameters, chunkSize = 100) {
    const params = { 
      ...queryParameters,
      offset: 0,
      limit: chunkSize,
    };

    const requests = [];
    const total = await this.getTotal(urlParameters, queryParameters);

    while (params.offset < total) {
      requests.push(
        this.query({ 
          urlParameters, 
          queryParameters: params,
        }),
      );

      params.offset += chunkSize;
    }

    const result = await Promise.all(requests);

    return {
      total,
      params: {
        ...queryParameters,
        offset: 0,
        limit: total,
      },
      collection: responsesToCollection(result),
    };
  }

  async create(requestBody, urlParameters) {
    const { data } = await this.query({
      method: 'POST',
      urlParameters,
      data: requestBody,
    });

    return data;
  }

  async get(id = '', urlParameters, queryParameters = {}) {
    const { data } = await this.query({
      method: 'GET',
      nestedEndpoint: `/${id}`,
      urlParameters,
      queryParameters,
    });

    return data;
  }

  async update(id = '', requestBody, urlParameters) {
    const { data } = await this.query({
      method: 'PUT',
      nestedEndpoint: `/${id}`,
      urlParameters,
      data: requestBody,
    });

    return data;
  }

  async delete(id = '', requestBody, urlParameters) {
    const { data } = await this.query({
      method: 'DELETE',
      nestedEndpoint: `/${id}`,
      urlParameters,
      data: requestBody,
    });

    return data;
  }
}

Для того чтобы начать работать с нашим API, достаточно указать название ресурса.
Например, из ресурса users получить конкретного пользователя:


const usersRepository = new BaseRepository('users');
const win0err = await usersRepository.get('USER-007');

Что делать, когда нужно реализовать дополнительные действия?
Например, если нужно активировать пользователя, послав POST-запрос на /v1/users/:id/activate.
Для этого создадим дополнительные методы, например:


class UsersRepository extends BaseRepository {
    constructor() {
        super('users');
    }

    activate(id) {
        // POST /v1/users/:id/activate
        return this.query({
            nestedEndpoint: '/:id/activate',
            method: 'POST',
            urlParameters: { id },
        });
    }
}

Теперь с API очень легко работать:


const usersRepository = new UsersRepository();
await usersRepository.activate('USER-007');
await usersRepository.listAll();

2. Фабрика хранилища


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


Мутации


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


import {
    is,
    clone,
} from 'ramda';

const mutations = {
    replace: (state, { obj, value }) => {
        const data = clone(state[obj]);

        state[obj] = is(Function, value) ? value(data) : value;
    },
}

Состояние и геттеры


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


Соответственно, на данный момент достаточно трёх элементов:


  • collection — коллекция;
  • current — текущий элемент;
  • total — общее количество элементов.

Экшены


В модуле должны быть созданы экшены, которые будут работать с методами, определёнными в репозитории: get, list, listAll, create, update и delete. Взаимодействуя с бекендом, они будут обновлять данные в хранилище.


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


Фабрика хранилищ


Фабрика хранилища будет отдавать модули, которые нужно будет зарегистрировать в сторе с помощью метода registerModule: store.registerModule(name, module);.


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


StoreFactory.js
import {
  clone,
  is,
  mergeDeepRight,
} from 'ramda';

const keyBy = (pk, collection) => {
  const keyedCollection = {};
  collection.forEach(
      item => keyedCollection[item[pk]] = item,
  );

  return keyedCollection;
}

const replaceState = (state, { obj, value }) => {
  const data = clone(state[obj]);

  state[obj] = is(Function, value) ? value(data) : value;
};

const updateItemInCollection = (id, item) => collection => {
  collection[id] = item;

  return collection
};

const removeItemFromCollection = id => collection => {
  delete collection[id];

  return collection
};

const inc = v => ++v;
const dec = v => --v;

export const createStore = (repository, primaryKey = 'id') => ({
  namespaced: true,

  state: {
    collection: {},
    currentId: '',

    total: 0,
  },

  getters: {
    collection: ({ collection }) => Object.values(collection),
    total: ({ total }) => total,
    current: ({ collection, currentId }) => collection[currentId],
  },

  mutations: {
    replace: replaceState,
  },

  actions: {
    async list({ commit }, attrs = {}) {
      const { queryParameters = {}, urlParameters = {} } = attrs;

      const result = await repository.list(queryParameters, urlParameters);

      commit({
        obj: 'collection',
        type: 'replace',
        value: keyBy(primaryKey, result.collection),
      });

      commit({
        obj: 'total',
        type: 'replace',
        value: result.total,
      });

      return result;
    },

    async listAll({ commit }, attrs = {}) {
      const {
        queryParameters = {},
        urlParameters = {},
        chunkSize = 100,
      } = attrs;

      const result = await repository.listAll(queryParameters, urlParameters, chunkSize)

      commit({
        obj: 'collection',
        type: 'replace',
        value: keyBy(primaryKey, result.collection),
      });

      commit({
        obj: 'total',
        type: 'replace',
        value: result.total,
      });

      return result;
    },

    async get({ commit, getters }, attrs = {}) {
      const { urlParameters = {}, queryParameters = {} } = attrs;
      const id = urlParameters[primaryKey];

      try {
        const item = await repository.get(
          id,
          urlParameters,
          queryParameters,
        );

        commit({
          obj: 'collection',
          type: 'replace',
          value: updateItemInCollection(id, item),
        });

        commit({
          obj: 'currentId',
          type: 'replace',
          value: id,
        });
      } catch (e) {
        commit({
          obj: 'currentId',
          type: 'replace',
          value: '',
        });

        throw e;
      }

      return getters.current;
    },

    async create({ commit, getters }, attrs = {}) {
      const { data, urlParameters = {} } = attrs;

      const createdItem = await repository.create(data, urlParameters);
      const id = createdItem[primaryKey];

      commit({
        obj: 'collection',
        type: 'replace',
        value: updateItemInCollection(id, createdItem),
      });

      commit({
        obj: 'total',
        type: 'replace',
        value: inc,
      });

      commit({
        obj: 'current',
        type: 'replace',
        value: id,
      });

      return getters.current;
    },

    async update({ commit, getters }, attrs = {}) {
      const { data, urlParameters = {} } = attrs;
      const id = urlParameters[primaryKey];

      const item = await repository.update(id, data, urlParameters);

      commit({
        obj: 'collection',
        type: 'replace',
        value: updateItemInCollection(id, item),
      });

      commit({
        obj: 'current',
        type: 'replace',
        value: id,
      });

      return getters.current;
    },

    async delete({ commit }, attrs = {}) {
      const { urlParameters = {}, data } = attrs;
      const id = urlParameters[primaryKey];

      await repository.delete(id, urlParameters, data);

      commit({
        obj: 'collection',
        type: 'replace',
        value: removeItemFromCollection(id),
      });

      commit({
        obj: 'total',
        type: 'replace',
        value: dec,
      });
    },
  },
});

const StoreFactory = (repository, extension = {}) => {
  const genericStore = createStore(
    repository, 
    extension.primaryKey || 'id',
  );

  ['state', 'getters', 'actions', 'mutations'].forEach(
    part => {
      genericStore[part] = mergeDeepRight(
        genericStore[part],
        extension[part] || {},
      );
    }
  )

  return genericStore;
};

export default StoreFactory;

Пример использования


Для создания типового модуля достаточно создать экземпляр репозитория и передать его в качестве аргумента:


const usersRepository = new UsersRepository();
const usersModule = StoreFactory(usersRepository);

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


import { assoc } from 'ramda';

const usersRepository = new UsersRepository();
const usersModule = StoreFactory(
    usersRepository,
    {
        actions: {
            async activate({ commit }, { urlParameters }) {
                const { id } = urlParameters;
                const item = await usersRepository.activate(id);

                commit({
                    obj: 'collection',
                    type: 'replace',
                    value: assoc(id, item),
                });
            }
        }
    },
);

3. Фабрика ресурсов


Осталось собрать всё вместе в единую фабрику ресурсов, которая будет сначала создавать репозиторий, затем модуль и, наконец, регистрировать его в сторе:


ResourceFactory.js
import BaseRepository from './BaseRepository';
import StoreFactory from './StoreFactory';

const createRepository = (endpoint, repositoryExtension = {}) => {
  const repository = new BaseRepository(endpoint, 'v1');

  return Object.assign(repository, repositoryExtension);
}

const ResourceFactory = (
  store,
  {
    name,
    endpoint,
    repositoryExtension = {},
    storeExtension = () => ({}),
  },
) => {
    const repository = createRepository(endpoint, repositoryExtension);
    const module = StoreFactory(repository, storeExtension(repository));

    store.registerModule(name, module);
}

export default ResourceFactory;

Пример использования фабрики ресурсов


Использование типовых модулей получилось очень простым. Например, создание модуля для управления пользователями (включая кастомное действие по активации пользователя) описывается одним объектом:


const store = Vuex.Store();

ResourceFactory(
    store,
    {
        name: 'users',
        endpoint: 'users',
        repositoryExtension: {
            activate(id) {
                return this.query({
                    nestedEndpoint: '/:id/activate',
                    method: 'POST',
                    urlParameters: { id },
                });
            },
        },
        storeExtension: (repository) => ({
            actions: {
                async activate({ commit }, { urlParameters }) {
                    const { id } = urlParameters;
                    const item = await repository.activate(id);

                    commit({
                        obj: 'collection',
                        type: 'replace',
                        value: assoc(id, item),
                    });
                }
            }
        }),
    },
);

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


{
    computed: {
        ...mapGetters('users', {
            users: 'collection',
            totalUsers: 'total',
            currentUser: 'current',
        }),

        ...mapGetters('groups', {
            users: 'collection',
        }),

        ...
    },

    methods: {
        ...mapActions('users', {
            getUsers: 'list',
            deleteUser: 'delete',
            updateUser: 'update',
            activateUser: 'activate',
        }),

        ...mapActions('groups', {
            getAllUsers: 'listAll',
        }),

        ...

        async someMethod() {
            await this.activateUser({ urlParameters: { id: 'USER-007' } });
            ...
        }
    },
}

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


Описание и регистрация модуля:


ResourceFactory(
    store,
    {
        name: 'userOrders',
        endpoint: 'users/:userId/orders',
    },
);

Работа с модулем в компоненте:


{
    ...

    methods: {
        ...mapActions('userOrders', {
            getOrder: 'get',
        }),

        async someMethod() {
            const order = await this.getOrder({ 
                urlParameters: { 
                    userId: 'USER-007',
                    id: 'ORDER-001',
                } 
            });

            console.log(order);
        }
    }
}

Что можно улучшить


Получившееся решение можно доработать. Первое, что можно улучшить — это кеширование результатов на уровне стора. Второе — добавить постпроцессоры, которые будут трансформировать объекты на уровне репозитория. Третье — добавить поддержку моков (mocks), чтобы можно было разрабатывать фронтенд, пока не готова бекендовая часть.
Если будет интересно продолжение, то напишите об этом в комментариях — я обязательно напишу продолжение и расскажу о том, как мы решили эти задачи.


Выводы


Если писать код, следуя принципу DRY, это позволит делать его поддерживаемым. Это, в том числе, доступно благодаря конвенциям по проектированию API в нашей команде. Например, не всем подойдёт метод с определением количества элементов через заголовок Content-Range, у вас может быть другое решение.


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


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




К сожалению, не доступен сервер mySQL