URL
date
AI summary
slug
status
tags
summary
type
 
canal-adapter是基于插件式架构来设计的。我们会从两个维度去分析:SPI机制和构建流程
SPI机制包含了核心SPI的抽象设计以及加载SPI实现类的核心流程。而构建流程主要就是为了把canal-adapter的多插件的项目按照既定的目录规则打包成1个可执行的应用。下面我们先来看看SPI机制

SPI机制

核心接口

首先,canal-adapter定义了一个接口OuterAdapter
notion image
OuterAdapter里的方法可以分成3大类:
  1. 生命周期相关的,init和destory,分别在初始化和销毁的时候调用
  1. 数据同步核心接口,sync,是在每次监听到binlog消息时调用,从接口的入参能看出来是批量的
  1. etl相关接口,这块主要是用来手动触发数据同步的

加载流程

我们在之前分析启动流程的文章中简化了loadAdapter的流程,这里重点来看一下
notion image
loadAdapter总共分成4个部分:
  1. 根据name和key来加载对应的adapter类,并实例化
  1. 根据容器内的环境变量构造properties,提供给adapter的初始化方法使用
  1. adapter的初始化(init)
  1. 把adapter加到canalOutConnectiors列表,这个列表保存了该消费组内的所有adapter实例
其中name就是es6/es7/rds等adapter的类型,而key则是我们自定义的标识。两者组合到一起,构成adapter实例的缓存key(缓存在loader里)。加载完OuterAdapter实现类之后,首先会实例化,然后初始化,初始化的过程中需要用到容器中的环境变量。
下面我们重点看看第一部分:loader.getExtension(name, key)
notion image
没有什么逻辑,就维护了一张Map——cacheInstances,用来缓存adapter实例。缓存键是由name和key组合而成。从这里也不难看出,同样的name和key,会获取到同一个实例。你有没有发现,缓存里并没有直接存adapter实例,而是存了一个holder对象,这应该是专门用来作为锁对象的(不然就只能锁一个更大的范围)
抛开缓存的维护,关键代码还是在createExtension(name, key)里:
notion image
这里看起来又是一层缓存,不过前面getExtension不是已经有缓存了吗?并且维度都是name和key。不知道是怎么考虑的,我感觉重复了。不过不影响主流程,我们忽略,继续跟踪getExtensionClasses()
notion image
这里也是用了一个缓存Map + Holder的小技巧(cachedClasses是个holder对象),没什么逻辑,我们继续跟踪loadExtensionClasses()
notion image
上面这段代码就是去加载SPI实现类的核心代码了。可以看到首先要找到plugin目录,这个目录的获取会分两种情况。一种是打成release包运行的时候,启动类是在jar包内。还有一种是本地运行,启动类不在jar包内。这段代码(包含getJarDirectoryPath()方法)就是为了让两种情况都能找到正确的plugin目录。
plugin目录里躺着的都是以jar-with-dependencies结尾的jar包。每个jar包对应1种adapter插件,并且jar包里面包含了对应插件类以及其所有的依赖。
notion image
定位到plugins目录之后,canal-adapter会为其下的每个jar包创建单独的类加载器,并在特定的2个目录 SERVICES_DIRECTORY(META-INF/services/)CANAL_DIRECTORY(META-INF/canal/)下加载com.alibaba.otter.canal.client.adapter.OuterAdapter文件。我们来看看具体是怎么loadFile
notion image
上面的代码虽然比较长,但是逻辑很简单:
  1. 加载com.alibaba.otter.canal.client.adapter.OuterAdapter文件
  1. 读取每个加载到的资源文件,按照key=value的格式解析。key为插件名称,value为OuterAdapter实现类的全限定类名
  1. 用独立的classloader读取全限定类名对应的class文件
  1. 把2中读取到的key和3中读取到的class作为k-v放到extensionClasses里
后续需要加载OuterAdapter实现类都从extensionClasses这个缓存Map里取。
不过图中标红的代码有点诡异,是想要把有带参构造器的实现类给过滤掉?但是感觉意图不是太明确,不过好在我看当前实现的OuterAdapter都是只有无参构造器。
组装完extensionClasses之后,回到createExtension方法。这里我们就可以根据本次调用传入的name(也就是对应OuterAdapter的类型)从extensionClasses获取到对应的class,然后调用它的无参构造器实例化一个对象,并以name+key为key放到缓存中:
notion image
至此,loadAdapter的过程就结束了。我们来总结一下:
  1. 在第一个OuterAdapter需要被加载的时候会去plugin目录下加载所有OuterAdapter实现类并缓存
    1. 扫描plugin目录下的所有jar包
    2. 为每个jar包创建独立的类加载器
    3. 用2中创建的类加载器加载jar包里的com.alibaba.otter.canal.client.adapter.OuterAdapter文件
    4. 解析文件里的key、value。key为OuterAdapter的标识,value为实现类的全限定类名
    5. 用2中创建的加载器加载上一步的全限定类名
    6. 以4中的key为缓存键,5中加载的class为value,put到缓存map中
  1. 根据此次需要加载的OuterAdapter的name,去缓存map中读取对应的class
  1. 调用其无参构造器实例化OuterAdapter对象
  1. 缓存这个OuterAdapter对象,缓存键为name+key

自定义类加载器

我们再来看看自定义的类加载的逻辑
notion image
其实没什么特别的,主要的几个改动点
  1. 先说图中2这一点,这也是这个自定义类加载器最重要的一点,不遵循双亲委派类加载模式。它是优先使用自身加载类,而不是优先委派给parent加载
  1. 再来看图中1这一点,正是因为1违背了双亲委派的模式,所以才有了这一段代码。相当于增加了一个走双亲委派模式的白名单。其实没有这一段代码程序也可以正常运行。那么为什么要加这一段呢?个人猜想应该是为了避免内存里加载大量的重复的class,节省内存空间吧。
  1. 加载资源文件也只走自身加载,而不走parent加载
这里为每个jar包创建自己的类加载器的主要目的应该有2个:
  1. 做类隔离
  1. 非白名单的类,优先走自身加载

构建流程

说完了SPI机制,我们再来看看打包构建的流程。其实构建流程主要也是为了前面loadAdapter而服务的。比如前面我们看到的plugin目录,这个目录是怎么来的呢?它就是通过构建流程来生成的。
notion image
我们回顾一下canal-adapter整个项目的结构:
notion image
其中框出来的都是插件包,最终会被打成jar-with-dependencies包,然后被放到plugin目录下。而launcher模块则是一切打包流程的编排者,我们来看看究竟是怎么做到的

插件项目打“fat jar”——jar-with-dependencies

插件项目用到了maven-assembly-plugin,主要用到了jar-with-dependencies功能。它可以将项目打包为一个包含所有依赖项的可执行的JAR文件。这样,您就可以将这个可执行的JAR文件直接部署到目标环境中,而无需担心缺少依赖项的问题。
使用jar-with-dependencies装配描述符的好处是,它提供了一个方便的方式来创建一个自包含的、可独立运行的JAR文件,使您的应用程序更易于部署和分发。
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-assembly-plugin</artifactId> <version>2.4</version> <configuration> <descriptorRefs> <descriptorRef>jar-with-dependencies</descriptorRef> </descriptorRefs> </configuration> <executions> <execution> <id>make-assembly</id> <phase>package</phase> <goals> <goal>single</goal> </goals> </execution> </executions> </plugin>

插件项目配置文件copy到launcher项目classpath下

这个插件也是配置在插件项目里的,主要是把配置的各插件的adapter config拷贝到launcher项目的classpath下。主要是针对本地运行模式且使用本地配置文件且在launcher里没有配置把配置文件copy到conf目录下的方式
当然我们做文件copy最常用的应该是maven-resources-plugin,用maven-resources-plugin同样能达到此插件的效果。
<plugin> <artifactId>maven-antrun-plugin</artifactId> <executions> <execution> <phase>package</phase> <goals> <goal>run</goal> </goals> <configuration> <tasks> <copy todir="${project.basedir}/../launcher/target/classes/es6" overwrite="true"> <fileset dir="${project.basedir}/target/classes/es6" erroronmissingdir="true"> <include name="*.yml" /> </fileset> </copy> </tasks> </configuration> </execution> </executions> </plugin>

launcher项目打包目录编排

<build> <plugins> <plugin> <artifactId>maven-assembly-plugin</artifactId> <configuration> <descriptors> <descriptor>${basedir}/src/main/assembly/dev.xml</descriptor> </descriptors> <finalName>canal-adapter</finalName> <outputDirectory>${project.build.directory}</outputDirectory> </configuration> </plugin> </plugins> </build>
用的还是maven-assembly-plugin插件,绑定到了package生命周期上。不过这次是通过一个外部配置文件来编排整个目录结构的。上面这一段还定义了输出目录为target,finalName为canal-adapter,整个编排会创建1个基于这个finalName的目录,然后所有编排的文件都会输出到这个目录下。
外部配置文件根据profile的不同,分成2个:dev.xml和release.xml。两者的主要区别是
  1. 输出的目录不一样(1个在launcher项目下的target目录,1个在根项目的target目录)
  1. 打包的format不一样,release是打包成压缩包
这两种模式,其实也对应了canal-adapter支持的两种运行模式:
  1. 第一种我们称之为是开发模式,也就是直接运行launcher模块下CanalAdapterApplication类的main方法启动
  1. 另外一种我们称之为生产模式,这种模式是通过下载压缩包然后解压之后通过canal-adapter提供的startup.sh脚本启动
