Golang: Простое API

В этой заметке я расскажу, как сделать простейшее API на Go, которое умеет работать с базой данных MySQL.


Подготовка

Будем делать API для управления списком задач. Для этого сделаем одну единственную таблицу в нашей базе данных.

CREATE TABLE `tasks` ( 
    `id` INT(1) NOT NULL AUTO_INCREMENT , 
    `task` VARCHAR(255) NOT NULL , 
    PRIMARY KEY (`id`)
) ENGINE = InnoDB;
Go сам по себе не умеет работать с базой данных, это все делается через внешние пакеты, которые можно подключить. Поэтому, в целом, базу данных можно использовать любую, лишь бы пакет (грубо говоря - плагин) поддерживал. В рамках текущей заметки будет использоваться MySQL.

Папка нашего проекта - C:\GoPath\simple-api, собственно все файлы создаем там. Предполагается, что она пустая. В предыдущих заметках на тему Go мои примеры были в рамках одного стартового файла main.go. Теперь же мы не будем держать все яйца в одной корзине, а будем держать все в разных: роутер, контроллер и прочие вещи. Просто потому, что так проще поддерживать приложение.


Роутинг

Давайте начнем с того, что создадим файл routes.go, в котором опишем структуру роута и обозначим сами роуты, с которыми будем работать.

// routes.go
package main

import "net/http"

type Route struct {
    Name string
    Method string
    Pattern string
    HandlerFunc http.HandlerFunc
}

type Routes[] Route

var routes = Routes {
    Route {
        "Index",
        "GET",
        "/",
        Index,
    },
    Route {
        "List",
        "GET",
        "/tasks",
        TaskList,
    },
    Route {
        "Create",
        "POST",
        "/tasks",
        TaskCreate,
    },
    Route {
        "Show",
        "GET",
        "/tasks/{taskId}",
        TaskShow,
    },
}

Так же необходимо сделать сам роутер, который будет работать с роутами, указаными выше. Для этого сделаем файл router.go, в котором пропишем роутер на основе внешнего пакета (плагина) "gorilla/mux":

// router.go
package main

import (
    "net/http"
    "github.com/gorilla/mux"
)

func NewRouter() *mux.Router {

    router: = mux.NewRouter().StrictSlash(true)
    for _, route := range routes {
        var handler http.Handler

        handler = route.HandlerFunc
        handler = Logger(handler, route.Name)

        router.
            Methods(route.Method).
            Path(route.Pattern).
            Name(route.Name).
            Handler(handler)
    }

    return router
}

Контроллер

Теперь добавим простейший контроллер, точнее методы обработчики, которые мы указали в наши роутах, а именно Index, TaskList, TaskCreate, TaskShow. Пока мы не добрались до работы с базой данных - сделаем так, чтобы они просто что-нибудь отдавали нам, чтобы проверить, запросы работают и обрабатываются. Создадим файл controller.go со следующим содержимым:

// controller.go
package main

import (
    "fmt"
    "net/http"
)

func Index(w http.ResponseWriter, r * http.Request) {
    fmt.Fprint(w, "Welcome!\n")
}

func TaskList(w http.ResponseWriter, r * http.Request) {
    fmt.Fprint(w, "List!\n")
}

func TaskCreate(w http.ResponseWriter, r * http.Request) {
    fmt.Fprint(w, "Create!\n")
}

func TaskShow(w http.ResponseWriter, r * http.Request) {
    fmt.Fprint(w, "Show!\n")
}

Логирование запросов

Добавим простой логгер запросов, чтобы в командном окне можно было смотреть, кто и куда обращается в API. Это может помочь в отладке приложения. Создадим файл logger.go со следующим содержимым:

// logger.go
package main

import (
    "log"
    "net/http"
    "time"
)

func Logger(inner http.Handler, name string) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r * http.Request) {
        start := time.Now()

        inner.ServeHTTP(w, r)

        log.Printf(
            "%s\t%s\t%s\t%s",
            r.Method,
            r.RequestURI,
            name,
            time.Since(start),
        )
    })
}

Входная точка

Сейчас создадим файл main.go, который является входной точкой программы Go, в котором запустим веб-сервер и "подключим" роутер к нему:

// main.go
package main

import (
    "log"
    "net/http"
)

func main() {
    router := NewRouter()
    log.Fatal(http.ListenAndServe(":8080", router))
}

Промежуточный тест

Давайте соберем API и пробежимся по роутам, чтобы проверить, что они отзываются. Для начала нам надо импортировать пакет "gorilla/mux". Делается это командой:

go get
Эту команду всегда нужно выполнять, когда добавляется внешний пакет. При выполнении команды может быть сообщение о переменной GOPATH, обсудим это позже.

После выполнения команды будет создана папка src в папке C:\GoPath, в которой будут исходники внешнего пакета.

Скачивание пакетов

Теперь можно запустить сервер следующей командой:

cmd
go build && simple-api.exe
simple-api.exe будет скомпилирован после выполнения команды go build.

Теперь можно протестировать наши роуты:

Результат обращения в API будет отображен в командном окне.

Тестирование запросов

JSON

Самое время, перед тем как работать с базой данных, показать и дать возможность нашему API отдавать данные в формате JSON. Для начала нам нужно описать структуру ответа. Добавим в файл controller.go после import следующий код:

type Task struct {
    Id int
    Name string
}

type Tasks []Task

Структура Task описывает, по сути, модель задачи, а тип переменной Tasks[] говорит о том, что это массив состоящий из типа Task. Теперь мы можем изменить код наших функций, чтобы они отдавали пока что захардскоженный JSON. Файл controller.go выглядит так:

Обратите внимение, что добавился "encoding/json" в import, чтобы была возможность работать с JSON
// controller.go
package main

import (
    "fmt"
    "net/http"
    "encoding/json"
)

type Task struct {
    Id int
    Name string
}

type Tasks []Task

func Index(w http.ResponseWriter, r * http.Request) {
    fmt.Fprint(w, "Welcome!\n")
}

func TaskList(w http.ResponseWriter, r * http.Request) {
    var tasks Tasks
    
    tasks = append(tasks, Task{Id: 1, Name: "x"});
    tasks = append(tasks, Task{Id: 2, Name: "y"});
    
    json, err := json.Marshal(tasks)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    
    w.Header().Set("Content-Type", "application/json")
    w.Write(json)
}

func TaskCreate(w http.ResponseWriter, r * http.Request) {
    var message string
    message = "Task created"
    
    json, err := json.Marshal(message)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    
    w.Header().Set("Content-Type", "application/json")
    w.Write(json)
}

func TaskShow(w http.ResponseWriter, r * http.Request) {
    var task Task
    
    task.Id = 1
    task.Name = "Hello";
    
    json, err := json.Marshal(task)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    
    w.Header().Set("Content-Type", "application/json")
    w.Write(json)
}

И теперь при обращении к методам нашего API мы будем получать ответ в JSON формате:

JSON

База данных

Отлично! Теперь добавим возможность добавления задачи в базу данных, получение списка задач и получение задачи по ее ID.

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

_ "github.com/go-sql-driver/mysql"
"database/sql"
"_" перед именем позволяет скомпилировать программу без ошибок. Компилятор Go не скомилирует приложение если найдет неиспользуемый пакет или переменную

И выполним загрузку этого пакета командой:

go get

Теперь пара примеров как работать с базой данных через этот пакет:

// Подключение к базе данных
db, err := sql.Open("mysql", "username:password@/database")
if err != nil {
    panic(err.Error())  
}
defer db.Close()
// Запрос в таблицу
tasksList, err := db.Query("SELECT * FROM tasks")
if err != nil {
    panic(err.Error())
}
defer tasksList.Close()
// Подготовка запроса для вставки
// Пример запроса с параметрами
stmtIns, err := db.Prepare("INSERT INTO tasks VALUES(?, ?)") 
if err != nil {
    panic(err.Error()) 
}
defer stmtIns.Close() 

// Выполнение запроса для вставки
_, err = stmtIns.Exec("", r.Form.Get("name"))
if err != nil {
    panic(err.Error()) 
}

Так же нам потребуется подключить пакеты для работы с параметрами и вводом-выводом:

_ "io" 
_ "io/ioutil"
"strconv"
"github.com/gorilla/mux"

В общем итоге наш файл controller.go будет выглядеть так:

package main

// Подключение пакеты
import (
    "fmt"
    "net/http"
    "encoding/json"
    _ "github.com/go-sql-driver/mysql"
    "database/sql"
    _ "io" 
    _ "io/ioutil"
    "strconv"
    "github.com/gorilla/mux"
)

// Модель задачи
type Task struct {
    Id int
    Name string
}

// Список задач
type Tasks []Task

// Главная "страница"
func Index(w http.ResponseWriter, r * http.Request) {
    fmt.Fprint(w, "Welcome!\n")
}

// Список задач
// GET: /tasks
func TaskList(w http.ResponseWriter, r * http.Request) {

    // Подключение к базе данных
    db, err := sql.Open("mysql", "username:password@/database")
    if err != nil {
        panic(err.Error())  
    }
    defer db.Close()
    
    // Запрос в таблицу
    tasksList, err := db.Query("SELECT * FROM tasks")
    if err != nil {
        panic(err.Error())
    }
    defer tasksList.Close()

    // Наполнение списка задач в цикле
    var tasks Tasks
    for tasksList.Next() {
        var task Task
        tasksList.Scan(&task.Id, &task.Name)
        tasks = append(tasks, task);
    }
    
    // Формирование JSON
    json, err := json.Marshal(tasks)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    
    // Отдача JSON
    w.Header().Set("Content-Type", "application/json")
    w.Write(json)
}

// Создание задачи
// POST: /tasks/1
func TaskCreate(w http.ResponseWriter, r * http.Request) {
    // Париснг входящих HTTP параметров
    r.ParseForm()
    
    // Подключение к базе данных
    db, err := sql.Open("mysql", "username:password@/database")
    if err != nil {
        panic(err.Error())  
    }
    defer db.Close()

    // Подготовка запроса для вставки
    stmtIns, err := db.Prepare("INSERT INTO tasks VALUES(?, ?)") 
    if err != nil {
        panic(err.Error()) 
    }
    defer stmtIns.Close() 
    
    // Выполнение запроса для вставки
    _, err = stmtIns.Exec("", r.Form.Get("name"))
    if err != nil {
        panic(err.Error()) 
    }
    
    // Формирование JSON
    json, err := json.Marshal("Task created")
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    
    // Отдача JSON
    w.Header().Set("Content-Type", "application/json")
    w.Write(json)
}

// Выборка конкретной задачи по ID
// GET: /tasks/1
func TaskShow(w http.ResponseWriter, r * http.Request) {

    // Получение параметров из URL (те, которые в адресе роутов)
    vars := mux.Vars(r)
    var taskId int
    var err error
	
    // Проверка, что taskId - число
    if taskId, err = strconv.Atoi(vars["taskId"]); err != nil {
        panic(err)
    }
    
    // Подключение к базе данных
    db, err := sql.Open("mysql", "username:password@/database")
    if err != nil {
        panic(err.Error())  
    }
    defer db.Close()
    
    // Подготовка запроса на выборк по ID
    taskRow, err := db.Prepare("SELECT * FROM tasks WHERE id = ?")
    if err != nil {
        panic(err.Error()) 
    }
    defer taskRow.Close()
    
    // Наполнение модели задачи
    var task Task
    err = taskRow.QueryRow(taskId).Scan(&task.Id, &task.Name) 
    if err != nil {
        panic(err.Error()) 
    }
    
    // Формирование JSON
    json, err := json.Marshal(task)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    
    // Отдача JSON
    w.Header().Set("Content-Type", "application/json")
    w.Write(json)
}

Теперь наше простое API готово. Можно запустить, добавить пару задач через POST запрос /task с переданным параметром name, потом получить список задач через GET запрос /tasks и получить конкретную задачу через GET запрос /tasks/:id (где :id - идентификатор задачи в БД)

При создании задачи в вашем REST клиенте на забудьте поставить заголовок Content-Type: application/x-www-form-urlencoded, иначе программа не получит входящий параметр name.

Код всего API можно скачать. Распаковывать в папку C:/GoPath.

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


Это был простой пример как можно сделать простейшее RESTful API на Go. Я не разбираю синтаксис самого языка, так как предполагаю, что какие-то основы читатель знает, тем более ранее я приводил в пример пару ресурсов на которые надо обратить внимание на эту ему. Суть данной заметки - показать принцип построения простого API.

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

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