liuyongfei

liuyongfei 查看完整档案

北京编辑  |  填写毕业院校  |  填写所在公司/组织 liuyongfei1.github.io/ 编辑
编辑

可以访问 这里 查看更多关于大数据平台建设的原创文章。

个人动态

liuyongfei 发布了文章 · 2020-12-09

webpack项目如何正确打包引入的自定义字体?

一. 如何在Vue或React项目中使用自定义字体

在开发前端项目时,经常会遇到UI同事希望在项目中使用一个炫酷字体的需求。那么怎么在项目中使用自定义字体呢?

其实实现起来并不复杂,可以借用CSS3 @font-face 来实现。

本文着重介绍一下 webpack 项目如何正确打包引入的自定义字体。

@font-face有什么用

总结一下就是:用户借助该规则,可以为引入的字体包命一个名字,并指定在哪里可以找到它(指定字体包的存储路径)后,就可以像使用通用字体那样去使用它了。

具体实现步骤

例如现在的需求是:需要在项目中使用 KlavikaMedium-Italic 字体。

则只需以下三个步骤即可。

1. 将字体包放入项目目录下

这里放到根目录下的 tool/fonts 文件夹里。

2. 在index.css文件中定义

@font-face {

 font-family: 'myFont';

 src: url(tool/fonts/KlavikaMedium-Italic.otf);

}

3. 使用自定义字体

新建一个index.vue文件,引入样式:

import './index.css'

<template>

<h1>使用自定义字体</h1>

<style>

 h1 {

 font-family: 'myFont'

 }

</style>

</template>

效果如下:

image

二. webpack项目如何正确打包自定义的字体

1. 打包时报错

既然在本地开发环境实现了效果,于是就使用 webpack 打包准备上线,却发现 webpack 在打包过程中报错:

image

2. 打包时为什么会报错

我们在定义自定义字体时使用URL指定了字体包的路径,由于 webpack 默认是无法处理 css 中的 url 地址的,因此这里会报错。

3. 解决报错

3.1 认识file-loader

这时就需要借助 loader 来大显身手了,解决这个问题需要使用 file-loader,它主要干了两件事儿:

  • 根据配置修改打包后图片、字体包的存放路径;
  • 再根据配置修改我们引用的路径,使之对应引入。

3.2 安装file-loader

yarn add file-loader

3.3 配置file-loader

在 webpack.config.js 中,配置file-loader:

module.exports = {

 module: {

 rules: [

 {

 // 命中字体包

 test: /.(woff2?|eot|ttf|otf)(?.*)?$/,

 // 只命中指定 目录下的文件,加快Webpack 搜索速度

 include: [paths.toolSrc],

 // 排除 node_modules 目录下的文件

 exclude: /(node_modules)/,

 loader: 'file-loader',

 },

 ]

 }

}

再次执行打包命令,不再报错。

4. 自定义字体为什么不生效

于是将打包出来的 dist 目录重新部署到服务器上后访问页面,却发现由于找不到字体导致没有生效:

image

从图中可以看出,http请求字体包的路径为:根目录下(打包出来的静态文件index.html所在目录)的 css/620db1b997cd78cd373003282ee4453f.otf

4.1 字体不生效的原因

看了一下打包命令生成的 dist 目录结构:

├── 620db1b997cd78cd373003282ee4453f.otf

├── css

│   ├── backend.66a35.css

│   └── backend.66a35.css.map

├── favicon.ico

├── images

│   ├── bg.5825f.svg

│   ├── data-baseTexture.c2963.jpg

│   ├── data-heightTexture.6f50d.jpg

│   └── logo.7227a.png

├── index.html

└── js

 ├── backend.66a35.js

却发现,字体包和 index.html 是在同一级。因此字体无法生效的原因就很明朗了:

  • 由于http请求的字体包路径与实际的存放路径一致,就导致了404;
  • 找不到字体包的实际路径,因此使用的字体无法生效。

4.2 字体不生效的解决办法

可以通过修改字体包打包后的实际存储路径去解决这个问题,在 webpack.config.js 中,借助 options 参数可以继续给 file-loader 设置更多的配置项:

module.exports = {

 module: {

 rules: [

 {

 // 命中字体包

 test: /.(woff2?|eot|ttf|otf)(?.*)?$/,

 // 只命中指定 目录下的文件,加快Webpack 搜索速度

 include: [paths.toolSrc],

 // 排除 node_modules 目录下的文件

 exclude: /(node_modules)/,

 loader: 'file-loader',

 // 新增options配置参数:关于file-loader的配置项

 options: {

 limit: 10000,

 // 定义打包完成后最终导出的文件路径

 outputPath: 'css/fonts/',

 // 文件的最终名称

 name: '[name].[hash:7].[ext]'

 }

 },

 ]

 }

}

再次打包,生成的 dist 目录结构如下:


├── css

│   ├── backend.66a35.css

│   ├── backend.66a35.css.map

│   └── fonts

│       └── KlavikaMedium-Italic.620db1b.otf

├── favicon.ico

├── images

│   ├── bg.5825f.svg

│   ├── data-baseTexture.c2963.jpg

│   ├── data-heightTexture.6f50d.jpg

│   └── logo.7227a.png

├── index.html

└── js

 ├── backend.66a35.js

可以看到字体包正如配置时预期的那样存储在 css/fonts 目录下面。

重新部署项目,再次查看:

这一次 http 请求的字体包路径与实际的存放路径一致,因此自定义字体生效。

可以通过下面这个梳理流程图看的更清楚一些:
image

三. 总结

为什么本地开发的时候可以看到字体,部署到服务器后却看不到了呢?

  • 由于 webpack 项目在本地开发中使用的是 webpack-dev-server,实时编译后的文件都保存到了内存当中,引用字体包的时候使用的是绝对路径,因此在本地开发中使用的自定义字体能够生效;
  • 使用webpack打包后的 dist 目录,字体包的实际存储路径与 http 请求字体包的路径不一致,因此导致找不到字体包;
  • 借助 file-loader 解决 webpack 打包报错,通过使用 options 参数去设置字体包在打包后的实际存储路径,从而解决问题。

四. 更多文章

欢迎访问更多关于webpack系列的原创文章:

关注微信公众号

欢迎大家关注我的微信公众号阅读更多原创文章:
微信公众号二维码.jpg

查看原文

赞 0 收藏 0 评论 0

liuyongfei 赞了回答 · 2020-09-27

解决vue 子组件emit没效果

handleTip需要在父组件显示的监听, <child @handleTip="handleTip"></child>

关注 3 回答 3

liuyongfei 发布了文章 · 2020-09-04

使用easyexcel时遇到Could not initialize class cglib.beans.BeanMap怎么解决

  1. 上一篇文章 Maven项目为什么会产生NoClassDefFoundError的jar包冲突 结合了大量的图解,详细介绍了Maven项目产生jar包冲突的原因,以及为什么在编译的时候不报错,在运行的时候会报错的场景分析;
  2. 本篇记录一下在项目开发中使用Alibaba的开源组件easyexcel做excel文件上传和下载功能时,遇到的一个jar包冲突问题的排查思路和解决办法。

一. 问题现象

在使用alibaba的easyexcel工具开发excel的上传和下载功能时,本机环境测试没有问题,部署到测试环境时,却发现下载excel文件的功能一直异常,查看后台服务报错日志如下:

nested exception is com.alibaba.excel.exception.ExcelGenerateException: java.lang.NoClassDefFoundError: Could not initialize class net.sf.cglib.beans.BeanMap$Generator] with root cause]
java.lang.NoClassDefFoundError: Could not initialize class net.sf.cglib.beans.BeanMap$Generator

上一篇Maven项目为什么会产生NoClassDefFoundError的jar包冲突时 已经介绍过,看到 NoClassDefFoundError类似的异常时,大多数都是因为jar包冲突引起的。

二. 问题排查流程

由于本地开发环境无法浮现,所以只能从测试环境着手排查。

1. 解压jar包

登录项目部署的docker容器,解压项目jar包,将解压后的文件放入 app 文件夹里:

[root@08e08117bd99 /]# cd /data/application/java
[root@08e08117bd99 /data/application/java]# unzip app.jar -d app/

2. 查找jar包里有没有cglib.beans.BeanMap类

进入解压后的 app 目录,查找 cglib 包:

[root@08e08117bd99 /data/application/java]# cd /app/BOOT-INF/lib
$ ls -l | grep cglib
-rw-r--r-- 1 root root  283080 Dec  7  2013 cglib-3.1.jar
# 继续解压cglib包
[root@08e08117bd99 /data/application/java/app/BOOT-INF/lib]# unzip -l cglib-3.1.jar | grep BeanMap
      336  12-07-2013 11:28   net/sf/cglib/beans/BeanMap$Generator$BeanMapKey.class
     3219  12-07-2013 11:28   net/sf/cglib/beans/BeanMap$Generator.class
     5008  12-07-2013 11:28   net/sf/cglib/beans/BeanMap.class
     1825  12-07-2013 11:28   net/sf/cglib/beans/BeanMapEmitter$1.class
     2090  12-07-2013 11:28   net/sf/cglib/beans/BeanMapEmitter$2.class
     1546  12-07-2013 11:28   net/sf/cglib/beans/BeanMapEmitter$3.class
     6339  12-07-2013 11:28   net/sf/cglib/beans/BeanMapEmitter.class

