WebAssembly с Go: вывод веб-приложений на новый уровень

В сообществе разработчиков участились обсуждения WebAssembly, или WASM. Потенциал этой технологии огромен, для нашего Open Source проекта  —  инфраструктуры Permify, с помощью которой разработчики создают и управляют детализированными разрешениями приложений,  —  она просто бесценна.

Расскажем, почему и как мы интегрировали WASM в нашу интерактивную среду и получили преимущества от использования Golang.

Каков функционал этой среды? Если вкратце, это интерактивный модуль Permify, которым создаются и тестируются модели авторизации.

Содержание статьи:

  1. Краткое описание WASM и преимуществ его применения с Go.
  2. Что способствовало нашему решению интегрировать WASM в Permify?
  3. Реализация WASM, в том числе:
  • быстрая подготовка, реализация WASM с Go;
  • подробная разбивка кода WASM в Permify;
  • фронтенд, этапы внедрения Go WASM в приложение на React.

К концу у вас должно сложиться четкое представление о том, почему и как мы использовали возможности WASM для проекта Permify.

Понятие о WebAssembly

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

1. Возможности WebAssembly

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

Основные преимущества:

  • Благодаря поддержке основных браузеров, Wasm обеспечивается стабильная производительность на различных платформах.
  • Значительно повышается реактивность веб-приложений, так как в Wasm двоичный код выполняется со скоростью нативных.

Мы приняли стратегическое решение включить Golang в фундаментальную основу Open Source проекта Permify, выбор этот обусловлен широким признанием статической типизации, конкурентного выполнения и оптимизации производительности Golang. А когда мы создали интерактивную среду Permify, обратили внимание на WebAssembly как важнейший элемент.

2. Сочетание Go и WebAssembly

  • Характеристики Go: благодаря оптимальной производительности и возможностям конкурентного выполнения Go обзавелся солидной репутацией в сообществе разработчиков.
  • Синергия с WebAssembly: код Go преобразуется в WebAssembly, и разработчики эффективно используют надежную производительность и управление параллелизмом Go прямо в браузере, создавая мощные, эффективные, масштабируемые веб-приложения.

Но объединением Go и WebAssembly мы не ограничимся: определим причины выбора Wasm технологией разработки интерактивной среды Permify и выгоды этого решения.

Почему WebAssembly?

Создав интерактивную среду Permify, мы задались вопросом: «Как продемонстрировать наши возможности, не опутываясь трудностями и проблемами сопровождения традиционных серверных архитектур?» Блестящим ответом стал WebAssembly.

Перейдя на этот двоичный формат инструкций, мы:

  • Работаем с интерактивной средой Permify прямо в браузере, избегая накладных расходов на сопровождение сервера и повторных API-вызовов, упрощая при этом текущее сопровождение по сравнению со старыми, серверными подходами.
  • Достигаем максимальной производительности, по которой приложения Go благодаря WebAssembly сопоставимы с нативными, совершенствуя пользовательское взаимодействие время отклика.

Технические преимущества и обратная связь

Применяя WebAssembly в интерактивной среде Permify, мы получили ощутимые технические преимущества и поддержку сообщества.

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

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

Реализация WASM с Go

Прежде чем изучать применение WebAssembly и Go в Permify, рассмотрим их сочетание в примере приложения. Поэтапно объединим их, предваряя глубокое погружение в реализацию Permify.

1. Преобразование Go в WebAssembly

  • Этапы:
  1. Сначала задаем целевую платформу компиляции WebAssembly в Go:
GOOS=js GOARCH=wasm go build -o main.wasm main.go

2. Затем применяем оптимизации, уменьшая размер файла и повышая производительность:

wasm-opt main.wasm --enable-bulk-memory -Oz -o play.wasm
  • Обработка событий:

Реакция функции Go на нажатие кнопки на веб-странице:

package main

import "syscall/js"

func registerCallbacks() {
js.Global().Set("handleClick", js.FuncOf(handleClick))
}

func handleClick(this js.Value, inputs []js.Value) interface{} {
println("Button clicked!")
return nil
}

И в HTML после загрузки модуля WebAssembly:

<button onclick="window.handleClick()">Click me</button>

2. Интеграция с веб-страницами

  • Инициализация Wasm:

Привязываем скрипт wasm_exec.js, затем инстанцируем модуль Wasm:

<script src="wasm_exec.js"></script>
<script>
const go = new Go();
WebAssembly.instantiateStreaming(fetch("play.wasm"), go.importObject).then((result) => {
go.run(result.instance);
});
</script>
  • Взаимодействие с DOM:

Доступ к веб-элементам и их изменение принципиально важны. Вот, например, изменение содержимого элемента абзаца из Go:

func updateDOMContent() {
document := js.Global().Get("document")
element := document.Call("getElementById", "myParagraph")
element.Set("innerText", "Updated content from Go!")
}

