Пет-проект на Go: делаю сокращатель ссылок в открытую

Пет-проект на Go сокращатель ссылок

Наконец-то, решился взять себе пет-проект, чтобы не просто читать про Go, а руками пощупать net/http на чём-то настоящем. Выбор пал на сервис сокращения ссылок — штука на первый взгляд простая, а внутри есть чем заняться: хранилище, конкурентный доступ, генерация идентификаторов, обработка ошибок. Плюс результат сразу можно потрогать руками через curl или браузер, а это всегда приятно.

Формат такой: я буду вести разработку открыто, шаг за шагом, и каждую рабочую версию помечать как dev1, dev2 и так далее. Надеюсь, такой формат вам зайдет и получится дойти до полноценной рабочей версии. Ошибки и неидеальные решения наверняка будут по пути: буду выкладывать код как есть, а рефакторить и наводить порядок буду уже в следующих версиях.

В этой статье — dev1, самая первая версия, с которой вообще можно начать пользоваться сервисом.

Что должен уметь dev1

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

POST / — принимает в теле запроса обычный текстовый URL и возвращает 201 Created с короткой ссылкой:

POST / HTTP/1.1
Host: localhost:8080
Content-Type: text/plain

https://lessgo.ru

HTTP/1.1 201 Created
Content-Type: text/plain

http://localhost:8080/sVBn7aIk

GET /{id} — принимает идентификатор из короткой ссылки и отвечает 307 Temporary Redirect с оригинальным адресом в заголовке Location:

GET /sVBn7aIk HTTP/1.1
Host: localhost:8080

HTTP/1.1 307 Temporary Redirect
Location: https://lessgo.ru

А на любой некорректный запрос — метод, который сервер не поддерживает, пустое тело, несуществующий идентификатор — сервер должен вернуть 400 Bad Request. Так сформулировано в задании. По стандарту HTTP на неподдерживаемый метод принято отвечать 405 Method Not Allowed, а на неизвестный идентификатор — 404 Not Found, но в dev1 я держусь задания.

Хранилище: простая мапа, но с оглядкой на будущее

Начал с самого простого хранилища — мапа в памяти. Но сразу обернул её в структуру с мьютексом, а не оставил голой мапой. Сама переменная storage при этом пока глобальная — к этому я вернусь, когда дойду до тестов.

Go
type urlStorage struct {
    mu   sync.RWMutex
    urls map[string]string
}

func newURLStorage() *urlStorage {
    return &urlStorage{
        urls: make(map[string]string),
    }
}

func (s *urlStorage) save(id, originalURL string) {
    s.mu.Lock()
    defer s.mu.Unlock()
    s.urls[id] = originalURL
}

func (s *urlStorage) get(id string) (string, bool) {
    s.mu.RLock()
    defer s.mu.RUnlock()

    originalURL, ok := s.urls[id]
    return originalURL, ok
}

Причина простая: net/http обрабатывает каждый входящий запрос в отдельной горутине. Если два запроса одновременно обратятся к одной мапе и хотя бы один из них пишет, это гонка данных. Рантайм Go старается такое ловить и роняет программу с fatal error: concurrent map writes или concurrent map read and map write, но не гарантирует этого: гонка может пройти незаметно и испортить данные. sync.RWMutex решает эту проблему: Lock/Unlock для записи, RLock/RUnlock для чтения, чтобы несколько параллельных чтений не блокировали друг друга.

Я специально прогнал сервис через go build -race и отправил на него 40 параллельных пар запросов: POST на сокращение и сразу GET по полученной ссылке. Гонок данных не нашлось. Детектор находит только те гонки, которые реально случились во время прогона, поэтому нагрузка должна задевать и запись, и чтение. Такую проверку стоит держать в уме: то, что код скомпилировался и один раз отработал, ничего не говорит о его поведении под нагрузкой.

Повторить несложно: собираешь бинарник с -race, запускаешь его как обычный сервер, а во втором окне терминала бьёшь по нему параллельными запросами. В PowerShell, например, так:

PowerShell
1..40 | ForEach-Object -Parallel {
  $short = curl.exe -s -X POST http://localhost:8080/ -d "https://lessgo.ru"
  curl.exe -s -o NUL $short
} -ThrottleLimit 40

Генерация идентификатора

Для короткого идентификатора взял crypto/rand вместо math/rand — генератор случайных чисел общего назначения не годится там, где важна непредсказуемость, а crypto/rand для этого и существует.

Go
func generateShortID() string {
    b := make([]byte, 6)
    _, _ = rand.Read(b)
    return base64.RawURLEncoding.EncodeToString(b)
}

Ошибку от rand.Read я игнорирую намеренно: начиная с Go 1.24 эта функция по документации никогда не возвращает ошибку и всегда заполняет срез целиком. Если системный источник случайности недоступен, программа аварийно завершится сама.

Здесь есть тонкость, на которую я сначала не обратил внимания: обычный base64.URLEncoding кодирует байты с паддингом — символами = в конце строки, если длина входных данных не делится на 3 без остатка. При 4 байтах на выходе получались идентификаторы вида y_oX_Q==. Формально = — валидный символ в пути URL, сервер с ним прекрасно работает, но выглядит криво и не похоже на то, что ожидаешь от короткой ссылки. Переключился на base64.RawURLEncoding (без паддинга) и взял 6 байт вместо 4 — на выходе ровно 8 символов, чистых, без мусора.

Хендлеры

Основную развилку между POST и GET делает хендлер:

Go
func handleRoot(w http.ResponseWriter, r *http.Request) {
    switch r.Method {
    case http.MethodPost:
        handleShorten(w, r)
    case http.MethodGet:
        handleRedirect(w, r)
      default:
        w.WriteHeader(http.StatusBadRequest)
    }
}

Сокращение URL:

Go
func handleShorten(w http.ResponseWriter, r *http.Request) {
    body, err := io.ReadAll(r.Body)
    if err != nil {
        w.WriteHeader(http.StatusBadRequest)
        return
    }
  
    originalURL := strings.TrimSpace(string(body))
    if originalURL == "" {
        w.WriteHeader(http.StatusBadRequest)
        return
    }
      
    id := generateShortID()
    storage.save(id, originalURL)
      
    shortURL := fmt.Sprintf("%s/%s", baseURL, id)
      
    w.Header().Set("Content-Type", "text/plain")
    w.WriteHeader(http.StatusCreated)
    w.Write([]byte(shortURL))
}

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

Редирект по идентификатору:

Go
func handleRedirect(w http.ResponseWriter, r *http.Request) {
	id := strings.TrimPrefix(r.URL.Path, "/")
	if id == "" {
		w.WriteHeader(http.StatusBadRequest)
		return
	}

	originalURL, ok := storage.get(id)
	if !ok {
		w.WriteHeader(http.StatusBadRequest)
		return
	}

	w.Header().Set("Location", originalURL)
	w.WriteHeader(http.StatusTemporaryRedirect)
}

Достаю идентификатор прямо из пути запроса через strings.TrimPrefix, ищу его в хранилище — и если нашёл, ставлю заголовок Location с оригинальным адресом и код 307.

Ошибки, которые я сам у себя поймал

Расскажу про пару багов, на которые наткнулся, когда начал гонять сервис через curl — это тоже часть учебного процесса, и мне кажется, полезнее их показать, чем сделать вид, что код сразу получился идеальным.

  • Порт сервера и порт в ответе разъехались. На каком-то этапе рефакторинга я поменял порт в http.ListenAndServe на 8000, а строку с шаблоном ответа http://localhost:8080/%s забыл поправить рядом. Получилось: сервер слушает 8000, а клиенту отдаёт ссылку на 8080, где вообще ничего не запущено. Внешне всё выглядело рабочим — сервер стартовал, POST отвечал 201 — но по выданной ссылке перейти было физически невозможно. Поймал это только когда буквально попробовал curl’ом дойти по адресу из ответа сервера.
  • ListenAndServe — не про URL. Решил вынести адрес в константу и по инерции засунул туда сразу с http://:

Собралось без единой ошибки компилятора — тип-то везде string. А при запуске сразу упало:

listen tcp: address http://localhost:8080: too many colons in address

ListenAndServe ждёт адрес в формате хост:порт, без схемы. А строка для ответа клиенту, наоборот, обязана содержать http://, потому что это готовая ссылка для перехода в браузере. Внешне похожие строки решают разные задачи — разнёс их на две отдельные константы, и проблема ушла:

Go
const (
	serverAddr = ":8080"
	baseURL    = "http://localhost:8080"
)

Оба случая объединяет одно: код компилировался. Ошибка проявлялась только в рантайме, при реальном запросе. Так что теперь у меня твёрдое правило — после любой правки не просто смотреть, что go build прошёл, а гонять сервис вживую через curl хотя бы по паре сценариев.

Что получилось в итоге

Go
// Example: curl -i -X POST http://localhost:8080/ -H "Content-Type: text/plain" -d "https://lessgo.ru"
package main

import (
	"crypto/rand"
	"encoding/base64"
	"fmt"
	"io"
	"log"
	"net/http"
	"strings"
	"sync"
)

const (
	serverAddr = ":8080"
	baseURL    = "http://localhost:8080"
)

func main() {
	if err := run(); err != nil {
		log.Fatal(err)
	}
}

func run() error {
	http.HandleFunc("/", handleRoot)
	return http.ListenAndServe(serverAddr, nil)
}

// handleRoot routes requests based on the method:
// POST / - shorten a URL, GET /{id} - return a redirect to the original
func handleRoot(w http.ResponseWriter, r *http.Request) {
	switch r.Method {
	case http.MethodPost:
		handleShorten(w, r)
	case http.MethodGet:
		handleRedirect(w, r)
	default:
		w.WriteHeader(http.StatusBadRequest)
	}
}

func handleShorten(w http.ResponseWriter, r *http.Request) {
	body, err := io.ReadAll(r.Body)
	if err != nil {
		w.WriteHeader(http.StatusBadRequest)
		return
	}

	originalURL := strings.TrimSpace(string(body))
	if originalURL == "" {
		w.WriteHeader(http.StatusBadRequest)
		return
	}

	id := generateShortID()
	storage.save(id, originalURL)

	shortURL := fmt.Sprintf("%s/%s", baseURL, id)
	
	w.Header().Set("Content-Type", "text/plain")
	w.WriteHeader(http.StatusCreated)
	w.Write([]byte(shortURL))
}

func handleRedirect(w http.ResponseWriter, r *http.Request) {
	id := strings.TrimPrefix(r.URL.Path, "/")
	if id == "" {
		w.WriteHeader(http.StatusBadRequest)
		return
	}

	originalURL, ok := storage.get(id)
	if !ok {
		w.WriteHeader(http.StatusBadRequest)
		return
	}

	w.Header().Set("Location", originalURL)
	w.WriteHeader(http.StatusTemporaryRedirect)
}

func generateShortID() string {
	b := make([]byte, 6)
	_, _ = rand.Read(b)
	return base64.RawURLEncoding.EncodeToString(b)
}

var storage = newURLStorage()

type urlStorage struct {
	mu   sync.RWMutex
	urls map[string]string
}

func newURLStorage() *urlStorage {
	return &urlStorage{
		urls: make(map[string]string),
	}
}

func (s *urlStorage) save(id, originalURL string) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.urls[id] = originalURL
}

func (s *urlStorage) get(id string) (string, bool) {
	s.mu.RLock()
	defer s.mu.RUnlock()

	originalURL, ok := s.urls[id]
	return originalURL, ok
}

Проверил финальную версию по всем сценариям сразу: корректное сокращение, переход по короткой ссылке, запрос неизвестного идентификатора, пустое тело, неподдерживаемый метод, запрос без идентификатора — везде получил ожидаемые коды ответа. Плюс go fmt и go vet не нашли по коду вопросов, а нагрузочный прогон под -race не выявил гонок данных.

Что дальше

dev1 — это фундамент: сервис умеет главное, ради чего он вообще существует, — сокращать и раскрывать ссылки. Дальше в планах покрыть код тестами, разобраться с конфигурацией через флаги и переменные окружения и, возможно, попробовать сторонний роутер вместо голого net/http. Но это уже тема для dev2.

Ознакомиться с dev1 можно по ссылке

Расскажите в комментариях, как бы вы подошли к первой версии такого сервиса — оставили бы мапу в памяти или сразу взяли бы что-то более серьёзное для хранилища?

Понравилась статья? Поделиться с друзьями:
Добавить комментарий

;-) :| :x :twisted: :smile: :shock: :sad: :roll: :razz: :oops: :o :mrgreen: :lol: :idea: :grin: :evil: :cry: :cool: :arrow: :???: :?: :!: