Первоначально опубликовано на www.krakend.io Даниэлем Лопесом.

KrakenD - это API-шлюз, написанный на Go, который использует один файл конфигурации для определения всего своего поведения. Поскольку файл конфигурации может быть сложным, KrakenDesigner представляет собой пользовательский интерфейс на основе javascript для редактирования этого файла, и нам не хватало возможности воспроизводить непосредственно на javascript существующие каналы шлюза, чтобы пользователи могли запускать ручные тесты над конфигурацией редактирования. .

В этом посте мы собираемся объяснить, как мы включили компоненты инфраструктуры KrakenD в файл .wasm и как мы интегрировали его в наш существующий SPA. Это наш код, работающий на javascript.

Давайте начнем!

Golang 1.11 и WebAssembly

В течение последних 3 лет популярность WebAssembly (и его обещания заменить JS) росла головокружительной скоростью.

На домашней странице проекта:

WebAssembly (сокращенно Wasm) - это двоичный формат инструкций для виртуальной машины на основе стека. Wasm разработан как переносимая цель для компиляции языков высокого уровня, таких как C / C ++ / Rust, что позволяет развертывать клиентские и серверные приложения в Интернете.

Последний выпуск языка golang поставляется с экспериментальным портом на WebAssembly.

Быстрая настройка среды

В этом проекте есть две зависимости, убедитесь, что обе правильно установлены в вашей среде.

Чтобы воспроизвести проделанные нами шаги, давайте начнем с чистого проекта Go. Скопируйте JS и HTML, уже включенные в ваш дистрибутив golang, в свой новый проект.

$ cd myproject/ 
$ cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" . 
$ cp "$(go env GOROOT)/misc/wasm/wasm_exec.html" index.html

Теперь добавьте очень простой Makefile, который будет загружать все зависимости и генерировать .wasm файл:

all: prepare build
prepare:
    dep ensure -v
build:
    GOARCH=wasm GOOS=js go build -o test.wasm main.go

Запуск make - это все, что нужно для создания test.wasm файла, но чтобы ускорить среду разработки, мы также использовали два удобных инструмента:

  • Reflex отслеживает изменения в main.go файле и автоматически перекомпилирует.
  • Goexec помогает нам запускать однострочники Go, которые мы используем для запуска локального веб-сервера.

Откройте новый терминал, перейдите в свой проект и запустите задачу reflex:

$ reflex -r '^main.go$' make build

Мы создаем main.go контент за минуту. Теперь при любых изменениях, происходящих в файле main.go, каждый раз выполняется make build.

Во втором терминале перейдите в свой проект и создайте базовый локальный HTTP-сервер для обслуживания файлов index.html, wasm_exec.js и test.wasm

$ goexec 'http.ListenAndServe(":8080", http.FileServer(http.Dir(".")))'

Пришло время проверить, все ли работает правильно. Создайте файл с именем main.go со следующим содержимым:

package main
import "fmt"
func main() {
    fmt.Println("Hello, WebAssembly!")
}

Откройте браузер по адресу http: // localhost: 8080 и нажмите кнопку run. Вы должны найти сообщение Hello, WebAssembly! в консоли.

Использование go libs

Когда наша среда готова, мы можем начать проникать внутрь. Итак, давайте создадим минимально необходимый код, чтобы предоставить функцию golang в пространство JS под названием parse.

package main
import (
   "fmt"
   "syscall/js"
)
func parse(i []js.Value) {
    fmt.Println(i)
}
func main() {
    fmt.Println("WASM Go Initialized")
    js.Global().Set("parse", js.NewCallback(parse))
    select {}
}

Вы можете протестировать это в консоли (после нажатия на кнопку «запустить»):

WASM Go Initialized
> parse("aaaa", {a:42,b:true}, ["one","two"]); [aaaa [object Object] one,two]

Поскольку мы собираемся использовать внешние зависимости, мы будем использовать ту же систему, что и во фреймворке KrakenD: dep. При использовании версий, заблокированных во фреймворке в нашем WASM, возникают некоторые проблемы, поэтому вы можете создать файл с именем Gopkg.toml со следующим содержимым:

[[override]]
    name = "github.com/mattn/go-isatty"
    revision = "3fb116b820352b7f0c281308a4d6250c22d94e27"
[[override]]
    name = "github.com/gin-gonic/gin"
    branch = "master"
[[override]]
    version = "v2.2.1"
    source = "github.com/go-yaml/yaml"
    name = "gopkg.in/yaml.v2"
[[constraint]]
    name = "github.com/devopsfaith/krakend"
    branch = "master"
[prune]
    go-tests = true
    unused-packages = true

С Gopkg.toml в корне вашего проекта подготовьте его, набрав

make prepare

Самый простой способ создать KrakenD - использовать только фреймворк. Давайте изменим функцию parse так, чтобы она выводила результат фактического парсера конфигурации KrakenD:

func parse(i []js.Value) {
    cfg, err := config.NewParserWithFileReader(func(s string) ([]byte, error) {
        return []byte(s), nil
    }).Parse(i[0].String())
    if err != nil {
        fmt.Println("error:", err.Error())
        return
    }
    fmt.Printf("%d endpoints parsed:\n", len(cfg.Endpoints))
    fmt.Printf("%+v\n", cfg)
}

Все идет нормально. У нас уже есть работающая среда и четкий способ портировать существующий код golang в JS.

Создание эфемерного экземпляра KrakenD

Прежде чем углубляться в привязки и переводы функций, необходимые для использования библиотек golang из пространства JS, нам понадобится некоторая оболочка над эфемерными экземплярами KrakenD, чтобы мы могли использовать их без запуска HTTP-сервера внутри браузера .

Все router реализации фреймворка принимают RunServerFunc и делегируют ему настройку сервера для раскрытия http.Handler, содержащего экземпляр KrakenD. Итак, если мы хотим избежать создания экземпляра сервера и захватить обработчик, мы можем создать собственную реализацию client.RunServer. Как видите, мы также улавливаем cancel функцию контекста экземпляра, поэтому оболочка может предложить способ закрыть встроенный сервис KrakenD.

type LocalServer struct {
    close   func()
    handler func(rw http.ResponseWriter, req *http.Request)
}
func newServer(cfg string) (*LocalServer, error) {
    // parse the received config string
    serviceConfig, err := config.NewParserWithFileReader(func(s string) ([]byte, error) {
        return []byte(s), nil
    }).Parse(cfg)
    if err != nil {
        return nil, err
    }
    serviceConfig.Debug = true
// instantiate the framework logger
    logger, err := logging.NewLogger("DEBUG", os.Stdout, "[KRAKEND]")
    if err != nil {
        return nil, err
    }
// create a context for the ephimeral instance
    ctx, cancel := context.WithCancel(context.Background())
s := &LocalServer{
        // capture the context cancel function
        close:   cancel,
        // empty handler to override at the RunServer func
        handler: func(rw http.ResponseWriter, req *http.Request) {},
    }
routerFactory := krakendgin.NewFactory(krakendgin.Config{
        Engine:         gin.New(),
        ProxyFactory:   proxy.DefaultFactory(logger),
        Logger:         logger,
        HandlerFactory: krakendgin.EndpointHandler,
        // RunServer just captures the handler and waits for a context cancelation
        RunServer: func(ctx context.Context, _ config.ServiceConfig, handler http.Handler) error {
            s.handler = handler.ServeHTTP
            <-ctx.Done()
            return ctx.Err()
        },
    })
// start the service in a goroutine
    go func(ctx context.Context, serviceConfig config.ServiceConfig) {
        routerFactory.NewWithContext(ctx).Run(serviceConfig)
    }(ctx, serviceConfig)
return s, nil
}

Когда LocalServer готов, мы можем создать простейшего потребителя / клиента с некоторой помощью пакета httptest:

type Client struct {
    Server *LocalServer
}
func (c Client) Do(req *http.Request) *http.Response {
    req.Header.Add("js.fetch:credentials", "omit")
    rw := httptest.NewRecorder()
    c.Server.handler(rw, req)
    return rw.Result()
}
func (c *Client) Close() {
    c.Server.close()
}

