iOS: Открываем deep link’и, notification’ы и shortcut’ы

14 июня 2018

Один инструмент, чтобы править всеми

Доводилось ли вам в своём приложении реализовывать поддержку пуш-уведомлений (push notifications)? Если вы уже разрабатывали что-либо более сложное, чем “Hello, World!”, то, скорее всего, ваш ответ — “да”.

А что вы скажете про открытие шорткатов (пунктов меню быстрых действий)? Теперь, когда все новые iOS-устройства поддерживают 3d touch, эта функция уже больше чем просто приятное дополнение.

Поддерживает ли ваше приложение универсальные ссылки? Это относительно новая возможность, популярность которой в современных приложениях растёт с каждым днём. Даже если вы пока её не используете, возможно, сейчас самое время начать.

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

Но что если ваше приложение должно поддерживать все эти функции? Неужели необходимо реализовывать их в трёх отдельных компонентах?

*   *   *

Прежде чем мы продолжим, давайте определимся с терминами:

  1. Универсальные ссылки — это способ перехватывать некоторые URL’ы и вместо обработки URL’а в Safari, открывать соответствующую страницу приложения. Универсальные ссылки требуют определённой работы на уровне бэкенда, поэтому в данном уроке мы будем говорить конкретно о deep links (глубинных ссылках). Deep links работают по тому же принципу, но имеют дело конкретно с пользовательскими URL-схемами. Их применение не сильно отличается, поэтому для вас не составит труда добавить поддержку универсальных ссылок, если возникнет такая необходимость.
  2. Шорткаты предоставляют возможность запустить приложение сразу на определённом разделе (экране), исходя из выбранного пункта меню быстрых действий, которое возникает, если плавно надавить на иконку приложения. Эта функция работает на устройствах с включённым 3d-touch.
  3. Уведомления: когда вы касаетесь уведомления (неважно, локальное оно или удалённое), приложение запустится на определённой странице или выполнит определённые действия.

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

Apple назвали это опциями запуска (launching options), которые обрабатываются в AppDelegate методом didFinishLaunchingWithOptions.

Проблема в том, что применение всех опций запуска — достаточно запутанная задача, зачастую выливающаяся в сотни строк избыточного кода. Ещё сложнее реализовать переход приложения на передний план, а не его запуск. Обработка с шорткатов, deep link’ов и уведомлений происходит в разных методах делегата, и на первый взгляд не имеет ничего общего.

Для большей ясности скажу ещё раз: все эти функции служат одной цели — открыть определённый экран приложения.

Вопрос только в том, как заставить всех их работать оптимальным образом.

*   *   *

Подготовка проекта

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

  • Страница “Мои сообщения” (предпросмотр): доступна через шорткат
  • Сообщения (определённый чат): доступны через пуш-уведомления
  • Создать новый список: доступно через шорткат только для профиля хозяина (принимающей стороны)
  • Мои последние действия: доступны через шорткат
  • Запрос брони: доступен как через email-ссылку (deep link), так и через пуш-уведомление

Для начала создайте простой проект с ViewController’ом, NavigationBar’ом и кнопкой для смены профиля “Switch Profile”:

Подготовка проекта

Во View Controller’е будет содержаться текущий тип профиля и механизм переключения профилей.

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

enum ProfileType: String {
    case guest = "Guest" // default
    case host = "Host"
}
class ViewController: UIViewController {
    var currentProfile = ProfileType.guest
    override func viewDidLoad() {
        super.viewDidLoad()
        configureFor(profileType: currentProfile)
    }
    @IBAction func didPressSwitchProfile(_ sender: Any) {
        currentProfile = currentProfile == .guest ? .host : .guest
        configureFor(profileType: currentProfile)
    }
    func configureFor(profileType: ProfileType) {
        title = profileType.rawValue
    }
}

*   *   *

Один инструмент, чтобы править всеми

Для удобства далее в этой статье все переходы на определённый экран приложения я буду называть deep link, независимо от того, какой именно механизм применяется.

Теперь, когда у нас есть базовая структура и графический интерфейс, давайте организуем наш список deep link - элементов:

enum DeeplinkType {
    enum Messages {
        case root
        case details(id: String)
    }
    case messages(Messages)
    case activity
    case newListing
    case request(id: String)
}

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

Далее мы можем создать для управления одиночку, где будут все deep linking options:

let Deeplinker = DeepLinkManager()
class DeepLinkManager {
   fileprivate init() {}
}

Добавьте опциональное свойство для хранения текущего DeeplinkType:

let Deeplinker = DeepLinkManager()
class DeepLinkManager {
   fileprivate init() {}
   private var deeplinkType: DeeplinkType?
}

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

let Deeplinker = DeepLinkManager()
class DeepLinkManager {
   fileprivate init() {}
   private var deeplinkType: DeeplinkType?
   // check existing deepling and perform action
   func checkDeepLink() {
   }
}

И при запуске, и при переходе на передний план приложение будет вызывать в appDelegate метод didBecomeActive. Здесь мы будем проверять наличие любых deep link’ов, которые сможем обработать:

func applicationDidBecomeActive(_ application: UIApplication) {
   // handle any deeplink
   Deeplinker.checkDeepLink()
}

Всякий раз, когда приложение становится активным, оно будет проверять наличие deep link’а, который надлежит открыть. Создадим класс-навигатор, который будет открывать соответствующий экран приложения в зависимости от DeeplinkType:

class DeeplinkNavigator {
   static let shared = DeeplinkNavigator()
   private init() { }
   
   func proceedToDeeplink(_ type: DeeplinkType) {
   }
}

В рамках данного примера мы будем просто отображать alert (окно предупреждения) с именем переданного DeeplinkType:

private var alertController = UIAlertController()
private func displayAlert(title: String) {
   alertController = UIAlertController(title: title, message: nil, preferredStyle: .alert)
   let okButton = UIAlertAction(title: "Ok", style: .default, handler: nil)
   alertController.addAction(okButton)
   if let vc = UIApplication.shared.keyWindow?.rootViewController {  
      if vc.presentedViewController != nil {
         alertController.dismiss(animated: false, completion: {
            vc.present(self.alertController, animated: true, completion: nil)
         })
      } else {
          vc.present(alertController, animated: true, completion: nil)
      }
   }
}

В методе proceedToDeeplink мы пробегаем по разным DeeplinkType и определяем, какой alert нужно отобразить:

func proceedToDeeplink(_ type: DeeplinkType) {
   switch type {
   case .activity:
      displayAlert(title: "Activity")
   case .messages(.root):
      displayAlert(title: "Messages Root")
   case .messages(.details(id: let id)):
      displayAlert(title: "Messages Details \(id)")
   case .newListing:
      displayAlert(title: "New Listing")
   case .request(id: let id):
      displayAlert(title: "Request Details \(id)")
   }
}

Вернёмся в DeepLinkManager и используем навигатор, чтобы обработать deepLink:

// check existing deeplink and perform action
func checkDeepLink() {
   guard let deeplinkType = deeplinkType else {
      return
   }
 
   DeeplinkNavigator().proceedToDeeplink(deeplinkType)
   // reset deeplink after handling
   self.deeplinkType = nil // (1)
}

Не забудьте после использования сбросить значение deepLink к nil (1). В противном случае эта же ссылка будет использована, когда вы в следующий раз откроете приложение.

Теперь нам нужно просто проверить, имеются ли deep links (шорткаты, deep links или уведомления), которые следует обработать, определить их DeeplinkType и передать их DeepLinkManager.

Сперва мы должны произвести некоторую базовую подготовку для шорткатов,  deep links и уведомлений.

Хоть мы и хотим, чтобы DeepLinkManager обрабатывал любые виды deep links, следует помнить об SRP (single responsibility principle — принцип единственной ответственности). Не будем смешивать процессы разбора различных типов deep links.

*   *   *

Шорткаты

Общепринятый метод создания статических шорткатов подразумевает использование файла info.plist. Вот короткий урок на эту тему, ознакомьтесь если вам интересно.

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

Во-первых, создадим класс ShortcutParser. Он будет отвечать исключительно за шорткаты.

class ShortcutParser {
   static let shared = ShortcutParser()
   private init() { }
}

Затем нужно задать возможные ключи шорткатов:

enum ShortcutKey: String {
   case newListing = "com.myApp.newListing"
   case activity = "com.myApp.activity"
   case messages = "com.MyApp.messages"
}

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

