打造千万级流量系统——秒杀系统(项目初始化续)

热更新:如何解决程序升级中的稳定性问题?

配置热更新

在做配置热更新前,首先要明白配置项的分类,然后才好有的放矢。一般,秒杀系统中的配置项按加载方式分为两类:启动时加载、运行时加载。

其中,启动时加载的配置也叫固定配置,主要是因为一些配置如果在启动后变更,容易导致程序故障。像秒杀系统中的固定配置,主要有日志等级和 pid 文件路径, MySQL 和 Redis 的地址,admin 和 api 用的监听地址和端口,服务注册和发现以及配置管理用的 etcd 地址,黑名单文件路径,等等。

具体配置项如下

[global]
    logLevel = "info"
    pid = "./.pid"
[mysql]
    address = "127.0.0.1:3306"
    username = "root"
    password = "123"
    database = "seckill"
[redis]
    address = "127.0.0.1:6379"
    auth = "abcdefg"
[etcd]
    address = "127.0.0.1:2379"
[api]
    bind = "127.0.0.1:8080"
    blacklist = "./blacklist.txt"
[admin]
    bind = "127.0.0.1:8081"

注意,以上各配置项的值是本地开发环境的值。如果是测试环境、生产环境等服务器环境,则需要配置成与这些环境相应的值。
运行时加载配置项,就是希望在程序运行过程中能够动态变更某些配置,也就是热更新。热更新通常有三种方法:定时器法、事件驱动法、接口推送法。

定时器法是定时从配置文件、Redis、MySQL 等可以存储配置的地方拉取配置,并更新到本地内存缓存中。事件驱动法是通过监控配置变更的事件,由事件触发拉取配置,相比定时器法,它有实时、性能消耗低等特点。接口推送法是指程序实现同步配置的接口,由其他程序或者工具通过调用该程序的接口,将配置发送到该程序并缓存到内存中,它通常适用于调试某个节点的情况下。

在这三种方法里,秒杀系统主要用到事件驱动法和接口推送法,涉及三类数据:活动配置数据、黑名单、集群信息等。

如何热更新黑名单?

黑名单文件主要由数据分析系统生成,并由文件同步工具分发到秒杀 API 服务节点上,然后由秒杀 API 服务加载到内存中,以便实现反黄牛功能。

黑名单中的数据主要是黄牛用户 ID,以及少量恶意用户 ID。数据量可能达到几十兆字节,甚至是上百兆字节。由于数据量较大,如果从 MySQL、Redis 这类存储中读取的话,可能耗时较长,影响性能和稳定性。因此,黑名单数据需要从本地磁盘加载。

如何做到监控黑名单文件被修改并及时加载到内存中呢?

操作系统使用 inode 维护文件信息,当有程序调用系统函数创建、修改、删除文件的时候,操作系统会修改 inode 中的数据,触发 inode 事件。如果我们能监控 inode 事件,也就能利用事件驱动来将文件数据更新到内存中。

在配置管理工具 viper 中,有个函数 WatchConfig,它的作用便是监控配置文件的 inode 事件,并将最新的配置从配置文件中读取到内存中。此外,viper 还提供了另一个函数 OnConfigChange ,你可以用它注入一个回调函数,当配置文件变更的时候,viper 在 WatchConfig 函数中会调用你注入的回调函数。

具体要怎么做呢?

第一步,你需要实现一个函数 WatchBlacklist ,用于初始化事件监控;

第二步,为了接收配置文件事件,你需要实现一个回调函数 onBlacklistChange;

第三步,实现一个 updateBlacklist 函数,并在回调函数中调用 updateBlacklist 函数,以便更新本地内存中的数据。

具体示例代码如下:

func WatchBlacklist() {
   v := viper.New()
   v.SetConfigFile(viper.GetString("blacklist.filePath"))
   v.OnConfigChange(onBlackListChange)
   go v.WatchConfig()
}
func onBlacklistChange(in fsnotify.Event) {
   const writeOrCreateMask = fsnotify.Write | fsnotify.Create
   if in.Op&writeOrCreateMask != 0 {
      updateBlacklist()
   }
}
func updateBlacklist() {
   // TODO: do update
}

在 WatchBlacklist 函数中,我们创建一个新的 viper 对象,并通过调用它的 SetConfigFile 方法设置了配置文件路径。然后通过调用它的 OnConfigChange 函数,注册了我们实现的事件回调函数 onBlacklistChange。在 onBlacklistChange 中,我们判断事件的类型是否为创建或者修改文件,如果是的话,就调用 updateBlacklist 函数更新内存中的数据。

如何热更新集群信息?

集群信息一般存储在 etcd 中,它包括服务节点信息、日志等级、降级开关、限流限速、连接池大小等。**在热更新方面,秒杀系统中的集群信息如果使用 viper 会更简单。**我们可以将秒杀系统集群信息保存在 etcd 中的 /seckill/config/ 这个 key 下,然后用 viper 来实现配置更新的示例代码。

具体思路是:创建一个 viper 对象 clusterCfgViper,并实现一个 WatchClusterConfig 函数;在函数中调用 clusterCfgViper 的 AddRemoteProvider 方法,传入 etcd 相关的地址、key 等;最后调用 clusterCfgViper 的 WatchRemoteConfigOnChannel 方法,实时监控配置变更并更新到内存缓存。这样,我们就可以在后续代码中,通过调用 clusterCfgViper 的 GetString 等方法获取最新的配置。

具体代码如下:

var clusterCfgViper = viper.New()
func WatchClusterConfig() error {
   if err := clusterCfgViper.AddRemoteProvider("etcd", viper.GetString("etcd.address"), "/seckill/config"); err != nil {
      logrus.Error("add remote provider failed, error ", err)
      return err
   }
   return clusterCfgViper.WatchRemoteConfigOnChannel()
}

viper 支持很多种配置管理,etcd 只是其中之一。如果你有时间,建议你好好学习下这个优秀开源库的源码,你将会有很大的收获。

程序热更新

前面我提到,程序重启的时候会导致程序的所有连接都中断,从而导致正在处理中的请求失败。虽然我们解决了配置的热更新,但程序升级的时候,必须要停掉老版本程序,运行新版本程序。如何让新版本程序平稳地替换运行中的老版本,就是程序热更新所要解决的核心问题。

程序热更新步骤

程序热更新的大致流程是这样的:

首先,为了后期让老版本程序平稳切换到新版本程序,在启动时我们就可以把端口设置为可重复监听,以便让新版本程序及时接收请求;

然后,在发布新版本的时候,先启动新版本程序,与老版本程序监听相同端口;

接下来,通过信号机制通知老版本停止监听端口,所有新请求由新版本接收并处理;

最后,老版本等待所有处理中的请求处理完,并关闭所有连接,然后退出。

代码实现举例
以 API 服务为例,具体到代码中,我们可以在程序启动的时候,在 api.Run 中设置端口为可重复监听。注册一个信号通知通道 onSignal 和服务退出通道 onExit,在从 onSignal 接收到信号后,执行 api.Exit 函数

示例代码如下:

onExit := make(chan error)
go func() {
   if err := api.Run(); err != nil {
      logrus.Error(err)
      onExit <- err
   }
   close(onExit)
}()
onSignal := make(chan os.Signal)
signal.Notify(onSignal, syscall.SIGINT, syscall.SIGTERM)
select {
case sig := <-onSignal:
   api.Exit()
   logrus.Info("exit by signal ", sig)
case err := <-onExit:
   logrus.Info("exit by error ", err)
}

api.Run 函数中又是如何做到端口可重复监听的呢?具体思路是:先创建一个 ListenConfig 对象 lisCfg,利用它的 Control 属性设置一个 Control 函数;在 Control 函数里面调用系统函数 SetsockoptInt 将端口设置为可重复监听,需要确保系统参数 net.ipv4.tcp_tw_reuse=1;然后调用 lisCfg 的 Listen 方法创建一个 Listener 对象 lis;最后创建一个 gin 对象 g 并调用它的 RunListener 监听 lis。

为了给老版程序发信号,并更新到新版程序,我们需要实现一个 updateProc 函数。该函数获取到老版本的 pid,也就是进程 id,每隔一段时间获取其状态并发送信号,通知老版本退出,然后将文件中的 pid 更新为新版本的 pid。

为了让老版本程序不再接收请求,在 api.Exit 函数中,我们将 lis 关闭,并等待一段时间,让老版程序将进行中的请求处理完。

具体代码如下:

var lis net.Listener
func Run() error {
   var err error
   bind := viper.GetString("api.bind")
   lisCfg := &net.ListenConfig{
      Control: func(network, address string, c syscall.RawConn) error {
         var err error
         err1 := c.Control(func(fd uintptr) {
            err = syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, unix.SO_REUSEPORT, 1)
         })
         if err == nil {
            err = err1
         }
         return err
      },
      KeepAlive: 0,
   }
   lis, err = lisCfg.Listen(context.Background(), "tcp", bind)
   if err != nil {
      return err
   }
   g := gin.New()
   // TODO: init routers

   // 更新程序,给老版本发送信号
   go updateProc()
   return g.RunListener(lis)
}
func updateProc() {
   if pidFile, err := os.Open(viper.GetString("api.pid")); err == nil {
      pidBytes, _ := ioutil.ReadAll(pidFile)
      pid, _ := strconv.Atoi(string(pidBytes))
      if pid > 0 {
         // 为了避免因某些原因老版本程序无法退出,尝试发送多个信号,最后一次 SIGKILL 将强制结束老版程序
         signals := []syscall.Signal{syscall.SIGINT, syscall.SIGTERM, syscall.SIGKILL}
         if proc, err := os.FindProcess(pid); err == nil {
            for _, sig := range signals {
               if err = proc.Signal(sig); err != nil {
                  break
               }
               var stat *os.ProcessState
               // 等待老版程序退出
               stat, err = proc.Wait()
               if err != nil || stat.Exited() {
                  break
               }
            }
         }
      }
      pidFile.Close()
   }
   if pidFile, err := os.Create(viper.GetString("api.pid")); err == nil {
      pid := os.Getpid()
      pidFile.Write([]byte(strconv.Itoa(pid)))
      pidFile.Close()
   }
}
func Exit() {
   lis.Close()
   // TODO: 等待请求处理完
   // time.Sleep(10 * time.Second)
}

然后我们执行 make 命令将程序编译出来并运行,当启动第二个程序的时候,第一个收到了信号并退出。以下是效果图。

API 设计:如何使用 RESTFul 和 RPC 实现 API ?

RESTful 解决什么问题?
首先,我们来看下三个时间点:

1991 年 HTTP 0.9 诞生

1996 年 5 月 HTTP 1.0 诞生

1997 年 1 月 HTTP 1.1 诞生

在 HTTP 1.0 出现以前,也就是 HTTP 0.9 时代,HTTP 协议只支持 GET 请求,所有参数只能通过 URL 传递。比如一个获取动态网页的请求可能是这样的 GET /index.php?page=hello&user=1234&action=view 。

在 HTTP 1.0 出现后,开始有了 POST 请求和 HEAD 请求,支持从 HTTP 协议头和协议体传参数。但 HTTP 1.0 并没有解决每次请求都需要建立新连接的问题,然后 HTTP 1.1 很快就出现了。

HTTP 1.1 除了能保持连接外,还新增了多种方法,如 PUT、DELETE、PATCH、OPTIONS。虽然功能更强大,体验更丰富了,却带来了新的问题:这些方法该如何选择呢?比如我要上传数据,到底是用 POST 、PUT 还是 PATCH 呢?这无疑增加了选择成本。

随后,REST( Representational State Transfer,表现层状态转移) 出现了,简单来说它就是一组架构约束条件和原则,而符合 REST 规则的设计便是 RESTful。

