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

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


  • 首页

  • 归档

  • 搜索

吐血推荐-详解分布式锁(下)

发表于 2021-11-24

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

MySql 实现分布式锁

使用 Mysql 实现分布式锁在实际开发中的应用场景比较少,一般只有在性能要求不是很高,不想要引入别的组件的时候才会使用。它最大的特点就是理解起来比较容易。

它主要有以下三种实现方式:

  1. 基于表记录实现
  2. 借助 mysql 的悲观锁实现

基于表记录实现

先创建一张表:

1
2
3
4
5
6
7
sql复制代码CREATE TABLE `mysql_lock` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`resource` int NOT NULL COMMENT '锁定的资源',
`description` varchar(1024) NOT NULL DEFAULT "" COMMENT '描述',
PRIMARY KEY (`id`),
UNIQUE KEY `uiq_idx_resource` (`resource`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='基于表记录实现';

申请锁操作就是在表中插入一条对应的记录:

1
sql复制代码INSERT INTO mysql_lock (resource, description) VALUES (1, '申请资源:1');

释放锁操作就是删除插入的那一条表记录即可:

1
sql复制代码DELETE FROM mysql_lock WHERE resource = 1;

实现原理说明:

我们在创建表的时候给 resource 加了唯一约束,它是不能重复插入的,这样就实现了互斥性。前面我们讲分布式锁的时候详细说明了分布式锁应该拥有的特性,上面的例子没有实现其他的特性,下面我们来说一下具体的优化方案:

  1. 对于超时时间:可以写一个定时清理过期资源的程序
  2. 对于可重入性,独占性:可以添加一个字段来记录线程的编号,如果是同一线程允许再次获取锁,每次删除数据库记录的时候校验线程编号,保证独占性,自己的锁只能自己解开。
  3. 对于 mysql 的可靠性:可以设置主备或者集群来防止单点故障。
  4. 这边还有一个小问题,就是每次去获取锁的时候,线程不是阻塞的只去插入向库里面插入一次,失败了也不重试,这个都需要在代码逻辑中自己实现了。

借助 mysql 悲观锁实现

为了提高分布式锁的效率,可以使用查询语句,借助 for update 关键字来给被查询的记录添加行锁中悲观锁,这样别的线程就没有办法对这条记录进行任何操作,从而达到保护共享资源的目的。

使用行锁需要注意的点

  1. mysql 默认是会自动提交事务的,应该手动禁止一下:SET AUTOCOMMIT = 0;
  2. 行锁是建立在索引的基础上的,如果查询时候不是走的索引的话,行锁会升级为表锁进行全表扫描。

我们继续使用上面那张表来进行说明:

  1. 申请锁操作:SELECT * FROM mysql_lock WHERE id = 1 FOR UPDATE; 只要可以查的出来就是申请成功的,没有获取到的会被阻塞,阻塞的超时时可以通过设置 mysql 的 innodb_lock_wait_timeout 来进行设置。注意 WHERE id = 1 这个查询条件是走索引的。
  2. 释放锁操作:COMMIT; 事务提交之后别的线程就可以查的这条记录了。

说明:这边简单提供了一下 mysql 实现分布式锁的两种思路,实际开发不建议使用,要使用的话建议使用第二种,像上面说的分布式锁应该实现的特性使用数据库的话,好多需要开发者手动去实现,不太友好。

Zookeeper 实现分布式锁

在使用 zookeeper 实现分布式锁之前我们先来了解一点前置知识:

zk 的节点类型

  1. 持久化节点:客户端断开连接,节点还在
  2. 持久化顺序节点:在持久化节点的基础上保证有序性
  3. 临时节点:客户端断开连接,节点就删除了
  4. 临时顺序节点:在临时节点的基础上保证有序性

实现 zk 分布式锁的思路

  1. 利用 zk 同级节点的唯一特性可以实现锁的互斥性
  2. 利用 zk 临时节点的特性可以避免正在占用锁的线程没释放锁就因为一些不可抗力因素宕机导致其他线程无法获取锁最终死锁的问题。
  3. 利用 zk 的 节点的watcher 事件可以轻松实现通知其他线程争抢锁的功能。
  4. 利用 zk 顺序节点的特性,可以实现公平锁,按照申请锁的顺序来唤醒阻塞的线程,防止羊群效应。

羊群效应:当并发量巨大的时候,只有一个线程会获得锁,很多线程就会阻塞,当获得锁的那个线程释放锁之后,其他所有在等待的线程就会一起争抢锁,可能会因为瞬间启动的线程过多而导致服务器挂到的情况,这就是所谓的羊群效应。

自定义 zk 分布式锁

代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
java复制代码package com.aha.lock.zk;

import lombok.extern.slf4j.Slf4j;
import org.I0Itec.zkclient.IZkDataListener;
import org.I0Itec.zkclient.ZkClient;
import org.springframework.util.StringUtils;

import java.util.Collections;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;

/**
* 使用 zk 实现分布式锁
* 实现 Lock 遵循 JUC 提供的规范
*
* @author: WT
* @date: 2021/11/23 16:41
*/
@Slf4j
public class ZkDistributedLock implements Lock {

/**
* 用于协调线程的执行时机,注意:countdown 之后,再次 await 是没有办法阻塞线程的,不能使用线程隔离的变量,要不怎么实现线程之间的通知,所以 ThreadLocal 是不可行的
* private ThreadLocal<CountDownLatch> countDownLatch = ThreadLocal.withInitial(() -> new CountDownLatch(1));
*/
private CountDownLatch countDownLatch = new CountDownLatch(1);

private static final String IP_PORT = "10.211.55.3:2181";

/**
* 根节点路径
*/
private static final String ROOT_NODE = "/LOCK";

/**
* 当前节点的前置节点路径
*/
private ThreadLocal<String> beforeNodePath = new ThreadLocal<>();

/**
* 当前节点路径
*/
private ThreadLocal<String> nodePath = new ThreadLocal<>();

private ZkClient zkClient = new ZkClient(IP_PORT);

public ZkDistributedLock() {

// 创建分布式锁对象时,初始化 zk 的路径
if (!zkClient.exists(ROOT_NODE)) {
zkClient.createPersistent(ROOT_NODE);
}

}

/**
* 加锁方法
*/
@Override
public void lock() {
if (tryLock()) {
log.info("{} 加锁成功", Thread.currentThread().getName());
return;
}
// 阻塞 - 等待下次加锁的时机
waitForLock();
// 再次尝试加锁
lock();
}

/**
* 阻塞 - 等待下次加锁的时机
*/
private void waitForLock() {

// 监听前置节点的删除事件 - 监听内部类
IZkDataListener zkDataListener = new IZkDataListener() {

@Override
public void handleDataChange(String dataPath, Object data) throws Exception {

}

/**
* 监听节点的删除时间
* @param dataPath 节点路径
* @throws Exception 异常
*/
@Override
public void handleDataDeleted(String dataPath) throws Exception {
log.info("{} 前置节点被删除", dataPath);
// TODO: 2021/11/24 这个countDown 会将所有正在等待的线程都唤醒,没有实现只唤醒自己的后置节点
countDownLatch.countDown();
}

};

// 订阅监听前置节点的删除时间
zkClient.subscribeDataChanges(beforeNodePath.get(), zkDataListener);
// 判断在监听之前,前置节点是否已经被删除
if (zkClient.exists(beforeNodePath.get())) {
// 前置节点还存在 - 阻塞线程 等待前置节点被删除之后继续执行
try {
countDownLatch.await();
log.info("阻塞线程: {}, nodePath: {}", Thread.currentThread().getName(), nodePath.get());
} catch (InterruptedException e) {
log.info("阻塞 {} 线程失败, 中断此线程", Thread.currentThread().getName());
e.printStackTrace();
Thread.currentThread().interrupt();
}
}
// 重置 countDownLatch , 前置节点已经被删除,取消订阅事件
countDownLatch = new CountDownLatch(1);
zkClient.unsubscribeDataChanges(beforeNodePath.get(), zkDataListener);

}

@Override
public void lockInterruptibly() throws InterruptedException {

}

/**
* 尝试加锁
* @return boolean: 是否加锁成功
*/
@Override
public boolean tryLock() {

// 1. 判断 nodePath 是否为空,为空的话说明 ZkDistributedLock 第一次申请锁, zk 需要进行临时节点的创建
if (!StringUtils.hasText(nodePath.get())) {
nodePath.set(zkClient.createEphemeralSequential(ROOT_NODE + "/", "lock"));
// log.info("ZkDistributedLock 第一次申请锁, zk 需要进行临时节点的创建:{}", nodePath);
log.info("nodePath为空,创建临时节点:{}", nodePath.get());
}

// 2. 获取 根节点所有子节点
List<String> childrenNodeList = zkClient.getChildren(ROOT_NODE);

// 3. 将子节点列表进行排序
Collections.sort(childrenNodeList);

log.info("nodePath: {}, nodeList:{}", nodePath.get(), childrenNodeList);

// 4. 判断当前线程是为最小的节点,是最小的节点说明获取锁成功,反之等待并监听自己前面的节点,当自己前面的节点删除之后,就是自己再次申请锁的时候
if (nodePath.get().equals(ROOT_NODE + "/" + childrenNodeList.get(0))) {
log.info("线程名称: {}, nodePath: {} 是最小的节点,获取锁成功。", Thread.currentThread().getName(), nodePath.get());
return true;
} else {
// 获取当前节点应该在 节点列表中插入的位置 进而取得他的上一个节点
int i = Collections.binarySearch(childrenNodeList, nodePath.get().substring(ROOT_NODE.length() + 1));
// 获取上一个节点的路径
beforeNodePath.set(ROOT_NODE + "/" + childrenNodeList.get(i - 1));
log.info("{} 前面的节点为:{}", nodePath.get(), beforeNodePath.get());
}

return false;
}

@Override
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
return false;
}

@Override
public void unlock() {
zkClient.delete(nodePath.get());
}

@Override
public Condition newCondition() {
return null;
}

}

代码解析:

  1. implements Lock: 实现 Lock 遵循 JUC 提供的规范。
  2. 使用 zkClient.createEphemeralSequential() 创建临时顺序节点,避免羊群效应和拥有锁线程意外挂掉造成死锁的问题。
  3. 阻塞线程这边使用的是 CountDownLatch。当有线程争抢到锁之后,其他的线程会被 CountDownLatch 的 await 方法给阻塞,当被阻塞线程的前置节点被删除,就说明当前节点应该被唤醒,因为顺序节点是有序的,所以只唤醒当前节点就可以了。这边唤醒方法使用的是 zkClient.subscribeDataChanges(beforeNodePath.get(), zkDataListener); 在检测到前置节点被删除之后使用 CountDownLatch 的 countDown 方法,当 countDown() 变成 0 之后就会唤醒线程。
  4. nodePath 和 beforeNodePath 应该是线程私有变量,这样才能保证,每个线程记录自己的 nodePath 和 beforeNodePath。
  5. 具体的实现细节可以参考代码中的注释。

步骤 3 问题说明:

  1. 当 countDown() 变成 0 之后就会唤醒所有的线程,这边应该实现成唤醒自己下一个节点
  2. 当 countDwon() 变成 0 之后需要重新 new 这个对象,不然 await 方法是没有办法重新阻塞线程的。

测试自定义 zk 分布式锁:

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
java复制代码package com.aha.lock.zk;

import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

/**
* @author: WT
* @date: 2021/11/23 17:56
*/
@RestController
@Slf4j
public class ZkDistributeLockTest {

static int inventory = 10;
private static final int NUM = 10;

private final ZkDistributedLock zkDistributedLock = new ZkDistributedLock();

@GetMapping("/zk/lock")
public void zkLockTest() {
try {
for (int i = 0; i < NUM; i++) {
new Thread(() -> {
try {
zkDistributedLock.lock();
Thread.sleep(200);
if (inventory > 0) {
inventory--;
}
log.warn("库存扣减完之后为:{}", inventory);
} catch (InterruptedException e) {
e.printStackTrace();
Thread.currentThread().interrupt();
} finally {
zkDistributedLock.unlock();
}
}
).start();
}
} catch (Exception e) {
e.printStackTrace();
}
}

}

使用 curator 的 zk 分布式锁

curator 提供的几种分布式锁方案

  1. InterProcessMutex:分布式可重入排它锁
  2. InterProcessSemaphoreMutex:分布式排它锁
  3. InterProcessReadWriteLock:分布式读写锁

InterProcessMutex 使用实例

配置 curatorFramework 客户端

1
2
3
4
5
6
7
yaml复制代码zookeeper:
address: 10.211.55.3:2181 # zookeeper Server 地址,如果有多个使用逗号分隔。如 ip1:port1,ip2:port2,ip3:port3
retryCount: 5 # 重试次数
initElapsedTimeMs: 1000 # 初始重试间隔时间
maxElapsedTimeMs: 5000 # 最大重试间隔时间
sessionTimeoutMs: 30000 # Session 超时时间
connectionTimeoutMs: 10000 # 连接超时时间
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
java复制代码package com.aha.config;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;

/**
* 连接 zookeeper 配置类
*
* @author: WT
* @date: 2021/11/22 18:14
*/
@Data
@Configuration
@ConfigurationProperties(prefix = "zookeeper")
public class ZkClientProperties {

/** 重试次数 */
private int retryCount;

/** 初始重试间隔时间 */
private int initElapsedTimeMs;

/** 最大重试间隔时间 */
private int maxElapsedTimeMs;

/**连接地址 */
private String address;

/**Session过期时间 */
private int sessionTimeoutMs;

/**连接超时时间 */
private int connectionTimeoutMs;

}
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
java复制代码package com.aha.client;

import com.aha.config.ZkClientProperties;
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.CuratorFrameworkFactory;
import org.apache.curator.retry.ExponentialBackoffRetry;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
* 生成 zk 客户端
*
* @author: WT
* @date: 2021/11/22 18:18
*/
@Configuration
public class ZookeeperClient {

/**
* initMethod = "start"
* curatorFramework 创建对象之后,调用 curatorFramework 实例的 start 方法
*/
// @Bean(initMethod = "start")
// public CuratorFramework curatorFramework(ZkClientProperties zookeeperProperties) {
// return CuratorFrameworkFactory.newClient(
// zookeeperProperties.getAddress(),
// zookeeperProperties.getSessionTimeoutMs(),
// zookeeperProperties.getConnectionTimeoutMs(),
// new RetryNTimes(zookeeperProperties.getRetryCount(), zookeeperProperties.getInitElapsedTimeMs())
// );
// }

@Bean(initMethod = "start")
private static CuratorFramework getZkClient(ZkClientProperties zookeeperProperties) {
ExponentialBackoffRetry retryPolicy = new ExponentialBackoffRetry(1000, zookeeperProperties.getRetryCount(), 5000);
return CuratorFrameworkFactory.builder()
.connectString(zookeeperProperties.getAddress())
.sessionTimeoutMs(zookeeperProperties.getSessionTimeoutMs())
.connectionTimeoutMs(zookeeperProperties.getConnectionTimeoutMs())
.retryPolicy(retryPolicy)
.build();
}

}

模拟 50 个线程争抢锁:

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
java复制代码package com.aha.lock.service;

import lombok.extern.slf4j.Slf4j;
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.recipes.locks.InterProcessMutex;
import org.springframework.stereotype.Service;

/**
*
* @author: WT
* @date: 2021/11/22 17:41
*/
@Slf4j
@Service
public class InterprocessMutexLock {

private final CuratorFramework curatorFramework;

public InterprocessMutexLock (CuratorFramework curatorFramework) {
this.curatorFramework = curatorFramework;
}

public void test(String lockPath) {

InterProcessMutex lock = new InterProcessMutex(curatorFramework, lockPath);
//模拟 50 个线程抢锁
for (int i = 0; i < 50; i++) {
new Thread(new TestThread(i, lock)).start();
}

}

static class TestThread implements Runnable {

private final Integer threadFlag;
private final InterProcessMutex lock;

public TestThread(Integer threadFlag, InterProcessMutex lock) {
this.threadFlag = threadFlag;
this.lock = lock;
}

@Override
public void run() {
try {
lock.acquire();
log.info("第 {} 个线程获取到了锁", threadFlag);
//等到1秒后释放锁
Thread.sleep(1000);
} catch (Exception e) {
e.printStackTrace();
}finally {
try {
lock.release();
} catch (Exception e) {
e.printStackTrace();
}
}
}
}

}
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
java复制代码package com.aha.lock.controller;

import com.aha.lock.service.InterprocessMutexLock;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

/**
* @author: WT
* @date: 2021/11/23 14:13
*/
@RestController
public class TestController {

private final InterprocessMutexLock interprocessMutexLock;

public TestController (InterprocessMutexLock interprocessMutexLock) {
this.interprocessMutexLock = interprocessMutexLock;
}

@GetMapping("/lock/mutex")
public void testMutexLock () {
interprocessMutexLock.test("/lock/mutex");
}


}

本文转载自: 掘金

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

一些常用的代码规范总结

发表于 2021-11-24

前言

最近在看王争大佬的设计模式之美,里面谈到了代码规范,刚好也是我平时比较注意的一些点,这里做了一个总结。

下面将从命名,注释,代码风格,编程技巧四个维度展开讨论

命名

选取一个合适的命名有时候确实是很难的,来看下有哪些可以帮我我们命名的技巧

1、命名的长度选择

关于命名长度,在能够表达含义的额情况下,命名当然是越短越好。在大多数的情况下,短的命名不如长的命名更能表达含义,很多书籍是不推荐使用缩写的。

尽管长的命名可以包含更多的信息,更能准确直观地表达意图,但是,如果函数、变量的命名很长,那由它们组成的语句就会很长。在代码列长度有限制的情况下,就会经常出现一条语句被分割成两行的情况,这其实会影响代码可读性。

所以有时候我们是可以适量的使用缩写的短命名

在什么场景下合适使用短命名

1、对于一些默认,大家都熟知的倒是可以使用缩写的命名,比如,sec 表示 second、str 表示 string、num 表示 number、doc 表示 document 等等

2、对于作用域比较小的变量,我们可以使用相对短的命名,比如一些函数内的临时变量,相对应的对于作用于比较大的,更推荐使用长命名

2、利用上下文简化命名

来看个栗子

1
2
3
4
5
go复制代码type User struct {
UserName string
UserAge string
UserAvatarUrl string
}

比如这个struct,我们已经知道这是一个 User 信息的 struct。里面用户的 name ,age,就没有必要加上user的前缀了

修稿后的

1
2
3
4
5
go复制代码type User struct {
Name string
Age string
AvatarUrl string
}

当然这个在数据库的设计中也是同样有用

3、命名要可读、可搜索

“可读”,指的是不要用一些特别生僻、难发音的英文单词来命名。

我们在IDE中编写代码的时候,经常会用“关键词联想”的方法来自动补全和搜索。比如,键入某个对象“.get”,希望IDE返回这个对象的所有get开头的方法。再比如,通过在IDE搜索框中输入“Array”,搜索JDK中数组相关的函数和方法。所以,我们在命名的时候,最好能符合整个项目的命名习惯。大家都用“selectXXX”表示查询,你就不要用“queryXXX”;大家都用“insertXXX”表示插入一条数据,你就要不用“addXXX”,统一规约是很重要的,能减少很多不必要的麻烦。

4、如何命名接口

对于接口的命名,一般有两种比较常见的方式。一种是加前缀“I”,表示一个Interface。比如IUserService,对应的实现命名为UserService。另一种是不加前缀,比如UserService,对应的实现加后缀“Impl”,比如UserServiceImpl。

注释

我们接受一个项目的时候,经常会吐槽老项目注释不好,文档不全,那么如果注释都让我们去写,怎样的注释才是好的注释

有时候我们会在书籍或一些博客中看到,如果好的命名是不需要注释的,也就是代码即注释,如果需要注释了,就是代码的命名不好了,需要在命名中下功夫。

这种是有点极端了,命名再好,毕竟有长度限制,不可能足够详尽,而这个时候,注释就是一个很好的补充。

1、注释到底该写什么

我们写数注释的目的是让代码更易懂,注释一般包括三个方面,做什么、为什么、怎么做。

这是 golang 中 sync.map中的注释,也是分别从做什么、为什么、怎么做 来进行注释

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
go复制代码// Map is like a Go map[interface{}]interface{} but is safe for concurrent use
// by multiple goroutines without additional locking or coordination.
// Loads, stores, and deletes run in amortized constant time.
//
// The Map type is specialized. Most code should use a plain Go map instead,
// with separate locking or coordination, for better type safety and to make it
// easier to maintain other invariants along with the map content.
//
// The Map type is optimized for two common use cases: (1) when the entry for a given
// key is only ever written once but read many times, as in caches that only grow,
// or (2) when multiple goroutines read, write, and overwrite entries for disjoint
// sets of keys. In these two cases, use of a Map may significantly reduce lock
// contention compared to a Go map paired with a separate Mutex or RWMutex.
//
// The zero Map is empty and ready for use. A Map must not be copied after first use.
type Map struct {
mu Mutex
read atomic.Value // readOnly
dirty map[interface{}]*entry
misses int
}

有些人认为,注释是要提供一些代码没有的额外信息,所以不要写“做什么、怎么做”,这两方面在代码中都可以体现出来,只需要写清楚“为什么”,表明代码的设计意图即可。

不过写了注释可能有以下几个优点

1、注释比代码承载的信息更多

函数和变量如果命名得好,确实可以不用再在注释中解释它是做什么的。但是,对结构体来说,包含的信息比较多,一个简单的命名就不够全面详尽了。这个时候,在注释中写明“做什么”就合情合理了。

2、注释起到总结性作用、文档的作用

在注释中,关于具体的代码实现思路,我们可以写一些总结性的说明、特殊情况的说明。这样能够让阅读代码的人通过注释就能大概了解代码的实现思路,阅读起来就会更加容易。

3、一些总结性注释能让代码结构更清晰

对于逻辑比较复杂的代码或者比较长的函数,如果不好提炼、不好拆分成小的函数调用,那我们可以借助总结性的注释来让代码结构更清晰、更有条理。

2、注释是不是越多越好

注释本身有一定的维护成本,所以并非越多越好。结构体和函数一定要写注释,而且要写得尽可能全面、详细,而函数内部的注释要相对少一些,一般都是靠好的命名、提炼函数、解释性变量、总结性注释来提高代码可读性。

代码风格

1、函数多大才合适

函数的代码太多和太少,都是不太好的

太多了:

一个方法上千行,一个函数几百行,逻辑过于繁杂,阅读代码的时候,很容易就会看了后面忘了前面

太少了:

在代码总量相同的情况下,被分割成的函数就会相应增多,调用关系就会变得更复杂,阅读某个代码逻辑的时候,需要频繁地在n多方法或者n多函数之间跳来跳去,阅读体验也不好。

多少最合适的呢?

不过很难给出具体的值,有的地方会讲,那就是不要超过一个显示屏的垂直高度。比如,在我的电脑上,如果要让一个函数的代码完整地显示在IDE中,那最大代码行数不能超过50。

2、一行代码多长最合适

这个也没有一个完全的准侧,毕竟语言不同要求也是不同的

当然有个通用的原则:一行代码最长不能超过IDE显示的宽度。

太长了就不方便代码的阅读了

3、善用空行分割单元块

也就是垂直留白,不太建议我们的代码写下来,一个函数或方法中一行空格也没余,通常会根据不同的语义,一个小模块的内容完了,通过空白空格进行分割。

1
2
3
4
5
6
7
8
9
10
11
go复制代码// Store sets the value for a key.
func (m *Map) Store(key, value interface{}) {
read, _ := m.read.Load().(readOnly)
if e, ok := read.m[key]; ok && e.tryStore(&value) {
return
}

m.mu.Lock()
// ...
m.mu.Unlock()
}

这里上锁的代码就和上文进行了空格

当然有的地方会讲首行不空格,这也是对的,函数头部的空行是没有任何用的。

编程技巧

1、把代码分割成更小的单元块

善于将代码中的模块进行抽象,能够方便我们的阅读

所以,我们要有模块化和抽象思维,善于将大块的复杂逻辑提炼成小的方法或函数,屏蔽掉细节,让阅读代码的人不至于迷失在细节中,这样能极大地提高代码的可读性。不过,只有代码逻辑比较复杂的时候,我们其实才建议把对应的逻辑提炼出来。

2、避免函数或方法参数过多

函数包含3、4个参数的时候还是能接受的,大于等于5个的时候,我们就觉得参数有点过多了,会影响到代码的可读性,使用起来也不方便。

针对这种情况有两种处理方法

1、考虑函数是否职责单一,是否能通过拆分成多个函数的方式来减少参数。

2、将函数的参数封装成对象。

栗子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
go复制代码func updateBookshelf(userId, deviceId string, platform, channel, step int) {
// ...
}

// 修改后
type UpdateBookshelfInput struct {
UserId string
DeviceId string
Step int
Platform int
Channel int
}

func updateBookshelf(input *UpdateBookshelfInput) {
// ...
}

3、勿用函数参数来控制逻辑

不要在函数中使用布尔类型的标识参数来控制内部逻辑,true的时候走这块逻辑,false的时候走另一块逻辑。这明显违背了单一职责原则和接口隔离原则。

可以拆分成两个函数分别调用

栗子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
go复制代码func sendVip(userId string, isNewUser bool) {
// 是新用户
if isNewUser {
// ...
} else {
// ...
}
}

// 修改后
func sendVip(userId string) {
// ...
}

func sendNewUserVip(userId string) {
// ...
}

不过,如果函数是private私有函数,影响范围有限,或者拆分之后的两个函数经常同时被调用,我们可以酌情考虑不用拆分。

4、函数设计要职责单一

对于函数的设计我们也要尽量职责单一,避免设计一个大而全的函数,可以根据不同的功能点,对函数进行拆分。

举个栗子:我们来校验下我们的额一些用户属性,当然这个校验就省略成判断是否为空了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
go复制代码func validate(name, phone, email string) error {
if name == "" {
return errors.New("name is empty")
}

if phone == "" {
return errors.New("phone is empty")
}

if email == "" {
return errors.New("name is empty")
}
return nil
}

修改过就是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
go复制代码func validateName(name string) error {
if name == "" {
return errors.New("name is empty")
}

return nil
}

func validatePhone( phone string) error {
if phone == "" {
return errors.New("phone is empty")
}

return nil
}

func validateEmail(name, phone, email string) error {
if email == "" {
return errors.New("name is empty")
}
return nil
}

5、移除过深的嵌套层次

代码嵌套层次过深往往是因为if-else、switch-case、for循环过度嵌套导致的。过深的嵌套,代码除了不好理解外,嵌套过深很容易因为代码多次缩进,导致嵌套内部的语句超过一行的长度而折成两行,影响代码的整洁。

对于嵌套代码的修改,大概有四个方向可以考虑

举个栗子:

这段代码中,有些地方是不太合适的,我们从下面的四个方向来分析

1
2
3
4
5
6
7
8
9
10
11
12
13
go复制代码func sum(sil []*User, age int) int {
count := 0
if len(sil) == 0 || age == 0 {
return count
} else {
for _, item := range sil {
if item.Age > age {
count++
}
}
}
return count
}

1、去掉多余的if或else语句

修改为

1
2
3
4
5
6
7
8
9
10
11
go复制代码func sum(sil []*User, age int) int {
count := 0
if len(sil) != 0 && age == 0 {
for _, item := range sil {
if item.Age > age {
count++
}
}
}
return count
}

2、使用编程语言提供的continue、break、return关键字,提前退出嵌套

1
2
3
4
5
6
7
8
9
10
11
12
go复制代码func sum(sil []*User, age int) int {
count := 0
if len(sil) != 0 && age == 0 {
for _, item := range sil {
if item.Age <= age {
continue
}
count++
}
}
return count
}

3、调整执行顺序来减少嵌套

1
2
3
4
5
6
7
8
9
10
11
12
13
go复制代码func sum(sil []*User, age int) int {
count := 0
if len(sil) == 0 || age == 0 {
return count
}
for _, item := range sil {
if item.Age <= age {
continue
}
count++
}
return count
}

4、将部分嵌套逻辑封装成函数调用,以此来减少嵌套

6、学会使用解释性变量

常用的用解释性变量来提高代码的可读性的情况有下面2种

1、常量取代魔法数字

1
2
3
4
5
6
7
8
9
10
11
go复制代码func CalculateCircularArea(radius float64) float64 {

return 3.1415 * radius * radius
}

// 修改后
const PI = 3.1415
func CalculateCircularArea(radius float64) float64 {

return PI * radius * radius
}

2、使用解释性变量来解释复杂表达式

1
2
3
4
5
6
7
8
9
go复制代码if appOnlineTime.Before(userId.Timestamp()) {
appOnlineTime = userId.Timestamp()
}

// 修改后
isBeforeRegisterTime := appOnlineTime.Before(userId.Timestamp())
if isBeforeRegisterTime {
appOnlineTime = userId.Timestamp()
}

参考

【设计模式之美】time.geekbang.org/column/intr…

本文转载自: 掘金

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

手把手教你搭建一个前后台分离的个人博客系统—后台实现 一、个

发表于 2021-11-24

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

Hello,你好呀,我是灰小猿,一个超会写bug的程序猿!

利用国庆期间做了一个基于springboot+vue的前后端分离的个人博客网站,今天在这里将开发过程和大家分享一下,手把手教你搭建一个自己专属的个人博客。

完整源码放置在Gitee上了,【源码链接】

小伙伴们记得⭐star⭐哟!

小伙伴们一键三连➕关注!灰小猿带你上高速啦🎉🎉🎉**!**

⚡项目目录⚡

一、个人博客网站项目整体思路

二、Java后端接口开发

(1)数据库设计

​(2)整合MybatisPlus

(3)统一结果封装

(4)整合shiro+jwt实现安全验证

(5)全局异常处理

(6)实体校验

(7)跨域问题

(8)登录接口开发

(9)博客接口开发

一、个人博客网站项目整体思路

整个项目的设计是前后端分离的,后端使用的是SpringBoot+MybatisPlus设计,前端使用Vue+ElementUI搭建页面。安全验证等操作由shiro安全框架完成,在进行前后端数据交互的时候采用路由传输,同时在前后端解决了跨域问题。博客实现登录功能,在未登录的情况下只能访问博客主页,在登录的状态下可以实现博客的发布与编辑功能。

整个博客主页的博客采用时间线的方式布局,先发布的文章会在最前面展示;博客编辑功能同时支持Markdown编辑器编辑。具体的功能实现小伙伴们继续往下看!

二、Java后端接口开发

(1)数据库设计

在数据库设计上主要就是两个表,一个用户信息表和一个博客信息表,

博客信息表中的数据ID会和用户ID相对应。详细的表结构如下:

)​

​

(2)整合MybatisPlus

平常我们使用的都是mybatis来做数据库操作,MybatisPlus是在Mybatis的基础上兴起的,我个人的理解是它在Mybatis和逆向工程的结合,可以直接读取我们的数据库,并且自动的生成*Mapper.xml、Dao、Service中的代码,提高我们的开发效率。

整合MybatisPlus的步骤如下:

第一步,导入所需jar包

在这里我们需要导入MybatisPlus所依赖的jar包,同时因为MybatisPlus需要涉及到代码的自动生成,所以还需要引入freemarker的页面模版引擎。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
xml复制代码        <!--mp-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.2.0</version>
</dependency>
<!--freemarker模版引擎依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-freemarker</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.37</version>
<scope>runtime</scope>
</dependency>
<!--mp代码生成器-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-generator</artifactId>
<version>3.2.0</version>
</dependency>

第二步、写入配置文件

因为我们需要连接数据库嘛,所以当然需要用到数据库连接驱动,同时还需要在配置文件中进行配置,指定好我们的数据库驱动、用户名、密码、数据库名称这些。

同时还需要指定好MybatisPlus扫描的xml文件,

1
2
3
4
5
6
7
8
9
10
11
ruby复制代码#配置数据库信息
spring:
datasource:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql://localhost:3306/vueblog?useUnicode=true&useSSL=false&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: ADMIN

#指定mybatisPlus扫描的xml文件
mybatis-plus:
mapper-locations: classpath*:/mapper/**Mapper.xml

第三步、开启mapper接口扫描,添加分页插件

在这里需要实现一个分页插件PaginationInterceptor,使用该分页插件的目的很简单,就是让我们在每次查询到的结果以分页的形式展示出来。该插件是写在MybatisPlusConfig类下的,

**同时还有一点需要注意的是,**在添加该配置文件的时候我们需要在类上增加@MapperScan(“”)注解,在其中传入我们想要将接口写入到的包名,该接口的目的就是执行想要变成实现类的接口所在的包,如@MapperScan(“com.gyg.mapper”)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
less复制代码/**
* mybatisPlus配置
*/
@Configuration
@EnableTransactionManagement
@MapperScan("com.gyg.mapper") //指定变成实现类的接口所在的包
public class MybatisPlusConfig {

/**
* 实现一个分页插件PaginationInterceptor
* @return
*/
@Bean
public PaginationInterceptor paginationInterceptor() {
PaginationInterceptor paginationInterceptor = new PaginationInterceptor();
return paginationInterceptor;
}

}

第四步、生成相关代码

想要通过mybatisplus生成代码,官方是给了我们一个工具类的,通过该工具类,我们可以写入自己的参数,然后就可以自动的生成相关的代码了。

工具类名叫:CodeGenerator ,使用时我们需要将其和springboot的启动类放置在同级目录下。启动运行之后,输入我们想要生成对应代码的表名即可。

工具类的代码比较长,我放置在了gitee上,【源码链接】

运行这个代码生成器我们就可以自动的生成相关数据表的mapper、dao、service等内容了!

现在数据库相关的代码已经是基本完成了,

(3)统一结果封装

由于我们的数据都是需要通过json串的形式返回给我们的前端页面的,所以我们就需要对返回的结果进行一个统一的封装。在这里我们可以自定义一个封装类Result,方便我们将数据以统一的格式返回出去。

该封装类中一般需要返回的信息有三个:

  • 状态码code(如200表示操作正确,400表示异常)
  • 结果消息msg
  • 结果数据data

同时在封装类中定义全局方法,用于在不同的状态下返回不同的数据。封装类的代码如下:

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
typescript复制代码import lombok.Data;

import java.io.Serializable;

/**
* 封装一个返回统一格式数据的结果集
*/
@Data
public class Result implements Serializable {

private int code; //200正常、非200异常
private String msg; //提示信息
private Object data; //返回数据

public static Result success(Object data) {
return success(200,"操作成功",data);
}

/**
* 消息返回方法
*
* @param code
* @param msg
* @param data
* @return
*/
public static Result success(int code, String msg, Object data) {
Result r = new Result();
r.setCode(code);
r.setMsg(msg);
r.setData(data);
return r;
}


public static Result fail(String msg) {
return fail(400,msg,null);
}

public static Result fail(String msg, Object data) {
return fail(400,msg,data);
}

public static Result fail(int code, String msg, Object data) {
Result r = new Result();
r.setCode(code);
r.setMsg(msg);
r.setData(data);
return r;
}

}

