作者 | 克識
來源 | 阿裡技術公衆号
一 概述
指令行解析是幾乎每個後端程式員都會用到的技術,但相比業務邏輯來說,這些細枝末節顯得并不緊要,如果僅僅追求滿足簡單需求,指令行的處理會比較簡單,任何一個後端程式員都可以信手拈來。Go 标準庫提供了 flag 庫以供大家使用。
然而,當我們稍微想讓我們的指令行功能豐富一些,問題開始變得複雜起來,比如,我們要考慮如何處理可選項和必選項,對于可選項,如何設定其預設值,如何處理子指令,以及子指令的子指令,如何處理子指令的參數等等。
目前,Go 語言中使用最廣泛功能最強大的指令行解析庫是 cobra,但豐富的功能讓 cobra 相比标準庫的 flag 而言,變得異常複雜,為了減少使用的複雜度,cobra 甚至提供了代碼生成的功能,可以自動生成指令行的骨架。然而,自動生成在節省了開發時間的同時,也讓代碼變得不夠直覺。
本文通過打破大家對指令行的固有印象,對指令行的概念解構後重新梳理,開發出一種功能強大但使用極為簡單的指令行解析方法。這種方法支援任意多的子指令,支援可選和必選參數,對可選參數可提供預設值,支援配置檔案,環境變量及指令行參數同時使用,配置檔案,環境變量,指令行參數生效優先級依次提高,這種設計可以更符合 12 factor的原則。
二 現有的指令行解析方法
Go 标準庫 flag提供了非常簡單的指令行解析方法,定義好指令行參數後,隻需要調用 flag.Parse方法即可。
// demo.go
var limit int
flag.IntVar(&limit, "limit", 10, "the max number of results")
flag.Parse()
fmt.Println("the limit is", limit)
// 執行結果
$ go run demo.go
the limit is 10
$ go run demo.go -limit 100
the limit is 100
可以看到, flag 庫使用非常簡單,定要好指令行參數後,隻需要調用 flag.Parse就可以實作參數的解析。在定義指令行參數時,可以指定預設值以及對這個參數的使用說明。
如果要處理子指令,flag 就無能為力了,這時候可以選擇自己解析子指令,但更多的是直接使用 cobra 這個庫。
這裡用 cobra 官方給出的例子,示範一下這個庫的使用方法
package main
import (
"fmt"
"strings"
"github.com/spf13/cobra"
)
func main() {
var echoTimes int
var cmdPrint = &cobra.Command{
Use: "print [string to print]",
Short: "Print anything to the screen",
Long: `print is for printing anything back to the screen.
For many years people have printed back to the screen.`,
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Print: " + strings.Join(args, " "))
},
}
var cmdEcho = &cobra.Command{
Use: "echo [string to echo]",
Short: "Echo anything to the screen",
Long: `echo is for echoing anything back.
Echo works a lot like print, except it has a child command.`,
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Echo: " + strings.Join(args, " "))
},
}
var cmdTimes = &cobra.Command{
Use: "times [string to echo]",
Short: "Echo anything to the screen more times",
Long: `echo things multiple times back to the user by providing
a count and a string.`,
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
for i := 0; i < echoTimes; i++ {
fmt.Println("Echo: " + strings.Join(args, " "))
}
},
}
cmdTimes.Flags().IntVarP(&echoTimes, "times", "t", 1, "times to echo the input")
var rootCmd = &cobra.Command{Use: "app"}
rootCmd.AddCommand(cmdPrint, cmdEcho)
cmdEcho.AddCommand(cmdTimes)
rootCmd.Execute()
}
可以看到子指令的加入讓代碼變得稍微複雜,但邏輯仍然是清晰的,并且子指令和跟指令遵循相同的定義模闆,子指令還可以定義自己子指令。
$ go run cobra.go echo times hello --times 3
Echo: hello
Echo: hello
Echo: hello
cobra 功能強大,邏輯清晰,是以得到大家廣泛的認可,然而,這裡卻有兩個問題讓我無法滿意,雖然問題不大,但時時萦懷于心,讓人郁郁。
1 參數定義跟指令邏輯分離
從上面 --times的定義可以看到,參數的定義跟指令邏輯的定義(即這裡的 Run)是分離的,當我們有大量子指令的時候,我們更傾向把指令的定義放到不同的檔案甚至目錄,這就會出現指令的定義是分散的,而所有指令的參數定義卻集中在一起的情況。
當然,這個問題用 cobra 也很好解決,隻要把參數定義從 main函數移動到 init函數,并将 init 函數分散到跟子指令的定義一起即可。比如子指令 times 定義在 times.go檔案中,同時在檔案中定義 init函數,函數中定義了 times 的參數。然而,這樣導緻當參數比較多時需要定義大量的全局變量,這對于追求代碼清晰簡潔無副作用的人來說如芒刺背。
為什麼不能像 flag庫一樣,把參數定義放到指令函數的裡面呢?這樣代碼更緊湊,邏輯更直覺。
// 為什麼我不能寫成下面這樣呢?
func times(){
cobra.IntVarP(&echoTimes, "times", "t", 1, "times to echo the input")
cobra.Parse()
}
相信大家稍加思考就會明白,times函數隻有解析完指令行參數才能調用,這就要求指令行參數要事先定義好,如果把參數定義放到 times,這就意味着隻有調用 times函數時才會解析相關參數,這就跟讓手機根據外殼顔色變換主題一樣無理取鬧,可是,真的是這樣嗎?
2 子指令與父指令的順序定義不夠靈活
在開發有子指令甚至多級子指令的工具時,我們經常面臨到底是選擇 cmd {resource} {action}還是 cmd {action} {resource}的問題,也就是 resource 和 action 誰是子指令誰是參數的問題,比如 Kubernetes 的設計,就是 action 作為子指令:kubectl get pods ... kubectl get deploy ...,而對于 action 因不同 resource 而差别很大時,則往往選擇 resource 作為子指令, 比如阿裡雲的指令行工具: aliyun ecs ... aliyun ram ...
在實際開發過程中,一開始我們可能無法确定action 和 resource 哪個作為子指令會更好,在有多級子指令的情況下這個選擇可能會更困難。
在不使用任何庫的時候,開發者可能會選擇在父指令中初始化相關資源,在子指令中執行代碼邏輯,這樣父指令和子指令互相調換變得非常困難。 這其實是一種錯誤的邏輯,調用子指令并不意味着一定要調用父指令,對于指令行工具來說,指令執行完程序就會退出,父指令初始化後的資源,并不會在子指令中重複使用。
cobra 的設計可以讓大家規避這個錯誤邏輯,其子指令需要提供一個 Run 函數,在這個函數,應該實作初始化資源,執行業務邏輯,銷毀資源的整個生命周期。然而,cobra 仍然需要定義父指令,即必須定義 echo 指令,才能定義 echo times 這個子指令。實際上,在很多場景下,父指令是沒有執行邏輯的,特别是以 resource 作為父指令的場景,父指令的唯一作用就是列印這個指令的用法。
cobra 讓子指令和父指令的定義非常簡單,但父子調換仍然需要修改其間的連結關系,是否有方法讓這個過程更簡單一點呢?
三 重新認識指令行
關于指令行的術語有很多,比如參數(argument),辨別(flag)和選項(option)等,cobra 的設計是基于以下概念的定義
Commands represent actions, Args are things and Flags are modifiers for those actions.
另外,又基于這些定義延伸出更多的概念,比如 persistent flags代表适用于所有子指令的 flag,local flags 代表隻用于目前子指令的 flag, required flags代表必選 flag 等等。
這些定義是 cobra 的核心設計來源,要想解決我上面提到的兩個問題,我們需要重新審視這些定義。為此,我們從頭開始一步步分析何為一個指令行。
1 指令行隻是一個可被 shell 解析執行的字元串
$ cmd arg1 arg2 arg3
指令行及其參數,本質上就是一個字元串而已。字元串的含義是由 shell來解釋的,對于 shell來說,一個指令行由指令和參數組成,指令和參數以及參數和參數之間是由空白符分割。
還有别的嗎? 沒了,沒有什麼父指令、子指令,也沒有什麼持久參數、本地參數,一個參數是雙橫線(--) 、單橫線(-)還是其他字元開頭,都沒有關系,這隻是字元串而已,這些字元串由 shell 傳遞給你要執行的程式,并放到 os.Args (Go 語言)這個數組裡。
2 參數、辨別與選項
從上面的描述可知,參數(argument)是對指令行後面那一串空白符分隔的字元串的稱呼,而一個參數,在指令行中又可以賦予不同的含義。
以橫線或雙橫線開頭的參數看起來有些特殊,結合代碼來看,這種類型的參數有其獨特的作用,就是将某個值跟代碼中的某個變量關聯起來,這種類型的參數,我們叫做辨別(flag)。回想一下,os.Args 這個數組裡的參數有很多,這些參數跟指令中的變量是沒有直接關系的,而 flag 提供的本質上是一個鍵值對,我們的代碼中,通過把鍵跟某個變量關聯起來,進而實作了對這個變量指派的功能。
flag.IntVar(&limit, "limit", 10, "the max number of results")
// 變量綁定,當在指令行中指定 -limit 100 的時候,這意味着我們是把 100 這個值,賦予變量 limit
辨別(flag)賦予了我們通過指令行直接給代碼中某個變量指派的能力。那麼一個新的問題是,如果我沒有給這個變量指派呢,程式還能繼續運作下去嗎?如果不能繼續運作,則這個參數(flag 隻是一種特殊的參數)就是必選的,否則就是可選的。還有一種可能,指令行定義了多個變量,任意一個變量有值,程式都可以執行下去,也即是說隻要這多個辨別中随便指定一個,程式就可以執行,那麼這些辨別或參數從這個角度講又可以叫做選項(option)。
經過上面的分析,我們發現參數、辨別、選項的概念彼此交織,既有差別又有相近的含義。辨別是以橫線開頭的參數,辨別名後面的參數(如果有的話),是辨別的值。這些參數可能是必選或可選,或多個選項中的一個,是以這些參數又可以稱為選項。
3 子指令
經過上面的分析,我們可以很簡單的得出結論,子指令隻是一種特殊的參數,這種參數外觀上跟其他參數沒有任何差別(不像辨別用橫線開頭),但是這個參數會引發特殊的動作或函數(任意動作都可以封裝為一個函數)。
對比辨別和子指令我們會意外的發現其中的關聯:辨別關聯變量而子指令關聯函數!他們具有相同的目的,辨別後面的參數,是變量的值,那麼子指令後面的所有參數,就是這個函數的參數(并非指語言層面的函數參數)。
更有趣的問題是,為什麼辨別需要以橫線開頭?如果沒有橫線,是否能達成關聯變量的目的?這顯然可以的,因為子指令就沒有橫線,對變量的關聯和對函數的關聯并沒有什麼差別。本質上,這個關聯是通過辨別或子指令的名字實作的,那橫線起到什麼作用呢?
是跟變量關聯還是函數關聯,仍然是由參數的名字決定的,這是在代碼中預先定義的,沒有橫線一樣可以差別辨別和子指令,一樣可以完成變量或參數的關聯。
比如:
// 不帶有橫線的參數也可以實作關聯變量或函數
for _, arg := range os.Args{
switch arg{
case "limit": // 設定 limit 變量
case "scan": // 調用 scan 函數
}
}
由此可見,辨別在核心功能實作上,并沒有特殊的作用,橫線的作用主要是用來增強可讀性。然而需要注意的是,雖然本質上我們可以不需要辨別,但一旦有了辨別,我們就可以利用其特性實作額外的功用,比如 netstat -lnt這裡的 -lnt就是 -l -n -t的文法糖。
4 指令行的構成
經過上面的分析,我們可以把指令行的參數賦予不同的概念
- 辨別(flag):以橫線或雙橫線開頭的參數,辨別又由辨別名和辨別參數組成
- --flagname flagarg
- 非辨別參數
- 子指令(subcommand),子指令也會有子指令,辨別和非辨別參數
$ command --flag flagarg subcommand subcmdarg --subcmdfag subcmdflagarg
四 啟發式指令行解析
我們來重新審視一下第一個需求,即我們期望任何一個子指令的實作,都跟使用标準庫的 flag 一樣簡單。這也就意味着,隻有在執行這個函數的時候,才開始解析其指令行參數。如果我們能把子指令和其他參數區分開來,那麼就可以先執行子指令對應的函數,後解析這個子指令的參數。
flag 之是以在 main中調用 Parse, 是因為 shell 已經知道字元串的第一個項是指令本身,後面所有項都是參數,同樣的,如果我們能識别出子指令來,那麼也可以讓以下代碼變為可能:
func command(){
// 定義 flags
// 調用 Parse 函數
}
問題的關鍵是如何将子指令跟其他參數區分開來,其中辨別名以橫線或雙橫線開頭,可以顯而易見的差別開來,其他則需要區分子指令、子指令參數以及辨別參數。仔細思考可以發現,我們雖然期望參數無需預先定義,但子指令是可以預先定義的,通過把非辨別名的參數,跟預先定義的子指令比對,則可以識别出子指令來。
為了示範如何識别出子指令,我們以上面 cobra 的代碼為例,假設 cobra.go 代碼編譯為程式 app,那麼其指令行可以執行
$ app echo times hello --times 3
按 cobra 的概念, times 是 echo 的子指令,而 echo 又是 app 的子指令。我們則把 echo times整體作為 app 的子指令。
1 簡單解析流程
- 定義echo子指令關聯到函數echo, echo times子指令關聯到函數 echoTimes
- 解析字元串 echo times hello --times 3
- 解析第一個參數,通過 echo比對到我們預定義的 echo子指令,同時發現這也是 echo times指令的字首部分,此時,隻有知道後一個參數是什麼,我們才能确定使用者調用的是 echo還是 echo times
- 解析第二個參數,通過 times我們比對到 echo times子指令,并且其不再是任何子指令的字首。此時确定子指令為 echo times,其他所有參數皆為這個子指令的參數。
- 如果解析第二個參數為 hello,那麼其隻能比對到 echo這個子指令,那麼會調用 echo函數而不是 echoTimes函數。
2 啟發式探測流程
上面的解析比較簡單,但現實情況下,我們往往期望允許辨別可以出現在指令行的任意位置,比如,我們期望新加一個控制列印顔色的選項 --color red,從邏輯上講,顔色選項更多的是對 echo的描述,而非對 times的描述,是以我們期望可以支援如下的指令行:
$ app echo --color red times hello --times 3
此時,我們期望調用的子指令仍然是 echo times,然而中間的參數讓情況變得複雜起來,因為這裡的參數 red可能是 --color的辨別參數(red),可能是子指令的一部分,也可能是子指令的參數。更有甚者,使用者還可能把參數錯誤的寫為 --color times
所謂啟發式的探測,是指當解析到 red參數時,我們并不知道 red到底是子指令(或者子指令的字首部分),還是子指令的參數,是以我們可以将其假定為子指令的字首進行比對,如果比對不到,則将其當做子指令參數處理。
- 解析到 red時,用 echo red搜尋預定義的子指令,若搜尋不到,則将 red視為參數
- 解析 times時,用 echo times搜尋預定義的子指令,此時可搜尋到 echo times子指令
可以看到 red不需區分是 --color的辨別參數,還是子指令的非辨別參數,隻要其比對不到任何子指令,則可以确認,其一定是子指令的參數。
3 子指令任意書寫順序
子指令本質上就是一個字元串,我們上面的啟發式解析已經實作将任意子指令字元串識别出來,前提是預先對這個字元串進行定義。也就是将這個字元串關聯到某個函數。這樣的設計使得父指令、子指令隻是邏輯上的概念,而跟具體的代碼實作毫無關聯,我們需要做的就是調整映射而已。
維護映射關系
# 關聯到 echoTimes 函數
"echo times" => echoTimes
# 調整子指令隻是改一下這個映射而已
"times echo" => echoTimes
五 Cortana: 基于啟發式指令行解析的實作
為了實作上述思路,我開發了 Cortana這個項目。Cortana 引入 Btree 建立子指令與函數之間的映射關系,得益于其字首搜尋的能力,使用者輸入任意子指令字首,程式都會自動列出所有可用的子指令。啟發式指令行解析機制,可以在解析具體的辨別或子指令參數前,先解析出子指令,進而搜尋到子指令所映射的函數,在映射的函數中,去真正的解析子指令的參數,實作變量的綁定。另外,Cortana 充分利用了 Go 語言 Struct Tag 的特性,簡化了變量綁定的流程。
我們用 cortana 重新實作 cobra 代碼的功能
package main
import (
"fmt"
"strings"
"github.com/shafreeck/cortana"
)
func print() {
cortana.Title("Print anything to the screen")
cortana.Description(`print is for printing anything back to the screen.
For many years people have printed back to the screen.`)
args := struct {
Texts []string `cortana:"texts"`
}{}
cortana.Parse(&args)
fmt.Println(strings.Join(args.Texts, " "))
}
func echo() {
cortana.Title("Echo anything to the screen")
cortana.Description(`echo is for echoing anything back.
Echo works a lot like print, except it has a child command.`)
args := struct {
Texts []string `cortana:"texts"`
}{}
cortana.Parse(&args)
fmt.Println(strings.Join(args.Texts, " "))
}
func echoTimes() {
cortana.Title("Echo anything to the screen more times")
cortana.Description(`echo things multiple times back to the user by providing
a count and a string.`)
args := struct {
Times int `cortana:"--times, -t, 1, times to echo the input"`
Texts []string `cortana:"texts"`
}{}
cortana.Parse(&args)
for i := 0; i < args.Times; i++ {
fmt.Println(strings.Join(args.Texts, " "))
}
}
func main() {
cortana.AddCommand("print", print, "print anything to the screen")
cortana.AddCommand("echo", echo, "echo anything to the screen")
cortana.AddCommand("echo times", echoTimes, "echo anything to the screen more times")
cortana.Launch()
}
指令用法跟 cobra 完全一樣,隻是自動生成的幫助資訊有一些差別
# 不加任何子指令,輸出自動生成的幫助資訊
$ ./app
Available commands:
print print anything to the screen
echo echo anything to the screen
echo times echo anything to the screen more times
# 預設啟用 -h, --help 選項,開發者無需做任何事情
$ ./app print -h
Print anything to the screen
print is for printing anything back to the screen.
For many years people have printed back to the screen.
Usage: print [texts...]
-h, --help help for the command
# echo 任意内容
$ ./app echo hello world
hello world
# echo 任意次數
$ ./app echo times hello world --times 3
hello world
hello world
hello world
# --times 參數可以在任意位置
$ ./app echo --times 3 times hello world
hello world
hello world
hello world
1 選項與預設值
args := struct {
Times int `cortana:"--times, -t, 1, times to echo the input"`
Texts []string `cortana:"texts"`
}{}
可以看到, echo times 指令有一個 --times 辨別,另外,則是要回顯的内容,内容本質上也是指令行參數,并且可能因為内容中有空格,而被分割為多個參數。
我們上面提到,辨別本質上是将某個值綁定到某個變量,辨別的名字,比如這裡的 --times,跟變量 args.Times 關聯,那麼對于非辨別的其他參數呢,這些參數是沒有名字的,是以我們統一綁定到一個 Slice,也就是 args.Texts
Cortana 定義了屬于自己的 Struct Tag,分别用來指定其長辨別名、短辨別名,預設值和這個選項的描述資訊。其格式為: cortana:"long, short, default, description"
- 長辨別名(long): --flagname, 任意辨別都支援長辨別名的格式,如果不寫,則預設用字段名
- 短辨別名(short): -f,可以省略
- 預設值(default):可以為任意跟字段類型比對的值,如果省略,則預設為空值,如果為單個橫線 "-",則辨別使用者必須提供一個值
- 描述(description):這個選項的描述資訊,用于生成幫助資訊,描述中可以包含任意可列印字元(包括逗号和空格)
為了便于記憶,cortana這個 Tag 名字也可以寫為 lsdd,即上述四部分的英文首字母。
2 子指令與别名
AddCommond 可以添加任意子指令,其本質上是建立子指令與其處理函數的映射關系。
cortana.AddCommand("echo", echo, "echo anything to the screen")
在這個例子裡,print指令和 echo指令是相同的,我們其實可以通過别名的方式将兩者關聯
// 定義 print 為 echo 指令的别名
cortana.Alias("print", "echo")
執行 print 指令實際上執行的是 echo
$ ./app print -h
Echo anything to the screen
echo is for echoing anything back.
Echo works a lot like print, except it has a child command.
Available commands:
echo times echo anything to the screen more times
Usage: echo [texts...]
-h, --help help for the command
别名的機制非常靈活,可以為任意指令和參數設定别名,比如我們期望實作 three這個子指令,列印任意字元串 3 次。可以直接通過别名的方式實作:
cortana.Alias("three", "echo times --times 3")
# three 是 echo times --times 3 的别名
$ ./app three hello world
hello world
hello world
hello world
3 help 辨別和指令
Cortana 自動為任意指令生成幫助資訊,這個行為也可以通過 cortana.DisableHelpFlag禁用,也可以通過 cortana.HelpFlag來設定自己喜歡的辨別名。
cortana.Use(cortana.HelpFlag("--usage", "-u"))
# 自定義 --usage 來列印幫助資訊
$ ./app echo --usage
Echo anything to the screen
echo is for echoing anything back.
Echo works a lot like print, except it has a child command.
Available commands:
echo times echo anything to the screen more times
Usage: echo [texts...]
-u, --usage help for the command
Cortana 預設并沒有提供 help子指令,但利用别名的機制,我們自己很容易實作 help指令。
cortana.Alias("help", "--help")
// 通過别名,實作 help 指令,用于列印任意子指令的幫助資訊
$ ./app help echo times
Echo anything to the screen more times
echo things multiple times back to the user by providing
a count and a string.
Usage: echo times [options] [texts...]
-t, --times <times> times to echo the input. (default=1)
-h, --help help for the command
4 配置檔案與環境變量
除了通過指令行參數實作變量的綁定外,Cortana 還支援使用者自定義綁定配置檔案和環境變量,Cortana 并不負責配置檔案或環境變量的解析,使用者可以借助第三方庫來實作這個需求。Cortana 在這裡的主要作用是根據優先級合并不同來源的值。其遵循的優先級順序如下:
預設值 < 配置檔案 < 環境變量 < 參數
Cortana 設計為便于使用者使用任意格式的配置,使用者隻需要實作 Unmarshaler 接口即可,比如,使用 JSON 作為配置檔案:
cortana.AddConfig("app.json", cortana.UnmarshalFunc(json.Unmarshal))
Cortana 将配置檔案或環境變量的解析完全交給第三方庫,使用者可以自由定義如何将配置檔案綁定到變量,比如使用 jsonTag。
5 沒有子指令?
Cortana 的設計将指令查找和參數解析解耦,是以兩者可以分别獨立使用,比如在沒有子指令的場景下,直接在main函數中實作參數解析:
func main(){
args := struct {
Version bool `cortana:"--version, -v, , print the command version"`
}{}
cortana.Parse(&args)
if args.Version {
fmt.Println("v0.1.1")
return
}
// ...
}
$ ./app --version
v0.1.1
六 總結
指令行解析是一個大家都會用到,但并不是特别重要的功能,除非是專注于指令行使用的工具,一般程式我們都不需要過多關注指令行的解析,是以對于對這篇文章的主題感興趣,并能讀到文章最後的讀者,我表示由衷的感謝。
flag庫簡單易用,cobra 功能豐富,這兩個庫已經幾乎可以滿足我們所有的需求。然而,我在編寫指令行程式的過程中,總感到現有的庫美中不足,flag庫隻解決辨別解析的問題,cobra庫雖然支援子指令和參數的解析,但把子指令和參數的解析耦合在一起,導緻參數定義跟函數分離。Cortana的核心訴求是将指令查找和參數解析解耦,我通過重新回歸指令行參數的本質,發明了啟發式解析的方法,最終實作了上述目标。這種解耦使得 Cortana即具備 cobra一樣的豐富功能,又有像 flag一樣的使用體驗。這種通過精巧設計而用非常簡單的機制實作強大功能體驗讓我感到非常舒适,希望通過這篇文章,可以跟大家分享我的快樂。
項目位址:
https://github.com/shafreeck/cortana資料庫核心概念
資料庫,簡而言之可視為電子化的檔案櫃——存儲電子檔案的處所,使用者可以對檔案中的資料運作新增、截取、更新、删除等操作。資料庫管理系統(Database Management System,簡稱DBMS)是為管理資料庫而設計的電腦軟體系統,一般具有存儲、截取、安全保障、備份等基礎功能 要想學習資料庫,需要了解SQL、索引、視圖、鎖等概念,本節課帶你走進資料庫。
點選這裡,加入學習。