func registerShortcuts(for profileType: ProfileType) {
   let activityIcon = UIApplicationShortcutIcon(templateImageName: "Alert Icon")
   let activityShortcutItem = UIApplicationShortcutItem(type: ShortcutKey.activity.rawValue, localizedTitle: "Recent Activity", localizedSubtitle: nil, icon: activityIcon, userInfo: nil)
   let messageIcon = UIApplicationShortcutIcon(templateImageName: "Messenger Icon")
   let messageShortcutItem = UIApplicationShortcutItem(type: ShortcutKey.messages.rawValue, localizedTitle: "Messages", localizedSubtitle: nil, icon: messageIcon, userInfo: nil)
   UIApplication.shared.shortcutItems = [activityShortcutItem, messageShortcutItem]
switch profileType {
      case .host:
         let newListingIcon = UIApplicationShortcutIcon(templateImageName: "New Listing Icon")
         let newListingShortcutItem = UIApplicationShortcutItem(type: ShortcutKey.newListing.rawValue, localizedTitle: "New Listing", localizedSubtitle: nil, icon: newListingIcon, userInfo: nil)
    UIApplication.shared.shortcutItems?.append(newListingShortcutItem)
      case .guest:
         break
   }
}

Создаём activityShortcutItem и messageShortcutItem для обоих типов профилей. Если текущий пользователь — хозяин (принимающая сторона), то добавляем newListingShortcutItem. Для каждого шортката мы используем UIApplicationShortcutIcon (эта иконка будет отображаться рядом с текстом соответствующего пункта меню быстрых действий при плавном надавливании на иконку приложения).

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

func configureFor(profileType: ProfileType) {
   title = profileType.rawValue
   ShortcutParser.registerShortcuts(for: profileType)
}

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

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

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

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

Перейдите в AppDelegate и добавьте метод делегата performActionForShortcutItem:

// MARK: Shortcuts
func application(_ application: UIApplication, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) {
}

тот метод определит, когда сработает шорткат, и completionHandler сообщит делегату, следует ли его обрабатывать. Мы не будем помещать логику работы с шорткатами в appDelegate, вместо этого создадим метод в классе DeeplinkManager:

@discardableResult
func handleShortcut(item: UIApplicationShortcutItem) -> Bool {
   deeplinkType = ... // we will parse the item here
   return deeplinkType != nil
}

Этот метод вначале попытается получить из шорткат-элемента DeeplinkType, и затем вернёт булево значение, обозначающее, удалась ли эта операция. При этом метод сохранит полученный из шортката тип в переменную deeplinkType.

@discardableResult говорит компилятору игнорировать неиспользуемое получаемое в методе значение, благодаря чему мы не получаем предупреждение “unused result”

Вернитесь в appDelegate и завершите метод performActionFor:

// MARK: Shortcuts
func application(_ application: UIApplication, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) {
   completionHandler(Deeplinker.handleShortcut(item: shortcutItem))
}

Последнее что мы должны сделать — это получить DeeplinkType на основе шорткат-элемента. У нас уже есть класс ShortcutParser, отвечающий за все действия, относящиеся к шорткату. Добавьте ещё один метод:

func handleShortcut(_ shortcut: UIApplicationShortcutItem) -> DeeplinkType? {
   switch shortcut.type {
   case ShortcutKey.activity.rawValue:
      return .activity
   case ShortcutKey.messages.rawValue:
      return .messages(.root)
   case ShortcutKey.newListing.rawValue:
      return .newListing
   default:
      return nil
   }
}

Теперь вернитесь в DeeplinkManager и дополните метод handleShortcut:

@discardableResult
func handleShortcut(item: UIApplicationShortcutItem) -> Bool {
   deeplinkType = ShortcutParser.shared.handleShortcut(item)
   return deeplinkType != nil
}

Вот и вся подготовка, необходимая для обработки шорткатов! Давайте ещё раз пошагово пройдёмся по ней. Когда мы касаемся иконки шортката:

  1. Активность шортката вызывает метод performActionForShortcutItem класса appDelegate
  2. Метод performActionForShortcutItem передаёт ShortcutItem в DeeplinkManager
  3. DeeplinkManager пробует получить DeeplinkType на основе ShortcutItem, используя ShortcutParser
  4. В applicationDidBecomeActive мы проверяем наличие каких-либо DeeplinkTypes
  5. Если DeeplinkType существует (т.е. шаг 3 выполнен успешно), мы выполняем соответствующее действие используя DeeplinkNavigator
  6. Как только шорткат был обработан, DeeplinkManager сбрасывает значение текущего шорткат-элемента к nil, чтобы не использовать его повторно

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

