您的位置:首页 > 编程语言 > ASP

Android基于AOP的非侵入式监控之——AspectJ实战

2016-05-24 10:45 756 查看
一引言

二什么是AspectJ
1 它只是一个代码编译器

2 它是用来做AOP编程的

3为什么要用AspectJ

三AspectJ原理与运用
1 基本原理

2 使用方式
21 纯注解方式

22 AspectJ语言

23 结合自定义注解使用

四AspectJ实战监听方法执行耗时打印并输出

五一些比较常见的问题

六推荐文章

一、引言

本博文的目的不是详细的介绍AspectJ的细节,而是最近项目用到了AspectJ,因此对其作了一些使用和重要概念上的总结。

相信很多做过Web的同学对AspectJ都不陌生,Spring的AOP就是基于它而来的。如果说平常我们随便写写程序的时候,基本也不会用到它,需要调试的话无非就是多加一个System.out.printfln()或者Log.d()。但是由于基于面向对象的固有缺陷,导致很多同模块、同一水平上的工作要在许多类中重复出现。比如说:输出日志,监控方法执行时间,修改程序运行时的参数等等这样的事情,其实它们的代码都是可以重用的。

如果在一个大型的项目当中,使用手动修改源码的方式来达到调试、监控的目的,第一,需要插入许多重复代码(打印日志,监控方法执行时间),代码无法复用;第二,修改的成本太高,处处需要手动修改(分分钟累死、眼花)。

OOP: 面向对象把所有的事物都当做对象看待,因此每一个对象都有自己的生命周期,都是一个封装的整体。每一个对象都有自己的一套垂直的系列方法和属性,使得我们使用对象的时候不需要太多的关系它的内部细节和实现过程,只需要关注输入和输出,这跟我们的思维方式非常相近,极大的降低了我们的编写代码成本(而不像C那样让人头痛!)。但在现实世界中,并不是所有问题都能完美得划分到模块中。举个最简单而又常见的例子:现在想为每个模块加上日志功能,要求模块运行时候能输出日志。在不知道AOP的情况下,一般的处理都是:先设计一个日志输出模块,这个模块提供日志输出API,比如Android中的Log类。然后,其他模块需要输出日志的时候调用Log类的几个函数,比如e(TAG,…),w(TAG,…),d(TAG,…),i(TAG,…)等。

AOP: 面向对象编程固然是开启另一个编程时代,但是久而久之也显露了它的缺点,最明显的一点就是它无法横向切割某一类方法、属性,当我们需要了解某一类方法、某一类属性的信息时,就必须要在每一个类的方法里面(即便他们是同样的方法,只因是不同的类所以不同)添加监控代码,在代码量庞大的情况下,这是一个不可取的方法。因此,AOP编产生了,基于AOP的编程可以让我们横向的切割某一类方法和属性(不需要关心他是什么类别!),我觉得AOP并不是与OOP对立的,而是为了弥补OOP的不足,因为有了AOP我们的调试和监控就变得简单清晰。

二、什么是AspectJ?

2.1 它只是一个代码编译器

AspectJ 意思就是Java的Aspect,Java的AOP。它其实不是一个新的语言,它就是一个代码编译器(ajc,后面以此代替),在Java编译器的基础上增加了一些它自己的关键字识别和编译方法。因此,ajc也可以编译Java代码。它在编译期将开发者编写的Aspect程序编织到目标程序中,对目标程序作了重构,目的就是建立目标程序与Aspect程序的连接(耦合,获得对方的引用(获得的是声明类型,不是运行时类型)和上下文信息),从而达到AOP的目的(这里在编译期还是修改了原来程序的代码,但是是ajc替我们做的)。

2.2 它是用来做AOP编程的

AspectJ就是AOP,只不过是面向java的。AOP里面有一些重要基本的概念:

aspect(切面):实现了cross-cutting功能,是针对切面的模块。最常见的是logging模块、方法执行耗时模块,这样,程序按功能被分为好几层,如果按传统的继承的话,商业模型继承日志模块的话需要插入修改的地方太多,而通过创建一个切面就可以使用AOP来实现相同的功能了,我们可以针对不同的需求做出不同的切面。

jointpoint(连接点):连接点是切面插入应用程序的地方,该点能被方法调用,而且也会被抛出意外。连接点是应用程序提供给切面插入的地方,在插入地建立AspectJ程序与源程序的连接。

下面列表上的是被AspectJ认为是joinpoint的:



advice(处理逻辑): advice是我们切面功能的实现,它是切点的真正执行的地方。比如像写日志到一个文件中,advice(包括:before、after、around等)在jointpoint处插入代码到应用程序中。我们来看一看原AspectJ程序和反编译过后的程序。看完下面的图我们就大概明白了AspectJ是如何达到监控源程序的信息了。

原Activity代码:



Advise:



反编译后的原代码:



pointcut(切点): pointcut可以控制你把哪些advice应用于jointpoint上去,通常你使用pointcuts通过正则表达式来把明显的名字和模式进行匹配应用。决定了那个jointpoint会获得通知。分为call、execution、target、this、within等关键字(具体含义见第四节)



2.3、为什么要用AspectJ?

1、非侵入式监控: 可以在不修监控目标的情况下监控其运行,截获某类方法,甚至可以修改其参数和运行轨迹!

2、学习成本低: 它就是Java,只要会Java就可以用它。

3、功能强大,可拓展性高: 它就是一个编译器+一个库,可以让开发者最大限度的发挥,实现形形色色的AOP程序!

三、AspectJ原理与运用

先放一块AspectJ代码,(这里使用的都是AspectJ较为常用的知识),接着在解释。

import android.annotation.TargetApi;
import android.app.Activity;
import android.graphics.Path;
import android.os.Build;

import org.android10.gintonic.internal.ChooseDialog;
import org.android10.gintonic.internal.DebugLog;
import org.android10.gintonic.internal.MethodMsg;
import org.android10.gintonic.internal.StopWatch;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;

/**
* 截获类名最后含有Activity、Layout的类的所有方法
* 监听目标方法的执行时间
*/
@Aspect
public class TraceAspect {
private static Object currentObject = null;
//进行类似于正则表达式的匹配,被匹配到的方法都会被截获
////截获任何包中以类名以Activity、Layout结尾,并且该目标类和当前类是一个Object的对象的所有方法
private static final String POINTCUT_METHOD =
"(execution(* *..Activity+.*(..)) ||execution(* *..Layout+.*(..))) && target(Object) && this(Object)";
//精确截获MyFrameLayou的onMeasure方法
private static final String POINTCUT_CALL = "call(* org.android10.viewgroupperformance.component.MyFrameLayout.onMeasure(..))";

private static final String POINTCUT_METHOD_MAINACTIVITY = "execution(* *..MainActivity+.onCreate(..))";

//切点,ajc会将切点对应的Advise编织入目标程序当中
@Pointcut(POINTCUT_METHOD)
public void methodAnnotated() {}
@Pointcut(POINTCUT_METHOD_MAINACTIVITY)
public void methodAnootatedWith(){}

/**
* 在截获的目标方法调用之前执行该Advise
* @param joinPoint
* @throws Throwable
*/
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
@Before("methodAnootatedWith()")
public void onCreateBefore(JoinPoint joinPoint) throws Throwable{
Activity activity = null;
//获取目标对象
activity = ((Activity)joinPoint.getTarget());
//插入自己的实现,控制目标对象的执行
ChooseDialog dialog = new ChooseDialog(activity);
dialog.show();

//做其他的操作
buildLogMessage("test",20);
}
/**
* 在截获的目标方法调用返回之后(无论正常还是异常)执行该Advise
* @param joinPoint
* @throws Throwable
*/
@After("methodAnootatedWith()")
public void onCreateAfter(JoinPoint joinPoint) throws Throwable{
Log.e("onCreateAfter:","onCreate is end .");

}
/**
* 在截获的目标方法体开始执行时(刚进入该方法实体时)调用
* @param joinPoint
* @return
* @throws Throwable
*/
@Around("methodAnnotated()")
public Object weaveJoinPoint(ProceedingJoinPoint joinPoint) throws Throwable {

if (currentObject == null){
currentObject = joinPoint.getTarget();
}
//初始化计时器
final StopWatch stopWatch = new StopWatch();
//开始监听
stopWatch.start();
//调用原方法的执行。
Object result = joinPoint.proceed();
//监听结束
stopWatch.stop();
//获取方法信息对象
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
String className;
//获取当前对象,通过反射获取类别详细信息
className = joinPoint.getThis().getClass().getName();

String methodName = methodSignature.getName();
if (currentObject != null && currentObject.equals(joinPoint.getTarget())){
DebugLog.log(new MethodMsg(className, buildLogMessage(methodName, stopWatch.getTotalTimeMicros()),stopWatch.getTotalTimeMicros()));
}else if(currentObject != null && !currentObject.equals(joinPoint.getTarget())){
DebugLog.log(new MethodMsg(className, buildLogMessage(methodName, stopWatch.getTotalTimeMicros()),stopWatch.getTotalTimeMicros()));
currentObject = joinPoint.getTarget();
DebugLog.outPut(new Path());    //日志存储
DebugLog.ReadIn(new Path());    //日志读取
}
return result;
}

/**
* 创建一个日志信息
*
* @param methodName 方法名
* @param methodDuration 执行时间
* @return
*/
private static String buildLogMessage(String methodName, long methodDuration) {
StringBuilder message = new StringBuilder();
message.append(methodName);
message.append(" --> ");
message.append("[");
message.append(methodDuration);
if (StopWatch.Accuracy == 1){
message.append("ms");
}else {
message.append("mic");
}
message.append("]      ");

return message.toString();
}

}


