InfoPool

私人信息记录

0%

Nginx 架构

图 0
从上边这张图,我们可以一览nginx的架构设计,首先我们可以直观得出nginx的几大特点:

  1. 事件驱动&异步非阻塞
  2. 多进程机制
  3. 服务端缓存
  4. 反向代理

Nginx模块

图 1

  • 核心模块 :是nginx 服务器正常运行必不可少的模块,提供错误日志记录、配置文件解析、事件驱动
    机制、进程管理等核心功能
  • 标准HTTP模块 :提供 HTTP 协议解析相关的功能,如:端口配置、网页编码设置、HTTP 响应头设
    置等
  • 可选HTTP模块 :主要用于扩展标准的 HTTP 功能,让nginx能处理一些特殊的服务,如:Flash 多
    媒体传输、解析 GeoIP 请求、SSL 支持等
  • 邮件服务模块 :主要用于支持 nginx 的邮件服务,包括对 POP3 协议、IMAP 协议和 SMTP 协议的支持
  • 第三方模块 :是为了扩展 Nginx 服务器应用,完成开发者自定义功能,如:Json 支持、Lua 支持等

nginx目录一览

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
[root@localhost /]# tree  /usr/local/nginx/  -L 2
/usr/local/nginx/
├── conf #存放一系列配置文件的目录
│   ├── fastcgi.conf #fastcgi程序相关配置文件
│   ├── fastcgi.conf.default #fastcgi程序相关配置文件备份
│   ├── fastcgi_params #fastcgi程序参数文件
│   ├── fastcgi_params.default #fastcgi程序参数文件备份
│   ├── koi-utf #编码映射文件
│   ├── koi-win #编码映射文件
│   ├── mime.types #媒体类型控制文件
│   ├── mime.types.default #媒体类型控制文件备份
│   ├── nginx.conf #主配置文件
│   ├── nginx.conf.default #主配置文件备份
│   ├── scgi_params #scgi程序相关配置文件
│   ├── scgi_params.default #scgi程序相关配置文件备份
│   ├── uwsgi_params #uwsgi程序相关配置文件
│   ├── uwsgi_params.default #uwsgi程序相关配置文件备份
│   └── win-utf #编码映射文件
├── html #存放网页文档
│   ├── 50x.html #错误页码显示网页文件
│   └── index.html #网页的首页文件
├── logs #存放nginx的日志文件
├── sbin #存放启动程序
│   ├── nginx #nginx启动程序
│   └── nginx.old

nginx.conf文件 解读

首先我们要知道nginx.conf文件是由一个一个的指令块组成的,nginx用{}标识一个指令块,指令块中再设置具体的指令(注意指令必须以;号结尾)。
指令块有:

  1. 全局模块
  2. events模块
  3. http模块
  4. server模块
  5. location模块
  6. upstream模块

精简后的结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
全局模块
event模块
http模块
upstream模块

server模块
location块
location块
....
server模块
location块
location块
...
....

各模块的功能作用如下描述:

  1. 全局模块: 配置影响nginx全局的指令,比如运行nginx的用户名,nginx进程pid存放路径,日志存放路径,配置文件引入,worker进程数等。
  2. events块: 配置影响nginx服务器或与用户的网络连接。比如每个进程的最大连接数,选取哪种事件驱动模型(select/poll epoll或者是其他等等nginx支持的)来处理连接请求,是否允许同时接受多个网路连接,开启多个网络连接序列化等。
  3. http块: 可以嵌套多个server,配置代理,缓存,日志格式定义等绝大多数功能和第三方模块的配置。如文件引入,mime-type定义,日志自定义,是否使用sendfile传输文件,连接超时时间,单连接请求数等。
  4. upstream块: 配置上游服务器的地址以及负载均衡策略和重试策略等等。
  5. server块: 配置虚拟主机的相关参数比如域名端口等等,一个http中可以有多个server。
  6. location块: 配置url路由规则。

下面看下nginx.conf长啥样并对一些指令做个解释:

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
# 注意:有些指令是可以在不同指令块使用的(需要时可以去官网看看对应指令的作用域)。这里只是演示

[root@localhost /usr/local/nginx]# cat /usr/local/nginx/conf/nginx.conf

#user nobody; # 指定Nginx Worker进程运行用户以及用户组,默认由nobody账号运行

worker_processes 1; # 指定工作进程的个数,默认是1个。具体可以根据服务器cpu数量进行设置, 比如cpu有4个,可以设置为4。如果不知道cpu的数量,可以设置为auto。 nginx会自动判断服务器的cpu个数,并设置相应的进程数

#error_log logs/error.log; # 用来定义全局错误日志文件输出路径,这个设置也可以放入http块,server块,日志输出级别有debug、info、notice、warn、error、crit可供选择,其中,debug输出日志最为最详细,而crit输出日志最少。
#error_log logs/error.log notice;
#error_log logs/error.log info; # 指定error日志位置和日志级别

#pid logs/nginx.pid; # 用来指定进程pid的存储文件位置

events {
accept_mutex on; # 设置网路连接序列化,防止惊群现象发生,默认为on

# Nginx支持的工作模式有select、poll、kqueue、epoll、rtsig和/dev/poll,其中select和poll都是标准的工作模式,kqueue和epoll是高效的工作模式,不同的是epoll用在Linux平台上,而kqueue用在BSD系统中,对于Linux系统,epoll工作模式是首选
use epoll;

# 用于定义Nginx每个工作进程的最大连接数,默认是1024。最大客户端连接数由worker_processes和worker_connections决定,即Max_client=worker_processes*worker_connections在作为反向代理时,max_clients变为:max_clients = worker_processes *worker_connections/4。进程的最大连接数受Linux系统进程的最大打开文件数限制,在执行操作系统命令“ulimit -n 65536”后worker_connections的设置才能生效
worker_connections 1024;
}

# 对HTTP服务器相关属性的配置如下
http {
include mime.types; # 引入文件类型映射文件
default_type application/octet-stream; # 如果没有找到指定的文件类型映射 使用默认配置
# 设置日志打印格式
#log_format main '$remote_addr - $remote_user [$time_local] "$request" '
# '$status $body_bytes_sent "$http_referer" '
# '"$http_user_agent" "$http_x_forwarded_for"';
#
#access_log logs/access.log main; # 设置日志输出路径以及 日志级别
sendfile on; # 开启零拷贝 省去了内核到用户态的两次copy故在文件传输时性能会有很大提升
#tcp_nopush on; # 数据包会累计到一定大小之后才会发送,减小了额外开销,提高网络效率
keepalive_timeout 65; # 设置nginx服务器与客户端会话的超时时间。超过这个时间之后服务器会关闭该连接,客户端再次发起请求,则需要再次进行三次握手。
#gzip on; # 开启压缩功能,减少文件传输大小,节省带宽。
sendfile_max_chunk 100k; #每个进程每次调用传输数量不能大于设定的值,默认为0,即不设上限。

# 配置你的上游服务(即被nginx代理的后端服务)的ip和端口/域名
upstream backend_server {
server 172.30.128.65:8080;
server 172.30.128.65:8081 backup; #备机
}

server {
listen 80; #nginx服务器监听的端口
server_name localhost; #监听的地址 nginx服务器域名/ip 多个使用英文逗号分割
#access_log logs/host.access.log main; # 设置日志输出路径以及 级别,会覆盖http指令块的access_log配置

# location用于定义请求匹配规则。 以下是实际使用中常见的3中配置(即分为:首页,静态,动态三种)

# 第一种:直接匹配网站根目录,通过域名访问网站首页比较频繁,使用这个会加速处理,一般这个规则配成网站首页,假设此时我们的网站首页文件就是: usr/local/nginx/html/index.html
location = / {
root html; # 静态资源文件的根目录 比如我的是 /usr/local/nginx/html/
index index.html index.htm; # 静态资源文件名称 比如:网站首页html文件
}
# 第二种:静态资源匹配(静态文件修改少访问频繁,可以直接放到nginx或者统一放到文件服务器,减少后端服务的压力),假设把静态文件我们这里放到了 usr/local/nginx/webroot/static/目录下
location ^~ /static/ {
alias /webroot/static/; #访问 ip:80/static/xxx.jpg后,将会去获取/url/local/nginx/webroot/static/xxx.jpg 文件并响应
}
# 第二种的另外一种方式:拦截所有 后缀名是gif,jpg,jpeg,png,css.js,ico这些 类静态的的请求,让他们都去直接访问静态文件目录即可
location ~* \.(gif|jpg|jpeg|png|css|js|ico)$ {
root /webroot/static/;
}
# 第三种:用来拦截非首页、非静态资源的动态数据请求,并转发到后端应用服务器
location / {
proxy_pass http://backend_server; #请求转向 upstream是backend_server 指令块所定义的服务器列表
deny 192.168.3.29; #拒绝的ip (黑名单)
allow 192.168.5.10; #允许的ip(白名单)
}

# 定义错误返回的页面,凡是状态码是 500 502 503 504 总之50开头的都会返回这个 根目录下html文件夹下的50x.html文件内容
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}

}
# 其余的server配置 ,如果有需要的话
#server {
......
# location / {
....
# }
#}

# include /etc/nginx/conf.d/*.conf; # 一般我们实际使用中有很多配置,通常的做法并不是将其直接写到nginx.conf文件,
# 而是写到新文件 然后使用include指令 将其引入到nginx.conf即可,这样使得主配置nginx.conf文件更加清晰。

}

以上就是nginx.conf文件的配置了,主要讲了一些指令的含义,当然实际的指令有很多,我在配置文件并没有全部写出来,准备放到后边章节详细阐述这些东西,比如:location匹配规则,反向代理,动静分离,负载均衡策略,重试策略,压缩,https,限流,缓存,跨域这些 我们都没细说,这些东西比较多比较细不可能把使用规则和细节都写到上边的配置文件中,所以我们下边一一解释说明关于这些东西的配置和使用方式。

另外值的注意的是: 因为有些指令是可以在不同作用域使用的,如果在多个作用域都有相同指令的使用,那么nginx将会遵循就近原则或者我愿称之为 内层配置优先。 eg: 你在 http配了日志级别,也在某个server中配了日志级别,那么这个server将使用他自己配置的已不使用外层的http日志配置。

反向代理

图 2

要让 nginx 代理 我们的 服务 很简单,简单描述一下就是 两步:

通过upstream指令块来定义我们的上游服务(即被代理的服务)
通过location指令块中的 proxy_pass指令,指定该location要路由到哪个upstream

配置好1和2后,如果来了请求后 会通过url路由到对应的location, 然后nginx会将请求打到upstream定义的服务地址中去。
图 3

注意上边的 proxy_pass http://mybackendserver/ 后边这个斜线加和不加区别挺大的,加的话不会拼接/backend , 而不加的话会拼接 /backend

消除 proxy_pass 转发 /的恐怖阴影

在配置Nginx过程中路由转发的一个小小 / 可能会给你带来很多麻烦,虽然只是一个小小的斜杠,但是转发的结果千差万别

  1. proxy_pass 不带/
1
2
3
4
5
6
location /alpha/ {
proxy_pass http://192.168.xxx.xxx:80;
}
http://domain/alpha/ --> http://192.168.xxx.xxx:80/alpha/
http://domain/alpha/beta/abc --> http://192.168.xxx.xxx:80/alpha/beta/abc

  1. proxy_pass 带/
1
2
3
4
5
6
location /alpha/ {
proxy_pass http://192.168.xxx.xxx:80/;
}
http://domain/alpha/ --> http://192.168.xxx.xxx:80/
http://domain/alpha/beta/abc --> http://192.168.xxx.xxx:80/beta/abc

反向代理流程与原理

对于上边演示的反向代理案例的流程与原理,我们来个示意图如下
图 4

负载均衡

说到负载均衡很多人应该并不陌生,总而言之负载均衡就是:避免高并发高流量时请求都聚集到某一个服务或者某几个服务上,而是让其均匀分配(或者能者多劳),从而减少高并发带来的系统压力,从而让服务更稳定。对于nginx来说,负载均衡就是从 upstream 模块定义的后端服务器列表中按照配置的负载策略选取一台服务器接受用户的请求。

nginx常用的负载策略

  1. 轮询(默认方式)
    1. 每个请求会按时间顺序逐一分配到不同的后端服务器
    2. 在轮询中,如果服务器down掉了,会自动剔除该服务器
    3. 缺省配置就是轮询策略
    4. 此策略适合服务器配置相当,无状态且短平快的服务使用
  2. weight(权重方式)
    1. 在轮询策略的基础上指定轮询的几率
    2. 权重越高分配到的请求越多
    3. 此策略可以与least_conn和ip_hash结合使用
    4. 此策略比较适合服务器的硬件配置差别比较大的情况
  3. ip_hash(依据ip的hash值来分配)
    1. 在nginx版本1.3.1之前,不能在ip_hash中使用权重(weight)
    2. ip_hash不能与backup同时使用
    3. 此策略适合有状态服务,比如session
    4. 当有服务器需要剔除,必须手动down掉
  4. least_conn(最少连接方式)
    1. 此负载均衡策略适合请求处理时间长短不一造成服务器过载的情况
  5. fair(响应时间方式)
    1. 根据后端服务器的响应时间来分配请求,响应时间短的优先分配
    2. Nginx本身不支持fair,如果需要这种调度算法,则必须安装upstream_fair模块
  6. url_hash(依据URL分配方式)
    1. 按访问的URL的哈希结果来分配请求,使每个URL定向到一台后端服务器
    2. Nginx本身不支持url_hash,如果需要这种调度算法,则必须安装Nginx的hash软件包

轮询

轮询策略是默认的,所以只需要如下这样修改配置文件就可以了

1
2
3
4
5
6
upstream backend_server { 
ip_hash
server 172.30.128.65:8080;
server 172.30.128.65:8081;
server 172.30.128.65:8082;
}

weight

weight指令用于指定轮询机率,weight的默认值为1,weight的数值与访问比率成正比。 接下来我们指定8082端口的服务的weight=2,如下:

1
2
3
4
5
6
upstream backend_server { 
ip_hash
server 172.30.128.65:8080;
server 172.30.128.65:8081;
server 172.30.128.65:8082 weight=2;
}

ip_hash

设定ip哈希很简单,就是在你的upstream中 指定 ip_hash;即可,如下:

1
2
3
4
5
6
upstream backend_server { 
ip_hash;
server 172.30.128.65:8080;
server 172.30.128.65:8081;
server 172.30.128.65:8083;
}

least_conn

同ip_hash一样,设定最小连接数策略也很简单,就是在你的upstream中 指定 least_conn;即可,如下:

1
2
3
4
5
6
upstream backend_server { 
least_conn;
server 172.30.128.65:8080;
server 172.30.128.65:8081;
server 172.30.128.65:8083;
}

动静分离

如果将静态资源都搞到后端服务的话,将会提高后端服务的压力且占用带宽增加了系统负载(要知道,静态资源的访问频率其实蛮高的)所以为了避免该类问题我们可以把不常修改的静态资源文件放到nginx的静态资源目录中去,这样在访问静态资源时直接读取nginx服务器本地文件目录之后返回,这样就大大减少了后端服务的压力同时也加快了静态资源的访问速度,何为静,何为动呢?:

  • 静:将不常修改且访问频繁的静态文件,放到nginx本地静态目录(当然也可以搞个静态资源服务器专门存放所有静态文件)
  • 动:将变动频繁/实时性较高的比如后端接口,实时转发到对应的后台服务

接下来我们将构造一个html页面,然后点击按钮后发送get请求到后端接口。流程如下:
图 7
首先我们搞个html,内容如下:
图 8
命令将index_page.html 文件上传到虚拟机。

1
scp /Users/hzz/fsdownload/index_page.html root@172.30.128.65:/usr/local/nginx/test/static

修改nginx.conf文件
首先我们配置俩location规则,一个( /frontend )是读取静态文件,一个(/backend)是转发到 我们配置的upstream服务中去。如下:
图 9
首先我们在浏览器输入:www.xxx.com/frontend/ ,可以看到请求返回了一个html页面,其实就是我们刚才的 /usr/local/nginx/test/static/index_page.html文件
图 10
接着我们输入要查询的人名,之后点击 “调用get接口” 按钮,如下:
图 11
返回数据:
图 12

跨域

产生跨域问题的主要原因就在于同源策略,为了保证用户信息安全,防止恶意网站窃取数据,同源策略是必须的,该政策由 Netscape 公司于1995年引入浏览器。目前,所有浏览器都实行这个政策。同源策略主要是指三点相同即:协议+域名+端口 相同的两个请求,则可以被看做是同源的,但如果其中任意一点存在不同,则代表是两个不同源的请求,同源策略会限制不同源之间的资源交互从而减少数据安全问题。

首先我在nginx.conf文件中加一个server配置也即将前后端配成不同的server 并且监听的端口以及域名名称都不一致,从而造成访问前端服务和后端服务时候 这俩服务不是”同源”, 如下:
图 13
之后我修改index_page中的后端地址:
图 14
之后在浏览器中测试一下:
图 15
可以看到浏览器提示我们受同源规则影响我们不能跨域访问资源。造成的原因是我的两个域名解析出来的端口不一致 一个是80一个是90。不符合同源策略,所以必然会有跨域报错。

nginx解决跨域

首先想解决跨越就得避免不同源,而我们可不可以 把对后端的代理 放在前端的server中呢(也就是说让前后端统一使用一个端口,一个server_name)?答案是可以的,因为server支持多个location配置呀(一个location处理前端,一个location转发后端),我们改下配置文件试一把如下:
图 16
之后重启nginx后在浏览器输入 www.xxxadminsystem.com/page/ ,效果如下:
图 17
上边/page请求返回了html页面之后我们输入参数点击“调用get接口”查看到后端接口的调用如下:
图 18

缓存

在开头我们就介绍过,nginx代理缓存可以在某些场景下有效的减少服务器压力,让请求快速响应,从而提升用户体验和服务性能,那么nginx缓存如何使用呢?在使用及演示前我们先来熟悉下相关的配置以及其含义,知道了这些才能更好的使用nginx缓存。

nginx缓存配置参数表格一览

  • proxy_cache:设置是否开启对后端响应的缓存。
    • 语法:proxy_cache zone | off;
    • 默认配置:proxy_cache off;
    • 示例:proxy_cache mycache; # 规定开启nginx缓存并且缓存名称为: mycache
    • 作用域:http, server, location
  • proxy_cache_valid:配置什么状态码可以被缓存,以及缓存时长
    • 语法:proxy_cache_valid [code …] time;
    • 示例:proxy_cache_valid 200 304 2m; # 对于状态为200和304的缓存文件,缓存时间是2分钟
    • 作用域:http, server, location
  • proxy_cache_key:设置缓存文件的 key
    • 语法:proxy_cache_key string;
    • 默认配置:proxy_cache_key:$scheme $proxy_host $request_uri;
    • 示例:proxy_cache_key “$host $request_uri $cookie_user”; # 使用host +请求的uri以及cookie拼接成缓存key
    • 作用域:http, server, location

事实上,ngx_http_proxy_module模块中代理缓存proxy_cache相关的指令远不止这些,如果有需要请参考: nginx官方文档。https://nginx.org/en/docs/http/ngx_http_proxy_module.html?_ga=2.13518455.1300709501.1700036543-1660479828.1698914648#proxy_cache

nginx缓存使用

接下来我们修改下nginx.conf文件,如下:

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
http{
...
# 指定缓存存放目录为/usr/local/nginx/test/nginx_cache_storage,并设置缓存名称为mycache,大小为64m, 1天未被访问过的缓存将自动清除,磁盘中缓存的最大容量为1gb
proxy_cache_path /usr/local/nginx/test/nginx_cache_storage levels=1:2 keys_zone=mycache:64m inactive=1d max_size=1g;
...

server{
...
# 指定 username 参数中只要有字母 就不走nginx缓存
if ($arg_username ~ [a-z]) {
set $cache_name "no cache";
}

location /interface {
proxy_pass http://mybackendserver/;
# 使用名为 mycache 的缓存空间
proxy_cache mycache;
# 对于200 206 状态码的数据缓存2分钟
proxy_cache_valid 200 206 2m;
# 定义生成缓存键的规则(请求的url+参数作为缓存key)
proxy_cache_key $host$uri$is_args$args;
# 资源至少被重复访问2次后再加入缓存
proxy_cache_min_uses 3;
# 出现重复请求时,只让其中一个去后端读数据,其他的从缓存中读取
proxy_cache_lock on;
# 上面的锁 超时时间为4s,超过4s未获取数据,其他请求直接去后端
proxy_cache_lock_timeout 4s;
# 对于请求参数中有字母的 不走nginx缓存
proxy_no_cache $cache_name; # 判断该变量是否有值,如果有值则不进行缓存,没有值则进行缓存
# 在响应头中添加一个缓存是否命中的状态(便于调试)
add_header Cache-status $upstream_cache_status;
}
...
}

ps: 在上边配置文件中除了缓存相关的配置,我们还加了一个参数:

add_header Cache-status $upstream_cache_status;
这个参数可以方便从响应头看到是否命中了nginx缓存,方便我们观察,其不同的值有不同的含义,

upstream_cache_status的值集合如下:

  • MISS:请求未命中缓存
  • HIT:请求命中缓存。
  • EXPIRED:请求命中缓存但缓存已过期。
  • STALE:请求命中了陈旧缓存。
  • REVALIDDATED:Nginx验证陈旧缓存依然有效。
  • UPDATING:命中的缓存内容陈旧,但正在更新缓存。
  • BYPASS:响应结果是从原始服务器获取的。

黑白名单

nginx黑白名单比较简单,allow后配置你的白名单,deny后配置你的黑名单,在实际使用中,我们一般都是建个黑名单和白名单的文件然后再nginx.copnf中incluld一下,这样保持主配置文件整洁,也好管理。下边我为了方便就直接在主配置写了。
图 19
可以看到ip 可以是ipv4 也可以是ipv6 也可以按照网段来配置,当然ip黑白配置可以在 http,server,location和limit_except这几个域都可以区别只是作用粒度大小问题。当然nginx建议我们使用 ngx_http_geo_module这个库,ngx_http_geo_module库支持 按地区、国家进行屏蔽,并且提供了IP库,当需要配置的名单比较多或者根据地区国家屏蔽时这个库可以帮上大忙。

nginx限流

Nginx主要有两种限流方式:按并发连接数限流(ngx_http_limit_conn_module)、按请求速率限流(ngx_http_limit_req_module 使用的令牌桶算法)。
关于 ngx_http_limit_req_module模块,里边有很多种限流指令,官网资料一览:

我们下面使用 ngx_http_limit_req_module 模块中的limit_req_zone和 limit_req 这两个指令来达到限制单个IP的请求速率 的目的。

nginx限流配置解释

在 nginx.conf 中添加限流配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
http{
...
# 对请求速率限流
limit_req_zone $binary_remote_addr zone=myRateLimit:10m rate=5r/s;

server{
location /interface{
...
limit_req zone=myRateLimit burst=5 nodelay;
limit_req_status 520;
limit_req_log_level info;
}
}
}
  • $binary_remote_addr:表示基于 remote_addr(客户端IP) 来做限流
    • zone=myRateLimit:10m:表示使用myRateLimit来作为内存区域(存储访问信息)的名字,大小为10M,1M能存储16000 IP地址的访问信息,10M可以存储16W IP地址访问信息
    • rate=5r/s:表示相同ip每秒最多请求5次,nginx是精确到毫秒的,也就是说此配置代表每200毫秒处理一个请求,这意味着自上一个请求处理完后,若后续200毫秒内又有请求到达,将拒绝处理该请求(如果没配burst的话)
    • burst=5:(英文 爆发 的意思),意思是设置一个大小为5的缓冲队列,若同时有6个请求到达,Nginx 会处理第一个请求,剩余5个请求将放入队列,然后每隔200ms从队列中获取一个请求进行处理。若请求数大于6,将拒绝处理多余的请求,直接返回503
    • nodelay:针对的是 burst 参数,burst=5 nodelay 这个配置表示被放到缓冲队列的这5个请求会立马处理,不再是每隔200ms取一个了。但是值得注意的是,即使这5个突发请求立马处理并结束,后续来了请求也不一定不会立马处理,因为虽然请求被处理了但是请求所占的坑并不会被立即释放,而是只能按 200ms 一个来释放,释放一个后 才将等待的请求 入队一个。
    • 另外两个: limit_req_status=520表示当被限流后,nginx的返回码,limit_req_log_level info代表日志级别

注意: 如果不开启nodelay且开启了burst这个配置,那么将会严重影响用户体验(你想想假设burst队列长度为100的话每100ms处理一个,那队列最后那个请求得等10000ms=10s后才能被处理,那不超时才怪呢此时burst已经意义不大了)所以一般情况下 建议burst和nodelay结合使用,从而尽可能达到速率稳定,但突然流量也能正常处理的效果。

nginx限流(针对请求速率)

限制每秒同一ip最多访问5次/1s
修改nginx.conf,把burst=5 nodelay注释掉,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
http{
...
# 对请求速率限流
limit_req_zone $binary_remote_addr zone=myRateLimit:10m rate=5r/s;

server{
location /interface{
...
limit_req zone=myRateLimit #burst=5 nodelay;
limit_req_status 520;
limit_req_log_level info;
}
}
}

上边的配置意味着每秒最多处理5次同样ip的请求

打开burst参数并设置成5。现在我们的速率不变还是最多5次一秒,但是设置burst=5代表缓冲队列的长度为5,nginx每隔200ms,从缓冲队列拿一个进行处理,配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
http{
...
# 对请求速率限流
limit_req_zone $binary_remote_addr zone=myRateLimit:10m rate=5r/s;

server{
location /interface{
...
limit_req zone=myRateLimit burst=5 #nodelay;
limit_req_status 520;
limit_req_log_level info;
}
}
}

打开nodelay

我们上边说过打开nodlay的话,代表放到burst队列的请求直接处理 ,不再按速率 200ms/次 拿了,配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
http{
...
# 对请求速率限流
limit_req_zone $binary_remote_addr zone=myRateLimit:10m rate=5r/s;

server{
location /interface{
...
limit_req zone=myRateLimit burst=5 nodelay;
limit_req_status 520;
limit_req_log_level info;
}
}
}

开启nodelay后响应时间10几秒明显比不开启nodelay快很多,但是请求成功的还是6个,因为就像我们上边说的ngdelay虽然会即时处理,但是释放坑位是200ms释放一个 (也就是说即时开启了nodelay 但释放令牌的速度是不变的) ,所以nodelay参数本质上并没有提高访问速率,而仅仅是让处于burst队列的请求 ”被快速处理“ 罢了。

nginx限流(针对连接数量)

针对连接数量的限流和速率不一样,即使你速率是1ms一次,只要你连接数量不超过设置的,那么也访问成功。如果连接数超过设置的值将会请求失败。值得注意的是他是 ngx_http_limit_conn_module模块中的,不要和 速率限流的 ngx_http_limit_req_module模块搞混了。
配置如下:

1
2
3
4
5
6
7
8
9
10
11
http{
# 针对ip 对请求连接数限流
...
limit_conn_zone $binary_remote_addr zone=myConnLimit:10m;
...

server{
...
limit_conn myConnLimit 12;
}
}

limit_conn_zone $binary_remote_addr zone=myConnLimit:10m; 代表的意思 是 基于连接数量限流,限流的对象是ip 名称是myConnLimit 存储空间大小10mb(即存放某ip的访问记录),limit_conn myConnLimit 12;标识该ip最大支持12个连接超过则返回503(被限流后状态码默认是503,当然你也可以修改返回码 像上边的 针对请求速率限流 ,返回码就是 我修改的520)。

if

该指令用于条件判断,并且根据条件判断结果来选择不同的配置,其作用于为:server/location 块。这个指令比较简单,因为编程中if语句都是非常高频使用的。因为我们很多时候,都是在对 比如:url 参数 ip 域名等等做比对或者判断(一般都使用正则的方式),而这些都在nginx全局变量中可以拿到。

if判断指令语法为

1
2
3
4
if(condition)
{
...
},

对给定的条件condition进行判断。如果为真,大括号内的rewrite等指令将被执行,if条件(conditon)可以是如下任何内容:

  1. 一个变量名,如果变量 $variable 的值为空字符串或者字符串”0”,则为false
  2. 变量与一个字符串的比较 相等为(=) 不相等为(!=) 注意此处不要把相等当做赋值语句啊
  3. 变量与一个正则表达式的模式匹配 操作符可以是(~ 区分大小写的正则匹配, ~*不区分大小写的正则匹配, !~ !~*,前面两者的非)
  4. 检测文件是否存在 使用 -f(存在) 和 !-f(不存在)
  5. 检测路径是否存在 使用 -d(存在) 和 !-d(不存在) 后面判断可以是字符串也可是变量
  6. 检测文件、路径、或者链接文件是否存在 使用 -e(存在) 和 !-e(不存在) 后面判断可以是字符串也可是变量
  7. 检测文件是否为可执行文件 使用 -x(可执行) 和 !-x(不可执行) 后面判断可以是字符串也可是变量

注意 上面 第1,2,3条被判断的必须是 变量, 4, 5, 6, 7则可以是变量也可是字符串

例如

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
set $variable "0"; 
if ($variable) {
# 不会执行,因为 "0" 为 false
break;
}

# 使用变量与正则表达式匹配 没有问题
if ( $http_host ~ "^star\.igrow\.cn$" ) {
break;
}

# 字符串与正则表达式匹配 报错
if ( "star" ~ "^star\.igrow\.cn$" ) {
break;
}
# 检查文件类的 字符串与变量均可
if ( !-f "/data.log" ) {
break;
}

if ( !-f $filename ) {
break;
}

# 指定 username 参数中只要有字母 就不走nginx缓存
if ($arg_username ~ [a-z]) {
set $cache_name "no cache";
}

#如果UA包含"MSIE",rewrite请求到/msid/目录下
if ($http_user_agent ~ MSIE) {
rewrite ^(.)$ /msie/$1break;
}

#如果cookie匹配正则,设置变量$id等于正则引用部分
if ($http_cookie ~ "id=([^;]+)(?:;|$)")
{
set$id$1;
}

#如果提交方法为POST,则返回状态405(Method not allowed)。return不能返回301,302
if ($request_method = POST) {
return405;
}

#限速,$slow可以通过 set 指令设置
if ($slow) {
limit_rate 10k;
}

#如果请求的文件名不存在,则反向代理到localhost 。这里的break也是停止rewrite检查
if (!-f $request_filename){
break;
proxy_pass http://127.0.0.1;
}

#如果querystring中包含"post=140",永久重定向到example.com
if ($args ~ post=140){
rewrite ^ http://example.com/ permanent;
}

#防盗链
location ~* .(gif|jpg|png|swf|flv)$ {
valid_referers none blocked www.jefflei.com www.leizhenfang.com;
if ($invalid_referer) {
return 404;
}
}

auto_index

我们配置nginx, 使得访问 hzznb-xzll.xyz/book/ 时,返回 /usr/local/nginx/test/book/目录下的书籍,配置如下:

1
2
3
4
5
6
7
location /book/ {
root /usr/local/nginx/test;
autoindex on; # 打开 autoindex,,可选参数有 on | off
autoindex_format html; # 以html的方式进行格式化,可选参数有 html | json | xml
autoindex_exact_size on; # 修改为off,会以KB、MB、GB显示文件大小,默认为on以bytes显示出⽂件的确切⼤⼩
autoindex_localtime off; # 显示⽂件时间 GMT格式
}

重启nginx并在浏览器输入 hzznb-xzll.xyz/book/ (注意book后边的斜线 / 不能去掉,否则404了,具体原因我们下边马上会说),看下效果:
图 20

root&alias

root和alias 一般都是用于指定静态资源目录,但是还是有区别的,虽然这是个小知识点但是如果你不清楚规则,很容易走弯路,所以这里阐明并演示这俩的区别。

root

1
2
3
location /static/ { #注意static后边的斜线 / 不能去掉,否则404了
root /usr/local/nginx/test;
}

此时,当你请求 www.hzznb-xzll.xyz/static/imag… 时,/usr/local/nginx/test/static/image2.jpg 文件将被作为响应内容响应给客户端,也就是说 :
root指令会 将 /static/ 拼接到 /usr/local/nginx/test 后边
即完整目录路径为: /usr/local/nginx/test/static/

alias

alias 中文意思别名,这个和root最大区别就是 不会进行拼接,下边我们改下nginx.conf文件来演示下:

1
2
3
location /static { # 注意一般 alias的 url都不带后边的/
alias /usr/local/nginx/test/; # 使用alias时 这里的目录最后边一定要加/ 否则就404
}

上边配置的意思就是当前你访问 www.hzznb-xzll.xyz/static/imag… 时,nginx会去alias配置的路径:/usr/local/nginx/test/目录下找 image2.jpg 这个文件从而返回。比如我现在的/usr/local/nginx/test/目录下没有image2.jpg 文件则返回404。

proxy_pass 中的斜线与root和 alias的相似之处

在我们上边的负载均衡以及动静锋利等等演示中,可以看到我们的proxy_pass的配置基本上都是这么配的:

proxy_pass http://mybackendserver/

这里有个东西和root与alias的规则很像,所以我们在这里也提一下,就是 http://mybackendserver/ 后边这个斜线 /,如果你不写 / 则会将location的url拼接到路径后边,如果你写了则不会。

upstream 中常用的几个指令

参数 描述
server 反向服务地址
weight 权重
fail_timeout 与max_fails结合使用。
max_fails 设置在fail_timeout参数设置的时间内最大失败次数,如果在这个时间内,所有针对该服务器的请求都失败了,那么认为该服务器会被认为是停机了。
max_conns 允许最大连接数
fail_time 服务器会被认为停机的时间长度,默认为10s
backup 标记该服务器为备用服务器,当主服务器停止时,请求会被发送到它这里。
down 标记服务器永久停机
slow_start 当节点恢复,不立即加入

重试策略

服务不可用重试

重试是在发生错误时的一种不可缺少的手段,这样当某一个或者某几个服务宕机时(因为我们现在大多都是多实例部署),如果有正常服务,那么将请求 重试到正常服务的机器上去。

下边我们先修改下nginx.conf文件:

1
2
3
4
5
6
7
8
9
upstream mybackendserver {
# 60秒内 如果请求8081端口这个应用失败
# 3次,则认为该应用宕机 时间到后再有请求进来继续尝试连接宕机应用且仅尝试 1 次,如果还是失败,
# 则继续等待 60 秒...以此循环,直到恢复
server 172.30.128.64:8081 fail_timeout=60s max_fails=3;

server 172.30.128.64:8082;
server 172.30.128.64:8083;
}

错误重试

可以配置哪些状态下 才会执行重试,比如如下这个配置:

1
2
# 指定哪些错误状态才执行 重试 比如下边的 error 超时,500,502,503 504
proxy_next_upstream error timeout http_500 http_502 http_503 http_504;

backup

Nginx 支持设置备用节点,当所有线上节点都异常时会启用备用节点,同时备用节点也会影响到失败
重试的逻辑。

我们可以通过 backup 指令来定义备用服务器,backup有如下特征:

  1. 正常情况下,请求不会转到 backup 服务器,包括失败重试的场景
  2. 当所有正常节点全部不可用时,backup 服务器生效,开始处理请求
  3. 一旦有正常节点恢复,就使用已经恢复的正常节点
  4. backup 服务器生效期间,不会存在所有正常节点一次性恢复的逻辑
  5. 如果全部 backup 服务器也异常,则会将所有节点一次性恢复,加入存活列表
  6. 如果全部节点(包括 backup)都异常了,则 Nginx 返回 502 错误

接着我们修改下nginx.conf文件演示下backup的作用:

1
2
3
4
5
upstream mybackendserver {
server 172.30.128.64:8081 fail_timeout=60s max_fails=3; # 60秒内 如果请求某一个应用失败3次,则认为该应用宕机 时间到后再有请求进来继续尝试连接宕机应用且仅尝试 1 次,如果还是失败,则继续等待 60 秒...以此循环,直到恢复
server 172.30.128.64:8082;
server 172.30.128.64:8083 backup; # 设置8083位备机
}

即使8081 不可用也只是去8082重试而不会到备机重试,如果8081 8082都不可用则请求重试到备机:8083。

压缩

压缩功能比较实用尤其是处理一些大文件时,而gzip 是规定的三种标准 HTTP 压缩格式之一。目前绝大多数的网站都在使用 gzip 传输 HTML 、CSS 、 JavaScript 等资源文件。需要知道的是,并不是每个浏览器都支持 gzip 压缩,如何知道客户端(浏览器)是否支持 压缩 呢? 可以通过观察 某请求头中的 Accept-Encoding 来观察是否支持压缩,另外只有客户端支持也不顶事,服务端得返回gzip格式的文件呀,那么这件事nginx可以帮我们做,我们可以通过 Nginx 的配置来让服务端支持 gzip。服务端返回压缩文件后浏览器进行解压缩从而展示正常内容。

想要压缩就得配置nginx,修改nginx.conf文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
http {
# 开启/关闭 压缩机制
gzip on;
# 根据文件类型选择 是否开启压缩机制
gzip_types text/plain application/javascript text/css application/xml text/javascript image/jpeg image/jpg image/gif image/png application/json;
# 设置压缩级别,一共9个级别 1-9 ,越高资源消耗越大 越耗时,但压缩效果越好,
gzip_comp_level 9;
# 设置是否携带Vary:Accept-Encoding 的响应头
gzip_vary on;
# 处理压缩请求的 缓冲区数量和大小
gzip_buffers 32 64k;
# 对于不支持压缩功能的客户端请求 不开启压缩机制
gzip_disable "MSIE [1-6]\."; # 比如低版本的IE浏览器不支持压缩
# 设置压缩功能所支持的HTTP最低版本
gzip_http_version 1.1;
# 设置触发压缩的最小阈值
gzip_min_length 2k;
# off/any/expired/no-cache/no-store/private/no_last_modified/no_etag/auth 根据不同配置对后端服务器的响应结果进行压缩
gzip_proxied any;
}

不管是html还是接口响应数据, 压缩后的体积变得非常小了,压缩的效果还是不错的,但是值得注意的是压缩后虽然体积变小了,但是响应的时间会变长,因为压缩/解压也需要时间呀!压缩功能似乎有点:用时间换空间的感觉!,当然压缩级别可以调的,你可以选择较低级别的压缩,这样既能实现压缩功能使得数据包体积降下来,同时压缩时间也会缩短是比较折中的一种方案。

完整nginx.conf文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251

user nginx;
worker_processes auto;

error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;


events {
worker_connections 1024;
}


http {
include /etc/nginx/mime.types;
default_type application/octet-stream;

# log_format debug '$remote_addr - $remote_user [$time_local] "$request" '
# '$status $body_bytes_sent "$http_referer" '
# '"$http_user_agent" "$http_x_forwarded_for"';
#
log_format debug ' $remote_user [$time_local] $http_x_Forwarded_for $remote_addr $request '
'$http_x_forwarded_for '
'$upstream_addr '
'ups_resp_time: $upstream_response_time '
'request_time: $request_time';

access_log /var/log/nginx/access.log debug;

sendfile on;
#tcp_nopush on;

keepalive_timeout 65;

upstream mybackendserver {
server 172.30.128.64:8081 fail_timeout=60s max_fails=3; # 60秒内 如果请求某一个应用失败3次,则认为该应用宕机 时间到后再有请求进来继续尝试连接宕机应用且仅尝试 1 次,如果还是失败,则继续等待 60 秒...以此循环,直到恢复
server 172.30.128.64:8082;
server 172.30.128.64:8083 backup; # 设置8083位备机
}

# 开启/关闭 压缩机制
gzip on;
# 根据文件类型选择 是否开启压缩机制
gzip_types text/plain application/javascript text/css application/xml text/javascript image/jpeg image/jpg image/gif image/png application/json;
# 设置压缩级别,越高资源消耗越大越耗时,但压缩效果越好
gzip_comp_level 9;
# 设置是否携带Vary:Accept-Encoding 的响应头
gzip_vary on;
# 处理压缩请求的 缓冲区数量和大小
gzip_buffers 32 64k;
# 对于不支持压缩功能的客户端请求 不开启压缩机制
gzip_disable "MSIE [1-6]\."; # 比如低版本的IE浏览器不支持压缩
# 设置压缩功能所支持的HTTP最低版本
gzip_http_version 1.1;
# 设置触发压缩的最小阈值
gzip_min_length 2k;
# off/any/expired/no-cache/no-store/private/no_last_modified/no_etag/auth 根据不同配置对后端服务器的响应结果进行压缩
gzip_proxied any;

# 指定缓存存放目录为/usr/local/nginx/test/nginx_cache_storage,并设置缓存名称为mycache,大小为64m, 1天未被访问过的缓存将自动清除,磁盘中缓存的最大容量为1gb
proxy_cache_path /usr/local/nginx/test/nginx_cache_storage levels=1:2 keys_zone=mycache:64m inactive=1d max_size=1g;
# 对请求速率限流
#limit_req_zone $binary_remote_addr zone=myRateLimit:10m rate=5r/s;
# 对请求连接数限流
limit_conn_zone $binary_remote_addr zone=myConnLimit:10m;

# --------------------HTTP 演示 配置---------------------
server {
listen 80 default;
charset utf-8;
server_name www.hzznb-xzll.xyz hzznb-xzll.xyz;

#location /static/ {
# root /usr/local/nginx/test; # /usr/local/nginx/test/static/xxx.jpg
#}

location /static { # 注意一般 alias的 url都不带后边的/
alias /usr/local/nginx/test/; # 使用alias时 这里的目录最后边一定要加/ 否则就404
}
# 测试autoindex效果
location /book/ {
root /usr/local/nginx/test;
autoindex on; # 打开 autoindex,,可选参数有 on | off
autoindex_format html; # 以html的方式进行格式化,可选参数有 html | json | xml
autoindex_exact_size on; # 修改为off,会以KB、MB、GB显示文件大小,默认为on以bytes显示出⽂件的确切⼤⼩
autoindex_localtime off; # 显示⽂件时间 GMT格式
}

# 临时重定向
location /temp_redir {
rewrite ^/(.*) https://www.baidu.com redirect;
}
# 永久重定向
location /forever_redir {

rewrite ^/(.*) https://www.baidu.com permanent;
}
# rewrite last规则测试
location /1 {
rewrite /1/(.*) /2/$1 last;
}
location /2 {
rewrite /2/(.*) /3/$1 last;
}
location /3 {
alias '/usr/local/nginx/test/static/';
index location_last_test.html;
}
}

# --------------------HTTP配置---------------------
server {
listen 80;
charset utf-8;
#server_name www.xxxadminsystem.com;
server_name www.hzznbc-xzll.xyz hzznbc-xzll.xyz;
# 重定向,会显示跳转的地址server_name,如果访问的地址没有匹配会默认使用第一个,即www.likeong.icu
return 301 https://$server_name$request_uri;

# # 指定 username 参数中只要有字母 就不走nginx缓存
# if ($arg_username ~ [a-z]) {
# set $cache_name "no cache";
# }
# # 前端页面资源
# location /page {
# alias '/usr/local/nginx/test/static/';
# index index_page.html;

# allow all;
# }
# # 后端服务
# location /interface {
# proxy_pass http://mybackendserver/;
# # 使用名为 mycache 的缓存空间
# proxy_cache mycache;
# # 对于200 206 状态码的数据缓存2分钟
# proxy_cache_valid 200 206 1m;
# # 定义生成缓存键的规则(请求的url+参数作为缓存key)
# proxy_cache_key $host$uri$is_args$args;
# # 资源至少被重复访问2次后再加入缓存
# proxy_cache_min_uses 3;
# # 出现重复请求时,只让其中一个去后端读数据,其他的从缓存中读取
# proxy_cache_lock on;
# # 上面的锁 超时时间为4s,超过4s未获取数据,其他请求直接去后端
# proxy_cache_lock_timeout 4s;
# # 对于请求参数中有字母的 不走nginx缓存
# proxy_no_cache $cache_name; # 判断该变量是否有值,如果有值则不进行缓存,没有值则进行缓存
# # 在响应头中添加一个缓存是否命中的状态(便于调试)
# add_header Cache-status $upstream_cache_status;

# limit_conn myConnLimit 12;

# limit_req zone=myRateLimit burst=5 nodelay;
# limit_req_status 520;
# limit_req_log_level info;
#}
}

# --------------------HTTPS 配置---------------------
server {
#SSL 默认访问端口号为 443
listen 443 ssl;
#填写绑定证书的域名
server_name www.hzznb-xzll.xyz hzznb-xzll.xyz;
#请填写证书文件的相对路径或绝对路径
ssl_certificate /usr/local/nginx/certificate/hzznb-xzll.xyz_bundle.crt;
#请填写私钥文件的相对路径或绝对路径
ssl_certificate_key /usr/local/nginx/certificate/hzznb-xzll.xyz.key;
#停止通信时,加密会话的有效期,在该时间段内不需要重新交换密钥
ssl_session_timeout 5m;
#服务器支持的TLS版本
ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3;
#请按照以下套件配置,配置加密套件,写法遵循 openssl 标准。
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:HIGH:!aNULL:!MD5:!RC4:!DHE;
#开启由服务器决定采用的密码套件
ssl_prefer_server_ciphers on;

# 指定 username 参数中只要有字母 就不走nginx缓存
if ($arg_username ~ [a-z]) {
set $cache_name "no cache";
}
# 前端页面资源
location /page {
alias '/usr/local/nginx/test/static/';
index index_page.html;

allow all;
}
# 后端服务
location /interface {
proxy_pass http://mybackendserver/;

# 指定哪些错误状态才执行 重试
proxy_next_upstream error timeout http_500 http_502 http_503 http_504 http_404;


# 使用名为 mycache 的缓存空间
proxy_cache mycache;
# 对于200 206 状态码的数据缓存2分钟
proxy_cache_valid 200 206 1m;
# 定义生成缓存键的规则(请求的url+参数作为缓存key)
proxy_cache_key $host$uri$is_args$args;
# 资源至少被重复访问2次后再加入缓存
proxy_cache_min_uses 3;
# 出现重复请求时,只让其中一个去后端读数据,其他的从缓存中读取
proxy_cache_lock on;
# 上面的锁 超时时间为4s,超过4s未获取数据,其他请求直接去后端
proxy_cache_lock_timeout 4s;
# 对于请求参数中有字母的 不走nginx缓存
proxy_no_cache $cache_name; # 判断该变量是否有值,如果有值则不进行缓存,没有值则进行缓存
# 在响应头中添加一个缓存是否命中的状态(便于调试)
add_header Cache-status $upstream_cache_status;

limit_conn myConnLimit 12;

# limit_req zone=myRateLimit burst=5 nodelay;
# limit_req_status 520;
# limit_req_log_level info;
}

# 后端服务
location /interface2 {
proxy_pass http://mybackendserver;
# 使用名为 mycache 的缓存空间
proxy_cache mycache;
# 对于200 206 状态码的数据缓存2分钟
proxy_cache_valid 200 206 1m;
# 定义生成缓存键的规则(请求的url+参数作为缓存key)
proxy_cache_key $host$uri$is_args$args;
# 资源至少被重复访问2次后再加入缓存
proxy_cache_min_uses 3;
# 出现重复请求时,只让其中一个去后端读数据,其他的从缓存中读取
proxy_cache_lock on;
# 上面的锁 超时时间为4s,超过4s未获取数据,其他请求直接去后端
proxy_cache_lock_timeout 4s;
# 对于请求参数中有字母的 不走nginx缓存
proxy_no_cache $cache_name; # 判断该变量是否有值,如果有值则不进行缓存,没有值则进行缓存
# 在响应头中添加一个缓存是否命中的状态(便于调试)
add_header Cache-status $upstream_cache_status;

limit_conn myConnLimit 12;

# limit_req zone=myRateLimit burst=5 nodelay;
# limit_req_status 520;
# limit_req_log_level info;
}

}

# include /etc/nginx/conf.d/*.conf;
}

参考

https://juejin.cn/post/7306041273822527514#heading-67
https://openresty.org/download/agentzh-nginx-tutorials-zhcn.html#00-Foreword02

https://openresty.org/cn/

https://juejin.cn/post/7166923395802595336?searchId=2024052312322984A6B385B1DDE00B11FC#heading-13

环视基础

环视只进行子表达式的匹配,不占有字符,匹配到的内容不保存到最终的匹配结果,是零宽度的。环视匹配的最终结果就是一个位置。

环视的作用相当于对所在位置加了一个附加条件,只有满足这个条件,环视子表达式才能匹配成功。

图 0
对于字符串”abc”,正则表达式可以匹配的元素有三个字符与四个位置,分别是字符“a”,“b”,“c”与位置0,1,2,3。正则中类似a,\d,\w,.,*之类的,都是用来匹配的字符的,而环视是用来匹配位置的。

环视按照方向划分有顺序和逆序两种,按照是否匹配有肯定和否定两种,组合起来就有四种环视。顺序环视相当于在当前位置右侧附加一个条件,而逆序环视相当于在当前位置左侧附加一个条件。

表达式 说明
(?=Expression) 顺序肯定环视,表示所在位置右侧能够匹配Expression
(?!Expression) 顺序否定环视,表示所在位置右侧不能匹配Expression
(?<=Expression) 逆序肯定环视,表示所在位置左侧能够匹配Expression
(?<!Expression) 逆序否定环视,表示所在位置左侧不能匹配Expression

所谓顺序、逆序,说的其实就是对此位置的左侧还是右侧进行约束。因为正则表达式的匹配顺序是从左至右的,所以顺序环视就是对匹配位置的右侧进行约束,逆序环视就是对匹配位置的左侧进行约束。肯定说的是需要此位置的左侧或者右侧是什么,否定说的是需要此位置的左侧或者右侧不是什么。

对于环视的叫法,有的文档里叫预搜索,有的叫什么什么断言的,这里使用了更多人容易接受的《精通正则表达式》中“环视”的叫法,其实叫什么无所谓,只要知道是什么作用就是了,就这么几个语法规则, 还是很容易记的。

顺序肯定环视

顺序肯定环视的表达式为(?=expression),要求位置右侧要符合expression,这个位置才能成功匹配整个的环视表达式。(?=b)即要求匹配位置的右侧为b。对于字符串abb,共有四个位置可供匹配。
图 1

(?=b)的简要匹配过程如下:

  1. (?=b)先去尝试匹配位置0,发现位置0的右侧是a,不是b,不符合(?=b);
  2. 字符a不是位置,不进行匹配;
  3. (?=b)尝试匹配位置1,位置1的右侧是b,符合(?=b),保存匹配结果;
  4. 字符b不是位置,不进行匹配;
  5. (?=b)尝试匹配位置2,位置2的右侧是b,符合(?=b),保存匹配结果;
  6. 字符b不是位置,不进行匹配;
  7. (?=b)尝试匹配位置3,位置3的右侧没有字符,不是b,不符合(?=b)。

最终匹配结果,只有位置1与位置2匹配成功,如下图所示。
图 3

环视单独使用作用不明显,因为它是零宽度的,不占有字符,匹配成功也不会返回字符。环视与字符配合使用,才能发挥它的最大威力。

要求匹配字符a,并且a的右侧必须是b。相应的正则表达式为a(?=b)。准备匹配的字符串为abb、ada、acb。

正则表达式a匹配,abb、ada、acb中的所有a都会被匹配到,如下图。
图 4

当正则表达式改为a(?=b),就只有一个a符合要求。
图 5

下面解释一下a(?=b)。为了方便理解,将正则表达式a(?=b)的匹配分为两步。

先用a去匹配字符串中的a,这时会有4个字符符合要求。分别是abb中的a,ada中两个a,acb中的a。表达式中(?=b)用来匹配位置,因为(?=b)在表达式a(?=b)中位于a的右侧,意味着它对匹配到的a的右侧位置提出了要求。如下图所示,有四个位置需要匹配。
图 7

图 6

图 8

(?=b)是顺序肯定环视,即需要匹配的位置的右侧是表达式b。用(?=b)去匹配位置1,位置1的右侧是字符b,符合要求,所以位置1符合要求。用(?=b)匹配位置2,位置2的右侧是字符d,所以位置2不符合要求。用(?=b)匹配位置3,位置3的右侧是换行符或是没有字符(这取决于ada是不是字符串结尾),不符合(?=b)。位置4也不符合(?=b)。最终只有abb中的a符合整体正则表达式a(?=b)。

匹配过程不是上文说的那样,上述说法只是为了方便理解。实际上,正则引擎会先用正则表达式a(?=b)中最左侧的字符a去尝试匹配到abb中的第一个a,匹配成功,再用(?=b)去匹配a右侧的位置,符合要求,所以abb中的a符合正则表达式a(?=b)的整体要求,匹配成功。然后正则引擎会用a去尝试匹配abb中的第一个b,不符合要求,舍弃,继续用a尝试匹配abb中的第二个b,仍然不符合要求。正则引擎继续用正则表达式中的a匹配ada中的第一个a,符合要求,再用(?=b)尝试匹配此a的右侧位置,此位置的右侧不是b,不符合要求。ada中的第一个a不符合正则表达式a(?=b)的整体要求,舍弃,继续向右匹配,直到所有的字符都被匹配一遍。

需要注意的是,正则表达式a(?=b)最终匹配的是abb中的a,而不是abb中的ab,时刻牢记,环视匹配的是位置,不占有字符,所以也被称为零宽断言。

顺序否定环视

顺序否定环视的表达式为(?!expression),要求匹配的位置右侧不能是expression。(?!b)即要求匹配位置的右侧不能是b。

正则表达式a匹配,abb、ada、acb中的所有a都会被匹配到,如下图。
图 10

当正则表达式改为a(?!b),则abb中的a会不符合正则表达式。
图 11

匹配过程简述如下:

  1. 正则引擎先尝试用a匹配abb中的a,符合要求;
  2. 引擎继续用(?!b)匹配a的右侧位置,要求此位置的右侧不能是b,匹配后发现位置不符合此要求,则abb中的字符a不符合整个正则表达式a(?!b);
  3. 正则引擎舍弃abb中的a,继续向右匹配;
  4. 匹配到ada中的第一个a,符合正则表达式中的a;
  5. 正则引擎继续用(?!b)匹配a的右侧位置,要求此位置的右侧不能是b,符合要求,所以ada中的第一个a符合正则表达式a(?!b)的整体要求,匹配成功;
  6. 剩下的两个a也是同样是匹配过程。

就上面的问题而言,使用a[^b]同样可以只匹配到ada、acb中的a,而不去匹配abb中的a。但是这样有两个问题,一是a[^b]不只是匹配了a一个字符,实际匹配到了a与a后面的字符;二是若是想要匹配abb、abd、ab中的a,并且排除abc中的a,上述正则表达式就会失效。这时环视就显示出优势了,通过a(?!bc)就可以区分。
图 12

所以,环视在针对表达式进行匹配时,会很方便。

逆序肯定环视

逆序肯定环视的表达式为(?<=expression),要求匹配位置的左侧必须是expression,例如(?<=b)即要求匹配位置的左侧必须是b。

正则表达式a匹配,abc、bab、dad中的所有a都会被匹配到,如下图。
图 13
当正则表达式改为(?<=b)a,则只有bab中的a符合正则表达式。
图 14

匹配过程简述如下:

  1. 表达式(?<=b)a会先用(?<=b)对字符串abc中的位置0进行匹配,也就是abc中a的左侧位置,此位置的左侧无字符,自然也不是b,不符合(?<=b);
  2. 继续使用(?<=b)匹配abc中的位置1,也就是b的左侧位置,此位置左侧为a,不符合(?<=b);
  3. 继续向左匹配,直到匹配到bab中的位置1,也就是a的左侧位置,位置1的左侧为b,符合(?<=b);
  4. 然后用正则表达式(?<=b)a中的a继续进行匹配,因为a位于(?<=b)的右侧,所以用a去匹配bab中位置1的右侧字符,匹配成功;
  5. 至此,bab中的位置1与位置1右侧的字符a对表达式(?<=b)a全部匹配成功,将匹配结果保存;
  6. 正则表达式继续向左匹配,直到全部匹配结束。

可以看到,对正则表达式进行匹配时,也是先对表达式中的左侧元素匹配,成功后才会匹配表达式中的下一个元素。

逆序否定环视

逆序否定环视的表达式为(?<!expression),要求匹配位置的左侧不是expression,例如(?<!b)即要求匹配位置的左侧不能是b。
正则表达式a匹配,abc、bab、dad中的所有a都会被匹配到,如下图。
图 15
当正则表达式改为(?<!b)a,则只有abc、dad中的a符合正则表达式。
图 16

匹配过程简述如下:

  1. 表达式(?<!b)a首先用(?<!b)去尝试匹配abc中的位置0,也就是abc中的a的左侧位置,位置0的左边无字符,自然也不是字符b,符合(?<!b);
  2. 然后用(?<!b)a中的a继续匹配,因为表达式中a在(?<!b)的右侧,所以会尝试匹配位置0的右侧字符,匹配成功;
  3. abc中的位置0与位置0右侧的字符a对整个正则表达式(?<!b)a匹配成功;
  4. 表达式(?<!b)a用(?<!b)去继续尝试匹配abc中的位置1,匹配成功;
  5. 正则中的a继续匹配位置1的右侧字符,匹配失败,
  6. abc中的位置1与位置1右侧的字符b对整个正则表达式(?<!b)a匹配失败;
  7. 正则表达式(?<!b)a继续向左匹配,直到所有的位置与字符都匹配完毕。

其他环视

既然是对位置的匹配,那么\b,^,$也可以视为环视。\b匹配单词边界或是字符串的起始或结束,要求是此位置的前后,分别是单词字符和不是单词字符,等价于(?<!\w)(?=\w)|(?<=\w)(?!\w),但是不等价于(?<=\W)(?=\w)|(?<=\w)(?=\W)。同样的,^也是匹配一个位置,要求是此位置左侧非字符,右侧任意字符,等价于(?<![\w\W])(?=[\w\W])。$匹配一个位置,要求是此位置左侧任意字符,右侧非字符,等价于(?<=[\w\W])(?![\w\W])。

\b匹配单词边界或是字符串的起始或结束,如下图所示,\b匹配了word单词前后,on单词前后,off单词前后共6个位置。

图 17

(?<!\w)(?=\w)|(?<=\w)(?!\w)匹配同样的内容,结果如下所示,匹配结果与\b一致。

图 18
(?<=\W)(?=\w)|(?<=\w)(?=\W)匹配同样的内容,结果如下所示。
图 19
可以看到,字符串内的单词间隔都可以正确匹配,问题出在字符串的起始与结束。表达式的本意是通过(?<=\W)(?=\w)匹配单词与字符串的起始位置,(?<=\w)(?=\W)匹配单词与字符串的终止位置。

但(?<=\W)(?=\w)要求匹配的位置,左侧是非单词字符,但其中隐含要求左侧需要有字符,然后右侧为单词字符。但是对于字符串起始位置,左侧是没有字符的,自然也就就无法匹配(?<=\W),所以字符串起始位置匹配失败。字符串终止位置是同样的问题。

参考链接

正则大神主页:https://blog.csdn.net/lxcnn?t=1
正则表达式入门教程:https://deerchao.cn/tutorials/regex/regex.htm#top

location 匹配的变量

Nginx 的 location 规则匹配的变量是 $uri, 所以不用管后面的参数 $query_string (或者 $args)

location 匹配的种类

匹配格式:

1
2
3
location [空格 | = | ~ | ~* | ^~ | @ ] /uri/ {
...
}

上面匹配格式分为三部分:

  1. 修饰符匹配规则
    [空格 | = | ~ | ~* | ^~ | @ ]
  2. uri匹配规则
    /uri/
  3. 大括号内的路由转发
    1
    2
    3
    {
    ...
    }

修饰符匹配规则

格式:
[空格 | ^~ | = | ~ | ~* |@ ]

接下来解释一下,这些都表示啥意思:

字符 解释
空格 无修饰符的前缀匹配,匹配前缀是 你配置的(比如说你配的是 /aaa) 的url
^~ 常规字符串匹配(跟空格类似,但是它优先级比空格高)。^表示“非”,即不查询正则表达式。如果匹配成功,并且所匹配的字符串是最长的, 则不再匹配其他location。
= 表示精确匹配,如果找到,立即停止搜索并立即处理此请求。
~ 表示执行一个正则匹配,区分大小写
~* 表示执行一个正则匹配,不区分大小写。注意,如果是运行 Nginx server 的系统本身对大小写不敏感,比如 Windows ,那么 ~* 和 ~ 这两个表现是一样的
@ 用于定义一个 Location块,且该块不能被外部Client 所访问,只能被Nginx内部配置指令所访问,比如try_files 或 error_page

修饰符的匹配顺序

  1. 优先查找精确匹配,精确匹配 (=) 的 location 如果匹配请求 URI 的话,此 location 被马上使用,匹配过程结束。
  2. 接下来进行常规字符串匹配(空格 和 ^), 如果发现匹配最长的那个是 ^ 前缀, 那么也停止搜索并且马上使用,匹配过程结束。 否则继续往下走。
  3. 如果常规字符串匹配失败,或者匹配的最长字符串不是 ^~ 前缀 (比如是空格匹配),那么继续搜索正则表达式匹配, 这时候就根据在配置文件定义的顺序,取最上面的配置(正则匹配跟匹配长度没关系,只跟位置有关系,只取顺序最上面的匹配)
  4. 如果第三步找到了,那么就用第三步的匹配,否则就用第二步的匹配(字符匹配最长的空格匹配)

简单的来说就是顺序如下:
精确匹配 > 字符串匹配( 长 > 短 [ 注: ^~ 匹配则停止匹配 ]) > 正则匹配( 上 > 下 )

换成符号的优先级就是:
[=] > [^~] > [~/~*] > [空格]

注意几个细节:

  1. 常规字符串匹配类型。是按前缀匹配(从根开始)。 而正则表达式匹配是包含匹配,只要包含就可以匹配
  2. *的优先级一样,取决于在配置文件中的位置,最上面的为主,跟匹配的字符串长度没关系,所以在写的时候,应该越精准的要放在越前面
  3. 空格匹配和^~都是字符串匹配,所以如果两个后面的匹配字符串一样,是会报错的,因为 nginx 会认为两者的匹配规则一致,所以会有冲突
  4. ^~, =, ~, ~* 这些修饰符和后面的 URI 字符串中间可以不使用空格隔开(大部分都是用空格隔开)。但是 @ 修饰符必须和 URI 字符串直接连接。

例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
location / {
return 200 '404';
}

location ~ /hello {
return 200 '1';
}

location ^~ /hello {
return 200 '2';
}

location = /hello {
return 200 '3';
}

location ~* /hello {
return 200 '4';
}

这个是测试结果

1
2
3
4
5
6
7
8
9
10
11
12
[root@VM_156_200_centos ~]# curl 127.0.0.1/hello  #精确匹配,直接结束
3
[root@VM_156_200_centos ~]# curl 127.0.0.1/hello11 #字符串匹配,并且最大长度的匹配是 ^~,直接结束
2
[root@VM_156_200_centos ~]# curl 127.0.0.1/hello/22 #字符串匹配,并且最大长度的匹配是 ~^,直接结束
2
[root@VM_156_200_centos ~]# curl 127.0.0.1/11/hello/ #字符串不匹配(前缀匹配),正则匹配有两个,取最上面的那个
1
[root@VM_156_200_centos ~]# curl 127.0.0.1/11/Hello/ #字符串不匹配(前缀匹配),正则匹配有一个(大小写不敏感),取最上面的那个
4
[root@VM_156_200_centos ~]# curl 127.0.0.1/11/Hell #都不匹配,有设置通用匹配,取通用匹配
404

配置文件配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
location /images/test.png {
return 200 '1';
}

location ^~ /images/ {
return 200 '2';
}

location ~ /images/ {
return 200 '3';
}

location ~ /images/test.png {
return 200 '4';
}

这个是测试结果

1
2
3
4
[root@VM_156_200_centos ~]#  curl http://127.0.0.1/images/test.png 
3
[root@VM_156_200_centos ~]# curl http://127.0.0.1/images/1
2

第一个返回 3 是因为本例是 普通匹配和正则匹配都存在, 并且因为 ^~ 匹配不是最长的话,那么就取 正则匹配, 正则匹配满足条件的有两个, 取最上面那个, 所以是 3, (本例的字符串最长匹配是空格匹配)。(对于本例来说,如果去掉后面的两个正则匹配,那么返回的就是 1, 因为空格匹配的字符串是最长的)
第二个返回 2 是因为普通匹配和正则匹配都存在,但是这个^~ 匹配是最长的,所以就是 2。

普通字符串的匹配冲突
如果我这样子写:

1
2
3
4
5
6
7
location /images/test.png {
return 200 '1';
}

location ^~ /images/test.png {
return 200 '2';
}

这时候我 reload 是会报错的:

1
2
root@VM_156_200_centos sbin]# ./nginx -s reload
nginx: [emerg] duplicate location "/images/test.png" in /usr/local/nginx/conf/nginx.conf:20

辟谣 ~ 比 ~* 优先级高

之前也有在网上看到这种说法,就是匹配顺序的时候,如果都匹配了,那么 ~ 比 ~* 优先级高,其实这个说法是错误的,这哥俩并没有谁比谁高贵,根据上面的匹配顺序,如果都是正则匹配,那么就是谁排在前面,就采用谁。 做个实践, 我的执行顺序是这样子的:

1
2
3
4
5
6
7
location ~* \.jpG$ {
return 200 '1';
}

location ~ \.jpg$ {
return 200 '2';
}

我的测试结果如下:

1
2
3
4
[root@VM_156_200_centos ~]# curl 127.0.0.1/1.jpg
1
[root@VM_156_200_centos ~]# curl 127.0.0.1/1.JPG
1

只要有匹配,肯定是最上面的那个 1。

辟谣之 修饰符 包含 !~ 和 !~*

我看到有些网上的文章说 nginx 的 location modifier 还包含 !~ 和 !~* 这两个,其实是不对的(也有可能是旧版本的,至少我的最新版本的 nginx 不支持), 如果你这样子:

1
2
3
location !~ \.(gif|jpg)$ {
return 200 '1';
}

那么在 reload 的时候, nginx 会报这个错误:

1
nginx: [emerg] invalid location modifier "!~" in /usr/local/nginx/conf/nginx.conf:25

不过因为 Nginx 的正则是使用PCRE(Perl Compatible Regular Expressions), 所以我们可以这样子写来达到我们想要的目的:

1
2
3
location ~ \.*(?<!(gif|jpg))$ {
return 200 '1';
}

做个实践,假设我的路由是这样子: 如果后缀含有 gif 或者是 jpg, 那么就会返回 200, 否则就会返回 404

1
2
3
4
5
6
7
location / {
return 200 '404';
}

location ~ \.(gif|jpg)$ {
return 200 '200';
}

测试结果如下:

1
2
3
4
5
6
7
8
[root@VM_156_200_centos ~]# curl 127.0.0.1/1.gif
200
[root@VM_156_200_centos ~]# curl 127.0.0.1/1.jpg
200
[root@VM_156_200_centos ~]# curl 127.0.0.1/1.js
404
[root@VM_156_200_centos ~]# curl 127.0.0.1/1.css
404

这个结果是对的。 那么就换成,如果后缀不是 gif 或者是 jpg,那么才返回 200, 否则就返回 404 (相当于上述的 !~):

1
2
3
4
5
6
location / {
return 200 '404';
}
location ~ \.*(?<!(gif|jpg))$ {
return 200 '200';
}

可以看到,同样的结果,结果相反了:

1
2
3
4
5
6
7
8
[root@VM_156_200_centos ~]# curl 127.0.0.1/1.gif
404
[root@VM_156_200_centos ~]# curl 127.0.0.1/1.jpg
404
[root@VM_156_200_centos ~]# curl 127.0.0.1/1.js
200
[root@VM_156_200_centos ~]# curl 127.0.0.1/1.css
200

所以是可以实现这种效果的。

@前缀的命名匹配

这个主要是内部定义一个 location 块,举个例子,因为 location 只验证 uri,参数是没有验证的,如果我们要验证参数,并且要根据不同的参数来进行不同的操作的话,就可以用这个内部定义块,举个例子:

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
location / {
error_page 418 = @queryone;
error_page 419 = @querytwo;
error_page 420 = @querythree;

if ( $args ~ "service=one" ) { return 418; }
if ( $args ~ "service=two" ) { return 419; }
if ( $args ~ "service=three" ) { return 420; }

# do the remaining stuff
# ex: try_files $uri =404;

}

location @queryone {
return 200 'do stuff for one';
}

location @querytwo {
return 200 'do stuff for two';
}

location @querythree {
return 200 'do stuff for three';
}

测试结果如下:

1
2
3
4
5
6
7
8
[root@VM_156_200_centos ~]#  curl http://127.0.0.1/?service=one
do stuff for one

[root@VM_156_200_centos ~]# curl http://127.0.0.1/?service=two
do stuff for two

[root@VM_156_200_centos ~]# curl http://127.0.0.1/?service=three
do stuff for three

location uri匹配规则

根据前面的符号,这里可以填写精确到 path 路径,也可以填正则表达式,Nginx 用的是 PCRE正则表达式语法,下表是在PCRE中元字符及其在正则表达式上下文中的行为的一个完整列表:

字符 描述
|将下一个字符标记为一个特殊字符、或一个原义字符、或一个向后引用、或一个八进制转义符。例如,\n 匹配 \n。\n 匹配换行符。序列 \ 匹配 \ , 而 ( 则匹配 (。即相当于多种编程语言中都有的转义字符的概念。
^ 匹配输入字符串的开始位置。如果设置了 RegExp 对象的 Multiline 属性,^ 也匹配\n 或 \r 之后的位置。
$ 匹配输入字符串的结束位置。如果设置了 RegExp 对象的 Multiline 属性,$ 也匹配\n 或 \r 之前的位置。
* 匹配前面的子表达式零次或多次。例如,zo* 能匹配 z 以及zoo。 * 等价于 {0,}。
+ 匹配前面的子表达式一次或多次。例如,zo+ 能匹配 zo 以及 zoo,但不能匹配 z。+ 等价于 {1,}。
? 匹配前面的子表达式零次或一次。例如,do(es)? 可以匹配 does 或 do。? 等价于 {0,1}。
{n} n 是一个非负整数。匹配确定的n次。例如,o{2} 不能匹配 Bob 中的 o,但是能匹配 food 中的两个o。
{n,} n 是一个非负整数。至少匹配n次。例如,o{2,} 不能匹配 Bob 中的 o,但能匹配 foooood 中的所有o。o{1,} 等价于 o+ 。o{0,} 则等价于 o*。
{n,m} m和n均为非负整数,其中n<=m。最少匹配n次且最多匹配m次。例如,o{1,3} 将匹配 fooooood 中的前三个o。o{0,1} 等价于o?。 请注意在逗号和两个数之间不能有空格。
? 当该字符紧跟在任何一个其他限制符(*,+,?,{n},{n,},{n,m})后面时,匹配模式是非贪婪的。非贪婪模式尽可能少的匹配所搜索的字符串,而默认的贪婪模式则尽可能多的匹配所搜索的字符串。例如,对于字符串oooo,o+? 将匹配单个 o,而 o+ 将匹配所有 o。
. 匹配除\n和\r之外的任何单个字符。要匹配包括\n和\r在内的任何字符,请使用像[\s\S]的模式。
(pattern) 匹配pattern并获取这一匹配。所获取的匹配可以从产生的 Matches 集合得到,在 VBScript 中使用 SMatches 集合,在JScript中则使用 0…0…0…9 属性。要匹配圆括号字符,请使用( 或 )。
(?:pattern) 匹配 pattern 但不获取匹配结果,也就是说这是一个非获取匹配,不进行存储供以后使用。这在使用或字符 “(
(?=pattern) 非获取匹配,正向肯定预查,在任何匹配 pattern 的字符串开始处匹配查找字符串。这是一个非获取匹配,也就是说,该匹配不需要获取供以后使用。例如,”Windows(?=95
(?!pattern) 非获取匹配,正向否定预查,在任何不匹配 pattern 的字符串开始处匹配查找字符串。这是一个非获取匹配,也就是说,该匹配不需要获取供以后使用。例如”Windows(?!95
(?<=pattern) 非获取匹配,反向肯定预查,与正向肯定预查类似,只是方向相反。例如,”(?<=95
(?<!pattern) 非获取匹配,反向否定预查,与正向否定预查类似,只是方向相反。例如 “(?<!95
x y
[xyz] 字符集合。匹配所包含的任意一个字符。例如,[abc] 可以匹配 plain 中的 a。
[^xyz] 否定字符集合。匹配未包含的任意字符。例如,[^abc] 可以匹配 plain 中的 p。
[a-z] 字符范围。匹配指定范围内的任意字符。例如,[a-z] 可以匹配 a 到 z 范围内的任意小写字母字符。
[^a-z] 否定字符范围。匹配任何不在指定范围内的任意字符。例如,[^a-z] 可以匹配任何不在 a 到 z 范围内的任意字符。
\b 匹配一个单词边界,也就是指单词和空格间的位置。例如,er\b 可以匹配 never 中的 er ,但不能匹配 verb 中的 er。\b1_ 可以匹配1_23中的1_,但不能匹配21_3中的1_。
\B 匹配非单词边界。er\B 能匹配 verb 中的 er ,但不能匹配never 中的 er。
\cx 匹配由x指明的控制字符。例如,\cM 匹配一个Control-M 或 回车符。x 的值必须为A-Z或a-z之一。否则,将c视为一个原义的c字符。
\d 匹配一个数字字符。等价于 [0-9]。
\D 匹配一个非数字字符。等价于[^0-9]。
\f 匹配一个换页符。等价于\x0c和\cL。
\n 匹配一个换行符。等价于\x0a和\cJ。
\r 匹配一个回车符。等价于\x0d和\cM。
\t 匹配一个制表符。等价于\x09和\cI。
\v 匹配一个垂直制表符。等价于\x0b和\cK。
\s 匹配任何空白字符,包括空格、制表符、换页符等等。等价于[\f\n\r\t\v]。
\S 匹配任何非空白字符。等价于[^\f\n\r\t\v]。
\w 匹配包括下划线的任何单词字符。类似但不等价于[A-Za-z0-9_],这里的单词字符使用Unicode字符集。
\W 匹配任何非单词字符。等价于[^A-Za-z0-9_]。
\xn 匹配n,其中n为十六进制转义值。十六进制转义值必须为确定的两个数字长。例如,\x41匹配A。\x041则等价于\x04&1。正則表达式中可以使用ASCII编码。
\num 匹配num,其中num是一个正整数。对所获取的匹配的引用。例如,(.)\1匹配两个连续的相同字符。
\n 标识一个八进制转义值或一个向后引用。如果\n之前至少 n 个获取的子表达式,则 n 为向后引用。否则,如果 n 为八进制数字(0-7),则 n 为一个八进制转义值。
\nm 标识一个八进制转义值或一个向后引用。如果\nm之前至少有 nm 个获得子表达式,则 nm 为向后引用。如果\nm 之前至少有 n 个获取,则 n 为一个后跟文字m的向后引用。如果前面的条件都不满足,若n和m均为八进制数字(0-7),则\nm将匹配八进制转义值nm。
\nml 如果n为八进制数字(0-7),且m和l均为八进制数字(0-7),则匹配八进制转义值nml。
\un 匹配n,其中n是一个用四个十六进制数字表示的Unicode字符。例如,\u00A9 匹配版权符号(©)。

例子

  1. 判断是不是 IP 白名单:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    #定义初始值
    set $my_ip 0;

    #判断是否为指定的白名单
    if ( $http_x_forwarded_for ~* "10.0.0.1|172.16.0.1" ){
    set $my_ip 1;
    }

    #不是白名单的IP进行重定向跳转
    if ( $my_ip = 0 ){
    rewrite ^/$ /40x.html;
    }
  2. 如果页面有多语言,但是这个多语言所在的文件找不到,那么就将多语言路径去掉,重新跳转

    1
    2
    3
    4
    5
    location / {
    if (!-e $request_filename) {
    rewrite ^/([A-Za-z0-9_-]+)/(.*) /$2 permanent;
    }
    }

    比如你请求是这样子的 https://foo.com/zh-cn/a.html 这时候服务端找不到这个文件,那么就会重定向到 https://foo.com/a.html。 这个很适合那种有多语言静态页面的站点

  3. 如果是一些特殊的静态文件,那么额外配置

    1
    2
    3
    location ~* \.(gif|jpg|jpeg|png|css|js|ico)$ {
    root /webroot/res/;
    }
  4. 针对国内的搜索爬虫,返回中文的页面

    1
    2
    3
    4
    5
    6
    7
    set $chinaspider "0";
    if ($http_user_agent ~* "Baiduspider|Sogou spider|Sogou web spider") {
    set $chinaspider "1";
    }
    if ($chinaspider = 1) {
    return 301 https://$server_name/zh-cn;
    }
  5. 禁止以/data开头的文件

    1
    2
    3
    location ~ ^/data {
    deny all;
    }
  6. 文件反盗链并设置过期时间

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    location ~* ^.+\.(jpg|jpeg|gif|png|swf|rar|zip|css|js)$ {
    valid_referers none blocked *.domain.com *.domain.net localhost 208.97.167.194;
    if ($invalid_referer) {
    rewrite ^/ http://error.domain.com/error.gif;
    return 412;
    break;
    }
    access_log off;
    root /opt/lampp/htdocs/web;
    expires 3d;
    break;
    }

    这里的 return 412 为自定义的 http 状态码,默认为403,方便找出正确的盗链的请求

最后附上可以用作判断的全局变量

全局变量 内容
$remote_addr 获取客户端ip
$binary_remote_addr 客户端ip(二进制)
$remote_port 客户端port,如:50472
$remote_user 已经经过Auth Basic Module验证的用户名
$host 请求主机头字段,否则为服务器名称,如:blog.sakmon.com$request用户请求信息,如:GET ?a=1&b=2 HTTP/1.1
$request_filename 当前请求的文件的路径名,由root或alias和URI request组合而成,如:/2013/81.html
$status 请求的响应状态码,如:200
$body_bytes_sent 响应时送出的body字节数数量。即使连接中断,这个数据也是精确的,如:40
$content_length 等于请求行的Content_Length的值
$content_type 等于请求行的Content_Type的值
$http_referer 引用地址
$http_user_agent 客户端agent信息,如:Mozilla/5.0 (Windows NT 5.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/29.0.1547.76 Safari/537.36
$args 与 $query_string 相同 等于当中URL的参数(GET),如a=1&b=2
$document_uri 与$uri相同, 这个变量指当前的请求URI,不包括任何参数(见$args) 如:/2013/81.html
$document_root 针对当前请求的根路径设置值
$hostname 如:centos53.localdomain
$http_cookie 客户端cookie信息
$cookie_COOKIEcookie COOKIE变量的值
$is_args 如果有$args参数,这个变量等于 ”?”,否则等于””
$limit_rate 这个变量可以限制连接速率,0 表示不限速
$query_string 与$args相同,等于当中URL的参数(GET),如a=1&b=2
$request_body 记录POST过来的数据信息
$request_body_file 客户端请求主体信息的临时文件名
$request_method 客户端请求的动作,通常为GET或POST,如:GET
$request_uri 包含请求参数的原始URI,不包含主机名,如:/2013/81.html?a=1&b=2
$scheme HTTP方法(如http,https),如:http
$uri 这个变量指当前的请求URI,不包括任何参数(见$args) 如:/2013/81.html
$request_completion 如果请求结束,设置为OK。当请求未结束或如果该请求不是请求链串的最后一个时,为空(Empty),如:OK
$server_protocol 请求使用的协议,通常是HTTP/1.0或HTTP/1.1,如:HTTP/1.1
$server_addr 服务器IP地址,在完成一次系统调用后可以确定这个值
$server_name 服务器名称,如:blog.sakmon.com
$server_port 请求到达服务器的端口号,如:80

路由转发

Rewrite语法规则

rewrite功能就是,使用nginx提供的全局变量或自己设置的变量,结合正则表达式和标志位实现url重写以及重定向。rewrite只能放在server{},location{},if{}中,并且只能对域名后边的除去传递的参数外的字符串起作用,例如http://seanlook.com/a/we/index.php?id=1&u=str 只对/a/we/index.php重写。

如果想修改域名或参数字符串,可以使用全局变量匹配,也可以使用proxy_pass反向代理。

rewrite和location功能有点像,都能实现跳转,主要区别在于rewrite是在同一域名内更改获取资源的路径,而location是对一类路径做控制访问或反向代理,可以proxy_pass到其他机器。

rewrite语法:rewrite 正则表达式 要替换的内容 [flag];

其中flag有如下几个值:

  1. last – 本条规则匹配完成后,立即发起新一轮的location 匹配规则
  2. break – 本条规则匹配完成即终止,不再匹配后面的任何规则
  3. redirect – 返回302临时重定向,浏览器地址会显示跳转新的URL地址
  4. permanent – 返回301永久重定向。浏览器地址会显示跳转新的URL地址
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# rewrite 后面没有任何 flag 时就顺序执行 
# 当 location 中没有 rewrite 模块指令可被执行时 就重写发起新一轮location匹配
location / {
# 顺序执行如下两条rewrite指令
rewrite ^/test1 /test2;
rewrite ^/test2 /test3; # 此处发起新一轮location匹配 uri为/test3
}

location = /test2 {
return 200 "/test2";
}

location = /test3 {
return 200 "/test3";
}
# 发送如下请求
# curl 127.0.0.1:8080/test1
# /test3

很多情况下rewrite也会写在location里,它们的执行顺序是:

  1. 顺序执行server块中的rewrite模块指令,得到rewrite后的请求URI
  2. 执行location匹配
  3. 执行选定的location中的rewrite指令
  4. 如果其中某步URI被重写,则重新循环执行1-3,直到找到真实存在的文件;
  5. 循环超过10次,则返回500 Internal Server Error错误。

last 和 break的区别

  1. last 和 break一样 它们都会终止此 location 中其他它rewrite模块指令的执行
  2. 但是 last 立即发起新一轮的 location 匹配 而 break 则不会
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
location / {
rewrite ^/test1 /test2;
rewrite ^/test2 /test3 last; # 此处发起新一轮location匹配 uri为/test3
rewrite ^/test3 /test4;
proxy_pass http://www.baidu.com;
}

location = /test2 {
return 200 "/test2";
}

location = /test3 {
return 200 "/test3";
}
location = /test4 {
return 200 "/test4";
}
# 发送如下请求
# curl 127.0.0.1:8080/test1
# /test3

当如果将上面的 location / 改成如下代码
location / {
rewrite ^/test1 /test2;
# 此处 不会 发起新一轮location匹配;会终止执行后续rewrite 重写后的uri为 /more/index.html
rewrite ^/test2 /more/index.html break;
rewrite /more/index\.html /test4; # 这条指令会被忽略

# 因为 proxy_pass 不是rewrite模块的指令 所以它不会被 break终止
proxy_pass https://www.baidu.com;
}
# 发送如下请求
# 浏览器输入 127.0.0.1:8080/test1
# 代理到 百度产品大全页面 https://www.baidu.com/more/index.html;

redirect 和 permanent

  1. 临时重定向:对旧网址没有影响,但新网址不会有排名
  2. 永久重定向:新网址完全继承旧网址,旧网址的排名等完全清零

修改nginx.conf文件:

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
server {
listen 80 default;
charset utf-8;
server_name www.hzznb-xzll.xyz hzznb-xzll.xyz;

# 临时(redirect)重定向配置 当访问 hzznb-xzll.xyz/temp_redir/ 这个请求会临时(302)重定向到百度页面
location /temp_redir {
rewrite ^/(.*) https://www.baidu.com redirect;
}
# 永久重定向(permanent)配置 当访问 hzznb-xzll.xyz/forever_red… 这个请求会永久(301)重定向到百度页面
location /forever_redir {

rewrite ^/(.*) https://www.baidu.com permanent;
}

# rewrite last配置 可以看到我们定义 访问 hzznb-xzll.xyz/1/ 的请求被替换为 hzznb-xzll.xyz/2/ 之后再被替换为 hzznb-xzll.xyz/3/ 最后找到/usr/local/nginx/test/static/location_last_test.html 这个文件并返回。
location /1 {
rewrite /1/(.*) /2/$1 last;
}
location /2 {
rewrite /2/(.*) /3/$1 last;
}
location /3 {
alias '/usr/local/nginx/test/static/';
index location_last_test.html;
}
}

rewrite 后的请求参数

如果替换字符串replacement包含新的请求参数,则在它们之后附加先前的请求参数。如果你不想要之前的参数,则在替换字符串 replacement 的末尾放置一个问号,避免附加它们。

1
2
# 由于最后加了个 ?,原来的请求参数将不会被追加到rewrite之后的url后面 
rewrite ^/users/(.*)$ /show?user=$1? last;

rewrite_log

开启或者关闭 rewrite模块指令执行的日志,如果开启,则重写将记录下notice 等级的日志到nginx 的 error_log 中,默认为关闭 off

Syntax: rewrite_log on | off;

return

格式:

1
2
3
return code [text];
return code URL;
return URL;

停止处理并将指定的code码返回给客户端。 非标准code码 444 关闭连接而不发送响应报头。

从0.8.42版本开始, return 语句可以指定重定向 url (状态码可以为如下几种 301,302,303,307),也可以为其他状态码指定响应的文本内容,并且重定向的url和响应的文本可以包含变量。

有一种特殊情况,就是重定向的url可以指定为此服务器本地的uri,这样的话,nginx会依据请求的协议$scheme, server_name_in_redirect 和 port_in_redirect自动生成完整的 url

此处要说明的是server_name_in_redirect和port_in_redirect 指令是表示是否将server块中的 server_name 和 listen 的端口 作为redirect用 )

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# return code [text]; 返回 ok 给客户端
location = /ok {
return 200 "ok";
}

# return code URL; 临时重定向到 百度
location = /redirect {
return 302 http://www.baidu.com;
}

# return URL; 和上面一样 默认也是临时重定向
location = /redirect {
return http://www.baidu.com;
}

break

停止执行 ngx_http_rewrite_module 的指令集,但是其他模块指令是不受影响的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
server {
listen 8080;
# 此处 break 会停止执行 server 块的 return 指令(return 指令属于rewrite模块)
# 如果把它注释掉 则所有请求进来都返回 ok
break;
return 200 "ok";
location = /testbreak {
break;
return 200 $request_uri;
proxy_pass http://127.0.0.1:8080/other;
}
location / {
return 200 $request_uri;
}
}

# 发送请求如下
# curl 127.0.0.1:8080/testbreak
# /other

# 可以看到 返回 `/other` 而不是 `/testbreak`,说明 `proxy_pass` 指令还是被执行了
# 也就是说 其他模块的指令是不会被 break 中断执行的
# (proxy_pass是ngx_http_proxy_module的指令)

一个 Vue 组件在使用前需要先被“注册”,这样 Vue 才能在渲染模板时找到其对应的实现。组件注册有两种方式:全局注册和局部注册。

全局注册

我们可以使用 Vue 应用实例的 .component() 方法,让组件在当前 Vue 应用中全局可用。

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
import { createApp } from 'vue'

const app = createApp({})

app.component(
// 注册的名字
'MyComponent',
// 组件的实现
{
/* ... */
}
)

//---//

//如果使用单文件组件,你可以注册被导入的 .vue 文件:

import MyComponent from './App.vue'

app.component('MyComponent', MyComponent)

//.component() 方法可以被链式调用:
app
.component('ComponentA', ComponentA)
.component('ComponentB', ComponentB)
.component('ComponentC', ComponentC)

//全局注册的组件可以在此应用的任意组件的模板中使用:
//<!-- 这在当前应用的任意组件中都可用 -->
<ComponentA/>
<ComponentB/>
<ComponentC/>

所有的子组件也可以使用全局注册的组件,这意味着这三个组件也都可以在彼此内部使用。

局部注册

全局注册虽然很方便,但有以下几个问题:

  • 全局注册,但并没有被使用的组件无法在生产打包时被自动移除 (也叫“tree-shaking”)。如果你全局注册了一个组件,即使它并没有被实际使用,它仍然会出现在打包后的 JS 文件中。

  • 全局注册在大型项目中使项目的依赖关系变得不那么明确。在父组件中使用子组件时,不太容易定位子组件的实现。和使用过多的全局变量一样,这可能会影响应用长期的可维护性。

相比之下,局部注册的组件需要在使用它的父组件中显式导入,并且只能在该父组件中使用。它的优点是使组件之间的依赖关系更加明确,并且对 tree-shaking 更加友好。

在使用

RestTemplate 概述

Spring 框架提供的 RestTemplate 类可用于在应用中调用 rest 服务,它简化了与 http 服务的通信方式,统一了 RESTful 的标准,封装了 http 链接, 我们只需要传入 url 及返回值类型即可。相较于之前常用的 HttpClient,RestTemplate 是一种更优雅的调用 RESTful 服务的方式。

RestTemplate 默认依赖 JDK 提供 http 连接的能力(HttpURLConnection),如果有需要的话也可以通过 setRequestFactory 方法替换为 Apache HttpComponents、Netty 或 OkHttp 等其它 HTTP library。

考虑到 RestTemplate 类是为调用 REST 服务而设计的,因此它的主要方法与 REST 的基础紧密相连就不足为奇了,后者是 HTTP 协议的方法:HEAD、GET、POST、PUT、DELETE 和 OPTIONS。例如,RestTemplate 类具有 headForHeaders()、getForObject()、postForObject()、put()和 delete()等方法。

发送 Get 请求

接口代码

1
2
3
4
5
@GetMapping("/test/get")
@ResponseBody
public BookDto get() {
return new BookDto(1, "SpringMVC系列");
}

使用 RestTemplate 调用上面这个接口,通常有 2 种写法,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Test
public void test1() {
RestTemplate restTemplate = new RestTemplate();
String url = "http://localhost:8080/chat16/test/get";
//getForObject方法,获取响应体,将其转换为第二个参数指定的类型
BookDto bookDto = restTemplate.getForObject(url, BookDto.class);
System.out.println(bookDto);
}

@Test
public void test2() {
RestTemplate restTemplate = new RestTemplate();
String url = "http://localhost:8080/chat16/test/get";
//getForEntity方法,返回值为ResponseEntity类型
// ResponseEntity中包含了响应结果中的所有信息,比如头、状态、body
ResponseEntity<BookDto> responseEntity = restTemplate.getForEntity(url, BookDto.class);
//状态码
System.out.println(responseEntity.getStatusCode());
//获取头
System.out.println("头:" + responseEntity.getHeaders());
//获取body
BookDto bookDto = responseEntity.getBody();
System.out.println(bookDto);
}

url 中含有动态参数

1
2
3
4
5
@GetMapping("/test/get/{id}/{name}")
@ResponseBody
public BookDto get(@PathVariable("id") Integer id, @PathVariable("name") String name) {
return new BookDto(id, name);
}

使用 RestTemplate 调用上面这个接口,通常有 2 种写法,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Test
public void test3() {
RestTemplate restTemplate = new RestTemplate();
//url中有动态参数
String url = "http://localhost:8080/chat16/test/get/{id}/{name}";
Map<String, String> uriVariables = new HashMap<>();
uriVariables.put("id", "1");
uriVariables.put("name", "SpringMVC系列");
//使用getForObject或者getForEntity方法
BookDto bookDto = restTemplate.getForObject(url, BookDto.class, uriVariables);
System.out.println(bookDto);
}

@Test
public void test4() {
RestTemplate restTemplate = new RestTemplate();
//url中有动态参数
String url = "http://localhost:8080/chat16/test/get/{id}/{name}";
Map<String, String> uriVariables = new HashMap<>();
uriVariables.put("id", "1");
uriVariables.put("name", "SpringMVC系列");
//getForEntity方法
ResponseEntity<BookDto> responseEntity = restTemplate.getForEntity(url, BookDto.class, uriVariables);
BookDto bookDto = responseEntity.getBody();
System.out.println(bookDto);
}

接口返回值为泛型

1
2
3
4
5
6
7
8
@GetMapping("/test/getList")
@ResponseBody
public List<BookDto> getList() {
return Arrays.asList(
new BookDto(1, "Spring高手系列"),
new BookDto(2, "SpringMVC系列")
);
}

当接口的返回值为泛型的时候,这种情况比较特殊,使用 RestTemplate 调用上面这个接口,代码如下,需要用到restTemplate.exchange的方法,这个方法中有个参数是ParameterizedTypeReference类型,通过这个参数类指定泛型类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Test
public void test5() {
RestTemplate restTemplate = new RestTemplate();
//返回值为泛型
String url = "http://localhost:8080/chat16/test/getList";
//若返回结果是泛型类型的,需要使用到exchange方法,
//这个方法中有个参数是ParameterizedTypeReference类型,通过这个参数类指定泛型类型
ResponseEntity<List<BookDto>> responseEntity =
restTemplate.exchange(url,
HttpMethod.GET,
null,
new ParameterizedTypeReference<List<BookDto>>() {
});
List<BookDto> bookDtoList = responseEntity.getBody();
System.out.println(bookDtoList);
}

下载小文件

1
2
3
4
5
6
7
8
9
10
11
12
@GetMapping("/test/downFile")
@ResponseBody
public HttpEntity<InputStreamResource> downFile() {
//将文件流封装为InputStreamResource对象
InputStream inputStream = this.getClass().getResourceAsStream("/1.txt");
InputStreamResource inputStreamResource = new InputStreamResource(inputStream);
//设置header
MultiValueMap<String, String> headers = new HttpHeaders();
headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment;filename=1.txt");
HttpEntity<InputStreamResource> httpEntity = new HttpEntity<>(inputStreamResource);
return httpEntity;
}

使用 RestTemplate 调用这个接口,代码如下,目前这个文件的内容比较少,可以直接得到一个数组。

1
2
3
4
5
6
7
8
9
10
11
@Test
public void test6() {
RestTemplate restTemplate = new RestTemplate();
String url = "http://localhost:8080/chat16/test/downFile";
//文件比较小的情况,直接返回字节数组
ResponseEntity<byte[]> responseEntity = restTemplate.getForEntity(url, byte[].class);
//获取文件的内容
byte[] body = responseEntity.getBody();
String content = new String(body);
System.out.println(content);
}

下载大文件

1
2
3
4
5
6
7
8
9
10
11
12
@GetMapping("/test/downFile")
@ResponseBody
public HttpEntity<InputStreamResource> downFile() {
//将文件流封装为InputStreamResource对象
InputStream inputStream = this.getClass().getResourceAsStream("/1.txt");
InputStreamResource inputStreamResource = new InputStreamResource(inputStream);
//设置header
MultiValueMap<String, String> headers = new HttpHeaders();
headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment;filename=1.txt");
HttpEntity<InputStreamResource> httpEntity = new HttpEntity<>(inputStreamResource);
return httpEntity;
}

此时使用 RestTemplate 调用这个接口,代码如下

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
@Test
public void test7() {
RestTemplate restTemplate = new RestTemplate();
String url = "http://localhost:8080/chat16/test/downFile";
/**
* 文件比较大的时候,比如好几个G,就不能返回字节数组了,会把内存撑爆,导致OOM
* 需要这么玩:
* 需要使用execute方法了,这个方法中有个ResponseExtractor类型的参数,
* restTemplate拿到结果之后,会回调{@link ResponseExtractor#extractData}这个方法,
* 在这个方法中可以拿到响应流,然后进行处理,这个过程就是变读边处理,不会导致内存溢出
*/
String result = restTemplate.execute(url,
HttpMethod.GET,
null,
new ResponseExtractor<String>() {
@Override
public String extractData(ClientHttpResponse response) throws IOException {
System.out.println("状态:"+response.getStatusCode());
System.out.println("头:"+response.getHeaders());
//获取响应体流
InputStream body = response.getBody();
//处理响应体流
String content = IOUtils.toString(body, "UTF-8");
return content;
}
}, new HashMap<>());

System.out.println(result);
}

传递头

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@GetMapping("/test/header")
@ResponseBody
public Map<String, List<String>> header(HttpServletRequest request) {
Map<String, List<String>> header = new LinkedHashMap<>();
Enumeration<String> headerNames = request.getHeaderNames();
while (headerNames.hasMoreElements()) {
String name = headerNames.nextElement();
Enumeration<String> values = request.getHeaders(name);
List<String> list = new ArrayList<>();
while (values.hasMoreElements()) {
list.add(values.nextElement());
}
header.put(name, list);
}
return header;
}

使用 RestTemplate 调用接口,请求头中传递数据,代码如下,注意代码①和②,这两处是关键,用到了HttpHeaders和RequestEntity

  • 请求头放在 HttpHeaders 对象中
  • RequestEntity:请求实体,请求的所有信息都可以放在 RequestEntity 中,比如 body 部分、头、请求方式、url 等信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Test
public void test8() {
RestTemplate restTemplate = new RestTemplate();
String url = "http://localhost:8080/chat16/test/header";
//①:请求头放在HttpHeaders对象中
MultiValueMap<String, String> headers = new HttpHeaders();
headers.add("header-1", "V1");
headers.add("header-2", "Spring");
headers.add("header-2", "SpringBoot");
//②:RequestEntity:请求实体,请求的所有信息都可以放在RequestEntity中,比如body部分、头、请求方式、url等信息
RequestEntity requestEntity = new RequestEntity(
null, //body部分数据
headers, //头
HttpMethod.GET,//请求方法
URI.create(url) //地址
);
ResponseEntity<Map<String, List<String>>> responseEntity = restTemplate.exchange(requestEntity,
new ParameterizedTypeReference<Map<String, List<String>>>() {
});
Map<String, List<String>> result = responseEntity.getBody();
System.out.println(result);
}

综合案例:含头、url 动态参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@GetMapping("/test/getAll/{path1}/{path2}")
@ResponseBody
public Map<String, Object> getAll(@PathVariable("path1") String path1,
@PathVariable("path2") String path2,
HttpServletRequest request) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("path1", path1);
result.put("path2", path2);
//头
Map<String, List<String>> header = new LinkedHashMap<>();
Enumeration<String> headerNames = request.getHeaderNames();
while (headerNames.hasMoreElements()) {
String name = headerNames.nextElement();
Enumeration<String> values = request.getHeaders(name);
List<String> list = new ArrayList<>();
while (values.hasMoreElements()) {
list.add(values.nextElement());
}
header.put(name, list);
}
result.put("header", header);
return result;
}

如下,使用 RestTemplate 调用接口,GET 方式、传递 header、path 中动态参数。

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
@Test
public void test9() {
RestTemplate restTemplate = new RestTemplate();
String url = "http://localhost:8080/chat16/test/getAll/{path1}/{path2}";
//①:请求头
MultiValueMap<String, String> headers = new HttpHeaders();
headers.add("header-1", "V1");
headers.add("header-2", "Spring");
headers.add("header-2", "SpringBoot");
//②:url中的2个参数
Map<String, String> uriVariables = new HashMap<>();
uriVariables.put("path1", "v1");
uriVariables.put("path2", "v2");
//③:HttpEntity:HTTP实体,内部包含了请求头和请求体
HttpEntity requestEntity = new HttpEntity(
null,//body部分,get请求没有body,所以为null
headers //头
);
//④:使用exchange发送请求
ResponseEntity<Map<String, Object>> responseEntity = restTemplate.exchange(
url, //url
HttpMethod.GET, //请求方式
requestEntity, //请求实体(头、body)
new ParameterizedTypeReference<Map<String, Object>>() {
},//返回的结果类型
uriVariables //url中的占位符对应的值
);
Map<String, Object> result = responseEntity.getBody();
System.out.println(result);
}

POST 请求

http 请求头中的 Content-Type 用来指定请求的类型,常见的有 3 种

Content-Type 说明
application/x-www-form-urlencoded 页面中普通的 form 表单提交时就是这种类型,表单中的元素会按照名称和值拼接好,然后之间用&连接,格式如:p1=v1&p2=v2&p3=v3然后通过 urlencoded 编码之后丢在 body 中发送
multipart/form-data 页面中表单上传文件的时候,用到的就是这种格式
application/json 将发送的数据转换为 json 格式,丢在 http 请求的 body 中发送,后端接口通常用@RequestBody 配合对象来接收。

普通表单请求

普通表单默认为 application/x-www-form-urlencoded 类型的请求。

1
2
3
4
5
@PostMapping("/test/form1")
@ResponseBody
public BookDto form1(BookDto bookDto) {
return bookDto;
}

使用 RestTemplate 调用接口

1
2
3
4
5
6
7
8
9
10
11
12
13
@Test
public void test10() {
RestTemplate restTemplate = new RestTemplate();
String url = "http://localhost:8080/chat16/test/form1";
//①:表单信息,需要放在MultiValueMap中,MultiValueMap相当于Map<String,List<String>>
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
//调用add方法填充表单数据(表单名称:值)
body.add("id","1");
body.add("name","SpringMVC系列");
//②:发送请求(url,请求体,返回值需要转换的类型)
BookDto result = restTemplate.postForObject(url, body, BookDto.class);
System.out.println(result);
}

如果想携带头信息,代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Test
public void test11() {
RestTemplate restTemplate = new RestTemplate();
String url = "http://localhost:8080/chat16/test/form1";
//①:表单信息,需要放在MultiValueMap中,MultiValueMap相当于Map<String,List<String>>
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
//调用add方法放入表单元素(表单名称:值)
body.add("id","1");
body.add("name","SpringMVC系列");
//②:请求头
HttpHeaders headers = new HttpHeaders();
//调用set方法放入请求头
headers.set(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE);
//③:请求实体:包含了请求体和请求头
HttpEntity<MultiValueMap<String, String>> httpEntity = new HttpEntity<>(body, headers);
//④:发送请求(url,请求实体,返回值需要转换的类型)
BookDto result = restTemplate.postForObject(url, httpEntity, BookDto.class);
System.out.println(result);
}

上传本地文件

上传文件 Content-Type 为 multipart/form-data 类型。

接口如下,上传上传单个文件,返回值为一个 Map 类型,是泛型类型

1
2
3
4
5
6
7
8
9
@PostMapping(value = "/test/form2")
@ResponseBody
public Map<String, String> form2(@RequestParam("file1") MultipartFile file1) {
Map<String, String> fileMetadata = new LinkedHashMap<>();
fileMetadata.put("文件名", file1.getOriginalFilename());
fileMetadata.put("文件类型", file1.getContentType());
fileMetadata.put("文件大小(byte)", String.valueOf(file1.getSize()));
return fileMetadata;
}

使用 RestTemplate 调用接口,主要下面代码②上传的文件需要包装为org.springframework.core.io.Resource,常用的有 3 中[FileSystemResource、InputStreamResource、ByteArrayResource],这里案例中我们用到的是 FileSystemResource 来上传本地文件,另外 2 种(InputStreamResource、ByteArrayResource)用法就比较特殊了,见下个案例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Test
public void test12() {
RestTemplate restTemplate = new RestTemplate();
String url = "http://localhost:8080/chat16/test/form2";
//①:表单信息,需要放在MultiValueMap中,MultiValueMap相当于Map<String,List<String>>
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
//调用add方法放入表单元素(表单名称:值)
//②:文件对应的类型,需要是org.springframework.core.io.Resource类型的,常见的有[FileSystemResource、InputStreamResource、ByteArrayResource]
body.add("file1", new FileSystemResource(".\\src\\main\\java\\com\\javacode2018\\springmvc\\chat16\\dto\\UserDto.java"));
//③:头
HttpHeaders headers = new HttpHeaders();
headers.add("header1", "v1");
headers.add("header2", "v2");
//④:请求实体
RequestEntity<MultiValueMap<String, Object>> requestEntity = new RequestEntity<>(body, headers, HttpMethod.POST, URI.create(url));
//⑤:发送请求(请求实体,返回值需要转换的类型)
ResponseEntity<Map<String, String>> responseEntity = restTemplate.exchange(
requestEntity,
new ParameterizedTypeReference<Map<String, String>>() {
});
Map<String, String> result = responseEntity.getBody();
System.out.println(result);
}

通过流或字节数组的方式上传文件

有时候,上传的文件是通过流的方式或者字节数组的方式,那么就需要用到 InputStreamResource、ByteArrayResource 这俩了。
注意:使用这俩的时候,需要重写 2 个方法,否则会上传失败

  • getFilename:文件名称
  • contentLength:长度
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
@Test
public void test13() {
RestTemplate restTemplate = new RestTemplate();
String url = "http://localhost:8080/chat16/test/form2";
//①:表单信息,需要放在MultiValueMap中,MultiValueMap相当于Map<String,List<String>>
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
/**
* ②:通过流的方式上传文件,流的方式需要用到InputStreamResource类,需要重写2个方法
* getFilename:文件名称
* contentLength:长度
*/
InputStream inputStream = RestTemplateTest.class.getResourceAsStream("/1.txt");
InputStreamResource inputStreamResource = new InputStreamResource(inputStream) {
@Override
public String getFilename() {
return "1.txt";
}

@Override
public long contentLength() throws IOException {
return inputStream.available();
}
};
body.add("file1", inputStreamResource);
//③:头
HttpHeaders headers = new HttpHeaders();
headers.add("header1", "v1");
headers.add("header2", "v2");
//④:请求实体
RequestEntity<MultiValueMap<String, Object>> requestEntity = new RequestEntity<>(body, headers, HttpMethod.POST, URI.create(url));
//⑤:发送请求(请求实体,返回值需要转换的类型)
ResponseEntity<Map<String, String>> responseEntity = restTemplate.exchange(
requestEntity,
new ParameterizedTypeReference<Map<String, String>>() {
});
Map<String, String> result = responseEntity.getBody();
System.out.println(result);
}

复杂表单:多个普通元素+多文件上传

1
2
3
4
5
6
7
8
9
10
@PostMapping("/test/form3")
@ResponseBody
public Map<String, String> form3(UserDto userDto) {
Map<String, String> result = new LinkedHashMap<>();
result.put("name", userDto.getName());
result.put("headImg", userDto.getHeadImg().getOriginalFilename());
result.put("idImgList", Arrays.toString(userDto.getIdImgList().stream().
map(MultipartFile::getOriginalFilename).toArray()));
return result;
}

UserDto:包含了多个元素(姓名、头像、多张证件照),这种可以模拟复杂的表单

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
public class UserDto {
//姓名
private String name;
//头像
private MultipartFile headImg;
//多张证件照
private List<MultipartFile> idImgList;

//get set 省略了...
}

@Test
public void test14() {
RestTemplate restTemplate = new RestTemplate();
String url = "http://localhost:8080/chat16/test/form3";
//①:表单信息,需要放在MultiValueMap中,MultiValueMap相当于Map<String,List<String>>
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("name", "路人");
body.add("headImg", new FileSystemResource(".\\src\\main\\resources\\1.jpg"));
//来2张证件照,元素名称一样
body.add("idImgList", new FileSystemResource(".\\src\\main\\resources\\2.jpg"));
body.add("idImgList", new FileSystemResource(".\\src\\main\\resources\\3.jpg"));
//③:头
HttpHeaders headers = new HttpHeaders();
headers.add("header1", "v1");
headers.add("header2", "v2");
//④:请求实体
RequestEntity<MultiValueMap<String, Object>> requestEntity = new RequestEntity<>(body, headers, HttpMethod.POST, URI.create(url));
//⑤:发送请求(请求实体,返回值需要转换的类型)
ResponseEntity<Map<String, String>> responseEntity = restTemplate.exchange(
requestEntity,
new ParameterizedTypeReference<Map<String, String>>() {
});
Map<String, String> result = responseEntity.getBody();
System.out.println(result);
}

发送 json 格式数据:传递 java 对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@PostMapping("/test/form4")
@ResponseBody
public BookDto form4(@RequestBody BookDto bookDto) {
return bookDto;
}

@Test
public void test15() {
RestTemplate restTemplate = new RestTemplate();
String url = "http://localhost:8080/chat16/test/form4";
BookDto body = new BookDto(1, "SpringMVC系列");
BookDto result = restTemplate.postForObject(url, body, BookDto.class);
System.out.println(result);
}

发送 json 格式数据:传递 java 对象,返回值为泛型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@PostMapping("/test/form5")
@ResponseBody
public List<BookDto> form5(@RequestBody List<BookDto> bookDtoList) {
return bookDtoList;
}

@Test
public void test16() {
RestTemplate restTemplate = new RestTemplate();
String url = "http://localhost:8080/chat16/test/form5";
//①:请求体,发送的时候会被转换为json格式数据
List<BookDto> body = Arrays.asList(
new BookDto(1, "SpringMVC系列"),
new BookDto(2, "MySQL系列"));
//②:头
HttpHeaders headers = new HttpHeaders();
headers.add("header1", "v1");
headers.add("header2", "v2");
//③:请求实体
RequestEntity requestEntity = new RequestEntity(body, headers, HttpMethod.POST, URI.create(url));
//④:发送请求(请求实体,返回值需要转换的类型)
ResponseEntity<List<BookDto>> responseEntity = restTemplate.exchange(
requestEntity,
new ParameterizedTypeReference<List<BookDto>>() {
});
//⑤:获取结果
List<BookDto> result = responseEntity.getBody();
System.out.println(result);
}

发送 json 字符串格式数据

上面 2 个 json 案例 body 都是 java 对象,RestTemplate 默认自动配上 Content-Type=application/json

但是如果 body 的值是 json 格式字符串的时候,调用的时候需要在头中明确指定 Content-Type=application/json,写法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Test
public void test17() {
RestTemplate restTemplate = new RestTemplate();
String url = "http://localhost:8080/chat16/test/form5";
//①:请求体为一个json格式的字符串
String body = "[{\"id\":1,\"name\":\"SpringMVC系列\"},{\"id\":2,\"name\":\"MySQL系列\"}]";
/**
* ②:若请求体为json字符串的时候,需要在头中设置Content-Type=application/json;
* 若body是普通的java类的时候,无需指定这个,RestTemplate默认自动配上Content-Type=application/json
*/
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
//③:请求实体(body,头、请求方式,uri)
RequestEntity requestEntity = new RequestEntity(body, headers, HttpMethod.POST, URI.create(url));
//④:发送请求(请求实体,返回值需要转换的类型)
ResponseEntity<List<BookDto>> responseEntity = restTemplate.exchange(
requestEntity,
new ParameterizedTypeReference<List<BookDto>>() {
});
//⑤:获取结果
List<BookDto> result = responseEntity.getBody();
System.out.println(result);
}

DELETE、PUT、OPTION 请求

DELETE 请求

1
2
3
public void delete(String url, Object... uriVariables);
public void delete(String url, Map<String, ?> uriVariables);
public void delete(URI url);

PUT 请求

PUT 请求和 POST 请求类似,将类型改为 PUT 就可以了。

OPTIONS 请求

OPTIONS 请求用来探测接口支持哪些 http 方法

1
2
3
public Set<HttpMethod> optionsForAllow(String url, Object... uriVariables);
public Set<HttpMethod> optionsForAllow(String url, Map<String, ?> uriVariables);
public Set<HttpMethod> optionsForAllow(URI url);

集成 HttpClient

RestTemplate 内部默认用的是 jdk 自带的 HttpURLConnection 发送请求的,性能上面并不是太突出。
可以将其替换为 httpclient 或者 okhttp。
先来看下如何替换为 HttpClient。

1
2
3
4
5
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.5.7</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
public HttpClient httpClient() {
HttpClientBuilder httpClientBuilder = HttpClientBuilder.create();
try {
//设置信任ssl访问
SSLContext sslContext = new SSLContextBuilder().loadTrustMaterial(null, (arg0, arg1) -> true).build();
httpClientBuilder.setSSLContext(sslContext);
HostnameVerifier hostnameVerifier = NoopHostnameVerifier.INSTANCE;
SSLConnectionSocketFactory sslConnectionSocketFactory = new SSLConnectionSocketFactory(sslContext, hostnameVerifier);
Registry<ConnectionSocketFactory> socketFactoryRegistry = RegistryBuilder.<ConnectionSocketFactory>create()
// 注册http和https请求
.register("http", PlainConnectionSocketFactory.getSocketFactory())
.register("https", sslConnectionSocketFactory).build();

//使用Httpclient连接池的方式配置(推荐),同时支持netty,okHttp以及其他http框架
PoolingHttpClientConnectionManager poolingHttpClientConnectionManager = new PoolingHttpClientConnectionManager(socketFactoryRegistry);
// 最大连接数
poolingHttpClientConnectionManager.setMaxTotal(1000);
// 同路由并发数
poolingHttpClientConnectionManager.setDefaultMaxPerRoute(100);
//配置连接池
httpClientBuilder.setConnectionManager(poolingHttpClientConnectionManager);
// 重试次数
httpClientBuilder.setRetryHandler(new DefaultHttpRequestRetryHandler(0, true));
//设置默认请求头
List<Header> headers = new ArrayList<>();
httpClientBuilder.setDefaultHeaders(headers);
return httpClientBuilder.build();
} catch (Exception e) {
throw new RuntimeException(e);
}
}

public ClientHttpRequestFactory clientHttpRequestFactory() {
HttpComponentsClientHttpRequestFactory clientHttpRequestFactory = new HttpComponentsClientHttpRequestFactory(httpClient());
// 连接超时(毫秒),这里设置10秒
clientHttpRequestFactory.setConnectTimeout(10 * 1000);
// 数据读取超时时间(毫秒),这里设置60秒
clientHttpRequestFactory.setReadTimeout(60 * 1000);
// 从连接池获取请求连接的超时时间(毫秒),不宜过长,必须设置,比如连接不够用时,时间过长将是灾难性的
clientHttpRequestFactory.setConnectionRequestTimeout(10 * 1000);
return clientHttpRequestFactory;
}

public RestTemplate restTemplate(){
//创建RestTemplate的时候,指定ClientHttpRequestFactory
return new RestTemplate(this.clientHttpRequestFactory());
}

@Test
public void test18() {
RestTemplate restTemplate = this.restTemplate();
String url = "http://localhost:8080/chat16/test/get";
//getForObject方法,获取响应体,将其转换为第二个参数指定的类型
BookDto bookDto = restTemplate.getForObject(url, BookDto.class);
System.out.println(bookDto);
}

集成 okhttp

1
2
3
4
5
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.3.1</version>
</dependency>

创建 RestTemplate

1
new RestTemplate(new OkHttp3ClientHttpRequestFactory());

Spring Security支持方法级别的权限控制。我们可以在任意层的任意方法上加入权限注解,加入注解的方法将自动被Spring Security保护起来,仅仅允许特定的用户访问,从而还到权限控制的目的,当然如果现有的权限注解不满足我们也可以自定义。

Spring Security默认是禁用注解的,要想开启注解,要在继承WebSecurityConfigurerAdapter的类加@EnableMethodSecurity注解,并在该类中将AuthenticationManager定义为Bean。

1
2
3
4
5
6
7
8
9
10
11
12
13
@EnableWebSecurity
@Configuration
@EnableGlobalMethodSecurity(
prePostEnabled = true,
securedEnabled = true,
jsr250Enabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}

我们看到@EnableGlobalMethodSecurity 分别有prePostEnabled 、securedEnabled、jsr250Enabled三个字段,其中每个字段代码一种注解支持,默认为false,true为开启。那么我们就一一来说一下这三总注解支持。

  • prePostEnabled = true 的作用的是启用Spring Security的@PreAuthorize 以及@PostAuthorize 注解。

  • securedEnabled = true 的作用是启用Spring Security的@Secured 注解。

  • jsr250Enabled = true 的作用是启用@RoleAllowed 注解

在方法上设置权限认证

@RolesAllowed

1
2
3
4
5
6
7
8
9
@RolesAllowed("ROLE_VIEWER")
public String getUsername2() {
//...
}

@RolesAllowed({ "USER", "ADMIN" })
public boolean isValidUsername2(String username) {
//...
}

代表标注的方法只要具有USER, ADMIN任意一种权限就可以访问。这里可以省略前缀ROLE_,实际的权限可能是ROLE_ADMIN

在功能及使用方法上与 @Secured 完全相同

securedEnabled注解

1
2
3
4
5
6
7
8
9
10
@Secured("ROLE_VIEWER")
public String getUsername() {
SecurityContext securityContext = SecurityContextHolder.getContext();
return securityContext.getAuthentication().getName();
}

@Secured({ "ROLE_DBA", "ROLE_ADMIN" })
public String getUsername2() {
//...
}

@Secured 规定了访问访方法的角色列表,在列表中最少指定一种角色。@Secured在方法上指定安全性,要求 角色/权限等 只有对应 角色/权限 的用户才可以调用这些方法。

还有一点就是@Secured,不支持Spring EL表达式

prePostEnabled注解

这个开启后支持Spring EL表达式。如果没有访问方法的权限,会抛出AccessDeniedException。

@PreAuthorize注解:
进入方法之前验证授权。

1
2
3
4
@PreAuthorize("hasRole('ROLE_VIEWER')")
public String getUsernameInUpperCase() {
return getUsername().toUpperCase();
}

@PreAuthorize(“hasRole(‘ROLE_VIEWER’)”) 相当于@Secured(“ROLE_VIEWER”)。

同样的@Secured({“ROLE_VIEWER”,”ROLE_EDITOR”}) 也可以替换为:@PreAuthorize(“hasRole(‘ROLE_VIEWER’) or hasRole(‘ROLE_EDITOR’)”)。

在方法执行之前执行,这里可以调用方法的参数,也可以得到参数值,这里利用JAVA8的参数名反射特性,如果没有JAVA8,那么也可以利用Spring Secuirty的@P标注参数,或利用Spring Data的@Param标注参数。

1
2
3
4
5
6
7
//无java8
@PreAuthorize("#userId == authentication.principal.userId or hasAuthority(‘ADMIN’)")
void changePassword(@P("userId") long userId ){}
//有java8
@PreAuthorize("#userId == authentication.principal.userId or hasAuthority(‘ADMIN’)")
void changePassword(long userId ){}

这里表示在changePassword方法执行之前,判断方法参数userId的值是否等于principal中保存的当前用户的userId,或者当前用户是否具有ROLE_ADMIN权限,两种符合其一,就可以访问该 方法。

@PostAuthorize注解:
在方法执行之后执行,以获取到方法的返回值,并且可以根据该方法来决定最终的授权结果(是允许访问还是不允许访问)

1
2
3
4
5
@PostAuthorize
("returnObject.username == authentication.principal.nickName")
public CustomUser loadUserDetail(String username) {
return userRoleRepository.loadUserByUserName(username);
}

上述代码中,仅当loadUserDetail方法的返回值中的username与当前登录用户的username相同时才被允许访问

注意如果EL为false,那么该方法也已经执行完了,可能会回滚。EL变量returnObject表示返回的对象。

@PreFilter注解:
在方法执行之前执行,而且这里可以调用方法的参数,然后对参数值进行过滤或处理或修改。

1
2
3
4
@PreFilter("filterObject != authentication.principal.username")
public String joinUsernames(List<String> usernames) {
return usernames.stream().collect(Collectors.joining(";"));
}

当usernames中的子项与当前登录用户的用户名不同时,则保留;当usernames中的子项与当前登录用户的用户名相同时,则移除。比如当前使用用户的用户名为zhangsan,此时usernames的值为{“zhangsan”, “lisi”, “wangwu”},则经@PreFilter过滤后,实际传入的usernames的值为{“lisi”, “wangwu”}

如果执行方法中包含有多个类型为Collection的参数,filterObject 就不太清楚是对哪个Collection参数进行过滤了。此时,便需要加入 filterTarget 属性来指定具体的参数名称:

1
2
3
4
5
6
7
@PreFilter(value = "filterObject != authentication.principal.username",
filterTarget = "usernames")
public String joinUsernamesAndRoles(List<String> usernames, List<String> roles) {

return usernames.stream().collect(Collectors.joining(";"))
+ ":" + roles.stream().collect(Collectors.joining(";"));
}

@PostFilter:
在方法执行之后执行,而且这里可以调用方法的返回值,然后对返回值进行过滤或处理或修改并返回

1
2
3
4
@PostFilter("filterObject != authentication.principal.username")
public List<String> getAllUsernamesExceptCurrent() {
return userRoleRepository.getAllUsernames();
}

此时 filterObject 代表返回值。如果按照上述代码则实现了:移除掉返回值中与当前登录用户的用户名相同的子项。

自定义元注解

如果我们需要在多个方法中使用相同的安全注解,则可以通过创建元注解的方式来提升项目的可维护性。

比如创建以下元注解:

1
2
3
4
5
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasRole('ROLE_VIEWER')")
public @interface IsViewer {
}

然后可以直接将该注解添加到对应的方法上:

1
2
3
4
@IsViewer
public String getUsername4() {
//...
}

在生产项目中,由于元注解分离了业务逻辑与安全框架,所以使用元注解是一个非常不错的选择。

类上使用安全注解

如果一个类中的所有的方法我们全部都是应用的同一个安全注解,那么此时则应该把安全注解提升到类的级别上:

1
2
3
4
5
6
7
8
9
10
11
12
@Service
@PreAuthorize("hasRole('ROLE_ADMIN')")
public class SystemService {

public String getSystemYear(){
//...
}

public String getSystemDate(){
//...
}
}

上述代码实现了:访问getSystemYear 以及getSystemDate 方法均需要ROLE_ADMIN权限。

总结

默认情况下,在方法中使用安全注解是由Spring AOP代理实现的,这意味着:如果我们在方法1中去调用同类中的使用安全注解的方法2,则方法2上的安全注解将失效。
Spring Security上下文是线程绑定的,这意味着:安全上下文将不会传递给子线程。

1
2
3
4
5
public boolean isValidUsername4(String username) {
// 以下的方法将会跳过安全认证
this.getUsername();
return true;
}

微服务中使用Spring Security + OAuth 2.0 + JWT 搭建认证授权服务

OAuth 是一种用来规范令牌(Token)发放的授权机制,主要包含了四种授权模式:

  1. 授权码模式
  2. 简化模式
  3. 密码模式
  4. 客户端模式

引入依赖

spring-cloud-starter-oauth2 已经包含了 spring-cloud-starter-security、spring-security-oauth2、spring-security-jwt 这3个依赖,只需引入 spring-cloud-starter-oauth2 即可。

准备工作

  1. 新建 UserDTO 类,实现 org.springframework.security.core.userdetails.UserDetails 接口

nec-common:Account

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
@Data
public class UserDTO implements Serializable, UserDetails {
private static final long serialVersionUID = 5538522337801286424L;

private String userName;
private String password;
private Set<SimpleGrantedAuthority> authorities;

public Collection<? extends GrantedAuthority> getAuthorities() {
return this.authorities;
}

public String getPassword() {
return this.password;
}

public String getUsername() {
return this.userName;
}

public boolean isAccountNonExpired() {
return true;
}

public boolean isAccountNonLocked() {
return true;
}

public boolean isCredentialsNonExpired() {
return true;
}

public boolean isEnabled() {
return true;
}
}
  1. 新建类 UserDetailsServiceImpl,实现 org.springframework.security.core.userdetails.UserDetailsService 接口,用于校验用户凭据。

nec-auth:NecUserDetailService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

private PasswordEncoder passwordEncoder;

@Autowired
public void setPasswordEncoder(PasswordEncoder passwordEncoder) {
this.passwordEncoder = passwordEncoder;
}

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// TODO 实际开发中,这里请修改从数据库中查询...
UserDTO user = new UserDTO();
user.setUserName(username);
// 密码为 123456 ,且加密
user.setPassword(passwordEncoder.encode("123456"));
return user;
}
}

配置认证授权服务器

  1. 新建类 Oauth2ServerConfig,继承 org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter 类;在 Oauth2ServerConfig 类上 添加注解 @EnableAuthorizationServer 。

nec-auth:AuthorizationServer

框架提供了几个默认的端点:

  • /oauth/authorize:授权端点
  • /oauth/token:获取令牌端点
  • /oauth/confirm_access:用户确认授权端点
  • /oauth/check_token:校验令牌端点
  • /oauth/error:用于在授权服务器中呈现错误
  • /oauth/token_key:获取 jwt 公钥端点
  1. 继承 AuthorizationServerConfigurerAdapter 类后,我们需要重写以下三个方法扩展实现我们的需求。
  • configure(ClientDetailsServiceConfigurer clients) :用于定义、初始化客户端信息
  • configure(AuthorizationServerEndpointsConfigurer endpoints):用于定义授权令牌端点及服务
  • configure(AuthorizationServerSecurityConfigurer security):用于定义令牌端点的安全约束

配置客户端详细信息 ClientDetailsServiceConfigurer

用于定义 内存 中或 基于JDBC存储实现 的客户端,其重要的几个属性有:

  1. clientId:客户端id,必填;
  2. clientSecret:客户端密钥;
  3. authorizedGrantTypes:客户端授权类型,有 5 种模式: authorization_code、password、client_credentials、implicit、refresh_token;
  4. scope:授权范围;
  5. accessTokenValiditySeconds:access_token 有效时间,单位为秒,默认为 12 小时;
  6. refreshTokenValiditySeconds:refresh_token 有效时间,单位为秒,默认为 30 天;

客户端信息一般保存在 Redis 或 数据库中

  1. 使用以下 SQL(适用于MySQL) 来建表:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    CREATE TABLE `oauth_client_details`  (
    `client_id` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
    `resource_ids` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
    `client_secret` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
    `scope` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
    `authorized_grant_types` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
    `web_server_redirect_uri` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
    `authorities` varchar(256) CHARACTER SET utf8 COLLATE utf8_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 utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
    `autoapprove` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
    PRIMARY KEY (`client_id`) USING BTREE
    ) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
  2. 添加一条客户端信息用于测试:

    1
    2
    INSERT INTO `oauth_client_details` VALUES ('auth-server', NULL, '$2a$10$mcEwJ8qqhk2DYIle6VfhEOZHRdDbCSizAQbIwBR7tTuv9Q7Fca9Gi', 'all', 'password,refresh_token', '', NULL, NULL, NULL, NULL, NULL);

    其中密码 123456 使用 BCryptPasswordEncoder 加密,加密后字符为 $2a$10$mcEwJ8qqhk2DYIle6VfhEOZHRdDbCSizAQbIwBR7tTuv9Q7Fca9Gi。

  3. 配置 ClientDetailsServiceConfigurer ,指定客户端信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Configuration
@EnableAuthorizationServer
public class Oauth2ServerConfig extends AuthorizationServerConfigurerAdapter {

private final DataSource dataSource;

private final PasswordEncoder passwordEncoder;

@Autowired
public Oauth2ServerConfig(DataSource dataSource, PasswordEncoder passwordEncoder) {
this.dataSource = dataSource;
this.passwordEncoder = passwordEncoder;
}

@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
// 使用基于 JDBC 存储模式
JdbcClientDetailsService clientDetailsService = new JdbcClientDetailsService(dataSource);
// client_secret 加密
clientDetailsService.setPasswordEncoder(passwordEncoder);
clients.withClientDetails(clientDetailsService);
}
}

配置授权令牌端点及服务 AuthorizationServerEndpointsConfigurer

需要指定 AuthenticationManager 及 UserDetailService,尤其是使用密码模式时,必须指定 AuthenticationManager,否则会报 Unsupported grant type: password 错误。

新建 WebSecurityConfig 类,继承 org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter 类,重写 authenticationManagerBean() 方法,并定义需要用到的 PasswordEncoder;

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
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

@Override
protected void configure(HttpSecurity http) throws Exception {
http
// 支持跨域请求
.cors()

.and()
// 禁用 CSRF
.csrf().disable()

.formLogin().disable()
.httpBasic().disable()
.logout().disable()

.authorizeRequests()
.antMatchers("/oauth/token").permitAll();

.anyRequest().authenticated();
}

/**
* 重写 authenticationManagerBean()
* @return
* @throws Exception
*/
@Override
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
  1. 配置 AuthorizationServerEndpointsConfigurer:
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
@Configuration
@EnableAuthorizationServer
public class Oauth2ServerConfig extends AuthorizationServerConfigurerAdapter {

private final UserDetailsServiceImpl userDetailsService;

/**
* 密码模式 grant_type:password 需指定 AuthenticationManager
*/
private final AuthenticationManager authenticationManager;


@Autowired
public Oauth2ServerConfig(UserDetailsServiceImpl userDetailsService,
AuthenticationManager authenticationManager) {
this.userDetailsService = userDetailsService;
this.authenticationManager = authenticationManager;
}

@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints
// 开启密码模式授权
.authenticationManager(authenticationManager)
.userDetailsService(userDetailsService);
}
}

使用 JWT 作为令牌格式
生成 JWT 密钥对
使用 JDK 的 keytool 工具生成 JKS 密钥对 jwt.jks,并将 jwt.jks 放到 resources 目录下。

定位至 JDK 目录下的 bin 目录,执行以下命令生成密钥对,记住口令密钥,代码中需要用到密钥来读取密钥对,以下命令以 123456 为例:

keytool -genkey -alias weihong -keyalg RSA -keypass 123456 -keystore jwt.jks -storepass 123456

1
2
3
4
5
6
7
8
9
10
11
-genkey 生成密钥

-alias 别名

-keyalg 密钥算法

-keypass 密钥口令

-keystore 生成密钥对的存储路径和名称

-storepass 密钥对口令

定义 token 转换器
在 Oauth2ServerConfig 类中定义 accessTokenConverter() 及 keyPair():
指定令牌存储策略为 JWT
配置 AuthorizationServerEndpointsConfigurer 的令牌存储策略为 JWT,指定 accessTokenConverter 为我们定义好的 accessTokenConverter():

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
@Configuration
@EnableAuthorizationServer
public class Oauth2ServerConfig extends AuthorizationServerConfigurerAdapter {

private final UserDetailsServiceImpl userDetailsService;

/**
* 密码模式 grant_type:password 需指定 AuthenticationManager
*/
private final AuthenticationManager authenticationManager;

@Autowired
public Oauth2ServerConfig(UserDetailsServiceImpl userDetailsService,
AuthenticationManager authenticationManager) {
this.userDetailsService = userDetailsService;
this.authenticationManager = authenticationManager;
}


@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
endpoints
// 开启密码模式授权
.authenticationManager(authenticationManager)
.userDetailsService(userDetailsService)
// 指定令牌存储策略
.accessTokenConverter(accessTokenConverter());
}

/**
* token 转换器
* 默认是 uuid 格式,我们在这里指定 token 格式为 jwt
* @return
*/
@Bean
public JwtAccessTokenConverter accessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
// 使用非对称加密算法对 token 签名
converter.setKeyPair(keyPair());
return converter;
}

@Bean
public KeyPair keyPair() {
// 从 classpath 目录下的证书 jwt.jks 中获取秘钥对
KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(new ClassPathResource("jwt.jks"), "123456".toCharArray());
return keyStoreKeyFactory.getKeyPair("weihong", "123456".toCharArray());
}

}

扩展 JWT 存储内容

有时候我们需要扩展 JWT 存储的内容,比如存储一些用户数据、权限信息等。我们可以定义 TokenEnhancer 或继承 TokenEnhancer 来实现 JWT 内容增强器:

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
@Configuration
@EnableAuthorizationServer
public class Oauth2ServerConfig extends AuthorizationServerConfigurerAdapter {

private final UserDetailsServiceImpl userDetailsService;

private final AuthenticationManager authenticationManager;

@Autowired
public Oauth2ServerConfig(UserDetailsServiceImpl userDetailsService,
AuthenticationManager authenticationManager) {
this.userDetailsService = userDetailsService;
this.authenticationManager = authenticationManager;
}

@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
TokenEnhancerChain enhancerChain = new TokenEnhancerChain();
List<TokenEnhancer> delegates = new ArrayList<>();

delegates.add(tokenEnhancer());
delegates.add(accessTokenConverter());

// 配置 JWT 内容增强
enhancerChain.setTokenEnhancers(delegates);

endpoints
// 开启密码模式授权
.authenticationManager(authenticationManager)
.userDetailsService(userDetailsService)
.accessTokenConverter(accessTokenConverter())
.tokenEnhancer(enhancerChain);
}

/**
* token 转换器
* 默认是 uuid 格式,我们在这里指定 token 格式为 jwt
* @return
*/
@Bean
public JwtAccessTokenConverter accessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
// 使用非对称加密算法对 token 签名
converter.setKeyPair(keyPair());
return converter;
}

@Bean
public KeyPair keyPair() {
KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(new ClassPathResource("jwt.jks"), "123456".toCharArray());
return keyStoreKeyFactory.getKeyPair("weihong", "123456".toCharArray());
}

/**
* JWT 内容增强器,用于扩展 JWT 内容,可以保存用户数据
* @return
*/
@Bean
public TokenEnhancer tokenEnhancer() {
return (oAuth2AccessToken, oAuth2Authentication) -> {
Map<String, Object> map = new HashMap<>(1);
UserDTO userDTO = (UserDTO) oAuth2Authentication.getPrincipal();
map.put("userName", userDTO.getUsername());
// TODO 其他信息可以自行添加
((DefaultOAuth2AccessToken) oAuth2AccessToken).setAdditionalInformation(map);
return oAuth2AccessToken;
};
}
}

使用 Redis 存储 token

添加 token 保存至 redis 的配置:

1
2
3
4
5
6
7
8
9
10
11
@Configuration
public class RedisTokenStoreConfig {

@Resource
private RedisConnectionFactory connectionFactory;

@Bean
public TokenStore redisTokenStore() {
return new RedisTokenStore(connectionFactory);
}
}

在认证服务配置中指定 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
30
31
32
@Configuration
@EnableAuthorizationServer
public class Oauth2ServerConfig extends AuthorizationServerConfigurerAdapter {

private final UserDetailsServiceImpl userDetailsService;

/**
* 密码模式 grant_type:password 需指定 AuthenticationManager
*/
private final AuthenticationManager authenticationManager;

private final TokenStore tokenStore;

@Autowired
public Oauth2ServerConfig(UserDetailsServiceImpl userDetailsService,
AuthenticationManager authenticationManager,
@Qualifier("redisTokenStore") TokenStore tokenStore) {
this.userDetailsService = userDetailsService;
this.authenticationManager = authenticationManager;
this.tokenStore = tokenStore;
}

@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints
// 开启密码模式授权
.authenticationManager(authenticationManager)
.userDetailsService(userDetailsService)
// 设置 token 存储方式
.tokenStore(tokenStore);
}
}

配置授权令牌安全约束 AuthorizationServerSecurityConfigurer

1
2
3
4
5
6
7
8
9
10
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
security
// 允许表单认证
.allowFormAuthenticationForClients()
// 开放 /oauth/token_key 获取 token 加密公钥
.tokenKeyAccess("permitAll()")
// 开放 /oauth/check_token
.checkTokenAccess("permitAll()");
}

@AllArgsConstructor

生成包含所有字段的构造器,标记为的字段@NonNull将对这些参数进行空检查。

参数

  1. staticName : 不为空的话,生成一个静态方法返回实例,并把构造器设置为private
1
2
3
4
5
6
@AllArgsConstructor(staticName = "create")
public class Example {

private int foo;
private final String bar;
}

生成:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Example {
private int foo;
private final String bar;

private Example(int foo, String bar) {
this.foo = foo;
this.bar = bar;
}

public static Example create(int foo, String bar) {
return new Example(foo, bar);
}
}
  1. access : 这个选项可以用来改变生成的构造方法的访问级别,默认public

@RequiredArgsConstructor

生成必须初始化字段的构造器,比如带final、@NonNull,对于标有@NonNull注解的字段,还将生成一个显式的null检查。

1
2
3
4
5
6
7
@RequiredArgsConstructor
public class Example {

@NonNull
private Integer foo;
private final String bar;
}

生成后:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Example {
@NonNull
private Integer foo;
private final String bar;

public Example(@NonNull Integer foo, String bar) {
if (foo == null) {
throw new NullPointerException("foo is marked @NonNull but is null");
} else {
this.foo = foo;
this.bar = bar;
}
}
}

@NoArgsConstructor

生成无参数构造器

参数

  1. access:访问权限修饰符
  2. force:为true时,当有 final 字段没有被初始化时,这个选项可以强制 Lombok 生成一个无参数的构造方法,并将所有 final 字段初始化为其默认值(0、false、null等)
  3. onConstructor:添加注解,参考@Getter#onMethod

@NoArgsConstructor、@AllArgsConstructor和@RequiredArgsConstructor区别

  1. @NoArgsConstructor:这个注解用于生成一个无参数的构造函数。当你在一个类上使用这个注解时,Lombok会自动为该类生成一个无参数的构造函数。这个注解在Spring Boot中常常被用在不需要参数就能创建对象的地方,例如单例模式或者作为其他构造函数的依赖注入。
  2. @AllArgsConstructor:这个注解用于生成一个包含所有参数的构造函数。当你在一个类上使用这个注解时,Lombok会自动为该类的所有字段生成一个带有参数的构造函数。这个注解在Spring Boot中常常被用在需要使用所有字段来创建对象的地方,例如DTO(Data Transfer Object)对象的创建。
  3. @RequiredArgsConstructor:这个注解用于自动生成带有final修饰符的成员变量的构造函数。当一个类中存在多个final修饰符的成员变量时,使用这个注解可以避免手动编写重复的构造函数代码。这个注解在Spring Boot中常常被用在需要将final字段绑定到具体实现的地方,例如Spring的Bean配置。

总结一下,@NoArgsConstructor、@AllArgsConstructor和@RequiredArgsConstructor这三个注解在Spring Boot项目中的常见应用场景包括:
在单例模式中,使用@NoArgsConstructor来创建一个无参数的构造函数,以便在不需要任何参数的情况下创建对象。
在DTO对象的创建中,使用@AllArgsConstructor来创建一个包含所有字段的构造函数,以便将所有字段的值传递给对象。
在Spring的Bean配置中,使用@RequiredArgsConstructor来自动生成包含final字段的构造函数的代码,以便将final字段绑定到具体实现。

@Getter

生成getter、写在类上会生成该类下所有字段的getter。写在某个字段上就作用与该字段

参数

  1. onMethod:把需要添加的注解写在这
    1
    2
    3
    4
    5
    6
    public class Example {

    @Getter(onMethod_={@Deprecated}) // JDK7写法 @Getter(onMethod=@__({@Deprecated}))
    private int foo;
    private final String bar = "";
    }

生成:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Example {
private int foo;
private final String bar = "";

public Example() {
}

/** @deprecated */
@Deprecated
public int getFoo() {
return this.foo;
}
}
  1. value:访问权限修饰符

@Setter

生成Setter

  1. onMethod:在方法上添加中注解,见@Getter#onMethod
  2. onParam:在方法的参数上添加注解,见@Getter#onMethod
  3. value:访问权限修饰符

@NonNull

空检查

1
2
3
4
5
6
7
public class Example {

@NonNull
@Getter
@Setter
private Integer foo;
}

生成后:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Example {
@NonNull
private Integer foo;

public Example() {
}

@NonNull
public Integer getFoo() {
return this.foo;
}

public void setFoo(@NonNull Integer foo) {
if (foo == null) {
throw new NullPointerException("foo is marked @NonNull but is null");
} else {
this.foo = foo;
}
}
}

RocketMQ主要由 Producer、Broker、Consumer 三部分组成,其中Producer 负责生产消息,Consumer 负责消费消息,Broker 负责存储消息。Broker 在实际部署过程中对应一台服务器,每个 Broker 可以存储多个Topic的消息,每个Topic的消息也可以分片存储于不同的 Broker。Message Queue 用于存储消息的物理地址,每个Topic中的消息地址存储于多个 Message Queue 中。ConsumerGroup 由多个Consumer 实例构成。

RocketMQ 网络部署特点

图 0

RocketMQ架构上主要分为四部分

  1. NameServer:是一个几乎无状态节点,可集群部署,节点之间无任何信息同步。是一个非常简单的Topic路由注册中心,其角色类似Dubbo中的zookeeper,支持Broker的动态注册与发现。
    主要包括两个功能:
    • Broker管理,NameServer接受Broker集群的注册信息并且保存下来作为路由信息的基本数据。然后提供心跳检测机制,检查Broker是否还存活;
    • 路由信息管理,每个NameServer将保存关于Broker集群的整个路由信息和用于客户端查询的队列信息。然后Producer和Conumser通过NameServer就可以知道整个Broker集群的路由信息,从而进行消息的投递和消费。

NameServer通常也是集群的方式部署,各实例间相互不进行信息通讯。Broker是向每一台NameServer注册自己的路由信息,所以每一个NameServer实例上面都保存一份完整的路由信息。当某个NameServer因某种原因下线了,Broker仍然可以向其它NameServer同步其路由信息,Producer,Consumer仍然可以动态感知Broker的路由的信息。

  1. Broker部署相对复杂,Broker分为Master与Slave,一个Master可以对应多个Slave,但是一个Slave只能对应一个Master,Master与Slave 的对应关系通过指定相同的BrokerName,不同的BrokerId 来定义,BrokerId为0表示Master,非0表示Slave。Master也可以部署多个。每个Broker与NameServer集群中的所有节点建立长连接,定时注册Topic信息到所有NameServer。

    注意:当前RocketMQ版本在部署架构上支持一Master多Slave,但只有BrokerId=1的从服务器才会参与消息的读负载。

  2. Producer与NameServer集群中的其中一个节点(随机选择)建立长连接,定期从NameServer获取Topic路由信息,并向提供Topic 服务的Master建立长连接,且定时向Master发送心跳。Producer完全无状态,可集群部署。

  3. Consumer与NameServer集群中的其中一个节点(随机选择)建立长连接,定期从NameServer获取Topic路由信息,并向提供Topic服务的Master、Slave建立长连接,且定时向Master、Slave发送心跳。Consumer既可以从Master订阅消息,也可以从Slave订阅消息,消费者在向Master拉取消息时,Master服务器会根据拉取偏移量与最大偏移量的距离(判断是否读老消息,产生读I/O),以及从服务器是否可读等因素建议下一次是从Master还是Slave拉取。

结合部署架构图,描述集群工作流程

  1. 启动NameServer,NameServer起来后监听端口,等待Broker、Producer、Consumer连上来,相当于一个路由控制中心。
  2. Broker启动,跟所有的NameServer保持长连接,定时发送心跳包。心跳包中包含当前Broker信息(IP+端口等)以及存储所有Topic信息。注册成功后,NameServer集群中就有Topic跟Broker的映射关系。
  3. Producer发送消息,启动时先跟NameServer集群中的其中一台建立长连接,并从NameServer中获取当前发送的Topic存在哪些Broker上,轮询从队列列表中选择一个队列,然后与队列所在的Broker建立长连接从而向Broker发消息
  4. Consumer跟Producer类似,跟其中一台NameServer建立长连接,获取当前订阅Topic存在哪些Broker上,然后直接跟Broker建立连接通道,开始消费消息。
  5. 收发消息前,先创建Topic,创建Topic时需要指定该Topic要存储在哪些Broker上,也可以在发送消息时自动创建Topic。

Broker包含了以下几个重要子模块

图 1

  1. Remoting Module:整个Broker的实体,负责处理来自clients端的请求。
  2. Client Manager:负责管理客户端(Producer/Consumer)和维护Consumer的Topic订阅信息
  3. Store Service:提供方便简单的API接口处理消息存储到物理硬盘和查询功能。
  4. HA Service:高可用服务,提供Master Broker 和 Slave Broker之间的数据同步功能。
  5. Index Service:根据特定的Message key对投递到Broker的消息进行索引服务,以提供消息的快速查询。

集群部署

单Master模式

这种方式风险较大,一旦Broker重启或者宕机时,会导致整个服务不可用。不建议线上环境使用,可以用于本地测试。

  1. 启动 NameServer

    1
    2
    3

    ### 首先启动Name Server
    $ nohup sh mqnamesrv
  2. 启动 Broker

    1
    2
    $ nohup sh bin/mqbroker -n localhost:9876 
    ### 验证Name Server 是否启动成功,例如Broker的IP为:192.168.1.2,且名称为broker-a

    多Master模式

    一个集群无Slave,全是Master,例如2个Master或者3个Master,这种模式的优缺点如下:

  • 优点:配置简单,单个Master宕机或重启维护对应用无影响,在磁盘配置为RAID10时,即使机器宕机不可恢复情况下,由于RAID10磁盘非常可靠,消息也不会丢(异步刷盘丢失少量消息,同步刷盘一条不丢),性能最高;
  • 缺点:单台机器宕机期间,这台机器上未被消费的消息在机器恢复之前不可订阅,消息实时性会受到影响。
  1. 启动NameServer

NameServer需要先于Broker启动,且如果在生产环境使用,为了保证高可用,建议一般规模的集群启动3个NameServer,各节点的启动命令相同,如下:

1
2
### 首先启动Name Server
$ nohup sh mqnamesrv
  1. 启动Broker集群
1
2
3
4
5
### 在机器A,启动第一个Master,例如NameServer的IP为:192.168.1.1
$ nohup sh mqbroker -n 192.168.1.1:9876 -c $ROCKETMQ_HOME/conf/2m-noslave/broker-a.properties

### 在机器B,启动第二个Master,例如NameServer的IP为:192.168.1.1
$ nohup sh mqbroker -n 192.168.1.1:9876 -c $ROCKETMQ_HOME/conf/2m-noslave/broker-b.properties

如上启动命令是在单个NameServer情况下使用的。对于多个NameServer的集群,Broker启动命令中-n后面的地址列表用分号隔开即可,例如 192.168.1.1:9876;192.161.2:9876。

多Master多Slave模式-异步复制

每个Master配置一个Slave,有多对Master-Slave,HA采用异步复制方式,主备有短暂消息延迟(毫秒级),这种模式的优缺点如下:

优点:即使磁盘损坏,消息丢失的非常少,且消息实时性不会受影响,同时Master宕机后,消费者仍然可以从Slave消费,而且此过程对应用透明,不需要人工干预,性能同多Master模式几乎一样;
缺点:Master宕机,磁盘损坏情况下会丢失少量消息。

  1. 启动NameServer
    1
    2
    3
    4
    5
    6
    ### 首先启动Name Server
    $ nohup sh mqnamesrv &

    ### 验证Name Server 是否启动成功
    $ tail -f ~/logs/rocketmqlogs/namesrv.log
    The Name Server boot success...
  2. 启动Broker集群
1
2
3
4
5
6
7
8
9
10
11
### 在机器A,启动第一个Master,例如NameServer的IP为:192.168.1.1
$ nohup sh mqbroker -n 192.168.1.1:9876 -c $ROCKETMQ_HOME/conf/2m-2s-async/broker-a.properties &

### 在机器B,启动第二个Master,例如NameServer的IP为:192.168.1.1
$ nohup sh mqbroker -n 192.168.1.1:9876 -c $ROCKETMQ_HOME/conf/2m-2s-async/broker-b.properties &

### 在机器C,启动第一个Slave,例如NameServer的IP为:192.168.1.1
$ nohup sh mqbroker -n 192.168.1.1:9876 -c $ROCKETMQ_HOME/conf/2m-2s-async/broker-a-s.properties &

### 在机器D,启动第二个Slave,例如NameServer的IP为:192.168.1.1
$ nohup sh mqbroker -n 192.168.1.1:9876 -c $ROCKETMQ_HOME/conf/2m-2s-async/broker-b-s.properties

多Master多Slave模式-同步双写

每个Master配置一个Slave,有多对Master-Slave,HA采用同步双写方式,即只有主备都写成功,才向应用返回成功,这种模式的优缺点如下:

优点:数据与服务都无单点故障,Master宕机情况下,消息无延迟,服务可用性与数据可用性都非常高;
缺点:性能比异步复制模式略低(大约低10%左右),发送单个消息的RT会略高,且目前版本在主节点宕机后,备机不能自动切换为主机。

  1. 启动NameServer
1
2
3
4
5
6
### 首先启动Name Server
$ nohup sh mqnamesrv &

### 验证Name Server 是否启动成功
$ tail -f ~/logs/rocketmqlogs/namesrv.log
The Name Server boot success...
  1. 启动Broker集群
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    ### 在机器A,启动第一个Master,例如NameServer的IP为:192.168.1.1
    $ nohup sh mqbroker -n 192.168.1.1:9876 -c $ROCKETMQ_HOME/conf/2m-2s-sync/broker-a.properties &

    ### 在机器B,启动第二个Master,例如NameServer的IP为:192.168.1.1
    $ nohup sh mqbroker -n 192.168.1.1:9876 -c $ROCKETMQ_HOME/conf/2m-2s-sync/broker-b.properties &

    ### 在机器C,启动第一个Slave,例如NameServer的IP为:192.168.1.1
    $ nohup sh mqbroker -n 192.168.1.1:9876 -c $ROCKETMQ_HOME/conf/2m-2s-sync/broker-a-s.properties &

    ### 在机器D,启动第二个Slave,例如NameServer的IP为:192.168.1.1
    $ nohup sh mqbroker -n 192.168.1.1:9876 -c $ROCKETMQ_HOME/conf/2m-2s-sync/broker-b-s.properties &

以上Broker与Slave配对是通过指定相同的BrokerName参数来配对,Master的BrokerId必须是0,Slave的BrokerId必须是大于0的数。另外一个Master下面可以挂载多个Slave,同一Master下的多个Slave通过指定不同的BrokerId来区分。$ROCKETMQ_HOME指的RocketMQ安装目录,需要用户自己设置此环境变量。

集群监控平台搭建

RocketMQ有一个对其扩展的开源项目incubator-rocketmq-externals,这个项目中有一个子模块叫rocketmq-console,这个便是管理控制台项目了,先将incubator-rocketmq-externals拉到本地,因为我们需要自己对rocketmq-console进行编译打包运行。

下载并编译打包

1
2
git clone https://github.com/apache/rocketmq-dashboard.git
cd rocketmq-dashboard

进入到解压的文件夹下修改 application.yml 文件

执行 mvn clean package -DskipTests=true

启动 rocketmq-dashboard:

java -jar rocketmq-dashboard-1.0.1-SNAPSHOT.jar

启动成功后,我们就可以通过浏览器访问http://localhost:8080进入控制台界面了,如下图:

图 2

示例

在基本样例中我们提供如下的功能场景:

  • 使用RocketMQ发送三种类型的消息:同步消息、异步消息和单向消息。其中前两种消息是可靠的,因为会有发送是否成功的应答。
  • 使用RocketMQ来消费接收到的消息。

加入依赖

1
2
3
4
5
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-client</artifactId>
<version>4.3.0</version>
</dependency>

消息发送

Producer端发送同步消息 这种可靠性同步地发送方式使用的比较广泛,比如:重要的消息通知,短信通知。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class SyncProducer {
public static void main(String[] args) throws Exception {
// 实例化消息生产者Producer
DefaultMQProducer producer = new DefaultMQProducer("please_rename_unique_group_name");
// 设置NameServer的地址
producer.setNamesrvAddr("localhost:9876");
// 启动Producer实例
producer.start();
for (int i = 0; i < 100; i++) {
// 创建消息,并指定Topic,Tag和消息体
Message msg = new Message("TopicTest" /* Topic */,
"TagA" /* Tag */,
("Hello RocketMQ " + i).getBytes(RemotingHelper.DEFAULT_CHARSET) /* Message body */
);
// 发送消息到一个Broker
SendResult sendResult = producer.send(msg);
// 通过sendResult返回消息是否成功送达
System.out.printf("%s%n", sendResult);
}
// 如果不再发送消息,关闭Producer实例。
producer.shutdown();
}
}

发送异步消息 异步消息通常用在对响应时间敏感的业务场景,即发送端不能容忍长时间地等待Broker的响应。

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
public class AsyncProducer {
public static void main(String[] args) throws Exception {
// 实例化消息生产者Producer
DefaultMQProducer producer = new DefaultMQProducer("please_rename_unique_group_name");
// 设置NameServer的地址
producer.setNamesrvAddr("localhost:9876");
// 启动Producer实例
producer.start();
producer.setRetryTimesWhenSendAsyncFailed(0);

int messageCount = 100;
// 根据消息数量实例化倒计时计算器
final CountDownLatch2 countDownLatch = new CountDownLatch2(messageCount);
for (int i = 0; i < messageCount; i++) {
final int index = i;
// 创建消息,并指定Topic,Tag和消息体
Message msg = new Message("TopicTest",
"TagA",
"OrderID188",
"Hello world".getBytes(RemotingHelper.DEFAULT_CHARSET));
// SendCallback接收异步返回结果的回调
producer.send(msg, new SendCallback() {
@Override
public void onSuccess(SendResult sendResult) {
System.out.printf("%-10d OK %s %n", index,
sendResult.getMsgId());
}
@Override
public void onException(Throwable e) {
System.out.printf("%-10d Exception %s %n", index, e);
e.printStackTrace();
}
});
}
// 等待5s
countDownLatch.await(5, TimeUnit.SECONDS);
// 如果不再发送消息,关闭Producer实例。
producer.shutdown();
}
}

单向发送消息 这种方式主要用在不特别关心发送结果的场景,例如日志发送。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class OnewayProducer {
public static void main(String[] args) throws Exception{
// 实例化消息生产者Producer
DefaultMQProducer producer = new DefaultMQProducer("please_rename_unique_group_name");
// 设置NameServer的地址
producer.setNamesrvAddr("localhost:9876");
// 启动Producer实例
producer.start();
for (int i = 0; i < 100; i++) {
// 创建消息,并指定Topic,Tag和消息体
Message msg = new Message("TopicTest" /* Topic */,
"TagA" /* Tag */,
("Hello RocketMQ " + i).getBytes(RemotingHelper.DEFAULT_CHARSET) /* Message body */
);
// 发送单向消息,没有任何返回结果
producer.sendOneway(msg);

}
// 如果不再发送消息,关闭Producer实例。
producer.shutdown();
}
}

发送延时消息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class ScheduledMessageProducer {
public static void main(String[] args) throws Exception {
// 实例化一个生产者来产生延时消息
DefaultMQProducer producer = new DefaultMQProducer("ExampleProducerGroup");
// 启动生产者
producer.start();
int totalMessagesToSend = 100;
for (int i = 0; i < totalMessagesToSend; i++) {
Message message = new Message("TestTopic", ("Hello scheduled message " + i).getBytes());
// 设置延时等级3,这个消息将在10s之后发送(现在只支持固定的几个时间,详看delayTimeLevel)
message.setDelayTimeLevel(3);
// 发送消息
producer.send(message);
}
// 关闭生产者
producer.shutdown();
}
}

批量发送消息能显著提高传递小消息的性能。限制是这些批量消息应该有相同的topic,相同的waitStoreMsgOK,而且不能是延时消息。此外,这一批消息的总大小不应超过4MB。

1
2
3
4
5
6
7
8
9
10
11
String topic = "BatchTest";
List<Message> messages = new ArrayList<>();
messages.add(new Message(topic, "TagA", "OrderID001", "Hello world 0".getBytes()));
messages.add(new Message(topic, "TagA", "OrderID002", "Hello world 1".getBytes()));
messages.add(new Message(topic, "TagA", "OrderID003", "Hello world 2".getBytes()));
try {
producer.send(messages);
} catch (Exception e) {
e.printStackTrace();
//处理error
}

你可能不确定它是否超过了大小限制(4MB)。这时候你最好把你的消息列表分割一下:

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
public class ListSplitter implements Iterator<List<Message>> { 
private final int SIZE_LIMIT = 1024 * 1024 * 4;
private final List<Message> messages;
private int currIndex;
public ListSplitter(List<Message> messages) {
this.messages = messages;
}
@Override public boolean hasNext() {
return currIndex < messages.size();
}
@Override public List<Message> next() {
int startIndex = getStartIndex();
int nextIndex = startIndex;
int totalSize = 0;
for (; nextIndex < messages.size(); nextIndex++) {
Message message = messages.get(nextIndex);
int tmpSize = calcMessageSize(message);
if (tmpSize + totalSize > SIZE_LIMIT) {
break;
} else {
totalSize += tmpSize;
}
}
List<Message> subList = messages.subList(startIndex, nextIndex);
currIndex = nextIndex;
return subList;
}
private int getStartIndex() {
Message currMessage = messages.get(currIndex);
int tmpSize = calcMessageSize(currMessage);
while(tmpSize > SIZE_LIMIT) {
currIndex += 1;
Message message = messages.get(curIndex);
tmpSize = calcMessageSize(message);
}
return currIndex;
}
private int calcMessageSize(Message message) {
int tmpSize = message.getTopic().length() + message.getBody().length();
Map<String, String> properties = message.getProperties();
for (Map.Entry<String, String> entry : properties.entrySet()) {
tmpSize += entry.getKey().length() + entry.getValue().length();
}
tmpSize = tmpSize + 20; // 增加⽇日志的开销20字节
return tmpSize;
}
}
//把大的消息分裂成若干个小的消息
ListSplitter splitter = new ListSplitter(messages);
while (splitter.hasNext()) {
try {
List<Message> listItem = splitter.next();
producer.send(listItem);
} catch (Exception e) {
e.printStackTrace();
//处理error
}
}

消费消息

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
public class Consumer {

public static void main(String[] args) throws InterruptedException, MQClientException {

// 实例化消费者
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("please_rename_unique_group_name");

// 设置NameServer的地址
consumer.setNamesrvAddr("localhost:9876");

// 订阅一个或者多个Topic,以及Tag来过滤需要消费的消息
consumer.subscribe("TopicTest", "*");
// 注册回调实现类来处理从broker拉取回来的消息
consumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
System.out.printf("%s Receive New Messages: %s %n", Thread.currentThread().getName(), msgs);
// 标记该消息已经被成功消费
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
// 启动消费者实例
consumer.start();
System.out.printf("Consumer Started.%n");
}
}

@Bean

Bean可以说是Spring当中最为重要的概念之一了。简单来说,Bean就是一个对象,只不过这个对象是由Spring容器来初始化,装配,管理的,因此也可以叫做Spring Bean。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

@Target({ElementType.METHOD, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Bean {
@AliasFor("name")
String[] value() default {};

@AliasFor("value")
String[] name() default {};

/** @deprecated */
@Deprecated
Autowire autowire() default Autowire.NO;

boolean autowireCandidate() default true;

String initMethod() default "";

String destroyMethod() default "(inferred)";
}

注:当注解中只传入value一个属性的时候,value属性的名称可以省略;即:@Bean(value=”myBean”)等同于@Bean(“myBean”);Alias是别名的意思,因此使用@Bean(name=”myBean”)也是一样的效果。

1
2
3
4
5
6
7
8
9
10
11
12
@Configuration
public class AppConfig {
@Bean
public FooService fooService() {
return new FooService(fooRepository());
}

@Bean
public FooRepository fooRepository() {
return new JdbcFooRepository(datasource());
}
}

在上面的代码中,我们向Spring容器中注入了两个Bean,当没有显式命名时,自动注册的名称为方法名。

使用name属性显式命名如下:

1
2
3
4
5
@Bean({"b1", "b2"}) // Bean可以用'b1'或者'b2'取到,而非'myBean'
public MyBean myBean() {
// 此处初始化和配置MyBean对象
return obj;
}

@Configuration

@Bean注解往往和@Configuration配合,前者标注在方法上,后者标注在类上;两者搭配,将Bean注册到容器中。

1
2
3
4
5
6
7
8
9
10
11
12
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component
public @interface Configuration {
@AliasFor(
annotation = Component.class
)
String value() default "";

boolean proxyBeanMethods() default true;
}

我们可以从源码中可以看到,它实质上也是一个@Component注解,所以该注解标记的类本身也会被注册成一个Bean

1
2
3
4
5
6
7
@Configuration
public class AppConfig {
@Bean
public MyBean myBean() {
// instance, configure and return bean
}
}

它指示类生成一个或者多个@Bean方法,可以由Spring容器处理,在运行时为这些Bean生成BeanDefination和服务请求。

@Component

从前面我们知道,所谓的Bean其实就是一个个对象;但是@Bean注解是标注在方法上的,意思就是通过方法返回一个对象,那么有没有直接通过类获取对象的呢?当然有,那就是@Component,被该注解标注的类会被注册到当前容器,bean的id就是类名转换为小驼峰。

1
2
3
4
5
6
7
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Indexed
public @interface Component {
String value() default "";
}

直接标注在类上,Spring扫描时会将其加入容器。

1
2
3
4
@Component // or @Component("myBean")
public class MyClass {
// write your bean here...
}

Spring常用注册Bean注解:

  • @Component, @Service, @Repository, @Controller四个注解作用于类上,实质上是一样的,注册类到当前容器,value属性就是BeanName
  • @Configuration注解也作用于类上,该注解通常与@Bean配合使用
  • @Bean用于方法上,该方法需要在@Configutation标注的类里面,且方法必须为public
  • @Component注解的效果与@Bean的效果类似,也是单例模式。可以搭配的注解也类似,例如@Scope, @Profile, @Primary等等。

@Autowired

我们使用@Bean(或者@Component)注解将Bean注册到了Spring容器;我们创建这些Bean的目的,最终还是为了使用,@Autowired注解可以将bean自动注入到类的属性中。@Autowired注解可以直接标注在属性上,也可以标注在构造器,方法,甚至是传入参数上,其实质都是调用了setter方法注入。

1
2
3
4
5
6
@Target({ElementType.CONSTRUCTOR, ElementType.METHOD, ElementType.PARAMETER, ElementType.FIELD, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Autowired {
boolean required() default true;
}

当自动注入的Bean不存在时,Spring会报错;如果希望Bean存在时才注入,可以使用@Autowired(required=false)。

使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
...
@Autowired //标注在属性上
private MyBean myBean;
...

@Autowired //标注在方法上
public void setMyBean(MyBean myBean) {
this.myBean = myBean;
}
...

@Autowired//标注在构造函数上
public MyClass(MyBean myBean) {
this.myBean = myBean;
}
...
//标注在方法参数上
public void setMyBean(@Autowired MyBean myBean) {
this.myBean = myBean;
}
...
  • @Autowired和@Resource注解都是作为bean对象注入时使用的,@Autowired是Spring提供的注解,而@Resource是J2EE本身提供的。
  • @Autowired首先根据类型去寻找注入的对象,如果有多个再根据名字匹配。
  • 当名字也无法区分时可以通过@Qulifier显式指定,如:
    1
    2
    3
    @Autowired
    @Qualifier("userServiceImpl1")
    private UserService userService;

@Qualifier

当同一个类型的Bean创建了多个时,我们可以通过搭配@Autowired和@Qualifier来确定需要注入的Bean解决混淆。除此以外,@Qualifier还可以标注在Bean上实现逻辑分组。

1
2
3
4
5
6
7
@Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.TYPE, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface Qualifier {
String value() default "";
}

标注在单个属性上,是根据name去获取Bean:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Autowired
@Qualifier("bean2")
private MyBean mybean;

@Bean
public MyBean bean1() {
return new MyBean();
}

@Bean
public MyBean bean2() {
return new MyBean();
}

标注在集合上,则可以筛选出被@Qualifier标注的Bean

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Autowired
private List<User> users; // user1, user2, user3

@Autowired
@Qualifier
private List<User> usersQualifier; // user2, user3

@Bean
public User user1() {
return new User();
}

@Bean
@Qualifier
public User user2() {
return new User();
}

@Bean
@Qualifier
public User user3() {
return new User();
}

@Primary

当一个接口有多个实现时,我们可以通过给@Autowired注解搭配@Qualifier来注入我们想要的Bean。这里还有另一种情况:Bean之前分优先级顺序,一般情况下我们只会注入默认实现;这个时候可以采用@Primary注解,该注解标注于Bean上,指示了优先注入的类。

1
2
3
4
5
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Primary {
}

使用时直接标注在返回Bean的方法上

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
...
@Autowired
private MyBean myBean(); // 注入myBean1

@Primary
@Bean
public MyBean myBean1() {
return new MyBean();
}

@Bean
public MyBean myBean2() {
return new MyBean();
}
...

也可以标注在@Component的类上(@Controller, @Service, @Repository也是一样的)

1
2
3
4
5
@Primary
@Component
public class MyBean {
//...
}

@Scope

Spring中的Bean默认是单例模式,在代码各处注入的一个Bean都是同一个实例;我们将在Spring IoC创建的Bean对象的请求可见范围称为作用域。注解@Scope可用于更改Bean的作用域。

1
2
3
4
5
6
7
8
9
10
11
12
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Scope {
@AliasFor("scopeName")
String value() default "";

@AliasFor("value")
String scopeName() default "";

ScopedProxyMode proxyMode() default ScopedProxyMode.DEFAULT;
}

传入作用域类型时直接传入字符串,例如:@Scope(“prototype”),但这种方法字符串拼写错误不容易发现;Spring提供了默认的参数:ConfigurableBeanFactory.SCOPE_PROTOTYPE, ConfigurableBeanFactory.SCOPE_SINGLETON, WebApplicationContext.SCOPE_REQUEST, WebApplicationContext.SCOPE_SESSION
proxyMode也提供了参数:ScopedProxyMode.INTERFACES, ScopedProxyMode.TARGET_CLASS

作用域分为:

  • 基本作用域(singleton, prototype)
  • Web作用域(request, session, globalssion)
    由于默认为单例模式,所以需要标注时一般都是使用prototype
    1
    2
    3
    4
    5
    @Bean
    @Scope("prototype") // @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
    public MyBean myBean() {
    return new MyBean();
    }
    prototype是原型模式,具体可以看设计模式中的”原型模式”,每次通过容器的getBean方法获取Bean时,都将产生一个新的Bean实例。

常见使用误区,单例调用多例:

1
2
3
4
5
6
7
8
9
@Component
public class SingletonBean {
@Autowired
private PrototypeBean bean;

@Bean
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)

}

上面的代码中,外面的SingletonBean默认单例模式,里面的PrototypeBean设置成原型模式。
这里并不能达到我们每次创建返回新实例的效果。
因为@Autowired只会在单例SingletonBean初始化的时候注入一次,再次调用SingletonBean的时候,PrototypeBean不会再创建了(注入时创建,而注入只有一次)。
解决方法1:不使用@Autowired,每次调用多例的时候,直接调用Bean;
解决方法2:Spring的解决办法:设置proxyMode, 每次请求的时候实例化。

两种代理模式的区别:
ScopedProxyMode.INTERFACES: 创建一个JDK代理模式
ScopedProxyMode.TARGET_CLASS: 基于类的代理模式
前者只能将其注入一个接口,后者可以将其注入类。

@Lazy

Spring Boot中Bean默认的作用域为单例模式;而Spring IoC容器会在启动的时候实例化所有单例Bean。熟悉单例模式的朋友也许立刻想到了饿汉模式和懒汉模式。在Spring Boot中,@Lazy用于指定Bean是否取消预初始化,该注解可以用于直接或者间接用了@Component注解的类,或使用了@Bean的方法。

1
2
3
4
5
6
7

@Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Lazy {
boolean value() default true;
}

@Lazy注解使用在@Bean方法上:

1
2
3
4
5
@Bean
@Lazy
public MyBean myBean() {
return new MyBean();
}

也可以在@Comfiguration类上使用,该类中所有的@Bean方法都会延迟初始化

1
2
3
4
5
6
7
8
9
10
11
12
@Configuration
@Lazy
public class MyConfig() {
@Bean
public MyBean myBean1() {
return new MyBean();
}
@Bean
public MyBean myBean2() {
return new MyBean();
}
}

在标注了@Lazy的@Configuration类内,在某个@Bean方法上标注@Lazy(false),表明这个bean立即初始化。

1
2
3
4
5
6
7
8
9
@Configuration
@Lazy
public class MyConfig() {
@Bean
@Lazy(false)
public MyBean myBean1() {
return new MyBean();
}
}

@Profile

@Profile注解在前面的的文章中提到过,这是一个直接用英文翻译比较难理解的词;profile本身的意思是”轮廓”,”侧写”,在编程的其他领域,这个词有针对每个用户的数据存储的意思。而在Spring Boot当中,这个词用于区分不同的环境,如开发环境,测试环境,生产环境。

1
2
3
4
5
6
7
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Conditional({ProfileCondition.class})
public @interface Profile {
String[] value();
}

该注解可以传入多个字符串,@Profile(“prod”), @Profile({“test”,”master”})都是合法的使用,如果有多个条件,需要同时满足

一般在@Configuration下使用,标注在能返回Bean的类或者方法上,标注的时候填入一个字符串,作为一个场景或者一个区分。

1
2
3
4
5
@Configuration
@Profile("dev")
public class MyConfig {
// your beans here
}

当Profile为dev时,上面的代码才会在Spring中注册MyConfig这个类。

在Profile的名字前加”!”,即Profile不激活才注册相应的Bean。

1
2
3
4
5
6
7
...
@Bean
@Profile("!test") // test不激活才创建该Bean
public MyBean myBean() {
return new MyBean();
}
...

激活环境

最推荐的方式是在计算机中配置环境变量:export SPRING_PROFILES_ACTIVE=prod,这样注册的环境就和计算机绑定在一起。
也可以设置启动参数-Dspring.profiles.active=test
允许同时激活多个环境,如:-Dspring.profiles.active=test,master
也可以在application.yml中配置,或者使用@ActiveProfiles注解,但是它们直接写在了源代码中,失去了灵活性,这里就不展开说了。

prod, dev, test等都是常用的缩写, 属于自己定义的内容而非Spring提供的定义

@ComponentScan

标注了@Component等注解的类会被注册为Bean。那么@ComponentScan自然就是用于开启包扫描,将Bean注册到Spring IoC环境中的注解。该注解标注在启动类上,它定义了扫描路径,从中找到标识了需要装配的类,自动将其装配到环境中。

在Spring Boot中,我们常常见到的是复合注解@SpringBootApplication,这个注解的功能之一等效于使用了@ComponentScan,因此我们前面没有显式提到要使用@ComponentScan

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
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE})
@Documented
@Repeatable(ComponentScans.class)
public @interface ComponentScan {
@AliasFor("basePackages")
String[] value() default {};

@AliasFor("value")
String[] basePackages() default {};

Class<?>[] basePackageClasses() default {};

Class<? extends BeanNameGenerator> nameGenerator() default BeanNameGenerator.class;

Class<? extends ScopeMetadataResolver> scopeResolver() default AnnotationScopeMetadataResolver.class;

ScopedProxyMode scopedProxy() default ScopedProxyMode.DEFAULT;

String resourcePattern() default "**/*.class";

boolean useDefaultFilters() default true;

ComponentScan.Filter[] includeFilters() default {};

ComponentScan.Filter[] excludeFilters() default {};

boolean lazyInit() default false;

@Retention(RetentionPolicy.RUNTIME)
@Target({})
public @interface Filter {
FilterType type() default FilterType.ANNOTATION;

@AliasFor("classes")
Class<?>[] value() default {};

@AliasFor("value")
Class<?>[] classes() default {};

String[] pattern() default {};
}
}

@ComponentScan标注在启动类上,如果不传入basePackages属性,那么扫描的路径默认为当前包以及当前路径下的子包,这也是启动类为什么一般放在源代码最外层的原因。

1
2
3
4
@ComponentScan
public class MyApplication {
// 启动类
}

includeFilters和excludeFilters两个属性的行为比较理解,就是包含或者排除哪些特殊情况,但Filter类值得讲一讲。
FilterType有五种类型:注解类型FilterType.ANNOTATION, 指定固定类FilterType.ASSIGNABLE_TYPE, 切入点类型FilterType.ASPECTJ, 正则表达式FilterType.REGEX, 自定义类型FilterType.CUSTOM
首先说注解类型,比如我们知道@Controller会被注册为Bean,我们可以将其排除。

1
2
3
4
5
6
@ComponentScan(
excludeFilters={@Filter(type=FilterType.ANNOTATION,classes={Controller.class})}
)
public class MyApplication {
// 启动类
}

使用includeFilters将未标注的自定义类注册进容器:

1
2
3
4
5
6
@ComponentScan(
includeFilters={@Filter(type=FilterType.ASSIGNABLE_TYPE, classes={MyClass.class})}
)
public class MyApplication {
// 启动类
}

总结

我们对Spring注解的学习整理也完成了第一阶段。别的文章往往一开始都会介绍@SpringBootApplication,而我们仅仅只让它在第十篇文章内露了一下面。看似我们的文章组织的没有章法,实际上也是无可奈何,平级的知识之间的关系就如同网状,起步阶段总是感觉备受困扰,我已经按照我认为最容易理解的顺序组织内容。如果看的不太懂,不妨先去找找DEMO,上手敲一敲。
回到我们的文章上来,第一阶段的主题是Bean装配,想要将Bean注册到Spring IoC容器。一般有两种思路:
①使用@Configuration标注在类上, 正如其名字所表达的那样,在这个配置类中,我们可以在返回Bean的方法上标注@Bean;
②直接在类上标注@Component,那么这个类就会被注册到容器中。
Bean被注册到了容器中,但我们并不能直接使用,否则那岂不是成了全局变量,开了历史的倒车?
@Autowired能够将Bean注入到成员变量中,它实际上调用了setter方法,所以需要注入的字段,需要声明public的setter方法。
自动注入时类型必须匹配,必须是对应的类或者子类。当对应的类只有一个时自然不必烦恼。当有多个实例时则根据名字匹配。但为了自动匹配上而强迫自己按照Bean的名字命名字段对象无异于削足适履,还好,@Qualifier和@Primary能解决我们的问题。Spring会根据@Qualifier内传入的名字去匹配相应的Bean;而@Primary更加适用于Bean之间有优先级顺序的情况,像主从数据库就是一个很好的例子。
之后,我们需要对Bean有着更加细粒度的控制。比如@Scope来决定Bean的作用域。@Lazy来决定Bean是否懒加载。@Profile让我们根据条件决定Bean是否装配。
最后,是十分重要常常被提到,却往往沦为@SpringBootApplication的背景板的@ComponentScan,它开启了包扫描,是我们Bean被装配到容器中的最大功臣。

@ConfigurationProperties

即便现在简化了配置,但是一个独立的配置文件总是易于理解而且使人安心的。Spring在构建完项目后,会默认在resources文件夹下创建一个application.properties文件,application.yml也是一样的效果。@ConfigurationProperties可以获取配置文件中的数据,将其注入类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ConfigurationProperties {
@AliasFor("prefix")
String value() default "";

@AliasFor("value")
String prefix() default "";

boolean ignoreInvalidFields() default false;

boolean ignoreUnknownFields() default true;
}

向注解中传入配置文件中的前缀名,如果配置文件如下:

1
2
3
4
5
myConfigs:
config1:
field1: f1
field2: f2
field3: f3

那么代码中的配置类应该这样写:

1
2
3
4
5
6
7
@Component
@ConfigurationProperties("myConfigs.config1")
public class MyConfig1 {
String field1;
String field2;
String field3;
}

如上所示,field1, field2, field3三个属性就被绑定到了对象上。

ignoreInvalidFields默认为false,不合法的属性的属性会默认抛出异常;
ignoreUnknownFields默认为true, 未能识别的属性会被忽略(所以打错了名字就会被忽略了)

1
2
3
4
@ConfigurationProperties(prefix="config.prefix", ignoreInvalidFields=true, ignoreUnknownFields=false)
public class MyConfig {
// fields
}

Spring Boot的绑定规则相当宽松,myField, my-field, my_field等都能识别绑定到myField上。

可以给字段设定默认值,这样配置中没有传入时会使用默认值。

1
2
3
4
5
@ConfigurationProperties("your.prefix")
public class YourConfig {
private String field = "Default"
// setter
}

类的字段必须要有public访问权限的setter方法。

@EnableConfigurationProperties

我们从前面大概知道,在Spring中,想让一个类被扫描进入IoC容器中,一般有两种方法:一是在这个类上添加@Component;二是在配置类上指定类。第二类的方法对应到实际中就是大多以Enable开头,例如@EnableConfigurationProperties就是和@ConfigurationProperties搭配使用。

1
2
3
4
5
6
7
8
9
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Import({EnableConfigurationPropertiesRegistrar.class})
public @interface EnableConfigurationProperties {
String VALIDATOR_BEAN_NAME = "configurationPropertiesValidator";

Class<?>[] value() default {};

使用使用时要加载配置类上,启动类也是一种配置类。

1
2
3
4
5
@Configuration
@EnableConfigurationProperties(YouConfigurationProperties.class)
public class MyConfig {
// ...
}

@Value

使用@ConfigurationProperties能够将配置注入到整个类中,而@Value注解能够将配置注入到字段中,进行更为细粒度的控制。

1
2
3
4
5
6
@Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Value {
String value();
}

注意:注解修饰的字段不能为static或者final。

使用上有两种形式:
@Value(“${}”),用来加载外部文件中的值;
@Value(“#{}”),用于执行SpEl表达式,并将内容赋值给属性。
我们可以获取Spring Boot配置文件(.yml或.properties)中的属性值并将其赋值给指定变量。

1
2
3
@Value("${my.config.field}")
private String value;
...

SpEL是一种表达式语言,能够动态的运行语句。
如果有使用Thymeleaf,会感到一种熟悉感。

1
2
3
4
5
...
@Value("#{1 + 1}")
private Integer value; // 2
...