(4)整合shiro+jwt实现安全验证

在进行安全验证的时候我采用的是shiro+jwt结合的方式,大概验证思路是这样的:

前端将登陆信息传送过来之后,通过shiro的Realm进行安全验证,如果验证不通过,那么直接将错误信息返回到前端。如果登录信息验证通过,就将用户信息存储到服务器端,然后通过jwtUtils工具类根据用户的ID生成一个token,并且将该token放入返回请求的请求头中,携带给浏览器,浏览器在接收到服务器的返回的请求的时候,就会解析并获取到该token,并将该token存储到本地;

这样在浏览器每次向服务器发送请求的时候都会从本地携带上该token,服务器也会对每次浏览器发送的请求进行验证,验证浏览器返回的token和服务器端保存的token是否相同。如果相同就放行进行处理;如果不相同就将错误信息返回到浏览器。

附上一个请求过程的图示:

​

安全验证所用到的类有:

  1. ShiroConfig:用于配置shiro的验证信息
  2. AccountRealm:用于对浏览器返回的登录信息进行验证
  3. JwtToken:封装和获取token中的数据
  4. AccountProfile:登录之后返回的用户信息的一个载体
  5. JwtFilter:jwt过滤器,用于过滤浏览器的请求

其中的代码比较多,我就放置在的Gitee上,小伙伴们可以在其中获取【源码链接】

(5)全局异常处理

无论我们平常在进行什么样的项目开发,进行全局异常处理都是一个非常好的习惯,进行全局异常处理,它可以将我们的错误信息用最简单的方式表示出来,并不会出现大量的报错信息。方便我们查阅,在这里我声明了几个在项目中经常会遇到的报错信息。

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
java复制代码/**
* 异常处理工具类
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

/**
* 运行时异常
* @param e
* @return
*/
@ResponseStatus(HttpStatus.BAD_REQUEST) //判断返回消息是否正常
@ExceptionHandler(value = RuntimeException.class)
public Result handler(RuntimeException e){
log.error("运行时异常---------->>>" + e);
return Result.fail(e.getMessage());
}

/**
* shiro运行异常
* @param e
* @return
*/
@ResponseStatus(HttpStatus.UNAUTHORIZED) //判断返回消息是否正常,没有权限异常
@ExceptionHandler(value = ShiroException.class)
public Result handler(ShiroException e){
log.error("shiro异常---------->>>" + e);
return Result.fail(401,e.getMessage(),null);
}

/**
* 实体校验异常
* @param e
* @return
*/
@ResponseStatus(HttpStatus.BAD_REQUEST) //判断返回消息是否正常,没有权限异常
@ExceptionHandler(value = MethodArgumentNotValidException.class)
public Result handler(MethodArgumentNotValidException e){
log.error("实体检验异常异常---------->>>" + e);
BindingResult bindingResult = e.getBindingResult();
ObjectError objectError = bindingResult.getAllErrors().stream().findFirst().get();
return Result.fail(objectError.getDefaultMessage());
}

/**
* 处理断言异常
* @param e
* @return
*/
@ResponseStatus(HttpStatus.BAD_REQUEST) //判断返回消息是否正常,没有权限异常
@ExceptionHandler(value = IllegalArgumentException.class)
public Result handler(IllegalArgumentException e){
log.error("断言异常异常---------->>>" + e);
return Result.fail(e.getMessage());
}

}

(6)实体校验

在表单数据提交的时候,我们通常会对数据进行校验,比如不能为空,或长度不能小于指定值等,在前端我们可以通过js插件来完成,但是如果在后端的话,我们可以通过使用Hibernate validatior的方式来进行校验。

在springboot中已经自动集成了Hibernate validatior的校验,我们只需要在代码中直接使用就可以了。

所以我们只需要在实体的属性上添加相应的校验规则就可以了,比如在user实例类中:

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
less复制代码/**
*
* @author 关注公众号:码猿编程日记
* @since 2021-09-21
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("m_user")
public class User implements Serializable {

private static final long serialVersionUID = 1L;

@TableId(value = "id", type = IdType.AUTO)
private Long id;

@NotBlank(message = "用户名不能为空")
private String username;

private String avatar;

@NotBlank(message = "邮箱不能为空")
@Email(message = "邮箱格式不正确")
private String email;


private String password;

private Integer status;

private LocalDateTime created;

private LocalDateTime lastLogin;


}

(7)跨域问题

由于我们做的是前后端分离的项目,所以在请求发送上一定会出现同源策略的相关问题,这就需要我们解决跨域问题了,关于在前后端交互中解决跨域问题,我专门写了一篇博客,小伙伴们可以去看那一篇《SpringBoot与Vue交互解决跨域问题》

在springboot的后端解决跨域问题的策略比较简单,只需要添加一个类CorsConfig,并且让它实现WebMvcConfigurer接口, 其中代码如下,一般在开发的时候直接将代码复制过去就可以了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
kotlin复制代码import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
* 解决跨域问题
*/
@Configuration
public class CorsConfig implements WebMvcConfigurer {

@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS")
.allowCredentials(true)
.maxAge(3600)
.allowedHeaders("*");

}
}

(8)登录接口开发

登录接口的开发思路很简单,就是接收前端发送过来的登录信息,进行验证是否通过。同时还有一个退出登录的接口,传入用户的信息,确定是在登录状态时可以实现退出登录操作。

代码如下;

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
less复制代码@RestController
public class AccountController {

@Autowired
UserService userService;

@Autowired
JwtUtils jwtUtils;


@PostMapping("/login")
public Result login(@Validated @RequestBody LoginDto loginDto, HttpServletResponse response) {

System.out.println("用户名和密码:" + loginDto.getUsername() + " " + loginDto.getPassword());
// 获取到当前用户
Subject subject = SecurityUtils.getSubject();
// 封装用户名和密码
UsernamePasswordToken token = new UsernamePasswordToken(loginDto.getUsername(), loginDto.getPassword());

System.out.println("封装用户名和密码成功!!!");

try {
// 使用shiro进行用户验证
subject.login(token);
// 如果验证通过再根据用户名查找到该用户
User user = userService.getOne(new QueryWrapper<User>().eq("username", loginDto.getUsername()));
Assert.notNull(user, "用户不存在!");

if (!user.getPassword().equals(loginDto.getPassword())) {
return Result.fail("密码错误!");
}
// 根据用户id生成一个jwt
String jwt = jwtUtils.generateToken(user.getId());

// 将jwt写入
response.setHeader("authorization", jwt);
response.setHeader("Access-Control-Expose-Headers", "authorization");

// 如果正确就返回用户信息
return Result.success(MapUtil.builder()
.put("id", user.getId())
.put("username", user.getUsername())
.put("avatar", user.getAvatar())
.put("email", user.getEmail())
.map()
);
} catch (UnknownAccountException e) {
return Result.fail("用户不存在2");
} catch (IncorrectCredentialsException e) {
return Result.fail("密码不正确2");
}
}

/**
* 退出登录
*
* @return
*/
@RequiresAuthentication
@GetMapping("/logout")
public Result logout() {
Subject subject = SecurityUtils.getSubject();
// AccountProfile profile = (AccountProfile) subject.getPrincipal();
// System.out.println(profile.getId());
// 会请求到logout
subject.logout();

return Result.success("退出成功");
}

@RequiresAuthentication
@GetMapping("/testlogin")
public Result testlogin() {
User user = userService.getById(1L);
return Result.success(user);
}


}

(9)博客接口开发

博客接口中主要实现的功能有:返回主页信息,返回指定博客信息,编辑和发布博客、删除博客的功能,其中编辑和删除博客只有在登录状态下才能请求成功,其他两个请求无需进行登录。

代码如下:

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
less复制代码/**
* @author 关注公众号:码猿编程日记
* @since 2021-09-21
*/
@RestController
//@RequestMapping("/blog")
public class BlogController {

@Autowired
BlogService blogService;

/**
* 分页博客页
*
* @param currentPage
* @return
*/
@GetMapping("/blogs")
public Result list(@RequestParam(defaultValue = "1") Integer currentPage) {
Page page = new Page(currentPage, 5);

AccountProfile accountProfile = (AccountProfile) SecurityUtils.getSubject().getPrincipal();
System.out.println(accountProfile);

IPage<Blog> pageDate = blogService.page(page, new QueryWrapper<Blog>().orderByDesc("created"));

return Result.success(pageDate);
}

/**
* 查找指定的博客
*
* @param id
* @return
*/
@GetMapping("/blog/{id}")
public Result detail(@PathVariable(name = "id") long id) {

Blog blog = blogService.getById(id);
// 用断言来来判断文章是否找不到
Assert.notNull(blog, "该博客已经被删除!");

// 返回该博客数据
return Result.success(blog);
}

/**
* @param blog
* @return
*/
// 只有登录之后才能编辑
@RequiresAuthentication
@PostMapping("/blog/edit")
public Result edit(@Validated @RequestBody Blog blog) {

System.out.println("编辑测试11111111111111111");
System.out.println(blog.toString());
System.out.println("当前用户ID:" + ShiroUtil.getProfile().getId());
System.out.println(blog.toString());
// System.out.println("当前用户id:" + ShiroUtil.getSubjectID());

Blog temp = null;
// 如果博客id不为空,就是编辑
if (blog.getId() != null) {
temp = blogService.getById(blog.getId());
// 每一个用户只能编辑自己的文章
Assert.isTrue(temp.getUserId().equals(ShiroUtil.getProfile().getId()), "你没有权限编辑");

} else {
// 如果id为空,就是添加
temp = new Blog();
// 将这篇文章添加给当前用户的id
temp.setUserId(ShiroUtil.getProfile().getId());
// 博客创建时间
temp.setCreated(LocalDateTime.now());
temp.setStatus(0);
}

// 将两个对象进行复制,指定那些字段不复制
//BeanUtil.copyProperties("转换前的类","转换后的类");
BeanUtil.copyProperties(blog, temp, "id", "userId", "created", "status");

//保存或者更新这一篇文章
blogService.saveOrUpdate(temp);

return Result.success("操作成功");
}

/**
* 根据博客ID删除博客
* @param id
* @return
*/
@RequiresAuthentication
@PostMapping("/blog/delete/{id}")
public Result deleteBlog(@PathVariable("id") long id){
System.out.println(id);
System.out.println("------------");
// int bid = Integer.parseInt(id);
boolean isRemove = blogService.removeById(id);
if (!isRemove){
return Result.fail("删除失败!");
}
return Result.success("删除成功!");
}

}

以上就是我们后台接口开发的全部过程,在开发完成之后需要进行相关的接口测试,测试完成无误之后就可以进行前台页面的开发了。

最后

项目源码我放在gitee了,【源码链接】,小伙伴们别忘了⭐star⭐哟!

**一键三连加关注!灰小猿带你上高速啦!**✨✨✨

我是灰小猿,我们下期见!

)​

​

本文转载自: 掘金

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

ES底层读写工作原理,看这一篇就够了!

发表于 2021-11-24

前言

es 就像是个黑盒,如果你不了解其中的内部原理,你还能干啥?你唯一能干的就是用 es 的 api去做最基本的读写数据了。要是出点什么问题,你啥都不知道,那还能指望你做什么呢?

所以为了能更深入的去了解es内部构造及解决使用过程中出现的问题,知道es最底层的工作原理就显得尤为重要了,那么接下来我将展开说说其底层的工作原理是怎样的。

es 写数据过程

  • 客户端选择一个 node 发送请求过去,这个 node 就是 coordinating node (协调节点)。
  • coordinating node 对 document 进行路由,将请求转发给对应的 node(有 primary shard)。
  • 实际的 node 上的 primary shard 处理请求,然后将数据同步到 replica node 。
  • coordinating node 如果发现 primary node 和所有 replica node 都搞定之后,就返回响应结果给客户端。

