02|Context:请求控制器,让每个请求都在掌控之中

思考并回答以下问题:

添加上下文Context为请求设置超时时间。

framework/context.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
package framework

import (
"bytes"
"context"
"encoding/json"
"errors"
"io/ioutil"
"net/http"
"strconv"
"sync"
"time"
)

type Context struct {
request *http.Request
responseWriter http.ResponseWriter
ctx context.Context
handler ControllerHandler

// 是否超时标记位
hasTimeout bool
// 写保护机制
writerMux *sync.Mutex
}

func NewContext(r *http.Request, w http.ResponseWriter) *Context {
return &Context{
request: r,
responseWriter: w,
ctx: r.Context(),
writerMux: &sync.Mutex{},
}
}

// #region base function

func (ctx *Context) WriterMux() *sync.Mutex {
return ctx.writerMux
}

func (ctx *Context) GetRequest() *http.Request {
return ctx.request
}

func (ctx *Context) GetResponse() http.ResponseWriter {
return ctx.responseWriter
}

func (ctx *Context) SetHasTimeout() {
ctx.hasTimeout = true
}

func (ctx *Context) HasTimeout() bool {
return ctx.hasTimeout
}

// #endregion

func (ctx *Context) BaseContext() context.Context {
return ctx.request.Context()
}

// #region implement context.Context
func (ctx *Context) Deadline() (deadline time.Time, ok bool) {
return ctx.BaseContext().Deadline()
}

func (ctx *Context) Done() <-chan struct{} {
return ctx.BaseContext().Done()
}

func (ctx *Context) Err() error {
return ctx.BaseContext().Err()
}

func (ctx *Context) Value(key interface{}) interface{} {
return ctx.BaseContext().Value(key)
}

// #endregion

// #region query url
func (ctx *Context) QueryInt(key string, def int) int {
params := ctx.QueryAll()
if vals, ok := params[key]; ok {
len := len(vals)
if len > 0 {
intval, err := strconv.Atoi(vals[len-1])
if err != nil {
return def
}
return intval
}
}
return def
}

func (ctx *Context) QueryString(key string, def string) string {
params := ctx.QueryAll()
if vals, ok := params[key]; ok {
len := len(vals)
if len > 0 {
return vals[len-1]
}
}
return def
}

func (ctx *Context) QueryArray(key string, def []string) []string {
params := ctx.QueryAll()
if vals, ok := params[key]; ok {
return vals
}
return def
}

func (ctx *Context) QueryAll() map[string][]string {
if ctx.request != nil {
return map[string][]string(ctx.request.URL.Query())
}
return map[string][]string{}
}

// #endregion

// #region form post
func (ctx *Context) FormInt(key string, def int) int {
params := ctx.FormAll()
if vals, ok := params[key]; ok {
len := len(vals)
if len > 0 {
intval, err := strconv.Atoi(vals[len-1])
if err != nil {
return def
}
return intval
}
}
return def
}

func (ctx *Context) FormString(key string, def string) string {
params := ctx.FormAll()
if vals, ok := params[key]; ok {
len := len(vals)
if len > 0 {
return vals[len-1]
}
}
return def
}

func (ctx *Context) FormArray(key string, def []string) []string {
params := ctx.FormAll()
if vals, ok := params[key]; ok {
return vals
}
return def
}

func (ctx *Context) FormAll() map[string][]string {
if ctx.request != nil {
return map[string][]string(ctx.request.PostForm)
}
return map[string][]string{}
}

// #endregion

// #region application/json post

func (ctx *Context) BindJson(obj interface{}) error {
if ctx.request != nil {
body, err := ioutil.ReadAll(ctx.request.Body)
if err != nil {
return err
}
ctx.request.Body = ioutil.NopCloser(bytes.NewBuffer(body))

err = json.Unmarshal(body, obj)
if err != nil {
return err
}
} else {
return errors.New("ctx.request empty")
}
return nil
}

// #endregion

// #region response

func (ctx *Context) Json(status int, obj interface{}) error {
if ctx.HasTimeout() {
return nil
}
ctx.responseWriter.Header().Set("Content-Type", "application/json")
ctx.responseWriter.WriteHeader(status)
byt, err := json.Marshal(obj)
if err != nil {
ctx.responseWriter.WriteHeader(500)
return err
}
ctx.responseWriter.Write(byt)
return nil
}

func (ctx *Context) HTML(status int, obj interface{}, template string) error {
return nil
}

func (ctx *Context) Text(status int, obj string) error {
return nil
}

// #endregion

controller.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
package main

import (
"context"
"coredemo/framework"
"fmt"
"log"
"time"
)

func FooControllerHandler(c *framework.Context) error {
finish := make(chan struct{}, 1)
panicChan := make(chan interface{}, 1)

durationCtx, cancel := context.WithTimeout(c.BaseContext(), time.Duration(1*time.Second))
defer cancel()

// mu := sync.Mutex{}
go func() {
defer func() {
if p := recover(); p != nil {
panicChan <- p
}
}()
// Do real action
time.Sleep(10 * time.Second)
c.Json(200, "ok")

finish <- struct{}{}
}()
select {
case p := <-panicChan:
c.WriterMux().Lock()
defer c.WriterMux().Unlock()
log.Println(p)
c.Json(500, "panic")
case <-finish:
fmt.Println("finish")
case <-durationCtx.Done():
c.WriterMux().Lock()
defer c.WriterMux().Unlock()
c.Json(500, "time out")
c.SetHasTimeout()
}
return nil
}

// func Foo(request *http.Request, response http.ResponseWriter) {
// obj := map[string]interface{}{
// "errno": 50001,
// "errmsg": "inner error",
// "data": nil,
// }

// response.Header().Set("Content-Type", "application/json")

// foo := request.PostFormValue("foo")
// if foo == "" {
// foo = "10"
// }
// fooInt, err := strconv.Atoi(foo)
// if err != nil {
// response.WriteHeader(500)
// return
// }
// obj["data"] = fooInt
// byt, err := json.Marshal(obj)
// if err != nil {
// response.WriteHeader(500)
// return
// }
// response.WriteHeader(200)
// response.Write(byt)
// return
// }

// func Foo2(ctx *framework.Context) error {
// obj := map[string]interface{}{
// "errno": 50001,
// "errmsg": "inner error",
// "data": nil,
// }

// fooInt := ctx.FormInt("foo", 10)
// obj["data"] = fooInt
// return ctx.Json(http.StatusOK, obj)
// }

// func Foo3(ctx *framework.Context) error {
// rdb := redis.NewClient(&redis.Options{
// Addr: "localhost:6379",
// Password: "", // no password set
// DB: 0, // use default DB
// })

// return rdb.Set(ctx, "key", "value", 0).Err()
// }

封装一个自己的Context

geekbang/02

代码结构图

在框架里,我们需要有更强大的Context,除了可以控制超时之外,常用的功能比如获取请求、返回结果、实现标准库的Context接口,也都要有。

我们首先来设计提供获取请求、返回结果功能

先看一段未封装自定义Context的控制器代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package main

import (
"encoding/json"
"net/http"
"strconv"
)

// 控制器
func Foo1(request *http.Request, response http.ResponseWriter) {
obj := map[string]interface{}{
"data": nil,
}
// 设置控制器 response 的 header 部分
response.Header().Set("Content-Type", "application/json")

// 从请求体中获取参数
foo := request.PostFormValue("foo")
if foo == "" {
foo = "10"
}
fooInt, err := strconv.Atoi(foo)
if err != nil {
response.WriteHeader(500)
return
}
// 构建返回结构
obj["data"] = fooInt
byt, err := json.Marshal(obj)
if err != nil {
response.WriteHeader(500)
return
}
// 构建返回状态,输出返回结构
response.WriteHeader(200)
response.Write(byt)
return
}

这段代码重点是操作调用了http.Requesthttp.ResponseWriter,实现WebService接收和处理协议文本的功能。但这两个结构提供的接口粒度太细了,需要使用者非常熟悉这两个结构的内部字段,比如response里设置Header和设置Body的函数,用起来肯定体验不好。

如果我们能将这些内部实现封装起来,对外暴露语义化高的接口函数,那么我们这个框架的易用性肯定会明显提升。什么是好的封装呢?再看这段有封装的代码:

1
2
3
4
5
6
7
8
9
10
11
12
// 控制器
func Foo2(ctx *framework.Context) error {
obj := map[string]interface{}{
"data": nil,
}
// 从请求体中获取参数
fooInt := ctx.FormInt("foo", 10)
// 构建返回结构
obj["data"] = fooInt
// 输出返回结构
return ctx.Json(http.StatusOK, obj)
}

你可以明显感受到封装性高的Foo2函数,更优雅更易读了。首先它的代码量更少,而且语义性也更好,近似对业务的描述:从请求体中获取foo参数,并且封装为Map,最后JSON输出。

思路清晰了,所以这里可以将request和response封装到我们自定义的Context中,对外提供请求和结果的方法,我们把这个Context结构写在框架文件夹的context.go文件中:

1
2
3
4
5
6
// 自定义 Context
type Context struct {
request *http.Request
responseWriter http.ResponseWriter
...
}

对request和response封装的具体实现,我们到第五节课封装的时候再仔细说。

然后是第二个功能,标准库的Context接口

标准库的Context通用性非常高,基本现在所有第三方库函数,都会根据官方的建议,将第一个参数设置为标准Context接口。所以我们封装的结构只有实现了标准库的Context,才能方便直接地调用。

到底有多方便,我们看使用示例:

1
2
3
4
5
6
7
8
9
func Foo3(ctx *framework.Context) error {
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
Password: "", // no password set
DB: 0, // use default DB
})

return rdb.Set(ctx, "key", "value", 0).Err()
}

这里使用了go-redis库,它每个方法的参数中都有一个标准Context接口,这让我们能将自定义的Context直接传递给rdb.Set

只需要调用刚才封装的request中的Context的标准接口就行了,继续在context.go中进行补充:

1
2
3
4
5
6
7
func (ctx *Context) BaseContext() context.Context {
return ctx.request.Context()
}

func (ctx *Context) Done() <-chan struct{} {
return ctx.BaseContext().Done()
}

context.go完整代码

Context最终需要提供四类功能函数:

  • base封装基本的函数功能,比如获取http.Request结构
  • context实现标准Context接口
  • request封装了http.Request的对外接口
  • response封装了http.ResponseWriter对外接口

函数列表

有了我们自己封装的Context之后,控制器就非常简化了。把框架定义的ControllerHandler放在框架目录下的controller.go文件中:

1
type ControllerHandler func(c *Context) error

把处理业务的控制器放在业务目录下的controller.go文件中:
1
2
3
4
5
func FooControllerHandler(ctx *framework.Context) error {
return ctx.Json(200, map[string]interface{}{
"code": 0,
})
}

为单个请求设置超时

如何使用自定义Context设置超时呢?

1,继承request的Context,创建出一个设置超时时间的Context;
2,创建一个新的Goroutine来处理具体的业务逻辑;
3,设计事件处理顺序,当前Goroutine监听超时时间Context的Done()事件,和具体的业务处理结束事件,哪个先到就先处理哪个。

第一步生成一个超时的Context

controller.go

1
2
3
durationCtx, cancel := context.WithTimeout(c.BaseContext(), time.Duration(1*time.Second))
// 这里记得当所有事情处理结束后调用 cancel,告知 durationCtx 的后续 Context 结束
defer cancel()

这里为了最终在浏览器做验证,我设置超时事件为1s,这样最终验证的时候,最长等待1s就可以知道超时是否生效。

第二步创建一个新的Goroutine来处理业务逻辑

1
2
3
4
5
6
7
8
9
10
11
finish := make(chan struct{}, 1)

go func() {
...
// 这里做具体的业务
time.Sleep(10 * time.Second)
c.Json(200, "ok")
...
// 新的 goroutine 结束的时候通过一个 finish 通道告知父 goroutine
finish <- struct{}{}
}()

为了最终的验证效果,我们使用time.Sleep将新Goroutine的业务逻辑事件人为往后延迟了10s,再输出“ok”,这样最终验证的时候,效果比较明显,因为前面的超时设置会在1s生效了,浏览器就有表现了。

到这里我们这里先不急着进入第三步,还有错误处理情况没有考虑到位。这个新创建的Goroutine如果出现未知异常怎么办?需要我们额外捕获吗?

其实在Golang的设计中,每个Goroutine都是独立存在的,父Goroutine一旦使用Go关键字开启了一个子Goroutine,父子Goroutine就是平等存在的,他们互相不能干扰。而在异常面前,所有Goroutine的异常都需要自己管理,不会存在父Goroutine捕获子Goroutine异常的操作。

所以切记:在Golang中,每个Goroutine创建的时候,我们要使用defer和recover关键字为当前Goroutine捕获panic异常,并进行处理,否则,任意一处panic就会导致整个进程崩溃!

回看第二步,做完具体业务逻辑就结束是不行的,还需要处理panic。所以这个Goroutine应该要有两个channel对外传递事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 这个channal负责通知结束
finish := make(chan struct{}, 1)
// 这个channel负责通知panic异常
panicChan := make(chan interface{}, 1)

go func() {
// 这里增加异常处理
defer func() {
if p := recover(); p != nil {
panicChan <- p
}
}()
// 这里做具体的业务
time.Sleep(10 * time.Second)
c.Json(200, "ok")
...
// 新的goroutine结束的时候通过一个finish通道告知父goroutine
finish <- struct{}{}
}()

现在第二步才算完成了,我们继续写第三步监听。使用select关键字来监听三个事件:异常事件、结束事件、超时事件。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
select {
// 监听 panic
case p := <-panicChan:
...
c.Json(500, "panic")
// 监听结束事件
case <-finish:
...
fmt.Println("finish")
// 监听超时事件
case <-durationCtx.Done():
...
c.Json(500, "time out")
}

接收到结束事件,只需要打印日志,但是,在接收到异常事件和超时事件的时候,我们希望告知浏览器前端“异常或者超时了”,所以会使用c.Json来返回一个字符串信息。

三步走到这里就完成了对某个请求的超时设置,你可以通过go buildgo run尝试启动下这个服务。如果你在浏览器开启一个请求之后,浏览器不会等候事件处理10s,而在等待我们设置的超时事件1s后,页面显示“timeout”就结束这个请求了,就说明我们为某个事件设置的超时生效了。

边界场景

边界场景有两种可能:

1,异常事件、超时事件触发时,需要往responseWriter中写入信息,这个时候如果有其他Goroutine也要操作responseWriter,会不会导致responseWriter中的信息出现乱序?
2,超时事件触发结束之后,已经往responseWriter中写入信息了,这个时候如果有其他Goroutine也要操作responseWriter,会不会导致responseWriter中的信息重复写入?

第一个问题,我们要保证在事件处理结束之前,不允许任何其他Goroutine操作responseWriter,这里可以使用一个锁(sync.Mutex)对responseWriter进行写保护。

context.go

1
2
3
4
5
6
7
8
9
type Context struct {
// 写保护机制
writerMux *sync.Mutex
}

// 对外暴露锁
func (ctx *Context) WriterMux() *sync.Mutex {
return ctx.writerMux
}

controller.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func FooControllerHandler(c *framework.Context) error {
...
// 请求监听的时候增加锁机制
select {
case p := <-panicChan:
c.WriterMux().Lock()
defer c.WriterMux().Unlock()
...
c.Json(500, "panic")
case <-finish:
...
fmt.Println("finish")
case <-durationCtx.Done():
c.WriterMux().Lock()
defer c.WriterMux().Unlock()
c.Json(500, "time out")
c.SetTimeout()
}
return nil
}

第二个问题,可以设计一个标记,当发生超时的时候,设置标记位为true,在Context提供的response输出函数中,先读取标记位;当标记位为true,表示已经有输出了,不需要再进行任何的response设置了。

context.go

1
2
3
4
5
6
7
8
9
10
func (ctx *Context) SetHasTimeout() {
ctx.hasTimeout = true
}

func (ctx *Context) Json(status int, obj interface{}) error {
if ctx.HasTimeout() {
return nil
}
...
}

controller.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func FooControllerHandler(c *framework.Context) error {
...
select {
case p := <-panicChan:
...
case <-finish:
fmt.Println("finish")
case <-durationCtx.Done():
c.WriterMux().Lock()
defer c.WriterMux().Unlock()
c.Json(500, "time out")
// 这里记得设置标记为
c.SetHasTimeout()
}
return nil
}

剩下的验证部分,我们写一个简单的路由函数,将这个控制器路由在业务文件夹中创建一个route.go:

1
2
3
4
func registerRouter(core *framework.Core) {
// 设置控制器
core.Get("foo", FooControllerHandler)
}

并修改main.go:
1
2
3
4
5
6
func main() {
...
// 设置路由
registerRouter(core)
...
}

context标准库设计思路

如何控制超时,context标准库的解决思路是:在整个树形逻辑链条中,用上下文控制器Context,实现每个节点的信息传递和共享。

具体操作是:用Context定时器为整个链条设置超时时间,时间一到,结束事件被触发,链条中正在处理的服务逻辑会监听到,从而结束整个逻辑链条,让后续操作不再进行。

1
go doc context | grep "^func"
1
2
3
4
5
6
// 创建退出Context
func WithCancel(parent Context) (ctx Context, cancel CancelFunc){}
// 创建有超时时间的Context
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc){}
// 创建有截止时间的Context
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc){}

其中,WithCancel直接创建可以操作退出的子节点,WithTimeout为子节点设置了超时时间(还有多少时间结束),WithDeadline为子节点设置了结束时间线(在什么时间结束)。

但是这只是表层功能的不同,其实这三个库函数的本质是一致的。怎么理解呢?

我们先通过go doc context | grep "^type",搞清楚Context的结构定义和函数句柄,再来解答这个问题。

1
2
3
4
5
6
7
8
type Context interface {
// 当Context被取消或者到了deadline,返回一个被关闭的channel
Done() <-chan struct{}
...
}

// 函数句柄
type CancelFunc func()

只理解核心的Done()方法和CancelFunc这两个函数就可以了。

在树形逻辑链条上,一个节点其实有两个角色:一是下游树的管理者;二是上游树的被管理者,那么就对应需要有两个能力:

  • 一个是能让整个下游树结束的能力,也就是函数句柄CancelFunc;
  • 另外一个是在上游树结束的时候被通知的能力,也就是Done()方法。同时因为通知是需要不断监听的,所以Done()方法需要通过channel作为返回值让使用方进行监听。

看官方代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package main

import (
"context"
"fmt"
"time"
)

const shortDuration = 1 * time.Millisecond

func main() {
// 创建截止时间
d := time.Now().Add(shortDuration)
// 创建有截止时间的 Context
ctx, cancel := context.WithDeadline(context.Background(), d)
defer cancel()

// 使用 select 监听 1s 和有截止时间的 Context 哪个先结束
select {
case <-time.After(1 * time.Second):
fmt.Println("overslept")
case <-ctx.Done():
fmt.Println(ctx.Err())
}
}

主线程创建了一个1毫秒结束的定时器Context,在定时器结束的时候,主线程会通过Done()函数收到事件结束通知,然后主动调用函数句柄cancelFunc来通知所有子Context结束(这个例子比较简单没有子Context)。

打个更形象的比喻,CancelFunc和Done方法就像是电话的话筒和听筒,话筒CancelFunc,用来告诉管辖范围内的所有Context要进行自我终结,而通过监听听筒Done方法,我们就能听到上游父级管理者的终结命令。

总之,CancelFunc是主动让下游结束,而Done是被上游通知结束

WithCancel/WithDeadline/WithTimeout的本质就是“通过定时器来自动触发终结通知”,WithTimeout设置若干秒后通知触发终结,WithDeadline设置未来某个时间点触发终结。

对应到Context代码中,它们的功能就是:为一个父节点生成一个带有Done方法的子节点,并且返回子节点的CancelFunc函数句柄。

Context的使用会形成一个树形结构,下游指的是树形结构中的子节点及所有子节点的子树,而上游指的是当前节点的父节点。比如图中圈起来的部分,当WithTimeout调用CancelFunc的时候,所有下游的With系列产生的Context都会从Done中收到消息。

Context是怎么产生的

Context在哪里产生?它的上下游逻辑是什么?

从图中最后一层的代码req.ctx=ctx中看到,每个连接的Context最终是放在request结构体中的。

而且这个时候,Context已经有多层父节点。因为,在代码中,每执行一次WithCancel、WithValue,就封装了一层Context,我们通过这一张流程图能清晰看到最终Context的生成层次。

每个连接的Context都是基于baseContext复制来的。对应到代码中就是,在为某个连接开启Goroutine的时候,为当前连接创建了一个connContext,这个connContext是基于server中的Context而来,而server中Context的基础就是baseContext。

生成最终的Context的流程中,net/http设计了两处可以注入修改的地方,都在Server结构里面,一处是BaseContext,另一处是ConnContext。

  • BaseContext是整个Context生成的源头,如果我们不希望使用默认的context.Backgroud(),可以替换这个源头。
  • 而在每个连接生成自己要使用的Context时,会调用ConnContext,它的第二个参数是net.Conn,能让我们对某些特定连接进行设置,比如要针对性设置某个调用IP。
1
2
3
4
5
6
7
8
9
10
11
12
type Server struct {
...

// BaseContext 用来为整个链条创建初始化 Context
// 如果没有设置的话,默认使用 context.Background()
BaseContext func(net.Listener) context.Context{}

// ConnContext 用来为每个连接封装 Context
// 参数中的 context.Context 是从 BaseContext 继承来的
ConnContext func(ctx context.Context, c net.Conn) context.Context{}
...
}

最后,我们回看一下req.ctx是否能感知连接异常。

是可以的,因为链条中一个父节点为CancelContext,其cancelFunc存储在代表连接的conn结构中,连接异常的时候,会触发这个函数句柄。

小结

定义了一个属于自己框架的Context,它有两个功能:在各个Goroutine间传递数据;控制各个Goroutine,也就是是超时控制。

这个自定义Context结构封装了net/http标准库主逻辑流程产生的Context,与主逻辑流程完美对接。它除了实现了标准库的Context接口,还封装了request和response的请求。你实现好了Context之后,就会发现它跟百宝箱一样,在处理具体的业务逻辑的时候,如果需要获取参数、设置返回值等,都可以通过Context获取。

封装后,我们通过三步走为请求设置超时,并且完美地考虑了各种边界场景。

思考题

在context库的官方文档中有这么一句话:

1
2
3
Do not store Contexts inside a struct type;
instead, pass a Context explicitly to each function that needs it.
The Context should be the first parameter.

大意是说建议我们设计函数的时候,将Context作为函数的第一个参数。你能理解官方为什么如此建议,有哪些好处?可以结合你的工作经验,说说自己的看法。

0%