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

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


  • 首页

  • 归档

  • 搜索

还在从零开始搭建项目?手撸了款快速开发脚手架!

发表于 2020-09-15

SpringBoot实战电商项目mall(40k+star)地址:github.com/macrozheng/…

摘要

之前开源了一款项目骨架mall-tiny,完整继承了mall项目的整个技术栈。总感觉mall-tiny集成了太多中间件,过于复杂了。这次对其进行了简化和升级,使它成为了一款拥有完整权限管理功能的快速开发脚手架,希望对大家有所帮助!

简介

mall-tiny是一款基于SpringBoot+MyBatis-Plus的快速开发脚手架,拥有完整的权限管理功能,可对接Vue前端,开箱即用。

项目演示

mall-tiny项目可无缝对接mall-admin-web前端项目,秒变权限管理系统。前端项目地址:github.com/macrozheng/…

技术选型

技术 版本 说明
SpringBoot 2.3.0 容器+MVC框架
SpringSecurity 5.3.2 认证和授权框架
MyBatis 3.5.4 ORM框架
MyBatis-Plus 3.3.2 MyBatis增强工具
MyBatis-Plus Generator 3.3.2 数据层代码生成器
Swagger-UI 2.9.2 文档生产工具
Redis 5.0 分布式缓存
Docker 18.09.0 应用容器引擎
Druid 1.1.10 数据库连接池
JWT 0.9.0 JWT登录支持
Lombok 1.18.12 简化对象封装工具

数据库表结构

  • 化繁为简,仅保留了权限管理功能相关的9张表,方便自由定制;
  • 数据库源文件地址:github.com/macrozheng/…

使用流程

环境搭建

简化依赖服务,只需安装最常用的MySql和Redis服务即可,服务安装具体参考《mall在Windows环境下的部署》,数据库中需要导入mall_tiny.sql脚本。

开发规约

项目包结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
lua复制代码src
├── common -- 用于存放通用代码
| ├── api -- 通用结果集封装类
| ├── config -- 通用配置类
| ├── domain -- 通用封装对象
| ├── exception -- 全局异常处理相关类
| └── service -- 通用业务类
├── config -- SpringBoot中的Java配置
├── domain -- 共用封装对象
├── generator -- MyBatis-Plus代码生成器
├── modules -- 存放业务代码的基础包
| └── ums -- 权限管理模块业务代码
| ├── controller -- 该模块相关接口
| ├── dto -- 该模块数据传输封装对象
| ├── mapper -- 该模块相关Mapper接口
| ├── model -- 该模块相关实体类
| └── service -- 该模块相关业务处理类
└── security -- SpringSecurity认证授权相关代码
├── annotation -- 相关注解
├── aspect -- 相关切面
├── component -- 认证授权相关组件
├── config -- 相关配置
└── util -- 相关工具类

资源文件说明

1
2
3
4
5
6
lua复制代码resources
├── mapper -- MyBatis中mapper.xml存放位置
├── application.yml -- SpringBoot通用配置文件
├── application-dev.yml -- SpringBoot开发环境配置文件
├── application-prod.yml -- SpringBoot生产环境配置文件
└── generator.properties -- MyBatis-Plus代码生成器配置

接口定义规则

  • 创建表记录:POST /{控制器路由名称}/create
  • 修改表记录:POST /{控制器路由名称}/update/{id}
  • 删除指定表记录:POST /{控制器路由名称}/delete/{id}
  • 分页查询表记录:GET /{控制器路由名称}/list
  • 获取指定记录详情:GET /{控制器路由名称}/{id}
  • 具体参数及返回结果定义可以运行代码查看Swagger-UI的Api文档:http://localhost:8080/swagger-ui.html

项目运行

直接运行启动类MallTinyApplication的main函数即可。

业务代码开发流程

创建业务表

创建好pms模块的所有表,需要注意的是一定要写好表字段的注释,这样实体类和接口文档中就会自动生成字段说明了。

使用代码生成器

运行MyBatisPlusGenerator类的main方法来生成代码,可直接生成controller、service、mapper、model、mapper.xml的代码,无需手动创建。

  • 代码生成器支持两种模式,一种生成单表的代码,比如只生成pms_brand表代码可以先输入pms,后输入pms_brand;

  • 生成代码结构一览;

  • 另一种直接生成整个模块的代码,比如生成pms模块代码可以先输入pms,后输入pms_*。

编写业务代码

单表查询

由于MyBatis-Plus提供的增强功能相当强大,单表查询几乎不用手写SQL,直接使用ServiceImpl和BaseMapper中提供的方法即可。

比如我们的菜单管理业务实现类UmsMenuServiceImpl中的方法都直接使用了这些方法。

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复制代码/**
* 后台菜单管理Service实现类
* Created by macro on 2020/2/2.
*/
@Service
public class UmsMenuServiceImpl extends ServiceImpl<UmsMenuMapper,UmsMenu>implements UmsMenuService {

@Override
public boolean create(UmsMenu umsMenu) {
umsMenu.setCreateTime(new Date());
updateLevel(umsMenu);
return save(umsMenu);
}

@Override
public boolean update(Long id, UmsMenu umsMenu) {
umsMenu.setId(id);
updateLevel(umsMenu);
return updateById(umsMenu);
}

@Override
public Page<UmsMenu> list(Long parentId, Integer pageSize, Integer pageNum) {
Page<UmsMenu> page = new Page<>(pageNum,pageSize);
QueryWrapper<UmsMenu> wrapper = new QueryWrapper<>();
wrapper.lambda().eq(UmsMenu::getParentId,parentId)
.orderByDesc(UmsMenu::getSort);
return page(page,wrapper);
}

@Override
public List<UmsMenuNode> treeList() {
List<UmsMenu> menuList = list();
List<UmsMenuNode> result = menuList.stream()
.filter(menu -> menu.getParentId().equals(0L))
.map(menu -> covertMenuNode(menu, menuList)).collect(Collectors.toList());
return result;
}

@Override
public boolean updateHidden(Long id, Integer hidden) {
UmsMenu umsMenu = new UmsMenu();
umsMenu.setId(id);
umsMenu.setHidden(hidden);
return updateById(umsMenu);
}
}
分页查询

对于分页查询MyBatis-Plus原生支持,不需要再整合其他插件,直接构造Page对象,然后调用ServiceImpl中的page方法即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码/**
* 后台菜单管理Service实现类
* Created by macro on 2020/2/2.
*/
@Service
public class UmsMenuServiceImpl extends ServiceImpl<UmsMenuMapper,UmsMenu>implements UmsMenuService {
@Override
public Page<UmsMenu> list(Long parentId, Integer pageSize, Integer pageNum) {
Page<UmsMenu> page = new Page<>(pageNum,pageSize);
QueryWrapper<UmsMenu> wrapper = new QueryWrapper<>();
wrapper.lambda().eq(UmsMenu::getParentId,parentId)
.orderByDesc(UmsMenu::getSort);
return page(page,wrapper);
}
}
多表查询

对于多表查询,我们需要手写mapper.xml中的SQL实现,由于之前我们已经生成了mapper.xml文件,所以我们直接在Mapper接口中定义好方法,然后在mapper.xml写好SQL实现即可。

  • 比如说我们需要写一个根据用户ID获取其分配的菜单的方法,首先我们在UmsMenuMapper接口中添加好getMenuList方法;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码/**
* <p>
* 后台菜单表 Mapper 接口
* </p>
*
* @author macro
* @since 2020-08-21
*/
public interface UmsMenuMapper extends BaseMapper<UmsMenu> {

/**
* 根据后台用户ID获取菜单
*/
List<UmsMenu> getMenuList(@Param("adminId") Long adminId);

}
  • 然后在UmsMenuMapper.xml添加该方法的对应SQL实现即可。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
xml复制代码<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.macro.mall.tiny.modules.ums.mapper.UmsMenuMapper">

<select id="getMenuList" resultType="com.macro.mall.tiny.modules.ums.model.UmsMenu">
SELECT
m.id id,
m.parent_id parentId,
m.create_time createTime,
m.title title,
m.level level,
m.sort sort,
m.name name,
m.icon icon,
m.hidden hidden
FROM
ums_admin_role_relation arr
LEFT JOIN ums_role r ON arr.role_id = r.id
LEFT JOIN ums_role_menu_relation rmr ON r.id = rmr.role_id
LEFT JOIN ums_menu m ON rmr.menu_id = m.id
WHERE
arr.admin_id = #{adminId}
AND m.id IS NOT NULL
GROUP BY
m.id
</select>

</mapper>

项目部署

mall-tiny已经集成了Docker插件,可以打包成Docker镜像来部署,具体参考:《使用Maven插件为SpringBoot应用构建Docker镜像》

其他说明

SpringSecurity相关

由于使用了SpringSecurity来实现认证和授权,部分接口需要token才可以访问,访问需要认证授权接口流程如下。

  • 访问Swagger-UI接口文档:http://localhost:8080/swagger-ui.html
  • 调用登录接口获取token;

  • 点击右上角Authorize按钮输入token,然后访问相关接口即可。

请求参数校验

默认集成了Jakarta Bean Validation参数校验框架,只需在参数对象属性中添加javax.validation.constraints包中的注解注解即可实现校验功能,这里以登录参数校验为例。

  • 首先在登录请求参数中添加@NotEmpty注解;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
java复制代码/**
* 用户登录参数
* Created by macro on 2018/4/26.
*/
@Data
@EqualsAndHashCode(callSuper = false)
public class UmsAdminLoginParam {
@NotEmpty
@ApiModelProperty(value = "用户名",required = true)
private String username;
@NotEmpty
@ApiModelProperty(value = "密码",required = true)
private String password;
}
  • 然后在登录接口中添加@Validated注解开启参数校验功能即可。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
java复制代码/**
* 后台用户管理
* Created by macro on 2018/4/26.
*/
@Controller
@Api(tags = "UmsAdminController", description = "后台用户管理")
@RequestMapping("/admin")
public class UmsAdminController {

@ApiOperation(value = "登录以后返回token")
@RequestMapping(value = "/login", method = RequestMethod.POST)
@ResponseBody
public CommonResult login(@Validated @RequestBody UmsAdminLoginParam umsAdminLoginParam) {
String token = adminService.login(umsAdminLoginParam.getUsername(), umsAdminLoginParam.getPassword());
if (token == null) {
return CommonResult.validateFailed("用户名或密码错误");
}
Map<String, String> tokenMap = new HashMap<>();
tokenMap.put("token", token);
tokenMap.put("tokenHead", tokenHead);
return CommonResult.success(tokenMap);
}
}

项目地址

开源不易,觉得项目有帮助的话点个Star支持下吧!

github.com/macrozheng/…

本文转载自: 掘金

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

ES + Spring boot的正确姿势 (ES系列三)

发表于 2020-09-13

前言

在前边我们探讨了ES的基本概念以及根据不同的场景选择数据迁移的方案。在这一篇我们来探讨如何与Spring boot集成,以及为了平滑地从Mysql迁移到ES中我们如何”翻译SQL“。

一、Spring Boot集成ES

第一步我们就要实现Spring boot和ES集成,在Spring boot中主要有Java REST Client、spring-data-elasticsearch两种方式,这里我建议使用Elasticsearch官方提供的Java High Level REST Client来集成,也方便在生产环境中使用阿里云的ES云服务。关键的版本信息如下:

  • ES集群:7.3.0
  • ES相关依赖:7.3.0

这里有两点需要注意:

  • High Level Client能够向上兼容,例如7.3.0版本的Java High Level REST Client能确保与大于等于7.3.0版本的Elasticsearch集群通信。为了保证最大程度地使用最新版客户端的特性,推荐High Level Client版本与集群版本一致。
  • 在集成的过程中可能会踩到一些坑,因为Spring Boot的版本、ES集群的版本、High Level Client的版本之间会存在”关联关系“,所以当Demo无法正常跑起来的时候能做的就是多尝试一些High Level Client版本。

1、pom依赖

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
xml复制代码<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-high-level-client</artifactId>
<version>7.3.0</version>
</dependency>

<dependency>
<groupId>org.elasticsearch</groupId>
<artifactId>elasticsearch</artifactId>
<version>7.3.0</version>
</dependency>

<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-client</artifactId>
<version>7.3.0</version>
</dependency>

<dependency>
<groupId>org.elasticsearch.plugin</groupId>
<artifactId>rank-eval-client</artifactId>
<version>7.3.0</version>
</dependency>

<dependency>
<groupId>org.elasticsearch.plugin</groupId>
<artifactId>lang-mustache-client</artifactId>
<version>7.3.0</version>
</dependency>

2、初始化客户端

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

@Value("${elasticsearch.host}")
public String host;

/**
* 之前使用transport的接口的时候是9300端口,现在使用HighLevelClient则是9200端口
*/
@Value("${elasticsearch.port:9200}")
public int port;

public static final String SCHEME = "http";

@Value("${elasticsearch.username:admin}")
public String username;

@Value("${elasticsearch.authenticationPassword}")
public String authenticationPassword;

@Bean(name = "remoteHighLevelClient")
public RestHighLevelClient restHighLevelClient() {
final CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
credentialsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials(username,
authenticationPassword));
RestClientBuilder builder = RestClient.builder(new HttpHost(host, port, SCHEME)).
setHttpClientConfigCallback(httpClientBuilder -> httpClientBuilder
.setDefaultCredentialsProvider(credentialsProvider));
return new RestHighLevelClient(builder);
}
}

在上边的代码中需要注意username和authenticationPassword的认证信息都是在Kibana中设置的。

二、Java API

下面的代码片段均能在单元测试中正常运行,在执行下边的单元测试之前,我们先创建一个_template,大家可以选择在Kibana提供的Dev Tools里边执行。

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
json复制代码PUT _template/hero_template
{
"index_patterns":[
"hero*"
],
"mappings":{
"properties":{
"@timestamp":{
"type":"date"
},
"id":{
"type":"integer"
},
"name":{
"type":"keyword"
},
"country":{
"type":"keyword"
},
"birthday":{
"type":"keyword"
},
"longevity":{
"type":"integer"
}
}
}
}

1、创建索引

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码@Test
public void createIndex() throws IOException {
IndexRequest request = new IndexRequest("hero");
request.id("1");
Map<String, String> map = new HashMap<>();
map.put("id", "1");
map.put("name", "曹操");
map.put("country", "魏");
map.put("birthday", "公元155年");
map.put("longevity", "65");
request.source(map);
IndexResponse indexResponse = client.index(request, RequestOptions.DEFAULT);
long version = indexResponse.getVersion();
assertEquals(DocWriteResponse.Result.CREATED, indexResponse.getResult());
assertEquals(1, version);
}

在ES中索引是我们存储、查询数据的逻辑单元,在ES7.0之后对应的是Mysql中表的概念。上边的代码我们创建了一个名为hero的索引,然后我们创建一个map作为我们插入的第一条数据,然后设置到IndexRequest请求对象中。

2、批量插入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
java复制代码@Test
public void bulkRequestTest() throws IOException {
BulkRequest request = new BulkRequest();
request.add(new IndexRequest("hero").id("2")
.source(XContentType.JSON,"id", "2", "name", "刘备", "country", "蜀", "birthday", "公元161年", "longevity", "61"));
request.add(new IndexRequest("hero").id("3")
.source(XContentType.JSON,"id", "3", "name", "孙权", "country", "吴", "birthday", "公元182年", "longevity", "61"));
request.add(new IndexRequest("hero").id("4")
.source(XContentType.JSON,"id", "4", "name", "诸葛亮", "country", "蜀", "birthday", "公元181年", "longevity", "53"));
request.add(new IndexRequest("hero").id("5")
.source(XContentType.JSON,"id", "5", "name", "司马懿", "country", "魏", "birthday", "公元179年", "longevity", "72"));
request.add(new IndexRequest("hero").id("6")
.source(XContentType.JSON,"id", "6", "name", "荀彧", "country", "魏", "birthday", "公元163年", "longevity", "49"));
request.add(new IndexRequest("hero").id("7")
.source(XContentType.JSON,"id", "7", "name", "关羽", "country", "蜀", "birthday", "公元160年", "longevity", "60"));
request.add(new IndexRequest("hero").id("8")
.source(XContentType.JSON,"id", "8", "name", "周瑜", "country", "吴", "birthday", "公元175年", "longevity", "35"));
BulkResponse bulkResponse = client.bulk(request, RequestOptions.DEFAULT);
assertFalse(bulkResponse.hasFailures());
}

在kibana中查询到的数据如下图

我们后边的查询、更新等等操作都是基于这里的数据。

3、更新数据

1
2
3
4
5
6
7
8
java复制代码@Test
public void updateTest() throws IOException {
Map<String, Object> jsonMap = new HashMap<>();
jsonMap.put("country", "魏");
UpdateRequest request = new UpdateRequest("hero", "7").doc(jsonMap);
UpdateResponse updateResponse = client.update(request, RequestOptions.DEFAULT);
assertEquals(DocWriteResponse.Result.UPDATED, updateResponse.getResult());
}

上边的代码如果用SQL来表示就是下边这样

1
shell复制代码> update hero set country='魏' where id=7;

4、插入/更新数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java复制代码@Test
public void insertOrUpdateOne(){
Hero hero = new Hero();
hero.setId(5);
hero.setName("曹丕");
hero.setCountry("魏");
hero.setBirthday("公元187年");
hero.setLongevity(39);
IndexRequest request = new IndexRequest("hero");
request.id(hero.getId().toString());
request.source(JSON.toJSONString(hero), XContentType.JSON);
try {
IndexResponse indexResponse = client.index(request, RequestOptions.DEFAULT); // 1
assertEquals(DocWriteResponse.Result.UPDATED, indexResponse.getResult());
} catch (Exception e) {
throw new RuntimeException(e);
}
}

注意在上边代码中标注1的这行代码,是不是和前边创建索引很像?这里使用方法index()我们可以轻松的实现创建索引、插入数据、更新数据于一体,当指定的索引不存在时即创建索引,当数据不存在时就插入,数据存在时就更新。

5、删除数据

1
2
3
4
5
6
7
java复制代码@Test
public void deleteByIdTest() throws IOException {
DeleteRequest deleteRequest = new DeleteRequest("hero");
deleteRequest.id("1");
DeleteResponse deleteResponse = client.delete(deleteRequest, RequestOptions.DEFAULT);
assertEquals(DocWriteResponse.Result.DELETED, deleteResponse.getResult());
}

上边我们删除了在前边创建id=1的数据,其对应的SQL如下:

1
sql复制代码> delete from hero where id=1;

当然,在ES中我们不仅仅可以使用主键来删除,我们还可以通过其他的字段条件来删除。

1
2
3
4
5
6
7
8
9
java复制代码@Test
public void deleteByQueryRequestTest() throws IOException {
DeleteByQueryRequest request = new DeleteByQueryRequest("hero");
request.setConflicts("proceed");
request.setQuery(new TermQueryBuilder("country", "吴"));
BulkByScrollResponse bulkResponse =
client.deleteByQuery(request, RequestOptions.DEFAULT);
assertEquals(0, bulkResponse.getBulkFailures().size());
}

对应的SQL:

1
sql复制代码> delete from hero where country='吴';

6、复合操作

在上边的增删改都是一次只能操作一种类型,而ES还给我们提供了一次进行多种类型的操作,例如下边的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
java复制代码@Test
public void bulkDiffRequestTest() throws IOException {
BulkRequest request = new BulkRequest();
request.add(new DeleteRequest("hero", "3"));
request.add(new UpdateRequest("hero", "7")
.doc(XContentType.JSON,"longevity", "70"));
BulkResponse bulkResponse = client.bulk(request, RequestOptions.DEFAULT);
BulkItemResponse[] bulkItemResponses = bulkResponse.getItems();
for (BulkItemResponse item : bulkItemResponses){
DocWriteResponse itemResponse = item.getResponse();
switch (item.getOpType()) {
case UPDATE:
UpdateResponse updateResponse = (UpdateResponse) itemResponse;
break;
case DELETE:
DeleteResponse deleteResponse = (DeleteResponse) itemResponse;
}
assertEquals(RestStatus.OK, item.status());
}
}

我们使用了BulkRequest对象,将DeleteRequest、UpdateRequest两种操作add到BulkRequet中,然后将返回的BulkItemResponse[]数组根据不同的操作类型进行分类处理即可。当然据我所知,目前Mysql并没有类似的语法支持,如果有希望大家留言指正哈。

7、查询

到这里才是我们真正的重点,在ES里边支持多种类型的查询,例如**”精确“(和RDBMS有所区别)查询、模糊查询、相关性查询、范围查询、全文检索、分页查询、排序、聚合**等等查询功能,在Mysql中的大部分查询功能在ES中均能实现。同还允许我们选择同步、异步的方式来执行查询

单条件查询 + limit

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码@Test
public void selectByUserTest(){
SearchRequest request = new SearchRequest("hero");
SearchSourceBuilder builder = new SearchSourceBuilder();
builder.query(new TermQueryBuilder("country", "魏"));
// 相当于mysql里边的limit 1;
builder.size(1);
request.source(builder);
try {
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
SearchHit[] hits = response.getHits().getHits();
assertEquals(1, hits.length);
} catch (Exception e) {
throw new RuntimeException(e);
}
}

上边的单元测试中,我们用user作为查询条件,并且限制返回条数,类似SQL如下

1
sql复制代码> select * from posts where country='魏' limit 1;

多条件查询 + 排序 + 分页

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
java复制代码@Test
public void boolQueryTest(){
SearchRequest request = new SearchRequest("hero");
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
BoolQueryBuilder boolQueryBuilder = new BoolQueryBuilder();
boolQueryBuilder.must(termQuery("country", "魏"));
boolQueryBuilder.must(rangeQuery("longevity").gte(50));
sourceBuilder.query(boolQueryBuilder);
sourceBuilder.from(0).size(2);
sourceBuilder.query(boolQueryBuilder);
sourceBuilder.sort("longevity", SortOrder.DESC);
request.source(sourceBuilder);
SearchResponse response = null;
try {
response = client.search(request, RequestOptions.DEFAULT);
} catch (IOException e) {
log.error("Query by Condition execution failed: {}", e.getMessage(), e);
}
assert response != null;
assertEquals(0, response.getShardFailures().length);
SearchHit[] hits = response.getHits().getHits();
List<Hero> herosList = new ArrayList<>(hits.length);
for (SearchHit hit : hits) {
herosList.add(JSON.parseObject(hit.getSourceAsString(), Hero.class));
}
log.info("print info: {}, size: {}", herosList.toString(), herosList.size());
}

上边的将曹魏集团的寿命50岁以上的英雄查询出来,并根据寿命从高到低排序,只截取两位英雄,其对应的sql:

1
java复制代码> select * from hero where country='魏' and longevity >= 50 order by longevity DESC limit 2;

这里要注意,我们在ES提供的API中使用多条件查询时需要将多个条件封装到BoolQueryBuilder对象中,其支持下边几种查询类型

1
2
3
4
5
java复制代码private static final String MUSTNOT = "mustNot";
private static final String MUST_NOT = "must_not";
private static final String FILTER = "filter";
private static final String SHOULD = "should";
private static final String MUST = "must";

具体解释参考官方文档

总结

在这部分我们先分享了如何将Spring boot和ES集成,以及最佳实践的建议——采用Java High Level REST Client来构建我们的API,然后分享了相关的依赖以及如何初始化客户端。

紧接着我们开始用High Level REST Client实现了创建索引、批量插入、更新数据、插入/更新数据、删除数据、复合操作,最后我们用两个简单的例子实现了查询数据,当然还有很多的查询例子没有展示出来,建议大家根据自己的需求,去官网查询使用的方法。

参考

Java High Level REST Client

本文转载自: 掘金

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

不是吧?不会多态,你还说自己会Java

发表于 2020-09-13

大家好,我是小菜,一个渴望在互联网行业做到蔡不菜的小菜。可柔可刚,点赞则柔,白嫖则刚!
死鬼~看完记得给我来个三连哦!

本文主要介绍 Java中多态的用法

如有需要,可以参考

如有帮助,不忘 点赞 ❥

微信公众号已开启,小菜良记,没关注的小伙伴记得关注哦!

今天是周五,跟往常一样踩点来到了公司。坐到自己的工位上打开电脑,"又是搬砖的一天"。想归想,还是"熟练"的打开了 Idea,看了下今天的需求,便敲起了代码。咦,这些代码是谁写的,怎么出现在我的代码里面,而且还是待提交状态,我记得我没写过呀,饶有兴趣的看了看:

这不是多态吗,谁在我电脑写的测试,不禁一阵奇怪。

"你看看这会输出什么结果?"

一阵声音从身后传来,因为在思考输出结果,也没在意声音的来源,继续看了看代码,便得出结论:

1
2
3
4
xml复制代码    polygon() before cal()
square.cal(), border = 2
polygon() after cal()
square.square(), border = 4

心里想:就这?起码也是名 Java 开发工程师好吗,虽然平时搬搬砖,一些基本功还是有的。不禁有点得意了~

"这就是你的答案吗?看来你也不咋的"

声音又突然响起,这次我不淡定了,尼玛!这答案我也是在心里想的好吗,谁能看得到啊,而且说得话让人那么想施展一套阿威十八式。"你是谁啊?"带着丝微疑惑和愤怒转过了头。怎么没人?容不得我疑惑半分,"小菜,醒醒,你怎么上班时间就睡着了"

上班时间,睡着了?我睁开了眼,看了下周围环境,原来是梦啊,舒了一口气。望眼就看到部门主管站在我面前,上班时间睡觉,你是身体不舒服还是咋样?昨天写了一堆 bug 没改,今天又提交什么乱七八糟的东西上去,我看你这个月的绩效是不想要的,而且基于你的表现,我也要开始为部门考虑考虑了。

"我不是,我没有,我也不知道怎么就睡着了,你听我解释啊!" 这句话还没来得及说出口,心里的花我要带你回家,在那深夜酒吧哪管它是真是假,请你尽情摇摆忘记钟意的他,你是最迷人噶,你知道吗,闹铃响了起来,我一下子立起身子,后背微湿,额顶微汗,看了下手机,周六,8点30分,原来那是梦啊!

奇怪,怎么会做那么奇怪的梦,也太吓人了。然后就想到了梦中的那部分代码,难道我的结果是错的吗?凭着记忆,在电脑上重新敲了出来,运行结果如下:

1
2
3
4
5
6
xml复制代码/*
polygon() before cal()
square.cal(), border = 0
polygon() after cal()
square.square(), border = 4
*/

square.cal(), border的结果居然是 0,而不是2。难道我现在连多态都不会了吗?电脑手机前的你,不知道是否得出了正确答案了呢!不管有没有,接下来就跟小菜一起来复习一下多态吧!

有些小伙伴疑惑的点可能不止square.cal(), border的结果是 0,也有为什么不是 square.square(), border = 4 先输出的疑惑。那么我们就带着疑惑,整起!

多态

在面向对象的程序设计语言中,多态是继数据抽象和继承之后的第三种基本特征。

多态不但能够改善代码的组织结构和可读性,还能够创建可扩展的程序。多态的作用就是消除类型之间的耦合关系。

1. 向上转型

根据里氏代换原则:任何基类可以出现的地方,子类一定可以出现。

对象既可以作为它自己本身的类型使用,也可以作为它的基类型使用。而这种吧对某个对象的引用视为对其基类型的引用的做法被称作为 - 向上转型。因为父类在子类的上方,子类要引用父类,因此称为 向上转型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
java复制代码public class Animal {
void eat() {
System.out.println("Animal eat()");
}
}

class Monkey extends Animal {

void eat() {
System.out.println(" Monkey eat()");
}
}

class test {

public static void start(Animal animal) {
animal.eat();
}

public static void main(String[] args) {
Monkey monkey = new Monkey();
start(monkey);
}
}

/* OUTPUT:
Monkey eat()
*/

上述 test 类中的 start() 方法接收一个 Animal 的引用,自然也可以接收从 Animal 的导出类。调用eat() 方法的时候,自然而然的使用到 Monkey 中定义的eat()方法,而不需要做任何的类型转换。因为从 Monkey 向上转型到 Animal 只能减少接口,而不会比Animal 的接口更少。

打个不是特别恰当的比方:你父亲的财产会继承给你,而你的财产还是你的,总的来说,你的财产不会比你父亲的少。

忘记对象类型

在 test.start()方法中,定义传入的是 Animal 的引用,但是却传入Monkey,这看起来似乎忘记了Monkey 的对象类型,那么为什么不直接把test类中的方法定义为void start(Monkey monkey),这样看上去难道不会更直观吗。

直观也许是它的优点,但是就会带来其他问题:Animal不止只有一个Monkey的导出类,这个时候来了个pig ,那么是不是就要再定义个方法为void start(Monkey monkey),重载用得挺溜嘛小伙子,但是未免太麻烦了。懒惰才是开发人员的天性。

因此这样就有了多态的产生

2.显露优势

方法调用中分为 静态绑定和动态绑定。何为绑定:将一个方法调用同一个方法主体关联起来被称作绑定。

  • 静态绑定:又称为前期绑定。是在程序执行前进行把绑定。我们平时听到”静态”的时候,不难免想到static关键字,被static关键字修饰后的变量成为静态变量,这种变量就是在程序执行前初始化的。前期绑定是面向过程语言中默认的绑定方式,例如 C 语言只有一种方法调用,那就是前期绑定。

引出思考:

1
2
3
java复制代码public static void start(Animal animal) {
animal.eat();
}

在start()方法中传入的是Animal 的对象引用,如果有多个Animal的导出类,那么执行eat()方法的时候如何知道调用哪个方法。如果通过前期绑定那么是无法实现的。因此就有了后期绑定。

  • 动态绑定:又称为后期绑定。是在程序运行时根据对象类型进行绑定的,因此又可以称为运行时绑定。而 Java 就是根据它自己的后期绑定机制,以便在运行时能够判断对象的类型,从而调用正确的方法。

小结:

Java 中除了 static 和 final 修饰的方法之外,都是属于后期绑定

合理即正确

显然通过动态绑定来实现多态是合理的。这样子我们在开发接口的时候只需要传入 基类 的引用,从而这些代码对所有 基类 的 导出类 都可以正确的运行。

其中Monkey、Pig、Dog皆是Animal的导出类

Animal animal = new Monkey() 看上去不正确的赋值,但是上通过继承,Monkey就是一种Animal,如果我们调用animal.eat()方法,不了解多态的小伙伴常常会误以为调用的是Animal的eat()方法,但是最终却是调用了Monkey自己的eat()方法。

Animal作为基类,它的作用就是为导出类建立公用接口。所有从Animal继承出去的导出类都可以有自己独特的实现行为。

可扩展性

有了多态机制,我们可以根据自己的需求对系统添加任意多的新类型,而不需要重载void start(Animal animal)方法。

在一个设计良好的OOP程序中,大多数或者所有方法都会遵循start()方法的模型,只与基类接口同行,这样的程序就是具有可扩展性的,我们可以通过从通用的基类继承出新的数据类型,从而添加一些功能,那些操纵基类接口的方法就不需要任何改动就可以应用于新类。

失灵了?

我们先来复习一下权限修饰符:

作用域 当前类 用一个package 子孙类 其他package
public √ √ √ √
protected √ √ √ ×
default √ √ × ×
private √ × × ×
  • public:所有类可见
  • protected:本类、本包和子类都可见
  • default:本类和本包可见
  • private:本类可见

私有方法带来的失灵:

复习完我们再来看一组代码:

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

private void f() {
System.out.println("PrivateScope f()");
}

public static void main(String[] args) {
PrivateScope p = new PrivateOverride();
p.f();
}
}

class PrivateOverride extends PrivateScope {

private void f() {
System.out.println("PrivateOverride f()");
}
}
/* OUTPUT
PrivateScope f()
*/

是否感到有点奇怪,为什么这个时候调用的f()是基类中定义的,而不像上面所述的那样,通过动态绑定,从而调用导出类PrivateOverride中定义的f()。不知道心细的你是否发现,基类中f()方法的修饰是private。没错,这就是问题所在,PrivateOverride中定义的f()方法是一个全新的方法,因为private的缘故,对子类不可见,自然也不能被重载。

结论:

只有非 private 修饰的方法才可以被覆盖

我们通过 Idea 写代码的时候,重写的方法头上可以标注@Override注解,如果不是重写的方法,标注@Override注解就会报错:

这样也可以很好的提示我们非重写方法,而是全新的方法。

域带来的失灵:

当小伙伴看到这里,就会开始认为所有事物(除private修饰)都可以多态地发生。然而现实却不是这样子的,只有普通的方法调用才可以是多态的。这边是多态的误区所在。

让我们再看看下面这组代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
java复制代码class Super {
public int field = 0;

public int getField() {
return field;
}
}

class Son extends Super {
public int field = 1;

public int getField() {
return field;
}

public int getSuperField() {
return super.field;
}
}

class FieldTest {
public static void main(String[] args) {
Super sup = new Son();
System.out.println("sup.field:" + sup.field + " sup.getField():" + sup.getField());

Son son = new Son();
System.out.println("son.field:" + son.field + " son.getField:" + son.getField() + " son.getSupField:" + son.getSuperField());
}
}
/* OUTPUT
sup.field:0 sup.getField():1
son.field:1 son.getField:1 son.getSupField:0
*/

从上面代码中我们看到sup.field输出的值不是 Son 对象中所定义的,而是Super本身定义的。这与我们认识的多态有点冲突。

其实不然,当Super对象转型为Son引用时,任何域访问操作都将由编译器解析,因此不是多态的。在本例中,为Super.field和Son.field分配了不同的存储空间,而Son类是从Super类导出的,因此,Son实际上是包含两个称为field的域:它自己的+Super的。

虽然这种问题看上去很令人头痛,但是我们开发规范中,通常会将所有的域都设置为 private,这样就不能直接访问它们,只能通过调用方法来访问。

static 带来的失灵:

看到这里,小伙伴们应该对多态有个大致的了解,但是不要掉以轻心哦,还有一种情况也是会出现失灵的,那就是如果某个方法是静态的,那么它的行为就不具有多态性。

老规矩,我们看下这组代码:

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

public static void staticTest() {
System.out.println("StaticSuper staticTest()");
}

}

class StaticSon extends StaticSuper{

public static void staticTest() {
System.out.println("StaticSon staticTest()");
}

}

class StaticTest {
public static void main(String[] args) {
StaticSuper sup = new StaticSon();
sup.staticTest();
}
}
/* OUTPUT
StaticSuper staticTest()
*/

静态方法是与类相关联,而非与对象相关联

3.构造器与多态

首先我们需要明白的是构造器不具有多态性,因为构造器实际上是static方法,只不过该static的声明是隐式的。

我们先回到开头的那段神秘代码:

其中输出结果是:

1
2
3
4
5
6
xml复制代码/*
polygon() before cal()
square.cal(), border = 0
polygon() after cal()
square.square(), border = 4
*/

我们可以看到先输出的是基类polygon中构造器的方法。

这是因为基类的构造器总是在导出类的构造过程中被调用,而且是按照继承层次逐渐向上链接,以使每个基类的构造器都能得到调用。

因为构造器有一项特殊的任务:检查对象是否能正确的被构造。导出类只能访问它自己的成员,不能访问基类的成员(基类成员通常是private类型)。只有基类的构造器才具有权限来对自己的元素进行初始化。因此,必须令所有构造器都得到调用,否则就不可能正确构造完整对象。

步骤如下:

  • 调用基类构造器,这个步骤会不断的递归下去,首先是构造这种层次结构的根,然后是下一层导出类,…,直到最底层的导出类
  • 按声明顺序调用成员的初始化方法
  • 调用导出类构造其的主体

打个不是特别恰当的比方:你的出现是否先要有你父亲,你父亲的出现是否先要有你的爷爷,这就是逐渐向上链接的方式

构造器内部的多态行为

有没有想过如果在一个构造器的内调用正在构造的对象的某个动态绑定方法,那么会发生什么情况呢?
动态绑定的调用是在运行时才决定的,因为对象无法知道它是属于方法所在的那个类还是那个类的导出类。如果要调用构造器内部的一个动态绑定方法,就要用到那个方法的被覆盖后的定义。然而因为被覆盖的方法在对象被完全构造之前就会被调用,这可能就会导致一些难于发现的隐藏错误。

问题引索:

一个动态绑定的方法调用会向外深入到继承层次结构内部,它可以调动导出类里的方法,如果我们是在构造器内部这样做,那么就可能会调用某个方法,而这个方法做操纵的成员可能还未进行初始化,这肯定就会招致灾难的。

敏感的小伙伴是不是想到了开头的那段代码:

输出结果是:

1
2
3
4
5
6
xml复制代码/*
polygon() before cal()
square.cal(), border = 0
polygon() after cal()
square.square(), border = 4
*/

我们在进行square对象初始化的时候,会先进行polygon对象的初始化,在polygon构造器中有个cal()方法,这个时候就采用了动态绑定机制,调用了square的cal(),但这个时候border这个变量尚未进行初始化,int 类型的默认值为 0,因此就有了square.cal(), border = 0的输出。看到这里,小伙伴们是不是有种拨开云雾见青天的感觉!

这组代码初始化的实际过程为:

  • 在其他任何事物发生之前,将分配给对象的存储空间初始化成二进制的零
  • 调用基类构造器时,会调用被覆盖后的cal()方法,由于步骤1的缘故,因此 border 的值为 0
  • 按照声明的顺序调用成员的初始化方法
  • 调用导出类的构造器主体

呼~终于复习完多态了,幸好是梦,没人发现我的菜。不知道电脑手机前的你,是否跟小菜一样呢,如果是的话赶紧跟小菜一起复习,不让别人发现自己还不会多态哦!

不知道下次又会做什么样的梦~

看完不赞,都是坏蛋

今天的你多努力一点,明天的你就能少说一句求人的话!

我是小菜,一个和你一起学习的男人。 💋

微信公众号已开启,小菜良记,没关注的小伙伴记得关注哦!

本文转载自: 掘金

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

面试:为了进阿里,死磕了ConcurrentHashMap源

发表于 2020-09-13

在上篇《面试:为了进阿里,死磕了ConcurrentHashMap源码和面试题(一)》,研究了基础原理,以及ConcurrentHashMap数据put的流程等线程安全的,来回顾一下面试的问题点:

  • ConcurrentHashMap的实现原理
    • ConcurrentHashMap1.7和1.8的区别?
    • ConcurrentHashMap使用什么技术来保证线程安全
  • ConcurrentHashMap的put()方法
    • ConcurrentHashmap 不支持 key 或者 value 为 null 的原因?
    • put()方法如何实现线程安全呢?
  • ConcurrentHashMap扩容机制
  • ConcurrentHashMap的get方法是否要加锁,为什么?
  • 其他问题
    • 为什么使用ConcurrentHashMap
    • ConcurrentHashMap迭代器是强一致性还是弱一致性?HashMap呢?
    • JDK1.7与JDK1.8中ConcurrentHashMap的区别

那我们接下继续看看CurrentHashMap核心内容,扩容机制。

ConcurrentHashMap的扩容机制

  1. 扩容变量
1
2
3
4
5
6
7
8
9
java复制代码// 新 tab 的 length  
int nextn = nextTab.length;
// 创建一个 fwd 节点,用于占位。当别的线程发现这个槽位中是 fwd 类型的节点,则跳过这个节点。
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
// 首次推进为 true,如果等于 true,说明需要再次推进一个下标(i--)
//反之,如果是 false,那么就不能推进下标,需要将当前的下标处理完毕才能继续推进
boolean advance = true;
// 完成状态,如果是 true,就结束此方法。
boolean finishing = false; // to ensure sweep before committing nextTab
  1. 因为ConcurrentHashMap支持多线程扩容,多个线程处理不同的节点,首先先计算出每个线程(CPU)处理的桶数:将 length / 8 然后除以 CPU核心数。如果得到的结果小于 16,那么就使用 16。(避免出现转移任务不均匀的现象)
1
2
3
java复制代码int n = tab.length, stride;  
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE;
  1. 新的 table 尚未初始化,进行2倍扩容
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码 if (nextTab == null) {            // initiating  
try {
// 扩容 2 倍
Node<K,V>[] nt = (Node<K,V>\[])new Node<?,?>[n << 1];
// 更新
nextTab = nt;
} catch (Throwable ex) { // try to cope with OOME
// 扩容失败, sizeCtl 使用 int 最大值。
sizeCtl = Integer.MAX_VALUE;
return;// 结束
}
// 更新成员变量
nextTable = nextTab;
// 更新转移下标,就是 老的 tab 的 length
transferIndex = n;
}
  1. 在死循环中,每个线程先取得自己需要转移的桶的区间:先获取CAS 修改 transferIndex,即 length - 区间值,留下剩余的区间值供后面的线程使用(i 表示下标,bound 表示当前线程可以处理的当前桶区间最小下标)。
* 判断`--i`是否大于等于`bound` ,正常情况下,如果大于 bound 不成立,说明该线程上次领取的任务已经完成了。那么,需要在下面继续领取任务。
* `transferIndex` 小于等于0,说明没有区间了 ,i 改成 -1,推进状态变成 false,不再推进,表示,扩容结束了,当前线程可以退出了
* 第一次进入循环,走下面的 nextIndex 赋值操作(获取最新的转移下标)。其余情况都是:如果可以推进,将 i 减一,然后修改成不可推进。如果 i 对应的桶处理成功了,改成可以推进。
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复制代码int nextIndex, nextBound;  
if (--i >= bound || finishing)
//是为了防止在没有成功处理一个桶的情况下却进行了推进
advance = false;
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
} else if ((nextIndex = transferIndex) <= 0) {
// 如果小于等于0,说明没有区间了 ,i 改成 -1,
//推进状态变成 false,不再推进,表示,扩容结束了,当前线程可以退出了
// 这个 -1 会在下面的 if 块里判断,从而进入完成状态判断
i = -1;
advance = false;
}
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
//当前线程可以处理的最小当前区间最小下标
bound = nextBound;
//初次对i 赋值,这个就是当前线程可以处理的当前区间的最大下标
i = nextIndex - 1;
advance = false;
}
  1. 判断该节点是否需要进行扩容处理
* 是否已完成扩容


    + `finishing`为`true`,完成扩容
    + 如果没完成
        - 这个线程结束帮助扩容了`U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)`为true
* `(f = tabAt(tab, i)) == null`,获取老 tab i 下标位置的变量,如果是 null,写入 fwd 占位,推进下个下标
* `(fh = f.hash) == MOVED`说明别的线程已经处理过了,再次推进一个下标。
* 以上情况都不符合就说明,这个位置有实际值了,且不是占位符,需要对这个节点`synchronized`上锁,进行数据迁移



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复制代码if (i < 0 || i >= n || i + n >= nextn) {
int sc;
if (finishing) { // 如果完成了扩容
nextTable = null;// 删除成员变量
table = nextTab;// 更新 table
sizeCtl = (n << 1) - (n >>> 1); // 更新阈值
return;// 结束方法。
}// 如果没完成
// 尝试将 sc -1. 表示这个线程结束帮助扩容了,将 sc 的低 16 位减一。
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
// 如果 sc - 2 等于标识符左移 16 位,说明没有线程在帮助他们扩容了
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)。
return;// 不相等,说明没结束,当前线程结束方法。
finishing = advance = true;// 如果相等,扩容结束了,更新 finising 变量
i = n; // 再次循环检查一下整张表
}
}
// 获取老 tab i 下标位置的变量,如果是 null就写入fwd占位,再次推进一个下标
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
// 如果不是 null 且 hash 值是 MOVED,说明别的线程已经处理过了,再次推进一个下标。
else if ((fh = f.hash) == MOVED)
advance = true;
else {// 到这里,说明这个位置有实际值了,且不是占位符。对这个节点上锁。
//为什么上锁,防止 putVal 的时候向链表插入数据
synchronized (f) {
....
}
6. 扩容时,对该节点`synchronized`加锁,再进行处理,判断 i 下标处的桶节点是否和 f 相同: * 如果 f 的 hash 值大于 0 ,是链表结构,根据当前节点和首节点的 `hash &n`值取于结果不同,进行处理: + 相等为低位节点处理 + 不相等为高位节点处理
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
java复制代码 if (fh >= 0) {
//获取当前
int runBit = fh & n;
// 尾节点,且和头节点的 hash 值取于不相等
Node<K,V> lastRun = f;
// 遍历这个桶
for (Node<K,V> p = f.next; p != null; p = p.next) {
// 取于桶中每个节点的 hash 值
int b = p.hash & n;
// 如果节点的 hash 值和首节点的 hash 值取于结果不同
if (b != runBit) {
// 更新 runBit,用于下面判断 lastRun 该赋值给 ln 还是 hn。
runBit = b;
lastRun = p;
}
}
// 如果最后更新的 runBit 是 0 ,设置低位节点
if (runBit == 0) {
ln = lastRun;
hn = null;
}
else {// 如果最后更新的 runBit 是 1, 设置高位节点
hn = lastRun;
ln = null;
}
for (Node<K,V> p = f; p != lastRun; p = p.next) {
int ph = p.hash; K pk = p.key; V pv = p.val;
// 如果与运算结果是 0,那么就还在低位
if ((ph & n) == 0) // 如果是0 ,那么创建低位节点
ln = new Node<K,V>(ph, pk, pv, ln);
else // 1 则创建高位
hn = new Node<K,V>(ph, pk, pv, hn);
}
// 其实这里类似 hashMap
// 设置低位链表放在新链表的 i
setTabAt(nextTab, i, ln);
// 设置高位链表,在原有长度上加 n
setTabAt(nextTab, i + n, hn);
// 将旧的链表设置成占位符
setTabAt(tab, i, fwd);
// 继续向后推进
advance = true;
}
* TreeBin 的 hash 是 -2,是红黑树结构进行处理
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
java复制代码   else if (f instanceof TreeBin) {
TreeBin<K,V> t = (TreeBin<K,V>)f;
TreeNode<K,V> lo = null, loTail = null;
TreeNode<K,V> hi = null, hiTail = null;
int lc = 0, hc = 0;
// 遍历
for (Node<K,V> e = t.first; e != null; e = e.next) {
int h = e.hash;
TreeNode<K,V> p = new TreeNode<K,V>
(h, e.key, e.val, null, null);
// 和链表相同的判断,与运算 == 0 的放在低位
if ((h & n) == 0) {
if ((p.prev = loTail) == null)
lo = p;
else
loTail.next = p;
loTail = p;
++lc;
} // 不是 0 的放在高位
else {
if ((p.prev = hiTail) == null)
hi = p;
else
hiTail.next = p;
hiTail = p;
++hc;
}
}
// 如果树的节点数小于等于 6,那么转成链表,反之,创建一个新的树
ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) :
(hc != 0) ? new TreeBin<K,V>(lo) : t;
hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
(lc != 0) ? new TreeBin<K,V>(hi) : t;
// 低位树
setTabAt(nextTab, i, ln);
// 高位数
setTabAt(nextTab, i + n, hn);
// 旧的设置成占位符
setTabAt(tab, i, fwd);
// 继续向后推进
advance = true;
}

当ConcurrentHashMap中元素的数量达到cap * loadFactor时,就需要进行扩容。扩容主要通过transfer()方法进行,当有线程进行put操作时,如果正在进行扩容,可以通过helpTransfer()方法加入扩容。也就是说,ConcurrentHashMap支持多线程扩容,多个线程处理不同的节点,实现方式是,将Map表拆分,让每个线程处理自己的区间。如下图:

ConcurrentHashMap的get方法是否要加锁,为什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
java复制代码public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
int h = spread(key.hashCode());
//满足条件直接返回对应的值
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
//e.hash<0,正在扩容
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
//遍历当前节点
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}

ConcurrentHashMap的get方法就是从Hash表中读取数据,并不会与扩容不冲突,因此该方法也不需要同步锁,这样可提高ConcurrentHashMap 的并发性能。


总结

为什么使用ConcurrentHashMap

  • HashMap在多线程中进行put方法有可能导致程序死循环,因为多线程可能会导致HashMap形成环形链表,(即链表的一个节点的next节点永不为null,就会产生死循环),会导致CPU的利用率接近100%,因此并发情况下不能使用HashMap。
  • HashTable通过使用synchronized保证线程安全,但在线程竞争激烈的情况下效率低下。因为当一个线程访问HashTable的同步方法时,其他线程只能阻塞等待占用线程操作完毕。
  • ConcurrentHashMap使用分段锁的思想,对于不同的数据段使用不同的锁,可以支持多个线程同时访问不同的数据段,这样线程之间就不存在锁竞争,从而提高了并发效率。

ConcurrentHashMap迭代器是强一致性还是弱一致性?HashMap呢?

在迭代时,ConcurrentHashMap使用了不同于传统集合的快速失败迭代器,弱一致迭代器。

在这种迭代方式中,当iterator被创建后集合再发生改变就不再是抛出ConcurrentModificationException,取而代之的是在改变时new新的数据从而不影响原有的数据,iterator完成后再将头指针替换为新的数据,

这样iterator线程可以使用原来老的数据,而写线程也可以并发的完成改变,更重要的,这保证了多个线程并发执行的连续性和扩展性,是性能提升的关键。

JDK1.7与JDK1.8中ConcurrentHashMap的区别

其实可以看出JDK1.8版本的ConcurrentHashMap的数据结构已经接近HashMap,

  • 相对而言,ConcurrentHashMap只是增加了同步的操作来控制并发,从JDK1.7版本的ReentrantLock+Segment+HashEntry,到JDK1.8版本中synchronized+CAS+HashEntry+红黑树。
  • 数据结构:取消了Segment分段锁的数据结构,取而代之的是数组+链表+红黑树的结构。
  • 保证线程安全机制:JDK1.7采用segment的分段锁机制实现线程安全,其中segment继承自ReentrantLock。JDK1.8采用CAS+Synchronized保证线程安全。
  • 锁的粒度:原来是对需要进行数据操作的Segment加锁,现调整为对每个数组元素加锁(Node)。
  • 链表转化为红黑树:定位结点的hash算法简化会带来弊端,Hash冲突加剧,因此在链表节点数量大于8时,会将链表转化为红黑树进行存储。
  • 查询时间复杂度:从原来的遍历链表O(n),变成遍历红黑树O(logN)。

各位看官还可以吗?喜欢的话,动动手指点个💗,点个关注呗!!谢谢支持!

欢迎关注公众号【Ccww技术博客】,原创技术文章第一时间推出

本文转载自: 掘金

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

Kafka时间轮算法

发表于 2020-09-13

一、背景

因为前公司有个项目(一个给运营同学使用的可以通过配置化 实现 圈取人群并发送 红包,优惠券,短信,push, 站内信等等 推送能力的 营销推送系统,其中大部分的任务都是延迟定时发送方式,所以当时实现的时候大量使用了公司基础中间件团队开发的延迟消息中间件,可以实现指定时间推送任务的能力), 在阅读这个中间件源码的时候, 发现其中借鉴了时间轮算法的思想,所以就研究了下。

首先说一下,时间轮算法真心有点绕,但它的思想是在现实生活中有相关的模型的,那就是时钟,研究了相关实现算法之后,对 时钟 这个生活中看似 普普通通的习以为常的东西 佩服的很啊,发明这些东西的人真的厉害啊,当然 实现了时间轮算法的大牛们 也一样厉害,别人写出来的算法,活生生研究了两天才算是完全搞明白,在此记录下

二、算法以及源码实现

本文也是我在阅读一些网友的分析文章之后,因为觉得他们对于算法实现的的一些”连接点”没有很清晰的说明,当时看完之后,自己又琢磨了很久,想明白了之后,主要是在那些文章的基础上完善这些 介绍相薄弱的”连接点”的内容。所以再阅读这篇文章之前,建议阅读以下关于时间轮的基本知识。我阅读的相关文章有

my.oschina.net/anur/blog/2… 这篇文章里还有一些引用也可以一并看下。下面是一些关于我的理解:

时间轮:我自己觉得也可以叫做 “时间线”,因为时间是一直往前的,如果用时间戳表示的话就是一个不断增大的long类型的数字,但是为什么它又叫做”轮”呢,轮就有循环,后来者居上的意思 . 解释这个之前我们先看看层次时间轮一般的模型吧

1
markdown复制代码										图1.时钟

1
markdown复制代码                                     图2.秒钟表盘

1
markdown复制代码									图3.时分秒表盘

如上面3张图,图1 是现实生活中的钟表,其分别有有秒针,分针,时针,而一个表盘被分成了60个刻度(也可以看成60个格子,这里采用刻度这一说法吧其实两者表示含义是一样的但因为秒针每走一次之后会落在下一个刻度线上,这样好理解一些)。因为有60个刻度,所以也可以看成一个60进制的计数系统, 秒针走一圈,分针走一个刻度,而时针不变(因为时针走一个刻度其实是一个小时,此时还没到)。所以这个钟表就使用了3个计树器来表示了12小时的秒数也就是 12*60*60=43200 秒。当然对于大于这个树的时间也延生出了 周,月,年,等等,本质上也是一种进制的思想。 对于基本的表盘而言这个计时器的时间精度也就是到秒了,无法衡量比秒更精确地时间了。虽然表盘上的刻度一直是在不断循环的,但因为现实中的时间一直是在流逝的,表示时间的这个时间戳是在不断增大的,所以其实,个人觉得时间轮可以用下面的图表示:

1
markdown复制代码									图4. 真实时间轮

解释下上面的图:首先说明一下,上图展示了3个表盘,他们拥有相同的刻度数量10个。第一个每个刻度代表前进20ms的计数器盘。第二个是每个刻度代表前进200ms(也就是第一个走完一周的)的计数器盘,第三个是每个刻度代表前进2000ms(也就是第二个走完一周的)的计数器盘。我们能表示的时间范围是固定的——当然这个范围固定是指这里限定了只有三个轮子,可以想象成一个分别只有秒针,分针,时针的表盘,对于同一个表盘而言,他们都有10个刻度(时钟是60个刻度),每个刻度代表时间向前移动20个单位(单位可以是 毫秒,秒,分,时等等。时钟每个刻度是 1个单位也就是1s)。但后一个表盘的一个刻度是前一个表盘从开头到结尾时间的总和,这个很重要,因为为了能表示超过第一个或者第二个表盘时间范围的时间,只能不断调大表盘刻度所代表的的时间范围。最后虽然三个表盘加起来能表示的时间范围是有限的,但因为有一个currentTime(表示当前时间的指针) 一直在向前进也就是不断在变大,所以,这个currentTime指针加上一个刻度就能不断表示 未来一段时间范围内时间戳。(不断增减表盘就能增加表示的未来的时间范围)

对于跟时间挂钩的任务来说,我们把任务挂在相应的时间点下面等时间一到就执行任务就ok了。对于延时任务而言,无非就是挂载的时间点是将来的某个时间点而已。所以分析下来,无非就是把任务挂在某个时间点,然后驱动currentTime 指针向前走,遇到某个时间点下面挂载的有任务,就执行。还是比较抽象,我们举几个例子:

假设前提:currentTime指针 指向现在 假如是 晚上 20:00:00:000 精确到毫秒 ,假如这个时间点对应的时间戳long timeStamp 是 0 (假设,方便下面计算)

举例0:

我在现在也就是20:00:00:000 (时间戳是0) 想让时间在5ms之后也就是20:00:00:005 时 放一首歌(一个任务),那很显然我只需要把这个任务挂在 图4 的 currentTime指针位置就行了。为什么?因为表盘最小颗粒度一个刻度就代表了20ms,5ms包含在内无法区分,直接就执行了。

举例1 :

我在现在也就是20:00:00:000 (时间戳是0) 想让时间在20ms之后也就是20:00:00:023 时 放一首歌(一个任务),那很显然我只需要把这个任务挂在 图4 的 20和40 交接的那个刻度上就ok了。然后currrentTime 继续以一个刻度20ms前进, 发现20和40交界线处有个任务,然后就取出任务,此时任务的真实执行时间是20:00:00:023 , 而currentTime 是20:00:00:020,他们只差3ms,小于一个刻度的时间了,所以也就立即执行了

举例2:

其他条件跟举例1一样,我只是想在当前时间 加230 毫秒 也就是 20:00:00:230 时执行一个闹钟,此时这个任务就应该挂在200和400相交的那条线上,因为此时第二个轮子,每个刻度表示200ms,就相当于此时表盘的精度是 200ms,无法再区分比这更精确地范围了,所以在200ms~400之间的任务都会在 20:00:00:200 的时候被拿出来,但此时我们需要对拿出的任务做下检查,此任务是否应该执行,因为其实我们最小的表盘最小刻度是20ms,也就是精度,也就说我们能精确到当前时间+20ms内的时间,但此时我们拿出的任务的它的执行时间是 20:00:00:230(当前时间是20:00:00:200), 它需要在30ms之后执行的,也就是说这个30ms大于了我们的最小表盘的的刻度时间,我们还不能执行,需要等真实时间消耗20ms (最小刻度盘刻度走完一个也就是20ms之后) , 此时当前时间是20:00:00:220, 任务执行时间只比当前时间多10ms , 小于最小盘的精度20ms 就能 执行了。那应该怎么做呢————解决办法:因为这个任务是在20:00:00:200 的时候拿出来的,也就是说currentTime当前时间是这个.任务执行时间只大于这个时间点 30ms,所以这个任务此时应该再次进行挂载,放在 20和40交界线上(从第二个时间轮跑到第一个时间轮了,一切都因为 currentTime变了这个很重要,不变的话,是挂不到第一个时间轮上面的)

补充一下:最小刻度也叫精度 也可以理解成 原子性操作无法拆分的,都是一个刻度一个刻度地执行,没有执行到中间的

嗯,通过上面三个例子 相关的任务执行机制 已经说清了。下面来分析下源码:

首先是时间轮(TimeWheel) 的相关介绍:

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

一个时间轮的定义就如图所以:

tickMs 就是一个刻度代表的时间范围

wheelSize 就是刻度数量

interval 就代表一个表盘能表示的时间范围 interval=tickMs*wheelSize

buckets 主要是用来盛放挂载在时间轮上某个时间刻度的任务的,数组下表其实就是一个个刻度
记号,我们通过把任务的执行时间与当前时间的差值 然后除以一个刻度代表的范围也就是tickMs
再 对wheelSize 取余就得到了刻度下表也就是buckets 数组下表,然后就挂载任务就ok了。

currentTimestamp 这个是每个时间轮自己的当前时间,currentTimestamp 是 精度的整数
倍,取整处理了

overflowWheel 对上面时间轮的引用

delayQueue 其实这个 阻塞队列一个是用来存储这些bucket的,还有一个是用来驱动时间使用
的,具体会在下面解释

下面说一下 任务,因为不同的任务可能执行时间是一样的,所以 执行时间一样的任务,都被挂载在了同一个bucket 下了。

1
markdown复制代码            							图6. 具体的延迟任务

OK,系统需要的两个基本元素我们已经有了。下面就看看具体是什么算法让他们运行的吧

1: 一个Timer 管理器

众所周知,按照设计模式来说,异步任务执行,肯定是要有个时间任务管理器的——Timer, 这个管理器,一是提供给客户端一个 能随时添加 延迟任务 的接口。二是要能在任务指定的时间执行任务。我们一个个来分析。让我们从正常的业务场景出发,我们先添加一些延时任务

如下:

1
bash复制代码									图7. Timer#addTask(TimedTask)

如上所示逻辑很清晰:具体的添加工作有 时间轮来完成,Timer 本来也只是做做管理,具体工作还是要下派啊,一个很重要的逻辑,68行 如果添加不进去而且没取消就 交给工作线程池执行了。这是一个很重要的点,可我们明明是添加逻辑啊,为什么跑到执行了呢,这是因为 如果一个任务 将要执行的时间 小于最小精度了也就是 20ms 了,那还添加啥啊,直接就执行了。就像 举例0 的案例一样 。接着看 TimeWheel.addTask(timedTask) 的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
bash复制代码								图8. TImer#addTask(TimedTask timedTask)
如上所示主要逻辑:

1: 任务执行时间-当前时间 < 当前时间轮最小精度 返回false 也就是直接执行不需要挂载了

2: 任务执行时间-当前时间 大于等于 当前时间轮最小精度 小于 当前时间轮整个时间范围时
说明还是可以挂载在某个刻度下的 ,那就 根据 时间偏移 % 刻度数量,就得到了 bucket的下
表位置,然后把 timedTask 假如到相应的bucket下,注意此时bucket 是挂载在某个刻度下
的。时间是刻度的整数倍。如果这个刻度下也就是这个bucket下的第一个任务,那么就把这个
bucket假如延迟队列里(为什么要加延迟队列?bucket能加说明bucket实现了Delayed接口,为
什么?下面说)

3: 如果这个时间轮所能代表的时间范围不够,那就去上面一个时间轮去执行同样的逻辑进行挂载

看下 上一个时间轮的如何构造的:

1
bash复制代码                                	图9 Timer#getOverflowWheel()

如上所示:对于取父时间轮,其刻度所代表的时间范围是 本时间轮 一圈时间范围综合,刻度数量不变,delayQueue 一直是同一个,3个时间轮都是往一个延迟队列里塞。以上就是 添加一个任务的逻辑总结一下:

1
2
3
4
5
6
csharp复制代码1: 根据任务的时间和当前时间对比,如果小于当前时间轮的刻度时间范围,直接执行

2:大于等于一个刻度 小于整个 当前时间轮所能表示的时间,就 添加到相应的 bucket 下,
bucket 加入延迟队列

3:如果大于当前时间轮所能表示的时间,那么就找到父亲时间轮(如果为null就创建)从1继续相同逻辑

到目前为止只有添加逻辑,时间轮的currentTime到目前为止还是刚创建时的时间戳,根本没有往前走,currentTime不往前走,他和 挂载在 时间线 下的任务的距离就一直 没有缩小,任务又怎么能触发执行呢?

所以当务之急就是 怎么把currentTime 往前走?答案就是 要借助 DelayQueue 了

还记得前面添加逻辑里 最终bucket是放进了 DelayQueue 里的,bucket 放进去后,就会按bucket时间大小 排列,从小往大 依次出队,当然要其时间在当前时间之后,就像 图4 那样 bucket 按照时间线 前进方向一个个挂载时间下,时间不到 ,不会出队,bucket里又放着这个刻度下 (这个时间点 所有需要执行的任务)。所以 Timer (时间管理器 )其实是通过 DelayQueue.poll(timeout, TimeUnit.MILLISECONDS) 的不断尝试阻塞地把任务出队的方式 模拟时间前进的效果:源码如下:


图10 Timer#advanceClock(long timeout)

如上所以:

1:delayQueue 不断阻塞timeout的时间,timeout 一般为一个 最小盘的刻度时间,本文中20ms,用来模拟最小盘时间在向前进一个刻度的时间内,能获取到的这个刻度下 挂载的bucket,能取出来,说明这个bucket已经过了真实世界的当前时间了,已经过期了,但要注意

bucket 的时间 是刻度时间,也就是一个刻度时间范围的整数倍其实也是刻度开始时间,bucket里盛放的 任务的具体时间可以有差别,这个可以参考 举例2

2:如果经过一个刻度 ,这个刻度下挂载的有任务bucket,首先 先更新当前时间轮的 currentTime ,这个很重要,因为 延迟队列或者说真实的时间确实在前进,如果一旦发现有任务 ,时间轮的currentTime 就应该更新,而且这个更新 是需要递归地尝试更新 父时间轮。源码如下:

1
arduino复制代码					图11.TimeWheel#advanceClock(long timestamp)

1: timestamp 代表取出的bucket时的时间,对于最小时间轮来说,因为时间往前走了一个刻度,所以timestamp至少等于currentTimestamp + tickMs,如果delayqueue.poll跳过几个没有挂载数据的刻度的话,那么timestamp 大于currentTimestamp + tickMs 但不管怎样,只要poll 出bucket后,currentTimestamp 就会和当前时间保持相对一致的,也算是一直懒处理吧

2: 尝试更新父时间轮,这个操作会只要overflowWheel不为null就会触发,但父currentTimestamp 不一定会改变,因为子时间轮一次20m前进,要走10次,才能到达父时间轮tickMs

最后一步:

其实就是对取出的bucket里的任务 做处理了,因为这个bucket有可能是从 第二个 或者 第三个时间轮刻度下取出的 bucket,所以 对于这些bucket 里的 TimedTask 的 执行时间有可能是大于 最小盘的一个刻度所能表示的时间的,因为第二个,三个时间轮 一个刻度 所代表的时间范围太大,比如第二时间轮的第一个刻度 表示 当前时间 +200ms , 在这个时间范围内的任务都放在一个bucket里了,所以我们需要对bucket里的task,从新执行 addTask(TimedTask) ,因为当前时间已经变了,所以需要重新计算他们的时间轮是第几个,以及任务所属的bucket 是哪一个,具体代码如下:

1
lua复制代码					图12.Bucket.flush(Consumer<TimedTask> flush)

Consumer flush 就是 addTask(TimedTask timedTask) 【上面已经分析过这份方法】,所以逻辑就是遍历bucket里的任务,然后重新添加一遍

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
总结:复制代码
1: 任务都属于某一个bucket,bucket 都挂载在某个刻度下,有一个时间范围

2:同时bucket 是一个延迟队里的元素

3:利用延迟队列阻塞出队的方式模拟时间线的前行,然后执行挂载在 时间刻度下 bucket 的取出

4: 取出bucket后,更新各个时间轮的当前时间,以减小和更未来任务执行时间的差距

5: 遍历取出bucket里的任务,如果任务执行时间和当前时间的差值小于最小时间轮一个刻度表示时间范围时,就可以执行了

以上就是整个执行流程了

回答几个问题:

1: 为什么用延迟队列

如果不用延迟队列模拟时间前进,还可以通过while循环,但是这样会一直在轮训,cpu也扛不住,如果while里sleep ,那就无法保证最小精度的执行了

2:为什么用bucket

首先bucket实现了 delayed , 属于阻塞队列的元素,那可不可以不要bucket,全部任务,直接挂到 具体的时间点下,这样的话 队列元素太多,而且入队时间复杂度是 O(logn), 而如果用bucket的话,就会减少元素入队的次数,而且 任务定位bucket是一个O(1)的复杂度

参考:

https://my.oschina.net/anur/blog/2252539

https://juejin.cn/post/6844903648397525006

https://www.jianshu.com/p/87240220097b

本文转载自: 掘金

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

面试加分项:如何防止序列化和反射破坏单例模式

发表于 2020-09-12

大家应该都知道反射和序列化会破坏单例模式,但是一部分人可能不知道,如何防止这种破坏,下面文章就记录一下,如何防止单例模式被反射和序列化破坏!

1、单例模式

单例模式:顾名思义就是只有一个实例,并且它自己负责创建自己的对象,这个类提供了一种访问其唯一的对象的方式,可以直接访问,不需要实例化该类的对象。

实现方式:单例模式的实现方式有很多种,比如懒汉式,饿汉式,双重校验锁,静态内部类,枚举等等,这里就不一一贴出来代码看了,不熟悉或者感兴趣的同学可以点后面链接去自己查看。单例模式

我们这里先写一个饿汉式的,方便下面文章使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
csharp复制代码public class HungrySingleton implements Serializable {

private final static HungrySingleton hungrySingleton;

// 初始化
static {
hungrySingleton = new HungrySingleton();
}

// 构造器私有化
private HungrySingleton(){

}

// 提供唯一访问方式
public static HungrySingleton getInstance(){
return hungrySingleton;
}
}

2、序列化破坏单例模式

首先通过getInstance()得到对象,然后序列化到文件中,再读取文件得到一个新的对象,然后判断两个对象是否相同,代码如下:

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

public static void main(String[] args) throws IOException, ClassNotFoundException {

// 获取对象
HungrySingleton instance = HungrySingleton.getInstance();
// 将对象序列化到文件中
ObjectOutputStream objectOutputStream = new ObjectOutputStream(new FileOutputStream("hungrysingleton.txt"));
objectOutputStream.writeObject(instance);

// 读取文件
File file = new File("hungrysingleton.txt");
ObjectInputStream objectInputStream = new ObjectInputStream(new FileInputStream(file));
HungrySingleton newInstance = (HungrySingleton) objectInputStream.readObject();

// 判断是否为同一个对象
System.out.println("初始对象:" + instance);
System.out.println("反序列化后得到的对象:" + newInstance);

System.out.println(instance == newInstance);
}
}

执行结果
很明显可以看到,通过反序列化拿到了不同的对象,从而说序列化破坏了单例模式,!

原因:因为反序列化是从文件中读取数据,那先来看下ObjectInputStream的readObject()方法,这里主要看下下图打断点的这个方法,也就是readObject0这个方法:
image
这个方法里面的代码比较长,我就不截图出来了,这个方法里面有个switch判断,判断读取类型,因为刚才读取的是Object类型的,那就看下TC_OBJECT这一类型里面的readOrdinaryObject方法:
image
进入这个方法可以看到最后返回的Object有一个判断,如果这个类实现了序列化接口,那么返回一个newInstance,否则返回null,而刚才写的类已经实现了序列化接口,那么这个方法返回的就是通过反射得到的一个新的实例,所以反序列化拿到了不同的对象!
image

3、反射破坏单例模式

首先得到HungrySingleton类的构造器,然后将权限改为true,然后newInstance得到一个新的实例,判断两个实例是否相同,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
ini复制代码public class HungrySingletonTest {

public static void main(String[] args) throws IOException, ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {

Class hungrySingletonClass = HungrySingleton.class;
// 得到HungrySingleton类的构造器
Constructor constructor = hungrySingletonClass.getDeclaredConstructor();
// 将构造器权限改为true
constructor.setAccessible(true);
HungrySingleton instance = HungrySingleton.getInstance();
HungrySingleton newInstance = (HungrySingleton) constructor.newInstance();

System.out.println("初始实例:" + instance);
System.out.println("反射得到的实例:" + newInstance);
System.out.println(instance == newInstance);
}
}

结果图
可以看到,得到的实例不同,表示单例模式被反射破坏了!

原因:原因很简单,上面代码也写了注释,就是因为反射将构造器的私有属性改变了,所以类可以通过构造器得到一个新的实例,这样相比于初始化的实例,肯定是不相同的。

4、序列化破坏解决方案

要想解决序列化破坏单例模式其实很简单,只用在单例类内添加一个名为readResolve()方法,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
csharp复制代码public class HungrySingleton implements Serializable {

private final static HungrySingleton hungrySingleton;

// 初始化
static {
hungrySingleton = new HungrySingleton();
}

// 构造器私有化
private HungrySingleton(){

}

// 提供唯一访问方式
public static HungrySingleton getInstance(){
return hungrySingleton;
}

// 返回唯一对象
private Object readResolve(){
return hungrySingleton;
}
}

再执行一下main方法,可以看到如下结果:
结果
可以看到返回了同一个对象,至于原因,继续往下看。

上面将反序列化源码的时候,进入了下图这里:
image
由于类实现了序列化接口,那么返回一个新的实例,那么obj必不可能为null,再往下看:
image
可以看到它调用了一个hasReadResolveMethod方法,这个方法你进去看注释:
image
或者看方法名字也能看清楚,就是判断要反序列化的类是否有readResolve这个方法,而我们刚才的类中有这个方法,那么就会返回true,往下执行,就到了下图这一步,执行invokeReadResolve方法:
image

invokeReadResolve(Object obj)

invokeReadResolve(Object obj)

这里通过名字也能看出来,就是通过反射去调用readResolve方法,而我们写的类中,readResolve方法返回的是唯一的HungrySingleton类的实例,由此得到的对象和通过getInstance()得到的对象是一致的!

5、反射破坏解决方案

反射破坏的解决方案,就要针对不同的实现方式来说了,比如上面写的单例模式是饿汉式,就是类在初始化的时候就已经将对象创建好了,针对于这种,我们可以修改一下代码,在构造器内增加判断:

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

private final static HungrySingleton hungrySingleton;

// 初始化
static {
hungrySingleton = new HungrySingleton();
}

// 构造器私有化
private HungrySingleton(){
if (hungrySingleton != null){
throw new RuntimeException("单例构造器禁止反射调用 ");
}
}

// 提供唯一访问方式
public static HungrySingleton getInstance(){
return hungrySingleton;
}
}

结果
如果是那些懒汉式或者双重锁的那种的话,可以增加一个静态变量,然后在类初始化的时候将静态变量修改值,然后在构造器内判断静态变量的值来做相应的操作!

6、公众号

如果你觉得我的文章对你有帮助话,欢迎关注我的微信公众号:”一个快乐又痛苦的程序员”(无广告,单纯分享原创文章、已pj的实用工具、各种Java学习资源,期待与你共同进步)
公众号

本文转载自: 掘金

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

微信开放平台之第三方平台开发,一整套流程

发表于 2020-09-12



假如说,有多个业务,功能模式相同的公众号/小程序,如果只是小程序开发,那是不是需要复制多套代码,改appid信息,在微信公众号后台,配置域名服务器以及密钥等繁琐的信息,每改一个提交发布一次,进行重复的步骤。随着要维护的公众号/小程序数量逐步增加,需要投入的资源以及成本也随之增加。

有没有想过,只需要开发一套公众号/小程序代码,以之为模板,再来一套后台管理系统,把在微信公众号后台做的那些事都搬到我们自己的系统中。来一个业务相同的小程序,只需要管理员授权后,只要在我们的系统中点点几个按钮,就可以把小程序发布上线,一次开发供 N 个公众号使用,提供标准化的接口服务来满足业务的基础需求。通过扫描二维码授权给平台,帮助 N 多个公众号代实现业务,不再需要理解繁琐参数设置,并且密码不提供给开发者,保证安全,真正做到解放运营同学和开发的双手,有更多的时间去谈女朋友,那该多好。没错,微信第三方平台开发就是来帮你节省更多时间去把妹的神器。

概述

微信公众平台-第三方平台(简称第三方平台)开放给所有通过开发者资质认证后的开发者使用。在得到公众号或小程序运营者(简称运营者)授权后,第三方平台开发者可以通过调用微信开放平台的接口能力,为公众号或小程序的运营者提供账号申请、小程序创建、技术开发、行业方案、活动营销、插件能力等全方位服务。同一个账号的运营者可以选择多家适合自己的第三方为其提供产品能力或委托运营。

从业务特征上来说,第三方平台必须如图所示:

从具体的业务场景上说,第三方平台包括以下场景:

提供行业解决方案,如针对电商行业的解决方案,或针对旅游行业的解决方案等;

行业:(横向)提供更加专业的运营能力,精细化运营用户公众号或小程序;

功能:(纵向)对公众平台功能的优化,如专门优化图文消息视觉样式和排版的工具,或专门定制的 CRM 用户管理功能,或功能强大的小程序插件等。

接入第三方开发的前提是要有微信开放平台应用,详细创建步骤请参考

developers.weixin.qq.com/doc/oplatfo…

1、获取验证票据

验证票据(component_verify_ticket),在第三方平台创建审核通过后,微信服务器会向其 ”授权事件接收URL” 每隔 10 分钟以 POST 的方式推送 component_verify_ticket

接收 POST 请求后,只需直接返回字符串 success。为了加强安全性,postdata 中的 xml 将使用服务申请时的加解密 key 来进行加密,在收到推送后需进行解密。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
ini复制代码public void saveTicket(HttpServletRequest request, HttpServletResponse response) throws IOException {
String msgSignature = request.getParameter("msg_signature");// 微信加密签名
String timeStamp = request.getParameter("timestamp");// 时间戳
String nonce = request.getParameter("nonce"); // 随机数
BufferedReader br = new BufferedReader(new InputStreamReader(request.getInputStream(),"UTF-8"));
StringBuffer sb = new StringBuffer();
String line = null;
while ((line = br.readLine()) != null) {
sb = sb.append(line);
}
String postData = sb.toString();
try {
AuthorizedUtils.saveComponentVerifyTicket(msgSignature, timeStamp, nonce, postData);
} catch (Exception e) {
logger.error("系统异常", e);
} finally {
// 响应消息
PrintWriter out = response.getWriter();
out.print("success");
}
}

2、获取令牌

令牌(component_access_token)是第三方平台接口的调用凭据。令牌的获取是有限制的,每个令牌的有效期为 2 小时,请自行做好令牌的管理,在令牌快过期时(比如1小时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
ini复制代码public static ComponentToken getComponentToken(String ticket) {

RedisService<ComponentToken> redisService = RedisService.load();

ComponentToken componentToken = redisService.load(ComponentToken.COMPONENTTOKEN_ID, ComponentToken.class);
if (componentToken == null) {
String encryptAppId = ThirdPlat.PLAT_APPID;
String appId = EnDecryptUtil.d3esDecode(encryptAppId);
String encryptSecret = ThirdPlat.PLAT_SECRET;
String secret = EnDecryptUtil.d3esDecode(encryptSecret);

String requestUrl = AuthAccessUrl.COMPONENT_ACCESS_URL;
Map<String, String> map = new HashMap<>();
map.put("component_appid", appId); //第三方平台appid
map.put("component_appsecret", secret); //第三方平台appsecret
map.put("component_verify_ticket", ticket);
String outputStr = JSONObject.toJSONString(map);
logger.warn("请求数据"+outputStr);
JSONObject jsonObject = HttpRequestUtils.httpRequest(requestUrl, "POST", outputStr);
if (null != jsonObject) {
long expires = System.currentTimeMillis() + 7200;
try{
expires = System.currentTimeMillis() + jsonObject.getIntValue("expires_in");
}catch (Exception e) {
}
try {
componentToken = new ComponentToken();
componentToken.setComponentAccessToken(jsonObject.getString("component_access_token"));
componentToken.setExpiresIn(expires);
redisService.save(componentToken, ComponentToken.class);
} catch (Exception e) {
componentToken = null;
logger.error("系统异常", e);
}
}
} else {
long sysTime = System.currentTimeMillis();
if (sysTime >= componentToken.getExpiresIn()) {
redisService.delete(ComponentToken.COMPONENTTOKEN_ID, ComponentToken.class);
componentToken = getComponentToken(ticket);
}else{
}
}
return componentToken;
}


3、快速创建小程序

快速创建小程序接口优化了小程序注册认证的流程,能帮助第三方平台迅速拓展线下商户,拓展商户的服务范围,占领小程序线下商业先机。采用法人人脸识别方式替代小额打款等认证流程,极大的减轻了小程序主体、类目资质信息收集的人力成本。第三方平台只需收集法人姓名、法人微信、企业名称、企业代码信息这四个信息,便可以向企业法人下发一条模板消息来采集法人人脸信息,完成全部注册、认证流程。以及法人收到创建成功后的小程序APPID时,同时下发模板消息给法人,提示法人进行邮箱和密码的设置,便于后续法人登陆小程序控制台进行管理。

通过该接口创建小程序默认为“已认证”。为降低接入小程序的成本门槛,通过该接口创建的小程序无需交 300 元认证费。

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
ini复制代码public AjaxResult fastRegister(String merchantId) {
Merchant merchant = merchantService.getById(merchantId);
if (merchant == null) {
logger.warn("快速创建小程序---->失败,merchant为null");
return AjaxResult.error("快速创建小程序失败,merchant为null",null);
} else {
RedisService<ComponentVerifyTicket> redisService = RedisService.load();
ComponentVerifyTicket componentVerifyTicket = redisService.load(ComponentVerifyTicket.COMPONENT_VERIFY_TICKET_ID,
ComponentVerifyTicket.class);
if (componentVerifyTicket == null) {
logger.warn("快速创建小程序---->失败,component_verify_ticket为null");
return AjaxResult.error("快速创建小程序失败,component_verify_ticket为null",null);
} else {
ComponentToken componentToken = AuthorizedUtils.getComponentToken(componentVerifyTicket.getComponentVerifyTicket());
RegisterWeappOut out = new RegisterWeappOut();
out.setName(merchant.getName())
.setCode(merchant.getCode())
.setCode_type(merchant.getCodeType())
.setLegal_persona_wechat(merchant.getLegalPersonaWechat())
.setLegal_persona_name(merchant.getLegalPersonaName())
.setComponent_phone(merchant.getComponentPhone());
JSONObject obj = BaseUtils.createRegisterWeapp(componentToken,out);
if (obj.getInteger("errcode") == 0 && "ok".equalsIgnoreCase(obj.getString("errmsg"))) {
return AjaxResult.success();
} else {
return AjaxResult.error(obj.getInteger("errcode"),obj.getString("errmsg"));
}
}
}
}

4、获取预授权码

预授权码(pre_auth_code)是第三方平台方实现授权托管的必备信息,每个预授权码有效期为 10 分钟。需要先获取令牌才能调用。

1
2
3
4
5
6
7
8
9
10
ini复制代码public static String getPreAuthCode(String ticket) {
ComponentToken componentToken = getComponentToken(ticket);
String encryptAppId = ThirdPlat.PLAT_APPID;
String appId = EnDecryptUtil.d3esDecode(encryptAppId);
String url = AuthAccessUrl.PRE_AUTH_CODE_URL + componentToken.getComponentAccessToken();
Map<String, String> map = new HashMap<String, String>();
map.put("component_appid", appId);
JSONObject jsonObject = HttpRequestUtils.httpRequest(url, "POST", JSONObject.toJSONString(map));
return jsonObject.getString("pre_auth_code");
}

5、引导商户授权获取授权信息

第三方服务商构建授权链接放置自己的网站,用户点击后,弹出授权页面。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
ini复制代码public AjaxResult getMchWebAuthUrl(@PathVariable("id") String id) {
RedisService<ComponentVerifyTicket> redisService = RedisService.load();
ComponentVerifyTicket componentVerifyTicket = redisService.load(ComponentVerifyTicket.COMPONENT_VERIFY_TICKET_ID,
ComponentVerifyTicket.class);
if(componentVerifyTicket == null){
return AjaxResult.error("引入用户进入授权页失败,component_verify_ticket为null",null);
}else{
String preAuthCode = AuthorizedUtils.getPreAuthCode(componentVerifyTicket.getComponentVerifyTicket());
String encryptAppId = ThirdPlat.PLAT_APPID;
String appId = EnDecryptUtil.d3esDecode(encryptAppId);
String auth_type = ThirdPlat.AUTH_TYPE;
String requestUrl = AuthAccessUrl.WEB_AUTH_URL;
try {
requestUrl = requestUrl.replace("COMPONENT_APPID", appId).replace("PRE_AUTH_CODE", preAuthCode)
.replace("REDIRECT_URI", URLEncoder.encode(ThirdPlat.REDIRECT_URI.replace("MERCHANTID", id),"UTF-8")).replace("AUTH_TYPE", auth_type);
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
logger.warn("步骤2:引入用户进入授权页---->成功,url为:" + requestUrl);
return AjaxResult.success("操作成功",requestUrl);

}
}

6、设置小程序基本信息

设置小程序名称,当名称没有命中关键词,则直接设置成功;当名称命中关键词,需提交证明材料,并需要审核。修改小程序的头像。修改功能介绍。修改小程序隐私设置,即修改是否可被搜索。

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
scss复制代码
public AjaxResult setBasicInfo(BasicInfo basicInfo) throws IOException {
Merchant merchant = merchantService.getById(basicInfo.getMerchantId());
if (merchant == null) {
logger.warn("设置基本信息---->失败,merchant为null");
return AjaxResult.error("设置基本信息失败,merchant为null",null);
} else {
AuthorizationInfo info = AuthorizedUtils.getAuthorizationInfo(merchant.getAppid());
//修改头像
if (StringUtils.isNotEmpty(basicInfo.getHeadImage())) {
UploadIn uli = new UploadIn();
uli.setType("image").setUrlPath(basicInfo.getHeadImage());
JSONObject uploadJson = BaseUtils.upload(info,uli);
String mediaId = uploadJson.getString("media_id");
ModifyHeadImageIn mhi = new ModifyHeadImageIn();
mhi.setHead_img_media_id(mediaId).setX1("0").setY1("0").setX2("1").setY2("1");
JSONObject obj = BaseUtils.modifyHeadImage(info,mhi);
if (!obj.getInteger(ResStatus.ERRCODE).equals(ResStatus.CODE) || !ResStatus.MSG.equalsIgnoreCase(obj.getString(ResStatus.ERRMSG))) {
return AjaxResult.error(obj.getInteger(ResStatus.ERRCODE),obj.getString(ResStatus.ERRMSG));
} else {
merchant.setAppletsHeadImg(basicInfo.getHeadImage());
}
}
//修改名称
if (StringUtils.isNotEmpty(basicInfo.getNickname())) {
UploadIn uli = new UploadIn();
uli.setType("image").setUrlPath(merchant.getBusinessLicense());
JSONObject uploadJson = BaseUtils.upload(info,uli);
String mediaId = uploadJson.getString("media_id");
SetNicknameIn sni = new SetNicknameIn();
sni.setNick_name(basicInfo.getNickname());
sni.setLicense(mediaId);
JSONObject obj = BaseUtils.setNickname(info,sni);
if (!obj.getInteger(ResStatus.ERRCODE).equals(ResStatus.CODE) || !ResStatus.MSG.equalsIgnoreCase(obj.getString(ResStatus.ERRMSG))) {
return AjaxResult.error(obj.getInteger(ResStatus.ERRCODE),obj.getString(ResStatus.ERRMSG));
} else {
merchant.setAppletsName(basicInfo.getNickname());
if (obj.containsKey("audit_id") && StringUtils.isNotEmpty(obj.getString("audit_id"))) {
merchant.setAuditId(obj.getString("audit_id"));
}
}
}
//修改功能介绍
if (StringUtils.isNotEmpty(basicInfo.getSignature())) {
ModifySignatureIn msi = new ModifySignatureIn();
msi.setSignature(basicInfo.getSignature());
JSONObject obj = BaseUtils.modifySignature(info, msi);
if (!obj.getInteger(ResStatus.ERRCODE).equals(ResStatus.CODE) || !ResStatus.MSG.equalsIgnoreCase(obj.getString(ResStatus.ERRMSG))) {
return AjaxResult.error(obj.getInteger(ResStatus.ERRCODE),obj.getString(ResStatus.ERRMSG));
} else {
merchant.setAppletsSignature(basicInfo.getSignature());
}
}
//修改隐私设置,即修改是否可被搜索
if (StringUtils.isNotEmpty(basicInfo.getStatus())) {
SearchStatusIn ssi = new SearchStatusIn();
ssi.setStatus(basicInfo.getStatus());
JSONObject obj = BaseUtils.changeWxaSearchStatus(info, ssi);
if (!obj.getInteger(ResStatus.ERRCODE).equals(ResStatus.CODE) || !ResStatus.MSG.equalsIgnoreCase(obj.getString(ResStatus.ERRMSG))) {
return AjaxResult.error(obj.getInteger(ResStatus.ERRCODE),obj.getString(ResStatus.ERRMSG));
} else {
merchant.setSearchStatus(basicInfo.getStatus());
}
}
merchantService.updateById(merchant);
return AjaxResult.success();
}
}


7、支付授权

即填写商户号和商户号密钥,以及上传p12证书

8、设置服务器域名

授权给第三方的小程序,其服务器域名只可以为第三方平台的服务器,当小程序通过第三方平台发布代码上线后,小程序原先自己配置的服务器域名将被删除,只保留第三方平台的域名,所以第三方平台在代替小程序发布代码之前,需要调用接口为小程序添加第三方平台自身的域名。

注意:

需要先将域名登记到第三方平台的小程序服务器域名中,才可以调用接口进行配置。

最多可以添加1000个合法服务器域名;其中,Request域名、Socket域名、Uploadfile域名、Download域名、Udp域名的设置数量均最大支持200个。

每月可提交修改申请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
ini复制代码public AjaxResult modifyDomain(ModifyDomain modifyDomain) {
Merchant merchant = merchantService.getById(modifyDomain.getMerchantId());
if (merchant == null) {
logger.warn("设置服务器域名---->失败,merchant为null");
return AjaxResult.error("设置服务器域名失败,merchant为null",null);
} else {
AuthorizationInfo info = AuthorizedUtils.getAuthorizationInfo(merchant.getAppid());
ModifyDomainOut out = new ModifyDomainOut();
out.setAction(modifyDomain.getAction());
String[] requests = modifyDomain.getRequestdomain().split(",");
List<String> requestList = Arrays.asList(requests);
out.setRequestdomain(requestList);
String[] wsrequests = modifyDomain.getWsrequestdomain().split(",");
List<String> wsrequestList = Arrays.asList(wsrequests);
out.setWsrequestdomain(wsrequestList);
String[] uploads = modifyDomain.getUploaddomain().split(",");
List<String> uploadList = Arrays.asList(uploads);
out.setUploaddomain(uploadList);
String[] downloads = modifyDomain.getDownloaddomain().split(",");
List<String> downloadsList = Arrays.asList(downloads);
out.setDownloaddomain(downloadsList);
JSONObject obj = BaseUtils.modifyDomain(info, out);
if("0".equals(obj.getString("errcode")) && "ok".equalsIgnoreCase(obj.getString("errmsg"))){
return AjaxResult.success();
} else {
return AjaxResult.error(obj.getInteger("errcode"),obj.getString("errmsg"));
}
}
}

9、设置业务域名

授权给第三方的小程序,其业务域名只可以为第三方平台的服务器,当小程序通过第三方发布代码上线后,小程序原先自己配置的业务域名将被删除,只保留第三方平台的域名,所以第三方平台在代替小程序发布代码之前,需要调用接口为小程序添加业务域名。

注意:

需要先将业务域名登记到第三方平台的小程序业务域名中,才可以调用接口进行配置。

为授权的小程序配置域名时支持配置子域名,例如第三方登记的业务域名如为 qq.com,则可以直接将 qq.com 及其子域名(如 xxx.qq.com)也配置到授权的小程序中。

最多可以添加100个业务域名。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
ini复制代码public AjaxResult webviewDomain(WebviewDomain webviewDomain) {
Merchant merchant = merchantService.getById(webviewDomain.getMerchantId());
if (merchant == null) {
logger.warn("设置业务域名---->失败,merchant为null");
return AjaxResult.error("设置业务域名失败,merchant为null",null);
} else {
AuthorizationInfo info = AuthorizedUtils.getAuthorizationInfo(merchant.getAppid());
SetWebViewDomainOut out = new SetWebViewDomainOut();
out.setAction(webviewDomain.getAction());
String[] webviews = webviewDomain.getWebviewdomain().split(",");
List<String> webviewList = Arrays.asList(webviews);
out.setWebviewdomain(webviewList);
JSONObject obj = BaseUtils.setWebViewDomain(info, out);
if("0".equals(obj.getString("errcode")) && "ok".equalsIgnoreCase(obj.getString("errmsg"))){
return AjaxResult.success();
} else {
return AjaxResult.error(obj.getInteger("errcode"),obj.getString("errmsg"));
}
}
}


10、上传小程序代码

第三方平台需要先将草稿添加到代码模板库,或者从代码模板库中选取某个代码模板,得到对应的模板 id(template_id);然后调用本接口可以为已授权的小程序上传代码。

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
ini复制代码
public AjaxResult commit(CommitModel model) {
Merchant merchant = merchantService.selectMerchantById(model.getMerchantId());
if (merchant == null) {
logger.warn("上传代码---->失败,merchant为null");
return AjaxResult.error("上传代码,merchant为null",null);
}
AuthorizationInfo info = AuthorizedUtils.getAuthorizationInfo(merchant.getAppid());
CommitIn commitIn = new CommitIn();
String value = model.getValue();
String[] items = value.split("_");
String version = items[2];
commitIn.setTemplate_id(items[0])
.setUser_desc(items[1])
.setUser_version(version);

//第三方自定义的配置
JSONObject obj = new JSONObject();
obj.put("extAppid", merchant.getAppid());
Map<String, Object> map = new HashMap<>();
map.put("merchantId", model.getMerchantId());
map.put("userVersion", commitIn.getUser_version());
obj.put("ext", map);
map = new HashMap<>();
Map<String, Object> maps = new HashMap<>();
maps.put("pages/index/index", map);
obj.put("extPages", maps);
commitIn.setExt_json(JSONObject.toJSONString(obj));
//接受微信返回的数据
obj = CodeUtils.commit(info, commitIn);
if("0".equals(obj.getString("errcode")) && "ok".equalsIgnoreCase(obj.getString("errmsg"))){
AppletsRelease ar = appletsReleaseService.getOne(new LambdaQueryWrapper<AppletsRelease>()
.eq(AppletsRelease::getMerchantId,merchant.getId()));
if(ar == null){
ar = new AppletsRelease();
ar.setMerchantId(model.getMerchantId()).setHistoryversion(version);
} else{
ar.setHistoryversion(version);
}
appletsReleaseService.saveOrUpdate(ar);
return AjaxResult.success();
} else {
return AjaxResult.error(obj.getInteger("errcode"),obj.getString("errmsg"));
}
}


11、成员管理

第三方平台在帮助旗下授权的小程序提交代码审核之前,可先让小程序运营者体验,体验之前需要将运营者的个人微信号添加到该小程序的体验者名单中。

注意: 如果运营者同时也是该小程序的管理员,则无需绑定,管理员默认有体验权限。

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
java复制代码
/**
* 绑定体验者
* @parambindTester
* @return
*/
@Override
public AjaxResult bindTester(BindTester bindTester) {
Merchant merchant = merchantService.getById(bindTester.getMerchantId());
if (merchant == null) {
logger.warn("绑定体验者---->失败,merchant为null");
return AjaxResult.error("绑定体验者失败,merchant为null",null);
} else {
AuthorizationInfo info = AuthorizedUtils.getAuthorizationInfo(merchant.getAppid());
JSONObject obj = MemberUtils.bindTester(info, bindTester.getWechatId());
if("0".equals(obj.getString("errcode")) && "ok".equalsIgnoreCase(obj.getString("errmsg"))){
AppletsTester at = new AppletsTester();
at.setMerchantId(bindTester.getMerchantId()).setWechatId(bindTester.getWechatId()).setUserStr(obj.getString("userstr"));
appletsTesterService.insertAppletsTester(at);
return AjaxResult.success();
} else {
return AjaxResult.error(obj.getInteger("errcode"),obj.getString("errmsg"));
}
}
}

/**
* 解除体验者
* @paramtesterIds
* @return
*/
@Override
public AjaxResult unbindTester(Long[] testerIds) {
for (Long id : testerIds) {
AppletsTester tester = appletsTesterService.getById(id);
if (tester == null) {
logger.warn("解除体验者---->失败,tester为null");
return AjaxResult.error("解除体验者,tester为null",null);
}
Merchant merchant = merchantService.getById(tester.getMerchantId());
if (merchant == null) {
logger.warn("解除体验者---->失败,merchant为null");
return AjaxResult.error("解除体验者,merchant为null",null);
}
AuthorizationInfo info = AuthorizedUtils.getAuthorizationInfo(merchant.getAppid());
JSONObject obj = MemberUtils.unbindTester(info, tester.getWechatId());
if("0".equals(obj.getString("errcode")) && "ok".equalsIgnoreCase(obj.getString("errmsg"))){
appletsTesterService.removeById(id);
} else {
return AjaxResult.error(obj.getInteger("errcode"),obj.getString("errmsg"));
}
}
return AjaxResult.success();
}

12、获取体验版二维码

1
2
3
4
5
6
7
8
9
10
11
ini复制代码
public AjaxResult getQrcode(String merchantId) {
Merchant merchant = merchantService.getById(merchantId);
if (merchant == null) {
logger.warn("获取体验二维码---->失败,merchant为null");
return AjaxResult.error("获取体验二维码,merchant为null",null);
}
AuthorizationInfo info = AuthorizedUtils.getAuthorizationInfo(merchant.getAppid());
String qrcodeUrl = CodeUtils.getQrcode(info, "pages/index/index");
return AjaxResult.success("操作成功",qrcodeUrl);
}

13、提交审核

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
less复制代码
public AjaxResult submitAudit(SubmitAudit submit) {
Merchant merchant = merchantService.getById(submit.getMerchantId());
if (merchant == null) {
logger.warn("获取体验二维码---->失败,merchant为null");
return AjaxResult.error("获取体验二维码,merchant为null", null);
}
AuthorizationInfo info = AuthorizedUtils.getAuthorizationInfo(merchant.getAppid());
List<String> categorys = submit.getCategory();
submit.setFirst_id(categorys.get(0).split("-")[0])
.setFirst_class(categorys.get(0).split("-")[1])
.setSecond_id(categorys.get(1).split("-")[0])
.setSecond_class(categorys.get(1).split("-")[1])
.setTag(submit.getTag().replace(",", " "));
List<SubmitAudit> submits = new ArrayList<>();
submits.add(submit);
JSONObject sa = CodeUtils.submitAudit(info, submits);
if (sa.getInteger(ResStatus.ERRCODE).equals(ResStatus.CODE) && ResStatus.MSG.equalsIgnoreCase(sa.getString(ResStatus.ERRMSG))) {
JSONObject obj = CodeUtils.getAuditStatus(info, sa.getString("auditid"));
if (obj.getInteger(ResStatus.ERRCODE).equals(ResStatus.CODE) && ResStatus.MSG.equalsIgnoreCase(obj.getString(ResStatus.ERRMSG))) {
AppletsRelease ar = appletsReleaseService.getOne(new LambdaQueryWrapper<AppletsRelease>()
.eq(AppletsRelease::getMerchantId,merchant.getId()));
if (ar == null) {
return AjaxResult.error("请先上传代码");
}
ar.setMerchantId(submit.getMerchantId())
.setAuditId(sa.getString("auditid"))
.setStatus(obj.getString("status"))
.setRemark(obj.getString("screenshot"));
if (AppletsRelease.STATUS_0.equals(ar.getStatus())) {
ar.setRemark(AppletsRelease.MSG_0);
} else if (AppletsRelease.STATUS_1.equals(ar.getStatus())) {
ar.setReason(obj.getString("reason"))
.setScreenshot(obj.getString("screenshot"))
.setRemark(AppletsRelease.MSG_1);
} else if (AppletsRelease.STATUS_2.equals(ar.getStatus())) {
ar.setRemark(AppletsRelease.MSG_2);
} else if (AppletsRelease.STATUS_3.equals(ar.getStatus())) {
ar.setRemark(AppletsRelease.MSG_3);
} else if (AppletsRelease.STATUS_4.equals(ar.getStatus())) {
ar.setRemark(AppletsRelease.MSG_4);
}
appletsReleaseService.updateById(ar);
return AjaxResult.success();
} else {
return AjaxResult.error(obj.getInteger(ResStatus.ERRCODE), obj.getString(ResStatus.ERRMSG));
}
} else {
return AjaxResult.error(sa.getInteger(ResStatus.ERRCODE), sa.getString(ResStatus.ERRMSG));
}
}

14、审核撤回

注意: 单个帐号每天审核撤回次数最多不超过 1 次,一个月不超过 10 次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
ini复制代码
public AjaxResult undoCodeAudit(String[] ids) {
StringBuilder sb = new StringBuilder();
for (String id : ids) {
Merchant merchant = merchantService.getById(id);
AuthorizationInfo info = AuthorizedUtils.getAuthorizationInfo(merchant.getAppid());
JSONObject obj = CodeUtils.undoCodeAudit(info);
if (obj.getInteger(ResStatus.ERRCODE).equals(ResStatus.CODE) && ResStatus.MSG.equalsIgnoreCase(obj.getString(ResStatus.ERRMSG))) {
AppletsRelease ar = appletsReleaseService.getOne(new LambdaQueryWrapper<AppletsRelease>()
.eq(AppletsRelease::getMerchantId,merchant.getId()));
ar.setStatus(AppletsRelease.MSG_3);
appletsReleaseService.updateById(ar);
} else{
sb.append(merchant.getName()+",");
}
}
if (sb.length() == 0) {
return AjaxResult.success();
} else {
String name = sb.substring(0, sb.length()-1);
return AjaxResult.error(name+"审核撤回失败");
}
}

15、发布已通过审核的小程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
ini复制代码
public AjaxResult releaseApplets(String[] ids) {
StringBuilder sb = new StringBuilder();
for (String id : ids) {
Merchant merchant = merchantService.getById(id);
AuthorizationInfo info = AuthorizedUtils.getAuthorizationInfo(merchant.getAppid());
JSONObject obj = CodeUtils.release(info);
if (obj.getInteger(ResStatus.ERRCODE).equals(ResStatus.CODE) && ResStatus.MSG.equalsIgnoreCase(obj.getString(ResStatus.ERRMSG))) {
AppletsRelease ar = appletsReleaseService.getOne(new LambdaQueryWrapper<AppletsRelease>()
.eq(AppletsRelease::getMerchantId,merchant.getId()));
ar.setStatus(AppletsRelease.STATUS_5);
appletsReleaseService.updateById(ar);
} else{
sb.append(merchant.getName()+",");
}
}
if (sb.length() == 0) {
return AjaxResult.success();
} else {
String name = sb.substring(0, sb.length()-1);
return AjaxResult.error(name+"发布失败");
}
}

16、小程序版本回退

注意:

如果没有上一个线上版本,将无法回退

只能向上回退一个版本,即当前版本回退后,不能再调用版本回退接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ini复制代码
public AjaxResult revertCodeRelease(String[] ids) {
StringBuilder sb = new StringBuilder();
for (String id : ids) {
Merchant merchant = merchantService.getById(id);
AuthorizationInfo info = AuthorizedUtils.getAuthorizationInfo(merchant.getAppid());
JSONObject obj = CodeUtils.revertCodeRelease(info);
if (!(obj.getInteger(ResStatus.ERRCODE).equals(ResStatus.CODE) && ResStatus.MSG.equalsIgnoreCase(obj.getString(ResStatus.ERRMSG)))) {
sb.append(merchant.getName()+",");
}
}
if (sb.length() == 0) {
return AjaxResult.success();
} else {
String name = sb.substring(0, sb.length()-1);
return AjaxResult.error(null,name+"审核撤回失败");
}
}

17、获取小程序码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
ini复制代码public AjaxResult getMiniQrcode(@PathVariable("merchantId") String merchantId) {
Merchant merchant = merchantService.getById(merchantId);
if (merchant == null) {
logger.warn("获取小程序码---->失败,merchant为null");
return AjaxResult.error("获取小程序码,merchant为null",null);
}
String qrcode;
if (StringUtils.isNotEmpty(merchant.getAppletImage())) {
qrcode = merchant.getAppletImage();
} else {
AuthorizationInfo info = AuthorizedUtils.getAuthorizationInfo(merchant.getAppid());
qrcode = WxUtils.getMiniQrcode(merchantId, "pages/index/index", "merchant", "miniQrcode", info.getAuthorizer_access_token());
merchant.setAppletImage(qrcode);
merchantService.updateById(merchant);
}
return AjaxResult.success("操作成功",qrcode);
}

本文转载自: 掘金

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

网易架构师心得:Springboot下使用redis踩过的坑

发表于 2020-09-11

分享一下我的网易架构师同事在spring boot下使用redis的心得~

首先总结了redis服务端单线程工作模型,redis四种部署方式及使用场景,然后从源码的角度上,分析springboot在jedis和lettuce客户端下使用redis的一些坑~尤其是在集群模式下的一些不兼容问题!

最近整理的Java架构学习视频和大厂项目底层知识点,需要的同学欢迎私信我发给你~一起学习进步!

1 Redis服务端单线程模型

网易架构师心得:Springboot下使用redis踩过的坑
redis 内部使用文件事件处理(file event handler)处理客户端的请求,文件事件处理器是单线程的,所以redis才叫做单线程的模型。

文件事件处理器的结构包含4个部分:多个socket、IO 多路复用程序、文件事件分派器、事件处理器(连接应答处理器、命令请求处理器、命令回复处理器)。

文件事件处理器采用 IO 多路复用机制同时监听多个 socket,根据 socket 上的事件来选择对应的事件处理器进行处理。

Redis客户端通过socket连接reids服务端,多个 socket 可能会并发产生不同的操作,每个操作对应不同的文件事件,但是 IO 多路复用程序会监听多个 socket,将 socket 产生的事件放入队列中排队,事件分派器每次从队列中取出一个事件,把该事件交给对应的事件处理器进行处理。

redis 单线程模型也能效率高的原因:

  1. 纯内存操作
  2. 基于非阻塞的 IO 多路复用机制
  3. 单线程反而避免了多线程的频繁上下文切换问题

为什么redis采用单线程模型呢?

如果采用多线程模型,cpu需要进行上下文切换,假设1MB的数据由多个线程读取了1000次,那么就有1000次时间上下文的切换,那么就有1500ns * 1000 = 1500us,而单线程的读完1MB数据才250us ,所以完全没必要使用多线程。

什么时候适合采用多线程的方案呢?

对于慢速设备:磁盘,网络,SSD 等等,将请求和处理的线程不绑定,请求的线程将请求放在一个buff里,然后等buff快满了,处理的线程再去处理这个buff。然后由这个buff 统一的去写入磁盘,或者读磁盘,这样效率最高。

Redis线程安全吗?

redis实际上是采用了线程封闭的观念,把任务封闭在一个线程,自然避免了线程安全问题,不过对于需要依赖多个redis操作的复合操作来说,依然需要锁,而且有可能是分布式锁。

2 redis部署方式

2.1 单节点模式

单节点模式只有一个节点,一般用来测试

2.2 主从模式

主从模式包括一个主节点和多个从节点,一般来说,主节点用来读写操作,从节点用户读操作,主节点的数据可以同步到从节点,所以从节点即便支持写操作也没有意义。

2.3 哨兵模式

网易架构师心得:Springboot下使用redis踩过的坑
哨兵模式是基于主从模式的,哨兵模式为了实现主从模式的高可用,监控主从节点的状态,当sentinel发现master节点挂了以后,sentinel就会从slave中重新选举一个master。

一般来说,通过sentinel集群可以管理多个主从redis,sentinel最好不要和Redis部署在同一台机器,不然redis的服务器挂了以后,sentinel也挂了。使用sentinel集群也是为了保证redis的高可用,避免哨兵节点挂了之后影响redis的使用。

当使用sentinel模式的时候,客户端就不要直接连接Redis,而是连接sentinel的ip和port,由sentinel来提供具体的可提供服务的Redis实现,这样当master节点挂掉以后,sentinel就会感知并将新的master节点提供给使用者。

sentinel模式基本可以满足一般生产的需求,具备高可用性。但是当数据量过大到一台服务器存放不下的情况时,主从模式或sentinel模式就不能满足需求了,这个时候需要对存储的数据进行分片,将数据存储到多个Redis实例中。

2.4 集群模式:

网易架构师心得:Springboot下使用redis踩过的坑
cluster的出现是为了解决单机Redis容量有限的问题,将Redis的数据根据一定的规则分配到多台机器。

cluster可以说是sentinel和主从模式的结合体,通过cluster可以实现主从和master重选功能,所以如果配置两个副本三个分片的话,就需要六个Redis实例。

如图所示,部署了三主三从的redis集群,redis cluster有固定的16384个hash slot,对每个key计算CRC16值,然后对16384取模,可以获取key对应的hash slot,从而将数据存储至对应的slot上。

3 Springboot使用redis总结

spring-boot-starter-data-redis支持两种redis客户端:jedis和lettuce

网易架构师心得:Springboot下使用redis踩过的坑
Springboot2.0默认使用的客户端是lettuce,下面跟踪源码来分析springboot如何加在lettuce客户端的,首先找到springboot自动加载的jar包下redis相关的加载配置类RedisAutoConfiguration

网易架构师心得:Springboot下使用redis踩过的坑
这里采用@Configuration @bean的方式向容器中注入RedisTemplate和StringRedisTemplate,注入两者的方法中需要传入RedisConnectionFactory,RedisConnectionFactory通过@Import导入的LettuceConnectionConfiguration和JedisConnectionConfiguration生成

网易架构师心得:Springboot下使用redis踩过的坑
网易架构师心得:Springboot下使用redis踩过的坑
可以看到在没有RedisConnectionFactory的情况下,会默认向Spring容器中注入LettuceConnectionFactory,如果要使用jedis客户端,只需要手动配置一个JedisConnectionFactory并注入容器即可。

3.1 jedis和lettuce的区别

  • Jedis在实现上是直接连接的redis server,如果在多线程环境下是非线程安全的,这个时候只有使用连接池,为每个Jedis实例增加物理连接。
  • Lettuce的连接是基于Netty的,连接实例(StatefulRedisConnection)可以在多个线程间并发访问, StatefulRedisConnection是线程安全的,所以一个连接实例(StatefulRedisConnection)就可以满足多线程环境下的并发访问,当然这个也是可伸缩的设计,一个连接实例不够的情况也可以按需增加连接实例。

3.2 jedis非线程安全分析:

从源码角度分析jedis客户端执行每个命令的过程

网易架构师心得:Springboot下使用redis踩过的坑
首先借助于Client类的对应方法去执行命令

网易架构师心得:Springboot下使用redis踩过的坑
然后借助于Connection类的sendCommand方法执行

网易架构师心得:Springboot下使用redis踩过的坑
sendCommand方法每次执行都会调用connect方法

网易架构师心得:Springboot下使用redis踩过的坑
从connect方法中可以看到,socket是一个共享变量,假如两个线程公用一个jedis实例,当前还没有建立socket连接,两个线程同时进入建立socket连接

网易架构师心得:Springboot下使用redis踩过的坑
线程1建立socket连接后,开始获取输入输出流,于此同时,线程2重新初始化socket,并且没有执行到建立socket连接,此时线程1获取输入输出流将失败,因为此时的socket并没有连接。

jedis本身不是多线程安全的,这并不是jedis的bug,而是jedis的设计与redis本身就是单线程相关,jedis实例抽象的是发送命令相关,一个jedis实例使用一个线程与使用100个线程去发送命令没有本质上的区别,所以没必要设置为线程安全的。但是如果需要用多线程方式访问redis服务器怎么做呢?那就使用多个jedis实例,每个线程对应一个jedis实例,而不是一个jedis实例多个线程共享。一个jedis关联一个Client,相当于一个客户端,Client继承了Connection,Connection维护了Socket连接,对于Socket这种昂贵的连接,一般都会做池化,jedis提供了JedisPool。

3.3 集群模式下jedis和lettuce使用的一些坑

  1. Lettuce在集群模式下主节点宕机,从节点更新为主节点,lettuce如何更新集群拓扑结构

集群中每个节点只负责部分slot, slot可能从一个节点迁移到另一节点,造成客户端有可能会向错误的节点发起请求。因此需要有一种机制来对其进行发现和修正,这就是请求重定向。

集群拓扑刷新是在ClusterTopologyRefreshScheduler中进行,下面进入类中一探究竟

网易架构师心得:Springboot下使用redis踩过的坑
网易架构师心得:Springboot下使用redis踩过的坑
ClusterTopologyRefreshScheduler类实现了ClusterEventListener接口,用来监听redis集群事件,集群事件包括ask重定向,move重定向,以及重新连接等。

网易架构师心得:Springboot下使用redis踩过的坑
在重定向方法中首先调用isEnabled方法判断是否开启刷新集群拓扑,然后调用indicateTopologyRefreshSignal方法刷新集群拓扑

网易架构师心得:Springboot下使用redis踩过的坑
判断集群是否开启刷新拓扑结构,依据ClusterTopologyRefreshOptions中自适应刷新的trigger中是否包含指定的重定向trigger,在默认配置下,这个trigger是什么样的呢?

网易架构师心得:Springboot下使用redis踩过的坑
可以看到默认情况下自适应刷新的trigger是空的,所以在集群模式下,使用默认的lettuce配置,如果主节点宕机,是不会刷新集群拓扑的,也就是会导致redis连接失败。

网易架构师心得:Springboot下使用redis踩过的坑
在enableAllAdaptiveRefreshTriggers方法中可以开启自适应刷新集群拓扑。开启自适应刷新集群拓扑后,又是如何刷新的呢?

网易架构师心得:Springboot下使用redis踩过的坑
在indicateTopologyRefreshSignal方法中提交一个刷新集群拓扑的clusterTopologyRefreshTask

网易架构师心得:Springboot下使用redis踩过的坑
在task中调用RedisClusterClient类的reloadPartitions方法重新加载集群拓扑信息,达到刷新的效果。

除了通过开始自适应刷新集群拓扑之外,还可以通过开启周期刷新的方式刷新集群拓扑

网易架构师心得:Springboot下使用redis踩过的坑
开启周期刷新集群拓扑后,在初始化集群拓扑的时,会调用activateTopologyRefreshIfNeeded开启周期刷新集群拓扑任务

网易架构师心得:Springboot下使用redis踩过的坑
网易架构师心得:Springboot下使用redis踩过的坑
这里会判断是否开启周期刷新,开启后才会提交一个定时任务。

周期刷新和自适应刷新比较:周期刷新和自适应刷新两种方法,最好还是使用自适应刷新,因为周期刷新的周期需要设置,设置太长会导致服务可能一段时间不可用,设置太短对资源是一种浪费,而自适应刷新根据服务端的响应来刷新集群拓扑。

两种刷新方法没必要都开启,都开启对资源也是一种浪费。

2.Jedis客户端执行lua脚本的坑

redis使用lua脚本的好处:

  • 减少网络开销。可以将多个请求通过脚本的形式一次发送,减少网络时延。
  • 原子操作。redis会将整个脚本作为一个整体执行,中间不会被其他命令插入。因此在编写脚本的过程中无需担心会出现竞态条件,无需使用事务。
  • 复用。客户端发送的脚本会永久存在redis中,这样,其他客户端可以复用这一脚本而不需要使用代码完成相同的逻辑。

那Jedis客户端是如何支持lua脚本的呢?

网易架构师心得:Springboot下使用redis踩过的坑
Jedis执行lua脚本是通过ScriptExecutor类的execute方法执行的,在方法中进一步调用eval方法

网易架构师心得:Springboot下使用redis踩过的坑
进一步调用RedisScriptingCommands类的eval方法,因为实在集群模式下使用jedis客户端,所以调用JedisClusterScriptingCommands实现类的eval方法

网易架构师心得:Springboot下使用redis踩过的坑
再看JedisClusterScriptingCommands实现类的eval方法,居然直接抛出异常,集群模式下不支持脚本。

解决方法是使用lettuce客户端,LettuceScriptingCommands类中的eval方法支持脚本

网易架构师心得:Springboot下使用redis踩过的坑

看到这里的小伙伴,如果你喜欢这篇文章的话,别忘了转发、收藏、留言互动!

如果对文章有任何问题,欢迎在留言区和我交流~

最近我新整理了一些Java资料,包含面经分享、模拟试题、和视频干货,如果你需要的话,欢迎私信我!

还有,关注我!关注我!关注我!

本文转载自: 掘金

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

全网最全一篇数据库MVCC详解,不全你打我

发表于 2020-09-11

什么是MVCC

全称Multi-Version Concurrency Control,即多版本并发控制,主要是为了提高数据库的并发性能。以下文章都是围绕InnoDB引擎来讲,因为myIsam不支持事务。

同一行数据平时发生读写请求时,会上锁阻塞住。但mvcc用更好的方式去处理读—写请求,做到在发生读—写请求冲突时不用加锁。

这个读是指的快照读,而不是当前读,当前读是一种加锁操作,是悲观锁。

那它到底是怎么做到读—写不用加锁的,快照读和当前读又是什么鬼,跟着你们的贴心老哥,继续往下看。

当前读、快照读都是什么鬼

什么是MySQL InnoDB下的当前读和快照读?

当前读

它读取的数据库记录,都是当前最新的版本,会对当前读取的数据进行加锁,防止其他事务修改数据。是悲观锁的一种操作。

如下操作都是当前读:

  • select lock in share mode (共享锁)
  • select for update (排他锁)
  • update (排他锁)
  • insert (排他锁)
  • delete (排他锁)
  • 串行化事务隔离级别

快照读

快照读的实现是基于多版本并发控制,即MVCC,既然是多版本,那么快照读读到的数据不一定是当前最新的数据,有可能是之前历史版本的数据。

如下操作是快照读:

  • 不加锁的select操作(注:事务级别不是串行化)

快照读与mvcc的关系

MVCCC是“维持一个数据的多个版本,使读写操作没有冲突”的一个抽象概念。

这个概念需要具体功能去实现,这个具体实现就是快照读。(具体实现下面讲)

听完贴心老哥的讲解,是不是瞬间茅厕顿开。

数据库并发场景

  • 读-读:不存在任何问题,也不需要并发控制
  • 读-写:有线程安全问题,可能会造成事务隔离性问题,可能遇到脏读,幻读,不可重复读
  • 写-写:有线程安全问题,可能会存在更新丢失问题,比如第一类更新丢失,第二类更新丢失

MVCC解决并发哪些问题?

mvcc用来解决读—写冲突的无锁并发控制,就是为事务分配单向增长的时间戳。为每个数据修改保存一个版本,版本与事务时间戳相关联。

读操作只读取该事务开始前的数据库快照。

解决问题如下:

  • 并发读-写时:可以做到读操作不阻塞写操作,同时写操作也不会阻塞读操作。
  • 解决脏读、幻读、不可重复读等事务隔离问题,但不能解决上面的写-写 更新丢失问题。

因此有了下面提高并发性能的组合拳:

  • MVCC + 悲观锁:MVCC解决读写冲突,悲观锁解决写写冲突
  • MVCC + 乐观锁:MVCC解决读写冲突,乐观锁解决写写冲突

MVCC的实现原理

它的实现原理主要是版本链,undo日志 ,Read View 来实现的

版本链

我们数据库中的每行数据,除了我们肉眼看见的数据,还有几个隐藏字段,得开天眼才能看到。分别是db_trx_id、db_roll_pointer、db_row_id。

  • db_trx_id

6byte,最近修改(修改/插入)事务ID:记录创建这条记录/最后一次修改该记录的事务ID。

  • db_roll_pointer(版本链关键)

7byte,回滚指针,指向这条记录的上一个版本(存储于rollback segment里)

  • db_row_id

6byte,隐含的自增ID(隐藏主键),如果数据表没有主键,InnoDB会自动以db_row_id产生一个聚簇索引。

  • 实际还有一个删除flag隐藏字段, 记录被更新或删除并不代表真的删除,而是删除flag变了

如上图,db_row_id是数据库默认为该行记录生成的唯一隐式主键,db_trx_id是当前操作该记录的事务ID,而db_roll_pointer是一个回滚指针,用于配合undo日志,指向上一个旧版本。

每次对数据库记录进行改动,都会记录一条undo日志,每条undo日志也都有一个roll_pointer属性(INSERT操作对应的undo日志没有该属性,因为该记录并没有更早的版本),可以将这些undo日志都连起来,串成一个链表,所以现在的情况就像下图一样:

对该记录每次更新后,都会将旧值放到一条undo日志中,就算是该记录的一个旧版本,随着更新次数的增多,所有的版本都会被roll_pointer属性连接成一个链表,我们把这个链表称之为版本链,版本链的头节点就是当前记录最新的值。另外,每个版本中还包含生成该版本时对应的事务id,这个信息很重要,在根据ReadView判断版本可见性的时候会用到。

undo日志

Undo log 主要用于记录数据被修改之前的日志,在表信息修改之前先会把数据拷贝到undo log里。

当事务进行回滚时可以通过undo log 里的日志进行数据还原。

Undo log 的用途

  • 保证事务进行rollback时的原子性和一致性,当事务进行回滚的时候可以用undo log的数据进行恢复。
  • 用于MVCC快照读的数据,在MVCC多版本控制中,通过读取undo log的历史版本数据可以实现不同事务版本号都拥有自己独立的快照数据版本。

undo log主要分为两种:

  • insert undo log

代表事务在insert新记录时产生的undo log , 只在事务回滚时需要,并且在事务提交后可以被立即丢弃

  • update undo log(主要)

事务在进行update或delete时产生的undo log ; 不仅在事务回滚时需要,在快照读时也需要;

所以不能随便删除,只有在快速读或事务回滚不涉及该日志时,对应的日志才会被purge线程统一清除

Read View(读视图)

事务进行快照读操作的时候生产的读视图(Read View),在该事务执行的快照读的那一刻,会生成数据库系统当前的一个快照。

记录并维护系统当前活跃事务的ID(没有commit,当每个事务开启时,都会被分配一个ID, 这个ID是递增的,所以越新的事务,ID值越大),是系统中当前不应该被本事务看到的其他事务id列表。

Read View主要是用来做可见性判断的, 即当我们某个事务执行快照读的时候,对该记录创建一个Read View读视图,把它比作条件用来判断当前事务能够看到哪个版本的数据,既可能是当前最新的数据,也有可能是该行记录的undo log里面的某个版本的数据。

Read View几个属性

  • trx_ids: 当前系统活跃(未提交)事务版本号集合。
  • low_limit_id: 创建当前read view 时“当前系统最大事务版本号+1”。
  • up_limit_id: 创建当前read view 时“系统正处于活跃事务最小版本号”
  • creator_trx_id: 创建当前read view的事务版本号;

Read View可见性判断条件

  • db_trx_id < up_limit_id || db_trx_id == creator_trx_id(显示)

如果数据事务ID小于read view中的最小活跃事务ID,则可以肯定该数据是在当前事务启之前就已经存在了的,所以可以显示。

或者数据的事务ID等于creator_trx_id ,那么说明这个数据就是当前事务自己生成的,自己生成的数据自己当然能看见,所以这种情况下此数据也是可以显示的。

  • db_trx_id >= low_limit_id(不显示)

如果数据事务ID大于read view 中的当前系统的最大事务ID,则说明该数据是在当前read view 创建之后才产生的,所以数据不显示。如果小于则进入下一个判断

  • db_trx_id是否在活跃事务(trx_ids)中
+ `不存在`:则说明read view产生的时候事务`已经commit`了,这种情况数据则可以`显示`。
+ `已存在`:则代表我Read View生成时刻,你这个事务还在活跃,还没有Commit,你修改的数据,我当前事务也是看不见的。

MVCC和事务隔离级别

上面所讲的Read View用于支持RC(Read Committed,读提交)和RR(Repeatable Read,可重复读)隔离级别的实现。

RR、RC生成时机

  • RC隔离级别下,是每个快照读都会生成并获取最新的Read View;
  • 而在RR隔离级别下,则是同一个事务中的第一个快照读才会创建Read View, 之后的快照读获取的都是同一个Read View,之后的查询就不会重复生成了,所以一个事务的查询结果每次都是一样的。

解决幻读问题

  • 快照读:通过MVCC来进行控制的,不用加锁。按照MVCC中规定的“语法”进行增删改查等操作,以避免幻读。
  • 当前读:通过next-key锁(行锁+gap锁)来解决问题的。

RC、RR级别下的InnoDB快照读区别

  • 在RR级别下的某个事务的对某条记录的第一次快照读会创建一个快照及Read View, 将当前系统活跃的其他事务记录起来,此后在调用快照读的时候,还是使用的是同一个Read View,所以只要当前事务在其他事务提交更新之前使用过快照读,那么之后的快照读使用的都是同一个Read View,所以对之后的修改不可见;
  • 即RR级别下,快照读生成Read View时,Read View会记录此时所有其他活动事务的快照,这些事务的修改对于当前事务都是不可见的。而早于Read View创建的事务所做的修改均是可见
  • 而在RC级别下的,事务中,每次快照读都会新生成一个快照和Read View, 这就是我们在RC级别下的事务中可以看到别的事务提交的更新的原因

总结

从以上的描述中我们可以看出来,所谓的MVCC指的就是在使用READ COMMITTD、REPEATABLE READ这两种隔离级别的事务在执行普通的SEELCT操作时访问记录的版本链的过程,这样子可以使不同事务的读-写、写-读操作并发执行,从而提升系统性能。

关注微信公众号:IT 老哥

回复:Java实战项目视频教程:即可获取200G,27套实战项目视频教程

回复:Java 学习路线,即可获取最新最全的一份学习路线图

回复:Java 电子书,即可领取 13 本顶级程序员必读书籍

回复:Java 全套教程,即可领取:Java 基础、Java web、JavaEE 全部的教程,包括 spring boot 等

回复:简历模板,即可获取 100 份精美简历

本文转载自: 掘金

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

Mybatis如何阅读源码,教你一招!!!

发表于 2020-09-10

前言

  • 前一篇文章简单的介绍了Mybatis的六个重要组件,这六剑客占据了Mybatis的半壁江山,和六剑客搞了基友,那么Mybatis就是囊中之物了。对六剑客感兴趣的朋友,可以看看这篇文章:Mybatis源码解析篇之六剑客
  • 有些初入门的朋友可能很害怕阅读源码,不知道如何阅读源码,与其我一篇文章按照自己的思路写完Mybatis的源码,但是你们又能理解多少呢?不如教会你们思路,让你们能够自己知道如何阅读源码。

环境配置

  • 本篇文章讲的一切内容都是基于Mybatis3.5和SpringBoot-2.3.3.RELEASE。

从哪入手?

  • 还是要说一说六剑客的故事,既然是Mybatis的重要组件,当然要从六剑客下手了,沿用上篇文章的一张图,此图记录了六剑客先后执行的顺序,如下:
    六剑客执行流程图
  • 阅读源码最重要的一点不能忘了,就是开启DEBUG模式,重要方法打上断点,重要语句打上断点,先把握整体,再研究细节,基本就不难了。
  • 下面就以Myabtis的查询语句selectList()来具体分析下如何阅读。

总体把握六剑客

  • 从六剑客开整,既然是重要组件,源码执行流程肯定都是围绕着六剑客,下面来对六剑客一一分析,如何打断点。
  • 下面只是简单的教你如何打断点,对于六剑客是什么不再介绍,请看上篇文章。

SqlSession

  • 既然是接口,肯定不能在接口方法上打断点,上文介绍有两个实现类,分别是DefaultSqlSession、SqlSessionTemplate。那么SpringBoot在初始化的时候到底注入的是哪一个呢?这个就要看Mybatis的启动器的自动配置类了,其中有一段这样的代码,如下:
1
2
3
4
5
6
7
8
9
10
11
java复制代码  //如果容器中没有SqlSessionTemplate这个Bean,则注入
@Bean
@ConditionalOnMissingBean
public SqlSessionTemplate sqlSessionTemplate(SqlSessionFactory sqlSessionFactory) {
ExecutorType executorType = this.properties.getExecutorType();
if (executorType != null) {
return new SqlSessionTemplate(sqlSessionFactory, executorType);
} else {
return new SqlSessionTemplate(sqlSessionFactory);
}
}
  • 从上面的代码可以知道,SpringBoot启动时注入了SqlSessionTemplate,此时就肯定从SqlSessionTemplate入手了。它的一些方法如下图:
    SqlSessionTemplate方法
  • 从上图的标记可以知道,首当其冲的就是构造方法了;既然是分析selectList()的查询流程,当然全部的selectList()方法要打上断点了;上篇文章也讲了Mapper的接口最终是走的动态代理生成的实例,因此此处的getMapper()也打上断点。
  • 对于初入门的来说,上面三处打上断点已经足够了,但是如果你仔细看一眼selectList()方法,如下:
1
2
3
4
5
java复制代码  @Override
public <E> List<E> selectList(String statement) {
//此处的sqlSessionProxy是什么,也是SqlSession类型的,此处断点运行到这里可以知道,就是DefaultSqlSession实例
return this.sqlSessionProxy.selectList(statement);
}
  • sqlSessionProxy是什么,没关系,这个不能靠猜,那么此时断点走一波,走到selectList()方法内部,如下图:
  • 从上图可以很清楚的看到了,其实就是DefaultSqlSession。哦,明白了,原来SqlSessionTemplate把过甩给了DefaultSqlSession了,太狡诈了。
  • DefaultSqlSession如何打断点就不用说了吧,自己搞搞吧。

Executor

  • 上面文章讲过执行器是什么作用,也讲过Mybatis内部是根据什么创建执行器的。此处不再赘述了。
  • SpringBoot整合各种框架有个特点,万变不离自动配置类,框架的一些初始化动作基本全是在自动配置类中完成,于是我们在配置类找一找在哪里注入了Executor的Bean,于是找到了如下的一段代码:
  • 从上面的代码可以知道默认创建了CachingExecutor,二级缓存的执行器,别管那么多,看看它重写了Executor的哪些接口,与selectList()相关的方法打上断点,如下图:
  • 从上图也知道哪些方法和selectList()相关了,显然的query是查询的意思,别管那么多,先打上断点。
  • 此时再仔细瞅一眼query()的方法怎么执行的,哦?发现了什么,如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
java复制代码@Override
public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql)
throws SQLException {
//先尝试从缓存中获取
Cache cache = ms.getCache();
if (cache != null) {
flushCacheIfRequired(ms);
if (ms.isUseCache() && resultHandler == null) {
ensureNoOutParams(ms, boundSql);
@SuppressWarnings("unchecked")
List<E> list = (List<E>) tcm.getObject(cache, key);
if (list == null) {
list = delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
tcm.putObject(cache, key, list); // issue #578 and #116
}
return list;
}
}
//没有缓存,直接调用delegate的query方法
return delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
}
  • 从上面的代码知道,有缓存了,直接返回了,没有缓存,调用了delegate中的query方法,那么这个delegate是哪个类的对象呢?参照sqlSession的分析的方法,调试走起,可以知道是SimpleExecutor的实例,如下图:
  • 后面的SimpleExecutor如何打断点就不再说了,自己尝试找找。

StatementHandler

  • 很熟悉的一个接口,在学JDBC的时候就接触过类似的,执行语句和设置参数的作用。
  • 这个接口很简单,大佬写的代码,看到方法名就知道这个方法是干什么的,如下图:
  • 最重要的实现类是什么?当然是PreparedStatementHandler,因此在对应的方法上打上断点即可。

ParameterHandler

  • 这个接口很简单,也别选择了,总共两个方法,一个设置,一个获取,在实现类DefaultParameterHandler中对应的方法上打上断点即可。

TypeHandler

  • 类型处理器,也是一个简单的接口,总共’两个’方法,一个设置参数的转换,一个对结果的转换,啥也别说了,自己找到对应参数类型的处理器,在其中的方法打上断点。

ResultSetHandler

  • 结果处理器,负责对结果的处理,总共三个方法,一个实现类DefaultResultSetHandler,全部安排断点。

总结

  • 授人以鱼不授人以渔,与其都分析了给你看,不如教会你阅读源码的方式,先自己去研究,不仅仅是阅读Mybatis的源码是这样,阅读任何框架的源码都是如此,比如Spring的源码,只要找到其中重要的组件,比如前置处理器,后置处理器,事件触发器等等,一切都迎刃而解。
  • 如果你觉得作者写的不错,有所收获,不妨关注分享一波,后续更多精彩内容更新。

本文转载自: 掘金

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

1…780781782…956

开发者博客

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