3.1 基本原理

在编译期对目标对象、方法做标记,对目标类、方法进行重构,将PointCut插入目标中,截获该目标的信息以及上下文环境,以达到非侵入代码监控的目的——注意,它只能获得对象的声明,如果对象的声明式接口,那么默认情况下(不使用this、target约束切点),获取的是声明类型,而不是具体运行时的类。

编写Aspect:声明Aspect、PointCut和Advise。

ajc编织: AspectJ编译器在编译期间对所切点所在的目标类进行了重构,在编译层将AspectJ程序与目标程序进行双向关联,生成新的目标字节码,即将AspectJ的切点和其余辅助的信息类段插入目标方法和目标类中,同时也传回了目标类以及其实例引用。这样便能够在AspectJ程序里对目标程序进行监听甚至操控。

execution: 顾名思义,它截获的是方法真正执行的代码区,Around方法块就是专门为它存在的。调用Around可以控制原方法的执行与否,可以选择执行也可以选择替换。

//截获任何包中以类名以Activity、Layout结尾,并且该目标类和当前类是一个Object的对象的所有方法
private static final String POINTCUT_METHOD =
"(execution(* *..Activity+.*(..)) ||execution(* *..Layout+.*(..))) && target(Object) && this(Object)";
//基于execution的切点
@Pointcut(POINTCUT_METHOD)
public void methodAnnotated() {}


4 . call: 同样,从名字可以看出,call截获的是方法的调用区,它并不截获代码真正的执行区域,它截获的是方法调用之前与调用之后(与before、after配合使用),在调用方法的前后插入JoinPoint和before、after通知。它截获的信息并没有execution那么多,它无法控制原来方法的执行与否,只是在方法调用前后插入切点,因此它比较适合做一些轻量的监控(方法调用耗时,方法的返回值等)。

//精确截获MyFrameLayou的onMeasure方法
private static final String POINTCUT_CALL = "call(* org.android10.viewgroupperformance.component.MyFrameLayout.onMeasure(..))";
//基于call的切点
@Pointcut(POINTCUT_METHOD_MAINACTIVITY)
public void methodAnootatedWith(){}


