Привет, Хабр. Меня зовут Антон Логинов, я iOS-разработчик в компании FINCH.

Недавно мы столкнулись с проблемой использования web-интерфейсов для азартных игр. В очередном обновлении AppStore Review Guidelines коллеги из Купертино опять ужесточили правила. Если конкретнее, то теперь Apple может зареджектить приложение, если какой-либо из web-интерфейсов будет классифицирован как азартная игра на реальные деньги.

Одно наше приложение на 90% состоит из азартных игр, а оставшиеся 10% используются, чтобы эти игры рекламировать. Часть из них работает через webView, поэтому нам нужно было любым образом обезопасить себя от реджекта.

Что можно было сделать:

  1. Вынести эти игры за пределы основного приложения.
    Если сказать другими словами — просто отсрочить неизбежное.
  2. Использовать некий контейнер для игр, который можно будет безболезненно обновлять.
    Звучит неплохо > попытался вникнуть > убил несколько дней на изучение React и React Native > понял, что «лыжи не едут» > звучит уже не так хорошо.

    Это очень дорогое решение, поскольку времени было мало, а игры пришлось бы переписывать с нуля. Все дело во внутреннем роутинге — он целиком завязан на urlPathComponents
  3. Реализовать игры нативно.
    Долго, дорого, и еще раз долго. В дальнейшем пришлось бы их поддерживать на постоянной основе, а у нас таких возможностей не было.
  4. Имитировать поведение сервера, который бы отдавал лежащий локально сайт с играми.
    Звучит безумно, но именно этот вариант я выбрал. Это быстро, так как требуются минимальные доработки легаси игр.
    К оценочным минусам можно отнести: увеличение размера сборки за счет лежащего локально сайта, увеличение нагрузки на устройство за счет запуска сервера.

Я не нашел ни одной статьи на Хабре, где описывалось бы как запустить сервер на телефоне. Решив, что кейс достаточно редкий и интересный, решил рассказать об этом здесь, на Хабре.

Подготовка


Пока наш бравый фронтмен пытался уменьшить размер игр в 16 раз (80 Mb -> 5 Mb) и менял внутренние пути на относительные, я определился с библиотекой, выбрав GCDWebServer. Это легковесный фреймворк, с помощью которого можно поднять HTTP-сервер в несколько строк кода.

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

Настройка


func initWebServer() {

     // Инициализация
     let webServer = GCDWebServer()

    // В таких хендлерах обрабатываются обращения к серверу, например, этот будет обрабатывать все GET/POST запросы:

     webServer.addDefaultHandler(
          forMethod: HTTPMethod.get.rawValue,
          request: GCDWebServerDataRequest.self) { [weak self] request in
     
          return self?.handle(request: request)
     }
}

Старт


Собственно прописываем параметры для старта нашего сервера и запускаем:

do {
     try webServer.start(options: [GCDWebServerOption_BindToLocalhost: true,
                                                GCDWebServerOption_Port: 8080])
} catch {
     assertionFailure(error.localizedDescription)
     webServer.start(withPort: 8080, bonjourName: "PROJECT_NAME Web Server")
}

Прокси


image

Модуль внутри себя общается с API, но использует для этого свой собственный baseURL. В нашем случае — localhost. Следовательно, нужно было научить сервер определять те запросы, которые должны идти к API, и менять их baseURL.

// MARK: - в это время я узнал, насколько полезной может быть панель разработчика в Safari

