来源: TinyPinyin(一):单字符转拼音的极致优化

汉字转拼音是一个开发中经常使用的功能。其中,pinyin4j是应用较广的Java汉字转拼音库。然而,此库有不少的缺点:

  1. Jar文件较大,205KB;
  2. Pinyin4J的PinyinHelper.toHanyuPinyinStringArray 在第一次调用时耗时非常长(~2000ms);
  3. 功能臃肿,许多情况下我们不需要声调、方言;
  4. 无法添加自定义词典,进而无法有效处理多音字;
  5. 内存占用太高。

为了解决上述问题,我开发了TinyPinyin,旨在提供最好的Java和Android拼音库。经过一段时间的开发,目前该项目已迈入2.x版本,基本的功能已经完整,项目架构也已定型,有着相当好的运行速度及很低的内存占用,也在不少项目中实际得到了应用。

在TinyPinyin的开发中遇到了不少有趣的问题,汇总为几篇文章,与大家分享 :)

1. TinyPinyin简要介绍

TinyPinyin是一个适用于Java和Android的快速、低内存占用的汉字转拼音库。

其特性包括:

  • 支持基于词典的多音字处理,支持简体中文、繁体中文;

  • 极速的执行效率(Pinyin4J的4~16倍);

  • 很低的内存占用(不添加词典时小于30KB)。

1.1 简洁的API

汉字转拼音API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 如果c为汉字,则返回大写拼音;如果c不是汉字,则返回String.valueOf(c)
*/
String Pinyin.toPinyin(char c)

/**
* c为汉字,则返回true,否则返回false
*/
boolean Pinyin.isChinese(char c)

/**
* 将输入字符串转为拼音,转换过程中会使用之前设置的用户词典,以字符为单位插入分隔符
*/
String toPinyin(String str, String separator)

词典API

TinyPinyin基于词典加入了对多音字的支持。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 添加中文城市词典
Pinyin.init(Pinyin.newConfig().with(CnCityDict.getInstance());

// 添加自定义词典
Pinyin.init(Pinyin.newConfig()
.with(new PinyinMapDict() {
@Override
public Map<String, String[]> mapping() {
HashMap<String, String[]> map = new HashMap<String, String[]>();
map.put("重庆", new String[]{"CHONG", "QING"});
return map;
}
}));

1.2 极速的执行效率

使用JMH工具得到bechmark,对比TinyPinyin和Pinyin4J的运行速度。

性能测试结果简要说明:单个字符转拼音的速度是Pinyin4j的四倍,添加字典后字符串转拼音的速度是Pinyin4j的16倍

从性能测试的结果来看,TinyPinyin显著优于应用广泛的Pinyin4j。特别的是,添加了含有近15000个词的大词典后,TinyPinyin的速度竟比不支持多音字处理的Pinyin4j的速度快的更多,到底是如何做到的呢?

接下来,本文将详细介绍TinyPinyin在速度和内存方面的详细设计。

附:性能测试的详细结果:

Benchmark Mode Samples Score Unit
TinyPinyin_Init_With_Large_Dict(初始化大词典) thrpt 200 66.131 ops/s
TinyPinyin_Init_With_Small_Dict(初始化小词典) thrpt 200 35408.045 ops/s
TinyPinyin_StringToPinyin_With_Large_Dict(添加大词典后进行String转拼音) thrpt 200 16.268 ops/ms
Pinyin4j_StringToPinyin(Pinyin4j的String转拼音) thrpt 200 1.033 ops/ms
TinyPinyin_CharToPinyin(字符转拼音) thrpt 200 14.285 ops/us
Pinyin4j_CharToPinyin(Pinyin4j的字符转拼音) thrpt 200 4.460 ops/us
TinyPinyin_IsChinese(字符是否为汉字) thrpt 200 15.552 ops/us
Pinyin4j_IsChinese(Pinyin4j的字符是否为汉字) thrpt 200 4.432 ops/us

2. 单字符转拼音的极致优化

对于单字符转拼音来说,要解决两个问题:

  • 判断传入的字符是否为汉字

  • 如果是汉字,则返回它的拼音

在具体解决问题前,首先要深入了解问题本身。最直观的单字符转拼音方案是维护一张巨大的映射表,存储每一个中文字符对应的拼音,如“中”对应“ZHONG”。那么中文字符和拼音共多少个呢?经过简单的统计分析,发现中文字符有如下特征:

  • 中文字符共有20378个

  • 中文字符除了12295外,均分布在 19968 ~ 40869 之间 (Unicode的4E00 ~ 9FA5),并非连续分布,此范围内夹杂了524个非中文字符

  • 拼音共有407个(不包含声调)

根据中文字符和拼音的特征,便可以设计如下的字符转拼音方案:

  • 预先构建19968 ~ 40869的映射表,将每一个char映射为一个拼音(是中文字符)或null(不是中文字符)

  • 判断传入的字符是否为12295,是则返回其拼音

  • 判断传入的字符是否处于19968 ~ 40869之间,不属于则判定不是中文字符;属于的话则查预先构建的映射表,根据查到的值判断是否为中文,并返回相应的结果。

上述方案采用了查表的方法转换拼音,因此速度很快。然而,映射表的构建往往占据较大的内存,因此需设法降低映射表的空间占用。下文将具体阐述TinyPinyin所做的优化。

2.1 拼音映射表原始方案

最naive的拼音映射表的结构是:

1
char --> String // 字符 --> 拼音,如 20013(中) --> "ZHONG"

此方案的劣势非常明显:中文字符共有两万多个,但拼音共有407个,为每个中文字符都分配一个String对象过于浪费空间。因此,需加以优化。

2.2 拼音映射表初步优化

之前统计发现拼音共有407个,那么我们可以分配一个静态的数组保存这407个拼音:

1
static final String[] PINYIN_TABLE = new String[]{"A", "AI", ...

然后以拼音在数组中的位置作为此拼音的编码,如”A”对应的编码为0,”AI”的编码为1。拼音映射表便只需存储char对应的拼音编码即可,无需存储拼音本身,大幅降低了内存消耗。

需要注意的是,拼音共407个,因此至少需要9位来表示一个拼音。Java中byte为8位,short为16位,可采用short来表示一个拼音。

优化后的映射表如下:

1
char --> short // 字符 --> 拼音编码

内存占用为:short[21000]存储映射表,共占用42KB内存,存编码的方式比直接存拼音占用空间要小很多。

然而,我们注意到,编码使用9位就足够了,使用short造成了较大的浪费,每个拼音编码浪费了16 - 9 = 7位,也就是说,理想情况下我们可以将存储所有汉字拼音的42KB内存优化到 42*9/16 = 24KB。

那么如何实现呢?请见下一步优化。

2.3 拼音映射表终极优化

思路是使用byte[21000]存储每个汉字的低8位拼音编码,另外采用byte[21000/8]来存储每个汉字第9位(最高位)的编码,每个byte可存储8个汉字的第9位编码。

共耗用内存21KB + 3KB = 24KB,整整降低了42.8%的内存占用。

当然,由于每个编码分为两部分存储,因此解码过程稍微复杂一些,不过采用位运算即可快速的计算出真实的编码:

1
2
3
4
5
6
7
8
9
10
11
// 计算出真实的编码
short decodeIndex(byte[] paddings, byte[] indexes, int offset) {
int index1 = offset / 8;
int index2 = offset % 8;
short realIndex = (short) (indexes[offset] & 0xff);

if ((paddings[index1] & PinyinData.BIT_MASKS[index2]) != 0) {
realIndex = (short) (realIndex | PinyinData.PADDING_MASK);
}
return realIndex;
}

3 小结

TinyPinyin的单字符转拼音功能就介绍到这里,从上述过程可以看到,转拼音虽然是一个很简单的功能,但想要做到极致却不容易。经过优化,TinyPinyin的内存占用得到了极大的降低,且单字符转拼音的速度达到了Pinyin4j的四倍。

下篇文章将介绍多音字的快速处理方案。还记得前文的性能测试吗?添加了含有近15000个词的大词典后,能够处理多音字的TinyPinyin的速度,竟比不支持多音字处理的Pinyin4j的速度快的更多(16倍),具体做了哪些优化呢?请移步下篇文章:打造最好的Java拼音库TinyPinyin(二):多音字快速处理方案

来源: TinyPinyin(二):多音字快速处理方案

上篇文章TinyPinyin(一):单字符转拼音的极致优化,介绍了单字符转拼音的设计方案,本文将介绍TinyPinyin项目中的多音字处理功能。

1. 多音字快速处理方案概览

多音字处理是汉字转拼音库的一个重要特性。多音字的识别是基于词典实现的,这是由于绝大部分情况下,一个多音字到底该取哪个拼音,是由其所处的词决定的。例如,对于“重”字,在“重要”一词中应读“ZHONG”,在“重庆”一词中应读“CHONG”。

TinyPinyin中对应的API如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 添加中文城市词典
Pinyin.init(Pinyin.newConfig().with(CnCityDict.getInstance());


/**
* 将输入字符串转为拼音,转换过程中会使用之前设置的用户词典,以字符为单位插入分隔符
*
* 例: "hello:中国" 在separator为","时,输出: "h,e,l,l,o,:,ZHONG,GUO,!"
*
* @param str 输入字符串
* @param separator 分隔符
* @return 中文转为拼音的字符串
*/
String toPinyin(String str, String separator);

在TinyPinyin中,对多音字的处理也是基于词典实现的,步骤如下图所示:

TinyPinyin1.png

  • 向TinyPinyin添加词典
  • 传入待转为拼音的字符串
  • 根据词典,对字符串进行中文分词
  • 单独将分词得到的各个词或字符转为拼音,拼接后返回结果

在整个过程中,最为核心的部分便是分词了。下面具体介绍分词的处理。

2 TinyPinyin分词方案

基于词典的分词,本质上可分解为两个问题:

  • 一是多串匹配问题。即给定一些模式串(字典中的词),在一段正文中找到所有模式串出现的位置,注意匹配可能有重叠,如”中国人民”可匹配出:[“中国”, “中国人”, “人民”]
  • 二是从匹配到的所有模式串集合中,按照一定的规则挑选出相互没有重叠的模式串子集,以此来得到分词结果,如上例中可挑选出的两种分词结果为:[“中国”, “人民”]和[“中国人”, “民”]

2.1 多串匹配算法

TinyPinyin选用了Aho–Corasick算法实现了多串匹配。Aho-Corasick算法简称AC算法,通过将模式串预处理为确定有限状态自动机,扫描文本一遍就能结束。其复杂度为O(n),即与模式串的数量和长度无关,非常适合应用在基于词典的分词场景。

网上有很多对AC算法原理的介绍,这里不再展开,需要注意的是,AC算法的Java实现有两个流行的版本:AC算法Java实现双数组Trie树(DoubleArrayTrie)Java实现

后者声称在降低内存占用的情况下,速度能够提升很多,因此TinyPinyin首先集成了此库作为AC算法的实现。然而,集成后实际使用JProfiler监测发现,双数组Trie树(DoubleArrayTrie)Java实现占用的内存很高,初步分析后发现,AhoCorasickDoubleArrayTrie.loseWeight()中有一些神奇的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* free the unnecessary memory
*/
private void loseWeight()
{
int nbase[] = new int[size + 65535];
System.arraycopy(base, 0, nbase, 0, size);
base = nbase;

int ncheck[] = new int[size + 65535];
System.arraycopy(check, 0, ncheck, 0, size);
check = ncheck;
}

从代码中可以看到,即使是一个空词典,也至少会分配两个 int[65535],共512KB,内存占用实在太高,因此TinyPinyin最终选用了Trie树(DoubleArrayTrie)Java实现

2.2 分词选择器

分词选择器的作用是,从匹配到的所有模式串集合中,按照一定的规则挑选出相互没有重叠的模式串子集,以此来得到分词结果。

如上例中可挑选出的两种分词结果为:[“中国”, “人民”]和[“中国人”, “民”]。常见的分词选择算法包括:正向最大匹配算法、逆向最大匹配算法、双向最大匹配算法等。

TinyPinyin选择了正向最大匹配算法,其基本思路是:从句子左端开始,不断匹配最长的词(组不了词的单字则单独划开),直到把句子划分完。算法的思想简单直接:人在阅读时是从左往右逐字读入的,正向最大匹配法是与人的习惯相符的。

算法的具体实现请见ForwardLongestSelector。算法的输入是匹配到的所有模式串集合(相互之间可能存在重叠),要求输出一个符合最大正向匹配原则的相互没有重叠的模式串子集。TinyPinyin用10行代码就实现了此算法,感兴趣的可以看一下 :-)

3. 多音字处理性能

我们旨在提供最好的汉字转拼音库,因此TinyPinyin很看重运行速度和内存占用。在多音字处理方面,我们关心以下几点:

  • 词典的初始化耗时多久?
  • 词典的内存占用如何?
  • 分词速度如何?

性能测试的结果:

Benchmark Mode Samples Score Unit
TinyPinyin_Init_With_Large_Dict(初始化大词典) thrpt 200 66.131 ops/s
TinyPinyin_Init_With_Small_Dict(初始化小词典) thrpt 200 35408.045 ops/s
TinyPinyin_StringToPinyin_With_Large_Dict(添加大词典后进行String转拼音) thrpt 200 16.268 ops/ms
Pinyin4j_StringToPinyin(Pinyin4j的String转拼音) thrpt 200 1.033 ops/ms

我们选择了2个词典进行初始化的性能测试,分别是含有15000个词的大词典,和含有几十个词的小词典。

从结果中可以看到,初始化大词典约耗时15ms,初始化小词典仅需0.03ms。可以说初始化本身所需时间非常的少。

而在内存占用方面,使用中文城市词典时,额外消耗约43KB内存。

最后,添加了含有近15000个词的大词典后,TinyPinyin的速度比不支持多音字处理的Pinyin4j的速度快16倍,可以说执行速度非常的快,达到了TinyPinyin极致速度、极致空间占用的目标。

3 小结

TinyPinyin的多音字支持就介绍到这里,下篇文章将分享TinyPinyin的API设计和测试实践

来源: TinyPinyin(三):API设计和测试实践

之前的两篇文章TinyPinyin(一):单字符转拼音的极致优化TinyPinyin(二):多音字快速处理方案,详细介绍了单字符转拼音、多音字的处理这两个具体功能的高效实现细节,本文是TinyPinyin系列的完结篇,将分享TinyPinyin项目在API设计上的思考,以及测试实践。

1. 汉字转拼音API设计

1.1 字符处理接口

TinyPinyin的汉字转拼音API非常简洁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 如果c为汉字,则返回大写拼音;如果c不是汉字,则返回String.valueOf(c)
*/
String Pinyin.toPinyin(char c)

/**
* c为汉字,则返回true,否则返回false
*/
boolean Pinyin.isChinese(char c)

/**
* 将输入字符串转为拼音,转换过程中会使用之前设置的用户词典,以字符为单位插入分隔符
*/
String toPinyin(String str, String separator)

优秀的API的设计应满足正交性完备性。从正交性的角度来看,Pinyin.toPinyin(char c)和Pinyin.isChinese(char c)是正交的,但String Pinyin.toPinyin(char c)与String toPinyin(String str, String separator)严格意义上并不是正交的。

之所以这么做的原因,在于Pinyin.isChinese(char c)接口无法支持多音字的处理,因为绝大部分情况下,一个多音字到底该取哪个拼音,是由其所处的词决定的,单个字符无法确定多音字的读音。在这里,功能实现的优先级要大于想要遵循的设计范式。

1.2 词典设置接口

初始化TinyPinyin时,添加词典的接口如下:

1
2
3
4
5
6
Config config = Pinyin.newConfig()
.with(dict_1)
.with(....) // 可以继续添加多个词典
.with(dict_n);

Pinyin.init(config);

还有一个需求是,在TinyPinyin初始化之后,想再追加一些词典,实现此功能的接口是:

1
Pinyin.add(other_dict); // 向Pinyin中追加词典

然而,每次新添加词典都会触发一次完整的AC算法构建过程,因此若有多个词典,推荐使用性能更优的Pinyin.init(Config)接口。

2. 词典API设计

拼音转换的接口较为容易,词典API的设计就没那么简单了。

2.1 基础词典:PinyinDict

设计具体的词典之前,我们需要思考汉字转拼音词典究竟是什么。

对汉字转拼音词典来说,它本质上包含了一组词的集合,以及集合中每个词的拼音。词的集合可以用Set来表示,每个词和它的拼音之间的映射关系是:

1
词(String) --> 拼音(String[]) // 如:"重庆" --> ["CHONG", "QING"]

因此,词典可由两个API组成:返回词典所有词的 Set words() 和 将词转为拼音的 String[] toPinyin(String word)。这里有个约定,toPinyin接口应保证对words中的所有词,toPinyin(String)均返回非null的结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 字典接口
*/
public interface PinyinDict {

/**
* 字典所包含的所有词
*/
Set<String> words();

/**
* 将词转换为拼音,应保证对words中的所有词,toPinyin(String)均返回非null的结果
*/
String[] toPinyin(String word);
}

这两个接口是不是看起来很熟悉?没错,这两个接口加起来,便成为了一个Map:Map<String, String[]>。

问题来了,为什么词典接口不做成直接返回一个Map<String, String[]>呢?

原因在于,如果直接返回Map,则相当于限定了词典只能采用Map这一种数据结构来实现,而在设计词典模型时,不应限制词典具体的实现方式。

例如,当词典非常大时,把整个词典加载到内存中的Map便不合适了,更好的做法应该是在执行 toPinyin(String) 时,从文件或数据库中读取相应的拼音,降低内存占用。这时我们拆分出的接口便体现出优势了:这两个接口是支持流处理的!因此词典既可以放在内存中,也可以放到文件数据中,甚至可以通过网络接口获取拼音转换结果。

2.2 便捷词典:PinyinMapDict

上文描述的基础词典PinyinDict较为简洁。然而,用户使用PinyinDict实现自定义词典时却很复杂:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
final Map<String, String[]> map = new HashMap();
map.put("重庆", new String[]{"CHONG", "QING"});

PinyinDict dict = new PinyinDict() {
@Override
public Set<String> words() {
return map.keySet();
}

@Override
public String[] toPinyin(String word) {
return map.get(word);
}
};

为了便于更好的创建自定义词典,TinyPinyin提供了对基础词典的封装:PinyinMapDict

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* 基于java.util.Map的字典实现,利于添加自定义字典
*/
public abstract class PinyinMapDict implements PinyinDict {

/**
* Key为字典的词,Value为该词所对应的拼音
*
* @return 包含词和对应拼音的 {@link java.util.Map}
*/
public abstract Map<String, String[]> mapping();


@Override
public Set<String> words() {
return mapping() != null ? mapping().keySet() : null;
}

@Override
public String[] toPinyin(String word) {
return mapping() != null ? mapping().get(word) : null;
}
}

这样一来,用户添加自定义词典便非常简洁了:

1
2
3
4
5
6
7
8
PinyinDict dict = new PinyinMapDict() {
@Override
public Map<String, String[]> mapping() {
Map<String, String[]> map = new HashMap();
map.put("重庆", new String[]{"CHONG", "QING"});
return map;
}
}

2.3 Android词典:AndroidAssetDict

为了提升效率,TinyPinyin专门为Android平台的词典设计了一个辅助类:AndroidAssetDict

这是由于Android代码访问JAR文件中的资源非常低效(参考)。因此,AndroidAssetDict采用了将字典文件存入asset中的方式提升访问效率。

AndroidAssetDict的使用非常简单,大家可参考tinypinyin-lexicons-android-cncity这个子项目,只需要重写String assetFileName()这一个方法即可。当然,词典文件的格式需要与示例保持一致。

3 测试实战

TinyPinyin项目中,功能代码和测试代码的比例是10:6,测试的覆盖率还是很高的。另外,为了评估性能,也添加了一些基于jmh工具的性能测试。

3.1 单元测试

单元测试在TinyPinyin中扮演了非常重要的角色,下面介绍一个核心的测试:单字符转拼音测试。

既然TinyPinyin之前已经有了Pinyin4J这个库,那就以它作为基准,确保对所有的字符(Character.MAX_VALUE ~ Character.MIN_VALUE),TinyPinyin与Pinyin4J有相同的返回结果。这样便保证了单字符转拼音功能的正确性,该部分测试如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Test
public void test_toPinyin_char() {
char[] allChars = allChars();
int chineseCount = 0;

for (int i = 0; i < allChars.length; i++) {
char targetChar = allChars[i];
String[] pinyins = PinyinHelper.toHanyuPinyinStringArray(targetChar, format);
if (pinyins != null && pinyins.length > 0) {
// is chinese
chineseCount++;
assertThat(Pinyin.toPinyin(targetChar), equalTo(pinyins[0]));
} else {
// not chinese
assertThat(Pinyin.toPinyin(targetChar), equalTo(String.valueOf(targetChar)));
}
}

int expectedChineseCount = 20378;

assertThat(chineseCount, is(expectedChineseCount));
}

3.2 性能测试

性能测试并不是一件容易的事情,在借助专业的工具(如jmh)外,还需要精心设计测试的输入、初始化等过程,如果这些因素做的不好,则可能会得到错误的性能测试结果。

下面介绍添加大字典后,字符串转拼音API的性能测试的输入选择。

字符串转拼音API的输入是一个字符串,那么我们应该选什么样的字符串呢?

首先,不能使用随机生成的字符串,这是由于随机生成的字符串中几乎不会出现词典中的词,那么在执行过程中便不会触发词典匹配,性能测试无效。

其次,不能使用过短的字符串,过短的字符串测试效果不明显;也不能在所有的执行轮次中选用同一个字符串,测试结果不精确。

那TinyPinyin是怎么选择输入的呢?找了一本很棒的小说《刀锋》的txt文档,每轮运行前,从txt文档中随机抽取1000个连续字符,作为输入字符串,完美解决了上述问题。具体代码请见PinyinDictBenchmark2。

4. 总结

本系列介绍了TinyPinyin单字符转拼音、多音字的处理这两个具体功能的高效实现细节,以及API设计上的思考和在测试方面的实战。从TinyPinyin开发过程可以看到,即使是一个功能非常简单的库,想做到极致也很不容易。

希望大家喜欢,欢迎讨论!

来源: Spring Boot 2.7 来了

Spring Boot 2.7 来了

大家好,我是栈长。

Spring Boot 2.6.0 发布已经过去大半年了,现在 Spring Boot 2.7.0 如期而至:

图片

图片

Spring Boot 又接连发布了三个版本:

  • Spring Boot 2.7.0(最新)
  • Spring Boot 2.6.8
  • Spring Boot 2.5.14

后面两个版本都是修复 bug 版本,2.7.0 才是硬菜,毕竟等了大半年。。

老规矩,栈长重点来解读下 Spring Boot 2.7.0 都更新了什么鬼!


Spring Boot 2.7 新特性

自动配置变更(重要)

自动配置注册文件

  • 自动配置注册有了一个比较大的调整,之前都是写在下面 文件中的:

    META-INF/spring.factories

  • 现在改名了:

    META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports

  • 2.7.0
    图片
  • 另外格式也变了,Spring Boot 2.7 中直接每一行是一个自动配置类:
    图片
  • 2.6
    图片

编写格式确实是比之前方便多了,但文件名确实也太长了,比较难记。。。

需要注意的是:

为了向后兼容,META-INF/spring.factories 虽然现在被标识废弃了,但现在仍然可以使用,后续可能被彻底删除,建议使用新的规范。

Spring Boot 基础就不介绍了,推荐下这个实战教程:

https://github.com/javastacks/spring-boot-best-practice

新注解(@AutoConfiguration)

新增了一个自动配置注解 @AutoConfiguration,用来代替之前的 @Configuration,用于标识新自动配置注册文件中的顶级自动配置类,由 @AutoConfiguration 注解嵌套、导入进来的其他配置类可以继续使用 @Configuration 注解。

另外,为方便起见,@AutoConfiguration 注解还支持 after, afterNames, beforebeforeNames 属性进行自动配置排序,用于代替之前的 @AutoConfigureAfter@AutoConfigureBefore 注解。

这个注解可以说更加细分了吧,自动配置专用注解,用专门的注解来干专门的事,这样也可以用来区分用 @Configuration 标识的普通配置类。

另外,最新 Spring Boot 面试题我也整理好了,大家可以在Java面试库小程序在线刷题。

支持 GraphQL

GraphQL = Graph + QL(Query Language),它是一种用于 API 的基于图表化的查询语言:

图片

Spring for GraphQL(1.0) 如今正式发布了,Spring Boot 2.7.0 也集成了对 GraphQL 的自动配置、指标支持等,Starter 名为:spring-boot-starter-graphql,Spring 大家族又新增一员。

支持 Podman

Podman 和 Docker 一样,是现在比较火热的容器引擎。

现在使用 Cloud Native Buildpacks 构建映像时,Maven 和 Gradle 插件就可以使用 Podman 容器引擎进行构建了,可用来代替 Docker 容器引擎。

牛逼啊,Podman 现在被 Spring Boot 官方支持了。

支持 RabbitStreamTemplate

现在支持自动配置 RabbitStreamTemplate,只需要配置以下参数:

spring.rabbitmq.stream.name = xxx

同时还新增了一个 RabbitStreamTemplateConfigurer 配置类来进行自定义扩展其他实例。

支持 Hazelcast

Hazelcast 和 Redis 一样,它是一款开源的分布式内存数据库,可用作分布式缓存。

Hazelcast 自动配置嵌入式服务器现在默认使用了 SpringManagerContext,可以在 Hazelcast 实例对象中注入 Spring Bean 了。另外,还引入了HazelcastConfigCustomizer 回调接口,可用于进一步调整 Hazelcast 服务器配置。

支持 Cache2k

Cache2k 是一个开源的轻量级、高性能 Java 内存缓存库。

现在添加了 Cache2k 的依赖项管理和自动配置,也可以通过定义一个 Cache2kBuilderCustomizer 实例 Bean 来自定义默认缓存设置。

Web Server SSL 增强

嵌入式 Web 服务器 SSL 配置增强了,可以配置带有 PEM 编码证书和私钥文件的 SSL。

使用以下参数进行配置:

  • server.ssl.certificate

  • server.ssl.certificate-private-key

  • server.ssl.trust-certificate(可选)

  • server.ssl.trust-certificate-private-key(可选)

另外,也可以使用类似的 management.server.ssl.* 属性来保护管理端点。

info 端点增强

操作系统信息

现在 /info 端点支持暴露应用程序运行时的一些操作系统信息:

{ "os": { "name": "Linux", "version": "5.4.0-1051-gke", "arch": "amd64" } }

不过默认是禁用的,有需要的可以手动开启:

management.info.os.enabled = true

Java 供应商信息

现在 /info 端点中的 Java 供应商添加了供应商版本信息:

{ "java": { "vendor": { "name": "Eclipse Adoptium", "version": "Temurin-17.0.1+12" }, "..." }

需要注意的是: 并非所有供应商都公开 java.vendor.version 系统属性,所以,获取版本属性时可能为空。

单元测试加强

新增了 @DataCouchbaseTestDataElasticsearchTest 注解,可用于测试使用了 Spring Data Couchbase 和 Spring Data Elasticsearch 的应用程序。

其他更多

除了上面列出的更新之外,在其他方面都还有许多小的调整和改进,栈长这里就不一一介绍了,感兴趣的可以看下官方发布说明:

https://spring.io/blog/2022/05/19/spring-boot-2-7-0-available-now


最新支持版本

栈长整理了 Spring Boot 的最新版本支持情况:

版本 发布时间 停止维护时间 停止商业支持
2.7.0 2022-05-19 2023-05-18 2024-08-22
2.6.0 2021-12-17 2022-11-24 2024-02-24
2.5.x 2021-05-20 已停止 2023-08-24
2.4.x 2020-11-12 已停止 2023-02-23
2.3.x 2020-05-15 已停止 2022-08-20
2.2.x 2019-10-16 已停止 已停止
2.1.x 2018-10-10 已停止 已停止
2.0.x 2018-03-01 已停止 已停止
1.5.x 2017-01-30 已停止 已停止

大部分版本要么停止维护,或者仅提供商业支持,随着 2.7 的发布,现在连 Spring Boot 2.5 也停止维护了:

图片

能用的也就 Spring Boot 2.6 及以上的版本了,并且,Spring Boot 2.6.0 在今年 11/24 也会停止维护。。

总结

Spring Boot 2.7.0 新增了不少新特性,变化真的还挺大的,特别是自动配置的变更,有明显调整,大家要特别注意。

Spring Boot 现在已经成为了实事上的脚手架框架了,让学习和开发变得更简单,同时这版本的淘汰节奏也让我感觉技术更新实在太快了,所以我们也要不断保持学习,不然也会跟着淘汰。

如果你还没用过 Spring Boot,今天我就送你一份 《Spring Boot 学习笔记》这个很全了,包括底层实现原理及代码实战,非常齐全,助你快速打通 Spring Boot 的各个环节。

往期 Spring Boot 教程及示例源码整理:

https://github.com/javastacks/javastack

来源: 区间重叠问题(排序or边界)

1.会议室(252-easy)

题目描述:给定一个会议时间安排的数组 intervals ,每个会议时间都会包括开始和结束的时间 intervals[i] = [starti, endi] ,请你判断一个人是否能够参加这里面的全部会议,即判断是否存在重叠区域!

示例

1
2
3
4
输入: intervals = [[0,30],[5,10],[15,20]]
输出: false
解释: 存在重叠区间,一个人在同一时刻只能参加一个会议。

思路:对每个会议时间按照开始时间排序(关键)。然后遍历数组进行判断,如果前一个会议结束的时间大于后一个会议开始的时间(前一个还没结束,后一个就开始了),则存在重叠区域。

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public boolean canAttendMeetings(int[][] intervals) {
if (intervals == null || intervals.length == 0) return false;
/**
Arrays.sort(intervals, new Comparator<int[]>() {

@Override
public int compare(int[] o1, int[] o2) {
return o1[0] - o2[0];
}
});
*/
Arrays.sort(intervals, (a, b) -> a[0] - b[0]);
for (int i = 1; i < intervals.length; i++) {
// 前一个结束时间大于后一个开始时间
if (intervals[i][0] < intervals[i - 1][1]) {
returtn false;
}
}
return true;
}

补充

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
// 用日期判断
public boolean canAttendMeetings(List<Meeting> meetings) {
if (meetings == null || meetings.isEmpty()) {
return false;
}
meetings.sort(Comparator.comparingLong(value -> value.getBdate().getTime()));
log.debug("meetings={}", JSON.toJSONString(meetings));
for (int i = 1; i < meetings.size(); i++) {
// 前一个结束时间大于后一个开始时间
if (meetings.get(i - 1).getEdate().after(meetings.get(i).getBdate())) {
return false;
}
}
return true;
}

// 测试
@Test
void test() {
int[][] meetings = {{830, 900}, {900, 1000}, {1500, 1530}};
log.debug("{}", MeetingUtils.canAttendMeetings(meetings));
meetings = new int[][]{{830, 1000}, {900, 1000}, {1500, 1530}};
log.debug("{}", MeetingUtils.canAttendMeetings(meetings));
meetings = new int[][]{{830, 1000}, {1500, 1530}};
log.debug("{}", MeetingUtils.canAttendMeetings(meetings));
assertTrue(true);
}

@Test
void test1() {
List<Meeting> meetingMoList = new ArrayList<>();
Meeting meetingMo1;
Calendar calendar = Calendar.getInstance();
calendar.set(Calendar.SECOND, 0);
calendar.set(Calendar.MILLISECOND, 0);

meetingMo1 = new Meeting();
calendar.set(Calendar.HOUR_OF_DAY, 8);
calendar.set(Calendar.MINUTE, 0);
meetingMo1.setBdate(calendar.getTime());
calendar.set(Calendar.HOUR_OF_DAY, 8);
calendar.set(Calendar.MINUTE, 30);
meetingMo1.setEdate(calendar.getTime());
meetingMoList.add(meetingMo1);

meetingMo1 = new Meeting();
calendar.set(Calendar.HOUR_OF_DAY, 8);
calendar.set(Calendar.MINUTE, 30);
meetingMo1.setBdate(calendar.getTime());
calendar.set(Calendar.HOUR_OF_DAY, 9);
calendar.set(Calendar.MINUTE, 0);
meetingMo1.setEdate(calendar.getTime());
meetingMoList.add(meetingMo1);

log.debug("{}", MeetingUtils.canAttendMeetings(meetingMoList));

meetingMo1 = new Meeting();
calendar.set(Calendar.HOUR_OF_DAY, 9);
calendar.set(Calendar.MINUTE, 0);
meetingMo1.setBdate(calendar.getTime());
calendar.set(Calendar.HOUR_OF_DAY, 10);
calendar.set(Calendar.MINUTE, 0);
meetingMo1.setEdate(calendar.getTime());
meetingMoList.add(meetingMo1);

log.debug("{}", MeetingUtils.canAttendMeetings(meetingMoList));
}

拓展1:会议室II 253,给定一个会议时间安排的数组,每个会议时间都会包括开始和结束的时间 [[s1,e1],[s2,e2],…] (si < ei),为避免会议冲突,同时要考虑充分利用会议室资源,请你计算至少需要多少间会议室,才能满足这些会议安排。

示例

1
2
3
4
5
6
7
8
示例 1:
输入: [[0, 30],[5, 10],[15, 20]]
输出: 2

示例 2:
输入: [[7,10],[2,4]]
输出: 1

思路:有时间重叠的,肯定不能安排在一间会议室。还是需要先排序,这里按照会议的开始时间排序,这里使用小根堆(优先级队列)存储会议的结束时间(堆顶为会议最早结束时间):

  • 如果另一场会议的开始时间小于当前堆顶,说明会议时间冲突,我们需要再单独开一间(即当前会议的结束时间作为新的堆顶);
  • 否则,没有时间冲突,等这场会议结束使用。

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import java.util.LinkedList;
class Solution {
public int minMeetingRooms(int[][] intervals) {
if(intervals == null || intervals.length == 0){
return 0;
}
Arrays.sort(intervals, (a, b) -> a[0] - b[0]);

PriorityQueue<Integer> queue = new PriorityQueue<>((o1, o2) -> o1 - o2);
queue.offer(intervals[0][1]);
for(int i = 1; i < intervals.length; i++){
if(intervals[i][0] >= queue.peek()){
queue.poll();
}
queue.offer(intervals[i][1]);
}
return queue.size();
}
}

拓展2:最多可以参加的会议数目1353,给你一个数组 events,其中 events[i] = [startDayi, endDayi] ,表示会议 i 开始于 startDayi ,结束于 endDayi 。

你可以在满足 startDayi <= d <= endDayi 中的任意一天 d 参加会议 i 。注意,一天只能参加一个会议。请你返回你可以参加的 最大 会议数目。

示例

1
2
3
4
5
6
7
8
9
示例 1:
输入:events = [[1,2],[2,3],[3,4]]
输出:3
解释:你可以参加所有的三个会议。
安排会议的一种方案如上图。
1 天参加第一个会议。
2 天参加第二个会议。
3 天参加第三个会议。

思路:本题还是排序,注意题意,我们不是要把一个会议的所有天都在,只需要参加满足会议(在开始和结束之间参加)的一天即可,比较简单的是利用set存储已经占用的天数(因为同一天不能参加两个会议),但是超时。。。

可以使用优先级队列,将这一天能参加的会议的结束时间全部入堆,会议的起始时间 == day,这一天一定能参加(注意按照会议的开始时间排序)。为什么是结束时间?有点贪心思想,保证能参加最多的会议

  • 我们一天一天安排,首先删除已经结束的会议(出堆),此时堆顶这一天是我们可以参加的会议(一天只能参加一场)!

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
class Solution {
// public int maxEvents(int[][] events) {
// Arrays.sort(events, (o1, o2) -> o1[1] - o2[1]);
// Set<Integer> set = new HashSet<>();
// for (int[] event : events) {
// for (int i = event[0]; i <= event[1]; ++i) {
// if (!set.contains(i)) {
// set.add(i);
// break;
// }
// }
// }
// return set.size();
// }

public int maxEvents(int[][] events) {
Arrays.sort(events, (o1, o2) -> o1[0] - o2[0]);
PriorityQueue<Integer> pq = new PriorityQueue<>();
int i = 0;
int day = 1;
int ans = 0;
int n = events.length;

while (i < n || !pq.isEmpty()) {
while (i < n && events[i][0] == day) {
pq.offer(events[i][1]);
i++;
}

while (!pq.isEmpty() && pq.peek() < day) {
pq.poll();
}

if (!pq.isEmpty()) {
pq.poll();
ans++;
}
day++;
}
return ans;
}
}

2.不重叠区间(435-medium)

题目描述:给定一个区间的集合,找到需要移除区间的最小数量,使剩余区间互不重叠。

示例

1
2
3
输入: [ [1,2], [2,3], [3,4], [1,3] ]
输出: 1
解释: 移除 [1,3] 后,剩下的区间没有重叠。

思路:为了保证移除最少的区间集合,即寻找最多的不重叠区间贪心策略:保证每个区间尾越小,那么后边预留的空间越大。

  • 可以通过对尾区间进行排序,遍历数组(记录不重叠个数count),当前头小于上一个的尾,直接删除(重叠),
  • 否则count++,更新最小的尾区间end。

ps:注意起始边界处理,时间复杂度:O_(_nlogn)!

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public int eraseOverlapIntervals(int[][] intervals) {
if (intervals == null || intervals.length == 0) return 0;
Arrays.sort(intervals, (a, b) -> a[1] - b[1]); //1.每个子区间尾排序
int count = 1;
int end = intervals[0][1];

for (int i = 1; i < intervals.length; i++) {
if (intervals[i][0] < end) continue; //2.与之前区间重叠(即当前区间的头小于之前区间的尾,删除)
end = intervals[i][1];
count++;
}
return intervals.length - count; //3.要删除的区间数量
}

3.合并区间(56-medium)

题目描述:给出一个区间的集合,请合并所有重叠的区间。

示例

1
2
3
4
5
6
7
8
输入: intervals = [[1,3],[2,6],[8,10],[15,18]]
输出: [[1,6],[8,10],[15,18]]
解释: 区间 [1,3] 和 [2,6] 重叠, 将它们合并为 [1,6].

输入: intervals = [[1,4],[4,5]]
输出: [[1,5]]
解释: 区间 [1,4] 和 [4,5] 可被视为重叠区间。

思路:实现合并区间肯定要进行排序。采用每个子区间左边界排序(右边界也可),然后从左向右遍历数组。注意:

  • 若没有重叠(数组为空/当前区间的左边界,大于结果区间的右边界),不需合并则加入结果;
  • 否则更新右边界生成新的区间加入结果,更新结果区间的右边界。

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public int[][] merge(int[][] intervals) {
//1.按照每个子区间端点(左端点)进行排序
Arrays.sort(intervals, (a, b) -> a[0] - b[0]);
int[][] ret = new int[intervals.length][2];
int idx = -1;

for (int[] interval : intervals) {
//3.没有重叠(数组为空/当前区间左边界大于结果区间右边界),直接加入
if (idx == -1 || interval[0] > ret[idx][1]) {
ret[++idx] = interval;
} else {
//4.有重叠,合并区间(更新结果区间右边界)
ret[idx][1] = Math.max(interval[1], ret[idx][1]);
}
}
//ps:copyOf(int[] original, int newLength)
return Arrays.copyOf(ret, idx + 1);
}

4.插入区间

题目描述:给出一个无重叠的按照区间起始端点排序的区间列表。在列表中插入一个新的区间,你需要确保列表中的区间仍然有序且不重叠(如果有必要的话,可以合并区间)。

示例

1
2
3
4
5
6
输入:intervals = [[1,3],[6,9]], newInterval = [2,5]
输出:[[1,5],[6,9]]

输入:intervals = [[1,2],[3,5],[6,7],[8,10],[12,16]], newInterval = [4,8]
输出:[[1,2],[3,10],[12,16]]
解释:这是因为新的区间 [4,8][3,5],[6,7],[8,10] 重叠。

思路:因为原始数组已经排好序,并且不存在重叠,那么直接用索引i遍历数组,索引idx记录结果集索引,分三步走

  • 如果存在,将左边与新区间不重叠的部分直接接入结果(没影响的部分);
  • 新区间与区间存在重叠,合并区间(更新新区间左右边界),将更新后的新区间加入结果;通俗一点说:新区间的左边界<= 当前上一个区间的右边界,>= 下一个区间的左边界,我们需要将这三个区间合并(更新新区间的左右边界,加入结果)
  • 如果存在,将右边与新区间不重叠的部分直接接入结果(没影响的部分);

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public int[][] insert(int[][] intervals, int[] newInterval) {
int[][] ret = new int[intervals.length + 1][2];
int idx = 0, i = 0;
//1.左边不重叠区间
while (i < intervals.length && newInterval[0] > intervals[i][1]) {
ret[idx++] = intervals[i++];
}
//2.区间合并(更新新区间的左右边界)
while (i < intervals.length && newInterval[1] >= intervals[i][0]) {
newInterval[0] = Math.min(newInterval[0], intervals[i][0]);
newInterval[1] = Math.max(newInterval[1], intervals[i][1]);
i++;
}
ret[idx++] = newInterval;
//3.右边不重叠区间
while (i < intervals.length) {
ret[idx++] = intervals[i++];
}
return Arrays.copyOf(ret, idx);
}

总结

总结上述四个题目,主要可以细分为两类问题。

涉及区间重叠问题:

  • 区间是否重叠(T252)
  • 最多的的不重叠区间(T435)
    涉及区间合并问题:
  • 合并所有重叠区间(T56)
  • 合并插入过程中可能引入的重叠区间(T57)

其实,上述问题都需要对区间端点进行排序,这里明确一点,不管是根据左端点排序还是右端点排序都是可以的。需要画图去看左右边界情况,比较直观。

来源: Java中的BigDecimal

一、BigDecimal概述

  • Java在java.math包中提供的API类BigDecimal,用来对超过16位有效位的数进行精确的运算。双精度浮点型变量double可以处理16位有效数,但在实际应用中,可能需要对更大或者更小的数进行运算和处理。 一般情况下,对于那些不需要准确计算精度的数字,我们可以直接使用Float和Double处理,但是Double.valueOf(String) 和Float.valueOf(String)会丢失精度。所以开发中,如果我们需要精确计算的结果,则必须使用BigDecimal类来操作。

  • BigDecimal所创建的是对象,故我们不能使用传统的+、-、*、/等算术运算符直接对其对象进行数学运算,而必须调用其相对应的方法。方法中的参数也必须是BigDecimal的对象。构造器是类的特殊方法,专门用来创建对象,特别是带有参数的对象。

二、BigDecimal常用构造函数

2.1、常用构造函数

  1. BigDecimal(int)
    创建一个具有参数所指定整数值的对象
  2. BigDecimal(double)
    创建一个具有参数所指定双精度值的对象
  3. BigDecimal(long)
    创建一个具有参数所指定长整数值的对象
  4. BigDecimal(String)
    创建一个具有参数所指定以字符串表示的数值的对象

2.2、使用问题分析

使用示例:

1
2
3
4
5
BigDecimal a =new BigDecimal(0.1);
System.out.println("a values is:"+a);
System.out.println("=====================");
BigDecimal b =new BigDecimal("0.1");
System.out.println("b values is:"+b);

结果示例:

1
2
3
a values is:0.1000000000000000055511151231257827021181583404541015625
=====================
b values is:0.1

原因分析:

  1. 参数类型为double的构造方法的结果有一定的不可预知性。有人可能认为在Java中写入newBigDecimal(0.1)所创建的BigDecimal正好等于 0.1(非标度值 1,其标度为 1),但是它实际上等于0.1000000000000000055511151231257827021181583404541015625。这是因为0.1无法准确地表示为 double(或者说对于该情况,不能表示为任何有限长度的二进制小数)。这样,传入到构造方法的值不会正好等于 0.1(虽然表面上等于该值)。
  2. String 构造方法是完全可预知的:写入 newBigDecimal(“0.1”) 将创建一个 BigDecimal,它正好等于预期的 0.1。因此,比较而言, 通常建议优先使用String构造方法。
  3. 当double必须用作BigDecimal的源时,请注意,此构造方法提供了一个准确转换;它不提供与以下操作相同的结果:先使用Double.toString(double)方法,然后使用BigDecimal(String)构造方法,将double转换为String。要获取该结果,请使用static valueOf(double)方法。

三、BigDecimal常用方法详解

3.1、常用方法

  1. add(BigDecimal)
    BigDecimal对象中的值相加,返回BigDecimal对象
  2. subtract(BigDecimal)
    BigDecimal对象中的值相减,返回BigDecimal对象
  3. multiply(BigDecimal)
    BigDecimal对象中的值相乘,返回BigDecimal对象
  4. divide(BigDecimal)
    BigDecimal对象中的值相除,返回BigDecimal对象
  5. toString()
    将BigDecimal对象中的值转换成字符串
  6. doubleValue()
    将BigDecimal对象中的值转换成双精度数
  7. floatValue()
    将BigDecimal对象中的值转换成单精度数
  8. longValue()
    将BigDecimal对象中的值转换成长整数
  9. intValue()
    将BigDecimal对象中的值转换成整数

3.2、BigDecimal大小比较

java中对BigDecimal比较大小一般用的是bigdemical的compareTo方法

1
int a = bigdemical.compareTo(bigdemical2)

返回结果分析:

1
2
3
a = -1,表示bigdemical小于bigdemical2;
a = 0,表示bigdemical等于bigdemical2;
a = 1,表示bigdemical大于bigdemical2;

举例:a大于等于b

1
new bigdemica(a).compareTo(new bigdemical(b)) >= 0

四、BigDecimal格式化

  • 由于NumberFormat类的format()方法可以使用BigDecimal对象作为其参数,可以利用BigDecimal对超出16位有效数字的货币值,百分值,以及一般数值进行格式化控制。

  • 以利用BigDecimal对货币和百分比格式化为例。首先,创建BigDecimal对象,进行BigDecimal的算术运算后,分别建立对货币和百分比格式化的引用,最后利用BigDecimal对象作为format()方法的参数,输出其格式化的货币值和百分比。

1
2
3
4
5
6
7
8
9
10
11
NumberFormat currency = NumberFormat.getCurrencyInstance(); //建立货币格式化引用 
NumberFormat percent = NumberFormat.getPercentInstance(); //建立百分比格式化引用
percent.setMaximumFractionDigits(3); //百分比小数点最多3位

BigDecimal loanAmount = new BigDecimal("15000.48"); //贷款金额
BigDecimal interestRate = new BigDecimal("0.008"); //利率
BigDecimal interest = loanAmount.multiply(interestRate); //相乘

System.out.println("贷款金额:\t" + currency.format(loanAmount));
System.out.println("利率:\t" + percent.format(interestRate));
System.out.println("利息:\t" + currency.format(interest));

结果:

1
贷款金额: ¥15,000.48 利率: 0.8% 利息: ¥120.00

BigDecimal格式化保留2为小数,不足则补0:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class NumberFormat {
public static void main(String[] s){
System.out.println(formatToNumber(new BigDecimal("3.435")));
System.out.println(formatToNumber(new BigDecimal(0)));
System.out.println(formatToNumber(new BigDecimal("0.00")));
System.out.println(formatToNumber(new BigDecimal("0.001")));
System.out.println(formatToNumber(new BigDecimal("0.006")));
System.out.println(formatToNumber(new BigDecimal("0.206")));
}
/**
* @desc 1.0~1之间的BigDecimal小数,格式化后失去前面的0,则前面直接加上0。
* 2.传入的参数等于0,则直接返回字符串"0.00"
* 3.大于1的小数,直接格式化返回字符串
* @param obj传入的小数
* @return
*/
public static String formatToNumber(BigDecimal obj) {
DecimalFormat df = new DecimalFormat("#.00");
if(obj.compareTo(BigDecimal.ZERO)==0) {
return "0.00";
}else if(obj.compareTo(BigDecimal.ZERO)>0&&obj.compareTo(new BigDecimal(1))<0){
return "0"+df.format(obj).toString();
}else {
return df.format(obj).toString();
}
}
}

结果为:

1
2
3
4
5
6
3.44
0.00
0.00
0.00
0.01
0.21

五、BigDecimal常见异常

5.1、除法的时候出现异常

1
java.lang.ArithmeticException: Non-terminating decimal expansion; no exact representable decimal result

原因分析:

  • 通过BigDecimal的divide方法进行除法时当不整除,出现无限循环小数时,就会抛异常:java.lang.ArithmeticException: Non-terminating decimal expansion; no exact representable decimal result.
    解决方法:
  • divide方法设置精确的小数点,如:divide(xxxxx,2)

六、BigDecimal总结

6.1、总结

  1. 在需要精确的小数计算时再使用BigDecimal,BigDecimal的性能比double和float差,在处理庞大,复杂的运算时尤为明显。故一般精度的计算没必要使用BigDecimal。
  2. 尽量使用参数类型为String的构造函数。
  3. BigDecimal都是不可变的(immutable)的, 在进行每一次四则运算时,都会产生一个新的对象 ,所以在做加减乘除运算时要记得要保存操作后的值。

6.2、工具类推荐

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
package com.vivo.ars.util;
import java.math.BigDecimal;

/**
* 用于高精确处理常用的数学运算
*/
public class ArithmeticUtils {
//默认除法运算精度
private static final int DEF_DIV_SCALE = 10;

/**
* 提供精确的加法运算
*
* @param v1 被加数
* @param v2 加数
* @return 两个参数的和
*/

public static double add(double v1, double v2) {
BigDecimal b1 = new BigDecimal(Double.toString(v1));
BigDecimal b2 = new BigDecimal(Double.toString(v2));
return b1.add(b2).doubleValue();
}

/**
* 提供精确的加法运算
*
* @param v1 被加数
* @param v2 加数
* @return 两个参数的和
*/
public static BigDecimal add(String v1, String v2) {
BigDecimal b1 = new BigDecimal(v1);
BigDecimal b2 = new BigDecimal(v2);
return b1.add(b2);
}

/**
* 提供精确的加法运算
*
* @param v1 被加数
* @param v2 加数
* @param scale 保留scale 位小数
* @return 两个参数的和
*/
public static String add(String v1, String v2, int scale) {
if (scale < 0) {
throw new IllegalArgumentException(
"The scale must be a positive integer or zero");
}
BigDecimal b1 = new BigDecimal(v1);
BigDecimal b2 = new BigDecimal(v2);
return b1.add(b2).setScale(scale, BigDecimal.ROUND_HALF_UP).toString();
}

/**
* 提供精确的减法运算
*
* @param v1 被减数
* @param v2 减数
* @return 两个参数的差
*/
public static double sub(double v1, double v2) {
BigDecimal b1 = new BigDecimal(Double.toString(v1));
BigDecimal b2 = new BigDecimal(Double.toString(v2));
return b1.subtract(b2).doubleValue();
}

/**
* 提供精确的减法运算。
*
* @param v1 被减数
* @param v2 减数
* @return 两个参数的差
*/
public static BigDecimal sub(String v1, String v2) {
BigDecimal b1 = new BigDecimal(v1);
BigDecimal b2 = new BigDecimal(v2);
return b1.subtract(b2);
}

/**
* 提供精确的减法运算
*
* @param v1 被减数
* @param v2 减数
* @param scale 保留scale 位小数
* @return 两个参数的差
*/
public static String sub(String v1, String v2, int scale) {
if (scale < 0) {
throw new IllegalArgumentException(
"The scale must be a positive integer or zero");
}
BigDecimal b1 = new BigDecimal(v1);
BigDecimal b2 = new BigDecimal(v2);
return b1.subtract(b2).setScale(scale, BigDecimal.ROUND_HALF_UP).toString();
}

/**
* 提供精确的乘法运算
*
* @param v1 被乘数
* @param v2 乘数
* @return 两个参数的积
*/
public static double mul(double v1, double v2) {
BigDecimal b1 = new BigDecimal(Double.toString(v1));
BigDecimal b2 = new BigDecimal(Double.toString(v2));
return b1.multiply(b2).doubleValue();
}

/**
* 提供精确的乘法运算
*
* @param v1 被乘数
* @param v2 乘数
* @return 两个参数的积
*/
public static BigDecimal mul(String v1, String v2) {
BigDecimal b1 = new BigDecimal(v1);
BigDecimal b2 = new BigDecimal(v2);
return b1.multiply(b2);
}

/**
* 提供精确的乘法运算
*
* @param v1 被乘数
* @param v2 乘数
* @param scale 保留scale 位小数
* @return 两个参数的积
*/
public static double mul(double v1, double v2, int scale) {
BigDecimal b1 = new BigDecimal(Double.toString(v1));
BigDecimal b2 = new BigDecimal(Double.toString(v2));
return round(b1.multiply(b2).doubleValue(), scale);
}

/**
* 提供精确的乘法运算
*
* @param v1 被乘数
* @param v2 乘数
* @param scale 保留scale 位小数
* @return 两个参数的积
*/
public static String mul(String v1, String v2, int scale) {
if (scale < 0) {
throw new IllegalArgumentException(
"The scale must be a positive integer or zero");
}
BigDecimal b1 = new BigDecimal(v1);
BigDecimal b2 = new BigDecimal(v2);
return b1.multiply(b2).setScale(scale, BigDecimal.ROUND_HALF_UP).toString();
}

/**
* 提供(相对)精确的除法运算,当发生除不尽的情况时,精确到
* 小数点以后10位,以后的数字四舍五入
*
* @param v1 被除数
* @param v2 除数
* @return 两个参数的商
*/

public static double div(double v1, double v2) {
return div(v1, v2, DEF_DIV_SCALE);
}

/**
* 提供(相对)精确的除法运算。当发生除不尽的情况时,由scale参数指
* 定精度,以后的数字四舍五入
*
* @param v1 被除数
* @param v2 除数
* @param scale 表示表示需要精确到小数点以后几位。
* @return 两个参数的商
*/
public static double div(double v1, double v2, int scale) {
if (scale < 0) {
throw new IllegalArgumentException("The scale must be a positive integer or zero");
}
BigDecimal b1 = new BigDecimal(Double.toString(v1));
BigDecimal b2 = new BigDecimal(Double.toString(v2));
return b1.divide(b2, scale, BigDecimal.ROUND_HALF_UP).doubleValue();
}

/**
* 提供(相对)精确的除法运算。当发生除不尽的情况时,由scale参数指
* 定精度,以后的数字四舍五入
*
* @param v1 被除数
* @param v2 除数
* @param scale 表示需要精确到小数点以后几位
* @return 两个参数的商
*/
public static String div(String v1, String v2, int scale) {
if (scale < 0) {
throw new IllegalArgumentException("The scale must be a positive integer or zero");
}
BigDecimal b1 = new BigDecimal(v1);
BigDecimal b2 = new BigDecimal(v1);
return b1.divide(b2, scale, BigDecimal.ROUND_HALF_UP).toString();
}

/**
* 提供精确的小数位四舍五入处理
*
* @param v 需要四舍五入的数字
* @param scale 小数点后保留几位
* @return 四舍五入后的结果
*/
public static double round(double v, int scale) {
if (scale < 0) {
throw new IllegalArgumentException("The scale must be a positive integer or zero");
}
BigDecimal b = new BigDecimal(Double.toString(v));
return b.setScale(scale, BigDecimal.ROUND_HALF_UP).doubleValue();
}

/**
* 提供精确的小数位四舍五入处理
*
* @param v 需要四舍五入的数字
* @param scale 小数点后保留几位
* @return 四舍五入后的结果
*/
public static String round(String v, int scale) {
if (scale < 0) {
throw new IllegalArgumentException(
"The scale must be a positive integer or zero");
}
BigDecimal b = new BigDecimal(v);
return b.setScale(scale, BigDecimal.ROUND_HALF_UP).toString();
}

/**
* 取余数
*
* @param v1 被除数
* @param v2 除数
* @param scale 小数点后保留几位
* @return 余数
*/
public static String remainder(String v1, String v2, int scale) {
if (scale < 0) {
throw new IllegalArgumentException(
"The scale must be a positive integer or zero");
}
BigDecimal b1 = new BigDecimal(v1);
BigDecimal b2 = new BigDecimal(v2);
return b1.remainder(b2).setScale(scale, BigDecimal.ROUND_HALF_UP).toString();
}

/**
* 取余数 BigDecimal
*
* @param v1 被除数
* @param v2 除数
* @param scale 小数点后保留几位
* @return 余数
*/
public static BigDecimal remainder(BigDecimal v1, BigDecimal v2, int scale) {
if (scale < 0) {
throw new IllegalArgumentException(
"The scale must be a positive integer or zero");
}
return v1.remainder(v2).setScale(scale, BigDecimal.ROUND_HALF_UP);
}

/**
* 比较大小
*
* @param v1 被比较数
* @param v2 比较数
* @return 如果v1 大于v2 则 返回true 否则false
*/
public static boolean compare(String v1, String v2) {
BigDecimal b1 = new BigDecimal(v1);
BigDecimal b2 = new BigDecimal(v2);
int bj = b1.compareTo(b2);
boolean res;
if (bj > 0)
res = true;
else
res = false;
return res;
}
}

来源: 用了Stream后,代码反而越写越丑?

介绍

Java8的stream流,加上lambda表达式,可以让代码变短变美,已经得到了广泛的应用。我们在写一些复杂代码的时候,也有了更多的选择。

代码首先是给人看的,其次才是给机器执行的。代码写的是否简洁明了,是否写的漂亮,对后续的bug修复和功能扩展,意义重大。很多时候,是否能写出优秀的代码,是和工具没有关系的。代码是工程师能力和修养的体现,有的人,即使用了stream,用了lambda,代码也依然写的像屎一样。

不信,我们来参观一下一段美妙的代码。好家伙,filter里面竟然带着潇洒的逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public List<FeedItemVo> getFeeds(Query query,Page page){
List<String> orgiList = new ArrayList<>();

List<FeedItemVo> collect = page.getRecords().stream()
.filter(this::addDetail)
.map(FeedItemVo::convertVo)
.filter(vo -> this.addOrgNames(query.getIsSlow(),orgiList,vo))
.collect(Collectors.toList());
//...其他逻辑
return collect;
}

private boolean addDetail(FeedItem feed){
vo.setItemCardConf(service.getById(feed.getId()));
return true;
}

private boolean addOrgNames(boolean isSlow,List<String> orgiList,FeedItemVo vo){
if(isShow && vo.getOrgIds() != null){
orgiList.add(vo.getOrgiName());
}
return true;
}

如果觉得不过瘾的话,我们再贴上一小段。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
if (!CollectionUtils.isEmpty(roleNameStrList) && roleNameStrList.contains(REGULATORY_ROLE)) {
vos = vos.stream().filter(
vo -> !CollectionUtils.isEmpty(vo.getSpecialTaskItemVoList())
&& vo.getTaskName() != null)
.collect(Collectors.toList());
} else {
vos = vos.stream().filter(vo -> vo.getIsSelect()
&& vo.getTaskName() != null)
.collect(Collectors.toList());
vos = vos.stream().filter(
vo -> !CollectionUtils.isEmpty(vo.getSpecialTaskItemVoList())
&& vo.getTaskName() != null)
.collect(Collectors.toList());
}
result.addAll(vos.stream().collect(Collectors.toList()));

代码能跑,但多画蛇添足。该缩进的不缩进,该换行的不换行,说什么也算不上好代码。

如何改善?除了技术问题,还是一个意识问题。时刻记得,优秀的代码,首先是可读的,然后才是功能完善。

1. 合理的换行

在Java中,同样的功能,代码行数写的少了,并不见得你的代码就好。由于Java使用;作为代码行的分割,如果你喜欢的话,甚至可以将整个Java文件搞成一行,就像是混淆后的JavaScript一样。

当然,我们知道这么做是不对的。在lambda的书写上,有一些套路可以让代码更加规整。

1
Stream.of("i", "am", "xjjdog").map(toUpperCase()).map(toBase64()).collect(joining(" "));

上面这种代码的写法,就非常的不推荐。除了在阅读上容易造成障碍,在代码发生问题的时候,比如抛出异常,在异常堆栈中找问题也会变的困难。所以,我们要将它优雅的换行。

1
2
3
4
Stream.of("i", "am", "xjjdog")
.map(toUpperCase())
.map(toBase64())
.collect(joining(" "));

不要认为这种改造很没有意义,或者认为这样的换行是理所当然的。在我平常的代码review中,这种糅杂在一块的代码,真的是数不胜数,你完全搞不懂写代码的人的意图。

合理的换行是代码青春永驻的配方。

2. 舍得拆分函数

为什么函数能够越写越长?是因为技术水平高,能够驾驭这种变化么?答案是因为懒!由于开发工期或者意识的问题,遇到有新的需求,直接往老的代码上添加ifelse,即使遇到相似的功能,也直接选择将原来的代码拷贝过去。久而久之,码将不码。

首先聊一点性能方面的。在JVM中,JIT编译器会对调用量大,逻辑简单的代码进行方法内联,以减少栈帧的开销,并能进行更多的优化。所以,短小精悍的函数,其实是对JVM友好的。

在可读性方面,将一大坨代码,拆分成有意义的函数,是非常有必要的,也是重构的精髓所在。在lambda表达式中,这种拆分更是有必要。

我将拿一个经常在代码中出现的实体转换示例来说明一下。下面的转换,创建了一个匿名的函数order->{},它在语义表达上,是非常弱的。

1
2
3
4
5
6
7
8
9
10
public Stream<OrderDto> getOrderByUser(String userId){
return orderRepo.findOrderByUser().stream()
.map(order-> {
OrderDto dto = new OrderDto();
dto.setOrderId(order.getOrderId());
dto.setTitle(order.getTitle().split("#")[0]);
dto.setCreateDate(order.getCreateDate().getTime());
return dto;
});
}

在实际的业务代码中,这样的赋值拷贝还有转换逻辑通常非常的长,我们可以尝试把dto的创建过程给独立开来。因为转换动作不是主要的业务逻辑,我们通常不会关心其中到底发生了啥。

1
2
3
4
5
6
7
8
9
10
11
public Stream<OrderDto> getOrderByUser(String userId){
return orderRepo.findOrderByUser().stream()
.map(this::toOrderDto);
}
public OrderDto toOrderDto(Order order){
OrderDto dto = new OrderDto();
dto.setOrderId(order.getOrderId());
dto.setTitle(order.getTitle().split("#")[0]);
dto.setCreateDate(order.getCreateDate().getTime());
return dto;
}

这样的转换代码还是有点丑。但如果OrderDto的构造函数,参数就是Order的话public OrderDto(Order order),那我们就可以把真个转换逻辑从主逻辑中移除出去,整个代码就可以非常的清爽。

1
2
3
4
public Stream<OrderDto> getOrderByUser(String userId){
return orderRepo.findOrderByUser().stream()
.map(OrderDto::new);
}

除了map和flatMap的函数可以做语义化,更多的filter可以使用Predicate去代替。比如:

1
2
3
4
Predicate<Registar> registarIsCorrect = reg -> 
reg.getRegulationId() != null
&& reg.getRegulationId() != 0
&& reg.getType() == 0;

registarIsCorrect,就可以当作filter的参数。

3. 合理的使用Optional

在Java代码里,由于NullPointerException不属于强制捕捉的异常,它会隐藏在代码里,造成很多不可预料的bug。所以,我们会在拿到一个参数的时候,都会验证它的合法性,看一下它到底是不是null,代码中到处充满了这样的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
if(null == obj)
if(null == user.getName() || "".equals(user.getName()))

if (order != null) {
Logistics logistics = order.getLogistics();
if(logistics != null){
Address address = logistics.getAddress();
if (address != null) {
Country country = address.getCountry();
if (country != null) {
Isocode isocode = country.getIsocode();
if (isocode != null) {
return isocode.getNumber();
}
}
}
}
}

Java8引入了Optional类,用于解决臭名昭著的空指针问题。实际上,它是一个包裹类,提供了几个方法可以去判断自身的空值问题。

上面比较复杂的代码示例,就可以替换成下面的代码。

1
String result = Optional.ofNullable(order)      .flatMap(order->order.getLogistics())      .flatMap(logistics -> logistics.getAddress())      .flatMap(address -> address.getCountry())      .map(country -> country.getIsocode())      .orElse(Isocode.CHINA.getNumber());

当你不确定你提供的东西,是不是为空的时候,一个好的习惯是不要返回null,否则调用者的代码将充满了null的判断。我们要把null消灭在萌芽中。

1
2
3
public Optional<String> getUserName() {
return Optional.ofNullable(userName);
}

另外,我们要尽量的少使用Optional的get方法,它同样会让代码变丑。比如:

1
2
Optional<String> userName = "xjjdog";
String defaultEmail = userName.get() == null ? "":userName.get() + "@xjjdog.cn";

而应该修改成这样的方式:

1
2
3
4
Optional<String> userName = "xjjdog";
String defaultEmail = userName
.map(e -> e + "@xjjdog.cn")
.orElse("");

那为什么我们的代码中,依然充满了各式各样的空值判断?即使在非常专业和流行的代码中?一个非常重要的原因,就是Optional的使用需要保持一致。当其中的一环出现了断层,大多数编码者都会以模仿的方式去写一些代码,以便保持与原代码风格的一致。

如果想要普及Optional在项目中的使用,脚手架设计者或者review人,需要多下一点功夫。

4. 返回Stream还是返回List?

很多人在设计接口的时候,会陷入两难的境地。我返回的数据,是直接返回Stream,还是返回List?

如果你返回的是一个List,比如ArrayList,那么你去修改这个List,会直接影响里面的值,除非你使用不可变的方式对其进行包裹。同样的,数组也有这样的问题。

但对于一个Stream来说,是不可变的,它不会影响原始的集合。对于这种场景,我们推荐直接返回Stream流,而不是返回集合。这种方式还有一个好处,能够强烈的暗示API使用者,多多使用Stream相关的函数,以便能够统一代码风格。

1
2
3
4
public Stream<User> getAuthUsers(){
...
return Stream.of(users);
}

不可变集合是一个强需求,它能防止外部的函数对这些集合进行不可预料的修改。在guava中,就有大量的Immutable类支持这种包裹。再举一个例子,Java的枚举,它的values()方法,为了防止外面的api对枚举进行修改,就只能拷贝一份数据。

但是,如果你的api,面向的是最终的用户,不需要再做修改,那么直接返回List就是比较好的,比如函数在Controller中。

5. 少用或者不用并行流

Java的并行流有很多问题,这些问题对并发编程不熟悉的人高频率踩坑。不是说并行流不好,但如果你发现你的团队,老在这上面栽跟头,那你也会毫不犹豫的降低推荐的频率。

并行流一个老生常谈的问题,就是线程安全问题。在迭代的过程中,如果使用了线程不安全的类,那么就容易出现问题。比如下面这段代码,大多数情况下运行都是错误的。

1
2
3
4
5
6
7
8
9
10
11
12
List transform(List source){
List dst = new ArrayList<>();
if(CollectionUtils.isEmpty()){
return dst;
}
source.stream.
.parallel()
.map(..)
.filter(..)
.foreach(dst::add);
return dst;
}

你可能会说,我把foreach改成collect就行了。但是注意,很多开发人员是没有这样的意识的。既然api提供了这样的函数,它在逻辑上又讲得通,那你是阻挡不住别人这么用的。

并行流还有一个滥用问题,就是在迭代中执行了耗时非常长的IO任务。在用并行流之前,你有没有一个疑问?既然是并行,那它的线程池是怎么配置的?

很不幸,所有的并行流,共用了一个ForkJoinPool。它的大小,默认是CPU个数-1,大多数情况下,是不够用的。

如果有人在并行流上跑了耗时的IO业务,那么你即使执行一个简单的数学运算,也需要排队。关键是,你是没办法阻止项目内的其他同学使用并行流的,也无法知晓他干了什么事情。

那怎么办?我的做法是一刀切,直接禁止。虽然残忍了一些,但它避免了问题。

总结

Java8加入的Stream功能非常棒,我们不需要再羡慕其他语言,写起代码来也更加行云流水。虽然看着很厉害的样子,但它也只不过是一个语法糖而已,不要寄希望于用了它就获得了超能力。

随着Stream的流行,我们的代码里这样的代码也越来越多。但现在很多代码,使用了Stream和Lambda以后,代码反而越写越糟,又臭又长以至于不能阅读。没其他原因,滥用了!

总体来说,使用Stream和Lambda,要保证主流程的简单清晰,风格要统一,合理的换行,舍得加函数,正确的使用Optional等特性,而且不要在filter这样的函数里加代码逻辑。在写代码的时候,要有意识的遵循这些小tips,简洁优雅就是生产力。

如果觉得Java提供的特性还是不够,那我们还有一个开源的类库vavr,提供了更多的可能性,能够和Stream以及Lambda结合起来,来增强函数编程的体验。

1
2
3
4
5
<dependency>
<groupId>io.vavr</groupId>
<artifactId>vavr</artifactId>
<version>0.10.3</version>
</dependency>

但无论提供了如何强大的api和编程方式,都扛不住小伙伴的滥用。这些代码,在逻辑上完全是说的通的,但就是看起来别扭,维护起来费劲。

写一堆垃圾lambda代码,是虐待同事最好的方式,也是埋坑的不二选择。

写代码嘛,就如同说话、聊天一样。大家干着同样的工作,有的人说话好听颜值又高,大家都喜欢和他聊天;有的人不好好说话,哪里痛戳哪里,虽然他存在着但大家都讨厌。

代码,除了工作的意义,不过是我们在世界上表达自己想法的另一种方式罢了。如何写好代码,不仅仅是个技术问题,更是一个意识问题。

Java SPI 概述

SPI全称Service Provider Interface,是Java提供的一套用来被第三方实现或者扩展的API,它可以用来启用框架扩展和替换组件。

实际中的例子

  • JDBC驱动
  • SLF4J日志
  • Spring Starter

转发来自B站的视频讲解

讲的非常好,PPT做的也非常用心,值得学习

第一讲

10分钟让你彻底明白Java SPI,附实例代码演示#安员外很有码

第二讲

10分钟讲清楚Java SLF4J,Java日志框架的扛把子,从原理到实践 #安员外很有码

第二讲

硬核干货!SpringBoot自动配置实战项目,从0开始手撸Starter,零基础小白可全程跟学#安员外很有码

背景

  • 一些行业的文件数据交互都是用GBK作为字符集
  • 会产生一些乱码
  • 文件的字段会被要求定长,不够要用空格补充
  • 哪些字符是占1个长度,哪些字符占2个长度,也是一个问题

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
/**
* 按GBK编码写入文件,固定长度
* 补空格(有判断不全的情况)
*
* @param length 总长度
* @param str 要补位的字符串
* @return 补全位数的字符串
*/
public static String jointSpaceOld(int length, String str) {
if (str.length() < length) {
int chinaNum = 0;
// 中文一个字符占两位
if (!StringUtils.isEmpty(str)) {
for (int i = 0; i < str.length(); i++) {
char c = str.charAt(i);
try {
if (Character.toString(c).getBytes(GBK).length == 2) {
chinaNum++;
}
} catch (UnsupportedEncodingException e) {
if (isAllChineseByBlock(c) || isLatin1Supplement(c)) {
chinaNum++;
}
}
}
}
int total = length - str.length() - chinaNum;
str = str + " ".repeat(Math.max(0, total));
}
return str;
}

/**
* 按GBK编码写入文件,固定长度
* 补空格(有判断不全的情况)
*
* @param length 总长度
* @param str 要补位的字符串
* @return 补全位数的字符串
*/
public static String jointSpace(int length, String str) {
StringBuilder sb = new StringBuilder(StringUtils.defaultString(str));
try {
while (sb.toString().getBytes(GBK).length > length) {
int sbLen = sb.length();
sb.delete(sbLen - 1, sbLen);
}
while (sb.toString().getBytes(GBK).length < length) {
sb.append(" ");
}
} catch (UnsupportedEncodingException e) {
sb = new StringBuilder();
sb.append(" ".repeat(Math.max(0, length)));
}
return sb.toString();
}

public static boolean isAllChineseByBlock(char c) {
return isChineseByBlock(c) || isChinesePunctuation(c);
}

/**
* 使用UnicodeBlock方法判断
* 是否为中文字符
*
* @param c 字符
* @return 是否
*/
public static boolean isChineseByBlock(char c) {
Character.UnicodeBlock ub = Character.UnicodeBlock.of(c);
return ub == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS
|| ub == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A
|| ub == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_B
|| ub == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_C
|| ub == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_D
|| ub == Character.UnicodeBlock.CJK_COMPATIBILITY_IDEOGRAPHS
|| ub == Character.UnicodeBlock.CJK_COMPATIBILITY_IDEOGRAPHS_SUPPLEMENT;
}

/**
* 使用UnicodeScript方法判断
* 是否为中文
*
* @param c 字符
* @return 是否
*/
public static boolean isChineseByScript(char c) {
Character.UnicodeScript sc = Character.UnicodeScript.of(c);
return sc == Character.UnicodeScript.HAN;
}


/**
* 根据UnicodeBlock方法判断中文标点符号
*
* @param c 字符
* @return 是否
*/
public static boolean isChinesePunctuation(char c) {
Character.UnicodeBlock ub = Character.UnicodeBlock.of(c);
return ub == Character.UnicodeBlock.GENERAL_PUNCTUATION
|| ub == Character.UnicodeBlock.CJK_SYMBOLS_AND_PUNCTUATION
|| ub == Character.UnicodeBlock.HALFWIDTH_AND_FULLWIDTH_FORMS
|| ub == Character.UnicodeBlock.CJK_COMPATIBILITY_FORMS
|| ub == Character.UnicodeBlock.VERTICAL_FORMS;
}


/**
* 根据UnicodeBlock方法是否拉丁1-增补
*
* @param c 字符
* @return 是否
*/
public static boolean isLatin1Supplement(char c) {
Character.UnicodeBlock ub = Character.UnicodeBlock.of(c);
return ub == Character.UnicodeBlock.LATIN_1_SUPPLEMENT;
}


/**
* 根据UnicodeBlock方法是否基本拉丁语
*
* @param c 字符
* @return 是否
*/
public static boolean isBasic(char c) {
Character.UnicodeBlock ub = Character.UnicodeBlock.of(c);
return ub == Character.UnicodeBlock.BASIC_LATIN;
}

测试类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@Test
void gbk() throws UnsupportedEncodingException {
// 错误=张·三 end
// 正确=张·三 end
// 固定长度10
String spaceStr = "张·三";
log.debug(GbkUtils.jointSpace(10, spaceStr));
String str = "减法10−2=8,下划线1_2,中横线1-2,少数民族名张·三,中文符号,。;,全角123,繁體張三";
StringBuilder newStr = new StringBuilder();
for (int i = 0; i < str.length(); i++) {
char c = str.charAt(i);
log.debug("{},gbk_length={},block={},chinese={},latin={}", c, Character.toString(c).getBytes("GBK").length
, Character.UnicodeBlock.of(c), GbkUtils.isAllChineseByBlock(c), GbkUtils.isLatin1Supplement(c));
if (GbkUtils.isAllChineseByBlock(c) || GbkUtils.isLatin1Supplement(c) || GbkUtils.isBasic(c)) {
newStr.append(c);
} else {
// 无法识别的字符转换为.
newStr.append(".");
}
}
// 数学符号
assertFalse(GbkUtils.isAllChineseByBlock("−".charAt(0)));
assertTrue(GbkUtils.isAllChineseByBlock("中".charAt(0)));
assertTrue(GbkUtils.isAllChineseByBlock("體".charAt(0)));
assertTrue(GbkUtils.isAllChineseByBlock("。".charAt(0)));
assertTrue(GbkUtils.isAllChineseByBlock("1".charAt(0)));
// 基本拉丁语
assertTrue(GbkUtils.isBasic("a".charAt(0)));
log.debug("\n str={}\nGBK的乱码替换为.\nnewStr={}", str, newStr);
}

测试结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
25: 张·三end
26: 张·三 end
27: 张·三 end
32: 减,gbk_length=2,block=CJK_UNIFIED_IDEOGRAPHS,chinese=true,latin=false
32: 法,gbk_length=2,block=CJK_UNIFIED_IDEOGRAPHS,chinese=true,latin=false
32: 1,gbk_length=1,block=BASIC_LATIN,chinese=false,latin=false
32: 0,gbk_length=1,block=BASIC_LATIN,chinese=false,latin=false
32: −,gbk_length=1,block=MATHEMATICAL_OPERATORS,chinese=false,latin=false
32: 2,gbk_length=1,block=BASIC_LATIN,chinese=false,latin=false
32: =,gbk_length=1,block=BASIC_LATIN,chinese=false,latin=false
32: 8,gbk_length=1,block=BASIC_LATIN,chinese=false,latin=false
32: ,,gbk_length=1,block=BASIC_LATIN,chinese=false,latin=false
32: 下,gbk_length=2,block=CJK_UNIFIED_IDEOGRAPHS,chinese=true,latin=false
32: 划,gbk_length=2,block=CJK_UNIFIED_IDEOGRAPHS,chinese=true,latin=false
32: 线,gbk_length=2,block=CJK_UNIFIED_IDEOGRAPHS,chinese=true,latin=false
32: 1,gbk_length=1,block=BASIC_LATIN,chinese=false,latin=false
32: _,gbk_length=1,block=BASIC_LATIN,chinese=false,latin=false
32: 2,gbk_length=1,block=BASIC_LATIN,chinese=false,latin=false
32: ,,gbk_length=1,block=BASIC_LATIN,chinese=false,latin=false
32: 中,gbk_length=2,block=CJK_UNIFIED_IDEOGRAPHS,chinese=true,latin=false
32: 横,gbk_length=2,block=CJK_UNIFIED_IDEOGRAPHS,chinese=true,latin=false
32: 线,gbk_length=2,block=CJK_UNIFIED_IDEOGRAPHS,chinese=true,latin=false
32: 1,gbk_length=1,block=BASIC_LATIN,chinese=false,latin=false
32: -,gbk_length=1,block=BASIC_LATIN,chinese=false,latin=false
32: 2,gbk_length=1,block=BASIC_LATIN,chinese=false,latin=false
32: ,,gbk_length=1,block=BASIC_LATIN,chinese=false,latin=false
32: 少,gbk_length=2,block=CJK_UNIFIED_IDEOGRAPHS,chinese=true,latin=false
32: 数,gbk_length=2,block=CJK_UNIFIED_IDEOGRAPHS,chinese=true,latin=false
32: 民,gbk_length=2,block=CJK_UNIFIED_IDEOGRAPHS,chinese=true,latin=false
32: 族,gbk_length=2,block=CJK_UNIFIED_IDEOGRAPHS,chinese=true,latin=false
32: 名,gbk_length=2,block=CJK_UNIFIED_IDEOGRAPHS,chinese=true,latin=false
32: 张,gbk_length=2,block=CJK_UNIFIED_IDEOGRAPHS,chinese=true,latin=false
32: ·,gbk_length=2,block=LATIN_1_SUPPLEMENT,chinese=false,latin=true
32: 三,gbk_length=2,block=CJK_UNIFIED_IDEOGRAPHS,chinese=true,latin=false
32: ,,gbk_length=1,block=BASIC_LATIN,chinese=false,latin=false
32: 中,gbk_length=2,block=CJK_UNIFIED_IDEOGRAPHS,chinese=true,latin=false
32: 文,gbk_length=2,block=CJK_UNIFIED_IDEOGRAPHS,chinese=true,latin=false
32: 符,gbk_length=2,block=CJK_UNIFIED_IDEOGRAPHS,chinese=true,latin=false
32: 号,gbk_length=2,block=CJK_UNIFIED_IDEOGRAPHS,chinese=true,latin=false
32: ,,gbk_length=2,block=HALFWIDTH_AND_FULLWIDTH_FORMS,chinese=true,latin=false
32: 。,gbk_length=2,block=CJK_SYMBOLS_AND_PUNCTUATION,chinese=true,latin=false
32: ;,gbk_length=2,block=HALFWIDTH_AND_FULLWIDTH_FORMS,chinese=true,latin=false
32: ,,gbk_length=1,block=BASIC_LATIN,chinese=false,latin=false
32: 全,gbk_length=2,block=CJK_UNIFIED_IDEOGRAPHS,chinese=true,latin=false
32: 角,gbk_length=2,block=CJK_UNIFIED_IDEOGRAPHS,chinese=true,latin=false
32: 1,gbk_length=2,block=HALFWIDTH_AND_FULLWIDTH_FORMS,chinese=true,latin=false
32: 2,gbk_length=2,block=HALFWIDTH_AND_FULLWIDTH_FORMS,chinese=true,latin=false
32: 3,gbk_length=2,block=HALFWIDTH_AND_FULLWIDTH_FORMS,chinese=true,latin=false
32: ,,gbk_length=1,block=BASIC_LATIN,chinese=false,latin=false
32: 繁,gbk_length=2,block=CJK_UNIFIED_IDEOGRAPHS,chinese=true,latin=false
32: 體,gbk_length=2,block=CJK_UNIFIED_IDEOGRAPHS,chinese=true,latin=false
32: 張,gbk_length=2,block=CJK_UNIFIED_IDEOGRAPHS,chinese=true,latin=false
32: 三,gbk_length=2,block=CJK_UNIFIED_IDEOGRAPHS,chinese=true,latin=false
49:
str=减法10−2=8,下划线1_2,中横线1-2,少数民族名张·三,中文符号,。;,全角123,繁體張三
GBK的乱码替换为.
newStr=减法10.2=8,下划线1_2,中横线1-2,少数民族名张·三,中文符号,。;,全角123,繁體張三

说明

Spring Boot 使用Redis做缓存,并且使用jackson做序列化

程序版本

Jdk14
Spring Boot 2.6.6

启动程序

1
2
3
4
5
6
7
@EnableScheduling
@EnableJpaAuditing
// 这里要,或者放在配置文件上
@EnableCaching
@SpringBootApplication
public class AppServer {
}

RedisConfig

Spring Boot 默认是不序列化的缓存,redis的内容几乎没有可读性
所以要用JSON工具序列化

方法一

1
2
3
4
5
6
7
8
9
10
11
12
13
@Bean
public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
RedisCacheWriter redisCacheWriter = RedisCacheWriter.nonLockingRedisCacheWriter(redisConnectionFactory);
// 配置cache 序列化为jsonSerializer
RedisSerializer<Object> jsonSerializer = new GenericJackson2JsonRedisSerializer();
RedisSerializationContext.SerializationPair<Object> pair = RedisSerializationContext.SerializationPair.fromSerializer(jsonSerializer);
RedisCacheConfiguration defaultCacheConfig = RedisCacheConfiguration.defaultCacheConfig().serializeValuesWith(pair);
// 设置默认过期时间
defaultCacheConfig.entryTtl(Duration.ofDays(30));

// 也可以通过builder来构建
return new RedisCacheManager(redisCacheWriter, defaultCacheConfig);
}

方法二

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Bean
public CacheManager cacheManager(RedisConnectionFactory factory) {
RedisSerializer<String> redisSerializer = new StringRedisSerializer();
Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
ObjectMapper om = new ObjectMapper();
om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
// 有地方出错说要注释掉这里,但注释掉这里别的又出错
om.activateDefaultTyping(
om.getPolymorphicTypeValidator(),
ObjectMapper.DefaultTyping.NON_FINAL,
JsonTypeInfo.As.PROPERTY);
jackson2JsonRedisSerializer.setObjectMapper(om);
// 配置序列化
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofDays(30))
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(redisSerializer))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jackson2JsonRedisSerializer))
.disableCachingNullValues();
return RedisCacheManager.builder(factory).cacheDefaults(config).build();
}

Redis存入的数据

1
2
3
4
5
6
7
8
9
// 数组
[
"java.util.ArrayList",//这里很重要,意味着是否能够序列化
[]
]
// 对象
{
"@class": "*.*.Object",
}

缓存设置

  • 特别注意
    • stream流写法,序列化会出现没有对象说明的情况,在读取缓存时候就报错了
    • [ “java.util.ArrayList”, 这个就不会出现在redis中
    • return userDao.findAll(spec).stream().map(UserConverter.INSTANCE::fromEntity).toList();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// 缓存更新
@CachePut(value = "user", key = "#user.id")
// 缓存全部清空
@CacheEvict(value = "allUsers", allEntries = true)
public UserMo save(User user) {
return UserConverter.INSTANCE.fromEntity(userDao.save(user));
}

@Override
@Caching(
evict = {
// 缓存全部清空
@CacheEvict(value = "allUsers", allEntries = true),
// 缓存根据key删除
@CacheEvict(value = "user", key = "#id")
}
)
public void delById(String id) {
super.delById(id);
}

// 缓存, key也可以自定义
@Cacheable(value = "allUsers")
public List<UserMo> allUsers() {
Specification<User> spec = (root, query, criteriaBuilder) -> {
List<Predicate> predicates = new ArrayList<>();
predicates.add(criteriaBuilder.equal(root.get("state"), 1));
return criteriaBuilder.and(predicates.toArray(new Predicate[0]));
};
// 此写法缓存序列化有问题
// return userDao.findAll(spec).stream().map(UserConverter.INSTANCE::fromEntity).toList();
List<User> all = userDao.findAll(spec);
List<UserMo> userMoList = new ArrayList<>();
for (User user : all) {
userMoList.add(UserConverter.INSTANCE.fromEntity(user));
}
return userMoList;
}

// 缓存
@Cacheable(value = "users")
public List<User> users() {
return userDao.findAll();
}

// 缓存
@Cacheable(value = "user", key = "#id")
public UserMo getById(String id) {
User user = userDao.findById(id).orElse(null);
return UserConverter.INSTANCE.fromEntity(user);
}