5 . Around替代原理:目标方法体被Around方法替换,原方法重新生成,名为XXX_aroundBody(),如果要调用原方法需要在AspectJ程序的Around方法体内调用joinPoint.proceed()还原方法执行,是这样达到替换原方法的目的。达到这个目的需要双方互相引用,桥梁便是Aspect类,目标程序插入了Aspect类所在的包获取引用。AspectJ通过在目标类里面加入Closure(闭包)类,该类构造函数包含了目标类实例、目标方法参数、JoinPoint对象等信息,同时该类作为切点原方法的执行代理,该闭包通过Aspect类调用Around方法传入Aspect程序。这样便达到了关联的目的,便可以在Aspect程序中监控和修改目标程序。

/**
* 在截获的目标方法体开始执行时(刚进入该方法实体时)调用
* @param joinPoint
* @return
* @throws Throwable
*/
@Around("methodAnnotated()")
public Object weaveJoinPoint(ProceedingJoinPoint joinPoint) throws Throwable {

if (currentObject == null){
currentObject = joinPoint.getTarget();
}
//初始化计时器
final StopWatch stopWatch = new StopWatch();
//开始监听
stopWatch.start();
//调用原方法的执行。
Object result = joinPoint.proceed();
//监听结束
stopWatch.stop();
//获取方法信息对象
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
String className;
//获取当前对象,通过反射获取类别详细信息
className = joinPoint.getThis().getClass().getName();

String methodName = methodSignature.getName();
if (currentObject != null && currentObject.equals(joinPoint.getTarget())){
DebugLog.log(new MethodMsg(className, buildLogMessage(methodName, stopWatch.getTotalTimeMicros()),stopWatch.getTotalTimeMicros()));
}else if(currentObject != null && !currentObject.equals(joinPoint.getTarget())){
DebugLog.log(new MethodMsg(className, buildLogMessage(methodName, stopWatch.getTotalTimeMicros()),stopWatch.getTotalTimeMicros()));
currentObject = joinPoint.getTarget();
DebugLog.outPut(new Path());    //日志存储
DebugLog.ReadIn(new Path());    //日志读取
}
return result;
}


6 . Before与After: Before与After只是在方法被调用前和调用之后添加JoinPoint和通知方法(直接插入原程序方法体中),调用AspectJ程序定义的Advise方法,它并不替代原方法,是在方法call之前和之后做一个插入操作。After分为returnning和throwing两类,前者是在正常returning之后调用,后者是在throwing发生之后调用。默认的After是在finally处调用,因此它包含了前面的两种情况。

/**
* 在截获的目标方法调用之前执行该Advise
* @param joinPoint
* @throws Throwable
*/
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
@Before("methodAnootatedWith()")
public void onCreateBefore(JoinPoint joinPoint) throws Throwable{
Activity activity = null;
//获取目标对象
activity = ((Activity)joinPoint.getTarget());
//插入自己的实现,控制目标对象的执行
ChooseDialog dialog = new ChooseDialog(activity);
dialog.show();

//做其他的操作
buildLogMessage("test",20);
}
/**
* 在截获的目标方法调用返回之后(无论正常还是异常)执行该Advise
* @param joinPoint
* @throws Throwable
*/
@After("methodAnootatedWith()")
public void onCreateAfter(JoinPoint joinPoint) throws Throwable{
Log.e("onCreateAfter:","onCreate is end .");

}


7 . 重要关键字:

在其它关键字中,必须要注意的就是this、target的使用和区别,同时还有一个很重要的方法**Signature.getDeclaringType();**AspectJ是在编译期截获的对象信息,因此它获得的标签只是对象的声明(比如:接口、抽象类),而不是运行时具体的对象。如果想要获得运行时对象,就需要用this、target关键字

this  :用于匹配当前AOP代理对象类型的执行方法;注意是AOP代理对象的类型匹配,这样就可能包括引入接口也类型匹配;
target:用于匹配当前目标对象类型的执行方法;注意是目标对象的类型匹配,这样就不包括引入接口也类型匹配;
args:用于匹配当前执行的方法传入的参数为指定类型的执行方法;
within:用于匹配指定类型内的方法执行;


更加详细的解说请参考深入理解Android之AOP,该博文对于AspectJ的其他详细概念、定义、细节示例解说的非常清楚,如果想要详细了解请务必要看。

3.2 使用方式

3.2.1 纯注解方式

上面贴的代码就是该方式,也是最普遍的方式,它不需要其他插件的支持(Eclipse中有AJDT可以支持AspectJ关键字声明,但Android Studio中没有改插件),使用Java的注解和ajc以及它的库就可以完成AOP编程,非常方便,而且可以在绝大部分支持Java的IDE中使用。缺点就是对于注释部分的匹配没有检错功能。

/**
* Created by lingyi.mly on 2016/5/21.
*/
@Aspect
public class TraceAspect3 {
private static volatile Object currentObject = null;
private ExecutorService ThreadPool = Executors.newFixedThreadPool(10);
private static final String POINTCUT_METHOD =
"call(* *.*(..))&&target(Object) &&!within(*.TimeMonitorFragment)";
@Pointcut(POINTCUT_METHOD)
public void methodAnnotated() {
}

StopWatch stopWatch;
MethodSignature methodSignature;
String methodName;
String className;

@Before("methodAnnotated()")
public void beforeInvoked(final JoinPoint joinPoint) {
className = "call target: " + joinPoint.getTarget().getClass().getName();
methodSignature = (MethodSignature) joinPoint.getSignature();
methodName = methodSignature.getName();
stopWatch = new StopWatch();
stopWatch.start();
}

@After("methodAnnotated()")
public void afterInvoked(final JoinPoint joinPoint) {
stopWatch.stop();
double methodDuration = stopWatch.getTotalTime(StopWatch.Accuracy);
DebugLog.log(new MethodMsg(className, methodName, methodDuration, StopWatch.Accuracy));
}
}


3.2.2 AspectJ语言

在Eclipse中使用AJDT插件,可以识别AspectJ的语法。这样编写起来相对于注解要方便许多,还提供检错功能,比较强大。不过不是所有的IDE都支持,比如Android Studio目前就没有(我哭了好久)。

package main;
import java.util.HashMap;
import java.util.Map;
/**
* 只有call才能区分this 与target 在与的情况下两者不共存,在交的情况下共存。
* execution匹配this 与 target时无论是 与 还是 交集 都是同一个对象
* @author lingyi.mly
*
*/

public aspect Aspect{
static int count = 0;
pointcut targetTest() : call(* main.*.*(..)) &&( target(Object) );
pointcut thisTest( ) : execution(* main.*.*(..)) && (target(Object) ||this(Object));
Object around() : thisTest() {
if (thisJoinPoint.getThis() != null) {
System.out.println(thisJoinPoint.getThis().getClass().getName()  +  "   " + thisJoinPoint.getSourceLocation());
}else if (thisJoinPoint.getTarget() != null) {
System.out.println(thisJoinPoint.getTarget().getClass().getName()  +  "   " + thisJoinPoint.getSourceLocation());
}
return null;
}
before() : targetTest() {
if (thisJoinPoint.getThis() != null) {
System.out.println("this:  "+thisJoinPoint.getThis().getClass().getName()  +  "   " + thisJoinPoint.getSourceLocation());
}else if (thisJoinPoint.getTarget() != null) {
System.out.println("target:  "+thisJoinPoint.getTarget().getClass().getName()  +  "   " + thisJoinPoint.getSourceLocation());
}
}

private static Map<String, Integer> threadMap = new HashMap<String,Integer>();
}


3.2.3 结合自定义注解使用

这个是混合用法,可以在execution、call中使用注解,然后该注解标注在目标方法上就可以实现关联,并且截获。这样做的好处实在想不到,最多就是可以精确定位到某一个方法(那使用绝对路径匹配不也可以?)。而且还侵入了源码。实在是不推荐,不过我在网上看到有人这么用了,所以也贴上来了。如果哪位高手知道这样做的精髓,请一定指教。下面贴一下它的用法实现。

自定义注解及被标记的方法:

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
* 表明被注释的方法将被跟踪(仅在Debug模式下)并且将会与Aspect程序中截获该注释的Advise关联,调用该切点
* 的Advise
*/
@Retention(RetentionPolicy.CLASS)
@Target({ ElementType.CONSTRUCTOR, ElementType.METHOD })
public @interface DebugTrace {}

/**
* 被注解的类
*/
public class MyFrameLayout extends FrameLayout {
//........
//被注解的方法
@DebugTrace
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
@DebugTrace
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
}
}


切面:

package org.android10.gintonic.aspect;

/**
* 跟踪被DebugTrace注解标记的方法和构造函数
*/
@Aspect
public class TraceAspect {
//跟踪DebugTrace注解
private static final String POINTCUT_METHOD =
"execution(@org.android10.gintonic.annotation.DebugTrace * *(..))";

@Pointcut(POINTCUT_METHOD)
public void methodAnnotatedWithDebugTrace() {}

@Around("methodAnnotatedWithDebugTrace() || constructorAnnotatedDebugTrace()")
public Object weaveJoinPoint(ProceedingJoinPoint joinPoint) throws Throwable {
// Do SomeThing
stopWatch.start();
Object result = joinPoint.proceed();
stopWatch.stop();
// Do SomeThing
return result;
}
// ........省略
}


四、AspectJ实战——监听方法执行耗时,打印并输出

源程序代码:Android-AOPExample-master

关键代码:

private static final String POINTCUT_METHOD =
"(execution(* *..Activity+.*(..)) ||execution(* *..Layout+.*(..))) && target(Object) && this(Object)";
// ...........
@Pointcut(POINTCUT_METHOD)
public void methodAnnotated() {}
// .........

@Around("methodAnnotated()")
public Object weaveJoinPoint(ProceedingJoinPoint joinPoint) throws Throwable {

if (currentObject == null){
currentObject = joinPoint.getTarget();
}
//初始化计时器
final StopWatch stopWatch = new StopWatch();
//开始监听
stopWatch.start();
//调用原方法的执行。
Object result = joinPoint.proceed();
//监听结束
stopWatch.stop();
//获取方法信息对象
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
String className;
//获取当前对象,通过反射获取类别详细信息
className = joinPoint.getThis().getClass().getName();

String methodName = methodSignature.getName();
String msg =  buildLogMessage(methodName, stopWatch.getTotalTime(1));
if (currentObject != null && currentObject.equals(joinPoint.getTarget())){
DebugLog.log(new MethodMsg(className,msg,stopWatch.getTotalTime(1)));
}else if(currentObject != null && !currentObject.equals(joinPoint.getTarget())){
DebugLog.log(new MethodMsg(className, msg,stopWatch.getTotalTime(1)));
Log.e(className,msg);
currentObject = joinPoint.getTarget();
//        DebugLog.outPut(new Path());    //日志存储
//        DebugLog.ReadIn(new Path());    //日志读取
}
return result;
}


监听方法执行时间:

TimeMonitor:: org.android10.viewgroupperformance.activity.RelativeLayoutTestActivity     onCreate --> [8.636ms]
org.android10.viewgroupperformance.activity.MainActivity     openActivity --> [6.561ms]
org.android10.viewgroupperformance.activity.MainActivity     mapGUI --> [0.061ms]


五、一些比较常见的问题

(1)问题:AspectJ中Signature提供的getDeclareType返回的是声明类型,无法获取运行时类型,因此无法准确获取接口运行时类别。

方案:使用target关键字约束pointCut,获取目标对象,通过反射获取其运行时类别。

(2)问题:使用target关键字约束pointcut获取目标对象Object之后,无法获取静态方法(不属于对象)

方案:单独将静态方法提出来,再与前面的target关键字约束的集合取并集。

(3)问题:使用Before、After通知,测试方法耗时的精确度误差较大

方案:改用execution+around。两点,第一:由于Before、After是在原方法调用前后插入通知(会影响本来所在方法快的执行速率);第二:同时Before、After两个操作无法保证是原子操作,多线程情况下会有误差。因此该用execution关键字,截获方法体的真正执行处,使用Around通知,替代原方法(原方法被更名,但结构不变),在Around通知体内调用原方法计时,这样能够真正还原方法执行耗时;

六、推荐文章

深入理解Android之AOP

官方英文文档

跟我学习AspectJ系列
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: