nginx(前端必会-项目部署-精简通用篇)
前言
最近在公司部署项目时遇上了一点关于nginx的问题,于是就想着写一篇关于nginx的文章...
主要给小白朋友分享,nginx是什么,nginx有什么用,最后到nginx的实际应用,项目部署。
nginx
公司项目刚刚上线,用户量少访问量不大,并发量低,一个jar包启动应用就能够解决问题了。但是随着项目的不断扩大,用户体量大增加,一台服务器可能就无法满足我们的需求了(当初是一个人用一个服务器,现在是多人用一个服务器,时间长了服务器都要红温)。
于是乎,我们就会想着增加服务器,那么我们多个项目就启动在多个服务器上面,用户需要访问,就需要做一个代理服务器,通过代理请求服务器来做前后端之间的转发和请求包括跨域等等问题。
那么到这就不得不说一下nginx的反向代理
了,正向代理指的其实就是比如我们通过VPN去请求xxx,这里就是因为用到了其他地方的代理服务器,这是一个从客户端到服务端的过程,然而反向代理其实就是,因为我们有多个服务器,最后都映射到了代理服务器身上,客户端最终访问的都是例如:baidu.com,但是事实上他底下是有多台服务器的。
既然他有多台服务器,每台服务器的性能,各种条件都是不同的,这里就要说到nginx的另一个能力---负载均衡
,可以给不同的服务器增加不同的权重,能力更强的服务器可以增大他的负荷,减轻其他服务器的负荷
这就是大家常说的nginx:Nginx
是一个高性能的 HTTP
和反向代理
服务器,它还支持 IMAP/POP3/SMTP 代理服务器。
nginx的特点:
- 高性能:
- 高并发连接处理能力:Nginx 使用异步事件驱动模型(如 epoll, kqueue 等),能够高效地处理大量并发连接。
- 低资源消耗:与 Apache 相比,Nginx 在相同硬件环境下通常消耗更少的内存和其他系统资源。
- 稳定性:
- 运行稳定:在高负载情况下依然保持稳定运行,崩溃或错误的发生率较低。
- 平滑升级:可以在不停止服务的情况下进行升级或配置更改。
- 丰富的功能集:
- 反向代理:可以作为反向代理服务器,将请求转发到后端服务器。
- URL 重写:通过简单的配置即可实现复杂的 URL 重写规则。
- 动态内容与静态内容分离:可以配置为只处理静态文件请求,而动态请求则交给后端应用服务器处理。
- 高度可配置性:
- 灵活的配置选项:可以根据需要定制各种配置选项,以适应不同的应用场景。
- 容易管理:配置文件结构清晰,易于理解和修改。
- 负载均衡:
- 支持多种负载均衡算法,例如轮询、加权轮询、最少连接数等,可以帮助分散到多个后端服务器的流量。
- 缓存功能:
- 可用作HTTP缓存服务器,减少对后端数据库或应用服务器的压力。
- 安全性:
- 提供 SSL/TLS 加密支持,保障数据传输安全。
- 可以设置访问控制、防火墙规则等来增强安全性。
- 模块化架构:
- 支持第三方模块扩展功能,比如 Nginx+Lua 使得开发者可以在 Nginx 中直接使用 Lua 脚本语言编写插件或处理逻辑。
- 日志与监控:
- 详细的访问和错误日志记录,便于故障排查和性能分析。
- 支持实时监控和统计,方便管理员了解当前系统的状态。
nginx下载
nginx.org/ 大家自行下载,我下载的是一个稳定版本,以防万一。下载完毕之后大家自行解压即可(默认大家是windows系统),解压完毕之后,可以看到nginx.exe就是我们的启动文件,conf就是配置文件,nginx.config中可以看到server的listen监听端口为80,这意味着当我们访问了80端口就会被nginx拦截,首先启动nginx,可以直接双击nginx.exe也可以通过cmd命令行直接输入nginx.exe运行(推荐,因为这样不会关闭窗口,双击的话就是一闪而过了)
接下来我们浏览器访问localhost:80
启动成功。
nginx常用命令
停止:nginx.exe -s stop
安全退出:nginx.exe -s quit
重新加载配置文件:nginx.exe -s reload(常用)例如我们更改了端口
查看nginx进程:ps aux|grep nginx
实际应用
下载完毕后打开可以看到:
于是我们建立aa、bb两个文件夹,我们将index.html 分别放入aa和bb,这两个页面都打上自己的标记aa/bb
然后我们对nginx进行配置 nginx.conf
server {
listen 8001;
server_name localhost;
location / {
root html/aa;
index index.html index.htm;
}
}
server {
listen 8002;
server_name localhost;
location / {
root html/bb;
index index.html index.htm;
}
}
如果没结束,记得reload
nginx.exe -s reload
接下来我们放一个项目进去,打包后放入html中
修改配置文件,然后reload
server {
listen 80;
server_name localhost;
#charset koi8-r;
#access_log logs/host.access.log main;
location / {
root html/dist;
index index.html index.htm;
}
然后我们访问localhost,端口默认是80所以不用写,如果失败,可能是reload失败,再次reload就可
其他配置问题
Nginx的主配置文件(conf/nginx.conf
)按以下结构组织:
- 全局块 与Nginx运行相关的全局设置
- events 与网络连接有关的设置
- http 代理、缓存、日志、虚拟主机等的配置
- server 虚拟主机的参数设置(一个http块可包含多个server块)
- location 定义请求路由及页面处理方式
前端开发中经常会遇到跨域问题,nginx可以做代理轻松解决,事实上原理和cors一样,设置请求头
server {
location /api {
proxy_pass http://backend_server;
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS';
add_header Access-Control-Allow-Headers 'Origin, Content-Type, Accept';
}
}
缓存问题:
proxy_cache_path /data/nginx/cache levels=1:2 keys_zone=my_cache:10m max_size=1g inactive=60m use_temp_path=off;
server {
location / {
proxy_cache my_cache;
proxy_pass http://backend;
add_header X-Cache-Status $upstream_cache_status;
}
}
https提升安全性
server {
listen 443 ssl;
server_name example.com;
ssl_certificate /etc/nginx/ssl/example.com.crt;
ssl_certificate_key /etc/nginx/ssl/example.com.key;
location / {
proxy_pass http://backend_server;
}
}
一个比较全面的配置
# 全局段配置
# ------------------------------
# 指定运行nginx的用户或用户组,默认为nobody。
#user administrator administrators;
# 设置工作进程数,通常设置为等于CPU核心数。
#worker_processes 2;
# 指定nginx进程的PID文件存放位置。
#pid /nginx/pid/nginx.pid;
# 指定错误日志的存放路径和日志级别。
error_log log/error.log debug;
# events段配置信息
# ------------------------------
events {
# 设置网络连接序列化,用于防止多个进程同时接受到新连接的情况,这种情况称为"惊群"。
accept_mutex on;
# 设置一个进程是否可以同时接受多个新连接。
multi_accept on;
# 设置工作进程的最大连接数。
worker_connections 1024;
}
# http配置段,用于配置HTTP服务器的参数。
# ------------------------------
http {
# 包含文件扩展名与MIME类型的映射。
include mime.types;
# 设置默认的MIME类型。
default_type application/octet-stream;
# 定义日志格式。
log_format myFormat '$remote_addr–$remote_user [$time_local] $request $status $body_bytes_sent $http_referer $http_user_agent $http_x_forwarded_for';
# 指定访问日志的存放路径和使用的格式。
access_log log/access.log myFormat;
# 允许使用sendfile方式传输文件。
sendfile on;
# 限制每次调用sendfile传输的数据量。
sendfile_max_chunk 100k;
# 设置连接的保持时间。
keepalive_timeout 65;
# 定义一个上游服务器组。
upstream mysvr {
server 127.0.0.1:7878;
server 192.168.10.121:3333 backup; #此服务器为备份服务器。
}
# 定义错误页面的重定向地址。
error_page 404 https://www.baidu.com;
# 定义一个虚拟主机。
server {
# 设置单个连接上的最大请求次数。
keepalive_requests 120;
# 设置监听的端口和地址。
listen 4545;
server_name 127.0.0.1;
# 定义location块,用于匹配特定的请求URI。
location ~*^.+$ {
# 设置请求的根目录。
#root path;
# 设置默认页面。
#index vv.txt;
# 将请求转发到上游服务器组。
proxy_pass http://mysvr;
# 定义访问控制规则。
deny 127.0.0.1;
allow 172.18.5.54;
}
}
}
如果有不明白的地方,遇到问题可以通过ai去迅速了解,在ai时代,我们的学习成本也大大下降了。
小结
本次主要带小白朋友学习nginx是什么、用于做什么,nginx的常用命令,nginx如何进行配置,最后实际操作一次简单的nginx。
来源:juejin.cn/post/7424168473423020066
Kafka 为什么要抛弃 Zookeeper?
嗨,你好,我是猿java
在很长一段时间里,ZooKeeper都是 Kafka的标配,现如今,Kafka官方已经在慢慢去除ZooKeeper,Kafka 为什么要抛弃 Zookeeper?这篇文章我们来聊聊其中的缘由。
Kafka 和 ZooKeeper 的关系
ZooKeeper 是一个分布式协调服务,常用于管理配置、命名和同步服务。长期以来,Kafka 使用 ZooKeeper 负责管理集群元数据、控制器选举和消费者组协调等任务理,包括主题、分区信息、ACL(访问控制列表)等。
ZooKeeper 为 Kafka 提供了选主(leader election)、集群成员管理等核心功能,为 Kafka提供了一个可靠的分布式协调服务,使得 Kafka能够在多个节点之间进行有效的通信和管理。然而,随着 Kafka的发展,其对 ZooKeeper的依赖逐渐显露出一些问题,这些问题也是下面 Kafka去除 Zookeeper的原因。
抛弃ZooKeeper的原因
1. 复杂性增加
ZooKeeper 是独立于 Kafka 的外部组件,需要单独部署和维护,因此,使用 ZooKeeper 使得 Kafka的运维复杂度大幅提升。运维团队必须同时管理两个分布式系统(Kafka和 ZooKeeper),这不仅增加了管理成本,也要求运维人员具备更高的技术能力。
2. 性能瓶颈
作为一个协调服务,ZooKeeper 并非专门为高负载场景设计, 因此,随着集群规模扩大,ZooKeeper在处理元数据时的性能问题日益突出。例如,当分区数量增加时,ZooKeeper需要存储更多的信息,这导致了监听延迟增加,从而影响Kafka的整体性能34。在高负载情况下,ZooKeeper可能成为系统的瓶颈,限制了Kafka的扩展能力。
3. 一致性问题
Kafka 内部的分布式一致性模型与 ZooKeeper 的一致性模型有所不同。由于 ZooKeeper和 Kafka控制器之间的数据同步机制不够高效,可能导致状态不一致,特别是在处理集群扩展或不可用情景时,这种不一致性会影响消息传递的可靠性和系统稳定性。
4. 发展自己的生态
Kafka 抛弃 ZooKeeper,我个人觉得最核心的原因是:Kafka生态强大了,需要自立门户,这样就不会被别人卡脖子。纵观国内外,有很多这样鲜活的例子,当自己弱小时,会先选择使用别家的产品,当自己羽翼丰满时,再选择自建完善自己的生态圈。
引入KRaft
为了剥离和去除 ZooKeeper,Kafka 引入了自己的亲儿子 KRaft(Kafka Raft Metadata Mode)。KRaft 是一个新的元数据管理架构,基于 Raft 一致性算法实现的一种内置元数据管理方式,旨在替代 ZooKeeper 的元数据管理功能。其优势在于:
- 完全内置,自包含:KRaft 将所有协调服务嵌入 Kafka 自身,不再依赖外部系统,这样大大简化了部署和管理,因为管理员只需关注 Kafka 集群。
- 高效的一致性协议:Raft 是一种简洁且易于理解的一致性算法,易于调试和实现。KRaft 利用 Raft 协议实现了强一致性的元数据管理,优化了复制机制。
- 提高元数据操作的扩展性:新的架构允许更多的并发操作,并减少了因为扩展性问题导致的瓶颈,特别是在高负载场景中。
- 降低延迟:在消除 ZooKeeper 作为中间层之后,Kafka 的延迟性能有望得到改善,特别是在涉及选主和元数据更新的场景中。
- 完全自主:因为是自家产品,所以产品的架构设计,代码开发都可以自己说了算,未来架构走向完全控制在自己手上。
KRaft的设计细节
- 控制器(Controller)节点的去中心化:KRaft 模式中,控制器节点由一组 Kafka 服务进程代替,而不是一个独立的 ZooKeeper 集群。这些节点共同负责管理集群的元数据,通过 Raft 实现数据的一致性。
- 日志复制和恢复机制:利用 Raft 的日志复制和状态机应用机制,KRaft 实现了对元数据变更的强一致性支持,这意味着所有控制器节点都能够就集群状态达成共识。
- 动态集群管理:KRaft 允许动态地向集群中添加或移除节点,而无需手动去 ZooKeeper 中更新配置,这使得集群管理更为便捷。
下面给出一张 Zookeeper 和 KRaft的对比图:
总结
本文,我们分析了为什么 Kafka 要移除 ZooKeeper,主要原因有两个:ZooKeeper不能满足 Kafka的发展以及 Kafka想创建自己的生态。在面临越来越复杂的数据流处理需求时,KRaft 模式为 Kafka 提供了一种更高效、简洁的架构方案。不论结局如何,Kafka 和 ZooKeeper曾经也度过了一段美好的蜜月期,祝福 Kafka 在 KRaft模式越来越强大,为使用者带来更好的体验。
学习交流
如果你觉得文章有帮助,请帮忙转发给更多的好友,或关注公众号:猿java,持续输出硬核文章。
来源:juejin.cn/post/7425812523129634857
面试官:为什么你们项目中还在用多表关联!
我们来看这样一个面试场景。
面试官:“在你们的项目中,用到多表关联查询了吗?”
候选人:“嗯,每个项目都用到了。”
面试官听了似乎有些愤怒,说:“多表关联查询这么慢,为什么你们还要用它,那你们项目的性能如何保障呢?”
面对这突如其来地质问,候选人明显有些慌了,解释道:“主要是项目周期太紧张了,这样写在开发效率上能高一些,后期我们会慢慢进行优化的。”
面试官听了,带着三分理解、三分无奈、四分恨铁不成钢地摇了摇头。
面试之后,这个同学问我说:“学长,是不是在项目中使用多表关联太low了,可是从业这五年多,我换过三家公司,哪个公司的项目都是这么用的。”
下面我来从实现原理的角度,回答一下这个问题。
“是否应该使用多表关联”,这绝对是个王炸级别的话题了,其争议程度,甚至堪比“中医废存之争”了。
一部分人坚定地认为,应该禁止使用SQL语句进行多表关联,正确的方式是把各表的数据读到应用程序中来,再由程序进行数据merge操作。
而另一部分人则认为,在数据库中进行多表关联是没有问题的,但需要多关注SQL语句的执行计划,只要别产生过大的资源消耗和过长的执行时间即可。
嗯,我完全支持后者的观点,况且MySQL在其底层算法的优化上,一直在努力完善。
MySQL表连接算法
我们以最常用的MySQL数据库为例,其8.0版本支持的表连接算法为Nested-Loops Join(嵌套循环连接)和Hash Join(哈希连接)。
如下图所示:
嵌套循环连接算法,顾名思义,其实现方式是通过内外层循环的方式进行表连接,SQL语句中有几个表进行连接就实现为几层循环。
接下来,我们看看嵌套循环连接算法的四种细分实现方式。
1、简单嵌套循环连接(Simple Nested-Loops Join)
这是嵌套循环连接中最简单粗暴的一种实现方式,直接用驱动表A中符合条件的数据,一条一条地带入到被驱动表B中,进行全表扫描匹配。
其伪代码实现为:
for each row in table A matching range {
for each row in table B matching join column{
if row satisfies join conditions,send to client
}
}
我们以下面的SQL语句举例:
SELECT * FROM product p INNER JOIN order o ON p.id = o.product_id;
其对应的详细执行步骤为:
(1)外循环从product表中每次读取一条记录。
(2)以内循环的方式,将该条记录与order表中的记录进行关联键的逐一比较,并找到匹配记录。
(3)将product表中的记录和order表中的匹配记录进行关联,放到结果集中。
(4)重复执行步骤1、2、3,直到内外两层循环结束。
也就是说,如果驱动表A中符合条件的数据有一万条,那么就需要带入到被驱动表B中进行一万次全表扫描,这种查询效率会非常慢。因此,除非特殊场景,否则查询优化器不太会选择这种连接算法。
2、索引嵌套循环连接(Index Nested-Loops Join)
接上文,如果在被驱动表B的关联列上创建了索引,那MySQL查询优化器极大概率会选择这种这种实现方式,因为其非常高效。
我们依然以下面的SQL语句举例:
SELECT * FROM product p INNER JOIN order o ON p.id = o.product_id;
其对应的详细执行步骤为:
(1)外循环从product表中每次读取一条记录。
(2)以内循环的方式,将该条记录与order表中关联键的索引进行匹配,直接找到匹配记录。
(3)将product表中的记录和order表中的匹配记录进行关联,放到结果集中。
(4)重复执行步骤1、2、3,直到内外两层循环结束。
这里需要说明一下,若order表的关联列是主键索引,则可以直接在表中查到记录;若order表的关联列是二级索引,则通过索引扫描的方式查到记录的主键,然后再回表查到具体记录。
3、缓存块嵌套循环连接(Block Nested-Loops Join)
我们在上文中说过,在简单嵌套循环连接中,如果驱动表A中符合条件的数据有一万条,那么就需要带入到被驱动表B中进行一万次全表扫描,这种查询效率会非常慢。
而缓存块嵌套循环连接,则正是针对于该场景进行的优化。
当驱动表A进行循环匹配的时候,数据并不会直接带入到被驱动表B,而是使用Join Buffer(连接缓存)先缓存起来,等到Join Buffer满了再去一次性关联被驱动表B,这样可以减少被驱动表B被全表扫描的次数,提升查询性能。
其伪代码实现为:
for each row in table A matching range {
store join column in join buffer
if(join buffer is full){
for each row in table B matching join column{
if row satisfies join conditions,send to client
}
}
}
我们依然以下面的SQL语句举例:
SELECT * FROM product p INNER JOIN order o ON p.id = o.product_id;
其对应的详细执行步骤为:
(1)外循环从product表中每次读取一定数量的记录,将Join Buffer装满。
(2)以内循环的方式,将Join Buffer中记录与order表中的记录进行关联键的逐一比较,并找到匹配记录。
(3)将product表中的记录和order表中的匹配记录进行关联,放到结果集中。
(4)重复执行步骤1、2、3,直到内外两层循环结束。
需要注意的是,从MySQL 8.0.20开始,MySQL就不再使用缓存块嵌套循环连接了,将以前使用缓存块嵌套循环连接的场景全部改为哈希连接。
所以,MySQL的研发者一直在努力优化这款产品,其产品本身也没有大家所想的那么弱鸡。
4、批量键访问连接(Batched Key Access Joins)
说到这里,不得不先提一下MySQL5.6版本的新特性,多范围读(Multi-Range Read)。
我们继续拿product表的场景进行举例。
SELECT * FROM product WHERE price > 5 and price < 20;
没有使用MRR的情况下:
由上图可见,在MySQL InnoDB中使用二级索引的范围扫描时,所获取到的主键ID是无序的,因此在数据量很大的情况下进行回表操作时,会产生大量的磁盘随机IO,从而影响数据库性能。
使用MRR的情况下:
显而易见的是,在二级索引和数据表之间增加了一层buffer,在buffer中进行主键ID的排序操作,这样回表操作就从磁盘随机I/O转化为顺序I/O,并可减少磁盘的I/O次数(1个page里面可能包含多个命中的record),提高查询效率。
而从MySql 5.6开始支持Batched Key Access Joins,则正是结合了MRR和Join Buffer,以此来提高多表关联的执行效率,其发生的条件为被驱动表B有索引,并且该索引为非主键。
其伪代码实现和详细步骤为:
for each row in table A matching range {
store join column in join buffer
if(join buffer is full){
for each row in table B matching join column{
send to MRR interface,and order by its primary key
if row satisfies join conditions,send to client
}
}
}
另外,如果查询优化器选择了批量键访问连接的实现方式,我们可以在执行计划中的Extra列中看到如下信息:
Using where; Using join buffer (Batched Key Access)
结语
在本文中,我们介绍了嵌套循环连接的四种实现方式,下文会继续讲解哈希连接算法,以及跟在程序中进行多表数据merge的实现方式进行对比。
来源:juejin.cn/post/7387626171158675466
都说PHP性能差,但PHP性能真的差吗?
今天本能是想测试一个PDO持久化,会不会带来会话混乱的问题
先贴一下PHP代码, 代码丑了点,但是坚持能run就行,反正就是做个测试。
<?php
$dsn = 'mysql:host=localhost;dbname=test;charset=utf8';
$user = 'root';
$password = 'root';
// 设置 PDO 选项,启用持久化连接
$options = [
PDO::ATTR_PERSISTENT => true,
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
];
try {
// 创建持久化连接
$pdo = new PDO($dsn, $user, $password, $options);
$stmt = $pdo->prepare("INSERT INTO test_last_insert_id (uni) VALUES (:uni);");
$uni = uniqid('', true);
$stmt->bindValue(':uni', $uni);
$aff = $stmt->execute(); //
if ($aff === false) {
throw new Exception("insert fail:");
}
$id = $pdo->lastInsertId();
function getExecutedSql($stmt, $params)
{
$sql = $stmt->queryString;
$keys = array();
$values = array();
// 替换命名占位符 :key with ?
$sql = preg_replace('/\:(\w+)/', '?', $sql);
// 绑定的参数可能包括命名占位符,我们需要将它们转换为匿名占位符
foreach ($params as $key => $value) {
$keys[] = '/\?/';
$values[] = is_string($value) ? "'$value'" : $value;
}
// 替换占位符为实际参数
$sql = preg_replace($keys, $values, $sql, 1, $count);
return $sql;
}
$stmt = $pdo->query("SELECT id FROM test_last_insert_id WHERE uni = '{$uni}'", PDO::FETCH_NUM);
$row = $stmt->fetch();
$value = $row[0];
if ($value != $id) {
throw new Exception("id is diff");
}
echo "success" . PHP_EOL;
} catch (PDOException $e) {
header('HTTP/1.1 500 Internal Server Error');
file_put_contents('pdo_perisistent.log', $e->getMessage() . PHP_EOL);
die('Database connection failed: ' . $e->getMessage());
} catch (Exception $e) {
header('HTTP/1.1 500 Internal Server Error');
file_put_contents('pdo_perisistent.log', $e->getMessage() . PHP_EOL);
die('Exception: ' . $e->getMessage());
}
用wrk压测,一开始uniqid因为少了混淆参数还报了500,加了一下参数,用来保证uni值
% ./wrk -c100 -t2 -d3s --latency "http://localhost/pdo_perisistent.php"
Running 3s test @ http://localhost/pdo_perisistent.php
2 threads and 100 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 52.17ms 7.48ms 103.38ms 80.57%
Req/Sec 0.96k 133.22 1.25k 75.81%
Latency Distribution
50% 51.06ms
75% 54.17ms
90% 59.45ms
99% 80.54ms
5904 requests in 3.10s, 1.20MB read
Requests/sec: 1901.92
Transfer/sec: 397.47KB
1900 ~ 2600 之间的QPS,其实这个数值还是相当满意的,测试会话会不会混乱的问题也算完结了。
但是好奇心突起,之前一直没做过go和php执行sql下的对比,正好做一次对比压测
package main
import (
"database/sql"
"fmt"
"net/http"
"sync/atomic"
"time"
_ "github.com/go-sql-driver/mysql"
"log"
)
var id int64 = time.Now().Unix() * 1000000
func generateUniqueID() int64 {
return atomic.AddInt64(&id, 1)
}
func main() {
dsn := "root:root@tcp(localhost:3306)/test?charset=utf8"
db, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatalf("Error opening database: %v", err)
}
defer func() { _ = db.Close() }()
//// 设置连接池参数
//db.SetMaxOpenConns(100) // 最大打开连接数
//db.SetMaxIdleConns(10) // 最大空闲连接数
//db.SetConnMaxLifetime(time.Hour) // 连接最大存活时间
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
var err error
uni := generateUniqueID()
// Insert unique ID int0 the database
insertQuery := `INSERT INTO test_last_insert_id (uni) VALUES (?)`
result, err := db.Exec(insertQuery, uni)
if err != nil {
log.Fatalf("Error inserting data: %v", err)
}
lastInsertID, err := result.LastInsertId()
if err != nil {
log.Fatalf("Error getting last insert ID: %v", err)
}
// Verify the last insert ID
selectQuery := `SELECT id FROM test_last_insert_id WHERE uni = ?`
var id int64
err = db.QueryRow(selectQuery, uni).Scan(&id)
if err != nil {
log.Fatalf("Error selecting data: %v", err)
}
if id != lastInsertID {
log.Fatalf("ID mismatch: %d != %d", id, lastInsertID)
}
fmt.Println("success")
})
_ = http.ListenAndServe(":8080", nil)
}
truncate表压测结果,这低于预期了吧
% ./wrk -c100 -t2 -d3s --latency "http://localhost:8080"
Running 3s test @ http://localhost:8080
2 threads and 100 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 54.05ms 36.86ms 308.57ms 80.77%
Req/Sec 0.98k 243.01 1.38k 63.33%
Latency Distribution
50% 43.70ms
75% 65.42ms
90% 99.63ms
99% 190.18ms
5873 requests in 3.01s, 430.15KB read
Requests/sec: 1954.08
Transfer/sec: 143.12KB
开个连接池,清表再测,结果半斤八两
% ./wrk -c100 -t2 -d3s --latency "http://localhost:8080"
Running 3s test @ http://localhost:8080
2 threads and 100 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 54.07ms 35.87ms 281.38ms 79.84%
Req/Sec 0.97k 223.41 1.40k 60.00%
Latency Distribution
50% 44.91ms
75% 66.19ms
90% 99.65ms
99% 184.51ms
5818 requests in 3.01s, 426.12KB read
Requests/sec: 1934.39
Transfer/sec: 141.68KB
然后开启不清表的情况下,php和go的交叉压测
% ./wrk -c100 -t2 -d3s --latency "http://localhost:8080"
Running 3s test @ http://localhost:8080
2 threads and 100 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 52.51ms 43.28ms 436.00ms 86.91%
Req/Sec 1.08k 284.67 1.65k 65.00%
Latency Distribution
50% 40.22ms
75% 62.10ms
90% 102.52ms
99% 233.98ms
6439 requests in 3.01s, 471.61KB read
Requests/sec: 2141.12
Transfer/sec: 156.82KB
% ./wrk -c100 -t2 -d3s --latency "http://localhost/pdo_perisistent.php"
Running 3s test @ http://localhost/pdo_perisistent.php
2 threads and 100 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 41.41ms 10.44ms 77.04ms 78.07%
Req/Sec 1.21k 300.99 2.41k 73.77%
Latency Distribution
50% 38.91ms
75% 47.62ms
90% 57.38ms
99% 69.84ms
7332 requests in 3.10s, 1.50MB read
Requests/sec: 2363.74
Transfer/sec: 493.98KB
// 这里骤降是我很不理解的不明白是因为什么
% ./wrk -c100 -t2 -d3s --latency "http://localhost:8080"
Running 3s test @ http://localhost:8080
2 threads and 100 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 156.72ms 75.48ms 443.98ms 66.10%
Req/Sec 317.93 84.45 480.00 71.67%
Latency Distribution
50% 155.21ms
75% 206.36ms
90% 254.32ms
99% 336.07ms
1902 requests in 3.01s, 139.31KB read
Requests/sec: 631.86
Transfer/sec: 46.28KB
% ./wrk -c100 -t2 -d3s --latency "http://localhost/pdo_perisistent.php"
Running 3s test @ http://localhost/pdo_perisistent.php
2 threads and 100 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 43.47ms 10.04ms 111.41ms 90.21%
Req/Sec 1.15k 210.61 1.47k 72.58%
Latency Distribution
50% 41.17ms
75% 46.89ms
90% 51.27ms
99% 95.07ms
7122 requests in 3.10s, 1.45MB read
Requests/sec: 2296.19
Transfer/sec: 479.87KB
% ./wrk -c100 -t2 -d3s --latency "http://localhost:8080"
Running 3s test @ http://localhost:8080
2 threads and 100 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 269.08ms 112.17ms 685.29ms 73.69%
Req/Sec 168.22 125.46 520.00 79.59%
Latency Distribution
50% 286.58ms
75% 335.40ms
90% 372.61ms
99% 555.80ms
1099 requests in 3.02s, 80.49KB read
Requests/sec: 363.74
Transfer/sec: 26.64KB
% ./wrk -c100 -t2 -d3s --latency "http://localhost/pdo_perisistent.php"
Running 3s test @ http://localhost/pdo_perisistent.php
2 threads and 100 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 41.74ms 9.67ms 105.86ms 91.72%
Req/Sec 1.20k 260.04 2.24k 80.33%
Latency Distribution
50% 38.86ms
75% 46.77ms
90% 49.02ms
99% 83.01ms
7283 requests in 3.10s, 1.49MB read
Requests/sec: 2348.07
Transfer/sec: 490.71KB
% ./wrk -c100 -t2 -d3s --latency "http://localhost:8080"
Running 3s test @ http://localhost:8080
2 threads and 100 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 464.85ms 164.66ms 1.06s 71.97%
Req/Sec 104.18 60.01 237.00 63.16%
Latency Distribution
50% 467.00ms
75% 560.54ms
90% 660.70ms
99% 889.86ms
605 requests in 3.01s, 44.31KB read
Requests/sec: 200.73
Transfer/sec: 14.70KB
% ./wrk -c100 -t2 -d3s --latency "http://localhost/pdo_perisistent.php"
Running 3s test @ http://localhost/pdo_perisistent.php
2 threads and 100 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 50.62ms 9.16ms 85.08ms 75.74%
Req/Sec 0.98k 170.66 1.30k 69.35%
Latency Distribution
50% 47.93ms
75% 57.20ms
90% 61.76ms
99% 79.90ms
6075 requests in 3.10s, 1.24MB read
Requests/sec: 1957.70
Transfer/sec: 409.13KB
% ./wrk -c100 -t2 -d3s --latency "http://localhost:8080"
Running 3s test @ http://localhost:8080
2 threads and 100 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 568.84ms 160.91ms 1.04s 66.38%
Req/Sec 81.89 57.59 262.00 67.27%
Latency Distribution
50% 578.70ms
75% 685.85ms
90% 766.72ms
99% 889.39ms
458 requests in 3.01s, 33.54KB read
Requests/sec: 151.91
Transfer/sec: 11.13KB
go 的代码随着不断的测试,很明显处理速度在不断的下降,这说实话有点超出我的认知了。
PHP那边却是基本稳定的,go其实一开始我还用gin测试过,发现测试结果有点超出预料,还改了用http库来测试,这结果属实差强人意了。
突然明白之前经常看到别人在争论性能问题的时候,为什么总有人强调PHP性能并不差。
或许PHP因为fpm的关系导致每次加载大量文件导致的响应相对较慢,比如框架laravel 那个QPS只有一两百的家伙,但其实这个问题要解决也是可以解决的,也用常驻内存的方式就好了。再不行还有phalcon
我一直很好奇一直说PHP性能问题的到底是哪些人, 不会是从PHP转到其他语言的吧。
% php -v
PHP 8.3.12 (cli) (built: Sep 24 2024 18:08:04) (NTS)
Copyright (c) The PHP Gr0up
Zend Engine v4.3.12, Copyright (c) Zend Technologies
with Xdebug v3.3.2, Copyright (c) 2002-2024, by Derick Rethans
with Zend OPcache v8.3.12, Copyright (c), by Zend Technologies
% go version
go version go1.23.1 darwin/amd64
这结果,其实不太能接受,甚至都不知道原因出在哪了,有大佬可以指出问题一下吗
加一下时间打印再看看哪里的问题
package main
import (
"database/sql"
"fmt"
"net/http"
"sync/atomic"
"time"
_ "github.com/go-sql-driver/mysql"
"log"
)
var id int64 = time.Now().Unix() * 1000000
func generateUniqueID() int64 {
return atomic.AddInt64(&id, 1)
}
func main() {
dsn := "root:root@tcp(localhost:3306)/test?charset=utf8"
db, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatalf("Error opening database: %v", err)
}
defer func() { _ = db.Close() }()
// 设置连接池参数
db.SetMaxOpenConns(100) // 最大打开连接数
db.SetMaxIdleConns(10) // 最大空闲连接数
db.SetConnMaxLifetime(time.Hour) // 连接最大存活时间
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
reqStart := time.Now()
var err error
uni := generateUniqueID()
start := time.Now()
// Insert unique ID int0 the database
insertQuery := `INSERT INTO test_last_insert_id (uni) VALUES (?)`
result, err := db.Exec(insertQuery, uni)
fmt.Printf("insert since: %v uni:%d \n", time.Since(start), uni)
if err != nil {
log.Fatalf("Error inserting data: %v", err)
}
lastInsertID, err := result.LastInsertId()
if err != nil {
log.Fatalf("Error getting last insert ID: %v", err)
}
selectStart := time.Now()
// Verify the last insert ID
selectQuery := `SELECT id FROM test_last_insert_id WHERE uni = ?`
var id int64
err = db.QueryRow(selectQuery, uni).Scan(&id)
fmt.Printf("select since:%v uni:%d \n", time.Since(selectStart), uni)
if err != nil {
log.Fatalf("Error selecting data: %v", err)
}
if id != lastInsertID {
log.Fatalf("ID mismatch: %d != %d", id, lastInsertID)
}
fmt.Printf("success req since:%v uni:%d \n", time.Since(reqStart), uni)
})
_ = http.ListenAndServe(":8080", nil)
}
截取了后面的一部分输出,这不会是SQL库的问题吧,
success req since:352.310146ms uni:1729393975000652
insert since: 163.316785ms uni:1729393975000688
insert since: 154.983173ms uni:1729393975000691
insert since: 158.094503ms uni:1729393975000689
insert since: 136.831695ms uni:1729393975000697
insert since: 141.857079ms uni:1729393975000696
insert since: 128.115216ms uni:1729393975000702
select since:412.94524ms uni:1729393975000634
success req since:431.383768ms uni:1729393975000634
select since:459.596445ms uni:1729393975000601
success req since:568.576336ms uni:1729393975000601
insert since: 134.39147ms uni:1729393975000700
select since:390.926517ms uni:1729393975000643
success req since:391.622183ms uni:1729393975000643
select since:366.098937ms uni:1729393975000648
success req since:373.490764ms uni:1729393975000648
insert since: 136.318919ms uni:1729393975000699
select since:420.626209ms uni:1729393975000640
success req since:425.243441ms uni:1729393975000640
insert since: 167.181068ms uni:1729393975000690
select since:272.22808ms uni:1729393975000671
单次请求的时候输出结果是符合预期的, 但是并发SQL时会出现执行慢的问题,这就很奇怪了
% curl localhost:8080
insert since: 1.559709ms uni:1729393975000703
select since:21.031284ms uni:1729393975000703
success req since:22.62274ms uni:1729393975000703
经群友提示还和唯一键的区分度有关,两边算法一致有点太难了,Go换了雪法ID之后就正常了。
因为之前 Go这边生成的uni值是递增的导致区分度很低,最终导致并发写入查询效率变低。
% ./wrk -c100 -t2 -d3s --latency "http://localhost:8080"
Running 3s test @ http://localhost:8080
2 threads and 100 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 44.51ms 24.87ms 187.91ms 77.98%
Req/Sec 1.17k 416.31 1.99k 66.67%
Latency Distribution
50% 37.46ms
75% 54.55ms
90% 80.44ms
99% 125.72ms
6960 requests in 3.01s, 509.77KB read
Requests/sec: 2316.02
Transfer/sec: 169.63KB
2024-10-23 更新
今天本来是想验证一下有关,并发插入自增有序的唯一键高延迟的问题,发现整个有问题的只有一行代码。
就是在查询时,类型转换的问题,插入和查询都转换之后,空表的情况下QPS 可以到4000多。即使在已有大数据量(几十万)的情况也有两千多的QPS。
现在又多了一个问题,为什么用雪花ID时不会有这样的问题。雪花ID也是int64类型的,这是为什么呢。
// 旧代码
err = db.QueryRow(selectQuery, uni).Scan(&id)
if err != nil {
log.Fatalf("Error selecting data: %v", err)
}
// 新代码 变化只有一个就是把uni 转成字符串之后就没有问题了
var realId int64
err = db.QueryRow(selectQuery, fmt.Sprintf("%d", uni)).Scan(&realId)
if err != nil {
log.Fatalf("Error selecting data: %v", err)
}
来源:juejin.cn/post/7427455855941976076
从2s优化到0.1s,我用了这5步
前言
分类树
查询功能,在各个业务系统中可以说随处可见,特别是在电商系统中。
但就是这样一个简单的分类树查询功能,我们却优化了
5
次。
到底是怎么回事呢?
苏三的免费刷题网站:http://www.susan.net.cn 里面:面试八股文、BAT面试真题、工作内推、工作经验分享、技术专栏等等什么都有,欢迎收藏和转发。
背景
我们的网站使用了SpringBoot
推荐的模板引擎:Thymeleaf
,进行动态渲染。
它是一个XML/XHTML/HTML5模板引擎,可用于Web与非Web环境中的应用开发。
它提供了一个用于整合SpringMVC的可选模块,在应用开发中,我们可以使用Thymeleaf来完全代替JSP或其他模板引擎,如Velocity\FreeMarker等。
前端开发写好Thymeleaf的模板文件,调用后端接口获取数据,进行动态绑定,就能把想要的内容展示给用户。
由于当时这个是从0-1的新项目,为了开快速开发功能,我们第一版接口,直接从数据库中查询分类
数据,组装成分类树
,然后返回给前端。
通过这种方式,简化了数据流程,快速把整个页面功能调通了。
第1次优化
我们将该接口部署到dev环境,刚开始没啥问题。
随着开发人员添加的分类越来越多,很快就暴露出性能瓶颈。
我们不得不做优化了。
我们第一个想到的是:加Redis缓存
。
流程图如下:于是暂时这样优化了一下:
- 用户访问接口获取分类树时,先从Redis中查询数据。
- 如果Redis中有数据,则直接数据。
- 如果Redis中没有数据,则再从数据库中查询数据,拼接成分类树返回。
- 将从数据库中查到的分类树的数据,保存到Redis中,设置过期时间5分钟。
- 将分类树返回给用户。
我们在Redis中定义一个了key,value是一个分类树的json格式转换成了字符串,使用简单的key/value形式保存数据。
经过这样优化之后,dev环境的联调和自测顺利完成了。
第2次优化
我们将这个功能部署到st环境了。
刚开始测试同学没有发现什么问题,但随着后面不断地深入测试,隔一段时间就出现一次首页访问很慢的情况。
于是,我们马上进行了第2次优化。
我们决定使用Job
定期异步
更新分类树到Redis中,在系统上线之前,会先生成一份数据。
当然为了保险起见,防止Redis在哪条突然挂了,之前分类树同步写入Redis的逻辑还是保留。
于是,流程图改成了这样:增加了一个job每隔5分钟执行一次,从数据库中查询分类数据,封装成分类树,更新到Redis缓存中。
其他的流程保持不变。
此外,Redis的过期时间之前设置的5分钟,现在要改成永久。
通过这次优化之后,st环境就没有再出现过分类树查询的性能问题了。
第3次优化
测试了一段时间之后,整个网站的功能快要上线了。
为了保险起见,我们需要对网站首页做一次压力测试。
果然测出问题了,网站首页最大的qps是100多,最后发现是每次都从Redis获取分类树导致的网站首页的性能瓶颈。
我们需要做第3次优化。
该怎么优化呢?
答:加内存缓存。
如果加了内存缓存,就需要考虑数据一致性问题。
内存缓存是保存在服务器节点上的,不同的服务器节点更新的频率可能有点差异,这样可能会导致数据的不一致性。
但分类本身是更新频率比较低的数据,对于用户来说不太敏感,即使在短时间内,用户看到的分类树有些差异,也不会对用户造成太大的影响。
因此,分类树这种业务场景,是可以使用内存缓存的。
于是,我们使用了Spring推荐的caffine
作为内存缓存。
改造后的流程图如下:
- 用户访问接口时改成先从本地缓存分类数查询数据。
- 如果本地缓存有,则直接返回。
- 如果本地缓存没有,则从Redis中查询数据。
- 如果Redis中有数据,则将数据更新到本地缓存中,然后返回数据。
- 如果Redis中也没有数据(说明Redis挂了),则从数据库中查询数据,更新到Redis中(万一Redis恢复了呢),然后更新到本地缓存中,返回返回数据。
需要注意的是,需要改本地缓存设置一个过期时间,这里设置的5分钟,不然的话,没办法获取新的数据。
这样优化之后,再次做网站首页的压力测试,qps提升到了500多,满足上线要求。
最近就业形势比较困难,为了感谢各位小伙伴对苏三一直以来的支持,我特地创建了一些工作内推群, 看看能不能帮助到大家。
你可以在群里发布招聘信息,也可以内推工作,也可以在群里投递简历找工作,也可以在群里交流面试或者工作的话题。
添加苏三的私人微信:su_san_java,备注:掘金+所在城市,即可加入。
第4次优化
之后,这个功能顺利上线了。
使用了很长一段时间没有出现问题。
两年后的某一天,有用户反馈说,网站首页有点慢。
我们排查了一下原因发现,分类树的数据太多了,一次性返回了上万个分类。
原来在系统上线的这两年多的时间内,运营同学在系统后台增加了很多分类。
我们需要做第4次优化。
这时要如何优化呢?
限制分类树的数量?
答:也不太现实,目前这个业务场景就是有这么多分类,不能让用户选择不到他想要的分类吧?
这时我们想到最快的办法是开启nginx
的GZip
功能。
让数据在传输之前,先压缩一下,然后进行传输,在用户浏览器
中,自动解压,将真实的分类树数据展示给用户。
之前调用接口返回的分类树有1MB的大小,优化之后,接口返回的分类树的大小是100Kb,一下子缩小了10倍。
这样简单的优化之后,性能提升了一些。
第5次优化
经过上面优化之后,用户很长一段时间都没有反馈性能问题。
但有一天公司同事在排查Redis中大key的时候,揪出了分类树。之前的分类树使用key/value的结构保存数据的。
我们不得不做第5次优化。
为了优化在Redis中存储数据的大小,我们首先需要对数据进行瘦身。
只保存需要用到的字段。
例如:
@AllArgsConstructor
@Data
public class Category {
private Long id;
private String name;
private Long parentId;
private Date inDate;
private Long inUserId;
private String inUserName;
private List children;
}
像这个分类对象中inDate、inUserId和inUserName字段是可以不用保存的。
修改自动名称。
例如:
@AllArgsConstructor
@Data
public class Category {
/**
* 分类编号
*/
@JsonProperty("i")
private Long id;
/**
* 分类层级
*/
@JsonProperty("l")
private Integer level;
/**
* 分类名称
*/
@JsonProperty("n")
private String name;
/**
* 父分类编号
*/
@JsonProperty("p")
private Long parentId;
/**
* 子分类列表
*/
@JsonProperty("c")
private List children;
}
由于在一万多条数据中,每条数据的字段名称是固定的,他们的重复率太高了。
由此,可以在json序列化时,改成一个简短的名称,以便于返回更少的数据大小。
这还不够,需要对存储的数据做压缩。
之前在Redis中保存的key/value,其中的value是json格式的字符串。
其实RedisTemplate
支持,value保存byte数组
。
先将json字符串数据用GZip
工具类压缩成byte数组,然后保存到Redis中。
再获取数据时,将byte数组转换成json字符串,然后再转换成分类树。
这样优化之后,保存到Redis中的分类树的数据大小,一下子减少了10倍,Redis的大key问题被解决了。
性能优化问题,无论在面试,还是工作中,都会经常遇到。
来源:juejin.cn/post/7425382886297600050
MapStruct这么用,同事也开始模仿
前言
hi,大家好,我是大鱼七成饱。
前几天同事review我的代码,发现mapstruct有这么多好用的技巧,遇到POJO转换的问题经常过来沟通。考虑到不可能每次都一对一,所以我来梳理五个场景,谁在过来问,直接甩出总结。
环境准备
由于日常使用都是spring,所以后面的示例都是在springboot框架中运行的。关键pom依赖如下:
<properties>
<java.version>1.8</java.version>
<org.mapstruct.version>1.5.5.Final</org.mapstruct.version>
<org.projectlombok.version>1.18.30</org.projectlombok.version>
</properties>
<dependencies>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${org.mapstruct.version}</version>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${org.mapstruct.version}</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-mapstruct-binding</artifactId>
<version>0.2.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
场景一:常量转换
这是最简单的一个场景,比如需要设置字符串、整形和长整型的常量,有的又需要日期,或者新建类型。下面举个例子,演示如何转换
//实体类
@Data
public class Source {
private String stringProp;
private Long longProp;
}
@Data
public class Target {
private String stringProperty;
private long longProperty;
private String stringConstant;
private Integer integerConstant;
private Long longWrapperConstant;
private Date dateConstant;
}
- 设置字符串常量
- 设置long常量
- 设置java内置类型默认值,比如date
那么mapper这么设置就可以
@Mapper(componentModel = MappingConstants.ComponentModel.SPRING)
public interface SourceTargetMapper {
@Mapping(target = "stringProperty", source = "stringProp", defaultValue = "undefined")
@Mapping(target = "longProperty", source = "longProp", defaultValue = "-1l")
@Mapping(target = "stringConstant", constant = "Constant Value")
@Mapping(target = "integerConstant", constant = "14")
@Mapping(target = "longWrapperConstant", constant = "3001L")
@Mapping(target = "dateConstant", dateFormat = "yyyy-MM-dd", constant = "2023-09-")
Target sourceToTarget(Source s);
}
解释下,constant用来设置常量值,source的值如果没有设置,则会使用defaultValue的值,日期可以按dateFormat解析。
Talk is cheap, show me the code !废话不多说,自动生成的转换类如下:
@Component
public class SourceTargetMapperImpl implements SourceTargetMapper {
public SourceTargetMapperImpl() {
}
public Target sourceToTarget(Source s) {
if (s == null) {
return null;
} else {
Target target = new Target();
if (s.getStringProp() != null) {
target.setStringProperty(s.getStringProp());
} else {
target.setStringProperty("undefined");
}
if (s.getLongProp() != null) {
target.setLongProperty(s.getLongProp());
} else {
target.setLongProperty(-1L);
}
target.setStringConstant("Constant Value");
target.setIntegerConstant(14);
target.setLongWrapperConstant(3001L);
try {
target.setDateConstant((new SimpleDateFormat("dd-MM-yyyy")).parse("09-01-2014"));
return target;
} catch (ParseException var4) {
throw new RuntimeException(var4);
}
}
}
}
是不是一目了然
场景二:转换中调用表达式
比如id不存在使用UUID生成一个,或者使用已有参数新建一个对象作为属性。当然可以用after mapping,qualifiedByName等实现,感觉还是不够优雅,这里介绍个雅的(代码少点的)。
实体类如下:
@Data
public class CustomerDto {
public Long id;
public String customerName;
private String format;
private Date time;
}
@Data
public class Customer {
private String id;
private String name;
private TimeAndFormat timeAndFormat;
}
@Data
public class TimeAndFormat {
private Date time;
private String format;
public TimeAndFormat(Date time, String format) {
this.time = time;
this.format = format;
}
}
Dto转customer,加创建TimeAndFormat作为属性,mapper实现如下:
@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, imports = UUID.class)
public interface CustomerMapper {
@Mapping(target = "timeAndFormat",
expression = "java( new TimeAndFormat( s.getTime(), s.getFormat() ) )")
@Mapping(target = "id", source = "id", defaultExpression = "java( UUID.randomUUID().toString() )")
Customer toCustomer(CustomerDto s);
}
解释下,id为空则走默认的defaultExpression,通过imports引入,java括起来调用。新建对象直接new TimeAndFormat。有的小伙伴喜欢用qualifiedByName自定义方法,可以对比下,哪个合适用哪个,都能调用转换方法。
生成代码如下:
@Component
public class CustomerMapperImpl implements CustomerMapper {
public CustomerMapperImpl() {
}
public Customer toCustomer(CustomerDto s) {
if (s == null) {
return null;
} else {
Customer customer = new Customer();
if (s.getId() != null) {
customer.setId(String.valueOf(s.getId()));
} else {
customer.setId(UUID.randomUUID().toString());
}
customer.setTimeAndFormat(new TimeAndFormat(s.getTime(), s.getFormat()));
return customer;
}
}
}
场景三:类共用属性,如何复用
比如下面的Bike和车辆类,都有id和creationDate属性,我又不想重复写mapper属性注解
public class Bike {
/**
* 唯一id
*/
private String id;
private Date creationDate;
/**
* 品牌
*/
private String brandName;
}
public class Car {
/**
* 唯一id
*/
private String id;
private Date creationDate;
/**
* 车牌号
*/
private String chepaihao;
}
解决起来很简单,写个共用的注解,使用的时候引入就可以,示例如下:
//通用注解
@Retention(RetentionPolicy.CLASS)
//自动生成当前日期
@Mapping(target = "creationDate", expression = "java(new java.util.Date())")
//忽略id
@Mapping(target = "id", ignore = true)
public @interface ToEntity { }
//使用
@Mapper(componentModel = MappingConstants.ComponentModel.SPRING)
public interface TransportationMapper {
@ToEntity
@Mapping( target = "brandName", source = "brand")
Bike map(BikeDto source);
@ToEntity
@Mapping( target = "chepaihao", source = "plateNo")
Car map(CarDto source);
}
这里Retention修饰ToEntity注解,表示ToEntity注解被保留到class文件,但jvm加载class文件时候被遗弃,这是默认的生命周期,辅助生成mapper实现类。上面定义了creationDate和id的转换规则,新建日期,忽略id。
生成的mapper实现类如下:
@Component
public class TransportationMapperImpl implements TransportationMapper {
public TransportationMapperImpl() {
}
public Bike map(BikeDto source) {
if (source == null) {
return null;
} else {
Bike bike = new Bike();
bike.setBrandName(source.getBrand());
bike.setCreationDate(new Date());
return bike;
}
}
public Car map(CarDto source) {
if (source == null) {
return null;
} else {
Car car = new Car();
car.setChepaihao(source.getPlateNo());
car.setCreationDate(new Date());
return car;
}
}
}
坚持一下,还剩俩场景,剩下的俩更有意思
场景四:lombok和mapstruct冲突了
啥冲突?用了builder注解后,mapstuct转换不出来了。哎,这个问题困扰了我那同事两天时间。
解决方案如下:
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-mapstruct-binding</artifactId>
<version>0.2.0</version>
</dependency>
加上lombok-mapstruct-binding就可以了,看下生成的效果:
@Builder
@Data
public class Person {
private String name;
}
@Data
public class PersonDto {
private String name;
}
@Mapper(componentModel = MappingConstants.ComponentModel.SPRING)
public interface PersonMapper {
Person map(PersonDto dto);
}
@Component
public class PersonMapperImpl implements PersonMapper {
public PersonMapperImpl() {
}
public Person map(PersonDto dto) {
if (dto == null) {
return null;
} else {
Person.PersonBuilder person = Person.builder();
person.name(dto.getName());
return person.build();
}
}
}
从上面可以看到,mapstruct匹配到了lombok的builder方法。
场景五:说个难点的,转换的时候,如何注入springBean
有时候转换方法比不是静态的,他可能依赖spring bean,这个如何导入?
这个使用需要使用抽象方法了,上代码:
@Component
public class SimpleService {
public String formatName(String name) {
return "您的名字是:" + name;
}
}
@Data
public class Student {
private String name;
}
@Data
public class StudentDto {
private String name;
}
@Mapper(componentModel = MappingConstants.ComponentModel.SPRING)
public abstract class StudentMapper {
@Autowired
protected SimpleService simpleService;
@Mapping(target = "name", expression = "java(simpleService.formatName(source.getName()))")
public abstract StudentDto map(StudentDto source);
}
接口是不支持注入的,但是抽象类可以,所以采用抽象类解决,后面expression直接用皆可以了,生成mapperimpl如下:
@Component
public class StudentMapperImpl extends StudentMapper {
public StudentMapperImpl() {
}
public StudentDto map(StudentDto source) {
if (source == null) {
return null;
} else {
StudentDto studentDto = new StudentDto();
studentDto.setName(this.simpleService.formatName(source.getName()));
return studentDto;
}
}
}
思考
以上场景肯定还有其他解决方案,遵循合适的原则就可以。驾驭不了的代码,可能带来更多问题,先简单实现,后续在迭代优化可能适合更多的业务场景。
本文示例代码放在了github,需要的朋友请关注公众号大鱼七成饱,回复关键词MapStruct使用即可获得。
来源:juejin.cn/post/7297222349731627046
请不要自己写,Spring Boot非常实用的内置功能
在 Spring Boot 框架中,内置了许多实用的功能,这些功能可以帮助开发者高效地开发和维护应用程序。
松哥来和大家列举几个。
一 请求数据记录
Spring Boot提供了一个内置的日志记录解决方案,通过 AbstractRequestLoggingFilter
可以记录请求的详细信息。
AbstractRequestLoggingFilter
有两个不同的实现类,我们常用的是 CommonsRequestLoggingFilter
。
通过 CommonsRequestLoggingFilter
开发者可以自定义记录请求的参数、请求体、请求头和客户端信息。
启用方式很简单,加个配置就行了:
@Configuration
public class RequestLoggingConfig {
@Bean
public CommonsRequestLoggingFilter logFilter() {
CommonsRequestLoggingFilter filter = new CommonsRequestLoggingFilter();
filter.setIncludeQueryString(true);
filter.setIncludePayload(true);
filter.setIncludeHeaders(true);
filter.setIncludeClientInfo(true);
filter.setAfterMessagePrefix("REQUEST ");
return filter;
}
}
接下来需要配置日志级别为 DEBUG,就可以详细记录请求信息:
logging.level.org.springframework.web.filter.CommonsRequestLoggingFilter=DEBUG
二 请求/响应包装器
2.1 什么是请求和响应包装器
在 Spring Boot 中,请求和响应包装器是用于增强原生 HttpServletRequest
和 HttpServletResponse
对象的功能。这些包装器允许开发者在请求处理过程中拦截和修改请求和响应数据,从而实现一些特定的功能,如请求内容的缓存、修改、日志记录,以及响应内容的修改和增强。
请求包装器
ContentCachingRequestWrapper
:这是 Spring 提供的一个请求包装器,用于缓存请求的输入流。它允许多次读取请求体,这在需要多次处理请求数据(如日志记录和业务处理)时非常有用。
响应包装器
ContentCachingResponseWrapper
:这是 Spring 提供的一个响应包装器,用于缓存响应的输出流。它允许开发者在响应提交给客户端之前修改响应体,这在需要对响应内容进行后处理(如添加额外的头部信息、修改响应体)时非常有用。
2.2 使用场景
- 请求日志记录:在处理请求之前和之后记录请求的详细信息,包括请求头、请求参数和请求体。
- 修改请求数据:在请求到达控制器之前修改请求数据,例如添加或修改请求头。
- 响应内容修改:在响应发送给客户端之前修改响应内容,例如添加或修改响应头,或者对响应体进行签名。
- 性能测试:通过缓存请求和响应数据,可以进行性能测试,而不影响实际的网络 I/O 操作。
2.3 具体用法
请求包装器的使用
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component
public class RequestWrapperFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
ContentCachingRequestWrapper requestWrapper = new ContentCachingRequestWrapper(request);
// 可以在这里处理请求数据
byte[] body = requestWrapper.getContentAsByteArray();
// 处理body,例如记录日志
//。。。
filterChain.doFilter(requestWrapper, response);
}
}
响应包装器的使用
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component
public class ResponseWrapperFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
ContentCachingResponseWrapper responseWrapper = new ContentCachingResponseWrapper(response);
filterChain.doFilter(request, responseWrapper);
// 可以在这里处理响应数据
byte[] body = responseWrapper.getContentAsByteArray();
// 处理body,例如添加签名
responseWrapper.setHeader("X-Signature", "some-signature");
// 必须调用此方法以将响应数据发送到客户端
responseWrapper.copyBodyToResponse();
}
}
在上面的案例中,OncePerRequestFilter
确保过滤器在一次请求的生命周期中只被调用一次,这对于处理请求和响应数据尤为重要,因为它避免了在请求转发或包含时重复处理数据。
通过使用请求和响应包装器,开发者可以在不改变原有业务逻辑的情况下,灵活地添加或修改请求和响应的处理逻辑。
三 单次过滤器
3.1 OncePerRequestFilter
OncePerRequestFilter
是 Spring 框架提供的一个过滤器基类,它继承自 Filter
接口。这个过滤器具有以下特点:
- 单次执行:
OncePerRequestFilter
确保在一次请求的生命周期内,无论请求如何转发(forwarding)或包含(including),过滤器逻辑只执行一次。这对于避免重复处理请求或响应非常有用。 - 内置支持:它内置了对请求和响应包装器的支持,使得开发者可以方便地对请求和响应进行包装和处理。
- 简化代码:通过继承
OncePerRequestFilter
,开发者可以减少重复代码,因为过滤器的执行逻辑已经由基类管理。 - 易于扩展:开发者可以通过重写
doFilterInternal
方法来实现自己的过滤逻辑,而不需要关心过滤器的注册和执行次数。
3.2 OncePerRequestFilter 使用场景
- 请求日志记录:在请求处理之前和之后记录请求的详细信息,如请求头、请求参数和请求体,而不希望在请求转发时重复记录。
- 请求数据修改:在请求到达控制器之前,对请求数据进行预处理或修改,例如添加或修改请求头,而不希望这些修改在请求转发时被重复应用。
- 响应数据修改:在响应发送给客户端之前,对响应数据进行后处理或修改,例如添加或修改响应头,而不希望这些修改在请求包含时被重复应用。
- 安全控制:实现安全控制逻辑,如身份验证、授权检查等,确保这些逻辑在一次请求的生命周期内只执行一次。
- 请求和响应的包装:使用
ContentCachingRequestWrapper
和ContentCachingResponseWrapper
等包装器来缓存请求和响应数据,以便在请求处理过程中多次读取或修改数据。 - 性能监控:在请求处理前后进行性能监控,如记录处理时间,而不希望这些监控逻辑在请求转发时被重复执行。
- 异常处理:在请求处理过程中捕获和处理异常,确保异常处理逻辑只执行一次,即使请求被转发到其他处理器。
通过使用 OncePerRequestFilter
,开发者可以确保过滤器逻辑在一次请求的生命周期内只执行一次,从而避免重复处理和潜在的性能问题。这使得 OncePerRequestFilter
成为处理复杂请求和响应逻辑时的一个非常有用的工具。
OncePerRequestFilter
的具体用法松哥就不举例了,第二小节已经介绍过了。
四 AOP 三件套
在 Spring 框架中,AOP(面向切面编程)是一个强大的功能,它允许开发者在不修改源代码的情况下,对程序的特定部分进行横向切入。AopContext
、AopUtils
和 ReflectionUtils
是 Spring AOP 中提供的几个实用类。
我们一起来看下。
4.1 AopContext
AopContext
是 Spring 框架中的一个类,它提供了对当前 AOP 代理对象的访问,以及对目标对象的引用。
AopContext
主要用于获取当前代理对象的相关信息,以及在 AOP 代理中进行一些特定的操作。
常见方法有两个:
getTargetObject()
: 获取当前代理的目标对象。currentProxy()
: 获取当前的代理对象。
其中第二个方法,在防止同一个类中注解失效的时候,可以通过该方法获取当前类的代理对象。
举个栗子:
public void noTransactionTask(String keyword){ // 注意这里 调用了代理类的方法
((YourClass) AopContext.currentProxy()).transactionTask(keyword);
}
@Transactional
void transactionTask(String keyword) {
try {
Thread.sleep(5000);
} catch (InterruptedException e) { //logger
//error tracking
}
System.out.println(keyword);
}
同一个类中两个方法,noTransactionTask 方法调用 transactionTask 方法,为了使事务注解不失效,就可以使用 AopContext.currentProxy() 去获取当前代理对象。
4.2 AopUtils
AopUtils
提供了一些静态方法来处理与 AOP 相关的操作,如获取代理对象、获取目标对象、判断代理类型等。
常见方法有三个:
getTargetObject()
: 从代理对象中获取目标对象。isJdkDynamicProxy(Object obj)
: 判断是否是 JDK 动态代理。isCglibProxy(Object obj)
: 判断是否是 CGLIB 代理。
举个栗子:
import org.springframework.aop.framework.AopProxyUtils;
import org.springframework.aop.support.AopUtils;
public class AopUtilsExample {
public static void main(String[] args) {
MyService myService = ...
// 假设 myService 已经被代理
if (AopUtils.isCglibProxy(myService)) {
System.out.println("这是一个 CGLIB 代理对象");
}
}
}
4.3 ReflectionUtils
ReflectionUtils
提供了一系列反射操作的便捷方法,如设置字段值、获取字段值、调用方法等。这些方法封装了 Java 反射 API 的复杂性,使得反射操作更加简单和安全。
常见方法:
makeAccessible(Field field)
: 使私有字段可访问。getField(Field field, Object target)
: 获取对象的字段值。invokeMethod(Method method, Object target, Object... args)
: 调用对象的方法。
举个栗子:
import org.springframework.util.ReflectionUtils;
import java.lang.reflect.Field;
import java.util.Map;
public class ReflectionUtilsExample {
public static void main(String[] args) throws Exception {
ExampleBean bean = new ExampleBean();
bean.setMapAttribute(new HashMap<>());
Field field = ReflectionUtils.findField(ExampleBean.class, "mapAttribute");
ReflectionUtils.makeAccessible(field);
Object value = ReflectionUtils.getField(field, bean);
System.out.println(value);
}
static class ExampleBean {
private Map<String, String> mapAttribute;
public void setMapAttribute(Map<String, String> mapAttribute) {
this.mapAttribute = mapAttribute;
}
}
}
还有哪些实用内置类呢?欢迎小伙伴们留言~
来源:juejin.cn/post/7417630844100231206
谈谈我做 Electron 应用的这一两年
大家好,我是徐徐。今天和大家谈谈我做 Electron 桌面端应用的这一两年,把一些经验和想法分享给大家。
前言
入职现在这家公司三年了,刚进公司的时候是 21 年年初,那时候会做一些稍微复杂的后台管理系统以及一些简单的 C 端 SDK。准备开始做 Electron 项目是因为我所在的是安全部门,急需一款桌面管控软件来管理(监控)员工的电脑安全以及入网准入,可以理解为一款零信任的桌面软件。其实之前公司也有一款安全管控的软件,但是Windows 和 Mac是分端构建的,而且维护成本极高,Windows 是使用的 C#, Mac 是用的 Objective-C,开发和发版效率低下,最后在研发老大的同意下,我和另外一个同事开始研究如何用 Electron 这个框架来做一款桌面端软件。
我们发起这个项目大概是在 21 年年底,Windows 版本上线是在上海疫情封城期间,2022年4月份的时候,疫情结束后由于事业部业务方向的调整,又被抽调到了另外一个组去做一个 C 端的创业项目,后面项目结束了,又回来做 Electron 相关的工作直到现在,之所以是一两年,其实就是这个时间线。
对桌面端开发的一些看法
如果你是前端的话,多一门桌面端开发的技能也不是坏事,相当是你的一个亮点,进可攻,退可守。因为桌面端开发到后期的架构可以非常的复杂,不亚于服务端(chromium 就是一个例子),当然也取决于你所应对的场景的挑战,如果所做的产品跟普通前端无异,那也不能说是一个亮点,但是如果你的工作已经触及到一些操作系统的底层,那肯定是一个亮点。
当然也有人说,做桌面端可能就路越走越窄了,但是我想说的是深度和广度其实也可以理解为一个维度,对于技术人来说,知道得越多就行,因为到后期你要成为某个方面的专家,就是可能会非常深入某一块,换一种思路其实也是叫知道得越多。所以,我觉得前端能有做桌面端的机会也是非常好的,即拓展了自己的技能,还能深入底层,因为现阶段由于业务方向的需要,我已经开始看 chromium 源码了,前端的老祖宗。当然,以上这些只代表自己的观点,大家自行斟酌。
谈谈 Electron
其实刚刚工作前两年我就知道这个框架了,当时也做过小 demo,而且还在当时的团队里面分享过这个技术,但是当时对这门技术的认知是非常浅薄的,就知道前端可以做桌面端了,好厉害,大概就停留在这个层面。后面在真正需要用到这门技术去做一个企业级的桌面应用的时候才去真正调研了一下这个框架,然后才发现它真的非常强大,强大到几乎可以实现你桌面端的任何需求。网上关于 Electron 与其他框架的对比实在是太多了,Google 或者 Baidu 都能找到你想要的答案,好与不好完全看自己的业务场景以及自己所在团队的情况。
谈谈自己的感受,什么情况下可以用这门框架
- 追求效率,节省人力财力
- 团队前端居多
- UI交互多
什么情况下不适合这门框架呢?
- 包体积限制
- 性能消耗较高的应用
- 多窗口应用
我们当时的情况就是要追求效率,双端齐头并进,所以最后经过综合对比,选择了 Electron。毕竟 Vscode 就是用它做的,给了我们十足的信心和勇气,一点都不虚。
一图抵千字,我拿出这张图你自己就有所判断了,还是那句话,仁者见仁,智者见智,完全看自己情况。
图片来源:http://www.electronjs.org/apps
技术整体架构
这里我画了一张我所从事 Electron 产品的整体技术架构图。
整个项目基于 Vite 开发构建的,基础设施就是常见的安全策略,然后加上一些本地存储方案,外加一个外部插件,这个插件是用 Tauri 做的 Webview,至于为什么要做这个插件我会在后面的段落说明。应用层面的框架主要是分三个大块,下面主要是为了构建一些基础底座,然后将架构进行分层设计,添加一些原生扩展,上面就是基础的应用管理和 GUI 相关的东西,有了这个整体的框架在后面实现一些业务场景的时候就会变得易如反掌(夸张了一点,因为有的技术细节真的很磨人😐)。
当然这里只是一个整体的架构图,其实还有很多技术细节的流程图以及业务场景图并没有在这里体现出来,不过我也会挑选一些方案在后面的篇幅里面做出相应的讲解。
挑战和方案
桌面端开发会遇到一些挑战,这些挑战大部分来源于特殊的业务场景,框架只能解决一些比较常见的应用场景,当然不仅仅是桌面端,其实移动端或者是 Web 端我相信大家都会遇到或多或少的挑战,我这里遇到的一些挑战和响应的方案不一定适合你,只是做单纯的记录分享,如果有帮助到你,我很开心。下面我挑选软件升级更新,任务队列设计,性能检测优化以及一些特殊的需求这几个方面来聊聊相应的挑战和方案。
软件升级更新
桌面端的软件更新升级是桌面端开发中非常重要的一环,一个好的商业产品必须有稳定好用的解决方案。桌面端的升级跟 C 端 App 的升级其实也是差不多的思路,虽然我所做的产品是公司内部人使用,但是用户也是你面向公司所有用户的,所以跟 C 端产品的解决思路其实是无异的。
升级更新主要是需要做到定向灰度。这个功能是非常重要的,应该大部分的应用都有定向灰度的功能,所以我们为了让软件能够平滑升级,第一步就是实现定向灰度,达到效果可回收,性能可监控,错误可告警的目的。定向更新的功能实现了之后,后面有再多的功能需要实现都有基础保障了,下面是更新相关的能力图。
整个更新模块的设计是分为两大块,一块是后台管理系统,一块是客户端。后台管理系统主要是维护相应的策略配置,比如哪些设备需要定向更新,哪些需要自动更新,不需要更新的白名单以及更新后是需要提醒用户相应的更新功能还是就静默更新。客户端主要就是拉取相应的策略,然后执行相应的更新动作。
由于我们的软件是比较特殊的一个产品,他是需要长期保活的,Mac 端上了文件锁是无法删除的。所以我们在执行更新的时候和常规的软件更新是不一样的,软件的更新下载是利用了 electron-update 相应的钩子,但是安装的时候并没有使用相应的钩子函数,而是自己研究了 electron 的更新源码后做了自己的更新脚本。 因为electron 的更新它自己也会注册一个保活的更新任务的服务,但是这个和我们的文件锁和保活是冲突的,所以是需要禁用掉它的保活服务,完成自己的更新。
整体来说,这一块是花了很多时间去研究的,windows 还好,没有破坏其整个生命周期,傻瓜式的配置一下electron-update 相关的函数钩子就可以了。Mac 的更新花了很多时间,因为破坏了文件的生命周期,再加上保活任务,所以会对 electron-update 的更新钩子进行毁灭性的破坏,最后也只能研究其源码然后自己去实现特殊场景下的更新了。
任务队列设计
任务模块的实现在我们这个软件里面也是非常重要的一环,因为客户端会跑非常多的定时任务。刚开始研发这个产品的时候其实还好,定时任务屈指可数,但是随着长时间的迭代,端上要执行的任务越来越多,每个任务的触发时间,触发条件都不一样,以及还要考虑任务的并发情况和对性能的影响,所以在中后期我们对整个任务模块都做了相应的改造。
下面是整个任务模块的核心能力图。
业界也有一些任务相关的开源工具包,比如 node-schedule、node-cron、cron,这些都是很优秀的库,但是我在使用过程中发现他们好像不具备并发限制的场景,比如有很多任务我们在开始设置的时候都会有个时间间隔,这些任务的时间间隔都是可以在后台随意配置的,如果端上不做并发限制会导致一个问题,就是用户某一瞬间会觉得电脑非常卡。
比如你有 4 个 10 分钟间隔的任务 和 2 个五分钟间隔的任务,那么到某一个时间段,他最大并发可能就是 6,如果刚好这 6 个任务都是非常耗费 CPU 的任务,那他们一起执行的时候就会导致整个终端CPU 飙升,导致用户感觉卡顿,这样就会收到相应的 Diss。
安全类的软件产品其实有的时候不需要太过醒目,后台默默运行就行,所以我们的宗旨就是稳定运行,不超载。为此我们就自己实现了相应的任务队列模式,然后去控制任务并发。其实底层逻辑也不难,就是一个 setInterval 的函数,然后不断的创建销毁,读取队列的函数,执行相应的函数。
性能优化
Electron相关的性能优化其实网上也有非常多的文章,我这里说说我的实践和感受。
首先,性能优化你需要优化什么?这个就是你的出发点了,我们要解决一个问题,首先得知道问题的现状,如果你都不知道现在的性能是什么样子,如何去优化呢?所以发现问题是性能优化的最重要的一步。
这里就推荐两个工具,一个是chrome dev-tool,一个是electron 的 inspector,第一个可以观测渲染进程相关的性能情况,第二个可以观测主进程相关的性能情况。
具体可参考以下网址:
有了工具之后我们就需要用工具去分析一些数据和问题,这里面最重要的就是内存相关的分析,你通过内存相关的分析可以看到 CPU 占用高的动作,以及提前检测出内存泄漏的风险。只要把这两个关键的东西抓住了,应用的稳定性就可以得到保障了,我的经验就是每次发布之前都会跑一遍内存快照,内存没有异常才进行发布动作,内存泄漏是最后的底线。
我说说我大概的操作步骤。
- 通过Performance确认大体的溢出位置
- 使用Memory进行细粒度的问题分析
- 根据heap snapshot,判断内存溢出的代码位置
- 调试相应的代码块
- 循环往复上面的步骤
上面的步骤在主进程和渲染进程都适用,每一步实际操作在这里就不详细展开了,主要是提供一个思路和方法,因为 dev-tool 的面板东西非常多,扩展开来都可以当一个专题了。
然后我再说说桌面端什么地方可能会内存泄漏或者溢出,下面这些都是我血和泪的教训。
- 创建的子进程没有及时销毁:
如果子进程在完成任务后未被正确终止,这些进程会继续运行并占用系统资源,导致内存泄漏和资源浪费。
假设你的 Electron 应用启动了一个子进程来执行某些计算任务,但在计算完成后未调用
childProcess.kill()
或者未确保子进程已正常退出,那么这些子进程会一直存在,占用系统内存。
const { spawn } = require('child_process');
const child = spawn('someCommand');
child.on('exit', () => {
console.log('Child process exited');
});
// 未正确终止子进程可能导致内存泄漏
- HTTP 请求时间过长没有正确处理:
长时间未响应的 HTTP 请求如果没有设定超时机制,会使得这些请求占用内存资源,导致内存泄漏。
在使用 fetch
或 axios
进行 HTTP 请求时,如果服务器长时间不响应且没有设置超时处理,内存会被这些未完成的请求占用。
const fetch = require('node-fetch');
fetch('https://example.com/long-request')
.then(response => response.json())
.catch(error => console.error('Error:', error));
// 应该设置请求超时
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, 5000); // 5秒超时
fetch('https://example.com/long-request', { signal: controller.signal })
.then(response => response.json())
.catch(error => console.error('Error:', error));
- 事件处理器没有移除
未正确移除不再需要的事件处理器会导致内存一直被占用,因为这些处理器仍然存在并监听事件。
在添加事件监听器后,未在适当时机移除它们会导致内存泄漏。
const handleEvent = () => {
console.log('Event triggered');
};
window.addEventListener('resize', handleEvent);
// 在不再需要时移除事件监听器
window.removeEventListener('resize', handleEvent);
- 定时任务未被正确销毁
未在适当时候清除不再需要的定时任务(如 setInterval
)会导致内存持续占用。
使用 setInterval
创建的定时任务,如果未在不需要时清除,会导致内存泄漏。
const intervalId = setInterval(() => {
console.log('Interval task running');
}, 1000);
// 在适当时机清除定时任务
clearInterval(intervalId);
- JavaScript 对象未正确释放
长时间保留不再使用的 JavaScript 对象会导致内存占用无法释放,特别是当这些对象被全局变量或闭包引用时。
创建了大量对象但未在适当时机将它们置为 null
或解除引用。
let bigArray = new Array(1000000).fill('data');
// 当不再需要时,应释放内存
bigArray = null;
- 窗口实例未被正确销毁
未关闭或销毁不再使用的窗口实例会继续占用内存资源,即使用户已经关闭了窗口界面。
创建了一个新的 BrowserWindow 实例,但在窗口关闭后未销毁它。
const { BrowserWindow } = require('electron');
let win = new BrowserWindow({ width: 800, height: 600 });
win.on('closed', () => {
win = null;
});
// 应确保在窗口关闭时正确释放资源
- 大文件或大数据量的处理
处理大文件或大量数据时,如果没有进行内存优化和分批处理,会导致内存溢出和性能问题。
在读取一个大文件时,未采用流式处理,而是一次性加载整个文件到内存中。
const fs = require('fs');
// 不推荐的方式:一次性读取大文件
fs.readFile('largeFile.txt', (err, data) => {
if (err) throw err;
console.log(data);
});
// 推荐的方式:流式读取大文件
const readStream = fs.createReadStream('largeFile.txt');
readStream.on('data', (chunk) => {
console.log(chunk);
});
一些特殊的需求
做这个产品也遇到一些特殊的需求,有的需求还挺磨人的,这里也和大家分享一下。
- 保活和文件锁
作为一个前端,桌面端的保活和文件锁这种需求基本是之前不可能接触到的,为了做这个需求也去了解了一下业界的实现,其实实现都还好,主要是它会带来一些问题,诸如打包构建需要自定义前置脚本和后置脚本,root 用户环境下 mac 端无法输入中文,上面提到的用 Tauri 构建一个 webview 组件就是为了解决 root 用户无法输入中文的场景。
- 静默安装应用
这个需求也是很绝的一个需求。我想如果是做常规的前端开发,估计一般都不会遇到这种需求,你需要从头到尾实现一个下载器,一个软件安装器,而且还要双端适配,不仅如此,还需要实现 exe、zip、dmg、pkg 等各种软件格式的安装,里面包含重试机制,断点下载,队列下载等各种技术细节。当时接到这个需求头也特别大,不过技术方案做出来后感觉也还好,再复杂的需求只要能理清思路,其实都可以慢慢解决。
- VPN 和 访问记录监控
这种需求对一个前端来说更是无从入手,但是好在之前有老版本的 VPN 做参考,就是根据相应的代码翻译一遍也能实现,大部分可以用命令行解决。至于访问记录监控这个玩意咋说呢,客户端做其实也挺费神的,如果不借助第三方的开源框架,自己是非常难实现的,所以这种就是需要疯狂的翻国外的网站,就GitHub,Stackoverflow啰,总有一款适合你,这里就不具体说明了。
- 进程禁用
违规进程禁用其实在安全软件的应用场景是非常常见的,它需要实时性,而且对性能要求很高,一个是不能影响用户正常使用,还要精准杀掉后台配置的违规进程,这个地方其实也是做了很多版优化,但是最后的感觉还是觉得任务队列有性能瓶颈,无法达到要求,现阶段我们也在想用另外的方式去改造,要么就是上全局钩子,要么就是直接把相应的进程文件上锁或者改文件权限。
上面所提到的需求只是一小部例子,还好很多奇奇怪怪的需求没有举例,这些奇怪的需求就像小怪物,不断挑战我的边界,让我也了解和学习和很多奇奇怪怪的知识,有的时候我就会发出这样的感叹:我去,还能这样?
结语
洋洋洒洒,不知不觉已经写了 5000 字了,其实做 Electron 桌面端应用的这一两年自我感觉还是成长了不少,不管是技术方面还是产品设计方面,自己的能力都有所提升。但是同样会遇到瓶颈,就是一个东西一直做一直做,到后面创新会比较难,取得的成就也会慢慢变少。
另外就是安全类的桌面端产品在整个软件开发的里其实是非常冷门的一个领域,他有他的独特性,也有相应的价值,他需要默默的运行,稳定的运行,出问题可以监控到,该提醒的时候提醒用户。你说他低调吧,有时候也挺高调的,真的不好定论,你说没影响力吧,有的时候没他还真不行。让用户不反感这种软件,拥抱这种软件其实挺难的。从一个前端开发的视角来看,桌面端的体验的确很重要,不管是流畅度还是美观度,都不能太差,这也是我们现阶段追求的一个点,就是不断提升用户体验。
路漫漫其修远兮,吾将上下而求索。前端开发这条路的确很长,如果你想朝某个方面深度发展,你会发现边界是非常难触达的,当然也看所处的环境和对应的机遇,就从技术来说的话,前端的天花板也可以很高,不管是桌面端,服务端,移动端,Web端,每个方向前端的天花板都非常难触摸到。
最后,祝大家在自己的领域越来越深,早日触摸到天花板。
原文链接
来源:juejin.cn/post/7399100662610395147
你的团队是“活”的吗?
最近有同学离职,让我突然思考一个话题。
之前在腾讯,内部转岗叫做活水,是希望通过内部转岗,盘活团队。让团队保持一定的人员流动性,让个人与团队双向奔赴,满足各自的需要。因此,我们都希望,团队是活水,而不是一潭死水。
为什么团队要保持一定的人员流动性呢?
- “优”胜“劣”汰。这里不是指恶意竞争和卷。而是通过一定的人员流动性,有进有出,从而找到更加适合团队的人。找到跟团队价值观一致的,志同道合的成员。而跟团队匹配度不是很高的人,可以去寻找更加适合自己的团队和岗位,这对于双方都是有好处的。
- 激活团队。当一个团队保持稳定太久,就会有点思想固化,甚至落后了。这时候,需要通过一些新鲜血液,带来不同的思想和经验,来激活团队,这就像鲶鱼一样。
那想要形成一个“活”的团队,需要什么条件呢?
- 薪资待遇要好。首先是基本福利待遇要高于业界平均水平。其次,绩效激励是有想象空间的。如果没有这个条件,那人员流动肯定是入不敷出的,优秀的人都被挖跑了。
- 团队专业。团队在业界有一定的影响力,在某一方面的专业技术和产出保持业界领先。这个条件隐含了一个信息,就是团队所在业务是有挑战的,因为技术产出一般都是依赖于业务的,没有业务实践和验证,是做不出优秀的技术产出的。因此,待遇好、有技术成长、有职业发展空间,这三者是留住人才的主要手段。
- 梯队完整。在有了前面 2 个条件之后,就有了吸引人才的核心资源了。那接下来就需要有一个完整的梯队。因为资源是有限的,团队资源只能分配到有限人手里,根据最经典的 361,待遇和职业发展空间最多只能覆盖 3 成,技术成长再多覆盖 3 成人已经不错了。那剩下的 4 成人怎么办?所以,团队需要有一些相对稳定的人,他们能完成安排的事情,不出错,也不需要他们卷起来。
这是我当前的想法,我想我还需要更多的经验和讨论的。
那我目前的团队是“活”的吗?答案是否定的。
首先,过去一年,公司的招聘被锁了,内部转岗也基本转不动。薪资待遇就更不用说了。整个环境到处都充斥着“躺”的氛围。
其次,团队专业度一般,在金融业务,前端的发挥空间极其有限。我也只能尽自己所能,帮大家寻求一些技术成长的空间,但还是很有限。
最后,梯队还没有完整,还在建设中,不过也是步履维艰。因为前两个条件限制,别说吸引优秀人才了,能不能保住都是个问题。
最近公司开始放开招聘了,但还不是大面积的,不过还是有希望可以给有人员流失的团队补充 hc 的。但比较难受的是,这个 hc 不是过我的手的,哈哈,又有种听天由命的感觉。
这就是我最近的一个随想,那么,你的团队是“活”的吗?
----------------【END】----------------
【往期文章】
《程序员职场工具库》必须及格的职场工具 —— PPT 系列1
《程序员职场工具库》高效工作的神器 —— checklist
欢迎关注公众号【潜龙在渊灬】(点此扫码关注),收获程序员职场相关经验、提升工作效率和职场效能、结交更多人脉。
来源:juejin.cn/post/7298347391164383259
工作两年,本地git分支达到了惊人的361个,该怎么快速清理呢?
说在前面
不知道大家平时工作的时候会不会需要经常新建git分支来开发新需求呢?在我这边工作的时候,需求都是以issue的形式来进行开发,每个issue新建一个关联的分支来进行开发,这样可以通过issue看到一个需求完整的开发记录,便于后续需求回顾和需求回退。而我平时本地分支都不怎么清理,这就导致了我这两年来本地分支的数量达到了惊人的361个,所以便开始写了这个可以批量删除分支的命令行工具。
功能设计
我们希望可以通过命令行命令的方式来进行交互,快速获取本地分支列表及各分支的最后提交时间和合并状态,在控制台选择我们想要删除的分支。
功能实现
1、命令行交互获取相关参数
这里我们使用@jyeontu/j-inquirer
模块来完成命令行交互功能,@jyeontu/j-inquirer
模块除了支持inquirer
模块的所有交互类型,还扩展了文件选择器、文件夹选择器及多级选择器交互类型,具体介绍可以查看文档:http://www.npmjs.com/package/@jy…
(1)获取操作分支类型
我们的分支分为本地分支和远程分支,这里我们可以选择我们需要操作的分支类型,选择列表为:"本地分支"、"远程分支"、"本地+远程"
。
(2)获取远程仓库名(remote)
我们可以输入自己git的远程仓库名,默认为origin
。
(3)获取生产分支名
我们需要判断各分支是否已经合并到生产分支,所以需要输入自己项目的生产分支名,默认为develop
。
相关代码
const branchListOptions = [
{
type: "list",
message: "请选择要操作的分支来源:",
name: "branchType",
choices: ["本地分支", "远程分支", "本地+远程"],
},
{
type: "input",
message: "请输入远程仓库名(默认为origin):",
name: "gitRemote",
default: "origin",
},
{
type: "input",
message: "请输入生产分支名(默认为develop):",
name: "devBranch",
default: "develop",
},
];
const res = await doInquirer(branchListOptions);
2、命令行输出进度条
在分支过多的时候,获取分支信息的时间也会较长,所以我们需要在控制台中打印相关进度,避免用户以为控制台卡死了,如下图:
3、git操作
(1)获取git本地分支列表
想要获取当前仓库的所有的本地分支,我们可以使用git branch
命令来获取:
function getLocalBranchList() {
const command = "git branch";
const currentBranch = getCurrentBranch();
let branch = child_process
.execSync(command)
.toString()
.replace(trimReg, "")
.replace(rowReg, "、");
branch = branch
.split("、")
.filter(
(item) => item !== "" && !item.includes("->") && item !== currentBranch
);
return branch;
}
(2)获取远程仓库分支列表
想要获取当前仓库的所有的远程分支,我们可以使用git ls-remote --heads origin
命令来获取,git ls-remote --heads origin
命令将显示远程仓库 origin
中所有分支的引用信息。其中,每一行显示一个引用,包括提交哈希值和引用的全名(格式为 refs/heads/
)。
示例输出可能如下所示:
Copy Code
refs/heads/master
refs/heads/develop
refs/heads/feature/xyz
其中,
是每个分支最新提交的哈希值。
function getRemoteList(gitRemote) {
const command = `git ls-remote --heads ${gitRemote}`;
let branchList = child_process
.execSync(command)
.toString()
.replace(trimReg, "")
.replace(rowReg, "、");
branchList = branchList
.split("、")
.filter((item) => item.includes("refs/heads/"))
.map((branch) => {
return gitRemote + "/" + branch.split("refs/heads/")[1];
});
return branchList;
}
(3)获取各分支详细信息
我们想要在每个分支后面显示该分支最后提交时间和是否已经合并到生产分支,这两个信息可以作为我们判断该分支是否要删除的一个参考。
- 获取分支最后提交时间
git show -s --format=%ci
命令用于查看 指定 分支最新提交的提交时间。其中,--format=%ci
用于指定输出格式为提交时间。
在 Git 中,git show
命令用于显示某次提交的详细信息,包括作者、提交时间、修改内容等。通过使用 -s
参数,我们只显示提交摘要信息,而不显示修改内容。
git show -s --format=%ci develop
命令将显示 develop
分支最新提交的提交时间。输出格式为 ISO 8601 标准的时间戳,例如 2023-10-22 16:41:47 +0800
。
function getBranchLastCommitTime(branchName) {
try {
const command = `git show -s --format=%ci ${branchName}`;
const result = child_process.execSync(command).toString();
const date = result.split(" ");
return date[0] + " " + date[1];
} catch (err) {
return "未获取到时间";
}
}
- 判断分支是否合并到生产分支
git branch --contains
命令用于查找包含指定分支(
)的所有分支。
在 Git 中,git branch
命令用于管理分支。通过使用 --contains
参数,我们可以查找包含指定提交或分支的所有分支。
git branch --contains
命令将列出包含
的所有分支。输出结果将显示每个分支的名称以及指定分支是否为当前分支。
示例输出可能如下所示:
Copy Code
develop
* feature/xyz
bugfix/123
其中,*
标记表示当前所在的分支,我们只需要判断输出的分支中是否存在生产分支即可:
function isMergedCheck(branch) {
try {
const command = `git branch --contains ${branch}`;
const result = child_process
.execSync(command)
.toString()
.replace(trimReg, "")
.replace(rowReg, "、");
const mergedList = result.split("、");
return mergedList.includes(gitInfoObj.devBranch)
? `已合并到${gitInfoObj.devBranch}`
: "";
} catch (err) {
return "未获取到合并状态";
}
}
(4)删除选中分支
选完分支后我们就该来删除分支了,删除分支的命令大家应该就比较熟悉了吧
- git branch -D
git branch -D
命令用于强制删除指定的分支(
)。该命令会删除本地仓库中的指定分支,无法恢复已删除的分支。
- git push
:
git push
命令用于删除远程仓库
中的指定分支(
)。这个命令通过推送一个空分支到远程仓库的
分支来实现删除操作。
async function doDeleteBranch(branchList) {
const deleteBranchList = await getDeleteBranch(branchList);
if (!deleteBranchList) return;
console.log("正在删除分支");
progressBar.run(0);
deleteBranchList.forEach((branch, index) => {
let command = `git branch -D ${branch}`;
if (branch.includes("/")) {
const tmp = branch.split("/");
command = `git push ${tmp[0]} :${tmp[1]}`;
}
child_process.execSync(command);
progressBar.run(Math.floor(((index + 1) / deleteBranchList.length) * 100));
});
console.log("");
console.log("已删除分支:" + deleteBranchList);
}
可以看到我们的分支瞬间就清爽了很多。
使用
该工具已经发布到 npm 上,可以直接通过命令npm i -g jyeontu
进行安装,安装完后在控制台中输入jyeontu git
即可进行操作。
源码
该工具的源码也已经开源,有兴趣的同学可以到Gitee上查看:Gitee地址
来源:juejin.cn/post/7292635075304964123
OpenHarmony统一互联PMC启动孵化
在2024年10月12日于上海举办的第三届OpenHarmony技术大会上,OpenHarmony统一互联PMC(项目群项目管理委员会)正式启动孵化。
OpenHarmony统一互联PMC 致力于解决OpenAtom OpenHarmony(以下简称“OpenHarmony”)设备跨操作系统、跨厂家之间的互联互通互操作问题,聚焦HarmonyOS之间、不同OpenHarmony厂商之间,以及与三方OS之间的设备连接,从建底座、定标准、搭平台三个维度构筑统一互联的技术底座。OpenHarmony统一互联PMC孵化范围包括OneConnect应用和组件、OneConnect云侧配套,包括OpenHarmony通用互联应用、OpenHarmony图库分享组件、OpenHarmony文件管理器分享组件、OpenHarmony投屏分享组件、OpenHarmony设备侧业务控制联动组件、统一互联物模型服务器、统一互联认证服务器等。
据了解,OpenHarmony统一互联PMC主要通过共建项目的方式运作,其中共建项目1.0分为3个子项目,分别包括富对瘦设备控制、富对富投屏、富对富文件互传,3个子项目均在交付中;共建项目2.0分为4个子项目,分别包括富对瘦设备控制2.0、富对富投屏2.0、富对富文件互传2.0、分布式摄像头,现已完成场景确定及相关需求分析。OpenHarmony统一互联PMC生态伙伴由最初的华为与7家生态伙伴扩增到25家。
在启动仪式上,还发布了OpenHarmony统一互联系列标准2.0。该系列标准作为统一互联PMC所孵化的设备互联、数据互通、业务互操作相关解决方案的技术沉淀,为教育、金融、交通、政务、医疗等行业形成统一互联互通提供了基础标准参考。
本次发布的标准共计六篇,不仅包含了富对瘦设备之间设备控制、设备联动场景,还涵盖了富对富设备之间的投屏、文件分享等常用业务。可以预见,OpenHarmony统一互联技术标准的发展,将助力打造真正的OpenHarmony物联网生态,实现设备之间的无缝连接,提供更流畅、更安全的用户体验。
收起阅读 »Seata解决分布式的四种方案
前言:
Seata是一款开源的分布式事务解决方案,提供高性能和简单易用的服务,确保微服务架构下的数据一致性, 本文章将依照本人的实际研究所得展开讲述,若有差错,敬请批评,还望海涵~
一、什么是分布式事务?
在分布式项目中,因为服务的拆分,每个服务单独管理自己的数据库。而一个业务操作往往涉及多个服务的数据落库,所以会出现某个服务出现业务异常而出现数据不一致的问题,为了避免数据库的数据不一致问题,分布式服务提供了全局事务,每个微服务作为分支事务,正是因为有全局事务的存在就可以保证了所有的分支事务能够同时成功或同时失败,能正确的进行数据的落库或回滚。
二、Seata解决分布式的四种方案
AT
第一阶段,在AT模式中TC(事务协调者)为包含在其中的多个RM(资源管理者)注册全局事务,然后调用分支事务注册到TC中,接着RM执行sql并提交到undo log(数据快照)中,数据此时处于一种中间状态(软状态),其中包含了事务执行前的旧数据,和事务执行之后的新数据,为保证数据一致性提供了保障。接着RM报告TC事务的执行状态。
第二阶段,TC判断事务是否全部执行成功,并对RM进行对应的提交事务或者回滚事务的通知。最后由TM(应用程序)提交或者回滚全局事务,TC再次进行检查分支事务的状态。
优点:AT模式以分二阶段提交事务,弥补了XA模型资源占用周期过长的缺陷,性能也得到了进一步的加强。
缺点:舍弃了XA的资源占用的同时,也不能保证事务的强一致性,会出现数据在转化过程中提前被用户访问的情况,导致用户得到的是旧数据。
Seata的AT模式的执行流程
XA :
XA模式主要特点为两阶段事务提交,
第一阶段,TC(事务协调者)会通知每个分支事务RM(每个微服务)做好执行事务的准备工作,RM返回就绪信息。在此阶段事务执行但不提交,但是持有数据库锁,占用了数据库的连接。
第二阶段,TC(事务的协调者)会根据第一阶段的执行报告来进行下一步判断,若所有分支事务都能执行成功,那就提交事务,数据落库,否则回滚事务。
优点:1、保证了数据的强一致性,满足ACID原则
2、支持常用的数据库,实现简单,没有代码侵入
应用场景:银行业务、金融行业
缺点:1、若事务因为第一阶段分支事务的失败而长时间等待则会导致资源长时间不得释放,业务无法快速实现响应的问题。
2、依赖关系型数据库实现
TCC
TCC模式的核心思想是通过三个阶段来确保分布式事务的一致性:
在Try阶段中系统会检查业务是否可以执行,并操作对资源进行预留,但依旧没有真正操作和使用资源。
在Confirm阶段,预留资源在此阶段会真正的使用
在Cancel阶段,释放掉锁定的资源
优点:TCC模式对资源的锁定预留保证了强一致性
缺点:TCC模式需要额外的网络通信和预留的预留,导致性能开销大。
SAGA
Saga模式是一种分布式事务处理模式,它将一个大的事务分解为多个小的事务(子事务),每个子事务都是独立的本地事务,可以独立提交或回滚。如果在执行过程中某个子事务失败,Saga模式会触发补偿事务(Compensation Transaction)来撤销之前已经提交的子事务的影响,从而保持数据的一致性。
来源:juejin.cn/post/7424901256151728166
MySQL9.0.0爆重大Bug!不要升级,不要升级...
前言
2024年7月1推出了最新的MySQL9.0.0创新版本,但是由于存在重大BUG,MySQL在7月23日重新发布了新版本.
1.重大bug
7/11日开源数据库软件服务商percona发布MySQL9.0.0重大BUG警告
本次涉及的是3个版本如下
MySQL 8.0.38
MySQL 8.4.1
MySQL 9.0.0
简而言之,如果你创建了大量的表,比如10000个,mysql守护进程将在重启时崩溃。
DELIMITER //
CREATE PROCEDURE CreateTables()
BEGIN
DECLARE i INT DEFAULT 1;
WHILE i <= 10001 DO
SET @tableName := CONCAT('mysql', i);
SET @stmt := CONCAT('CREATE TABLE ', @tableName, ' (id INT PRIMARY KEY AUTO_INCREMENT, data VARCHAR(100));');
PREPARE createTable FROM @stmt;
EXECUTE createTable;
DEALLOCATE PREPARE createTable;
SET i := i + 1;
END WHILE;
END //
DELIMITER ;
CALL CreateTables();
于是我也在之前安装的环境做了下测试,确实存在当创建的表达到10000个后重启实例,就能看到实例启动失败。
2.修复版本
目前三个存在Bug的版本已经无法下载了,以下是之前MySQL9.0.0的截图
7 月 23 日之后
MySQL9.0.0和MySQL 8.4.1 和 MySQL 8.0.38
这三个版本已经无法从历史归档中下载了
目前发布的新版本如下,测试发现确实修复了bug
MySQL 9.0.1
MySQL 8.4.2
MySQL 8.0.39
3.版本升级的风险
数据库版本升级是一项重要的维护工作,但同时也伴随着一定的风险,确保升级过程顺利进行,同时保证业务的连续性和数据的安全性至关重要。
4.总结
本来以为这次 MySQL9.0会有一些王炸的新特性,结果呢,本次除了修复了 100 多个 Bug 之外,几乎没啥对开发者有帮助的点,结果还出现了重大bug.
来源:juejin.cn/post/7395022563976740902
告别 VSCode:VSCodium 自由开发之旅
Visual Studio Code (VSCode)
是一个由微软开发的免费源代码编辑器,它在开发者社区中非常受欢迎。然而,对于那些寻求完全开源替代方案的人来说,VSCodium 成为了一个不错的选择。下面我将撰写一篇关于 VSCodium 的文章,并阐述它与 VSCode 的主要区别。
VSCodium:自由且开放的开发环境
VSCodium 是一个基于 Visual Studio Code(简称 VSCode)的开源版本控制集成开发环境(IDE)。它不仅提供了与 VSCode 相同的强大功能,还去除了专有软件组件,确保了软件的自由度和透明度。
VSCodium 的特点
- 完全开源:VSCodium 是完全基于 MIT 许可证发布的,这意味着它的源代码是完全公开的,用户可以自由地查看、修改和分发其副本。
- 无数据收集:与 VSCode 不同的是,VSCodium 默认不会发送遥测数据或任何其他信息到微软服务器,这对于注重隐私的开发者来说是一个重要的优势。
- 社区驱动:由于其开源性质,VSCodium 可以通过社区贡献来改进和发展,任何人都可以参与到项目的维护和发展中来。
- 跨平台支持:VSCodium 支持 Windows、macOS 和 Linux 操作系统,为不同平台上的开发者提供了一致的- - 使用体验。
VSCodium 与 VSCode 的主要区别
尽管 VSCodium 和 VSCode 在功能上非常相似,但它们之间存在一些关键差异:
许可证与所有权:
- VSCode 虽然也是免费的,但它使用的是专有的“源码可用”许可证,并且由微软拥有和维护。
- VSCodium 则是完全开源的,遵循 MIT 许可证。
数据收集:
- VSCode 默认会收集一些使用数据,虽然这些数据主要用于改善产品,但对于部分用户来说可能是一个隐私问题。
- VSCodium 去除了所有与数据收集相关的功能,保证用户的隐私不受侵犯。
扩展生态系统:
- VSCode 有一个庞大的官方市场,其中包含了大量的插件和扩展,这使得它成为很多开发者的首选。
- VSCodium 使用相同的扩展格式,但由于其相对较小的用户基数,某些插件可能首先发布在 VSCode 市场上。
更新和支持:
- VSCode 得到了微软的强大支持,通常会有更频繁的更新和新特性发布。
- VSCodium 的更新频率取决于社区贡献者的工作,虽然它通常会紧跟 VSCode 的步伐,但在某些情况下可能会稍微滞后。
下载和安装
使用
与vscode基本相同。
结论
选择 VSCodium 还是 VSCode 主要取决于个人的需求和价值观。如果你重视隐私并且希望使用完全开源的工具,那么 VSCodium 将是一个很好的选择。如果你更看重官方支持以及广泛的插件生态系统,那么 VSCode 仍然是一个强大的开发工具。无论选择哪一个,你都将获得一个功能强大、灵活且易于使用的 IDE。
来源:juejin.cn/post/7424908830902485044
一分钟学会 Rust 生成 JWT和校验
JWT 在 web 开发中作用毋庸置疑。下面我们快速的了解 jwt 在 rust 中的使用。
JWT 基础知识
JWT 是一种用于安全传输信息的轻量级、无状态的标准,适用于身份验证和信息交换场景。通过签名验证和自包含的声明,它能够高效地传递用户信息,同时减少服务器负担。
JWT 组成
Header.Payload.Signature
- Header: 类型(typ)和算法(alg, 指定 hash 签名算法),Header 对象会被比 base64URL 编码。
- Payload:负载部分(事件传递的数据),可以分为三类:
注册 Claims
、公共 Claims
以及私有 Claims
,Payload 也会被 base64URL 编码。 - Signature: 签名部分,用于验证消息的完整性,并确保消息在传输过程中未被篡改。
他们直接通过点 .
链接
依赖
[dependencies]
chrono = "0.4.38"
dotenv = "0.15.0"
jsonwebtoken = { version = "9.3.0", features = [] }
serde = { version = "1.0.210", features = ["derive"] }
env 文件
使用 dotenv 将 jwt 需要 secet 放在 env 文件中
JWT_SECRET=your_secret_key
token 的创建和解析
生成解析 token 需要:
- Algorithm 算法
- Claims 有效负载数据。
- chrono 世间处理
实现
jwt 创建和解析十分重要,我们将 jwt 的单独封装到 utils/jwt.rs下
use jsonwebtoken::{encode, decode, Header, Validation, EncodingKey, DecodingKey, Algorithm, TokenData};
use serde::{Serialize, Deserialize};
use chrono::{Utc, Duration};
use std::error::Error;
use std::env;
// Define the claims structure
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
sub: String, // Subject (user ID, email, etc.)
exp: usize, // Expiration time (as UTC timestamp)
}
pub fn create_jwt(sub: &str) -> Result<String, Box<dyn Error>> {
let secret = env::var("JWT_SECRET").expect("JWT_SECRET must be set");
// Define some claims
let my_claims = Claims {
sub: sub.to_owned(),
exp: (Utc::now() + Duration::hours(24)).timestamp() as usize, // JWT expires in 24 hours
};
// Encoding the token
let token = encode(
&Header::default(),
&my_claims,
&EncodingKey::from_secret(secret.as_ref()),
)?;
Ok(token)
}
pub fn verify_jwt(token: &str) -> Result<TokenData<Claims>, Box<dyn Error>> {
let secret = env::var("JWT_SECRET").expect("JWT_SECRET must be set");
let token_data = decode::<Claims>(
token,
&DecodingKey::from_secret(secret.as_ref()),
&Validation::new(Algorithm::HS256),
)?;
Ok(token_data)
}
- create_jwt 对外暴露,接受 sub 作为参数,传递给 Claims 结构体。然后使用 encode 进行加密即可。
- verify_jwt 一般是用在请求拦截器中,请求头中 headers 获取 token 进行解析。
小结
本文主要讲解 jwt 在 rust 中使用。本质就是 Claims 对象和 token 创建与解析。我们需要一些 web 组件像 jsonwebtoken、chrono、dotenv 和 serde 等。JWT 往往单独的放在一个单独的 utils 模块中方便能使用时调用。
来源:juejin.cn/post/7424901483987304463
开发了优惠促销功能,产品再也不汪汪叫了。。。
背景
大家好,我是程序员 cq。
最近快国庆节了,很多平台为了促销都开始发放优惠券,连上海都发放了足足 5 亿元的消费券,当知道这个消息的时候,我差点都忘了自己没钱的事了。
言归正传,我们 网站 也准备做一个优惠券的功能,正好借此机会给大家分享下我们是怎么做的优惠券,下面是某一次需求评审会的对话:
产品小 y:最近国庆节快到了,咱们网站也要像其他网站学习,给用户发放优惠券,把我们的价格打下来!
开发小 c:我才不做,你直接把会员价格下调不就好了?要啥优惠券,不做不做。
产品小 y:诶,你这个小同志,我下调价格是简单,但是我下调之后怎么统计有多少人看了我们的商品没买呢(转化率)?
开发小 c:你统计这个干什么,让用户感觉到优惠不就行了?
产品小 y:我统计转化率肯定是有意义的呀,我给不同的推广渠道发放不同的优惠券,可以得到不同渠道的转化率,如果有的渠道转化率很差,那我下次就不在这个渠道里推广了,有的渠道转化率很好,那我后面就要在这个渠道里加大推广量。还有就是我直接改价要手动改,假期人工改价格很难保证准时开始活动。
开发小 c:好像有道理啊,而且直接改价格也会让用户感觉平台的价格不稳定,价格经常变动,导致用户总是处于观望的状态。
技术方案
需求分析
既然上面需求评审确定了我们要做优惠券功能,那我们就要先梳理下我们要做什么东西。
首先就是优惠券的优惠能力,我们第一期就做的简单些,只用完成直减券,也就是购买会员的时候可以直接抵扣金额的消费券。当然实际上优惠券不光有直减券,还有满减券、折扣券等等,我们的网站也不需要那么复杂,能做一个直减券就够应对会员降价的功能了。
其次就是优惠券的开始时间和结束时间,便于控制优惠的开始时间和结束时间。
最后还有对应的使用条件,比如我们的优惠券只允许新用户使用,便于拉新,之类的。
那我们就可以确定这个优惠券只需要有减免价格、优惠券名称、开始结束时间以及使用条件就好了。
这时候网站运营同学听到我的喃喃低语,头突然伸过来。
他高呼:你这样可不行啊,光有这些可不够,我还要有优惠券的浏览量、领取量、使用量算转化率的嘞!
我心神一动,喔喔喔,那这样的话就可以做一个漏斗:浏览量 > 领取量 > 使用量,「使用量 / 领取量」就可以得到这个优惠券的转化率,也就知道了这个营销渠道的效果。
想到这里,我果断开口,好好好,就宠你。
具体实现
ok,我们确定了我们要做的内容,也就是要给会员商品做一个优惠券,其中要可以控制减免的金额、优惠券的浏览量、领取量、使用量、还有优惠券的开始时间、结束时间以及使用条件。
业务流程
明确了要做的需求,那么我们就可以确定下我们的整个业务流程:
技术实现
其实上述流程中最复杂的地方还是用户领取优惠券和使用优惠券购买后的处理逻辑,下面我带大家看下我们都是怎么做的。
首先我们要对领取优惠券做幂等,确保用户只在第一次领取优惠券的时候可以领取成功,后续的领取都返回已领取。要实现这个效果,我们需要对用户领取优惠券加锁,也就是下面这样:
String lockKey = "coupon_receive_lock:" + userId + ":" + couponId;
synchronized (lockKey.intern()) {
couponService.receiveCouponInner(loginUser, coupon);
}
但是由于我们公司是一个分布式的服务,所以本地锁其实是无效的,正好我们的服务用到了 redis,而且还引入了 redisson,那么就可以直接使用 redisson 的分布式锁,
String lockKey = RedisKeyConstant.COUPON_RECEIVE_LOCK + userId + ":" + couponId;
RLock lock = redissonClient.getLock(lockKey);
try {
lock.lock();
return couponService.receiveCouponInner(loginUser, coupon);
} finally {
lock.unlock();
}
但是这样写就太 low 了,我不如直接封装一个方法,让后续再使用分布式锁更方便:
public T blockExecute(String lockKey, Supplier supplier ) {
RLock lock = redissonClient.getLock(LOCK_KEY_PREFIX + lockKey);
try {
lock.lock();
// 执行方法
return supplier.get();
} finally {
lock.unlock();
}
}
这样,我们在使用分布式锁的时候就更方便了:
String lockKey = RedisKeyConstant.COUPON_RECEIVE_LOCK + userId + ":" + couponId;
return redisLockUtil.blockExecute(lockKey,
() -> couponService.receiveCouponInner(loginUser, coupon));
很好,在完成需求的时候又做了个基建工作,不愧是我。
其次就是在用户领取优惠券之后,我们的优惠券库存就应该对应的 -1,同时领取量对应 +1,那么这个 sql 就是这样的:
update coupon set leftNum = leftNum - 1, receiveNum = receiveNum + 1 where id = ?
这里可以确定数据不会出现混乱,因为在执行更新操作的时候,mysql 会有行锁,所以所有领取的用户都会在这里进行排队操作。
但是这样反而会出现问题,如果一瞬间领取优惠券的用户量激增,大家都在排队等库存量 - 1,就导致领取优惠券的时候用户会感觉很卡,也就是我们常说的热点行问题,不过像很多云厂商,都会针对热点行进行优化,比如某某云的 Inventory Hint 就可以实现快速提交 / 回滚事务,提高吞吐量,详细文章可以看下这篇文章:Inventory Hint,如果并发量真的很大的话,可以考虑用 redis 实现库存的 - 1,不过按照以往的经验来说不用 redis 也可以扛得住,这里就没必要再搞那么复杂了。
最后在领取优惠券之后,我们要接收来自支付中心的回调,根据用户的支付信息给用户赋予对应的会员权限,同时设置用户领取的优惠券为已使用,这样用户就不能再使用这个优惠券了,同时我们也可以关联查询到优惠券的使用量让运营同学进行分析。
了解领取优惠券的具体实现之后,后面的开发就简单了,这里就不再细说了,属于是商业机密了(其实就是代码写的太烂)。
优惠券到底带来了什么
优惠券的技术方案完成后,我们就应该深思一下,优惠券对我们来说到底有什么用,可以给我们带来什么好处?
对于用户而言自然是比之前便宜了许多,更省钱;用户觉得自己赚了,性价比高。
对于运营而言,可以对运营效果进行评估,比如销售变化、客户流失、获客成本等进而调整后续的营销活动;查看不同营销渠道表现,确定不同营销渠道优惠券效果;还可以利用历史数据预测未来的销售趋势,就像我们国庆节的优惠券就是参考了去年的优惠券使用情况来定的。
这也就是为什么运营同学会对优惠券这么痴迷了。
最后
通过这个优惠券功能的开发,其实大家也发现了,从最开始的直接改价到最后的优惠券,我们要做的不光是对商品的减免,还要做运营相关的功能。所以对于开发者而言,很多功能其实不仅要关注代码怎么写,更要考虑实现这个功能的意义,如果没意义,我就不用做了(bushi)。
了解功能实现的意义之后,我们身为开发就应该本能的想到,如何确定这个功能有多少人用,如何埋点,也就是为后续的运营计划以及后续的开发计划做准备。
来源:juejin.cn/post/7419598962346328105
MQTT vs HTTP:谁更适合物联网?
前言
随着物联网(IoT)技术的飞速发展中,其应用规模和使用场景正在持续扩大,但它关键的流程仍然是围绕数据传输来进行的,因此设备通信协议选择至关重要。
作为两种主要的通信协议,MQTT 协议和 HTTP 协议各自拥有独特的优势和应用场景:MQTT 完全围绕物联网设计,拥有更灵活的使用方式,和诸多专为物联网场景设计的特性;而 HTTP 的诞生比它更早,并且被广泛应用在各类非物联网应用中,用户可能拥有更加丰富的开发和使用经验。
本文将深入探讨在物联网环境下,MQTT 和 HTTP 的不同特性、应用场景以及它们在实际应用中的表现。通过对这两种协议的比较分析,我们可以更好地理解如何根据具体需求选择合适的通信协议,以优化物联网系统的性能和可靠性。
MQTT 是什么
MQTT 是一种基于发布/订阅模式的轻量级消息传输协议,针对性地解决了物联网设备网络环境复杂而不可靠、内存和闪存容量小、处理器能力有限的问题,可以用极少的代码为联网设备提供实时可靠的消息服务。
在典型的 MQTT 使用方式中,所有需要通信的客户端(通常是硬件设备和应用服务)与同一个 MQTT 服务器(MQTT Broker)建立 TCP 长连接。发送消息的客户端(发布者)与接收消息的客户端(订阅者)不需要建立直接的连接,而是通过 MQTT 服务器实现消息的路由和分发工作。
实现这一操作的关键在于另一个概念 —— **主题(Topic),**主题是 MQTT 进行消息路由的基础,它类似 URL 路径,使用斜杠 /
进行分层,比如 sensor/1/temperature
。订阅者订阅感兴趣的主题,当发布者向这个主题发布消息时,消息将按照主题进行转发。
一个主题可以有多个订阅者,服务器会将该主题下的消息转发给所有订阅者;一个主题也可以有多个发布者,服务将按照消息到达的顺序转发。同一个客户端,既能作为发布者,也能作为订阅者,双方根据主题进行通信,因此 MQTT 能够实现一对一、一对多、多对一的双向通信。
HTTP 是什么
HTTP 是一种基于请求/响应模式的应用层协议,尽管它主要针对传统的客户端-服务器架构而设计,但它在物联网应用中同样扮演着重要角色。
特别说明的是,本文对比的 HTTP 特指传统的请求/响应模式用例,基于 HTTP 协议扩展实现的 WebSocket 与 Server-Sent Events 协议不参与对比。
在典型的 HTTP 使用方式中,客户端(通常是浏览器或其他网络应用)向服务器发送请求以获取资源或提交数据,服务器接收到请求后,需要处理请求并返回响应,例如将提交的数据保存到数据库中,等待另一个客户端来请求获取。
HTTP 协议使用 URL 来标识资源路径,类似于 MQTT 中的主题(Topic)。例如,HTTP 请求中的 URL 可能是 http://example.com/api/sensor
,这与 MQTT 中的 sensor/1/temperature
主题有相似的分层结构。
HTTP 每次通信都通过独立的请求和响应流程完成,因此它需要额外的开销,并且两个客户端之间无法直接通信,在实时性上稍有欠缺。
资源消耗对比
MQTT 和 HTTP 都是非常简单的协议,许多物联网硬件设备和嵌入式系统都同时提供了对两者的支持。实时上资源体积与运行内存通常不会限制两者的使用,但 MQTT 设计初衷和使用特性是针对物联网设计,因此长期使用中,它具有更小的资源消耗。
首先,MQTT 在连接方面具有较低的开销。MQTT 将协议本身占用的额外消耗最小化,消息头部最小只需要占用 2 个字节,连接建立时的握手过程相对简单,可稳定运行在带宽受限的网络环境下。
一旦建立连接,客户端和服务器之间可以保持长时间的持久连接,多个消息可以在同一连接上传输,从而减少了频繁建立和断开连接的开销。以向 topic/1
主题发布 HelloWorld
内容为例,其报文信息如下:
字段 | 大小(字节) | 描述 |
---|---|---|
固定头部 | 1 | 固定为 0b0011xxxx |
主题长度 | 2 | 0x00 0x08 |
主题 | 9 | "topic/1" |
消息内容长度 | 2 | "HelloWorld"长度 |
消息内容 | 10 | "HelloWorld"内容 |
合计:24 |
HTTP 在每个请求-响应周期中都需要建立和断开连接,会带来额外的服务器资源使用。相对来说,HTTP 协议较为复杂,消息头部较大。同时,由于它是无状态协议,因此每次连接时客户端都需要携带额外的身份信息,这会进一步增加带宽消耗。
以向 http://localhost:3000/topic
URL 传输 HelloWorld
内容为例,在不携带身份凭证的情况下,其报文信息如下:
字段 | 大小(字节) | 描述 |
---|---|---|
请求行 | 17 | POST /topic HTTP/1.1 |
Host | 20 | Host: localhost:3000 |
Content-Type | 24 | Content-Type: text/plain |
Content-Length | 18 | Content-Length: 10 |
空行 | 2 | 用于分隔请求头和请求体 |
请求体 | 10 | HelloWorld 内容 |
合计:91 字节 |
总结:
- MQTT 的连接开销较低,连接建立简单,报文头较小,适用于需要频繁通信或保持持久连接的场景。
- 相比之下,HTTP 需要在每次请求-响应周期中建立和关闭连接,报文头较大,在网络带宽有限的情况下可能会增加传输延迟和负担。
在报文尺寸和连接开销方面,MQTT 通常比 HTTP 更为高效,特别是在需要频繁通信、保持长连接或网络带宽有限的物联网场景下。
安全性对比
MQTT 和 HTTP 两者都是基于 TCP 的协议,并且在协议设计上都充分考虑了安全性。
SSL/TLS 加密
两者都能支持通过 SSL/TLS 进行加密通信:
- 可以保护数据在传输过程中的机密性和完整性;
- 可以防止数据被窃听、篡改或伪造。
多样化的认证授权机制
- MQTT 提供了用户名/密码认证,可以扩展支持 JWT 认证,也支持客户端和服务器之间的 X.509 证书认证;在授权方面,可以支持基于主题的发布订阅授权检查,取决于MQTT 服务器的实现,。
- HTTP 则提供了更灵活的选项,包括基本认证(Basic Auth)、令牌认证(Token Auth)、OAuth 认证;可以通过应用层的权限控制机制,通过访问令牌(Access Token)、会话管理等来控制资源的访问权限。
物联网特性对比
MQTT 协议是专为物联网而设计的通讯协议,内置了丰富的物联网场景特性,能够有效地帮助用户实现设备间稳定可靠的通讯、实时数据传输功能,满足灵活的业务场景需求。
断线重连与持久会话
MQTT 支持持久连接和断线重连,确保设备与服务器之间的稳定通信,即使在网络不稳定的情况下也能保持连接。客户端可以选择是否创建持久会话,在断线重连时恢复之前的会话状态,确保消息不会丢失。
QoS 控制
MQTT 提供三种 QoS 等级:
- QoS 0:最多一次传递,消息可能会丢失。
- QoS 1:至少一次传递,消息可能重复。
- QoS 2:只有一次传递,消息保证不丢失也不重复。
客户端可根据需求选择适当的 QoS 等级,确保消息传递的可靠性。
多个客户端可以订阅相同的主题,接收相同的消息,适用于多个设备间共享数据或订阅相同事件的场景。
服务器可以保留指定主题最新的消息,当新的订阅者连接时立即发送,确保新订阅者获取最新数据。
客户端可以设置遗嘱消息,当客户端异常断开连接时,服务器会发布遗嘱消息,通知其他订阅者客户端已离线。
可以设置消息的过期时间,确保消息在一定时间内被消费,避免过期消息对系统造成不必要的负担。
尽管 HTTP 是 Web 应用中使用最广泛的协议之一,基于成熟的工具链和功能设计经验用户可以实现一些特性,但需要额外的开发工作。在物联网场景下,由于 MQTT 协议原生内置了许多适用于物联网的特性,使用 MQTT 可以降低开发成本,提高通信效率,更适合于物联网应用的需求。
对比总结
总而言之,MQTT 和 HTTP 在通信模型和物联网特性上有显著的区别:
- MQTT 基于发布订阅模型,HTTP 基于请求响应,因此 MQTT 支持双工通信。
- MQTT 可实时推送消息,但 HTTP 需要通过轮询获取数据更新。
- MQTT 是有状态的,但是 HTTP 是无状态的。
- MQTT 可从连接异常断开中恢复,HTTP 无法实现此目标。
- MQTT 支持更多开箱即用的物联网功能,HTTP 则没有针对性的设计。
这些差异将直接影响它们物联网中的使用场景选择:
- 实时通信: MQTT 在实时性要求较高的场景下更为适用。由于其基于发布/订阅模型,设备可以实时推送消息给服务器或其他设备,而不需要等待请求。例如,实时监测传感器数据、实时控制设备等场景下,MQTT 可以提供更快的响应速度。
- 轻量且频繁的通信: 对于带宽和资源有限的环境,MQTT 通常比 HTTP 更加高效。MQTT 不需要频繁建立连接,且消息头相对较小,通信开销较低;而 HTTP 同步的请求/响应模式则显得效率低下,每次通信都需要完整的请求和响应头,导致带宽和资源的浪费。
- 网络波动的场景: MQTT 支持客户端与服务器之间的持久连接,并且能够从连接异常中恢复,这意味着即使网络断开,设备重新连接后也能够恢复通信。而 HTTP 是无状态的,每次通信都是独立的,无法实现断线恢复。
另一个想法:MQTT 与 HTTP 集成使用
到目前为止,我们讨论的都是在物联网设备上更应该选择哪个协议的问题。实际上,在一个复杂的物联网应用中,不仅有硬件设备,还涉及到其他客户端角色和业务流程。MQTT 和 HTTP 作为物联网和互联网中最广泛使用的两种协议,在许多场景下可以互相补充使用,提高系统的效率和灵活性。
例如,在一个典型的车联网应用中,用户侧更适合使用 HTTP 协议:用户可以通过 App 中的"打开车门"按钮来控制停在车库中的汽车。这个过程中,App 与服务器之间并不是双向通信,使用 HTTP 也能实现更复杂和灵活的安全与权限检查。而服务器到车辆之间则依赖实时的双向通信:车辆需要确保任何时候都能够响应来自用户的操作。
车辆可以通过 MQTT 协议周期性的上报自身状态,服务器将其保存下来,当用户需要获取时,在 App 上通过 HTTP 协议完成请求即可。
在知名的 MQTT 服务器 EMQX 中,可以轻松、灵活地实现 MQTT 协议和 HTTP 协议的集成,从而实现这一过程。
EMQX 是一款大规模分布式 MQTT 物联网接入平台,为高可靠、高性能的物联网实时数据移动、处理和集成提供动力,助力企业快速构建物联网时代的关键应用。
HTTP → MQTT:
应用系统通过调用 EMQX 提供的 API,将 HTTP 请求转换为 MQTT 消息发送到指定设备,实现应用系统向设备发送控制指令或通知。
curl -X POST 'http://localhost:18083/api/v5/publish' \
-H 'Content-Type: application/json' \
-u '<appkey>:<secret>'
-d '{
"payload_encoding": "plain",
"topic": "cmd/{CAR_TYPE}/{VIN}",
"qos": 1,
"payload": "{ \"oper\": \"unlock\" }",
"retain": false
}'
MQTT → HTTP:
当设备发送 MQTT 消息到 EMQX 时,通过 EMQX 提供的 Webhook 可以将消息转发到 HTTP 服务器,实现设备数据的即时传输到应用系统。
配置界面如下:
在未来版本中,EMQX 还将提供提供扩展功能,能够将实时的 MQTT 消息保存到内置的消息队列(Message Queue)和流(Stream)中,并允许用户通过 HTTP 拉取的方式进行消费,更好地支持复杂的物联网应用场景,提供更强大的消息处理能力。
总结
总的来说,选择 MQTT 还是 HTTP 取决于具体的应用需求和场景特点。如果需要实时性好、双向通信、资源占用低的通信方式,可以选择 MQTT;只有简单的请求/响应通信,例如物联网客户端数据采集上报、主动拉取服务器数据,或者迫切希望使用现有的 Web 基础设施,那么可以选择 HTTP。
来源:juejin.cn/post/7423605871840608267
HTTP3为什么抛弃了经典的TCP,转而拥抱 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 表示网络请求采用的协议。
HTTP/2的一个主要特性是使用多路复用(multiplexing),因而它可以通过同一个TCP连接发送多个逻辑数据流。复用使得很多事情变得更快更好,它带来更好的拥塞控制、更充分的带宽利用、更长久的TCP连接————这些都比以前更好了,链路能更容易实现全速传输。标头压缩技术也减少了带宽的用量。
采用HTTP/2后,浏览器对每个主机一般只需要 一个 TCP连接,而不是以前常见的六个连接。
如下图所示,HTTP/2 在使用 TCP 传输数据的时候,可以在一个连接上传输两个不同的流,红色是一个流,绿色是另外一个流,但是仍然是按顺序传输的,假设其中有一个包丢了,那整个链路上这个包后面的部分都要等待。
这就造成了阻塞,虽然一个连接可传多个流,但仍然存在单点问题。这个问题就叫做队头阻塞。
QUIC 如何解决的
TCP 这个问题是无解的,QUIC 就是为了彻底解决这个问题。
如下图所示,两台设备之间建立的是一个 QUIC 连接,但是可以同时传输多个相互隔离的数据流。例如黄色是一个数据流,蓝色是一个数据流,它俩互不影响,即便其中一个数据流有丢包的问题,也完全不会影响到其他的数据流传输。
这样一来,也就解决了 TCP 的队头阻塞问题。
为什么要基于 UDP 协议
QUIC 虽然是和TCP 平行的传输协议,工作在传输层,但是其并不是完全采用全新设计的,而是对 UDP 协议进行了包装。
UDP 是无连接的,相对于 TCP 来说,无连接就是不可靠的,没有三次握手,没有丢包重传,没有各种各样的复杂算法,但这带来了一个好处,那就是速度快。
而 QUIC 为了达到 TCP 的可靠性,所以在 UDP 的基础上增加了序号机制、丢包重传等等 UDP 没有而 TCP 具有的特性。
既然这么多功能都做了,还差一个 UDP 吗,完全全新设计一个不好吗,那样更彻底呀。
之所以不重新设计应该有两个原因:
- UDP 本身就是非常经典的传输层协议,对于快速传输来说,其功能完全没有问题。
- 还有一个重要的原因,前面也说到了,互联网上的设备太多,而很多设备只认 TCP 和 UDP 协议,如果设计一个完全全新的协议,很难实施。
QUIC 协议
不需要三次握手
QUIC 建立连接的速度是非常快的,不需要 TCP 那样的三次握手,称之为 0-RTT(零往返时间)及 1-RTT(1次往返时间)。
QUIC 使用了TLS 1.3传输层安全协议,所以 QUIC 传输的数据都是加密的,也就是说 HTTP/3 直接就是 HTTPS 的,不存在 HTTP 的非加密版本。
正是因为这样,所以,QUIC 建立连接的过程就是 TLS 建立连接的过程,如下图这样,首次建立连接只需要 1-RTT。
而在首次连接建立之后,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
MacOS14.4 + Java = 崩溃!!!
使用Mac开发Java的好兄弟们,最近你们还好吗,不知道你们对下面这张图熟不熟悉。
我是很熟悉啊,而且今天就遇到了两次0.0,那么到底是怎么回事呢?
1.场景复现
下午起床,洗把脸开始上班。很有感觉哈,思考问题脑子变快了,手上的代码也快了起来。正当我洋洋得意感觉就要大功告成的时候,突然,很快啊,我他喵的IDEA没了。突然返回到了桌面,我甚至都没反应过来发生了什么事,然后就弹出了上面那个非常让人想把它枪毙五分钟的弹窗。我IDEA崩溃了!!!不过,还好,IDEA向来智能,还会自动保存。然后我就点击了重新打开,打开后我是真的傻眼了。
我代码呢???啊???IDEA我代码呢???说好的自动保存呢?
然后我又找了下本地历史,发现也没有记录,真的G了,快一个小时的汗水白流了。。。我直接想骂街。那又能怎么办呢,只能在跟同事吐槽几句后,重新来过。
生活就是这样,每时每刻都在发生我们无法控制的事情,尽管你很委屈,尽管那是你努力很久很久的东西,但是我们无能为力。我们只能选择擦干眼泪,重新再来。你要习惯,这就是生活。
2.情景再现
就在我感慨完之后,又是一顿行云流水写完了代码,然后点击小虫子按钮准备DeBug时,好家伙,他又来了。 不过好消息是,这次大部分代码还在,只有一小部分丢失了。
3.抓到罪魁祸首
总是感觉不太对劲,从做开发到现在,说实话还从未遇到如此场景。心里除了崩溃之外还存在着疑惑,到底是什么导致一天出现两次这种情况。
晚上逛掘金的时候让我找到了这个答案。
感谢@程序猿DD的文章:juejin.cn/post/734700…
原来是你,我最信赖的MacOS。
原来官方早在3.15就发布了一篇博客,里面讲解了macos14.4中运行java程序会有导致崩溃的情况。大致意思是,在macos14.4之前,系统会向访问受保护区域的进程发送两种信号SIGBUS和SIGSEGV,进程可以选择处理信号并继续运行。但是在macos14.4中,这个信号变了,变成了新的信号SIGKILL,然后我们的java进程没法处理这个信号,所以进程就被无条件的杀死了。
而且影响从 Java 8 到 JDK 22 的早期访问版本的所有 Java 版本。并且目前没有可用的解决方法。
(帮好兄弟们翻译一下)
再看看我的系统版本,好家伙,没谁了,帮苹果测试系统兼容性了。
4.懊悔ing
现在的我已经想象到未来一段时间IDEA接连崩溃的情况了
来源:juejin.cn/post/7347957860087250996
用 Maven 还是 Gradle?
大家好,我是风筝
作为Java 开发者,你平时用 Maven 还是 Gradle?
我一直用的都是 Maven,但是前几天做了一个小项目,用的是 Gradle,因为项目创建出来默认就是用的 Gradle,而且功能足够简单,我也就没动。
实话说,以前也接触过 Gradle。最早是我想学学 Android 开发,Android 项目默认就是用 Gradle,其实那时候我对Gradle 的印象就不是很好。
本来下载 Android SDK 就够慢的了,我记得第一次搭Android 环境,弄了足足一天。好不容易 SDK下载完了,就想写了 Hello World 跑一下,结果发现本地没有 Gradle,这时候Android Stuido 其实会自动下载 Gradle 的(就是一个 Gradle.zip
的文件,相信很多人对这个文件有阴影),但是国内的网络死活就是下载不下来。(ps: 现在下载 Gradle 应该是问题不大了,因为 Gradle 开通了国内的 CDN)
大哥,我就想跑个 Hello World,何罪之有啊!后来一顿搜索,跟着好几个教程,好歹是跑起来了。
在那儿之后,我就没碰过 Gradle 了。直到有一天,看到 Spring 和 Spring Boot 都从 Maven 切换到 Gradle了。诶,难不成 Gradle 已经这么厉害了,让 Spring 团队都抛弃 Maven 了。
然后我把 Spring Boot 最新仓库 clone 下来,结果一构建,一堆报错,解决一个又一个呀,就这?
我把原因归结于 Gradle 使用门槛过高,外加自己能力不行。直到有一天看到有人说:“有几个 Gradle 项目能一次性构建成功跑起来的吗?”
当然这不能就说 Gradle 不好用,Gradle 老鸟们基本上不存在这样的问题,说到底还是理解的不够到位。
为什么 Spring 放着好好的 Maven 不用,要费大力气切到 Gradle呢?Spring 这么大的项目,切到 Gradle 也没那么容易,也是在很多人(包括Gradle 团队成员)的帮助下才迁移完成的。据官方介绍,迁移的主要原因就是为了减少构建时间,构建速度确实是 Gradle 强于 Maven的一大优势,尤其是对于大项目更是如此。
Maven
Maven 是一个项目管理和构建工具,主要用于 Java 项目的构建、依赖管理和项目生命周期管理。Maven 的核心是包管理工具,至于项目构建其实是依靠插件来完成的,比如 maven-compiler-plugin
插件等。
Maven 遵循“约定优于配置”的原则,提供了一套默认的项目结构和构建流程。如果开发者遵循这些约定,Maven 就能自动处理很多配置工作,从而减少开发者的配置负担。
Maven 使用 XML 文件的形式管理依赖包,也就是项目中的 pom.xml
,整个 XML 文件的格式都是固定的,仓库怎么引入、依赖怎么引入、插件怎么引入都是约定好的,照着做就好了,一个项目的 pom 文件,复制到另一个项目中,改一下包依赖、改一下基本项目信息,其他的基本完全复用。
Gradle
Gradle 是一个构建自动化工具,广泛用于软件项目的构建、依赖管理和项目生命周期的管理。它最初是为了构建 Java 项目而设计的,但如今它支持多种编程语言和技术,包括 Java、Kotlin、Groovy、Scala、Android 等。
其在自动化构建能力上更强,包管理只是其中的一个功能。
Gradle 采用基于 Groovy 或 Kotlin 的领域特定语言(DSL),允许开发者通过编写脚本来自定义构建过程。相比其他构建工具(如 Maven),Gradle 更加灵活和强大。这就是它灵活性所在,但是也是它的门槛所在,也就是说你要使用它,还要理解 Groovy 或 Kotlin,理解的不到位可能会带来很多问题。这也是很多人吐槽它的原因,过于灵活的副作用就是门槛过高。
优缺点比较
其实通过上面的介绍也能看出一些端倪了。
学习门槛
首先在学习门槛上,显然 Gradle 更高。一般项目, Maven 加几行 XML 就行了,构建插件也就那么几个,只需要复制粘贴就可以了,而 Gradle 中多少要了解一点 Groovy 或 Kotlin 吧。
灵活性
Gradle 的灵活度更高,Maven 则是中规中举。如果你的构建行为比较复杂,可能纯靠 Maven 自己的配置文件没办法实现,就需要你自己写一些辅助脚本了。而用 Gradle 的话,你可以使用它的 DSL 能力定制非常复杂的构建流程。
性能
这个不得不承认,Gradle 的性能更高。据官方介绍,一般的项目使用 Gradle 构建的速度是Maven 的至少2倍,而一些大型项目的复杂构建,在极端情况下能达到 Maven 的100倍,这好像有点儿夸张了,不过快几倍应该是有的,这也是为什么 Spring 切换到 Gradle 的理由,切换到 Gradle 后,构建时间大概是20多分钟,可想而知,使用 Maven 的话,应该要一个小时以上了。
性能好是有代价的,除了原理不一样外,Gradle 会有一些后台进程,所以,对于一些性能不怎么样的开发机来说,使用 Gradle 反而会觉得卡。
用户体验
用户体验是个很主观的东西,有人就觉得 Maven 好,即使慢一点,也是它好。有人就觉得 Gradle 好,灵活,而且门槛高,用它说明我技术好啊。
但是 Maven 的稳定性是非常好的,一个 Maven 3.5 用好几年也没啥问题,但是 Gradle 不一样,好多版本是不做兼容的。比如我本地安装了新版本,但是有一个项目用的是老版本,那很可能这个项目是没办法跑起来的,只能去安装和这个项目适配的版本。这也是 Gradle 被疯狂吐槽的一个点,即使是 Gradle 用户。
最后
作为开发者如何选择呢?对我来说,我就老实的用 Maven 就好了,反正我也基本上做不了那么大型的、构建一次几个小时的应用,使用 Maven 就图个省心。安心写代码就好了,构建的事儿交给Maven、交给插件就好了。典型的实用主义。
一家之言啊,作为开发者来说,第一肯定是要跟着公司的规定来,公司如果用 Gradle ,那你也不能坚持用 Maven。同理,公司用 Maven,你也不能另辟蹊径的用 Gradle 。
其实到最后可能就是习惯问题了,如果一个工具用的时间超过几个月,那基本上所有的问题都不是问题了。
来源:juejin.cn/post/7424302125460619298
农行1面:Java如何保证线程T1,T2,T3 顺序执行?
你好,我是猿java。
线程是 Java执行的最小单元,通常意义上来说,多个线程是为了加快速度且无需保序,这篇文章,我们来分析一道农业银行的面试题目:如要保证线程T1, T2, T3顺序执行?
考察意图
在面试中出现这道问题,通常是为了考察候选人的以下几个知识点:
1. 多线程基础知识: 希望了解候选人是否熟悉Java多线程的基本概念,包括线程的创建、启动和同步机制。
2. 同步机制的理解:候选人需要展示对Java中各种同步工具的理解,如join()
、CountDownLatch
、Semaphore
、CyclicBarrier
等,并知道如何在不同场景下应用这些工具。
3. 线程间通信:希望候选人理解线程间通信的基本原理,例如如何使用wait()
和notify()
来协调线程。
4. 对Java并发包的熟悉程度: 希望候选人了解Java并发包(java.util.concurrent
)中的工具和类,展示其对现代Java并发编程的掌握。
保证线程顺序执行的方法
在分析完面试题的考察意图之后,我们再分析如何保证线程顺序执行,这里列举了几种常见的方式。
join()
join()
方法是Thread类的一部分,可以让一个线程等待另一个线程完成执行。 当你在一个线程T上调用T.join()时,调用线程将进入等待状态,直到线程T完成(即终止)。因此,可以通过在每个线程启动后调用join()
来实现顺序执行。
如下示例代码,展示了join()
如何保证线程顺序执行:
Thread t1 = new Thread(() -> {
// 线程T1的任务
});
Thread t2 = new Thread(() -> {
// 线程T2的任务
});
Thread t3 = new Thread(() -> {
// 线程T3的任务
});
t1.start();
t1.join(); // 等待t1完成
t2.start();
t2.join(); // 等待t2完成
t3.start();
t3.join(); // 等待t3完成
CountDownLatch
CountDownLatch
通过一个计数器来实现,初始时,计数器的值由构造函数设置,每次调用countDown()方法,计数器的值减1。当计数器的值变为零时,所有等待在await()方法上的线程都将被唤醒,继续执行。
CountDownLatch
是Java并发包(java.util.concurrent)中的一个同步辅助类,用于协调多个线程之间的执行顺序。它允许一个或多个线程等待另外一组线程完成操作。
如下示例代码,展示了CountDownLatch
如何保证线程顺序执行:
CountDownLatch latch1 = new CountDownLatch(1);
CountDownLatch latch2 = new CountDownLatch(1);
Thread t1 = new Thread(() -> {
// 线程T1的任务
latch1.countDown(); // 完成后递减latch1
});
Thread t2 = new Thread(() -> {
try {
latch1.await(); // 等待T1完成
// 线程T2的任务
latch2.countDown(); // 完成后递减latch2
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
Thread t3 = new Thread(() -> {
try {
latch2.await(); // 等待T2完成
// 线程T3的任务
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
t1.start();
t2.start();
t3.start();
CountDownLatch
关键方法解析:
- CountDownLatch(int count) : 构造函数,创建一个CountDownLatch实例,计数器的初始值为count。
- void await() : 使当前线程等待,直到计数器的值变为零。
- boolean await(long timeout, TimeUnit unit) : 使当前线程等待,直到计数器的值变为零或等待时间超过指定的时间。
- void countDown() : 递减计数器的值。当计数器的值变为零时,所有等待的线程被唤醒。
Semaphore
Semaphore
通过一个计数器来管理许可,计数器的初始值由构造函数指定,表示可用许可的数量。线程可以通过调用acquire()
方法请求许可,如果许可可用则授予访问权限,否则线程将阻塞。使用完资源后,线程调用release()
方法释放许可,从而允许其他阻塞的线程获取许可。
如下示例代码,展示了Semaphore
如何保证线程顺序执行:
Semaphore semaphore1 = new Semaphore(0);
Semaphore semaphore2 = new Semaphore(0);
Thread t1 = new Thread(() -> {
// 线程T1的任务
semaphore1.release(); // 释放一个许可
});
Thread t2 = new Thread(() -> {
try {
semaphore1.acquire(); // 获取许可,等待T1完成
// 线程T2的任务
semaphore2.release(); // 释放一个许可
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
Thread t3 = new Thread(() -> {
try {
semaphore2.acquire(); // 获取许可,等待T2完成
// 线程T3的任务
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
t1.start();
t2.start();
t3.start();
Semaphore
关键方法分析:
- Semaphore(int permits) :构造一个具有给定许可数的Semaphore。
- Semaphore(int permits, boolean fair) :构造一个具有给定许可数的Semaphore,并指定是否是公平的。公平性指的是线程获取许可的顺序是否是先到先得。
- void acquire() :获取一个许可,如果没有可用许可,则阻塞直到有许可可用。
- void acquire(int permits) :获取指定数量的许可。
- void release() :释放一个许可。
- void release(int permits) :释放指定数量的许可。
- int availablePermits() :返回当前可用的许可数量。
- boolean tryAcquire() :尝试获取一个许可,立即返回true或false。
- boolean tryAcquire(long timeout, TimeUnit unit) :在给定的时间内尝试获取一个许可。
单线程池
单线程池(Executors.newSingleThreadExecutor())可以确保任务按提交顺序依次执行。所有任务都会在同一个线程中运行,保证了顺序性。
如下示例代码展示了单线程池如何保证线程顺序执行:
ExecutorService executor = Executors.newSingleThreadExecutor();
executor.submit(new T1());
executor.submit(new T2());
executor.submit(new T3());
executor.shutdown();
单线程这种方法简单易用,适合需要顺序执行的场景。
synchronized
synchronized 是Java中的一个关键字,用于实现线程同步,确保多个线程对共享资源的访问是互斥的。它通过锁机制来保证同一时刻只有一个线程可以执行被Synchronized保护的代码块,从而避免数据不一致和线程安全问题。
如下示例代码,展示了synchronized
如何保证线程顺序执行:
class Task {
synchronized void executeTask(String taskName) {
System.out.println(taskName + " 执行");
}
}
public class Main {
public static void main(String[] args) {
Task task = new Task();
new Thread(() -> task.executeTask("T1")).start();
new Thread(() -> task.executeTask("T2")).start();
new Thread(() -> task.executeTask("T3")).start();
}
}
总结
本文,我们分析了5种保证线程T1,T2,T3顺序执行的方法,依次如下:
- join()
- CountDownLatch
- Semaphore
- 单线程池
- synchronized
在实际开发中,这种需要在业务代码中去保证线程执行顺序的情况几乎不会出现,因此,这个面试题其实缺乏实际的应用场景,纯粹是为了面试存在。尽管是面试题,还是可以帮助我们更好地去了解和掌握线程。
来源:juejin.cn/post/7423196076507562023
入职第一天,看了公司代码,牛马沉默了
入职第一天就干活的,就问还有谁,搬来一台N手电脑,第一分钟开机,第二分钟派活,第三分钟干活,巴适。。。。。。
打开代码发现问题不断
- 读取配置文件居然读取两个配置文件,一个读一点,不清楚为什么不能一个配置文件进行配置
一边获取WEB-INF下的配置文件,一边用外部配置文件进行覆盖,有人可能会问既然覆盖,那可以全在外部配置啊,问的好,如果全用外部配置,咱们代码获取属性有的加上了项目前缀(上面的两个put),有的没加,这样配置文件就显得很乱不可取,所以形成了分开配置的局面,如果接受混乱,就写在外部配置;不能全写在内部配置,因为
prop_c.setProperty(key, value);
value获取外部配置为空的时候会抛出异常;properties底层集合用的是hashTable
public synchronized V put(K key, V value) {
// Make sure the value is not null
if (value == null) {
throw new NullPointerException();
}
}
- 很多参数写死在代码里,如果有改动,工作量会变得异常庞大,举例权限方面伪代码
role.haveRole("ADMIN_USE")
- 日志打印居然sout和log混合双打
先不说双打的事,对于上图这个,应该输出包括堆栈信息,不然定位问题很麻烦,有人可能会说e.getMessage()最好,可是生产问题看多了发现还是打堆栈好;还有如果不是定向返回信息,仅仅是记录日志,完全没必要catch多个异常,一个Exception足够了,不知道原作者这么写的意思是啥;还是就是打印日志要用logger,用sout打印在控制台,那我日志文件干啥;
4.提交的代码没有技术经理把关,下发生产包是个人就可以发导致生产环境代码和本地代码或者数据库数据出现不一致的现象,数据库数据的同步是生产最容易忘记执行的一个事情;比如我的这家公司上传文件模板变化了,但是没同步,导致出问题时开发环境复现问题真是麻烦;
5.随意更改生产数据库,出不出问题全靠开发的职业素养;
6.Maven依赖的问题,Maven引pom,而pom里面却是另一个pom文件,没有生成的jar供引入,是的,我们可以在dependency里加上
<type>pom
来解决这个问题,但是公司内的,而且实际也是引入这个pom里面的jar的,我实在不知道这么做的用意是什么,有谁知道;求教
以上这些都是我最近一家公司出现的问题,除了默默接受还能怎么办;
那有什么优点呢:
- 不用太怎么写文档
- 束缚很小
- 学到了js的全局调用怎么写的(下一篇我来写,顺便巩固一下)
解决之道
怎么解决这些问题呢,首先对于现有的新项目或升级的项目来说,spring的application.xml/yml 完全可以写我们的配置,开发环境没必要整外部文件,如果是生产环境我们可以在脚本或启动命令添加 nohup java -Dfile.encoding=UTF-8 -Dspring.config.location=server/src/main/config/application.properties -jar xxx.jar &
来告诉jar包引哪里的配置文件;也可以加上动态配置,都很棒的,
其次就是规范代码,养成良好的规范,跟着节奏,不要另辟蹊径;老老实实的,如果原项目上迭代,不要动源代码,追加即可,没有时间去重构的;
我也曾是个快乐的童鞋,也有过崇高的理想,直到我面前堆了一座座山,脚下多了一道道坑,我。。。。。。!
来源:juejin.cn/post/7371986999164928010
这个评论系统设计碉堡了
先赞后看,南哥助你Java进阶一大半
geeksforgeeks.org
官网给出了Facebook评论系统的高级设计图,Facebook的评论竟然是支持实时刷新的。也就是说用户不用刷新帖子,只要帖子有新的评论就会自动推送到用户端,这里Facebook使用的便是每天在全球有超过20亿设备在使用的WebSocket
技术。
我是南哥,一个Java学习与进阶的领路人。
相信对你通关面试、拿下Offer进入心心念念的公司有所帮助。
⭐⭐⭐本文收录在《Java学习/进阶/面试指南》:https://github/JavaSouth
精彩文章推荐
- 面试官没想到一个ArrayList,我都能跟他扯半小时
- 《我们一起进大厂》系列-Zookeeper基础
- 再有人问你WebSocket为什么牛逼,就把这篇文章发给他!
- 全网把Kafka概念讲的最透彻的文章,别无二家
- 可能是最漂亮的Java I/O流详解
1. 评论系统设计
1.1 评论表如何设计
评论系统的表要这么设计,每条评论的id标识要么是根评论id、要么是回复评论id。如果是根评论,那parent_comment_id字段就为空;而回复别人的评论,parent_comment_id字段指向根评论id。
CREATE TABLE `comments` (
`comment_id` INT AUTO_INCREMENT PRIMARY KEY, -- 评论唯一ID
`user_id` INT NOT NULL, -- 用户ID
`content` TEXT NOT NULL, -- 评论内容
`parent_comment_id` INT DEFAULT NULL, -- 如果是回复,则指向原始评论ID
`post_id` INT NOT NULL, -- 被评论的帖子或内容ID
`like_count` INT DEFAULT 0, -- 点赞数量
`create_time` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 评论创建时间
);
我们还要给评论加上点赞数,南哥给大家看看抖音的评论设计。
用户可以给每条评论打上点赞,所以我们应该再设计一个点赞表。其实抖音这种评论模式叫嵌套式评论结构
,嵌套式评论注重用户对话交流,用户可以很方便地查看一个对话里的所有回复,我们看下抖音评论里有着展开10条回复
的按钮。
其他评论模式设计还有平铺式评论结构
,像微信朋友圈,或者Github的issue都是平铺式评论结构。这种设计更适合用户关注重点在发布的内容本身,而不是对话。大家有没发现微信朋友圈的特点是对话比较少点,点赞反而更多。
来看看点赞表设计。
CREATE TABLE `comment_likes` (
`user_id` INT NOT NULL, -- 点赞用户ID
`comment_id` INT NOT NULL, -- 被点赞的评论ID
`liked_timeMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 点赞时间
);
1.2 评论数据存储
抖音每天产生视频几百万、上千万,每个视频的评论高的甚至有上万条评论,要怎么样的数据查询设计才能支持每天亿级的评论?
南哥先假设我们用MySQL作为实际的数据存储,这么高的并发肯定不能让查询直接冲击数据库 。再分库分表也是没用。
Elasticsearch官网这么宣传它的产品:
Elasticsearch 极其快速,快到不可思议
当用户发表评论时,我们首先把评论写入MySQL数据库,再使用异步机制把评论同步到Elasticsearch中。当在用户请求查询评论时,优先从 Elasticsearch 中进行查询。
// 评论存储到MySQL、Elasticsearch
public void storeComment(Comment comment) {
// 将评论存入 MySQL
commentRepository.save(comment);
// 异步将评论同步到 Elasticsearch
CompletableFuture.runAsync(() -> {
elasticsearchService.indexComment(comment);
});
}
1.3 事务控制
大家想一想以上设计,有哪些需要进行事务控制?
例如comment_likes点赞表的插入和comment评论表的更新,用户为某一个评论点赞,会在comment_likes表插入一条新记录,同时会更新comment表的点赞数量。
但是,从用户需求的角度来看,用户并不在意点赞数的强一致性和实时性,这点不使用事务也可以接受。
我曾经和老外程序员在论坛聊过,他说他们的点赞后端分布式服务用的本地缓存,即使每一个服务的本地缓存相对不太一致,对系统完全没有影响。
// 事务控制
@Transactional
public void likeComment(int commentId, int userId) {
// 插入一条点赞记录
commentLikesRepository.insert(userId, commentId);
// 更新评论表中的点赞数量,假设有一个专门的方法来处理这个更新
commentRepository.incrementLikeCount(commentId);
}
1.4 点赞数加入Redis
点赞数相比评论来说,量更加巨大,用户点赞时直接落到MySQL数据库肯定不合理,服务器扛不住也没必要扛。
假如点赞数没有进行事务控制。南哥打算这样处理,用户点赞后,后端服务接受到点赞请求,把用户内容、点赞数放到Redis里,这里采用Redis五大基本类型之一:Map。
// Map结构
comment_like_key = [comment_id_6:like_count = 66, comment_id_7:like_count = 77]
我们需要查询点赞数时直接从高性能内存数据库Redis查询。
当然这还没完,MySQL数据库和Elasticsearch的点赞量需要去同步更新,我们设置定时任务每个一段时间完成数据同步任务。上文的comment_likes点赞记录表同样需要记录,把点赞放到Redis时进行异步添加点赞记录即可。
// 定时任务数据同步任务
@Scheduled(fixedRate = 10000)
public void syncLikes() {
// 从 Redis 中读取最新的点赞数据
Map<Integer, Integer> likes = redisService.fetchAllLikes();
// 同步到 MySQL 和 Elasticsearch
likes.forEach((commentId, likeCount) -> {
commentRepository.updateLikeCount(commentId, likeCount);
elasticsearchService.updateLikeCount(commentId, likeCount);
});
}
戳这,《JavaSouth》作为一份涵盖Java程序员所需掌握核心知识、面试重点的神秘文档。
我是南哥,南就南在Get到你的点赞点赞。
创作不易,不妨点赞、收藏、关注支持一下,各位的支持就是我创作的最大动力❤️
来源:juejin.cn/post/7418084847615737868
2024 最新最全 VS Code 插件推荐!
Visual Studio Code 是由微软开发的一款开源的代码编辑器,它有了一个丰富的插件市场,提供了很多实用的插件。本文就来分享 2024 年开发必备的 VS Code 插件!
功能强化
驼峰翻译助手
纠结怎么取变量? 中文一键翻译转换成常用大小驼峰等格式。
change-case
Change-case插件提供了一种简单的方法来将单词或变量名更改为各种情况,包括驼峰命名(camelCase)、下划线命名(snake_case)、标题命名(TitleCase)等多种格式。
Codelf
变量命名神器,搜索 Github、Bitbucket、Google Code、Codeplex、Sourceforge、Fedora Project、GitLab 中的项目以查找实际使用的变量名称。
Surround
用于在代码块周围添加包装代码片段。该插件支持语言标识符、多选区操作、完全可自定义、自定义包装代码片段,并为每个包装代码片段单独分配快捷键。
Duplicate Action
一键复制并创建文件或文件夹,提高了开发过程中的文件操作效率。
CSS Peek
它允许开发者直接从HTML文档中快速跳转到匹配的CSS样式定义,并提供预览功能,从而大大提高CSS样式的查找和编辑效率。
Regex Previewer
可以实时预览正则表达式匹配结果,并适用于多种前端框架和语言,同时提供快捷键操作、全局和多行选项等便捷功能,以提升开发效率。
Code Spell Checker
Code Spell Checker 插件可以检查单词拼写是否出现错误,检查的规则遵循 camelCase (驼峰拼写法)。
Markdown Preview Enhanced
支持实时预览 Markdown 文件效果,并具备导出 PDF、支持数学公式、流程图等多种高级功能,提供了丰富的定制选项和兼容性,极大地提升了 Markdown 文档的编辑和预览体验。
Markdown All in One
用于提供Markdown编辑的全方位支持,包括实时预览、语法提示、目录生成、表格生成等多种功能。
i18n-ally
主要用于国际化多语言开发,提供内联提示、快速修改key值对应的语言文件、统一管理翻译、自动翻译等功能。
GitHub Repositories
在 VS Code 中快速打开 Github 仓库,无需克隆到本地。
Turbo Console Log
一键生成有意义的 console.log 消息,支持多语言、多光标操作,提供可定制的日志类型和输出格式,提高调试效率。
indent-rainbow
一款代码缩进可视化插件,它通过为文本前面的缩进着色,使缩进更具可读性。
Remote-SSH
允许开发者通过 SSH 协议连接到远程服务器或虚拟机,直接在本地 VS Code 编辑器中操作远程服务器上的代码,实现无缝的远程开发体验。主要功能包括远程连接、无缝的代码编辑和调试、扩展兼容性、多种连接选项、优化的性能以及支持多个远程服务器同时连接等。
前端框架
ES7+ React/Redux/React-Native snippets
提供了许多速记前缀来加速开发并帮助开发人员为 React、Redux、GraphQL 和 React Native 创建代码片段和语法。
Typescript React Code Snippets
此插件包含了使用 Typescript 的 React 代码片段,它支持 Typescript(.ts) 或 TypeScript React (.tsx) 等语言。以下是使用 TypeScript 创建 React 组件的两个片段。
- 默认导出 React:
- 导出 React 组件:
Vue - Official
Vue 官方扩展。
Vue 3 Snippets
这个插件包含了所有的 Vue.js 2 和 Vue.js 3 的 api 对应的代码片段。
Vue VSCode Snippets
此插件将 Vue 2 Snippets 和 Vue 3 Snippets 添加到 Visual Studio Code 中。
React Native Tools
允许在不同的模拟器或仿真器上轻松运行和调试代码,从命令面板快速运行 react-native 命令,而无需在终端中手动运行命令,并使用 IntelliSense 浏览 React Native 的函数、对象和参数等。
JavaScript (ES6) code snippets
通过此插件可以使用预定义的 ES6 语法片段速记,从而提高开发效率。这个 VS Code 插件可以自定义,因为它不特定于任何框架。
Tailwind CSS IntelliSense
专为使用 Tailwind CSS 的开发者设计,提供实时的类名建议、自动完成、文档预览等功能,以提升 Tailwind CSS 的开发效率和代码质量。
Git 集成
GitLens
该插件增强了 VS Code 中的 Git,并从每个存储库中释放隐藏数据。可以快速查看代码的编写者、轻松导航和探索 Git 存储库、通过丰富的可视化效果和强大的比较命令获取有效信息,以及执行更多操作,帮助我们更好地理解代码。
Git History
该插件用于查看 Git 日志和文件历史记录并比较分支或提交。
Git Graph
Git Graph 插件用于可视化查看存储库的 Git 操作,并从图形中轻松执行Git操作。
统计分析
Import Cost
在项目中导入多个包时可能会出现性能问题,Import Cost 就用于查看将特定库导入项目的成本。该插件会显示导入库的大小,如果大小为绿色,则表示库很小,而红色表示库很大。
Time Master
从编程活动中自动生成的指标、见解和时间跟踪。它是一个开源项目,独立于网络环境,安全轻量。
VS Code Counter
VS Code Counter 插件用于统计项目代码的行数,安装插件之后,右键点击需要统计代码的文件夹,选择“Count lines in directory”,这时就会在项目根目录出现一个名为 .VSCodeCounter 的文件夹,包含了不同格式的结果,编辑器会打开其中的的 .md 格式。结果中会显示代码总行数,不同格式文件行数,不同路径文件函数等。代码行数中有纯代码行数、空白行数、注释行数。
AI 编程辅助
GitHub Copilot
GitHub Copilot 是 Github 推出的一款 AI 结对编程工具,可以帮助开发者更快、更智能地编写代码,不过该插件并不是免费的。
Tabnine
Tabnine 是一款 AI 代码助手,可加速和简化软件开发,同时保证代码的私密性、安全性和合规性。
Codeium
一个基于 AI 技术的免费代码加速工具包,为VSCode提供70多种语言的快速自动补全、聊天和搜索功能,支持IDE内聊天和多种编程语言的建议。
TONGYI Lingma
通义灵码是阿里云推出的一款基于通义大模型的智能编码辅助工具,提供实时续写、自然语言生成代码、单元测试生成、代码注释生成、代码解释、研发智能问答、异常报错排查等能力。
CodeGeeX
CodeGeeX 是一款基于大模型的智能编程助手,它完善了代码的生成与补全,自动为代码添加注释,此外,它还针对代码问题的智能问答,当然还包括代码解释,实现代码,修复代码bug等非常丰富的功能。
代码美化
Highlight Matching Tag
用于实时高亮显示匹配的标签对,方便用户在 HTML 或 XML 代码中快速找到配对的标签。它可以在点击一个标签时,自动显示配对的标签,并通过下划线或其他样式来指示它们之间的对应关系。
TODO Highlight
实时高亮显示代码中的TODO、FIXME等标记,支持自定义关键字和正则表达式匹配,方便开发者快速识别、管理和追踪待办事项。
Todo Tree
用于在Visual Studio Code中搜索、管理和高亮代码中的待办事项标记(如TODO、FIXME等)。它支持自定义标签、颜色编码、实时更新、过滤与排序等功能,并以可视化的树形结构展示待办事项列表,方便开发者快速定位、编辑和跟踪代码中的待办事项。
Better comments
该插件对不同类型的注释会附加了不同的颜色,更加方便区分,帮助我们在代码中创建更人性化的注释。
Colorize
Colorize 会给颜色代码增加一个当前匹配代码颜色的背景。它通过 CSS 变量、预处理器变量、hsl/hsla 颜色、跨浏览器颜色、exa、rgb、rgba和argb的彩色背景将 CSS 颜色可视化,帮助开发者快速区分颜色。
Image preview
通过此插件,当鼠标悬浮在图片的链接上时,可以实时预览该图片,除此之外,还可以看到图片的大小和分辨率。
CodeSnap
CodeSnap 用于对代码的进行截图和共享。屏幕截图可以用文本或形状进行注释,并通过链接共享或包含在网站或文档中。只需使用 ctrl + shift + P 并输入 CodeSnap,然后按回车键,CodeSnap 窗口就会打开。
Error Lens
Error Lens 是一款把代码检查(错误、警告、语法问题)进行突出显示的插件。Error Lens 通过使诊断更加突出,增强了语言的诊断功能,突出显示了由该语言生成的诊断所在的整行,并在代码行的位置以行方式在线打印了诊断消息。
Pretty TypeScript Errors
Pretty TypeScript Errors 旨在使 TypeScript 的错误信息更易读、更人性化。随着 TypeScript 类型复杂性的增加,错误消息可能会变得混乱不堪,充满了难以理解的括号和“...”。这个扩展通过重新格式化或解释 TypeScript 编译器产生的原始错误信息,来帮助开发者更容易地理解发生了什么。
编辑器美化
Power Mode
Power Mode 旨在通过添加视觉特效来增强编程体验,通过了诸如粒子效果、烟火、火焰、魔法效果等特效,让编程过程更加生动有趣。
One Dark Pro
一款编辑器主题。
Dracula Official
一款编辑器主题。
GitHub Theme
一款编辑器主题。
Winter Is Coming Theme
一款编辑器主题。
Ayu
一款编辑器主题。
vscode-icons
VSCode 官方出品的图标库。
Material Icon Theme
该插件根据最新的 Material Design 主题为文件和文件夹提供图标。它可以帮助我们识别文件并为编辑器添加自定义的外观。
Peacock
允许开发者为 Visual Studio Code 的工作区界面(如侧边栏、底栏和标题栏)自定义颜色,以区分不同的项目或编码环境。
来源:juejin.cn/post/7384765023343394827
听我一句劝,业务代码中,别用多线程。
你好呀,我是歪歪。
前几天我在网上冲浪,看到一个哥们在吐槽,说他工作三年多了,没使用过多线程。
虽然八股文背的滚瓜烂熟,但是没有在实际开发过程中写的都是业务代码,没有使用过线程池,心里还是慌得一比。
我只是微微一笑,这不是很正常吗?
业务代码中一般也使不上多线程,或者说,业务代码中不知不觉你以及在使用线程池了,你再 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 点多了。
本来想着在沙发上躺一会,结果一躺就是一整个下午。期间也想过起来写一会文章,坐在电脑前又飞快的躺回到沙发上,就是觉得这个事情索然无味,当下的那一刻就想躺着,然后无意识的刷手机,原本是拿来写文章中关于源码的部分的时间就这样浪费了。
像极了高中时的我,周末带大量作业回家,准备来个悬梁刺股,弯道超车,结果变成了一睡一天,捏紧刹车。
高中的时候,时间浪费了是真的可惜。
现在,不一样了。
荒腔走板这张图片,就是我躺在沙发上的时候,别人问我在干什么时随手拍的一张。
我并不为躺了一下午没有干正事而感到惭愧,浪费了的时间,才是属于自己的时间。
很久以前我看到别人在做一些浪费时间的事情的时候,我心里可能会嘀咕几句,劝人惜时。
这两年我不会了,允许自己做自己,允许别人做别人。
来源:juejin.cn/post/7297980721590272040
大厂必问 · 如何防止订单重复?
在电商系统或任何涉及订单操作的场景中,用户多次点击“提交订单”按钮可能会导致重复订单提交,造成数据冗余和业务逻辑错误,导致库存问题、用户体验下降或财务上的错误。因此,防止订单重复提交是一个常见需求。
常见的重复提交场景
- 网络延迟:用户在提交订单后未收到确认,误以为订单未提交成功,连续点击提交按钮。
- 页面刷新:用户在提交订单后刷新页面,触发订单的重复提交。
- 用户误操作:用户无意中点击多次订单提交按钮。
防止重复提交的需求
- 幂等性保证:确保相同的请求多次提交只能被处理一次,最终结果是唯一的。
- 用户体验保障:避免由于重复提交导致用户感知的延迟或错误。
常用解决方案
前端防重机制:在前端按钮点击时禁用按钮或加锁,防止用户多次点击。
后端幂等处理:
- 利用Token机制:在订单生成前生成一个唯一的Token,保证每个订单提交时只允许携带一次Token。
- 基于数据库的唯一索引:通过对订单字段(如订单号、用户ID)创建唯一索引来防止重复数据的插入。
- 分布式锁:使用Redis等分布式缓存加锁,保证同一时间只允许处理一个订单请求。
功能实践
Spring Boot 提供了丰富的工具和库,今天我们基于Spring Boot框架,可以利用 Token机制 和 Redis分布式锁 来防止订单的重复提交。
功能原理与技术实现
通过Redis的原子性操作,我们可以确保高并发情况下多个请求对同一个订单的操作不会冲突。
Token机制
Token机制是一种常见的防止重复提交的手段,通常的工作流程如下:
- Token生成:在用户开始提交订单时,服务器生成一个唯一的
OrderToken
并将其存储在 Redis 等缓存中,同时返回给客户端。 - Token验证:用户提交订单时,客户端会将
OrderToken
发送回服务器。服务器会验证此OrderToken
是否有效。 - Token销毁:一旦验证通过,服务器会立即销毁
OrderToken
,防止重复使用同一个Token提交订单。
这种机制确保每次提交订单时都需要一个有效且唯一的Token,从而有效防止重复提交。
Redis分布式锁
在多实例的分布式环境中,Token机制可以借助 Redis 来实现更高效的分布式锁:
- Token存储:生成的Token可以存储在Redis中,Token的存活时间通过设置TTL(如10分钟),保证Token在一定时间内有效。
- Token校验与删除:当用户提交订单时,服务器通过Redis查询该Token是否存在,并立即删除该Token,确保同一个订单只能提交一次。
流程设计
- 用户发起订单请求时,后端生成一个唯一的Token(例如UUID),并将其存储在Redis中,同时将该Token返回给前端。
- 前端提交订单时,将Token携带至后端。
- 后端校验该Token是否有效,若有效则执行订单创建流程,同时删除Redis中的该Token,确保该Token只能使用一次。
- 如果该Token已被使用或过期,则返回错误信息,提示用户不要重复提交。
功能实现
依赖配置(pom.xml)
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
application. properties
# Thymeleaf ??
spring.thymeleaf.prefix=classpath:/templates/
spring.thymeleaf.suffix=.html
spring.thymeleaf.mode=HTML
spring.thymeleaf.encoding=UTF-8
spring.thymeleaf.cache=false
spring.redis.host=127.0.0.1
spring.redis.port=23456
spring.redis.password=pwd
订单Token生成服务
生成Token并存储到Redis: 当用户请求生成订单页面时,服务器生成一个唯一的UUID作为订单Token,并将其与用户ID一起存储在Redis中。
package com.example.demo.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@Service
@Slf4j
public class OrderTokenService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
// 生成订单Token
public String generateOrderToken(String userId) {
String token = UUID.randomUUID().toString();
// 将Token存储在Redis中,设置有效期10分钟
redisTemplate.opsForValue().set("orderToken:" + userId, token, 10, TimeUnit.MINUTES);
return token;
}
// 验证订单Token
public boolean validateOrderToken(String userId, String token) {
String redisToken = redisTemplate.opsForValue().get("orderToken:" + userId);
log.info("@@ 打印Redis中记录的redisToken :{} `VS` @@ 打印当前请求过来的token :{}", redisToken, token);
if (token.equals(redisToken)) {
// 验证成功,删除Token
redisTemplate.delete("orderToken:" + userId);
return true;
}
return false;
}
}
订单控制器
订单提交与验证Token: 提交订单时,系统会检查用户传递的Token是否有效,若有效则允许提交并删除该Token,确保同一Token只能提交一次。
package com.example.demo.controller;
import com.example.demo.entity.Order;
import com.example.demo.service.OrderTokenService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/order")
public class OrderController {
@Autowired
private OrderTokenService orderTokenService;
// 获取订单提交的Token
@GetMapping("/getOrderToken")
public ResponseEntity<String> getOrderToken(@RequestParam String userId) {
String token = orderTokenService.generateOrderToken(userId);
return ResponseEntity.ok(token);
}
// 提交订单
@PostMapping("/submitOrder")
public ResponseEntity<String> submitOrder(Order order) {
// 校验Token
if (!orderTokenService.validateOrderToken(order.getUserId(), order.getOrderToken())) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("订单重复提交,请勿重复操作");
}
// 此处处理订单逻辑
// ...
// 假设订单提交成功
return ResponseEntity.ok("订单提交成功");
}
}
前端实现
前端通过表单提交订单,并在每次提交前从服务器获取唯一的订单Token:
<script>
document.getElementById('orderForm').addEventListener('submit', function(event) {
event.preventDefault();
const userId = document.getElementById('userId').value;
if (!userId) {
alert("请填写用户ID");
return;
}
// 先获取Token,再提交订单
fetch(`/order/getOrderToken?userId=${userId}`)
.then(response => response.text())
.then(token => {
document.getElementById('orderToken').value = token;
// 提交订单请求
const formData = new FormData(document.getElementById('orderForm'));
fetch('/order/submitOrder', {
method: 'POST',
body: formData
})
.then(response => response.text())
.then(result => {
document.getElementById('message').textContent = result;
})
.catch(error => {
document.getElementById('message').textContent = '订单提交失败,请重试';
});
})
.catch(error => {
document.getElementById('message').textContent = '获取Token失败';
});
});
</script>
为了验证功能,我们在代码中增加 Thread.sleep(2000); 来进行阻塞。
然后快速点击提交表单,可以看到提示表单重复提价的信息
**技术选型与优化:**通过Redis结合Token机制,我们有效地防止了订单的重复提交,并通过Token的唯一性和时效性保证了订单操作的幂等性。
- Redis缓存:通过Redis的分布式锁和高并发处理能力,确保系统在高并发情况下仍然可以正常运行,并发订单提交的场景中不会出现Token重复使用问题。
- UUID:使用UUID生成唯一的Token,保证Token的唯一性和安全性。
- Token时效性:Token通过设置Redis的TTL(过期时间)来控制有效期,避免无效Token长期占用资源。
总结
防止订单重复提交的关键在于:
- Token的唯一性与时效性:确保每次订单提交前都有唯一且有效的Token。
- Token的原子性验证与删除:在验证Token的同时删除它,防止同一个Token被多次使用。
- Redis的高效存储与分布式锁:通过Redis在高并发环境中提供稳定的锁机制,保证并发提交的准确性。
这套基于Token机制和Redis的解决方案具有简单、高效、可扩展的特点,适合各种高并发场景下防止重复订单提交。
来源:juejin.cn/post/7418776600738840628
JDK23,带来了哪些新功能?
前言
2024年9月17日,Java开发者们迎来了期待已久的JDK23版本。
下载地址:jdk.java.net/23/
文档地址:jdk.java.net/23/release-…
为 JDK 21 之后的第一个非 LTS 版本,最终的 12 个 JEP 特性包括:
JEP 455:模式、instanceof 和 switch 中的原始类型(Primitive Types in Patterns, instanceof, and switch,预览)
JEP 466:类文件 API(Class-File API,第二轮预览)
JEP 467:Markdown 文档注释(Markdown Documentation Comments)
JEP 469:Vector API(第八轮孵化)
JEP 471:废弃 sun.misc.Unsafe 中的内存访问方法以便于将其移除(Deprecate the Memory-Access Methods in sun.misc.Unsafe for Removal)
JEP 473:流收集器(Stream Gatherers,第二轮预览)
JEP 474:ZGC:默认的分代模式(ZGC: Generational Mode by Default)
JEP 476:模块导入声明(Module Import Declarations,预览)
JEP 477:隐式声明的类和实例主方法(Implicitly Declared Classes and Instance Main Methods,第三轮预览)
JEP 480:结构化并发(Structured Concurrency,第三轮预览)
JEP 481:作用域值(Scoped Values,第三轮预览)
JEP 482:灵活的构造函数体(: Flexible Constructor Bodies,第二轮预览)
基本上每次JDK的升级,在带来一些新功能的同时,也会提升一些性能。
一些优秀的语法,也能提升开发效率。
除了编程语言升级能提升开发效率之外,一些好的开发工具或者设备,也可以。
2 提升开发效率
显示器可以提升开发效率?
答:是真的。
2.1 屏幕尺寸
在之后的一段时间内,我尝试过一些不同品牌和型号的外接显示器。
常见的显示器的屏幕比是16:9。
而我现在正在用的明基RD280U显示器的屏幕比是3:2。
跟我的笔记本电脑屏幕相比,高度是笔记本电脑的两倍了。
我第一次使用时,就明显感觉到,明基RD240Q显示器的屏幕更高一些,一屏可以多看十几行代码。
在开发过程中,每次滚动屏幕,都可以多看几行代码,如果次数多了,可以多看很多行代码,真的可以提高开发效率。
2.2 专业编程模式
我后来才知道明基RD280U是一个专业的编程显示器,专门给程序员设计的。
屏幕正下方的这个按键,可以调整编程模式,可以优化IDE上代码的显示效果,让代码更加清晰:
2.3 背光灯
我们之前在晚上编程的时候,经常需要打开台灯,才能让屏幕看到更清楚。
为了解决这个问题,明基RD280U提供了Moonhalo背光灯的功能,下面这张图是我在关灯的情况下拍摄的:
可以看到屏幕有黄色的背景灯光。
下面的这张灯光图更直观:
可以让你沉浸在开发中,不被打扰。
3 全方位呵护
明基RD280U显示器使用了莱茵认证护眼技术,实现了:低蓝光、无屏闪的效果。
3.1 护眼模式
在夜间开发,可以切换夜间保护模式:
如果切换成自动模式,当外面环境变亮时,屏幕会自动变暗。当外面环境变暗时,屏幕会自动变暗。
保护我们的眼睛。
智慧蓝光模式是为了减少蓝光对眼睛的刺激,提供更舒适的视觉体验。
我们可以调节让自己眼睛感到舒服的蓝光。
3.2 抗反射面板
当我们的屏幕出现其他的灯光直射时,笔记本电脑的效果是这样的:
代码完全看不清楚。
而明基RD280U显示器,即使遇到强光也能看清代码。
这是我非常喜欢的设计。
4 软件协同
明基RD280U显示器为了方便我们操作,还提供了一个驱动软件:Display Pilot2。
里面包含了画面切换,快速搜索,桌面分区和键盘快速切换功能。
我们可以在电脑上直接控制显示器:
文章前面介绍的这些功能,都可以直接在电脑上通过Display Pilot2进行控制。
比如开启显示器的Moonhalo背光灯。
新增的flow功能可以设置特定时间场景下的一些显示器的参数。
5 总结
本文主要介绍了JDK23的12项新特性,涵盖了语言预览、API增强、性能优化等多个方面,可能会对开发者的工作流程和编程习惯产生深远的影响。
同时也介绍了我正在使用的明基RD280U显示器的一些优秀的功能,比如:屏幕尺寸更大、专业编程模式、Moonhalo背光灯、护眼功能(夜间防护功能、智慧蓝光)、抗反射面板、display pilot2功能,能够提升开发效率和保护我们的眼睛。
来源:juejin.cn/post/7418072992838500362
“你好BOE”即将重磅亮相上海国际光影节 这场“艺术x科技”的顶级光影盛宴不容错过!
当艺术遇上科技,将会擦出怎样的璀璨火花?答案即将在首届上海国际光影节揭晓。9月29日-10月5日,全球显示龙头企业BOE(京东方)年度标杆性品牌IP“你好BOE”即将重磅亮相上海国际光影节,这也是虹口区的重点项目之一。
现场BOE(京东方)将携手上海电影、上影元、OUTPUT、新浪微博、海信、OPPO、京东等众多顶级文化机构与全球一线知名企业,在上海城市地标北外滩临江5米平台打造一场以创新科技赋能影像艺术的顶级视觉盛宴,将成为本届光影节期间上海北外滩“最吸睛”的打卡地点!
看点一:全球首款升降式裸眼3D数字艺术装置“大地穹幕” 闪耀北外滩最具科技感的光影亮色
在 “你好BOE”活动现场,全球首款升降式裸眼3D数字艺术装置——“大地穹幕”将正式与观众见面!你可以在高达5米的大型裸眼3D折角屏幕前亲身感受极具视觉冲击力的3D大屏画面,还可以通过智能识别技术与屏幕光影尽情交互,让整个北外滩广场瞬间变成户外露天观众席,极具特色的“大地穹幕”与临江对岸的上海“三件套”交相辉映,市民朋友们可以在外滩的微风中尽情畅享裸眼3D影院级体验。
看点二:灵动“舞屏”、 MELD 裸眼3D沉浸空间、智慧光幕技术 打造极具未来感的科技大秀
屏幕会跳舞?即将亮相的全新“舞屏”将颠覆你的想象力!动态的机械臂仿佛拥有生命一般自如地抓取、搬运、旋转,让高清大屏在你眼前自由舞动;或者在MELD 裸眼3D沉浸空间体验一场虚拟与现实之间的穿梭;亦或是在搭载智慧光幕技术的“玻璃窗”前,感受玻璃明暗随日升月落不断流转变化,未来科技带来的美好生活仿若近在眼前!
看点三:经典动画IP、当代摄影艺术、国产3A游戏巨制 焕活文化艺术全新生命力
在活动现场,《大闹天宫》、《哪吒闹海》等上影元运营的经典动画作品将在AI技术加持下以全新面貌惊艳呈现,带你瞬间梦回童年!敦煌画院的古老壁画也在8K超高清全真还原技术下纤毫毕现;现场你还能看到当下最炙手可热的全球首款量产的“三折屏”手机等最新科技产品齐齐亮相。在这一文化艺术的聚集地,自然也少不了时下最火热的国产3A游戏巨制《黑神话·悟空》,游戏爱好者可以在BOE(京东方)赋能的110英寸超大尺寸IP定制电视上亲眼感受媲美OLED的顶级画质,亲身体验288Hz极致超高刷新率带来的酣畅淋漓。
你还在等什么?这个十一假期,让我们相约金秋时节的上海北外滩,一起打卡这场顶级的光影科技盛会,开启令人期待的美好科技体验吧!
收起阅读 »三方接口不动声色将http改为了https,于是开启了我痛苦的一天
早上刚来,就看到仓库那边不停发消息说,我们的某个功能用不了了。赶紧放下早餐加紧看。
原来是调的一个三方接口报错了:
javax.net.ssl.SSLHandshakeException: sun.security.validator.ValidatorException: PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target
at sun.security.ssl.Alert.createSSLException(Alert.java:131)
at sun.security.ssl.TransportContext.fatal(TransportContext.java:353)
at sun.security.ssl.TransportContext.fatal(TransportContext.java:296)
at sun.security.ssl.TransportContext.fatal(TransportContext.java:291)
at sun.security.ssl.CertificateMessage$T12CertificateConsumer.checkServerCerts(CertificateMessage.java:652)
at sun.security.ssl.CertificateMessage$T12CertificateConsumer.onCertificate(CertificateMessage.java:471)
at sun.security.ssl.CertificateMessage$T12CertificateConsumer.consume(CertificateMessage.java:367)
at sun.security.ssl.SSLHandshake.consume(SSLHandshake.java:376)
at sun.security.ssl.HandshakeContext.dispatch(HandshakeContext.java:444)
at sun.security.ssl.HandshakeContext.dispatch(HandshakeContext.java:422)
at sun.security.ssl.TransportContext.dispatch(TransportContext.java:183)
at sun.security.ssl.SSLTransport.decode(SSLTransport.java:154)
at sun.security.ssl.SSLSocketImpl.decode(SSLSocketImpl.java:1279)
at sun.security.ssl.SSLSocketImpl.readHandshakeRecord(SSLSocketImpl.java:1188)
at sun.security.ssl.SSLSocketImpl.startHandshake(SSLSocketImpl.java:401)
at sun.security.ssl.SSLSocketImpl.startHandshake(SSLSocketImpl.java:373)
查看原因:
由于JVM默认信任证书不包含该目标网站的SSL证书,导致无法建立有效的信任链接。
奥...原来是他们把接口从http改为了https,导致我们获取数据报错了。再看看他们的证书,奥...新的。
好了,看看我们的逻辑,这其实是一个获取对方生成的PDF文件的接口
PdfReader pdfReader = new PdfReader(url);
url就是他们给的链接,是这行代码报的错。这时候,开始研究,在网上扒拉,找到了初版方案
尝试1
写一个程序专门获取安全证书,这代码有点长,全贴出来影响阅读。我给扔我hithub上了github.com/lukezhao6/I… 将这个文件贴到本地,执行javac InstallCert.java
将其进行编译
编译完长这样:
然后再执行java InstallCert http://www.baidu.com
(这里我们用百度举例子,实际填写的就是你想要获取证书的目标网站)
报错不用怕,因为它会去检查目标服务器的证书,如果出现了SSLException,表示证书可能存在问题,这时候会把异常信息打印出来。
在生成的时候需要输入一个1
这样,我们需要的证书文件就生成好了
这时候,将它放入我们本地的 jdk的lib\security文件夹内就行了
重启,这时候访问是没有问题了。阶段性胜利。
但是,但是。一顿操作下来,对于测试环境的docker,还有生产环境貌似不能这么操作。 放这个证书文件比较费事。
那就只能另辟蹊径了。
尝试2
搜到了,还有两种方案。
1.通过
System.setProperty("javax.net.ssl.trustStore", "你的jssecacerts证书路径");
2.程序启动命令
-Djavax.net.ssl.trustStore=你的jssecacerts证书路径 -Djavax.net.ssl.trustStorePassword=changeit
我尝试了第一种,System.setProperty可以成功,但是读不到文件,权限什么的都是ok的。
检查了蛮多地方
- 路径格式问题
- 文件是否存在
- 文件权限
- 信任库密码
- 系统属性优先级
貌似都是没问题的,但肯定又是有问题的,因为没起作用。但是想着这样的接口有4个,万一哪天其他三个也改了,我又得来一遍。所以就算研究出来了,还是不能稳坐钓鱼台。有没有一了百了的方法嘞。
尝试3
还真找到了:这个错是因为对方网站的证书不被java信任么,那咱不校验了,直接全部信任。这样就算其他接口改了,咱也不愁。而且这个就是获取pdf,貌似安全性没那么重。那就开搞。
代码贴在了下方,上边的大概都能看懂吧,下方的我加了注释。
URL console = new URL(url);
HttpURLConnection conn = (HttpURLConnection) console.openConnection();
if (conn instanceof HttpsURLConnection) {
SSLContext sc = SSLContext.getInstance("SSL");
sc.init(null, new TrustManager[]{new TrustAnyTrustManager()}, new java.security.SecureRandom());
((HttpsURLConnection) conn).setSSLSocketFactory(sc.getSocketFactory());
((HttpsURLConnection) conn).setHostnameVerifier(new TrustAnyHostnameVerifier());
}
conn.connect();
InputStream inputStream = conn.getInputStream();
PdfReader pdfReader = new PdfReader(inputStream);
inputStream.close();
conn.disconnect();
private static class TrustAnyTrustManager implements X509TrustManager {
//这个方法用于验证客户端的证书。在这里,方法体为空,表示不对客户端提供的证书进行任何验证。
public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
}
//这个方法用于验证服务器的证书。同样,方法体为空,表示不对服务器提供的证书进行任何验证。
public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
}
//这个方法返回一个信任的证书数组。在这里,返回空数组,表示不信任任何证书,也就是对所有证书都不做任何信任验证。
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[]{};
}
}
//这个方法用于验证主机名是否可信。在这里,无论传入的主机名是什么,方法始终返回 true,表示信任任何主机名。这就意味着对于 SSL 连接,不会对主机名进行真实的验证,而是始终接受所有主机名。
private static class TrustAnyHostnameVerifier implements HostnameVerifier {
public boolean verify(String hostname, SSLSession session) {
return true;
}
}
解决了解决了,这样改算是个比较不错的方案了吧。
来源:juejin.cn/post/7362587412066893834
不是,哥们,谁教你这样处理生产问题的?
你好呀,我是歪歪。
最近遇到一个生产问题,我负责的一个服务触发了内存使用率预警,收到预警的时候我去看了内存使用率已经到了 80%,看了一眼 GC 又发现还没有触发 FullGC,一次都没有。
基于这个现象,当时推测有两种可能,一种是内存溢出,一种是内存泄漏。
好,假设现在是面试,面试官目前就给了这点信息,他问你到底是溢出还是泄漏,你怎么回答?
在回答之前,我们得现明确啥是溢出,啥情况又是泄漏。
- 内存溢出(OutOfMemoryError):内存溢出指的是程序请求的内存超出了 JVM 当前允许的最大内存容量。当 JVM 试图为一个对象分配内存时,如果当前可用的堆内存不足以满足需求,就会抛出 java.lang.OutOfMemoryError 异常。这通常是因为堆空间太小或者由于某些原因导致堆空间被占满。
- 内存泄漏 (Memory Leak):内存泄漏是指不再使用的内存空间没有被释放,导致这部分内存无法再次被使用。虽然内存泄漏不会立即导致程序崩溃,但它会逐渐消耗可用内存,最终可能导致内存溢出。
虽然都与内存相关,但它们发生的时机和影响有所不同。内存溢出通常发生在程序运行时,当数据结构的大小超过预设限制时,常见的情况是你要分配一个大对象,比如一次从数据中查到了过多的数据。
而内存泄漏和“过多”关系不大,是一个细水长流的过程,一次内存泄漏的影响可能微乎其微,但随着时间推移,多次内存泄漏累积起来,最终可能导致内存溢出。
概念就是这个概念,这两个玩意经常被大家搞混,所以多嘴提一下。
概念明确了,回到最开始这个问题,你怎么回答?
你回答不了。
因为这些信息太不完整了,所以你回答不了。
面试的时候面试官就喜欢出这种全是错误选项的题目来迷惑你,摸摸你的底子到底怎么样。
首先,为什么不能判断,是因为前面说了:一次 FullGC 都没有。
虽然现在内存使用率已经到 80% 了,万一一次 FullGC 之后,内存使用率又下去了呢,说明程序没有任何问题。
如果没有下去,说明大概率是内存溢出了,需要去代码里面找哪里分配了大对象了。
那如果下去了,能说明一定没有内存泄漏吗?
也不能,因为前面又说了:内存泄漏是一个细水长流的过程。
关于内存溢出,如果监控手段齐全到位的话,你就记住左边这个走势图:
一个缓慢的持续上升的内存趋势图, 最后疯狂触发 GC,但是并没有内存被回收,最后程序直接崩掉。
内存泄漏,一眼定真假。
这个图来自我去年写的这篇文章:《虽然是我遇到的一个棘手的生产问题,但是我写出来之后,就是你的了。》
里面就是描述了一个内存泄漏的问题,通过分析 Dump 文件的方式,最终成功定位到泄漏点,修复代码。
一个不论多么复杂的内存泄漏问题,处理起来都是有方法论的。
不过就是 Dump 文件分析、工具的使用以及足够的耐心和些许的运气罢了。
所以我不打算赘述这些东西了,我想要分享的是我这次是怎么对应文章开始说的内存预警的。
我的处理方式就是:重启服务。
是的,常规来说都是会保留现场,然后重启服务。但是我的处理方式是:直接执行重启服务的预案。没有后续动作了。
我当时脑子里面的考虑大概是这样的。
首先,这个服务是一个边缘服务,它所承载的数据量不多,其业务已经超过一年多没有新增,存量数据正在慢慢的消亡。代码近一两年没啥改动,只有一些升级 jar 包,日志埋点这类的横向改造。
其次,我看了一下这个服务已经有超过四个月没有重启过了,这期间没有任何突发流量,每天处理的数据呈递减趋势,内存走势确实是一个缓慢上升的过程,我初步怀疑是有内存泄漏。
然后,这个服务是我从别的团队那边接手的一个服务,基于前一点,业务正在消亡这个因素,我也只是知道大概的功能,并不知道内部的细节,所以由于对系统的熟悉度不够,如果要定位问题,会较为困难。
最后,基于公司制度,虽然我知道应该怎么去排查问题,命令和工具我都会使用,但是我作为开发人员是没有权限使用运维人员的各类排查工具和排查命令的,所以如果要定位问题,我必须请求协调一个运维同事帮忙。
于是,在心里默默的盘算了一下投入产出比,我决定直接重启服务,不去定位问题。
按照目前的频率,程序正常运行四五个月后可能会触发内存预警,那么大不了就每隔三个月重启一次服务嘛,重启一次只需要 30s。一年按照重启 4 次算,也就是才 2 分钟。
这个业务我们就算它要五年后才彻底消亡,那么也就才 10 分钟而已。
如果我要去定位到底是不是内存泄露,到底在哪儿泄露的,结合我对于系统的熟悉程度和公司必须有的流程,这一波时间消耗,少说点,加起来得三五个工作日吧。
10 分钟和三五个工作日,这投入产出比,该选哪个,一目了然了吧?
我分享这个事情的目的,其实就是想说明我在这个事情上领悟到的一个点:在工作中,你遇到的问题,不是每一个都必须被解决的,也可以选择绕过问题,只要最终结果是好的就行。
如果我们抛开其他因素,只是从程序员的本职工作来看,那么遇到诸如内存泄漏的问题的时候,就是应该去定位问题、解决问题。
但是在职场中,其实还需要结合实际情况,进行分析。
什么是实际情况呢?
我前面列出来的那个“首先,其次,然后,最后”,就是我这个问题在技术之外的实际情况。
这些实际情况,让我决定不用去定位这个问题。
这也不是逃避问题,这是权衡利弊之后的最佳选择。
同样是一天的时间,我可以去定位这个“重启就能解决”的问题,也可以去做其他的更有价值事情,敲一些业务价值更大的代码。
这个是需要去权衡的,一个重要的衡量标准就是前面说的:投入产出比。
关于“不是所有的问题都必须被解决的,也可以选择绕过问题”这个事情,我再给你举一个我遇到的真实的例子。
几年前,我们团队遇到一个问题,我们使用的 RPC 框架是 Dubbo,有几个核心服务在投产期间滚动发布的时候,流量老是弄不干净,导致服务已经下线了,上游系统还在调用。
当时安排我去调研一下解决方案。
其实这就是一个优雅下线的问题,但是当时资历尚浅,我认真研究了一段时间,确实没研究出问题的根本解决方案。
后来我们给出的解决方案就是做一个容错机制,如果投产期间有因为流量不干净的问题导致请求处理失败的,我们把这些数据记录下来,然后等到投产完成后再进行重发。
没有解决根本问题,选择绕过了问题,但是从最终结果上看,问题是被解决了。
再后来,我们搭建了双中心。投产之前,A,B 中心都有流量,每次投产的时候,先把所有流量从 A 中心切到 B 中心去,在 A 中心没有任何流量的情况下,进行服务投产。B 中心反之。
这样,从投产流程上就规避了“流量老是弄不干净”的问题,因为投产的时候对应的服务已经没有在途流量了,不需要考虑优雅的问题了,从而规避了优雅下线的问题。
问题还是没有被解决,但是问题被彻底绕过。
最后,再举一个我在知乎上看到的一个回答,和我想要表达的观点,有异曲同工之妙:
http://www.zhihu.com/question/63…
这个回答下面的评论也很有意思,有兴趣的可以去翻一下,我截取两个我觉得有意思的:
在职场上,甚至在生活中,一个虽然没有解决方案但是可以被绕过的问题,我认为不是问题。
但是这个也得分情况,不是所有问题都能绕开的,假如是一个关键服务,那肯定不能置之不理,硬着头皮也得上。
关键是,我在职场上和生活中遇到过好多人,遇到问题的时候,似乎只会硬着头皮往上冲。
只会硬着头皮往上冲和知道什么时候应该硬着头皮往上冲,是两种截然不同的职场阶段。
所以有时候,遇到问题的时候,不要硬上,也让头皮休息一下,看看能不能绕过去。
来源:juejin.cn/post/7417842116506058771
BOE(京东方)携故宫博物院举办2024“照亮成长路”公益项目落地仪式以创新科技赋能教育可持续发展
2024年9月20日,BOE(京东方)“照亮成长路”智慧教室落成暨百堂故宫传统文化公益课山西活动落地仪式在山西省太原市娄烦县实验小学隆重举行。自“照亮成长路”教育公益项目正式设立以来,BOE(京东方)持续以创新科技赋能偏远地区的教育升级,随着2024年捐赠的23间智慧教室全面圆满竣工并正式投入使用,BOE(京东方)捐建的智慧教室总数已达126间,它们不仅代表了教育创新、文化传承与先进技术的融合,也开启了BOE(京东方)面向新三十年发展征程、积极践行企业社会责任的新起点。
故宫博物院作为“照亮成长路”公益项目的重要合作伙伴,一直致力于促进中华优秀传统文化在青少年中的普及与传播。尤其是与京东方科技集团共同发起的“百堂故宫传统文化公益课”项目是故宫博物院教育推广的又一次重要实践。项目启动后近一年间已为26所学校,2万余名学生送去了400余场线上公益课程。此次落地的山西娄烦实验小学和静乐君宇中学也成为该计划的线下落地试点学校。活动现场,娄烦县委副书记、县长景博,娄烦县委常委、常务副县长任瑛,娄烦县委常委、副县长李学斌,静乐县副县长许龙平,中国乡村发展基金会副秘书长丁亚冬,故宫博物院副院长朱鸿文,京东方科技集团执行副总裁、艺云科技董事长姚项军,京东方科技集团副总裁、首席品牌官司达等出席了本次仪式,共同见证这一重要时刻。
在活动现场,京东方科技集团执行副总裁姚项军表示:“教育数字化是推进教育现代化的关键力量。BOE(京东方)充分发挥自身在物联网创新领域的专长,通过首创的多项类纸护眼显示技术,制定的低蓝光健康显示技术国际标准,推出了一系列智慧校园产品与服务;同时还充分发挥企业产业优势,开发科学与工程教育产品,用科学创新实践支持做公益。BOE(京东方)将携手各界同仁开启‘照亮成长路’教育公益项目的下一个十年篇章,继续推动教育与科技的深度融合,迈向一个更加智慧、更加光明、更加美好的未来!”
中国乡村发展基金会副秘书长丁亚冬在致辞中表示:“BOE(京东方)是我们多年的合作伙伴,持续关注乡村数字化教育的发展,携手实施的‘照亮成长路’教育公益项目已改造完成126间智慧教室,用科技力量助力消弭教育鸿沟,照亮乡村学生的成长之路。未来,我们将继续与BOE(京东方)、故宫博物院及社会各界通力合作,充分发挥各自优势,着力推进教育公平,促进教育均衡发展,为助力全面乡村振兴做出不懈努力。”
故宫博物院副院长朱鸿文在致辞中表示:“故宫博物院作为中华民族五千多年文明的重要承载者、中华优秀传统文化的汇聚地,始终将传承弘扬中华优秀传统文化作为己任,不断探索创新,希望通过丰富多彩的博物馆教育项目,将中华优秀传统文化传递给广大观众。很高兴能够携手京东方这样的科技企业通过传统文化振兴乡村发展,在以科技赋能偏远地区提升数字化水平的基础上,融入传统文化教育,增强师生文化自信,建设文化强国,助力中华民族伟大复兴。”故宫博物院也在活动中向学校的全体师生赠送了《我要去故宫》系列图书。
2024 BOE(京东方)“照亮成长路”教育公益项目的成功落地与本次活动的顺利举办,得益于山西省娄烦县政府和故宫博物院的大力支持,也离不开中国乡村发展基金会在项目推进过程中的通力合作。此次活动过程中,各方领导嘉宾围绕科技文化在教育领域的融合应用、智慧教育的未来趋势以及公益事业的长足发展进行了深入探讨。娄烦县政府相关领导作为代表对BOE(京东方)“百所校园”的公益新里程表示肯定与祝贺,并祝愿“照亮成长路”及“百堂故宫传统文化公益课”在未来能够惠及更多校园,助力更多偏远地区师生了解优秀传统文化、体验智慧教育。
作为一家全球化的科技公司,BOE(京东方)坚持Green+、Innovation+、Community+可持续发展理念,在教育、文化、健康等领域积极开展公益活动,通过引领绿色永续发展、持续驱动科技创新、赋能整个产业和社会。其中,“照亮成长路”是BOE(京东方)2014年启动的教育公益项目,通过智慧教室建设、教育资源融合、教师赋能培训计划等,携手社会各界力量,将技术融入社区公益发展与乡村振兴事业。目前,BOE(京东方)已在全国8大省市地区建成126间智慧教室,为63500余名师生提供软硬融合的智慧教育解决方案和教师赋能计划,切实帮助偏远地区学生群体获得更优质的教育和成长机会,在缩小城乡间数字差距、推动区域教育现代化、促进社会全面进步方面彰显了重要价值。
作为“照亮成长路”的特色项目,“百堂故宫传统文化公益课”让偏远地区的孩子能够通过BOE(京东方)的智慧教育创新技术跨越时间和空间的限制,近距离感受故宫的魅力,了解中国传统文化的精髓。接下来,更多课程将陆续在更为广泛的偏远地区展开,到2025年故宫博物院建院百年之际,双方将联手在北京故宫博物院为孩子们带来第100堂特别课程。
未来,BOE(京东方)与故宫博物院也将继续携手,以科技和文化双重赋能教育,让知识的光芒照亮每一个孩子的未来。
收起阅读 »独家授权!广东盈世获网易邮箱反垃圾服务的独家授权,邮件反垃圾更全面
近日,广东盈世计算机科技有限公司(以下简称“Coremail”)成功获得了网易(杭州)网络有限公司(以下简称“网易”)授予的网易邮箱反垃圾服务独家使用权。这一授权使得Coremail能够在邮件安全产品上运用网易邮箱反垃圾服务,进一步强化其反垃圾能力,邮件反垃圾更全面。
凭借24年的反垃圾反钓鱼技术沉淀,Coremail邮件安全致力于提供一站式邮件安全解决方案,为用户提供安全、可靠的安全解决方案。而网易作为国内邮箱行业的佼佼者,拥有强大的技术实力和丰富的经验,其网易邮箱反垃圾服务更是享有盛誉。
通过合作,网易为Coremail提供Saas在线网关服务,进行进信和外发的在线反垃圾检测。Coremail邮件安全反垃圾服务将以自研反垃圾引擎为主,网易反垃圾服务为辅,以“双引擎”机制保障用户享有最高等级的邮件反垃圾服务。
此外,除网易自身、广州网易计算机系统有限公司及其关联公司外,Coremail是唯一被授权在服务期内独家使用网易邮箱反垃圾服务的公司。这一独家授权充分体现了网易对Coremail的高度认可和信任,同时也彰显了Coremail在邮件安全领域的卓越实力。
此次独家授权,为Coremail带来更多的技术优势和市场竞争优势,进一步巩固其在邮件安全领域的领先地位。同时,对于广大用户来说,这也意味着用户将能够享受到更加安全、高效的邮件安全服务。
未来,Coremail将继续秉持技术创新的精神,致力于为用户提供更优质、安全、智能的邮件安全服务。与此同时,Coremail也将与网易保持紧密的合作关系,为企业的邮件安全保驾护航。
收起阅读 »为什么2.01 变成了 2.00 ,1分钱的教训不可谓不深刻
前些日子,测试提过来一个bug,说下单价格应该是 2.01,但是在订单详情中展示了2.00元。我头嗡的一下子,艹,不会是因为double 的精度问题吧~
果不其然,经过排查代码,最终定位原因订单详情展示金额时,使用double 进行了金额转换,导致金额不准。
我马上排查核心购买和售后链路,发现涉及资金交易的地方没有问题,只有这一处问题,要不然这一口大锅非得扣我身上。
为什么 2.01 变成了 2.0
2.01等小数 在计算机中按照2进制补码存储时,存在除不尽,精度丢失的问题。 例如 2.01的补码为 000000010.009999999999999787
。正如十进制场景存在 1/3等无限小数问题,二进制场景也存在无限小数,所以一定会存在精度问题。
什么场景小数转换存在问题
for (int money = 0; money < 10000; money++) {
String valueYuan = String.format("%.2f", money * 1.0 / 100);
int value = (int) (Double.valueOf(valueYuan) * 100);
if (value != money) {
System.out.println(String.format("原值: %s, 现值:%s", money, value));
}
}
如上代码中,先将数字 除以 100,转为元, 精度为2位,然后将double 乘以100,转为int。 在除以、乘以两个操作后,精度出现丢失。
我把1-10000 的范围测试一遍,共有573个数字出现精度转换错误。 这个概率已经相当大了。
如何转换金额更安全?
Java 提供了BigDecimcal 专门处理精度更高的浮点数。简单封装一下代码,元使用String表示,分使用int表示。提供两个方法实现 元和分的 互转。
public static String change2Yuan(int money) {
BigDecimal base = BigDecimal.valueOf(money);
BigDecimal yuanBase = base.divide(new BigDecimal(100));
return yuanBase.setScale(2, BigDecimal.ROUND_HALF_UP).toString();
}
public static int change2Fen(String money) {
BigDecimal base = new BigDecimal(money);
BigDecimal fenBase = base.multiply(new BigDecimal(100));
return fenBase.setScale(0, BigDecimal.ROUND_HALF_UP).intValue();
}
测试
测试0-1 亿 的金额转换逻辑,均成功转换,不存在精度丢失。
int error = 0;
long time = System.currentTimeMillis();
for (int money = 0; money < 100000000; money++) {
String valueYuan = change2Yuan(money);
int value = change2Fen(valueYuan);
if (value != money) {
error++;
}
}
System.out.println(String.format("时间:%s", (System.currentTimeMillis() - time)));
System.out.println(error);
性能测试
网上很多人说使用 BigDecimcal 存在性能影响,但是我测试性能还是不错的。可能首次耗时略高,大约2ms
标题 | 耗时 |
---|---|
0-1亿 | 14.9 秒 |
0-100万 | 0.199秒 |
0-1万 | 0.59秒 |
0-100 | 0.004秒 |
0-1 | 0.002秒 |
总结
涉及金额转换的 地方,一定要小心处理,防止出现精度丢失问题。可以使用代码审查工具,查看代码中是否存在使用double 进行金额转换的代码, 同时提供 金额转换工具类。
来源:juejin.cn/post/7399985723673837577
我的 Electron 客户端也可以全量/增量更新了
前言
本文主要介绍 Electron
客户端应用的自动更新,包括全量和增量这两种方式。
全量更新: 运行新的安装包,安装目录所有资源覆盖式更新。
增量更新: 只更新修改的部分,通常是渲染进程和主进程文件。
本文并没有拿真实项目来举例子,而是起一个 新的项目 从 0 到 1 来实现自动更新。
如果已有的项目需要支持该功能,借鉴本文的主要步骤即可。
前置说明:
- 由于业务场景的限制,本文介绍的更新仅支持
Windows
操作系统,其余操作系统未作兼容处理。 - 更新流程全部由前端完成,不涉及到后端,没有轮询 更新的机制。
- 发布方式限制为
generic
,线上服务需要配置nginx
确保访问到资源文件。
准备工作
脚手架搭建项目
我们通过 electron-vite 快速搭建一个基于 Vite + React + TS
的 Electron
项目。
该模板已经包括了我们需要的核心第三方库:electron-builder
,electron-updater
。
前者是用来打包客户端程序的,后者是用来实现自动更新的。
在项目根目录下,已经自动生成了两份配置文件:electron-builder.yml
和 dev-app-update.yml
。
electron-builder.yml
该文件描述了一些 打包配置,更多信息可参考 官网。
在这些配置项中,publish
字段比较重要,因为它关系到更新源。
publish:
provider: generic // 使用一个通用的 HTTP 服务器作为更新源。
url: https://example.com/auto-updates // 存放安装包和描述文件的地址
provider
字段还有其他可选项,但是本文只介绍 generic
这种方式,即把安装包放在 HTTP 服务器里。
dev-app-update.yml
provider: generic
url: https://example.com/auto-updates
updaterCacheDirName: electron-update-demo-updater
其中,updaterCacheDirName
定义下载目录,也就是安装包存放的位置。全路径是C:\Users\用户名\AppData\Local\electron-update-demo-updater
,不配置则在C:\Users\用户名\AppData\Local
下自动创建文件夹,开发环境下为项目名
,生产环境下为项目名-updater
。
模拟服务器
我们直接运行 npm run build:win
,在默认 dist
文件夹下就出现了打包后的一些资源。
其实更新的基本思路就是对比版本号,即对比本地版本和 线上服务器 版本是否一致,若不一致则需要更新。
因此我们在开发调试的时候,需要起一个本地服务器,来模拟真实的情况。
新建一个文件夹 mockServer
,把打包后的 setup.exe
安装包和 latest.yml
文件粘贴进去,然后通过 serve
命令默认起了一个 http://localhose:3000
的本地服务器。
既然有了存放资源的本地地址,在开发调试的时候,我们更新下 dev-app-update.yml
文件的 url
字段,也就是修改为 http://localhose:3000
。
注:如果需要测试打包后的更新功能,需要同步修改打包配置文件。
全量更新
与主进程文件同级,创建 update.ts
文件,之后我们的更新逻辑将在这里展开。
import { autoUpdater } from 'electron-updater' //核心库
需要注意的是,在我们开发过程中,通过 npm run dev
起来的 Electron
程序其实不能算是打包后的状态。
你会发现在调用 autoUpdater
的一些方法会提示下面的错误:
Skip checkForUpdates because application is not packed and dev update config is not forced
因此我们需要在开发环境去做一些动作,并且手动指定 updateConfigPath
:
// update.ts
if (isDev) {
Object.defineProperty(app, 'isPackaged', {
get: () => true
})
autoUpdater.updateConfigPath = path.join(__dirname, '../../dev-app-update.yml')
}
核心对象 autoUpdater
有很多可以主动调用的方法,也有一些监听事件,同时还有一些属性可以设置。
这里只展示了本人项目场景所需的一些配置。
autoUpdater.autoDownload = false // 不允许自动下载更新
autoUpdater.allowDowngrade = true // 允许降级更新(应付回滚的情况)
autoUpdater.checkForUpdates()
autoUpdater.downloadUpdate()
autoUpdater.quitAndInstall()
autoUpdater.on('checking-for-update', () => {
console.log('开始检查更新')
})
autoUpdater.on('update-available', info => {
console.log('发现更新版本')
})
autoUpdater.on('update-not-available', info => {
console.log('不需要全量更新', info.version)
})
autoUpdater.on('download-progress', progressInfo => {
console.log('更新进度信息', progressInfo)
})
autoUpdater.on('update-downloaded', () => {
console.log('更新下载完成')
})
autoUpdater.on('error', errorMessage => {
console.log('更新时出错了', errorMessage)
})
在监听事件里,我们可以拿到下载新版本 整个生命周期 需要的一些信息。
// 新版本的版本信息(已筛选)
interface UpdateInfo {
readonly version: string;
releaseName?: string | null;
releaseNotes?: string | Array<ReleaseNoteInfo> | null;
releaseDate: string;
}
// 下载安装包的进度信息
interface ProgressInfo {
total: number;
delta: number;
transferred: number;
percent: number;
bytesPerSecond: number;
}
在写完这些基本的方法之后,我们需要决定 检验更新 的时机,一般是在应用程序真正启动之后,即 mainWindow
创建之后。
运行项目,预期会提示 不需要全量更新
,因为刚才复制到本地服务器的 latest.yml
文件里的版本信息与本地相同。修改 version
字段,重启项目,主进程就会提示有新版本需要更新了。
频繁启动应用太麻烦,除了应用初次启动时主进程主动帮我们检验更新外,还需要用户 手动触发 版本更新检测,此外,由于产品场景需要,当发现有新版本的时候,需要在 渲染进程 通知用户,包括版本更新时间和更新内容。因此我们需要加上主进程与渲染进程的 IPC通信
来实现这个功能。
其实需要的数据主进程都能够提供了,渲染进程具体的展示及交互方式就自由发挥了,这里简单展示下我做的效果。
1. 发现新版本
2. 无需更新
增量更新
为什么要这么做
其实全量更新是一种比较简单粗暴的更新方式,我没有花费太大的篇幅去介绍,基本上就是使用 autoUpdater
封装的一些方法,开发过程中更多的注意力则是放在了渲染进程的交互上。
此外,我们的更新交互用的是一种 用户感知 的方式,即更不更新是由用户自己决定的。而 静默更新 则是用户无感知,在监测到更新后不用去通知用户,而是自行在后台下载,在某个时刻执行安装的逻辑,这种方式往往是一键安装,不会弹出让用户去选择安装路径的窗口。接下去要介绍的增量更新其实是这种 用户无感知
的形式,只不过我们更新的不是整个应用程序。
由于产品迭代比较频繁,我们的业务方经常收到更新提示,意味着他们要经常手动更新应用,以便使用到最新的功能特性。众所周知,Electron
给前端开发提供了一个比较容易上手的客户端开发解决方案,但在享受 跨平台
等特性的同时,还得忍受 臃肿的安装包 。
带宽、流量在当前不是什么大问题,下载和安装的速度也挺快,但这频繁的下载覆盖一模一样的资源文件还是挺糟心的,代码改动量小时,全量更新完全没有必要。我们希望的是,开发更新了什么代码,应用程序就替换掉这部分代码就好了,这很优雅。在 我们的场景 中,这部分代码指的是 —— main、preload、renderer
,并不包括 dll
、第三方SDK
等资源。
网上有挺多种增量更新的 解决方案,例如:
- 通过
win.loadURL(一个线上地址)
实现,相当于就套了一层客户端的壳子,与加载的Web
端同步更新。这种方法对于简单应用来说是最省心的,但是也有一定的局限性,例如不能使用node
去操作一些底层的东西。 - 设置
asar
的归档方式,替换app.asar
或app.asar.unpack
来实现。但后者在我实践过程中存在文件路径不存在的问题。 - 禁用
asar
归档,解压渲染进程压缩包后实现替换。简单尝试过,首次执行安装包的时候写入文件很慢,不知道是不是这个方式引起的。 - 欢迎补充。
本文我们采用较普遍的 替换asar 来实现。
优化 app.asar 体积
asar
是Electron
提供的一种将多个文件合并成一个文件的类tar
风格的归档格式,不仅可以缓解Windows
下路径名过长的问题, 还能够略微加快一下require
的速度, 并且可以隐藏你的源代码。(并非完全隐藏,有方法可以找出来)
Electron
应用程序启动的时候,会读取 app.asar.unpacked
目录中的内容合并到 app.asar
的目录中进行加载。在此之前我们已经打包了一份 Demo 项目,安装到本地后,我们可以根据安装路径找到 app.asar
这个文件。
例如:D:\你的安装路径\electron-update-demo\resources
在这个 Demo 项目中,脚手架生成的模板将图标剥离出来,因此出现了一个 app.asar.unpacked
文件夹。我们不难发现,app.asar
这个文件竟然有 66 MB,再回过头看我们真正的代码文件,甚至才 1 MB !那么到底是什么原因导致的这个文件这么大呢,刚才提到了,asar
其实是一种压缩格式,因此我们只要解压看看就知道了。
npm i -g asar // 全局安装
asar e app.asar folder // 解压到folder文件夹
解压后我们不难发现,out
文件夹里的才是我们打包出来的资源,罪魁祸首竟然是 node_modules
,足足有 62.3 MB。
查阅资料得知,Electron
在打包的时候,会把 dependencies 字段里的依赖也一起打包进去。而我们的渲染进程是用 React
开发的,这些第三方依赖早就通过 Vite
等打包工具打到资源文件里了,根本不需要再打包一次,不然我们重新下载的 app.asar
文件还是很大的,因此需要尽可能减少体积。
优化应用程序体积 == 减少 node_modules
文件夹的大小 == 减少需要打包的依赖数量 == 减少 dependencies
中的依赖。
1. 移除 dependencies
最开始我想的是把 package.json
中的 dependencies
全都移到 devDependencies
,打包后的体积果然减小了,但是在测试过程中会发现某些功能依赖包会缺失,比如 @electron/remote
。因此在修改这部分代码的时候,需要特别留意,哪些依赖是需要保留下来的。
由于不想影响 package.json
的版本结构,我只是在写了一个脚本,在 npm i
之后,执行打包命令前修改 devDependencies
就好了。
2. 双 package.json 结构
这是 electron-builder
官网上看到的一种技巧,传送门, 创建 app
文件夹,再创建第二个 package.json
,在该文件中配置我们应用的名称、版本、主进程入口文件等信息。这样一来,electron-builder
在打包时会以该文件夹作为打包的根文件夹,即只会打包这个文件夹下的文件。
但是我原项目的主进程和预加载脚本也放在这,尝试修改后出现打包错误的情况,感兴趣的可以试试。
这是我们优化之后的结果,1.32 MB,后面只需要替换这个文件就实现了版本的更新。
校验增量更新
全量更新的校验逻辑是第三方库已经封装好了的,那么对于增量更新,就得由我们自己来实现了。
首先明确一下 校验的时机,package.json
的 version
字段表示应用程序的版本,用于全量更新的判断,那也就意味着,当这个字段保持不变的时候,会触发上文提到的 update-not-available
事件。所以我们可以在这个事件的回调函数里来进行校验。
autoUpdater.on('update-not-available', info => { console.log('不需要全量更新', info.version) })
然后就是 如何校验,我们回过头来看 electron-builder
的打包配置,在 releaseInfo
字段里描述了发行版本的一些信息,目前我们用到了 releaseNotes
来存储更新日志,查阅官网得知还有个 releaseName
好像对于我们而言是没什么用的,那么我们就可以把它作为增量更新的 热版本号。(但其实 官网 配置项还有个 vendor
字段,可是我在用的时候打包会报错,可能是版本的问题,感兴趣的可以试试。)
每次发布新版本的时候,只要不是 Electron自身版本变化
等重大更新,我们都可以通过修改 releaseInfo
的 releaseName
来发布我们的热版本号。现在我们已经有了线上的版本,那 本地热版本号 该如何获取呢。这里我们在每次热更新完成之后,就会把最新的版本号存储在本地的一个配置文件中,通常是 userData
文件夹,用于存储我们应用程序的用户信息,即使程序卸载 了也不会影响里面的资源。在 Windows
下,路径如下:
C:\Users\用户名\AppData\Roaming\electron-update-demo
。
因此,整个 校验流程 就是,在打开程序的时候,autoUpdater
触发 update-not-available
事件,拿到线上 latest.yml
描述的 releaseName
作为热版本号,与本地配置文件(我们命名为 config.json
)里存储的热版本号(我们命名为 hotVersion
)进行对比,若不同就去下载最新的 app.asar
文件。
我们使用 electron-log
来记录日志,代码如下所示。
// 本地配置文件
const localConfigFile = path.join(app.getPath('userData'), 'config.json')
const checkHotVersion = (latestHotVersion: string): boolean => {
let needDownload = false
if (!fs.existsSync(localConfigFile)) {
fs.writeFileSync(localConfigFile, '{}', 'utf8')
log.info('配置文件不存在,已自动创建')
} else {
log.info('监测到配置文件')
}
try {
const configStr = fs.readFileSync(localConfigFile, 'utf8')
const configData = JSON.parse(configStr || '{}')
// 对比版本号
if (latestHotVersion !== configData.hotVersion) {
log.info('当前版本不是最新热更版本')
needDownload = true
} else {
log.info('当前为最新版本')
}
} catch (error) {
log.error('读取配置文件时出错', error)
}
return needDownload
}
下载增量更新包
通过刚才的校验,我们已经知道了什么时候该去下载增量更新包。
在开发调试的时候,我们可以把新版本的 app.asar
也放到起了本地服务器的 mockServer
文件夹里来模拟真实的情况。有很多方式可以去下载文件,这里我用了 nodejs
的 http
模块去实现,如果是 https
的需要引用 https
模块。
下载到本地的时候,我是放在了与 app.asar
同级目录的 resources
文件夹,我们不能直接覆盖原文件。一是因为进程权限占用的问题,二是为了容错,所以我们需要以另一个名字来命名下载后的文件(这里我们用的是 app.asar-temp
),也就不需要去备份原文件了,代码如下。
const resourcePath = isDev
? path.join('D:\\测试\\electron-update-demo\\resources')
: process.resourcesPath
const localAsarTemp = path.join(resourcePath, 'app.asar-temp')
const asarUrl = 'http://localhost:3000/app.asar'
downloadAsar() {
const fileStream = fs.createWriteStream(localAsarTemp)
http.get(asarUrl, res => {
res.pipe(fileStream)
fileStream
.on('finish', () => {
log.info('asar下载完成')
})
.on('error', error => {
// 删除文件
fs.unlink(localAsarTemp, () => {
log.error('下载出现异常,已中断', error)
})
})
})
}
因此,我们的流程更新为:发现新版本后,下载最新的 app.asar
到 resources
目录,并重命名为 app.asar-temp
。这个时候我们启动项目进行调试,找到记录在本地的日志—— C:\Users\用户名\AppData\Roaming\electron-update-demo\logs
,会有以下的记录:
[2024-09-20 13:49:22.456] [info] 监测到配置文件
[2024-09-20 13:49:22.462] [info] 当前版本不是最新热更版本
[2024-09-20 13:49:23.206] [info] asar下载完成
在看看项目 resources
文件夹,多了一个 app.asar-temp
文件。
至此,下载这一步已经完成了,但这样还会有一个问题,因为文件名没有加上版本号,也没有重复性校验,倘若一直没有更新,那么每次启动应用需要增量更新的时候都需要再次下载。
替换 app.asar 文件
好了,新文件也有了,接下来就是直接替换掉老文件就可以了,但是 替换的时机 很重要。
在 Windows
操作系统下,直接替换 app.asar
会提示程序被占用而导致失败,所以我们应该在程序关闭的时候实现替换。
- 进入应用,下载新版本,自动关闭应用,替换资源,重启应用。
- 进入应用,下载新版本,不影响使用,用户 手动关闭 后替换,下次启动 就是新版本了。
我们有上面两种方案,最终采用了 方案2
。
在主进程监听 app.on('quit')
事件,在应用退出的时候,判断 app.asar
和 app.asar-temp
是否同时存在,若同时存在,就去替换。这里替换不能直接用 nodejs
在主进程里去写,因为主进程执行的时候,资源还是被占用的。因此我们需要额外起一个 子进程 去做。
nodejs
可以通过 spawn
、exec
等方法创建一个子进程。子进程可以执行一些自定义的命令,我们并没有使用子进程去执行 nodejs
,因为业务方的机器上不一定有这个环境,而是采用了启动 exe
可执行文件的方式。可能有人问为什么不直接运行 .bat
批处理文件,因为我调试关闭应用的时候,会有一个 命令框闪烁 的现象,这不太优雅,尽管已经设置 spawn
的 windowsHide: true
。
那么如何获得这个 exe
可执行文件呢,其实是通过 bat
文件去编译的,命令如下:
@echo off
timeout /T 1 /NOBREAK
del /f /q /a %1\app.asar
ren %1\app.asar-temp app.asar
我们加了延时,等待父进程完全关闭后,再去执行后续命令。其中 %1
为运行脚本传入的参数,在我们的场景里就是 resources
文件夹的地址,因为每个人的安装地址应该是不一样的,所以需要作为参数传进去。
转换文件的工具一开始用的是 Bat To Exe Converter
下载地址,但是在实际使用的过程中,我的电脑竟然把转换后的 exe
文件 识别为病毒,但在其他同事电脑上没有这种表现,这肯定就不行了,最后还是同事使用 python
帮我转换生成了一份可用的文件(replace.exe
)。
这里我们可以选择不同的方式把 replace.exe
存放到 用户目录 上,要么是从线上服务器下载,要么是安装的时候就已经存放在本地了。我们选择了后者,这需要修改 electron-builder
打包配置,指定 asarUnpack
, 这样就会存放在 app.asar.unpacked
文件夹中,不经常修改的文件都可以放在这里,不会随着增量更新而替换掉。
有了这个替换脚本之后,开始编写子进程相关的代码。
import { spawn } from 'child_process'
cosnt localExePath = path.join(resourcePath, 'app.asar.unpacked/resources/replace.exe')
replaceAsar() {
if (fs.existsSync(localAsar) && fs.existsSync(localAsarTemp)) {
const command = `${localExePath} ${resourcePath}`
log.info(command)
const logPath = app.getPath('logs')
const childOut = fs.openSync(path.join(logPath, './out.log'), 'a')
const childErr = fs.openSync(path.join(logPath, './err.log'), 'a')
const child = spawn(`"${localExePath}"`, [`"${resourcePath}"`], {
detached: true, // 允许子进程独立
shell: true,
stdio: ['ignore', childOut, childErr]
})
child.on('spawn', () => log.info('子进程触发'))
child.on('error', err => log.error('child error', err))
child.on('close', code => log.error('child close', code))
child.on('exit', code => log.error('child exit', code))
child.stdout?.on('data', data => log.info('stdout', data))
child.stderr?.on('data', data => log.info('stderr', data))
child.unref()
}
}
app.on('quit', () => {
replaceAsar()
})
在这块代码中,创建子进程的配置项比较重要,尤其是路径相关的。因为用户安装路径是不同的,可能会存在 文件夹名含有空格
的情况,比如 Program Files
,这会导致在执行的时候将空格识别为分隔符,导致命令执行失败,因此需要加上 双引号。此外,detached: true
可以让子进程独立出来,也就是父进程退出后可以执行,而shell: true
可以将路径名作为参数传过去。
const child = spawn(`"${localExePath}"`, [`"${resourcePath}"`], {
detached: true,
shell: true,
stdio: ['ignore', childOut, childErr]
})
但这块有个 疑惑,为什么我的 close
、exit
以及 stdout
都没有触发,以至于不能拿到命令是否执行成功的最终结果,了解的同学可以评论区交流一下。
至此,在关闭应用之后,app.asar
就已经被替换为最新版本了,还差最后一步,更新本地配置文件里的 hotVersion
,防止下次又去下载更新包了。
child.on('spawn', () => {
log.info('子进程触发')
updateHotVersion()
})
updateHotVersion() {
fs.writeFileSync(localConfigFile, JSON.stringify({ hotVersion }, null, 2))
}
增量更新日志提示
既然之前提到的全量更新有日志说明,那增量更新可以不用,也应该需要具备这个能力,不然我们神不知鬼不觉帮用户更新了,用户都不知道 新在哪里。
至于更新内容,我们可以复用 releaseInfo
的 releaseNotes
字段,把更新日志写在这里,增量更新完成后展现给用户就好了。
但是总不能每次打开都展示,这里需要用户有个交互,比如点击 知道了
按钮,或者关闭 Modal
后,把当前的热更新版本号保存在本地配置文件,记录为 logVersion
。在下次打开程序或者校验更新的时候,我们判断一下 logVersion === hotVersion
,若不同,再去提示更新日志。
日志版本 校验和修改的代码如下所示:
checkLogVersion() {
let needShowLog = false
try {
const configStr = fs.readFileSync(localConfigFile, 'utf8')
const configData = JSON.parse(configStr || '{}')
const { hotVersion, logVersion } = configData
if (hotVersion !== logVersion) {
log.info('日志版本与当前热更新版本不同,需要提示更新日志')
needShowLog = true
} else {
log.info('日志已是最新版本,无需提示更新日志')
}
} catch (error) {
log.error('读取配置文件失败', error)
}
return needShowLog
}
updateLogVersion() {
try {
const configStr = fs.readFileSync(localConfigFile, 'utf8')
const configData = JSON.parse(configStr || '{}')
const { hotVersion } = configData
fs.writeFileSync(localConfigFile, JSON.stringify({ hotVersion, logVersion: hotVersion }, null, 2))
log.info('日志版本已更新')
} catch (error) {
log.error('读取配置文件失败', error)
}
}
读取 config.json
文件的方法自行封装一下,我这里就重复使用了。主进程 ipcMain.on
监听一下用户传递过来的事件,再去调用 updateLogVersion
即可,渲染进程效果如下:
提示增量更新日志
点击 知道了
后,再次打开应用后就不会提示日志了,因为本地配置文件已经被修改了。
当然可能也有这么个场景,开发就改了一点点无关功能的代码,无需向用户展示日志,我们只需要加一个判断 releaseNotes
是否为空的逻辑就好了,也做到了 静默更新。
小结
不足之处
本文提出的增量更新方案应该算是比较简单的,可能并不适用于所有的场景,考虑也不够全面,例如:
dll
、第三方SDK
等资源的更新。- 增量更新失败后应该通过全量更新 兜底。
- 用户在使用过程中发布新版本,得等到 第二次打开 才能用到新版本。
流程图
针对本文的解决方案,我简单画了一个 流程图。
参考文章
网上其实有不少关于 Electron
自动更新的文章,在做这个需求之前也是浏览了好多,但也有些没仔细看完,所以就躺在我的标签页没有关闭,在这罗列出来,也当作收藏了。
写这篇文章的目的也是为了记录和复盘,如果能为你提供一些思路那就再好不过了。
鸣谢:
来源:juejin.cn/post/7416311252580352034
国产语言MoonBit崛起,比Rust快9倍,比GO快35倍
在这个技术日新月异的时代,编程语言的更新换代似乎已经成为了家常便饭。但国产语言却一直不温不火,对于程序员来说,拥有一款属于我们自己的语言,一直是大家梦寐以求的事情。
如果我说有一种国产编程语言,它的运行速度比Rust快9倍,比GO快35倍,你会相信吗?
这不是天方夜谭,最近,被称为“国产编程语引领者”的MoonBit
(月兔),宣布正式进入Beta预览版本阶段啦!
一听月兔这名字起得挺中式的。
一、初识MoonBit
MoonBit
是由粤港澳大湾区数字经济研究院(福田)研发的全新编程语言。
① 官网
② 目前状态
MoonBit
是2022年推出的国产编程语言,并在2023年8月18日海外发布后,立即获得国际技术社区的广泛关注。
经过一年多的高速迭代,MoonBit
推出了beta预览版。
MoonBit
目前处于 Beta-Preview
阶段。官方希望能在 2024/11/22 达到 Beta 阶段,2025年内达到 1.0 阶段。
③ 由来
诞生于AI浪潮,没有历史包袱:MoonBit
诞生于 ChatGPT
出世之后,使得 MoonBit
团队有更好的机会去重新构想整个程序语言工具链该如何与 AI 友好的协作,不用承担太多的历史包袱
二、MoonBit 语言优势
① 编译与运行速度快
MoonBit在编译速度和运行时性能上表现出色,其编译626个包仅需1.06秒,比Rust快了近9倍;运行速度比GO快35倍!
② 代码体积小
MoonBit
在输出 Wasm
代码体积上相较于传统语言有显著优势。
一个简单的HTTP 服务器时,MoonBit
的输出文件大小仅为 27KB
,而 WasmCloud
提供的http-hello-world
模板中 Rust
的输出为 100KB
,TypeScript
为 8.7MB
,Python
更是高达 17MB
。
③ 多重安全保障
MoonBit
采用了强大的类型系统,并内置静态检测工具,在编译期检查类型错误,
MoonBit
自身的静态控制流分析能在编译器捕获异常的类型,从而提高代码的正确性和可靠性。
④ 高效迭代器
MoonBit
创新地使用了零开销的迭代器设计,使得用户能够写出既优雅又高效的代码。
⑤ 创新的泛型系统设计
MoonBit
语言在它的测试版里就已经搞定了泛型和特殊的多态性,而且在编译速度特别快的同时,还能做到用泛型时不增加额外负担。
你要知道,这种本事在很多流行的编程语言里,都是正式发布很久之后才慢慢有的,但MoonBit
一开始就做到了。这种设计在现在编程语言越来越复杂的大背景下特别关键,因为一个好的类型系统对于整个编程语言生态的健康成长是特别重要的。
三、应用场景
① 云计算
② 边缘计算
③ AI 以及教学领域的发展
四、开发样例
我们在官网 http://www.moonbitlang.cn/gallery/ 可以看到用使用MoonBit
开发的游戏样例
- 罗斯方块游戏
- 马里奥游戏
- 数独求解器
- 贪吃蛇游戏
五、语言学习
5.1 语法文档
如果你也对
MoonBit
感兴趣,想学习它,访问官方文档docs.moonbitlang.cn/。文档算是比较详细的了
5.2 在线编译器
无需本地安装编译器即可使用,官方提供了在线编译器
① 在线编辑器地址
② 点击这儿运行代码
5.3 VS Code 中安装插件编写代码、
① 安装插件
② 下载程序
按下shift+cmd+p
快捷键(mac快捷键,windows和linux快捷键是ctrl+shift+p
),输入 MoonBit:install latest moonbit toolchain
,随后会出现提示框,点击“yes”,等待程序下载完成。
③ 创建并打开新项目
下载完成后,点击terminal,输入moon new hello && code hello
以创建并打开新项目。
④ 始执行代码
项目启动后,再次打开terminal,输入moon run main
命令,即可开始执行代码。
六、小结
下面是晓凡的一些个人看法
MoonBit
作为一款新兴的国产编程语言,其在性能和安全性方面的表现令人印象深刻。
特别是它在编译速度和运行效率上的优化,对于需要处理大量数据和高并发请求的现代应用来说,是一个很大的优势。
同时,它的设计理念符合当前软件开发的趋势,比如对云计算和边缘计算的支持,以及对 AI 应用的适配。
此外,MoonBit
团队在语言设计上的前瞻性思考,比如泛型系统的实现,显示出了其对未来编程语言发展趋势的深刻理解。
而且,提供的游戏开发样例不仅展示了 MoonBit
的实用性,也降低了初学者的学习门槛。
来源:juejin.cn/post/7416604150933733410
花了一天时间帮财务朋友开发了一个实用小工具
大家好,我是晓凡。
写在前面
不知道大家有没有做财务的朋友,我就有这么一位朋友就经常跟我抱怨。一到月底简直就是噩梦,总有加不完的班,熬不完的夜,做不完的报表。
一听到这儿,这不就一活生生的一个“大表哥”么,这加班跟我们程序员有得一拼了,忍不住邪恶一笑,心里平衡了很多。
身为牛马,大家都不容易啊。我不羡慕你数钱数到手抽筋,你也别羡慕我整天写CRUD 写到手起老茧🤣
吐槽归吐槽,饭还得吃,工作还得继续干。于是乎,真好赶上周末,花了一天的时间,帮朋友写了个小工具
一、功能需求
跟朋友吹了半天牛,终于把需求确定下来了。就一个很简单的功能,通过名字,将表一和表二中相同名字的金额合计。
具体数据整合如下图所示
虽然一个非常简单的功能,但如果不借助工具,数据太多,人工来核对,整合数据,还是需要非常消耗时间和体力的。
怪不得,这朋友到月底就消失了,原来时间都耗在这上面了。
二、技术选型
由于需求比较简单,只有excel导入导出,数据整合功能。不涉及数据库相关操作。
综合考虑之后选择了
PowerBuilder
Pbidea.dll
使用PowerBuilder
开发桌面应用,虽然界面丑一点,但是开发效率挺高,简单拖拖拽拽就能完成界面(对于前端技术不熟的小伙伴很友好)
其次,由于不需要数据库,放弃web开发应用,又省去了云服务器费用。最终只需要打包成exe
文件即可跑起来
Pbidea.dll
算是Powerbuilder
最强辅助开发,没有之一。算是PBer们的福音吧
三、简单界面布局
四、核心代码
① 导入excel
string ls_pathName,ls_FileName //路径+文件名,文件名
long ll_Net
long rows
dw_1.reset()
uo_datawindowex dw
dw = create uo_datawindowex
dw_1.setredraw(false)
ll_Net = GetFileSaveName("请选择文件",ls_pathName,ls_FileName,"xlsx","Excel文(*.xlsx),*.xlsx")
rows = dw.ImportExcelSheet(dw_1,ls_pathName,1,0,0)
destroy dw
dw_1.setredraw(true)
MessageBox("提示信息","导入成功 " + string(rows) + "行数据")
② 数据整合
long ll_row,ll_sum1,ll_sum2
long ll_i,ll_j
long ll_yes
string ls_err
//重置表三数据
dw_3.reset()
//处理表一数据
ll_sum1 = dw_1.rowcount( )
if ll_sum1<=0 then
ls_err = "表1 未导入数据,请先导入数据"
goto err
end if
for ll_i=1 to ll_sum1
ll_row = dw_3.insertrow(0)
dw_3.object.num[ll_row] =ll_row //序号
dw_3.object.name[ll_row]=dw_1.object.name[ll_i] //姓名
dw_3.object.salary[ll_row]=dw_1.object.salary[ll_i] //工资
dw_3.object.endowment[ll_row]=dw_1.object.endowment[ll_i] //养老
dw_3.object.medical[ll_row]=dw_1.object.medical[ll_i] //医疗
dw_3.object.injury[ll_row]=dw_1.object.injury[ll_i] //工伤
dw_3.object.unemployment[ll_row]=dw_1.object.unemployment[ll_i] //失业
dw_3.object.publicacc[ll_row]=dw_1.object.publicacc[ll_i] //公积金
dw_3.object.annuity[ll_row]=dw_1.object.annuity[ll_i] //年金
next
//处理表二数据
ll_sum2 = dw_2.rowcount( )
if ll_sum2<=0 then
ls_err = "表2未导入数据,请先导入数据"
goto err
end if
for ll_j =1 to ll_sum2
string ls_name
ls_name = dw_2.object.name[ll_j]
ll_yes = dw_3.Find("name = '"+ ls_name +"' ",1,dw_3.rowcount())
if ll_yes<0 then
ls_err = "查找失败!"+SQLCA.SQLErrText
goto err
end if
if ll_yes = 0 then //没有找到
ll_row = dw_3.InsertRow (0)
dw_3.ScrollToRow(ll_row)
dw_3.object.num[ll_row] = ll_row //序号
dw_3.object.name[ll_row] = dw_1.object.name[ll_j] //姓名
dw_3.object.salary[ll_row] = dw_1.object.salary[ll_j] //工资
dw_3.object.endowment[ll_row] = dw_1.object.endowment[ll_j] //养老
dw_3.object.medical[ll_row] = dw_1.object.medical[ll_j] //医疗
dw_3.object.injury[ll_row] = dw_1.object.injury[ll_j] //工伤
dw_3.object.unemployment[ll_row] = dw_1.object.unemployment[ll_j] //失业
dw_3.object.publicacc[ll_row] = dw_1.object.publicacc[ll_j] //公积金
dw_3.object.annuity[ll_row] = dw_1.object.annuity[ll_j] //年金
end if
if ll_yes >0 then //找到
dec{2} ld_salary,ld_endowment,ld_medical,ld_injury,ld_unemployment,ld_publicacc,ld_annuity
ld_salary = dw_3.object.salary[ll_yes] + dw_2.object.salary[ll_j]
ld_endowment = dw_3.object.endowment[ll_yes] + dw_2.object.endowment[ll_j]
ld_medical = dw_3.object.medical[ll_yes] + dw_2.object.medical[ll_j]
ld_injury = dw_3.object.injury[ll_yes] + dw_2.object.injury[ll_j]
ld_unemployment = dw_3.object.unemployment[ll_yes] + dw_2.object.unemployment[ll_j]
ld_publicacc = dw_3.object.publicacc[ll_yes] + dw_2.object.publicacc[ll_j]
ld_annuity = dw_3.object.annuity[ll_yes] + dw_2.object.annuity[ll_j]
dw_3.object.salary[ll_yes]= ld_salary //工资
dw_3.object.endowment[ll_yes]=ld_endowment //养老
dw_3.object.medical[ll_yes]=ld_medical //医疗
dw_3.object.injury[ll_yes]=ld_injury //工伤
dw_3.object.unemployment[ll_yes]=ld_unemployment //失业
dw_3.object.publicacc[ll_yes]=ld_publicacc //公积金
dw_3.object.annuity[ll_yes]=ld_publicacc //年金
end if
next
return 0
err:
messagebox('错误信息',ls_err)
③ excel导出
string ls_err
string ls_pathName,ls_FileName //路径+文件名,文件名
long ll_Net
if dw_3.rowcount() = 0 then
ls_err = "整合数据为空,不能导出"
goto err
end if
uo_wait_box luo_waitbox
luo_waitbox = create uo_wait_box
luo_waitBox.OpenWait(64,RGB(220,220,220),RGB(20,20,20),TRUE,"正在导出 ", 8,rand(6) - 1)
long rows
CreateDirectory("tmp")
uo_datawindowex dw
dw = create uo_datawindowex
ll_Net = GetFileSaveName("选择路径",ls_pathName,ls_FileName,"xlsx","Excel文(*.xlsx),*.xlsx")
rows = dw.ExportExcelSheet(dw_3,ls_pathName,true,true)
destroy dw
destroy luo_waitbox
MessageBox("提示信息","成功导出 " + string(rows) + " 行数据")
return 0
err:
messagebox('错误信息',ls_err)
五、最终效果
这次分享就到这吧,★,°:.☆( ̄▽ ̄)/$: .°★ 。希望对您有所帮助,也希望多来几个这样的朋友,不多说了, 蹭饭去了
我们下期再见ヾ(•ω•`)o (●'◡'●)
来源:juejin.cn/post/7404036818973245478
37K star!实时后端服务,一个文件实现
如果你经常开发web类的的项目,那你一定经常会接触到后端服务,给项目找一个简单、好用的后端服务可以大大加速开发。
今天我们分享的开源项目,它可以作为SaaS或者是Mobile的后端服务,最简单情况只要一个文件,它就是:PocketBase
PocketBase 是什么
PocketBase是一个开源的Go后端框架,它以单个文件的形式提供了一个实时的后端服务。这个框架特别适合于快速开发小型到中型的Web应用和移动应用。它的设计哲学是简单性和易用性,使得开发者能够专注于他们的产品而不是后端的复杂性。
PocketBase包含以下功能:
- 内置数据库(SQLite)支持实时订阅
- 内置文件和用户管理
- 方便的管理面板 UI
- 简洁的 REST 风格 API
安装使用PocketBase
首先你可以下载PocketBase的预构建版本,你可以在github的release页面下载到对应平台的包。
下载后,解压存档并./pocketbase serve在解压的目录中运行。
启动完成后会3个web服务的路由:
- http://127.0.0.1:8090 - 如果存在pb_public目录,则提供其中的静态内容(html、css、图像等)
- http://127.0.0.1:8090/_/ - Admin的管理页面
- http://127.0.0.1:8090/api/ - Rest API
默认情况下,PocketBase 在端口上运行8090。但您可以通过在 serve 命令后附加--http和--https参数将其绑定到任何端口。
Admin panel
第一次访问管理仪表板 UI 时,它会提示您创建第一个管理员帐户。在管理页面里您可以完全使用 GUI 构建数据架构、添加记录并管理数据库。
API
它带有一个开箱即用的 API,可让您操作任何集合,还具有一个优雅的查询系统,可让您分页搜索记录。这将使您不必自己编写和维护同样无聊的 CRUD 操作,而可以更专注于产品特定的功能。
内置访问规则
PocketBase 可让您通过简单的语法直接从 GUI 定义对资源的访问规则。例如,这有助于定义访问范围和控制对用户特定数据的访问。同样,这将使您无需担心编写身份验证和授权代码。
SDK
使用PocketBase的API可以通过官方SDK,目前官方提供了JS SDK和Dart SDK。
- JavaScript - pocketbase/js-sdk (浏览器和nodejs)
- Dart - pocketbase/dart-sdk(网页、移动、桌面)
它们提供了用于连接数据库、处理身份验证、查询、实时订阅等的库,使开发变得简单。
开发定制应用
PocketBase 作为常规 Go 库包分发,允许您构建自己的自定义应用程序特定的业务逻辑,并且最后仍具有单个可移植的可执行文件。
这是一个简单的例子:
- 首先如果你没有Go的环境,那么需要安装 Go1.21以上版本
- 创建一个新的项目目录,并创建一个main.go文件,文件包含以下内容:
package main
import (
"log"
"net/http"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
)
func main() {
app := pocketbase.New()
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
// add new "GET /hello" route to the app router (echo)
e.Router.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/hello",
Handler: func(c echo.Context) error {
return c.String(200, "Hello world!")
},
Middlewares: []echo.MiddlewareFunc{
apis.ActivityLogger(app),
},
})
return nil
})
if err := app.Start(); err != nil {
log.Fatal(err)
}
}
- 初始化依赖项,请运行go mod init myapp && go mod tidy。
- 要启动应用程序,请运行go run main.go serve。
- 要构建静态链接的可执行文件,您可以运行CGO_ENABLED=0 go build,然后使用 启动创建的可执行文件./myapp serve。
总结
整体来说PocketBase是一个非常不错的后端服务,它兼顾了易用性和定制的灵活性,如果你有项目的需要或是想要自己开发一个SAAS的服务,都可以选择它来试试。
项目信息
- 项目名称:pocketbase
- GitHub 链接:github.com/pocketbase/…
- Star 数:37K
来源:juejin.cn/post/7415672130190704640
还在用 top htop? 赶紧换 btop 吧,真香!
top
在 Linux 服务器上,或类 Unix 的机器上,一般我们想查看每个进程的 CPU 使用率、内存使用情况以及其他相关信息时会使用 top 命令。
top 是一个标准的 Linux/Unix 工具,实际上我从一开始接触 Linux 就一直使用 top , 一般是两种场景:
- Linux 服务器上用
- 自己的 Mac 电脑上用
top 有一些常用的功能,比如可以动态的显示进程的情况,按照 CPU 、内存使用率排序等。说实话,这么多年了,使用最多的还就是 top ,一来是因为习惯了,工具用惯了很多操作都是肌肉记忆。二来是 top 一般系统自带不用安装,省事儿。
htop
top 挺好的,但 top 对于初学者和小白用户不太友好,尤其是它的用户界面和操作。于是后来有了 htop
htop 是 top 的一个增强替代品,提供了更加友好的用户界面和更多的功能。与 top 相比,htop 默认以颜色区分不同的信息,并且支持水平滚动查看更多的进程信息。htop 还允许用户使用方向键来选择进程,并可以直接发送信号给进程(如 SIGKILL)。htop 支持多种视图和配置选项,使得用户可以根据自己的喜好定制显示的内容。
htop 我也用了几年,确实舒服一些,但由于需要安装和我对 top 的肌肉记忆 ,htop 在我的使用中并未完全替代 top。 直到 btop 的出现
btop
现在,我本机使用的是 btop,有了 btop,top 和 htop 一点儿都不想用了,哈哈。
在服务器上有时候因为懒不想安装,一部分时间还是 top,一部分用 btop。
第一印象是真漂亮啊,然而它不止好看,功能也是很实用,操作还很简单,你说能不喜欢它吗?
说是 btop ,实际上人家真正的名字是 btop++ , 用 C++ 开发的
安装
btop 支持各种类 Unix 系统,你可以在它的文档中找到对应系统的安装方法 github.com/aristocrato…
本文演示,我是用我自己的 Mac 笔记本电脑,用 Mac 安装很简单,用 brew 一行搞定
brew install btop
我的系统情况是这样的:
安装完成后,直接运行 btop
就可以看到如上图的界面了。
功能界面
打开 btop 后不要被它的界面唬住了,其实非常的简单,我们来介绍一下。
打开 btop 后,其实显示的是它给你的 “预置” 界面。 默认有 4 个预置界面,你可以按 p
键进行切换。命令行界面上会分别显示:
- preset 0
- preset 1
- preset 2
- preset 3
你可能注意到了,这 4 个预置界面中有很多内容是重复的,没错,其实 btop 一共就 4 个模块,预置界面只是把不同的模块拼在一起显示罢了。这 4 个模块分别是:
- CPU 模块
- 存储 模块
- 网络 模块
- 进程 模块
这 4 个模块对应的快捷键分别就是 1
,2
,3
,4
你按一下模块显示,再按一下模块隐藏。
所以如果你对预置界面的内容想立刻调整,就可以按快捷键来显示/隐藏 你想要的模块,当然预置界面也是可以通过配置文件调整的,这个我们后面说。
CPU 模块
CPU 模块可以显示 CPU 型号、各内核的使用率、温度,CPU 整体的负载,以及一个直观的图象,所有数据都是实时显示的。
存储 模块
存储模块包括两部分,一个是内存使用情况,一个是磁盘使用情况:
因为比较直观,具体内容我就不解释了。
网络模块
网络模块可以看下网络的整体负载和吞吐情况,主要包括上行和下行数据汇总,你可以通过按快捷键 b
和n
来切换看不同的网卡。
进程模块
初始的进程模块可以看到:
- pid
- Program: 进程名称
- Command: 执行命令的路径
- Threads: 进程包含的线程数
- User: 启动进程的用户
- MemB: 进程所占用内存
- Cpu%: 进程所占用 CPU 百分比
你可以按快捷键 e
显示树状视图:
可以按快捷键 r
对进行排序,按一下是倒序,再按一下是正序。具体排序列可以按左右箭头
,根据界面显示进行选择,比如我要按照内存使用排序,那么右上角就是这样的:
按 f
键输入你想过滤的内容然后回车,可以过滤一下界面显示的内容,比如我只想看 chrome 的进程情况:
还可以通过 上下箭头选中某一个进程按回车查看进程详情,再次按回车可以隐藏详情:
显示进程详情后可以对进程进行操作,比如 Kill
只需要按快捷键 k
就可以了,然后会弹出提示:
主题
怎么样,是不是很方便,操作简单,上手容易,还好看。关于 btop 的主要操作就这些了,剩下的可以参考 help
和 menu
中显示的内容自行操作和设置都很简单。
btop 的配置文件默认在这里:$HOME/.config/btop
,你可以直接修改配置文件中的详细参数,如我们前文提到的 “预置” 界面以及预置界面内容都可以在配置文件中设置 :
此外 btop 还有很多好看的主题配色,但默认安装的情况下只带了一个 Default
的,如果你想切换用其他的主题,需要先下载这些主题,主题文件在这里:github.com/aristocrato…
下载好以后放到本地对应的文件夹中 ~/.config/btop/themes
然后你就可以要界面上进行主题的切换了,具体流程是先按快捷键 m
,然后选 OPTIONS
接着在 Color theme 中就能看到你当前拥有的 theme 数据,按方向键就可以切换主题配色了:
主题有很多,我这里给大家一个完整的预览:
我目前使用的就是 Default
我觉得最符合我的审美。
最后
用了 btop 后你就再也回不去了,一般情况下再也不会想用 htop 和 top 了,大家没有换的可以直接换了
来源:juejin.cn/post/7415197972009287692
简单实现一个插件系统(不引入任何库),学会插件化思维
插件系统被广泛应用在各个系统中,比如浏览器、各个前端库如vue、webpack、babel,它们都可以让用户自行添加插件。插件系统的好处是允许开发人员在保证内部核心逻辑不变的情况下,以安全、可扩展的方式添加功能。
本文参考了webpack的插件,不引入任何库,写一个简单的插件系统,帮助大家理解插件化思维。
下面我们先看看插件有哪些概念和设计插件的流程。
准备
三个概念
- 核心系统(Core):有着系统的基本功能,这些功能不依赖于任何插件。
- 核心和插件之间的联系(Core <--> plugin):即插件和核心系统之间的交互协议,比如插件注册方式、插件对核心系统提供的api的使用方式。
- 插件(plugin):相互独立的模块,提供了单一的功能。
插件系统的设计和执行流程
那么对着上面三个概念,设计插件的流程:
- 首先要有一个核心系统。
- 然后确定核心系统的生命周期和暴露的 API。
- 最后设计插件的结构。
- 插件的注册 -- 安装加载插件到核心系统中。
- 插件的实现 -- 利用核心系统的生命周期钩子和暴露的 API。
最后代码执行的流程是:
- 注册插件 -- 绑定插件内的处理函数到生命周期
- 调用插件 -- 触发钩子,执行对应的处理函数
直接看代码或许更容易理解⬇️
代码实现
准备一个核心系统
一个简单的 JavaScript 计算器,可以做加、减操作。
class Calculator {
constructor(options = {}) {
const { initialValue = 0 } = options
this.currentValue = initialValue;
}
getCurrentValue() {
return this.currentValue;
}
setValue(value) {
this.currentValue = value;
}
plus(addend) {
this.setValue(this.currentValue + addend);
}
minus(subtrahend) {
this.setValue(this.currentValue - subtrahend);
}
}
// test
const calculator = new Calculator()
calculator.plus(10);
calculator.getCurrentValue() // 10
calculator.minus(5);
calculator.getCurrentValue() // 5
确定核心系统的生命周期
实现Hooks
核心系统想要对外提供生命周期钩子,就需要一个事件机制。不妨叫Hooks。(日常开发可以考虑使用webpack的核心库 Tapable )
class Hooks {
constructor() {
this.listeners = {};
}
on(eventName, handler) {
let listeners = this.listeners[eventName];
if (!listeners) {
this.listeners[eventName] = listeners = [];
}
listeners.push(handler);
}
off(eventName, handler) {
const listeners = this.listeners[eventName];
if (listeners) {
this.listeners[eventName] = listeners.filter((l) => l !== handler);
}
}
trigger(eventName, ...args) {
const listeners = this.listeners[eventName];
const results = [];
if (listeners) {
for (const listener of listeners) {
const result = listener.call(null, ...args);
results.push(result);
}
}
return results;
}
destroy() {
this.listeners = {};
}
}
暴露生命周期(通过Hooks)
然后将hooks运用在核心系统中 -- JavaScript 计算器
每个钩子对应的事件:
- pressedPlus 做加法操作
- pressedMinus 做减法操作
- valueWillChanged 即将赋值currentValue,如果执行此钩子后返回值为false,则中断赋值。
- valueChanged 已经赋值currentValue
class Calculator {
constructor(options = {}) {
this.hooks = new Hooks();
const { initialValue = 0 } = options
this.currentValue = initialValue;
}
getCurrentValue() {
return this.currentValue;
}
setValue(value) {
const result = this.hooks.trigger('valueWillChanged', value);
if (result.length !== 0 && result.some( _ => ! _ )) {
} else {
this.currentValue = value;
}
this.hooks.trigger('valueChanged', this.currentValue);
}
plus(addend) {
this.hooks.trigger('pressedPlus', this.currentValue, addend);
this.setValue(this.currentValue + addend);
}
minus(subtrahend) {
this.hooks.trigger('pressedMinus', this.currentValue, subtrahend);
this.setValue(this.currentValue - subtrahend);
}
}
设计插件的结构
插件注册
class Calculator {
constructor(options = {}) {
this.hooks = new Hooks();
const { initialValue = 0, plugins = [] } = options
this.currentValue = initialValue;
// 在options中取出plugins
// 通过plugin执行apply来注册插件 -- apply执行后会绑定(插件内的)处理函数到生命周期
plugins.forEach(plugin => plugin.apply(this.hooks));
}
...
}
插件实现
插件一定要实现apply方法。在Calculator的constructor调用时,才能确保插件“apply执行后会绑定(插件内的)处理函数到生命周期”。
apply的入参是this.hooks,通过this.hooks来监听生命周期并添加处理器。
下面实现一个日志插件和限制最大值插件:
// 日志插件:用console.log模拟下日志
class LogPlugins {
apply(hooks) {
hooks.on('pressedPlus',
(currentVal, addend) => console.log(`${currentVal} + ${addend}`));
hooks.on('pressedMinus',
(currentVal, subtrahend) => console.log(`${currentVal} - ${subtrahend}`));
hooks.on('valueChanged',
(currentVal) => console.log(`结果: ${currentVal}`));
}
}
// 限制最大值的插件:当计算结果大于100时,禁止赋值
class LimitPlugins {
apply(hooks) {
hooks.on('valueWillChanged', (newVal) => {
if (100 < newVal) {
console.log('result is too large')
return false;
}
return true
});
}
}
全部代码
class Hooks {
constructor() {
this.listener = {};
}
on(eventName, handler) {
if (!this.listener[eventName]) {
this.listener[eventName] = [];
}
this.listener[eventName].push(handler);
}
trigger(eventName, ...args) {
const handlers = this.listener[eventName];
const results = [];
if (handlers) {
for (const handler of handlers) {
const result = handler(...args);
results.push(result);
}
}
return results;
}
off(eventName, handler) {
const handlers = this.listener[eventName];
if (handlers) {
this.listener[eventName] = handlers.filter((cb) => cb !== handler);
}
}
destroy() {
this.listener = {};
}
}
class Calculator {
constructor(options = {}) {
this.hooks = new Hooks();
const { initialValue = 0, plugins = [] } = options;
this.currentValue = initialValue;
plugins.forEach((plugin) => plugin.apply(this.hooks));
}
getCurrentValue() {
return this.currentValue;
}
setValue(value) {
const result = this.hooks.trigger("valueWillChanged", value);
if (result.length !== 0 && result.some((_) => !_)) {
} else {
this.currentValue = value;
}
this.hooks.trigger("valueChanged", this.currentValue);
}
plus(addend) {
this.hooks.trigger("pressedPlus", this.currentValue, addend);
this.setValue(this.currentValue + addend);
}
minus(subtrahend) {
this.hooks.trigger("pressedMinus", this.currentValue, subtrahend);
this.setValue(this.currentValue - subtrahend);
}
}
class LogPlugins {
apply(hooks) {
hooks.on("pressedPlus", (currentVal, addend) =>
console.log(`${currentVal} + ${addend}`)
);
hooks.on("pressedMinus", (currentVal, subtrahend) =>
console.log(`${currentVal} - ${subtrahend}`)
);
hooks.on("valueChanged", (currentVal) =>
console.log(`结果: ${currentVal}`)
);
}
}
class LimitPlugins {
apply(hooks) {
hooks.on("valueWillChanged", (newVal) => {
if (100 < newVal) {
console.log("result is too large");
return false;
}
return true;
});
}
}
// run test
const calculator = new Calculator({
plugins: [new LogPlugins(), new LimitPlugins()],
});
calculator.plus(10);
calculator.minus(5);
calculator.plus(1000);
脚本的执行结果如下,大家也可以自行验证一下
看完代码可以回顾一下“插件系统的设计和执行流程”哈。
更多实现
假如要给Calculator设计一个扩展运算方式的插件,支持求平方、乘法、除法等操作,这时候怎么写?
实际上目前核心系统Calculator是不支持的,因为它并没有支持的钩子。那这下只能改造Calculator。
可以自行尝试一下怎么改造。也可以直接看答案:github.com/coder-xuwen…
最后
插件化的好处
在上文代码实现的过程中,可以感受到插件让Calculator变得更具扩展性。
- 核心系统(Core)只包含系统运行的最小功能,大大降低了核心代码的包体积。
- 插件(plugin)则是互相独立的模块,提供单一的功能,提高了内聚性,降低了系统内部耦合度。
- 每个插件可以单独开发,也支持了团队的并行开发。
- 另外,每个插件的功能不一样,也给用户提供了选择功能的能力。
本文的局限性
另外,本文的代码实现很简单,仅供大家理解,大家还可以继续完善:
- 增加ts类型,比如给把所有钩子的类型用emun记录起来
- 支持动态加载插件
- 提供异常拦截机制 -- 处理注册插件插件的情况
- 暴露接口、处理钩子返回的结构时要注意代码安全
参考
Designing a JavaScript Plugin System | CSS-Tricks
干货!撸一个webpack插件(内含tapable详解+webpack流程) - 掘金
来源:juejin.cn/post/7344670957405126695
用了Go的匿名结构体,搬砖效率更高,产量更足了
今天给大家分享一个使用匿名结构体,提升Go编程效率的小技巧,属于在日常写代码过程中积累下来的一个提升自己效率的小经验。
这个技巧之所以提效率主要体现在两方面:
- 减少一些不会复用的类型定义
- 节省纠结该给类型起什么名字的时间
尤其第二项,通过匿名结构体这个名字就能体现出来,它本身没有类型名,这能节省不少想名字的时间。再一个也能减少起错名字给其他人带来的误解,毕竟并不是所有人编程时都会按照英文的词法做命名的。
下面我先从普通结构体说起,带大家看看什么情形下用匿名结构体会带来编码效率的提升。
具名结构体
具名结构体就是平时用的普通结构体。
结构体大家都知道,用于把一组字段组织在一起,来在Go语言里抽象表达现实世界的事物,类似“蓝图”一样。
比如说定义一个名字为Car的结构体在程序里表示“小汽车”
// 定义结构体类型'car'
type car struct {
make string
model string
mileage int
}
用到这个结构体的地方通过其名字引用其即可,比如创建上面定义的结构体的实例
// 创建car 的实例
newCar := car{
make: "Ford",
model: "taurus",
mileage: 200000,
}
匿名结构体
匿名结构体顾名思义就是没有名字的结构体,通常只用于在代码中仅使用一次的结构类型,比如
func showMyCar() {
newCar := struct {
make string
model string
mileage int
}{
make: "Ford",
model: "Taurus",
mileage: 200000,
}
fmt.Printlb(newCar.mode)
}
上面这个函数中声明的匿名结构体赋值给了函数中的变量,所以只能在函数中使用。
如果一个结构体初始化后只被使用一次,那么使用匿名结构体就会很方便,不用在程序的package中定义太多的结构体类型,比如在解析接口的响应到结构体后,就可以使用匿名结构体
用于解析接口响应
func createCarHandler(w http.ResponseWriter, req *http.Request) {
defer req.Body.Close()
decoder := json.NewDecoder(req.Body)
newCar := struct {
Make string `json:"make"`
Model string `json:"model"`
Mileage int `json:"mileage"`
}{}
err := decoder.Decode(&newCar)
if err != nil {
log.Println(err)
return
}
......
return
}
类似上面这种代码一般在控制层写,可以通过匿名结构体实例解析到请求后再去创建对应的DTO或者领域对象供服务层或者领域层使用。
有人会问为什么不直接把API的响应解析到DTO对象里,这里说一下,匿名结构体的使用场景是在觉得定一个Struct 不值得、不方便的情况下才用的。 比如程序拿到接口响应后需要按业务规则加工下才能创建DTO实例这种情况,就很适合用匿名结构体先解析响应。
比用map更健壮
这里再说一点使用匿名结构体的好处。
使用匿名解析接口响应要比把响应解析到map[string]interface{}
类型的变量里要好很多,json数据解析到匿名结构体的时候在解析的过程中会进行类型检查,会更安全。使用的时候直接通过s.FieldName
访问字段也比map
访问起来更方便和直观。
用于定义项目约定的公共字段
除了上面这种结构体初始化后只使用一次的情况,在项目中定义各个接口的返回或者是DTO时,有的公共字段使用匿名结构体声明类型也很方便。
一般在启动项目的时候我们都会约定项目提供的接口的响应值结构,比如响应里必须包含Code
、Msg
、Data
三个字段,每个接口会再细分定义返回的Data的结构,这个时候用匿名结构题能节省一部分编码效率。
比如下面这个Reponse
的结构体类型的定义
type UserCouponResponse struct {
Code int64 `json:"code"`
Msg string `json:"message"`
Data []*struct {
CouponId int `json:"couponId"`
ProdCode string `json:"prodCode"`
UserId int64 `json:"userId"`
CouponStatus int `json:"couponStatus"`
DiscountPercentage int `json:"discount"`
} `json:"data"`
}
就省的先去定义一个UserCoupon类型
type UserCoupon struct {
CouponId int `json:"couponId"`
ProdCode string `json:"prodCode"`
UserId int64 `json:"userId"`
CouponStatus int `json:"couponStatus"`
DiscountPercentage int `json:"discount"`
}
再在Response声明里使用定义的UserCoupon了
type UserCouponResponse struct {
Code int64 `json:"code"`
Msg string `json:"message"`
Data []*UserCoupon `json:"data"`
}
当然如果UserCoupon是你的项目其他地方也会用到的类型,那么先声明,顺带在Response结构体里也使用是没问题的,只要会多次用到的类型都建议声明成正常的结构体类型。
还是那句话匿名结构体只在你觉得"这还要定义个类型?”时候使用,用好的确实能提高点代码生产效率。
总结
本次的分享就到这里了,内容比较简单,记住这个口诀:匿名结构体只在你写代码时觉得这还要定义个类型,感觉没必要的时候使用,采纳这个技巧,时间长了还是能看到一些自己效率的提高的。
来源:juejin.cn/post/7359084604663709748
不到50元如何自制智能开关?
前言
家里床头离开关太远了,每次躺在床上玩手机准备睡觉时候还的下去关灯,着实麻烦,所以用目前仅有的一点单片机知识,做了一个小小小的智能开关,他有三个模块构成,如下:
- 主模块是ESP32(20元)
他是一个低成本、低功耗的微控制器,集成了 Wi-Fi 和蓝牙功能,因为我们需要通过网络去开/关灯,还有一个ESP8266,比这个便宜,大概6 块钱,但是他烧录程序的时候比较慢。

- 光电开关(10元)
这个可有可无,这个是用来当晚上下班回家后,开门自动开灯使用的,如果在他前面有遮挡,他的信号线会输出高/低电压,这个取决于买的是常开还是常闭,我买的是常开,当有物体遮挡时,会输出低电压,所以当开门时,门挡住了它,它输出低电压给ESP32,ESP32读取到电压状态后触发开灯动作。
家里床头离开关太远了,每次躺在床上玩手机准备睡觉时候还的下去关灯,着实麻烦,所以用目前仅有的一点单片机知识,做了一个小小小的智能开关,他有三个模块构成,如下:
- 主模块是ESP32(20元)
他是一个低成本、低功耗的微控制器,集成了 Wi-Fi 和蓝牙功能,因为我们需要通过网络去开/关灯,还有一个ESP8266,比这个便宜,大概6 块钱,但是他烧录程序的时候比较慢。
- 光电开关(10元)
这个可有可无,这个是用来当晚上下班回家后,开门自动开灯使用的,如果在他前面有遮挡,他的信号线会输出高/低电压,这个取决于买的是常开还是常闭,我买的是常开,当有物体遮挡时,会输出低电压,所以当开门时,门挡住了它,它输出低电压给ESP32,ESP32读取到电压状态后触发开灯动作。
- 舵机 SG90(5元)
这是用来触发开/关灯动作的设备,需要把它用胶粘在开关上,他可以旋转0-180度,力度也还行,对于开关足够了。还有一个MG90舵机,力度特别大,但是一定要买180度的,360度的舵机只能正转和反转,不能控制角度。
- 杜邦线(3元)
Arduino Ide
Arduino是什么就不说了,要烧录代码到ESP32,需要使用官方乐鑫科技提供的ESP-IDF工具,它是用来开发面向ESP32和ESP32-S系列芯片的开发框架,但是,Arduino Ide提供了一个核心,封装了ESP-IDF一些功能,便于我们更方便的开发,当然Arduino还有适用于其他开发板的库。
Arduino配置ESP32的开发环境比较简单,就是点点点、选选选即可。
接线
下面就是接线环节,先看下ESP32的引脚,他共有30个引脚,有25个GPIO(通用输入输出)引脚,如下图中紫色的引脚,在我们的这个设备里,舵机和光电开关都需要接入正负级到下图中的红色(VCC)和黑色(GND)引脚上,而他们都需要在接入一个信号作为输出/输入点,可以在着25个中选择一个,但还是有几个不能使用的,比如有一些引脚无法配置为输出,只用于作输入,还有RX和TX,我们这里使用26(光电开关)和27(舵机)引脚就可以了。
esp32代码
下面写一点点代码,主要逻辑很简单,创建一个http服务器,用于通过外部去控制舵机的转向,外部通过http请求并附带一个角度参数,在通过ESP32Servo这个库去使舵机角度发生改变。
esp32的wifi有以下几种模式。
- Station Mode(STA模式): 在STA模式下,esp32可以连接到一个wifi,获取一个ip地址,并且可以与网络中的其他设备进行通信。
- Access Point Mode(AP模式): 在AP模式下,它充当wifi热点,其他设备可以连接到esp32,就像连接到普通路由器一样,一般用作配置模式使用,经常买到的智能设备,进入配置模式和后,他会开一个热点,你的手机连接到这个热点后,在通过他们提供的app去配置,就是用这种模式。
- Soft Access Point Mode(SoftAP模式): 同时工作在STA模式和AP模式下。
下一步根据自己的逻辑,比如当光电开关被遮挡时,并且又是xxxx时,就开灯,或者当xxx点后就关灯。
#include
#include
#include
#include
#include
#define SERVO_PIN_NUMBER 27
#define STATE_PIN_NUMBER 26
#define CLOSE_VALUE 40
#define OPEN_VALUE 150
const char* ssid = "wifi名称";
const char* password = "wifi密码";
AsyncWebServer server(80);
Servo systemServo;
bool openState = false;
void setup() {
Serial.begin(115200);
WiFi.mode(WIFI_AP_STA);
WiFi.begin(ssid, password);
Serial.println("\nConnecting");
while (WiFi.status() != WL_CONNECTED) {
Serial.print(".");
delay(100);
}
systemServo.attach(SERVO_PIN_NUMBER);
systemServo.write(90);
openState = false;
write_state(CLOSE_VALUE);//启动时候将灯关闭
Serial.print("Local ESP32 IP: ");
Serial.println(WiFi.localIP());
pinMode(STATE_PIN_NUMBER, INPUT);
int timezone = 8 * 3600;
configTime(timezone, 0, "pool.ntp.org");
server.on("/set_value", HTTP_GET, [](AsyncWebServerRequest * request) {
if (request->hasParam("value")) {
String value = request->getParam("value")->value();
int intValue = value.toInt();
write_state(intValue);
request->send(200, "text/plain", "value: " + String(intValue));
} else {
request->send(400, "text/plain", "error");
}
});
server.begin();
}
void write_state(int value) {
openState = value < 90 ? false : true;
systemServo.write(value);
delay(100);
systemServo.write(90);
}
void loop() {
time_t now = time(nullptr);
struct tm *timeinfo;
timeinfo = localtime(&now);
//指定时间关灯
int currentMin = timeinfo->tm_min;
int currentHour = timeinfo->tm_hour;
if (currentHour == 23 && currentMin == 0 && openState ) {
write_state(CLOSE_VALUE);
openState = false;
}
//下班开灯
if (digitalRead(STATE_PIN_NUMBER) == 0 && currentHour > 18 && !openState) {
write_state(OPEN_VALUE);
openState = true;
}
}
Android下控制
当然,还得需要通过外部设备进行手动开关,这里就简单写一个Android程序,上面写了一个http服务,访问esp32的ip地址,发起一个http请求就可以了,所以浏览器也可以,但更方便的是app,效果如下。
package com.example.composedemo
import android.content.Context
import android.content.SharedPreferences
import android.os.Bundle
import android.util.Log
import android.view.View
import android.widget.TextView
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import com.example.composedemo.ui.theme.ComposeDemoTheme
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.net.HttpURLConnection
import java.net.URL
class MainActivity : ComponentActivity() {
private val state = State()
private lateinit var sharedPreferences: SharedPreferences
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
sharedPreferences = getPreferences(Context.MODE_PRIVATE)
state.ipAddressChange = {
with(sharedPreferences.edit()) {
putString("ipAddress", it)
apply()
}
}
state.slideChange = {setValue(it) }
state.lightChange = {
Log.i(TAG, "onCreate: $it")
if (it) openLight()
if (!it) closeLight()
}
state.esp32IpAddress.value = sharedPreferences.getString("ipAddress", "")!!
setContent {
ComposeDemoTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
SlidingOvalLayout(state)
}
}
}
}
private fun closeLight() =setValue(40)
private fun openLight() = setValue(150)
private fun setValue(value: Int) {
sendHttpRequest("http://${state.esp32IpAddress.value}/set_value/?value=$value:")
}
private fun sendHttpRequest(url: String) {
GlobalScope.launch(Dispatchers.IO) {
try {
val connection = URL(url).openConnection() as HttpURLConnection
connection.requestMethod = "GET"
connection.connect()
if (connection.responseCode == HttpURLConnection.HTTP_OK) {
val response = connection.inputStream.bufferedReader().readText()
withContext(Dispatchers.Main) {
}
} else {
withContext(Dispatchers.Main) {
}
}
connection.disconnect()
} catch (e: Exception) {
e.printStackTrace()
withContext(Dispatchers.Main) {
}
}
}
}
}
@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
ComposeDemoTheme {
}
}
ui组件
package com.example.composedemo
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.layout
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import com.example.composedemo.ui.theme.ComposeDemoTheme
const val TAG = "TAG"
@Composable
fun SlidingOvalLayout(state: State) {
var offset by remember { mutableStateOf(Offset(0f, 0f)) }
var parentWidth by remember { mutableStateOf(0) }
var sliderValue by remember { mutableStateOf(0) }
var closeStateColor by remember { mutableStateOf(Color(0xFFDF2261)) }
var openStateColor by remember { mutableStateOf(Color(0xFF32A34B)) }
Box(
modifier = Modifier
.fillMaxSize()
.padding(40.dp)
.width(100.dp)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.SpaceBetween,
horizontalAlignment = Alignment.CenterHorizontally
) {
Column {
Box() {
TextField(
value = state.esp32IpAddress.value,
onValueChange = {
state.esp32IpAddress.value = it
state.ipAddressChange(it)
},
colors = TextFieldDefaults.textFieldColors(
disabledTextColor = Color.Transparent,
backgroundColor = Color(0xFFF1EEF1),
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent
),
modifier = Modifier
.fillMaxWidth()
.background(color = Color(0xFFF1EEF1))
)
}
Box() {
Column() {
Text(text = sliderValue.toString())
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Slider(
value = sliderValue.toFloat(),
onValueChange = {
sliderValue = it.toInt()
state.slideChange(sliderValue)},
valueRange = 0f..180f,
onValueChangeFinished = {
},
colors = SliderDefaults.colors(
thumbColor = Color.Blue,
activeTrackColor = Color.Blue
)
)
}
}
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(70.dp)
.shadow(10.dp, shape = RoundedCornerShape(100.dp))
.background(color = Color(0xFFF1EEF1))
.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
parentWidth = placeable.width
layout(placeable.width, placeable.height) {
placeable.place(0, 0)
}
}
) {
Box(
modifier = Modifier
.offset {
if (state.lightValue.value) {
IntOffset((parentWidth - 100.dp.toPx()).toInt(), 0)
} else {
IntOffset(0, 0)
}
}
.graphicsLayer {
translationX = offset.x
}
.clickable() {
state.lightValue.value = !state.lightValue.value
state.lightChange(state.lightValue.value )
}
.pointerInput(Unit) {
}
.background(
color = if(state.lightValue.value) openStateColor else closeStateColor,
shape = RoundedCornerShape(100.dp)
)
.size(Dp(100f), Dp(80f))
)
}
}
}
}
@Preview
@Composable
fun PreviewSlidingOvalLayout() {
ComposeDemoTheme {
}
}
class State {
var esp32IpAddress: MutableState = mutableStateOf("")
var lightValue :MutableState<Boolean> = mutableStateOf(false)
var ipAddressChange :(String)->Unit={}
var slideChange:(Int)->Unit={}
var lightChange:(Boolean)->Unit={}
}
来源:juejin.cn/post/7292245569482407988
Java音视频文件解析工具
@[toc]
小伙伴们知道,松哥平时录了蛮多视频课程,视频录完以后,就想整理一个视频文档出来,在整理视频文档的时候,就会遇到一个问题,就是怎么统计视频时长?
特别是有时候为了方便大家看到每一集视频的时长,我要把视频目录整理成下面这个样子:
这个逐集去查看就很麻烦,一套视频动辄几百集,挨个统计不现实,也不符合咱们程序员做事风格。
那么怎么办呢?
一开始我是使用 Python 去解决的,Python 做这样一个小工具其实特别方便,简简单单 30 行代码左右就能搞定了。之前的课程的这些时间统计我基本上都是用 Python 去完成的。
不过最近松哥发现 Java 里边其实也有一个视频处理的库,做这个事情也是非常方便,而且使用 Java 属于主场作战,就能够更加灵活的扩展功能了。
一 jave-all-deps
在 Java 开发中,处理音视频文件经常需要复杂的编解码操作,开发者通常需要依赖于外部库来实现这些功能,其中最著名的是 FFmpeg。然而,直接在 Java 中使用 FFmpeg 并不是一件容易的事,因为它需要处理本地库和复杂的命令行接口。
幸运的是,jave-all-deps 库提供了一个简洁而强大的解决方案,让 Java 开发者能够轻松地进行音视频文件的转码和处理。
jave-all-deps 是 JAVE2(Java Audio Video Encoder)项目的一部分,它是一个基于 ffmpeg 项目的 Java 封装库。JAVE2 通过提供一套简单易用的 API,允许 Java 开发者在不直接处理 ffmpeg 复杂命令的情况下,进行音视频文件的格式转换、转码、剪辑等操作。
jave-all-deps 库特别之处在于它集成了核心 Java 代码和所有支持平台的二进制可执行文件,使得开发者无需手动配置 ffmpeg 环境,即可在多个操作系统上无缝使用。
是不是非常方便?
整体上来说,jave-all-deps 帮我们解决了三大类问题:
- 跨平台兼容性问题:音视频处理往往涉及到不同的操作系统和硬件架构,jave-all-deps 库提供了针对不同平台的预编译 ffmpeg 二进制文件,使得开发者无需担心平台兼容性问题。
- 复杂的命令行操作:ffmpeg 虽然功能强大,但其命令行接口复杂且难以记忆。jave-all-deps 通过封装 ffmpeg 的命令行操作,提供了简洁易用的 Java API,降低了使用门槛。
- 依赖管理:在项目中集成音视频处理功能时,往往需要处理多个依赖项。jave-all-deps 库将核心代码和所有必要的二进制文件打包在一起,简化了依赖管理。
简单来说,就是你想在项目中使用 ffmpeg,但是又嫌麻烦,那么就可以使用 jave-all-deps 这个工具封装后的 ffmpeg,简单快捷!
二 具体用法
jave-all-deps 库提供了多种音视频处理功能,松哥这里来和大家演示几个常见的。
2.1 添加依赖
添加依赖有两种方式,一种就是添加所有的依赖库,如下:
<dependency>
<groupId>ws.schild</groupId>
<artifactId>jave-all-deps</artifactId>
<version>3.5.0</version>
</dependency>
这个库中包含了不同平台所依赖的库的内容。
也可以根据自己平台选择不同的依赖库,这种方式需要首先添加 java-core:
<dependency>
<groupId>ws.schild</groupId>
<artifactId>jave-core</artifactId>
<version>3.5.0</version>
</dependency>
然后再根据自己使用的不同平台,继续添加不同依赖库:
Linux 64 位 amd/intel:
<dependency>
<groupId>ws.schild</groupId>
<artifactId>jave-nativebin-linux64</artifactId>
<version>3.5.0</version>
</dependency>
Linux 64 位 arm:
<dependency>
<groupId>ws.schild</groupId>
<artifactId>jave-nativebin-linux-arm64</artifactId>
<version>3.5.0</version>
</dependency>
Linux 32 位 arm:
<dependency>
<groupId>ws.schild</groupId>
<artifactId>jave-nativebin-linux-arm32</artifactId>
<version>3.5.0</version>
</dependency>
Windows 64 位:
<dependency>
<groupId>ws.schild</groupId>
<artifactId>jave-nativebin-win64</artifactId>
<version>3.5.0</version>
</dependency>
MacOS 64 位:
<dependency>
<groupId>ws.schild</groupId>
<artifactId>jave-nativebin-osx64</artifactId>
<version>3.5.0</version>
</dependency>
2.2 视频转音频
将视频文件从一种格式转换为另一种格式,例如将 AVI 文件转换为 MPEG 文件。
File source = new File("D:\\AI智能体\\mp4\\01.大模型有什么缺陷.mp4");
File target = new File("D:\\AI智能体\\mp4\\01.大模型有什么缺陷.mp3");
AudioAttributes audio = new AudioAttributes();
audio.setCodec("libmp3lame");
audio.setBitRate(128000);
audio.setChannels(2);
audio.setSamplingRate(44100);
EncodingAttributes attrs = new EncodingAttributes();
attrs.setOutputFormat("mp3");
attrs.setAudioAttributes(audio);
Encoder encoder = new Encoder();
encoder.encode(new MultimediaObject(source), target, attrs);
2.3 视频格式转换
将一种视频格式转换为另外一种视频格式,例如将 mp4 转为 flv:
File source = new File("D:\\AI智能体\\mp4\\01.大模型有什么缺陷.mp4");
File target = new File("D:\\AI智能体\\mp4\\01.大模型有什么缺陷.flv");
AudioAttributes audio = new AudioAttributes();
audio.setCodec("libmp3lame");
audio.setBitRate(64000);
audio.setChannels(1);
audio.setSamplingRate(22050);
VideoAttributes video = new VideoAttributes();
video.setCodec("flv");
video.setBitRate(160000);
video.setFrameRate(15);
video.setSize(new VideoSize(400, 300));
EncodingAttributes attrs = new EncodingAttributes();
attrs.setOutputFormat("flv");
attrs.setAudioAttributes(audio);
attrs.setVideoAttributes(video);
Encoder encoder = new Encoder();
encoder.encode(new MultimediaObject(source), target, attrs);
2.4 获取视频时长
这个就是松哥的需求了,我这块举个简单例子。
public class App {
static SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("mm:ss");
public static void main(String[] args) throws EncoderException {
System.out.println("输入视频目录:");
String dir = new Scanner(System.in).next();
File folder = new File(dir);
List<String> files = sort(folder);
outputVideoTime(files);
}
private static void outputVideoTime(List<String> files) throws EncoderException {
for (String file : files) {
File video = new File(file);
if (video.isFile() && !video.getName().startsWith(".") && video.getName().endsWith(".mp4")) {
MultimediaObject multimediaObject = new MultimediaObject(video);
long duration = multimediaObject.getInfo().getDuration();
String s = "%s %s";
System.out.println(String.format(s, video.getName(), DATE_FORMAT.format(duration)));
} else if (video.isDirectory()) {
System.out.println(video.getName());
outputVideoTime(sort(video));
}
}
}
public static List<String> sort(File folder) {
return Arrays.stream(folder.listFiles()).map(f -> f.getAbsolutePath()).sorted(String.CASE_INSENSITIVE_ORDER).collect(Collectors.toList());
}
}
这段代码基本上都是 Java 基础语法,没啥难的,我也就不多说了。有不明白的地方欢迎加松哥微信讨论。
其实 Java 解决这个似乎也不难,也就是 20 行代码左右,似乎和 Python 不相上下。
三 总结
jave-all-deps 库是 Java 音视频处理领域的一个强大工具,它通过封装 ffmpeg 的复杂功能,为 Java 开发者提供了一个简单易用的音视频处理解决方案。该库解决了跨平台兼容性问题、简化了复杂的命令行操作,并简化了项目中的依赖管理。无论是进行格式转换、音频转码还是其他音视频处理任务,jave-all-deps 库都是一个值得考虑的选择。
通过本文的介绍,希望能够帮助读者更好地理解和使用 jave-all-deps 库。
来源:juejin.cn/post/7415723701947154473
127.0.0.1 和 localhost,如何区分?
在实际开发中,我们经常会用到 127.0.0.1
和 localhost
,那么,两者到底有什么区分呢?这篇文章,我们来详细了解 127.0.0.1
和 localhost
。
127.0.0.1
127.0.0.1
是一个特殊的 IPv4 地址,通常被称为“环回地址”或“回送地址”。它被用于测试和调试网络应用程序。
当你在计算机上向 127.0.0.1
发送数据包时,数据不会离开计算机,而是直接返回到本地。这种机制允许开发者测试网络应用程序而不需要实际的网络连接。
127.0.0.1
是一个专用地址,不能用于实际的网络通信,仅用于本地通信。除了 127.0.0.1
,整个 127.0.0.0/8
(即 127.0.0.1 到 127.255.255.255)范围内的地址都是保留的环回地址。
在 IPv6 中,类似的环回地址是 ::1
。如下图,为 MacOS的 /etc/hosts
文件中的内容:
使用场景
1. 开发和测试
- 开发人员常常使用
127.0.0.1
来测试网络应用程序,因为它不需要实际的网络连接。 - 可以在本地机器上运行服务器和客户端,进行开发和调试。
2. 网络配置和诊断: - 使用
ping 127.0.0.1
可以测试本地网络栈是否正常工作。 - 一些服务会绑定到
127.0.0.1
以限制访问范围,仅允许本地访问。
示例
运行一个简单的 Python HTTP 服务器并访问它:
python -m http.server --bind 127.0.0.1 8000
然后在浏览器中访问 http://127.0.0.1:8000,你会看到服务器响应。通过 127.0.0.1,开发人员和系统管理员可以方便地进行本地网络通信测试和开发工作,而不需要依赖实际的网络连接。
优点
- 快速测试:可以快速测试本地网络应用程序。
- 独立于网络:不依赖于实际的网络连接或外部网络设备。
- 安全:由于数据包不离开本地计算机,安全性较高。
缺点
- 局限性:只能用于本地计算机,不适用于与其他计算机的网络通信。
- 调试范围有限:无法测试跨网络的通信问题。
localhost
localhost
是一个特殊的域名,指向本地计算机的主机名。
- 在 IPv4 中,
localhost
通常映射到 IP 地址127.0.0.1
。 - 在 IPv6 中,
localhost
通常映射到 IP 地址::1
。
localhost
被定义在 hosts 文件中(例如,在 Linux 系统中是 /etc/hosts 文件)。如下图,为 MacOS的 /etc/hosts
文件中的内容:
因此,当你在应用程序中使用 localhost
作为目标地址时,系统会将其解析为 127.0.0.1
,然后进行相同的环回处理。
使用场景
- 开发和测试:开发人员常使用
localhost
来测试应用程序,因为它不需要实际的网络连接。 - 本地服务:一些服务(如数据库、Web 服务器等)可以配置为只在
localhost
上监听,以限制访问范围仅限于本地计算机,增强安全性。 - 网络调试:使用
localhost
可以帮助诊断网络服务问题,确保服务在本地环境中正常运行。
优点
- 易记:相对 IP 地址,
localhost
更容易记忆和输入。 - 一致性:在不同操作系统和环境中,
localhost
通常都被解析为127.0.0.1
。
缺点
- 依赖 DNS 配置:需要正确的 hosts 文件配置,如果配置错误可能导致问题。
- 与 127.0.0.1 相同的局限性:同样只能用于本地计算机。
两者对比
- 本质:
127.0.0.1
是一个 IP 地址,而localhost
是一个主机名。 - 解析方式:
localhost
需要通过 DNS 或 hosts 文件解析为127.0.0.1
,而127.0.0.1
是直接使用的 IP 地址。 - 易用性:
localhost
更容易记忆和输入,但依赖于正确的 DNS/hosts 配置。 - 性能:通常情况下,两者在性能上没有显著差异,因为
localhost
最终也会解析为127.0.0.1
。
结论
127.0.0.1
和 localhost
都是指向本地计算机的地址,适用于本地网络应用程序的测试和调试。选择使用哪个主要取决于个人偏好和具体需求。在需要明确指定 IP 地址的场景下,127.0.0.1
更为直接;而在需要易记和通用的主机名时,localhost
更为合适。两者在实际使用中通常是等价的,差别微乎其微。
来源:juejin.cn/post/7413189674107273257
桌面端Electron基础配置
机缘
机缘巧合之下获取到一个桌面端开发的任务。
为了最快的上手速度,最低的开发成本,选择了electron。
介绍
Electron是一个使用 JavaScript、HTML 和 CSS 构建桌面应用程序的框架。 嵌入 Chromium 和 Node.js 到 二进制的 Electron 允许您保持一个 JavaScript 代码代码库并创建 在Windows上运行的跨平台应用 macOS和Linux——不需要本地开发 经验。
主要结构
electron主要有一个主进程和一个或者多个渲染进程组成,方便的脚手架项目有
electron-vite
安装方式
npm i electron-vite -D
electron-vite分为3层结构
main // electron主进程
preload // electron预加载进程 node
renderer // electron渲染进程 vue
创建项目
npm create @quick-start/electron
项目创建完成启动之后
会在目录中生成一个out目录
out目录中会生成项目文件代码,在electron-vite中使用ESmodel来加载文件,启动的时候会被全部打包到out目录中合并在一起。所以一些使用CommonJs的node代码复制进来需要做些修改。npm安装的依赖依然可以使用CommonJs的方式引入。
node的引入
在前面的推荐的几篇文章中都有详细的讲解,无需多言。electron是以chrom+node,所以node的加入也非常的简单。
nodeIntegration: true,
main主进程中的简单配置
preload目录下引入node代码,留一个口子在min主进程中调用。
配置数据库
以sequelize为例
npm install --save sequelize
npm install --save sqlite3
做本地应用使用推荐sqlite3,使用本地数据库,当然了用其他的数据也没问题,用法和node中一样。需要注意的是C++代码编译的问题,可能会存在兼容性问题,如果一直尝试还是报错就换版本吧。electron-vite新版本问题不大,遇到过老版本一直编译失败的问题
测试能让用版本
- "electron": "^25.6.0",
- "electron-vite": "^1.0.27",
- "sequelize": "^6.33.0",
node-gyp vscode 这些安装环境网上找找也很多就不多说了。
import { Sequelize } from 'sequelize'
import log from '../config/log/log'
const path = require('path')
let documentsPath
if (process.env['ELECTRON_RENDERER_URL']) {
documentsPath = './out/config/sqlite/sqlite.db'
} else {
documentsPath = path.join(process.env.USERPROFILE, 'Documents') + '\\sqlite\\sqlite.db'
}
console.log('documentsPath-------------****-----------', documentsPath)
export const seq = new Sequelize({
dialect: 'sqlite',
storage: documentsPath
})
seq
.authenticate()
.then(() => {
log.info('数据库连接成功')
})
.catch((err) => {
log.error('数据库连接失败' + err)
})
终端乱码问题
"dev:win": "chcp 65001 && electron-vite dev",
chcp 65001只在win环境下添加
electron多页签
electron日志
import logger from 'electron-log'
logger.transports.file.level = 'debug'
logger.transports.file.maxSize = 30 * 1024 * 1024 // 最大不超过10M
logger.transports.file.format = '[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}]{scope} {text}' // 设置文件内容格式
var dayjs = require('dayjs')
const date = dayjs().format('YYYY-MM-DD') // 格式化日期为 yyyy-mm-dd
logger.transports.file.fileName = date + '.log' // 创建文件名格式为 '时间.log' (2023-02-01.log)
// 可以将文件放置到指定文件夹中,例如放到安装包文件夹中
const path = require('path')
let logsPath
if (process.env['ELECTRON_RENDERER_URL']) {
logsPath = './out/config/logs/' + date + '.log'
} else {
logsPath = path.join(process.env.USERPROFILE, 'Documents') + '\\logs\\' + date + '.log'
}
console.log('logsPath-------------****-----------', logsPath) // 获取到安装目录的文件夹名称
// 指定日志文件夹位置
logger.transports.file.resolvePath = () => logsPath
// 有六个日志级别error, warn, info, verbose, debug, silly。默认是silly
export default {
info(param) {
logger.info(param)
},
warn(param) {
logger.warn(param)
},
error(param) {
logger.error(param)
},
debug(param) {
logger.debug(param)
},
verbose(param) {
logger.verbose(param)
},
silly(param) {
logger.silly(param)
}
}
对应用做好日志维护是一个很重要的事情
主进程中也可以在main文件下监听
app.on('activate', function () {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
// 渲染进程崩溃
app.on('renderer-process-crashed', (event, webContents, killed) => {
log.error(
`APP-ERROR:renderer-process-crashed; event: ${JSON.stringify(event)}; webContents:${JSON.stringify(
webContents
)}; killed:${JSON.stringify(killed)}`
)
})
// GPU进程崩溃
app.on('gpu-process-crashed', (event, killed) => {
log.error(`APP-ERROR:gpu-process-crashed; event: ${JSON.stringify(event)}; killed: ${JSON.stringify(killed)}`)
})
// 渲染进程结束
app.on('render-process-gone', async (event, webContents, details) => {
log.error(
`APP-ERROR:render-process-gone; event: ${JSON.stringify(event)}; webContents:${JSON.stringify(
webContents
)}; details:${JSON.stringify(details)}`
)
})
// 子进程结束
app.on('child-process-gone', async (event, details) => {
log.error(`APP-ERROR:child-process-gone; event: ${JSON.stringify(event)}; details:${JSON.stringify(details)}`)
})
应用更新
在Electron中实现自动更新,需要使用electron-updater
npm install electron-updater --save
需要知道服务器地址,单版本号有可更新内容的时候可以通过事件监听控制更新功能
provider: generic
url: 'http://localhost:7070/urfiles'
updaterCacheDirName: 111-updater
import { autoUpdater } from 'electron-updater'
import log from '../config/log/log'
export const autoUpdateInit = (mainWindow) => {
let result = {
message: '',
result: {}
}
autoUpdater.setFeedURL('http://localhost:50080/latest.yml')
//设置自动下载
autoUpdater.autoDownload = false
autoUpdater.autoInstallOnAppQuit = false
// 监听error
autoUpdater.on('error', function (error) {
log.info('检测更新失败' + error)
result.message = '检测更新失败'
result.result = error
mainWindow.webContents.send('update', JSON.stringify(result))
})
// 检测开始
autoUpdater.on('checking-for-update', function () {
result.message = '检测更新触发'
result.result = ''
// mainWindow.webContents.send('update', JSON.stringify(result))
log.info(`检测更新触发`)
})
// 更新可用
autoUpdater.on('update-available', (info) => {
result.message = '有新版本可更新'
result.result = info
mainWindow.webContents.send('update', JSON.stringify(result))
log.info(`有新版本可更新${JSON.stringify(info)}${info}`)
})
// 更新不可用
autoUpdater.on('update-not-available', function (info) {
result.message = '检测更新不可用'
result.result = info
mainWindow.webContents.send('update', JSON.stringify(result))
log.info(`检测更新不可用${info}`)
})
// 更新下载进度事件
autoUpdater.on('download-progress', function (progress) {
result.message = '检测更新当前下载进度'
result.result = progress
mainWindow.webContents.send('update', JSON.stringify(result))
log.info(`检测更新当前下载进度${JSON.stringify(progress)}${progress}`)
})
// 更新下载完毕
autoUpdater.on('update-downloaded', function () {
//下载完毕,通知应用层 UI
result.message = '检测更新当前下载完毕'
result.result = {}
mainWindow.webContents.send('update', result)
autoUpdater.quitAndInstall()
log.info('检测更新当前下载完毕,开始安装')
})
}
export const updateApp = (ctx) => {
let message
if (ctx.params == 'inspect') {
console.log('检测是否有新版本')
message = '检测是否有新版本'
autoUpdater.checkForUpdates() // 开始检查是否有更新
}
if (ctx.params == 'update') {
message = '开始更新'
autoUpdater.downloadUpdate() // 开始下载更新
}
return (ctx.body = {
code: 200,
message,
result: {
currentVersion: 0
}
})
}
dev下想测试更新功能,可以在主进程main文件中添加
Object.defineProperty(app, 'isPackaged', {
get() {
return true
}
})
接口封装
eletron中可以像node一样走http的形式编写接口,但是更推荐用IPC走内存直接进行主进程和渲染进程之间的通信
前端
import { ElMessage } from 'element-plus'
import router from '../router/index'
export const getApi = (url: string, params: object) => {
return new Promise(async (resolve, rej) => {
try {
console.log('-------------------url+params', url, params)
// 如果有token的话
let token = sessionStorage.getItem('token')
// 走ipc
if (window.electron) {
const res = await window.electron.ipcRenderer.invoke('getApi', JSON.stringify({ url, params, token }))
console.log('res', res)
if (res?.code == 200) {
return resolve(res.result)
} else {
// token校验不通过退出登录
if (res?.error == 10002 || res?.error == 10002) {
router.push({ name: 'loginPage' })
}
// 添加接口错误的处理
ElMessage.error(res?.message || res || '未知错误')
rej(res)
}
} else {
// 不走ipc
}
} catch (err) {
console.error(url + '接口请求错误----------', err)
rej(err)
}
})
}
后端
ipcMain.handle('getApi', async (event, args) => {
const { url, params, token } = JSON.parse(args)
//
})
electron官方文档中提供的IPC通信的API有好几个,每个使用的场景不一样,根据情况来选择
node中使用的是esmodel和一般的node项目写法上还有些区别,得适应一下。
容易找到的都是渲染进程发消息,也就是vue发消息给node,但是node发消息给vue没有写
这时候就需要使用webContents方法来实现
this.mainWindow.webContents.send('receive-tcp', JSON.stringify({ code: key, data: res.data }))
使用webContents的时候在vue中一样是通过事件监听‘receive-tcp’事件来获取
本地图片读取
// node中IO操作是异步所以得订阅一下
const subscribeImage = new Promise((res, rej) => {
// 读取图片文件进行压缩
sharp(imagePath)
.webp({ quality: 80 })
.toBuffer((err, buffer) => {
if (err) {
console.error('读取本地图片失败Error converting image to buffer:', err)
rej(
(ctx.body = {
error: 10003,
message: '本地图片读取失败'
})
)
} else {
log.info(`读取本地图片成功:${ctx.params}`)
res({
code: 200,
msg: '读取本地图片成功:',
result: buffer.toString('base64')
})
}
})
})
TCP
既然写了桌面端,那数据交互的方式可能就不局限于http,也会有WS,TCP,等等其他的通信协议。
node中提供了Tcp模块,net
const net = require('net')
const server = net.createServer()
server.on('listening', function () {
//获取地址信息
let addr = server.address()
tcpInfo.TcpAddress = `ip:${addr.port}`
log.info(`TCP服务启动成功---------- ip:${addr.port}`)
})
//设置出错时的回调函数
server.on('error', function (err) {
if (err.code === 'EADDRINUSE') {
console.log('地址正被使用,重试中...')
tcpProt++
setTimeout(() => {
server.close()
server.listen(tcpProt, 'ip')
}, 1000)
} else {
console.error('服务器异常:', err)
}
})
TCP链接成功获取到数据之后在data事件中,就可以使用webContents方法来主动传递消息给渲染进程
也得对Tcp数据包进行解析,一般都是和外部系统协商沟通的数据格式。一般是十六进制或者是二进制数据,需要对数据进行解析,切割,缓存。
使用 Bufferdata = Buffer.concat([overageBuffer, data])
对数据进行处理
根据数据的长度对数据进行切割,判断数据的完整性质,对数据进行封包和拆包
粘包处理网上都有
处理完.toString()一下 over
socket.on('data', async (data) => {
...
let buffer = data.slice(0, packageLength) // 取出整个数据包
data = data.slice(packageLength) // 删除已经取出的数据包
// 数据处理
let key = buffer.slice(4, 8).reverse().toString('hex')
console.log('data', key, buffer)
let res = await isFunction[key](buffer)
this.mainWindow.webContents.send('receive-tcpData', JSON.stringify({ code: key, data: res.data }))
})
// 获取包长度的方法
getPackageLen(buffer) {
let bufferCopy = Buffer.alloc(12)
buffer.copy(bufferCopy, 0, 0, 12)
let bufferSize = bufferCopy.slice(8, this.headSize).reverse().readInt32BE(0)
console.log('bufferSize', bufferSize, bufferSize + this.headSize, buffer.length)
if (bufferSize > buffer.length - this.headSize) {
return -1
}
if (buffer.length >= bufferSize + this.headSize) {
return bufferSize + this.headSize // 返回实际长度 = 消息头长度 + 消息体长度
}
}
打完收工
来源:juejin.cn/post/7338265878289301567
短信接口被爆破了,一晚上差点把公司干破产了
背景
某天夜里,你正睡着觉,与周公神游。
老板打来电话:“小李,快看一下,系统出故障了,一个小时发了200条短信,再搞下去,我要破产了..."
巴拉巴拉...
于是,你赶紧跳下床,查了一个后台日志,发送短信API接口5s发送一次
,都已经发送了500条了。在达到每日限额后,自动终止了。很明显被黑客攻击了。
500 * 0.1 * 8 = 400
一晚上约干掉了400元人民币
睡意全无,赶紧起来排查原因
故障分析
我司是做国外业务的,用的短信厂家是RingRing
, 没有阿里云那种自带的强悍的预警和封禁功能。黑客通过伪造IP地址
和手机号
然后攻破了APP的短信接口,然后顺藤摸瓜的拿到相关发布的全部应用。于是,一个晚上,单个APP的每日短信限额和全部短信限额都攻破了。
APP使用的是https双向加密,黑客也不是单纯的爆破,没有大量的验证码错误日志。我们现在都不清楚黑客是通过什么方式绕过我们系统的,或者直接攻破了验证码
可能有懂这方面的掘友,可以分享一下哈
我们先上了一个临时方案,如果10分钟内,发送短信超过30条,且手机号超过60%都是同一个国家
,我们关闭短信发送功能10分钟,并推送告警
然后抓紧时间去升级验证码,提高安全标准
验证码
文字验证码

我司最开始用的就是这种,简单易用。但是任你把噪点和线条铺满
,整的面目全非,都防不住机器的识别,这种验证码直接pass了
优点:简易,具有一定的防爆破功能
缺点:防君子不防小人,在黑客面前,GG
滑块验证码
我司对于滑块验证码有几点考虑:
- 安全有待商榷,
- 背景图片需要符合国外市场和审美,需要UI介入,增加人工成本
- 不确定是否符合国外的习惯
基于这几点考虑,我司放弃了这个方案。但平心而论,国内用滑块验证码的是最多的,原因如下:
- 用户体验好
- 防破解性更强
- 适应移动设备
- 适用性广
npm install rc-slider-captcha
import SliderCaptcha from 'rc-slider-captcha';
const Demo = () => {
return (
<SliderCaptcha
request={async () => {
return {
bgUrl: 'background image url',
puzzleUrl: 'puzzle image url'
};
}}
onVerify={async (data) => {
console.log(data);
// verify data
return Promise.resolve();
}}
/>
);
};
滑块验证码是用的最多的验证码,操作简单,基本平替了图片验证码
图形顺序验证码 & 图形匹配验证码 & 语顺验证码


我司没有采用这种方案的原因如下:
- 我们的APP是多语言,点击文字这种方案不适用
- 没有找到免费且合适的APP插件
- 时间紧,项目紧急,没有功夫就研究
总结:
安全性更强,用户量越大的网站越受青睐
难度相对更大,频繁验证会流失一些用户
reCAPTCHA v3
综上,我司使用了reCAPTCHA
理由如下:
- 集成简单
- 自带控制台,方便管理和查看
- 谷歌出品,值得信赖,且有保障
<script src="https://www.google.com/recaptcha/api.js?render=reCAPTCHA_site_key"></script>
<script>
function onClick(e) {
e.preventDefault();
grecaptcha.ready(function() {
grecaptcha.execute('reCAPTCHA_site_key', {action: 'submit'}).then(function(token) {
// Add your logic to submit to your backend server here.
});
});
}
</script>
// 返回值
{
score: 1 // 评分0 到 1。1:确认为人类,0:确认为机器人。
hostname: "localhost"
success: true,
challenge_ts: "2024-xx-xTxx:xx:xxZ"
action: "homepage"
}
紧急上线后,安全性大大增强,再也没有遭受黑客袭击了
。本以为可以睡个安稳觉了,又有其他的问题了,听我细讲
根据官方文档,建议score取0.5
, 我们根据测试的情况,降低了标准,设置为0.3。上线后,很多用户投诉安全度过低,请30分后重试
。由于我们当时的业务是出行和游乐
, APP受限后,用户生活受到了很大限制,很多用户预约了我们的产品,却用不了,导致收到了大量的投诉。更糟糕的时候,我们的评分标准0.3是写死的,只能重新发布,一来二去,3天过去了。客服被用户骂了后,天天来我们技术部骂我们。哎,想想都是泪
我们紧急发布了一版,将评分标准设置成可配置的,通过API获取
, 暂定0.1。算是勉强度过了这一关
reCAPTCHA v2
把分数调整到0.1后,我们觉得不是很安全,有爆破的风险,于是在下个版本使用了v2
使用v2,一切相对平稳,APP短信验证码风波也算平安度过了
2FA
双因素验证(Two-factor authentication,简称2FA,又名二步验证、双重验证),是保证账户安全的一道有效防线。在登录或进行敏感操作时,需要输入验证器上的动态密码(类似于银行U盾),进一步保护您的帐户免受潜在攻击者的攻击。双因素验证的动态密码生成器分为软件和硬件两种,最常用的软件有OTP Auth和谷歌验证器 (Google Authenticator)


经市场调用,客户要求,后续的APP,我们的都采用2fa方案,一人一码,安全可靠
。
实现起来也比较简单,后端使用sha1加密一串密钥,生成哈希值,用户扫码绑定,然后每次将这个验证码提交给服务器进行比对即可
每次使用都要看一下验证码,感觉有点烦
服务器和手机进行绑定,是同一把密钥,每次输入都找半天。一旦用户更换手机,就必须生成全新的密钥。
总结
参考资料
来源:juejin.cn/post/7413322738315378697
Spring Boot整合Kafka+SSE实现实时数据展示
2024年3月10日
知识积累
为什么使用Kafka?
不使用Rabbitmq或者Rocketmq是因为Kafka是Hadoop集群下的组成部分,对于大数据的相关开发适应性好,且当前业务场景下不需要使用死信队列,不过要注意Kafka对于更新时间慢的数据拉取也较慢,因此对与实时性要求高可以选择其他MQ。
使用消息队列是因为该中间件具有实时性,且可以作为广播进行消息分发。
为什么使用SSE?
使用Websocket传输信息的时候,会转成二进制数据,产生一定的时间损耗,SSE直接传输文本,不存在这个问题
由于Websocket是双向的,读取日志的时候,如果有人连接ws日志,会发送大量异常信息,会给使用段和日志段造成问题;SSE是单向的,不需要考虑这个问题,提高了安全性
另外就是SSE支持断线重连;Websocket协议本身并没有提供心跳机制,所以长时间没有数据发送时,会将这个连接断掉,因此需要手写心跳机制进行实现。
此外,由于是长连接的一个实现方式,所以SSE也可以替代Websocket实现扫码登陆(比如通过SSE的超时组件在实现二维码的超时功能,具体实现我可以整理一下)
另外,如果是普通项目,不需要过高的实时性,则不需要使用Websocket,使用SSE即可
代码实现
Java代码
pom.xml引入SSE和Kafka
<!-- SSE,一般springboot开发web应用的都有 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- kafka,最主要的是第一个,剩下两个是测试用的 -->
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
<dependency
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.kafka</groupId>
<artifactId>kafka-clients</artifactId>
<version>3.4.0</version>
</dependency>
application.properties增加Kafka配置信息
# KafkaProperties
spring.kafka.bootstrap-servers=localhost:9092
spring.kafka.consumer.group-id=community-consumer-group
spring.kafka.consumer.auto-offset-reset=earliest
spring.kafka.producer.value-serializer=org.apache.kafka.common.serialization.StringSerializer
spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer
配置Kafka信息
@Configuration
public class KafkaProducerConfig {
@Value("${spring.kafka.bootstrap-servers}")
private String bootstrapServers;
@Bean
public Map<String, Object> producerConfigs() {
Map<String, Object> props = new HashMap<>();
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
return props;
}
@Bean
public ProducerFactory<String, String> producerFactory() {
return new DefaultKafkaProducerFactory<>(producerConfigs());
}
@Bean
public KafkaTemplate<String, String> kafkaTemplate() {
return new KafkaTemplate<>(producerFactory());
}
}
配置controller,通过web方式开启效果
@RestController
@RequestMapping(path = "sse")
public class KafkaSSEController {
private static final Map<String, SseEmitter> sseCache = new ConcurrentHashMap<>();
@Resource
private KafkaTemplate<String, String> kafkaTemplate;
@Resource
private SseEmitter sseEmitter;
/**
* @param message
* @apiNote 发送信息到Kafka主题中
*/
@PostMapping("/send")
public void sendMessage(@RequestBody String message) {
kafkaTemplate.send("my-topic", message);
}
/**
* 监听Kafka数据
*
* @param message
*/
@KafkaListener(topics = "my-topic", groupId = "my-group-id")
public void consume(String message) {
System.out.println("Received message: " + message);
//使用接口建立起sse连接后,监听到kafka消息则会发送给对应链接
SseEmitter sseEmitter = sseCache.get(id); if (sseEmitter != null) { sseEmitter.send(content); }
}
/**
* 连接sse服务
*
* @param id
* @return
* @throws IOException
*/
@GetMapping(path = "subscribe", produces = {MediaType.TEXT_EVENT_STREAM_VALUE})
public SseEmitter push(@RequestParam("id") String id) throws IOException {
// 超时时间设置为5分钟,用于演示客户端自动重连
SseEmitter sseEmitter = new SseEmitter(5_60_000L);
// 设置前端的重试时间为1s
// send(): 发送数据,如果传入的是一个非SseEventBuilder对象,那么传递参数会被封装到 data 中
sseEmitter.send(SseEmitter.event().reconnectTime(1000).data("连接成功"));
sseCache.put(id, sseEmitter);
System.out.println("add " + id);
sseEmitter.send("你好", MediaType.APPLICATION_JSON);
SseEmitter.SseEventBuilder data = SseEmitter.event().name("finish").id("6666").data("哈哈");
sseEmitter.send(data);
// onTimeout(): 超时回调触发
sseEmitter.onTimeout(() -> {
System.out.println(id + "超时");
sseCache.remove(id);
});
// onCompletion(): 结束之后的回调触发
sseEmitter.onCompletion(() -> System.out.println("完成!!!"));
return sseEmitter;
}
/**
* http://127.0.0.1:8080/sse/push?id=7777&content=%E4%BD%A0%E5%93%88aaaaaa
* @param id
* @param content
* @return
* @throws IOException
*/
@ResponseBody
@GetMapping(path = "push")
public String push(String id, String content) throws IOException {
SseEmitter sseEmitter = sseCache.get(id);
if (sseEmitter != null) {
sseEmitter.send(content);
}
return "over";
}
@ResponseBody
@GetMapping(path = "over")
public String over(String id) {
SseEmitter sseEmitter = sseCache.get(id);
if (sseEmitter != null) {
// complete(): 表示执行完毕,会断开连接
sseEmitter.complete();
sseCache.remove(id);
}
return "over";
}
}
前端方式
<html>
<head>
<script>
console.log('start')
const clientId = "your_client_id_x"; // 设置客户端ID
const eventSource = new EventSource(`http://localhost:9999/v1/sse/subscribe/${clientId}`); // 订阅服务器端的SSE
eventSource.onmessage = event => {
console.log(event.data)
const message = JSON.parse(event.data);
console.log(`Received message from server: ${message}`);
};
// 发送消息给服务器端 可通过 postman 调用,所以下面 sendMessage() 调用被注释掉了
function sendMessage() {
const message = "hello sse";
fetch(`http://localhost:9999/v1/sse/publish/${clientId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(message)
});
console.log('dddd'+JSON.stringify(message))
}
// sendMessage()
</script>
</head>
</html>
来源:juejin.cn/post/7356770034180898857
看了Kubernetes 源码后,我的Go水平突飞猛进
接口方式隐藏传入参数的细节
当方法的入参是一个结构体的时候,内部去调用时会看到入参过多的细节,这个时候可以将入参隐式转成结构,让内部只看到需要的方法即可。
type Kubelet struct{}
func (kl *Kubelet) HandlePodAdditions(pods []*Pod) {
for _, pod := range pods {
fmt.Printf("create pods : %s\n", pod.Status)
}
}
func (kl *Kubelet) Run(updates <-chan Pod) {
fmt.Println(" run kubelet")
go kl.syncLoop(updates, kl)
}
func (kl *Kubelet) syncLoop(updates <-chan Pod, handler SyncHandler) {
for {
select {
case pod := <-updates:
handler.HandlePodAdditions([]*Pod{&pod})
}
}
}
type SyncHandler interface {
HandlePodAdditions(pods []*Pod)
}
这里我们可以看到 Kubelet
本身有比较多的方法:
- syncLoop 同步状态的循环
- Run 用来启动监听循环
- HandlePodAdditions 处理Pod增加的逻辑
由于 syncLoop 其实并不需要知道 kubelet
上其他的方法,所以通过 SyncHandler
接口的定义,让 kubelet
实现该接口后,外面作为参数传入给 syncLoop
,它就会将类型转换为 SyncHandler
。
经过转换后 kubelet
上其他的方法在入参里面就看不到了,编码时就可以更加专注在 syncLoop
本身逻辑的编写。
但是这样做同样会带来一些问题,第一次研发的需求肯定是能满足我们的抽象,但是随着需求的增加和迭代,我们在内部需要使用 kubelet
其他未封装成接口的方法时,我们就需要额外传入 kubelet
或者是增加接口的封装,这都会增加我们的编码工作,也破坏了我们最开始的封装。
分层隐藏设计是我们设计的最终目的,在代码设计的过程中让一个局部关注到它需要关注的东西即可。
接口封装方便Mock测试
通过接口的抽象,我们在测试的时候可以把不关注的内容直接实例化成一个 Mock 的结构。
type OrderAPI interface {
GetOrderId() string
}
type realOrderImpl struct{}
func (r *realOrderImpl) GetOrderId() string {
return ""
}
type mockOrderImpl struct{}
func (m *mockOrderImpl) GetOrderId() string {
return "mock"
}
这里如果测试的时候不需要关注 GetOrderId
的方法是否正确,则直接用 mockOrderImpl
初始化 OrderAPI
即可,mock的逻辑也可以进行复杂编码
func TestGetOrderId(t *testing.T) {
orderAPI := &mockOrderImpl{} // 如果要获取订单id,且不是测试的重点,这里直接初始化成mock的结构体
fmt.Println(orderAPI.GetOrderId())
}
gomonkey 也同样能进行测试注入,所以如果以前的代码没能够通过接口封装也同样可以实现mock,而且这种方式更加强大
patches := gomonkey.ApplyFunc(GetOrder, func(orderId string) Order {
return Order{
OrderId: orderId,
OrderState: delivering,
}
})
return func() {
patches.Reset()
}
使用 gomonkey
能够更加灵活的进行 mock
, 它能直接设置一个方法的返回值,而接口的抽象只能够处理结构体实例化出来的内容。
接口封装底层多种实现
iptables 、ipvs等的实现就是通过接口的抽象来实现,因为所有网络设置都需要处理 Service 和 Endpoint ,所以抽象了 ServiceHandler
和 EndpointSliceHandler
// ServiceHandler 是一个抽象接口,用于接收有关服务对象更改的通知。
type ServiceHandler interface {
// OnServiceAdd 在观察到创建新服务对象时调用。
OnServiceAdd(service *v1.Service)
// OnServiceUpdate 在观察到现有服务对象的修改时调用。
OnServiceUpdate(oldService, service *v1.Service)
// OnServiceDelete 在观察到现有服务对象的删除时调用。
OnServiceDelete(service *v1.Service)
// OnServiceSynced 一旦所有初始事件处理程序都被调用并且状态完全传播到本地缓存时调用。
OnServiceSynced()
}
// EndpointSliceHandler 是一个抽象接口,用于接收有关端点切片对象更改的通知。
type EndpointSliceHandler interface {
// OnEndpointSliceAdd 在观察到创建新的端点切片对象时调用。
OnEndpointSliceAdd(endpointSlice *discoveryv1.EndpointSlice)
// OnEndpointSliceUpdate 在观察到现有端点切片对象的修改时调用。
OnEndpointSliceUpdate(oldEndpointSlice, newEndpointSlice *discoveryv1.EndpointSlice)
// OnEndpointSliceDelete 在观察到现有端点切片对象的删除时调用。
OnEndpointSliceDelete(endpointSlice *discoveryv1.EndpointSlice)
// OnEndpointSlicesSynced 一旦所有初始事件处理程序都被调用并且状态完全传播到本地缓存时调用。
OnEndpointSlicesSynced()
}
然后通过 Provider
注入即可,
type Provider interface {
config.EndpointSliceHandler
config.ServiceHandler
}
这个也是我在做组件的时候用的最多的一种编码技巧,通过将类似的操作进行抽象,能够在替换底层实现后,上层代码不发生改变。
封装异常处理
我们开启协程之后如果不对异常进行捕获,则会导致协程出现异常后直接 panic
,但是每次写一个 recover
的逻辑做全局类似的处理未免不太优雅,所以通过封装 HandleCrash
方法来实现。
package runtime
var (
ReallyCrash = true
)
// 全局默认的Panic处理
var PanicHandlers = []func(interface{}){logPanic}
// 允许外部传入额外的异常处理
func HandleCrash(additionalHandlers ...func(interface{})) {
if r := recover(); r != nil {
for _, fn := range PanicHandlers {
fn(r)
}
for _, fn := range additionalHandlers {
fn(r)
}
if ReallyCrash {
panic(r)
}
}
}
这里既支持了内部异常的函数处理,也支持外部传入额外的异常处理,如果不想要 Crash
的话也可以自己进行修改。
package runtime
func Go(fn func()) {
go func() {
defer HandleCrash()
fn()
}()
}
要起协程的时候可以通过 Go
方法来执行,这样也避免了自己忘记增加 panic
的处理。
waitgroup的封装
import "sync"
type Gr0up struct {
wg sync.WaitGr0up
}
func (g *Gr0up) Wait() {
g.wg.Wait()
}
func (g *Gr0up) Start(f func()) {
g.wg.Add(1)
go func() {
defer g.wg.Done()
f()
}()
}
这里最主要的是 Start
方法,内部将 Add
和 Done
进行了封装,虽然只有短短的几行代码,却能够让我们每次使用 waitgroup
的时候不会忘记去对计数器增加一和完成计数器。
信号量触发逻辑封装
type BoundedFrequencyRunner struct {
sync.Mutex
// 主动触发
run chan struct{}
// 定时器限制
timer *time.Timer
// 真正执行的逻辑
fn func()
}
func NewBoundedFrequencyRunner(fn func()) *BoundedFrequencyRunner {
return &BoundedFrequencyRunner{
run: make(chan struct{}, 1),
fn: fn,
timer: time.NewTimer(0),
}
}
// Run 触发执行 ,这里只能够写入一个信号量,多余的直接丢弃,不会阻塞,这里也可以根据自己的需要增加排队的个数
func (b *BoundedFrequencyRunner) Run() {
select {
case b.run <- struct{}{}:
fmt.Println("写入信号量成功")
default:
fmt.Println("已经触发过一次,直接丢弃信号量")
}
}
func (b *BoundedFrequencyRunner) Loop() {
b.timer.Reset(time.Second * 1)
for {
select {
case <-b.run:
fmt.Println("run 信号触发")
b.tryRun()
case <-b.timer.C:
fmt.Println("timer 触发执行")
b.tryRun()
}
}
}
func (b *BoundedFrequencyRunner) tryRun() {
b.Lock()
defer b.Unlock()
// 可以增加限流器等限制逻辑
b.timer.Reset(time.Second * 1)
b.fn()
}
写在最后
感谢你读到这里,如果想要看更多 Kubernetes 的文章可以订阅我的专栏: juejin.cn/column/7321… 。
来源:juejin.cn/post/7347221064429469746
Go 重构:尽量避免使用 else、break 和 continue
今天,我想谈谈相当简单的事情。我不会发明什么,但我在生产代码中经常看到这样的事情,所以我不能回避这个话题。
我经常要解开多个复杂的 if else
结构。多余的缩进、过多的逻辑只会加深理解。首先,这篇文章的主要目的是让代码更透明、更易读。不过,在某些情况下还是必须使用这些操作符。
else 操作
例如,我们有简单的用户处理程序:
func handleRequest(user *User) {
if user != nil {
showUserProfilePage(user)
} else {
showLoginPage()
}
}
如果没有提供用户,则需要将收到的请求重定向到登录页面。If else
似乎是个不错的决定。但我们的主要任务是确保业务逻辑单元在任何输入情况下都能正常工作。因此,让我们使用提前返回来实现这一点。
func handleRequest(user *User) {
if user == nil {
return showLoginPage()
}
showUserProfilePage(user)
}
逻辑是一样的,但是下面的做法可读性会更强。
break 操作
对我来说,Break
和 Continue
语句总是可以分解的信号。
例如,我们有一个简单的搜索任务。找到目标并执行一些业务逻辑,或者什么都不做。
func processData(data []int, target int) {
for i, value := range data {
if value == target {
performActionForTarget(data[i])
break
}
}
}
你应该始终记住,使用 break
操作符并不能保证整个数组都会被处理。这对性能有好处,因为我们丢弃了不必要的迭代,但对代码支持和可读性不利。因为我们永远不知道程序会在列表的开头还是结尾停止。
在某些情况下,带有子任务的简单功能可能会破坏这段代码。
func processData(data []int, target int, subtask int) {
for i, value := range data {
if value == subtask {
performActionForSubTarget(data[i])
}
if value == target {
performActionForTarget(data[i])
break
}
}
}
这样我们实际上可以拆出一个 find
的方法:
func processData(data []int, target int, subTarget int) {
found := findTarget(data, target)
if found > notFound {
performActionForTarget(found)
}
found = findTarget(data, subTarget)
if found > notFound {
performActionForSubTarget(found)
}
}
const notFound = -1
func findTarget(data []int, target int) int {
if len(data) == 0 {
return notFound
}
for _, value := range data {
if value == target {
return value
}
}
return notFound
}
同样的逻辑,但是拆分成更细粒度的方法,也有精确的返回语句,可以很容易地通过测试来实现。
continue 操作
该操作符与 break
类似。为了正确阅读代码,您应该牢记它对操作顺序的具体影响。
func processWords(words []string, substring string) {
for _, word := range words {
if !strings.Contains(word, substring) {
continue
}
// do some buisness logic
performAction(word)
}
}
Continue
使得这种简单的流程变得有点难以理解。
让我们写得更简洁些:
func processWords(words []string, substring string) {
for _, word := range words {
if strings.Contains(word, substring) {
performAction(word)
}
}
}
来源:juejin.cn/post/7290931758786756669
Innodb之buffer pool 图文详解
介绍
数据通常都是持久化在磁盘中的,innodb如果在处理客户端的请求时直接访问磁盘,无论是IO压力还是响应速度都是无法接受的;所以innodb在处理请求时,如果需要某个页的数据就会把整个页都加载到缓存中,数据页会长时间待在缓存中,根据一定策略进行淘汰,后续如果在缓存中的数据就不需要再次加载数据页了,这样即可提高响应时间又可以节省磁盘IO.
buffer pool
上述介绍中我们有提到一个缓存,这个缓存就指的是buffer pool
,通过innodb_buffer_pool_size
进行设置它的大小,默认为128MB,最小为5MB;可以根据各自线上机器的情况来设置它的大小,设置几个G甚至上百个G都是合理的。
内部组成
buffer pool中包含数据页、索引页、change buffer、自适应hash等内容;数据页、索引页在buffer pool中占用了大部分,不能简单的认为缓冲池中只有数据页和索引页;change buffer在较老的版本中叫insert buffer,后面对其进行了升级形成了现在的change buffer;自适应hash可以方便我们快速查询数据;锁信息、数据字典都是占用比较小的一部分;以上就是buffer pool的内部组成。
页数据
数据页、索引页数据在mysql启动的时候,会直接给申请一块连续的内存空间;如图:
上图中的缓冲页对应的就是磁盘中的数据,默认每个页大小为16KB,并且innodb为每个缓冲页都创建了一些控制块,每个控制块占用大小是800字节左右,需要额外付出百分之5的内存,它记录页所属的表空间编号、页号、缓存页在buffer pool中的地址、链表节点信息等。内存中间可能会有碎片进行对齐。
注意:这里只有缓冲页占用的空间是计算在buffer pool中的。
free链表
根据上面的图可以了解到,buffer pool中有一堆缓冲页,但innodb从磁盘中读取数据页时,由于不能直接知道哪些缓冲页是空闲的、哪些页已经被使用了,导致了不知道把要读取的数据页存放到哪里;此时就引入了一个free链表的概念。如图:
上图中可以看到free链表靠一个free节点连接到控制块中,其中free头节点仅占用40字节空间,但它也不计算在buffer pool中;有了这个free链表后每当需要从磁盘中加载一个页到buffer pool中时就可以从free链表上取一个控制块,把控制块所需信息填充上,同时把从磁盘上加载的数据放到对应的缓冲页上,并把该控制块从free链表中移除。此时就把磁盘中的页加载到内存中了,后续查询数据时就会优先查询该内存页,但每次查询时没办法立刻知道该页是在内存中还是磁盘中,上述操作后还会把这个页信息放到一个散列表中,以(表空间号+页号)作为key,以控制块地址作为value。
flush链表
上述介绍了读数据时通过优先读取内存页可以提高我们的响应速度以及节省磁盘io,那么如果是写数据呢?其实在innodb中,更改也会优先在内存中更改,在后续会根据一定规则(会在后续redolog文章中详细介绍)进行刷盘,在刷盘时只需要刷被更改的缓冲页即可,那么哪些缓存页被更改了innodb是不知道的,此时innodb就设计了flush链表,它和free链表几乎一样,如图:
当需要刷盘时会从flush链表中拿出一部分控制块对应的缓冲页进行刷盘,刷盘后控制块会从flush链表中移除,并放到free链表中。
LRU链表
由于buffer pool的内存区域是有限的,如果当来不及刷盘时内存就不够用了;此时innodb采用了LRU淘汰策略,标准的LRU算法:
- 如果数据页已经被加载到buffer pool中了,则直接把对应的控制块移动到LRU链表的头部;
- 如果数据页不在buffer pool中,则从磁盘中加载到buffer pool中,并把其对应的控制块放到LRU头部;此时内存空间已经满了的话,就会从链表中移除最后一个内存页。
但直接采用lru方案,在内存较小或者临时一次性加载到内存中的页过多时会把真正的热点数据刷掉。如预读和全表扫描。
- 线性预读:如果顺序访问的某个区的页面数超过
innodb_read_ahead_threshold
(默认值为56)就会触发一次异步预加载下一个区中的全部页到内存中; - 随机预读:如果某个区的13个连续的页都被加载到young区前1/4的位置中,会触发一次异步预加载本区中的全部页到内存中;
- 全表扫描:把整张表的数据都加载到内存中。
为了解决上述问题,innodb对这个淘汰策略做了一点改变。如图:
innodb根据innodb_old_blocks_pct
(默认37)参数把整个lru分成一定比例,具体的淘汰策略:
- 当数据页第一次加载进来时会先放到old head处,当链表满时会把old tail刷盘并从链表中移除。
- 当再次使用一个数据页时,并且该页在old区,会先判断在old区的停留时间是否超过
innodb_old_blocks_time
(默认1000ms),如果超过则把该数据页移动到young head处,反之移动到old head处。 - 当再次使用一个数据页时,并且该页young区为了节省移动的操作,会判断该缓冲页是否在young区前1/4中,如果在就不进行移动,如果不在则移动到young head处。
多buffer pool实例
对于buffer pool设置很大内存的实例,由于操作各种链表都需要进行加锁这样就比较影响整体的性能,为了提高并发处理能力,可以通过innodb_buffer_pool_instances
来设置buffer pool的实例个数。在mysql5.7.5版本中,会以chunk为单位向系统申请内存空间,每个buffer pool中包含了N个chunk。如图:
可以通过innodb_buffer_pool_chunk_size
(默认128M)来设置chunk的大小,只能在启动前设置好,启动后可以更改innodb_buffer_pool_size
的大小,但必须时innodb_buffer_pool_chunk_size * innodb_buffer_pool_instances的整数倍。
自适应hash
对于b+数来讲,整体的查询时间复杂度为O(logN),而innodb为了进一步提升性能,引入了自适应hash,对热点数据可以做到O(1)的时间复杂度就可以定位到数据在具体哪个页中。
innodb会自动根据访问频率和模式自动为某些热点页在内存中建立自适应哈希索引,规则:
- 模式:例如有一个联合索引(a,b);查询条件
where a = xxx
与where a = xxx and b = xxx
这就属于两种模式,如果交叉使用这两种查询,不会为其建立自适应哈希索引;
- 频率:使用一种默认访问的次数大于Math.min(100,页中数据/16)。
根据官方数据,启动自适应哈希索引读写速度可以提升2倍,辅助索引的连接性能可以提升5倍。
总结
通过上述的介绍,希望能帮助大家对buffer pool有一个基础的了解,想进一步深入了解可以通过执行show engine innodb status
观察下各种参数,通过对每个参数的细致研究可以全方面的掌握buffer pool。
创作不易,觉得文章写得不错帮忙点个赞吧!如转载请标明出处~
来源:juejin.cn/post/7413196978601295899
为什么很多人不推荐你用JWT?
为什么很多人不推荐你用JWT?
如果你经常看一些网上的带你做项目的教程,你就会发现 有很多的项目都用到了JWT。那么他到底安全吗?为什么那么多人不推荐你去使用。这个文章将会从全方面的带你了解JWT 以及他的优缺点。
什么是JWT?
这个是他的官网JSON Web Tokens - jwt.io
这个就是JWT
JWT 全称JSON Web Token
如果你还不熟悉JWT,不要惊慌!它们并不那么复杂!
你可以把JWT想象成一些JSON数据,你可以验证这些数据是来自你认识的人。
当然如何实现我们在这里不讲,有兴趣的可以去自己了解。
下面我们来说一下他的流程:
- 当你登录到一个网站,网站会生成一个JWT并将其发送给你。
- 这个JWT就像是一个包裹,里面装着一些关于你身份的信息,比如你的用户名、角色、权限等。
- 然后,你在每次与该网站进行通信时都会携带这个JWT。
- 每当你访问一个需要验证身份的页面时,你都会把这个JWT带给网站。
- 网站收到JWT后,会验证它的签名以确保它是由网站签发的,并且检查其中的信息来确认你的身份和权限。
- 如果一切都通过了验证,你就可以继续访问受保护的页面了。
为什么说JWT很烂?
首先我们用JWT应该就是去做这些事情:
- 用户注册网站
- 用户登录网站
- 用户点击并执行操作
- 本网站使用用户信息进行创建、更新和删除 信息
这些事情对于数据库的操作经常是这些方面的
- 记录用户正在执行的操作
- 将用户的一些数据添加到数据库中
- 检查用户的权限,看看他们是否可以执行某些操作
之后我们来逐步说出他的一些缺点
大小
这个方面毋庸置疑。
比如我们需要存储一个用户ID 为xiaou
如果存储到cookie里面,我们的总大小只有5个字节。
如果我们将 ID 存储在 一个 JWT 里。他的大小就会增加大概51倍
这无疑就增大了我们的宽带负担。
冗余签名
JWT的主要卖点之一就是其加密签名。因为JWT被加密签名,接收方可以验证JWT是否有效且可信。
但是,在过去20年里几乎每一个网络框架都可以在使用普通的会话cookie时获得加密签名的好处。
事实上,大多数网络框架会自动为你加密签名(甚至加密!)你的cookie。这意味着你可以获得与使用JWT签名相同的好处,而无需使用JWT本身。
实际上,在大多数网络身份验证情况下,JWT数据都是存储在会话cookie中的,这意味着现在有两个级别的签名。一个在cookie本身上,一个在JWT上。
令牌撤销问题
由于令牌在到期之前一直有效,服务器没有简单的方法来撤销它。
以下是一些可能导致这种情况危险的用例。
注销并不能真正使你注销!
想象一下你在推特上发送推文后注销了登录。你可能会认为自己已经从服务器注销了,但事实并非如此。因为JWT是自包含的,将在到期之前一直有效。这可能是5分钟、30分钟或任何作为令牌一部分设置的持续时间。因此,如果有人在此期间获取了该令牌,他们可以继续访问直到它过期。
可能存在陈旧数据
想象一下用户是管理员,被降级为权限较低的普通用户。同样,这不会立即生效,用户将继续保持管理员身份,直到令牌过期。
JWT通常不加密
因此任何能够执行中间人攻击并嗅探JWT的人都拥有你的身份验证凭据。这变得更容易,因为中间人攻击只需要在服务器和客户端之间的连接上完成
安全问题
对于JWT是否安全。我们可以参考这个文章
JWT (JSON Web Token) (in)security - research.securitum.com
同时我们也可以看到是有专门的如何攻击JWT的教程的
高级漏洞篇之JWT攻击专题 - FreeBuf网络安全行业门户
总结
总的来说,JWT适合作为单次授权令牌,用于在两个实体之间传输声明信息。
但是,JWT不适合作为长期持久数据的存储机制,特别是用于管理用户会话。使用JWT作为会话机制可能会引入一系列严重的安全和实现上的问题,相反,对于长期持久数据的存储,更适合使用传统的会话机制,如会话cookie,以及建立在其上的成熟的实现。
但是写了这么多,我还是想说,如果你作为自己开发学习使用,不考虑安全,不考虑性能的情况下,用JWT是完全没有问题的,但是一旦用到生产环境中,我们就需要避免这些可能存在的问题。
来源:juejin.cn/post/7365533351451672612