你好,我是轩脉刃。
上面两节课把数据库操作接入到hade框架中了,现在我们能使用容器中的ORM服务来操作数据库了。在实际工作中,一旦数据库出现性能瓶颈,除了优化数据库本身之外,另外一个常用的方法是使用缓存来优化业务请求。所以这节课,我们来讨论一下,hade框架如何提供缓存支持。
现在的Web业务,大部分都是使用Redis来做缓存实现。但是,缓存的实现方式远不止Redis一种,比如在Redis出现之前,Memcached一般是缓存首选;在单机上,还可以使用文件来存储数据,又或者直接使用进程的内存也可以进行缓存实现。
缓存服务的底层使用哪个存储方式,和具体的业务架构原型相关。我个人在不同业务场景中用过不少的缓存存储方案,不过业界用的最多的Redis,还是优点比较突出。相比文件存储,它能集中分布式管理;而相比Memcached,优势在于多维度的存储数据结构。所以,顺应潮流,我们hade框架主要也针对使用Redis来实现缓存服务。
我们这节课会创建两个服务,一个是Redis服务,提供对Redis的封装,另外一个是缓存服务,提供一系列对“缓存”的统一操作。而这些统一操作,具体底层是由Redis还是内存进行驱动的,这个可以根据配置决定。
下面我们一个个来讨论吧。
首先封装一个可以对Redis进行操作的服务。和封装ORM一样,我们自己并不实现Redis的底层传输协议和操作封装,只将Redis“创建连接”的过程封装在hade中就行了。
这里我们就选择go-redis这个库来实现对Redis的连接。这个库目前也是Golang开源社区最常用的Redis库,有12.8k的star数,使用的是BSD 协议,可以引用,可以修改,但是修改的同时要保留版权声明,这里我们并不需要修改,所以BSD已经足够了。这个库目前是v8版本,可以使用 go get github.com/go-redis/redis/v8
来引入它。
go-redis的连接非常简单,我们看官网的例子,就看创建连接部分:
import (
"context"
"github.com/go-redis/redis/v8"
)
var ctx = context.Background()
func ExampleClient() {
// 创建连接
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
Password: "", // no password set
DB: 0, // use default DB
})
...
}
核心的redis.NewClient方法,返回的是一个*redis.Client结构,它就相当于Gorm中的DB数据结构,就是我们要实例化Redis的实例。这个结构是一个封装了300+个Redis操作的数据结构,你可以使用 go doc github.com/go-redis/redis/v8.Client
来观察它封装的Redis操作。
redis.NewClient方法还有一个参数:*redis.Options 数据结构。这个数据结构就相当于Gorm中的gorm.Config,里面封装了实例化redis.Client的各种配置信息,来看一些重要的配置,都做了注释:
// redis的连接配置
type Options struct {
// 网络情况
// Default is tcp.
Network string
// host:port 格式的地址
Addr string
// redis的用户名
Username string
// redis密码
Password string
// redis的database
DB int
// 连接超时
// Default is 5 seconds.
DialTimeout time.Duration
// 读超时
// Default is 3 seconds.
ReadTimeout time.Duration
// 写超时
// Default is ReadTimeout.
WriteTimeout time.Duration
// 最小空闲连接数
MinIdleConns int
// 最大连接时长
MaxConnAge time.Duration
// 空闲连接时长
// Default is 5 minutes. -1 disables idle timeout check.
IdleTimeout time.Duration
...
}
这些配置项相信你也非常熟悉了,既有连接请求的配置项,也有连接池的配置项。
和Gorm的配置封装一样,我们想要给用户提供一个配置即用的缓存服务,需要做如下三个事情:
在framework/contract/redis.go中,我们首先定义RedisConfig数据结构,这个结构单纯封装redis.Options就行了,没有其他额外的参数需要设置:
// RedisConfig 为hade定义的Redis配置结构
type RedisConfig struct {
*redis.Options
同时为这个RedisConfig定义一个唯一标识,来标识一个redis.Client。这里我们选用了Addr、DB、UserName、Network 四个字段值来标识。基本上这四个字段加起来能标识“用什么账号登录哪个Redis地址的哪个database”了:
// UniqKey 用来唯一标识一个RedisConfig配置
func (config *RedisConfig) UniqKey() string {
return fmt.Sprintf("%v_%v_%v_%v", config.Addr, config.DB, config.Username, config.Network)
}
RedisConfig结构定义完成,下面想要把它加载并支持可修改,我们要结合实例化redis.Client对象来说。
如何封装Redis的连接实例,这个同Gorm的封装一样,使用Option可变参数的方式。还是在 framework/contract/redis.go中继续写入:
package contract
...
const RedisKey = "hade:redis"
// RedisOption 代表初始化的时候的选项
type RedisOption func(container framework.Container, config *RedisConfig) error
// RedisService 表示一个redis服务
type RedisService interface {
// GetClient 获取redis连接实例
GetClient(option ...RedisOption) (*redis.Client, error)
}
定义了一个RedisService,表示Redis服务对外提供的协议,它只有一个GetClient方法,通过这个方法能获取到Redis的一个连接实例redis.Client。
你能看到GetClient方法有一个可变参数RedisOption,这个可变参数是一个函数结构,参数中带有传递进入了的RedisConfig指针,所以这个RedisOption是有修改RedisConfig结构的能力的。
那具体提供哪些RedisOption函数呢?和ORM一样,我们要提供多层次的修改方案,包括默认配置、按照配置项进行配置,以及手动配置:
在实现这三个函数之前,有必要先看一下我们的Redis配置文件cofig/testing/redis.yaml:
timeout: 10s # 连接超时
read_timeout: 2s # 读超时
write_timeout: 2s # 写超时
write:
host: localhost # ip地址
port: 3306 # 端口
db: 0 #db
username: jianfengye # 用户名
password: "123456789" # 密码
timeout: 10s # 连接超时
read_timeout: 2s # 读超时
write_timeout: 2s # 写超时
conn_min_idle: 10 # 连接池最小空闲连接数
conn_max_open: 20 # 连接池最大连接数
conn_max_lifetime: 1h # 连接数最大生命周期
conn_max_idletime: 1h # 连接数空闲时长
和database.yaml的配置一样,根级别的作为默认配置,二级配置作为单个Redis的配置,并且二级配置会覆盖默认配置。这里还有一个小心思,特意将这些配置项都和database.yaml保持一致了,这样使用者在配置的时候能减少学习成本。
这三个方法的具体实现和Gorm没有什么太大的区别。基本方法就是使用容器中的配置服务、读取配置信息,然后修改参数中的RedisConfig指针,你可以参考分支中的代码文件framework/provider/redis/config.go。
我们重点把注意力放在GetClient方法的实现上,写在framework/provider/redis/service.go中。类似gorm的GetDB方法,它是一个单例模式,就是一个RedisConfig,只产生一个redis.Client,用一个map加上一个lock来初始化Redis实例:
// HadeRedis 代表hade框架的redis实现
type HadeRedis struct {
container framework.Container // 服务容器
clients map[string]*redis.Client // key为uniqKey, value为redis.Client (连接池)
lock *sync.RWMutex
}
在GetClient函数中,首先还是获取基本Redis配置 redisConfig,使用参数opts对redisConfig进行修改,最后判断当前redisConfig是否已经实例化了:
// GetClient 获取Client实例
func (app *HadeRedis) GetClient(option ...contract.RedisOption) (*redis.Client, error) {
// 读取默认配置
config := GetBaseConfig(app.container)
// option对opt进行修改
for _, opt := range option {
if err := opt(app.container, config); err != nil {
return nil, err
}
}
// 如果最终的config没有设置dsn,就生成dsn
key := config.UniqKey()
// 判断是否已经实例化了redis.Client
app.lock.RLock()
if db, ok := app.clients[key]; ok {
app.lock.RUnlock()
return db, nil
}
app.lock.RUnlock()
// 没有实例化gorm.DB,那么就要进行实例化操作
app.lock.Lock()
defer app.lock.Unlock()
// 实例化gorm.DB
client := redis.NewClient(config.Options)
// 挂载到map中,结束配置
app.clients[key] = client
return client, nil
}
这里只讲了Redis服务的接口和服务实现的关键函数,其中provider的实现基本上和ORM的一致,没有什么特别,就不在这里重复列出代码了。
到这里我们就将Redis的服务融合进入hade框架了。但Redis只是缓存服务的一种实现,我们这节课最终目标是想实现一个缓存服务。
缓存服务的使用方式其实非常多,我们可以设置有超时/无超时的缓存,也可以使用计数器缓存,一份好的缓存接口的设计,能对应用的缓存使用帮助很大。
所以这一部分,相比缓存服务的具体实现,缓存服务的协议设计直接影响了这个服务的可用性,我们要重点理解对缓存协议的设计。
实现一个服务的三步骤,服务协议、服务提供者、服务实例。就先从协议开始,我们希望这个缓存服务提供哪些能力呢?
首先,缓存协议一定是有两个方法,一个设置缓存、一个获取缓存。设定为Get方法为获取缓存,Set方法为设置缓存。
// Get 获取某个key对应的值
Get(ctx context.Context, key string) (string, error)
// Set 设置某个key和值到缓存,带超时时间
Set(ctx context.Context, key string, val string, timeout time.Duration) error
同时,注意设置缓存的时候,又区分出两种需求,我们需要设置带超时时间的缓存,也需要设置不带超时时间的、永久的缓存。所以,Set方法衍生出Set和SetForever两种。
// SetForever 设置某个key和值到缓存,不带超时时间
SetForever(ctx context.Context, key string, val string) error
在设置了某个key之后,会不会需要修改这个缓存key的缓存时长呢?完全是有可能的,比如将某个key的缓存时长加大,或者想要获取某个key的缓存时长,所以我们再把注意力放在缓存时长的操作上,提供对缓存时长的操作函数SetTTL和GetTTL:
// SetTTL 设置某个key的超时时间
SetTTL(ctx context.Context, key string, timeout time.Duration) error
// GetTTL 获取某个key的超时时间
GetTTL(ctx context.Context, key string) (time.Duration, error)
再来,Get和Set目前对应的value值为string,但是我们希望value值能不仅仅是一个字符串,它还可以直接是一个对象,这样缓存服务就能存储和获取一个对象出来,能大大方便缓存需求。
所以我们定义两个GetObj和SetObj方法,来实现对象的缓存存储和获取,但是这个对象在实际存储的时候,又势必要进行序列化和反序列的过程,所以我们对存储和获取的对象再增加一个要求,让它实现官方库的BinaryMarshaler和BinaryUnMarshaler接口:
// GetObj 获取某个key对应的对象, 对象必须实现 https://pkg.go.dev/encoding#BinaryUnMarshaler
GetObj(ctx context.Context, key string, model interface{}) error
// SetObj 设置某个key和对象到缓存, 对象必须实现 https://pkg.go.dev/encoding#BinaryMarshaler
SetObj(ctx context.Context, key string, val interface{}, timeout time.Duration) error
// SetForeverObj 设置某个key和对象到缓存,不带超时时间,对象必须实现 https://pkg.go.dev/encoding#BinaryMarshaler
SetForeverObj(ctx context.Context, key string, val interface{}) error
现在,我们已经可以一个key进行缓存获取和设置了,但是有时候要同时对多个key做缓存的获取和设置,来设置对多个key进行操作的方法GetMany和SetMany:
// GetMany 获取某些key对应的值
GetMany(ctx context.Context, keys []string) (map[string]string, error)
// SetMany 设置多个key和值到缓存
SetMany(ctx context.Context, data map[string]string, timeout time.Duration) error
在实际业务中,我们还会有一些计数器的需求,需要将计数器存储到缓存,同时也要能对这个计数器缓存进行增加和减少的操作。可以为计数器缓存设计Calc、Increment、Decrement的接口:
// Calc 往key对应的值中增加step计数
Calc(ctx context.Context, key string, step int64) (int64, error)
// Increment 往key对应的值中增加1
Increment(ctx context.Context, key string) (int64, error)
// Decrement 往key对应的值中减去1
Decrement(ctx context.Context, key string) (int64, error)
缓存的使用有一种Cache-Aside模式,可以提升“获取数据”的性能。可能你没有听过这个名字,但其实我们都用过,这个模式描述的就是在实际操作之前,先去缓存中查看有没有对应的数据,如果有的话,不进行操作,如果没有的话才进行实际操作生成数据,并且把数据存储在缓存中。
我们希望缓存服务也能支持这种Cache-Aside模式。如何支持呢?
首先,要有一个生成数据的通用方法结构,我们定义为RememberFunc,让这个函数将服务容器传递进去,这样在具体的实现中,使用者就可以从服务容器中获取各种各样的具体注册服务了,能大大增强这个RemeberFunc的实现能力:
// RememberFunc 缓存的Remember方法使用,Cache-Aside模式对应的对象生成方法
type RememberFunc func(ctx context.Context, container framework.Container) (interface{}, error)
然后,我们为缓存服务定义一个Remember方法,来实现这个Cache-Aside模式。
// Remember 实现缓存的Cache-Aside模式, 先去缓存中根据key获取对象,如果有的话,返回,如果没有,调用RememberFunc 生成
Remember(ctx context.Context, key string, timeout time.Duration, rememberFunc RememberFunc, model interface{}) error
它的参数来仔细看下。除了context之外,有一个key,代表这个缓存使用的key,其次是timeout 代表缓存时长,接着是前面定义的 RememberFunc了,代表如果缓存中没有这个key,就调用RememberFunc函数来生成数据对象。
这个数据对象从哪里输出呢?就是这里的最后一个参数model了,当然这个Obj必须实现BinaryMarshaler和BinaryUnmarshaler接口。这样定义之后,Remember的具体实现就简单了。
看这个我在单元测试代码provider/cache/services/redis_test.go中写的测试:
type Bar struct {
Name string
}
func (b *Bar) MarshalBinary() ([]byte, error) {
return json.Marshal(b)
}
func (b *Bar) UnmarshalBinary(bt []byte) error {
return json.Unmarshal(bt, b)
}
Convey("remember op", func() {
objNew := Bar{}
objNewFunc := func(ctx context.Context, container framework.Container) (interface{}, error) {
obj := &Bar{
Name: "bar",
}
return obj, nil
}
err = mc.Remember(ctx, "foo_remember", 1*time.Minute, objNewFunc, &objNew)
So(err, ShouldBeNil)
So(objNew.Name, ShouldEqual, "bar")
})
我们定义了Bar结构,它实现了BinaryMarshaler和BinaryUnmarshaler接口,并且定义了一个objNewFunc方法实现了前面我们定义的RememberFunc。
之后可以使用Remember方法来为这个方法设置一个Cache-Aside缓存,它的key为foo_remember,缓存时长为1分钟。
最后回看一下我们对缓存的协议定义,各种缓存的设置和获取方法都有了,还差删除缓存的方法对吧。所以来定义删除单个key的缓存和删除多个key的缓存:
// Del 删除某个key
Del(ctx context.Context, key string) error
// DelMany 删除某些key
DelMany(ctx context.Context, keys []string) error
到这里缓存协议就定义完成了,一共16个方法,要好好理解下这些方法的定义,还是那句话,理解如何定义协议比实现更为重要。
下面来实现这个缓存服务。前面一再强调了,Redis只是缓存的一种实现,Redis之外,我们可以用不同的存储来实现缓存,甚至,可以使用内存来实现。目前hade框架支持内存和Redis实现缓存,这里我们就先看看如何用Redis来实现缓存。
由于缓存有不同实现,所以和日志服务一样,要使用配置文件来cache.yaml中的driver字段,来区别使用哪个缓存。如果driver为redis,表示使用Redis来实现缓存,如果为memory,表示用内存来实现缓存。当然如果使用Redis的话,就需要同时带上Redis连接的各种参数,参数关键字都类似前面说的Redis服务的配置。
一个典型的cache.yaml的配置如下:
driver: redis # 连接驱动
host: 127.0.0.1 # ip地址
port: 6379 # 端口
db: 0 #db
timeout: 10s # 连接超时
read_timeout: 2s # 读超时
write_timeout: 2s # 写超时
#driver: memory # 连接驱动
那对应到具体实现上,区分使用哪个缓存驱动,我们会在服务提供者provider中来进行。在provider中,注意下Register方法,注册具体的服务实例方法时,要先读取配置中的cache.driver路径:
// Register 注册一个服务实例
func (l *HadeCacheProvider) Register(c framework.Container) framework.NewInstance {
if l.Driver == "" {
tcs, err := c.Make(contract.ConfigKey)
if err != nil {
// 默认使用console
return services.NewMemoryCache
}
cs := tcs.(contract.Config)
l.Driver = strings.ToLower(cs.GetString("cache.driver"))
}
// 根据driver的配置项确定
switch l.Driver {
case "redis":
return services.NewRedisCache
case "memory":
return services.NewMemoryCache
default:
return services.NewMemoryCache
}
}
如果是Redis驱动,我们使用service.NewRedisCache来初始化一个Redis连接,定义RedisCache结构来存储redis.Client。
在初始化的时候,先确定下容器中是否已经绑定了Redis服务,如果没有的话,做一下绑定操作。这个行为能让我们的缓存容器更为安全。
接着使用cache.yaml中的配置,来初始化一个redis.Client,这里使用的redisService.GetClient和redis.WithConfigPath,都是上面设计Redis服务的时候刚设计实现的方法。最后将redis.Client 封装到RedisCache中,返回:
import (
"context"
"errors"
redisv8 "github.com/go-redis/redis/v8"
"github.com/gohade/hade/framework"
"github.com/gohade/hade/framework/contract"
"github.com/gohade/hade/framework/provider/redis"
"sync"
"time"
)
// RedisCache 代表Redis缓存
type RedisCache struct {
container framework.Container
client *redisv8.Client
lock sync.RWMutex
}
// NewRedisCache 初始化redis服务
func NewRedisCache(params ...interface{}) (interface{}, error) {
container := params[0].(framework.Container)
if !container.IsBind(contract.RedisKey) {
err := container.Bind(&redis.RedisProvider{})
if err != nil {
return nil, err
}
}
// 获取redis服务配置,并且实例化redis.Client
redisService := container.MustMake(contract.RedisKey).(contract.RedisService)
client, err := redisService.GetClient(redis.WithConfigPath("cache"))
if err != nil {
return nil, err
}
// 返回RedisCache实例
obj := &RedisCache{
container: container,
client: client,
lock: sync.RWMutex{},
}
return obj, nil
}
好,有Redis缓存的实例了,下面来看16个方法的实现。
Set系列的方法一共有Set/SetObj/SetMany/SetForever/SetForeverObj/SetTTL 6个,其他5个相对简单一些,在生成的redis.Client结构中都有对应实现,我们直接使用redis.Client调用即可,就不赘述了。其中SetMany方法相对复杂些,我们着重说明下。
在Redis中,SetMany这种为多个key设置缓存的方法,一般可以遍历key,然后一个个调用Set方法,但是这样效率就低了。更好的实现方式是使用pipeline。
什么是Redis的pipeline呢?Redis的客户端和服务端的交互,采用的是客户端-服务端模式,就是每个客户端的请求发送到Redis服务端,都会有一个完整的响应。所以,向服务端发送n个请求,就对应有n次响应。那么对于这种n个请求且n个请求没有上下文逻辑关系,我们能不能批量发送,但是只发送一次请求,然后只获取一次响应呢?
Redis的pipeline就是这个原理,它将多个请求合成为一个请求,批量发送给Redis服务端,并且只从服务端获取一次数据,拿到这些请求的所有结果。
我们的SetMany就很符合这个场景。具体的代码如下:
// SetMany 设置多个key和值到缓存
func (r *RedisCache) SetMany(ctx context.Context, data map[string]string, timeout time.Duration) error {
pipline := r.client.Pipeline()
cmds := make([]*redisv8.StatusCmd, 0, len(data))
for k, v := range data {
cmds = append(cmds, pipline.Set(ctx, k, v, timeout))
}
_, err := pipline.Exec(ctx)
return err
}
先用redis.Client.Pipeline() 来创建一个pipeline管道,然后用一个redis.StatusCmd数组来存储要发送的所有命令,最后调用一次pipeline.Exec来一次发送命令。
Set方法就讲到这里,Get系列的方法一共有4个,Get/GetObj/GetMany/GetTTL。
在实现Get系列方法的时候有地方需要注意下,因为Get是有可能Get一个不存在的key的,对于这种不存在的key是否返回error,是一个可以稍微思考的话题。
比如Get这个方法,返回的是string和error,如果对于一个不存在的key,返回了空字符串+空error的组合,而对于一个设置了空字符串的key,也返回空字符串+空error的组合,这里其实是丢失了“是否存在key”的信息的。
所以,对于这些不存在的key,我们设计返回一个 ErrKeyNotFound 的自定义error。像Get函数就实现为如下:
// Get 获取某个key对应的值
func (r *RedisCache) Get(ctx context.Context, key string) (string, error) {
val, err := r.client.Get(ctx, key).Result()
// 这里判断了key是否为空
if errors.Is(err, redisv8.Nil) {
return val, ErrKeyNotFound
}
return val, err
}
其他Get相关的实现没有什么难点。
除了Get系列和Set系列,其他的方法有Calc、Increment、Decrement、Del、DelMany 都没有什么太复杂的逻辑,都是redis.Client的具体封装。
最后看下Remember这个方法:
// Remember 实现缓存的Cache-Aside模式, 先去缓存中根据key获取对象,如果有的话,返回,如果没有,调用RememberFunc 生成
func (r *RedisCache) Remember(ctx context.Context, key string, timeout time.Duration, rememberFunc contract.RememberFunc, obj interface{}) error {
err := r.GetObj(ctx, key, obj)
// 如果返回为nil,说明有这个key,且有数据,obj已经注入了,返回nil
if err == nil {
return nil
}
// 有err,但是并不是key不存在,说明是有具体的error的,不能继续往下执行了,返回err
if !errors.Is(err, ErrKeyNotFound) {
return err
}
// 以下是key不存在的情况, 调用rememberFunc
objNew, err := rememberFunc(ctx, r.container)
if err != nil {
return err
}
// 设置key
if err := r.SetObj(ctx, key, objNew, timeout); err != nil {
return err
}
// 用GetObj将数据注入到obj中
if err := r.GetObj(ctx, key, obj); err != nil {
return err
}
return nil
}
前面说过Remember方法是Cache-Aside模式的实现,它的逻辑是先判断缓存中是否有这个key,如果有的话,直接返回对象,如果没有的话,就调用RememberFunc方法来实例化这个对象,并且返回这个实例化对象。
好了,这里的framework/provider/cache/redis.go我们实现差不多了。
来做验证,我们为缓存服务写一个简单的路由,在这个路由中:
// DemoCache cache的简单例子
func (api *DemoApi) DemoCache(c *gin.Context) {
logger := c.MustMakeLog()
logger.Info(c, "request start", nil)
// 初始化cache服务
cacheService := c.MustMake(contract.CacheKey).(contract.CacheService)
// 设置key为foo
err := cacheService.Set(c, "foo", "bar", 1*time.Hour)
if err != nil {
c.AbortWithError(500, err)
return
}
// 获取key为foo
val, err := cacheService.Get(c, "foo")
if err != nil {
c.AbortWithError(500, err)
return
}
logger.Info(c, "cache get", map[string]interface{}{
"val": val,
})
// 删除key为foo
if err := cacheService.Del(c, "foo"); err != nil {
c.AbortWithError(500, err)
return
}
c.JSON(200, "ok")
}
增加对应的路由:
r.GET("/demo/cache/redis", api.DemoRedis)
在浏览器中请求地址: http://localhost:8888/demo/cache/redis:
查看控制台输出的日志:
可以明显看到cacheService.Get的数据为bar,打印了出来。验证正确!
本节课我们主要修改了framework目录下Redis和cache相关的代码。目录截图也放在这里供你对比查看,所有代码都已经上传到geekbang/27分支了。
除DB之外,缓存是我们最常使用的一个存储了,今天我们先是实现了Redis的服务,再用Redis服务实现了一个缓存服务。
第一部分的Redis服务,同上一节课ORM的逻辑一样,我们只是将go-redis库进行了封装,具体怎么使用,还是依赖你在实际工作中多使用、多琢磨,网上也有很多go-redis库的相关资料。
在第二部分实现的过程中,相信你现在能理解,一个服务的接口设计,就是一个“我们想要什么服务”的思考过程。比如在缓存服务接口设计中,我们定义了16个方法,囊括了Get/Set/Del/Remember等一系列方法,你可以对照思维导图复习一下。但这些方法并不是随便拍脑袋出来的,是因为有设置缓存、获取缓存、删除缓存等需求,才这样设计的。
目前hade框架支持内存和Redis实现缓存,我们今天展示了Redis的实现。缓存服务的内存缓存如何实现呢?可以先思考一下,如果是你来实现会如何设计呢?如果有兴趣,你可以自己动手操作一下。完成之后,你可以比对GitHub分支上我已经实现的版本,看看有没有更好的方案。
欢迎在留言区分享你的思考。感谢你的收听,如果你觉得今天的内容对你有所帮助,也欢迎分享给你身边的朋友,邀请他一起学习。我们下节课见~