3. Увеличение эффективности и скорости

  • Горутины Go в браузере:

Вот пример операций выборки данных, выполняемых одновременно без блокировки основного потока:

func fetchData(url string, ch chan string) {
// Имитируем выборку данных.
ch <- "Data from " + url
}

func main() {
ch := make(chan string)
go fetchData("<https://api.example1.com>", ch)
go fetchData("<https://api.example2.com>", ch)

data1 := <-ch
data2 := <-ch
println(data1, data2)
}

Перемещением по Go и WebAssembly демонстрируется мощное объединение параллельной обработки Go с быстрым выполнением WASM на стороне клиента.

Теперь рассмотрим применение этих технологических преимуществ в реальной масштабируемой системе авторизации Permify.

Подробный разбор кода WASM в Permify

Переходим к самой интеграции WebAssembly, изучим ключевые сегменты WASM-кода на Go.

1. Настройка среды Go-WASM

Готовим и указываем код на Go, компилируемый для среды выполнения WebAssembly:

// go:build wasm
// +build wasm

Эти строки  —  указания компилятору Go на то, что следующий код предназначен для среды выполнения WebAssembly, а именно:

  • //go:build wasm  —  ограничение сборки, которым обеспечивается компиляция кода только в цели WASM и согласно современному синтаксису.
  • // +build wasm  —  аналогичное ограничение со старым синтаксисом для совместимости с прошлыми версиями Go.

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

2. Объединение JavaScript и Go с помощью функции «run»

package main

import (
"context"
"encoding/json"
"syscall/js"

"google.golang.org/protobuf/encoding/protojson"

"github.com/Permify/permify/pkg/development"
)

var dev *development.Development

func run() js.Func {
// Функцией «run» возвращается новая функция JavaScript,
// в которую оборачивается функция Go.
return js.FuncOf(func(this js.Value, args []js.Value) interface{} {

// «t» понадобится для хранения демаршалированных данных JSON.
// Тип «interface{}» пустой, значит, может содержать значение любого типа.
var t interface{}

// Демаршалируем JSON из аргумента функции JavaScript «args[0]» в структуру данных Go «map».
// В «args[0].String()» от аргумента JavaScript получается строка JSON,
// преобразуемая затем в байты и демаршалируемая — с выполнением парсинга — в карту «t».
err := json.Unmarshal([]byte(args[0].String()), &t)

// Если при демаршалировании — парсинге — JSON возникает ошибка,
// возвращается массив с сообщением об ошибке «invalid JSON» в JavaScript.
if err != nil {
return js.ValueOf([]interface{}{"invalid JSON"})
}

// Попытка подтвердить, что JSON с выполненным парсингом, то есть «t», — это карта со строковыми ключами.
// На этом этапе демаршалированному JSON обеспечивается ожидаемый тип «map», то есть карта.
input, ok := t.(map[string]interface{})

// Если утверждение ложно — это и означает «ok», —
// возвращается массив с сообщением об ошибке «invalid JSON» в JavaScript.
if !ok {
return js.ValueOf([]interface{}{"invalid JSON"})
}

// Запускаем основную логику приложения с входными данными, над которыми выполнен парсинг.
// Предполагается, что «input» каким-то образом обрабатывается этим «dev.Run» и возвращаются любые ошибки, обнаруженные во время этого процесса.
errors := dev.Run(context.Background(), input)

// Если ошибок нет и длина среза «errors» равна 0,
// в JavaScript возвращается пустой массив. Это означает, что запуск прошел успешно, без ошибок.
if len(errors) == 0 {
return js.ValueOf([]interface{}{})
}

// Если имеются ошибки, каждая ошибка в срезе «errors» маршалируется, преобразуется в строку JSON.
// «vs» — это срез, в котором сохранится каждая из этих ошибок — строк JSON.
vs := make([]interface{}, 0, len(errors))

// Перебираем каждую ошибку в срезе «errors».
for _, r := range errors {
// Преобразуем ошибку «r» в строку JSON и сохраняем ее в «result».
// Если во время этого маршалирования возникает ошибка, в JavaScript возвращается массив с тем сообщением об ошибке.
result, err := json.Marshal(r)
if err != nil {
return js.ValueOf([]interface{}{err.Error()})
}
// Добавляем ошибку — строку JSON в срез «vs».
vs = append(vs, string(result))
}

// Возвращаем в JavaScript срез «vs» со всеми ошибками — строками JSON.
return js.ValueOf(vs)
})
}

В Permify функция run  —  краеугольный камень, ею выполняется важнейшая операция объединения входных данных JavaScript и возможностей обработки на Go, организуется обмен данными в реальном времени в формате JSON, чем обеспечивается плавный и мгновенный доступ к основному функционалу Permify через интерфейс браузера.

