深入理解 Go 語(yǔ)言中的 Context
Hi,大家好,我是明哥。
在自己學(xué)習(xí) Golang 的這段時(shí)間里,我寫(xiě)了詳細(xì)的學(xué)習(xí)筆記放在我的個(gè)人微信公眾號(hào) 《Go編程時(shí)光》,對(duì)于 Go 語(yǔ)言,我也算是個(gè)初學(xué)者,因此寫(xiě)的東西應(yīng)該會(huì)比較適合剛接觸的同學(xué),如果你也是剛學(xué)習(xí) Go 語(yǔ)言,不防關(guān)注一下,一起學(xué)習(xí),一起成長(zhǎng)。
我的在線博客:http://golang.iswbm.com
我的 Github:github.com/iswbm/GolangCodingTime
1. 什么是 Context?
在 Go 1.7 版本之前,context 還是非編制的,它存在于 golang.org/x/net/context 包中。
后來(lái),Golang 團(tuán)隊(duì)發(fā)現(xiàn) context 還挺好用的,就把 context 收編了,在 Go 1.7 版本正式納入了標(biāo)準(zhǔn)庫(kù)。
Context,也叫上下文,它的接口定義如下
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key interface{}) interface{}
}
可以看到 Context 接口共有 4 個(gè)方法
Deadline:返回的第一個(gè)值是 截止時(shí)間,到了這個(gè)時(shí)間點(diǎn),Context 會(huì)自動(dòng)觸發(fā) Cancel 動(dòng)作。返回的第二個(gè)值是 一個(gè)布爾值,true 表示設(shè)置了截止時(shí)間,false 表示沒(méi)有設(shè)置截止時(shí)間,如果沒(méi)有設(shè)置截止時(shí)間,就要手動(dòng)調(diào)用 cancel 函數(shù)取消 Context。Done:返回一個(gè)只讀的通道(只有在被cancel后才會(huì)返回),類型為struct{}。當(dāng)這個(gè)通道可讀時(shí),意味著parent context已經(jīng)發(fā)起了取消請(qǐng)求,根據(jù)這個(gè)信號(hào),開(kāi)發(fā)者就可以做一些清理動(dòng)作,退出goroutine。Err:返回 context 被 cancel 的原因。Value:返回被綁定到 Context 的值,是一個(gè)鍵值對(duì),所以要通過(guò)一個(gè)Key才可以獲取對(duì)應(yīng)的值,這個(gè)值一般是線程安全的。
2. 為何需要 Context?
當(dāng)一個(gè)協(xié)程(goroutine)開(kāi)啟后,我們是無(wú)法強(qiáng)制關(guān)閉它的。
常見(jiàn)的關(guān)閉協(xié)程的原因有如下幾種:
- goroutine 自己跑完結(jié)束退出
- 主進(jìn)程crash退出,goroutine 被迫退出
- 通過(guò)通道發(fā)送信號(hào),引導(dǎo)協(xié)程的關(guān)閉。
第一種,屬于正常關(guān)閉,不在今天討論范圍之內(nèi)。
第二種,屬于異常關(guān)閉,應(yīng)當(dāng)優(yōu)化代碼。
第三種,才是開(kāi)發(fā)者可以手動(dòng)控制協(xié)程的方法,代碼示例如下:
func main() {
stop := make(chan bool)
go func() {
for {
select {
case <-stop:
fmt.Println("監(jiān)控退出,停止了...")
return
default:
fmt.Println("goroutine監(jiān)控中...")
time.Sleep(2 * time.Second)
}
}
}()
time.Sleep(10 * time.Second)
fmt.Println("可以了,通知監(jiān)控停止")
stop<- true
//為了檢測(cè)監(jiān)控過(guò)是否停止,如果沒(méi)有監(jiān)控輸出,就表示停止了
time.Sleep(5 * time.Second)
}
例子中我們定義一個(gè)stop的chan,通知他結(jié)束后臺(tái)goroutine。實(shí)現(xiàn)也非常簡(jiǎn)單,在后臺(tái)goroutine中,使用select判斷stop是否可以接收到值,如果可以接收到,就表示可以退出停止了;如果沒(méi)有接收到,就會(huì)執(zhí)行default里的監(jiān)控邏輯,繼續(xù)監(jiān)控,只到收到stop的通知。
以上是一個(gè) goroutine 的場(chǎng)景,如果是多個(gè) goroutine ,每個(gè)goroutine 底下又開(kāi)啟了多個(gè) goroutine 的場(chǎng)景呢?在 飛雪無(wú)情的博客 里關(guān)于為何要使用 Context,他是這么說(shuō)的
chan+select的方式,是比較優(yōu)雅的結(jié)束一個(gè)goroutine的方式,不過(guò)這種方式也有局限性,如果有很多goroutine都需要控制結(jié)束怎么辦呢?如果這些goroutine又衍生了其他更多的goroutine怎么辦呢?如果一層層的無(wú)窮盡的goroutine呢?這就非常復(fù)雜了,即使我們定義很多chan也很難解決這個(gè)問(wèn)題,因?yàn)間oroutine的關(guān)系鏈就導(dǎo)致了這種場(chǎng)景非常復(fù)雜。
在這里我不是很贊同他說(shuō)的話,因?yàn)槲矣X(jué)得就算只使用一個(gè)通道也能達(dá)到控制(取消)多個(gè) goroutine 的目的。下面就用例子來(lái)驗(yàn)證一下。
該例子的原理是:使用 close 關(guān)閉通道后,如果該通道是無(wú)緩沖的,則它會(huì)從原來(lái)的阻塞變成非阻塞,也就是可讀的,只不過(guò)讀到的會(huì)一直是零值,因此根據(jù)這個(gè)特性就可以判斷 擁有該通道的 goroutine 是否要關(guān)閉。
package main
import (
"fmt"
"time"
)
func monitor(ch chan bool, number int) {
for {
select {
case v := <-ch:
// 僅當(dāng) ch 通道被 close,或者有數(shù)據(jù)發(fā)過(guò)來(lái)(無(wú)論是true還是false)才會(huì)走到這個(gè)分支
fmt.Printf("監(jiān)控器%v,接收到通道值為:%v,監(jiān)控結(jié)束。\n", number,v)
return
default:
fmt.Printf("監(jiān)控器%v,正在監(jiān)控中...\n", number)
time.Sleep(2 * time.Second)
}
}
}
func main() {
stopSingal := make(chan bool)
for i :=1 ; i <= 5; i++ {
go monitor(stopSingal, i)
}
time.Sleep( 1 * time.Second)
// 關(guān)閉所有 goroutine
close(stopSingal)
// 等待5s,若此時(shí)屏幕沒(méi)有輸出 <正在監(jiān)控中> 就說(shuō)明所有的goroutine都已經(jīng)關(guān)閉
time.Sleep( 5 * time.Second)
fmt.Println("主程序退出??!")
}
輸出如下
監(jiān)控器4,正在監(jiān)控中...
監(jiān)控器1,正在監(jiān)控中...
監(jiān)控器2,正在監(jiān)控中...
監(jiān)控器3,正在監(jiān)控中...
監(jiān)控器5,正在監(jiān)控中...
監(jiān)控器2,接收到通道值為:false,監(jiān)控結(jié)束。
監(jiān)控器3,接收到通道值為:false,監(jiān)控結(jié)束。
監(jiān)控器5,接收到通道值為:false,監(jiān)控結(jié)束。
監(jiān)控器1,接收到通道值為:false,監(jiān)控結(jié)束。
監(jiān)控器4,接收到通道值為:false,監(jiān)控結(jié)束。
主程序退出??!
上面的例子,說(shuō)明當(dāng)我們定義一個(gè)無(wú)緩沖通道時(shí),如果要對(duì)所有的 goroutine 進(jìn)行關(guān)閉,可以使用 close 關(guān)閉通道,然后在所有的 goroutine 里不斷檢查通道是否關(guān)閉(前提你得約定好,該通道你只會(huì)進(jìn)行 close 而不會(huì)發(fā)送其他數(shù)據(jù),否則發(fā)送一次數(shù)據(jù)就會(huì)關(guān)閉一個(gè)goroutine,這樣會(huì)不符合咱們的預(yù)期,所以最好你對(duì)這個(gè)通道再做一層封裝做個(gè)限制)來(lái)決定是否結(jié)束 goroutine。
所以你看到這里,我做為初學(xué)者還是沒(méi)有找到使用 Context 的必然理由,我只能說(shuō) Context 是個(gè)很好用的東西,使用它方便了我們?cè)谔幚聿l(fā)時(shí)候的一些問(wèn)題,但是它并不是不可或缺的。
換句話說(shuō),它解決的并不是 能不能 的問(wèn)題,而是解決 更好用 的問(wèn)題。
3. 簡(jiǎn)單使用 Context
如果不使用上面 close 通道的方式,還有沒(méi)有其他更優(yōu)雅的方法來(lái)實(shí)現(xiàn)呢?
有,那就是本文要講的 Context
我使用 Context 對(duì)上面的例子進(jìn)行了一番改造。
package main
import (
"context"
"fmt"
"time"
)
func monitor(ctx context.Context, number int) {
for {
select {
// 其實(shí)可以寫(xiě)成 case <- ctx.Done()
// 這里僅是為了讓你看到 Done 返回的內(nèi)容
case v :=<- ctx.Done():
fmt.Printf("監(jiān)控器%v,接收到通道值為:%v,監(jiān)控結(jié)束。\n", number,v)
return
default:
fmt.Printf("監(jiān)控器%v,正在監(jiān)控中...\n", number)
time.Sleep(2 * time.Second)
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
for i :=1 ; i <= 5; i++ {
go monitor(ctx, i)
}
time.Sleep( 1 * time.Second)
// 關(guān)閉所有 goroutine
cancel()
// 等待5s,若此時(shí)屏幕沒(méi)有輸出 <正在監(jiān)控中> 就說(shuō)明所有的goroutine都已經(jīng)關(guān)閉
time.Sleep( 5 * time.Second)
fmt.Println("主程序退出!!")
}
這里面的關(guān)鍵代碼,也就三行
第一行:以 context.Background() 為 parent context 定義一個(gè)可取消的 context
ctx, cancel := context.WithCancel(context.Background())
第二行:然后你可以在所有的goroutine 里利用 for + select 搭配來(lái)不斷檢查 ctx.Done() 是否可讀,可讀就說(shuō)明該 context 已經(jīng)取消,你可以清理 goroutine 并退出了。
case <- ctx.Done():
第三行:當(dāng)你想到取消 context 的時(shí)候,只要調(diào)用一下 cancel 方法即可。這個(gè) cancel 就是我們?cè)趧?chuàng)建 ctx 的時(shí)候返回的第二個(gè)值。
cancel()
運(yùn)行結(jié)果輸出如下??梢园l(fā)現(xiàn)我們實(shí)現(xiàn)了和 close 通道一樣的效果。
監(jiān)控器3,正在監(jiān)控中...
監(jiān)控器4,正在監(jiān)控中...
監(jiān)控器1,正在監(jiān)控中...
監(jiān)控器2,正在監(jiān)控中...
監(jiān)控器2,接收到通道值為:{},監(jiān)控結(jié)束。
監(jiān)控器5,接收到通道值為:{},監(jiān)控結(jié)束。
監(jiān)控器4,接收到通道值為:{},監(jiān)控結(jié)束。
監(jiān)控器1,接收到通道值為:{},監(jiān)控結(jié)束。
監(jiān)控器3,接收到通道值為:{},監(jiān)控結(jié)束。
主程序退出!!
4. 根Context 是什么?
創(chuàng)建 Context 必須要指定一個(gè) 父 Context,當(dāng)我們要?jiǎng)?chuàng)建第一個(gè)Context時(shí)該怎么辦呢?
不用擔(dān)心,Go 已經(jīng)幫我們實(shí)現(xiàn)了2個(gè),我們代碼中最開(kāi)始都是以這兩個(gè)內(nèi)置的context作為最頂層的parent context,衍生出更多的子Context。
var (
background = new(emptyCtx)
todo = new(emptyCtx)
)
func Background() Context {
return background
}
func TODO() Context {
return todo
}
一個(gè)是Background,主要用于main函數(shù)、初始化以及測(cè)試代碼中,作為Context這個(gè)樹(shù)結(jié)構(gòu)的最頂層的Context,也就是根Context,它不能被取消。
一個(gè)是TODO,如果我們不知道該使用什么Context的時(shí)候,可以使用這個(gè),但是實(shí)際應(yīng)用中,暫時(shí)還沒(méi)有使用過(guò)這個(gè)TODO。
他們兩個(gè)本質(zhì)上都是emptyCtx結(jié)構(gòu)體類型,是一個(gè)不可取消,沒(méi)有設(shè)置截止時(shí)間,沒(méi)有攜帶任何值的Context。
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
}
5. Context 的繼承衍生
上面在定義我們自己的 Context 時(shí),我們使用的是 WithCancel 這個(gè)方法。
除它之外,context 包還有其他幾個(gè) With 系列的函數(shù)
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
func WithValue(parent Context, key, val interface{}) Context
這四個(gè)函數(shù)有一個(gè)共同的特點(diǎn),就是第一個(gè)參數(shù),都是接收一個(gè) 父context。
通過(guò)一次繼承,就多實(shí)現(xiàn)了一個(gè)功能,比如使用 WithCancel 函數(shù)傳入 根context ,就創(chuàng)建出了一個(gè)子 context,該子context 相比 父context,就多了一個(gè) cancel context 的功能。
如果此時(shí),我們?cè)僖陨厦娴淖觕ontext(context01)做為父context,并將它做為第一個(gè)參數(shù)傳入WithDeadline函數(shù),獲得的子子context(context02),相比子context(context01)而言,又多出了一個(gè)超過(guò) deadline 時(shí)間后,自動(dòng) cancel context 的功能。
接下來(lái)我會(huì)舉例介紹一下這幾種 context,其中 WithCancel 在上面已經(jīng)講過(guò)了,下面就不再舉例了
例子 1:WithDeadline
package main
import (
"context"
"fmt"
"time"
)
func monitor(ctx context.Context, number int) {
for {
select {
case <- ctx.Done():
fmt.Printf("監(jiān)控器%v,監(jiān)控結(jié)束。\n", number)
return
default:
fmt.Printf("監(jiān)控器%v,正在監(jiān)控中...\n", number)
time.Sleep(2 * time.Second)
}
}
}
func main() {
ctx01, cancel := context.WithCancel(context.Background())
ctx02, cancel := context.WithDeadline(ctx01, time.Now().Add(1 * time.Second))
defer cancel()
for i :=1 ; i <= 5; i++ {
go monitor(ctx02, i)
}
time.Sleep(5 * time.Second)
if ctx02.Err() != nil {
fmt.Println("監(jiān)控器取消的原因: ", ctx02.Err())
}
fmt.Println("主程序退出??!")
}
輸出如下
監(jiān)控器5,正在監(jiān)控中...
監(jiān)控器1,正在監(jiān)控中...
監(jiān)控器2,正在監(jiān)控中...
監(jiān)控器3,正在監(jiān)控中...
監(jiān)控器4,正在監(jiān)控中...
監(jiān)控器3,監(jiān)控結(jié)束。
監(jiān)控器4,監(jiān)控結(jié)束。
監(jiān)控器2,監(jiān)控結(jié)束。
監(jiān)控器1,監(jiān)控結(jié)束。
監(jiān)控器5,監(jiān)控結(jié)束。
監(jiān)控器取消的原因: context deadline exceeded
主程序退出??!
例子 2:WithTimeout
WithTimeout 和 WithDeadline 使用方法及功能基本一致,都是表示超過(guò)一定的時(shí)間會(huì)自動(dòng) cancel context。
唯一不同的地方,我們可以從函數(shù)的定義看出
func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc) func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
WithDeadline 傳入的第二個(gè)參數(shù)是 time.Time 類型,它是一個(gè)絕對(duì)的時(shí)間,意思是在什么時(shí)間點(diǎn)超時(shí)取消。
而 WithTimeout 傳入的第二個(gè)參數(shù)是 time.Duration 類型,它是一個(gè)相對(duì)的時(shí)間,意思是多長(zhǎng)時(shí)間后超時(shí)取消。
package main
import (
"context"
"fmt"
"time"
)
func monitor(ctx context.Context, number int) {
for {
select {
case <- ctx.Done():
fmt.Printf("監(jiān)控器%v,監(jiān)控結(jié)束。\n", number)
return
default:
fmt.Printf("監(jiān)控器%v,正在監(jiān)控中...\n", number)
time.Sleep(2 * time.Second)
}
}
}
func main() {
ctx01, cancel := context.WithCancel(context.Background())
// 相比例子1,僅有這一行改動(dòng)
ctx02, cancel := context.WithTimeout(ctx01, 1* time.Second)
defer cancel()
for i :=1 ; i <= 5; i++ {
go monitor(ctx02, i)
}
time.Sleep(5 * time.Second)
if ctx02.Err() != nil {
fmt.Println("監(jiān)控器取消的原因: ", ctx02.Err())
}
fmt.Println("主程序退出??!")
}
輸出的結(jié)果和上面一樣
監(jiān)控器1,正在監(jiān)控中...
監(jiān)控器5,正在監(jiān)控中...
監(jiān)控器3,正在監(jiān)控中...
監(jiān)控器2,正在監(jiān)控中...
監(jiān)控器4,正在監(jiān)控中...
監(jiān)控器4,監(jiān)控結(jié)束。
監(jiān)控器2,監(jiān)控結(jié)束。
監(jiān)控器5,監(jiān)控結(jié)束。
監(jiān)控器1,監(jiān)控結(jié)束。
監(jiān)控器3,監(jiān)控結(jié)束。
監(jiān)控器取消的原因: context deadline exceeded
主程序退出?。?/p>
例子 3:WithValue
通過(guò)Context我們也可以傳遞一些必須的元數(shù)據(jù),這些數(shù)據(jù)會(huì)附加在Context上以供使用。
元數(shù)據(jù)以 Key-Value 的方式傳入,Key 必須有可比性,Value 必須是線程安全的。
還是用上面的例子,以 ctx02 為父 context,再創(chuàng)建一個(gè)能攜帶 value 的ctx03,由于他的父context 是 ctx02,所以 ctx03 也具備超時(shí)自動(dòng)取消的功能。
package main
import (
"context"
"fmt"
"time"
)
func monitor(ctx context.Context, number int) {
for {
select {
case <- ctx.Done():
fmt.Printf("監(jiān)控器%v,監(jiān)控結(jié)束。\n", number)
return
default:
// 獲取 item 的值
value := ctx.Value("item")
fmt.Printf("監(jiān)控器%v,正在監(jiān)控 %v \n", number, value)
time.Sleep(2 * time.Second)
}
}
}
func main() {
ctx01, cancel := context.WithCancel(context.Background())
ctx02, cancel := context.WithTimeout(ctx01, 1* time.Second)
ctx03 := context.WithValue(ctx02, "item", "CPU")
defer cancel()
for i :=1 ; i <= 5; i++ {
go monitor(ctx03, i)
}
time.Sleep(5 * time.Second)
if ctx02.Err() != nil {
fmt.Println("監(jiān)控器取消的原因: ", ctx02.Err())
}
fmt.Println("主程序退出!!")
}
輸出如下
監(jiān)控器4,正在監(jiān)控 CPU
監(jiān)控器5,正在監(jiān)控 CPU
監(jiān)控器1,正在監(jiān)控 CPU
監(jiān)控器3,正在監(jiān)控 CPU
監(jiān)控器2,正在監(jiān)控 CPU
監(jiān)控器2,監(jiān)控結(jié)束。
監(jiān)控器5,監(jiān)控結(jié)束。
監(jiān)控器3,監(jiān)控結(jié)束。
監(jiān)控器1,監(jiān)控結(jié)束。
監(jiān)控器4,監(jiān)控結(jié)束。
監(jiān)控器取消的原因: context deadline exceeded
主程序退出?。?/p>
6. Context 使用注意事項(xiàng)
- 通常 Context 都是做為函數(shù)的第一個(gè)參數(shù)進(jìn)行傳遞(規(guī)范性做法),并且變量名建議統(tǒng)一叫 ctx
- Context 是線程安全的,可以放心地在多個(gè) goroutine 中使用。
- 當(dāng)你把 Context 傳遞給多個(gè) goroutine 使用時(shí),只要執(zhí)行一次 cancel 操作,所有的 goroutine 就可以收到 取消的信號(hào)
- 不要把原本可以由函數(shù)參數(shù)來(lái)傳遞的變量,交給 Context 的 Value 來(lái)傳遞。
- 當(dāng)一個(gè)函數(shù)需要接收一個(gè) Context 時(shí),但是此時(shí)你還不知道要傳遞什么 Context 時(shí),可以先用 context.TODO 來(lái)代替,而不要選擇傳遞一個(gè) nil。
- 當(dāng)一個(gè) Context 被 cancel 時(shí),繼承自該 Context 的所有 子 Context 都會(huì)被 cancel。
總結(jié)
到此這篇關(guān)于深入理解 Go 語(yǔ)言中的 Context的文章就介紹到這了,更多相關(guān)Go 語(yǔ)言 Context內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
關(guān)于Go 空結(jié)構(gòu)體的 3 種使用場(chǎng)景
在今天這篇文章要給大家介紹得是Go 語(yǔ)言中幾種常見(jiàn)類型的寬度,并且基于開(kāi)頭的問(wèn)題 ”空結(jié)構(gòu)體“ 進(jìn)行了剖析,需要的朋友可以參考一下,希望對(duì)你有所幫助2021-10-10
Go語(yǔ)言入門之基礎(chǔ)語(yǔ)法和常用特性解析
這篇文章主要給大家講解了Go語(yǔ)言的基礎(chǔ)語(yǔ)法和常用特性解析,比較適合入門小白,文中通過(guò)代碼示例介紹的非常詳細(xì),對(duì)我們學(xué)習(xí)Go語(yǔ)言有一定的幫助,需要的朋友可以參考下2023-07-07
Go操作Kafka的實(shí)現(xiàn)示例(kafka-go)
本文介紹了使用kafka-go庫(kù)在Go語(yǔ)言中與Kafka進(jìn)行交互,涵蓋了kafka-go的安裝、API使用、消息發(fā)送與消費(fèi)方法,以及如何通過(guò)DockerCompose快速搭建Kafka環(huán)境,文章還比較了其他兩個(gè)常用的Kafka客戶端庫(kù),感興趣的可以了解一下2024-10-10
Go語(yǔ)言數(shù)據(jù)結(jié)構(gòu)之二叉樹(shù)必會(huì)知識(shí)點(diǎn)總結(jié)
如果你是一個(gè)開(kāi)發(fā)人員,或多或少對(duì)樹(shù)型結(jié)構(gòu)都有一定的認(rèn)識(shí)。二叉樹(shù)作為樹(shù)的一種,是一種重要的數(shù)據(jù)結(jié)構(gòu),也是面試官經(jīng)??嫉臇|西。本文為大家總結(jié)了一些二叉樹(shù)必會(huì)知識(shí)點(diǎn),需要的可以參考一下2022-08-08
Golang如何快速構(gòu)建一個(gè)CLI小工具詳解
這篇文章主要為大家介紹了Golang如何快速構(gòu)建一個(gè)CLI小工具詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-11-11
Golang Model 字段自動(dòng)化校驗(yàn)設(shè)計(jì)方案
在我們?nèi)粘i_(kāi)發(fā)中,不可避免的總要去進(jìn)行各種參數(shù)校驗(yàn),但是如果在某個(gè)場(chǎng)景中,要校驗(yàn)的字段非常多,并且在其中還有耦合關(guān)系,那么我們手寫(xiě)校驗(yàn)邏輯就變得非常的低效且難以維護(hù),本篇文檔就基于 DDD 領(lǐng)域模型設(shè)計(jì)的思想下,提供自動(dòng)化的校驗(yàn)?zāi)P妥侄?感興趣的朋友一起看看吧2025-02-02

