x/net/http2: what happens if Stream ID exhausted and what the action when the error of errStreamID is triggered

huangapple go评论91阅读模式
英文:

x/net/http2: what happens if Stream ID exhausted and what the action when the error of errStreamID is triggered

问题

相关的RFC章节提到了以下内容:

RFC 7540
流标识符不能被重复使用。长时间的连接可能导致端点耗尽可用的流标识符范围。无法建立新的流标识符的客户端可以为新的流建立新的连接。无法建立新的流标识符的服务器可以发送一个GOAWAY帧,以便客户端被迫为新的流打开一个新的连接。

根据HTTP2的RFC,在客户端发送请求时,如果流标识符大于2^31,我们的HTTP2库将视其为无效的流标识符,并返回errStreamId错误。但是似乎在我们的HTTP2库或h2_bundle中没有对errStreamId进行额外的处理。那么客户端如何知道流标识符已耗尽,以便可以请求新的连接?而且旧的连接不能被客户端或服务器关闭,因为旧的流标识符仍然可以保持。

是否可能遇到流标识符耗尽(超过2^31)的HTTP2连接问题?换句话说,是否会触发errStreamID错误?

类似于这样:

https://github.com/golang/net/blob/daac0cec0cf964a628a29bb4b82940c225b921ed/http2/frame.go#L1097

但是没有将errStreamID返回给werr,所以werr(写入错误)为nil

https://github.com/golang/net/blob/daac0cec0cf964a628a29bb4b82940c225b921ed/http2/transport.go#L1637

所以我感到困惑如何处理errStreamID,并且我尝试使用HTTP2客户端/服务器回显模块进行测试,其中流标识符为1,并手动修改代码如下:

func (f *Framer) WriteHeaders(p HeadersFrameParam) error {
	//if !validStreamID(p.StreamID) && !f.AllowIllegalWrites {
	//	return errStreamID
	//}
	return errStreamID

我发现客户端没有发送任何TCP连接请求,通过使用Wireshark捕获帧,代码在pipe.go的read函数中阻塞,可能是因为我的观察有限。

客户端使用的是:
https://github.com/posener/h2conn/blob/master/example/echo/client.go#L35

在此处输入图片描述

Golang如何处理errStreamId?为什么HTTP2库会被阻塞?可能是因为Golang的HTTP2库不支持这个功能?如果要实现流标识符耗尽的功能,应用层必须自己维护流标识符,而不是依赖于HTTP2库,如果流标识符耗尽,应用程序应该请求一个新的连接。

HTTP2库的下一个流标识符应该传递给应用层,并且应用程序应该维护流标识符,如果应用程序知道下一个流标识符大于2^31,它将通过新的端口请求一个新的连接,并且旧的HTTP2(TCP)连接应该保持。

英文:

A related RFC chapter mentions this:

RFC 7540
Stream identifiers cannot be reused. Long-lived connections can
result in an endpoint exhausting the available range of stream
identifiers. A client that is unable to establish a new stream
identifier can establish a new connection for new streams. A server
that is unable to establish a new stream identifier can send a GOAWAY
frame so that the client is forced to open a new connection for new
streams.

Based on http2 RFC, from client site, when the request send out, if stream id larger then 2^31, our http2 lib will see it as invalid stream id , and return errStreamId. But it is seem that there is no additional processing for errStreamId in our http2 lib or in h2_bundle. So how client to know streamID exhausted so that it can request new connection? and legacy connection could not be shutdown by client or server, because legacy stream id still can be maintain.

Is it possible to run into a problem where HTTP2 connections where the stream id is exhausted (more than 2^31). Another words, errStreamID will be triggered.

Like this:

https://github.com/golang/net/blob/daac0cec0cf964a628a29bb4b82940c225b921ed/http2/frame.go#L1097

but not return errStreamID to werr, so werr(write err) is nil

https://github.com/golang/net/blob/daac0cec0cf964a628a29bb4b82940c225b921ed/http2/transport.go#L1637

So I feel confuse how to handle the errStreamID , and I try to test by myself by using http2 client/server echo module, which stream id is 1 ,and manually modify the code like this

func (f *Framer) WriteHeaders(p HeadersFrameParam) error {
	//if !validStreamID(p.StreamID) && !f.AllowIllegalWrites {
	//	return errStreamID
	//}
	return errStreamID

I find the client do not send any tcp connection request by capture frames using wireshark, and the code blocked at function of read in pipe.go, maybe my observations are limited.

client used :
https://github.com/posener/h2conn/blob/master/example/echo/client.go#L35

enter image description here

How golang handle errStreamId, and Why http2 lib will be blocked? May golang's http2 lib do not support this? if want to implement about stream id exhausted. Application level have to maintain stream id by itself, not rely on http2 lib, if stream id exhaust, application should request a new connection.

http2 lib's next stream id should transfer to application level and application should maintain the stream Id, if application knows next stream id is larger then 2^31, it will request a new connection by a new port, and legacy http2(tcp) connection should be kept.

答案1

得分: 1

这个答案关注客户端。

在调用(*Framer).WriteHeaders(或*Framer的其他方法)之前,流ID在很长时间内都是有效的。

入口点:(*Transport).RoundTrip

让我们从(*Transport).RoundTrip开始,可以将其视为发送请求的入口点。该方法依次调用(*Transport).RoundTripOpt完整源代码)。

func (t *Transport) RoundTripOpt(req *http.Request, opt RoundTripOpt) (*http.Response, error) {

	// ...省略的无关代码...
	for retry := 0; ; retry++ {
		cc, err := t.connPool().GetClientConn(req, addr)
		
		// ...省略的无关代码...
		res, err := cc.RoundTrip(req)
		// ...省略的无关代码...
		return res, nil
	}
}

我们将深入研究t.connPool().GetClientConn(req, addr)res, err := cc.RoundTrip(req)

连接池:(*clientConnPool).getClientConn

(*clientConnPool).GetClientConn依次调用(*clientConnPool).getClientConn完整源代码):

