Как сделать поиск пользователей по GitHub на WebAssembly

в 9:25, , рубрики: Go, javascript, webassembly, Программирование

image

Всем привет! 24 августа 2018 вышла версия Go 1.11 с экспериментальной поддержкой WebAssembly (Wasm). Технология интересная и у меня сразу возникло желание поэкспериментировать. Написать "Hello World" скучно (и он кстати есть в документации), тем более тренд прошедшего лета статьи из серии "Как сделать поиск пользователей по GitHub <вставить свой любимый JS-фреймворк>"

Итак, данная статья это продолжение цикла. Вот предыдущие главы:
Как сделать поиск пользователей по GitHub используя React + RxJS 6 + Recompose
Как сделать поиск пользователей по GitHub без React + RxJS 6 + Recompose
Как сделать поиск пользователей по GitHub, используя Vue
Как сделать поиск пользователей по Github используя Angular
Как сделать поиск пользователей по Github используя VanillaJS

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

image

Внимание! Данная статья не призывает бросать JS и переписывать все web-приложения на Go, а только показывает возможности работы с JS API из WASM.

Установка

Прежде всего стоит обновиться до последней версии Go (на момент написания статьи 1.11)
Копируем два файла с поддержкой HTML & JS в свой проект

cp $(go env GOROOT)/misc/wasm/wasm_exec.{html,js} .

Hello World и базовую настройку HTTP сервера я пропущу, подробности можно прочитать на Go Wiki

Взаимодействие с DOM

Осуществляется через пакет syscall/js
В статье будут использоваться следующие функции и методы для управления JavaScript:

js.Value // тип, представляющий значение JavaScript    
func Global() Value // возвращает глобальный объект JavaScript, в браузере это `window`    
func (v Value) Call(m string, args ...interface{}) Value // вызывает метод объекта как функцию    
func (v Value) Get(p string) Value // возвращают значения полей объекта    
func (v Value) Set(p string, x interface{}) // устанавливает значения полей объекта  

Все методы имеют аналоги в JS, и станут понятнее по ходу статьи.

Поле ввода

Для начала создадим поле, в которое пользователь будет вводить логин для поиска, это будет классический тег input с placeholder.
В дальнейшем нам потребуется создание еще одного тега, поэтому мы сразу напишем конструктор HTML-элементов.

type Element struct {
    tag    string
    params map[string]string
}

Структура HTML-элемента содержит название тега tag (например input, div и т.д) и дополнительные параметры params (например: placeholder, id и т.д)

Сам конструктор выглядит так:

func (el *Element) createEl() js.Value {
    e := js.Global().Get("document").Call("createElement", el.tag)
    for attr, value := range el.params {
        e.Set(attr, value)
    }
    return e
}

e := js.Global().Get("document").Call("createElement", el.tag) это аналог var e = document.createElement(tag) в JS
e.Set(attr, value) аналог e.setAttribute(attr, value)
Данный метод только создает элементы, но не добавляет их на страницу.

Чтобы добавить элемент на страницу необходимо определить место его вставки. В нашем случае будем добавлять в div с id="box"wasm_exec.html строка <div id="box"></div>)

type Box struct {
    el js.Value
}

box := Box{
    el: js.Global().Get("document").Call("getElementById", "box"),
}

В box.el храниться ссылка на основной контейнер нашего приложения.
js.Global().Get("document").Call("getElementById", "box") в JS это document.getElementById('box')

Сам метод создания input-элемента:

func (b *Box) createInputBox() js.Value {
    // Конструктор
    el := Element{
        tag: "input",
        params: map[string]string{
            "placeholder": "GitHub username",
        },
    }
    // Создание элемента
    input := el.createEl()
    // Вывод на страницу в div с id="box"
    b.el.Call("appendChild", input)
    return input
}

Данный метод возвращает ссылку на созданный элемент

<input placeholder="GitHub username">

Контейнер вывода результатов

Полученные результаты куда-то надо выводить, давайте добавим div c id="search_result" по аналогии с input

func (b *Box) createResultBox() js.Value {
    el := Element{
        tag: "div",
        params: map[string]string{
            "id": "search_result",
        },
    }
    div := el.createEl()
    b.el.Call("appendChild", div)
    return div
}

Создается контейнер и возвращается ссылка на элемент

<div id="search_result"></div>

Настало время определить структуру для всего нашего Web-приложения

type App struct {
    inputBox     js.Value
    resultBox    js.Value
}

a := App{
    inputBox:     box.createInputBox(),
    resultBox:    box.createResultBox(),
}

inputBox и resultBox — ссылки на ранее созданные элементы <input placeholder="GitHub username"> и <div id="search_result"></div> соответственно

Отлично! Мы добавили два элемента на страницу. Теперь пользователь может вводить данные в input и смотреть на пустой div, уже неплохо, но пока наше приложение не интерактивно. Давайте исправим это.

