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

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


  • 首页

  • 归档

  • 搜索

Android底部对话框神器BottomSheetDialo

发表于 2021-09-25

前言

在以前想让底部弹出一个对话框,一般的做法是继承Dialog,然后设置布局、设置位置、设置高宽,如果还想增加一个从下到上的动画,还需要新建一个动画文件,但是到后来,我们有了官方提供的BottomSheetDialogFragment、BottomSheetDialog、bottomSheetBehavior这几个类,几行代码就可以实现上述效果。

如下所示,就可以简单创建一个底部弹出的Dialog。

1
2
3
java复制代码var bottomSheetDialog = BottomSheetDialog(this)
bottomSheetDialog.setContentView(R.layout.dialog)
bottomSheetDialog.show()

录屏_选择区域_20210925170605.gif

还可以使用BottomSheetDialogFragment,BottomSheetDialogFragment继承自DialogFragment ,在需要的时候我们可以重写onCreateDialog方法,返回自定义的Dialog,他默认也是返回BottomSheetDialog,

1
2
3
4
5
6
7
8
9
java复制代码public class BottomSheetDialogFragment extends AppCompatDialogFragment {
private boolean waitingForDismissAllowingStateLoss;
@NonNull
@Override
public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
return new BottomSheetDialog(getContext(), getTheme());
}
.....
}

拖拽

如果视图中有很多东西要展示,默认可以只展示一部分,另一部分可以向上拖拽显示,这个具体是怎么计算的,可以继续往下看,在这里,只需要新建一个dialog布局,高度设置成match_parent即可。

1
2
3
4
5
6
7
8
9
java复制代码class MemberBottomSheetDialog : BottomSheetDialogFragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? = inflater.inflate(R.layout.dialog, container, false)
}

MemberBottomSheetDialog().show(supportFragmentManager,"TAG")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
xml复制代码
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout 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"
android:layout_width="match_parent"
android:background="#ffffff"
android:layout_height="match_parent">

<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">

</FrameLayout>
</RelativeLayout>

录屏_选择区域_20210925171341.gif

但是你会发现,只有当根布局是RelativeLayout的时候,内容才会显示出来。

修改高度

由于BottomSheetDialogFragment使用的是BottomSheetDialog,他默认的高度计算方式是parentHeight - parentWidth * 9 / 16,但是他对外提供了一个方法给我们,用来设置开始时候的状态,如下所示:

1
2
3
4
5
java复制代码override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
var bottomSheetDialog = BottomSheetDialog(context!!);
bottomSheetDialog.behavior.peekHeight=100;
return bottomSheetDialog
}

默认展开

还有个问题是,默认是不展开的,如果想展开,也就是全屏,可以设置state为BottomSheetBehavior.STATE_EXPANDED。

1
2
3
4
5
6
java复制代码override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

if (dialog is BottomSheetDialog) {
val behaviour = (dialog as BottomSheetDialog).behavior behaviour.state = BottomSheetBehavior.STATE_EXPANDED }
}

禁止拖拽

官方对setDraggable的解释是:设置是否可以通过拖动折叠/展开,禁用拖动时,应用程序需要实现自定义方式来展开/折叠对话框。

1
2
3
4
5
6
7
java复制代码override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val bottomSheetDialog = super.onCreateDialog(savedInstanceState) as BottomSheetDialog

bottomSheetDialog.behavior.isDraggable=false

return bottomSheetDialog
}

背景不变暗

默认情况下,弹出对话框时,会便暗,这其实加个样式就可以了。

1
2
3
xml复制代码<style name="myDialog" parent="Theme.MaterialComponents.BottomSheetDialog">
<item name="android:backgroundDimEnabled">false</item>
</style>
1
2
3
java复制代码override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return BottomSheetDialog(context!!, R.style.myDialog)
}

监听滚动

有时候还需要在向上拖拽时候做一些联动,就需要获取对话框滑动的值,可以通过behavior.addBottomSheetCallback来实现。

slideOffset的值是0-1之间,默认状态下是0,滑动到顶部的时候值是1,消失的时候值是-1,

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val bottomSheetDialog = super.onCreateDialog(savedInstanceState) as BottomSheetDialog
bottomSheetDialog.behavior.addBottomSheetCallback(object :
BottomSheetBehavior.BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) {
Log.i(TAG, "onStateChanged: ")
}
override fun onSlide(bottomSheet: View, slideOffset: Float) {
Log.i(TAG, "onSlide: ${slideOffset}")
}
})
return bottomSheetDialog
}

向上拖拽时候是怎么计算的?

这个问题很简单,他内部拖拽是通过ViewDragHelper方式来完成的,所以当我们不能向上拖拽的时候,通过ViewDragHelper有两种办法,一是在tryCaptureView中返回false,二是在clampViewPositionVertical中返回某个值,这个值返回一个最大的拖拽值,当我们拖拽到最顶部时候,就无法继续向上了。

上面我们说过,默认的高度是parentHeight - parentWidth * 9 / 16,但如果我们的View小于这个值,那么最终的高度是取最小的,也就是取这个View的值,这样的话,对话框中的内容已经就全部显示出来了,还要上移干什么?

但是如果我们的View高度大于这个值,他就会取parentHeight - parentWidth * 9 / 16这个值作为Dialog的高度,那么还可以上移多少空间?答案就是View的高度-parentHeight - parentWidth * 9 / 16。

比如在我的手机上,parentHeight是2159,那么如果View取1600,那么就还可以上移40px,具体实现方式是定义了fitToContentsOffset变量,这个值就是parentHeight - childHeight,也就是屏幕中空闲的空间,当向上拖拽的时候会触发clampViewPositionVertical方法,他返回的值作为新的垂直位置,所以当拖拽到顶部时候,会返回fitToContentsOffset。

BottomSheetBehavior实现底部Dialog

BottomSheetDialog中也是使用了BottomSheetBehavior来实现的,首先定义一个布局,注意根布局只能是CoordinatorLayout。

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"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout 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"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">

<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">

<Button
android:id="@+id/bt"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="打开" />
</LinearLayout>

<LinearLayout
android:background="@color/cardview_dark_background"
android:id="@+id/design_bottom_sheet1"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
app:behavior_hideable="true"
app:behavior_peekHeight="300dp"
app:elevation="6dp"
app:layout_behavior="@string/bottom_sheet_behavior"></LinearLayout>

</androidx.coordinatorlayout.widget.CoordinatorLayout>
1
2
3
4
5
6
7
8
9
java复制代码  var bottomSheetBehavior = BottomSheetBehavior.from(findViewById(R.id.design_bottom_sheet1));
bottomSheetBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
findViewById<View>(R.id.bt).setOnClickListener {
if (bottomSheetBehavior.state == BottomSheetBehavior.STATE_HIDDEN) {
bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED)
} else if (bottomSheetBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) {
bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN
}
}

本文转载自: 掘金

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

AOP的底层原理

发表于 2021-09-25

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

什么是AOP?

全称为Aspect Oriented Programming: 面向切面编程. 通过预编译方式和运行期动态代理的方式实现功能的一种技术.

利用AOP可以对业务逻辑的各个部分进行隔离, 从而使得业务逻辑各部分之间的耦合度降低, 提高程序的可重用性, 并且提高开发效率.

AOP作用

AOP可以做到在程序的运行期间, 不修改业务代码的情况下对方法进行功能的增强.

AOP优势

  1. AOP可以减少重复的代码
  2. AOP可以在很大程度上提高开发效率
  3. AOP编写出来的代码, 可以很方便的进行维护

AOP的实现原理

AOP的底层是通过spring提供的动态代理技术实现的. 在程序的运行期间, spring动态生成代理对象, 代理对象的方法在执行时就可以进行增强功能的介入, 从而完成目标对象方法的功能增强.

基于JDK的动态代理

目标接口

1
2
3
csharp复制代码 public interface TargetInterface {
     public void save();
 }

目标类

1
2
3
4
5
csharp复制代码 public class Target implements TargetInterface {
     public void save() {
         System.out.println("do...");
    }
 }

功能增强类

1
2
3
4
5
6
7
8
9
csharp复制代码 public class Advice {
     public void before() {
         System.out.println("前置增强");
    }
 ​
     public void after() {
         System.out.println("后置增强");
    }
 }

基于jdk的功能增强

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
scss复制代码 // 获取目标对象
 final Target target = new Target();
 ​
 // 获取增加对象
 final Advice advice = new Advice();
 ​
 TargetInterface ti = (TargetInterface) Proxy.newProxyInstance(
         target.getClass().getClassLoader(),
         target.getClass().getInterfaces(),
         new InvocationHandler() {
             public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
                 // 前置增强
                 advice.before();
                 Object invoke = method.invoke(target, args);
 ​
                 // 后置增强
                 advice.after();
                 return invoke;
            }
        }
 );
 ​
 // 调用方法
 ti.save();

基于cglib的动态代理

目标类

1
2
3
4
5
csharp复制代码 public class Target {
     public void save() {
         System.out.println("do...");
    }
 }

功能增强类

1
2
3
4
5
6
7
8
9
csharp复制代码 public class Advice {
     public void before() {
         System.out.println("前置增强");
    }
 ​
     public void after() {
         System.out.println("后置增强");
    }
 }

基于cglib的功能增强

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
scss复制代码 // 获取目标对象
 final Target target = new Target();
 ​
 // 获取增强对象
 final Advice advice = new Advice();
 ​
 // 创建增强器
 Enhancer enhancer = new Enhancer();
 ​
 // 设置目标对象
 enhancer.setSuperclass(Target.class);
 ​
 // 设置回调
 enhancer.setCallback(
         new MethodInterceptor() {
             public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
 ​
                 // 前置增强
                 advice.before();
 ​
                 Object invoke = method.invoke(target, objects);
 ​
                 // 后置增强
                 advice.after();
                 return invoke;
            }
        }
 );
 ​
 // 创建代理对象
 Target proxy = (Target) enhancer.create();
 ​
 // 执行目标对象的方法
 proxy.save();

本文转载自: 掘金

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

Rust从0️⃣入门(2) 基础语法2

发表于 2021-09-25
  • 小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

基础语法2

由于rust语法与大部分语言语法差别较大,所以慢一点学习语法
元祖

  • tuple
    • 如果写成这样let aa = (1i32,true);
    • 代表第一个是i32类型,第二个是bool类型
    • 来看下面代码
1
2
3
4
5
6
7
8
9
rust复制代码fn main() {
let p = (1i32, 2i32);
let (a , b) = p;
let x = p.0;
let y = p.1;

// 输出 1 2 1 2
println!("{} {} {} {}",a,b,x,y);
}
  • 其中a,b被赋值是使用了模式匹配
    • 而x,y被赋值是使用了数字索引
    枚举
  • 一般读取枚举,会使用到模式匹配
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
rust复制代码use std::num::NonZeroU8;

enum Number {
Int(i32),
Float(f32)
}

fn input_num(num : &Number) {
match num {
&Number::Int(v) => println!("Integer {}",v),
&Number::Float(v) => println!("Float {}",v),
}
}

fn main() {

let n : Number = Number::Int(100);
// 输出 Integer 100
input_num(&n);
}

基本的算术表达式

1
2
3
4
5
6
7
rust复制代码fn main() {

let x = 100;
let y = 10;
// 110 90 1000 10 0
println!("{} {} {} {} {}", x + y, x - y, x * y, x / y, x % y);
}

代码小规定

  • 如果出现连续等于的判断,需要适当的根据语义去增加括号
    • 如果下面的代码,a==b==c不加括号,编译器就会报错了
1
2
3
4
5
6
7
8
9
rust复制代码fn calculate(a: bool,b: bool,c: bool) -> bool {
(a == b) == c
}

fn main() {

// true
println!("{}",calculate(false,true,false));
}

if-else

1
2
3
4
5
6
7
8
9
10
11
12
13
14
rust复制代码fn func(i: i32) {
if i > 10 && i < 20 {
print!("i > 10");
}else if i > 20 && i < 30 {
print!("i > 20")
}else {
print!("i > 30")
}
}

fn main() {
// i > 30
func(40);
}

函数

1
2
3
4
5
6
7
8
rust复制代码fn add(t : (i32,i32)) -> i32 {
t.0 + t.1
}

fn main() {
//输出 3
println!("{}",add((1,2)))
}
  • 上面函数的变种
1
2
3
4
5
6
7
8
rust复制代码fn add((x,y) : (i32,i32)) -> i32 {
x + y
}

fn main() {
//输出 3
println!("{}",add((1,2)))
}
  • 当类型转换遇上函数
    • add1是传入一个参数
    • 而add2是传入一个tuple,两个i32类型的变量
    • 下面代码,相当于func进行了类型转换
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
rust复制代码fn add1(t : (i32,i32)) -> i32 {
t.0 + t.1
}

fn add2((x,y) : (i32,i32)) -> i32 {
x + y
}

fn add3(x: i32,y: i32) -> i32 {
x + y
}

fn main() {
let mut func : fn((i32,i32)) -> i32 = add1;
func = add2;
//输出 7
println!("{}",func((2,5)))
}

条件判断配合函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
rust复制代码fn check(x : bool) -> i32 {
let p = if x {
panic!("Error!");
} else {
7
};
return p;
}

fn main() {
let func = check(false);
//输出 7
println!("{}",func)
}

递归之斐波那契

1
2
3
4
5
6
7
8
9
10
11
12
13
rust复制代码fn fib(i : u32) -> u64 {
if i == 1 || i == 2 {
return 1
} else {
return fib(i - 1) + fib(i - 2);
}
}

fn main() {
let c = fib(10);
//输出 55
println!("{}",c)
}

trait

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
rust复制代码trait Shape {
fn area(self: &Self) -> f64;
}

struct Circle {
r: f64,
}

impl Shape for Circle {
fn area(self: &Self) -> f64 {
std::f64::consts::PI * self.r * self.r
}
}


fn main() {
let c = Circle {r : 3f64};
//输出 圆的面积为 28.274333882308138
println!("圆的面积为 {}",c.area())
}
  • 上面代码可以看出
    • 如果有一个Circle类型的实例c,我们就可以调用内部的c.area()
    • 可以通过self.r来访问内部成员

本文转载自: 掘金

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

Redis缓存击穿、穿透、雪崩概念及解决方案

发表于 2021-09-25

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动
本文讲解Redis缓存击穿、穿透、雪崩概念及解决方案。

一、缓存击穿

缓存击穿的概念

​ 缓存击穿是指一个请求要访问的数据,缓存中没有,但数据库中有。

这种情况一般来说就是缓存过期了。但是这时由于并发访问这个缓存的用户特别多,这是一个热点key,这么多用户的请求同时过来,在缓存里面都没有取到数据,所以又同时去访问数据库取数据,引起数据库流量激增,压力瞬间增大,直接崩溃给你看。

​ 所以一个数据有缓存,每次请求都从缓存中快速的返回了数据,但是某个时间点缓存失效了,某个请求在缓存中没有请求到数据,这时候我们就说这个请求就”击穿”了缓存。

re01.png

缓存击穿的解决方案

1、方案一 互斥锁

​ 互斥锁方案的思路就是如果从redis中没有获取到数据,就让一个线程去数据库查询数据,然后构建缓存,其他的线程就等着,过一段时间后再从redis中去获取。

re02.png

​ 伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
java复制代码String get(String ycf) {
String music = redis.get(ycf);
if (music == null) {
//nx的方式设置一个key=ycf_lock,
//value=y_lock的数据,60秒后过期
if (redis.set("ycf_lock", "y_lock","nx",60)) {
//从数据库里获取数据
music = db.query(ycf);
//构建数据,24*60*60s后过期
redis.set(ycf, music,24*60*60);
//构建成功,可以删除互斥锁
redis.delete("ycf_lock");
} else {
//其他线程休息100ms后重试
Thread.sleep(100);
//再次获取数据,如果前面在100ms内设置成功,则有数据
music = redis.get(ycf);
}
}
}

​ 这个方案能解决问题,但是一个线程构建缓存的时候,另外的线程都在睡眠或者轮询。

​ 而且在这个四处宣讲高并发,低延时的时代,你居然让你的用户等待了宝贵的100ms。有可能别人比你快100ms,就抢走了大批用户。

2、方案二 后台续命

​ 后台续命方案的思想就是,后台开一个定时任务,专门主动更新即将过期的数据。

​ 比如程序猿设置jay这个热点key的时候,同时设置了过期时间为60分钟,那后台程序在第55分钟的时候,会去数据库查询数据并重新放到缓存中,同时再次设置缓存为60分钟。

3、方案三 永不过期

​ 这个方案就有点简单粗暴了。

​ 见名知意,如果结合实际场景你用脚趾头都能想到这个key是一个热点key,会有大量的请求来访问这个数据。对于这样的数据你还设置过期时间干什么?直接放进去,永不过期。

re03.png

​ 但是具体情况具体分析,没有一套方案走天下的。

​ 比如,如果这个key是属于被各种”自来水”活生生的炒热的呢?就像哪吒一样,你预想不到这个key会闹出这么大的动静。这种情况你这么处理?

​ 所以,具体情况,具体分析。但是思路要清晰,最终方案都是常规方案的组合或者变种。

二、缓存穿透

缓存穿透的概念

​ 缓存穿透是指一个请求要访问的数据,缓存和数据库中都没有,而用户短时间、高密度的发起这样的请求,每次都打到数据库服务上,给数据库造成了压力。一般来说这样的请求属于恶意请求。

re04.png

​ 根据图片显示的,缓存中没有获取到数据,然后去请求数据库,没想到数据库里面也没有。

缓存穿透的解决方案

1、方案一 缓存空对象

​ 缓存空对象就是在数据库即使查到的是空对象,我们也把这个空对象缓存起来。

re05.png

缓存空对象,下次同样请求就会命中这个空对象,缓存层就处理了这个请求,不会对数据库产生压力。

​ 这样实现起来简单,开发成本很低。但这样随之而来的问题必须要注意一下:

第一个问题:如果在某个时间,缓存为空的记录,在数据库里面有值了,你怎么办?

​ 我知道三个解决方法:

解决方法一:设置缓存的时候,同时设置一个过期时间,这样过期之后,就会重新去数据库查询最新的数据并缓存起来。

解决方法二:如果对实时性要求非常高的话,那就写数据库的时候,同时写缓存。这样可以保障实时性。

解决方法三:如果对实时性要求不是那么高,那就写数据库的时候给消息队列发一条数据,让消息队列再通知处理缓存的逻辑去数据库取出最新的数据。

第二个问题:对于恶意攻击,请求的时候key往往各不相同,且只请求一次,那你要把这些key都缓存起来的话,因为每个key都只请求一次,那还是每次都会请求数据库,没有保护到数据库呀?

​ 这个时候,你就告诉他:”布隆过滤器,了解一下”。

2、方案二 布隆过滤器

​ 什么是布隆过滤器?

本质上布隆过滤器是一种数据结构,比较巧妙的概率型数据结构(probabilistic data structure),特点是高效地插入和查询,可以用来告诉你 “某样东西一定不存在或者可能存在”。

相比于传统的 List、Set、Map 等数据结构,它更高效、占用空间更少,但是缺点是其返回的结果是概率性的,而不是确切的。

re06.png

当布隆过滤器说某个值存在时,这个值可能不存在;当它说不存在时,那就肯定不存在。

​ 所以布隆过滤器返回的结果是概率性的,所以它能缓解数据库的压力,并不能完全挡住,这点必须要明确。

​ guava组件可以开箱即用的实现一个布隆过滤器,但是guava是基于内存的,所以不太适用于分布式环境下。

​ 要在分布式环境下使用布隆过滤器,那还得redis出马,redis可以用来实现布隆过滤器。

看到了吗,redis不仅仅是拿来做缓存的。这就是一个知识点呀。

三、缓存雪崩

缓存雪崩的概念

​ 缓存雪崩是指缓存中大多数的数据在同一时间到达过期时间,而查询数据量巨大,这时候,又是缓存中没有,数据库中有的情况了。请求都打到数据库上,引起数据库流量激增,压力瞬间增大,直接崩溃给你看。

​ 和前面讲的缓存击穿不同的是,缓存击穿指大量的请求并发查询同一条数据。

​ 缓存雪崩是不同数据都到了过期时间,导致这些数据在缓存中都查询不到,

re07.png

​ 或是缓存服务直接挂掉了,所以缓存都没有了。

re08.png

​ 总之,请求都打到了数据库上。对于数据库来说,流量雪崩了,很形象。

缓存雪崩的解决方案

1、方案一 加互斥锁

​ 如果是大量缓存在同一时间过期的情况,那么我们可以加互斥锁。

​ 等等,互斥锁不是前面介绍过了吗?

​ 是的,缓存雪崩可以看成多个缓存击穿,所以也可以使用互斥锁的解决方案,这里就不再赘述。

2、方案二 “错峰”过期

​ 如果是大量缓存在同一时间过期的情况,我们还有一种解决方案就是在设置key过期时间的时候,在加上一个短的随机过期时间,这样就能避免大量缓存在同一时间过期,引起的缓存雪崩。

比如设置一类key的过期时间是10分钟,在10分钟的基础上再加上60秒的随机事件,就像这样:

1
java复制代码redis.set(key,value,10*60+RandomUtils.nextInt(0, 60),TimeUnit.SECONDS)

3、方案三 缓存集群

​ 如果对于缓存服务挂掉的情况,大多原因是单点应用。那么我们可以引入redis集群,使用主从加哨兵。用Redis Cluster部署集群很方便的,可以了解一下。

​ 当然这是属于一种事前方案,在使用单点的时候,你就得考虑服务宕机后引起的问题。所以,事前部署集群,提高服务的可用性。

4、方案四 限流器+本地缓存

​ 那你要说如果Cluster集群也挂了怎么办呢?其实这就是对服务鲁棒性的考虑:

鲁棒性(robustness)就是系统的健壮性。它是指一个程序中对可能导致程序崩溃的各种情况都充分考虑到,并且作相应的处理,在程序遇到异常情况时还能正常工作,而不至于死机。

​ 这个时候,可以考虑一下引入限流器,比如 Hystrix,然后实现服务降级。

​ 假设你的限流器设置的一秒钟最多5千个请求,那么这个时候来了8千个请求,多出来的3000个就走降级流程,对用户进行友好提示。

​ 进来的这5000个请求,如果redis挂了,还是有可能会干翻数据库的,那么这个时候我们在加上如果redis挂了,就查询类似于echcache或者guava cache本地缓存的逻辑,则可以帮助数据库减轻压力,挺过难关。

5、方案五 尽快恢复

​ 这个没啥说的了吧?

​ 大哥,你服务挂了诶?赶紧恢复服务啊。

推荐:SpringBoot整合Redis,封装RedisUtils工具

本文转载自: 掘金

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

聊聊redis分布式锁的8大坑

发表于 2021-09-24

前言

在分布式系统中,由于redis分布式锁相对于更简单和高效,成为了分布式锁的首先,被用到了很多业务场景当中。

尤其是分布式配置中心:apollo、nocos等的出现,让zookeeper的地位越来越低了。zookeeper分布式锁复杂度更高,想把它使用好并不容易。

所以我们还是好好使用redis分布式锁吧。

不是说用了redis分布式锁,就可以高枕无忧了,如果没有用好,也会引来一些意想不到的麻烦。

今天我们重点聊聊redis分布式锁的一些坑,给有需要的朋友一个参考。

最近无意间获得一份BAT大厂大佬写的刷题笔记,一下子打通了我的任督二脉,越来越觉得算法没有想象中那么难了。

BAT大佬写的刷题笔记,让我offer拿到手软

1 非原子操作

使用redis的分布式锁,我们首先想到的可能是setNx命令。

1
2
3
java复制代码if (jedis.setnx(lockKey, val) == 1) {
jedis.expire(lockKey, timeout);
}

容易,三下五除二,就可以把代码写好。

这段代码确实可以加锁成功,但你有没有发现什么问题?

加锁操作和后面的设置超时时间是分开的,并非原子操作。

假如加锁成功,但是设置超时时间失败了,该lockKey就变成永不失效。假如在高并发场景中,有大量的lockKey加锁成功了,但不会失效,有可能直接导致redis内存空间不足。

那么,有没有保证原子性的加锁命令呢?

答案是:有,请看下面。

2 忘了释放锁

上面说到使用setNx命令加锁操作和设置超时时间是分开的,并非原子操作。

而在redis中还有set命令,该命令可以指定多个参数。

1
2
3
4
5
java复制代码String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
if ("OK".equals(result)) {
return true;
}
return false;

其中:

  • lockKey:锁的标识
  • requestId:请求id
  • NX:只在键不存在时,才对键进行设置操作。
  • PX:设置键的过期时间为 millisecond 毫秒。
  • expireTime:过期时间

set命令是原子操作,加锁和设置超时时间,一个命令就能轻松搞定。

nice

使用set命令加锁,表面上看起来没有问题。但如果仔细想想,加锁之后,每次都要达到了超时时间才释放锁,会不会有点不合理?加锁后,如果不及时释放锁,会有很多问题。

分布式锁更合理的用法是:

  1. 手动加锁
  2. 业务操作
  3. 手动释放锁
  4. 如果手动释放锁失败了,则达到超时时间,redis会自动释放锁。

大致流程图如下:

那么问题来了,如何释放锁呢?

1
2
3
4
5
6
7
8
9
java复制代码try{
String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
if ("OK".equals(result)) {
return true;
}
return false;
} finally {
unlock(lockKey);
}

需要捕获业务代码的异常,然后在finally中释放锁。换句话说就是:无论代码执行成功或失败了,都需要释放锁。

此时,有些朋友可能会问:假如刚好在释放锁的时候,系统被重启了,或者网络断线了,或者机房断点了,不也会导致释放锁失败?

这是一个好问题,因为这种小概率问题确实存在。

但还记得前面我们给锁设置过超时时间吗?即使出现异常情况造成释放锁失败,但到了我们设定的超时时间,锁还是会被redis自动释放。

但只在finally中释放锁,就够了吗?

3 释放了别人的锁

做人要厚道,先回答上面的问题:只在finally中释放锁,当然是不够的,因为释放锁的姿势,还是不对。

哪里不对?

答:在多线程场景中,可能会出现释放了别人的锁的情况。

有些朋友可能会反驳:假设在多线程场景中,线程A获取到了锁,如果线程A没有释放锁,线程B是获取不到锁的,何来释放了别人锁之说?

答:假如线程A和线程B,都使用lockKey加锁。线程A加锁成功了,但是由于业务功能耗时时间很长,超过了设置的超时时间。这时候,redis会自动释放lockKey锁。此时,线程B就能给lockKey加锁成功了,接下来执行它的业务操作。恰好这个时候,线程A执行完了业务功能,释放了锁lockKey。这不就出问题了,线程B的锁,被线程A释放了。

我想这个时候,线程B肯定哭晕在厕所里,并且嘴里还振振有词。

那么,如何解决这个问题呢?

不知道你们注意到没?在使用set命令加锁时,除了使用lockKey锁标识,还多设置了一个参数:requestId,为什么要需要记录requestId呢?

答:requestId是在释放锁的时候用的。

1
2
3
4
5
java复制代码if (jedis.get(lockKey).equals(requestId)) {
jedis.del(lockKey);
return true;
}
return false;

在释放锁的时候,先获取到该锁的值(之前设置值就是requestId),然后判断跟之前设置的值是否相同,如果相同才允许删除锁,返回成功。如果不同,则直接返回失败。

换句话说就是:自己只能释放自己加的锁,不允许释放别人加的锁。

这里为什么要用requestId,用userId不行吗?

答:如果用userId的话,对于请求来说并不唯一,多个不同的请求,可能使用同一个userId。而requestId是全局唯一的,不存在加锁和释放锁乱掉的情况。

此外,使用lua脚本,也能解决释放了别人的锁的问题:

1
2
3
4
5
java复制代码if redis.call('get', KEYS[1]) == ARGV[1] then 
return redis.call('del', KEYS[1])
else
return 0
end

lua脚本能保证查询锁是否存在和删除锁是原子操作,用它来释放锁效果更好一些。

说到lua脚本,其实加锁操作也建议使用lua脚本:

1
2
3
4
5
6
7
8
9
10
11
java复制代码if (redis.call('exists', KEYS[1]) == 0) then
redis.call('hset', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1)
redis.call('hincrby', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
return redis.call('pttl', KEYS[1]);

这是redisson框架的加锁代码,写的不错,大家可以借鉴一下。

4 大量失败请求

上面的加锁方法看起来好像没有问题,但如果你仔细想想,如果有1万的请求同时去竞争那把锁,可能只有一个请求是成功的,其余的9999个请求都会失败。

在秒杀场景下,会有什么问题?

答:每1万个请求,有1个成功。再1万个请求,有1个成功。如此下去,直到库存不足。这就变成均匀分布的秒杀了,跟我们想象中的不一样。

如何解决这个问题呢?

此外,还有一种场景:

比如,有两个线程同时上传文件到sftp,上传文件前先要创建目录。假设两个线程需要创建的目录名都是当天的日期,比如:20210920,如果不做如何控制,这样直接并发的创建,第二个线程会失败。

有同学会说:这还不容易,加一个redis分布式锁就能解决问题了,此外再判断一下,如果目录已经存在就不创建,只有目录不存在才需要创建。

伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码try {
String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
if ("OK".equals(result)) {
if(!exists(path)) {
mkdir(path);
}
return true;
}
} finally{
unlock(lockKey,requestId);
}
return false;

答:只是加redis分布式锁是不够的,因为第二个请求如果加锁失败了,接下来,是返回失败呢?还是返回成功呢?


显然肯定是不能返回失败的,如果返回失败了,这个问题还是没有被解决。如果文件还没有上传成功,直接返回成功会有更大的问题。头疼,到底该如何解决呢?

答:使用自旋锁。

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复制代码try {
Long start = System.currentTimeMillis();
while(true) {
String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
if ("OK".equals(result)) {
if(!exists(path)) {
mkdir(path);
}
return true;
}

long time = System.currentTimeMillis() - start;
if (time>=timeout) {
return false;
}
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
} finally{
unlock(lockKey,requestId);
}
return false;

在规定的时间,比如500毫秒内,自旋不断尝试加锁(说白了,就是在死循环中,不断尝试加锁),如果成功则直接返回。如果失败,则休眠50毫秒,再发起新一轮的尝试。如果到了超时时间,还未加锁成功,则直接返回失败。

5 锁重入问题

我们都知道redis分布式锁是互斥的。如果我们对某个key加锁了,如果该key对应的锁还没失效,再用相同key去加锁,大概率会失败。

没错,大部分场景是没问题的。

为什么说是大部分场景呢?

因为还有这样的场景:

假设在某个请求中,需要获取一颗满足条件的菜单树或者分类树。我们以菜单为例,这就需要在接口中从根节点开始,递归遍历出所有满足条件的子节点,然后组装成一颗菜单树。

需要注意的是菜单不是一成不变的,在后台系统中运营同学可以动态添加、修改和删除菜单。为了保证在并发的情况下,每次都可能获取最新的数据,这里可以加redis分布式锁。

加redis分布式锁的思路是对的。但接下来问题来了,在递归方法中递归遍历多次,每次都是加的同一把锁。递归第一层当然是可以加锁成功的,但递归第二层、第三层…第N层,不就会加锁失败了?

递归方法中加锁的伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java复制代码private int expireTime = 1000;

public void fun(int level,String lockKey,String requestId){
try{
String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
if ("OK".equals(result)) {
if(level<=10){
this.fun(++level,lockKey,requestId);
} else {
return;
}
}
return;
} finally {
unlock(lockKey,requestId);
}
}

如果你直接这么用,看起来好像没有问题。但最终执行程序之后发现,等待你的结果只有一个:出现异常。

因为从根节点开始,第一层递归加锁成功,还没释放说,就直接进入第二层递归。因为requestId作为key的锁已经存在,所以第二层递归大概率会加锁失败,然后返回到第一层。第一层接下来正常释放锁,然后整个递归方法直接返回了。

这下子,大家知道出现什么问题了吧?

没错,递归方法其实只执行了第一层递归就返回了,其他层递归由于加锁失败,根本没法执行。

那么这个问题该如何解决呢?

答:使用可重入锁。

我们以redisson框架为例,它的内部实现了可重入锁的功能。

古时候有句话说得好:为人不识陈近南,便称英雄也枉然。

我说:分布式锁不识redisson,便称好锁也枉然。哈哈哈,只是自娱自乐一下。

由此可见,redisson在redis分布式锁中的江湖地位很高。

伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
java复制代码private int expireTime = 1000;

public void run(String lockKey) {
RLock lock = redisson.getLock(lockKey);
this.fun(lock,1);
}

public void fun(RLock lock,int level){
try{
lock.lock(5, TimeUnit.SECONDS);
if(level<=10){
this.fun(lock,++level);
} else {
return;
}
} finally {
lock.unlock();
}
}

上面的代码也许并不完美,这里只是给了一个大致的思路,如果大家有这方面需求的话,可以参数一下。

接下来,聊聊redisson可重入锁的实现原理。

加锁主要是通过以下脚本实现的:

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码if (redis.call('exists', KEYS[1]) == 0) 
then
redis.call('hset', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1)
then
redis.call('hincrby', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
return redis.call('pttl', KEYS[1]);

其中:

  • KEYS[1]: 锁名
  • ARGV[1]: 过期时间
  • ARGV[2]: uuid + “:” + threadId,可认为是requestId
  1. 先判断如果锁名不存在,则加锁。
  2. 然后判断判断如果锁名和requestId值都存在,则使用hincrby命令给该锁名和requestId值计数,每次都加1。注意一下,这里就是重入锁的关键,锁重入一次就加1。
  3. 如果锁名存在,但值不是requestId,则返回过期时间。

释放锁主要是通过以下脚本实现的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
java复制代码if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) 
then
return nil
end
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1)
if (counter > 0)
then
redis.call('pexpire', KEYS[1], ARGV[2]); return 0;
else
redis.call('del', KEYS[1]);
redis.call('publish', KEYS[2], ARGV[1]);
return 1;
end;
return nil
  1. 先判断如果锁名和requestId值不存在,则时间返回。
  2. 如果锁名和requestId值存在,则重入锁减1。
  3. 如果减1后,重入锁的value值还大于0,说明还有引用,则重试设置过期时间。
  4. 如果减1后,重入锁的value值还等于0,则可以删除锁,然后发消息通知等待线程抢锁。

再次强调一下,如果你们系统可以容忍数据暂时不一致,不加锁也行,我在这里只是举个例子,本节内容并不适用于所有场景。

6 锁竞争问题

如果有大量写入的场景,使用普通的redis分布式锁是没有问题的。

但如果有些业务场景,写入的操作比较少,反而有大量读取的操作。直接使用普通的redis分布式锁,性能会不会不太好?

我们都知道,锁的粒度越粗,多个线程抢锁时竞争就越激烈,造成多个线程锁等待的时间也就越长,性能也就越差。

所以,提升redis分布式锁性能的第一步,就是要把锁的粒度变细。

6.1 读写锁

众所周知,加锁的目的是为了保证,在并发环境中读写数据的安全性,即不会出现数据错误或者不一致的情况。

但在绝大多数实际业务场景中,一般是读数据的频率远远大于写数据。而线程间的并发读操作是并不涉及并发安全问题,我们没有必要给读操作加互斥锁,只要保证读写、写写并发操作上锁是互斥的就行,这样可以提升系统的性能。

我们以redisson框架为例,它内部已经实现了读写锁的功能。

读锁的伪代码如下:

1
2
3
4
5
6
7
8
9
10
java复制代码RReadWriteLock readWriteLock = redisson.getReadWriteLock("readWriteLock");
RLock rLock = readWriteLock.readLock();
try {
rLock.lock();
//业务操作
} catch (Exception e) {
log.error(e);
} finally {
rLock.unlock();
}

写锁的伪代码如下:

1
2
3
4
5
6
7
8
9
10
java复制代码RReadWriteLock readWriteLock = redisson.getReadWriteLock("readWriteLock");
RLock rLock = readWriteLock.writeLock();
try {
rLock.lock();
//业务操作
} catch (InterruptedException e) {
log.error(e);
} finally {
rLock.unlock();
}

将读锁和写锁分开,最大的好处是提升读操作的性能,因为读和读之间是共享的,不存在互斥性。而我们的实际业务场景中,绝大多数数据操作都是读操作。所以,如果提升了读操作的性能,也就会提升整个锁的性能。

下面总结一个读写锁的特点:

  • 读与读是共享的,不互斥
  • 读与写互斥
  • 写与写互斥

6.2 锁分段

此外,为了减小锁的粒度,比较常见的做法是将大锁:分段。

在java中ConcurrentHashMap,就是将数据分为16段,每一段都有单独的锁,并且处于不同锁段的数据互不干扰,以此来提升锁的性能。

放在实际业务场景中,我们可以这样做:

比如在秒杀扣库存的场景中,现在的库存中有2000个商品,用户可以秒杀。为了防止出现超卖的情况,通常情况下,可以对库存加锁。如果有1W的用户竞争同一把锁,显然系统吞吐量会非常低。

为了提升系统性能,我们可以将库存分段,比如:分为100段,这样每段就有20个商品可以参与秒杀。

在秒杀的过程中,先把用户id获取hash值,然后除以100取模。模为1的用户访问第1段库存,模为2的用户访问第2段库存,模为3的用户访问第3段库存,后面以此类推,到最后模为100的用户访问第100段库存。


如此一来,在多线程环境中,可以大大的减少锁的冲突。以前多个线程只能同时竞争1把锁,尤其在秒杀的场景中,竞争太激烈了,简直可以用惨绝人寰来形容,其后果是导致绝大数线程在锁等待。现在多个线程同时竞争100把锁,等待的线程变少了,从而系统吞吐量也就提升了。

需要注意的地方是:将锁分段虽说可以提升系统的性能,但它也会让系统的复杂度提升不少。因为它需要引入额外的路由算法,跨段统计等功能。我们在实际业务场景中,需要综合考虑,不是说一定要将锁分段。

7 锁超时问题

前面提到过,如果线程A加锁成功了,但是由于业务功能耗时时间很长,超过了设置的超时时间,这时候redis会自动释放线程A加的锁。

有些朋友可能会说:到了超时时间,锁被释放了就释放了呗,对功能又没啥影响。

答:错,错,错。对功能其实有影响。

通常我们加锁的目的是:为了防止访问临界资源时,出现数据异常的情况。比如:线程A在修改数据C的值,线程B也在修改数据C的值,如果不做控制,在并发情况下,数据C的值会出问题。

为了保证某个方法,或者段代码的互斥性,即如果线程A执行了某段代码,是不允许其他线程在某一时刻同时执行的,我们可以用synchronized关键字加锁。

但这种锁有很大的局限性,只能保证单个节点的互斥性。如果需要在多个节点中保持互斥性,就需要用redis分布式锁。

做了这么多铺垫,现在回到正题。

假设线程A加redis分布式锁的代码,包含代码1和代码2两段代码。

由于该线程要执行的业务操作非常耗时,程序在执行完代码1的时,已经到了设置的超时时间,redis自动释放了锁。而代码2还没来得及执行。

此时,代码2相当于裸奔的状态,无法保证互斥性。假如它里面访问了临界资源,并且其他线程也访问了该资源,可能就会出现数据异常的情况。(PS:我说的访问临界资源,不单单指读取,还包含写入)

那么,如何解决这个问题呢?

答:如果达到了超时时间,但业务代码还没执行完,需要给锁自动续期。

我们可以使用TimerTask类,来实现自动续期的功能:

1
2
3
4
5
6
7
java复制代码Timer timer = new Timer(); 
timer.schedule(new TimerTask() {
@Override
public void run(Timeout timeout) throws Exception {
//自动续期逻辑
}
}, 10000, TimeUnit.MILLISECONDS);

获取锁之后,自动开启一个定时任务,每隔10秒钟,自动刷新一次过期时间。这种机制在redisson框架中,有个比较霸气的名字:watch dog,即传说中的看门狗。

当然自动续期功能,我们还是优先推荐使用lua脚本实现,比如:

1
2
3
4
5
java复制代码if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then 
redis.call('pexpire', KEYS[1], ARGV[1]);
return 1;
end;
return 0;

需要注意的地方是:在实现自动续期功能时,还需要设置一个总的过期时间,可以跟redisson保持一致,设置成30秒。如果业务代码到了这个总的过期时间,还没有执行完,就不再自动续期了。

自动续期的功能是获取锁之后开启一个定时任务,每隔10秒判断一下锁是否存在,如果存在,则刷新过期时间。如果续期3次,也就是30秒之后,业务方法还是没有执行完,就不再续期了。

8 主从复制的问题

上面花了这么多篇幅介绍的内容,对单个redis实例是没有问题的。

but,如果redis存在多个实例。比如:做了主从,或者使用了哨兵模式,基于redis的分布式锁的功能,就会出现问题。

具体是什么问题?

假设redis现在用的主从模式,1个master节点,3个slave节点。master节点负责写数据,slave节点负责读数据。

本来是和谐共处,相安无事的。redis加锁操作,都在master上进行,加锁成功后,再异步同步给所有的slave。

突然有一天,master节点由于某些不可逆的原因,挂掉了。

这样需要找一个slave升级为新的master节点,假如slave1被选举出来了。


如果有个锁A比较悲催,刚加锁成功master就挂了,还没来得及同步到slave1。

这样会导致新master节点中的锁A丢失了。后面,如果有新的线程,使用锁A加锁,依然可以成功,分布式锁失效了。

那么,如果解决这个问题呢?

答:redisson框架为了解决这个问题,提供了一个专门的类:RedissonRedLock,使用了Redlock算法。

RedissonRedLock解决问题的思路如下:

  1. 需要搭建几套相互独立的redis环境,假如我们在这里搭建了3套。
  2. 每套环境都有一个redisson node节点。
  3. 多个redisson node节点组成了RedissonRedLock。
  4. 环境包含:单机、主从、哨兵和集群模式,可以是一种或者多种混合。

在这里我们以主从为例,架构图如下:

RedissonRedLock加锁过程如下:

  1. 循环向所有的redisson node节点加锁,假设节点数为N,例子中N等于5。
  2. 如果在N个节点当中,有N/2 + 1个节点加锁成功了,那么整个RedissonRedLock加锁是成功的。
  3. 如果在N个节点当中,小于N/2 + 1个节点加锁成功,那么整个RedissonRedLock加锁是失败的。
  4. 如果中途发现各个节点加锁的总耗时,大于等于设置的最大等待时间,则直接返回失败。

从上面可以看出,使用Redlock算法,确实能解决多实例场景中,假如master节点挂了,导致分布式锁失效的问题。

但也引出了一些新问题,比如:

  1. 需要额外搭建多套环境,申请更多的资源,需要评估一下,经费是否充足。
  2. 如果有N个redisson node节点,需要加锁N次,最少也需要加锁N/2+1次,才知道redlock加锁是否成功。显然,增加了额外的时间成本,有点得不偿失。

由此可见,在实际业务场景,尤其是高并发业务中,RedissonRedLock其实使用的并不多。
最近无意间获得一份BAT大厂大佬写的刷题笔记,一下子打通了我的任督二脉,越来越觉得算法没有想象中那么难了。

BAT大佬写的刷题笔记,让我offer拿到手软

在分布式环境中,CAP是绕不过去的。

CAP指的是在一个分布式系统中:

  • 一致性(Consistency)
  • 可用性(Availability)
  • 分区容错性(Partition tolerance)

这三个要素最多只能同时实现两点,不可能三者兼顾。

如果你的实际业务场景,更需要的是保证数据一致性。那么请使用CP类型的分布式锁,比如:zookeeper,它是基于磁盘的,性能可能没那么好,但数据一般不会丢。

如果你的实际业务场景,更需要的是保证数据高可用性。那么请使用AP类型的分布式锁,比如:redis,它是基于内存的,性能比较好,但有丢失数据的风险。

其实,在我们绝大多数分布式业务场景中,使用redis分布式锁就够了,真的别太较真。因为数据不一致问题,可以通过最终一致性方案解决。但如果系统不可用了,对用户来说是暴击。

最后说一句(求关注,别白嫖我)

如果这篇文章对您有所帮助,或者有所启发的话,帮忙扫描下发二维码关注一下,您的支持是我坚持写作最大的动力。

求一键三连:点赞、转发、在看。

关注公众号:【苏三说技术】,在公众号中回复:面试、代码神器、开发手册、时间管理有超赞的粉丝福利,另外回复:加群,可以跟很多BAT大厂的前辈交流和学习。

本文转载自: 掘金

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

Kotlin有趣的DSL

发表于 2021-09-24

这阵子在研究Kotlin,它提供了类似DSL的语法能力,一些在Java中写起来冗长的方法,在Kotlin中则可以方便的使用,同时具有很高的可读性。

举个例子,如果我们要构造这样的xml:

1
2
3
4
5
6
xml复制代码<?xml version="1.0" encoding="UTF-8"?>
<student enable="true">
<name>张三</name>
<gender>男</gender>
<remark/>
</student>

如果使用Java来做的话,是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
java复制代码import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;

public class XmlExample {
public static void main(String[] args) throws ParserConfigurationException {
final var document = DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument();
document.setXmlStandalone(true);
final var student = document.createElement("student");
student.setAttribute("enable", "true");
document.appendChild(student);
final var name = document.createElement("name");
name.appendChild(document.createTextNode("张三"));
student.appendChild(name);
final var gender = document.createElement("gender");
gender.appendChild(document.createTextNode("男"));
student.appendChild(gender);
student.appendChild(document.createElement("remark"));
}
}

简单的例子看起来还算清晰,但如果层级变多了可读性会迅速下降。

接下来给大伙整个活,我用Kotlin写一个DSL,效果是这样的:

1
2
3
4
5
6
7
8
9
kotlin复制代码fun main() {
document {
"student"("enable" to "true") {
"name"{ +"张三" }
"gender"{ +"男" }
"remark"()
}
}
}

可以看出代码和xml的结构是一一对应的,这样我们就非常方便地构造了一个xml实例。

以上效果的全部实现代码包括import在内仅53行,并且支持格式化输出:

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
kotlin复制代码import org.w3c.dom.Document
import org.w3c.dom.Node
import java.io.ByteArrayOutputStream
import javax.xml.parsers.DocumentBuilderFactory
import javax.xml.transform.OutputKeys
import javax.xml.transform.TransformerFactory
import javax.xml.transform.dom.DOMSource
import javax.xml.transform.stream.StreamResult

private val defaultDocumentBuilder = DocumentBuilderFactory.newInstance().newDocumentBuilder()
private val defaultTransformerFactory = TransformerFactory.newInstance()

fun document(block: DocumentBuilderDsl.() -> Unit): Document = defaultDocumentBuilder.newDocument().apply {
xmlStandalone = true
block(DocumentBuilderDsl(this))
}

@DslMarker
@Target(AnnotationTarget.CLASS)
annotation class XmlDsl

@XmlDsl
class DocumentBuilderDsl(private val document: Document) {
operator fun String.invoke(vararg attributes: Pair<String, String?>): Node = this(*attributes) {}
operator fun String.invoke(vararg attributes: Pair<String, String?>, block: NodeBuilderDsl.() -> Unit): Node =
document.appendChild(document.createElement(this).apply {
attributes.forEach { setAttribute(it.first, it.second) }
block(NodeBuilderDsl(document, this))
})
}

@XmlDsl
class NodeBuilderDsl(private val document: Document, private val node: Node) {
operator fun String.invoke(vararg attributes: Pair<String, String?>): Node = this(*attributes) {}
operator fun String.invoke(vararg attributes: Pair<String, String?>, block: NodeBuilderDsl.() -> Unit): Node =
node.appendChild(document.createElement(this).apply {
attributes.forEach { setAttribute(it.first, it.second) }
block(NodeBuilderDsl(document, this))
})

operator fun String.unaryPlus(): Node = node.appendChild(document.createTextNode(this))
}

fun Document.asXml(format: Boolean = false, indentAmount: Int = 4): String = ByteArrayOutputStream().use {
defaultTransformerFactory.newTransformer().apply {
if (format) {
setOutputProperty(OutputKeys.INDENT, "yes")
setOutputProperty("{http://xml.apache.org/xslt}indent-amount", indentAmount.toString())
setOutputProperty(OutputKeys.STANDALONE, "yes")
}
}.transform(DOMSource(this), StreamResult(it))
it.toString()
}

还有用poi构造Excel的也可以这样玩,如果我们要构造一个表格:

姓名 性别
张三 男

那么我们就可以这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
kotlin复制代码fun main() {
val workbook = workbook<XSSFWorkbook> {
sheet {
row {
cell { setCellValue("姓名") }
cell { setCellValue("性别") }
}
row {
cell { setCellValue("张三") }
cell { setCellValue("男") }
}
}
}
workbook.write(File("src/main/resources/test.xlsx"))
}

实现代码比上面的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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
kotlin复制代码import org.apache.poi.hssf.usermodel.HSSFWorkbook
import org.apache.poi.ss.usermodel.*
import org.apache.poi.ss.util.CellRangeAddress
import org.apache.poi.xssf.usermodel.XSSFWorkbook
import java.io.File
import java.io.FileOutputStream

inline fun <reified T : Workbook> workbook(block: WorkbookBuilderDsl.() -> Unit): Workbook {
val workbook = when (T::class) {
HSSFWorkbook::class -> HSSFWorkbook()
XSSFWorkbook::class -> XSSFWorkbook()
else -> error("不支持的类型:${T::class}")
}
block(WorkbookBuilderDsl(workbook))
return workbook
}

@DslMarker
@Target(AnnotationTarget.CLASS)
annotation class WorkbookDsl

@WorkbookDsl
class WorkbookBuilderDsl(private val workbook: Workbook) {
fun sheet(sheetName: String? = null, block: SheetBuilderDsl.() -> Unit): Sheet =
(if (sheetName != null) workbook.createSheet(sheetName) else workbook.createSheet()).also { block(SheetBuilderDsl(it)) }
}

@WorkbookDsl
class SheetBuilderDsl(private val sheet: Sheet) {
private var rownum = 0
fun row(block: RowBuilderDsl.() -> Unit): Row = sheet.createRow(rownum).also { block(RowBuilderDsl(it, rownum++)) }
}

@WorkbookDsl
class RowBuilderDsl(private val row: Row, private val rownum: Int) {
private var column = 0
private val sheet = row.sheet
fun cell(type: CellType? = null, rowspan: Int = 1, colspan: Int = 1, block: Cell.() -> Unit): Cell {
if (colspan > 1 || rowspan > 1) sheet.addMergedRegion(CellRangeAddress(rownum, rownum + rowspan - 1, column, column + colspan - 1))
sheet.mergedRegions
.firstOrNull { rownum in it.firstRow..it.lastRow && column in it.firstColumn..it.lastColumn }
?.also { if (rownum != it.firstRow || column != it.firstColumn) column = it.lastColumn + 1 }
return (if (type != null) row.createCell(column++, type) else row.createCell(column++)).also(block)
}
}

fun Workbook.write(file: File) {
use { it.write(FileOutputStream(file)) }
}

有兴趣的同学可以玩下,当然这些只是实现了核心功能,如果要完善的实现可以根据情况自行修改,有时间的话我也打算就以上的内容放到Github分享出来。

本文转载自: 掘金

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

如何自己实现一个ArrayList? 一、实现ArrayLi

发表于 2021-09-24

一、实现ArrayList的关键点

  • 怎么存储数据?
  • 怎么增删改查数据?

二、怎么存储数据?

首先想到的就是数组,用int[]?还是String[]?既然我们也不知道类型,那干脆用Objec[]来表示。所以变成了如下:

1
2
3
4
java复制代码public class ArrayList {
// 存放元素的地方
private Object[] elementData;
}

数组长度设置多少呢?设置多少我们也不知道呀,所以给个默认值然后也支持让用户自定义设置比较合适,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
java复制代码public class ArrayList {
// 默认数组容量,是10
private static final int DEFAULT_CAPACITY = 10;
// 存放元素的地方
private Object[] elementData;

// 默认无参构造器,容量是10
public ArrayList() {
elementData = new Object[DEFAULT_CAPACITY];
}

// 自定义数组容量的构造器
public ArrayList(int initialCapacity) {
if (initialCapacity < 0) {
initialCapacity = DEFAULT_CAPACITY;
}
elementData = new Object[initialCapacity];
}
}

很完美的样子,那我们容器有了,是不是就该提供api进行增删改查了?

三、API

1、增(add)

增加分为两种:直接在数组末尾增加,还一种就是我。

1.1、直接在数组末尾增加

那我们怎么知道数组中的元素的下标到哪了呢?是不是elementData.length ++就行呢?肯定不对。elementData.length是数组的容量,假设是10,那就代表一共可以容纳10个元素,但是很可能现在只有2个元素,也就是再add的时候应该放到下标为2(第三个元素)的位置上。

那怎么知道目前有多少元素在集合中呢?

维护个变量,每次add完都变量+1

1
2
3
4
5
6
7
8
9
10
11
java复制代码public class ArrayList {
// 数组当前元素个数
private int size;

// 在末尾添加元素
public boolean add(Object e) {
// 直接赋值,然后size+1
elementData[size++] = e;
return true;
}
}

很简单,就是直接添加。那我们不想再末尾追加元素,想在自定义位置追加个元素,怎么办?

1.2、在固定位置增加

怎么在固定位置追加?比如[1, 2, 3],我要在第二个位置追加4,变成[1, 4, 2, 3],那是不是需要在第二个位置以后的元素整体向后移动一位?怎么移动?

可以通过System.arraycopy()直接复制元素到数组的位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码public class ArrayList {
// 数组当前元素个数
private int size;

// 在index位置添加element
public void add(int index, Object element) {
// 复制,也就是移动元素
System.arraycopy(elementData, index, elementData, index + 1, size - index);
// 赋值
elementData[index] = element;
size++;
}
}

上面移动没看懂?分析下:

1
java复制代码System.arraycopy(elementData, index, elementData, index + 1, size - index);

这几个参数的含义是:

1
2
3
4
5
less复制代码src: 源数组
srcPos: 源数组要复制的起始位置
dest: 目的数组
destPos: 目的数组放置的起始位置
length: 复制的长度

我们这里原数组和目标数组都是elementData,那不就是相当于把第index位置的数据复制到index+1上嘛?一共复制size-index个。

比如[1,2,3,4],index=1,element=5。

那么这行代码的意思是从第index开始复制(index=1,也就是第2个元素,在这里是2),将数据复制到elementData中。那复制到新数组的哪个位置呢?复制几个数据呢?

复制size-index=4-1=3个元素到新数组上,从新数组的index+1=2位置开始。所以变成了:[1,2,2,3,4]

然后最后执行了elementData[index] = element;,那不就是elementData[1] = 5;嘛?变成了[1,5,2,3,4]。完美。

效率不高,因为需要数组数据整体向后移动一位。

不管直接在数组末尾追加还是在固定位置增加,都有一个问题:因为数组是定长的,数组满了咋办?

只有两种解决方案:报错、自动扩容。报错显然不合适,自动扩容的机制怎么实现?

1.3、扩容

1
2
3
4
5
6
7
8
java复制代码// 扩容为1.5倍
private void grow(int minCapacity) {
int oldCapacity = elementData.length;
// >> 1相当于除以2,所以相当于1.5倍
int newCapacity = oldCapacity + (oldCapacity >> 1);
// 完成扩容
elementData = Arrays.copyOf(elementData, newCapacity);
}

很简单,就是利用Arrays.copyOf()来完成扩容。所以在add方法开始就先判断是不是需要扩容,是的话先扩容然后在add

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码public class ArrayList {
// 数组当前元素个数
private int size;

// 在末尾添加元素
public boolean add(Object e) {
// 如果达到容量了,就扩容。
if (size >= elementData.length) {
grow(minCapacity);
}
// 直接赋值,然后size+1
elementData[size++] = e;
return true;
}
}

2、删(remove)

也很简单吧,删除后需要把删除后的数据都向前移动一位。移动方法是arraycopy,上面add详细讲解过了,不再多说。自己代数进去看看就知道了。

1
2
3
4
5
6
7
8
9
java复制代码public void remove(int index) {
int numMoved = size - index - 1;
if (numMoved > 0)
// 整体向前移动
System.arraycopy(elementData, index+1, elementData, index,
numMoved);
// 移动完后移除最后的元素
elementData[--size] = null;
}

3、改(set)

把某个位置的元素替换成新的,然后把被替换的旧值返回回去。

1
2
3
4
5
6
7
8
9
java复制代码public class ArrayList {
public Object set(int index, Object element) {
// 先找到旧值,返回用
Object oldValue = elementData[index];
// 其实就这一行代码就搞定了,直接赋值。
elementData[index] = element;
return oldValue;
}
}

很简单,直接赋值。O(1)时间复杂度。

4、查(get)

查的话无非就两种:根据下标查元素、根据元素查下标。

根据下标查元素

1
2
3
4
5
6
7
java复制代码public class ArrayList {
// 根据下标查元素
public Object get(int index) {
// 直接根据下标定位到元素了
return elementData[index];
}
}

效率杠杠的,O(1)时间复杂度,接下来再看看根据元素查下标怎么实现。

根据元素查下标

1
2
3
4
5
6
7
8
9
10
11
12
13
14
java复制代码public class ArrayList {
// 根据元素查下标
public int get(Object value) {
// 只能遍历查找
for (int i = 0; i < size; i ++) {
// 找到了
if (value == elementData[i]) {
return i;
}
}
// 没找到
return -1;
}
}

这个性能就不行了,平均时间复杂度是O(n)。不过工作中遇到这种需求的也很少,我实在想不到根据元素查找元素在数组中所在的位置有何用。

四、总结

其实实现原理就是Object[],让给个默认长度10。

关键点:

  • add(index, e)/remove(index)需要移动数据,效率低下。
  • add(e)/add(index, e)可能需要扩容,扩容效率低下。
  • get(index)直接根据index找到数组中的元素,效率极高。

五、广告

个人微信公众号:Java码农社区

WechatIMG2.png

本文转载自: 掘金

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

Bug 竟然出在集合判空上,我人傻了

发表于 2021-09-24

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

  1. 前言

还记得那天,新来的测试小姐姐跑来跟我说:“帅哥,你的代码报错喔”! 我连忙解释:“小姐姐饭可以乱吃,话不能乱说喔,你怎么知道这个 Bug 是我写的呢?”

事情是这样的,某一天我 Review 实习生代码的时候,发现集合判空做的不到位;于是我带着三分随意,两分叹息,五分教育的心情改了他一行代码,由于代码过于简单遂提交完就抛在脑后了

结果代码到测试小姐姐的手上,就有了开头那一句招呼……

Bug 不 Bug 无所谓,我就是喜欢听别人喊我帅哥

  1. 事件

2021 年某天,天气晴,心情阴

Review 的时候发现实习生小伙写了这么一段代码

1
2
3
4
5
6
7
java复制代码List<Object> list;

//业务逻辑……

if (list.size() != 0) {
//业务逻辑
}

我认为他的意图是当这个集合不为空,才去对数据进行操作,出于波浪线的提示我就把它改为了这样

1
2
3
4
5
6
7
java复制代码List<Object> list = null;

//业务逻辑……

if (list.isEmpty()) {
//业务逻辑
}

很明显我忘了取反,这直接导致了后面的某一行 get() 操作抛出了数组越界异常

老脸挂不住的我马上当着测试小姐姐的面,语重心长的对实习小伙说道:“你这个,代码,出了点小问题;我,帮你改好了,你下次耗子尾汁,莫要粗心大意!”

小伙连忙点头称是,我两对视会心一笑,他懂了,我也懂了!

后来我才知道,原来我上厕所的时候,实习生跟小姐姐说了,这 Bug 是我改出来的……

  1. 一些思考

其实程序员在代码上粗心,不是什么稀奇事,更粗心的还有执行 rm -f * 搞错目录的呢,真人真事哈

说到底,除了自己本身写代码的时候要多加细心之外,还要养成良好的开发习惯,严格遵守开发规范,特别是刚写代码不久的阶段,这是你养成良好开发习惯、良好代码风格的最佳时机,过了这个阶段真的特别难纠正

比如这个集合判空的问题,我们就可以通过习惯来避免这样的问题,像现在我会习惯用 Hutool 的 CollectionUtil.isEmpty()、 CollectionUtil.isNotEmpty() 来对集合进行判空,这样能最大程度的避免上述那种粗心的问题

  1. 写在最后

感谢阅读,我是 Java 雏鸡开发,如果我写的内容能让你觉得有所帮助,还望不吝点赞,关注,收藏,当然要是能转发就更好啦

如果你想了解更多欢迎访问我的个人博客:Java🐤雏鸡开发de博客

本文转载自: 掘金

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

妙用“Function”消灭ifelse

发表于 2021-09-24

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

在开发过程中经常会使用if...else...进行判断抛出异常、分支处理等操作。这些if...else...充斥在代码中严重影响了代码代码的美观,这时我们可以利用Java 8的Function接口来消灭if...else...。

1
2
3
4
5
6
7
8
9
java复制代码if (...){
throw new RuntimeException("出现异常了");
}

if (...){
doSomething();
} else {
doOther();
}

Function 函数式接口

使用注解@FunctionalInterface标识,并且只包含一个抽象方法的接口是函数式接口。函数式接口主要分为Supplier供给型函数、Consumer消费型函数、Runnable无参无返回型函数和Function有参有返回型函数。

Function可以看作转换型函数

Supplier供给型函数

Supplier的表现形式为不接受参数、只返回数据
image.png

Consumer消费型函数

Consumer消费型函数和Supplier刚好相反。Consumer接收一个参数,没有返回值

image.png

Runnable无参无返回型函数

Runnable的表现形式为即没有参数也没有返回值
image.png

Function函数的表现形式为接收一个参数,并返回一个值。Supplier、Consumer和Runnable可以看作Function的一种特殊表现形式

image.png

使用小技巧

处理抛出异常的if

  1. 定义函数
    定义一个抛出异常的形式的函数式接口, 这个接口只有参数没有返回值是个消费型接口
1
2
3
4
5
6
7
8
9
10
11
12
13
14
java复制代码/**
* 抛异常接口
**/
@FunctionalInterface
public interface ThrowExceptionFunction {

/**
* 抛出异常信息
*
* @param message 异常信息
* @return void
**/
void throwMessage(String message);
}
  1. 编写判断方法
    创建工具类VUtils并创建一个isTure方法,方法的返回值为刚才定义的函数式接口-ThrowExceptionFunction。ThrowExceptionFunction的接口实现逻辑为当参数b为true时抛出异常
1
2
3
4
5
6
7
8
9
10
11
12
13
14
java复制代码/**
* 如果参数为true抛出异常
*
* @param b
* @return com.example.demo.func.ThrowExceptionFunction
**/
public static ThrowExceptionFunction isTure(boolean b){

return (errorMessage) -> {
if (b){
throw new RuntimeException(errorMessage);
}
};
}
  1. 使用方式
    调用工具类参数参数后,调用函数式接口的throwMessage方法传入异常信息。
    当出入的参数为false时正常执行
    image.png

当出入的参数为true时抛出异常
image.png

处理if分支操作

  1. 定义函数式接口
    创建一个名为BranchHandle的函数式接口,接口的参数为两个Runnable接口。这两个两个Runnable接口分别代表了为true或false时要进行的操作
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码/**
* 分支处理接口
**/
@FunctionalInterface
public interface BranchHandle {

/**
* 分支操作
*
* @param trueHandle 为true时要进行的操作
* @param falseHandle 为false时要进行的操作
* @return void
**/
void trueOrFalseHandle(Runnable trueHandle, Runnable falseHandle);

}
  1. 编写判断方法
    创建一个名为isTureOrFalse的方法,方法的返回值为刚才定义的函数式接口-BranchHandle。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码/**
* 参数为true或false时,分别进行不同的操作
*
* @param b
* @return com.example.demo.func.BranchHandle
**/
public static BranchHandle isTureOrFalse(boolean b){

return (trueHandle, falseHandle) -> {
if (b){
trueHandle.run();
} else {
falseHandle.run();
}
};
}
  1. 使用方式

参数为true时,执行trueHandle
image.png

参数为false时,执行falseHandle
image.png

如果存在值执行消费操作,否则执行基于空的操作

  1. 定义函数
    创建一个名为PresentOrElseHandler的函数式接口,接口的参数一个为Consumer接口。一个为Runnable,分别代表值不为空时执行消费操作和值为空时执行的其他操作
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码/**
* 空值与非空值分支处理
*/
public interface PresentOrElseHandler<T extends Object> {

/**
* 值不为空时执行消费操作
* 值为空时执行其他的操作
*
* @param action 值不为空时,执行的消费操作
* @param emptyAction 值为空时,执行的操作
* @return void
**/
void presentOrElseHandle(Consumer<? super T> action, Runnable emptyAction);

}
  1. 编写判断方法
    创建一个名为isBlankOrNoBlank的方法,方法的返回值为刚才定义的函数式接口-PresentOrElseHandler。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码/**
* 参数为true或false时,分别进行不同的操作
*
* @param b
* @return com.example.demo.func.BranchHandle
**/
public static PresentOrElseHandler<?> isBlankOrNoBlank(String str){

return (consumer, runnable) -> {
if (str == null || str.length() == 0){
runnable.run();
} else {
consumer.accept(str);
}
};
}
  1. 使用方式
    调用工具类参数参数后,调用函数式接口的presentOrElseHandle方法传入一个Consumer和Runnable

参数不为空时,打印参数
image.png

参数不为空时
image.png

结尾

Function函数式接口是java 8非常重要的特性,利用好Function函数可以极大的简化代码。

本文转载自: 掘金

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

Spring Security 玩出花!两种方式 DIY 登

发表于 2021-09-24

@[toc]
一般情况下,我们在使用 Spring Security 的时候,用的是 Spring Security 自带的登录方案,配置一下登录接口,配置一下登录参数,再配置一下登录回调就能用了,这种用法可以算是最佳实践了!

但是!

总会有一些奇奇怪怪得需求,例如想自定义登录,像 Shiro 那样自己写登录逻辑,如果要实现这一点,该怎么做?今天松哥就来和大家分享一下。

松哥琢磨了一下,想在 Spring Security 中自定义登录逻辑,我们有两种思路,不过这两种思路底层实现其实异曲同工,我们一起来看下。

  1. 化腐朽为神奇

前面松哥和大家分享了一个 Spring Security 视频:

  • 没见过的奇葩登录

这个视频里主要是和大家分享了我们其实可以使用 HttpServletRequest 来完成系统的登录,这其实是 JavaEE 的规范,这种登录方式虽然冷门,但是却很好玩!

然后松哥还和大家分享了一个视频:

  • SpringSecurity登录数据获取最后一讲

这个视频其实是在讲 Spring Security 对 HttpServletRequest 登录逻辑的实现,或句话说,HttpServletRequest 中提供的那几个和登录相关的 API,Spring Security 都按照自己的实现方式对其进行了重写。

有了这两个储备知识后,第一个 DIY Spring Security 登录的方案呼之欲出。

1.1 实践

我们来看看具体操作。

首先我们来创建一个 Spring Boot 工程,引入 Web 和 Security 两个依赖,如下:

方便起见,我们在 application.properties 中配置一下默认的用户名密码:

1
2
properties复制代码spring.security.user.name=javaboy
spring.security.user.password=123

接下来我们提供一个 SecurityConfig,为登录接口放行:

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/login")
.permitAll()
.anyRequest().authenticated()
.and()
.csrf().disable();
}
}

登录接口就是 /login,一会我们自定义的登录逻辑就写在这个里边,我们来看下:

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码@RestController
public class LoginController {
@PostMapping("/login")
public String login(String username, String password, HttpServletRequest req) {
try {
req.login(username, password);
return "success";
} catch (ServletException e) {
e.printStackTrace();
}
return "failed";
}
}

直接调用 HttpServletRequest#login 方法,传入用户名和密码完成登录操作。

最后我们再提供一个测试接口,如下:

1
2
3
4
5
6
7
java复制代码@RestController
public class HelloController {
@GetMapping("/hello")
public String hello() {
return "hello security!";
}
}

just this!

启动项目,我们首先访问 /hello 接口,会访问失败,接下来我们访问 /login 接口执行登录操作,如下:

登录成功之后,再去访问 /hello 接口,此时就可以访问成功了。

是不是很 Easy?登录成功后,以后的授权等操作都还是原来的写法不变。

1.2 原理分析

上面这种登录方式的原理其实松哥一开始就介绍过了,如果大家还不熟悉,可以看看这两个视频就懂了:

  • 没见过的奇葩登录
  • SpringSecurity登录数据获取最后一讲

这里我也是稍微说两句。

我们在 LoginController#login 方法中所获取到的 HttpServletRequest 实例其实是 HttpServlet3RequestFactory 中的一个内部类 Servlet3SecurityContextHolderAwareRequestWrapper 的对象,在这个类中,重写了 HttpServletRequest 的 login 以及 authenticate 等方法,我们先来看看 login 方法,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码@Override
public void login(String username, String password) throws ServletException {
if (isAuthenticated()) {
throw new ServletException("Cannot perform login for '" + username + "' already authenticated as '"
+ getRemoteUser() + "'");
}
AuthenticationManager authManager = HttpServlet3RequestFactory.this.authenticationManager;
if (authManager == null) {
HttpServlet3RequestFactory.this.logger.debug(
"authenticationManager is null, so allowing original HttpServletRequest to handle login");
super.login(username, password);
return;
}
Authentication authentication = getAuthentication(authManager, username, password);
SecurityContextHolder.getContext().setAuthentication(authentication);
}

