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

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


  • 首页

  • 归档

  • 搜索

Nginx报错(104:Connection reset b

发表于 2021-09-13

问题解决

应用部署环境

  • 语言:java
  • 框架:ssm
  • web容器:tomcat
  • 负载:nginx
  • 外层代理:F5

现象

根据客户需求对接一个停车缴费的功能,发布到生产环境之后发现,少量账单同时支付没有问题,一旦同时支付的账单数量超过某个值,就会出现网路连接问题,稳定复现。

解决

过程

  1. 首先查看了应用的日志,发现用户提示网络异常的时候,服务端没有任何相关的日志打印,确定请求没有发到服务端
  2. 查看Nginx Error日志发现打印了错误信息
1
java复制代码2021/09/09 08:38:56 [error] 16299#16299: *240963 readv() failed (104: Connection reset by peer) while reading upstream, client: ****, server: ****, request: "POST ****?formData=E172Rfbkeuw2Z6fFYyg95hUMDmDwaOZT7Mqopwu07lo%3CVxsdDikPopy1XjjtjmvSusJwb7UF3erixZi5Wy099%3CewyDvM3wWhvE8X/z/vxKow2ttM1iHPSmWn...
  1. 通过nginx日志发现,虽然是nginx层抛出了错误,但是以日志内容来看,其实nginx已经是将请求的报文完整的接收了下来(这个也是在解决问题之后才反应过来),所以其实问题应该是出在Nginx将请求转给被代理的应用服务的时候。
  2. 当时在排查问题的时候,没有考虑到还有一层tomcat,导致哪怕是当时怀疑了问题不在nginx这块,还是不敢相信自己,去网上一顿乱搜。

最终解决

在tomcat/conf/server.xml中,增加Connector中的参数配置maxHttpHeaderSize="65536",增加允许tomcat接收的最大请求头大小

1
2
3
4
5
6
xml复制代码<Connector port="****" protocol="org.apache.coyote.http11.Http11NioProtocol"
URIEncoding="UTF-8"
maxHttpHeaderSize="65536"
connectionTimeout="20000"
acceptCount="500" maxThreads="500"
redirectPort="****" />

问题分析

连接重置

TCP RST

正常情况,服务端使用socket建立一个服务端监听,客户端通过socket向服务端监听发起连接, 双方经过TCP握手协议之后,数据开始传输,TCP协议规定连接在建立之后,双方只要有一端发起关闭的信号,两端就会走放手协议的流程(四次挥手),不再进行数据传输。但是如果一端发起关闭信号之后,不再接收请求,另外一端依然不进入关闭流程,而是依然不停的发送数据,或者是关闭的一端缓存区的数据没有读完就进行了关闭,这时候,关闭的一端就会返回一个RST的信号,告诉另外一端连接被重置

其他情况的RST

除了上边的一种情况,RST还可能出现在客户端找不到服务端端口,服务端因为各种关闭不接收数据等等场景中,但是无一例外,最终就是一端的数据,没有被另外一端完整读取到 ,比如以下几种情况

  1. 客户端直接找不到想要连接的服务端
  2. 一端早就处于关闭的状态了,另外一端还在傻乎乎的给他传输数据
  3. 一端关闭的时候,没有读完另外一端发过来的数据

Tomcat 的 Connector

其实在一定程度上说,Tomcat和Nginx的作用相同,只不过两者的职责不同,Nginx使用了异步非阻塞高性能的组合,可以代理各种各样的URI资源,而Tomcat代理的是一个一个的Servlet容器,它可以容纳所有遵循Servlet规范的应用,并且统一将它们管理。Connector是其中最重要的一部分,它是一个HTTP连接器,它通过启动一个Socket监听,用来接收不同类型的请求,然后把他们解析成对应的Servlet规范的请求,才会将这些请求分发到不同的Servlet中进行处理。当然,内部做了很多其他的事情包括请求校验拦截,请求转化,请求异步线程处理等等。这里只是简单介绍一下,后续会增加关于tomcat部分的文章

Nginx 104

在我们这个案例的场景下分析,nginx要将拿到的请求转发给tomcat中的应用,需要跟tomcat的Connector建立连接,可以将nginx理解为客户端,将tomcat中的Connector理解为socket服务端。tomcat给Connector一套默认的配置,其中maxHttpHeaderSize默认的值是4096字节,也就是4kb。超过4kb的请求头大小的请求,不进行处理,当然这里也有可能发生两种情况,第一种是Connector一开始就知道nginx发过来的请求头过大,直接不接收,响应回去RST标识,还有一种是Connector没有管请求头的大小,直接去接收,但是因为没有将请求头数据读取完就关闭了,响应了RST。这部分没有细看,但是不论怎么说,都是因为上边说过的,没有正常处理完客户端发送过来所有的数据。

类似问题解决思路

在开始无脑查询的时候,其实有很多答案虽然错误码是104,但是报错的原因是不相同的,解决方案也是各不相同,看到过大概以下几种解决思路

  1. nginx的buffer太小,timeout太小。
  2. 长连接,增加长连接超时时间
  3. 将 http version改到1.1 (其实也是使用长连接解决,因为http1.1默认使用长连接)

虽然个人试其他解决方式的时候,都没有成功,也有可能是因为tomcat Connector 连接器的最大请求头4K大小的这个默认配置从最基础的环节直接给把其他配置砍掉了。但是不论使用何种方式解决,最终来说我们就一个思路(虽然说了很像没说),先找到是哪端没有将数据读取完毕,然后想办法让它正常读取

总结

本片文章根据个人发生的实际生产问题,着手解决并且进行问题分析,通过对nginx104的跟踪,对连接重置的概念有一个更详细的了解。

本文转载自: 掘金

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

前端瓦片地图加载之塞尔达传说旷野之息

发表于 2021-09-13

背景

最近在 肝🤕 塞尔达旷野之息,希望 2022年 新作发布前可以救出公主 👸。 同时公司有地图加载的需求,于是想以 旷野之息 地图为例,学习实践一下前端开发相关的地图知识,本文内容主要介绍通过使用瓦片地图加载原理,实现 塞尔达旷野之息 地图加载并添加交互锚点。

基础知识

瓦片地图 🗺

在游戏开发过程中,经常会遇到超过屏幕大小的地图,例如在即时战略游戏中,它使得玩家可以在地图中滚动游戏画面。这类游戏通常会有丰富的背景元素,如果直接使用背景图切换的方式,需要为每个不同的场景准备一张背景图,但是每个背景图都不小,这样会造成资源浪费。

瓦片地图就是为了解决此类问题产生的,一张大的世界地图或者背景图可以由几种地形来表示,每种地形对应一张小的的图片,我们称这些小的地形图片为瓦片。把这些瓦片拼接在一起,一个完整的地图就组合出来了,这就是瓦片地图的原理。

tile_1

瓦片地图金字塔模型是一种多分辨率层次模型,从瓦片金字塔的底层到顶层,分辨率越来越低,但表示的地理范围不变。 首先确定地图服务平台所要提供的缩放级别的数量 N,把缩放级别最高、地图比例尺最大的地图图片作为金字塔的底层,即第 0 层,并对其进行分块,从地图图片的左上角开始,从左至右、从上到下进行切割,分割成相同大小的正方形地图瓦片,形成第 0 层瓦片矩阵;在第 0 层地图图片的基础上,按每像素分割为 2×2 个像素的方法生成第 1 层地图图片,并对其进行分块,分割成与下一层相同大小的正方形地图瓦片,形成第1层瓦片矩阵;采用同样的方法生成第 2 层瓦片矩阵;…… 如此下去,直到第 N-1 层,构成整个瓦片金字塔。

tile_0

瓦片地图一般采用ZXY规范的地图瓦片。(瓦片层级、瓦片 x坐标、瓦片 y坐标)

墨卡托投影

瓦片地图采用的都是墨卡托投影,即正轴等角圆柱投影,又称等角圆柱投影, 是圆柱投影的一种,由荷兰地图学家墨卡托(Gerhardus Mercator)拟定。基本原理是假设地球被围在一中空的圆柱里,其基准纬线与圆柱相切(赤道)接触,然后再假想地球中心有一盏灯,把球面上的图形投影到圆柱体上,再把圆柱体展开,这就是一幅选定基准纬线上的 墨卡托投影 绘制出的地图。百度地图、高德地图及 Google Maps 使用的投影方法都是墨卡托投影。

mercator

瓦片地图不用自己生成,有很多工具可以用来制作瓦片地图,Tiled、Arcgis 等都是非常流行的制作工具。

hr

实现

在本例中,塞尔达旷野之息瓦片地图来源网络开源地图,加载瓦片地图使用 Leaflet web 地图库,开发之前简要了解一下。

Leaflet.js

Leaflet 🌿 (https://leafletjs.com) 是一个为建设交互性好适用于移动设备地图,而开发的现代的、开源的 JavaScript 库。使用它我们可以部署简单,交互式,轻量级的Web地图。

  • 代码仅 33 KB,但它具有开发在线地图的大部分功能。
  • 允许使用图层,WMS,标记,弹出窗口,矢量图层(折线,多边形,圆形等),图像叠加层和 GeoJSON 等图层。
  • 可以通过拖动地图,缩放(通过双击或滚轮滚动),使用键盘,使用事件处理以及拖动标记来与 Leaflet 地图进行交互。
  • 浏览器支持桌面端 Chrome、Firefox、Safari 5+、Opera 12+、IE 7-11 以及 Safari、Android、Chrome、Firefox等手机浏览器。

代码实现

在页面的 head 标签中引入 Leaflet的 css 文件和 js 文件。在想要创建地图的地方创建一个带有 id 的 div,示例中用 #mapContainer 元素承载地图。

1
2
3
4
5
6
7
html复制代码<head>
<link href="assets/libs/leaflet/leaflet.css" rel="stylesheet"/>
<script src="assets/libs/leaflet/leaflet-src.js"></script>
</head>
<body>
<div id="mapContainer"></div>
</body>

需要确保地图有一个明确的高度, 可以在 CSS 中添加如下全屏显示的样式。

1
2
3
4
css复制代码#mapContainer {
width: 100%;
height: 100%;
}

现在地图的初始化已经完成了,这一步进行瓦片地图加载。

  • L.LatLngBounds(西南角点,东北角点):通过定义矩形西南角点和东北角点来创建经纬度的矩形框。
  • setView:初始化地图,并将其视图设置为我们所选择的地理坐标和缩放级别。
  • L.tileLayer:加载瓦片图层。
  • addTo:显示地图。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
js复制代码var bounds = new L.LatLngBounds(
new L.LatLng(-49.875, 34.25), new L.LatLng(-206, 221)
);

var map = L.map('mapContainer', {
crs: L.CRS.Simple,
attributionControl: false,
maxBounds: bounds,
maxBoundsViscosity: 1.0,
}).setView([0, 0], 2);

var layer = L.tileLayer('assets/maps/{z}_{x}_{y}.png', {
attribution: '&copy; David',
minZoom: 2,
maxZoom: 7,
noWrap: true,
bounds: bounds
}).addTo(map);

map_0

确保所有代码都在用于显示地图的 div 和 leaflet.js 包含之后调用。默认情况下(因为我们在创建地图实例时没有设置任何参数),地图上的所有鼠标事件和触摸交互功能都是开启的,并且它具有缩放和属性控件。

此时我们在页面中对地图进行拖动、缩放等操作,并打开浏览器控制台查看 network 中的 img 选项,随着操作的触发,不同的地图瓦片被浏览器加载显示。

map_0.5

塞尔达旷野之息 地图非常大,据国外 油管阿婆主 测试,林克从最北走到地图最南端需要 20 多分钟。在如此宏大的地图上进行游戏,探索的神庙、地图塔、人马等怪物点等位置需要花很大精力,如果使用已有数据进行标注,可以节省很多精力(但也失去了探索的乐趣 😂)。此时可以利用 Leaflet 的地图标注功能,在地图上进行标记。其中标注数据来源于网络资料。以下内容以神庙 🛕 为例,实现在瓦片地图上的标注和交互功能。

  • L.marker([x, y]):除了瓦片之外,可以轻松地在地图中添加其他东西,包括标记、折线、多边形、圆圈和弹出窗口。
  • L.divIcon: 自定义图标。
  • bindPopup: 弹出窗口通常用于将某些信息附加到地图上的特定对象上。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
js复制代码$.each(markerData, function () {
var key = this.markerCategoryId + "-" + this.id + "-" + this.name.replace(/[^A-Z]/gi, "-");
var popupHtml = '<div class="popupContainer">';
popupHtml += '<strong class="name">' + this.name + '</strong>';
popupHtml += '<div class="buttonContainer">';
popupHtml += '<span class="markButton" onclick="markPoint(this)" data-key="' + key + '">标记</span>';
popupHtml += '</div>';
var className = "mark-" + key;
className += " markIcon";
className += " icon-" + markerStyle[this.markerCategoryId];
var marker = L.marker([this.y, this.x], {
title: this.name,
icon: L.divIcon({
className: className,
iconSize: [20, 20],
iconAnchor: [10, 10],
popupAnchor: [0, -10],
})
}).addTo(map).bindPopup(popupHtml);
});

至此,通过遍历,将数据中神庙的坐标点添加到了地图上,同时在dom结构中添加了点击事件,点击神庙可以进行交互。

map_1

使用同样的方法,可以将地图塔、村庄、人马、呀哈哈 😂、回忆点、子任务点等位置信息标注在地图中方便查找。

map_2

实现效果

map

在线预览:dragonir.github.io/zelda-map

hr_light

总结

使用瓦片地图,可以做到地图的整体和局部都能高清展示,并且能够做到按需加载,需要注意的是,分层较多的地图瓦片图片也会指数增长,需要做好缓存处理,这样就能提升地图页面加载速度,提升用户体验。leaflet.js 虽然很轻量,但是功能非常强大,本例中只用到它的一些基础功能,其他高级用法还要在后续开发中继续探索。

参考资料

  • 塞尔达旷野之息地图标注来源:github.com
  • 瓦片地图生成工具 Tiled 项目:www.mapeditor.org
  • 瓦片地图生成工具 arcgis:developers.arcgis.com
  • 开放地理空间实验室 Leaflet.js: webgis.cn/leaflet-ind…
  • 墨卡托投影:baike.baidu.com/item/%E5%A2…
  • www.cnblogs.com/fwc1994/p/6…
  • ZXY标准瓦片 support.supermap.com.cn/DataWarehou…
  • 原项目:bbs.a9vg.com/forum.php?m…

footer

本文转载自: 掘金

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

java中的多线程:线程使用、线程安全、线程通信 Java中

发表于 2021-09-13

Java中的多线程使用

Thread:

1
2
3
4
5
6
7
java复制代码 Thread thread = new Thread() {
@Override
public void run() {
System.out.println("Thread started!");
}
};
thread.start();

Thread类的几个常用的方法:

  • sleep():静态方法,使当前线程睡眠一段时间;
  • currentThread():静态方法,返回对当前正在执行的线程对象的引用;
  • start():开始执行线程的方法,java虚拟机会调用线程内的run()方法;
  • join():使当前线程等待另一个线程执行完毕之后再继续执行,内部调用的是Object类的wait方法实现的;
  • yield():yield意为放弃,yield()方法指当前线程愿意让出对当前处理器的占用。需要注意,即时当前线程调用了yield()方法让出处理机,调度时也有可能继续让该线程竞争获得处理机并运行;

Runnable:

Runnable是一个函数式接口:

1
2
3
4
java复制代码@FunctionalInterface
public interface Runnable {
public abstract void run();
}

Runnable不利于线程重用管理

1
2
3
4
5
6
7
8
java复制代码 Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println("Thread with Runnable started!");
}
};
Thread thread = new Thread(runnable);
thread.start();

ThreadFactory:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java复制代码    ThreadFactory factory = new ThreadFactory() {
int count = 0;
@Override
public Thread newThread(Runnable r) {
count ++;
return new Thread(r, "Thread-" + count);
}
};
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + "started!");
}
};
Thread thread = factory.newThread(runnable);
thread.start();
Thread thread1 = factory.newThread(runnable);
thread1.start();

Executor线程池:

Executor线程池(最为推荐):

1
2
3
4
5
6
7
8
9
10
java复制代码 Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println("Thread with Runnable started!");
}
};
Executor executor = Executors.newCachedThreadPool();
executor.execute(runnable);
executor.execute(runnable);
executor.execute(runnable);

Callable 和 Future:

Callable与Runnable类似,同样是只有一个抽象方法的函数式接口。不同的是,Callable提供的方法是有返回值的,而且支持泛型。

1
2
3
4
java复制代码@FunctionalInterface
public interface Callable<V> {
V call() throws Exception;
}

Callable和Future一般成对出现,future.get()获取异步执行的结果,如果没有结果可用,此方法会阻塞直到异步计算完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
java复制代码 Callable<String> callable = new Callable<String>() {
@Override
public String call() {
try {
Thread.sleep(1500);
} catch (InterruptedException e) {
e.printStackTrace();
}
return "Done!";
}
};
ExecutorService executor = Executors.newCachedThreadPool();
Future<String> future = executor.submit(callable);
try {
String result = future.get();
System.out.println("result: " + result);
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}

线程安全和线程同步

线程安全:指函数在多线程环境中被调用时,能够正确地处理多个线程之间的全局变量,使得功能正确完成。

线程同步:即当有一个线程在对内存进行操作时,其他线程都不可以对这个内存地址进行操作。

线程同步不等于线程安全,现在很多人误解了这一点而喜欢将他们混为一谈。现实是,当我询问面试者何为线程同步时,很多人回答的都是线程安全。

线程同步是实现线程安全的一种手段,你当然也可以用其他的方式达到线程安全。

Threadsafe vs Synchronized

线程安全的本质问题是资源问题:

当一个共享资源被一个线程读操作时,该资源不能被其他线程任意写;

当一个共享资源被一个线程写操作时,该资源不能被其他线程任意读写;

下面介绍java中实现线程安全的几种方式:

synchronized

synchronized以同步方式保证了方法内部或代码块内部资源(数据)的互斥访问,保证了线程之间对监视资源的数据同步.

1
2
3
4
java复制代码 private synchronized void count(int newValue) {
x = newValue;
System.out.println("x= " + x);
}

另一种写法:

1
2
3
4
5
6
java复制代码 private void count(int newValue) {
synchronized (this) {
x = newValue;
System.out.println("x= " + x);
}
}

volatile

volatile关键字修饰的变量具有原子性和同步性,相当于实现了对单⼀字段的线程间互斥访问。

volatile关键字能够保证内存的可见性,如果用volatile关键字声明了一个变量,在一个线程里面改变了这个变量的值,那其它线程是立马可见更改后的值的。

volatile可以看做是简化版的 synchronized.

volatile 只对基本类型 (byte、char、short、int、long、float、double、boolean)的赋值操作和对象的引⽤赋值操作有效。

java.util.concurrent.atomic:

AtomicInteger、AtomicBoolean 等类,作⽤和 volatile 基本⼀致,可以看做是 volatile修饰的Integer、Boolean等类。

Lock / ReentrantReadWriteLock

Lock同样是加锁机制,但使⽤⽅式更灵活,同时也更麻烦:

1
2
3
4
5
6
7
8
java复制代码Lock lock = new ReentrantLock();
...
lock.lock();
try {
x++;
} finally {
lock.unlock();
}

Synchronized存在的一个性能问题就是读与读之间互斥,所以我们⼀般并不会只是使⽤ Lock ,⽽是会使⽤更复杂的锁,例如 ReadWriteLock ,从而进行一些更加细致化的操作,如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
java复制代码ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
Lock readLock = lock.readLock();
Lock writeLock = lock.writeLock();
private int x = 0;
private void writeOperate () {
writeLock.lock();
try {
x++;
} finally {
writeLock.unlock();
}
}
private void readOperate ( int time){
readLock.lock();
try {
System.out.println();
} finally {
readLock.unlock();
}
}

读取锁是共享的,因而上述代码中,有线程写操作时,其他线程不可写,不可读;该线程读操作时,其他线程不可写,但可读。

线程间通信/交互

线程有自己的私有空间,但当我多个线程之间相互协作的时候,就需要进行线程间通信方,本节将介绍Java线程之间的几种通信原理。

锁与同步

这种方式主要是对全局变量加锁,即用synchronized关键字对对象或者代码块加锁lock,来达成线程间通信。

这种方式可详见上一节线程同步中的例子。

等待/通知机制

基于“锁”的方式需要线程不断去尝试获得锁,这会耗费服务器资源。

Java多线程的等待/通知机制是基于Object类的wait()方法和notify(), notifyAll()方法来实现的,

wait()方法和notify()方法必须写在synchronized代码块里面:

wait()和notify()方法必须通过获取的锁对象进行调用,因为wait就是线程在获取对象锁后,主动释放对象锁,同时休眠本线程,直到有其它线程调用对象的notify()唤醒该线程,才能继续获取对象锁,并继续执行。相应的notify()就是对对象锁的唤醒操作,因而必须放在加锁的synchronized代码块环境内。

