提交 a86266b8 编写于 作者: Z zhangxin

Modify the Dubbo plugin using the new API

上级 4581fde0
package com.a.eye.skywalking.api.plugin.dubbo;
import com.a.eye.skywalking.api.IBuriedPointType;
public enum DubboBuriedPointType implements IBuriedPointType {
INSTANCE;
@Override
public String getTypeName() {
return "D";
}
@Override
public CallType getCallType() {
return CallType.SYNC;
}
}
package com.a.eye.skywalking.api.plugin.dubbox.bugfix.below283;
public final class BugFixAcitve {
public static boolean isActive = false;
public BugFixAcitve(){
isActive = true;
}
}
package com.a.eye.skywalking.api.plugin.dubbo;
package com.a.eye.skywalking.plugin.dubbo;
import com.a.eye.skywalking.api.plugin.interceptor.ConstructorInterceptPoint;
import com.a.eye.skywalking.api.plugin.interceptor.InstanceMethodsInterceptPoint;
import com.a.eye.skywalking.plugin.interceptor.ConstructorInterceptPoint;
import com.a.eye.skywalking.plugin.interceptor.enhance.ClassInstanceMethodsEnhancePluginDefine;
import com.a.eye.skywalking.api.plugin.interceptor.enhance.ClassInstanceMethodsEnhancePluginDefine;
import com.alibaba.dubbo.rpc.Invocation;
import com.alibaba.dubbo.rpc.Invoker;
import net.bytebuddy.description.method.MethodDescription;
import net.bytebuddy.matcher.ElementMatcher;
import static net.bytebuddy.matcher.ElementMatchers.named;
public class DubboPluginDefine extends ClassInstanceMethodsEnhancePluginDefine {
/**
* {@link DubboInstrumentation} present that skywalking use class {@link DubboInterceptor} to
* enhance class {@link com.alibaba.dubbo.monitor.support.MonitorFilter#invoke(Invoker, Invocation)}
* for support trace of the dubbo framework.
*
* @author zhangxin
*/
public class DubboInstrumentation extends ClassInstanceMethodsEnhancePluginDefine {
/**
* Enhance class
*/
private static final String ENHANCE_CLASS = "com.alibaba.dubbo.monitor.support.MonitorFilter";
/**
* Intercept class
*/
private static final String INTERCEPT_CLASS = "com.a.eye.skywalking.plugin.dubbo.DubboInterceptor";
@Override
protected String enhanceClassName() {
return "com.alibaba.dubbo.monitor.support.MonitorFilter";
return ENHANCE_CLASS;
}
@Override
......@@ -29,7 +49,7 @@ public class DubboPluginDefine extends ClassInstanceMethodsEnhancePluginDefine {
@Override
public String getMethodsInterceptor() {
return "com.a.eye.skywalking.plugin.dubbo.MonitorFilterInterceptor";
return INTERCEPT_CLASS;
}
}};
}
......
package com.a.eye.skywalking.api.plugin.dubbo;
import com.a.eye.skywalking.api.plugin.dubbox.bugfix.below283.BugFixAcitve;
import com.a.eye.skywalking.api.plugin.dubbox.bugfix.below283.SWBaseBean;
import com.a.eye.skywalking.invoke.monitor.RPCClientInvokeMonitor;
import com.a.eye.skywalking.invoke.monitor.RPCServerInvokeMonitor;
import com.a.eye.skywalking.model.ContextData;
import com.a.eye.skywalking.model.Identification;
package com.a.eye.skywalking.plugin.dubbo;
import com.a.eye.skywalking.api.context.ContextCarrier;
import com.a.eye.skywalking.api.context.ContextManager;
import com.a.eye.skywalking.plugin.dubbox.BugFixActive;
import com.a.eye.skywalking.plugin.dubbox.SWBaseBean;
import com.a.eye.skywalking.api.plugin.interceptor.EnhancedClassInstanceContext;
import com.a.eye.skywalking.plugin.interceptor.enhance.InstanceMethodInvokeContext;
import com.a.eye.skywalking.api.plugin.interceptor.enhance.InstanceMethodInvokeContext;
import com.a.eye.skywalking.api.plugin.interceptor.enhance.InstanceMethodsAroundInterceptor;
import com.a.eye.skywalking.api.plugin.interceptor.enhance.MethodInterceptResult;
import com.a.eye.skywalking.trace.Span;
import com.a.eye.skywalking.trace.tag.Tags;
import com.alibaba.dubbo.rpc.Invocation;
import com.alibaba.dubbo.rpc.Invoker;
import com.alibaba.dubbo.rpc.Result;
import com.alibaba.dubbo.rpc.RpcContext;
public class MonitorFilterInterceptor implements InstanceMethodsAroundInterceptor {
/**
* {@link DubboInterceptor} define how to enhance class{@link com.alibaba.dubbo.monitor.support.MonitorFilter#invoke(Invoker, Invocation)}.
* the context data will transport to the provider side by {@link RpcContext#attachments}.but all the version of dubbo framework below 2.8.3
* don't support {@link RpcContext#attachments}, we support another way to support it. it is that all request parameters of dubbo service
* need to extend {@link SWBaseBean}, and {@link DubboInterceptor} will inject the serialized context data to the {@link SWBaseBean} bean and
* extract the serialized context data from {@link SWBaseBean}, or the context data will not transport to the provider side.
*
* @author zhangxin
*/
public class DubboInterceptor implements InstanceMethodsAroundInterceptor {
public static final String ATTACHMENT_NAME_OF_CONTEXT_DATA = "contextData";
public static final String DUBBO_COMPONENT = "Dubbo";
/**
* <h2>Consumer:</h2>
* The serialized context data will inject the first param that extend {@link SWBaseBean} of dubbo service
* if the method {@link BugFixActive#active()} be called. or the serialized context data will inject to the
* {@link RpcContext#attachments} for transport to provider side.
*
* <h2>Provider:</h2>
* The serialized context data will extract from the first param that extend {@link SWBaseBean} of dubbo service
* if the method {@link BugFixActive#active()} be called. or it will extract from {@link RpcContext#attachments}.
* current trace segment will ref if the serialize context data is not null.
*
* @param context instance context, a class instance only has one {@link EnhancedClassInstanceContext} instance.
* @param interceptorContext method context, includes class name, method name, etc.
* @param result change this result, if you want to truncate the method.
*/
@Override
public void beforeMethod(EnhancedClassInstanceContext context, InstanceMethodInvokeContext interceptorContext,
MethodInterceptResult result) {
Object[] arguments = interceptorContext.allArguments();
Invoker invoker = (Invoker) arguments[0];
Invocation invocation = (Invocation) arguments[1];
RpcContext rpcContext = RpcContext.getContext();
boolean isConsumer = rpcContext.isConsumerSide();
context.set("isConsumer", isConsumer);
if (isConsumer) {
ContextData contextData =
new RPCClientInvokeMonitor().beforeInvoke(createIdentification(invoker, invocation, true));
String contextDataStr = contextData.toString();
//追加参数
if (!BugFixAcitve.isActive) {
Span span = ContextManager.INSTANCE.createSpan(generateOperationName(invoker, invocation));
if (isConsumer) {
Tags.SPAN_KIND.set(span, Tags.SPAN_KIND_CLIENT);
ContextCarrier contextCarrier = new ContextCarrier();
ContextManager.INSTANCE.inject(contextCarrier);
if (!BugFixActive.isActive()) {
// context.setAttachment("contextData", contextDataStr);
// context的setAttachment方法在重试机制的时候并不会覆盖原有的Attachment
// 参见Dubbo源代码:“com.alibaba.dubbo.rpc.RpcInvocation”
......@@ -47,94 +73,110 @@ public class MonitorFilterInterceptor implements InstanceMethodsAroundIntercepto
// 在Rest模式中attachment会被抹除,不会传入到服务端
// Rest模式会将attachment存放到header里面,具体见com.alibaba.dubbo.rpc.protocol.rest.RpcContextFilter
//invocation.getAttachments().put("contextData", contextDataStr);
rpcContext.getAttachments().put("contextData", contextDataStr);
rpcContext.getAttachments().put(ATTACHMENT_NAME_OF_CONTEXT_DATA, contextCarrier.serialize());
} else {
fix283SendNoAttachmentIssue(invocation, contextDataStr);
fix283SendNoAttachmentIssue(invocation, contextCarrier.serialize());
}
} else {
// 读取参数
String contextDataStr;
Tags.SPAN_KIND.set(span, Tags.SPAN_KIND_SERVER);
if (!BugFixAcitve.isActive) {
contextDataStr = rpcContext.getAttachment("contextData");
String contextDataStr;
if (!BugFixActive.isActive()) {
contextDataStr = rpcContext.getAttachment(ATTACHMENT_NAME_OF_CONTEXT_DATA);
} else {
contextDataStr = fix283RecvNoAttachmentIssue(invocation);
}
ContextData contextData = null;
if (contextDataStr != null && contextDataStr.length() > 0) {
contextData = new ContextData(contextDataStr);
ContextManager.INSTANCE.extract(new ContextCarrier().deserialize(contextDataStr));
}
new RPCServerInvokeMonitor().beforeInvoke(contextData, createIdentification(invoker, invocation, false));
}
Tags.URL.set(span, generateRequestURL(invoker, invocation));
Tags.COMPONENT.set(span, DUBBO_COMPONENT);
Tags.SPAN_LAYER.asRPCFramework(span);
}
/**
* {@link DubboInterceptor#afterMethod(EnhancedClassInstanceContext, InstanceMethodInvokeContext, Object)} be executed after
* {@link com.alibaba.dubbo.monitor.support.MonitorFilter#invoke(Invoker, Invocation)}, and it will check {@link Result#getException()} if is null.
* current active span will log the exception and set true to the value of error tag if the {@link Result#getException()} is not null.
*
* @param context instance context, a class instance only has one {@link EnhancedClassInstanceContext} instance.
* @param interceptorContext method context, includes class name, method name, etc.
* @param ret the method's original return value.
*/
@Override
public Object afterMethod(EnhancedClassInstanceContext context, InstanceMethodInvokeContext interceptorContext,
Object ret) {
Result result = (Result) ret;
if (result != null && result.getException() != null) {
dealException(result.getException(), context);
}
if (isConsumer(context)) {
new RPCClientInvokeMonitor().afterInvoke();
} else {
new RPCServerInvokeMonitor().afterInvoke();
dealException(result.getException());
}
ContextManager.INSTANCE.stopSpan();
return ret;
}
@Override
public void handleMethodException(Throwable t, EnhancedClassInstanceContext context,
InstanceMethodInvokeContext interceptorContext) {
dealException(t, context);
dealException(t);
}
private boolean isConsumer(EnhancedClassInstanceContext context) {
return (Boolean) context.get("isConsumer");
/**
* Active span will log the exception and set current span value of error tag.
*/
private void dealException(Throwable throwable) {
Span span = ContextManager.INSTANCE.activeSpan();
Tags.ERROR.set(span, true);
span.log(throwable);
}
private void dealException(Throwable t, EnhancedClassInstanceContext context) {
if (isConsumer(context)) {
new RPCClientInvokeMonitor().occurException(t);
} else {
new RPCServerInvokeMonitor().occurException(t);
}
}
private static Identification createIdentification(Invoker<?> invoker, Invocation invocation, boolean isConsumer) {
StringBuilder viewPoint = new StringBuilder();
if (isConsumer) {
viewPoint.append("comsumer:");
} else {
viewPoint.append("provider:");
}
viewPoint.append(invoker.getUrl().getProtocol() + "://");
viewPoint.append(invoker.getUrl().getHost());
viewPoint.append(":" + invoker.getUrl().getPort());
viewPoint.append(invoker.getUrl().getAbsolutePath());
viewPoint.append("." + invocation.getMethodName() + "(");
/**
* Generate operation name.
* the operation name should be like this <code>com.a.eye.skywalking.plugin.test.Test.test(String)</code>
*
* @return operation name
*/
private static String generateOperationName(Invoker<?> invoker, Invocation invocation) {
StringBuilder operationName = new StringBuilder();
operationName.append(invoker.getUrl().getPath());
operationName.append("." + invocation.getMethodName() + "(");
for (Class<?> classes : invocation.getParameterTypes()) {
viewPoint.append(classes.getSimpleName() + ",");
operationName.append(classes.getSimpleName() + ",");
}
if (invocation.getParameterTypes().length > 0) {
viewPoint.delete(viewPoint.length() - 1, viewPoint.length());
operationName.delete(operationName.length() - 1, operationName.length());
}
viewPoint.append(")");
return Identification.newBuilder().viewPoint(viewPoint.toString()).spanType(DubboBuriedPointType.INSTANCE)
.build();
operationName.append(")");
return operationName.toString();
}
/**
* Generate request url.
* The request url may be like this <code>dubbo://127.0.0.1:20880/com.a.eye.skywalking.plugin.test.Test.test(String)</code>
*
* @return request url
*/
private static String generateRequestURL(Invoker<?> invoker, Invocation invocation) {
StringBuilder requestURL = new StringBuilder();
requestURL.append(invoker.getUrl().getProtocol() + "://");
requestURL.append(invoker.getUrl().getHost());
requestURL.append(":" + invoker.getUrl().getPort() + "/");
requestURL.append(generateOperationName(invoker, invocation));
return requestURL.toString();
}
/**
* Set the serialized context data to the first request param that extend {@link SWBaseBean} of dubbo service
*
* @param contextDataStr serialized context data
*/
private static void fix283SendNoAttachmentIssue(Invocation invocation, String contextDataStr) {
for (Object parameter : invocation.getArguments()) {
if (parameter instanceof SWBaseBean) {
((SWBaseBean) parameter).setContextData(contextDataStr);
......@@ -143,6 +185,11 @@ public class MonitorFilterInterceptor implements InstanceMethodsAroundIntercepto
}
}
/**
* Fetch the serialize context data from the first request param that extend {@link SWBaseBean} of dubbo service.
*
* @return serialized context data
*/
private static String fix283RecvNoAttachmentIssue(Invocation invocation) {
for (Object parameter : invocation.getArguments()) {
if (parameter instanceof SWBaseBean) {
......
package com.a.eye.skywalking.plugin.dubbox;
/**
* {@link BugFixActive#active} is an flag that present the dubbox version is below 2.8.3,
* The version 2.8.3 of dubbox don't support attachment. so skywalking provided another way
* to support the function that transport the serialized context data. the way that
*
* @author zhangxin
*/
public final class BugFixActive {
private static boolean active = false;
/**
* This method should be call first if the dubbo version is below 2.8.3.
*/
public static void active() {
BugFixActive.active = true;
}
public static boolean isActive(){
return BugFixActive.active;
}
}
package com.a.eye.skywalking.api.plugin.dubbox.bugfix.below283;
package com.a.eye.skywalking.plugin.dubbox;
import java.io.Serializable;
/**
* All the request parameter of dubbox service need to extend {@link SWBaseBean} to transport
* the serialized context data to the provider side if the version of dubbox is below 2.8.3.
*
* @author zhangxin
*/
public class SWBaseBean implements Serializable {
/**
* Serialized context data
*/
private String contextData;
public String getContextData() {
......
package com.a.eye.skywalking.plugin.dubbo;
import com.a.eye.skywalking.api.context.TracerContext;
import com.a.eye.skywalking.api.plugin.interceptor.EnhancedClassInstanceContext;
import com.a.eye.skywalking.api.plugin.interceptor.enhance.InstanceMethodInvokeContext;
import com.a.eye.skywalking.api.plugin.interceptor.enhance.MethodInterceptResult;
import com.a.eye.skywalking.plugin.dubbox.BugFixActive;
import com.a.eye.skywalking.sniffer.mock.context.MockTracerContextListener;
import com.a.eye.skywalking.sniffer.mock.context.SegmentAssert;
import com.a.eye.skywalking.trace.LogData;
import com.a.eye.skywalking.trace.Span;
import com.a.eye.skywalking.trace.TraceSegment;
import com.a.eye.skywalking.trace.TraceSegmentRef;
import com.a.eye.skywalking.trace.tag.Tags;
import com.alibaba.dubbo.common.URL;
import com.alibaba.dubbo.rpc.Invocation;
import com.alibaba.dubbo.rpc.Invoker;
import com.alibaba.dubbo.rpc.Result;
import com.alibaba.dubbo.rpc.RpcContext;
import org.hamcrest.CoreMatchers;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.powermock.core.classloader.annotations.PrepareForTest;
import org.powermock.modules.junit4.PowerMockRunner;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.Assert.assertNull;
import static org.powermock.api.mockito.PowerMockito.mockStatic;
import static org.powermock.api.mockito.PowerMockito.when;
@RunWith(PowerMockRunner.class)
@PrepareForTest({RpcContext.class, BugFixActive.class})
public class DubboInterceptorTest {
private MockTracerContextListener mockTracerContextListener;
private DubboInterceptor dubboInterceptor;
private RequestParamForTestBelow283 testParam;
@Mock
private RpcContext rpcContext;
@Mock
private Invoker invoker;
@Mock
private Invocation invocation;
@Mock
private EnhancedClassInstanceContext classInstanceContext;
@Mock
private InstanceMethodInvokeContext methodInvokeContext;
@Mock
private MethodInterceptResult methodInterceptResult;
@Mock
private Result result;
@Before
public void setUp() throws Exception {
dubboInterceptor = new DubboInterceptor();
testParam = new RequestParamForTestBelow283();
mockTracerContextListener = new MockTracerContextListener();
TracerContext.ListenerManager.add(mockTracerContextListener);
mockStatic(RpcContext.class);
mockStatic(BugFixActive.class);
when(invoker.getUrl()).thenReturn(URL.valueOf("dubbo://127.0.0.1:20880/com.a.eye.skywalking.test.TestDubboService"));
when(invocation.getMethodName()).thenReturn("test");
when(invocation.getParameterTypes()).thenReturn(new Class[]{String.class});
when(invocation.getArguments()).thenReturn(new Object[]{testParam});
Mockito.when(RpcContext.getContext()).thenReturn(rpcContext);
when(rpcContext.isConsumerSide()).thenReturn(true);
when(methodInvokeContext.allArguments()).thenReturn(new Object[]{invoker, invocation});
}
@Test
public void testConsumerBelow283() {
when(BugFixActive.isActive()).thenReturn(true);
dubboInterceptor.beforeMethod(classInstanceContext, methodInvokeContext, methodInterceptResult);
dubboInterceptor.afterMethod(classInstanceContext, methodInvokeContext, result);
mockTracerContextListener.assertSize(1);
mockTracerContextListener.assertTraceSegment(0, new SegmentAssert() {
@Override
public void call(TraceSegment traceSegment) {
assertThat(traceSegment.getSpans().size(), is(1));
assertConsumerSpan(traceSegment.getSpans().get(0));
testParam.assertSelf("0");
}
});
}
@Test
public void testConsumerWithAttachment() {
dubboInterceptor.beforeMethod(classInstanceContext, methodInvokeContext, methodInterceptResult);
dubboInterceptor.afterMethod(classInstanceContext, methodInvokeContext, result);
mockTracerContextListener.assertSize(1);
mockTracerContextListener.assertTraceSegment(0, new SegmentAssert() {
@Override
public void call(TraceSegment traceSegment) {
assertThat(traceSegment.getSpans().size(), is(1));
assertConsumerSpan(traceSegment.getSpans().get(0));
}
});
}
@Test
public void testConsumerWithException() {
dubboInterceptor.beforeMethod(classInstanceContext, methodInvokeContext, methodInterceptResult);
dubboInterceptor.handleMethodException(new RuntimeException(), classInstanceContext, methodInvokeContext);
dubboInterceptor.afterMethod(classInstanceContext, methodInvokeContext, result);
mockTracerContextListener.assertSize(1);
mockTracerContextListener.assertTraceSegment(0, new SegmentAssert() {
@Override
public void call(TraceSegment traceSegment) {
assertConsumerTraceSegmentInErrorCase(traceSegment);
}
});
}
@Test
public void testConsumerWithResultHasException() {
when(result.getException()).thenReturn(new RuntimeException());
dubboInterceptor.beforeMethod(classInstanceContext, methodInvokeContext, methodInterceptResult);
dubboInterceptor.afterMethod(classInstanceContext, methodInvokeContext, result);
mockTracerContextListener.assertSize(1);
mockTracerContextListener.assertTraceSegment(0, new SegmentAssert() {
@Override
public void call(TraceSegment traceSegment) {
assertConsumerTraceSegmentInErrorCase(traceSegment);
}
});
}
@Test
public void testProviderWithAttachment() {
when(rpcContext.isConsumerSide()).thenReturn(false);
when(rpcContext.getAttachment(DubboInterceptor.ATTACHMENT_NAME_OF_CONTEXT_DATA)).thenReturn("302017.1487666919810.624424584.17332.1.1|1");
dubboInterceptor.beforeMethod(classInstanceContext, methodInvokeContext, methodInterceptResult);
dubboInterceptor.afterMethod(classInstanceContext, methodInvokeContext, result);
assertProvider();
}
@Test
public void testProviderBelow283() {
when(rpcContext.isConsumerSide()).thenReturn(false);
when(BugFixActive.isActive()).thenReturn(true);
testParam.setContextData("302017.1487666919810.624424584.17332.1.1|1");
dubboInterceptor.beforeMethod(classInstanceContext, methodInvokeContext, methodInterceptResult);
dubboInterceptor.afterMethod(classInstanceContext, methodInvokeContext, result);
assertProvider();
}
private void assertConsumerTraceSegmentInErrorCase(TraceSegment traceSegment) {
assertThat(traceSegment.getSpans().size(), is(1));
assertConsumerSpan(traceSegment.getSpans().get(0));
Span span = traceSegment.getSpans().get(0);
assertThat(span.getLogs().size(), is(1));
assertErrorLog(span.getLogs().get(0));
}
private void assertErrorLog(LogData logData) {
assertThat(logData.getFields().size(), is(3));
assertThat(logData.getFields().get("error.kind"), CoreMatchers.<Object>is(RuntimeException.class.getName()));
assertNull(logData.getFields().get("message"));
}
private void assertProvider() {
final TraceSegmentRef expect = new TraceSegmentRef();
expect.setSpanId(1);
expect.setTraceSegmentId("302017.1487666919810.624424584.17332.1.1");
mockTracerContextListener.assertSize(1);
mockTracerContextListener.assertTraceSegment(0, new SegmentAssert() {
@Override
public void call(TraceSegment traceSegment) {
assertThat(traceSegment.getSpans().size(), is(1));
assertProviderSpan(traceSegment.getSpans().get(0));
assertTraceSegmentRef(traceSegment.getPrimaryRef(), expect);
}
});
}
private void assertTraceSegmentRef(TraceSegmentRef actual, TraceSegmentRef expect) {
assertThat(actual.getSpanId(), is(expect.getSpanId()));
assertThat(actual.getTraceSegmentId(), is(expect.getTraceSegmentId()));
}
private void assertProviderSpan(Span span) {
assertCommonsAttribute(span);
assertThat(Tags.SPAN_KIND.get(span), is(Tags.SPAN_KIND_SERVER));
}
private void assertConsumerSpan(Span span) {
assertCommonsAttribute(span);
assertThat(Tags.SPAN_KIND.get(span), is(Tags.SPAN_KIND_CLIENT));
}
private void assertCommonsAttribute(Span span) {
assertThat(Tags.SPAN_LAYER.isRPCFramework(span), is(true));
assertThat(Tags.COMPONENT.get(span), is(DubboInterceptor.DUBBO_COMPONENT));
assertThat(Tags.URL.get(span), is("dubbo://127.0.0.1:20880/com.a.eye.skywalking.test.TestDubboService.test(String)"));
assertThat(span.getOperationName(), is("com.a.eye.skywalking.test.TestDubboService.test(String)"));
}
@After
public void tearDown() throws Exception {
TracerContext.ListenerManager.remove(mockTracerContextListener);
}
}
\ No newline at end of file
package com.a.eye.skywalking.plugin.dubbo;
import com.a.eye.skywalking.plugin.dubbox.SWBaseBean;
import static org.hamcrest.CoreMatchers.endsWith;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertThat;
/**
* {@link RequestParamForTestBelow283} store context data for test.
*/
public class RequestParamForTestBelow283 extends SWBaseBean {
/**
* This method assert that {@link SWBaseBean#getContextData()} if it's not null and context data
* will end with the expect span id.
*
* @param expectSpanId expect span id
*/
public void assertSelf(String expectSpanId) {
assertNotNull(getContextData());
assertThat(getContextData(), endsWith(expectSpanId));
}
}
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册