Наконец-то, решился взять себе пет-проект, чтобы не просто читать про 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 можно по ссылке
Расскажи в комментариях, как бы ты подошёл к первой версии такого сервиса — оставил бы мапу в памяти или сразу взял бы что-то более серьёзное для хранилища?