func (p *clientConnPool) getClientConn(req *http.Request, addr string, dialOnMiss bool) (*ClientConn, error) {
	// ...省略的无关代码...
	for {
		p.mu.Lock()
		for _, cc := range p.conns[addr] {
			if cc.ReserveNewRequest() {
				// ...省略的无关代码...
				p.mu.Unlock()
				return cc, nil
			}
		}
		// ...省略的无关代码...
		call := p.getStartDialLocked(req.Context(), addr)
		p.mu.Unlock()
		<-call.done
		if shouldRetryDial(call, req) {
			continue
		}
		cc, err := call.res, call.err
		if err != nil {
			return nil, err
		}
		if cc.ReserveNewRequest() {
			return cc, nil
		}
	}
}

如果它可以从池中找到可用于发送请求的连接,则使用该连接。否则,拨号获取一个新连接。这是(*ClientConn).ReserveNewRequest完整源代码):

func (cc *ClientConn) ReserveNewRequest() bool {
	cc.mu.Lock()
	defer cc.mu.Unlock()
	if st := cc.idleStateLocked(); !st.canTakeNewRequest {
		return false
	}
	cc.streamsReserved++
	return true
}

以及(*ClientConn).idleStateLocked完整源代码):

func (cc *ClientConn) idleStateLocked() (st clientConnIdleState) {
	// ...省略的无关代码...
	st.canTakeNewRequest = cc.goAway == nil && !cc.closed && !cc.closing && maxConcurrentOkay &&
		!cc.doNotReuse &&
		int64(cc.nextStreamID)+2*int64(cc.pendingRequests) < math.MaxInt32 &&
		!cc.tooIdleLocked()
	return
}

这里是关键int64(cc.nextStreamID)+2*int64(cc.pendingRequests) < math.MaxInt32。只有在保证其nextStreamID有效时,才会使用连接。

分配流ID:(*ClientConn).RoundTrip

现在让我们回到(*ClientConn).RoundTrip,看看如何为clientStream分配流ID。

调用顺序如下:

func (cc *ClientConn) addStreamLocked(cs *clientStream) {
	cs.flow.add(int32(cc.initialWindowSize))
	cs.flow.setConnFlow(&cc.flow)
	cs.inflow.init(transportDefaultStreamFlow)
	cs.ID = cc.nextStreamID
	// ^^^^^^^^^^^^^^^^^^^^ <==== 这里
	cc.nextStreamID += 2
	cc.streams[cs.ID] = cs
	if cs.ID == 0 {
		panic("assigned stream ID 0")
	}
}