发现,解压后的 jar包里是存在 cglib.beans.BeanMap$Generator 这个类。

3. 继续看其它错误日志

继续看错误日志,发现这段:

class net.sf.cglib.core.DebuggingClassWriter has interface org.objectweb.asm.ClassVisitor as super class

大概意思是:

ClassVisitor 定义的是一个 interface,但是现在却作为一个父类(super class)被其它类继承了。

这种情况表明,此时应该存在jar包冲突了:

  • ClassVisitor 在一个jar包中是一个 interface;
  • 在另一个jar包中却是一个 class。

4. 从刚刚解压的项目jar包里查找asm包

由于这个报错信息里包含asm,所以尝试查找包含asm的jar包:
image

发现竟然有两个不同版本的jar包。。。

三. 问题原因

为什么一个项目打出的 jar包里会有两个 asm 包呢?

1.项目哪些jar包依赖了asm包

使用 Maven helper 搜索 asm:

image

  • easyexcel 2.1.6 依赖 cglib 3.1,cglib又依赖 asm 4.2;
  • 项目的springboot版本是2.0.0.M6,底层会依赖 asm 3.1。

2. 为什么会有两个版本的asm包?

从Maven 官方网站里搜索 asm 包:

image
发现有两个 artifactId 都叫 asm(但groupId 不一样),点击第2个 asm,查看详情:

image

也就是说 gropuId 为 asm 的包,从3.3.1版本后不再维护了,后续版本迁移到 gropuId为 org.ow2.asm 的 asm 包。

看到这里,结论已经出来:

  • asm 包从3.3.1 往后,gropuId 发生了变更(由asm 变更 org.ow2.asm);
  • 由于项目使用的springboot版本是2.0.0,需要依赖asm3.0,easyexcel 2.1.6 依赖的是asm 4.2;
  • 导致 Maven 在打包的时候将这两个 asm包( artifactId 一样,但groupId 不一样)都打进去了。

四. 问题解决​

1. 该使用哪个版本的asm包?

到现在为止,已经在排查过程中也得到了有用的报错信息

class net.sf.cglib.core.DebuggingClassWriter has interface org.objectweb.asm.ClassVisitor as super class
翻译过来就是: ClassVisitor 定义的是一个 interface,但是现在却作为一个父类(super class)被其它类继承了

并且也理清了产生冲突的原因:项目jar包里包含两个asm包,现在要确定项目使用哪个版本的asm包。

于是,从本机的 Maven 仓库里 分别找到 asm3.1 和 asm4.2 的包,并在 Idea 里打开ClassVisitor.class

asm3.1:

image

asm4.2:

image

可以看到:

  • asm3.1的 ClassVisitor.class 是 interface,asm4.2的ClassVisitor.class是 class;
  • 再结合报错信息,确定项目应该使用 asm3.1 的包

2. 怎么解决冲突

由于asm 4.2是由 easyexcel 2.1.6 的依赖 cglib 3.1 引入的,因此降级 cglib 的版本,直接在pom.xml里引入低版本的 cglib 即可:

<dependency>
    <groupId>cglib</groupId>
    <artifactId>cglib</artifactId>
    <version>2.2</version>
</dependency>

更新依赖,再次使用Maven helper查看:

image

​可以看到:asm 版本已经降级成功。

​使用 Jenkins 编译、打包、部署至测试环境后,再次测试项目的 excel 下载功能已经可以正常使用,jar包冲突导致的问题已经解决。

更多文章 

欢迎访问更多关于消息中间件的原创文章:

关注微信公众号

欢迎大家关注我的微信公众号阅读更多原创文章:
微信公众号二维码.jpg

查看原文

赞 1 收藏 0 评论 0

liuyongfei 发布了文章 · 2020-08-31

Maven项目为什么会产生NoClassDefFoundError的jar包冲突?

可以访问 这里 查看更多关于大数据平台建设的原创文章。

一. 先看看什么是Maven的传递性依赖

Maven的传递性依赖