Исходя из вышенаписанного, необходимо было сконфигурировать хендлеры для конкретных задач:

  • Отдать сайт. (Ну тут все просто);
  • Отдать какую-нибудь статику из Bundle. (Разобрали url запроса, поменяли baseUrl на bundleUrl, отдали контент (js/медиа);
  • Получить актуальные данные. (Разобрали url, поменяли baseUrl, запросили, отдали);
  • Отправить новые данные. (А POST запросы-то мы не обрабатывали, прикрутили, настроили, отправили);

Let's do it:

private func handle(request: GCDWebServerRequest) -> GCDWebServerResponse? {
        
        // 1) Отдаем сайт
        if request.url.pathComponents.contains(Endpoint.game.rawValue) {
            
            guard let indexURL = bundle.url(forResource: "index", withExtension: "html") else {
                return sendError(.noHTML(nil))
            }
            
            do {
                let data = try Data(contentsOf: indexURL)
                let htmlString = String(data: data, encoding: .utf8) ?? ""
                
                return GCDWebServerDataResponse(html: htmlString)
            
            } catch {
                return sendError(.noHTML(error))
            }
            
        // 2) Отдаем статику (js etc)   
        } else if request.url.pathComponents.contains(Endpoint.nstatic.rawValue) {
            
            guard let resoursePath = bundle.resourcePath else {
                return sendError(.noJS(nil))
            }
          
            let relativePath = request.url.pathComponents.joined(separator: "/")
            let absolutePath = resoursePath + relativePath.dropFirst()
            let staticURL = URL(fileURLWithPath: absolutePath)
            
            do {
                let data = try Data(contentsOf: staticURL)
                return GCDWebServerDataResponse(data: data, contentType: ContentType.js.description)
           
            } catch {
                return sendError(.noJS(error))
            }
        
        // 3) Делаем редирект для API
        } else if request.url.pathComponents.contains(Endpoint.api.rawValue) {
            var proxyRequest = request
            
            // Меняем url, дописываем хедеры, тело, и отправляем этот запрос
            let output = URLSession.shared.synchronousDataTask(with: proxyRequest)
            
           // Инициализируем ответ от сервера, используя данные полученные из запроса
           let response = GCDWebServerDataResponse(data: outputData, contentType: ContentType.url.description)
         
         // и возвращаем его пользователю
         return response
        }
    }

Заключение


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

На реализацию у нас ушло около 32 часов: 8 на оптимизацию размера сайта, 24 на проектирование и написание данного функционала.

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

Ну и подытожим плюсами выбранного подхода:

  • Экономия времени бэкенда за счет того, что сами даем ему модель данных
  • Возможность протестировать любое поведение сервера
  • Для перехода с Mock на реальное API нам просто нужно выключить конкретные хендлеры

Спасибо за внимание. Если у вас был подобный опыт, то расскажите о нем в комментариях.

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


  1. ice2heart
    17.10.2019 12:51

    Шутки с башорга про вебсервера на айфоне уже не шутки…


  1. Kamiraider Автор
    17.10.2019 13:09

    сейчас бы в 2019 смеяться над шутками из 2008)

    #396445
    28.04.2008 в 19:57
    xxx: Apple Inc. покупает P.A. Semi за 278 миллионов долларов. Напомним, что компания P.A. Semi занимается разработкой процессоров для мобильных платформ. Она была основана Дэном Добберпулом, благодаря усилиям которого появились такие знаковые и судьбоносные процессоры как Alpha и StrongArm и благодаря которому в феврале 2007 года миру был представлен 2GHz, 64-bit dual-core микропроцессор.
    yyy: представляю Apple iPhone 2GHz 64-bit dual core 2GB RAM 160GB SSD
    xxx: возьмете на колокейшен наш iPhone сервер?
    yyy: :)
    xxx: висят на стойках грозди айфонов
    yyy: зачем колокейшн? Сети 5-го поколения позволят носить сервер всегда при себе :)
    yyy: и даже совершать с него звонки
    xxx: главное чтобы жопу не обжог
    yyy: при DOS-атаке? o_O
    yyy: от перегрева? :)
    xxx: новый вид хака — задосить все Iphone серваки в радиусе действия блютуса. удачным считается хак, когда хозяин вскакивает с криком ;)
    yyy: и новая брутфорс — потырить айфон из кармана соседа


  1. iWheelBuy
    17.10.2019 17:11

    Я не понял что конкретно дает гарантию не попасть под реджект. Теперь ваш веб интерфес не может быть классифицирован как азартная игра на реальные деньги?


  1. Kamiraider Автор
    17.10.2019 17:29

    Конкретно — теперь наши игры лежат внутри приложения, что соответсвует обновленным требованиям. Apple не имеют ничего против азартных игр как таковых, главное, чтобы они соответствовали их требованиям.


    1. NetBUG
      21.10.2019 10:12

      Получается, игры потеряли возможность многопользовательского режима?


      1. Kamiraider Автор
        21.10.2019 10:41

        У нас ведь есть прокси, который будет обрабатывать этот кейс.


        1. NetBUG
          21.10.2019 11:03

          СЛОЖНО
          Разработка под мобильные платформы всё больше напоминает героическое преодоление костылей. И ладно это было 10 лет назад, когда платформы были дохлые, а SDK хреновые…


  1. Stas_Telnov
    18.10.2019 11:45

    Мы как то использовали локальный сервер, у нас была читалка, которая показывала html, загружаемый с сервака и хранящийся локально, не помню уже почему именно сервер локальный пришлось поднимать, но ресурсы телефона он жрал тогда очень хорошо. И это было давно, году в 2012-2013. Причём вроде как тот же GCDWebServer был, но эт не точно. Вообще жаль что приходится такие костыли использовать. Всё таки не должны аппы локалхост у себя в песочнице поднимать.

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

    ИМХО, для этого я бы не стал, даже для dev сборок тащить либу. Проще у себя на маке локалхост развернуть (MAMP какой-нибудь или что-то в этом роде) сделать на нём заглушки и обращаться к ним. А потом когда бек появится, просто поменять url на бекендовский.


    1. Kamiraider Автор
      18.10.2019 14:51

      Я сравнивал графики нагрузки, там минимальные различия, да и устройства сейчас помощнее, чем в 2012. Но бизнес требует. А насчёт тестирования — есть ещё QA)


      P.S. Проведу замеры ещё раз, и отпишусь с цифрами)


  1. smanioso
    20.10.2019 19:10

    Guideline 4.7. HTML5 games distributed in apps may not provide access to real money gaming, lotteries, or charitable donations, and may not support digital commerce. This functionality is only appropriate for code that’s embedded in the binary and can be reviewed by Apple. This guideline is now enforced for new apps. Existing apps must follow this guideline by September 3, 2019.

    Apple просто хочет иметь доступ, чтобы провести ревью. Вы не обошли ревью — вы подчинились правилам.


    1. Kamiraider Автор
      20.10.2019 19:14

      Как показывает практика, с AppStore, как и с PlayMarket, лучше играть честно. А под словом обошли я имел ввиду — избежали дорогостоящей нативной реализации.


  1. mkll
    21.10.2019 10:09
    +1

    Сэйлзам нужно было на iPad Pro показывать красивую объемную презенташку, реализованную в виде адаптивного-преадаптивного вебсайта, при этом наличие интернета вовсе не гарантировалось, поскольку презентации проводятся на территории клиента, которая может располагаться где угодно.

    Переписывать нативно, тратить 2-3 месяца на приложение, которое используется пятью людьми… Подняли этот же самый GCDWebServer, сделали загрузку обновлений презентации — всё работает.


    1. aknew
      21.10.2019 13:28

      А мы в свое время (году в 2012-2013) были вынуждены сервер локальный сервер поднять потому как курсы проигрывались в вебморде и стучались к локалхосту (не помню уже требование ли это SCORM в целом или той конкретной реализации курсов), причем сначала использовали просто веб-сервер из справки эппла, но потом пришлось встраивать тот же GCDWebServer когда внезапно оказалось что нужна поддержка тега video (тогда он еще был в черновиках), а сервер из справки не умеет работать с ним


      1. mkll
        21.10.2019 13:49

        Думаю, применений найти можно достаточно много. Я вообще GCDWebServer случайно встретил на гитхабе, отложил в сторонку — вдруг пригодится. Пригодилось буквально через полгода. :)