Подробнее о run:

  • Обмен данными JSON: преобразованием входных данных JavaScript в используемый на Go формат этой функцией демаршалируется JSON  —  с передачей данных между JS и Go  —  и благодаря надежным возможностям обработки Go обеспечивается беспроблемное манипулирование входными данными браузера.
  • Обработка ошибок: обеспечивая ясность для пользователя, его осведомленность и удобство взаимодействия, во время парсинга и обработки данных проводится тщательная проверка ошибок с возвращением соответствующих сообщений об ошибках обратно в среду JavaScript.
  • Контекстная обработка: с помощью dev.Run функцией в определенном контексте обрабатываются входные данные, над которыми выполнен парсинг. Чтобы обеспечить стабильный контроль данных и обратную связь с пользователем, при обработке потенциальных ошибок осуществляется управление логикой приложения.
  • Двунаправленный обмен данными: поскольку ошибки маршалируются обратно в формат JSON и возвращаются в JavaScript, функцией обеспечивается двунаправленный поток данных с сохранением обеих сред в синхронизированной гармонии.

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

Такое упрощение взаимодействия позволяет не только повысить удовлетворенность пользователей, но и применить в среде Permify соответствующие сильные стороны JavaScript и Go.

3. Выполнение и инициализация «main»

// Продолжаем рассмотренный выше код...

func main() {
// Инстанцируем канал «ch» без буфера, это точка синхронизации для горутины.
ch := make(chan struct{}, 0)

// Создаем новый экземпляр «Container» из пакета «development» и присваиваем его глобальной переменной «dev».
dev = development.NewContainer()

// Присоединяем определенную ранее функцию «run» к глобальному объекту JavaScript,
// делая ее вызываемой из среды JavaScript.
js.Global().Set("run", run())

// Чтобы остановить горутину «main» и предотвратить завершение программы, используем выражение приема канала.
<-ch
}
  1. ch := make(chan struct{}, 0): для координации активности горутин  —  параллельных потоков на Go  —  создается канал синхронизации.
  2. dev = development.NewContainer(): из пакета разработки инициализируется новый экземпляр контейнера и присваивается переменной dev.
  3. js.Global().Set("run", run()): функция run предоставляется глобальному контексту JavaScript для вызова функций Go.
  4. <-ch: горутина main на неопределенное время останавливается, обеспечивая, что модуль Go WebAssembly остается активным в среде JavaScript.

В итоге кодом устанавливается среда Go, запускаемая в WebAssembly с конкретной функциональностью, то есть функцией run, на стороне JavaScript, и сохраняется активной и доступной для вызовов функций из JavaScript.

Встраивание кода Go в модуль WASM

Прежде чем переходить к функционалу Permify, важно разобрать этапы преобразования кода Go в модуль WASM, подготовив его для выполнения в браузере.

Полная кодовая база Go доступна в репозитории GitHub.

1. Компиляция в WASM

Запускаем преобразование приложения Go в бинарный код WASM:

GOOS=js GOARCH=wasm go build -o permify.wasm main.go

Этой командой компилятором Go создается двоичный файл .wasm для сред JavaScript, с источником main.go.

permify.wasm  —  это результат, краткое описание возможностей Go, подготовленное для веб-развертывания.

2. WASM Exec JS

Наряду с бинарным кодом WASM в экосистеме Go имеется незаменимая часть wasm_exec.js. Она важна для инициализации и упрощения модуля WASM в настройке браузера. Этот необходимый скрипт обычно находится внутри установки Go в misc/wasm.

Чтобы долго не искать, мы разместили wasm_exec.js прямо здесь:cp «$(go env GOROOT)/misc/wasm/wasm_exec.js» .

С этими файлами  —  двоичным WASM и JavaScript  —  мы готовы к объединению с фронтендом.

Этапы внедрения Go WASM в приложение React

1. Настройка структуры приложения React

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

loadWasm/

├── index.tsx // Основной компонент React для интегрирования WASM.
├── wasm_exec.js // Этим скриптом Go объединяются WASM и JS.
└── wasmTypes.d.ts // Объявления типов TypeScript для WebAssembly.

Полная структура и специфика каждого файла доступны здесь.

2. Установка объявлений типов

Внутри wasmTypes.d.ts создаются глобальные объявления типов, которые распространяются на интерфейс Window для признания новых методов из WebAssembly:

declare global {
export interface Window {
Go: any;
run: (shape: string) => any[];
}
}
export {};

Так обеспечивается распознавание в TypeScript конструктора Go и метода run при вызове в глобальном объекте window.

3. Подготовка загрузчика WebAssembly

В index.tsx выполняются важные задачи:

  • Импорт зависимостей: сначала импортируются необходимые объявления JS и TypeScript:
import "./wasm_exec.js";
import "./wasmTypes.d.ts";
  • Инициализация WebAssembly: весь процесс осуществляется асинхронной функцией loadWasm:
