Pull to refresh

Коммуникация по RESTful API: синхронизируем данные с маркетплейсами

Level of difficultyMedium
Reading time33 min
Views1.8K

В статье разбирается код на Ruby и в Ruby on Rails, в частности, на примере задачи по разработке web-сервиса синхронизации данных с внешними источниками. Погружение в программное решение начинается с разбора бизнес-задачи. Через освещение реальных API с маркетплейсов OZON и Яндекс.Маркет обосновываются способы принятия архитектурных решений и способы оптимизации кода. Эта статья также является авторской попыткой раскрыть принцы SOLID при реализации логики реального бэкенд приложения по переработке структурированных данных в условиях эксплуатации стороннего REST API.

I. Введение

1. О контексте бизнес-задачи

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

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

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

Внимательный читатель может сделать замечание о том, что здесь не хватает ещё одной важной сущности – Склад, покрывающей ряд организационных моментов, связанных с хранением товаров и логистикой. Это справедливо в отношении производителей и глобальных импортёров. Однако на текущий момент мы рассматриваем розничных продавцов, для которых идеальная схема продаж – «с колёс». Поэтому для них эта сущность, очевидно, может быть избыточной, а следовательно, и мы последуем их логике.

2. О предмете автоматизации

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

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

На данный момент мы определили предмет автоматизации и тип приложения. Далее перейдём к его архитектурным особенностям и инфраструктурным потребностям. Поскольку приложение должно обеспечить коммуникацию с маркетплейсами – поставщиками данных, и коммуникацию с пользователями – потребителями данных, то мы можем воспроизвести такую же модель Web-приложения, состоящего из нескольких относительно автономных частей. То есть требуется создать фоновое приложение, умеющее коммуницировать по REST API в глобальной сети с поставщиками данных, и визуальное приложение, умеющее отображать систематизированную информацию из собранных данных на дэшбордах. Это две активные части Web-приложения. В качестве пассивной части приложения, и фактически связующим звеном между двумя перечисленными, выступает хранилище данных.

Итак, у нас уже начинает складываться структура Web-приложения, которое должно состоять из фронтенда для коммуникации с пользователями и бэкенда, обслуживающего фронтенд данными из БД. И ещё из одного бэкенда, коммуницирующего с маркетплейсами, и из реляционной базы данных, хранящей историю данных клиента. Мы уже понимаем, что по мере увеличения нагрузки на арендуемые сервера приложение будет масштабироваться горизонтально посредством запуска большего количества параллельно работающих модулей над однотипными задачами. По мере увеличения нагрузки на СУБД её можно будет превратить в кластер, чтобы отделить аналитический блок от сервисного блока по сбору и актуализации данных о клиентах.

На этом вводную часть статьи мы завершаем. Дальнейшей темой нашего дискурса будет задача по загрузке данных с разных маркетплейсов в собственную базу данных. В рамках этой задачи обсудим механизм приёма данных, способ обработки данных с отделением бизнесовых вопросов от деталей технической реализации самого процесса загрузки, а также разберём вопрос трансформации данных перед их сохранением в базу данных.

II. REST API. Открываем двери на маркетплейсы

Для коммуникации с маркетплейсами организуем сервис-приложение, которое будет работать в фоновом режиме по расписанию. Далее сконцентрируемся на подзадаче по загрузке Товаров чтобы развернуть детали организуемой нами внутренней структуры приложения. Некоторая часть этой программной структуру также будет использована для загрузки данных о Заказах и Транзакциях. Сразу отметим, что хранение на собственных серверах «сырых» данных с маркетплейсов о каждом заказчике – дорогое мероприятие, поэтому имеет смысл предусмотреть первичную упаковку данных на этапе её загрузки с маркетплейсов в объёме, необходимом строго для будущей аналитической системы.

1. Собираемые данные

Маркетплейсы предоставляют подробное описание API, которое мы будем использовать при проектировании нашего сервиса:

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

name

Название товара

description

Подробное описание товара

images

Массив ссылок на изображения

category_title

Наименование категории, к которой продавец относит свой товар.

offer_id

Идентификатор товара в системе продавца — артикул

product_id

Уникальный идентификатор товара на маркетплейсе

skus

Массив идентификаторов товара SKU (stock keeping unit — единица складского учёта)

schemes

Перечень наименований схем складского учёта, по которым продаётся товар

barcodes

Все штрихкоды товара

price

Цена товара с учётом скидок — это значение показывается на карточке товара

status

Идентификатор Состояния товара. На разных маркетплейсах это различные величины. Поэтому мы будем сводить их к следующему перечню значений — preliminary, on_moderation, failed_moderation, published, unpublished, archived

/app/services/yandex/products_downloader/importing_scheme.rb

module Yandex
  class ProductsDownloader
    module ImportingScheme
      def prepare_product(product, item)
        offer = item[:offer]
        mapping = item[:mapping]
        product.assign_attributes(
          {
            name:           offer.fetch(:name, ''),
            barcodes:       offer.fetch(:barcodes, []),
            price:          find_price(offer),
            status:         find_status(offer),
            schemes:        find_schemes(offer),
            images:         offer.fetch(:pictures, []),
            description:    offer.fetch(:description, nil),
            product_id:     mapping.fetch(:marketModelId, nil),
            skus:           find_skus(mapping),
            category_title: find_category_title(offer, mapping),
            scrub_status:   'success'
          }
        )
        product
      end
    end
  end
end

/app/services/ozon/products_downloader/importing_scheme.rb

module Ozon
  class ProductsDownloader
    module ImportingScheme
      def prepare_product(product, item)
        product.assign_attributes(
          {
            name:           item.fetch(:name, ''),
            offer_id:       item[:offer_id],
            barcodes:       find_barcodes(item),
            price:          find_price(item),
            status:         find_status(item),
            schemes:        find_schemes(item),
            images:         item[:images],
            skus:           find_skus(item),
            category_title: find_category_title(item),
            stock:          item.dig(:stocks, :present),
            scrub_status:   'success'
          }
        )
        product
      end

При разработке учётной системы товаров с маркетплейсов формирование такой сводной таблицы с характеристиками товара обязывает разработчика учесть несколько нюансов. Далее по тексту перечислим эти нюансы и подробно обсудим их детали.

2. Периодичность обновления информации о товарах

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

/app/controllers/api/v1/credentials_controller.rb

module Api
  module V1
    class CredentialsController < ApplicationController
      before_action :fetch_credential, only: %i[update archive descriptions]
 
      def update
        resp, status = Tasks::ReimportProducts.new(true, @mp_credential, I18n.t('messages.users_control')).call
        render json: resp, status:
      end

      private

      def fetch_credential
        @mp_credential = fetch_credentials.find_by(id: params[:id])
        if @mp_credential.nil?
          render json: {
            errors: [{
              code: 'error',
              title: I18n.t('errors.not_found', class_name: 'MarketplaceCredential')
            }]
          }, status: 404
        else
          @mp_credential
        end
      end

      def fetch_credentials
        current_user.marketplace_credentials
      end
    end
  end
end

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

/clock.rb

module Clockwork
  every(1.day, 'Import products', at: '23:00') do
    MarketplaceInteraction::ImportProductsJob.perform_later
  end
end

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

scrub_status

Статус обработки товара:
unspecified — устанавливается при первичном заведении товара из учётной системы;
success — устанавливается при успешном обновлении информации о товаре;
gone — устанавливается, когда маркет сообщил о завершении доставки данных о товарах, а в базе осталась необработанная позиция.

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

/app/services/yandex/products_downloader.rb

module Yandex
  class ProductsDownloader
    attr_reader :mp_credential, :http_client, :parsed_ids

    private

    def tag_lost_products!
      products = Product.where(marketplace_credential_id: mp_credential.id)
      lost_offer_ids = products.pluck(:offer_id) - parsed_ids

      products.where(
        offer_id: lost_offer_ids
      ).update_all(scrub_status: 'gone')
    end
  end
end

/app/services/ozon/products_downloader.rb

module Ozon
  class ProductsDownloader
    attr_reader :mp_credential, :parsed_ids, :http_client_list, :http_client_info, :http_client_description

    private

    def tag_lost_products!
      products = Product.where(marketplace_credential_id: mp_credential.id)
      lost_product_ids = products.pluck(:product_id) - parsed_ids.keys

      products.where(
        product_id: lost_product_ids
      ).update_all(scrub_status: 'gone')
    end
  end
end

3. У товаров на разных маркетплейсах существуют различные способы их уникальной идентификации

На Яндекс.Маркет — это offerId. У OZON — это product_id или даже просто id, как «Номер задания на формирование документов».

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

/app/services/ozon/products_downloader/importing_scheme.rb

module Ozon
  class ProductsDownloader
    module ImportingScheme
      def iterate(list_by_id, exists = [], updated_products = [], updated_fields = [])
        Product.where(
          marketplace_credential_id: mp_credential.id,
          product_id: list_by_id.keys
        ).find_each do |product|
          exists << product.product_id
          product = prepare_product(product, list_by_id[product.product_id])
          @parsed_ids[product.product_id] = 1
          # We can record the changes somewhere.
          # pp("product.changes=",product.changes) if product.changed?
          if product.changed?
            updated_products << product
            updated_fields += product.changes.keys
          end
        end
        [updated_products, updated_fields, exists]
      end
    end
  end
end

/app/services/yandex/products_downloader/importing_scheme.rb

module Yandex
  class ProductsDownloader
    module ImportingScheme
      def iterate(list_by_id, exists = [], updated_products = [], updated_fields = [])
        Product.where(
          marketplace_credential_id: mp_credential.id,
          offer_id: list_by_id.keys
        ).find_each do |product|
          exists << product.offer_id
          product = prepare_product(product, list_by_id[product.offer_id])
          # We can record the changes somewhere.
          # pp("product.changes=",product.changes) if product.changed?
          if product.changed? && imported?(product)
            updated_products << product
            updated_fields += product.changes.keys
          end
        end
        [updated_products, updated_fields, exists]
      end
    end
  end
end

Кстати, у Яндекс.Маркет нет однозначной характеристики, соответствующей нашему параметру product_id. Наиболее близким по семантике становится marketModelId — что в описании API определяется как «Идентификатор модели на Маркете, который может отсутствовать в ответе, если товар ещё не привязан к карточке».

4. Тип данных для идентификаторов

У OZON для offer_id, product_id, sku реализован самый очевидный формат — числовой. Однако offerId на Яндекс.Маркет уже строковый. На других маркетплейсах тоже часто используется строковый тип данных для перечисленных параметров. Поэтому у нас идентификаторы будем хранить только в строках. Эта информация является востребованной СУДБ.

/db/migrate/20240106161532_create_products.rb

class CreateProducts < ActiveRecord::Migration[7.1]
  def up
    create_enum :product_scrub_status, ["unspecified", "success", "gone"]
    create_enum :product_status, ["preliminary", "on_moderation", "failed_moderation", "published", "unpublished", "archived"]

    execute <<-SQL
      DO $$
      BEGIN
        IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'monetary_amount') THEN
          CREATE TYPE monetary_amount AS
          (
            value     float,
            currency  VARCHAR(3)
          );
        END IF;
      END$$;
    SQL

    create_table :products do |t|
      t.references :marketplace_credential, type: :uuid, null: false, foreign_key: true
      t.string :offer_id, comment: 'client SKU'
      t.string :product_id, comment: 'marketplace object, articule or model'
      t.string :name, null: false
      t.text :description
      t.string :skus, array: true, comment: 'marketplace SKUs'
      t.string :images, array: true
      t.string :barcodes, array: true
      t.enum :status, enum_type: :product_status, default: "preliminary", null: false
      t.enum :scrub_status, enum_type: :product_scrub_status, default: "unspecified", null: false
      t.column :price, :monetary_amount
      t.integer :stock
      t.string :category_title
      t.string :schemes, array: true, comment: 'sales schemes'

      t.timestamps
    end
  end
end

5. О пакетной загрузке данных

Маркетплейсы имеют разные стратегии обслуживания клиентских приложений по API. Яндекс.Маркет быстро отдаёт необходимые данные, накладывая лишь ограничение на количество товаров в одном запросе в размере 200 позиций и разрешая до 600 запросов в минуту. Это значит, что в минуту можно загружать до 120_000 товарных позиций, чего с лихвой должно хватать для большинства продавцов на маркетплейсе без привлечения специальных алгоритмических техник по соблюдению rate limits при коммуникации с маркетплейсом. Для клиентов с большим количеством данных при установленных лимитах от маркетплейса можно позволить себе оставаться с алгоритмом синхронной обработки данных, и воспользоваться слиппером для пережидания ограничения на запрос к маркетплейсу.

/app/services/yandex/sleeper.rb

module Yandex
  module Sleeper
    # INPUT:
    # headers = {
    #   Date: Tue, 09 Jan 2024 09:14:10 GMT
    #   X-RateLimit-Resource-Until: Tue, 09 Jan 2024 09:15:00 GMT
    # }
    def do_sleep(headers, duration)
      dt =
        Time.parse(headers['X-RateLimit-Resource-Until']) -
        Time.parse(headers['Date']) +
        1
      if dt > duration
        Rails.logger.error I18n.t('errors.duration_of_rate_limit_has_been_changed',
                                  marketplace_name: 'Yandex')
      else
        sleep dt
      end
    rescue StandardError => e
      # We are checking the code. It's fixable
      ErrorLogger.push_trace e
    end
  end
end

При этом можно воспользоваться заголовочными параметрами X-RateLimit-Resource-Remaining и X-RateLimit-Resource-Until из последнего запроса к маркетплейсу.

/app/services/yandex/api/offer_mappings.rb

module Yandex
  class Api
    class OfferMappings < Api
      MAX_REQUESTS_PER_MINUTE = 600
      RATE_LIMIT_DURATION = 60

      include Yandex::Sleeper

      private

      # INPUT:
      # headers = {
      #   X-RateLimit-Resource-Remaining: 600
      # }
      def delay_if_limits(headers)
        if headers.fetch(
          'X-RateLimit-Resource-Remaining',
          MAX_REQUESTS_PER_MINUTE
        ).to_i < limiting_remaining_requests
          do_sleep(headers, RATE_LIMIT_DURATION)
        end
      end
    end
  end
end

У OZON нет лимитов на количество загрузок данных в единицу времени, но при этом в целом ситуация немного хуже по нескольким причинам. Первая, OZON задерживает время отклика на запрос. Вторая, OZON исключает возможность получать данные из одного метода с маркетплейса по ожидаемому перечню параметров. Только из отдельных запросов можно получить данные об описании продукта description и о наименовании категории товара category_title.

Что касается каталог с категориями товаров, то его можно скачать у OZON отдельно.

Дерево категорий и типов товаров (версия 2)

/app/services/ozon/load_categories.rb

Struct.new('CategoryOzon', :name, :id, :disabled)

module Ozon
  class LoadCategories
    def call
      @http_client = http_client
      list = http_client_call
      @imported_list = []
      scalp(
        list: list[:result],
        previous_category: Struct::CategoryOzon.new(name: '')
      )
      OzonCategory.import @imported_list,
                          on_duplicate_key_ignore: true,
                          on_duplicate_key_update: {
                            conflict_target: %i[
                              description_category_id
                              type_id
                            ],
                            columns: %i[
                              category_name
                              category_disabled
                              type_name
                              type_disabled
                            ]
                          }
    end
  end
end

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

/app/services/ozon/products_downloader/importing_scheme.rb

module Ozon
  class ProductsDownloader
    module ImportingScheme
      def find_category_title(item)
        return if item[:description_category_id].blank? && item[:type_id].blank?

        CashOzonCategory.get(
          item[:description_category_id] || 0,
          item[:type_id] || 0
        )
      end
    end
  end
end

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

/app/services/cash_ozon_category.rb

class CashOzonCategory
  class << self
    def o_cat
      @o_cat ||= Kredis.hash 'ozon_categories'
    end

    def get(category, type)
      o_cat["#{category}_#{type}"] || take_cat(category, type)
    end

    def take_cat(category, type)
      obj = OzonCategory.find_by(
        description_category_id: category,
        type_id: type
      )
      return if obj.nil?

      o_cat["#{category}_#{type}"] = "#{obj.category_name}/#{obj.type_name}"
    end
  end
end

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

Теперь вернёмся к текстовому описанию продукта description, который можно загружать строго под одну товарную единицу, как разрешает OZON.

Получить описание товара

15 тыс. товарных позиций грузится около 5 минут. Это очень долго. Поэтому мы выносим этот процесс в фоновую задачу, отделённую от процедуры загрузки описаний товаров.

/app/services/ozon/products_downloader/import_desriptions.rb

module Ozon
  class ProductsDownloader
    module ImportDesriptions
      def downloading_product_desriptions
        Product.where(
          marketplace_credential_id: @mp_credential.id,
          product_id: @parsed_ids.keys
        ).find_in_batches(batch_size: 100) do |products|
          updated_products = []
          products.each do |product|
            product.description = load_description(product.product_id)
            updated_products << product if product.changed?
          end
          if updated_products.any?
            Product.import(updated_products,
                           on_duplicate_key_ignore: true,
                           on_duplicate_key_update: {
                             conflict_target: %i[
                               marketplace_credential_id
                               product_id
                               offer_id
                             ],
                             columns: %i[
                               description
                             ]
                           })
          end
        end
      end
    end
  end
end

Импорт описаний товаров тоже пусть запускается из CRON задачи:

/clock.rb

module Clockwork
  every(1.day, 'Import product descriptions (for Ozon)', at: '1:00') do
    MarketplaceInteraction::ImportProductDescriptionsJob.perform_later
  end
end

Поскольку OZON считает данные с описаниями товаров несущественными для API приложения, то имеет смысл в предоставлении продавцам самостоятельного решения - осуществлять или нет загрузку описаний товаров с OZON в нашу платформу. Поэтому выносим выключатель загрузчика описаний товаров в эндпоинт приложения.

/app/controllers/api/v1/credentials_controller.rb

module Api
  module V1
    class CredentialsController < ApplicationController
      before_action :fetch_credential, only: %i[update archive descriptions]

      def descriptions
        value = Handles::ProductsDownloader.to_bool(params[:value])
        @mp_credential.autoload_descriptions.value = value if value.in? [true, false]
        render json: {
          marketplace_credential: {
            id: @mp_credential.id,
            autoload_descriptions: @mp_credential.autoload_descriptions.value
          }
        }
      end

      private

      def fetch_credential
        @mp_credential = fetch_credentials.find_by(id: params[:id])
        if @mp_credential.nil?
          render json: {
            errors: [{
              code: 'error',
              title: I18n.t('errors.not_found', class_name: 'MarketplaceCredential')
            }]
          }, status: 404
        else
          @mp_credential
        end
      end
    end
  end
end

6. Привязка списка продуктов к учётным данным пользователя на маркетплейсе

В начале этой главы было упомянуто, что списки товаров должны быть связаны с пользователями сервиса. На самом деле Яндекс.Маркет подсказывает, что у пользователя на маркетплейсе может быть до нескольких бизнесов. Собственно эти бизнесы и являются промежуточным звеном между пользователями и товарами. То есть схема базы данных будет выглядеть следующим образом:

ER-диаграмма для marketplace_credentials
ER-диаграмма для marketplace_credentials

В этом случае коммуникация приложения с маркетплейсами будет устанавливаться через регистрационные данные пользователя marketplace_credentials. Поэтому, чтобы работать с маркетплейсом по REST API, Яндекс.Маркет в личном кабинете пользователя выдаёт token, а OZON - api_key.

Дальнейшая коммуникация с «рабочей средой» маркетплейса осуществляется посредством добавления в заголовочную часть запроса авторизационных данных:

Что такое рабочая среда

/app/services/ozon/api.rb

module Ozon
  class Api < BaseApi
    def headers
      super.merge(
        {
          'Api-Key' => @api_key,
          'Client-Id' => @client_id,
          'x-o3-app-name' => ENV.fetch('OZON_APP_ID')
        }
      )
    end
  end
end

Авторизация для запросов магазина к Маркету

/app/services/yandex/api.rb

module Yandex
  class Api < BaseApi
    def headers
      super.merge(
        {
          'Authorization' => "OAuth oauth_token=\"#{@token}\", oauth_client_id=\"#{ENV.fetch('YANDEX_APP_ID')}\""
        }
      )
    end
  end
end

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

В случае Яндекс.Маркет проверка регистрационных данных может быть осуществлена через конкретный URL, который в своём адресе может содержать какой-нибудь дополнительный Id. Например, запрос за описаниями товаров содержит в себе business_id:

https://api.partner.market.yandex.ru/businesses/{businessId}/offer-mappings

Актуальность этого Id можно проверить только через реальный запрос

Информация о товарах в каталоге

чтобы по реальному HTTP-ответу получить статус ошибки или подтверждение об успешности выполнения запроса

Типы ошибок и что с ними делать

В нашем случае можно использовать универсальный механизм для обоих маркетплейсов по обработке статусов HTTP-запросов

/app/services/base_api.rb

class BaseApi
  EXACT_ERROR_RAISER = {
    420 => ->(status, msg, mp_id) { raise MarketplaceAggregator::ApiLimitError.new(status, msg, mp_id) },
    401 => ->(status, msg, mp_id) { raise MarketplaceAggregator::ApiAccessDeniedError.new(status, msg, mp_id) },
    403 => ->(status, msg, mp_id) { raise MarketplaceAggregator::ApiAccessDeniedError.new(status, msg, mp_id) }
  }.freeze

  RANGE_ERROR_RAISER = {
    (400...500) => ->(status, msg, mp_id) { raise MarketplaceAggregator::ApiBadRequestError.new(status, msg, mp_id) },
    (500..) => ->(status, msg, mp_id) { raise MarketplaceAggregator::ApiError.new(status, msg, mp_id) }
  }.freeze

  def call(method:, raise_an_error: false, params: {}, body: {})
    # Let's define an HTTP method for a specific endpoint
    @raise_an_error = raise_an_error
    send(method, params, body)
  end

  private

  def get(params = {}, _ = {})
    api_call do
      connection.get do |req|
        req.url url
        req.params = params
        req.headers = headers
      end
    end
  end

  def post(params = {}, body = {})
    api_call do
      connection.post do |req|
        req.url url
        req.params = params
        req.headers = headers
        req.body = body.to_json
      end
    end
  end

  def api_call
    response = yield
    @status = response.status
    @response_content_type = content_type(response.headers)
    body = response_parse(response.body)
    raise_error(
      error_message(body)
    )

    [@status, response.headers, body]
  end

  def content_type(headers)
    case headers.[]('content-type')
    when %r{application/json}
      :json
    when %r{text/html}
      :html
    else
      :any
    end
  end

  def response_parse(body)
    if response_content_type == :json
      begin
        JSON.parse body, symbolize_names: true
      rescue JSON::ParserError => e
        ErrorLogger.push e
        {}
      end
    else
      body
    end
  end

  def raise_error(message)
    return unless @raise_an_error

    EXACT_ERROR_RAISER[status]&.call(status, message, @marketplace_credential.id)

    RANGE_ERROR_RAISER.each do |k, v|
      v.call(status, message, @marketplace_credential.id) if k.include?(status)
    end
  end
end

Недостаток проверки регистрационных данных через реальный запрос заключается в сокращении количества допустимых запросов в единицу времени (то есть в расходовании rate limits).

По идее, в разрешении данной проблемы должен помочь HTTP-метод HEAD, вида:

irb(main):01> response = Faraday.head(
    'https://api.partner.market.yandex.ru/businesses/:business_id/offer-mappings.json',
    { limit: 1 },
    {
      'Authorization' => "OAuth oauth_token=\"y0_token\", oauth_client_id=\"#{ENV.fetch('YANDEX_APP_ID')}\"",
      'Content-Type' => 'application/json'
    }
  )

На который мы бы получили статус ответа, вида:

irb(main):02> response.status
=> 200

Но мы, к сожалению, получаем только статус 405:

irb(main):02> response.status
=> 405

что означает, https://developer.mozilla.org/ru/docs/Web/HTTP/Status - Method Not Allowed

Поэтому был найден альтернативный способ проверки регистрационных данных через curl-запрос с параметром -I

/app/services/yandex/check_credentials.rb

module Yandex
  class CheckCredentials < BaseCheckCredentials
    def bash_command
      <<~`BASH`
        curl -I \
        -H 'Authorization: OAuth oauth_token=#{@mp_credential.credentials['token']}, \
        oauth_client_id=#{ENV.fetch('YANDEX_APP_ID')}' \
        -X POST https://api.partner.market.yandex.ru/businesses/\
        #{@mp_credential.credentials['business_id']}/offer-mappings.json?limit=1
      BASH
    end
  end
end

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

/app/services/base_check_credentials.rb

class BaseCheckCredentials
  def call
    curl = bash_command

    case message = curl.split("\r\n").first
    when %r{(HTTP/1.1 200)|(HTTP/2 200)}
      { ok: true }
    when %r{(HTTP/1.1 403)|(HTTP/2 403)|(HTTP/1.1 401)|(HTTP/2 401)|(<html>)}
      # find out details from e.message of POST request
      redo_post_request
    else
      { errors: message }
    end
  end
end

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

7. Как достать данные клиента о товарах на маркетплейсе

Разобранный выше (раздел 5) способ загрузки данных с маркетплейса Ozon не заканчивается на освещённых проблемах отдельной загрузки описаний и категорий товаров. Метод выдачи списка товаров «Получить список товаров по идентификаторам» работает только при явном указании перечня идентификаторов для запрашиваемых товаров. На выбор предлагается три категории идентификаторов – offer_id, product_id, sku. Вопрос: откуда их взять? Как узнать от маркетплейса, какие товары вообще размещены пользователем на маркетплейсе?

Ответом на поставленный вопрос становится метод «Список товаров», который в пакетном режиме выдаёт списки offer_id и product_id товаров, размещённых продавцом на маркетплейсе.

В этом методе API также предоставляется возможность указания фильтров по visibility товара. Для нас актуальны два значения — ALL и ARCHIVED.

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

/app/services/ozon/products_downloader/downloading_scheme.rb

Первый этап. Загружаем все товары, кроме архивных.

      def downloading_unarchived_products
        @archive = false
        benchmarking(
          -> { "import: :mp_credential[#{@mp_credential.id}] — actual[#{@parsed_ids.size}] — Done" }
        ) { circle_downloader }
        @total = @parsed_ids.size
      end

Второй этап. Загружаем архивные товары.

      def downloading_archived_products
        if @mp_credential.autoload_archives.value
          @archive = true
          benchmarking(
            -> { "import: :mp_credential[#{@mp_credential.id}] — archived[#{@parsed_ids.size - @total}] — Done" }
          ) { circle_downloader }
        end
      end

Третий этап. Загружаем описания товаров по списку товаров из собственной БД.

      def downloading_desriptions
        if @mp_credential.autoload_descriptions.value
          benchmarking(
            -> { "import: :mp_credential[#{@mp_credential.id}] — desriptions[#{@parsed_ids.size}] — Done" }
          ) { downloading_product_desriptions }
        end
      en

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

  • формируется запрос к странице со списком идентификаторов товаров

  • загружается пакет со списками product_id товаров

  • верифицируется успешность загрузки пакета данных с product_id

  • вызывается процедура пакетной загрузки характеристик товаров кроме описаний товаров с последующим импортом данных в локальную базу

  • достаётся токен следующей страницы для загрузки очередного пакета с product_id

  • прерывается цикл, если токен пустой

/app/services/ozon/products_downloader/downloading_scheme.rb

      def circle_downloader
        page_tokens = {}
        loop do
          status, _, body = @http_client_list.call(
            body: {
              filter: {
                visibility: (@archive ? 'ARCHIVED' : 'ALL')
              },
              limit: PAGE_LIMIT
            }.merge(page_tokens)
          )
          break_if_http_error(status) if status != 200

          items = (body&.dig(:result, :items) || []).map { |elem| elem[:product_id] }
          break if items.blank?

          download_product_info_list(items)

          page_token = body&.dig(:result, :last_id)
          if page_token.blank?
            break
          else
            page_tokens = { last_id: page_token }
          end
        end
      end

Для сравнения Яндекс.Маркет работает в два этапа. Первый этап — загрузка всех товаров, кроме архивных. Второй этап — загрузка архивных данных. А цикл загрузки товаров выглядит следующим образом:

  • формируется запрос к странице с характеристиками товаров

  • загружается пакет с характеристиками товаров

  • верифицируется успешность загрузки пакета данных с характеристиками товаров

  • вызывается процедура импорта пакета данных с характеристиками товаров в локальную базу данных

  • достаётся токен следующей страницы для загрузки очередного пакета с характеристиками товаров

  • прерывается цикл, если токен пустой

/app/services/yandex/products_downloader/downloading_scheme.rb

      def circle_downloader
        page_tokens = {}
        loop do
          status, _, body = @http_client.call(
            params: LIMITS.merge(page_tokens),
            body: { archived: @archive }
          )
          if status == 200
            items = body&.dig(:result, :offerMappings) || []
            break if items.blank?

            import_payload(items)
          else # any other status anyway
            # To be safe, but we shouldn't get here.
            # This is possible if the status is < 400 and the status is != 200.
            raise MarketplaceAggregator::ApiError.new(
              status,
              I18n.t('errors.something_went_wrong'),
              mp_credential.id
            )
          end

          page_token = body&.dig(:result, :paging, :nextPageToken)
          if page_token.blank?
            break
          else
            page_tokens = { page_token: }
          end
        end
      end

III. Оптимизация кода. Повышение надёжности

1. Откуда берётся бизнес логика

Загрузка данных с маркетплейсов осуществляется с помощью HTTP-клиента. В нашем случае используется Faraday.

/app/services/connection.rb

class Connection
  def self.start
    @conn ||= Faraday.new do |config|
      config.request :retry, RETRY_OPTIONS
      config.adapter Faraday.default_adapter
    end
  end
end

Предполагается, что в целях обеспечения контакта с маркетплейсом во время обслуживания одной сессии или работы одной фоновой задачи может быть выполнено до нескольких обращений к маркетплейсу. При этом достаточно одного HTTP-клиента, поэтому при объявлении нового HTTP-клиента используется оператор ||= присвоения по условию отсутствия предварительного подключения к нему. Поскольку диапазон задач, для которых будет использован данный клиент, может быть различным, то мы минимизируем на данном этапе его конфигурацию. Конфигурация будет отличаться, например, при GET и POST запросах.

/app/services/base_api.rb

class BaseApi
  attr_accessor :connection

  def initialize(mp_credential, options = {})
    @connection = Connection.start
    @marketplace_credential = mp_credential
    @raise_an_error = false
  end

  private

  def get(params = {}, _ = {})
    api_call do
      connection.get do |req|
        req.url url
        req.params = params
        req.headers = headers
      end
    end
  end

  def post(params = {}, body = {})
    api_call do
      connection.post do |req|
        req.url url
        req.params = params
        req.headers = headers
        req.body = body.to_json
      end
    end
  end
