未验证 提交 96686722 编写于 作者: X xbkaishui 提交者: GitHub

add http client parameter collector (#5756)

* add http client parameter collector
* Update CHANGES.md
Co-authored-by: Nkezhenxu94 <kezhenxu94@apache.org>
Co-authored-by: wu-sheng's avatar吴晟 Wu Sheng <wu.sheng@foxmail.com>
上级 1d0d07ba
......@@ -7,6 +7,7 @@ Release Notes.
#### Project
#### Java Agent
* Make HttpClient 3.x, 4.x, and HttpAsyncClient 3.x plugins to support collecting HTTP parameters.
* Make the Feign plugin to support Java 14
* Make the okhttp3 plugin to support Java 14
......
......@@ -45,6 +45,12 @@
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.apache.skywalking</groupId>
<artifactId>apm-httpclient-commons</artifactId>
<version>${project.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>junit</groupId>
......
......@@ -20,11 +20,14 @@ package org.apache.skywalking.apm.plugin.httpClient.v4;
import java.lang.reflect.Method;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
import org.apache.http.HttpHost;
import org.apache.http.HttpRequest;
import org.apache.http.HttpResponse;
import org.apache.http.StatusLine;
import org.apache.http.client.methods.HttpUriRequest;
import org.apache.skywalking.apm.agent.core.context.CarrierItem;
import org.apache.skywalking.apm.agent.core.context.ContextCarrier;
import org.apache.skywalking.apm.agent.core.context.ContextManager;
......@@ -35,12 +38,14 @@ import org.apache.skywalking.apm.agent.core.plugin.interceptor.enhance.EnhancedI
import org.apache.skywalking.apm.agent.core.plugin.interceptor.enhance.InstanceMethodsAroundInterceptor;
import org.apache.skywalking.apm.agent.core.plugin.interceptor.enhance.MethodInterceptResult;
import org.apache.skywalking.apm.network.trace.component.ComponentsDefine;
import org.apache.skywalking.apm.plugin.httpclient.HttpClientPluginConfig;
import org.apache.skywalking.apm.util.StringUtil;
public class HttpClientExecuteInterceptor implements InstanceMethodsAroundInterceptor {
@Override
public void beforeMethod(EnhancedInstance objInst, Method method, Object[] allArguments, Class<?>[] argumentsTypes,
MethodInterceptResult result) throws Throwable {
MethodInterceptResult result) throws Throwable {
if (allArguments[0] == null || allArguments[1] == null) {
// illegal args, can't trace. ignore.
return;
......@@ -66,11 +71,14 @@ public class HttpClientExecuteInterceptor implements InstanceMethodsAroundInterc
next = next.next();
httpRequest.setHeader(next.getHeadKey(), next.getHeadValue());
}
if (HttpClientPluginConfig.Plugin.HttpClient.COLLECT_HTTP_PARAMS) {
collectHttpParam(httpRequest, span);
}
}
@Override
public Object afterMethod(EnhancedInstance objInst, Method method, Object[] allArguments, Class<?>[] argumentsTypes,
Object ret) throws Throwable {
Object ret) throws Throwable {
if (allArguments[0] == null || allArguments[1] == null) {
return ret;
}
......@@ -85,6 +93,11 @@ public class HttpClientExecuteInterceptor implements InstanceMethodsAroundInterc
span.errorOccurred();
Tags.STATUS_CODE.set(span, Integer.toString(statusCode));
}
HttpRequest httpRequest = (HttpRequest) allArguments[1];
// Active HTTP parameter collection automatically in the profiling context.
if (!HttpClientPluginConfig.Plugin.HttpClient.COLLECT_HTTP_PARAMS && span.isProfiling()) {
collectHttpParam(httpRequest, span);
}
}
}
......@@ -94,7 +107,7 @@ public class HttpClientExecuteInterceptor implements InstanceMethodsAroundInterc
@Override
public void handleMethodException(EnhancedInstance objInst, Method method, Object[] allArguments,
Class<?>[] argumentsTypes, Throwable t) {
Class<?>[] argumentsTypes, Throwable t) {
AbstractSpan activeSpan = ContextManager.activeSpan();
activeSpan.log(t);
}
......@@ -132,4 +145,17 @@ public class HttpClientExecuteInterceptor implements InstanceMethodsAroundInterc
int port = httpHost.getPort();
return port > 0 ? port : "https".equals(httpHost.getSchemeName().toLowerCase()) ? 443 : 80;
}
private void collectHttpParam(HttpRequest httpRequest, AbstractSpan span) {
if (httpRequest instanceof HttpUriRequest) {
URI uri = ((HttpUriRequest) httpRequest).getURI();
String tagValue = uri.getQuery();
if (StringUtil.isNotEmpty(tagValue)) {
tagValue = HttpClientPluginConfig.Plugin.Http.HTTP_PARAMS_LENGTH_THRESHOLD > 0 ?
StringUtil.cut(tagValue, HttpClientPluginConfig.Plugin.Http.HTTP_PARAMS_LENGTH_THRESHOLD) :
tagValue;
Tags.HTTP.PARAMS.set(span, tagValue);
}
}
}
}
......@@ -18,13 +18,14 @@
package org.apache.skywalking.apm.plugin.httpClient.v4;
import java.net.URI;
import java.util.List;
import org.apache.http.HttpHost;
import org.apache.http.HttpRequest;
import org.apache.http.HttpResponse;
import org.apache.http.ProtocolVersion;
import org.apache.http.RequestLine;
import org.apache.http.StatusLine;
import org.apache.http.client.methods.HttpGet;
import org.apache.skywalking.apm.agent.core.boot.ServiceManager;
import org.apache.skywalking.apm.agent.core.context.trace.AbstractTracingSpan;
import org.apache.skywalking.apm.agent.core.context.trace.LogDataEntity;
......@@ -37,6 +38,7 @@ import org.apache.skywalking.apm.agent.test.tools.AgentServiceRule;
import org.apache.skywalking.apm.agent.test.tools.SegmentStorage;
import org.apache.skywalking.apm.agent.test.tools.SegmentStoragePoint;
import org.apache.skywalking.apm.agent.test.tools.TracingSegmentRunner;
import org.apache.skywalking.apm.plugin.httpclient.HttpClientPluginConfig;
import org.hamcrest.CoreMatchers;
import org.junit.Assert;
import org.junit.Before;
......@@ -72,7 +74,7 @@ public class HttpClientExecuteInterceptorTest {
@Mock
private HttpHost httpHost;
@Mock
private HttpRequest request;
private HttpGet request;
@Mock
private HttpResponse httpResponse;
@Mock
......@@ -89,6 +91,7 @@ public class HttpClientExecuteInterceptorTest {
ServiceManager.INSTANCE.boot();
httpClientExecuteInterceptor = new HttpClientExecuteInterceptor();
HttpClientPluginConfig.Plugin.HttpClient.COLLECT_HTTP_PARAMS = true;
PowerMockito.mock(HttpHost.class);
when(statusLine.getStatusCode()).thenReturn(200);
......@@ -112,6 +115,7 @@ public class HttpClientExecuteInterceptorTest {
}
});
when(httpHost.getPort()).thenReturn(8080);
when(request.getURI()).thenReturn(new URI("http://127.0.0.1:8080/test-web/test?a=1&b=test"));
allArguments = new Object[] {
httpHost,
......@@ -149,8 +153,8 @@ public class HttpClientExecuteInterceptorTest {
assertThat(spans.size(), is(1));
List<TagValuePair> tags = SpanHelper.getTags(spans.get(0));
assertThat(tags.size(), is(3));
assertThat(tags.get(2).getValue(), is("500"));
assertThat(tags.size(), is(4));
assertThat(tags.get(3).getValue(), is("500"));
assertHttpSpan(spans.get(0));
assertThat(SpanHelper.getErrorOccurred(spans.get(0)), is(true));
......@@ -223,6 +227,7 @@ public class HttpClientExecuteInterceptorTest {
List<TagValuePair> tags = SpanHelper.getTags(span);
assertThat(tags.get(0).getValue(), is("http://127.0.0.1:8080/test-web/test"));
assertThat(tags.get(1).getValue(), is("GET"));
assertThat(tags.get(2).getValue(), is("a=1&b=test"));
assertThat(span.isExit(), is(true));
}
......
......@@ -42,5 +42,11 @@
<version>${apache-httpasyncclient.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.apache.skywalking</groupId>
<artifactId>apm-httpclient-commons</artifactId>
<version>${project.version}</version>
<scope>provided</scope>
</dependency>
</dependencies>
</project>
......@@ -32,8 +32,11 @@ import org.apache.skywalking.apm.agent.core.plugin.interceptor.enhance.EnhancedI
import org.apache.skywalking.apm.agent.core.plugin.interceptor.enhance.InstanceMethodsAroundInterceptor;
import org.apache.skywalking.apm.agent.core.plugin.interceptor.enhance.MethodInterceptResult;
import org.apache.skywalking.apm.network.trace.component.ComponentsDefine;
import org.apache.skywalking.apm.plugin.httpclient.HttpClientPluginConfig;
import org.apache.skywalking.apm.util.StringUtil;
import java.lang.reflect.Method;
import java.net.URI;
import java.net.URL;
import static org.apache.skywalking.apm.plugin.httpasyncclient.v4.SessionRequestCompleteInterceptor.CONTEXT_LOCAL;
......@@ -69,6 +72,9 @@ public class HttpAsyncRequestExecutorInterceptor implements InstanceMethodsAroun
next = next.next();
requestWrapper.setHeader(next.getHeadKey(), next.getHeadValue());
}
if (HttpClientPluginConfig.Plugin.HttpClient.COLLECT_HTTP_PARAMS) {
collectHttpParam(requestWrapper.getURI(), span);
}
}
@Override
......@@ -82,4 +88,17 @@ public class HttpAsyncRequestExecutorInterceptor implements InstanceMethodsAroun
Class<?>[] argumentsTypes, Throwable t) {
}
private void collectHttpParam(URI uri, AbstractSpan span) {
if (uri == null) {
return;
}
String tagValue = uri.getQuery();
if (StringUtil.isNotEmpty(tagValue)) {
tagValue = HttpClientPluginConfig.Plugin.Http.HTTP_PARAMS_LENGTH_THRESHOLD > 0 ?
StringUtil.cut(tagValue, HttpClientPluginConfig.Plugin.Http.HTTP_PARAMS_LENGTH_THRESHOLD) :
tagValue;
Tags.HTTP.PARAMS.set(span, tagValue);
}
}
}
......@@ -17,6 +17,7 @@
package org.apache.skywalking.apm.plugin.httpasyncclient.v4;
import java.net.URI;
import java.util.List;
import org.apache.http.HttpHost;
import org.apache.http.HttpResponse;
......@@ -47,6 +48,7 @@ import org.apache.skywalking.apm.agent.test.tools.SegmentStoragePoint;
import org.apache.skywalking.apm.agent.test.tools.TracingSegmentRunner;
import org.apache.skywalking.apm.plugin.httpasyncclient.v4.wrapper.FutureCallbackWrapper;
import org.apache.skywalking.apm.plugin.httpasyncclient.v4.wrapper.HttpAsyncResponseConsumerWrapper;
import org.apache.skywalking.apm.plugin.httpclient.HttpClientPluginConfig;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Rule;
......@@ -119,6 +121,7 @@ public class HttpAsyncClientInterceptorTest {
httpContext.setAttribute(HttpClientContext.HTTP_REQUEST, requestWrapper);
httpContext.setAttribute(HttpClientContext.HTTP_TARGET_HOST, httpHost);
CONTEXT_LOCAL.set(httpContext);
HttpClientPluginConfig.Plugin.HttpClient.COLLECT_HTTP_PARAMS = true;
when(httpHost.getHostName()).thenReturn("127.0.0.1");
when(httpHost.getSchemeName()).thenReturn("http");
......@@ -158,6 +161,7 @@ public class HttpAsyncClientInterceptorTest {
when(requestWrapper.getRequestLine()).thenReturn(requestLine);
when(requestWrapper.getOriginal()).thenReturn(new HttpGet("http://localhost:8081/original/test"));
when(requestWrapper.getURI()).thenReturn(new URI("http://localhost:8081/original/test?a=1&b=test"));
when(httpHost.getPort()).thenReturn(8080);
enhancedInstance = new EnhancedInstance() {
......@@ -266,6 +270,7 @@ public class HttpAsyncClientInterceptorTest {
List<TagValuePair> tags = SpanHelper.getTags(span);
assertThat(tags.get(0).getValue(), is("http://localhost:8081/original/test"));
assertThat(tags.get(1).getValue(), is("GET"));
assertThat(tags.get(2).getValue(), is("a=1&b=test"));
assertThat(span.isExit(), is(true));
}
......
......@@ -44,5 +44,11 @@
<version>${commons-httpclient.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.apache.skywalking</groupId>
<artifactId>apm-httpclient-commons</artifactId>
<version>${project.version}</version>
<scope>provided</scope>
</dependency>
</dependencies>
</project>
......@@ -20,8 +20,6 @@ package org.apache.skywalking.apm.plugin.httpclient.v3;
import java.lang.reflect.Method;
import org.apache.commons.httpclient.HostConfiguration;
import org.apache.commons.httpclient.HttpClient;
import org.apache.commons.httpclient.HttpMethod;
import org.apache.commons.httpclient.URI;
import org.apache.commons.httpclient.URIException;
......@@ -35,21 +33,19 @@ import org.apache.skywalking.apm.agent.core.plugin.interceptor.enhance.EnhancedI
import org.apache.skywalking.apm.agent.core.plugin.interceptor.enhance.InstanceMethodsAroundInterceptor;
import org.apache.skywalking.apm.agent.core.plugin.interceptor.enhance.MethodInterceptResult;
import org.apache.skywalking.apm.network.trace.component.ComponentsDefine;
import org.apache.skywalking.apm.plugin.httpclient.HttpClientPluginConfig;
import org.apache.skywalking.apm.util.StringUtil;
public class HttpClientExecuteInterceptor implements InstanceMethodsAroundInterceptor {
@Override
public void beforeMethod(final EnhancedInstance objInst, final Method method, final Object[] allArguments,
final Class<?>[] argumentsTypes, final MethodInterceptResult result) throws Throwable {
final HttpClient client = (HttpClient) objInst;
HostConfiguration hostConfiguration = (HostConfiguration) allArguments[0];
if (hostConfiguration == null) {
hostConfiguration = client.getHostConfiguration();
}
final Class<?>[] argumentsTypes, final MethodInterceptResult result) throws Throwable {
final HttpMethod httpMethod = (HttpMethod) allArguments[1];
if (httpMethod == null) {
return;
}
final String remotePeer = httpMethod.getURI().getHost() + ":" + httpMethod.getURI().getPort();
final URI uri = httpMethod.getURI();
......@@ -67,12 +63,15 @@ public class HttpClientExecuteInterceptor implements InstanceMethodsAroundInterc
next = next.next();
httpMethod.setRequestHeader(next.getHeadKey(), next.getHeadValue());
}
if (HttpClientPluginConfig.Plugin.HttpClient.COLLECT_HTTP_PARAMS) {
collectHttpParam(httpMethod, span);
}
}
@Override
public Object afterMethod(final EnhancedInstance objInst, final Method method, final Object[] allArguments,
final Class<?>[] argumentsTypes, final Object ret) {
final Class<?>[] argumentsTypes, final Object ret) {
if (ret != null) {
final int statusCode = (Integer) ret;
final AbstractSpan span = ContextManager.activeSpan();
......@@ -80,15 +79,22 @@ public class HttpClientExecuteInterceptor implements InstanceMethodsAroundInterc
span.errorOccurred();
Tags.STATUS_CODE.set(span, Integer.toString(statusCode));
}
final HttpMethod httpMethod = (HttpMethod) allArguments[1];
if (httpMethod == null) {
return ret;
}
// Active HTTP parameter collection automatically in the profiling context.
if (!HttpClientPluginConfig.Plugin.HttpClient.COLLECT_HTTP_PARAMS && span.isProfiling()) {
collectHttpParam(httpMethod, span);
}
}
ContextManager.stopSpan();
return ret;
}
@Override
public void handleMethodException(final EnhancedInstance objInst, final Method method, final Object[] allArguments,
final Class<?>[] argumentsTypes, final Throwable t) {
final Class<?>[] argumentsTypes, final Throwable t) {
ContextManager.activeSpan().log(t);
}
......@@ -97,4 +103,13 @@ public class HttpClientExecuteInterceptor implements InstanceMethodsAroundInterc
return requestPath != null && requestPath.length() > 0 ? requestPath : "/";
}
private void collectHttpParam(HttpMethod httpMethod, AbstractSpan span) {
String tagValue = httpMethod.getQueryString();
if (StringUtil.isNotEmpty(tagValue)) {
tagValue = HttpClientPluginConfig.Plugin.Http.HTTP_PARAMS_LENGTH_THRESHOLD > 0 ?
StringUtil.cut(tagValue, HttpClientPluginConfig.Plugin.Http.HTTP_PARAMS_LENGTH_THRESHOLD) :
tagValue;
Tags.HTTP.PARAMS.set(span, tagValue);
}
}
}
<?xml version="1.0"?>
<!--
~ Licensed to the Apache Software Foundation (ASF) under one or more
~ contributor license agreements. See the NOTICE file distributed with
~ this work for additional information regarding copyright ownership.
~ The ASF licenses this file to You under the Apache License, Version 2.0
~ (the "License"); you may not use this file except in compliance with
~ the License. You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
~
-->
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<artifactId>apm-sdk-plugin</artifactId>
<groupId>org.apache.skywalking</groupId>
<version>8.3.0-SNAPSHOT</version>
</parent>
<artifactId>apm-httpclient-commons</artifactId>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<build>
<plugins>
<plugin>
<artifactId>maven-deploy-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package org.apache.skywalking.apm.plugin.httpclient;
import org.apache.skywalking.apm.agent.core.boot.PluginConfig;
public class HttpClientPluginConfig {
public static class Plugin {
@PluginConfig(root = HttpClientPluginConfig.class)
public static class HttpClient {
/**
* This config item controls that whether the HttpClient plugin should collect the parameters of the request.
*/
public static boolean COLLECT_HTTP_PARAMS = false;
}
@PluginConfig(root = HttpClientPluginConfig.class)
public static class Http {
/**
* When either {@link HttpClient#COLLECT_HTTP_PARAMS} is enabled, how many characters to keep and send to the
* OAP backend, use negative values to keep and send the complete parameters, NB. this config item is added
* for the sake of performance
*/
public static int HTTP_PARAMS_LENGTH_THRESHOLD = 1024;
}
}
}
......@@ -101,6 +101,7 @@
<module>graphql-plugin</module>
<module>xxl-job-2.x-plugin</module>
<module>thrift-plugin</module>
<module>httpclient-commons</module>
</modules>
<packaging>pom</packaging>
......
......@@ -133,6 +133,7 @@ property key | Description | Default |
`plugin.jdkthreading.threading_class_prefixes` | Threading classes (`java.lang.Runnable` and `java.util.concurrent.Callable`) and their subclasses, including anonymous inner classes whose name match any one of the `THREADING_CLASS_PREFIXES` (splitted by `,`) will be instrumented, make sure to only specify as narrow prefixes as what you're expecting to instrument, (`java.` and `javax.` will be ignored due to safety issues) | Not set |
`plugin.tomcat.collect_http_params`| This config item controls that whether the Tomcat plugin should collect the parameters of the request. Also, activate implicitly in the profiled trace. | `false` |
`plugin.springmvc.collect_http_params`| This config item controls that whether the SpringMVC plugin should collect the parameters of the request, when your Spring application is based on Tomcat, consider only setting either `plugin.tomcat.collect_http_params` or `plugin.springmvc.collect_http_params`. Also, activate implicitly in the profiled trace. | `false` |
`plugin.httpclient.collect_http_params`| This config item controls that whether the HttpClient plugin should collect the parameters of the request | `false` |
`plugin.http.http_params_length_threshold`| When `COLLECT_HTTP_PARAMS` is enabled, how many characters to keep and send to the OAP backend, use negative values to keep and send the complete parameters, NB. this config item is added for the sake of performance. | `1024` |
`plugin.http.http_headers_length_threshold`| When `include_http_headers` declares header names, this threshold controls the length limitation of all header values. use negative values to keep and send the complete headers. Note. this config item is added for the sake of performance. | `2048` |
`plugin.http.include_http_headers`| Set the header names, which should be collected by the plugin. Header name must follow `javax.servlet.http` definition. Multiple names should be split by comma. | ``(No header would be collected) |
......
......@@ -33,6 +33,10 @@ segmentItems:
tags:
- {key: url, value: 'http://localhost:8080/httpclient-3.x-scenario/case/context-propagate'}
- {key: http.method, value: GET}
- key: http.params
value: |-
q1=[v1]
chinese=[中文]
refs:
- {parentEndpoint: /httpclient-3.x-scenario/case/httpclient, networkAddress: 'localhost:8080',
refType: CrossProcess, parentSpanId: 1, parentTraceSegmentId: not null, parentServiceInstance: not
......@@ -52,8 +56,9 @@ segmentItems:
spanType: Exit
peer: localhost:8080
tags:
- {key: url, value: 'http://localhost:8080/httpclient-3.x-scenario/case/context-propagate'}
- {key: url, value: 'http://localhost:8080/httpclient-3.x-scenario/case/context-propagate?q1=v1&chinese=%e4%b8%ad%e6%96%87'}
- {key: http.method, value: GET}
- {key: http.params, value: q1=v1&chinese=%e4%b8%ad%e6%96%87}
skipAnalysis: 'false'
- operationName: /httpclient-3.x-scenario/case/httpclient
operationId: 0
......
......@@ -18,4 +18,4 @@ type: tomcat
entryService: '"http://localhost:8080/httpclient-3.x-scenario/case/httpclient?q1=v1&chinese=%e4%b8%ad%e6%96%87"'
healthCheck: http://localhost:8080/httpclient-3.x-scenario/healthCheck
environment:
- CATALINA_OPTS="-Dskywalking.plugin.tomcat.collect_http_params=true"
- CATALINA_OPTS="-Dskywalking.plugin.tomcat.collect_http_params=true -Dskywalking.plugin.httpclient.collect_http_params=true"
......@@ -35,7 +35,7 @@ public class CaseServlet extends HttpServlet {
MultiThreadedHttpConnectionManager httpConnectionManager = new MultiThreadedHttpConnectionManager();
HttpClient client = new HttpClient(httpConnectionManager);
HttpMethod httpGet = new GetMethod("http://localhost:8080" + req.getContextPath() + "/case/context-propagate");
HttpMethod httpGet = new GetMethod("http://localhost:8080" + req.getContextPath() + "/case/context-propagate?q1=v1&chinese=%e4%b8%ad%e6%96%87");
int statusCode = client.executeMethod(httpGet);
try (PrintWriter printWriter = resp.getWriter()) {
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册