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

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


  • 首页

  • 归档

  • 搜索

Python matplotlib 绘制动态图 复习回顾 1

发表于 2021-11-21

这是我参与11月更文挑战的第20天,活动详情查看:2021最后一次更文挑战

复习回顾

在matplotlib模块中我们前面学习绘制如折线、柱状、散点、直方图等静态图形。我们都知道在matplotlib模块主要有三层脚本层为用户提供快捷的绘制图形方法,美工层接收到脚本层的命令后将绘制指令发送给后端,后端提供执行绘制操作、事件响应、图形渲染工作。具体的详情可见往期文章如下

  • matplotlib 底层结构:matplotlib模块底层结构
  • matplotlib 绘制折线图:介绍折线图相关属性进行汇总说明
  • matplotlib 绘制柱状图:介绍柱状图相关属性进行汇总说明
  • matplotlib 绘制图形:常见的矩形、圆形等图形绘制方法
  • matplotlib 绘制3D图形:介绍mplot3d绘制3D图形步骤

在matplotlib模块中,除了以上静态图形的绘制,还提供Animation类支持绘制动态图制作

image.png

本期,我们对matplotlib.animation绘制动态图方法学习,Let’s go~

  1. Animation 概述

Animation 是matplotlib模块制作实时动画的动画类,包含三个子类

  • Animation 是动画类的基类
  • TimedAnimation 是 Animation的子类,可通过绘制时间绘制每一帧动画
  • FuncAnimation 是基于Timed子类,可以通过重复调用fun()方法来绘制动画
  • ArtistAnimation 使用一组Artist对象来绘制动画

image.png

  • 绘制动画特点

+ 绘制对象引用:动画对象要在制作动画时要保持长期有效,否则会被系统资源回收,动画暂停
+ 动画计时器:是对动画对象推进的唯一引用对象
+ 动画保存:需要使用animation.save、animation.To\_html5\_video或animation.To\_jshtml进行动画保存
+ matpoltlib.animation 还提供关于电影格式的类
  • 动画制作方法

matplotlib.animation.Animation()是动画类的基类,是不能被使用的。常用的两个类主要animation两个子类

+ matplotlib.animation.FuncAnimation

1
2
3
4
5
6
7
Python复制代码matplotlib.animation.FuncAnimation(fig, func, 
frames=None,
init_func=None, 
fargs=None, 
save_count=None, 
* , cache_frame_data=True, 
**kwargs)
+ matplotlib.animation.ArtistAnimation
1
2
3
4
python复制代码matplotlib.animation.ArtistAnimation(fig, 
artists, 
*args, 
**kwargs)
  1. 绘制动态图步骤

matplotlib 绘制动态图最重要的是要准备好每一帧显示的数据,通常我们使用FuncAnimation可以传入产生连续数字的func方法,因此绘制动态图主要步骤为:

  • 导入绘制图形的matplotlib.pyplot和制作动态图的matplotlib.animation
1
2
python复制代码import matplotlib.pyplot as plt
import matplotlib.animation as animation
  • 使用Pyplot.subplots创建一个fig画布对象和一组子图
1
python复制代码fig,ax = plt.subplots()
  • 调用numpy.random或者numpy.arange()等方法准备x,y轴数据
1
python复制代码x = np.arange(0, 2*np.pi, 0.01)
  • Axes对象调用plot()、scatter()、hist()等绘制方法,并赋值给list对象
1
python复制代码line, = ax.plot(x, np.cos(x),color="pink")
  • 需要定义一个专门update data方法生成每一帧显示的数据例如func()
1
2
3
python复制代码def update(i):
line.set_ydata(np.cos(x + i / 50))
return line,
  • 调用animation.FuncAnimation把fig和update()方法
1
2
python复制代码ani = animation.FuncAnimation(
fig, update, interval=20, blit=True, save_count=50)
  • 调用plt.show()显示出动态图
1
python复制代码plt.show()
  • 我们可以调用animation.save(“movie.gif”,writer=”pillow”)保存动画为gif格式

ps:我们需要提前pip install pillow 安装pillow库,否则会提示无法使用

1
2
python复制代码
ani.save("movie.gif",writer='pillow')

movie.gif

  1. 小试牛刀

我们使用animation类绘制直方动态图,在绘制的过程中需要注意几点

  • 使用numpy.linspace生成100个在-5,5的等差数列
  • 使用numpy.random.randn()生成随机数据
  • Axes对象调用hist()返回n,bins,BarContainer
  • 定义一个递归update()函数,使用Python闭包跟踪barcontainer来更新每次直方图矩形高度
  • 调用animation.FuncAnimation()方法绘制动态图
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
python复制代码def drawanimationhist():
fig, ax = plt.subplots()
BINS = np.linspace(-5, 5, 100)
data = np.random.randn(1000)
n, _ = np.histogram(data, BINS)
_, _, bar_container = ax.hist(data, BINS, lw=2,
ec="b", fc="pink")
def update(bar_container):
def animate(frame_number):
data = np.random.randn(1000)
n, _ = np.histogram(data, BINS)
for count, rect in zip(n, bar_container.patches):
rect.set_height(count)
return bar_container.patches
return animate

ax.set_ylim(top=55)

ani = animation.FuncAnimation(fig, update(bar_container), 50,
repeat=False, blit=True)
plt.show()

hist.gif

总结

本期,我们对matplotlib模块制作动态图类animation相关方法学习。在绘制动态图过程中,需要定义func方法来更新每一帧所需要的数据。

以上是本期内容,欢迎大佬们点赞评论,下期见~

本文转载自: 掘金

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

Springboot配置图片虚拟映射

发表于 2021-11-21

这是我参与11月更文挑战的第21天,活动详情查看:2021最后一次更文挑战」
​

  1. 新建一个MyWebAppConfigurer 配置静态资源需要映射的位置、建议把addResourceLocations的路径写在application.yml中、方面以后部署的打包修改配置文件的时候做外部配置文件加载、方面修改。这边为了节约时间就在代码中写了。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
JAVA复制代码package io.renren.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
*
* @author lyy
* 2021.11.21
*/
//springboot 2.x配置
@Configuration
public class MyWebAppConfigurer implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/image/**").addResourceLocations("file:C:\Users\Administrator\Desktop\11月毕设\");
}
}

2.没有加权限控制的话直接通过IP+端口+项目名以及addResourceHandler中的路径就可以访问了、有权限控制的话需要权限放心或放在static静态资源文件夹下。个人用的shiro权限、所以需要放行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
JAVA复制代码 @Bean("shiroFilter")
public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) {
ShiroFilterFactoryBean shiroFilter = new ShiroFilterFactoryBean();
shiroFilter.setSecurityManager(securityManager);

//oauth过滤
Map<String, Filter> filters = new HashMap<>();
filters.put("oauth2", new OAuth2Filter());
shiroFilter.setFilters(filters);

Map<String, String> filterMap = new LinkedHashMap<>();
filterMap.put("/webjars/**", "anon");
filterMap.put("/druid/**", "anon");
filterMap.put("/app/**", "anon");
filterMap.put("/sys/login", "anon");
filterMap.put("/swagger/**", "anon");
filterMap.put("/v2/api-docs", "anon");
filterMap.put("/swagger-ui.html", "anon");
filterMap.put("/swagger-resources/**", "anon");
filterMap.put("/captcha.jpg", "anon");
filterMap.put("/image/**", "anon");

filterMap.put("/**", "oauth2");
shiroFilter.setFilterChainDefinitionMap(filterMap);

return shiroFilter;
}

3.映射成功、访问http://localhost:8080/renren-fast/image/v2-4f45411c72eb128a6085fc8173286ffc_1440w.jpg

大家点赞、收藏、关注、评论啦 、

打卡 文章 更新 104/ 365天

本文转载自: 掘金

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

Redis系列(2) — 主从读写分离 Redis 主从架构

发表于 2021-11-21

系列专栏:Redis系列专栏

Redis 主从架构

主从读写分离架构

Redis高可用性一般来说有两方面,一个是数据尽量少丢失,这个可以通过 AOF 和 RDB 来保证。另一个则是服务尽量少中断,不会出现单点故障,这个Redis的做法就是增加副本冗余,Redis 提供了主从库模式,以保证数据副本的一致,主从库之间采用的是读写分离的方式。

缓存一般都是用来支撑读高并发的,Redis 单机并发最多可能也就几万QPS,如果想要支持更高的并发,也需要通过主从读写分离的模式来部署 Redis,这样从节点就可以支持横向扩展,来提高读的吞吐量。接下来就来实践Redis主从架构高可用。

主从读写分离:主库、从库都可以接收读操作,而写操作先到主库执行,然后主库将写操作同步给从库。

image.png

部署主从读写分离

假设我们要部署的主从架构包含一个 master 节点和两个 slave 节点,信息如下:

节点 IP
Master 172.17.0.2:6379
Slave 172.17.0.3:6379
Slave 172.17.0.4:6379

按照前一篇文章的部署步骤,在另两台虚拟机(172.17.0.3/172.17.0.4)中各搭建一个 Redis。

注意有两个地方的IP需要更改:

  • /etc/redis/6379.conf 配置文件中的 bind <本机IP>
  • /etc/init.d/redis_6379 脚本中 shutdown 制定的IP改为本机IP

接下来只需要在从节点的配置文件中修改一项配置即可:

1
shell复制代码# vim /etc/redis/6379.conf

将注释掉的 replicaof 打开,并配置master节点的IP和端口:

1
sh复制代码replicaof 172.17.0.2 6379

然后重启 redis 即可:

1
2
3
4
5
shell复制代码# cd /etc/init.d

# ./redis_6379 stop

# ./redis_6379 start

之后就可以做些测试,可以看到数据已经从主节点复制过来了,并且只能读取不能写入。

1
2
3
4
5
6
shell复制代码[root@centos-02 /]# redis-cli -h 172.17.0.3
172.17.0.3:6379> get name
"bojiangzhou"
172.17.0.3:6379> set age 20
(error) READONLY You can't write against a read only replica.
172.17.0.3:6379

除了修改配置文件之外,也可以直接执行 replicaof <ip> <port> 命令来形成主库和从库的关系(Redis 5.0 之前使用 slaveof)。

主从搭建完成之后,可在主服务器上通过 info replication 命令查看连接的从服务器。

image.png

强制读写分离

配置了 replicaof 后,默认就是强制读写分离的,只接收读请求,拒绝写请求。

1
shell复制代码replica-read-only yes

一般不建议修改这个配置,如果主库和从库都可以接收写请求,那么最直接的一个问题就是客户端对同一个key多次修改,可能会落到不同的库上,那么数据副本一致性就是一个问题。

集群安全认证

如果要开启集群间的安全认证,首先需在 master 实例的配置文件中设置密码:

1
xml复制代码requirepass <密码>

然后在 slave 实例的配置文件中配置认证密码:

1
xml复制代码masterauth <密码>

集群压测

如果要对搭建好的redis做一个基准的压测,看下redis的性能和QPS,可以使用redis提供的 redis-benchmark压测工具。此工具在安装目录下:/usr/local/src/redis-6.2.5/src。

redis-benchmark 的基本用法如下

1
2
3
4
5
6
7
8
9
shell复制代码[root@centos-01 src]# ./redis-benchmark --help
Usage: redis-benchmark [-h <host>] [-c <clients>] [-n <requests>] [-d <size>]

-h <hostname> Server hostname (default 127.0.0.1)
-p <port> Server port (default 6379)
-a <password> Password for Redis Auth
-c <clients> Number of parallel connections (default 50)
-n <requests> Total number of requests (default 100000)
-d <size> Data size of SET/GET value in bytes (default 3)

例如测试 10000 并发,100万次请求,这个可以根据系统高峰时期的用户访问量来调整。

由于客户端连接会非常多,需调整下redis的最大连接数配置:

1
shell复制代码maxclients 20000

在 master 节点上做一次压测:

  • set:QPS 为 84623,平均每条命令执行时间59.4毫秒
  • get:QPS 为 88261,平均每条命令执行时间56.4毫秒
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
txt复制代码[root@centos-01 src]# ./redis-benchmark -h 172.17.0.2 -p 6379 -c 10000 -n 1000000

====== SET ======
Summary:
throughput summary: 84623.84 requests per second
latency summary (msec):
avg min p50 p95 p99 max
59.473 34.656 59.359 69.439 85.567 110.399

====== GET ======
Summary:
throughput summary: 88261.25 requests per second
latency summary (msec):
avg min p50 p95 p99 max
56.486 23.408 56.127 62.623 68.671 94.783

也就是说Redis单节点读 QPS 在8万多,如果想再提高读吞吐量,就可以部署主从架构,横向扩展读节点,比如再部署两个 slave 节点,集群的读 QPS 就可以达到20几万,读QPS是随着节点数线性增长的。

不过Redis的吞吐量和服务器本身的性能、配置,以及生产环境的网络等都有关系,不同的环境,需要针对性的去测试,然后调整节点数。

主从复制原理

Master 必须开启持久化

如果采用主从架构,Master 节点必须开启数据持久化,并且必须要做好数据备份,在数据丢失时做恢复。如果 master 没有开启数据持久化,那 master 的数据都在内存中,重启后数据就没了,master 就会将空的数据集同步到 slave 节点上,所有 slave 节点的数据也会被清空。

所以 master 节点一定要开启 AOF 和 RDB 持久化,可参考 Redis系列(1) — 单机版安装及数据持久化

主从同步原理

我们在配置文件中配置了 replicaof 参数,这样在启动时,或者直接在客户端执行 replicaof 命令时,slave 节点会发送一个 PSYNC 命令给 Master 节点,表示要进行数据同步。

psync 命令的格式为:psync {runID} {offset}。

  • runID:每个 Redis 实例启动时都会自动生成一个随机 ID,用来唯一标记这个实例。runID 表示 master 节点的运行ID。
  • offset:表示复制的偏移量,也就是接收数据量的字节数,master 和 slave 节点都会维护一个 offset,master 写入 N 字节的命令,偏移量就会加 N;slave 接收了 N 字节的数据,slave 的偏移量就会加上 N。

首次同步的步骤:

  • 1、首次同步时,slave 节点会发送 psync ? -1,runID为 ? 是因为首次不知道 master 的 runID;offset 为 -1 表示第一次复制。
  • 2、master 收到 psync 命令后,会响应命令 +FULLRESYNC {runID} {offset} 给 slave,FULLRESYNC 表示第一次复制采用全量复制,runID 表示 master 的唯一标识,offset 表示 master 当前的偏移量。slave 收到响应后,会记录下这两个参数。
  • 3、然后 master 执行 bgsave 命令,生成 RDB 文件,接着将文件发给 slave。slave 接收到 RDB 文件后,为了避免之前数据的影响,会先清空当前数据库,然后再加载 RDB 文件,这样就同步到 master 创建 RDB 文件的时刻了。如果 slave 开启了 AOF,那么会立即执行 BGREWRITEAOF,重写 AOF。
  • 4、在 master 将数据同步给 slave 的过程中,master 不会阻塞,仍然会接收写请求,否则 Redis 就无法提供服务了。这个阶段的写命令会写入 slave 连接客户端的缓冲区中(replication buffer)。
  • 5、最后 RDB 文件发送完成后,master 就会把缓冲区中的命令发给 slave,slave 再重放这些命令,并更新偏移量 offset。至此,master-slave 就完成首次数据同步了。
  • 6、一旦主从库完成了全量复制,它们之间就会一直维护一个网络连接,master 会通过这个连接将后续收到的命令操作再同步给 slave,这个过程也称为基于长连接的命令传播,可以避免频繁建立连接的开销。

image.png

需注意,replication buffer 其实就是客户端连接的缓冲区,无论是客户端还是从库,都是一个 client,每个 client 连上 Redis 后,Redis 都会分配一个 client buffer。所有数据交互都是通过这个 buffer 进行的,redis 先把数据写入这个 buffer 中,然后再把 buffer 中的数据发到 client socket 中再通过网络发送出去,这样就完成了数据交互。只不过在主从同步时,这个 client buffer 专门用来传播用户的写命令到从库,所以通常叫做 replication buffer。

这块 buffer 区域的大小通过下面这个参数来控制,超过这个阈值后,主库就会强制断开这个 client 的连接。

1
2
3
4
5
6
7
8
9
10
11
12
13
conf复制代码# The limit can be set differently for the three different classes of clients:
#
# normal -> normal clients including MONITOR clients
# replica -> replica clients
# pubsub -> clients subscribed to at least one pubsub channel or pattern
#
# The syntax of every client-output-buffer-limit directive is the following:
#
# client-output-buffer-limit <class> <hard limit> <soft limit> <soft seconds>

client-output-buffer-limit normal 0 0 0
client-output-buffer-limit replica 256mb 64mb 60
client-output-buffer-limit pubsub 32mb 8mb 60

断线增量复制

第一次全量同步完成后,就会基于长连接来进行命令传播,这个过程就可能会出现网络断连或阻塞的情况,这时就无法进行命令传播了,那么主从库的数据就可能不一致了。

在 Redis 2.8 之前,如果主从库在命令传播时出现了网络闪断,从库就会和主库重新进行一次全量复制,开销非常大。

从 Redis 2.8 开始,网络断了之后,主从库会采用增量复制的方式继续同步,下面就是增量复制的步骤。

  • 1、master 在命令同步时,不仅会发给 slave,还会写入一个 repl_backlog_buffer 的复制积压缓冲区中,还记录了每个字节对应的偏移量。
  • 2、刚开始,master 将命令同步给各个 slave,offset 基本都是一致的。如果某个 slave 与 master 断开连接后,master 还会接收写请求,所以 master 的 offset 可能会继续增大,而断开连接的 slave 的 offset 就会小于 master 了,这样数据就不一致了。
  • 3、当网络连接恢复后,slave 会发一个 psync {runID} {offset} 命令给 master,runID 表示 master 的唯一标识,offset 表示 slave 当前复制的偏移量。
  • 4、如果 slave 传过来的 offset 在还在缓冲区中,则向 slave 返回 CONTINUE 命令,表示增量复制。如果不存在或者 runID 不一致,则返回 +FULLRESYNC {runID} {offset} 命令,表示全量复制。
  • 5、master 收到 psync 命令后,这时就会将 backlog 缓冲区中 master offset 和 slave offset 偏移量之间的字节发送给客户端。然后客户端执行这些命令,实现断连后的增量同步。

image.png

复制积压缓冲区

可以看出,增量复制主要用到了主从库的 offset 和复制积压缓冲区来实现。主从库的 offset 一致则表示数据一致,否则就是不一致。而复制积压缓冲区是由主库维护的一个固定长度先进先出(FIFO) 队列,默认大小为1MB。

这里就会有一个问题,由于复制积压缓冲区是一个固定大小的环形缓冲区,那么可能在 slave 重连后,未同步的数据可能被覆盖了,即 slave_offset+1 之后的数据在缓冲区中已经不存在了,这时就会执行全量同步。否则就执行增量同步,将 slave_offset+1 到 master_offset 之间的数据同步给 slave。

如果 master 会执行大量写入命令,或者 slave 断开连接后重连时间较长,那么复制积压缓冲区很快就会被覆盖,就会导致 slave 重连后执行全量复制,全量复制就会影响主库的性能,所以默认的 1MB 大小可能并不合适。

复制积压缓冲区的大小可以根据公式 seconds * write_size_per_second * 2 来计算,seconds 表示断开连接重连的平均时间,write_size_per_second 表示服务器平均每秒产生的写命令数据量,为了应对突发压力,再扩大一倍。参数配置如下。

1
conf复制代码repl-backlog-size 1mb

服务器运行ID

断线重连还用到了服务器运行ID(runID),每个 Redis 在启动时会自动生成运行ID,它是一个40位长度的字符串。

在服务器上可通过 info server 命令查看:

image.png

在 slave 初次连接 master 复制时,master 会把 runID 传给 slave 保存起来,slave 断线重连的时候,会把这个 runID 传给 master。master 会验证这个 runID 和自身的 runID 是否一致,一致则说明 slave 断线之前就是连接的自己,则进行增量复制。否则说明 slave 之前连接的 master 不是自己,master 将对 slave 执行全量同步。

RDB 复制超时

每次全量同步时,主服务器要执行bgsave命令生成RDB文件,并将RDB文件传输到从服务器。因此全量同步是一个非常耗费资源和耗时的操作,如果 RDB 复制时间超过60秒,那么 slave 就会认为复制失败。RDB 文件比较大时,传输时间就可能会超过 60秒,可以适当调大这个参数。

1
conf复制代码repl-timeout 60

无磁盘化复制

默认情况下,生成的 RDB 文件会落到磁盘,并从磁盘复制到 slave。Redis可以配置无磁盘化复制,开启后,RDB 文件会生成在内存中,然后等待一段时间,这样就可以合并多个 slave 的同步请求,使用同一份 RDB 文件。之后在 RDB 文件传输期间,新的slave同步请求就会进入阻塞队列排队等待。

无磁盘化复制在 master 节点磁盘性能较低,slave 节点数量很多的情况下的性能是更好的,配置如下。

1
2
3
4
5
perl复制代码# 是否开启无磁盘化复制
repl-diskless-sync no

# 无磁盘化开启后,延迟多久发送 RDB 文件
repl-diskless-sync-delay 5

心跳检测

在命令传播阶段,主从节点会互相发送心跳检查,master 默认每隔10秒发送一次心跳给 slave,salve 默认每隔1秒发送一个心跳给 master。心跳命令格式为:REPLCONF ACK {slave_offset},slave_offset 表示 slave 当前复制的偏移量。

发送心跳主要有如下几个作用:

1、检测主从服务器的网络连接状态

通过发送心跳,主服务器可以知道主从服务器间的网络连接是否正常,如果 master 超过1秒没有收到 slave 的心跳命令,那么 master 就会知道主从间的网络可能出了问题。

可以通过 info replication 命令查看 slave 节点列表,正常情况下,lag 的值等于 0 或者 1,如果超过 1秒的话,说明主从服务器之间的连接出现了故障。

image.png

2、控制 master 停止写入

Redis 有两个配置需要借助心跳检测来实现:

1
2
3
4
5
conf复制代码# slave 的数量
min-replicas-to-write 0

# slave心跳延迟时间(lag)
min-replicas-max-lag 10

min-replicas-max-lag 表示多久没有接收到 slave 的心跳后(lag),就认为 salve 故障,默认为 10秒。

min-replicas-to-write 表示健康的 slave 数量,即每个 slave 的 lag 小于等于 min-replicas-max-lag 认为是健康的,否则是不正常的。当超过 min-replicas-to-write 数量的 slave 不正常时,master 会停止接收写请求,避免没有足够健康的 slave,导致数据丢失。

min-replicas-to-write 默认值为0,表示关闭这个特性,可以设置大于0的值来开启这个特性。

3、检测命令丢失

如果因为网络故障,master 传播给 slave 的写命令在半路丢失,那么当 slave 向 master 发送 REPLCONF ACK {slave_offset} 命令时,master 将发现 slave 当前的复制偏移量少于自己的复制偏移量,然后 master 就会根据 slave 提交的复制偏移量,在复制积压缓冲区里面找到 slave 缺少的数据,并将这些数据重新发送给 slave。

主从从级联模式

每次执行全量同步,对于主库来说,有两个耗费资源和性能的操作:

  • 执行BGSAVE命令,会 fork 子进程生成 RDB 文件,fork 这个操作会阻塞主线程处理正常请求,这个生成操作会耗费服务器大量的CPU、内存和磁盘I/O资源,从而导致主库响应客户端的请求速度变慢。
  • 接着主服务器还要将 RDB 文件发送给从服务器,这个操作会耗费主服务器大量的网络带宽资源(流量),并对主服务器响应客户端命令的时间产生影响。

因此全量同步是一个非常耗费资源和耗时的操作,所以要尽量让Redis在必要的时候才执行全量同步操作,这个可以通过上面介绍的一些配置来进行调优。

除此之外,如果从库数量很多,而且都要和主库进行全量复制的话,这会给主库的资源使用带来很大的压力。这时可以使用主 - 从 - 从的级联模式来分担主库压力,通过主 - 从 - 从模式将主库生成 RDB 和传输 RDB 的压力,以级联的方式分散到从库上。

在部署主从集群的时候,可以手动选择一个从库(内存资源配置较高的从库),用于级联其他的从库。

image.png

本文转载自: 掘金

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

Githook实践以代码规范检测插件golangci-lin

发表于 2021-11-21

「这是我参与11月更文挑战的第8天,活动详情查看:2021最后一次更文挑战」

引子

“kovogo哥, 我怎么提交不了代码了”

没想到工作两年就已经进入哥字辈了, 往旁边一看旁边新来的小伙子正常对着屏幕发愁, 一看控制台的报错,哦原来是golangci-lint打回了提交, 原因是某一行的代码太长了超过了限制。

那么在提交代码对代码规范进行检查是怎么做到的? 这就需要我们来回顾一下githook的知识了。

githook

什么是githook?

一般来说只要是上了规模的软件/中间件/开源框架或多或少的会提供一些hook, 供开发人员使用。而这些hook的作用就是在这些框架/软件中引入开发人员编写代码, 从而改变软件的表现形式或行为逻辑。

而githook就是git是向开发者提供的在git执行重要的操作引入开发者的代码的功能,比如上文提到在commit时引入代码检查的功能就是通过githook实现的。

每个git仓库在初始化的时候, 都会在.git目录下创建一个hooks目录,用来存放开发人员自定义的hook脚本(shell脚本), 脚本文件名即hook名。

windows 环境安装git时会自带git bash, 因此无需要担心脚本的兼容性

当hook脚本返回0时表示放行对应的操作, 返回非0值表示拒绝此操作。

每个程序在退出的时候都会有返回值, 该返回值通常用来表示程序是否执行成功.

在Linux shell中我们可以通过, $?来获取上一个程序的返回值.

image.png

如上图所示, 我们尝试访问一个不存在的路径, ls命令的返回值是2.

我们可以在.git/hooks中看到如下文件:

image.png

上图中.sample文件都是git官方提供的参考, 去掉.sample后缀该钩子就会起效, 记住: 文件名即钩子名

如果需要在提交代码时对代码进行检测,我们就需要为pre-commit钩子编写代码规范检测脚本。

更多详细信息可参见官方文档

golintci-lint

由于golint已经被谷歌抛弃不再维护了, 我们使用了golangci-lint对代码规范进行检查。

安装

golangci-lint官网给出的安装方式有两种:

  • 二进制安装, 直接下载对应平台的可执行文件
  • 如果版本小于1.16使用go get下载源文件并编译安装, 大于1.16则使用go install
1
2
3
4
5
shell复制代码# Go 1.16+
go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.43.0

# Go version < 1.16
go get -u github.com/golangci/golangci-lint/cmd/golangci-lint@v1.43.0

以windows平台为例, 我的golang版本是1.16.10

image.png

因此我们使用go install的方式进行安装

image.png

使用

使用run命令即可对目标目录的代码进行检测

1
shell复制代码golangci-lint run [目录]

使用-h可以查看run命令的更多帮助信息

1
shell复制代码golangci-lint run -h

image.png

值得注意的选项有:

  • --issues-exit-code 代码检测失败返回的错误码, 默认是1
  • -c, --config PATH 用于定制代码检测项目的配置文件(yaml格式)
  • --skip-files 要跳过的文件
  • --skip-dirs 要跳过的目录
  • --enable 要启用的代码检测项目

定制代码检测

上文提到, 我们可以通过--config来定制代码检测项目的配置, 其格式如下:

1
2
3
4
5
6
7
8
yaml复制代码linters-settings:
cyclop: # 代码检测项目名通常会对应到某个插件
# 最大的复杂度
max-complexity: 10
# 每个包允许的最大复杂度
package-average: 0.0
# 是否跳过测试文件
skip-tests: false

更多信息参考官方文档

githook + golangci-lint

以windows平台为例, 我们新建一个项目

image.png

然后新建一个代码文件, 随便写点乱七八糟的代码:

1
2
3
4
5
6
7
8
9
go复制代码func main() {
for i := 0; i < 1024; i++ {
for j := 0; j < 1024; j++ {
for n := 0; n < 1024; n++ {
fmt.Printf("%d-%d-%s", i, j, n)
}
}
}
}

golangci-lint检测到了Printf传了错误的参数

image.png

我们再往项目中里面加点有”坏味道”的代码, 如下所示:(示例来自cyclop项目的测试文件)

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
go复制代码func T() {
i := 1
if i > 2 {
if i > 2 {
}
if i > 2 {
}
if i > 2 {
}
if i > 2 {
}
} else {
if i > 2 {
}
if i > 2 {
}
if i > 2 {
}
if i > 2 {
}
}

if i > 2 {
}
}

再次进行代码检测, 注意此时需要启用圈复杂度检测插件cyclop

image.png

如果我们想要让代码通过检测,就需要使用到上文提到的使用配置文件自定义代码检测项目了。

在项目中创建lint.yaml, 并编写配置项如下

1
2
3
4
5
6
7
8
yaml复制代码linters-settings:
cyclop:
# 最大的复杂度
max-complexity: 15
# 每个包允许的最大复杂度
package-average: 0.0
# 是否跳过测试文件
skip-tests: false

再次运行并指定配置文件, 此时已不再提示圈复杂度过高

image.png

pre-commit hook

利用上文的知识,我们的pre-commit hook 脚本的逻辑如下所示:

  • 检测是否安装golangci-lint, 如果没有则进行安装
  • 运行golangci-lint对代码进行检测,如果检测失败返回1成功则返回0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
shell复制代码#!/bin/sh

echo "Start lint code"
if !(golangci-lint.exe --version); then
go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.43.0
fi
# 目录名后跟上...表示对该目录进行递归查找
if !(golangci-lint.exe run --enable cyclop --config ./lint.yaml ./...); then
echo "Lint fail!"
exit 1
fi

echo "Lint success"

exit 0

尝试对代码提交,运行效果如下图所示
image.png

本文转载自: 掘金

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

MySQL中的MVCC是怎么实现的,你们知道吗?

发表于 2021-11-21

「这是我参与11月更文挑战的第21天,活动详情查看:2021最后一次更文挑战」

不晓得大家了解不了解MySQL的MVCC机制,这个是MySQL底层原理中比较重要的一项,它能极大的提高MySQL数据库的并发性能。MVCC广泛应用于数据库技术,像Oracle,PostgreSQL等都引入了该技术。本篇文章我们就带大家一起了解一下MySQL的MVCC机制实现原理。

什么是MVCC?

Multi-Version Concurrency Control(MVCC),翻译过来就是多版本并发控制,MVCC是为提高MySQL数据库并发性能的一个重要设计。

同一行数据发生读写请求时,会通过锁来保证数据的一致性。MVCC可以在读写冲突时,让其读数据时通过快照读,而不是当前读,快照读不必加锁。

在前边文章我们也介绍了MySQL中的锁机制,不熟悉的可以翻阅前边的文章。

InnoDB的事务

MySQL中的MVCC是在InnoDB存储引擎中得到支持的,InnoDB中最重要,也是最特殊的可谓就是事务,所以事务相关的一些设计我们必须了解。

  • 行级锁 InnoDB提供了行级锁,行级锁无疑使锁的粒度更细,但是数据过多时,在高并发场景下,同一时刻会产生大量的锁,因此,InnoDB也对锁进行了空间的有效优化,使得其在并发量高的情况下,也不会因为同一时刻锁过多,而导致内存耗尽。
+ 排他锁
+ 共享锁。
  • 隔离级别
+ READ\_UNCOMMITTED:脏读
+ READ\_COMMITTED:读提交
+ REPEATABLE\_READ:重复读
+ SERIALIZABLE:串行化
  • redo log

redo log 就是保存执行的SQL语句到一个指定的Log文件,当Mysql执行recovery时重新执行redo log记录的SQL操作即可。当客户端执行每条SQL(更新语句)时,redo log会被首先写入log buffer;当客户端执行COMMIT命令时,log buffer中的内容会被视情况刷新到磁盘。redo log在磁盘上作为一个独立的文件存在,即InnoDB的log文件。

  • undo log

与redo log相反,undo log是为回滚而用,具体内容就是将事务影响到的行的原始数据行写入到到undo buffer,在合适的时间把undo buffer中的内容刷新到磁盘。undo buffer与redo buffer一样,也是环形缓冲,但当缓冲满的时候,undo buffer中的内容会也会被刷新到磁盘;与redo log不同的是,磁盘上不存在单独的undo log文件,所有的undo log均存放在主ibd(表空间)数据文件中,即使客户端设置了每表一个数据文件也是如此。

行更新的过程

InnoDB为每行记录都实现了三个隐藏字段:

  • 隐藏的ID
  • 6字节的事务ID(DB_TRX_ID)
  • 7字节的回滚指针(DB_ROLL_PTR)

行更新的过程

  1. 数据库新增一条数据,该条数据三个隐藏字段,只有ID有值
  2. T1修改该条数据,开启事务,记录read_view
* 排它锁锁定该行数据
* 记录redo log
* 将该行数据写入undo log
* 将修改值写入该条数据,填写事务Id,根据undo log记录位置填写回滚指针
  1. T2修改该条数据,开启事务,记录read_view
* 排它锁锁定该行数据
* 记录redo log
* 将该行数据写入undo log
* 将修改值写入该条数据,填写事务Id,通过回滚指针将undo log 的两条记录连接起来(版本链)
  1. 事务提交,记录read_view
* 正常提交
* 如果触发回滚,需要根据回滚指针找到undo log对应记录进行回滚

注意:

  • InnoDB中存在purge线程,它负责查询,并清理那些无效的undo log。
  • 上述过程描述的是UPDATE事务的过程,当INSERT时,原始的数据并不存在,所以在回滚时把insert丢弃即可

MVCC的基本特征

  • 每行数据都存在一个版本,每次更新数据时都更新该版本。
  • 修改时拷贝出当前版本随意修改,各个事务之间无干扰。
  • 保存时比较版本号,如果成功提交事务,则覆盖原记录;如果失败回滚则放弃拷贝的数据。

InnoDB如何实现MVCC?

MVCC则是建立在undo log 之上的。

undo log 中记录的数据就是MVCC中的多版本。

通过回滚指针形成版本链。

通过事务ID可以查找到read-view上的记录

RC隔离级别和RR隔离级别生成read-view的时机不一样,RR是在开始事务时,RC是在每一次查询,所以在RR隔离级别下,MVCC可以解决幻读问题。

read-view记录:

  • m_ids:表示活跃事务id列表
  • min_trx_id:活跃事务中的最小事务id
  • max_trx_id:已创建的最大事务id
  • creator_trx_id:当前的事务id

版本链比对规则:

  1. 如果 trx_id < min_trx_id,表示这个版本是已提交的事务生成的,这个数据是可见的;
  2. 如果 trx_id > max_trx_id,表示这个版本是由将来启动的事务生成的,是肯定不可见的。
  3. 如果 min_trx_id <= trx_id <= max_trx_id,那就包括两种情况
* 若row的trx\_id在m\_ids数组中,表示这个版本是由还没提交的事务生成的,不可见,当前自己的事务是可见的。
* 若row的trx\_id不在m\_ids数组中,表示这个版本是已经提交了的事务生成的,可见

MySQL的InnoDB实现MVCC,就是在隔离级别为读已提交和可重复读,基于乐观锁理论,通过事务ID和read-view的记录进行比较判断分析数据是否可见,从而使其大部分读操作可以无需加锁,从而提高并发性能。

但是在写数据的时候,InnoDB还是需要加排它锁的。

总结,就是用乐观锁代替悲观锁,从而提高并发性能,这就是MVCC。

本文转载自: 掘金

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

Netty编程(一)—— 初识Netty+超全注释

发表于 2021-11-21

这是我参与11月更文挑战的第20天,活动详情查看:2021最后一次更文挑战

之前的博客介绍了NIO网络编程的相关知识,从这篇博客开始,我将开始介绍Netty的相关知识。

什么是Netty

Netty 是一个异步的、基于事件驱动的网络应用框架,可用于快速开发可维护、高性能的网络服务器和客户端

  • 基于事件驱动意思是底层实现采用多路复用技术(selector),事件发生时才需要进行处理
  • 异步是指使用了多线程完成方法调用和处理结果相分离,并不是异步IO

Hello World

学习一个技术或者框架,可以先从hello world开始了解它,然后一步一步进行学习。下面就先通过一段最基础的Netty代码来初始Netty,这段代码可以看作Netty的Hello World,它分为服务器端和客户端两部分,首先来看服务端的代码

服务端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
java复制代码public class HelloServer {
public static void main(String[] args) {
// 1、服务器端的启动器,负责装配下方的netty组件,启动服务器
new ServerBootstrap()
// 2、创建 NioEventLoopGroup,可以简单理解为 线程池 + Selector
.group(new NioEventLoopGroup())
// 3、选择服务器的 ServerSocketChannel 实现
.channel(NioServerSocketChannel.class)
// 4、child(work) 负责处理读写,该方法决定了 child(work) 执行哪些操作(handler)
// ChannelInitializer 处理器(仅执行一次)
// 5、channel的作用是待客户端SocketChannel建立连接后与客户端进行读写的通道,执行initChannel初始化,作用是添加别的handler
.childHandler(new ChannelInitializer<NioSocketChannel>() {
@Override
protected void initChannel(NioSocketChannel nioSocketChannel) throws Exception {//添加handler
// 6、添加具体的handler
nioSocketChannel.pipeline().addLast(new StringDecoder());//使用StringDecoder解码,ByteBuf=>String

nioSocketChannel.pipeline().addLast(new SimpleChannelInboundHandler<String>() {// 自定义handler,使用上一个处理器的处理结果
@Override
protected void channelRead0(ChannelHandlerContext channelHandlerContext, String s) throws Exception {
System.out.println(s);//打印上一步转换好的字符串
}
});
}
// 7、ServerSocketChannel绑定8080端口
}).bind(8080);
}
}

下面对这段代码进行解读:

  1. 首先使用new ServerBootstrap()打开一个服务端的启动器,它负责装配下面的Nettty组件,同时会启动服务器
  2. group(new NioEventLoopGroup())用来创建一个事件循环组EventLoopGroup,可以把它理解成是Selector+线程池的组合
  3. channel(NioServerSocketChannel.class)选择服务器的ServerSocketChannel 实现
  4. childHandler(new ChannelInitializer<NioSocketChannel>()中的child可以理解成worker,是负责处理读写事件的,这个方法决定了child执行哪些操作(handler)
  5. channel的作用是待客户端SocketChannel建立连接后与客户端进行读写的通道,执行initChannel初始化,作用是添加别的handler
  6. initChannel(NioSocketChannel nioSocketChannel)方法是用来添加具体的handler,具体是使用nioSocketChannel.pipeline().addLast来添加,代码中添加了第一个handler是解码,将客户端发来的数据变成String,添加了第二个handler是自定义handler,并且这个handler需要使用上一个解码handler的结果。
  7. bind(8080)最后使用bind方法对端口号进行绑定

客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
java复制代码public class HelloClient {
public static void main(String[] args) throws InterruptedException {
//1、启动类
new Bootstrap()
//2、添加EventLoop
.group(new NioEventLoopGroup())
// 3、选择客户 Socket 实现类,NioSocketChannel 表示基于 NIO 的客户端实现
.channel(NioSocketChannel.class)
// 4、添加处理器 ChannelInitializer 处理器(仅执行一次)
// 它的作用是待客户端SocketChannel建立连接后,执行initChannel以便添加更多的处理器
.handler(new ChannelInitializer<NioSocketChannel>() {//初始化器会在连接建立后被调用,调用后就会执行下面的initChannel
@Override
protected void initChannel(NioSocketChannel channel) throws Exception {
// 消息会经过通道 handler 处理,这里是将 String => ByteBuf 编码发出
channel.pipeline().addLast(new StringEncoder());
}
})
// 指定要连接的服务器和端口
.connect(new InetSocketAddress("localhost", 8080))
// Netty 中很多方法都是异步的,如 connect
// 这时需要使用 sync 方法等待 connect 建立连接完毕,是一个阻塞方法,知道连接建立
.sync()
// 获取 channel 对象,它即为通道抽象,可以进行数据读写操作
.channel()
// 写入消息并清空缓冲区,不管收发数据,都会走handle,调用处理器内部的方法
.writeAndFlush("hello world");//把字符串转成了bytebuf
}
}

可以看到其实客户端代码与服务端代码类似,下面对这段客户端的代码进行解读:

  1. new Bootstrap()启动一个客户端
  2. group(new NioEventLoopGroup())打开一个事件循环组,可以向其中添加handler
  3. channel(NioSocketChannel.class)选择客户 Socket 实现类,NioSocketChannel 表示基于 NIO 的客户端实现
  4. handler(new ChannelInitializer<NioSocketChannel>()添加 ChannelInitializer 处理器,它的作用是待客户端SocketChannel 建立连接 后,执行initChannel以便添加更多的处理器
  5. connect(new InetSocketAddress("localhost", 8080))指定要连接的服务器和端口
  6. sync()的作用是阻塞,他等待connect连接完毕
  7. channel()获取 channel 对象,它即为通道抽象,可以进行数据读写操作
  8. writeAndFlush("hello world")写入消息并清空缓冲区,无论收发数据,都会通过handle,调用处理器内部的方法

执行流程

在这里插入图片描述

有以下几点执行顺序与代码顺序不同:

  1. 服务器端初始化了ChannelInitializer 处理器后,会执行最后一行的bind方法进行绑定端口号,之后等待客户端的连接
  2. 客户端初始化ChannelInitializer后会去执行connect方法连接服务端,连接未成功之前会阻塞住,在连接成功后会立即执行上面的initChannel方法来添加handler
  3. 客户端拿到channel连接对象后发送数据”hello world”,然后添加handler会把String转成ByteBuf(类似于ByteBuffer)后发送给服务端
  4. 服务端发现有读事件发生后,会启用事件循环组EventLoopGroup中的一个EventLoop去处理这个读事件,调用具体的handler进行处理

组件解释

  • eventLoop 可以理解为处理数据的工人
+ eventLoop 可以管理多个 channel 的 io 操作,并且一旦 eventLoop 负责了某个 channel,就**会将其与channel进行绑定**(channel使用工人1发送数据了,之后channel要接收数据,那么还是使用工人1进行处理),即以后该 channel 中的 io 操作都由该 eventLoop 负责,这是为了线程安全,防止消息覆盖了
+ eventLoop 既可以执行 io 操作,**也可以进行任务处理**,每个 eventLoop 有自己的任务队列,队列里可以堆放多个 channel 的待处理任务
+ eventLoop 按照 pipeline 顺序,依次按照 handler 的规划(代码)处理数据,可以为每个 handler 指定不同的 eventLoop
  • handler 可以理解为数据的处理工序
+ 工序有多道,合在一起就是 pipeline(传递途径),pipeline 负责发布事件(读、读取完成…)传播给每个 handler, handler 对自己感兴趣的事件进行处理(重写了相应事件处理方法)
+ pipeline 中有多个 handler,处理时会依次调用其中的 handler
+ handler 分 Inbound 和 Outbound 两类


    - Inbound 入站,写入
    - Outbound 出站,写出
  • channel 可以理解为数据的通道
  • msg 理解为流动的数据,最开始输入是 ByteBuf,但经过 pipeline 中的各个 handler 加工,会变成其它类型对象,最后输出又变成 ByteBuf

本文转载自: 掘金

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

orika-Java bean属性复制工具使用

发表于 2021-11-21

orika:Java bean属性复制工具,底层基于javassist生成字段属性映射的字节码,运行时动态加载执行字节码,性能上比cglib的BeanCopier稍差。

Github地址:github.com/orika-mappe…

maven依赖

引入orika-core包

1
2
3
4
5
xml复制代码<dependency>
   <groupId>ma.glasnost.orika</groupId>
   <artifactId>orika-core</artifactId>
   <version>1.5.4</version>
</dependency>

Java使用示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
xml复制代码<!-- 测试demo依赖    -->
<dependency>
   <groupId>org.projectlombok</groupId>
   <artifactId>lombok</artifactId>
   <version>1.18.22</version>
</dependency>
<dependency>
   <groupId>org.junit.jupiter</groupId>
   <artifactId>junit-jupiter</artifactId>
   <version>5.8.1</version>
</dependency>
<dependency>
   <groupId>com.google.guava</groupId>
   <artifactId>guava</artifactId>
   <version>29.0-jre</version>
</dependency>

常用操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
java复制代码package bean;
​
import com.google.common.collect.Lists;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;
import lombok.experimental.FieldNameConstants;
import ma.glasnost.orika.MapperFacade;
import ma.glasnost.orika.converter.ConverterFactory;
import ma.glasnost.orika.converter.builtin.DateToStringConverter;
import ma.glasnost.orika.impl.DefaultMapperFactory;
import org.junit.jupiter.api.Test;
​
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
​
import static org.junit.jupiter.api.Assertions.*;
​
/**
* The type Orika demo test.
*/
class OrikaDemoTest {
​
   @Test
   void testMap() {
       DefaultMapperFactory mapperFactory = getMapperFactory();
       MapperFacade facade = mapperFactory.getMapperFacade();
​
       SourceClass source = getSourceClass();
       // 基本使用
       TargetClass target = facade.map(source, TargetClass.class);
       compare(source, target);
       assertNotEquals(source.getEmail(), target.getMyEmail());
  }
​
   @Test
   void testMapAsList() {
       DefaultMapperFactory mapperFactory = getMapperFactory();
       MapperFacade facade = mapperFactory.getMapperFacade();
​
       SourceClass source = getSourceClass();
       // 集合映射
       List<TargetClass> target = facade.mapAsList(Lists.newArrayList(source), TargetClass.class);
       compare(source, target.get(0));
       assertNotEquals(source.getEmail(), target.get(0).getMyEmail());
  }
​
   @Test
   void testDiffFieldName() {
       DefaultMapperFactory mapperFactory = getMapperFactory();
       // 设置不同字段名双向映射
       mapperFactory.classMap(SourceClass.class, TargetClass.class)
              .field(SourceClass.Fields.email, TargetClass.Fields.myEmail)
              .byDefault()
              .register();
​
       MapperFacade facade = mapperFactory.getMapperFacade();
       SourceClass source = getSourceClass();
       TargetClass target = facade.map(source, TargetClass.class);
       compare(source, target);
       assertEquals(source.getEmail(), target.getMyEmail());
  }
​
   @Test
   void testConverter() throws ParseException {
       String format = "yyyy-MM-dd";
       DefaultMapperFactory mapperFactory = getMapperFactory();
       // 设置类型转换
       ConverterFactory converterFactory = mapperFactory.getConverterFactory();
       converterFactory.registerConverter(new DateToStringConverter(format));
​
       String birth = "2021-11-21";
       Date date = new SimpleDateFormat(format).parse(birth);
       SourceClass source = getSourceClass()
              .setBirth(date);
​
       MapperFacade facade = mapperFactory.getMapperFacade();
       TargetClass target = facade.map(source, TargetClass.class);
       compare(source, target);
       assertEquals(birth, target.getBirth());
  }
​
   private DefaultMapperFactory getMapperFactory() {
       return new DefaultMapperFactory.Builder().build();
  }
​
   private void compare(SourceClass source, TargetClass target) {
       assertEquals(source.getId(), target.getId());
       assertEquals(source.getName(), target.getName());
       assertEquals(source.getAge(), target.getAge());
  }
​
   private SourceClass getSourceClass() {
       return SourceClass.builder()
              .id(1L)
              .name("test")
              .age(18)
              .email("email@xxx")
              .build();
  }
​
   @Data
   @Builder
   @NoArgsConstructor
   @AllArgsConstructor
   @FieldNameConstants
   @Accessors(chain = true)
   static class SourceClass {
       private Long id;
       private String name;
       private Integer age;
       private String email;
       private Date birth;
  }
​
   @Data
   @Builder
   @NoArgsConstructor
   @AllArgsConstructor
   @FieldNameConstants
   static class TargetClass {
       private Long id;
       private String name;
       private int age;
       private String myEmail;
       private String birth;
  }
​
}
​

本文转载自: 掘金

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

强引用和弱引用的Threadlocal

发表于 2021-11-21

从SimpleDateFormat开始

首先看一个例子,创建20个线程,线程里就干一件事,就是转换时间

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

//非线程安全的
private static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

public static Date parse(String strDate) throws ParseException {
return sdf.parse(strDate);
}

public static void main(String[] args) {
for (int i = 0; i < 20; i++) {
new Thread(() -> {
try {
System.out.println(parse("2021-11-18 21:36:17"));
} catch (ParseException e) {
e.printStackTrace();
}
}).start();
}
}

}

运行一下,报错了
image.png
原因是什么,原因就是SimpleDateFormat是非线程安全的,点进去看一下SimpleDateFormat的源码,在类的上面就写着一段话,DateFormat不是同步的,它被推荐创建独立的format实例给每个线程,如果多线程要同时访问的话,必须在外部加一个同步的。

image.png
这段话是什么意思呢,就是解决这个问题有两个办法,一个是加synchronized,代码如下:

1
2
3
java复制代码public static synchronized Date parse(String strDate) throws ParseException {
return sdf.parse(strDate);
}

但是这样做肯定会降低性能。还有一种方法就是做线程隔离,就是他注释上写的,为每个线程单独创建一个SimpleDateFormat对象,独一份的,线程独有的,这样就不会产生线程安全问题。这个就需要用到今天的主角ThreadLocal,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
java复制代码public class ThreadLoaclExample {

private static ThreadLocal<SimpleDateFormat> dateFormatThreadLocal = new ThreadLocal<>();

private static SimpleDateFormat getDateFormat() {
SimpleDateFormat dateFormat = dateFormatThreadLocal.get();//从当前线程的范围内获得一个DateFormat
if (dateFormat == null) {
dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");//在当前线程的范围内设置一个simpleDateFormat对象
//Thread.currentThread();
dateFormatThreadLocal.set(dateFormat);
}
return dateFormat;
}

public static Date parse(String strDate) throws ParseException {
return getDateFormat().parse(strDate);
}

public static void main(String[] args) {
for (int i = 0; i < 20; i++) {
new Thread(() -> {
try {
System.out.println(parse("2021-11-18 21:36:17"));
} catch (ParseException e) {
e.printStackTrace();
}
}).start();
}
}
}

运行一下,不报错了

image.png

当然上面还有个优化点就是20个线程,当1000个线程的时候,每个线程都有自己独立的SimpleDateFormat副本,这样会创建1000个SimpleDateFormat对象,会很浪费空间,所以改写成线程池的方式:

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码public static void main(String[] args) {
ExecutorService executorService = Executors.newFixedThreadPool(16);
for (int i = 0; i < 1000; i++) {
executorService.execute(() -> {
try {
System.out.println(parse("2021-11-18 21:36:17"));
} catch (ParseException e) {
e.printStackTrace();
}
});
}
}

这样的话有个好处就是用16个SimpleDateFormat对象即可完成1000个任务。

image.png
以上就是第一种非常典型的适合使用 ThreadLocal 的场景。

第二种场景

第二个作用就是起到一个上下文的作用,有这样一个应用场景,当一个请求过来service-1把user的信息计算出来,后面的方法service-2,service-3,service-4都需要用到user信息,这时的做法就是把user作为参数,不停的往后传,这样的做法导致代码十分冗余。
image.png

有一个解决办法就是把user信息放在内存中,比如hashmap,这样service-1把user信息put进去,service-2,service-3,service-4直接get就能把user信息获取出来,这样可以避免把user作为参数不停的传。
image.png

那么随之而来就会产生另一个线程并发安全问题,当个线程同时请求访问的时候呢?那我们就是要使用 synchronized 或者 ConcurrentHashMap来保证hashmap的安全,它对性能都是有所影响的。
image.png

那么最终解决方案就是使用ThreadLocal,它使得每个线程独享自己的user信息,保证了线程安全,使用的时候也只要在service-1里面存进去,service-2,service-3,service-4里面取出来即可。
image.png
这个就是第二个作用,起到上下文的作用UserContextHolder,避免了传参。

ThreadLocal的存储位置

首先来看下Thread、 ThreadLocal 及 ThreadLocalMap 三者存储的位置。
image.png
在Thread类里面有个ThreadLocalMap变量,如下图,因为存在线程里面,这样才能做到线程独有。

image.png
在ThreadLocalMap里面有很多个Entry,这个Entry的key就是弱引用的threadlocal,value就是需要存储的值。

image.png

为什么在ThreadLocalMap里会有多个Entry呢,因为我们在使用的时候可以定义多个ThreadLocal,而这些值最终的存储就是一个一个的Entry。

image.png
有了上面宏观上的感受,我们再来看下源码分析,首先看set方法:

1
2
3
4
5
6
7
8
9
10
11
java复制代码public void set(T value) {
//得到当前线程,保证隔离性
Thread t = Thread.currentThread();
//根据线程得到ThreadLocalMap,没有初始化则进行初始化
ThreadLocalMap map = getMap(t);
//如果map不为空,则将值set进去
if (map != null)
map.set(this, value);
else //否则的话创建map
createMap(t, value);
}

如果map为空的话先进行创建

image.png
初始化的过程也比较简单,新创建一个数组,根据hash值计算位置,然后把key和value放到该位置上

1
2
3
4
5
6
7
8
9
10
java复制代码ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
//默认长度为16的数组
table = new Entry[INITIAL_CAPACITY];
//计算数组下标
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
//把key和value放到i的位置上
table[i] = new Entry(firstKey, firstValue);
size = 1;
setThreshold(INITIAL_CAPACITY);
}

我们再看下map.set方法,set的时候也是先计算位置,如果位置上已经有值的,就是我之前这个key,则把value的值进行替换,如果是null则执行replaceStaleEntry方法,否则的话就移动到下一个位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
java复制代码private void set(ThreadLocal<?> key, Object value) {

// We don't use a fast path as with get() because it is at
// least as common to use set() to create new entries as
// it is to replace existing ones, in which case, a fast
// path would fail more often than not.

Entry[] tab = table;
int len = tab.length;
//计算数组下标
int i = key.threadLocalHashCode & (len-1);

//线性探测
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();
//i位置已经有值了,直接替换
if (k == key) {
e.value = value;
return;
}
//如果key==null,则进行replaceStaleEntry(替换空余的数组)
if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}

tab[i] = new Entry(key, value);
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}

我们知道Hashmap当发生冲突的时候,采用的是拉链法(也叫链地址法),而我们这的ThreadLocalMap采用的是线性探测法,如果发生冲突,并不会用链表的形式往下链,而是会继续寻找下一个空的格子。感兴趣的小伙伴可以看下《ConcurrentHashMap源码精讲》 。

image.png

我们再来看下get方法,这个方法也很简单,先从线程中拿到ThreadLocalMap,然后再从map中传入this自己作为key,来拿到Entry,再从Entry中拿到value。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java复制代码public T get() {
//获取到当前线程
Thread t = Thread.currentThread();
//获取到当前线程内的 ThreadLocalMap 对象
ThreadLocalMap map = getMap(t);
if (map != null) {
//获取 ThreadLocalMap 中的 Entry 对象并拿到 Value
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
//如果线程内之前没创建过 ThreadLocalMap,就创建
return setInitialValue();
}

如果map为空的话则进行初始化操作setInitialValue,这个跟上面的set方法里面的逻辑是一样的。

1
2
3
4
5
6
7
8
9
10
java复制代码private T setInitialValue() {
T value = initialValue();
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
return value;
}

强引用和弱引用

标题中已经提到了强引用和弱引用,还有上面讲到的Entry里面的key是ThreadLocal的弱引用,那么具体什么是强引用,什么是弱引用,这里做下介绍。

先看下强引用的代码:

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

static Object object = new Object();

public static void main(String[] args) {
Object strongRef = object;
object = null;
System.gc();
System.out.println(strongRef);
}
}

运行一下,没有被回收掉

image.png

我画了个示意图大家看下,一开始object和strongRef都指向了堆区的new Object()对象。

image.png

后来执行object = null,相当于栈和堆之间的连线断掉了,所以在System.gc()以后,由于strongRef还连接着new Object(),所以就没有被释放掉。
image.png
再看下弱引用的代码:

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

static Object object = new Object();

public static void main(String[] args) {
WeakReference<Object> weakRef = new WeakReference<>(object);
object = null;
System.gc();
System.out.println(weakRef.get());
}
}

再执行一下,结果为null,已经被回收掉了
image.png

弱引用的连接就很弱,这根虚线等于没有,形同虚设,在回收的时候new Object()一看没人在引用了,那么就直接回收掉了,所以打印weakRef的时候就为null。

image.png

所以在上面看源码中会出现k==null的判断,就是因为threadlocal是弱引用,当我们在业务代码中执行了 ThreadLocal instance = null 操作,我们想要清理掉这个 ThreadLocal 实例,由于是弱引用,就像上面的例子一样,经过垃圾回收以后key会变为null,那么这个Entry一直在数组里占着是不行的,所以会把key==null的给清理掉。

对于垃圾回收不是很懂的小伙伴可以看下《一篇文章搞懂GC垃圾回收》 。

内存泄露/remove()方法

首先说下用完ThreadLocal一定要调用remove()方法!一定要调用remove()方法!一定要调用remove()方法! 否则就是会造成内存泄露。

内存泄漏指的是,当某一个对象不再有用的时候,占用的内存却不能被回收,这就叫作内存泄漏。

Key 的泄漏

在上面提到过key是弱引用,如果是强引用的话,当执行ThreadLocal instance = null的时候,key还在引用着threadlocal,这时候就不会释放内存,那么这个Entry就一直存在数组中,得不到清理,越堆越多。

但是如果采用弱引用,key会变为null,JDK帮我们考虑了这一点,在执行 ThreadLocal 的 get、set、remove、rehash 等方法时,它都会扫描 key 为 null 的 Entry,如果发现某个 Entry 的 key 为 null,则代表它所对应的 value 也没有作用了,所以它就会把对应的 value 置为 null, 这样,value 对象就可以被正常回收了,防止内存泄露。

value的泄露

虽然解决了key的泄露,但是我们知道value是强引用,我们看下下面的调用链:
image.png

Thread Ref → Current Thread → ThreadLocalMap → Entry → Value → 可能泄漏的value实例。

这条链路是随着线程的存在而一直存在的,如果线程执行耗时任务而不停止,而ThreadLocal的get、set、remove、rehash 方法也没有被调用的话,那么这个value指向的内存也一直存在,一直占着。解决这种情况,就是使用remove方法。看下源码:

1
2
3
4
5
java复制代码public void remove() {
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null)
m.remove(this);
}

还有一种危险,如果线程是线程池的话,在线程执行完代码的时候并没有结束,只是归还给线程池,那么这个线程中的value就一直被占着,得不到回收,造成内存泄露。所以我们在编码中要养成良好的习惯,不再使用ThreadLocal的时候就要调用remove()方法,及时释放内存。最后感谢大家的收看~

本文转载自: 掘金

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

PYTHONPATH环境变量

发表于 2021-11-21

这是我参与11月更文挑战的第21天,活动详情查看:2021最后一次更文挑战

软硬件环境

  • windows 10 64bit
  • anaconda3 with python 3.7
  • git bash

PYTHONPATH

PYTHONPATH 是一个环境变量,它是一个列表,列表的元素是目录,也就是一些文件夹的路径,python 会将这些路径加入到 sys.path 目录列表中

PYTHONPATH的作用

不知道大家有没有注意到,我们在使用 pip 安装第三方库的时候终端的一些输出信息,比如这里的安装 ffmpy3

PYTHONPATH

可以看到库是安装到了 c:\users\admin\anaconda3\lib\site-packages,这个目录也是安装库的默认位置。这时候我们 import ffmpy3,python 就会去上面这个路径去找 ffmpy3,如果没有找到,就会报错 ModuleNotFoundError: No module named 'ffmpy3'

有时候,我们自己写了一些代码,想要在其他模块中被导入并使用,但是这些代码并没有被安装到 c:\users\admin\anaconda3\lib\site-packages,这时候 PYTHONPATH 就派的上用场了,来看下面的示例,2个 python 文件在同一级目录

1
2
3
4
go复制代码# 模块mymodule.py

def func():
print('mymodule')
1
2
3
4
5
6
python复制代码# 使用上面的模块
# script.py

import mymodule

mymodule.func()

默认情况下,PYTHONPATH 是个空值

PYTHONPATH

这时候去执行 script.py 的话,就会报错

1
2
3
4
5
java复制代码$ python script.py
Traceback (most recent call last):
File "script.py", line 1, in <module>
import mymodule
ModuleNotFoundError: No module named 'mymodule'

现在我们来设置 PYTHONPATH,在 script.py 同级目录执行

1
bash复制代码export PYTHONPATH="$PWD"

PYTHONPATH

然后再去执行 script.py 就可以看到 mymodule.py 中函数的输出了

路径列表

如果有多个要被导入的模块,这时候就需要添加多个路径到 PYTHONPATH,方法是类似的

1
2
bash复制代码cd ..
export PYTHONPATH="$PWD":$PYTHONPATH

如果要调整路径的顺利的话,可以将冒号前后的部分对调,即 $PYTHONPATH:$PWD

PYTHONPATH

python中获取PYTHONPATH值

使用 os.environ 来获取,其实所有的环境变量都可以通过它来获取

1
2
3
lua复制代码import os

os.environ['PYTHONPATH']

PYTHONPATH

本文转载自: 掘金

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

GitHub OAuth2 第三方登录及自定义认证服务器的实

发表于 2021-11-21

前言

本文将介绍如何访问基于OAuth2协议的GitHub用户信息API接口以及如何自己实现一个简单的基于授权码模式的认证服务器,如果对OAuth2的基本概念和四种授权模式还不熟悉,可以先看一下阮一峰老师的博客:OAuth 2.0 的一个简单解释,本文则主要以实际的demo来讲解使用方法。本文所展示示例的完整代码已上传到GitHub。

GitHub 第三方登录

前置准备

在访问Github的API接口之前,需要先访问https://github.com/settings/applications/new,然后填写以下的内容:

image-20211121161853496

这里除了最后一项Authorization callback URL,其它内容对后续的代码处理都没有影响(用于用户点击第三方登录时展示网站的信息),而最后一项则是用于接收临时授权码code来换取Access Token的回调地址,即对应下图中的D和E,下图来自https://datatracker.ietf.org/doc/html/rfc6749#section-4.1。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
sql复制代码     +----------+
| Resource |
| Owner |
| |
+----------+
^
|
(B)
+----|-----+ Client Identifier +---------------+
| -+----(A)-- & Redirection URI ---->| |
| User- | | Authorization |
| Agent -+----(B)-- User authenticates --->| Server |
| | | |
| -+----(C)-- Authorization Code ---<| |
+-|----|---+ +---------------+
| | ^ v
(A) (C) | |
| | | |
^ v | |
+---------+ | |
| |>---(D)-- Authorization Code ---------' |
| Client | & Redirection URI |
| | |
| |<---(E)----- Access Token -------------------'
+---------+ (w/ Optional Refresh Token)

Note: The lines illustrating steps (A), (B), and (C) are broken into two parts as they pass through the user-agent.
Authorization Code Flow

在填完以上信息后,就会跳转到以下界面:

image-20211121163154306

这里需要将Client ID以及点击Generate a new client secret后生成的Client Secret进行保存用于后续使用。

编码

完成以上的准备步骤后就可以开始编码工作了,首先为了后续使用和修改方便,可以先将Client ID和Client Secret在配置文件中进行配置(为了减少篇幅,只展示部分核心代码):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
yaml复制代码server:
port: 8080

oauth:
github:
# 替换为自己的 Client ID 和 Client Secret
clientId: 8aed0bc8316548e9d26d
clientSecret: fdd0e7af5052164e459098703005c5db25f857a8
# 用于后台获取 GitHub 用户信息后生成本地 token 后传递给前端处理的地址
frontRedirectUrl: http://localhost/redirect

# 一个 HTTP客户端(https://github.com/LianjiaTech/retrofit-spring-boot-starter)框架的配置
retrofit:
global-connect-timeout-ms: 20000
global-read-timeout-ms: 10000

然后编写对应的实体GithubAuth:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
java复制代码/**
* github 认证信息
*
* @author zjw
* @date 2021-10-23
*/
@Data
@Component
@ConfigurationProperties(prefix = "oauth.github")
public class GithubAuth {

/**
* 客户端 id
*/
private String clientId;

/**
* 客户端密钥
*/
private String clientSecret;

/**
* 前端重定向地址
*/
private String frontRedirectUrl;

}

然后编写GitHub的认证接口服务类:

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复制代码/**
* github oauth 接口服务类
*
* @author zjw
* @date 2021-10-23
*/
@RetrofitClient(baseUrl = "https://github.com/login/oauth/")
public interface GithubAuthService {

/**
* 进行 github 授权请求
*
* @param clientId 客户端 id
* @param clientSecret 客户端密钥
* @param code 临时授权码
* @return access_token
*/
@POST("access_token")
@Headers("Accept: application/json")
GithubToken getToken(
@Query("client_id") String clientId,
@Query("client_secret") String clientSecret,
@Query("code") String code);

}

以及获取用户信息的接口服务类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
java复制代码/**
* github 接口服务类
*
* @author zjw
* @date 2021-10-23
*/
@RetrofitClient(baseUrl = "https://api.github.com")
public interface GithubApiService {

/**
* 根据 access_token 获取 github 用户信息
*
* @param authorization 请求认证头
* @return github 用户
*/
@GET("/user")
GithubUser getUserInfo(@Header(HttpHeaders.AUTHORIZATION) String authorization);

}

然后是处理临时授权码code的接口(这里的接口地址即对应上文中填写的回调地址):

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复制代码/**
* oauth2 认证控制器
*
* @author zjw
* @date 2021-10-23
*/
@RestController
@RequestMapping("/oauth")
public class OauthController {

@Resource
private GithubAuth githubAuth;

@Resource
private GithubApiService githubApiService;

@Resource
private GithubAuthService githubAuthService;

/**
* github 重定向地址
*
* @param code 临时授权码
* @param response 响应
*/
@GetMapping("/github/redirect")
public void githubRedirect(String code, HttpServletResponse response) {
// 获取 access_token
String clientId = githubAuth.getClientId();
String clientSecret = githubAuth.getClientSecret();
GithubToken githubToken = githubAuthService.getToken(clientId, clientSecret, code);
// 获取 github 用户信息
String authorization = String.join(
StringUtils.SPACE, githubToken.getTokenType(), githubToken.getAccessToken());
GithubUser githubUser = githubApiService.getUserInfo(authorization);
// 生成本地访问 token
String token = JwtUtils.sign(githubUser.getUsername(), UserType.GITHUB.getType());
try {
response.sendRedirect(githubAuth.getFrontRedirectUrl() + "?token=" + token);
} catch (IOException e) {
throw new ApiException(REDIRECT_FAILED);
}
}

}

而前端只需要在登录首页放置对应的GitHub图标并设置点击事件:

1
2
3
4
5
6
javascript复制代码githubAuthorize() {
const env = process.env
window.location.href = `https://github.com/login/oauth/authorize?
client_id=${env.VUE_APP_GITHUB_CLIENT_ID}
&redirect_uri=${env.VUE_APP_GITHUB_REDIRECT_URI}`
}

并且在重定向界面进行以下跳转处理:

1
2
3
4
javascript复制代码created() {
this.setToken(this.$route.query.token)
this.$router.push('/')
}

以上便是项目的核心配置,最终效果如下:

动画

自己实现 OAuth2 认证服务器

前置准备

