Олег Марков
Функция append в Go Golang
Введение
После обновления — updated — это типичная ситуация, с которой вы сталкиваетесь каждый раз, когда меняете версию приложения, библиотеки, конфигурации сервиса или даже структуру данных в своей системе. На первый взгляд фраза «после обновления» звучит как что‑то очевидное, но на практике именно этот этап чаще всего становится источником скрытых ошибок, откатов и аварий.
Смотрите, в чем суть: само обновление — это всего лишь момент применения изменений. А вот состояние системы после обновления — это уже результат, который может сильно отличаться от того, что вы планировали. Конфиги могли не подтянуться, миграции могли частично выполниться, кэш мог остаться старым, клиенты — отправлять запросы в старом формате, а логи — молчать.
В этой статье мы разберем, как правильно работать с этапом «после обновления», чтобы:
- проверять, что система действительно обновилась;
- понимать, что именно изменилось;
- контролировать совместимость новых и старых компонентов;
- находить и устранять проблемы, которые проявляются уже после релиза;
- строить предсказуемые процедуры пост‑деплоя.
Я буду показывать вам примеры в основном на основе типового веб‑приложения и микросервисов, но подходы из статьи применимы и к десктопным программам, и к CLI‑утилитам, и к мобильным приложениям, и к инфраструктуре.
Подход к работе с состоянием «после обновления»
Что важно фиксировать сразу после обновления
После любого обновления у вас есть короткое «окно» времени, когда проще всего поймать ошибки конфигурации, несовместимости и неправильной работы. Давайте по шагам разберем, что стоит проверить в первую очередь.
- Версию и билд‑информацию.
- Миграции данных и состояние схемы.
- Конфигурацию и переменные окружения.
- Состояние зависимостей (БД, очереди, внешние API).
- Здоровье приложения и ключевые метрики.
- Поведение основных пользовательских сценариев.
Чем больше из этого списка вы автоматизируете, тем менее болезненными будут релизы.
Пример общей последовательности действий после обновления
Давайте разберемся на абстрактном примере веб‑сервиса.
- Обновление бинаря или контейнера.
- Перезапуск/переключение трафика.
- Проверка версии через служебный эндпоинт.
- Запуск миграций и их валидация.
- Проверка ключевых метрик.
- Прогон набора smoke‑тестов.
- Фиксация результата (лог / отчет).
Дальше я покажу, как эту логику можно реализовать на практике.
Проверка версии и состояния сервиса после обновления
Эндпоинт /version или /health
После обновления важно иметь простой способ убедиться, что запущена именно та версия, которую вы хотели. Для этого часто используют служебный эндпоинт.
Теперь вы увидите, как это выглядит в коде на примере Go‑сервиса:
package main
import (
"encoding/json"
"net/http"
)
// Структура с информацией о версии приложения
type VersionInfo struct {
Version string `json:"version"` // Номер версии (например, 1.4.2)
Commit string `json:"commit"` // Хэш git-коммита
BuildTime string `json:"buildTime"` // Время сборки
Env string `json:"env"` // Окружение (prod, staging, dev)
}
// Здесь мы задаем значения по умолчанию. Они будут перезаписаны на этапе сборки.
var version = "dev"
var commit = "none"
var buildTime = "unknown"
var env = "local"
// Обработчик, который возвращает информацию о версии
func versionHandler(w http.ResponseWriter, r *http.Request) {
info := VersionInfo{
Version: version,
Commit: commit,
BuildTime: buildTime,
Env: env,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(info) // Кодируем структуру в JSON и отправляем клиенту
}
func main() {
http.HandleFunc("/version", versionHandler) // Регистрируем маршрут /version
// Запускаем HTTP-сервер на порту 8080
http.ListenAndServe(":8080", nil)
}
Как видите, этот код выполняет простую задачу: по запросу на /version он отдает текущие параметры версии. На этапе сборки вы можете подменять переменные version, commit, buildTime, env через флаги компилятора или переменные окружения.
После обновления вы запрашиваете /version и сразу видите, действительно ли на узле крутится нужный билд.
Пример сборки с подстановкой данных версии
Ниже пример минимального скрипта для сборки и запуска, который помогает привязать бинарь к конкретному коммиту.
#!/usr/bin/env bash
set -e
# Здесь мы получаем номер версии из тега или переменной
VERSION=${VERSION:-"1.0.0"}
COMMIT=$(git rev-parse --short HEAD) # Текущий git-коммит
BUILD_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") # Время сборки в формате ISO 8601
ENVIRONMENT=${ENVIRONMENT:-"staging"} # Окружение по умолчанию
# Собираем бинарь с подстановкой значений в переменные
go build -ldflags "-X main.version=$VERSION -X main.commit=$COMMIT -X main.buildTime=$BUILD_TIME -X main.env=$ENVIRONMENT" -o app
# Запускаем сервис
./app
После обновления вы можете сделать:
curl http://localhost:8080/version
и сравнить возвращенные значения с теми, что ожидались для текущего релиза.
Миграции и состояние данных после обновления
Почему проблемы часто проявляются только после обновления
Обратите внимание: большинство проблем после обновления связаны с данными. Код уже новый, а данные — старые. Или наоборот: данные уже в новой схеме, а часть экземпляров приложения еще работает на старой версии.
Типичные проявления:
- паники или ошибки десериализации;
- «странные» NPE/NullPointer из‑за смены обязательных полей;
- падение запросов только при доступе к определенным записям;
- расхождение бизнес‑логики из‑за неучтенных миграций.
Здесь важно не только запустить миграции, но и проверить, что они успешно завершились и система уже работает в согласованном состоянии.
Пример миграций с проверкой статуса
Покажу вам пример на основе типичного SQL‑приложения. Предположим, у нас есть миграции в виде SQL‑файлов и отдельная таблица для их учета.
-- Здесь мы создаем таблицу, в которой будет храниться информация о миграциях
CREATE TABLE IF NOT EXISTS schema_migrations (
id SERIAL PRIMARY KEY,
version VARCHAR(50) NOT NULL,
applied_at TIMESTAMP NOT NULL DEFAULT NOW()
);
А теперь пример кода на Go, который запускается после обновления и проверяет, все ли миграции применены:
package main
import (
"database/sql"
"fmt"
"log"
)
// Migration представляет собой одну миграцию, которую нужно применить
type Migration struct {
Version string // Номер версии миграции
UpSQL string // SQL-скрипт, который нужно выполнить
}
// applyMigration применяет одну миграцию и записывает ее в таблицу schema_migrations
func applyMigration(db *sql.DB, m Migration) error {
tx, err := db.Begin() // Начинаем транзакцию
if err != nil {
return err
}
// Выполняем SQL миграции
if _, err := tx.Exec(m.UpSQL); err != nil {
tx.Rollback() // Откатываем транзакцию при ошибке
return err
}
// Фиксируем, что миграция с этой версией применена
if _, err := tx.Exec(`INSERT INTO schema_migrations (version) VALUES ($1)`, m.Version); err != nil {
tx.Rollback()
return err
}
return tx.Commit() // Подтверждаем изменения
}
// isMigrationApplied проверяет, применена ли миграция с указанной версией
func isMigrationApplied(db *sql.DB, version string) (bool, error) {
var count int
err := db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE version = $1`, version).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil // Если count > 0, миграция уже применена
}
// runMigrations запускает список миграций
func runMigrations(db *sql.DB, migrations []Migration) error {
for _, m := range migrations {
applied, err := isMigrationApplied(db, m.Version)
if err != nil {
return err
}
if applied {
log.Printf("Миграция %s уже применена, пропускаем", m.Version)
continue
}
log.Printf("Применяем миграцию %s", m.Version)
if err := applyMigration(db, m); err != nil {
return fmt.Errorf("ошибка при применении миграции %s: %w", m.Version, err)
}
}
return nil
}
Этот код можно вызывать как часть post‑deploy шага. После обновления приложение запускает миграции, и вы точно знаете, что схема данных соответствует версии кода.
Проверка согласованности данных после миграций
Дополнительно полезно реализовать проверки целостности данных. Например, если вы ввели новое обязательное поле, стоит проверить, что оно заполнено для всех существующих записей.
Пример логики на псевдокоде:
-- Здесь мы проверяем, есть ли записи, у которых новое поле status равно NULL
SELECT COUNT(*)
FROM orders
WHERE status IS NULL;
После обновления вы можете прогнать такие запросы и убедиться, что система не окажется в «полумигрированном» состоянии.
Конфигурация после обновления
Почему конфиги часто расходятся с кодом
После обновления конфигурация может остаться старой, а код уже ожидает новые параметры. Часто это выглядит так:
- переменная окружения не задана;
- значение устарело и интерпретируется по‑другому;
- изменилось имя параметра;
- формат конфигурационного файла обновился.
Чтобы избежать подобных ситуаций, полезно реализовать явную валидацию конфига на старте и иметь отдельный шаг пост‑проверки после обновления.
Пример загрузки и валидации конфига
Давайте посмотрим, что происходит в следующем примере: мы загружаем конфиг из переменных окружения и проверяем его.
package config
import (
"fmt"
"os"
"strconv"
)
// AppConfig описывает конфигурацию приложения
type AppConfig struct {
Port int // Порт HTTP-сервера
DBUrl string // Строка подключения к базе данных
LogLevel string // Уровень логирования
FeatureXEnabled bool // Флаг включения новой фичи
}
// LoadConfig читает конфиг из переменных окружения
func LoadConfig() (*AppConfig, error) {
portStr := os.Getenv("APP_PORT")
if portStr == "" {
portStr = "8080" // Значение по умолчанию
}
port, err := strconv.Atoi(portStr)
if err != nil {
return nil, fmt.Errorf("некорректное значение APP_PORT: %w", err)
}
dbUrl := os.Getenv("DB_URL")
if dbUrl == "" {
return nil, fmt.Errorf("переменная DB_URL не задана") // Без БД приложение работать не может
}
logLevel := os.Getenv("LOG_LEVEL")
if logLevel == "" {
logLevel = "info" // Уровень логирования по умолчанию
}
featureXEnabled := os.Getenv("FEATURE_X_ENABLED") == "1"
cfg := &AppConfig{
Port: port,
DBUrl: dbUrl,
LogLevel: logLevel,
FeatureXEnabled: featureXEnabled,
}
// Здесь мы дополнительно валидируем значения
if err := validateConfig(cfg); err != nil {
return nil, err
}
return cfg, nil
}
// validateConfig проверяет, что конфигурация корректна
func validateConfig(cfg *AppConfig) error {
if cfg.Port <= 0 || cfg.Port > 65535 {
return fmt.Errorf("параметр Port вне допустимого диапазона")
}
if cfg.LogLevel != "debug" && cfg.LogLevel != "info" && cfg.LogLevel != "warn" && cfg.LogLevel != "error" {
return fmt.Errorf("некорректный LOG_LEVEL: %s", cfg.LogLevel)
}
return nil
}
После обновления вы можете специально проверить, что новые переменные окружения заданы. Например, если вы добавили FEATURE_X_ENABLED, на post‑deploy шаге можно запрашивать /version и отдельный /config/check, который вернет результат валидации.
Здоровье сервиса и метрики после обновления
Базовые health‑checks
После обновления нужно быстро понять, «жив» ли сервис. Здесь обычно используют два типа проверок:
- liveness — жив ли процесс в принципе;
- readiness — готов ли сервис принимать трафик.
Покажу вам, как это реализовано на практике.
package main
import (
"net/http"
)
// livenessHandler отвечает "OK", если процесс жив
func livenessHandler(w http.ResponseWriter, r *http.Request) {
// Здесь можно добавить простейшую проверку внутреннего состояния
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
// readinessHandler проверяет, готов ли сервис обрабатывать запросы
func readinessHandler(w http.ResponseWriter, r *http.Request) {
// Здесь вы можете проверить подключение к БД или другому критичному ресурсу
if !isDatabaseConnected() {
w.WriteHeader(http.StatusServiceUnavailable) // 503 - сервис временно недоступен
w.Write([]byte("DB not ready"))
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("READY"))
}
// isDatabaseConnected - заглушка для проверки подключения к базе
func isDatabaseConnected() bool {
// В реальном коде здесь будет пинг БД или проверка пула соединений
return true
}
func main() {
http.HandleFunc("/health/live", livenessHandler)
http.HandleFunc("/health/ready", readinessHandler)
http.ListenAndServe(":8080", nil)
}
После обновления вы можете настроить оркестратор (Kubernetes, Docker Swarm, systemd) на проверку этих эндпоинтов. Если readiness не проходит, сервис не будет получать трафик, и это позволит безопасно завершить миграции или отладку.
Ключевые метрики после обновления
Кроме состояния «жив/не готов», важно следить за:
- числом ошибок 5xx;
- временем ответа;
- количеством запросов;
- ошибками в логах.
Смотрите, я покажу вам минимальный пример сбора метрик через Prometheus:
import "github.com/prometheus/client_golang/prometheus"
var (
// Здесь мы объявляем счетчик ошибок HTTP
httpErrorsTotal = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "http_errors_total",
Help: "Количество HTTP-ошибок по коду",
},
[]string{"code"},
)
)
// В обработчиках вы можете инкрементировать счетчик при ошибках
// httpErrorsTotal.WithLabelValues("500").Inc()
После обновления вы сравниваете дашборды «до» и «после» и видите, не выросло ли количество ошибок или latency.
Smoke‑тесты и сценарии проверки после обновления
Что такое smoke‑тесты
Smoke‑тесты — это небольшой набор проверок, которые «пробегают» по основным функциям системы и дают быстрый ответ: в целом все работает или нет.
Идея простая: после обновления вы не сразу прогоняете полный регрессионный набор, а начинаете с нескольких ключевых сценариев:
- можно ли залогиниться;
- создается ли сущность (например, заказ);
- открывается ли главная страница;
- выполняется ли критичный бизнес‑процесс.
Пример простого smoke‑теста на Go
Теперь давайте перейдем к следующему шагу: напишем небольшой smoke‑тест, который выполняется сразу после деплоя.
package main
import (
"fmt"
"net/http"
"time"
)
// checkEndpoint отправляет GET-запрос и проверяет код ответа
func checkEndpoint(url string, expectedStatus int) error {
client := &http.Client{Timeout: 5 * time.Second} // Ограничиваем время ожидания ответа
resp, err := client.Get(url)
if err != nil {
return fmt.Errorf("ошибка запроса к %s: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != expectedStatus {
return fmt.Errorf("ожидали статус %d, получили %d", expectedStatus, resp.StatusCode)
}
return nil
}
func main() {
// Здесь мы задаем список проверок после обновления
tests := []struct {
Name string
URL string
ExpectedStatus int
}{
{"Главная страница", "https://example.com/", 200},
{"API состояние", "https://example.com/api/health", 200},
{"Страница логина", "https://example.com/login", 200},
}
var failed int
for _, t := range tests {
fmt.Printf("Проверяем: %s\n", t.Name)
if err := checkEndpoint(t.URL, t.ExpectedStatus); err != nil {
fmt.Printf(" НЕ УСПЕХ: %v\n", err)
failed++
} else {
fmt.Println(" OK")
}
}
if failed > 0 {
fmt.Printf("Итого: %d проверок завершились с ошибкой\n", failed)
// Здесь вы можете вернуть ненулевой код выхода, чтобы CI пометил деплой как неуспешный
} else {
fmt.Println("Все smoke-тесты прошли успешно")
}
}
Этот мини‑инструмент можно запускать автоматически «после обновления» в пайплайне. Если какой‑то ключевой сценарий падает, вы сразу знаете, что релиз проблемный.
Стратегии обновлений и поведение системы после них
Blue‑Green и Canary обновления
Фраза «после обновления» в зависимости от стратегии может означать разное:
- Blue‑Green — у вас есть две идентичные среды, и вы переключаете трафик между ними. После обновления вы проверяете уже новую среду, пока старая еще жива.
- Canary — вы запускаете новую версию только для части трафика. После обновления вы анализируете метрики именно для этой части и решаете, расширять ли охват.
В обоих случаях очень важно:
- иметь раздельные метрики для версий;
- уметь быстро откатываться;
- уметь сравнивать «до/после» на уровне показателей.
Локальное и staging‑окружение
Перед тем как дойти до production, вы, как правило, обновляете:
- локальное окружение разработчиков;
- тестовый стенд;
- staging.
После обновления на каждом из этапов полезно иметь одинаковый набор проверок, чтобы поведение в prod не стало сюрпризом.
Пример типового пайплайна:
- Обновление на dev/stage.
- Прогон автотестов.
- Ручная проверка критичных сценариев.
- Анализ логов и метрик.
- Только после этого — обновление production.
Логи и отслеживание поведения после обновления
Добавление контекста версии в логи
Чтобы понимать, как система ведет себя после обновления, важно привязать события в логах к конкретной версии.
Например, вы можете добавлять версию в каждую запись лога.
package logger
import "log"
// LogWithVersion пишет сообщение в лог с указанием версии приложения
func LogWithVersion(version string, msg string) {
// Здесь мы просто добавляем версию перед сообщением
log.Printf("[version=%s] %s", version, msg)
}
После обновления вы сможете фильтровать логи по version и видеть, какие ошибки относятся к новому релизу.
Отдельные алерты на первые часы после обновления
Многие команды усиливают мониторинг в первые часы после обновления. Вы можете:
- временно снизить пороги срабатывания алертов;
- включить дополнительные дашборды;
- отслеживать специфичные метрики, связанные с новой фичей.
Это важно, потому что часть проблем проявляется не сразу, а при первых реальных нагрузках, которые сложно смоделировать на тестовых стендах.
Обратная совместимость и сценарии rollback после обновления
Почему rollback тоже относится к этапу «после обновления»
После обновления может понадобиться откат. Это тоже часть поведения системы «после обновления» — только уже в сторону старой версии. Если архитектура и миграции не учитывают возможность отката, rollback может быть либо невозможен, либо очень дорог.
Чтобы упростить жизнь, стоит:
- по возможности делать миграции обратимыми;
- иметь процедуру «safe rollback»;
- помнить о совместимости схемы данных и форматов сообщений.
Пример осторожной миграции
Допустим, вы хотите переименовать поле в таблице. Самый безопасный путь — сделать это в несколько шагов.
- Добавить новое поле, не убирая старое.
- Обновить код так, чтобы он писал в оба поля и читал из нового.
- Обновить все сервисы, которые используют это поле.
- Через какое‑то время убрать старое поле.
Если после шага 2 вы решите откатиться, старое поле все еще будет актуальным, и rollback пройдет мягче.
Автоматизация пост‑деплой шагов
Почему ручные проверки — источник нестабильности
Пока проверки после обновления выполняются вручную, вы зависите от человеческого фактора: кто‑то может забыть запустить smoke‑тесты, кто‑то — не проверить миграции, кто‑то — не взглянуть на нужный дашборд.
Поэтому имеет смысл вынести все, что возможно, в автоматические шаги:
- отдельный job с миграциями;
- запускаемый скрипт с пост‑проверками;
- автоматическая публикация отчета о результатах;
- блокировка пайплайна до прохождения критичных шагов.
Пример простого post‑deploy скрипта
Смотрите, я покажу вам, как может выглядеть минимальный post‑deploy скрипт на bash.
#!/usr/bin/env bash
set -e
# Здесь вы задаете базовый URL сервиса
BASE_URL=${BASE_URL:-"https://example.com"}
echo "Проверяем версию после обновления..."
curl -sf "$BASE_URL/version" || { echo "version endpoint недоступен"; exit 1; }
echo "Проверяем readiness..."
curl -sf "$BASE_URL/health/ready" || { echo "readiness check не прошел"; exit 1; }
echo "Запускаем smoke-тесты..."
go test ./tests/smoke/... || { echo "smoke-тесты не прошли"; exit 1; }
echo "Все проверки после обновления прошли успешно"
Этот скрипт можно запускать из CI сразу после деплоя. Если что‑то идет не так, пайплайн помечается как неуспешный, и у вас есть возможность быстро откатиться.
Практический чек‑лист действий после обновления
Чтобы было проще применять материал на практике, соберу основной перечень шагов в один блок. Вы можете адаптировать его под свои проекты.
Технический чек‑лист
После обновления:
Убедиться, что:
- поднята нужная версия (через
/version,--version, просмотр артефакта); - все реплики/инстансы обновлены (если их несколько).
- поднята нужная версия (через
Запустить миграции данных, затем:
- проверить статус миграций;
- выполнить выборочные проверки целостности данных.
Проверить конфигурацию:
- все новые переменные окружения заданы;
- формат конфигов соответствует ожиданиям;
- нет конфликтов между старым и новым параметрами.
Проверить здоровье сервиса:
- liveness и readiness эндпоинты возвращают корректные ответы;
- метрики ошибок и времени ответа в пределах нормы;
- нет всплеска 5xx.
Выполнить smoke‑тесты:
- вход в систему / базовый сценарий авторизации;
- создание и чтение основных сущностей;
- критичные бизнес‑процессы.
Проанализировать логи:
- нет новых неожиданных ошибок;
- ошибки можно связать с конкретной версией.
При необходимости:
- усилить алертинг на первые часы после обновления;
- подготовить план отката, если что‑то пошло не так.
Заключение
Этап «после обновления — updated» — не формальность, а важная часть жизненного цикла вашего приложения. От того, как вы его организуете, зависит, будут ли релизы предсказуемыми или каждый деплой будет лотереей.
Мы разобрали:
- как проверять версию и состояние сервиса после обновления;
- как работать с миграциями и проверками целостности данных;
- как валидировать конфигурацию и следить за health‑чеками;
- как использовать smoke‑тесты и метрики;
- как думать о совместимости и откатах;
- как автоматизировать post‑deploy шаги.
Если вы сформируете для своих проектов четкий и повторяемый сценарий действий после обновления, со временем релизы перестанут быть стрессом, а большинство проблем начнет ловиться либо до попадания в production, либо в самые первые минуты после деплоя — когда их проще всего исправить.
Частозадаваемые технические вопросы по теме статьи и ответы на них
1. Как убедиться, что все инстансы микросервиса действительно обновились до одной версии?
Используйте служебный эндпоинт /version (или аналогичный) и собирайте с него данные со всех инстансов через сервис‑дискавери, оркестратор или балансировщик. Напишите небольшой скрипт или job, который опрашивает все зарегистрированные инстансы и сверяет поля version и commit. Если хотя бы один отличается — деплой считается незавершенным.
2. Что делать, если миграция частично выполнилась и приложение уже начало писать данные в новую схему?
Во‑первых, проверьте таблицу миграций и логи, чтобы понять, на каком шаге произошел сбой. Во‑вторых, остановите запись новых данных (переведите сервис в режим только чтения или снимите трафик). Далее либо допишите миграцию так, чтобы она была повторяемой (idempotent) и доработала начатые изменения, либо выполните ручные корректирующие SQL‑запросы. После этого перезапустите миграцию или выполните следующий шаг цепочки.
3. Как безопасно тестировать фичи после обновления, не влияя на всех пользователей?
Используйте feature flags. Добавьте в конфигурацию флаг включения новой функциональности и управляйте им через конфиг‑сервер, БД или систему флагов (например, Unleash, LaunchDarkly). После обновления приложение уже содержит новый код, но он не активен, пока флаг не включен. Это позволяет поэтапно включать фичу для небольших групп пользователей и быстро отключать ее при проблемах.
4. Как отличить проблемы, вызванные обновлением, от уже существовавших, но незамеченных багов?
Сохраняйте исторические метрики и логи и сравнивайте показатели «до» и «после» обновления. Если ошибка или рост латентности начинаются строго после деплоя и привязаны к новой версии (по полю version в логах или лейблам в метриках), с высокой вероятностью это эффект обновления. Помогают отдельные дашборды, где на временных графиках отмечено время релиза.
5. Как уменьшить простой системы при выполнении миграций после обновления?
Используйте техники «on‑the‑fly» миграций и стратегии zero‑downtime: выполняйте миграции, не блокирующие чтение (например, добавление nullable колонок, создание индексов CONCURRENTLY), а бизнес‑логику переведите на постепенную миграцию записей при доступе к ним. Критичные миграции, требующие блокировок, выполняйте в периоды минимальной нагрузки и обязательно фиксируйте максимально допустимое время простоя.
Постройте личный план изучения Vue до уровня Middle — бесплатно!
Vue — часть карты развития Frontend
100+ шагов развития
30 бесплатных лекций
300 бонусных рублей на счет
Бесплатные лекции
Все гайды по Vue
Лучшие курсы по теме

Vue 3 и Pinia
Антон Ларичев
TypeScript с нуля
Антон Ларичев