Как подружить Go, WebAssembly и TypeScript
2023.11.29. Теги: Golang, История разработки, Кейс
Недавно в одном из пет-проектов добавил код на Go, скомпилированный в WebAssembly (WASM) для выполнения на стороне клиента. Интерфейс выполнен на Svelte с TypeScript, а на Golang реализуется логика. В результате я получил интересный опыт, которым хочу поделиться: как гармонично подружить между собой Go, WebAssembly и TypeScript.
Содержание
Как экспортировать Go функцию в JavaScript
Создайте отдельную директорию для Go модуля – с файлами go.mod
и main.go
.
Напрямую импортировать функции из Go в JavaScript нельзя. Вместо этого ваш Go код, запущенный в браузере, может создать для JavaScript глобальные функции или любые другие глобальные переменные.
Зарегистрировать для JavaScript глобальную функцию можно так:
package main
import "syscall/js"
func myFunc(this js.Value, args []js.Value) any {
return "hello"
}
func main() {
// создаём в JavaScript глобальную переменную MyFunc
// и присваиваем ей функцию
js.Global().Set("MyFunc", js.FuncOf(myFunc))
// ждём бесконечно, чтобы Go не завершил выполнение и
// функция были доступна
<-make(chan struct{})
}
syscall/js
– пакет из стандартной библиотеки Go. Компилятор Go и среда разработки могут ругаться, что они его не знают. Чтобы этого избежать, в начало main.go
нужно добавить директиву о том, что файл должен компилироваться только в WebAssembly:
//go:build wasm
// +build wasm
package main
import (
"syscall/js"
...
Компиляция Go кода в WebAssembly
Компилировать нужно с переменными окружения GOOS=js
и GOARCH=wasm
. Для удобства можно сделать Makefile
:
build: copy-wasm-exec
GOOS=js GOARCH=wasm go build -o main.wasm
copy-wasm-exec:
cp $(shell go env GOROOT)/misc/wasm/wasm_exec.js .
Команда make build
выполняет компиляцию, а make copy-wasm-exec
– копирует библиотеку wasm_exec.js
из вашей версии Go – она нам понадобится при запуске WebAssembly из JavaScript.
Запуск Go кода в JavaScript
Для выполнения Go кода, скомпилированного в WebAssembly, нужно импортировать wasm_exec.js
:
// $go - алиас для пути к нашей директории с кодом на Go
import '$go/wasm_exec'
Этот файл создаёт класс Go
. Его TypeScript тип:
declare global {
export interface Window {
Go: {
new (): {
run: (inst: WebAssembly.Instance) => Promise<void>
importObject: WebAssembly.Imports
}
}
}
}
Запуск WebAssembly бинарника в браузере производится с помощью связки WebAssembly.instantiateStreaming
, fetch
и импортированного класса Go
:
// переменная wasm будет содержать URL
// нашего скомпилированного WASM бинарника
import wasm from '$go/main.wasm?url'
// функция, загружающая и запускающая наш Go код
export async function load() {
if (!WebAssembly) {
throw new Error('WebAssembly is not supported in your browser')
}
const go = new window.Go()
const result = await WebAssembly.instantiateStreaming(
// загружаем бинарник
fetch(wasm),
go.importObject
)
// запускаем
go.run(result.instance)
// ждём, пока он создаст нужную нам функцию
await until(() => window.MyFunc != undefined)
// возвращаем эту функцию
return window.MyFunc
}
// вспомогательный промис, который ждёт, пока условие f станет true
const until = (f: () => boolean): Promise<void> => {
return new Promise(resolve => {
const intervalCode = setInterval(() => {
if (f()) {
resolve()
clearInterval(intervalCode)
}
}, 10)
})
}
Как создать JavaScript класс через WebAssembly
В JavaScript класс – это функция, которая создаёт объект. Для того, создать такой класс из Go, нужно создать функции для его конструктора и методов. В каждой функции нужно конвертировать типы: в начале – из типа js.Value
в нужный нам, в конце – обратно.
// Обёртка вокруг объекта myObj
type wrapper struct {
obj *mypkg.myObj
}
// Конструктор JavaScript объекта
func NewObj(this js.Value, args []js.Value) any {
// Конвертируем аргументы из типа js.Value в нужные нам
name := args[0].String()
number := args[1].Int()
// Создаём объект
wrap := &wrapper{
obj: mypkg.NewObj(name, number)
}
// Конвертируем наш объект wrapper в
// JavaScript объект, содержащий два метода
return js.ValueOf(map[string]any{
"set": js.FuncOf(wrap.Set),
"get": js.FuncOf(wrap.Get),
})
}
// Метод объекта
func (w *wrapper) Set(this js.Value, args []js.Value) any {
name := args[0].String()
number := args[1].Int()
res, err := w.obj.Set(name, number)
if err != nil {
return js.ValueOf(map[string]any{
"error": err.Error(),
})
}
return js.ValueOf(map[string]any{
"result": res,
})
}
...
Обработка ошибок
В Go коде вы не можете вызвать исключение (throw exception) JavaScript. Если вызвать панику в Go коде, то он завершит свою работу и вы больше не сможете вызвать его функции.
Если ваша функция может завершиться с ошибкой, то можно возвращать объект, который содержит либо поле result
, либо error
. В нотации TypeScript интерфейс выглядит так:
interface Result<T>{
result?: T
error?: string
}
Тогда проверять ошибки можно так же, как это обычно делается в Go:
const { result, error } = myobj.get()
if (error != undefined) {
// обработать ошибку
} else {
// обработать результат
}
Создаём типы для TypeScript
Под всё, что импортируется из Go, стоит создать типы TypeScript:
declare global {
export interface Window {
NewObj: (name: string, num: number) => MyObj
}
}
export interface MyObj {
get() => Result<number>
set(name: string, num: number) => Result<number>
}
Всё вместе
Посмотреть пример того, как это всё сочетается, можно на GitHub: