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

Структурирование вашего приложения Golang: плоская структура против многоуровневой архитектуры

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

Golang - относительно простой язык, в котором нет мнения о том, как должны быть структурированы приложения. В этой статье мы рассмотрим два основных способа структурирования приложения Go.

Прежде чем мы продолжим, важно отметить, что ни одна структура не идеальна для всех приложений. Некоторые из того, что мы рассмотрим, может не подходить для вашей библиотеки или проекта. Однако вы должны понимать, что доступно для использования, чтобы вы могли легко решить, как лучше всего создать свое приложение.

Создание приложения Golang с плоской структурой

Этот метод структурирования проектов позволяет хранить все файлы и пакеты в одном каталоге.

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

flat_app/
  main.go
  lib.go
  lib_test.go
  go.mod
  go.sum

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

Эту структуру лучше всего использовать для создания библиотек, простых сценариев или простых приложений CLI. HttpRouter, широко используемая библиотека маршрутизации для создания API-интерфейсов, использует аналогичную плоскую структуру.

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

В идеале при начальной загрузке приложения следует использовать плоскую структуру. Как только вы почувствуете себя некомфортно из-за беспорядка, вы можете перейти на любую другую структуру проекта.

Создание простого API с использованием плоской структуры

Чтобы продемонстрировать плоскую структуру, давайте создадим API для приложения создания заметок.

Создайте новый каталог для этого проекта, запустив:

mkdir notes_api_flat

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

Теперь инициализируйте проект:

go mod init github.com/username/notes_api_flat

Это приложение позволит пользователям хранить заметки. Мы будем использовать SQLite3 для хранения заметок и Gin для маршрутизации. Запустите приведенный ниже фрагмент, чтобы установить их:

go get github.com/mattn/go-sqlite3
go get github.com/gin-gonic/gin

Затем создайте следующие файлы:

  1. main.go: точка входа в приложение
  2. models.go: управляет доступом к базе данных
  3. migration.go: управляет созданием таблиц

После их создания структура должна выглядеть так:

notes_api_flat/
  go.mod
  go.sum
  go.main.go
  migration.go
  models.go

migration.go

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

package main

import (
  "database/sql"
  "log"
)

const notes = `
  CREATE TABLE IF NOT EXISTS notes (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title VARCHAR(64) NOT NULL,
    body MEDIUMTEXT NOT NULL,
    created_at TIMESTAMP NOT NULL,
    updated_at TIMESTAMP NOT NULL
  )
`

func migrate(dbDriver *sql.DB) {
  statement, err := dbDriver.Prepare(notes)
  if err == nil {
    _, creationError := statement.Exec()
    if creationError == nil {
      log.Println("Table created successfully")
    } else {
      log.Println(creationError.Error())
    }
  } else {
    log.Println(err.Error())
  }
}

В приведенном выше фрагменте мы объявляем пакет main. Обратите внимание, что мы не можем настроить его как нечто отличное от того, что было бы в main.go, поскольку они находятся в одном каталоге. Следовательно, все, что делается в каждом файле, будет доступно глобально, потому что все файлы находятся в одном пакете.

Обратите внимание, что мы импортировали пакеты, которые потребуются для взаимодействия с SQL, а также пакет журнала для регистрации любых ошибок, которые могут произойти.

Далее, у нас есть SQL запрос, который создает таблицу примечания со следующими полями: idtitlebodycreated_at и updated_at.

Наконец, мы определили функцию migrate, которая выполняет запрос, который был написан выше, и печатает все ошибки, возникающие в процессе.

models.go

Добавьте следующее в models.go:

package main

import (
  "log"
  "time"
)

type Note struct {
  Id        int       `json:"id"`
  Title     string    `json:"title"`
  Body      string    `json:"body"`
  CreatedAt time.Time `json:"created_at"`
  UpdatedAt time.Time `json:"updated_at"`
}

func (note *Note) create(data NoteParams) (*Note, error) {
  var created_at = time.Now().UTC()
  var updated_at = time.Now().UTC()
  statement, _ := DB.Prepare("INSERT INTO notes (title, body, created_at, updated_at) VALUES (?, ?, ?, ?)")
  result, err := statement.Exec(data.Title, data.Body, created_at, updated_at)
  if err == nil {
    id, _ := result.LastInsertId()
    note.Id = int(id)
    note.Title = data.Title
    note.Body = data.Body
    note.CreatedAt = created_at
    note.UpdatedAt = updated_at
    return note, err
  }
  log.Println("Unable to create note", err.Error())
  return note, err
}

func (note *Note) getAll() ([]Note, error) {
  rows, err := DB.Query("SELECT * FROM notes")
  allNotes := []Note{}
  if err == nil {
    for rows.Next() {
      var currentNote Note
      rows.Scan(
        &currentNote.Id,
        &currentNote.Title,
        &currentNote.Body,
        &currentNote.CreatedAt,
        &currentNote.UpdatedAt)
      allNotes = append(allNotes, currentNote)
    }
    return allNotes, err
  }
  return allNotes, err
}

func (note *Note) Fetch(id string) (*Note, error) {
  err := DB.QueryRow(
    "SELECT id, title, body, created_at, updated_at FROM notes WHERE id=?", id).Scan(
    &note.Id, &note.Title, &note.Body, &note.CreatedAt, &note.UpdatedAt)
  return note, err
}

model содержит определение структуры заметки и три метода, которые позволяют заметке взаимодействовать с базой данных. Структура заметки содержит все данные, которые могут быть в заметке и которые должны быть синхронизированы со столбцами в базе данных.

Метод create отвечает за создание новой заметки и возвращает вновь созданную заметку и все ошибки, возникающие в процессе.

Метод getAll получает все записи в базе данных как часть и возвращает его с ошибками, которые возникают в процессе.

Метод Fetch получает определенную заметку по ее id.

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

API на Go

Последняя часть, оставшаяся в API - это маршрутизация. Измените main.go, чтобы включить следующий код:

package main

import (
  "database/sql"
  "log"
  "net/http"
  "github.com/gin-gonic/gin"
  _ "github.com/mattn/go-sqlite3"
)

// Create this to store instance to SQL
var DB *sql.DB
func main() {
  var err error
  DB, err = sql.Open("sqlite3", "./notesapi.db")
  if err != nil {
    log.Println("Driver creation failed", err.Error())
  } else {
    // Create all the tables
    migrate(DB)
    router := gin.Default()
    router.GET("/notes", getAllNotes)
    router.POST("/notes", createNewNote)
    router.GET("/notes/:note_id", getSingleNote)
    router.Run(":8000")
  }
}

type NoteParams struct {
  Title string `json:"title"`
  Body  string `json:"body"`
}

func createNewNote(c *gin.Context) {
  var params NoteParams
  var note Note
  err := c.BindJSON(&params)
  if err == nil {
    _, creationError := note.create(params)
    if creationError == nil {
      c.JSON(http.StatusCreated, gin.H{
        "message": "Note created successfully",
        "note":    note,
      })
    } else {
      c.String(http.StatusInternalServerError, creationError.Error())
    }
  } else {
    c.String(http.StatusInternalServerError, err.Error())
  }
}

func getAllNotes(c *gin.Context) {
  var note Note
  notes, err := note.getAll()
  if err == nil {
    c.JSON(http.StatusOK, gin.H{
      "message": "All Notes",
      "notes":   notes,
    })
  } else {
    c.String(http.StatusInternalServerError, err.Error())
  }
}

func getSingleNote(c *gin.Context) {
  var note Note
  id := c.Param("note_id")
  _, err := note.Fetch(id)
  if err == nil {
    c.JSON(http.StatusOK, gin.H{
      "message": "Single Note",
      "note":    note,
    })
  } else {
    c.String(http.StatusInternalServerError, err.Error())
  }
}

Здесь мы импортируем все необходимые пакеты. Обратите внимание на окончательный импорт:

"github.com/mattn/go-sqlite3"

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

Затем мы переносим таблицы, вызывая функцию migrate, которая была определена в migrations.go.

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

Затем определите маршруты. Нам нужно всего три маршрута:

  1. GET запрос /notes,  который извлекает все заметки, которые были созданы и сохранены в базе данных
  2. POST запрос /notes создает новую заметку и сохраняет ее в базе данных
  3. GET запрос /note/:note_id извлекает заметку по ее идентификатору

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

Преимущества использования плоской конструкции

Мы видим, что, используя плоскую структуру, мы можем быстро создавать простые API без управления несколькими пакетами. Это особенно полезно для авторов библиотек, поскольку большинство модулей должны быть частью базового пакета.

Минусы использования плоской конструкции

Несмотря на преимущества использования плоской структуры, это не лучший вариант для создания API. Во-первых, эта структура довольно ограничивающая, и она автоматически делает функции и переменные доступными глобально.

Также нет истинного разделения интересов. Мы пытались отделить модель от миграции и маршрутизации, но это было почти невозможно, потому что к ним все еще можно получить доступ напрямую друг от друга. Это может привести к тому, что один файл изменит элемент, которого он не должен был делать, или без ведома другого файла, поэтому это приложение будет нелегко поддерживать.

Следующая структура, которую мы рассмотрим, решит многие проблемы с использованием плоской структуры.

Использование многоуровневой архитектуры (классическая структура MVC) в Go

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

Давайте посмотрим, как выглядит структура многоуровневой архитектуры:

layered_app/
  app/
    models/
      User.go         
    controllers/
      UserController.go
  config/
    app.go
  views/
    index.html
  public/
    images/
      logo.png
  main.go
  go.mod
  go.sum

Обратите внимание на разделение. Благодаря этому легко поддерживать проекты, которые структурированы таким образом, и у вас будет меньше беспорядка в вашем коде, используя структуру MVC.

Хотя многоуровневая архитектура не идеальна для создания простых библиотек, она хорошо подходит для создания API-интерфейсов и других больших приложений. Часто это структура по умолчанию для приложений, созданных с использованием Revel, популярной платформы Go для создания REST API.

Обновление приложения Go с многоуровневой архитектурой

Теперь, когда вы видели пример проекта, использующего многоуровневую архитектуру, давайте обновим наш проект с плоской структуры до структуры MVC.

Создайте новую папку с именем notes_api_layered и инициализируйте в ней модуль Go, запустив приведенный ниже фрагмент:

mkdir notes_api_layered
go mod init github.com/username/notes_api_layered

Установите необходимые пакеты SQLite и Gin.

go get github.com/mattn/go-sqlite3
go get github.com/gin-gonic/gin

Теперь обновите структуру папок проекта, чтобы она выглядела следующим образом:

notes_api_layered/
  config/
    db.go
  controllers/
    note.go
  migrations/
    main.go
    note.go
  models/
    note.go
  go.mod
  go.sum
  main.go

Как видно из новой структуры папок, все файлы упорядочены в зависимости от их функций. Все модели находятся в каталоге модели, то же самое касается миграции, контроллеров и конфигураций.

Затем мы реорганизуем работу, которую мы проделали при реализации плоской структуры, в эту новую структуру.

Начиная с конфигурации базы данных, добавьте следующее в config/db.go:

package config

import (
  "database/sql"
  _ "github.com/mattn/go-sqlite3"
)

var DB *sql.DB

func InitializeDB() (*sql.DB, error) {
  // Initialize connection to the database
  var err error
  DB, err = sql.Open("sqlite3", "./notesapi.db")
  return DB, err
}

Здесь мы объявляем пакет с именем config и импортируем все соответствующие библиотеки, чтобы обеспечить связь с базой данных. Обратите внимание, что мы можем объявить несколько пакетов, потому что не все они находятся в одном каталоге.

Затем мы создаем переменную DB, которая будет поддерживать соединение с базой данных, поскольку для каждой модели не идеально иметь разные экземпляры базы данных. Примечание: начало имени переменной или имени функции с заглавных букв означает, что они должны быть экспортированы.

Затем мы объявляем и экспортируем функцию InitializeDB, которая открывает базу данных и сохраняет ссылку на базу данных в переменной DB.

После того, как мы закончили настройку базы данных, мы приступим к миграции. В папке миграции у нас есть два файла: main.go и note.go.

main.go обрабатывает загрузку запросов, которые должны быть выполнены, а затем их выполнение, note.go также содержит запросы SQL, относящиеся к таблице заметок.

Если бы у нас были другие таблицы, у них также был бы файл миграции, который бы содержал запрос для создания таблицы комментариев.

Теперь добавьте следующее migrations/note.go:

package migrations

const Notes = `
CREATE TABLE IF NOT EXISTS notes (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  title VARCHAR(64) NOT NULL,
  body MEDIUMTEXT NOT NULL,
  created_at TIMESTAMP NOT NULL,
  updated_at TIMESTAMP NOT NULL
)
`

Обновите migrations/main.go:

package migrations

import (
  "database/sql"
  "log"
  "github.com/username/notes_api_layered/config"
)

func Run() {
  // Migrate notes
  migrate(config.DB, Notes)
  // Other migrations can be added here.
}

func migrate(dbDriver *sql.DB, query string) {
  statement, err := dbDriver.Prepare(query)
  if err == nil {
    _, creationError := statement.Exec()
    if creationError == nil {
      log.Println("Table created successfully")
    } else {
      log.Println(creationError.Error())
    }
  } else {
    log.Println(err.Error())
  }
}

Как объяснялось ранее, migrations/main.go обрабатывает загрузку запроса из отдельных файлов миграции и запускает его при вызове метода Run. Миграция является частной функцией и не может использоваться вне этого модуля. Единственная функция, экспортируемая во внешний мир - это Run.

После выполнения миграций нам нужно обновить модели. Разница между реализацией многоуровневой структуры и реализацией плоской структуры здесь довольно небольшая.

Все методы, которые будут использоваться извне, должны быть экспортированы, а все ссылки на DB должны быть изменены на config.DB.

После применения этих изменений models/note.go должен выглядеть так:

package models

import (
  "log"
  "time"
  "github.com/username/notes_api_layered/config"
)

type Note struct {
  Id        int       `json:"id"`
  Title     string    `json:"title"`
  Body      string    `json:"body"`
  CreatedAt time.Time `json:"created_at"`
  UpdatedAt time.Time `json:"updated_at"`
}

type NoteParams struct {
  Title string
  Body  string
}

func (note *Note) Create(data NoteParams) (*Note, error) {
  var created_at = time.Now().UTC()
  var updated_at = time.Now().UTC()
  statement, _ := config.DB.Prepare("INSERT INTO notes (title, body, created_at, updated_at) VALUES (?, ?, ?, ?)")
  result, err := statement.Exec(data.Title, data.Body, created_at, updated_at)
  if err == nil {
    id, _ := result.LastInsertId()
    note.Id = int(id)
    note.Title = data.Title
    note.Body = data.Body
    note.CreatedAt = created_at
    note.UpdatedAt = updated_at
    return note, err
  }
  log.Println("Unable to create note", err.Error())
  return note, err
}

func (note *Note) GetAll() ([]Note, error) {
  rows, err := config.DB.Query("SELECT * FROM notes")
  allNotes := []Note{}
  if err == nil {
    for rows.Next() {
      var currentNote Note
      rows.Scan(
        &currentNote.Id,
        &currentNote.Title,
        &currentNote.Body,
        &currentNote.CreatedAt,
        &currentNote.UpdatedAt)
      allNotes = append(allNotes, currentNote)
    }
    return allNotes, err
  }
  return allNotes, err
}

func (note *Note) Fetch(id string) (*Note, error) {
  err := config.DB.QueryRow(
    "SELECT id, title, body, created_at, updated_at FROM notes WHERE id=?", id).Scan(
    &note.Id, &note.Title, &note.Body, &note.CreatedAt, &note.UpdatedAt)
  return note, err
}

Мы объявили новый пакет models и импортировали конфигурацию из github.com/username/notes_api_layered/config. При этом у нас есть доступ к DB, которая была бы инициализирована после вызова функции InitializeDB.

Изменения в контроллере тоже довольно небольшие, и в основном они заключаются в экспорте функций и импорте модели из вновь созданной модели.

Измените этот фрагмент кода:

var note Note
var params NoteParams

На этот:

var note models.Note
var params models.NoteParams

После этой модификации контроллер будет выглядеть так:

package controllers

import (
  "net/http"
  "github.com/gin-gonic/gin"
  "github.com/username/notes_api_layered/models"
)

type NoteController struct{}

func (_ *NoteController) CreateNewNote(c *gin.Context) {
  var params models.NoteParams
  var note models.Note
  err := c.BindJSON(&params)
  if err == nil {
    _, creationError := note.Create(params)
    if creationError == nil {
      c.JSON(http.StatusCreated, gin.H{
        "message": "Note created successfully",
        "note":    note,
      })
    } else {
      c.String(http.StatusInternalServerError, creationError.Error())
    }
  } else {
    c.String(http.StatusInternalServerError, err.Error())
  }
}

func (_ *NoteController) GetAllNotes(c *gin.Context) {
  var note models.Note
  notes, err := note.GetAll()
  if err == nil {
    c.JSON(http.StatusOK, gin.H{
      "message": "All Notes",
      "notes":   notes,
    })
  } else {
    c.String(http.StatusInternalServerError, err.Error())
  }
}

func (_ *NoteController) GetSingleNote(c *gin.Context) {
  var note models.Note
  id := c.Param("note_id")
  _, err := note.Fetch(id)
  if err == nil {
    c.JSON(http.StatusOK, gin.H{
      "message": "Single Note",
      "note":    note,
    })
  } else {
    c.String(http.StatusInternalServerError, err.Error())
  }
}

Из приведенного выше фрагмента мы преобразовали функции в методы, чтобы к ним можно было получить доступ через NoteController.Create вместо controller.Create. Это необходимо для учета возможности наличия нескольких контроллеров, что было бы характерно для большинства современных приложений.

Наконец, мы обновляем main.go, чтобы отразить рефакторинг:

package main

import (
  "log"
  "github.com/gin-gonic/gin"
  "github.com/username/notes_api_layered/config"
  "github.com/username/notes_api_layered/controllers"
  "github.com/username/notes_api_layered/migrations"
)

func main() {
  _, err := config.InitializeDB()
  if err != nil {
    log.Println("Driver creation failed", err.Error())
  } else {
    // Run all migrations
    migrations.Run()

    router := gin.Default()

    var noteController controllers.NoteController
    router.GET("/notes", noteController.GetAllNotes)
    router.POST("/notes", noteController.CreateNewNote)
    router.GET("/notes/:note_id", noteController.GetSingleNote)
    router.Run(":8000")
  }
}

После рефакторинга у нас есть основной пакет lean, который импортирует необходимые пакеты: config, controllers и models. Затем он инициализирует базу данных, вызывая config.InitializeDB().

Теперь можно перейти к маршрутизации. Маршруты следует обновить, чтобы использовать вновь созданный контроллер заметок для обработки запросов.

Преимущества использования многоуровневой структуры в Go

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

Благодаря многоуровневой архитектуре этот проект легко расширяется. Например, если будет добавлена новая функция, позволяющая пользователям комментировать заметки, ее будет легко реализовать, потому что вся основная работа уже проделана. В этом случае нужно будет просто создать model, migration и controller, затем обновить маршруты и вуаля! Эта функция была добавлена.

Недостатки использования многоуровневой структуры

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

Источник:

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

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

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

Попробовать

В подарок 100$ на счет при регистрации

Получить