Контекст

Команда Content Delivery Engineering (CDE) отвечает за доставку метаданных видео (названия, описания, изображения, актерский состав/команда, права/ограничения, URL-адреса воспроизведения и т. д.) во внешние пункты назначения за пределами Disney Streaming Services (каталоги сторонних рекламные биржи, партнерские CMS и т. д.). Когда нашей команде было поручено создать новое приложение в поддержку этой хартии, мы стремились построить его таким образом, чтобы обеспечить наименьший объем обслуживания и накладных расходов. Это включает в себя возможность поддержки большинства новых вариантов использования синдикации без необходимости каких-либо изменений кода. Кроме того, мы хотели не писать собственный язык шаблонов, а использовать существующую проверенную структуру для поддержки наших шаблонов. Наконец, у каждого партнера были разные требования к данным, которые они хотели доставить, что требовало большого количества пользовательской логики. Мы хотели, чтобы эта настраиваемая логика для каждого партнера не существовала в нашей кодовой базе, а содержалась в конкретном шаблоне каждого партнера. Введите руль!

Рули спешат на помощь!

Handlebars.js — это мощный движок шаблонов, который позволяет вам писать шаблоны для преобразования данных в произвольные форматы. Он построен на Языке шаблонов Mustache, но предлагает дополнительную функциональность. Такие функции, как оценка условных операторов и итерация массива, чрезвычайно полезны при построении шаблонов.

Одним из ограничений является то, что Handlebars является фреймворком javascript и поставляется в виде модуля npm. Наше приложение было разработано как приложение Scala с несколькими компонентами. К нашему заявлению предъявлялись следующие требования:

  • Доставлять метаданные контента во внешние системы в различных форматах, требуемых этими системами. Конкретным примером этого является необходимость синдицировать метаданные видео на рекламный сервер, чтобы включить релевантную рекламу в наших продуктах, поддерживаемых рекламой.
  • Поддерживать вывод в форматах json, xml и обычный текст. В настоящее время у нас есть около 20 различных фидов, которые мы доставляем внешним партнерам, все в разных форматах.
  • Отправить окончательный результат в конечную точку службы, корзину S3 или поток Kinesis. Помимо протоколов, основанных на push-уведомлениях, нам нужно было иметь возможность поддерживать конечные точки HTTP, из которых потребители могли бы извлекать данные.

Решение

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

{{#if data.photos}}                
  <bam:Images>                    
    {{#each data.photos}}                        
      <bam:Image xlink:href="{{uri}}" height="{{height}}" 
        width="{{width}}" type="image/jpeg" key="{{imageKey}}"/>     
    {{/each}}                
  </bam:Images>            
{{/if}}

Приведенный выше шаблон проверяет, существует ли массив photos. Если это так, он создаст объект <bam:Images>. Затем с помощью помощника #each он будет перебирать массив фотографий и добавлять значения объекта Photo к атрибутам <bam:Image>. Если бы мы попытались написать код, обрабатывающий эту логику напрямую в нашем приложении, мы бы получили множество заказной логики, основанной на желаемом результате партнера. Использование шаблонов с помощниками, как показано выше, позволяет нам ограничить всю эту логику конкретным шаблоном для каждого партнера.

Вот код Scala для помощника #if в библиотеке Scala:

def `if`(obj: Object, options: Options): CharSequence =
  obj match {
    case it: Iterable[_] =>
      if (it.isEmpty) options.inverse() else options.fn()
    case _ =>
      IfHelper.INSTANCE(obj, options)
  }

Поле photos представляет собой массив, поэтому оно будет совпадать в начальном случае, а затем выполняет простую проверку, является ли массив пустым или нет. Если он пуст, он вернет options.inverse(), что на самом деле говорит шаблону использовать значение, определенное шаблоном в блоке «else». В противном случае он вызовет options.fn(), который сообщает шаблону, что условие оценивается как истинное, и использовать значение в блоке «если».

Наконец, вывод вышеуказанного блока будет:

<bam:Images>                
  <bam:Image key="" type="image/jpeg" width="133" height="200"
   xlink:href="https://images.unsplash.com/photo-1530143584546-  02191bc84eb5"/>            
</bam:Images>

Нам нужно больше!

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

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

{{#in system "FCC-TVPG (USA), TVPG"}}
      <advisory systemCode="us-tv"/>
{{/in}}

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

Вот код Scala, который мы написали для поддержки этого помощника #in:

def in(sourceObject: Object, field: Object, values: Object, options: Options): CharSequence =
  (sourceObject, field, values) match {
    case (str: String, list: String, _) =>
      if (list.split(", *") contains str) options.fn() else options.inverse()
    case (sourceObject, field: String, _) =>
      in(Try(sourceObject.toString).getOrElse(""), list, null, options)
    case _ =>
      options.inverse()
  }

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

Чуть более сложным помощником будет наш помощник #dateCompare, который позволяет нам проверять, является ли поле даты до, равно или после определенной даты, которую мы указываем. Ниже приведен пример его использования:

{{#dateCompare startDate "isBefore" "2019-01-31T00:00:00Z"}}
  <matchTime="{{startDate}}"/>
{{/dateCompare}}

Поле, которое мы сравниваем с исходным объектом, — это startDate. Если дата начала до 2019–01–31T00:00:00Z, мы можем продолжить и установить элемент matchTime. Как вы заметили, у нас в выражении есть дополнительный аргумент по сравнению с хелпером #in. Это позволяет нам указать, хотим ли мы использовать isBefore, isEqual или isAfter. Ключ к этому находится в нашей вспомогательной функции Scala, нам просто нужно было добавить дополнительные аргументы в нашу сигнатуру функции. На самом деле, вы можете указать любое количество аргументов, которое хотите, в любом помощнике шаблона Scala, который вы пишете. Код для этого конкретного помощника приведен ниже:

def dateCompare(dateObject: Object, operatorObject: Object, compObject: Object, dateFormatObject: Object, options: Options): CharSequence = {
  val (dateValueOption, compValueOption): (Option[LocalDateTime], Option[LocalDateTime]) = (dateObject, compObject, dateFormatObject) match {
    case (dateField: String, compDateField: String, format: String) =>
      (DataExtractor.getDate(dateField, format), DataExtractor.getDate(compDateField, format))
    case (dateField: String, compDateField: String, _) =>
      (Try(Some(LocalDateTime.parse(dateField, DateTimeFormatter.ISO_DATE_TIME))).getOrElse(None), Try(Some(LocalDateTime.parse(compDateField, DateTimeFormatter.ISO_DATE_TIME))).getOrElse(None))
    case _ =>
      (None, None)
  }

  val result: Boolean = (dateValueOption, operatorObject, compValueOption) match {
    case (Some(dateValue), "isAfter", Some(compValue)) =>
      dateValue.isAfter(compValue)
    case (Some(dateValue), "isBefore", Some(compValue)) =>
      dateValue.isBefore(compValue)
    case (Some(dateValue), ("isEqual" | "==" | "equals"), Some(compValue)) =>
      dateValue.isEqual(compValue)
    case _ =>
      false
  }
  if (result) {
    options.fn()
  } else {
    options.inverse()
  }
}

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

Ниже приведены еще несколько пользовательских помощников, которые мы реализовали:

  • #in как указано выше
  • #notIn, который является просто инверсией помощника #in
  • #startsWith, который используется для сопоставления любой строки, начинающейся с указанного префикса.
  • #xmlDuration, который используется для преобразования длительности в миллисекундах в формат xsd:duration
  • и еще несколько!

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

Собираем все вместе

Теперь, когда мы создали все необходимые нам помощники и создали шаблоны, соответствующие требованиям, изложенным нашими партнерами, как нам собрать все это вместе, чтобы получить ожидаемый результат? Используемая нами библиотека Scala написана поверх этой реализации Java. Для начала мы просто создаем новый экземпляр объекта Handlebars.

val handlebars = new Handlebars()

Затем мы можем приступить к регистрации ваших помощников. Библиотека Scala предлагает несколько помощников Scala, и, конечно же, мы хотим добавить созданные нами. Мы можем сделать это, связав функцию registerHelpers с приведенной выше:

val handlebars = new Handlebars()
  .registerHelpers(ScalaHelpers)
  .registerHelpers(inject[CustomHelpers])

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

val template = <some handlebars template>
val handlebarsTemplate = handlebars.compileInline(template)

где template равно:

{{#if data.photos}}                
  <bam:Images>                    
    {{#each data.photos}}                        
      <bam:Image xlink:href="{{uri}}" height="{{height}}" 
        width="{{width}}" type="image/jpeg" key="{{imageKey}}"/>     
    {{/each}}                
  </bam:Images>            
{{/if}}

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

def ctx(obj: Object) = Context.newBuilder(obj).resolver(Json4sResolver, MapValueResolver.INSTANCE).build
val output = handlebarsTemplate(ctx(contentJson))

где contentJson равно:

{  
   "photos":[  
      {        
         "uri":"https://someurl1.com/",
         "height":608,
         "width":1920
      },
      {  
         "uri":"https://someurl2.com/",
         "height":1024,
         "width":780
      }
   ]
}

Определенная выше функция ctx создает стек контекста для шаблона, что делает все элементы в исходных объектах доступными для шаблона. Затем мы используем эту функцию и передаем ее в объект ourTemplate. Затем скомпилированный шаблон объединяется с исходным контекстом, и вуаля, у нас есть полностью построенный вывод!

Ниже приведен последний output, который будет возвращен из нашего вызова для слияния скомпилированного шаблона с исходным контекстом.

<bam:Images>                                
    <bam:Image xlink:href="https://someurl1.com/"
        height="608" width="1920" type="image/jpeg" key=""/>
    <bam:Image xlink:href="https://someurl2.com/"
        height="1024" width="780" type="image/jpeg" key=""/>     </bam:Images>

Где мы сейчас?

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