Привет, Хабр. Меня зовут Антон Потапов, я iOS разработчик в компании FINCH. Сегодня я хочу подробно рассказать про то как перевести мобильный проект на GraphQL, описать плюсы и минусы этого подхода. Приступим.

Краткое введение


«Легаси». Я думаю каждый слышал это страшное слово, а большинство встречались с ним лицом к лицу. Поэтому не нужно рассказывать насколько сложно интегрировать новые и большие функции в легаси проект.

image

Один из наших проектов — приложение для крупной лотерейной компании (точно название дать не могу так как NDA). Недавно мне нужно было перевести его на GraphQL, не лишившись рассудка.

Проект очень большой — на первый взгляд, нужно было потратить минимум 200-300 часов, но это выходит за все возможные рамки двухнедельных спринтов. Выделять целый спринт только на одну задачу мы тоже не могли, так как были и побочные фичи, не менее важные чем GraphQL.
Долго думали что же делать и решили переводить проект шаг за шагом, модельку за моделькой. При таком подходе переход будет плавным, а 200-300 часов распределятся на несколько спринтов.

Технические моменты


Для работы над проектом я использовал библиотеку Apollo. На Хабре уже есть статья, где описываются все тонкости работы с ним, так что я не буду лишний раз повторяться. Заранее предупреждаю — работа с кодогенерированной моделью не очень удобна и лучше держать её в качестве «сетевой». Каждая сущность содержит поле __typename: String, которое, как ни странно, возвращает имя типа.

Декомпозиция задачи


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

  • GameInfo — содержит информация о лотерейных играх и тиражах.
  • DrawInfo — содержит данные о тираже

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

Всего можно выделить три этапа внедрения GraphQL:

  • Создание клиента;
  • Создание инициализатора для легаси модели;
  • Замена запросов к API на GraphQL.

GraphQLClient


Как и в любом клиенте, в GraphQLClient должен быть метод fetch, после вызова которого будет выполняться загрузка нужных нам данных.

На мой взгляд, метод fetch для GraphQLClient должен принимать enum, на основе которого будет выполняться соответствующий запрос. Такой подход позволит нам с лёгкостью получать данные для нужной сущности или даже экрана. В случае, если запрос принимает параметры, то они будут передаваться в качестве associated values. При таком подходе проще использовать GraphQL и создавать гибкие запросы.

enum GraphQLRequest {
    case image(ImageId?)
}

Наш настроенный GraphQL клиент должен содержать настроенный под существующие особенности ApolloClient, а также метод загрузки fetch, о котором говорилось выше.

func fetch<Response>(requestType: GraphQLRequest, completion: @escaping (QLRequestResult<Response>) -> Void)

Что за QLRequestResult? Это обычный

 typealias QLRequestResult<Response> = Result<Response, APIError>

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

    private func fetchGameInfo<Response>(gameId: String? = "", completion: @escaping (QLRequestResult<Response>) -> Void) {
        
	//Создаём запрос для Apollo клиента
        let query = GetLotteriesQuery(input: gameId)
        
	// Выполняем загрузку данных на глобальной очереди
        apolloClient.fetch(query: query, cachePolicy: .returnCacheDataAndFetch, queue: .global()) { result in
            
            switch result {
                
            case .success(let response):
                guard let gamesQL = response.data?.games,
                    let info = gamesQL.info else {
                    completion(.failure(.decodingError))
                    return
                }
                
		// Мапим Аполовские модельки
                let infos: [Games.Info] = info.compactMap({ gameInfo -> Games.Info? in
                    
                    guard let gameIdRaw = gameInfo?.gameId,
                        let gameId = GameId(rawValue: gameIdRaw),
                        let bonusMultiplier = gameInfo?.bonusMultiplier,
                        let maxTicketCost = gameInfo?.maxTicketCost,
                        let currentDraws = gameInfo?.currentDraws else { return nil }
                    
                    let currentDrawsInfo = Games.Info.CurrentDrawsInfo(currntDraw: currentDraws)
                    let gameInfo = Games.Info(gameId: gameId, bonusMultiplier: bonusMultiplier, maxTicketCost: maxTicketCost, currentDraws: currentDrawsInfo)
                    
                    return gameInfo
                })
                
		// Инициализируем старую модельку
                let games = Games(info: infos)
                
                guard let response = games as? Response else {
                    completion(.failure(.decodingError))
                    return
                }
                
                completion(.success(response))
                
            case .failure(let error):
                
                …
            }
        }
    }

В итоге мы получаем готовую, старую модель, полученную из GraphQL.

Scalar type


В документации GraphQL скалярный тип описан следующим образом: «Скалярный тип представляет собой уникальный идентификатор, часто используемый для повторной выборки объекта или в качестве ключа для кэша». Для swift скалярный тип можно легко ассоциировать с typealias.

При написании клиента, я столкнулся с проблемой, что в моей схеме присутствовал скалярный тип Long, который по сути был

typealias Long = Int64

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

  • Дописал в скрипте кодгена –passthroughCustomScalars
  • Создал отдельный файл для typealias
  • Добавил в файл
    public typealias Long = Int64

В дальнейшем для каждого скалярного типа нужно добавлять новый typealias. С версией Apollo 14.0 добавили «поддержку для Int кастомных скаляров». Если вы хотите избежать использования данного подхода и флага в кодгене, то ознакомьтесь с решением данной проблемы в гите Apollo.

Плюсы и минусы


Чем хорош такой подход? Достаточно быстрый переход на использование GraphQL, относительно подхода с убиранием всех старых моделей.

