Fullstack на Go

Создадим простое web приложение на языке программирования Go, где можно будет оставлять и читать цитаты. Для их хранения будем использовать СУБД PostgreSQL.

Директория приложения имеет следующую структуру:

code/
├── static
│   ├── styles.css
│   ├── script.js
│   └── index.html
├── amvera.yml
├── main.go
├── go.sum
├── go.mod
└── Dockerfile

Код статических файлов доступен в конце страницы.

Dockerfile

Шаги:

  1. Создаем Dockerfile в директории с проектом.

  2. В Dockerfile указываем базовый образ с названием builder:

    FROM golang:1.21.1 AS builder
    

    вместо 1.21.1 можете указать любую другую версию, которая вам нужна.

  3. Устанавливаем рабочую директорию:

    WORKDIR /app
    
  4. Копируем файлы main.go, go.mod и go.sum в текущую директорию рабочего каталога:

    COPY main.go go.mod go.sum ./
    
  5. Выполняем сборку проекта и создаем исполняемый файл server:

    RUN CGO_ENABLED=0 go build -a -installsuffix cgo -o server
    
  6. Используем образ Alpine Linux (это уменьшит размер конечного образа и улучшит его производительность):

    FROM alpine:latest
    
  7. Копируем исполняемый файл server, собранный в предыдущем образе, в текущую директорию рабочего каталога:

    COPY --from=builder /app/server ./
    
  8. Копируем статические файлы внутрь контейнера:

    COPY static/ ./static/
    
  9. Открываем порт 80 для внешних подключений:

    EXPOSE 80
    
  10. Добавляем команду для запуска приложения:

CMD ["./server", "--port", "80"]

Получившийся Dockerfile:

FROM golang:1.21.1 AS builder
    
WORKDIR /app

COPY main.go go.mod go.sum ./

RUN CGO_ENABLED=0 go build -a -installsuffix cgo -o server

FROM alpine:latest

COPY --from=builder /app/server ./

COPY static/ ./static/

EXPOSE 80

CMD ["./server", "--port", "80"]

Amvera.yml

Написать yaml файл можно как самостоятельно, так и воспользоваться нашим генератором yaml, перейдя по ссылке, либо заполнить в разделе «Конфигурация» личного кабинета. Пример файла amvera.yml:

meta:
  environment: docker
  toolchain: docker
build:
  dockerfile: Dockerfile
  skip: false
run:
  persistenceMount: /data
  containerPort: "80"

Подсказка

Если вы используете Dockerfile, то конфигурационный файл amvera.yaml можно не добавлять.

Зависимости (go.mod и sum.go)

Инициализируем новый модуль для управления зависимостями. Для этого выполняем следующую команду, которая создаст файл go.mod:

go mod init main

Подсказка

Вместо main можно написать произвольную строку

Остаётся выполнить ещё одну команду, которая добавит по две записи на каждую зависимость и создаст файл go.sum:

go mod tidy

Развертывание СУБД (PostgreSQL)

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

Создание проекта в Amvera

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

  • user - Имя пользователя

  • password - Пароль пользователя

  • dbname - Имя создаваемой БД

  • параметр host можно найти на странице Инфо вашего PostgreSQL проекта (например, amvera-username-cnpg-appname-rw)

main.go:

package main
   
import (
    "database/sql"
    "encoding/json"
    "log"
    "net/http"
    "strconv"

    _ "github.com/lib/pq"
)

// Укажите те значения, которые задавали при создании БД на Amvera Cloud
const (
    host     = "amvera-nskripko-cnpg-godb-rw"
    port     = 5432
    user     = "nick"
    password = "href239"
    dbname   = "godb"
)

func main() {
    portStr := strconv.Itoa(port)
    dbinfo := "host=" + host + " port=" + portStr + " user=" + user + " password=" + password + " dbname=" + dbname + " sslmode=disable"

    db, err := sql.Open("postgres", dbinfo)
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    _, err = db.Exec(`CREATE TABLE IF NOT EXISTS quotes (
                        id SERIAL PRIMARY KEY,
                        quote TEXT NOT NULL
                    )`)
    if err != nil {
        log.Fatal(err)
    }
    
    http.HandleFunc("/quotes", func(w http.ResponseWriter, r *http.Request) {
        if r.Method != http.MethodGet {
            http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
            return
        }

        rows, err := db.Query("SELECT quote FROM quotes")
        if err != nil {
            log.Println("Error querying database:", err)
            http.Error(w, "Internal server error", http.StatusInternalServerError)
            return
        }
        defer rows.Close()

        var quotes []string
        for rows.Next() {
            var quote string
            if err := rows.Scan(&quote); err != nil {
                log.Println("Error scanning rows:", err)
                continue
            }
            quotes = append(quotes, quote)
        }

        json.NewEncoder(w).Encode(quotes)
    })

    http.HandleFunc("/addquote", func(w http.ResponseWriter, r *http.Request) {
        if r.Method != http.MethodPost {
            http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
            return
        }

        var data struct {
            Quote string `json:"quote"`
        }
        if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
            http.Error(w, "Bad request", http.StatusBadRequest)
            return
        }

        _, err := db.Exec("INSERT INTO quotes (quote) VALUES ($1)", data.Quote)
        if err != nil {
            log.Println("Error inserting quote into database:", err)
            http.Error(w, "Internal server error", http.StatusInternalServerError)
            return
        }

        w.WriteHeader(http.StatusCreated)
    })

    fs := http.FileServer(http.Dir("static"))
    http.Handle("/", fs)

    log.Fatal(http.ListenAndServe(":80", nil))
}

