开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

【Deprecated】计算机基础:Unicode 和 UT

发表于 2020-10-26

这篇文章是 2020 年写的,今年我更新了一些新内容,你可以直接看:计算机基础:今天一次把 Unicode 和 UTF-8 说清楚

⭐️ 本文已收录到 AndroidFamily,技术和职场问题,请关注公众号 [彭旭锐] 和 [BaguTree Pro] 知识星球提问。

前言

  • 在日常开发过程中,Unicode & UTF-8并不是很受关注的知识,但在阅读源码或文章时,出现频率很高;
  • 在这篇文章里,我将带你理解Unicode 字符集的原理,希望能帮上忙。

  1. 字符编码简介

  • 字符(character) 是文字和符号的总称,例如汉字、拉丁字母、emoji 都是字符。一个字符由两个要素组成:一个是用户看到的图画,另一个则是图画背后的编码。
    【图】字符 (图与编号)
# 咬文嚼字

很多名词都可以带上“编码”两个字,容易混淆。这里列举出“编码”的三层含义,以后在文章中看到“编码”再结合上下文即可理解作者的意思。

  • 作为动词,表示把一个字符转换为一个二进制机器数的过程,这个机器数才是字符在计算机中真实存储/传输的格式。例如把 A 转换为65(ASCII)的动作,就是编码;
  • 作为名词,可以表示字符转换为机器数之后的那个值,对于 A 来说,65(ASCII)就是 A 的编码(值),有时会称为编号;
  • 作为名词,可以表示把字符转换为机器数的编码方案,例如 ASCII 编码、GBK 编码、UTF-8 编码。
  • 字符集(character set) 是多个字符与字符编码组成的系统称为,由于历史的原因,曾经发展出多种字符集,具体如下:

i

  • 兼容性问题:字符相同但编码不同
    正是因为历史上出现多种字符编码集,相互之间无法相互兼容,甚至连 emoji 最初也不具备兼容性。
    例如,最早的 emoji 在日本的一些手机厂商创造并流行起来,使得 emoji 在不同厂商的设备间无法兼容。要想正确解析一个字符编码,就需要先知道它使用的字符编码集,否则用错误的字符集解读,就会出现乱码。想象以下,你发送的一个在女朋友的手机上看到的是另一个 emoji,是一件多么可怕的事情。

  1. Unicode 的编号规则

为了解决字符集间互不兼容的问题,包罗万象的 Unicode 字符集出场了,要点如下:

  • 码点
    从0开始编号,每个字符都分配一个唯一的码点(code point),例如U+0011。完整的十六进制格式是U+[XX]XXXX,具体可表示的范围为 U+0000 ~ U+10FFFF,所需要的空间最大为3个字节的空间。这个范围可以容纳超过100万个字符,全世界已创造的字符都包含在里面。完整的unicode码点列表可以参考:unicode.org

image.png

  • 字符平面映射
    这么多字符并不是一次性定义完成的,而是采用了分组的方式。每一个组称为一个平面(Plane),每个平面有216=655362^{16} = 65536216=65536个数。从U+[00]XXXX到U+[10]XXXX一共有17个平面,其中第一个平面为基本多文种平面(Basic Multilingual Plane, BMP),后面的16个平面称为辅助平面(Supplementary Plane),辅助平面目前只使用了一小部分。

  1. Unicode 的三种实现方式

Unicode的实现方式不同于编码方式。一个字符的 Unicode 编码是确定的,但是在实际存储 / 传输中,处于节省空间或运算效率的考量,使用的 Unicode 编码的实现方式有所不同。Unicode 的实现方式称为 Unicode转换格式(Unicode Transformation Format,简称为 UTF),常见的有 UTF-8、UTF-16 和 UTF-32。

3.1 UTF-32

UTF-32使用4个字节的定长编码,前面说到Unicode码点最大需要3个字节的空间,这对于4个字节UTF-32编码来说绰绰有余。

  • 规则:
  1. 编码值是码点的原码,例如:
1
2
3
4
5
> ini复制代码U+0000   => 0x00000000
> U+6C38 => 0x00006C38
> U+10FFFF => 0x0010FFFF
>
>
  • 缺点:
    任何一个码点编码后都需要4个字节的空间,每个字符都会浪费 1~3 个字节的存储空间
  • 优点:
    解码方式的转换规则简单,编解码效率更快

3.2 UTF-16

UTF-16是2个字节或4个字节的变长编码,结合了UTF-8和UTF-32两者的特点

  • 规则:
  1. 编号范围在U+0000 ~ U+FFFF的码点(基本平面)使用2个字节表示;
  2. 编号范围在U+10000 ~ U+10FFFF的码点(辅助平面)使用4个字节表示:
    16个辅助平面总共有2202^{20}220个字符,需要20bits的空间才能区分。UTF-16将这20位拆成两半,高10位映射在U+D800 ~ U+DBFF,称为高位代理(high surrogate),低10位映射在U+DC00 ~ U+DFFF,称为低位代理(low surrogate)。

第一条规则比较好理解,怎么理解第二条规则呢?

我们知道辅助平面字符的范围是U+10000 ~ U+10FFFF,转换为二进制一共需要21 bits,1个char只有16位肯定是不够的,那么用2个char该如何表示呢?

最简单的方法一个char表示低16位,另一个char表示高5位(多余11位置零),如下图所示:

怎么理解前缀有歧义?假如给到一个char,它的机器数范围是0 ~ 0xFFFF,那么它既可能是基本平面字符的前缀,也可能是辅助平面字符的前缀。那么对于一串字符流(字节流同理),我们就无法区分出哪一个char应该单独解析为基本平面字符,哪一个char应该和后继的一个char合起来解析为辅助平面字符。

为了解决这个问题,必须实现前缀无歧义编码(PFC编码,类似的还有哈弗曼编码)。UTF-16的方案是将用于基本平面字符char和辅助平面字符的char的机器数范围错开,这个方案的前提就是在基本平面中有一段区域是专门空出一段区域作为UTF-16的代理:

Plane 0中,浅灰色的 D8 ~ DF 为 UTF-16 代理区 —— 引用自维基百科

具体解释如下:

    1. 辅助平面字符的范围是U+10000 ~ U+10FFFF,换句话说,第一个辅助平面字符是0x10000。那么就可先把每个码点减去0x10000,映射到U+0000 ~ U+0AFFFF,这样的好处是只需要20 bits就能表示所有辅助平面字符(否则需要21 bits)。
    1. 20 bits依旧是超过了char的表示范围,那就用两个char吧,平分正好是10 bits为一组:用一个char表示低10位,另一个char表示高10位(多余位置零),即codepoint=high<<10+low+0x10000code point = high << 10 + low + 0x10000codepoint=high<<10+low+0x10000
    1. 分别给highhighhigh和lowlowlow一个偏移量,使得char的机器数落在代理区(high偏移0xD800,low偏移0xDC00),即codepoint=((high−0xD800)<<10)+low−0xDC00+0x10000code point = ((high - 0xD800)<< 10 ) + low - 0xDC00 + 0x10000codepoint=((high−0xD800)<<10)+low−0xDC00+0x10000
      反过来,则有:
      high=(codepoint−0x10000)>>>10+0xD800high = (codepoint - 0x10000) >>>10 + 0xD800high=(codepoint−0x10000)>>>10+0xD800
      low=(codepoint & 0x3FFF)+0xDC00low = (codepoint\ & \ 0x3FFF) + 0xDC00low=(codepoint & 0x3FFF)+0xDC00

下表举例了一起字符的转换过程:

UTF-16 示例 —— 引用自维基百科

Java的String的内存表示基于UTF-16 BE编码,我们可以在String与Character中找到相应的支持:

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
java复制代码// String.java

public String(int[] codePoints, int offset, int count) {
// 0. 前处理:参数不合法的情况
final int end = offset + count;

// 1. 计算总共需要的char数组容量
int n = count;
for (int i = offset; i < end; i++) {
int c = codePoints[i];
// 分析点 1.1
if (Character.isBmpCodePoint(c))
continue;
// 分析点 1.2
else if (Character.isValidCodePoint(c))
n++; // 每个辅助平面字符需要多一个char
else throw new IllegalArgumentException(Integer.toString(c));
}

// 2. 分配数组并填充数据
final char[] v = new char[n];
for (int i = offset, j = 0; i < end; i++, j++) {
int c = codePoints[i];
// 分析点 2.1
if (Character.isBmpCodePoint(c))
v[j] = (char)c;
else
// 分析点 2.2
Character.toSurrogates(c, v, j++);
}
// 结束
this.value = v;
}

// Character.java

// 分析点 1.1:判断码点是否处于基本平面
public static boolean isBmpCodePoint(int codePoint) {
return codePoint >>> 16 == 0;
}
// 分析点 1.2:判断码点是否处于辅助平面
public static boolean isValidCodePoint(int codePoint) {
int plane = codePoint >>> 16;
return plane < ((0x10FFFF + 1) >>> 16);
}
// 分析点 2.2:辅助平面字符 - 规则2
static void toSurrogates(int codePoint, char[] dst, int index) {
// high在高位,low在低位,是大端序
dst[index+1] = lowSurrogate(codePoint);
dst[index] = highSurrogate(codePoint);
}
// 计算高位代理
public static char highSurrogate(int codePoint) {
return (char) ((codePoint >>> 10) + (0xDBFF - (0x010000 >>> 10)));
}
// 计算低位代理
public static char lowSurrogate(int codePoint) {
return (char) ((codePoint & 0x3ff) + 0xDC00);
}

反过来,从UTF-16编码解码出codepoint的代码:

1
2
3
4
5
arduino复制代码// Character.java
public static int toCodePoint(char high, char low) {
// 源码有算术表达式优化,此处为等价逻辑
return ((high - 0xD800) << 10) + (low - 0xDC00) + 0x010000;
}

3.3 UTF-8

UTF-8是1~4个字节的变长编码

  • 规则:

下述规则表述与你在任何文章 / 百科里看到的规则表述不一样,但是逻辑上是一样的。因为笔者更倾向于使用前缀无歧义的概念理解UTF-8的编码规则。

    1. 不同范围的码点值使用不同长度的编码
    1. 1字节编码前缀为0、2字节编码前缀为110、3字节编码前缀为1110、4字节编码前缀为11110
    1. 每个码元的非首字节前缀为10

UTF-8是常用的Unicode编码方式,很多地方都会发现它的身影,例如:

  • 1. XML文件的编码
1
xml复制代码<?xml version="1.0" encoding="utf-8"?>
  • 2. Java 字节码中字符串常量的编码
类型 标示 描述
CONSTANT_Utf8_info 1 UTF-8编码的字符串
CONSTANT_String_info 8 字符串类型字面量

其中CONSTANT_Utf8_info常量的结构:

类型 名称 数量
u1 tag 1
u2 length 1
u1 bytes length

可以看到,Class文件中的字符串只支持基本平面字符,同时length的值说明UTF-8编码的字符串常量的字节数,u2能表达的最大值是65535,所以Java中定义的变量名和方法名超过64KB将无法通过编译。

  • 3. HTTP报文主体的编码
    HTTP报文首部字段Content-Type可以使用charset参数指定字符编码方式:
1
2
3
4
5
css复制代码HTTP/1.1 200 OK
... 省略
Content-Type:text/html; charset=UTF-8

[报文主体]

在OkHttp源码中,当响应报文首部字段content-type缺省时,默认按UTF-8解码,看源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
java复制代码// ResponseBody.java
// ResponseBody实例化的过程可以看下BridgeInterceptor.java

public final String string() throws IOException {
BufferedSource source = source();
try {
// 分析点 1
Charset charset = Util.bomAwareCharset(source, charset());
return source.readString(charset);
} finally {
Util.closeQuietly(source);
}
}
// 分析点1:获得解码需要的charset
private Charset charset() {
// contentType为null时,使用 UTF_8
MediaType contentType = contentType();
return contentType != null ? contentType.charset(UTF_8) : UTF_8;
}

3.4 小结

ASCII UTF-8 UTF-16 UTF-32 UCS-2
编号空间 0-7F 0-10FFFF 0-10FFFF 0-10FFFF 0-FFFF
最少字节 1 1 2 4 2
最多字字节 1 4 4 4 2
字节序 ×\times× ×\times× ✓\checkmark✓ ✓\checkmark✓ ✓\checkmark✓

参考资料

  • 《隔空传情: emoji 简史》 —— Google Play
  • 《字符编码笔记:ASCII,Unicode 和 UTF-8》 —— 阮一峰 著
  • 《Unicode与JavaScript详解》 —— 阮一峰 著
  • 《阮一峰老师文章的常识性错误之 Unicode 与 UTF-8》 —— 刘志军 著
  • 《Unicode》 —— 维基百科
  • 《UTF-8, a transformation format of ISO 10646》 —— 互联网工程任务组(IETF)
  • 《UTF-16, a transformation format of ISO 10646》 —— 互联网工程任务组(IETF)
  • 《Unicode Format for Network Interchange》 —— 互联网工程任务组(IETF)
  • 《编码·隐匿在计算机软硬件背后的语言》(第23章) —— [美] Charles Petzold 著

你的点赞对我意义重大!微信搜索公众号 [彭旭锐],希望大家可以一起讨论技术,找到志同道合的朋友,我们下次见!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Android 带你理解 NativeAllocatio

发表于 2020-10-26

点赞关注,不再迷路,你的支持对我意义重大!

🔥 Hi,我是丑丑。本文 GitHub · Android-NoteBook 已收录,这里有 Android 进阶成长路线笔记 & 博客,欢迎跟着彭丑丑一起成长。(联系方式在 GitHub)

前言

  • NativeAllocationRegistry是Android 8.0(API 27)引入的一种辅助回收native内存的机制,使用步骤并不复杂,但是关联的Java原理知识却不少
  • 这篇文章将带你理解NativeAllocationRegistry的原理,并分析相关源码。如果能帮上忙,请务必点赞加关注,这真的对我非常重要。

目录

  1. 使用步骤

从Android 8.0(API 27)开始,Android中很多地方可以看到NativeAllocationRegistry的身影,我们以Bitmap为例子介绍NativeAllocationRegistry的使用步骤,涉及文件:Bitmap.java、Bitmap.h、Bitmap.cpp

步骤1:创建 NativeAllocationRegistry

首先,我们看看实例化NativeAllocationRegistry的地方,具体在Bitmap的构造函数中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
java复制代码// # Android 8.0

// Bitmap.java

// called from JNI
Bitmap(long nativeBitmap,...){
// 省略其他代码...

// 【分析点 1:native 层需要的内存大小】
long nativeSize = NATIVE_ALLOCATION_SIZE + getAllocationByteCount();
// 【分析点 2:回收函数 nativeGetNativeFinalizer()】
// 【分析点 3:加载回收函数的类加载器:Bitmap.class.getClassLoader()】
NativeAllocationRegistry registry = new NativeAllocationRegistry(
Bitmap.class.getClassLoader(), nativeGetNativeFinalizer(), nativeSize);
// 注册 Java 层对象引用与 native 层对象的地址
registry.registerNativeAllocation(this, nativeBitmap);
}

private static final long NATIVE_ALLOCATION_SIZE = 32;
private static native long nativeGetNativeFinalizer();

可以看到,Bitmap的构造函数(在从JNI中调用)中实例化了NativeAllocationRegistry,并传递了三个参数:

参数 解释
classLoader 加载freeFunction函数的类加载器
freeFunction 回收native内存的native函数直接地址
size 分配的native内存大小(单位:字节)

步骤2:注册对象

紧接着,调用了registerNativeAllocation(...),并传递两个参数:

参数 解释
referent Java层对象的引用
nativeBitmap native层对象的地址
1
2
3
4
5
6
7
8
9
10
11
12
13
14
arduino复制代码// Bitmap.java

// called from JNI
Bitmap(long nativeBitmap,...){
// 省略其他代码...
// 注册 Java 层对象引用与 native 层对象的地址
registry.registerNativeAllocation(this, nativeBitmap);
}

// NativeAllocationRegistry.java

public Runnable registerNativeAllocation(Object referent, long nativePtr) {
// 代码省略,下文补充...
}

步骤3:回收内存

完成前面两步后,当Java层对象被垃圾回收后,NativeAllocationRegistry会自动回收注册的native内存。例如,我们加载几张图片,随后释放Bitmap的引用,可以观察到GC之后,native层的内存也自动回收了:

1
2
3
4
5
scss复制代码tv.setOnClickListener{
val map = HashSet<Any>()
for(index in 0 .. 2){
map.add(BitmapFactory.decodeResource(resources,R.drawable.test))
}
  • GC 前的内存分配情况 —— Android 8.0

  • GC 后的内存分配情况 —— Android 8.0


  1. 提出问题

掌握了NativeAllocationRegistry的作用和使用步骤后,很自然地会有一些疑问:

  • 为什么在Java层对象被垃圾回收后,native内存会自动被回收呢?
  • NativeAllocationRegistry是从Android 8.0(API 27)开始引入,那么在此之前,native内存是如何回收的呢?

通过分析NativeAllocationRegistry源码,我们将一步步解答这些问题,请继续往下看。


  1. NativeAllocationRegistry 源码分析

现在我们将视野回到到NativeAllocationRegistry的源码,涉及文件:NativeAllocationRegistry.java 、NativeAllocationRegistry_Delegate.java、libcore_util_NativeAllocationRegistry.cpp

3.1 构造函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
arduino复制代码// NativeAllocationRegistry.java

public class NativeAllocationRegistry {
// 加载 freeFunction 函数的类加载器
private final ClassLoader classLoader;
// 回收 native 内存的 native 函数直接地址
private final long freeFunction;
// 分配的 native 内存大小(字节)
private final long size;

public NativeAllocationRegistry(ClassLoader classLoader, long freeFunction, long size) {
if (size < 0) {
throw new IllegalArgumentException("Invalid native allocation size: " + size);
}

this.classLoader = classLoader;
this.freeFunction = freeFunction;
this.size = size;
}
}

可以看到,NativeAllocationRegistry的构造函数只是将三个参数保存下来,并没有执行额外操作。以Bitmap为例,三个参数在Bitmap的构造函数中获得,我们继续上一节未完成的分析过程:

  • 分析点 1:native 层需要的内存大小
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
arduino复制代码// Bitmap.java

// 【分析点 1:native 层需要的内存大小】
long nativeSize = NATIVE_ALLOCATION_SIZE + getAllocationByteCount();

public final int getAllocationByteCount() {
if (mRecycled) {
Log.w(TAG, "Called getAllocationByteCount() on a recycle()'d bitmap! "
+ "This is undefined behavior!");
return 0;
}
// 调用 native 方法
return nativeGetAllocationByteCount(mNativePtr);
}

private static final long NATIVE_ALLOCATION_SIZE = 32;

可以看到,nativeSize 由固定的32字节加上getAllocationByteCount(),总之,NativeAllocationRegistry需要一个native层内存大小的参数,这里就不展开了。关于Bitmap内存分配的详细分析请务必阅读文章:《Android | 各版本中 Bitmap 内存分配对比》

  • 分析点 2:回收函数 nativeGetNativeFinalizer()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
arduino复制代码// Bitmap.java

// 【分析点 2:回收函数 nativeGetNativeFinalizer()】
NativeAllocationRegistry registry = new NativeAllocationRegistry(
Bitmap.class.getClassLoader(), nativeGetNativeFinalizer(), nativeSize);

private static native long nativeGetNativeFinalizer();

// Java 层
// ----------------------------------------------------------------------
// native 层

// Bitmap.cpp
static jlong Bitmap_getNativeFinalizer(JNIEnv*, jobject) {
// 转为long
return static_cast<jlong>(reinterpret_cast<uintptr_t>(&Bitmap_destruct));
}

static void Bitmap_destruct(BitmapWrapper* bitmap) {
delete bitmap;
}

可以看到,nativeGetNativeFinalizer()是一个native函数,返回值是一个long,这个值其实相当于Bitmap_destruct()函数的直接地址。很明显,Bitmap_destruct()就是用来回收native层内存的。

那么,Bitmap_destruct()是在哪里调用的呢?继续往下看!

  • 分析点 3:加载回收函数的类加载器
1
2
arduino复制代码// Bitmap.java
Bitmap.class.getClassLoader()

另外,NativeAllocationRegistry还需要ClassLoader参数,文档注释指出:classloader是加载freeFunction所在native库的类加载器,但是NativeAllocationRegistry内部并没有使用这个参数。这里笔者也不理解为什么需要传递这个参数,如果有知道答案的小伙伴请告诉我一下~

3.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
java复制代码// Bitmap.java

// 注册 Java 层对象引用与 native 层对象的地址
registry.registerNativeAllocation(this, nativeBitmap);

// NativeAllocationRegistry.java

public Runnable registerNativeAllocation(Object referent, long nativePtr) {
if (referent == null) {
throw new IllegalArgumentException("referent is null");
}
if (nativePtr == 0) {
throw new IllegalArgumentException("nativePtr is null");
}

CleanerThunk thunk;
CleanerRunner result;
try {
thunk = new CleanerThunk();
Cleaner cleaner = Cleaner.create(referent, thunk);
result = new CleanerRunner(cleaner);
registerNativeAllocation(this.size);
} catch (VirtualMachineError vme /* probably OutOfMemoryError */) {
applyFreeFunction(freeFunction, nativePtr);
throw vme;
// Other exceptions are impossible.
// Enable the cleaner only after we can no longer throw anything, including OOME.
thunk.setNativePtr(nativePtr);
return result;
}

可以看到,registerNativeAllocation (...)方法参数是**Java层对象引用与native层对象的地址**。函数体乍一看是有点绕,笔者在这里也停留了好长一会。我们简化一下代码,try-catch代码先省略,函数返回值Runnable暂时用不到也先省略,瘦身后的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码// NativeAllocationRegistry.java

// (简化)
public void registerNativeAllocation(Object referent, long nativePtr) {
CleanerThunk thunk thunk = new CleanerThunk();
// Cleaner 绑定 Java 对象与回收函数
Cleaner cleaner = Cleaner.create(referent, thunk);
// 注册 native 内存
registerNativeAllocation(this.size);
thunk.setNativePtr(nativePtr);
}

private class CleanerThunk implements Runnable {
// 代码省略,下文补充...
}

看到这里,上文提出的第一个疑问就可以解释了,原来NativeAllocationRegistry内部是利用了sun.misc.Cleaner.java机制,简单来说:使用虚引用得知对象被GC的时机,在GC前执行额外的回收工作。若还不了解Java的四种引用类型,请务必阅读:《Java | 引用类型》

# 举一反三

DirectByteBuffer内部也是利用了Cleaner实现堆外内存的释放的。若不了解,请务必阅读:《Java | 堆内存与堆外内存》

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
csharp复制代码private class CleanerThunk implements Runnable {
// native 层对象的地址
private long nativePtr;

public CleanerThunk() {
this.nativePtr = 0;
}

public void run() {
if (nativePtr != 0) {
// 【分析点 4:执行内存回收方法】
applyFreeFunction(freeFunction, nativePtr);
// 【分析点 5:注销 native 内存】
registerNativeFree(size);
}
}

public void setNativePtr(long nativePtr) {
this.nativePtr = nativePtr;
}
}

继续往下看,CleanerThunk 其实是Runnable的实现类,run()在Java层对象被垃圾回收时触发,主要做了两件事:

  • 分析点 4:执行内存回收方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
arduino复制代码public static native void applyFreeFunction(long freeFunction, long nativePtr);

// NativeAllocationRegistry.cpp

typedef void (*FreeFunction)(void*);

static void NativeAllocationRegistry_applyFreeFunction(JNIEnv*,
jclass,
jlong freeFunction,
jlong ptr) {
void* nativePtr = reinterpret_cast<void*>(static_cast<uintptr_t>(ptr));
FreeFunction nativeFreeFunction = reinterpret_cast<FreeFunction>(static_cast<uintptr_t>(freeFunction));
// 调用回收函数
nativeFreeFunction(nativePtr);
}

可以看到,applyFreeFunction(...)最终就是执行到了前面提到的内存回收函数,对于Bitmap就是Bitmap_destruct()

  • 分析点 5:注册 / 注销native内存
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
arduino复制代码// NativeAllocationRegistry.java

// 注册 native 内存
registerNativeAllocation(this.size);
// 注销 native 内存
registerNativeFree(size);

// 提示:这一层函数其实就是为了将参数转为long
private static void registerNativeAllocation(long size) {
VMRuntime.getRuntime().registerNativeAllocation((int)Math.min(size, Integer.MAX_VALUE));
}

private static void registerNativeFree(long size) {
VMRuntime.getRuntime().registerNativeFree((int)Math.min(size, Integer.MAX_VALUE));
}

向VM注册native内存,比便在内存占用达到界限时触发GC,在该native内存回收时,需要向VM注销该内存量


  1. 对比 Android 8.0 之前回收 native 内存的方式

前面我们已经分析完NativeAllocationRegistry的源码了,我们看一看在Android 8.0之前,Bitmap是用什么方法回收native内存的,涉及文件:Bitmap.java (before Android 8.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
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
java复制代码// before Android 8.0

// Bitmap.java

private final long mNativePtr;
private final BitmapFinalizer mFinalizer;

// called from JNI
Bitmap(long nativeBitmap,...){
// 省略其他代码...
mNativePtr = nativeBitmap;
mFinalizer = new BitmapFinalizer(nativeBitmap);
int nativeAllocationByteCount = (buffer == null ? getByteCount() : 0);
mFinalizer.setNativeAllocationByteCount(nativeAllocationByteCount);
}

private static class BitmapFinalizer {
private long mNativeBitmap;

private int mNativeAllocationByteCount;

BitmapFinalizer(long nativeBitmap) {
mNativeBitmap = nativeBitmap;
}

public void setNativeAllocationByteCount(int nativeByteCount) {
if (mNativeAllocationByteCount != 0) {
// 注册 native 层内存
VMRuntime.getRuntime().registerNativeFree(mNativeAllocationByteCount);
}
mNativeAllocationByteCount = nativeByteCount;
if (mNativeAllocationByteCount != 0) {
// 注销 native 层内存
VMRuntime.getRuntime().registerNativeAllocation(mNativeAllocationByteCount);
}
}

@Override
public void finalize() {
try {
super.finalize();
} catch (Throwable t) {
// Ignore
} finally {
setNativeAllocationByteCount(0);
// 执行内存回收函数
nativeDestructor(mNativeBitmap);
mNativeBitmap = 0;
}
}
}

private static native void nativeDestructor(long nativeBitmap);

如果理解了NativeAllocationRegistry的源码,上面这段代码就很好理解呀!

  • 共同点:
    • 分配的native层内存需要向VM注册 / 注销
    • 通过一个native层的内存回收函数来回收内存
  • 不同点:
    • NativeAllocationRegistry依赖于sun.misc.Cleaner.java
    • BitmapFinalizer依赖于Object#finalize()

我们知道,finalize()在Java对象被垃圾回收时会调用,BitmapFinalizer就是利用了这个机制来回收native层内存的。若不了解,请务必阅读文章:《Java | 谈谈我对垃圾回收的理解》

再举几个常用的类在Android 8.0之前的源码为例子,原理都大同小异:Matrix.java (before Android 8.0)、Canvas.java (before Android 8.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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
java复制代码// Matrix.java

@Override
protected void finalize() throws Throwable {
try {
finalizer(native_instance);
} finally {
super.finalize();
}
}
private static native void finalizer(long native_instance);

// Canvas.java

private final CanvasFinalizer mFinalizer;
private static final class CanvasFinalizer {
private long mNativeCanvasWrapper;

public CanvasFinalizer(long nativeCanvas) {
mNativeCanvasWrapper = nativeCanvas;
}

@Override
protected void finalize() throws Throwable {
try {
dispose();
} finally {
super.finalize();
}
}

public void dispose() {
if (mNativeCanvasWrapper != 0) {
finalizer(mNativeCanvasWrapper);
mNativeCanvasWrapper = 0;
}
}
}

public Canvas() {
// 省略其他代码...
mFinalizer = new CanvasFinalizer(mNativeCanvasWrapper);
}
  1. 问题回归

  • NativeAllocationRegistry利用虚引用感知Java对象被回收的时机,来回收native层内存
  • 在Android 8.0 (API 27)之前,Android通常使用Object#finalize()调用时机来回收native层内存

推荐阅读

  • Java | 带你理解 ServiceLoader 的原理与设计思想
  • Android | 谈一谈 Matrix 与坐标变换
  • Android | 一文带你全面了解 AspectJ 框架
  • Android | 使用 AspectJ 限制按钮快速点击
  • Android | 这是一份详细的 EventBus 使用教程
  • 开发者 | 浅析App社交分享的5种形式
  • 计算机组成原理 | Unicode 和 UTF-8是什么关系?
  • 计算机组成原理 | 为什么浮点数运算不精确?(阿里笔试)

感谢喜欢!你的点赞是对我最大的鼓励!欢迎关注彭旭锐的GitHub!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Reactor响应式编程,你只差这个!

发表于 2020-10-25

哈哈哈哈哈,题目有点猖狂。但是既然你都来了,那就看看吧,毕竟响应式编程随着高并发对于性能的吃紧,越来越重要了。

哦对了,这是一篇Java文章。

废话不多说,直接步入正题。

响应式编程核心组件

步入正题之前,我希望你对发布者/订阅者模型有一些了解。

直接看图:

Talk is cheap, show you the code!

1
2
3
4
5
6
7
8
9
10
11
12
Java复制代码public class Main {

public static void main(String[] args) {
Flux<Integer> flux = Flux.range(0, 10);
flux.subscribe(i -> {
System.out.println("run1: " + i);
});
flux.subscribe(i -> {
System.out.println("run2: " + i);
});
}
}

输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
vbnet复制代码run1: 0
run1: 1
run1: 2
run1: 3
run1: 4
run1: 5
run1: 6
run1: 7
run1: 8
run1: 9
run2: 0
run2: 1
run2: 2
run2: 3
run2: 4
run2: 5
run2: 6
run2: 7
run2: 8
run2: 9

Process finished with exit code 0

Flux

Flux是一个多元素的生产者,言外之意,它可以生产多个元素,组成元素序列,供订阅者使用。

Mono

Mono和Flux的区别在于,它只能生产一个元素供生产者订阅,也就是数量的不同。

Mono的一个常见的应用就是Mono作为WebFlux的返回值。毕竟每次请求只有一个Response对象,所以Mono刚刚好。

快速创建一个Flux/Mono并订阅它

来看一些官方文档演示的方法。

1
2
3
4
5
6
7
8
9
10
Java复制代码Flux<String> seq1 = Flux.just("foo", "bar", "foobar");

List<String> iterable = Arrays.asList("foo", "bar", "foobar");
Flux<String> seq2 = Flux.fromIterable(iterable);

Mono<String> noData = Mono.empty();

Mono<String> data = Mono.just("foo");

Flux<Integer> numbersFromFiveToSeven = Flux.range(5, 3);

subscribe()方法(Lambda形式)

  • subscribe()方法默认接受一个Lambda表达式作为订阅者来使用。它有四个变种形式。
  • 在这里说明一下subscribe()第四个参数,指出了当订阅信号到达,初次请求的个数,如果是null则全部请求(Long.MAX_VALUE)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Java复制代码public class FluxIntegerWithSubscribe {

public static void main(String[] args) {
Flux<Integer> integerFlux = Flux.range(0, 10);
integerFlux.subscribe(i -> {
System.out.println("run");
System.out.println(i);
}, error -> {
System.out.println("error");
}, () -> {
System.out.println("done");
}, p -> {
p.request(2);
});
}
}

如果去掉初次请求,那么会请求最大值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Java复制代码public class FluxIntegerWithSubscribe {

public static void main(String[] args) {
Flux<Integer> integerFlux = Flux.range(0, 10);
// 在这里说明一下subscribe()第四个参数,指出了当订阅信号到达,初次请求的个数,如果是null则全部请求(Long.MAX_VALUE)
// 其余subscribe()详见源码或文档:https://projectreactor.io/docs/core/release/reference/#flux
integerFlux.subscribe(i -> {
System.out.println("run");
System.out.println(i);
}, error -> {
System.out.println("error");
}, () -> {
System.out.println("done");
});
}
}

输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
arduino复制代码run
0
run
1
run
2
run
3
run
4
run
5
run
6
run
7
run
8
run
9
done

Process finished with exit code 0

继承BaseSubscriber(非Lambda形式)

  • 这种方式更多像是对于Lambda表达式的一种替换表达。
  • 对于基于此方法的订阅,有一些注意事项,比如初次订阅时,要至少请求一次。否则会导致程序无法继续获得新的元素。
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
Java复制代码public class FluxWithBaseSubscriber {

public static void main(String[] args) {
Flux<Integer> integerFlux = Flux.range(0, 10);
integerFlux.subscribe(new MySubscriber());
}

/**
* 一般来说,通过继承BaseSubscriber<T>来实现,而且一般自定义hookOnSubscribe()和hookOnNext()方法
*/
private static class MySubscriber extends BaseSubscriber<Integer> {

/**
* 初次订阅时被调用
*/
@Override
protected void hookOnSubscribe(Subscription subscription) {
System.out.println("开始啦!");
// 记得至少请求一次,否则不会执行hookOnNext()方法
request(1);
}

/**
* 每次读取新值调用
*/
@Override
protected void hookOnNext(Integer value) {
System.out.println("开始读取...");
System.out.println(value);
// 指出下一次读取多少个
request(2);
}

@Override
protected void hookOnComplete() {
System.out.println("结束啦");
}
}
}

输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Java复制代码开始啦!
开始读取...
0
开始读取...
1
开始读取...
2
开始读取...
3
开始读取...
4
开始读取...
5
开始读取...
6
开始读取...
7
开始读取...
8
开始读取...
9
结束啦

Process finished with exit code 0

终止订阅:Disposable

  • Disposable是一个订阅时返回的接口,里面包含很多可以操作订阅的方法。
  • 比如取消订阅。

在这里使用多线程模拟生产者生产的很快,然后立马取消订阅(虽然立刻取消但是由于生产者实在太快了,所以订阅者还是接收到了一些元素)。

其他的方法,比如Disposables.composite()会得到一个Disposable的集合,调用它的dispose()方法会把集合里的所有Disposable的dispose()方法都调用。

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
Java复制代码public class FluxWithDisposable {

public static void main(String[] args) {
Disposable disposable = getDis();
// 每次打印数量一般不同,因为调用了disposable的dispose()方法进行了取消,不过如果生产者产地太快了,那么可能来不及终止。
disposable.dispose();
}

private static Disposable getDis() {
class Add implements Runnable {

private final FluxSink<Integer> fluxSink;

public Add(FluxSink<Integer> fluxSink) {
this.fluxSink = fluxSink;
}

@Override
public synchronized void run() {
fluxSink.next(new Random().nextInt());
}
}
Flux<Integer> integerFlux = Flux.create(integerFluxSink -> {
Add add = new Add(integerFluxSink);
new Thread(add).start();
new Thread(add).start();
new Thread(add).start();
new Thread(add).start();
new Thread(add).start();
new Thread(add).start();
new Thread(add).start();
new Thread(add).start();
new Thread(add).start();
new Thread(add).start();
new Thread(add).start();
});
return integerFlux.subscribe(System.out::println);
}
}

输出:

1
复制代码这里的输出每次调用可能都会不同,因为订阅之后取消了,所以能打印多少取决于那一瞬间CPU的速度。

调整发布者发布速率

  • 为了缓解订阅者压力,订阅者可以通过负压流回溯进行重塑发布者发布的速率。最典型的用法就是下面这个——通过继承BaseSubscriber来设置自己的请求速率。但是有一点必须明确,就是hookOnSubscribe()方法必须至少请求一次,不然你的发布者可能会“卡住”。
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
Java复制代码public class FluxWithLimitRate1 {

public static void main(String[] args) {
Flux<Integer> integerFlux = Flux.range(0, 100);
integerFlux.subscribe(new MySubscriber());
}

private static class MySubscriber extends BaseSubscriber<Integer> {

@Override
protected void hookOnSubscribe(Subscription subscription) {
System.out.println("开始啦!");
// 记得至少请求一次,否则不会执行hookOnNext()方法
request(1);
}

@Override
protected void hookOnNext(Integer value) {
System.out.println("开始读取...");
System.out.println(value);
// 指出下一次读取多少个
request(2);
}

@Override
protected void hookOnComplete() {
System.out.println("结束啦!");
}
}
}
  • 或者使用limitRate()实例方法进行限制,它返回一个被限制了速率的Flux或Mono。某些上流的操作可以更改下流订阅者的请求速率,有一些操作有一个prefetch整型作为输入,可以获取大于下流订阅者请求的数量的序列元素,这样做是为了处理它们自己的内部序列。这些预获取的操作方法一般默认预获取32个,不过为了优化;每次已经获取了预获取数量的75%的时候,会再获取75%。这叫“补充优化”。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Java复制代码public class FluxWithLimitRate2 {

public static void main(String[] args) {
Flux<Integer> integerFlux = Flux.range(0, 100);
// 最后,来看一些Flux提供的预获取方法:
// 指出预取数量
integerFlux.limitRate(10);
// lowTide指出预获取操作的补充优化的值,即修改75%的默认值;highTide指出预获取数量。
integerFlux.limitRate(10, 15);
// 哎~最典型的就是,请求无数:request(Long.MAX_VALUE)但是我给你limitRate(2);那你也只能乖乖每次得到两个哈哈哈哈!
// 还有一个就是limitRequest(N),它会把下流总请求限制为N。如果下流请求超过了N,那么只返回N个,否则返回实际数量。然后认为请求完成,向下流发送onComplete信号。
integerFlux.limitRequest(5).subscribe(new MySubscriber());
// 上面这个只会输出5个。
}
}

程序化地创建一个序列

静态同步方法:generate()

现在到了程序化生成Flux/Mono的时候。首先介绍generate()方法,这是一个同步的方法。言外之意就是,它是线程不安全的,且它的接收器只能一次一个的接受输入来生成Flux/Mono。也就是说,它在任意时刻只能被调用一次且只接受一个输入。

或者这么说,它生成的元素序列的顺序,取决于代码编写的方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Java复制代码public class FluxWithGenerate {

public static void main(String[] args) {
// 下面这个是它的变种方法之一:第一个参数是提供初始状态的,第二个参数是一个向接收器写入数据的生成器,入参为state(一般为整数,用来记录状态),和接收器。
// 其他变种请看源码
Flux.generate(() -> 0, (state, sink) -> {
sink.next(state+"asdf");
// 加上对于sink.complete()的调用即可终止生成;否则就是无限序列。
return state+1;
}).subscribe(System.out::println);
// generate方法的第三个参数用于结束生成时被调用,消耗state。
Flux.generate(AtomicInteger::new, (state, sink) -> {
sink.next(state.getAndIncrement()+"qwer");
return state;
}).subscribe(System.out::println);
// generate()的工作流看起来就像:next()->next()->next()->...
}
}
  • 通过上述代码不难看到,每次的接收器接受的值来自于上一次生成方法的返回值,也就是state=上一个迭代的返回值(其实称为上一个流才准确,这么说只是为了方便理解)。
  • 不过这个state每次都是一个全新的(每次都+1当然是新的),那么有没有什么方法可以做到前后两次迭代的state是同一个引用且还可以更新值呢?答案就是原子类型。也就是上面的第二种方式。

静态异步多线程方法:create()

说完了同步生成,接下来就是异步生成,还是多线程的!让我们有请:create()闪亮登场!!!

  • create()方法对外暴露出一个FluxSink对象,通过它我们可以访问并生成需要的序列。除此之外,它还可以触发回调中的多线程事件。
  • create另一特性就是很容易把其他的接口与响应式桥接起来。注意,它是异步多线程并不意味着create可以并行化你写的代码或者异步执行;怎么理解呢?就是,create方法里面的Lambda表达式代码还是单线程阻塞的。如果你在创建序列的地方阻塞了代码,那么可能造成订阅者即使请求了数据,也得不到,因为序列被阻塞了,没法生成新的。
  • 其实通过上面的现象可以猜测,默认情况下订阅者使用的线程和create使用的是一个线程,当然阻塞create就会导致订阅者没法运行咯!
  • 上述问题可以通过Scheduler解决,后面会提到。
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
Java复制代码public class FluxWithCreate {

public static void main(String[] args) throws InterruptedException {
TestProcessor<String> testProcessor = new TestProcessor<>() {

private TestListener<String> testListener;

@Override
public void register(TestListener<String> stringTestListener) {
this.testListener = stringTestListener;
}

@Override
public TestListener<String> get() {
return testListener;
}
};
Flux<String> flux = Flux.create(stringFluxSink -> testProcessor.register(new TestListener<String>() {
@Override
public void onChunk(List<String> chunk) {
for (String s : chunk) {
stringFluxSink.next(s);
}
}

@Override
public void onComplete() {
stringFluxSink.complete();
}
}));
flux.subscribe(System.out::println);
System.out.println("现在是2020/10/22 22:58;我好困");
TestListener<String> testListener = testProcessor.get();
Runnable1<String> runnable1 = new Runnable1<>() {

private TestListener<String> testListener;

@Override
public void set(TestListener<String> testListener) {
this.testListener = testListener;
}

@Override
public void run() {
List<String> list = new ArrayList<>(10);
for (int i = 0; i < 10; ++ i) {
list.add(i+"-run1");
}
testListener.onChunk(list);
}
};
Runnable1<String> runnable2 = new Runnable1<>() {

private TestListener<String> testListener;

@Override
public void set(TestListener<String> testListener) {
this.testListener = testListener;
}

@Override
public void run() {
List<String> list = new ArrayList<>(10);
for (int i = 0; i < 10; ++ i) {
list.add(i+"-run2");
}
testListener.onChunk(list);
}
};
Runnable1<String> runnable3 = new Runnable1<>() {

private TestListener<String> testListener;

@Override
public void set(TestListener<String> testListener) {
this.testListener = testListener;
}

@Override
public void run() {
List<String> list = new ArrayList<>(10);
for (int i = 0; i < 10; ++ i) {
list.add(i+"-run3");
}
testListener.onChunk(list);
}
};
runnable1.set(testListener);
runnable2.set(testListener);
runnable3.set(testListener);
// create所谓的"异步","多线程"指的是在多线程中调用sink.next()方法。这一点在下面的push对比中可以看到
new Thread(runnable1).start();
new Thread(runnable2).start();
new Thread(runnable3).start();
Thread.sleep(1000);
testListener.onComplete();
// 另一方面,create的另一个变体可以设置参数来实现负压控制,具体看源码。
}
public interface TestListener<T> {

void onChunk(List<T> chunk);

void onComplete();
}

public interface TestProcessor<T> {

void register(TestListener<T> tTestListener);

TestListener<T> get();
}

public interface Runnable1<T> extends Runnable {
void set(TestListener<T> testListener);
}
}

静态异步单线程方法:push()

说完了异步多线程,同步的生成方法,接下来就是异步单线程:push()。

其实说到push和create的对比,我个人理解如下:

  • create允许多线程环境下调用.next()方法,只管生成元素,元素序列的顺序取决于…算了,随机的,毕竟多线程;
  • 但是push只允许一个线程生产元素,所以是有序的,至于异步指的是在新的线程中也可以,而不必非得在当前线程。
  • 顺带一提,push和create都支持onCancel()和onDispose()操作。一般来说,onCancel只响应于cancel操作,而onDispose响应于error,cancel,complete等操作。
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
Java复制代码public class FluxWithPush {

public static void main(String[] args) throws InterruptedException {
TestProcessor<String> testProcessor = new TestProcessor<>() {

private TestListener<String> testListener;

@Override
public void register(TestListener<String> testListener) {
this.testListener = testListener;
}

@Override
public TestListener<String> get() {
return this.testListener;
}
};
Flux<String> flux = Flux.push(stringFluxSink -> testProcessor.register(new TestListener<>() {
@Override
public void onChunk(List<String> list) {
for (String s : list) {
stringFluxSink.next(s);
}
}

@Override
public void onComplete() {
stringFluxSink.complete();
}
}));
flux.subscribe(System.out::println);
Runnable1<String> runnable = new Runnable1<>() {

private TestListener<String> testListener;

@Override
public void set(TestListener<String> testListener) {
this.testListener = testListener;
}

@Override
public void run() {
List<String> list = new ArrayList<>(10);
for (int i = 0; i < 10; ++i) {
list.add(UUID.randomUUID().toString());
}
testListener.onChunk(list);
}
};
TestListener<String> testListener = testProcessor.get();
runnable.set(testListener);
new Thread(runnable).start();
Thread.sleep(15);
testListener.onComplete();
}

public interface TestListener<T> {
void onChunk(List<T> list);
void onComplete();
}

public interface TestProcessor<T> {
void register(TestListener<T> testListener);
TestListener<T> get();
}

public interface Runnable1<T> extends Runnable {
void set(TestListener<T> testListener);
}
}

同create一样,push也支持负压调节。但是我没写出来,我试过的Demo都是直接请求Long.MAX_VALUE,其实就是通过sink.onRequest(LongConsumer)方法调用来实现负压控制的。原理在这,想深究的请自行探索,鄙人不才,花费一下午没实现。

实例方法:handle()

在Flux的实例方法里,handle类似filter和map的操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Java复制代码public class FluxWithHandle {

public static void main(String[] args) {
Flux<String> stringFlux = Flux.push(stringFluxSink -> {
for (int i = 0; i < 10; ++ i) {
stringFluxSink.next(UUID.randomUUID().toString().substring(0, 5));
}
});
// 获取所有包含'a'的串
Flux<String> flux = stringFlux.handle((str, sink) -> {
String s = f(str);
if (s != null) {
sink.next(s);
}
});
flux.subscribe(System.out::println);
}

private static String f(String str) {
return str.contains("a") ? str : null;
}
}

线程和调度

Schedulers的那些静态方法

一般来说,响应式框架都不支持并发,P.s. create那个是生产者并发,它本身不是并发的。所以也没有可用的并发库,需要开发者自己实现。

同时,每一个操作一般都是在上一个操作所在的线程里运行,它们不会拥有自己的线程,而最顶的操作则是和subscribe()在同一个线程。比如Flux.create(…).handle(…).subscribe(…)都在主线程运行的。

在响应式框架里,Scheduler决定了操作在哪个线程被怎么执行,它的作用类似于ExecutorService。不过功能稍微多点。如果你想实现一些并发操作,那么可以考虑使用Schedulers提供的静态方法,来看看有哪些可用的:

Schedulers.immediate(): 直接在当前线程提交Runnable任务,并立即执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Java复制代码package com.learn.reactor.flux;

import reactor.core.scheduler.Schedulers;

/**
* @author Mr.M
*/
public class FluxWithSchedulers {

public static void main(String[] args) throws InterruptedException {
// Schedulers.immediate(): 直接在当前线程提交Runnable任务,并立即执行。
System.out.println("当前线程:" + Thread.currentThread().getName());
System.out.println("zxcv");
Schedulers.immediate().schedule(() -> {
System.out.println("当前线程是:" + Thread.currentThread().getName());
System.out.println("qwer");
});
System.out.println("asdf");
// 确保异步任务可以打印出来
Thread.sleep(1000);
}
}

通过上面看得出,immediate()其实就是在执行位置插入需要执行的Runnable来实现的。和直接把代码写在这里没什么区别。

Schedulers.newSingle():保证每次执行的操作都使用的是一个新的线程。

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
Java复制代码package com.learn.reactor.flux;

import reactor.core.scheduler.Schedulers;

/**
* @author Mr.M
*/
public class FluxWithSchedulers {

public static void main(String[] args) throws InterruptedException {
// 如果你想让每次调用都是一个新的线程的话,可以使用Schedulers.newSingle(),它可以保证每次执行的操作都使用的是一个新的线程。
Schedulers.single().schedule(() -> {
System.out.println("当前线程是:" + Thread.currentThread().getName());
System.out.println("bnmp");
});
Schedulers.single().schedule(() -> {
System.out.println("当前线程是:" + Thread.currentThread().getName());
System.out.println("ghjk");
});
Schedulers.newSingle("线程1").schedule(() -> {
System.out.println("当前线程是:" + Thread.currentThread().getName());
System.out.println("1234");
});
Schedulers.newSingle("线程1").schedule(() -> {
System.out.println("当前线程是:" + Thread.currentThread().getName());
System.out.println("5678");
});
Schedulers.newSingle("线程2").schedule(() -> {
System.out.println("当前线程是:" + Thread.currentThread().getName());
System.out.println("0100");
});
Thread.sleep(1000);
}
}

Schedulers.single(),它的作用是为当前操作开辟一个新的线程,但是记住,所有使用这个方法的操作都共用一个线程;

Schedulers.elastic():一个弹性无界线程池。

无界一般意味着不可管理,因为它可能会导致负压问题和过多的线程被创建。所以马上就要提到它的替代方法。

Schedulers.bounededElastic():有界可复用线程池

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
Java复制代码package com.learn.reactor.flux;

import reactor.core.scheduler.Schedulers;

/**
* @author Mr.M
*/
public class FluxWithSchedulers {

public static void main(String[] args) throws InterruptedException {
Schedulers.boundedElastic().schedule(() -> {
System.out.println("当前线程是:" + Thread.currentThread().getName());
System.out.println("1478");
});
Schedulers.boundedElastic().schedule(() -> {
System.out.println("当前线程是:" + Thread.currentThread().getName());
System.out.println("2589");
});
Schedulers.boundedElastic().schedule(() -> {
System.out.println("当前线程是:" + Thread.currentThread().getName());
System.out.println("0363");
});
Thread.sleep(1000);
}
}

Schedulers.boundedElastic()是一个更好的选择,因为它可以在需要的时候创建工作线程池,并复用空闲的池;同时,某些池如果空闲时间超过一个限定的数值就会被抛弃。

同时,它还有一个容量限制,一般10倍于CPU核心数,这是它后备线程池的最大容量。最多提交10万条任务,然后会被装进任务队列,等到有可用时再调度,如果是延时调度,那么延时开始时间是在有线程可用时才开始计算。

由此可见Schedulers.boundedElastic()对于阻塞的I/O操作是一个不错的选择,因为它可以让每一个操作都有自己的线程。但是记得,太多的线程会让系统备受压力。

Schedulers.parallel():提供了系统级并行的能力

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Java复制代码package com.learn.reactor.flux;

import reactor.core.scheduler.Schedulers;

/**
* @author Mr.M
*/
public class FluxWithSchedulers {

public static void main(String[] args) throws InterruptedException {
Schedulers.parallel().schedule(() -> {
System.out.println("当前线程是:" + Thread.currentThread().getName());
System.out.println("6541");
});
Schedulers.parallel().schedule(() -> {
System.out.println("当前线程是:" + Thread.currentThread().getName());
System.out.println("9874");
});
Thread.sleep(1000);
}
}

最后,Schedulers.parallel()提供了并行的能力,它会创建数量等于CPU核心数的线程来实现这一功能。

其他线程操作

顺带一提,还可以通过ExecutorService创建新的Scheduler。当然,Schedulers的一堆newXXX方法也可以。

有一点很重要,就是boundedElastic()方法可以适用于传统阻塞式代码,但是single()和parallel()都不行,如果你非要这么做那就会抛异常。自定义Schedulers可以通过设置ThreadFactory属性来设置接收的线程是否是被NonBlocking接口修饰的Thread实例。

Flux的某些方法会使用默认的Scheduler,比如Flux.interval()方法就默认使用Schedulers.parallel()方法,当然可以通过设置Scheduler来更改这种默认。

在响应式链中,有两种方式可以切换执行上下文,分别是publishOn()和subscribeOn()方法,前者在流式链中的位置很重要。在Reactor中,可以以任意形式添加任意数量的订阅者来满足你的需求,但是,只有在设置了订阅方法后,才能激活这条订阅链上的全部对象。只有这样,请求才会上溯到发布者,进而产生源序列。

在订阅链中切换执行上下文

publishOn()

publishOn()就和普通操作一样,添加在操作链的中间,它会影响在它下面的所有操作的执行上下文。看个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Java复制代码public class FluxWithPublishOnSubscribeOn {

public static void main(String[] args) throws InterruptedException {
// 创建一个并行线程
Scheduler s = Schedulers.newParallel("parallel-scheduler", 4);
final Flux<String> flux = Flux
.range(1, 2)
// map肯定是跑在T上的。
.map(i -> 10 + i)
// 此时的执行上下文被切换到了并行线程
.publishOn(s)
// 这个map还是跑在并行线程上的,因为publishOn()的后面的操作都被切换到了另一个执行上下文中。
.map(i -> "value " + i);
// 假设这个new出来的线程名为T
new Thread(() -> flux.subscribe(System.out::println));
Thread.sleep(1000);
}
}

subscribeOn()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Java复制代码public class FluxWithPublishOnSubscribeOn {

public static void main(String[] args) throws InterruptedException {
// 依旧是创建一个并行线程
Scheduler ss = Schedulers.newParallel("parallel-scheduler", 4);
final Flux<String> fluxflux = Flux
.range(1, 2)
// 不过这里的map就已经在ss里跑了
.map(i -> 10 + i)
// 这里切换,但是切换的是整个链
.subscribeOn(s)
// 这里的map也运行在ss上
.map(i -> "value " + i);
// 这是一个匿名线程TT
new Thread(() -> fluxflux.subscribe(System.out::println));
Thread.sleep(1000);
}
}

subscribeOn()方法会把订阅之后的整个订阅链都切换到新的执行上下文中。无论在subscribeOn()哪里,都可以把最前面的订阅之后的订阅序列进行切换,当然了,如果后面还有publishOn(),publishOn()会进行新的切换。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

密码学 庐山真面!你认为 Base64 是加密算法吗?

发表于 2020-10-25

点赞关注,不再迷路,你的支持对我意义重大!

🔥 Hi,我是丑丑。本文 GitHub · Android-NoteBook 已收录,这里有 Android 进阶成长路线笔记 & 博客,欢迎跟着彭丑丑一起成长。(联系方式在 GitHub)

前言

  • 对网络通信有所了解的同学,应该都听过Base64编码。例如,我们一段数据通过MD5 、SHA等手段加密后,经过Base64编码为字符串就可以很方便地在网路上传输。那么Base64也算是一种加密算法吗?
  • 在这篇文章里,我将带你理解Base64的基本原理 & 实现,希望能帮上忙

系列文章

  • 《密码学 | 庐山真面!你认为 Base64 是加密算法吗?》
  • 《密码学 | 蓄势待发!说说什么是散列算法?》
  • 《密码学 | 高屋建瓴!摘要、签名与数字证书都是什么?》

相关文章

  • 《计算机网络 | 图解 DNS & HTTPDNS 原理》
  • 《Android | 他山之石,可以攻玉!一篇文章看懂 v1/v2/v3 签名机制》

目录

  1. 基本原理

Base64是一种将二进制流表示为 64 个字符的编码方式。标准的 Base64 使用的索引表为:

标准的 Base64 索引表 —— 引用自维基百科

举个例子,字符串"Base64 编码"经过编码后的结果为:QmFzZTY0IOe8lueggQ==。当然,这里隐含了以UTF-8作为字符编码的前提,如果使用了其他的字符编码方式,用Base64编码后就不是这个结果了。很多在线编解码的网站其实也是默认使用了UTF-8,但是没有明确说明。

1.1 标准 Base64 编码步骤

下面解释一下Base64的编码步骤:

  • 步骤1:数据输入
    在这一步骤,需要将原数据(字符串、图片、音频等任何数据)转换为二进制流。例如前面举的字符串的例子,则需要经过字符编码转换为二进制流。
  • 步骤2:分组转换
+ 从二进制流头部开始,每 6 位为一组,若不足 6 位,则低位补0
+ 每 6 位组成一个新的字节,高位 2 位补 0 ,此时已经获得二进制的`Base64`编码
  • 步骤3:转换为字符串
    将二进制的Base64编码每个字节映射为一个字符,例如0000 0000映射为 A,0011 1111映射为/,此时已经获得Base64编码字符串
  • 步骤4:末尾补位
    标准Base64编码字符串的长度为 4 的倍数,否则,在末尾补充=。例如前面的QmFzZTY0IOe8lueggQ==长度就是补充了两个=后,长度为 20。

整个编码步骤并不复杂,我们用一张示意图表示为:

Base64编码 示意图

1.2 非标准 Base64

  • Url Base 64
    标准Base 64中使用了'/',这在URL和文件系统中存在冲突,因此延伸出 Url Base64 算法,主要就是将'+'和'/'符号替换成了'-'和'_'符号。
  • MIME Base 64
    这是一种MIME友好格式,它输出每行为 76 个字符,每行末需追加回车换行符\r\n,不论每行是否够 76 个字符,都要添加一个回车换行符

1.3 意义

Base64能够将任何数据转换为易移植的字符串,避免了传输过程中失真问题。最初,Base64是为了解决电子邮件中无法直接使用非ASCII字符的问题。一段数据先经过Base64编码为ASCII字符串后,可以在接收端,通过Base64解码还原为原数据后,而无需担心传输过程中失真。

很多时候,我们都将Base64编码作为数据加密后的传输 / 存储格式。例如,一段明文数据通过MD5 、SHA等手段加密后,经过Base64编码为字符串,就可以很方便地进行传输 & 存储。再比如,网络上的数字证书其实也是使用Base64编码的形式传输的,我们可以在浏览器上查看百度官网的数字证书:

  • 1、 查看百度官网的数字证书

  • 2、将证书保存到本地

  • 3、证书以 Base64 编码格式存储

需要注意的是,Base64并不是一种加密方式,明文使用Base64编码后的字符串通过索引表可以直接还原为明文。因此,Base64只能作为一种数据的存储格式。


  1. 算法实现

2.1 Java 环境

在Java 8之前,JDK中并没有提供Base64的算法实现,这其实挺让人纳闷的。虽然源码中sun.misc.BASE64Encoder,但是它其实并不是公有 API,而是 sun 团队内部使用的 API,最好不要在生产中使用。从Java 8,JDK 总算是补充了Base64的实现,例如:

1
2
3
4
5
6
7
8
9
10
less复制代码import java.util.Base64;

标准 Base 64
System.out.println(Base64.getEncoder().encodeToString("".getBytes()));

Url Base 64
System.out.println(Base64.getUrlEncoder().encodeToString("".getBytes()));

MIME Base 64
System.out.println(Base64.getMimeEncoder().encodeToString("".getBytes()));

在Java 8之前,Bouncy Castle和Apache也提供了Base64的算法实现。

2.2 Android环境

Android SDK提供了Base64的算法实现,例如:

1
2
3
arduino复制代码import android.util.Base64;

System.out.println(Base64.encodeToString("".getBytes(),Base64.DEFAULT));

相对于Java 8的算法实现,Android提供的 API 更为灵活,可以通过flag自定义控制算法的输出。


  1. 总结

  • Base 64能够将任何数据转换为易移植的字符串,避免了传输过程中失真问题。
  • 需要注意的是,Base 64不是一种加密方式,只是一种编码方式。很多时候,我们都将Base64编码作为数据加密后的传输 / 存储格式

参考资料

  • 《Java加密与解密的艺术》(第5章) —— 梁栋 著
  • 《HTTP权威指南》 —— [美] David Gourley,Brian Totty等 著
  • 《Base64》 —— 维基百科

推荐阅读

  • Java | 带你理解 ServiceLoader 的原理与设计思想
  • Java | 请概述一下 Class 文件的结构
  • Java | 聊一聊编译过程(编译前端 & 编译后端)
  • Java | 为什么 Java 实现了平台无关性?
  • Android | 一个进程有多少个 Context 对象(答对的不多)
  • Android | 带你理解 NativeAllocationRegistry 的原理与设计思想
  • Android | 一文带你全面了解 AspectJ 框架
  • 计算机组成原理 | Unicode 和 UTF-8是什么关系?
  • 计算机组成原理 | 为什么浮点数运算不精确?(阿里笔试)

感谢喜欢!你的点赞是对我最大的鼓励!欢迎关注彭旭锐的GitHub!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

听说过spring-data-jdbc么?来个最佳实践

发表于 2020-10-24

原创:猿逻辑,欢迎分享,转载请保留出处。跟着小q学Java,最快的进阶方式。

本文的完整示例代码,见github仓库。小q只在文中介绍最关键的代码块。

1
bash复制代码https://github.com/yuanluoji/purestart-springboot-data-jdbc

很多人知道Mybatis,知道Jpa,但对2019年新诞生的一门技术知之甚少。那就是:spring-data-jdbc。这个标题起的很普通,但是内容绝对是最新的。

注意我们这里说的是data-jdbc,而不是普通的jdbc。它拥有了类似jpa的一些特性,比如能够根据方法名推导出sql,基本的CRUD等,也拥有了写原生sql的能力。

最为关键的是,它非常的清爽,不需要依赖hibernte或者jpa。

千呼万唤始出来,使用了一下,真是惊艳。它们的关系可以看下面这张图。

可以看到spring-data-jdbc是和spring-data-jpa一样,同属于spring-data系列的。下面我们就来实践一把,来看一下它的最佳实践。

  1. 配置准备工作

创建好Springboot项目之后,需要加入spring-data-jdbc的依赖。

1
2
3
4
xml复制代码<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jdbc</artifactId>
</dependency>

为了方便演示,我这里使用的是h2数据库。可以在springboot中配置开启它的web配置端。

1
2
3
4
5
6
7
yaml复制代码  h2:
console:
enable: true
path: /h2-console
settings:
trace: true
web-allow-others: true

启动之后,就可以通过下面的地址访问h2的console。

可以看到里面有一张叫做goods_basic的表。它是怎么创建进去的呢?先来看一下我们的datasource配置。

1
2
3
4
5
6
yaml复制代码spring:
datasource:
driver-class-name: org.h2.Driver
url: jdbc:h2:mem:test;MODE=MYSQL;CASE_INSENSITIVE_IDENTIFIERS=TRUE;
schema: classpath:sql/h2/schema.sql
#data: classpath:sql/h2/data.sql

其中, spring.datasource.schema所指定的sql文件,将会在项目启动的时候,自动执行,这当然也是有AutoConfigure来完成的。来看一下我们的建表语句。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
sql复制代码CREATE TABLE `goods_basic` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`code` varchar(255) NOT NULL DEFAULT '' COMMENT '编码,不可重复;',
`barcode` varchar(255) NOT NULL COMMENT '69开头的13位标准码',
`short_name` varchar(255) NOT NULL COMMENT '商品名称',
`photos` json DEFAULT NULL COMMENT '商品图片',
`properties` json NOT NULL COMMENT '商品属性,或者规格',
`unit` varchar(8) NOT NULL COMMENT '单位;最多8个字节',
`state` tinyint NOT NULL DEFAULT '1',
`created` datetime NOT NULL COMMENT '创建时间',
`modified` datetime NOT NULL COMMENT '更新时间',
`version` bigint NOT NULL DEFAULT '0' COMMENT '乐观锁版本号',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_account_role` (`code`,`barcode`) USING BTREE
) ;

可以看到是彻头彻尾的mysql语法。通过在h2里面指定MODE=MYSQL属性,就可以把h2切换到mysql的语法。虽然h2在项目实际运行中感觉总是差那么一点意思,但对于测试来说,不得不说是个好工具。

到此为止,我们的准备工作就完成了,可以看到就是普通的datasource配置,简单的很。

2.如何启用spring-data-jdbc?

由于我们在前面引入的是starter的jar包,那就代表一些配置某人就在后台完成了。下面来看一下,创建一个Dao(Repository),是有多简单。

没错,我们只需要继承PagingAndSortingRepository或者CrudRepository,就可以了,和jpa的一样。

1
2
java复制代码org.springframework.data.repository.PagingAndSortingRepository
org.springframework.data.repository.CrudRepository

看一下上面的路径,和jpa和jdbc是没什么关系的,这就是spring-data抽象层的强大之处。

看一下我下面定义的这个dao,它实际上表现了常见的四种书写方式。

1
2
3
4
5
6
7
8
9
10
11
java复制代码/**
* Copyright (c) 2020. All Rights Reserved.
* @author yuanluoji
* @date 2020/10/16
*/
public interface GoodsBasicRepository extends PagingAndSortingRepository<GoodsBasic, Integer>,
Complex<GoodsBasic> {
List<GoodsBasic> findByCode(String code);
@Query("select * from goods_basic where code=:code")
List<GoodsBasic> findByCode2(String code);
}

2.1 默认的CRUD

当你继承了CrudRepository这个接口,就默认已经有了CRUD的能力,你可以调用save,findAll等方法,直接获取对实体的读写,无需再做任何映射。

这比MyBatis还要简单方便,因为MyBatis你要不的不上一个MyBatisPlus才能得到相同的功能。

当你继承了PagingAndSortingRepository接口,除了拥有CRUD,还拥有了分页的功能。

2.2 根据方法名直接查询

有一段时间,使用jpa,可以直接根据规则写方法名,不用写任何SQL,就可以完成查询功能。这个现在在jdbc中也有了。

代码中的findByCode方法,意思就是根据code,来查询当前实体。这个过程将被翻译成:

1
sql复制代码select * from goods_basic where code = :code

我们无需多些任何sql。下面,就是一张基本的映射表。 这可都是标准sql哦,都可以在方法名中完成。

2.3 使用Query注解

1
2
java复制代码@Query("select * from goods_basic where code=:code")
List<GoodsBasic> findByCode2(String code);

如果条件很多,这个方法名将会变得很长很长。在service层调用的时候你会一直喊卧槽!

这种复杂查询语句,你可能需要使用Query注解来完成。写在接口里的方法,此时将失去语意表达的意义。你可以使用任意的方法名,只需要把你的sql写在注解里就可以了。

2.4 灵活的自定义

但对于想要灵活的控制sql行为的应用来说,上面这几种方式就不行了。

比如,根据输入条件的有无,做一些逻辑判断。做一些类似mybatis的动态sql,或者使用StringBuilder来拼接一些sql。

注意我们的基础Dao,继承了一个接口,叫做Complex。spring-data-jdbc约定,这个接口的实现,放在ComplexImpl中,否则就会报错。所以,这又是一个约定所实现的魔法。

我们定定义了一个test方法,期望通过传入的code获取一个列表。

1
2
3
csharp复制代码public interface Complex<T> {
List<T> test(String code);
}

为了支持这种自定义查询,我做了一个基类。里面注入了一个jdbc的模版类,还注入了一个JdbcAggregateOperations。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码public abstract class BaseRepository {
/**
* 高度封装的JDBC操作,可直接保存实体
*/
@Getter
@Autowired
private JdbcAggregateOperations operations;
/**
* 普通命名的JDBC查询和操作,可使用 {@link org.springframework.jdbc.core.BeanPropertyRowMapper}
* 完成高级自动装配,可自动完成驼峰和下划线的自动映射
*/
@Getter
@Autowired
private NamedParameterJdbcTemplate template;
}

来看一下我们的实现类。

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码public class ComplexImpl<T> extends BaseRepository implements Complex<T> {
@Override
public List<T> test(String code) {
StringBuilder sb = new StringBuilder("select * from goods_basic");
Map params = new HashMap();
if (!StringUtils.isEmpty(code)) {
sb.append(" where code=:code");
params.put("code", code);
}
List x = getTemplate().query(sb.toString(), params, new BeanPropertyRowMapper<>(GoodsBasic.class));
return x;
}
}

使用BeanPropertyRowMapper,就可以避免繁琐的属性拷贝,代码变的非常清爽。

  1. 实体配置

很多时候,实体有许多的通用属性。这就需要抽取出来,在外面进行自定义。下面是我定义的一个基本的实体。包含id、创建爱你更新时间以及一个乐观锁版本号。这里的Id注解是org.springframework.data.annotation.Id,而不是javax的。

1
2
3
4
5
6
7
8
9
java复制代码@Data
public abstract class AbstractEntity {
@Id
private Long id;
private Date created;
private Date modified;
@Version
private Long version;
}

里面的created和modified字段,如果忘了写怎么办?难道要写一个过滤器么?

不需要那么麻烦,我们可以追加一个callback。

下面的代码,就是一个自动添加更新时间的例子。非常的好用。

1
2
3
4
5
6
7
8
9
10
java复制代码@Configuration
public class DataJdbcConfiguration extends AbstractJdbcConfiguration {
@Bean
public BeforeSaveCallback<AbstractEntity> absEntityBeforeSet() {
return (entity, aggregateChange) -> {
entity.setModified(new Date());
return entity;
};
}
}
  1. 小结

spring-data-jdbc是一个比较新的技术,现在的实践文章还是很少。小Q在这里尝试了一个语句的四种写法,对此还是深有感慨的。

现在的技术框架,背后做了很多工作,靠约定实现了很多功能。我来发表一下对于这些sql写作方式的见解。

1.CRUD方式

这个很简单,在不同的ORM框架下迁移也很方便,如果没有其他必要,建议只需要继承一个接口类就可以了。

2.根据方法名查询

这个在参数比较少的时候,比较推荐,因为很清晰,也能在jpa之间进行切换。

3.使用Query

对于稍微复杂的sql,建议使用这种方式。和jpa的写法一样,jpa中开启nativeSQL,和它的效果是一样的。

4.灵活自定义

这个约定方式不错,适合非常复杂的业务逻辑场景。

5.QueryDSL

querydsl作为一门通用的查询语言,用在Spring data jdbc上,也是可以的。但它要生成一些额外的代码,个人比较有洁癖,暂未使用。

可以说,有了sping-data-jdbc,又不需要ORM去给你做什么缓存,用起来真是爽呆了。

再次提醒,本文的完整示例代码,见github仓库。

1
bash复制代码https://github.com/yuanluoji/purestart-springboot-data-jdbc

如果对你有所帮助,请不要忘了为我点赞。你的支持是我创作的动力,后续会有更优质的内容分享给大家。

很多人都假装颓废,我劝你不要上当。不要放弃每一个想要学习的念头,因为那可能是未来的你在向你求救。我是小Q,与你共进步。放弃不难,但坚持一定很酷。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Android View & Fragment & Wi

发表于 2020-10-24

点赞关注,不再迷路,你的支持对我意义重大!

🔥 Hi,我是丑丑。本文 「Android 路线」| 导读 —— 从零到无穷大 已收录,这里有 Android 进阶成长路线笔记 & 博客,欢迎跟着彭丑丑一起成长。(联系方式在 GitHub)


前言

  • 最近,在玩安卓上看到 每日一问:View#getContext() 一定会返回 Activity 对象么? 直觉是:View 是由 Activity 管理的,那么 View#getContext() 一定是 Activity 了,事实真的如此吗?
  • 其实这个问题主要还是考察应试者对于源码(包括:Context类型、LayoutInflater 布局解析、View 体系等)的熟悉度,在这篇文章里,我将跟你一起探讨。如果能帮上忙,请务必点赞加关注,这真的对我非常重要。

相关文章

  • 《Android | 一个进程有多少个 Context 对象(答对的不多)》
  • 《Android | 带你探究 LayoutInflater 布局解析原理》
  • 《Android | View & Fragment & Window 的 getContext() 一定返回 Activity 吗?》
  • 《Android | 说说从 android:text 到 TextView 的过程》

目录


  1. 问题分析

1.1 Context 有哪些?

首先,我们回顾一下 Context 以及它的子类,在之前的这篇文章里,我们曾经讨论过:《Android | 一个进程有多少个 Context 对象(答对的不多)》。简单来说:Context 使用了装饰模式,除了 ContextImpl 外,其他 Context 都是 ContextWrapper 的子类。

我们熟悉的 Activity & Service & Application,都是 ContextWrapper 的子类。调用getBaseContext(),可以获得被代理的基础对象:

ContextWrapper.java

1
2
3
4
5
6
7
8
9
csharp复制代码Context mBase;

public ContextWrapper(Context base) {
mBase = base;
}

public Context getBaseContext() {
return mBase;
}

需要注意的是,Activity 也是可以作为被代理的对象的,类似这样:

1
2
3
4
5
6
ini复制代码Activity activity = ...;
Context wrapper = new ContextThemeWrapper(activity, themeResId);

wrapper.startActivity(...); // OK

wrapper instanceOf Activity // false

这个时候,代理对象wrapper可以使用 Activity 的能力,可以用它 startActivity(),也可以初始化 View,然而它却不是 Activity。 看到这里,我们似乎找到了问题的一点苗头了:getContext() 可能返回 Activity 的包装类,而不是 Activity。

1.2 问题延伸

网上讨论得比较多的,主要还是View#getContext()的返回值,在这篇文章里,我们将延伸一下,以下几种情况我都会归纳,以便帮助你建立更为清晰全面的认识:

    1. View#getContext()
    1. Fragment#getContext()
    1. Window#getContext()
    1. Dialog#getContext()

  1. View#getContext() 的返回值

我们来看View#getContext()的源码,可以看到,View#getContext()返回值是在构造函数中设置的,源码里未发现其它赋值语句。所以,这个问题的关键是看:实例化 View 时传入构造器的 Context 对象。

View.java

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码@hide
protected Context mContext;

public final Context getContext() {
return mContext;
}

public View(Context context) {
mContext = context;
...
}
...

在使用 View 的过程用,有两种方式可以实例化 View :

  • 方法1:代码调用,类似这样:new TextView(Context)

很明显,只要你传入什么对象,将来你调用 getContext(),得到的就是同一个对象。回顾 第 1 节 的讨论,你可以传入 Activity,也可以传入包装类。诶,那可以传入 Service、Application、ContextImpl 吗?还真的可以,只是你要保证 getContext() 后的行为正确,一般不会这么做。

1
2
3
4
5
scss复制代码new TextView(Activity)
new TextView(ContextWrapper)
new TextView(Service) 一般不会这么做
new TextView(Application) 一般不会这么做
new TextView(ContextImpl) 一般不会这么做
  • 方法2:布局文件,类似这样:<TextView ...>

这种方式其实是利用了 LayoutInflater 布局解析的能力,在之前的这篇文章里,我们曾经讨论过:《Android | 带你探究 LayoutInflater 布局解析原理》,如果你对 LayoutInflater 布局解析的流程还不熟悉,可以先复习下,相同的地方不再重复提。在这里,我们只关注使用反射实例化 View 的地方:

createViewFromTag(...) 示意图

可以看到,实例化 View 的地方使用了反射,而Constructor#newInstance(...)的首个参数即为将来 getContext() 返回的对象。那么,mConstructorArgs[0]到底是什么对象呢,是 Activity 吗?我们逆着源码找找看:

LayoutInflater.java

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
less复制代码public final View createView(@NonNull Context viewContext, @NonNull String name,
@Nullable String prefix, @Nullable AttributeSet attrs){
...
疑问:viewContext 到底是什么呢?
mConstructorArgs[0] = viewContext;
final View view = constructor.newInstance(mConstructorArgs);
...
}

createViewFromTag() -> createView()(已简化)
View createViewFromTag(View parent, String name, Context context, AttributeSet attrs, boolean ignoreThemeAttr) {

1. 应用 ContextThemeWrapper 以支持 android:theme
if (!ignoreThemeAttr) {
final TypedArray ta = context.obtainStyledAttributes(attrs, ATTRS_THEME);
final int themeResId = ta.getResourceId(0, 0);
if (themeResId != 0) {
1.1 注意:这里使用了包装类
context = new ContextThemeWrapper(context, themeResId);
}
ta.recycle();
}

2. 先使用 Factory2 / Factory 实例化 View,相当于拦截
3. 使用 mPrivateFactory 实例化 View,相当于拦截
4. 调用自身逻辑
if (view == null) {
view = createView(name, null, attrs);
}
return view;
}

// inflate() -> createViewFromTag()

public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
...
注意:使用了 mContext
final Context inflaterContext = mContext;
...
final View temp = createViewFromTag(root, name, inflaterContext, attrs);
...
}

protected LayoutInflater(Context context) {
mContext = context;
initPrecompiledViews();
}

AppCompatViewInflater.java

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
java复制代码2. 先使用 Factory2 / Factory 实例化 View,相当于拦截
final View createView(...) {
final Context originalContext = context;
2.1 应用 ContextThemeWrapper 以支持 android:theme / app:theme
if (readAndroidTheme || readAppTheme) {
context = themifyContext(context, attrs, readAndroidTheme, readAppTheme);
}
if (wrapContext) {
2.2 应用 ContextThemeWrapper 以支持矢量图 tint
context = TintContextWrapper.wrap(context);
}

View view = null;

switch (name) {
case "TextView":
2.3 实例化 AppCompatTextView
view = createTextView(context, attrs);
break;
...
default:
view = createView(context, name, attrs);
}
return view;
}

-> 2.1 应用 ContextThemeWrapper 以支持 android:theme(已简化)
private static Context themifyContext(Context context, AttributeSet attrs, boolean useAndroidTheme, boolean useAppTheme) {
// 事实上,分支 1.1 已经处理了,这里是兼容 Android 5.0 以前。
return new ContextThemeWrapper(context, themeId);
}

-> 2.2 应用 ContextThemeWrapper 以支持矢量图 android:tint(已简化)
public static Context wrap(@NonNull final Context context) {
return new TintContextWrapper(context);
}

AppCompatTextView.java

1
2
3
4
java复制代码-> 2.3 实例化 AppCompatTextView
public AppCompatTextView(Context context, AttributeSet attrs, int defStyleAttr) {
super(TintContextWrapper.wrap(context), attrs, defStyleAttr);
}

以上代码已经十分简化了,当然你也可以选择直接看结论:

小结:

  • 分支 1.1:应用 ContextThemeWrapper 以支持android:theme,此时 View#getContext() 返回这个包装类;
  • 分支 2.1:应用 ContextThemeWrapper 以支持android:theme(事实上,分支 1.1 已经处理了,这里是兼容 Android 5.0 前),同样也是返回包装类;
  • 分支 2.2:应用 ContextThemeWrapper 以支持矢量图android:tint,这是为了兼容 Android 5.0 以前不支持 tint,同样也是返回包装类;
  • 分支 2.3:实例化 AppCompatTextView,同样也是返回包装类;
  • 分支 4:返回的是 LayoutInflater#mContext,这个是LayoutInflater.from(Context)传入的参数。在 《Android | 带你探究 LayoutInflater 布局解析原理》里,我们讨论过:在 Activity / Fragment / View / Dialog 中,获取LayoutInflater#getContext(),返回的就是 Activity。

第 2 节讨论完后,下面这几节就容易多了。


  1. Dialog & Window 的 getContext() 的返回值

直接看源码:

Window.java

1
2
3
4
5
6
7
8
9
10
arduino复制代码private final Context mContext;

public final Context getContext() {
return mContext;
}

public Window(Context context) {
mContext = context;
mFeatures = mLocalFeatures = getDefaultFeatures(context);
}

Activity.java

1
2
3
4
5
6
javascript复制代码final void attach(Context context, ActivityThread aThread,...){
...
注意:mContext 为 Activity 本身
mWindow = new PhoneWindow(this, window, activityConfigCallback);
...
}

Dialog.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
less复制代码public Dialog(@NonNull Context context, @StyleRes int themeResId) {
this(context, themeResId, true);
}

Dialog(@NonNull Context context, @StyleRes int themeResId, boolean createContextThemeWrapper) {
if (createContextThemeWrapper) {
if (themeResId == Resources.ID_NULL) {
final TypedValue outValue = new TypedValue();
context.getTheme().resolveAttribute(R.attr.dialogTheme, outValue, true);
themeResId = outValue.resourceId;
}
包装为 ContextThemeWrapper
mContext = new ContextThemeWrapper(context, themeResId);
} else {
mContext = context;
}
...
final Window w = new PhoneWindow(mContext);
...
}

小结:

  • Dialog#getContext() 返回 ContextThemeWrapper;
  • 在 Activity 中,Window#getContext() 返回 Activity;在 Dialog中,Window#getContext() 返回 ContextThemeWrapper;

  1. Fragment#getContext() 的返回值

直接看源码:

Fragment.java

1
2
3
4
5
csharp复制代码FragmentHostCallback mHost;

public Context getContext() {
return mHost == null ? null : mHost.getContext();
}

FragmentHostCallback.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ini复制代码Context getContext() {
return mContext;
}

FragmentHostCallback(FragmentActivity activity) {
this(activity, activity /*context*/, activity.mHandler, 0 /*windowAnimations*/);
}

FragmentHostCallback(Activity activity, Context context, Handler handler, int windowAnimations) {
mActivity = activity;
mContext = Preconditions.checkNotNull(context, "context == null");
mHandler = Preconditions.checkNotNull(handler, "handler == null");
mWindowAnimations = windowAnimations;
}

FragmentActivity.java

1
2
3
4
5
6
7
8
scala复制代码final FragmentController mFragments = FragmentController.createController(new HostCallbacks());

class HostCallbacks extends FragmentHostCallback<FragmentActivity> {
public HostCallbacks() {
super(FragmentActivity.this /*fragmentActivity*/);
}
...
}

小结:

  • Fragment#getContext() 返回 Activity;

  1. 从 View#getContext() 获得 Activity 对象

在很多场景中,经常需要通过 View 来获得 Activity 对象,经过前面几节内容的讨论,我们已经知道View#getContext()的返回值总共有以下五种情况:

1
2
3
4
5
复制代码Activity
ContextWrapper
Service 一般不会
Application 一般不会
ContextImpl 一般不会

那么,要获得 Activity 则只要不断得获取 Context 的被代理对象(基础对象),就可以获得 Activity;当然了,下面 Service & Application & ContextImpl几种情况是返回空的,所以我们用@Nullable修饰。

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
java复制代码递归写法:
@Nullable
private static Activity findActivity(Context context) {
if (context instanceof Activity) {
return (Activity) context;
} else if (context instanceof ContextWrapper) {
return findActivity(((ContextWrapper) context).getBaseContext());
} else {
return null;
}
}
迭代写法:
@Nullable
public static Activity findActivity(Context context){
Context cur = context;
while (true){
if (cur instanceof Activity){
return (Activity) cur;
}

if (cur instanceof ContextWrapper){
ContextWrapper cw = (ContextWrapper) cur;
cur = cw.getBaseContext();
}else{
return null;
}
}
}

  1. 总结

  • 应试建议
    • 遇到此问题,答案应为:可能是Application、Service、ContextImpl、ContextWrapper、Activity的任何一个;
    • 应该对Context类型、LayoutInflater 布局解析、View 体系等源码有一定熟悉度,不仅仅能够解答本文问题,更多有意思/深度的问题也能迎刃而解。

推荐阅读

  • 密码学 | Base64是加密算法吗?
  • 算法面试题 | 回溯算法解题框架
  • 算法面试题 | 链表问题总结
  • Java | 带你理解 ServiceLoader 的原理与设计思想
  • Android | 面试必问的 Handler,你确定不看看?
  • Android | 带你理解 NativeAllocationRegistry 的原理与设计思想
  • 计算机组成原理 | Unicode 和 UTF-8是什么关系?
  • 计算机组成原理 | 为什么浮点数运算不精确?(阿里笔试)
  • 计算机网络 | 图解 DNS & HTTPDNS 原理

感谢喜欢!你的点赞是对我最大的鼓励!欢迎关注彭旭锐的GitHub!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

java安全编码指南之 线程安全规则 简介 注意线程安全方法

发表于 2020-10-23

简介

如果我们在多线程中引入了共享变量,那么我们就需要考虑一下多线程下线程安全的问题了。那么我们在编写代码的过程中,需要注意哪些线程安全的问题呢?

一起来看看吧。

注意线程安全方法的重写

大家都做过方法重写,我们知道方法重写是不会检查方法修饰符的,也就是说,我们可以将一个synchronized的方法重写成为非线程安全的方法:

1
2
3
4
java复制代码public class SafeA {
public synchronized void doSomething(){
}
}
1
2
3
4
5
java复制代码public class UnsafeB extends SafeA{
@Override
public void doSomething(){
}
}

我们在实现子类功能的时候,一定要保持方法的线程安全性。

构造函数中this的溢出

this是什么呢?根据JLS的规范,当用作主要表达式时,关键字this表示一个值,该值是对其调用实例方法的对象或正在构造的对象的引用。

那么问题来了,因为this能够表示正在构造的对象,那么意味着,如果对象还没有构建完毕,而this又可以被外部访问的话,就会造成外部对象访问到还未构造成功对象的问题。

我们来具体看一下this溢出都会发生在哪些情况:

1
2
3
4
5
6
7
8
9
10
java复制代码public class ChildUnsafe1 {

public static ChildUnsafe1 childUnsafe1;
int age;

ChildUnsafe1(int age){
childUnsafe1 = this;
this.age = age;
}
}

上面是一个非常简单的this溢出的情况,在构造函数的过程中,将this赋值给了一个public对象,将会导致this还没有被初始化完毕就被其他对象访问。

那么我们调整一下顺序是不是就可以了呢?

1
2
3
4
5
6
7
8
9
10
java复制代码public class ChildUnsafe2 {

public static ChildUnsafe2 childUnsafe2;
int age;

ChildUnsafe2(int age){
this.age = age;
childUnsafe2 = this;
}
}

上面我们看到,this的赋值被放到了构造方法的最后面,是不是就可以避免访问到未初始化完毕的对象呢?

答案是否定的,因为java会对代码进行重排序,所以childUnsafe2 = this的位置是不定的。

我们需要这样修改:

1
2
3
4
5
6
7
8
9
10
java复制代码public class Childsafe2 {

public volatile static Childsafe2 childUnsafe2;
int age;

Childsafe2(int age){
this.age = age;
childUnsafe2 = this;
}
}

加一个volatile描述符,禁止重排序,完美解决。

我们再来看一个父子类的问题,还是上面的Childsafe2,我们再为它写一个子类:

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码public class ChildUnsafe3 extends Childsafe2{

private Object obj;

ChildUnsafe3(int age){
super(10);
obj= new Object();
}

public void doSomething(){
System.out.println(obj.toString());
}
}

上面的例子有什么问题呢?因为父类在调用构造函数的时候,已经暴露了this变量,所以可能会导致ChildUnsafe3中的obj还没有被初始化的时候,外部程序就调用了doSomething(),这个时候obj还没有被初始化,所以会抛出NullPointerException。

解决办法就是不要在构造函数中设置this,我们可以新创建一个方法,在构造函数调用完毕之后,再进行设置。

不要在类初始化的时候使用后台线程

如果在类初始化的过程中,使用后台进程,有可能会造成死锁,我们考虑下面的情况:

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
java复制代码public final class ChildFactory {
private static int age;

static {
Thread ageInitializerThread = new Thread(()->{
System.out.println("in thread running");
age=10;
});

ageInitializerThread.start();
try {
ageInitializerThread.join();
} catch (InterruptedException ie) {
throw new AssertionError(ie);
}
}

public static int getAge() {
if (age == 0) {
throw new IllegalStateException("Error initializing age");
}
return age;
}

public static void main(String[] args) {
int age = getAge();
}
}

上面的类使用了一个static的block,在这个block中,我们启动一个后台进程来设置age这个字段。

为了保证可见性,static变量必须在其他线程运行之前初始化完毕,所以ageInitializerThread需要等待main线程的static变量执行完毕之后才能运行,但是我们又调用了ageInitializerThread.join()方法,主线程又需要反过来等待ageInitializerThread的执行完毕。

最终导致了循环等待,造成了死锁。

最简单的解决办法就是不使用后台进程,直接在static block中设置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
java复制代码public final class ChildFactory2 {
private static int age;

static {
System.out.println("in thread running");
age=10;
}

public static int getAge() {
if (age == 0) {
throw new IllegalStateException("Error initializing age");
}
return age;
}

public static void main(String[] args) {
int age = getAge();
}
}

还有一种办法就是使用ThreadLocal将初始化变量保存在线程本地。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码public final class ChildFactory3 {

private static final ThreadLocal<Integer> ageHolder = ThreadLocal.withInitial(() -> 10);

public static int getAge() {
int localAge = ageHolder.get();
if (localAge == 0) {
throw new IllegalStateException("Error initializing age");
}
return localAge;
}

public static void main(String[] args) {
int age = getAge();
}
}

本文的代码:

learn-java-base-9-to-20/tree/master/security

本文已收录于 www.flydean.com/java-securi…

最通俗的解读,最深刻的干货,最简洁的教程,众多你不知道的小技巧等你来发现!

欢迎关注我的公众号:「程序那些事」,懂技术,更懂你!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

PageHelper在SpringBoot+Mybatis中

发表于 2020-10-23

PageHelper

一. 开发准备

原创标识

1. 开发工具

  • IntelliJ IDEA 2020.2.3

2. 开发环境

  • Red Hat Open JDK 8u256
  • Apache Maven 3.6.3

3. 开发依赖

  • SpringBoot
1
2
3
4
xml复制代码<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
  • MyBatis
1
2
3
4
5
xml复制代码<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.1.3</version>
</dependency>
  • PageHelper
1
2
3
4
5
xml复制代码<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>1.3.0</version>
</dependency>

二. 技术文档

原创标识

1. 基于SpringBoot

  • SpringBoot 官方文档
  • SpringBoot 中文社区

2. 基于MyBatis

  • MyBatis 官方文档

3. 集成PageHelper

  • PageHelper 开源仓库

三. 应用讲解

原创标识

1. 基本使用

在实际项目运用中,PageHelper的使用非常便利快捷,仅通过PageInfo + PageHelper两个类,就足以完成分页功能,然而往往这种最简单的集成使用方式,却在很多实际应用场景中,没有得到充分的开发利用.

接下来是我们最常见的使用方式:

1
2
3
4
5
6
java复制代码public PageInfo<ResponseEntityDto> page(RequestParamDto param) {
PageHelper.startPage(param.getPageNum(), param.getPageSize());
List<ResoinseEntityDto> list = mapper.selectManySelective(param);
PageInfo<ResponseEntityDto> pageInfo = (PageInfo<ResponseEntityDto>)list;
return pageInfo;
}

在某种程度上而言,上述写法的确是符合PageHelper的使用规范 :

在集合查询前使用PageHelper.startPage(pageNum,pageSize),并且中间不能穿插执行其他SQL

但是作为Developer的我们,往往只有在追求完美和极致的道路上才能够寻得突破和机遇;
以下是合理且规范的基本使用:

1
2
3
4
5
6
7
java复制代码public PageInfo<ResponseEntityDto> page(RequestParamDto param) {
return PageHelper.startPage(param.getPageNum(), param.getPageSize())
.doSelectPageInfo(() -> list(param))
}
public List<ResponseEntityDto> list(RequestParamDto param) {
return mapper.selectManySelective(param);
}

FAQ

1. 为什么要重新声明一个list函数?

答: 往往在很多实际业务应用场景中, 分页查询是基于大数据量的表格展示需求来进行的.

然而很多时候,譬如: 内部服务的互相调用,OpenAPI的提供.

甚至在某些前后端分离联调的业务场景中,是同样需要一个非分页集合查询接口来提供服务的.

另外,暂时以上因素抛开不谈,我们可以根据上述写法来定义和规范某些东西

譬如: 分页和集合查询的分离和解耦(解耦详情请看进阶使用),

分页请求的请求和响应与实际业务参数的分离(详情请看进阶使用)等等…

2. doSelectPageInfo是什么?

答: doSelectPageInfo是PageHelper.startPage()函数返回的默认Page实例内置的函数

该函数可以用以Lambda的形式通过额外的Function来进行查询而不需要再进行多余的PageInfo与List转换

而doSelectPageInfo的参数则是PageHelper内置的Function(ISelect)接口用以达到转换PageInfo的目的

3. 这种写法的代码量看起来不少反多?

答: 正如同①中所描述的,就代码量而言,确实没有更进一步的简化

但是再某些业务场景中,在已具有list函数接口的情况下,是一种更直观的优化(优化详情请看进阶使用)

原创标识

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
java复制代码import com.github.pagehelper.PageHelper;
import com.github.pagehelper.PageInfo;

import java.util.List;

/**
* @param <Param> 泛型request
* @param <Result> 泛型response
*/
public interface BaseService<Param, Result> {
/**
* 分页查询
*
* @param param 请求参数DTO
* @return 分页集合
*/
default PageInfo<Result> page(PageParam<Param> param) {
return PageHelper.startPage(param).doSelectPageInfo(() -> list(param.getParam()));
}
/**
* 集合查询
*
* @param param 查询参数
* @return 查询响应
*/
List<Result> list(Param param);
}

可以看到BaseService可以作为全局Service通用接口的封装和声明

而作为通用分页接口page函数却在此处利用interface特有关键字default 直接声明了page函数的方法体body

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
java复制代码import com.github.pagehelper.IPage;
import lombok.Data;
import lombok.experimental.Accessors;

@Data // 为省略冗余代码使用lombok 实际应有常规Getter/Setter Construction toString等
@Accessors(chain = true) // 此lombok注解是为了实现 Entity伪Build 譬如: entity.setX(x).setY(y)
public class PageParam<T> implements IPage {

// description = "页码", defaultValue = 1
private Integer pageNum = 1;

// description = "页数", defaultValue = 20
private Integer pageSize = 20;

// description = "排序", example = "id desc"
private String orderBy;

// description = "参数"
private T param;

public PageParam<T> setOrderBy(String orderBy) {
this.orderBy = orderBy; // 此处可优化 优化详情且看解析
return this;
}
}

在BaseService中我们看到了一个新的PageParam,参考了PageInfo用以包装/声明/分离分页参数和业务参数,且参数类型为泛型,即支持任何数据类型的业务参数

同时也可以看到PageParam实现了IPage接口,并且多了一个orderBy属性字段

1
2
3
4
5
6
7
8
java复制代码import common.base.BaseService;
import dto.req.TemplateReqDto;
import dto.resp.TemplateRespDto;

public interface TemplateService extends BaseService<TemplateReqDto, TemplateeRespDto> {
// 同为interface接口, 业务Service只需要继承BaseService
// 并根据实际使用场景声明请求参数和响应结果的Entity实体即可
}

在实际应用中,只需要声明我们通用的业务查询请求参数和响应结果即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
java复制代码import dto.req.TemplateReqDto;
import dto.resp.TemplateRespDto;
import service.TemplateService;
import persistence.mapper.TemplateMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.util.List;

@Slf4j // 基于lombok自动生成logger日志记录实例
@Service // SpringBoot中注册Service Bean的注解
@RequiredArgsConstructor // 基于lombok根据类所有final属性生成构造函数 即可完成Spring构造注入
public class TemplateServiceImpl implements TemplateService {

private final TemplateMapper mapper;

@Override
public List<TemplateRespDto> list(TemplateReqDto param) {
return mapper.selectManySelective(param) // 可根据实际情况将实体做转换
}
}

实现类中也只需要重写list方法体,将实际业务场景中需要处理的业务逻辑处理和查询方法写入其中,并不需要关心分页功能

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
java复制代码@Slf4j	// 同上
@RestController // SpringBoot中注册Controller Bean的注解
@RequiredArgsConstructor // 同上
public class TemplateController {

public final TemplateService service;

/**
* 分页查询
*
* @param pageParam 分页查询参数
* @return 分页查询响应
*/
@PostMapping(path = "page")
public PageInfo<Result> page(@RequestBody PageParam<Param> pageParam) {
return service.page(pageParam);
}

/**
* 集合查询
*
* @param listParam 集合查询参数
* @return 集合查询响应
*/
@PostMapping(path = "list")
public List<Result> list(@RequestBody Param listParam) {
return service.list(listParam);
}
}

最后编码Controller接口时,也只需要直接调用service.page即可,而请求参数直接用PageParam包装,将分页参数和业务参数分离,在前后端接口联调中,保持这种==分离规范==,可以很大程度上的降低沟通和开发成本

原创标识

FAQ

1. BaseService作为interface,page为什么可以声明方法体?

答: Java8中新特性之一就是为interface接口类增加了static/default方法,即声明方法后,其子类或实现都将默认具有这些方法,可以直接调用

而在此处为Page方法声明default是因为page函数只关注分页参数和分页响应,脱离了业务场景,方法体大相径庭,所以索性抽象定义出来,免去了其实现的复杂冗余过程

2. PageParam的声明有什么意义?实现IPage是为了什么?

答: PageParam是参考PageInfo编写的类(不确定往后PageHelper是否会封装此类,兴许我可以提个Issue上去,也参与开源框架的开发)

编写此类的目的就是为了分离分页和业务数据,让开发者专注于业务的实现和开发,同时也是对分页查询API的一种规范,无论是请求还是响应都将分页相关的数据抽离出来,单独使用

而实现IPage则是因为IPage作为PageHelper内置的interface,在不了解它更多意义上的作用前,可以作为我们分页参数声明的一种规范,而IPage中也只声明了三个方法,分别是pageNum/pageSize/orderBy的Getter方法,另外在源码分析中,我将会提到实现此接口更深层的意义

3. PageParam中除了常规的pageNum/pageSize,为什么还需要一个orderBy?

答: 常规的分页查询中只需要pageNum/pageSize即可完成分页的目的,但是往往伴随着分页查询的还有筛选排序

而orderBy则是专注基于SQL的动态传参排序

4. orderBy如何使用?会有什么问题吗?

答: orderBy和pageNum/pageSize一样,都是Pagehelper通过MyBatis拦截器,在query查询中注入进去的,所以在前端传参时,orderBy参数应为数据库column desc/asc这种形式,多字段排序则可以用逗号(,)拼接,譬如: columnA desc,columnB,

但是另外一方面又存在两个问题, 第一就是大多数数据库表字段设计中,都会使用==蛇形case==命名,而非常规开发中的==驼峰case==命名,所以存在一层转换,而这种转换可以分配给前端传参时,也可以分配给后端接参时.

第二就是这样赤裸裸的将排序字段暴露在接口中,会存在order by SQL注入的风险,所以在实际使用过程中,我们需要通过某些手段去校验和排查orderBy的传参是否合法,譬如用正则表达式匹配参数值只能含有order by语法中必要的值,例如字段名,desc or asc,不允许包含特殊字符/数据库关键字等

5. pageNum/pageSize一定需要给默认值吗?

答: 通过阅读PageHelper源码,我们得知在Page查询参数为null时,它并不会赋予它们默认值,并不进行额外的处理,以至于导致分页失败,而给默认值,也是为了谨防前后端调试接口过程中可能会出现的各种意外

3. 源码分析

首先我们看PageHelper.startPage(param)过程中发生了什么 :

1
2
3
4
5
6
7
8
9
java复制代码public static <E> Page<E> startPage(Object params) {
Page<E> page = PageObjectUtil.getPageFromObject(params, true);
Page<E> oldPage = getLocalPage();
if (oldPage != null && oldPage.isOrderByOnly()) {
page.setOrderBy(oldPage.getOrderBy());
}
setLocalPage(page);
return page;
}

这是PageHelper继承(extend)的抽象类PageMethod中的一个静态方法

再看代码第一行 Page<E> page = PageObjectUtil.getPageFromObject(params, true)发生了什么:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
java复制代码public static <T> Page<T> getPageFromObject(Object params, boolean required) {
if (params == null) {
throw new PageException("无法获取分页查询参数!");
} else if (params instanceof IPage) {
IPage pageParams = (IPage)params;
Page page = null;
if (pageParams.getPageNum() != null && pageParams.getPageSize() != null) {
page = new Page(pageParams.getPageNum(), pageParams.getPageSize());
}
if (StringUtil.isNotEmpty(pageParams.getOrderBy())) {
if (page != null) {
page.setOrderBy(pageParams.getOrderBy());
} else {
page = new Page();
page.setOrderBy(pageParams.getOrderBy());
page.setOrderByOnly(true);
}
}
return page;
} else {
... // 此处我只截取了部分代码片段, 以上是较为重要的一块
}
}

可以看到在此方法中,会先判断params是否为null,再而通过instanceof判断是否为IPage的子类或实现类
如果以上两个if/else 皆不满足,则PageHelper则会在我省略贴出的代码中通过大量的反射代码来获取pageNum/pageSize以及orderBy.
总所皆知,反射在Java中虽然广泛应用,并且作为语言独有特性之一,深受广大开发者的喜爱,但是反射在某种程度上,是需要性能成本的,甚至于现阶段很多主流的框架和技术,都在尽量减少反射的运用,以防止框架性能过差,被市场淘汰.
那么到此为止,我们也终于解释并知道了为什么PageParam要实现IPage接口了,在此处的代码中可以直接通过接口获取到分页参数,而不需要通过有损性能的反射获取PageHelper需要的参数

继续看startPage中的后续代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java复制代码public abstract class PageMethod {
protected static final ThreadLocal<Page> LOCAL_PAGE = new ThreadLocal();
protected static boolean DEFAULT_COUNT = true;

public PageMethod() {
}

protected static void setLocalPage(Page page) {
LOCAL_PAGE.set(page);
}

public static <T> Page<T> getLocalPage() {
return (Page)LOCAL_PAGE.get();
}
...
...
}

可以看到PageHelper继承的抽象类PageMethod中声明了一个Page的线程本地变量,而getLocalPage()则是为了获取当前线程中的Page

而接下来if (oldPage != null && oldPage.isOrderByOnly())则是判断是否存在旧分页数据

此处的isOrderByOnly通过getPageFromObject()函数我们可以知道,当只存在orderBy参数时,即为true

也就是说,当存在旧分页数据并且旧分页数据只有排序参数时,就将旧分页数据的排序参数列入新分页数据的排序参数

然后将新的分页数据page存入本地线程变量中

实际应用场景中,这种情况还是比较少,仅排序而不分页,所以某种角度上而言,我们仅当了解便好

原创标识

接下来再看doSelectPageInfo(ISelect) 中发生了什么:

1
2
3
4
java复制代码public <E> PageInfo<E> doSelectPageInfo(ISelect select) {
select.doSelect();
return this.toPageInfo();
}

可以看到,该方法的实现非常简单明了,就是通过注册声明ISelect接口由开发自定义集合查询方式并由它内部执行,随后便返回PageInfo实体

前面我们有提到,PageHelper基于MyBatis拦截器达到分页的目的,那么为什么此处的ISelect.doSelect()执行,就可以返回PageInfo实体呢?

实际上这便是拦截器的妙用所在,在select.doSelect()执行时,会触发PageHelper自定义的MyBatis查询拦截器,并通过解析SQL和SQL参数,根据数据库类型,进行分页,譬如MySQL的limit,Oracle的Rownum等,

同时还会在我们定义的查询SQL之前,PageHelper会重新生成一条select count(*)的SQL率先执行,已达到它定义Page内置分页参数的目的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
java复制代码@Intercepts({@Signature(
type = Executor.class,
method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}
), @Signature(
type = Executor.class,
method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class}
)})
public class PageInterceptor implements Interceptor {
private volatile Dialect dialect;
private String countSuffix = "_COUNT";
protected Cache<String, MappedStatement> msCountMap = null;
private String default_dialect_class = "com.github.pagehelper.PageHelper";

public PageInterceptor() {
}

public Object intercept(Invocation invocation) throws Throwable {
...
...
}
}

以上便是PageHelper内置的自定义MyBatis拦截器,因代码量过多,为了保证不违反本博文文不对题的原则,此处不再做多余讲解,如有需要,我可以另行写一篇博客单独解释并讲解MyBatis拦截器的概念和原理,深度解析MyBatis源码

拓展

PageHelper不仅有pageNum/pageSize/orderBy这几个参数,更还有pageSizeZero, reasonable参数等用以更进阶的分页查询定义,如需更深入的了解,我可以另行写一遍进阶==PageHelper==使用,此文只作为寻常开发使用讲解

原创标识

四. 总结

PageHelper作为GitHub上现在近10K的开源分页框架,也许代码深度和广度不及主流市场框架和技术,虽然在功能的实现和原理上,造轮子的难度不高,源码也很清晰,但是在很大程度上解决了很多基于MyBatis的分页技术难题,简化并提示了广大开发者的效率,这才是开发者们在开发的路上应该向往并为之拼搏的方向和道路.
而我们作为受益者,也不应当仅仅是对其进行基本的使用,开发之余,我们也应该关注一些框架的拓展,对框架的底层有一定程度上的了解,并为之拓展和优化

此处再次放上PageHelper的开源仓库!

我是臣不贰, 你,认识我了吗?

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Spring Boot第十三弹,Spring Boot与多数

发表于 2020-10-22

前言

大约在19年的这个时候,老同事公司在做医疗系统,需要和HIS系统对接一些信息,比如患者、医护、医嘱、科室等信息。但是起初并不知道如何与HIS无缝对接,于是向我取经。

最终经过讨论采用了视图对接的方式,大致就是HIS系统提供视图,他们进行对接。

写这篇文章的目的

这篇文章将会涉及到Spring Boot 与Mybatis、数据库整合,类似于整合Mybatis与数据库的文章其实网上很多,作者此前也写过一篇文章详细的介绍了一些整合的套路:Spring Boot 整合多点套路,少走点弯路~,有兴趣的可以看看。

什么是多数据源?

最常见的单一应用中最多涉及到一个数据库,即是一个数据源(Datasource)。那么顾名思义,多数据源就是在一个单一应用中涉及到了两个及以上的数据库了。

其实在配置数据源的时候就已经很明确这个定义了,如以下代码:

1
2
3
4
5
6
7
8
9
java复制代码    @Bean(name = "dataSource")
public DataSource dataSource() {
DruidDataSource druidDataSource = new DruidDataSource();
druidDataSource.setUrl(url);
druidDataSource.setUsername(username);
druidDataSource.setDriverClassName(driverClassName);
druidDataSource.setPassword(password);
return druidDataSource;
}

url、username、password这三个属性已经唯一确定了一个数据库了,DataSource则是依赖这三个创建出来的。则多数据源即是配置多个DataSource(暂且这么理解)。

何时用到多数据源?

正如前言介绍到的一个场景,相信大多数做过医疗系统的都会和HIS打交道,为了简化护士以及医生的操作流程,必须要将必要的信息从HIS系统对接过来,据我了解的大致有两种方案如下:

  1. HIS提供视图,比如医护视图、患者视图等,而此时其他系统只需要定时的从HIS视图中读取数据同步到自己数据库中即可。
  2. HIS提供接口,无论是webService还是HTTP形式都是可行的,此时其他系统只需要按照要求调接口即可。

很明显第一种方案涉及到了至少两个数据库了,一个是HIS数据库,一个自己系统的数据库,在单一应用中必然需要用到多数据源的切换才能达到目的。

当然多数据源的使用场景还是有很多的,以上只是简单的一个场景。

整合单一的数据源

本文使用阿里的数据库连接池druid,添加依赖如下:

1
2
3
4
5
6
xml复制代码<!--druid连接池-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.1.9</version>
</dependency>

阿里的数据库连接池非常强大,比如数据监控、数据库加密等等内容,本文仅仅演示与Spring Boot整合的过程,一些其他的功能后续可以自己研究添加。

Druid连接池的starter的自动配置类是DruidDataSourceAutoConfigure,类上标注如下一行注解:

1
java复制代码@EnableConfigurationProperties({DruidStatProperties.class, DataSourceProperties.class})

@EnableConfigurationProperties这个注解使得配置文件中的配置生效并且映射到指定类的属性。

DruidStatProperties中指定的前缀是spring.datasource.druid,这个配置主要是用来设置连接池的一些参数。

DataSourceProperties中指定的前缀是spring.datasource,这个主要是用来设置数据库的url、username、password等信息。

因此我们只需要在全局配置文件中指定数据库的一些配置以及连接池的一些配置信息即可,前缀分别是spring.datasource.druid、spring.datasource,以下是个人随便配置的(application.properties):

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
properties复制代码spring.datasource.url=jdbc\:mysql\://120.26.101.xxx\:3306/xxx?useUnicode\=true&characterEncoding\=UTF-8&zeroDateTimeBehavior\=convertToNull&useSSL\=false&allowMultiQueries\=true&serverTimezone=Asia/Shanghai
spring.datasource.username=root
spring.datasource.password=xxxx
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
#初始化连接大小
spring.datasource.druid.initial-size=0
#连接池最大使用连接数量
spring.datasource.druid.max-active=20
#连接池最小空闲
spring.datasource.druid.min-idle=0
#获取连接最大等待时间
spring.datasource.druid.max-wait=6000
spring.datasource.druid.validation-query=SELECT 1
#spring.datasource.druid.validation-query-timeout=6000
spring.datasource.druid.test-on-borrow=false
spring.datasource.druid.test-on-return=false
spring.datasource.druid.test-while-idle=true
#配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
spring.datasource.druid.time-between-eviction-runs-millis=60000
#置一个连接在池中最小生存的时间,单位是毫秒
spring.datasource.druid.min-evictable-idle-time-millis=25200000
#spring.datasource.druid.max-evictable-idle-time-millis=
#打开removeAbandoned功能,多少时间内必须关闭连接
spring.datasource.druid.removeAbandoned=true
#1800秒,也就是30分钟
spring.datasource.druid.remove-abandoned-timeout=1800
#<!-- 1800秒,也就是30分钟 -->
spring.datasource.druid.log-abandoned=true
spring.datasource.druid.filters=mergeStat

在全局配置文件application.properties文件中配置以上的信息即可注入一个数据源到Spring Boot中。其实这仅仅是一种方式,下面介绍另外一种方式。

在自动配置类中DruidDataSourceAutoConfigure中有如下一段代码:

1
2
3
4
5
6
java复制代码  @Bean(initMethod = "init")
@ConditionalOnMissingBean
public DataSource dataSource() {
LOGGER.info("Init DruidDataSource");
return new DruidDataSourceWrapper();
}

@ConditionalOnMissingBean和@Bean这两个注解的结合,意味着我们可以覆盖,只需要提前在IOC中注入一个DataSource类型的Bean即可。

因此我们在自定义的配置类中定义如下配置即可:

1
2
3
4
5
6
7
8
9
10
11
java复制代码/**
* @Bean:向IOC容器中注入一个Bean
* @ConfigurationProperties:使得配置文件中以spring.datasource为前缀的属性映射到Bean的属性中
* @return
*/
@ConfigurationProperties(prefix = "spring.datasource")
@Bean
public DataSource dataSource(){
//做一些其他的自定义配置,比如密码加密等......
return new DruidDataSource();
}

以上介绍了两种数据源的配置方式,第一种比较简单,第二种适合扩展,按需选择。

整合Mybatis

Spring Boot 整合Mybatis其实很简单,简单的几步就搞定,首先添加依赖:

1
2
3
4
5
xml复制代码<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.0.0</version>
</dependency>

第二步找到自动配置类MybatisAutoConfiguration,有如下一行代码:

1
java复制代码@EnableConfigurationProperties(MybatisProperties.class)

老套路了,全局配置文件中配置前缀为mybatis的配置将会映射到该类中的属性。

可配置的东西很多,比如XML文件的位置、类型处理器等等,如下简单的配置:

1
2
properties复制代码mybatis.type-handlers-package=com.demo.typehandler
mybatis.configuration.map-underscore-to-camel-case=true

如果需要通过包扫描的方式注入Mapper,则需要在配置类上加入一个注解:@MapperScan,其中的value属性指定需要扫描的包。

直接在全局配置文件配置各种属性是一种比较简单的方式,其实的任何组件的整合都有不少于两种的配置方式,下面来介绍下配置类如何配置。

MybatisAutoConfiguration自动配置类有如下一断代码:

1
2
3
java复制代码  @Bean
@ConditionalOnMissingBean
public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception {}

@ConditionalOnMissingBean和@Bean真是老搭档了,意味着我们又可以覆盖,只需要在IOC容器中注入SqlSessionFactory(Mybatis六剑客之一生产者)。

在自定义配置类中注入即可,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码 /**
* 注入SqlSessionFactory
*/
@Bean("sqlSessionFactory1")
public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception {
SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean();
sqlSessionFactoryBean.setDataSource(dataSource);
sqlSessionFactoryBean.setMapperLocations(new PathMatchingResourcePatternResolver().getResources("classpath*:/mapper/**/*.xml"));
org.apache.ibatis.session.Configuration configuration = new org.apache.ibatis.session.Configuration();
// 自动将数据库中的下划线转换为驼峰格式
configuration.setMapUnderscoreToCamelCase(true);
configuration.setDefaultFetchSize(100);
configuration.setDefaultStatementTimeout(30);
sqlSessionFactoryBean.setConfiguration(configuration);
return sqlSessionFactoryBean.getObject();
}

以上介绍了配置Mybatis的两种方式,其实在大多数场景中使用第一种已经够用了,至于为什么介绍第二种呢?当然是为了多数据源的整合而做准备了。

在MybatisAutoConfiguration中有一行很重要的代码,如下:

1
java复制代码@ConditionalOnSingleCandidate(DataSource.class)

@ConditionalOnSingleCandidate这个注解的意思是当IOC容器中只有一个候选Bean的实例才会生效。

这行代码标注在Mybatis的自动配置类中有何含义呢?下面介绍,哈哈哈~

多数据源如何整合?

上文留下的问题:为什么的Mybatis自动配置上标注如下一行代码:

1
java复制代码@ConditionalOnSingleCandidate(DataSource.class)

以上这行代码的言外之意:当IOC容器中只有一个数据源DataSource,这个自动配置类才会生效。

哦?照这样搞,多数据源是不能用Mybatis吗?

可能大家会有一个误解,认为多数据源就是多个的DataSource并存的,当然这样说也不是不正确。

多数据源的情况下并不是多个数据源并存的,Spring提供了AbstractRoutingDataSource这样一个抽象类,使得能够在多数据源的情况下任意切换,相当于一个动态路由的作用,作者称之为动态数据源。因此Mybatis只需要配置这个动态数据源即可。

什么是动态数据源?

动态数据源简单的说就是能够自由切换的数据源,类似于一个动态路由的感觉,Spring 提供了一个抽象类AbstractRoutingDataSource,这个抽象类中哟一个属性,如下:

1
java复制代码private Map<Object, Object> targetDataSources;

targetDataSources是一个Map结构,所有需要切换的数据源都存放在其中,根据指定的KEY进行切换。当然还有一个默认的数据源。

AbstractRoutingDataSource这个抽象类中有一个抽象方法需要子类实现,如下:

1
java复制代码protected abstract Object determineCurrentLookupKey();

determineCurrentLookupKey()这个方法的返回值决定了需要切换的数据源的KEY,就是根据这个KEY从targetDataSources取值(数据源)。

数据源切换如何保证线程隔离?

数据源属于一个公共的资源,在多线程的情况下如何保证线程隔离呢?不能我这边切换了影响其他线程的执行。

说到线程隔离,自然会想到ThreadLocal了,将切换数据源的KEY(用于从targetDataSources中取值)存储在ThreadLocal中,执行结束之后清除即可。

单独封装了一个DataSourceHolder,内部使用ThreadLocal隔离线程,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
java复制代码/**
* 使用ThreadLocal存储切换数据源后的KEY
*/
public class DataSourceHolder {

//线程 本地环境
private static final ThreadLocal<String> dataSources = new InheritableThreadLocal();

//设置数据源
public static void setDataSource(String datasource) {
dataSources.set(datasource);
}

//获取数据源
public static String getDataSource() {
return dataSources.get();
}

//清除数据源
public static void clearDataSource() {
dataSources.remove();
}
}

如何构造一个动态数据源?

上文说过只需继承一个抽象类AbstractRoutingDataSource,重写其中的一个方法determineCurrentLookupKey()即可。代码如下:

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
java复制代码/**
* 动态数据源,继承AbstractRoutingDataSource
*/
public class DynamicDataSource extends AbstractRoutingDataSource {

/**
* 返回需要使用的数据源的key,将会按照这个KEY从Map获取对应的数据源(切换)
* @return
*/
@Override
protected Object determineCurrentLookupKey() {
//从ThreadLocal中取出KEY
return DataSourceHolder.getDataSource();
}

/**
* 构造方法填充Map,构建多数据源
*/
public DynamicDataSource(DataSource defaultTargetDataSource, Map<Object, Object> targetDataSources) {
//默认的数据源,可以作为主数据源
super.setDefaultTargetDataSource(defaultTargetDataSource);
//目标数据源
super.setTargetDataSources(targetDataSources);
//执行afterPropertiesSet方法,完成属性的设置
super.afterPropertiesSet();
}
}

上述代码很简单,分析如下:

  1. 一个多参的构造方法,指定了默认的数据源和目标数据源。
  2. 重写determineCurrentLookupKey()方法,返回数据源对应的KEY,这里是直接从ThreadLocal中取值,就是上文封装的DataSourceHolder。

定义一个注解

为了操作方便且低耦合,不能每次需要切换的数据源的时候都要手动调一下接口吧,可以定义一个切换数据源的注解,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java复制代码/**
* 切换数据源的注解
*/
@Target(value = ElementType.METHOD)
@Retention(value = RetentionPolicy.RUNTIME)
@Documented
public @interface SwitchSource {

/**
* 默认切换的数据源KEY
*/
String DEFAULT_NAME = "hisDataSource";

/**
* 需要切换到数据的KEY
*/
String value() default DEFAULT_NAME;
}

注解中只有一个value属性,指定了需要切换数据源的KEY。

有注解还不行,当然还要有切面,代码如下:

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
java复制代码@Aspect
//优先级设置到最高
@Order(Ordered.HIGHEST_PRECEDENCE)
@Component
@Slf4j
public class DataSourceAspect {


@Pointcut("@annotation(SwitchSource)")
public void pointcut() {
}

/**
* 在方法执行之前切换到指定的数据源
* @param joinPoint
*/
@Before(value = "pointcut()")
public void beforeOpt(JoinPoint joinPoint) {
/*因为是对注解进行切面,所以这边无需做过多判定,直接获取注解的值,进行环绕,将数据源设置成远方,然后结束后,清楚当前线程数据源*/
Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
SwitchSource switchSource = method.getAnnotation(SwitchSource.class);
log.info("[Switch DataSource]:" + switchSource.value());
DataSourceHolder.setDataSource(switchSource.value());
}

/**
* 方法执行之后清除掉ThreadLocal中存储的KEY,这样动态数据源会使用默认的数据源
*/
@After(value = "pointcut()")
public void afterOpt() {
DataSourceHolder.clearDataSource();
log.info("[Switch Default DataSource]");
}
}

这个ASPECT很容易理解,beforeOpt()在方法之前执行,取值@SwitchSource中value属性设置到ThreadLocal中;afterOpt()方法在方法执行之后执行,清除掉ThreadLocal中的KEY,保证了如果不切换数据源,则用默认的数据源。

如何与Mybatis整合?

单一数据源与Mybatis整合上文已经详细讲解了,数据源DataSource作为参数构建了SqlSessionFactory,同样的思想,只需要把这个数据源换成动态数据源即可。注入的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码/**
* 创建动态数据源的SqlSessionFactory,传入的是动态数据源
* @Primary这个注解很重要,如果项目中存在多个SqlSessionFactory,这个注解一定要加上
*/
@Primary
@Bean("sqlSessionFactory2")
public SqlSessionFactory sqlSessionFactoryBean(DynamicDataSource dynamicDataSource) throws Exception {
SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean();
sqlSessionFactoryBean.setDataSource(dynamicDataSource);
org.apache.ibatis.session.Configuration configuration = new org.apache.ibatis.session.Configuration();
configuration.setMapUnderscoreToCamelCase(true);
configuration.setDefaultFetchSize(100);
configuration.setDefaultStatementTimeout(30);
sqlSessionFactoryBean.setConfiguration(configuration);
return sqlSessionFactoryBean.getObject();
}

与Mybatis整合很简单,只需要把数据源替换成自定义的动态数据源DynamicDataSource。

那么动态数据源如何注入到IOC容器中呢?看上文自定义的DynamicDataSource构造方法,肯定需要两个数据源了,因此必须先注入两个或者多个数据源到IOC容器中,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
java复制代码 /**
* @Bean:向IOC容器中注入一个Bean
* @ConfigurationProperties:使得配置文件中以spring.datasource为前缀的属性映射到Bean的属性中
*/
@ConfigurationProperties(prefix = "spring.datasource")
@Bean("dataSource")
public DataSource dataSource(){
return new DruidDataSource();
}

/**
* 向IOC容器中注入另外一个数据源
* 全局配置文件中前缀是spring.datasource.his
*/
@Bean(name = SwitchSource.DEFAULT_NAME)
@ConfigurationProperties(prefix = "spring.datasource.his")
public DataSource hisDataSource() {
return DataSourceBuilder.create().build();
}

以上构建的两个数据源,一个是默认的数据源,一个是需要切换到的数据源(targetDataSources),这样就组成了动态数据源了。数据源的一些信息,比如url,username需要自己在全局配置文件中根据指定的前缀配置即可,代码不再贴出。

动态数据源的注入代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码/**
* 创建动态数据源的SqlSessionFactory,传入的是动态数据源
* @Primary这个注解很重要,如果项目中存在多个SqlSessionFactory,这个注解一定要加上
*/
@Primary
@Bean("sqlSessionFactory2")
public SqlSessionFactory sqlSessionFactoryBean(DynamicDataSource dynamicDataSource) throws Exception {
SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean();
sqlSessionFactoryBean.setDataSource(dynamicDataSource);
org.apache.ibatis.session.Configuration configuration = new org.apache.ibatis.session.Configuration();
configuration.setMapUnderscoreToCamelCase(true);
configuration.setDefaultFetchSize(100);
configuration.setDefaultStatementTimeout(30);
sqlSessionFactoryBean.setConfiguration(configuration);
return sqlSessionFactoryBean.getObject();
}

这里还有一个问题:IOC中存在多个数据源了,那么事务管理器怎么办呢?它也懵逼了,到底选择哪个数据源呢?因此事务管理器肯定还是要重新配置的。

事务管理器此时管理的数据源将是动态数据源DynamicDataSource,配置如下:

1
2
3
4
5
6
7
8
java复制代码   /**
* 重写事务管理器,管理动态数据源
*/
@Primary
@Bean(value = "transactionManager2")
public PlatformTransactionManager annotationDrivenTransactionManager(DynamicDataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}

至此,Mybatis与多数据源的整合就完成了。

演示

使用也是很简单,在需要切换数据源的方法上方标注@SwitchSource切换到指定的数据源即可,如下:

1
2
3
4
5
6
7
8
java复制代码    //不开启事务
@Transactional(propagation = Propagation.NOT_SUPPORTED)
//切换到HIS的数据源
@SwitchSource
@Override
public List<DeptInfo> list() {
return hisDeptInfoMapper.listDept();
}

这样只要执行到这方法将会切换到HIS的数据源,方法执行结束之后将会清除,执行默认的数据源。

总结

本篇文章讲了Spring Boot与单数据源、Mybatis、多数据源之间的整合,希望这篇文章能够帮助读者理解多数据源的整合,虽说用的不多,但是在有些领域仍然是比较重要的。

原创不易,点点赞分享一波,谢谢支持~

源码已经上传,需要源码的朋友公众号回复关键词多数据源。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

图解:什么是AVL树?

发表于 2020-10-22

​)​

本文绝对干货,食用时间约8分钟,建议细品!

引子

上一次我给大家介绍了什么是二叉搜索树,但是由于二叉搜索树查询效率的不稳定性,所以很少运用在实际的场景中,所以我们伟大的前人就对二叉搜索树进行了改良,发明了AVL树。

AVL树是一种自平衡二叉搜索树,因为AVL树任意节点的左右子树高度差的绝对值不超过1,所以AVL树又被称为高度平衡树。

AVL树本质上是一棵带有平衡条件的二叉搜索树,它满足二叉搜索树的基本特性,所以本次主要介绍AVL树怎么自平衡,也就是理解它的旋转过程。

二叉搜索树特性忘了的小伙伴可以看之前的文章:搞定二叉搜索树,9图足矣!同时我也将基本性质给大家再回顾一遍:

  1. 若它的左子树不为空,则左子树上所有节点的值均小于根节点的值。
  2. 若它的右子树不为空,则右子树上所有节点的值均大于根节点的值。
  3. 它的左、右子树也分别为二叉搜索树。

平衡条件:每个节点的左右子树的高度差的绝对值不超过1。

我们将每个节点的左右子树的高度差的绝对值又叫做平衡因子。

AVL树的旋转行为一般是在插入和删除过程中才发生的,因为插入过程中的旋转相比于删除过程的旋转而言更加简单和直观,所以我给大家图解一下AVL树的插入过程。

插入过程

最开始的时候为空树,没有任何节点,所以我们直接用数据构造一个节点插入就好了,比如第一个要插入的数据为18。

)​

第一个节点插入完成,开始插入第二个节点,假如数据为20。

)​

插入第三个节点数据为14。

)​

第四个节点数据为16。从根节点位置开始比较并寻找16的对应插入位置。

)​

)​

)​

第五个要插入的数据为12。还是一样,从树的根节点出发,根据二叉搜索树的特性向下寻找到对应的位置。

)​

此时插入一个数据11,根据搜索树的性质,我们不难找到它的对应插入位置,但是当我们插入11这个节点之后就不满足AVL树的平衡条件了。

)​

此时相当于18的左子树高了,右子树矮了,所以我们应该进行一次右单旋,右单旋使左子树被提起来,右子树被拉下去,相当于左子树变矮了,右子树变高了,所以一次旋转之后,又满足平衡条件了。

)​

简单分析上图的旋转过程:**因为左子树被提上去了,所以14成为了新的根节点,而18被拉到了14右子树的位置,又因为14这个节点原来有右子节点为16,所以18与16旋转之后的位置就冲突了,但是因为16小于18,**所以这个时候根据二叉搜索树的特性,将16调整到18的左子树中去,因为旋转之后的18这个节点的左子树是没有节点的,所以16可以直接挂到18的左边,如果18的左子树有节点,那么还需要根据二叉搜索树的性质去将16与18左子树中的节点比较大小,直到确定新的位置。

经过上面的分析我们可以知道:如果新插入的节点插入到根节点较高左子树的左侧,则需要进行一次右单旋,我们一般将这种情况简单记为左左情况,第一个左说的是较高左子树的左,第二个左说的是新节点插入到较高左子树的左侧。

分析完了左左的情况,我想小伙伴们不难推出右右的情况(第一个右说的是较高右子树的右,第二个右说的是新节点插入到较高右子树的右侧),就是一次左单旋,这里就不一步一步地分析右右的情况了,因为它和左左是对称的。给大家画个图,聪明的你一眼就可以学会!

)​

现在两种单旋的情况已经讲完了,分别是左左和右右,还剩下两种单旋的情况,不过别慌,因为双旋比你想象中的简单,而且同样,双旋也是两种对称的情况,实际上我们只剩下一种情况需要分析了,所以,加油,弄懂了的话,面试的时候就完全不用慌了!

双旋

我们假设当前的AVL树为下图。

)​

这个时候我们新插入一个节点,数据为15,根据搜索树的性质,我们找到15对应的位置并插入,如图

)​

我们此时再次计算每个节点的平衡因子,发现根节点18的平衡因子为2,超过了1,不满足平衡条件,所以需要对他进行旋转。

)​

我们将刚才需要进行右单旋的左左情况和现在的这种情况放在一起对比一下,聪明的你一定发现,当前的情况相比于左左的情况只是插入的位置不同而已,左左情况插入的节点在根节点18较高左子树的左侧,而当前这种情况插入节点是在根节点18较高左子树的右侧,我们将它称为左右情况。

)​

那么可能正看到这里的你可能不禁会想:这不跟刚才左左差不多嘛,直接右单旋不就完事了。真的是这样吗?让我们来一次右单旋看看再说。

)​

简单分析该右单旋:**节点14上提变成新的根节点,18下拉变成根节点的右子树,又因为当前根节点14原来有右子树16,所以18与16位置冲突,**比较18与16大小之后,发现18大于16,根据搜索树的性质,将以16为根节点的子树调整到18的左子树,因为18的左子树目前为空,所以以16为根的子树直接挂在18的左侧,若18的左子树不为空,则需要根据搜索树的性质继续进行比较,直到找到合适的挂载位置。

既然一次右单旋不行,那么我们应该怎么办呢?答案就是进行一次双旋,一次双旋可以拆分成两次单旋,对于当前这种不平衡条件,我们可以先进行一次左单旋,再进行一次右单旋,之后就可以将树调整成满足平衡条件的AVL树了,话不多说,图解一下。

)​

简单分析左右双旋:先对虚线框内的子树进行左单旋,则16上提变成子树的新根,以14为根节点的子树下拉,调整到16的左子树,此时发现16的左子树为15,与14这棵子树冲突,所以根据搜索树规则进行调整,将15挂载到以14为根节点子树的右子树,从而完成一次左单旋,之后再对整棵树进行一次右单旋,节点16上提成为新的根节点,18下拉变成根节点的右子树,因为之前16没有右子树,所以以18为根节点的子树直接挂载到16的右子树,从而完成右旋。

同样,对于左右情况的对称情况右左情况我就不给大家分析了,还是将图解送给大家,相信聪明的你一看就会!

)​

到此为止,我将AVL树的四种旋转情况都给大家介绍了一遍,仔细想想,其实不止这四种情况需要旋转,严格意义上来说有八种情况需要旋转,比如之前介绍的左左情况吧,我们说左左就是将新的节点插入到了根节点较高左子树的左侧,这个左侧其实细分一下又有两种情况,只不过这两种情况实际可以合成一种情况来看,也就是新的节点插入到左侧的时候可以成为它父亲节点的左孩子,也可以成为它父亲节点的右孩子,那么这样的话就是相当于两种情况了,简单画个图看一下吧。

)​

就是这样上图这样,每个新插入的节点都可以是它父亲节点的左孩子或者右孩子,这取决于新插入数据的大小,比如11就是12的左孩子,13就是12的右孩子,这两种情况都属于左左情况,也就是说他们本质上是一样的,都插在了节点18较高左子树的左侧。

那么这样看来这四种旋转情况严格上看都可以多分出一种情况,变成八种情况。

后话

emmm…这样看来AVL树确实解决了二叉搜索树可能不平衡的缺陷,补足了性能上不稳定的缺陷,但是细细想来AVL树的效率其实不是很好,这里说的不是查询效率,而是插入与删除效率,上面所说的这四大种八小种情况还是很容易命中的,那么这样的话就需要花费大量的时间去进行旋转调整,我的天,这样也太难搞了!

不过聪明的前人早就为我们想好了更加利于实际用途的搜索树,在现实场景中AVL树和二叉搜索树一样,基本上用不到,我们接下来要讲的这种二叉类的搜索树才是我们经常应用的,相信见多识广的你一定猜到了它的名字,对,就是它,大名鼎鼎的红黑树!我们下次来盘他!

鄙人才疏学浅,若有任何差错,还望各位海涵,不吝指教!

喜欢本文的少侠们,欢迎关注公众号雷子的编程江湖,修炼更多武林秘籍。

一键三连是中华民族的当代美德!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…772773774…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%