Событие ввода

Нам необходимо отслеживать когда пользователь вводит логин в input и получать эти данные, для этого подписываемся на событие keyup, сделать это очень просто

func (a *App) userHandler() {
    a.input.Call("addEventListener", "keyup", js.NewCallback(func(args []js.Value) {
        e := args[0]
        user := e.Get("target").Get("value").String()
        println(user)
    }))
}

e.Get("target").Get("value") — получение значения input, аналог event.target.value в JS, println(user) обычный console.log(user)

Таким образом мы консолим все действия пользователя по вводу логина в input.
Теперь у нас есть данные, с которыми мы можем формировать запрос к GitHub API

Запросы к GitHub API

Запрашивать мы будем информацию по зарегистрированным пользователям: get-запрос на https://api.github.com/users/:username
Но сперва определим структуру ответа GitHub API

type Search struct {
    Response Response
    Result   Result
}

type Response struct {
    Status string
}`

`type Result struct {
    Login            string `json:"login"`
    ID               int    `json:"id"`
    Message          string `json:"message"`
    DocumentationURL string `json:"documentation_url"`
    AvatarURL        string `json:"avatar_url"`
    Name             string `json:"name"`
    PublicRepos      int    `json:"public_repos"`
    PublicGists      int    `json:"public_gists"`
    Followers        int    `json:"followers"`
}

Response — содержит ответ сервера, для нашего приложения нужен только статус Status string — он потребуется для вывода на странице ошибки.
Result — тело ответа в сокращенном виде, только необходимые поля.

Сами запросы формируются через стандартный пакет net/http

func (a *App) getUserCard(user string) {
    resp, err := http.Get(ApiGitHub + "/users/" + user)
    if err != nil {
        log.Fatal(err)
    }
    defer resp.Body.Close()
    b, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        log.Fatal(err)
    }
    var search Search
    json.Unmarshal(b, &search.Result)

    search.Response.Status = resp.Status
    a.search <- search
}

Теперь, когда у нас есть метод получение информации о пользователе с GitHub API давайте модифицируем userHandler() и попутно расширим структуру Web-приложения App добавив туда канал chan Search для передачи данных из горутины getUserCard()

type App struct {
    inputBox     js.Value
    resultBox    js.Value
    search    chan Search
}

a := App{
    inputBox:     box.createInputBox(),
    resultBox:    box.createResultBox(),
    search:    make(chan Search),
}

func (a *App) userHandler() {
    a.input.Call("addEventListener", "keyup", js.NewCallback(func(args []js.Value) {
        e := args[0]
        user := e.Get("target").Get("value").String()
        go a.getUserCard(user)
    }))
}

Шаблонизатор

Прекрасно! Мы получили информацию о пользователе и у нас есть контейнер для вставки. Теперь нам нужен HTML-шаблон и разумеется какой-нибудь простой шаблонизатор. В нашем приложении будем использовать mustache — это популярный шаблонизатор с простой логикой.
Установка: go get github.com/cbroglie/mustache

Сам HTML-шаблон user.mustache находится в директории tmpl нашего приложения и выглядит следующим образом:

<div class="github-card user-card">
    <div class="header User" />
    <a class="avatar" href="https://github.com/{{Result.Login}}">
        <img src="{{Result.AvatarURL}}&s=80" alt="{{Result.Name}}" />
    </a>
    <div class="content">
        <h1>{{Result.Name}}</h1>
        <ul class="status">
            <li>
                <a href="https://github.com/{{Result.Login}}?tab=repositories">
                    <strong>{{Result.PublicRepos}}</strong>Repos
                </a>
            </li>
            <li>
                <a href="https://gist.github.com/{{Result.Login}}">
                    <strong>{{Result.PublicGists}}</strong>Gists
                </a>
            </li>
            <li>
                <a href="https://github.com/{{Result.Login}}/followers">
                    <strong>{{Result.Followers}}</strong>Followers
                </a>
            </li>
        </ul>
    </div>
</div>

Все стили прописаны в web/style.css

Следующий шаг — получить шаблон в виде строки и прокинуть его в наше приложение. Для этого опять расширяем структуру App добавив туда нужные поля.

type App struct {
    inputBox     js.Value
    resultBox    js.Value
    userTMPL  string
    errorTMPL string
    search    chan Search
}

a := App{
  inputBox:     box.createInputBox(),
  resultBox:    box.createResultBox(),
  userTMPL:  getTMPL("user.mustache"),
  errorTMPL: getTMPL("error.mustache"),
  search:    make(chan Search),
}

userTMPL — шаблон вывода информации о пользователе user.mustache. errorTMPL — шаблон обработки ошибок error.mustache

Для получения шаблона из приложения используем обычный Get-запрос

