Пет-проект на 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.

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

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

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 обрабатывает каждый входящий запрос в отдельной горутине. Если сразу два запроса попадут на запись в одну и ту же мапу — программа упадёт с fatal error: concurrent map writes, и это будет не гипотетический риск, а реальный сценарий при первой же нагрузке. sync.RWMutex решает эту проблему: Lock/Unlock для записи, RLock / RUnlock для чтения, чтобы несколько параллельных чтений не блокировали друг друга.

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

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

1..40 | ForEach-Object { Start-Job { curl.exe -s -o $null -X POST http://localhost:8080/ -d "https://lessgo.ru" }} | Out-Null 
Get-Job | Wait-Job | Out-Null

Если за время теста в окне с сервером не появилось WARNING: DATA RACE — гонок нет.

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

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

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

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

Хендлеры

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

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:

func handleShort(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)
}

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

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

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

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

func run() error {
    return http.ListenAndServe(baseURL, nil)
}

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

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

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

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

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

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

// To run 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 handleShort(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.RawURLEncodeToString(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: :???: :?: :!: