flink类加载器原理与隔离(flink jar包冲突)

这篇具有很好参考价值的文章主要介绍了flink类加载器原理与隔离(flink jar包冲突)。希望对大家有所帮助。如果存在错误或未考虑完全的地方,请大家不吝赐教,您也可以点击"举报违法"按钮提交疑问。


本文是转载自袋鼠云公众号的文章
不知道大家有没有遇到过,flink发布任务遇到一些奇奇怪怪的报错,很奇怪的某个类就开始报错,一步一步点击去查看,发现不知道是哪个类包的那个类在报错,其实这种情况很有可能就是jar包版本冲突。
首先为大家介绍一下Java类加载器解决类冲突的基本思想。

Java 类加载器解决类冲突基本思想

什么是 Classpath?

Classpath是JVM用到的一个环境变量,它用来指示JVM如何搜索Class。
因为Java是编译型语言,源码文件是.java,而编译后的.class文件才是真正可以被JVM执行的字节码。因此,JVM需要知道,如果要加载一个com.dtstack.HelloWorld的类,应该去哪搜索对应的HelloWorld.class文件。

所以,Classpath就是一组目录的集合,它设置的搜索路径与操作系统相关,例如:
在Windows系统上,用;分隔,带空格的目录用""括起来,可能长这样:
C:****
在MacOS & Linux系统上,用:分隔,可能长这样:
/usr*****

启动JVM时设置Classpath变量, 实际上就是给java命令传入-Classpath或-cp参数.
java -Classpath .;/Users/lzq/Java/a;/Users/lzq/Java/b com.dtstack.HelloWorld
没有设置系统环境变量,也没有传入-cp参数,那么JVM默认的Classpath为,即当前目录:
java com.dtstack.HelloWorld

Jar 包中的类什么时候被加载?

flink类加载器原理与隔离(flink jar包冲突),flink技术原理,flink,jar,大数据

  • jar包准备
    Jar 包就是 zip 包,只不过后缀名字不同。用于管理分散的 .class 类。
    生成 jar 包可以用 zip 命令 zip -r ChunJun.zip ChunJun
    java -cp ./ChunJun.zip com.dtstack.HelloWorld
  • 加载
    “加载”(Loading) 阶段是整个“类加载”(Class Loading) 过程中的一个阶段,希望读者没有混淆这两个看起来很相似的名词。在加载阶段,Java虚 拟机需要完成以下三件事情:
    1.通过一个类的全限定名来获取定义此类的二进制字节流;
    2.将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;
    3.在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
  • 解析
    类或接口的解析
    假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,那虚拟机完成整个解析的过程需要包括以下3个步骤:
    1.如果C不是一个数组类型,那虚拟机将会把代表N的全限定名传递给D的类加载器去加载这个类C。
    在加载过程中,由于元数据验证、字节码验证的需要,又可能触发其他相关类的加载动作,例如加载这个类的父类或实现的接口。一旦这个加载过程出现了任何异常,解析过程就将宣告失败。
    2.如果C是一个数组类型,并且数组的元素类型为对象,也就是N的描述符会是类
    似“[Ljava/lang/Integer的形式,那将会按照第一点的规则加载数组元素类型。
    如果N的描述符如前面所假设的形式,需要加载的元素类型就是“java.lang.Integer",接着由虚拟机生成一个代表该数组维度和元素的数组对象。
    3.如果上面两步没有出现任何异常,那么C在虚拟机中实际上已经成为一个有效的类或接口了,但在解析完成前还要进行符号引用验证,确认D是否具备对C的访问权限。如果发现不具备访问权限,将抛出java.lang,llegalAccessEror异常。

哪些行为会触发类的加载?

关于在什么情况下需要开始类加载过程的第一个阶段“加载”,《Java虚拟机规范》中并没有进行 强制约束,这点可以交给虚拟机的具体实现来自由把握。但是对于初始化阶段,《Java虚拟机规范》 则是严格规定了有且只有六种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之 前开始)
flink类加载器原理与隔离(flink jar包冲突),flink技术原理,flink,jar,大数据 - 场景一
遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类型没有进行过初始 化,则需要先触发其初始化阶段。能够生成这四条指令的典型Java代码场景有:
1.使用new关键字实例化对象的时候。
2.读取或设置一个类型的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外) 的时候。
3.调用一个类型的静态方法的时候。

  • 场景二
    使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需 要先触发其初始化。
  • 场景三
    当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
  • 场景四
    当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先 初始化这个主类
  • 场景五
    当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
  • 场景六
    当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。
    对于以上这六种会触发类型进行初始化的场景,《Java虚拟机规范》中使用了一个非常强烈的限定语 ——“有且只有”,这六种场景中的行为称为对一个类型进行主动引用。除此之外,所有引用类型的方 式都不会触发初始化,称为被动引用。

