Как написать JS-библиотеку на ScalaJS09.12.2015 02:33
Scala.js открывает огромный мир фронтенд технологий для Scala разработчиков. Обычно проекты, использующие Scala.js, это веб- или nodejs-приложения, но бывают случаи, когда вам просто нужно создать JavaScript-библиотеку.
Есть некоторые тонкости в написании такой Scala.js библиотеки, но они покажутся знакомыми для JS разработчиков. В этой статей мы создадим простую Scala.js библиотеку (код) для работы с Github API и сосредоточимся на идиоматичности JS API.
Но сначала наверняка вы хотите спросить, зачем вообще может понадобиться делать такую библиотеку? Например, если у вас уже есть клиентское приложение написанное на JavaScript и оно общается с бэкендом на Scala.
Вряд ли у вас получится написать ее с чистого листа с помощью Scala.js, но можно написать библиотеку для взаимодействия между вами и фронтенд разработчиками, которая позволит:
спрятать сложную или неочевидную клиентсайд логику в ней и предоставить удобное API;
в библиотеке вы сможете работать с моделями из backend приложения;
изоморфный код из коробки и можете забыть про проблемы синхронизации протоколов;
у вас будет публичный API для разработчиков, как у Facebook«s Parse.
Также это отличный выбор для разработки Javascript API SDK, благодаря всем этим преимуществам.
Недавно я столкнулся с тем что у нашего REST JSON API два разных браузерных клиента, поэтому разработка изоморфной библиотеки была хорошим выбором. Давайте начнем создание библиотеки
Требования: как Scala разработчики мы хотим писать в функциональном стиле и использовать все фишки Scala. В свою очередь API библиотеки должно быть легко понять для JS разработчиков.
Начнем со структуры каталогов, она не отличается от обычной структуры для Scala приложения:
resources/index-fastopt.html — страница только загрузит нашу библиотеку и файл resources/demo.js, для проверки API
API
Цель — упростить взаимодействие с Github API. Для начала мы сделаем только одну фичу — загрузку юзеров и их репозиториев. Итак это публичный метод и парой моделей с результатами ответа. Начнем с модели.
Модель
Определим наши классы вот так:
case class User(name: String,
avatarUrl: String,
repos: List[Repo])
sealed trait Repo {
def name: String
def description: String
def stargazersCount: Int
def homepage: Option[String]
}
case class Fork(name: String,
description: String,
stargazersCount: Int,
homepage: Option[String]) extends Repo
case class Origin(name: String,
description: String,
stargazersCount: Int,
homepage: Option[String],
forksCount: Int) extends Repo
Ничего сложного, User имеет несколько репозиториев, а репозиторий может быть оригиналом или форком, как же нам экспортировать это для JS разработчиков?
Для полного описания функционала смотрите Export Scala.js APIs to Javascript.
API для создания объектов. Давайте посмотрим как оно работает, простое решение экспортировать конструктор.
@JSExport
case class Fork(name: String, /*...*/)]
Но оно не сработает, у вас нет экспортированного конструктора Option, поэтому не получится создать параметр homepage. Есть и другие ограничения для case классов, вы не сможете экспортировать конструкторы с наследованием, вот такой код даже не скомпилируется
@JSExport
case class A(a: Int)
@JSExport
case class B(b: Int) extends A(12)
@JSExport
object Github {
@JSExport
def createFork(name: String,
description: String,
stargazersCount: Int,
homepage: UndefOr[String]): Fork =
Fork(name, description, stargazersCount, homepage.toOption)
}
Тут, с помощью js.UndefOr мы обрабатываем опциональный параметр в стиле JS: можно передать String или вообще обойтись без него:
// JS
var homelessFork = Github().createFork("bar-fork", "Bar", 1);
var fork = Github().createFork("bar-fork", "Bar", 1, "http://foo.bar");
Замечание касательно кеширования Scala-объектов:
Делать вызов Github() каждый раз не лучшее идея, если вам не нужна ленивость вы можете закешировать их при запуске:
<script>
var Github = Github()
Если сейчас мы попробуем получить имя форка, получим undefined. Все правильно, оно не экспортировалось, давайте экспортируем свойства модели.
C нативными типами, такими как String, Boolean или Int проблем нет, их можно экспортировать так:
Поле case класса может быть экспортировано с помощью аннотации @(JSExport@field). Пример для свойства forks:
case class Origin(name: String,
description: String,
stargazersCount: Int,
homepage: Option[String],
@(JSExport@field) forks: Int) extends Repo
Option
Но как вы уже догадались есть проблема с homepage: Option[String]. Мы можем экспортировать ее тоже, но это бесполезно, чтобы получить значение из Option, js разработчик должен будет вызвать какой нибудь метод, но для Option ничего не экспортировано.
С другой стороны, мы хотели бы сохранить Option, чтобы наш Scala-код оставался простой и понятный. Простое решение — экспортировать специальный js геттер:
import scala.scalajs.js.JSConverters._
sealed trait Repo {
//...
//не экспортируем поле, с которым неудобно работать в JS
def homepage: Option[String]
@JSExport("homepage")
def homepageJS: js.UndefOr[String] = homepage.orUndefined
}
Есть все еще одна проблема с трейтом Repo. Так как мы не экспортируем конструкторы, JS разработчик не сможет понять, с каким подтипом Repo он имеет дело.
в Javascript нет сопоставления с образцом (pattern matching) и использование наследования не так популярно (а иногда и спорно), поэтому у нас есть несколько вариантов:
Создать методы isFork: Boolean или hasForks: Boolean. Это нормально, но не достаточно обобщенно.
Добавить свойство type: String для всех подтипов.
Я выбираю 2 путь, его легко абстрагировать и использовать во всем проекте, давайте обьявим mixin который экспортирует свойство type:
trait Typed { self =>
@JSExport("type")
def typ: String = self.getClass.getSimpleName
}
Нам нужно другое имя, потому что type это зарезервированное слово в Scala.
sealed trait Repo extends Typed {
// ...
}
… и используем его:
// JS
fork.type // "Fork"
Сделать немного безопасней можно, если хранить константы (тут нам поможет компилятор):
С помощью этого хелпера мы можем объявить нужные константы в объекте GitHub:
@JSExportAll
object Github {
//...
val Fork = new TypeNameConstant[model.Fork]
val Origin = new TypeNameConstant[model.Origin]
}
Это позволит нам избежать строк в Javascript, пример
// JS
function isFork(repo) {
return repo.type == Github.Fork.type
}
Вот так мы работаем с подтипами.
Что, если я не могу поменять обьект, который хочу экспортировать?
В этом случае, возможно, вы экспортируете классы своей кросс — компилируемой модели или объекты из импортированных библиотек. Способы одинаковы и для Option и для List, с одним различием — вам нужно самим реализовать приемлемые, с точки зрения JS, классы-обертки и конвертацию.
Здесь важно использовать js замены только для экспорта (Scala => JS) и для создания экземпляров (JS => Scala) Все бизнес логика должна быть реализована только чистыми Scala классами.
Допустим у нас есть класс Commit, который мы изменить не можем.
Конечно, это не супер способ, но зато он проверяется компилятором. Вот почему я предпочитаю смотреть на такую библиотеку не только как на прокси для value-классов, а как на фасад, который скрывает ненужные детали и упрощает API.
AJAX
Реализация
Для простоты мы будем использовать Ajax расширение библиотеки scalajs-dom для сетевых запросов. Давайте отвлечемся от экспорта и просто реализуем API.
Чтобы не усложнять, мы положим все связанное с AJAX в обьект API, у него будет два метода: для загрузки пользователя и загрузки репозитория.
Так же мы сделаем слой DTO, чтобы отделить API от модели. Результатом метода будет Future[String \/ DTO], где DTO это тип запрошенных данных, а String будет представлять ошибку Вот непосредственно код:
Десериализация кода скрыта, нам она не интересна, метод load возвращает строку ошибки, если код не 200, иначе он конвертирует ответ в JSON, а потом в DTO
Теперь мы может конвертировать ответ API в модель.
Здесь мы используем monad transformer для работы с Future[\/[..]], а потом конвертируем DTO в модель.
Отлично, это выглядит как функциональный код Scala, приятно смотреть. Теперь перейдем к экспорту метода loadUser для пользователей нашей библиотеки.
Share the Future
Теперь у нас возникает вопрос, какой общепринятый способ для работы с асинхронными вызовами в Javascript? Я уже слышу смех js разработчиков, потому что его не существует. Callbacks, event emitters, promises, fibers, generators, async/await это все используется, что нам выбрать? Я считаю промисы это ближайшая реализация к Scala Future. Промисы очень популярны и уже поддерживаются из коробки многими соверменными бразуерами, мы возьмем их. Для начала надо сообщить нашему коду о промисах. это называется «Typed Facade». мы легко можем это сделать сами, но в scalajs-dom уже есть реализация. Вот пример для тех, кто хочет сделать реализацию сам: