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

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


  • 首页

  • 归档

  • 搜索

傻了吧?数据处理速度高2-6倍,我只写了3行Python代码

发表于 2021-03-23

在 Python 中,我们可以找到原生的并行化运算指令。本文可以教你仅使用 3 行代码,大大加快数据预处理的速度。

在默认情况下,Python 程序是单个进程,使用单 CPU 核心执行。而大多数硬件都至少搭载了双核处理器。这意味着如果没有进行优化,在数据预处理的时候会出现「一核有难九核围观」的情况——超过 50% 的算力都会被浪费。

幸运的是,Python 库中内建了一些隐藏的特性,可以让我们充分利用所有 CPU 核心的能力。通过使用 Python 的 concurrent.futures 模块,我们只需要 3 行代码就可以让一个普通的程序转换成适用于多核处理器并行处理的程序。

标准方法

让我们举一个简单的例子,在单个文件夹中有一个图片数据集,其中有数万张图片。在这里,我们决定使用 1000 张。我们希望在所有图片被传递到深度神经网络之前将其调整为 600×600 像素分辨率的形式。以下是你经常会在 GitHub 上看到的标准 Python 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
ini复制代码import glob
import os
import cv2


### Loop through all jpg files in the current folder
### Resize each one to size 600x600
for image_filename in glob.glob("*.jpg"):
### Read in the image data
img = cv2.imread(image_filename)

### Resize the image
img = cv2.resize(img, (600, 600))

上面的程序遵循你在处理数据脚本时经常看到的简单模式:

    1. 首先从需要处理内容的文件(或其他数据)列表开始。
    1. 使用 for 循环逐个处理每个数据,然后在每个循环迭代上运行预处理。

让我们在一个包含 1000 个 jpeg 文件的文件夹上测试这个程序,看看运行它需要多久:

1
css复制代码time python standard_res_conversion.py

在我的酷睿 i7-8700k 6 核 CPU 上,运行时间为 7.9864 秒!在这样的高端 CPU 上,这种速度看起来是难以让人接受的,看看我们能做点什么。

更快的方法

为了便于理解并行化的提升,假设我们需要执行相同的任务,比如将 1000 个钉子钉入木头,假如钉入一个需要一秒,一个人就需要 1000 秒来完成任务。四个人组队就只需要 250 秒。

在我们这个包含 1000 个图像的例子中,可以让 Python 做类似的工作:

  • 将 jpeg 文件列表分成 4 个小组;
  • 运行 Python 解释器中的 4 个独立实例;
  • 让 Python 的每个实例处理 4 个数据小组中的一个;
  • 结合四个处理过程得到的结果得出最终结果列表。

这一方法的重点在于,Python 帮我们处理了所有棘手的工作。我们只需告诉它我们想要运行哪个函数,要用多少 Python 实例,剩下的就交给它了!只需改变三行代码。实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
python复制代码import glob
import os
import cv2
import concurrent.futures


def load_and_resize(image_filename):
### Read in the image data
img = cv2.imread(image_filename)

### Resize the image
img = cv2.resize(img, (600, 600))


### Create a pool of processes. By default, one is created for each CPU in your machine.
with concurrent.futures.ProcessPoolExecutor() as executor:
### Get a list of files to process
image_files = glob.glob("*.jpg")

### Process the list of files, but split the work across the process pool to use all CPUs
### Loop through all jpg files in the current folder
### Resize each one to size 600x600
executor.map(load_and_resize, image_files)

从以上代码中摘出一行:

1
csharp复制代码with concurrent.futures.ProcessPoolExecutor() as executor:

你的 CPU 核越多,启动的 Python 进程越多,我的 CPU 有 6 个核。实际处理代码如下:

1
c复制代码executor.map(load_and_resize, image_files)

「executor.map()」将你想要运行的函数和列表作为输入,列表中的每个元素都是我们函数的单个输入。由于我们有 6 个核,我们将同时处理该列表中的 6 个项目!

如果再次用以下代码运行我们的程序:

1
css复制代码time python fast_res_conversion.py

我们可以将运行时间降到 1.14265 秒,速度提升了近 6 倍!

注意:在生成更多 Python 进程及在它们之间整理数据时会有一些开销,所以速度提升并不总是这么明显。但是总的来说,速度提升还是非常可观的。

它总是那么快吗?

如果你有一个数据列表要处理,而且在每个数据点上执行相似的运算,那么使用 Python 并行池是一个很好的选择。但有时这不是最佳解决方案。并行池处理的数据不会在任何可预测的顺序中进行处理。如果你对处理后的结果有特殊顺序要求,那么这个方法可能不适合你。

你处理的数据也必须是 Python 可以「炮制」的类型。所幸这些指定类别都很常见。以下来自 Python 官方文件:

  • None, True, 及 False
  • 整数、浮点数、复数
  • 字符串、字节、字节数组
  • 只包含可挑选对象的元组、列表、集合和字典
  • 在模块顶层定义的函数(使用 def ,而不是 lambda )
  • 在模块顶层定义的内置函数
  • 在模块顶层定义的类
  • 这种类的实例,其 __dict__ 或调用__getstate__() 的结果是可选择的(参见「Pickling Class Instances」一节)。

本文转载自: 掘金

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

基于Shiro JWT整合WxJava实现微信小程序登录

发表于 2021-03-23

前言

最近在做毕业设计,涉及到微信小程序的开发,要求前端小程序用户使用微信身份登录,登陆成功后,后台返回自定义登录状态token给小程序,后续小程序发送API请求都需要携带token才能访问后台数据。

本文是对接微信小程序,实现自定义登录状态的一个完整示例,实现了小程序的自定义登陆,将自定义登陆态token返回给小程序作为登陆凭证。用户的信息保存在数据库中,登陆态token缓存在redis中。涉及的技术栈:

  • SpringBoot -> 后端基础环境
  • Shiro -> 安全框架
  • JWT -> 加密token
  • MySQL -> 主库,存储业务数据
  • MyBatis-Plus -> 操作数据库
  • Redis -> 缓存token和其他热点数据
  • Lombok -> 简化开发
  • FastJson -> json消息处理
  • RestTemplate -> 优雅的处理web请求

项目GitHub地址:github.com/gongsir0630…

特性

  • 基于WxJava对接微信小程序,实现用户登录、消息处理
  • 支持Shiro注解编程,保持高度的灵活性
  • 使用JWT进行校验,完全实现无状态鉴权
  • 使用Redis存储自定义登陆态token,支持过期时间
  • 支持跨域请求

准备工作

基础知识预备:

  • 具备SpringBoot基础知识并且会使用基本注解;
  • 了解JWT(Json Web Token)的基本概念,并且会简单操作JWT的 JAVA SDK;
  • 了解Shiro的基本概念:Subject、Realm、SecurityManager等(建议去官网学习一下)

其他说明:

本文只对shiro和jwt整合进行介绍说明,具体的微信登录实现是使用RestTemplate调用我自己的wx-java-miniapp项目,该项目基于WxJava实现,支持多个小程序登录、消息处理。

本文使用以下调用处理即可:

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码// 1. todo: 微信登录: code + appid -> openId + session_key
// appid: 从配置文件读取
MultiValueMap<String, Object> request = new LinkedMultiValueMap<>();
// 参数封装, 微信登录需要以下参数
request.add("code", code);
// eg: http://localhost:8081/wx/user/{appid}/login
String path = url+"/user/"+appid+"/login";
// 请求
JSONObject dto = restTemplate.postForObject(path, request, JSONObject.class);
log.info("--->>>来自[{}]的返回 = [{}]",path,dto);

// 2. todo: 使用openId和session_key生成自定义登录状态 -> token

项目地址:

  • wx-java-miniapp -> 可以直接部署使用
  • WxJava -> 官方SDK

整体思路

先了解一下小程序官方登录流程,官方说明戳这里

小程序登录流程

  1. 小程序调用wx.login()得到code,将code发送到后台,后台通过wx-java-miniapp获取到用户的openId和session_key;
  2. 后台通过jwt工具生成自定义用户状态信息token,并且后台在数据库中查询openId判断是否存在,根据查询结果封装不同的消息,最后连同token一起返回给小程序;
  3. 之后用户访问每一个需要权限的API请求必须在header中添加Authorization字段,后台会进行token的校验,如果有误会直接返回401。

token加密说明

  • 使用uuid随机生成一个jwt-id
  • 将用户的openId、session_key连同jwt-id一起,使用小程序的appid进行签名加密并设置过期时间,最终生成token
  • 将"JWT-SESSION-"+jwt-id和token以key-value的形式存入redis中,并设置相同的过期时间

token校验说明

  • 解析token中jwt-id
  • 以"JWT-SESSION-"+jwt-id为key从redis中获取redisToken
  • 解析redisToken的携带信息,重新以相同的方式生成验证器,同token进行校验比对

项目实现

  • 项目数据库使用MySQL作为作为主库,如果是clone的项目,请在运行之前准备好相应的数据库,并修改配置信息。
  • 项目使用了redis缓存,运行前请在本地安装redis,使用默认配置即可,无需修改。
  • 项目中使用了lombok简化开发,请在idea或者eclipse安装lombok插件。

创建Maven项目

新建一个SpringBoot项目,修改pom文件,添加相关dependency:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
xml复制代码<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.github.gongsir0630</groupId>
<artifactId>shiro-jwt-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>shiro-jwt-demo</name>
<description>Demo project for Spring Boot</description>

<properties>
<java.version>1.8</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<spring-boot.version>2.3.7.RELEASE</spring-boot.version>
</properties>

<dependencies>
<!-- shiro: 用户认证\接口鉴权 -->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>1.4.0</version>
</dependency>
<!-- jwt: token认证 -->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.4.1</version>
</dependency>
<!-- redis: 数据缓存 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- 引入fastjson -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.47</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-json</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- druid数据库连接池 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.1.10</version>
</dependency>
<!-- mybatis-plus: 操作数据库 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.2</version>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<!-- mysql驱动-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<!-- 工具: 简化model开发 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- 单元测试工具 -->
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>

<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
<encoding>UTF-8</encoding>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>2.3.7.RELEASE</version>
<configuration>
<mainClass>com.github.gongsir0630.shirodemo.ShiroJwtDemoApplication</mainClass>
</configuration>
<executions>
<execution>
<id>repackage</id>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>

</project>

注意JDK版本:1.8

相关配置 | 工具准备

配置你的application.yml ,主要是配置你的小程序appid和url,还有你的数据库和redis。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
yml复制代码# 设置日志级别
logging:
level:
org.springframework.web: info
com.github.gongsir0630.shirodemo: debug
# dev环境配置文件
spring:
# 数据库相关配置信息: 无需再本地安装mysql,使用yzhelp.top云端数据库
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/shiro-jwt-demo
username: username
password: password
# redis 配置信息: 在本地安装redis
redis:
host: 127.0.0.1
port: 6379
database: 0

---
# 服务启动的端口号
server:
port: 8080

---
# 微信小程序配置 appid / url
wx:
# 小程序AppId
appid: appid
# 自研小程序接口调用地址
url: http://localhost:8081/wx

说明:
appid: 当前小程序的appid
url: wx-java-miniapp项目接口地址

配置fastJson

在启动类中配置fastJson -> ShiroJwtDemoApplication.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
30
java复制代码/**
* @author gongsir <a href="https://github.com/gongsir0630">码之泪殇</a>
* 描述: Spring Boot 工程启动类,可以直接点击下面的main方法运行程序
*/

@SpringBootApplication
public class ShiroJwtDemoApplication {

public static void main(String[] args) {
SpringApplication.run(ShiroJwtDemoApplication.class, args);
}

/**
* fastjson 配置注入: 使用阿里巴巴的 fastjson 处理 json 信息
* @return HttpMessageConverters
*/
@Bean
public HttpMessageConverters fastJsonHttpMessageConverters() {
// 消息转换对象
FastJsonHttpMessageConverter converter = new FastJsonHttpMessageConverter();
// fastjson 配置
FastJsonConfig config = new FastJsonConfig();
config.setSerializerFeatures(SerializerFeature.PrettyFormat);
config.setDateFormat("yyyy-MM-dd");
// 配置注入消息转换器
converter.setFastJsonConfig(config);
// 让 spring 使用自定义的消息转换器
return new HttpMessageConverters(converter);
}
}

配置Redis

配置Redis -> RedisConfig.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码/**
* @author 码之泪殇 GitHub: https://github.com/gongsir0630
* @date 2021/3/20 14:15
* 你的指尖,拥有改变世界的力量
* 描述: Redis配置
* EnableCaching: 开启缓存
*/
@Configuration
@EnableCaching
public class RedisConfig {
@Bean
public CacheManager cacheManager(RedisConnectionFactory factory) {
return RedisCacheManager.create(factory);
}
}

配置RestTemplate

配置RestTemplate -> RestTemplateConfig.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
java复制代码/**
* @author 码之泪殇 GitHub: https://github.com/gongsir0630
* @date 2021/3/20 14:10
* 你的指尖,拥有改变世界的力量
* 描述: RestTemplate的配置类
*/
@Configuration
public class RestTemplateConfig {

@Bean
public RestTemplate restTemplate(ClientHttpRequestFactory factory) {
return new RestTemplate(factory);
}

@Bean
public ClientHttpRequestFactory simpleClientHttpRequestFactory() {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
// 连接超时时间设置为10秒
factory.setConnectTimeout(1000 * 10);
// 读取超时时间为单位为60秒
factory.setReadTimeout(1000 * 60);
return factory;
}
}

返回集封装

CodeMsg.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
30
31
32
33
34
35
36
37
38
39
40
41
java复制代码/**
* @author 码之泪殇 GitHub: https://github.com/gongsir0630
* @date 2021/3/22 20:17
* 你的指尖,拥有改变世界的力量
* 描述: code和msg封装
*/
public class CodeMsg {
private final int code;
private final String msg;

public static CodeMsg SUCCESS=new CodeMsg(0,"success");

public static CodeMsg LOGIN_FAIL = new CodeMsg(-1,"code2session failure, please try aging");

public static CodeMsg NO_USER = new CodeMsg(1000,"user not found");
public static CodeMsg SESSION_KEY_ERROR = new CodeMsg(1001,"sessionKey is invalid");
public static CodeMsg TOKEN_ERROR = new CodeMsg(1002,"token is invalid");
public static CodeMsg SHIRO_ERROR = new CodeMsg(1003,"token is invalid");

public CodeMsg(int code, String msg) {
this.code=code;
this.msg=msg;
}

public int getCode() {
return code;
}

public String getMsg() {
return msg;
}

@Override
public String toString() {
return "CodeMsg{" +
"code=" + code +
", msg='" + msg + '\'' +
'}';
}

}

Result.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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
java复制代码/**
* @author 码之泪殇 GitHub: https://github.com/gongsir0630
* @date 2021/3/20 18:45
* 你的指尖,拥有改变世界的力量
* 描述:
* 输出结果的封装
* 只要get不要set,进行更好的封装
* @param <T> data泛型
*/
public class Result<T> {

private int code;
private String msg;
private T data;


private Result(T data){
this.code=0;
this.msg="success";
this.data=data;
}

private Result(CodeMsg mg, T data) {
if (mg==null){
return;
}
this.code=mg.getCode();
this.msg=mg.getMsg();
this.data=data;
}


/**
* 成功时
* @param <T> data泛型
* @return Result
*/
public static <T> Result<T> success(T data){
return new Result<T>(data);
}

/**
* 失败
* @param <T> data泛型
* @return Result
*/
public static <T> Result<T> fail(CodeMsg mg, T data){
return new Result<T>(mg,data);
}

public int getCode() {
return code;
}


public String getMsg() {
return msg;
}


public T getData() {
return data;
}
}

异常封装与处理

自定义异常 -> ApiAuthException.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
java复制代码import com.github.gongsir0630.shirodemo.controller.res.CodeMsg;

/**
* @author 码之泪殇 GitHub: https://github.com/gongsir0630
* @date 2021/3/22 20:24
* 你的指尖,拥有改变世界的力量
* 描述: 自定义异常, 用于处理Api认证失败异常信息保存
*/
public class ApiAuthException extends RuntimeException {
private CodeMsg codeMsg;

public ApiAuthException() {
super();
}

public ApiAuthException(CodeMsg codeMsg) {
super(codeMsg.getMsg());
this.codeMsg = codeMsg;
}

public CodeMsg getCodeMsg() {
return codeMsg;
}

public void setCodeMsg(CodeMsg codeMsg) {
this.codeMsg = codeMsg;
}
}

全局异常处理 -> AppExceptionHandler.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
30
31
32
33
34
java复制代码/**
* @author 码之泪殇 GitHub: https://github.com/gongsir0630
* @date 2021/3/20 15:49
* 你的指尖,拥有改变世界的力量
* 描述: 全局异常处理
*/
@RestControllerAdvice
@Slf4j
public class AppExceptionHandler {

/**
* 处理 Shiro 异常
* @param e 异常信息
* @return json
*/
@ExceptionHandler({ShiroException.class})
@ResponseStatus(HttpStatus.UNAUTHORIZED)
public ResponseEntity<Result<JSONObject>> handShiroException(ShiroException e) {
log.error("--->>> 捕捉到 [ApiAuthException] 异常: {}", e.getMessage());
return new ResponseEntity<>(Result.fail(CodeMsg.SHIRO_ERROR,null), HttpStatus.UNAUTHORIZED);
}

/**
* 处理 自定义ApiAuthException异常
* @param e 异常信息
* @return json
*/
@ExceptionHandler({ApiAuthException.class})
@ResponseStatus(HttpStatus.UNAUTHORIZED)
public ResponseEntity<Result<JSONObject>> handApiAuthException(ApiAuthException e) {
log.error("--->>> 捕捉到 [ApiAuthException] 异常: {},{}",e.getCodeMsg().getCode(),e.getCodeMsg().getMsg() );
return new ResponseEntity<>(Result.fail(e.getCodeMsg(),null), HttpStatus.UNAUTHORIZED);
}
}

准备数据源

  • 数据库:shiro-jwt-demo
  • 数据表:user
    示例数据库表结构

注意:这里是业务数据库,也就是我们小程序用户信息都由我们自己存储,第一次默认使用微信公开信息注册,之后用户可以自行更新这些信息,和微信信息独立开。

创建对应的实体类 -> User.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
java复制代码/**
* @author 码之泪殇 GitHub: https://github.com/gongsir0630
* @date 2021/3/21 23:44
* 你的指尖,拥有改变世界的力量
* 描述: 业务用户信息
*/
@Data
@TableName("user")
public class User {
/**
* 主键,数据库字段为user_id -> userId == openId
*/
@TableId(value = "user_id",type = IdType.INPUT)
private String userId;
private String name;
private String photo;
private String sex;
private String grade;
private String college;
private String contact;
}

使用MyBatis-plus创建mapper接口 -> UserMapper.java

1
2
3
4
5
6
7
8
9
java复制代码/**
* @author 码之泪殇 GitHub: https://github.com/gongsir0630
* @date 2021/3/22 19:44
* 你的指尖,拥有改变世界的力量
* 描述: User类mapper接口,继承自BaseMapper(已经实现User的CRUD)
*/
@Mapper
public interface UserMapper extends BaseMapper<User> {
}

MyBatis-Plus配置 -> MybatisPlusConfig.java

1
2
3
4
5
6
7
8
9
10
11
java复制代码/**
* @author 码之泪殇 GitHub: https://github.com/gongsir0630
* @date 2021/3/19 17:15
* 你的指尖,拥有改变世界的力量
* 描述: MyBatis-Plus插件配置
*/

@Configuration
@MapperScan("com.github.gongsir0630.shirodemo.mapper")
public class MybatisPlusConfig {
}

创建User业务接口,这里仅仅演示login -> UserService.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
java复制代码/**
* @author 码之泪殇 GitHub: https://github.com/gongsir0630
* @date 2021/3/22 19:49
* 你的指尖,拥有改变世界的力量
* 描述: 用户接口
*/
public interface UserService extends IService<User> {
/**
* 登录
* @param jsCode 小程序code
* @return 登录信息: 包含token
*/
Map<String, String> login(String jsCode);
}

再创建一个微信登录信息对象,主要用作接收微信的openid和session_key,以及用作shiro认证 -> WxAccount.java

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码 * @author 码之泪殇 GitHub: https://github.com/gongsir0630
* @date 2021/3/22 19:58
* 你的指尖,拥有改变世界的力量
* 描述: 微信认证信息
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class WxAccount {
private String openId;
private String sessionKey;
}

注意:该类不会用于业务信息交互,所以不需要Mapper与db交互。

微信登录接口,在这里实现与微信服务器的信息交互 -> WxAccountService.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java复制代码 * @author 码之泪殇 GitHub: https://github.com/gongsir0630
* @date 2021/3/22 20:06
* 你的指尖,拥有改变世界的力量
* 描述: 微信接口
*/
public interface WxAccountService {
/**
* 微信小程序用户登陆,完整流程可参考下面官方地址,本例中是按此流程开发
* https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/login.html
* 1 . 微信小程序端传入code。
* 2 . 通过wx-java-miniapp项目调用微信code2session接口获取openid和session_key
*
* @param code 小程序端 调用 wx.login 获取到的code,用于调用 微信code2session接口
* @return JSONObject: 包含openId和sessionKey
*/
WxAccount login(String code);
}

接口实现逻辑:

  1. 从配置文件读取appid和url;
  2. 凭借目标请求地址path,例如登录是 {url}/wx/user/{appid}/login;
  3. 参数封装,封装来自小程序的code;
  4. 使用RestTemplate发起登录请求;
  5. 处理返回集。

代码实现 -> WxAccountServiceImpl.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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
java复制代码/**
* @author 码之泪殇 GitHub: https://github.com/gongsir0630
* @date 2021/3/20 16:12
* 你的指尖,拥有改变世界的力量
* 描述: 微信接口实现: 用 restTemplate 调用 [wxApp] 应用的接口
*/
@Service
@Slf4j
public class WxAccountServiceImpl implements WxAccountService {

@Value("${wx.appid}")
private String appid;
@Value("${wx.url}")
private String url;

@Resource
private RestTemplate restTemplate;

@Override
public WxAccount login(String code) {
// todo: 微信登录: code + appid -> openId + session_key
// appid: 从配置文件读取
MultiValueMap<String, Object> request = new LinkedMultiValueMap<>();
// 参数封装, 微信登录需要以下参数
request.add("code", code);
// eg: http://localhost:8081/wx/user/{appid}/login
String path = url+"/user/"+appid+"/login";
// 请求
JSONObject dto = restTemplate.postForObject(path, request, JSONObject.class);
log.info("--->>>来自[{}]的返回 = [{}]",path,dto);
int errCode = -1;
if (dto != null ) {
errCode = Integer.parseInt(dto.get("code").toString());
} else {
throw new ApiAuthException(CodeMsg.LOGIN_FAIL);
}
if (0 != errCode) {
throw new ApiAuthException(new CodeMsg(Integer.parseInt(dto.get("code").toString()),
dto.get("msg").toString()));
}
// code2session success
JSONObject data = dto.getJSONObject("data");
return JSON.toJavaObject(data, WxAccount.class);
}
}

构建JWT

jwt工具类,用于生成token签名, token校验 -> JwtUtil.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
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
java复制代码/**
* @author 码之泪殇 GitHub: https://github.com/gongsir0630
* @date 2021/3/23 10:26
* 你的指尖,拥有改变世界的力量
* 描述: jwt工具类: 生成token签名, token校验
*/
@Component
@SuppressWarnings("All")
public class JwtUtil {
/**
* 过期时间: 2小时
*/
private static final long EXPIRE_TIME = 7200;
/**
* 使用 appid 签名
*/
@Value("${wx.appid}")
private String appsecret;

@Autowired
private StringRedisTemplate redisTemplate;

/**
* 根据微信用户登陆信息创建 token
* 使用`uuid`随机生成一个jwt-id
* 将用户的`openId`、`session_key`连同`jwt-id`一起,使用小程序的`appid`进行签名加密并设置过期时间,最终生成`token`
* 将`"JWT-SESSION-"+jwt-id`和`token`以key-value的形式存入`redis`中,并设置相同的过期时间
* 注 : 这里的token会被缓存到redis中,用作为二次验证
* redis里面缓存的时间应该和jwt token的过期时间设置相同
*
* @param wxAccount 微信用户信息
* @return 返回 jwt token
*/
public String sign(WxAccount account) {
//JWT 随机ID,做为redis验证的key
String jwtId = UUID.randomUUID().toString();
//1 . 加密算法进行签名得到token
Algorithm algorithm = Algorithm.HMAC256(appsecret);
String token = JWT.create()
.withClaim("openId", account.getOpenId())
.withClaim("sessionKey", account.getSessionKey())
.withClaim("jwt-id",jwtId)
.withExpiresAt(new Date(System.currentTimeMillis() + EXPIRE_TIME * 1000))
.sign(algorithm);
//2 . Redis缓存JWT, 注 : 请和JWT过期时间一致
redisTemplate.opsForValue().set("JWT-SESSION-"+jwtId, token, EXPIRE_TIME, TimeUnit.SECONDS);
return token;
}

/**
* token 检验
* @param token
* @return bool
*/
public boolean verify(String token) {
try {
//1 . 根据token解密,解密出jwt-id , 先从redis中查找出redisToken,匹配是否相同
String redisToken = redisTemplate.opsForValue().get("JWT-SESSION-" + getClaimsByToken(token).get("jwt-id").asString());
if (!token.equals(redisToken)) {
return Boolean.FALSE;
}
//2 . 得到算法相同的JWTVerifier
Algorithm algorithm = Algorithm.HMAC256(appsecret);
JWTVerifier verifier = JWT.require(algorithm)
.withClaim("openId", getClaimsByToken(redisToken).get("openId").asString())
.withClaim("sessionKey", getClaimsByToken(redisToken).get("sessionKey").asString())
.withClaim("jwt-id",getClaimsByToken(redisToken).get("jwt-id").asString())
.build();
//3 . 验证token
verifier.verify(token);
//4 . Redis缓存JWT续期
redisTemplate.opsForValue().set("JWT-SESSION-" + getClaimsByToken(token).get("jwt-id").asString(),
redisToken,
EXPIRE_TIME,
TimeUnit.SECONDS);
return Boolean.TRUE;
} catch (Exception e) {
//捕捉到任何异常都视为校验失败
return Boolean.FALSE;
}
}

/**
* 从token解密信息
* @param token token
* @return
* @throws JWTDecodeException
*/
public Map<String, Claim> getClaimsByToken(String token) throws JWTDecodeException {
return JWT.decode(token).getClaims();
}
}

Realm配置

创建JwtToken,用于shiro鉴权,需要实现AuthenticationToken -> JwtToken.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
java复制代码/**
* @author 码之泪殇 GitHub: https://github.com/gongsir0630
* @date 2021/3/23 10:48
* 你的指尖,拥有改变世界的力量
* 描述: 鉴权用的token,需要实现 AuthenticationToken
*/
@Data
@AllArgsConstructor
public class JwtToken implements AuthenticationToken {
private String token;
@Override
public Object getPrincipal() {
return token;
}

@Override
public Object getCredentials() {
return token;
}
}

自定义Shiro的Realm配置,需要在Realm中实现我们自定义的登陆及授权逻辑 -> ShiroRealm.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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
java复制代码import com.github.gongsir0630.shirodemo.controller.res.CodeMsg;
import com.github.gongsir0630.shirodemo.exception.ApiAuthException;
import com.github.gongsir0630.shirodemo.wx.util.JwtUtil;
import com.github.gongsir0630.shirodemo.wx.vo.JwtToken;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.SimpleAuthenticationInfo;
import org.apache.shiro.authc.credential.CredentialsMatcher;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.realm.Realm;
import org.apache.shiro.subject.PrincipalCollection;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;

/**
* @author 码之泪殇 GitHub: https://github.com/gongsir0630
* @date 2021/3/23 15:28
* 你的指尖,拥有改变世界的力量
* 描述: Realm 的一个配置管理类 allRealm()方法得到所有的realm
*/
@Component
@Slf4j
public class ShiroRealm {
@Resource
private JwtUtil jwtUtil;

/**
* 封装所有自定义的realm规则链 -> shiro配置中会将规则注入到shiro的securityManager
* @return 所有自定义的realm规则
*/
public List<Realm> allRealm() {
List<Realm> realmList = new LinkedList<>();
realmList.add(authorizingRealm());
return Collections.unmodifiableList(realmList);
}

/**
* 自定义 JWT的 Realm
* 重写 Realm 的 supports() 方法是通过 JWT 进行登录判断的关键
*/
private AuthorizingRealm authorizingRealm() {
AuthorizingRealm realm = new AuthorizingRealm() {
/**
* 当需要检测 用户权限 时调用此方法,例如checkRole,checkPermission之类的
* 根据业务需求自行编写验证逻辑
* @param principalCollection == token
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
String token = principalCollection.toString();
log.info("--->>>PrincipalCollection: [{}]",token);
// todo: 自定义权限验证, 比如role和permission验证
return new SimpleAuthorizationInfo();
}

/**
* 默认使用此方法进行用户名正确与否校验: 验证token逻辑
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
String jwtToken = (String) authenticationToken.getCredentials();
String openId = jwtUtil.getClaimsByToken(jwtToken).get("openId").asString();
String sessionKey = jwtUtil.getClaimsByToken(jwtToken).get("sessionKey").asString();
if (null == openId || "".equals(openId)) {
throw new ApiAuthException(CodeMsg.NO_USER);
}
if (null == sessionKey || "".equals(sessionKey)) {
throw new ApiAuthException(CodeMsg.SESSION_KEY_ERROR);
}
if (!jwtUtil.verify(jwtToken)) {
throw new ApiAuthException(CodeMsg.TOKEN_ERROR);
}
// 将 openId 和 sessionKey 装配到subject中
// 在 Controller 中使用 SecurityUtils.getSubject().getPrincipal() 即可获取用户openId
return new SimpleAuthenticationInfo(openId,sessionKey,this.getClass().getName());
}

/**
* 注意坑点 : 必须重写此方法,不然Shiro会报错
* 因为创建了 JWTToken 用于替换Shiro原生 token,所以必须在此方法中显式的进行替换,否则在进行判断时会一直失败
*/
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof JwtToken;
}
};
realm.setCredentialsMatcher(credentialsMatcher());
return realm;
}

/**
* 注意 : 密码校验, 这里因为是JWT形式,就无需密码校验和加密,直接让其返回为true(如果不设置的话,该值默认为false,即始终验证不通过)
*/
private CredentialsMatcher credentialsMatcher() {
// 实现boolean doCredentialsMatch(AuthenticationToken var1, AuthenticationInfo var2);
return (authenticationToken, authenticationInfo) -> true;
}
}

重写filter

所有的请求都会先经过Filter,所以我们继承官方的BasicHttpAuthenticationFilter,并且重写方法即可 -> JwtFilter.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
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
java复制代码import com.github.gongsir0630.shirodemo.wx.vo.JwtToken;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.web.filter.authc.BasicHttpAuthenticationFilter;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.RequestMethod;

import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
* @author 码之泪殇 GitHub: https://github.com/gongsir0630
* @date 2021/3/23 10:58
* 你的指尖,拥有改变世界的力量
* 描述: JWT核心过滤器配置
* 所有的请求都会先经过Filter,继承官方的BasicHttpAuthenticationFilter,并且重写鉴权的方法
* 执行流程 preHandle->isAccessAllowed->isLoginAttempt->executeLogin
*/
@Slf4j
public class JwtFilter extends BasicHttpAuthenticationFilter {
/**
* 跨域支持
* @param request 请求
* @param response 相应
* @return bool
* @throws Exception 异常
*/
@Override
protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
HttpServletResponse httpServletResponse = (HttpServletResponse) response;
httpServletResponse.setHeader("Access-control-Allow-Origin", httpServletRequest.getHeader("Origin"));
httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE");
httpServletResponse.setHeader("Access-Control-Allow-Headers", httpServletRequest.getHeader("Access-Control-Request-Headers"));
// 跨域时会首先发送一个option请求,这里我们给option请求直接返回正常状态
if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {
httpServletResponse.setStatus(HttpStatus.OK.value());
return false;
}
return super.preHandle(request, response);
}

@Override
protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) {
// 判断request是否包含 Authorization 字段
String auth = getAuthzHeader(request);
return auth != null && !"".equals(auth);
}

@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
if (isLoginAttempt(request,response)) {
// executeLogin 进入登录逻辑
// 从request请求头获取 Authorization 字段
String token = getAuthzHeader(request);
log.info("--->>>JwtFilter::isAccessAllowed拦截到认证token信息:[{}]",token);
// 这里会提交给刚刚我们自定义的realm处理
getSubject(request,response).login(new JwtToken(token));
}
// 这里返回true表示所有验证结果都能通过, 在controller中可以使用shiro注解限制是否需要登录权限
// 设置true即允许游客访问
// 设置false则必须携带token进行验证
return true;
}
}

Shiro核心配置

核心配置 -> ShiroConfig.java

  • 配置realm规则链
  • 配置访问策略:url和filter
  • 开启shiro注解支持
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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
java复制代码import com.github.gongsir0630.shirodemo.filter.JwtFilter;
import org.apache.shiro.mgt.DefaultSessionStorageEvaluator;
import org.apache.shiro.mgt.DefaultSubjectDAO;
import org.apache.shiro.spring.LifecycleBeanPostProcessor;
import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;

import javax.servlet.Filter;
import java.util.HashMap;
import java.util.Map;

/**
* @author 码之泪殇 GitHub: https://github.com/gongsir0630
* @date 2021/3/20 16:48
* 你的指尖,拥有改变世界的力量
* 描述: shiro核心配置
*/
@Configuration
public class ShiroConfig {
/**
* SecurityManager,安全管理器,所有与安全相关的操作都会与之进行交互;
* 它管理着所有Subject,所有Subject都绑定到SecurityManager,与Subject的所有交互都会委托给SecurityManager
* DefaultWebSecurityManager :
* 会创建默认的DefaultSubjectDAO(它又会默认创建DefaultSessionStorageEvaluator)
* 会默认创建DefaultWebSubjectFactory
* 会默认创建ModularRealmAuthenticator
*/
@Bean
public DefaultWebSecurityManager securityManager(ShiroRealm shiroRealm) {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
// 设置realms
securityManager.setRealms(shiroRealm.allRealm());
// close session
DefaultSubjectDAO defaultSubjectDAO = (DefaultSubjectDAO) securityManager.getSubjectDAO();
DefaultSessionStorageEvaluator evaluator = (DefaultSessionStorageEvaluator) defaultSubjectDAO.getSessionStorageEvaluator();
evaluator.setSessionStorageEnabled(Boolean.FALSE);
defaultSubjectDAO.setSessionStorageEvaluator(evaluator);
return securityManager;
}

/**
* 配置Shiro的访问策略
*/
@Bean
public ShiroFilterFactoryBean filterFactoryBean(DefaultWebSecurityManager securityManager) {
ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();
Map<String, Filter> filterMap = new HashMap<>(8);
filterMap.put("jwt", new JwtFilter());
factoryBean.setFilters(filterMap);
factoryBean.setSecurityManager(securityManager);

Map<String, String> filterRuleMap = new HashMap<>(8);
//登陆相关api不需要被过滤器拦截
filterRuleMap.put("/user/login/**", "anon");
// 所有请求通过JWT Filter
filterRuleMap.put("/**", "jwt");
factoryBean.setFilterChainDefinitionMap(filterRuleMap);
return factoryBean;
}

/**
* 添加注解支持
*/
@Bean
@DependsOn("lifecycleBeanPostProcessor")
public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator(){
DefaultAdvisorAutoProxyCreator advisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
advisorAutoProxyCreator.setProxyTargetClass(true);
return advisorAutoProxyCreator;
}

/**
* 添加注解依赖
*/
@Bean
public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
return new LifecycleBeanPostProcessor();
}

/**
* 开启注解
*/
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(DefaultWebSecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
advisor.setSecurityManager(securityManager);
return advisor;
}
}

验证

实现UserService中的login方法 -> UserServiceImpl.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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
java复制代码import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.github.gongsir0630.shirodemo.mapper.UserMapper;
import com.github.gongsir0630.shirodemo.model.User;
import com.github.gongsir0630.shirodemo.service.UserService;
import com.github.gongsir0630.shirodemo.wx.model.WxAccount;
import com.github.gongsir0630.shirodemo.wx.service.WxAccountService;
import com.github.gongsir0630.shirodemo.wx.util.JwtUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.util.HashMap;
import java.util.Map;

/**
* @author 码之泪殇 GitHub: https://github.com/gongsir0630
* @date 2021/3/23 11:19
* 你的指尖,拥有改变世界的力量
* 描述:
*/
@Service
@Slf4j
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
@Resource
private UserMapper userMapper;
@Resource
private JwtUtil jwtUtil;
@Resource
private WxAccountService wxAccountService;

@Override
public Map<String, String> login(String jsCode) {
Map<String, String> res = new HashMap<>();
WxAccount wxAccount = wxAccountService.login(jsCode);
log.info("--->>>wxAccount信息:[{}]",wxAccount);
User user = userMapper.selectById(wxAccount.getOpenId());
if (user == null) {
// todo: 用户不存在, 提醒用户提交注册信息
res.put("canLogin",Boolean.FALSE.toString());
} else {
res.put("canLogin",Boolean.TRUE.toString());
}
res.put("token", jwtUtil.sign(wxAccount));
return res;
}
}

创建controller,编写测试api -> UserController.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
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
java复制代码import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import com.github.gongsir0630.shirodemo.controller.res.CodeMsg;
import com.github.gongsir0630.shirodemo.controller.res.Result;
import com.github.gongsir0630.shirodemo.service.UserService;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authz.annotation.RequiresAuthentication;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;
import java.util.Map;

/**
* @author 码之泪殇 GitHub: https://github.com/gongsir0630
* @date 2021/3/23 11:12
* 你的指尖,拥有改变世界的力量
* 描述: 用户信息接口类,包含小程序登录注册
*/
@RestController
@Slf4j
@RequestMapping("user")
public class UserController {
@Resource
private UserService userService;

/**
* 从认证信息中获取用户Id: userId == openId
* @return userId
*/
private String getUserId() {
return SecurityUtils.getSubject().getPrincipal().toString();
}

/**
* 小程序用户登录接口: 通过js_code换取openId, 判断用户是否已经注册
* @param code wx.login() 得到的code凭证
* @return token
*/
@PostMapping("/login")
public ResponseEntity<Result<JSONObject>> login(String code) {
if (StringUtils.isBlank(code)) {
return new ResponseEntity<>(Result.fail(new CodeMsg(401,"code is empty"), null), HttpStatus.OK);
}
log.info("--->接收到来自小程序端的code:[{}]",code);
// todo: 使用 code -> wxAccountService.login() -> openId,session_key
Map<String, String> loginMap = userService.login(code);
boolean canLogin = Boolean.parseBoolean(loginMap.get("canLogin"));
String token = loginMap.get("token");
JSONObject data = new JSONObject();
data.put("token",token);
data.put("canLogin",canLogin);
log.info("--->>>返回认证信息:[{}]", data.toString());
if (!canLogin) {
// todo: 用户不存在,提示用户注册
return new ResponseEntity<>(Result.fail(CodeMsg.NO_USER,data),HttpStatus.OK);
}
return new ResponseEntity<>(Result.success(data),HttpStatus.OK);
}

/**
* 使用 RequiresAuthentication 注解, 需要验证才能访问
* @return userId
*/
@GetMapping("/hello")
@RequiresAuthentication
public ResponseEntity<Result<JSONObject>> requireAuth() {
JSONObject data = new JSONObject();
data.put("hello",getUserId());
return new ResponseEntity<>(Result.success(data),HttpStatus.OK);
}
}

编写小程序测试代码获取code:

1
2
3
4
5
6
js复制代码wx.login({
timeout: 3000,
success: (res) => {
console.log(res);
}
})

image.png

启动 wx-java-miniapp项目:

image.png

启动shiro-jwt-demo项目:

image.png

Postman测试认证:

image.png

携带token访问:

image.png

最后

以上就是基于Shiro、JWT实现微信小程序登录完整例子的逻辑过程说明及其实现。

  • 完整项目地址:github.com/gongsir0630…

本文转载自: 掘金

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

Google 的开源混沌测试工具 OSS-Fuzz 现在支持

发表于 2021-03-23
  • 原文地址:Google’s OSS-Fuzz extends fuzzing to Java apps
  • 原文作者:Paul Krill
  • 译文出自:掘金翻译计划
  • 本文永久链接:github.com/xitu/gold-m…
  • 译者:霜羽 Hoarfroster
  • 校对者:huifrank,1autodidact

Google 的开源混沌测试工具 OSS-Fuzz 现在支持 Java 应用了

Google 的开源混沌测试服务 OSS-Fuzz 现在支持 Java 以及 JVM-based 的应用程序了!这项功能于 3 月 10 日公布。

OSS-Fuzz 为开源软件提供持续的混沌测试。混沌测试作为一种将半随机和非法的输入流发送到程序,用于发现软件中的编程错误和安全漏洞的技术。用内存安全语言(基于 JVM 的语言)编写的混沌代码能够帮助我们发现导致程序崩溃或行为异常的错误。

Google 通过将 OSS-Fuzz 与 Code Intelligence 的 Jazzer 混沌器集成在一起,为 Java 和 JVM 开启了混沌处理功能。Jazzer 让我们可以通过 LLVM 项目的 libFuzzer 引擎(运行于进程内,覆盖引导的的混沌引擎),类似于对 C / C++ 的混沌处理,对基于 JVM 语言编写的代码进行混沌处理。Jazzer 支持的语言包括 Java、Clojure、Kotlin 和 Scala。代码覆盖率的反馈是基于 JVM 字节码提供给 libFuzzer 的,而 libFuzzer 功能包括:

  • FuzzedDataProvider 用于混沌测试中处理不接收字节数组的代码。
  • 基于8位边缘计数器的代码覆盖率评估。
  • 最大限度地减少崩溃的输入。
  • Value Profiles.

Google 提供了向基于 JVM 语言编写的开源项目加入 OSS-Fuzz 的文档。Jazzer 的开发计划要求 Jazzer 最终支持所有的 libFuzzer 功能。Jazzer 还可以通过 Java 原生接口执行本地代码时提供覆盖率反馈。这样可以发现内存不安全的本地代码中的内存损坏漏洞。OSS-Fuzz 还同时支持了 Go、Python,C/C + 和 Rust 等语言。

如果发现译文存在错误或其他需要改进的地方,欢迎到 掘金翻译计划 对译文进行修改并 PR,也可获得相应奖励积分。文章开头的 本文永久链接 即为本文在 GitHub 上的 MarkDown 链接。


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 Android、iOS、前端、后端、区块链、产品、设计、人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划、官方微博、知乎专栏。

本文转载自: 掘金

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

什么叫给密码“加盐”?如何安全的为你的用户密码“加盐”?

发表于 2021-03-23

在面对这个网络世界的时候,密码安全总是各个公司和用户都非常关心的一个内容,毕竟现在大家不管是休闲娱乐还是学习购物都是通过网上的帐号来进行消费的,所以我们通常会给用户的密码进行加密。在加密的时候,经常会听到“加盐”这个词,这是什么意思呢?

我们通常会将用户的密码进行 Hash 加密,如果不加盐,即使是两层的 md5 都有可能通过彩虹表的方式进行破译。彩虹表就是在网上搜集的各种字符组合的 Hash 加密结果。而加盐,就是人为的通过一组随机字符与用户原密码的组合形成一个新的字符,从而增加破译的难度。就像做饭一样,加点盐味道会更好。

接下来,我们通过代码来演示一种比较安全的加盐方式。

首先,我们建一个简单的用户表。这个表里只有四个字段,在这里仅作为测试使用。

1
2
3
4
5
6
7
sql复制代码CREATE TABLE `zyblog_test_user` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`username` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '用户名',
`password` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '密码',
`salt` char(4) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '盐',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;

然后定义两个方式,一个用来生成盐,一个用来生成加盐后的 Hash 密码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
php复制代码/**
* 随机生成四位字符串的salt
* 也可以根据实际情况使用6位或更长的salt
*/
function generateSalt()
{
// 使用随机方式生成一个四位字符
$chars = array_merge(range('A', 'Z'), range('a', 'z'), range('0', '9'));
for ($i = 0; $i < 4; $i++) {
$str .= $chars[mt_rand(0, count($chars) - 1)];
}
return $str;
}

/**
* 密码生成
* 使用两层hash,将salt加在第二层
* sha1后再加salt然后再md5
*/
function generateHashPassword($password, $salt)
{
return md5(sha1($password) . $salt);
}

generateSalt() 方法很简单,就是生成一个随机的四位字符的字符串,我们使用大小写加数字的形式生成这个字符串。这就是传说中的“盐”。

接下来我们就可以使用 generateHashPassword() 方法为用户的原密码加盐。在这里我们第一层先使用 sha1() 对原密码进行一次 Hash ,然后使用这个 Hash 值拼接盐字符串后再进行 md5() 加密。最后加密出来的 Hash 值就很难在彩虹表中找到了。即使找到,也只是上层 sha1() 拼接盐字符串的内容,用户的原文密码毕竟还有一层加密。

剩下的就是我们进行出入库的注册登录测试了。

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
php复制代码$pdo = new PDO('mysql:host=localhost;dbname=blog_test;charset=utf8mb4', 'root', '');

$username = 'ZyBlog1';
$password = '123456';

// 注册
function register($username, $password)
{
global $pdo;

// 首先判断用户是否已注册
$pre = $pdo->prepare("SELECT COUNT(id) FROM zyblog_test_user WHERE username = :username");
$pre->bindParam(':username', $username);
$pre->execute();
$result = $pre->fetchColumn();

// 如果用户名存在,则无法注册
if ($result > 0) {
echo '用户名已注册!', PHP_EOL;
return 0;
}

// 生成salt
$salt = generateSalt();
// 密码进行加盐hash处理
$password = generateHashPassword($password, $salt);

// 插入新用户
$pre = $pdo->prepare("insert into zyblog_test_user(username, password, salt) values(?, ?, ?)");

$pre->bindValue(1, $username);
$pre->bindValue(2, $password);
$pre->bindValue(3, $salt);

$pre->execute();

return $pdo->lastInsertId();
}

$userId = register($username, $password);
if ($userId > 0) {
echo '注册成功!用户ID为:' . $userId, PHP_EOL;
}

// 注册成功!用户ID为:1

// 查询数据库中的数据
$sth = $pdo->prepare("SELECT * FROM zyblog_test_user");
$sth->execute();

$result = $sth->fetchAll(PDO::FETCH_ASSOC);
print_r($result);

// Array
// (
// [0] => Array
// (
// [id] => 1
// [username] => ZyBlog1
// [password] => bbff8283d0f90625015256b742b0e694
// [salt] => xOkb
// )

// )

// 登录时验证
function login($username, $password)
{
global $pdo;
// 先根据用户名查表
$pre = $pdo->prepare("SELECT * FROM zyblog_test_user WHERE username = :username");
$pre->bindParam(':username', $username);
$pre->execute();
$result = $pre->fetch(PDO::FETCH_ASSOC);

// 用户名存在并获得用户信息后
if ($result) {
// 根据用户表中的salt字段生成hash密码
$password = generateHashPassword($password, $result['salt']);

// 比对hash密码确认登录是否成功
if ($password == $result['password']) {
return true;
}
}
return false;
}

$isLogin = login($username, $password);
if ($isLogin) {
echo '登录成功!', PHP_EOL;
} else {
echo '登录失败,用户名或密码错误!', PHP_EOL;
}

// 登录成功!

代码还是比较简单的,在注册的时候,我们直接对用户密码进行加密后入库。主要关注的地方是在登录时,我们先根据用户名查找出对应的用户信息。然后将用户登录提交上来的原文密码进行加密,与数据库中的原文密码进行对比验证,密码验证成功即可判断用户登录成功。

另外还需要注意的是,我们的盐字符串也是要存到数据库中的。毕竟在登录的时候我们还是需要将用户的原文密码与这个盐字符串进行组合加密之后才能进行密码的匹配。

这样加密后的代码其实想通过彩虹表来破解基本上是很难了。在几年前 CSDN 的帐号泄露事件中,大家发现作为中文程序员世界最大的网站竟然是明文存储的密码,这就为攻击者提供了一大堆用户的明文常用密码。因为大家都喜欢用同一个用户名和密码注册不同的网站,所以不管其他怎么加盐都是没用的,毕竟原文密码是对的,拿到这样一个网站的数据库中的用户明文密码后,就可以通过这些密码去尝试这些用户在其他网站是不是用了相同的帐号名和密码注册了帐号。所以在日常生活中,我们重要的一些网站帐号、密码尽量还是使用不同的内容,如果记不住的话,可以使用一些带加密能力的记事本软件进行保存,这样会更加安全。而我们程序员,则应该始终都将用户的密码及重要信息进行加密处理,这是一种基本的职业规范。

测试代码:

github.com/zhangyue050…

本文转载自: 掘金

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

使用Redis,你必须知道的21个注意要点

发表于 2021-03-23

前言

最近在学习Redis相关知识,看了阿里的redis开发规范,以及Redis开发与运维这本书。分使用规范、有坑的命令、项目实战操作、运维配置四个方向。整理了使用Redis的21个注意点,希望对大家有帮助,一起学习哈

公众号:捡田螺的小男孩
image.png

1、Redis的使用规范

1.1、 key的规范要点

我们设计Redis的key的时候,要注意以下这几个点:

  • 以业务名为key前缀,用冒号隔开,以防止key冲突覆盖。如,live:rank:1
  • 确保key的语义清晰的情况下,key的长度尽量小于30个字符。
  • key禁止包含特殊字符,如空格、换行、单双引号以及其他转义字符。
  • Redis的key尽量设置ttl,以保证不使用的Key能被及时清理或淘汰。

1.2、value的规范要点

Redis的value值不可以随意设置的哦。

第一点,如果大量存储bigKey是会有问题的,会导致慢查询,内存增长过快等等。

  • 如果是String类型,单个value大小控制10k以内。
  • 如果是hash、list、set、zset类型,元素个数一般不超过5000。

第二点,要选择适合的数据类型。不少小伙伴只用Redis的String类型,上来就是set和get。实际上,Redis 提供了丰富的数据结构类型,有些业务场景,更适合hash、zset等其他数据结果。

image.png

反例:

1
2
sql复制代码set user:666:name jay
set user:666:age 18

正例

1
sql复制代码hmset user:666 name jay age 18

1.3. 给Key设置过期时间,同时注意不同业务的key,尽量过期时间分散一点

  • 因为Redis的数据是存在内存中的,而内存资源是很宝贵的。
  • 我们一般是把Redis当做缓存来用,而不是数据库,所以key的生命周期就不宜太长久啦。
  • 因此,你的key,一般建议用expire设置过期时间。

如果大量的key在某个时间点集中过期,到过期的那个时间点,Redis可能会存在卡顿,甚至出现缓存雪崩现象,因此一般不同业务的key,过期时间应该分散一些。有时候,同业务的,也可以在时间上加一个随机值,让过期时间分散一些。

1.4.建议使用批量操作提高效率

我们日常写SQL的时候,都知道,批量操作效率会更高,一次更新50条,比循环50次,每次更新一条效率更高。其实Redis操作命令也是这个道理。

Redis客户端执行一次命令可分为4个过程:1.发送命令-> 2.命令排队-> 3.命令执行-> 4. 返回结果。1和4 称为RRT(命令执行往返时间)。 Redis提供了批量操作命令,如mget、mset等,可有效节约RRT。但是呢,大部分的命令,是不支持批量操作的,比如hgetall,并没有mhgetall存在。Pipeline 则可以解决这个问题。

Pipeline是什么呢?它能将一组Redis命令进行组装,通过一次RTT传输给Redis,再将这组Redis命令的执行结果按顺序返回给客户端.

我们先来看下没有使用Pipeline执行了n条命令的模型:

image.png

使用Pipeline执行了n次命令,整个过程需要1次RTT,模型如下:

image.png

2、Redis 有坑的那些命令

2.1. 慎用O(n)复杂度命令,如hgetall、smember,lrange等

因为Redis是单线程执行命令的。hgetall、smember等命令时间复杂度为O(n),当n持续增加时,会导致 Redis CPU 持续飙高,阻塞其他命令的执行。

hgetall、smember,lrange等这些命令不是一定不能使用,需要综合评估数据量,明确n的值,再去决定。
比如hgetall,如果哈希元素n比较多的话,可以优先考虑使用hscan。

2.2 慎用Redis的monitor命令

Redis Monitor 命令用于实时打印出Redis服务器接收到的命令,如果我们想知道客户端对redis服务端做了哪些命令操作,就可以用Monitor 命令查看,但是它一般调试用而已,尽量不要在生产上用!因为monitor命令可能导致redis的内存持续飙升。

monitor的模型是酱紫的,它会将所有在Redis服务器执行的命令进行输出,一般来讲Redis服务器的QPS是很高的,也就是如果执行了monitor命令,Redis服务器在Monitor这个客户端的输出缓冲区又会有大量“存货”,也就占用了大量Redis内存。

image.png

2.3、生产环境不能使用 keys指令

Redis Keys 命令用于查找所有符合给定模式pattern的key。如果想查看Redis 某类型的key有多少个,不少小伙伴想到用keys命令,如下:

1
perl复制代码keys key前缀*

但是,redis的keys是遍历匹配的,复杂度是O(n),数据库数据越多就越慢。我们知道,redis是单线程的,如果数据比较多的话,keys指令就会导致redis线程阻塞,线上服务也会停顿了,直到指令执行完,服务才会恢复。因此,一般在生产环境,不要使用keys指令。官方文档也有声明:

Warning: consider KEYS as a command that should only be used in production environments with extreme care. It may ruin performance when it is executed against large databases. This command is intended for debugging and special operations, such as changing your keyspace layout. Don’t use KEYS in your regular application code. If you’re looking for a way to find keys in a subset of your keyspace, consider using sets.

其实,可以使用scan指令,它同keys命令一样提供模式匹配功能。它的复杂度也是 O(n),但是它通过游标分步进行,不会阻塞redis线程;但是会有一定的重复概率,需要在客户端做一次去重。

scan支持增量式迭代命令,增量式迭代命令也是有缺点的:举个例子, 使用 SMEMBERS 命令可以返回集合键当前包含的所有元素, 但是对于 SCAN 这类增量式迭代命令来说, 因为在对键进行增量式迭代的过程中, 键可能会被修改, 所以增量式迭代命令只能对被返回的元素提供有限的保证 。

2.4 禁止使用flushall、flushdb

  • Flushall 命令用于清空整个 Redis 服务器的数据(删除所有数据库的所有 key )。
  • Flushdb 命令用于清空当前数据库中的所有 key。

这两命令是原子性的,不会终止执行。一旦开始执行,不会执行失败的。

2.5 注意使用del命令

删除key你一般使用什么命令?是直接del?如果删除一个key,直接使用del命令当然没问题。但是,你想过del的时间复杂度是多少嘛?我们分情况探讨一下:

  • 如果删除一个String类型的key,时间复杂度就是O(1),可以直接del。
  • 如果删除一个List/Hash/Set/ZSet类型时,它的复杂度是O(n), n表示元素个数。

因此,如果你删除一个List/Hash/Set/ZSet类型的key时,元素越多,就越慢。当n很大时,要尤其注意,会阻塞主线程的。那么,如果不用del,我们应该怎么删除呢?

  • 如果是List类型,你可以执行lpop或者rpop,直到所有元素删除完成。
  • 如果是Hash/Set/ZSet类型,你可以先执行hscan/sscan/scan查询,再执行hdel/srem/zrem依次删除每个元素。

2.6 避免使用SORT、SINTER等复杂度过高的命令。

执行复杂度较高的命令,会消耗更多的 CPU 资源,会阻塞主线程。所以你要避免执行如SORT、SINTER、SINTERSTORE、ZUNIONSTORE、ZINTERSTORE等聚合命令,一般建议把它放到客户端来执行。

3、项目实战避坑操作

3.1 分布式锁使用的注意点

分布式锁其实就是,控制分布式系统不同进程共同访问共享资源的一种锁的实现。秒杀下单、抢红包等等业务场景,都需要用到分布式锁。我们经常使用Redis作为分布式锁,主要有这些注意点:

3.1.1 两个命令SETNX + EXPIRE分开写(典型错误实现范例)

1
2
3
4
5
6
7
8
9
10
csharp复制代码if(jedis.setnx(key_resource_id,lock_value) == 1){ //加锁
expire(key_resource_id,100); //设置过期时间
try {
do something //业务请求
}catch(){
  }
  finally {
jedis.del(key_resource_id); //释放锁
}
}

如果执行完setnx加锁,正要执行expire设置过期时间时,进程crash或者要重启维护了,那么这个锁就“长生不老”了,别的线程永远获取不到锁啦,所以一般分布式锁不能这么实现。

3.1.2 SETNX + value值是过期时间 (有些小伙伴是这么实现,有坑)

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
kotlin复制代码long expires = System.currentTimeMillis() + expireTime; //系统时间+设置的过期时间
String expiresStr = String.valueOf(expires);

// 如果当前锁不存在,返回加锁成功
if (jedis.setnx(key_resource_id, expiresStr) == 1) {
return true;
}
// 如果锁已经存在,获取锁的过期时间
String currentValueStr = jedis.get(key_resource_id);

// 如果获取到的过期时间,小于系统当前时间,表示已经过期
if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) {

// 锁已过期,获取上一个锁的过期时间,并设置现在锁的过期时间(不了解redis的getSet命令的小伙伴,可以去官网看下哈)
String oldValueStr = jedis.getSet(key_resource_id, expiresStr);

if (oldValueStr != null && oldValueStr.equals(currentValueStr)) {
// 考虑多线程并发的情况,只有一个线程的设置值和当前值相同,它才可以加锁
return true;
}
}

//其他情况,均返回加锁失败
return false;
}

这种方案的缺点:

  • 过期时间是客户端自己生成的,分布式环境下,每个客户端的时间必须同步
  • 没有保存持有者的唯一标识,可能被别的客户端释放/解锁。
  • 锁过期的时候,并发多个客户端同时请求过来,都执行了jedis.getSet(),最终只能有一个客户端加锁成功,但是该客户端锁的过期时间,可能被别的客户端覆盖。

3.1.3: SET的扩展命令(SET EX PX NX)(注意可能存在的问题)

1
2
3
4
5
6
7
8
9
csharp复制代码if(jedis.set(key_resource_id, lock_value, "NX", "EX", 100s) == 1){ //加锁
try {
do something //业务处理
}catch(){
  }
  finally {
jedis.del(key_resource_id); //释放锁
}
}

这个方案还是可能存在问题:

  • 锁过期释放了,业务还没执行完。
  • 锁被别的线程误删。

3.1.4 SET EX PX NX + 校验唯一随机值,再删除(解决了误删问题,还是存在锁过期,业务没执行完的问题)

1
2
3
4
5
6
7
8
9
10
11
12
csharp复制代码if(jedis.set(key_resource_id, uni_request_id, "NX", "EX", 100s) == 1){ //加锁
try {
do something //业务处理
}catch(){
  }
  finally {
//判断是不是当前线程加的锁,是才释放
if (uni_request_id.equals(jedis.get(key_resource_id))) {
jedis.del(lockKey); //释放锁
}
}
}

在这里,判断是不是当前线程加的锁和释放锁不是一个原子操作。如果调用jedis.del()释放锁的时候,可能这把锁已经不属于当前客户端,会解除他人加的锁。

image.png

一般也是用lua脚本代替。lua脚本如下:

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

3.1.5 Redisson框架 + Redlock算法 解决锁过期释放,业务没执行完问题+单机问题

Redisson 使用了一个Watch dog解决了锁过期释放,业务没执行完问题,Redisson原理图如下:
image.png

以上的分布式锁,还存在单机问题:
image.png

如果线程一在Redis的master节点上拿到了锁,但是加锁的key还没同步到slave节点。恰好这时,master节点发生故障,一个slave节点就会升级为master节点。线程二就可以获取同个key的锁啦,但线程一也已经拿到锁了,锁的安全性就没了。

针对单机问题,可以使用Redlock算法。有兴趣的朋友可以看下我这篇文章哈,七种方案!探讨Redis分布式锁的正确使用姿势

3.2 缓存一致性注意点

  • 如果是读请求,先读缓存,后读数据库
  • 如果写请求,先更新数据库,再写缓存
  • 每次更新数据后,需要清除缓存
  • 缓存一般都需要设置一定的过期失效
  • 一致性要求高的话,可以使用biglog+MQ保证。

有兴趣的朋友,可以看下我这篇文章哈:并发环境下,先操作数据库还是先操作缓存?

3.3 合理评估Redis容量,避免由于频繁set覆盖,导致之前设置的过期时间无效。

我们知道,Redis的所有数据结构类型,都是可以设置过期时间的。假设一个字符串,已经设置了过期时间,你再去重新设置它,就会导致之前的过期时间无效。

image.png

Redis setKey源码如下:

1
2
3
4
5
6
7
8
9
10
scss复制代码void setKey(redisDb *db,robj *key,robj *val) {
if(lookupKeyWrite(db,key)==NULL) {
dbAdd(db,key,val);
}else{
dbOverwrite(db,key,val);
}
incrRefCount(val);
removeExpire(db,key); //去掉过期时间
signalModifiedKey(db,key);
}

实际业务开发中,同时我们要合理评估Redis的容量,避免频繁set覆盖,导致设置了过期时间的key失效。新手小白容易犯这个错误。

3.4 缓存穿透问题

先来看一个常见的缓存使用方式:读请求来了,先查下缓存,缓存有值命中,就直接返回;缓存没命中,就去查数据库,然后把数据库的值更新到缓存,再返回。

image.png

缓存穿透:指查询一个一定不存在的数据,由于缓存是不命中时需要从数据库查询,查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到数据库去查询,进而给数据库带来压力。

通俗点说,读请求访问时,缓存和数据库都没有某个值,这样就会导致每次对这个值的查询请求都会穿透到数据库,这就是缓存穿透。

缓存穿透一般都是这几种情况产生的:

  • 业务不合理的设计,比如大多数用户都没开守护,但是你的每个请求都去缓存,查询某个userid查询有没有守护。
  • 业务/运维/开发失误的操作,比如缓存和数据库的数据都被误删除了。
  • 黑客非法请求攻击,比如黑客故意捏造大量非法请求,以读取不存在的业务数据。

如何避免缓存穿透呢? 一般有三种方法。

    1. 如果是非法请求,我们在API入口,对参数进行校验,过滤非法值。
    1. 如果查询数据库为空,我们可以给缓存设置个空值,或者默认值。但是如有有写请求进来的话,需要更新缓存哈,以保证缓存一致性,同时,最后给缓存设置适当的过期时间。(业务上比较常用,简单有效)
    1. 使用布隆过滤器快速判断数据是否存在。即一个查询请求过来时,先通过布隆过滤器判断值是否存在,存在才继续往下查。

布隆过滤器原理:它由初始值为0的位图数组和N个哈希函数组成。一个对一个key进行N个hash算法获取N个值,在比特数组中将这N个值散列后设定为1,然后查的时候如果特定的这几个位置都为1,那么布隆过滤器判断该key存在。

3.5 缓存雪奔问题

缓存雪奔: 指缓存中数据大批量到过期时间,而查询数据量巨大,请求都直接访问数据库,引起数据库压力过大甚至down机。

  • 缓存雪奔一般是由于大量数据同时过期造成的,对于这个原因,可通过均匀设置过期时间解决,即让过期时间相对离散一点。如采用一个较大固定值+一个较小的随机值,5小时+0到1800秒酱紫。
  • Redis 故障宕机也可能引起缓存雪奔。这就需要构造Redis高可用集群啦。

3.6 缓存击穿问题

缓存击穿: 指热点key在某个时间点过期的时候,而恰好在这个时间点对这个Key有大量的并发请求过来,从而大量的请求打到db。

缓存击穿看着有点像,其实它两区别是,缓存雪奔是指数据库压力过大甚至down机,缓存击穿只是大量并发请求到了DB数据库层面。可以认为击穿是缓存雪奔的一个子集吧。有些文章认为它俩区别,是区别在于击穿针对某一热点key缓存,雪奔则是很多key。

解决方案就有两种:

  • 1.使用互斥锁方案。缓存失效时,不是立即去加载db数据,而是先使用某些带成功返回的原子操作命令,如(Redis的setnx)去操作,成功的时候,再去加载db数据库数据和设置缓存。否则就去重试获取缓存。
  • 2. “永不过期”,是指没有设置过期时间,但是热点数据快要过期时,异步线程去更新和设置过期时间。

3.7、缓存热key问题

在Redis中,我们把访问频率高的key,称为热点key。如果某一热点key的请求到服务器主机时,由于请求量特别大,可能会导致主机资源不足,甚至宕机,从而影响正常的服务。

而热点Key是怎么产生的呢?主要原因有两个:

  • 用户消费的数据远大于生产的数据,如秒杀、热点新闻等读多写少的场景。
  • 请求分片集中,超过单Redi服务器的性能,比如固定名称key,Hash落入同一台服务器,瞬间访问量极大,超过机器瓶颈,产生热点Key问题。

那么在日常开发中,如何识别到热点key呢?

  • 凭经验判断哪些是热Key;
  • 客户端统计上报;
  • 服务代理层上报

如何解决热key问题?

  • Redis集群扩容:增加分片副本,均衡读流量;
  • 对热key进行hash散列,比如将一个key备份为key1,key2……keyN,同样的数据N个备份,N个备份分布到不同分片,访问时可随机访问N个备份中的一个,进一步分担读流量;
  • 使用二级缓存,即JVM本地缓存,减少Redis的读请求。
  1. Redis配置运维

4.1 使用长连接而不是短连接,并且合理配置客户端的连接池

  • 如果使用短连接,每次都需要过 TCP 三次握手、四次挥手,会增加耗时。然而长连接的话,它建立一次连接,redis的命令就能一直使用,酱紫可以减少建立redis连接时间。
  • 连接池可以实现在客户端建立多个连接并且不释放,需要使用连接的时候,不用每次都创建连接,节省了耗时。但是需要合理设置参数,长时间不操作 Redis时,也需及时释放连接资源。

4.2 只使用 db0

Redis-standalone架构禁止使用非db0.原因有两个

  • 一个连接,Redis执行命令select 0和select 1切换,会损耗新能。
  • Redis Cluster 只支持 db0,要迁移的话,成本高

4.3 设置maxmemory + 恰当的淘汰策略。

为了防止内存积压膨胀。比如有些时候,业务量大起来了,redis的key被大量使用,内存直接不够了,运维小哥哥也忘记加大内存了。难道redis直接这样挂掉?所以需要根据实际业务,选好maxmemory-policy(最大内存淘汰策略),设置好过期时间。一共有8种内存淘汰策略:

  • volatile-lru:当内存不足以容纳新写入数据时,从设置了过期时间的key中使用LRU(最近最少使用)算法进行淘汰;
  • allkeys-lru:当内存不足以容纳新写入数据时,从所有key中使用LRU(最近最少使用)算法进行淘汰。
  • volatile-lfu:4.0版本新增,当内存不足以容纳新写入数据时,在过期的key中,使用LFU算法进行删除key。
  • allkeys-lfu:4.0版本新增,当内存不足以容纳新写入数据时,从所有key中使用LFU算法进行淘汰;
  • volatile-random:当内存不足以容纳新写入数据时,从设置了过期时间的key中,随机淘汰数据;。
  • allkeys-random:当内存不足以容纳新写入数据时,从所有key中随机淘汰数据。
  • volatile-ttl:当内存不足以容纳新写入数据时,在设置了过期时间的key中,根据过期时间进行淘汰,越早过期的优先被淘汰;
  • noeviction:默认策略,当内存不足以容纳新写入数据时,新写入操作会报错。

4.4 开启 lazy-free 机制

Redis4.0+版本支持lazy-free机制,如果你的Redis还是有bigKey这种玩意存在,建议把lazy-free开启。当开启它后,Redis 如果删除一个 bigkey 时,释放内存的耗时操作,会放到后台线程去执行,减少对主线程的阻塞影响。

image.png

参考与感谢

  • Redis 千万不要乱用KEYS命令,不然会挨打的
  • 阿里云Redis开发规范
  • Redis 最佳实践指南:7个维度+43条使用规范
  • Redis的缓存穿透及解决方法——布隆过滤器BloomFilter
  • Redis 缓存性能实践及总结

本文转载自: 掘金

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

万字总结,体系化带你全面认识 Nginx ! 前言 Ngin

发表于 2021-03-23

前言

作为一名前端开发人员,你是不是经常碰到领导让你上服务器去修改 Nginx 配置,然而你会以“我是前端,这个我不会”为理由搪塞过去呢!今天就让我们一起告别这种尴尬,向“真正”的程序员迈进!!!

如果本文对你有所帮助,请点个👍 👍 👍 吧!

Nginx 概述

image.png

Nginx 是开源、高性能、高可靠的 Web 和反向代理服务器,而且支持热部署,几乎可以做到 7 * 24 小时不间断运行,即使运行几个月也不需要重新启动,还能在不间断服务的情况下对软件版本进行热更新。性能是 Nginx 最重要的考量,其占用内存少、并发能力强、能支持高达 5w 个并发连接数,最重要的是, Nginx 是免费的并可以商业化,配置使用也比较简单。

Nginx 特点

  • 高并发、高性能;
  • 模块化架构使得它的扩展性非常好;
  • 异步非阻塞的事件驱动模型这点和 Node.js 相似;
  • 相对于其它服务器来说它可以连续几个月甚至更长而不需要重启服务器使得它具有高可靠性;
  • 热部署、平滑升级;
  • 完全开源,生态繁荣;

Nginx 作用

Nginx 的最重要的几个使用场景:

  1. 静态资源服务,通过本地文件系统提供服务;
  2. 反向代理服务,延伸出包括缓存、负载均衡等;
  3. API 服务, OpenResty ;

对于前端来说 Node.js 并不陌生, Nginx 和 Node.js 的很多理念类似, HTTP 服务器、事件驱动、异步非阻塞等,且 Nginx 的大部分功能使用 Node.js 也可以实现,但 Nginx 和 Node.js 并不冲突,都有自己擅长的领域。 Nginx 擅长于底层服务器端资源的处理(静态资源处理转发、反向代理,负载均衡等), Node.js 更擅长上层具体业务逻辑的处理,两者可以完美组合。

用一张图表示:
未命名文件.png

Nginx 安装

本文演示的是 Linux centOS 7.x 的操作系统上安装 Nginx ,至于在其它操作系统上进行安装可以网上自行搜索,都非常简单的。

使用 yum 安装 Nginx :

1
bash复制代码yum install nginx -y

安装完成后,通过 rpm -ql nginx 命令查看 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
26
bash复制代码# Nginx配置文件
/etc/nginx/nginx.conf # nginx 主配置文件
/etc/nginx/nginx.conf.default

# 可执行程序文件
/usr/bin/nginx-upgrade
/usr/sbin/nginx

# nginx库文件
/usr/lib/systemd/system/nginx.service # 用于配置系统守护进程
/usr/lib64/nginx/modules # Nginx模块目录

# 帮助文档
/usr/share/doc/nginx-1.16.1
/usr/share/doc/nginx-1.16.1/CHANGES
/usr/share/doc/nginx-1.16.1/README
/usr/share/doc/nginx-1.16.1/README.dynamic
/usr/share/doc/nginx-1.16.1/UPGRADE-NOTES-1.6-to-1.10

# 静态资源目录
/usr/share/nginx/html/404.html
/usr/share/nginx/html/50x.html
/usr/share/nginx/html/index.html

# 存放Nginx日志文件
/var/log/nginx

主要关注的文件夹有两个:

  1. /etc/nginx/conf.d/ 是子配置项存放处, /etc/nginx/nginx.conf 主配置文件会默认把这个文件夹中所有子配置项都引入;
  2. /usr/share/nginx/html/ 静态文件都放在这个文件夹,也可以根据你自己的习惯放在其他地方;

Nginx 常用命令

systemctl 系统命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
bash复制代码# 开机配置
systemctl enable nginx # 开机自动启动
systemctl disable nginx # 关闭开机自动启动

# 启动Nginx
systemctl start nginx # 启动Nginx成功后,可以直接访问主机IP,此时会展示Nginx默认页面

# 停止Nginx
systemctl stop nginx

# 重启Nginx
systemctl restart nginx

# 重新加载Nginx
systemctl reload nginx

# 查看 Nginx 运行状态
systemctl status nginx

# 查看Nginx进程
ps -ef | grep nginx

# 杀死Nginx进程
kill -9 pid # 根据上面查看到的Nginx进程号,杀死Nginx进程,-9 表示强制结束进程

Nginx 应用程序命令:

1
2
3
4
5
6
bash复制代码nginx -s reload  # 向主进程发送信号,重新加载配置文件,热重启
nginx -s reopen # 重启 Nginx
nginx -s stop # 快速关闭
nginx -s quit # 等待工作进程处理完成后关闭
nginx -T # 查看当前 Nginx 最终的配置
nginx -t # 检查配置是否有问题

Nginx 核心配置

配置文件结构

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
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
bash复制代码# main段配置信息
user nginx; # 运行用户,默认即是nginx,可以不进行设置
worker_processes auto; # Nginx 进程数,一般设置为和 CPU 核数一样
error_log /var/log/nginx/error.log warn; # Nginx 的错误日志存放目录
pid /var/run/nginx.pid; # Nginx 服务启动时的 pid 存放位置

# events段配置信息
events {
use epoll; # 使用epoll的I/O模型(如果你不知道Nginx该使用哪种轮询方法,会自动选择一个最适合你操作系统的)
worker_connections 1024; # 每个进程允许最大并发数
}

# http段配置信息
# 配置使用最频繁的部分,代理、缓存、日志定义等绝大多数功能和第三方模块的配置都在这里设置
http {
# 设置日志模式
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 /var/log/nginx/access.log main; # Nginx访问日志存放位置

sendfile on; # 开启高效传输模式
tcp_nopush on; # 减少网络报文段的数量
tcp_nodelay on;
keepalive_timeout 65; # 保持连接的时间,也叫超时时间,单位秒
types_hash_max_size 2048;

include /etc/nginx/mime.types; # 文件扩展名与类型映射表
default_type application/octet-stream; # 默认文件类型

include /etc/nginx/conf.d/*.conf; # 加载子配置项

# server段配置信息
server {
listen 80; # 配置监听的端口
server_name localhost; # 配置的域名

# location段配置信息
location / {
root /usr/share/nginx/html; # 网站根目录
index index.html index.htm; # 默认首页文件
deny 172.168.22.11; # 禁止访问的ip地址,可以为all
allow 172.168.33.44;# 允许访问的ip地址,可以为all
}

error_page 500 502 503 504 /50x.html; # 默认50x对应的访问页面
error_page 400 404 error.html; # 同上
}
}
  • main 全局配置,对全局生效;
  • events 配置影响 Nginx 服务器与用户的网络连接;
  • http 配置代理,缓存,日志定义等绝大多数功能和第三方模块的配置;
  • server 配置虚拟主机的相关参数,一个 http 块中可以有多个 server 块;
  • location 用于配置匹配的 uri ;
  • upstream 配置后端服务器具体地址,负载均衡配置不可或缺的部分;

用一张图清晰的展示它的层级结构:
未命名文件 (4).png

配置文件 main 段核心参数

user

指定运行 Nginx 的 woker 子进程的属主和属组,其中组可以不指定。

1
2
3
bash复制代码user USERNAME [GROUP]

user nginx lion; # 用户是nginx;组是lion

pid

指定运行 Nginx master 主进程的 pid 文件存放路径。

1
bash复制代码pid /opt/nginx/logs/nginx.pid # master主进程的的pid存放在nginx.pid的文件

worker_rlimit_nofile_number

指定 worker 子进程可以打开的最大文件句柄数。

1
bash复制代码worker_rlimit_nofile 20480; # 可以理解成每个worker子进程的最大连接数量。

worker_rlimit_core

指定 worker 子进程异常终止后的 core 文件,用于记录分析问题。

1
2
bash复制代码worker_rlimit_core 50M; # 存放大小限制
working_directory /opt/nginx/tmp; # 存放目录

worker_processes_number

指定 Nginx 启动的 worker 子进程数量。

1
2
bash复制代码worker_processes 4; # 指定具体子进程数量
worker_processes auto; # 与当前cpu物理核心数一致

worker_cpu_affinity

将每个 worker 子进程与我们的 cpu 物理核心绑定。

1
bash复制代码worker_cpu_affinity 0001 0010 0100 1000; # 4个物理核心,4个worker子进程

未命名文件 (1).png

将每个 worker 子进程与特定 CPU 物理核心绑定,优势在于,避免同一个 worker 子进程在不同的 CPU 核心上切换,缓存失效,降低性能。但其并不能真正的避免进程切换。

worker_priority

指定 worker 子进程的 nice 值,以调整运行 Nginx 的优先级,通常设定为负值,以优先调用 Nginx 。

1
bash复制代码worker_priority -10; # 120-10=110,110就是最终的优先级

Linux 默认进程的优先级值是120,值越小越优先; nice 定范围为 -20 到 +19 。

[备注] 应用的默认优先级值是120加上 nice 值等于它最终的值,这个值越小,优先级越高。

worker_shutdown_timeout

指定 worker 子进程优雅退出时的超时时间。

1
bash复制代码worker_shutdown_timeout 5s;

timer_resolution

worker 子进程内部使用的计时器精度,调整时间间隔越大,系统调用越少,有利于性能提升;反之,系统调用越多,性能下降。

1
bash复制代码timer_resolution 100ms;

在 Linux 系统中,用户需要获取计时器时需要向操作系统内核发送请求,有请求就必然会有开销,因此这个间隔越大开销就越小。

daemon

指定 Nginx 的运行方式,前台还是后台,前台用于调试,后台用于生产。

1
bash复制代码daemon off; # 默认是on,后台运行模式

配置文件 events 段核心参数

use

Nginx 使用何种事件驱动模型。

1
2
3
bash复制代码use method; # 不推荐配置它,让nginx自己选择

method 可选值为:select、poll、kqueue、epoll、/dev/poll、eventport

worker_connections

worker 子进程能够处理的最大并发连接数。

1
bash复制代码worker_connections 1024 # 每个子进程的最大连接数为1024

accept_mutex

是否打开负载均衡互斥锁。

1
bash复制代码accept_mutex on # 默认是off关闭的,这里推荐打开

server_name 指令

指定虚拟主机域名。

1
2
3
4
bash复制代码server_name name1 name2 name3

# 示例:
server_name www.nginx.com;

域名匹配的四种写法:

  • 精确匹配: server_name www.nginx.com ;
  • 左侧通配: server_name *.nginx.com ;
  • 右侧统配: server_name www.nginx.* ;
  • 正则匹配: server_name ~^www\.nginx\.*$ ;

匹配优先级:精确匹配 > 左侧通配符匹配 > 右侧通配符匹配 > 正则表达式匹配

server_name 配置实例:

1、配置本地 DNS 解析 vim /etc/hosts ( macOS 系统)

1
2
3
4
5
6
7
bash复制代码# 添加如下内容,其中 121.42.11.34 是阿里云服务器IP地址
121.42.11.34 www.nginx-test.com
121.42.11.34 mail.nginx-test.com
121.42.11.34 www.nginx-test.org
121.42.11.34 doc.nginx-test.com
121.42.11.34 www.nginx-test.cn
121.42.11.34 fe.nginx-test.club

[注意] 这里使用的是虚拟域名进行测试,因此需要配置本地 DNS 解析,如果使用阿里云上购买的域名,则需要在阿里云上设置好域名解析。

2、配置阿里云 Nginx ,vim /etc/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
35
36
37
38
39
40
41
bash复制代码# 这里只列举了http端中的sever端配置

# 左匹配
server {
listen 80;
server_name *.nginx-test.com;
root /usr/share/nginx/html/nginx-test/left-match/;
location / {
index index.html;
}
}

# 正则匹配
server {
listen 80;
server_name ~^.*\.nginx-test\..*$;
root /usr/share/nginx/html/nginx-test/reg-match/;
location / {
index index.html;
}
}

# 右匹配
server {
listen 80;
server_name www.nginx-test.*;
root /usr/share/nginx/html/nginx-test/right-match/;
location / {
index index.html;
}
}

# 完全匹配
server {
listen 80;
server_name www.nginx-test.com;
root /usr/share/nginx/html/nginx-test/all-match/;
location / {
index index.html;
}
}

3、访问分析

  • 当访问 www.nginx-test.com 时,都可以被匹配上,因此选择优先级最高的“完全匹配”;
  • 当访问 mail.nginx-test.com 时,会进行“左匹配”;
  • 当访问 www.nginx-test.org 时,会进行“右匹配”;
  • 当访问 doc.nginx-test.com 时,会进行“左匹配”;
  • 当访问 www.nginx-test.cn 时,会进行“右匹配”;
  • 当访问 fe.nginx-test.club 时,会进行“正则匹配”;

root

指定静态资源目录位置,它可以写在 http 、 server 、 location 等配置中。

1
2
3
4
5
6
7
8
bash复制代码root path

例如:
location /image {
root /opt/nginx/static;
}

当用户访问 www.test.com/image/1.png 时,实际在服务器找的路径是 /opt/nginx/static/image/1.png

[注意] root 会将定义路径与 URI 叠加, alias 则只取定义路径。

alias

它也是指定静态资源目录位置,它只能写在 location 中。

1
2
3
4
5
bash复制代码location /image {
alias /opt/nginx/static/image/;
}

当用户访问 www.test.com/image/1.png 时,实际在服务器找的路径是 /opt/nginx/static/image/1.png

[注意] 使用 alias 末尾一定要添加 / ,并且它只能位于 location 中。

location

配置路径。

1
2
3
bash复制代码location [ = | ~ | ~* | ^~ ] uri {
...
}

匹配规则:

  • = 精确匹配;
  • ~ 正则匹配,区分大小写;
  • ~* 正则匹配,不区分大小写;
  • ^~ 匹配到即停止搜索;

匹配优先级: = > ^~ > ~ > ~* > 不带任何字符。

实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
bash复制代码server {
listen 80;
server_name www.nginx-test.com;

# 只有当访问 www.nginx-test.com/match_all/ 时才会匹配到/usr/share/nginx/html/match_all/index.html
location = /match_all/ {
root /usr/share/nginx/html
index index.html
}

# 当访问 www.nginx-test.com/1.jpg 等路径时会去 /usr/share/nginx/images/1.jpg 找对应的资源
location ~ \.(jpeg|jpg|png|svg)$ {
root /usr/share/nginx/images;
}

# 当访问 www.nginx-test.com/bbs/ 时会匹配上 /usr/share/nginx/html/bbs/index.html
location ^~ /bbs/ {
root /usr/share/nginx/html;
index index.html index.htm;
}
}

location 中的反斜线

1
2
3
4
5
6
7
bash复制代码location /test {
...
}

location /test/ {
...
}
  • 不带 / 当访问 www.nginx-test.com/test 时, Nginx 先找是否有 test 目录,如果有则找 test 目录下的 index.html ;如果没有 test 目录, nginx 则会找是否有 test 文件。
  • 带 / 当访问 www.nginx-test.com/test 时, Nginx 先找是否有 test 目录,如果有则找 test 目录下的 index.html ,如果没有它也不会去找是否存在 test 文件。

return

停止处理请求,直接返回响应码或重定向到其他 URL ;执行 return 指令后, location 中后续指令将不会被执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
bash复制代码return code [text];
return code URL;
return URL;

例如:
location / {
return 404; # 直接返回状态码
}

location / {
return 404 "pages not found"; # 返回状态码 + 一段文本
}

location / {
return 302 /bbs ; # 返回状态码 + 重定向地址
}

location / {
return https://www.baidu.com ; # 返回重定向地址
}

rewrite

根据指定正则表达式匹配规则,重写 URL 。

1
2
3
4
5
bash复制代码语法:rewrite 正则表达式 要替换的内容 [flag];

上下文:server、location、if

示例:rewirte /images/(.*\.jpg)$ /pic/$1; # $1是前面括号(.*\.jpg)的反向引用

flag 可选值的含义:

  • last 重写后的 URL 发起新请求,再次进入 server 段,重试 location 的中的匹配;
  • break 直接使用重写后的 URL ,不再匹配其它 location 中语句;
  • redirect 返回302临时重定向;
  • permanent 返回301永久重定向;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
bash复制代码server{
listen 80;
server_name fe.lion.club; # 要在本地hosts文件进行配置
root html;
location /search {
rewrite ^/(.*) https://www.baidu.com redirect;
}

location /images {
rewrite /images/(.*) /pics/$1;
}

location /pics {
rewrite /pics/(.*) /photos/$1;
}

location /photos {

}
}

按照这个配置我们来分析:

  • 当访问 fe.lion.club/search 时,会自动帮我们重定向到 https://www.baidu.com。
  • 当访问 fe.lion.club/images/1.jpg 时,第一步重写 URL 为 fe.lion.club/pics/1.jpg ,找到 pics 的 location ,继续重写 URL 为 fe.lion.club/photos/1.jpg ,找到 /photos 的 location 后,去 html/photos 目录下寻找 1.jpg 静态资源。

if 指令

1
2
3
4
5
6
7
8
bash复制代码语法:if (condition) {...}

上下文:server、location

示例:
if($http_user_agent ~ Chrome){
rewrite /(.*)/browser/$1 break;
}

condition 判断条件:

  • $variable 仅为变量时,值为空或以0开头字符串都会被当做 false 处理;
  • = 或 != 相等或不等;
  • ~ 正则匹配;
  • ! ~ 非正则匹配;
  • ~* 正则匹配,不区分大小写;
  • -f 或 ! -f 检测文件存在或不存在;
  • -d 或 ! -d 检测目录存在或不存在;
  • -e 或 ! -e 检测文件、目录、符号链接等存在或不存在;
  • -x 或 ! -x 检测文件可以执行或不可执行;

实例:

1
2
3
4
5
6
7
8
9
10
11
bash复制代码server {
listen 8080;
server_name localhost;
root html;

location / {
if ( $uri = "/images/" ){
rewrite (.*) /pics/ break;
}
}
}

当访问 localhost:8080/images/ 时,会进入 if 判断里面执行 rewrite 命令。

autoindex

用户请求以 / 结尾时,列出目录结构,可以用于快速搭建静态资源下载网站。

autoindex.conf 配置信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
bash复制代码server {
listen 80;
server_name fe.lion-test.club;

location /download/ {
root /opt/source;

autoindex on; # 打开 autoindex,,可选参数有 on | off
autoindex_exact_size on; # 修改为off,以KB、MB、GB显示文件大小,默认为on,以bytes显示出⽂件的确切⼤⼩
autoindex_format html; # 以html的方式进行格式化,可选参数有 html | json | xml
autoindex_localtime off; # 显示的⽂件时间为⽂件的服务器时间。默认为off,显示的⽂件时间为GMT时间
}
}

当访问 fe.lion.com/download/ 时,会把服务器 /opt/source/download/ 路径下的文件展示出来,如下图所示:

image.png

变量

Nginx 提供给使用者的变量非常多,但是终究是一个完整的请求过程所产生数据, Nginx 将这些数据以变量的形式提供给使用者。

下面列举些项目中常用的变量:

变量名 含义
remote_addr 客户端 IP 地址
remote_port 客户端端口
server_addr 服务端 IP 地址
server_port 服务端端口
server_protocol 服务端协议
binary_remote_addr 二进制格式的客户端 IP 地址
connection TCP 连接的序号,递增
connection_request TCP 连接当前的请求数量
uri 请求的URL,不包含参数
request_uri 请求的URL,包含参数
scheme 协议名, http 或 https
request_method 请求方法
request_length 全部请求的长度,包含请求行、请求头、请求体
args 全部参数字符串
arg_参数名 获取特定参数值
is_args URL 中是否有参数,有的话返回 ? ,否则返回空
query_string 与 args 相同
host 请求信息中的 Host ,如果请求中没有 Host 行,则在请求头中找,最后使用 nginx 中设置的 server_name 。
http_user_agent 用户浏览器
http_referer 从哪些链接过来的请求
http_via 每经过一层代理服务器,都会添加相应的信息
http_cookie 获取用户 cookie
request_time 处理请求已消耗的时间
https 是否开启了 https ,是则返回 on ,否则返回空
request_filename 磁盘文件系统待访问文件的完整路径
document_root 由 URI 和 root/alias 规则生成的文件夹路径
limit_rate 返回响应时的速度上限值

实例演示 var.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
bash复制代码server{
listen 8081;
server_name var.lion-test.club;
root /usr/share/nginx/html;
location / {
return 200 "
remote_addr: $remote_addr
remote_port: $remote_port
server_addr: $server_addr
server_port: $server_port
server_protocol: $server_protocol
binary_remote_addr: $binary_remote_addr
connection: $connection
uri: $uri
request_uri: $request_uri
scheme: $scheme
request_method: $request_method
request_length: $request_length
args: $args
arg_pid: $arg_pid
is_args: $is_args
query_string: $query_string
host: $host
http_user_agent: $http_user_agent
http_referer: $http_referer
http_via: $http_via
request_time: $request_time
https: $https
request_filename: $request_filename
document_root: $document_root
";
}
}

当我们访问 http://var.lion-test.club:8081/test?pid=121414&cid=sadasd 时,由于 Nginx 中写了 return 方法,因此 chrome 浏览器会默认为我们下载一个文件,下面展示的就是下载的文件内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
bash复制代码remote_addr: 27.16.220.84
remote_port: 56838
server_addr: 172.17.0.2
server_port: 8081
server_protocol: HTTP/1.1
binary_remote_addr: 茉
connection: 126
uri: /test/
request_uri: /test/?pid=121414&cid=sadasd
scheme: http
request_method: GET
request_length: 518
args: pid=121414&cid=sadasd
arg_pid: 121414
is_args: ?
query_string: pid=121414&cid=sadasd
host: var.lion-test.club
http_user_agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.182 Safari/537.36
http_referer:
http_via:
request_time: 0.000
https:
request_filename: /usr/share/nginx/html/test/
document_root: /usr/share/nginx/html

Nginx 的配置还有非常多,以上只是罗列了一些常用的配置,在实际项目中还是要学会查阅文档。

Nginx 应用核心概念

代理是在服务器和客户端之间假设的一层服务器,代理将接收客户端的请求并将它转发给服务器,然后将服务端的响应转发给客户端。

不管是正向代理还是反向代理,实现的都是上面的功能。

image.png

正向代理

正向代理,意思是一个位于客户端和原始服务器(origin server)之间的服务器,为了从原始服务器取得内容,客户端向代理发送一个请求并指定目标(原始服务器),然后代理向原始服务器转交请求并将获得的内容返回给客户端。

正向代理是为我们服务的,即为客户端服务的,客户端可以根据正向代理访问到它本身无法访问到的服务器资源。

正向代理对我们是透明的,对服务端是非透明的,即服务端并不知道自己收到的是来自代理的访问还是来自真实客户端的访问。

反向代理

  • 反向代理*(Reverse Proxy)方式是指以代理服务器来接受internet上的连接请求,然后将请求转发给内部网络上的服务器,并将从服务器上得到的结果返回给internet上请求连接的客户端,此时代理服务器对外就表现为一个反向代理服务器。

反向代理是为服务端服务的,反向代理可以帮助服务器接收来自客户端的请求,帮助服务器做请求转发,负载均衡等。

反向代理对服务端是透明的,对我们是非透明的,即我们并不知道自己访问的是代理服务器,而服务器知道反向代理在为他服务。

反向代理的优势:

  • 隐藏真实服务器;
  • 负载均衡便于横向扩充后端动态服务;
  • 动静分离,提升系统健壮性;

那么“动静分离”是什么?负载均衡又是什么?

动静分离

动静分离是指在 web 服务器架构中,将静态页面与动态页面或者静态内容接口和动态内容接口分开不同系统访问的架构设计方法,进而提示整个服务的访问性和可维护性。

未命名文件.png

一般来说,都需要将动态资源和静态资源分开,由于 Nginx 的高并发和静态资源缓存等特性,经常将静态资源部署在 Nginx 上。如果请求的是静态资源,直接到静态资源目录获取资源,如果是动态资源的请求,则利用反向代理的原理,把请求转发给对应后台应用去处理,从而实现动静分离。

使用前后端分离后,可以很大程度提升静态资源的访问速度,即使动态服务不可用,静态资源的访问也不会受到影响。

负载均衡

一般情况下,客户端发送多个请求到服务器,服务器处理请求,其中一部分可能要操作一些资源比如数据库、静态资源等,服务器处理完毕后,再将结果返回给客户端。

这种模式对于早期的系统来说,功能要求不复杂,且并发请求相对较少的情况下还能胜任,成本也低。随着信息数量不断增长,访问量和数据量飞速增长,以及系统业务复杂度持续增加,这种做法已无法满足要求,并发量特别大时,服务器容易崩。

很明显这是由于服务器性能的瓶颈造成的问题,除了堆机器之外,最重要的做法就是负载均衡。

请求爆发式增长的情况下,单个机器性能再强劲也无法满足要求了,这个时候集群的概念产生了,单个服务器解决不了的问题,可以使用多个服务器,然后将请求分发到各个服务器上,将负载分发到不同的服务器,这就是负载均衡,核心是「分摊压力」。 Nginx 实现负载均衡,一般来说指的是将请求转发给服务器集群。

举个具体的例子,晚高峰乘坐地铁的时候,入站口经常会有地铁工作人员大喇叭“请走 B 口, B 口人少车空….”,这个工作人员的作用就是负载均衡。
未命名文件 (3).png

Nginx 实现负载均衡的策略:

  • 轮询策略:默认情况下采用的策略,将所有客户端请求轮询分配给服务端。这种策略是可以正常工作的,但是如果其中某一台服务器压力太大,出现延迟,会影响所有分配在这台服务器下的用户。
  • 最小连接数策略:将请求优先分配给压力较小的服务器,它可以平衡每个队列的长度,并避免向压力大的服务器添加更多的请求。
  • 最快响应时间策略:优先分配给响应时间最短的服务器。
  • 客户端 ip 绑定策略:来自同一个 ip 的请求永远只分配一台服务器,有效解决了动态网页存在的 session 共享问题。

Nginx 实战配置

在配置反向代理和负载均衡等等功能之前,有两个核心模块是我们必须要掌握的,这两个模块应该说是 Nginx 应用配置中的核心,它们分别是: upstream 、proxy_pass 。

upstream

用于定义上游服务器(指的就是后台提供的应用服务器)的相关信息。

未命名文件 (2).png

1
2
3
4
5
6
7
8
9
10
bash复制代码语法:upstream name {
...
}

上下文:http

示例:
upstream back_end_server{
server 192.168.100.33:8081
}

在 upstream 内可使用的指令:

  • server 定义上游服务器地址;
  • zone 定义共享内存,用于跨 worker 子进程;
  • keepalive 对上游服务启用长连接;
  • keepalive_requests 一个长连接最多请求 HTTP 的个数;
  • keepalive_timeout 空闲情形下,一个长连接的超时时长;
  • hash 哈希负载均衡算法;
  • ip_hash 依据 IP 进行哈希计算的负载均衡算法;
  • least_conn 最少连接数负载均衡算法;
  • least_time 最短响应时间负载均衡算法;
  • random 随机负载均衡算法;

server

定义上游服务器地址。

1
2
3
bash复制代码语法:server address [parameters]

上下文:upstream

parameters 可选值:

  • weight=number 权重值,默认为1;
  • max_conns=number 上游服务器的最大并发连接数;
  • fail_timeout=time 服务器不可用的判定时间;
  • max_fails=numer 服务器不可用的检查次数;
  • backup 备份服务器,仅当其他服务器都不可用时才会启用;
  • down 标记服务器长期不可用,离线维护;

keepalive

限制每个 worker 子进程与上游服务器空闲长连接的最大数量。

1
2
3
4
5
bash复制代码keepalive connections;

上下文:upstream

示例:keepalive 16;

keepalive_requests

单个长连接可以处理的最多 HTTP 请求个数。

1
2
3
4
5
bash复制代码语法:keepalive_requests number;

默认值:keepalive_requests 100;

上下文:upstream

keepalive_timeout

空闲长连接的最长保持时间。

1
2
3
4
5
bash复制代码语法:keepalive_timeout time;

默认值:keepalive_timeout 60s;

上下文:upstream

配置实例

1
2
3
4
5
6
bash复制代码upstream back_end{
server 127.0.0.1:8081 weight=3 max_conns=1000 fail_timeout=10s max_fails=2;
keepalive 32;
keepalive_requests 50;
keepalive_timeout 30s;
}

proxy_pass

用于配置代理服务器。

1
2
3
4
5
6
7
bash复制代码语法:proxy_pass URL;

上下文:location、if、limit_except

示例:
proxy_pass http://127.0.0.1:8081
proxy_pass http://127.0.0.1:8081/proxy

URL 参数原则

  1. URL 必须以 http 或 https 开头;
  2. URL 中可以携带变量;
  3. URL 中是否带 URI ,会直接影响发往上游请求的 URL ;

接下来让我们来看看两种常见的 URL 用法:

  1. proxy_pass http://192.168.100.33:8081
  2. proxy_pass http://192.168.100.33:8081/

这两种用法的区别就是带 / 和不带 / ,在配置代理时它们的区别可大了:

  • 不带 / 意味着 Nginx 不会修改用户 URL ,而是直接透传给上游的应用服务器;
  • 带 / 意味着 Nginx 会修改用户 URL ,修改方法是将 location 后的 URL 从用户 URL 中删除;

不带 / 的用法:

1
2
3
bash复制代码location /bbs/{
proxy_pass http://127.0.0.1:8080;
}

分析:

  1. 用户请求 URL : /bbs/abc/test.html
  2. 请求到达 Nginx 的 URL : /bbs/abc/test.html
  3. 请求到达上游应用服务器的 URL : /bbs/abc/test.html

带 / 的用法:

1
2
3
bash复制代码location /bbs/{
proxy_pass http://127.0.0.1:8080/;
}

分析:

  1. 用户请求 URL : /bbs/abc/test.html
  2. 请求到达 Nginx 的 URL : /bbs/abc/test.html
  3. 请求到达上游应用服务器的 URL : /abc/test.html

并没有拼接上 /bbs ,这点和 root 与 alias 之间的区别是保持一致的。

配置反向代理

这里为了演示更加接近实际,作者准备了两台云服务器,它们的公网 IP 分别是: 121.42.11.34 与 121.5.180.193 。

我们把 121.42.11.34 服务器作为上游服务器,做如下配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
bash复制代码# /etc/nginx/conf.d/proxy.conf
server{
listen 8080;
server_name localhost;

location /proxy/ {
root /usr/share/nginx/html/proxy;
index index.html;
}
}

# /usr/share/nginx/html/proxy/index.html
<h1> 121.42.11.34 proxy html </h1>

配置完成后重启 Nginx 服务器 nginx -s reload 。

把 121.5.180.193 服务器作为代理服务器,做如下配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
bash复制代码# /etc/nginx/conf.d/proxy.conf
upstream back_end {
server 121.42.11.34:8080 weight=2 max_conns=1000 fail_timeout=10s max_fails=3;
keepalive 32;
keepalive_requests 80;
keepalive_timeout 20s;
}

server {
listen 80;
server_name proxy.lion.club;
location /proxy {
proxy_pass http://back_end/proxy;
}
}

本地机器要访问 proxy.lion.club 域名,因此需要配置本地 hosts ,通过命令:vim /etc/hosts 进入配置文件,添加如下内容:

1
bash复制代码121.5.180.193 proxy.lion.club

image.png

分析:

  1. 当访问 proxy.lion.club/proxy 时通过 upstream 的配置找到 121.42.11.34:8080 ;
  2. 因此访问地址变为 http://121.42.11.34:8080/proxy ;
  3. 连接到 121.42.11.34 服务器,找到 8080 端口提供的 server ;
  4. 通过 server 找到 /usr/share/nginx/html/proxy/index.html 资源,最终展示出来。

配置负载均衡

配置负载均衡主要是要使用 upstream 指令。

我们把 121.42.11.34 服务器作为上游服务器,做如下配置( /etc/nginx/conf.d/balance.conf ):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
bash复制代码server{
listen 8020;
location / {
return 200 'return 8020 \n';
}
}

server{
listen 8030;
location / {
return 200 'return 8030 \n';
}
}

server{
listen 8040;
location / {
return 200 'return 8040 \n';
}
}

配置完成后:

  1. nginx -t 检测配置是否正确;
  2. nginx -s reload 重启 Nginx 服务器;
  3. 执行 ss -nlt 命令查看端口是否被占用,从而判断 Nginx 服务是否正确启动。

把 121.5.180.193 服务器作为代理服务器,做如下配置( /etc/nginx/conf.d/balance.conf ):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
bash复制代码upstream demo_server {
server 121.42.11.34:8020;
server 121.42.11.34:8030;
server 121.42.11.34:8040;
}

server {
listen 80;
server_name balance.lion.club;

location /balance/ {
proxy_pass http://demo_server;
}
}

配置完成后重启 Nginx 服务器。并且在需要访问的客户端配置好 ip 和域名的映射关系。

1
2
3
bash复制代码# /etc/hosts

121.5.180.193 balance.lion.club

在客户端机器执行 curl http://balance.lion.club/balance/ 命令:

image.png

不难看出,负载均衡的配置已经生效了,每次给我们分发的上游服务器都不一样。就是通过简单的轮询策略进行上游服务器分发。

接下来,我们再来了解下 Nginx 的其它分发策略。

hash 算法

通过制定关键字作为 hash key ,基于 hash 算法映射到特定的上游服务器中。关键字可以包含有变量、字符串。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
bash复制代码upstream demo_server {
hash $request_uri;
server 121.42.11.34:8020;
server 121.42.11.34:8030;
server 121.42.11.34:8040;
}

server {
listen 80;
server_name balance.lion.club;

location /balance/ {
proxy_pass http://demo_server;
}
}

hash $request_uri 表示使用 request_uri 变量作为 hash 的 key 值,只要访问的 URI 保持不变,就会一直分发给同一台服务器。

ip_hash

根据客户端的请求 ip 进行判断,只要 ip 地址不变就永远分配到同一台主机。它可以有效解决后台服务器 session 保持的问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
bash复制代码upstream demo_server {
ip_hash;
server 121.42.11.34:8020;
server 121.42.11.34:8030;
server 121.42.11.34:8040;
}

server {
listen 80;
server_name balance.lion.club;

location /balance/ {
proxy_pass http://demo_server;
}
}

最少连接数算法

各个 worker 子进程通过读取共享内存的数据,来获取后端服务器的信息。来挑选一台当前已建立连接数最少的服务器进行分配请求。

1
2
3
bash复制代码语法:least_conn;

上下文:upstream;

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
bash复制代码upstream demo_server {
zone test 10M; # zone可以设置共享内存空间的名字和大小
least_conn;
server 121.42.11.34:8020;
server 121.42.11.34:8030;
server 121.42.11.34:8040;
}

server {
listen 80;
server_name balance.lion.club;

location /balance/ {
proxy_pass http://demo_server;
}
}

最后你会发现,负载均衡的配置其实一点都不复杂。

配置缓存

缓存可以非常有效的提升性能,因此不论是客户端(浏览器),还是代理服务器( Nginx ),乃至上游服务器都多少会涉及到缓存。可见缓存在每个环节都是非常重要的。下面让我们来学习 Nginx 中如何设置缓存策略。

proxy_cache

存储一些之前被访问过、而且可能将要被再次访问的资源,使用户可以直接从代理服务器获得,从而减少上游服务器的压力,加快整个访问速度。

1
2
3
4
5
bash复制代码语法:proxy_cache zone | off ; # zone 是共享内存的名称

默认值:proxy_cache off;

上下文:http、server、location

proxy_cache_path

设置缓存文件的存放路径。

1
2
3
4
5
bash复制代码语法:proxy_cache_path path [level=levels] ...可选参数省略,下面会详细列举

默认值:proxy_cache_path off

上下文:http

参数含义:

  • path 缓存文件的存放路径;
  • level path 的目录层级;
  • keys_zone 设置共享内存;
  • inactive 在指定时间内没有被访问,缓存会被清理,默认10分钟;

proxy_cache_key

设置缓存文件的 key 。

1
2
3
4
5
bash复制代码语法:proxy_cache_key

默认值:proxy_cache_key $scheme$proxy_host$request_uri;

上下文:http、server、location

proxy_cache_valid

配置什么状态码可以被缓存,以及缓存时长。

1
2
3
4
5
bash复制代码语法:proxy_cache_valid [code...] time;

上下文:http、server、location

配置示例:proxy_cache_valid 200 304 2m;; # 说明对于状态为200和304的缓存文件的缓存时间是2分钟

proxy_no_cache

定义相应保存到缓存的条件,如果字符串参数的至少一个值不为空且不等于“ 0”,则将不保存该响应到缓存。

1
2
3
4
5
bash复制代码语法:proxy_no_cache string;

上下文:http、server、location

示例:proxy_no_cache $http_pragma $http_authorization;

proxy_cache_bypass

定义条件,在该条件下将不会从缓存中获取响应。

1
2
3
4
5
bash复制代码语法:proxy_cache_bypass string;

上下文:http、server、location

示例:proxy_cache_bypass $http_pragma $http_authorization;

upstream_cache_status 变量

它存储了缓存是否命中的信息,会设置在响应头信息中,在调试中非常有用。

1
2
3
4
5
6
7
bash复制代码MISS: 未命中缓存
HIT: 命中缓存
EXPIRED: 缓存过期
STALE: 命中了陈旧缓存
REVALIDDATED: Nginx验证陈旧缓存依然有效
UPDATING: 内容陈旧,但正在更新
BYPASS: X响应从原始服务器获取

配置实例

我们把 121.42.11.34 服务器作为上游服务器,做如下配置( /etc/nginx/conf.d/cache.conf ):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
bash复制代码server {
listen 1010;
root /usr/share/nginx/html/1010;
location / {
index index.html;
}
}

server {
listen 1020;
root /usr/share/nginx/html/1020;
location / {
index index.html;
}
}

把 121.5.180.193 服务器作为代理服务器,做如下配置( /etc/nginx/conf.d/cache.conf ):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
bash复制代码proxy_cache_path /etc/nginx/cache_temp levels=2:2 keys_zone=cache_zone:30m max_size=2g inactive=60m use_temp_path=off;

upstream cache_server{
server 121.42.11.34:1010;
server 121.42.11.34:1020;
}

server {
listen 80;
server_name cache.lion.club;
location / {
proxy_cache cache_zone; # 设置缓存内存,上面配置中已经定义好的
proxy_cache_valid 200 5m; # 缓存状态为200的请求,缓存时长为5分钟
proxy_cache_key $request_uri; # 缓存文件的key为请求的URI
add_header Nginx-Cache-Status $upstream_cache_status # 把缓存状态设置为头部信息,响应给客户端
proxy_pass http://cache_server; # 代理转发
}
}

缓存就是这样配置,我们可以在 /etc/nginx/cache_temp 路径下找到相应的缓存文件。

对于一些实时性要求非常高的页面或数据来说,就不应该去设置缓存,下面来看看如何配置不缓存的内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
bash复制代码...

server {
listen 80;
server_name cache.lion.club;
# URI 中后缀为 .txt 或 .text 的设置变量值为 "no cache"
if ($request_uri ~ \.(txt|text)$) {
set $cache_name "no cache"
}

location / {
proxy_no_cache $cache_name; # 判断该变量是否有值,如果有值则不进行缓存,如果没有值则进行缓存
proxy_cache cache_zone; # 设置缓存内存
proxy_cache_valid 200 5m; # 缓存状态为200的请求,缓存时长为5分钟
proxy_cache_key $request_uri; # 缓存文件的key为请求的URI
add_header Nginx-Cache-Status $upstream_cache_status # 把缓存状态设置为头部信息,响应给客户端
proxy_pass http://cache_server; # 代理转发
}
}

HTTPS

在学习如何配置 HTTPS 之前,我们先来简单回顾下 HTTPS 的工作流程是怎么样的?它是如何进行加密保证安全的?

HTTPS 工作流程

  1. 客户端(浏览器)访问 https://www.baidu.com 百度网站;
  2. 百度服务器返回 HTTPS 使用的 CA 证书;
  3. 浏览器验证 CA 证书是否为合法证书;
  4. 验证通过,证书合法,生成一串随机数并使用公钥(证书中提供的)进行加密;
  5. 发送公钥加密后的随机数给百度服务器;
  6. 百度服务器拿到密文,通过私钥进行解密,获取到随机数(公钥加密,私钥解密,反之也可以);
  7. 百度服务器把要发送给浏览器的内容,使用随机数进行加密后传输给浏览器;
  8. 此时浏览器可以使用随机数进行解密,获取到服务器的真实传输内容;

这就是 HTTPS 的基本运作原理,使用对称加密和非对称机密配合使用,保证传输内容的安全性。

关于HTTPS更多知识,可以查看作者的另外一篇文章《学习 HTTP 协议》。

配置证书

下载证书的压缩文件,里面有个 Nginx 文件夹,把 xxx.crt 和 xxx.key 文件拷贝到服务器目录,再进行如下配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
bash复制代码server {
listen 443 ssl http2 default_server; # SSL 访问端口号为 443
server_name lion.club; # 填写绑定证书的域名(我这里是随便写的)
ssl_certificate /etc/nginx/https/lion.club_bundle.crt; # 证书地址
ssl_certificate_key /etc/nginx/https/lion.club.key; # 私钥地址
ssl_session_timeout 10m;
ssl_protocols TLSv1 TLSv1.1 TLSv1.2; # 支持ssl协议版本,默认为后三个,主流版本是[TLSv1.2]

location / {
root /usr/share/nginx/html;
index index.html index.htm;
}
}

如此配置后就能正常访问 HTTPS 版的网站了。

配置跨域 CORS

先简单回顾下跨域究竟是怎么回事。

跨域的定义

同源策略限制了从同一个源加载的文档或脚本如何与来自另一个源的资源进行交互。这是一个用于隔离潜在恶意文件的重要安全机制。通常不允许不同源间的读操作。

同源的定义

如果两个页面的协议,端口(如果有指定)和域名都相同,则两个页面具有相同的源。

下面给出了与 URL http://store.company.com/dir/page.html 的源进行对比的示例:

1
2
3
4
bash复制代码http://store.company.com/dir2/other.html 同源
https://store.company.com/secure.html 不同源,协议不同
http://store.company.com:81/dir/etc.html 不同源,端口不同
http://news.company.com/dir/other.html 不同源,主机不同

不同源会有如下限制:

  • Web 数据层面,同源策略限制了不同源的站点读取当前站点的 Cookie 、 IndexDB 、 LocalStorage 等数据。
  • DOM 层面,同源策略限制了来自不同源的 JavaScript 脚本对当前 DOM 对象读和写的操作。
  • 网络层面,同源策略限制了通过 XMLHttpRequest 等方式将站点的数据发送给不同源的站点。

Nginx 解决跨域的原理

例如:

  • 前端 server 的域名为: fe.server.com
  • 后端服务的域名为: dev.server.com

现在我在 fe.server.com 对 dev.server.com 发起请求一定会出现跨域。

现在我们只需要启动一个 Nginx 服务器,将 server_name 设置为 fe.server.com 然后设置相应的 location 以拦截前端需要跨域的请求,最后将请求代理回 dev.server.com 。如下面的配置:

1
2
3
4
5
6
7
bash复制代码server {
listen 80;
server_name fe.server.com;
location / {
proxy_pass dev.server.com;
}
}

这样可以完美绕过浏览器的同源策略: fe.server.com 访问 Nginx 的 fe.server.com 属于同源访问,而 Nginx 对服务端转发的请求不会触发浏览器的同源策略。

配置开启 gzip 压缩

GZIP 是规定的三种标准 HTTP 压缩格式之一。目前绝大多数的网站都在使用 GZIP 传输 HTML 、CSS 、 JavaScript 等资源文件。

对于文本文件, GZiP 的效果非常明显,开启后传输所需流量大约会降至 1/4~1/3 。

并不是每个浏览器都支持 gzip 的,如何知道客户端是否支持 gzip 呢,请求头中的 Accept-Encoding 来标识对压缩的支持。
image.png
启用 gzip 同时需要客户端和服务端的支持,如果客户端支持 gzip 的解析,那么只要服务端能够返回 gzip 的文件就可以启用 gzip 了,我们可以通过 Nginx 的配置来让服务端支持 gzip 。下面的 respone 中 content-encoding:gzip ,指服务端开启了 gzip 的压缩方式。
image.png
在 /etc/nginx/conf.d/ 文件夹中新建配置文件 gzip.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
bash复制代码# # 默认off,是否开启gzip
gzip on;
# 要采用 gzip 压缩的 MIME 文件类型,其中 text/html 被系统强制启用;
gzip_types text/plain text/css application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript;

# ---- 以上两个参数开启就可以支持Gzip压缩了 ---- #

# 默认 off,该模块启用后,Nginx 首先检查是否存在请求静态文件的 gz 结尾的文件,如果有则直接返回该 .gz 文件内容;
gzip_static on;

# 默认 off,nginx做为反向代理时启用,用于设置启用或禁用从代理服务器上收到相应内容 gzip 压缩;
gzip_proxied any;

# 用于在响应消息头中添加 Vary:Accept-Encoding,使代理服务器根据请求头中的 Accept-Encoding 识别是否启用 gzip 压缩;
gzip_vary on;

# gzip 压缩比,压缩级别是 1-9,1 压缩级别最低,9 最高,级别越高压缩率越大,压缩时间越长,建议 4-6;
gzip_comp_level 6;

# 获取多少内存用于缓存压缩结果,16 8k 表示以 8k*16 为单位获得;
gzip_buffers 16 8k;

# 允许压缩的页面最小字节数,页面字节数从header头中的 Content-Length 中进行获取。默认值是 0,不管页面多大都压缩。建议设置成大于 1k 的字节数,小于 1k 可能会越压越大;
# gzip_min_length 1k;

# 默认 1.1,启用 gzip 所需的 HTTP 最低版本;
gzip_http_version 1.1;

其实也可以通过前端构建工具例如 webpack 、rollup 等在打生产包时就做好 Gzip 压缩,然后放到 Nginx 服务器中,这样可以减少服务器的开销,加快访问速度。

关于 Nginx 的实际应用就学习到这里,相信通过掌握了 Nginx 核心配置以及实战配置,之后再遇到什么需求,我们也能轻松应对。接下来,让我们再深入一点学习下 Nginx 的架构。

Nginx 架构

进程结构

多进程结构 Nginx 的进程模型图:

未命名文件.png

多进程中的 Nginx 进程架构如下图所示,会有一个父进程( Master Process ),它会有很多子进程( Child Processes )。

  • Master Process 用来管理子进程的,其本身并不真正处理用户请求。
    • 某个子进程 down 掉的话,它会向 Master 进程发送一条消息,表明自己不可用了,此时 Master 进程会去新起一个子进程。
    • 某个配置文件被修改了 Master 进程会去通知 work 进程获取新的配置信息,这也就是我们所说的热部署。
  • 子进程间是通过共享内存的方式进行通信的。

配置文件重载原理

reload 重载配置文件的流程:

  1. 向 master 进程发送 HUP 信号( reload 命令);
  2. master 进程检查配置语法是否正确;
  3. master 进程打开监听端口;
  4. master 进程使用新的配置文件启动新的 worker 子进程;
  5. master 进程向老的 worker 子进程发送 QUIT 信号;
  6. 老的 worker 进程关闭监听句柄,处理完当前连接后关闭进程;
  7. 整个过程 Nginx 始终处于平稳运行中,实现了平滑升级,用户无感知;

Nginx 模块化管理机制

Nginx 的内部结构是由核心部分和一系列的功能模块所组成。这样划分是为了使得每个模块的功能相对简单,便于开发,同时也便于对系统进行功能扩展。Nginx 的模块是互相独立的,低耦合高内聚。
image.png

总结

相信通过本文的学习,你应该会对 Nginx 有一个更加全面的认识。

都看到这里了,就点个👍 👍 👍 吧。

本文转载自: 掘金

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

我的SQL学习经历

发表于 2021-03-22

点击关注上方“SQL数据库开发”,

设为“置顶或星标 ”,第一时间送达干货

大家好,我是李岳~\

经常有小伙伴问我:岳哥,你之前是怎么学会SQL的?有没有什么快速入门的方法?\

常常因为比较忙也不能系统的回答,今天趁着周末把我曾经的一些学习经历和方法分享给大家,希望对大家有点帮助。

万事开头难

没错,我开始学SQL的时候,只有大学老师教的那点基础。很多表之间的关联,子查询,存储过程等等都只停留在听说过,并没有什么实际使用经验。

也像大多数人一样,刚开始像无头苍蝇,不知道该如何下手。也是到处找各种学习资料,曾经下载过很多学习资料,包括各种数据库的视频教程,电子书,也买过一些纸质版的实体书。

这些资料确实帮了我不少,总的来说,电子书>实体书>视频教程。是的,反而是书籍让我学到了更多数据库的一些知识。

这其中一些我看过的电子书,比较好的我都收录在我的公众号菜单栏里了。

)

当然这里并不是在推荐资料什么的,只是我也有一些收集好东西的习惯,毕竟这些书带给我了很多帮助。

但是看书也有方法的,很多小伙伴虽然收集了不少资料,但是学习方法可能有点问题,特别像我自己就吃过一些学习方法的亏。

记得刚开始,看书总以为把书看完了,知识就全进到我脑子里了,我就会用SQL了。但是当我看完一本两本,我发现我只是翻完了而已。后来我发现不能再这样大概读读了,要精读,要动手写代码,哪怕是对着书上的代码一个一个的敲。

是的,这个方法行之有效,而且后来屡试不爽。按照这个方法先后读完了《Microsoft SQL Server 2008技术内幕 》的上下两卷,把SQL Server的一些运行原理和使用方法都仔仔细细的操作了一遍。

要知道以前我只知道数据库的一些简单的使用方法,但是看过这些原理性的书之后,写的每一行代码我都知道它会如何进到数据库,如何被执行等等。

有些东西真的只有知道它是如何运行的你才能更好的理解它,这是我看完这两本书的一个深刻体会。

看千遍不如练一遍

有时候一个知识点一看就会,一写就废,造成这样的情况就是练的太少了。\

记得刚工作那会儿,做的工作就是一些数据统计的活,每天的统计需求各种各样的,这样间接的迫使我每天都要去写很多的SQL代码。

从开始数据聚合(包括汇总,计数,平均等等),到后来的累加,连续N天,循环等等。遇到的逻辑需求越来越复杂,但是总能用SQL代码求出来,这就得益于每天这样不停的写啊写。

哪怕是现在,每天还是会写各种各样的SQL逻辑。每当用SQL解决一个问题,就感觉很开心,这可能就是一种成就感吧。\

所以除了读书,平时也要保证一种频繁写SQL的状态,让它成为你的一部分,我觉得如果能够做到这样,那离学会SQL也就不远了。

难题才是你进步的阶梯

很多小伙伴一遇到问题就不知所措,希望能够找人直接帮忙解决了。找人帮忙解决固然是好,但是如果能够自己弄懂问题原因,然后自己动手解决那无疑是最好的。\

大家应该都知道,数据库的安装对于初学者,是一个非常让人头疼的事,至少对刚开始的我是这样的。每次换一台电脑就要安装一次,那种安装不成功的痛苦我想只有经历过的小伙伴才能理解。

以至于安装的太多次了,摸索出了一些门道,才知道一个安装不过如此。是的,当你把所有可能踩到的坑都踩一遍,你就知道这条路怎么走才最安全。

同样的,我们在写SQL时也会遇到各种难题,就像上面提到的循环,不知道怎么写咋办?查资料模仿,没其他的好办法,别人能成,为什么你不能成?

不同平台的SQL是相通的

很多小伙伴经常说自己会某个平台的SQL,比如SQL Server,但是不会其他平台的SQL。

其实SQL的基础语法都是相通的,不一样的是底层的数据流转原理和各个平台的一些特色内容。

比如SQL Server里面有个ceiling函数,如果是其他平台一开始肯定不知道有什么函数与之对应,那就直接搜索这个平台和ceiling,例如:Oracle ceiling,你就会找的Oracle中与之对应的是ceil函数,同样的MySQL则两个都可以使用。

其他一些比较高级的知识点,虽然写法上的区别比较大,但是他们的原理都大致类似,所以如果你真的会了SQL,就不要怕自己没用的数据库平台。

今天就暂时分享这么多,如果对你有所帮助,记得帮忙转发+点赞,感谢

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
php复制代码
我是岳哥,最后给大家分享我写的SQL两件套:《SQL基础知识第二版》和《SQL高级知识第二版》的PDF电子版。里面有各个语法的解释、大量的实例讲解和批注等等,非常通俗易懂,方便大家跟着一起来实操。有需要的读者可以下载学习,在下面的公众号「数据前线」(非本号)后台回复关键字:SQL,就行数据前线
——End——

后台回复关键字:1024,获取一份精心整理的技术干货
后台回复关键字:进群,带你进入高手如云的交流群。
推荐阅读
SQL 进阶技巧(下)

MySQL基本知识点梳理和查询优化

SQL 查询总是先执行SELECT语句吗?

SQL 进阶技巧(上)

超全整理,MySQL常用函数

本文转载自: 掘金

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

一套亿级用户的IM架构技术干货(下篇):可靠性、有序性、弱网

发表于 2021-03-22

本文内容和编写思路是基于邓昀泽的“大规模并发IM服务架构设计”、“IM的弱网场景优化”两文的提纲进行的,感谢邓昀泽的无私分享。

1、引言

接上篇《一套亿级用户的IM架构技术干货(上篇):整体架构、服务拆分等》,本文主要聚焦这套亿级用户的IM架构的一些比较细节但很重要的热门问题上,比如:消息可靠性、消息有序性、数据安全性、移动端弱网问题等。

以上这些热门IM问题每个话题其实都可以单独成文,但限于文章篇幅,本文不会逐个问题详细深入地探讨,主要以抛砖引玉的方式引导阅读者理解问题的关键,并针对问题提供专项研究文章链接,方便有选择性的深入学习。希望本文能给你的IM开发带来一些益处。

2、系列文章

为了更好以进行内容呈现,本文拆分两了上下两篇。

本文是2篇文章中的第2篇:

《一套亿级用户的IM架构技术干货(上篇):整体架构、服务拆分等》

《一套亿级用户的IM架构技术干货(下篇):可靠性、有序性、弱网优化等》(本文)

本篇主要聚焦这套亿级用户的IM架构的一些比较细节但很重要的热门问题上。

3、消息可靠性问题

消息的可靠性是IM系统的典型技术指标,对于用户来说,消息能不能被可靠送达(不丢消息),是使用这套IM的信任前提。

换句话说,如果这套IM系统不能保证不丢消息,那相当于发送的每一条消息都有被丢失的概率,对于用户而言,一定会不会“放心”地使用它,即“不信任”这套IM。

从产品经理的角度来说,有这样的技术障碍存在,再怎么费力的推广,最终用户都会很快流失。所以一套IM如果不能保证消息的可靠性,那问题是很严重的。

**PS:**如果你对IM消息可靠性的问题还没有一个直观的映象的话,通过《零基础IM开发入门(三):什么是IM系统的可靠性?》这篇文章可以通俗易懂的理解它。

如上图所示,消息可靠性主要依赖2个逻辑来保障:

  • 1)上行消息可靠性;
  • 2)下行消息可靠性。

1)针对上行消息的可靠性,可以这样的思路来处理:

用户发送一个消息(假设协议叫PIMSendReq),用户要给这个消息设定一个本地ID,然后等待服务器操作完成给发送者一个PIMSendAck(本地ID一致),告诉用户发送成功了。

如果等待一段时间,没收到这个ACK,说明用户发送不成功,客户端SDK要做重试操作。

2)针对下行消息的可靠性,可以这样的思路来处理:

服务收到了用户A的消息,要把这个消息推送给B、C、D 3个人。假设B临时掉线了,那么在线推送很可能会失败。

**因此确保下行可靠性的核心是:**在做推送前要把这个推送请求缓存起来。

这个缓存由存储系统来保证,MsgWriter要维护一个(离线消息列表),用户的一条消息,要同时写入B、C、D的离线消息列表,B、C、D收到这个消息以后,要给存储系统一个ACK,然后存储系统把消息ID从离线消息列表里拿掉。

针对消息的可靠性问题,具体的解决思路还可以从另一个维度来考虑:即实时消息的可靠性和离线消息的可靠性。

有兴趣可以深入读一读这两篇:

《IM消息送达保证机制实现(一):保证在线实时消息的可靠投递》

《IM消息送达保证机制实现(二):保证离线消息的可靠投递》

而对于离线消息的可靠性来说,单聊和群聊又有很大区别,有关群聊的离线消息可靠投递问题,可以深入读一读《IM开发干货分享:如何优雅的实现大量离线消息的可靠投递》。

4、消息有序性问题

消息的有序性问题是分布式IM系统中的另一个技术“硬骨头”。

因为是分布式系统,客户端和服务器的时钟可能是不同步的。如果简单依赖某一方的时钟,就会出现大量的消息乱序。

比如只依赖客户端的时钟,A比B时间晚30分钟。所有A给B发消息,然后B给A回复。

发送顺序是:

客户端A:“XXX”

客户端B:“YYY”

接收方的排序就会变成:

客户端B:“YYY”

客户端A:“XXX”

因为A的时间晚30分钟,所有A的消息都会排在后面。

如果只依赖服务器的时钟,也会出现类似的问题,因为2个服务器时间可能也不一致。虽然客户端A和客户端B时钟一致,但是A的消息由服务器S1处理,B的消息由服务器S2处理,也会导致同样消息乱序。

为了解决这种问题,我的思路是通过可以做这样一系列的操作来实现。

1)服务器时间对齐:

这部分就是后端运维的锅了,由系统管理员来尽量保障,没有别的招儿。

2)客户端通过时间调校对齐服务器时间:

比如:客户端登录以后,拿客户端时间和服务器时间做差值计算,发送消息的时候考虑这部分差值。

在我的im架构里,这个能把时间对齐到100ms这个级,差值再小的话就很困难了,因为协议在客户端和服务器之间传递速度RTT也是不稳定的(网络传输存在不可控的延迟风险嘛)。

3)消息同时带上本地时间和服务器时间:

具体可以这样的处理:排序的时候,对于同一个人的消息,按照消息本地时间来排;对于不同人的消息,按照服务器时间来排,这是插值排序算法。

**PS:**关于消息有序性的问题,显然也不是上面这三两句话能讲的清楚,如果你想更通俗一理解它,可以读一读《零基础IM开发入门(四):什么是IM系统的消息时序一致性?》。

**另外:**从技术实践可行性的角度来说,《一个低成本确保IM消息时序的方法探讨》、《如何保证IM实时消息的“时序性”与“一致性”?》这两篇中的思路可以借鉴一下。

实际上,消息的排序问题,还可以从消息ID的角度去处理(也就是通过算法让消息ID产生顺序性,从而根据消息ID就能达到消息排序的目的)。

有关顺序的消息ID算法问题,这两篇非常值得借鉴:《IM消息ID技术专题(一):微信的海量IM聊天消息序列号生成实践(算法原理篇)》、《IM消息ID技术专题(三):解密融云IM产品的聊天消息ID生成策略》,我就不废话了。

5、消息已读同步问题

消息的已读未读功能,如下图所示:

上图就是钉钉中的已读未读消息。这在企业IM场景下非常有用(因为做领导的都喜欢,你懂的)。

已读未读功能,对于一对一的单聊消息来说,还比较好理解:就是多加一条对应的回执息(当用户阅读这条消息时发回)。

但对于群聊这说,这一条消息有多少人已读、多少人未读,想实现这个效果,那就真的有点麻烦了。对于群聊的已读未读功能实现逻辑,这里就不展开了,有兴趣可以读一下这篇《IM群聊消息的已读回执功能该怎么实现?》。

回归到本节的主题“已读同步”的问题,这显示难度又进一级,因为已读未读回执不只是针对“账号”,现在还要细分到“同一账号在不同端登陆”的情况,对于已读回执的同步逻辑来说,这就有点复杂化了。

在这里,根据我这边IM架构的实践经验,提供一些思路。

**具体来说就是:**用户可能有多个设备登录同一个账户(比如:Web PC和移动端同时登陆),这种情况下的已读未读功能,就需要来实现已读同步,否则在设备1看过的消息,设备2看到依然是未读消息,从产品的角度来说,这就影响用户体验了。

对于我的im架构来说,已读同步主要依赖2个逻辑来保证:

  • 1)同步状态维护,为用户的每一个Session,维护一个时间戳,保存最后的读消息时间;
  • 2)如果用户打开了某个Session,且用户有多个设备在线,发送一条PIMSyncRead消息,通知其它设备。

6、数据安全问题

6.1 基础

IM系统架构中的数据安全比一般系统要复杂一些,从通信的角度来说,它涉及到socket长连接通信的安全性和http短连接的两重安全性。而随着IM在移动端的流行,又要在安全性、性能、数据流量、用户体验这几个维度上做权衡,所以想要实现一套完善的IM安全架构,要面临的挑战是很多的。

IM系统架构中,所谓的数据安全,主要是通信安全和内容安全。

6.2 通信安全

所谓的通信安全,这就要理解IM通信的服务组成。

目前来说,一个典型的im系统,主要由两种通信服务组成:

  • 1)socket长连接服务:技术上也就是多数人耳熟能详的网络通信这一块,再细化一点也就是tcp、udp协议这一块;
  • 2)http短连接服务:也就是最常用的http rest接口那些。

对于提升长连接的安全性思路,可以深入阅读《通俗易懂:一篇掌握即时通讯的消息传输安全原理》。另外,微信团队分享的《微信新一代通信安全解决方案:基于TLS1.3的MMTLS详解》一文,也非常有参考意义。

如果是通信安全级别更高的场景,可以参考《即时通讯安全篇(二):探讨组合加密算法在IM中的应用》,文中关于组合加密算法的使用思路非常不错。

至于短连接安全性,大家就很熟悉了,开启https多数情况下就够用了。如果对于https不甚了解,可以从这几篇开始:《一文读懂Https的安全性原理、数字证书、单项认证、双项认证等》、《即时通讯安全篇(七):如果这样来理解HTTPS,一篇就够了》。

6.3 内容安全

这个可能不太好理解,上面既然实现了通信安全,那为什么还要纠结“内容安全”?

我们了解一下所谓的密码学三大作用:加密( Encryption)、认证(Authentication),鉴定(Identification) 。

详细来说就是:

加密:防止坏人获取你的数据。

认证:防止坏人修改了你的数据而你却并没有发现。

鉴权:防止坏人假冒你的身份。

在上节中,恶意攻击者如果在通信环节绕开或突破了“鉴权”、“认证”,那么依赖于“鉴权”、“认证”的“加密”,实际上也有可有被破解。

针对上述问题,那么我们需要对内容进行更加安全独立的加密处理,就这是所谓的“端到端加密”(E2E)。

比如,那个号称无法被破解的IM——Telegram,实际上就是使用了端到端加密技术。

关于端到端加密,这里就不深入探讨,这里有两篇文章有兴趣地可以深入阅读:

《移动端安全通信的利器——端到端加密(E2EE)技术详解》

《简述实时音视频聊天中端到端加密(E2EE)的工作原理》

7、雪崩效应问题

在分布式的IM架构中,存在雪崩效应问题。

我们知道,分布式的IM架构中,为了高可用性,用户每次登陆都是根据负载均衡算法分配到不同的服务器。那么问题就来了。

**举个例子:**假设有5个机房,其中A机房故障,导致这个机房先前服务的用户都跑去B机房。B机房不堪重负也崩溃了,A+B的用户跑去机房C,连锁反应会导致所有服务挂掉。

防止雪崩效应需要在服务器架构,客户端链接策略上有一些配合的解决方案。服务器需要有限流能力作为基础,主要是限制总服务用户数和短时间链接用户数。

在客户端层面,发现服务断开之后要有一个策略,防止大量用户同一时间去链接某个服务器。

通常有2种方案:

  • 1)退避:重连之间设置一个随机的间隔;
  • 2)LBS:跟服务器申请重连的新的服务器IP,然后由LBS服务去降低短时间分配到同一个服务器的用户量。

这2种方案互不冲突,可以同时做。

8、弱网问题

8.1 弱网问题的原因

鉴于如今IM在移动端的流行,弱网是很常态的问题。电梯、火车上、开车、地铁等等场景,都会遇到明显的弱网问题。

那么为什么会出现弱网问题?

要回答这个问题,那就需要从无线通信的原理上去寻找答案。

因为无线通信的质量受制于很多方面的因素,比如:无线信号强弱变化快、信号干扰、通信基站分布不均、移动速度太快等等。要说清楚这个问题,那就真是三天三夜都讲不完。

有兴趣的读者,一定要仔细阅读下面这几篇文章,类似的跨学科科谱式文章并不多见:

《IM开发者的零基础通信技术入门(十一):为什么WiFi信号差?一文即懂!》

《IM开发者的零基础通信技术入门(十二):上网卡顿?网络掉线?一文即懂!》

《IM开发者的零基础通信技术入门(十三):为什么手机信号差?一文即懂!》

《IM开发者的零基础通信技术入门(十四):高铁上无线上网有多难?一文即懂!》

弱网问题是移动端APP的必修课,下面这几篇总结也值得借鉴:

《移动端IM开发者必读(一):通俗易懂,理解移动网络的“弱”和“慢”》

《移动端IM开发者必读(二):史上最全移动弱网络优化方法总结》

《现代移动端网络短连接的优化手段总结:请求速度、弱网适应、安全保障》

《百度APP移动端网络深度优化实践分享(三):移动端弱网优化篇》

8.2 IM针对弱网问题的处理

对于IM来说,弱网问题并不是很复杂,核心是做好消息的重发、排序以及接收端的重试。

为了解决好弱网引发的IM问题,通常可以通过以下手段改善:

  • 1)消息自动重发;
  • 2)离线消息接收;
  • 3)重发消息排序;
  • 4)离线指令处理。

下面将逐一展开讨论。

8.3 消息自动重发

坐地铁的时候,经常遇到列车开起来以后,网络断开,发送消息失败。

这时候产品有2种表现形式:

  • a、直接告诉用户发送失败;
  • b、保持发送状态,自动重试3-5次(3分钟)以后告诉用户发送失败。

**显然:**自动重试失败以后再告诉用户发送失败体验要好很多。尤其是在网络闪断情况下,重试成功率很高,很可能用户根本感知不到有发送失败。

**从技术上:**客户端IMSDK要把每条消息的状态监控起来。发送消息不能简单的调用一下网络发送请求,而是要有一个状态机,管理几个状态:初始状态,发送中,发送失败,发送超时。对于失败和超时的状态,要启用重试机制。

这里还有一篇关于重试机制设计的讨论帖子,有兴趣可以看看:《完全自已开发的IM该如何设计“失败重试”机制?》。

《IM消息送达保证机制实现(一):保证在线实时消息的可靠投递》一文中关于消息超时与重传机制的实现思路,也可以参考一下。

8.4 离线消息接收

现代IM是没有“在线”这个状态的,不需要给用户这个信息。但是从技术的层面,用户掉线了还是要正确的去感知的。

感知方法有几条:

  • a、信令长连接状态:如果长时间没收到到服务器的心跳反馈,说明掉线了;
  • b、网络请求失败次数:如果多次网络请求失败,说明”可能“掉线了;
  • c、设备网络状态检测:直接检测网卡状态就好,一般Android/iOS/Windows/Mac都有相应系统API。

正确检测到网络状态以后,发现网络从”断开到恢复“的切换,要去主动拉取离线阶段的消息,就可以做到弱网状态不丢消息(从服务器的离线消息列表拉取)。

上面文字中提到的网络状态的确定,涉及到IM里网络连接检查和保活机制问题,是IM里比较头疼的问题。

一不小心,又踩进了IM网络保活这个坑,我就不在这里展开,有兴趣一定要读读下面的文章:

《为何基于TCP协议的移动端IM仍然需要心跳保活机制?》

《一文读懂即时通讯应用中的网络心跳包机制:作用、原理、实现思路等》

《微信团队原创分享:Android版微信后台保活实战分享(网络保活篇)》

《移动端IM实践:实现Android版微信的智能心跳机制》

《移动端IM实践:WhatsApp、Line、微信的心跳策略分析》

8.5 重发消息排序

弱网逻辑的另一个坑是消息排序。

假如有A、B、C 3条消息,A、C发送成功,B发送的时候遇到了网络闪断,B触发自动重试。

那么接收方的接收顺序应该是 A B C还是A C B呢?我观察过不同的IM产品,处理逻辑各不相同,这个大家有兴趣可以去玩一下。

这个解决方法是要依赖上一篇服务架构里提到的差值排序,同一个人发出的消息,排序按消息附带的本地时间来排。不同人的消息,按照服务器时间排序。

具体我这边就不再得复,可以回头看看本篇中的第四节“4、消息有序性问题”。

8.6 离线指令处理

部分指令操作的时候,网络可能出现了问题,等网络恢复以后,要自动同步给服务器。

举一个例子,大家可以试试手机设置为飞行模式,然后在微信里删除一个联系人,看看能不能删除。然后重新打开网络,看看这个数据会不会同步到服务器。

类似的逻辑也适用于已读同步等场景,离线状态看过的信息,要正确的跟服务器同步。

8.7 小结一下

IM的弱网处理,其实相对还是比较简单的,基本上自动重试+消息状态就可以解决绝大部分的问题了。

一些细节处理也并不复杂,主要原因是IM的消息量比较小,网络恢复后能快速的恢复操作。

视频会议在弱网下的逻辑,就要复杂的多了。尤其是高丢包的弱网环境下,要尽力去保证音视频的流畅性。

9、本文小结

《一套亿级用户的IM架构技术干货》这期文章的上下两篇就这么侃完了,上篇涉及到的IM架构问题倒还好,下篇一不小心又带出了IM里的各种热门问题“坑”,搞IM开发直是一言难尽。。。

建议IM开发的入门朋友们,如果想要系统地学习移动端IM开发的话,应该去读一读我整理的那篇IM开发“从入门到放弃”的文章(哈哈哈),就是这篇《新手入门一篇就够:从零开发移动端IM》。具体我就不再展开了,不然这篇幅又要刹不住车了。。。(本文同步发布于:www.52im.net/thread-3445…)

10、参考资料

[1] 大规模并发IM服务架构设计

[2] IM的弱网场景优化

[3] 零基础IM开发入门(三):什么是IM系统的可靠性?

[4] IM消息送达保证机制实现(一):保证在线实时消息的可靠投递

[5] IM开发干货分享:如何优雅的实现大量离线消息的可靠投递

[6] 即时通讯安全篇(二):探讨组合加密算法在IM中的应用

[7] 微信新一代通信安全解决方案:基于TLS1.3的MMTLS详解

本文转载自: 掘金

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

Socket是什么?WebSocket和Socket的区别?

发表于 2021-03-22

Socket是什么?

  • TPC/IP协议是传输层协议,主要解决数据如何在网络中传输;
  • Socket是对TCP/IP协议的封装和应用(程序员层面上);
  • 而HTTP是应用层协议,主要解决如何包装数据。

TCP/IP和HTTP协议的关系是:“我们在传输数据时,可以只使用(传输层)TCP/IP协议,但是那样的话,如果没有应用层,便无法识别数据内容。如果想要使传输的数据有意义,则必须使用到应用层协议。应用层协议有很多,比如HTTP、FTP、TELNET等,也可以自己定义应用层协议。WEB使用HTTP协议作应用层协议,以封装HTTP文本信息,然后使用TCP/IP做传输层协议将它发到网络上。”

Socket是什么呢,实际上socket是对TCP/IP协议的封装,Socket本身并不是协议,而是一个调用接口(API)。通过Socket,我们才能使用TCP/IP协议。

Socket跟TCP/IP协议关系是:“TCP/IP只是一个协议栈,就像操作系统的运行机制一样,必须要具体实现,同时还要提供对外的操作接口。这个就像操作系统会提供标准的编程接口,比如win32编程接口一样,TCP/IP也要提供可供程序员做网络开发所用的接口,这就是Socket编程接口。”

在TCP/IP网络中HTTP的位置

从上图中可以看到,HTTP是基于传输层的TCP协议的,而Socket API也是,所以只是从使用上说,可以认为Socket和HTTP类似(但一个是成文的互联网协议,一个是一直沿用的一种编程概念),是对于传输层协议的另一种直接使用,因为按照设计,网络对用户的接口都应该在应用层。

WebSocket协议的来源:

WebSocket属于WHATWG发布的Web Application的一部分(即HTML5)的产物。大约在08年的时候,WG的工程师在讨论网络环境中需要一种全双工的连接形式,刚开始一直叫做「TCPConnection」,并讨论了这种协议需要支持的功能,大致已经和我们今天看到的WebSocket差不多了。他们认为基于现有的HTTP之上的一些技术(如长轮询、Comet)并满足不了这种需求,有必要定义一个全新的协议。

WebSocket协议的开篇就说,本协议的目的是为了解决基于浏览器的程序需要拉取资源时必须发起多个HTTP请求和长时间的轮训的问题……而创建的。

WebSocket和Socket的区别?

两者没什么关系,就像雷锋跟雷锋塔一样。

在这里插入图片描述

为什么要使用Websocket?

WebSocket的目的就是解决网络传输中的双向通信的问题,HTTP1.1默认使用持久连接(persistent connection),在一个TCP连接上也可以传输多个Request/Response消息对,但是HTTP的基本模型还是一个Request对应一个Response。这在双向通信(客户端要向服务器传送数据,同时服务器也需要实时的向客户端传送信息,一个聊天系统就是典型的双向通信)时一般会使用这样几种解决方案:

  • 轮询(polling),轮询就会造成对网络和通信双方的资源的浪费,且非实时。
  • 长轮询,客户端发送一个超时时间很长的Request,服务器hold住这个连接,在有新数据到达时返回Response,相比#1,占用的网络带宽少了,其他类似。
  • 长连接,其实有些人对长连接的概念是模糊不清的,我这里讲的其实是HTTP的长连接(1)。如果你使用Socket来建立TCP的长连接(2),那么,这个长连接(2)跟我们这里要讨论的WebSocket是一样的,实际上TCP长连接就是WebSocket的基础,但是如果是HTTP的长连接,本质上还是Request/Response消息对,仍然会造成资源的浪费、实时性不强等问题。

Websocket协议内容:

WebSocket的目的是取代HTTP在双向通信场景下的使用,而且它的实现方式有些也是基于HTTP的(WS的默认端口是80和443)。现有的网络环境(客户端、服务器、网络中间人、代理等)对HTTP都有很好的支持,所以这样做可以充分利用现有的HTTP的基础设施,有点向下兼容的意味。
WS协议有两部分组成:握手和数据传输。

握手

使用Http进行实现。由客户端使用http的方式发起握手请求,服务端接请求后,将当前正在使用的连接(TCP)的协议,由http协议切换为websocket协议。

握手请求头会带有Upgrade参数用于升级协议类型:

Upgrade:upgrade是HTTP1.1中用于定义转换协议的header域。它表示,如果服务器支持的话,客户端希望使用现有的「网络层」已经建立好的这个「连接(此处是TCP连接)」,切换到另外一个「应用层」(此处是WebSocket)协议。

Upgrade扩展:Upgrade是HTTP中用来进行协议升级的头域,在扩展的协议内容中,客户端发起的协议转换的方式更多,同时服务器也可以选择不接受客户端的协议升级请求;服务端也可以发起协议升级。

请求uri格式:

1
2
3
4
5
6
7
javascript复制代码  ws-URI = "ws:" "//" host [ ":" port ] path [ "?" query ]
wss-URI = "wss:" "//" host [ ":" port ] path [ "?" query ]

host = <host, defined in [RFC3986], Section 3.2.2>
port = <port, defined in [RFC3986], Section 3.2.3>
path = <path-abempty, defined in [RFC3986], Section 3.3>
query = <query, defined in [RFC3986], Section 3.4>

数据传输

服务端接收握手请求后,回复response消息,一旦这个握手回复发出去,服务端就认为此WebSocket连接已经建立成功,处于OPEN状态。它就可以开始发送数据了。

WebSocket中所有发送的数据使用帧的形式发送。客户端发送的数据帧都要经过掩码处理,服务端发送的所有数据帧都不能经过掩码处理。否则对方需要发送关闭帧。

一个帧包含一个帧类型的标识码,一个负载长度,和负载。负载包括扩展内容和应用内容。

WebSocket和HTTP的对比

相同点

  • 都是基于TCP的应用层协议。
  • 都使用Request/Response模型进行连接的建立。
  • 在连接的建立过程中对错误的处理方式相同,在这个阶段WS可能返回和HTTP相同的返回码。
  • 都可以在网络中传输数据。

不同点

  • WS使用HTTP来建立连接,但是定义了一系列新的header域,这些域在HTTP中并不会使用。
  • WS的连接不能通过中间人来转发,它必须是一个直接连接。
  • WS连接建立之后,通信双方都可以在任何时刻向另一方发送数据。
  • WS连接建立之后,数据的传输使用帧来传递,不再需要Request消息。
  • WS的数据帧有序。

参考:www.jianshu.com/p/59b5594ff…

本文转载自: 掘金

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

师弟说不错的 Java 自学方式

发表于 2021-03-22

掘金的小伙伴们,大家好,来分享一点学习 Java 的小心得,希望能给大家一点点帮助。

我有一个大学校友,他是去年 8 月份才开始正式学习 Java 的,之前在郑州一家私企工作了 5 年,工资一个月只有不到 6000 块,日子过得很苦逼,毕竟郑州的房贷压力也不小,公司就那么大,除非领导离职,否则根本看不到晋升的希望。他刚 26 岁,正值青春年华,我就劝他不如改学 Java,他之前学 PHP 的,虽然做起来项目很快,但发展前景确实不怎么乐观。我身边的很多朋友在北京做 Java 开发,差不多能拿到 2 到 3 万的月薪,师弟听了非常羡慕,感觉超出了他的认知范围,就下定决心开始学习 Java,一共学了大概 6 个月的时间,今年跑去杭州找到了一份 Java 开发的工作,比之前高了不少,他本人很满意,因为是自学,能拿到这个薪水我觉得也很不错了。

这期间,我给了他一些帮助,帮他梳理了一条非常清晰的自学路线,他自己也很下劲,遇见什么问题就来问我,我有时候回复不及时,过一会他就说,不用了,自己找到了答案,这股劲真的让我感觉非常佩服。

自学的过程并不容易,我认为有两个因素非常重要。

第一,就是一定要自律。很多人看到 Java 很吃香,就跑过来学,很草率,学了一段时间后,感觉很痛苦,就放弃了。我师弟的情况特殊一点,他本身感觉到生存的压力比较大,就抱着很强烈的学习愿望,再一听别人拿那么多钱,就更下劲了。加上自己学习又非常自律,每天上班的时候偷偷学一点,然后下班后又一直学到晚上一点多真的超级自律。

第二,就是有人带。如果全程靠自己去摸索,超级难受,一是没有目标,不知道该学什么,东学一块,西学一块,成不了知识体系;二是遇到问题的时候没人交流,这个是非常难受的,信心就会受到很大的打击,慢慢学习的劲头就下去了。师弟来找我,我基本上看到就回复他了,我还认识很多技术很厉害的大佬,基本上没有解决不了的问题。

所以,总结一下就是,自身一定要主动去学习,然后要有人带你,然后就是时间,水到渠成的事。这两点少了一点,就很难成功。

自学 Java 必须要注意的一些问题。

1)遇到不会的点,不要死磕。

很多问题,可能是自己当时累了,然后如果钻牛角尖的话,很容易出不来,耽误了学习时间不说,还很有挫败感。可能休息一下,或者问一下大佬,或者去学一下其他的知识点,很快可能就找到了解决方案。

2)一定要高效。

学习就是这样,如果短时间内看不到效果,会很沮丧,会怀疑自己,然后就越来越没劲去学习了。动手动脑,不要一直眼睛盯着去看,无论是视频,还是书,要学会去调解自己,累了就休息会,千万不要学头悬梁锥刺股的那种学习方式。

3)及时沟通。

可以去问搜索引擎,一定不要用度娘,最起码也得用必应去搜索吧,最次跑到知乎直接问问题,这也是一种沟通,俗称“人机交互”,哈哈。另外一个就是问大佬,跑技术交流群去提问,不要怕没人回答你,自己总结的过程中可能就会自己得到答案。

接下来,说一下 Java 主要学的内容。

第一部分,Java 基础

学 Java 基础的话,我推荐两本,一本《Java 核心技术卷》,一本《Head First Java》。

《Java 核心技术卷》分为上下两册,上册的难度较低,可以在最短时间内刷完,尤其是有了 C语言的编程基础后,再刷这本书可以说是手到擒来。下册涉及到的内容有流与文件(☆)、XML、网络(☆)、数据库编程、国际化、Swing、AWT、JavaBean、安全(☆)、脚本编译和注解处理、分布式对象、本地方法(☆),没有标星的内容我认为可以略过。

《Head First Java》更有趣一些,里面有很多小游戏,很活泼的一本书。

两本书之间的风格差别比较大,之前就有读者给我反馈说,《Head First Java》有点驾驭不了,那就挑《Java 核心技术卷》。

刷完任意一本书后,可以看一看《Java 编程思想》,看自己是否能驾驭得了。因为思想的东西嘛,只有经过一些实践后才能有所感悟,否则就像王阳明一开始对着竹子格物一样,屁也格不出来,有了后面领军作战的经验,以及到地方上体验艰苦生活的感受后就开创了心学。

如果 Java 编程思想看起来确实比较痛苦的话,可以看一看《On Java 8》,GitHub 上有开源的中文翻译版,作者是同一个人。

上面提到的这 4 本书里面都还保留了图形程序设计的内容(AWT 和 Swing),我认为是完全可以跳过的,希望后面出版社再版的时候能把这些内容全部删除,定价估计就降了,但他们不一定会这么干。

Java 是一门面向对象的编程语言,所以三大特性:封装、继承、多态是必须要掌握的,然后是异常处理、IO、集合和并发编程。只要这些内容掌握了,可以说 Java 的基础知识就全部掌握了。

这其中的难点是并发编程,我前面提到过,显然这部分内容学起来并不容易,但却最能考验一名 Java 后端工程师的功底了。怎么才能学好并发编程这块呢?我推荐一本非常牛逼的开源电子书《深入浅出 Java 多线程》,几位阿里朋友写的,质量非常高,我在很早之前推荐过。

几位阿里朋友重写的Java并发编程,可下载离线版

第二部分,Java Web

大部分 Java 程序员都要从事 JavaWeb 的相关开发工作,要开发 JavaWeb,自然就离不开 Spring 的系列框架。甚至可以这么说,没有 Spring 的 Java 项目是不存在的。

要学习 Spring,能读的书不多,我能想到的只有《Spring实战》,坦白地说,这本书很一般,但市面上比它好的书,我还不知道。学完 Spring,就要学 SpringMVC,推荐大家看松哥的视频,在 B 站上虽然播放量不是特别大,但我觉得内容特别棒。

然后是 MyBatis,不用找书看了,直接看官方文档就行,讲得特别好。本身 MyBatis 也没有特别难的东西,就是一些 XML 配置和动态 SQL。

Spring+SpringMVC+MyBatis 这三个学完,可以说你就具备了开发企业级应用的能力了。

然后是 Spring Boot,我推荐看大哥纯洁的微笑的博客,访问量在千万级别以上,影响了无数的初学者,我个人强烈推荐。链接就不用我贴了,直接搜“Spring Boot”关键字就行了。

如果说你已经掌握了 Spring、SpringMVC、MyBatis、Spring Boot 等内容,就有能力进行一些真正有用的应用项目开发了,比如说学生管理系统、商城系统、博客系统、秒杀系统等等。

如果你觉得这些系统无从下手的话,其实可以到 GitHub 或者码云上去找,很快就能找到一大堆。为了节省大家的时间,我直接给大家推荐两个。一个微人事,一个 mall,可以直接到 GitHub 上搜。

第三部分,数据库

学习一门编程语言,如果不去操作下数据,就感觉这门编程语言空有皮囊却没有灵魂,对吧?要想学好数据库,首先要学习一下 SQL(《SQL 必知必会》这本小册子就足够用了),然后是 MySQL(最流行的关系型数据库,当推《高性能 MySQL》),然后是 Redis(缓存,老钱的《Redis 深度历险:核心原理与应用实践》非常经典)和 MongoDB(非关系型数据库,《MongoDB权威指南》就可以吃透)。

第四部分,工具

工欲善其事必先利其器,掌握了下面这些工具,学习起来其他的内容也会更加顺手,不用瞎折腾。

Intellij IDEA,编写 Java 程序的最佳 IDE,必须得掌握。GitHub 上有一本开源的书库值得推荐。

接下来是 Maven,可以帮助我们解决 jar 包的烦恼。看《Maven 实战》就可以了。

然后是 Git,工作中是必须掌握的,看《Git 权威指南》就好了。

这些内容学完后,就可以准备找工作了,但在找工作之前,一定还要做两件事。这两件事对找到一份心满意足的工作至关重要,缺一不可。拼命学习了这么长时间,就剩下这最后两个关键节点了,怎么能不锦上添花。

1)first blood

一定要刷面试题,做到有备而战。就像打战一样,如果只是招募到兵员,却不训练,上了场,根本就不经打,只有训练有素的士兵,在场上作战的时候才能做好攻守平衡,应付自如。

我推荐这份在 GitHub 上星标 100k 的面试攻略,离线版已经更新到第四版,内容更加详实。新增了校招/社招面试指南、程序员简历之道这些大多数程序员在面试前比较关心的内容。可通过下面的方式下载离线版。

最新版 JavaGuide 面试突击来啦!GitHub 上标星 100k,已获得 G 哥授权!
​

2)double kill

一定要准备一份让面试官耳目一新的简历,很多初学者容易忽略的一个点就是,随随便便整一份简历,然后就开始投递,结果就像石沉大海一般,没有回响。作为简历的撰写者,你必须要搞清楚一点,简历的本质是什么,它就是为了来销售你的价值主张的。往深处说,简历就是一块敲门砖,它通过白纸黑字的方式告诉招聘方,我是谁,我想来贵公司担任什么职位,我能够完成什么样的工作,我能够为公司带来什么价值,我期望的薪资是多少。

好的简历长这个样子,我敲门敲敲敲|附下载地址

OK,这两件事准备充足了,就大胆地往前冲吧,相信我,就像我的师弟一样,你也一定能找到一份满意的工作。有任何关于 Java 相关的疑问,也欢迎随时私信我。

可能写得不够尽善尽美,因为是站在初学者的角度出发,希望掘金上的小伙伴能看得上眼,有不足的地方也欢迎在评论区提出来。

传统美德不能丢,来个一键三连吧,笔芯芯~

本文转载自: 掘金

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

1…701702703…956

开发者博客

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