Создание высокопроизводительных веб-сайтов с использованием htmx и Go

Сфера веб-разработки снова смещается в сторону серверного рендеринга и отказа от клиентских архитектур, основанных на JavaScript. Этой тенденции способствуют такие инструменты, как серверные компоненты React и каталог приложений в таких фреймворках, как Next.js, которые упрощают маршрутизацию и рендеринг на стороне сервера.

В ответ на этот сдвиг набирают популярность такие инструменты, как htmx, для создания интерактивных веб-приложений с минимальным использованием JavaScript. HTMX на основе HTML позволяет выполнять рендеринг на стороне сервера с использованием AJAX. В этой статье мы рассмотрим, как создать высокопроизводительный веб-сайт, используя htmx и Go, серверные языки, известные своей скоростью и эффективностью.

Что такое htmx?

htmx - это облегченная библиотека JavaScript, которая позволяет создавать большие динамичные сайты с минимальной зависимостью от клиентского JavaScript.

htmx внедряет различные AJAX-подобные атрибуты и преобразуется на сервере в простой HTML, что позволяет разработчикам получать обновления, подобные AJAX, и динамические взаимодействия на страницах.

Давайте рассмотрим краткий пример прямо из документации, чтобы продемонстрировать, как htmx обрабатывает динамические взаимодействия:

<button hx-post="/clicked"
    hx-trigger="click"
    hx-target="#parent-div"
    hx-swap="outerHTML">
    Click Me!
</button>

Здесь элементу button присваиваются различные атрибуты. При нажатии атрибут hx-post="/clicked" отправляет HTTP POST-запрос в API /clicked. После этого нажатие кнопки заменит целевой div на идентификатор #parent-div с ответом, полученным от API.

Именно так htmx обрабатывает типичные динамические взаимодействия. Как вы можете видеть, страница или элемент в этом случае будут отображаться на сервере, что позволяет повысить интерактивность и сэкономить на клиентских пакетах JavaScript.

Что такое Голанг?

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

Создание простого динамического приложения с помощью htmx и Go

Настройка сервера Go

Настройка сервера Go - это первый шаг в создании серверной части с помощью Go. Спецификация Go позволяет легко и быстро настроить сервер с помощью встроенного пакета net/http. Предполагая, что в вашей системе настроен Go, вы можете создать проект Go в каталоге и начать с создания файла с именем main. go.

В этот файл вы должны импортировать fmt для форматирования строк и журнала и net/http для запуска сервера:

package main
import (
  "fmt"
  "net/http"
)

При этом создается основная функция со следующим серверным кодом:

package main
import (
"fmt"
"net/http"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello, World!")
})
fmt.Println("Server running at http://localhost:8080")
http.ListenAndServe(":8080", nil)
}

Это запустит ваш сервер на порту 8080 и напечатает "Привет, мир!"на вашем терминале.

Вы можете пойти еще дальше и вместо печати журнала создать простой пользовательский интерфейс, изменив основную функцию:

http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// Set the Content-Type header to HTML
w.Header().Set("Content-Type", "text/html")
// Write an HTML response
fmt.Fprintln(w, `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hello, World</title>
</head>
<body>
<h1>Hello, World!</h1>
<p>Welcome to your first Go web server.</p>
</body>
</html>
`)
})

Теперь в корне / вместо этого будет отображаться этот HTML-код. Здесь важно отметить, что w.Header().Set("Content-Type", "text/html") устанавливает заголовок ответа таким образом, чтобы он указывал, что типом контента является HTML. Наконец, вы можете запустить этот файл, выполнив команду go run main.go, где main.go - это имя файла.

Добавление интерактивности с помощью htmx

Вы можете использовать htmx для отображения того же фрагмента HTML с атрибутами, специфичными для htmx, которые позволят вам добавлять взаимодействия на страницу.

Вы можете интегрировать htmx в этот проект, просто используя CDN и включая его в свой скрипт, где бы вы ни выполняли рендеринг:

<script src="https://unpkg.com/htmx.org"></script>

В этом примере вы можете обновить свою функцию main(), включив в нее синтаксис htmx:

func main() {
// Handler for the main page
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
fmt.Fprintln(w, `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HTMX Demo</title>
<script src="https://unpkg.com/htmx.org"></script>
</head>
<body>
<h1>HTMX Demo</h1>
<div id="content">
<p>Click the button to fetch updated content!</p>
</div>
<button hx-get="/update" hx-target="#content" hx-swap="innerHTML">
Get Updated Content
</button>
</body>
</html>
`)
})

Вы можете увидеть тег script, который теперь позволяет вам писать синтаксис, специфичный для htmx, и, следовательно, атрибуты. Что здесь на самом деле происходит?

Что ж, как вы видели в первом разделе этой статьи, атрибут hx-get="/update" получит ответ от API /update и заменит innerHTML на hx-swap="innerHTML". Этот новый ответ обновит div с идентификатором "#content" из-за атрибута hx-target="#content".

Чтобы все это произошло, вам нужна конечная точка /update, которая отправит ответ content, который должен заменить существующий HTML-контент. В Go вы можете создать такой обработчик следующим образом:

http.HandleFunc("/update", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
fmt.Fprintln(w, `<p>Content updated at: `+r.RemoteAddr+`</p>`)
})

Этот обработчик отправит HTML-ответ с обновленным содержимым по адресу: +r.RemoteAddr+, т.е. напечатает IP-адрес пользователя.

Расширенное использование: реальные примеры

Теперь, когда вы разобрались с базовой реализацией Go, мы собираемся пойти немного глубже и создать небольшое приложение для составления списка дел.

Создание приложения для составления списка дел

Перво-наперво создайте каталог/папку в вашей системе и создайте файл main.go. В качестве альтернативы вы можете запустить следующий код:

go mod init todo-app

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

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

go get -u github.com/go-sql-driver/mysql

Наконец, в ваш файл main.go импортируйте следующие библиотеки:

package main
import (
    "database/sql"
    "fmt"
    "html/template"
    "log"
    "net/http"
    _ "github.com/go-sql-driver/mysql"
)

Определите модель задачи в Go

Теперь вам нужно определить схему для ваших текущих задач. У любых текущих задач будет идентификатор и статус, чтобы отслеживать, завершены они или нет. В Go вы можете ввести эту схему следующим образом:

type Todo struct {
    ID int `json:"id"`
    Title string `json:"title"`
    Completed bool `json:"completed"`
}

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

func indexHandler(w http.ResponseWriter, r *http.Request) {
    tmpl, err := template.ParseFiles("index.html")
    if err != nil {
        http.Error(w, "Unable to load index.html", http.StatusInternalServerError)
        return
}
    tmpl.Execute(w, nil)
}

Конечные точки API для добавления, удаления и пометки задач как выполненных

После добавления indexHandler следующим шагом будет определение конечных точек API и соответствующих им функций:

  • getTodosHandler: Получает все необходимые элементы из серверной части SQL
  • addTodoHandler: добавляет вводимые данные от пользователя путем ввода в поле ввода HTML
  • deleteTodoHandler: Удаляет элементы путем нажатия кнопки удаления.
  • completeTodoHandler: Переключает статус элемента и помечает его как завершенный.

Вы можете ознакомиться с полной внутренней логикой main.go здесь:

package main
import (
    "database/sql"
    "fmt"
    "html/template"
    "log"
    "net/http"
    _ "github.com/go-sql-driver/mysql"
)
type Todo struct {
    ID int `json:"id"`
    Title string `json:"title"`
    Completed bool `json:"completed"`
}
var db *sql.DB
func main() {
    var err error
    dsn := "root:Thecityofroma@123@tcp(localhost:3306)/todo_app"
    db, err = sql.Open("mysql", dsn)
    if err != nil {
        log.Fatalf("Error connecting to the database: %v", err)
}
    defer db.Close()
    if err = db.Ping(); err != nil {
        log.Fatalf("Error pinging the database: %v", err)
}
    http.HandleFunc("/", indexHandler)
    http.HandleFunc("/api/todos", func(w http.ResponseWriter, r *http.Request) {
        if r.Method == http.MethodGet {
            getTodosHandler(w, r)
} else if r.Method == http.MethodPost {
            addTodoHandler(w, r)
} else {
            http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
    http.HandleFunc("/api/delete-todo", deleteTodoHandler)
    http.HandleFunc("/api/complete-todo", completeTodoHandler)
    log.Println("Server is running on http://localhost:8080")
    if err := http.ListenAndServe(":8080", nil); err != nil {
        log.Fatalf("Error starting server: %v", err)
}
}
func indexHandler(w http.ResponseWriter, r *http.Request) {
    tmpl, err := template.ParseFiles("index.html")
    if err != nil {
        http.Error(w, "Unable to load index.html", http.StatusInternalServerError)
        return
}
    tmpl.Execute(w, nil)
}
func renderTodoHTML(todo Todo) string {
    completedStatus := ""
    bgColor := "white"
    buttonText := "Complete"
    if todo.Completed {
        completedStatus = " (Completed)"
        bgColor = "#f0f0f0" // Light grey background for completed tasks
        buttonText = "Uncomplete"
}
    return fmt.Sprintf(`
<div class="todo-item" id="todo-%d" style="background-color: %s;">
<p><strong>%s</strong>%s</p>
<button hx-post="/api/delete-todo"
hx-target="#todo-%d"
hx-swap="outerHTML"
hx-include="#todo-%d [name=id]"
type="button">
Delete
</button>
<button hx-post="/api/complete-todo"
hx-target="#todo-%d"
hx-swap="outerHTML"
hx-include="#todo-%d [name=id]"
type="button">
                %s
</button>
<input type="hidden" name="id" value="%d">
</div>`, todo.ID, bgColor, todo.Title, completedStatus, todo.ID, todo.ID, todo.ID, todo.ID, buttonText, todo.ID)
}
func getTodosHandler(w http.ResponseWriter, r *http.Request) {
    rows, err := db.Query("SELECT id, title, completed FROM todos")
    if err != nil {
        http.Error(w, "Unable to fetch TODO items", http.StatusInternalServerError)
        return
}
    defer rows.Close()
    var todos []Todo
    for rows.Next() {
        var todo Todo
        if err := rows.Scan(&todo.ID, &todo.Title, &todo.Completed); err != nil {
            http.Error(w, "Error reading TODO items", http.StatusInternalServerError)
            return
}
        todos = append(todos, todo)
}
    var html string
    for _, todo := range todos {
        html += renderTodoHTML(todo)
}
    w.Header().Set("Content-Type", "text/html")
    w.Write([]byte(html))
}
func addTodoHandler(w http.ResponseWriter, r *http.Request) {
    if err := r.ParseForm(); err != nil {
        http.Error(w, "Invalid form data", http.StatusBadRequest)
        return
}
    title := r.FormValue("title")
    if title == "" {
        http.Error(w, "Title is required", http.StatusBadRequest)
        return
}
    // Insert new TODO into the database
    result, err := db.Exec("INSERT INTO todos (title, completed) VALUES (?, false)", title)
    if err != nil {
        http.Error(w, "Unable to add TODO item", http.StatusInternalServerError)
        return
}
    // Get the last inserted ID
    id, err := result.LastInsertId()
    if err != nil {
        http.Error(w, "Unable to fetch inserted ID", http.StatusInternalServerError)
        return
}
    // Fetch the newly added TODO from the database
    todo := Todo{
        ID: int(id),
        Title: title,
        Completed: false,
}
    // Render the newly added TODO item as HTML
    html := renderTodoHTML(todo)
    // Return the generated HTML for the new todo
    w.Header().Set("Content-Type", "text/html")
    w.Write([]byte(html))
}
// deleteTodoHandler deletes a TODO item by ID.
func deleteTodoHandler(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        return
}
    if err := r.ParseForm(); err != nil {
        http.Error(w, "Invalid form data", http.StatusBadRequest)
        return
}
    id := r.FormValue("id")
    if id == "" {
        http.Error(w, "ID is required", http.StatusBadRequest)
        return
}
    // Execute the delete query
    _, err := db.Exec("DELETE FROM todos WHERE id = ?", id)
    if err != nil {
        http.Error(w, "Unable to delete TODO item", http.StatusInternalServerError)
        return
}
    // Respond with an empty string to indicate successful deletion.
    w.Header().Set("Content-Type", "text/html")
    w.Write([]byte(""))
}
// completeTodoHandler toggles the completed status of a TODO item by ID.
func completeTodoHandler(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        return
}
    if err := r.ParseForm(); err != nil {
        http.Error(w, "Invalid form data", http.StatusBadRequest)
        return
}
    id := r.FormValue("id")
    if id == "" {
        http.Error(w, "ID is required", http.StatusBadRequest)
        return
}
    // Toggle the completed status
    var completed bool
    err := db.QueryRow("SELECT completed FROM todos WHERE id = ?", id).Scan(&completed)
    if err == sql.ErrNoRows {
        http.Error(w, "TODO item not found", http.StatusNotFound)
        return
} else if err != nil {
        http.Error(w, "Unable to fetch TODO item", http.StatusInternalServerError)
        return
}
    // Update the completed status
    _, err = db.Exec("UPDATE todos SET completed = ? WHERE id = ?", !completed, id)
    if err != nil {
        http.Error(w, "Unable to update TODO item", http.StatusInternalServerError)
        return
}
    // Fetch the updated TODO item
    var todo Todo
    err = db.QueryRow("SELECT id, title, completed FROM todos WHERE id = ?", id).Scan(&todo.ID, &todo.Title, &todo.Completed)
    if err == sql.ErrNoRows {
        http.Error(w, "Updated TODO item not found", http.StatusNotFound)
        return
} else if err != nil {
        http.Error(w, "Unable to fetch updated TODO item", http.StatusInternalServerError)
        return
}
    // Render and return the updated TODO item's HTML
    html := renderTodoHTML(todo)
    w.Header().Set("Content-Type", "text/html")
    w.Write([]byte(html))
}

Создание базы данных с использованием SQL

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

create DATABASE todo_app;

Теперь создайте схему в базе данных под названием todo_app с описанными типами и ключами:

CREATE TABLE todos (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
completed BOOLEAN DEFAULT FALSE
);

Здесь идентификатор является нашим первичным ключом.

Чтобы убедиться, что ваша база данных создана, вы можете запустить show databases;; это отобразит все ваши базы данных следующим образом:

+--------------------+
| Database |
+--------------------+
| information_schema |
| mysql              |
| performance_schema |
| sys                |
| testdb             |
| todo_app.          | -> this is your database
+--------------------+

Чтобы проверить записи в вашем приложении, запустите команду SELECT id, title, details FROM todos;, которая отобразит все записи о текущих задачах.

Создание интерфейса с использованием htmx

Теперь, когда установлены main.go и логика SQL, вы можете перейти к HTML-части и создать файл с именем index.html. Он будет отвечать за отображение и замену элементов на основе изменения внутренней логики из файла main.go:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>TODO List</title>
    <script src="https://unpkg.com/htmx.org"></script>
    <style>
        body {
            font-family: Arial, sans-serif;
            margin: 20px;
}
        h1 {
            color: #333;
}
        .todo-item {
            border: 1px solid #ddd;
            padding: 10px;
            margin-bottom: 10px;
            border-radius: 5px;
}
    </style>
</head>
<body>
    <h1>TODO List</h1>

    <!-- Form to add a new TODO -->
    <form id="add-todo-form" hx-post="/api/todos" hx-swap="beforeend" hx-target="#todo-list">
        <input type="text" name="title" placeholder="Enter a TODO item" required>
        <button type="submit">Add</button>
    </form>
    <div id="todo-list"
         hx-get="/api/todos"
         hx-trigger="load"
         hx-swap="innerHTML">
        <!-- TODO items will be loaded here dynamically -->
    </div>
</body>
</html>

Обратите внимание, что CSS записывается в том же файле, но вы можете перейти к отдельному файлу CSS, исходя из ваших предпочтений. Вы можете переместить часть, связанную со стилем, в отдельный файл и импортировать сам файл в формате HTML. Полный код можно найти в этом репозитории на GitHub.

Чтобы приложение работало корректно, убедитесь, что в вашей системе настроен SQL. Предварительный просмотр можно посмотреть здесь:

To-Do List App Preview

Оптимизация

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

Внутренняя оптимизация с помощью Go

Внутренняя оптимизация обеспечивает бесперебойную доставку API и масштабирование производительности приложения. Go создан с учетом оптимальной производительности и масштабируемости.

Эффективная работа с базами данных с Go: используйте sqlx или собственные драйверы баз данных

Go обеспечивает быстрое взаимодействие с базой данных, что приводит к высокой производительности. Он предоставляет как собственные драйверы баз данных, так и sqlx для упрощения запросов. Как вы видели в этой статье, вы использовали собственный драйвер базы данных, SQL, просто импортировав пакет прямо с GitHub. Аналогичным образом, вы можете использовать sqlx, чтобы уменьшить количество шаблонов и получить больше встроенных функций, таких как сопоставление структур.

Кэширование ответов для снижения нагрузки на сервер

Go предлагает несколько подходов к кэшированию, позволяющих сократить объем вычислений и не перегружать базу данных. Вы можете использовать методы кэширования в памяти, такие как sync.Map для упрощенного и простого в использовании кэширования в стиле пары ключ-значение:

import "sync"
var cache sync.Map
// setting a value in in-memory cache
func setCache(key, value string) {
cache.Store(key, value)
}

Аналогичным образом, для более сложных случаев использования вы можете использовать Redis. Все, что вам нужно сделать, это импортировать пакет с GitHub и приступить к работе:

import ( "github.com/redis/go-redis/v9" "context" )

Используйте подпрограммы для параллельного выполнения задач

Было бы несправедливо говорить о Go и не упоминать о параллелизме. Goroutines в сочетании с параллелизмом могут быть весьма эффективными. Goroutines - это небольшие легкие потоки, которые могут запускаться программно для управления во время выполнения Go:

func doTask(ID int) {
    fmt.Printf("Processing")
}
func main() {
    for i := 1; i <= 10; i++ {
        go doTask(i)
    }
}

Оптимизация интерфейса с помощью htmx

В этом приложении вы оптимизировали HTML с помощью htmx, внешней сторонней библиотеки, которая в значительной степени зависит от рендеринга на стороне сервера. htmx не только упрощает разработку приложений на Go, но и минимизирует полезную нагрузку.

Он использует некоторые из очень умных и хорошо продуманных атрибутов, таких как hx-trigger="revealed", для отложенной загрузки содержимого. Если вы разрабатываете масштабируемое приложение с рендерингом на стороне сервера, htmx, вероятно, является недостающей частью, если не использовать какие-либо другие серверные библиотеки рендеринга.

Преимущества, компромиссы и передовая практика

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

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

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