es 读数据过程

可以通过 doc id 来查询,会根据 doc id 进行 hash,判断出来当时把 doc id 分配到了哪个 shard 上面去,从那个 shard 去查询。

  • 客户端发送请求到任意一个 node,成为 coordinate node 。
  • coordinate node 对 doc id 进行哈希路由,将请求转发到对应的 node,此时会使用 round-robin 随机轮询算法,在 primary shard 以及其所有 replica 中随机选择一个,让读请求负载均衡。
  • 接收请求的 node 返回 document 给 coordinate node 。
  • coordinate node 返回 document 给客户端。

es 搜索数据过程

es 最强大的是做全文检索,就是比如你有三条数据:

1
2
3
复制代码java真好玩儿啊
java好难学啊
j2ee特别牛

你根据 java 关键词来搜索,将包含 java 的 document 给搜索出来。es 就会给你返回:java 真好玩儿啊,java 好难学啊。

  • 客户端发送请求到一个 coordinate node 。
  • 协调节点将搜索请求转发到所有的 shard 对应的 primary shard 或 replica shard ,都可以。
  • query phase:每个 shard 将自己的搜索结果(其实就是一些 doc id )返回给协调节点,由协调节点进行数据的合并、排序、分页等操作,产出最终结果。
  • fetch phase:接着由协调节点根据 doc id 去各个节点上拉取实际的 document 数据,最终返回给客户端。

写请求是写入 primary shard,然后同步给所有的 replica shard;读请求可以从 primary shard 或 replica shard 读取,采用的是随机轮询算法。

写数据底层原理

先写入内存 buffer,在 buffer 里的时候数据是搜索不到的;同时将数据写入 translog 日志文件。

如果 buffer 快满了,或者到一定时间,就会将内存 buffer 数据 refresh 到一个新的 segment file 中,但是此时数据不是直接进入 segment file 磁盘文件,而是先进入 os cache 。这个过程就是 refresh 。

每隔 1 秒钟,es 将 buffer 中的数据写入一个新的 segment file ,每秒钟会产生一个新的磁盘文件 segment file ,这个 segment file 中就存储最近 1 秒内 buffer 中写入的数据。

但是如果 buffer 里面此时没有数据,那当然不会执行 refresh 操作,如果 buffer 里面有数据,默认 1 秒钟执行一次 refresh 操作,刷入一个新的 segment file 中。

操作系统里面,磁盘文件其实都有一个东西,叫做 os cache ,即操作系统缓存,就是说数据写入磁盘文件之前,会先进入 os cache ,先进入操作系统级别的一个内存缓存中去。只要 buffer 中的数据被 refresh 操作刷入 os cache 中,这个数据就可以被搜索到了。

为什么叫 es 是准实时的? NRT ,全称 near real-time 。默认是每隔 1 秒 refresh 一次的,所以 es 是准实时的,因为写入的数据 1 秒之后才能被看到。可以通过 es 的 restful api 或者 java api ,手动执行一次 refresh 操作,就是手动将 buffer 中的数据刷入 os cache 中,让数据立马就可以被搜索到。只要数据被输入 os cache 中,buffer 就会被清空了,因为不需要保留 buffer 了,数据在 translog 里面已经持久化到磁盘去一份了。

重复上面的步骤,新的数据不断进入 buffer 和 translog,不断将 buffer 数据写入一个又一个新的 segment file 中去,每次 refresh 完 buffer 清空,translog 保留。随着这个过程推进,translog 会变得越来越大。当 translog 达到一定长度的时候,就会触发 commit 操作。

commit 操作发生第一步,就是将 buffer 中现有数据 refresh 到 os cache 中去,清空 buffer。然后,将一个 commit point 写入磁盘文件,里面标识着这个 commit point 对应的所有 segment file ,同时强行将 os cache 中目前所有的数据都 fsync 到磁盘文件中去。最后清空 现有 translog 日志文件,重启一个 translog,此时 commit 操作完成。

这个 commit 操作叫做 flush 。默认 30 分钟自动执行一次 flush ,但如果 translog 过大,也会触发 flush 。flush 操作就对应着 commit 的全过程,我们可以通过 es api,手动执行 flush 操作,手动将 os cache 中的数据 fsync 强刷到磁盘上去。

translog 日志文件的作用是什么?你执行 commit 操作之前,数据要么是停留在 buffer 中,要么是停留在 os cache 中,无论是 buffer 还是 os cache 都是内存,一旦这台机器死了,内存中的数据就全丢了。所以需要将数据对应的操作写入一个专门的日志文件 translog 中,一旦此时机器宕机,再次重启的时候,es 会自动读取 translog 日志文件中的数据,恢复到内存 buffer 和 os cache 中去。

translog 其实也是先写入 os cache 的,默认每隔 5 秒刷一次到磁盘中去,所以默认情况下,可能有 5 秒的数据会仅仅停留在 buffer 或者 translog 文件的 os cache 中,如果此时机器挂了,会丢失 5 秒钟的数据。但是这样性能比较好,最多丢 5 秒的数据。也可以将 translog 设置成每次写操作必须是直接 fsync 到磁盘,但是性能会差很多。

  • index.translog.sync_interval 控制 translog 多久 fsync 到磁盘,最小为 100ms;
  • index.translog.durability translog 是每 5 秒钟刷新一次还是每次请求都 fsync,这个参数有 2 个取值:request(每次请求都执行 fsync,es 要等 translog fsync 到磁盘后才会返回成功)和 async(默认值,translog 每隔 5 秒钟 fsync 一次)。

所以关于数据丢失问题,数据写入 1 秒后可以搜索到;可能会丢失数据的,有 5 秒的数据,停留在 buffer、translog os cache、segment file os cache 当中,而不在磁盘上,此时如果宕机,会导致 5 秒的数据丢失。

总结一下,数据先写入内存 buffer,然后每隔 1s,将数据 refresh 到 os cache,到了 os cache 数据就能被搜索到(所以我们才说 es 从写入到能被搜索到,中间有 1s 的延迟)。每隔 5s,将数据写入 translog 文件(这样如果机器宕机,内存数据全没,最多会有 5s 的数据丢失),translog 大到一定程度,或者默认每隔 30mins,会触发 commit 操作,将缓冲区的数据都 flush 到 segment file 磁盘文件中。

数据写入 segment file 之后,同时就建立好了倒排索引。

删除/更新数据底层原理

如果是删除操作,commit 的时候会生成一个 .del 文件,里面将某个 doc 标识为 deleted 状态,那么搜索的时候根据 .del 文件就知道这个 doc 是否被删除了。

如果是更新操作,就是将原来的 doc 标识为 deleted 状态,然后新写入一条数据。

buffer 每 refresh 一次,就会产生一个 segment file ,所以默认情况下是 1 秒钟一个 segment file ,这样下来 segment file 会越来越多,此时会定期执行 merge。每次 merge 的时候,会将多个 segment file 合并成一个,同时这里会将标识为 deleted 的 doc 给物理删除掉,然后将新的 segment file 写入磁盘,这里会写一个 commit point ,标识所有新的 segment file ,然后打开 segment file 供搜索使用,同时删除旧的 segment file 。

底层 lucene

简单来说,lucene 就是一个 jar 包,里面包含了封装好的各种建立倒排索引的算法代码。我们用 Java 开发的时候,引入 lucene jar,然后基于 lucene 的 api 去开发就可以了。

通过 lucene,我们可以将已有的数据建立索引,lucene 会在本地磁盘上面,给我们组织索引的数据结构。

倒排索引

在搜索引擎中,每个文档都有一个对应的文档 ID,文档内容被表示为一系列关键词的集合。例如,文档 1 经过分词,提取了 20 个关键词,每个关键词都会记录它在文档中出现的次数和出现位置。

那么,倒排索引就是关键词到文档 ID 的映射,每个关键词都对应着一系列的文件,这些文件中都出现了关键词。

举个栗子。

有以下文档:

对文档进行分词之后,得到以下倒排索引。

另外,实用的倒排索引还可以记录更多的信息,比如文档频率信息,表示在文档集合中有多少个文档包含某个单词。

那么,有了倒排索引,搜索引擎可以很方便地响应用户的查询。比如用户输入查询 Facebook ,搜索系统查找倒排索引,从中读出包含这个单词的文档,这些文档就是提供给用户的搜索结果。

要注意倒排索引的两个重要细节:

  • 倒排索引中的所有词项对应一个或多个文档;
  • 倒排索引中的词项根据字典顺序升序排列

上面只是一个简单的栗子,并没有严格按照字典顺序升序排列。

本文转载自: 掘金

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

Java并发系列-AQS详解

发表于 2021-11-24

Java并发系列-AQS详解

前言

  • AQS核心思想是什么?如何实现的,以及底层的数据结构
  • 线程获取锁失败,之后的处理流程是什么
  • 处于排队等候机制中的线程一直无法获取到锁,需要一直等待嘛,AQS还有用别的策略解决这一问题
  • Lock函数通过Acquire方法进行加锁的时候,具体是如何进行加锁

AQS简介

AQS全称为AbstractQueuedSynchronizer,AQS是用来构建锁和同步器的框架,类似我们常用的ReentrantLock,CountDownLatch,ThreadPoolExecutor等,核心思想主要是:用一个volatle修饰的state表示共享资源是否空闲,如果请求的共享资源空闲,会将当前的请求资源的线程设置为有效工作线程,并且会将共享资源设置为锁定状态。如果被请求的共享资源被占用的话,AQS通过CLH队列实现了线程阻塞等待以及唤醒机制,会将暂时获取不到锁的线程加入到队列中。

1
java复制代码 private volatile int state;

status

state

我们可以看到,这个状态量为private,内部提供了几个访问这个字段的方法(final修饰的,子类无法重写)

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码//获取State值
protected final int getState() {
return state;
}
//设置State的值
protected final void setState(int newState) {
state = newState;
}
//使用CAS方式更新State
protected final boolean compareAndSetState(int expect, int update) {
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}

独占和共享

通过和state对比,实现独占和共享

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
java复制代码/**
*独占
*/
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
//判断state是否等于0 线程进行独占操作
if (c == 0) {
……
}
}
/**
*共享
*/
private void doAcquireShared(int arg) {
for (;;) {
……
// 尝试获取一定数量的锁
int r = tryAcquireShared(arg);
////判断state是否大于0进行共享操作
if (r >= 0) {
// 获取锁成功,而且还有剩余资源,就设置当前结点为head,并继续唤醒下一个线程
setHeadAndPropagate(node, r);
……
}
}

自定义的同步器要么实现独占,要么实现共享,我们在重写的时候,只需要实现tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared中的一种即可。接下来我们非公平锁为例子,了解一下队列

队列

多线程来请求共享资源,如果线程获取锁失败(获取锁的方式分为公平和非公平方式),就需要加入等待队列中,带着下面两个问题,我们看下队列相关的知识点

  • 获取锁失败的线程何时加入队列
  • 如何加入队列
何时加入队列

我们借助ReentrantLock来看下AQS相关队列的东西,平常开发过程中我们使用ReentrantLock的时候,一般是这样使用的

1
2
3
4
5
6
7
8
9
java复制代码  Lock lock = new ReentrantLock();
try {
lock.lock();
……
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}

ReentrantLock底层是AQS来实现的,ReentrantLock内部有分为公平锁和非公平锁,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
java复制代码//默认是非公平锁
public ReentrantLock() {
sync = new NonfairSync();
}
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}

//非公平锁调用Lock
static final class NonfairSync extends Sync {
private static final long serialVersionUID = 7316153563782823691L;
final void lock() {
if (compareAndSetState(0, 1))
//获取成功,不再往下执行
setExclusiveOwnerThread(Thread.currentThread());
else
//失败,将线程放入等待队列中
acquire(1);
}
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
}
//调用AQS中再次尝试获取锁,获取失败,调用addWait加入等待队列中
public final void acquire(int arg) {
if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}