什么是双亲委派机制?

双亲委派机制,是按照加载器的层级关系,逐层进行委派,例如下图中的自定义类加载器想要加载类,它首先不会想要自己去加载,它会通过层级关系逐层进行委派,从自定义类加载器 -> App ClassLoader -> Ext ClassLoader -> BootStrap ClassLoader,如果在BootStrap ClassLoader中没有找到想要加载的类,又会逆循环加载。
flink类加载器原理与隔离(flink jar包冲突),flink技术原理,flink,jar,大数据

如何打破双亲委派机制?

那么如何打破双亲委派机制呢?其实可以通过重写 loadclass 方法来实现,具体过程大家可通过更多学习了解,这里就不过多赘述。
flink类加载器原理与隔离(flink jar包冲突),flink技术原理,flink,jar,大数据
flink类加载器原理与隔离(flink jar包冲突),flink技术原理,flink,jar,大数据

Flink 类加载隔离的方案

接下来我们来介绍下Flink 类加载隔离的方案,Flink有两种类加载器Parent-First和Child-First,他们的区别是:

  • Parent-First:
    类似 Java 中的双亲委派的类加载机制。Parent First ClassLoader 实际的逻辑就是一个 URL ClassLoader。

  • Child-First:
    先用 classloader.parent-first-patterns.default 和 classloader.parent-first-patterns.additional 拼接的list做匹配,如果类名前缀匹配了,先走双亲委派。否则就用 ChildFirstClassLoader 先加载。

  • Child-First 存在的问题:
    每次新 new 一个 ChildFirstClassLoader,如果运行时间久的话,类似 Session 这种 TaskManager 一直不关闭的情况。任务运行多次以后,会出现元数据空间爆掉,导致任务失败。

  • Child-First 加载原理:flink类加载器原理与隔离(flink jar包冲突),flink技术原理,flink,jar,大数据
    flink类加载器原理与隔离(flink jar包冲突),flink技术原理,flink,jar,大数据
    flink类加载器原理与隔离(flink jar包冲突),flink技术原理,flink,jar,大数据

Flink是如何避免类泄露的?

大家可以参考Flink中的jira,这里面包含一些bug和处理方法:
https://issues.a@pache.org/jira/br@owse/FLINK-16245
https://issues.a@pache.org/jira/br@owse/FLINK-11205

Flink如何避免类泄露,主要是通过以下两种方法:

  1. 增加一层委派类加载器,将真正的 UserClassloader 包裹起来。

  2. 增加一个回调钩子,当任务结束的时候可以提供给用户一个接口,去释放未释放的资源。
    KinesisProducer 使用了这个钩子

final RuntimeContext ctx = getRuntimeContext();
ctx.registerUserCodeClassLoaderReleaseHookIfAbsent(
  KINESIS_PRODUCER_RELEASE_HOOK_NAME,
  ()-> this.runClassLoaderReleaseHook
  (ctx.getUserCodeClassLoader()));

Flink 卸载用户代码中动态加载的类

卸载用户代码中动态加载的类,所有涉及动态用户代码类加载(会话)的场景都依赖于再次卸载的类。

类卸载指垃圾回收器发现一个类的对象不再被引用,这时会对该类(相关代码、静态变量、元数据等)进行移除。

当TaskManager启动或重启任务时会加载指定任务的代码,除非这些类可以卸载,否则就有可能引起内存泄露,因为更新新版本的类可能会随着时间不断的被加载积累。这种现象经常会引起OutOfMemoryError: Metaspace这种典型异常。

类泄漏的常见原因和建议的修复方式:
● Lingering Threads
确保应用代码的函数/sources/sink关闭了所有线程。延迟关闭的线程不仅自身消耗资源,同时会因为占据对象引用,从而阻止垃圾回收和类的卸载。

● Interners
避免缓存超出function/sources/sinks生命周期的特殊结构中的对象。比如Guava的Interner,或是Avro的序列化器中的类或对象。

● JDBC
JDBC驱动会在用户类加载器之外泄漏引用。为了确保这些类只被加载一次,可以将驱动JAR包放在Flink的 lib/ 目录下,或者将驱动类通过 classloader-parent-first-patterns-additional 加到父级优先加载类的列表中。

释放用户代码类加载器的钩子(hook)可以帮助卸载动态加载的类,这种钩子在类加载器卸载前执行,通常情况下最好把关闭和卸载资源作为正常函数生命周期操作的一部分(比如典型的close()方法)。有些情况下(比如静态字段)最好确定类加载器不再需要后就立即卸载。

释放类加载器的钩子可以通过
RuntimeContext.registerUserCodeClassLoaderReleaseHookIfAbsent()方法进行注册。

Flink 卸载 Classloader 源码

BlobLibraryCacheManager$ResolvedClassLoader

private void runReleaseHooks() {
    Set<map.entry> hooks = releaseHooks.entrySet();
    if (!hooks.isEmpty()) {
        for (Map.EntryhookEntry : hooks) {
            try {
                LOG.debug("Running class loader shutdown hook: {}.", hookEntry.getKey());
                hookEntry.getValue().run();
            } catch (Throwable t) {
                LOG.warn(
                        "Failed to run release hook '{}' for user code class loader.",
                        hookEntry.getValue(),
                        t);
            }
        }
        releaseHooks.clear();
    }
}

flinkx 如何实现类加载隔离

Flink jar 的上传时机

首先我们需要上传Jar包,整体流程如下图所示:

flink类加载器原理与隔离(flink jar包冲突),flink技术原理,flink,jar,大数据
● Yarn Perjob
提交任务的时候上传 jar 包,会放到
hd@fs://flink03:9000/@user/root/.flink/@application_1654762357754_0140。

● Yarn Session
启动 Session 的时候,Yarn 的 App 上传 Jar 包机制,往 Session 提交任务的时候,Flink 的 Blob Server 负责收。

Yarn 的分布式缓存

flink类加载器原理与隔离(flink jar包冲突),flink技术原理,flink,jar,大数据
分布式缓存机制是由各个NM实现的,主要功能是将应用程序所需的文件资源缓存到本地,以便后续任务的使用。资源缓存是用时触发的,也就是第一个用到该资源的任务触发,后续任务无需再进行缓存,直接使用即可。

根据资源类型和资源可见性,NM可将资源分成不同类型:
● 资源可见性分类
● Public
节点上所有的用户都可以共享该资源,只要有一个用户的应用程序将着这些资源缓存到本地,其他所有用户的所有应用程序都可以使用。

● Private
节点上同一用户的所有应用程序共享该资源,只要该用户其中一个应用程序将资源缓存到本地,该用户的所有应用程序都可以使用。

● Application
节点上同一应用程序的所有Container共享该资源

● 资源类型分类
● Archive
归档文件,支持.jar、.zip、.tar.gz、.tgz、.tar的5种归档文件。
● File
普通文件,NM只是将这类文件下载到本地目录,不做任何处理

● Pattern
以上两种文件的混合体

YARN是通过比较resource、type、timestamp和pattern四个字段是否相同来判断两个资源请求是否相同的。如果一个已经被缓存到各个节点上的文件被用户修改了,则下次使用时会自动触发一次缓存更新,以重新从HDFS上下载文件。

分布式缓存完成的主要功能是文件下载,涉及大量的磁盘读写,因此整个过程采用了异步并发模型加快文件下载速度,以避免同步模型带来的性能开销。

NodeManager采用轮询的分配策略将这三类资源存放在yarn.nodemanager.local-dirs指定的目录列表中,在每个目录中,资源按照以下方式存放:
● Public资源
存放在${yarn.nodemanager.local-dirs}/filecache/目录下,每个资源将单独存放在以一个随机整数命名的目录中,且目录的访问权限均为0755。

● Private资源
存放在 y a r n . n o d e m a n a g e r . l o c a l − d i r s / u s e r c a c h e / {yarn.nodemanager.local-dirs}/usercache/ yarn.nodemanager.localdirs/usercache/{user}/filecache/目录下,(其中${user}是应用程序提交者,默认情况下均为NodeManager启动者),每个资源将单独存放在以一个随机整数命名的目录中,且目录的访问权限均为0710。

● Application资源
存放在 y a r n . n o d e m a n a g e r . l o c a l − d i r s / u s e r c a c h e / {yarn.nodemanager.local-dirs}/usercache/ yarn.nodemanager.localdirs/usercache/{user}/ a p p c a c h e / {appcache}/ appcache/{appid}/filecache/目录下(其中${appid}是应用程序ID),每个资源将单独存放在以一个随机整数命名的目录中,且目录的访问权限均为0710;

其中Container的工作目录位于 y a r n . n o d e m a n a g e r . l o c a l − d i r s / u s e r c a c h e / {yarn.nodemanager.local-dirs}/usercache/ yarn.nodemanager.localdirs/usercache/{user}/ a p p c a c h e / {appcache}/ appcache/{appid}/${containerid}目录下,其主要保存jar包文件、字典文件对应的软链接。
flink类加载器原理与隔离(flink jar包冲突),flink技术原理,flink,jar,大数据

Flink BlobServer

flink类加载器原理与隔离(flink jar包冲突),flink技术原理,flink,jar,大数据

如何快速提交,减少上传 jar 包

Flink libs 下面 jar包、Flink Plugins 下面的 jar 包、Flink 任务的 jar 包(对于 ChunJun 来说就是所有 connector 和 core), Flink jar 用户自定义 jar 包。
● Perjob
如果可以提前上传到 HDFS:

  1. 提前把 Flink lib 、Flink plugins、ChunJun jar 上传到 HDFS 上面。
  2. 提交任务的时候通过 yarn.provided.lib.dirs 指定 HDFS 上面的路径即可。

如果不可以提前上传到 HDFS:

  1. 任务提交上传到 HDFS 固定位置,提交的时候检查 HDFS 上如果有对应的 jar(有缓存策略),就把本地路径替换成远程路径。
  2. 利用回调钩子,清楚异常任务结束的垃圾文件。

● Seeion
如果可以提前上传到 HDFS:

  1. 提前把 Flink lib 、Flink plugins、ChunJun jar 上传到 HDFS 上面。
  2. 启动 session 的时候通过 yarn.provided.lib.dirs 指定 HDFS 上面的路径即可。
  3. 提交任务的时候不需要上传 core 包。

如果不可以提前上传到 HDFS:

  1. Session 启动的时候就上传所有 jar 到 HDFS 上面。通过 yarnship 指定。
  2. Flink 任务提交到 Session 的时候,不需要提交任何 jar 包。flink类加载器原理与隔离(flink jar包冲突),flink技术原理,flink,jar,大数据

类加载隔离遇到的问题分析

● 思路分析

  1. 首先要把不同插件(connector) 放到不同的 Classloader 里面。
  2. 然后使用 child-first 的加载策略。
  3. 确保不会发生 x not cast x 错误。
  4. 元数据空间不会内存泄露,导致任务报错。
  5. 要缓存 connector jar 包。

