文档章节

golang中的context包

o
 osc_gu9d45li
发布于 2019/04/08 10:06
字数 4471
阅读 3
收藏 0

精选30+云产品,助力企业轻松上云!>>>

标准库的context

从设计角度上来讲, golang的context包提供了一种父routine对子routine的管理功能. 我的这种理解虽然和网上各种文章中讲的不太一样, 但我认为基本上还是很贴合实际的.

context包中定义了一个很重要的接口, 叫context.Context.它的使用逻辑是这样的:

  1. 当父routine需要创建一个子routine的时候, 父routine应当先创建一个context.Context的实例, 这个实例中包括的内容有:
    1. 对子routine生命周期的限制: 比如子routine应该什么时候自杀, 什么条件下自杀. 在服务端编程中, 一个生动的粟子就是: 接收请求的routine在将请求派发给工作routine的时候, 需要告诉工作routine: 超过400ms没处理完你就给我就地爆炸.
    2. 将一些数据共享给子routine.
    3. 在子routine运行过程中, 通过这个Context实例, 可以干涉子routine的生命周期
  2. 子routine拿到父routine创建的context.Context实例后, 开始干活, 干活的过程中, 需要:
    1. 遵守Context实例中关于自身生命周期的约束: 400ms请求没有处理完, 我要就地爆炸
    2. 在自杀之前将自己自杀的消息传递给Context, 这样父routine就可以得知自己的生命状态. 比如我200ms处理完了请求, 我要告诉父routine, 我已经好了
    3. 工作的时候, 如有必要, 从Context中获取一些必要数据.
    4. 工作结束时, 如有必要, 将一些工作成果发送给Context, 以让父routine得知: 比如, 我处理这个请求花费的时间是197ms
    5. 在运行过程中, 从Context接收来自你routine的调度信号

所以说很显然:

  1. Context实例是由父routine创建的. 创建之后传递给子routine作为行为规范
  2. 子routine一般是不允许操作这个Context实例的. 子routine应当耐心倾听, 仅在必要的时候, 比如自杀之前, 将一些信息传递给Context
  3. 一个Context的一生, 从生到死, 是和子routine绑定在一起的. 子routine生, Context生, 子routine死, Context
  4. 良好设计的服务端程序, 每个routine都应该有自己的Context. 而既然routine之间有父子关系树, 那么显然所有routine的Context之间也有一坨树型关系.

我们现在来看context/context.go中是如何实现这套工具的

1 首先是对基本Context的定义

// 定义了一个接口, 名为Context
type Context interface {
	// 返回这个Context的死亡时刻, 如果ok == false, 则这个Context是永生的
    Deadline() (deadline time.Time, ok bool)
    
    // 返回一个channel, 这个channel在Context被Cancel的时候被关闭
    // 如果Context是永生的, 则返回一个nil
    Done() <-chan struct{}
    
    // 在Context活着的时候, (Done()返回的channel还没被关闭), 它返回nil
    // 在Context死后, (Done()返回的channel被关闭), 它返回一个error实例用以说明:
    //   这个Context是为什么死掉的, 是被Cancel, 还是自然死亡?
    Err() error
    
    // 返回存储在Context中的通信数据
    // 注意: 不要滥用这个接口, 它不是用来给子routine传递参数用的!
    Value(key interface{}) interface
}

// 定义了两个error实例, 并为其中一个实例的error类型定义了三个方法
var Canceled = errors.New("context canceled") // 用以在Context被Cancel时, 从Err()返回
var DeadlineExceeded error = deadlineExceedError{} // 用以在Context自然死亡时, 从Err()返回
type deadlineExceedError struct{}
func (deadlineExceededError) Error() string   { return "context deadline exceeded" } // 实现error接口
func (deadlineExceededError) Timeout() bool   { return true }
func (deadlineExceededError) Temporary() bool { return true }

// 实现了一个Context类型: emptyCtx, 它有以下特点:
//  0. 这个类型不对外公开, 仅通过后面的两个接口公开它的两个实例
// 	1. 不能被Cancel
//  2. 也从不自然死亡, 它是永生的
//  3. 不同的实例之间需要有不同的地址, 所以它没有被定义成struct{}, 而是用一个int来替代
//  4. 它内部也不存储任何数据
type emptyCtx int

