1. 前言
本文是前作「Lambda 设计参考」的实战局部,具体将介绍如何应用 ASM 对 Java 8 Lambda 表达式和办法援用进行 Hook 操作。在此之前会介绍一些根底概念和字节码相干的常识不便大家对这块内容的了解,最初会给出一个残缺的代码供大家参考。
2. 脱糖
2.1. 概念介绍
Java 脱糖(Desugar):简略地说,就是在编译阶段将语法层面一些底层字节码不反对的个性转换为底层反对的构造。例如:能够在 Android 中应用 Java 8 的 Lambda 个性,就是应用了脱糖。应用脱糖的最次要起因是 Android 设施并没有提供 Java 8 的运行时环境。上面用一个例子来展现对 Lambda 脱糖须要做的工作。
class Java8 {
interface Logger {
void log(String s);
}
public static void main(String… args) {
sayHi(s -> System.out.println(s));
}
private static void sayHi(Logger logger) {
logger.log("Hello!");
}
}
首先是将 Lambda 办法体中的内容从 main 办法中移到 Java8 类的外部办法中,扭转后的后果如下:
public class Java8 {
interface Logger {void log(String s);
}
public static void main(String... args) {
// 应用 lambda$main$0 替换原有的逻辑
sayHi(s -> lambda$main$0(s));
}
private static void sayHi(Logger logger) {logger.log("Hello!");
}
// 办法体中的内容移到这里
static void lambda$main$0(String str){System.out.println(str);
}
}
接着生成一个类,这个类实现了 Logger 接口,实现的办法中调用 lambda$main$0 办法,并且应用实现类替换代码 sayHi(s -> lambda$main$0(s)),扭转后的代码如下:
public class Java8 {
interface Logger {void log(String s);
}
public static void main(String... args) {
// 这里应用 Logger 的实现类 Java8$1
sayHi(s -> new Java8$1());
}
private static void sayHi(Logger logger) {logger.log("Hello!");
}
// 办法体中的内容移到这里
static void lambda$main$0(String str){System.out.println(str);
}
}
public class Java8$1 implements Java8.Logger {
public Java8$1(){}
@Override
public void log(String s) {
// 这里调用 Java8 办法的静态方法
Java8.lambda$main$0(s);
}
}
最初,因为 Lambda 并没有捕捉内部作用的任何变量,所以这是一个无状态 Lambda。实现类会生成一个单例,在应用的中央用这个单例来替换 new Java8$1(),最终的代码如下:
class Java8 {
interface Logger {
void log(String s);
}
public static void main(String… args) {
// 此处应用单例替换原有代码
sayHi(Java8$1.INSTANCE);
}
static void lambda$main$0(String s) {
System.out.println(s);
}
private static void sayHi(Logger logger) {
logger.log("Hello!");
}
}
public class Java8$1 implements Java8.Logger {
static final Java8$1 INSTANCE = new Java8$1();
@Override
public void log(String s) {Java8.lambda$main$0(s);
}
}
这个例子简略地展现了脱糖的过程,其中 lambda$main$0 办法会在编译的时候生成。须要留神的是:办法援用并不会生成额定的办法(对于办法援用和 lambda$main$0 的生成规定以及下面提到的 无状态 lambdas 等常识能够通过「Lambda 设计参考」获取,读者如果对这部分内容不理解能够先看这篇文章)。
2.2. Android 中的脱糖
上一节介绍了什么是脱糖以及用一个简略的例子来演示 Lambda 表达式的脱糖逻辑,那么咱们为什么要关注 Android 中的脱糖呢?
首先 Android 零碎自身并不反对 Java 8,后面说了 Android 设施并没有提供 Java 8 的运行时环境。因而,App 我的项目应用 Java 8 编译产生的字节码是无奈在 Android 设施上解析的,Android 应用 Gradle 在编译时会将 .class 文件中的一些 Java 8 语法个性脱糖成 Java 7 中反对的语法个性。咱们看下图 2-1 形容的 Android 解决 Java 文件的流程,留神图中的“Third-party plugins”是 Android 为咱们提供的能够在编译期有机会解决 .class 文件。对于插件开发,能够参考我司出版的《Android 全埋点解决方案》一书。
图 2-1 Android 解决 Java 文件的流程(起源:https://developer.android.com…)
依据图 2-1 所示,自定义的 Android 插件是在 D8/R8 之前先操作 .class 文件。D8 是 Android 提供的脱糖工具,这就导致自定义插件获取的 .class 是原始未脱糖的 .class(注:多个自定义插件执行程序跟引入程序无关,咱们自定义的插件获取到的 .class 可能是其余插件解决过的)。当初咱们来剖析上面这段代码:
View.setOnClickListener(System.out::println)
Android 开发者对这段代码很容易了解。当初咱们对这段代码进行解决,心愿在执行点击事件的时候,除了执行 println 办法,同时还可能退出一些其余的逻辑,如上面代码的形容:
View.setOnClickListener(view->{
System.out.println(view); // 办法援用
SensorsDataAutoTrackHelper.trackViewClick(view); // 增加的额定逻辑
})
因为这里是一个办法援用,并不会像 Lambda 表达式那样在编译时生成一个 lambd$ 结尾的办法(注:对于这块的形容请参考「Lambda 设计参考」),而且咱们也不能在 println 办法中插入代码,本文就是给大家介绍如何解决这种状况。
留神
Android 能够抉择在工程中敞开 D8 的脱糖性能(能够通过在 gradle.properties 里配置 android.enableD8.desugaring=false 来敞开),那么 .class 文件的解决流程会变成:.class → desugar → third-party plugins → dex。
- invokedynamic 指令
在正式介绍如何应用 ASM 解决 Lambda 和办法援用之前,咱们首先理解一下字节码指令 invokedynamic。invokedynamic 指令是在 JDK 7 引入的,用来实现动静类型语言性能,简略来说就是可能在运行时去调用理论的代码。在进一步介绍 invokedynamic 指令之前,咱们先相熟几个类:MethodType、MethodHandle、CallSite。在介绍这几个类之前咱们先来理解一个办法的形成:
办法名;
办法签名(参数类型和返回值类型);
办法所在的类;
办法体(办法中的代码)。
依据下面办法的形成,咱们来顺次介绍下面的几个类的用法。
3.1. MethodType
MethodType 代表一个办法所需的参数签名和返回值签名,MethodType 类有多个静态方法来结构 MethodType 对象,示例如下:
MethodType methodType = MethodType.methodType(String.class, int.class);
下面这个 MethodType 形容的是返回值为 String 类型,参数是一个 int 类型的办法签名,例如:int foo(String) 这个办法就合乎这个形容。
3.2. MethodHandle
MethodHandle 翻译过去就是办法句柄,通过这个句柄能够调用相应的办法,MethodType 形容了办法的参数和返回值,MethodHandle 则是依据类名、办法名并且配合 MethodType 来找到特定办法而后执行它;MethodType 和 MethodHandle 配合起来残缺表白了一个办法的形成。例如:咱们调用 String.valueOf(int) 办法,能够这么做:
// 申明参数和返回值类型
MethodType methodType = MethodType.methodType(String.class, int.class);
MethodHandles.Lookup lookup = MethodHandles.lookup();
// 申明一个办法句柄:这里阐明的是 String 类外面的 valueOf 办法,办法签名须要合乎 methodType
MethodHandle methodHandle = lookup.findStatic(String.class, “valueOf”, methodType);
// 执行这个办法
String result = (String) methodHandle.invoke(99);
System.out.println(result);
这个跟反射很相似,从这个例子能够看出办法句柄里蕴含了须要执行的办法信息,只有传入所需的参数就能够执行这个办法了。
3.3. CallSite
CallSite 是办法调用点,调用点中蕴含了办法句柄信息,通常 invokedynamic 指令所形容的内容会应用 CallSite 来链接,对于这块内容的介绍也能够在「Lambda 设计参考」找到。能够从调用点上获取 MethodHandle,代码如下所示:
CallSite callSite = new ConstantCallSite(methodHandle);
MethodHandle mh = callSite.getTarget();
3.4. invokedynamic
后面介绍了一些跟 Lambda 相干的 API,上面正式介绍 invokedynamic,先看上面这段代码对应的字节码:
// 源码局部
public class TestMain2 {
public void test() {final Date date = new Date();
Consumer<String> consumer = (String s) -> System.out.println(s + date.toString());
}
}
// 对应的局部字节码
Constant pool:
#4 = InvokeDynamic #0:#30 // #0(Ljava/util/Date;)Ljava/util/function/Consumer;
#5 = Fieldref #31.#32 // java/lang/System.out:Ljava/io/PrintStream;
#6 = Class #33 // java/lang/StringBuilder
#7 = Methodref #6.#23 // java/lang/StringBuilder.”<init>”:()V
#8 = Methodref #6.#34 // java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
#9 = Methodref #2.#35 // java/util/Date.toString:()Ljava/lang/String;
#10 = Methodref #6.#35 // java/lang/StringBuilder.toString:()Ljava/lang/String;
#11 = Methodref #36.#37 // java/io/PrintStream.println:(Ljava/lang/String;)V
#12 = Class #38 // cn/curious/asm/method_ref/TestMain2
#19 = Utf8 lambda$test$0
#20 = Utf8 (Ljava/util/Date;Ljava/lang/String;)V
#21 = Utf8 SourceFile
#22 = Utf8 TestMain2.java
#23 = NameAndType #14:#15 // “<init>”:()V
#24 = Utf8 java/util/Date
#25 = Utf8 BootstrapMethods
#26 = MethodHandle #6:#40 // invokestatic java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
#27 = MethodType #41 // (Ljava/lang/Object;)V
#28 = MethodHandle #6:#42 // invokestatic cn/curious/asm/method_ref/TestMain2.lambda$test$0:(Ljava/util/Date;Ljava/lang/String;)V
#29 = MethodType #43 // (Ljava/lang/String;)V
#30 = NameAndType #44:#45 // accept:(Ljava/util/Date;)Ljava/util/function/Consumer;
#31 = Class #46 // java/lang/System
#32 = NameAndType #47:#48 // out:Ljava/io/PrintStream;
#33 = Utf8 java/lang/StringBuilder
#34 = NameAndType #49:#50 // append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
#35 = NameAndType #51:#52 // toString:()Ljava/lang/String;
#36 = Class #53 // java/io/PrintStream
#37 = NameAndType #54:#43 // println:(Ljava/lang/String;)V
#38 = Utf8 cn/curious/asm/method_ref/TestMain2
#39 = Utf8 java/lang/Object
#40 = Methodref #55.#56 // java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
#41 = Utf8 (Ljava/lang/Object;)V
#42 = Methodref #12.#57 // cn/curious/asm/method_ref/TestMain2.lambda$test$0:(Ljava/util/Date;Ljava/lang/String;)V
#43 = Utf8 (Ljava/lang/String;)V
#44 = Utf8 accept
#45 = Utf8 (Ljava/util/Date;)Ljava/util/function/Consumer;=
{
public void test();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: new #2 // class java/util/Date
3: dup
4: invokespecial #3 // Method java/util/Date."<init>":()V
7: astore_1
8: aload_1
9: invokedynamic #4, 0 // InvokeDynamic #0:accept:(Ljava/util/Date;)Ljava/util/function/Consumer;
14: astore_2
15: return
private static void lambda$test$0(java.util.Date, java.lang.String);
descriptor: (Ljava/util/Date;Ljava/lang/String;)V
flags: ACC_PRIVATE, ACC_STATIC, ACC_SYNTHETIC
Code:
stack=3, locals=2, args_size=2
0: getstatic #5 // Field java/lang/System.out:Ljava/io/PrintStream;
3: new #6 // class java/lang/StringBuilder
6: dup
7: invokespecial #7 // Method java/lang/StringBuilder."<init>":()V
10: aload_1
11: invokevirtual #8 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
14: aload_0
15: invokevirtual #9 // Method java/util/Date.toString:()Ljava/lang/String;
18: invokevirtual #8 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
21: invokevirtual #10 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
24: invokevirtual #11 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
27: return
}
InnerClasses:
public static final #61= #60 of #64; //Lookup=class java/lang/invoke/MethodHandles$Lookup of class java/lang/invoke/MethodHandles
BootstrapMethods:
0: #26 invokestatic java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
Method arguments:
#27 (Ljava/lang/Object;)V
#28 invokestatic cn/curious/asm/method_ref/TestMain2.lambda$test$0:(Ljava/util/Date;Ljava/lang/String;)V
#29 (Ljava/lang/String;)V
下面是局部次要的字节码信息,能够看下要害代码:
首先能够看第 58 行的 invokedynamic 指令:invokedynamic #4, 0 // invokeDynamic #0(Ljava/util/Date;)Ljava/util/function/Consumer,其中,0 是预留字段,#4 示意的是常量池中的字段;
第 11 行 #4 = InvokeDynamic #0:#30 // #0(Ljava/util/Date;)Ljava/util/function/Consumer,这里的 #0 示意的是第一个疏导办法,如果有多个 Lambda 可能会有多个疏导办法。所谓的疏导办法指的是在执行 invokedynamic 指令时,该指令所指向的、须要去执行的 Java 办法,通常在执行疏导办法的时候会生成一些额定的类,例如后面介绍脱糖的时候 Java8.Logger 的实现类 Java8$1,这个类会在第一次执行疏导办法的时候生成,大家有趣味能够看一下疏导办法的源码;
第 83 行,这能够看到这个疏导办法是 LambdaMetafacotry.metafactory,此办法的定义如下:
public static CallSite metafactory(MethodHandles.Lookup caller,
String invokedName,
MethodType invokedType,
MethodType samMethodType,
MethodHandle implMethod,
MethodType instantiatedMethodType)
这个办法会返回一个 Callsite 调用点,调用点中包含了办法句柄信息,咱们当初来具体解释下这个办法的参数,其中前三个参数不须要关注,零碎会主动生成,次要是看前面三个参数:
samMethodType: 函数式接口中形象办法的签名形容信息,对于 MethodType 后面的章节有介绍,这里的办法签名是 Consumer#apply 的签名,因为泛型参数,泛型 T 对立被转换成 Object(注:这里的 sam 指的是 Single Abstract Method,大家能够了解为函数式接口);
implMethod: 是一个办法句柄,这个在后面也介绍了,办法句柄蕴含了具体须要执行的办法,从下面的字节码能够看到,这个办法句柄的内容是:#23 invokestatic cn/curious/asm/method_ref/TestMain2.lambda$main$0:(Ljava/lang/String;)V,意思是调用静态方法 lambda$main$0,在后面有介绍 Lambda 脱糖的时候咱们晓得,Lambda 会生成一个办法,此办法默认是暗藏的,如果想查看,能够应用 java 的 javap -p -v xxx.class 命令查看这个办法;
instantiatedMethodType: 是 samMethodType 的具体实现,源码传入的泛型类型是 String,所以这里就是 String。
接下来再看 invokedynamic 指令执行的前后代码:
public void test();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: new #2 // class java/util/Date
3: dup
4: invokespecial #3 // Method java/util/Date."<init>":()V
7: astore_1
8: aload_1
9: invokedynamic #4, 0 // InvokeDynamic #0:accept:(Ljava/util/Date;)Ljava/util/function/Consumer;
14: astore_2
15: return
从下面的指令能够看到,在执行 invokedynamic 指令的时候将创立的 Date 对象加载到栈顶,invokedynamic 指令对应的 accept:(Ljava/util/Date;)Ljava/util/function/Consumer; 中的 Date 就是动静参数,这个参数会增加在编译时生成的办法 lambda$test$0(java.util.Date, java.lang.String) 参数列表的后面。脱糖的具体规定在「Lambda 设计参考」中的 Lambda 办法体脱糖章节有具体的介绍。
4. 应用 ASM 实现
综合前三节的常识,咱们晓得办法句柄中蕴含了办法调用的信息,而且咱们也阐明了办法援用并不会生成一个 lambda$ 结尾的两头办法,同时咱们晓得 MethodHandle 蕴含了办法调用的信息。因而,如果要去 Hook Lambda 和办法援用,咱们能够创立一个新的 MethodHandle 替换原有的。具体做法是:咱们会生成一个新的办法,新的办法中会实现 invokedynamic 指令中形容的代码逻辑。而后创立新的 MethodHandle,将这个 MethodHandle 替换本来的 MethodHandle。
当初整体的思路和计划曾经有了,接下来就是应用 ASM 编写代码来实现,具体的实现如下:
public class MethodReferenceAdapter extends ClassNode {
private final AtomicInteger counter = new AtomicInteger(0);
private List<MethodNode> syntheticMethodList = new ArrayList<>();
public MethodReferenceAdapter(ClassVisitor classVisitor) {super(Opcodes.ASM7);
this.cv = classVisitor;
}
@Override
public void visitEnd() {super.visitEnd();
this.methods.forEach(methodNode -> {ListIterator<AbstractInsnNode> iterator = methodNode.instructions.iterator();
while (iterator.hasNext()) {AbstractInsnNode node = iterator.next();
if (node instanceof InvokeDynamicInsnNode) {InvokeDynamicInsnNode tmpNode = (InvokeDynamicInsnNode) node;
// 形如:(Ljava/util/Date;)Ljava/util/function/Consumer; 能够从 desc 中获取函数式接口,以及动静参数的内容。// 如果没有参数那么描述符的参数局部应该是空。String desc = tmpNode.desc;
Type descType = Type.getType(desc);
Type samBaseType = descType.getReturnType();
//sam 接口名
String samBase = samBaseType.getDescriptor();
//sam 办法名
String samMethodName = tmpNode.name;
Object[] bsmArgs = tmpNode.bsmArgs;
//sam 办法描述符
Type samMethodType = (Type) bsmArgs[0];
//sam 实现办法理论参数描述符
Type implMethodType = (Type) bsmArgs[2];
//sam name + desc,能够用来分别是否是须要 Hook 的 lambda 表达式
String bsmMethodNameAndDescriptor = samMethodName + samMethodType.getDescriptor();
// 两头办法的名称
String middleMethodName = "lambda$" + samMethodName + "$sa" + counter.incrementAndGet();
// 两头办法的描述符
String middleMethodDesc = "";
Type[] descArgTypes = descType.getArgumentTypes();
if (descArgTypes.length == 0) {middleMethodDesc = implMethodType.getDescriptor();
} else {
middleMethodDesc = "(";
for (Type tmpType : descArgTypes) {middleMethodDesc += tmpType.getDescriptor();
}
middleMethodDesc += implMethodType.getDescriptor().replace("(", "");
}
//INDY 本来的 handle,须要将此 handle 替换成新的 handle
Handle oldHandle = (Handle) bsmArgs[1];
Handle newHandle = new Handle(Opcodes.H_INVOKESTATIC, this.name, middleMethodName, middleMethodDesc, false);
InvokeDynamicInsnNode newDynamicNode = new InvokeDynamicInsnNode(tmpNode.name, tmpNode.desc, tmpNode.bsm, samMethodType, newHandle, implMethodType);
iterator.remove();
iterator.add(newDynamicNode);
generateMiddleMethod(oldHandle, middleMethodName, middleMethodDesc);
}
}
});
this.methods.addAll(syntheticMethodList);
accept(cv);
}
private void generateMiddleMethod(Handle oldHandle, String middleMethodName, String middleMethodDesc) {
// 开始对生成的办法中插入或者调用相应的代码
MethodNode methodNode = new MethodNode(Opcodes.ACC_PRIVATE | Opcodes.ACC_STATIC /*| Opcodes.ACC_SYNTHETIC*/,
middleMethodName, middleMethodDesc, null, null);
methodNode.visitCode();
// 此块 tag 具体能够参考: https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-6.html#jvms-6.5.invokedynamic
int accResult = oldHandle.getTag();
switch (accResult) {
case Opcodes.H_INVOKEINTERFACE:
accResult = Opcodes.INVOKEINTERFACE;
break;
case Opcodes.H_INVOKESPECIAL:
//private, this, super 等会调用
accResult = Opcodes.INVOKESPECIAL;
break;
case Opcodes.H_NEWINVOKESPECIAL:
//constructors
accResult = Opcodes.INVOKESPECIAL;
methodNode.visitTypeInsn(Opcodes.NEW, oldHandle.getOwner());
methodNode.visitInsn(Opcodes.DUP);
break;
case Opcodes.H_INVOKESTATIC:
accResult = Opcodes.INVOKESTATIC;
break;
case Opcodes.H_INVOKEVIRTUAL:
accResult = Opcodes.INVOKEVIRTUAL;
break;
}
Type middleMethodType = Type.getType(middleMethodDesc);
Type[] argumentsType = middleMethodType.getArgumentTypes();
if (argumentsType.length > 0) {
int loadIndex = 0;
for (Type tmpType : argumentsType) {int opcode = tmpType.getOpcode(Opcodes.ILOAD);
methodNode.visitVarInsn(opcode, loadIndex);
loadIndex += tmpType.getSize();}
}
methodNode.visitMethodInsn(accResult, oldHandle.getOwner(), oldHandle.getName(), oldHandle.getDesc(), false);
Type returnType = middleMethodType.getReturnType();
int returnOpcodes = returnType.getOpcode(Opcodes.IRETURN);
methodNode.visitInsn(returnOpcodes);
methodNode.visitEnd();
syntheticMethodList.add(methodNode);
}
}
咱们对后面介绍的示例中的 .class 文件应用 ASM 运行后输入的后果如下:
public class TestMain2 {
public TestMain2() {}
public void test() {Date var1 = new Date();
Consumer var2 = TestMain2::lambda$accept$sa1;
}
private static void lambda$accept$sa1(Date var0, String var1) {
//TODO 能够在此插桩
lambda$test$0(var0, var1);
}
}
其中,lambda$accept$sa1 是咱们应用 ASM 生成的办法。在这个办法中,咱们替换了本来的 lambda$test$0(此办法的 tag 是 acc_synthetic,示意代码是主动生成的,反编译默认不显示)办法,在咱们生成的办法中调用编译器生成的 lambda$test$0 办法。这里须要再揭示一下,办法援用并不会生成相似 lambda$test$0 这样的办法,咱们须要将办法援用的代码放在咱们生成的办法中,这个读者能够写一个办法援用测试一下后果。
至此,如果咱们想要对 Lambda 或者办法援用的代码进行插桩,只有在咱们生成的办法中插入即可。
5. 总结
整体的原理是:咱们本人生成一个两头办法,如果是 Lambda,那么咱们在办法中调用这个 Lambda 编译时生成的两头办法;如果是办法援用,就把办法援用里的内容放到咱们生成的两头办法中,而后将自定义的 MethodHandle 指向生成的办法;最初替换掉 Bootstrap Method 中的 MethodHandle,达到移花接木的成果。
不过,这种形式的弊病是会多生成一些两头办法。
至此,用了两篇文章介绍了 ASM Hook Lambda 和办法援用的常识,心愿对大家有所帮忙。
6. 参考资料
D8 & R8: https://developer.android.com…
Android’s Java 8 Support: https://jakewharton.com/andro…
JVM invokeydynamic instruction: https://docs.oracle.com/javas…
https://www.jianshu.com/p/d74…
https://developer.android.com…
https://www.infoq.com/news/20…
文章起源:公众号神策技术社区