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

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


  • 首页

  • 归档

  • 搜索

封装DataBinding让你少写万行代码 本章前言

发表于 2021-05-02

banners_twitter.png

本章前言

这篇文章是在讲解kotlin协程的时候扩展而来,如果对kotlin协程感兴趣的可以通过下面链接进行阅读、

Kotlin协程基础及原理系列

  • 史上最详Android版kotlin协程入门进阶实战(一) -> kotlin协程的基础用法
  • 史上最详Android版kotlin协程入门进阶实战(二) -> kotlin协程的关键知识点初步讲解
  • 史上最详Android版kotlin协程入门进阶实战(三) -> kotlin协程的异常处理
  • 史上最详Android版kotlin协程入门进阶实战(四) -> 使用kotlin协程开发Android的应用
  • 史上最详Android版kotlin协程入门进阶实战(五) -> kotlin协程的网络请求封装

Flow系列

  • Kotlin协程之Flow使用(一)
  • Kotlin协程之Flow使用(二)
  • Kotlin协程之Flow使用(三)

扩展系列

  • 封装DataBinding让你少写万行代码
  • ViewModel的日常使用封装

笔者也只是一个普普通通的开发者,设计不一定合理,大家可以自行吸收文章精华,去糟粕。

现在我们就可以开始做一些基础的封装工作,同时在app的bulid.gradle文件中开启dataBinding的使用

1
2
3
4
5
6
kotlin复制代码android {
buildFeatures {
dataBinding = true
}
//省略...
}

基于DataBinding的封装

我们先创建一个简单的布局文件activity_main.xml。为了节约时间,同时我们也创建一个fragment_main.xml保持一样的布局。

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
xml复制代码<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">

<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.MainActivity">

<Button
android:id="@+id/btn"
android:layout_width="100dp"
android:layout_height="50dp"
android:gravity="center"
android:text="Hello World"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />

<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

我们在使用DataBinding初始化布局时候,我们通常喜欢使用下面几种方式,
在Activity中:

1
2
3
4
5
6
7
kotlin复制代码class MainActivity : AppCompatActivity() {
private lateinit var mBinding:ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
mBinding = DataBindingUtil.setContentView<ActivityMainBinding>(this,R.layout.activity_main)
}
}

在Fragment中:

1
2
3
4
5
6
7
8
9
10
11
12
kotlin复制代码class HomeFragment:Fragment() {

private lateinit var mBinding:FragmentMainBinding
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
mBinding = DataBindingUtil.inflate(layoutInflater, R.layout.fragment_main,container,false)
return mBinding.root
}
}

这种情况下,每创建一个activity和Fragment都需要重写一遍。所以我们创建一个BaseActivity进行抽象,然后使用泛型传入我们需要的ViewDataBinding对象VB,再通过构造方法或者抽象方法获取LayoutRes资源

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
kotlin复制代码abstract class BaseActivity<VB : ViewDataBinding>(@LayoutRes resId:Int) : AppCompatActivity() {
lateinit var mBinding:VB
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
mBinding = DataBindingUtil.setContentView<VB>(this,resId)
}
//...
}
//或者
abstract class BaseActivity<VB : ViewDataBinding> : AppCompatActivity() {
lateinit var mBinding:VB
@LayoutRes abstract fun getLayoutId():Int
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
mBinding = DataBindingUtil.setContentView<VB>(this,getLayoutId())
}
}

这个时候是不是经过我们经过上面处理后,再使用的时候我们会方便很多。可能有些人封装过的人看到这里会想,你讲的这都是啥,这些我都会,没有一点创意。笔者想说:不要捉急,我们要讲的可不是上面的东西,毕竟做事情都需要前奏铺垫滴。

image.png

虽然经过上面的抽象以后,我们是减少了一些步骤。但是笔者还写觉得有些麻烦,因为我们还是需要手写的通过外部传一个LayoutRes资源才能进行使用。想要再次细化缩减代码,那我们就得看看ActivityMainBinding的实现。

我们在开启DataBinding的时候,通过使用layout的activity_main.xml布局,DataBinding在编译的时候会自动在我们的工程app/build/generated/data_binding_base_class_source_out/packname/databinding目录下为我们生成一个ActivityMainBinding类,我们看看它的实现:

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
kotlin复制代码public abstract class ActivityMainBinding extends ViewDataBinding {
@NonNull
public final Button btn;

protected ActivityMainBinding(Object _bindingComponent, View _root, int _localFieldCount,
TextView tv) {
super(_bindingComponent, _root, _localFieldCount);
this.btn = btn;
this.recyclerView = recyclerView;
}

public static ActivityMainBinding inflate(@NonNull LayoutInflater inflater,
@Nullable ViewGroup root, boolean attachToRoot) {
return inflate(inflater, root, attachToRoot, DataBindingUtil.getDefaultComponent());
}

public static ActivityMainBinding inflate(@NonNull LayoutInflater inflater,
@Nullable ViewGroup root, boolean attachToRoot, @Nullable Object component) {
return ViewDataBinding.<ActivityMainBinding>inflateInternal(inflater, R.layout.activity_main, root, attachToRoot, component);
}

public static ActivityMainBinding inflate(@NonNull LayoutInflater inflater) {
return inflate(inflater, DataBindingUtil.getDefaultComponent());
}

public static ActivityMainBinding inflate(@NonNull LayoutInflater inflater,
@Nullable Object component) {
return ViewDataBinding.<ActivityMainBinding>inflateInternal(inflater, R.layout.activity_main, null, false, component);
}
//省略...
}

我们可以看到在ActivityMainBinding中的有4个inflate方法,同时他们最后的都会直接使用我们的布局文件activity_main.xml进行加载。所以我们想在上面的基础上进一步的简化使用方式,我们就必须通过反射的机制,从拿到ActivityMainBinding中的inflate方法,使用相对应的inflate方法去加载我们的布局。代码如下:

1
2
3
4
5
kotlin复制代码inline fun <VB:ViewBinding> Any.getViewBinding(inflater: LayoutInflater):VB{
val vbClass = (javaClass.genericSuperclass as ParameterizedType).actualTypeArguments.filterIsInstance<Class<VB>>()
val inflate = vbClass[0].getDeclaredMethod("inflate", LayoutInflater::class.java)
return inflate.invoke(null, inflater) as VB
}

我们先定义个扩展方法,通过反射的方式,从我们的Class中拿到我们想要的泛型类ViewBinding,然后invoke调用ViewBinding的inflate方法。然后我们再创建一个接口用于BaseActivity子类进行UI初始化绑定操作。

1
2
3
kotlin复制代码interface BaseBinding<VB : ViewDataBinding> {
fun VB.initBinding()
}
1
2
3
4
5
6
7
8
9
10
11
kotlin复制代码abstract class BaseActivity<VB : ViewDataBinding> : AppCompatActivity(), BaseBinding<VB> {
internal val mBinding: VB by lazy(mode = LazyThreadSafetyMode.NONE) {
getViewBinding(layoutInflater)
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(mBinding.root)
mBinding.initBinding()
}
}

现在我们就可以继承BaseActivity实现我们的Activity:

1
2
3
4
5
6
kotlin复制代码class MainActivity : BaseActivity<ActivityMainBinding>() {

override fun ActivityMainBinding.initBinding() {
Log.d("MainActivity","btn :${btn.text}")
}
}
1
log复制代码D/MainActivity: btn  :Hello World

现在我们的代码是不是变得简洁、清爽很多。这样我们不仅节省了编写大量重复代码的时间,同时也让我们代码的变得更加合理、美观。

image.png

和Activity有一些不同,因为Fragment创建布局的时候需要传入ViewGroup,所以我们稍微做一个变化。

1
2
3
4
5
kotlin复制代码inline fun <VB:ViewBinding> Any.getViewBinding(inflater: LayoutInflater, container: ViewGroup?):VB{
val vbClass = (javaClass.genericSuperclass as ParameterizedType).actualTypeArguments.filterIsInstance<Class<VB>>()
val inflate = vbClass[0].getDeclaredMethod("inflate", LayoutInflater::class.java, ViewGroup::class.java, Boolean::class.java)
return inflate.invoke(null, inflater, container, false) as VB
}

可能在某些环境下有些人在创建Fragment的时候需要把attachToRoot设置成true,这个时候自己扩展一个就好了,我们这里就不再演示。同理我们再抽象一个BaseFragment:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
kotlin复制代码abstract class BaseFragment<VB : ViewDataBinding>: Fragment(),BaseBinding<VB> {
protected lateinit var mBinding:VB
private set
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
mBinding = getViewBinding(inflater,container)
return mBinding.root
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
mBinding.initBinding()
}

override fun onDestroy() {
super.onDestroy()
//此处记得取消绑定,避免内存泄露
if(::mBinding.isInitialized){
mBinding.unbind()
}
}
1
2
3
4
5
kotlin复制代码class HomeFragment:BaseFragment<FragmentMainBinding>() {
override fun FragmentMainBinding.initBinding() {
Log.d("HomeFragment","btn :${btn.text}")
}
}

看到这里是不是心灵舒畅了很多,不仅代码量减少了,逼格也提升了许多,同时又增加了XX技术群里摸鱼吹水的时间。

image.png

当然我们也不能仅仅满足于此,在码代码的过程中还有一个大量重复工作的就是我们的Adapter,我们就拿使用到做多的RecyclerView.Adapter为例,假设我们创建一个最简单的HomeAdapter:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
kotlin复制代码class HomeAdapter(private val data: List<String>? = null) : RecyclerView.Adapter<HomeAdapter.BindingViewHolder>() {

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingViewHolder {
val mBinding = DataBindingUtil.inflate<ItemHomeBinding>(LayoutInflater.from(parent.context), R.layout.item_home ,parent, false)
return BindingViewHolder(mBinding)
}

override fun onBindViewHolder(holder: BindingViewHolder, position: Int) {
holder.binding.tv.text = data?.get(position) ?: ""
//其他绑定...
holder.binding.executePendingBindings()
}

fun setData(){
//刷新数据...
}

override fun getItemCount(): Int {
return data?.size ?: 0
}

class BindingViewHolder constructor(val binding: ItemHomeBinding) : RecyclerView.ViewHolder(binding.root)
}

就这样一个最简单的Adapter,我们都需要些一堆啰嗦代码,如果再加上item的click事件的话,我们要做的
工作就变得更多。那么我们现在要解决这么一个问题的话,我们要处理哪里东西呢:

  1. 统一Adapter的初始化工作。
  2. 简化onBindViewHolder的使用。
  3. 去掉每次都需要重复创建ViewHolder。
  4. 统一我们设置Item的监听事件方式。
  5. 统一Adapter的数据刷新。

首先我们需要修改一下我们之前定义的扩展getViewBinding,因为我们是不知道具体这个getViewBinding是用在哪个类上,这个类又定义了几个泛型。所以我们增加一个默认值为0的position参数代替之前写死的0,通过这种方式让调用者自行设定VB:ViewBinding所在的位置顺序:

1
2
3
4
5
6
7
8
9
10
11
kotlin复制代码inline fun <VB:ViewBinding> Any.getViewBinding(inflater: LayoutInflater,position:Int = 0):VB{
val vbClass = (javaClass.genericSuperclass as ParameterizedType).actualTypeArguments.filterIsInstance<Class<VB>>()
val inflate = vbClass[position].getDeclaredMethod("inflate", LayoutInflater::class.java)
return inflate.invoke(null, inflater) as VB
}

inline fun <VB:ViewBinding> Any.getViewBinding(inflater: LayoutInflater, container: ViewGroup?,position:Int = 0):VB{
val vbClass = (javaClass.genericSuperclass as ParameterizedType).actualTypeArguments.filterIsInstance<Class<VB>>()
val inflate = vbClass[position].getDeclaredMethod("inflate", LayoutInflater::class.java, ViewGroup::class.java, Boolean::class.java)
return inflate.invoke(null, inflater, container, false) as VB
}

创建我们的BaseAdapter,先将完整代码贴出:

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
kotlin复制代码abstract class BaseAdapter<T, VB : ViewDataBinding> : RecyclerView.Adapter<BaseAdapter.BindViewHolder<VB>>() {

private var mData: List<T> = mutableListOf()

fun setData(data: List<T>?) {
data?.let {
val result = DiffUtil.calculateDiff(object : DiffUtil.Callback() {
override fun getOldListSize(): Int {
return mData.size
}

override fun getNewListSize(): Int {
return it.size
}

override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldData: T = mData[oldItemPosition]
val newData: T = it[newItemPosition]
return this@BaseAdapter.areItemsTheSame(oldData, newData)
}

override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldData: T = mData[oldItemPosition]
val newData: T = it[newItemPosition]
return this@BaseAdapter.areItemContentsTheSame(oldData, newData, oldItemPosition, newItemPosition)
}
})
mData = data
result.dispatchUpdatesTo(this)
} ?: let {
mData = mutableListOf()
notifyItemRangeChanged(0, mData.size)
}

}

fun addData(data: List<T>?, position: Int? = null) {
if (!data.isNullOrEmpty()) {
with(LinkedList(mData)){
position?.let {
val startPosition = when {
it < 0 -> 0
it >= size -> size
else -> it
}
addAll(startPosition, data)
}?: addAll(data)
setData(this)
}
}
}

protected open fun areItemContentsTheSame(oldItem: T, newItem: T, oldItemPosition: Int, newItemPosition: Int): Boolean {
return oldItem == newItem
}

protected open fun areItemsTheSame(oldItem: T, newItem: T): Boolean {
return oldItem == newItem
}

fun getData(): List<T> {
return mData
}

fun getItem(position: Int): T {
return mData[position]
}

fun getActualPosition(data: T): Int {
return mData.indexOf(data)
}

override fun getItemCount(): Int {
return mData.size
}

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindViewHolder<VB> {
return with(getViewBinding<VB>(LayoutInflater.from(parent.context), parent,1)) {
setListener()
BindViewHolder(this)
}
}

override fun onBindViewHolder(holder: BindViewHolder<VB>, position: Int) {
with(holder.binding){
onBindViewHolder(getItem(position), position)
executePendingBindings()
}
}

open fun VB.setListener() {}

abstract fun VB.onBindViewHolder(bean: T, position: Int)

class BindViewHolder<M : ViewDataBinding>(var binding: M) :
RecyclerView.ViewHolder(binding.root)
}

我们这里先忽略这个BaseAdapter的定义,现在我们将HomeAdapter修改一下就变成了:

1
2
3
4
5
6
kotlin复制代码class HomeAdapter : BaseAdapter<String, ItemHomeBinding>() {

override fun ItemHomeBinding.onBindViewHolder(bean: String, position: Int) {
tv.text = bean
}
}

我们在Activity中使用时:

1
2
3
4
5
6
7
8
9
10
11
12
13
kotlin复制代码class MainActivity : BaseActivity<ActivityMainBinding>() {
lateinit var homeAdapter:HomeAdapter
override fun ActivityMainBinding.initBinding() {
homeAdapter = HomeAdapter()
with(recyclerView){
layoutManager = LinearLayoutManager(this@MainActivity).apply {
orientation = RecyclerView.VERTICAL
}
adapter = homeAdapter
}
homeAdapter.setData(listOf("a","b","c","d","e","f"))
}
}

现在我们的adapter中的代码是不是变的超简洁,我相信现在即使让你再写100个Adapter也不害怕。

image.png

现在我们来一步一步的拆解BaseAdapter。我们看到BaseAdapter需要传入2个泛型,T是我们需要显示的实体的数据类型,VB是我们的布局绑定ViewDataBinding。

1
2
3
4
KOTLIN复制代码abstract class BaseAdapter<T, VB : ViewDataBinding> 
: RecyclerView.Adapter<BaseAdapter.BindViewHolder<VB>>() {
//...
}

往下可以看到我们通过在BaseAdapter实现onCreateViewHolder来处理Item布局的初始化工作,我们这里调用getViewBinding的时候position传入的是1,正好对应我们VB所在的顺序:

1
2
3
4
5
6
KOTLIN复制代码    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindViewHolder<VB> {
return with(getViewBinding<VB>(LayoutInflater.from(parent.context), parent,1)) {
setListener()
BindViewHolder(this)
}
}

同时我们创建了一个内部类BindViewHolder来进行统一ViewHolder的创建工作。

1
2
KOTLIN复制代码    class BindViewHolder<M : ViewDataBinding>(var binding: M) :
RecyclerView.ViewHolder(binding.root)

我们在初始化布局的同时又调用了一个空实现的setListener方法。为什么我们在这里采用open而不是采用abstract来定义。是因为我们不是每一个Adapter都需要设置Item的监听事件,因此我们把setListener只是作为一个可选的项来处理。

1
kotlin复制代码    open fun VB.setListener() {}

image.png

初始化完成以后,我们需要进行布局绑定,但是因为不同的Adapter的界面,需要处理的绑定是不一样的,所以我们在实现onBindViewHolder的同时,通过调用内部创建的抽象方法VB.onBindViewHolder将我们的绑定处理交由子类进行处理。

1
2
3
4
5
6
KOTLIN复制代码    override fun onBindViewHolder(holder: BindViewHolder<VB>, position: Int) {
with(holder.binding){
onBindViewHolder(getItem(position), position)
executePendingBindings()
}
}

同时将VB.onBindViewHolder参数转换为实际的数据bean和对应的位置position.

1
KOTLIN复制代码 abstract fun VB.onBindViewHolder(bean: T, position: Int)

到目前为止,我们在BaseAdapter中已经处理了:

  1. 统一Adapter的初始化工作。
  2. 简化onBindViewHolder的使用。
  3. 去掉每次都需要重复创建ViewHolder。
  4. 统一我们设置Item的监听事件方式。

现在我们就来看下是如何统一Adapter的数据刷新。可以看到我们在BaseAdapter创建了一个私有数据集合mData,在mData中存放的是我们需要显示的泛型T的数据类型。

1
KOTLIN复制代码private var mData: List<T> = mutableListOf()

同时我们增加了一个setData方法,在此方法中我们使用DiffUtil对我们的数据进行对比刷新。,如果对DiffUtil不太熟的可以查一下它的方法。

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
KOTLIN复制代码    fun setData(data: List<T>?) {
data?.let {
val result = DiffUtil.calculateDiff(object : DiffUtil.Callback() {
override fun getOldListSize(): Int {
return mData.size
}

override fun getNewListSize(): Int {
return it.size
}

override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldData: T = mData[oldItemPosition]
val newData: T = it[newItemPosition]
return this@BaseAdapter.areItemsTheSame(oldData, newData)
}

override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldData: T = mData[oldItemPosition]
val newData: T = it[newItemPosition]
return this@BaseAdapter.areItemContentsTheSame(oldData, newData, oldItemPosition, newItemPosition)
}
})
mData = data
result.dispatchUpdatesTo(this)
} ?: let {
mData = mutableListOf()
notifyItemRangeChanged(0, mData.size)
}
}

这里我们需要注意一下,DiffUtil.Callback中的areItemsTheSame和areContentsTheSame2个对比数据的方法,实际上是通过我们在BaseAdapter中定义2个open方法areItemContentsTheSame,areItemsTheSame。

1
2
3
4
5
6
7
KOTLIN复制代码    protected open fun areItemContentsTheSame(oldItem: T, newItem: T, oldItemPosition: Int, newItemPosition: Int): Boolean {
return oldItem == newItem
}

protected open fun areItemsTheSame(oldItem: T, newItem: T): Boolean {
return oldItem == newItem
}

为什么要这么去定义的呢。虽然在BaseAdapter中实现了这2个方法,因为我们不知道子类在实现的时候是否需要改变对比的方式。比如我在使用areItemsTheSame的时候,泛型T如果泛型T不是一个基本数据类型,通常只需要对比泛型T中的唯一key就可以。现在假设泛型T是一个数据实体类User:

1
KOTLIN复制代码    data class User(val id:Int,val name:String)

那我们在子类复写areItemsTheSame方法的时候,就可以在我们的实现的apapter如下使用:

1
2
3
KOTLIN复制代码    protected open fun areItemsTheSame(oldItem: User, newItem: User): Boolean {
return oldItem.id == newItem.id
}

细心的可能注意到我们有定义一个getActualPosition方法,为什么不是叫getPosition。这是因为在有些为了方便,我们在onBindViewHolder的时候把此时position保存下来,或者设置监听器的时候传入了position。

如果我们在之前的数据基础上插入或者减少几条数据的话,但是又因为我们使用了DiffUtil的方式去刷新,由于之前已存在bean的数据没变,只是位置变了,所以onBindViewHolder不会执行,这个时候我们直接使用position的时候会出现位置不对问题,或者是越界的问题。比如如下使用:

1
2
3
4
KOTLIN复制代码interface ItemClickListener<T> {
fun onItemClick(view: View,position:Int, data: T){}
fun onItemClick(view: View, data: T)
}

我们在ItemClickListener定义了2个方法,我们使用带有position的onItemClick方法来演示:

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
XML复制代码<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">

<data>

<variable
name="bean"
type="String" />

<variable
name="position"
type="int" />

<variable
name="itemClickListener"
type="com.carman.kotlin.coroutine.interf.ItemClickListener" />
</data>

<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">

<TextView
android:id="@+id/tv"
android:layout_width="match_parent"
android:layout_height="50dp"
android:onClick="@{(v)->itemClickListener.onItemClick(v,position,bean)}"
android:textColor="@color/black"
android:textSize="18sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

我们在XML中进行Click绑定,然后我们在HomeAdapter进行监听事件和数据设置

1
2
3
4
5
6
7
8
9
10
11
12
KOTLIN复制代码class HomeAdapter(private val listener: ItemClickListener<String>) : BaseAdapter<String, ItemHomeBinding>() {

override fun ItemHomeBinding.setListener() {
itemClickListener = listener
}

override fun ItemHomeBinding.onBindViewHolder(bean: String, position: Int) {
this.bean = bean
this.position = position
tv.text = bean
}
}

接下来我们在MainActivity通过2次设置数据在点击查看日志

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
KOTLIN复制代码class MainActivity : BaseActivity<ActivityMainBinding>() {
lateinit var homeAdapter:HomeAdapter
override fun ActivityMainBinding.initBinding() {
homeAdapter = HomeAdapter(itemClickListener)
with(recyclerView){
layoutManager = LinearLayoutManager(this@MainActivity).apply {
orientation = RecyclerView.VERTICAL
}
adapter = homeAdapter
}
homeAdapter.setData(listOf("a","b","c","d","e","f"))
btn.setOnClickListener {
Log.d("刷新", "第二次setData")
homeAdapter.setData(listOf("c","d","e","f"))
}
}

private val itemClickListener = object : ItemClickListener<String> {
override fun onItemClick(view: View, position: Int, data: String) {
Log.d("onItemClick", "data:$data position:$position")
}
}
}
1
2
3
4
5
6
7
8
9
10
11
logcat复制代码D/onItemClick: data:a   position:0
D/onItemClick: data:b position:1
D/onItemClick: data:c position:2
D/onItemClick: data:d position:3
D/onItemClick: data:e position:4
D/onItemClick: data:f position:5
D/刷新: 第二次setData
D/onItemClick: data:c position:2
D/onItemClick: data:d position:3
D/onItemClick: data:e position:4
D/onItemClick: data:f position:5

所以我们需要在使用position的时候,最好是通过getActualPosition来获取真实的位置,我们修改一下itemClickListener中的日志输出。

1
2
3
4
5
KOTLIN复制代码    private val itemClickListener = object : ItemClickListener<String> {
override fun onItemClick(view: View, position: Int, data: String) {
Log.d("onItemClick", "data:$data position:${homeAdapter.getActualPosition(data)}")
}
}

这个时候我们再重复上面操作的时候,就可以看到position的位置就是它目前所处的真实位置。

1
2
3
4
KOTLIN复制代码D/onItemClick: data:c   position:0
D/onItemClick: data:d position:1
D/onItemClick: data:e position:2
D/onItemClick: data:f position:3

到此为止,我们对于这个BaseAdapter<T, VB : ViewDataBinding>的抽象原理,以及使用方式有了大概的了解。

需要注意的是:为了方便简单演示,我们这里假设是,没有在xml中直接使用Databinding进行绑定。因为有些复杂逻辑我们是没有办法简单的在xml中进行绑定的。

image.png

很显然我们的工作并没有到此结束,因为我们的adapter在常用的场景中还有多布局的情况,那我们又应该如何处理呢。

这个其实很好办。因为我们是多布局,那么就意味着我们需要把onCreateViewHolder中的一部分工作暴露给子类处理,所以我们需要在上面BaseAdapter的基础上做一些修改。照例上代码:

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
KOTLIN复制代码abstract class BaseMultiTypeAdapter<T> : RecyclerView.Adapter<BaseMultiTypeAdapter.MultiTypeViewHolder>() {

private var mData: List<T> = mutableListOf()

fun setData(data: List<T>?) {
data?.let {
val result = DiffUtil.calculateDiff(object : DiffUtil.Callback() {
override fun getOldListSize(): Int {
return mData.size
}

override fun getNewListSize(): Int {
return it.size
}

override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldData: T = mData[oldItemPosition]
val newData: T = it[newItemPosition]
return this@BaseMultiTypeAdapter.areItemsTheSame(oldData, newData)
}

override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldData: T = mData[oldItemPosition]
val newData: T = it[newItemPosition]
return this@BaseMultiTypeAdapter.areItemContentsTheSame(oldData, newData, oldItemPosition, newItemPosition)
}
})
mData = data
result.dispatchUpdatesTo(this)
} ?: let {
mData = mutableListOf()
notifyItemRangeChanged(0, mData.size)
}

}

fun addData(data: List<T>?, position: Int? = null) {
if (!data.isNullOrEmpty()) {
with(LinkedList(mData)) {
position?.let {
val startPosition = when {
it < 0 -> 0
it >= size -> size
else -> it
}
addAll(startPosition, data)
} ?: addAll(data)
setData(this)
}
}
}

protected open fun areItemContentsTheSame(oldItem: T, newItem: T, oldItemPosition: Int, newItemPosition: Int): Boolean {
return oldItem == newItem
}

protected open fun areItemsTheSame(oldItem: T, newItem: T): Boolean {
return oldItem == newItem
}

fun getData(): List<T> {
return mData
}

fun getItem(position: Int): T {
return mData[position]
}

fun getActualPosition(data: T): Int {
return mData.indexOf(data)
}

override fun getItemCount(): Int {
return mData.size
}

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MultiTypeViewHolder {
return MultiTypeViewHolder(onCreateMultiViewHolder(parent, viewType))
}

override fun onBindViewHolder(holder: MultiTypeViewHolder, position: Int) {
holder.onBindViewHolder(holder, getItem(position), position)
holder.binding.executePendingBindings()
}

abstract fun MultiTypeViewHolder.onBindViewHolder(holder: MultiTypeViewHolder, item: T, position: Int)

abstract fun onCreateMultiViewHolder(parent: ViewGroup, viewType: Int): ViewDataBinding

protected fun <VB :ViewDataBinding> loadLayout(vbClass: Class<VB>,parent: ViewGroup): VB {
val inflate = vbClass.getDeclaredMethod("inflate", LayoutInflater::class.java, ViewGroup::class.java, Boolean::class.java)
return inflate.invoke(null, LayoutInflater.from(parent.context), parent, false) as VB
}

class MultiTypeViewHolder(var binding: ViewDataBinding) :
RecyclerView.ViewHolder(binding.root)
}

通过上面的代码可以看到,我们没有在BaseMultiTypeAdapter中定义泛型VB :ViewDataBinding,因为我们是多布局,如果都写在类的定义中明显是不合适的,我们也不知道在具体实现需要有多少个布局。

所以我们onCreateViewHolder初始化布局的时候调用了一个抽象的onCreateMultiViewHolder方法,这个方法交由我们具体业务实现类去实现。同时我们对onBindViewHolder进行修改,增加了一个holder参数供外部使用。
我们先数据实体类型

1
2
3
4
5
6
7
8
9
10
11
KOTLIN复制代码sealed class Person(open val id :Int, open val name:String)

data class Student(
override val id:Int,
override val name:String,
val grade:String):Person(id, name)

data class Teacher(
override val id:Int,
override val name:String,
val subject:String):Person(id, name)

和我们需要实现的Adapter业务类,:

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
KOTLIN复制代码class SecondAdapter: BaseMultiTypeAdapter<Person>() {

companion object{
private const val ITEM_DEFAULT_TYPE = 0
private const val ITEM_STUDENT_TYPE = 1
private const val ITEM_TEACHER_TYPE = 2
}

override fun getItemViewType(position: Int): Int {
return when(getItem(position)){
is Student -> ITEM_STUDENT_TYPE
is Teacher -> ITEM_TEACHER_TYPE
else -> ITEM_DEFAULT_TYPE
}
}

override fun onCreateMultiViewHolder(parent: ViewGroup, viewType: Int): ViewDataBinding {
return when(viewType){
ITEM_STUDENT_TYPE -> loadLayout(ItemStudentBinding::class.java,parent)
ITEM_TEACHER_TYPE -> loadLayout(ItemTeacherBinding::class.java,parent)
else -> loadLayout(ItemPersionBinding::class.java,parent)
}
}

override fun MultiTypeViewHolder.onBindViewHolder(holder: MultiTypeViewHolder, item: Person, position: Int) {
when(holder.binding){
is ItemStudentBinding ->{
Log.d("ItemStudentBinding","item : $item position : $position")
}
is ItemTeacherBinding ->{
Log.d("ItemTeacherBinding","item : $item position : $position")
}
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
KOTLIN复制代码class MainActivity : BaseActivity<ActivityMainBinding>() {
override fun ActivityMainBinding.initBinding() {
val secondAdapter = SecondAdapter()
with(recyclerView){
layoutManager = LinearLayoutManager(this@MainActivity).apply {
orientation = RecyclerView.VERTICAL
}
adapter = secondAdapter
}
secondAdapter.setData(
listOf(
Teacher(1,"Person","语文"),
Student(2,"Person","一年级"),
Teacher(3,"Person","数学"),
))
}

运行一下就可以看到我们想要的结果:

1
2
3
KOTLIN复制代码D/ItemTeacherBinding: item : Teacher(id=1, name=Person, subject=语文)   position : 0
D/ItemStudentBinding: item : Student(id=2, name=Person, grade=一年级) position : 1
D/ItemTeacherBinding: item : Teacher(id=3, name=Person, subject=数学) position : 2

经过上面的处理以后,我们在创建Activiy、Fragment、Adapter的时候减少了大量的代码。同时也节省了码这些重复垃圾代码的时间,起码让你们的工作效率起码提升10个百分点,是不是感觉到自己无形中又变帅了许多。

image.png

经过以上封装处理以后,我们是不是也可以对Dialog,PopWindow、动态初始化View进行处理呢。那还等什么,赶紧去实现吧。毕竟授人以鱼,不如授人以渔。

需要源码的看这里:demo源码

原创不易。如果您喜欢这篇文章,您可以动动小手点赞收藏image.png。

微信截图_20211227104733.jpg

Android技术交流群,有兴趣的可以私聊加入。

关联文章
Kotlin协程基础及原理系列

  • 史上最详Android版kotlin协程入门进阶实战(一) -> kotlin协程的基础用法
  • 史上最详Android版kotlin协程入门进阶实战(二) -> kotlin协程的关键知识点初步讲解
  • 史上最详Android版kotlin协程入门进阶实战(三) -> kotlin协程的异常处理
  • 史上最详Android版kotlin协程入门进阶实战(四) -> 使用kotlin协程开发Android的应用
  • 史上最详Android版kotlin协程入门进阶实战(五) -> kotlin协程的网络请求封装

Flow系列

  • Kotlin协程之Flow使用(一)
  • Kotlin协程之Flow使用(二)
  • Kotlin协程之Flow使用(三)

扩展系列

  • 封装DataBinding让你少写万行代码
  • ViewModel的日常使用封装

本文转载自: 掘金

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

如何高效地进行大规模数据迁移?

发表于 2021-05-02

导读

我们在做一次系统业务模型重构的时候,往往会遇到把旧模型表中的数据迁移到新模型表中,如果这时候,我们旧表中的数据规模已达到千万级以上,那么,这个从旧表迁移到新表的过程会非常漫长,而业务仍需快速推进,业务方接受不了这么高的迁移成本,此时,开发同学就会想:既然业务不允许停滞,但是,又不得不将大规模的数据迁移新模型表中。那么,为了保证对业务影响最小,更快完成数据迁移,一批插入多少条记录到新表的效率最高呢?

今天,我就以用户中心这个系统中的用户表为例,详细讲解一下一条Insert语句的插入过程,从而找出影响一条记录插入性能的因素,最后,回答一批插入多少条记录到新表效率最高?

插入过程

假设我们使用的用户表结构如下:

1
2
3
4
5
6
7
8
9
10
sql复制代码CREATE TABLE `user` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`user_id` int(8) DEFAULT NULL COMMENT '用户id',
`user_name` varchar(29) DEFAULT NULL COMMENT '用户名',
`user_introduction` varchar(498) DEFAULT NULL COMMENT '用户介绍',
`sex` tinyint(1) DEFAULT NULL COMMENT '性别',
`age` int(3) DEFAULT NULL COMMENT '年龄',
`birthday` date DEFAULT NULL COMMENT '生日',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8;

现在,假设这张表是我们的新用户模型表,然后,我们要将旧模型表中的的3000w条用户记录插入新模型表,采用分批插入的方式,即一次8000条,那么,这个插入的过程如下:

image-20201115180850814.png

图中,为了重点描述插入记录的过程,所以,我只插入了4条记录:

<1, 10001, Jack, ...>

<2, 10002, Nancy, ...>

<3, 10003, Jack, ...>

<4, 10004, John, ...>

这4条记录的插入过程大致如下:

  1. 将4条记录的插入操作和相应的删除操作写入Undo Log。
  2. 将4条记录的插入操作写入Redo Log。
  3. 将4条记录写入Bin Log(行模式)。
  4. 读取redo log,将4条记录写入索引和数据文件Data File。

其中,由于写入上面undo log、redo log、bin log和data file这4个文件,除了data file是异步写,其他3个都是同步写,且都为磁盘写,势必影响插入记录的性能,所以,MySQL针对这3个文件的同步写做了一系列优化,保证插入记录的性能。

现在我就从性能优化的角度,讲解一下MySQL是如何优化这3个文件的写入的?讲解完后,我将回答本章标题的问题:一批插入多少条记录到新表的效率最高呢?

Undo Log

对MySQL事务有一定了解的同学,应该知道MySQL的事务回滚就依赖这个log文件,所以,关于undo log的内容,我会在《我的事务执行好慢,怎么办?》中详细讲解。

Redo Log

关于redo log,你可能会问:MySQL为什么要记录这个log?

从数据可靠性来看,以上面的插入记录为例,如果没有第2步,MySQL执行到第4步,此时,刚好完成第1和2条记录插入,插入第3条记录时,MySQL宕机了,那么,将导致第3和4条记录丢失,所以,MySQL为了保证在重启机器时,能够再将3和4两条记录重新插入表中,将4条记录的插入操作全部写入redo log,那么,在重启机器后,MySQL就能从redo log中找到3和4两条记录,将这两条记录重新插入表中。

结构

现在,我们再来看一下redo log的结构:

image-20201115190124579.png

redo log由多组文件组成,每组对应一个文件,每个文件大小一致。其中,文件以ib_logfile[number]命名。如上图,有4组文件,ib_logfile1到ib_logfile4。同时,ib_logfile有如下特点:

  1. 每个ib_logfile文件存的是记录的操作信息。
  2. ib_logfile之间是循环依赖,如上图,在所有文件写满后,新的操作信息重新写入第一个ib_logfile。

一共有多少个组,以及每个文件大小多大,可以通过参数配置。主要参数如下:

innodb_log_files_in_group:分几个组。

innodb_log_file_size:每组对应的文件的大小。

所以,redo log的总大小 = innodb_log_files_in_group * innodb_log_file_size

写入

我以上面插入4条记录为例,讲解一下redo log的写入过程:

image-20201116005129246.png

我以船运货来模拟这个写入的过程,主要包含4个步骤:

  1. 将4条记录的插入操作写入缓冲区buffer,如上图(1)中的黄色小方块为这4条记录的插入操作组成的一组。图中最左边的码头代表buffer。
  2. 如果buffer满了,从buffer中读取已有的操作,开始IO总线传输这些操作,准备把它们写入redo log文件。如上图(1),绿色小方块代表buffer中已有的操作,表示buffer满了,见图(2),小船运输绿色小方块代表IO总线传输。再执行步骤1,将4条记录的插入操作写入buffer。如图(2)中黄色小方块放到最左边的码头。
  3. 将buffer中已有的操作写入redo log文件。如上图(3),中间的码头代表redo log文件,绿色小方块准备放到该码头上。
  4. 如果redo log文件满了,MySQL会执行我在《导读》中的那张图中写redo log之后的动作,将文件中的操作的记录刷到数据文件中。如上图(3),粉色小方块代表redo log中的操作,表示redo log满了,见图(4),小船运输粉色小方块到最右边的码头,最右边的码头代表数据文件。再执行步骤3,结合上面讲到的redo log的结构,从第一个文件ib_logfile1开始写4条记录的插入操作。如图(4)中绿色小方块放到中间的码头。

这里,其实是有问题的:如果缓冲区buffer写入成功,但是,之后写入redo log失败,那么,4条记录的操作将全部丢失。

所以,MySQL为了保证buffer和redo log文件的一致性,引入mtr的概念,全称Mini-Transaction,将buffer和redo log操作封装在一个mtr中,这样,就能保证buffer和redo log的写入要么都成功,要么都失败,如果失败的话,记录仍旧保留,可继续写buffer。

关于mtr的详细内容,我将在《MySQL是如何平衡日志写入的性能和数据可靠性的?》讲解。

从上面写入redo log的过程中,我们发现由于MySQL在写redo log文件前,先写buffer,而写buffer是一个内存操作,所以,同步写的性能就有了保障。

这里还有一个问题:插入操作时,如果MySQL每次都是等redo log写满,再将log中的所有内容刷到数据文件,那么,redo log很大的话,这样,刷数据文件的性能会很差。那怎么解决呢?
我们看下面这张图:

image-20201116000346467.png

MySQL引入了checkpoint的概念,由一个线程定时推进redo log的某个位置,这个位置就叫checkpoint,该位置之前的内容,即图中write position到checkpoint之间的内容,会由另一个线程刷到数据文件。这样,就避免大量的数据刷到数据文件,从而影响插入的性能。ps:MySQL会在write position到checkpoint之间的大小超过redo log总大小的76%就会提前触发刷数据文件的行为。

Bin Log

初步了解MySQL的同学一定知道binlog,它记录了每一条SQL的操作,MySQL主从实例之间的同步就是依靠它来实现的。那么,这里对比redo log,你可能会有一个疑问:redo log用来记录记录操作行为的,bin log也是记录记录操作的,那么,为什么有redo log,还需要bin log?

主要基于两点原因:

  1. redo log是InnoDB特有的日志,其他存储引擎没有这样的日志。而bin log是所有存储引擎都支持的日志。
  2. redo log是固定大小不变的。而bin log是可以不断追加的。如果用redo log来同步记录到从库上,如果出现从库还未拉取redo log上的一些操作,redo log里的这些操作因为日志写满,导致相应操作的记录被刷到数据文件,那么,redo log上针对这些操作的记录将丢失,记录无法同步到从库。

关于bin log详细内容,我会在《如何降低主从同步延迟?》中讲解。

参数配置

现在回到本章标题的那个问题:一批插入多少条记录到新表的效率最高呢?

从本章《插入过程》这一部分中,我们知道,在真正插入记录到数据文件前,会经过3个日志文件的写入:undo log、redo log和bin log,可见,3个日志文件的写入性能是影响整个记录插入性能的关键。

由于undo log和bin log都是可以不断追加的,同时,两者都是顺序写文件,所以,在批量插入记录的时候写这两个文件在性能上不会有太大的影响。

而redo log是有固定大小的,采用的是循环写,所以,批量插入记录的操作,在所有redo log写满后,把redo log中的操作对应的记录刷到数据文件后,会重新从第一个ib_logfile中写记录的批量插入操作,这是一个随机IO,会影响批量插入的性能。

所以,为了不影响批量插入记录的性能,我们可以调整redo log的总大小,即我在讲解redo log结构的时候提到的两个参数:innodb_log_files_in_group和innodb_log_file_size,保证一次批量插入的记录大小小于等于innodb_log_files_in_group * innodb_log_file_size,这样,我们就能减少redo log的随机IO,保证批量插入的性能。

比如:我现在一次批量插入的记录大小为800M,那么,我就要调整innodb_log_files_in_group=10,innodb_log_file_size=80M

具体调整方法如下:

  1. 打开my.cnf配置文件
  2. 设置innodb_log_files_in_group=10
  3. 设置innodb_log_file_size=80M
  4. 重启MySQL

通过上面的参数调整,我们就能保证一次批量插入大量的记录的效率是最高的。

小结

在本章节中,我以一次批量插入大量记录的例子为引线,讲解了Insert批量插入记录的过程,最后,由这个过程分别引出3个日志:undo log、redo log和bin log。重点讲解了redo log。

最后通过redo log的分析,回答了一批插入多少条记录到新表的效率最高呢?这个问题。

希望你在这篇文章中有所收获!

本文转载自: 掘金

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

面试官问我:聊聊spring七种事务传播行为?

发表于 2021-05-02
  • 大家好,这里是公众号:java小杰要加油
  • 我们都知道 spring 有七种事务传播行为,面试也经常被问道,不过他们长得都太像啦,老虎老鼠傻傻分不清楚,今天我们就用这篇文章来彻底搞懂他们!
  • 文中有大量代码论证,建议收藏在电脑端食用
  • 话不多说,直接开车

spring的七种事务传播行为

以下事务传播属性都是打在B方法上的事务注解

  • Propagation.REQUIRED: spring默认的事务传播行为,A方法调用B方法,如果A方法有事务,则B方法加入到A方法中的事务中,否则B方法自己开启一个新事务
  • Propagation.SUPPORTS: A方法调用B方法,如果A方法有事务,则B方法加入到A方法中的事务中,否则B方法自己使用非事务方式执行
  • Propagation.MANDATORY: 只能在存在事务的方法中被调用,A方法调用B方法,如果A方法没事务,则B方法会抛出异常
  • Propagation.REQUIRES_NEW: A方法调用B方法,如果A方法有事务,则B方法把A方法的事务挂起,B方法自己重新开启一个新事务
  • Propagation.NOT_SUPPORTED: A方法调用B方法,如果A方法有事务,则B方法挂起A方法中的事务中,否则B方法自己使用非事务方式执行
  • Propagation.NEVER: 不支持事务,A方法调用B方法,如果A方法有事务,则B方法会抛出异常
  • Propagation.NESTED: 同 Propagation.REQUIRED,不过此传播属性还可以,保存状态节点,从而避免所有嵌套事务都回滚

我们看完了每个传播属性的一些解释,脑子里应该是还是蒙蒙的,下面来看下真正的代码

实战

Propagation.REQUIRED

  • spring 默认的事务传播属性,A方法调用B方法,如果A方法有事务,则B方法加入到A方法中的事务中,否则B方法自己开启一个新事务

A接口

1
2
3
4
5
6
7
8
9
10
java复制代码@Transactional(rollbackFor = Exception.class,propagation = Propagation.REQUIRED)
@Override
public Integer updateTest(Test updateVO) {
System.out.println("A updateTest方法");
int i = testMapper.updateByPrimaryKey(updateVO);
Test test =new Test("小杰",24);
// 调用B接口的insertTest方法事务方法
BTestService.insertTest(test);
return i;
}

我们再来看下B接口

1
2
3
4
5
6
7
8
9
java复制代码 @Transactional(rollbackFor = Exception.class,propagation = Propagation.REQUIRED)
@Override
public Integer insertTest(Test updateVO) {
System.out.println("B insertTest方法");
int i = testMapper.insert(updateVO);
// 抛出异常
int a = 1 / 0 ;
return i;
}

假如说,我代码这么写的话,那么这个数据库里最终是会有什么数据呢?

1
2
3
4
5
java复制代码@PostMapping("/update")
public Object updateTest(@RequestBody Test updateVO){
Integer flag = ATestService.updateTest(updateVO);
return flag;
}

原数据库内容

postman来一发看看

可以看到控制台的结果是这样的,他们共用一个事务(sqlSession是一样的)

此时数据库的内容也并没有发生变化,说明A,B接口都回滚了

这个时候就会出现一个常见的面试题:如果B方法抛出的异常被A方法try catch 捕获了,那么A方法的操作还会回滚吗?

答案是:会回滚

来看下测试代码,我们在A方法中添加了捕获B方法抛出异常的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
java复制代码@Transactional(rollbackFor = Exception.class,propagation = Propagation.REQUIRED)
@Override
public Integer updateTest(Test updateVO) {
System.out.println("A updateTest方法");
int i = testMapper.updateByPrimaryKey(updateVO);
Test test =new Test("小杰",24);
try {
// 调用insertTest方法事务方法
BTestService.insertTest(test);
}catch (Exception e){
System.out.println("A方法补获了异常"+e.getMessage());
}
return i;
}

再次来一发postman,控制台输出测试结果如下


我们看数据库数据也没有变

那么问题又来了,如果A没有捕获,B方法自己捕获了异常,那么事务还会回滚吗?
答案是:不会

把B接口的代码改一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码@Transactional(rollbackFor = Exception.class,propagation = Propagation.REQUIRED)
@Override
public Integer insertTest(Test updateVO) {
System.out.println("B insertTest方法");
int i = 0;

try {
i = testMapper.insert(updateVO);
// 抛出异常
int a = 1 / 0 ;
}catch (Exception e){
System.out.println("B方法补获了异常"+e.getMessage());
}

return i;
}

同时把A方法的捕获异常去掉

1
2
3
4
5
6
7
8
9
10
java复制代码@Transactional(rollbackFor = Exception.class,propagation = Propagation.REQUIRED)
@Override
public Integer updateTest(Test updateVO) {
System.out.println("A updateTest方法");
int i = testMapper.updateByPrimaryKey(updateVO);
Test test =new Test("小杰",24);
// 调用insertTest方法事务方法
BTestService.insertTest(test);
return i;
}

这个时候的结果是

数据库的数据是


由此可见,A和B两个接口都生效了都操作数据库了,都没有回滚

A方法捕获和B方法捕获有什么区别吗(指捕获异常)
  • 区别就是,A方法捕获异常的话,B方法的事务注解会感知到异常的发生,从而回滚;
  • 而B方法自己捕获了,那么B方法的事务注解就不会感知到异常了,所以不会回滚

只要理解了上面这个例子,我们以后各种异常/传播属性到底回滚不回滚就好分析啦!

Propagation.SUPPORTS

  • A方法调用B方法,如果A方法有事务,则B方法加入到A方法中的事务中,否则B方法自己使用非事务方式执行

我们把B接口的事务传播属性换成 Propagation.SUPPORTS

1
2
3
4
5
6
7
8
9
java复制代码@Transactional(rollbackFor = Exception.class,propagation = Propagation.SUPPORTS)
@Override
public Integer insertTest(Test updateVO) {
System.out.println("B insertTest方法");
int i = testMapper.insert(updateVO);
// 抛出异常
int a = 1 / 0 ;
return i;
}

A方法

1
2
3
4
5
6
7
8
9
10
java复制代码@Transactional(rollbackFor = Exception.class,propagation = Propagation.REQUIRED)
@Override
public Integer updateTest(Test updateVO) {
System.out.println("A updateTest方法");
int i = testMapper.updateByPrimaryKey(updateVO);
Test test =new Test("小杰",24);
// 调用insertTest方法事务方法
BTestService.insertTest(test);
return i;
}

测试结果是


数据库的值也没有被改变 , 所以两个操作都被回滚了
那我们把A方法的事务注解去掉后再看一下

1
2
3
4
5
6
7
8
9
java复制代码 @Override
public Integer updateTest(Test updateVO) {
System.out.println("A updateTest方法");
int i = testMapper.updateByPrimaryKey(updateVO);
Test test =new Test("小杰",24);
// 调用insertTest方法事务方法
BTestService.insertTest(test);
return i;
}

测试结果是是


数据库的值是

由此可见,两个操作都没有被回滚,B方法是以非事务方式进行的操作

Propagation.MANDATORY

只能在存在事务的方法中被调用,A方法调用B方法,如果A方法没事务,则B方法会抛出异常

A接口如下

1
2
3
4
5
6
7
8
9
java复制代码@Override
public Integer updateTest(Test updateVO) {
System.out.println("A updateTest方法");
int i = testMapper.updateByPrimaryKey(updateVO);
Test test =new Test("小杰",24);
// 调用insertTest方法事务方法
BTestService.insertTest(test);
return i;
}

B接口如下

1
2
3
4
5
6
7
8
9
java复制代码@Transactional(rollbackFor = Exception.class,propagation = Propagation.MANDATORY)
@Override
public Integer insertTest(Test updateVO) {
System.out.println("B insertTest方法");
int i = testMapper.insert(updateVO);
// 抛出异常
int a = 1 / 0 ;
return i;
}


数据库的值也没有变,由此可见,B方法的事务注解为 Propagation.MANDATORY 当A方法没事务时,则直接报错。

Propagation.REQUIRES_NEW

  • A方法调用B方法,如果A方法有事务,则B方法把A方法的事务挂起,B方法自己重新开启一个新事务

A方法

1
2
3
4
5
6
7
8
9
10
java复制代码@Transactional(rollbackFor = Exception.class,propagation = Propagation.REQUIRED)
@Override
public Integer updateTest(Test updateVO) {
System.out.println("A updateTest方法");
int i = testMapper.updateByPrimaryKey(updateVO);
Test test =new Test("小杰",24);
// 调用insertTest方法事务方法
BTestService.insertTest(test);
return i;
}

B方法

1
2
3
4
5
6
7
8
9
java复制代码@Transactional(rollbackFor = Exception.class,propagation = Propagation.REQUIRES_NEW)
@Override
public Integer insertTest(Test updateVO) {
System.out.println("B insertTest方法");
int i = testMapper.insert(updateVO);
// 抛出异常
int a = 1 / 0 ;
return i;
}

结果是

其中可以发现 两个接口的 DefaultSqlSession 不一样,那么就表明,这两个不是一个事务,所以就是,当A接口存在事务的时候,B接口将其挂起并且重新开启一个新的事务

  • B方法抛出了异常,那么A方法没有捕获的话,则A,B方法都会回滚
  • A方法捕获了异常,则A方法不回滚

还是那句话,如果在方法内捕获了异常,则此方法上的事务注解就感知不到这个异常的存在了,那么此方法的操作就不会回滚!

Propagation.NOT_SUPPORTED

A方法调用B方法,如果A方法有事务,则B方法挂起A方法中的事务中,否则B方法自己使用非事务方式执行

A接口

1
2
3
4
5
6
7
8
9
10
java复制代码@Transactional(rollbackFor = Exception.class,propagation = Propagation.REQUIRED)
@Override
public Integer updateTest(Test updateVO) {
System.out.println("A updateTest方法");
int i = testMapper.updateByPrimaryKey(updateVO);
Test test =new Test("小杰",24);
// 调用insertTest方法事务方法
BTestService.insertTest(test);
return i;
}

B接口

1
2
3
4
5
6
7
8
9
java复制代码@Transactional(rollbackFor = Exception.class,propagation = Propagation.NOT_SUPPORTED)
@Override
public Integer insertTest(Test updateVO) {
System.out.println("B insertTest方法");
int i = testMapper.insert(updateVO);
// 抛出异常
int a = 1 / 0 ;
return i;
}

测试结果是

数据库的结果是

我们可以看到,B接口生效了,确实插入了一条数据,A接口没有生效,没有更改数据,这是因为,异常在B接口内抛出来了,由于B接口的事务传播行为是 Propagation.NOT_SUPPORTED 则会挂起A接口的事务,B接口以非事务情况操作(所以报错也不回滚),异常刨到了A接口内,A接口是有事务的,则会回滚,所以就没有更改数据

Propagation.NEVER

  • 不支持事务,A方法调用B方法,如果A方法有事务,则B方法会抛出异常

A接口

1
2
3
4
5
6
7
8
9
10
java复制代码@Transactional(rollbackFor = Exception.class,propagation = Propagation.REQUIRED)
@Override
public Integer updateTest(Test updateVO) {
System.out.println("A updateTest方法");
int i = testMapper.updateByPrimaryKey(updateVO);
Test test =new Test("小杰",24);
// 调用insertTest方法事务方法
BTestService.insertTest(test);
return i;
}

B接口

1
2
3
4
5
6
7
8
9
java复制代码@Transactional(rollbackFor = Exception.class,propagation = Propagation.NEVER)
@Override
public Integer insertTest(Test updateVO) {
System.out.println("B insertTest方法");
int i = testMapper.insert(updateVO);
// 抛出异常
int a = 1 / 0 ;
return i;
}

结果是


数据库也没有被改变,
可见,当A接口有事务的情况下调用B接口,直接报错

Propagation.NESTED

  • 同 Propagation.REQUIRED,不过此传播属性还可以,保存状态节点,从而避免所有嵌套事务都回滚

A接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
java复制代码@Transactional(rollbackFor = Exception.class,propagation = Propagation.REQUIRED)
@Override
public Integer updateTest(Test updateVO) {
System.out.println("A updateTest方法");
int i = testMapper.updateByPrimaryKey(updateVO);
Test test =new Test("小杰",24);
try {
// 调用insertTest方法事务方法
BTestService.insertTest(test);
}catch (Exception e){
System.out.println("A方法补获了异常"+e.getMessage());
}
return i;
}

B接口

1
2
3
4
5
6
7
8
9
java复制代码@Transactional(rollbackFor = Exception.class,propagation = Propagation.NESTED)
@Override
public Integer insertTest(Test updateVO) {
System.out.println("B insertTest方法");
int i = testMapper.insert(updateVO);
// 抛出异常
int a = 1 / 0 ;
return i;
}

结果是

数据库的变化如下


A接口的操作没有回滚,B操作的回滚了,这就是因为“savePoint”安全点,在进行B接口操作时,当前的状态(A接口已经操作完了)被保存至安全点,B接口失败的话,回滚只会回滚到这个安全点

注:需要在A接口里try catch B接口的异常

这里是公众号:java小杰要加油,我们下期见

好文推荐

  • 多图慎入,从四层模型上解析网络是怎么连接的
  • 想和你聊聊操作系统的内存管理
  • 快来自定义一个属于你自己的java注解吧
  • 五千来字小作文,是的,我们是有个HTTP。
  • 京东面试官问我:“聊聊MySql事务,MVCC?”

本文转载自: 掘金

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

盘点 SpringIOC ApplicationCont

发表于 2021-05-01

总文档 :文章目录

Github : github.com/black-ant

一 . 前言

这一篇来详细看看 ApplicationContext 的整体逻辑 , ApplicationContext 整个逻辑中最重要的的一环 , 他具有以下的作用 :

  • 从ListableBeanFactory继承了用于访问应用程序组件的Bean工厂方法的能力
  • 从ResourceLoader接口继承了以通用方式加载文件资源的能力
  • 从ApplicationEventPublisher接口继承了向注册监听器发布事件的能力。
  • 从MessageSource接口继承了解析消息的能力,支持国际化。
  • 单个父上下文可以被整个web应用程序使用,而每个servlet有它自己的独立于任何其他servlet的子上下文。
  • 标准的BeanFactory生命周期功能之外
  • 支持检测和调用ApplicationContextAware以及ResourceLoaderAware , ApplicationEventPublisherAware和MessageSourceAware

看一下整体的逻辑
ApplicationModule.png

二 . ApplicationContext 的初始化

容器的初始化主要是 ApplicationContextInitializer , 我们先看一下他的家族体系

ApplicationContextInitializer.png

容器初始化的起点

ApplicationContextInitializer 内部只有一个方法

1
2
3
4
5
6
7
8
java复制代码I- ApplicationContextInitializer
M- void initialize(C applicationContext)


// 该方法有三个地方进行了调用
C- DelegatingApplicationContextInitializer # applyInitializers
C- ContextLoader # customizeContext
C- SpringApplication # applyInitializers

我们要看的主要 C- SpringApplication , 先看看主方法 , 其中run 方法中处理 Context 的有几个地方

1
2
3
4
5
6
7
8
java复制代码public ConfigurableApplicationContext run(String... args) {
// ..... 创建 Application Context
context = createApplicationContext();
// ..... 容器前置处理
prepareContext(context, environment, listeners, applicationArguments, printedBanner);
// ..... 容器刷新操作
refreshContext(context);
}

一个个看这几个节点

Step 1 : 创建容器

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复制代码C01- SpringApplication
M1_30- createApplicationContext
?- 具体可以参考 Application 主流程 , 这里主要是通过 webApplicationType 创建了三个不同 ApplicationContext
- AnnotationConfigServletWebServerApplicationContext
- AnnotationConfigReactiveWebServerApplicationContext
- AnnotationConfigApplicationContext

// M1_30 伪代码
protected ConfigurableApplicationContext createApplicationContext() {
Class<?> contextClass = this.applicationContextClass;
if (contextClass == null) {
// 根据 type 类型构建
switch (this.webApplicationType) {
case SERVLET:
contextClass = Class.forName(DEFAULT_SERVLET_WEB_CONTEXT_CLASS);
break;
case REACTIVE:
contextClass = Class.forName(DEFAULT_REACTIVE_WEB_CONTEXT_CLASS);
break;
default:
contextClass = Class.forName(DEFAULT_CONTEXT_CLASS);
}
}
return (ConfigurableApplicationContext) BeanUtils.instantiateClass(contextClass);
}

PS: 通常如果我们使用 SpringMVC , 创建的一般是 AnnotationConfigServletWebServerApplicationContext

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
JAVA复制代码C01- SpringApplication
M1_35- prepareContext : 完善容器
- postProcessApplicationContext : 对 Context 进行设置
- applyInitializers(context) 初始化容器属性
- 发布了一个 contextPrepared 监听事件
- 如果是懒加载 , 添加 BeanFactoryPostProcessor
- 发布了一个 contextLoaded 监听事件
M1_37- applyInitializers
FOR- 循环获取到的所有的 getInitializers , 执行 initialize
?- 这里分别执行了 DelegatingApplicationContextInitializer
M1_39- postProcessApplicationContext
- 设置 Context BeanFactory beanName 生成器
?- org.springframework.context.annotation.internalConfigurationBeanNameGenerator
- 设置 ResourceLoader
- 设置 ClassLoader
- 设置 Context BeanFactory ConversionService
?- ApplicationConversionService

伪代码 —->

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
java复制代码// M1_35 核心代码
private void prepareContext(ConfigurableApplicationContext context, ConfigurableEnvironment environment,
SpringApplicationRunListeners listeners, ApplicationArguments applicationArguments, Banner printedBanner) {
context.setEnvironment(environment);
postProcessApplicationContext(context);
// 核心操作 : 初始化容器属性
applyInitializers(context);
listeners.contextPrepared(context);
if (this.logStartupInfo) {
logStartupInfo(context.getParent() == null);
logStartupProfileInfo(context);
}
// Add boot specific singleton beans
ConfigurableListableBeanFactory beanFactory = context.getBeanFactory();
beanFactory.registerSingleton("springApplicationArguments", applicationArguments);
if (printedBanner != null) {
beanFactory.registerSingleton("springBootBanner", printedBanner);
}
if (beanFactory instanceof DefaultListableBeanFactory) {
((DefaultListableBeanFactory) beanFactory)
.setAllowBeanDefinitionOverriding(this.allowBeanDefinitionOverriding);
}
if (this.lazyInitialization) {
context.addBeanFactoryPostProcessor(new LazyInitializationBeanFactoryPostProcessor());
}
// Load the sources
Set<Object> sources = getAllSources();
Assert.notEmpty(sources, "Sources must not be empty");
load(context, sources.toArray(new Object[0]));
listeners.contextLoaded(context);
}

整个代码的核心就在于 applyInitializers :

1
2
3
4
5
6
7
8
java复制代码// M1_37 伪代码
protected void applyInitializers(ConfigurableApplicationContext context) {
// getInitializers -> PS:M1_37_01
for (ApplicationContextInitializer initializer : getInitializers()) {
// .... 省略断言操作 , 判断当前的类是否可以 initializer
initializer.initialize(context);
}
}

可以看到 , 这里 For 循环执行了很多 ApplicationContextInitializer 类 ,他们分别为容器配置了相关的属性

applicationInitializersBean.jpg

相关处理

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
java复制代码C07- DelegatingApplicationContextInitializer
?- 根据环境变量配置的context.initializer.classes 配置的 ApplicationContextInitializer 类们,交给它们进行初始化
I- ApplicationContextInitializer
I- Ordered
M7_01- initialize(ConfigurableApplicationContext context)
-> 调用 #getInitializerClasses(ConfigurableEnvironment env) 方法,
?- 获得环境变量配置的 ApplicationContextInitializer 集合们
-> 调用 #applyInitializerClasses方法,执行初始化
M- getInitializerClasses
-> 获取环境变量配置的属性
-> 拼装为数组 ,逗号分隔
M- applyInitializerClasses方法
P- ConfigurableApplicationContext context
P- List<Class<?>> initializerClasses
-> 遍历 initializerClasses , 创建对应的ApplicationContextInitializer , 加入 initializers
-> 执行 ApplicationContextInitializer 的初始化逻辑
-> 先排序 , 再执行
M- initialize(ConfigurableApplicationContext context) 方法
|- 获取环境变量配置的ApplicationContextInitializer 集合们
|- 如果非空 , 则进行初始化
M- getInitializerClasses(ConfigurableEnvironment env)
|- 获得环境变量配置的属性
|- 以逗号分隔 ,拼装成List -> classes
|- Return classes
M- getInitializerClasses(String className)
|- ClassUtils 获取全类名对应的类
M- applyInitializerClasses


C08- ContextIdApplicationContextInitializer : 负责生成 Spring 容器的编号
I- ApplicationContextInitializer
I- Ordered
M8_01- initialize(ConfigurableApplicationContext applicationContext)
-> 调用 #getContextId(ConfigurableApplicationContext applicationContext) 方法,
?- 获得(创建) ContextId 对象
-> 设置到 applicationContext.id 中
-> 注册到 contextId 到 Spring 容器中
M- getContextId(ConfigurableApplicationContext applicationContext)
|- 获取 ApplicationContext -> applicationContext.getParent();
B-> ApplicationContext 包含 ContextId 类
|- 直接返回 -> parent.getBean(ContextId.class).createChildId()
E-> 否则构建新 ContextId
|- new ContextId()->
P-getApplicationId(applicationContext.getEnvironment())


C09- ConfigurationWarningsApplicationContextInitializer : 用于检查配置,报告错误的配置
I- ApplicationContextInitializer
M9_01- initialize(ConfigurableApplicationContext context)
- 注册 ConfigurationWarningsPostProcessor 到 Spring 容器中

C10- RSocketPortInfoApplicationContextInitializer : socket 连接配置
M10_01- initialize(ConfigurableApplicationContext applicationContext)
- addApplicationListener : 添加了一个 Listener

C11- ServerPortInfoApplicationContextInitializer
?- 监听 EmbeddedServletContainerInitializedEvent 类型的事件,然后将内嵌的 Web 服务器使用的端口给设置到 ApplicationContext 中
I- ApplicationContextInitializer
I- ApplicationListener
M11_01- initialize
|- 将自身作为一个 ApplicationListener 监听器,添加到 Spring 容器中
M- onApplicationEvent
?- 当监听到 WebServerInitializedEvent 事件,进行触发
|- 获取属性名 -> local + name + port 拼接
|- 设置端口到 environment 的 PropertyName 中
M- getName(WebServerApplicationContext context)
|- Context.getServerNamespace()
M- setPortProperty(ApplicationContext context, String propertyName, int port)
|- 设置端口到enviroment 的 propertyName 中
|- 如果有父容器 ,则继续设置
M- setPortProperty(ConfigurableEnvironment , propertyName, port)
|- 获取 server.ports 属性对应的值
B- 如果source 为 null ,则创建MapPropertySource

C12- ConditionEvaluationReportLoggingListener
M12_01- initialize(ConfigurableApplicationContext applicationContext)
- 同样加入了一个 ConditionEvaluationReportListener

C13- SharedMetadataReaderFactoryContextInitializer
?- 它会创建一个用于在 ConfigurationClassPostProcessor 和 Spring Boot 间共享的 CachingMetadataReaderFactory Bean 对象
I- ApplicationContextInitializer
I- Ordered

额外操作 ComponentScanPackageCheck

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码C- ComponentScanPackageCheck
I- Check
M- getWarning
-> 调用 #getComponentScanningPackages(BeanDefinitionRegistry registry) 方法,
?- 获得要扫描的包
-> 扫描的包的集合
-> names = registry.getBeanDefinitionNames(); // 获得所有 BeanDefinition 的名字们
FOR -> names
-> 如果是 AnnotatedBeanDefinition , 如果有 @ComponentScan 注解,则添加到 packages 中
-> getProblematicPackages(Set<String> scannedPackages) 方法,获得要扫描的包中
-> 就是判断 scannedPackages 哪些在 PROBLEM_PACKAGES 中
-> 如果是在 PROBLEM_PACKAGES 中 ,添加到 problemPackage

三 . ApplicationContext 的业务逻辑

上面看完了 ApplicationContext 的初始化逻辑 , 下面看一下业务逻辑

SpringApplicationContextSystem.jpg

业务处理的核心在于类AbstractApplicationContext , SpringBoot 启动时 , 会在该类中处理部分 Context 的逻辑

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
java复制代码C50- AbstractApplicationContext
// 支持的ApplicationContext 功能
M- void setId(String id)
M- void setDisplayName(String displayName)
M- void setEnvironment(ConfigurableEnvironment environment)
M- ConfigurableEnvironment createEnvironment()
// 允许发布事件
M- void publishEvent(ApplicationEvent event)
M- void publishEvent(Object event)
M- void publishEvent(Object event, @Nullable ResolvableType eventType)
// 主要的get方法
M- ApplicationEventMulticaster getApplicationEventMulticaster()
M- LifecycleProcessor getLifecycleProcessor()
M- ResourcePatternResolver getResourcePatternResolver()
// 初始化操作
M50_20- void initMessageSource()
M50_21- void initApplicationEventMulticaster()
M50_22- void initLifecycleProcessor()
M50_23- void initApplicationEventMulticaster()
M50_24- void registerListeners()
M50_27- void initPropertySources()
// 主要操作
M- void resetCommonCaches()
M- void registerShutdownHook()
M- void destroy()
M- void close()
M50_25- void doClose()
M- void destroyBeans()
// 核心方法详解
M50_2- void refresh()
M50_10- void setParent(@Nullable ApplicationContext parent)
M50_11- void addBeanFactoryPostProcessor(BeanFactoryPostProcessor postProcessor)
M50_12- void addApplicationListener(ApplicationListener<?> listener)
M50_13- void prepareRefresh()
M50_14- void prepareBeanFactory(ConfigurableListableBeanFactory beanFactory)
M50_15- void invokeBeanFactoryPostProcessors(ConfigurableListableBeanFactory beanFactory)
M50_16- void finishBeanFactoryInitialization(ConfigurableListableBeanFactory beanFactory)
M50_17- void finishRefresh()
M50_18- void cancelRefresh(BeansException ex)
M50_30- void start()
M50_31- void stop()
M50_13- boolean isRunning()
M50_32- void postProcessBeanFactory()
M50_33- void registerBeanPostProcessors(ConfigurableListableBeanFactory beanFactory)
// 获取 Bean 操作
M- Object getBean(String name)
M- <T> T getBean(String name, Class<T> requiredType)
M- Object getBean(String name, Object... args)
M- <T> T getBean(Class<T> requiredType)
M- <T> T getBean(Class<T> requiredType, Object... args)
M- <T> ObjectProvider<T> getBeanProvider(Class<T> requiredType)
M- <T> ObjectProvider<T> getBeanProvider(ResolvableType requiredType)
M- <T> Map<String, T> getBeansOfType(@Nullable Class<T> type)
M- String[] getBeanNamesForType(ResolvableType type)
M- String[] getBeanDefinitionNames()
M- <A extends Annotation> A findAnnotationOnBean(String beanName, Class<A> annotationType)
M- Map<String, Object> getBeansWithAnnotation(Class<? extends Annotation> annotationType)
M- String[] getBeanNamesForAnnotation(Class<? extends Annotation> annotationType)
M- boolean containsLocalBean(String name)
// 判断 Bean
M- boolean containsBean(String name)
M- boolean isSingleton(String name)
M- boolean isPrototype(String name)
M- boolean isTypeMatch(String name, ResolvableType typeToMatch)
M- boolean containsBeanDefinition(String beanName)
// 其他操作
M- String getMessage(....)
M- MessageSource getMessageSource()

我们依次来看一下相关的方法功能 :

Module 1 : 标识属性的作用 , 我们常用的属性通常Id 和 Environment

1
2
3
4
5
6
7
8
java复制代码M- void setId(String id)
?- ContextIdApplicationContextInitializer # initialize 中进行设置
?- 该 ID 会被用于设置 beanFactory SerializationId
M- void setDisplayName(String displayName)
M- void setEnvironment(ConfigurableEnvironment environment)
?- SpringApplication # prepareContext 中进行设置
M- ConfigurableEnvironment createEnvironment()
?- PS : AnnotatedBeanDefinitionReader getOrCreateEnvironment 中会直接创建

Module 2 : 事件的发布

1
2
3
4
5
6
7
8
java复制代码M- void publishEvent(ApplicationEvent event)
M- void publishEvent(Object event)
M- void publishEvent(Object event, @Nullable ResolvableType eventType)
?- 上面2个最终都会调用该方法
- 传入的 event 为 ApplicationEvent 则直接使用 , 不是则转换为 PayloadApplicationEvent
- 如果开启了 earlyApplicationEvents , 则加入 ,否则通过 ApplicationEventMulticaster 发布
?- ApplicationEventMulticaster用来通知所有的观察者对象,属于观察者设计模式中的Subject对象
- 父容器不为空 , 则parent.publishEvent

Module 3 : 主要的get方法

1
2
3
4
5
java复制代码M- ApplicationEventMulticaster getApplicationEventMulticaster() : 获取多播器
M- LifecycleProcessor getLifecycleProcessor() : 返回上下文使用的内部LifecycleProcessor
?- LifecycleProcessor : 用于在ApplicationContext中处理生命周期bean的策略接口
M- ResourcePatternResolver getResourcePatternResolver()
?- 用于将 location (例如 ant location)解析为资源对象的策略接口

Module 4 : 初始化操作

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复制代码M50_20- void initMessageSource() : 初始化MessageSource
- 获取 ConfigurableListableBeanFactory , 判断其中是否包含 messageSource
TRUE- 获取 MessageSource , 并且设置 ParentMessageSource
?- hms.setParentMessageSource(getInternalParentMessageSource())
FALSE- 创建一个 DelegatingMessageSource , 设置 ParentMessageSource
- 将当前 MessageSource 设置到 beanFactory 中
?- beanFactory.registerSingleton(MESSAGE_SOURCE_BEAN_NAME, this.messageSource)

M- void initApplicationEventMulticaster() : 初始化ApplicationEventMulticaster
?- ApplicationEventMulticaster : 管理许多ApplicationListener对象并向它们发布事件
?- 如果上下文中没有定义,则使用SimpleApplicationEventMulticaster
- 获取 ConfigurableListableBeanFactory , 判断是否包含 applicationEventMulticaster
TRUE- 包含则从 beanFactory 中获取
FALSE- 构建一个 SimpleApplicationEventMulticaster , 并且 registerSingleton 注册

M- void initLifecycleProcessor()
- 同理 , 从 ConfigurableListableBeanFactory 中判断是否存在 lifecycleProcessor
- 存在则获取 , 不存在则创建一个 DefaultLifecycleProcessor

M- void initApplicationEventMulticaster()
- 同理 , 从 ConfigurableListableBeanFactory 中判断是否存在 applicationEventMulticaster
- 存在则获取 , 不存在则创建一个 SimpleApplicationEventMulticaster

M50_24- void registerListeners()
- 获取 当前 Context Set<ApplicationListener<?>> , 添加到 ApplicationEventMulticaster 中
- 通过 ApplicationListener 类型获取( getBeanNamesForType) , 添加到 ApplicationEventMulticaster 中
- 选择合适的 listener 处理当前 earlyApplicationEvents

Module 5 : 主要操作

1
2
3
4
5
6
7
8
9
java复制代码M- void resetCommonCaches() : 重新清空缓存
?- 包括 ReflectionUtils / AnnotationUtils / ResolvableType / CachedIntrospectionResults
M- void registerShutdownHook()
M- void destroy()
- 调用 close
M- void close()
- 调用 doClose
M50_25 - void doClose() :执行最终的逻辑
M- void destroyBeans()

M50_25 伪代码

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复制代码protected void doClose() {
// Check whether an actual close attempt is necessary...
if (this.active.get() && this.closed.compareAndSet(false, true)) {
LiveBeansView.unregisterApplicationContext(this);

publishEvent(new ContextClosedEvent(this));
// 停止所有Lifecycle bean,以避免单个销毁期间的延迟
if (this.lifecycleProcessor != null) {
this.lifecycleProcessor.onClose();
}

// 销毁上下文BeanFactory中所有缓存的单例
destroyBeans();

// 关闭此上下文本身的状态.
closeBeanFactory();

// 如果子类愿意的话,让它们做一些最后的清理工作
onClose();

// 将本地应用程序监听器重置为预刷新状态.
if (this.earlyApplicationListeners != null) {
this.applicationListeners.clear();
this.applicationListeners.addAll(this.earlyApplicationListeners);
}

// 切换 Active 状态.
this.active.set(false);
}
}

最重要的核心逻辑系列

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复制代码// 核心方法详解
M50_2- void refresh() : 刷新容器主流程
- 调用 prepareRefresh -> M50_13
- 获取 ConfigurableListableBeanFactory , 并且调用 prepareBeanFactory 进行前置处理 -> M50_14
- 调用 postProcessBeanFactory -> M50_32
- 调用 registerBeanPostProcessors -> M50_33
- 调用 initMessageSource -> M50_20
- 调用 initApplicationEventMulticaster -> M50_21
- 调用 onRefresh -> 该方法需要子类实现
- 调用 registerListeners -> M50_24
- 调用 finishBeanFactoryInitialization 完成 BeanFactory 初始化流程 -> M50_16
- 调用 finishRefresh -> finishRefresh M50_17
M50_10- void setParent(@Nullable ApplicationContext parent) : 设置父容器
M50_11- void addBeanFactoryPostProcessor(BeanFactoryPostProcessor postProcessor)
M50_12- void addApplicationListener(ApplicationListener<?> listener) : 添加事件监听器
M50_13- void prepareRefresh() : 前置处理
- 前置处理 , 首先会设置状态 : close active
- 调用 initPropertySources (空实现)
- 校验 enviroment validateRequiredProperties
- 如果 earlyApplicationListeners 为空 , 则将当前 applicationListeners 全部加入
- 如果 earlyApplicationListeners 存在 , 则将 applicationListeners 替换为 earlyApplicationListeners 的监听器
- 重置 earlyApplicationEvents
M50_14- void prepareBeanFactory(ConfigurableListableBeanFactory beanFactory)
- 为当前 beanFactory 设置各类属性
M50_15- void invokeBeanFactoryPostProcessors(ConfigurableListableBeanFactory beanFactory)
?- 方法中做了2类事
- 调用 PostProcessorRegistrationDelegate.invokeBeanFactoryPostProcessors
- 如果存在 Bean , 为beanFactory 添加 BeanPostProcessor + TempClassLoader
M50_16- void finishBeanFactoryInitialization(ConfigurableListableBeanFactory beanFactory)
- 如果存在 ConversionService , 则为 BeanFactory 设置 ConversionService
- 视情况添加 EmbeddedValueResolver
- 尽早始化LoadTimeWeaverAware bean,以允许尽早注册它们的转换器
?- 通过触发 getBean(weaverAwareName) 获取
- 停止使用临时ClassLoader进行类型匹配
- 允许缓存所有bean定义元数据
- 实例化所有剩余的(非lazy-init)单例
M50_17- void finishRefresh()
- 清除上下文级资源缓存(例如扫描ASM元数据)
- 初始化此上下文的生命周期处理器
- 首先将刷新传播到生命周期处理器
- 发布最终事件
- 将当前 ApplicationContext 注册到 LiveBeansView
M50_18- void cancelRefresh(BeansException ex)
- 修改状态 -> this.active.set(false
M50_30- void start()
- 启动生命周期管理 getLifecycleProcessor().start();
- 发布一个 publishEvent 事件 ContextStartedEvent
M50_31- void stop()
- 启动生命周期管理 getLifecycleProcessor().stop()
- 发布事件 ContextStoppedEvent
M50_13- boolean isRunning()
- 判断 lifecycleProcessor.isRunning()
M50_32- void postProcessBeanFactory()
- 空实现 , 在应用程序上下文的标准初始化之后修改其内部bean工厂
- 该方法会在子类中处理
M50_33- void registerBeanPostProcessors(ConfigurableListableBeanFactory beanFactory)
- 调用 PostProcessorRegistrationDelegate # registerBeanPostProcessors 方法
?->
M50_27- void initPropertySources()

M50_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
java复制代码    @Override
public void refresh() throws BeansException, IllegalStateException {
synchronized (this.startupShutdownMonitor) {
// 准备此上下文以进行刷新 -> M50_13
prepareRefresh();

// Tell the subclass to refresh the internal bean factory.
ConfigurableListableBeanFactory beanFactory = obtainFreshBeanFactory();

// 告诉子类刷新内部bean工厂
prepareBeanFactory(beanFactory);

try {
// 允许在上下文子类中对bean工厂进行后处理
postProcessBeanFactory(beanFactory);

// 调用在上下文中注册为bean的工厂处理器
// IOC 的主要逻辑就在其中
invokeBeanFactoryPostProcessors(beanFactory);

// 注册拦截Bean创建的Bean处理器
registerBeanPostProcessors(beanFactory);

// 初始化此上下文的消息源
initMessageSource();

// 初始化此上下文的事件多主控器.
initApplicationEventMulticaster();

// 初始化特定上下文子类中的其他特殊bean.
onRefresh();

// 检查侦听器bean并注册它们.
registerListeners();

// 实例化所有剩余的(非lazy-init)单例.
finishBeanFactoryInitialization(beanFactory);

//最后一步:发布相应的事件
finishRefresh();
}catch (BeansException ex) {

// 销毁已创建的单件以避免资源悬空
destroyBeans();

// 重置“活动”标志.
cancelRefresh(ex);

// 将异常传播到调用方.
throw ex;
}

finally {
// Reset common introspection caches in Spring's core, since we
// might not ever need metadata for singleton beans anymore...
resetCommonCaches();
}
}
}

BeanPostProcessors 流程比较多 , 足够开个单章了,这里就不说了

作用 : 允许对应用程序上下文的bean定义进行自定义修改 , 可以调整上下文的基础bean工厂的bean属性值

特点 : BeanFactoryPostProcessor可以与bean定义交互并修改它们,但绝不会与bean实例交互

此处主要调用 ConfigurationClassPostProcessor , 其中处理 processConfigBeanDefinitions

1
2
3
4
5
java复制代码C52- PostProcessorRegistrationDelegate
M52_01- invokeBeanFactoryPostProcessors
- 调用给定的BeanFactoryPostProcessor bean
M52_02- registerBeanPostProcessors
- 注册给定的BeanPostProcessor bean

postprocess.jpg

registerBeanPostProcessors

1
2
3
4
5
6
java复制代码
private static void registerBeanPostProcessors(ConfigurableListableBeanFactory beanFactory, List<BeanPostProcessor> postProcessors) {
for (BeanPostProcessor postProcessor : postProcessors) {
beanFactory.addBeanPostProcessor(postProcessor);
}
}

image-20210501175658179.png

DefaultLifecycleProcessor 流程

1
2
3
4
5
6
7
8
9
10
11
java复制代码


C53- DefaultLifecycleProcessor
?- 用于在ApplicationContext中处理生命周期bean的策略接口
M53_01- startBeans
-
M53_02- stopBeans
M53_03- doStart
M53_04- doStop
M53_05- getLifecycleBeans() : 检索所有适用的生命周期bean
  • Lifecycle 接口 :
    • 定义启动/停止生命周期控制方法的公共接口
    • 可以是 Bean 和容器
    • 当ApplicationContext自身启动和停止时,它将自动调用上下文内所有生命周期的实现
  • LifecycleProcessor 接口 :
    • LifecycleProcessor 负责管理ApplicationContext生命周期
    • LifecycleProcessor自身扩展了Lifecycle接口。它也增加了两个其他的方法来与上下文交互,使得可以刷新和关闭
    • LifecycleProcessor的onRefresh与onClose是比较重要的方法
      • onRefresh作用是容器启动成功
      • onClose是只应用要关闭的时候
  • DefaultLifecycleProcessor
    • 默认LifecycleProcessor实现,主要是负责所有的LifecycleProcessor实现执行
    • DefaultLifecycleProcessor是LifecycleProcessor的代理对象。

M50_14 : 伪代码

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复制代码protected void prepareBeanFactory(ConfigurableListableBeanFactory beanFactory) {
// Tell the internal bean factory to use the context's class loader etc.
beanFactory.setBeanClassLoader(getClassLoader());
beanFactory.setBeanExpressionResolver(new StandardBeanExpressionResolver(beanFactory.getBeanClassLoader()));
beanFactory.addPropertyEditorRegistrar(new ResourceEditorRegistrar(this, getEnvironment()));

// Configure the bean factory with context callbacks.
beanFactory.addBeanPostProcessor(new ApplicationContextAwareProcessor(this));
beanFactory.ignoreDependencyInterface(EnvironmentAware.class);
beanFactory.ignoreDependencyInterface(EmbeddedValueResolverAware.class);
beanFactory.ignoreDependencyInterface(ResourceLoaderAware.class);
beanFactory.ignoreDependencyInterface(ApplicationEventPublisherAware.class);
beanFactory.ignoreDependencyInterface(MessageSourceAware.class);
beanFactory.ignoreDependencyInterface(ApplicationContextAware.class);

// BeanFactory interface not registered as resolvable type in a plain factory.
// MessageSource registered (and found for autowiring) as a bean.
beanFactory.registerResolvableDependency(BeanFactory.class, beanFactory);
beanFactory.registerResolvableDependency(ResourceLoader.class, this);
beanFactory.registerResolvableDependency(ApplicationEventPublisher.class, this);
beanFactory.registerResolvableDependency(ApplicationContext.class, this);

// Register early post-processor for detecting inner beans as ApplicationListeners.
beanFactory.addBeanPostProcessor(new ApplicationListenerDetector(this));

// Detect a LoadTimeWeaver and prepare for weaving, if found.
if (beanFactory.containsBean(LOAD_TIME_WEAVER_BEAN_NAME)) {
beanFactory.addBeanPostProcessor(new LoadTimeWeaverAwareProcessor(beanFactory));
// Set a temporary ClassLoader for type matching.
beanFactory.setTempClassLoader(new ContextTypeMatchClassLoader(beanFactory.getBeanClassLoader()));
}

// Register default environment beans.
if (!beanFactory.containsLocalBean(ENVIRONMENT_BEAN_NAME)) {
beanFactory.registerSingleton(ENVIRONMENT_BEAN_NAME, getEnvironment());
}
if (!beanFactory.containsLocalBean(SYSTEM_PROPERTIES_BEAN_NAME)) {
beanFactory.registerSingleton(SYSTEM_PROPERTIES_BEAN_NAME, getEnvironment().getSystemProperties());
}
if (!beanFactory.containsLocalBean(SYSTEM_ENVIRONMENT_BEAN_NAME)) {
beanFactory.registerSingleton(SYSTEM_ENVIRONMENT_BEAN_NAME, getEnvironment().getSystemEnvironment());
}
}

M50_16 finishBeanFactoryInitialization

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复制代码M50_16- void finishBeanFactoryInitialization(ConfigurableListableBeanFactory beanFactory)
-
- 视情况添加 EmbeddedValueResolver
- 尽早始化LoadTimeWeaverAware bean,以允许尽早注册它们的转换器
?- 通过触发 getBean(weaverAwareName) 获取
- 停止使用临时ClassLoader进行类型匹配
- 允许缓存所有bean定义元数据
- 实例化所有剩余的(非lazy-init)单例


protected void finishBeanFactoryInitialization(ConfigurableListableBeanFactory beanFactory) {
// 如果存在 ConversionService , 则为 BeanFactory 设置 ConversionService
if (beanFactory.containsBean(CONVERSION_SERVICE_BEAN_NAME) &&
beanFactory.isTypeMatch(CONVERSION_SERVICE_BEAN_NAME, ConversionService.class)) {
beanFactory.setConversionService(
beanFactory.getBean(CONVERSION_SERVICE_BEAN_NAME, ConversionService.class));
}

// 视情况添加 EmbeddedValueResolver
if (!beanFactory.hasEmbeddedValueResolver()) {
beanFactory.addEmbeddedValueResolver(strVal -> getEnvironment().resolvePlaceholders(strVal));
}

// 尽早始化LoadTimeWeaverAware bean,以允许尽早注册它们的转换器
String[] weaverAwareNames = beanFactory.getBeanNamesForType(LoadTimeWeaverAware.class, false, false);
for (String weaverAwareName : weaverAwareNames) {
// 通过触发 getBean(weaverAwareName) 获取
getBean(weaverAwareName);
}

// 停止使用临时ClassLoader进行类型匹配
beanFactory.setTempClassLoader(null);

// 允许缓存所有bean定义元数据
beanFactory.freezeConfiguration();

// 实例化所有剩余的(非lazy-init)单例
beanFactory.preInstantiateSingletons();
}

总结

整个 ApplicationContext 流程就完成了 , 这个流程完结后 , 就可以开始 IOC Bean 的加载流程了.

写着真累 , 具体逻辑看图应该就差不多了

更新记录

  • V20210803 : 优化文本错位问题

本文转载自: 掘金

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

MySQL分表时机:100w?300w?500w?都对也都不

发表于 2021-05-01

导读

以交友平台用户中心的user表为例,单表数据规模达到千万级别时,你可能会发现使用用户筛选功能查询用户变得非常非常慢,明明查询命中了索引,但是,部分查询还是很慢,这时候,我们就需要考虑拆分这张user表了。

如果此时,我们才去做分表,可能已经太晚了,为什么呢?

我以最典型的应用场景:用户筛选功能,以查询年龄在18到24岁的100位女性用户为例:

在单表的情况下,我们的SQL是这么写的:

1
sql复制代码SELECT * FROM user WHERE age >= 18 AND age <= 24 AND sex = 0 LIMIT 100

但是,拆分user表后,用户记录分散到了多张表,比如,分散到user_1,user_2,user_3这三张表,此时,要查询满足上面条件的用户,我们的查询过程就变成这样:

  1. 遍历user_1到user_3这三张表
  2. 分别从三张表找出满足条件的用户,即执行上面的SQL
  3. 合并这些用户记录
  4. 从合并结果中过滤出前100名用户记录

通过对比,我们会发现分表后的查询过程跟单表相比,变化是比较大的,这势必导致我们不得不修改代码,如果系统内类似的情况很多,那么,可能引发系统较大规模的业务逻辑改动,所以,在系统真正出现数据库性能瓶颈前,必须提前规划分表方案,预留时间去做系统改造。

那么,问题来了,我们到底在单表数据规模达到多少时,做分表是最合适的呢?

在开头我提到分表的原因是因为单表数据规模太大,导致系统功能使用越来越慢,而影响数据库查询性能的因素很多,有并发连接线程数、磁盘IO,锁等等。但是,一条查询语句如果需要通过磁盘IO来获得查询结果,那么,无论是否存在数据库的并发查询请求,磁盘IO的性能瓶颈都会存在。而连接线程和锁导致的的性能问题,一般只有在高并发的场景下才会出现。所以,减少数据查询的磁盘IO,是我们在优化数据库查询性能时,最先需要考虑的。

那么,MySQL又是通过什么方法来减少数据查询的磁盘IO的呢?我们来看下面这张图:

image-20210128202928887.png

这是很典型的应用请求MySQL的示意图,从图中,我们很容易发现,MySQL为了避免查询时都从磁盘读取查询结果,所以,在磁盘和应用之间加了一层内存,尽可能将磁盘数据加载到内存,那么,下次查询请求访问MySQL时,可以从内存中获取查询结果,避免了过多的磁盘IO的读取。

所以,通过MySQL对磁盘IO的优化方案,我们可以看出,只要把表中大部分数据缓存在内存中,那么,数据库的查询性能可以大大提升。结合user表来看,只要user表的数据规模可以保证大多数的数据可以加载到内存,那么,就不需要对user表拆分,反之,则需要拆分。

既然MySQL内存的大小决定了表何时拆分,那么,我们就先来看一下MySQL的内存结构吧!

内存管理

MySQL的内存结构:
image-20210126223939325.png
整个MySQL的内存主要分为3部分:

Thread Memory:这部分内存空间是每个连接线程独享的,也就是说每个连接自身独立拥有自己的内存空间。连接释放时,内存就释放。所以,它是动态的。

Sharing:这部分是所有连接线程共享的内存空间。

InnoDB Buffer Pool:这部分就是InnoDB引擎层维护的一块内存空间,它也是共享给每个连接线程的。它是相对静态的内存,不会随连接的释放而释放。

其中,Thread Memory和Sharing属于MySQL Server层的内存空间,InnoDB Buffer Pool属于MySQL InnoDB层的内存空间。

下面我再简单介绍一下上面3部分内存空间具体包含哪些部分:

Thread Memory

thread stack(线程栈):主要用来存放每一个线程自身的标识信息,如线程id,线程运行时基本信息等等。

sort_buffer:MySQL使用该内存区域进行记录排序。排序相关的内容,我会在《如何让我的大表排序更快?》详 细讲解。

join_buffer:在连表查询时,MySQL会使用该内存区来协助完成 Join操作。我会在《Join查询的极致优化》详细 讲解。

read_buffer:当查询无法使用索引时,需要全表扫描或全索引扫描来读取记录,那么,这时候,MySQL按照记录 的存储顺序依次读取数据页,每次读取的数据页首先会暂存在read_buffer中,该buffer写满或记录 读取完,就会将结果返回给上层调用。

read_rnd_buffer:和上面的顺序读取相对应,当 MySQL 进行非顺序读取(随机读取)数据页的时候,会利用这 个缓冲区暂存读取的数据。

net_buffer:这部分用来存放客户端连接线程的连接信息。

bulk_insert_buffer:当我们执行批量插入时,会使用该内存空间收集批量插入的记录,当该内存写满时,将该内 存中的记录写入数据文件。

tmp_table:临时表使用的内存空间。

Sharing

Key Buffer:MyISAM 索引缓存使用的内存空间。

Thread Cache:MySQL 为了减少连接线程的创建,将部分空闲的连接线程缓存在该内存区域,给后续连接使用。

InnoDB Log Buffer:这是 InnoDB 存储引擎的事务日志所使用的缓冲区。

Query Cache:缓存查询结果集的内存空间。

Table Cache:用来缓存表文件的文件句柄信息。

BinLog Buffer:用来缓存binlog的信息。

Table Definition Cache:用来缓存表定义信息。

InnoDB Additional Memory Pool:用来缓存InnoDB存储引擎internal 的共享数据结构信息。

InnoDB Buffer Pool

Index Page/Data Page:用来缓存InnoDB索引树的节点,包括非叶子节点的Index Page和叶子节点的Data Page。

Lock:用来缓存InnoDB索引树锁、AHI锁、数据字典锁等锁信息。

Dictionary:用来缓存InnoDB数据字典信息。

AHI:用来缓存InnoDB AHI结构相关信息,其中,AHI在《有没有比B-Tree更快的查询数据的数据结构?》这篇中 有详细讲解过。

Change Buffer:用来存储change buffer信息。其中,change buffer在《为什么不建议使用唯一索引?》这篇中 有讲解过。

LRU List/Free List/Flush List:InnoDB管理和维护索引树节点使用的几个链表,即使用这3个链表维护节点的增删 改查。

通过上面MySQL内存结构的讲解,我们得出2点:

  1. Thread Memory是连接线程独享的内存空间。
  2. Sharing和InnoDB Buffer Pool是连接线程共享的内存空间。

我们先来看下线程独享的内存空间Thread Memory是如何分配和释放的?

Linux内存结构

由于大多数情况,我们会把MySQL安装在Linux系统下,所以,MySQL连接线程独享的内存空间对Linux而言,就是Linux内存空间,所以,这里,我先讲解一下Linux中的内存结构是怎么样的?然后,再看一下它的分配和释放过程。

W311.png

上图为Linux系统分别在32位和64位情况下的内存结构。

32位

内核空间:从0xC0000000 ~ 0xFFFFFFF为内核空间,大小为1G,只有Linux系统自身可以访问,用户进程不能访问。

用户空间:从0x0 ~ 0xC0000000,大小为3G,Linux系统自身和用户进程都可以访问。

64位

内核空间:从0xFFF8000000000000 ~ 0xFFFFFFFFFFFF为内核空间,大小为128T,只有Linux系统自身可以访问,用户进程不能访问。

用户空间:从0x0 ~ 0x00007FFFFFFFF000,大小也为128T,Linux系统自身和用户进程都可以访问。

未定义:从0x00007FFFFFFFF000 ~ 0xFFF8000000000000,Linux未定义的空间。

用户空间

由于用户空间是我们进程使用的内存区,对MySQL而言,就是MySQL进程可以访问并控制的内存区域,所以,我们再详细看一下用户空间的内存结构:

W310.png

上图为Linux用户空间(用户态)的内存结构,叫做虚拟内存,它包括以下几部分:

栈:包括局部变量和函数调用的上下文、调用返回地址等。

文件映射:包括动态库、共享内存等,从高地址开始向下增长。

堆:包括动态分配的内存,从低地址开始向上增长。

数据段:包括全局变量等。

只读段:包括代码和常量等。

内存分配

理解了用户空间内存的概念,我们再结合用户空间的概念,来看一下MySQL进程是如何分配和释放用户空间内存的?

MySQL使用C标准库的malloc()在堆动态分配内存,使用mmap()在文件映射段动态分配内存。详细过程如下图:
image-20210128234256178.png

上图为MySQL分配内存的过程,主要分Server层和InnoDB层两部分的内存分配。

通过上图,我们发现MySQL在Server层是通过malloc来分配内存的,而InnoDB层是通过mmap来分配内存的。

图中,我们从上往下看:

  1. MySQL Server层调用C语言的malloc函数申请分配内存
  2. malloc调用内存分配器从用户态向Linux内核申请内存,为什么有个内存分配器,这是什么?我们先来看一张图:
    image-20210128234601866.png

这张图是malloc函数直接调用系统函数申请内存的过程,我们发现malloc通过brk和mmap这两个Linux系统函数从用户态向内核申请内存。这两个系统函数是干什么的呢?

brk

image-20210128235723307.png

当申请内存大小小于MMAP_THRESHOLD这个内核参数配置的大小(默认128K)时,Linux系统使用brk来分配内存,上图展示了brk分配内存的过程,从上到下,假设内存总大小为50 + 20 + 20 = 90M:

  1. 进程1申请分配了50M堆内存
  2. 进程1执行结束,释放50M堆内存,如上图,50M内存区域变为虚线
  3. 进程2申请分配了20M堆内存,如上图,在50M堆内存右边又分配了20M
  4. 进程2执行结束,释放20M堆内存,如上图,中间20M内存区域变虚线
  5. 进程3申请分配了20M堆内存,如上图,在中间20M堆内存右边又分配了20M

通过brk分配内存的过程,我们发现,这些分配的堆内存释放后并不会立刻归还系统。所以,内存工作繁忙时,频繁的内存分配和释放会造成内存碎片。

mmap

image-20210128220606454.png

当申请内存大小大于MMAP_THRESHOLD这个内核参数配置的大小(默认128K)时,Linux使用mmap分配内存,上图展示了mmap分配内存的过程,从上到下,假设内存总大小为50 + 20 + 20 = 90M:

  1. 进程1申请分配了50M文件映射段的内存
  2. 进程1执行结束,释放50M文件映射段的内存,如上图,50M内存区域变虚线
  3. 进程2申请分配了20M文件映射段的内存,如上图,在原来50M内存区域内又分配了20M
  4. 进程2执行结束,释放20M文件映射段的内存,如上图,最左边20M内存区域变虚线
  5. 进程3申请分配了40M堆内存,如上图,在原来50M内存区域内又分配了40M内存,剩下10M仍是释放状态,为虚线

通过mmap分配内存的过程,我们发现mmap方式释放内存后会将内存及时归还给系统,避免 OOM。但是频繁的内存分配会导致大量的缺页异常,使内核的管理负担增大。这也是 malloc 只对大块内存使用 mmap 的原因。欧!!这里冒出一个新名词,缺页异常?别着急,我会在后面讲解。

通过malloc调用系统函数申请内存分配的过程,我们发现调用brk函数分配的内存在释放时不会归还给Linux系统,所以,导致了内存碎片,而过多的内存碎片会造成内存利用率下降。所以,Linux引入了一个内存分配器,用来管理和维护这些内存碎片,将碎片内存连接起来,提升内存的利用率。

内存分配器

内存分配器处在用户进程和内核态的内存之间,其采用内存池来管理和维护内存空间,它响应用户的分配请求,向Linux内核申请内存,然后将其返回给用户程序。

目前主流的内存分配器主要有三种:ptmalloc、tcmalloc和jemalloc。

关于内存分配器以及管理内存的策略和算法,将来在《Linux内核深度解读》新专题中,我会详细讲解。

缺页异常

在《mmap》这部分中,我提到了缺页异常,那么,什么是缺页异常呢?

在讲解缺页异常之前,我们先看一下《内存分配》这部分中的第一张图底部的虚拟内存管理器,通过讲解它的工作原理,我们慢慢理解什么是缺页异常。

其实,虚拟内存管理器里面包含了许多组件,通过这些组件,虚拟内存管理器管理和维护进程用户态申请的内存和物理内存的关系。ps:用户态申请的内存,我们一般叫它虚拟内存。

整个虚拟内存管理器包含的组件有:

MMU:全称内存管理单元,它的作用是接收一个虚拟内存地址,将其转换为一个物理内存地址,然后,输出这个物理地址

Page Table:页表,Linux内核通过页表来维护虚拟内存地址和物理内存地址的映射关系,表中的每一条映射关系又叫做Page Table Entry,即页表项,缩写PTE,页表项地址缩写PTEA

讲完这些名词,我们再来看一下这张图:

image-20210128234749564.png

上图为一个虚拟内存管理器的工作原理,其中,VA全称虚拟地址,即虚拟内存的地址:

  1. 处理器生成一个虚拟地址,并把它传送给 MMU
  2. MMU 根据虚拟地址生成 VPN(虚拟页号,因为CPU与内存交互以页为单位),然后请求内存,获取 PTE 的数据。
  3. 内存向 MMU 返回 PTE 的数据
  4. 由于判断出 PTE 的有效位是 0,即内存中没有虚拟页号对应的物理页,所以 CPU 将出发一次异常中断,将控制权转移给内核中的缺页异常处理程序。
  5. 缺页异常处理程序确定出物理内存中的牺牲页,如果这个页面被修改过了(D 标志位为 1),那么将牺牲页换出到磁盘。
  6. 缺页处理程序从磁盘中调入新的页面到内存中,并且更新 PTE
  7. 缺页处理程序将控制权返回给原来的进程,再次执行导致缺页的指令。再次执行后,就会产生页命中时的情况了。

所以,mmap过程中讲到的缺页异常就是上面过程中所提到的缺页异常。

最后,通过上面Linux内存分配的各部分细节的讲解,我们再来回顾一下《内存分配》部分开头那张图:

image-20210128234334536.png

server层

Server层内存分配的过程,见上图:

  1. MySQL调用系统函数malloc去申请内存
  2. malloc调用内存分配器向Linux内核申请内存,减少brk分配内存后产生的碎片
  3. 内存分配器调用系统函数brk或mmap向Linux内核申请内存

(1) 当申请内存大小小于MMAP_THRESHOLD这个内核参数配置的大小(默认128K)时,使用brk分配内存

(2) 当申请内存大小大于MMAP_THRESHOLD这个内核参数配置的大小(默认128K)时,使用mmap分配内存

当出现缺页时,Linux内核使用虚拟内存管理器的几个组件处理缺页异常

InnoDB层

如上图,InnoDB层采用Free、LRU和Flush List三个链表来管理InnoDB引擎相关的内存,也就是管理InnoDB Buffer Pool。

见上图,其中,MySQL在给InnoDB Buffer Pool申请内存时,直接调用系统函数mmap来完成内存的申请,这是由于InnoDB Buffer Pool缓存中的数据包含索引树、Change Buffer等等,这些都是大结构的数据,所以,MySQL不希望这些数据长时间占用内存,导致潜在的系统内存溢出的风险。

小结

通过MySQL底层内存分配和释放的详细分析,我们知道了MySQL在不同的内存结构中,使用了完全不同的内存分配和释放策略:

  1. Server层,即Thread Memory和Sharing:使用malloc申请并分配内存
  2. InnoDB层:即InnoDB Buffer Pool:使用mmap申请并分配内存,并使用Free、LRU和Flush List三个链表来维护内存

回到标题的问题:单表数据规模达到多大时进行分表最佳?

我们表数据(包含索引和记录)属于相对静态的数据,不随连接线程的释放而发生变化,结合MySQL的内存结构及分配和释放的过程,我们发现跟这些数据直接相关的内存区域就是InnoDB Buffer Pool,所以,我们只要看这个pool大小,来决定单表数据规模达到多大进行分表。即如果单表数据规模大小超过

InnoDB Buffer Pool的大小,就需要进行分表了。

InnoDB Buffer Pool的大小可以通过innodb_buffer_pool_size参数得到。

本文转载自: 掘金

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

记录一下实战用的Java策略模式

发表于 2021-05-01

前言

  • 策略模式是为了减少主业务逻辑else if的代码量。
  • 当我们用策略模式的时候,要把else if的关系用另一种方式体现出来,以后如果要增加else if的关系,只需要增加策略模式的对应关系就可以。
  • 优点: 可以减少主业务逻辑的代码量,便于维护

一、接口方式

1.1 接口定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码public interface Adapter {
/**
* 方法1
*
* @param actModel 参数
*/
void method1(Object param);

/**
* 方法2
*
* @param param 参数
*/
void method2(Object param);
}

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
java复制代码/**
* 实现类1
*/
@Service
public class AdapterImplOne implements Adapter {
/**
* 方法1
*
* @param actModel 参数
*/
public void method1(Object param) {
// AdapterImplOne方法1具体实现
// ...
}
/**
* 方法2
*
* @param actModel 参数
*/
public void method2(Object param) {
// AdapterImplOne方法2具体实现
// ...
}
}

/**
* 实现类2
*/
@service
public class AdapterImplTwo implements Adapter {
/**
* 方法1
*
* @param actModel 参数
*/
public void method1(Object param) {
// AdapterImplTwo方法1具体实现
// ...
}
/**
* 方法2
*
* @param actModel 参数
*/
public void method2(Object param) {
// AdapterImplOne方法2具体实现
// ...
}
}

1.3 找到不同实现类之间的对应关系

接口模式的对应关系,我一般会用HashMap。这样处理数据时,把数据else if的条件作为key,实现类.class作为value,维护一个HashMap。调用时,不同的数据就可以找到对应的实现类

1.3.1 首先维护一个枚举

else if的关系可以存入到枚举里,便于维护

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java复制代码@Getter
public enum ActTemplateIdEnum {
/**
* 实现模板1
*/
IMPL_ONE(1),
/**
* 实现模板2
*/
IMPL_TWO(2),
;
private final Integer id;

ActTemplateIdEnum(Integer id) {
this.id = id;
}
}

1.3.2 维护HashMap

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
java复制代码public class ActExecutor {

private final ApplicationContext applicationContext;

@Autowired
public ActExecutor(ApplicationContext applicationContext) {
this.applicationContext = applicationContext;
}

/**
* 活动实现类Map
* key: actTemplate 主键
* Value: ActAdapter 实现类
*/
private static Map<Integer, ActAdapter> actAdapterMap;

// spring加载完上下文之后,就可以把实现类作为bean注入进去
@PostConstruct
public void init() {
actAdapterMap = new HashMap<>(2);
actAdapterMap.put(TemplateIdEnum.IMPL_ONE.getId(), applicationContext.getBean(AdapterImplOne.class));
actAdapterMap.put(TemplateIdEnum.IMPL_TWO.getId(), applicationContext.getBean(AdapterImplTwo.class));
}

/**
* 根据模板id找对应的活动实现类
*
* @param templateId 模板id
* @return clz
*/
public Adapter findServiceByTemplateId(Integer templateId) {
return templateId == null || actAdapterIdx == null ? null : actAdapterIdx.get(templateId);
}

1.4 业务层调用

数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
java复制代码public class People {
/**
* 模板id
*/
private Integer templateId;
/**
* 姓名
*/
private String name;
/**
* 年龄
*/
private Integer age;
}

调用

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码// data可以理解为业务层拿到的数据
List<People> data = new ArrayList<>();
for (People p : data) {
Adapter service = ActExecutor.findServiceByTemplateId(p.getTemplateId);
if (service != null) {
// 执行方法1
service.method1();
// 执行方法2
service.method2();
// ...
}
}

1.5 总结

定义一个通用的接口和继承该接口的不同实现类,用枚举和HashMap维护else if的对应关系。业务代码就可以根据数据去HashMap中找到实现类并执行。

上述的基于接口和实现类的策略模式有点时候会有点儿笨重。比如上述的接口有两个方法,但是有几种情况只需要执行一种方法,或者还有几种情况要执行超过两个方法,那这样情况多了之后,定义接口时就要充分考虑到不同业务的兼容,这样的策略模式实现必须要充分的对业务进行抽象。

二、枚举直接实现

当时为了解决上述的问题,我看了《Effective Java》中对于枚举的用法。自己设计了一个一个策略就是一个方法的模式。

2.1 枚举

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
java复制代码@Getter
@AllArgsConstructor
public enum AdaptEnum {
/**
* 实现方法1
*/
IMPL_ONE(1) {

final Adapter adapter = SpringContextHolder.getBean(AdapterImplOne.class);

@Override
public void deal(Object param) {
// 业务实现
adapter.method1(param);
}
},
/**
* 实现方法2
*/
IMPL_ONE(2) {

final Adapter adapter = SpringContextHolder.getBean(AdapterImplTwo.class);

@Override
public void deal(Object param) {
// 业务实现
adapter.method2(param);
}
},
;

/**
* 公共抽象方法
*/
public abstract void deal(Object param);
/**
* 模板id
*/
private final Integer id;

/**
* 根据str获取枚举实例
*
* @param templateId 模板id
* @return AdaptEnum
*/
public static AdaptEnum getEnumById(Integer templateId) {
return Arrays.stream(AdaptEnum.class.getEnumConstants())
.filter(e -> Objects.equals(e.getId(), templateId))
.findFirst().orElse(null);
}

spring上下文工具

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
java复制代码/**
* spring容器
*
* @author sunxy
* @date:2021-01-05
*/
@Component
public class SpringContextHolder implements ApplicationContextAware {

private static ApplicationContext applicationContext;

@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
SpringContextHolder.applicationContext = applicationContext;
}

public static ApplicationContext getApplicationContext() {
assertApplicationContext();
return applicationContext;
}

public static <T> T getBean(String beanName) {
assertApplicationContext();
return (T) applicationContext.getBean(beanName);
}

public static <T> T getBean(Class<T> requiredType) {
assertApplicationContext();
return applicationContext.getBean(requiredType);
}

private static void assertApplicationContext() {
if (SpringContextHolder.applicationContext == null) {
throw new RuntimeException("ApplicationContext属性为null,请检查是否注入了SpringContextHolder!");
}
}

}

2.2 方法调用

1
2
3
4
5
6
7
8
java复制代码// data可以理解为业务层拿到的数据
List<People> data = new ArrayList<>();
for (People p : data) {
AdaptEnum e = p.getEnumById(p.getTemplateId);
if (e != null) {
e.deal(p);
}
}

2.3 总结

这样可以一个方法一个策略,就是枚举里重写的每个方法都需要引入spring的上下文,也就是说要确保枚举方法的调用一定要在spring上下文的加载完成后才行,否则会报Bean找不到的错误。

本文转载自: 掘金

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

学会mybatis-plus的使用,做一个快乐的Curd-B

发表于 2021-04-30

前言

对于mybatis ,很多后端开发已经很熟悉了,因为现在大部分公司用的框架就是mybatis,而Mybatis-Plus(简称MP)是一个 Mybatis 的增强工具。(很多公司也在用这个框架)

在项目里面,你经常是不是这样书写:(如查询) Wrappers.query().lambda().eq(Entity::getXX, entity2.getXX());

网上想找到Mybatis-Plus的文档和案例,其实很简单,在Mybatis-Plus的官网上或者有很多博客上都能找到的。但你有木有相关它是怎么能实现不需要再写xml了(针对写sql),就能针对性的查询/新增/修改/删除的?当你遇到lambda表达式时,会不会想到他是怎么把这个Get方法传入的?下面就来谈谈Mybatis-Plus是怎么使用lambda表达式,自动生成对应的sql语句的。

代码分析

基于Mybatis-Plus的3.0.6 版本,这个框架用到了工厂模式和组合模式 以及拦截过滤器模式。

file

首先:Wrappers.query() 或者Wrappers.update() 其实就是在创建一个QueryWrapper 或UpdateWrapper。然后调用lambda方法就是创建LambdaUpdateWrapper 或者 LambdaUpdateWrapper

file

如图,需要重点关注的是Compare(接口)和AbstractWrapper(类),在Compare接口里面。
file

1
2
3
4
5
6
7
8
9
perl复制代码
public interface Compare<This, R> extends Serializable {

default This eq(R column, Object val) {
return this.eq(true, column, val);
}

This eq(boolean condition, R column, Object val);
}

这里面的This就是代表就是返回自身(这里字面是这个意思,实际也是这样弄的),在3.3.2版本里面这个This用Children给取代了。

在AbstractWrapper类里面,其实已经实现了eq方法(如下图),这个类实现我把其他实现接口去掉了,只留下了Compare接口。(这样看起来比较清晰)

1
2
3
4
5
scala复制代码public abstract class AbstractWrapper<T, R, This extends AbstractWrapper<T, R, This>> extends Wrapper<T> implements Compare<This, R>{
public This eq(boolean condition, R column, Object val) {
return this.addCondition(condition, column, SqlKeyword.EQ, val);
}
}

可能你很疑惑为什么eq /ne 这些方法里面可以直接传递lambda的方法引用(Entity:getXX),而不应该是泛型R?
不要着急。AbstractLambdaWrapper (实现了AbstractWrapper类,此时 AbstractWrapper类的泛型R用接口SFunction来具体化“取代了”,这个SFunction指定了必须是泛型T里面的方法,这点要注意,如果没有指定泛型可能会报Object is not a functional interface 这样的错误)。

1
2
3
4
scala复制代码public abstract class AbstractLambdaWrapper<T, This extends AbstractLambdaWrapper<T, This>>
extends AbstractWrapper<T, SFunction<T, ?>, Children> {
//省略
}

调用上面的addCondition方法,实际会解析这个”接口”,这个是使用流读取,方法在LambdaUtils里面,如果你有需要可以在项目中直接使用这个方法,
这就是看源码的好处。其实这部分就是把当前对象的“数据库”对于列存入缓存(map),将对应列和值也就进行存储。以便到最后面生成sql。(其实在mapper层调用方法时)

file
file
file

自己实现这样的功能 (记录处理列和 对象所有的数据库字段 和串写的方式)

这个里面解析lambda等相关工具从mybatis-plus里面挪了出来,部分功能一重写,还原一个无依赖的项目。

1.继承接口

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
typescript复制代码package interfaces;

import java.io.Serializable;

/**
* <ul>
* <li>Title: Compare</li>
* </ul>
* @author 程序员ken
* @date 2021/4/28 0028 下午 14:48
*/
public interface Compare<This, R> extends Serializable {

This eq(boolean var1, R var2, Object var3);

default This eq(R column, Object val) {
return this.eq(true, column, val);
}

This ne(boolean var1, R var2, Object var3);

default This ne(R column, Object val) {
return this.ne(true, column, val);
}

This gt(boolean var1, R var2, Object var3);

default This gt(R column, Object val) {
return this.gt(true, column, val);
}


This lt(boolean var1, R var2, Object var3);

default This lt(R column, Object val) {
return this.lt(true, column, val);
}

}

2.接口实现类

AbstractWrapper 类 所有核心方法的实现,这里没有判断是不是SFunction,直接强转的,实际项目必须要判断哦

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
ini复制代码package wrapper;


/**
* <ul>
* <li>Title: AbstractWrapper</li>
* <li>Description: TODO </li>
* </ul>
*
* @author 程序员ken
* @date 2021/4/28 0028 下午 16:23
*/
//extends Wrapper<T>
public abstract class AbstractWrapper<T, R, This extends AbstractWrapper>
implements Compare<This, R> {

protected MergeSegmentList expression;
protected Map<String, Object> paramNameValuePairs;

public Class<T> entityClass;
private Map<String, String> columnMap = null;
private boolean initColumnMap = false;



public AbstractWrapper() {
}

//实际实现
@Override
public This eq(boolean condition, R column, Object val) {
String fileName = columnToString((SFunction) column);
MergeSegment segment = new MergeSegment();
segment.setColumName(fileName);
segment.setColumValue(val);
segment.setMatchCondition(MatchCondition.EQ);
expression.add(segment);
paramNameValuePairs.putIfAbsent(fileName,val);
return (This)this;
}

@Override
public This ne(boolean condition, R column, Object val) {
String fileName = columnToString((SFunction) column);
MergeSegment segment = new MergeSegment();
segment.setColumName(fileName);
segment.setColumValue(val);
segment.setMatchCondition(MatchCondition.NE);
expression.add(segment);
paramNameValuePairs.putIfAbsent(fileName,val);
return (This)this;
}

@Override
public This gt(boolean condition, R column, Object val) {
String fileName = columnToString((SFunction) column);
MergeSegment segment = new MergeSegment();
segment.setColumName(fileName);
segment.setColumValue(val);
segment.setMatchCondition(MatchCondition.GT);
expression.add(segment);
paramNameValuePairs.putIfAbsent(fileName,val);
return (This)this;
}

@Override
public This lt(boolean condition, R column, Object val) {
String fileName = columnToString((SFunction) column);
MergeSegment segment = new MergeSegment();
segment.setColumName(fileName);
segment.setColumValue(val);
segment.setMatchCondition(MatchCondition.LT);
expression.add(segment);
paramNameValuePairs.putIfAbsent(fileName,val);
return (This)this;
}

/***
* 功能描述: 获取字段信息
* @return: java.lang.String
* @author: 程序员ken
* @date: 2021/4/28 21:34
*/
protected String columnToString(SFunction<T, ?> column) {
SerializedLambda resolve = LambdaUtils.resolve(column);
return this.getColumn(resolve);
}


private String getColumn(SerializedLambda lambda) {
String fieldName = resolveFieldName(lambda.getImplMethodName());
if (!this.initColumnMap || !this.columnMap.containsKey(fieldName)) {
String entityClassName = lambda.getImplClassName();
try{
Class<T> aClass = (Class<T>)Class.forName(entityClassName.replaceAll("\\\\", "."));
if(entityClass==null){
entityClass = aClass;
}
this.columnMap = getColumnMap(aClass);
//3.0.6 支持 ==>3.3.2 不支持
//this.columnMap = LambdaUtils.getColumnMap(entityClassName);
this.initColumnMap = true;
}catch (Exception ex){

}
}
return fieldName;
}

/**
* 功能描述: 获取当前实体的“数据库”字段
* @param aClass
* @return: java.util.Map<java.lang.String,java.lang.String>
* @author: 程序员ken
* @date: 2021/4/29 0029 下午 12:39
*/
protected Map<String,String> getColumnMap(Class<?> aClass){
Map<String,String> map = new HashMap<String,String>();
//ClassLoader classLoader = aClass.getClassLoader();
Field[] declaredFields = aClass.getDeclaredFields();
TableField tableField =null;
for (Field field:declaredFields) {
tableField = field.getAnnotation(TableField.class);

if(!(tableField!=null && !tableField.exist())){
map.putIfAbsent(field.getName(),field.getName());
}
}

return map;
}

public static String resolveFieldName(String getMethodName) {
if (getMethodName.startsWith("get")) {
getMethodName = getMethodName.substring(3);
} else if (getMethodName.startsWith("is")) {
getMethodName = getMethodName.substring(2);
}
return firstToLowerCase(getMethodName);
}

public static String firstToLowerCase(String param) {
return param==null || "".equals(param.trim()) ? "" :
param.substring(0, 1).toLowerCase() + param.substring(1);
}

}

file
file

3.记录列

file
file

3.枚举类

file

4.注解类

file

5.工具类
工具类的lambda解析的接口,我是指定了解析“继承”了Function这个接口,才会被解析,mybatis-plus里面是写死了 解析SFunction,这样限制性很大,然后脱离了mybatis-plus框架这个解析类的很多功能就用不了。

file

6.接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java复制代码package interfaces;

import java.io.Serializable;
import java.util.function.Function;

/**
* <ul>
* <li>Title: SFunction</li>
* <li>Description: TODO </li>
* </ul>
*
* @author 程序员ken
* @date 2021/4/28 0028 下午 14:33
*/

@FunctionalInterface
public interface SFunction<T, R> extends Function<T,R>, Serializable {
}

7.测试:
file

8.其他

另外在own包下我也仿写了一个这样的串写lambda的示例,所有的测试案例在LambadaTest里面doun都能找到。

file

总结:其实本文也并没有深入源码,只是让大致了解这个框架的原理。

【纸上得来终觉浅,绝知此事要躬行】

(多看看优秀的代码,这样你的代码才会有进步哦,不要做一个只会curd的boy哦)

源码地址:gitee.com/ten-ken/myb…

欢迎关注我的公众号:程序员ken,程序之路,让我们一起探索,共同进步。

本文转载自: 掘金

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

设计一个简单的点赞功能

发表于 2021-04-30

新增功能:点赞

现在几乎所有的媒体内容,无论是商品评价、话题讨论还是朋友圈都支持点赞,点赞功能成为了互联网项目的标配,那么我们也尝试在评价系统中加入点赞功能,实现为每一个评价点赞。

豆瓣短评中的点赞:

image-20210407234242890

要实现的点赞需求细节:

image-20210414215247131

从放弃出发

完整得实现点赞系统功能是很困难的。要支持亿级的用户数量,又要做到数据归档入库,要支持高峰期百万的秒并发写入,又要实现多客户端实时同步,要记录并维护用户的点赞关系,又要展示用户的点赞列表,这样全方位的需求会产生设计上的矛盾,就像CAP矛盾一样。

典型的比如并发量和同步性的矛盾。高并发的本质是速度,网络传输速度和程序运行速度决定了系统所能承载的容量,每个请求处理速度快才能在单位时间内处理更多的请求,只是一味得增大连接数而忽略请求响应时间,并发问题得不到根本性的解决。在我看来,应用程序内部运行速度的瓶颈在于三处,优先级由高到低是网络请求、对象创建、冗余计算,网络请求对响应速度具有决定性的影响力。但是,同步性又要求我们进行网络请求,比如同步数据到mysql或redis之中。鱼与熊掌不可兼得,并发量和同步性具有不可调和的矛盾。

还有存储容量与访问速度的矛盾。要记录用户的点赞列表,就意味着要长期维护用户的点赞关系,日积月累,用户的点赞关系在单台存储系统中装不下,需要写入分布式存储系统中,这带来了额外的复杂度与调度时延,并且需要很好地设计区分维度,不同分区之间数据不耦合。而一旦一次查询跨越了多个存储节点,就会产生级联调用,具有较大的网络时延。

要实现,先舍弃。看到一个新的需求时,我习惯于反向思考,观察这个需求不涉及到哪些功能,哪些功能可以放弃,从这个角度出发,很容易找到取巧而又简单,却能满足当前需求的设计方案。

重新列一个需求清单,上面写了不需要实现哪些功能,这样做设计决策时,就豁然开朗了。

image-20210414225218732

产品经理只会给你提供表格1,他们很少会显示说明什么不需要做。在决定放弃时,还是需要商量一下,因为这些需求往往是软性的,需求文档中没有包含不一定是不需要,也有可能是没考虑到。

如何记录用户的点赞关系

点赞关系是典型的K-V类型或是集合类型,用Redis实现是比较合适的,那么用Redis中的哪种数据类型呢?

下表列出了能想到的数据类型与它们各自的优劣。

image-20210414231656907

比较关键的特性是批量查询和内存占用,批量查询特性使得可以在一次请求中查询全部的点赞关系,内存占用使得可以用尽可能少的redis节点,甚至一台redis解决存储问题。

我选择字符串类型,因为哈希类型真的很难实现点赞数据的淘汰,除非记录点赞时间并且定期全局扫描,或者记录双份哈希键,做新旧替换,代价太高,不合适。而淘汰机制本身就是解决内存占用问题,所以字符串类型不会占用异常多的内存。

image-20210415101020806

点赞操作的原子性

点赞操作需要改写两个值,一个是用户对内容的点赞关系,另一个是内容的点赞总数,这两个能不能放在一个key中表示呢?显然是不行的。所以需要先设置用户的点赞关系,再增加点赞总数,如果点赞关系已经存在,就不能增加点赞总数。

设置点赞关系可以用setnx命令实现,仅当不存在key时才设置,并返回一个是否设置的标志,根据这个标志决定是否增加点赞总数。比如:

1
2
3
lua复制代码if setnx(key1) == 1
then
incr(key2)

看似每个操作都是原子性的,但是这样的逻辑如果在客户端执行,整体上仍不满足原子性,仍有可能在两个操作之间发生中断,导致点赞成功但是没有增加计数的情况发生。虽然这对于点赞系统来说不是什么大问题,极少出现的概率可以接受,但是我们完全可以做的更好。

redis的事务或脚本特性可以解决上述的问题。脚本的实现更加灵活自由,而且能减少网络请求,我们选择脚本的方式:

1
2
3
4
5
6
7
lua复制代码--点赞操作,写入并自增,如果写入失败则不自增,【原子性、幂等性】
if redis.call('SETNX',KEYS[1],1) == 1
then
redis.call('EXPIRE',KEYS[1],864000)
redis.call('INCR',KEYS[2])
end
return redis.call('GET',KEYS[2])
1
2
3
4
5
6
lua复制代码--取消点赞操作,删除并递减,如果删除失败则不递减,【原子性、幂等性】
if redis.call('DEL',KEYS[1]) == 1
then
redis.call('DECR',KEYS[2])
end
return redis.call('GET',KEYS[2])

稳定性的基本要求之一就是数据不能无限膨胀,否则迟早出问题,任何存储方案都必须设计与之对应的销毁方案,才能保证系统的稳定长久运行。所以设置KEY1的有效期非常重要,而KEY2可能需要一直保持,由其他机制来删除它,比如销毁陈旧评价或折叠评价时,需要删除对应的KEY2.

脚本返回了点赞后的总数,这对后续数据归档是有帮助的。

封装脚本操作

既然已经决定了redis存储方式,那么就先来实现它。一步一个脚印,扎扎实实地把点赞功能完成。

首先使用Spring配置Lua脚本,它自动预加载脚本,不用麻烦在redis服务器上用script load预编译。

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复制代码/**
* Lua脚本
*/
@Configuration
public class LuaConfiguration {
/**
* [点赞]脚本 lua_set_and_incr
*/
@Bean
public DefaultRedisScript<Integer> voteScript() {
DefaultRedisScript<Integer> redisScript = new DefaultRedisScript<>();
redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("script/lua_set_and_incr.lua")));
redisScript.setResultType(Integer.class);
return redisScript;
}

/**
* [取消点赞]脚本 lua_del_and_decr
*/
@Bean
public DefaultRedisScript<Integer> noVoteScript() {
DefaultRedisScript<Integer> redisScript = new DefaultRedisScript<>();
redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("script/lua_del_and_decr.lua")));
redisScript.setResultType(Integer.class);
return redisScript;
}
}
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复制代码/**
* 点赞箱
*/
@Repository
public class VoteBox {
private final RedisTemplate<String, Object> redisTemplate;
private final DefaultRedisScript<Integer> voteScript;
private final DefaultRedisScript<Integer> noVoteScript;

public VoteBox(RedisTemplate<String, Object> redisTemplate, DefaultRedisScript<Integer> voteScript, DefaultRedisScript<Integer> noVoteScript) {
this.redisTemplate = redisTemplate;
this.voteScript = voteScript;
this.noVoteScript = noVoteScript;
}

/**
* 给评价投票(点赞),用户增加评价点赞记录,评价点赞次数+1.该操作是原子性、幂等性的。
* @param voterId 投票人
* @param contentId 投票目标内容id
* @return 返回当前最新点赞数
*/
public Integer vote(long voterId, long contentId){
//使用lua脚本
List<String> list = new ArrayList<>();
list.add(MessageFormat.format(RedisKeyConstants.VOTE_USER_PATTERN, voterId, contentId));
list.add(MessageFormat.format(RedisKeyConstants.VOTE_SUM_PATTERN, contentId));
return redisTemplate.execute(voteScript, list);
}

/**
* 取消给评价投票(点赞),用户删除评价点赞记录,评价点赞次数-1.该操作是原子性、幂等性的。
* @param voterId 投票人
* @param contentId 投票目标内容id
* @return 返回当前最新点赞数
*/
public Integer noVote(long voterId, long contentId){
//使用lua脚本
List<String> list = new ArrayList<>();
list.add(MessageFormat.format(RedisKeyConstants.VOTE_USER_PATTERN, voterId, contentId));
list.add(MessageFormat.format(RedisKeyConstants.VOTE_SUM_PATTERN, contentId));
return redisTemplate.execute(noVoteScript, list);
}
}

点赞的流程

点赞的流程可以用如下时序图表示:

image-20210415151828448

  1. 服务端接收用户的点赞请求
  2. 执行redis脚本,并返回点赞总数信息,redis保存点赞功能的暂时数据
  3. 发送普通消息到消息队列
  4. 以上两步执行成功后响应点赞完成,否则加入重试队列
  5. 重试队列异步重试请求redis或消息队列,直到成功或重试次数用尽
  6. 消息队列消费者接收消息,并将消息写入mysql

为什么加入消息队列这个角色?因为消息队列使得同步和异步可以优雅的分离。redis命令需要在当前请求中完成,用户想看到请求的执行结果,希望在其他客户端上立刻看到自己的点赞状态,这个举例可能不太恰当,点赞也可能是单向请求,用户没有那么在乎同步性,这里只是为了演示案例。而数据入库或者是其他操作不需要在当前请求生命周期内完成。

如果同步可以称之为“在线服务”,那么异步可以称之为“半在线半离线服务”,虽然不在请求的生命周期内,但是运行于在线服务器之上,占用cpu和内存,占用网络带宽,势必给线上业务造成影响。当异步模式调整时,需要连同在线业务一起发布,造成逻辑上的耦合。而消息队列让“离线服务”成为可能,消费者可以与在线服务器独立开来,独立开发独立部署,无论是物理上还是逻辑上都完全解耦。当然前提是消息对象的序列化格式一致,所以我喜欢使用字符串作为消息对象的内容,而不是对象序列化。

实现mysql的点赞入库

设计好redis的存储方案后,接下来设计mysql的存储方案。

首先是表结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
mysql复制代码#点赞/投票归档表
CREATE TABLE IF NOT EXISTS vote_document
(
id INT primary key auto_increment COMMENT 'ID',
gmt_create datetime not null default CURRENT_TIMESTAMP COMMENT '创建时间',
voter_id INT not null COMMENT '投票人id',
contentr_id INT not null COMMENT '投票内容id',
voting TINYINT not null COMMENT '投票状态(0:取消投票 1:投票)',
votes INT not null COMMENT '投下/放弃这一票后,内容在此刻的投票总数',
create_date INT not null COMMENT '创建日期 如:20210414 用于分区分表'
);

insert into vote_document(voter_id,content_id,voting,votes,create_date)
values(1,1,1,1,'20210414');

显然,这是一个以Insert代替Update的日志表,无论是点赞、取消点赞还是重新点赞,都是追加新的记录,而不是修改原有记录。这样做有两个原因,一是Insert不用锁表,执行效率远高于Update,二是蕴含的信息更丰富,可以看到用户的完整行为,对于大数据分析是有帮助的。

Insert代替Update之后,一大难点就是数据聚合,解决方案就是每一次插入,都冗余地记录聚合状态,就像votes字段一样,分析时只需要拿相关评价的最后一条记录即可知道点赞总数,而不需全表扫描。

入库代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码@Repository
public class VoteRepository {
@Autowired
private JdbcTemplate db;

/**
* 添加点赞
* @param vote 点赞对象
* @return 如果插入成功,返回true,否则返回false
*/
public boolean addVote(/*valid*/ Vote vote) {
String sql = "insert into vote_document(voter_id,content_id,voting,votes,create_date) values(?,?,?,?,?)";
return db.update(sql, vote.getVoterId(), vote.getContentId(), vote.getVoting(), vote.getVotes(), Sunday.getDate()) > 0;
}
}

RocketMQ

Apache RocketMQ是一种低延迟、高并发、高可用、高可靠的分布式消息中间件。消息队列RocketMQ既可为分布式应用系统提供异步解耦和削峰填谷的能力,同时也具备互联网应用所需的海量消息堆积、高吞吐、可靠重试等特性。

消息队列核心概念:

  • Topic:消息主题,一级消息类型,生产者向其发送消息。
  • Broker:中间人/经纪人,消息队列集群的节点,负责保存和收发消息。
  • 生产者:也称为消息发布者,负责生产并发送消息至Topic。
  • 消费者:也称为消息订阅者,负责从Topic接收并消费消息。
  • Tag:消息标签,二级消息类型,表示Topic主题下的具体消息分类。
  • 消息:生产者向Topic发送并最终传送给消费者的数据和(可选)属性的组合。
  • 消息属性:生产者可以为消息定义的属性,包含Message Key和Tag。
  • Group:一类生产者或消费者,这类生产者或消费者通常生产或消费同一类消息,且消息发布或订阅的逻辑一致。

生产者发送消息到消息队列,最终发送到消费者的示意图如下:

image-20210112223820896
消息类型可以划分为:

  • 普通消息。也称并发消息,没有顺序,生产消费都是并行的,拥有极高的吞吐性能
  • 事务消息。提供了保证消息一定送达到broker的机制。
  • 分区顺序消息。Topic分为多个分区,在一个分区内遵循先入先出原则。
  • 全局顺序消息。把Topic分区数设置为1,所有消息都遵循先入先出原则。
  • 定时消息。将消息发送到MQ服务端,在消息发送时间(当前时间)之后的指定时间点进行投递
  • 延迟消息。将消息发送到MQ服务端,在消息发送时间(当前时间)之后的指定延迟时间点进行投递

消费方式可以划分为:

  • 集群消费。任意一条消息只需要被集群内的任意一个消费者处理即可。
  • 广播消费。将每条消息推送给集群内所有注册过的消费者,保证消息至少被每个消费者消费一次。

消费者获取消息模式可以划分为:

  • Push。开启单独的线程轮询broker获取消息,回调消费者的接收方法,仿佛是broker在推消息给消费者。
  • Pull。消费者主动从消息队列拉取消息。

使用RocketMQ

我们使用某云产品的RocketMq消息队列,按照官方文档,先在云控制中心创建Group和Topic,然后引入maven依赖,创建好MqConfig连接配置对象。最后:

配置生产者(在项目A):

1
2
3
4
5
6
7
8
9
10
java复制代码@Configuration
public class ProducerConfig {
@Autowired
private MqConfig mqConfig;

@Bean(initMethod = "start", destroyMethod = "shutdown")
public Producer buildProducer() {
return ONSFactory.createProducer(mqConfig.getMqPropertie());
}
}

配置消费者(在项目B):

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
java复制代码@Configuration
public class ConsumerClient {
@Autowired
private MqConfig mqConfig;

@Autowired
private VoteMessageReceiver receiver;

@Bean(initMethod = "start", destroyMethod = "shutdown")
public ConsumerBean buildConsumer() {
ConsumerBean consumerBean = new ConsumerBean();
Properties properties = mqConfig.getMqPropertie();
properties.setProperty(PropertyKeyConst.GROUP_ID, mqConfig.GROUP_CONSUMER_ID);
properties.setProperty(PropertyKeyConst.ConsumeThreadNums, "10");
consumerBean.setProperties(properties);

Map<Subscription, MessageListener> subscriptionTable = new HashMap<>();
Subscription subscription = new Subscription();
subscription.setTopic(mqConfig.TOPIC_ISSUE);
subscription.setExpression(mqConfig.TAG_ISSUE);
subscriptionTable.put(subscription, receiver);

consumerBean.setSubscriptionTable(subscriptionTable);
return consumerBean;
}
}

创建消息接收、监听器:

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
java复制代码/**
* 投票消息接收器
*/
@Component
public class VoteMessageReceiver implements MessageListener {
private final VoteRepository voteRepository;

public VoteMessageReceiver(VoteRepository voteRepository) {
this.voteRepository = voteRepository;
}

@Override
public Action consume(Message message, ConsumeContext context) {
try {
JSONObject object = JSONObject.parseObject(new String(message.getBody()));

Vote vote = new Vote();
vote.setVoterId(object.getLongValue("voterId"));
vote.setContentId(object.getLongValue("contentId"));
vote.setVoting(object.getIntValue("voting"));
vote.setVotes(object.getLongValue("votes"));

try {
vote.validate();
voteRepository.addVote(vote);
} catch (IllegalArgumentException ignored) {
}

return Action.CommitMessage;
}catch (Exception e) {
e.printStackTrace();
return Action.ReconsumeLater;
}
}
}

发送消息的生产者,再稍稍封装一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
java复制代码/**
* 消息生产者,消息投递仓库
*/
@Repository
public class MessagePoster {
private final Producer producer;

public MessagePoster(Producer producer) {
this.producer = producer;
}

public void sendMessage(String topic, String tag, String content){
Message message = new Message();
message.setTopic(topic);
message.setTag(tag);
message.setBody(content.getBytes(StandardCharsets.UTF_8));
producer.send(message);
}

public void sendMessage(String topic, String content){
sendMessage(topic, "", content);
}
}

发布消费者,在云控制中心测试(确保流程走通,步步为营):

image-20210415171026192

能达成一致吗

执行redis命令与发送消息这两步,能做到一致性吗,也就是常说的同时完成与同时失败?如果是同构的系统,可以利用系统本身的特性实现事务,比如同是redis操作可以使用redis事务或脚本,前面已经这么做了,如果同是数据库操作,可以使用数据库事务,其他存储系统应该也有类似的支持。

但它们是异构的系统,只能通过在客户端实现事务逻辑或者由第三方协调。常见的客户端实现方法是回滚:

1
2
3
4
5
6
7
java复制代码try{
redis.call();
mq.call();
}catch(MqException e){ //只有mq出错时才需要回滚
//使用反向操作回滚
redis.rollback();
}

但是如果回滚失败呢?如果消息发到MQ但却接收失败呢?如果依赖的服务不支持回滚呢?在苛刻的条件下实现苛刻的一致性是不可能的。

还是应该反向思考,有选择性地舍弃某些不重要的部分,才能实现我们的需求。在目前这个需求中,没有必要为了redis和MQ的同步引入第三方的事务协调,但也不能对明显的事务问题视而不见。

我总结的分布式事务解决思路导图:

image-20210415202027996

我们选择使用重试队列来解决这个问题。

设计重试队列

不局限于当前的分布式事务问题,我们设计一个较为通用的重试队列。

先设计重试队列中的基本概念:任务。一个任务由多个单元组成,可计算单元表示有返回值的方法对象,执行单元表示没有返回值的方法对象,但是会接收上一步可计算单元的返回值作为入参。任务中保持了单元的单向链表,只有当一个单元执行成功后,才会指向下一个单元继续执行,但当执行失败时,会在当前单元不断重试直到成功,已执行通过的单元不会重试。这样就保证了各个单元的稳定、有序运行,每个环节的执行具有容错性。

image-20210415210047077

基础接口,让使用者可以自己实现任务执行失败的日志记录,比如持久化磁盘或是发送到远程服务器,避免任务丢失,是保持事务一致性的兜底方案之一,设置成缺省方法使得使用者有选择地实现,不强制一定要有失败处理方案。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
java复制代码/**
* 失败记录器
*/
interface IFailRecorder {
/**
* 记录每次重试的失败情况
* @param attemptTimes 重试次数,第一次重试=0
* @param e 导致失败的异常
*/
default void recordFail(int attemptTimes, Exception e){}

/**
* 记录每次重试的失败情况
* @param attemptTimes 重试次数,第一次重试=0
*/
default void recordFail(int attemptTimes){}

/**
* 记录重试之后的最终失败
* @param e 导致失败的异常,如果没有异常,返回null
*/
default void recordEnd(Exception e){}
}

定义执行的基本单元,代表需要执行一个redis操作或是发送MQ操作,接口方法可能会由调度器重复地执行,所以要求接口实现者自身保证幂等性。

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 interface Repeatable<V> extends IFailRecorder{
/**
* Computes a result, or throws an exception if unable to do so.
*
* @param repeatTimes repeat times, first repeatTimes is 0
* @return computed result
* @throws Exception if unable to compute a result
*/
V compute(int repeatTimes) throws Exception;

/**
* Execute with no result, and throws an exception if unable to do so.
*
* @param repeatTimes repeat times, first repeatTimes is 0
* @param receiveValue last step computed result
* @throws Exception if unable to compute a result
*/
default void execute(int repeatTimes, V receiveValue) throws Exception{}

/**
* Execute with no result, and throws an exception if unable to do so.
*
* @param repeatTimes repeat times, first repeatTimes is 0
* @throws Exception if unable to compute a result
*/
default void execute(int repeatTimes) throws Exception{}
}

对应的派生抽象类,主要是为了引导用户实现接口。

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复制代码/**
* 可计算任务
* @param <V> 计算结果类型
*/
public abstract class Computable<V> implements Repeatable<V>{
@Override
public void execute(int repeatTimes) throws Exception {
throw new IllegalAccessException("不支持的方法");
}

@Override
public void execute(int repeatTimes, V receiveValue) throws Exception {
throw new IllegalAccessException("不支持的方法");
}
}

/**
* 可执行任务
*/
public abstract class Executable<V> implements Repeatable<V>{
@Override
public V compute(int repeatTimes) throws Exception {
throw new IllegalAccessException("不支持的方法");
}
}

重试的意义

好的重试机制可以起到削峰填谷的作用,而不好的重试机制可能火上浇油。

这不是危言耸听,仔细思考一下,程序什么情况下会失败,大致可以总结为三种情况:

  1. 参数错误导致的逻辑异常
  2. 负载过大导致的超时或熔断
  3. 不稳定的网络与人工意外事故

其中对于情况1进行重试是完全没有意义的,参数错误的问题应该通过改变参数来解决,逻辑异常应该修复逻辑bug,无脑重试只能让错误重复发生,只会浪费cpu。对于情况2的重试得小心,因为遇到流量波峰而失败,短时间内重试很可能再次遭遇失败,并且这次重试还会带来更大的流量压力,像滚雪球一样把自己搞垮,也就是火上浇油。

对于情况3的重试就非常有价值,尤其是对于具有SLA协议的第三方服务。第三方服务可能因为种种意外(比如停服更新),导致服务短暂不可用,但是却不违反SLA协议。将这种失败情况加入重试队列,确保只要第三方服务在较长的一段时间内有响应,任务就可以成功,如果第三方服务一直没有响应而导致任务最终失败,那么他往往也就破坏了SLA协议,可以申请赔偿了。

所以,设计重试策略时首先需要判断什么情况下需要重试,可以设定当出现特定的比如参数错误的异常时,就没必要重试了,直接失败即可。可以设定只要当返回参数不为空时才算成功。可以设置固定的重试间隔,让两个重试之间拉开比较长的时间。

更聪明的做法是,使用断路器模式,借助当前连接对目标服务器的请求结果,如果不符预期(异常比率大),就暂时阻塞重试队列中等待的任务,隔一段时间再试探一下。

重试队列与普通限流降级或熔断的区别:

image-20210415234437188

重试的策略

重试策略决定任务何时发起重试,重试策略接口:

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
java复制代码/**
* 重试策略,决定任务何时可以重试
*/
public interface IRetryStrategy {

/**
* 现在是否应该执行重试
* @param attemptTimes 第几次重试
* @param lastTimestamp 上一次重试的时间戳
* @param itemId 当前的执行项目id
* @return 允许重试,返回true,否则,返回false
*/
boolean shouldTryAtNow(int attemptTimes, long lastTimestamp, int itemId);

/**
* 通知一次失败
* @param itemId 当前的执行项目id
*/
void noticeFail(int itemId);

/**
* 通知一次成功
* @param itemId 当前的执行项目id
*/
void noticeSuccess(int itemId);
}

基本实现类:

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
java复制代码/**
* 指定间隔时间的重试策略
*/
public class DefinedRetryStrategy implements IRetryStrategy {
private final int[] intervals;

public DefinedRetryStrategy(int... intervals) {
if (intervals.length == 0) {
this.intervals = new int[]{0};
} else {
this.intervals = intervals;
}
}

private DefinedRetryStrategy() {
this.intervals = new int[]{0};
}

/**
* 现在是否应该执行重试
*
* @param attemptTimes 第几次重试
* @param lastTimestamp 上一次重试的时间戳
* @param itemId 当前的执行项目id
* @return 允许重试,返回true,否则,返回false
*/
@Override
public boolean shouldTryAtNow(int attemptTimes, long lastTimestamp, int itemId) {
return System.currentTimeMillis() > lastTimestamp + getWaitSecond(attemptTimes) * 1000L;
}

@Override
public void noticeFail(int itemId) {

}

@Override
public void noticeSuccess(int itemId) {

}

/**
* 根据当前重试次数,获取下一次重试等待间隔(单位:秒)
*/
private int getWaitSecond(int attemptTimes) {
if (attemptTimes < 0) {
attemptTimes = 0;
}

if (attemptTimes >= intervals.length) {
attemptTimes = intervals.length - 1;
}

return intervals[attemptTimes];
}
}

使用断路器实现重试策略,断路器内部实现省略:

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
java复制代码/**
* 断路器模式实现的智能的重试策略
*/
public class SmartRetryStrategy extends DefinedRetryStrategy {
//断路器集合
private final Map<Integer, CircuitBreaker> circuitBreakers = new ConcurrentHashMap<>();

private final Object LOCK = new Object();

private static CircuitBreaker newCircuitBreaker() {
return new ExceptionCircuitBreaker();
}

public SmartRetryStrategy(int[] intervals) {
super(intervals);
}

private CircuitBreaker getCircuitBreaker(Integer itemId) {
if (!circuitBreakers.containsKey(itemId)) {
synchronized (LOCK) {
if (!circuitBreakers.containsKey(itemId)) {
circuitBreakers.put(itemId, newCircuitBreaker());
}
}
}

return circuitBreakers.get(itemId);
}

/**
* 现在是否应该执行重试
*
* @param attemptTimes 第几次重试
* @param lastTimestamp 上一次重试的时间戳
* @param itemId 当前的执行项目id
* @return 允许重试,返回true,否则,返回false
*/
@Override
public boolean shouldTryAtNow(int attemptTimes, long lastTimestamp, int itemId) {
//如果基本条件不满足,则不能重试
if (!super.shouldTryAtNow(attemptTimes, lastTimestamp, itemId)) {
return false;
}

//断路器是否允许请求通过
return canPass(itemId);
}

/**
* 通知一次失败
*
* @param itemId 当前的执行项目id
*/
@Override
public void noticeFail(int itemId) {
getCircuitBreaker(itemId).onFail();
}

/**
* 通知一次成功
*
* @param itemId 当前的执行项目id
*/
@Override
public void noticeSuccess(int itemId) {
getCircuitBreaker(itemId).onSuccess();
}

/**
* 是否允许通过
*/
public boolean canPass(int itemId){
return getCircuitBreaker(itemId).canPass();
}
}

可重试任务

根据上面的结构图,定义可重试任务接口:

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复制代码/**
* 重试任务
*/
public interface IRetryTask<V> {
/**
* 执行一次重试
* @return 如果执行成功,返回true,否则返回false
*/
boolean tryOnce();

/**
* 是否应该关闭任务
* @return 如果达到最大重试次数,返回true,表示可以关闭
*/
boolean shouldClose();

/**
* 现在是否应该执行重试
* @return 当等待时间超过重试间隔时间后,允许重试,返回true,否则,返回false
*/
boolean shouldTryAtNow();

/**
* 获取执行结果
*/
V getResult();
}

然后设计抽象类:

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
java复制代码/**
* 重试任务.
* 非线程安全
*/
public abstract class AbstractRetryTask<V> implements IRetryTask<V> {
//重试等待间隔
protected final IRetryStrategy retryStrategy;

//当前重试次数
protected int curAttemptTimes = -1;

//最大重试次数
private final int maxAttemptTimes;

//上一次重试的时间戳
protected long lastTimestamp = 0;

public AbstractRetryTask(IRetryStrategy retryStrategy, int maxAttemptTimes) {
this.retryStrategy = retryStrategy;
this.maxAttemptTimes = maxAttemptTimes;
}

/**
* 执行一次重试
*
* @return 如果执行成功,返回true,否则返回false
*/
@Override
public boolean tryOnce() {
if (isFinished()) {
return true;
}

setNextCycle();

//执行重试
doTry();

//重试任务执行异常或者返回null,将视为执行失败
return isFinished();
}

/**
* 是否结束
*/
protected abstract boolean isFinished();

/**
* 执行回调
*/
protected abstract void doTry();

/**
* 是否应该关闭任务
*
* @return 如果达到最大重试次数,返回true,表示可以关闭
*/
@Override
public boolean shouldClose() {
return curAttemptTimes >= maxAttemptTimes;
}

//设置下一执行周期
private void setNextCycle() {
curAttemptTimes++;
lastTimestamp = System.currentTimeMillis();
}
}

以及实现类:

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
java复制代码/**
* 多段重试任务. 任务链路执行失败时,下一次重试从失败的点继续执行。
*/
@Slf4j
public class SegmentRetryTask<V> extends AbstractRetryTask<V> {
//分段执行方法
private final List<Repeatable<V>> segments;

//当前执行片段,上一次执行中断的片段
private int currentSegment = 0;

//上一次的执行结果值
private V result;

public SegmentRetryTask(IRetryStrategy retryStrategy, int maxAttemptTimes, List<Repeatable<V>> segments) {
super(retryStrategy == null ? new DefinedRetryStrategy(0) : retryStrategy, maxAttemptTimes);
this.segments = segments;
}

/**
* 执行回调
*/
@Override
protected void doTry() {
try {
for (; currentSegment < segments.size(); currentSegment++) {
//如果当前断路器打开,不尝试执行
if (retryStrategy instanceof SmartRetryStrategy){
if (!((SmartRetryStrategy)retryStrategy).canPass(currentSegment)) {
segments.get(currentSegment).recordFail(curAttemptTimes, new CircuitBreakingException());
return;
}
}

//如果抛异常,分段计数器不增加,下次从这个地方执行
Repeatable<V> repeatable = segments.get(currentSegment);
if (!execute(repeatable)) return;
}
} catch (Exception e) {
retryStrategy.noticeFail(currentSegment);
if (currentSegment < segments.size()) {
if (shouldClose()) {
segments.get(currentSegment).recordEnd(e);
} else {
segments.get(currentSegment).recordFail(curAttemptTimes, e);
}
}
}
}

private boolean execute(Repeatable<V> repeatable) throws Exception {
if (repeatable instanceof Computable) {
result = repeatable.compute(curAttemptTimes);
if (result == null) {
repeatable.recordFail(curAttemptTimes);
retryStrategy.noticeFail(currentSegment);
return false;
}
retryStrategy.noticeSuccess(currentSegment);
}

if (repeatable instanceof Executable) {
if (result == null) {
repeatable.execute(curAttemptTimes);
} else {
repeatable.execute(curAttemptTimes, result);
}
retryStrategy.noticeSuccess(currentSegment);
}
return true;
}

@Override
protected boolean isFinished() {
return currentSegment >= segments.size();
}

/**
* 现在是否应该执行重试
*
* @return 当等待时间超过重试间隔时间后,允许重试,返回true,否则,返回false
*/
@Override
public boolean shouldTryAtNow() {
return retryStrategy.shouldTryAtNow(curAttemptTimes, lastTimestamp, currentSegment);
}

/**
* 获取执行结果
*/
@Override
public V getResult() {
return result;
}
}

一个单元测试,当然单元测试有很多,不能全贴出来,这里只展示有代表性的:

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复制代码class SegmentRetryTaskTest {
private final List<String> messages = new ArrayList<>();

@Test
void doTry() {
List<Repeatable<String>> list = new ArrayList<>();
list.add(new Computable<>(){
@Override
public String compute(int repeatTimes) throws Exception {
if (repeatTimes < 2)
throw new Exception();
if (repeatTimes < 4)
return null;
messages.add("result:good");
return "good";
}

@Override
public void recordFail(int attemptTimes, Exception e) {
messages.add("fail:" + attemptTimes);
}

@Override
public void recordFail(int attemptTimes) {
messages.add("fail:" + attemptTimes);
}

@Override
public void recordEnd(Exception e) {
messages.add("end");
}
});

list.add(new Executable<>() {
@Override
public void execute(int repeatTimes, String receiveValue) throws Exception {
messages.add("receive:" + receiveValue);
throw new Exception("exc");
}

@Override
public void recordEnd(Exception e) {
messages.add("end:" + e.getMessage());
}
});

IRetryTask retryTask = new SegmentRetryTask<>(new DefinedRetryWaitStrategy(0), 5, list);

//重试未开始
assertFalse(retryTask.shouldClose());

//重试直到成功
assertFalse(retryTask.tryOnce());
assertFalse(retryTask.shouldClose());
assertFalse(retryTask.tryOnce());
assertFalse(retryTask.tryOnce());
assertFalse(retryTask.tryOnce());
assertFalse(retryTask.tryOnce());
assertFalse(retryTask.tryOnce());
assertTrue(retryTask.shouldClose());

assertTrue(messages.contains("result:good"));
assertTrue(messages.contains("fail:1"));
assertTrue(messages.contains("fail:2"));
assertTrue(messages.contains("fail:3"));
assertFalse(messages.contains("end"));
assertTrue(messages.contains("receive:good"));
assertTrue(messages.contains("end:exc"));
}
}

重试队列的运作

image-20210416101646494

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码线程安全的重试队列。
* (Spring-retry 和 guava-retrying都不完全适合这个场景,决定自己开发一个简单的重试机制)
* 重试队列会尽最大努力让任务多次执行并成功,使用时需要考虑以下几点。
* 1.重试队列存储在内存之中,暂未同步到磁盘,要求使用者可以承受丢失的风险。
* 2.重试不保证一定会成功,它将在重试一定的次数后结束,如果最终失败,将记录失败结果。
* 3.为了不让频繁的重试让系统的负载过大,建议设置恰当的重试间隔,以起到削峰填谷的作用。
* 4.当超过重试队列允许容纳的数量时,将抛出异常。
* 5.重试任务将在独立的线程中执行,不会阻塞当前线程
* 6.重试任务执行异常或者返回null,将视为执行失败。暂不支持拦截自定义异常。
* 7.由于网络问题,远程过程执行成功未必代表会返回成功,重试任务需要实现幂等性。
* 8."队列"仅指按先进先出的顺序扫描任务,任务移除队列操作取决于其何时完成或结束
*

实现重试队列

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
java复制代码/**
* 线程安全的重试队列。
* @author sunday
* @version 0.0.1
*/
public final class RetryQueue {
//重试任务队列(全局唯一)
private final static Deque<IRetryTask> retryTaskList = new ConcurrentLinkedDeque<>();

//重试任务工厂
private final IRetryTaskFactory retryTaskFactory;

public RetryQueue(IRetryTaskFactory retryTaskFactory) {
this.retryTaskFactory = retryTaskFactory;
}

static {
Thread daemon = new Thread(RetryQueue::scan);
daemon.setDaemon(true);
daemon.setName(RetryConstants.RETRY_THREAD_NAME);
daemon.start();
}

//扫描重试队列,执行重试并移除任务(如果成功),周期性执行
private static void scan() {
while (true) {
//先执行,再删除
retryTaskList.removeIf(task -> retry(task) || task.shouldClose());

// wait some times
try {
TimeUnit.MILLISECONDS.sleep(RetryConstants.SCAN_INTERVAL);
} catch (Throwable ignored) {
}
}
}

//执行重试
private static boolean retry(/*not null*/IRetryTask task) {
if (task.shouldTryAtNow()) {
return task.tryOnce();
}
return false;
}

/**
* 提交任务。在当前线程立刻执行,如果失败,则使用设置的重试任务工厂创建包装对象,把这个对象写入重试队列等待异步重试。
*
* @param segments 分段执行任务
* @param <V> 结果返回类型
* @return 如果当前线程一次就执行成功,同步返回结果值,否则加入重试队列,异步通知结果值。
* @throws RetryRefuseException 当超过重试队列允许容纳的数量时,将抛出异常
*/
public final <V> V submit(List<Repeatable<V>> segments) throws RetryRefuseException {
if (segments == null || segments.size() == 0) {
return null;
}

IRetryTask<V> task = retryTaskFactory.createRetryTask(segments);

//在当前线程执行
if(!task.tryOnce()){
//失败后加入队列
ensureCapacity();
retryTaskList.push(task);
}

//只要当前已经有执行结果,就返回,即便是加入了重试队列
return task.getResult();
}

/**
* 提交任务。在当前线程立刻执行,如果失败,则使用设置的重试任务工厂创建包装对象,把这个对象写入重试队列等待异步重试。
*
* @param repeatable 执行任务
* @param <V> 结果返回类型
* @return 如果当前线程一次就执行成功,同步返回结果值,否则加入重试队列,异步通知结果值。
* @throws RetryRefuseException 当超过重试队列允许容纳的数量时,将抛出异常
*/
public final <V> V submit(Repeatable<V> repeatable) throws RetryRefuseException {
return submit(List.of(repeatable));
}

//确保容量
private void ensureCapacity() throws RetryRefuseException {
//非线程安全,高并发下可能短暂冲破最大容量,不过问题不大
if (retryTaskList.size() >= RetryConstants.MAX_QUEUE_SIZE) {
throw RetryRefuseException.getInstance();
}
}

/**
* 队列是否为空
*
* @return 如果当前无正在执行的任务,返回true
*/
public boolean isEmpty() {
return retryTaskList.isEmpty();
}
}

单元测试:

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
java复制代码class RetryQueueTest {
private final static int NUM = 100000;
private List<String> messages1 = Collections.synchronizedList(new ArrayList<>());
private List<String> messages2 = Collections.synchronizedList(new ArrayList<>());

IRetryTaskFactory taskFactory = new IRetryTaskFactory() {
@Override
public <V> IRetryTask createRetryTask(List<Repeatable<V>> segments) {
return new SegmentRetryTask<>(new DefinedRetryWaitStrategy(0), 10, segments);
}
};

RetryQueue retryQueue = new RetryQueue(taskFactory);

@Test
void submit() {
List<Repeatable<String>> list = new ArrayList<>();
list.add(new Executable<>() {
@Override
public void execute(int repeatTimes) throws Exception {
if (repeatTimes < 4)
throw new Exception();
messages1.add("good");
}
});

//模拟高并发提交
ExecutorService executorService = Executors.newFixedThreadPool(100);
Semaphore semaphore = new Semaphore(0);
for (int i = 0; i < NUM; i++) {
executorService.submit(() -> {
try {
retryQueue.submit(list);
} catch (RetryRefuseException e) {
fail();
}
semaphore.release();
});
}

executorService.shutdown();

//等待执行完成
try {
semaphore.acquire(NUM);
} catch (InterruptedException e) {
e.printStackTrace();
}

//等待执行完成
while (!retryQueue.isEmpty()) Thread.yield();
assertEquals(NUM, messages1.size());
for (String s : messages1) {
assertEquals(s, "good");
}
}
}

久等的点赞实现代码

好了,轮子已经造完了,可以开始写点赞服务的代码了:

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
java复制代码/**
* 投票服务
*/
@Service
@Slf4j
public class VoteService {
private final VoteBox voteBox;
private final MessagePoster mq;
private final RetryQueue retryQueue = new RetryQueue(new SegmentRetryTaskFactory());

public VoteService(VoteBox voteBox, MessagePoster mq) {
this.voteBox = voteBox;
this.mq = mq;
}

/**
* 给评价投票(点赞)
*
* @param voterId 投票人
* @param contentId 投票目标内容id
* @param voting 是否进行点赞(true:点赞 false:取消点赞)
* @return 当前内容点赞后的总数,如果点赞失败,抛出异常
* @throws VoteException 投票异常
*/
public int vote(long voterId, long contentId, boolean voting) throws VoteException {
/*
* 第零种情况:用户请求没有发送到服务器,用户可以适时重试。
* 第一种情况:执行1失败,最终点赞失败,记录日志,加入重试队列池,用户也可以适时重试。
* 第二种情况:执行1成功,但返回时网络异常,最终点赞失败,记录日志,加入重试队列池,用户也可能适时重试,该方法是幂等的。
* 第三种情况:执行1成功,但并未增加点赞总数,因为这次是重复提交。仍然执行之后的逻辑,该方法是幂等的。
* 第四种情况:执行1成功,但执行2失败,记录日志,把发送mq加入重试队列池,返回成功。
* 第五种情况:执行方法成功,但返回过程网络异常,用户未收到响应,用户稍后可以查询出点赞结果,用户也可以适时重试
*/

List<Repeatable<Integer>> list = new ArrayList<>();

//1.先在redis中投票
list.add(new Computable<>() {
@Override
public Integer compute(int repeatTimes) {
return voting ? voteBox.vote(voterId, contentId) : voteBox.noVote(voterId, contentId);
}

@Override
public void recordFail(int attemptTimes, Exception e) {
//只记录第一次错误
if (attemptTimes == 0)
log.warn("function VoteService.vote.redis make exception:{} by:{},{},{}", e.getMessage(), voterId, contentId, voting);
}

@Override
public void recordEnd(Exception e) {
//放弃重试.当然,日志会记录下来,或者通过其他机制将失败记录到中央存储库中,最终还是可以恢复。
log.warn("function VoteService.vote.redis quit:{} by:{},{},{}", e.getMessage(), voterId, contentId, voting);
}
});

//2.再通知mq
list.add(new Executable<>() {
@Override
public void execute(int repeatTimes, Integer receiveValue) {
JSONObject object = new JSONObject();
object.put("voterId", voterId);
object.put("contentId", contentId);
object.put("voting", voting ? 1 : 0);
object.put("votes", receiveValue);
mq.sendMessage(SystemConstants.VOTE_TOPIC, object.toString());
}

@Override
public void recordFail(int attemptTimes, Exception e) {
if (attemptTimes == 0)
log.warn("function VoteService.vote.mq make exception:{} by:{},{},{}", e.getMessage(), voterId, contentId, voting);
}

@Override
public void recordEnd(Exception e) {
log.trace("function VoteService.vote.mq quit:{} by:{},{},{}", e.getMessage(), voterId, contentId, voting);
}
});

Integer value = null;
try {
//系统可能因为mq或者redis自身的过载等问题导致点赞失败,我们想珍惜用户的一次点赞,所以选择为他重试。
value = retryQueue.submit(list);
} catch (RetryRefuseException e) {
log.error("function VoteService.vote.refuse make exception:{} by:{},{},{}", e.getMessage(), voterId, contentId, voting);
}

if (value == null){
//当前无法获得投票总数,意味着点赞操作失败,虽然我们会稍后重试,但仍将这个信息告知用户,他们可以进行更理智的选择。
throw new VoteException("投票失败,请稍后再试");
}

return value;
}

private static class SegmentRetryTaskFactory implements IRetryTaskFactory {
private final static IRetryStrategy waitStrategy = new SmartRetryStrategy(new int[]{10,100,100,1000,10000});

@Override
public <V> IRetryTask<V> createRetryTask(List<Repeatable<V>> segments) {
return new SegmentRetryTask<>(waitStrategy, 5, segments);
}
}
}

补充说明:

  1. 封装工厂对象的目的是为了简化构造方法参数,并且复用不变对象,如重试策略。
  2. 只要重试队列执行有返回结果,哪怕只是部分成功,仍可以算作接口响应成功,剩余部分加入重试队列。
  3. 如果重试队列执行全部失败,没有返回结果,则抛出异常,毕竟此刻确实失败了,用户有权知道。
  4. 只有熔断器闭合时,才会执行任务,否则将会一直等待,可以设置恰当的中止策略来完善这个机制。
  5. 重试队列这个轮子在其他很多场景也都有用武之地,依照我的理解,它大致算是“仓库层”。

但就点赞实现来说,没有必要使用重试,实际上,mq是多节点高可用的,一般不会出现问题,并且,mq自带了重试功能。mq的重试机制是,在一次请求中,如果失败了,立刻向另外的broker发起请求,是一种负载均衡融合高可用的设计。在不要求刚性事务的情景下,可以认为mq是可靠的。

给评价添加点赞

评价列表的数据是相对静态的,不含用户个性化信息,可以很容易地缓存供所有人访问,但是一旦加上用户对每个评价的点赞关系,或是实时变化的点赞数量信息,就变得难以缓存了。我们选择动静分离,静态的数据按照原先的缓存策略不变,动态的数据专门从redis服务中获取,然后再追加到静态数据上。

服务层、控制层,就是数据的聚合层、任务的委派层。

而至于数据聚合,有三种模式:

image-20210416110925640

我们选择第三种方式,这次设计点赞功能,只是作为评价系统的一部分。

在RemarkService中添加如下代码:

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
java复制代码/**
* 给评价列表添加点赞信息,在现有列表数据上修改
* @param remarks 评价列表
* @param consumerId 用户id
* @return 修改后的评价列表
*/
public JSONArray appendVoteInfo(JSONArray remarks, Integer consumerId){
if (remarks == null || remarks.size() == 0) {
return remarks;
}

//获取评价id列表
List<Object> idList = new ArrayList<>();
for (int i = 0; i < remarks.size(); i++) {
idList.add(remarks.getJSONObject(i).getString("id"));
}

//获取并添加点赞总数
List<String> voteKeys = new ArrayList<>();
for (Object s : idList) {
voteKeys.add(MessageFormat.format(RedisKeyConstants.VOTE_SUM_PATTERN, s));
}
List<Object> voteValues = redisRepository.readAll(voteKeys);
for (int i = 0; i < remarks.size(); i++) {
remarks.getJSONObject(i).put("votes", voteValues.get(i) == null ? 0 : voteValues.get(i));
}

//未传用户id,查询时不附带个人点赞数据
if (consumerId == null) {
return remarks;
}

//获取并添加个人点赞状态
List<String> votesKeys = new ArrayList<>();
for (Object s : idList) {
votesKeys.add(MessageFormat.format(RedisKeyConstants.VOTE_USER_PATTERN, consumerId, s));
}
List<Object> votingValues = redisRepository.readAll(votesKeys);
for (int i = 0; i < remarks.size(); i++) {
remarks.getJSONObject(i).put("voting", votingValues.get(i) == null ? 0 : 1);
}

return remarks;
}

//更新商品的评价缓存
private void updateRemarkCache(String itemId){
//吞掉异常,让更新评价方法不影响原操作的执行结果
try {
redisRepository.refreshKeys(RedisKeyConstants.REMARK_PREFIX + itemId);
} catch (Exception e) {
log.warn("function RemarkService.updateRemarkCache make exception:{} by:{}", e.getMessage(), itemId);
}
}

修改查询评价列表接口,聚合内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码/**
* 查询商品关联的评价,一次查询固定的条目
* @param itemId 商品id
* @param curIndex 当前查询坐标
*/
@GetMapping("/remark")
public APIBody listRemarks(String itemId, int curIndex, Integer consumerId){
Assert.isTrue(!StringUtils.isEmpty(itemId), "商品id不能为空");
Assert.isTrue(curIndex > 0, "查询坐标异常");

JSONArray list = remarkService.listRemarks(itemId, curIndex, SystemConstants.REMARK_MAX_LIST_LENGTH);

//原列表是从redis或db中读取的静态数据,而点赞数据每时每刻都在变化,分开获取这两个部分。
return APIBody.buildSuccess(remarkService.appendVoteInfo(list, consumerId));
}

优化点:评价的点赞总数信息是固定的,是用户无关的,可以与评价内容结合在一起缓存在内存中,而用户的点赞信息只能每次请求都去redis查询。

推荐优质评价

完整的评价系统应该能够输出一个优质评价内容的推荐列表,作为用户查看商品评价时的默认展示。

何为”优质内容“呢?我的理解是具有话题性、高热度、内容丰富的评价内容,其中”点赞总数“是衡量高热度的重要指标之一。当前,我们就以点赞数量为唯一指标,算出优质内容并提供查询接口。未来引入其他指标时,也可能会继续沿用这种设计思路。

评价表中有votes字段,可以据此排序生成前n条数据:

1
sql复制代码select id,consumer_id,order_id,score,header,content,images,user_name,user_face,gmt_create from remark where item_id = ? and status = '1' order by votes desc limit ?

需要注意的是,votes字段并不随着用户点赞而更新它,因为频繁的更新是低效的。可以通过定期汇总的方式来更新votes字段,点赞表保存着评价的最新点赞总数,所以可以每隔1天或1小时,筛选这期间内对应内容的最近一条点赞,就可以更新votes了。

不管基础数据是在何种数据库何种表中,不管是通过什么方式,我都将这一步骤称为”回源“,回源是缓存未命中时的一种行为概念。

在加载推荐评价时,回源算法为

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 List<Remark> listRecommendRemarks(/*not null*/ String itemId, int expectCount){
if (expectCount <= 0)
return new ArrayList<>();

Assert.isTrue(expectCount <= MAX_LIST_SIZE, "不允许一次性查询过多内容");

String sql = "select id,consumer_id,order_id,score,header,content,images,user_name,user_face,gmt_create from remark where item_id = ? and status = '1' order by votes desc limit ?";
return db.query(sql, (resultSet, i) -> {
Remark remark = new Remark();
remark.setId(resultSet.getLong(1));
remark.setConsumerId(resultSet.getLong(2));
remark.setOrderId(resultSet.getString(3));
remark.setItemId(itemId);
remark.setScore(resultSet.getShort(4));
remark.setHeader(resultSet.getString(5));
remark.setContent(resultSet.getString(6));
remark.setImages(resultSet.getString(7));
remark.setUsername(resultSet.getString(8));
remark.setUserface(resultSet.getString(9));
remark.setCreateTime(resultSet.getString(10));
return remark;
}, itemId, expectCount);
}

接下来所要做的,只要将这部分内容保存到缓存,然后输出就可以了。

原子性地替换列表

推荐评价是一个列表,我选择使用Redis的LIST数据类型,可以方便地进行范围查询,参考上篇文章的评价列表。

但是Redis并未直接提供替换列表的操作,只有DEL、LRPUSH、RENAME等命令组合在一起可以才能实现,但客户端的组合操作是非原子性的,不用多说,又要使用脚本了:

1
2
3
4
5
6
7
8
9
10
11
12
13
lua复制代码--删除并创建列表
--params 1 2
--KEYS 列表键名 代理键
--ARGV 列表

redis.call('DEL', KEYS[1])
for i= 1, #ARGV do
redis.call('RPUSH', KEYS[1], ARGV[i])
end

--延长代理锁的过期时间
redis.call('SET', KEYS[2], 1)
redis.call('EXPIRE',KEYS[2], 3600)

查询推荐评价的主要代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
java复制代码@Cacheable(value = "recommend")
public JSONArray listRecommendRemarks(/*not null*/ String itemId, int start, int stop) {
try {
if (remarkRedis.shouldUpdateRecommend(itemId)) {
//加锁成功,需要加载数据库中的评价内容到redis
remarkQueue.push(itemId, () -> reloadRecommendRemarks(itemId));
}

return appendVoteInfo(remarkRedis.readRecommendRange(itemId, start, stop));
} catch (Exception e) {
log.error("function RemarkService.listRecommendRemarks make exception:{} by:{},{},{}", e.getMessage(), itemId, start, stop);
return SystemConstants.EMPTY_ARRAY;
}
}

其中,仍使用代理键的模式,使Redis存储主要业务数据的列表永不过期,避免缓存击穿以及频繁的分布式阻塞加锁。

一些重要的redis操作代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
java复制代码//保存推荐内容并重置过期时间
public void saveRecommendData(String itemId, /*not null*/ List<Remark> list) {
String[] argv = new String[list.size()];
for (int i = 0; i < list.size(); i++) {
argv[i] = JSONObject.fromObject(list.get(i)).toString();
}
redisTemplate.execute(resetListScript,
List.of(RedisKeyConstants.REMARK_RECOMMEND_PREFIX + itemId,
RedisKeyConstants.REMARK_RECOMMEND_PROXY_PREFIX + itemId), argv);
}

//读取推荐内容
public JSONArray readRecommendRange(String itemId, int start, int stop) {
String key = RedisKeyConstants.REMARK_RECOMMEND_PREFIX + itemId;
return range(start, stop, key);
}

//是否应该更新推荐
public boolean shouldUpdateRecommend(String itemId) {
Boolean flag = redisTemplate.opsForValue().setIfAbsent(RedisKeyConstants.REMARK_RECOMMEND_PROXY_PREFIX + itemId);
return flag == null || !flag;
}

冷启动与空数据

冷启动是指服务的第一次上线或者redis在零缓存下重新启动时,这时,任何的缓存都未加载,或者之前加载过,现在因为意外已经不存在了。这时,代理锁会过期,SETNX命令成功,加锁成功的线程会同步数据库数据到redis,这样业务数据KEY就不再为空了。如果同步过程出现失败,锁会在2秒后自动过期,新的线程会继续接任这项未完成的使命。如果业务数据加载完成,那么就随即延迟代理锁的寿命为1小时,这样1小时之后才会触发同步。整个流程是异步的,用户请求的线程只会读取业务数据KEY,有则返回,无则为空。也就是说,接口只在冷启动的几秒内是返回为空的,这是可以接受的,因为冷启动只在新业务上线或者redis内存无法恢复这些极为特殊的时间点才会出现。

空数据是指数据库的内容是原本就是空的。根据上面的设计思路,可以得出结论,如果数据库内容为空,那么业务数据KEY是空的,也就是nil,不存储占位符,因为代理KEY已经起到占位符的作用了。这一点来看,一个简简单单的代理KEY,可以起到防止缓存击穿、防止同步阻塞、占位符等作用。

后续

可能会更新一些抽奖、秒杀活动的实现方法。

本文转载自: 掘金

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

Java双刃剑之Unsafe类详解

发表于 2021-04-30

前一段时间在研究juc源码的时候,发现在很多工具类中都调用了一个Unsafe类中的方法,出于好奇就想要研究一下这个类到底有什么作用,于是先查阅了一些资料,一查不要紧,很多资料中对Unsafe的态度都是这样的画风:

0hh.jpg

其实看到这些说法也没什么意外,毕竟Unsafe这个词直译过来就是“不安全的”,从名字里我们也大概能看来Java的开发者们对它有些不放心。但是作为一名极客,不能你说不安全我就不去研究了,毕竟只有了解一项技术的风险点,才能更好的避免出现这些问题嘛。

下面我们言归正传,先通过简单的介绍来对Unsafe类有一个大致的了解。Unsafe类是一个位于sun.misc包下的类,它提供了一些相对底层方法,能够让我们接触到一些更接近操作系统底层的资源,如系统的内存资源、cpu指令等。而通过这些方法,我们能够完成一些普通方法无法实现的功能,例如直接使用偏移地址操作对象、数组等等。但是在使用这些方法提供的便利的同时,也存在一些潜在的安全因素,例如对内存的错误操作可能会引起内存泄漏,严重时甚至可能引起jvm崩溃。因此在使用Unsafe前,我们必须要了解它的工作原理与各方法的应用场景,并且在此基础上仍需要非常谨慎的操作,下面我们正式开始对Unsafe的学习。

Unsafe 基础

首先我们来尝试获取一个Unsafe实例,如果按照new的方式去创建对象,不好意思,编译器会报错提示你:

1
properties复制代码Unsafe() has private access in 'sun.misc.Unsafe'

查看Unsafe类的源码,可以看到它被final修饰不允许被继承,并且构造函数为private类型,即不允许我们手动调用构造方法进行实例化,只有在static静态代码块中,以单例的方式初始化了一个Unsafe对象:

1
2
3
4
5
6
7
8
9
10
java复制代码public final class Unsafe {
private static final Unsafe theUnsafe;
...
private Unsafe() {
}
...
static {
theUnsafe = new Unsafe();
}
}

在Unsafe类中,提供了一个静态方法getUnsafe,看上去貌似可以用它来获取Unsafe实例:

1
2
3
4
5
6
7
8
9
java复制代码@CallerSensitive
public static Unsafe getUnsafe() {
Class var0 = Reflection.getCallerClass();
if (!VM.isSystemDomainLoader(var0.getClassLoader())) {
throw new SecurityException("Unsafe");
} else {
return theUnsafe;
}
}

但是如果我们直接调用这个静态方法,会抛出异常:

1
2
3
java复制代码Exception in thread "main" java.lang.SecurityException: Unsafe
at sun.misc.Unsafe.getUnsafe(Unsafe.java:90)
at com.cn.test.GetUnsafeTest.main(GetUnsafeTest.java:12)

这是因为在getUnsafe方法中,会对调用者的classLoader进行检查,判断当前类是否由Bootstrap classLoader加载,如果不是的话那么就会抛出一个SecurityException异常。也就是说,只有启动类加载器加载的类才能够调用Unsafe类中的方法,来防止这些方法在不可信的代码中被调用。

那么,为什么要对Unsafe类进行这么谨慎的使用限制呢,说到底,还是因为它实现的功能过于底层,例如直接进行内存操作、绕过jvm的安全检查创建对象等等,概括的来说,Unsafe类实现功能可以被分为下面8类:

创建实例

看到上面的这些功能,你是不是已经有些迫不及待想要试一试了。那么如果我们执意想要在自己的代码中调用Unsafe类的方法,应该怎么获取一个它的实例对象呢,答案是利用反射获得Unsafe类中已经实例化完成的单例对象:

1
2
3
4
5
6
7
java复制代码public static Unsafe getUnsafe() throws IllegalAccessException {
Field unsafeField = Unsafe.class.getDeclaredField("theUnsafe");
//Field unsafeField = Unsafe.class.getDeclaredFields()[0]; //也可以这样,作用相同
unsafeField.setAccessible(true);
Unsafe unsafe =(Unsafe) unsafeField.get(null);
return unsafe;
}

在获取到Unsafe的实例对象后,我们就可以使用它为所欲为了,先来尝试使用它对一个对象的属性进行读写:

1
2
3
4
5
6
7
8
java复制代码public void fieldTest(Unsafe unsafe) throws NoSuchFieldException {
User user=new User();
long fieldOffset = unsafe.objectFieldOffset(User.class.getDeclaredField("age"));
System.out.println("offset:"+fieldOffset);
unsafe.putInt(user,fieldOffset,20);
System.out.println("age:"+unsafe.getInt(user,fieldOffset));
System.out.println("age:"+user.getAge());
}

运行代码输出如下,可以看到通过Unsafe类的objectFieldOffset方法获取了对象中字段的偏移地址,这个偏移地址不是内存中的绝对地址而是一个相对地址,之后再通过这个偏移地址对int类型字段的属性值进行了读写操作,通过结果也可以看到Unsafe的方法和类中的get方法获取到的值是相同的。

1
2
3
properties复制代码offset:12
age:20
age:20

在上面的例子中调用了Unsafe类的putInt和getInt方法,看一下源码中的方法:

1
2
java复制代码public native int getInt(Object o, long offset);
public native void putInt(Object o, long offset, int x);

先说作用,getInt用于从对象的指定偏移地址处读取一个int,putInt用于在对象指定偏移地址处写入一个int,并且即使类中的这个属性是private私有类型的,也可以对它进行读写。但是有细心的小伙伴可能发现了,这两个方法相对于我们平常写的普通方法,多了一个native关键字修饰,并且没有具体的方法逻辑,那么它是怎么实现的呢?

native方法

在java中,这类方法被称为native方法(Native Method),简单的说就是由java调用非java代码的接口,被调用的方法是由非java 语言实现的,例如它可以由C或C++语言来实现,并编译成DLL,然后直接供java进行调用。native方法是通过JNI(Java Native Interface)实现调用的,从 java1.1开始 JNI 标准就是java平台的一部分,它允许java代码和其他语言的代码进行交互。

Unsafe类中的很多基础方法都属于native方法,那么为什么要使用native方法呢?原因可以概括为以下几点:

  • 需要用到 java 中不具备的依赖于操作系统的特性,java在实现跨平台的同时要实现对底层的控制,需要借助其他语言发挥作用
  • 对于其他语言已经完成的一些现成功能,可以使用java直接调用
  • 程序对时间敏感或对性能要求非常高时,有必要使用更加底层的语言,例如C/C++甚至是汇编

在juc包的很多并发工具类在实现并发机制时,都调用了native方法,通过它们打破了java运行时的界限,能够接触到操作系统底层的某些功能。对于同一个native方法,不同的操作系统可能会通过不同的方式来实现,但是对于使用者来说是透明的,最终都会得到相同的结果,至于java如何实现的通过JNI调用其他语言的代码,不是本文的重点,会在后续的文章中具体学习。

Unsafe 应用

在对Unsafe的基础有了一定了解后,我们来看一下它的基本应用。由于篇幅有限,不能对所有方法进行介绍,如果大家有学习的需要,可以下载openJDK的源码进行学习。

1、内存操作

如果你是一个写过c或者c++的程序员,一定对内存操作不会陌生,而在java中是不允许直接对内存进行操作的,对象内存的分配和回收都是由jvm自己实现的。但是在Unsafe中,提供的下列接口可以直接进行内存操作:

1
2
3
4
5
6
7
8
9
10
java复制代码//分配新的本地空间
public native long allocateMemory(long bytes);
//重新调整内存空间的大小
public native long reallocateMemory(long address, long bytes);
//将内存设置为指定值
public native void setMemory(Object o, long offset, long bytes, byte value);
//内存拷贝
public native void copyMemory(Object srcBase, long srcOffset,Object destBase, long destOffset,long bytes);
//清除内存
public native void freeMemory(long address);

使用下面的代码进行测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java复制代码private void memoryTest() {
int size = 4;
long addr = unsafe.allocateMemory(size);
long addr3 = unsafe.reallocateMemory(addr, size * 2);
System.out.println("addr: "+addr);
System.out.println("addr3: "+addr3);
try {
unsafe.setMemory(null,addr ,size,(byte)1);
for (int i = 0; i < 2; i++) {
unsafe.copyMemory(null,addr,null,addr3+size*i,4);
}
System.out.println(unsafe.getInt(addr));
System.out.println(unsafe.getLong(addr3));
}finally {
unsafe.freeMemory(addr);
unsafe.freeMemory(addr3);
}
}

先看结果输出:

1
2
3
4
shell复制代码addr: 2433733895744
addr3: 2433733894944
16843009
72340172838076673

分析一下运行结果,首先使用allocateMemory方法申请4字节长度的内存空间,在循环中调用setMemory方法向每个字节写入内容为byte类型的1,当使用Unsafe调用getInt方法时,因为一个int型变量占4个字节,会一次性读取4个字节,组成一个int的值,对应的十进制结果为16843009,可以通过图示理解这个过程:

在代码中调用reallocateMemory方法重新分配了一块8字节长度的内存空间,通过比较addr和addr3可以看到和之前申请的内存地址是不同的。在代码中的第二个for循环里,调用copyMemory方法进行了两次内存的拷贝,每次拷贝内存地址addr开始的4个字节,分别拷贝到以addr3和addr3+4开始的内存空间上:

拷贝完成后,使用getLong方法一次性读取8个字节,得到long类型的值为72340172838076673。

需要注意,通过这种方式分配的内存属于堆外内存,是无法进行垃圾回收的,需要我们把这些内存当做一种资源去手动调用freeMemory方法进行释放,否则会产生内存泄漏。通用的操作内存方式是在try中执行对内存的操作,最终在finally块中进行内存的释放。

2、内存屏障

在介绍内存屏障前,需要知道编译器和CPU会在保证程序输出结果一致的情况下,会对代码进行重排序,从指令优化角度提升性能。而指令重排序可能会带来一个不好的结果,导致CPU的高速缓存和内存中数据的不一致,而内存屏障(Memory Barrier)就是通过组织屏障两边的指令重排序从而避免编译器和硬件的不正确优化情况。

在硬件层面上,内存屏障是CPU为了防止代码进行重排序而提供的指令,不同的硬件平台上实现内存屏障的方法可能并不相同。在java8中,引入了3个内存屏障的函数,它屏蔽了操作系统底层的差异,允许在代码中定义、并统一由jvm来生成内存屏障指令,来实现内存屏障的功能。Unsafe中提供了下面三个内存屏障相关方法:

1
2
3
4
5
6
java复制代码//禁止读操作重排序
public native void loadFence();
//禁止写操作重排序
public native void storeFence();
//禁止读、写操作重排序
public native void fullFence();

内存屏障可以看做对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。以loadFence方法为例,它会禁止读操作重排序,保证在这个屏障之前的所有读操作都已经完成,并且将缓存数据设为无效,重新从主存中进行加载。

看到这估计很多小伙伴们会想到volatile关键字了,如果在字段上添加了volatile关键字,就能够实现字段在多线程下的可见性。基于读内存屏障,我们也能实现相同的功能。下面定义一个线程方法,在线程中去修改flag标志位,注意这里的flag是没有被volatile修饰的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
java复制代码@Getter
class ChangeThread implements Runnable{
/**volatile**/ boolean flag=false;
@Override
public void run() {
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("subThread change flag to:" + flag);
flag = true;
}
}

在主线程的while循环中,加入内存屏障,测试是否能够感知到flag的修改变化:

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码public static void main(String[] args){
ChangeThread changeThread = new ChangeThread();
new Thread(changeThread).start();
while (true) {
boolean flag = changeThread.isFlag();
unsafe.loadFence(); //加入读内存屏障
if (flag){
System.out.println("detected flag changed");
break;
}
}
System.out.println("main thread end");
}

运行结果:

1
2
3
java复制代码subThread change flag to:false
detected flag changed
main thread end

而如果删掉上面代码中的loadFence方法,那么主线程将无法感知到flag发生的变化,会一直在while中循环。可以用图来表示上面的过程:

了解java内存模型(JMM)的小伙伴们应该清楚,运行中的线程不是直接读取主内存中的变量的,只能操作自己工作内存中的变量,然后同步到主内存中,并且线程的工作内存是不能共享的。上面的图中的流程就是子线程借助于主内存,将修改后的结果同步给了主线程,进而修改主线程中的工作空间,跳出循环。

3、对象操作

a、对象成员属性的内存偏移量获取,以及字段属性值的修改,在上面的例子中我们已经测试过了。除了前面的putInt、getInt方法外,Unsafe提供了全部8种基础数据类型以及Object的put和get方法,并且所有的put方法都可以越过访问权限,直接修改内存中的数据。阅读openJDK源码中的注释发现,基础数据类型和Object的读写稍有不同,基础数据类型是直接操作的属性值(value),而Object的操作则是基于引用值(reference value)。下面是Object的读写方法:

1
2
3
4
java复制代码//在对象的指定偏移地址获取一个对象引用
public native Object getObject(Object o, long offset);
//在对象指定偏移地址写入一个对象引用
public native void putObject(Object o, long offset, Object x);

除了对象属性的普通读写外,Unsafe还提供了volatile读写和有序写入方法。volatile读写方法的覆盖范围与普通读写相同,包含了全部基础数据类型和Object类型,以int类型为例:

1
2
3
4
java复制代码//在对象的指定偏移地址处读取一个int值,支持volatile load语义
public native int getIntVolatile(Object o, long offset);
//在对象指定偏移地址处写入一个int,支持volatile store语义
public native void putIntVolatile(Object o, long offset, int x);

相对于普通读写来说,volatile读写具有更高的成本,因为它需要保证可见性和有序性。在执行get操作时,会强制从主存中获取属性值,在使用put方法设置属性值时,会强制将值更新到主存中,从而保证这些变更对其他线程是可见的。

有序写入的方法有以下三个:

1
2
3
java复制代码public native void putOrderedObject(Object o, long offset, Object x);
public native void putOrderedInt(Object o, long offset, int x);
public native void putOrderedLong(Object o, long offset, long x);

有序写入的成本相对volatile较低,因为它只保证写入时的有序性,而不保证可见性,也就是一个线程写入的值不能保证其他线程立即可见。为了解决这里的差异性,需要对内存屏障的知识点再进一步进行补充,首先需要了解两个指令的概念:

  • Load:将主内存中的数据拷贝到处理器的缓存中
  • Store:将处理器缓存的数据刷新到主内存中

顺序写入与volatile写入的差别在于,在顺序写时加入的内存屏障类型为StoreStore类型,而在volatile写入时加入的内存屏障是StoreLoad类型,如下图所示:

在有序写入方法中,使用的是StoreStore屏障,该屏障确保Store1立刻刷新数据到内存,这一操作先于Store2以及后续的存储指令操作。而在volatile写入中,使用的是StoreLoad屏障,该屏障确保Store1立刻刷新数据到内存,这一操作先于Load2及后续的装载指令,并且,StoreLoad屏障会使该屏障之前的所有内存访问指令,包括存储指令和访问指令全部完成之后,才执行该屏障之后的内存访问指令。

综上所述,在上面的三类写入方法中,在写入效率方面,按照put、putOrder、putVolatile的顺序效率逐渐降低,

b、使用Unsafe的allocateInstance方法,允许我们使用非常规的方式进行对象的实例化,首先定义一个实体类,并且在构造函数中对其成员变量进行赋值操作:

1
2
3
4
5
6
7
java复制代码@Data
public class A {
private int b;
public A(){
this.b =1;
}
}

分别基于构造函数、反射以及Unsafe方法的不同方式创建对象进行比较:

1
2
3
4
5
6
7
8
java复制代码public void objTest() throws Exception{
A a1=new A();
System.out.println(a1.getB());
A a2 = A.class.newInstance();
System.out.println(a2.getB());
A a3= (A) unsafe.allocateInstance(A.class);
System.out.println(a3.getB());
}

打印结果分别为1、1、0,说明通过allocateInstance方法创建对象过程中,不会调用类的构造方法。使用这种方式创建对象时,只用到了Class对象,所以说如果想要跳过对象的初始化阶段或者跳过构造器的安全检查,就可以使用这种方法。在上面的例子中,如果将A类的构造函数改为private类型,将无法通过构造函数和反射创建对象,但allocateInstance方法仍然有效。

4、数组操作

在Unsafe中,可以使用arrayBaseOffset方法可以获取数组中第一个元素的偏移地址,使用arrayIndexScale方法可以获取数组中元素间的偏移地址增量。使用下面的代码进行测试:

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码private void arrayTest() {
String[] array=new String[]{"str1str1str","str2","str3"};
int baseOffset = unsafe.arrayBaseOffset(String[].class);
System.out.println(baseOffset);
int scale = unsafe.arrayIndexScale(String[].class);
System.out.println(scale);

for (int i = 0; i < array.length; i++) {
int offset=baseOffset+scale*i;
System.out.println(offset+" : "+unsafe.getObject(array,offset));
}
}

上面代码的输出结果为:

1
2
3
4
5
properties复制代码16
4
16 : str1str1str
20 : str2
24 : str3

通过配合使用数组偏移首地址和各元素间偏移地址的增量,可以方便的定位到数组中的元素在内存中的位置,进而通过getObject方法直接获取任意位置的数组元素。需要说明的是,arrayIndexScale获取的并不是数组中元素占用的大小,而是地址的增量,按照openJDK中的注释,可以将它翻译为元素寻址的转换因子(scale factor for addressing elements)。在上面的例子中,第一个字符串长度为11字节,但其地址增量仍然为4字节。

那么,基于这两个值是如何实现的寻址和数组元素的访问呢,这里需要借助一点在前面的文章中讲过的Java对象内存布局的知识,先把上面例子中的String数组对象的内存布局画出来,就很方便大家理解了:

在String数组对象中,对象头包含3部分,mark word标记字占用8字节,klass point类型指针占用4字节,数组对象特有的数组长度部分占用4字节,总共占用了16字节。第一个String的引用类型相对于对象的首地址的偏移量是就16,之后每个元素在这个基础上加4,正好对应了我们上面代码中的寻址过程,之后再使用前面说过的getObject方法,通过数组对象可以获得对象在堆中的首地址,再配合对象中变量的偏移量,就能获得每一个变量的引用。

5、CAS操作

在juc包的并发工具类中大量地使用了CAS操作,像在前面介绍synchronized和AQS的文章中也多次提到了CAS,其作为乐观锁在并发工具类中广泛发挥了作用。在Unsafe类中,提供了compareAndSwapObject、compareAndSwapInt、compareAndSwapLong方法来实现的对Object、int、long类型的CAS操作。以compareAndSwapInt方法为例:

1
java复制代码public final native boolean compareAndSwapInt(Object o, long offset,int expected,int x);

参数中o为需要更新的对象,offset是对象o中整形字段的偏移量,如果这个字段的值与expected相同,则将字段的值设为x这个新值,并且此更新是不可被中断的,也就是一个原子操作。下面是一个使用compareAndSwapInt的例子:

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复制代码private volatile int a;
public static void main(String[] args){
CasTest casTest=new CasTest();
new Thread(()->{
for (int i = 1; i < 5; i++) {
casTest.increment(i);
System.out.print(casTest.a+" ");
}
}).start();
new Thread(()->{
for (int i = 5 ; i <10 ; i++) {
casTest.increment(i);
System.out.print(casTest.a+" ");
}
}).start();
}

private void increment(int x){
while (true){
try {
long fieldOffset = unsafe.objectFieldOffset(CasTest.class.getDeclaredField("a"));
if (unsafe.compareAndSwapInt(this,fieldOffset,x-1,x))
break;
} catch (NoSuchFieldException e) {
e.printStackTrace();
}
}
}

运行代码会依次输出:

1
shell复制代码1 2 3 4 5 6 7 8 9

在上面的例子中,使用两个线程去修改int型属性a的值,并且只有在a的值等于传入的参数x减一时,才会将a的值变为x,也就是实现对a的加一的操作。流程如下所示:

需要注意的是,在调用compareAndSwapInt方法后,会直接返回true或false的修改结果,因此需要我们在代码中手动添加自旋的逻辑。在AtomicInteger类的设计中,也是采用了将compareAndSwapInt的结果作为循环条件,直至修改成功才退出死循环的方式来实现的原子性的自增操作。

6、线程调度

Unsafe类中提供了park、unpark、monitorEnter、monitorExit、tryMonitorEnter方法进行线程调度,在前面介绍AQS的文章中我们提到过使用LockSupport挂起或唤醒指定线程,看一下LockSupport的源码,可以看到它也是调用的Unsafe类中的方法:

1
2
3
4
5
6
7
8
9
10
java复制代码public static void park(Object blocker) {
Thread t = Thread.currentThread();
setBlocker(t, blocker);
UNSAFE.park(false, 0L);
setBlocker(t, null);
}
public static void unpark(Thread thread) {
if (thread != null)
UNSAFE.unpark(thread);
}

LockSupport的park方法调用了Unsafe的park方法来阻塞当前线程,此方法将线程阻塞后就不会继续往后执行,直到有其他线程调用unpark方法唤醒当前线程。下面的例子对Unsafe的这两个方法进行测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码public static void main(String[] args) {
Thread mainThread = Thread.currentThread();
new Thread(()->{
try {
TimeUnit.SECONDS.sleep(5);
System.out.println("subThread try to unpark mainThread");
unsafe.unpark(mainThread);
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();

System.out.println("park main mainThread");
unsafe.park(false,0L);
System.out.println("unpark mainThread success");
}

程序输出为:

1
2
3
properties复制代码park main mainThread
subThread try to unpark mainThread
unpark mainThread success

程序运行的流程也比较容易看懂,子线程开始运行后先进行睡眠,确保主线程能够调用park方法阻塞自己,子线程在睡眠5秒后,调用unpark方法唤醒主线程,使主线程能继续向下执行。整个流程如下图所示:

此外,Unsafe源码中monitor相关的三个方法已经被标记为deprecated,不建议被使用:

1
2
3
4
5
6
7
8
9
java复制代码//获得对象锁
@Deprecated
public native void monitorEnter(Object var1);
//释放对象锁
@Deprecated
public native void monitorExit(Object var1);
//尝试获得对象锁
@Deprecated
public native boolean tryMonitorEnter(Object var1);

monitorEnter方法用于获得对象锁,monitorExit用于释放对象锁,如果对一个没有被monitorEnter加锁的对象执行此方法,会抛出IllegalMonitorStateException异常。tryMonitorEnter方法尝试获取对象锁,如果成功则返回true,反之返回false。

7、Class操作

Unsafe对Class的相关操作主要包括类加载和静态变量的操作方法。

a、静态属性读取相关的方法:

1
2
3
4
5
6
java复制代码//获取静态属性的偏移量
public native long staticFieldOffset(Field f);
//获取静态属性的对象指针
public native Object staticFieldBase(Field f);
//判断类是否需要实例化(用于获取类的静态属性前进行检测)
public native boolean shouldBeInitialized(Class<?> c);

创建一个包含静态属性的类,进行测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
java复制代码@Data
public class User {
public static String name="Hydra";
int age;
}
private void staticTest() throws Exception {
User user=new User();
System.out.println(unsafe.shouldBeInitialized(User.class));
Field sexField = User.class.getDeclaredField("name");
long fieldOffset = unsafe.staticFieldOffset(sexField);
Object fieldBase = unsafe.staticFieldBase(sexField);
Object object = unsafe.getObject(fieldBase, fieldOffset);
System.out.println(object);
}

运行结果:

1
2
java复制代码false
Hydra

在Unsafe的对象操作中,我们学习了通过objectFieldOffset方法获取对象属性偏移量并基于它对变量的值进行存取,但是它不适用于类中的静态属性,这时候就需要使用staticFieldOffset方法。在上面的代码中,只有在获取Field对象的过程中依赖到了Class,而获取静态变量的属性时不再依赖于Class。

在上面的代码中首先创建一个User对象,这是因为如果一个类没有被实例化,那么它的静态属性也不会被初始化,最后获取的字段属性将是null。所以在获取静态属性前,需要调用shouldBeInitialized方法,判断在获取前是否需要初始化这个类。如果删除创建User对象的语句,运行结果会变为:

1
2
java复制代码true
null

b、使用defineClass方法允许程序在运行时动态地创建一个类,方法定义如下:

1
2
java复制代码public native Class<?> defineClass(String name, byte[] b, int off, int len,
ClassLoader loader,ProtectionDomain protectionDomain);

在实际使用过程中,可以只传入字节数组、起始字节的下标以及读取的字节长度,默认情况下,类加载器(ClassLoader)和保护域(ProtectionDomain)来源于调用此方法的实例。下面的例子中实现了反编译生成后的class文件的功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
java复制代码private static void defineTest() {
String fileName="F:\\workspace\\unsafe-test\\target\\classes\\com\\cn\\model\\User.class";
File file = new File(fileName);
try(FileInputStream fis = new FileInputStream(file)) {
byte[] content=new byte[(int)file.length()];
fis.read(content);
Class clazz = unsafe.defineClass(null, content, 0, content.length, null, null);
Object o = clazz.newInstance();
Object age = clazz.getMethod("getAge").invoke(o, null);
System.out.println(age);
} catch (Exception e) {
e.printStackTrace();
}
}

在上面的代码中,首先读取了一个class文件并通过文件流将它转化为字节数组,之后使用defineClass方法动态的创建了一个类,并在后续完成了它的实例化工作,流程如下图所示,并且通过这种方式创建的类,会跳过JVM的所有安全检查。

除了defineClass方法外,Unsafe还提供了一个defineAnonymousClass方法:

1
java复制代码public native Class<?> defineAnonymousClass(Class<?> hostClass, byte[] data, Object[] cpPatches);

使用该方法可以用来动态的创建一个匿名类,在Lambda表达式中就是使用ASM动态生成字节码,然后利用该方法定义实现相应的函数式接口的匿名类。在jdk15发布的新特性中,在隐藏类(Hidden classes)一条中,指出将在未来的版本中弃用Unsafe的defineAnonymousClass方法。

8、系统信息

Unsafe中提供的addressSize和pageSize方法用于获取系统信息,调用addressSize方法会返回系统指针的大小,如果在64位系统下默认会返回8,而32位系统则会返回4。调用pageSize方法会返回内存页的大小,值为2的整数幂。使用下面的代码可以直接进行打印:

1
2
3
4
java复制代码private void systemTest() {
System.out.println(unsafe.addressSize());
System.out.println(unsafe.pageSize());
}

执行结果:

1
2
java复制代码8
4096

这两个方法的应用场景比较少,在java.nio.Bits类中,在使用pageCount计算所需的内存页的数量时,调用了pageSize方法获取内存页的大小。另外,在使用copySwapMemory方法拷贝内存时,调用了addressSize方法,检测32位系统的情况。

总结

在本文中,我们首先介绍了Unsafe的基本概念、工作原理,并在此基础上,对它的API进行了说明与实践。相信大家通过这一过程,能够发现Unsafe在某些场景下,确实能够为我们提供编程中的便利。但是回到开头的话题,在使用这些便利时,确实存在着一些安全上的隐患,在我看来,一项技术具有不安全因素并不可怕,可怕的是它在使用过程中被滥用。尽管之前有传言说会在java9中移除Unsafe类,不过它还是照样已经存活到了jdk16,按照存在即合理的逻辑,只要使用得当,它还是能给我们带来不少的帮助,因此最后还是建议大家,在使用Unsafe的过程中一定要做到使用谨慎使用、避免滥用。

如果文章对您有所帮助,欢迎关注公众号 码农参上

本文转载自: 掘金

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

JUnit5学习之七:参数化测试(Parameterized

发表于 2021-04-30

欢迎访问我的GitHub

github.com/zq2599/blog…

内容:所有原创文章分类汇总及配套源码,涉及Java、Docker、Kubernetes、DevOPS等;

关于《JUnit5学习》系列

《JUnit5学习》系列旨在通过实战提升SpringBoot环境下的单元测试技能,一共八篇文章,链接如下:

  1. 基本操作
  2. Assumptions类
  3. Assertions类
  4. 按条件执行
  5. 标签(Tag)和自定义注解
  6. 参数化测试(Parameterized Tests)基础
  7. 参数化测试(Parameterized Tests)进阶
  8. 综合进阶(终篇)

本篇概览

  • 本文是《JUnit5学习》系列的第七篇,前文咱们对JUnit5的参数化测试(Parameterized Tests)有了基本了解,可以使用各种数据源控制测试方法多次执行,今天要在此基础上更加深入,掌握参数化测试的一些高级功能,解决实际问题;
  • 本文由以下章节组成:
  1. 自定义数据源
  2. 参数转换
  3. 多字段聚合
  4. 多字段转对象
  5. 测试执行名称自定义

源码下载

  1. 如果您不想编码,可以在GitHub下载所有源码,地址和链接信息如下表所示:
名称 链接 备注
项目主页 github.com/zq2599/blog… 该项目在GitHub上的主页
git仓库地址(https) github.com/zq2599/blog… 该项目源码的仓库地址,https协议
git仓库地址(ssh) git@github.com:zq2599/blog_demos.git 该项目源码的仓库地址,ssh协议
  1. 这个git项目中有多个文件夹,本章的应用在junitpractice文件夹下,如下图红框所示:

在这里插入图片描述

  1. junitpractice是父子结构的工程,本篇的代码在parameterized子工程中,如下图:

在这里插入图片描述

自定义数据源

  1. 前文使用了很多种数据源,如果您对它们的各种限制不满意,想要做更彻底的个性化定制,可以开发ArgumentsProvider接口的实现类,并使用@ArgumentsSource指定;
  2. 举个例子,先开发ArgumentsProvider的实现类MyArgumentsProvider.java:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
java复制代码package com.bolingcavalry.parameterized.service.impl;

import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.ArgumentsProvider;
import java.util.stream.Stream;

public class MyArgumentsProvider implements ArgumentsProvider {

@Override
public Stream<? extends Arguments> provideArguments(ExtensionContext context) throws Exception {
return Stream.of("apple4", "banana4").map(Arguments::of);
}
}
  1. 再给测试方法添加@ArgumentsSource,并指定MyArgumentsProvider:
1
2
3
4
5
6
7
java复制代码    @Order(15)
@DisplayName("ArgumentsProvider接口的实现类提供的数据作为入参")
@ParameterizedTest
@ArgumentsSource(MyArgumentsProvider.class)
void argumentsSourceTest(String candidate) {
log.info("argumentsSourceTest [{}]", candidate);
}
  1. 执行结果如下:

在这里插入图片描述

参数转换

  1. 参数化测试的数据源和测试方法入参的数据类型必须要保持一致吗?其实JUnit5并没有严格要求,而事实上JUnit5是可以做一些自动或手动的类型转换的;
  2. 如下代码,数据源是int型数组,但测试方法的入参却是double:
1
2
3
4
5
6
7
java复制代码    @Order(16)
@DisplayName("int型自动转为double型入参")
@ParameterizedTest
@ValueSource(ints = { 1,2,3 })
void argumentConversionTest(double candidate) {
log.info("argumentConversionTest [{}]", candidate);
}
  1. 执行结果如下,可见int型被转为double型传给测试方法(Widening Conversion):

在这里插入图片描述

  1. 还可以指定转换器,以转换器的逻辑进行转换,下面这个例子就是将字符串转为LocalDate类型,关键是@JavaTimeConversionPattern:
1
2
3
4
5
6
7
8
java复制代码    @Order(17)
@DisplayName("string型,指定转换器,转为LocalDate型入参")
@ParameterizedTest
@ValueSource(strings = { "01.01.2017", "31.12.2017" })
void argumentConversionWithConverterTest(
@JavaTimeConversionPattern("dd.MM.yyyy") LocalDate candidate) {
log.info("argumentConversionWithConverterTest [{}]", candidate);
}
  1. 执行结果如下:

在这里插入图片描述

字段聚合(Argument Aggregation)

  1. 来思考一个问题:如果数据源的每条记录有多个字段,测试方法如何才能使用这些字段呢?
  2. 回顾刚才的@CsvSource示例,如下图,可见测试方法用两个入参对应CSV每条记录的两个字段,如下所示:

在这里插入图片描述
3. 上述方式应对少量字段还可以,但如果CSV每条记录有很多字段,那测试方法岂不是要定义大量入参?这显然不合适,此时可以考虑JUnit5提供的字段聚合功能(Argument Aggregation),也就是将CSV每条记录的所有字段都放入一个ArgumentsAccessor类型的对象中,测试方法只要声明ArgumentsAccessor类型作为入参,就能在方法内部取得CSV记录的所有字段,效果如下图,可见CSV字段实际上是保存在ArgumentsAccessor实例内部的一个Object数组中:

在这里插入图片描述
4. 如下图,为了方便从ArgumentsAccessor实例获取数据,ArgumentsAccessor提供了获取各种类型的方法,您可以按实际情况选用:

在这里插入图片描述

  1. 下面的示例代码中,CSV数据源的每条记录有三个字段,而测试方法只有一个入参,类型是ArgumentsAccessor,在测试方法内部,可以用ArgumentsAccessor的getString、get等方法获取CSV记录的不同字段,例如arguments.getString(0)就是获取第一个字段,得到的结果是字符串类型,而arguments.get(2, Types.class)的意思是获取第二个字段,并且转成了Type.class类型:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码    @Order(18)
@DisplayName("CsvSource的多个字段聚合到ArgumentsAccessor实例")
@ParameterizedTest
@CsvSource({
"Jane1, Doe1, BIG",
"John1, Doe1, SMALL"
})
void argumentsAccessorTest(ArgumentsAccessor arguments) {
Person person = new Person();
person.setFirstName(arguments.getString(0));
person.setLastName(arguments.getString(1));
person.setType(arguments.get(2, Types.class));

log.info("argumentsAccessorTest [{}]", person);
}
  1. 上述代码执行结果如下图,可见通过ArgumentsAccessor能够取得CSV数据的所有字段:

在这里插入图片描述

更优雅的聚合

  1. 前面的聚合解决了获取CSV数据多个字段的问题,但依然有瑕疵:从ArgumentsAccessor获取数据生成Person实例的代码写在了测试方法中,如下图红框所示,测试方法中应该只有单元测试的逻辑,而创建Person实例的代码放在这里显然并不合适:

在这里插入图片描述
2. 针对上面的问题,JUnit5也给出了方案:通过注解的方式,指定一个从ArgumentsAccessor到Person的转换器,示例如下,可见测试方法的入参有个注解@AggregateWith,其值PersonAggregator.class就是从ArgumentsAccessor到Person的转换器,而入参已经从前面的ArgumentsAccessor变成了Person:

1
2
3
4
5
6
7
8
9
10
java复制代码    @Order(19)
@DisplayName("CsvSource的多个字段,通过指定聚合类转为Person实例")
@ParameterizedTest
@CsvSource({
"Jane2, Doe2, SMALL",
"John2, Doe2, UNKNOWN"
})
void customAggregatorTest(@AggregateWith(PersonAggregator.class) Person person) {
log.info("customAggregatorTest [{}]", person);
}
  1. PersonAggregator是转换器类,需要实现ArgumentsAggregator接口,具体的实现代码很简单,也就是从ArgumentsAccessor示例获取字段创建Person对象的操作:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
java复制代码package com.bolingcavalry.parameterized.service.impl;

import org.junit.jupiter.api.extension.ParameterContext;
import org.junit.jupiter.params.aggregator.ArgumentsAccessor;
import org.junit.jupiter.params.aggregator.ArgumentsAggregationException;
import org.junit.jupiter.params.aggregator.ArgumentsAggregator;

public class PersonAggregator implements ArgumentsAggregator {

@Override
public Object aggregateArguments(ArgumentsAccessor arguments, ParameterContext context) throws ArgumentsAggregationException {

Person person = new Person();
person.setFirstName(arguments.getString(0));
person.setLastName(arguments.getString(1));
person.setType(arguments.get(2, Types.class));

return person;
}
}
  1. 上述测试方法的执行结果如下:

在这里插入图片描述

进一步简化

  1. 回顾一下刚才用注解指定转换器的代码,如下图红框所示,您是否回忆起JUnit5支持自定义注解这一茬,咱们来把红框部分的代码再简化一下:

在这里插入图片描述
2. 新建注解类CsvToPerson.java,代码如下,非常简单,就是把上图红框中的@AggregateWith(PersonAggregator.class)搬过来了:

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码package com.bolingcavalry.parameterized.service.impl;

import org.junit.jupiter.params.aggregator.AggregateWith;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
@AggregateWith(PersonAggregator.class)
public @interface CsvToPerson {
}
  1. 再来看看上图红框中的代码可以简化成什么样子,直接用@CsvToPerson就可以将ArgumentsAccessor转为Person对象了:
1
2
3
4
5
6
7
8
9
10
java复制代码    @Order(20)
@DisplayName("CsvSource的多个字段,通过指定聚合类转为Person实例(自定义注解)")
@ParameterizedTest
@CsvSource({
"Jane3, Doe3, BIG",
"John3, Doe3, UNKNOWN"
})
void customAggregatorAnnotationTest(@CsvToPerson Person person) {
log.info("customAggregatorAnnotationTest [{}]", person);
}
  1. 执行结果如下,可见和@AggregateWith(PersonAggregator.class)效果一致:

在这里插入图片描述

测试执行名称自定义

  1. 文章最后,咱们来看个轻松的知识点吧,如下图红框所示,每次执行测试方法,IDEA都会展示这次执行的序号和参数值:

在这里插入图片描述

  1. 其实上述红框中的内容格式也可以定制,格式模板就是@ParameterizedTest的name属性,修改后的测试方法完整代码如下,可见这里改成了中文描述信息:
1
2
3
4
5
6
7
8
9
10
11
java复制代码    @Order(21)
@DisplayName("CSV格式多条记录入参(自定义展示名称)")
@ParameterizedTest(name = "序号 [{index}],fruit参数 [{0}],rank参数 [{1}]")
@CsvSource({
"apple3, 31",
"banana3, 32",
"'lemon3, lime3', 0x3A"
})
void csvSourceWithCustomDisplayNameTest(String fruit, int rank) {
log.info("csvSourceWithCustomDisplayNameTest, fruit [{}], rank [{}]", fruit, rank);
}
  1. 执行结果如下:

在这里插入图片描述

  • 至此,JUnit5的参数化测试(Parameterized)相关的知识点已经学习和实战完成了,掌握了这么强大的参数输入技术,咱们的单元测试的代码覆盖率和场景范围又可以进一步提升了;

你不孤单,欣宸原创一路相伴

  1. Java系列
  2. Spring系列
  3. Docker系列
  4. kubernetes系列
  5. 数据库+中间件系列
  6. DevOps系列

欢迎关注公众号:程序员欣宸

微信搜索「程序员欣宸」,我是欣宸,期待与您一同畅游Java世界…
github.com/zq2599/blog…

本文转载自: 掘金

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

1…677678679…956

开发者博客

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