可以看到:

  1. 如果用户已经认证了,就抛出异常。
  2. 获取到一个 AuthenticationManager 对象。
  3. 调用 getAuthentication 方法完成登录,在该方法中,会根据用户名密码构建 UsernamePasswordAuthenticationToken 对象,然后调用 Authentication#authenticate 方法完成登录,具体代码如下:
1
2
3
4
5
6
7
8
9
10
java复制代码private Authentication getAuthentication(AuthenticationManager authManager, String username, String password)
throws ServletException {
try {
return authManager.authenticate(new UsernamePasswordAuthenticationToken(username, password));
}
catch (AuthenticationException ex) {
SecurityContextHolder.clearContext();
throw new ServletException(ex.getMessage(), ex);
}
}

该方法返回的是一个认证后的 Authentication 对象。

  1. 最后,将认证后的 Authentication 对象存入 SecurityContextHolder 中,这里的具体逻辑我就不啰嗦了,我在公众号【江南一点雨】之前的视频中已经讲过多次了。

这就是 login 方法的执行逻辑。

Servlet3SecurityContextHolderAwareRequestWrapper 类也重写了 HttpServletRequest#authenticate 方法,这个也是做认证的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码@Override
public boolean authenticate(HttpServletResponse response) throws IOException, ServletException {
AuthenticationEntryPoint entryPoint = HttpServlet3RequestFactory.this.authenticationEntryPoint;
if (entryPoint == null) {
HttpServlet3RequestFactory.this.logger.debug(
"authenticationEntryPoint is null, so allowing original HttpServletRequest to handle authenticate");
return super.authenticate(response);
}
if (isAuthenticated()) {
return true;
}
entryPoint.commence(this, response,
new AuthenticationCredentialsNotFoundException("User is not Authenticated"));
return false;
}

