» Go:使用Kafka构建事件驱动微服务 » 2. 生产者:Web 服务 » 2.4 创建和搜索图书

创建和搜索图书

首先让我们来添加创建、搜索图书的 API,然后再放入一些测试数据供后续使用。

创建图书 API

创建 domain/gateway/book_manager.go

/*
Package gateway contains all domain gateways.
*/
package gateway

import (
	"context"

	"literank.com/event-books/domain/model"
)

// BookManager 管理所有图书
type BookManager interface {
	CreateBook(ctx context.Context, b *model.Book) (uint, error)
}

此处也是在使用4层架构。项目越大,该架构的好处越明显。阅读更多

安装 gorm 依赖:

go get -u gorm.io/gorm
go get -u gorm.io/driver/mysql

准备 MySQL 数据库:

  • 在你的机器上安装 MySQL 并启动。
  • 创建一个库,名为 lr_event_book
CREATE DATABASE lr_event_book CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
  • 创建用户 test_user:
CREATE USER 'test_user'@'localhost' IDENTIFIED BY 'test_pass';
GRANT ALL PRIVILEGES ON lr_event_book.* TO 'test_user'@'localhost';
FLUSH PRIVILEGES;

创建 infrastructure/database/mysql.go:

/*
Package database 处理所有数据库实现。
*/
package database

import (
	"context"

	"gorm.io/driver/mysql"
	"gorm.io/gorm"

	"literank.com/event-books/domain/model"
)

// MySQLPersistence runs all MySQL operations
type MySQLPersistence struct {
	db       *gorm.DB
	pageSize int
}

// NewMySQLPersistence constructs a new MySQLPersistence
func NewMySQLPersistence(dsn string, pageSize int) (*MySQLPersistence, error) {
	db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
	if err != nil {
		return nil, err
	}
	// Auto Migrate the data structs
	if err := db.AutoMigrate(&model.Book{}); err != nil {
		return nil, err
	}

	return &MySQLPersistence{db, pageSize}, nil
}

// CreateBook creates a new book
func (s *MySQLPersistence) CreateBook(ctx context.Context, b *model.Book) (uint, error) {
	if err := s.db.WithContext(ctx).Create(b).Error; err != nil {
		return 0, err
	}
	return b.ID, nil
}

安装 yaml 依赖:

 go get -u gopkg.in/yaml.v3

创建 infrastructure/config/config.go:

/*
Package config 提供所有配置结构和解析函数。
*/
package config

import (
	"fmt"
	"os"

	"gopkg.in/yaml.v3"
)

// Config is the global configuration.
type Config struct {
	App ApplicationConfig `json:"app" yaml:"app"`
	DB  DBConfig          `json:"db" yaml:"db"`
}

// DBConfig is the configuration of databases.
type DBConfig struct {
	DSN string `json:"dsn" yaml:"dsn"`
}

// ApplicationConfig is the configuration of main app.
type ApplicationConfig struct {
	Port             int    `json:"port" yaml:"port"`
	PageSize         int    `json:"page_size" yaml:"page_size"`
	TemplatesPattern string `json:"templates_pattern" yaml:"templates_pattern"`
}

// Parse parses config file and returns a Config.
func Parse(filename string) (*Config, error) {
	buf, err := os.ReadFile(filename)
	if err != nil {
		return nil, err
	}
	c := &Config{}
	err = yaml.Unmarshal(buf, c)
	if err != nil {
		return nil, fmt.Errorf("failed to parse file %s: %v", filename, err)
	}
	return c, nil
}

创建 config.yml:

app:
  port: 8080
  page_size: 5
  templates_pattern: "adapter/templates/*.html"
db:
  dsn: "test_user:test_pass@tcp(127.0.0.1:3306)/lr_event_book?charset=utf8mb4&parseTime=True&loc=Local"

警醒:
不要直接 git 提交 config.yml。可能会导致敏感数据泄露。如果非要提交的话,建议只提交配置格式模板。
比如:

app:
  port: 8080
db:
  dsn: ""

创建 application/executor/book_operator.go:

/*
Package executor handles request-response style business logic.
*/
package executor

import (
	"context"

	"literank.com/event-books/domain/gateway"
	"literank.com/event-books/domain/model"
)

// BookOperator handles book input/output and proxies operations to the book manager.
type BookOperator struct {
	bookManager gateway.BookManager
}

// NewBookOperator constructs a new BookOperator
func NewBookOperator(b gateway.BookManager) *BookOperator {
	return &BookOperator{bookManager: b}
}

// CreateBook creates a new book
func (o *BookOperator) CreateBook(ctx context.Context, b *model.Book) (*model.Book, error) {
	id, err := o.bookManager.CreateBook(ctx, b)
	if err != nil {
		return nil, err
	}
	b.ID = id
	return b, nil
}

创建 application/wire_helper.go:

/*
Package application provides all common structures and functions of the application layer.
*/
package application

import (
	"literank.com/event-books/domain/gateway"
	"literank.com/event-books/infrastructure/config"
	"literank.com/event-books/infrastructure/database"
)

// WireHelper is the helper for dependency injection
type WireHelper struct {
	sqlPersistence *database.MySQLPersistence
}

// NewWireHelper constructs a new WireHelper
func NewWireHelper(c *config.Config) (*WireHelper, error) {
	db, err := database.NewMySQLPersistence(c.DB.DSN, c.App.PageSize)
	if err != nil {
		return nil, err
	}

	return &WireHelper{
		sqlPersistence: db}, nil
}

// BookManager returns an instance of BookManager
func (w *WireHelper) BookManager() gateway.BookManager {
	return w.sqlPersistence
}

创建 adapter/router.go:

/*
Package adapter adapts to all kinds of framework or protocols.
*/
package adapter

import (
	"fmt"
	"net/http"

	"github.com/gin-gonic/gin"

	"literank.com/event-books/application"
	"literank.com/event-books/application/executor"
	"literank.com/event-books/domain/model"
)

// RestHandler handles all restful requests
type RestHandler struct {
	bookOperator *executor.BookOperator
}

func newRestHandler(wireHelper *application.WireHelper) *RestHandler {
	return &RestHandler{
		bookOperator: executor.NewBookOperator(wireHelper.BookManager()),
	}
}

// MakeRouter makes the main router
func MakeRouter(templates_pattern string, wireHelper *application.WireHelper) (*gin.Engine, error) {
	rest := newRestHandler(wireHelper)
	// Create a new Gin router
	r := gin.Default()
	// Load HTML templates from the templates directory
	r.LoadHTMLGlob(templates_pattern)

	// Define a health endpoint handler
	r.GET("/", rest.indexPage)

	apiGroup := r.Group("/api")
	apiGroup.POST("/books", rest.createBook)
	return r, nil
}

// Render and show the index page
func (r *RestHandler) indexPage(c *gin.Context) {
	// Render the HTML template named "index.html"
	c.HTML(http.StatusOK, "index.html", gin.H{
		"title": "LiteRank Book Store",
	})
}

// Create a new book
func (r *RestHandler) createBook(c *gin.Context) {
	var reqBody model.Book
	if err := c.ShouldBindJSON(&reqBody); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	book, err := r.bookOperator.CreateBook(c, &reqBody)
	if err != nil {
		fmt.Printf("Failed to create: %v\n", err)
		c.JSON(http.StatusNotFound, gin.H{"error": "failed to create"})
		return
	}
	c.JSON(http.StatusCreated, book)
}

移动 templatesadapter/templates

替换 main.go 中内容,如下所示:

package main

import (
	"fmt"

	"literank.com/event-books/adapter"
	"literank.com/event-books/application"
	"literank.com/event-books/infrastructure/config"
)

const configFileName = "config.yml"

func main() {
	// Read the config
	c, err := config.Parse(configFileName)
	if err != nil {
		panic(err)
	}

	// Prepare dependencies
	wireHelper, err := application.NewWireHelper(c)
	if err != nil {
		panic(err)
	}

	// Build main router
	r, err := adapter.MakeRouter(c.App.TemplatesPattern, wireHelper)
	if err != nil {
		panic(err)
	}
	// Run the server on the specified port
	if err := r.Run(fmt.Sprintf(":%d", c.App.Port)); err != nil {
		panic(err)
	}
}

执行 go mod tidy 整理 go.mod 文件:

go mod tidy

再次运行 server,然后使用 curl 进行测试:

go run main.go
curl --request POST \
  --url http://localhost:8080/api/books \
  --header 'Content-Type: application/json' \
  --data '{
	"title": "The Great Gatsby",
	"author": "F. Scott Fitzgerald",
	"published_at": "1925-04-10",
	"description": "A novel depicting the opulent lives of wealthy Long Island residents during the Jazz Age."
}'

样例响应:

{
	"id": 1,
	"title": "The Great Gatsby",
	"author": "F. Scott Fitzgerald",
	"published_at": "1925-04-10",
	"description": "A novel depicting the opulent lives of wealthy Long Island residents during the Jazz Age.",
	"created_at": "2024-04-02T20:36:01.015+08:00"
}

放入测试数据

curl -X POST -H "Content-Type: application/json" -d '{"title": "To Kill a Mockingbird", "author": "Harper Lee", "published_at": "1960-07-11", "description": "A novel set in the American South during the 1930s, dealing with themes of racial injustice and moral growth."}' http://localhost:8080/api/books
curl -X POST -H "Content-Type: application/json" -d '{"title": "1984", "author": "George Orwell", "published_at": "1949-06-08", "description": "A dystopian novel depicting a totalitarian regime, surveillance, and propaganda."}' http://localhost:8080/api/books
curl -X POST -H "Content-Type: application/json" -d '{"title": "Pride and Prejudice", "author": "Jane Austen", "published_at": "1813-01-28", "description": "A classic novel exploring the themes of love, reputation, and social class in Georgian England."}' http://localhost:8080/api/books
curl -X POST -H "Content-Type: application/json" -d '{"title": "The Catcher in the Rye", "author": "J.D. Salinger", "published_at": "1951-07-16", "description": "A novel narrated by a disaffected teenager, exploring themes of alienation and identity."}' http://localhost:8080/api/books
curl -X POST -H "Content-Type: application/json" -d '{"title": "The Lord of the Rings", "author": "J.R.R. Tolkien", "published_at": "1954-07-29", "description": "A high fantasy epic following the quest to destroy the One Ring and defeat the Dark Lord Sauron."}' http://localhost:8080/api/books
curl -X POST -H "Content-Type: application/json" -d '{"title": "Moby-Dick", "author": "Herman Melville", "published_at": "1851-10-18", "description": "A novel exploring themes of obsession, revenge, and the nature of good and evil."}' http://localhost:8080/api/books
curl -X POST -H "Content-Type: application/json" -d '{"title": "The Hobbit", "author": "J.R.R. Tolkien", "published_at": "1937-09-21", "description": "A fantasy novel set in Middle-earth, following the adventure of Bilbo Baggins and the quest for treasure."}' http://localhost:8080/api/books
curl -X POST -H "Content-Type: application/json" -d '{"title": "The Adventures of Huckleberry Finn", "author": "Mark Twain", "published_at": "1884-12-10", "description": "A novel depicting the journey of a young boy and an escaped slave along the Mississippi River."}' http://localhost:8080/api/books
curl -X POST -H "Content-Type: application/json" -d '{"title": "War and Peace", "author": "Leo Tolstoy", "published_at": "1869-01-01", "description": "A novel depicting the Napoleonic era in Russia, exploring themes of love, war, and historical determinism."}' http://localhost:8080/api/books
curl -X POST -H "Content-Type: application/json" -d '{"title": "Alice’s Adventures in Wonderland", "author": "Lewis Carroll", "published_at": "1865-11-26", "description": "A children’s novel featuring a young girl named Alice who falls into a fantastical world populated by peculiar creatures."}' http://localhost:8080/api/books
curl -X POST -H "Content-Type: application/json" -d '{"title": "The Odyssey", "author": "Homer", "published_at": "8th Century BC", "description": "An ancient Greek epic poem attributed to Homer, detailing the journey of Odysseus after the Trojan War."}' http://localhost:8080/api/books

搜索图书 API

修改 domain/gateway/book_manager.go:

@@ -12,4 +12,5 @@ import (
 // BookManager manages all books
 type BookManager interface {
        CreateBook(ctx context.Context, b *model.Book) (uint, error)
+       GetBooks(ctx context.Context, offset int, keyword string) ([]*model.Book, error)
 }

更新 infrastructure/database/mysql.go:

@@ -39,3 +39,17 @@ func (s *MySQLPersistence) CreateBook(ctx context.Context, b *model.Book) (uint,
        }
        return b.ID, nil
 }
+
+// GetBooks gets a list of books by offset and keyword
+func (s *MySQLPersistence) GetBooks(ctx context.Context, offset int, keyword string) ([]*model.Book, error) {
+       books := make([]*model.Book, 0)
+       tx := s.db.WithContext(ctx)
+       if keyword != "" {
+               term := "%" + keyword + "%"
+               tx = tx.Where("title LIKE ?", term).Or("author LIKE ?", term).Or("description LIKE ?", term)
+       }
+       if err := tx.Offset(offset).Limit(s.pageSize).Find(&books).Error; err != nil {
+               return nil, err
+       }
+       return books, nil
+}

更新 application/executor/book_operator.go:

@@ -29,3 +29,8 @@ func (o *BookOperator) CreateBook(ctx context.Context, b *model.Book) (*model.Bo
        b.ID = id
        return b, nil
 }
+
+// GetBooks gets a list of books by offset and keyword, and caches its result if needed
+func (o *BookOperator) GetBooks(ctx context.Context, offset int, query string) ([]*model.Book, error) {
+       return o.bookManager.GetBooks(ctx, offset, query)
+}

更新 adapter/router.go:

@@ -6,6 +6,7 @@ package adapter
 import (
        "fmt"
        "net/http"
+       "strconv"
 
        "github.com/gin-gonic/gin"
 
@@ -14,6 +15,11 @@ import (
        "literank.com/event-books/domain/model"
 )
 
+const (
+       fieldOffset = "o"
+       fieldQuery  = "q"
+)
+
 // RestHandler handles all restful requests
 type RestHandler struct {
        bookOperator *executor.BookOperator
@@ -37,6 +43,7 @@ func MakeRouter(templates_pattern string, wireHelper *application.WireHelper) (*
        r.GET("/", rest.indexPage)
 
        apiGroup := r.Group("/api")
+       apiGroup.GET("/books", rest.getBooks)
        apiGroup.POST("/books", rest.createBook)
        return r, nil
 }
@@ -49,6 +56,27 @@ func (r *RestHandler) indexPage(c *gin.Context) {
        })
 }
 
+// Get all books
+func (r *RestHandler) getBooks(c *gin.Context) {
+       offset := 0
+       offsetParam := c.Query(fieldOffset)
+       if offsetParam != "" {
+               value, err := strconv.Atoi(offsetParam)
+               if err != nil {
+                       c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid offset"})
+                       return
+               }
+               offset = value
+       }
+       books, err := r.bookOperator.GetBooks(c, offset, c.Query(fieldQuery))
+       if err != nil {
+               fmt.Printf("Failed to get books: %v\n", err)
+               c.JSON(http.StatusNotFound, gin.H{"error": "failed to get books"})
+               return
+       }
+       c.JSON(http.StatusOK, books)
+}
+
 // Create a new book
 func (r *RestHandler) createBook(c *gin.Context) {
        var reqBody model.Book

重启后再次使用 curl 测试:

curl --request GET --url 'http://localhost:8080/api/books?q=love'

样例响应:

[
	{
		"id": 4,
		"title": "Pride and Prejudice",
		"author": "Jane Austen",
		"published_at": "1813-01-28",
		"description": "A classic novel exploring the themes of love, reputation, and social class in Georgian England.",
		"created_at": "2024-04-02T21:02:59.314+08:00"
	},
	{
		"id": 10,
		"title": "War and Peace",
		"author": "Leo Tolstoy",
		"published_at": "1869-01-01",
		"description": "A novel depicting the Napoleonic era in Russia, exploring themes of love, war, and historical determinism.",
		"created_at": "2024-04-02T21:02:59.42+08:00"
	}
]

在首页上展示图书

更新 adapter/router.go:

@@ -50,9 +50,15 @@ func MakeRouter(templates_pattern string, wireHelper *application.WireHelper) (*
 
 // Render and show the index page
 func (r *RestHandler) indexPage(c *gin.Context) {
+       books, err := r.bookOperator.GetBooks(c, 0, "")
+       if err != nil {
+               c.String(http.StatusNotFound, "failed to get books")
+               return
+       }
        // Render the HTML template named "index.html"
        c.HTML(http.StatusOK, "index.html", gin.H{
                "title": "LiteRank Book Store",
+               "books": books,
        })
 }

更新 adapter/templates/index.html:

@@ -10,7 +10,7 @@
 </head>
 <body class="bg-gray-100 p-2">
     <div class="container mx-auto py-8">
-        <h1 class="text-4xl font-bold">{{ .title }}</h1>
+        <h1 class="text-4xl font-bold"><a href="/">{{ .title }}</a></h1>
 
         <!-- Search Bar Section -->
         <div class="mb-8">
@@ -18,6 +18,20 @@
             <input type="text" placeholder="Search for books..." class="w-full px-4 py-2 rounded-md border-gray-300 focus:outline-none focus:border-blue-500">
         </div>
 
+        <!-- Books Section -->
+        <div class="mb-8">
+            <h2 class="text-2xl font-bold mb-4">Books</h2>
+            <div class="grid grid-cols-4 gap-2">
+                {{range .books}}
+                    <div class="bg-white p-4 rounded-md border-gray-300 shadow mt-2">
+                        <div><b>{{.Title}}</b></div>
+                        <div class="text-gray-500 text-sm">{{.PublishedAt}}</div>
+                        <div class="italic text-sm">{{.Author}}</div>
+                    </div>
+                {{end}}
+            </div>
+        </div>
+
         <!-- Trends Section -->
         <div class="mb-8">
             <h2 class="text-2xl font-bold mb-4">Trends</h2>

重启 server 并刷新页面,你将看到图书列表。

在首页上搜索图书

更新 adapter/router.go:

@@ -50,7 +50,8 @@ func MakeRouter(templates_pattern string, wireHelper *application.WireHelper) (*
 
 // Render and show the index page
 func (r *RestHandler) indexPage(c *gin.Context) {
-       books, err := r.bookOperator.GetBooks(c, 0, "")
+       q := c.Query(fieldQuery)
+       books, err := r.bookOperator.GetBooks(c, 0, q)
        if err != nil {
                c.String(http.StatusNotFound, "failed to get books")
                return
@@ -59,6 +60,7 @@ func (r *RestHandler) indexPage(c *gin.Context) {
        c.HTML(http.StatusOK, "index.html", gin.H{
                "title": "LiteRank Book Store",
                "books": books,
+               "q":     q,
        })
 }

更新 adapter/templates/index.html:

@@ -15,12 +15,15 @@
         <!-- Search Bar Section -->
         <div class="mb-8">
             <h2 class="text-2xl font-bold mb-4 mt-6">Search</h2>
-            <input type="text" placeholder="Search for books..." class="w-full px-4 py-2 rounded-md border-gray-300 focus:outline-none focus:border-blue-500">
+            <form class="flex">
+                <input type="text" name="q" value="{{.q}}" placeholder="Search for books..." class="flex-grow px-4 py-2 rounded-l-md border-gray-300 focus:outline-none focus:border-blue-500">
+                <button type="submit" class="px-4 py-2 bg-blue-500 text-white rounded-r-md">Search</button>
+            </form>
         </div>
 
         <!-- Books Section -->
         <div class="mb-8">
-            <h2 class="text-2xl font-bold mb-4">Books</h2>
+            <h2 class="text-2xl font-bold mb-4">{{if .q}}Keyword: “{{.q}}“{{else}}Books{{end}}</h2>
             <div class="grid grid-cols-4 gap-2">
                 {{range .books}}
                     <div class="bg-white p-4 rounded-md border-gray-300 shadow mt-2">

重启 server,刷新页面。

搜索结果如下所示:

search results