end

Общий каркас настройки HTTP-клиента на этом заканчивается. Далее перейдём к реализации дополнительных параметров подключения к разным маркетплейсам из отдельных классов.

Для установления контакта с маркетплейсом используются параметры url, params, headers, body. Два из них — params и body — являются входными хэшами при вызове соответствующих методов .get и .post при обращении к Web-сервису. Два других параметра — url и headers можно настроить автоматически из отдельных классов для каждого маркетплейса. Headers содержат регистрационные данные на маркетплейсах, и они уже упоминались выше в разделе I.6 — /app/services/ozon/api.rb, /app/services/yandex/api.rb.

Url от API-методов также предполагается объявлять в отдельных классах в целях соблюдения «Принципа единственной ответственности». Так повышается Cohesion внутри кода, обслуживающего HTTP-клиента, и понижается Coupling между HTTP-клиентом и другими частями кода по переработке различных данных, загруженных с маркетплейсов. /Cohesion и Coupling: отличия/

В нашем случае имеется один API-метод на загрузку продуктов с Яндекс.Маркет:

/app/services/yandex/api/offer_mappings.rb

module Yandex
  class Api
    class OfferMappings < Api
      def url
        "#{URL}/businesses/#{@business_id}/offer-mappings.json"
      end
    end
  end
end

и три API-метода на загрузку продуктов с OZON:

/app/services/ozon/api/product_list.rb

module Ozon
  class Api
    class ProductList < Api
      def url
        "#{URL}/v2/product/list"
      end
    end
  end
end

/app/services/ozon/api/product_info_list.rb

module Ozon
  class Api
    class ProductInfoList < Api
      def url
        "#{URL}/v2/product/info/list"
      end
    end
  end
end

/app/services/ozon/api/product_info_description.rb

module Ozon
  class Api
    class ProductInfoDescription < Api
      def url
        "#{URL}/v1/product/info/description"
      end
    end
  end
end

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

/app/services/ozon/products_downloader.rb

module Ozon
  class ProductsDownloader
    def initialize(mp_credential)
      @mp_credential = mp_credential
      @http_client_list = Ozon::Api::ProductList.new(mp_credential)
      @http_client_info = Ozon::Api::ProductInfoList.new(mp_credential)
      @http_client_description = Ozon::Api::ProductInfoDescription.new(mp_credential)
      @parsed_ids = {}
    end

    def call
      return false if mp_credential.credentials.nil?

      download_products
      tag_lost_products!
      true
    end
  end
end

/app/services/yandex/products_downloader.rb

module Yandex
  class ProductsDownloader
    def initialize(mp_credential)
      @mp_credential = mp_credential
      @http_client = Yandex::Api::OfferMappings.new(mp_credential)
      @parsed_ids = []
    end

    def call
      return false if mp_credential.credentials.nil?

      download_products
      tag_lost_products!
      true
    end
  end
end

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

/app/jobs/products/import_job.rb

module Products
  class ImportJob < ApplicationJob
    include MaBenchmarking

    queue_as do
      is_client_queue = arguments.first
      if is_client_queue
        :client_grabber_products
      else
        :marketplace_grabber_products
      end
    end

    DOWNLOADER_CLASS = 'ProductsDownloader'

    def perform(is_client_queue, mp_credential_id)
      @is_client_queue = is_client_queue
      @mp_credential = MarketplaceCredential.find_by(id: mp_credential_id)
      return if irrelevant?

      @mp_credential.update(last_sync_at_products: Time.current) if downloadable?
    end

    private

    def downloadable?
      import(@mp_credential.marketplace.to_constant_with(DOWNLOADER_CLASS).new(@mp_credential))
      true
      #... 
    end

    def import(downloader)
      benchmarking(
        -> { "import: :mp_credential[#{@mp_credential.id}] — [#{downloader.parsed_ids.size}] - OK" }
      ) { downloader.call }
    end
  end
end

Здесь важно обратить внимание на использование принципа инверсии зависимостей, когда по имени маркетплейса выбирается класс, в котором реализованы детали процедуры импорта - downloader.call /Принципы SOLID на примере ruby♦️/

Другим не менее значимым алгоритмическим подходом, реализованным в приведённом коде, является разнесение задачи импорта по разным очередям в зависимости от источника инициации процесса импорта. Это может быть клиентский запрос на немедленную загрузку данных, например, при регистрации нового пользователя в нашем Web-приложении. Такие запросы отправляются в очередь :client_grabber_products.

Вызов клиентского запроса на импорт данных осуществляется из соответствующего эндпоинта POST http://localhost:3000/api/v1/credentials

/app/controllers/api/v1/credentials_controller.rb

module Api
  module V1
    class CredentialsController < ApplicationController
      def create
        @mp_credential = fetch_credentials.new(credential_params)
        @mp_credential.fix_credentials!
        Operations::CheckCredentials.new(@mp_credential).call
        if @mp_credential.is_valid
          @mp_credential.save!
          Products::ImportJob.perform_later(true, @mp_credential.id)
          render json: {
            marketplace_credential: @mp_credential
          }
        else
          render json: {
            errors: [{
              code: 'error',
              title: I18n.t('errors.credentials_are_invalid'),
              detail: @mp_credential.credentials['errors']
            }]
          }, status: 400
        end
      end
    end
  end
end

В другом случае инициатором запроса на импорт данных может быть CRON-задача, о чём говорится в разделе I.2. Тогда такие запросы отправляются в очередь :marketplace_grabber_products.

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

/app/business_logic/tasks/import_products.rb

module Tasks
  class ImportProducts
    def call
      MarketplaceCredential.find_each(batch_size: 100) do |mp_credential|
        # 1. correcting client mistakes
        mp_credential.fix_credentials!
        # 2. verifying the validity of credentials
        Operations::CheckCredentials.new(mp_credential).call
        # 3. refresh the record
        mp_credential.save! if mp_credential.changed?
        # 4. cause direct data import
        Products::ImportJob.perform_later(false, mp_credential.id) if mp_credential.is_valid
      end
    end
  end
end

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

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

/app/jobs/marketplace_interaction/import_products_job.rb