因为 HTTP 协议本身是无状态的,但后端数据是有状态的,如何用无状态的协议来操作有状态的数据就比较有挑战。比如,当只用 GET 请求的时候,你无法从请求参数上直观判断到底是对数据做什么操作,因为大家对 GET 请求参数命名没有统一规范。在前面的例子中,action=view 也有可能被定义成 a=v。

RESTful 是如何解决这个问题的呢?它充分利用了各种 HTTP 请求方法的特点,来表达对数据的具体操作方式。比如当你要新增数据时,应当用 POST 方法;要整个替换数据时,应当用 PUT 方法;仅仅是替换数据的部分字段时,应当用 PATCH 方法;如果是删除数据,应当用 DELETE 方法。这样一来,对数据的操作就清晰明了。

RPC 解决什么问题?
RPC 的全称叫 Remote Procedure Call,也就是远程过程调用。它和 REST 都是客户端向服务端发起请求的技术手段。

前面提到了 RESTful 能解决很多问题,但为何还需要 RPC 呢?

首先,前面提到了从 HTTP 1.0 开始,HTTP 协议增加了很多功能。在 HTTP2 出现前,HTTP 协议内容都是文本格式,功能越来越多导致协议内容变得越来越臃肿。在一些高性能场景下,特别是系统内部服务之间频繁请求的时候,臃肿的协议会带来不少网络开销,影响服务性能。而 RPC 在底层协议可以选择直接使用 TCP 这种长连接,协议内容可以做到比较精简。RPC 还可以对数据进行压缩后再传输,性能要比 HTTP 好很多。

其次,RESTful 只是一种约定,而不是一种强制规范,你可以遵守,也可以不遵守。这就导致不同技术水平的人,实现出来的接口风格和行为不一致。

比如,新手程序员在用 HTTP 协议实现 API 的时候,可能不小心给客户端多返回一些额外的数据,客户端也不一定会报错,但却存在数据泄漏的风险。而 RPC 通常利用像 protobuf 这种 IDL (Interactive Data Language ,交互式数据语言)来定义接口规范,并生成客户端和服务端的框架代码。

其中,IDL的作用是定义客户端和服务端之间的数据交互方式,利用生成的框架代码,在双方开发代码时形成强约束,是契约式编程的一种体现。在使用 RPC 的时候,服务端只能返回 IDL 中定义的数据,否则使用框架代码时会报错。

目前,主流跨平台 RPC 协议主要有 Thrift 和 gRPC。Thrift 是由 Facebook 开源的,底层主要基于 TCP 传输数据。而 gRPC 是由谷歌开源的,底层基于 HTTP2 协议。gRPC 由于支持丰富的中间件以及双向流模式,具有很强的易用性,应用越来越广泛。比如,ETCD 就提供了 gRPC 接口。

秒杀 API 设计

**秒杀系统中的 API 主要有两大块:API 服务、Admin 服务。**接下来,我将给你介绍下 RESTful 和 gRPC 在这两个服务中的应用。

API 服务 API 设计

根据我们之前做的需求分析,秒杀 API 服务主要给用户提供活动信息和抢购商品的功能。那么,秒杀 API 服务的 API 应当分为活动信息和抢购这两组,我们分别用 event 和 shop 来表示。

在 event 功能中,我们需要提供三个接口给前端:

第一个是 /event/list ,采用 GET 方法,用于获取所有正在进行或者即将进行的活动列表;

第二个是 /event/info,采用 GET 方法,用于查询某个商品当前的秒杀活动信息,参数是商品 ID;

第三个是 /event/subscribe,采用 POST 方法,用于订阅某商品的活动开始通知,参数是活动场次 ID 、商品 ID、设备 ID。

在 shop 功能中,我们只需要提供一个抢购接口即可:/shop/cart/add,具体可采用 PUT 方法,也就是将商品加到购物车,参数为商品 ID。这里之所以用 PUT 方法,是因为用户的购物车是一直存在的,用 PUT 方法是遵循 RESTful。

这些接口,都会返回一些相同的信息,比如:错误号、提示信息、用户是否已登录、数据等。它们可以用 Go 语言中的结构体定义,如下所示:

type Response struct {
   Code        int         `json:"code"`         // 业务错误码
   Data        interface{} `json:"data"`         // 数据
   Msg         string      `json:"msg"`          // 提示信息
}

需要注意的是,为了支持返回多种类型的数据,需要将结构体中的 Data 字段定义为 interface 类型。另外,登录状态 Login-Status 将放到 HTTP Header 中返回给前端。为了让 admin 也能复用这个结构体定义,我们需要将它放在 infrastructure/utils/response.go 里。

接下来,我们在 application/api 中定义 Event 和 Shop 这两个应用,并定义与前面接口对应的处理函数,代码如下:

type Event struct{
type Shop struct{
func (e *Event) List(ctx *gin.Context) {
   resp := &utils.Response{
      Code: 0,
      Data: nil,
      Msg:  "ok",
   }
   status := http.StatusOK
   logrus.Info("event list")
   ctx.JSON(status, resp)
}
func (e *Event) Info(ctx *gin.Context) {
   resp := &utils.Response{
      Code: 0,
      Data: nil,
      Msg:  "ok",
   }
   status := http.StatusOK
   logrus.Info("event info")
   ctx.JSON(status, resp)
}
func (e *Event) Subscribe(ctx *gin.Context) {
   resp := &utils.Response{
      Code: 0,
      Data: nil,
      Msg:  "ok",
   }
   status := http.StatusOK
   logrus.Info("event subscribe")
   ctx.JSON(status, resp)
}
func (s *Shop) AddCart(ctx *gin.Context) {
   resp := &utils.Response{
      Code: 0,
      Data: nil,
      Msg:  "ok",
   }
   status := http.StatusOK
   logrus.Info("shop add cart")
   ctx.JSON(status, resp)
}

接下来,我们实现一个 initRouters 函数,将前面实现的接口函数注册到 Gin 框架的路由中。代码在 interfaces/api 目录下的 routers.go 文件中,如下所示:

func initRouters(g *gin.Engine) {
   logrus.Info("init api routers")
   event := g.Group("/event")
   eventApp := api.Event{}
   event.GET("/list", eventApp.List)
   event.GET("/info", eventApp.Info)
   event.POST("/subscribe", eventApp.Subscribe)

   shop := g.Group("/shop")
   shopApp := api.Shop{}
   shop.PUT("/cart/add", shopApp.AddCart)
}

然后我们就可以在 api.go 文件的 Run 函数中调用 initRouters 函数,以便将路由注册到 Gin 框架中。

除了需要实现给浏览器请求的 API 接口外,我们还需要实现一个给 admin 请求的 RPC 服务,用于同步活动配置,主要提供专题、场次的上线和下线功能。代码在 application/api/rpc 目录下的 event.proto, 定义如下:

syntax = "proto3";
package rpc;
message Goods {
  int32 id = 1;
  string desc = 2;
  string img = 3;
  string price = 4;
  string event_price = 5;
  int32 event_stock = 6;
}
message Event {
  int32 id = 1;
  int32 topic_id = 2;
  int64 start_time = 3;
  int64 end_time = 4;
  int32 limit = 5;
  repeated Goods goods_list = 6;
}
message Topic {
  int32 id = 1;
  string title = 2;
  string desc = 3;
  string banner = 4;
  int64 start_time = 5;
  int64 end_time = 6;
}
message Response {
  int32 code = 1;
  string msg = 2;
}
service EventRPC {
  rpc EventOnline(Event) returns(Response);
  rpc EventOffline(Event) returns(Response);
  rpc TopicOnline(Topic) returns(Response);
  rpc TopicOffline(Topic) returns(Response);
}

你可以看到,我在该 protobuf 文件中定义了一个名为 EventRPC 的 RPC 服务,以及对应的参数和返回值类型,这些将会在后面生成服务端和客户端的 Go 代码。
我们要如何使用这个 protobuf 文件呢?让我们在 Makefile 中添加编译 protobuf 的指令 proto,如下所示:

proto: application/api/rpc/event.proto
   protoc --go_out=plugins=grpc:./ application/api/rpc/event.proto

通过执行 make proto,我们就可以在 application/api/rpc 目录下编译出一个 event.pb.go 文件。这个文件里面就是 RPC 接口的 Go 定义,包括服务端和客户端的定义,它便是秒杀 Admin 服务与 API 服务通信的契约。具体的定义如下:

// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://godoc.org/google.golang.org/grpc#ClientConn.NewStream.
type EventRPCClient interface {
   EventOnline(ctx context.Context, in *Event, opts ...grpc.CallOption) (*Response, error)
   EventOffline(ctx context.Context, in *Event, opts ...grpc.CallOption) (*Response, error)
   TopicOnline(ctx context.Context, in *Topic, opts ...grpc.CallOption) (*Response, error)
   TopicOffline(ctx context.Context, in *Topic, opts ...grpc.CallOption) (*Response, error)
}
// EventRPCServer is the server API for EventRPC service.
type EventRPCServer interface {
   EventOnline(context.Context, *Event) (*Response, error)
   EventOffline(context.Context, *Event) (*Response, error)
   TopicOnline(context.Context, *Topic) (*Response, error)
   TopicOffline(context.Context, *Topic) (*Response, error)
}

然后,我们还需要按照 event.pb.go 的定义实现 RPC 服务。具体代码在 application/api/rpc.go 中,如下所示:

type EventRPCServer struct {
}
func (s *EventRPCServer) EventOnline(ctx context.Context, evt *rpc.Event) (*rpc.Response, error) {
   logrus.Info("event online ", evt)
   resp := &rpc.Response{}
   return resp, nil
}
func (s *EventRPCServer) EventOffline(ctx context.Context, evt *rpc.Event) (*rpc.Response, error) {
   logrus.Info("event offline ", evt)
   resp := &rpc.Response{}
   return resp, nil
}
func (s *EventRPCServer) TopicOnline(ctx context.Context, t *rpc.Topic) (*rpc.Response, error) {
   logrus.Info("topic online ", t)
   resp := &rpc.Response{}
   return resp, nil
}
func (s *EventRPCServer) TopicOffline(ctx context.Context, t *rpc.Topic) (*rpc.Response, error) {
   logrus.Info("topic offline ", t)
   resp := &rpc.Response{}
   return resp, nil
}

同样地,我们也要像 API 服务那样提供启动和退出相关的函数,代码在 interfaces/rpc 目录下的 rpc.go 文件中,如下所示:

var grpcS *grpc.Server
func Run() error {
   bind := viper.GetString("api.rpc")
   logrus.Info("run RPC server on ", bind)
   lis, err := utils.Listen("tcp", bind)
   if err != nil {
      return err
   }
   grpcS = grpc.NewServer()
   eventRPC := &api.EventRPCServer{}
   rpc.RegisterEventRPCServer(grpcS, eventRPC)
   // 支持 gRPC reflection,方便调试
   reflection.Register(grpcS)
   return grpcS.Serve(lis)
}
func Exit() {
   grpcS.GracefulStop()
   logrus.Info("rpc server exit")
}

接下来,我们还需要在启动命令中加上启动 RPC 服务的代码。具体代码你可以看代码仓库中的 cmd/api.go。

最后,我们编译出可执行程序并运行,效果如下:

你可以看到,当请求 /event/list 接口时,服务输出了 "event list" 日志。使用 grpcurl 工具请求 EventRPC 的 EventOnline 接口时,服务输出了 "event online" 日志。

Admin 服务 API 设计
首先,我们回顾下需求分析时介绍的管理后台功能需求,主要有这几块:专题管理、场次管理、商品管理。每个模块分别有:列表、创建、查看、修改、删除等功能。其中,专题和场次还有上线、下线这两个功能。那么,我们需要实现的接口清单如下所示:

# 创建专题
POST /topic
# 获取专题列表
GET /topic?page=1&size=10
# 查看某个专题
GET /topic/{id}
# 修改某个专题
PUT /topic/{id}
# 上线/下线某个专题
PUT /topic/{id}/{status}
# 删除某个专题
DELETE /topic/{id}
# 创建场次
POST /event
# 获取场次列表
GET /eventpage=1&size=10
# 查看某个场次
GET /event/{id}
# 修改某个场次
PUT /event/{id}
# 上线/下线某个场次
PUT /event/{id}/{status}
# 删除某个场次
DELETE /event/{id}
# 创建商品
POST /goods
# 获取商品列表
GET /goods?page=1&size=10
# 查看某个商品
GET /goods/{id}
# 修改某个商品
PUT /goods/{id}
# 删除某个商品
DELETE /goods/{id}

由于接口比较多,我们在 application/admin 目录下新建三个文件,分别是 topic.go、event.go、goods.go,用于实现 admin 的 API 代码。

topic.go 中定义了 Topic 这个应用,它包含 4 个方法,分别是Post、Get、Put、Delete,代码如下:

type Topic struct{
func (t *Topic) Post(ctx *gin.Context) {
   resp := &utils.Response{
      Code: 0,
      Data: nil,
      Msg:  "ok",
   }
   status := http.StatusOK
   logrus.Info("topic post")
   ctx.JSON(status, resp)
}
func (t *Topic) Get(ctx *gin.Context) {
   resp := &utils.Response{
      Code: 0,
      Data: nil,
      Msg:  "ok",
   }
   status := http.StatusOK
   logrus.Info("topic get")
   ctx.JSON(status, resp)
}
func (t *Topic) Put(ctx *gin.Context) {
   resp := &utils.Response{
      Code: 0,
      Data: nil,
      Msg:  "ok",
   }
   status := http.StatusOK
   logrus.Info("topic put")
   ctx.JSON(status, resp)
}
func (t *Topic) Delete(ctx *gin.Context) {
   resp := &utils.Response{
      Code: 0,
      Data: nil,
      Msg:  "ok",
   }
   status := http.StatusOK
   logrus.Info("topic delete")
   ctx.JSON(status, resp)
}

event.go 中定义了 Event 这个应用,它的 4 个方法命名跟 topic 的一样,代码如下:

type Event struct{}
func (t *Event) Post(ctx *gin.Context) {
   resp := &utils.Response{
      Code: 0,
      Data: nil,
      Msg:  "ok",
   }
   status := http.StatusOK
   logrus.Info("event post")
   ctx.JSON(status, resp)
}
func (t *Event) Get(ctx *gin.Context) {
   resp := &utils.Response{
      Code: 0,
      Data: nil,
      Msg:  "ok",
   }
   status := http.StatusOK
   logrus.Info("event get")
   ctx.JSON(status, resp)
}
func (t *Event) Put(ctx *gin.Context) {
   resp := &utils.Response{
      Code: 0,
      Data: nil,
      Msg:  "ok",
   }
   status := http.StatusOK
   logrus.Info("event put")
   ctx.JSON(status, resp)
}
func (t *Event) Delete(ctx *gin.Context) {
   resp := &utils.Response{
      Code: 0,
      Data: nil,
      Msg:  "ok",
   }
   status := http.StatusOK
   logrus.Info("event delete")
   ctx.JSON(status, resp)
}

goods.go 中主要是定义了 Goods 这个应用,代码如下:

type Goods struct{}
func (t *Goods) Post(ctx *gin.Context) {
   resp := &utils.Response{
      Code: 0,
      Data: nil,
      Msg:  "ok",
   }
   status := http.StatusOK
   logrus.Info("goods post")
   ctx.JSON(status, resp)
}
func (t *Goods) Get(ctx *gin.Context) {
   resp := &utils.Response{
      Code: 0,
      Data: nil,
      Msg:  "ok",
   }
   status := http.StatusOK
   logrus.Info("goods get")
   ctx.JSON(status, resp)
}
func (t *Goods) Put(ctx *gin.Context) {
   resp := &utils.Response{
      Code: 0,
      Data: nil,
      Msg:  "ok",
   }
   status := http.StatusOK
   logrus.Info("goods put")
   ctx.JSON(status, resp)
}
func (t *Goods) Delete(ctx *gin.Context) {
   resp := &utils.Response{
      Code: 0,
      Data: nil,
      Msg:  "ok",
   }
   status := http.StatusOK
   logrus.Info("goods delete")
   ctx.JSON(status, resp)
}

接下来,我们需要参考前面 api 命令的实现,将应用关联到路由并注入框架中,也就是在 interfaces/admin/routers.go 中实现 initRouters 函数。代码如下:

func initRouters(g *gin.Engine) {
   topic := g.Group("/topic")
   topicApp := admin.Topic{}
   topic.POST("/", topicApp.Post)
   topic.GET("/", topicApp.Get)
   topic.GET("/:id", topicApp.Get)
   topic.PUT("/:id", topicApp.Put)
   topic.PUT("/:id/:status", topicApp.Put)
   topic.DELETE("/:id", topicApp.Delete)

   event := g.Group("/event")
   eventApp := admin.Event{}
   event.POST("/", eventApp.Post)
   event.GET("/", eventApp.Get)
   event.GET("/:id", eventApp.Get)
   event.PUT("/:id", eventApp.Put)
   event.PUT("/:id/:status", eventApp.Put)
   event.DELETE("/:id", eventApp.Delete)

   goods := g.Group("/goods")
   goodsApp := admin.Goods{}
   goods.POST("/", goodsApp.Post)
   goods.GET("/", goodsApp.Get)
   goods.GET("/:id", goodsApp.Get)
   goods.PUT("/:id", goodsApp.Put)
   goods.DELETE("/:id", goodsApp.Delete)
}

除此之外,我们还需要启动 admin 服务,以及处理服务退出时关闭连接的问题。这里我将 interfaces/api 目录下的 api.go 做了适当调整,把关于接口重用和程序更新的代码放到了 infrastructure/utils 目录下的 proc.go 和 listen.go 中,以便给 admin 复用。最终,我们在 interfaces/admin 目录下实现的 admin.go 代码如下:

var lis net.Listener
func Run() error {
   var err error
   bind := viper.GetString("admin.bind")
   lis, err = utils.Listen("tcp", bind)
   if err != nil {
      return err
   }
   g := gin.New()
   // 更新程序,给老版本发送信号
   go utils.UpdateProc("admin")
   // 初始化路由
   initRouters(g)
   // 运行服务
   return g.RunListener(lis)
}
func Exit() {
   lis.Close()
   // TODO: 等待请求处理完
   // time.Sleep(10 * time.Second)
}

接下来,我们就可以在 cmd/admin.go 中解析命令并启动 admin 了。代码如下:

var adminCmd = &cobra.Command{
   Use:   "admin",
   Short: "Seckill admin server.",
   Long:  `Seckill admin server.`,
   Run: func(cmd *cobra.Command, args []string) {
      onExit := make(chan error)
      go func() {
         if err := admin.Run(); err != nil {
            logrus.Error(err)
            onExit <- err
         }
         close(onExit)
      }()
      onSignal := make(chan os.Signal)
      signal.Notify(onSignal, syscall.SIGINT, syscall.SIGTERM)
      select {
      case sig := <-onSignal:
         logrus.Info("exit by signal ", sig)
         admin.Exit()
      case err := <-onExit:
         logrus.Info("exit by error ", err)
      }
   },
}
func init() {
   rootCmd.AddCommand(adminCmd)
}

最终编译出来的程序运行效果如下:

你可以看到,我在命令行下同时运行了 admin 和 api 服务,并且分别给它们发送请求都能正常接收。

到这里,秒杀系统的 API 就设计完毕,并实现了最基础的框架。你可以看到,在整个过程中,我并不是一次性将某个功能开发完毕,而是从外到内逐渐深入,并随时验证功能正确性。这个过程叫渐进式开发,是软件开发中常用的方法。

引自:拉勾打造千万级流量系统

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值