DevGang
Авторизоваться

Разработка веб-приложения на Go с использованием многоуровневой архитектуры 

Написание веб-сервера с использованием Go очень просто. Но проблема возникает, когда код должен быть тестируемым, структурированным, чистым и обслуживаемым.

Ниже мы пишем простой сервер, который хранит и извлекает данные из MySQL.

main.go
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())
      }
   }
}

Код выше нуждается в уточнении. Почему?

  1. Одна функция делает больше, чем одно действие. Когда это приложение растет, им становится трудно управлять и тестировать.
  2. Нет способа протестировать обработчики без базы данных. Хранилище данных не внедряется и является глобальной переменной, что затрудняет моделирование базы данных в обработчике.

В идеальном случае обработчик не должен зависеть от базового хранилища данных. Хороший подход к решению этой проблемы - следовать многоуровневой архитектуре. Каждый слой будет делать только одно действие.

Многоуровневая архитектура

Три независимых уровня - это доставка, сценарий использования и хранилище данных.

Уровень доставки:

Уровень доставки будет получать запрос и анализировать все, что требуется из запроса. Он вызывает уровень варианта использования, гарантирует, что ответ имеет требуемый формат, и записывает его в средство записи ответов.

Слой варианта использования

Уровень прецедента выполняет бизнес-логику, необходимую для приложения. Этот слой будет взаимодействовать со слоем хранилища данных. Он берет все, что ему нужно от уровня доставки, а затем вызывает уровень хранилища данных. До и после вызова уровня хранилища данных он применяет необходимую бизнес-логику.

Уровень хранилища данных

В хранилище данных хранятся данные. Это может быть любое хранилище данных. Уровень варианта использования является единственным уровнем, который связывается с хранилищем данных. Таким образом, каждый слой может быть проверен независимо друг от друга.

Поскольку каждый уровень не зависит друг от друга, если приложение растет с поддержкой 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);

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

entities/animal.go
package entities

type Animal struct {
     ID   int
     Name string
     Age  int
}
driver/mysql.go
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.

datastore/interface.go
package datastore

import "web-app/entities"

type Animal interface {
    Get(id int) ([]entities.Animal, error)
    Create(entities.Animal) (entities.Animal, error)
}
datastore/animal/mysql.go
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
}
datastore/animal/mysql_test.go
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.

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

delivery/animal/http.go
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)
}

Этот обработчик может быть протестирован независимо от хранилища данных. Вы можете смоделировать ответы из хранилища данных и написать модульные тесты, тестируя только обработчики.

delivery/animal/http_test.go
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

Это создаст сервер. Он подключается к базе данных путем извлечения конфигурации из среды, а затем вводит эту базу данных на уровень доставки.

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))
}

Как вы можете видеть, с этой многоуровневой архитектурой становится легко поддерживать код.

  1. Когда есть ошибка, становится легче изолировать и исправить ее.
  2. Когда приложение разрастется и вы решите создать другое хранилище данных, допустим, вы хотите включить кэширование, тогда изменится только слой хранилища данных, не касаясь уровня доставки или варианта использования.
  3. Благодаря независимым слоям написание модульных тестов стало проще.

Источник:

#Golang
Комментарии
Чтобы оставить комментарий, необходимо авторизоваться

Присоединяйся в тусовку

Поделитесь своим опытом, расскажите о новом инструменте, библиотеке или фреймворке. Для этого не обязательно становится постоянным автором.

Попробовать

В этом месте могла бы быть ваша реклама

Разместить рекламу