Разработка веб-приложения на Go с использованием многоуровневой архитектуры
Написание веб-сервера с использованием Go очень просто. Но проблема возникает, когда код должен быть тестируемым, структурированным, чистым и обслуживаемым.
Ниже мы пишем простой сервер, который хранит и извлекает данные из MySQL.
package main
import (
"bytes"
"net/http"
"net/http/httptest"
"os"
"reflect"
"testing"
)
func Test_Handler(t *testing.T) {
// initializing db
conf := mysqlConfig{
host: os.Getenv("SQL_HOST"),
user: os.Getenv("SQL_USER"),
password: os.Getenv("SQL_PASSWORD"),
port: os.Getenv("SQL_PORT"),
db: os.Getenv("SQL_DB"),
}
var err error
db, err = connectToMySQL(conf)
if err != nil {
t.Errorf("could not connect to sql, err:%v", err)
}
testcases := []struct {
// input
method string
body []byte
// output
expectedStatusCode int
expectedResponse []byte
}{
{"GET", nil, http.StatusOK, []byte(`[{"Name":"Hippo","Age":10}]`)},
{"POST", []byte(`{"Name":"Dog","Age":12}`), http.StatusOK, []byte(`success`)},
{"DELETE", nil, http.StatusMethodNotAllowed, nil},
}
for _, v := range testcases {
req := httptest.NewRequest(v.method, "/animal", bytes.NewReader(v.body))
w := httptest.NewRecorder()
h := http.HandlerFunc(handler)
h.ServeHTTP(w, req)
if w.Code != v.expectedStatusCode {
t.Errorf("Expected %v\tGot %v", v.expectedStatusCode, w.Code)
}
expected := bytes.NewBuffer(v.expectedResponse)
if !reflect.DeepEqual(w.Body, expected) {
t.Errorf("Expected %v\tGot %v", expected.String(), w.Body.String())
}
}
}
Код выше нуждается в уточнении. Почему?
- Одна функция делает больше, чем одно действие. Когда это приложение растет, им становится трудно управлять и тестировать.
- Нет способа протестировать обработчики без базы данных. Хранилище данных не внедряется и является глобальной переменной, что затрудняет моделирование базы данных в обработчике.
В идеальном случае обработчик не должен зависеть от базового хранилища данных. Хороший подход к решению этой проблемы - следовать многоуровневой архитектуре. Каждый слой будет делать только одно действие.
Многоуровневая архитектура
Три независимых уровня - это доставка, сценарий использования и хранилище данных.
Уровень доставки:
Уровень доставки будет получать запрос и анализировать все, что требуется из запроса. Он вызывает уровень варианта использования, гарантирует, что ответ имеет требуемый формат, и записывает его в средство записи ответов.
Слой варианта использования
Уровень прецедента выполняет бизнес-логику, необходимую для приложения. Этот слой будет взаимодействовать со слоем хранилища данных. Он берет все, что ему нужно от уровня доставки, а затем вызывает уровень хранилища данных. До и после вызова уровня хранилища данных он применяет необходимую бизнес-логику.
Уровень хранилища данных
В хранилище данных хранятся данные. Это может быть любое хранилище данных. Уровень варианта использования является единственным уровнем, который связывается с хранилищем данных. Таким образом, каждый слой может быть проверен независимо друг от друга.
Поскольку каждый уровень не зависит друг от друга, если приложение растет с поддержкой gRPC, меняется только уровень доставки. Уровень хранилища данных и варианта использования останется прежним. Даже если в хранилище данных произошли изменения, не нужно менять все приложение. Изменится только слой хранилища данных. Таким образом, легко изолировать любые ошибки, поддерживать код и расширять приложение.
Примечание: Если у вас нет бизнес-логики, вы можете пропустить слой вариантов использования и иметь только слой доставки и хранилища данных.
Каждый уровень будет связываться друг с другом через интерфейсы.
Обратитесь к следующей схеме (datastore/animal.sql)
DROP DATABASE IF EXISTS animal;
CREATE DATABASE animal;
USE animal;
CREATE TABLE animals(
id int NOT NULL AUTO_INCREMENT,
name varchar(50),
age int,
PRIMARY KEY(id));
INSERT INTO animals VALUES(1,'Hippo',10);
INSERT INTO animals VALUES(2,'Ele',20);
Для лучшей структуры, включая пакет сущностей и драйвер. Сущности будут поддерживать все структуры, которые представляют каждую сущность в приложении. Драйвер будет иметь функции для подключения к хранилищам данных.
package entities
type Animal struct {
ID int
Name string
Age int
}
package driver
import (
"database/sql"
"fmt"
_ "github.com/go-sql-driver/mysql"
)
type MySQLConfig struct {
Host string
User string
Password string
Port string
Db string
}
// ConnectToMySQL takes mysql config, forms the connection string and connects to mysql.
func ConnectToMySQL(conf MySQLConfig) (*sql.DB, error) {
connectionString := fmt.Sprintf("%v:%v@tcp(%v:%v)/%v", conf.User, conf.Password, conf.Host, conf.Port, conf.Db)
db, err := sql.Open("mysql", connectionString)
if err != nil {
return nil, err
}
return db, nil
}
Структура каталогов:
├── datastore
│ ├── animal
│ │ ├── mysql.go
│ │ ├── mysql_test.go
│ ├── interface.go
│ │
├── delivery
│ ├── animal
│ │ └── http.go
│ │ └── http_test.go
│ │
├── driver
│ ├── mysql.go
│ │
├── entities
│ ├── animal.go
│ │
├── animal.sql
│ │
├── main.go
Уровень хранилища данных:
Этот слой будет использовать MySQL для хранения и извлечения данных, связанных с Animal.
package datastore
import "web-app/entities"
type Animal interface {
Get(id int) ([]entities.Animal, error)
Create(entities.Animal) (entities.Animal, error)
}
package animal
import (
"database/sql"
"web-app/entities"
)
type AnimalStorer struct {
db *sql.DB
}
func New(db *sql.DB) AnimalStorer {
return AnimalStorer{db: db}
}
func (a AnimalStorer) Get(id int) ([]entities.Animal, error) {
var (
rows *sql.Rows
err error
)
if id != 0 {
rows, err = a.db.Query("SELECT * FROM animals where id = ?", id)
} else {
rows, err = a.db.Query("SELECT * FROM animals")
}
if err != nil {
return nil, err
}
defer rows.Close()
var animals []entities.Animal
for rows.Next() {
var a entities.Animal
_ = rows.Scan(&a.ID, &a.Name, &a.Age)
animals = append(animals, a)
}
return animals, nil
}
func (a AnimalStorer) Create(animal entities.Animal) (entities.Animal, error) {
res, err := a.db.Exec("INSERT INTO animals (name,age) VALUES(?,?)", animal.Name, animal.Age)
if err != nil {
return entities.Animal{}, err
}
id, _ := res.LastInsertId()
animal.ID = int(id)
return animal, nil
}
package animal
import (
"database/sql"
"os"
"reflect"
"testing"
"web-app/driver"
"web-app/entities"
)
func initializeMySQL(t *testing.T) *sql.DB {
conf := driver.MySQLConfig{
Host: os.Getenv("SQL_HOST"),
User: os.Getenv("SQL_USER"),
Password: os.Getenv("SQL_PASSWORD"),
Port: os.Getenv("SQL_PORT"),
Db: os.Getenv("SQL_DB"),
}
var err error
db, err := driver.ConnectToMySQL(conf)
if err != nil {
t.Errorf("could not connect to sql, err:%v", err)
}
return db
}
func TestDatastore(t *testing.T) {
db := initializeMySQL(t)
a := New(db)
testAnimalStorer_Get(t, a)
testAnimalStorer_Create(t, a)
}
func testAnimalStorer_Create(t *testing.T, db AnimalStorer) {
testcases := []struct {
req entities.Animal
response entities.Animal
}{
{entities.Animal{Name: "Hen", Age: 1}, entities.Animal{3, "Hen", 1}},
{entities.Animal{Name: "Pig", Age: 2}, entities.Animal{4, "Pig", 2}},
}
for i, v := range testcases {
resp, _ := db.Create(v.req)
if !reflect.DeepEqual(resp, v.response) {
t.Errorf("[TEST%d]Failed. Got %v\tExpected %v\n", i+1, resp, v.response)
}
}
}
func testAnimalStorer_Get(t *testing.T, db AnimalStorer) {
testcases := []struct {
id int
resp []entities.Animal
}{
{0, []entities.Animal{{1, "Hippo", 10}, {2, "Ele", 20}}},
{1, []entities.Animal{{1, "Hippo", 10}}},
}
for i, v := range testcases {
resp, _ := db.Get(v.id)
if !reflect.DeepEqual(resp, v.resp) {
t.Errorf("[TEST%d]Failed. Got %v\tExpected %v\n", i+1, resp, v.resp)
}
}
}
Уровень доставки
Это получит HTTP-запрос t
и подтвердит фильтр для запроса GET и подтвердит тело запроса в запросе POST.
Этим слоям необходимо внедрить слой хранилища, поскольку он связывается со слоем хранилища данных для хранения и извлечения данных.
package animal
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"strconv"
"web-app/datastore"
"web-app/entities"
)
type AnimalHandler struct {
datastore datastore.Animal
}
func New(animal datastore.Animal) AnimalHandler {
return AnimalHandler{datastore: animal}
}
func (a AnimalHandler) Handler(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
a.get(w, r)
case http.MethodPost:
a.create(w, r)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
func (a AnimalHandler) get(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id")
i, err := strconv.Atoi(id)
if err != nil {
_, _ = w.Write([]byte("invalid parameter id"))
w.WriteHeader(http.StatusBadRequest)
return
}
resp, err := a.datastore.Get(i)
if err != nil {
_, _ = w.Write([]byte("could not retrieve animal"))
w.WriteHeader(http.StatusInternalServerError)
return
}
body, _ := json.Marshal(resp)
_, _ = w.Write(body)
}
func (a AnimalHandler) create(w http.ResponseWriter, r *http.Request) {
var animal entities.Animal
body, _ := ioutil.ReadAll(r.Body)
err := json.Unmarshal(body, &animal)
if err != nil {
fmt.Println(err)
_, _ = w.Write([]byte("invalid body"))
w.WriteHeader(http.StatusBadRequest)
return
}
resp, err := a.datastore.Create(animal)
if err != nil {
_, _ = w.Write([]byte("could not create animal"))
w.WriteHeader(http.StatusInternalServerError)
return
}
body, _ = json.Marshal(resp)
_, _ = w.Write(body)
}
Этот обработчик может быть протестирован независимо от хранилища данных. Вы можете смоделировать ответы из хранилища данных и написать модульные тесты, тестируя только обработчики.
package animal
import (
"bytes"
"errors"
"net/http"
"net/http/httptest"
"reflect"
"testing"
"web-app/entities"
)
func TestAnimalHandler_Handler(t *testing.T) {
testcases := []struct {
method string
expectedStatusCode int
}{
{"GET", http.StatusOK},
{"POST", http.StatusOK},
{"DELETE", http.StatusMethodNotAllowed},
}
for _, v := range testcases {
req := httptest.NewRequest(v.method, "/animal", nil)
w := httptest.NewRecorder()
a := New(mockDatastore{})
a.Handler(w, req)
if w.Code != v.expectedStatusCode {
t.Errorf("Expected %v\tGot %v", v.expectedStatusCode, w.Code)
}
}
}
func TestAnimalGet(t *testing.T) {
testcases := []struct {
id string
response []byte
}{
{"1", []byte("could not retrieve animal")},
{"1a", []byte("invalid parameter id")},
{"2", []byte(`[{"ID":2,"Name":"Dog","Age":8}]`)},
{"0", []byte(`[{"ID":1,"Name":"Ken","Age":23},{"ID":2,"Name":"Dog","Age":8}]`)},
}
for i, v := range testcases {
req := httptest.NewRequest("GET", "/animal?id="+v.id, nil)
w := httptest.NewRecorder()
a := New(mockDatastore{})
a.get(w, req)
if !reflect.DeepEqual(w.Body, bytes.NewBuffer(v.response)) {
t.Errorf("[TEST%d]Failed. Got %v\tExpected %v\n", i+1, w.Body.String(), string(v.response))
}
}
}
func TestAnimalPost(t *testing.T) {
testcases := []struct {
reqBody []byte
respBody []byte
}{
{[]byte(`{"Name":"Hen","Age":12}`), []byte(`could not create animal`)},
{[]byte(`{"Name":"Maggie","Age":10}`), []byte(`{"ID":12,"Name":"Maggie","Age":10}`)},
{[]byte(`{"Name":"Maggie","Age":"10"}`), []byte(`invalid body`)},
}
for i, v := range testcases {
req := httptest.NewRequest("GET", "/animal", bytes.NewReader(v.reqBody))
w := httptest.NewRecorder()
a := New(mockDatastore{})
a.create(w, req)
if !reflect.DeepEqual(w.Body, bytes.NewBuffer(v.respBody)) {
t.Errorf("[TEST%d]Failed. Got %v\tExpected %v\n", i+1, w.Body.String(), string(v.respBody))
}
}
}
type mockDatastore struct{}
func (m mockDatastore) Get(id int) ([]entities.Animal, error) {
if id == 1 {
return nil, errors.New("db error")
} else if id == 2 {
return []entities.Animal{{2, "Dog", 8}}, nil
}
return []entities.Animal{{1, "Ken", 23}, {2, "Dog", 8}}, nil
}
func (m mockDatastore) Create(animal entities.Animal) (entities.Animal, error) {
if animal.Age == 12 {
return entities.Animal{}, errors.New("db error")
}
return entities.Animal{12, "Maggie", 10}, nil
}
main.go
Это создаст сервер. Он подключается к базе данных путем извлечения конфигурации из среды, а затем вводит эту базу данных на уровень доставки.
package main
import (
"fmt"
"log"
"net/http"
"os"
"web-app/datastore/animal"
handlerAnimal "web-app/delivery/animal"
"web-app/driver"
)
func main() {
// get the mysql configs from env:
conf := driver.MySQLConfig{
Host: os.Getenv("SQL_HOST"),
User: os.Getenv("SQL_USER"),
Password: os.Getenv("SQL_PASSWORD"),
Port: os.Getenv("SQL_PORT"),
Db: os.Getenv("SQL_DB"),
}
var err error
db, err := driver.ConnectToMySQL(conf)
if err != nil {
log.Println("could not connect to sql, err:", err)
return
}
datastore := animal.New(db)
handler := handlerAnimal.New(datastore)
http.HandleFunc("/animal", handler.Handler)
fmt.Println(http.ListenAndServe(":9000", nil))
}
Как вы можете видеть, с этой многоуровневой архитектурой становится легко поддерживать код.
- Когда есть ошибка, становится легче изолировать и исправить ее.
- Когда приложение разрастется и вы решите создать другое хранилище данных, допустим, вы хотите включить кэширование, тогда изменится только слой хранилища данных, не касаясь уровня доставки или варианта использования.
- Благодаря независимым слоям написание модульных тестов стало проще.