Обратите внимание, что мы добавляем заголовок js.fetch:credentials, поэтому файлы cookie не отправляются вместе с запросами. Это особенность адаптера golang функции JS fetch. Комментарии к исходному коду stdlib golang показывают возможные значения.

// jsFetchMode is a Request.Header map key that, if present,
// signals that the map entry is actually an option to the Fetch API mode setting.
// Valid values are: "cors", "no-cors", "same-origin", "navigate"
// The default is "same-origin".
//
// Reference: https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters
const jsFetchMode = "js.fetch:mode"
// jsFetchCreds is a Request.Header map key that, if present,
// signals that the map entry is actually an option to the Fetch API credentials setting.
// Valid values are: "omit", "same-origin", "include"
// The default is "same-origin".
//
// Reference: https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters
const jsFetchCreds = "js.fetch:credentials"

И это все, что нам нужно, чтобы встроить экземпляр KrakenD в простой клиент. В качестве сноски, попытка избежать проверки браузера CORS доставит вам удовольствие от встречи со слоем CORB.

Добавление обратных вызовов

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

Помня об этом важном моменте, мы можем определить единственную функцию, которую мы добавим в DOM: parse.

func parse(i []js.Value) {
    if len(i) < 2 {
        println("not enough args")
        return
    }
if i[1].Type() != js.TypeFunction {
        println("arg 1 should be a function")
        return
    }
logger := func(msg string) {
        fmt.Println(msg)
    }
    if len(i) > 2 {
        if i[2].Type() == js.TypeFunction {
            logger = func(msg string) {
                i[2].Invoke(msg)
            }
        }
    }
client, err := newJSClient(i[0].String(), logger)
    if err != nil {
        logger(err.Error())
        return
    }
i[1].Invoke(client.Value())
}

Как видите, эта функция определяет регистратор, создает JS-клиент шлюза KrakenD и вызывает обратный вызов. Регистратор по умолчанию основан на функции fmt.Println, но parse также принимает третий параметр в качестве регистратора ошибок.

Функция newJSClient создает адаптер JS поверх уже определенного Client.

func newJSClient(cfg string, logger func(string)) (*JSClient, error) {
    server, err := newServer(cfg)
    if err != nil {
        return nil, err
    }
return &JSClient{
        client: &Client{server},
        logger: logger,
    }, nil
}
type JSClient struct {
    client *Client
    logger func(string)
}

Структура JSClient имеет единственный экспортированный метод Value, поэтому она может генерировать объект JS с привязками к методам Close и Do, предоставляемым Client.

func (j *JSClient) Value() js.Value {
    opt := js.Global().Get("Object").New()
opt.Set("close", js.NewCallback(j.close))
    opt.Set("test", js.NewCallback(j.test))
return opt
}
func (j *JSClient) close(_ []js.Value) {
    j.client.Close()
}

Самая важная часть JSClient - метод test, в котором запрос создается и отправляется в независимой горутине. После синтаксического анализа ответа в JS-объект горутина выполняет внедренный обратный вызов и завершает работу.

func (j *JSClient) test(i []js.Value) {
    // check if there are enough arguments
    if len(i) < 5 {
        j.logger("the test function requires at least 5 arguments: method, path, body, headers and a callback")
        return
    }
    // inject the body if present
    var reqBody io.Reader
    if b := i[2].String(); b != "" {
        reqBody = bytes.NewBufferString(b)
    }
    // create the http request
    req, err := http.NewRequest(i[0].String(), i[1].String(), reqBody)
    if err != nil {
        j.logger(err.Error())
        return
    }
    // parse and add the injected headers
    headers := map[string][]string{}
    if err := json.Unmarshal([]byte(i[3].String()), &headers); err != nil {
        j.logger(err.Error())
        return
    }
    req.Header = headers
go func() {
        // dispatch the request to the client
        resp := j.client.Do(req)
        body, err := ioutil.ReadAll(resp.Body)
        if err != nil {
            j.logger(err.Error())
            return
        }
        resp.Body.Close()
        // parse the response
        headers := js.Global().Get("Object").New()
        for k, v := range resp.Header {
            headers.Set(k, v[0])
        }
jsResp := js.Global().Get("Object").New()
        jsResp.Set("statusCode", resp.StatusCode)
        jsResp.Set("header", headers)
        jsResp.Set("body", string(body))
        // invoke the injected callback
        i[4].Invoke(jsResp)
    }()
}

Тестирование KrakenD на JS

Мы не являемся фронтенд-разработчиками, и наши навыки и знания в этой области очень ограничены. Вот почему мы подумали, что это должна быть самая трудоемкая часть проекта. Однако этого не было!

Нам просто нужно было добавить этот фрагмент в наш шаблон, и мы были готовы вызвать функцию parse, создать клиента и протестировать нашу конфигурацию KrakenD (обратите внимание, что wasm было переименовано, и оба файла были помещены в папку wasm):

<script src='wasm/wasm_exec.js'></script>
<script>
    if (!WebAssembly.instantiateStreaming) { // polyfill
        WebAssembly.instantiateStreaming = async (resp, importObject) => {
            const source = await (await resp).arrayBuffer();
            return await WebAssembly.instantiate(source, importObject);
        };
    }
var krakendClient = {};
    var onKrakendClientReady = function() {};
    var krakendClientReady = new Promise(function(resolve, reject) {
        onKrakendClientReady = resolve;
    });
const go = new Go();
    let mod, inst;
    WebAssembly.instantiateStreaming(fetch("wasm/main.wasm"), go.importObject).then(async (result) => {
        mod = result.module;
        inst = result.instance;
        await go.run(inst);
    });
</script>

Сценарий также определяет некоторые глобальные объекты, чтобы остальная часть кода JS знала, когда код WASM загружен и доступен. Поскольку обратный вызов уже существует, мы можем вызвать его из кода wasm / go:

func main() {
    fmt.Println("WASM Go Initialized")
js.Global().Set("parse", js.NewCallback(parse))
    js.Global().Get("onKrakendClientReady").Invoke()
select {}
}

Последний шаг - ввести в SPA следующие фрагменты кода:

$rootScope.krakendPrepare = function() {
if ( 'undefined' !== typeof krakendClient.close ) {
        krakendClient.close();
        console.log('Resetting KrakenD client');
    }
var cfg = JSON.stringify($rootScope.service);
    krakendClientReady.then(function(){
        parse(cfg, function(c) {
            krakendClient = c;
            console.log("KrakenD Client is ready");
        })
    })
};
$rootScope.runEndpoint = function(requestMethod, requestURL, requestBody, requestHeaders) {
if ( 'undefined' === typeof requestBody ) {
        // GET or HEAD methods
        requestBody = "";
    }
    if ( 'undefined' === typeof requestHeaders || requestHeaders == "" ) {
        // GET or HEAD methods
        requestHeaders = "{}";
    }
krakendClient.test(requestMethod, requestURL, requestBody, requestHeaders, console.log);
};

Отображение возвращаемых значений - это просто вопрос замены console.log на имя функции, ответственной за привязку свойств ответа к полям представления.

Вывод

С очень небольшим дополнительным кодом мы встроили наш основной шлюз KrakenD в браузер! Мы можем сделать то же самое практически с любой существующей кодовой базой golang, если мы будем следовать урокам, полученным в этом эксперименте:

  • поскольку код WASM загружается асинхронно, а основная функция блокируется пустым select, нам нужно уведомить код JS, когда WASM готов (перед тем, как войти в бесконечное ожидание).
  • Функции golang, перенесенные на WASM, ничего не возвращают. Они должны принимать обратные вызовы, если есть что передать / вернуть вызывающему.
  • HTTP-запросы должны выполняться в специальной горутине. Выполнение той же горутины, что и вызывающая программа, приводит к возникновению тупиковых ситуаций.
  • HTTP-запросы GET и HEAD должны иметь нулевое тело.
  • преобразование типов между JS и Go сложно. Ручные преобразования очень подробны.

Вы можете увидеть это в действии на https://designer.krakend.io

Спасибо за чтение! Если вам понравился наш продукт, не забудьте поставить наш проект звездой!