В дальнейшем, когда нам нужно будет получить данные для какого-либо экрана/модели мы можем обратиться к GraphQLClient и получить необходимые данные.

Из минусов:

  • Преобразование типа модели в клиенте, можно было бы передать другой сущности т.к. работа с данными не входит в ответственность клиента.
  • Разрастающийся GraphQLClient. После добавления новых запросов наш класс будет разрастаться всё больше и больше. Одно из решений — расширения в которых будут описаны методы загрузки, про них я рассказывал в главе GraphQLClient.

Выводы


В целом переход на GraphQL может оказаться быстрым и безболезненным при хорошо написанном клиенте. У меня он занял около трех дней = 24 рабочих часа. За это время я успел создать клиент, перевести модель и воссоздать модель GameInfo. Описанный подход — не панацея, а сугубо мое решение, реализованное в сжатые сроки.

Если среди вас есть гуру GraphQL, предлагаю поделиться опытом и рассказать насколько удобно использовать GraphQL + Apollo в больших проектах. Или овчинка выделки не стоит?

Спасибо за внимание!

Комментарии (14)


  1. apapacy
    07.10.2019 20:06

    Насколько удобно работать с ошибками? Применялись ли какие-то дополнительные библиотеки на сервере или на клиенте для работы с ошибками? Ведь в стандартном случае graphql выдает ошибки аналогичные stacktrace.


    Я хочу прояснить что я имею в виду. В REST я могу например выдать ошибку в которой передать например message которое будет выдано клиенту в виде всплывающего окна. Либо передать поле status c кодом ошибки в котором клиент по зараннее заданному списков кодов ошибки выберет вариант взаимодействия с пользователем. Или если это ошибка валидации входных данных, то в REST я могу выдать список полей с описанием ошибок во входных данных. Всего этого нет в стандартном graphql который в случае ошибки выдает текст, генерацией которого мы не можем управлять.


    1. Zada
      08.10.2019 00:05

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


      1. apapacy
        08.10.2019 00:26

        Вы имеете в виду что каждый тип выходного объекта должен включать в себя поле error/errors? А сам объект возвращать в поле data/payload и т.п.?


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


        1. Zada
          08.10.2019 00:35
          +1

          Да. Но для запросов вам никаких ошибок или валидаций делать не надо. Потому это все усложнение будет касаться только мутаций. А мутация может возвращать результат в виде составного объекта. Там будет объект и какая-то механика для ошибок.


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


    1. yarick123
      08.10.2019 00:12

      Генерацией текста ошибки управлять можно. За это отвечает поле «formatError» в структуре конфигурации обработки запроса, используемой в graphqlHTTP(...). Можно также вставлять свои поля. Вот пример из работающего кода. Правда, используется не Apollo, a graphql и express-graphql.

      app.use('/api', auth.validateTokenMiddleware, graphqlHTTP((req, res) => {
      ___ return {
      ___ ___ schema: apiModulesManager.gqlSchema,
      ___ ___ pretty: true,
      ___ ___ context: { req, res, dataloaders: buildDataloaders() },
      ___ ___ formatError
      ___ };
      }));

      function formatError(err) {
      ___ log.error(err);
      ___ return {
      ___ ___ // standard fields:
      ___ ___ message: err.message,
      ___ ___ path: err.path,
      ___ ___ locations: err.locations,

      ___ ___ // extended fields:
      ___ ___ code: err.code || 'not classified error',
      ___ ___ localizedMessage: err.locMessage || `not localized: ${err.message}`
      ___ };
      }


      Для того, чтобы всё это работало, в нужных местах кидаются исключения с необходимыми полями.

      P.S. Отступы в коде были безжалостно пожраны хабром. Пришлось извращаться с "___ " :(

      P.P.S. Естественно, речь идёт об ошибках, полученных во время выполнения функциональной части запроса, а не при парсинге текста запроса (то бишь синтаксических ошибках в тексте запроса).


    1. eee
      08.10.2019 13:39

      Рекомендую к прочтению https://github.com/nodkz/conf-talks. Идея в том, что надо использовать свои типы-ошибки для возврата бизнесовых ошибок через Union. Стандартный errors использовать только для технических ошибок (500-ые)


  1. Neveil
    07.10.2019 23:12
    +2

    Из статьи неясна мотивация перехода на GraphQL, поподробнее бы это осветить.


    1. yarick123
      08.10.2019 00:33

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

      Neveil вот, например, статья, которая могла бы послужить мотивацией: habr.com/ru/post/470097


    1. TheSwitch Автор
      08.10.2019 14:45

      Мотивацией являлась поставленная задача, которая должна была быть оценена и выполнена в соответствие с этой оценкой. А так, как уже описывалось в статье, на текущее API присылает множество лишних данных, которые мы просто не используем, переход на GraphQL, в дальнейшем позволит обеспечить приложение только нужными данными.


  1. Homa27
    08.10.2019 14:04
    +1

    Молодец!


  1. ip1981
    08.10.2019 14:45
    +1

    Почему какие-то "спринты" вообще рассматриваются как фактор?


    1. TheSwitch Автор
      08.10.2019 14:50
      -1

      Ознакомьтесь с методом управления проектом SCRUM. Я не говорю, что мы работаем в точности по данному методу, но как можно заметить, «спринты» ограничены сроками. Почему сроки не могут являться этим «фактором»?


      1. ip1981
        08.10.2019 15:16
        +1

        Сроки — вторичный фактор. Главное — "что произойдет, если 'срок' пройден?" Что-то мне подсказывает, что будет новый срок.


        1. TheSwitch Автор
          08.10.2019 15:28

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