什么是REST API?
API或应用程序编程接口是一组规则,该规则定义了应用程序或设备如何相互连接和通信。 REST API是符合其余的设计原理或代表性状态转移架构风格的API。因此,REST API有时被转移到Restful API中。
本教程的重点是用GO编写REST API。
电影资源
我们将使用当前项目管理Movie
资源。这不是您将如何在Acutal系统中建模电影资源的准确表示,而只是少数基本类型的混合以及如何在REST API中处理。
字段 | 类型 |
---|---|
id | uuid |
标题 | 字符串 |
导演 | 字符串 |
导演 | 字符串 |
发布 | 时间 |
ticketprice | float64 |
项目设置
- 创建一个用于项目的文件夹,我将其命名为
restapi-with-go-chi-and-inmemory-store
,但通常是GitHub repo的根或单声道中的子文件夹的根。 - 执行以下命令以在终端上初始化
go.mod
go mod init movies-api
- 添加一个新的文件
main.go
,其中包含以下内容以开始
package main
func main() {
println("Hello, World!")
}
配置
添加一个名为config
的文件夹和一个名为config.go
的文件。我喜欢将所有应用程序配置都放在一个地方,并且将使用出色的envconfig
软件包来加载配置,还为选项设置了一些默认值。此软件包允许我们从环境变量加载应用程序配置,可以使用标准GO软件包来完成相同的操作,但是我认为此软件包提供了很好的抽象而不会失去可读性。
package config
import (
"time"
"github.com/kelseyhightower/envconfig"
)
const envPrefix = ""
type Configuration struct {
HTTPServer
}
type HTTPServer struct {
IdleTimeout time.Duration `envconfig:"HTTP_SERVER_IDLE_TIMEOUT" default:"60s"`
Port int `envconfig:"PORT" default:"8080"`
ReadTimeout time.Duration `envconfig:"HTTP_SERVER_READ_TIMEOUT" default:"1s"`
WriteTimeout time.Duration `envconfig:"HTTP_SERVER_WRITE_TIMEOUT" default:"2s"`
}
func Load() (*Configuration, error) {
cfg := Configuration{}
err := envconfig.Process(envPrefix, &cfg)
if err != nil {
return nil, err
}
return &cfg, nil
}
这将导致一个错误可以通过在终端上执行。
go mod tidy
您可以通过将Configuration
转换为interface
,然后在每个子包装中添加配置来改进它,例如api
,store
等
可以使用环境变量来更新配置,例如在我们更新main.go
以启动服务器之后,在终端上执行以下将在端口5000启动服务器。
PORT=5000 go run main.go
电影商店界面
添加一个名为store
的新文件夹和一个名为movie_store.go
的文件。我们将为您的电影商店添加一个接口和支持结构。
package store
import (
"context"
"time"
"github.com/google/uuid"
)
type Movie struct {
ID uuid.UUID
Title string
Director string
ReleaseDate time.Time
TicketPrice float64
CreatedAt time.Time
UpdatedAt time.Time
}
type CreateMovieParams struct {
ID uuid.UUID
Title string
Director string
ReleaseDate time.Time
TicketPrice float64
}
func NewCreateMovieParams(
id uuid.UUID,
title string,
director string,
releaseDate time.Time,
ticketPrice float64,
) CreateMovieParams {
return CreateMovieParams{
ID: id,
Title: title,
Director: director,
ReleaseDate: releaseDate,
TicketPrice: ticketPrice,
}
}
type UpdateMovieParams struct {
Title string
Director string
ReleaseDate time.Time
TicketPrice float64
}
func NewUpdateMovieParams(
title string,
director string,
releaseDate time.Time,
ticketPrice float64,
) UpdateMovieParams {
return UpdateMovieParams{
Title: title,
Director: director,
ReleaseDate: releaseDate,
TicketPrice: ticketPrice,
}
}
type MoviesStore interface {
GetAll(ctx context.Context) ([]*Movie, error)
GetByID(ctx context.Context, id uuid.UUID) (*Movie, error)
Create(ctx context.Context, createMovieParams CreateMovieParams) error
Update(ctx context.Context, id uuid.UUID, updateMovieParams UpdateMovieParams) error
Delete(ctx context.Context, id uuid.UUID) error
}
还添加了一个名为errors.go
的自定义应用程序错误的文件,这些文件使我们的商店包的客户封装不可知,我们的存储实现将在返回客户端之前将任何本机错误转换为我们的业务错误。
package store
import (
"fmt"
"github.com/google/uuid"
)
type DuplicateIDError struct {
ID uuid.UUID
}
func (e *DuplicateIDError) Error() string {
return fmt.Sprintf("duplicate movie id: %v", e.ID)
}
type RecordNotFoundError struct{}
func (e *RecordNotFoundError) Error() string {
return "record not found"
}
Inmemorymoviesstore
在store
下添加文件夹,名为in_memory
和一个名为in_memory_movies_store.go
的文件。添加带有地图字段的结构InMemoryMoviesStore
将电影存储在内存中。还要添加一个RWMutex
字段,以避免并发读/写入电影字段。
我们实现了为MovieStore
接口定义的所有方法,以将/删除电影添加到InMemoryMoviesStore
结构的映射字段中。对于阅读,我们锁定了收集以进行阅读,请阅读结果并使用defer
释放锁。对于写作,我们获得了写锁而不是读取锁。
package in_memory
import (
"context"
"errors"
"movies-api/store"
"sync"
"time"
"github.com/google/uuid"
)
type InMemoryMoviesStore struct {
movies map[uuid.UUID]*store.Movie
mu sync.RWMutex
}
func NewInMemoryMoviesStore() *InMemoryMoviesStore {
return &InMemoryMoviesStore{
movies: map[uuid.UUID]*store.Movie{},
}
}
func (s *InMemoryMoviesStore) GetAll(ctx context.Context) ([]*store.Movie, error) {
s.mu.RLock()
defer s.mu.RUnlock()
movies := make([]*store.Movie, 0)
for _, m := range s.movies {
movies = append(movies, m)
}
return movies, nil
}
func (s *InMemoryMoviesStore) GetByID(ctx context.Context, id uuid.UUID) (*store.Movie, error) {
s.mu.RLock()
defer s.mu.RUnlock()
m, ok := s.movies[id]
if !ok {
return nil, &store.RecordNotFoundError{}
}
return m, nil
}
func (s *InMemoryMoviesStore) Create(ctx context.Context, createMovieParams store.CreateMovieParams) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.movies[createMovieParams.ID]; ok {
return &store.DuplicateIDError{ID: createMovieParams.ID}
}
movie := &store.Movie{
ID: createMovieParams.ID,
Title: createMovieParams.Title,
Director: createMovieParams.Director,
ReleaseDate: createMovieParams.ReleaseDate,
TicketPrice: createMovieParams.TicketPrice,
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
s.movies[movie.ID] = movie
return nil
}
func (s *InMemoryMoviesStore) Update(ctx context.Context, id uuid.UUID, updateMovieParams store.UpdateMovieParams) error {
s.mu.Lock()
defer s.mu.Unlock()
m, ok := s.movies[id]
if !ok {
return &store.RecordNotFoundError{}
}
m.Title = updateMovieParams.Title
m.Director = updateMovieParams.Director
m.ReleaseDate = updateMovieParams.ReleaseDate
m.TicketPrice = updateMovieParams.TicketPrice
m.UpdatedAt = time.Now().UTC()
s.movies[id] = m
return nil
}
func (s *InMemoryMoviesStore) Delete(ctx context.Context, id uuid.UUID) error {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.movies, id)
return nil
}
REST服务器
添加一个新文件夹以添加所有REST API服务器相关的文件。让我们从添加server.go
文件开始,然后添加一个结构来表示REST服务器。该结构将具有运行服务器,路由和所有依赖项所需的配置实例。还添加方法来启动服务器。
对于路线,我们将使用出色的chi
路由器,这是一种构建HTTP服务的LigtWeight,IDOMANT和可组合路由器。
在开始方法中,我们将构造由标准net/http
软件包提供的Server
实例,提供我们在NewServer
方法中设置的chi mux
。然后,我们将设置一种优雅关闭的方法,并致电ListenAndServe
启动我们的REST服务器。
package api
import (
"context"
"fmt"
"log"
"movies-api/config"
"net/http"
"os"
"os/signal"
"syscall"
"movies-api/store"
"github.com/go-chi/chi/v5"
)
type Server struct {
cfg config.HTTPServer
store store.MoviesStore
router *chi.Mux
}
func NewServer(cfg config.HTTPServer, store store.MoviesStore) *Server {
srv := &Server{
cfg: cfg,
store: store,
router: chi.NewRouter(),
}
srv.routes()
return srv
}
func (s *Server) Start(ctx context.Context) {
server := http.Server{
Addr: fmt.Sprintf(":%d", s.cfg.Port),
Handler: s.router,
IdleTimeout: s.cfg.IdleTimeout,
ReadTimeout: s.cfg.ReadTimeout,
WriteTimeout: s.cfg.WriteTimeout,
}
shutdownComplete := handleShutdown(func() {
if err := server.Shutdown(ctx); err != nil {
log.Printf("server.Shutdown failed: %v\n", err)
}
})
if err := server.ListenAndServe(); err == http.ErrServerClosed {
<-shutdownComplete
} else {
log.Printf("http.ListenAndServe failed: %v\n", err)
}
log.Println("Shutdown gracefully")
}
func handleShutdown(onShutdownSignal func()) <-chan struct{} {
shutdown := make(chan struct{})
go func() {
shutdownSignal := make(chan os.Signal, 1)
signal.Notify(shutdownSignal, os.Interrupt, syscall.SIGTERM)
<-shutdownSignal
onShutdownSignal()
close(shutdown)
}()
return shutdown
}
自定义错误
我们将在api
文件夹下的errors.go
文件中定义REST服务器返回的任何自定义错误。我已经继续前进,并添加了我在文件中此服务中返回的所有错误。但是实际上,我们将从最常见的开始,然后在需要时添加任何新的。
package api
import (
"net/http"
"github.com/go-chi/render"
)
type ErrResponse struct {
Err error `json:"-"` // low-level runtime error
HTTPStatusCode int `json:"-"` // http response status code
StatusText string `json:"status"` // user-level status message
AppCode int64 `json:"code,omitempty"` // application-specific error code
ErrorText string `json:"error,omitempty"` // application-level error message, for debugging
}
func (e *ErrResponse) Render(w http.ResponseWriter, r *http.Request) error {
render.Status(r, e.HTTPStatusCode)
return nil
}
var (
ErrNotFound = &ErrResponse{HTTPStatusCode: 404, StatusText: "Resource not found."}
ErrBadRequest = &ErrResponse{HTTPStatusCode: 400, StatusText: "Bad request"}
ErrInternalServerError = &ErrResponse{HTTPStatusCode: 500, StatusText: "Internal Server Error"}
)
func ErrConflict(err error) render.Renderer {
return &ErrResponse{
Err: err,
HTTPStatusCode: 409,
StatusText: "Duplicate ID",
ErrorText: err.Error(),
}
}
路线
我喜欢保留服务在一个位置和名为routes.go
的单个文件中提供的所有路线。它更容易记住并减轻认知超负荷。
routes
方法悬挂在我们的Server
结构上,定义了router
字段上的所有端点。我定义了一个/health
端点,该端点将返回此服务的当前健康状况。然后添加了一个用于电影的子路线组。这可以帮助我们仅适用于/api/movies
路线,例如身份验证,请求记录。
package api
import "github.com/go-chi/chi/v5"
func (s *Server) routes() {
s.router.Use(render.SetContentType(render.ContentTypeJSON))
s.router.Get("/health", s.handleGetHealth())
s.router.Route("/api/movies", func(r chi.Router) {
r.Get("/", s.handleListMovies())
r.Post("/", s.handleCreateMovie())
r.Route("/{id}", func(r chi.Router) {
r.Get("/", s.handleGetMovie())
r.Put("/", s.handleUpdateMovie())
r.Delete("/", s.handleDeleteMovie())
})
})
}
请注意,所有处理程序都会悬挂在Server
结构上,这有助于访问每个处理程序中所需的依赖项。如果服务中有多个资源,则在每个资源中添加单独的structs
可能仅包含该资源所需的依赖项。
健康终点处理程序
我为health
资源添加了一个单独的文件。它具有一个用于单个端点的处理程序,我们将作为响应结构的响应和实现发送一个结构。
package api
import (
"net/http"
"github.com/go-chi/render"
)
type healthResponse struct {
OK bool `json:"ok"`
}
func (hr healthResponse) Render(w http.ResponseWriter, r *http.Request) error {
return nil
}
func (s *Server) handleGetHealth() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
health := healthResponse{OK: true}
render.Render(w, r, health)
}
}
电影端点处理程序
通过ID获取电影
让我们开始添加一个结构,我们将使用一个构造将Movie
返回到我们的休息服务的呼叫者,并实现Renderer
接口,以便我们可以使用Render
方法返回数据。
type movieResponse struct {
ID uuid.UUID `json:"id"`
Title string `json:"title"`
Director string `json:"director"`
ReleaseDate time.Time `json:"release_date"`
TicketPrice float64 `json:"ticket_price"`
}
func NewMovieResponse(m *store.Movie) movieResponse {
return movieResponse{
ID: m.ID,
Title: m.Title,
Director: m.Director,
ReleaseDate: m.ReleaseDate,
TicketPrice: m.TicketPrice,
}
}
func (hr movieResponse) Render(w http.ResponseWriter, r *http.Request) error {
return nil
}
我喜欢使用这些使structs
更靠近方法/软件包。它确实导致了一些代码重复,例如在这种情况下,movieResponse
与store/movies_store.go
文件中的Movie
struct Defind非常相似,但这允许REST软件包不完全依赖store
包,我们可以具有不同的标签,例如。 store
struct中的db特定标签,但在movieResponse
结构中没有。
现在是处理程序,我们收到一个ResponseWriter
和Request
,我们使用URLParam
方法从路径中提取id
参数,如果解析失败,我们rendera BadRequest
。
然后,如果在使用给定的id
商店中找到记录,我们将继续从store
获取movie
,我们将渲染NotFound
,如果返回的错误不是我们在商店包中定义的错误,那么我们可以添加InternalServerError
,我们可以添加更多根据用例,定制/已知错误以存储并转换为Accland http状态代码。
如果一切都起作用,那么我们将store.Movie
转换为movieResponse
并呈现结果。结果将以json
响应主体返回给呼叫者。
func (s *Server) handleGetMovie() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
idParam := chi.URLParam(r, "id")
id, err := uuid.Parse(idParam)
if err != nil {
render.Render(w, r, ErrBadRequest)
return
}
movie, err := s.store.GetByID(r.Context(), id)
if err != nil {
var rnfErr *store.RecordNotFoundError
if errors.As(err, &rnfErr) {
render.Render(w, r, ErrNotFound)
} else {
render.Render(w, r, ErrInternalServerError)
}
return
}
mr := NewMovieResponse(movie)
render.Render(w, r, mr)
}
}
获取所有/列表电影
对于响应,我们将使用我们为Get By ID
定义的相同movieResponse
结构
func NewMovieListResponse(movies []*store.Movie) []render.Renderer {
list := []render.Renderer{}
for _, movie := range movies {
mr := NewMovieResponse(movie)
list = append(list, mr)
}
return list
}
和处理程序方法非常简单,我们称为GetAll
,如果错误返回InternalServerError
else返回电影列表。
func (s *Server) handleListMovies() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
movies, err := s.store.GetAll(r.Context())
if err != nil {
render.Render(w, r, ErrInternalServerError)
return
}
render.RenderList(w, r, NewMovieListResponse(movies))
}
}
创建电影
与GET相同,我们将添加一个新结构以接收创建新电影所需的参数。但是,不用暗示Renderer
,我们将实现Binder
接口,在Bind
中,如果需要,可以进行自定义映射,例如添加元数据或从JWT
设置CreatedBy
字段。
请注意,我们在此结构中没有CreatedAt
和UpdatedAt
。
type createMovieRequest struct {
ID string `json:"id"`
Title string `json:"title"`
Director string `json:"director"`
ReleaseDate time.Time `json:"release_date"`
TicketPrice float64 `json:"ticket_price"`
}
func (mr *createMovieRequest) Bind(r *http.Request) error {
return nil
}
在处理程序中,我们将请求主体绑定到我们的结构,如果Bind
成功,则将其转换为store.Create
方法期望的CreateMovieParams
struct,并致电Create
方法将电影添加到数据存储中。如果存在重复的密钥错误,我们将返回409 Conflict
对于未知错误,我们返回500 InternalServerError
,如果一切成功,我们都将返回200 OK
。
func (s *Server) handleCreateMovie() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
data := &createMovieRequest{}
if err := render.Bind(r, data); err != nil {
render.Render(w, r, ErrBadRequest)
return
}
createMovieParams := store.NewCreateMovieParams(
uuid.MustParse(data.ID),
data.Title,
data.Director,
data.ReleaseDate,
data.TicketPrice,
)
err := s.store.Create(r.Context(), createMovieParams)
if err != nil {
var dupIdErr *store.DuplicateIDError
if errors.As(err, &dupIdErr) {
render.Render(w, r, ErrConflict(err))
} else {
render.Render(w, r, ErrInternalServerError)
}
return
}
w.WriteHeader(200)
w.Write(nil)
}
}
更新电影
与上面的Create Movie
相同,我们引入了一个新的struct updateMovieRequest
,以接收更新电影和插入结构的Binder
接口所需的参数。
type updateMovieRequest struct {
Title string `json:"title"`
Director string `json:"director"`
ReleaseDate time.Time `json:"release_date"`
TicketPrice float64 `json:"ticket_price"`
}
func (mr *updateMovieRequest) Bind(r *http.Request) error {
return nil
}
在柄中,我们从路径中读取id
,然后从请求主体绑定结构。如果没有错误,则我们将请求转换为store.UpdateMovieParams
,并致电Update
商店的方法更新电影。如果Upate成功,我们返回200 OK
。
func (s *Server) handleUpdateMovie() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
idParam := chi.URLParam(r, "id")
id, err := uuid.Parse(idParam)
if err != nil {
render.Render(w, r, ErrBadRequest)
return
}
data := &updateMovieRequest{}
if err := render.Bind(r, data); err != nil {
render.Render(w, r, ErrBadRequest)
return
}
updateMovieParams := store.NewUpdateMovieParams(
data.Title,
data.Director,
data.ReleaseDate,
data.TicketPrice,
)
err = s.store.Update(r.Context(), id, updateMovieParams)
if err != nil {
var rnfErr *store.RecordNotFoundError
if errors.As(err, &rnfErr) {
render.Render(w, r, ErrNotFound)
} else {
render.Render(w, r, ErrInternalServerError)
}
return
}
w.WriteHeader(200)
w.Write(nil)
}
}
删除电影
这可能是最简单的处理程序,因为它不需要任何Renderer
或Binder
,我们只是从路径中获取id
,然后调用Delete
商店的方法来删除资源。如果删除成功,我们返回200 OK
。
func (s *Server) handleDeleteMovie() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
idParam := chi.URLParam(r, "id")
id, err := uuid.Parse(idParam)
if err != nil {
render.Render(w, r, ErrBadRequest)
return
}
err = s.store.Delete(r.Context(), id)
if err != nil {
var rnfErr *store.RecordNotFoundError
if errors.As(err, &rnfErr) {
render.Render(w, r, ErrNotFound)
} else {
render.Render(w, r, ErrInternalServerError)
}
return
}
w.WriteHeader(200)
w.Write(nil)
}
}
启动服务器
现在一切都设置了,是时候更新main
方法了。首先要洗涤配置,然后创建一个InMemoryMoviesStore
的实例,在这里我们还可以实例化服务器依赖的任何其他依赖关系。下一步是创建一个api.Server
struct的实例,并调用Start
方法来启动服务器。服务器将开始在配置的端口上收听,您可以使用curl
或Postman
调用端点。
package main
import (
"context"
"log"
"movies-api/api"
"movies-api/config"
"movies-api/store/in_memory"
)
func main() {
ctx := context.Background()
cfg, err := config.Load()
if err != nil {
log.Fatal(err)
}
store := in_memory.NewInMemoryMoviesStore()
server := api.NewServer(cfg.HTTPServer, store)
server.Start(ctx)
}
测试
我将列出手动测试API端点的步骤,因为我们没有Swagger UI
或任何其他UI与此交互,Postman
也可以用于测试端点。
- 启动服务器执行以下
go run main.go
执行以下测试,请记住如果您在与8080不同的端口上运行的端口。
测试
获取所有返回空名单
请求
curl --request GET --url "http://localhost:8080/api/movies"
预期响应
[]
使用无效ID获得ID
请求
curl --request GET --url "http://localhost:8080/api/movies/1"
预期响应
{"status":"Bad request"}
通过不存在的记录获得ID
请求
curl --request GET --url "http://localhost:8080/api/movies/98268a96-a6ac-444f-852a-c6472129aa22"
预期响应
{"status":"Resource not found."}
创建电影
请求
curl --request POST --data '{ "id": "98268a96-a6ac-444f-852a-c6472129aa22", "title": "Star Wars: Episode I – The Phantom Menace", "director": "George Lucas", "release_date": "1999-05-16T01:01:01.00Z", "ticket_price": 10.70 }' --url "http://localhost:8080/api/movies"
预期响应
使用现有ID创建电影
请求
curl --request POST --data '{ "id": "98268a96-a6ac-444f-852a-c6472129aa22", "title": "Star Wars: Episode I – The Phantom Menace", "director": "George Lucas", "release_date": "1999-05-16T01:01:01.00Z", "ticket_price": 10.70 }' --url "http://localhost:8080/api/movies"
预期响应
{"status":"Duplicate ID","error":"duplicate movie id: 98268a96-a6ac-444f-852a-c6472129aa22"}
获取所有电影
请求
curl --request GET --url "http://localhost:8080/api/movies"
预期响应
[{"id":"98268a96-a6ac-444f-852a-c6472129aa22","title":"Star Wars: Episode I – The Phantom Menace","director":"George Lucas","release_date":"1999-05-16T01:01:01Z","ticket_price":10.7}]
通过ID获取电影
请求
curl --request GET --url "http://localhost:8080/api/movies/98268a96-a6ac-444f-852a-c6472129aa22"
预期响应
{"id":"98268a96-a6ac-444f-852a-c6472129aa22","title":"Star Wars: Episode I – The Phantom Menace","director":"George Lucas","release_date":"1999-05-16T01:01:01Z","ticket_price":10.7}
更新电影
请求
curl --request PUT --data '{ "title": "Star Wars: Episode I – The Phantom Menace", "director": "George Lucas", "release_date": "1999-05-16T01:01:01.00Z", "ticket_price": 20.70 }' --url "http://localhost:8080/api/movies/98268a96-a6ac-444f-852a-c6472129aa22"
预期响应
通过ID获取电影 - 获取更新记录
请求
curl --request GET --url "http://localhost:8080/api/movies/98268a96-a6ac-444f-852a-c6472129aa22"
预期响应
{"id":"98268a96-a6ac-444f-852a-c6472129aa22","title":"Star Wars: Episode I – The Phantom Menace","director":"George Lucas","release_date":"1999-05-16T01:01:01Z","ticket_price":20.7}
删除电影
请求
curl --request DELETE --url "http://localhost:8080/api/movies/98268a96-a6ac-444f-852a-c6472129aa22"
预期响应
通过ID获取电影 - 删除记录
请求
curl --request GET --url "http://localhost:8080/api/movies/98268a96-a6ac-444f-852a-c6472129aa22"
预期响应
{"status":"Resource not found."}
来源
演示应用程序的源代码托管在blog-code-samples存储库中的GitHub上。
参考
没有特定顺序