Привет, Хабр. Меня зовут Антон Потапов, я 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 в больших проектах. Или овчинка выделки не стоит?

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