Java 动态代理机制解析

通过这篇文章,你将了解Java的静态代理以及实现动态代理的两种方式,你还将能看到动态代理类结构信息。

既然有动态代理,那么肯定有其对立面静态代理。两者最明显的区别就是字节码class文件生成的时间。静态代理的class文件是在编译期生成,而动态代理的class文件信息是在运行期生成。

介绍动态代理之前,我们先看我们核心业务类UserService,现在有个需求要为这个类增加统一的日志打印功能。

// UserService.java 
interface UserService {
    public String getLoginNameById(String id);
}

//UserServiceImpl.java
public class UserServiceImpl implements UserService {
    public String getLoginNameById(String id){
        return "admin";
    }
}

1. 静态代理

静态代理是编译器生成class文件,具体实现方式如下:

//StaticLogProxy.java
public class StaticLogProxy implements UserService {

    public UserService userService;

    @Override
    public String getLoginNameById(String id) {

        System.out.println("getLoginNameById invoked param: "+id);

        return userService.getLoginNameById(id);
    }
}

静态代理通过新建一个代理类StaticLogProxy(该类实现UserService接口)从而完成代理操作。这种实现方式需要为每个被代理的类增加对应的代理类(如果被代理类数量较多,工作量较大),代码耦合性较强(每个代理类只能对应某个类及其子类),且不易扩展。

2. jdk动态代理类

动态代理主要分为两种:jdk原生的动态代理以及cglib动态代理。我们先来看一下jdk原生的动态代理。

笔者认为jdk原生动态代理是基于静态代理,解决了静态代理中代码耦合性强,不易扩展等问题。

jdk动态代理包括两个重要的类:InvocationHandler和Proxy。
InvocationHandler:被代理类公共行为的引入的定义,在本文指的是为UserService增加日志打印的功能。
Proxy:根据被代理类及InvocationHandler,生成动态代理的字节码,并加载到JVM中。

//LogInvocationHandler.java 
public class LogInvocationHandler implements InvocationHandler {
    public Object target;

    public LogInvocationHandler(Object target){
        this.target = target;
    }
    /**
     * invoke方法
     * @param proxy  代理类
     * @param method 被代理的方法
     * @param args  被代理方法的参数
     * @return  返回值
     * @throws Throwable
     */
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        System.out.println("method:"+method.getName()+ " invoked"+","+"params: "+ Arrays.toString(args));
        return method.invoke(target, args);
    }
}

LogInvocationHandler是InvocationHandler的实现类,被代理的类必须为LogInvocationHandler的属性。LogInvocationHandler的invoke方法定义了日志打印的功能。
有了Invocation的实现类,接下来我们就需要使用Proxy的静态方法为UserService创建代理,Proxy创建代理方法如下:

 /**
   * 
   * @param loader  加载代理类字节的类加载器
   * @param interfaces  代理类需要实现的接口
   * @param h  方法调用的实际处理者
   * @return  返回值
   * @throws Throwable
*/
public static Object newProxyInstance(ClassLoader loader,
                                          Class<?>[] interfaces,
                                          InvocationHandler h)

具体实现代码如下:

// Main.java
public class Main {
    public static void main(String[] args){
        //该设置是为了将为UserService生成代理类的字节码保存在磁盘上,便于查看
        System.getProperties().put("sun.misc.ProxyGenerator.saveGeneratedFiles", "true");
        // 实例化一个被代理的UserSerivce
        UserService target = new UserServiceImpl();
        // 实例化InvocationHandler实现类,并将target作为构造函数参数传入。
        LogInvocationHandler logInvocationHandler = new LogInvocationHandler(target);
        // 创建动态代理类
        UserService userService = (UserService) Proxy.newProxyInstance(
                Main.class.getClassLoader(), 
                new Class<?>[] {UserService.class}, 
                logInvocationHandler);
        //调用方法,验证日志打印功能
        userService.getLoginNameById("1");
    }
}

通过对比jdk动态代理和静态代理的实现方式,我们发现jdk动态代理的实际业务处理类LogInvocationHandler中没有与UserService相关的代码,从代码层面上进行解耦。通过LogInvocationHandler实现动态代理,我们可以为任意的接口实现类增加日志打印功能。
注意我们特别说明了是可以为任意的接口实现类增加日志打印功能,这是我们使用动态代理的一个限制。

在Main.java中,我们通过增加下面这行代码,将代理类的字节保存的在磁盘上

//生成字节码的路径在本工程下
System.getProperties().put("sun.misc.ProxyGenerator.saveGeneratedFiles", "true");

紧接着,我们来看一下生成字节码反编译后的代码:

//我们可以看出jdk动态代理通过实现接口的方式生成了代理类
//所有被代理的方法都作为代理$Proxy0的属性,由静态代码块进行初始化
//在调用具体的方法时,又通过invocationHandler定义的增强逻辑来进行统一处理
public final class $Proxy0 extends Proxy implements UserService {
    private static Method m1;
    private static Method m3;
    private static Method m2;
    private static Method m0;

    public $Proxy0(InvocationHandler var1) throws  {
        super(var1);
    }

    public final boolean equals(Object var1) throws  {
        try {
            return ((Boolean)super.h.invoke(this, m1, new Object[]{var1})).booleanValue();
        } catch (RuntimeException | Error var3) {
            throw var3;
        } catch (Throwable var4) {
            throw new UndeclaredThrowableException(var4);
        }
    }

    public final String getLoginNameById(String var1) throws  {
        try {
            return (String)super.h.invoke(this, m3, new Object[]{var1});
        } catch (RuntimeException | Error var3) {
            throw var3;
        } catch (Throwable var4) {
            throw new UndeclaredThrowableException(var4);
        }
    }

    public final String toString() throws  {
        try {
            return (String)super.h.invoke(this, m2, (Object[])null);
        } catch (RuntimeException | Error var2) {
            throw var2;
        } catch (Throwable var3) {
            throw new UndeclaredThrowableException(var3);
        }
    }

    public final int hashCode() throws  {
        try {
            return ((Integer)super.h.invoke(this, m0, (Object[])null)).intValue();
        } catch (RuntimeException | Error var2) {
            throw var2;
        } catch (Throwable var3) {
            throw new UndeclaredThrowableException(var3);
        }
    }

    static {
        try {
            m1 = Class.forName("java.lang.Object").getMethod("equals", new Class[]{Class.forName("java.lang.Object")});
            m3 = Class.forName("org.weking.proxy.jdk.UserService").getMethod("getLoginNameById", new Class[]{Class.forName("java.lang.String")});
            m2 = Class.forName("java.lang.Object").getMethod("toString", new Class[0]);
            m0 = Class.forName("java.lang.Object").getMethod("hashCode", new Class[0]);
        } catch (NoSuchMethodException var2) {
            throw new NoSuchMethodError(var2.getMessage());
        } catch (ClassNotFoundException var3) {
            throw new NoClassDefFoundError(var3.getMessage());
        }
    }
}

3.CGLIB动态代理

之前我们提到jdk动态代理只能为接口实现类创建代理,那么我们如何为一般的类创建动态代理呢?这时就需要用到CGLIB动态代理机制。
CGLIB(Code Generation Library)是一个基于ASM的字节码生成库,它允许我们在运行时对字节码进行修改和动态生成,也正因为如此,CGLIB的性能优于jdk动态代理。CGLIB通过继承方式实现代理。
CGLIB动态代理主要包括两个重要的类:MethodInterceptor和Enhancer。
MethodInterceptor:该类与InvocationHandler类似,是包含增强方法的类。
EnHancer:该类与Proxy类似,完成生成代理类字节码的功能。

注意:CGLIB不是jdk自带的功能,需要引用相关的jar包才能使用

首先我们来看一下没有实现接口的UserSerivce的代码

//UserService.java
public class UserService {

    public final String finalMethod(String name){
        System.out.println("finalMethod running!!!");
        return "final";
    }

    public String getLoginNameById(String id){
        return "admin";
    }
}

接下来,我们看一下MethodInterceptor的实现类LogMethodInterceptor

//LogMethodInterceptor .java
public class LogMethodInterceptor implements MethodInterceptor {

    public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
        System.out.println("method exexcute,name: "+method.getName()+",params: "+ Arrays.toString(args));
        return proxy.invokeSuper(obj, args);
    }
}

最后,我们看一下如何通过Enhancer创建代理的代码:

// Main.java
public class Main {
    public static void main(String[] args){
        //将创建代理的字节保存到D盘下的class文件夹下
        System.setProperty(DebuggingClassWriter.DEBUG_LOCATION_PROPERTY, "D:\\class");
        //实例化增强器类
        Enhancer enhancer = new Enhancer();
        //设置代理类需要继承的类
        enhancer.setSuperclass(UserService.class);
        //设置代理类的方法拦截器
        enhancer.setCallback(new LogMethodInterceptor());
        //创建UserService的代理类
        UserService userService = (UserService)enhancer.create();
        //执行代理类方法
        userService.getLoginNameById("123");

    }
}

相比于jdk动态代理需要实例化UserService的实现类,CGLIB动态代理更加简单,易用。关键是CGLIB可以为非接口实现类创建代理。
当然,CGLIB也有限制:CGLIB无法代理类的final方法,比如UserService中的finalMethod方法。
注意:对于从Object中继承的方法,CGLIB代理也会进行代理,如hashCode()、equals()、toString()等,但是getClass()、wait()等方法不会,因为它是final方法,CGLIB无法代理。
在Main.java中,我们通过增加如下的代码,将代理类的字节码保存在磁盘上

     System.setProperty(DebuggingClassWriter.DEBUG_LOCATION_PROPERTY, "D:\\class");

同样,我们看一下字节码反编译后的结果

//不同于jdk动态代理,CGLIB通过继承的方式生成UserService的代理类
public class UserService$$EnhancerByCGLIB$$d603014e extends UserService implements Factory {
    private boolean CGLIB$BOUND;
    public static Object CGLIB$FACTORY_DATA;
    private static final ThreadLocal CGLIB$THREAD_CALLBACKS;
    private static final Callback[] CGLIB$STATIC_CALLBACKS;
    private MethodInterceptor CGLIB$CALLBACK_0;
    private static Object CGLIB$CALLBACK_FILTER;
    private static final Method CGLIB$getLoginNameById$0$Method;
    private static final MethodProxy CGLIB$getLoginNameById$0$Proxy;
    private static final Object[] CGLIB$emptyArgs;
    private static final Method CGLIB$equals$1$Method;
    private static final MethodProxy CGLIB$equals$1$Proxy;
    private static final Method CGLIB$toString$2$Method;
    private static final MethodProxy CGLIB$toString$2$Proxy;
    private static final Method CGLIB$hashCode$3$Method;
    private static final MethodProxy CGLIB$hashCode$3$Proxy;
    private static final Method CGLIB$clone$4$Method;
    private static final MethodProxy CGLIB$clone$4$Proxy;

    static void CGLIB$STATICHOOK1() {
        CGLIB$THREAD_CALLBACKS = new ThreadLocal();
        CGLIB$emptyArgs = new Object[0];
        Class var0 = Class.forName("org.weking.proxy.cglib.UserService$$EnhancerByCGLIB$$d603014e");
        Class var1;
        Method[] var10000 = ReflectUtils.findMethods(new String[]{"equals", "(Ljava/lang/Object;)Z", "toString", "()Ljava/lang/String;", "hashCode", "()I", "clone", "()Ljava/lang/Object;"}, (var1 = Class.forName("java.lang.Object")).getDeclaredMethods());
        CGLIB$equals$1$Method = var10000[0];
        CGLIB$equals$1$Proxy = MethodProxy.create(var1, var0, "(Ljava/lang/Object;)Z", "equals", "CGLIB$equals$1");
        CGLIB$toString$2$Method = var10000[1];
        CGLIB$toString$2$Proxy = MethodProxy.create(var1, var0, "()Ljava/lang/String;", "toString", "CGLIB$toString$2");
        CGLIB$hashCode$3$Method = var10000[2];
        CGLIB$hashCode$3$Proxy = MethodProxy.create(var1, var0, "()I", "hashCode", "CGLIB$hashCode$3");
        CGLIB$clone$4$Method = var10000[3];
        CGLIB$clone$4$Proxy = MethodProxy.create(var1, var0, "()Ljava/lang/Object;", "clone", "CGLIB$clone$4");
        CGLIB$getLoginNameById$0$Method = ReflectUtils.findMethods(new String[]{"getLoginNameById", "(Ljava/lang/String;)Ljava/lang/String;"}, (var1 = Class.forName("org.weking.proxy.cglib.UserService")).getDeclaredMethods())[0];
        CGLIB$getLoginNameById$0$Proxy = MethodProxy.create(var1, var0, "(Ljava/lang/String;)Ljava/lang/String;", "getLoginNameById", "CGLIB$getLoginNameById$0");
    }

    final String CGLIB$getLoginNameById$0(String var1) {
        return super.getLoginNameById(var1);
    }

    public final String getLoginNameById(String var1) {
        MethodInterceptor var10000 = this.CGLIB$CALLBACK_0;
        if(this.CGLIB$CALLBACK_0 == null) {
            CGLIB$BIND_CALLBACKS(this);
            var10000 = this.CGLIB$CALLBACK_0;
        }

        return var10000 != null?(String)var10000.intercept(this, CGLIB$getLoginNameById$0$Method, new Object[]{var1}, CGLIB$getLoginNameById$0$Proxy):super.getLoginNameById(var1);
    }

    final boolean CGLIB$equals$1(Object var1) {
        return super.equals(var1);
    }

    public final boolean equals(Object var1) {
        MethodInterceptor var10000 = this.CGLIB$CALLBACK_0;
        if(this.CGLIB$CALLBACK_0 == null) {
            CGLIB$BIND_CALLBACKS(this);
            var10000 = this.CGLIB$CALLBACK_0;
        }

        if(var10000 != null) {
            Object var2 = var10000.intercept(this, CGLIB$equals$1$Method, new Object[]{var1}, CGLIB$equals$1$Proxy);
            return var2 == null?false:((Boolean)var2).booleanValue();
        } else {
            return super.equals(var1);
        }
    }

    final String CGLIB$toString$2() {
        return super.toString();
    }

    public final String toString() {
        MethodInterceptor var10000 = this.CGLIB$CALLBACK_0;
        if(this.CGLIB$CALLBACK_0 == null) {
            CGLIB$BIND_CALLBACKS(this);
            var10000 = this.CGLIB$CALLBACK_0;
        }

        return var10000 != null?(String)var10000.intercept(this, CGLIB$toString$2$Method, CGLIB$emptyArgs, CGLIB$toString$2$Proxy):super.toString();
    }

    final int CGLIB$hashCode$3() {
        return super.hashCode();
    }

    public final int hashCode() {
        MethodInterceptor var10000 = this.CGLIB$CALLBACK_0;
        if(this.CGLIB$CALLBACK_0 == null) {
            CGLIB$BIND_CALLBACKS(this);
            var10000 = this.CGLIB$CALLBACK_0;
        }

        if(var10000 != null) {
            Object var1 = var10000.intercept(this, CGLIB$hashCode$3$Method, CGLIB$emptyArgs, CGLIB$hashCode$3$Proxy);
            return var1 == null?0:((Number)var1).intValue();
        } else {
            return super.hashCode();
        }
    }

    final Object CGLIB$clone$4() throws CloneNotSupportedException {
        return super.clone();
    }

    protected final Object clone() throws CloneNotSupportedException {
        MethodInterceptor var10000 = this.CGLIB$CALLBACK_0;
        if(this.CGLIB$CALLBACK_0 == null) {
            CGLIB$BIND_CALLBACKS(this);
            var10000 = this.CGLIB$CALLBACK_0;
        }

        return var10000 != null?var10000.intercept(this, CGLIB$clone$4$Method, CGLIB$emptyArgs, CGLIB$clone$4$Proxy):super.clone();
    }

    public static MethodProxy CGLIB$findMethodProxy(Signature var0) {
        String var10000 = var0.toString();
        switch(var10000.hashCode()) {
        case -508378822:
            if(var10000.equals("clone()Ljava/lang/Object;")) {
                return CGLIB$clone$4$Proxy;
            }
            break;
        case 69527633:
            if(var10000.equals("getLoginNameById(Ljava/lang/String;)Ljava/lang/String;")) {
                return CGLIB$getLoginNameById$0$Proxy;
            }
            break;
        case 1826985398:
            if(var10000.equals("equals(Ljava/lang/Object;)Z")) {
                return CGLIB$equals$1$Proxy;
            }
            break;
        case 1913648695:
            if(var10000.equals("toString()Ljava/lang/String;")) {
                return CGLIB$toString$2$Proxy;
            }
            break;
        case 1984935277:
            if(var10000.equals("hashCode()I")) {
                return CGLIB$hashCode$3$Proxy;
            }
        }

        return null;
    }

    public UserService$$EnhancerByCGLIB$$d603014e() {
        CGLIB$BIND_CALLBACKS(this);
    }

    public static void CGLIB$SET_THREAD_CALLBACKS(Callback[] var0) {
        CGLIB$THREAD_CALLBACKS.set(var0);
    }

    public static void CGLIB$SET_STATIC_CALLBACKS(Callback[] var0) {
        CGLIB$STATIC_CALLBACKS = var0;
    }

    private static final void CGLIB$BIND_CALLBACKS(Object var0) {
        UserService$$EnhancerByCGLIB$$d603014e var1 = (UserService$$EnhancerByCGLIB$$d603014e)var0;
        if(!var1.CGLIB$BOUND) {
            var1.CGLIB$BOUND = true;
            Object var10000 = CGLIB$THREAD_CALLBACKS.get();
            if(var10000 == null) {
                var10000 = CGLIB$STATIC_CALLBACKS;
                if(CGLIB$STATIC_CALLBACKS == null) {
                    return;
                }
            }

            var1.CGLIB$CALLBACK_0 = (MethodInterceptor)((Callback[])var10000)[0];
        }

    }

    public Object newInstance(Callback[] var1) {
        CGLIB$SET_THREAD_CALLBACKS(var1);
        UserService$$EnhancerByCGLIB$$d603014e var10000 = new UserService$$EnhancerByCGLIB$$d603014e();
        CGLIB$SET_THREAD_CALLBACKS((Callback[])null);
        return var10000;
    }

    public Object newInstance(Callback var1) {
        CGLIB$SET_THREAD_CALLBACKS(new Callback[]{var1});
        UserService$$EnhancerByCGLIB$$d603014e var10000 = new UserService$$EnhancerByCGLIB$$d603014e();
        CGLIB$SET_THREAD_CALLBACKS((Callback[])null);
        return var10000;
    }

    public Object newInstance(Class[] var1, Object[] var2, Callback[] var3) {
        CGLIB$SET_THREAD_CALLBACKS(var3);
        UserService$$EnhancerByCGLIB$$d603014e var10000 = new UserService$$EnhancerByCGLIB$$d603014e;
        switch(var1.length) {
        case 0:
            var10000.<init>();
            CGLIB$SET_THREAD_CALLBACKS((Callback[])null);
            return var10000;
        default:
            throw new IllegalArgumentException("Constructor not found");
        }
    }

    public Callback getCallback(int var1) {
        CGLIB$BIND_CALLBACKS(this);
        MethodInterceptor var10000;
        switch(var1) {
        case 0:
            var10000 = this.CGLIB$CALLBACK_0;
            break;
        default:
            var10000 = null;
        }

        return var10000;
    }

    public void setCallback(int var1, Callback var2) {
        switch(var1) {
        case 0:
            this.CGLIB$CALLBACK_0 = (MethodInterceptor)var2;
        default:
        }
    }

    public Callback[] getCallbacks() {
        CGLIB$BIND_CALLBACKS(this);
        return new Callback[]{this.CGLIB$CALLBACK_0};
    }

    public void setCallbacks(Callback[] var1) {
        this.CGLIB$CALLBACK_0 = (MethodInterceptor)var1[0];
    }

    static {
        CGLIB$STATICHOOK1();
    }
}

4.小结

前文详细介绍了静态代理、jdk动态代理、CGLIB动态代理三种实现方式,分析了三种实现方式的优缺点。最后我们总结一下:

  • 静态代理:编译器生成字节码;硬编码,代码耦合性较强,某个代理类能代理的范围有限,不易扩展。

  • jdk动态代理: 运行期动态生成字节码;基于接口实现创建代理;代理相关类代码没有与具体被代理类耦合,能代理范围较大;不能为非接口实现类创建代理;

  • CGLIB动态代理:运行期动态生成字节码;基于继承实现创建代理;代理相关类代码没有与具体被代理类耦合,能代理范围较大,可以为非接口实现类创建代理;无法为final修饰的方法创建代理,使用时需要引用CGLIB相关jar包,性能优于jdk动态代理。

动态代理在Java中应用中较多,比较常见的如Spring AOP的实现就是利用动态代理的方式。学习动态代理对于我们日后使用以及学习其他相关技术的源码有非常大的帮助。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,921评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,635评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,393评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,836评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,833评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,685评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,043评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,694评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,671评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,670评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,779评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,424评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,027评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,984评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,214评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,108评论 2 351
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,517评论 2 343