module MarketplaceInteraction
  class ImportProductsJob < ApplicationJob
    queue_as :marketplace_grabber_products

    def perform
      Tasks::ImportProducts.new.call
    end
  end
end

Аналогично организован код масс-импорта описаний по товарам с OZON:

/app/business_logic/tasks/import_product_descriptions.rb, /app/jobs/marketplace_interaction/import_product_descriptions_job.rb

2. Обработка ошибок

Существует несколько способов работы с программными ошибками. В данном приложении активно эксплуатируются четыре способа - перехват ошибок; генерация и перехват ошибок (Railway Oriented Programming); генерация и перехват ошибок, чтобы перезапустить задачу; генерация ошибок.

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

/app/jobs/products/import_job.rb

module Products
  class ImportJob < ApplicationJob
    def downloadable?
      import(@mp_credential.marketplace.to_constant_with(DOWNLOADER_CLASS).new(@mp_credential))
      true
    rescue NameError => e
      # We are checking the code. It's fixable
      ErrorLogger.push_trace e
      false
      #...
    end
  end
end

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

Второй способ, связанный с «генерированием и перехватом ошибок», эксплуатируется в двух случаях.

Первый случай — это генерация исключения с целью идентификации основания отказа в исполнении функции по эндпоинту и перехват исключения для возврата на запрос по эндпоинту форматированного ответа с указанием причины отказа

/app/controllers/application_controller.rb

class ApplicationController < ActionController::API
  rescue_from(MarketplaceAggregator::UnauthorizedError) do
    render json: {
      errors: [{
        code: 'error',
        title: I18n.t('errors.unauthorized')
      }]
    }, status: 401
  end

  rescue_from(MarketplaceAggregator::ForbiddenError) do
    render json: {
      errors: [{
        code: 'error',
        title: I18n.t('errors.forbidden')
      }]
    }, status: 403
  end

  def current_user
    raise MarketplaceAggregator::UnauthorizedError unless fetch_user_uuid

    @current_user ||= Client.find_by(id: fetch_user_uuid)
    raise MarketplaceAggregator::ForbiddenError unless @current_user

    @current_user
  end

  private

  def fetch_user_uuid
    request.headers['HTTP_USER']
  end
end

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

В этом случае также следует учесть, что для запроса используется нестандартный заголовок запроса, поэтому Ruby on Rails добавляет к нему префикс HTTP_ . /https://github.com/rails/rails/blob/main/actionpack/lib/action_dispatch/http/headers.rb/

Таким образом, заголовок у запроса к эндпоинту выглядит как USER:registered_user_id, а внутри приложения следует использовать обращение за заголовком, как request.headers['HTTP_USER'].

Второй случай «генерирования и перехвата ошибок» предназначен для реализации Railway-ориентированного программирования при возвращении маркетплейсами статусов, отличных от значения 200.

Генерация исключения происходит в классе HTTP-клиента. Пример реализации был рассмотрен в разделе I.6 /app/services/base_api.rb .

Перехват исключения для этого случая осуществляется в методе downloadable? у класса с реализацией фоновой задачи импорта данных.

/app/jobs/products/import_job.rb

module Products
  class ImportJob < ApplicationJob
     def downloadable?
      import(@mp_credential.marketplace.to_constant_with(DOWNLOADER_CLASS).new(@mp_credential))
      true
    rescue NameError => e
      # We are checking the code. It's fixable
      ErrorLogger.push_trace e
      false
    rescue MarketplaceAggregator::ApiAccessDeniedError => e
      ErrorLogger.push e
      # that's all
      false
    rescue MarketplaceAggregator::ApiBadRequestError => e
      ErrorLogger.push e
      # that's all
      false
    rescue MarketplaceAggregator::ApiLimitError => e
      # restart the task taking into account restrictions on limits
      Tasks::ReimportProducts.new(@is_client_queue, @mp_credential, e).call
      false
    rescue MarketplaceAggregator::ApiError => e
      ErrorLogger.push e
      # TODO: restart task after an hour (for example)
      false
    end
  end
end

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

Кроме Второго способа обработки исключений здесь мы также видим Третий способ «генерирования и перехвата ошибок с целью перезапуска задачи по импорту данных» на строке с rescue MarketplaceAggregator::ApiLimitError. Это тот редкий случай, когда правила маркетплейса позволяют очевидным способом автоматически разрешить проблему последнего отказа в отклике по достижению rate limits.

/app/business_logic/tasks/reimport_products.rb

module Tasks
  class ReimportProducts
    def call
      # 1. check the history of ReimportProducts calls
      # It should be less than three times
      analyse do
        # 2. cause direct data import
        Products::ImportJob.set(wait_until: 1.minute.from_now)
                           .perform_later(@is_client_queue, @mp_credential.id)
      end
    end

  end
end

Здесь стоит рассказать об одном нюансе, учтённом в настройках HTTP-клиента при обработке статусов HTTP-ответов от маркетплейсов. Известно, что ответ по REST API может приходить не только от Web-приложения, но и от Web-сервера. Это ошибки 500-й серии. Некоторые из них являются некритическими для удержания контакта на незначительном промежутке времени посредством повторения запроса без отключения соединения с реципиентом. Пример такой настройки представлен в коде HTTP-клиента:

/app/services/connection.rb

class Connection
  RETRY_OPTIONS = {
    max: 5,                     # Retry a failed request up to 5 times
    interval: 2,                # First retry after 2s
    backoff_factor: 2,          # Double the delay for each subsequent retry
    retry_statuses: [500, 502, 503, 504]
    # Retry only when we get a 500, 502, 503 or 504 response
  }.freeze

  def self.start
    @conn ||= Faraday.new do |config|
      config.request :retry, RETRY_OPTIONS
      config.adapter Faraday.default_adapter
    end
  end
end

В заключении этого раздела разберём Четвёртый способ обработки ошибок, основанный на генерации ошибок без последующей их программной обработки. Это ситуация называется «проектированием по контракту», когда в родительском классе заявляется пул методов, обязательных к реализации в дочерних классах. Чтобы не забывать прописывать реализации обязательных методов и создаются искусственные условия, генерирующие критические прерывания программы. Ещё такой подход в программировании называют Принципом Барбары-Лисков. Пример его применения можно видеть в /app/services/base_check_credentials.rb.