func (*emptyCtx) Deadline() (deadline time.Time, ok bool) {
	return
}

func (*emptyCtx) Done() <-chan struct{} {
	return nil
}

func (*emptyCtx) Err() error {
	return nil
}

func (*emptyCtx) Value(key interface{}) interface{} {
	return nil
}

// 定义了两个emptyCtx的实例, 并写了两个接口对外公开这两个实例
var (
	background = new(emptyCtx)
	todo       = new(emptyCtx)
)

func (e *emptyCtx) String() string {
	switch e {
	case background:
		return "context.Background"
	case todo:
		return "context.TODO"
	}
	return "unknown empty Context"
}

func Background() Context {
	return background
}

func TODO() Context {
	return todo
}

上面定义了Context的接口规范, 也定义了一个Context接口的实现: emptyCtx, 从代码上可以看出来, 标准库并不公开这个emptyCtx的实现, 你只能从它的公开接口context.Background()context.TODO()来访问两个已经实例化的emptyCtx实例.

这两个实例是用于为顶层routine使用的.下面我们再来看, 可被创建者Cancel的Context是怎么实现的

2 Context接口的实现: 支持Cancel操作的Context: 非公开类cancelCtx

首先是类定义

type cancelCtx struct {
	Context						   // 他爹

	mu       sync.Mutex            // 一个互斥锁, 用来保护其它字段
	done     chan struct{}         // Done()方法的返回值
	children map[canceler]struct{} // 这里记录了它的孩子
	err      error                 // Err()方法的返回值
}

我们在上面说了, 由于程序中的routine之间是有父子关系树存在的, 那么一个context正常情况下就有可能有孩子, 那么, 如果当前的routine持有的Context实例是可被Cancel的, 那么显然, 它的所有孩子routine, 也应当是可被Cancel的.

这就是为什么cancelCtx类中有Context字段和children字段的原因, 也是为什么children字段是一个map[canceler]struct{}类型的原因: key中记录着所有的孩子, value是没有意义的, 为什么这样写呢? 因为这里把map当成C++中的std::set在用!

key的类型canceler是一个接口, 一个表示Context必须可被Cancel的接口:

type canceler interface {
	cancel(removeFromParent bool, err error)
    
    // Context接口中的Done方法
	Done() <-chan struct{}
}

显然, cancelCtx类本身也是可被Cancel的, 所以它也要实现canceler这个接口

下面是cancelCtx类的方法实现:

// Context.Done的实现: 返回字段 done
func (c *cancelCtx) Done() <-chan struct{} {
	c.mu.Lock()	// 锁保护done字段的初始化
	if c.done == nil {
		c.done = make(chan struct{})
	}
	d := c.done
	c.mu.Unlock()
	return d
}
// Context.Err的实现
func (c *cancelCtx) Err() error {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.err
}

// String()方法实现
func (c *cancelCtx) String() string {
	return fmt.Sprintf("%v.WithCancel", c.Context)
}

// canceler.cancel接口实现
// 参数 removeFromParent 指示是否需要把它从它爹的孩子中除名
// 参数 err 将赋值给字段 err, 以供Context.Err方法返回
func (c *cancelCtx) cancel(removeFromParent bool, err error) {
	if err == nil {
		panic("context: internal error: missing cancel error")
	}
	c.mu.Lock()	// 上锁
	if c.err != nil {	// 如果err字段有值, 则说明已经被Cancel了
		c.mu.Unlock()
		return // already canceled
	}
	c.err = err
	if c.done == nil {	// 设置c.done, 以供Done方法返回
		c.done = closedchan
	} else {
		close(c.done)
	}
	
	// 挨个cancel它的所有孩子, 子随父死的时候, 并不除名父子关系
	for child := range c.children {
		// NOTE: acquiring the child's lock while holding parent's lock.
		child.cancel(false, err)
	}
	c.children = nil
	c.mu.Unlock()

	// 如有必要, 把它从它爹那里除名
	if removeFromParent {
		removeChild(c.Context, c)
	}
}

// 这是一个全局复用的, 被关闭的channel, 用于被Context.Done返回使用
var closedchan = make(chan struct{})

func init() {
	close(closedchan)
}

可以看到, cancelCtx本身并没有实现所有的Context接口中的方法. 其余没有实现的接口是通过Context这个没有指定字段名的字段实现的. 这是go的特殊语法糖: 继承接口.