使用 Maven 工具,带给开发者最直观的好处就是:

  • 不用再去各个网站下载各种不同的jar包了,也不用考虑它们之间的依赖关系;
  • 只需把项目依赖的jar包信息配置在pom文件中,它就会帮我们提供好一个jar包和该jar包所依赖的其它jar包。

举例解释

比如,在项目中引入 easyexcel 后:

<dependency>

<groupId>com.alibaba</groupId>

<artifactId>easyexcel</artifactId>

<version>2.1.6</version>

</dependency>

我们使用 Maven Helper 插件,查询依赖树:
image

可以看到:

  • easyexcel 依赖 cglib,cglib 又依赖 asm;
  • 因此 cglib 和 asm 这两个包也会被引进来。

通过查看 External Libraries 列表可以得到验证:
image

|二. Maven的最短路径原则和最先声明原则

思考一个问题,由于 Maven 的依赖性传递特性,如果有多个jar包同时都依赖一个jar包,且版本不一样的时候,如下图:
image
那么 Maven 该使用jarC 1.1的包还是 jarC 1.2的包呢?

这时会遵循两个规则,分别是最短路径原则最先声明原则

最短路径原则

如上图:

  • jarA -> jarC(1.1)
  • jarB -> jarD -> jarC(1.2)

Maven 项目最终会使用 1.1 的 C 。

最先声明原则

那么当最短路径一样的时候该怎么办? 
这时就需要最先声明原则,且最先声明原则就是在最短路径相同的时候生效,如下图所示:
image

由上图可以看出,依赖路径如下:

  • jarA -> jarC(1.2)
  • jarB -> jarC(1.1)

现在最短路径是一样的,则就需要看谁先声明的。 
在工程的pom.xml里,可以看到是先引入的 jarA,那么根据规则,则jarC(1.2)生效,Maven 会使用jarC(1.2)来进行编译和打包。

三. jar包冲突:NoClassDefFoundError

软件开发通常都是会有不断的版本迭代,如果:

  • 在一个项目中同时会有多个jar包都依赖同一个jar包;
  • 且都依赖的这个jar进行了版本升级,

则此时工程就有可能会产生jar包冲突,下面将通过图解举例介绍。

1. jarC版本升级丢弃了G.class

假如jarC 版本升级,由1.1 版本 升级到 1.2后,G.class由于被修改而不存在了:

image

2. 编译期间为什么不报错

我们知道,Maven编译时会把业务代码编译成class文件。
比如下面这种场景,业务代码DemoController.java类引入了jarA中的A1.class,maven 编译时会将业务代码DemoController.java类编译为DemoController.class

image

备注:

  1. DemoController.class没有直接引用jarC中的G.class类,引入的A1.class在jarA中是存在的;
  2. 编译的目的只是把业务源代码编译成.class文件;
  3. 所以在项目在编译的时候不会报错。

3. 为什么运行期间报错:NoClassDefFoundError

项目部署成功后,当接收到前端发起的一次请求,需要调用selectList接口查询数据时,会因为在项目依赖的jar包里找不到G.class类而报错,一个详细的流程如下图所示:
image

备注:

  1. 由于要调用jarA中A1.classhandleWrite方法;
  2. 但是jarC 1.2版本已经没有G.class类;
  3. 所以在执行的时候会报错:NoClassDefFoundError

四. jar包冲突:NoSuchMethodError

1. jarC版本升级丢弃了G.class的method1

问题来了,jarC 版本升级,由1.1 版本 升级到 1.2后,G.class类的begin方法由于逻辑优化而不存在了,改为begin1

image

2. 编译期间为什么不报错

image

备注:

  1. DemoController.class没有直接引用jarC中的G.class,而引入的A1.class在jarA中是存在的;
  2. 编译的目的只是把业务源代码编译成class文件;
  3. 所以在项目在编译的时候不会报错。

3. 运行期间为什么会报错:NoSuchMethodError

image
备注:

  1. 由于要调用jarA中A1.classhandleWrite方法;
  2. 在执行jarA包里的A1.classhandleWrite方法时需要调用jarC包里的begin方法;
  3. 但是jarC 1.2版本里,G.classbegin方法已经不存在了;
  4. 因此在执行的时候会报错:NoSuchMethodError

五. 总结

  1. jar包冲突是java开发中经常会遇见且有的时候要耗时很久才能解决的问题;
  2. Maven 项目中jar包冲突大部分都是这两种场景,只有在弄清楚jar包冲突产生的原因后,我们才能够在遇见问题的时候行之有效的去排查、解决;
  3. 可以借助相关工具去帮助排查jar冲突原因(后边会以实例介绍解决思路):

    • 如 idea自带的Show Dependencies;
    • idea安装插件 Maven Helper,通过查看 Dependency Analyzer;
    • 通过命令行执行 mvn dependency:tree>temp/tree.txt,可以生成具有jar包依赖关系的文件。

更多文章 

欢迎访问更多关于消息中间件的原创文章:

欢迎大家关注微信公众号阅读更多文章

微信公众号二维码.jpg

查看原文

赞 0 收藏 0 评论 0

liuyongfei 关注了专栏 · 2020-08-28

new JinhaoPlus()

JinhaoPlus的“赅”博客,最少的话说清想明白一点儿的事儿

关注 41

liuyongfei 赞了文章 · 2020-08-25

Maven依赖冲突的产生原因和解决方式

maven定义了许多dependency,每个dependency内部也会定义它的dependency。

首先我们来看一下依赖冲突产生的原因:

  1. 如果项目的依赖A和依赖B同时引入了依赖C。
  2. 如果依赖C在A和B中的版本不一致就可能依赖冲突。
  3. 比如 项目 <- A, B, A <- C(1.0),B <- C(1.1)。
  4. 那么maven如果选择高版本C(1.1)来导入(这个选择maven会根据不等路径短路径原则和同等路径第一声明原则选取),C(1.0)中的类c在C(1.1)中被修改而不存在了。
  5. 在编译期可能并不会报错,因为编译的目的只是把业务源代码编译成class文件,所以如果项目源代码中没有引入共有依赖C因升级而缺失的类c,就不会出现编译失败。除非源代码就引入了共有依赖C因升级而缺失的类c则会直接编译失败。
  6. 在运行期,很有可能出现依赖A在执行过程中调用C(1.0)以前有但是升级到C(1.1)就缺失的类c,导致运行期失败,出现很典型的依赖冲突时的NoClassDefFoundError错误。
  7. 如果是升级后出现原有的方法被修改而不存在的情况时,就会抛出NoSuchMethodError错误。

那么怎么来解决依赖冲突呢?

  1. 首先可以借助Maven查看依赖的依赖树来分析一下:mvn dependency:tree,或者使用IDEA的插件Dependency Analyzer插件来可视化地分析依赖关系。这个过程后可以明确哪些dependency引入了可能会冲突的依赖。
  2. 比如我们的项目引入A的依赖C为1.1版本,引入的B会在内部依赖C的1.0版本,那么Dependency Analyzer插件会出现依赖冲突提示,会提示B引入的C的1.0版本和当前选用的C的1.1版本冲突因而被忽略(1.0 omitted for conflict with 1.1)。
  3. 如果这时候打war包出来启动很有可能会遇到因依赖冲突而出现的NoClassDefFoundErrorNoSuchMethodError,导致编译期正常的代码无法在运行期跑起来。
  4. 由于A引入的C的版本高而B依赖的C版本低,我们优先会选择兼容高版本C的方案,即试图把B的版本调高以使得引入的依赖C可以和A引入的依赖A达到一致的版本,以此来解决依赖冲突。当然这是一个理想状况。
  5. 如果找到了目前已有的所有的B的版本,均发现其依赖的C没有与A一致的1.1版本,比如B是一个许久未升级的旧项目,那么也可以考虑把A的版本拉低以使得C的版本降到与B一致的1.0版本,当然这也可能会反过来导致A不能正常工作。
  6. 上面已经可以看出来解决依赖冲突这件事情并不简单,很难顾及两边,很多情况下引入不同版本依赖的很可能超过两方而是更多方。
  7. 那么来考虑一下妥协的方案,如果A引入的C使用的功能并不跟被抛弃的类或方法有关,而是其他在1.1版本中仍然没有改变的类或方法,那么可以考虑直接使用旧的1.0版本,那么可以使用exclusion标签来在A中排除掉对C的依赖,那么A在使用到C的功能时会使用B引入的1.0旧版本C。即A其实向B妥协使用了B依赖的C。