流ID从(ClientConn).nextStreamID复制而来,而(ClientConn).nextStreamID增加了2(由客户端发起的流必须使用奇数流标识符)。

无法用于发送新请求的连接会发生什么?

这些连接迟早会变为空闲状态。它们将保持空闲一段时间(最大时间由Transport.IdleConnTimeout控制),然后自行关闭(如果Transport.IdleConnTimeout为零,则不会关闭)。

以下示例演示了这种行为。

注意

  1. 该示例需要修改标准包以快速耗尽流ID。这是修改的行

    - nextStreamID:          1,
    + nextStreamID:          math.MaxInt32-2,
    
  2. 由于需要修改标准包,因此还需要从源代码构建go工具。假设go工具构建到~/src/golang/go/bin/go

  3. 使用以下命令运行示例:

    GODEBUG=http2debug=2 ~/src/golang/go/bin/go run main.go > logs.txt 2>&1
    
  4. 示例中的Transport与我们在前面的部分中讨论的不同。下面的示例中的客户端最终将调用h2_bundle.goh2_bundle.go是从golang.org/x/net/http2生成的。在前面的部分中,所有源代码都来自该包。

以下是示例的输出。它显示了由于没有可用的缓存连接(流ID耗尽),因此创建了新连接。在IdleConnTimeout之后,空闲连接会自行关闭。

2023/05/10 23:48:20 ======= request: 0
2023/05/10 23:48:20 http2: Transport failed to get client conn for 127.0.0.1:46027: http2: no cached connection was available
2023/05/10 23:48:20 http2: server connection from 127.0.0.1:48362 on 0xc00014a000
<...省略的输出...>
2023/05/10 23:48:20 http2: Transport received DATA flags=END_STREAM stream=2147483645 len=19 data="404 page not found\n"
2023/05/10 23:48:20 ======= request: 1
2023/05/10 23:48:20 http2: Transport failed to get client conn for 127.0.0.1:46027: http2: no cached connection was available
2023/05/10 23:48:20 http2: Transport failed to get client conn for 127.0.0.1:46027: http2: no cached connection was available
2023/05/10 23:48:20 http2: Transport creating client conn 0xc000004780 to 127.0.0.1:46027
<...省略的输出...>
2023/05/10 23:48:20 http2: Transport received DATA flags=END_STREAM stream=2147483645 len=19 data="404 page not found\n"
2023/05/10 23:48:20 ======= request: 2
2023/05/10 23:48:20 http2: Transport failed to get client conn for 127.0.0.1:46027: http2: no cached connection was available
2023/05/10 23:48:20 http2: Transport failed to get client conn for 127.0.0.1:46027: http2: no cached connection was available
2023/05/10 23:48:20 http2: Transport creating client conn 0xc00009e480 to 127.0.0.1:46027
<...省略的输出...>
2023/05/10 23:48:20 http2: Transport received DATA flags=END_STREAM stream=2147483645 len=19 data="404 page not found\n"
2023/05/10 23:48:20 ======= begin sleep for 100s
2023/05/10 23:49:50 http2: Transport closing idle conn 0xc00034a180 (forSingleUse=false, maxStream=2147483645)
2023/05/10 23:49:50 http2: Transport closing idle conn 0xc000004600 (forSingleUse=false, maxStream=2147483645)
<...省略的输出...>
2023/05/10 23:50:00 ======= end sleep for 100s
英文:

This answer focus on the client side.

The stream id is guaranteed to be valid far before (*Framer).WriteHeaders (or other methods of *Framer) is called.

Entry point: (*Transport).RoundTrip

Let's start from (*Transport).RoundTrip, which could be considered as the entry point to send a request. This method calls (*Transport).RoundTripOpt in turn (full source).

func (t *Transport) RoundTripOpt(req *http.Request, opt RoundTripOpt) (*http.Response, error) {

	// ...unrelated code truncated...
	for retry := 0; ; retry++ {
		cc, err := t.connPool().GetClientConn(req, addr)
		
		// ...unrelated code truncated...
		res, err := cc.RoundTrip(req)
		// ...unrelated code truncated...
		return res, nil
	}
}

We will drill down t.connPool().GetClientConn(req, addr) and res, err := cc.RoundTrip(req).

Connection pool: (*clientConnPool).getClientConn

(*clientConnPool).GetClientConn calls (*clientConnPool).getClientConn (full source) in turn:

func (p *clientConnPool) getClientConn(req *http.Request, addr string, dialOnMiss bool) (*ClientConn, error) {
	// ...unrelated code truncated...
	for {
		p.mu.Lock()
		for _, cc := range p.conns[addr] {
			if cc.ReserveNewRequest() {
				// ...unrelated code truncated...
				p.mu.Unlock()
				return cc, nil
			}
		}
		// ...unrelated code truncated...
		call := p.getStartDialLocked(req.Context(), addr)
		p.mu.Unlock()
		&lt;-call.done
		if shouldRetryDial(call, req) {
			continue
		}
		cc, err := call.res, call.err
		if err != nil {
			return nil, err
		}
		if cc.ReserveNewRequest() {
			return cc, nil
		}
	}
}

If it can find a connection from the pool that can be used to send the request, use it. Otherwise, dial for a new one. Here is (*ClientConn).ReserveNewRequest (full source):

func (cc *ClientConn) ReserveNewRequest() bool {
	cc.mu.Lock()
	defer cc.mu.Unlock()
	if st := cc.idleStateLocked(); !st.canTakeNewRequest {
		return false
	}
	cc.streamsReserved++
	return true
}

And (*ClientConn).idleStateLocked (full source):

func (cc *ClientConn) idleStateLocked() (st clientConnIdleState) {
	// ...unrelated code truncated...
	st.canTakeNewRequest = cc.goAway == nil &amp;&amp; !cc.closed &amp;&amp; !cc.closing &amp;&amp; maxConcurrentOkay &amp;&amp;
		!cc.doNotReuse &amp;&amp;
		int64(cc.nextStreamID)+2*int64(cc.pendingRequests) &lt; math.MaxInt32 &amp;&amp;
		!cc.tooIdleLocked()
	return
}

Here it is: int64(cc.nextStreamID)+2*int64(cc.pendingRequests) &lt; math.MaxInt32. A connection will be used only when its nextStreamID is guaranteed to be valid.

Assign stream id: (*ClientConn).RoundTrip

Now let's turn back to (*ClientConn).RoundTrip to see how a stream id is assigned to clientStream.

The call sequence is:

func (cc *ClientConn) addStreamLocked(cs *clientStream) {
	cs.flow.add(int32(cc.initialWindowSize))
	cs.flow.setConnFlow(&amp;cc.flow)
	cs.inflow.init(transportDefaultStreamFlow)
	cs.ID = cc.nextStreamID
	// ^^^^^^^^^^^^^^^^^^^^ &lt;==== Here it is.
	cc.nextStreamID += 2
	cc.streams[cs.ID] = cs
	if cs.ID == 0 {
		panic(&quot;assigned stream ID 0&quot;)
	}
}

The stream id is copied from (ClientConn).nextStreamID and (ClientConn).nextStreamID increases by 2 (Streams
initiated by a client MUST use odd-numbered stream identifiers).

What happens to the connections that can not be used to send new request?

These connections will become idle sooner or later. And they will remain idle for some time (the maximum amount is controlled by Transport.IdleConnTimeout) and then close themselves (if Transport.IdleConnTimeout is zero, they will not close themselves).

The behavior is demonstrated by the following example.

Notes:

  1. the example requires to modify the standard package to make the stream id exhausted fast. This is the line to change:

    - nextStreamID:          1,
    + nextStreamID:          math.MaxInt32-2,
    
  2. since it requires to modify the standard package, the go tools should be built from the source code too. Let's assume that the go tools is built to ~/src/golang/go/bin/go.

  3. run the example with this command:

    GODEBUG=http2debug=2 ~/src/golang/go/bin/go run main.go &gt;logs.txt 2&gt;&amp;1
    
  4. the Transport in the example is not the same as the one we discussed in the previous sections. The client in the example below will call into h2_bundle.go eventually. h2_bundle.go is generated from golang.org/x/net/http2. In the previous sections, all the source codes is from this package.

Here comes the example:

package main

import (
	&quot;io&quot;
	&quot;log&quot;
	&quot;net&quot;
	&quot;net/http&quot;
	&quot;net/http/httptest&quot;
	&quot;time&quot;
)

func main() {
	ts := httptest.NewUnstartedServer(nil)
	ts.EnableHTTP2 = true
	ts.StartTLS()
	defer ts.Close()

	client := ts.Client()
	if transport, ok := client.Transport.(*http.Transport); ok {
		// The transport created by httptest is different from the default
		// transport. Let&#39;s copy the configurations from the default transport.
		// See:
		// - https://github.com/golang/go/blob/f30cd520516037b2fdb367ddd8e0851019bf3440/src/net/http/httptest/server.go#L176-L181
		// - https://github.com/golang/go/blob/f30cd520516037b2fdb367ddd8e0851019bf3440/src/net/http/transport.go#L43-L54
		transport.DialContext = (&amp;net.Dialer{
			Timeout:   30 * time.Second,
			KeepAlive: 30 * time.Second,
		}).DialContext
		// It seems that the MaxIdleConns is ignored in HTTP2.
		transport.MaxIdleConns = 100
		// Here it is. The IdleConnTimeout.
		transport.IdleConnTimeout = 90 * time.Second
		transport.TLSHandshakeTimeout = 10 * time.Second
		transport.ExpectContinueTimeout = 1 * time.Second
	}

	for i := 0; i &lt; 5; i++ {
		log.Printf(&quot;======= request: %d\n&quot;, i)
		resp, err := client.Get(ts.URL)
		if err != nil {
			log.Printf(&quot;client.Get: %v&quot;, err)
			continue
		}
		_, _ = io.Copy(io.Discard, resp.Body)
		resp.Body.Close()
	}

	log.Println(&quot;======= begin sleep for 100s&quot;)
	// Wait for the IdleConnTimeout to take effect.
	time.Sleep(100 * time.Second)
	log.Println(&quot;======= end sleep for 100s&quot;)
}

Below is the output. It shows that new connections are created because no cached connection was available (stream id exhausted). And after the IdleConnTimeout, the idle connections close themselves.

2023/05/10 23:48:20 ======= request: 0
2023/05/10 23:48:20 http2: Transport failed to get client conn for 127.0.0.1:46027: http2: no cached connection was available
2023/05/10 23:48:20 http2: server connection from 127.0.0.1:48362 on 0xc00014a000
&lt;...truncated...&gt;
2023/05/10 23:48:20 http2: Transport received DATA flags=END_STREAM stream=2147483645 len=19 data=&quot;404 page not found\n&quot;
2023/05/10 23:48:20 ======= request: 1
2023/05/10 23:48:20 http2: Transport failed to get client conn for 127.0.0.1:46027: http2: no cached connection was available
2023/05/10 23:48:20 http2: Transport failed to get client conn for 127.0.0.1:46027: http2: no cached connection was available
2023/05/10 23:48:20 http2: Transport creating client conn 0xc000004780 to 127.0.0.1:46027
&lt;...truncated...&gt;
2023/05/10 23:48:20 http2: Transport received DATA flags=END_STREAM stream=2147483645 len=19 data=&quot;404 page not found\n&quot;
2023/05/10 23:48:20 ======= request: 2
2023/05/10 23:48:20 http2: Transport failed to get client conn for 127.0.0.1:46027: http2: no cached connection was available
2023/05/10 23:48:20 http2: Transport failed to get client conn for 127.0.0.1:46027: http2: no cached connection was available
2023/05/10 23:48:20 http2: Transport creating client conn 0xc00009e480 to 127.0.0.1:46027
&lt;...truncated...&gt;
2023/05/10 23:48:20 http2: Transport received DATA flags=END_STREAM stream=2147483645 len=19 data=&quot;404 page not found\n&quot;
2023/05/10 23:48:20 ======= begin sleep for 100s
2023/05/10 23:49:50 http2: Transport closing idle conn 0xc00034a180 (forSingleUse=false, maxStream=2147483645)
2023/05/10 23:49:50 http2: Transport closing idle conn 0xc000004600 (forSingleUse=false, maxStream=2147483645)
&lt;...truncated...&gt;
2023/05/10 23:50:00 ======= end sleep for 100s

答案2

得分: 0

以下是翻译好的内容:

在x/net/http中的源代码中,就像@Zuke Lu的答案一样,我发现errStreamID的错误可能很少在http2客户端中触发,因为只有在获取有效连接后,流ID才会递增。

func (t *Transport) RoundTripOpt(req *http.Request, opt RoundTripOpt) (*http.Response, error) {

    // ...省略的不相关的代码...
    for retry := 0; ; retry++ {
        cc, err := t.connPool().GetClientConn(req, addr)
        
        // ...省略的不相关的代码...
        res, err := cc.RoundTrip(req)//只有在这个函数中,新的流ID才会递增。
        // ...省略的不相关的代码.
        
        return res, nil
    }
}

必须能够找到连接,所以进入函数(*ClientConn).idleStateLocked。

func (cc *ClientConn) idleStateLocked() (st clientConnIdleState) {
    // ...省略的不相关的代码...
    st.canTakeNewRequest = cc.goAway == nil && !cc.closed && !cc.closing && maxConcurrentOkay &&
        !cc.doNotReuse &&
        int64(cc.nextStreamID)+2*int64(cc.pendingRequests) < math.MaxInt32 &&
        !cc.tooIdleLocked()
    return
}

如果st.canTakeNewRequest返回false,当MaxInt32无法包含streamid时,代码将进入

func(p* clientConnPool) getStartDialLocked(ctx context.Context, addr string) * diaCall {
  call := &dialCall{p, make(chan struct{}),ctx}
  ...
  go call.dial(call.ctx, addr)
}

然后进入dial函数

func (t *Transport) dialClientConn(addr string, singleUse bool) (*ClientConn, error) {
	...
	return t.newClientConn(tconn, singleUse)
}

所以newClientConnection已经被调用,这个函数将创建一个新的TCP连接,因为将分配一个随机端口。

所以我又感到困惑了,客户端/服务器如何处理传统的TCP连接。我认为应用层根据实际情况决定如何处理传统的TCP连接。当服务器发现传统连接上没有有效载荷时,可能会关闭它。这只是一种推测。

英文:

Following source code in x/net/http, just like @Zuke Lu's answer,thank you his tracing code again.
https://stackoverflow.com/a/76209075/21847706.

I find that the error of errStreamID maybe rarely triggered from http2 client, because stream id will only be inc only after get valid connection.

func (t *Transport) RoundTripOpt(req *http.Request, opt RoundTripOpt) (*http.Response, error) {

    // ...unrelated code truncated...
    for retry := 0; ; retry++ {
        cc, err := t.connPool().GetClientConn(req, addr)
        
        // ...unrelated code truncated...
        res, err := cc.RoundTrip(req)//only in this function, new stream id will be only inc.
        // ...unrelated code truncated.
        
        return res, nil
    }
}

connection must can be found. so enter into the function (*ClientConn).idleStateLocked.

func (cc *ClientConn) idleStateLocked() (st clientConnIdleState) {
    // ...unrelated code truncated...
    st.canTakeNewRequest = cc.goAway == nil &amp;&amp; !cc.closed &amp;&amp; !cc.closing &amp;&amp; maxConcurrentOkay &amp;&amp;
        !cc.doNotReuse &amp;&amp;
        int64(cc.nextStreamID)+2*int64(cc.pendingRequests) &lt; math.MaxInt32 &amp;&amp;
        !cc.tooIdleLocked()
    return
}

if the st.canTakeNewRequest return false when MaxInt32 can not contain streamid. then code will go into

func(p* clientConnPool) getStartDialLocked(ctx context.Context, addr string) * diaCall {
  call := &amp;dialCall{p, make(chan struct{}),ctx}
  ...
  go call.dial(call.ctx, addr)
}

then goto dial function

func (t *Transport) dialClientConn(addr string, singleUse bool) (*ClientConn, error) {
...
return t.newClientConn(tconn, singleUse)
}

so the newClientConnection has been call, and this function will create a new tcp connection, because a random port will be assign.

so I feel confuse again, how to client/server handle legacy tcp connection. I think that application layer make decision how to legacy tcp connection based on actual situation. server may shutdown this when it find there is no payload on legacy connection. This is just a speculation

huangapple
  • 本文由 发表于 2023年5月8日 11:03:53
  • 转载请务必保留本文链接:https://go.coder-hub.com/76197262.html
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定