Есть некоторые тонкости в написании такой 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 приложения:
+-- build.sbt
+-- project
¦ +-- build.properties
¦ L-- plugins.sbt
+-- src
¦ L-- main
¦ +-- resources
¦ ¦ +-- demo.js
¦ ¦ L-- index-fastopt.html
¦ L-- scala
L-- version.sbt
resources/index-fastopt.html
— страница только загрузит нашу библиотеку и файл resources/demo.js
, для проверки APIAPI
Цель — упростить взаимодействие с 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()
каждый раз не лучшее идея, если вам не нужна ленивость вы можете закешировать их при запуске:<!--index-fastopt.html-->
<script>
var Github = Github()
Если сейчас мы попробуем получить имя форка, получим
undefined
. Все правильно, оно не экспортировалось, давайте экспортируем свойства модели.C нативными типами, такими как
String
, Boolean
или Int
проблем нет, их можно экспортировать так:sealed trait Repo {
@JSExport
def name: String
// ...
}
Поле 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
}
Давайте попробуем:
console.log("fork.name: " + fork.name);
console.log("fork.homepage: " + fork.homepage);
Мы оставили наш любимый
Option
и сделали чистое красивое API для JS. Ура!List
User.repos
это List
и есть трудности с его экспортированием. Решение такое же, просто экспортируем его как JS массив:@JSExport("repos")
def reposJS: js.Array[Repo] = repos.toJSArray
// JS
user.repos.map(function (repo) {
return repo.name;
});
Подтипы
Есть все еще одна проблема с трейтом
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
}
</code>
Нам нужно другое имя, потому что <code>type</code> это зарезервированное слово в Scala.
<source lang="scala">
sealed trait Repo extends Typed {
// ...
}
… и используем его:
// JS
fork.type // "Fork"
Сделать немного безопасней можно, если хранить константы (тут нам поможет компилятор):
class TypeNameConstant[T: ClassTag] {
@JSExport("type")
def typ: String = classTag[T].runtimeClass.getSimpleName
}
С помощью этого хелпера мы можем объявить нужные константы в объекте
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
, который мы изменить не можем.case class Commit(hash: String)
Вот как его можно экспортировать:
object CommitJS {
def fromCommit(c: Commit): CommitJS = CommitJS(c.hash)
}
case class CommitJS(@(JSExport@field) hash: String) {
def toCommit: Commit = Commit(hash)
}
Затем, например, класс
Branch
из управляемого нами кода будет выглядеть вот так:case class Branch(initial: Commit) {
@JSExport("initial")
def initialJS: CommitJS = CommitJS.fromCommit(initial)
}
Так как в JS среде commits представлены как
CommitJS
обьекты, фабричный метод для Branch
будет:@JSExport
def createBranch(initial: CommitJS) = Branch(initial.toCommit)
Конечно, это не супер способ, но зато он проверяется компилятором. Вот почему я предпочитаю смотреть на такую библиотеку не только как на прокси для value-классов, а как на фасад, который скрывает ненужные детали и упрощает API.
AJAX
Реализация
Для простоты мы будем использовать
Ajax
расширение библиотеки scalajs-dom для сетевых запросов. Давайте отвлечемся от экспорта и просто реализуем API.Чтобы не усложнять, мы положим все связанное с AJAX в обьект
API
, у него будет два метода: для загрузки пользователя и загрузки репозитория.Так же мы сделаем слой DTO, чтобы отделить API от модели. Результатом метода будет
Future[String \/ DTO]
, где DTO
это тип запрошенных данных, а String
будет представлять ошибку Вот непосредственно код:object API {
case class UserDTO(name: String, avatar_url: String)
case class RepoDTO(name: String,
description: String,
stargazers_count: Int,
homepage: Option[String],
forks: Int,
fork: Boolean)
def user(login: String)
(implicit ec: ExecutionContext): Future[String \/ UserDTO] =
load(login, s"$BASE_URL/users/$login", jsonToUserDTO)
def repos(login: String)
(implicit ec: ExecutionContext): Future[String \/ List[RepoDTO]] =
load(login, s"$BASE_URL/users/$login/repos", arrayToRepos)
private def load[T](login: String,
url: String,
parser: js.Any => Option[T])
(implicit ec: ExecutionContext): Future[String \/ T] =
if (login.isEmpty)
Future.successful("Error: login can't be empty".left)
else
Ajax.get(url).map(xhr =>
if (xhr.status == 200) {
parser(js.JSON.parse(xhr.responseText))
.map(_.right)
.getOrElse("Request failed: can't deserialize result".left)
} else {
s"Request failed with response code ${xhr.status}".left
}
)
private val BASE_URL: String = "https://api.github.com"
private def jsonToUserDTO(json: js.Any): Option[UserDTO] = //...
private def arrayToRepos(json: js.Any): Option[List[RepoDTO]] = //...
}
Десериализация кода скрыта, нам она не интересна, метод
load
возвращает строку ошибки, если код не 200, иначе он конвертирует ответ в JSON, а потом в DTOТеперь мы может конвертировать ответ API в модель.
import scala.scalajs.concurrent.JSExecutionContext.Implicits.queue
object Github {
// ...
def loadUser(login: String): Future[String \/ User] = {
for {
userDTO <- EitherT(API.user(login))
repoDTO <- EitherT(API.repos(login))
} yield userFromDTO(userDTO, repoDTO)
}.run
private def userFromDTO(dto: API.UserDTO,
repos: List[API.RepoDTO]): User = //..
}
Здесь мы используем 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 уже есть реализация. Вот пример для тех, кто хочет сделать реализацию сам:
trait Promise[+A] extends js.Object {
@JSName("catch")
def recover[B >: A](
onRejected: js.Function1[Any, B]): Promise[Any] = js.native
@JSName("then")
def andThen[B](
onFulfilled: js.Function1[A, B]): Promise[Any] = js.native
@JSName("then")
def andThen[B](
onFulfilled: js.Function1[A, B],
onRejected: js.Function1[Any, B]): Promise[Any] = js.native
}
Ну и companion object с методами вроде
Promise.all
. Теперь нам надо только расширить этот trait:@JSName("Promise")
class Promise[+R](
executor: js.Function2[js.Function1[R, Any], js.Function1[Any, Any], Any]
)
extends org.scalajs.dom.raw.Promise[R]
Итак, теперь нам надо лишь сконвертировать
Future
в Promise
. Сделаем это с помощью implicit class:object promise {
implicit class JSFutureOps[R: ClassTag, E: ClassTag](f: Future[\/[E, R]]) {
def toPromise(recovery: Throwable => js.Any)
(implicit ectx: ExecutionContext): Promise[R] =
new Promise[R]((resolve: js.Function1[R, Unit],
reject: js.Function1[js.Any, Unit]) => {
f.onSuccess({
case \/-(f: R) => resolve(f)
case -\/(e: E) => reject(e.asInstanceOf[js.Any])
})
f.onFailure {
case e: Throwable => reject(recovery(e))
}
})
}
}
Функция recovery превращает «упавший»
Future
в «упавший» Promise
. Левая сторона дизъюнкции так же «роняет» promise.Итак, теперь давайте поделимся нашим промисом с друзьями фронтендерами, как обычно мы добавим его в обьект
Github
рядом с оригинальным методом:def loadUser(login: String): Future[String \/ User] = //...
@JSExport("loadUser")
def loadUserJS(login: String): Promise[User] =
loadUser(login).toPromise(_.getMessage)
Здесь в случае ошибки мы роняем promise с ошибкой из исключения. Все, теперь можем протестировать API.
// JS
Github.loadUser("vpavkin")
.then(function (result) {
console.log("Name: ", result.name);
}, function (error) {
console.log("Error occured:", error)
});
// Name: Vladimir Pavkin
Отлично, теперь мы можем использовать Future и все, к чему привыкли — и все же экспортировать его как идиоматичный JS API.
Заключение Вот несколько советов по написанию Javascript библиотеки с помощью Scala.js
- Кешируйте экспортируемые объекты при запуске;
- Экспортируйте seamless типы как есть;
- Не экспортируйте
Option
,List
и другие Scala штуки. Используте геттер который конвертирует вjs.UndefOr
andjs.Array
; - Не экспортируйте конструкторы. Используйте JS-friendly фабрики;
- JS-friendly означает принятие
js.*
типов и преобразовывайте их в стандартные типы Scala; - Подмешивайте строковое поле
type
в типы-суммы; - Экспортируйте
Future
какJS Promise
; - В первую очередь пишите на Scala. Не ограничивайте себя в самовыражении как Scala-разработчик, используйте возможности языка на полную.
Теперь вы знаете, что все это можно экспортировать.
Код примеров можно найти на GitHub: https://github.com/vpavkin/scalajs-library-tips
Владимир Павкин
Scala–разработчик
Комментарии (17)
igor_suhorukov
09.12.2015 08:34Правильно я понимаю, что проект — компилятор scala в клиентский javascript? Т.е. по сути аналог GWT, но с функциональным программированием.
Что из базовой библиотеки функций scala поддерживается на клиенте?vayho
09.12.2015 10:39+1Почти все кроме описанного вот тут http://www.scala-js.org/doc/semantics.html
qiwi_russia
09.12.2015 11:01+1GWT — это громадный фреймворк, а ScalaJS — просто компилятор из Scala в JS. То есть тут никто не навязывает подходов — просто дают возможность писать фронтенд на Scala.
Практически вся стандартная библиотека Scala поддерживается, к тому же многие популярные библиотеки также уже поддерживают ScalaJS.
forgotten
09.12.2015 10:21+1Смотрю на список аргументов «за» использование ScalaJS
спрятать сложную или неочевидную клиентсайд логику в ней и предоставить удобное API;
в библиотеке вы сможете работать с моделями из backend приложения;
изоморфный код из коробки и можете забыть про проблемы синхронизации протоколов;
у вас будет публичный API для разработчиков, как у Facebook’s Parse.
Очевидно, первое и четвёртное ничто не мешает писать на JavaScript-е. Третье я не понимаю (во всяком случае, на JS хватает фреймворков для работы с REST-сервисами без всяких Скал). Остаётся один действительно валидный аргумент — реюз серверного кода.
Признаться, к аргументации «у нас на фронте и бэкенде используется один и тот же язык» и вообще ко всей концепции я отношусь, кхм, с некоторым подозрением. Всё равно фронт придётся писать на JavaScript-е. Не собираетесь же вы в самом деле писать работу с HTML/CSS на Scala. Во-первых, библиотек на каждое браузерное API не напасёшься делать; во-вторых, это [написание типизированных обёрток к браузерному JS, который чуть менее чем полностью, состоит из сурового легаси] — попросту выбрасывание времени и денег на ветер; в-третьих, представить себе типизированную обёртку над jQuery/jQuery UI я не могу при всём желании, а разработка веб-приложений без них я иначе как мастурбацией вприсядку не могу назвать.
Фактически, использование Scala в этом месте необходимо в основном затем, чтобы бесшовно транслировать объекты как они представлены на сервере в идентичные на клиенте. ИМХО, накладные расходы в виде кучи бессмысленного для JS синтаксиса это сомнительное удобство не оправдывают — работать-то с объектами всё равно придётся по-разному на клиенте и сервере.senia
09.12.2015 11:26Для jQuery есть обертки: jquery-facade, scala-js-jquery. Вообще не полный список типизированных фасадов можно посмотреть здесь.
Я пробовал писать на scalajs-angular — очень понравилось.
Есть scalajs-react.
К сожалению на боевых проектах опробовать scala.js не довелось. На моем текущем проекте мне бы разделение кода между клиентом и сервером очень не помешало бы. У нас огромное количество довольно сложных проверок данных, которые приходится дублировать на клиенте (чтоб быстро подсказывать клиентам) и на сервере (чтоб не пропустить не валидные данные).
senia
09.12.2015 11:30Про третий пункт: scala.js, например, позволяет использовать одну и ту же библиотеку сериализации на клиенте и сервере. Не подобрать похожие и синхронизировать поведение, а действительно использовать одну библиотеку.
some_x
10.12.2015 15:23представить себе типизированную обёртку над jQuery/jQuery UI я не могу при всём желании
Не надо представлять: https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/jquery/jquery.d.ts
а разработка веб-приложений без них я иначе как мастурбацией вприсядку не могу назвать.
Да ладно? Ну вот зачем например jquery в angular2 приложении?
solver
10.12.2015 15:48Такой момент.
ScalaJS несет с собой рантайм, который дает минимум порядка 170кб оверхед.
Соответственно, каждая библиотека, в проекте, будет иметь минимум 170кб вес. А если их 4-5 надо в проект?
Не многовата ли цена, за не очень большое удобство разработки?vayho
10.12.2015 15:52Можно использовать один рантайм для всех библиотек. Это же просто js скрипт.
solver
10.12.2015 18:37Это если бы рантайм был просто библиотекой.
Так то он компилится вместе с библиотекой, и из него выкидывается все, что не используется в коде библиотеки.vayho
10.12.2015 19:57ну либы по идее поставляются в Scala коде, а потом уже все приложение компилируется в js, т.е. рантайм все равно будет один, хотя да любая отдельная либа собирается вместе с рантаймом
nixan
11.12.2015 17:56Можно лишь добавить, что потенциальные области применения именно js-библиотек написанных на scalajs практически исключают возможность поключения более одной такой библиотеки. Даже если такое произойдет, скорее всего будет возможность подключить их вместе в sbt и скомпилировать в одну большую либу с одним общим рантаймом
senia
С экранированием не работает?
qiwi_russia
Вполне пригодный вариант, я просто визуально не очень люблю экранированые имена.