来看个实例:

  1. 项目引入了4.2.5.RELEASEspring-webmvc,同时引入了3.2.1.RELEASEspring-security-web
  2. 用Dependency Analyzer插件分析得到的依赖关系是这样的:

  3. 为什么maven会选择4.2.5.RELEASE引入呢,其实前文提到过:比如对spring-beans这个依赖来说spring-webmvc:4.2.5.RELEASE-spring-beans:4.2.5.RELEASE的路径是2,而spring-security-web:3.2.1.RELEASE-spring-security-core:3.2.1.RELEASE-spring-beans:3.2.8.RELEASE的路径是3,因此根据不等路径取短路径原则则选取了前者,即spring-beans:4.2.5.RELEASE,注意的是引入时并不是优先引入高版本的,同时如果依赖的路径长相等则取第一个出现的版本。
  4. 可以看到spring-security-web引入的一众spring依赖是3.X版本的,同spring-webmvc4.2.5.RELEASE版本不一致。
  5. 果不其然,打好的war包启动的时候即会报错终止。
  6. 那么在我们的问题在于兼容高版本的spring-webmvc4.2.5.RELEASE的情形下寻找spring依赖版本一致的spring-security-web,在www.mvnrepository.com寻找合适版本依赖的spring-security-web,最终我们看到了4.1.0.RELEASE版本似乎还不错,其依赖的spring版本在4.2.5.RELEASE

  7. 因此我们将这个版本的spring-security-web填入pom.xml看一下效果,确实已经没有依赖冲突产生了:

  8. 如果我们找不到完美匹配4.2.5.RELEASEspring-security-web怎么办,比如所有的spring-security-web版本就是在4.2.3.RELEASE或者4.2.6.RELEASE,那么我们就得想到妥协一下了:使用4.1.1.RELEASE版本的spring-security-web,它的spring依赖版本是4.3.1.RELEASE

那么我们试图使用exclusive标签来忽略spring版本:

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-web</artifactId>
    <version>4.1.1.RELEASE</version>
    <exclusions>
        <exclusion>
            <artifactId>spring-aop</artifactId>
            <groupId>org.springframework</groupId>
        </exclusion>
        <exclusion>
            <artifactId>spring-beans</artifactId>
            <groupId>org.springframework</groupId>
        </exclusion>
        <exclusion>
            <artifactId>spring-context</artifactId>
            <groupId>org.springframework</groupId>
        </exclusion>
        <exclusion>
            <artifactId>spring-core</artifactId>
            <groupId>org.springframework</groupId>
        </exclusion>
        <exclusion>
            <artifactId>spring-expression</artifactId>
            <groupId>org.springframework</groupId>
        </exclusion>
        <exclusion>
            <artifactId>spring-web</artifactId>
            <groupId>org.springframework</groupId>
        </exclusion>
    </exclusions>
</dependency>

依赖冲突会消失,spring-security-web会使用4.2.5.RELEASE版本的spring:

查看原文

赞 12 收藏 10 评论 2

liuyongfei 关注了专栏 · 2020-08-16

vivo 互联网技术

分享 vivo 互联网技术干货与沙龙活动,推荐最新行业动态与热门会议。

关注 6528

liuyongfei 关注了专栏 · 2020-08-16

阿里云栖号

汇集阿里技术精粹-yq.aliyun.com

关注 15772

liuyongfei 关注了专栏 · 2020-08-16

宜信技术学院

宜信技术学院是宜信旗下的金融科技平台。专注分享金融科技深度文章。

关注 11390

liuyongfei 发布了文章 · 2020-08-14

移山(数据迁移平台)实时数据同步服务是如何保证消息的顺序性?

上一篇介绍了移山(数据迁移平台)实时数据同步的整体架构;
本文主要介绍移山(数据迁移平台)实时数据同步是如何保证消息的顺序性。
可以访问 这里 查看更多关于大数据平台建设的原创文章。

一. 什么是消息的顺序性?

  1. 消息生产端将消息发送给同一个MQ服务器的同一个分区,并且按顺序发送;
  2. 消费消费端按照消息发送的顺序进行消费。

二. 为什么要保证消息的顺序性?

在某些业务功能场景下需要保证消息的发送和接收顺序是一致的,否则会影响数据的使用。

需要保证消息有序的场景

移山的实时数据同步使用 canal 组件订阅MySQL数据库的日志,并将其投递至 kafka 中(想了解移山实时同步服务架构设计的可以点这里);
kafka 消费端再根据具体的数据使用场景去处理数据(存入 HBase、MySQL 或直接做实时分析);
由于binlog 本身是有序的,因此写入到mq之后也需要保障顺序。
  1. 假如现在移山创建了一个实时同步任务,然后订阅了一个业务数据库的订单表;
  2. 上游业务,向订单表里插入了一个订单,然后对该订单又做了一个更新操作,则 binlog 里会自动写入插入操作和更新操作的数据,这些数据会被 canal server 投递至 kafka broker 里面;
  3. 如果 kafka 消费端先消费到了更新日志,后消费到插入日志,则在往目标表里做操作时就会因为数据缺失导致发生异常。

三. 移山实时同步服务是怎么保证消息的顺序性

实时同步服务消息处理整体流程如下:

image

我们主要通过以下两个方面去保障保证消息的顺序性。

1. 将需要保证顺序的消息发送到同一个partition

1.1 kafka的同一个partition内的消息是有序的
  • kafka 的同一个 partition 用一个write ahead log组织, 是一个有序的队列,所以可以保证FIFO的顺序;
  • 因此生产者按照一定的顺序发送消息,broker 就会按照这个顺序把消息写入 partition,消费者也会按照相同的顺序去读取消息;
  • kafka 的每一个 partition 不会同时被两个消费者实例消费,由此可以保证消息消费的顺序性。
1.2 控制同一key分发到同一partition

要保证同一个订单的多次修改到达 kafka 里的顺序不能乱,可以在Producer 往 kafka 插入数据时,控制同一个key (可以采用订单主键key-hash算法来实现)发送到同一 partition,这样就能保证同一笔订单都会落到同一个 partition 内。

1.3 canal 需要做的配置

canal 目前支持的mq有kafka/rocketmq,本质上都是基于本地文件的方式来支持了分区级的顺序消息的能力。我们只需在配置 instance 的时候开启如下配置即可:

1> canal.properties

# leader节点会等待所有同步中的副本确认之后再确认这条记录是否发送完成
canal.mq.acks = all

备注:

  • 这样只要至少有一个同步副本存在,记录就不会丢失。

2> instance.properties

# 散列模式的分区数
canal.mq.partitionsNum=2
# 散列规则定义 库名.表名: 唯一主键,多个表之间用逗号分隔
canal.mq.partitionHash=test.lyf_canal_test:id

备注:

  • 同一条数据的增删改操作 产生的 binlog 数据都会写到同一个分区内;
  • 查看指定topic的指定分区的消息,可以使用如下命令:

    bin/kafka-console-consumer.sh --bootstrap-server serverlist --topic topicname --from-beginning --partition 0

2. 通过日志时间戳和日志偏移量进行乱序处理

将同一个订单数据通过指定key的方式发送到同一个 partition 可以解决大部分情况下的数据乱序问题。

2.1 特殊场景

对于一个有着先后顺序的消息A、B,正常情况下应该是A先发送完成后再发送B。但是在异常情况下:

  • A发送失败了,B发送成功,而A由于重试机制在B发送完成之后重试发送成功了;
  • 这时对于本身顺序为AB的消息顺序变成了BA。

移山的实时同步服务会在将订阅到的数据存入HBase之前再加一层乱序处理 。

2.2 binlog里的两个重要信息

使用 mysqlbinlog 查看 binlog:

/usr/bin/mysqlbinlog --base64-output=decode-rows -v /var/lib/mysql/mysql-bin.000001

执行时间和偏移量:

image

备注:

  1. 每条数据都会有执行时间和偏移量这两个重要信息,下边的校验逻辑核心正是借助了这两个值
  2. 执行的sql 语句在 binlog 中是以base64编码格式存储的,如果想查看sql 语句,需要加上:--base64-output=decode-rows -v 参数来解码;
  3. 偏移量:

    • Position 就代表 binlog 写到这个偏移量的地方,也就是写了这么多字节,即当前 binlog 文件的大小;
    • 也就是说后写入数据的 Position 肯定比先写入数据的 Position 大,因此可以根据 Position 大小来判断消息的顺序。

3.消息乱序处理演示

3.1 在订阅表里插入一条数据,然后再做两次更新操作:
MariaDB [test]> insert into lyf_canal_test (name,status,content) values('demo1',1,'demo1 test');
Query OK, 1 row affected (0.00 sec)

MariaDB [test]> update lyf_canal_test set name = 'demo update' where id = 13;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

MariaDB [test]> update lyf_canal_test set name = 'demo update2',content='second update',status=2  where id = 13;
Query OK, 1 row affected (0.00 sec)
3.2 产生三条需要保证顺序的消息

插入,第一次更新,第二次更新这三次操作产生的 binlog 被 canal server 推送至 kafka 中的消息分别称为:消息A,消息B,消息C

  • 消息A:
    image
  • 消息B:
    image
  • 消息C:
    image
3.3 网络原因造成消息乱序

