Предисловие. Спасибо за тёплые отзывы о предыдущих статьях цикла. Ваша поддержка – настоящий стимул продолжать дальнейшую работу! Оставляйте мнение и вопросы по пройденному материалу в комментариях – я буду рад любой обратной связи.
Если вы читали предыдущую часть – вы помните, какой серьезный фундамент мы заложили для нашего сервиса. DeadDrop уже научлся:
Принимать и обрабатывать POST-запросы
Парсить файлы
Защищаться от DoS-атак
Хранить данные в потоко-безопасном in-memory хранилище
Мы даже успели написать наши первые утилиты для генерации ID и паролей. Если вы воспользовались подсказками к обновлению Create Handler, которые я привел в итогах предыдущей статьи, то, вероятно, у вас уже удалось протестировать его работу.
Но, не всё так просто… Если вы посмотрите на код нашего CreateHandler, то он вызовет, мягко говоря, смешанные чувства. Сейчас 100+ строк кода обработчика делают всё сразу:
Проверяют метод запроса
Читают файл
Генерируют ID и пароли
Вычисляют время
Логируют результаты
Я перечислил даже не весь перечень его функция, но даже неопытному веб-разработчику это уже покажется грузным. Сейчас наш хэндлер напоминает швейцарский нож, которым пытаются забивать гвозди – вроде и получается, но как-то выглядит странно, да и масштабировать такое будет сложновато.
И это – начало проблем.
Когда наш сервис начнёт разрастаться логичным шагом станет переход к более серьезным технологиям (мы осознаем, что хранение всего подряд в памяти – настоящая роскошь) и что теперь будем делать? Вырывать куски обработчика и переписывать их сначала, обновляя логику?
Ещё один важный момент – безопасность, мы всё ещё храним пароли в открытом виде. Злоумышленник, получивший доступ к нашей памяти (или базе данных), увидит и все «секреты» - все пароли в открытом виде, данные, хранящиеся в ячейках. Это катастрофа для любого сервиса и исправлять это нужно срочно.
Чем будем заниматься?
Сегодня мы превратим эту «поделку» в структурированное и масштабируемое приложение. Учиться будем, как и всегда на практике – Go и net/http:
Будем рефакторить «толстый» обработчик. Пора разделить ответственность: Слой доставки будет только принимать запросы, а бизнес-логика будет вынесена в use case слой
Научимся внедрять зависимости (Dependency Injection), подключим наше хранилище к логике
Будем хэшировать пароли с помощью bcrypt и насовсем забудем о хранении паролей в открытом виде
Это лишь малая часть работы, которую нам предстоит выполнить. К концу статьи мы сделаем код масштабируемым и правильным.
Начнём вместе!
Весь код, как всегда, доступен в GitHub-репозитории проекта (директория для этой статьи — v2 ) https://github.com/Meedoeed/DeadDrop.
Реализуем сценарии использования
Теперь, когда у нас есть хранилище, утилиты и модели настало время объединить всё вместе в нашем первом сценарии использования (use case) – создании секрета.
Но возникает вопрос: где должна располагаться бизнес-логика? До сих пор мы писали весь код прямо в обработчике (CreateHandler), но это не совсем правильно. Обратите внимание, где лежит наш обработчик: в слое internal/delivery – это слой доставки, его задача – принять запрос, вызвать нужные методы и вернуть ответ. Бизнес-логика – генерация идентификатора, пароля и так далее должна жить в отдельном месте.
Вспомним структуру проекта:
internal/ ├── delivery/ │ └── http/ │ └── handler/ ├── lib/ │ └── generator/ ├── models/ ├── storage/ │ └── in-memory/ └── usecase/
Созданная ранее директория internal/usecase – это как раз то место, где должен находиться полезный функционал нашего приложения. Давайте создадим внутри неё файл secret.go.
Use case: CreateSecret
В этом файле нам нужно описать логику создания секрета. Как она должна выглядеть?
Принимаем «сырые» данные от клиента: сообщение, время жизни, файл (при наличии)
Генерируем ID и надежный пароль с помощью утилит, реализованных выше
Вычисляем точное время истечения срока действия ячейки на основе текущего времени и ttl
Создаем экземпляр структуры models.Secret, заполняя все поля
Сохраняем секрет в хранилище, пользуясь интерфейсом. Обратите внимание! Мы не будем завязываться на конкретной реализации (in-memory или база данных), мы работаем через интерфейс, который всё это в себе объединяет методами.
Возвращаем созданный секрет (или только ID с паролем) обратно в слой доставки, чтобы обработчик мог отдать пользователю результат
Как нам использовать наш интерфейс и не привязываться к реализации?
Когда нашему сценарию использования нужно хранилище (storage) есть два пути
ПЛОХО | Проблема |
| UseCase теперь намертво привязан к in-memory хранилищу. Если захотим перейти на PostgreSQL — придётся переписывать UseCase. |
ХОРОШО | |
| Файл main.go |
| |
Здесь главная идея в следующем:
Внедрение зависимостей — это когда объект говорит: "Дайте мне то, с чем я должен работать", а не "Я сам создам то, с чем буду работать". Как повар в ресторане не выращивает овощи сам, а получает их от поставщиков — так и наш код получает готовые зависимости извне. Это делает код гибким, тестируемым и понятным.
Давайте обозначим, какая логика внутри CreateHandler лишняя и перенесем ее в use case. Посмотрите на код Create Handler и соответствующие комментарии:
func CreateHandler(w http.ResponseWriter, r *http.Request) { // --- СЛОЙ ДОСТАВКИ (HTTP-специфичная логика) --- // Проверка метода — чисто HTTP-овая задача if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Ограничение размера тела — защита на уровне HTTP r.Body = http.MaxBytesReader(w, r.Body, 10<<20) // Парсинг multipart формы — работа с HTTP-запросом if err := r.ParseMultipartForm(20 << 20); err != nil { http.Error(w, "File is too big or invalid form", http.StatusBadRequest) return } // Извлечение данных из формы — чисто HTTP message := r.FormValue("message") ttl := r.FormValue("ttl") var fileData []byte var fileName string var fileExt string // --- ЗДЕСЬ СМЕШАНО: И HTTP, И БИЗНЕС-ЛОГИКА --- if file, fileHeader, err := r.FormFile("file"); err == nil { defer file.Close() // Проверка размера — защита, но уже ближе к бизнесу if fileHeader.Size > 20<<20 { http.Error(w, "file is too big", http.StatusBadRequest) return } // Чтение файла — техническая операция fileData, err = io.ReadAll(file) if err != nil { http.Error(w, "Cannot read file", http.StatusInternalServerError) return } // Санитаризация имени — БИЗНЕС-ЛОГИКА! fileName = filepath.Base(fileHeader.Filename) fileExt = filepath.Ext(fileHeader.Filename) // Проверка MIME-типа — БИЗНЕС-ЛОГИКА (какие файлы разрешены) mime := http.DetectContentType(fileData) allowedExts := map[string]bool{ "image/jpeg": true, "image/png": true, "image/gif": true, "application/pdf": true, "text/plain": true, } if !allowedExts[mime] { http.Error(w, "Invalid file type", http.StatusBadRequest) return } } // --- БИЗНЕС-ЛОГИКА (должна быть в use case) --- id, err := generator.GenerateID(10) if err != nil { http.Error(w, "Cannot generate ID", http.StatusInternalServerError) return } // --- СНОВА СЛОЙ ДОСТАВКИ --- log.Printf("[INFO] POST /create | id=%s file=%s ttl=%s message=%s fileext=%s", id, fileName, ttl, message, fileExt) http.Redirect(w, r, "/", 303) }
Давайте подытожим:
Что должно остаться в обработчике? | Что должно уйти в use case? |
1) Проверка HTTP-метода 2) Ограничение размера тела 3) Парсинг формы 4) Чтение файла 5) Вызов use case’а – должен появиться 6) Формирование HTTP-ответа 7) Логирование на уровне запроса | 1) Генерация ID 2) Генерация пароля 3) Проверка mime-типа 4) Вычисление ExpiresAt по TTL 5) Валидация данных 6) Сохранение хранилища – через интерфейс |
Итак, давайте создадим файл internal/usecase/secret.go
И начнем с описания структуры и функции-конструктора:
package usecase package usecase import ( "deaddrop/internal/storage" ) type SecretUseCase struct { storage storage.Storage // Зависимость от интерфейса, а не конкретной реализации } //функция-конструктор возвращает объект SecretUseCase func NewSecretUseCase(s storage.Storage) *SecretUseCase { return &SecretUseCase{ storage: s, } }
Теперь давайте решим, как мы будем меняться данными между слоем доставки (где лежит наш хэндлер) со слоем сценариев (secret.go). Обратимся к CreateHandler:
log.Printf("[INFO] POST /create | id=%s file=%s ttl=%s message=%s fileext=%s", id, fileName, ttl, message, fileExt)
В логе, по сути, находятся все поля, которые нам нужно отправлять на сохранение: id, file, ttl, message, fileext. Давайте определим структуру запроса для передачи данных:
type CreateSecretRequest struct { Message string TTL string FileData []byte FileName string FileExt string }
В свою очередь, слой сценариев использования должен отдавать обратно какой-то ответ (пользователь в ответ на запрос создание ячейки хочет узнать от неё пароль и id), назовем его CreateSecretResponse и определим соответствующую структуру:
type CreateSecretResponse struct { ID string Password string ExpiresAt time.Time }
Таким образом, взаимодействие слоев между собой в нашей реализации будет иметь следующий вид:

Теперь мы готовы к реализации сценария создания ячейки. Ознакомьтесь с моей реализацией, я вынес сюда те части CreateHandler, которые мы обозначили бизнес-логикой выше, в последствии у вас ещё будет возможность попробовать свои силы в написании собственных use case’ов:
func (uc *SecretUseCase) Create(req *CreateSecretRequest) (*CreateSecretResponse, error) { id, err := generator.GenerateID(10) if err != nil { return nil, errors.New("failed to generate ID: " + err.Error()) } password, err := generator.GeneratePassword(12) if err != nil { return nil, errors.New("failed to generate password: " + err.Error()) } ttlSeconds := 3600 if req.TTL != "" { var parsedTTL int _, err := fmt.Sscanf(req.TTL, "%d", &parsedTTL) if err == nil && parsedTTL > 0 { ttlSeconds = parsedTTL } } expiresAt := time.Now().Add(time.Duration(ttlSeconds) * time.Hour) if len(req.FileData) > 0 { mime := http.DetectContentType(req.FileData) allowedExts := map[string]bool{ "image/jpeg": true, "image/png": true, "image/gif": true, "application/pdf": true, "text/plain": true, } if !allowedExts[mime] { return nil, errors.New("failed to recognize mime-type") } } secret := &models.Secret{ ID: id, Message: req.Message, FileData: req.FileData, FileName: req.FileName, FileExt: req.FileExt, Password: password, // ВАЖНО! Мы пока сохраняем пароль в открытом виде. Это небезопасно! ExpiresAt: expiresAt, } if err := uc.storage.Save(secret); err != nil { return nil, errors.New("failed to save secret: " + err.Error()) } return &CreateSecretResponse{ ID: id, Password: password, ExpiresAt: expiresAt, }, nil }
Остановка и рефакторинг
Посмотрите на реализацию сценария использования, приведенную выше, я специально допустил в ней несколько ошибок, чтобы мы смогли вместе обсудить и исправить их. Это важно и в реальной разработке править код приходится постоянно.
Проблема 1: Нарушение архитектуры
Мы распознаем mime-тип прямо в нашем сценарии использования, но делать так – неправильно. Мы не можем пользоваться методом DetectContentType пакета “net/http” – это создает ненужную зависимость от http-пакета, о котором слой use case знать и вовсе не должен. Что будет, если мы захотим из формата web-приложения перейти, к примеру, к формату телеграм-бота или консольной утилиты? Тогда нам придется тащить за собой весь пакет net/http.
Решение: создать отдельную утилиту для определения mime-типа
Создаём файл internal/lib/mime/mime.go
internal/lib/mime/mime.go — утилита для работы с MIME-типами
package mime import ( "net/http" // Здесь зависимость от http допустима, потому что это утилитный пакет ) var allowedMimeTypes = map[string]bool{ "image/jpeg": true, "image/png": true, "image/gif": true, "application/pdf": true, "text/plain": true, } // IsMimeTypeAllowed проверяет, разрешён ли данный MIME-тип func IsMimeTypeAllowed(mimeType string) bool { return allowedMimeTypes[mimeType] } // DetectFromData определяет MIME-тип по содержимому файла func DetectFromData(data []byte) string { if len(data) == 0 { return "application/octet-stream" } return http.DetectContentType(data) }
Проблема 2: Мы храним пароли в открытом виде
Это критическая уязвимость! Если мы ничего не изменим, то в случае получения злоумышленником доступа к нашей памяти или же базе данных, он увидит все пароли пользователей. Пароли должны храниться только в захэшированном виде.
Решение: хэширование паролей.
Хэширование – процесс преобразования пароля в строку фиксированной длины, которую практически невозможно обернуть вспять. Когда пользователь снова введет пароль мы обратно превратим его в хэш и сверим между собой не пароли, а эти хэши.

Взгляните, к какому виду преобразует хэш-алгоритм SHA-256 базовый пароль «QWERTY123», давайте теперь посмотрим, каким будет хэш для очень похожего пароля: «QWERTY122»

Как вы можете заметить, они друг на друга совсем не похожи – это одно из ключевых свойств хэшей. Хэши схожих оригинальных последовательностей очень далеки друг от друга.
Как вы могли догадаться, под капотом это устроено сложно и реализация хорошего, правильного хэш-алгоритма – тема для отдельной статьи и к тому же, его реализация в рамках веб-сервиса будет нерациональной. В Go есть общепринятый пакет, который предоставляет возможности хэширования и делает это максимально безопасно и удобно. Речь идет о пакете bcrypt (возможно, вы с ним уже встречались при разработке других языков: C++, C# или Python).
bcrypt – сторонний пакет и нам следует научиться импортировать такие пакеты в наши проекты, они зачастую очень полезны!
Добавить пакет можно с помощью консольной утилиты:
go get <адрес_пакета>
Адресом может служить как github-репозиторий отдельного пакета, так и адрес пакета на официальной странице go, как в случае нашего пакета для хэширования. Его адрес: golang.org/x/crypto/bcrypt
Установите пакет в свой проект DeadDrop.

Пакет успешно добавлен и теперь мы можем им пользоваться.
Мы уже знаем, что завязываться на конкретных зависимостях не стоит, что это для нас значит? Именно, хэширование паролей мы тоже вынесем в отдельную утилиту, чтобы не тянуть в use case зависимость от bcrypt пакета.
Давайте перед реализацией я расскажу вам о методах bcrypt, которые понадобятся нам при реализации новой утилиты.
Для работы с bcrypt нам будет достаточно всего две основные функции:
GenerateFromPassword – принимает на входе слайс байт (наш пароль) и стоимость (cost) хэширования. Возвращает захэшированный пароль (тоже слайс байт) или ошибку. Логичный вопрос: «Что за стоимость?». Стоимость – это просто число, которое определяет насколько «тяжелым» будет процесс вычисления хэша. Чем тяжелее – тем дольше злоумышленник будет его подбирать, но вместе с этим и ресурсов ваш сервер на генерацию хэша будет тратить больше, а тратить он будет их при каждой аутентификации (чтобы сверить хэш в хранилище с хэшем того, что пользователь дал ему в форме на сайте). Здесь важно уметь найти баланс. Обратите внимание на константу bcrypt.DefaultCost предоставляемую пакетом, она отсылает нас к числу 10 – стандартной стоимости хэша – это хороший компромисс для начала!
CompareHashAndPassword – принимает хэш и пароль в открытом виде (слайс байт). Функция сама хэширует пароль и сравнивает его с хэшем. Возвращает nil при совпадении пароля с хэшем и ошибку, если они не совпали.
Теперь ваших знаний более чем достаточно для реализации утилиты для хэширования, напишите готовую реализацию (функции создания хэша из пароля и сверки пароля с хэшем) в специально отведенном файле, который создайте по адресу internal/lib/hash/hash.go.
Предлагаю ознакомиться с моим вариантом ниже:
internal/lib/hash/hash.go — утилита для хэширования паролей с bcrypt
package hash import ( "fmt" "golang.org/x/crypto/bcrypt" ) func HashPassword(password string) (string, error) { bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return "", fmt.Errorf("failed to hash password: %w", err) } return string(bytes), nil } func CheckPasswordHash(password, hash string) bool { err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) return err == nil }
Теперь настал момент внедрить наши новые утилиты в use case и на этом завершить его рефакторинг. Вернемся к internal/usecase/secret.go и обновим функцию secret.go:
internal/usecase/secret.go — финальная версия Use Case с хэшированием и MIME-проверкой
package usecase import ( "errors" "fmt" "time" "deaddrop/internal/lib/generator" "deaddrop/internal/lib/hash" "deaddrop/internal/lib/mime" "deaddrop/internal/models" "deaddrop/internal/storage" ) type SecretUseCase struct { storage storage.Storage } type CreateSecretRequest struct { Message string TTL string FileData []byte FileName string FileExt string } type CreateSecretResponse struct { ID string Password string ExpiresAt time.Time } func NewSecretUseCase(s storage.Storage) *SecretUseCase { return &SecretUseCase{ storage: s, } } func (uc *SecretUseCase) Create(req *CreateSecretRequest) (*CreateSecretResponse, error) { id, err := generator.GenerateID(10) if err != nil { return nil, fmt.Errorf("failed to generate ID: %w", err) } rawPassword, err := generator.GeneratePassword(12) if err != nil { return nil, fmt.Errorf("failed to generate password: %w", err) } hashedPassword, err := hash.HashPassword(rawPassword) if err != nil { return nil, fmt.Errorf("failed to hash password: %w", err) } ttlSeconds := 3600 // значение по умолчанию - 1 час if req.TTL != "" { var parsedTTL int _, err := fmt.Sscanf(req.TTL, "%d", &parsedTTL) if err == nil && parsedTTL > 0 { ttlSeconds = parsedTTL } } expiresAt := time.Now().Add(time.Duration(ttlSeconds) * time.Hour) if len(req.FileData) > 0 { detectedMime := mime.DetectFromData(req.FileData) if !mime.IsMimeTypeAllowed(detectedMime) { return nil, errors.New("file type not allowed") } } secret := &models.Secret{ ID: id, Message: req.Message, FileData: req.FileData, FileName: req.FileName, FileExt: req.FileExt, Password: hashedPassword, // Сохраняем ТОЛЬКО хэш! ExpiresAt: expiresAt, } if err := uc.storage.Save(secret); err != nil { return nil, fmt.Errorf("failed to save secret: %w", err) } // возвращаем ответ с raw-паролем (он нужен пользователю для доступа) return &CreateSecretResponse{ ID: id, Password: rawPassword, // Отдаём пользователю оригинальный пароль ExpiresAt: expiresAt, }, nil }
Ключевые изменения:
Хэширование пароля: пароль пользователя хранится только в захэшированном виде. Возвращаем клиенту в ответе от сервера оригинальный пароль, так как он нужен для доступа. В хранилище только хэши.
Вынос Mime-логики: используем утилитный пакет mime вместо прямого вызова http.DetectContentType
Мы успешно завершили рефакторинг: теперь наш код архитектурно верен и безопасен. Логика работы в нашем сценарии использования четко разделена. Как уже говорилось выше, в разработке постоянно приходится что-то править и нужно не бояться потратить на это время, в последствии это позволяет намного проще отлаживать код или адаптировать его. Наконец, давайте обновим наш CreateHandler.
Правим CreateHandler
Теперь, когда у нас есть полноценный use case, мы можем значительно упростить наш обработчик. Важно помнить то, для чего мы всё это делали – всё это время мы разделяли логику и, наконец, задачи обработчика упираются в:
Получить и подготовить данные из формы
Вызвать соответствующий сценарий использования с этими данными
Обработать результат и вернуть клиенту
Давайте обдумаем, что нужно будет поменять в CreateHandler, чтобы учесть все обновления и изменения:
Шаг 1: Что нужно делать в CreateHandler?
Нам нужно читать и обрабатывать данные из формы. Мы уже умеем это делать, просто оставим уже существующие участки кода где мы парсим текстовые поля и читаем файл.
Вызвать соответствующий сценарий использования:
Обратите внимание! Для этого дать обработчику доступ к конкретному экземпляру SecretUseCase. Верно говоря, вы должны «внедрить» зависимость в наш хэндлер. Для этого предлагаю вспомнить материал первой статьи (https://habr.com/ru/articles/981356/), где мы обсуждали, что хэндлером может являться структура, удовлетворяющая интерфейсу Handler. Он удовлетворяется в случае наличия у функции метода: ServeHTTP(http.ResponseWriter, *http.Request). Думаю, вы поняли, к чему я веду. Верным решением с внедрением конкретного экземпляра SecretUseCase в наш CreateHandler будет создание структуры следующего вида:
type Handler struct {secretUseCase *usecase.SecretUseCase} // Хэндлер для работы должен иметь метод ServeHTTP, перенесем всю текущую логику из нашего CreateHandler в этот метод«Общаться» с use case’ом наш хэндлер должен путем отправки ему структур CreateSecretRequest, созданных нами выше.
Обработать результат и вернуть ответ пользователю:
Когда сценарий использования успешно отработал и секрет создан он возвращает CreateSecretResponse(напомню, он содержит ID, сырой пароль и время истечения срока ячейки), в случае отсутствия ошибок необходимо каким-то образом эти данные сообщить создателю ячейки и здесь самым правильным и безопасным решением будет перенаправление клиента на одноразовую страницу, которая покажет всю эту информацию

Третий пункт в последствии мы реализуем в виде отдельного шаблона, на подобие того, как мы делали в первой статье цикла, пока ограничимся методом Write, применимым к http.ResponseWriter нашего хэндлера.
Рефакторим код CreateHandler
Вы можете попробовать самостоятельно обновить код для CreateHandler и в случае трудностей обратиться к моей реализации, приведенной ниже, в ней приведены комментарии к наиболее сложным этапам.
internal/delivery/http/handler/create.go — обновлённый хэндлер создания секрета
package handler import ( "deaddrop/internal/usecase" "fmt" "io" "log" "net/http" "path/filepath" "time" ) type CreateHandler struct { secretUseCase *usecase.SecretUseCase } // NewCreateHandler - конструктор для нашего хэндлера. Мы внедрили зависимость от uc func NewCreateHandler(uc *usecase.SecretUseCase) *CreateHandler { return &CreateHandler{ secretUseCase: uc, } } func (h *CreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Здесь проверяем метод запроса - это слой доставки if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } //Ограничение размера тела запроса для защиты от DoS-атак. Мы обсуждали это выше r.Body = http.MaxBytesReader(w, r.Body, 10<<20) // Парсим multipart форму и ограничиваем размер if err := r.ParseMultipartForm(20 << 20); err != nil { http.Error(w, "File is too big or invalid form", http.StatusBadRequest) return } // Достаем данные из формы, готовимся к передаче в uc message := r.FormValue("message") ttl := r.FormValue("ttl") var fileData []byte var fileName string var fileExt string // Если файл в наличии пытаемся его обработать if file, fileHeader, err := r.FormFile("file"); err == nil { defer file.Close() // Прооверяем размер файла через fileHeader.Size if fileHeader.Size > 20<<20 { http.Error(w, "File is too big", http.StatusBadRequest) return } // Читаем содержимое файла fileData, err = io.ReadAll(file) if err != nil { http.Error(w, "Cannot read file", http.StatusInternalServerError) return } // Проверка чтобы имя файла не включало в себя пути, оно не должно иметь вид "/home/.." или "./..." - это опасно fileName = filepath.Base(fileHeader.Filename) fileExt = filepath.Ext(fileHeader.Filename) } // Готовим структуру для отправки в uc createReq := &usecase.CreateSecretRequest{ Message: message, TTL: ttl, FileData: fileData, FileName: fileName, FileExt: fileExt, } // Отправляем данные в uc-слой resp, err := h.secretUseCase.Create(createReq) if err != nil { log.Printf("[ERROR] Failed to create secret: %v", err) http.Error(w, "Failed to create secret: "+err.Error(), http.StatusInternalServerError) return } // Шаг 7: Логирование успешного создания log.Printf("[INFO] POST /create | id=%s ttl=%s message='%s' file=%s", resp.ID, ttl, message, fileName) // Перенаправляем пользователя на страницу с результатом // Позже здесь будет редирект на /secret/{id}, а пароль нужно будет показать на отдельной странице. // Пока же мы просто вернём ID и пароль в теле ответа для наглядности. w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusCreated) responseHTML := fmt.Sprintf(` <h1>Ячейка создана!</h1> <p><strong>ID:</strong> %s</p> <p><strong>Пароль:</strong> <code>%s</code></p> <p><strong>Исчезнет:</strong> %s</p> <p>Сохраните пароль. Он исчезнет при перезагрузке</p> <a href="/">На главную</a> `, resp.ID, resp.Password, resp.ExpiresAt.Format(time.RFC1123)) w.Write([]byte(responseHTML)) }
Посмотрите, не вызвало ли обновление CreateHandler никаких ошибок внутри проекта?
Файл router.go теперь сообщает нам о наличии такой ошибки внутри себя:
handler.CreateHandler (type) is not an expression
на строке
mux.HandleFunc("/create", handler.CreateHandler)
Это происходит потому что теперь CreateHandler – не функция. Мы сделали хэндлер структурой с зависимостями, а метод HandleFunc ждёт аргументом функцию-обработчик. Как мы говорили выше, это не проблема, если обработчик – структура покрывающая интерфейс Handler (структура, имеющая метод ServeHTTP), то она может быть зарегистрирована в мультиплексор методом mux.Handle(“<маршрут>”, <экземпляр структуры>)
Обновленный код для router.go имеет вид:
func RegisterRoutes(mux *http.ServeMux, secretUC *usecase.SecretUseCase) { mux.Handle("/static/", http.StripPrefix("/static/", handler.StaticHandler)) mux.HandleFunc("/", handler.HomeHandler) createHandler := handler.NewCreateHandler(secretUC) mux.Handle("/create", createHandler) }
Можете заметить, что теперь мы передаем в функцию RegisterRoutes указатель на экземпляр SecretUseCase – это нужно для создания конструктором (NewCreateHandler) экземпляра хэндлера. Для нас важно, что теперь в RegisterRoutes нужно передавать usecase.SecretUseCase, создавать и передавать его будем там, где регистрируем машруты – прямо в app.go. Обратите внимание, он сам сообщает нам о недостающем аргументе для вызова регистратора маршрутов.

Давайте сделаем всё, что указано выше. Это буквально последний шаг перед тем, как мы увидим результаты нашей долгой работы! Создайте конструкторами экземпляр storage и secretUseCase и передайте последний в RegisterRoutes вторым аргументом
Обновленный deaddrop/internal/app/app.go
func Run() { mux := http.NewServeMux() storage := inmemory.NewStorage() secretUseCase := usecase.NewSecretUseCase(storage) deliveryHttp.RegisterRoutes(mux, secretUseCase) handler := middleware.Chain( mux, middleware.RecoveryMiddleware, middleware.LoggingMiddleware, ) http.ListenAndServe(":8080", handler) }
Первые результаты работы
Собираем и запускаем наш сервис, переходим на localhost:8080, заполняем форму, отправляем и, наконец, смотрим на результат!

Первые результаты достигнуты, мы успешно создаём ячейки, сохраняя их в in-memory хранилище, логика приложения разделена. Теперь мы сможем легко масштабировать DeadDrop, ведь каждый слой берёт на себя только то, за что он должен отвечать:
Слой доставки – принимает HTTP-запросы, парсит форму, валидирует базовые параметры (метод, размер тела), вызывает соответствующий use case и формирует HTTP-ответ (шаблон, редирект или ошибку).
Слой use case - содержит бизнес-логику: генерирует ID и пароль, хэширует пароль, вычисляет время жизни, проверяет MIME-типы, создает модель секрета и сохраняет её через интерфейс хранилища, не заботясь о деталях реализации самого хранилища.
Шаблон для ответа от обработчика создания ячейки
В дополнение, реализуем шаблон для ответа сервера клиенту при создании ячейки. В первой статье (https://habr.com/ru/articles/981356/) мы подробно обсудили, как делать шаблоны и интегрировать их в handler, повторив все те же шаги – заменим w.Write в конце кода обработчика на шаблон result.html
Вы можете вспомнить как писать шаблоны и интегрировать их в код ознакомившись с коротким планом-заметкой, который я составил для вашего удобства (Обратите внимание! Есть важное уточнение относительно создания шаблонов на шаге 3, ознакомьтесь с ним!):
Шаг 1: Создаем файл шаблона
По пути internal/assets/templates создаем result.html и в нём создаем шаблон. Не забываем про блоки {{ define "content" }} и {{ end }}
Шаг 2: Убеждаемся что шаблоны подключены к embedFS
Проверьте наличие//go:embed templates/*.html
var templateFiles embed.FS
по пути internal/assets/templates.go
Важно!
Шаг 3: Создаем для шаблона глобальную переменную по образцу HomeTemplate.
Важно! Ранее в переменной HomeTemplate мы парсили все шаблоны:template.ParseFS(templateFiles, "templates/*.html")
Это будет проблемой и помешает нам встраивать новые. Впредь, будем указывать для парсинга конкретные шаблоны, чтобы не между ними не случалось конфликтов, это делается так:template.ParseFS(templateFiles, "templates/layout.html", "templates/home.html"),
для HomeTemplate и так:template.ParseFS(templateFiles, "templates/layout.html", "templates/result.html"),
для шаблона результата
Шаг 4: Определяем структуру данных для передачи в шаблон
Внутри CreateHandler или в отдельном файле models нужна структура с полями к передаче в result.html. Например:
data := struct { ID string Password string ExpiresAt string ID: resp.ID, Password: resp.Password, // сырой пароль, не хэш! ExpiresAt: resp.ExpiresAt.Format(time.RFC1123), }
Шаг 5: Устанавливаем заголовок Content-Type для http.ResponseWriter
Браузер должен чётко знать, что мы отдаём не plain/text, а именно html разметку
Шаг 6: Выполняем шаблон
Используем метод ExecuteTemplate по отношению к assets.ResultTemplate по образцу с домашней страницей
Шаг 7: Убираем старый код
В результате обновлений и легкого рефакторинга на странице /create успешно прогружается такой красивый шаблон в стилистике нашего сервиса:

Если вы хотите сделать такую же страницу или если у вас возникли проблемы с интеграцией шаблона – вы всегда можете обратиться к GitHub репозиторию проекта: https://github.com/Meedoeed/DeadDrop - можете достать отсюда новый style.css и result.html или любые другие необходимые файлы или ознакомиться с особенностями реализации непонятных моментов!
Промежуточный итог по статье «Разбираем net/http на практике. Часть 2.2»
Мы провели фундаментальную работу по превращению нашего рабочего прототипа в архитектурно-целостное приложение. В отличие от первой части, где мы заложили технический фундамент, здесь мы занялись серьезной качественной перестройкой кода.
Что мы сделали в этой части?
Провели архитектурный рефакторинг
Научились внедрять зависимости
Обеспечили безопасность паролей
Привели в порядок маршрутизацию и сборку проекта в соответствии с применением нового слоем use case
Что будет дальше?
Работа над нашим проектом вступает в новую фазу! Теперь, когда у нас появилось безопасное хранилище и проект, благодаря архитектурным обновлениям, готов к масштабированию мы готовы двигаться дальше:
Реализуем получение секретов – напишем новый handler и use case для чтения ячеек
Защитим файлы – научимся отдавать файлы только после аутентификации, с использованием cookie
Реализуем фоновую очистку нашего хранилища в отдельной горутине
Подписывайтесь и следите за обновлениями в блоге! Буду рад обратной связи в комментариях!
Полезные ссылки:
Репозиторий проекта: https://github.com/Meedoeed/DeadDrop
Предыдущая часть цикла (Основы работы с формами и хранилищем)