在一个类型定义中, 声明一个接口类型字段, 并且还不指定字段的名称, 这代表

  1. 当前类型必然实现了接口类型
  2. 当调用接口方法时, 默认调用的是子字段的方法, 除非当前类型显式overwrite了一些方法的实现

其实就是一种更为灵活的继承写法

我们再来看, 当父routine需要创建一个带有Cancel功能的Context实例的时候, 应该怎么办:

// 首先是定义一个函数指针别名
type CancelFunc func()

// 再就是父routine创建带Cancel功能的子Context的函数
// 父routine将自己的Context实例传入, 这个函数会返回子Context(带Cancel功能)
// 还会返回一个可调用对象 cancel, 调用这个对象(函数), 就能达到Cancel的功能
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
	c := newCancelCtx(parent)	// 创建一个cancelCtx的实例
	propagateCancel(parent, &c)
	return &c, func() { c.cancel(true, Canceled) }
}

// 下面是WithCancel中引用的两个私有函数的实现

// 创建一个cancelCtx实例
func newCancelCtx(parent Context) cancelCtx {
	return cancelCtx{Context: parent}	// 把爹先记录下来
}

func propagateCancel(parent Context, child canceler) {
	// 如果父Context是不可Cancel, 什么也不做
	if parent.Done() == nil {
		return // parent is never canceled
	}
	// 如果父Context本身是可Cancel的
	if p, ok := parentCancelCtx(parent); ok {
        // 进入此分支, 说明父Context是以下三种之一:
        //  1. 是一个cancelCtx, 本身就可被Cancel
        //  2. 是一个timerCtx, timerCtx是canctx的一个子类, 也可被Cancel
        //  3. 是一个valueCtx, valueCtx继承体系上的某个爹, 是以上两者之一
        // 那么p就是那个父Context的继承体系中的cancelCtx实例
		p.mu.Lock()
		if p.err != nil {
            // 若p已经被Cancel或自然死亡, 作为儿子, 就必须死了
            // 直接调用p.cancel
			child.cancel(false, p.err)
		} else {
            // 若p还活着, 就把儿子添加到它的儿子列表中去
			if p.children == nil {
				p.children = make(map[canceler]struct{})
			}
			p.children[child] = struct{}{}
		}
		p.mu.Unlock()
	} else {
        // 进入此分支, 说明父Context虽然可被Cancel
        // 但并不是标准库中预设的cancelCtx或timerCtx两种可被Cancel的类型
        // 这意味着这个特殊的父Context, 内部并不能保证记录了所有儿子的列表
        // 这里就得新开一个routine, 时刻监视着父Context的生存状态
        // 一旦父Context死亡, 就立即调用child.cancel把儿子弄死
		go func() {
			select {
			case <-parent.Done():	// 如果爹死了, 把孩子弄死
				child.cancel(false, parent.Err())
			case <-child.Done():	// 如果孩子死了, 什么也不做
			}
		}()
	}
}

// 判断Context实例是否是一个可被Cancel的类型
// 标准库中可被Cancel的Context类型共有三种:
//    1. cancelCtx
//    2. timerCtx
// 仅有这两种
func parentCancelCtx(parent Context) (*cancelCtx, bool) {
	for {
		switch c := parent.(type) {
		case *cancelCtx:
			return c, true
		case *timerCtx:
			return &c.cancelCtx, true
		case *valueCtx:
			parent = c.Context
		default:
			return nil, false
		}
	}
}

3 当你使用WithCancel

一个简单的例子

这里来捋一捋, 当你调用WithCancel创建一个可被Cancel的Context实例时, 都发生了些什么:

// 第一步, 创建者routine本身必须持有一个Context
// 这里假定创建者就是main routine
// 我们调用 Background创建一个不可被Cancel, 不会自杀的Context
contextOfMain := ctx.Background()

// 第二步: 调用WithCancel创建子Context
contextOfSubRoutine, cancelFuncOfSubRoutine := ctx.WithCancel(contextOfMain)

用起来是十分简单的, 我们再来捋一捋第二步背后都发生了什么, 下面是伪码:

WithCancel(contextOfMain) {
    // 第一步: 调用newCancelCtx创建了一个 cancelCtx 私有类的实例, 长这样:
    c := cancelCtx {
        Context: contextOfMain,
        mu     : 默认值,
        done   : nil, // 虽然现在是nil, 但在调用Done()方法时会返回一个make(chan struct{})
        children: nil,
        err    : nil,
    }
    // 第二步, 调用propagateCancel(contextOfMain, c)
    // 内部大概发生这样:
    {
        if contextOfMain.Done() == nil {
            // 什么也没有发生
        }
    }
    // 第三步: 返回c, 并且构造一个CancelFunc返回
    // 先是返回c
    return &c // 这里返回的是c的地址
    // 再是原地构造一个CancelFunc
   	func () {
        c.cancel(true, Canceled)
   	}
   	
   	/*
   		注意:
   			c.cancel调用的是cancelCtx.cancel方法
   			Canceled是一个全局变量, 值 == errors.New("context canceled")
   	*/
}

然后, 你将这个创建好的cancelFuncOfSubRoutine传递给新启动的子routine, 过了几分钟, 你调用cancelFuncOfSubRoutine()意图主动Cancel掉子routine的时候, 内部是这样执行的:

// 其实内部执行的是
contextofSubRoutine.cancel(true, errors.New("context canceled")) {
	
    contextofSubRoutine.mu.Lock()
    contextofSubRoutine.err = errors.New("context canceled")
    contextofSubRoutine.done = closedchan // 这是一个已经被关闭的chan struct{}
    for child := range contextofSubRoutine.children {
    	// 递归Cancel掉子routine下的所有孙子
        // 而实际上它并没有孩子, 所以什么也不做
        child.cancel(false, errors.New("context canceled"))
    }
    contextofSubRoutine.children = nil // 一把火把孙子的尸首全烧了
    contextofSubRoutine.mu.Unlock()
    
    removeChild(contextofSubRoutine.Context, contextofSubRoutine) {
        p, ok := parentCancelCtx(contextOfMainRoutine, contextofSubRoutine)
        // 由于contextOfMainRoutine的类型是emptyCtx
        // 所以parentCancelCtx函数返回的是 nil, false
        所以, 什么也不做, 就返回了
    }
    
}

一个稍微复杂一点的例子

我们假设当前进程中的routine树(即是Context树)关系如下所示:

mainContext // emptyCtx
	|
	\->	subContext // cancelCtx
		|
		\-> subsubContext1 // cancelCtx
		\-> subsubContext2 // cancenCtx
		\-> subsubContext3 // cancelCtx

现在, subContext要创建第四个subsubContext4, 它会这样做:

// 在subRoutine中
subsubContext4, cancelFunc4 := ctx.WithCancel(subContext) {
    // 内部是这样的:
    
    // step 1: 调用 ctx.newCancelCtx()
    subsubContext4 := &cancelCtx {
        Context: subContext,
		mu     : 默认值,
        done   : nil, // 虽然现在是nil, 但在调用Done()时会返回一个make(chan struct{})
        children: nil,
        err    : nil,
    }
    // step 2: 调用propagateCancel(subContext, subsubContext4)
    {
        // p, ok := parentCancelCtx(subContext)
        {
            p := subContext
            ok := true
        }
        // 这里将subsubContext4加到subContext的儿子列表中去
        subContext.mu.Lock()
        subContext[subsubContext4] = struct{}{}
        subContext.mu.Unlock()
    }
    // step 3: 创建CancelFunc
    cancelFunc4 := func() {
        subsubContext4.cancel(true, errors.New("context canceled"))
    }
}

创建结束后, subContext长这样:

subContext := &cancelCtx {
    Context: mainContext,
    mu     : 默认值,
    done   : nil, // 虽然现在是nil, 但在调用Done()时会返回一个make(chan struct{})
    children : {
        subsubContext1 : struct{}{},
        subsubContext2 : struct{}{},
        subsubContext3 : struct{}{},
        subsubContext4 : struct{}{},
    },
    err    : nil
}

然后, 当subRoutine调用cancelFunc4意图弄死subsubRoutine4的时候, 会发生如下:

subsubContext4.cancel(true, errors.New("context canceled")){
    subsubContext4.mu.Lock()
    subsubContext4.err = errors.New("context canceled")
    subsubContext4.done = closedchan
    for child := range subsubContext4.children {
        // subsubContext4并没有孩子
        // 什么也不做
    }
    subsubContext4.children = nil
    subsubContext4.mu.Unlock()
    
    removeChild(subsubContext, subsubContext4) {
        // 从subContext的children中删除 subsubContext4
    }
}

而如果, 在Cancel了subRoutine4后, 主线程中直接要Cancel SubRoutine的话, 会发生什么? 会发生如下:

subContext.cancel(true, errors.New("context canceled")) {
    subContext.mu.Lock()
    subContext.err = errors.New("context canceled")
    subContext.done = closedchan
    for child := range subContext.children {
    	// 这里会调用 subsubContext1/2/3的cancel方法
        child.cancel(false, errors.New("context canceled"))
    }
    subContext.children = nil
    subContext.mu.Unlock()
    // 最后一步本身要将subContext从他爹那里除名
    // 但由于他爹是个emptyCtx, 所以什么也不做
}

基本把整个cancelCtx的流程理解掉之后, 后面的所谓的带DeadLine的Context就非常好理解了

4 Context接口的实现: 支持Deadline()操作的Context: 非公开类timerCtx

timerCtx实现了定时器功能: 到达指定时刻, 自杀.

首先是类定义:

type timerCtx struct {
	cancelCtx
	timer *time.Timer // Under cancelCtx.mu.

	deadline time.Time
}

需要注意的是两点:

  1. 它继承了cancelCtx
  2. 定时功能是由标准库的time.Timer实现的

先看它的Deadline()方法的实现, 这个方法就是它的灵魂

func (c *timerCtx) Deadline() (deadline time.Time, ok bool) {
	return c.deadline, true
}

只是简单的字段deadline的getter

它还重写了canceler.cancel方法:

func (c *timerCtx) cancel(removeFromParent bool, err error) {
	c.cancelCtx.cancel(false, err)
	if removeFromParent {
		// Remove this timerCtx from its parent cancelCtx's children.
		removeChild(c.cancelCtx.Context, c)
	}
	c.mu.Lock()
	if c.timer != nil {	// 主要是在Cancel时停掉内部的计时器
		c.timer.Stop()
		c.timer = nil
	}
	c.mu.Unlock()
}

再来看和WithCancel平级的WithDeadLine函数:

func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
	if cur, ok := parent.Deadline(); ok && cur.Before(d) {
		// 如果父Context也是一个有死期的Context, 并且死期还在儿子想死之前
        // 那么只是简单的调用WithCancel来给创建一个可被Cancel的cancelCtx即可
        // 这样, 创建出的子Context调用Deadline()方法时, 实质上调用的是他爹的Deadline(), 语义上完美完成任务
		return WithCancel(parent)
	}
    
    // 不然, 得带个定时器, 先把死期记在deadline字段中
	c := &timerCtx{
		cancelCtx: newCancelCtx(parent),
		deadline:  d,
	}
    // 设置逻辑: 爹死的时候儿子也得死, 并且如果可能, 把儿子记在爹的children字段中
	propagateCancel(parent, c)
    
    // 如果死期已经过了
	dur := time.Until(d)
	if dur <= 0 {
        // 原地自杀, 但还是要返回这个Context
		c.cancel(true, DeadlineExceeded) // deadline has already passed
		return c, func() { c.cancel(true, Canceled) }
	}
	c.mu.Lock()
    
    // 创建定时器
	defer c.mu.Unlock()
	if c.err == nil {
		c.timer = time.AfterFunc(dur, func() {
			c.cancel(true, DeadlineExceeded)
		})
	}
	return c, func() { c.cancel(true, Canceled) }
}

注意:

  1. WithDeadline也返回一个CancelFunc
  2. 如果爹死的比儿子预想的还早, 那只不过是用爹调用WithCancel创建了一个可Cancel的Context
  3. 如果死期在调用WithDeadline的时候已经到达了, 那么依然要给调用方返回一个死掉的儿子尸体, 只不过它的Done()Err()会指出这个Context已经死掉了
  4. 定时器的到期回调, 调用的就是canceler.cancel方法

可以看到, timerCtx只是对cancelCtx在功能上的追加. WithDeadline也只是简单的追加了一个定时器,逻辑还是比较简单的.

所以,如果到这里你已经脑子有点乱掉了, 还是要回头把cancelCtx理清

另外, 这里还提供了一个名为WithTimeout的函数, 其实与WithDeadline是完全等价的, 实现如下:

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
	return WithDeadline(parent, time.Now().Add(timeout))
}

这里我们就不再着重分析WithDeadline/WithTimeout的逻辑流程了

5 Context接口的实现: 带数据共享的非公开类valueCtx

整个定义十分简单, 就是在Context接口之上, 实现了对数据的存储而已, 并且只能存储一个key, 全文如下:


func WithValue(parent Context, key, val interface{}) Context {
   if key == nil {
      panic("nil key")
   }
   if !reflect.TypeOf(key).Comparable() {
      panic("key is not comparable")
   }
   return &valueCtx{parent, key, val}
}

type valueCtx struct {
   Context
   key, val interface{}
}

func (c *valueCtx) String() string {
   return fmt.Sprintf("%v.WithValue(%#v, %#v)", c.Context, c.key, c.val)
}

func (c *valueCtx) Value(key interface{}) interface{} {
   if c.key == key {
      return c.val
   }
   return c.Context.Value(key)
}

可以看到, valueCtx将所有重要功能的实现都委托到了父类上, 这在使用时就非常信赖父类, 也就是说, 如果你想仅仅依靠标准库的这些公开接口, 来直接在主routine下开启一个, 既带数据共享, 还带可Cancel功能的子Context的话, 你只能这样写:

mainContext := ctx.Background() // 主routine
// 为了创建一个带Cancel功能的valueCtx, 首先需要创建一个cancelCtx
tmpContext, subRoutineCancelFunc := ctx.WithCancel(mainContext)
subContext := ctx.WithValue(tmpContext, key, value)

6 总结

标准库的context包, 只实现了几个基本的Context接口的实现, 并且还很受限的只能通过公开接口WithXXX来创建, 这很显然是在鼓励你做下面的事情:

  1. 在已有的Context接口定义上, 定义你自己的Context实现类.
  2. 不要将过多的逻辑放置在Context中去, 让它只干好自己该干的事情: 那就是父子routine间生命周期的管理

并且显然context包只实现了Context的语义, 并没有实现相关的routine的操作: 比如在Cancel时掐死子进程, 在Deadline到期的时候自动自杀等. 这还需要由使用者自行实现.

o
粉丝 0
博文 500
码字总数 0
作品 0
私信 提问
加载中
请先登录后再评论。

暂无文章

Pycharm文件打开方式

Pycharm修改文件默认打开方式 新下载了一个Pycharm,建了个小demo,期间产生了一个sqlite3文件,由于是第一次打开,就弹出选择打开方式的对话框,手一块直接点了个Text,然后就乱码了: 那我...

osc_fi9eaftu
5分钟前
0
0
微信域名检测中反应速度的重要性

随着微信域名检测的普及,越来越多的人重视这方面有个客户是这样跟我说的,他现在用的那个检测有频率限制 最快只能一秒检测一个, 并发多的时候是不能边跳转边检测的, 只能写到计划任务里面...

mkapi01
6分钟前
0
0
状压dp大总结1 [洛谷]

前言 状态压缩是一种\(dp\)里的暴力,但是非常优秀,状态的转移,方程的转移和定义都是状压\(dp\)的难点,本人在次总结状压dp的几个题型和例题,便于自己以后理解分析状态和定义方式 状态压缩...

osc_s28jz759
7分钟前
0
0
aspnet core 2.1中使用jwt从原理到精通一

目录 原理; 根据原理使用C#语言,生成jwt; 自定义验证jwt; 使用aspnetcore 中自带的类生成jwt; 学有所得 了解jwt原理; 使用C#轻松实现jwt生成和验证 原理 jwt对所有语言都是通用的,只要...

osc_1ls4yaq1
9分钟前
0
0
github上DQN代码的环境搭建,及运行(Human-Level Control through Deep Reinforcement Learning)conda配置

最近师弟在做DQN的实验,由于是强化学习方面的东西,正好和我现在的研究方向一样于是我便帮忙跑了跑实验,于是就有了今天的这个内容。 首先在github上进行搜寻,如下图: 发现第一个星数最多...

osc_252iaxru
10分钟前
8
0

没有更多内容

加载失败,请刷新页面

加载更多

返回顶部
顶部