本文采用了数据库存储的方式用来保存客户端的信息,因此首先需要执行以下SQL脚本创建对应的表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
sql复制代码-- ----------------------------
-- Table structure for oauth_client_details
-- ----------------------------
DROP TABLE IF EXISTS `oauth_client_details`;
CREATE TABLE `oauth_client_details` (
`client_id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
`resource_ids` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
`client_secret` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
`scope` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
`authorized_grant_types` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
`web_server_redirect_uri` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
`authorities` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
`access_token_validity` int(11) NULL DEFAULT NULL,
`refresh_token_validity` int(11) NULL DEFAULT NULL,
`additional_information` varchar(4096) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
`autoapprove` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
PRIMARY KEY (`client_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;

-- ----------------------------
-- Records of oauth_client_details
-- ----------------------------
INSERT INTO `oauth_client_details` VALUES ('butterfly', NULL, '$2a$10$KvJWyf4wI.YcpzmbYGw8NOSlauim7dF9b/VSMOomONJf40Bq8F4Me', 'all', 'authorization_code', 'http://localhost:8080/oauth/oauth2/redirect', NULL, 3600, 7200, NULL, 'false');

编码

首先是yaml配置:

1
2
3
4
5
6
7
8
9
10
yaml复制代码server:
port: 9002

spring:
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driverClassName: com.mysql.cj.jdbc.Driver
jdbcUrl: jdbc:mysql://localhost:3306/oauth2?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
username: root
password: root

然后是认证服务器的配置:

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
java复制代码/**
* 认证服务器配置
*
* @author zjw
* @date 2021-10-13
*/
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfiguration extends AuthorizationServerConfigurerAdapter {

@Resource
private UserDetailsServiceImpl userDetailsService;

/**
* 配置 jwt 类型的 access_token
*/
@Bean
public TokenStore tokenStore() {
return new JwtTokenStore(jwtAccessTokenConverter());
}

/**
* 设置 token 签名
*/
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
JwtAccessTokenConverter accessTokenConverter = new JwtAccessTokenConverter();
accessTokenConverter.setSigningKey(BaseConstants.SECRET);
return accessTokenConverter;
}

/**
* 配置认证数据源
*/
@Bean
@Primary
@ConfigurationProperties(prefix = "spring.datasource")
public DataSource dataSource() {
return DataSourceBuilder.create().build();
}

/**
* 配置 jdbc 认证方式
*/
@Bean
public ClientDetailsService jdbcClientDetails() {
return new JdbcClientDetailsService(dataSource());
}

/**
* 配置 jdbc 认证方式
*/
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.withClientDetails(jdbcClientDetails());
}

/**
* 配置获取凭证的信息格式及内容
*/
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
endpoints.tokenStore(tokenStore())
.accessTokenConverter(jwtAccessTokenConverter())
// 获取 refresh_token 需要配置此项
.userDetailsService(userDetailsService);
}

/**
* 开启 token 认证功能
*/
@Override
public void configure(AuthorizationServerSecurityConfigurer security) {
security.allowFormAuthenticationForClients()
.tokenKeyAccess("isAuthenticated()")
.checkTokenAccess("isAuthenticated()");
}

}

然后是自定义的用户权限信息,在这里可以设置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
29
java复制代码/**
* 用户权限信息设置
*
* @author zjw
* @date 2021-10-13
*/
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

@Resource
private IUserService userService;

/**
* 设置 access_token 中存储的认证信息
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userService.getByUsername(username);
if (user == null) {
throw new ApiException(BaseConstants.AUTHENTICATION_FAILED);
}
String account = user.getUsername();
return new org.springframework.security.core.userdetails.User(
account, user.getPassword(),
Collections.singletonList(new SimpleGrantedAuthority(account))
);
}

}

然后是Web安全相关的配置:

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
java复制代码/**
* web 安全配置
*
* @author zjw
* @date 2021-10-13
*/
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {

/**
* 密码加密方式
*/
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

/**
* 自定义用户权限配置
*/
@Bean
@Override
public UserDetailsService userDetailsService() {
return new UserDetailsServiceImpl();
}

/**
* 设置 token 中存储的权限信息
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService())
.passwordEncoder(passwordEncoder());
}

/**
* 跨域配置
*/
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Collections.singletonList(ALL));
configuration.setAllowedMethods(Arrays.asList(
HttpMethod.POST.name(), HttpMethod.GET.name(), HttpMethod.OPTIONS.name()));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration(ALL_PATTERN, configuration);
return source;
}

}

以上便是授权服务器全部的配置,下面展示资源服务器相关的配置,这里仍然采取将授权和资源服务器分开的方式进行配置,并且采用的仍然是老版本的配置方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
java复制代码/**
* 资源服务器
*
* @author zjw
* @date 2021-11-21
*/
@Configuration
@EnableResourceServer
public class ResourceServerConfiguration extends ResourceServerConfigurerAdapter {

@Override
public void configure(HttpSecurity http) throws Exception {
http
.exceptionHandling()
.and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/user/**").access("#oauth2.hasScope('all')");
}

}

然后创建一个返回用户信息的接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
java复制代码/**
* 资源控制器
*
* @author zjw
* @date 2021-11-20
*/
@RestController
public class ResourceController {

/**
* 根据 token 获取用户名
*
* @param authorization token 请求头
* @return 用户名
*/
@GetMapping("/user/info")
public String info(@RequestHeader(HttpHeaders.AUTHORIZATION) String authorization) {
String token = authorization.substring(TokenConstants.BEARER_PREFIX.length());
return JWT.decode(token).getClaim(TokenConstants.USERNAME).asString();
}

}

yaml的配置如下,这里的http://localhost:9002需要修改为自己授权服务器的地址:

1
2
3
4
5
6
7
8
9
10
11
12
yaml复制代码server:
port: 9003

security:
oauth2:
client:
client-id: butterfly
client-secret: 123456
access-token-uri: http://localhost:9002/oauth/token
user-authorization-uri: http://localhost:9002/oauth/authorize
resource:
token-info-uri: http://localhost:9002/oauth/check_token

完成以上的配置后便可以参考GitHub中步骤来编写客户端的后端代码:

首先是yaml的配置:

1
2
3
4
5
6
7
8
9
10
11
12
yaml复制代码server:
port: 8080

oauth:
oauth2:
clientId: butterfly
clientSecret: 123456
frontRedirectUrl: http://localhost/redirect

retrofit:
global-connect-timeout-ms: 20000
global-read-timeout-ms: 10000

然后创建对应的实体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
java复制代码/**
* oauth2 认证信息
*
* @author zjw
* @date 2021-10-31
*/
@Data
@Component
@ConfigurationProperties(prefix = "oauth.oauth2")
public class Oauth2Auth {

/**
* 客户端 id
*/
private String clientId;

/**
* 客户端密钥
*/
private String clientSecret;

/**
* 前端重定向地址
*/
private String frontRedirectUrl;

}

然后编写对应的认证及获取用户信息的接口服务类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
java复制代码/**
* oauth2 接口服务类
*
* @author zjw
* @date 2021-10-31
*/
@RetrofitClient(baseUrl = "http://localhost:9002/oauth/")
public interface Oauth2AuthService {

/**
* 进行 oauth2 授权请求
*
* @param authorization 认证头
* @param code 临时授权码
* @param grantType 认证类型
* @return access_token
*/
@POST("token")
Oauth2Token getToken(
@Header(HttpHeaders.AUTHORIZATION) String authorization,
@Query("code") String code,
@Query("grant_type") String grantType);

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
java复制代码/**
* oauth2 接口服务类
*
* @author zjw
* @date 2021-10-31
*/
@RetrofitClient(baseUrl = "http://localhost:9003")
public interface Oauth2ApiService {

/**
* 根据 access_token 获取 oauth2 用户名
*
* @param authorization 请求认证头
* @return oauth2 用户名
*/
@GET("/user/info")
String getUserInfo(@Header(HttpHeaders.AUTHORIZATION) String authorization);

}

然后编写处理临时授权码code的回调接口:

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复制代码/**
* oauth2 认证控制器
*
* @author zjw
* @date 2021-10-23
*/
@RestController
@RequestMapping("/oauth")
public class OauthController {

@Resource
private Oauth2Auth oauth2Auth;

@Resource
private Oauth2AuthService oauth2AuthService;

@Resource
private Oauth2ApiService oauth2ApiService;

/**
* oauth2 重定向地址
*
* @param code 临时授权码
* @param response 响应
*/
@GetMapping("/oauth2/redirect")
public void oauth2Redirect(String code, HttpServletResponse response) {
// 获取 access_token
String clientId = oauth2Auth.getClientId();
String clientSecret = oauth2Auth.getClientSecret();
String authorization = BaseConstants.BASIC_TYPE + Base64.getEncoder().encodeToString(
(String.join(":", clientId, clientSecret)).getBytes()
);
// 获取 oauth2 用户名
Oauth2Token oauth2Token = oauth2AuthService.getToken(
authorization, code, "authorization_code");
String username = oauth2ApiService.getUserInfo(
String.join(
StringUtils.SPACE,
oauth2Token.getTokenType(),
oauth2Token.getAccessToken()
)
);
// 生成本地访问 token
String token = JwtUtils.sign(username, UserType.OAUTH2.getType());
try {
response.sendRedirect(oauth2Auth.getFrontRedirectUrl() + "?token=" + token);
} catch (IOException e) {
throw new ApiException(REDIRECT_FAILED);
}
}

}

然后在前端同样添加对应图标的点击事件:

1
2
3
4
5
javascript复制代码oauth2Authorize() {
const env = process.env
window.location.href = `http://localhost:9002/oauth/authorize?
client_id=${env.VUE_APP_OAUTH_CLIENT_ID}&response_type=code`
}

重定向界面的跳转处理仍然不变:

1
2
3
4
javascript复制代码created() {
this.setToken(this.$route.query.token)
this.$router.push('/')
}

最终的效果如下:

动画

总结

本文简单讲解了如果访问GitHub的第三方登录接口以及实现一个简单的认证服务器,在后续文章中会讲解如何自定义认证服务器的登录和确认界面以及增加自定义的认证方式。

本文转载自: 掘金

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

1…248249250…956

开发者博客

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