class BaseCheckCredentials
  
  private

  # curl command can invoke POST method with -I/--head option
  # https://curl.se/docs/manpage.html
  def bash_command
    raise NotImplementedError, "#{self.class}.#{__method__}: #{I18n.t('errors.marketplace_has_not_been_selected')}"
  end

  def http_client
    raise NotImplementedError, "#{self.class}.#{__method__}: #{I18n.t('errors.marketplace_has_not_been_selected')}"
  end
end

В наследуемых классах заявленные методы превращаются в следующее содержание

/app/services/yandex/check_credentials.rb

module Yandex
  class CheckCredentials < BaseCheckCredentials

    private

    def bash_command
      <<~`BASH`
        curl -I \
        -H 'Authorization: OAuth oauth_token=#{@mp_credential.credentials['token']}, \
        oauth_client_id=#{ENV.fetch('YANDEX_APP_ID')}' \
        -X POST https://api.partner.market.yandex.ru/businesses/\
        #{@mp_credential.credentials['business_id']}/offer-mappings.json?limit=1
      BASH
    end

    def http_client
      Yandex::Api::OfferMappings.new(@mp_credential)
    end
  end
end

/app/services/ozon/check_credentials.rb

module Ozon
  class CheckCredentials < BaseCheckCredentials

    private

    def bash_command
      <<~`BASH`
        curl -I \
        -H 'Api-Key: #{@mp_credential.credentials['api_key']}' \
        -H 'Client-Id: #{@mp_credential.credentials['client_id']}' \
        -H 'x-o3-app-name: #{ENV.fetch('OZON_APP_ID')}' \
        -X POST https://api-seller.ozon.ru/v2/product/list
      BASH
    end

    def http_client
      Ozon::Api::ProductList.new(@mp_credential)
    end
  end
end

3. Выстраивание задач по загрузке данных в очередь

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

У нас в качестве адаптера очередей используется Sidekiq, о чём и указано в конфигурационном файле /config/application.rb.

Поскольку Sidekiq является Ruby процессом, то он остаётся обладателем только одного ядра процессора на инстанс. Чтобы увеличить количество запущенных инстансов, нужно увеличить количество конфигурационных файлов в папке /config.

Для текущего приложения принято решение выделить два инстанса. Один на клиентские запросы по подключению новых аккаунтов и обслуживанию запросов на повторную, внеплановую перезагрузку данных с маркетплейсов /config/sidekiq_live.yml .

:timeout: 10
:concurrency: <%= ENV.fetch("SIDEKIQ_CONCURRENCY") { 15 } %>
:max_retries: 0
:queues:
  - [client_grabber_products, 5]
  - [client_grabber_orders, 3]
  - [client_grabber_transactions, 2]
  - [default, 1]
  - [client_grabber_product_descriptions, 4]

Второй инстанс выделяется на выполнение регулярной плановой загрузки данных по расписанию и их автоматическую догрузку в случае предусмотренных вариантов такой отложенной обработки /config/sidekiq_scheduled.yml .

:timeout: 10
:concurrency: <%= ENV.fetch("SIDEKIQ_CONCURRENCY") { 15 } %>
:max_retries: 0
:queues:
  - [marketplace_grabber_products, 5]
  - [marketplace_grabber_orders, 3]
  - [marketplace_grabber_transactions, 3]
  - [marketplace_grabber_product_descriptions, 4]

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

Далее распределим выделенное количество потоков между очередями. Здесь нужно учитывать, что чем больше потоков находится в параллельной работе, тем больше они друг другу мешают. Идеального распределения потоков между очередями не существует. В любом случае конфигурацию придётся настраивать под реальные объёмы обрабатываемых данных. Страница административной панели Sidekiq будет нам в помощь.

Теперь оценим процессы, происходящие внутри одного потока Sidekiq.

После ознакомления в данной статье с подробным описанием всех процессов импорта данных с маркетплейсов можно составить представления о расходуемых ресурсах. Одним существенным ресурсом является время загрузки данных по запросу о товарах. Для Яндекс.Маркет тормозящим фактором в имеющейся реализации может стать процесс «засыпания» на очень больших списках товаров в целях соблюдения наложенных маркетплейсом rate limits. На OZON замедляющим выполнение задачи фактором при загрузке данных может становиться загрузка описаний товаров. Для обоих маркетплейсов с течением продолжительной эксплуатации активно торгующим продавцом может оказаться избыточной операция загрузки архивных данных. Просто их может оказаться слишком много. На все эти случаи у нас уже интегрирован в код рукописный бенчмаркинг /lib/ma_benchmarking.rb .

module MaBenchmarking
  def benchmarking(log_string)
    back_time = Time.now
    yield
    Rails.logger.info format("(in %.3f sec) #{log_string.call}", Time.now - back_time)
  end
end

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

Следующим расходным ресурсом является количество обращений из параллельных потоков к базе данных на запись и обновление данных. На текущий момент в программе реализована наилучшая практика на чтение и запись данных из/в базу пакетами. Так для импорта используется гем activerecord-import. Предполагается соблюдать заданную практику при дальнейшем развитии программного решения.

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

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

В статье детально разобран код Web-сервиса, эксплуатирующего REST API по импорту данных о товарах с маркетплейсов Яндекс.Маркет и OZON. На реальных участках кода показаны примеры реализации принципов SOLID. Проектируемое приложение не предполагает превращение Web-сервиса в большой монолит. Здесь намеренно отсутствует модуль авторизации пользователей. Этот функционал предполагает реализацию в отдельном сервисе. Архитектуру проектируемого приложения можно назвать сервисной. Описанные в документации к Web-сервису эндпоинты предназначены для внутреннего использования внутри приложения.

Представленный Web-сервис построен на фреймворке Ruby on Rails. В качестве СУБД используется PostgreSQL. Для хранения данных активно используются нативные типы данных Postgres. Выполнение фоновых задач вынесено в Sidekiq. В проекте используется системный менеджер задач CRON из гема clockwork. Для кэширования в Redis используется гем Kredis. С полностью рабочим проектом можно ознакомиться в репозитории https://github.com/rubygitflow/marketplace_aggregator.

Проект открыт для вкладов от контрибьютеров.

Приветствуется сохранение выбранного стиля в настройках Rubocop. Одобряется глубокое тестирование в районе 100% с сохранением покрытия кода тестами в Rspec.

Tags:
Hubs:
Rating0
Comments1

Articles