Deep link’и

Deeplink’и, которые мы хотим обрабатывать, будут иметь следующий формат:

deeplinkTutorial://messages/1
deeplinkTutorial://request/1

Сохраните эти URL’ы в “Заметках” на вашем тестовом устройстве. Если сейчас коснуться любой из этих ссылок, ничего не произойдёт.

Если бы это была универсальная ссылка, по касанию бы открылся браузер с соответствующим URL’ом.

Когда мы выполним всё, что указано в этом разделе, касание URL’а будет открывать соответствующую страницу нашего приложения.

AppDelegate обнаружит, было ли приложение открыто через deeplink-URL и если это так, инициирует метод openUrl:

// MARK: Deeplinks
func application(_ app: UIApplication, open url: URL, options: [UIApplicationOpenURLOptionsKey : Any] = [:]) -> Bool {  
}

Возвращаемое значение сообщает делегату, следует ли открывать URL.

Если вы хотите реализовать поддержку универсальных ссылок (представлены в iOS9), добавьте также следующий метод делегата:

// MARK: Universal Links
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([Any]?) -> Void) -> Bool {
   if userActivity.activityType == NSUserActivityTypeBrowsingWeb {
      if let url = userActivity.webpageURL {
         
      }
   }
   return false
}

ContinueUserActivity также вызывается когда вы запускаете приложение через элементы Spotlights (в рамках данной статьи это не обсуждается).

Следуя тому же шаблону, что мы использовали для шорткатов, создаём DeeplinkParser:

class DeeplinkParser {
   static let shared = DeeplinkParser()
   private init() { }
}

Чтобы парсить Deeplink, создадим метод, который принимает URL и возвращает опциональный DeeplinkType:

func parseDeepLink(_ url: URL) -> DeeplinkType? {
   guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true), let host = components.host else {
      return nil
   }
   var pathComponents = components.path.components(separatedBy: "/")
   // the first component is empty
   pathComponents.removeFirst()
   switch host {
   case "messages":
      if let messageId = pathComponents.first {
         return DeeplinkType.messages(.details(id: messageId))
      }
   case "request":
      if let requestId = pathComponents.first {
         return DeeplinkType.request(id: requestId)
      }
   default:
      break
   }
   return nil
}

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

Теперь мы можем подключить этот парсер к нашему основному deeplink-классу. Добавьте этот метод в DeeplinkManager:

@discardableResult
func handleDeeplink(url: URL) -> Bool {
   deeplinkType = DeeplinkParser.shared.parseDeepLink(url)
   return deeplinkType != nil
}

В appDelegate дополним методы openUrl и continueUserActivity:

// MARK: Deeplinks
func application(_ app: UIApplication, open url: URL, options: [UIApplicationOpenURLOptionsKey : Any] = [:]) -> Bool {
   return Deeplinker.handleDeeplink(url: url)
}
// MARK: Universal Links
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([Any]?) -> Void) -> Bool {
   if userActivity.activityType == NSUserActivityTypeBrowsingWeb {
      if let url = userActivity.webpageURL {
         return Deeplinker.handleDeeplink(url: url)
      }
   }
   return false
}

Теперь нужно сделать ещё кое-что: сообщить нашему приложению, какой именно тип ссылок ему следует обнаруживать. Добавьте этот сниппет в файл info.plist (правый клик по info.plist -> Open As -> Source Code):

<key>CFBundleURLTypes</key>
<array>
    <dict>
        <key>CFBundleURLName</key>
        <string>com.deeplinkTut.Deeplink</string>
        <key>CFBundleURLSchemes</key>
        <array>
            <string>deeplinkTutorial</string>
        </array>
    </dict>
</array>

Будьте внимательны, не нарушьте XML-структуру файла!

После добавления этого кода попробуйте открыть plist как Property List. Вы должны увидеть следующие строки:

Так мы сообщаем приложению, что оно должно выявлять только ссылки с URL’ом “deeplinkTutorial”.

Давайте ещё раз пробежимся по всем шагам:

  1. Пользователь касается deeplink’а вне приложения
  2. AppDelegate обнаруживает ссылку и запускает метод делегата openUrl (или метод делегата ContinueUserActivity для универсальных ссылок)
  3. Метод openUrl передаёт ссылку в Deeplink Manager
  4. Deeplink Manager пробует получить DeeplinkType используя DeeplinkParser
  5. В методе applicationDidBecomeActive мы производим проверку наличия DeeplinkType’ов
  6. Если существует DeeplinkType (т.е. шаг 4 завершился успешно), мы выполняем соответствующее действие с помощью DeeplinkNavigator’а
  7. Как только шорткат был обработан, DeeplinkManager сбрасывает значение текущего шорткат-элемента к nil, чтобы не использовать его повторно

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