从上面源代码中,我们可以总结出第一个问题的答案,当线程执行Acquire(1)尝试获取锁的时候,如果获取失败,调用addWaiter加入等待队列中

如何加入队列

获取锁失败之后,会调用addWaiter方法,将线程封装成Node节点加入队列中,具体实现方法如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java复制代码//java.util.concurrent.locks.AbstractQueuedSynchronizer#addWaiter
private Node addWaiter(Node mode) {
//当前节点新建一个节点
Node node = new Node(Thread.currentThread(), mode);
// 节点的前驱节点执行CLH尾结点pred
Node pred = tail;
if (pred != null) {
//完成尾结点设置
node.prev = pred;
if (compareAndSetTail(pred, node)) {
//pred指的是尾节点
pred.next = node;
return node;
}
}
enq(node);
return node;
}

新来的节点

如图所示,

  • 新节点来的之后,首先将新节点的前驱节点指向尾节点
  • 通过cas(pred,node)方法完成尾结点指向新插入的节点这个操作。
    • 为啥用CAS方式设置尾部节点,原因就是如果我们在设置途中,如果别的线程比如T2先这个节点插入尾部,这个时候Pred指针指的是老的位置,Tail这个时候应该指向的是新T2线程的Node节点位置,这个时候CAS失败,就需要走enq方法完成尾节点设置。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java复制代码    private Node enq(final Node node) {
for (;;) {
Node t = tail;
if (t == null) { // Must initialize
//节点为空,初始化节点出来
if (compareAndSetHead(new Node()))
tail = head;
} else {
//节点不为空,就是对应设置尾结点失败走addWaiter()方法那一套完成尾结点设置
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
啥时候出队列

这线程以Node节点的方式添加到等待队列中了,那什么时候出去呢,接下来我们带着这两个问题一起了解下啥时候出队列

  • 何时出队列(添加节点成功之后 队列中的元素就会一直尝试获取锁)
  • 如何出队列(节点获取锁成功,将节点设置为取消状态,节点出队列)

上面我们讲的addWait方法,会将线程以Node节点的数据结构添加到双端队列中,这个方法其实是返回一个包含改线程的Node,Node作为参数进入到acquireQueued()方法。acquireQueued()就是对排队的线程获取锁操作,这玩意会一直把放入队列中的线程不断的获取锁,一直到获取成功或者不再获取(线程被中断)。我们一起看下**acquireQueued()**方法

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
java复制代码   public final void acquire(int arg) {
if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
//线程设置中断,当前线程挂起
selfInterrupt();
}

final boolean acquireQueued(final Node node, int arg) {
//标记是否成功拿到资源
boolean failed = true;
try {
//中断标识
boolean interrupted = false;
for (;;) {
//开始自旋,等待的线程要么获取到锁,要么就中断,才会跳出for循环
//获取前驱节点
final Node p = node.predecessor();
//当前节点的前驱节点是头节点,说明当前节点在真实数据的首部,说明当前节点前面没有节点等待获取锁资源,则可以尝试获取锁
if (p == head && tryAcquire(arg)) {
//获取锁成功,将当前节点设置为头结点
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
/**
* 头节点没有获取到锁两种情况 1:p节点不是头节点 2:非公平锁,被别的锁捷足先登被强占
* 出现上述两种情况,判断当前Node是否需要被阻塞(防止无限循环浪费资源) 这里判断方法主要根据前驱节点状态去判断是否挂起,方法主要是 * shouldParkAfterFailedAcquire()会将前驱节点的waitStatus变为了SIGNAL=-1, parkAndCheckInterrupt会挂起当前线程,然后判断线程是否 * 被中断,中断就重新跑CAS自旋
*/
if (shouldParkAfterFailedAcquire(p, node) &&parkAndCheckInterrupt())
//将当前线程挂起,阻止调用栈
interrupted = true;
}
} finally {
// 拿到了锁,failed=false,没有拿到锁则将此线程对应的node的waitStatus改为CANCEL(1),CANCEL状态表示当前结点已取消调度。当timeout或被中断(响应中断的情况,下)会触发变更为此状态,进入该状态后的结点将不会再变化。
if (failed)
cancelAcquire(node);
}
}
/**
*java.util.concurrent.locks.AbstractQueuedSynchronizer#shouldParkAfterFailedAcquire
* 根据前驱节点判断当前线程是否应该被阻塞
*/
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
//获取前驱节点的状态
int ws = pred.waitStatus;
if (ws == Node.SIGNAL)
//前置节点的状态,如果状态是SIGNAL(SIGNAL表示释放锁之后,立马会唤醒后继节点),我等着被唤醒
return true;
if (ws > 0) {
//前驱节点取消状态,表明前驱节点已经超时或者中断,需要从同步队列中删除该前驱节点
do {
//循环向前查找,把取消的节点从队列中去除
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
} else {
//将前取节点的状态设置不再是取消状态,将前驱节点设置为-1,准备好状态,当前线程不阻塞等待这被唤醒
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}
//挂起当前线程
private final boolean parkAndCheckInterrupt() {
/**
*线程被唤醒的情况有两种,一是被unpark(),二是被interrupt(),
*如果是第二种情况的话,需要返回被中断的标志,然后在acquire顶层方法自我中断补上
*/
LockSupport.park(this);
return Thread.interrupted();
}

// psacquireQueued我有个疑问,这样循环之后节点状态不就成这样了 …->就序(-1)-->当前的挂起……

简单总结一下上面的流程:

  • CAS自旋的方式判断传入的Node节点的前节点状态,如果是前节点是head节点,Node就尝试获取锁,获取成功的话就将Node节点设置为Head节点,以前的Head等于null,然后跳出自旋,final中不执行
  • 如果前节点不是head获取加锁失败,就调用shouldParkAfterFailedAcquire,函数会将前驱节点的waitStatus变为了SIGNAL=-1,调用 parkAndCheckInterrupt会挂起当前线程,Node等待前面节点唤醒。

看完上面的源码,我们可能会想到一下新的问题:

  • shouldParkAfterFailedAcquire()中取消节点是什么时候生成的?
  • 线程被挂起的时候什么时候被通知()
cancelled节点生成

获取锁失败(什么时候失败?中断,获取锁超时?),则将此线程对应的node的waitStatus改为CANCEL,会在final里面将节点设置为取消状态,总结三种情况

  • 要取消的节点是尾节点
    • 当前节点如果是尾结点,从后往前设置第一个非取消状态设置为未节点
  • 要取消的节点是Head的后继节点
    • 唤醒当前节点的后继节点
  • 要取消的节点在链表的中间
    • 把当前节点的前驱节点的后继指针指向当前节点的后继节点
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
java复制代码    private void cancelAcquire(Node node) {
//节点为空的筛掉
if (node == null)
return;
//当前节点设置不关联任何线程
node.thread = null;
Node pred = node.prev;
while (pred.waitStatus > 0)
//通过前驱节点跳过取消状态的Node,取消的是以前设置的取消状态
node.prev = pred = pred.prev;
//获取前驱节点的后继节点(我感觉是为了将当前节点设置为取消状态挂到这个节点上,不同意见小伙伴可以关注下公众号程序员fly,我们一起探讨下,期待跟大神一块 学习)
Node predNext = pred.next;
//当前节点设置为取消状态
node.waitStatus = Node.CANCELLED;
if (node == tail && compareAndSetTail(node, pred)) {
//当前节点如果是尾结点,从后往前设置第一个非取消状态设置为未节点
compareAndSetNext(pred, predNext, null);
} else {
/**既不是头也不是尾,当前节点在中间
* 判断 当前节点的前驱节点是否为signal(唤醒状态)||不是尝试把前驱节点设置为唤醒状态
* 两个添加满足其一,再&&当前节点的前驱节点是不是为Null
* 将当前前驱节点的后继执行尝试指向当前线程的后继节点
*/
int ws;
if (pred != head && ((ws = pred.waitStatus) == Node.SIGNAL ||
(ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) &&
pred.thread != null) {
Node next = node.next;
if (next != null && next.waitStatus <= 0)
compareAndSetNext(pred, predNext, next);
} else {
//当前节点是head的后继节点,或者上面条件都不满足,唤醒当前节点的后继节点
unparkSuccessor(node);
}
node.next = node; // help GC
}
}

如何解锁

AQS中释放锁的方法是release(),如果state=0(彻底释放)。就会唤醒等待队列中的其他线程来获取资源

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
java复制代码   //独占模式下释放锁
public void unlock() {
sync.release(1);
}
public final boolean release(int arg) {
if (tryRelease(arg)) {
//锁没有被线程持有,获取头节点
Node h = head;
if (h != null && h.waitStatus != 0)
//头节点不为空并且头结点的waitStatus不是初始化节点情况,解除线程挂起状态
unparkSuccessor(h);
return true;
}
return false;
}
//返回true说明该锁没有被任何线程持有
protected final boolean tryRelease(int releases) {
//减少可重入次数
int c = getState() - releases;
//当前线程不是持有持有锁的线程,抛出一次
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
if (c == 0) {
//持有线程全部释放,将当前独占锁所有线程设置为null,更新state
free = true;
setExclusiveOwnerThread(null);
}
setState(c);
return free;
}
//释放锁之后当前线程要做的就是唤醒CLH队列中第一个在等待资源的线程,也就是head结点后面的线程,此时调用的方法是unparkSuccessor(),
private void unparkSuccessor(Node node) {
int ws = node.waitStatus;
if (ws < 0)
compareAndSetWaitStatus(node, ws, 0);
// 获取节点的下一个节点
Node s = node.next;
//下一个节点是null||下一个节点为cancelled,找到队列中最开始的非cancell节点
if (s == null || s.waitStatus > 0) {
s = null;
//从尾部节点开始找,找到队列第一个waitStatus<0的节点
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0)
s = t;
}
//当前节点的下个节点不为空,而且状态<=0,当前节点unpark
if (s != null)
//唤醒
LockSupport.unpark(s.thread);
}
总结
  • tryRelease方法会减去state对于的值,等于0的话会彻底释放资源,完全释放资源之后,会调用unparkSuccessor唤醒CLH队列中第一个等待资源的线程

公平锁VS非公平锁

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
java复制代码   /**
* ReentrantLock公平锁
*/
static final class FairSync extends Sync {
final void lock() {
acquire(1);
}
//AQS部分
public final void acquire(int arg) {
if (!tryAcquire(arg) &&acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
//公平平锁在获取锁之前会先判断等待队列是否为空或者自己是否位于队列头部,该条件通过才能继续获取锁
if (!hasQueuedPredecessors() &&compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
}
/**
*ReentrantLock非公平锁
*/
static final class NonfairSync extends Sync {
final void lock() {
//非公平锁在尝试获取锁时,不会调用hasQueuedPredecessors
if (compareAndSetState(0, 1))
//获取锁成功
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
}

问题

我们尝试说下这几个问题的回答点

  • AQS核心思想是什么?如何实现的,以及底层的数据结构
    • state以及CLH队列
  • 线程获取锁失败,之后的处理流程是什么
    • 队列排队等候,线程继续等待,保留获取锁的可能,获取锁的流程仍然继续
  • 处于排队等候机制中的线程一直无法获取到锁,需要一直等待嘛,AQS还有用别的策略解决这一问题
    • 节点变为取消状态(finally执行的),节点从链表中摘掉
  • Lock函数通过Acquire方法进行加锁的时候,具体是如何进行加锁
    • AQS的Acquire会调用tryAcquire方法,tryAcquire由各个自定义同步器实现(公平非公平),通过tryAcquire完成加锁过程,

闲谈

感觉有帮助的同学还请点赞关注,这将对我是很大的鼓励~,公众号有自己开始总结的一系列文章,需要的小伙伴还请关注下个人公众号程序员fly呀,干货多多,湿货也不少(∩_∩)。

巨人肩膀

  • 文章主要参考自美团技术文章从ReentrantLock的实现看AQS的原理及应用,在此基础上做了增改
  • juejin.cn/post/700689…
  • tech.meituan.com/2019/12/05/…
  • juejin.cn/post/689627…

本文转载自: 掘金

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

分布式 ID 生成器

发表于 2021-11-24

背景

假设我们有一个分布式系统,系统中需要维护全局 id 字段,我们可以把它认为是唯一的标识,不能够重复出现,那么问题来了,我们应该如何生成这样的 id 呢?

其实很容易想到的一种解决方式就是使用 Redis 的键值对了,每次更新的时候直接调用 incr,生成的 id 也是唯一的,还有一种方式就是使用 MySQL 或者其他的数据库,因为我们知道 MySQL 中可以生成自增主键,使用这个主键作为一个分布式 id 也是可行的。

但是上面的这两种方式效率不会特别高,并且依赖于第三方,我们如果想要更高效的生成分布式 id,那么最好的方式就是尽量本地生成,不需要和其他节点进行协商,但是有一个问题出现了,如何保证 id 不重复?,我们可以使用 Snowflake 算法来解决该问题。

概念

Snowflake 可用于在分布式系统中生成唯一的 id,由 Twitter 提出,目前存在很多不同的版本,但是基本的思想是一致的,只不过不同版本不同结构采用的位数不一致。

Snowflakes 使用 64 比特, 但是只有 63 位被使用,第一个比特位为符号位,大体结构如下:

image.png

timestamp 占 41 bits,是生成 ID 的时间戳,也可以是相对某一个特定时间的时间戳差,machine id 为分布式系统每一个机器分配到的 id 号,10 bits 表示最多 1024 台机器,sequence number 表示序列号,因为同一个时间戳可能分配多个 id。

ID 生成

每一台机器的 machine id 都是事先配置好的,可以由数据中心 id 和数据中心的机器 id 组成,直接可以获取到。当我们需要生成一个 id 的时候,首先我们需要获取当前的时间戳,判断是否和上一次的时间戳一致,如果说和上一次的时间戳一致,那么我们应该增加序列号,然后通过移位操作构造对应的一个 64 bits 的 id 号返回。 如果说当前的时间戳与上一次的不同,那么我们直接修改时间戳,然后序列号取零,进行拼接即可。

image.png

代码实现

算法的实现挺简单的,下面给出核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
go复制代码func (s *Snowflake) GetID() int64 {
// 首先获取当前的时间戳
timestamp := time.Now().UnixMilli()
// 相同的时间戳序列号+1
if timestamp == s.LastTimestamp {
s.Sequence = (s.Sequence + 1) & sequenceMask
// 重新绕了一圈
// 同一个时间戳里面生成了很多 id
if s.Sequence == 0 {
for timestamp <= s.LastTimestamp {
timestamp = time.Now().UnixMilli()
}
}
} else {
s.Sequence = 0
}
// 重新设置
s.LastTimestamp = timestamp

// 进行拼接
return (timestamp-epoch)<<timestampShift | (s.DatacenterID << datacenterIDShift) | (s.WorkerID << workerIDShift) | s.Sequence
}

测试

写了一个很简单的基准测试

1
2
3
4
5
6
go复制代码func BenchmarkGetID(b *testing.B) {
s := New(10, 10)
for i := 0; i < b.N; i++ {
s.GetID()
}
}

下面是它的结果,表现还不错吧,这样算起来,假设每次需要 250ns,那么 1s 也可以生成 4,000,000 个不同的 id

1
2
3
4
5
6
7
makefile复制代码goos: windows
goarch: amd64
pkg: snowflake
cpu: Intel(R) Core(TM) i7-8565U CPU @ 1.80GHz
BenchmarkGetID-8 4897538 246.0 ns/op
PASS
ok snowflake 2.069s

Twitter 其实也提供了自己的 Scala 实现方式,具体的可以参见 GitHub, 本文的实现方式可以参见 我的仓库

本文转载自: 掘金

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

【Go实战 电商平台】(4) 用户注册 1 编写路由

发表于 2021-11-24

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

用到的包

github.com/gin-contrib/sessions

github.com/gin-contrib/sessions/cookie

github.com/gin-gonic/gin

  1. 编写路由

  • 新建一个WSGI应用程序实例。
1
go复制代码	r := gin.Default()
  • 设置全局变量store
1
go复制代码	store := cookie.NewStore([]byte("something-very-secret"))
  • 在路由中使用中间件调用store
1
go复制代码	r.Use(sessions.Sessions("mysession", store))
  1. 注册路由

  • 分一个基础路由组
1
go复制代码v1 := r.Group("api/v1")
  • 在这个基础路由编写用户注册路由
1
2
3
4
5
go复制代码v1 := r.Group("api/v1")
{
//用户操作
v1.POST("user/register", api.UserRegister)
}
  1. 注册接口

3.1 service层

  • 在service先创建一个user.go

在这里插入图片描述

  • 再在service层上编写用户注册服务的结构体
1
2
3
4
5
6
go复制代码//UserRegisterService 管理用户注册服务
type UserRegisterService struct {
Nickname string `form:"nickname" json:"nickname" binding:"required,min=2,max=10"`
UserName string `form:"user_name" json:"user_name" binding:"required,min=5,max=15"`
Password string `form:"password" json:"password" binding:"required,min=8,max=16"`
}
  • 在service上编写用户注册register方法
1
2
3
go复制代码func (service *UserRegisterService) Register() {

}

在这里插入图片描述

3.2 api层

  • 在api层创建user.go

在这里插入图片描述

  • 申请一个 UserRegisterService用户注册服务对象。
1
go复制代码var userRegisterService service.UserRegisterService

但是这个服务我们还没有写,先把这个接口写完,再把这个服务补上。

  • 上下文绑定数据
1
go复制代码c.ShouldBind(&userRegisterService)
  • 调用这个服务的register方法
1
go复制代码res := userRegisterService.Register()
  • 返回这个服务的处理结果
1
go复制代码c.JSON(200, res)
  • api 层的用户注册服务全部代码
1
2
3
4
5
6
7
8
9
10
11
12
go复制代码func UserRegister(c *gin.Context) {
var userRegisterService service.UserRegisterService
//相当于创建了一个UserRegisterService对象
if err := c.ShouldBind(&userRegisterService); err == nil {
res := userRegisterService.Register()
//调用这个对象中的Register方法。
c.JSON(200, res)
} else {
c.JSON(200, ErrorResponse(err))
logging.Info(err)
}
}
  • 创建一个common.go文件, 进行错误的返回

在这里插入图片描述

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
go复制代码//返回错误信息 ErrorResponse
func ErrorResponse(err error) serializer.Response {
if ve, ok := err.(validator.ValidationErrors); ok {
for _, e := range ve {
field := conf.T(fmt.Sprintf("Field.%s", e.Field))
tag := conf.T(fmt.Sprintf("Tag.Valid.%s", e.Tag))
return serializer.Response{
Status: 40001,
Msg: fmt.Sprintf("%s%s", field, tag),
Error: fmt.Sprint(err),
}
}
}
if _, ok := err.(*json.UnmarshalTypeError); ok {
return serializer.Response{
Status: 40001,
Msg: "JSON类型不匹配",
Error: fmt.Sprint(err),
}
}
return serializer.Response{
Status: 40001,
Msg: "参数错误",
Error: fmt.Sprint(err),
}
}

3.2 serializer

  • 创建一个基础的序列化返回结构体
1
2
3
4
5
6
7
go复制代码// Response 基础序列化器
type Response struct {
Status int `json:"status"`
Data interface{} `json:"data"`
Msg string `json:"msg"`
Error string `json:"error"`
}

在这里插入图片描述

  1. 注册服务

接下来我们就可以编写register()注册服务了

  • 先对传过来的用户名进行验证,查看是否已经存在了
1
2
3
4
5
6
7
8
go复制代码	model.DB.Model(&model.User{}).Where("user_name=?",service.UserName).Count(&count)
if count == 1 {
code = e.ErrorExistUser
return serializer.Response{
Status: code,
Msg: e.GetMsg(code),
}
}
  • 再对密码进行加密
1
2
3
4
5
6
7
8
go复制代码	if err := user.SetPassword(service.Password); err != nil {
logging.Info(err)
code = e.ErrorFailEncryption
return serializer.Response{
Status: code,
Msg: e.GetMsg(code),
}
}

注意: 我们可以直接再model/user.go下编写密码加密方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
go复制代码const (
PassWordCost = 12 //密码加密难度
Active string = "active" //激活用户
)

//SetPassword 设置密码
func (user *User) SetPassword(password string) error {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), PassWordCost)
if err != nil {
return err
}
user.PasswordDigest = string(bytes)
return nil
}
  • 确认无误之后对用户进行创建
1
2
3
4
5
6
7
8
go复制代码	if err := model.DB.Create(&user).Error; err != nil {
logging.Info(err)
code = e.ErrorDatabase
return serializer.Response{
Status: code,
Msg: e.GetMsg(code),
}
}

服务层中用户注册的完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
go复制代码func (service *UserRegisterService) Register() serializer.Response {
var user model.User
var count int
code := e.SUCCESS
model.DB.Model(&model.User{}).Where("user_name=?",service.UserName).Count(&count)
if count == 1 {
code = e.ErrorExistUser
return serializer.Response{
Status: code,
Msg: e.GetMsg(code),
}
}
user = model.User{
Nickname: service.Nickname,
UserName: service.UserName,
Status: model.Active,
}
//加密密码
if err := user.SetPassword(service.Password); err != nil {
logging.Info(err)
code = e.ErrorFailEncryption
return serializer.Response{
Status: code,
Msg: e.GetMsg(code),
}
}
user.Avatar = "http://q1.qlogo.cn/g?b=qq&nk=294350394&s=640"
//创建用户
if err := model.DB.Create(&user).Error; err != nil {
logging.Info(err)
code = e.ErrorDatabase
return serializer.Response{
Status: code,
Msg: e.GetMsg(code),
}
}
return serializer.Response{
Status: code,
Msg: e.GetMsg(code),
}
}

下一章中,我们编写用户登录的业务逻辑。

本文转载自: 掘金

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

Android存储数据的三种方式

发表于 2021-11-24

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

今天来给大家讲一下Android中如何存储数据。我编写Android使用的是Java语言,所以今天讲的也是Java版的数据存储。在Android中,数据存储主要有三种,文件存储、Sp、SQLite。文件存储就是我们平时的IO流,是非常传统的一种方式。而Sp是Android中的,利用XML文件存储数据的一种方式,要比文件存储简单。SQLite就是一个数据库了,基本操作和数据库大致一样。

1、文件存储

先写一个简洁的登陆界面:

​

布局文件activity_main.xml如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
ini复制代码<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".MainActivity">

<EditText
android:id="@+id/et_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="用户名"/>

<EditText
android:id="@+id/et_pwd"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPassword"
android:hint="密码"/>

<Button
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:onClick="save"
android:text="登录"/>

</LinearLayout>

MainActivity中基本代码如下,就是简单的声明控件和关联控件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
scala复制代码public class MainActivity extends AppCompatActivity {
//声明控件
private EditText etName;
private EditText etPwd;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
initView();
}
private void initView() {
//关联控件
etName = findViewById(R.id.et_name);
etPwd = findViewById(R.id.et_pwd);
}
}

1.1、保存文件

接下来实现一下点击事件save():

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
scss复制代码public void save(View view) {
//当用户名密码不为空时
if(!TextUtils.isEmpty(etName.getText()) && !TextUtils.isEmpty(etPwd.getText())){
FileOutputStream fos = null;
try {


//Context中的方法openFileOutput(),获取一个FileOutputStream对象
fos = openFileOutput("data", Context.MODE_PRIVATE);
String str = etName.getText().toString().trim() + "#" + etPwd.getText().toString().trim();
fos.write(str.getBytes());
Toast.makeText(getApplicationContext(), "保存成功", Toast.LENGTH_SHORT).show();



} catch (Exception e) {
e.printStackTrace();
} finally {
try {
fos.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}else{
Toast.makeText(getApplicationContext(), "用户名或密码不能为空", Toast.LENGTH_SHORT).show();
}
}

代码看起来有点乱,我在中间空了许多,主要代码就那些。利用Context中的openFileOutput(String name, int mode)方法,传入文件名和操作模式。获取一个FileOutputStream对象,然后在存储文件。我这里直接用#来拼接,这样是有问题的。这里只是为了方便。

1.2、读取文件

我们写一个方法load,用来读取文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
ini复制代码private void load() {
try{
//利用Context中的openFileInput()方法获取输入流
FileInputStream data = openFileInput("data");
BufferedReader reader = new BufferedReader(new InputStreamReader(data));
String line = reader.readLine();
String[] split = line.split("#");
etName.setText(split[0]);
etPwd.setText(split[1]);
} catch (Exception e) {
e.printStackTrace();
}
}

这里使用了Context中的openFileInput()方法,获取流,然后读取文件。因为文件流比较少用,我就讲到这里。

2、SharedPreferences简称Sp

Sp是一种用xml文件存储数据的方式,下面我具体讲一下。

2.1、Sp的创建

sp的创建方式有三种,第一种,之直接使用Context中的getSharedPreferences()方法,传入文件名和操作模式:

1
2
3
4
csharp复制代码private void initView(){
//使用Context中的getSharedPreferences方法获取Sp对象
SharedPreferences sp = getSharedPreferences("data", Context.MODE_PRIVATE);
}

第二种,使用Activity中的getPreferences()方法,传入一个操作模式,文件名自动以类名命名:

1
ini复制代码SharedPreferences sp = getPreferences(Context.MODE_PRIVATE);

第三种,使用PreferenceManager中的getDefaultPreferences()方法,传入一个Context参数:

1
ini复制代码SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(this);

2.2、用Sp保存数据

使用Editor对象存储,Editor中对应的数据类型有对应的方法。putString、putInt…

1
2
3
4
5
6
7
8
9
10
11
12
13
14
java复制代码private void initData(){
//获取sp对象
SharedPreferences sp = getSharedPreferences("data", Context.MODE_PRIVATE);

//获取Editor对象
SharedPreferences.Editor editor = sp.edit();

//用Editor对象储存数据,传入键和值
editor.putString("name", "zack");

//最后调用apply()方法
editor.apply();

}

2.3、获取Sp中的文件

获取的时候直接用存储时的文件名创建一个Sp对象,就可以读取数据:

1
2
3
4
5
6
7
8
csharp复制代码private void initData(){
//创建一个文件名为data的sp对象
SharedPreferences sp = getSharedPreferences("data", Context.MODE_PRIVATE);

//直接通过键获取数据,如果通过这个键找不到,就返回第二个参数中的值
sp.getString("name", null);

}

3、SQLite数据库

3.1、SQLite数据库的创建

SQLite数据库的创建需要实现抽象类SQLiteOpenHelper,具体先定义一个类MySQLiteOpenHelper继承SQLiteOpenHelper:

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
java复制代码public class MySQLiteOpenHelper extends SQLiteOpenHelper {

/**
* @param context 上下文
* @param name 数据库名称
* @param factory 游标工场
* @param version 版本
*/
public MySQLiteOpenHelper(Context context, String name, SQLiteDatabase.CursorFactory factory, int version) {
super(context, name, factory, version);
}

/**
* 数据库创建时调用这个方法
* @param db 数据库对象
*/
@Override
public void onCreate(SQLiteDatabase db) {

}

/**
* 数据库升级的时候自动调用
* @param db 数据库对象
* @param oldVersion 老版本
* @param newVersion 新版本
*/
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {

}
}

这里写了三个方法,其中onCreate()和onUpgrade()方法为SQLiteOpenHelper中的抽象方法。onCreate()在数据库创建时调用,只。而onUpgrade()在数据库升级时调用()(Version改变时)。所以onCreate()用于初始化表结构、onUpgrade()用于更新表结构。调用db.execSQL()方法,传入一个SQL语句就好了。

3.2、获取数据库

在Activity中创建MySQLiteOpenHelper的实例,然后通过这个实例获取数据库:

1
2
3
4
5
6
7
8
9
java复制代码private void initData(){
//这里游标工场暂时用不到,设为null
MySQLiteOpenHelper sqLiteOpenHelper = new MySQLiteOpenHelper(this, "db", null, 1);

//通过MySQLiteOpenHelper对象获取数据。这两个方法暂时不区别
//SQLiteDatabase db = sqLiteOpenHelper.getWritableDatabase();
SQLiteDatabase db = sqLiteOpenHelper.getReadableDatabase();

}

3.3、数据库操作

因为篇幅的关系,这里先不讲这么多了。SQLite中可以调用DataBase对象的execSQL()方法,通过SQL语句完成大多数操作。具体Android中特有的操作后面我再讲。大家有兴趣的话可以去了解一下郭林大神的LitePal,用来操作SQLite数据库方便很多。

​

本文转载自: 掘金

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

又一次漏洞复现与分析

发表于 2021-11-24

漏洞介绍

Weblogic的WLS Security组件对外提供webservice服务,其中使用了XMLDecoder来解析用户传入的XML数据,在解析的过程中出现反序列化漏洞,导致可执行任意命令。攻击者发送精心构造的xml数据甚至能通过反弹shell拿到权限。

影响版本

OracleWebLogic Server10.3.6.0.0
OracleWebLogic Server12.1.3.0.0
OracleWebLogic Server12.2.1.1.0
OracleWebLogic Server12.2.1.2.0

漏洞复现环境

攻击方:kali 2021

服务端:vulhub的weblogic的CVE-2017-10271环境

漏洞复现

攻击方使用Burp发送反弹shell的payload:

image.png

【一>所有资源获取<一】
1、200份很多已经买不到的绝版电子书
2、30G安全大厂内部的视频资料
3、100份src文档
4、常见安全面试题
5、ctf大赛经典题目解析
6、全套工具包
7、应急响应笔记

成功得到shell:

image.png

POC

github.com/NingKn/CVE_…

漏洞分析

CVE-2017-10271漏洞是WebLogic Server WLS组件的远程命令执行漏洞,触发漏洞url如下:

http://192.168.124.42:7001/wls-wsat/CoordinatorPortType 发送post数据包,通过构造SOAP(XML)格式的请求,在解析的过程中导致XMLDecoder反序列化漏洞。

  1. 在weblogic/wsee/jaxws/workcontext/WorkContextServerTube类的processRequest方法中,处理我们发送的POST数据包中的SOAP(XML)数据。var1即是传入的SOAP数据,我们idea配置好远程调试并且开启debug后,在processRequest方法中下断点:

image.png

  1. Burp通过post发送数据包后,idea停留在断点处,查看其中的var1变量既是我们发送的xml数据:
    image.png
  2. 代码往下走,进入readHeaderOld方法,跟进前我们查看var3变量的信息:
    image.png

image.png

  1. 跟进readHeaderOld方法,发现这里其实就是进行了一波转换,生成了WorkContextXmlInputAdapert var6对象,下图是该方法中的var4变量存放了的xml数据:
    image.png
  2. Var6中的buf集合就是var4中的数据,及我们传输的xml:
    image.png
  3. 跟进receive方法,var1实参是上面var6形参:

image.png

  1. 跟进var2的receiveRequest方法:
    image.png
  2. 这里的var1就是上面的var1形参:
    image.png
  3. 跟进receiveRequest方法:
    image.png
  4. 跟进readEntry方法,var0存放了xml数据:
    image.png
  5. 跟进var0的readUTF方法:
    image.png

直接调用了xmlDecoder.readObject方法,产生漏洞。

总结:

XMLDecoder来解析用户传入的XML数据,在解析的过程中出现反序列化漏洞,导致可执行任意命令执行。

漏洞修复:

方法一:删除wls-wsat组件,然后重启weblogic

方法二:官网下载,打补丁

本文转载自: 掘金

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

Java代理模式之Java外观模式 Java外观模式

发表于 2021-11-24

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

Java外观模式

外观模式(Facade Pattern)隐藏系统的复杂性,并向客户端提供了一个客户端可以访问系统的接口。这种类型的设计模式属于结构型模式,它向现有的系统添加一个接口,来隐藏系统的复杂性。

这种模式涉及到一个单一的类,该类提供了客户端请求的简化方法和对现有系统类方法的委托调用。

介绍

意图:为子系统中的一组接口提供一个一致的界面,外观模式定义了一个高层接口,这个接口使得这一子系统更加容易使用。

主要解决:降低访问复杂系统的内部子系统时的复杂度,简化客户端与之的接口。

何时使用:

⒈客户端不需要知道系统内部的复杂联系,整个系统只需提供一个”接待员”即可。

⒉定义系统的入口。

如何解决:客户端不与系统耦合,外观类与系统耦合。

关键代码:在客户端和复杂系统之间再加一层,这一层将调用顺序、依赖关系等处理好。

应用实例:

⒈去医院看病,可能要去挂号、门诊、划价、取药,让患者或患者家属觉得很复杂,如果有提供接待人员,只让接待人员来处理,就很方便。

⒉JAVA 的三层开发模式。

优点:

⒈减少系统相互依赖。

⒉提高灵活性。

⒊提高了安全性。

缺点:不符合开闭原则,如果要改东西很麻烦,继承重写都不合适。

使用场景:

⒈为复杂的模块或子系统提供外界访问的模块。

⒉子系统相对独立。

⒊预防低水平人员带来的风险。

注意事项:在层次化结构中,可以使用外观模式定义系统中每一层的入口。

实现

我们将创建一个 Shape 接口和实现了 Shape 接口的实体类。下一步是定义一个外观类 ShapeMaker。

ShapeMaker 类使用实体类来代表用户对这些类的调用。FacadePatternDemo,我们的演示类使用 ShapeMaker 类来显示结果。

步骤 1

创建一个接口。

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

步骤 2

创建实现接口的实体类。

1
2
3
4
5
6
7
typescript复制代码public class Rectangle implements Shape {

@Override
public void draw() {
System.out.println("Rectangle::draw()");
}
}
1
2
3
4
5
typescript复制代码public class Square implements Shape {

@Override
public void draw() {
System.out.println("Square::draw()");
1
2
3
4
5
6
7
typescript复制代码public class Circle implements Shape {

@Override
public void draw() {
System.out.println("Circle::draw()");
}
}

步骤 3

创建一个外观类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
csharp复制代码public class ShapeMaker {
private Shape circle;
private Shape rectangle;
private Shape square;

public ShapeMaker() {
circle = new Circle();
rectangle = new Rectangle();
square = new Square();
}

public void drawCircle(){
circle.draw();
}
public void drawRectangle(){
rectangle.draw();
}
public void drawSquare(){
square.draw();
}
}

步骤 4

使用该外观类画出各种类型的形状。

1
2
3
4
5
6
7
8
9
typescript复制代码public class FacadePatternDemo {
public static void main(String[] args) {
ShapeMaker shapeMaker = new ShapeMaker();

shapeMaker.drawCircle();
shapeMaker.drawRectangle();
shapeMaker.drawSquare();
}
}

步骤 5

执行程序,输出结果:

1
2
3
scss复制代码Circle::draw()
Rectangle::draw()
Square::draw()

本文转载自: 掘金

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

1…210211212…956

开发者博客

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