可以看到,这个方法用来判断用户是否已经完成认证操作,返回 true 表示用户已经完成认证,返回 false 表示用户尚未完成认证工作。

  1. 源码的力量

看了上面的原理分析,大家应该也明白了第二种方案了,就是不使用 HttpServletRequest#login 方法,我们直接调用 AuthenticationManager 进行登录验证。

一起来看下。

首先我们修改配置类如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
java复制代码@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/login","/login2")
.permitAll()
.anyRequest().authenticated()
.and()
.csrf().disable();
}

@Override
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
manager.createUser(User.withUsername("javaboy").password("{noop}123").roles("admin").build());
provider.setUserDetailsService(manager);
return new ProviderManager(provider);
}
}
  1. 首先在登录放行中,添加 /login2 接口,这是我即将自定义的第二个登录接口。
  2. 提供一个 AuthenticationManager 实例,关于 AuthenticationManager 的玩法松哥在之前的 Spring Security 系列中已经多次分享过,这里就不再赘述(没看过的小伙伴公众号后台回复 ss)。创建 AuthenticationManager 实例时,还需要提供一个 DaoAuthenticationProvider,大家知道,用户密码的校验工作在这个类里边完成,并为 DaoAuthenticationProvider 配置一个 UserDetailsService 实例,该实体提供了用户数据源。

