理解go语言编程-网络编程

TCP链接:
conn, err := net.Dial(“tcp”, “192.168.0.10:2100”)
UDP链接:
conn, err := net.Dial(“udp”, “192.168.0.12:975”)
ICMP链接(使用协议名称):
conn, err := net.Dial(“ip4:icmp”, “www.baidu.com")
ICMP链接(使用协议编号):
conn, err := net.Dial(“ip4:1”, “10.0.0.3”)

在成功建立连接后,我们就可以进行数据的发送和接收。发送数据时,使用conn的Write()成员方法,接收数据时使用Read()方法。

Dial()函数是对DialTCP()、DialUDP()、DialIP()和DialUnix()的封装.
验证IP地址有效性的代码如下: func net.ParseIP()
创建子网掩码的代码如下:
func IPv4Mask(a, b, c, d byte) IPMask
获取默认子网掩码的代码如下:
func (ip IP) DefaultMask() IPMask 根据域名查找IP的代码如下:
func ResolveIPAddr(net, addr string) (*IPAddr, error)
func LookupHost(name string) (cname string, addrs []string, err error);

net/http包的Client类型提供了如下几个方法,让我们可以用最简洁的方式实现HTTP请求:
func (c Client) Get(url string) (r Response, err error)
func (c Client) Post(url string, bodyType string, body io.Reader) (r Response, err
error)
func (c Client) PostForm(url string, data url.Values) (r Response, err error) func (c Client) Head(url string) (r Response, err error)
func (c Client) Do(req Request) (resp *Response, err error)

http.Get()方法,等价于http.DefaultClient.Get()。

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
resp, err := http.Get("http://example.com/")
if err != nil {
// 处理错误 ...
return
}
defer resp.Body.close() io.Copy(os.Stdout, resp.Body)

// 上传图片
resp, err := http.Post("http://example.com/upload", "image/jpeg", &imageDataBuf)
if err != nil {
// 处理错误
return
}
if resp.StatusCode != http.StatusOK {
// 处理错误
return
}
// http.PostForm()方法实现了标准编码格式为application/x-www-form-urlencoded的表单提交
resp, err := http.PostForm("http://example.com/posts", url.Values{"title": {"article title"}, "content": {"article body"}})
if err != nil {
// 处理错误
return
}
// Go 内置的 net/http 包同样也提供了 http.Head() 方法
resp, err := http.Head("http://example.com/")
// header定制
req, err := http.NewRequest("GET", "http://example.com", nil)
req.Header.Add("User-Agent", "Gobook Custom User-Agent")
client := &http.Client{}
resp, err := client.Do(req)

http.DefaultClient在字面上就向我们传达了一个信息,既然存在默认的Client,那么HTTP Client大概是可以自定义的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type Client struct {
// Transport用于确定HTTP请求的创建机制。
// 如果为空,将会使用DefaultTransport
Transport RoundTripper
// CheckRedirect定义重定向策略。
// 如果CheckRedirect不为空,客户端将在跟踪HTTP重定向前调用该函数。
// 两个参数req和via分别为即将发起的请求和已经发起的所有请求,最早的
// 已发起请求在最前面。
// 如果CheckRedirect返回错误,客户端将直接返回错误,不会再发起该请求。
// 如果CheckRedirect为空,Client将采用一种确认策略,将在10个连续
// 请求后终止
CheckRedirect func(req *Request, via []*Request) error
// 如果Jar为空,Cookie将不会在请求中发送,并会在响应中被忽略
Jar CookieJar
}

Transport类型必须实现http.RoundTripper接口。Transport指定了执行一个HTTP请求的运行机制,倘若不指定具体的Transport,默认会使用http.DefaultTransport,这意味着http.Transport也是可以自定义的。net/http包中的http.Transport类型实现了http.RoundTripper接口。
CheckRedirect函数指定处理重定向的策略。当使用 HTTP Client 的Get()或者是Head()方法发送HTTP请求时,若响应返回的状态码为 0x(比如 301/ 302/303/307),HTTP Client会在遵循跳转规则之前先调用这个CheckRedirect函数。
Jar可用于在HTTP Client中设定Cookie,Jar的类型必须实现了http.CookieJar接口,该接口预定义了SetCookies()和Cookies()两个方法。如果HTTP Client中没有设定Jar,Cookie将被忽略而不会发送到客户端。实际上,我们一般都用http.SetCookie()方法来设定Cookie。

1
2
3
4
5
6
7
8
client := &http.Client {
CheckRedirect: redirectPolicyFunc,
}
resp, err := client.Get("http://example.com")
// ...
req, err := http.NewRequest("GET", "http://example.com", nil) // ...
req.Header.Add("User-Agent", "Our Custom User-Agent") req.Header.Add("If-None-Match", `W/"TheFileEtag"`)
resp, err := client.Do(req)

除了 http.Transport 类型中定义的公开数据成员以外,它同时还提供了几个公开的成员
方法。

  • func(t *Transport) CloseIdleConnections()。该方法用于关闭所有非活跃的
    连接.
  • func(t *Transport) RegisterProtocol(scheme string, rt RoundTripper)。
    该方法可用于注册并启用一个新的传输协议,比如WebSocket的传输协议标准(ws),或
    者FTP、File协议等。
  • func(t Transport) RoundTrip(req Request) (resp *Response, err error)。
    用于实现http.RoundTripper接口。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    type Transport struct {
    // Proxy指定用于针对特定请求返回代理的函数。
    // 如果该函数返回一个非空的错误,请求将终止并返回该错误。
    // 如果Proxy为空或者返回一个空的URL指针,将不使用代理
    Proxy func(*Request) (*url.URL, error)
    // Dial指定用于创建TCP连接的dail()函数。
    // 如果Dial为空,将默认使用net.Dial()函数
    Dial func(net, addr string) (c net.Conn, err error)
    // TLSClientConfig指定用于tls.ClientTLS配置。
    // 如果为空则使用默认配置
    TLSClientConfig *tls.Config
    DisableKeepAlives bool
    DisableCompression bool
    // 如果MaxIdleConnsPerHost为非零值,它用于控制每个host所需要
    // 保持的最大空闲连接数。如果该值为空,则使用DefaultMaxIdleConnsPerHost MaxIdleConnsPerHost int
    }

    tr := &http.Transport{
    TLSClientConfig: &tls.Config{RootCAs: pool},
    DisableCompression: true,
    }
    client := &http.Client{Transport: tr}
    resp, err := client.Get("https://example.com")

非必要情况下,不应该在RoundTrip()中改写传入的请求体(*Request),请求体的内容(比如URL和Header等)必须在传入RoundTrip()之前就已组织好并完成初始化。

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
type RoundTripper interface {
// RoundTrip执行一个单一的HTTP事务,返回相应的响应信息。
// RoundTrip函数的实现不应试图去理解响应的内容。如果RoundTrip得到一个响应,
// 无论该响应的HTTP状态码如何,都应将返回的err设置为nil。非空的err
// 只意味着没有成功获取到响应。
// 类似地,RoundTrip也不应试图处理更高级别的协议,比如重定向、认证和
// Cookie等。 //
// RoundTrip不应修改请求内容, 除非了是为了理解Body内容。每一个请求
// 的URL和Header域都应被正确初始化
RoundTrip(*Request) (*Response, error)
}
// 例子
type OurCustomTransport struct {
Transport http.RoundTripper
}
func (t *OurCustomTransport) transport() http.RoundTripper {
if t.Transport != nil {
return t.Transport
}
return http.DefaultTransport
}

func (t *OurCustomTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// 处理一些事情 ...
// 发起HTTP请求
// 添加一些域到req.Header中
return t.transport().RoundTrip(req)
}
func (t *OurCustomTransport) Client() *http.Client {
return &http.Client{Transport: t}
}
func main() {
t := &OurCustomTransport{}
c := t.Client()
resp, err := c.Get("http://example.com")
// ...
}

HTTP Client在业务层初始化HTTP Method目标URL、请求参数、请求内容等重要信息后,经过“传输层”,“传输层”在业务层处理的基础上补充其他细节,然后再发起HTTP请求,接收服务端返回的HTTP响应。

使用net/http包提供的http.ListenAndServe()方法,可以在指定的地址进行监听,开启一个HTTP。该方法用于在指定的TCP网络地址addr进行监听,然后调用服务端处理程序来处理传入的连接请求。该方法有两个参数:第一个参数addr即监听地址;第二个参数表示服务端处理程序,通常为空,这意味着服务端调用http.DefaultServeMux进行处理,而服务端编写的业务逻辑处理程序http.Handle()或http.HandleFunc()默认注入http.DefaultServeMux中

1
2
3
4
5
http.Handle("/foo", fooHandler)
http.HandleFunc("/bar", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello, %q", html.EscapeString(r.URL.Path))
})
log.Fatal(http.ListenAndServe(":8080", nil))

如果想更多地控制服务端的行为,可以自定义http.Server

1
2
3
4
5
6
7
8
s := &http.Server{
Addr: ":8080",
Handler: myHandler,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
MaxHeaderBytes: 1 << 20,
}
log.Fatal(s.ListenAndServe())

net/http包还提供http.ListenAndServeTLS()方法,用于处理HTTPS连接请求:
服务器上必须存在包含证书和与之匹配的私钥的相关文件,比如certFile对应SSL证书文件存放路径,keyFile对应证书私钥文件路径。如果证书是由证书颁发机构签署的,certFile 参数指定的路径必须是存放在服务器上的经由CA认证过的SSL证书。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
http.Handle("/foo", fooHandler)
http.HandleFunc("/bar", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello, %q", html.EscapeString(r.URL.Path))
})
log.Fatal(http.ListenAndServeTLS(":10443", "cert.pem", "key.pem", nil))
// 或者是:
ss := &http.Server{
Addr: ":10443",
Handler: myHandler,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
MaxHeaderBytes: 1 << 20,
}
log.Fatal(ss.ListenAndServeTLS("cert.pem", "key.pem"))

RPC协议构建于TCP或UDP,或者是HTTP之上,允许开发者直接调用另一台计算机上的程序,而开发者无需额外地为这个调用过程编写网络通信相关代码,使得开发包括网络分布式程序在内的应用程序更加容易。

一个对象中只有满足如下这些条件的方法,才能被RPC服务端设置为可供远程访问:

  • 必须是在对象外部可公开调用的方法(首字母大写);
  • 必须有两个参数,且参数的类型都必须是包外部可以访问的类型或者是Go内建支持的类
    型;
  • 第二个参数必须是一个指针;
  • 方法必须返回一个error类型的值。
    以上4个条件,可以简单地用如下一行代码表示:
    func (t T) MethodName(argType T1, replyType T2) error
    类型T、T1和T2默认会使用Go内置的encoding/gob包进行编码解码

在RPC客户端,Go的net/rpc包提供了便利的rpc.Dial()和rpc.DialHTTP()方法来与指定的RPC服务端建立连接。在建立连接之后,Go的net/rpc包允许我们使用同步或者异步的方式接收RPC服务端的处理结果。调用RPC客户端的Call()方法则进行同步处理,这时候客户端程序按顺序执行,只有接收完RPC服务端的处理结果之后才可以继续执行后面的程序。当调用RPC客户端的Go()方法时,则可以进行异步处理,RPC客户端程序无需等待服务端的结果即可执行后面的程序,而当接收到RPC服务端的处理结果时,再对其进行相应的处理。无论是调用RPC客户端的Call()或者是Go()方法,都必须指定要调用的服务及其方法名称,以及一个客户端传入参数的引用,还有一个用于接收处理结果参数的指针。

如果没有明确指定RPC传输过程中使用何种编码解码器,默认将使用Go标准库提供的encoding/gob包进行数据传输。

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
type Args struct {
A, B int
}
type Quotient struct {
Quo, Rem int
}
type Arith int

func (t *Arith) Multiply(args *Args, reply *int) error {
*reply = args.A * args.B
return nil
}

func (t *Arith) Divide(args *Args, quo *Quotient) error {
if args.B == 0 {
return errors.New("divide by zero")
}
quo.Quo = args.A / args.B
quo.Rem = args.A % args.B
return nil
}

func main() {
// 注册服务对象并开启该 RPC 服务的代码如下
arith := new(Arith)
rpc.Register(arith)
rpc.HandleHTTP()
l, e := net.Listen("tcp", ":1234")
if e != nil {
log.Fatal("listen error:", e)
}
go http.Serve(l, nil)

client, err := rpc.DialHTTP("tcp", "127.0.0.1:1234")
if err != nil {
log.Fatal("dialing:", err)
}

// 同步调用
args := &Args{7, 8}
var reply int
err = client.Call("Arith.Multiply", args, &reply)
if err != nil {
log.Fatal("arith error:", err)
}
fmt.Printf("Arith: %d*%d=%d\n", args.A, args.B, reply)

// 异步方式进行调用
args = &Args{20, 3}
quotient := new(Quotient)
divCall := client.Go("Arith.Divide", args, &quotient, nil)
replyCall := <-divCall.Done
fmt.Println(replyCall.Args, quotient)
}

Gob是Go的一个序列化数据结构的编码解码工具,在Go标准库中内置encoding/gob包以供使用。一个数据结构使用Gob进行序列化之后,能够用于网络传输。与JSON或XML这种基于文本描述的数据交换语言不同,Gob是二进制编码的数据流,并且Gob流是可以自解释的,它在保证高效率的同时,也具备完整的表达能力。
作为针对Go的数据结构进行编码和解码的专用序列化方法,这意味着Gob无法跨语言使用。在Go的net/rpc包中,传输数据所需要用到的编码解码器,默认就是Gob。由于Gob仅局限于使用Go语言开发的程序,这意味着我们只能用Go的RPC实现进程间通信.

RP提供的编码解码器接口如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
type ClientCodec interface {
WriteRequest(*Request, interface{}) error
ReadResponseHeader(*Response) error
ReadResponseBody(interface{}) error
Close() error
}

type ServerCodec interface {
ReadRequestHeader(*Request) error
ReadRequestBody(interface{}) error
WriteResponse(*Response, interface{}) error
Close() error
}

接口ClientCodec定义了RPC客户端如何在一个RPC会话中发送请求和读取响应。客户端程序通过WriteRequest()方法将一个请求写入到RPC连接中,并通过ReadResponseHeader()和ReadResponseBody()读取服务端的响应信息。当整个过程执行完毕后,再通过Close()方法来关闭该连接。
接口ServerCodec定义了RPC服务端如何在一个RPC会话中接收请求并发送响应。服务端程序通过ReadRequestHeader()和ReadRequestBody()方法从一个RPC连接中读取请求信息,然后再通过WriteResponse()方法向该连接中的RPC客户端发送响应。当完成该过程后,通过Close()方法来关闭连接。
通过实现上述接口,我们可以自定义数据传输前后的编码解码方式,而不仅仅局限于Gob。同样,可以自定义RPC服务端和客户端的交互行为。实际上,Go标准库提供的net/rpc/json包,就是一套实现了rpc.ClientCodec和rpc.ServerCodec接口的JSON-RPC模块。

JSON(JavaScript Object Notation)是一种比XML更轻量级的数据交换格式,在易于人们阅读和编写的同时,也易于程序解析和生成。尽管JSON是JavaScript的一个子集,但JSON采用完全独立于编程语言的文本格式,且表现为键/值对集合的文本描述形式(类似一些编程语言中的字典结构),这使它成为较为理想的、跨平台、跨语言的数据交换语言。

Go语言内建对JSON的支持。使用Go语言内置的encoding/json标准库,开发者可以轻松使用Go程序生成和解析JSON格式的数据。在Go语言实现JSON的编码和解码时,遵循RFC4627协议标准。

使用json.Marshal()函数可以对一组数据进行JSON格式的编码。

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
func Marshal(v interface{}) ([]byte, error)
type Book struct {
Title string
Authors []string
Publisher string
IsPublished bool
Price float
}

gobook := Book{
"Go语言编程",
["XuShiwei", "HughLv", "Pandaman", "GuaguaSong", "HanTuo", "BertYuan", "XuDaoli"],
"ituring.com.cn", true,
9.99
}
b, err := json.Marshal(gobook)
// 如果编码成功,err将赋于零值nil,变量b将会是一个进行JSON格式化之后的[]byte类型
b == []byte(`{
"Title": "Go语言编程",
"Authors": ["XuShiwei", "HughLv", "Pandaman", "GuaguaSong", "HanTuo", "BertYuan",
"XuDaoli"],
"Publisher": "ituring.com.cn",
"IsPublished": true,
"Price": 9.99
}`)

当我们调用json.Marshal(gobook)语句时,会递归遍历gobook对象,如果发现gobook这个数据结构实现了json.Marshaler接口且包含有效的值,Marshal()就会调用其MarshalJSON()方法将该数据结构生成JSON格式的文本。
Go语言的大多数数据类型都可以转化为有效的JSON文本,但channel、complex和函数这几种类型除外。如果转化前的数据结构中出现指针,那么将会转化指针所指向的值,如果指针指向的是零值,那么null将作为转化后的结果输出。
在Go中,JSON转化前后的数据类型映射如下

  • 浮点数和整型会被转化为JSON里边的常规数字
  • 字符串将以UTF-8编码转化输出为Unicode字符集的字符串,特殊字符比如<将会被转义为\u003c
  • 数组和切片会转化为JSON里边的数组,但[]byte类型的值将会被转化为Base64编码后的字符串,slice类型的零值会被转化为null
  • 结构体会转化为JSON对象,并且只有结构体里边以大写字母开头的可被导出的字段才会被转化输出,而这些可导出的字段会作为JSON对象的字符串索引
  • 转化一个map类型的数据结构时,该数据的类型必须是map[string]T(T可以是encoding/json包支持的任意数据类型)。

可以使用json.Unmarshal()函数将JSON格式的文本解码为Go里边预期的数据结构,该函数的第一个参数是输入,即JSON格式的文本(比特序列),第二个参数表示目标输出容器,用于存放解码后的值。

1
2
3
4
5
6
7
8
9
10
11
func Unmarshal(data []byte, v interface{}) error
var book Book
err := json.Unmarshal(b, &book)
// 如果b是一个有效的JSON数据并能和book结构对应起来,那么JSON解码后的值将会一一存放到book结构体中
book := Book{
"Go语言编程",
["XuShiwei", "HughLv", "Pandaman", "GuaguaSong", "HanTuo", "BertYuan", "XuDaoli"],
"ituring.com.cn",
true,
9.99
}

如果JSON中的字段在Go目标类型中不存在,json.Unmarshal()函数在解码过程中会丢弃该字段。
如果要解码一段未知结构的JSON,只需将这段JSON数据解码输出到一个空接口即可。在解码JSON数据的过程中,JSON数据里边的元素类型将做如下转换:

  • JSON中的布尔值将会转换为Go中的bool类型;
  • 数值会被转换为Go中的float64类型;
  • 字符串转换后还是string类型;
  • JSON数组会转换为[]interface{}类型;
  • JSON对象会转换为map[string]interface{}类型;
  • null值会转换为nil。
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
b := []byte(`{
"Title": "Go语言编程",
"Authors": ["XuShiwei", "HughLv", "Pandaman", "GuaguaSong", "HanTuo", "BertYuan","XuDaoli"],
"Publisher": "ituring.com.cn",
"IsPublished": true,
"Price": 9.99,
"Sales": 1000000
}`)
var r interface{}
err := json.Unmarshal(b, &r)
// r将会是一个键值对的map[string]interface{}结构
map[string]interface{}{
"Title": "Go语言编程",
"Authors": ["XuShiwei", "HughLv", "Pandaman", "GuaguaSong", "HanTuo", "BertYuan", "XuDaoli"],
"Publisher": "ituring.com.cn",
"IsPublished": true,
"Price": 9.99,
"Sales": 1000000
}
// 要访问解码后的数据结构,需要先判断目标结构是否为预期的数据类型:
gobook, ok := r.(map[string]interface{})
// 一种解码未知结构的JSON数据的安全方式
if ok {
for k, v := range gobook {
switch v2 := v.(type) {
case string:
fmt.Println(k, "is string", v2)
case int:
fmt.Println(k, "is int", v2)
case bool:
fmt.Println(k, "is bool", v2)
case []interface{}:
fmt.Println(k, "is an array:")
for i, iv := range v2 {
fmt.Println(i, iv)
}
default:
fmt.Println(k, "is another type not handle yet")
}
}
}

Go内建的encoding/json包还提供Decoder和Encoder两个类型,用于支持JSON数据的流式读写,并提供NewDecoder()和NewEncoder()两个函数来便于具体实现:
Go的标准库net/rpc/jsonrpc就是一个应用了Decoder和Encoder的实际例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func NewDecoder(r io.Reader) *Decoder
func NewEncoder(w io.Writer) *Encoder

// 标准输入流中读取JSON数据,然后将其解码,但只保留Title字段(书名),再写入到标准输出流中
func main() {
dec := json.NewDecoder(os.Stdin)
enc := json.NewEncoder(os.Stdout)
for {
var v map[string]interface{}
if err := dec.Decode(&v); err != nil {
log.Println(err)
return
}
for k := range v {
if k != "Title" {
v[k] = nil
}
}
if err := enc.Encode(&v); err != nil {
log.Println(err)
}
}
}

使用io包而不是fmt包来输出字符串,这样源文件编译成可执行文件后,体积要小很多,运行起来也更省资源.

1
2
3
4
5
6
7
8
9
10
func helloHandler(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, "Hello, world!")
}
func main() {
http.HandleFunc("/hello", helloHandler)
err := http.ListenAndServe(":8080", nil)
if err != nil {
log.Fatal("ListenAndServe: ", err.Error())
}
}

如你想到的那样,在业务逻辑处理程序中混杂HTML可不是什么好事情,代码多起来后会导致程序不够清晰,而且改动程序里边的HTML文本时,每次都要重新编译整个工程的源代码才能看到修改后的效果。正确的做法是,应该将业务逻辑程序和表现层分离开来,各自单独处理。这时候,就需要使用网页模板技术了。

我们引入了Go标准库中的html/template包,实现了业务逻辑层与表现层分离后,对模板渲染逻辑去重,编写并使用通用模板渲染方法renderHtml(),这让业务逻辑处理层的代码看起来确实要清晰简洁许多。

Go支持闭包。闭包可以是一个函数里边返回的另一个匿名函数,该匿名函数包含了定义在它外面的值。使用闭包,可以让我们网站的业务逻辑处理程序更安全地运行。

1
2
3
4
5
6
7
8
9
10
11
12
func safeHandler(fn http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err, ok := recover().(error); ok {
http.Error(w, err.Error(), http.StatusInternalServerError) // 或者输出自定义的 50x错误页面
log.Printf("WARN: panic in %v - %v", fn, err)
log.Println(string(debug.Stack()))
}
}()
fn(w, r)
}
}

net/http包提供的这个ServeFile()函数可以将服务端的一个文件内容读写到http.Response- Writer并返回给请求来源的*http.Request客户端。

nephen wechat
欢迎您扫一扫上面的微信公众号,订阅我的博客!
坚持原创技术分享,您的支持将鼓励我继续创作!