func getTMPL(name string) string {
    resp, err := http.Get("tmpl/" + name)
    if err != nil {
        log.Fatal(err)
    }
    defer resp.Body.Close()
    b, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        log.Fatal(err)
    }
    return string(b)
}

Шаблон есть, данные есть теперь попробуем отрендерить HTML-представление

func (a *App) listResults() {
    var tmpl string
    for {
        search := <-a.search
        switch search.Result.ID {
        case 0:
            // TMPL for Error page
            tmpl = a.errorTMPL
        default:
            tmpl = a.userTMPL
        }
        data, _ := mustache.Render(tmpl, search)
        // Output the resultBox to a page
        a.resultBox.Set("innerHTML", data)
    }
}

Это горутина, которая ожидает данные из канала <-a.search и рендерит HTML. Условно считаем, что если в данных из GitHub API есть ID-пользователя search.Result.ID то результат корректный, в противном случае возвращаем страницу ошибки.
data, _ := mustache.Render(tmpl, search) — рендерит готовый HTML, а a.resultBox.Set("innerHTML", data) выводит HTML на страницу

Debounce

Работает! Но есть одна проблема — если посмотреть в консоль мы увидим, что на каждое нажатие клавиши отправляется запрос к GitHub API, при таком раскладе мы быстро упремся в лимиты.

image

Решение — Debounce. Это функция, которая откладывает вызов другой функции на заданное время. То есть когда пользователь нажимает кнопку мы должны отложить запрос к GitHub API на X миллисекунд, при этом если срабатывает еще одно событие нажатие кнопки — запрос откладывается еще на X миллисекунд.

Debounce в Go реализуется с помощью каналов. Рабочий вариант взял из статьи debounce function for golang

func debounce(interval time.Duration, input chan string, cb func(arg string)) {
    var item string
    timer := time.NewTimer(interval)
    for {
        select {
        case item = <-input:
            timer.Reset(interval)
        case <-timer.C:
            if item != "" {
                cb(item)
            }
        }
    }
}

Перепишем метод (a *App) userHandler() с учетом Debounce:

func (a *App) userHandler() {
    spammyChan := make(chan string, 10)
    go debounce(1000*time.Millisecond, spammyChan, func(arg string) {
        // Get Data with github api
        go a.getUserCard(arg)
    })
    a.inputBox.Call("addEventListener", "keyup", js.NewCallback(func(args []js.Value) {
        e := args[0]
        user := e.Get("target").Get("value").String()
        spammyChan <- user
        println(user)
    }))
}

Событие keyup срабатывает всегда, но запросы отправляются только через 1000ms после завершения последнего события.

Полируем

И напоследок немного улучшим наш UX, добавив индикатор загрузки "Loading..." и очистку контейнера в случае пустого input

func (a *App) loadingResults() {
    a.resultBox.Set("innerHTML", "<b>Loading...</b>")
}

func (a *App) clearResults() {
    a.resultBox.Set("innerHTML", "")
}

Конечный вариант метода (a *App) userHandler() выглядит так:

func (a *App) userHandler() {
    spammyChan := make(chan string, 10)
    go debounce(1000*time.Millisecond, spammyChan, func(arg string) {
        // Get Data with github api
        go a.getUserCard(arg)
    })
    a.inputBox.Call("addEventListener", "keyup", js.NewCallback(func(args []js.Value) {
        // Placeholder "Loading..."
        a.loadingResults()

        e := args[0]
        // Get the value of an element
        user := e.Get("target").Get("value").String()
        // Clear the results block
        if user == "" {
            a.clearResults()
        }
        spammyChan <- user
        println(user)
    }))
}

Готово! Теперь у нас есть полноценный поиск пользователей по GitHub без единой строчки на JS. По моему это круто.

Вывод

Написать Web-приложение работающее с DOM на wasm возможно, но стоит ли это делать — вопрос. Во-первых, пока не понятно как тестировать код, во-вторых, в некоторых браузерах работает не стабильно (например в Хроме падало с ошибкой через раз, в FF с этим лучше), в-третьих вся работа с DOM осуществляется через JS API, что должно сказываться на производительности (правда замеры не делал, поэтому все субъективно)

Кстати большинство примеров это работа с графикой в canvas и выволнение тяжелых вычислений, скорее всего wasm проектировался именно для этих задач. Хотя… время покажет.

Сборка и запуск

Клонируем репозиторий

cd work_dir
git clone https://github.com/maxchagin/gowasm-example ./gowasm-example
cd gowasm-example

Сборка

GOARCH=wasm GOOS=js go build -o web/test.wasm main.go

Запуск сервера

go run server.go

Просмотр

http://localhost:8080/web/wasm_exec.html

Исходники на github
Интерактивное демо на lovefrontend.ru

Автор: Maxchagin

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js