我们以dev.xml为例来看看里面相关的配置:
<assembly xmlns="<http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.0>" xmlns:xsi="<http://www.w3.org/2001/XMLSchema-instance>" xsi:schemaLocation="<http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.0> <http://maven.apache.org/xsd/assembly-1.1.0.xsd>"> <id>dist</id> <formats> <format>dir</format> </formats> <includeBaseDirectory>false</includeBaseDirectory> <!-- 下面就是具体的目录编排 --> <fileSets> <fileSet> <directory>.</directory> <outputDirectory>/</outputDirectory> <includes> <include>README*</include> </includes> </fileSet> <!-- bin 里面都是一些可执行文件 所以还对文件的mode做了一些特殊处理 --> <fileSet> <directory>./src/main/bin</directory> <outputDirectory>bin</outputDirectory> <includes> <include>**/*</include> </includes> <fileMode>0755</fileMode> </fileSet> <!-- conf 这里主要是从launcher里拷贝bootstrap.yml和application.yml --> <fileSet> <directory>./src/main/resources</directory> <outputDirectory>/conf</outputDirectory> <includes> <include>**/*</include> </includes> </fileSet> <!-- 下面这一坨是从各个插件项目里拷贝adapter config --> <fileSet> <directory>../es6x/src/main/resources/es6</directory> <outputDirectory>/conf/es6</outputDirectory> <includes> <include>**/*</include> </includes> </fileSet> <fileSet> <directory>../es7x/src/main/resources/es7</directory> <outputDirectory>/conf/es7</outputDirectory> <includes> <include>**/*</include> </includes> </fileSet> <fileSet> <directory>../hbase/src/main/resources/hbase</directory> <outputDirectory>/conf/hbase</outputDirectory> <includes> <include>**/*</include> </includes> </fileSet> <fileSet> <directory>../kudu/src/main/resources/kudu</directory> <outputDirectory>/conf/kudu</outputDirectory> <includes> <include>**/*</include> </includes> </fileSet> <fileSet> <directory>../rdb/src/main/resources/</directory> <outputDirectory>/conf</outputDirectory> <excludes> <exclude>META-INF/**</exclude> </excludes> </fileSet> <!-- 这个应该只是为了创建1个logs目录 应该是没有文件需要拷贝的 --> <fileSet> <directory>target</directory> <outputDirectory>logs</outputDirectory> <excludes> <exclude>**/*</exclude> </excludes> </fileSet> </fileSets> <!-- 这个是依赖的输出路径,输出到lib目录下 --> <dependencySets> <dependencySet> <outputDirectory>lib</outputDirectory> <excludes> <exclude>junit:junit</exclude> </excludes> </dependencySet> </dependencySets> </assembly>
这个dev编排文件做的事情比较清晰,我们来文字总结一下:
  1. 创建bin目录,并把launcher模块下./src/main/bin目录下的内容copy过去,里面主要是启停脚本
  1. 创建conf目录,并把launcher模块下./src/main/resources目录下的内容直接copy过去
  1. 把es6、es7、hbase、kudu、rdb这几个插件包下的/src/main/resources/xxx带目录copy到conf目录下
  1. 创建logs目录
有没有发现,前面在每个插件包里好像也有copy配置文件到launcher项目下这一环节?不过看仔细,前面是copy到classpath下,而这里是copy到conf目录下。这个就跟canal-adapter读取这些adapter config配置文件的机制有关系。我们直接上代码,就很容易理解了。
notion image
可以看到,canal-adapter是优先读取conf目录下的配置文件,读取不到才去读classpath。所以一般来说classpath下的配置文件是用不到的。

launcher项目plugin拷贝

<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-dependency-plugin</artifactId> <version>2.10</version> <executions> <execution> <id>copy-dependencies-to-canal-client-service</id> <phase>package</phase> <goals> <goal>copy-dependencies</goal> </goals> <configuration> <includeClassifiers>jar-with-dependencies</includeClassifiers> <outputDirectory>${project.basedir}/target/canal-adapter/plugin</outputDirectory> </configuration> </execution> </executions> </plugin>
这里没有使用静态文件拷贝的方式,而是用了依赖拷贝的方式。这种方式首先要把这些plugin包依赖进来。然后再定义上述的插件,指定jar-with-dependencies会被copy到指定的plugin目录
至此,相关的插件我们都介绍完了,相信到这里你应该对构建机制有一个相对清晰的了解了。我再总结一下
  1. 插件包里用到了2个插件
    1. maven-assembly-plugin:把插件项目打包成jar-with-dependencies的形式
    2. maven-antrun-plugin:把相关的配置文件copy到launcher的classpath下,当然我们更常用的是maven-resources-plugin来做静态文件copy的事情。
  1. launcher项目里也用到了2个插件
    1. maven-assembly-plugin:根据不同的profile(dev、release)读取外部文件去做构建的流程编排
      1. 创建bin目录,并把launcher模块下./src/main/bin目录下的内容copy过去,里面主要是启停脚本
      2. 创建conf目录,并把launcher模块下./src/main/resources目录下的内容直接copy过去
      3. 把es6、es7、hbase、kudu、rdb这几个插件包下的/src/main/resources/xxx带目录copy到conf目录下
      4. 创建logs目录
    2. maven-dependency-plugin:把所有依赖的插件包copy到plugin目录下
线上问题分析——maven循环依赖导致传递依赖失效问题线上问题分析——canal-adapter数据同步不全问题排查