帖子服务
帖子服务
内容目录
在本文中我们将构建一个帖子服务. 这将是学习如何使用 键值存储接口 构建不简单应用程序的一种挺好的途径.
本文最重要的收获可能是对非常规用例的键值存储的使用 (通过 slug 查询博客帖子以及按创建订单反向列出).
基础内容
让我们开始使用这些知识吧! 提醒一下, 我们必须确保 micro server
已在其他终端中运行, 并确保能链接上, 如下
$ micro env
* local 127.0.0.1:8081
platform proxy.m3o.com
这里选择了本地环境. 如果没有, 我们可以使用 micro env set local
来补救.
现在回到 micro new
命令:
$ micro new posts
$ ls posts
Dockerfile Makefile README.md generate.go go.mod handler main.go proto
非常好! 开始一个服务的最佳方式是定义原型. 生成的默认内容类似如下:
$ cd posts; # 进入项目根目录
$ cat proto/posts.proto
syntax = "proto3";
package posts;
service Posts {
rpc Call(Request) returns (Response) {}
// some more methods here...
}
message Message {
string say = 1;
}
message Request {
string name = 1;
}
message Response {
string msg = 1;
}
// some more types here...
在我们的帖子服务中, 我们需要 3 个方法:
Save
用于博客的插入和更新Query
用于读取博客及列表Delete
用于删除博客
我们从 post 方法开始. 更新我们的 proto/posts.proto
文件内容匹配如下:
syntax = "proto3";
package post;
option go_package = "proto;posts";
service Posts {
rpc Save(SaveRequest) returns (SaveResponse) {}
}
message Post {
string id = 1;
string title = 2;
string slug = 3;
string content = 4;
int64 timestamp = 5;
repeated string tagNames = 6;
}
message SaveRequest {
Post post = 1;
}
message SaveResponse {
Post post = 1;
}
要生成原型代码, 我们必须在项目根目录执行 make proto
命令. 然后我们调整处理器代码来匹配原型!
package handler
import (
"context"
"github.com/micro/micro/v3/service/logger"
pb "posts/proto"
)
type Posts struct {}
func (p *Posts) Save(ctx context.Context, req *pb.SaveRequest, rsp *pb.SaveResponse) error {
logger.Info("Received Posts.Save request")
return nil
}
接下来是 main.go
:
package main
import (
"posts/handler"
pb "posts/proto"
"github.com/micro/micro/v3/service"
"github.com/micro/micro/v3/service/logger"
)
func main() {
// 新服务
srv := service.New(
service.Name("posts"),
service.Version("latest"),
)
// 注册处理器
pb.RegisterPostsHandler(srv.Server(), new(handler.Posts))
// 运行服务
if err := srv.Run(); err != nil {
logger.Fatal(err)
}
}
现在在项目根目录使用 micro run .
命令来部署我们的帖子服务. 并通过 micro logs posts
来验证是否正常:
$ micro logs posts
Starting [service] posts
Server [grpc] Listening on [::]:53031
Registry [service] Registering node: posts-b36361ae-f2ae-48b0-add5-a8d4797508be
(具体的输出依赖于实际的配置格式内容.)
保存帖子
现在让我们的服务开始做一些有用的事: 保存一篇帖子. 我们定义模型, Post
类型, 来匹配原型并修改对应的处理器. 现在处理器的代码应该看起来如下:
package handler
import (
"context"
pb "posts/proto"
"github.com/micro/micro/v3/service/errors"
)
type Posts struct {}
type Post struct {
ID string `json:"id"`
Title string `json:"title"`
Slug string `json:"slug"`
Content string `json:"content"`
CreateTimestamp int64 `json:"create_timestamp"`
UpdateTimestamp int64 `json:"update_timestamp"`
TagNames []string `json:"tagNames"`
}
func (p *Posts) Save(ctx context.Context, req *pb.SaveRequest, rsp *pb.SaveResponse) error {
if len(req.Post.Id) == 0 || len(req.Post.Title) == 0 || len(req.Post.Content) == 0 {
return errors.BadRequest("posts.Save", "ID, title or content is missing")
}
return nil
}
一些健壮性编程不会让我们在混乱的道路上越走越远! 还不太过瘾. 如何才能真正保存我们的帖子? 要做到这一点我们需要理解键值存储是如何工作的. 现在, 我们只需要理解我们希望将帖子保存在一个或多个用于检索其的键下. 由于 UUIDs 不太美好, 我们将使用 github.com/gosimple/slug
生成 slug.
(一个 slug 就是一个标题的 url 化的版本, 如: How to Micro
变成 how-to-micro
.)
import (
// 其他的导入
"github.com/micro/micro/v3/service/store"
gostore "github.com/micro/micro/v3/service/store"
)
// ...
// 保存一篇帖子
func (p *Posts) Save(ctx context.Context, req *pb.SaveRequest, rsp *pb.SaveResponse) error {
if len(req.Post.Id) == 0 || len(req.Post.Title) == 0 || len(req.Post.Content) == 0 {
return errors.BadRequest("posts.Save", "ID, title or content is missing")
}
post := &Post{
ID: req.Post.Id,
Title: req.Post.Title,
Content: req.Post.Content,
Slug: slug.Make(req.Post.Title),
TagNames: req.Post.TagNames,
CreateTimestamp: time.Now().Unix(),
UpdateTimestamp: time.Now().Unix(),
}
bytes, err := json.Marshal(post)
if err != nil {
return err
}
return store.Write(&gostore.Record{
Key: post.Slug,
Value: bytes,
})
}
在项目根目录执行 micro update .
命令, 然后我们就可以开始保存帖子了!
micro posts save --post_id=1 --post_title="Post one" --post_content="First saved post"
micro posts save --post_id=2 --post_title="Post two" --post_content="Second saved post"
哇哦! 进展顺利! 我们刚刚保存了 2 个帖子. 但是这里有一个问题. 没有办法从帖子服务中把帖子拿出来. 幸运的是, micro store
命令被设计为可以与保存后的数据进行交互. micro store list
命令将会列出所有保存后的键 (注意不是值):
$ micro store list --table=posts
post-one
post-two
为什么这些键在这里? 还记得我们通过 slug 保存了帖子嘛. 好的, 但是值哪去了? micro store read
来拯救我们:
$ micro store read --table=posts post-one
{"id":"1","title":"Post one", "content":"First saved post", "create_timestamp":1591970869, "update_timestamp":1591970869}
$ micro store read --table=posts post-two
{"id":"2","title":"Post two", "content":"Second saved post", "create_timestamp":1591970870, "update_timestamp":1591970870}
但是一个个的读取值有点烦人, 于是有了 --prefix
标志的存在:
$ micro store read --table=posts --prefix post
{"id":"1","title":"Post one", "Content":"First saved post", "create_timestamp":1591970869, "update_timestamp":1591970869}
{"id":"2","title":"Post two", "Content":"Second saved post", "create_timestamp":1591970870, "update_timestamp":1591970870}
这把我们带到了这篇文章最重要的部分.
具备键值存储的复杂应用程序
目前为止我们通过 slug 保存了帖子, 但是我们应该如何列出带顺序的帖子呢? 正我们所见, 按前缀列出是键值存储提供给我们几乎唯一的查询功能 (大单单是 Micro, 多数其他键值存储也是这样).
那么我们应该如何通过 slug 读取以及罗列帖子呢?
我们设想有以下键:
$ micro store list
slug:first-post
slug:second-post
timestamp:1591970869
timestamp:1591970870
我们还应该注意到所有记录都按键的字母顺序排序. 我们可以利用这一点, 再加上 --offset
以及 --limit
来实现分页, 如下.
micro store read --table=posts --prefix --offset 0 --limit 20 post
会返回前 20 篇帖子,
micro store read --table=posts --prefix --offset 20 --limit 20 post
将返回第二批的 20 篇帖子 - 也就是第二页 - 等等. 这同样适用于我们的帖子服务, 因为大多数的 micro 接口 CLI 命令和框架功能比例为 1-1, 也就是说 micro CLI 命令有的功能框架内也基本都有.
我们继续来修改我们的 Post
处理器. 我们将在 3 种不同的键下保存帖子: 用 Id, slug 以及 create timestamp. 尽管有 SQL 背景的同学通常会使用数据模型优先, 让这看起来有些别扭, 但键值存储可以夸张的形式扩容并快速的方式来存储信息.
这种违背常理并使用别扭的方式来处理数据可以使我们扩容我们的 web 应用程序到难以想象的规模. 下面的代码可能比之前的长那么一点点, 但其包含了许多重要的内容, 如检查 slug 变更.
const (
idPrefix = "id"
slugPrefix = "slug"
timestampPrefix = "timestamp"
)
func (p *Posts) Save(ctx context.Context, req *pb.SaveRequest, rsp *pb.SaveResponse) error {
if len(req.Post.Id) == 0 || len(req.Post.Title) == 0 || len(req.Post.Content) == 0 {
return errors.BadRequest("posts.Save", "ID, title or content is missing")
}
// 通过父级 ID 读取, 这样我们就可以检查其是否存在, 而不会出现 slug 变更的问题.
records, err := store.Read(fmt.Sprintf("%v:%v", idPrefix, req.Post.Id))
if err != nil && err != gostore.ErrNotFound {
return err
}
postSlug := slug.Make(req.Post.Title)
// 如果发现记录不存在, 则创建一个新的
if len(records) == 0 {
return p.savePost(ctx, nil, &Post{
ID: req.Post.Id,
Title: req.Post.Title,
Content: req.Post.Content,
TagNames: req.Post.TagNames,
Slug: postSlug,
CreateTimestamp: time.Now().Unix(),
})
}
record := records[0]
oldPost := &Post{}
if err := json.Unmarshal(record.Value, oldPost); err != nil {
return err
}
post := &Post{
ID: req.Post.Id,
Title: req.Post.Title,
Content: req.Post.Content,
Slug: postSlug,
TagNames: req.Post.TagNames,
CreateTimestamp: oldPost.CreateTimestamp,
UpdateTimestamp: time.Now().Unix(),
}
// 检查 slug 是否存在
recordsBySlug, err := store.Read(fmt.Sprintf("%v:%v", slugPrefix, postSlug))
if err != nil && err != gostore.ErrNotFound {
return err
}
otherSlugPost := &Post{}
if err := json.Unmarshal(record.Value, otherSlugPost); err != nil {
return err
}
if len(recordsBySlug) > 0 && oldPost.ID != otherSlugPost.ID {
return errors.BadRequest("posts.Save", "An other post with this slug already exists")
}
return p.savePost(ctx, oldPost, post)
}
func (p *Posts) savePost(ctx context.Context, oldPost, post *Post) error {
bytes, err := json.Marshal(post)
if err != nil {
return err
}
// 通过 ID 保存帖子
record := &gostore.Record{
Key: fmt.Sprintf("%v:%v", idPrefix, post.ID),
Value: bytes,
}
if err := store.Write(record); err != nil {
return err
}
// 如果 slug 发生了变更则删除旧的 slug 索引
if oldPost.Slug != post.Slug {
if err := store.Delete(fmt.Sprintf("%v:%v", slugPrefix, post.Slug)); err != nil {
return err
}
}
// 通过 slug 保存帖子
slugRecord := &gostore.Record{
Key: fmt.Sprintf("%v:%v", slugPrefix, post.Slug),
Value: bytes,
}
if err := store.Write(slugRecord); err != nil {
return err
}
// 通过时间戳保存帖子
return store.Write(&gostore.Record{
// 我们还原时间戳这样顺序就颠倒了
Key: fmt.Sprintf("%v:%v", timestampPrefix, math.MaxInt64-post.CreateTimestamp),
Value: bytes,
})
}
在项目根目录执行 micro update .
命令之后我们可以再次通过 Micro CLI 调用我们的服务. 我们插入两个帖子:
micro posts save --post_id="1" --post_title="How to Micro" --post_content="Simply put, Micro is awesome."
micro posts save --post_id="2" --post_title="Fresh posts are fresh" --post_content="This post is fresher than the How to Micro one"
查询帖子
虽然我们可以通过 micro store list --table=posts
查询数据, 但我们仍然不能通过服务查询. 实现了 Query
处理器将可以这样做, 但首先我们需要修改并重新生成 我们的原型文件. 我们还将同时在原型文件中定义 Delete
终结点以免后面还要再次接触此文件.
syntax = "proto3";
package posts;
option go_package = "proto;posts";
service Posts {
// Query 目测仅支持按 slug 或 timestamp, 暂不支持列表形式.
rpc Query(QueryRequest) returns (QueryResponse) {}
rpc Save(SaveRequest) returns (SaveResponse) {}
rpc Delete(DeleteRequest) returns (DeleteResponse) {}
}
message Post {
string id = 1;
string title = 2;
string slug = 3;
string content = 4;
int64 timestamp = 5;
repeated string tagNames = 6;
}
message QueryRequest {
string slug = 1;
int64 offset = 2;
int64 limit = 3;
}
message QueryResponse {
repeated Post posts = 1;
}
message SaveRequest {
Post post = 1;
}
message SaveResponse {
Post post = 1;
}
message DeleteRequest {
string id = 1;
}
message DeleteResponse {}
在项目根本来执行 make proto
命令会重新生成原型文件及代码, 然后我们应该准备重新定义我们的新的处理器:
// 查询帖子
func (p *Posts) Query(ctx context.Context, req *pb.QueryRequest, rsp *pb.QueryResponse) error {
var opts []gostore.ReadOption
var key string
// detemine the key
if len(req.Slug) > 0 {
key = fmt.Sprintf("%v:%v", slugPrefix, req.Slug)
} else {
key = fmt.Sprintf("%v:", timestampPrefix)
opts = append(opts, gostore.ReadPrefix())
}
// set the limit
if req.Limit > 0 {
opts = append(opts, gostore.ReadLimit(uint(req.Limit)))
} else {
opts = append(opts, gostore.ReadLimit(20))
}
// 执行查询
records, err := store.Read(key, opts...)
if err != nil {
return err
}
// 序列化响应内容
rsp.Posts = make([]*pb.Post, len(records))
for i, record := range records {
postRecord := &Post{}
if err := json.Unmarshal(record.Value, postRecord); err != nil {
return err
}
rsp.Posts[i] = &pb.Post{
Id: postRecord.ID,
Title: postRecord.Title,
Slug: postRecord.Slug,
Content: postRecord.Content,
TagNames: postRecord.TagNames,
}
}
return nil
}
// 删除一篇帖子
func (p *Posts) Delete(ctx context.Context, req *pb.DeleteRequest, rsp *pb.DeleteResponse) error {
return nil
}
在项目根目录执行 micro update .
命令之后, 我们现在可以查询帖子了:
$ micro posts query --limit=10
{
"posts": [
{
"id": "1",
"title": "How to Micro",
"slug": "how-to-micro",
"content": "Simply put, Micro is awesome."
}
]
}
优秀如你! 现在只要再落实剩下的 Delete
就可以实现一个基本的帖子服务了.
删除帖子
之前我们已经在原型中定义了 Delele
, 这里我们就只需要实现对应的处理器即可:
// 删除一篇帖子
func (p *Posts) Delete(ctx context.Context, req *pb.DeleteRequest, rsp *pb.DeleteResponse) error {
records, err := store.Read(fmt.Sprintf("%v:%v", idPrefix, req.Id))
if err == gostore.ErrNotFound {
return errors.NotFound("posts.Delete", "Post not found")
} else if err != nil {
return err
}
post := &Post{}
if err := json.Unmarshal(records[0].Value, post); err != nil {
return err
}
// 通过 ID 删除
if err = store.Delete(fmt.Sprintf("%v:%v", idPrefix, post.ID)); err != nil {
return err
}
// 通过 slug 删除
if err := store.Delete(fmt.Sprintf("%v:%v", slugPrefix, post.Slug)); err != nil {
return err
}
// 通过时间戳删除
return store.Delete(fmt.Sprintf("%v:%v", timestampPrefix, post.CreateTimestamp))
}
如上所示, 我们必须记住为给定帖子插入的所有键. 我们可以先通过 ID 读取帖子并获取 slug 以及时间戳, 也就是说同时知道怎么删除了.
结论
这里我们的初级帖子服务系列教程算告一段落了. 后续我们将添加更多功能, 如通过标签保存及查询, 但本文以及足够我们消化了. 我们将在本系列的后续中介绍更多内容.
有关本服务的最新版本代码, 我们可以查询 帖子服务的 github 目录. 因为是最新版本所以其可能包含一些 (也许很多) 本文未提及的额外内容.
重新构建一遍文中描述的内容可以留着读者来练习. 我们一般会把早期版本中的代码尽可能与最新版本保持一致 (处理器名称, 导入名称, 字段名称等.). 但早期版本的代码与 GitHub 上的最新版本肯定会有所区别, 因此协调二者的也是一个不错的实践.