notify()方法会随机叫醒一个正在等待的线程,而notifyAll()会叫醒所有正在等待的线程,被唤醒的线程重新在就绪队列中按照一定算法最终再次被处理机获得并进行处理,而不是立马重新获得处理机。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
java复制代码public class mythread {

private static Object lock = new Object();

static class ThreadA implements Runnable {
@Override
public void run() {
synchronized (lock) {
for (int i = 0; i < 5; i++) {
try {
System.out.println("ThreadA: " + i);
lock.notify();
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
lock.notify();
}
}
}

static class ThreadB implements Runnable {
@Override
public void run() {
synchronized (lock) {
for (int i = 0; i < 5; i++) {
try {
System.out.println("ThreadB: " + i);
lock.notify();
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
lock.notify();
}
}
}


public static void main(String[] args) {
new Thread(new ThreadA()).start();
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
e.printStackTrace();
}
new Thread(new ThreadB()).start();


}
}

join方法

join()方法让当前线程陷入“等待”状态,等join的这个线程执行完成后,再继续执行当前线程。

当主线程创建并启动了耗时子线程,而主线程早于子线程结束之前结束时,就可以用join方法等子线程执行完毕后,从而让主线程获得子线程中的处理完的某个数据。

join()方法及其重载方法底层都是利用了wait(long)这个方法。

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 class mythread {

static class ThreadA implements Runnable {

@Override
public void run() {
try {
System.out.println("子线程睡一秒");
Thread.sleep(1000);
System.out.println("子线程睡完了一秒");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(new ThreadA());
thread.start();
thread.join();
System.out.println("如果不加join方法,这行就会先打印出来");
}
}

sleep方法

sleep方法是Thread类的一个静态方法。它的作用是让当前线程睡眠一段时间:

  • Thread.sleep(long)

这里需要强调一下:**sleep方法是不会释放当前的锁的,而wait方法会。**这也是最常见的一个多线程面试题。

sleep方法和wait方法的区别:

  • wait可以指定时间,也可以不指定;而sleep必须指定时间。
  • wait释放cpu资源,同时释放锁;sleep释放cpu资源,但是不释放锁,所以易死锁。
  • wait必须放在同步块或同步方法中,而sleep可以再任意位置

ThreadLocal类

ThreadLocal是一个本地线程副本变量工具类,可以理解成为线程本地变量或线程本地存储。严格来说,ThreadLocal类并不属于多线程间的通信,而是让每个线程有自己“独立”的变量,线程之间互不影响。

ThreadLocal类最常用的就是set方法和get方法。示例代码:

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复制代码public class mythread {
static class ThreadA implements Runnable {
private ThreadLocal<String> threadLocal;

public ThreadA(ThreadLocal<String> threadLocal) {
this.threadLocal = threadLocal;
}

@Override
public void run() {
threadLocal.set("A");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("ThreadA输出:" + threadLocal.get());
}

public static void main(String[] args) {
ThreadLocal<String> threadLocal = new ThreadLocal<>();
new Thread(new ThreadA(threadLocal)).start();
}
}
}

可以看到,ThreadA可以存取自己当前线程的一个值。如果开发者希望将类的某个静态变量(user ID或者transaction ID)与线程状态关联,则可以考虑使用ThreadLocal,而不是在每个线程中声明一个私有变量来操作,加“重”线程。

InheritableThreadLocal是ThreadLocal的继承子类,不仅当前线程可以存取副本值,而且它的子线程也可以存取这个副本值。

信号量机制

JDK提供了一个类似于“信号量”功能的类Semaphore。在多个线程(超过2个)需要相互合作的场景下,我们用简单的“锁”和“等待通知机制”就不那么方便了。这个时候就可以用到信号量。JDK中提供的很多多线程通信工具类都是基于信号量模型的。

管道

管道是基于“管道流”的通信方式。JDK提供了PipedWriter、 PipedReader、 PipedOutputStream、 PipedInputStream。其中,前面两个是基于字符的,后面两个是基于字节流的。

应用场景:管道多半与I/O流相关。当我们一个线程需要先另一个线程发送一个信息(比如字符串)或者文件等等时,就需要使用管道通信了。

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
java复制代码public class Pipe {
static class ReaderThread implements Runnable {
private PipedReader reader;

public ReaderThread(PipedReader reader) {
this.reader = reader;
}

@Override
public void run() {
System.out.println("this is reader");
int receive = 0;
try {
while ((receive = reader.read()) != -1) {
System.out.print((char)receive);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}

static class WriterThread implements Runnable {

private PipedWriter writer;

public WriterThread(PipedWriter writer) {
this.writer = writer;
}

@Override
public void run() {
System.out.println("this is writer");
int receive = 0;
try {
writer.write("test");
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
writer.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}

public static void main(String[] args) throws IOException, InterruptedException {
PipedWriter writer = new PipedWriter();
PipedReader reader = new PipedReader();
writer.connect(reader); // 这里注意一定要连接,才能通信

new Thread(new ReaderThread(reader)).start();
Thread.sleep(1000);
new Thread(new WriterThread(writer)).start();
}
}

// 输出:
this is reader
this is writer
test

参考:

concurrent.redspider.group/

www.matools.com/api/java8

本文转载自: 掘金

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

网络安全之一个渗透测试小案例

发表于 2021-09-13
  1. 起因:

几天前,收到一个国外目标(公司)的渗透测试任务,时间为两周;

大概看了一下目标是类似于国内阿里云那样提供云服务的平台;

image.png

常规信息收集过后,尝试渗透三天无果… 于是下班前只能祭出我的”大杀器”—缝合怪.py。缝合了一些好用的扫描器,一键 XRAY多线程批量扫 + 自动添加任务到AWVS + 自动添加任务到arl + …加入资产后就下班回家了。

到了第二天一看扫描结果,心里暗道不妙,md坏起来了啊。。。

image.png

image.png

扫描器里一个洞都没,goby里所有资产显示只开放两个端口80、443。

image.png

image.png

不慌,问题不大,时间还长,接下来要做的,就是整理思路,重新来过。

在重新整理之前收集到的资产中,发现测试目标的旁站有一个有趣的404页面:

image.png

NoSuchBucket + BucketaName

想到了 阿里云 的bucket劫持漏洞,幸福来得太突然了。

  1. 经过:

使用测试账号登录自己的云平台尝试进行劫持:
1.点击对象存储服务:

image.png

2.点击创建桶:

image.png

3.桶的名字为BucketName字段:

image.png

4.将访问控制权限更改为公共读写:

image.png

5.点击对象,创建hack.txt:

image.png

6.完成后刷新321.asd.com为如下:

image.png

发现BucketName字段消失了,原来的NoSuchBucket也变成了NoSuchCustomDomain,说明我们的修改对它造成了影响!
7.NoSuchCustomDomain?那我们就来给他设置一个,点击域名管理尝试绑定域名:

image.png

8.访问321.asd.com/

image.png

9.访问:321.asd.com/hack.txt (hack.txt为我们刚才上传的)

(后期尝试上传图片,html等文件均可)

[图片上传中…(image.png-8136f3-1631536782821-0)]

劫持成功!拿来吧你!

image.png

  1. 结果:

  1. 删除该桶后,321.asd.com/恢复bucket字段:

image.png

漏洞危害:劫持Bucket,并开放匿名读取功能。可以挂黑页或引用js文件,攻击者可以上传恶意js文件,去盗取用户信息。。。

2021最新整理网络安全\渗透测试/安全学习(全套视频、大厂面经、精品手册、必备工具包)一>点我<一

  1. 还有几天时间,接着搞搞(ji xu mo yu),渗透测试一定要耐心并且专注~

本文转载自: 掘金

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

Activiti 学习(三)—— Activiti 流程启动

发表于 2021-09-13

Activiti 流程启动

流程定义部署后,就可以通过工作流管理业务流程了,也就是说前文部署的出差申请流程可以使用了。针对该流程,启动一个流程表示发起一个新的出差申请单,这就相当于 java 类与 java 对象的关系,类定义好后需要创建一个对象使用,也可以创建多个对象。对于出差申请流程,张三发起一个出差申请单需要启动一个流程实例,李四发起一个出差申请单也需要启动一个流程实例

Activiti 流程启动主要有两种方式,分别是根据 processDefinitionKey 启动和根据 processDefinitionId 启动

1. 根据 processDefinitionKey 启动

processDefinitionKey 就是 act_re_procdef 表的 KEY_ 字段的值,是对应的流程定义的 key

1
2
3
4
5
6
7
8
9
java复制代码@Test
public void testStartProcess() {
// 1. 创建 ProcessEngine
ProcessEngine processEngine = ProcessEngines.getDefaultProcessEngine();
// 2. 获取 RuntimeService
RuntimeService runtimeService = processEngine.getRuntimeService();
// 3. 根据 processDefinitionKey 启动流程
ProcessInstance instance = runtimeService.startProcessInstanceByKey("evection");
}

2. 根据 processDefinitionId 启动

processDefinitionId 就是 act_re_procdef 的主键 ID 例如 evection:1:22503

1
2
3
4
5
6
7
8
9
java复制代码@Test
public void testStartProcess() {
// 1. 创建 ProcessEngine
ProcessEngine processEngine = ProcessEngines.getDefaultProcessEngine();
// 2. 获取 RuntimeService
RuntimeService runtimeService = processEngine.getRuntimeService();
// 3. 根据 processDefinitionKey 启动流程
ProcessInstance instance = runtimeService.startProcessInstanceById("evection:1:22503");
}

Activiti 个人任务查询

流程启动后,任务的负责人就可以查询自己当前需要处理的任务,查询出来的任务都是该用户的待办任务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java复制代码public void testFindPersonTaskList() {
// 1. 获取流程引擎
ProcessEngine processEngine = ProcessEngines.getDefaultProcessEngine();
// 2. 获取 taskService
TaskService taskService = processEngine.getTaskService();
// 3. 获取流程 key 和任务的负责人,查询任务
List<Task> taskList = taskService.createTaskQuery()
.processDefinitionKey("evection") // 流程key
.taskAssignee("zhangsan") // 要查询的负责人
.list();
// 4. 输出
for (Task task : taskList) {
System.out.println("流程实例 id = " + task.getProcessInstanceId());
System.out.println("任务 id = " + task.getId());
System.out.println("任务负责人 = " + task.getAssignee());
System.out.println("任务名称 = " + task.getName());
}
}

Activiti 完成个人任务

根据任务 id,也就是 查找任务并处理

1
2
3
4
5
6
7
8
java复制代码public void completeTask() {
// 1. 获取流程引擎
ProcessEngine processEngine = ProcessEngines.getDefaultProcessEngine();
// 2. 获取 TaskService
TaskService taskService = processEngine.getTaskService();
// 3. 根据任务id 完成任务
taskService.complete("25005");
}

每次都要查找任务 id 很麻烦,一般来说,是任务负责人查询待办任务,选择任务进行处理,完成任务,那我们就能根据任务负责人和任务 key 来查找任务并完成

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码public void completeTask() {
// 1. 获取流程引擎
ProcessEngine processEngine = ProcessEngines.getDefaultProcessEngine();
// 2. 获取 TaskService
TaskService taskService = processEngine.getTaskService();
// 3. 获取 jerry - evection 对应的任务
Task task = taskService.createTaskQuery()
.processDefinitionKey("evection")
.taskAssignee("jerry")
.singleResult();
// 4. 根据任务 id 完成任务
taskService.complete(task.getId());
}

以此类推,直至整个出差流程完成

本文转载自: 掘金

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

关系型数据库如何存储树形结构?

发表于 2021-09-13

图片说明

方法一 父节点表示法

每一个节点持有父节点的引用。为了更好的处理森林,抽象一个不存在的0节点,森林中所有树挂在改节点下,将森林转换为一颗树来处理。

1
2
3
4
5
6
sql复制代码CREATE TABLE node1 (
id INT AUTO_INCREMENT PRIMARY KEY ,
name VARCHAR(12) NOT NULL,
num INT NOT NULL DEFAULT 0 COMMENT '节点下叶子的数量',
p_id INT NOT NULL DEFAULT 0 COMMENT '0表示根节点'
);

此方法结构简单,更新也简单,但是在查询子孙节点时,效率低下.

方法二 路径表示法

在方法一的基础上,添加path_key(search_key)字段,该字段存储从根节点到节点的标识路径,这里依然抽象一个不存在的0节点。

1
2
3
4
5
6
7
8
sql复制代码CREATE TABLE node2 (
id INT AUTO_INCREMENT PRIMARY KEY ,
name VARCHAR(12) NOT NULL ,
num INT NOT NULL DEFAULT 0 COMMENT '节点下叶子的数量',
p_id INT NOT NULL DEFAULT 0 COMMENT '0表示根节点',
search_key VARCHAR(128) DEFAULT '' COMMENT '用来快速搜索子孙的key,存储根节点到该节点的路径',
level INT DEFAULT 0 COMMENT '层级'
);

插入数据

1
2
3
4
5
6
7
8
sql复制代码INSERT INTO node2(id,name, num, p_id,search_key) VALUES
(1,'A',10,0,'0-1'),
(2,'B',7,1,'0-1-2'),
(3,'C',3,1,'0-1-3'),
(4,'D',1,3,'0-1-3-4'),
(5,'E',2,3,'0-1-3-5'),
(6,'F',2,0,'0-6'),
(7,'G',2,6,'0-6-7');

查询数据

1
2
3
4
5
6
sql复制代码# 查询森林的根节点
SELECT * FROM node2 WHERE p_id = 0 AND search_key LIKE '0-%' AND level = 0;

# 查询节点A的所有子孙节点
##### SELECT * FROM node2 WHERE search_key LIKE '{A.search_key}%';
SELECT * FROM node2 WHERE search_key LIKE '0-1-%';

更新数据(当插入一定量叶子结点时)

1
2
sql复制代码# 例如,更新节点C的权重
UPDATE node2,(SELECT sum(num) AS sum FROM node2 WHERE search_key LIKE '0-1-3-%') rt SET num = rt.sum WHERE id=3;

有节点权重累加时,将所有父辈权重再加1,只需要将该节点的search_key以’-‘ 切分,得到的就是所有父辈的id(0除外)。例如,将节点D的权重+1,这里使用where locate,实际更好是先将search_key split之后使用where in查询

1
sql复制代码UPDATE node2,(SELECT search_key FROM node2 WHERE id = 4) rt SET num=num+1 WHERE locate(id,rt.search_key);

删除某个节点,比如删除B节点

假设删除节点子孙全部清理

1
sql复制代码DELETE FROM node2 WHERE search_key LIKE '0-1-2%';

方法3 前序遍历法

给节点分配左右值,第一次到达该节点时,设置左值,第二次到达该节点,设置右值,每走一步,序号加1
图片说明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
sql复制代码CREATE TABLE node3 (
id INT AUTO_INCREMENT PRIMARY KEY ,
tree_id INT NOT NULL COMMENT '为保证对某一棵的操作不影响森林中的其他书',
name VARCHAR(12) NOT NULL ,
num INT NOT NULL DEFAULT 0 COMMENT '节点下叶子的数量、节点权重(可认为分类下产品数量)',
lft INT NOT NULL ,
rgt INT NOT NULL ,
level INT DEFAULT 0
);
insert into node3 (tree_id, name, num, lft, rgt, level) VALUE (1,'B',7,2,3,2);
insert into node3 (tree_id, name, num, lft, rgt, level) VALUE (1,'D',1,5,6,3);
insert into node3 (tree_id, name, num, lft, rgt, level) VALUE (1,'E',2,7,8,3);
insert into node3 (tree_id, name, num, lft, rgt, level) VALUE (1,'C',3,4,9,2);
insert into node3 (tree_id, name, num, lft, rgt, level) VALUE (1,'A',10,1,10,1);
insert into node3 (tree_id, name, num, lft, rgt, level) VALUE (2,'G',2,2,3,2);
insert into node3 (tree_id, name, num, lft, rgt, level) VALUE (2,'F',2,1,4,1);

这里加入了一个tree_id字段,用来保证对一棵树内的更新操作,不会影响到别的树,有利于提高效率。

插入数据(用tree_id判断是否同一棵树)
图片说明
仔细看可以发现,在已有一个节点下append一个节点M的话,M的左右值应该连续的,按照先序遍历的顺序,只需要将走在其后的节点的左右值分别+2,并且M节点的父节点的右值必然也要+2。

查询数据

1
2
3
4
5
csharp复制代码# 查询子节点
select * from node3 where lft >lft && rgt < rgt

# 查询父节点
select * from node3 where lft < lft && rgt >rgt

本文转载自: 掘金

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

DDD落地之架构分层 一前言 二为什么我们要使用DDD

发表于 2021-09-13

一.前言

hello,everyone。周末我开通了我的公众号:柏炎大叔。会与掘金同步发布系列文章,可以加个关注,第一时间收到我的推文。

DDD的微信群我也已经建好了,由于文章内不能放二维码,大家可以加我微信baiyan_lou,备注DDD交流,我拉你进群,欢迎交流共同进步。

image.png

DDD系列Demo被好多读者催更。肝了一周,参考了众多资料,与众多DDD领域的大佬进行了结构与理念的沟通后,终于完成了改良版的代码层次结构。

本文将给大家展开讲一讲

  • 为什么我们要使用DDD?
  • 到底什么样的系统适配DDD?
  • DDD的代码怎么做,为什么要这么做?

你可以直接阅读本文,但我建议先阅读:一文带你落地DDD,如果你对DDD已经有过了解与认知,请直接阅读。

干货直接上,点此查看demo代码,配合代码阅读本文,体验更深,别忘了star~

DDD系列博客

  1. 一文带你落地DDD
  2. DDD落地之事件驱动模型
  3. DDD落地之仓储
  4. DDD落地之架构分层

我的第一本掘金小册《深入浅出DDD》已经在掘金上线,欢迎大家试读~

二.为什么我们要使用DDD

虽然我在第一篇DDD的系列文:一文带你落地DDD中已经做过介绍我们使用DDD的理由。但是对于业务架构不太熟悉的同学还是无法get到DDD的优势是什么。

作为程序员嘛,我还是比较提倡大家多思考,多扎实自己的基础知识的。面试突击文虽香,但是,面试毕竟像是考试,更多时候我们还是需要在一家公司里面去工作。别人升职加薪,你怨声载道,最后跳槽加小几千没有意义嘛。

image.png

言归正传,我相信基本上99%的java开发读者,不管你是计科专业出身还是跨专业,初学spring或者springboot的时候,接触到的代码分层都是MVC。

这说明了MVC有它自身独有的优势:

  • 开发人员可以只关注整个结构中的其中某一层;
  • 可以很容易的用新的实现来替换原有层次的实现;
  • 可以降低层与层之间的依赖;
  • 有利于标准化;
  • 利于各层逻辑的复用。

但是真实情况是这样吗?随着你系统功能迭代,业务逻辑越来越复杂之后。MVC三层中,V层作为数据载体,C层作为逻辑路由都是很薄的一层,大量的代码都堆积在了M层(模型层)。一个service的类,动辄几百上千行,大的甚至几万行,逻辑嵌套复杂,主业务逻辑不清晰。service做的稍微轻量化一点的,代码就像是胶水,把数据库执行逻辑与控制返回给前端的逻辑胶在一起,主次不清晰。

一看你的工程,类啊,代码量啊都不少,你甚至不知道如何入手去修改“屎山”一样的代码。

归根到底的原因是什么?

image.png

service承载了它这个年纪不该承受的业务逻辑。

举个例子: 你负责了一个项目的从0到1的搭建,后面业务越来越好,招了新的研发进来。新的研发跟你一起开发,service层逻辑方法类似有不完全相同,为了偷懒,拷贝了你的代码,改了一小段逻辑。这时候基本上你的代码量已经是乘以2了。同理再来一个人,你的代码量可能乘了4。然而作为数据载体的POJO繁多,里面空空如也,你想把逻辑放进去,却发现无从入手。POJO的贫血模型陷入了恶性循环。

那么DDD为什么可以去解决以上的问题呢?

DDD核心思想是什么呢?解耦!让业务不是像炒大锅饭一样混在一起,而是一道道工序复杂的美食,都有他们自己独立的做法。

DDD的价值观里面,任何业务都是某个业务领域模型的职责体现。A领域只会去做A领域的事情,A领域想去修改B领域,需要找中介(防腐层)去对B领域完成操作。我想完成一个很长的业务逻辑动作,在划分好业务边界之后,交给业务服务的编排者(应用服务)去组织业务模型(聚合)完成逻辑。

这样,每个服务(领域)只会做自己业务边界内的事情,最小细粒度的去定义需求的实现。原先空空的贫血模型摇身一变变成了充血模型。原理冗长的service里面类似到处set,get值这种与业务逻辑无关的数据载体包装代码,都会被去除,进到应用服务层,你的代码就是你的业务逻辑。逻辑清晰,可维护性高!

三.到底什么样的系统适配DDD

看完上文对于DDD的分析之后是不是觉得MVC一对比简直就是垃圾。但是你回过头来想想,DDD其实在10几年前就已经被提出来了,但为什么是近几年才开始逐渐进入大众的视野?

相信没有看过我之前DDD的文章的同学看了我上面的分析大概也能感觉的到,DDD这个系统不像MVC结构那么简单,分层肯定更加复杂。

因此不是适配DDD的系统是什么呢?

中小规模的系统,本身业务体量小,功能单一,选择mvc架构无疑是最好的。

项目化交付的系统,研发周期短,一天到晚按照甲方的需求定制功能。

相反的,适配DDD的系统是什么呢?

中大规模系统,产品化模式,业务可持续迭代,可预见的业务逻辑复杂性的系统。

总而言之就是:

你还不了解DDD或者你们系统功能简单,就选择MVC.

你不知道选用什么技术架构做开发,业务探索阶段,选用MVC.

其他时候酌情考虑上DDD。

四.DDD的代码怎么做,为什么要这么做

4.1.经典分层

image-20210913185730992.png
在用户界面层和业务逻辑层中间加了应用层(Application Layer) , 业务逻辑层改为领域层, 数据访问层改为基础设施层(Infrastructure Layer) , 突破之前数据库访问的限制。 固有的思维中,依赖是自顶向下传递的,用户界面依赖应用层,应用层依赖领域层和基础设施层,越往下的层,与业务越远,并更加通用;出于重用的考虑,通用的功能会剥离成框架或者平台,而在低层次(基础设施层)会调用、依赖这些框架,也就导致了业务对象(领域层)依赖外部平台或框架。

4.2.依赖倒置分层

image-20210913190943631.png

为了突破这种违背本身业务领域的依赖,将基础设施往上提,当领域服务与基础设置有交集时,定义一个接口(灰度接口),让基础设施去实现对应的接口。接口本身是介于应用服务与领域服务之间的,为了纯净化领域层而存在。

Image.png

这么做的好处就是,从分包逻辑来看,上层依赖下层,底层业务域不依赖任何一方,领域独立。

4.3.DDD分层请求调用链

未命名文件.png

4.3.1.增删改

1.用户交互层发起请求

2.应用服务层编排业务逻辑【仅做方法编排,不处理任何逻辑】

3.编排逻辑如果依赖三方rpc,则定义adapter,方式三方服务字段影响到本服务。

4.编排逻辑如果依赖其他领域服务,应用服务,可直接调用,无需转化。但是与当前框架不相符合的,例如发送短信这种,最好还是走一下适配器,运营商换了,依赖的应用服务没准都不同了。

5.聚合根本身无法处理的业务在领域层处理,依赖倒置原则,建立一层interfaces层(灰度防腐层),放置领域层与基础设置的耦合。

6.逻辑处理结束,调用仓储聚合方法。

4.3.2.查询

CQRS模型,与增删改不同的应用服务,是查询应用服务。不必遵守DDD分层规则(不会对数据做修改)。简单逻辑甚至可以直接由controller层调用仓储层返回数据。

五.总结

其实DDD在分层上从始至终一致在贯穿的一个逻辑就是,解耦。如果真的极端推崇者,每一层,每一步都会增加一个适配器。我觉得这个对于研发来说实在太痛苦了,还是要在架构与实际研发上做一个中和。

六.特别鸣谢

lilpilot

七.联系我

文中如有不正确之处,欢迎指正,写文不易,点个赞吧,么么哒~

钉钉:louyanfeng25

微信:baiyan_lou

公众号:柏炎大叔

image.png

本文转载自: 掘金

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

Java 多线程 JUC 并发工具原理

发表于 2021-09-13

首先分享之前的所有文章 , 欢迎点赞收藏转发三连下次一定 >>>> 😜😜😜

文章合集 : 🎁 juejin.cn/post/694164…

Github : 👉 github.com/black-ant

CASE 备份 : 👉 gitee.com/antblack/ca…

一 . 前言

趁着有空 , 赶紧把之前欠的债还上 . 这是多线程一阶段计划的最后一篇 , 后续多线程会转入修订和深入阶段 . 彻底吃透多线程.

二. 工具介绍

之前说 AQS 的时候曾经提到过这几个类 , 这几个类有一些各自的特点 , 很符合特定的场景 , 之前在生产上用的还挺舒服.

我们一般使用的并发工具有四种 :

CyclicBarrier : 放学一起走

  • 允许一组线程互相等待,直到到达某个公共屏障点 (common barrier point)
  • 让一组线程到达一个屏障时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活

CountDownLatch : 等人到齐了就触发

  • 在完成一组正在其他线程中执行的操作之前,它允许一个或多个线程一直等待
  • 用给定的计数 初始化 CountDownLatch。
  • 由于调用了 countDown() 方法,所以在当前计数到达零之前,await 方法会一直受阻塞。
  • 之后,会释放所有等待的线程,await 的所有后续调用都将立即返回。这种现象只出现一次——计数无法被重置。
  • CountDownLatch是通过一个计数器来实现的,当我们在new 一个CountDownLatch对象的时候需要带入该计数器值,该值就表示了线程的数量。
  • 每当一个线程完成自己的任务后,计数器的值就会减1。当计数器的值变为0时,就表示所有的线程均已经完成了任务

Semaphore

  • 信号量Semaphore是一个控制访问多个共享资源的计数器,和CountDownLatch一样,其本质上是一个“共享锁”。

Exchanger

  • 可以在对中对元素进行配对和交换的线程的同步点
  • 每个线程将条目上的某个方法呈现给 exchange 方法,与伙伴线程进行匹配,并且在返回时接收其伙伴的对象 , Exchanger 可能被视为 SynchronousQueue 的双向形式

三 .原理解析

3 .1 CyclicBarrier

作用 :
它允许一组线程互相等待,直到到达某个公共屏障点 (Common Barrier Point)。在涉及一组固定大小的线程的程序中,这些线程必须不时地互相等待,此时 CyclicBarrier 很有用。因为该 Barrier 在释放等待线程后可以重用,所以称它为循环( Cyclic ) 的 屏障( Barrier ) 。

内部原理 :
内部使用重入锁ReentrantLock 和 Condition

构造函数 :

  • CyclicBarrier(int parties):
+ 创建一个新的 CyclicBarrier,它将在给定数量的参与者(线程)处于等待状态时启动,
+ 但它不会在启动 barrier 时执行预定义的操作。
  • CyclicBarrier(int parties, Runnable barrierAction) :
+ 创建一个新的 CyclicBarrier,它将在给定数量的参与者(线程)处于等待状态时启动,
+ 并在启动 barrier 时执行给定的屏障操作,该操作由最后一个进入 barrier 的线程执行。

使用变量 :

  • parties 变量 : 表示拦截线程的总数量。
  • count 变量 : 表示拦截线程的剩余需要数量。
  • barrierAction 变量 : 为 CyclicBarrier 接收的 Runnable 命令,用于在线程到达屏障时,优先执行 barrierAction ,用于处理更加复杂的业务场景。
  • generation 变量 : 表示 CyclicBarrier 的更新换代
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
java复制代码// 常用方法 : 
M- await : 等待状态
M- await(long timeout, TimeUnit unit) : 等待超时
M- dowait
- 该方法第一步会试着获取锁
- 如果分代已经损坏,抛出异常
- 如果线程中断,终止CyclicBarrier
- 进来线程 ,--count
- count == 0 表示所有线程均已到位,触发Runnable任务
- 唤醒所有等待线程,并更新generation
> 跳出等待状态的方法
- 最后一个线程到达,即index == 0
- 超出了指定时间(超时等待)
- 其他的某个线程中断当前线程
- 其他的某个线程中断另一个等待的线程
- 其他的某个线程在等待barrier超时
- 其他的某个线程在此barrier调用reset()方法。reset()方法用于将屏障重置为初始状态。
SC- Generation : 描述了 CyclicBarrier 的更新换代。
- 在CyclicBarrier中,同一批线程属于同一代。
- 当有 parties 个线程全部到达 barrier 时,generation 就会被更新换代。
- 其中 broken 属性,标识该当前 CyclicBarrier 是否已经处于中断状态
M- breakBarrier : 终止所有的线程
M- nextGeneration : 更新换代操作
- 1. 唤醒所有线程。
- 2. 重置 count 。
- 3. 重置 generation 。
M- reset : 重置 barrier 到初始化状态
M- getNumberWaiting : 获得等待的线程数
M- 判断 CyclicBarrier 是否处于中断

CyclicBarrier.jpg

使用案例 :

Gitee CyclicBarrier 使用

问题补充 :

1
2
3
4
5
6
7
8
9
java复制代码
// 问题一 : 拦截的核心
1. 传入总得 Count 数
2. 每次进来都会 --count , 同时判断 count ==0
3. 如果不为 0 ,当前线程就会阻塞

// 问题二 : 涉及源码
private final ReentrantLock lock = new ReentrantLock();
private final Condition trip = lock.newCondition();

3.2 CountDownLatch

在完成一组正在其他线程中执行的操作之前,它允许一个或多个线程一直等待

用给定的计数 初始化 CountDownLatch。由于调用了 countDown() 方法,所以在当前计数到达零之前,await 方法会一直受阻塞。之后,会释放所有等待的线程,await 的所有后续调用都将立即返回。这种现象只出现一次, 计数无法被重置。如果需要重置计数,请考虑使用 CyclicBarrier。

CountDownLatch是通过一个计数器来实现的,当我们在new 一个CountDownLatch对象的时候需要带入该计数器值,该值就表示了线程的数量。每当一个线程完成自己的任务后,计数器的值就会减1。当计数器的值变为0时,就表示所有的线程均已经完成了任务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
java复制代码// 内部主要方法
> CountDownLatch内部依赖Sync实现,而Sync继承AQS

> sync :
: tryAcquireShared 获取同步状态
: tryReleaseShared 释放同步状态

> await() :
使当前线程在锁存器倒计数至零之前一直等待,除非线程被中断
: sync.acquireSharedInterruptibly(1);
: 内部使用AQS的acquireSharedInterruptibly(int arg)

> getState()
: 获取同步状态,其值等于计数器的值
: 从这里我们可以看到如果计数器值不等于0,则会调用doAcquireSharedInterruptibly(int arg)

> doAcquireSharedInterruptibly
: 自旋方法会尝试一直去获取同步状态

> countDown
: CountDownLatch提供countDown() 方法递减锁存器的计数,如果计数到达零,则释放所有等待的线程
: 内部调用AQS的releaseShared(int arg)方法来释放共享锁同步状态
: tryReleaseShared(int arg)方法被CountDownLatch的内部类Sync重写

CountDownLatch.jpg

参考案例

Gitee CountDownLatch 使用

总结
CountDownLatch 内部通过共享锁实现。在创建CountDownLatch实例时,需要传递一个int型的参数:count,该参数为计数器的初始值,也可以理解为该共享锁可以获取的总次数。

当某个线程调用await()方法,程序首先判断count的值是否为0,如果不会0的话则会一直等待直到为0为止 (PS : 可以多个线程都调用 await)

当其他线程调用countDown()方法时,则执行释放共享锁状态,使count值 – 1 (PS :countDown 并不会阻塞)

当在创建CountDownLatch时初始化的count参数,必须要有count线程调用countDown方法才会使计数器count等于0,锁才会释放,前面等待的线程才会继续运行。注意CountDownLatch不能回滚重置

3 .3 Semaphore

基础点
信号量Semaphore是一个控制访问多个共享资源的计数器,和CountDownLatch一样,其本质上是一个“共享锁”。

从概念上讲,信号量维护了一个许可集。如有必要,在许可可用前会阻塞每一个 acquire(),然后再获取该许可。每个 release() 添加一个许可,从而可能释放一个正在阻塞的获取者。

Semaphore 通常用于限制可以访问某些资源(物理或逻辑的)的线程数目

当一个线程想要访问某个共享资源时,它必须要先获取Semaphore,当Semaphore >0时,获取该资源并使Semaphore – 1。如果Semaphore值 = 0,则表示全部的共享资源已经被其他线程全部占用,线程必须要等待其他线程释放资源。当线程释放资源时,Semaphore则+1

实现细节

Semaphore提供了两个构造函数:

  • Semaphore(int permits) :创建具有给定的许可数和非公平的公平设置的 Semaphore。
  • Semaphore(int permits, boolean fair) :创建具有给定的许可数和给定的公平设置的 Semaphore。

Semaphore默认选择非公平锁。

当信号量Semaphore = 1 时,它可以当作互斥锁使用。其中0、1就相当于它的状态,当=1时表示其他线程可以获取,当=0时,排他,即其他线程必须要等待。

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码//------ 信号量获取
> acquire()方法来获取一个许可
: 内部调用AQS的acquireSharedInterruptibly(int arg),该方法以共享模式获取同步状态


> 公平
: 判断该线程是否位于CLH队列的列头
: 获取当前的信号量许可
: 设置“获得acquires个信号量许可之后,剩余的信号量许可数”
: CAS设置信号量
> 非公平
: 不需要判断当前线程是否位于CLH同步队列列头

Semaphore.jpg

3 .4 Exchanger

可以在对中对元素进行配对和交换的线程的同步点

每个线程将条目上的某个方法呈现给 exchange 方法,与伙伴线程进行匹配,并且在返回时接收其伙伴的对象 , Exchanger 可能被视为 SynchronousQueue 的双向形式

Exchanger,它允许在并发任务之间交换数据
当两个线程都到达同步点时,他们交换数据结构,因此第一个线程的数据结构进入到第二个线程中,第二个线程的数据结构进入到第一个线程中

TODO : Exchanger 的源代码比较绕 ,而且这个组件使用场景并不多 , 所以先留个坑 , 以后项目上真的有场景了再实际上分析一下

3.5 并发工具使用

@ github.com/black-ant/c…

补充 :

# CountDownLatch 和 CyclicBarrier 如何理解 ?

  • CyclicBarrier : 小学生去郊游 , 老师下车时统计人数 ,人数到齐了才能一起参观
  • CountDownLatch : 幼儿园老师送孩子(ChildThread)放学 , 走一个记一个数 ,当所有的学生放学后 , 老师(BossThread)下班
1
2
3
4
5
6
java复制代码// 核心解释 : 
CyclicBarrier 就是一堵墙 , 人数到了所有线程才能一起越过墙
CountDownLatch 只是一个计数器 , 数目到了主线程才能执行

// 其他要点 :
CyclicBarrier 可以重置计数 , CountDownLatch 不可以

总结

终于补上了最后一块板 , 后面来真正的深入多线程看看吧 , 争取早日成为多线程大师段位

更新记录

  • 20210915 : 优化布局

本文转载自: 掘金

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

可莉要你帮她做一个蹦蹦炸弹管理系统!(Spring Secu

发表于 2021-09-13

提示:该文章内容已过时!可以作为安全框架的基本学习和体验,但请勿用于新项目或者生产环境!
除了Spring Security作为安全框架,Sa-Token也是个很好的选择!

最近旅行者除了完成每日委托之外,还花了许多时间研究了Spring Security的使用。

今天,可莉急匆匆地跑来找到了旅行者,说自己研究了许多种类的炸弹,但是随着炸弹种类、数量的增加,可莉觉得越来越难以管理自己的“宝贝”了,想请旅行者帮帮忙。

看着可莉满是“求求你”的样子,旅行者决定答应她。

8606675912226.gif

旅行者一拍脑袋,决定用自己这几天“废寝忘食”研究的Spring Security,加上Vue,搭建一个“蹦蹦炸弹”的管理系统。

于此同时,可莉担心被琴团长发现自己具体的“宝贝”类型和数量,便让旅行者做的管理系统只能由她自己来操作,其他人不能查看或者修改。

可莉的好伙伴七七、迪奥娜和早柚也听说可莉最近研发出来了很多类型的炸弹,都非常好奇,可莉也决定让旅行者将她们也加入管理系统,但是可莉只让她们查询炸弹信息,不能修改。

废话不多说,明确了任务,旅行者拿出了笔记本电脑,坐在猫尾酒馆里开始整理思路,并打开了vscode、idea开始干了。

1,思路?整理!梳理啦!

在做这个管理系统之前,旅行者决定好好梳理一下思路。

在此之前,我们还是需要学习用户登录的一些基本原理,这样看这个文章才更好理解,如果不熟悉可以看看这个文章:链接

既然旅行者使用了Spring Security作为安全框架,我们就先来大致了解一下其原理。

Spring Security的登录验证流程核心就是过滤器链。当一个请求到达时按照过滤器链的顺序依次进行处理,通过了所有过滤器链的验证,才能访问我们的API接口。

20200413213910599.png

与此同时,Spring Security还提供了多种过滤器,以实现多种不同认证方式:

  • UsernamePasswordAuthenticationFilter 使用用户名密码的登录认证方式
  • SmsCodeAuthenticationFilter 使用短信验证码登录认证方式
  • SocialAuthenticationFilter 使用社交媒体方式登录认证方式
  • Oauth2AuthenticationProcessingFilter 使用Oauth2的认证方式

今天,旅行者决定使用UsernamePasswordAuthenticationFilter的方式来实现这个管理系统的登录认证方式。

来简单看看认证流程:

image.png

这里看不懂没关系,我们不需要完全掌握里面所有类的逻辑,重要的部分在下面会讲到。

然后,这是一个前后端分离的系统,也就是说,Spring Security不再负责各个页面的跳转,只是负责各个接口的访问管理,前端vue实现页面跳转。

默认情况下,Spring Security中登录成功、失败、退出登录、403等等,都是会跳转到一个专门的页面(默认是请求转发方式)。

但是现在由于是前后端分离的系统,Spring Security只能向前端发送一个JSON格式的响应,前端接收到之后,根据请求内容,控制页面跳转。

除此之外,登录成功后,后端还需要写一个判断用户是否登录的接口并加入拦截。这样前端每次访问这个接口判断是否登录,如果登录了后端就会返回用户数据给前端显示,没有登录就会被拦截返回403,前端跳转至登录页。

思路明确了,我们只让Spring Security控制对各个接口的访问,还有用户登录、权限等等。每一个操作,后端就会返回前端一个JSON数据,前端判断是否操作成功,然后控制跳转和显示。

2,依赖?准备!设置啦!

首先新建Spring Boot工程,在其中勾选上Security,以及MyBatis的相关依赖,并配置数据源。

也可以后续在pom.xml中加入Spring Security依赖:

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

除此之外,我们还需要用到fastjson的json工具:

1
2
3
4
5
xml复制代码<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.73</version>
</dependency>

然后启动项目,访问你的地址,你会发现进入了个登录页面,因为默认情况下,Spring Security会拦截所有请求。

因此,里面有许多东西我们需要修改。

3,数据?模型!构建啦!

现在,我们就需要构建一下数据模型,以及数据库表。

在这个系统中,无疑要使用到RBAC(基于角色的权限管理)。

也就是说,我们要通过建立用户与角色的对应关系,使得每个用户可以拥有多个角色,每个角色可以拥有多个权限,用户根据拥有的角色进行操作与资源访问。

一个最基本的RBAC的类图如下:

image.png

用户可以有多个角色,一个角色可以被多个用户拥有。同时,一个角色可以有多个权限,一个权限也可以被多个角色拥有。

也就是说,用户和角色之间是多对多的关系,角色和权限之间也是多对多的关系。

至于为什么要在用户和权限之间加一个角色?这不是多此一举吗?其实这是有必要的,有利于系统未来扩展,以及灵活地权限控制。

根据此,我们就可以设计数据库了,对于多对多的数据库模型建立和MyBatis的级联如果不太熟悉,可以先看看这个文章:链接

在这个管理系统中,需要有 管理员(admin) 和 访问者(visitor) 两个角色,其中管理员可以 增加(addBomb) 和查询(queryBomb) 炸弹,而访问者只能 查询(queryBomb) 炸弹。

这里的管理员、访问者就是角色,增加、查询炸弹就是权限。

这里创建一个名为dataobject的包,并在里面创建用户类(MyUser)、角色类(Role)和权限类(Permission),如下:

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
java复制代码package com.example.securitytest.dataobject;

import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import java.io.Serializable;
import java.util.HashSet;
import java.util.Set;

/**
* 用户
*/
@Setter
@Getter
@NoArgsConstructor
public class MyUser implements Serializable {

/**
* 主键id
*/
private int id;

/**
* 用户名
*/
private String username;

/**
* 密码
*/
private String password;

/**
* 昵称
*/
private String nickname;

/**
* 头像
*/
private String avatar;

/**
* 用户的角色
*/
private Set<Role> roles;

/**
* 获取该用户的全部权限
*
* @return 用户的权限
*/
public Set<Permission> getPermissions() {
Set<Permission> permissions = new HashSet<>();
for (Role role : roles) {
for (Permission permission : role.getPermissions()) {
permissions.add(permission);
}
}
return permissions;
}

}
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复制代码package com.example.securitytest.dataobject;

import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import java.io.Serializable;
import java.util.List;
import java.util.Set;

/**
* 角色类
*/
@Getter
@Setter
@NoArgsConstructor
public class Role implements Serializable {

/**
* 主键id
*/
private int id;

/**
* 角色名
*/
private String name;

/**
* 拥有该角色的用户
*/
private List<MyUser> myUsers;

/**
* 该角色拥有的权限
*/
private Set<Permission> permissions;

}
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复制代码package com.example.securitytest.dataobject;

import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import java.io.Serializable;
import java.util.Set;

/**
* 权限
*/
@Getter
@Setter
@NoArgsConstructor
public class Permission implements Serializable {

/**
* 主键id
*/
private int id;

/**
* 权限名
*/
private String name;

/**
* 该权限下的角色
*/
private Set<Role> roles;

}

然后创建相应的MyBatis的Mapper类和XML,这里只实现增加和查询两个功能,在此不贴代码了,最后会给出示例仓库的地址。

与此同时,我们最好再构建一个返回结果模型,专门用于返回结果给前端。新建model包,里面建立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
java复制代码package com.example.securitytest.model;

import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import java.io.Serializable;

@Setter
@Getter
@NoArgsConstructor
public class Result<T> implements Serializable {

/**
* 消息
*/
private String message;

/**
* 是否操作成功
*/
private boolean success;

/**
* 返回的数据主体(返回的内容)
*/
private T data;

/**
* 设定结果为成功
*
* @param msg 消息
*/
public void setResultSuccess(String msg) {
this.message = msg;
this.success = true;
this.data = null;
}

/**
* 设定结果为成功
*
* @param msg 消息
* @param data 数据体
*/
public void setResultSuccess(String msg, T data) {
this.message = msg;
this.success = true;
this.data = data;
}

/**
* 设定结果为失败
*
* @param msg 消息
*/
public void setResultFailed(String msg) {
this.message = msg;
this.success = false;
this.data = null;
}

}

可莉要管理的内容是炸弹,因此还需要创建炸弹实体类及其相应的数据库表。

好了,数据模型构建完成,DAO层也完成了,最后定义Control层编写API获取数据,这里就省略API的代码了。

接下来,我们要开始建立用户登录服务了。

这里给出sql文件:

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
sql复制代码-- 初始化用户表
drop table if exists `my_user`, `role`, `user_role`, `permission`, `role_permission`, `bomb`;
create table `my_user`
(
`id` int not null,
`username` varchar(16) not null unique,
`password` varchar(64) not null,
`nickname` varchar(16) not null,
`avatar` varchar(128) not null,
primary key (`id`)
) engine = InnoDB
default charset = utf8mb4;

create table `role`
(
`id` int not null,
`name` varchar(16) not null unique,
primary key (`id`)
) engine = InnoDB
default charset = utf8mb4;

create table `user_role`
(
`user_id` int not null,
`role_id` int not null,
primary key (`user_id`, `role_id`),
foreign key (`user_id`) references `my_user` (`id`) on delete cascade on update cascade,
foreign key (`role_id`) references `role` (`id`) on delete cascade on update cascade
) engine = InnoDB
default charset = utf8mb4;

create table `permission`
(
`id` int not null,
`name` varchar(16) not null unique,
primary key (`id`)
) engine = InnoDB
default charset = utf8mb4;

create table `role_permission`
(
`role_id` int not null,
`permission_id` int not null,
primary key (`role_id`, `permission_id`),
foreign key (`role_id`) references `role` (`id`) on delete cascade on update cascade,
foreign key (`permission_id`) references `permission` (`id`) on delete cascade on update cascade
) engine = InnoDB
default charset = utf8mb4;

create table `bomb`
(
`id` int unsigned auto_increment,
`name` varchar(32) not null,
`type` varchar(32) not null,
primary key (`id`)
) engine = InnoDB
default charset = utf8mb4;

-- 初始化测试数据
insert into `my_user`
values (0, 'klee', '$2a$10$uifIuXG6FzrI.NqhhZN0H.PziUzqn78Nwq7Dg9C2V4Fa3nXNdZj5e', '可莉', '/avatar/klee.jpg'), -- 密码:123456
(1, 'qiqi', '$2a$10$4QRxlo/YuR8ZdFJSW4uV3uT9HqUxZgad5oeQHuHy2nfS4en/Z8dQ2', '七七', '/avatar/qiqi.jpg'), -- 密码:789101112
(2, 'diona', '$2a$10$Q8QQb4s9Iy12qCDmiw0Qwe8/TvCOolKgaylPAus5kE5E5k/cp.2km', '迪奥娜', '/avatar/diona.jpg'), -- 密码:13141516
(3, 'sayu', '$2a$10$uifIuXG6FzrI.NqhhZN0H.PziUzqn78Nwq7Dg9C2V4Fa3nXNdZj5e', '早柚', '/avatar/sayu.jpg');
-- 密码:123456

-- Spring Security中角色必须以"ROLE_"开头
insert into `role`
values (0, 'ROLE_admin'),
(1, 'ROLE_visitor');

insert into `user_role`
values (0, 0),
(1, 1),
(2, 1),
(3, 1);

insert into `permission`
values (0, 'queryBomb'),
(1, 'addBomb');

insert into `role_permission`
values (0, 0),
(0, 1),
(1, 0);

insert into `bomb` (name, type)
values ('小炸弹', '诡雷'),
('兔兔伯爵', '嘲讽'),
('蹦蹦炸弹', '集束炸弹');

4,服务?逻辑!重写啦!

Spring Security中有着自己的用户认证、登录成功/失败、退出登录等等逻辑,这些我们都需要重写,自定义为自己的,才能满足实际业务需要。

(1) 自定义用户名密码登录拦截器

由于是前后端分离系统,我们一般会发送json格式的登录数据给后端,但是默认只支持表单格式的,这个时候我们就需要重写UsernamePasswordAuthenticationFilter,重新实现其中attemptAuthentication方法,这个方法主要用于从前端请求获取用户名和密码,并提交给后续认证。

创建一个包filter,在里面创建MyAuthFilter类,实现自定义的认证过滤器,先给出我的代码:

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复制代码package com.example.securitytest.filter;


import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Map;

/**
* 自定义的认证过滤器,实现前后端分离的json格式登录请求解析
*/
public class MyAuthFilter extends UsernamePasswordAuthenticationFilter {

/**
* 请求体中的用户名字段名
*/
private String usernameParameter = "username";

/**
* 请求体中的密码字段名
*/
private String passwordParameter = "password";

/**
* 创建自定义认证过滤器
*/
public MyAuthFilter() {

}

/**
* 创建自定义认证过滤器,并自定义用户名、密码字段名
*
* @param usernameParameter 自定义用户名字段名
* @param passwordParameter 自定义密码字段名
*/
public MyAuthFilter(String usernameParameter, String passwordParameter) {
this.usernameParameter = usernameParameter;
this.passwordParameter = passwordParameter;
}

@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
// 判断是否为JSON类型数据
if (request.getContentType().equals(MediaType.APPLICATION_JSON_VALUE)) {
// 获取请求体
Map<String, String> requestBody = null;
try {
// 解析json为Map对象
requestBody = new ObjectMapper().readValue(request.getInputStream(), Map.class);
} catch (Exception e) {
e.printStackTrace();
}
// 获取请求体中的用户名/密码字段值,并执行认证
UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(requestBody.get(usernameParameter), requestBody.get(passwordParameter));
// 返回认证结果
return this.getAuthenticationManager().authenticate(auth);
} else {
// 否则,使用默认表单登录方式
return super.attemptAuthentication(request, response);
}
}

}

上述我还加入了usernameParameter和passwordParameter字段,以方便自定义前端登录认证请求中的用户名/密码字段名。

当然,现在实现了自定义的拦截器,我们是不是还需要配置进Spring Security中去呢?是的!当前现在不要急啦!这些自己实现的类,后面会来一起配置。

(2) 自定义用户数据查询逻辑

我们建立一个service的包,并在里面建立impl包,在其中创建一个类表示自己的用户数据查询逻辑,这个类需要实现UserDetailsService接口里面的loadUserByUsername方法,我这里先放上代码:

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
java复制代码package com.example.securitytest.service.impl;

import com.example.securitytest.dao.MyUserDAO;
import com.example.securitytest.dataobject.MyUser;
import com.example.securitytest.dataobject.Permission;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Component;

import java.util.HashSet;
import java.util.Set;

/**
* 自行实现Spring Security的登录逻辑
*/
@Component
public class MyUserDetailsServiceImpl implements UserDetailsService {

@Autowired
private MyUserDAO myUserDAO;

/**
* 取出用户
*
* @param username 用户名
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
MyUser myUser = myUserDAO.getByUsername(username);
if (myUser == null) {
throw new UsernameNotFoundException("找不到用户!");
}
// 组装权限
Set<GrantedAuthority> authorities = new HashSet<>();
Set<Permission> permissions = myUser.getPermissions();
for (Permission permission : permissions) {
authorities.add(new SimpleGrantedAuthority(permission.getName()));
}
return new User(myUser.getUsername(), myUser.getPassword(), authorities);
}

}

首先需要说明的是,Spring Security中有一个User类,表示用户对象,但是实际情况下我们还是要建立自己的用户模型(我这里的MyUser),因为内置的User类不能满足业务需要。

这个User有个三个参数构造器,三个参数分别是:用户名、密码、权限。

不过这个loadUserByUsername到底是干什么的呢?顾名思义,这个方法肯定是用于根据用户名(凭证)从数据库查询用户信息的。

因此,这里仅仅只用从数据库查询我们自己的用户信息,然后将里面的用户名、密码和权限或者角色字段提取出来放到User类实例并返回即可。不需要在这里实现密码比对。

注意在Spring Security中,角色和权限都被视为GrantedAuthority,但是角色名一定要以ROLE_开头!这是Spring Security对角色和权限两者的判断依据,这个在前面sql文件中也注释说明了。

这样,这个方法就会返回相应的用户信息拿去和前端传来的进行比对。

还有一点要注意的是:这个类虽然也是属于Service层的,但是其作用仅仅是用于查询出用户对象传给Spring Security框架,不建议将自己的其它用户逻辑也写在里面,建议自己建立自己的用户服务接口和实现类并把自己的用户服务逻辑写在自己的实现类中。

(3) 自定义登录成功后逻辑

默认登录成功会跳转至某个页面,但是现在我们需要登录成功后发送json数据。

建立包handler,并创建类MyAuthSuccessHandler,这个类要实现AuthenticationSuccessHandler接口中的onAuthenticationSuccess方法。这里先上代码:

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复制代码package com.example.securitytest.handler;

import com.alibaba.fastjson.JSON;
import com.example.securitytest.model.Result;
import org.springframework.http.MediaType;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;

/**
* 自定义登录成功处理器逻辑
*/
public class MyAuthSuccessHandler implements AuthenticationSuccessHandler {

@Override
public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException {
// 设定响应状态码为200
httpServletResponse.setStatus(HttpServletResponse.SC_OK);
// 设定响应内容是utf-8编码的json类型
httpServletResponse.setContentType(MediaType.APPLICATION_JSON_VALUE);
httpServletResponse.setCharacterEncoding("utf-8");
// 组装自己的结果对象
Result result = new Result();
result.setResultSuccess("登录成功!");
// 序列化结果对象为JSON
String resultJSON = JSON.toJSONString(result);
// 写入响应体
PrintWriter writer = httpServletResponse.getWriter();
writer.write(resultJSON);
writer.flush();
writer.close();
}

}

在这个方法中,有一个HttpServletRequest类型参数表示接收的请求对象,HttpServletResponse参数表示返回相应对象,Authentication类型参数表示用户认证后的信息,里面有用户名、权限、用户登录ip等等我们可以在此获取。

与此同时,我们使用fastjson将我们的结果对象序列化为JSON字符串,并写入响应流,使得前端得到我们的JSON数据的响应。

(4) 自定义登录失败后逻辑

登录失败同样也需要返回json。

我们还是在handler中建立MyAuthFailureHandler,需要实现接口AuthenticationFailureHandler的onAuthenticationFailure方法。

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复制代码package com.example.securitytest.handler;

import com.alibaba.fastjson.JSON;
import com.example.securitytest.model.Result;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;

/**
* 自定义认证失败处理器
*/
public class MyAuthFailureHandler implements AuthenticationFailureHandler {

@Override
public void onAuthenticationFailure(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException {
// 设定响应状态码为200
httpServletResponse.setStatus(HttpServletResponse.SC_OK);
// 设定响应内容是utf-8编码的json类型
httpServletResponse.setContentType(MediaType.APPLICATION_JSON_VALUE);
httpServletResponse.setCharacterEncoding("utf-8");
// 组装自己的结果对象
Result result = new Result();
result.setResultFailed("用户名或者密码错误!");
// 序列化结果对象为JSON
String resultJSON = JSON.toJSONString(result);
// 写入响应体
PrintWriter writer = httpServletResponse.getWriter();
writer.write(resultJSON);
writer.flush();
writer.close();
}

}

和登录成功的方法很类似,这里不再过多赘述。

(5) 自定义403无权限访问逻辑

在handler中新建MyAccessDeniedHandler,需要实现接口AccessDeniedHandler的handle方法。

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复制代码package com.example.securitytest.handler;

import com.alibaba.fastjson.JSON;
import com.example.securitytest.model.Result;
import org.springframework.http.MediaType;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;

/**
* 自定义权限不足处理器
*/
public class MyAccessDeniedHandler implements AccessDeniedHandler {

@Override
public void handle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AccessDeniedException e) throws IOException {
// 设定响应状态码为403
httpServletResponse.setStatus(HttpServletResponse.SC_FORBIDDEN);
// 设定响应内容是utf-8编码的json类型
httpServletResponse.setContentType(MediaType.APPLICATION_JSON_VALUE);
httpServletResponse.setCharacterEncoding("utf-8");
// 组装自己的结果对象
Result result = new Result();
result.setResultFailed("权限不足!请联系管理员!");
// 序列化结果对象为JSON
String resultJSON = JSON.toJSONString(result);
// 写入响应体
PrintWriter writer = httpServletResponse.getWriter();
writer.write(resultJSON);
writer.flush();
writer.close();
}

}

默认Spring Security会拦截全部请求,拦截后就会触发这个无权访问的方法。在后面我们需要配置具体需要拦截的路径。

(6) 自定义退出登录成功逻辑

在handler中建立MyLogoutSuccessHandler,实现接口LogoutSuccessHandler的onLogoutSuccess方法。

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复制代码package com.example.securitytest.handler;

import com.alibaba.fastjson.JSON;
import com.example.securitytest.model.Result;
import org.springframework.http.MediaType;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;

/**
* 自定义登出成功处理器
*/
public class MyLogoutSuccessHandler implements LogoutSuccessHandler {

@Override
public void onLogoutSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {
// 设定响应状态码为200
httpServletResponse.setStatus(HttpServletResponse.SC_OK);
// 设定响应内容是utf-8编码的json类型
httpServletResponse.setContentType(MediaType.APPLICATION_JSON_VALUE);
httpServletResponse.setCharacterEncoding("utf-8");
// 组装结果
Result result = new Result();
result.setResultSuccess("退出成功!", null);
// 序列化结果对象为JSON
String resultJSON = JSON.toJSONString(result);
// 写入响应体
PrintWriter writer = httpServletResponse.getWriter();
writer.write(resultJSON);
writer.flush();
writer.close();
}

}

可见这几个xxxHandler类(登录成功、登录失败、无权访问、退出登录逻辑)都很相似,返回JSON的方法也是一样的,主要是组装我们自己的结果对象、序列化为JSON字符串然后写入返回的响应流中。

5,安全?策略!配置啦!

上述定义了一系列的自定义实现类,现在就需要进行配置了。除此之外,我们还要配置拦截路径、各个接口路径的访问权限。

新建config包,在里面新建SecurityConfig类,继承WebSecurityConfigurerAdapter并重写其中的configure方法。先上代码:

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复制代码package com.example.securitytest.config;

import com.example.securitytest.filter.MyAuthFilter;
import com.example.securitytest.handler.MyAccessDeniedHandler;
import com.example.securitytest.handler.MyAuthFailureHandler;
import com.example.securitytest.handler.MyAuthSuccessHandler;
import com.example.securitytest.handler.MyLogoutSuccessHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

/**
* Spring Security配置
*/
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

/**
* 配置密码加密器
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

/**
* 配置自定义用户名密码拦截器为Bean
*/
@Bean
public UsernamePasswordAuthenticationFilter myAuthFilter() throws Exception {
UsernamePasswordAuthenticationFilter myAuthFilter = new MyAuthFilter();
// 注意,因为是自定义登录拦截器,所以登录接口地址要在此配置!
myAuthFilter.setFilterProcessesUrl("/api/user/login");
// 设定为自定义的登录成功/失败处理器
myAuthFilter.setAuthenticationSuccessHandler(new MyAuthSuccessHandler());
myAuthFilter.setAuthenticationFailureHandler(new MyAuthFailureHandler());
myAuthFilter.setAuthenticationManager(authenticationManagerBean());
return myAuthFilter;
}

/**
* 配置安全拦截策略
*/
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
// 设定认证拦截器
httpSecurity.authorizeRequests()
// 设定查询炸弹列表的权限
.antMatchers("/api/bomb/get").hasAuthority("queryBomb")
// 设定添加炸弹的权限
.antMatchers("/api/bomb/add").hasAuthority("addBomb")
// 需要登录后才能获取用户信息
.antMatchers("/api/user/islogin").authenticated()
// 放行头像url
.antMatchers("/avatar/*").permitAll();
// 自定义退出登录url和配置自定义的登出成功处理器
httpSecurity.logout().logoutUrl("/api/user/logout").logoutSuccessHandler(new MyLogoutSuccessHandler());
// 关闭csrf
httpSecurity.csrf().disable();
// 设定自己的登录认证拦截器
httpSecurity.addFilterAt(myAuthFilter(), UsernamePasswordAuthenticationFilter.class);
// 设定为自定义的权限不足处理器
httpSecurity.exceptionHandling().accessDeniedHandler(new MyAccessDeniedHandler());
}

}

首先,定义了上述的passwordEncoder方法注入为Bean,配置密码加密器,一般就如上配置BCryptPasswordEncoder即可。

后续在写添加用户服务时,需要对用户密码进行加密才能存入数据库,那么直接在相应服务类中自动注入(@Autowired)一个PasswordEncoder对象,并调用其encode方法即可加密。我们上述已经配置了创建一个BCryptPasswordEncoder实例注册为Bean,那么就可以在别的地方使用自动注入。

然后,上述方法myAuthFilter就是我们用于配置自己的用户名密码拦截器方法,将其注入为Bean。在其中还给它配置了登录请求的路径(登录信息请求发送给这个路径)、我们自定义的登录成功/失败拦截器。

重点在于下面的configure方法,利用其中的参数HttpSecurity完成各个配置。

可以看见它的方法是链式调用的,其中:

  • authorizeRequests 配置要认证的路径以及其访问权限,然后:
    • antMatchers 匹配路径,接着设定该路径的访问权限:
      • hasAuthority 拥有指定权限才能访问该路径
      • hasAnyAuthority 指定多个权限,只要拥有其中一个,就可以访问该路径
      • hasRole 拥有指定角色才能访问该路径
      • hasAnyRole 指定多个角色,只要拥有其中一个,就可以访问该路径
      • hasIpAddress 指定的ip才能访问该路径
      • authenticated 只要登录了就可以访问该路径
      • permitAll 不拦截该路径
  • logout 配置退出登录,然后:
    • logoutUrl 配置退出登录API的请求路径(退出登录请求发送给这个路径)
    • logoutSuccessHandler 设定退出登录成功的处理器,这里就填入了我们上述自定义的退出登录成功逻辑实现类的实例
  • addFilterAt 自定义拦截器,上述定义了我们自定义的用户名密码登录拦截器,在这里设定即可。第一个参数是我们拦截器的实例,上面已经定义了方法myAuthFilter,第二个参数表示要设定拦截器的类型,我们使用的是UsernamePasswordAuthenticationFilter
  • exceptionHandling 配置异常处理,然后:
    • accessDeniedHandler 设定自定义403逻辑

需要注意的是,旅行者在网上发现很多教程都是在这个configure方法、利用其中的参数HttpSecurity设定的登录路径、登录成功、失败方法逻辑,但是为什么这里不一样呢?因为我们自定义了用户名密码登录拦截器,就需要在自定义的拦截器实例里面设定,然后再像上面把自定义拦截器实例配置到HttpSecurity中即可。

以及我们上述antMatchers的路径匹配规则在这里提一下:

  • ? 表示匹配任何单字符,例如/api/??将会匹配/api/aa、/api/ab等等,但是不会匹配/api/a或者/api/aaa
  • * 表示匹配0或者任意数量的字符,例如/api/*会匹配/api/a、/api/aaa等等
  • ** 表示匹配0或者多级目录,例如/resource/**会匹配/resource/aa、/resource/aa/bb等等

上述的antMatchers其实也可以换成regexMatchers,即使用正则表达式进行匹配,其子方法和上面相同,这里不再赘述。

以及前面提了角色名必须以ROLE_开头,但是这里如果要用到hasRole或者hasAnyRole的话,里面的参数就不能以ROLE_开头了,否则会报错。例如只允许ROLE_admin访问,那么应该写hasRole("admin")。

与此同时,在这里我还是强调一下判断用户登录的API,我先放出用户API类(Controller层)代码:

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
java复制代码package com.example.securitytest.api;

import com.example.securitytest.dao.MyUserDAO;
import com.example.securitytest.dataobject.MyUser;
import com.example.securitytest.model.Result;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/user")
public class UserAPI {

@Autowired
private MyUserDAO myUserDAO;

/**
* 判断用户登录,这个api会被加入Spring Security拦截器,若用户没有登录会返回无权访问403
*
* @return 用户信息
*/
@GetMapping("/islogin")
public Result<MyUser> isLogin(Authentication authentication) { // 在Controller类方法中加上Spring Security的Authentication类型参数,可以获取当前登录的用户信息例如用户名等等
Result<MyUser> result = new Result<>();
// 判断是否登录,如果未登录通常这个authentication为null
if (authentication == null || !authentication.isAuthenticated()) {
result.setResultFailed("用户未登录!");
return result;
}
// 获取当前登录的用户信息
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
// 获取用户名并查询用户
MyUser myUser = myUserDAO.getByUsername(userDetails.getUsername());
if (myUser == null) {
result.setResultFailed("用户无效!");
return result;
}
result.setResultSuccess("用户已经登录!", myUser);
return result;
}

}

这里只有一个方法isLogin,可见其中我们可以加上Spring Security的Authentication类型参数,这个参数在我们上面自定义登录成功处理器的时候也见到过,其实就是一个东西,表示当前登录的用户信息。我们可以用这个参数,判断用户是否登录,以及获取当前登录的用户信息,我们这里就用它获取了当前登录用户的用户名并拿去查询数据库得到完整用户信息给前端。

我们还可以用这个Authentication类型参数判断当前操作和被操作的用户名是否一致。

为什么这里可以获取已经登录的用户呢?其实明白用户登录的原理就知道了,Spring Security也是用session实现用户登录的,只要某个设备在这个网站登录了,那么后面这个设备任何发给这个后端的请求都会带着cookie,里面存着sessionId,这样后端就知道这个设备的用户登录了,并且能够查到登录的用户信息,直到用户退出或者cookie失效,如果不熟悉还是建议看看我的一个讲解开发用户登录的博客,加深理解。

这样,从自定义一些处理逻辑、到配置,就完成了!

6,网页?前端!开干啦!

旅行者终于完成了后端的构建,现在开始构建前端了。

因为这篇文章主要是讲解Spring Security的使用,这里不讲解前端怎么写,只是提提思路。

这里是Vue的前后端分离模式,前端通过axios发送请求,得到了结果,来控制页面跳转、显示等等。

每一个页面加载时都会向后端的判断用户是否登录接口发送请求判断是否登录,也可见上面配置安全策略中拦截了这个判断是否登录的接口(/api/user/islogin),这个接口实质就是查询信息,这样前端判断如果返回的是403就说明没登录,返回200就说明登录了。

当然这是我的思路,大家也可以自己实现。

这里用到了Vue多页应用构建,对Vue多页应用不熟悉可以看看这个:链接

这里来看看部分效果吧!

image.png

登录界面

image.png

可莉可以增加和查看炸弹

image.png

早柚只能查看炸弹,但是不能添加炸弹

7,完成?放工!总结啦!

至此,旅行者给可莉的第一版“炸弹管理系统”就完成了!这里简单起见就没有做数量管理、用户注册等等功能。

实际开发中,我们会有数据模型(dataobject)、DAO层(操作数据库)、Service层(服务逻辑)和Controller层(API),这里简单起见省略了Service层。

不过,这里在前后端分离的模式下,我们Spring Security的一些逻辑就需要自行设定。

最核心的思想就是:

  • 后端不再控制页面跳转,只控制接口和数据的访问、用户登录等资源访问
  • 每一个操作后端都给前端返回JSON数据而非执行请求转发、跳转
  • 让前端读取结果判断操作成功还是失败,实现跳转显示等等
  • 后端开发一个判断用户是否登录的接口,前端每次加载(mounted时)就可以发送请求判断是否登录,如果登录了就返回用户信息前端以显示

示例仓库地址

本文转载自: 掘金

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

Eggjs & Nestjs 简易上手比较 主要实现思路

发表于 2021-09-13

今年二月份的时候忙于夜大的毕业设计,做了一个密钥管理生成的小项目。

image.png
当时的前端使用vue-cli + vue3实现,后端当时出于快速实现功能,用Egg.js开发了出来,最近出于学习的目的,学习了一下Nest.js,然后基于Nest.js我把之前做的后端的主要功能又用Nest.js实现了一遍。于是借着学习的契机回顾了一下Egg.js想写篇文章谈一下自己对这两个框架额使用感受。

主要实现思路

Egg.js基于Koa做的二次开发,Nest.js我用的是基于Express实现的版本,这二者的具体实现上还是有些差异,但整体上都是基于Controller - Service的实现思路。
可以先看看Egg.jsd的目录结构

image.png

Egg.js的实现思路较为简单,后端的所有功能最后都会绑定在全局变量app上,app就像一个巨大的容器,它包含了路由,控制器,服务,扩展,中间件等等,所有的东西都是全局的,router.js则充当了项目的主入口文件,在这里可以自行配置最终给前端调用的接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
javascript复制代码// router.js
const baseURL = '/api'

module.exports = app => {
const {
router,
controller,
middleware,
config
} = app;
const jwt = middleware.jwt(config.jwt);

router.get('/', controller.home.index);

// 一些查询
router.get(`${baseURL}/dict/roles`, controller.home.roles);
...

// 登录
router.post(`${baseURL}/login`, controller.login.login);

// 首页
router.get(`${baseURL}/dashboard/userroles`, jwt, controller.dashboard.userroles);
...

这种模式确实会让开发变得简单,比如我需要获取请求头的token可以直接在app的全局变量中获取,但是这也会导致app的功能过于庞杂有一点臃肿。

Nest.js的目录结构大概是这样的

image.png

相比之下,Nest.js的结构可以说更清晰一些,main.ts作为项目的入口文件,而Nest比Egg多一项的概念则是模块,即Module - Controller - Service的结构,app.module.ts导入并整合各个模块,最终将这个大模块用于main.ts中

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
typescript复制代码// app.module.ts
import { Module } from '@nestjs/common';
import { SequelizeModule } from '@nestjs/sequelize';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { MailerModule } from '@nestjs-modules/mailer';
import { PugAdapter } from '@nestjs-modules/mailer/dist/adapters/pug.adapter';
import { UsersModule } from './users/users.module';
...

@Module({
imports: [
SequelizeModule.forRoot({
dialect: 'mysql',
host: '127.0.0.1',
port: 3306,
username: 'root',
password: '1234567',
database: 'mykms',
autoLoadModels: true,
synchronize: true,
}),
MailerModule.forRoot({
transport: {
host: "smtp.qq.com",
port: "465",
auth: {
user: "1534739331@qq.com",
pass: "qaurwlgawczubace"
}
},
defaults: {
from: '陈晟 <1534739331@qq.com>',
},
}),
UsersModule,
DictModule,
LoginModule,
DashboardModule,
ScheduleModule.forRoot(),
...
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
typescript复制代码// main.ts
import { NestFactory } from '@nestjs/core';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
import { AppModule } from './app.module';
import { AuthService } from 'src/auth/auth.service';

async function bootstrap() {
const app = await NestFactory.create(AppModule);
const refAuthService = app.get<AuthService>(AuthService);

app.useGlobalInterceptors(new TransformInterceptor(refAuthService));
app.useGlobalFilters(new HttpExceptionFilter(refAuthService));
app.setGlobalPrefix('api');
await app.listen(7001);
}
bootstrap();

相较而言,Nest的思路和组织更为清晰,Nest的思路其实就是万物皆模块,如果一个功能用一个模块不能实现,那就再一个模块,Nest的另一个思路是功能皆装饰,这个会在后面再说。Nest这样设计的好处很明显,代码更容易组织,缺点就是如果控制不好会写的过于零散,除非自己设置,很多东西也不是全局的,比如token,我需要在每一个具体的请求中从具体的请求头获取,而Egg就可以从全局app中获取,这个见仁见智吧。

具体请求的编写

我以一个POST的请求为例,使用Egg的话请求接口的具体地址是在router.js中编写实现,同时,该请求需要token权限才能访问,那么Egg的思路是将token作为中间件,作为参数传到router.js中具体的post的请求上

1
2
3
4
5
6
7
8
9
10
11
12
13
14
javascript复制代码// router.js
// 在全局变量中获取中间件和配置项
const {
router,
controller,
middleware,
config
} = app;
// 生成中间件token
const jwt = middleware.jwt(config.jwt);

...
// 在具体的请求中添加token中间件
router.post(`${baseURL}/users/add`, jwt, controller.users.create);

然后,路由函数会调用controller对应的方法,controller会再从对应的service中找到对应的方法执行并返回结果

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
javascript复制代码// controller/users.js
async create() {
const {
ctx,
app
} = this;
const body = ctx.request.body;
const result = await ctx.service.users.create(body);
if (!!result.code && result.code != 200) {
if (!body.id) {
// 插入操作文档
await this.ctx.service.home.addOperaLog({
url: '/app/users/add',
method: 'POST',
action: '新增',
status: 0
});
} else {
// 插入操作文档
await this.ctx.service.home.addOperaLog({
url: '/app/users/update',
method: 'PUT',
action: '修改',
status: 0
});
}

this.result(null, result.code, result.success, result.message);
} else {
// 发送邮件
const {
roles
} = await ctx.service.home.roles();
let title = body.id ? '修改' : '创建';
let rolesText = "";
let userRoles = body.roles;
userRoles.forEach(id => {
for (let i = 0; i < roles.length; i++) {
if (id == roles[i].code) {
rolesText += `${roles[i].text}, `;
break;
}
}
});
// console.log(body.email);

const mailresult = await app.email.sendEmail(
`账号${title}成功`,
`您的账号已${title}成功`,
`${body.email}`,
[{
data: `<html>
<p>您的账户已${title}成功</p>
<p>登录名:<b>${body.loginName}</b></p>
<p>密码:<b>${body.password}</b></p>
<p>角色:<b>${rolesText}</b></p>
<p>如有疑问请联系管理员</p>
</html>`,
alternative: true
}]
);

if (!body.id) {
// 插入操作文档
await this.ctx.service.home.addOperaLog({
url: '/app/users/add',
method: 'POST',
action: '新增',
status: 1
});
} else {
// 插入操作文档
await this.ctx.service.home.addOperaLog({
url: '/app/users/update',
method: 'PUT',
action: '修改',
status: 1
});
}

this.result(mailresult);
}
}
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
javascript复制代码// service/users.js
// 插入/修改一条数据
async create(params) {
const users = await this.app.mysql.select('users');
let insertData = JSON.parse(JSON.stringify(params));
insertData.roles = insertData.roles.join(",");
insertData.hexPassword = getMd5Data(params.password);
if (!params.hasOwnProperty('id')) {
insertData.id = `user_${users.length}_${Math.random().toFixed(5)}`;
}

const findUsers = await this.app.mysql.select('users', {
where: {
loginName: params.loginName
}
});
// console.log('findUsers: ', findUsers);

if (params.hasOwnProperty('id')) {
// 更新操作
if (findUsers.length === 0 || (findUsers.length === 1 && findUsers[0].id === params.id)) {
const result = await this.app.mysql.update('users', insertData);
if (result.affectedRows === 1) {
return {
result
};
} else {
return {
code: 500,
success: false,
message: '更新失败'
}
}
} else {
return {
code: 4001,
success: false,
message: '登录名重复'
}
}
} else {
// 新增操作
if (findUsers.length > 0) {
return {
code: 4001,
success: false,
message: '登录名已存在'
}
} else {
const result = await this.app.mysql.insert('users', insertData);
if (result.protocol41) {
return {
result
};
} else {
return {
code: 500,
success: false,
message: '添加失败'
}
}
}
}
}

正如我之前所说,Nest是万物皆模块,所以Nest要想实现相应功能,需要先定义好users的模块,再将users的controller和service用于模块里并导出

1
2
3
4
5
6
7
8
9
10
11
12
typescript复制代码// users/users.module.ts
...
@Module({
imports: [
SequelizeModule.forFeature([Users, Roles, Usertokens]),
MailModule,
OperalogModule
],
providers: [UsersService],
controllers: [UsersController]
})
export class UsersModule {}

那么Nest怎样实现同样地址的路由,这则需要在controller中实现,不同于Egg的路由有一个固定的方法,Nest引入了装饰器的思想,Controller作为路由的主入口代表第一级,而具体的Post/Get装饰器中定义的字符串为第二级。而关于权限的拦截也是基于装饰器的思想,使用UseGuards装饰器在具体路由的上方填入token进行拦截处理。

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
typescript复制代码// users/users.controller.ts
import { Controller, Get, Post, Put, UseGuards, Request, Delete } from '@nestjs/common';
import { JwtAuthGuard } from 'src/auth/guards/jwt.guard';
import { UsersService } from './users.service';
import { OperalogService } from 'src/operaLog/operaLog.service';

@Controller('users') // controller装饰器里的users是路由名称的第一级
export class UsersController {
constructor(
private readonly usersService: UsersService,
private readonly operalogService: OperalogService,
) {}

...

// 新增用户
@UseGuards(JwtAuthGuard) // 通过装饰器注入token拦截
@Post('add') // Post装饰器表示该请求为Post亲请求里面的add表示第二级也就是/users/add
async add(@Request() req): Promise<any> {
const result:any = await this.create(req);
if (result && result.code == 200) {
await this.addUserLog(1, req);
} else {
await this.addUserLog(0, req);
}
return result;
}
}

我们可以看到这里Egg和Nest的思想不同处,Egg基于全局通过中间件对事物进行处理,Nest基于具体模块通过装饰器对事物进行处理

其他异同

数据的返回问题

通常情况下,我们会对数据库查询的内容包装一层再返回给前端,这里Egg和Nest的处理思路有很大不同,Egg在controller通过ctx.result返回,如果在service中强行赋值一个自己给的错误码则可以修改数据返回的状态码,如之前举例中的users.service错误码赋值500是可以返回回来的,平常情况下则可以自定义一个基础的BaseController返回包装的数据结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
javascript复制代码// controller/base.js
const Controller = require('egg').Controller;

class BaseController extends Controller {
result(data, code = 200, isSuccess = true, message = '操作成功') {
this.ctx.body = {
code: code,
success: isSuccess,
message: message,
data
}
}
}

module.exports = BaseController;

Nest修改状态码则不够灵活,只能通过装饰器@HttpStatus修改,但是在一些全局处理上这样修改很复杂,比如token失效我希望状态码变成401这样就很难,我看了文档也没找到合适的办法,最终只能采取折中方案,在自定义的返回中写入一个状态码,前端以这个自定义的状态码为准进行判断,实际上之前我也是这么写的。

token的生成

token这里Egg和Nest的思路也是不一样的,Egg有专门的生成token的插件,利用插件生成token后在全局使用,生成之后可以使用一个中间件作为拦截,判断当token失效以后返回401

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
javascript复制代码// middleware/jwt.js
module.exports = options => {
return async function jwt(ctx, next) {
const token = ctx.request.header.authorization;
let decode;
if (token) {
try {
// 解码token
decode = ctx.app.jwt.verify(token, options.secret);
const user = await ctx.app.mysql.get('usertokens', {
loginName: decode.loginName
});

if (token == user.token) {
await next();
} else {
ctx.status = 401;
ctx.body = {
message: '该账户已在其他设备登录',
};
return;
}
} catch (error) {
if (error.message == 'jwt expired') {
ctx.status = 401;
}
ctx.body = {
message: error.message,
};
return;
}
} else {
ctx.status = 401;
ctx.body = {
message: '没有token',
};
return;
}
};
};

Nest则已经秉持模块的思想,可以定一个名为auth的模块,叫其他名字也行,在该文件夹下使用官方的方法生成token并引入到auth的service中使用,最后将token作为拦截器在具体的接口之上用拦截装饰器导入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
typescript复制代码// auth/strategies/jwt.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { secret } from 'src/common/conmstr';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: secret,
});
}

async validate(payload: any) {
return { token: payload.token }
}
}

那要怎样在token失效后返回错误消息退出呢?我目前做到的也就是我之前提到的定义一个全局拦截器,在失效之后返回自定义的401错误码交由前端处理。

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
typescript复制代码// /common/interceptors/transform.interceptor.ts
import {
Injectable,
NestInterceptor,
CallHandler,
ExecutionContext,
} from '@nestjs/common';
import { map } from 'rxjs/operators';
import { Observable } from 'rxjs';
import { AuthService } from 'src/auth/auth.service';
import { secret } from '../conmstr';

interface Response<T> {
data: T;
}

@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, Response<T>> {
constructor(
private readonly authService: AuthService,
) {}

async intercept( context: ExecutionContext, next: CallHandler<T>): Promise<Observable<Response<T>>> {
const request = context.switchToHttp().getRequest();
const path = request.route.path;
let hasToken = true;
const mesData = {
code: 200,
message: path === '/api/login' ? '登录成功' : '请求成功',
success: true,
}

// 获取并解析token
if (request.headers.authorization) {
const usertokens = await this.authService.findUsertokens();
const token = (request.headers.authorization.split(' '))[1]; // request.headers.authorization;
const decode = this.authService.verifyToken(token, secret);
const user = usertokens.find((item) => { return item.loginName == decode.loginName });

if (user.token !== token) {
hasToken = false;
}
}

return next.handle().pipe(
map((data: any) => {
if (!hasToken && path != '/api/login') {
data = null;
mesData.message = '该账户已在其他设备登录';
mesData.code = 401;
mesData.success = false;
} else if (!hasToken && path === '/api/login') {
Object.keys(data).forEach(key => { mesData[key] = data[key] });
} else if (hasToken && data && data.code) {
Object.keys(data).forEach(key => { mesData[key] = data[key] });
} else if (!data) {
mesData.message = '请求失败';
mesData.code = 1;
mesData.success = false;
}

return {
data,
...mesData
};
}),
);
}
}

目前有一个问题我不知道怎么处理,就是Egg生成的token,前端请求时Authrotion不需要携带”Bearer”,但是Nest生成的token前端必须要携带”Bearer”。这也是前端部分我唯一修改的一处代码,我查了很多资料,目前没想到合适的办法让Nest的请求和Egg保持一致。

1
2
3
4
5
6
7
8
javascript复制代码// 前端 /api/axios.js
...
// 全局拦截添加token (Egg.js不需要Bearer, Nest.js需要Bearer, 我目前不知道怎样在后端解决这个问题)
if (store.state.token != "") {
config.headers.common['Authorization'] = 'Bearer ' + store.state.token
// config.headers.common["Authorization"] = store.state.token;
}
...

关于数据库

数据库我使用MySql,Egg操作数据库非常简单,Egg本身就可以找到mysql的数据库插件,大部分的查询和新增操作利用插件功能可以完成,小部分的查询编写sql可以实现,例如keys.js下的审核和查询等

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
javascript复制代码// service/keys.js
...
// 审核
async audit(params) {
const {
app
} = this;
let subData = {
id: params.id,
status: params.result,
reason: params.reason,
auditDate: app.mysql.literals.now
}

const result = await this.app.mysql.update('theKeys', subData); // 利用Egg自带的插件更新

if (result.affectedRows === 1) {
return {
result
};
} else {
return {
code: 500,
success: false,
message: '更新失败'
}
}
}

// 用户可操作性密钥
async userKeys(params) {
const user = await this.app.mysql.get('users', {
loginName: params.loginName
});
// console.log('user: ', user);
let sql = `select id,keyName from theKeys where keyUser like "%${user.id}%" and status=2`;
const list = await this.app.mysql.query(sql);
return list;
}

但是Nest的数据库操作就稍微复杂了一点,Nest没有有个比较官方的方案,在Mysql上目前可以配合官方插件使用第三方库TypeORM或者Sequelize操作数据库,我这里使用的是Sequelize,但不管使用哪个库,nest都不能直接查询或修改数据,而是要为每一个表建立相应的数据模型,再在不同的模块service下通过模型操作数据。比如,首先我们先定义用户数据表的模型

image.png

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
typescript复制代码// common/models/users.model.ts
import { Column, Model, Table } from 'sequelize-typescript';

@Table({
tableName: 'users',
timestamps: false,
freezeTableName: true
})
export class Users extends Model<Users> {
@Column({ primaryKey: true })
id: string;

@Column
loginName: string;

@Column
userName: string;

@Column
password: string;

@Column
roles: string;

@Column
email: string;

@Column
hexPassword: string;
}

然后在需要使用模型的module和service里分别引入,module需要在impots里声明使用了该模型

1
2
3
4
5
6
7
8
9
10
11
12
typescript复制代码import { Module } from '@nestjs/common';
import { SequelizeModule } from '@nestjs/sequelize';
...

@Module({
imports: [
SequelizeModule.forFeature([Users]), // 表示使用了Users模型
...
],
...
})
export class UsersModule {}

在对应的servcice里再次引入模型,并在constructor定义模型变量并具体使用

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
typescript复制代码import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/sequelize';
import { Sequelize } from 'sequelize-typescript';
import { Users } from 'src/common/models/users.model';
...

@Injectable()
export class UsersService {
constructor(
...
@InjectModel(Users)
private readonly usersModel: typeof Users, // 定义具体的使用变量
) {}

...

// 修改密码
async editPassword(params) {
const user = await this.usersModel.findOne({ where: { id: params.userId } });
if (user.password !== params.oldPass) {
return {
code: 500,
success: false,
message: '旧密码有误'
}
} else {
let insertData = {
id: params.userId,
password: params.newPass,
hexPassword: getMd5Data(params.newPass)
}

const result = await this.usersModel.update(insertData, { where: {id: insertData.id} });
const userEditer = await this.usersModel.findOne({ where: { id: params.userId } });
console.log('userEditer: ', userEditer);

if (result && result[0] === 1) {
await this.sendMail(userEditer);
return {
code: 200,
success: true,
message: '密码修改成功'
}
} else {
return {
code: 500,
success: false,
message: '更新失败'
}
}
}
}

}

也就是说,如果想对数据库的具体数据进行操作,前提是必须要事先实现对应的数据模型,这一点倒是比Egg相对严谨一些。

小结

以上就是我对Egg和Nest分别开发相同项目的一些比较感受,在我看来Egg的思想是便捷,一个像我这样对后端不怎么了解的开发者只要按照文档,使用对应的插件就可以开发出一个还不错的后端项目。而Nest则更加强调规范,严格的三层架构,基于模块的思想和装饰器的广泛使用,确保了开发者能编写出通用的代码,但是对于开发者对项目的组织能力也有一定的要求。如果是一个长期维护的后端项目,显然当下比较流行的Nest.js还是更胜一筹的。

本文转载自: 掘金

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

1…531532533…956

开发者博客

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