注册
环信即时通讯云

环信即时通讯云

单聊、群聊、聊天室...
环信开发文档

环信开发文档

Demo体验

Demo体验

场景Demo,开箱即用
RTE开发者社区

RTE开发者社区

汇聚音视频领域技术干货,分享行业资讯
技术讨论区

技术讨论区

技术交流、答疑
资源下载

资源下载

收集了海量宝藏开发资源
iOS Library

iOS Library

不需要辛辛苦苦的去找轮子, 这里都有
Android Library

Android Library

不需要辛辛苦苦的去找轮子, 这里都有

何时使用Elasticsearch而不是MySql

MySQL 和 Elasticsearch 是两种不同的数据管理系统,它们各有优劣,适用于不同的场景。本文将从以下几个方面对它们进行比较和分析: 数据模型 查询语言 索引和搜索 分布式和高可用 性能和扩展性 使用场景 数据模型 MySQL 是一个关系型数据...
继续阅读 »

MySQL 和 Elasticsearch 是两种不同的数据管理系统,它们各有优劣,适用于不同的场景。本文将从以下几个方面对它们进行比较和分析:



  • 数据模型

  • 查询语言

  • 索引和搜索

  • 分布式和高可用

  • 性能和扩展性

  • 使用场景


数据模型


MySQL 是一个关系型数据库管理系统(RDBMS),它使用表(table)来存储结构化的数据,每个表由多个行(row)和列(column)组成,每个列有一个预定义的数据类型,例如整数、字符串、日期等。MySQL 支持主键、外键、约束、触发器等关系型数据库的特性,以保证数据的完整性和一致性。


Elasticsearch 是一个基于 Lucene 的搜索引擎,它使用文档(document)来存储半结构化或非结构化的数据,每个文档由多个字段(field)组成,每个字段可以有不同的数据类型,例如文本、数字、布尔、数组等。Elasticsearch 支持动态映射(dynamic mapping),可以根据数据自动推断字段的类型和索引方式。


MySQL 和 Elasticsearch 的数据模型有以下几点区别:



  • MySQL 的数据模型是严格的,需要事先定义好表的结构和约束,而 Elasticsearch 的数据模型是灵活的,可以随时添加或修改字段。

  • MySQL 的数据模型是二维的,每个表只有行和列两个维度,而 Elasticsearch 的数据模型是多维的,每个文档可以有嵌套的对象或数组。

  • MySQL 的数据模型是关系型的,可以通过连接(join)多个表来查询相关的数据,而 Elasticsearch 的数据模型是非关系型的,不支持连接操作,需要通过嵌套文档或父子文档来实现关联查询。



推荐博主开源的 H5 商城项目waynboot-mall,这是一套全部开源的微商城项目,包含三个项目:运营后台、H5 商城前台和服务端接口。实现了商城所需的首页展示、商品分类、商品详情、商品 sku、分词搜索、购物车、结算下单、支付宝/微信支付、收单评论以及完善的后台管理等一系列功能。 技术上基于最新得 Springboot3.0、jdk17,整合了 MySql、Redis、RabbitMQ、ElasticSearch 等常用中间件。分模块设计、简洁易维护,欢迎大家点个 star、关注博主。


github 地址:github.com/wayn111/way…



查询语言


MySQL 使用标准的 SQL 语言来查询和操作数据,SQL 语言是一种声明式的语言,可以通过简洁的语法来表达复杂的逻辑。SQL 语言支持多种查询类型,例如选择(select)、插入(insert)、更新(update)、删除(delete)、聚合(aggregate)、排序(order by)、分组(gr0up by)、过滤(where)、连接(join)等。


Elasticsearch 使用 JSON 格式的查询 DSL(Domain Specific Language)来查询和操作数据,查询 DSL 是一种基于 Lucene 查询语法的语言,可以通过嵌套的 JSON 对象来构建复杂的查询。查询 DSL 支持多种查询类型,例如全文检索(full-text search)、结构化检索(structured search)、地理位置检索(geo search)、度量检索(metric search)等。


MySQL 和 Elasticsearch 的查询语言有以下几点区别:



  • MySQL 的查询语言是通用的,可以用于任何关系型数据库系统,而 Elasticsearch 的查询语言是专用的,只能用于 Elasticsearch 系统。

  • MySQL 的查询语言是字符串形式的,需要拼接或转义特殊字符,而 Elasticsearch 的查询语言是 JSON 形式的,可以直接使用对象或数组表示。

  • MySQL 的查询语言是基于集合论和代数运算的,可以进行集合操作和数学运算,而 Elasticsearch 的查询语言是基于倒排索引和相关度评分的,可以进行全文匹配和相似度计算。


索引和搜索


MySQL 使用 B+树作为主要的索引结构,B+树是一种平衡多路搜索树,它可以有效地存储和检索有序的数据。MySQL 支持主键索引、唯一索引、普通索引、全文索引等多种索引类型,以加速不同类型的查询。MySQL 也支持外部存储引擎,例如 InnoDB、MyISAM、Memory 等,不同的存储引擎有不同的索引和锁机制。


Elasticsearch 使用倒排索引作为主要的索引结构,倒排索引是一种将文档中的词和文档的映射关系存储的数据结构,它可以有效地支持全文检索。Elasticsearch 支持多种分词器(analyzer)和分词过滤器(token filter),以对不同语言和场景的文本进行分词和处理。Elasticsearch 也支持多种搜索类型,例如布尔搜索(boolean search)、短语搜索(phrase search)、模糊搜索(fuzzy search)、通配符搜索(wildcard search)等,以实现不同精度和召回率的检索。


MySQL 和 Elasticsearch 的索引和搜索有以下几点区别:



  • MySQL 的索引是基于数据的值的,可以精确地定位数据的位置,而 Elasticsearch 的索引是基于数据的内容的,可以近似地匹配数据的含义。

  • MySQL 的索引是辅助的,需要手动创建和维护,而 Elasticsearch 的索引是主要的,自动创建和更新。

  • MySQL 的索引是局部的,只针对单个表或列,而 Elasticsearch 的索引是全局的,涵盖所有文档和字段。


分布式和高可用


MySQL 是一个单机数据库系统,它只能运行在一台服务器上,如果服务器出现故障或负载过高,就会影响数据库的可用性和性能。为了解决这个问题,MySQL 提供了多种复制(replication)和集群(cluster)方案,例如主从复制(master-slave replication)、双主复制(master-master replication)、MySQL Cluster、MySQL Fabric 等,以实现数据的冗余和负载均衡。


Elasticsearch 是一个分布式数据库系统,它可以运行在多台服务器上,形成一个集群(cluster)。每个集群由多个节点(node)组成,每个节点可以承担不同的角色,例如主节点(master node)、数据节点(data node)、协调节点(coordinating node)等。每个节点可以存储多个索引(index),每个索引可以划分为多个分片(shard),每个分片可以有多个副本(replica)。Elasticsearch 通过一致性哈希算法(consistent hashing algorithm)来分配分片到不同的节点上,并通过心跳检测(heartbeat check)来监控节点的状态。如果某个节点出现故障或加入集群,Elasticsearch 会自动进行分片的重新分配和平衡。


MySQL 和 Elasticsearch 的分布式和高可用有以下几点区别:



  • MySQL 的分布式和高可用是可选的,需要额外配置和管理,而 Elasticsearch 的分布式和高可用是内置的,无需额外操作。

  • MySQL 的分布式和高可用是基于复制或共享存储的,需要保证数据一致性或可用性之间的权衡,而 Elasticsearch 的分布式和高可用是基于分片和副本的,可以根据需求调整数据冗余度或容错能力。

  • MySQL 的分布式和高可用是静态的,需要手动扩展或缩容集群规模,而 Elasticsearch 的分布式和高可用是动态的,可以自动适应集群变化。


性能和扩展性


MySQL 是一个面向事务(transaction)的数据库系统,它支持 ACID 特性(原子性、一致性、隔离性、持久性),以保证数据操作的正确性和完整性。MySQL 使用锁机制来实现事务隔离级别(isolation level),不同的隔离级别有不同的并发性能和一致性保证。MySQL 也使用缓冲池(buffer pool)来缓存数据和索引,以提高查询效率。MySQL 的性能主要取决于硬件资源、存储引擎、索引设计、查询优化等因素。


Elasticsearch 是一个面向搜索(search)的数据库系统,它支持近实时(near real-time)的索引和查询,以保证数据操作的及时性和灵活性。Elasticsearch 使用分片和副本来实现数据的分布式存储和并行处理,不同的分片数和副本数有不同的写入吞吐量和读取延迟。Elasticsearch 也使用缓存(cache)和内存映射文件(memory-mapped file)来加速数据和索引的访问,以提高搜索效率。Elasticsearch 的性能主要取决于集群规模、分片策略、文档结构、查询复杂度等因素。


MySQL 和 Elasticsearch 的性能和扩展性有以下几点区别:



  • MySQL 的性能和扩展性是有限的,它受到单机资源、锁竞争、复制延迟等因素的限制,而 Elasticsearch 的性能和扩展性是无限的,它可以通过增加节点、分片、副本等方式来水平扩展集群。

  • MySQL 的性能和扩展性是以牺牲搜索能力为代价的,它不能支持复杂的全文检索和相关度评分,而 Elasticsearch 的性能和扩展性是以牺牲事务能力为代价的,它不能保证数据操作的原子性和一致性。

  • MySQL 的性能和扩展性是以提高写入速度为目标的,它优化了数据插入和更新的效率,而 Elasticsearch 的性能和扩展性是以提高读取速度为目标的,它优化了数据检索和分析的效率。


使用场景


MySQL 和 Elasticsearch 适用于不同的使用场景,根据不同的业务需求,可以选择合适的数据库系统或组合使用两者。以下是一些常见的使用场景:



  • 如果需要存储结构化或半结构化的数据,并且需要保证数据操作的正确性和完整性,可以选择 MySQL 作为主要数据库系统。例如,电商网站、社交网络、博客平台等。

  • 如果需要存储非结构化或多样化的数据,并且需要支持复杂的全文检索和相关度评分,可以选择 Elasticsearch 作为主要数据库系统。例如搜索引擎、日志分析、推荐系统等。

  • 如果需要存储和分析大量的时序数据,并且需要支持实时的聚合和可视化,可以选择Elasticsearch作为主要数据库系统。例如,物联网、监控系统、金融市场等。

  • 如果需要同时满足上述两种需求,并且可以容忍一定程度的数据不一致或延迟,可以将 MySQL 作为主数据库系统,并将部分数据同步到 Elasticsearch 作为辅助数据库系统。例如新闻网站、电影网站、招聘网站等。


自此本文讲解内容到此结束,感谢您的阅读,希望本文对您有所帮助。


作者:程序员wayn
来源:juejin.cn/post/7264528507932327948
收起阅读 »

Easy-Es:像mybatis-plus一样,轻松操作ES

0. 引言 es的java客户端不太友好的语法一直饱受诟病,书写一个查询语句可能需要书写一大串的代码,如果能像mybatis--plus一样,支持比较灵活方便的语句生成器那就好了。 于是为elasticsearch而生的ORM框架Easy-Es诞生了,使用及其...
继续阅读 »

0. 引言


es的java客户端不太友好的语法一直饱受诟病,书写一个查询语句可能需要书写一大串的代码,如果能像mybatis--plus一样,支持比较灵活方便的语句生成器那就好了。


于是为elasticsearch而生的ORM框架Easy-Es诞生了,使用及其方便快捷,今天我们就一起来学习easy-es,对比看看原生java-client方便之处在哪儿。


1. Easy-Es简介


Easy-Es是以elasticsearch官方提供的RestHighLevelClient为基础,而开发的一款针对es的ORM框架,类似于es版的mybatis-plus,可以让开发者无需掌握es复杂的DSL语句,只要会mysql语法即可使用es,快速实现es客户端语法


官方文档:http://www.easy-es.cn/
在这里插入图片描述


2. Easy-Es使用


1、引入依赖


<!-- 引入easy-es最新版本的依赖-->
<dependency>
<groupId>org.dromara.easy-es</groupId>
<artifactId>easy-es-boot-starter</artifactId>
<version>2.0.0-beta3</version>
</dependency>

<!-- 排除springboot中内置的es依赖,以防和easy-es中的依赖冲突-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-high-level-client</artifactId>
</exclusion>
<exclusion>
<groupId>org.elasticsearch</groupId>
<artifactId>elasticsearch</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-high-level-client</artifactId>
<version>7.14.0</version>
</dependency>
<dependency>
<groupId>org.elasticsearch</groupId>
<artifactId>elasticsearch</artifactId>
<version>7.14.0</version>
</dependency>

2、添加配置项,这里只配置了几个基本的配置项,更多配置可参考官网文档:easy-es 配置介绍


easy-es: 
# es地址、账号密码
address: 192.168.244.11:9200
username: elastic
password: elastic

3、在启动类中添加es mapper文件的扫描路径


@EsMapperScan("com.example.easyesdemo.mapper")

在这里插入图片描述


4、创建实体类,通过@IndexName注解申明索引名称及分片数, @IndexField注解申明字段名、数据类型、分词器等,更多介绍参考官方文档:essy-es 注解介绍


@IndexName(value = "user_easy_es")
@Data
public class UserEasyEs {

@IndexId(type = IdType.CUSTOMIZE)
private Long id;

private String name;

private Integer age;

private Integer sex;

@IndexField(fieldType = FieldType.TEXT, analyzer = Analyzer.IK_SMART, searchAnalyzer = Analyzer.IK_SMART)
private String address;

@IndexField(fieldType = FieldType.DATE, dateFormat = "yyyy-MM-dd HH:mm:ss||yyyy-MM-dd||epoch_millis")
private Date createTime;

private String createUser;

}

5、创建mapper类,继承BaseEsMapper类,注意这里的mapper一定要创建到第3步中设置的mapper扫描路径下com.example.easyesdemo.mapper


public interface UserEsMapper extends BaseEsMapper<UserEasyEs> {
}

6、创建controller,书写创建索引、新增、修改、查询的接口


@RestController
@RequestMapping("es")
@AllArgsConstructor
public class UserEsController {

private final UserEsMapper userEsMapper;

/**
* 创建索引
* @return
*/

@GetMapping("create")
public Boolean createIndex(){
return userEsMapper.createIndex();
}

@GetMapping("save")
public Integer save(Long id){
UserEasyEs user = new UserEasyEs();
user.setId(id);
user.setName("用户"+id);
user.setAddress("江苏省无锡市滨湖区");
user.setAge(30);
user.setSex(1);
user.setCreateUser("admin");
user.setCreateTime(new Date());
Long count = userEsMapper.selectCount(EsWrappers.lambdaQuery(UserEasyEs.class).eq(UserEasyEs::getId, id));
if(count > 0){
return userEsMapper.updateById(user);
}else{
return userEsMapper.insert(user);
}
}

@GetMapping("search")
public List<UserEasyEs> search(String name, String address){
List<UserEasyEs> userEasyEs = userEsMapper.selectList(
EsWrappers.lambdaQuery(UserEasyEs.class)
.eq(UserEasyEs::getName, name)
.match(UserEasyEs::getAddress, address)
);
return userEasyEs;
}

}

7、分别调用几个接口



  • 创建索引
    在这里插入图片描述
    kibana中查询索引,发现创建成功
    在这里插入图片描述

  • 新增接口
    这里新增了4笔
    在这里插入图片描述
    数据新增成功
    在这里插入图片描述

  • 数据查询


在这里插入图片描述
如上便是针对easy-es的简单使用,这里的用法都与mp类似,上手相当简单,不用再写那些复杂的DSL语句了


3. 拓展介绍



  • 条件构造器



上述演示,我们构造查询条件时,使用了EsWrappers来构造条件,用法与mp及其类型,大家根据提示就可以推导出方法如何书写,更详细的使用说明可以查看官方文档:easy-es 条件构造器介绍




  • 索引托管



如果想要自动根据创建的es实体类来创建对应的索引,那么只需要调整索引的托管模式为非手动模式即可,因为这里我不需要自动同步数据,所以选择非平滑模式



easy-es:
global-config:
process_index_mode: not_smoothly


其中三种模式的区别为:
平滑模式:smoothly,索引的创建、数据更新迁移等都由easy-es自动完成
非平滑模式:not_smoothly,索引自动创建,但不会自动迁移数据
手动模式:manual,全部操作由用户手动完成,默认模式




  • 数据同步



如果数据源是来自mysql, 那么建议使用canal来进行同步,canal的使用可在我主页搜索。
其次还有DataX, Logstash等同步工具,当然你也可以使用easy-es提供的CRUD接口,来手动同步数据




  • 日志打印



通过开启日志,可以在控制台打印执行的DSL语句,更加方便我们在开发阶段进行问题排查



logging:
level:
tracer: trace # 开启trace级别日志,在开发时可以开启此配置,则控制台可以打印es全部请求信息及DSL语句,为了避免重复,开启此项配置后,可以将EE的print-dsl设置为false.


  • 聚合查询
    easy-es实现的聚合查询,只要是针对gr0up by这类聚合,也就是es中的Terms aggregation,以及最大值、最小值、平均值、求和,而对于其他类型的聚合,还在不断更新中,但这里大家也需要了解,es的聚合和mysql的聚合完全是不一样的维度和复杂度,es支持非常多的聚合查询,所以其他类型的实现还需要借助RestHighLevelClient来实现


我们利用easy-es来实现下之前书写的聚合案例
在这里插入图片描述


@RestController
@AllArgsConstructor
@RequestMapping("order")
public class OrderEsController {

private final OrderTestEsMapper orderEsMapper;

@GetMapping("search")
public String search(){
SearchResponse search = orderEsMapper.search(EsWrappers.lambdaQuery(OrderTest.class).groupBy(OrderTest::getStatus));

// 包装查询结果
Aggregations aggregations = search.getAggregations();
Terms terms = (Terms)aggregations.asList().get(0);
List<? extends Terms.Bucket> buckets = terms.getBuckets();
HashMap<String,Long> statusRes = new HashMap<>();
buckets.forEach(bucket -> {
statusRes.put(bucket.getKeyAsString(),bucket.getDocCount());
});
System.out.println("---聚合结果---");
System.out.println(statusRes);
return statusRes.toString();
}
}

可以看到实际上的查询语句就一行,而其他的都是对返回结果的封装,因为es本身返回的数据是封装到嵌套的对象中的,所以我们需要对其进行包装


对比原始的查询语句,其易用性上的提升还是很明显的
在这里插入图片描述


4. 总结


至此对easy-es的介绍就结束了,可以看到如果是针对es实现CRUD上,easy-es表现出非常好的便捷性,而在复杂的聚合查询中,仍然还有进步空间,目前还需要借助RestHighLevelClient,但easy-es的出现,为未来提供更好用的ES ORM框架,提供了希望和方向


文中演示代码见:gitee.com/wuhanxue/wu…


作者:wu55555
来源:juejin.cn/post/7271896547594682428
收起阅读 »

研发都认为DBA很Low?我反手一个嘴巴子

前言我用十多年的DBA经验告诉你,如果你作为研发觉得DBA很Low,你是会吃苦头的“你以为DBA就是安装一下数据库,管理一下数据库?你丢个SQL给DBA优化下?你的日志爆满了DBA给你清理一下?DBA帮你安装下中间件?你以为的DBA只是做这些事?”秉持着和平交...
继续阅读 »

前言

我用十多年的DBA经验告诉你,如果你作为研发觉得DBA很Low,你是会吃苦头的

“你以为DBA就是安装一下数据库,管理一下数据库?你丢个SQL给DBA优化下?你的日志爆满了DBA给你清理一下?DBA帮你安装下中间件?你以为的DBA只是做这些事?”

秉持着和平交流的学习态度,我这里精选了几位高赞粉丝的精彩回答

1.救火能力

1.1 调优

IT界并没有一个通行的 ”拳头“ 来判断谁low,谁更low。有时候,研发写的程序,新功能发布后,就出现磁盘IO出现瓶颈了、或者CPU飙高到100%了,但是这个时候,只是表象,只知道Linux机器的资源耗尽了,DBA得先找到资源消耗在哪了,才能进一步分析原因,用数据说话是应用的问题,才能责令程序员整改。

SQL调优是一个复杂的过程,涉及多个方面,包括但不限于SQL语句的编写、索引的使用、表的连接策略、数据库的统计信息、系统资源的利用等。调优的难度取决于多个因素,包括查询的复杂性、数据量、硬件资源、数据库的工作负载和现有的优化策略。

在这里给大家分享一个执行计划变,1个SQL把系统干崩的情景,由于业务用户检索数据范围过大,导致执行计划谓词越界,通过矫正执行计划及开启操作系统大页,服务器DB一直存在的CPU高负载从75%降低到25%!

生产问题,瞬息万变,DBA要同时熟悉业务,并对硬件、网络要精通,要在这样的复杂情况下作出正确的决策,这一点我想难度不小吧。

1.2 高可用

数据库高可用是指DB集群中任何一个节点的故障都不会影响用户的使用,连接到故障节点的用户会被自动转移到健康节点,从用户感受而言, 是感觉不到这种切换。

那么DBA在高可用的配置方面,下面就是某制造业大厂,应用层的链接方式

--jdbc应用端的连接
jdbc:oracle:thin:@(DESCRIPTION =
(ADDRESS_LIST =(ADDRESS = (PROTOCOL = TCP)(HOST = rac1-vip)
(PORT = 1521))(ADDRESS = (PROTOCOL = TCP)(HOST = rac2-vip)
(PORT = 1521))(LOAD_BALANCE = no)(FAILOVER = yes))
(CONNECT_DATA =(SERVER = DEDICATED)(SERVICE_NAME = dbserver)))

那么这种配置FAILOVER = yes,Net会从多个地址中按顺序选择一个地址进行连接,直到连接成功为止,那么就会保证数据库单节点故障,自动的切换,高可用是故障发生的第一个救命的稻草,系统上线前一定要测试好,才能确保数据库的高可用,这期间DBA功不可没!

还有客户要求选择的一套国产数据库支持核心业务,那么作为DBA在选型及业务适配上就发挥作用了,跟研发确认发现应用是兼容PG的,而且客户要求要同时兼容OLAT和OLTP业务,看下以下这套openGauss国产数据库的高可用架构。

1.openGauss高可用:CM
通过配置VIP故障转移,OLTP连接VIP,进行事物交易
同时支持动态配置CM集群故障切换策略和数据库集群脑裂故障恢复策略,
从而能够尽可能确保集群数据的完整性和一致性。

2.写重定向,报表分析业务连接,支持读写分离
主备节点开启控制参数 enable_remote_execute=on之后
通过备库发起的写操作,会重定向到主库执行

2.监控能力

这方面我是最有发言权了,SA一直是我的本职工作,从机房硬件部署、弱电以及数据库的安装实施,很多东西需要依赖于DBA来做,全力保障应用的稳定性,而且监控到的指标随时可以推送到邮件以及微信。这期间我也发现了很多天窗,原来还可以这么干?

2.1 服务器监控

首先监控Linux服务嘛,那肯定是要全方位系统的监控,网络、磁盘、CPU、内存等等,这才叫监控,那么其实给大家推荐一款免费的监控工作

Prometheus提供了从指标暴露,到指标抓取、存储和可视化,以及最后的监控告警等组件。

数据库监控

Zabbix聚焦于帮助用户通过性能优化和功能升级来快速响应业务需求,从而满足客户的高期望值,并提升IT运维人员的生产力。在可扩展性与性能、稳定性与高可用、可观测性几个领域获得持续提升。监控做不好,救火救到老!拿下Zabbix,现在!立刻!马上!!

1.监控Oracle
博客地址:
https://jeames.blog.csdn.net/article/details/126825934
2.监控PostgreSQL
博客地址:
https://jeames.blog.csdn.net/article/details/120300581
3.监控MySQL
博客地址:
https://jeames.blog.csdn.net/article/details/126825934

3 数据源赋能者

从AI、智能化到云迁移和安全性,业务和技术趋势不断重塑DBA在组织中的角色.DBA 群体站在时代的岔路口,国产数据库太多了应该怎么选?DBA 会被云上数据库抛弃吗?应该如何应对新时代挑战?职业终点在哪里?

1.云数据库解决方案
DBA要善于利用云原生保障数据安全和优化成本

2.数据安全与合规
随着数据保护法律的出台、日益严峻的网络攻击,
DBA必须掌握加密、访问控制和审计等技能

3.灾难恢复和业务连续性
随着企业愈加依赖数据的连续性,
快速恢复丢失数据并最大限度地减少停机时间至关重要

4.自动化和脚本编写
自动化和脚本编写对于DBA管理重复性任务和提高效率尤为关键

5.有效的沟通和协作
有效的沟通和协作仍然是DBA的重要技能。
能够向同事清楚地传达技术信息、与跨职能团队合作,
打破IT部门和业务部门之间的信息差,确保数据库的策略与组织目标保持一致。

4.总结

在一个公司写了屎山代码的研发,可以拍拍屁股走人,然后继续去下一个企业再写个屎山。反正不会追着代码跨省找你。而一个搞崩了系统的DBA,这个闯祸经历将成为他的黑历史,并影响到他未来的就业.因为需要专业DBA的好企业,基本都是几百台服务器起步的大项目,难免不会查背景,这就导致DBA如果想干得好,圈子会越来越小,请记住是干得好,不是混得好,混是会出事的。

好了,以上就是我对DBA的理解了,有不足之处还望指正。


作者:IT邦德
来源:juejin.cn/post/7386505099848646710
收起阅读 »

MySQL 高级(进阶)SQL 语句

MySQL 高级(进阶)SQL 语句 1. MySQL SQL 语句 1.1 常用查询 常用查询简单来说就是 增、删、改、查 对 MySQL 数据库的查询,除了基本的查询外,有时候需要对查询的结果集进行处理。 例如只取 10 条数据、对查询结果进行排序或分组等...
继续阅读 »

MySQL 高级(进阶)SQL 语句


1. MySQL SQL 语句


1.1 常用查询


常用查询简单来说就是 增、删、改、查


对 MySQL 数据库的查询,除了基本的查询外,有时候需要对查询的结果集进行处理。 例如只取 10 条数据、对查询结果进行排序或分组等等


1、按关键字排序
PS:类比于windows 任务管理器
使用 SELECT 语句可以将需要的数据从 MySQL 数据库中查询出来,如果对查询的结果进行排序,可以使用 ORDER BY 语句来对语句实现排序,并最终将排序后的结果返回给用户。这个语句的排序不光可以针对某一个字段,也可以针对多个字段


(1)语法
SELECT column1, column2, … FROM table_name ORDER BY column1, column2, …


ASC|DESC
ASC 是按照升序进行排序的,是默认的排序方式,即 ASC 可以省略。SELECT 语句中如果没有指定具体的排序方式,则默认按 ASC方式进行排序。


DESC 是按降序方式进 行排列。当然 ORDER BY 前面也可以使用 WHERE 子句对查询结果进一步过滤。


准备工作:


create database k1;
use k1;
create table location (Region char(20),Store_Name char(20));
insert int0 location values('East','Boston');
insert int0 location values('East','New York');
insert int0 location values('West','Los Angeles');
insert int0 location values('West','Houston');

create table store_info (Store_Name char(20),Sales int(10),Date char(10));
insert int0 store_info values('Los Angeles','1500','2020-12-05');
insert int0 store_info values('Houston','250','2020-12-07');
insert int0 store_info values('Los Angeles','300','2020-12-08');
insert int0 store_info values('Boston','700','2020-12-08');

1.2 SELECT


显示表格中一个或数个字段的所有数据记录 语法:SELECT "字段" FROM "表名";


SELECT Store_Name FROM location;

d1.png


SELECT Store_Name FROM Store_Info;

d2.png


1.3 DISTINCT


不显示重复的数据记录


语法:SELECT DISTINCT "字段" FROM "表名";


SELECT DISTINCT Store_Name FROM Store_Info;

d3.png


1.4 AND OR


且 或


语法:SELECT "字段" FROM "表名" WHERE "条件1" {[AND|OR] "条件2"}+ ;


d4.png


1.5 in


显示已知的值的数据记录


语法:SELECT "字段" FROM "表名" WHERE "字段" IN ('值1', '值2', ...);


SELECT * FROM store_info WHERE Store_Name IN ('Los Angeles', 'Houston');

b5.png


1.6 BETWEEN


显示两个值范围内的数据记录


语法:SELECT "字段" FROM "表名" WHERE "字段" BETWEEN '值1' AND '值2';


d6.png


2. 通配符 —— 通常与 LIKE 搭配 一起使用



% :百分号表示零个、一个或多个字符


_ :下划线表示单个字符


'A_Z':所有以 'A' 起头,另一个任何值的字符,且以 'Z' 为结尾的字符串。例如,'ABZ' 和 'A2Z' 都符合这一个模式,而 'AKKZ' 并不符合 (因为在 A 和 Z 之间有两个字符,而不是一个字符)。


'ABC%': 所有以 'ABC' 起头的字符串。例如,'ABCD' 和 'ABCABC' 都符合这个模式。 '%XYZ': 所有以 'XYZ' 结尾的字符串。例如,'WXYZ' 和 'ZZXYZ' 都符合这个模式。


'%AN%': 所有含有 'AN'这个模式的字符串。例如,'LOS ANGELES' 和 'SAN FRANCISCO' 都符合这个模式。


'_AN%':所有第二个字母为 'A' 和第三个字母为 'N' 的字符串。例如,'SAN FRANCISCO' 符合这个模式,而 'LOS ANGELES' 则不符合这个模式。



2.1 LIKE


匹配一个模式来找出我们要的数据记录


语法:SELECT "字段" FROM "表名" WHERE "字段" LIKE {模式};


SELECT * FROM store_info WHERE Store_Name like '%os%';

d7.png


2.2 ORDER BY


按关键字排序


语法:SELECT "字段" FROM "表名" [WHERE "条件"] ORDER BY "字段" [ASC, DESC];


注意ASC 是按照升序进行排序的,是默认的排序方式。 DESC 是按降序方式进行排序


SELECT Store_Name,Sales,Date FROM store_info ORDER BY Sales DESC;

d8.png


3. 函数


3.1数学函数


abs(x)返回 x 的绝对值
rand()返回 0 到 1 的随机数
mod(x,y)返回 x 除以 y 以后的余数
power(x,y)返回 x 的 y 次方
round(x)返回离 x 最近的整数
round(x,y)保留 x 的 y 位小数四舍五入后的值
sqrt(x)返回 x 的平方根
truncate(x,y)返回数字 x 截断为 y 位小数的值
ceil(x)返回大于或等于 x 的最小整数
floor(x)返回小于或等于 x 的最大整数
greatest(x1,x2...)返回集合中最大的值,也可以返回多个字段的最大的值
least(x1,x2...)返回集合中最小的值,也可以返回多个字段的最小的值

SELECT abs(-1), rand(), mod(5,3), power(2,3), round(1.89);
SELECT round(1.8937,3), truncate(1.235,2), ceil(5.2), floor(2.1), least(1.89,3,6.1,2.1);

d9.png


3.2 聚合函数


avg()返回指定列的平均值
count()返回指定列中非 NULL 值的个数
min()返回指定列的最小值
max()返回指定列的最大值
sum(x)返回指定列的所有值之和

SELECT avg(Sales) FROM store_info;

SELECT count(Store_Name) FROM store_info;
SELECT count(DISTINCT Store_Name) FROM store_info;

SELECT max(Sales) FROM store_info;
SELECT min(Sales) FROM store_info;

SELECT sum(Sales) FROM store_info;

d10.png


d11.png


3.3 字符串函数


trim()返回去除指定格式的值
concat(x,y)将提供的参数 x 和 y 拼接成一个字符串
substr(x,y)获取从字符串 x 中的第 y 个位置开始的字符串,跟substring()函数作用相同
substr(x,y,z)获取从字符串 x 中的第 y 个位置开始长度为 z 的字符串
length(x)返回字符串 x 的长度
replace(x,y,z)将字符串 z 替代字符串 x 中的字符串 y
upper(x)将字符串 x 的所有字母变成大写字母
lower(x)将字符串 x 的所有字母变成小写字母
left(x,y)返回字符串 x 的前 y 个字符
right(x,y)返回字符串 x 的后 y 个字符
repeat(x,y)将字符串 x 重复 y 次
space(x)返回 x 个空格
strcmp(x,y)比较 x 和 y,返回的值可以为-1,0,1
reverse(x)将字符串 x 反转

d12.png


如 sql_mode 开启了 PIPES_AS_CONCAT,"||" 视为字符串的连接操作符而非或运算符,和字符串的拼接函数Concat相类似,这和Oracle数据库使用方法一样的


SELECT Region || ' ' || Store_Name FROM location WHERE Store_Name = 'Boston';
SELECT substr(Store_Name,3) FROM location WHERE Store_Name = 'Los Angeles';
SELECT substr(Store_Name,2,4) FROM location WHERE Store_Name = 'New York'

d13.png


SELECT TRIM ([ [位置] [要移除的字符串] FROM ] 字符串);


**[位置]:的值可以为 LEADING (起头), TRAILING (结尾), BOTH (起头及结尾)。 **


[要移除的字符串]:从字串的起头、结尾,或起头及结尾移除的字符串。缺省时为空格。


SELECT TRIM(LEADING 'Ne' FROM 'New York');

SELECT Region,length(Store_Name) FROM location;

SELECT REPLACE(Region,'ast','astern')FROM location;

d14.png


4. GR0UP BY


对GR0UP BY后面的字段的查询结果进行汇总分组,通常是结合聚合函数一起使用的


GR0UP BY 有一个原则



  • 凡是在 GR0UP BY 后面出现的字段,必须在 SELECT 后面出现;

  • 凡是在 SELECT 后面出现的、且未在聚合函数中出现的字段,必须出现在 GR0UP BY 后面


语法:SELECT "字段1", SUM("字段2") FROM "表名" GR0UP BY "字段1";


SELECT Store_Name, SUM(Sales) FROM store_info GR0UP BY Store_Name ORDER BY sales desc;

d15.png


5. 别名


字段別名 表格別名


语法:SELECT "表格別名"."字段1" [AS] "字段別名" FROM "表格名" [AS] "表格別名";


SELECT A.Store_Name Store, SUM(A.Sales) "Total Sales" FROM store_info A GR0UP BY A.Store_Name;

d16.png


6. 子查询


子查询也被称作内查询或者嵌套查询,是指在一个查询语句里面还嵌套着另一个查询语 句。子查询语句是先于主查询语句被执行的,其结果作为外层的条件返回给主查询进行下一 步的查询过滤


连接表格,在WHERE 子句或 HAVING 子句中插入另一个 SQL 语句


语法:SELECT "字段1" FROM "表格1" WHERE "字段2" [比较运算符] #外查询 (SELECT "字段1" FROM "表格2" WHERE "条件"); #内查询


[比较运算符]


可以是符号的运算符,例如 =、>、<、>=、<= ;也可以是文字的运算符,例如 LIKE、IN、BETWEEN


SELECT SUM(Sales) FROM store_info WHERE Store_Name IN
(SELECT Store_Name FROM location WHERE Region = 'West');

SELECT SUM(A.Sales) FROM store_info A WHERE A.Store_Name IN
(SELECT Store_Name FROM location B WHERE B.Store_Name = A.Store_Name);

d17.png


7. EXISTS


用来测试内查询有没有产生任何结果,类似布尔值是否为真 #如果有的话,系统就会执行外查询中的SQL语句。若是没有的话,那整个 SQL 语句就不会产生任何结果。


语法:SELECT "字段1" FROM "表格1" WHERE EXISTS (SELECT \* FROM "表格2" WHERE "条件");


SELECT SUM(Sales) FROM store_info WHERE EXISTS (SELECT * FROM location WHERE Region = 'West');

d18.png


8. 连接查询


准备工作


create database k1;
use k1;
create table location (Region char(20),Store_Name char(20));
insert int0 location values('East','Boston');
insert int0 location values('East','New York');
insert int0 location values('West','Los Angeles');
insert int0 location values('West','Houston');

create table store_info (Store_Name char(20),Sales int(10),Date char(10));
insert int0 store_info values('Los Angeles','1500','2020-12-05');
insert int0 store_info values('Houston','250','2020-12-07');
insert int0 store_info values('Los Angeles','300','2020-12-08');
insert int0 store_info values('Boston','700','2020-12-08');

f1.png


UPDATE store_info SET store_name='Washington' WHERE sales=300;

f2.png


inner join(内连接):只返回两个表中联结字段相等的行


left join(左连接):返回包括左表中的所有记录和右表中联结字段相等的记录


right join(右连接):返回包括右表中的所有记录和左表中联结字段相等的记录


f3.png


8.1 内连接


MySQL 中的内连接就是两张或多张表中同时符合某种条件的数据记录的组合。通常在 FROM 子句中使用关键字 INNER JOIN 来连接多张表,并使用 ON 子句设置连接条件,内连接是系统默认的表连接,所以在 FROM 子句后可以省略 INNER 关键字,只使用 关键字 JOIN。同时有多个表时,也可以连续使用 INNER JOIN 来实现多表的内连接,不过为了更好的性能,建议最好不要超过三个表


(1) 语法 求交集


SELECT column_name(s)FROM table1 INNER JOIN table2 ON table1.column_name = table2.column_name;

SELECT * FROM location A INNER JOIN store_info B on A.Store_Name = B.Store_Name ;

f4.png


内连查询:通过inner join 的方式将两张表指定的相同字段的记录行输出出来


8.2 左连接


左连接也可以被称为左外连接,在 FROM 子句中使用 LEFT JOIN 或者 LEFT OUTER JOIN 关键字来表示。左连接以左侧表为基础表,接收左表的所有行,并用这些行与右侧参 考表中的记录进行匹配,也就是说匹配左表中的所有行以及右表中符合条件的行。


SELECT * FROM location A LEFT JOIN store_info B on A.Store_Name = B.Store_Name ;

f5.png


左连接中左表的记录将会全部表示出来,而右表只会显示符合搜索条件的记录,右表记录不足的地方均为 NULL


8.3 右连接


右连接也被称为右外连接,在 FROM 子句中使用 RIGHT JOIN 或者 RIGHT OUTER JOIN 关键字来表示。右连接跟左连接正好相反,它是以右表为基础表,用于接收右表中的所有行,并用这些记录与左表中的行进行匹配


SELECT * FROM location A RIGHT JOIN store_info B on A.Store_Name = B.Store_Name ;

f6.png


9. UNION ----联集


将两个SQL语句的结果合并起来,两个SQL语句所产生的字段需要是同样的数据记录种类


UNION :生成结果的数据记录值将没有重复,且按照字段的顺序进行排序


语法:[SELECT 语句 1] UNION [SELECT 语句 2];


SELECT Store_Name FROM location UNION SELECT Store_Name FROM store_info;

f7.png


UNION ALL :将生成结果的数据记录值都列出来,无论有无重复


语法:[SELECT 语句 1] UNION ALL [SELECT 语句 2];


SELECT Store_Name FROM location UNION ALL SELECT Store_Name FROM store_info;

f8.png


9.1 交集值


取两个SQL语句结果的交集


SELECT A.Store_Name FROM location A INNER JOIN store_info B ON A.Store_Name = B.Store_Name;

SELECT A.Store_Name FROM location A INNER JOIN store_info B USING(Store_Name);

f9.png


取两个SQL语句结果的交集,且没有重复


SELECT DISTINCT A.Store_Name FROM location A INNER JOIN store_info B USING(Store_Name);

SELECT DISTINCT Store_Name FROM location WHERE (Store_Name) IN (SELECT Store_Name FROM store_info);

SELECT DISTINCT A.Store_Name FROM location A LEFT JOIN store_info B USING(Store_Name) WHERE B.Store_Name IS NOT NULL;

SELECT A.Store_Name FROM (SELECT B.Store_Name FROM location B INNER JOIN store_info C ON B.Store_Name = C.Store_Name) A
GR0UP BY A.Store_Name;

SELECT A.Store_Name FROM
(SELECT DISTINCT Store_Name FROM location UNION ALL SELECT DISTINCT Store_Name FROM store_info) A
GR0UP BY A.Store_Name HAVING COUNT(*) > 1;

f10.png


f11.png


9.2 无交集值


显示第一个SQL语句的结果,且与第二个SQL语句没有交集的结果,且没有重复


SELECT DISTINCT Store_Name FROM location WHERE (Store_Name) NOT IN (SELECT Store_Name FROM store_info);

SELECT DISTINCT A.Store_Name FROM location A LEFT JOIN store_info B USING(Store_Name) WHERE B.Store_Name IS NULL;

SELECT A.Store_Name FROM
(SELECT DISTINCT Store_Name FROM location UNION ALL SELECT DISTINCT Store_Name FROM store_info) A
GR0UP BY A.Store_Name HAVING COUNT(*) = 1;

f12.png


10. case


是 SQL 用来做为 IF-THEN-ELSE 之类逻辑的关键字


语法:


SELECT CASE ("字段名")
WHEN "条件1" THEN "结果1"
WHEN "条件2" THEN "结果2"
...
[ELSE "结果N"]
END
FROM "表名";

"条件" 可以是一个数值或是公式。 ELSE 子句则并不是必须的。


SELECT Store_Name, CASE Store_Name 
WHEN 'Los Angeles' THEN Sales * 2
WHEN 'Boston' THEN 2000
ELSE Sales
END
"New Sales",Date
FROM store_info;

#"New Sales" 是用于 CASE 那个字段的字段名。

f13.png


11. 正则表达式


匹配模式描述实例
^匹配文本的结束字符‘^bd’ 匹配以 bd 开头的字符串
$匹配文本的结束字符‘qn$’ 匹配以 qn 结尾的字符串
.匹配任何单个字符‘s.t’ 匹配任何 s 和 t 之间有一个字符的字符串
*匹配零个或多个在它前面的字符‘fo*t’ 匹配 t 前面有任意个 o
+匹配前面的字符 1 次或多次‘hom+’ 匹配以 ho 开头,后面至少一个m 的字符串
字符串匹配包含指定的字符串‘clo’ 匹配含有 clo 的字符串
p1|p2匹配 p1 或 p2‘bg|fg’ 匹配 bg 或者 fg
[...]匹配字符集合中的任意一个字符‘[abc]’ 匹配 a 或者 b 或者 c
[^...]匹配不在括号中的任何字符‘[^ab]’ 匹配不包含 a 或者 b 的字符串
{n}匹配前面的字符串 n 次‘g{2}’ 匹配含有 2 个 g 的字符串
{n,m}匹配前面的字符串至少 n 次,至多m 次‘f{1,3}’ 匹配 f 最少 1 次,最多 3 次

语法:SELECT "字段" FROM "表名" WHERE "字段" REGEXP {模式};


SELECT * FROM store_info WHERE Store_Name REGEXP 'os';
SELECT * FROM store_info WHERE Store_Name REGEXP '^[A-G]';
SELECT * FROM store_info WHERE Store_Name REGEXP 'Ho|Bo';

f14.png


12. 存储过程


存储过程是一组为了完成特定功能的SQL语句集合。


存储过程在使用过程中是将常用或者复杂的工作预先使用SQL语句写好并用一个指定的名称存储起来,这个过程经编译和优化后存储在数据库服务器中。当需要使用该存储过程时,只需要调用它即可。存储过程在执行上比传统SQL速度更快、执行效率更高。


存储过程的优点


1、执行一次后,会将生成的二进制代码驻留缓冲区,提高执行效率


2、SQL语句加上控制语句的集合,灵活性高


3、在服务器端存储,客户端调用时,降低网络负载


4、可多次重复被调用,可随时修改,不影响客户端调用


5、可完成所有的数据库操作,也可控制数据库的信息访问权限


12.1 创建存储过程


DELIMITER $$							#将语句的结束符号从分号;临时改为两个$$(可以是自定义)
CREATE PROCEDURE Proc() #创建存储过程,过程名为Proc,不带参数
-> BEGIN #过程体以关键字 BEGIN 开始
-> select * from Store_Info; #过程体语句
-> END $$ #过程体以关键字 END 结束
DELIMITER ; #将语句的结束符号恢复为分号

实例


DELIMITER $$							#将语句的结束符号从分号;临时改为两个$$(可以自定义)
CREATE PROCEDURE Proc5() #创建存储过程,过程名为Proc5,不带参数
-> BEGIN #过程体以关键字 BEGIN 开始
-> create table user (id int (10), name char(10),score int (10));
-> insert int0 user values (1, 'cyw',70);
-> select * from cyw; #过程体语句
-> END $$ #过程体以关键字 END 结束
DELIMITER ; #将语句的结束符号恢复为分号

f15.png


12.2 调用存储过程


CALL Proc;

f16.png


12.3 查看存储过程


SHOW CREATE PROCEDURE [数据库.]存储过程名; #查看某个存储过程的具体信息


SHOW CREATE PROCEDURE Proc;

SHOW PROCEDURE STATUS [LIKE '%Proc%'] \G

f17.png


12.4 存储过程的参数


**IN 输入参数:**表示调用者向过程传入值(传入值可以是字面量或变量)


**OUT 输出参数:**表示过程向调用者传出值(可以返回多个值)(传出值只能是变量)


**INOUT 输入输出参数:**既表示调用者向过程传入值,又表示过程向调用者传出值(值只能是变量)


DELIMITER $$				
CREATE PROCEDURE Proc6(IN inname CHAR(16))
-> BEGIN
-> SELECT * FROM store_info WHERE Store_Name = inname;
-> END $$
DELIMITER ;

CALL Proc6('Boston');

f18.png


12.5 修改存储过程


ALTER PROCEDURE <过程名>[<特征>... ]
ALTER PROCEDURE GetRole MODIFIES SQL DATA SQL SECURITY INVOKER;
MODIFIES sQLDATA:表明子程序包含写数据的语句
SECURITY:安全等级
invoker:当定义为INVOKER时,只要执行者有执行权限,就可以成功执行。

12.6 删除存储过程


存储过程内容的修改方法是通过删除原有存储过程,之后再以相同的名称创建新的存储过程。如果要修改存储过程的名称,可以先删除原存储过程,再以不同的命名创建新的存储过程。


DROP PROCEDURE IF EXISTS Proc;		
#仅当存在时删除,不添加 IF EXISTS 时,如果指定的过程不存在,则产生一个错误

f19.png


13. 条件语句


if-then-else ···· end if


mysql> delimiter $$
mysql>
mysql> CREATE PROCEDURE proc8(IN pro int)
->
-> begin
->
-> declare var int;
-> set var=pro*2;
-> if var>=10 then
-> update t set id=id+1;
-> else
-> update t set id=id-1;
-> end if;
-> end $$

mysql> delimiter ;

f20.png


f21.png


14. 循环语句


while ···· end while


mysql> delimiter $$
mysql>
mysql> create procedure proc9()
-> begin
-> declare var int(10);
-> set var=0;
-> while var<6 do
-> insert int0 t values(var);
-> set var=var+1;
-> end while;
-> end $$

mysql> delimiter ;

f22.png


15. 视图表 create view


15.1 视图表概述


视图,可以被当作是虚拟表或存储查询。


视图跟表格的不同是,表格中有实际储存数据记录,而视图是建立在表格之上的一个架构,它本身并不实际储存数据记录。


临时表在用户退出或同数据库的连接断开后就自动消失了,而视图不会消失。
视图不含有数据,只存储它的定义,它的用途一般可以简化复杂的查询。


比如你要对几个表进行连接查询,而且还要进行统计排序等操作,写sql语句会很麻烦的,用视图将几个表联结起来,然后对这个视图进行查询操作,就和对一个表查询一样,很方便。


15.2 视图表能否修改?


首先我们需要知道,视图表保存的是select语句的定义,所以视图表可不可以修改需要视情况而定。



  • 如果 select 语句查询的字段是没有被处理过的源表字段,则可以通过视图表修改源表数据;

  • 如果select 语句查询的字段是被 gr0up by语句或 函数 处理过的字段,则不可以直接修改视图表的数据。


create view v_store_info as select store_name,sales from store_info;

update v_store_info set sales=1000 where store_name='Houston';

f23.png


create view v_sales as select store_name,sum(sales) from store_info gr0up by store_name having sum(sales)>1000;

update v_sales set store_name='xxxx' where store_name='Los Angeles';

f24.png


f25.png


15.3 基本语法


15.3.1 创建视图表


语法
create view "视图表名" as "select 语句";

create view v_region_sales as select a.region region,sum(b.sales) sales from location a 
inner join store_info b on a.store_name = b.store_name gr0up by region;

f26.png


15.4 查看视图表


语法
select * from 视图表名;

select * from v_region_sales;

f27.png


15.5 删除视图表


语法
drop view 视图表名;

drop view v_region_sales;

f28.png


15.6 通过视图表求无交集值


将两个表中某个字段的不重复值进行合并


只出现一次(count =1 ) ,即无交集


通过


create view 视图表名 as select distinct 字段 from 左表 union all select distinct 字段 from 右表;

select 字段 from 视图表名 gr0up by 字段 having count(字段)=1;

#先建立视图表
create viem v_union as select distinct store_name from location union all select distinct store_name from store_info;

f29.png


#再通过视图表求无交集
select store_name from v_union gr0up by store_name having count(*)=1;

f30.png


作者:lc111
来源:juejin.cn/post/7291952951047929868
收起阅读 »

哇塞,新来个架构师,把Nacos注册中心讲得炉火纯青,佩服佩服~~

大家好,我是三友~~ 今天就应某位小伙伴的要求,来讲一讲Nacos作为服务注册中心底层的实现原理 不知你是否跟我一样,在使用Nacos时有以下几点疑问: 临时实例和永久实例是什么?有什么区别? 服务实例是如何注册到服务端的? 服务实例和服务端之间是如何保活的...
继续阅读 »

大家好,我是三友~~


今天就应某位小伙伴的要求,来讲一讲Nacos作为服务注册中心底层的实现原理


不知你是否跟我一样,在使用Nacos时有以下几点疑问:



  • 临时实例和永久实例是什么?有什么区别?

  • 服务实例是如何注册到服务端的?

  • 服务实例和服务端之间是如何保活的?

  • 服务订阅是如何实现的?

  • 集群间数据是如何同步的?CP还是AP?

  • Nacos的数据模型是什么样的?

  • ...


本文就通过探讨上述问题来探秘Nacos服务注册中心核心的底层实现原理。


虽然Nacos最新版本已经到了2.x版本,但是为了照顾那些还在用1.x版本的同学,所以本文我会同时去讲1.x版本和2.x版本的实现


临时实例和永久实例


临时实例和永久实例在Nacos中是一个非常非常重要的概念


之所以说它重要,主要是因为我在读源码的时候发现,临时实例和永久实例在底层的许多实现机制是完全不同的


临时实例


临时实例在注册到注册中心之后仅仅只保存在服务端内部一个缓存中,不会持久化到磁盘


这个服务端内部的缓存在注册中心届一般被称为服务注册表


当服务实例出现异常或者下线之后,就会把这个服务实例从服务注册表中剔除


永久实例


永久服务实例不仅仅会存在服务注册表中,同时也会被持久化到磁盘文件中


当服务实例出现异常或者下线,Nacos只会将服务实例的健康状态设置为不健康,并不会对将其从服务注册表中剔除


所以这个服务实例的信息你还是可以从注册中心看到,只不过处于不健康状态


这是就是两者最最最基本的区别



当然除了上述最基本的区别之外,两者还有很多其它的区别,接下来本文还会提到



这里你可能会有一个疑问



为什么Nacos要将服务实例分为临时实例和永久实例?



主要还是因为应用场景不同


临时实例就比较适合于业务服务,服务下线之后可以不需要在注册中心中查看到


永久实例就比较适合需要运维的服务,这种服务几乎是永久存在的,比如说MySQL、Redis等等



MySQL、Redis等服务实例可以通过SDK手动注册



对于这些服务,我们需要一直看到服务实例的状态,即使出现异常,也需要能够查看时实的状态



所以从这可以看出Nacos跟你印象中的注册中心不太一样,他不仅仅可以注册平时业务中的实例,还可以注册像MySQL、Redis这个服务实例的信息到注册中心



在SpringCloud环境底下,一般其实都是业务服务,所以默认注册服务实例都是临时实例


当然如果你想改成永久实例,可以通过下面这个配置项来完成


spring
  cloud:
    nacos:
      discovery:
        #ephemeral单词是临时的意思,设置成false,就是永久实例了
        ephemeral: false

这里还有一个小细节


在1.x版本中,一个服务中可以既有临时实例也有永久实例,服务实例是永久还是临时是由服务实例本身决定的


但是2.x版本中,一个服务中的所有实例要么都是临时的要么都是永久的,是由服务决定的,而不是具体的服务实例


所以在2.x可以说是临时服务永久服务




为什么2.x把临时还是永久的属性由实例本身决定改成了由服务决定?



其实很简单,你想想,假设对一个MySQL服务来说,它的每个服务实例肯定都是永久的,不会出现一些是永久的,一些是临时的情况吧


所以临时还是永久的属性由服务本身决定其实就更加合理了


服务注册


作为一个服务注册中心,服务注册肯定是一个非常重要的功能


所谓的服务注册,就是通过注册中心提供的客户端SDK(或者是控制台)将服务本身的一些元信息,比如ip、端口等信息发送到注册中心服务端


服务端在接收到服务之后,会将服务的信息保存到前面提到的服务注册表中


1、1.x版本的实现


在Nacos在1.x版本的时候,服务注册是通过Http接口实现的



代码如下



整个逻辑比较简单,因为Nacos服务端本身就是用SpringBoot写的


但是在2.x版本的实现就比较复杂了


2、2.x版本的实现


2.1、通信协议的改变


2.x版本相比于1.x版本最主要的升级就是客户端和服务端通信协议的改变,由1.x版本的Http改成了2.x版本gRPC



gRPC是谷歌公司开发的一个高性能、开源和通用的RPC框架,Java版本的实现底层也是基于Netty来的



之所以改成了gRPC,主要是因为Http请求会频繁创建和销毁连接,白白浪费资源


所以在2.x版本之后,为了提升性能,就将通信协议改成了gRPC


根据官网显示,整体的效果还是很明显,相比于1.x版本,注册性能总体提升至少2倍



虽然通信方式改成了gRPC,但是2.x版本服务端依然保留了Http注册的接口,所以用1.x的Nacos SDK依然可以注册到2.x版本的服务端



2.2、具体的实现


Nacos客户端在启动的时候,会通过gRPC跟服务端建立长连接



这个连接会一直存在,之后客户端与服务端所有的通信都是基于这个长连接来的


当客户端发起注册的时候,就会通过这个长连接,将服务实例的信息发送给服务端


服务端拿到服务实例,跟1.x一样,也会存到服务注册表


除了注册之外,当注册的是临时实例时,2.x还会将服务实例信息存储到客户端中的一个缓存中,供Redo操作


所谓的Redo操作,其实就是一个补偿机制,本质是个定时任务,默认每3s执行一次


这个定时任务作用是,当客户端与服务端重新建立连接时(因为一些异常原因导致连接断开)


那么之前注册的服务实例肯定还要继续注册服务端(断开连接服务实例就会被剔除服务注册表)


所以这个Redo操作一个很重要的作用就是重连之后的重新注册的作用



除了注册之外,比如服务订阅之类的操作也需要Redo操作,当连接重新建立,之前客户端的操作都需要Redo一下



小总结


1.x版本是通过Http协议来进行服务注册的


2.x由于客户端与服务端的通信改成了gRPC长连接,所以改成通过gRPC长连接来注册


2.x比1.x多个Redo操作,当注册的服务实例是临时实例是,出现网络异常,连接重新建立之后,客户端需要将服务注册、服务订阅之类的操作进行重做


这里你可能会有个疑问



既然2.x有Redo机制保证客户端与服务端通信正常之后重新注册,那么1.x有类似的这种Redo机制么?



当然也会有,接下往下看。


心跳机制


心跳机制,也可以被称为保活机制,它的作用就是服务实例告诉注册中心我这个服务实例还活着



在正常情况下,服务关闭了,那么服务会主动向Nacos服务端发送一个服务下线的请求


Nacos服务端在接收到请求之后,会将这个服务实例从服务注册表中剔除


但是对于异常情况下,比如出现网络问题,可能导致这个注册的服务实例无法提供服务,处于不可用状态,也就是不健康


而此时在没有任何机制的情况下,服务端是无法知道这个服务处于不可用状态


所以为了避免这种情况,一些注册中心,就比如Nacos、Eureka,就会用心跳机制来判断这个服务实例是否能正常


在Nacos中,心跳机制仅仅是针对临时实例来说的,临时实例需要靠心跳机制来保活


心跳机制在1.x和2.x版本的实现也是不一样的


1.x心跳实现


在1.x中,心跳机制实现是通过客户端和服务端各存在的一个定时任务来完成的


在服务注册时,发现是临时实例,客户端会开启一个5s执行一次的定时任务



这个定时任务会构建一个Http请求,携带这个服务实例的信息,然后发送到服务端



在Nacos服务端也会开启一个定时任务,默认也是5s执行一次,去检查这些服务实例最后一次心跳的时间,也就是客户端最后一次发送Http请求的时间



  • 当最后一次心跳时间超过15s,但没有超过30s,会把这服务实例标记成不健康

  • 当最后一次心跳超过30s,直接把服务从服务注册表中剔除



这就是1.x版本的心跳机制,本质就是两个定时任务


其实1.x的这个心跳还有一个作用,就是跟上一节说的gRPC时Redo操作的作用是一样的


服务在处理心跳的时候,发现心跳携带这个服务实例的信息在注册表中没有,此时就会添加到服务注册表


所以心跳也有Redo的类似效果


2.x心跳实现


在2.x版本之后,由于通信协议改成了gRPC,客户端与服务端保持长连接,所以2.x版本之后它是利用这个gRPC长连接本身的心跳来保活


一旦这个连接断开,服务端就会认为这个连接注册的服务实例不可用,之后就会将这个服务实例从服务注册表中提出剔除


除了连接本身的心跳之外,Nacos还有服务端的一个主动检测机制


Nacos服务端也会启动一个定时任务,默认每隔3s执行一次


这个任务会去检查超过20s没有发送请求数据的连接


一旦发现有连接已经超过20s没发送请求,那么就会向这个连接对应的客户端发送一个请求


如果请求不通或者响应失败,此时服务端也会认为与客户端的这个连接异常,从而将这个客户端注册的服务实例从服务注册表中剔除


所以对于2.x版本,主要是两种机制来进行保活:



  • 连接本身的心跳机制,断开就直接剔除服务实例

  • Nacos主动检查机制,服务端会对20s没有发送数据的连接进行检查,出现异常时也会主动断开连接,剔除服务实例


小总结


心跳机制仅仅针对临时实例而言


1.x心跳机制是通过客户端和服务端两个定时任务来完成的,客户端定时上报心跳信息,服务端定时检查心跳时间,超过15s标记不健康,超过30s直接剔除


1.x心跳机制还有类似2.x的Redo作用,服务端发现心跳的服务信息不存在会,会将服务信息添加到注册表,相当于重新注册了


2.x是基于gRPC长连接本身的心跳机制和服务端的定时检查机制来的,出现异常直接剔除


健康检查


前面说了,心跳机制仅仅是临时实例用来保护的机制


而对于永久实例来说,一般来说无法主动上报心跳


就比如说MySQL实例,肯定是不会主动上报心跳到Nacos的,所以这就导致无法通过心跳机制来保活


所以针对永久实例的情况,Nacos通过一种叫健康检查的机制去判断服务实例是否活着


健康检查跟心跳机制刚好相反,心跳机制是服务实例向服务端发送请求


而所谓的健康检查就是服务端主动向服务实例发送请求,去探测服务实例是否活着



健康检查机制在1.x和2.x的实现机制是一样的


Nacos服务端在会去创建一个健康检查任务,这个任务每次执行时间间隔会在2000~7000毫秒之间


当任务触发的时候,会根据设置的健康检查的方式执行不同的逻辑,目前主要有以下三种方式:



  • TCP

  • HTTP

  • MySQL


TCP的方式就是根据服务实例的ip和端口去判断是否能连接成功,如果连接成功,就认为健康,反之就任务不健康


HTTP的方式就是向服务实例的ip和端口发送一个Http请求,请求路径是需要设置的,如果能正常请求,说明实例健康,反之就不健康


MySQL的方式是一种特殊的检查方式,他可以执行下面这条Sql来判断数据库是不是主库



默认情况下,都是通过TCP的方式来探测服务实例是否还活着


服务发现


所谓的服务发现就是指当有服务实例注册成功之后,其它服务可以发现这些服务实例


Nacos提供了两种发现方式:



  • 主动查询

  • 服务订阅


主动查询就是指客户端主动向服务端查询需要关注的服务实例,也就是拉(pull)的模式


服务订阅就是指客户端向服务端发送一个订阅服务的请求,当被订阅的服务有信息变动就会主动将服务实例的信息推送给订阅的客户端,本质就是推(push)模式



在我们平时使用时,一般来说都是选择使用订阅的方式,这样一旦有服务实例数据的变动,客户端能够第一时间感知


并且Nacos在整合SpringCloud的时候,默认就是使用订阅的方式


对于这两种服务发现方式,1.x和2.x版本实现也是不一样


服务查询其实两者实现都很简单


1.x整体就是发送Http请求去查询服务实例,2.x只不过是将Http请求换成了gRPC的请求


服务端对于查询的处理过程都是一样的,从服务注册表中查出符合查询条件的服务实例进行返回


不过对于服务订阅,两者的机制就稍微复杂一点


在Nacos客户端,不论是1.x还是2.x都是通过SDK中的NamingService#subscribe方法来发起订阅的



当有服务实例数据变动的时,客户端就会回调EventListener,就可以拿到最新的服务实例数据了


虽然1.x还是2.x都是同样的方法,但是具体的实现逻辑是不一样的


1.x服务订阅实现


在1.x版本的时候,服务订阅的处理逻辑大致会有以下三步:


第一步,客户端在启动的时候,会去构建一个叫PushReceiver的类


这个类会去创建一个UDP Socket,端口是随机的



其实通过名字就可以知道这个类的作用,就是通过UDP的方式接收服务端推送的数据的


第二步,调用NamingService#subscribe来发起订阅时,会先去服务端查询需要订阅服务的所有实例信息


之后会将所有服务实例数据存到客户端的一个内部缓存中



并且在查询的时候,会将这个UDP Socket的端口作为一个参数传到服务端


服务端接收到这个UDP端口后,后续就通过这个端口给客户端推送服务实例数据


第三步,会为这次订阅开启一个不定时执行的任务



之所以不定时,是因为这个当执行异常的时候,下次执行的时间间隔就会变长,但是最多不超过60s,正常是10s,这个10s是查询服务实例是服务端返回的



这个任务会去从服务端查询订阅的服务实例信息,然后更新内部缓存


这里你可能会有个疑问



既然有了服务变动推送的功能,为什么还要定时去查询更新服务实例信息呢?



其实很简单,那就是因为UDP通信不稳定导致的


虽然有Push,但是由于UDP通信自身的不确定性,有可能会导致客户端接收变动信息失败


所以这里就加了一个定时任务,弥补这种可能性,属于一个兜底的方案。


这就是1.x版本的服务订阅的实现



2.x服务订阅的实现


讲完1.x的版本实现,接下来就讲一讲2.x版本的实现


由于2.x版本换成了gRPC长连接的方式,所以2.x版本服务数据变更推送已经完全抛弃了1.x的UDP做法


当有服务实例变动的时候,服务端直接通过这个长连接将服务信息发送给客户端


客户端拿到最新服务实例数据之后的处理方式就跟1.x是一样了


除了处理方式一样,2.x也继承了1.x的其他的东西


比如客户端依然会有服务实例的缓存


定时对比机制也保留了,只不过这个定时对比的机制默认是关闭状态


之所以默认关闭,主要还是因为长连接还是比较稳定的原因


当客户端出现异常,接收不到请求,那么服务端会直接跟客户端断开连接


当恢复正常,由于有Redo操作,所以还是能拿到最新的实例信息的


所以2.x版本的服务订阅功能的实现大致如下图所示



这里还有个细节需要注意


在1.x版本的时候,任何服务都是可以被订阅的


但是在2.x版本中,只支持订阅临时服务,对于永久服务,已经不支持订阅了


小总结


服务查询1.x是通过Http请求;2.x通过gRPC请求


服务订阅1.x是通过UDP来推送的;2.x就基于gRPC长连接来实现的


1.x和2.x客户端都有服务实例的缓存,也有定时对比机制,只不过1.x会自动开启;2.x提供了一个开个,可以手动选择是否开启,默认不开启


数据一致性


由于Nacos是支持集群模式的,所以一定会涉及到分布式系统中不可避免的数据一致性问题


1、服务实例的责任机制


再说数据一致性问题之前,先来讨论一下服务实例的责任机制


什么是服务实例的责任机制?


比如上面提到的服务注册、心跳管理、监控检查机制,当只有一个Nacos服务时,那么自然而言这个服务会去检查所有的服务实例的心跳时间,执行所有服务实例的健康检查任务



但是当出现Nacos服务出现集群时,为了平衡各Nacos服务的压力,Nacos会根据一定的规则让每个Nacos服务只管理一部分服务实例的



当然每个Nacos服务的注册表还是全部的服务实例数据




这个管理机制我给他起了一个名字,就叫做责任机制,因为我在1.x和2.x都提到了responsible这个单词


本质就是Nacos服务对哪些服务实例负有心跳监测,健康检查的责任。


2、CAP定理和BASE理论


谈到数据一致性问题,一定离不开两个著名分布式理论



  • CAP定理

  • BASE理论


CAP定理中,三个字母分别代表这些含义:



  • C,Consistency单词的缩写,代表一致性,指分布式系统中各个节点的数据保持强一致,也就是每个时刻都必须一样,不一样整个系统就不能对外提供服务

  • A,Availability单词的缩写,代表可用性,指整个分布式系统保持对外可用,即使从每个节点获取的数据可能都不一样,只要能获取到就行

  • P,Partition tolerance单词的缩写,代表分区容错性。


所谓的CAP定理,就是指在一个分布式系统中,CAP这三个指标,最多同时只能满足其中的两个,不可能三个都同时满足



为什么三者不能同时满足?


对于一个分布式系统,网络分区是一定需要满足的


而所谓分区指的是系统中的服务部署在不同的网络区域中



比如,同一套系统可能同时在北京和上海都有部署,那么他们就处于不同的网络分区,就可能出现无法互相访问的情况


当然,你也可以把所有的服务都放在一个网络分区,但是当网络出现故障时,整个系统都无法对外提供服务,那这还有什么意义呢?


所以分布式系统一定需要满足分区容错性,把系统部署在不同的区域网络中


此时只剩下了一致性和可用性,它们为什么不能同时满足?


其实答案很简单,就因为可能出现网络分区导致的通信失败。


比如说,现在出现了网络分区的问题,上图中的A网络区域和B网络区域无法相互访问


此时假设往上图中的A网络区域发送请求,将服务中的一个值 i 属性设置成 1



如果保证可用性,此时由于A和B网络不通,此时只有A中的服务修改成功,B无法修改成功,此时数据AB区域数据就不一致性,也就没有保证数据一致性


如果保证一致性,此时由于A和B网络不通,所以此时A也不能修改成功,必须修改失败,否则就会导致AB数据不一致


虽然A没修改成功,保证了数据一致性,AB还是之前相同的数据,但是此时整个系统已经没有写可用性了,无法成功写数据了。


所以从上面分析可以看出,在有分区容错性的前提下,可用性和一致性是无法同时保证的。


虽然无法同时一致性和可用性,但是能不能换种思路来思考一下这个问题


首先我们可以先保证系统的可用性,也就是先让系统能够写数据,将A区域服务中的i修改成1


之后当AB区域之间网络恢复之后,将A区域的i值复制给B区域,这样就能够保证AB区域间的数据最终是一致的了


这不就皆大欢喜了么


这种思路其实就是BASE理论的核心要点,优先保证可用性,数据最终达成一致性。


BASE理论主要是包括以下三点:



  • 基本可用(Basically Available):系统出现故障还是能够对外提供服务,不至于直接无法用了

  • 软状态(Soft State):允许各个节点的数据不一致

  • 最终一致性,(Eventually Consistent):虽然允许各个节点的数据不一致,但是在一定时间之后,各个节点的数据最终需要一致的


BASE理论其实就是妥协之后的产物。


3、Nacos的AP和CP


Nacos其实目前是同时支持AP和CP的


具体使用AP还是CP得取决于Nacos内部的具体功能,并不是有的文章说的可以通过一个配置自由切换。


就以服务注册举例来说,对于临时实例来说,Nacos会优先保证可用性,也就是AP


对于永久实例,Nacos会优先保证数据的一致性,也就是CP


接下来我们就来讲一讲Nacos的CP和AP的实现原理


3.1、Nacos的AP实现


对于AP来说,Nacos使用的是阿里自研的Distro协议


在这个协议中,每个服务端节点是一个平等的状态,每个服务端节点正常情况下数据是一样的,每个服务端节点都可以接收来自客户端的读写请求



当某个节点刚启动时,他会向集群中的某个节点发送请求,拉取所有的服务实例数据到自己的服务注册表中



这样其它客户端就可以从这个服务节点中获取到服务实例数据了


当某个服务端节点接收到注册临时服务实例的请求,不仅仅会将这个服务实例存到自身的服务注册表,同时也会向其它所有服务节点发送请求,将这个服务数据同步到其它所有节点



所以此时从任意一个节点都是可以获取到所有的服务实例数据的。


即使数据同步的过程发生异常,服务实例也成功注册到一个Nacos服务中,对外部而言,整个Nacos集群是可用的,也就达到了AP的效果


同时为了满足BASE理论,Nacos也有下面两种机制保证最终节点间数据最终是一致的:



  • 失败重试机制

  • 定时对比机制


失败重试机制是指当数据同步给其它节点失败时,会每隔3s重试一次,直到成功


定时对比机制就是指,每个Nacos服务节点会定时向所有的其它服务节点发送一些认证的请求


这个请求会告诉每个服务节点自己负责的服务实例的对应的版本号,这个版本号随着服务实例的变动就会变动


如果其它服务节点的数据的版本号跟自己的对不上,那就说明其它服务节点的数据不是最新的


此时这个Nacos服务节点就会将自己负责的服务实例数据发给不是最新数据的节点,这样就保证了每个节点的数据是一样的了。


3.2、Nacos的CP实现


Nacos的CP实现是基于Raft算法来实现的


在1.x版本早期,Nacos是自己手动实现Raft算法


在2.x版本,Nacos移除了手动实现Raft算法,转而拥抱基于蚂蚁开源的JRaft框架


在Raft算法,每个节点主要有三个状态



  • Leader,负责所有的读写请求,一个集群只有一个

  • Follower,从节点,主要是负责复制Leader的数据,保证数据的一致性

  • Candidate,候选节点,最终会变成Leader或者Follower


集群启动时都是节点Follower,经过一段时间会转换成Candidate状态,再经过一系列复杂的选择算法,选出一个Leader




这个选举算法比较复杂,完全值得另写一篇文章,这里就不细说了。不过立个flag,如果本篇文章点赞量超过28个,我连夜爆肝,再来一篇。



当有写请求时,如果请求的节点不是Leader节点时,会将请求转给Leader节点,由Leader节点处理写请求


比如,有个客户端连到的上图中的Nacos服务2节点,之后向Nacos服务2注册服务


Nacos服务2接收到请求之后,会判断自己是不是Leader节点,发现自己不是


此时Nacos服务2就会向Leader节点发送请求,Leader节点接收到请求之后,会处理服务注册的过程


为什么说Raft是保证CP的呢?


主要是因为Raft在处理写的时候有一个判断过程



  • 首先,Leader在处理写请求时,不会直接数据应用到自己的系统,而是先向所有的Follower发送请求,让他们先处理这个请求

  • 当超过半数的Follower成功处理了这个写请求之后,Leader才会写数据,并返回给客户端请求处理成功

  • 如果超过一定时间未收到超过半数处理成功Follower的信号,此时Leader认为这次写数据是失败的,就不会处理写请求,直接返回给客户端请求失败


所以,一旦发生故障,导致接收不到半数的Follower写成功的响应,整个集群就直接写失败,这就很符合CP的概念了。


不过这里还有一个小细节需要注意


Nacos在处理查询服务实例的请求直接时,并不会将请求转发给Leader节点处理,而是直接查当前Nacos服务实例的注册表


这其实就会引发一个问题


如果客户端查询的Follower节点没有及时处理Leader同步过来的写请求(过半响应的节点中不包括这个节点),此时在这个Follower其实是查不到最新的数据的,这就会导致数据的不一致


所以说,虽然Raft协议规定要求从Leader节点查最新的数据,但是Nacos至少在读服务实例数据时并没有遵守这个协议


当然对于其它的一些数据的读写请求有的还是遵守了这个协议。



JRaft对于读请求其实是做了很多优化的,其实从Follower节点通过一定的机制也是能够保证读到最新的数据



数据模型


在Nacos中,一个服务的确定是由三部分信息确定



  • 命名空间(Namespace):多租户隔离用的,默认是public

  • 分组(Gr0up):这个其实可以用来做环境隔离,服务注册时可以指定服务的分组,比如是测试环境或者是开发环境,默认是DEFAULT_GR0UP

  • 服务名(ServiceName):这个就不用多说了


通过上面三者就可以确定同一个服务了


在服务注册和订阅的时候,必须要指定上述三部分信息,如果不指定,Nacos就会提供默认的信息


不过,在Nacos中,在服务里面其实还是有一个集群的概念



在服务注册的时候,可以指定这个服务实例在哪个集体的集群中,默认是在DEFAULT集群下


在SpringCloud环境底下可以通过如下配置去设置


spring
  cloud:
    nacos:
      discovery:
        cluster-name: sanyoujavaCluster

在服务订阅的时候,可以指定订阅哪些集群下的服务实例


当然,也可以不指定,如果不指定话,默认就是订阅这个服务下的所有集群的服务实例


我们日常使用中可以将部署在相同区域的服务划分为同一个集群,比如杭州属于一个集群,上海属于一个集群


这样服务调用的时候,就可以优先使用同一个地区的服务了,比跨区域调用速度更快。


总结


到这,终终终于总算是讲完了Nacos作为注册中心核心的实现原理




作者:zzyang90
来源:juejin.cn/post/7347325319198048283
收起阅读 »

你居然还去服务器上捞日志,搭个日志收集系统难道不香么!

1 ELK日志系统 经典的ELK架构或现被称为Elastic Stack。Elastic Stack架构为Elasticsearch + Logstash + Kibana + Beats的组合: Beats负责日志的采集 Logstash负责做日志的聚合和...
继续阅读 »

1 ELK日志系统


经典的ELK架构或现被称为Elastic Stack。Elastic Stack架构为Elasticsearch + Logstash + Kibana + Beats的组合:



  • Beats负责日志的采集

  • Logstash负责做日志的聚合和处理

  • ES作为日志的存储和搜索系统

  • Kibana作为可视化前端展示


整体架构图:


img


2 EFK日志系统


容器化场景中,尤其k8s环境,用户经常使用EFK架构。F代表Fluent Bit,一个开源多平台的日志处理器和转发器。Fluent Bit可以:



  • 让用户从不同来源收集数据/日志

  • 统一并发到多个目的地

  • 完全兼容Docker和k8s环境



3 PLG日志系统


3.1 Prometheus+k8s日志系统




PLG


Grafana Labs提供的另一个日志解决方案PLG逐渐流行。PLG架构即Promtail + Loki + Grafana的组合:
img


Grafana,开源的可视化和分析软件,允许用户查询、可视化、警告和探索监控指标。Grafana主要提供时间序列数据的仪表板解决方案,支持超过数十种数据源。


Grafana Loki是一组可以组成一个功能齐全的日志堆栈组件,与其它日志系统不同,Loki只建立日志标签的索引而不索引原始日志消息,而是为日志数据设置一组标签,即Loki运营成本更低,效率还提高几个数量级。



Loki设计理念


Prometheus启发,可实现可水平扩展、高可用的多租户日志系统。Loki整体架构由不同组件协同完成日志收集、索引、存储等。


各组件如下,Loki’s Architecture深入了解。Loki就是like Prometheus, but for logs。
img


Promtail是一个日志收集的代理,会将本地日志内容发到一个Loki实例,它通常部署到需要监视应用程序的每台机器/容器上。Promtail主要是用来发现目标、将标签附加到日志流以及将日志推送到Loki。截止到目前,Promtail可以跟踪两个来源的日志:本地日志文件和systemd日志(仅支持AMD64架构)。


4 PLG V.S ELK


4.1 ES V.S Loki


ELK/EFK架构确实强,经多年实际环境验证。存储在ES中的日志通常以非结构化JSON对象形式存储在磁盘,且ES为每个对象都建索引,以便全文搜索,然后用户可特定查询语言搜索这些日志数据。


而Loki数据存储解耦:



  • 既可在磁盘存储

  • 也可用如Amazon S3云存储系统


Loki日志带有一组标签名和值,只有标签对被索引,这种权衡使它比完整索引操作成本更低,但针对基于内容的查询,需通过LogQL再单独查询。


4.2 Fluentd V.S Promtail


相比Fluentd,Promtail专为Loki定制,它可为运行在同一节点的k8s Pods做服务发现,从指定文件夹读取日志。Loki类似Prometheus的标签方式。因此,当与Prometheus部署在同一环境,因为相同的服务发现机制,来自Promtail的日志通常具有与应用程序指标相同的标签,统一标签管理。


4.3 Grafana V.S Kibana


Kibana提供许多可视化工具来进行数据分析,高级功能如异常检测等机器学习功能。Grafana针对Prometheus和Loki等时间序列数据打造,可在同一仪表板上查看日志指标。


参考



作者:JavaEdge在掘金
来源:juejin.cn/post/7295623585364082739
收起阅读 »

超级火爆的前端视频方案 FFmpeg ,带你体验一下~

前言 大家好,我是林三心,用最通俗易懂的话讲最难的知识点是我的座右铭,基础是进阶的前提是我的初心~ ffmpeg FFmpeg 是一个开源的、跨平台的多媒体框架,它可以用来录制、转换和流式传输音频和视频。它包括了一系列的库和工具,用于处理多媒体内容,比如 l...
继续阅读 »

前言


大家好,我是林三心,用最通俗易懂的话讲最难的知识点是我的座右铭,基础是进阶的前提是我的初心~



ffmpeg


FFmpeg 是一个开源的、跨平台的多媒体框架,它可以用来录制、转换和流式传输音频和视频。它包括了一系列的库和工具,用于处理多媒体内容,比如 libavcodec(一个编解码库),libavformat(一个音视频容器格式库),libavutil(一个实用库),以及 ffmpeg 命令行工具本身。


FFmpeg 被广泛用于各种应用中,包括视频转换、视频编辑、视频压缩、直播流处理等。它支持多种音视频编解码器和容器格式,因此能够处理几乎所有类型的音视频文件。由于其功能强大和灵活性,FFmpeg 成为了许多视频相关软件和服务的底层技术基础。


很多网页都是用 ffmpeg 来进行视频切片,比如一个视频很大,如果通过一个连接去请求整个视频的话,那势必会导致加载时间过长,严重阻碍了用户观感


所以很多视频网站都会通过视频切片的方式来优化用户观感,就是一部分一部分地去加载出来,这样有利于用户的体验



安装 ffmpeg


安装包下载


首先到 ffmpeg 的安装网页:http://www.gyan.dev/ffmpeg/buil…



下载解压后将文件夹改名为 ffmpeg



环境变量配置


环境变量配置是为了能在电脑上使用 ffmpeg 命令行




体验 ffmpeg


先准备一个视频,比如我准备了一个视频,总共 300 多 M



视频切片


并在当前的目录下输入以下的命令


 ffmpeg -i jhys.mkv 
-c:v libx264
-c:a aac
-hls_time 60
-hls_segment_type mpegts
-hls_list_size 0
-f hls
-max_muxing_queue_size 1024
output.m3u8

接着 ffmpeg 会帮你将这个视频进行分片



直到切片步骤执行完毕,我们可以看到视频已经别切成几个片了



在这个命令中:



  • -i input_video.mp4 指定了输入视频文件。

  • -c:v libx264 -c:a aac 指定了视频和音频的编解码器。

  • -hls_time 10 指定了每个 M3U8 片段的时长,单位为秒。在这里,每个片段的时长设置为 10 秒。

  • -hls_segment_type mpegts 指定了 M3U8 片段的类型为 MPEG-TS。

  • -hls_list_size 0 设置 M3U8 文件中包含的最大片段数。这里设置为 0 表示没有限制。

  • -f hls 指定了输出格式为 HLS。

  • -max_muxing_queue_size 1024 设置了最大复用队列大小,以确保输出不会超过指定大小。

  • 最后输出的文件为 output.m3u8


视频播放


创建一个简单的前端项目




可以看到浏览器会加载所有的视频切片





作者:Sunshine_Lin
来源:juejin.cn/post/7361998447908864011
收起阅读 »

写给Java开发的16个小建议

前言 开发过程中其实有很多小细节要去注意,只有不断去抠细节,写出精益求精的代码,从量变中收获质变。 技术的进步并非一蹴而就,而是通过无数次的量变,才能引发质的飞跃。我们始终坚信,只有对每一个细节保持敏锐的触觉,才能绽放出完美的技术之花。 从一行行代码中,我们品...
继续阅读 »

前言


开发过程中其实有很多小细节要去注意,只有不断去抠细节,写出精益求精的代码,从量变中收获质变。


技术的进步并非一蹴而就,而是通过无数次的量变,才能引发质的飞跃。我们始终坚信,只有对每一个细节保持敏锐的触觉,才能绽放出完美的技术之花。


从一行行代码中,我们品味到了追求卓越的滋味。每一个小小的优化,每一个微妙的改进,都是我们追求技艺的印记。我们知道,只有更多的关注细节,才能真正理解技术的本质,洞察其中的玄机。正是在对细节的把握中,我们得以成就更好的技术人生。


耐心看完,你一定会有所收获。


image.png


补充


20230928


针对评论区指出的第14条示例的问题,现已修正。


原来的示例贴在这里,接受大家的批评:


image.png


1. 代码风格一致性


代码风格一致性可以提高代码的可读性和可维护性。例如,使用Java编程中普遍遵循的命名约定(驼峰命名法),使代码更易于理解。


// 不好的代码风格
int g = 10;
String S = "Hello";

// 好的代码风格
int count = 10;
String greeting = "Hello";

2. 使用合适的数据结构和集合


选择适当的数据结构和集合类可以改进代码的性能和可读性。例如,使用HashSet来存储唯一的元素。


// 不好的例子 - 使用ArrayList存储唯一元素
List<Integer> list = new ArrayList<>();
list.add(1);
list.add(2);
list.add(1); // 重复元素

// 好的例子 - 使用HashSet存储唯一元素
Set<Integer> set = new HashSet<>();
set.add(1);
set.add(2);
set.add(1); // 自动忽略重复元素

3. 避免使用魔法数值


使用常量或枚举来代替魔法数值可以提高代码的可维护性和易读性。


// 不好的例子 - 魔法数值硬编码
if (status == 1) {
// 执行某些操作
}

// 好的例子 - 使用常量代替魔法数值
final int STATUS_ACTIVE = 1;
if (status == STATUS_ACTIVE) {
// 执行某些操作
}

4. 异常处理


正确处理异常有助于代码的健壮性和容错性,避免不必要的try-catch块可以提高代码性能。


// 不好的例子 - 捕获所有异常,没有具体处理
try {
// 一些可能抛出异常的操作
} catch (Exception e) {
// 空的异常处理块
}

// 好的例子 - 捕获并处理特定异常,或向上抛出
try {
// 一些可能抛出异常的操作
} catch (FileNotFoundException e) {
// 处理文件未找到异常
} catch (IOException e) {
// 处理其他IO异常
}

5. 及时关闭资源


使用完资源后,及时关闭它们可以避免资源泄漏,特别是对于文件流、数据库连接等资源。


更好的处理方式参见第16条,搭配try-with-resources食用最佳


// 不好的例子 - 未及时关闭数据库连接
Connection conn = null;
Statement stmt = null;
try {
conn = DriverManager.getConnection(DB_URL, DB_USER, DB_PASSWORD);
stmt = conn.createStatement();
// 执行数据库查询操作
} catch (SQLException e) {
e.printStackTrace();
} finally {
// 数据库连接未关闭
}

// 好的例子 - 使用try-with-resources确保资源及时关闭,避免了数据库连接资源泄漏的问题
try (Connection conn = DriverManager.getConnection(DB_URL, DB_USER, DB_PASSWORD);
Statement stmt = conn.createStatement()) {
// 执行数据库查询操作
} catch (SQLException e) {
e.printStackTrace();
}

6. 避免过度使用全局变量


过度使用全局变量容易引发意外的副作用和不可预测的结果,建议尽量避免使用全局变量。


// 不好的例子 - 过度使用全局变量
public class MyClass {
private int count;

// 省略其他代码
}

// 好的例子 - 使用局部变量或实例变量
public class MyClass {
public void someMethod() {
int count = 0;
// 省略其他代码
}
}

7. 避免不必要的对象创建


避免在循环或频繁调用的方法中创建不必要的对象,可以使用对象池、StringBuilder等技术。


// 不好的例子 - 频繁调用方法创建不必要的对象
public String formatData(int year, int month, int day) {
String formattedDate = String.format("%d-d-d", year, month, day); // 每次调用方法都会创建新的String对象
return formattedDate;
}

// 好的例子 - 避免频繁调用方法创建不必要的对象
private static final String DATE_FORMAT = "%d-d-d";
public String formatData(int year, int month, int day) {
return String.format(DATE_FORMAT, year, month, day); // 重复使用同一个String对象
}

8. 避免使用不必要的装箱和拆箱


避免频繁地在基本类型和其对应的包装类型之间进行转换,可以提高代码的性能和效率。


// 不好的例子
Integer num = 10; // 不好的例子,自动装箱
int result = num + 5; // 不好的例子,自动拆箱

// 好的例子 - 避免装箱和拆箱
int num = 10; // 好的例子,使用基本类型
int result = num + 5; // 好的例子,避免装箱和拆箱

9. 使用foreach循环遍历集合


使用foreach循环可以简化集合的遍历,并提高代码的可读性。


// 不好的例子 - 可读性不强,并且增加了方法调用的开销
List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
for (int i = 0; i < names.size(); i++) {
System.out.println(names.get(i)); // 不好的例子
}

// 好的例子 - 更加简洁,可读性更好,性能上也更优
List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
for (String name : names) {
System.out.println(name); // 好的例子
}

10. 使用StringBuilder或StringBuffer拼接大量字符串


在循环中拼接大量字符串时,使用StringBuilder或StringBuffer可以避免产生大量临时对象,提高性能。


// 不好的例子 - 每次循环都产生新的字符串对象
String result = "";
for (int i = 0; i < 1000; i++) {
result += "Number " + i + ", ";
}

// 好的例子 - StringBuilder不会产生大量临时对象
StringBuilder result = new StringBuilder();
for (int i = 0; i < 1000; i++) {
result.append("Number ").append(i).append(", ");
}

11. 使用equals方法比较对象的内容


老生常谈的问题,在比较对象的内容时,使用equals方法而不是==操作符,确保正确比较对象的内容。


// 不好的例子
String name1 = "Alice";
String name2 = new String("Alice");
if (name1 == name2) {
// 不好的例子,使用==比较对象的引用,而非内容
}

// 好的例子
String name1 = "Alice";
String name2 = new String("Alice");
if (name1.equals(name2)) {
// 好的例子,使用equals比较对象的内容
}

12. 避免使用多个连续的空格或制表符


多个连续的空格或制表符会使代码看起来杂乱不堪,建议使用合适的缩进和空格,保持代码的清晰可读。


// 不好的例子
int a = 10; // 不好的例子,多个连续的空格和制表符
String name = "John"; // 不好的例子,多个连续的空格和制表符

// 好的例子
int a = 10; // 好的例子,适当的缩进和空格
String name = "John"; // 好的例子,适当的缩进和空格

13. 使用日志框架记录日志


在代码中使用日志框架(如Log4j、SLF4J)来记录日志,而不是直接使用System.out.println(),可以更灵活地管理日志输出和级别。


// 不好的例子:
System.out.println("Error occurred"); // 不好的例子,直接输出日志到控制台

// 好的例子:
logger.error("Error occurred"); // 好的例子,使用日志框架记录日志

14. 避免在循环中创建对象


在循环中频繁地创建对象会导致大量的内存分配和垃圾回收,影响性能。尽量在循环外部创建对象,或使用对象池来复用对象,从而减少对象的创建和销毁开销。


// 不好的例子 - 在循环过程中频繁地创建和销毁对象,增加了垃圾回收的负担
for (int i = 0; i < 1000; i++) {
// 在每次循环迭代中创建新的对象,增加内存分配和垃圾回收的开销
Person person = new Person("John", 30);
System.out.println("Name: " + person.getName() + ", Age: " + person.getAge());
}


// 好的例子 - 在循环外部创建对象,减少内存分配和垃圾回收的开销
Person person = new Person("John", 30);
for (int i = 0; i < 1000; i++) {
System.out.println("Name: " + person.getName() + ", Age: " + person.getAge());

// 可以根据需要修改 person 对象的属性
person.setName("Alice");
person.setAge(25);
}


15. 使用枚举替代常量


这条其实和第3条一个道理,使用枚举可以更清晰地表示一组相关的常量,并且能够提供更多的类型安全性和功能性。


// 不好的例子 - 使用常量表示颜色
public static final int RED = 1;
public static final int GREEN = 2;
public static final int BLUE = 3;

// 好的例子 - 使用枚举表示颜色
public enum Color {
RED, GREEN, BLUE
}

16. 使用try-with-resources语句


在处理需要关闭的资源(如文件、数据库连接等)时,使用try-with-resources语句可以自动关闭资源,避免资源泄漏。


// 不好的例子 - 没有使用try-with-resources
FileReader reader = null;
try {
reader = new FileReader("file.txt");
// 执行一些操作
} catch (IOException e) {
// 处理异常
} finally {
if (reader != null) {
try {
reader.close();
} catch (IOException e) {
// 处理关闭异常
}
}
}

// 好的例子 - 使用try-with-resources自动关闭资源
try (FileReader reader = new FileReader("file.txt")) {
// 执行一些操作
} catch (IOException e) {
// 处理异常
}

总结


这16个小建议,希望对你有所帮助。


技术的路上充满挑战,但要相信,把细节搞好,技术会越来越牛。小小的优化,微小的改进,都能让我们的代码变得更好。


时间飞逝,我们要不断学习,不断努力。每个技术小突破都是我们不懈努力的成果。要用心倾听,用心琢磨,这样才能在技术的道路上越走越远。


从每一次写代码的过程中,我们收获更多。我们要踏实做好每个细节。在代码的世界里,细节是我们的罗盘。


坚持初心,不忘初心!


006APoFYly8hgexvlq6lug306c06c7qd (1).gif


作者:一只叫煤球的猫
来源:juejin.cn/post/7261835383201726523
收起阅读 »

我写了一个程序,让端口占用无路可逃

作为一个 Java 工程师,经常会遇到这么个场景:IDEA 里的程序正在运行,此时直接关闭了 IDEA 而没有先关闭正在运行的服务。 在绝大多数情境下,此方式都无伤大雅,但总有一些抽风的场景运行的程序并没有被正常的关闭,也就导致了重启项目时将会提示 xxxx ...
继续阅读 »

作为一个 Java 工程师,经常会遇到这么个场景:IDEA 里的程序正在运行,此时直接关闭了 IDEA 而没有先关闭正在运行的服务。


在绝大多数情境下,此方式都无伤大雅,但总有一些抽风的场景运行的程序并没有被正常的关闭,也就导致了重启项目时将会提示 xxxx 端口已被占用。


Windows 下此方式解决也十分简单,在命令行输入下述两个命令即可根据端口关闭对应的进程。


# 端口占用进程
netstat -ano | findstr <port>

# 进程关闭
taskkill -PID <pid> -F

虽然说也不麻烦但却很繁杂,试想一下当遇到这种情况下,我需要先翻笔记找出这两个命令,在打开命令行窗口执行,一套连招下来相当影响编程情绪。


因此,我决定写一个程序能够便捷的实现这个操作,最好是带 GUI 页面。


说干就干,整个程序功能其实并不复杂,对于页面的展示要求也不高,我就确定下来了直接通过 Java Swing 实现 GUI 部分。而对于命令执行部分,在 Java 中提供了 Process 类可用于执行命令。


先让我们看下 Process 的作用方式,以最简单的 ping baidu.com 测试为例。


public void demo() {  
ProcessBuilder processBuilder = new ProcessBuilder();
List<String> command = new ArrayList<>();
command.add("ping");
command.add("www.baidu.com");
processBuilder.command(command);

try {
Process process = processBuilder.start();
try (
InputStreamReader ir = new InputStreamReader(process.getInputStream(), "GBK");
BufferedReader br = new BufferedReader(ir)
) {
String line;
while ((line = br.readLine()) != null) {
System.out.println(line);
}
}
} catch (IOException e) {
e.printStackTrace();
}
}

运行上述的代码,在控制台可以得到下图结果:


image.png


在上述程序中,ProcessBuilder 用于构建命令,processBuilder.start() 则相当于你敲下回车执行,而执行的结果的则以 IO 流的形式返回,这里通过 readLine() 将返回的结果逐行的形式进行读取。


了解的大概原理之后,剩下的事情就简单了,只需要将之前提到的两个命令以同样的方式通过 Process 执行就可以,再通过 Java Swing 进行一个页面展示就可以。


具体的实现并不复杂,这里就不详细展开介绍,完整的项目代码已经上传到 GitHub,感兴趣的小伙伴可自行前往查看,仓库地址:windows-process


下面主要介绍程序的使用与效果,开始前可以去上述提到的仓库 relase 里将打包完成的 exe 程序下载,下载地址


下载后启动 window process.exe 程序,在启动之后会先弹出下图的提示,这是因为使用了 exe4j 打包程序,选择确认即可。


image.png


选择确认之后即会展示下图页面,列表中展示的数据即 netstat -ano 命令返回的结果,


image.png


在选中列表任意一条进程记录后,会将该进程对应的端口号和 PID 填充至上面的输入框中。


20240630_104641.gif


同时,可在 Port 输入框中输入对应的端口号实现快速查询,若需要停止某个进程,则将点击对应端口进程记录其 PID 会自动填入输入框中,然后单击 Kill 按钮,成功停止进程后将会进行相应的提示。


最后的最后,再臭不要脸的给自己要个赞,觉得不错的可以去 GitHub 仓库上下载下来看看,如果能点个 star 更是万分感谢,这里再贴一下仓库地址:windows-process


image.png


作者:烽火戏诸诸诸侯
来源:juejin.cn/post/7385499574881026089
收起阅读 »

null 不好,我真的推荐你使用 Optional

"Null 很糟糕." - Doug Lea。 Doug Lea 是一位美国的计算机科学家,他是 Java 平台的并发和集合框架的主要设计者之一。他在 2014 年的一篇文章中说过:“Null sucks.”1,意思是 null 很糟糕。他认为 null 是...
继续阅读 »

"Null 很糟糕." - Doug Lea。



Doug Lea 是一位美国的计算机科学家,他是 Java 平台的并发和集合框架的主要设计者之一。他在 2014 年的一篇文章中说过:“Null sucks.”1,意思是 null 很糟糕。他认为 null 是一种不明确的表示,它既可以表示一个值不存在,也可以表示一个值未知,也可以表示一个值无效。这样就会导致很多逻辑错误和空指针异常,给程序员带来很多麻烦。他建议使用 Optional 类来封装可能为空的值,从而提高代码的可读性和健壮性。



"发明 null 引用是我的十亿美元错误。" - Sir C. A. R. Hoare。



Sir C. A. R. Hoare 是一位英国的计算机科学家,他是快速排序算法、Hoare 逻辑和通信顺序进程等重要概念的发明者。他在 2009 年的一个软件会议上道歉说:“I call it my billion-dollar mistake. It was the invention of the null reference in 1965.”,意思是他把 null 引用称为他的十亿美元错误。他说他在 1965 年设计 ALGOL W 语言时,引入了 null 引用的概念,用来表示一个对象变量没有指向任何对象。他当时认为这是一个很简单和自然的想法,但后来发现这是一个非常糟糕的设计,因为它导致了无数的错误、漏洞和系统崩溃。他说他应该使用一个特殊的对象来表示空值,而不是使用 null。


自作者从事 Java 编程一来,就与 null 引用相伴,与 NullPointerException 相遇已经是家常便饭了。


null 引用是一种表示一个对象变量没有指向任何对象的方式,它是 Java 语言中的一个特殊值,也是导致空指针异常(NullPointerException)的主要原因。虽然 null 引用可以用来表示一个值不存在或未知,也可以用来节省内存空间。但是它也不符合面向对象的思想,因为它不是一个对象,不能调用任何方法或属性。


可以看到,null 引用并不好,我们应该尽量避免使用 null,那么我们该怎么避免 null 引用引起的逻辑错误和运行时异常嘞?


其实这个问题 Java 的设计者也知道,于是他们在 Java8 之后设计引入了 Optional 类解决这个问题,本文将给大家详细介绍下 Optional 类的设计目的以及使用方法。



Optional 类是什么?


Optional 类是 java 8 中引入的一个新的类,它的作用是封装一个可能为空的值,从而避免空指针异常(NullPointerException)。Optional 类可以看作是一个容器,它可以包含一个非空的值,也可以为空。Optional 类提供了一些方法,让我们可以更方便地处理可能为空的值,而不需要显式地进行空值检查或者使用 null。



推荐作者开源的 H5 商城项目waynboot-mall,这是一套全部开源的微商城项目,包含三个项目:运营后台、H5 商城前台和服务端接口。实现了商城所需的首页展示、商品分类、商品详情、商品 sku、分词搜索、购物车、结算下单、支付宝/微信支付、收单评论以及完善的后台管理等一系列功能。 技术上基于最新得 Springboot3.0、jdk17,整合了 MySql、Redis、RabbitMQ、ElasticSearch 等常用中间件。分模块设计、简洁易维护,欢迎大家点个 star、关注我。


github 地址:github.com/wayn111/way…



Optional 类的设计


Optional 类的设计是基于函数式编程的思想,它借鉴了 Scala 和 Haskell 等语言中的 Option 类型。Optional 类实现了 java.util.function 包中的 Supplier、Consumer、Predicate、Function 等接口,这使得它可以和 lambda 表达式或者方法引用一起使用,形成更简洁和优雅的代码。


Optional 类被 final 修饰,因此它是一个不可变的类,它有两个静态方法用于创建 Optional 对象。


Optional.empty()


Optional.empty 表示一个空的 Optional 对象,它不包含任何值。


// 创建一个空的 Optional 对象
Optional empty = Optional.empty();

Optional.of(T value)


Optional.of 表示一个非空的 Optional 对象,它包含一个非空的值。


// 创建一个非空的 Optional 对象
Optional hello = Optional.of("Hello");

Optional.ofNullable(T value)


注意,如果我们使用 Optional.of 方法传入一个 null 值,会抛出 NullPointerException。如果我们不确定一个值是否为空,可以使用 Optional.ofNullable 方法,它会根据值是否为空,返回一个相应的 Optional 对象。例如:


// 创建一个可能为空的 Optional 对象
Optional name = Optional.ofNullable("Hello");

Optional 对象的使用方法


Optional 对象提供了一些方法,让我们可以更方便地处理可能为空的值,而不需要显式地进行空值检查或者使用 null。以下是一些常用的方法。


isPresent()


判断 Optional 对象是否包含一个非空的值,返回一个布尔值。


get()


如果 Optional 对象包含一个非空的值,返回该值,否则抛出 NoSuchElementException 异常。



// 使用 isPresent 和 get 方法
Optional name = Optional.ofNullable("tom");
if (name.isPresent()) {
System.out.println("Hello, " + name.get());
} else {
System.out.println("Name is not available");
}
// 输出:Hello tom

ifPresent(Consumer action)


如果 Optional 对象包含一个非空的值,执行给定的消费者操作,否则什么也不做。


// 使用 ifPresent(Consumer action)
Optional name = Optional.ofNullable("tom");
name.ifPresent(s -> {
System.out.println("Hello, " + name.get());
});
// 输出:Hello tom

orElse(T other)


如果 Optional 对象包含一个非空的值,返回该值,否则返回给定的默认值。


// 使用 orElse(T other)
Optional name = Optional.ofNullable(null);
String greeting = "Hello, " + name.orElse("Guest");
System.out.println(greeting);
// 输出:Hello Guest

orElseGet(Supplier supplier)


如果 Optional 对象包含一个非空的值,返回该值,否则返回由给定的供应者操作生成的值。


// 使用 orElseGet(Supplier supplier)
Optional name = Optional.ofNullable(null);
String greeting = "Hello, " + name.orElseGet(() -> "Guset");
System.out.println(greeting);
// 输出:Hello Guset

orElseThrow(Supplier exceptionSupplier)


如果 Optional 对象包含一个非空的值,返回该值,否则抛出由给定的异常供应者操作生成的异常。


// 使用 orElseThrow(Supplier exceptionSupplier)
Optional name = Optional.ofNullable(null);
String greeting = "Hello, " + name.orElseThrow(() -> new NullPointerException("null"));
// 抛出 java.lang.NullPointerException: null 异常

map(Function mapper)


如果 Optional 对象包含一个非空的值,对该值应用给定的映射函数,返回一个包含映射结果的 Optional 对象,否则返回一个空的 Optional 对象。


// 使用 map(Function mapper)
Optional name = Optional.ofNullable("tom");
String greeting = "Hello, " + name.map(s -> s.toUpperCase()).get();
System.out.println(greeting);
// 输出:Hello TOM

flatMap(Function> mapper)


如果 Optional 对象包含一个非空的值,对该值进行 mapper 参数操作,返回新的 Optional 对象,否则返回一个空的 Optional 对象。


// 使用 flatMap(Function> mapper)
Optional name = Optional.ofNullable("tom");
String greeting = name.flatMap(s -> Optional.of("Hello " + s)).get();
System.out.println(greeting);
// 输出:Hello tom

filter(Predicate predicate)


如果 Optional 对象包含一个非空的值,并且该值满足给定的谓词条件,返回包含该值的 Optional 对象,否则返回一个空的 Optional 对象。


// filter(Predicate predicate)
Optional name = Optional.ofNullable("tom");
String greeting = "Hello " + name.filter(s -> !s.isEmpty()).get();
System.out.println(greeting);
// 输出:Hello tom

Java 9 中 Optional 改进


Java 9 中 Optional 类有了一些改进,主要是增加了三个新的方法,分别是 stream()、ifPresentOrElse() 和 or()。这些方法可以让我们更方便地处理可能为空的值,以及和流或其他返回 Optional 的方法结合使用。我来详细讲解一下这些方法的作用和用法。


stream()


这个方法可以将一个 Optional 对象转换为一个 Stream 对象,如果 Optional 对象包含一个非空的值,那么返回的 Stream 对象就包含这个值,否则返回一个空的 Stream 对象。这样我们就可以利用 Stream 的各种操作来处理 Optional 的值,而不需要显式地判断是否为空。我们可以用 stream() 方法来过滤一个包含 Optional 的列表,只保留非空的值,如下所示:


List> list = Arrays.asList(
Optional.empty(),
Optional.of("A"),
Optional.empty(),
Optional.of("B")
);

// 使用 stream() 方法过滤列表,只保留非空的值
List filteredList = list.stream()
.flatMap(Optional::stream)
.collect(Collectors.toList());

System.out.println(filteredList);
// 输出 [A, B]

ifPresentOrElse(Consumer action, Runnable emptyAction)


这个方法可以让我们在 Optional 对象包含值或者为空时,执行不同的操作。它接受两个参数,一个是 Consumer 类型的 action,一个是 Runnable 类型的 emptyAction。如果 Optional 对象包含一个非空的值,那么就执行 action.accept(value),如果 Optional 对象为空,那么就执行 emptyAction.run()。这样我们就可以避免使用 if-else 语句来判断 Optional 是否为空,而是使用函数式编程的方式来处理不同的情况。我们可以用 ifPresentOrElse() 方法来打印 Optional 的值,或者提示不可用,如下所示 :


Optional optional = Optional.of(1);
optional.ifPresentOrElse(
x -> System.out.println("Value: " + x),
() -> System.out.println("Not Present.")
);

optional = Optional.empty();
optional.ifPresentOrElse(
x -> System.out.println("Value: " + x),
() -> System.out.println("Not Present.")
);

// 输出:Value: 1
// 输出:Not Present.

or(Supplier> supplier)


这个方法可以让我们在 Optional 对象为空时,返回一个预设的值。它接受一个 Supplier 类型的 supplier,如果 Optional 对象包含一个非空的值,那么就返回这个 Optional 对象本身,如果 Optional 对象为空,那么就返回 supplier.get() 返回的 Optional 对象。这样我们就可以避免使用三元运算符或者其他方式来设置默认值,而是使用函数式编程的方式来提供备选值。我们可以用 or() 方法来设置 Optional 的默认值,如下所示:


Optional optional = Optional.of("Hello ");
Supplier> supplier = () -> Optional.of("tom");
optional = optional.or(supplier);
optional.ifPresent(x -> System.out.println(x));

optional = Optional.empty();
optional = optional.or(supplier);
optional.ifPresent(x -> System.out.println(x));

// 输出:Hello
// 输出:tom

为什么我推荐你使用 Optional 类


最后我总结一下使用 Optional 类的几个好处:



  1. 可以避免空指针异常,提高代码的健壮性和可读性。

  2. 可以减少显式的空值检查和 null 的使用,使代码更简洁和优雅。

  3. 可以利用函数式编程的特性,实现更灵活和高效的逻辑处理。

  4. 可以提高代码的可测试性,方便进行单元测试和集成测试。


总之,Optional 类是一个非常有用的类,它可以帮助我们更好地处理可能为空的值,提高代码的质量和效率。所以我强烈推荐你在 Java 开发中使用 Optional 类,你会发现它的魅力和好处。


作者:程序员wayn
来源:juejin.cn/post/7302322661957845028
收起阅读 »

搭建个人直播间,实现24小时B站、斗鱼、虎牙等无人直播!

不知道大家平时看不看直播呢?现在有各式各样的直播,游戏直播、户外直播、带货直播、经典电视/电影直播等等。 电视、电影直播是24小时不间断无人直播,如斗鱼/虎牙中的一起看,这种直播要如何实现呢? 其实非常简单,只需要一台服务器和视频资源就能完成。 再借助于直播...
继续阅读 »

不知道大家平时看不看直播呢?现在有各式各样的直播,游戏直播、户外直播、带货直播、经典电视/电影直播等等。


电视、电影直播是24小时不间断无人直播,如斗鱼/虎牙中的一起看,这种直播要如何实现呢?


其实非常简单,只需要一台服务器和视频资源就能完成。


再借助于直播推流工具,如 KPlayer,将电视剧、电影等媒体资源推流到直播间,就能实现24小时无人直播了!



关注微信公众号:【Java陈序员】,获取开源项目分享、AI副业分享、超200本经典计算机电子书籍等。



KPlayer 简介


KPlayer —— ByteLang Studio 设计开发的一款用于在 Linux 环境下进行媒体资源推流的应用程序。


只需要简单的修改配置文件即可达到开箱即用的目的,不需要了解众多推流适配、视频编解码的细节即可方便的将媒体资源在主流直播平台上进行直播。意愿是提供一个简单易上手、扩展丰富、性能优秀适合长时间不间断推流的直播推流场景。


功能特色:



  • 本地/网络视频资源的无缝推流,切换资源不导致断流

  • 可自定义配置的编码参数,例如分辨率、帧率等

  • 自定义多输出源,适合相同内容一次编码多路推流节省硬件资源

  • 提供缓存机制避免相同内容二次编解码,大大降低在循环场景下对硬件资源的消耗

  • 丰富的API接口在运行时对播放行为和资源动态控制

  • 提供基础插件并具备自定义插件开发的能力


项目地址:https://github.com/bytelang/kplayer-go
在线文档:https:/
/docs.kplayer.net/v0.5.8/

安装 KPlayer


KPlayer 支持一键安装、手动安装和 Docker 安装。


一键安装


通过 ssh 进入到你的服务器中,找到合适的目录并运行以下的命令进行下载:


curl -fsSL get.kplayer.net | bash

手动安装(可选)


1、下载压缩包


wget http://download.bytelang.cn/kplayer-v0.5.8-linux_amd64.tar.gz

2、解压压缩包


tar zxvf kplayer-v0.5.8-linux_amd64.tar.gz

安装完成


1、执行 cd kplayer 进入到 kplayer 目录,使用 ll 查看文件列表:


-rw-r--r-- 1 root root 285 3  23 18:23 config.json.example
-rwxr-xr-x 1 root root 27M 7 29 11:12 kplayer


  • config.json.exampleKPlayer 最小化的配置信息示例

  • kplayerKPlayer 服务启动、停止的执行脚本命令


2、使用 ./kplayer 命令查看当前版本


创建配置文件


1、使用 cp 命令重命名并复制一份 config.json.example


cp config.json.example config.json

2、修改配置文件


{
"version": "2.0.0",
"resource": {
"lists": [
"/video/example_1.mp4",
"/video/example_2.mp4"
]
},
"output": {
"lists": [
{
"path": "rtmp://127.0.0.1:1935/push"
}
]
}
}


  • resource.lists 视频资源文件路径

  • output.lists 直播推流地址,在B站、斗鱼、虎牙等直播平台中开启直播后,将会得到推流地址与推流码


开启直播


上传视频


上传视频资源到服务器,并修改 KPlayer 中的 resource.lists 视频路径



❗❗❗注意:直播的媒体文件必须得有平台版权,否则就会被投诉,封禁直播间❗




{
"version": "2.0.0",
"resource": {
"lists": [
"/data/software/movie/WechatMomentScreenshot.mp4",
"/data/software/movie/IT Tools.mp4",
"/data/software/movie/EasyCode.mp4",
"/data/software/movie/TinyRDM.mp4",
"/data/software/movie/Fooocus.mp4",
"/data/software/movie/Stirling-PDF.mp4"
]
},
"output": {
"lists": [
{
"path": "rtmp://127.0.0.1:1935/push"
}
]
}
}
}

获取推流地址



以开启B站直播为例。



1、点击首页直播


2、点击网页右侧的开播设置


3、选择分类,点击开播



前提需要身-份-证和姓名实名认证




4、复制直播间地址


rtmp://live-push.bilivideo.com/live-bvc/?streamname=live_*********_********&key=**************&schedule=rtmp&pflag=1

5、将直播间地址配置到 KPlayer 配置文件中的 output.lists 直播推流地址


{
"version": "2.0.0",
"resource": {
"lists": [
"/data/software/movie/WechatMomentScreenshot.mp4",
"/data/software/movie/IT Tools.mp4",
"/data/software/movie/EasyCode.mp4",
"/data/software/movie/TinyRDM.mp4",
"/data/software/movie/Fooocus.mp4",
"/data/software/movie/Stirling-PDF.mp4"
]
},
"output": {
"lists": [
{
"path": "rtmp://live-push.bilivideo.com/live-bvc/?streamname=live_*********_********&key=**************&schedule=rtmp&pflag=1"
}
]
}
}

运行 KPlayer


执行以下命令启动 KPlayer


./kplayer play start


后台运行 KPlayer


./kplayer play start --daemon

测试访问


打开直播间地址,可以看到已经开始直播了。



斗鱼、虎牙等其他直播平台的直播配置也是类似的流程,只需要获取到平台的直播推流地址,并进行配置即可!可以同时配置多个平台同时进行直播!



配置循环播放


KPlayer 提供了很多的配置项,有资源配置、播放配置等。


如:可以配置循环播放视频,这样就可以保证24小时不间断的循环播放视频。


{
"version": "2.0.0",
"resource": {
"lists": [
"/data/software/movie/WechatMomentScreenshot.mp4",
"/data/software/movie/IT Tools.mp4",
"/data/software/movie/EasyCode.mp4",
"/data/software/movie/TinyRDM.mp4",
"/data/software/movie/Fooocus.mp4",
"/data/software/movie/Stirling-PDF.mp4"
]
},
"output": {
"lists": [
{
"path": "rtmp://live-push.bilivideo.com/live-bvc/?streamname=live_*********_********&key=**************&schedule=rtmp&pflag=1"
}
]
},
## 播放配置
"play": {
"fill_strategy": "ratio",
## 启用推流编码缓存,会生成缓存,命中缓存节约CPU资源
"skip_invalid_resource": true,
"cache_on": true,
# 播放模式为按顺序且循环播放
"play_model": "loop"
}
}


更多的配置信息可参考 KPlayer 提供的文档。



Docker 安装 KPlayer


1、创建缓存目录 /data/software/docker/kplayer/cache


cd /data/software/docker/kplayer
mkdir cache

2、创建配置文件 /data/software/docker/kplayer/config.json


cd /data/software/docker/kplayer
touch config.json

填入配置信息:


{
"version": "2.0.0",
"resource": {
"lists": [
"/data/software/movie/WechatMomentScreenshot.mp4",
"/data/software/movie/IT Tools.mp4",
"/data/software/movie/EasyCode.mp4",
"/data/software/movie/TinyRDM.mp4",
"/data/software/movie/Fooocus.mp4",
"/data/software/movie/Stirling-PDF.mp4"
]
},
"output": {
"lists": [
{
"path": "rtmp://live-push.bilivideo.com/live-bvc/?streamname=live_*********_********&key=**************&schedule=rtmp&pflag=1"
}
]
},
## 播放配置
"play": {
"fill_strategy": "ratio",
## 启用推流编码缓存,会生成缓存,命中缓存节约CPU资源
"skip_invalid_resource": true,
"cache_on": true,
# 播放模式为按顺序且循环播放
"play_model": "loop"
}
}

2、创建 docker-compose.yml


version: "3.3"
services:
kplayer:
container_name: kplayer
volumes:
- "/data/software/movie:/video"
- "/data/software/docker/kplayer/config.json:/kplayer/config.json"
- "/data/software/docker/kplayer/cache:/kplayer/cache"
restart: always
image: "bytelang/kplayer"

3、启动容器


docker-compose up -d 

以上,就是利用服务器搭建个人直播间的全流程,整个步骤不是很复杂。


我们可以利用闲置的服务器,将自己收藏的电影、电视等资源进行全天候直播,每天还能获得一定的收益!



❗❗❗注意:直播的媒体文件必须得有平台版权,否则就会被投诉,封禁直播间❗



最后


推荐的开源项目已经收录到 GitHub 项目,欢迎 Star


https://github.com/chenyl8848/great-open-source-project

或者访问网站,进行在线浏览:


https://chencoding.top:8090/#/


作者:Java陈序员
来源:juejin.cn/post/7385929329640226828
收起阅读 »

使用双异步后,从 191s 优化到 2s

大家好,我是哪吒。 在开发中,我们经常会遇到这样的需求,将Excel的数据导入数据库中。 一、一般我会这样做: 通过POI读取需要导入的Excel; 以文件名为表名、列头为列名、并将数据拼接成sql; 通过JDBC或mybatis插入数据库; 操作起来,...
继续阅读 »

大家好,我是哪吒。


在开发中,我们经常会遇到这样的需求,将Excel的数据导入数据库中。


一、一般我会这样做:



  1. 通过POI读取需要导入的Excel;

  2. 以文件名为表名、列头为列名、并将数据拼接成sql;

  3. 通过JDBC或mybatis插入数据库;



操作起来,如果文件比较多,数据量都很大的时候,会非常慢。


访问之后,感觉没什么反应,实际上已经在读取 + 入库了,只是比较慢而已。


读取一个10万行的Excel,居然用了191s,我还以为它卡死了呢!


private void readXls(String filePath, String filename) throws Exception {
@SuppressWarnings("resource")
XSSFWorkbook xssfWorkbook = new XSSFWorkbook(new FileInputStream(filePath));
// 读取第一个工作表
XSSFSheet sheet = xssfWorkbook.getSheetAt(0);
// 总行数
int maxRow = sheet.getLastRowNum();

StringBuilder insertBuilder = new StringBuilder();

insertBuilder.append("insert int0 ").append(filename).append(" ( UUID,");

XSSFRow row = sheet.getRow(0);
for (int i = 0; i < row.getPhysicalNumberOfCells(); i++) {
insertBuilder.append(row.getCell(i)).append(",");
}

insertBuilder.deleteCharAt(insertBuilder.length() - 1);
insertBuilder.append(" ) values ( ");

StringBuilder stringBuilder = new StringBuilder();
for (int i = 1; i <= maxRow; i++) {
XSSFRow xssfRow = sheet.getRow(i);
String id = "";
String name = "";
for (int j = 0; j < row.getPhysicalNumberOfCells(); j++) {
if (j == 0) {
id = xssfRow.getCell(j) + "";
} else if (j == 1) {
name = xssfRow.getCell(j) + "";
}
}

boolean flag = isExisted(id, name);
if (!flag) {
stringBuilder.append(insertBuilder);
stringBuilder.append('\'').append(uuid()).append('\'').append(",");
for (int j = 0; j < row.getPhysicalNumberOfCells(); j++) {
stringBuilder.append('\'').append(value).append('\'').append(",");
}
stringBuilder.deleteCharAt(stringBuilder.length() - 1);
stringBuilder.append(" )").append("\n");
}
}

List collect = Arrays.stream(stringBuilder.toString().split("\n")).collect(Collectors.toList());
int sum = JdbcUtil.executeDML(collect);
}

private static boolean isExisted(String id, String name) {
String sql = "select count(1) as num from " + static_TABLE + " where ID = '" + id + "' and NAME = '" + name + "'";
String num = JdbcUtil.executeSelect(sql, "num");
return Integer.valueOf(num) > 0;
}

private static String uuid() {
return UUID.randomUUID().toString().replace("-", "");
}

二、谁写的?拖出去,斩了!


优化1:先查询全部数据,缓存到map中,插入前再进行判断,速度快了很多。


优化2:如果单个Excel文件过大,可以采用 异步 + 多线程 读取若干行,分批入库。



优化3:如果文件数量过多,可以采一个Excel一个异步,形成完美的双异步读取插入。



使用双异步后,从 191s 优化到 2s,你敢信?


下面贴出异步读取Excel文件、并分批读取大Excel文件的关键代码。


1、readExcelCacheAsync控制类


@RequestMapping(value = "/readExcelCacheAsync", method = RequestMethod.POST)
@ResponseBody
public String readExcelCacheAsync() {
String path = "G:\\测试\\data\\";
try {
// 在读取Excel之前,缓存所有数据
USER_INFO_SET = getUserInfo();

File file = new File(path);
String[] xlsxArr = file.list();
for (int i = 0; i < xlsxArr.length; i++) {
File fileTemp = new File(path + "\\" + xlsxArr[i]);
String filename = fileTemp.getName().replace(".xlsx", "");
readExcelCacheAsyncService.readXls(path + filename + ".xlsx", filename);
}
} catch (Exception e) {
logger.error("|#ReadDBCsv|#异常: ", e);
return "error";
}
return "success";
}

2、分批读取超大Excel文件


@Async("async-executor")
public void readXls(String filePath, String filename) throws Exception {
@SuppressWarnings("resource")
XSSFWorkbook xssfWorkbook = new XSSFWorkbook(new FileInputStream(filePath));
// 读取第一个工作表
XSSFSheet sheet = xssfWorkbook.getSheetAt(0);
// 总行数
int maxRow = sheet.getLastRowNum();
logger.info(filename + ".xlsx,一共" + maxRow + "行数据!");
StringBuilder insertBuilder = new StringBuilder();

insertBuilder.append("insert int0 ").append(filename).append(" ( UUID,");

XSSFRow row = sheet.getRow(0);
for (int i = 0; i < row.getPhysicalNumberOfCells(); i++) {
insertBuilder.append(row.getCell(i)).append(",");
}

insertBuilder.deleteCharAt(insertBuilder.length() - 1);
insertBuilder.append(" ) values ( ");

int times = maxRow / STEP + 1;
//logger.info("将" + maxRow + "行数据分" + times + "次插入数据库!");
for (int time = 0; time < times; time++) {
int start = STEP * time + 1;
int end = STEP * time + STEP;

if (time == times - 1) {
end = maxRow;
}

if(end + 1 - start > 0){
//logger.info("第" + (time + 1) + "次插入数据库!" + "准备插入" + (end + 1 - start) + "条数据!");
//readExcelDataAsyncService.readXlsCacheAsync(sheet, row, start, end, insertBuilder);
readExcelDataAsyncService.readXlsCacheAsyncMybatis(sheet, row, start, end, insertBuilder);
}
}
}

3、异步批量入库


@Async("async-executor")
public void readXlsCacheAsync(XSSFSheet sheet, XSSFRow row, int start, int end, StringBuilder insertBuilder) {
StringBuilder stringBuilder = new StringBuilder();
for (int i = start; i <= end; i++) {
XSSFRow xssfRow = sheet.getRow(i);
String id = "";
String name = "";
for (int j = 0; j < row.getPhysicalNumberOfCells(); j++) {
if (j == 0) {
id = xssfRow.getCell(j) + "";
} else if (j == 1) {
name = xssfRow.getCell(j) + "";
}
}

// 先在读取Excel之前,缓存所有数据,再做判断
boolean flag = isExisted(id, name);
if (!flag) {
stringBuilder.append(insertBuilder);
stringBuilder.append('\'').append(uuid()).append('\'').append(",");
for (int j = 0; j < row.getPhysicalNumberOfCells(); j++) {
stringBuilder.append('\'').append(value).append('\'').append(",");
}
stringBuilder.deleteCharAt(stringBuilder.length() - 1);
stringBuilder.append(" )").append("\n");
}
}

List collect = Arrays.stream(stringBuilder.toString().split("\n")).collect(Collectors.toList());
if (collect != null && collect.size() > 0) {
int sum = JdbcUtil.executeDML(collect);
}
}

private boolean isExisted(String id, String name) {
return ReadExcelCacheAsyncController.USER_INFO_SET.contains(id + "," + name);
}

4、异步线程池工具类


@Async的作用就是异步处理任务。



  1. 在方法上添加@Async,表示此方法是异步方法;

  2. 在类上添加@Async,表示类中的所有方法都是异步方法;

  3. 使用此注解的类,必须是Spring管理的类;

  4. 需要在启动类或配置类中加入@EnableAsync注解,@Async才会生效;


在使用@Async时,如果不指定线程池的名称,也就是不自定义线程池,@Async是有默认线程池的,使用的是Spring默认的线程池SimpleAsyncTaskExecutor。


默认线程池的默认配置如下:



  1. 默认核心线程数:8;

  2. 最大线程数:Integet.MAX_VALUE;

  3. 队列使用LinkedBlockingQueue;

  4. 容量是:Integet.MAX_VALUE;

  5. 空闲线程保留时间:60s;

  6. 线程池拒绝策略:AbortPolicy;


从最大线程数可以看出,在并发情况下,会无限制的创建线程,我勒个吗啊。


也可以通过yml重新配置:


spring:
task:
execution:
pool:
max-size: 10
core-size: 5
keep-alive: 3s
queue-capacity: 1000
thread-name-prefix: my-executor

也可以自定义线程池,下面通过简单的代码来实现以下@Async自定义线程池。


@EnableAsync// 支持异步操作
@Configuration
public class AsyncTaskConfig {

/**
* com.google.guava中的线程池
*
@return
*/

@Bean("my-executor")
public Executor firstExecutor() {
ThreadFactory threadFactory = new ThreadFactoryBuilder().setNameFormat("my-executor").build();
// 获取CPU的处理器数量
int curSystemThreads = Runtime.getRuntime().availableProcessors() * 2;
ThreadPoolExecutor threadPool = new ThreadPoolExecutor(curSystemThreads, 100,
200, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(), threadFactory);
threadPool.allowsCoreThreadTimeOut();
return threadPool;
}

/**
* Spring线程池
*
@return
*/

@Bean("async-executor")
public Executor asyncExecutor() {
ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();
// 核心线程数
taskExecutor.setCorePoolSize(24);
// 线程池维护线程的最大数量,只有在缓冲队列满了之后才会申请超过核心线程数的线程
taskExecutor.setMaxPoolSize(200);
// 缓存队列
taskExecutor.setQueueCapacity(50);
// 空闲时间,当超过了核心线程数之外的线程在空闲时间到达之后会被销毁
taskExecutor.setKeepAliveSeconds(200);
// 异步方法内部线程名称
taskExecutor.setThreadNamePrefix("");

/**
* 当线程池的任务缓存队列已满并且线程池中的线程数目达到maximumPoolSize,如果还有任务到来就会采取任务拒绝策略
* 通常有以下四种策略:
* ThreadPoolExecutor.AbortPolicy:丢弃任务并抛出RejectedExecutionException异常。
* ThreadPoolExecutor.DiscardPolicy:也是丢弃任务,但是不抛出异常。
* ThreadPoolExecutor.DiscardOldestPolicy:丢弃队列最前面的任务,然后重新尝试执行任务(重复此过程)
* ThreadPoolExecutor.CallerRunsPolicy:重试添加当前的任务,自动重复调用 execute() 方法,直到成功
*/

taskExecutor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
taskExecutor.initialize();
return taskExecutor;
}
}


5、异步失效的原因



  1. 注解@Async的方法不是public方法;

  2. 注解@Async的返回值只能为void或Future;

  3. 注解@Async方法使用static修饰也会失效;

  4. 没加@EnableAsync注解;

  5. 调用方和@Async不能在一个类中;

  6. 在Async方法上标注@Transactional是没用的,但在Async方法调用的方法上标注@Transcational是有效的;


三、线程池中的核心线程数设置问题


有一个问题,一直没时间摸索,线程池中的核心线程数CorePoolSize、最大线程数MaxPoolSize,设置成多少,最合适,效率最高。


借着这个机会,测试一下。


1、我记得有这样一个说法,CPU的处理器数量


将核心线程数CorePoolSize设置成CPU的处理器数量,是不是效率最高的?


// 获取CPU的处理器数量
int curSystemThreads = Runtime.getRuntime().availableProcessors() * 2;

Runtime.getRuntime().availableProcessors()获取的是CPU核心线程数,也就是计算资源。



  • CPU密集型,线程池大小设置为N,也就是和cpu的线程数相同,可以尽可能地避免线程间上下文切换,但在实际开发中,一般会设置为N+1,为了防止意外情况出现线程阻塞,如果出现阻塞,多出来的线程会继续执行任务,保证CPU的利用效率。

  • IO密集型,线程池大小设置为2N,这个数是根据业务压测出来的,如果不涉及业务就使用推荐。


在实际中,需要对具体的线程池大小进行调整,可以通过压测及机器设备现状,进行调整大小。


如果线程池太大,则会造成CPU不断的切换,对整个系统性能也不会有太大的提升,反而会导致系统缓慢。


我的电脑的CPU的处理器数量是24。


那么一次读取多少行最合适呢?


测试的Excel中含有10万条数据,10万/24 = 4166,那么我设置成4200,是不是效率最佳呢?


测试的过程中发现,好像真的是这样的。


2、我记得大家都习惯性的将核心线程数CorePoolSize和最大线程数MaxPoolSize设置成一样的,都爱设置成200。


是随便写的,还是经验而为之?


测试发现,当你将核心线程数CorePoolSize和最大线程数MaxPoolSize都设置为200的时候,第一次它会同时开启150个线程,来进行工作。


这个是为什么?


3、经过数十次的测试



  1. 发现核心线程数好像差别不大

  2. 每次读取和入库的数量是关键,不能太多,因为每次入库会变慢;

  3. 也不能太少,如果太少,超过了150个线程,就会造成线程阻塞,也会变慢;


四、通过EasyExcel读取并插入数据库


EasyExcel的方式,我就不写双异步优化了,大家切记陷入低水平勤奋的怪圈。


1、ReadEasyExcelController


@RequestMapping(value = "/readEasyExcel", method = RequestMethod.POST)
@ResponseBody
public String readEasyExcel() {
try {
String path = "G:\\测试\\data\\";
String[] xlsxArr = new File(path).list();
for (int i = 0; i < xlsxArr.length; i++) {
String filePath = path + xlsxArr[i];
File fileTemp = new File(path + xlsxArr[i]);
String fileName = fileTemp.getName().replace(".xlsx", "");
List list = new ArrayList<>();
EasyExcel.read(filePath, UserInfo.class, new ReadEasyExeclAsyncListener(readEasyExeclService, fileName, batchCount, list)).sheet().doRead();
}
}catch (Exception e){
logger.error("readEasyExcel 异常:",e);
return "error";
}
return "suceess";
}

2、ReadEasyExeclAsyncListener


public ReadEasyExeclService readEasyExeclService;
// 表名
public String TABLE_NAME;
// 批量插入阈值
private int BATCH_COUNT;
// 数据集合
private List LIST;

public ReadEasyExeclAsyncListener(ReadEasyExeclService readEasyExeclService, String tableName, int batchCount, List list) {
this.readEasyExeclService = readEasyExeclService;
this.TABLE_NAME = tableName;
this.BATCH_COUNT = batchCount;
this.LIST = list;
}

@Override
public void invoke(UserInfo data, AnalysisContext analysisContext) {
data.setUuid(uuid());
data.setTableName(TABLE_NAME);
LIST.add(data);
if(LIST.size() >= BATCH_COUNT){
// 批量入库
readEasyExeclService.saveDataBatch(LIST);
}
}

@Override
public void doAfterAllAnalysed(AnalysisContext analysisContext) {
if(LIST.size() > 0){
// 最后一批入库
readEasyExeclService.saveDataBatch(LIST);
}
}

public static String uuid() {
return UUID.randomUUID().toString().replace("-", "");
}
}

3、ReadEasyExeclServiceImpl


@Service
public class ReadEasyExeclServiceImpl implements ReadEasyExeclService {

@Resource
private ReadEasyExeclMapper readEasyExeclMapper;

@Override
public void saveDataBatch(List list) {
// 通过mybatis入库
readEasyExeclMapper.saveDataBatch(list);
// 通过JDBC入库
// insertByJdbc(list);
list.clear();
}

private void insertByJdbc(List list){
List sqlList = new ArrayList<>();
for (UserInfo u : list){
StringBuilder sqlBuilder = new StringBuilder();
sqlBuilder.append("insert int0 ").append(u.getTableName()).append(" ( UUID,ID,NAME,AGE,ADDRESS,PHONE,OP_TIME ) values ( ");
sqlBuilder.append("'").append(ReadEasyExeclAsyncListener.uuid()).append("',")
.append("'").append(u.getId()).append("',")
.append("'").append(u.getName()).append("',")
.append("'").append(u.getAge()).append("',")
.append("'").append(u.getAddress()).append("',")
.append("'").append(u.getPhone()).append("',")
.append("sysdate )");
sqlList.add(sqlBuilder.toString());
}

JdbcUtil.executeDML(sqlList);
}
}

4、UserInfo


@Data
public class UserInfo {

private String tableName;

private String uuid;

@ExcelProperty(value = "ID")
private String id;

@ExcelProperty(value = "NAME")
private String name;

@ExcelProperty(value = "AGE")
private String age;

@ExcelProperty(value = "ADDRESS")
private String address;

@ExcelProperty(value = "PHONE")
private String phone;
}



作者:哪吒编程
来源:juejin.cn/post/7315730050577694720
收起阅读 »

SpringBoot统一结果返回,统一异常处理,大牛都这么玩

引言 在开发Spring Boot应用时,我们经常面临着不同的控制器方法需要处理各种不同类型的响应结果,以及在代码中分散处理异常可能导致项目难以维护的问题。你是否曾经遇到过在不同地方编写相似的返回格式,或者在处理异常时感到有些混乱?这些看似小问题的积累,实际上...
继续阅读 »

引言


在开发Spring Boot应用时,我们经常面临着不同的控制器方法需要处理各种不同类型的响应结果,以及在代码中分散处理异常可能导致项目难以维护的问题。你是否曾经遇到过在不同地方编写相似的返回格式,或者在处理异常时感到有些混乱?这些看似小问题的积累,实际上可能对项目产生深远的影响。统一结果返回和统一异常处理并非只是为了规范代码,更是为了提高团队的协作效率、降低项目维护的难度,并使代码更易于理解和扩展。


本文的目的是帮助你更好地理解和应用Spring Boot中的统一结果返回和统一异常处理。通过详细的讨论和实例演示,我们将为你提供一套清晰的指南,让你能够在自己的项目中轻松应用这些技术,提高代码质量,减轻开发压力。


统一结果返回


统一结果返回是一种通过定义通用的返回格式,使所有的响应结果都符合同一标准的方法。这有助于提高代码的一致性,减少重复代码的编写,以及使客户端更容易理解和处理API的响应。统一结果返回不仅规范了代码结构,还能提高团队协作效率,降低项目维护的难度。


接下来让我们一起看看在SpringBoot中如何实现统一结果返回。


1. 定义通用的响应对象


当实现统一结果返回时,需要创建一个通用的响应对象,定义成功和失败的返回情况,并确保在接口中使用这个通用返回对象。


@Setter  
@Getter  
public class ResultResponse<T> implements Serializable {  
    private static final long serialVersionUID = -1133637474601003587L;  

    /**  
     * 接口响应状态码  
     */
  
    private Integer code;  

    /**  
     * 接口响应信息  
     */
  
    private String msg;  

    /**  
     * 接口响应的数据  
     */
  
    private T data;
}    

2. 定义接口响应状态码


统一结果返回的关键之一是规定一套通用的状态码。这有助于客户端更容易地理解和处理 API 的响应,同时也为开发者提供了一致的标准。通常,一些 HTTP 状态码已经被广泛接受,如:



  • 200 OK:表示成功处理请求。

  • 201 Created:表示成功创建资源。

  • 204 No Content:表示成功处理请求,但没有返回任何内容。


对于错误情况,也可以使用常见的 HTTP 状态码,如:



  • 400 Bad Request:客户端请求错误。

  • 401 Unauthorized:未授权访问。

  • 404 Not Found:请求资源不存在。

  • 500 Internal Server Error:服务器内部错误。


除了 HTTP 状态码外,你还可以定义自己的应用程序特定状态码,以表示更具体的情况。确保文档中清晰地说明了每个状态码所代表的含义,使开发者能够正确地解释和处理它们。


public enum StatusEnum {  
    SUCCESS(200 ,"请求处理成功"),  
    UNAUTHORIZED(401 ,"用户认证失败"),  
    FORBIDDEN(403 ,"权限不足"),  
    SERVICE_ERROR(500"服务器去旅行了,请稍后重试"),  
    PARAM_INVALID(1000"无效的参数"),  
    ;  
    public final Integer code;  

    public final String message;  

    StatusEnum(Integer code, String message) {  
        this.code = code;  
        this.message = message;  
    }  

}

3. 定义统一的成功和失败的处理方法


定义统一的成功和失败的响应方法有助于保持代码一致性和规范性,简化控制器逻辑,提高代码复用性,降低维护成本,提高可读性,促进团队协作,以及更便于进行测试。


/**  
 * 封装成功响应的方法  
 * @param data 响应数据  
 * @return reponse  
 * @param <T> 响应数据类型  
 */
  
public static <T> ResultResponse<T> success(T data) {  

    ResultResponse<T> response = new ResultResponse<>();  
    response.setData(data);  
    response.setCode(StatusEnum.SUCCESS.code);  
    return response;  
}  

/**  
 * 封装error的响应  
 * @param statusEnum error响应的状态值  
 * @return  
 * @param <T>  
 */
  
public static <T> ResultResponse<T> error(StatusEnum statusEnum) {  
   return error(statusEnum, statusEnum.message);  
}  

/**  
 * 封装error的响应  可自定义错误信息
 * @param statusEnum error响应的状态值  
 * @return  
 * @param <T>  
 */
  
public static <T> ResultResponse<T> error(StatusEnum statusEnum, String errorMsg) {  
    ResultResponse<T> response = new ResultResponse<>();  
    response.setCode(statusEnum.code);  
    response.setMsg(errorMsg);  
    return response;  
}

4. web层统一响应结果


在web层使用统一结果返回的目的是将业务逻辑的处理结果按照预定的通用格式进行封装,以提高代码的一致性和可读性。


@RestController  
@RequestMapping("user")  
@Validated  
@Slf4j  
public class UserController {  

    private IUserService userService;  

    /**  
     * 创建用户  
     * @param requestVO  
     * @return  
     */
  
    @PostMapping("create")  
    public ResultResponse<VoidcreateUser(@Validated @RequestBody UserCreateRequestVO requestVO){  
        return ResultResponse.success(null);  
    }

    /**  
     * 根据用户ID获取用户信息  
     * @param userId 用户id  
     * @return 用户信息  
     */
  
    @GetMapping("info")  
    public ResultResponse<UserInfoResponseVOgetUser(@NotBlank(message = "请选择用户") String userId){  
        final UserInfoResponseVO responseVO = userService.getUserInfoById(userId);  
        return ResultResponse.success(responseVO);  
    }  

    @Autowired  
    public void setUserService(IUserService userService) {  
        this.userService = userService;  
    }  
}

调用接口,响应的信息统一为:


{
    "code": 200,
    "msg": null,
    "data": null
}

{
    "code": 200,
    "msg": null,
    "data": {
        "userId": "121",
        "userName": "码农Academy"
    }
}

统一结果返回通过定义通用的返回格式、成功和失败的返回情况,以及在控制器中使用这一模式,旨在提高代码的一致性、可读性和可维护性。采用统一的响应格式简化了业务逻辑处理流程,使得开发者更容易处理成功和失败的情况,同时客户端也更容易理解和处理 API 的响应。这一实践有助于降低维护成本、提高团队协作效率,并促进代码的规范化。


统一异常处理


统一异常处理的必要性体现在保持代码的一致性、提供更清晰的错误信息、以及更容易排查问题。通过定义统一的异常处理方式,确保在整个应用中对异常的处理保持一致,减少了重复编写相似异常处理逻辑的工作,同时提供友好的错误信息帮助开发者和维护人员更快地定位和解决问题,最终提高了应用的可维护性和可读性。


1.定义统一的异常类


我们需要定义服务中可能抛出的自定义异常类。这些异常类可以继承自RuntimeException,并携带有关异常的相关信息。即可理解为局部异常,用于特定的业务处理中异常。手动埋点抛出。


@Getter  
public class ServiceException extends RuntimeException{  

    private static final long serialVersionUID = -3303518302920463234L;  

    private final StatusEnum status;  

    public ServiceException(StatusEnum status, String message) {  
        super(message);  
        this.status = status;  
    }  

    public ServiceException(StatusEnum status) {  
        this(status, status.message);  
    }  
}

2.异常处理器


创建一个全局的异常处理器,使用@ControllerAdvice 或者 @RestControllerAdvice注解和@ExceptionHandler注解来捕获不同类型的异常,并定义处理逻辑。


2.1 @ControllerAdvice注解

用于声明一个全局控制器建言(Advice),相当于把@ExceptionHandler@InitBinder@ModelAttribute注解的方法集中到一个地方。常放在一个特定的类上,这个类被认为是全局异常处理器,可以跨足多个控制器。



当时用@ControllerAdvice时,我们需要在异常处理方法上加上@ResponseBody,同理我们的web接口。但是如果我们使用@RestControllerAdvice 就可以不用加,同理也是web定义的接口



2.2 @ExceptionHandler注解

用于定义异常处理方法,处理特定类型的异常。放在全局异常处理器类中的具体方法上。


通过这两个注解的配合,可以实现全局的异常处理。当控制器中抛出异常时,Spring Boot会自动调用匹配的@ExceptionHandler方法来处理异常,并返回定义的响应。


@Slf4j  
@ControllerAdvice  
public class ExceptionAdvice {  

    /**  
     * 处理ServiceException  
     * @param serviceException ServiceException  
     * @param request 请求参数  
     * @return 接口响应  
     */
  
    @ExceptionHandler(ServiceException.class)  
    @ResponseBody  
    public ResultResponse<Void> handleServiceException(ServiceException serviceException, HttpServletRequest request) {  
        log.warn("request {} throw ServiceException \n", request, serviceException);  
        return ResultResponse.error(serviceException.getStatus(), serviceException.getMessage());  
    }  

    /**  
     * 其他异常拦截  
     * @param ex 异常  
     * @param request 请求参数  
     * @return 接口响应  
     */
  
    @ExceptionHandler(Exception.class)  
    @ResponseBody  
    public ResultResponse<VoidhandleException(Exception ex, HttpServletRequest request) {  
        log.error("request {} throw unExpectException \n", request, ex);  
        return ResultResponse.error(StatusEnum.SERVICE_ERROR);  
    }
}    

3.异常统一处理使用


在业务开发过程中,我们可以在service层处理业务时,可以手动抛出业务异常。由全局异常处理器进行统一处理。


@Service  
@Slf4j  
public class UserServiceImpl implements IUserService {  

    private IUserManager userManager;

    /**  
     * 创建用户  
     *  
     * @param requestVO 请求参数  
     */
  
    @Override  
    public void createUser(UserCreateRequestVO requestVO) {  
        final UserDO userDO = userManager.selectUserByName(requestVO.getUserName());  
        if (userDO != null){  
            throw new ServiceException(StatusEnum.PARAM_INVALID, "用户名已存在");  
        }  
    }

    @Autowired  
    public void setUserManager(IUserManager userManager) {  
        this.userManager = userManager;  
    }  
}

@RestController  
@RequestMapping("user")  
@Validated  
@Slf4j  
public class UserController {  

    private IUserService userService;  

    /**  
     * 创建用户  
     * @param requestVO  
     * @return  
     */
  
    @PostMapping("create")  
    public ResultResponse<VoidcreateUser(@Validated @RequestBody UserCreateRequestVO requestVO){  
        userService.createUser(requestVO);  
        return ResultResponse.success(null);  
    }

    @Autowired  
    public void setUserService(IUserService userService) {  
        this.userService = userService;  
    }  
}

当我们请求接口时,假如用户名称已存在,接口就会响应:


{
    "code": 1000,
    "msg": "用户名已存在",
    "data": null
}

统一异常处理带来的好处包括提供一致的异常响应格式,简化异常处理逻辑,记录更好的错误日志,以及更容易排查和解决问题。通过统一处理异常,我们确保在整个应用中对异常的处理方式一致,减少了重复性代码的编写,提高了代码的规范性。简化的异常处理逻辑降低了开发者的工作负担,而更好的错误日志有助于更迅速地定位和解决问题,最终提高了应用的可维护性和稳定性。


其他类型的异常处理


在项目开发过程中,我们还有一些常见的特定异常类型,比如MethodArgumentNotValidExceptionUnexpectedTypeException等,并为它们定义相应的异常处理逻辑。这些特定异常可能由于请求参数校验失败或意外的数据类型问题而引起,因此有必要为它们单独处理,以提供更具体和友好的异常响应。


1.MethodArgumentNotValidException


由于请求参数校验失败引起的异常,通常涉及到使用@Valid注解或者@Validated进行请求参数校验。我们可以在异常处理器中编写@ExceptionHandler方法,捕获并处理MethodArgumentNotValidException,提取校验错误信息,并返回详细的错误响应。


/**  
 * 参数非法校验  
 * @param ex  
 * @return  
 */
  
@ExceptionHandler(MethodArgumentNotValidException.class)  
@ResponseBody  
public ResultResponse<Void> handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) {  
    try {  
        List<ObjectErrorerrors = ex.getBindingResult().getAllErrors();  
        String message = errors.stream().map(ObjectError::getDefaultMessage).collect(Collectors.joining(","));  
        log.error("param illegal: {}", message);  
        return ResultResponse.error(StatusEnum.PARAM_INVALID, message);  
    } catch (Exception e) {  
        return ResultResponse.error(StatusEnum.SERVICE_ERROR);  
    }  
}

当我们使用@Valid注解或者@Validated进行请求参数校验不通过时,响应结果为:


{
    "code": 1000,
    "msg": "请输入地址信息,用户年龄必须小于60岁,请输入你的兴趣爱好",
    "data": null
}


关于@Valid注解或者@Validated进行参数校验的功能请参考:SpringBoot优雅校验参数



2.UnexpectedTypeException


意外的数据类型异常,通常表示程序运行时发生了不符合预期的数据类型问题。一个常见的使用场景是在数据转换或类型处理的过程中。例如,在使用 Spring 表单绑定或数据绑定时,如果尝试将一个不符合预期类型的值转换为目标类型,就可能抛出 UnexpectedTypeException。这通常会发生在将字符串转换为数字、日期等类型时,如果字符串的格式不符合目标类型的要求。


我们可以在异常处理器中编写@ExceptionHandler方法,捕获并处理UnexpectedTypeException,提供适当的处理方式,例如记录错误日志,并返回合适的错误响应。


@ExceptionHandler(UnexpectedTypeException.class)  
@ResponseBody  
public ResultResponse<Void> handleUnexpectedTypeException(UnexpectedTypeException ex,  
                                                        HttpServletRequest request) {  
    log.error("catch UnexpectedTypeException, errorMessage: \n", ex);  
    return ResultResponse.error(StatusEnum.PARAM_INVALID, ex.getMessage());  
}

当发生异常时,接口会响应:


{
    "code": 500,
    "msg": "服务器去旅行了,请稍后重试",
    "data": null
}

3.ConstraintViolationException


javax.validation.ConstraintViolationException 是 Java Bean Validation(JSR 380)中的一种异常。它通常在使用 Bean Validation 进行数据校验时,如果校验失败就会抛出这个异常。即我们在使用自定义校验注解时,如果不满足校验规则,就会抛出这个错误。


@ExceptionHandler(ConstraintViolationException.class)  
@ResponseBody  
public ResultResponse<Void> handlerConstraintViolationException(ConstraintViolationException ex, HttpServletRequest request) {  
    log.error("request {} throw ConstraintViolationException \n", request, ex);  
    return ResultResponse.error(StatusEnum.PARAM_INVALID, ex.getMessage());  
}


案例请参考:SpringBoot优雅校验参数,注册ConstraintValidator示例中的@UniqueUser校验。



4.HttpMessageNotReadableException


表示无法读取HTTP消息的异常,通常由于请求体不合法或不可解析。


@ResponseBody  
@ResponseStatus(HttpStatus.BAD_REQUEST)  
@ExceptionHandler(HttpMessageNotReadableException.class)  
public ResultResponse<Void> handleHttpMessageNotReadableException(HttpMessageNotReadableException ex,  
HttpServletRequest request) {  
    log.error("request {} throw ucManagerException \n", request, ex);  
    return ResultResponse.error(StatusEnum.SERVICE_ERROR);  
}

5.HttpRequestMethodNotSupportedException


Spring Framework 中的异常类,表示请求的 HTTP 方法不受支持。当客户端发送了一个使用不被服务器支持的 HTTP 方法(如 GET、POST、PUT、DELETE等)的请求时,可能会抛出这个异常。


@ExceptionHandler({HttpRequestMethodNotSupportedException.class, HttpMediaTypeException.class})  
@ResponseBody  
public ResultResponse<Void> handleMethodNotSupportedException(Exception ex) {  
    log.error("HttpRequestMethodNotSupportedException \n", ex);  
    return ResultResponse.error(StatusEnum.HTTP_METHOD_NOT_SUPPORT);  
}

全局异常处理与局部异常处理在Spring Boot应用开发中扮演不同角色。全局异常处理通过统一的异常处理器确保了整个应用对异常的处理一致性,减少了冗余代码,提高了代码的整洁度。然而,这种方式可能在灵活性上略显不足,无法满足每个具体控制器或业务场景的个性化需求。


相比之下,局部异常处理能够为每个控制器或业务场景提供更具体、灵活的异常处理逻辑,允许定制化的异常响应。这使得在复杂的项目中更容易处理特定的异常情况,同时提供更详细的错误信息。然而,局部异常处理可能带来代码冗余和维护难度的问题,特别是在大型项目中。


在实际应用中,选择全局异常处理还是局部异常处理应根据项目规模和需求进行权衡。对于小型项目或简单场景,全局异常处理可能是一种更简单、合适的选择。而对于大型项目或需要个性化异常处理的复杂业务逻辑,局部异常处理则提供了更为灵活的方案。最佳实践是在项目中根据具体情况灵活使用这两种方式,以平衡一致性和个性化需求。


最佳实践与注意事项


1. 最佳实践



  • 统一响应格式:  在异常处理中,使用统一的响应格式有助于客户端更容易理解和处理错误。通常,返回一个包含错误码、错误信息和可能的详细信息的响应对象。

  • 详细错误日志:  在异常处理中记录详细的错误日志,包括异常类型、发生时间、请求信息等。这有助于快速定位和解决问题。

  • 使用HTTP状态码:  根据异常的性质,选择适当的HTTP状态码。例如,使用HttpStatus.NOT_FOUND表示资源未找到,HttpStatus.BAD_REQUEST表示客户端请求错误等。

  • 异常分类:  根据异常的种类,合理分类处理。可以定义不同的异常类来表示不同的异常情况,然后在异常处理中使用@ExceptionHandler分别处理。

  • 全局异常处理:  使用全局异常处理机制来捕获未被特定控制器处理的异常,以确保应用在整体上的健壮性。


2 注意事项



  • 不滥用异常:  异常应该用于表示真正的异常情况,而不是用作控制流程。滥用异常可能导致性能问题和代码可读性降低。

  • 不忽略异常:  避免在异常处理中忽略异常或仅仅打印日志而不进行适当的处理。这可能导致潜在的问题被掩盖,难以追踪和修复。

  • 避免空的catch块:  不要在catch块中什么都不做,这样会使得异常难以被发现。至少在catch块中记录日志,以便了解异常的发生。

  • 适时抛出异常:  不要过于吝啬地抛出异常,但也不要无谓地滥用。在必要的时候使用异常,例如表示无法继续执行的错误情况。

  • 测试异常场景:  编写单元测试时,确保覆盖异常场景,验证异常的正确抛出和处理。


总结


异常处理在应用开发中是至关重要的一环,它能够提高应用的健壮性、可读性和可维护性。全局异常处理和局部异常处理各有优劣,需要根据项目的规模和需求来灵活选择。通过采用统一的响应格式、详细的错误日志、适当的HTTP状态码等最佳实践,可以使异常处理更为有效和易于管理。同时,注意避免滥用异常、忽略异常、适时抛出异常等注意事项,有助于确保异常处理的质量。在开发过程中,持续关注和优化异常处理,将有助于提高应用的稳定性和用户体验。


本文已收录我的个人博客:码农Academy的博客,专注分享Java技术干货,包括Java基础、Spring Boot、Spring Cloud、Mysql、Redis、Elasticsearch、中间件、架构设计、面试题、程序员攻略等


作者:码农Academy
来源:juejin.cn/post/7322463748006248459
收起阅读 »

不要再用 StringBuilder 拼接字符串了,来试试字符串模板

引言 字符串操作是 Java 中使用最频繁的操作,没有之一。其中非常常见的操作之一就是对字符串的组织,由于常见所以就衍生了多种方案。比如我们要实现 x + y = ?,方案有如下几种 使用 + 进行字符串拼接 String s = x + " + " + ...
继续阅读 »


引言


字符串操作是 Java 中使用最频繁的操作,没有之一。其中非常常见的操作之一就是对字符串的组织,由于常见所以就衍生了多种方案。比如我们要实现 x + y = ?,方案有如下几种



  • 使用 + 进行字符串拼接


String s = x + " + " + y + " = " + (x + y);


  • 使用 StringBuilder


String s = new StringBuilder()
.append(x)
.append(" + ")
.append(y)
.append(" = ")
.append(x + y)
.toString()


  • String::formatString::formatted 将格式字符串从参数中分离出来


String s = String.format("%2$d + %1$d = %3$d", x, y, x + y);
or
String s = "%2$d + %1$d = %3$d".formatted(x, y, x + y);


  • java.text.MessageFormat


String s = MessageFormat.format("{0} + {1} = {2}", x,y, x + y);

这四种方案虽然都可以解决,但很遗憾的是他们或多或少都有点儿缺陷,尤其是面对 Java 13 引入的文本块(Java 13 新特性—文本块)更是束手无措。


字符串模板


为了简化字符串的构造和格式化,Java 21 引入字符串模板功能,该特性主要目的是为了提高在处理包含多个变量和复杂格式化要求的字符串时的可读性和编写效率。


它的设计目标是:



  • 通过简单的方式表达混合变量的字符串,简化 Java 程序的编写。

  • 提高混合文本和表达式的可读性,无论文本是在单行源代码中(如字符串字面量)还是跨越多行源代码(如文本块)。

  • 通过支持对模板及其嵌入式表达式的值进行验证和转换,提高根据用户提供的值组成字符串并将其传递给其他系统(如构建数据库查询)的 Java 程序的安全性。

  • 允许 Java 库定义字符串模板中使用的格式化语法(java.util.Formatter ),从而保持灵活性。

  • 简化接受以非 Java 语言编写的字符串(如 SQL、XML 和 JSON)的 API 的使用。

  • 支持创建由字面文本和嵌入式表达式计算得出的非字符串值,而无需通过中间字符串表示。


该特性处理字符串的新方法称为:Template Expressions,即:模版表达式。它是 Java 中的一种新型表达式,不仅可以执行字符串插值,还可以编程,从而帮助开发人员安全高效地组成字符串。此外,模板表达式并不局限于组成字符串——它们可以根据特定领域的规则将结构化文本转化为任何类型的对象。


STR 模板处理器


STR 是 Java 平台定义的一种模板处理器。它通过用表达式的值替换模板中的每个嵌入表达式来执行字符串插值。使用 STR 的模板表达式的求值结果是一个字符串。


STR 是一个公共静态 final 字段,会自动导入到每个 Java 源文件中。


我们先看一个简单的例子:


    @Test
public void STRTest() {
String sk = "死磕 Java 新特性";
String str1 = STR."{sk},就是牛";
System.out.println(str1);
}
// 结果.....
死磕 Java 新特性,就是牛

上面的 STR."{sk},就是牛" 就是一个模板表达式,它主要包含了三个部分:



  • 模版处理器:STR

  • 包含内嵌表达式({blog})的模版

  • 通过.把前面两部分组合起来,形式如同方法调用


当模版表达式运行的时候,模版处理器会将模版内容与内嵌表达式的值组合起来,生成结果。


这个例子只是 STR模版处理器一个很简单的功能,它可以做的事情有很多。



  • 数学运算


比如上面的 x + y = ?


    @Test
public void STRTest() {
int x = 1,y =2;
String str = STR."{x} + {y} = {x + y}";
System.out.println(str);
}

这种写法是不是简单明了了很多?



  • 调用方法


STR模版处理器还可以调用方法,比如:


String str = STR."今天是:{ LocalDate.now()} ";

当然也可以调用我们自定义的方法:


    @Test
public void STRTest() {
String str = STR."{getSkStr()},就是牛";
System.out.println(str);
}

public String getSkStr() {
return "死磕 Java 新特性";
}


  • 访问成员变量


STR模版处理器还可以访问成员变量,比如:


public record User(String name,Integer age) {
}

@Test
public void STRTest() {
User user = new User("大明哥",18);
String str = STR."{user.name()}今年{user.age()}";
System.out.println(str);
}

需要注意的是,字符串模板表达式中的嵌入表达式数量没有限制,它从左到右依次求值,就像方法调用表达式中的参数一样。例如:


    @Test
public void STRTest() {
int i = 0;
String str = STR."{i++},{i++},{i++},{i++},{i++}";
System.out.println(str);
}
// 结果......
0,1,2,3,4

同时,表达式中也可以嵌入表达式:


    @Test
public void STRTest() {
String name = "大明哥";
String sk = "死磕 Java 新特性";
String str = STR."{name}的{STR."{sk},就是牛..."}";
System.out.println(str);
}
// 结果......
大明哥的死磕 Java 新特性,就是牛...

但是这种嵌套的方式会比较复杂,容易搞混,一般不推荐。


多行模板表达式


为了解决多行字符串处理的复杂性,Java 13 引入文本块(Java 13 新特性—文本块),它是使用三个双引号(""")来标记字符串的开始和结束,允许字符串跨越多行而无需显式的换行符或字符串连接。如下:


String html = """
<html>
<body>
<h2>skjava.com</h2>
<ul>
<li>死磕 Java 新特性</li>
<li>死磕 Java 并发</li>
<li>死磕 Netty</li>
<li>死磕 Redis</li>
</ul>
</body>
</html>
""";

如果字符串模板表达式,我们就只能拼接这串字符串了,这显得有点儿繁琐和麻烦。而字符串模版表达式也支持多行字符串处理,我们可以利用它来方便的组织html、json、xml等字符串内容,比如这样:


    @Test
public void STRTest() {
String title = "skjava.com";
String sk1 = "死磕 Java 新特性";
String sk2 = "死磕 Java 并发";
String sk3 = "死磕 Netty";
String sk4 = "死磕 Redis";

String html = STR."""
<html>
<body>
<h2>{title}</h2>
<ul>
<li>{sk1}</li>
<li>{sk2}</li>
<li>{sk3}</li>
<li>{sk4}</li>
</ul>
</body>
</html>
"""
;
System.out.println(html);
}

如果决定定义四个 sk 变量麻烦,可以整理为一个集合,然后调用方法生成 <li> 标签。


FMT 模板处理器


FMT 是 Java 定义的另一种模板处理器。它除了与STR模版处理器一样提供插值能力之外,还提供了左侧的格式化处理。下面我们来看看他的功能。比如我们要整理模式匹配的 Switch 表达在 Java 版本中的迭代,也就是下面这个表格


Java 版本更新类型JEP更新内容
Java 17第一次预览JEP 406引入模式匹配的 Swith 表达式作为预览特性。
Java 18第二次预览JEP 420对其做了改进和细微调整
Java 19第三次预览JEP 427进一步优化模式匹配的 Swith 表达式
Java 20第四次预览JEP 433
Java 21正式特性JEP 441成为正式特性

如果使用 STR 模板处理器,代码如下:


    @Test
public void STRTest() {
SwitchHistory[] switchHistories = new SwitchHistory[]{
new SwitchHistory("Java 17","第一次预览","JEP 406","引入模式匹配的 Swith 表达式作为预览特性。"),
new SwitchHistory("Java 18","第二次预览","JEP 420","对其做了改进和细微调整"),
new SwitchHistory("Java 19","第三次预览","JEP 427","进一步优化模式匹配的 Swith 表达式"),
new SwitchHistory("Java 20","第四次预览","JEP 433",""),
new SwitchHistory("Java 21","正式特性","JEP 441","成为正式特性"),
};

String history = STR."""
Java 版本 更新类型 JEP 更新内容
{switchHistories[0].javaVersion()} {switchHistories[0].updateType()} {switchHistories[0].jep()} {switchHistories[0].content()}
{switchHistories[1].javaVersion()} {switchHistories[1].updateType()} {switchHistories[1].jep()} {switchHistories[1].content()}
{switchHistories[2].javaVersion()} {switchHistories[2].updateType()} {switchHistories[2].jep()} {switchHistories[2].content()}
{switchHistories[3].javaVersion()} {switchHistories[3].updateType()} {switchHistories[3].jep()} {switchHistories[3].content()}
{switchHistories[4].javaVersion()} {switchHistories[4].updateType()} {switchHistories[4].jep()} {switchHistories[4].content()}
""";
System.out.println(history);
}

得到的效果是这样的:


Java 版本     更新类型    JEP 更新内容
Java 17 第一次预览 JEP 406 引入模式匹配的 Swith 表达式作为预览特性。
Java 18 第二次预览 JEP 420 对其做了改进和细微调整
Java 19 第三次预览 JEP 427 进一步优化模式匹配的 Swith 表达式
Java 20 第四次预览 JEP 433
Java 21 正式特性 JEP 441 成为正式特性

是不是很丑?完全对不齐,没法看。为了解决这个问题,就可以采用FMT模版处理器,在每一列左侧定义格式:


   @Test
public void STRTest() {
SwitchHistory[] switchHistories = new SwitchHistory[]{
new SwitchHistory("Java 17","第一次预览","JEP 406","引入模式匹配的 Swith 表达式作为预览特性。"),
new SwitchHistory("Java 18","第二次预览","JEP 420","对其做了改进和细微调整"),
new SwitchHistory("Java 19","第三次预览","JEP 427","进一步优化模式匹配的 Swith 表达式"),
new SwitchHistory("Java 20","第四次预览","JEP 433",""),
new SwitchHistory("Java 21","正式特性","JEP 441","成为正式特性"),
};

String history = FMT."""
Java 版本 更新类型 JEP 更新内容
%-10s{switchHistories[0].javaVersion()} %-9s{switchHistories[0].updateType()} %-10s{switchHistories[0].jep()} %-20s{switchHistories[0].content()}
%-10s{switchHistories[1].javaVersion()} %-9s{switchHistories[1].updateType()} %-10s{switchHistories[1].jep()} %-20s{switchHistories[1].content()}
%-10s{switchHistories[2].javaVersion()} %-9s{switchHistories[2].updateType()} %-10s{switchHistories[2].jep()} %-20s{switchHistories[2].content()}
%-10s{switchHistories[3].javaVersion()} %-9s{switchHistories[3].updateType()} %-10s{switchHistories[3].jep()} %-20s{switchHistories[3].content()}
%-10s{switchHistories[4].javaVersion()} %-9s{switchHistories[4].updateType()} %-10s{switchHistories[4].jep()} %-20s{switchHistories[4].content()}
""";
System.out.println(history);
}

输出如下:


Java 版本     更新类型        JEP             更新内容
Java 17 第一次预览 JEP 406 引入模式匹配的 Swith 表达式作为预览特性。
Java 18 第二次预览 JEP 420 对其做了改进和细微调整
Java 19 第三次预览 JEP 427 进一步优化模式匹配的 Swith 表达式
Java 20 第四次预览 JEP 433
Java 21 正式特性 JEP 441 成为正式特性

作者:大明哥_
来源:juejin.cn/post/7323251349302706239
收起阅读 »

SpringBoot接收参数的19种方式

1. Get 请求 1.1 以方法的形参接收参数 1.这种方式一般适用参数比较少的情况 @RestController @RequestMapping("/user") @Slf4j public class UserController { @Ge...
继续阅读 »

1. Get 请求


1.1 以方法的形参接收参数


1.这种方式一般适用参数比较少的情况


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {

@GetMapping("/detail")
public Result<User> getUserDetail(String name,String phone) {
log.info("name:{}",name);
log.info("phone:{}",phone);
return Result.success(null);
}
}


2.参数用 @RequestParam 标注,表示这个参数需要必传,否则会报错。


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {

@GetMapping("/detail")
public Result<User> getUserDetail(@RequestParam String name,@RequestParam String phone) {
log.info("name:{}",name);
log.info("phone:{}",phone);
return Result.success(null);
}
}


1.2 以实体类接收参数


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {

@GetMapping("/detail")
public Result<User> getUserDetail(User user) {
log.info("name:{}",user.getName());
log.info("phone:{}",user.getPhone());
return Result.success(null);
}
}




注:Get 请求以实体类接收参数时,不能用 RequestParam 注解进行标注,因为不支持这样的方式获取参数。




1.3 通过 HttpServletRequest 接收参数


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@GetMapping("/detail")
public Result<User> getUserDetail(HttpServletRequest request) {
String name = request.getParameter("name");
String phone = request.getParameter("phone");
log.info("name:{}",name);
log.info("phone:{}",phone);
return Result.success(null);
}
}



1.4 通过 @PathVariable 注解接收参数


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {

@GetMapping("/detail/{name}/{phone}")
public Result<User> getUserDetail(@PathVariable String name,@PathVariable String phone) {
log.info("name:{}",name);
log.info("phone:{}",phone);
return Result.success(null);
}
}



1.5 接收数组参数


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {

@GetMapping("/detail")
public Result<User> getUserDetail(String[] names) {
Arrays.asList(names).forEach(name->{
System.out.println(name);
});
return Result.success(null);
}
}



1.6 接收集合参数



springboot 接收集合参数,需要用 RequestParam 注解绑定参数,否则会报错!!



@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {

@GetMapping("/detail")
public Result<User> getUserDetail(@RequestParam List<String> names) {
names.forEach(name->{
System.out.println(name);
});
return Result.success(null);
}
}



2. Post 请求


2.1 以方法的形参接收参数


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {

@PostMapping("/save")
public Result<User> getUserDetail(String name,String phone) {
log.info("name:{}",name);
log.info("phone:{}",phone);
return Result.success(null);
}
}




注:和 Get 请求一样,如果方法形参用 RequestParam 注解标注,表示这个参数需要必传。



2.2 通过 param 提交参数,以实体类接收参数


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {

@PostMapping("/save")
public Result<User> getUserDetail(User user) {
log.info("name:{}",user.getName());
log.info("phone:{}",user.getPhone());
return Result.success(null);
}
}




注:Post 请求以实体类接收参数时,不能用 RequestParam 注解进行标注,因为不支持这样的方式获取参数。




2.3 通过 HttpServletRequest 接收参数


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {

@PostMapping("/save")
public Result<User> getUserDetail(HttpServletRequest httpServletRequest) {
log.info("name:{}",httpServletRequest.getParameter("name"));
log.info("phone:{}",httpServletRequest.getParameter("phone"));
return Result.success(null);
}
}



2.4 通过 @PathVariable 注解进行接收


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {

@PostMapping("/save/{name}")
public Result<User> getUserDetail(@PathVariable String name) {
log.info("name:{}",name);
return Result.success(null);
}
}



2.5 请求体以 form-data 提交参数,以实体类接收参数


form-data 是表单提交的一种方式,比如常见的登录请求。


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@PostMapping("/save")
public Result<User> getUserDetail(User user) {
log.info("name:{}",user.getName());
log.info("phone:{}",user.getPhone());
return Result.success(null);
}
}



2.6 请求体以 x-www-form-urlencoded 提交参数,以实体类接收参数


x-www-form-urlencoded 也是表单提交的一种方式,只不过提交的参数被进行了编码,并且转换成了键值对。


例如你用form-data 提交的参数:


name: 知否君
age: 22

用 x-www-form-urlencoded 提交的参数:


name=%E5%BC%A0%E4%B8%89&age=22

@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@PostMapping("/save")
public Result<User> getUserDetail(User user) {
log.info("name:{}",user.getName());
log.info("phone:{}",user.getPhone());
return Result.success(null);
}
}



2.7 通过 @RequestBody 注解接收参数



注:RequestBody 注解主要用来接收前端传过来的 body 中 json 格式的参数。



2.7.1 接收实体类参数


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@PostMapping("/save")
public Result<User> getUserDetail(@RequestBody User user) {
log.info("name:{}",user.getName());
log.info("phone:{}",user.getPhone());
return Result.success(null);
}
}



2.7.2 接收数组和集合


接收数组


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@PostMapping("/save")
public Result<User> getUserDetail(@RequestBody String[] names) {
Arrays.asList(names).forEach(name->{
System.out.println(name);
});
return Result.success(null);
}
}


接收集合


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@PostMapping("/save")
public Result<User> getUserDetail(@RequestBody List<String> names) {
names.forEach(name->{
System.out.println(name);
});
return Result.success(null);
}
}



2.8 通过 Map 接收参数


1.以 param 方式传参, RequestParam 注解接收参数


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@PostMapping("/save")
public Result<User> getUserDetail(@RequestParam Map<String,Object> map) {
System.out.println(map);
System.out.println(map.get("name"));
return Result.success(null);
}
}



2.以 body json 格式传参,RequestBody 注解接收参数


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@PostMapping("/save")
public Result<User> getUserDetail(@RequestBody Map<String,Object> map) {
System.out.println(map);
System.out.println(map.get("name"));
return Result.success(null);
}
}



2.9 RequestBody 接收一个参数


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@PostMapping("/save")
public Result<User> getUserDetail(@RequestBody String name) {
System.out.println(name);
return Result.success(null);
}
}



3. Delete 请求


3.1 以 param 方式传参,以方法形参接收参数


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@DeleteMapping("/delete")
public Result<User> getUserDetail(@RequestParam String name) {
System.out.println(name);
return Result.success(null);
}
}



3.2 以 body json 方式传参,以实体类接收参数



注:需要用 RequestBody 注解,否则接收的参数为 null



@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@DeleteMapping("/delete")
public Result<User> getUserDetail(@RequestBody User user) {
System.out.println(user);
return Result.success(null);
}
}



3.3 以 body json 方式传参,以 map 接收参数



注:需要用 RequestBody 注解,否则接收的参数为 null



@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@DeleteMapping("/delete")
public Result<User> getUserDetail(@RequestBody Map<String,Object> map) {
System.out.println(map);
return Result.success(null);
}
}



3.4 PathVariable 接收参数


@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@DeleteMapping("/delete/{name}")
public Result<User> getUserDetail(@PathVariable String name) {
System.out.println(name);
return Result.success(null);
}
}



作者:知否技术
来源:juejin.cn/post/7343243744479625267
收起阅读 »

是的,JDK 也有不为人知的“屎山”!

在前几天我写了一篇文章分享了为何避免使用 Collectors.toMap(),感兴趣的可以去瞧一眼:Stream很好,Map很酷,但答应我别用toMap()。 评论区也有小伙伴提到自己也踩过同样的坑,在那篇文章里介绍了 toMap() 有哪些的易踩的坑,今天...
继续阅读 »

在前几天我写了一篇文章分享了为何避免使用 Collectors.toMap(),感兴趣的可以去瞧一眼:Stream很好,Map很酷,但答应我别用toMap()


评论区也有小伙伴提到自己也踩过同样的坑,在那篇文章里介绍了 toMap() 有哪些的易踩的坑,今天就让我们好好的扒一扒 Map 的底裤,看看这背后不为人知的故事。


要讲 Map,可以说 HashMap 是日常开发使用频次最高的,我愿称其为古希腊掌管性能的神。


举个简单的例子,如何判断两个集合是否存在交集?最简单也最粗暴的方式,两层 for 遍历暴力检索,别跟我提什么时间空间复杂度,给我梭哈就完事。


public void demo() {  
List<Integer> duplicateList = new ArrayList<>();
List<Integer> list1 = List.of(1, 2, 3, 4);
List<Integer> list2 = List.of(3, 4, 5, 6);
for (Integer l1 : list1) {
for (Integer l2 : list2) {
if (Objects.equals(l1, l2)) {
duplicateList.add(l1);
}
}
}
System.out.println(duplicateList);
}

image.png


敲下回车提交代码之后,当还沉浸在等待领导夸你做事又稳又快的时候,却发现领导黑着脸向你一步步走来。


刚准备开始摸鱼的你吓得马上回滚了提交,在一番资料查询之后你发现了原来可以通过 Map 实现 O(n) 级的检索效率,你意气风发的敲下一段新的代码:


public void demo() {  
List<Integer> duplicateList = new ArrayList<>();
List<Integer> list1 = List.of(1, 2, 3, 4);
List<Integer> list2 = List.of(3, 4, 5, 6);

Map<Integer, Integer> map = new HashMap<>();
list2.forEach(it -> map.put(it, it));
for (Integer l : list1) {
if (Objects.nonNull(map.get(l))) {
duplicateList.add(l);
}
}
System.out.println(duplicateList);
}

重新提交代码起身上厕所,你昂首挺胸的特地从领导面前路过,领导回了你一个肯定的眼神。


image.png


让我们回到 HashMap 的身上,作为八股十级选手而言的你,什么数据结构红黑树可谓信手拈来,但我们今天不谈八股,只聊聊背后的一些设计理念。


众所周知,在 HashMap 中有且仅允许存在一个 keynull 的元素,当 key 已存在默认的策略是进行覆盖,比如下面的示例最终 map 的值即 {null=2}


Map<Integer, Integer> map = new HashMap<>();  
map.put(null, 1);
map.put(null, 2);
System.out.println(map);

同时 HashMap 对于 value 的值并没有额外限制,只要你愿意,你甚至可以放几百万 value 为空的元素像下面这个例子:


Map<Integer, Integer> map = new HashMap<>();  
map.put(1, null);
map.put(2, null);
map.put(3, null);
map.put(4, null);
map.put(5, null);
System.out.println(map);

这也就引出了今天的重点!


stream 中使用 Collectors.toMap() 时,如果你不注意还是按照惯性思维那么它就会让你感受一下什么叫做暴击。就像上一篇文章提到的其异常触发机制,但却并不知道为什么要这么设计?


作为网络冲浪小能手,我反手就是在 stackoverflow 发了提问,咱虽然笨但主打一个好学。


image.png


值得一提的是,评论区有个老哥回复的相当戳我,他的回复如下:


image.png


用我三脚猫的英语水平翻译一下,大概意思如下:



因为人家 toMap() 并没有说返回的是 HashMap,所以你凭什么想要人家遵循跟 HashMap 一样的规则呢?



我滴个乖乖,他讲的似乎好有道理的样子。


image.png


我一开始也差点信了,但其实你认真看 toMap() 的内部实现,你会发现其返回的不偏不倚正好就是 HashMap


image.png


如果你还不信,以上篇文章的代码为例,执行后获取其类型可以看到输出就是 HashMap


image.png


这时候我的 CPU 又烧了,这还是我认识的 HashMap,怎么开始跟 stream 混之后就开始六亲不认了,是谁说的代码永远不会变心的?


image.png


一切彷佛又回到了起点,为什么在新的 stream 中不遵循大家已经熟悉规范,而是要改变习惯对此做出限制?


stackoverflow 上另外的一个老哥给出的他的意见:


image.png


让我这个四级 751 分老手再给大家做个免费翻译官简化一下观点:



Collectors.toMap() 的文档中已经标注其并不保证返回 Map 的具体类型,以及是否可变、序列化性以及是否线程安全,而 JDK 拥有众多的版本,可能在你的环境已经平稳运行了数年,但换个环境之后在不同的 JDK 下可能程序就发生了崩溃。因此,这些额外的保障实际上还帮了你的忙。



回头去看 toMap() 方法上的文档说明,确实也像这位老哥提到的那样。


image.png


而在 HashMap 中允许 KeyValue 为空带来的一个问题在此时也浮现了出来,当存入一个 value 为空的元素时,再后续执行 get() 再次读取时,存在一个问题那就是二义性。


很显然执行 get() 返回的结果将为空,那这个空究竟是 Map 中不存在这个元素?还是我存入的元素其 value 为空?这一点我想只有老天爷知道,而这种二义性所带来的问题在设计层面显然是一个失误。


那么到这里,我们就可以得到一个暴论:HashMap 允许 key 和 value 为空就是 JDK 留下的“屎山”!


为了验证这一结论,我们可以看看在新的 ConcurrentHashMapJDK 是怎么做的?查看源码可以看到,在 put() 方法的一开始就执行了 keyvalue 的空值校验,也验证了上面的猜想。


image.png


这还原不够支撑我们的结论,让我们继续深挖这背后还有什么猫腻。


首先让我看看是谁写的 ConcurrentHashMap,在 openjdkGitHub 仓库类文档注释可以看到主要的开发者是 Doug Lea


image.png


Doug Lea 又是何方大佬,通过维基百科的可以看到其早期是 Java 并发社区的主席,他参与了一众的 JDK 并发设计工作,可谓吾辈偶像。


image.png


在网络搜罗相关的资讯找到对应的话题,虽然图中的链接已经不存在了,但还是能从引用的内容看出其核心的原因正是为了规避的结果的模糊性,与前文我们讨论的二义性不尽相同。


image.png


那为什么 JDK 不同步更新 HashMap 的设计理念,在新版 HashMap 中引入 keyvalue 的非空校验?


我想剩下的理由只有一个:HashMap 的使用范围实在太广,就算是 JDK 自己也很难在不变更原有结构的基础上进行改动,而在 JDK 1.2 便被提出并广泛应用,对于一个发展了数十年的语言而言,兼容性是十分重要的一大考量。


因此,我们可以看到,在后续推出的 Map 中,往往对 keyValue 都作了进一步的限制,而对于 HashMap 而言,可能 JDK 官方也是有心无力吧。


到这里基本也就盖棺定论了,但本着严谨的态度大胆假设小心求证,让我们再来看看大家伙的意见,万一不小心就被人网暴了。


image.png


stackoverflow 上另外几篇有关 Map 回答下可以看到,许多人都认为 HashMap 支持空值是一个存在缺陷的设计。


image.png


感兴趣的小伙伴可以去原帖查看,这里我就不再展开介绍了,原帖链接:Why does Map.of not allow null keys and values?


看到这里,下次别人或者老板再说你写的代码是屎山的时候,请昂首挺胸自信的告诉他 JDk 都会犯错,我写的这点又算得了什么?


image.png


作者:烽火戏诸诸诸侯
来源:juejin.cn/post/7384629198130610215
收起阅读 »

零 rust 基础前端使直接上手 tauri 开发一个小工具

起因 有一天老爸找我,他们公司每年都要在线看视频学习,要花费很多时间,问我有没有办法可以自动学习。 在这之前,我还给我老婆写了个浏览器插件,解决了她的在线学习问题,她学习的是一个叫好医生的学习网站,我通过研究网站的接口和代码,帮她开发出了一键学习全部课程和自动...
继续阅读 »

起因


有一天老爸找我,他们公司每年都要在线看视频学习,要花费很多时间,问我有没有办法可以自动学习。


在这之前,我还给我老婆写了个浏览器插件,解决了她的在线学习问题,她学习的是一个叫好医生的学习网站,我通过研究网站的接口和代码,帮她开发出了一键学习全部课程和自动考试的插件,原本需要十来天的学习时间,分分钟就解决了。


有兴趣的可以看一下,好医生自动学习+考试插件源码


正因为这次的经历,我直接接下了这个需求,毕竟可以在家人面前利用自己的能力去帮他们解决问题,是一件非常骄傲的事。


事情并没有那么简单


我回家一看,他们的学习平台是个桌面端的软件(毕竟是银行的平台,做的比那个好医生严谨的多),内嵌的浏览器,无法打开控制台,更没办法装插件,甚至视频学习调了什么接口,有什么漏洞都无法发现,我感觉有点无能为力。


但是牛逼吹出去了,也得想办法做。


技术选型


既然没办法找系统漏洞去快速学习,那只能按部就班的去听课了,我第一想到的方式是用按键精灵写个脚本,去自动点击就可以了。但是我爸又想给他的同事用,再教他们用按键精灵还是有点上手成本的,所以我打算自己开发一个小工具去实现。


由于我是个前端开发者,做桌面端首先想到的是 Electron,因为我有一些开发经验,所以并不难,但打包后的体积太大,本来一个小工具,做这么大,这不是显得我技术太烂嘛。


所以我选择了 tauri 去开发。


需求分析


首先我想到的方式就是:



  1. 用鼠标框选一个区域,然后记录这个区域的颜色信息,记录区域坐标。

  2. 不断循环识别这个区域,匹配颜色。

  3. 如果匹配到颜色,则点击这个区域。


例如,本节课程学习后,会弹出提示框,进入下一节学习,那么可以识别这个按钮,如果屏幕出现这个按钮,则点击,从而实现自动学习的目的。


我还给它起了个很形象的名字,叫做打地鼠。


image.png


由于要点击的不一定只有一个下一节,可能还有其他章节的可能要学习,所以还实现了多任务执行,这样可以识别多个位置。


有兴趣可以看一下源码


零基础入门 rust


Tauri 已经提供了很多可以在前端调用的接口去实现很多桌面端的功能,但也不能完全能满足我本次开发的需求,所以还是要学习一点 rust 的语法。


这里简单说一下我学到的一些简单语法,方便大家快速入门。由于功能简单,我们并不需要了解 rust 那些高深的内容,了解基础语法即可,不然想学会 rust 我觉得真心很难。我们完全可以先入门,再深入。


适合人群


有一定其他编程语言(C/Java/Go/Python/JavaScript/Typescript/Dart等)基础。你至少得会写点代码是吧。


环境安装


推荐使用 rustup 安装 rust,rustup 是官方提供的的安装工具。


curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

安装后,检查版本,这类似安装 node 后查看版本去验证是否成功安装。


rustc --version

>>> rustc 1.73.0 (cc66ad468 2023-10-03)

cargo 是 rust 官方的包管理工具,类似 npm,这里也校验一下是否成功安装。


cargo --version


如果提示不存在指令,重新打开终端再尝试。



编辑器


官方推荐 Clion,是开发 rust 首选开发工具。


不过作为前端,我们依然希望可以使用 vscode 去开发,当然,这也是没有问题的。


vscode 需要搭配 rust-analyzer 一起使用。


除了上面提到的两个命令,还有 rustup 命令也可以直接使用了:


rustup component add rust-analyzer

执行后就都配置好了,可以进行语法的学习了。


变量与常量的声明


定义变量和常量的声明 javascript 和 rust 是一样的,都是通过 let 和 const,但是在定义变量时还是有一些区别的:



  • 默认情况下,变量是不可变的。(这点对于前端同学来说是不是很奇怪?)

  • 如果你想定义一个可变的变量,需要在变量名前面加上 mut


let x = 1;
x = 2; ❌

let mut x = 1;
x = 2; ✅

如果你不想用 mut,你也可以使用相同的名称声明新的变量:


let x = 1;
let x = x + 1;

Rust 里常量的命名规范是使用全大写字母,每个单词之间使用下划线分开,虽然 JS 没有强制的规范,但是我们也是这么做的。


数据类型


对于只了解 javascript 的同学,这个是非常重要的一环,因为 rust 需要在定义变量时做出类型的定义。即使是有过 typescript 开发经验的同学,这里也有着非常大的区别。这里只说一些与 js 区别较大的地方。


数字


首先 ts 对于数字的类型都是统一的 number,但是 rust 区别就比较大了,分为有符号整型,无符号整型,浮点型。


有 i8、i16、i32、i64、i128、u8、u16、u32、u64、isize、usize、f32、f64。


虽然上面看起来有这么多种类型去定义一个数字类型,实际上它们只是去定义了这个值所占用的空间,新手其实不用太过于纠结这里。如果你不知道应该选择哪种类型,直接使用默认的i32即可,速度也很快。有符号就是分正负(+,-),无符号只有正数。浮点型在现代计算机里上 f64 和 f32 运行速度差不多,f64 更加精确,所以不用太纠结。


数组


数组定义也有很大区别,你需要一开始就定义好数组的长度:


let a: [i32; 5] = [0; 5];

这表示定义一个包含 5 个元素的数组,所有元素都初始化为 0。一旦定义,数组的大小就不能改变了。


这是不是让前端同学很难理解,那么如何定义一个可变的数组呢?这好像更符合前端的思维。


在 Rust 中,Vec 是一个动态数组,也就是说,它可以在运行时增加或减少元素。


let v: Vec<i32> = Vec::new();
v.push(4);

这是不是更符合前端的直觉?毕竟后面我们要使用鼠标框选一个范围的颜色,这个颜色数组是不固定的,所以要用到 Vec


数据类型就说到这,其他的有兴趣自行了解即可。


引用包


rust 同 javascript 一样,也可以引入其他包,但语法上就不太一样了,例如:


use autopilot::{geometry::Point, screen, mouse};

强行翻译成 es module 引入:


import { Point, screen, mouse } from 'autopilot';

看到 :: 是不是有点懵逼,javascript 可没有这样的东西,你可以直觉的把它和 . 想象成一样就行。


:: 主要用于访问模块(module)或类型(type)的成员。例如,你可以使用 :: 来访问模块中的函数或常量,或者访问枚举的成员。


. 用于访问结构体(struct)、枚举(enum)或者 trait 对象的实例成员,包括字段(field)和方法(method)。


其他语法


循环:


for i in 0..colors.len() {}

条件判断:


if colors[i] != screen_colors[i] {

}

他们就是少了括号,还有一些高级的语法是 ES 没有的,这都很好理解。


那么我说这样就算入门了,不算过分吧?如果你要学一个语言,千万别因为它难而不敢上手,你直接上手去做,遇坑就填,你会进步很快。


如果你觉得这样很难写代码,那么我建议你买个 copilot 或者平替通义灵码,你上手写点小东西应该就不成问题了,毕竟我就这样就开始做了。


软件开发


Tauri 官网翻译还不全,读起来可能有点吃力,借助翻译工具将就着看吧,我有心帮大家翻译,但是提了 pr,好几天也没人审核。


你可以把 tauri 当作前端和后端不分离的项目,webview 就是前端,rust 写后端。


创建项目


tauri 提供了很多方式去帮你创建一个新的项目:


image.png


这里初始化一个 vite + vue + ts 的项目:


image.png


最后的目录结构可以看一下:


image.png


src 就是前端的目录。


src-tauri 就是后端的目录。


前端


前端是老本行,不想说太多的东西,大家都很熟悉,把页面写出来就可以了。


值得一提的就是 tauri 提供的一些接口,这些接口可以让我们实现一些浏览器上无法实现的功能。


与后端通讯


import { invoke } from "@tauri-apps/api";

invoke('event_name', payload)

通过 invoke 可以调用 rust 方法,并通过 payload 去传递参数。


窗口间传递信息


这里的窗口指的是软件的窗口,不是浏览器的标签页。由于我们要框选一块显示器上的区域,所以要创建一个新的窗口去实现,而选择后要将数据传递给主窗口。


import { listen } from '@tauri-apps/api/event';

listen<{ index: number}>("location", async (event) => {
const index = event.payload.index;
// ...
})

获取窗口实例


例如隐藏当前窗口的操作:


import { getCurrent } from '@tauri-apps/api/window';

const win = getCurrent()
win.hide() // 显示窗口即 win.show()

与之相似的还有:



  • appWindow 获取主窗口实例。

  • getAll 获取所有窗口实例,可以通过 label 来区分窗口。


最主要的是 WebviewWindow,可以通过他去创建一个新的窗口。


const screenshot = new WebviewWindow("screenshot", {
title: "screenshot",
decorations: false,
// 对应 views/screenshot.vue
url: `/#/screenshot?index=${props.index}`,
alwaysOnTop: true,
transparent: true,
hiddenTitle: true,
maximized: true,
visible: false,
resizable: false,
skipTaskbar: false,
})

这里我们创建了一个最大化、透明的窗口,且它位于屏幕最上方,页面指向就是 vue-router 的路由,index 是因为我们不确定要创建多少个窗口,用于区分。


可以通过创建这样的透明窗口,然后实现一个框选区域的功能,这对于前端来说,并不难。


例如鼠标点击左键,滑动鼠标,再松开左键,绘制这个矩形,再加一个按钮。


image.png


随后将位置信息传递给主窗口,并关闭这个透明窗口。


后端


首先,src-tauri/src/main.rs 是已经创建好的入口文件,里面已有一些内容,不用都了解。


暴露给前端的方法


tauri::Builder::default().invoke_handler(tauri::generate_handler![scan_once, ...])

通过 invoke_handler 可以暴露给前端 invoke 调用的方法。



! 在 rust 中是指宏调用,主要是方便,并不是 javascript 里的非的含义,这里注意下。



获取屏幕颜色


这里为了性能,我只获取了 x 起始位置到 x 结束位置,y 轴取中间一行的颜色。


use autopilot::{geometry::Point, screen};

pub fn scan_colors(start_x: f64, end_x: f64, y: f64) -> Vec<[u8; 3]> {
// 双重循环,根据 start_x, end_x, y 定义坐标数组
let mut points: Vec<Point> = Vec::new();
let mut x = start_x;
while x < end_x {
points.push(Point::new(x, y));
x += 1.0;
}
// 循环获取坐标数组的颜色
let mut colors: Vec<[u8; 3]> = Vec::new();
for point in points {
let pixel = screen::get_color(point).unwrap();
colors.push([pixel[0], pixel[1], pixel[2]]);
}
return colors;
}

这样就获取到一组颜色数组,包含了 RGB 信息。


这里安装了一个叫 autopilot 的包,可以通过 cargo add autopilot 安装,他可以获取屏幕的颜色,也可以操作鼠标。


鼠标操作


使用 autopilot::mouse 可以进行鼠标操作,移动至 x、y 坐标、病点击鼠标左键。


use autopilot::{geometry::Point, mouse};

mouse::move_to(Point::new(x, y));
mouse::click(mouse::Button::Left, );

配置权限


src-tauri/tauri.conf.json 中配置 allowlist,如果不想了解都有哪些权限,直接 all: true,全部配上,以后再慢慢了解。


"tauri": {
"macOSPrivateApi": true,
"allowlist": {
"all": true,
},
}

注意 mac 上如果使用透明窗口,还需要配置 macOSPrivateApi。


整体流程就是这样的,其他都是细节处理,有兴趣可以看下源码。


构建


我爸的电脑是 windows,而我的是 mac,所以需要构建一个 windows 安装包,但是 tauri 依赖本机库和开链,所以想跨平台编译是不可能的,最好的方法就是托管在 GitHub Actions 这种 CI/CD 平台去做。


在项目下创建 .github/workflows/release.yml,它将会在你发布 tag 时触发构建。


name: Release

on:
push:
tags:
- 'v*'
workflow_dispatch:

concurrency:
group: release-${{ github.ref }}
cancel-in-progress: true

jobs:
publish:
strategy:
fail-fast: false
matrix:
platform: [macos-latest, windows-latest]

runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
with:
version: 8
run_install: true

- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'

- name: install Rust stable
uses: actions-rs/toolchain@v1
with:
toolchain: stable

- name: Build Vite + Tauri
run: pnpm build

- name: Create release
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tagName: v__VERSION__ # the action automatically replaces \_\_VERSION\_\_ with the app version
releaseName: 'v__VERSION__'
releaseBody: 'See the assets to download and install this version.'
releaseDraft: true
prerelease: false

这里提供一个实例,具体情况具体修改。


secrets.GITHUB_TOKEN 并不需要你配置,他是自动获取的,主要是获得权限去操作你的仓库。因为构建完成会自动创建 release,并上传安装包。


你还需要修改一下仓库的配置:


image.png


选中 Read and write permissions,勾选
Allow GitHub Actions to create and approve pull requests。


image.png


当你发布 tag 后,会触发 action 执行。


image.png


可见,打包速度真的很慢。


Actions 执行完毕后,进入 Releases 页面,可以看到安装包已经发布。


image.png


总结



  • 关于 taurielectron,甚至是 flutterqt 这种技术方向没必要讨论谁好谁坏,主要还是考虑项目的痛点,去选择适合自己的方式,没必要捧高踩低。

  • Rust 真的很难学,我上文草草几句入门,其实并没有那么简单,刚上手会踩很多坑,甚至无从下手不会写代码。我主要的目的是希望大家有想法就要着手去做,毕竟站在岸上学不会游泳。Flutter 使用 dart,我曾经写写过两个 app,相比于 rustdart 对于前端同学来说可以更轻松的学习。

  • Tauri 我目前还是比较看好,也很看好 rust,大家有时间的话还是值得学习一下,尤其是 2.0 版本还支持了移动端。

  • 看到很多同学,在学习一门语言或技术时,总是不知道做什么,不只是工作,其实我们身边有很多事情都可以去做,可能只是你想不到。我平时真的是喜欢利用代码去搞一些奇奇怪怪的事,例如我写过 vscode 摸鱼插件、自动学习视频的 chrome 插件、互赞平台、小电影爬虫等等,这些都是用 javascript 就实现的。你可以做的很多,给自己提一个需求,然后不要怕踩坑,踩坑的过程是你进步最快的过程,享受它。


作者:codexu
来源:juejin.cn/post/7320288231194755122
收起阅读 »

告别破解版烦恼!Navicat Premium Lite免费版它来了

作为一名后端开发者,在开发过程中使用可视化工具查看数据库中的数据是我们的基本操作。Navicat作为一款广受欢迎的数据库连接工具,深受我们喜爱和挑战。我们喜爱它强大的功能和直观的操作习惯,但又对它的收费模式感到不满。个人使用可以通过破解解决,然而在公司环境下,...
继续阅读 »

作为一名后端开发者,在开发过程中使用可视化工具查看数据库中的数据是我们的基本操作。Navicat作为一款广受欢迎的数据库连接工具,深受我们喜爱和挑战。我们喜爱它强大的功能和直观的操作习惯,但又对它的收费模式感到不满。个人使用可以通过破解解决,然而在公司环境下,由于侵权问题,我们通常被禁止使用,这令我们感到很不便。然而,最近Navicat推出了一款免费的产品——Navicat Premium Lite。


_20240628065825.jpg


Navicat Premium Lite


Navicat Premium Lite 是 Navicat 的精简版,拥有基本数据库操作所需的核心功能。它允许你从单个应用程序同时连接到各种数据库平台,包括 MySQL、Redis、PostgreSQL、SQL Server、Oracle、MariaDB、SQLite 和 MongoDB。Navicat Premium Lite 提供简化的数据库管理体验,使其成为用户的实用选择。


下载地址:https://www.navicat.com.cn/download/direct-download?product=navicat170_premium_lite_cs_x64.exe&location=1


文档地址: https://www.navicat.com.cn/products/navicat-premium-lite


安装及功能对比



  • 由于这个版本是免费版,不需要破解,所以安装我们此处就不多作介绍。

  • 功能对比


功能对比列表地址:https://www.navicat.com.cn/products/navicat-premium-feature-matrix


Navicat Premium Lite 基础功能都是有的,但是和企业版的相比,还是缺失了一些功能,具体大家可查看官网地址,我们此处列举部分


_20240628063823.jpg


_20240628063823.jpg


使用感受


整体使用了下,感觉和破解版使用的差别基本不大,缺失的功能几乎无影响。


_20240628064405.jpg


_20240628064405.jpg


总结


Navicat Premium Lite不仅仅是一款功能全面的数据库管理工具,更是因其免费且功能强大而备受青睐的原因。对于个人开发者、小型团队以及教育用途来说,Navicat Premium Lite提供了一个完全满足需求的解决方案,而无需支付高昂的许可费用。其稳定性、易用性和丰富的功能使得它在数据库管理领域中具备了极高的竞争力。


作者:修己xj
来源:juejin.cn/post/7384997446219743272
收起阅读 »

多级校验、工作流,这样写代码才足够优雅!

责任链模式,简而言之,就是将多个操作组装成一条链路进行处理。 请求在链路上传递,链路上的每一个节点就是一个处理器,每个处理器都可以对请求进行处理,或者传递给链路上的下一个处理器处理。 责任链模式的应用场景,在实际工作中,通常有如下两种应用场景。 操作需要经...
继续阅读 »

责任链模式,简而言之,就是将多个操作组装成一条链路进行处理。


请求在链路上传递,链路上的每一个节点就是一个处理器,每个处理器都可以对请求进行处理,或者传递给链路上的下一个处理器处理。


图片


责任链模式的应用场景,在实际工作中,通常有如下两种应用场景。



  • 操作需要经过一系列的校验,通过校验后才执行某些操作。

  • 工作流。企业中通常会制定很多工作流程,一级一级的去处理任务。


下面通过两个案例来学习一下责任链模式。


案例一:创建商品多级校验场景


以创建商品为例,假设商品创建逻辑分为以下三步完成:


①创建商品、


②校验商品参数、


③保存商品。


第②步校验商品又分为多种情况的校验,必填字段校验、规格校验、价格校验、库存校验等等。


这些检验逻辑像一个流水线,要想创建出一个商品,必须通过这些校验。如下流程图所示:


图片


图片


伪代码如下:


创建商品步骤,需要经过一系列的参数校验,如果参数校验失败,直接返回失败的结果;通过所有的参数校验后,最终保存商品信息。


图片


图片


如上代码看起来似乎没什么问题,它非常工整,而且代码逻辑很清晰。



PS:我没有把所有的校验代码都罗列在一个方法里,那样更能产生对比性,但我觉得抽象并分离单一职责的函数应该是每个程序员最基本的规范!



但是随着业务需求不断地叠加,相关的校验逻辑也越来越多,新的功能使代码越来越臃肿,可维护性较差。


更糟糕的是,这些校验组件不可复用,当你有其他需求也需要用到一些校验时,你又变成了Ctrl+C , Ctrl+V程序员,系统的维护成本也越来越高。如下图所示:


图片


图片


伪代码同上,这里就不赘述了。


终于有一天,你忍无可忍了,决定重构这段代码。


使用责任链模式优化:创建商品的每个校验步骤都可以作为一个单独的处理器,抽离为一个单独的类,便于复用。


这些处理器形成一条链式调用,请求在处理器链上传递,如果校验条件不通过,则处理器不再向下传递请求,直接返回错误信息;若所有的处理器都通过检验,则执行保存商品步骤。


图片


图片


案例一实战:责任链模式实现创建商品校验


UML图:一览众山小


图片


图片


AbstractCheckHandler表示处理器抽象类,负责抽象处理器行为。其有3个子类,分别是:



  • NullValueCheckHandler:空值校验处理器

  • PriceCheckHandler:价格校验处理

  • StockCheckHandler:库存校验处理器


AbstractCheckHandler 抽象类中, handle()定义了处理器的抽象方法,其子类需要重写handle()方法以实现特殊的处理器校验逻辑;


protected ProductCheckHandlerConfig config 是处理器的动态配置类,使用protected声明,每个子类处理器都持有该对象。


该对象用于声明当前处理器、以及当前处理器的下一个处理器nextHandler,另外也可以配置一些特殊属性,比如说接口降级配置、超时时间配置等。


AbstractCheckHandler nextHandler 是当前处理器持有的下一个处理器的引用,当前处理器执行完毕时,便调用nextHandler执行下一处理器的handle()校验方法;


protected Result next() 是抽象类中定义的,执行下一个处理器的方法,使用protected声明,每个子类处理器都持有该对象。


当子类处理器执行完毕(通过)时,调用父类的方法执行下一个处理器nextHandler。


HandlerClient 是执行处理器链路的客户端,HandlerClient.executeChain()方法负责发起整个链路调用,并接收处理器链路的返回值。


商品参数对象:保存商品的入参


ProductVO是创建商品的参数对象,包含商品的基础信息。


并且其作为责任链模式中多个处理器的入参,多个处理器都以ProductVO为入参进行特定的逻辑处理。


实际业务中,商品对象特别复杂。咱们化繁为简,简化商品参数如下:


/**
 * 商品对象
 */

@Data
@Builder
public class ProductVO {
    /**
     * 商品SKU,唯一
     */

    private Long skuId;
    /**
     * 商品名称
     */

    private String skuName;
    /**
     * 商品图片路径
     */

    private String Path;
    /**
     * 价格
     */

    private BigDecimal price;
    /**
     * 库存
     */

    private Integer stock;
}

抽象类处理器:抽象行为,子类共有属性、方法


AbstractCheckHandler:处理器抽象类,并使用@Component注解注册为由Spring管理的Bean对象,这样做的好处是,我们可以轻松的使用Spring来管理这些处理器Bean。


/**
 * 抽象类处理器
 */

@Component
public abstract class AbstractCheckHandler {

    /**
     * 当前处理器持有下一个处理器的引用
     */

    @Getter
    @Setter
    protected AbstractCheckHandler nextHandler;


    /**
     * 处理器配置
     */

    @Setter
    @Getter
    protected ProductCheckHandlerConfig config;

    /**
     * 处理器执行方法
     * @param param
     * @return
     */

    public abstract Result handle(ProductVO param);

    /**
     * 链路传递
     * @param param
     * @return
     */

    protected Result next(ProductVO param) {
        //下一个链路没有处理器了,直接返回
        if (Objects.isNull(nextHandler)) {
            return Result.success();
        }

        //执行下一个处理器
        return nextHandler.handle(param);
    }

}

在AbstractCheckHandler抽象类处理器中,使用protected声明子类可见的属性和方法。


使用 @Component注解,声明其为Spring的Bean对象,这样做的好处是可以利用Spring轻松管理所有的子类,下面会看到如何使用。


抽象类的属性和方法说明如下:



  • public abstract Result handle():表示抽象的校验方法,每个处理器都应该继承AbstractCheckHandler抽象类处理器,并重写其handle方法,各个处理器从而实现特殊的校验逻辑,实际上就是多态的思想。

  • protected ProductCheckHandlerConfig config:表示每个处理器的动态配置类,可以通过“配置中心”动态修改该配置,实现处理器的“动态编排”和“顺序控制”。配置类中可以配置处理器的名称、下一个处理器、以及处理器是否降级等属性。

  • protected AbstractCheckHandler nextHandler:表示当前处理器持有下一个处理器的引用,如果当前处理器handle()校验方法执行完毕,则执行下一个处理器nextHandler的handle()校验方法执行校验逻辑。

  • protected Result next(ProductVO param):此方法用于处理器链路传递,子类处理器执行完毕后,调用父类的next()方法执行在config 配置的链路上的下一个处理器,如果所有处理器都执行完毕了,就返回结果了。


ProductCheckHandlerConfig配置类 :


/**
 * 处理器配置类
 */

@AllArgsConstructor
@Data
public class ProductCheckHandlerConfig {
    /**
     * 处理器Bean名称
     */

    private String handler;
    /**
     * 下一个处理器
     */

    private ProductCheckHandlerConfig next;
    /**
     * 是否降级
     */

    private Boolean down = Boolean.FALSE;
}

子类处理器:处理特有的校验逻辑


AbstractCheckHandler抽象类处理器有3个子类分别是:



  • NullValueCheckHandler:空值校验处理器

  • PriceCheckHandler:价格校验处理

  • StockCheckHandler:库存校验处理器


各个处理器继承AbstractCheckHandler抽象类处理器,并重写其handle()处理方法以实现特有的校验逻辑。


NullValueCheckHandler:空值校验处理器。针对性校验创建商品中必填的参数。如果校验未通过,则返回错误码ErrorCode,责任链在此截断(停止),创建商品返回被校验住的错误信息。注意代码中的降级配置!


super.getConfig().getDown()是获取AbstractCheckHandler处理器对象中保存的配置信息,如果处理器配置了降级,则跳过该处理器,调用super.next()执行下一个处理器逻辑。


同样,使用@Component注册为由Spring管理的Bean对象,


/**
 * 空值校验处理器
 */

@Component
public class NullValueCheckHandler extends AbstractCheckHandler{

    @Override
    public Result handle(ProductVO param) {
        System.out.println("空值校验 Handler 开始...");
        
        //降级:如果配置了降级,则跳过此处理器,执行下一个处理器
        if (super.getConfig().getDown()) {
            System.out.println("空值校验 Handler 已降级,跳过空值校验 Handler...");
            return super.next(param);
        }
        
        //参数必填校验
        if (Objects.isNull(param)) {
            return Result.failure(ErrorCode.PARAM_NULL_ERROR);
        }
        //SkuId商品主键参数必填校验
        if (Objects.isNull(param.getSkuId())) {
            return Result.failure(ErrorCode.PARAM_SKU_NULL_ERROR);
        }
        //Price价格参数必填校验
        if (Objects.isNull(param.getPrice())) {
            return Result.failure(ErrorCode.PARAM_PRICE_NULL_ERROR);
        }
        //Stock库存参数必填校验
        if (Objects.isNull(param.getStock())) {
            return Result.failure(ErrorCode.PARAM_STOCK_NULL_ERROR);
        }
        
        System.out.println("空值校验 Handler 通过...");
        
        //执行下一个处理器
        return super.next(param);
    }
}

PriceCheckHandler:价格校验处理。


针对创建商品的价格参数进行校验。这里只是做了简单的判断价格>0的校验,实际业务中比较复杂,比如“价格门”这些防范措施等。


/**
 * 价格校验处理器
 */

@Component
public class PriceCheckHandler extends AbstractCheckHandler{
    @Override
    public Result handle(ProductVO param) {
        System.out.println("价格校验 Handler 开始...");

        //非法价格校验
        boolean illegalPrice =  param.getPrice().compareTo(BigDecimal.ZERO) <= 0;
        if (illegalPrice) {
            return Result.failure(ErrorCode.PARAM_PRICE_ILLEGAL_ERROR);
        }
        //其他校验逻辑...

        System.out.println("价格校验 Handler 通过...");

        //执行下一个处理器
        return super.next(param);
    }
}

StockCheckHandler:库存校验处理器。


针对创建商品的库存参数进行校验。


/**
 * 库存校验处理器
 */

@Component
public class StockCheckHandler extends AbstractCheckHandler{
    @Override
    public Result handle(ProductVO param) {
        System.out.println("库存校验 Handler 开始...");

        //非法库存校验
        boolean illegalStock = param.getStock() < 0;
        if (illegalStock) {
            return Result.failure(ErrorCode.PARAM_STOCK_ILLEGAL_ERROR);
        }
        //其他校验逻辑..

        System.out.println("库存校验 Handler 通过...");

        //执行下一个处理器
        return super.next(param);
    }
}

客户端:执行处理器链路


HandlerClient客户端类负责发起整个处理器链路的执行,通过executeChain()方法。


如果处理器链路返回错误信息,即校验未通过,则整个链路截断(停止),返回相应的错误信息。


public class HandlerClient {

  public static Result executeChain(AbstractCheckHandler handler, ProductVO param) {
      //执行处理器
      Result handlerResult = handler.handle(param);
      if (!handlerResult.isSuccess()) {
          System.out.println("HandlerClient 责任链执行失败返回:" + handlerResult.toString());
          return handlerResult;
      }
      return Result.success();
  }
}

以上,责任链模式相关的类已经创建好了。


接下来就可以创建商品了。


创建商品:抽象步骤,化繁为简


createProduct()创建商品方法抽象为2个步骤:①参数校验、②创建商品。


参数校验使用责任链模式进行校验,包含:空值校验、价格校验、库存校验等等,只有链上的所有处理器均校验通过,才调用saveProduct()创建商品方法;否则返回校验错误信息。


createProduct()创建商品方法中,通过责任链模式,我们将校验逻辑进行解耦。createProduct()创建商品方法中不需要关注都要经过哪些校验处理器,以及校验处理器的细节。


/**
 * 创建商品
 * 
@return
 */

@Test
public Result createProduct(ProductVO param) {

    //参数校验,使用责任链模式
    Result paramCheckResult = this.paramCheck(param);
    if (!paramCheckResult.isSuccess()) {
        return paramCheckResult;
    }

    //创建商品
    return this.saveProduct(param);
}

参数校验:责任链模式


参数校验paramCheck()方法使用责任链模式进行参数校验,方法内没有声明具体都有哪些校验,具体有哪些参数校验逻辑是通过多个处理器链传递的。如下:


/**
 * 参数校验:责任链模式
 * 
@param param
 * 
@return
 */

private Result paramCheck(ProductVO param) {

    //获取处理器配置:通常配置使用统一配置中心存储,支持动态变更
    ProductCheckHandlerConfig handlerConfig = this.getHandlerConfigFile();

    //获取处理器
    AbstractCheckHandler handler = this.getHandler(handlerConfig);

    //责任链:执行处理器链路
    Result executeChainResult = HandlerClient.executeChain(handler, param);
    if (!executeChainResult.isSuccess()) {
        System.out.println("创建商品 失败...");
        return executeChainResult;
    }

    //处理器链路全部成功
    return Result.success();
}

paramCheck()方法步骤说明如下:


👉 步骤1:获取处理器配置。


通过getHandlerConfigFile()方法获取处理器配置类对象,配置类保存了链上各个处理器的上下级节点配置,支持流程编排、动态扩展。


通常配置是通过Ducc(京东自研的配置中心)、Nacos(阿里开源的配置中心)等配置中心存储的,支持动态变更、实时生效。


基于此,我们便可以实现校验处理器的编排、以及动态扩展了。


我这里没有使用配置中心存储处理器链路的配置,而是使用JSON串的形式去模拟配置,大家感兴趣的可以自行实现。


/**
 * 获取处理器配置:通常配置使用统一配置中心存储,支持动态变更
 * @
return
 */

private ProductCheckHandlerConfig getHandlerConfigFile() {
    //配置中心存储的配置
    String configJson = "{"handler":"nullValueCheckHandler","down":true,"next":{"handler":"priceCheckHandler","next":{"handler":"stockCheckHandler","next":null}}}";
    //转成Config对象
    ProductCheckHandlerConfig handlerConfig = JSON.parseObject(configJson, ProductCheckHandlerConfig.class);
    return handlerConfig;
}

ConfigJson存储的处理器链路配置JSON串,在代码中可能不便于观看,我们可以使用json.cn等格式化看一下,如下,配置的整个调用链路规则特别清晰。


图片


图片


getHandlerConfigFile()类获到配置类的结构如下,可以看到,就是把在配置中心储存的配置规则,转换成配置类ProductCheckHandlerConfig对象,用于程序处理。



注意,此时配置类中存储的仅仅是处理器Spring Bean的name而已,并非实际处理器对象。



图片


图片


接下来,通过配置类获取实际要执行的处理器。


👉 步骤2:根据配置获取处理器。


上面步骤1通过getHandlerConfigFile()方法获取到处理器链路配置规则后,再调用getHandler()获取处理器。


getHandler()参数是如上ConfigJson配置的规则,即步骤1转换成的ProductCheckHandlerConfig对象;


根据ProductCheckHandlerConfig配置规则转换成处理器链路对象。代码如下:


 * 使用Spring注入:所有继承了AbstractCheckHandler抽象类的Spring Bean都会注入进来。Map的Key对应Bean的name,Value是name对应相应的Bean
 */
@Resource
private Map handlerMap;

/**
 * 获取处理器
 * 
@param config
 * 
@return
 */

private AbstractCheckHandler getHandler (ProductCheckHandlerConfig config) {
    //配置检查:没有配置处理器链路,则不执行校验逻辑
    if (Objects.isNull(config)) {
        return null;
    }
    //配置错误
    String handler = config.getHandler();
    if (StringUtils.isBlank(handler)) {
        return null;
    }
    //配置了不存在的处理器
    AbstractCheckHandler abstractCheckHandler = handlerMap.get(config.getHandler());
    if (Objects.isNull(abstractCheckHandler)) {
        return null;
    }
    
    //处理器设置配置Config
    abstractCheckHandler.setConfig(config);
    
    //递归设置链路处理器
    abstractCheckHandler.setNextHandler(this.getHandler(config.getNext()));

    return abstractCheckHandler;
}

👉 👉 步骤2-1:配置检查。


代码14~27行,进行了配置的一些检查操作。如果配置错误,则获取不到对应的处理器。代码23行handlerMap.get(config.getHandler())是从所有处理器映射Map中获取到对应的处理器Spring Bean。



注意第5行代码,handlerMap存储了所有的处理器映射,是通过Spring @Resource注解注入进来的。注入的规则是:所有继承了AbstractCheckHandler抽象类(它是Spring管理的Bean)的子类(子类也是Spring管理的Bean)都会注入进来。



注入进来的handlerMap中 Map的Key对应Bean的name,Value是name对应的Bean实例,也就是实际的处理器,这里指空值校验处理器、价格校验处理器、库存校验处理器。如下:


图片


图片


这样根据配置ConfigJson(👉 步骤1:获取处理器配置)中handler:"priceCheckHandler"的配置,使用handlerMap.get(config.getHandler())便可以获取到对应的处理器Spring Bean对象了。


👉 👉 步骤2-2:保存处理器规则。


代码29行,将配置规则保存到对应的处理器中abstractCheckHandler.setConfig(config),子类处理器就持有了配置的规则。


👉 👉 步骤2-3:递归设置处理器链路。


代码32行,递归设置链路上的处理器。


//递归设置链路处理器 abstractCheckHandler.setNextHandler(this.getHandler(config.getNext()));

这一步可能不太好理解,结合ConfigJson配置的规则来看,似乎就很很容易理解了。


图片


图片


由上而下,NullValueCheckHandler 空值校验处理器通过setNextHandler()方法设置自己持有的下一节点的处理器,也就是价格处理器PriceCheckHandler。


接着,PriceCheckHandler价格处理器,同样需要经过步骤2-1配置检查、步骤2-2保存配置规则,并且最重要的是,它也需要设置下一节点的处理器StockCheckHandler库存校验处理器。


StockCheckHandler库存校验处理器也一样,同样需要经过步骤2-1配置检查、步骤2-2保存配置规则,但请注意StockCheckHandler的配置,它的next规则配置了null,这表示它下面没有任何处理器要执行了,它就是整个链路上的最后一个处理节点。


通过递归调用getHandler()获取处理器方法,就将整个处理器链路对象串联起来了。如下:


图片


图片



友情提示:递归虽香,但使用递归一定要注意截断递归的条件处理,否则可能造成死循环哦!



实际上,getHandler()获取处理器对象的代码就是把在配置中心配置的规则ConfigJson,转换成配置类ProductCheckHandlerConfig对象,再根据配置类对象,转换成实际的处理器对象,这个处理器对象持有整个链路的调用顺序。


👉 步骤3:客户端执行调用链路。


public class HandlerClient {

  public static Result executeChain(AbstractCheckHandler handler, ProductVO param) {
      //执行处理器
      Result handlerResult = handler.handle(param);
      if (!handlerResult.isSuccess()) {
          System.out.println("HandlerClient 责任链执行失败返回:" + handlerResult.toString());
          return handlerResult;
      }
      return Result.success();
  }
}

getHandler()获取完处理器后,整个调用链路的执行顺序也就确定了,此时,客户端该干活了!


HandlerClient.executeChain(handler, param)方法是HandlerClient客户端类执行处理器整个调用链路的,并接收处理器链路的返回值。


executeChain()通过AbstractCheckHandler.handle()触发整个链路处理器顺序执行,如果某个处理器校验没有通过!handlerResult.isSuccess(),则返回错误信息;所有处理器都校验通过,则返回正确信息Result.success()


总结:串联方法调用流程


基于以上,再通过流程图来回顾一下整个调用流程。


图片


图片


测试:代码执行结果


场景1:创建商品参数中有空值(如下skuId参数为null),链路被空值处理器截断,返回错误信息


//创建商品参数
ProductVO param = ProductVO.builder()
      .skuId(null).skuName("华为手机").Path("http://...")
      .price(new BigDecimal(1))
      .stock(1)
      .build();

测试结果


图片


图片


场景2:创建商品价格参数异常(如下price参数),被价格处理器截断,返回错误信息


ProductVO param = ProductVO.builder()
      .skuId(1L).skuName("华为手机").Path("http://...")
      .price(new BigDecimal(-999))
      .stock(1)
      .build();

测试结果


图片


图片


场景 3:创建商品库存参数异常(如下stock参数),被库存处理器截断,返回错误信息。


//创建商品参数,模拟用户传入
ProductVO param = ProductVO.builder()
      .skuId(1L).skuName("华为手机").Path("http://...")
      .price(new BigDecimal(1))
      .stock(-999)
      .build();

测试结果


图片


图片


场景4:创建商品所有处理器校验通过,保存商品。


![15](C:\Users\18796\Desktop\文章\15.png)![15](C:\Users\18796\Desktop\文章\15.png)![15](C:\Users\18796\Desktop\文章\15.png)![15](C:\Users\18796\Desktop\文章\15.png)//创建商品参数,模拟用户传入
ProductVO param = ProductVO.builder()
      .skuId(1L).skuName("华为手机").Path("http://...")
      .price(new BigDecimal(999))
      .stock(1).build();

测试结果


图片


责任链的优缺点


图片


图片


作者:程序员蜗牛
来源:juejin.cn/post/7384632888321179659
收起阅读 »

第一次使用缓存,因为没预热,翻车了

缓存不预热会怎么样?我帮大家淌了路。缓存不预热会导致系统接口性能下降,数据库压力增加,更重要的是导致我写了两天的复盘文档,在复盘会上被骂出了翔。 悲惨的上线时刻 事情发生在几年前,我刚毕业时,第一次使用缓存内心很激动。需求场景是虚拟商品页面需要向用户透出库存状...
继续阅读 »

缓存不预热会怎么样?我帮大家淌了路。缓存不预热会导致系统接口性能下降,数据库压力增加,更重要的是导致我写了两天的复盘文档,在复盘会上被骂出了翔。


悲惨的上线时刻


事情发生在几年前,我刚毕业时,第一次使用缓存内心很激动。需求场景是虚拟商品页面需要向用户透出库存状态,提单时也需要校验库存状态是否可售卖。但是由于库存状态的计算包含较复杂的业务逻辑,耗时比较高,在500ms以上。如果要在商品页面透出库存状态那么商品页面耗时增加500ms,这几乎是无法忍受的事情。


如何实现呢?最合适的方案当然是缓存了,我当时设计的方案是如果缓存有库存状态直接读缓存,如果缓存查不到,则计算库存状态,然后加载进缓存,同时设定过期时间。何时写库存呢? 答案是过期后,cache miss时重新加载进缓存。 由于计算逻辑较复杂,库存扣减等用户写操作没有同步更新缓存,但是产品认可库存状态可以有几分钟的状态不一致。为什么呢?


因为仓库有冗余库存,就算库存状态不一致导致超卖,也能容忍。同时库存不足以后,需要运营补充库存,而补充库存的时间是肯定比较长的。虽然补充库存完成几分钟后,才变为可售卖的,产品也能接受。 梳理完缓存的读写方案,我就沉浸于学习Redis的过程。


第一次使用缓存,我把时间和精力都放在Redis存储结构,Redis命令,Redis为什么那么快等方面的关注。如饥似渴的学习Redis知识。


直到上线阶段我也没有意识到系统设计的缺陷。


代码写的很快,测试验证也没有问题。然而上线过程中,就开始噼里啪啦的报警,开始我并没有想到报警这事和我有关。直到有人问我,“XXX,你是不是在上线库存状态的需求?”。


我人麻了,”怎么了,啥事”,我颤抖的问


“商品页面耗时暴涨,赶紧回滚”。一个声音传来


“我草”,那一瞬间,我的血压上涌,手心发痒,心跳加速,头皮发麻,颤抖的手不知道怎么在发布系统点回滚,“我没回滚过啊,咋回滚啊?”


“有降级开关吗”? 一个声音传来。


"没写..."。我回答的时候觉得自己真是二笔,为啥没加降级啊。(这也是复盘被骂的重要原因)


那么如何对缓存进行预热呢?


如何预热缓存


灰度放量


灰度放量实际上并不是缓存预热的办法,但是确实能避免缓存雪崩的问题。例如这个需求场景中,如果我没有放开全量数据,而是选择放量1%的流量。这样系统的性能不会有较大的下降,并且逐步放量到100%。


虽然这个过程中,没有主动同步数据到缓存,但是通过控制放量的节奏,保证了初始化缓存过程中,不会出现较大的耗时波动。


例如新上线的缓存逻辑,可以考虑逐渐灰度放量。


扫描数据库刷缓存


如果缓存维度是商品维度或者用户维度,可以考虑扫描数据库,提前预热部分数据到缓存中。


开发成本较高。除了开发缓存部分的代码,还需要开发扫描全表的任务。为了控制缓存刷新的进度,还需要使用线程池增加并发,使用限流器限制并发。这个方案的开发成本较高。


通过数据平台刷缓存


这是比较好的方式,具体怎么实现呢?


数据平台如果支持将数据库离线数据同步到Hive,Hive数据同步到Kafka,我们就可以编写Hive SQL,建立ETL任务。把业务需要被刷新的数据同步到Kafka中,再消费Kafka,把数据写入到缓存中。在这个过程中通过数据平台控制并发度,通过Kafka 分片和消费线程并发度控制 缓存写入的速率。


这个方案开发逻辑包括ETL 任务,消费Kafka写入缓存。这两部分的开发工作量不大。并且相比扫描全表任务,ETL可以编写更加复杂的SQL,修改后立即上线,无需自己控制并发、控制限流。在多个方面ETL刷缓存效率更高。


但是这个方案需要公司级别支持 多个存储系统之间可以进行数据同步。例如mysql、kafka、hive等。


除了首次上线,是否还有其他场景需要预热缓存呢?


需要预热缓存的其他场景


如果Redis挂了,数据怎么办


刚才提到上线前,一定要进行缓存预热。还有一个场景:假设Redis挂了,怎么办?全量的缓存数据都没有了,全部请求同时打到数据库,怎么办。


除了首次上线需要预热缓存,实际上如果缓存数据丢失后,也需要预热缓存。所以预热缓存的任务一定要开发的,一方面是上线前预热缓存,同时也是为了保证缓存挂掉后,也能重新预热缓存。


假如有大量数据冷启动怎么办


假如促销场景,例如春节抢红包,平时非活跃用户会在某个时间点大量打开App,这也会导致大量cache miss,进而导致雪崩。 此时就需要提前预热缓存了。具体的办法,可以考虑使用ETL任务。离线加载大量数据到Kafka,然后再同步到缓存。


总结



  1. 一定要预热缓存,不然线上接口性能和数据库真的扛不住。

  2. 可以通过灰度放量,扫描全表、ETL数据同步等方式预热缓存

  3. Redis挂了,大量用户冷启动的促销场景等场景都需要提前预热缓存。


作者:五阳
来源:juejin.cn/post/7277461864349777972
收起阅读 »

半夜被慢查询告警吵醒,limit深度分页的坑

故事梅雨季,闷热的夜,令人窒息,窗外一道道闪电划破漆黑的夜幕,小猫塞着耳机听着恐怖小说,辗转反侧,终于睡意来了,然而挨千刀的手机早不振晚不振,偏偏这个时候振动了一下,一个激灵,没有按捺住对内容的好奇,点开了短信,卧槽?告警信息,原来是负责的服务出现慢查询了。小...
继续阅读 »

故事

梅雨季,闷热的夜,令人窒息,窗外一道道闪电划破漆黑的夜幕,小猫塞着耳机听着恐怖小说,辗转反侧,终于睡意来了,然而挨千刀的手机早不振晚不振,偏偏这个时候振动了一下,一个激灵,没有按捺住对内容的好奇,点开了短信,卧槽?告警信息,原来是负责的服务出现慢查询了。小猫想起来,今天在下班之前上线了一个版本,由于新增了一个业务字段,所以小猫写了相关的刷数据的接口,在下班之前调用开始刷历史数据。

考虑到表的数据量比较大,一次性把数据全部读取出来然后在内存里面去刷新数据肯定是不现实的,所以小猫采用了分页查询的方式依次根据条件查询出结果,然后进行表数据的重置。没想到的是,数据量太大,分页的深度越来越深,渐渐地,慢查询也就暴露出来了。

慢查询告警

强迫症小猫瞬间睡意全无,翻起来打开电脑开始解决问题。

那么为什么用使用limit之后会出现慢查询呢?接下来老猫和大家一起来剖析一下吧。

剖析流程

limit分页为什么会变慢?

在解释为什么慢之前,咱们来重现一下小猫的慢查询场景。咱们从实际的例子推进。

做个小实验

假设我们有一张这样的业务表,商品Product表。具体的建表语句如下:

CREATE TABLE `Product` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`type` tinyint(3) unsigned NOT NULL DEFAULT '1' ,
`spuCode` varchar(50) NOT NULL DEFAULT '' ,
`spuName` varchar(100) NOT NULL DEFAULT '' ,
`spuTitle` varchar(300) NOT NULL DEFAULT '' ,
`channelId` bigint(20) unsigned NOT NULL DEFAULT '0',
`sellerId` bigint(20) unsigned NOT NULL DEFAULT '0'
`mallSpuCode` varchar(32) NOT NULL DEFAULT '',
`originCategoryId` bigint(20) unsigned NOT NULL DEFAULT '0' ,
`originCategoryName` varchar(50) NOT NULL DEFAULT '' ,
`marketPrice` decimal(10,2) unsigned NOT NULL DEFAULT '0.00',
`status` tinyint(3) unsigned NOT NULL DEFAULT '1' ,
`isDeleted` tinyint(3) unsigned NOT NULL DEFAULT '0',
`timeCreated` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`timeModified` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) ,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `uk_spuCode` (`spuCode`,`channelId`,`sellerId`),
KEY `idx_timeCreated` (`timeCreated`),
KEY `idx_spuName` (`spuName`),
KEY `idx_channelId_originCategory` (`channelId`,`originCategoryId`,`originCategoryName`) USING BTREE,
KEY `idx_sellerId` (`sellerId`)
) ENGINE=InnoDB AUTO_INCREMENT=12553120 DEFAULT CHARSET=utf8mb4 COMMENT='商品表'

从上述建表语句中我们发现timeCreated走普通索引。 接下来我们根据创建时间来执行一下分页查询:

当为浅分页的时候,如下:

select * from Product where timeCreated > "2020-09-12 13:34:20" limit 0,10

此时执行的时间为: "executeTimeMillis":1

当调整分页查询为深度分页之后,如下:

select * from Product where timeCreated > "2020-09-12 13:34:20" limit 10000000,10

此时深度分页的查询时间为: "executeTimeMillis":27499

此时看到这里,小猫的场景已经重现了,此时深度分页的查询已经非常耗时。

剖析一下原因

简单回顾一下普通索引和聚簇索引

我们来回顾一下普通索引和聚簇索引(也有人叫做聚集索引)的关系。

大家可能都知道Mysql底层用的数据结构是B+tree(如果有不知道的伙伴可以自己了解一下为什么mysql底层是B+tree),B+tree索引其实可以分为两大类,一类是聚簇索引,另外一类是非聚集索引(即普通索引)。

(1)聚簇索引:InnoDB存储表是索引组织表,聚簇索引就是一种索引组织形式,聚簇索引叶子节点存放表中所有行数据记录的信息,所以经常会说索引即数据,数据即索引。当然这个是针对聚簇索引。

02.png

由图可知在执行查询的时候,从根节点开始共经历了3次查询即可找到真实数据。倘若没有聚簇索引的话,就需要在磁盘上进行逐个扫描,直至找到数据为止。显然,索引会加快查询速度,但是在写入数据的时候,由于需要维护这颗B+树,因此在写入过程中性能也会下降。

(2)普通索引:普通索引在叶子节点并不包含所有行的数据记录,只是会在叶子节点存本身的键值和主键的值,在检索数据的时候,通过普通索引子节点上的主键来获取想要找到的行数据记录。

03.png

由图可知流程,首先从非聚簇索引开始寻找聚簇索引,找到非聚簇索引上的聚簇索引后,就会到聚簇索引的B+树上进行查询,通过聚簇索引B+树找到完整的数据。该过程比较专业的叫法也被称为“回表”。

看一下实际深度分页执行过程

有了以上的知识基础我们再来回过头看一下上述深度分页SQL的执行过程。 上述的查询语句中idx_timeCreated显然是普通索引,咱们结合上述的知识储备点,其深度分页的执行就可以拆分为如下步骤:

1、通过普通索引idx_timeCreated,过滤timeCreated,找到满足条件的记录ID;

2、通过ID,回到主键索引树,找到满足记录的行,然后取出展示的列(回表);

3、扫描满足条件的10000010行,然后扔掉前10000000行,返回。

结合看一下执行计划:

04.png

原因其实很清晰了: 显然,导致这句SQL速度慢的问题出现在第2步。其中发生了10000010次回表,这前面的10000000条数据完全对本次查询没有意义,但是却占据了绝大部分的查询时间。

再深入一点从底层存储来看,数据库表中行数据、索引都是以文件的形式存储到磁盘(硬盘)上的,而硬盘的速度相对来说要慢很多,存储引擎运行sql语句时,需要访问硬盘查询文件,然后返回数据给服务层。当返回的数据越多时,访问磁盘的次数就越多,就会越耗时。

替换limit分页的一些方案。

上述我们其实已经搞清楚深度分页慢的原因了,总结为“无用回表次数过多”。

那怎么优化呢?相信大家应该都已经知道了,其核心当然是减少无用回表次数了。

有哪些方式可以帮助我们减少无用回表次数呢?

子查询法

思路:如果把查询条件,转移回到主键索引树,那就不就可以减少回表次数了。 所以,咱们将实际的SQL改成下面这种形式:

select * FROM Product where id >= (select p.id from Product p where p.timeCreated > "2020-09-12 13:34:20" limit 10000000, 1) LIMIT 10;

测试一下执行时间: "executeTimeMillis":2534

我们可以明显地看到相比之前的27499,时间整整缩短了十倍,在结合执行计划观察一下。

05.png

我们综合上述的执行计划可以看出,子查询 table p查询是用到了idx_timeCreated索引。首先在索引上拿到了聚集索引的主键ID,省去了回表操作,然后第二查询直接根据第一个查询的 ID往后再去查10个就可以了!

显然这种优化方式是有效的。

使用inner join方式进行优化

这种优化的方式其实和子查询优化方法如出一辙,其本质优化思路和子查询法一样。 我们直接来看一下优化之后的SQL:

select * from Product p1 inner join (select p.id from Product p where p.timeCreated > "2020-09-12 13:34:20" limit 10000000,10) as p2 on p1.id = p2.id

测试一下执行的时间: "executeTimeMillis":2495

06.png

咱们发现和子查询的耗时其实差不多,该思路是先通过idx_timeCreated二级索引树查询到满足条件的主键ID,再与原表通过主键ID内连接,这样后面直接走了主键索引了,同时也减少了回表。

上面两种方式其核心优化思想都是减少回表次数进行优化处理。

标签记录法(锚点记录法)

我们再来看下一种优化思路,上述深度分页慢原因我们也清楚了,一次性查询的数据太多也是问题,所以我们从这个点出发去优化,每次查询少量的数据。那么我们可以采用下面那种锚点记录的方式。类似船开到一个地方短暂停泊之后继续行驶,那么那个停泊的地方就是抛锚的地方,老猫喜欢用锚点标记来做比方,当然看到网上有其他的小伙伴称这种方式为标签记录法。其实意思也都差不多。

这种方式就是标记一下上次查询到哪一条了,下次再来查的时候,从该条开始往下扫描。我们直接看一下SQL:

select * from Product p where p.timeCreated > "2020-09-12 13:34:20" and id>10000000 limit 10

显然,这种方式非常快,耗时如下: "executeTimeMillis":1

但是这种方式显然是有缺陷的,大家想想如果我们的id不是连续的,或者说不是自增形式的,那么我们得到的数据就一定是不准确的。与此同时咱们也不能跳页查看,只能前后翻页。

当然存在相同的缺陷,我们还可以换一种写法。

select * from Product p where p.timeCreated > "2020-09-12 13:34:20" and id between 10000000 and 10000010  

这种方式也是一样存在上述缺陷,另外的话更要注意的是between ...and语法是两头都是闭区域间。上述语句如果ID连续不断地情况下,咱们最终得到的其实是11条数据,并不是10条数据,所以这个地方还是需要注意的。

存入到es中

上述罗列的几种分页优化的方法其实已经够用了,那么如果数据量再大点的话咋整,那么我们可能就要选择其他中间件进行查询了,当然我们可以选择es。那么es真的就是万能药吗?显然不是。ES中同样存在深度分页的问题,那么针对es的深度分页,那么又是另外一个故事了,这里咱们就不展开了。

写到最后

那么半夜三更爬起来优化慢查询的小猫究竟有没有解决问题呢?电脑前,小猫长吁了一口气,解决了! 我们看下小猫的优化方式:

select * from InventorySku isk inner join (select id from InventorySku where inventoryId = 6058 limit 109500,500 ) as d on isk.id = d.id

显然小猫采用了inner join的优化方法解决了当前的问题。

相信小伙伴们后面遇到这类问题也能搞定了。


作者:程序员老猫
来源:juejin.cn/post/7384652811554308147
收起阅读 »

零成本搭建个人图床服务器

前言 图床服务器是一种用于存储和管理图片的服务器,可以给我们提供将图片上传后能外部访问浏览的服务。这样我们在写文章时插入的说明图片,就可以集中放到图床里,既方便多平台文章发布,又能统一管理和备份。 当然下面通过在 GitHub 上搭建的图床,不光不用成本,而且...
继续阅读 »

前言


图床服务器是一种用于存储和管理图片的服务器,可以给我们提供将图片上传后能外部访问浏览的服务。这样我们在写文章时插入的说明图片,就可以集中放到图床里,既方便多平台文章发布,又能统一管理和备份。


当然下面通过在 GitHub 上搭建的图床,不光不用成本,而且还能上传视频或音乐。操作方法和以前在 GitHub 上搭建静态博客类似,但是中间会多一些一些工具介绍和技巧。


流程



  • 创建仓库

  • 设置仓库

  • 连接仓库

  • 应用 Typora


创建仓库


创建仓库和平时的代码托管一样,添加一个 public 权限仓库,用默认的 main 分支。当然也可以提前创建一个目录,但是根目录最好有一个 index.html。



设置仓库


设置仓库主要是添加提交 Token,和配置 GitHub Pages 参数。而这两小步的设置,在前面文章 "Hexo 博客搭建" 有比较详细介绍,所以这里就稍微文字带过了。


Token 生成


登陆 GitHub -> Settings -> Developer settings -> Personal access tokens -> Tokens (classic),然后点击 "Generate new token",填写备注和过期时间,权限主要勾选 "repo"、"workflow"、"user"。最后生成 "ghp_" 前缀的字符串就是 Token 了,复制并保存下来。


GitHub Pages 配置


进入仓库页 -> Settings -> Pages,设置 Branch,指定仓库的分支和分支根目录,Source 选择 "Deploy from a branch",最后刷新或者重新进入,把访问链接地址复制保存下来。



连接仓库


连接可以除了 API 方式,也可以用第三方的工具,比如 "PicGo"。工具位置自行搜索哈,下面以他为例,演示工具的连接配置、文件上传和访问测试。


连接配置


找到 "图床设置" -> "GitHub",下面主要填写仓库名(需带上账户名),分支名(默认 main 即可),Token(上面生成保存下来的),存储路径(后带斜杠)可以填写已存在,如果不存在则在仓库根目录下新建。



文件上传


文件格式除了下面指定的如 Markdown、HTML、URL 外,还能上传图片音乐视频等(亲测有效)。点击 "上传区",将文件直接拖动到该窗口,提示上传成功后,进入 GitHub 仓库下查看是否存在。 



访问测试


访问就是能将仓库里的图片或视频以外链的方式展示,就像将文件放在云平台的存储桶一样。将前面 GitHub Pages 开启的链接复制下来,然后拼接存储路径和文件名就可以访问了。



应用 Typora


Typora 通过 PicGo 软件自动上传图片到 GitHub 仓库中。打开 Typora 的文件 -> 偏好设置 -> 图像 -> 上传图片 -> 配置 PicGo 路径,然后指定一下 PicGo 的安装位置。 



开始使用


可以点击 "验证图片上传选项",验证成功就代表已经将 Typora 的图标上传到仓库,也可以直接将图片复制到当前 md 文档位置。



![image-20240608145607117](https://raw.githubusercontent.com/z11r00/zd_image_bed/main/img/image-20240608145607117.png)

上传成功后会将返回一个如上面的远程链接,并且无法打开和显示,这是就要在 PicGo 工具的图床设置中。将自己 GitHUb 上的域名设定为自定义域名,格式 "域名 / 仓库名", 在 Typora 上传图片后重启就可展示了。


image-20240612104856943


作者:北桥苏
来源:juejin.cn/post/7384320850722553867
收起阅读 »

12306全球最大票务系统与Gemfire介绍

全球最大票务系统 自2019年12月12日发售春运首日车票,截至2020年1月9日,12306全渠道共发售车票4.12亿张,日均售票能力达到了2000万张,平均一年售出30亿张火车票,也就是说12306已经发展成全球交易量最大的实时票务系统。 12306发布数...
继续阅读 »

全球最大票务系统


自2019年12月12日发售春运首日车票,截至2020年1月9日,12306全渠道共发售车票4.12亿张,日均售票能力达到了2000万张,平均一年售出30亿张火车票,也就是说12306已经发展成全球交易量最大的实时票务系统。


12306发布数据显示,2020年春运期间,40天的春运期间,12306最高峰日网站点击量为1495亿次,这相当于每个中国人一天在12306上点击了100次,平均每秒点击量为170多万次。而全球访问量最大的搜索引擎网站, 谷歌日访问量也不过是56亿次,一个12306的零头。 再看一下大家习惯性做对比的淘宝,2019年双十一当天,淘宝的日活跃用户为4.76亿,相当于每个人也在淘宝上点击300多次,才能赶上12306的峰值点击量。


上亿人口,40天时间,30亿次出行,12306之前,全球没有任何一家公司和产品接手过类似的任务。这个网站是在数亿人苛刻的目光中,做一件史无前例却又必须成功的事情。


历史发展


10年前铁道部顶着重重压力决心要解决买车票这个全民难题,2010年春运首日12306网站开通并试运行,2011年12月23日网站正式上线,铁道部兑现了让网络售票覆盖所有车次的承诺,不料上线第一天,全民蜂拥而入,流量暴增,网站宕机,除此之外,支付流程繁琐支付渠道单一,各种问题不断涌现,宕机可能会迟到,但永远不会缺席,12306上线的第二年,网站仍然难以支撑春运的巨大流量,很多人因为网站的各种问题导致抢票失败,甚至耽误了去线下买票的最佳时机,铁道部马不解鞍听着批评,一次又一次给12306改版升级,这个出生的婴儿几乎是在骂声中长大的。2012年9月,中秋国庆双节来临之前,12306又一次全站崩溃,本来大家习以为常的操作,却被另一个消息彻底出炉,这次崩溃之前,铁道部曾花了3.3亿对系统进行升级,中标的不是IBM惠普EMC等大牌厂商,而是拥有国字号背景的太极股份和同方股份,铁道部解释说3.3亿已经是最低价了,但没人能听进去,大家只关心他长成了什么样,没人关心他累不累,从此之后,铁道部就很少再发声明了。


2013年左右,各种互联网公司表示我行我上,开发了各种抢票网站插件。当时360浏览器靠免费抢票创下国内浏览器使用率的最高纪录,百度猎豹搜狗UC也纷纷加入,如今各类生活服务APP,管他干啥的,都得植入购票抢票功能和服务,12306就这样被抢票软件围捕了。不同的是,过去抢票是免费,现在由命运馈赠的火车票,都在明面上标好了价格,比如抢票助力包,一般花钱买10元5份,也可以邀请好友砍一刀,抢票速度上,分为低快高级光速VIP等等速度,等级越高就越考验钱包。


2017年12306上线了选座和接续换乘功能,从此爱人可以自由抢靠窗座,而且夹在两人之间坐立不安,换乘购票也变得简单。2019年上线官方捡漏神器候补购票功能,可以代替科技黄牛,自动免费为旅客购买退票余票。......


阿里云当时主要是给他们提供虚拟机服务,主要是做IaaS这一层,就是基础设施服务这一层,2012年熟悉阿里云历史的应该都知道,那个时候阿里云其实还是很小的一个厂商,所以不要盲目夸大阿里云在里面起的作用。


技术难点


1、巨大流量,高请求高并发。


2、抢票流量。每天放出无数个爬虫机器人,模拟真人登陆12306,不间断的刷新网站余票,这会滋生很多的灰色流量,也会给12306本身的话造成非常大的压力。


3、动态库存。电商的任务是购物结算,库存是唯一且稳定的,而12306每卖出一张车票,不仅要减少首末站的库存,还要同时减少一趟列车所有过路站的。



以北京西到深圳福田的G335次高铁为例,表面上看起来中间有16个车站及16个SKU,但实际上不同的起始站都会产生新的SKU。我们将所有起始和终点的可能性相加,就是16+15+14一直加到一,一共136个SKU,而每种票对应三种座位,所以一共是408个商品。然后更复杂的是用户每买一张票会影响其他商品的库存,假如用户买了一张北京西的高碑店东的票,那北京始发的16个SKU库存都要减一,但是它并不影响非北京始发车票的库存,
更关键的是这些SKU间有的互斥,有的不互斥,优先卖长的还是优先卖短程的呢,每一次火车票的出售都会引发连锁变化,让计算量大大增加,如果再叠加当前的选座功能,计算数量可能还要再翻倍,而这些计算数据需要在大量购票者抢票的数秒,甚至数毫秒内完成,难度可想而知有多多大。



4、随机性。你永远都不知道哪一个人会在哪一天,去到哪一个地点,而双十一的预售和发货,其实已经提前准备了一个月,甚至几个月,并不是集中在双十一那天爆发的那一天。所以必须要有必须要有动态扩容的能力。


读扩散和写扩散


上面说的动态库存,就比如 A -> B -> C -> D 共 4 个车站,假如乘客买了 B -> C 的车票,那么同时会影响到 A->C,A->D,B->C,B->D,涉及了多个车站的排列组合,这里计算是比较耗费性能的。


那么这里就涉及到了 “读扩散” 和 “写扩散” 的问题,在 12 年的时候,12306 使用的就是读扩散,也就是在扣减余票库存的时候,直接扣减对应车站,而在查询的时候,进行动态计算。而写扩散就是在写的时候,就动态计算每个车站应该扣除多少余票库存,在查询的时候直接查即可。


12306本身他其实是读的流量远远大于写的流量,我个人是认为写扩散其实会更好一点。


Pivotal Gemfire


Redis 在互联网公司中使用的是比较多的,而在银行、12306 很多实时交易的系统中,很多采用 Pivotal Gemfire作为解决方案。Redis 是开源的缓存解决方案,而 Pivotal Gemfire 是商用的,我们在互联网项目中为什么使用 Redis 比较多呢,就是因为 Redis 是开源的,不要钱,开源对应的也就是稳定性不是那么的强,并且开源社区也不会给你提供解决方案,毕竟你是白嫖的,而在银行以及 12306 这些系统中,它们对可靠性要求非常的高,因此会选择商用的 Pivotal Gemfire,不仅性能强、高可用,而且 Gemfire 还会提供一系列的解决方案,据说做到了分布式系统中的 CAP


12306 的性能瓶颈就在于余票的查询操作上,上边已经说了,12306 是采用读扩散,也就是客户买票之后,扣减库存只扣减对应车站之间的余票库存,在读的时候,再来动态的计算每个站点应该有多少余票,因此读性能是 12306 的性能瓶颈


当时 12306 也尝试了许多其他的解决方案,比如 cassandra 和 mamcached,都扛不住查询的流量,而使用 Gemfire 之后扛住了流量,因此就使用了 Gemfire。2012年6月一期先改造12306的主要瓶颈——余票查询系统。 9月份完成代码改造,系统上线。2012年国庆,又是网上订票高峰期间,大家可以显著发现,可以登录12306,虽然还是很难订票,但是查询余票很快。2012年10月份,二期用GemFire改造订单查询系统(客户查询自己的订单记录)2013年春节,又是网上订票高峰期间,大家可以显著发现,可以登录12306,虽然还是很难订票,但是查询余票很快,而且查询自己的订票和下订单也很快。


技术改造之后,在只采用10几台X86服务器实现了以前数十台小型机的余票计算和查询能力,单次查询的最长时间从之前的15秒左右下降到0.2秒以下,缩短了75倍以上。 2012年春运的极端高流量并发情况下,系统几近瘫痪。而在改造之后,支持每秒上万次的并发查询,高峰期间达到2.6万个查询/秒吞吐量,整个系统效率显著提高;订单查询系统改造,在改造之前的系统运行模式下,每秒只能支持300-400个查询/秒的吞吐量,高流量的并发查询只能通过分库来实现。改造之后,可以实现高达上万个查询/秒的吞吐量,而且查询速度可以保障在20毫秒左右。新的技术架构可以按需弹性动态扩展,并发量增加时,还可以通过动态增加X86服务器来应对,保持毫秒级的响应时间。


通过云计算平台虚拟化技术,将若干X86服务器的内存集中起来,组成最高可达数十TB的内存资源池,将全部数据加载到内存中,进行内存计算。计算过程本身不需要读写磁盘,只是定期将数据同步或异步方式写到磁盘。GemFire在分布式集群中保存了多份数据,任何一台机器故障,其它机器上还有备份数据,因此通常不用担心数据丢失,而且有磁盘数据作为备份。GemFire支持把内存数据持久化到各种传统的关系数据库、Hadoop库和其它文件系统中。大家知道,当前计算架构的瓶颈在存储,处理器的速度按照摩尔定律翻番增长,而磁盘存储的速度增长很缓慢,由此造成巨大高达10万倍的差距。这样就很好理解GemFire为什么能够大幅提高系统性能了。Gemfire 的存储和计算都在一个地方,它的存储和实时计算的性能目前还没有其他中间件可以取代。


但是 Gemfire 也存在不足的地方,对于扩容的支持不太友好的,因为它里边有一个 Bucket 类似于 Topic 的概念,定好 Bucket 之后,扩容是比较难的,在 12306 中,也有过测试,需要几十个T的内存就可以将业务数据全部放到内存中来,因此直接将内存给加够,也就不需要很频繁的扩容。


12306业务解决方案


当然在优化中,我们靠改变架构加机器可以提升速度效率,剩下的也需要业务上的优化。


1、验证码。如果说是淘宝啊这种网站,他用这种验证码,用12306的验证码,可能大家都不会用了,对不对,但是12306他比较特殊,因为铁路全国就他一家,所以说他可以去做这个事情,他不用把用户体验放在第一位
。他最高的优先级是怎么把票给需要的人手上。


当然这个利益的确是比较大,所以也会采用这种人工打码的方式,可以雇一批大学生去做这个验证码识别。


2、候补。候补车票其实相当于整个系统上,它是一个异步的过程,你可以在这里排队,后面的话也没有抢到票,后面再通知你。


3、分时段售票。对于抢票来说,瞬时抢票会导致对服务器有瞬间很大的压力,因此从业务设计上来说需要将抢票的压力给分散开,比如今天才开启抢15天之后的车票。2点抢票,3点抢票等等。


总结


只有程序员才知道,一个每天完成超过1500万个订单,承受近1500亿次点击的系统到底有多难,在高峰阶段的时候,平均每秒就要承受170多万次的点击,面对铁路运输这种特殊的运算模式,也能够保证全国人民在短时间内抢到回家的票,12306就是在无数国人的苛责和质疑中,创造了一个世界的奇迹。


12306除了技术牛,还有着自己的人情关怀,系统会自动识别购票者的基本信息,如果识别出订单里有老人会优先给老人安排下铺儿童和家长会尽量安排在邻近的位置,12306 在保证所有人都能顺利抢到回家的票的同时,还在不断地增加更多的便利,不仅在乎技术问题,更在乎人情异味,12306可能还不够完美,但他一直在努力变得更好,为我们顺利回家提供保障,这是背后无数程序员日夜坚守的结果,我们也应该感谢总设计师单杏花女士,所以你可以调侃,可以批评,但不能否认12306背后所做出的所有努力!


作者:jack_xu
来源:juejin.cn/post/7381747852831653929
收起阅读 »

秒懂双亲委派机制

前言 最近有位小伙伴问了我一个问题:JDBC为什么会破坏双亲委派机制? 这个问题挺有代表性的。 双亲委派机制是Java中非常重要的类加载机制,它保证了类加载的完整性和安全性,避免了类的重复加载。 这篇文章就跟大家一起聊聊,Java中类加载的双亲委派机制到底是怎...
继续阅读 »

前言


最近有位小伙伴问了我一个问题:JDBC为什么会破坏双亲委派机制?


这个问题挺有代表性的。


双亲委派机制是Java中非常重要的类加载机制,它保证了类加载的完整性和安全性,避免了类的重复加载。


这篇文章就跟大家一起聊聊,Java中类加载的双亲委派机制到底是怎么回事,有哪些破坏双亲委派机制的案例,为什么要破坏双亲委派机制,希望对你会有所帮助。


1 为什么要双亲委派机制?


我们的Java在运行之前,首先需要把Java代码转换成字节码,即class文件。


然后JVM需要把字节码通过一定的方式加载到内存中的运行时数据区


这种方式就是类加载器(ClassLoader)。


再通过加载、验证、准备、解析、初始化这几个步骤完成类加载过程,然后再由jvm执行引擎的解释器和JIT即时编译器去将字节码指令转换为本地机器指令进行执行。


我们在使用类加载器加载类的时候,会面临下面几个问题:



  1. 如何保证类不会被重复加载?类重复加载会出现很多问题。

  2. 类加载器是否允许用户自定义?

  3. 如果允许用户自定义,如何保证类文件的安全性?

  4. 如何保证加载的类的完整性?


为了解决上面的这一系列的问题,我们必须要引入某一套机制,这套机制就是:双亲委派机制


2 什么是双亲委派机制?


接下来,我们看看什么是双亲委派机制。


双亲委派机制的基本思想是:当一个类加载器试图加载某个类时,它会先委托给其父类加载器,如果父类加载器无法加载,再由当前类加载器自己进行加载。


这种层层委派的方式有助于保障类的唯一性,避免类的重复加载,并提高系统的安全性和稳定性。


在Java中默认的类加载器有3层:



  1. 启动类加载器(Bootstrap Class Loader):负责加载 %JAVA_HOME%/jre/lib 目录下的核心Java类库,比如:rt.jar、charsets.jar等。它是最顶层的类加载器,通常由C++编写。

  2. 扩展类加载器(Extension Class Loader):负责加载Java的扩展库,一般位于/lib/ext目录下。

  3. 应用程序类加载器(Application Class Loader):也称为系统类加载器,负责加载用户类路径(ClassPath)下的应用程序类。


用一张图梳理一下,双亲委派机制中的3种类加载器的层次关系:图片


但这样不够灵活,用户没法控制,加载自己想要的一些类。


于是,Java中引入了自定义类加载器。


创建一个新的类并继承ClassLoader类,然后重写findClass方法。


该方法主要是实现从那个路径读取 ar包或者.class文件,将读取到的文件用字节数组来存储,然后可以使用父类的defineClass来转换成字节码。


如果想破坏双亲委派的话,就重写loadClass方法,否则不用重写。


类加载器的层次关系改成:图片


双亲委派机制流程图如下:图片


具体流程大概是这样的:



  1. 需要加载某个类时,先检查自定义类加载器是否加载过,如果已经加载过,则直接返回。

  2. 如果自定义类加载器没有加载过,则检查应用程序类加载器是否加载过,如果已经加载过,则直接返回。

  3. 如果应用程序类加载器没有加载过,则检查扩展类加载器是否加载过,如果已经加载过,则直接返回。

  4. 如果扩展类加载器没有加载过,则检查启动类加载器是否加载过,如果已经加载过,则直接返回。

  5. 如果启动类加载器没有加载过,则判断当前类加载器能否加载这个类,如果能加载,则加载该类,然后返回。

  6. 如果启动类加载器不能加载该类,则交给扩展类加载器。扩展类加载器判断能否加载这个类,如果能加载,则加载该类,然后返回。

  7. 如果扩展类加载器不能加载该类,则交给应用程序类加载器。应用程序类加载器判断能否加载这个类,如果能加载,则加载该类,然后返回。

  8. 如果应用程序类加载器不能加载该类,则交给自定义类加载器。自定义类加载器判断能否加载这个类,如果能加载,则加载该类,然后返回。

  9. 如果自定义类加载器,也无法加载这个类,则直接抛ClassNotFoundException异常。


这样做的好处是:



  1. 保证类不会重复加载。加载类的过程中,会向上问一下是否加载过,如果已经加载了,则不会再加载,这样可以保证一个类只会被加载一次。

  2. 保证类的安全性。核心的类已经被启动类加载器加载了,后面即使有人篡改了该类,也不会再加载了,防止了一些有危害的代码的植入。


3 破坏双亲委派机制的场景


既然Java中引入了双亲委派机制,为什么要破坏它呢?


答:因为它有一些缺点。


下面给大家列举一下,破坏双亲委派机制最常见的场景。


3.1 JNDI


JNDI是Java中的标准服务,它的代码由启动类加载器去加载。


但JNDI要对资源进行集中管理和查找,它需要调用由独立厂商在应用程序的ClassPath下的实现了JNDI接口的代码,但启动类加载器不可能“认识”这些外部代码。


为了解决这个问题,Java后来引入了线程上下文类加载器(Thread Context ClassLoader)。


这个类加载器可以通过java.lang.Thread类的setContextClassLoader()方法进行设置。


如果创建线程时没有设置,他将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。


有了线程上下文加载器,JNDI服务就可以使用它去加载所需要的SPI代码,也就是父类加载器请求子类加载器去完成类加载的动作,这样就打破了双亲委派机制。


3.2 JDBC


原生的JDBC中Driver驱动本身只是一个接口,并没有具体的实现,具体的实现是由不同数据库类型去实现的。


例如,MySQL的mysql-connector.jar中的Driver类具体实现的。


原生的JDBC中的类是放在rt.jar包,是由启动类加载器进行类加载的。


在JDBC中需要动态去加载不同数据库类型的Driver实现类,而mysql-connector.jar中的Driver实现类是用户自己写的代码,启动类加载器肯定是不能加载的,那就需要由应用程序启动类去进行类加载。


为了解决这个问题,也可以使用线程上下文类加载器(Thread Context ClassLoader)。


3.3  Tomcat容器


Tomcat是Servlet容器,它负责加载Servlet相关的jar包。


此外,Tomcat本身也是Java程序,也需要加载自身的类和一些依赖jar包。


这样就会带来下面的问题:



  1. 一个Tomcat容器下面,可以部署多个基于Servlet的Web应用,但如果这些Web应用下有同名的Servlet类,又不能产生冲突,需要相互独立加载和运行才行。

  2. 但如果多个Web应用,使用了相同的依赖,比如:SpringBoot、Mybatis等。这些依赖包所涉及的文件非常多,如果全部都独立,可能会导致JVM内存不足。也就是说,有些公共的依赖包,最好能够只加载一次。

  3. 我们还需要将Tomcat本身的类,跟Web应用的类隔离开。


这些原因导致,Tomcat没有办法使用传统的双亲委派机制加载类了。


那么,Tomcat加载类的机制是怎么样的?


图片



  • CommonClassLoader:是Tomcat最基本的类加载器,它加载的类可以被Tomcat容器和Web应用访问。

  • CatalinaClassLoader:是Tomcat容器私有的类加载器,加载类对于Web应用不可见。

  • SharedClassLoader:各个Web应用共享的类加载器,加载的类对于所有Web应用可见,但是对于Tomcat容器不可见。

  • WebAppClassLoader:各个Web应用私有的类加载器,加载类只对当前Web应用可见。比如不同war包应用引入了不同的Spring版本,这样能加载各自的Spring版本,相互隔离。


3.4 热部署


由于用户对程序动态性的追求,比如:代码热部署、代码热替换等功能,引入了OSGi(Open Service Gateway Initiative)。


OSGi中的每一个模块(称为Bundle)。


当程序升级或者更新时,可以只停用、重新安装然后启动程序的其中一部分,对企业来说这是一个非常诱人的功能。


OSGi的Bundle类加载器之间只有规则,没有固定的委派关系。


各个Bundle加载器是平级关系。


不是双亲委派关系。




作者:苏三说技术
来源:juejin.cn/post/7383894631312769074
收起阅读 »

ThreadLocal不香了,ScopedValue才是王道

ThreadLocal的缺点在Java中,当多个方法要共享一个变量时,我们会选择使用ThreadLocal来进行共享,比如:  以上代码将字符串“dadudu”通过设置到ThreadLocal中,从而可以做到在main()方法中赋值,在a(...
继续阅读 »

ThreadLocal的缺点

在Java中,当多个方法要共享一个变量时,我们会选择使用ThreadLocal来进行共享,比如:  以上代码将字符串“dadudu”通过设置到ThreadLocal中,从而可以做到在main()方法中赋值,在a()b()方法中获取值,从而共享值。

生命在于思考,我们来想想ThreadLocal有什么缺点:

  1. 第一个就是权限问题,也许我们只需要在main()方法中给ThreadLocal赋值,在其他方法中获取值就可以了,而上述代码中a()b()方法都有权限给ThreadLocal赋值,ThreadLocal不能做权限控制。
  2. 第二个就是内存问题,ThreadLocal需要手动强制remove,也就是在用完ThreadLocal之后,比如b()方法中,应该调用其remove()方法,但是我们很容易忘记调用remove(),从而造成内存浪费

ScopedValue

而JDK21中的新特性ScopedValue能不能解决这两个缺点呢?我们先来看一个ScopedValue的Demo: 

首先需要通过ScopedValue.newInstance()生成一个ScopedValue对象,然后通过ScopedValue.runWhere()方法给ScopedValue对象赋值,runWhere()的第三个参数是一个lambda表达式,表示作用域,比如上面代码就表示:给NAME绑定值为"dadudu",但是仅在调用a()方法时才生效,并且在执行runWhere()方法时就会执行lambda表达式。

比如上面代码的输出结果为: 

从结果可以看出在执行runWhere()时会执行a()a()方法中执行b()b()执行完之后返回到main()方法执行runWhere()之后的代码,所以,在a()方法和b()方法中可以拿到ScopedValue对象所设置的值,但是在main()方法中是拿不到的(报错了),b()方法之所以能够拿到,是因为属于a()方法调用栈中。

所以在给ScopedValue绑定值时都需要指定一个方法,这个方法就是所绑定值的作用域,只有在这个作用域中的方法才能拿到所绑定的值。

ScopedValue也支持在某个方法中重新开启新的作用域并绑定值,比如: 

以上代码中,在a()方法中重新给ScopedValue绑定了一个新值“xiaodudu”,并指定了作用域为c()方法,所以c()方法中拿到的值为“xiaodudu”,但是b()中仍然拿到的是“dadudu”,并不会受到影响,以上代码的输出结果为: 

甚至如果把代码改成: 

以上代码在a()方法中有两处调用了c()方法,我想大家能思考出c1c2输出结果分别是什么: 

所以,从以上分析可以看到,ScopedValue有一定的权限控制:就算在同一个线程中也不能任意修改ScopedValue的值,就算修改了对当前作用域(方法)也是无效的。另外ScopedValue也不需要手动remove,关于这块就需要分析它的实现原理了。

实现原理

大家先看下面代码,注意看下注释: 

执行main()方法时,main线程执行过程中会执行runWhere()方法三次,而每次执行runWhere()时都会生成一个Snapshot对象,Snapshot对象中记录了所绑定的值,而Snapshot对象有一个prev属性指向上一次所生成的Snapshot对象,并且在Thread类中新增了一个属性scopedValueBindings,专门用来记录当前线程对应的Snapshot对象。

比如在执行main()方法中的runWhere()时:

  1. 会先生成Snapshot对象1,其prev为null,并将Snapshot对象1赋值给当前线程的scopedValueBindings属性,然后执行a()方法
  2. 在执行a()方法中的runWhere()时,会先生成Snapshot对象2,其prevSnapshot对象1,并将Snapshot对象2赋值给当前线程的scopedValueBindings属性,使得在执行b()方法时能从当前线程拿到Snapshot对象2从而拿到所绑定的值,runWhere()内部在执行完b()方法后会取prev,从而取出Snapshot对象1,并将Snapshot对象1赋值给当前线程的scopedValueBindings属性,然后继续执行a()方法后续的逻辑,如果后续逻辑调用了get()方法,则会取当前线程的scopedValueBindings属性拿到Snapshot对象1,从Snapshot对象1中拿到所绑定的值就可以了,而对于Snapshot对象2由于没有引用则会被垃圾回收掉。

所以,在用ScopedValue时不需要手动remove。

好了,关于ScopedValue就介绍到这啦,下次继续分享JDK21新特性,欢迎大家关注我的公众号:Hoeller,第一时间接收我的原创技术文章,谢谢大家的阅读。


作者:IT周瑜
来源:juejin.cn/post/7287241480770928655
收起阅读 »

开发经理:谁在项目里面用Stream. paraller()直接gun

大家好,我是小玺,今天给大家分享一下项目中关于Stream.parallel() 碰到的坑。 Stream.parallel() 是Java 8及以上版本引入的Stream API中的一个方法,它用于将一个串行流转换为并行流。并行流可以在多个处理器上同时执行操...
继续阅读 »

大家好,我是小玺,今天给大家分享一下项目中关于Stream.parallel() 碰到的坑。


Stream.parallel() 是Java 8及以上版本引入的Stream API中的一个方法,它用于将一个串行流转换为并行流。并行流可以在多个处理器上同时执行操作,从而显著提高对大量数据进行处理的性能。


踩坑日记


某个大型项目,晚上十一点多有个用户对小部分数据进行某项批量操作后,接口大半天没有反应最后返回超时报错,但是过了一段时间后,出现了部分数据被修改成功,部分数据则没有反应。用户立马跳起来,打电话投诉到公司领导层,于是乎领导层对上至开发经理和PM,下至小开发进行会议批斗,要求马上排查并解决问题,毕竟项目这么大,当初也是要求测试做过压测的,怎么出现这么大的生产事故。


1712648893920.png


于是乎开发和实施运维分头行事,开发人员排查问题,实施人员先把问题数据维护好,不能应该用户使用。一群开发也是很疑惑,开发和测试环境都没法复现出问题,简单过一下代码也没看出个所以然,由于时间问题,不得不呼叫一手开发经理帮忙看看,开发经理后台接口看完Stream.parallel()进行的操作代码立马就炸了,git看了下提交人【会笑】,把这个开发从头到脚喷了一遍。


在对会笑单独进行了长达半小时的“耐心教育”后(ps:问题安排另一名开发同事修复),开发经理给团队的所有后端开发人员又都教育了一遍。原来会笑在用并行流的时候,没有考虑线程池配置和事务问题,把一堆数据进行了批量更新,Stream.parallel()并行流默认使用的是ForkJoinPool.commonPool()作为线程池,该线程池默认最大线程数就是CPU核数。


1712648957687.png


雀食对于一些初中级开发来说,开发过程中往往喜欢用一些比较新颖的写法来实现但是对新语法又是一知半解的,Stream.parallel()作为Java的新特性,也就成了其中一个反面教材。如果操作数据量不大的情况,其实没有必要用到Stream.parallel(),效率反而会变差。


注意事项



  1. 线程安全:并行流并不能保证线程安全性,因此,如果流中的元素是共享资源或操作本身不是线程安全的,你需要确保正确同步或使用线程安全的数据结构。

  2. 数据分区:Java的并行流机制会自动对数据进行分区,但在某些情况下,数据分区的开销可能大于并行带来的收益,特别是对于小规模数据集。

  3. 效率考量:并非所有的流操作都能从并行化中受益,有些操作(如短流操作或依赖于顺序的操作)并行执行反而可能导致性能下降。而且,过多的上下文切换也可能抵消并行带来的优势。

  4. 资源消耗:并行流默认使用的线程池大小可能与机器的实际物理核心数相适应,但也可能与其他并发任务争夺系统资源。

  5. 结果一致性:并行流并不保证执行的顺序性,也就是说,如果流操作的结果依赖于元素的处理顺序,则不应该使用并行流。

  6. 事务处理:在涉及到事务操作时,通常需要避免在并行流中直接处理,如上述例子所示,应当将事务边界放在单独的服务方法内,确保每个线程内的事务独立完成。


Tips:线程数可以通JVM启动参数-Djava.util.concurrent.ForkJoinPool.common.parallelism=20进行修改


作者:小玺
来源:juejin.cn/post/7355431482687864883
收起阅读 »

记一次难忘的json反序列化问题排查经历

前言 最近我在做知识星球中的商品秒杀系统,昨天遇到了一个诡异的json反序列化问题,感觉挺有意思的,现在拿出来跟大家一起分享一下,希望对你会有所帮助。 案发现场 我最近在做知识星球中的商品秒杀系统,写了一个filter,获取用户请求的header中获取JWT的...
继续阅读 »

前言


最近我在做知识星球中的商品秒杀系统,昨天遇到了一个诡异的json反序列化问题,感觉挺有意思的,现在拿出来跟大家一起分享一下,希望对你会有所帮助。


案发现场


我最近在做知识星球中的商品秒杀系统,写了一个filter,获取用户请求的header中获取JWT的token信息。


然后根据token信息,获取到用户信息。


在转发到业务接口之前,将用户信息设置到用户上下文当中。


这样接口中的业务代码,就能通过用户上下文,获取到当前登录的用户信息了。


我们的token和用户信息,为了性能考虑都保存到了Redis当中。


用户信息是一个json字符串。


当时在用户登录接口中,将用户实体,使用fastjson工具,转换成了字符串:


JSON.toJSONString(userDetails);

保存到了Redis当中。


然后在filter中,通过一定的key,获取Redis中的字符串,反序列化成用户实体。


使用的同样是fastjson工具:


JSON.parseObject(json, UserEntity.class);

但在反序列化的过程中,filter抛异常了:com.alibaba.fastjson.JSONException: illegal identifier : \pos 1, line 1, column 2{"accountNonExpired":true,"accountNonLocked":true,"authorities":[{"authority":"admin"}],"credentialsNonExpired":true,"enabled":true,"id":13,"password":"$2a$10$o3XfeGr0SHStAwLuJRW6y.kE0UTerQfv3SXrAcVLuJ6M3hEsC9RKe","roles":["admin"],"username":"admin"}


2 分析问题


我刚开始以为是json数据格式有问题。


将json字符串复制到在线json工具:http://www.sojson.com,先去掉化之后,再格式数据,发现json格式没有问题:![图片](p3-juejin.byteimg.com/tos-cn-i-k3…)


然后写了一个专门的测试类,将日志中打印的json字符串复制到json变量那里,使用JSON.parseObject方法,将json字符串转换成Map对象:


public class Test {

    public static void main(String[] args) {
        String json = "{"accountNonExpired":true,"accountNonLocked":true,"authorities":[{"authority":"admin"}],"credentialsNonExpired":true,"enabled":true,"id":13,"password":"$2a$10$o3XfeGr0SHStAwLuJRW6y.kE0UTerQfv3SXrAcVLuJ6M3hEsC9RKe","roles":["admin"],"username":"admin"}";
        Map map = JSON.parseObject(json, Map.class);
        // 输出解析后的 JSON 对象
        System.out.println(map);
    }
}

执行结果:


{password=$2a$10$o3XfeGr0SHStAwLuJRW6y.kE0UTerQfv3SXrAcVLuJ6M3hEsC9RKe, credentialsNonExpired=true, roles=["admin"], accountNonExpired=true, id=13, authorities=[{"authority":"admin"}], enabled=true, accountNonLocked=true, username=admin}

竟然转换成功了。


这就让我有点懵逼了。。。


为什么相同的json字符串,在Test类中能够正常解析,而在filter当中却不行?


当时怕搞错了,debug了一下filter,发现获取到的json数据,跟Test类中的一模一样:图片


带着一脸的疑惑,我做了下面的测试。


8000页BAT大佬写的刷题笔记,让我offer拿到手软


莫非是反序列化工具有bug?


3 改成gson工具


我尝试了一下将json的反序列化工具改成google的gson,代码如下:


 Map map = new Gson().fromJson(userJson, Map.class);

运行之后,报了一个新的异常:com.google.gson.JsonSyntaxException: java.lang.IllegalStateException: Expected BEGIN_OBJECT but was STRING at line 1 column 2 path $


这里提示json字符串中包含了:$


$是特殊字符,password是做了加密处理的,里面包含$.,这两种特殊字符。


为了快速解决问题,我先将这两个特字符替换成空字符串:


json = json.replace("$","").replace(".","");

日志中打印出的json中的password,已经不包含这两个特殊字符了:


2a10o3XfeGr0SHStAwLuJRW6ykE0UTerQfv3SXrAcVLuJ6M3hEsC9RKe

但调整之后代码报了下面的异常:com.google.gson.JsonSyntaxException: com.google.gson.stream.MalformedJsonException: Expected name at line 1 column 2 path $.


跟刚刚有点区别,但还是有问题。


4 改成jackson工具


我又尝试了一下json的反序列化工具,改成Spring自带的的jackson工具,代码如下:


ObjectMapper objectMapper = new ObjectMapper();
try {
    Map map = objectMapper.readValue(json, Map.class);
catch (JsonProcessingException e) {
    e.printStackTrace();
}

调整之后,反序列化还是报错:com.fasterxml.jackson.core.JsonParseException: Unexpected character ('' (code 92)): was expecting double-quote to start field name


3种反序列化工具都不行,说明应该不是fastjson的bug导致的当前json字符串,反序列化失败。


到底是什么问题呢?


5 转义


之前的数据,我在仔细看了看。


里面是对双引号,是使用了转义的,具体是这样做的:"


莫非还是这个转义的问题?


其实我之前已经注意到了转义的问题,但使用Test类测试过,没有问题。


当时的代码是这样的:


public class Test {

    public static void main(String[] args) {
        String json = "{"accountNonExpired":true,"accountNonLocked":true,"authorities":[{"authority":"admin"}],"credentialsNonExpired":true,"enabled":true,"id":13,"password":"$2a$10$o3XfeGr0SHStAwLuJRW6y.kE0UTerQfv3SXrAcVLuJ6M3hEsC9RKe","roles":["admin"],"username":"admin"}";
        Map map = JSON.parseObject(json, Map.class);
        // 输出解析后的 JSON 对象
        System.out.println(map);
    }
}

里面也包含了一些转义字符。


我带着试一试的心态,接下来,打算将转义字符去掉。


看看原始的json字符串,解析有没有问题。


怎么去掉转义字符呢?


手写工具类,感觉不太好,可能会写漏一些特殊字符的场景。


8000页BAT大佬写的刷题笔记,让我offer拿到手软


我想到了org.apache.commons包下的StringEscapeUtils类,它里面的unescapeJava方法,可以轻松去掉Java代码中的转义字符。


于是,我调整了一下代码:


json = StringEscapeUtils.unescapeJava(json);
JSON.parseObject(json, UserEntity.class);

这样处理之后,发现反序列化成功了。


总结


这个问题最终发现还是转义的问题。


那么,之前Test类中json字符串,也使用了转义,为什么没有问题?


当时的代码是这样的:


public class Test {

    public static void main(String[] args) {
        String json = "{"accountNonExpired":true,"accountNonLocked":true,"authorities":[{"authority":"admin"}],"credentialsNonExpired":true,"enabled":true,"id":13,"password":"$2a$10$o3XfeGr0SHStAwLuJRW6y.kE0UTerQfv3SXrAcVLuJ6M3hEsC9RKe","roles":["admin"],"username":"admin"}";
        Map map = JSON.parseObject(json, Map.class);
        System.out.println(map);
    }
}

但在filter中的程序,在读取到这个json字符串之后,发现该字符串中包含了``转义符号,程序自动把它变成了\


调整一下Test类的main方法,改成三个斜杠的json字符串:


public static void main(String[] args) {
    String json = "{\"accountNonExpired\":true,\"accountNonLocked\":true,\"authorities\":[{\"authority\":\"admin\"}],\"credentialsNonExpired\":true,\"enabled\":true,\"id\":13,\"password\":\"$2a$10$o3XfeGr0SHStAwLuJRW6y.kE0UTerQfv3SXrAcVLuJ6M3hEsC9RKe\",\"roles\":[\"admin\"],\"username\":\"admin\"}";
    Map map = JSON.parseObject(json, Map.class);
    System.out.println(map);
}

执行结果:Exception in thread "main" com.alibaba.fastjson.JSONException: illegal identifier : \pos 1, line 1, column 2{"accountNonExpired":true,"accountNonLocked":true,"authorities":[{"authority":"admin"}],"credentialsNonExpired":true,"enabled":true,"id":13,"password":"$2a$10$o3XfeGr0SHStAwLuJRW6y.kE0UTerQfv3SXrAcVLuJ6M3hEsC9RKe","roles":["admin"],"username":"admin"}抛出了跟文章最开始一样的异常。


说明其实就是转义的问题。


之前,我将项目的日志中的json字符串,复制到idea的Test的json变量中,当时将最外层的双引号一起复制过来了,保存的是1个斜杠的数据。


这个操作把我误导了。


而后面从在线的json工具中,把相同的json字符串,复制到idea的Test的json变量中,在双引号当中粘贴数据,保存的却是3个斜杠的数据,它会自动转义。


让我意识到了问题。


好了,下次如果遇到类似的问题,可以直接使用org.apache.commons包下的StringEscapeUtils类,先去掉转义,再反序列化,这样可以快速解决问题。


此外,这次使用了3种不同的反序列化工具,也看到了其中的一些差异。


作者:苏三说技术
来源:juejin.cn/post/7385081262871003175
收起阅读 »

Flutter桌面应用开发:深入Flutter for Desktop

Flutter 是一个开源的 UI 工具包,用于构建高性能、高保真、多平台的应用程序,包括移动、Web 和桌面。 安装和环境配置 安装Prerequisites: Java Development Kit (JDK): 安装JDK 8或更高版本,因为Flutt...
继续阅读 »

Flutter 是一个开源的 UI 工具包,用于构建高性能、高保真、多平台的应用程序,包括移动、Web 和桌面。


安装和环境配置


安装Prerequisites:


Java Development Kit (JDK): 安装JDK 8或更高版本,因为Flutter要求JDK 1.8或更高。配置环境变量JAVA_HOME指向JDK的安装路径。
Flutter SDK:


下载Flutter SDK:


访问Flutter官方网站下载适用于Windows的Flutter SDK压缩包。
解压并选择一个合适的目录安装,例如 C:\src\flutter
将Flutter SDK的bin目录添加到系统PATH环境变量中。例如,添加 C:\src\flutter\bin


Git:


如果还没有安装Git,可以从Git官网下载并安装。
在安装过程中,确保勾选 "Run Git from the Windows Command Prompt" 选项。


Flutter Doctor:


打开命令提示符或PowerShell,运行 flutter doctor 命令。这将检查你的环境是否完整,并列出任何缺失的组件,如Android Studio、Android SDK等。


Android Studio (如果计划开发Android应用):


下载并安装Android Studio,它包含了Android SDK和AVD Manager。
安装后,通过Android Studio设置向导配置Android SDK和AVD。
确保在系统环境变量中配置了ANDROID_HOME指向Android SDK的路径,通常是\Sdk


iOS Development (如果计划开发iOS应用):


你需要安装Xcode和Command Line Tools,这些只适用于macOS。
在终端中运行xcode-select --install以安装必要的命令行工具。


验证安装:


运行 flutter doctor --android-licenses 并接受所有许可证(如果需要)。
再次运行 flutter doctor,确保所有必需的组件都已安装并配置正确。


开始开发:


创建你的第一个Flutter项目:flutter create my_first_app
使用IDE(如VS Code或Android Studio)打开项目,开始编写和运行代码。


基础知识


在Flutter桌面应用开发中,Dart语言是核心。基础Flutter应用展示来学习Dart语言魅力:


import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}

class MyHomePage extends StatefulWidget {
MyHomePage({Key? key, required this.title}) : super(key: key);

final String title;

@override
_MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;

void _incrementCounter() {
setState(() {
// This call to setState tells the Flutter framework that something has
// changed in this State object, which causes it to re-build the widget.
_counter++;
});
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'You have pushed the button this many times:',
),
Text(
'$_counter',
style: Theme.of(context).textTheme.headline4,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: Icon(Icons.add),
), // This trailing comma makes auto-formatting nicer for build methods.
);
}
}

导入库:



  • import 'package:flutter/material.dart';: 导入Flutter的Material库,包含了许多常用的UI组件。


主入口点:


void main() => runApp(MyApp());: 应用的主入口点,启动MaterialApp。


MyApp StatelessWidget:


MyApp是一个无状态的Widget,用于配置应用的全局属性。


MyHomePage StatefulWidget:



  • MyHomePage是一个有状态的Widget,它有一个状态类_MyHomePageState,用于管理状态。

  • title参数在构造函数中传递,用于初始化AppBar的标题。


_MyHomePageState:



  • _counter变量用于存储按钮点击次数。

  • _incrementCounter方法更新状态,setState通知Flutter需要重建Widget。

  • build方法构建Widget树,根据状态_counter更新UI。


UI组件:



  • Scaffold提供基本的布局结构,包括AppBar、body和floatingActionButton。

  • FloatingActionButton是一个浮动按钮,点击时调用_incrementCounter。

  • Text组件显示文本,AppBar标题和按钮点击次数。

  • ColumnCenter用于布局管理。


Flutter应用


创建项目目录:


选择一个合适的位置创建一个新的文件夹,例如,你可以命名为my_flutter_app。


初始化Flutter项目:


打开终端或命令提示符,导航到你的项目目录,然后运行以下命令来初始化Flutter应用:


   cd my_flutter_app
flutter create .

这个命令会在当前目录下创建一个新的Flutter应用。


检查项目:


初始化完成后,你应该会看到以下文件和文件夹:



  • lib/:包含你的Dart代码,主要是main.dart文件。

  • pubspec.yaml:应用的配置文件,包括依赖项。

  • android/ios/:分别用于Android和iOS的原生项目配置。


运行应用:


为了运行应用,首先确保你的模拟器或物理设备已经连接并准备好。然后在终端中运行:


   flutter run

这将构建你的应用并启动它在默认的设备上。


编辑代码:


打开lib/main.dart文件,这是你的应用的入口点。你可以在这里修改代码以自定义你的应用。例如,你可以修改MaterialApphome属性来指定应用的初始屏幕。


热重载:


当你修改代码并保存时,可以使用flutter pub get获取新依赖,然后按r键(或在终端中输入flutter reload)进行热重载,快速查看代码更改的效果。


布局和组件


Flutter提供了丰富的Widget库来构建复杂的布局。下面是一个使用Row, Column, Expanded, 和 ListView的简单布局示例,展示如何组织UI组件。


import 'package:flutter/material.dart';

void main() {
runApp(MyApp());
}

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Desktop Layout Example',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: Scaffold(
appBar: AppBar(title: Text("Desktop App Layout")),
body: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
RaisedButton(onPressed: () {}, child: Text('Button 1')),
RaisedButton(onPressed: () {}, child: Text('Button 2')),
],
),
SizedBox(height: 20),
Expanded(
child: ListView.builder(
itemCount: 20,
itemBuilder: (context, index) {
return ListTile(title: Text('Item $index'));
},
),
),
],
),
),
);
}
}

Column和Row是基础的布局Widget,Expanded用于占据剩余空间,ListView.builder动态构建列表项,展示了如何灵活地组织UI元素。


状态管理和数据流


在Flutter中,状态管理是通过Widget树中的状态传递和更新来实现的。最基础的是使用StatefulWidgetsetState方法,但复杂应用通常会采用更高级的状态管理方案,如ProviderRiverpodBloc


import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

class Counter with ChangeNotifier {
int _count = 0;

int get count => _count;

void increment() {
_count++;
notifyListeners();
}
}

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (context) => Counter(),
child: MaterialApp(
home: Scaffold(
body: Center(
child: Consumer(
builder: (context, counter, child) {
return Text(
'${counter.count}',
style: TextStyle(fontSize: 24),
);
},
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
Provider.of(context, listen: false).increment();
},
child: Icon(Icons.add),
),
),
),
);
}
}

状态管理示例引入了Provider库,ChangeNotifier用于定义状态,ChangeNotifierProvider在树中提供状态,Consumer用于消费状态并根据状态更新UI,Provider.of用于获取状态并在按钮按下时调用increment方法更新状态。这种方式解耦了状态和UI,便于维护和测试。


路由和导航


Flutter使用Navigator进行页面间的导航。


import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Navigation Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
initialRoute: '/',
routes: {
'/': (context) => HomePage(),
'/details': (context) => DetailsPage(),
},
);
}
}

class HomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Home Page')),
body: Center(
child: ElevatedButton(
child: Text('Go to Details'),
onPressed: () {
Navigator.pushNamed(context, '/details');
},
),
),
);
}
}

class DetailsPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Details Page')),
body: Center(child: Text('This is the details page')),
);
}
}

MaterialApproutes属性定义了应用的路由表,initialRoute指定了初始页面,Navigator.pushNamed用于在路由表中根据名称导航到新页面。这展示了如何在Flutter中实现基本的页面跳转逻辑。


响应式编程


Flutter的UI是完全响应式的,意味着当状态改变时,相关的UI部分会自动重建。使用StatefulWidgetsetState方法是最直接的实现方式。


import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: CounterPage(),
);
}
}

class CounterPage extends StatefulWidget {
@override
_CounterPageState createState() => _CounterPageState();
}

class _CounterPageState extends State<CounterPage> {
int _counter = 0;

void _incrementCounter() {
setState(() {
_counter++;
});
}

@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'You have pushed the button this many times:',
),
Text(
'$_counter',
style: Theme.of(context).textTheme.headline4,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: Icon(Icons.add),
),
);
}
}

StatefulWidgetsetState的使用体现了Flutter的响应式特性。当调用_incrementCounter方法更新_counter状态时,Flutter框架会自动调用build方法,仅重绘受影响的部分,实现了高效的UI更新。这种模式确保了UI始终与最新的状态保持一致,无需手动管理UI更新逻辑。


平台交互


Flutter提供了Platform类来与原生平台进行交互。


import 'package:flutter/foundation.dart';

void main() {
runApp(MyApp());
}

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Platform.isAndroid ? AndroidScreen() : DesktopScreen(),
);
}
}

class AndroidScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Text('This is an Android screen');
}
}

class DesktopScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Text('This is a Desktop screen');
}
}

性能优化


优化主要包括减少不必要的渲染、使用高效的Widget和数据结构、压缩资源等。例如,使用const关键字创建常量Widget以避免不必要的重建:


class MyWidget extends StatelessWidget {
const MyWidget({Key? key}) : super(key: key);

@override
Widget build(BuildContext context) {
return Container(
color: Colors.blue,
child: const Text('Optimized Widget', style: TextStyle(fontSize: 24)),
);
}
}

调试和测试


Flutter提供了强大的调试工具,如热重载、断点、日志输出等。测试方面,可以使用test包进行单元测试和集成测试:


import 'package:flutter_test/flutter_test.dart';

void main() {
test('Counter increments correctly', () {
final counter = Counter(0);
expect(counter.value, equals(0));
counter.increment();
expect(counter.value, equals(1));
});
}

打包和发布


发布Flutter应用需要构建不同平台的特定版本。在桌面环境下,例如Windows,可以使用以下命令:


flutter build windows

这将生成一个.exe文件,可以分发给用户。确保在pubspec.yaml中配置好应用的元数据,如版本号和描述。


Flutter工作原理分析


Flutter Engine:



  • Flutter引擎是Flutter的基础,它负责渲染、事件处理、文本布局、图像解码等功能。引擎是用C++编写的,部分用Java或Objective-C/Swift实现原生平台的接口。

  • Skia是Google的2D图形库,用于绘制UI。在桌面应用中,Skia直接与操作系统交互,提供图形渲染。

  • Dart VM运行Dart代码,提供垃圾回收和即时编译(JIT)或提前编译(AOT)。


Flutter Framework:



  • Flutter框架是用Dart编写的,它定义了Widget、State和Layout等概念,以及动画、手势识别和数据绑定等机制。

  • WidgetsFlutterBinding是框架与引擎的桥梁,它实现了将Widget树转换为可绘制的命令,这些命令由引擎执行。


Widgets:



  • Flutter中的Widget是UI的构建块,它们是不可变的。StatefulWidget和State类用于管理可变状态。

  • 当状态改变时,setState方法被调用,导致Widget树重新构建,进而触发渲染。


Plugins:



  • 插件是Flutter与原生平台交互的方式,它们封装了原生API,使得Dart代码可以访问操作系统服务,如文件系统、网络、传感器等。

  • 桌面应用的插件需要针对每个目标平台(Windows、macOS、Linux)进行实现。


编译和运行流程:



  • 使用flutter build命令,Dart代码会被编译成原生代码(AOT编译),生成可执行文件。

  • 运行时,Flutter引擎加载并执行编译后的代码,同时初始化插件和设置渲染管线。


调试和热重载:



  • Flutter支持热重载,允许开发者在运行时快速更新代码,无需重新编译整个应用。

  • 调试工具如DevTools提供了对应用性能、内存、CPU使用率的监控,以及源代码级别的调试。


性能优化:



  • Flutter通过AOT编译和Dart的垃圾回收机制来提高性能。

  • 使用const关键字创建Widget可以避免不必要的重建,减少渲染开销。


作者:天涯学馆
来源:juejin.cn/post/7378015213347913791
收起阅读 »

一条SQL 最多能查询出来多少条记录?

问题 一条这样的 SQL 语句能查询出多少条记录? select * from user 表中有 100 条记录的时候能全部查询出来返回给客户端吗? 如果记录数是 1w 呢? 10w 呢? 100w 、1000w 呢? 虽然在实际业务操作中我们不会这么干,...
继续阅读 »

问题


一条这样的 SQL 语句能查询出多少条记录?


select * from user 

表中有 100 条记录的时候能全部查询出来返回给客户端吗?


如果记录数是 1w 呢? 10w 呢? 100w 、1000w 呢?


虽然在实际业务操作中我们不会这么干,尤其对于数据量大的表不会这样干,但这是个值得想一想的问题。


寻找答案


前提:以下所涉及资料全部基于 MySQL 8


max_allowed_packet


在查询资料的过程中发现了这个参数 max_allowed_packet



上图参考了 MySQL 的官方文档,根据文档我们知道:



  • MySQL 客户端 max_allowed_packet 值的默认大小为 16M(不同的客户端可能有不同的默认值,但最大不能超过 1G)

  • MySQL 服务端 max_allowed_packet 值的默认大小为 64M

  • max_allowed_packet 值最大可以设置为 1G(1024 的倍数)


然而 根据上图的文档中所述



The maximum size of one packet or any generated/intermediate string,or any parameter sent by the mysql_smt_send_long_data() C API function




  • one packet

  • generated/intermediate string

  • any parameter sent by the mysql_smt_send_long_data() C API function


这三个东东具体都是什么呢? packet 到底是结果集大小,还是网络包大小还是什么? 于是 google 了一下,搜索排名第一的是这个:



根据 “Packet Too Large” 的说明, 通信包 (communication packet) 是



  • 一个被发送到 MySQL 服务器的单个 SQL 语句

  • 或者是一个被发送到客户端的单行记录

  • 或者是一个从主服务器 (replication source server) 被发送到从属服务器 (replica) 的二进制日志事件。


1、3 点好理解,这也同时解释了,如果你发送的一条 SQL 语句特别大可能会执行不成功的原因,尤其是insert update 这种,单个 SQL 语句不是没有上限的,不过这种情况一般不是因为 SQL 语句写的太长,主要是由于某个字段的值过大,比如有 BLOB 字段。


那么第 2 点呢,单行记录,默认值是 64M,会不会太大了啊,一行记录有可能这么大的吗? 有必要设置这么大吗? 单行最大存储空间限制又是多少呢?


单行最大存储空间


MySQL 单行最大宽度是 65535 个字节,也就是 64KB 。无论是 InnoDB 引擎还是 MyISAM 引擎。



通过上图可以看到 超过 65535 不行,不过请注意其中的错误提示:“Row size too large. The maximum row size for the used table type, not counting BLOBs, is 65535” ,如果字段是变长类型的如 BLOB 和 TEXT 就不包括了,那么我们试一下用和上图一样的字段长度,只把最后一个字段的类型改成 BLOB 和 TEXT


mysql> CREATE TABLE t (a VARCHAR(10000), b VARCHAR(10000),
c VARCHAR(10000), d VARCHAR(10000), e VARCHAR(10000),
f VARCHAR(10000), g TEXT(6000)) ENGINE=InnoDB CHARACTER SET latin1;
Query OK, 0 rows affected (0.02 sec)

可见无论 是改成 BLOB 还是 TEXT 都可以成功。但这里请注意,字符集是 latin1 可以成功,如果换成 utf8mb4 或者 utf8mb3 就不行了,会报错,仍然是 :“Row size too large. The maximum row size for the used table type, not counting BLOBs, is 65535.” 为什么呢?


因为虽然不包括 TEXT 和 BLOB, 但总长度还是超了!


我们先看一下这个熟悉的 VARCHAR(255) , 你有没有想过为什么用 255,不用 256?



在 4.0 版本以下,varchar(255) 指的是 255 个字节,使用 1 个字节存储长度即可。当大于等于 256 时,要使用 2 个字节存储长度。所以定义 varchar(255) 比 varchar(256) 更好。


但是在 5.0 版本以上,varchar(255) 指的是 255 个字符,每个字符可能占用多个字节,例如使用 UTF8 编码时每个汉字占用 3 字节,使用 GBK 编码时每个汉字占 2 字节。



例子中我们用的是 MySQL8 ,由于字符集是 utf8mb3 ,存储一个字要用三个字节, 长度为 255 的话(列宽),总长度要 765 字节 ,再加上用 2 个字节存储长度,那么这个列的总长度就是 767 字节。所以用 latin1 可以成功,是因为一个字符对应一个字节,而 utf8mb3 或 utf8mb4 一个字符对应三个或四个字节,VARCHAR(10000) 就可能等于要占用 30000 多 40000 多字节,比原来大了 3、4 倍,肯定放不下了。


另外,还有一个要求,列的宽度不要超过 MySQL 页大小 (默认 16K)的一半,要比一半小一点儿。 例如,对于默认的 16KB InnoDB 页面大小,最大行大小略小于 8KB。


下面这个例子就是超过了一半,所以报错,当然解决办法也在提示中给出了。


mysql> CREATE TABLE t4 (
c1 CHAR(255),c2 CHAR(255),c3 CHAR(255),
c4 CHAR(255),c5 CHAR(255),c6 CHAR(255),
c7 CHAR(255),c8 CHAR(255),c9 CHAR(255),
c10 CHAR(255),c11 CHAR(255),c12 CHAR(255),
c13 CHAR(255),c14 CHAR(255),c15 CHAR(255),
c16 CHAR(255),c17 CHAR(255),c18 CHAR(255),
c19 CHAR(255),c20 CHAR(255),c21 CHAR(255),
c22 CHAR(255),c23 CHAR(255),c24 CHAR(255),
c25 CHAR(255),c26 CHAR(255),c27 CHAR(255),
c28 CHAR(255),c29 CHAR(255),c30 CHAR(255),
c31 CHAR(255),c32 CHAR(255),c33 CHAR(255)
) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET latin1;
ERROR 1118 (42000): Row size too large (> 8126). Changing some columns to TEXT or BLOB may help.
In current row format, BLOB prefix of 0 bytes is stored inline.

那么为什么是 8K,不是 7K,也不是 9K 呢? 这么设计的原因可能是:MySQL 想让一个数据页中能存放更多的数据行,至少也得要存放两行数据(16K)。否则就失去了 B+Tree 的意义。B+Tree 会退化成一个低效的链表。


你可能还会奇怪,不超过 8K ?你前面的例子明明都快 64K 也能存下,那 8K 到 64K 中间这部分怎么解释?


答:如果包含可变长度列的行超过 InnoDB 最大行大小, InnoDB 会选择可变长度列进行页外存储,直到该行适合 InnoDB ,这也就是为什么前面有超过 8K 的也能成功,那是因为用的是VARCHAR这种可变长度类型。



当你往这个数据页中写入一行数据时,即使它很大将达到了数据页的极限,但是通过行溢出机制。依然能保证你的下一条数据还能写入到这个数据页中。


我们通过 Compact 格式,简单了解一下什么是 页外存储行溢出


MySQL8 InnoDB 引擎目前有 4 种 行记录格式:



  • REDUNDANT

  • COMPACT

  • DYNAMIC(默认 default 是这个)

  • COMPRESSED


行记录格式 决定了其行的物理存储方式,这反过来又会影响查询和 DML 操作的性能。



Compact 格式的实现思路是:当列的类型为 VARCHAR、 VARBINARY、 BLOB、TEXT 时,该列超过 768byte 的数据放到其他数据页中去。



在 MySQL 设定中,当 varchar 列长度达到 768byte 后,会将该列的前 768byte 当作当作 prefix 存放在行中,多出来的数据溢出存放到溢出页中,然后通过一个偏移量指针将两者关联起来,这就是 行溢出机制



假如你要存储的数据行很大超过了 65532byte 那么你是写入不进去的。假如你要存储的单行数据小于 65535byte 但是大于 16384byte,这时你可以成功 insert,但是一个数据页又存储不了你插入的数据。这时肯定会行溢出!



MySQL 这样做,有效的防止了单个 varchar 列或者 Text 列太大导致单个数据页中存放的行记录过少的情况,避免了 IO 飙升的窘境。


单行最大列数限制


mysql 单表最大列数也是有限制的,是 4096 ,但 InnoDB 是 1017



实验


前文中我们疑惑 max_allowed_packet 在 MySQL8 的默认值是 64M,又说这是限制单行数据的,单行数据有这么大吗? 在前文我们介绍了行溢出, 由于有了 行溢出 ,单行数据确实有可能比较大。


那么还剩下一个问题,max_allowed_packet 限制的确定是单行数据吗,难道不是查询结果集的大小吗 ? 下面我们做个实验,验证一下。


建表


CREATE TABLE t1 (
c1 CHAR(255),c2 CHAR(255),c3 CHAR(255),
c4 CHAR(255),c5 CHAR(255),c6 CHAR(255),
c7 CHAR(255),c8 CHAR(255),c9 CHAR(255),
c10 CHAR(255),c11 CHAR(255),c12 CHAR(255),
c13 CHAR(255),c14 CHAR(255),c15 CHAR(255),
c16 CHAR(255),c17 CHAR(255),c18 CHAR(255),
c19 CHAR(255),c20 CHAR(255),c21 CHAR(255),
c22 CHAR(255),c23 CHAR(255),c24 CHAR(255),
c25 CHAR(255),c26 CHAR(255),c27 CHAR(255),
c28 CHAR(255),c29 CHAR(255),c30 CHAR(255),
c31 CHAR(255),c32 CHAR(192)
) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET latin1;


经过测试虽然提示的是 Row size too large (> 8126) 但如果全部长度加起来是 8126 建表不成功,最终我试到 8097 是能建表成功的。为什么不是 8126 呢 ?可能是还需要存储一些其他的东西占了一些字节吧,比如隐藏字段什么的。


用存储过程造一些测试数据,把表中的所有列填满


create
definer = root@`%` procedure generate_test_data()
BEGIN
DECLARE i INT DEFAULT 0;
DECLARE col_value TEXT DEFAULT REPEAT('a', 255);
WHILE i < 5 DO
INSERT INTO t1 VALUES
(
col_value, col_value, col_value,
col_value, REPEAT('b', 192)
);
SET i = i + 1;
END WHILE;
END;


max_allowed_packet 设置的小一些,先用 show VARIABLES like '%max_allowed_packet%'; 看一下当前的大小,我的是 67108864 这个单位是字节,等于 64M,然后用 set global max_allowed_packet =1024 将它设置成允许的最小值 1024 byte。 设置好后,关闭当前查询窗口再新建一个,然后再查看:



这时我用 select * from t1; 查询表数据时就会报错:



因为我们一条记录的大小就是 8K 多了,所以肯定超过 1024byte。可见文档的说明是对的, max_allowed_packet 确实是可以约束单行记录大小的。


答案


文章写到这里,我有点儿写不下去了,一是因为懒,另外一个原因是关于这个问题:“一条 SQL 最多能查询出来多少条记录?” 肯定没有标准答案


目前我们可以知道的是:



  • 你的单行记录大小不能超过 max_allowed_packet

  • 一个表最多可以创建 1017 列 (InnoDB)

  • 建表时定义列的固定长度不能超过 页的一半(8k,16k...)

  • 建表时定义列的总长度不能超过 65535 个字节


如果这些条件我们都满足了,然后发出了一个没有 where 条件的全表查询 select * 那么.....


首先,你我都知道,这种情况不会发生在生产环境的,如果真发生了,一定是你写错了,忘了加条件。因为几乎没有这种要查询出所有数据的需求。如果有,也不能开发,因为这不合理。


我考虑的也就是个理论情况,从理论上讲能查询出多少数据不是一个确定的值,除了前文提到的一些条件外,它肯定与以下几项有直接的关系



  • 数据库的可用内存

  • 数据库内部的缓存机制,比如缓存区的大小

  • 数据库的查询超时机制

  • 应用的可用物理内存

  • ......


说到这儿,我确实可以再做个实验验证一下,但因为懒就不做了,大家有兴趣可以自己设定一些条件做个实验试一下,比如在特定内存和特定参数的情况下,到底能查询出多少数据,就能看得出来了。


虽然我没能给出文章开头问题的答案,但通过寻找答案也弄清楚了 MySQL 的一些限制条件,并加以了验证,也算是有所收获了。


参考



作者:xiaohezi
来源:juejin.cn/post/7255478273652834360
收起阅读 »

前任开发在代码里下毒了,支付下单居然没加幂等

故事又是一个风和日丽没好的一天,小猫戴着耳机,安逸地听着音乐,撸着代码,这种没有会议的日子真的是巴适得板。不料祸从天降,组长火急火燎地跑过来找到了小猫。“快排查一下,目前有A公司用户反馈积分被多扣了”。小猫回忆了一下“不对啊,这接口我也没动过啊,前几天对外平台...
继续阅读 »

故事

又是一个风和日丽没好的一天,小猫戴着耳机,安逸地听着音乐,撸着代码,这种没有会议的日子真的是巴适得板。

不料祸从天降,组长火急火燎地跑过来找到了小猫。“快排查一下,目前有A公司用户反馈积分被多扣了”。

小猫回忆了一下“不对啊,这接口我也没动过啊,前几天对外平台的老六直接找我要个支付接口,我就给他了的,以前的代码,我都没有动过的......”。

于是小猫一边疑惑一边翻看着以前的代码,越看脸色越差......

42175B273A64E95B1B5B66D392256552.jpg

小猫做的是一个标准的积分兑换商城,以前和客户合作的时候,客户直接用的是小猫单位自己定制的h5页面。这次合作了一家公司有点特殊,由于公司想要定制化自己个性化的H5,加上本身A公司自己有开发能力,所以经过讨论就以接口的方式直接将相关接口给出去,A客户H5开发完成之后自己来对接。

慢慢地,原因也水落石出,之前好好的业务一直没有问题是因为商城的本身H5页面做了防重复提交,由于量小,并且一般对接方式用的都是纯H5,所以都没有什么问题,然后这次是直接将接口给出去了,完了接口居然没有加幂等......

小猫躺枪,数据订正当然是少不了了,事故报告当然也少不了了。

正所谓前人挖坑,后人遭殃,前人锅后人背。

聊聊幂等

接口幂等梗概

这个案例其实就是一个典型的接口幂等案例。那么老猫就和大家从以下几个方面好好剖析一下接口幂等吧。

interfacemd.png

什么是接口幂等

比较专业的术语:其任意多次执行所产生的影响均与第一次执行的影响相同。 大白话:多次调用的情况下,接口最终得到的结果是一致的。

那么为什么需要幂等呢?

  1. 用户进行提交动作的时候,由于网络波动等原因导致后端同步响应不及时,这样用户就会一直点点点,这样机会发生重复提交的情况。
  2. 分布式系统之间调用的情况下,例如RPC调用,为了防止网络波动超时等造成的请求失败,都会添加重试机制,导致一个请求提交多次。
  3. 分布式系统经常会用到消息中间件,当由于网络原因,mq没有收到ack的情况下,就会导致消息的重复投递,从而就会导致重复提交行为。
  4. 还有就是恶意攻击了,有些业务接口做的比较粗糙,黑客找到漏洞之后会发起重复提交,这样就会导致业务出现问题。打个比方,老猫曾经干过,邻居小孩报名了一个画画比赛,估计是机构培训发起的,功能做的也差,需要靠投票赢得某些礼品,然后老猫抓到接口信息之后就模拟投票进行重复刷了投票。

那么哪些接口需要做幂等呢?

首先我们说是不是所有的接口都需要幂等?是不是加了幂等就好呢?显然不是。 因为接口幂等的实现某种意义上是要消耗系统性能的,我们没有必要针对所有业务接口都加上幂等。

这个其实并不能做一个完全的定义说哪个就不用幂等,因为很多时候其实还是得结合业务逻辑一起看。但是其中也是有规律可循的。

既然我们说幂等就是多次调用,接口最终得到结果一致,那么很显然,查询接口肯定是不要加幂等的,另外一些简单删除数据的接口,无论是逻辑删除还是物理删除,看场景的情况下其实也不用加幂等。

但是大部分涉及到多表更新行为的接口,咱们最好还是得加上幂等。

接口幂等实战方案

前端防抖处理

前端防抖主要可以有两种方案,一种是技术层面的,一种是产品层面的:

  1. 技术层面:例如提交控制在100ms内,同一个用户最多只能做一次订单提交的操作。
  2. 产品层面:当然用户点击提交之后,按钮直接置灰。

基于数据库唯一索引

  1. 利用数据库唯一索引。我们具体来看一下流程,咱们就用小猫遇到的例子。如下:

unique-key.png

过程描述:

  • 建立一张去重表,其中某个字段需要建立唯一索引,例如小猫这个场景中,咱们就可以将订单提交流水单号作为唯一索引存储到我们的数据库中,就模型上而言,可以将其定义为支付请求流水表。
  • 客户端携带相关流水信息到后端,如果发现编号重复,那么此时就会插入失败,报主键冲突的错误,此时我们针对该错误做一下业务报错的二次封装给到客户另一个友好的提示即可。

数据库乐观锁实现

什么是乐观锁,它假设多用户并发的事务在处理时不会彼此互相影响,各事务能够在不产生锁的情况下处理各自影响的那部分数据。 说得直白一点乐观锁就是一个马大哈。总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,只在更新的时候会判断一下在此期间别人有没有去更新这个数据。

例如提交订单的进行支付扣款的时候,本来可能更新账户金额扣款的动作是这样的:

update Account set balance = balance-#{payAmount} where accountCode = #{accountCode}

加上版本号之后,咱们的代码就是这样的。

update Account set balance = balance-#{payAmount},version=version +1 where accountCode = #{accountCode} and version = #{currVersion}

这种情况下其实就要求客户端每次在请求支付下单的时候都需要上层客户端指定好当前的版本信息。 不过这种幂等的处理方式,老猫用的比较少。

数据库悲观锁实现

悲观锁的话具有强烈的独占和排他特性。大白话谁都不信的主。所以我们就用select ... for update这样的语法进行行锁,当然老猫觉得单纯的select ... for update只能解决同一时刻大并发的幂等,所以要保证单号重试这样非并发的幂等请求还是得去校验当前数据的状态才行。就拿当前的小猫遇到的场景来说,流程如下:

pessimistic.png

begin;  # 1.开始事务
select * from order where order_code='666' for update # 查询订单,判断状态,锁住这条记录
if(status !=处理中){
//非处理中状态,直接返回;
return ;
}
## 处理业务逻辑
update order set status='完成' where order_code='666' # 更新完成
update stock set num = num - 1 where spu='xxx' # 库存更新
commit; # 5.提交事务

这里老猫一再想要强调的是在校验的时候还是得带上本身的业务状态去做校验,select ... for update并非万能幂等。

后端生成token

这个方案的本质其实是引入了令牌桶的机制,当提交订单的时候,前端优先会调用后端接口获取一个token,token是由后端发放的。当然token的生成方式有很多种,例如定时刷新令牌桶,或者定时生成令牌并放到令牌池中,当然目的只有一个就是保住token的唯一性即可。

生成token之后将token放到redis中,当然需要给token设置一个失效时间,超时的token也会被删除。

当后端接收到订单提交的请求的时候,会先判断token在缓存中是否存在,第一次请求的时候,token一定存在,也会正常返回结果,但是第二次携带同一个token的时候被拒绝了。

流程如下:

token.png

有个注意点大家可以思考一下: 如果用户用程序恶意刷单,同一个token发起了多次请求怎么办? 想要实现这个功能,就需要借助分布式锁以及Lua脚本了,分布式锁可以保证同一个token不能有多个请求同时过来访问,lua脚本保证从redis中获取令牌->比对令牌->生成单号->删除令牌这一系列行为的原子性。

分布式锁+状态机(订单状态)

现在很多的业务服务都是分布式系统,所以就拿分布式锁来说,关于分布式锁,老猫在此不做赘述,之前老猫写过redis的分布式锁和实现,还有zk锁和实现,具体可见链接:

当然和上述的数据库悲观锁类似,咱们的分布式锁也只能保证同一个订单在同一时间的处理。其次也是要去校订单的状态,防止其重复支付的,也就是说,只要支付的订单进入后端,都要将原先的订单修改为支付中,防止后续支付中断之后的重复支付。

在上述小猫的流程中还没有涉及到现金补充,如果涉及到现金补充的话,例如对接了微信或者支付宝的情况,还需要根据最终的支付回调结果来最终将订单状态进行流转成支付完成或者是支付失败。

总结

在我们日常的开发中,一些重要的接口还是需要大家谨慎对待,即使是前任开发留下的接口,没有任何改动,当有人咨询的时候,其实就要好好去了解一下里面的实现,看看方案有没有问题,看看技术实现有没有问题,这应该也是每一个程序员的基本素养。

另外的,在一些重要的接口上,尤其是资金相关的接口上,幂等真的是相当的重要。小伙伴们,你们觉得呢?如果大家还有好的解决方案,或者有其他思考或者意见也欢迎大家的留言。


作者:程序员老猫
来源:juejin.cn/post/7324186292297482290
收起阅读 »

Stream很好,Map很酷,但答应我别用toMap()

在 JDK 8 中 Java 引入了让人欲罢不能的 stream 流处理,可以说已经成为了我日常开发中不可或缺的一部分。 当完成一次流处理之后需要返回一个集成对象时,已经肌肉记忆的敲下 collect(Collectors.toList()) 或者 colle...
继续阅读 »

JDK 8Java 引入了让人欲罢不能的 stream 流处理,可以说已经成为了我日常开发中不可或缺的一部分。


当完成一次流处理之后需要返回一个集成对象时,已经肌肉记忆的敲下 collect(Collectors.toList()) 或者 collect(Collectors.toSet())。你可能会想,toListtoSet 都这么便捷顺手了,当又怎么能少得了 toMap() 呢。


答应我,一定打消你的这个想法,否则这将成为你噩梦的开端。


image.png


什么?你不信,没有什么比代码让人更痛彻心扉,让我们直接上代码。


让我们先准备一个用户实体类。


@Data
@AllArgsConstructor
public class User {

private int id;

private String name;
}

假设有这么一个场景,你从数据库读取 User 集合,你需要将其转为 Map 结构数据,keyvalue 分别为 useridname


很快,你啪的一下就写出了下面的代码:


public class UserTest {
@Test
public void demo() {
List<User> userList = new ArrayList<>();
// 模拟数据
userList.add(new User(1, "Alex"));
userList.add(new User(1, "Beth"));

Map<Integer, String> map = userList.stream()
.collect(Collectors.toMap(User::getId, User::getName));
System.out.println(map);
}
}

运行程序,你已经想好了开始怎么摸鱼,结果啪的一下 IllegalStateException 报错就拍你脸上,你定睛一看怎么提示 Key 值重复。


image.png


作为优秀的八股文选手,你清楚的记得 HashMap 对象 Key 重复是进行替换。你不信邪,断点一打,堆栈一看,硕大的 uniqKeys 摆在了面前,凭借四级 424 分的优秀战绩你顿时菊花一紧,点开一看,谁家好人 map key 还要去重判断啊。


image.png


好好好,这么玩是吧,你转身打开浏览器一搜,原来需要自己手动处理重复场景,啪的一下你又重新改了一下代码:


public class UserTest {
@Test
public void demo() {
List<User> userList = new ArrayList<>();
// 模拟数据
userList.add(new User(1, "Alex"));
userList.add(new User(2, null));

Map<Integer, String> map = userList.stream()
.collect(Collectors.toMap(User::getId, User::getName, (oldData, newData) -> newData));
System.out.println(map);
}
}

再次执行程序,你似乎已经看到知乎的摸鱼贴在向你招手了,结果啪的一下 NPE 又拍在你那笑容渐渐消失的脸上。



静下心来,本着什么大风大浪我没见过的心态,断点堆栈一气呵成,而下一秒你又望着代码陷入了沉思,我是谁?我在干什么?




鼓起勇气,你还不信今天就过不去这个坎了,大手一挥,又一段优雅的代码孕育而生。


public class UserTest {
@Test
public void demo() {
List<User> userList = new ArrayList<>();
// 模拟数据
userList.add(new User(1, "Alex"));
userList.add(new User(1, "Beth"));
userList.add(new User(2, null));

Map<Integer, String> map = userList.stream()
.collect(Collectors.toMap(
User::getId,
it -> Optional.ofNullable(it.getName()).orElse(""),
(oldData, newData) -> newData)
);
System.out.println(map);
}
}

优雅,真是太优雅了,又是 Stream 又是 Optional,可谓是狠狠拿捏技术博文的 G 点了。


image.png


这时候你回头一看,我需要是什么来着?这 TM 不是一个循环就万事大吉了吗,不信邪的你回归初心,回归了 for 循环的怀抱,又写了一版。


public class UserTest {
@Test
public void demo() {
List<User> userList = new ArrayList<>();
// 模拟数据
userList.add(new User(1, "Alex"));
userList.add(new User(1, "Beth"));
userList.add(new User(2, null));

Map<Integer, String> map = new HashMap<>();
userList.forEach(it -> {
map.put(it.getId(), it.getName());
});
System.out.println(map);
}
}

看着运行完美无缺的代码,你一时陷入了沉思,数分钟过去了,你删除了 for 循环,换上 StreamOptional 不羁的外衣,安心的提交了代码,这口细糠一定也要让好同事去尝一尝。


image.png


作者:烽火戏诸诸诸侯
来源:juejin.cn/post/7383643463534018579
收起阅读 »

写了一个责任链模式,bug 无数...

责任链模式是一种行为设计模式, 允许你将请求沿着处理者链进行发送。 收到请求后, 每个处理者均可对请求进行处理, 或将其传递给链上的下个处理者。 使用场景 责任链的使用场景还是比较多的: 多条件流程判断:权限控制 ERP 系统流程审批:总经理、人事经理、项...
继续阅读 »

责任链模式是一种行为设计模式, 允许你将请求沿着处理者链进行发送。


收到请求后, 每个处理者均可对请求进行处理, 或将其传递给链上的下个处理者。


图片


使用场景


责任链的使用场景还是比较多的:



  • 多条件流程判断:权限控制

  • ERP 系统流程审批:总经理、人事经理、项目经理

  • Java 过滤器的底层实现 Filter


如果不使用该设计模式,那么当需求有所改变时,就会使得代码臃肿或者难以维护,例如下面的例子。


| 反例


假设现在有一个闯关游戏,进入下一关的条件是上一关的分数要高于 xx:



  • 游戏一共 3 个关卡

  • 进入第二关需要第一关的游戏得分大于等于 80

  • 进入第三关需要第二关的游戏得分大于等于 90


那么代码可以这样写:


//第一关
public class FirstPassHandler {
    public int handler(){
        System.out.println("第一关-->FirstPassHandler");
        return 80;
    }
}

//第二关
public class SecondPassHandler {
    public int handler(){
        System.out.println("第二关-->SecondPassHandler");
        return 90;
    }
}

//第三关
public class ThirdPassHandler {
    public int handler(){
        System.out.println("第三关-->ThirdPassHandler,这是最后一关啦");
        return 95;
    }
}

//客户端
public class HandlerClient {
    public static void main(String[] args) {

        FirstPassHandler firstPassHandler = new FirstPassHandler();//第一关
        SecondPassHandler secondPassHandler = new SecondPassHandler();//第二关
        ThirdPassHandler thirdPassHandler = new ThirdPassHandler();//第三关

        int firstScore = firstPassHandler.handler();
        //第一关的分数大于等于80则进入第二关
        if(firstScore >= 80){
            int secondScore = secondPassHandler.handler();
            //第二关的分数大于等于90则进入第二关
            if(secondScore >= 90){
                thirdPassHandler.handler();
            }
        }
    }
}

那么如果这个游戏有 100 关,我们的代码很可能就会写成这个样子:


if(第1关通过){
    // 第2关 游戏
    if(第2关通过){
        // 第3关 游戏
        if(第3关通过){
           // 第4关 游戏
            if(第4关通过){
                // 第5关 游戏
                if(第5关通过){
                    // 第6关 游戏
                    if(第6关通过){
                        //...
                    }
                }
            }
        }
    }
}

这种代码不仅冗余,并且当我们要将某两关进行调整时会对代码非常大的改动,这种操作的风险是很高的,因此,该写法非常糟糕。


| 初步改造


如何解决这个问题,我们可以通过链表将每一关连接起来,形成责任链的方式,第一关通过后是第二关,第二关通过后是第三关....


这样客户端就不需要进行多重 if 的判断了:


public class FirstPassHandler {
    /**
     * 第一关的下一关是 第二关
     */

    private SecondPassHandler secondPassHandler;

    public void setSecondPassHandler(SecondPassHandler secondPassHandler) {
        this.secondPassHandler = secondPassHandler;
    }

    //本关卡游戏得分
    private int play(){
        return 80;
    }

    public int handler(){
        System.out.println("第一关-->FirstPassHandler");
        if(play() >= 80){
            //分数>=80 并且存在下一关才进入下一关
            if(this.secondPassHandler != null){
                return this.secondPassHandler.handler();
            }
        }

        return 80;
    }
}

public class SecondPassHandler {

    /**
     * 第二关的下一关是 第三关
     */

    private ThirdPassHandler thirdPassHandler;

    public void setThirdPassHandler(ThirdPassHandler thirdPassHandler) {
        this.thirdPassHandler = thirdPassHandler;
    }

    //本关卡游戏得分
    private int play(){
        return 90;
    }

    public int handler(){
        System.out.println("第二关-->SecondPassHandler");

        if(play() >= 90){
            //分数>=90 并且存在下一关才进入下一关
            if(this.thirdPassHandler != null){
                return this.thirdPassHandler.handler();
            }
        }

        return 90;
    }
}

public class ThirdPassHandler {

    //本关卡游戏得分
    private int play(){
        return 95;
    }

    /**
     * 这是最后一关,因此没有下一关
     */

    public int handler(){
        System.out.println("第三关-->ThirdPassHandler,这是最后一关啦");
        return play();
    }
}

public class HandlerClient {
    public static void main(String[] args) {

        FirstPassHandler firstPassHandler = new FirstPassHandler();//第一关
        SecondPassHandler secondPassHandler = new SecondPassHandler();//第二关
        ThirdPassHandler thirdPassHandler = new ThirdPassHandler();//第三关

        firstPassHandler.setSecondPassHandler(secondPassHandler);//第一关的下一关是第二关
        secondPassHandler.setThirdPassHandler(thirdPassHandler);//第二关的下一关是第三关

        //说明:因为第三关是最后一关,因此没有下一关
        //开始调用第一关 每一个关卡是否进入下一关卡 在每个关卡中判断
        firstPassHandler.handler();

    }
}

| 缺点


现有模式的缺点:



  • 每个关卡中都有下一关的成员变量并且是不一样的,形成链很不方便

  • 代码的扩展性非常不好,最新设计模式面试题整理好了


| 责任链改造


既然每个关卡中都有下一关的成员变量并且是不一样的,那么我们可以在关卡上抽象出一个父类或者接口,然后每个具体的关卡去继承或者实现。


有了思路,我们先来简单介绍一下责任链设计模式的基本组成:



  • 抽象处理者(Handler)角色: 定义一个处理请求的接口,包含抽象处理方法和一个后继连接。

  • 具体处理者(Concrete Handler)角色: 实现抽象处理者的处理方法,判断能否处理本次请求,如果可以处理请求则处理,否则将该请求转给它的后继者。

  • 客户类(Client)角色: 创建处理链,并向链头的具体处理者对象提交请求,它不关心处理细节和请求的传递过程。


图片


public abstract class AbstractHandler {

    /**
     * 下一关用当前抽象类来接收
     */

    protected AbstractHandler next;

    public void setNext(AbstractHandler next) {
        this.next = next;
    }

    public abstract int handler();
}

public class FirstPassHandler extends AbstractHandler{

    private int play(){
        return 80;
    }

    @Override
    public int handler(){
        System.out.println("第一关-->FirstPassHandler");
        int score = play();
        if(score >= 80){
            //分数>=80 并且存在下一关才进入下一关
            if(this.next != null){
                return this.next.handler();
            }
        }
        return score;
    }
}

public class SecondPassHandler extends AbstractHandler{

    private int play(){
        return 90;
    }

    public int handler(){
        System.out.println("第二关-->SecondPassHandler");

        int score = play();
        if(score >= 90){
            //分数>=90 并且存在下一关才进入下一关
            if(this.next != null){
                return this.next.handler();
            }
        }

        return score;
    }
}

public class ThirdPassHandler extends AbstractHandler{

    private int play(){
        return 95;
    }

    public int handler(){
        System.out.println("第三关-->ThirdPassHandler");
        int score = play();
        if(score >= 95){
            //分数>=95 并且存在下一关才进入下一关
            if(this.next != null){
                return this.next.handler();
            }
        }
        return score;
    }
}

public class HandlerClient {
    public static void main(String[] args) {

        FirstPassHandler firstPassHandler = new FirstPassHandler();//第一关
        SecondPassHandler secondPassHandler = new SecondPassHandler();//第二关
        ThirdPassHandler thirdPassHandler = new ThirdPassHandler();//第三关

        // 和上面没有更改的客户端代码相比,只有这里的set方法发生变化,其他都是一样的
        firstPassHandler.setNext(secondPassHandler);//第一关的下一关是第二关
        secondPassHandler.setNext(thirdPassHandler);//第二关的下一关是第三关

        //说明:因为第三关是最后一关,因此没有下一关

        //从第一个关卡开始
        firstPassHandler.handler();

    }
}

| 责任链工厂改造


对于上面的请求链,我们也可以把这个关系维护到配置文件中或者一个枚举中。我将使用枚举来教会大家怎么动态的配置请求链并且将每个请求者形成一条调用链。


图片


public enum GatewayEnum {
    // handlerId, 拦截者名称,全限定类名,preHandlerId,nextHandlerId
    API_HANDLER(new GatewayEntity(1"api接口限流""cn.dgut.design.chain_of_responsibility.GateWay.impl.ApiLimitGatewayHandler"null2)),
    BLACKLIST_HANDLER(new GatewayEntity(2"黑名单拦截""cn.dgut.design.chain_of_responsibility.GateWay.impl.BlacklistGatewayHandler"13)),
    SESSION_HANDLER(new GatewayEntity(3"用户会话拦截""cn.dgut.design.chain_of_responsibility.GateWay.impl.SessionGatewayHandler"2null)),
    ;

    GatewayEntity gatewayEntity;

    public GatewayEntity getGatewayEntity() {
        return gatewayEntity;
    }

    GatewayEnum(GatewayEntity gatewayEntity) {
        this.gatewayEntity = gatewayEntity;
    }
}

public class GatewayEntity {

    private String name;

    private String conference;

    private Integer handlerId;

    private Integer preHandlerId;

    private Integer nextHandlerId;
}

public interface GatewayDao {

    /**
     * 根据 handlerId 获取配置项
     * 
@param handlerId
     * 
@return
     */

    GatewayEntity getGatewayEntity(Integer handlerId);

    /**
     * 获取第一个处理者
     * 
@return
     */

    GatewayEntity getFirstGatewayEntity();
}

public class GatewayImpl implements GatewayDao {

    /**
     * 初始化,将枚举中配置的handler初始化到map中,方便获取
     */

    private static Map gatewayEntityMap = new HashMap<>();

    static {
        GatewayEnum[] values = GatewayEnum.values();
        for (GatewayEnum value : values) {
            GatewayEntity gatewayEntity = value.getGatewayEntity();
            gatewayEntityMap.put(gatewayEntity.getHandlerId(), gatewayEntity);
        }
    }

    @Override
    
public GatewayEntity getGatewayEntity(Integer handlerId) {
        return gatewayEntityMap.get(handlerId);
    }

    @Override
    
public GatewayEntity getFirstGatewayEntity() {
        for (Map.Entry entry : gatewayEntityMap.entrySet()) {
            GatewayEntity value = entry.getValue();
            //  没有上一个handler的就是第一个
            if (value.getPreHandlerId() == null) {
                return value;
            }
        }
        return null;
    }
}

public class GatewayHandlerEnumFactory {

    private static GatewayDao gatewayDao = new GatewayImpl();

    // 提供静态方法,获取第一个handler
    public static GatewayHandler getFirstGatewayHandler() {

        GatewayEntity firstGatewayEntity = gatewayDao.getFirstGatewayEntity();
        GatewayHandler firstGatewayHandler = newGatewayHandler(firstGatewayEntity);
        if (firstGatewayHandler == null) {
            return null;
        }

        GatewayEntity tempGatewayEntity = firstGatewayEntity;
        Integer nextHandlerId = null;
        GatewayHandler tempGatewayHandler = firstGatewayHandler;
        // 迭代遍历所有handler,以及将它们链接起来
        while ((nextHandlerId = tempGatewayEntity.getNextHandlerId()) != null) {
            GatewayEntity gatewayEntity = gatewayDao.getGatewayEntity(nextHandlerId);
            GatewayHandler gatewayHandler = newGatewayHandler(gatewayEntity);
            tempGatewayHandler.setNext(gatewayHandler);
            tempGatewayHandler = gatewayHandler;
            tempGatewayEntity = gatewayEntity;
        }
    // 返回第一个handler
        return firstGatewayHandler;
    }

    /**
     * 反射实体化具体的处理者
     * 
@param firstGatewayEntity
     * 
@return
     */

    private static GatewayHandler newGatewayHandler(GatewayEntity firstGatewayEntity) {
        // 获取全限定类名
        String className = firstGatewayEntity.getConference();
        try {
            // 根据全限定类名,加载并初始化该类,即会初始化该类的静态段
            Class clazz = Class.forName(className);
            return (GatewayHandler) clazz.newInstance();
        } catch (ClassNotFoundException | IllegalAccessException | InstantiationException e) {
            e.printStackTrace();
        }
        return null;
    }

}

public class GetewayClient {
    public static void main(String[] args) {
        GetewayHandler firstGetewayHandler = GetewayHandlerEnumFactory.getFirstGetewayHandler();
        firstGetewayHandler.service();
    }
}

设计模式有很多,责任链只是其中的一种,我觉得很有意思,非常值得一学。设计模式确实是一门艺术,仍需努力呀!




作者:程序员蜗牛
来源:juejin.cn/post/7383643463534067731
收起阅读 »

HTTP3为什么抛弃了经典的TCP,转而拥抱 QUIC 呢

我们在看一些关于计算机网络的数据或文章的时候,最常听到的就是 TCP、UDP、HTTP 这些,除此之外,我们或多或少可能听过 QUIC这个东西,一般跟这个词一起出现的是 HTTP3,也就是HTTP协议的3.0版本,未来2.x 版本的升级方案。QUIC...
继续阅读 »

我们在看一些关于计算机网络的数据或文章的时候,最常听到的就是 TCP、UDP、HTTP 这些,除此之外,我们或多或少可能听过 QUIC这个东西,一般跟这个词一起出现的是 HTTP3,也就是HTTP协议的3.0版本,未来2.x 版本的升级方案。

QUIC 由 Google 主导设计研发。我们都知道 HTTP 协议是应用层协议,在传输层它使用的是 TCP 作为传输协议,当然这仅仅是对于 HTTP/1 和 HTTP/2 而言的。而 QUIC 的设计的对标协议就是 TCP ,也就是说将来只要能使用 TCP 的地方,都可以用 QUIC 代替。

Google 最开始的目的就是为了替换 HTTP 协议使用的 TCP 协议,所以最开始的名字叫做 HTTP over QUIC,后来由 IETF 接管后更名为 HTTP/3。所以,现在说起 HTTP/3 ,实际指的就是利用 QUIC 协议的版本。

TCP 不好吗,为什么还要 QUIC

TCP 协议作为传输层最负盛名的协议,可谓是久经考验。只要一说到 TCP ,我们都能说出来它是一种面向连接的、可靠的、基于字节流的传输层通信协议。

TCP 通过三次握手的方式建立连接,并且通过序号、ACK确认、丢包重传以及流量控制、拥塞控制等各种繁琐又细致的方式来保证它的可靠性。

关于 TCP 的更多细节,有兴趣的可以读读我之前写的《轻解计算机网络》里的 一个网管的自我修养之TCP协议

看上去很完美了,那为什么还要重新搞个 QUIC 出来呢,而且还被作为下一代 HTTP 的实现协议。确实不存在完美的协议,TCP 协议在整个发展过程中经过了多次改进,但是由于牵扯到互联网世界浩如烟海的各种设备,每次更新、修改都要考虑兼容问题,历史包袱太重,以至于尾大不掉。

所以为了弥补 TCP 的不足,在 TCP 上直接修改不太可能,那最好的方案就是重新开发一套协议。这种协议要吸收 TCP 的精华,又要解决 TCP 的不足,这就是 QUIC 出现的意义。

TCP 的问题-队头阻塞

时至今日,互联网上大多数网站都已经支持 HTTP/2 协议了,你可以在浏览器开发者工具中看一下网络请求,其中的 Protocol 表示网络请求采用的协议。

image.png

HTTP/2的一个主要特性是使用多路复用(multiplexing),因而它可以通过同一个TCP连接发送多个逻辑数据流。复用使得很多事情变得更快更好,它带来更好的拥塞控制、更充分的带宽利用、更长久的TCP连接————这些都比以前更好了,链路能更容易实现全速传输。标头压缩技术也减少了带宽的用量。

采用HTTP/2后,浏览器对每个主机一般只需要 一个 TCP连接,而不是以前常见的六个连接。

如下图所示,HTTP/2 在使用 TCP 传输数据的时候,可以在一个连接上传输两个不同的流,红色是一个流,绿色是另外一个流,但是仍然是按顺序传输的,假设其中有一个包丢了,那整个链路上这个包后面的部分都要等待。

image.png

这就造成了阻塞,虽然一个连接可传多个流,但仍然存在单点问题。这个问题就叫做队头阻塞。

QUIC 如何解决的

TCP 这个问题是无解的,QUIC 就是为了彻底解决这个问题。

如下图所示,两台设备之间建立的是一个 QUIC 连接,但是可以同时传输多个相互隔离的数据流。例如黄色是一个数据流,蓝色是一个数据流,它俩互不影响,即便其中一个数据流有丢包的问题,也完全不会影响到其他的数据流传输。

这样一来,也就解决了 TCP 的队头阻塞问题。

image.png

为什么要基于 UDP 协议

QUIC 虽然是和TCP 平行的传输协议,工作在传输层,但是其并不是完全采用全新设计的,而是对 UDP 协议进行了包装。

UDP 是无连接的,相对于 TCP 来说,无连接就是不可靠的,没有三次握手,没有丢包重传,没有各种各样的复杂算法,但这带来了一个好处,那就是速度快。

而 QUIC 为了达到 TCP 的可靠性,所以在 UDP 的基础上增加了序号机制、丢包重传等等 UDP 没有而 TCP 具有的特性。

既然这么多功能都做了,还差一个 UDP 吗,完全全新设计一个不好吗,那样更彻底呀。

之所以不重新设计应该有两个原因:

  1. UDP 本身就是非常经典的传输层协议,对于快速传输来说,其功能完全没有问题。
  2. 还有一个重要的原因,前面也说到了,互联网上的设备太多,而很多设备只认 TCP 和 UDP 协议,如果设计一个完全全新的协议,很难实施。

QUIC 协议

不需要三次握手

QUIC 建立连接的速度是非常快的,不需要 TCP 那样的三次握手,称之为 0-RTT(零往返时间)及 1-RTT(1次往返时间)。

QUIC 使用了TLS 1.3传输层安全协议,所以 QUIC 传输的数据都是加密的,也就是说 HTTP/3 直接就是 HTTPS 的,不存在 HTTP 的非加密版本。

正是因为这样,所以,QUIC 建立连接的过程就是 TLS 建立连接的过程,如下图这样,首次建立连接只需要 1-RTT。

image.png

而在首次连接建立之后,QUIC 客户端就缓存了服务端发来的 Server Hello,也就是加密中所需要的一些内容。在之后重新建立连接时,只需要根据缓存内容直接加密数据,所以可以在客户端向服务端发送连接请求的同时将数据也一并带过去,这就是 0-RTT 。

连接不依靠 IP

QUIC 在建立连接后,会为这个连接分配一个连接 ID,用这个 ID 可以识别出具体的连接。

假设我正在家里用 WIFI 发送请求,但是突然有事儿出去了,马上切换到了蜂窝网络,那对于 QUIC 来说就没有什么影响。因为这个连接没有变,所以仍然可以继续执行请求,数据该怎么传还怎么传。

而如果使用的是 TCP 协议的话,那只能重新建立连接,重传之前的数据,因为 TCP 的寻址依靠的是 IP 和 端口。

未来展望

随着 QUIC 协议的不断完善和推广,其应用场景将更加广泛,对互联网传输技术产生深远的影响。未来的互联网,将是 QUIC 和 HTTP3 主导的时代。

要知道,HTTP/1 到 HTTP/2,中间用了整整 16 年才完成,这还只是针对协议做改进和优化,而 QUIC 可谓是颠覆性的修改,可想而知,其过程只会更加漫长。



作者:古时的风筝
来源:juejin.cn/post/7384266820466180148
收起阅读 »

Moka Ascend 2024|势在·人为,技术创新,激发企业管理内在效能

2024年6月19日,Moka 在深圳举办的 Moka Ascend 2024 产品发布会圆满落幕。此次大会以「势在·人为」为主题,汇聚了来自不同企业的管理者、人力资源专家和技术创新者,共同探讨和分享他们在人力资源管理、组织效能提升以及全球化招聘等方面的最新实...
继续阅读 »

2024年6月19日,Moka 在深圳举办的 Moka Ascend 2024 产品发布会圆满落幕。此次大会以「势在·人为」为主题,汇聚了来自不同企业的管理者、人力资源专家和技术创新者,共同探讨和分享他们在人力资源管理、组织效能提升以及全球化招聘等方面的最新实践和深刻见解。

Moka 联合创始人兼 CEO 李国兴在题为「势在必行:激发管理效率的内在势能」的主题演讲中,正式发布「Moka 人效管理解决方案」,以回应当前企业对经营和管理的诉求。要看清楚「人效」,管理者不应该只是得到事后汇报和对补救过程做出决策,而是真的需要一个内嵌于HR业务中,涵盖企业人力资源管理全流程,具备人力资源全面效能的工具。「Moka 人效管理解决方案」从成本到人效,从规划到落地,让决策更有依据,让管理更有保障,为企业健康发展[ 控本增效 ],帮助企业更清晰而全面地看到人力资源效能的运作情况,也让过程中的管理价值不断重复循环起来,「看得清」才能更好的「管得住」。

今年1月初,Moka 携手钉钉,推出「钉钉人事旗舰版」,致力于解决中大型企业在 HR 软件的使用过程中面临的系统割裂、数据孤岛等问题。此产品一经推出,对于整个行业来说,都是让人眼前一亮的「HR SaaS 新物种」。

钉钉生态业务部总经理陈霆旭与李国兴一起就合作后的进展和成绩进行了分享。在阐述钉钉开放生态战略合作的构想时,陈霆旭提到,在人事场景方面,Moka 是理想的合作伙伴,我们双方通过深度融合的方式,创新性地满足了客户需求。「钉钉人事旗舰版」之所以能被市场快速验证和接纳,本质还为了更好满足客户价值,大家在价值链上找到了更精细的分工和更清晰的边界,干自己更擅长的事,钉钉有非常丰富的IM、OA、文档、音视频等协同办公能力,也让企业的组织数字化实现零门槛,Moka在人事领域也有非常深的积淀,有了这种强强融合共建的产品能给员工和HR带来前所未有的创新体验,为客户带来「1+1>2」的价值和效果。

除了「看得清」,「看得远」成为企业不断进步与更新的动力。新一代大模型时代到来,AI 技术越来越多的可能性呈现在大众面前。Moka 合伙人兼 CTO 刘洪泽在大会上分享了 Moka 在 AI 领域的技术创新和应用实践。以 AI 原生为理念,深入业务场景,Moka Eva 的能力不断进化,不仅完成了从智能面试到智能招聘解决方案的升级,同时也赋能于全新功能「SmartPractice」,集成多国家和地区招聘最佳实践,智能辅助HR全球招聘,全流程提升招聘效率与招聘质量。

同时,AI 亦全面赋能 Moka 技术平台,构建新一代底层平台能力。伴随着新加坡数据中心的投入使用,以及一直以来与合作伙伴的生态共融,Moka 将与更多客户实现合作共赢,不断向打造世界级HR产品的愿景靠近。

企业成长是个超长周期的动态过程,过程中企业会面临市场内卷、业务难管等挑战,针对企业发展难题,各行业有成功实践经验的管理者提出新解法。

本次大会亦邀请了多位企业管理者分享了他们在人效管理、本地化人才战略和全球化招聘方面的深刻见解和实践经验。来自卓尔数科的CHO屠俊强调了在不确定性环境下,"1+3"模型发挥着重要作用,在应用SOTE模型的同时,构建组织力、业务力和影响力,实现业务与组织管理的深度融合。KLOOK 客路旅行的中国区人力资源总监戴良辰则分享了如何通过业务共创和团队成长两大维度,落地本地招聘,推动业务快速发展。

此外,本次大会还举办了题为”无界之帆:全球化招聘的挑战和实践“的圆桌论坛,由李国兴主持,邀请了携程集团招聘运营负责人梁昊耘、AfterShip Global HRD Flora Zeng和海辰储能招聘总监金一凡,共同探讨海外团队组建的挑战与经验,如何在全球范围内构建雇主品牌,以及如何利用系统和工具优化招聘流程。这些分享不仅展现了中国企业在全球化浪潮中的积极作为,也为其他企业提供了宝贵的参考和启示。

势在人为,破局而出。Moka 未来仍会继续坚持「全员体验更好」的产品理念,响应客户需求,持续优化产品与服务,助力企业实现更高效、更智能的人事管理。同时,Moka 也将积极与各方共同探索人事管理新机遇,在动态的变化中寻求可期待的攀升,保持始终向上的能量。

收起阅读 »

打脸了,rust确实很快

正文: 原标题:不要盲目迷信rust,rust或许没有你想象中的那么快 之前的文章内容是关于rust速度不快的问题。但是我遗留的问题是,计算出来的哈希值不一样。经过老哥的指点,原来是我js代码的实现有点问题,让大家见笑了。 于是我修改代码,重新测试了一遍,这...
继续阅读 »

正文:


原标题:不要盲目迷信rust,rust或许没有你想象中的那么快


之前的文章内容是关于rust速度不快的问题。但是我遗留的问题是,计算出来的哈希值不一样。经过老哥的指点,原来是我js代码的实现有点问题,让大家见笑了。


image.png


于是我修改代码,重新测试了一遍,这时哈希值就一样了,并且计算的结果确实是rust要快。刚好拿来佐证rust快的事实。


image.png


以后一定经过仔细验证,再发表文章。原文不删,留作警醒。


以下是原文:


原文:


先说结论:抛开应用场景,单说语言速度都是耍流氓。因为js调度rust,会有时间损耗。所以rust在一定应用场景下,比js还慢。


# 使用 wasm 提高前端20倍的 md5 计算速度


前两天看了一篇文件,是使用rust和wasm来加快md5的计算时间。跑了他的demo,发现只有rust的demo,而没有js的对比,于是我fork项目后,补充了一个js的对比。


image.png


测试下来,发现rust并没有比js快多少,由于浏览器限制,我只能用2GB文件来测,不知道是不是这个原因。还是我使用的js的原因。至少在2GB的边界时,js比rust要快。


他rust部分我没有动,只是添加了一个js的对比,如果大家觉得我的js写得有问题,欢迎pr重新比较。


在线对比地址:minori-ty.github.io/digest-wasm…


项目地址: github.com/Minori-ty/d…


作者:天平
来源:juejin.cn/post/7359757993732734991
收起阅读 »

手把手带你开发一套用户权限系统,精确到按钮级

在实际的软件项目开发过程中,用户权限控制可以说是所有运营系统中必不可少的一个重点功能,根据业务的复杂度,设计的时候可深可浅,但无论怎么变化,设计的思路基本都是围绕着用户、角色、菜单这三个部分展开。 如何设计一套可以精确到按钮级别的用户权限功能呢? 今天通过这篇...
继续阅读 »

在实际的软件项目开发过程中,用户权限控制可以说是所有运营系统中必不可少的一个重点功能,根据业务的复杂度,设计的时候可深可浅,但无论怎么变化,设计的思路基本都是围绕着用户、角色、菜单这三个部分展开


如何设计一套可以精确到按钮级别的用户权限功能呢?


今天通过这篇文章一起来了解一下相关的实现逻辑,不多说了,直接上案例代码!


01、数据库设计


在进入项目开发之前,首先我们需要进行相关的数据库设计,以便能存储相关的业务数据。


对于【用户权限控制】功能,通常5张表基本就可以搞定,分别是:用户表、角色表、用户角色表、菜单表、角色菜单表,相关表结构示例如下。



其中,用户和角色是多对多的关系角色与菜单也是多对多的关系用户通过角色来关联到菜单,当然也有的用户权限控制模型中,直接通过用户关联到菜单,实现用户对某个菜单权限独有控制,这都不是问题,可以自由灵活扩展。


用户、角色表的结构设计,比较简单。下面,我们重点来解读一下菜单表的设计,如下:



可以看到,整个菜单表就是一个父子表结构,关键字段如下



  • name:菜单名称

  • menu_code:菜单编码,用于后端权限控制

  • parent_id:菜单父节点ID,方便递归遍历菜单

  • node_type:菜单节点类型,可以是文件夹、页面或者按钮类型

  • link_url:菜单对应的地址,如果是文件夹或者按钮类型,可以为空

  • level:菜单树的层次,以便于查询指定层级的菜单

  • path:树id的路径,主要用于存放从根节点到当前树的父节点的路径,想要找父节点时会特别快


为了方便项目后续开发,在此我们创建一个名为menu_auth_db的数据库,SQL 初始脚本如下:


CREATE DATABASE IF NOT EXISTS `menu_auth_db` default charset utf8mb4 COLLATE utf8mb4_unicode_ci;

CREATE TABLE `menu_auth_db`.`tb_user` (
`id` bigint(20) unsigned NOT NULL COMMENT '用户ID',
`mobile` varchar(20) NOT NULL DEFAULT '' COMMENT '用户手机号',
`name` varchar(100) NOT NULL DEFAULT '' COMMENT '用户姓名',
`password` varchar(128) NOT NULL DEFAULT '' COMMENT '用户密码',
`is_delete` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否删除 1:已删除;0:未删除',
PRIMARY KEY (`id`),
) ENGINE=InnoDB COMMENT='用户表';

CREATE TABLE `menu_auth_db`.`tb_user_role` (
`id` bigint(20) unsigned NOT NULL COMMENT '主键',
`user_id` bigint(20) NOT NULL COMMENT '用户ID',
`role_id` bigint(20) NOT NULL COMMENT '角色ID',
PRIMARY KEY (`id`),
) ENGINE=InnoDB COMMENT='用户角色表';

CREATE TABLE `menu_auth_db`.`tb_role` (
`id` bigint(20) unsigned NOT NULL COMMENT '角色ID',
`name` varchar(100) NOT NULL DEFAULT '' COMMENT '角色名称',
`code` varchar(100) NOT NULL DEFAULT '' COMMENT '角色编码',
`is_delete` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否删除 1:已删除;0:未删除',
PRIMARY KEY (`id`),
) ENGINE=InnoDB COMMENT='角色表';


CREATE TABLE `menu_auth_db`.`tb_role_menu` (
`id` bigint(20) unsigned NOT NULL COMMENT '主键',
`role_id` bigint(20) NOT NULL COMMENT '角色ID',
`menu_id` bigint(20) NOT NULL COMMENT '菜单ID',
PRIMARY KEY (`id`),
) ENGINE=InnoDB COMMENT='角色菜单表';


CREATE TABLE `menu_auth_db`.`tb_menu` (
`id` bigint(20) NOT NULL COMMENT '菜单ID',
`name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '菜单名称',
`menu_code` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '菜单编码',
`parent_id` bigint(20) DEFAULT NULL COMMENT '父节点',
`node_type` tinyint(4) NOT NULL DEFAULT '1' COMMENT '节点类型,1文件夹,2页面,3按钮',
`icon_url` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '菜单图标地址',
`sort` int(11) NOT NULL DEFAULT '1' COMMENT '排序号',
`link_url` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '菜单对应的地址',
`level` int(11) NOT NULL DEFAULT '0' COMMENT '菜单层次',
`path` varchar(2500) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '树id的路径,主要用于存放从根节点到当前树的父节点的路径',
`is_delete` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否删除 1:已删除;0:未删除',
PRIMARY KEY (`id`) USING BTREE,
KEY idx_parent_id (`parent_id`) USING BTREE
) ENGINE=InnoDB COMMENT='菜单表';

02、项目构建


菜单权限模块的数据库设计搞定之后,就可以正式进入系统开发阶段了。


2.1、创建项目


为了快速构建项目,这里采用的是springboot+mybatisPlus框架来快速开发,借助mybatisPlus提供的生成代码器,可以一键生成所需的daoserviceweb层的服务代码,以便帮助我们剩去 CRUD 中重复编程的工作量,内容如下:



CRUD 代码生成完成之后,此时我们就可以编写业务逻辑代码了,相关示例如下!


2.2、菜单功能开发


2.2.1、菜单新增逻辑示例

@Override
public void addMenu(Menu menu) {
//如果插入的当前节点为根节点,parentId指定为0
if(menu.getParentId().longValue() == 0){
menu.setLevel(1);//默认根节点层级为1
menu.setPath(null);//默认根节点路径为空
}else{
Menu parentMenu = baseMapper.selectById(menu.getParentId());
if(parentMenu == null){
throw new CommonException("未查询到对应的父菜单节点");
}
menu.setLevel(parentMenu.getLevel().intValue() + 1);
// 重新设置菜单节点路径,多个用【,】隔开
if(StringUtils.isNotEmpty(parentMenu.getPath())){
menu.setPath(parentMenu.getPath() + "," + parentMenu.getId());
}else{
menu.setPath(parentMenu.getId().toString());
}
}
// 设置菜单ID,可以用发号器来生成
menu.setId(System.currentTimeMillis());
// 将菜单信息插入到数据库
super.save(menu);
}

2.2.2、菜单查询逻辑示例

首先,编写一个视图对象,用于数据展示。


public class MenuVo {

/**
* 主键
*/

private Long id;

/**
* 名称
*/

private String name;

/**
* 菜单编码
*/

private String menuCode;

/**
* 父节点
*/

private Long parentId;

/**
* 节点类型,1文件夹,2页面,3按钮
*/

private Integer nodeType;

/**
* 图标地址
*/

private String iconUrl;

/**
* 排序号
*/

private Integer sort;

/**
* 页面对应的地址
*/

private String linkUrl;

/**
* 层次
*/

private Integer level;

/**
* 树id的路径 整个层次上的路径id,逗号分隔,想要找父节点特别快
*/

private String path;

/**
* 子菜单集合
*/

List childMenu;

// set、get方法等...
}

接着编写菜单查询逻辑,这里需要用到递归算法来封装菜单视图。


@Override
public List queryMenuTree() {
Wrapper queryObj = new QueryWrapper<>().orderByAsc("level","sort");
List
allMenu = super.list(queryObj);
// 0L:表示根节点的父ID
List resultList = transferMenuVo(allMenu, 0L);
return resultList;
}

递归算法,方法实现逻辑如下!


/**
* 封装菜单视图
*
@param allMenu
*
@param parentId
*
@return
*/

private List transferMenuVo(List
allMenu, Long parentId){
List resultList = new ArrayList<>();
if(!CollectionUtils.isEmpty(allMenu)){
for (Menu source : allMenu) {
if(parentId.longValue() == source.getParentId().longValue()){
MenuVo menuVo = new MenuVo();
BeanUtils.copyProperties(source, menuVo);
//递归查询子菜单,并封装信息
List childList = transferMenuVo(allMenu, source.getId());
if(!CollectionUtils.isEmpty(childList)){
menuVo.setChildMenu(childList);
}
resultList.add(menuVo);
}
}
}
return resultList;
}

最后编写一个菜单查询接口,将其响应给客户端。


@RestController
@RequestMapping("/menu")
public class MenuController {

@Autowired
private MenuService menuService;

@PostMapping(value = "/queryMenuTree")
public List queryTreeMenu(){
return menuService.queryMenuTree();
}
}

为了便于演示,这里我们先在数据库中初始化几条数据,最后三条数据指的是按钮类型的菜单,用户真正请求的时候,实际上请求的是这三个功能,内容如下:



queryMenuTree接口发起请求,返回的数据结果如下图:



将返回的数据,通过页面进行渲染之后,结果类似如下图:



2.3、用户权限开发


在上文,我们提到了用户通过角色来关联菜单,因此,很容易想到,用户控制菜单的流程如下:



  • 第一步:用户登陆系统之后,查询当前用户拥有哪些角色;

  • 第二步:再通过角色查询关联的菜单权限点;

  • 第三步:最后将用户拥有的角色名下所有的菜单权限点,封装起来返回给用户;


带着这个思路,我们一起来看看具体的实现过程。


2.3.1、用户权限点查询逻辑示例

首先,编写一个通过用户ID查询菜单的服务,代码示例如下!


@Override
public List queryMenus(Long userId) {
// 第一步:先查询当前用户对应的角色
Wrapper queryUserRoleObj = new QueryWrapper<>().eq("user_id", userId);
List userRoles = userRoleService.list(queryUserRoleObj);
if(!CollectionUtils.isEmpty(userRoles)){
// 第二步:通过角色查询菜单(默认取第一个角色)
Wrapper queryRoleMenuObj = new QueryWrapper<>().eq("role_id", userRoles.get(0).getRoleId());
List roleMenus = roleMenuService.list(queryRoleMenuObj);
if(!CollectionUtils.isEmpty(roleMenus)){
Set menuIds = new HashSet<>();
for (RoleMenu roleMenu : roleMenus) {
menuIds.add(roleMenu.getMenuId());
}
//查询对应的菜单
Wrapper queryMenuObj = new QueryWrapper<>().in("id", new ArrayList<>(menuIds));
List
menus = super.list(queryMenuObj);
if(!CollectionUtils.isEmpty(menus)){
//将菜单下对应的父节点也一并全部查询出来
Set allMenuIds = new HashSet<>();
for (Menu menu : menus) {
allMenuIds.add(menu.getId());
if(StringUtils.isNotEmpty(menu.getPath())){
String[] pathIds = StringUtils.split(",", menu.getPath());
for (String pathId : pathIds) {
allMenuIds.add(Long.valueOf(pathId));
}
}
}
// 第三步:查询对应的所有菜单,并进行封装展示
List
allMenus = super.list(new QueryWrapper().in("id", new ArrayList<>(allMenuIds)));
List resultList = transferMenuVo(allMenus, 0L);
return resultList;
}
}

}
return null;
}

然后,编写一个通过用户ID查询菜单的接口,将数据结果返回给用户,代码示例如下!


@PostMapping(value = "/queryMenus")
public List queryMenus(Long userId){
//查询当前用户下的菜单权限
return menuService.queryMenus(userId);
}

2.4、用户鉴权开发


完成以上的逻辑开发之后,可以实现哪些用户拥有哪些菜单权限点的操作,比如用户【张三】,拥有【用户管理】菜单,那么他只能看到【用户管理】的界面;用户【李四】,用于【角色管理】菜单,同样的,他只能看到【角色管理】的界面,无法看到其他的界面。


但是某些技术人员发生漏洞之后,可能会绕过页面展示逻辑,直接对接口服务发起请求,依然能正常操作,例如利用用户【张三】的账户,操作【角色管理】的数据,这个时候就会发生数据安全隐患的问题。


为此,我们还需要一套用户鉴权的功能,对接口请求进行验证,只有满足要求的才能获取数据。


其中上文提到的菜单编码menuCode就是一个前、后端联系的桥梁。其实所有后端的接口,与前端对应的都是按钮操作,因此我们可以以按钮为基准,实现前后端双向权限控制


以【角色管理-查询】这个为例,前端可以通过菜单编码实现是否展示这个查询按钮,后端可以通过菜单编码来鉴权当前用户是否具备请求接口的权限,实现过程如下!


2.4.1、权限控制逻辑示例

在此,我们采用权限注解+代理拦截器的方式,来实现接口权限的安全验证。


首先,编写一个权限注解CheckPermissions


@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface CheckPermissions {

String value() default "";
}

然后,编写一个代理拦截器,拦截所有被@CheckPermissions注解标注的方法


@Aspect
@Component
public class CheckPermissionsAspect {

@Autowired
private MenuMapper menuMapper;

@Pointcut("@annotation(com.company.project.core.annotation.CheckPermissions)")
public void checkPermissions() {}

@Before("checkPermissions()")
public void doBefore(JoinPoint joinPoint) throws Throwable {
Long userId = null;
// 获取请求参数
Object[] args = joinPoint.getArgs();
Object requestParam = args[0];
// 用户请求参数实体类中的用户ID
if(!Objects.isNull(requestParam)){
// 获取请求对象中属性为【userId】的值
Field field = requestParam.getClass().getDeclaredField("userId");
field.setAccessible(true);
userId = (Long) field.get(parobj);
}
if(!Objects.isNull(userId)){
// 获取方法上有CheckPermissions注解的参数
Class clazz = joinPoint.getTarget().getClass();
String methodName = joinPoint.getSignature().getName();
Class[] parameterTypes = ((MethodSignature)joinPoint.getSignature()).getMethod().getParameterTypes();
// 寻找目标方法
Method method = clazz.getMethod(methodName, parameterTypes);
if(method.getAnnotation(CheckPermissions.class) != null){
// 获取注解上的参数值
CheckPermissions annotation = method.getAnnotation(CheckPermissions.class);
String menuCode = annotation.value();
if (StringUtils.isNotBlank(menuCode)) {
// 通过用户ID、菜单编码查询是否有关联
int count = menuMapper.selectAuthByUserIdAndMenuCode(userId, menuCode);
if(count == 0){
throw new CommonException("接口无访问权限");
}
}
}
}
}
}

2.4.2、鉴权逻辑验证

我们以上文说到的【角色管理-查询】为例,编写一个服务接口来验证一下逻辑的正确性。


首先,编写一个请求实体类RoleDTO,添加userId属性


public class RoleDTO extends Role {

//添加用户ID
private Long userId;

// set、get方法等...
}

其次,编写一个角色查询接口,并在方法上添加@CheckPermissions注解,表示此方法需要鉴权,满足条件的用户才能请求通过。


@RestController
@RequestMapping("/role")
public class RoleController {

private RoleService roleService;

@CheckPermissions(value="roleMgr:list")
@PostMapping(value = "/queryRole")
public List queryRole(RoleDTO roleDTO){
return roleService.list();
}
}

最后,在数据库中初始化相关的数据。例如给用户【张三】分配一个【访客人员】角色,同时这个角色只有【系统配置】、【用户管理】菜单权限。






启动项目,在postman中传入用户【张三】的ID,查询用户具备的菜单权限,只有两个,结果如下:



同时,利用用户【张三】发起【角色管理-查询】操作,提示:接口无访问权限,结果如下:



与预期结果一致!因为没有配置角色查询接口,所以无权访问!


03、小结


最后总结一下,【用户权限控制】功能在实际的软件系统中非常常见,希望本篇的知识能帮助到大家。




作者:潘志的研发笔记
来源:juejin.cn/post/7380283378153914383
收起阅读 »

25k大专前端外包从深圳回武汉能拿多少?

2023 年 08 月我正式从深圳公司离职,从 7 月初开始投武汉的公司,截止 8 月底,2 个月时间有 5 个面试 3 个 offer:一个自研的 22k、两个外包一个 17k,一个 18k。最终选了一家离我比较近的 18k offer,但遗憾的是刚去第一天...
继续阅读 »

2023 年 08 月我正式从深圳公司离职,从 7 月初开始投武汉的公司,截止 8 月底,2 个月时间有 5 个面试 3 个 offer:一个自研的 22k、两个外包一个 17k,一个 18k。最终选了一家离我比较近的 18k offer,但遗憾的是刚去第一天就发现坑太深,还是决定放弃这家公司,目前失业在家,这里和大家聊一聊最近从深圳回武汉找工作的经历。

基本情况

先大致介绍下我的基本情况:大专学历,30+,从 17 年开始做全职前端开发,到现在有 6-7 年了,属于一年工作经验用 5 年的那种,Vue 一把梭,技术一般。进不了中、大厂,只能在外包混混日子。

时间回到两年前,2021 年 8 月前端行情好的时候,我凭运气找到了一家深圳二线互联网公司的前端外包岗位,offer 是 25k * 12,到今年 8 月正好呆了两年左右,为了说明真实性,下面附上我 2022 年度个税 App 收入纳税截图。

2-2022.png

声明

本人在网上冲浪时从未公开过这家我呆了两年的公司,出于薪资保密原则,如果有人认出我,知道我所在的这家公司,还请不要透露公司名字,万分感谢!

另外,为了避免纠纷,后面面试的公司,我都会进行匿名处理,如果有人猜到公司名称,评论还请使用化名,希望大家能理解。

深圳回武汉

从 21 年入职这家公司开始,这两年前端行情越来越差,目前我这个学历、技术水平比较难找到 25k 以上的工作。我有考虑过要不要先苟在这家公司,毕竟这个工作工资还可以,leader、同事、工作氛围都不错。但由于各方面原因,最终还是决定回武汉。

  1. 工作方面:我做的大多是技术需求,做的比较无聊,成就感较低;另外,新需求越来越少,蛋糕就这么大,僧多粥少,发挥空间较小。
  2. 生活方面:我时常在反思,我是不是一个精致的利己主义者?这些年基本就过年回家,回家也呆不了几天,在照顾父母、关心家人这方面我是做的比较差的,如果我只想着自己能不能拿高工资,自己过的是否惬意,我觉得这是很自私的,回武汉离家近可以很好的解决这个问题。

在业务需求少后,部门也有了裁员的消息,我正好在这边快两年了,也想回武汉换个环境。

为了变被动为主动,就在 7 月初开始投武汉的公司了,计划拿到 offer 就离职回去。因为在行业下行周期,越想往上挣扎越累,还不一定有好的结果,不如顺势躺平,好好享受生活。

简历投递面试数据

23 年 6 月 20 号左右,将简历开放,状态修改为在职-看机会。过了一段时间,发现没 hr 联系我,行情确实差了很多,之前简历一开放,一堆 hr 主动找你,这个时候还没主动投。

一直到 23 年 7 月 3 号,我终于修改好了简历,开始投简历。但如下图,简历比较难投出去,需要双方回复才能投。

3-boss-huifu.png

于是我又下载了拉钩、猎聘。拉钩猎聘大部分都可以直接投,但拉钩 20k+ 武汉的岗位很少,猎聘投了很多也没回复,整体还是 BOSS 上面试机会最多,下面是具体数据

App 类型沟通投递面试机会面试通过/Offer
BOSS13186142
猎聘-29411
拉钩-62(投递反馈)00

卡学历问题

我基本把武汉的 20k+ 前端岗位都投了一遍,但基本没有中、大厂都能通过简历筛选。分三种情况

  1. 没有任何回复(最多)
  2. 回复看了简历不合适(个别)
  3. 直接指出学历不符合(个别)

4-xueli.png

虽然我有自考本科+学士学位也没啥用,一般还是至少要统招本科及以上。当然也有可能会是年龄、技术菜、要的工资高等其他因素。

4-2-xueli.png

面试记录

某电商小公司 - 自研 22k(过)

来源:猎聘 App,岗位:中高级前端开发工程师(自研)(14-22k)

2023 年 7 月 10 号,在投了一个星期后,终于有了第一个面试,晚上 19:00 腾讯会议远程面,大概面了一个小时,问的问题不难,比如

  • 先自我介绍
  • 垂直居中有几种方式?
  • flex: 2 有用过吗?多列布局怎么实现?
  • 怎么判断对象为空?
  • 寻找字符串中出现最多的字符怎么实现?
  • 知不知道最新的 url 参数获取的 API?
  • 实现深拷贝
  • 实现 Promise
  • 新版本发布后,怎么用技术手段通知用户刷新页面?
  • 性能优化数据怎么上报、分析?
  • Vue 组件通信方式有哪些,各有什么特点?
  • Vue 项目怎么提高项目性能?举一些例子
  • element ui table 吸顶怎么做,滚动怎么处理等
  • 你有什么想问我的?

然后还问了一些项目问题,能不能加班,因为虽然双休,但周一到周五会有 3 天加班等。基本没有问啥原理性的问题,就是看基础怎么样,能不能干活。

面试第二天,没有消息,我以为挂了,但隔了一天,7 月 12 号,HR 电话二面,我问了我的一些基本情况后,表示可以直接发 offer,确定薪资为 22k,但其中 2.2k 要当做季度绩效发放,说的是一般不犯啥错误都可以拿到。下面是 offer 截图

5-offer-1.png

沟通入职时间定的 8 月 1 号,比较坑的是甲方都同意 7 月底可以走,外包公司这边不同意,要到 8 月中才放我走,合同确实是这样写的,我也不好说啥。

这家公司比较着急,觉得等的时间有点长了,1个月+,风险有点高。我也不能说让别人一直等,只能说,让他们可以先考虑其他候选人,这家公司过了段时间招到人了,这个 offer 就黄了。

(后面回想起来,我可能有点傻,规定是死的,人是活的,应该直接按甲方允许的 7 月底时间来,这样 offer 就没问题了。如果我们公司不让我走,我可以直接走人,就当旷工,直接被开除就行,只是没有离职证明,但工资流水是有的)

武汉某小公司 - 自研 (12-20k)x

来源:BOSS,岗位:前端开发工程师 - 自研(12-20k)14薪

在上面的 22k 这个 offer 时间有冲突的时候,我就意识到这个 offer 有风险,就开始继续投了。

到 23 年 8 月 2 号终于又有了面试机会,一面是笔试,如下图

6-hema.png

有 4 题,最后一题最简单,第 1、2 题忘记了,1、2、3 我都是用递归实现的,3、4 题如下

  1. _.flatten() 实现一个数组打平方法,支持第二个参数(可指定打平层级)
const array = [[0, 1], [2, [3, 4]], [5, 6]];
const result = _.flatten(array);
  1. 菜单数组转换为嵌套树形结构,但示例只有两级
[
{ id: 1, menu: '水果', level: 1 },
{ id: 2, menu: '橘子', level: 2, parentId: 1 }
// ...
]
// 转换为
[
{
id: 1, menu: '水果', level: 1, children: [{ id: 2, menu: '橘子', level: 2, parentId: 1 }]
},
// ...
]

笔试难度一般,主要靠思维,难度比 leetcode 算法题低,算是过了。

二面是 8 月 7 号电话面,19:00 - 20:00 一个小时左右,大部分问题都忘记了,模糊记得部分问题

  • 先自我介绍
  • 把之前的笔试题一题一题拿出来讲实现思路。
  • 对象的继承有哪几种?
  • TS 用的多吗?
  • 工作中解决的最有成就感的事?
  • vue3 在某些场景比 vue2 性能更低,为什么会这样?
  • 在团队协作时,有遇到过什么问题吗,如果有冲突你会怎么做
  • 你有什么想问我的?

另外面试小哥对我之前有两家半年左右的工作经历比较在意,问了很多之前公司的细节,因为他说之前有面试过的最后背调没通过,所以要问清楚。我的简历写的很真实,基本没有水分,是什么就是什么。

他最后透露,可能就算他可以过,但 HR 那边可能过不了,不知道是我跳槽太频繁还是啥,总之后面基本没消息了,这个算是挂了。

某上海武汉分公司 - 自研(18-23k)x

来源:BOSS,岗位:前端开发 自研(18-23k)

上次面试的挂了之后,继续投,但没面试机会,后面又忙搬家、邮寄东西,回武汉,找房子等,中间大概用了一个多星期。

在 8 月 18 号终于又有了一个自研的面试, 15:40 腾讯会议线上一面 - 技术面,上海那边的开发负责面试,问了一些问题,比较普通,我现在毫无印象。

一面过了,在 8 月 22 日,13:00 二面(现场面),公司办公地点在武昌火车站地铁口,刚开始觉得还不错,但一进去,一个开发都没有,就 1 个人,直接无语...... 武汉算是分部,那个人还不懂技术,和我吹了一下公司怎么怎么厉害,先是做了一份笔试题(比较基础)比如

  • 3 种方式实现顶部导航+左侧菜单+右侧主内容区域布局
  • jwt 鉴权逻辑
  • vue 数组下标改值,响应式丢失、为什么

7-hangshu.png

然后那个人拍了我写的笔试题,让上海那边的人看,说是做的不错。再视频连线进行面试,大致问了一些基础问题,然后坑的地方来了。我之前待过的公司,一个一个问我离职原因。。。。。。

然后就是副总面,问我有没有做过异形屏的适配,有没有写过绘制、渲染逻辑,我。。。。。。然后又问了我好几个假大空的问题,我一脸懵逼,比如一个公司呆 8 年和 8 年每年换一家公司你觉得哪种好。

后面就是回去等消息了,然后就没有然后了。。。。。

某金融公司 - 外包 17k(过)

来源:BOSS,岗位:前端开发 - 外包 17k

和上面那个公司同一时间段,在 8 月 18 号也进行了这家公司的腾讯会议一面

一面比较简单,大致为了下工作经历,重点问了下低代码、怎么动态加载渲染一个组件,底层怎么实现?面试时间比较短,有点仓促

8 月 21 号二面,大致问了一些问题后,还是追问低代码方面的问题,组件级别、可以内嵌到其他指定页面的这种低代码 sdk 封装怎么做?他们是想招个会低代码,有过 sdk 封装经验的。我之前工作中有做过组件库,封装过百万用户级别的小程序 sdk、也做过功能引导、错误上报等 sdk,还自己实现过多个 npm 包轮子,算是勉强符合他们的要求。

二面过了后,开始谈薪资,17k,基本不加班,8 月 23 号三面笔试(类似走过场),有题库,刷一下就没问题,通过就发邮件 offer 了。

8-zhengquan.png

这家公司过了,但我没有接轻易接 offer,而是让 HR 等第二天中午我的反馈,我不想接了别人 offer 又不去。这家公司的 HR 比较好、很热心积极。

主要有以下几个原因

  1. 后面还有一个 18k 的也是同一天二面,且面试体验好,大概率过了,只等确定 offer。
  2. 这家比较远,在花山,而后面一家离我比较近
  3. 这家试用期打折,下面一家不打折。

最终拒了这家 offer,因为下面要讲的这家 offer 下来了,前方高能预警,后面这家公司巨坑、后悔拒了这家。。。。

某互联网公司 - 外包 18k(过)

来源:BOSS,岗位:前端开发(外包)18k

和上面那家几乎同一时间,这家公司也进行了两轮面试

一面,腾讯会议,从 3-4 个 UI 中,选一个题来实现,30 分钟,就是平常干活画 UI,难度不大,面试官是个声音好听的妹子。

二面,腾讯会议,结对编程,面试官出题,我描述实现,面试官写代码,包括

  1. 一个简单的需要使用 Promise 应用题
  2. 运行一个 vue 项目,vue2 写法改 vue3 写法,封装一个计时器组件,组件加 props,组件加插槽等

面试体验真的很好,18k offer 下来后,果断选择了这家离我近的公司。

9-offer-3.png

但没想到的是,入职第一天发现这家公司管理问题很大

  1. 开发环境差,只能用网页版的 vscode,除了要配置 host 外,还有配置端口映射,配置稍微有问题就运行不起来,体验较差。
  2. 沟通太依赖线上,武汉这边基本是xx一线城市那边的产品、UI、开发分配任务给这边开发,沟通成本非常高。
  3. 加班问题,说的是早 9 晚 6,但他们自研一般下班这个点可能会去吃个饭, 然后回来加班,git log 看了下提交记录,不少是 20:00 之后的,还有 21 点、22 点之后的.... 如果真融入这个团队,不加班我是不信的。

从面试体验、沟通来看,这里的开发人员是优秀的,但实际入职却发现环境、氛围差的情况,我只能把这种问题归纳到管理上了。

第一天基本没干活就是配置环境,但这个氛围,我真的接受不了,后面就果断放弃这家公司了。

武汉找工作经验总结

上面我大致描述了从 7 月初到 8 月底的简历投递、面试经历。主要是面试少,实际面试通过率为 60%。下面是一些总结

  • 投递简历时段最好是周一到周三上午 8-9 点,回复、面试机会较多,周五到周天基本没反应。
  • 武汉原理性问的不多,主要还是能干活,比较需要多面手,就是什么都会的,比如 WebGL, Three.js,uni-app 等
  • 一定要问清楚、开发环境、加班问题,不要不好意思,能找自研就尽量找自研。
  • 不要听 HR 或者面试官怎么说,而是自己通过行业、所做的业务去判断是否有坑。

完结撒花,如果觉得内容对您有帮助,那就点个免费的赞吧~~

另外最近有和我一样在找工作的小伙伴吗?你们有遇到过什么坑吗?欢迎在评论区讨论~~~


作者:dev_zuo
来源:juejin.cn/post/7275225948453568552
收起阅读 »

Alpha系统联结大数据、GPT两大功能,助力律所管理降本增效

如何通过AI工具实现法律服务的提质增效,是每一位法律人都积极关注和学习的课题。但从AI技术火爆一下,法律人一直缺乏系统、实用的学习资料,来掌握在法律场景下AI的使用技巧。今年5月,iCourt携手贵阳律协大数据与人工智能专业委员会,联合举办了《人工智能助力律师...
继续阅读 »

如何通过AI工具实现法律服务的提质增效,是每一位法律人都积极关注和学习的课题。但从AI技术火爆一下,法律人一直缺乏系统、实用的学习资料,来掌握在法律场景下AI的使用技巧。

alphagpt

今年5月,iCourt携手贵阳律协大数据与人工智能专业委员会,联合举办了《人工智能助力律师行业高质量发展巡回讲座》,超过100家律所的律师参与活动。

讲座上, iCourt AIGC 研究员、AlphaGPT产品研发负责人兰洋,为贵州律协的律师们讲解了AI技术在法律领域的应用原理、AI技术赋能法律服务发展九大典型场景和业务实操、AI赋能法律服务的发展趋势,并强调了AI时代下,律师需要掌握的关键技能,以期帮助律师们更好地抓住技术变革带来的发展机遇。

iCourt很早就开始了法律发数据与法律AI工具的研发应用,旗下智能法律操作系统Alpha、法律人专属AI助手AlphaGPT,在法律科技日益繁荣的市场上始终走在前列。

alpha

Alpha系统的大数据功能整合了超过1.6亿的案例和超过410万的法规,通过与司法观点库、类案同判库、实务文章库等多数据库协同穿透检索,为律师办案提供精确实用的法律信息。大数据功能不仅支持最高26维度的检索,还支持标签选取、模糊检索、AI检索等多种检索方式,解决律师在任意工作场景下的检索需求。 

alphagpt

值得一提的是,Alpha系统还是仅有的一个集法律大数据、人工智能工具、数智化办公工具、律所管理与团队协同工具于一体的法律智能操作系统。Alpha系统不仅可以帮助律师实现工作效率、工作效果的双重提升,还可以助力律所推动一体化改革,真正地实现降本增效。Alpha系统中涵盖的利冲管理、知识管理、客户管理、财务管理、审批管理和人事行政管理等功能模块,涵盖律所管理与发展的方方面面,是律所发展、转型、升级的的必备工具。

而法律人专属AI助手AlphaGPT,可以帮助律师在案情分析、合同审查、文书写作、法律咨询、文件阅读、法律检索六大场景实现提质增效。例如,依托Alpha系统的法律大数据,AlphaGPT可以在短时间内实现案情智能分析,像律师一样思考,输出客观的法律服务意见书,还可以分钟级时间内实现合同风险与主体风险的一建审查。此外,AlphaGPT可以在短时间内实现文本的主题概括和核心提炼,还可以提供文本校对、润色、翻译等处理操作,大大节省了时间成本。

AI时代下,法律人应当抓住科技发展带来的机遇风口,拥抱AI等新兴技术,积极应用到法律服务领域当中,以实现自身的提质增效和推动行业的快速发展。

AlphaGPT官网入口:https://icourt.cc/product/alphaGPT

收起阅读 »

律所管理OA系统推荐,Alpha法律智能操作系统荣登榜首

随着“一体化”日渐成为律所改革的重要抓手,一个好的线上一体化管理工具(律所管理OA系统)就成为了几乎所有律所改革的必需品。Alpha法律智能操作系统集律所利冲管理、知识管理、客户管理、财务管理、审批管理和人事行政管理于一体,涵盖律所管理的方方面面,是律所管理O...
继续阅读 »

随着“一体化”日渐成为律所改革的重要抓手,一个好的线上一体化管理工具(律所管理OA系统)就成为了几乎所有律所改革的必需品。Alpha法律智能操作系统集律所利冲管理、知识管理、客户管理、财务管理、审批管理和人事行政管理于一体,涵盖律所管理的方方面面,是律所管理OA系统中的代表性产品。目前,Alpha系统已帮助到1.5万家律所提高办案能力与质效。

经过调研与精确设计,Alpha系统以科学的工作流程为基准,以解决律所管理痛点问题为目标,进一步完善功能设置。其中,案件管理、知识管理与行政事务管理广受好评,这是律所一体化建设中不可缺少的三大环节。

alpha

据调研,律所管理的痛点之一是办案过程难管理,从而导致人员调配、案件跟进等徒增风险。对此,Alpha系统的办案件进程管理功能将律师办案过程线上化,通过项目模板将一个案件的立项、签约、办案、开庭、结案等全流程进行统追踪记录,极大地帮助管理人更好地对案件进行监察跟进。Alpha系统的律所OA打通了企业微信、钉钉、飞书等办公软件,可以自动创建工作群,助力开展团队协作,实时提醒律师待办任务,降低工作出错率。

此外,律所一体化的过程中,知识管理能否做好直接关系到律所能否长期高效地运行,决定着管理模式的成败。好的知识管理方案可以促进律所内部的优秀经验高效复制、知识成果快速共享,进而整体提升律师的业务能力。Alpha系统为律所打造了电子化、协同式的知识管理功能,提供超1.5亿案例数据,提供全面、精细的数据检索库;同时,基于多项数据库的组合应用,系统还支持5秒内自动导出客户尽调报告与法律风险分析报告,帮助律师快速了解企业客户基本信息与可能存在的法律风险,高效置顶办案策略。

alpha

针对行政事务管理,Alpha系统集成了律所OA、财务统计分析、绩效管理、审批管理等方面,促进行政统一管理,有力帮助律所降本增效。Alpha系统的自定义审批引擎支持内务外勤分条件高效审批,同时提供全自动利冲检索,做好案涉合同、发票、收款等信息的记录与审批,保障项目信息高效同步流转,实现业务数据和财务数据的统一管理,真正做到了省时省力。

Alpha是目前唯一一个将大数据,律所OA,人工智能相结合的律所管理软件,在律所一体化改革与建设上发挥巨大作用,助力律所实现整体创收提升。

Alpha系统官网入口:https://www.icourt.cc/product/alpha

收起阅读 »

Go 再次讨论 catch error 模型,官方回应现状

大家好,我是煎鱼。 最近社区的同学和 Go 官方又因为错误处理的提案屡屡被否,发生了一些小的摩擦。也非常难得的看到核心团队成员首次表达了目前的态度和情况。 基于此,我们今天进行该内容分享。紧跟 Go 官方最新进展。 快速背景 Go 的错误处理机制,主要是依赖于...
继续阅读 »

大家好,我是煎鱼。


最近社区的同学和 Go 官方又因为错误处理的提案屡屡被否,发生了一些小的摩擦。也非常难得的看到核心团队成员首次表达了目前的态度和情况。


基于此,我们今天进行该内容分享。紧跟 Go 官方最新进展。


快速背景


Go 的错误处理机制,主要是依赖于 if err != nil 的方式。因此在对函数做一定的封装后。


代码会呈现出以下样子:



jy1, err := Foo1()
if err != nil {
return err
}
jy2, err := Foo2()
if err != nil {
return err
}
err := Foo3()
if err != nil {
return err
}
...

有部分开发者会认为这比较的丑陋、混乱且难以阅读。因此 Go 错误处理的优化,也是社区里一直反复提及和提出的领域。饱受各类争议。


新提案:追求类似 try-catch


最近一位国内的同学 @xiaokentrl 提了个类似 try catch error 的新提案,试图用魔法打败魔法。



原作者给出的提案内容是:


1、新增环境变量做开关:


ERROR_SINGLE = TRUE   //error_single = true

2、使用特定标识符来做 try-catch:


Demo1 单行错误处理:


//Single-line error handling
file, err := os.Create("abc.txt") @ return nil , err
defer file.Close()

Demo2 多行错误处理:


func main() {
//Multiline error handling
:@

file, err:= os.Open("abc.txt")
defer file.Close()

buf := make([]byte, 1024)
_, err2 := file.Read(buf)

@ err | err2 return ...
}

主要的变化内容是:利用标签 @ 添加一个类似 try-catch 的代码区块,并添加运算符和相关错误处理的联动规则。


这个提案本身,其实就是以往讲到的 goto error 和 check/with 这种类似 try-catch 的模式。


当然非常快的就遭到了 Go 核心团队的反对:



@Ian Lance Taylor 表示:由于很难处理声明和应用,如果一个标签的作用域中还有其他变量,就不能使用 goto。


新的争端:官方你行你上


社区中有同学看到这一次次被否的错误处理和关联提案们,深感无奈和无语。他发出了以下的质疑:


“为什么不让 Ian Lance Taylor 和/或 Go 核心团队的其他成员提出改进的错误处理框架的初始原型,然后让 Go 社区参与进来,为其最终形式做出贡献呢?Go 中的泛型正是这样发展到现在的。


如果我们等待 Go 社区提出最初的原型,我认为我们将永远不会有改进的 Go 错误处理框架,至少在未来几年内不会。”


但其实很可惜,因为人家真干过。


Go 核心团队是有主动提出过错误处理的优化提案的,提案名为《Proposal: A built-in Go error check function, try》,快速讲一下。


以前的代码:


f, err := os.Open(filename)
if err != nil {
return …, err // zero values for other results, if any
}

应用该提案后的新代码:


f := try(os.Open(filename))

try 只能在本身返回错误结果的函数中使用,且该结果必须是外层函数的最后一个结果参数。


不过很遗憾,该官方提案,喜提有史以来被否决最多的提案 TOP1:



最终该提案也由于形形色色的意见,最终凉了。感觉也给 Go 核心团队泼了一盆凉水,因为这是继 check/handle 后的 try,到目前也没有新的官方提案了。


Go 官方回应


本次提及的新提案下,大家的交流愈演愈烈,有种认为就是 Go 核心团队故意不让错误处理得到更好的改善。


此时 Go 核心团队的元老之一 @Ian Lance Taylor 站出来发声,诠释了目前 Go 团队对此的态度。这也是首次。


具体内容如下:



“我们愿意考虑一个有良好社区支持的好的错误处理提案。


不幸的是,我很遗憾地说,基本上所有新的错误处理提案都不好,也没有社区支持。例如,这个提案有 10 个反对票,没有赞成票。我当然会鼓励人们在广泛使用这门语言之前,避免提交错误处理提案。


我还鼓励人们审查早期的提案。它们在这里:github.com/golang/go/i… 。目前已有 183 个并在不断增加。


我自己阅读了每一个。重要的是,请记住,对已被否决提案的微调的新提案也几乎肯定也会被否决。


并且请记住,我们只会接受一个与现有语言契合良好的提案。例如:这个提案中使用了一个神奇的 @ 符号,这完全不像现有语言中的任何其他东西。


Go 团队可能会在适当的时候提出一个新的错误处理提案。然而,正如其他人所说,我们最好的想法被社区认为是不可接受的。而且有大量的 Go 程序员对现状表示满意。”


总结


目前 Go 错误处理的情况和困境是比较明确的,很多社区同学会基于以往已经被否决的旧提案上进行不断的微改,再不断提交。


现阶段都是被全面否定的,因为即使做了微调,也无法改变提案本身的核心思想。


而 Go 官方自己提出的 check/handle 和 try 提案,在社区中也被广大的网友否决了。还获得了史上最多人否决的提案的位置。


现阶段来看,未来 1-3 年内在错误处理的优化上仍然会继续僵持。



作者:煎鱼eddycjy
来源:juejin.cn/post/7381741857708752905
收起阅读 »

进程还在,JSF接口不干活了,这你敢信?

1、问题背景: 应用在配合R2m升级redis版本的过程中,上游反馈调用接口报错,RpcException:[Biz thread pool of provider has been exhausted],通过监控系统和日志系统定位到现象只出现在一两个节点,并...
继续阅读 »

1、问题背景:


应用在配合R2m升级redis版本的过程中,上游反馈调用接口报错,RpcException:[Biz thread pool of provider has been exhausted],通过监控系统和日志系统定位到现象只出现在一两个节点,并持续出现。第一时间通过JSF将有问题的节点下线,保留现场,业务恢复。


报错日志如下:


24-03-13 02:21:20.188 [JSF-SEV-WORKER-57-T-5] ERROR BaseServerHandler - handlerRequest error msg:[JSF-23003]Biz thread pool of provider has been exhausted, the server port is 22003
24-03-13 02:21:20.658 [JSF-SEV-WORKER-57-T-5] WARN BusinessPool - [JSF-23002]Task:com.alibaba.ttl.TtlRunnable - com.jd.jsf.gd.server.JSFTask@0 has been reject for ThreadPool exhausted! pool:80, active:80, queue:300, taskcnt: 1067777

2、排查步骤:


从现象开始推测原因,系统启动时,会给JSF线程池分配固定的大小,当线程都在工作的时,外部流量又打进来,那么会没有线程去处理请求,此时会有上述的异常。那么JSF线程在干什么呢?


1)借助SGM打印栈信息


2)分析栈信息


可以用在线分析工具:spotify.github.io/threaddump-…


2.1)分析线程状态


通过工具可以定位到JSF线程大部分卡在JedisClusterInfoCache#getSlaveOfSlotFromDc方法,如图:






























2.2)分析线程夯住的方法


getSlaveOfSlotFromDc在方法入口就需要获取读锁,同时在全局变量声明了读锁和写锁:
















此时对问题有一个大体的了解,大概推测:getSlaveOfSlotFromDc是获取redis连接池,该方法入口处需要获取读锁,由于读锁之间不会互斥,所以猜测有业务获取到写锁后没有释放。同时读锁没有设置超时时间,所以导致杰夫线程处理业务时卡在获取读锁处,无法释放。


2.3)从业务的角度分析持有写锁的逻辑


向中间件研发寻求帮助,经过排查,定位到有个更新拓扑的定时任务,执行时会先获取写锁,根据该消息,定位到任务的栈信息:









代码截图:









图1









图2









图3


从日志验证:日志只打印更新拓扑的日志,没有打印更新成功的日志,且02:20分以后r2m-topo-updater就不在打印日志









2.4)深入挖掘原因


虽然现象已经可以推测出来,但是对问题的原因还是百思不得其解,难道parallelStream().forEach存在bug?难道有远程请求,没有设置超时时间?...


经过查找资料确认,如果没有指定,那么parallelStream().forEach会使用ForkJoinPool.commonPool这个默认的线程池去处理任务,该线程池默认设置(容器核心数-1)个活跃线程。同时caffeine数据过期后会异步刷新数据,如果没有指定线程池,它默认也会使用ForkJoinPool.commonPool()来执行异步线程。那么就有概率出现获取到写锁的线程无法获取执行权,获取执行权的线程无法获取到读锁。









2.5)验证


3个ForkJoinPool.commonPool-worker的确都夯在获取redis连接处,线程池的活跃线程都在等待读锁。









本地caffeine缓存没有设置自定义线程池









topo-updater夯在foreach业务处理逻辑中









3.复盘


1)此问题在特定的使用场景下才会小概率出现,非常感谢中间件团队一起协助定位问题,后续也将异步更新拓扑改为同步处理。


2)Java提供了很多异步处理的能力,但是异常处理也代表需要开启线程或者使用共用的线程池,也需要注意。


3)做好监控,能第一时间发现问题并处理问题。


作者:京东科技 田蒙


来源:京东云开发者社区


作者:京东云开发者
来源:juejin.cn/post/7379831020496715813
收起阅读 »

通过代码实现 pdf 文件自动盖章

序言在数字化时代,电子文档的安全性和真实性越来越受到重视。电子印章作为一种数字化的身份验证工具,已经成为确保文档合法性和不可篡改性的重要手段。然而,传统的电子印章往往需要人工操作,不仅效率低下,而且在处理大量文件时容易出错。为了解决这一问题,自动化地给PDF文...
继续阅读 »

序言

在数字化时代,电子文档的安全性和真实性越来越受到重视。电子印章作为一种数字化的身份验证工具,已经成为确保文档合法性和不可篡改性的重要手段。然而,传统的电子印章往往需要人工操作,不仅效率低下,而且在处理大量文件时容易出错。为了解决这一问题,自动化地给PDF文件盖电子章成为了一个迫切的需求。本文将详细介绍,如何通过 .net 程序实现这一功能,废话不多说,步入正题

Nuget 包

本文的核心包为:

  • iTextSharp,用它来操作 pdf 文件非常方便,具体的用法这里不多赘述,请参考官网
  • DynamicExpresso,一个非常好用的动态表达式解析工具包

Include="DynamicExpresso.Core" Version="2.16.1" />
Include="iTextSharp" Version="5.5.13.3" />
Include="Newtonsoft.Json" Version="13.0.3" />

素材准备

本案例用到的素材包括:用于测试的 pdf 文件一个,模拟电子章图片一张,以及盖章配置文件,文件内容如下:

[
{
"SignType" : "image",//素材类型,image表示图片素材,text 表示文本素材
"LastPage" : true,//是否仅最后一页盖章
"ImageUrl" : "https://xxxxxxxx",//图片素材的下载链接
"FileName" : "sign.png",//图片素材文件名称
"ScalePercent" : 20,//图片缩放百分比,100 表示不缩放
"Opacity" : 0.6,//图片透明度,1 表示不透明
"LocationX" : "(input.Width/10)*6",//图片素材的绝对位置表达式,(0,0) 表示左下角
"LocationY" : "input.Height/23 +20",//input.With 和 input.Height 代表 pdf 文件的宽度及高度
"Rotation" : 0//素材的旋转角度
},
{
"SignType" : "text",
"LastPage" : true,
"LocationX" : "(input.Width/10)*6+85",
"LocationY" : "input.Height/23 ",
"Rotation" : 0,
"FontSize" : 20,
"Opacity" : 0.6,
"FontColor" : {//文本素材的字体颜色值
"R" : 255,
"G" : 0,
"B" : 0
},
"Text" : "input.Date"//文本素材的表达式,也可以直接写固定文本
}
]

说明:

  1. 这里之所以设计为一个数组,是因为可能有些场景下,不仅需要盖电子章,还需要自动签上日期,比如本案例。
  2. 签署位置可以自定义,坐标(0,0)代表的是左下角,x 变大即表示横向右移,y 变大表示纵向上移。
  3. 配置文件存储,我这里是把配置文件放在了本地,当然你可以存储在任何地方,比如 MongoDB等。

代码展示

本案例采用的是 .net7.0,当然 .net6及以后都是可以的。

  1. 配置文件类,与上一步的 json 配置文件对应
namespace PdfSign;

public class SignOpt
{
public string SignType { get; set; }
public bool LastPage { get; set; }
public string ImageUrl { get; set; }
public string FileName { get; set; }
public int ScalePercent { get; set; } = 50;
public string LocationX { get; set; }
public string LocationY { get; set; }
public float LocationYf { get; set; }
public float Rotation { get; set; } = 0;
public int FontSize { get; set; }
public float Opacity { get; set; }
public RBGColor FontColor { get; set; }
public string? Text { get; set; }

public record RBGColor(int R, int G, int B);
}
  1. pdf 签署方法
using System.Dynamic;
using DynamicExpresso;
using iTextSharp.text;
using iTextSharp.text.pdf;
using Newtonsoft.Json.Linq;

namespace PdfSign;

public class SignService
{
public static string PdfSign(List signOpts, string pdfName)
{
var beforeFileName = pdfName; //签名之前文件名
var afterFileName = pdfName + "_sign"; //签名之后文件名
var idx = 0;
foreach (var opt in signOpts)
{
//创建盖章后生成pdf
var outputPdfStream =
new FileStream(afterFileName + ".pdf", FileMode.Create, FileAccess.Write, FileShare.);
//读取原有pdf
var pdfReader = new PdfReader(beforeFileName + ".pdf");
var pdfStamper = new PdfStamper(pdfReader, outputPdfStream);
//读取页数
var pdfPageSize = pdfReader.NumberOfPages;
//读取pdf文件第一页尺寸,得到 With 和 Height
var size = pdfReader.GetPageSize(1);
//通过表达式计算出签署的绝对坐标
var locationX = Eval(opt.LocationX, new { size.Width, size.Height });
var locationY = Eval(opt.LocationY, new { size.Width, size.Height });

if (opt.LastPage)
{
//盖章在最后一页
var pdfContentByte = pdfStamper.GetOverContent(pdfPageSize);
var gs = new PdfGState
{
FillOpacity = opt.Opacity
};
pdfContentByte.SetGState(gs);
switch (opt.SignType.ToLower())
{
case "image":
//获取图片
var image = Image.GetInstance(opt.FileName);
//设置图片比例
image.ScalePercent(opt.ScalePercent);
//设置图片的绝对位置,位置偏移方向为:左到右,下到上
image.SetAbsolutePosition(locationX, locationY);
//图片添加到文档
pdfContentByte.AddImage(image);
break;
case "text":
if (string.IsNullOrWhiteSpace(opt.Text))
continue;
var font = BaseFont.CreateFont();
var text = Eval(opt.Text, new { Date = DateTime.Now.ToString("yyyy-MM-dd") });
//开始写入文本
pdfContentByte.BeginText();
pdfContentByte.SetColorFill(
new BaseColor(opt.FontColor.R, opt.FontColor.G, opt.FontColor.B));
pdfContentByte.SetFontAndSize(font, opt.FontSize);
pdfContentByte.SetTextMatrix(0, 0);
pdfContentByte.ShowTextAligned(Element.ALIGN_CENTER, text,
locationX, locationY, opt.Rotation);

pdfContentByte.EndText();
break;
}
}

pdfStamper.Close();
pdfReader.Close();
idx++;
if (idx >= signOpts.Count) continue;
//文件名重新赋值
beforeFileName = afterFileName;
afterFileName += "_sign";
}

return afterFileName + ".pdf";
}

//计算动态表达式的值
public static T? Eval(string expr, object context)
{
if (string.IsNullOrWhiteSpace(expr))
return default;

var target = new Interpreter();
var input = JObject.FromObject(context);

target.SetVariable("input", input.ToObject());
return target.Eval(expr);
}
}
  1. 测试调用
using Newtonsoft.Json;
using PdfSign;

//读取签名所需配置文件
var signOpts = await GetSignOpt();

if (signOpts != null && signOpts.Any())
{
//执行 pdf 文件盖章
var signFileName= SignService.PdfSign(signOpts, "test");
}

//读取配置文件
static async Task<List<SignOpt>?> GetSignOpt()
{
var strSign = await File.ReadAllTextAsync("cfg.json");
return JsonConvert.DeserializeObject<List<SignOpt>>(strSign);
}
  1. 效果展示
    原 pdf 文件如下图: image.png 最终效果如下图: image.png

结束语

随着本文的深入探讨,我们共同经历了一个完整的旅程,从理解电子印章的重要性到实现一个自动化的.NET程序,用于在PDF文件上高效、准确地加盖电子章。我们不仅学习了.NET环境下处理PDF文件的技术细节,还掌握了如何将电子印章整合到我们的应用程序中,以实现自动化的文档认证过程。


作者:架构师小任
来源:juejin.cn/post/7377643248187080715

收起阅读 »

MySQL的 where 1=1会不会影响性能?看完官方文档就悟了!

在日常业务开发中,会通过使用where 1=1来简化动态 SQL语句的拼接,有人说where 1=1会影响性能,也有人说不会,到底会不会影响性能?本文将从 MySQL的官方资料来进行分析。 动态拼接 SQL的方法 在 Mybatis中,动态拼接 SQL最常用的...
继续阅读 »

在日常业务开发中,会通过使用where 1=1来简化动态 SQL语句的拼接,有人说where 1=1会影响性能,也有人说不会,到底会不会影响性能?本文将从 MySQL的官方资料来进行分析。


动态拼接 SQL的方法


在 Mybatis中,动态拼接 SQL最常用的两种方式:使用 where 1=1 和 使用标签。


使用where 1=1


使用过 iBATIS的小伙伴应该都知道:在 iBATIS中没有标签,动态 SQL的处理相对较为原始和复杂,因此使用where 1=1这种写法的用户很大一部分是还在使用 iBATIS 或者是从 iBATIS过度到 Mybatis。


如下示例,通过where 1=1来动态拼接有效的 if语句:


<select id="" parameterType = "">
SELECT * FROM user
WHERE 1=1
<if test="name != null and name != ''">
AND name = #{name}
</if>
<if test="age != null ">
AND age = #{age }
</if>
</select>

使用标签


Mybatis提供了标签,标签只有在至少一个 if条件有值的情况下才去生成 where子句,若 AND或 OR前没有有效语句,where元素会将它们去除,也就是说,如果 Mybatis通过标签动态生成的语句为where AND name = '111',最终会被优化为where name = '111'


标签使用示例如下:


<select id="" parameterType = "">
SELECT * FROM user
<where>
<if test="name != null and name != ''">
AND name = #{name}
</if>
<if test="age != null">
AND age = #{age}
</if>
</where>
</select>

标签是在 MyBatis中引入的,所以,很多一开始就使用 MyBatis的用户对这个标签使用的比较多。


性能影响


where 1=1到底会不会影响性能?我们可以先看一个具体的例子:



说明:示例基于 MySQL 8.0.30



可以使用如下指令查看 MySQL版本:


SELECT VERSION();

image.png


场景:基于一张拥有 100多万条数据的user表,根据name进行查询,


查看表结构和表的总数据,如下图:


image.png


image.png


下面,通过执行两条 SQL查询语句(一条带有 1=1):


select * from user where name = 'name-96d1b3ce-1a24-4d47-b686-6f9c6940f5f6';
select * from user where 1=1 and name = 'name-f692472e-40de-4053-9498-54b9800e9fb1';

image.png


对比两条 SQL执行的结果,可以发现它们消耗的时间几乎相同,因此,看起来where 1=1对整体的性能似乎并不影响。


为了排除一次查询不具有代表性,我们分别对两条 SQL语句查询 100遍,然后计算平均值:


SET PROFILING = 1;
DO SLEEP(0.001); -- 确保每次查询之间有足够时间间隔

SET @count = 0;
WHILE @count < 100 DO
select * from user where name = 'name-96d1b3ce-1a24-4d47-b686-6f9c6940f5f6';
-- or
select * from user where 1=1 and name = 'name-f692472e-40de-4053-9498-54b9800e9fb1';
SET @count = @count + 1;
END WHILE;

SHOW PROFILES;

两条 SQL分别执行 100次后,最终也发现它们的平均值几乎相同,因此,上述示例似乎证明了 where 1=1 对整体的性能并没有不影响。


为什么没有影响?是不是 MySQL对 1=1进行了优化?


为了证明猜想,我们借助show warnings命令来查看信息,在 MySQL中,show warnings命令用于显示最近执行的 SQL语句产生的警告、错误或通知信息。它可以帮助我们了解语句执行过程中的问题。如下示例:


explain select * from user where 1=1 and name = 'name-f692472e-40de-4053-9498-54b9800e9fb1';
show warnings;

image.png


将上述示例的 warnings信息摘出来如下:


/* select#1 */ select `yuanjava`.`user`.`id` AS `id`,
`yuanjava`.`user`.`name` AS `name`,
`yuanjava`.`user`.`age` AS `age`,
`yuanjava`.`user`.`sex` AS `sex`,
`yuanjava`.`user`.`created_at` AS `created_at`
from `yuanjava`.`user`
where (`yuanjava`.`user`.`name` = 'name-f692472e-40de-4053-9498-54b9800e9fb1')

从 warnings信息可以看出:1=1已经被查询优化器优化掉,因此,对整体的性能影响并不大。


那么,有没有 MySQL的官方资料可以佐证 where 1=1确实被优化了?


答案:有!MySQL有一种 Constant-Folding Optimization(常量折叠优化)的功能。


Constant-Folding Optimization


MySQL的优化器具有一项称为 Constant-Folding Optimization(常量折叠优化)的功能,可以从查询中消除重言式表达式。Constant-Folding Optimization 是一种编译器的优化技术,用于优化编译时计算表达式的常量部分,从而减少运行时的计算量,换句话说:Constant-Folding Optimization 是发生在编译期,而不是引擎执行期间。


对于上述表达的"重言式表达式"又是什么呢?


重言式


重言式(Tautology )又称为永真式,它的汉语拼音为:[Chóng yán shì],是逻辑学的名词。命题公式中有一类重言式,如果一个公式,对于它的任一解释下其真值都为真,就称为重言式(永真式)。


其实,重言式在计算机领域也具有重要应用,比如"重言式表达式"(Tautological expression),它指的是那些总是为真的表达式或逻辑条件。


在 SQL查询中,重言式表达式是指无论在什么情况下,结果永远为真,它们通常会被优化器识别并优化掉,以提高查询效率。例如,如果 where中包含 1=1 或 A=A 这种重言式表达式,它们就会被优化器移除,因为对查询结果没有实际影响。如下两个示例:


SELECT * from user where 1=1 and name = 'xxx';
-- 被优化成
SELECT * from user where name = 'xxx'

SELECT id, name, salary * (1 + 0.05 * 2) AS real_salary FROM employees;
-- 优化成(1 + 0.05 * 2 被优化成 1.1)
SELECT id, name, salary * 1.1 AS real_salary FROM employees;

另外,通过下面 MySQL架构示意图可以看出:优化器是属于 MySQL的 Server层,因此,Constant-Folding Optimization功能支持受 MySQL Server的版本影响。


image.png


查阅了 MySQL的官方资料,Constant-Folding Optimization 从 MySQL5.7版本开始引入,至于 MySQL5.7以前的版本是否具备这个功能,还有待考证。


如何选择?


where 1=1 标签 两种方案,该如何选择?



  • 如果 MySQL Server版本大于等于 5.7,两个随便选,或者根据团队的要求来选;

  • 如果 MySQL Server版本小于 5.7,假如使用的是 MyBatis,建议使用 标签,如果使用的还是比较老的 iBATIS,只能使用where 1=1

  • 如果 MySQL Server版本小于 5.7,建议升升级



信息补充:2009年5月,iBATIS从 2.0版本开始更名为 MyBatis, 标签最早出现在MyBatis 3.2.0版本中



总结


where 1=1 标签到底会不会影响性能,这个问题在网上已经出现了很多次,今天还是想从官方文档来进行说明。本文通过 MySQL的官方资料,加上百万数据的表进行真实测试,得出下面的结论:



  • 如果 MySQL Server版本大于等于 5.7,两个随便选,或者根据团队的要求来选;

  • 如果 MySQL Server版本小于 5.7,假如使用的是 MyBatis,建议使用 标签,如果使用的还是比较老的 iBATIS,只能使用where 1=1


最后,遇到问题,建议首先查找官方的一手资料,这样才能帮助自己在一条正确的技术道路上成长!


参考资料


MySQL8.0 Constant-Folding Optimization


MySQL5.7 WHERE Clause Optimization


What’s New in MySQL 5.7




作者:猿java
来源:juejin.cn/post/7374238289107648551
收起阅读 »

代码很少,却很优秀!RocketMQ的NameServer是如何做到的?

今天我们来一起深入分析 RocketMQ的注册中心 NameServer。 本文基于 RocketMQ release-5.2.0 首先,我们回顾下 RocketMQ的内核原理鸟瞰图: 从上面的鸟瞰图,我们可以看出:Nameserver即和 Broker...
继续阅读 »

今天我们来一起深入分析 RocketMQ的注册中心 NameServer。



本文基于 RocketMQ release-5.2.0



首先,我们回顾下 RocketMQ的内核原理鸟瞰图:


image.png


从上面的鸟瞰图,我们可以看出:Nameserver即和 Broker交互,也和 Producer和 Consumer交互,因此,在 RocketMQ中,Nameserver起到了一个纽带性的作用。


接着,我们再看看 NameServer的工程结构,如下图:


image.png


整个工程只有 11个类(老版本好像只有不到 10个类),为什么 RocketMQ可以用如此少的代码,设计出如此高性能且轻量的注册中心?


我觉得最核心的有 3点是:



  1. AP设计思想

  2. 简单的数据结构

  3. 心跳机制


AP设计思想


像 ZooKeeper,采用了 Zab (Zookeeper Atomic Broadcast) 这种比较重的协议,必须大多数节点(过半数)可用,才能确保了数据的一致性和高可用,大大增加了网络开销和复杂度。


而 NameServer遵守了 CAP理论中 AP,在一个 NameServer集群中,NameServer节点之间是P2P(Peer to Peer)的对等关系,并且 NameServer之间并没有通信,减少很多不必要的网络开销,即便只剩一个 NameServer节点也能继续工作,足以保证高可用。


数据结构


NameServer维护了一套比较简单的数据结构,内部维护了一个路由表,该路由表包含以下几个核心元数据,对应的源码类RouteInfoManager如下:


public class RouteInfoManager {
private final static long DEFAULT_BROKER_CHANNEL_EXPIRED_TIME = 1000 * 60 * 2; // broker失效时间 120s
private final ReadWriteLock lock = new ReentrantReadWriteLock();
private final Map/* topic */, Map> topicQueueTable;
private final Map/* brokerName */, BrokerData> brokerAddrTable;
private final Map/* clusterName */, Set/* brokerName */>> clusterAddrTable;
private final Map/* brokerAddr */, BrokerLiveInfo> brokerLiveTable;
private final Map/* brokerAddr */, List/* Filter Server */> filterServerTable;
}


  • topicQueueTable: Topic消息队列路由信息,消息发送时根据路由表进行负载均衡

  • brokerAddrTable: Broker基础信息,包括brokerName、所属集群名称、主备Broker地址

  • clusterAddrTable: Broker集群信息,存储集群中所有Broker名称

  • brokerLiveTable: Broker状态信息,NameServer每次收到心跳包是会替换该信息

  • filterServerTable: Broker上的FilterServer列表,用于过滤标签(Tag)或 SQL表达式,以减轻 Consumer的负担,提高消息消费的效率。


TopicRouteData


TopicRouteData是 NameServer中最重要的数据结构之一,它包括了 Topic对应的所有 Broker信息以及每个 Broker上的队列信息,filter服务器列表,其源码如下:


public class TopicRouteData {
private List queueDatas;
private List brokerDatas;
private HashMap> filterServerTable;
//It could be null or empty
private Map/*brokerName*/, TopicQueueMappingInfo> topicQueueMappingByBroker;
}

BrokerData


BrokerData包含了 Broker的基本属性,状态,所在集群以及 Broker服务器的 IP地址,其源码如下:


public class BrokerData {
private String cluster;//所在的集群
private String brokerName;//所在的brokerName
private HashMap brokerAddrs;//该broker对应的机器IP列表
private String zoneName; // 区域名称
}

QueueData


QueueData包含了 BrokerName,readQueue的数量,writeQueue的数量等信息,对应的源码类是QueueData,其源码如下:


public class QueueData {
private String brokerName;//所在的brokerName
private int readQueueNums;// 读队列数量
private int writeQueueNums;// 写队列数量
private int perm; // 读写权限,参考PermName 类
private int topicSysFlag; // topic同步标记,参考TopicSysFlag 类
}

元数据举例


为了更好地理解元数据,这里对每一种元数据都给出一个数据实例:


topicQueueTable:{
"topicA":[
{
"brokeName":"broker-a",
"readQueueNums":4,
"writeQueueNums":4,
"perm":6,
"topicSyncFlag":0
},
{
"brokeName":"broker-b",
"readQueueNums":4,
"writeQueueNums":4,
"perm":6,
"topicSyncFlag":0
}
],
"topicB":[]
}

brokeAddrTable:{
"broker-a":{
"cluster":"cluster-1",
"brokerName":"broker-a",
"brokerAddrs":{
0:"192.168.0.1:8000",
1:"192.168.0.2:8000"
}
},
"broker-b":{
"cluster":"cluster-1",
"brokerName":"broker-b",
"brokerAddrs":{
0:"192.168.0.3:8000",
1:"192.168.0.4:8000"
}
}
}

brokerLiveTable:{
"192.168.0.1:8000":{
"lastUpdateTimestamp":1533434434344,//long 的时间戳
"dataVersion":dataVersionObj, //参考DataVersion类
"channel":channelObj,// 参考io.netty.channel.Channel
"haServerAddr":"192.168.0.2:8000"
},
"192.168.0.2:8000":{
"lastUpdateTimestamp":1533434434344,//long 的时间戳
"dataVersion":dataVersionObj, //参考DataVersion类
"channel":channelObj,// 参考io.netty.channel.Channel
"haServerAddr":"192.168.0.1:8000"
},
"192.168.0.3:8000":{ },
"192.168.0.4:8000":{ },
}

clusterAddrTable:{
"cluster-1":[{"broker-a"},{"broker-b"}],
"cluster-2":[],
}

filterServerTable:{
"192.168.0.1:8000":[{"192.168.0.1:7000"}{"192.168.0.1:9000"}],
"192.168.0.2:8000":[{"192.168.0.2:7000"}{"192.168.0.2:9000"}],
}

心跳机制


心跳机制是 NameServer维护 Broker的路由信息最重要的一个抓手,主要分为接收心跳、处理心跳、心跳超时 3部分:


接收心跳


Broker每 30s会向所有的 NameServer发送心跳包,告诉它们自己还存活着,从而更新自己在 NameServer的状态,整体交互如下图:


image.png


处理心跳


NameServer收到心跳包时会更新 brokerLiveTable缓存中 BrokerLiveInfo的 lastUpdateTimeStamp信息,整体交互如下图:


image.png


处理逻辑可以参考源码:
org.apache.rocketmq.namesrv.processor.DefaultRequestProcessor#processRequest#brokerHeartbeat:


public RemotingCommand brokerHeartbeat(ChannelHandlerContext ctx,
RemotingCommand request)
throws RemotingCommandException {
final RemotingCommand response = RemotingCommand.createResponseCommand(null);
final BrokerHeartbeatRequestHeader requestHeader =
(BrokerHeartbeatRequestHeader) request.decodeCommandCustomHeader(BrokerHeartbeatRequestHeader.class);

this.namesrvController.getRouteInfoManager().updateBrokerInfoUpdateTimestamp(requestHeader.getClusterName(), requestHeader.getBrokerAddr());

response.setCode(ResponseCode.SUCCESS);
response.setRemark(null);
return response;
}

心跳超时


NameServer每隔 10s(每隔5s + 5s延迟)扫描 brokerLiveTable检查 Broker的状态,如果在 120s内未收到 Broker心跳,则认为 Broker异常,会从路由表将该 Broker摘除并关闭 Socket连接,同时还会更新路由表的其他信息,整体交互如下图:


image.png


private void startScheduleService() {
this.scanExecutorService.scheduleAtFixedRate(NamesrvController.this.routeInfoManager::scanNotActiveBroker,
5, this.namesrvConfig.getScanNotActiveBrokerInterval(), TimeUnit.MILLISECONDS);
}

源码参考:org.apache.rocketmq.namesrv.routeinfo.RouteInfoManager#unRegisterBroker(),核心流程:



  1. 遍历brokerAddrTable

  2. 遍历broker地址

  3. 根据 broker地址移除 brokerAddr

  4. 如果当前 Topic只包含待移除的 Broker,则移除该 Topic


其他核心源码解读


NameServer启动


NameServer的启动类为:org.apache.rocketmq.namesrv.NamesrvStartup,整个流程如下图:


image.png
图片来自:Mark_Zoe


NameServer启动最核心的 3个事情是:



  1. 加载配置:NameServerConfig、NettyServerConfig主要是映射配置文件,并创建 NamesrvController。

  2. 启动 Netty通信服务:NettyRemotingServer是 NameServer和Broker,Producer,Consumer通信的底层通道 Netty服务器。

  3. 启动定时器和钩子程序:NameServerController实例一方面处理 Netty接收到消息后,一方面内部有多个定时器和钩子程序,它是 NameServer的核心控制器。


总结


NameServer并没有采用复杂的分布式协议来保持数据的一致性,而是采用 CAP理论中的 AP,各个节点之间是Peer to Peer的对等关系,数据的一致性通过心跳机制,定时器,延时感知来完成。


NameServer最核心的 3点设计是:



  1. AP的设计思想

  2. 简单的数据结构

  3. 心跳机制




作者:猿java
来源:juejin.cn/post/7379431978814275596
收起阅读 »

VSCode无限画布模式(可能会惊艳到你的一个小功能)

❓现存的痛点VSCode是我的主力开发工具,在实际的开发中,我经常会对编辑器进行分栏处理,组件A的tsx、css代码、工具类方法各一个窗口,组件B的tsx、css代码、工具类方法各一个窗口,组件C的......当组件拆的足够多的时候,多个分栏会把本就不大的编辑...
继续阅读 »

❓现存的痛点

image.png

VSCode是我的主力开发工具,在实际的开发中,我经常会对编辑器进行分栏处理,组件A的tsx、css代码、工具类方法各一个窗口组件B的tsx、css代码、工具类方法各一个窗口,组件C的......

small.gif

当组件拆的足够多的时候,多个分栏会把本就不大的编辑器窗口分成N份,每一份的可视区域就小的可怜,切换组件代码时,需要不小的翻找成本,而且经常忘记我之前把文件放在了那个格子里,特别是下面的场景(一个小窗口内开了N个小tab),此时更难找到想要的窗口了...

多个tab.gif

问题汇总

  1. 分栏会导致每个窗口的面积变小,开发体验差(即使可以双击放大,但效果仍不符合预期);
  2. 编辑器窗口容易被新打开的窗口替换掉,常找不到之前打开的窗口;
  3. 窗口的可操作性不强,位置不容易调整

💡解题的思路

1. 自由 & 独立的编辑器窗口

  1. 分栏会导致每个窗口的面积变小,开发体验不好。

那就别变小了!每个编辑器窗口都还是原来的大小,甚至更大!

20240531-220533.gif

2. 无限画布

  1. 编辑器窗口容易被新打开的窗口替换掉,常找不到之前打开的窗口。
  2. 窗口的可操作性不强,位置不容易调整。

那就每个窗口都拥有一个自己的位置好了!拖一下就可以找到了!

scroll.gif

3. 画布体验

好用的画布是可以较大的提升用户体验的,下面重点做了四个方面的优化:

3.1 在编辑器里可以快速缩小 & 移动

因为不可避免的会出现一些事件冲突(比如编辑器里的滚动和画布的滚动、缩放等等),通过提供快捷键的解法,可以在编辑器内快速移动、缩放画布。

command + 鼠标上下滑动 = 缩放
option + 鼠标移动 = 画布移动

注意下图,鼠标还在编辑器窗口中,依然可以拖动画布👇🏻

single-editor.gif

3.2 快速放大和缩小编辑窗口

通过快捷按钮的方式,可以快速的放大和缩小编辑器窗口。

scale.gif

3.3 一键定位到中心点

不小心把所有窗口都拖到了画布视口外找不到了?没事儿,可以通过点击快捷按钮的方式,快速回到中心点。

center.gif

3.4 窗口的合并和分解

可以在窗口下进行编辑器的合并,即可以简单的把一些常用的窗口进行合并、分解。

add-remove.gif

💬 提出的背景

作为一名前端开发同学,避免不了接触UI同学的设计稿,我司使用的就是figma,以figma平台为例,其无限画布模式可以非常方便的平铺N个稿子,并快速的看到所有稿子的全貌、找到自己想要的稿子等等,效果如下:

figma.gif

没错!我就是基于这个思路提出了第一个想法,既然图片可以无限展示,编辑器为什么不能呢?

这个想法其实去年就有了,期间大概断断续续花了半年多左右的时间在调研和阅读VSCode的源码上,年后花了大概3个月的时间进行实现,最终在上个月做到了上面的效果。

经过约一个月的试用(目前我的日常需求均是使用这种模式进行的开发),发现效果超出预期,我经常会在画布中开启约10+个窗口,并频繁的在各个窗口之间来回移动,在这个过程中,我发现以下几点很让我很是欣喜:

  1. 空间感:我个人对“空间和方向”比较敏感,恰好画布模式会给我一种真实的空间感,我仿佛在一间房子里,里面摆满了我的代码,我穿梭在代码中,修一修这个,调一调这个~
  2. 满足感:无限画布的方式,相当于我间接拥有了无限大的屏幕,我只需要动动手指找到我的编辑窗口就好了,它可以随意的放大和缩小,所以我可以在屏幕上展示足够多的代码。
  3. 更方便的看源码:我可以把源码的每个文件单独开一个窗口,然后把每个窗口按顺序铺起来,摆成一条线,这条线就是源码的思路(当然可以用截图的方式看源码 & 缕思路,但是,需要注意一点,这个编辑器是可以交互的!)

⌨️ 后续的计划

后续计划继续增强画布的能力,让它可以更好用:

  1. 小窗口支持命名,在缩小画布时,窗口缩小,但是命名不缩小,可以直观的找到想要的窗口。
  2. 增强看源码的体验:支持在画布上添加其他元素(文案、箭头、连线),试想一下,以后在看源码时,拥有一个无限的画板来展示代码和思路,关键是代码是可以交互的,这该有多么方便!
  3. 类似MacOS的台前调度功能:把有关联的一些窗口分组,画布一侧有分组的入口,点击入口可以切换画布中的组,便于用户快速的进行批量窗口切换,比如A页面的一些JS、CSS等放在一个组,B页面放在另一个组,这样可以快速的切换文件窗口。

📔 其他的补充

调研过程中发现无法使用VSCode的插件功能来实现这个功能,所以只好fork了一份VSCode的开源代码,进行了大量修改,最终需要对源码进行编译打包才能使用(一个新的VSCode),目前只打包了mac的arm64版本来供自己试用。

另外,由于VSCode并不是100%开源(微软的一些服务相关的逻辑是闭源的),所以github上的开源仓库只是它的部分代码,经过编译之后,发现缺失了远程连接相关的功能,其他的功能暂时没有发现缺失。

image.png

🦽 可以试用吗

目前还没有对外提供试用版的打算,想自己继续使用一些时间,持续打磨一下细节,等功能细节更完善了再对外进行推广,至于这次的软文~ 其实是希望可以引起阅读到这里的同学进行讨论,可以聊一下你对该功能的一些看法,以及一些其他的好点子~,thx~

🫡 小小的致敬

  • 致敬VSCode团队,在阅读和改造他们代码的过程中学习到了不少hin有用的代码技能,也正是因为有他们的开源,才能有我的这次折腾👍🏻
  • 致敬锤子科技罗永浩老师,这次实现思路也有借鉴当年发布的“无限屏”功能,本文的头图就是来自当年的发布会截图。

image.png


作者:木头就是我呀
来源:juejin.cn/post/7375586227984220169
收起阅读 »

接口幂等和防抖还在傻傻分不清楚。。。

最近想重温下接口幂等的相关知识,正所谓温故而知新,太久不CV的东西要是哪天CV起来都生疏了,哈哈哈 先从字面意思来温习下吧,幂等的官方概念来源于数学上幂等概念的衍生,如幂等函数,即使用相同的参数重复执行,可以得到相同的结果的函数,翻译成IT行业专业术语就是一个...
继续阅读 »

最近想重温下接口幂等的相关知识,正所谓温故而知新,太久不CV的东西要是哪天CV起来都生疏了,哈哈哈


先从字面意思来温习下吧,幂等的官方概念来源于数学上幂等概念的衍生,如幂等函数,即使用相同的参数重复执行,可以得到相同的结果的函数,翻译成IT行业专业术语就是一个接口使用相同的入参,无论执行多少次,最后得到的结果且保存的数据和执行一次是完全一样的,所以,基于这个概念,分析我们的CRUD,首先,查询,它可以说是幂等的,但是如果更精细的说,它也可能不是幂等的,基于数据库数据不变的情况下,查询接口是幂等的,如果是变化的话那可能上一秒你查出来的数据,下一秒它就被人修改了也不是不可能,所以,基于这一点它不符合幂等概念


接下来是删除接口,它和查询一样,也是天然幂等的,但是如果你的接口提供范围删除,那么就破坏了幂等性原则,理论上这个删除接口就不应该存在,那如果是产品经理非要那也是可以存在滴,技术永远都是为业务服务的嘛


修改接口也是同样的道理,理论上都必须是幂等的,如果不是,那就要考虑接口幂等性了,比如你的修改积分接口里写修改积分,每次都使用i++这种操作,那么它就破坏了幂等原则,有一个好方法就是基于用户唯一性标识把积分变动通过一张表记录下来,最后统计这张表的积分数值,这里也就涉及到新增接口的知识点,其实到这里,我们会发现,所有的接口理论上都可以是幂等的,但是总是这个那个的原因导致不幂等,所以,总结起来就是,如果你的系统需求需要接口幂等,那么就实现它,现在让我们进入正题吧


刚开始温习幂等知识的时候,我百度了很多别人写的文章,发现另一个概念,叫防抖,防止用户重复点击的意思,有意思的是有些文章竟然认为防抖就是幂等,他们解决接口幂等的思路是每次调用需要实现幂等接口时,前端都需要调用后端的颁布唯一token的接口,后端颁布token后保存在缓存中,然后前端带着这个token的请求去请求我们的幂等接口,验证携带的token来实现接口幂等,按照这个思路每次请求的token都不一样,如何保证幂等中相同参数的条件呢,这显然和幂等南辕北辙了,这显然就是接口防抖或者接口加锁的思路嘛


还有一种是可以实现接口幂等性的思路,这里也可以分享一下,和上面的思路差不多,也是每次请求幂等接口的时候,先调用颁发唯一token的接口,唯一不同的是它颁发的token是基于入参生成的哈希值,后面的业务逻辑就是后端基于这个哈希值去校验,如果缓存中已经存在了,说明这个入参已经请求过了,那么直接拒绝请求并返回成功,这样,就从表面上实现了接口幂等性,因为执行100次我只执行一次,剩余的99次我都拒绝,并直接返回成功,这样,我的接口就从表面上幂等了,但是这个方案有一个很大的问题就是每次调用都需要浪费一部分资源在请求颁发token上,这对需要大量的幂等接口的系统来说就是一个累赘,所以,接下来,我们基于这个思路实现一个不需要二次调用的实现接口幂等的方法。


我的思路是这样的,业务上有些接口是实现防抖功能,有些是实现幂等功能,其实这两个功能在业务上确实是相差不大,所以,我的思路是定义一个注解,包含防抖和幂等的功能,首先基于幂等如果要是把所有入参都哈希化作为唯一标识的话有点费劲,可以基于业务上的一些唯一标识来做,如用户id或者code,还需要一个开关,用于决定是否保存这个唯一标识,还要一个时间,保存多久,还有保存时间的单位,最后,还有一个返回提醒,就是拒绝之后的友好提示,基于这些差不多了,如果你的接口功能只需要实现防抖,那么你可以设置时间段内过期,这样就实现了防抖,如果你的接口没有唯一标识,那么可以基于路由来做防抖,这个不要忘了设置过期时间,不然你的接口就永远是拒绝了,好了,思路有了,接下来就是实操了,话不多数,上代码


@Retention(RetentionPolicy.RUNTIME)
//注解用于方法
@Target({ElementType.TYPE, ElementType.METHOD})
//注解包含在JavaDoc中
@Documented
public @interface Idempotent {

/**
* 幂等操作的唯一标识,使用spring el表达式 用#来引用方法参数
*
* @return Spring-EL expression
*/

String key() default "";

/**
* 有效期 默认:1 有效期要大于程序执行时间,否则请求还是可能会进来
*
* @return expireTime
*/

int expireTime() default 100;

/**
* 时间单位 默认:s
*
* @return TimeUnit
*/

TimeUnit timeUnit() default TimeUnit.SECONDS;

/**
* 提示信息,可自定义
*
* @return String
*/

String info() default "重复请求,请稍后重试";

/**
* 是否在业务完成后删除key true:删除 false:不删除
*
* @return boolean
*/

boolean delKey() default false;


基本和我们上面的思路一样,唯一key,有效期,有效期时间单位,提示信息,是否删除,注解有了,那么我们就要基于注解写我们的逻辑了,这里我们需要用到aop,引用注解应该都知道吧,这里我们直接上代码了


@Aspect
@Slf4j
public class IdempotentAspect {
@Resource
private RedisUtil redisUtil;

private static final SpelExpressionParser PARSER = new SpelExpressionParser();

private static final LocalVariableTableParameterNameDiscoverer DISCOVERER = new LocalVariableTableParameterNameDiscoverer();

/**
* 线程私有map
*/

private static final ThreadLocal<Map<String, Object>> THREAD_CACHE = ThreadLocal.withInitial(HashMap::new);

private static final String KEY = "key";

private static final String DEL_KEY = "delKey";

// 以自定义 @Idempotent 注解为切点
@Pointcut("@annotation(com.liuhui.demo_core.spring.Idempotent)")
public void idempotent() {
}

@Before("idempotent()")
public void before(JoinPoint joinPoint) throws Throwable {
//获取到当前请求的属性,进而得到HttpServletRequest对象,以便后续获取请求URL和参数信息。
ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder
.getRequestAttributes();
HttpServletRequest request = requestAttributes.getRequest();
//从JoinPoint中获取方法签名,并确认该方法是否被@Idempotent注解标记。如果是,则继续执行幂等性检查逻辑;如果不是,则直接返回,不进行幂等处理。。
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
if (!method.isAnnotationPresent(Idempotent.class)) {
return;
}
Idempotent idempotent = method.getAnnotation(Idempotent.class);
String key;
// 若没有配置 幂等 标识编号,则使用 url + 参数列表作为区分;如果提供了key规则,则利用keyResolver根据提供的规则和切点信息生成键
if (!StringUtils.hasLength(idempotent.key())) {
String url = request.getRequestURL().toString();
String argString = Arrays.asList(joinPoint.getArgs()).toString();
key = url + argString;
} else {
// 使用jstl 规则区分
key = resolver(idempotent, joinPoint);
}
//从注解中读取并设置幂等操作的过期时间、描述信息、时间单位以及是否删除键的标志。
long expireTime = idempotent.expireTime();
String info = idempotent.info();
TimeUnit timeUnit = idempotent.timeUnit();
boolean delKey = idempotent.delKey();
String value = LocalDateTime.now().toString().replace("T", " ");
Object valueResult = redisUtil.get(key);
synchronized (this) {
if (null == valueResult) {
redisUtil.set(key, value, expireTime, timeUnit);
} else {
throw new IdempotentException(info);
}
}
Map<String, Object> map = THREAD_CACHE.get();
map.put(KEY, key);
map.put(DEL_KEY, delKey);
}


/**
* 从注解的方法的参数中解析出用于幂等性处理的键值(key)
*
* @param idempotent
* @param point
* @return
*/

private String resolver(Idempotent idempotent, JoinPoint point) {
//获取被拦截方法的所有参数
Object[] arguments = point.getArgs();
//从字节码的局部变量表中解析出参数名称
String[] params = DISCOVERER.getParameterNames(getMethod(point));
//SpEL表达式执行的上下文环境,用于存放变量
StandardEvaluationContext context = new StandardEvaluationContext();
//遍历方法参数名和对应的参数值,将它们一一绑定到StandardEvaluationContext中。
//这样SpEL表达式就可以引用这些参数值
if (params != null && params.length > 0) {
for (int len = 0; len < params.length; len++) {
context.setVariable(params[len], arguments[len]);
}
}
//使用SpelExpressionParser来解析Idempotent注解中的key属性,将其作为SpEL表达式字符串
Expression expression = PARSER.parseExpression(idempotent.key());
//转换结果为String类型返回
return expression.getValue(context, String.class);
}

/**
* 根据切点解析方法信息
*
* @param joinPoint 切点信息
* @return Method 原信息
*/

private Method getMethod(JoinPoint joinPoint) {
//将joinPoint.getSignature()转换为MethodSignature
//Signature是AOP中表示连接点签名的接口,而MethodSignature是它的具体实现,专门用于表示方法的签名。
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
//获取到方法的声明。这将返回代理对象所持有的方法声明。
Method method = signature.getMethod();

//判断获取到的方法是否属于一个接口
//因为在Java中,当通过Spring AOP或其它代理方式调用接口的方法时,实际被执行的对象是一个代理对象,直接获取到的方法可能来自于接口声明而不是实现类。
if (method.getDeclaringClass().isInterface()) {
try {
//通过反射获取目标对象的实际类(joinPoint.getTarget().getClass())中同名且参数类型相同的方法
//这样做是因为代理类可能对方法进行了增强,直接调用实现类的方法可以确保获取到最准确的实现细节
method = joinPoint.getTarget().getClass().getDeclaredMethod(joinPoint.getSignature().getName(),
method.getParameterTypes());
} catch (SecurityException | NoSuchMethodException e) {
throw new RuntimeException(e);
}
}
return method;
}


@After("idempotent()")
public void after() throws Throwable {
Map<String, Object> map = THREAD_CACHE.get();
if (CollectionUtils.isEmpty(map)) {
return;
}

String key = map.get(KEY).toString();
boolean delKey = (boolean) map.get(DEL_KEY);
if (delKey) {
redisUtil.delete(key);
log.info("[idempotent]:has removed key={}", key);
}
//无论是否移除了键,最后都会清空当前线程局部变量THREAD_CACHE中的数据,避免内存泄漏
THREAD_CACHE.remove();
}
}

上面redisUtil是基于RedisTemplate封装的工具类,可以直接替换哈,这里我们定义一个切入点,也就是我们定义的注解,然后在调用接口之前获取到接口的入参以及注解的参数,获取到这些之后,判断是否有唯一标识,没有就用路由,保存到reids当中,然后设置过期时间,最后需要把删除的标识放到线程私有变量THREAD_CACHE中在接口处理完之后判断是否需要删除redis当中保存的key,这里,我们的逻辑就写完了,接下来是使用了,使用这个就很简单,直接在你需要实现防抖和幂等的接口上打上我们的注解


/**
* 测试接口添加幂等校验
*
* @return
*/

@PostMapping("/redis")
@Idempotent(key = "#user.id", expireTime = 10, delKey = true, info = "重复请求,请稍后再试")
public Result<?> getRedis(@RequestBody User user) throws InterruptedException {
return Result.success(true);
}

这里key的定义方式我们使用了SpEL表达式,如果不指定这个表达式的话就会使用路由作为key了


到这里,接口幂等和防抖功能就顺利完成了,以后,别再防抖和幂等傻傻分不清楚了哈哈哈


最后,还是要送上一位名人曾说的一句话:手上没有剑和有剑不用是两回事!


作者:失乐园
来源:juejin.cn/post/7380274613185970195
收起阅读 »

阿里也出手了!Spring CloudAlibaba AI问世了

写在前面 在之前的文章中我们有介绍过SpringAI这个项目。SpringAI 是Spring 官方社区项目,旨在简化 Java AI 应用程序开发, 让 Java 开发者像使用 Spring 开发普通应用一样开发 AI 应用。 而SpringAI 主要面向的...
继续阅读 »

写在前面


在之前的文章中我们有介绍过SpringAI这个项目。SpringAI 是Spring 官方社区项目,旨在简化 Java AI 应用程序开发,


让 Java 开发者像使用 Spring 开发普通应用一样开发 AI 应用。


SpringAI 主要面向的是国外的各种大模型接入,对于国内开发者可能不太友好。


于是乎,Spring Cloud Alibaba AI便问世了,Spring Cloud Alibaba AI以 Spring AI 为基础,并在此基础上提供阿里云通义系列大模型全面适配,


让用户在 5 分钟内开发基于通义大模型的 Java AI 应用。


一、Spring AI 简介


可能有些小伙伴已经忘记了SpringAI 是啥?我们这儿再来简单回顾一下。


Spring AI是一个面向AI工程的应用框架。其目标是将可移植性和模块化设计等设计原则应用于AI领域的Spring生态系统,


并将POJO作为应用程序的构建块推广到AI领域。


转换为人话来说就是:Spring出了一个AI框架,帮助我们快速调用AI,从而实现各种功能场景。


二、Spring Cloud Alibaba AI 简介


Spring Cloud Alibaba AISpring AI 为基础,并在此基础上,基于 Spring AI 0.8.1 版本 API 完成通义系列大模型的接入


实现阿里云通义系列大模型全面适配。


在当前最新版本中,Spring Cloud Alibaba AI 主要完成了几种常见生成式模型的适配,包括对话、文生图、文生语音等,


开发者可以使用 Spring Cloud Alibaba AI 开发基于通义的聊天、图片或语音生成 AI 应用,


框架还提供 OutParserPrompt TemplateStuff 等实用能力。


三、第一个Spring AI应用开发


① 新建maven 项目


注: 在创建项目的时候,jdk版本必须选择17+


新建maven项目


② 添加依赖


<dependency>
   <groupId>com.alibaba.cloud</groupId>
   <artifactId>spring-cloud-alibaba-dependencies</artifactId>
   <version>2023.0.1.0</version>
   <type>pom</type>
   <scope>import</scope>
</dependency>

<dependency>
   <groupId>com.alibaba.cloud</groupId>
   <artifactId>spring-cloud-starter-alibaba-ai</artifactId>
   <version>2023.0.1.0</version>
</dependency>

注: 这里我们需要配置镜像源,否则是没法下载依赖的。会报如下错误



spring-ai: 0.8.1 dependency not found



<repositories>
   <repository>
       <id>spring-milestones</id>
       <name>Spring Milestones</name>
       <url>https://repo.spring.io/milestone</url>
       <snapshots>
           <enabled>false</enabled>
       </snapshots>
   </repository>
</repositories>

③ 在 application.yml 配置文件中添加api-key


spring:
cloud:
  ai:
    tongyi:
      api-key: 你自己申请的api-key

小伙伴如果不知道在哪申请,我把申请连接也放这儿了


dashscope.console.aliyun.com/apiKey


操作步骤:help.aliyun.com/zh/dashscop…


④ 新建TongYiController 类,代码如下


@RestController
@RequestMapping("/ai")
@CrossOrigin
@Slf4j
public class TongYiController {

   @Autowired
   @Qualifier("tongYiSimpleServiceImpl")
   private TongYiService tongYiSimpleService;

   @GetMapping("/example")
   public String completion(@RequestParam(value = "message", defaultValue = "Tell me a joke") String message) {

       return tongYiSimpleService.completion(message);
  }
   
}

⑤ 新建TongYiService 接口,代码如下


public interface TongYiService {
   String completion(String message);

}

⑥ 新建TongYiSimpleServiceImpl 实现类,代码如下


@Service
@Slf4j
public  class TongYiSimpleServiceImpl  implements TongYiService {

   private final ChatClient chatClient;

   @Autowired
   public TongYiSimpleServiceImpl(ChatClient chatClient, StreamingChatClient streamingChatClient) {
       this.chatClient = chatClient;
  }

   @Override
   public String completion(String message) {
       Prompt prompt = new Prompt(new UserMessage(message));

       return chatClient.call(prompt).getResult().getOutput().getContent();
  }


}

到这儿我们一个简单的AI应用已经开发完成了,最终项目结构如下


项目结构


四、运行AI应用


启动服务,我们只需要在浏览器中输入:http://localhost:8080/ai/example 即可与AI交互。


① 不带message参数,则message=Tell me a joke,应用随机返回一个笑话


随机讲一个笑话1


② 我们在浏览器中输入:http://localhost:8080/ai/example?message=对话内容


message带入


五、前端页面对话模式


我们只需要在resources/static 路径下添加一个index.html前端页面,即可拥有根据美观的交互体验。


index.html代码官方github仓库中已给出样例,由于代码比较长,这里就不贴代码了


github.com/alibaba/spr…


添加完静态页面之后,我们浏览器中输入:http://localhost:8080/index.html 就可以得到一个美观的交互界面


美观交互界面


接下来,我们来实际体验一下


UI交互


六、其他模型


上面章节中我们只简单体验了对话模型,阿里还有很多其他模型。由于篇幅原因这里就不一一带大家一起体验了。


应用场景:


应用场景


各个模型概述:


模型概述


七、怎么样快速接入大模型


各种应用场景阿里官方GitHub都给出了接入例子


github.com/alibaba/spr…


官方样例


感兴趣的小伙伴可以自己到上面github 仓库看代码研究


本期内容到这儿就结束了,★,°:.☆( ̄▽ ̄)/$: .°★ 。 希望对您有所帮助


我们下期再见 ヾ(•ω•`)o (●'◡'●)


作者:xiezhr
来源:juejin.cn/post/7380771735681941523
收起阅读 »

反射为什么慢?

1. 背景 今天刷到一篇文章,标题是反射为什么慢,一下子懵逼了,确实没想过这个问题;盲猜了一下是由于反射实际上是做了一个代理的动作,导致执行的效率是小于直接实体类去调用方法的。 2. 文章给出的解释 文章中给出的理由是因为以下4点: 反射涉及动态解析的内容,...
继续阅读 »

1. 背景


今天刷到一篇文章,标题是反射为什么慢,一下子懵逼了,确实没想过这个问题;盲猜了一下是由于反射实际上是做了一个代理的动作,导致执行的效率是小于直接实体类去调用方法的。


2. 文章给出的解释


文章中给出的理由是因为以下4点:



  1. 反射涉及动态解析的内容,不能执行某些虚拟机优化,例如JIT优化技术

  2. 在反射时,参数需要包装成object[]类型,但是方法真正执行的时候,又使用拆包成真正的类型,这些动作不仅消耗时间,而且过程中会产生很多的对象,这就会导致gc,gc也会导致延时

  3. 反射的方法调用需要从数组中遍历,这个遍历的过程也比较消耗时间

  4. 不仅需要对方法的可见性进行检查,参数也需要做额外的检查


3. 结合实际理解


3.1 第一点分析


首先我们需要知道,java中的反射是一种机制,它可以在代码运行过程中,获取类的内部信息(变量、构造方法、成员方法);操作对象的属性、方法。
然后关于反射的原理,首先我们需要知道一个java项目在启动之后,会将class文件加载到堆中,生成一个class对象,这个class对象中有一个类的所有信息,通过这个class对象获取类相关信息的操作我们称为反射。


其次是JIT优化技术,首先我们需要知道在java虚拟机中有两个角色,解释器和编译器;这两者各有优劣,首先是解释器可以在项目启动的时候直接直接发挥作用,省去编译的时候,立即执行,但是在执行效率上有所欠缺;在项目启动之后,随着时间推移,编译器逐渐将机器码编译成本地代码执行,减少解释器的中间损耗,增加了执行效率。


我们可以知道JIT优化通常依赖于在编译时能够知道的静态信息,而反射的动态性可能会破坏这些假设,使得JIT编译器难以进行有效的优化。


3.2 第二点


关于第二点,我们直接写一段反射调用对象方法的demo:


@Test
public void methodTest() {
Class clazz = MyClass.class;

try {
//获取指定方法
//这个注释的会报错 java.lang.NoSuchMethodException
//Method back = clazz.getMethod("back");
Method back = clazz.getMethod("back", String.class);
Method say = clazz.getDeclaredMethod("say", String.class);
//私有方法需要设置
say.setAccessible(true);
MyClass myClass = new MyClass("abc", 99);
//反射调用方法
System.out.println(back.invoke(myClass, "back"));

say.invoke(myClass, "hello world");
} catch (Exception e) {
e.printStackTrace();
}
}

在上面这段代码中,我们调用了一个invoke 方法,并且传了class对象和参数,进入到invoke方法中,我们可以看到invoke方法的入参都是Object类型的,args更是一个Object 数组,这就第二点,关于反射调用过程中的拆装箱。


@CallerSensitive
public Object invoke(Object obj, Object... args)
throws IllegalAccessException, IllegalArgumentException,
InvocationTargetException
{
if (!override) {
if (!Reflection.quickCheckMemberAccess(clazz, modifiers)) {
Class<?> caller = Reflection.getCallerClass();
checkAccess(caller, clazz, obj, modifiers);
}
}
MethodAccessor ma = methodAccessor; // read volatile
if (ma == null) {
ma = acquireMethodAccessor();
}
return ma.invoke(obj, args);
}

3.3 第三点


关于调用方法需要遍历这点,还是上面那个demo,我们在获取Method 对象的时候是通过调用getMethod、getDeclaredMethod方法,点击进入这个方法的源码,我们可以看到如下代码:


private static Method searchMethods(Method[] methods,
String name,
Class<?>[] parameterTypes)

{
Method res = null;
String internedName = name.intern();
for (int i = 0; i < methods.length; i++) {
Method m = methods[i];
if (m.getName() == internedName
&& arrayContentsEq(parameterTypes, m.getParameterTypes())
&& (res == null
|| res.getReturnType().isAssignableFrom(m.getReturnType())))
res = m;
}

return (res == null ? res : getReflectionFactory().copyMethod(res));
}

我们可以看到,底层实际上也是将class对象的所有method遍历了一遍,最终才拿到我们需要的方法的,这也就是第二点,执行具体方法的时候需要遍历class对象的方法。


3.4 第四点


第4点说需要对方法和参数进行检查,也就是我们在执行具体的某一个方法的时候,我们实际上是需要校验这个方法是否可见的,如果不可见,我们还需要将这个方法设置为可见,否则如果我们直接调用这个方法的话,会报错。


同时还有一个点,在我们调用invoke方法的时候,反射类会对方法和参数进行一个校验,让我们来看一下源码:


@CallerSensitive
public Object invoke(Object obj, Object... args)
throws IllegalAccessException, IllegalArgumentException,
InvocationTargetException
{
if (!override) {
if (!Reflection.quickCheckMemberAccess(clazz, modifiers)) {
Class<?> caller = Reflection.getCallerClass();
checkAccess(caller, clazz, obj, modifiers);
}
}
MethodAccessor ma = methodAccessor; // read volatile
if (ma == null) {
ma = acquireMethodAccessor();
}
return ma.invoke(obj, args);
}

我们可以看到还有quickCheckMemberAccess、checkAccess 等逻辑


4. 总结


平时在反射这块用的比较少,也没针对性的去学习一下。在工作之余,还是得保持一个学习的习惯,这样子才不会出现今天这种被一个问题难倒的情况,而且才能产出更多、更优秀的方案。


作者:喜欢小钱钱
来源:juejin.cn/post/7330115846140051496
收起阅读 »

听我一句劝,业务代码中,别用多线程。

你好呀,我是歪歪。 前几天我在网上冲浪,看到一个哥们在吐槽,说他工作三年多了,没使用过多线程。 虽然八股文背的滚瓜烂熟,但是没有在实际开发过程中写的都是业务代码,没有使用过线程池,心里还是慌得一比。 我只是微微一笑,这不是很正常吗? 业务代码中一般也使不上多线...
继续阅读 »

你好呀,我是歪歪。


前几天我在网上冲浪,看到一个哥们在吐槽,说他工作三年多了,没使用过多线程。


虽然八股文背的滚瓜烂熟,但是没有在实际开发过程中写的都是业务代码,没有使用过线程池,心里还是慌得一比。


我只是微微一笑,这不是很正常吗?


业务代码中一般也使不上多线程,或者说,业务代码中不知不觉你以及在使用线程池了,你再 duang 的一下搞一个出来,反而容易出事。


所以提到线程池的时候,我个人的观点是必须把它吃得透透的,但是在业务代码中少用或者不用多线程。


关于这个观点,我给你盘一下。


Demo


首先我们还是花五分钟搭个 Demo 出来。


我手边刚好有一个之前搭的一个关于 Dubbo 的 Demo,消费者、生产者都有,我就直接拿来用了:



这个 Demo 我也是跟着网上的 quick start 搞的:



cn.dubbo.apache.org/zh-cn/overv…




可以说写的非常详细了,你就跟着官网的步骤一步步的搞就行了。


我这个 Demo 稍微不一样的是我在消费者模块里面搞了一个 Http 接口:



在接口里面发起了 RPC 调用,模拟从前端页面发起请求的场景,更加符合我们的开发习惯。


而官方的示例中,是基于了 SpringBoot 的 CommandLineRunner 去发起调用:



只是发起调用的方式不一样而已,其他没啥大区别。


需要说明的是,我只是手边刚好有一个 Dubbo 的 Demo,随手就拿来用了,但是本文想要表达的观点,和你使不使用 Dubbo 作为 RPC 框架,没有什么关系,道理是通用的。


上面这个 Demo 启动起来之后,通过 Http 接口发起一次调用,看到控制台服务提供方和服务消费方都有对应的日志输出,准备工作就算是齐活儿了:



上菜


在上面的 Demo 中,这是消费者的代码:



这是提供者的代码:



整个调用链路非常的清晰:



来,请你告诉我这里面有线程池吗?


没有!


是的,在日常的开发中,我就是写个接口给别人调用嘛,在我的接口里面并没有线程池相关的代码,只有 CRUD 相关的业务代码。


同时,在日常的开发中,我也经常调用别人提供给我的接口,也是一把梭,撸到底,根本就不会用到线程池。


所以,站在我,一个开发人员的角度,这个里面没有线程池。


合理,非常合理。


但是,当我们换个角度,再看看,它也是可以有的。


比如这样:



反应过来没有?


我们发起一个 Http 调用,是由一个 web 容器来处理这个请求的,你甭管它是 Tomcat,还是 Jetty、Netty、Undertow 这些玩意,反正是个 web 容器在处理。


那你说,这个里面有线程池吗?


在方法入口处打个断点,这个 http-nio-8081-exec-1 不就是 Tomcat 容器线程池里面的一个线程吗:



通过 dump 堆栈信息,过滤关键字可以看到这样的线程,在服务启动起来,啥也没干的情况下,一共有 10 个:



朋友,这不就是线程池吗?


虽然不是你写的,但是你确实用了。


我写出来的这个 test 接口,就是会由 web 容器中的一个线程来进行调用。所以,站在 web 容器的角度,这里是有一个线程池的:



同理,在 RPC 框架中,不管是消费方,还是服务提供方,也都存在着线程池。


比如 Dubbo 的线程池,你可以看一下官方的文档:



cn.dubbo.apache.org/zh-cn/overv…




而对于大多数的框架来说,它绝不可能只有一个线程池,为了做资源隔离,它会启用好几个线程池,达到线程池隔离,互不干扰的效果。


比如参与 Dubbo 一次调用的其实不仅一个线程池,至少还有 IO 线程池和业务线程池,它们各司其职:



我们主要关注这个业务线程池。


反正站在 Dubbo 框架的角度,又可以补充一下这个图片了:



那么问题来了,在当前的这个情况下?


当有人反馈:哎呀,这个服务吞吐量怎么上不去啊?


你怎么办?


你会 duang 的一下在业务逻辑里面加一个线程池吗?



大哥,前面有个 web 容器的线程池,后面有个框架的线程池,两头不调整,你在中间加个线程池,加它有啥用啊?


web 容器,拿 Tomcat 来说,人家给你提供了线程池参数调整的相关配置,这么一大坨配置,你得用起来啊:



tomcat.apache.org/tomcat-9.0-…




再比如 Dubbo 框架,都给你明说了,这些参数属于性能调优的范畴,感觉不对劲了,你先动手调调啊:



你把这些参数调优弄好了,绝对比你直接怼个线程池在业务代码中,效果好的多。


甚至,你在业务代码中加入一个线程池之后,反而会被“反噬”。


比如,你 duang 的一下怼个线程池在这里,我们先只看 web 容器和业务代码对应的部分:



由于你的业务代码中有线程池的存在,所以当接受到一个 web 请求之后,立马就把请求转发到了业务线程池中,由线程池中的线程来处理本次请求,从而释放了 web 请求对应的线程,该线程又可以里面去处理其他请求。


这样来看,你的吞吐量确实上去了。


在前端来看,非常的 nice,请求立马得到了响应。


但是,你考虑过下游吗?


你的吞吐量上涨了,下游同一时间处理的请求就变多了。如果下游跟不上处理,顶不住了,直接就是崩给你看怎么办?



而且下游不只是你一个调用方,由于你调用的太猛,导致其他调用方的请求响应不过来,是会引起连锁反应的。


所以,这种场景下,为了异步怼个线程池放着,我觉得还不如用消息队列来实现异步化,顶天了也就是消息堆积嘛,总比服务崩了好,这样更加稳妥。


或者至少和下游勾兑一下,问问我们这边吞吐量上升,你们扛得住不。


有的小伙伴看到这里可能就会产生一个疑问了:歪师傅,你这个讲得怎么和我背的八股文不一样啊?


巧了,你背过的八股文我也背过,现在我们来温习一下我们背过的八股文。


什么时候使用线程池呢?


比如一个请求要经过若干个服务获取数据,且这些数据没有先后依赖,最终需要把这些数据组合起来,一并返回,这样经典的场景:



用户点商品详情,你要等半天才展示给用户,那用户肯定骂骂咧咧的久走了。


这个时候,八股文上是怎么说的:用线程池来把串行的动作改成并行。



这个场景也是增加了服务 A 的吞吐量,但是用线程池就是非常正确的,没有任何毛病。


但是你想想,我们最开始的这个案例,是这个场景吗?



我们最开始的案例是想要在业务逻辑中增加一个线程池,对着一个下游服务就是一顿猛攻,不是所谓的串行改并行,而是用更多的线程,带来更多的串行。


这已经不是一个概念了。


还有一种场景下,使用线程池也是合理的。


比如你有一个定时任务,要从数据库中捞出状态为初始化的数据,然后去调用另外一个服务的接口查询数据的最终状态。



如果你的业务代码是这样的:


//获取订单状态为初始化的数据(0:初始化 1:处理中 2:成功 3:失败)
//select * from order where order_status=0;
ArrayList initOrderInfoList = queryInitOrderInfoList();
//循环处理这批数据
for(OrderInfo orderInfo : initOrderInfoList){
    //捕获异常以免一条数据错误导致循环结束
    try{
        //发起rpc调用
        String orderStatus = queryOrderStatus(orderInfo.getOrderId);
        //更新订单状态
        updateOrderInfo(orderInfo.getOrderId,orderStatus);  
    } catch (Exception e){
        //打印异常
    }
}

虽然你框架中使用了线程池,但是你就是在一个 for 循环中不停的去调用下游服务查询数据状态,是一条数据一条数据的进行处理,所以其实同一时间,只是使用了框架的线程池中的一个线程。


为了更加快速的处理完这批数据,这个时候,你就可以怼一个线程池放在 for 循环里面了:


//循环处理这批数据
for(OrderInfo orderInfo : initOrderInfoList){
    //使用线程池
    executor.execute(() -> {
        //捕获异常以免一条数据错误导致循环结束
        try {
            //发起rpc调用
            String orderStatus = queryOrderStatus(orderInfo.getOrderId);
            //更新订单状态
            updateOrderInfo(orderInfo.getOrderId, orderStatus);
        } catch (Exception e) {
            //打印异常
        }
    });
}


需要注意的是,这个线程池的参数怎么去合理的设置,是需要考虑的事情。


同时这个线程池的定位,就类似于 web 容器线程池的定位。


或者这样对比起来看更加清晰一点:



定时任务触发的时候,在发起远程接口调用之前,没有线程池,所以我们可以启用一个线程池来加快数据的处理。


而 Http 调用或者 RPC 调用,框架中本来就已经有一个线程池了,而且也给你提供了对应的性能调优参数配置,那么首先考虑的应该是把这个线程池充分利用起来。


如果仅仅是因为异步化之后可以提升服务响应速度,没有达到串行改并行的效果,那么我更加建议使用消息队列。


好了,本文的技术部分就到这里啦。


下面这个环节叫做[荒腔走板],技术文章后面我偶尔会记录、分享点生活相关的事情,和技术毫无关系。我知道看起来很突兀,但是我喜欢,因为这是一个普通博主的生活气息。


荒腔走板



不知道你看完文章之后,有没有产生一个小疑问:最开始部分的 Demo 似乎用处并不大?


是的,我最开始构思的行文结构是是基于 Demo 在源码中找到关于线程池的部分,从而引出其实有一些我们“看不见的线程池”的存在的。


原本周六我是有一整天的时间来写这篇文章,甚至周五晚上还特意把 Demo 搞定,自己调试了一番,该打的断点全部打上,并写完 Demo 那部分之后,我才去睡觉的,想得是第二天早上起来直接就能用。


按照惯例周六睡个懒觉的,早上 11 点才起床,自己慢条斯理的做了一顿午饭,吃完饭已经是下午 1 点多了。


本来想着在沙发上躺一会,结果一躺就是一整个下午。期间也想过起来写一会文章,坐在电脑前又飞快的躺回到沙发上,就是觉得这个事情索然无味,当下的那一刻就想躺着,然后无意识的刷手机,原本是拿来写文章中关于源码的部分的时间就这样浪费了。


像极了高中时的我,周末带大量作业回家,准备来个悬梁刺股,弯道超车,结果变成了一睡一天,捏紧刹车。


高中的时候,时间浪费了是真的可惜。


现在,不一样了。


荒腔走板这张图片,就是我躺在沙发上的时候,别人问我在干什么时随手拍的一张。


我并不为躺了一下午没有干正事而感到惭愧,浪费了的时间,才是属于自己的时间。


很久以前我看到别人在做一些浪费时间的事情的时候,我心里可能会嘀咕几句,劝人惜时。


这两年我不会了,允许自己做自己,允许别人做别人。


作者:why技术
来源:juejin.cn/post/7297980721590272040
收起阅读 »

全局异常统一处理很好,但建议你谨慎使用

思考,输出,沉淀。用通俗的语言陈述技术,让自己和他人都有所收获。 作者:毅航😜 在SpringBoot应用开发中,利用@ControllerAdvice 结合 @ExceptionHandler来实现对后端服务的统一异常管理似乎已经成为了开发者约定俗成的规...
继续阅读 »



思考,输出,沉淀。用通俗的语言陈述技术,让自己和他人都有所收获。

作者:毅航😜





SpringBoot应用开发中,利用@ControllerAdvice 结合 @ExceptionHandler来实现对后端服务的统一异常管理似乎已经成为了开发者约定俗成的规范了,为此网上也有很多文章来阐述如何更加优雅的来实现统一异常管理,但这样做真的好吗?


前言


SpringBoot中的全局统一异常管理通常是指利用@ControllerAdvice 结合 @ExceptionHandler来自定义一个全局的异常处理器,从而避免了在程序中频繁写书写try-catch来对异常进行处理。但结合笔者最近惨痛debug旧有项目的经历来看,笔者不推荐这样去做。


在讨论之前我们不妨先来看看实际开发中都有哪些地方可能出现的异常。


全局异常所带来的困惑


目前,大部分应用在开发时,通常会将代码划分为控制层,服务层,数据访问层三层结构,每个模块负责自己独立的逻辑。简单来看,控制层主要作用在于对外暴露 ur1访问地址,并将前台的处理请求委托给服务层来处理;而对于服务层来说其主要是业务逻辑处理的地方,以登录请求为例,用户名、密码的校验通常会放在服务层来处理;数据访问层则主要用于对数据库进行访问。如下这张图直观的反映了三层架构下各个模块所肩负的责任。


image.png


知晓了软件开发过程中的分层架构模式后。我们再来看每个模块可能产生的异常信息。其中:



  • 控制层主要用于处理实现前后端交互逻辑,其主要负责信息收禁、参数校验等,抛出的异常也多是参数校验、请求服务异常等异常信息。

  • 服务层主要用于处理业务逻辑,以及各种外部服务调用、访问数据作、缓存处理、消息处理等处理操作,这部分可能抛出的逻辑就非常多了。例如,数据库访问异常、服务调用异常等问题。

  • 数据访问层则主要负责数据访问实现,其一般没有业务逻辑。由于目前持久层使用技术通常为Mybatis,所以这一层抛出的异常多是Mybatis框架内部所抛出的异常。


不难发现,其中每一层所抛出的异常类型有着很大的差异。而我们在使用全局异常管理时,通常使用如下
代码逻辑:



@ControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler(Exception.class)
public ResponseEntity<String> handleGlobalException(Exception ex) {
// 在这里实现异常处理逻辑
log.error(ex.getMessage());//在控制台打印错误信息
return Result.error(ex.getMessage());
}
}



即我们通过在ExceptionHandler执行捕获异常为Exception来尽可能捕获业务中可能遇到的各种异常,相信网上已经有很多博主都在不断讲授这样的写法。


但这样做真的好吗?在笔者最近接手的旧项目中,当后端无法处理前端请求后会返回如下信息:


{
"message": “服务器异常”,
"code":602
}

后台服务通常会打印出如下信息:


image.png


(注:此处仅为展示,真实环境出现的异常远比此复杂)


不难发现,通过网上所盛传全局异常处理逻辑,我根本不知道问题出在哪里。我只知道当前程序出现异常,且异常信息为/ by zero。除此之外我无法得到任何有用的信息。


此外,通过在@ExceptionHandler配置Exception.class使得程序可以捕获多种异常信息,但这样粗粒度做法所导致的直接问题就是无法精确的定位异常问题发生所在地,极大的提升了问题定位的难度笔者项目同事debug经历来看,当项目无法正常运行时,组内的程序员通常会通过打日志的方法来一行一行定位问题所在。


破解之道


其实造成这样调试困难得本质原因在于全局异常机制的滥用,如下这张图真实的反映了引入全局异常机制后,异常的处理逻辑。


image.png


不难发现,当引入全局异常处理后,所有的异常信息都会交由RestExceptionHandler来进行处理。当程序遇到异常时,会将异常信息抛给RestExceptionHandler来处理,并由其定义错误的处理逻辑。


分析到此处,其实你已经发现了。对于全局异常处理逻辑而言,其更适合做异常的兜底工作。即如果当前层出现异常,并且不断上抛的仍然无法解决的话,不妨通过全局统一的异常管理来进行处理,以对这些未处理的异常进行捕获


此外,异常处理不应该进行像很多博客说的那样,仅是通过e.getMessage打印异常信息就可以了。这对于排查问题没有以一丁点的帮助,可以说是百害而无一利。


对于此,笔者更推荐在打印异常信息时,记录异常以及当前 URL、执行方法等信息以便后期方便问题排查。具体可参考如下代码:



@Slf4j
@RestControllerAdvice
public class GlobExceptionHandler {


@ExceptionHandler(ArithmeticException.class)//ArithmeticException异常类型通过注解拿到
public String exceptionHandler(HttpServletRequest request,ArithmeticException exception){
// 打印详细信息
log(request,exception);
return exception.getMessage();

}


public void log(HttpServletRequest request, Exception exception) {
//换行符
String lineSeparatorStr = System.getProperty("line.separator");

StringBuilder exStr = new StringBuilder();
StackTraceElement[] trace = exception.getStackTrace();
// 获取堆栈信息并输出为打印的形式
for (StackTraceElement s : trace) {
exStr.append("\tat " + s + "\r\n");
}
//打印error级别的堆栈日志
log.error("访问地址:" + request.getRequestURL() + ",请求方法:" + request.getMethod() +
",远程地址:" + request.getRemoteAddr() + lineSeparatorStr +
"错误堆栈信息如下:" + exception.toString() + lineSeparatorStr + exStr);
}
}

当程序发生错误时,其打印的日志信息如下:


image.png


不难发现,其完整的打印出了url、方法信息,错误参数、请求地址等信息,极大的降低了线上Bug的排查难度。


总结


技术本身并没有什么对与错之分,只不过有时我们用的方式和时机不对,进而使得本该提效的工具,反而在不断拖垮我们的效率。就如同本文分析的全局异常处理机制一样,其确实可以帮助我们降低try-catch的使用,但错误且不加考虑的乱用只会使得当系统出现问题时,我们只能两眼一抹黑,然后一行一行打日志来定位问题。


最后,对于代码中异常的捕获处理,笔者认为全局异常应该作为异常处理的都兜底操作,而不应该成为异常处理的灵丹妙药! 此外,全局异常处理过程不应该仅是简单的 e.getMessage()打印异常消息即可,其更应记录更加有助于异常排查的信息例如,方法,请求的url,请求参数等信息。



如果觉文章对你有帮助,不妨点赞+关注,不错过笔者之后的每一次更新!



作者:毅航
来源:juejin.cn/post/7291555600854106147
收起阅读 »