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

  • Tutorial

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:


// тип, представляющий значение JavaScript 
js.Value     
// возвращает глобальный объект JavaScript, в браузере это `window`
func Global() Value    
// вызывает метод объекта как функцию 
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

Ads
AdBlock has stolen the banner, but banners are not teeth — they will be back

More

Comments 18

    0
    Ну вот, javascript зажимают со всех сторон :)
      0
      Не зажимают, а расширяют. Правда производительность WebAsm пока под вопросом. Плюс пока еще частое обращение к JS API…
        0
        Почему под вопросом если WASM проектировался как с учётом более быстрого времени выполнения, так и с учетом более быстрой доставки на клиент?

        Из свежего, например, вот годная статья. Из неё же по результатам:
        The WebAssembly binary is in average 86 times faster than the actual Javascript implementation. The median of the speedup is 98.
          0
          JavaScript настолько брутально заоптимизирован в современных браузерах, что в случае изолированного кода для бенчмарков, wasm ещё и проигрывает нередко.
            +1
            Есть ожидания, а есть реальность. Я просто оставлю это здесь js vs webasm
              0
              Ну черт его знает… Прогнал все бенчи. Примерно 50/50. Там где JS уделывает — как правило, разница не большая. А вот у WASM в некоторых бенчах есть прям x2/x3 прирост.

              Не могу сказать, что реальность прям так уж и расходится с моими текущими ожиданиями. Но, видимо, у нас с вами просто разные ожидания.
            0

            И отлельный вопрос производительности произведенного golang кода wasm.


            Меня довольно удручает еще и невозможность собрать минималистичный wasm код, без лишнего из рантайма.

            0
            Никто JS не зажимает, человек просто наглядно демонстрирует нам возможности.
            +1
            Наглядная иллюстрация того как можно из пушки палить по воробьям.
              0
              Да я так понимаю никто не собирается этого делать, просто для примера взяли.
              0
              А можно ли в wasm подключать сторонние библиотеки? Например для работы с КриптоПро.
                0
                Вы можете использовать любые сторонние пакеты Go, для примера в статье используется пакет для работы с mustache template
                –1
                Везде надо стараться выбирать оптимальный инструмент, на любом языке программирования можно сделать куча извращений. Только зачем?
                image
                  0
                  Создание любой штуки, обычно, приследует некоторые цели.

                  Какие цели этого експерементального порта? Потому что можем?

                  Грустно становится, годика 2-3, и go гляди в франкинштейна привратиться…
                    0
                    Спасибо, статья годная. Нужно теперь какую-то обёртку для вызовов syscall/js, манипулирующих DOMом. В идеале — порт реакта.

                    По поводу шаблонизатора есть предложение не использовать mustache, он плохой. Как и стандартный. Предлагаю посмотреть на quicktemplate от автора fasthttp, не пожалеете
                    +1
                    Спасибо за хорошую статью. Кстати, ещё есть очень неплохой react-подобный фреймворк Vecty:
                    github.com/gopherjs/vecty
                    Он пока не умеет WASM, всё через gopherjs, но ирония в том, что работать с syscall/js+wasm и gopherjs – это фактически одно и то же, там буквально синтаксически чуть поменять код :)

                    На vecty написан goplay.space, например.
                      0
                      имхо всё же myitcv/react более готов к продакшену, чем vecty

                      Only users with full accounts can post comments. Log in, please.