Создание высокопроизводительных веб-сайтов с использованием 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. Предварительный просмотр можно посмотреть здесь:
Оптимизация
При типичной настройке 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 найдут его отличным выбором для создания быстрых серверных приложений.