Уведомления

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

Для отправки APNS-уведомлений вы можете использовать локальный сервер и PusherAPI (это один из наиболее простых способов).

Мы затронем только часть между тапом по пуш-уведомлению и получением видимого результата.

Когда приложение закрыто или работает в фоновом режиме, тап по уведомлению запустит в appDelegate метод didReceiveRemoteNotification:

func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
   
}

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

Для обработки уведомлений мы создадим NotificationParser:

class NotificationParser {
   static let shared = NotificationParser()
   private init() { }
   func handleNotification(_ userInfo: [AnyHashable : Any]) -> DeeplinkType? {
      return nil
   }
}

Теперь мы можем связать этот метод с Deeplink Manager’ом:

func handleRemoteNotification(_ notification: [AnyHashable: Any]) {
   deeplinkType =                       NotificationParser.shared.handleNotification(notification)
}

И дополнить метод класса appDelegate didReceiveRemoteNotification:

func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
   Deeplinker.handleRemoteNotification(userInfo)
}

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

func handleNotification(_ userInfo: [AnyHashable : Any]) -> DeeplinkType? {
   if let data = userInfo["data"] as? [String: Any] {
      if let messageId = data["messageId"] as? String {
         return DeeplinkType.messages(.details(id: messageId))
      }
   }
   return nil
}

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

    apns: {
        aps: {
            alert: {
            title: "New Message!",
            subtitle: "",
            body: "Hello!"
            },
            "mutable-content": 0,
            category: "pusher"
        },
        data: {
        "messageId": "1"
        }
    }

В этом примере для отправки уведомлений я использую локальный NodeJS-сервер и Pusher API. Настройка занимает всего несколько минут и требует базового уровня владения NodeJS или навыков копирования-вставки.

Запустите приложение, сверните его в фон и отправьте уведомление. Когда получите уведомление, коснитесь его, чтобы открыть приложение:

Вот что происходит за кадром:

  1. Когда вы касаетесь уведомления, приложение инициирует метод делегата didReceiveRemoteNotification
  2. didReceiveRemoteNotification передаёт информацию из уведомления в Deeplink Manager
  3. Deeplink Manager пробует получить Deeplink Type по Notification User Info используя NotificationParser
  4. В методе applicationDidBecomeActive мы производим проверку наличия DeeplinkType’ов
  5. Если существует DeeplinkType (т.е. шаг 3 завершился успешно), мы выполняем соответствующее действие с помощью DeeplinkNavigator’а
  6. Как только шорткат был обработан, DeeplinkManager сбрасывает значение текущего шорткат-элемента к nil, чтобы не использовать его повторно

*   *   *

Этот подход позволяет легко добавлять или изменять любые элементы и не требует значительных изменений кода. И, что особенно важно, вы можете разбирать deeplink, соответствующим парсером. Например, чтобы добавить “Новый запрос” в обработчик уведомлений, вам всего лишь нужно изменить метод handleNotification в NotificationParser’е:

func handleNotification(_ userInfo: [AnyHashable : Any]) -> DeeplinkType? {
   if let data = userInfo["data"] as? [String: Any] {
      if let messageId = data["messageId"] as? String {
         return DeeplinkType.messages(.details(id: messageId))
      }
      if let requestId = data["requestId"] as? String {
         return DeeplinkType.request(.details(id: requestId))
      }
   }
   return nil
}

Обратите внимание, мы не используем didFinishLaunchingWithOptions ни для одного из этих deeplink’ов. Всё обрабатывается средствами applicationDidBecomeActive.

Поздравляю! Теперь ваше приложение оснащено унифицированной поддержкой шорткатов, deeplink’ов и уведомлений!

*   *   *

Смотрите итоговый проект здесь.

*   *   *

Я также пишу в “American Express Engineering Blog”. Оцените мои другие работы и работы моих талантливых коллег на AmericanExpress.io.

Автор: Stan Ostrovsky

Оригинал статьи

Перевод: Борис Радченко, radchenko.boris@gmail.com

Содержание