async function loadWasm(): Promise<void> {
const goWasm = new window.Go();
const result = await WebAssembly.instantiateStreaming(
fetch("play.wasm"),
goWasm.importObject
);
goWasm.run(result.instance);
}

Здесь среда Go WASM инициализируется с помощью new window.Go(). В WebAssembly.instantiateStreaming модуль WASM извлекается, компилируется, и создается экземпляр. А активируется модуль WASM в goWasm.run.

  • Компонент React с пользовательским интерфейсом загрузчика: компонентом LoadWasm применяется хук useEffect для асинхронной загрузки WebAssembly при монтировании компонента:
export const LoadWasm: React.FC<React.PropsWithChildren<{}>> = (props) => {
const [isLoading, setIsLoading] = React.useState(true);

useEffect(() => {
loadWasm().then(() => {
setIsLoading(false);
});
}, []);

if (isLoading) {
return (
<div className="wasm-loader-background h-screen">
<div className="center-of-screen">
<SVG src={toAbsoluteUrl("/media/svg/rocket.svg")} />
</div>
</div>
);
} else {
return <React.Fragment>{props.children}</React.Fragment>;
}
};

Во время загрузки отображается ракета в SVG-формате. Это важная обратная связь: пользователи понимают, что инициализация продолжается. Как только загрузка завершится, отобразятся дочерние компоненты или содержимое.

4. Вызов функций WebAssembly

Метод Go WASM run вызывается так:

function Run(shape) {
return new Promise((resolve) => {
let res = window.run(shape);
resolve(res);
});
}

По сути, эта функция  —  мост, по которому фронтенд React взаимодействует с логикой бэкенда Go, инкапсулированной в WASM.

5. Реализация кнопки запуска в React

Кнопка, при нажатии которой запускается функция WebAssembly, интегрируется так:

1. Создание компонента кнопки

Сначала создаем простой компонент React с кнопкой:

import React from "react";

type RunButtonProps = {
shape: string;
onResult: (result: any[]) => void;
};

function RunButton({ shape, onResult }: RunButtonProps) {
const handleClick = async () => {
let result = await Run(shape);
onResult(result);
};

return <button onClick={handleClick}>Run WebAssembly</button>;
}

В этом коде компонентом RunButton принимается два свойства:

  • shape: аргумент формы для передачи в функцию WebAssembly run.
  • onResult: функция обратного вызова, получающая результат функции WebAssembly и обновляющая состояние или отображающая результат в пользовательском интерфейсе.

2. Интеграция кнопки в основной компонент

Теперь интегрируем RunButton:

import React, { useState } from "react";
import RunButton from "./path_to_RunButton_component"; // Заменяем на фактический путь

function App() {
const [result, setResult] = useState<any[]>([]);

// Определяем содержимое «shape»
const shapeContent = {
schema: `|-
entity user {}

entity account {
relation owner @user
relation following @user
relation follower @user

attribute public boolean
action view = (owner or follower) or public
}

entity post {
relation account @account

attribute restricted boolean

action view = account.view

action comment = account.following not restricted
action like = account.following not restricted
}`,
relationships: [
"account:1#owner@user:kevin",
"account:2#owner@user:george",
"account:1#following@user:george",
"account:2#follower@user:kevin",
"post:1#account@account:1",
"post:2#account@account:2",
],
attributes: [
"account:1$public|boolean:true",
"account:2$public|boolean:false",
"post:1$restricted|boolean:false",
"post:2$restricted|boolean:true",
],
scenarios: [
{
name: "Account Viewing Permissions",
description:
"Evaluate account viewing permissions for 'kevin' and 'george'.",
checks: [
{
entity: "account:1",
subject: "user:kevin",
assertions: {
view: true,
},
},
],
},
],
};

return (
<div>
<RunButton shape={JSON.stringify(shapeContent)} onResult={setResult} />
<div>
Results:
<ul>
{result.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
</div>
</div>
);
}

В этом примере App  —  это компонент с кнопкой RunButton. Когда кнопка нажимается, результат функции WebAssembly отображается в списке под кнопкой.

Заключение

Мы развернули интеграцию WebAssembly с Go для усовершенствованной веб-разработки и оптимального пользовательского взаимодействия в браузерах, настроили среду Go, преобразовали код Go в WebAssembly и выполнили его в веб-контексте.

В итоге получили интерактивную платформу play.permify.co  —  не только пример, но и маяк, высвечивающий конкретные, мощные возможности, достигаемые сочетанием этих технологических областей.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Ege Aytin: WebAssembly with Go: Taking Web Apps to the Next Level

Предыдущая статьяЧто ищут работодатели в вашем UX/UI портфолио
Следующая статьяТоп-5 способов стилизации React-приложений в 2024 году