假设由于不可知的网络原因:

  • kafka broker收到的三条消息分别为:消息A,消息C,消息B
  • 则 kafka 消费端消费到的这三条消息先后顺序就是:消息A,消息C,消息B
  • 这样就造成了消息的乱序,因此订阅到的数据在存入目标表前必须得加乱序校验处理
3.4 消息乱序处理逻辑

我们利用HBase的特性,将数据主键做为目标表的 rowkey。当 kafka 消费端消费到数据时,乱序处理主要流程(摘自禧云数芯大数据平台技术白皮书)如下:
image

demo的三条消息处理流程如下:
1> 判断消息A 的主键id做为rowkey在hbase的目标表中不存在,则将消息A的数据直接插入HBase:
image

2> 消息C 的主键id做为rowkey,已经在目标表中存在,则这时需要拿消息C 的执行时间和表中存储的执行时间去判断:

  • 如果消息C 中的执行时间小于表中存储的执行时间,则证明消息C 是重复消息或乱序的消息,直接丢弃;
  • 消息C 中的执行时间大于表中存储的执行时间,则直接更新表数据(本demo即符合该种场景):
    image
  • 消息C 中的执行时间等于表中存储的执行时间,则这时需要拿消息C 的偏移量和表中存储的偏移量去判断:

    • 消息C 中的偏移量小于表中存储的偏移量,则证明消息C 是重复消息,直接丢弃;
    • 消息C 中的偏移量大于等于表中存储的偏移量,则直接更新表数据。

3> 消息B 的主键id做为rowkey,已经在目标表中存在,则这时需要拿消息B 的执行时间和表中存储的执行时间去判断:

  • 由于消息B中的执行时间小于表中存储的执行时间(即消息C 的执行时间),因此消息B 直接丢弃。
3.5 主要代码

kafka 消费端将消费到的消息进行格式化处理和组装,并借助 HBase-client API 来完成对 HBase 表的操作。

1> 使用Put组装单行数据

/**
 * 包名: org.apache.hadoop.hbase.client.Put
 * hbaseData 为从binlog订阅到的数据,通过循环,为目标HBase表
 * 添加rowkey、列簇、列数据。
 * 作用:用来对单个行执行加入操作。
 */
Put put = new Put(Bytes.toBytes(hbaseData.get("id")));
// hbaseData 为从binlog订阅到的数据,通过循环,为目标HBase表添加列簇和列
put.addColumn(Bytes.toBytes("info"), Bytes.toBytes(mapKey), Bytes.toBytes(hbaseData.get(mapKey)));

2> 使用 checkAndMutate,更新HBase表的数据

只有服务端对应rowkey的列数据与预期的值符合期望条件(大于、小于、等于)时,才会将put操作提交至服务端。

 // 如果 update_info(列族) execute_time(列) 不存在值就插入数据,如果存在则返回false
boolean res1 = table.checkAndMutate(Bytes.toBytes(hbaseData.get("id")), Bytes.toBytes("update_info"))                   .qualifier(Bytes.toBytes("execute_time")).ifNotExists().thenPut(put);

// 如果存在,则去比较执行时间
if (!res1) {
   // 如果本次传递的执行时间大于HBase中的执行时间,则插入put
  boolean res2 =table.checkAndPut(Bytes.toBytes(hbaseData.get("id")), Bytes.toBytes("update_info"),Bytes.toBytes("execute_time"), CompareFilter.CompareOp.GREATER, Bytes.toBytes(hbaseData.get("execute_time")),put);

// 执行时间相等时,则去比较偏移量,本次传递的值大于HBase中的值则插入put
  if (!res2) {
    boolean res3  = table.checkAndPut(Bytes.toBytes(hbaseData.get("id")),
                  Bytes.toBytes("update_info"),       Bytes.toBytes("execute_position"),    CompareFilter.CompareOp.GREATER, Bytes.toBytes(hbaseData.get("execute_position")),put);
  }
}

四.总结

  1. 目前移山的实时同步服务,kafka 消费端是使用一个线程去消费数据;
  2. 如果将来有版本升级需求,将消费端改为多个线程去消费数据时,要考虑到多线程消费时有序的消息会被打乱这种情况的解决办法。

更多文章 

欢迎访问更多关于消息中间件的原创文章:

关注微信公众号

欢迎大家关注我的微信公众号阅读更多文章:
微信公众号二维码.jpg

查看原文

赞 0 收藏 0 评论 0

认证与成就

  • 获得 62 次点赞
  • 获得 22 枚徽章 获得 1 枚金徽章, 获得 7 枚银徽章, 获得 14 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2015-11-12
个人主页被 1.9k 人浏览