● 遇到的问题

  1. Flink 一个 job 可能有多个算子,一个 connector 就是一个算子。Flink 原生是为 job 级别新生成的 Classloader,无法把每个 connector 放在一个独立的 Classloader 里面。

  2. child-first 加载策略在 Session 模式下每次都新 new 一个 Classloader,导致元数据空间内存泄露。

  3. connecotor 之间用到公有的类会报错。

  4. 和问题2类似,主要是因为有些线程池,守护线程会拿着一些类对象,或者类 class 对象的引用。

  5. 如果用原生 -yarnship 去上传,会放到 App Classloader 里面。那么就会导致某些不期望用 App Classloader 加载的类被加载。flink类加载器原理与隔离(flink jar包冲突),flink技术原理,flink,jar,大数据

Flink JobGraph Classpath 的使用

/** Set of JAR files required to run this job. */
private final ListuserJars = new ArrayList();

/** Set of custom files required to run this job. */
private final MapuserArtifacts = new HashMap<>();

/** List of Classpaths required to run this job. */
private ListClasspaths = Collections.emptyList();
  1. 客户端处理,JobGraph 处理 userJars、userArtifacts、Classpaths 这三个属性。
  2. Classpath 只留下 connector 的层级目录。
  3. 启动 Session 的时候上传 jar,jar 缓存在 Yarn 的所有的 NodeManager 节点。
  4. jobmanager 和 taskmanager 构建 Classloader 的时候去修改 Classpath 的路径,替换成当前节点 NodeManager 的缓存路径。
  5. 根据不同 connecotr 去构建Flink Job 的 Classloader。
  6. 把构建出来的 classlaoder 进行缓存,下次任务还有相同的 Classloader。避免内存泄露。
  7. 重写新的 ChildFirstCacheClassloader 里面的 loadclass 方法,根据不同的 connector url 去生成 单独的 Classloader。

遇到的问题和排查方案?

jar包冲突常见的异常为找不到类(java.lang.ClassNotFoundException)、找不到具体方法(java.lang.NoSuchMethodError)、字段错误( java.lang.NoSuchFieldError)或者类错误(java.lang.LinkageError)。

● 常见的解决方法如下
1、首先做法是打出工程文件的依赖树,将根据jar包依赖情况判定是不是同一个jar包依赖了多个版本,如果确认问题所在,直接exclusion其中错误的jar包即可。

2、如果通过看依赖树不能确定具体冲突的jar包,可以使用添加jvm参数的方式启动程序,将类加载的具体jar信息打印出来;-verbose:class 。

3、经过上述步骤基本就可以解决jar包冲突问题,具体的问题要具体分析。

● 常用工具推荐
1.Maven-helper
主要排查类冲突的 IDEA 插件。
亲测,真的嘎嘎好用,我几乎99%的jar包冲突都能解决,
flink类加载器原理与隔离(flink jar包冲突),flink技术原理,flink,jar,大数据

2.Jstack
死锁的一些问题可以通过这个工具查看 jstack 调用栈。

3.Arthas
排查一些性能问题和 Classloader 泄露问题。

4.VisualVM
排查一些对象内存泄露、dump 文件分析等。文章来源地址https://www.toymoban.com/news/detail-837020.html

到了这里,关于flink类加载器原理与隔离(flink jar包冲突)的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请点击违法举报进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用

相关文章

  • 哪些设备可以隔离冲突域哪些可以隔离广播域,哪些设备什么都无法隔离

    在计算机网络中,有两个概念与网络隔离相关:冲突域和广播域。冲突域表示一个物理网络中共享相同带宽的设备集合,而广播域是指网络中一个广播消息(如ARP请求)传播的范围。以下是一些设备和技术,它们对冲突域和广播域的隔离具有影响: 隔离冲突域的设备和技术:

    2024年02月05日
    浏览(36)
  • 【数据库】事务的隔离级别以及实现原理

    经常提到数据库的事务,那你知道数据库还有事务隔离的说法吗,事务隔离还有隔离级别,那什么是事务隔离,隔离级别又是什么呢?本文就帮大家梳理一下。 事务,由一个有限的数据库操作序列构成,这些操作要么全部执行,要么全部不执行,是一个不可分割的工作单位。

    2023年04月26日
    浏览(40)
  • 解决华为手机驱动与Win11的内核隔离冲突问题

    将华为手机通过USB数据线连接电脑时,会安装相应的驱动,这部分驱动会引起Win11提示内存隔离冲突,可能会影响系统更新或一些软件更新安装。 通过Win11系统自带的 pnputil 工具写在相应的区间,指令范例: 1、通过 win+R 运行cmd 2、以管理员身份运行命令提示符 执行下述指令

    2024年04月26日
    浏览(46)
  • Sermant类隔离架构:解决JavaAgent场景类冲突的实践

    本文分享自华为云社区《Sermant类隔离架构解析——解决JavaAgent场景类冲突的实践》,作者:华为云开源。 Sermant是基于Java字节码增强技术的无代理服务网格,其利用Java字节码增强技术为宿主应用程序提供服务治理功能。因深知JavaAgent场景中类冲突问题会造成的影响,Sermant在

    2024年02月09日
    浏览(43)
  • 【Flink技术原理构造及特性】

    Flink是一个批处理和流处理结合的统一计算框架,其核心是一个提供了数据分发以及并行化计算的流数据处理引擎。它的最大亮点是流处理,是业界最顶级的开源流处理引擎。 Flink最适合的应用场景是低时延的数据处理(Data Processing)场景:高并发pipeline处理数据,时延毫秒级

    2024年04月10日
    浏览(35)
  • Flink 定时加载数据源

    一、简介 flink 自定义实时数据源使用流处理比较简单,比如 Kafka、MQ 等,如果使用 MySQL、redis 批处理也比较简单 如果需要定时加载数据作为 flink 数据源使用流处理,比如定时从 mysql 或者 redis 获取一批数据,传入 flink 做处理,如下简单实现 二、pom.xml 文件 注意 flink 好多包

    2024年02月05日
    浏览(34)
  • 深入理解 Flutter 图片加载原理 | 京东云技术团队

    随着Flutter稳定版本逐步迭代更新,京东APP内部的Flutter业务也日益增多,Flutter开发为我们提供了高效的开发环境、优秀的跨平台适配、丰富的功能组件及动画、接近原生的交互体验,但随之也带来了一些OOM问题,通过线上监控信息和Observatory工具结合分析我们发现问题的原因

    2024年02月12日
    浏览(35)
  • 交换机,路由器, 虚拟局域网是如何隔离冲突域和广播域的

    1. 交换机(switch): 隔离冲突域: 交换机在数据链路层工作,每个交换机端口都是一个独立的冲突域。这意味着每个设备连接到交换机的端口上都有其独立的通信媒体,不与其他设备共享。当一个设备向交换机发送数据时,交换机只将数据转发到目标设备的端口,而不是广

    2024年01月17日
    浏览(50)
  • jar包冲突解决方案

    使用背景 在构建工程中,不可避免的引入多方依赖。从jar包冲突产生结果可大致分为两类: 1.同一个jar包出现了多个不同的版本。应用选择了错误的版本导致jvm加载不到需要的类或者加载了错误版本的类。 2.不同的jar包出现了类路径一致的类,同样的类出现在多个不同的依赖

    2024年02月09日
    浏览(47)
  • -jar和 javaagent命令冲突吗?

    当使用 -jar 命令运行 Java 应用程序时,Java 虚拟机 (JVM) 会忽略任何设置的 -javaagent 命令。这是因为 -jar 命令会覆盖其他命令行选项,包括 -javaagent 。 这是因为 -jar 命令是用于运行打包为 JAR 文件的 Java 应用程序的快捷方式。它会忽略其他命令行选项并直接运行 JAR 文件的主类

    2024年02月15日
    浏览(37)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包