接下来提供一个登录接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码@RestController
public class LoginController {
@Autowired
AuthenticationManager authenticationManager;
@PostMapping("/login2")
public String login2(String username, String password, HttpServletRequest req) {
try {
Authentication token = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, password));
SecurityContextHolder.getContext().setAuthentication(token);
return "success";
} catch (Exception e) {
e.printStackTrace();
}
return "failed";
}
}

在登录接口中,传入用户名密码等参数,然后将用户名密码等参数封装成一个 UsernamePasswordAuthenticationToken 对象,最后调用 AuthenticationManager#authenticate 方法进行验证,验证成功后会返回一个认证后的 Authentication 对象,再手动把该 Authentication 对象存入 SecurityContextHolder 中。

配置完成后,重启项目,进行登录测试即可。

第二种方案和第一种方案异曲同工,第二种实际上就是把第一种的底层拉出来自己重新实现,仅此而已。

  1. 小结

好啦,今天就和大家介绍了两种 Spring Security DIY 登录的方案,这些方案可能工作中并不常用,但是对于大家理解 Spring Security 原理还是大有裨益的,感兴趣的小伙伴可以敲一下试试哦~

另外,如果你感觉阅读本文吃力,不妨在公众号后台回复 ss,看看 Spring Security 系列的其他文章,这有助于理解本文,当然也可以看看松哥的新书:

《深入浅出Spring Security》一书已由清华大学出版社正式出版发行,感兴趣的小伙伴戳这里->->>深入浅出Spring Security,一本书学会 Spring Security。

本文转载自: 掘金

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

1…520521522…956

开发者博客

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