Haxe как идеальный язык для разработчика full stack. Часть 1. JavaScript
Привет, меня зовут Дмитрий, я занимаюсь разработкой кросс-платформенных решений, игр, веб-сервисов и мобильных приложений. В этом мне очень помогает язык Haxe, который позволяет шире взглянуть на понятие full stack.
Я хотел бы поделиться с вами своим опытом использования Haxe и рассказать о том, как именно он упрощает мою жизнь как разработчика. Надеюсь, вам это будет интересно, и, возможно, кто-нибудь из вас даже начнет использовать в своих проектах этот замечательный инструмент.
Какое-то время назад я уже писал статьи об этом языке: одна была вводной, теоретической, а вторая, небольшая, практической; в ней речь шла о том, как запустить один и тот же код на разных платформах. Если вы никогда не слышали о Haxe, то я рекомендовал бы для начала ознакомиться с предыдущими статьями.
Также имейте в виду, что я буду использовать язык Haxe версии 4, который на момент написания этой статьи находится в версии release candindate.
В качестве редактора я использую VS Code с официальным плагином.
Описать в одной статье все преимущества и недостатки Haxe очень тяжело, да и усвоить весь материал, считай, невозможно. Потому я решил разбить все на части, и этой статьей начну цикл, читать который стоит последовательно, чтобы ничего не упустить.
Практическое знакомство с Haxe мы начнем через одну из самых популярных целевых платформ — JavaScript. Дальше, разобравшись с языком, углубимся в специфику отдельно взятых таргетов, в частности C++, Java (и JVM), C# и других. А потом, возможно, если это будет интересно сообществу, напишем какой-нибудь реальный проект.
Вступление
На самом деле, говоря о Haxe, стоит подразумевать не только язык программирования. Потому что Haxe — это инструмент, включающий в себя и язык, и компилятор со статическим анализатором, и оптимизатор кода, и стандартную кросс-платформенную библиотеку классов, доступную на каждой платформе.
Примечание. В большинстве случаев компилятор Haxe — это на самом деле транслятор (transpiler). Однако в официальной документации используется именно термин «компилятор», так что мы тоже будем употреблять его. В дальнейшем вы поймете, почему границы между этими понятиями в контексте Haxe размыты.
Итак, вернемся к теме статьи — JavaScript.
Сегодня существует тысяча и одна причина не писать на JavaScript. Однако мы не будем пытаться доказать, что с JavaScript что-то не так. Тем более что современный стандарт очень даже неплох. Мы просто будем рассматривать Haxe как его хорошую альтернативу.
Если вам комфортнее писать на строго типизированном языке, то Haxe будет для вас отличной альтернативой, даже без привязки к тому, что на нем можно писать кросс-платформенный код.
Haxe научился компилироваться в JS еще в 2006 году, задолго до появления TypeScript и Dart. Но, к сожалению, он выпал из информационного пространства, и далеко не каждый в курсе, что такой язык вообще существует. Именно потому я и пишу эти статьи: чтобы рассказать вам об инструменте, который незаслуженно находится в тени.
Однако пусть малая известность вас не пугает. Haxe — взрослый инструмент, а не фреймворк-однодневка. И, в отличие от TS и Dart, Haxe справляется с некоторыми задачами лучше и быстрее. Поэтому давайте для начала взглянем на следующий бенчмарк (ссылка на GitHub — внизу статьи), в котором тестируется декодирование изображения.
lang | compilation time | chrome run time | firefox run time | size | minimified size |
Haxe | 0.22s | 6.76s | 8.15s | 27KB* | 13KB |
TypeScript | 2.79s | 7.86s | 8.46s | 12KB | 7KB |
Dart | 5.38s | 9.137s | 8.8s | 98KB | 89KB |
Wasm | 8.74s | 6.8s | 5.93s | 82KB** | 69KB |
Автор этого бенчмарка протестировал Haxe, TypeScript, Dart и Wasm на одной и той же задаче. Как видите, скорость компиляции Haxe в десятки раз выше, чем у конкурентов. Haxe компилирует выходной файл очень маленького размера, а по времени выполнения кода он один из лучших, проигрывая лишь Wasm в Firefox.
Конечно, скажете вы, это все «синтетика». Но, уверяю вас: на практике Haxe даст вам множество способов оптимизации вашего кода. Более того, в некоторых случаях код, написанный на Haxe и транслированный в JS, может выполняться быстрее, чем аналогичный код, написанный вручную на чистом JS. Все это возможно благодаря оптимизирующему компилятору, статическому анализатору и языковым конструкциям, которые позволяют писать код, максимально оптимизированный под каждую платформу.
О качестве генерации кода
Давайте рассмотрим следующий искусственный пример, демонстрирующий некоторые возможности в плане оптимизации генерируемого кода. К слову, этот пример справедлив и для других таргетов. Мы будем рассматривать его и в следующих статьях.
Если вы захотите опробовать код самостоятельно, то вам понадобится установленный в системе Node.js. Итак, создайте файл модуля Main.hx и поместите в него следующий код:
class Main { // Точка входа // Тип возвращаемого значения функции писать необязательно. В Haxe есть выведение типов из выражения. // Возвращаемый тип функции будет Void, потому что мы ничего в ней не возвращаем. static function main() { // Создадим контейнер с пользователями. // Указывать тип переменной, как и в случае с функцией, необязательно. final users = new Users(); for (i in 0…10) { // Добавим 10 пользователей со случайным возрастом: users.push(new User(inline Std.random(99))); } // Выведем только взрослых пользователей в консоль: users.printOnlyAdultUsers(); } } // Класс нашего пользователя. // Сделаем все поля класса публичными (вместо ключевого слова `public`, объявленного возле каждого поля отдельно): @:publicFields class User { final age:Int; function new(age:Int) { this.age = age; } inline function toString():String { return 'User(age = $age)'; } } // Наш контейнер с пользователями (обратите внимание, что это абстрактный тип): @:forward(push, length) abstract Users(Array<User>) { // Обратите внимание на ключевое слово inline: static inline final ADULT_AGE_VALUE:Int = 18; // Если тело функции содержит 1 строчку, то указывать {} скобки необязательно: public inline function new() this = []; public inline function printOnlyAdultUsers():Void { for (user in this) { if (user.age >= ADULT_AGE_VALUE) { trace(user.toString()); } } } }
Обратите внимание на абстрактный тип Users. В Haxe это не совсем то, что вы обычно представляете себе в других языках программирования. Абстрактный тип Haxe — это тип, который в рантайме является каким-либо другим типом. Фактически это обертка какого-то типа, если хотите.
В нашем случае мы оперируем более высокоуровневой абстракцией Users, но на практике это всего лишь массив, и в рантайме он также будет массивом. Это дает большой простор для оптимизации выходного файла.
Наш пример очень примитивный, однако я постарался отобразить в нем некоторые языковые конструкции, которые облегчают разработчику жизнь и помогают получить на выходе достаточно оптимизированный код.
Вы можете скопировать приведенный выше Haxe-код и собрать его со следующими параметрами в файле build.hxml для достижения аналогичного результата:
# Класс с методом main -m Main # Выходной файл -js ./main.js # Флаг для удаления неиспользуемого кода -dce full # Флаг для генерации ES6-классов (по умолчанию ES5) -D js-es=6 # Включение статического анализатора -D analyzer-optimize
Убедитесь, что Main.hx и build.hxml лежат в одной директории. Сочетание Ctrl+Shift+B в VS Code вызовет меню компиляции. Выберите в выпадающем списке ваш build.hxml для продолжения.
Подробнее об hxml-файлах и компиляции Haxe читайте в предыдущей статье.
Итак, скомпилировав проект, на выходе мы получим вот такой JavaScript-код (ES6 для более удобного чтения, по умолчанию Haxe генерирует ES5):
class Main { static main() { // Класс `Users` был упразднен до обычного массива: var users = []; // Цикл for был полностью развернут (так происходит не всегда, к слову, глубина развертывания может быть задана опционально, // а тело метода `Std.random()` было помещено в место его вызова (inline-вызов): users.push(new User(Math.floor(Math.random() * 99))); users.push(new User(Math.floor(Math.random() * 99))); users.push(new User(Math.floor(Math.random() * 99))); users.push(new User(Math.floor(Math.random() * 99))); users.push(new User(Math.floor(Math.random() * 99))); users.push(new User(Math.floor(Math.random() * 99))); users.push(new User(Math.floor(Math.random() * 99))); users.push(new User(Math.floor(Math.random() * 99))); users.push(new User(Math.floor(Math.random() * 99))); users.push(new User(Math.floor(Math.random() * 99))); // Метод `printOnlyAdultUsers` класса Users // был упразднен и помещен в место его вызова: var _g = 0; while(_g < users.length) { var user = users[_g]; ++_g; // Константа ADULT_AGE_VALUE была упразднена. if (user.age >= 18) { // Метод toString класса User был упразднен и помещен в место вызова: console.log("Main.hx:32:", "User(age = " + user.age + ")"); } } } } class User { constructor(age) { this.age = age; } // Метод toString() был убран из реализации, так как его содержимое генерируется прямо в место вызова и в рантайме, в классе User, он нам будет не нужен. Это позволяет уменьшить размер выходного файла. }
Как видите, наш контейнер Users был упразднен полностью, так как надобности в нем больше нет. При этом программист продолжает оперировать высокоуровневой абстракцией над массивом в качестве контейнера над пользователями. Хотя де-факто это всего лишь массив, и в этом случае Haxe не будет генерировать какой-либо оверхед.
Использование Haxe-классов в JavaScript
Представим ситуацию: вы решили написать некую библиотеку на Haxe и хотите иметь возможность использовать ее в JavaScript-проектах. Однако по умолчанию Haxe-классы скрыты извне, и получить доступ к ним у вас не получится. Для таких задач в Haxe есть специальная мета — expose.
Давайте заменим код класса Main из предыдущего примера следующим:
class Main { static function main() { } // Отмечаем функцию как видимую извне @:expose public static function printText(text:String):Void { trace(text); } }
Скомпилируем его, а затем создадим в корне нашего проекта файл test.js. Импортируем туда наш сгенерированный Haxe’ом JavaScript:
const hx = require("./main.js").Main; hx.printText("Hello!");
Запускаем: node./test.js
И в консоли видим: Main.hx:8: Hello!
Отлично! Обратите внимание, что метатег expose вы можете использовать как на поле класса, так и на самом классе.
Давайте вернемся на пару шагов назад и снова взглянем на сгенерированный код в main.js:
class Main { static main() { } static printText(text) { console.log("Main.hx:8:",text); } } $hx_exports["Main"] = Main; Main.main();
Как видите, помимо нашей новой функции, здесь присутствует еще и стандартная точка входа — функция main. Но в некоторых случаях мы хотим генерировать код без нее — так, будто это полноценный API, где не должно быть ничего лишнего. Поэтому давайте удалим пустую функцию main.
Однако если вы попробуете скомпилировать проект на этом этапе, то получите ошибку: Haxe не может найти точку входа в приложение. Чтобы обойти это, нам нужно изменить файл с инструкциями для компилятора (наш build.hxml) и убрать оттуда строчку -m Main
, заменив ее просто Main
. Так мы скажем компилятору, что хотим экспортировать класс Main без привязки к какому-то конкретному главному классу приложения, в котором содержится точка входа.
Проделав эти манипуляции, мы можем смело компилировать код, получив на выходе то, что и требовалось:
class Main { static printText(text) { console.log("Main.hx:4:",text); } }
Ничего лишнего!
Примечание. Внимательный читатель, наверное, уже обратил внимание на всю мощь этого подхода. Ведь Haxe умеет компилироваться во множество языков, а не только в JS! Перед вами открывается мир кросс-платформенной разработки. Написав бизнес-логику один раз, без привязки к платформе, вы сможете импортировать ее в проекты на абсолютно разных платформах! Скажем, в front-end на JS, сервер на PHP или Java. Однако нюансы работы с другими языками будут описаны в будущих статьях.
Использование JavaScript-классов в Haxe
Естественно, современный JS — это бездонная папка с зависимостями :) И если вы решили писать проект на Haxe, то непременно захотите воспользоваться сторонними библиотеками.
Создайте файл api.js с таким содержимым:
class Api { static printText(text) { console.log("api.js : " + text); } }; module.exports.Api = Api;
Теперь нам нужно как-то подключить его в Haxe. Для этого есть несколько способов, и они зависят от вашего проекта.
Замените модуль Main.hx следующим:
// Всем известный require в JS: @:jsRequire("./api.js", "Api") // Обратите внимание, что класс объявлен с ключевым словом extern (т. е. "внешний"): extern class Api { // Объявляем биндинги к функции: public static function printText(text:String):Void; } class Main { static function main() { // Метод принимает только строку. Ошибки с типами, как в JS, исключены. Api.printText("This is a text"); } }
Скомпилируем и взглянем на сгенерированный main.js:
var Api = require("./api.js").Api; class Main { static main() { Api.printText("This is a text"); } } Main.main();
Запустим и увидим в консоли This is a text
. Все выглядит так, как и планировалось, и теперь мы можем вызвать JavaScript-функцию из сторонней библиотеки.
Стоит сказать, что, помимо этого, Haxe может встроить JS-файл и на лету склеить его с вашим кодом. Просто добавьте --macro haxe.macro.Compiler.includeFile("api.js")
в ваш hxml-файл, и содержимое api.js будет встроено сразу в main.js.
Вставки JavaScript-кода
Бывают случаи, когда вам нужно сделать вставки кода на JavaScript (или любого другого таргета Haxe) прямо в место вызова. И Haxe это позволяет!
В разрезе этой задачи мы рассмотрим еще один способ работы с внешними классами — без типизации и написания биндингов (в терминологии Haxe — экстернов). Для этого видоизмените модуль Main.hx до следующего вида:
// Обратите внимание, что вы можете импортировать функцию. import js.Syntax.code; class Main { static function main() { // Просто вставляем JavaScript-код: code("require('./api.js').Api.printText('Test 1')"); // То же самое с захватом переменной: code("require('./api.js').Api.printText({0})", "Test 2"); // То же самое с привязкой к Haxe-переменной: final printText1 = code("require('./api.js').Api.printText"); printText1("Test 3"); // То же самое с привязкой к Haxe-переменной и ее последующей типизацией: final printText2:(String)->Void = code("require('./api.js').Api.printText"); // Теперь мы защищены от ошибки, связанной с типами передаваемых аргументов: printText2("Test 4"); } }
Как и прежде, я оставил комментарии, описывающие, что именно происходит в коде. Для сравнения посмотрим на сгенерированный JS:
class Main { static main() { require('./api.js').Api.printText('Test 1'); require('./api.js').Api.printText("Test 2"); require('./api.js').Api.printText("Test 3"); require('./api.js').Api.printText("Test 4"); } } Main.main();
Результат работы кода:
api.js : Test 1 api.js : Test 2 api.js : Test 3 api.js : Test 4
Еще раз рассмотрим пример, где мы выводим строку «Test 3»:
final printText1 = code("require('./api.js').Api.printText"); printText1("Test 3")
Мы присваиваем JS-функцию Haxe-переменной, и в данном случае компилятор не знает, какого типа наша исходная JS-функция, а потому не может корректно вывести тип этой Haxe-переменной. Таким образом, ей присваивается тип Dynamic — это динамический, небезопасный тип. Читай, обычная переменная в JS-мире, которая может быть чем угодно и содержать какие угодно поля.
В разрезе философии Haxe такая работа с кодом возможна, однако не стоит злоупотреблять динамикой, лучше стараться типизировать переменные, чтобы избежать многих ошибок в будущем.
Так, в следующей строке мы уже написали более безопасный код:
final printText:(String)->Void = code('require("./api.js").Api.printText'); printText("Test 4");
Мы явно указали тип, сообщив компилятору о том, что printText — функция, которая принимает строку и ничего не возвращает. Теперь мы можем избежать ошибок, связанных с типами, еще на этапе компиляции.
Отладка Haxe и JavaScript
Haxe умеет генерировать карты исходников (source maps) для JavaScript, а значит, может и отлаживаться. Подробнее — здесь.
Если вкратце, у вас в арсенале есть два флага:
- —debug — для формирования debug-сборки и автоматической генерации source maps;
- —D js-source-map — для генерации source maps в релизной сборке.
Для отладки Node.js достаточно воспользоваться встроенным Node-отладчиком в VS Code. Добавьте -debug в ваш hxml-файл .
Добавьте задачу в tasks.json:
{ "version": "2.0.0", "tasks": [ { "type": "haxe", "args": "active configuration", "group": { "kind": "build", "isDefault": true }, "label": "haxe-build" } ] }
Подробнее про задачи в VSCode.
Добавьте конфигурацию в launch.json:
{ "version": "0.2.0", "configurations": [ { "type": "node", "request": "launch", "name": "Launch Program", "program": "${workspaceFolder}/main.js", "preLaunchTask": "haxe-build" } ] }
Подробнее про конфигурации запуска в VSCode .
В конфигурации launch.json мы указали нашу задачу из tasks.json, которая будет компилировать Haxe-код перед запуском отладки сгенерированного main.js.
Готово! Ставьте брейкпоинт и жмите F5!
Макросы
Одна из киллер-фич языка — генерация кода во время компиляции через так называемые макросы. Если вкратце, макрокод — это код, который выполняется во время компиляции, и это то место, где вы можете получить доступ к типизированному абстрактному синтаксическому дереву и повлиять на его ветки.
Давайте опробуем их на самом простом примере. Создайте модуль Config.hx с таким кодом:
import haxe.Json; import haxe.macro.Expr; import haxe.macro.Context;
class Config { public macro static function get(file:ExprOf<String>):ExprOf<ConfigDef> { final pos = Context.currentPos(); //Присваиваем значение прямо из блока try. Потому что в хаксе все является выражение. final c:ConfigDef = try { //Перечисляем наше выражение, которое пришло извне: switch file.expr { //Если выражение - строковая константа, то мы передаем ее значение далее в getContent: case EConst(CString(s)): Json.parse(sys.io.File.getContent(s)); //Во всех других случаях выбросим ошибку: case _: Context.fatalError("Invalid file name.", pos); } } catch (e:Any) { //Выбросим ошибку компиляции, если парсинг JSON не был успешным: Context.fatalError("Invalid project config or file is not exists.", pos); } //Возвращаем наш конфиг с валидацией его полей через type check: return macro ($v{c} : Config.ConfigDef); } }
А наш модуль Main.hx исправьте следующим образом:
class Main { static function main() { trace(Config.get("./config.json").server); } }
Также нам понадобится файл config.json в корне, рядом с выходным main.js:
{ "server": "https://google.com" }
Теперь скомпилируем и посмотрим на выходной JS:
class Main { static main() { console.log("src/Main.hx:3:", "https://google.com"); } }
Мы с вами прочитали JSON-файл во время компиляции, взяли из него значение и вставили в наш код. Посмотрите, никакого оверхеда в рантайме! Кроме того, вы еще и получили валидацию своего конфига на лету. Попробуйте изменить config.json , сделав там опечатку в слове server. Haxe не даст вам такое скомпилировать.
В макросах вам будет доступна вся стандартная библиотека Haxe. Вы сможете манипулировать конфигурациями, ресурсами, файлами и генерировать код на лету. Более того, вы даже сможете привносить новый синтаксис в свой Haxe-код, парсить его макросом и генерировать из него то, что вам хочется.
И самое главное, вам не придется делать такие вещи в рантайме, а значит, ваш код будет работать быстрее.
Приведу пример. В одном из своих pet-проектов я не стал использовать синтаксис Express.js один в один, как это предлагает фреймворк: хотелось больше безопасности и гибкости. И вот с помощью макросов я пришел к следующему виду роутера:
class EventsRouter implements IRouter { @:get("/events.get") function getEvent(id:String) { res.asJson({id: id, title: "Event Title"}); } }
Это компилируется в следующий JS-код:
class EventsRouter { constructor() { this.__router.get("/events.get", $bind(this, this.getEvent)); } getEvent(req, res) { var status = {isSuccess: true, message: "Operation done."}; if (req.query.id == null || req.query.id.length == 0) { res.writeHead(400, {"Content-Type": "application/json"}); status.message = "Invalid request"; status.isSuccess = false; res.end({status: status, data: null}); } else { var id = Std.string(req.query.id); res.writeHead(200, {"Content-Type": "application/json"}); res.end({status: status, data: {id: id, title: "Event Title"}}); } } }
Таким образом, макрос вместо меня делает рутинную работу: валидирует входящие данные и в случае неуспеха сигнализирует об ошибке либо же отвечает на запрос положительно, если данные валидны, а также автоматически прописывает роуты в роутер.
Итак, благодаря Haxe и макросам вы сможете автоматизировать множество процессов в своем проекте.
Конец
Экосистема Haxe довольно обширная, в ней очень много «черной магии» и нюансов работы с каждой поддерживаемой платформой. Описать все в одной статье, которая рассказала бы о Haxe от а до я и которую кто-то осилил бы за раз, невозможно.
Однако я буду продолжать рассказывать об этой замечательной технологии в надежде на то, что она заинтересует вас так же, как и меня.
Если кто-то не разобрался, как что-то скомпилировать, настроить и запустить, смело спрашивайте в комментариях.
Спасибо, что дочитали!
Полезные ссылки
Getting started with JavaScript
Мануал по Haxe
Примеры кода
Форум
Wiki по плагину VS Code
Бенчмарк со скриншота
Мои статьи о Haxe на DOU
Связь со мною в Telegramm