Важно

Код является демонстрационным примером и мы настоятельно не рекомендуем указывать логин и пароль для подключения к базе данных в коде. Используйте переменные окружения (секреты)!

Чтобы развернуть основное приложение в Amvera, нужно выполнить следующие простые шаги:

  1. Открываем страницу https://cloud.amvera.ru/projects

  2. Нажимаем кнопку Создать и выбираем тип сервиса приложение

  3. Выгружаем все файлы (можно через git, а можно через интерфейс). Убедитесь, что вы выгрузили все нужные файлы:

    • go.mod (обязательно)

    • go.sum (обязательно)

    • main.go (обязательно)

    • Dockerfile (обязательно)

    • static/index.html (если используете)

    • static/script.js (если используете)

    • static/styles.css (если используете)

    • amvera.yml (необязательно)

  4. После этого начнется сборка и развертывание приложения. Дождитесь появления статуса «Успешно развернуто».

Проверка работоспособности

  1. Переходим в настройки проекта и активируем доменное имя:

  2. Теперь можно перейти по данному URL и откроется наше приложение:

Если что-то не работает, рекомендуем ознакомиться с логами Сборки и Приложения.

Поздравляем, вы успешно создали свое первое приложение в Amvera!

Код статических файлов

static/index.html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Quote Board</title>
    <link rel="stylesheet" href="styles.css">
</head>
<body>
    <div class="container">
        <h1>Quote Board</h1>
        <form id="quoteForm">
            <input type="text" id="quoteInput" placeholder="Enter your quote" required>
            <button type="submit">Submit</button>
        </form>
        <div id="quoteList"></div>
    </div>
    <script src="script.js"></script>
</body>
</html>

static/styles.css:

body {
  font-family: Arial, sans-serif;
}

.container {
  max-width: 600px;
  margin: 50px auto;
  padding: 0 20px;
}

input[type="text"] {
  width: calc(100% - 80px);
  padding: 10px;
  margin-right: 10px;
}

button {
  padding: 10px 20px;
  background-color: #007bff;
  color: #fff;
  border: none;
  cursor: pointer;
}

button:hover {
  background-color: #0056b3;
}

#quoteList {
  margin-top: 20px;
}

static/script.js:

document.addEventListener('DOMContentLoaded', () => {
  const quoteForm = document.getElementById('quoteForm');
  const quoteInput = document.getElementById('quoteInput');
  const quoteList = document.getElementById('quoteList');

  // Function to fetch quotes from the server and display them
  const fetchQuotes = async () => {
      try {
          const response = await fetch('/quotes');
          const quotes = await response.json();

          // Clear previous quotes
          quoteList.innerHTML = '';

          // Append new quotes to the list
          quotes.forEach(quote => {
              const quoteItem = document.createElement('div');
              quoteItem.textContent = quote;
              quoteList.appendChild(quoteItem);
          });
      } catch (error) {
          console.error('Error fetching quotes:', error);
      }
  };

  // Fetch initial quotes when the page loads
  fetchQuotes();

  // Submit quote form
  quoteForm.addEventListener('submit', async event => {
      event.preventDefault();
      const newQuote = quoteInput.value.trim();

      if (newQuote === '') {
          alert('Please enter a quote.');
          return;
      }

      try {
          // Send the new quote to the server
          await fetch('/addquote', {
              method: 'POST',
              headers: {
                  'Content-Type': 'application/json'
              },
              body: JSON.stringify({ quote: newQuote })
          });

          // Clear the input field
          quoteInput.value = '';

          // Fetch and display updated quotes
          fetchQuotes();
      } catch (error) {
          console.error('Error adding quote:', error);
      }
  });
});