即时通讯 - 短轮询、长轮询、长连接、WebSocket
实现即时通讯主要有四种方式,它们分别是短轮询、长轮询、长连接、WebSocket
1. 短轮询
1.1 说明
传统的web通信模式。后台处理数据,需要一定时间,前端想要知道后端的处理结果,就要不定时的向后端发出请求以获得最新情况,得到想要的结果,或者超出规定的最长时间就终止再发请求。
1.2 优点:
前后端程序编写比较容易
1.3 缺点:
- 效率低:轮询的请求间隔时间一般是固定的,无论服务器是否有新的数据,都需要等待一段固定的时间。当数据更新的频率较低时,大部分请求都是无效的;
- 实时性差:如果数据在两次请求间发生了更新,那么用户只能在下一次轮询时才能得到最新数据;
- 浪费资源:高频率的操作功能,或者页面访问,导致的大量用户使用轮询时,会占用大量的网络资源,降低整体网络速度
1.4 基础实现:
每隔一段时间发送一个请求即可,得到想要的结果,或者超出规定的最长时间就终止再发请求。
let count = 0;
const timer = null;
// 超时时间
const MAX_TIME = 10 * 1000;
// 心跳间隙
const HEARTBEAT_INTERVAL = 1000;
/**
* @description: 模拟请求后端数据 (第6次时返回true)
*/
const fetchData = () => {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('fetch data...', count)
count += 1
if(count === 5) {
resolve(true);
}else {
resolve(false);
}
}, 1000)
});
};
/**
* @description: 异步轮询,当超时时或者接口返回true时,中断轮询
*/
const doSomething = async () => {
try {
let startTime = 0;
const timer = setInterval(async ()=>{
const res = await fetchData();
startTime += HEARTBEAT_INTERVAL;
if(res || startTime > MAX_TIME) {
clearInterval(timer)
}
}, HEARTBEAT_INTERVAL)
} catch (err) {
console.log(err);
}
};
doSomething();
2. 长轮询
2.1 说明
客户端向服务器发送Ajax请求,服务器接到请求后hold住连接
,直到有新消息才返回响应信息并关闭连接,客户端处理完响应信息后再向服务器发送新的请求
长轮询的实现原理与轮询类似,只是客户端的请求会保持打开状态,直到服务器返回响应或超时。在服务器端,可以使用阻塞方式处理长轮询请求,即服务器线程会一直等待直到有新的数据或事件,然后返回响应给客户端。客户端收到响应后,可以处理数据或事件,并随后发送下一个长轮询请求。
2.2 优点
长轮询相较于轮询技术来说,减少了不必要的网络流量和请求次数,降低了服务器和客户端的资源消耗
2.3 缺点
但是相对于传统的轮询技术,长轮询的实现更加复杂,并且需要服务器支持长时间保持连接的能力。
2.4 基础实现
超时和未得到想要的结果都需要重新执行原方法(递归实现)
async function subscribe() {
let response = await fetch("/subscribe");
if (response.status == 502) {
// 状态 502 是连接超时错误,
// 连接挂起时间过长时可能会发生,
// 远程服务器或代理会关闭它
// 让我们重新连接
await subscribe();
} else if (response.status != 200) {
// 一个 error —— 让我们显示它
showMessage(response.statusText);
// 一秒后重新连接
await new Promise(resolve => setTimeout(resolve, 1000));
await subscribe();
} else {
// 获取并显示消息
let message = await response.text();
showMessage(message);
// 再次调用 subscribe() 以获取下一条消息
await subscribe();
}
}
subscribe();
3. 长链接
3.1 说明
HTTP keep-alive 也称为 HTTP 长连接。它通过重用一个 TCP 连接来发送/接收多个 HTTP请求,来减少创建/关闭多个 TCP 连接的开销
3.1.1 为什么HTTP是短连接?
HTTP是短连接,客户端向服务器发送一个请求,得到响应后,连接就关闭。
例如,用户通过浏览器访问一个web站点上的某个网页,当网页内容加载完毕之后(已得到响应),用户可能需要花费几分钟甚至更多的时间来浏览网页内容,此时完全没有必要继续维持底层连。当用户需要访问其他网页时,再创建新的连接即可。
因此,HTTP连接的寿命通常都很短。这样做的好处是,可以极大的减轻服务端的压力。一般而言,一个站点能支撑的最大并发连接数也是有限的,
面对这么多客户端浏览器,不可能长期维持所有连接。每个客户端取得自己所需的内容后,即关闭连接,更加合理。
3.1.2 为什么要引入keep-alive(也称HTTP长连接)
通常一个网页可能会有很多组成部分,除了文本内容,还会有诸如:js、css、图片等静态资源,有时还会异步发起AJAX请求。
只有所有的资源都加载完毕后,我们看到网页完整的内容。然而,一个网页中,可能引入了几十个js、css文件,上百张图片,
如果每请求一个资源,就创建一个连接,然后关闭,代价实在太大了。
基于此背景,我们希望连接能够在短时间内得到复用,在加载同一个网页中的内容时,尽量的复用连接,这就是HTTP协议中keep-alive属性的作用。
- HTTP 1.0 中默认是关闭的,需要在http头加入"Connection: Keep-Alive",才能启用Keep-Alive;
- HTTP 1.1 中默认启用Keep-Alive,如果加入"Connection: close ",才关闭
注意:这里复用的是 TCP连接,并不是复用request
HTTP 的 Keep-Alive 也叫 HTTP 长连接,该功能是由「应用程序」实现的,可以使得用同一个 TCP 连接来发送和接收多个 HTTP 请求/应答,减少了 HTTP 短连接带来的多次 TCP 连接建立和释放的开销。
TCP 的 Keepalive 也叫 TCP 保活机制,该功能是由「内核」实现的,当客户端和服务端长达一定时间没有进行数据交互时,内核为了确保该连接是否还有效,就会发送探测报文,来检测对方是否还在线,然后来决定是否要关闭该连接
4. WebSocket
4.1 说明
Websocket是基于HTTP
协议的,在和服务端建立了链接后,服务端有数据有了变化后会主动推送给前端;
一般可以用于 股票交易行情分析、聊天室、在线游戏,替代轮询和长轮询。
4.2 优点
请求响应快,不浪费资源。(传统的http请求,其并发能力都是依赖同时发起多个TCP连接访问服务器实现的(因此并发数受限于浏览器允许的并发连接数),而websocket则允许我们在一条ws连接上同时并发多个请求,即在A请求发出后A响应还未到达,就可以继续发出B请求。由于TCP的慢启动特性(新连接速度上来是需要时间的),以及连接本身的握手损耗,都使得websocket协议的这一特性有很大的效率提升;http协议的头部太大,且每个请求携带的几百上千字节的头部大部分是重复的,websocket则因为复用长连接而没有这一问题。)
4.3 缺点
- 主流浏览器支持的Web Socket版本不一致;
- 服务端没有标准的API。
4.4 基础实现
这里使用了一个 网页和打印app的通信举例(部分敏感代码已省略)
const printConnect = () => {
try {
const host = 'ws://localhost:13888'
cloundPrintInfo.webSocket = new WebSocket(host)
// 通信
cloundPrintInfo.webSocket.onopen = () => {
// 获取打印机列表
cloundPrintInfo.webSocket.send(
JSON.stringify({
cmd: 'getPrinters',
version: '1.0',
})
)
}
// 通信返回
cloundPrintInfo.webSocket.onmessage = (msg: any) => {
const { data: returnData } = msg
// code 1000: 全部成功 1001: 部分失败 1002: 全部失败
const { cmd } = JSON.parse(`${returnData}`)
// 获取打印机数据
if (cmd === 'GETPRINTERS') {
printerInfoSet(returnData)
}
// 处理发送打印请求结果
if (cmd === 'PRINT') {
handlePrintResult(returnData)
}
// 批量推送打印结果
if (cmd === 'NOTIFYPRINTRESULT') {
cloudPrintTip(returnData)
}
}
// 通信失败
cloundPrintInfo.webSocket.onerror = () => {
printClose()
}
// 关闭通信
cloundPrintInfo.webSocket.onclose = () => {
printClose()
}
} catch (exception) {
console.log('建立连接失败', exception)
printClose()
}
}
在实际应用中,你可能需要处理更复杂的情况,比如重连逻辑、心跳机制来保持连接活跃、以及安全性问题等
重连逻辑:当WebSocket连接由于网络问题或其他原因断开时,客户端可能需要自动尝试重新连接
var socket;
var reconnectInterval = 5000; // 重连间隔时间,例如5秒
function connect() {
socket = new WebSocket('ws://localhost:3000');
socket.onopen = function(event) {
console.log('Connected to the WebSocket server');
};
socket.onclose = function(event) {
console.log('WebSocket connection closed. Reconnecting...');
setTimeout(connect, reconnectInterval); // 在指定时间后尝试重连
};
socket.onerror = function(error) {
console.error('WebSocket error:', error);
socket.close(); // 确保在错误后关闭连接,触发重连
};
}
connect(); // 初始连接
心跳机制:指定期发送消息以保持连接活跃的过程。这可以防止代理服务器或负载均衡器因为长时间的不活动而关闭连接
function heartbeat() {
if (socket.readyState === WebSocket.OPEN) {
socket.send('ping'); // 发送心跳消息,内容可以是'ping'
}
}
// 每30秒发送一次心跳
var heartbeatInterval = setInterval(heartbeat, 30000);
// 清除心跳定时器,通常在连接关闭时调用
function clearHeartbeat() {
clearInterval(heartbeatInterval);
}
socket.onclose = function(event) {
clearHeartbeat();
};
4种对比
从兼容性角度考虑,短轮询>长轮询>长连接SSE>WebSocket;
从性能方面考虑,WebSocket>长连接SSE>长轮询>短轮询。
参考文章:
来源:juejin.cn/post/7451612338408521743
面试官:MySQL单表过亿数据,如何优化count(*)全表的操作?
本文首发于公众号:托尼学长,立个写 1024 篇原创技术面试文章的flag,欢迎过来视察监督~
最近有好几个同学跟我说,他在技术面试过程中被问到这个问题了,让我找时间系统地讲解一下。
其实从某种意义上来说,这并不是一个严谨的面试题,接下来 show me the SQL,我们一起来看一下。
如下图所示,一张有 3000多万行记录的 user 表,执行全表 count 操作需要 14.8 秒的时间。
接下来我们稍作调整再试一次,神奇的一幕出现了,执行全表 count 操作竟然连 1 毫秒的时间都用不上。
这是为什么呢?
其实原因很简单,第一次执行全表 count 操作的时候,我用的是 MySQL InnoDB 存储引擎,而第二次则是用的 MySQL MyISAM 存储引擎。
这两者的差别在于,前者在执行 count(*) 操作的时候,需要将表中每行数据读取出来进行累加计数,而后者已经将表的总行数存储下来了,只需要直接返回即可。
当然,InnoDB 存储引擎对 count(*) 操作也进行了一些优化,如果该表创建了二级索引,其会通过全索引扫描的方式来代替全表扫描进行累加计数,
毕竟,二级索引值只存储了索引列和主键列两个字段,遍历计数肯定比存储所有字段的数据表的 IO 次数少很多,也就意味着其执行效率更高。
而且,MySQL 的优化器会选择最小的那个二级索引的索引文件进行遍历计数。
所以,这个技术面试题严谨的问法应该是 —— MySQL InnoDB 存储引擎单表过亿数据,如何优化 count(*) 全表的操作?
下面我们就来列举几个常见的技术解决方案,如下图所示:
(1)Redis 累加计数
这是一种最主流且简单直接的实现方式。
由于我们基本上不会对数据表执行 delete 操作,所以当有新的数据被写入表的时候,通过 Redis 的 incr 或 incrby 命令进行累加计数,并在用户查询汇总数据的时候直接返回结果即可。
如下图所示:
该实现方式在查询性能和数据准确性上两者兼得,Redis 需要同时负责累加计数和返回查询结果操作,缺点在于会引入缓存和数据库间的数据一致性的问题。
(2)MySQL 累加计数表 + 事务
这种实现方式跟“Redis 累加计数”大同小异,唯一的区别就是将计数的存储介质从 Redis 换成了 MySQL。
如下图所示:
但这么一换,就可以将写入表操作和累加计数操作放在一个数据库事务中,也就解决了缓存和数据库间的数据一致性的问题。
该实现方式在查询性能和数据准确性上两者兼得,但不如“Redis 累加计数”方式的性能高,在高并发场景下数据库会成为性能瓶颈。
(3)MySQL 累加计数表 + 触发器
这种实现方式跟“MySQL 累加计数表 + 事务”的表结构是一样的,如下图所示:
****
唯一的区别就是增加一个触发器,不用在工程代码中通过事务进行实现了。
CREATE TRIGGER `user_count_trigger` AFTER INSERT ON `user` FOR EACH ROW BEGIN UPDATE user_count SET count = count + 1 WHERE id = NEW.id;END
该实现方式在查询性能和数据准确性上两者兼得,与“MySQL 累加计数表 + 事务”方式相比,最大的好处就是不用污染工程代码了。
(4)MySQL 增加并行线程
在 MySQL 8.014 版本中,总算增加了并行查询的新特性,其通过参数 innodb_parallel_read_threads 进行设定,默认值为 4。
下面我们做个实验,将这个参数值调得大一些:
set local innodb_parallel_read_threads = 16;
然后,我们再来执行一次上文中那个 3000 多万行记录 user 表的全表 count 操作,结果如下所示:
参数调整后,执行全表 count 操作的时间由之前的 14.8 秒,降低至现在的 6.1 秒,是可以看到效果的。
接下来,我们继续将参数值调整得大一些,看看是否还有优化空间:
set local innodb_parallel_read_threads = 32;
然后,我们再来执行一次上文中那个 3000 多万行记录 user 表的全表 count 操作,结果如下所示:
参数调整后,执行全表 count 操作的时间竟然变长了,从原来的 6.1 秒变成了 6.8 秒,看样子优化空间已经达到上限了,再多增加执行线程数量只会适得其反。
该实现方式一样可以保证数据准确性,在查询性能上有所提升但相对有限,其最大优势是只需要调整一个数据库参数,在工程代码上不会有任何改动。
不过,如果数据库此时的负载和 IOPS 已经很高了,那开启并行线程或者将并行线程数量调大,会加速消耗数据库资源。
(5)MySQL 增加二级索引
还记得我们在上文中说的内容吗?
InnoDB 存储引擎对 count() 操作也进行了一些优化,如果该表创建了二级索引,其会通过全索引扫描的方式来代替全表扫描进行累加计数,*
毕竟,二级索引值只存储了索引列和主键列两个字段,遍历计数肯定比存储所有字段的数据表的IO次数少很多,也就意味着执行效率更高。
而且,MySQL 的优化器会选择最小的那个二级索引的索引文件进行遍历计数。
为了验证这个说法,我们给 user 表中最小的 sex 字段加一个二级索引,然后通过 EXPLAIN 命令看一下 SQL 语句的执行计划:
果然,这个 SQL 语句的执行计划会使用新建的 sex 索引,接下来我们执行一次看看时长:
果不其然,执行全表 count 操作走了 sex 二级索引后,SQL 执行时间由之前的 14.8 秒降低至现在的 10.6 秒,还是可以看到效果的。
btw:大家可能会觉得效果并不明显,这是因为我们用来测试的 user 表中算上主键 ID 只有七个字段,而且没有一个大字段。
反之,user 表中的字段数量越多,且包含的大字段越多,其优化效果就会越明显。
该实现方式一样可以保证数据准确性,在查询性能上有所提升但相对有限,其最大优势是只需要创建一个二级索引,在工程代码上不会有任何改动。
(6)SHOW TABLE STATUS
如下图所示,通过 SHOW TABLE STATUS 命令也可以查出来全表的行数:
我们常用于查看执行计划的 EXPLAIN 命令也能实现:
只不过,通过这两个命令得出来的表记录数是估算出来的,都不太准确。那到底有多不准确呢,我们来计算一下。
公式为:33554432 / 33216098 = 1.01
就这个 case 而言,误差率大概在百分之一左右。
该实现方式一样可以保证查询性能,无论表中有多大量级的数据都能毫秒级返回结果,且在工程代码方面不会有任何改动,但数据准确性上相差较多,只能用作大概估算。
来源:juejin.cn/post/7444919285170307107
订单超时自动取消,这7种方案真香!
大家好,我是苏三,又跟大家见面了。
前言
在电商、外卖、票务等系统中,订单超时未支付自动取消是一个常见的需求。
这个功能乍一看很简单,甚至很多初学者会觉得:"不就是加个定时器么?" 但真到了实际工作中,细节的复杂程度往往会超乎预期。
这里我们从基础到高级,逐步分析各种实现方案,最后分享一些在生产中常见的优化技巧,希望对你会有所帮助。
在电商、外卖、票务等系统中,订单超时未支付自动取消是一个常见的需求。
这个功能乍一看很简单,甚至很多初学者会觉得:"不就是加个定时器么?" 但真到了实际工作中,细节的复杂程度往往会超乎预期。
这里我们从基础到高级,逐步分析各种实现方案,最后分享一些在生产中常见的优化技巧,希望对你会有所帮助。
1. 使用延时队列(DelayQueue)
适用场景: 订单数量较少,系统并发量不高。
延时队列是Java并发包(java.util.concurrent
)中的一个数据结构,专门用于处理延时任务。
订单在创建时,将其放入延时队列,并设置超时时间。
延时时间到了以后,队列会触发消费逻辑,执行取消操作。
示例代码:
import java.util.concurrent.*;
public class OrderCancelService {
private static final DelayQueue delayQueue = new DelayQueue<>();
public static void main(String[] args) throws InterruptedException {
// 启动消费者线程
new Thread(() -> {
while (true) {
try {
OrderTask task = delayQueue.take(); // 获取到期任务
System.out.println("取消订单:" + task.getOrderId());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}).start();
// 模拟订单创建
for (int i = 1; i <= 5; i++) {
delayQueue.put(new OrderTask(i, System.currentTimeMillis() + 5000)); // 5秒后取消
System.out.println("订单" + i + "已创建");
}
}
static class OrderTask implements Delayed {
private final long expireTime;
private final int orderId;
public OrderTask(int orderId, long expireTime) {
this.orderId = orderId;
this.expireTime = expireTime;
}
public int getOrderId() {
return orderId;
}
@Override
public long getDelay(TimeUnit unit) {
return unit.convert(expireTime - System.currentTimeMillis(), TimeUnit.MILLISECONDS);
}
@Override
public int compareTo(Delayed o) {
return Long.compare(this.expireTime, ((OrderTask) o).expireTime);
}
}
}
优点:
- 实现简单,逻辑清晰。
缺点:
- 依赖内存,系统重启会丢失任务。
- 随着订单量增加,内存占用会显著上升。
适用场景: 订单数量较少,系统并发量不高。
延时队列是Java并发包(java.util.concurrent
)中的一个数据结构,专门用于处理延时任务。
订单在创建时,将其放入延时队列,并设置超时时间。
延时时间到了以后,队列会触发消费逻辑,执行取消操作。
示例代码:
import java.util.concurrent.*;
public class OrderCancelService {
private static final DelayQueue delayQueue = new DelayQueue<>();
public static void main(String[] args) throws InterruptedException {
// 启动消费者线程
new Thread(() -> {
while (true) {
try {
OrderTask task = delayQueue.take(); // 获取到期任务
System.out.println("取消订单:" + task.getOrderId());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}).start();
// 模拟订单创建
for (int i = 1; i <= 5; i++) {
delayQueue.put(new OrderTask(i, System.currentTimeMillis() + 5000)); // 5秒后取消
System.out.println("订单" + i + "已创建");
}
}
static class OrderTask implements Delayed {
private final long expireTime;
private final int orderId;
public OrderTask(int orderId, long expireTime) {
this.orderId = orderId;
this.expireTime = expireTime;
}
public int getOrderId() {
return orderId;
}
@Override
public long getDelay(TimeUnit unit) {
return unit.convert(expireTime - System.currentTimeMillis(), TimeUnit.MILLISECONDS);
}
@Override
public int compareTo(Delayed o) {
return Long.compare(this.expireTime, ((OrderTask) o).expireTime);
}
}
}
优点:
- 实现简单,逻辑清晰。
缺点:
- 依赖内存,系统重启会丢失任务。
- 随着订单量增加,内存占用会显著上升。
2. 基于数据库轮询
适用场景: 订单数量较多,但系统对实时性要求不高。
轮询是最容易想到的方案:定期扫描数据库,将超时的订单状态更新为“已取消”。
示例代码:
public void cancelExpiredOrders() {
String sql = "UPDATE orders SET status = 'CANCELLED' WHERE status = 'PENDING' AND create_time < ?";
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setTimestamp(1, new Timestamp(System.currentTimeMillis() - 30 * 60 * 1000)); // 30分钟未支付取消
int affectedRows = ps.executeUpdate();
System.out.println("取消订单数量:" + affectedRows);
} catch (SQLException e) {
e.printStackTrace();
}
}
优点:
- 数据可靠性强,不依赖内存。
- 实现成本低,无需引入第三方组件。
缺点:
- 频繁扫描数据库,会带来较大的性能开销。
- 实时性较差(通常定时任务间隔为分钟级别)。
优化建议:
- 为相关字段加索引,避免全表扫描。
- 结合分表分库策略,减少单表压力。
适用场景: 订单数量较多,但系统对实时性要求不高。
轮询是最容易想到的方案:定期扫描数据库,将超时的订单状态更新为“已取消”。
示例代码:
public void cancelExpiredOrders() {
String sql = "UPDATE orders SET status = 'CANCELLED' WHERE status = 'PENDING' AND create_time < ?";
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setTimestamp(1, new Timestamp(System.currentTimeMillis() - 30 * 60 * 1000)); // 30分钟未支付取消
int affectedRows = ps.executeUpdate();
System.out.println("取消订单数量:" + affectedRows);
} catch (SQLException e) {
e.printStackTrace();
}
}
优点:
- 数据可靠性强,不依赖内存。
- 实现成本低,无需引入第三方组件。
缺点:
- 频繁扫描数据库,会带来较大的性能开销。
- 实时性较差(通常定时任务间隔为分钟级别)。
优化建议:
- 为相关字段加索引,避免全表扫描。
- 结合分表分库策略,减少单表压力。
3. 基于Redis队列
适用场景: 适合对实时性有要求的中小型项目。
Redis 的 List 或 Sorted Set 数据结构非常适合用作延时任务队列。
我们可以把订单的超时时间作为 Score,订单 ID 作为 Value 存到 Redis 的 ZSet 中,定时去取出到期的订单进行取消。
例子:
public void addOrderToQueue(String orderId, long expireTime) {
jedis.zadd("order_delay_queue", expireTime, orderId);
}
public void processExpiredOrders() {
long now = System.currentTimeMillis();
Set<String> expiredOrders = jedis.zrangeByScore("order_delay_queue", 0, now);
for (String orderId : expiredOrders) {
System.out.println("取消订单:" + orderId);
jedis.zrem("order_delay_queue", orderId); // 删除已处理的订单
}
}
优点:
- 实时性高。
- Redis 的性能优秀,延迟小。
缺点:
- Redis 容量有限,适合中小规模任务。
- 需要额外处理 Redis 宕机或数据丢失的问题。
适用场景: 适合对实时性有要求的中小型项目。
Redis 的 List 或 Sorted Set 数据结构非常适合用作延时任务队列。
我们可以把订单的超时时间作为 Score,订单 ID 作为 Value 存到 Redis 的 ZSet 中,定时去取出到期的订单进行取消。
例子:
public void addOrderToQueue(String orderId, long expireTime) {
jedis.zadd("order_delay_queue", expireTime, orderId);
}
public void processExpiredOrders() {
long now = System.currentTimeMillis();
Set<String> expiredOrders = jedis.zrangeByScore("order_delay_queue", 0, now);
for (String orderId : expiredOrders) {
System.out.println("取消订单:" + orderId);
jedis.zrem("order_delay_queue", orderId); // 删除已处理的订单
}
}
优点:
- 实时性高。
- Redis 的性能优秀,延迟小。
缺点:
- Redis 容量有限,适合中小规模任务。
- 需要额外处理 Redis 宕机或数据丢失的问题。
4. Redis Key 过期回调
适用场景: 对超时事件实时性要求高,并且希望依赖 Redis 本身的特性实现简单的任务调度。
Redis 提供了 Key 的过期功能,结合 keyevent
事件通知机制,可以实现订单的自动取消逻辑。
当订单设置超时时间后,Redis 会在 Key 过期时发送通知,我们只需要订阅这个事件并进行相应的处理。
例子:
- 设置订单的过期时间:
public void setOrderWithExpiration(String orderId, long expireSeconds) {
jedis.setex("order:" + orderId, expireSeconds, "PENDING");
}
- 订阅 Redis 的过期事件:
public void subscribeToExpirationEvents() {
Jedis jedis = new Jedis("localhost");
jedis.psubscribe(new JedisPubSub() {
@Override
public void onPMessage(String pattern, String channel, String message) {
if (channel.equals("__keyevent@0__:expired")) {
System.out.println("接收到过期事件,取消订单:" + message);
// 执行取消订单的业务逻辑
}
}
}, "__keyevent@0__:expired"); // 订阅过期事件
}
适用场景: 对超时事件实时性要求高,并且希望依赖 Redis 本身的特性实现简单的任务调度。
Redis 提供了 Key 的过期功能,结合 keyevent
事件通知机制,可以实现订单的自动取消逻辑。
当订单设置超时时间后,Redis 会在 Key 过期时发送通知,我们只需要订阅这个事件并进行相应的处理。
例子:
- 设置订单的过期时间:
public void setOrderWithExpiration(String orderId, long expireSeconds) {
jedis.setex("order:" + orderId, expireSeconds, "PENDING");
}
- 订阅 Redis 的过期事件:
public void subscribeToExpirationEvents() {
Jedis jedis = new Jedis("localhost");
jedis.psubscribe(new JedisPubSub() {
@Override
public void onPMessage(String pattern, String channel, String message) {
if (channel.equals("__keyevent@0__:expired")) {
System.out.println("接收到过期事件,取消订单:" + message);
// 执行取消订单的业务逻辑
}
}
}, "__keyevent@0__:expired"); // 订阅过期事件
}
优点:
- 实现简单,直接利用 Redis 的过期机制。
- 实时性高,过期事件触发后立即响应。
缺点:
- 依赖 Redis 的事件通知功能,需要开启
notify-keyspace-events
配置。 - 如果 Redis 中大量使用过期 Key,可能导致性能问题。
注意事项: 要使用 Key 过期事件,需要确保 Redis 配置文件中 notify-keyspace-events
的值包含 Ex
。比如:
notify-keyspace-events Ex
最近就业形势比较困难,为了感谢各位小伙伴对苏三一直以来的支持,我特地创建了一些工作内推群, 看看能不能帮助到大家。
你可以在群里发布招聘信息,也可以内推工作,也可以在群里投递简历找工作,也可以在群里交流面试或者工作的话题。
添加苏三的私人微信:su_san_java,备注:掘金+所在城市,即可加入。
5. 基于消息队列(如RabbitMQ)
适用场景: 高并发系统,实时性要求高。
订单创建时,将订单消息发送到延迟队列(如RabbitMQ 的 x-delayed-message
插件)。
延迟时间到了以后,消息会重新投递到消费者,消费者执行取消操作。
示例代码(以RabbitMQ为例):
public void sendOrderToDelayQueue(String orderId, long delay) {
Map<String, Object> args = new HashMap<>();
args.put("x-delayed-type", "direct");
ConnectionFactory factory = new ConnectionFactory();
try (Connection connection = factory.newConnection();
Channel channel = connection.createChannel()) {
channel.exchangeDeclare("delayed_exchange", "x-delayed-message", true, false, args);
channel.queueDeclare("delay_queue", true, false, false, null);
channel.queueBind("delay_queue", "delayed_exchange", "order.cancel");
AMQP.BasicProperties props = new AMQP.BasicProperties.Builder()
.headers(Map.of("x-delay", delay)) // 延迟时间
.build();
channel.basicPublish("delayed_exchange", "order.cancel", props, orderId.getBytes());
} catch (Exception e) {
e.printStackTrace();
}
}
优点:
- 消息队列支持分布式,高并发下表现优秀。
- 数据可靠性高,不容易丢消息。
缺点:
- 引入消息队列增加了系统复杂性。
- 需要处理队列堆积的问题。
6. 使用定时任务框架
适用场景: 订单取消操作复杂,需要分布式支持。
定时任务框架,比如:Quartz、Elastic-Job,能够高效地管理任务调度,适合处理批量任务。
比如 Quartz 可以通过配置 Cron 表达式,定时执行订单取消逻辑。
示例代码:
@Scheduled(cron = "0 */5 * * * ?")
public void scanAndCancelOrders() {
System.out.println("开始扫描并取消过期订单");
// 这里调用数据库更新逻辑
}
优点:
- 成熟的调度框架支持复杂任务调度。
- 灵活性高,支持分布式扩展。
缺点:
- 对实时性支持有限。
- 框架本身较复杂。
7. 基于触发式事件流处理
适用场景: 需要处理实时性较高的订单取消,同时结合复杂业务逻辑,例如根据用户行为动态调整超时时间。
可以借助事件流处理框架(如 Apache Flink 或 Spark Streaming),实时地处理订单状态,并触发超时事件。
每个订单生成后,可以作为事件流的一部分,订单未支付时通过流计算触发超时取消逻辑。
示例代码(以 Apache Flink 为例):
DataStream orderStream = env.fromCollection(orderEvents);
orderStream
.keyBy(OrderEvent::getOrderId)
.process(new KeyedProcessFunction() {
@Override
public void processElement(OrderEvent event, Context ctx, Collector out ) throws Exception {
// 注册一个定时器
ctx.timerService().registerProcessingTimeTimer(event.getTimestamp() + 30000); // 30秒超时
}
@Override
public void onTimer(long timestamp, OnTimerContext ctx, Collector out ) throws Exception {
// 定时器触发,执行订单取消逻辑
System.out.println("订单超时取消,订单ID:" + ctx.getCurrentKey());
}
});
优点:
- 实时性高,支持复杂事件处理逻辑。
- 适合动态调整超时时间,满足灵活的业务需求。
缺点:
- 引入了流计算框架,系统复杂度增加。
- 对运维要求较高。
总结
每种方案都有自己的适用场景,大家在选择的时候,记得结合业务需求、订单量、并发量来综合考虑。
如果你的项目规模较小,可以直接用延时队列或 Redis;而在大型高并发系统中,消息队列和事件流处理往往是首选。
当然,代码实现只是第一步,更重要的是在实际部署和运行中进行性能调优,保证系统的稳定性。
来源:juejin.cn/post/7451018774743269391
一些之前遇到过但没答上来的Android面试题
这段时间面了几家公司,也跟不同的面试官切磋了一些面试题,有的没啥难度,有的则是问到了我的知识盲区,没办法,Android能问的东西太多了,要全覆盖到太难了,既然没法全覆盖,那么只好亡羊补牢,将这些没答上来的题目做下记录,让自己如果下次遇到了可以答上来
TCP与UDP有哪些差异
这道题回答的不全,仅仅只是将两个协议的概念说了一下,但是真正的差异却没有真正答上来,后来查询了一下资料,两者的差异如下
- TCP是传输控制协议,是面向连接的协议,发送数据前需要建立连接,TCP传输的数据不会丢失,不会重复,会按照顺序到达
- 与TCP相对的,UDP是无连接的协议,发送数据前不需要建立连接,数据没有可靠性
- TCP的通信类似于打电话,需要确认身份后才可以通话,而UDP更像是广播,不关心对方是不是接收,只需要播报出去即可
- TCP支持点对点通信,而UDP支持一对一,一对多,多对一,多对多
- TCP传输的是字节流,而UDP传输的是报文
- TCP首部开销为20个字节,而UDP首部开销是8个字节
- UDP主机不需要维持复杂的连接状态表
TCP的三次握手
这道题以及下面那道虽然说上来了,但是也没有说的很对,仅仅只是说了下每次握手或者挥手的目的,中间的过程没有说出来,以下是三次握手以及四次挥手的详细过程
- 第一次握手:客户端将SYN置为1,随机生成一个初始序列号seq发送给服务端,客户端进入SYN_SENT状态
- 第二次握手:服务端收到客户端的SYN=1之后,知道客户端请求建立连接,将自己的SYN置1,ACK置1,产生一个ack=seq+1,并随机产生一个自己的初始序列号,发送给客户端,服务端进入SYN_RCVD状态
- 第三次握手:客户端检查ack是否为序列号+1,ACK是否为1,检查正确之后将自己的ACK置为1,产生一个ack=服务器的seq+1,发送给服务器;进入ESTABLISHED状态;服务器检查ACK为1和ack为序列号+1之后,也进入ESTABLISHED状态;完成三次握手,连接建立
TCP的四次挥手
- 第一次挥手:客户端将FIN设置为1,发送一个序列号seq给服务端,客户端进入FIN_WAIT_1状态
- 第二次挥手:服务端收到FIN之后,发送一个ACK为1,ack为收到的序列号加一,服务端进入CLOSE_WAIT状态,这个时候客户端已经不会再向服务端发送数据了
- 第三次挥手:服务端将FIN置1,发送一个序列号给客户端,服务端进入LAST_ACK状态
- 第四次挥手:客户端收到服务器的FIN后,进入TIME_WAIT状态,接着将ACK置1,发送一个ack=序列号+1给服务器,服务器收到后,确认ack后,变为CLOSED状态,不再向客户端发送数据。客户端等待2* MSL(报文段最长寿命)时间后,也进入CLOSED状态。完成四次挥手
从浏览器输入地址到最终显示页面的整个过程
这个真的知识盲区了,谁会平时没事在用浏览器的时候去思考这个问题呢,结果一查居然还是某大厂的面试题,算了也了解下吧
- 第一步,浏览器查询DNS,获取域名对应的ip地址
- 第二步,获取ip地址后,浏览器向服务器建立连接请求,发起三次握手请求
- 第三步,连接建立好之后,浏览器向服务器发起http请求
- 第四步,服务器收到请求之后,根据路径的参数映射到特定的请求处理器进行处理,并将处理结果以及相应的视图返回给浏览器
- 第五步,浏览器解析并渲染视图,若遇到js,css以及图片等静态资源,则重复向服务器请求相应资源
- 第六步,浏览器根据请求到的数据,资源渲染页面,最终将完整的页面呈现在浏览器上
为什么Zygote进程使用socket通信而不是binder
应用层选手遇到偏底层问题就头疼了,但是这个问题还是要知道的,毕竟跟我们app的启动流程相关
- 原因一:从初始化时机上,
Binder
通信需要在Android运行时以及Binder
驱动已经初始化之后才能使用,而在这之前,Zygote
已经启动了,所以只能使用socket
通信 - 原因二:从出现的先后顺序上,
Zygote
相比于Binder
机制,更早的被设计以及投入使用,所以在Android的早期版本中,Android就已经使用socket
来监听其他进程的请求 - 原因三:从使用上,
socket
通信不依赖于Binder
机制,它是一种简单通用的IPC机制,也不需要复杂的接口定义 - 原因四:从兼容性上来讲,
socket
是一种跨平台的IPC机制,可以在不同的操作系统和环境中使用。 - 原因五:从性能上来讲,由于使用
Zygote
通信并不是频繁的操作,所以使用socket
通信不会对系统性能造成显著影响 - 原因六:从安全性上来讲,使用
socket
可以确保只有系统中特定的服务如system_server
才能与Zygote通信,从而提升一定的安全性
使用Binder的好处有哪些
上面那个问题问好了紧接着就是这道题,我嗯嗯啊啊的零碎说了几个,肯定也是不过关的,回头查了下资料,使用Binder
的优势如下
- 从效率上来讲,
Binder
比较高效,相比较于其他几种进程的通信方式(管道,消息队列,Socket,共享内存),Binder
只需要拷贝一次内存就好了,而除了共享内存,其余都都要拷贝两次内存,共享内存虽然不需要拷贝,但是实现方式复杂,所以综合考虑Binder
占优势 - 使用的是更加便于理解,更简单的面向对象的IPC通信方式
Binder
既支持同步调用,也支持异步调用Binder
使用UID和PID来验证请求的来源,这样可以确保每个Binder事务可以精确到发起者,为进程间的通信提供了保障Binder
是基于c/s架构,架构清晰明确,Server端与Client端相对独立Binder
有一套易于使用的API供进程间通信,将复杂的内部实现隐藏起来
如果一个线程连续调用两次start,会怎样?
会怎样?谁知道呀,正常人谁会没事去调用两次start
呢?但是这个还真有人问了,我只能说没遇到过,后来回去自己试了下才知道

如上述代码所示,有一个线程,然后连续调用了两次start
方法,当我们运行一下这段代码后,得到的结果如下

可以发现线程有正常运行,但同时也因为多调了一次start
而抛出了异常,这个异常在start
方法里面就能看到

有一个状态为started
,正常第一次启动线程时候,started
为false,所以是不会抛出异常的,started
为true的地方是在下面这个位置

调用了native方法nativeCreated
后,started
状态位才变成true,这个时候如果再去调用start
方法,那么必然会抛出异常
如何处理协程并发的数据安全
之前遇到过这么个问题,并发处理的协程之间是否可以保证数据安全,这个由于之前有实验过,所以想都没想就说可以保证数据安全,但面试官只是呵呵了一下,我捉摸着难道不对吗,后来回去试了一下才发现,不一定就能保证数据安全,看下面这段代码

这段代码里面在runBlocking
中创建了1000个协程,每一个协程都对变量count
做自增操作,最后把结果打印出来,我们预期的是打印出的结果就是1000,实际结果如下

看到的确就是1000,没啥毛病,多试几次也是一样的,但是如果换一种写法试试看呢

原本都是runBlocking
里面的子协程,现在将这些协程变成非runBlocking
的子协程,结果是不是还是1000呢,看下结果

明显不是了,所以并发处理的协程,并不能保证数据安全,那么如何可以让数据安全呢,有以下几个办法
原子类

这个好理解,同处理线程安全差不多
channel

receive
函数只有等到阻塞队列里面有数据的时候才会执行,没有数据的时候会一直等待,所以这就能保证这些协程可以并发执行,不过要注意的是这里的Channel
一定要设置队列大小,不然程序会一直阻塞,receive
一直在等待队列里面有数据
mutex

使用互斥锁的方式,withLock
函数内部执行了获取锁跟释放锁逻辑,将变量count
保护起来,实现数据安全,除此之外,还可以使用lock
与unLock
函数来实现,代码如下

总结
总的来讲自己在系统层面,偏底层的那些问题上,还是掌握的不多,这个也跟自己多年徘徊在应用层开发有关,底层知识用到的不多,自然也就忽略了,但是如果面试的话,就算是面的应用层,也是需要知道一些底层方面的知识,不然面试官随便问几个,你不会,别人会,岗位不就被别人拿走了吗
来源:juejin.cn/post/7402204610978545673
妙用MyBatisPlus,12个实战技巧解锁新知识
妙用MyBatisPlus,12个实战技巧解锁新知识
前言
说起数据库ORM,我忽然想起了小时候外婆做的那锅鲜美的羊肉汤。平常人家做的羊肉汤无非是几块肉、几片姜,味道寡淡得很,喝了和喝白开水差不多。但外婆的汤,那是另一回事儿 —— 一锅汤,香气四溢,肉质软烂,汤头浓郁得能让人连碗都想舔干净。
写代码何尝不是如此?以前写Mybatis,就像是在煮一锅没有灵魂的羊肉汤:原料都在,但就是不够鲜美。代码繁琐,每写一个查询都像是在不断调味,却怎么也调不出那种令人惊艳的味道。直到遇见MyBatisPlus,一切都变了 —— 这就像是从普通的羊肉汤,突然升级到了外婆秘制的顶级羊肉汤!
MyBatisPlus就像一位精通厨艺的帮厨,它帮你处理了所有繁琐的准备工作。想要一个复杂的查询?不用自己一刀一刀地切肉、一勺一勺地调味,框架已经帮你准备好了。你只需要轻轻地指挥,代码就像汤汁一样顺滑流畅,性能更是鲜美可口。
在接下来的篇幅里,我将与你分享12个MyBatisPlus优化的"秘制配方"。相信看完这些,你写的每一行代码,都会像外婆的羊肉汤一样,让人回味无穷。
耐心看完,你一定有所收获。
避免使用isNull判断
// ❌ 不推荐
LambdaQueryWrapper<User> wrapper1 = new LambdaQueryWrapper<>();
wrapper1.isNull(User::getStatus);
// ✅ 推荐:使用具体的默认值
LambdaQueryWrapper<User> wrapper2 = new LambdaQueryWrapper<>();
wrapper2.eq(User::getStatus, UserStatusEnum.INACTIVE.getCode());
- 📝 原因:
- 使用具体的默认值可以提高代码的可读性和维护性
- NULL值会使索引失效,导致MySQL无法使用索引进行查询优化
- NULL值的比较需要特殊的处理逻辑,增加了CPU开销
- NULL值会占用额外的存储空间,影响数据压缩效率
明确Select字段
// ❌ 不推荐
// 默认select 所有字段
List<User> users1 = userMapper.selectList(null);
// ✅ 推荐:指定需要的字段
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.select(User::getId, User::getName, User::getAge);
List<User> users2 = userMapper.selectList(wrapper);
- 📝 原因:
- 避免大量无用字段的网络传输开销
- 可以利用索引覆盖,避免回表查询
- 减少数据库解析和序列化的负担
- 降低内存占用,特别是在大量数据查询时
批量操作方法替代循环
// ❌ 不推荐
for (User user : userList) {
userMapper.insert(user);
}
// ✅ 推荐
userService.saveBatch(userList, 100); // 每批次处理100条数据
// ✅ 更优写法:自定义批次大小
userService.saveBatch(userList, BatchConstants.BATCH_SIZE);
- 📝 原因:
- 减少数据库连接的创建和销毁开销
- 批量操作可以在一个事务中完成,提高数据一致性
- 数据库可以优化批量操作的执行计划
- 显著减少网络往返次数,提升吞吐量
Exists方法子查询
// ❌ 不推荐
wrapper.inSql("user_id", "select user_id from order where amount > 1000");
// ✅ 推荐
wrapper.exists("select 1 from order where order.user_id = user.id and amount > 1000");
// ✅ 更优写法:使用LambdaQueryWrapper
wrapper.exists(orderService.lambdaQuery()
.gt(Order::getAmount, 1000)
.apply("order.user_id = user.id"));
- 📝 原因:
- EXISTS是基于索引的快速查询,可以使用到索引
- EXISTS在找到第一个匹配项就会停止扫描
- IN子查询需要加载所有数据到内存后再比较
- 当外表数据量大时,EXISTS的性能优势更明显
使用orderBy代替last
// ❌ 不推荐:SQL注入风险
wrapper.last("ORDER BY " + sortField + " " + sortOrder);
// ❌ 不推荐:直接字符串拼接
wrapper.last("ORDER BY FIELD(status, 'active', 'pending', 'inactive')");
// ✅ 推荐:使用 Lambda 安全排序
wrapper.orderBy(true, true, User::getStatus);
// ✅ 推荐:多字段排序示例
wrapper.orderByAsc(User::getStatus)
.orderByDesc(User::getCreateTime);
- 📝 原因:
- 直接拼接SQL容易导致SQL注入攻击
- 动态SQL可能破坏SQL语义完整性
- 影响SQL语句的可维护性和可读性
- last会绕过MyBatis-Plus的安全检查机制
使用LambdaQuery确保类型安全
// ❌ 不推荐:字段变更后可能遗漏
QueryWrapper<User> wrapper1 = new QueryWrapper<>();
wrapper1.eq("name", "张三").gt("age", 18);
// ✅ 推荐
LambdaQueryWrapper<User> wrapper2 = new LambdaQueryWrapper<>();
wrapper2.eq(User::getName, "张三")
.gt(User::getAge, 18);
// ✅ 更优写法:使用链式调用
userService.lambdaQuery()
.eq(User::getName, "张三")
.gt(User::getAge, 18)
.list();
- 📝 原因:
- 编译期类型检查,避免字段名拼写错误
- IDE可以提供更好的代码补全支持
- 重构时能自动更新字段引用
- 提高代码的可维护性和可读性
用between代替ge和le
// ❌ 不推荐
wrapper.ge(User::getAge, 18)
.le(User::getAge, 30);
// ✅ 推荐
wrapper.between(User::getAge, 18, 30);
// ✅ 更优写法:条件动态判断
wrapper.between(ageStart != null && ageEnd != null,
User::getAge, ageStart, ageEnd);
- 📝 原因:
- 生成的SQL更简洁,减少解析开销
- 数据库优化器可以更好地处理范围查询
- 代码更易读,语义更清晰
- 减少重复编写字段名的机会
排序字段注意索引
// ❌ 不推荐
// 假设lastLoginTime无索引
wrapper.orderByDesc(User::getLastLoginTime);
// ✅ 推荐
// 主键排序
wrapper.orderByDesc(User::getId);
// ✅ 更优写法:组合索引排序
wrapper.orderByDesc(User::getStatus) // status建立了索引
.orderByDesc(User::getId); // 主键排序
- 📝 原因:
- 索引天然具有排序特性,可以避免额外的排序操作
- 无索引排序会导致文件排序,极大影响性能
- 当数据量大时,内存排序可能导致溢出
- 利用索引排序可以实现流式读取
分页参数设置
// ❌ 不推荐
wrapper.last("limit 1000"); // 一次查询过多数据
// ✅ 推荐
Page<User> page = new Page<>(1, 10);
userService.page(page, wrapper);
// ✅ 更优写法:带条件的分页查询
Page<User> result = userService.lambdaQuery()
.eq(User::getStatus, "active")
.page(new Page<>(1, 10));
- 📝 原因:
- 控制单次查询的数据量,避免内存溢出
- 提高首屏加载速度,优化用户体验
- 减少网络传输压力
- 数据库资源利用更合理
条件构造处理Null值
// ❌ 不推荐
if (StringUtils.isNotBlank(name)) {
wrapper.eq("name", name);
}
if (age != null) {
wrapper.eq("age", age);
}
// ✅ 推荐
wrapper.eq(StringUtils.isNotBlank(name), User::getName, name)
.eq(Objects.nonNull(age), User::getAge, age);
// ✅ 更优写法:结合业务场景
wrapper.eq(StringUtils.isNotBlank(name), User::getName, name)
.eq(Objects.nonNull(age), User::getAge, age)
.eq(User::getDeleted, false) // 默认查询未删除记录
.orderByDesc(User::getCreateTime); // 默认按创建时间倒序
- 📝 原因:
- 优雅处理空值,避免无效条件
- 减少代码中的if-else判断
- 提高代码可读性
- 防止生成冗余的SQL条件
⚠️ 下面就要来一些高级货了
查询性能追踪
// ❌ 不推荐:简单计时,代码冗余
public List<User> listUsers(QueryWrapper<User> wrapper) {
long startTime = System.currentTimeMillis();
List<User> users = userMapper.selectList(wrapper);
long endTime = System.currentTimeMillis();
log.info("查询耗时:{}ms", (endTime - startTime));
return users;
}
// ✅ 推荐:使用 Try-with-resources 自动计时
public List<User> listUsersWithPerfTrack(QueryWrapper<User> wrapper) {
try (PerfTracker.TimerContext ignored = PerfTracker.start()) {
return userMapper.selectList(wrapper);
}
}
// 性能追踪工具类
@Slf4j
public class PerfTracker {
private final long startTime;
private final String methodName;
private PerfTracker(String methodName) {
this.startTime = System.currentTimeMillis();
this.methodName = methodName;
}
public static TimerContext start() {
return new TimerContext(Thread.currentThread().getStackTrace()[2].getMethodName());
}
public static class TimerContext implements AutoCloseable {
private final PerfTracker tracker;
private TimerContext(String methodName) {
this.tracker = new PerfTracker(methodName);
}
@Override
public void close() {
long executeTime = System.currentTimeMillis() - tracker.startTime;
if (executeTime > 500) {
log.warn("慢查询告警:方法 {} 耗时 {}ms", tracker.methodName, executeTime);
}
}
}
}
- 📝 原因:
- 业务代码和性能监控代码完全分离
- try-with-resources 即使发生异常,close() 方法也会被调用,确保一定会记录耗时
- 不需要手动管理计时的开始和结束
- 更优雅
枚举类型映射
// 定义枚举
public enum UserStatusEnum {
NORMAL(1, "正常"),
DISABLED(0, "禁用");
@EnumValue // MyBatis-Plus注解
private final Integer code;
private final String desc;
}
// ✅ 推荐:自动映射
public class User {
private UserStatusEnum status;
}
// 查询示例
userMapper.selectList(
new LambdaQueryWrapper<User>()
.eq(User::getStatus, UserStatusEnum.NORMAL)
);
- 📝 原因:
- 类型安全
- 自动处理数据库和枚举转换
- 避免魔法值
- 代码可读性更强
自动处理逻辑删除
@TableLogic // 逻辑删除注解
private Integer deleted;
// ✅ 推荐:自动过滤已删除数据
public List<User> getActiveUsers() {
return userMapper.selectList(null); // 自动过滤deleted=1的记录
}
// 手动删除
userService.removeById(1L); // 实际是更新deleted状态
- 📝 原因:
- 数据不丢失
- 查询自动过滤已删除数据
- 支持数据恢复
- 减少手动编写删除逻辑
- 📷 注意:
- XML中需要手动拼接 deleted = 1
乐观锁更新保护
public class Product {
@Version // 乐观锁版本号
private Integer version;
}
// ✅ 推荐:更新时自动处理版本
public boolean reduceStock(Long productId, Integer count) {
LambdaUpdateWrapper<Product> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(Product::getId, productId)
.ge(Product::getStock, count);
Product product = new Product();
product.setStock(product.getStock() - count);
return productService.update(product, wrapper);
}
- 📝 原因:
- 防止并发冲突
- 自动处理版本控制
- 简化并发更新逻辑
- 提高数据一致性
递增和递减:setIncrBy 和 setDecrBy
// ❌ 不推荐:使用 setSql
userService.lambdaUpdate()
.setSql("integral = integral + 10")
.update();
// ✅ 推荐:使用 setIncrBy
userService.lambdaUpdate()
.eq(User::getId, 1L)
.setIncrBy(User::getIntegral, 10)
.update();
// ✅ 推荐:使用 setDecrBy
userService.lambdaUpdate()
.eq(User::getId, 1L)
.setDecrBy(User::getStock, 5)
.update();
- 📝 原因:
- 类型安全
- 避免手动拼接sql,防止sql注入
- 代码可维护性更强,更清晰
总结
写代码如烹小鲜,讲究的是精细和用心。就像一碗好汤,不仅仅在于锅和火候,更在于厨师对食材的理解和尊重。MyBatisPlus的这12个优化技巧,何尝不是程序员对代码的一种尊重和雕琢?
还记得文章开头说的外婆的羊肉汤吗?优秀的代码,和一碗好汤,都需要用心。每一个细节,每一个调整,都是为了让最终的成果更加完美。MyBatisPlus就像是厨房里的得力助手,它帮你处理繁琐,让你专注于创造。
当你掌握了这些技巧,你的代码将不再是简单的指令堆砌,而是一首优雅的诗,一曲悦耳的交响乐。它们将像外婆的羊肉汤一样,散发着独特的魅力,让人回味无穷。
愿每一位开发者,都能用MyBatisPlus,煮出属于自己的"秘制汤羹"!
代码,就应该是这个样子 —— 简单而不失优雅,高效而不失温度。
来源:juejin.cn/post/7436567167728812044
反射为什么慢?
1. 背景
今天刷到一篇文章,标题是反射为什么慢,一下子懵逼了,确实没想过这个问题;盲猜了一下是由于反射实际上是做了一个代理的动作,导致执行的效率是小于直接实体类去调用方法的。
2. 文章给出的解释
文章中给出的理由是因为以下4点:
- 反射涉及动态解析的内容,不能执行某些虚拟机优化,例如JIT优化技术
- 在反射时,参数需要包装成object[]类型,但是方法真正执行的时候,又使用拆包成真正的类型,这些动作不仅消耗时间,而且过程中会产生很多的对象,这就会导致gc,gc也会导致延时
- 反射的方法调用需要从数组中遍历,这个遍历的过程也比较消耗时间
- 不仅需要对方法的可见性进行检查,参数也需要做额外的检查
3. 结合实际理解
3.1 第一点分析
首先我们需要知道,java中的反射是一种机制,它可以在代码运行过程中,获取类的内部信息(变量、构造方法、成员方法);操作对象的属性、方法。
然后关于反射的原理,首先我们需要知道一个java项目在启动之后,会将class文件加载到堆中,生成一个class对象,这个class对象中有一个类的所有信息,通过这个class对象获取类相关信息的操作我们称为反射。
其次是JIT优化技术,首先我们需要知道在java虚拟机中有两个角色,解释器和编译器;这两者各有优劣,首先是解释器可以在项目启动的时候直接直接发挥作用,省去编译的时候,立即执行,但是在执行效率上有所欠缺;在项目启动之后,随着时间推移,编译器逐渐将机器码编译成本地代码执行,减少解释器的中间损耗,增加了执行效率。
我们可以知道JIT优化通常依赖于在编译时能够知道的静态信息,而反射的动态性可能会破坏这些假设,使得JIT编译器难以进行有效的优化。
3.2 第二点
关于第二点,我们直接写一段反射调用对象方法的demo:
@Test
public void methodTest() {
Class clazz = MyClass.class;
try {
//获取指定方法
//这个注释的会报错 java.lang.NoSuchMethodException
//Method back = clazz.getMethod("back");
Method back = clazz.getMethod("back", String.class);
Method say = clazz.getDeclaredMethod("say", String.class);
//私有方法需要设置
say.setAccessible(true);
MyClass myClass = new MyClass("abc", 99);
//反射调用方法
System.out.println(back.invoke(myClass, "back"));
say.invoke(myClass, "hello world");
} catch (Exception e) {
e.printStackTrace();
}
}
在上面这段代码中,我们调用了一个invoke 方法,并且传了class对象和参数,进入到invoke方法中,我们可以看到invoke方法的入参都是Object类型的,args更是一个Object 数组,这就第二点,关于反射调用过程中的拆装箱。
@CallerSensitive
public Object invoke(Object obj, Object... args)
throws IllegalAccessException, IllegalArgumentException,
InvocationTargetException
{
if (!override) {
if (!Reflection.quickCheckMemberAccess(clazz, modifiers)) {
Class<?> caller = Reflection.getCallerClass();
checkAccess(caller, clazz, obj, modifiers);
}
}
MethodAccessor ma = methodAccessor; // read volatile
if (ma == null) {
ma = acquireMethodAccessor();
}
return ma.invoke(obj, args);
}
3.3 第三点
关于调用方法需要遍历这点,还是上面那个demo,我们在获取Method 对象的时候是通过调用getMethod、getDeclaredMethod方法,点击进入这个方法的源码,我们可以看到如下代码:
private static Method searchMethods(Method[] methods,
String name,
Class<?>[] parameterTypes)
{
Method res = null;
String internedName = name.intern();
for (int i = 0; i < methods.length; i++) {
Method m = methods[i];
if (m.getName() == internedName
&& arrayContentsEq(parameterTypes, m.getParameterTypes())
&& (res == null
|| res.getReturnType().isAssignableFrom(m.getReturnType())))
res = m;
}
return (res == null ? res : getReflectionFactory().copyMethod(res));
}
我们可以看到,底层实际上也是将class对象的所有method遍历了一遍,最终才拿到我们需要的方法的,这也就是第二点,执行具体方法的时候需要遍历class对象的方法。
3.4 第四点
第4点说需要对方法和参数进行检查,也就是我们在执行具体的某一个方法的时候,我们实际上是需要校验这个方法是否可见的,如果不可见,我们还需要将这个方法设置为可见,否则如果我们直接调用这个方法的话,会报错。
同时还有一个点,在我们调用invoke方法的时候,反射类会对方法和参数进行一个校验,让我们来看一下源码:
@CallerSensitive
public Object invoke(Object obj, Object... args)
throws IllegalAccessException, IllegalArgumentException,
InvocationTargetException
{
if (!override) {
if (!Reflection.quickCheckMemberAccess(clazz, modifiers)) {
Class<?> caller = Reflection.getCallerClass();
checkAccess(caller, clazz, obj, modifiers);
}
}
MethodAccessor ma = methodAccessor; // read volatile
if (ma == null) {
ma = acquireMethodAccessor();
}
return ma.invoke(obj, args);
}
我们可以看到还有quickCheckMemberAccess、checkAccess 等逻辑
4. 总结
平时在反射这块用的比较少,也没针对性的去学习一下。在工作之余,还是得保持一个学习的习惯,这样子才不会出现今天这种被一个问题难倒的情况,而且才能产出更多、更优秀的方案。
来源:juejin.cn/post/7330115846140051496
从 Java 8 到 Java 17:你真的会用 Stream API 吗
自从 Java 8 引入 Stream API,Java 开发者可以更方便地对集合进行操作,比如过滤、映射、排序等。
Stream API 提供了一种声明式编程风格,让代码更简洁、可读性更高。不过,虽然 Stream API 看起来很优雅,实际使用中可能会遇到一些性能问题和常见陷阱。
今天,我们就聊聊在 Java 8 到 Java 17 之间,Stream API 的性能优化技巧,以及我们可能踩到的那些坑。
1. Stream API 的优势
Stream 是一个抽象化的数据管道,允许我们以声明式的方式处理数据集合。Stream 的两个主要功能是:中间操作 和 终端操作。
- 中间操作:如
filter()
,map()
,这些操作是惰性的(lazy),不会立即执行。 - 终端操作:如
collect()
,forEach()
,这些操作会触发 Stream 的实际执行。
Java 8 的 Stream 使代码看起来更清晰,但它在使用时也带来了一些需要注意的地方,尤其是在处理大数据集时的性能。
2. Stream API 常见的性能陷阱
2.1 多次创建 Stream 导致浪费
在开发中,如果对同一个集合多次创建 Stream,可能会导致重复计算。例如:
List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David");
// 多次创建 Stream
long countA = names.stream().filter(name -> name.startsWith("A")).count();
long countB = names.stream().filter(name -> name.startsWith("B")).count();
在上面的代码中,names.stream()
被调用了两次,导致每次都从头开始扫描集合。可以优化为一次操作:
Map<String, Long> result = names.stream()
.collect(Collectors.groupingBy(name -> name.substring(0, 1), Collectors.counting()));

这样做的好处是只遍历一次集合,减少不必要的开销。
2.2 避免使用 forEach
进行数据聚合
forEach
是一个常见的终端操作,但它在很多场景下并不是最优解,尤其是在需要聚合数据时:
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
List<Integer> result = new ArrayList<>();
numbers.stream().forEach(result::add); // 这种方式不推荐
这里直接通过 forEach
操作来修改外部集合,会失去 Stream 的声明式风格,甚至可能出现线程安全问题。更好的做法是使用 collect
:
List<Integer> result = numbers.stream().collect(Collectors.toList());
这种方式不仅代码更简洁,还能保证线程安全,特别是在并行流的场景下。
简单说说声明式和命令式
Stream API 提供了一种声明式的编程风格,让你可以专注于“做什么”,而不是“怎么做”。使用
forEach
来修改外部集合是一个命令式的做法,涉及了外部状态的修改,这样就打破了 Stream 的声明式优势。
相比之下在使用
collect
的例子中,代码更简洁且更易读,表达了你的意图是“收集这些元素”,而不是“对每个元素进行操作”。
2.3 滥用并行流
Java 8 引入了并行流(Parallel Stream),它可以通过 stream().parallel()
方法来让 Stream 操作并行化。然而,并行流并不总是能带来性能提升:
// 生成一个 0~999999 的数字列表
List<Integer> numbers = IntStream.range(0, 1000000).boxed().collect(Collectors.toList());
// 直接使用并行流
long start1 = System.currentTimeMillis();
long sum = numbers.parallelStream().mapToInt(Integer::intValue).sum();
long end1 = System.currentTimeMillis();
System.out.println("并行流执行时间:" + (end1 - start1) + "ms");
System.out.println(sum);
// 使用普通流
long start2 = System.currentTimeMillis();
long sum2 = numbers.stream().mapToInt(Integer::intValue).sum();
long end2 = System.currentTimeMillis();
System.out.println("普通流执行时间:" + (end2 - start2) + "ms");
System.out.println(sum2);

> 并行流的适用场景是计算量较大、数据量足够多的情况下。如果数据量较小,或者 Stream 操作较简单,使用并行流反而会带来线程切换的开销,导致性能下降。
2.4 limit()
和 skip()
的误用
limit()
和 skip()
可以限制 Stream 的数据量,但要注意它们的相对位置。如果在 filter()
之后使用 limit()
,可能会带来不必要的性能消耗:
List<Integer> numbers = IntStream.range(0, 1_000_000).boxed().collect(Collectors.toList());
// 过滤偶数,然后取前 10 个
List<Integer> result = numbers.stream()
.filter(n -> n % 2 == 0)
.limit(10)
.collect(Collectors.toList());
这种情况下,filter()
会对 1,000,000 个元素逐个过滤,直到找到前 10 个符合条件的元素。更高效的方式是先 limit()
,再进行其他操作:
List<Integer> result = numbers.stream()
.limit(20) // 先取出前 20 个
.filter(n -> n % 2 == 0) // 再进行过滤
.collect(Collectors.toList());
这样,Stream 只会处理有限的元素,性能会更好。
3. Stream API 性能优化技巧
3.1 使用 toArray()
而不是 collect(Collectors.toList())
如果我们只需要将 Stream 转换为数组,使用 toArray()
是更快的选择:
String[] array = names.stream().toArray(String[]::new);
相比 collect(Collectors.toList())
,toArray()
在实现上更直接,尤其在处理大量数据时可以减少内存分配的开销。
collect(Collectors.toList())
:这个方法首先创建一个ArrayList
,然后将所有元素添加到这个列表中。在这个过程中,ArrayList
可能会经历多次扩容,每次扩容都需要新建一个更大的数组,并将现有元素复制到新数组中。这种重复的内存分配和数组复制操作在处理大量数据时会增加开销。
toArray()
:这个方法直接生成一个数组,避免了ArrayList
的扩容过程。
3.2 避免不必要的装箱与拆箱
在处理基本数据类型时,使用 mapToInt()
、mapToDouble()
这样的基本类型专用方法,可以避免不必要的装箱和拆箱操作,提高性能:
List<Integer> numbers = IntStream.range(0, 10000000).boxed().collect(Collectors.toList());
long start1 = System.currentTimeMillis();
// 使用 map 导致装箱和拆箱
int sumWithMap = numbers.stream()
.map(n -> n) // 装箱
.reduce(0, Integer::sum); // 拆箱
long end1 = System.currentTimeMillis();
System.out.println("sumWithMap: " + sumWithMap + " time: " + (end1 - start1));
long start2 = System.currentTimeMillis();
// 使用 mapToInt 避免装箱和拆箱
int sumWithMapToInt = numbers.stream()
.mapToInt(n -> n) // 直接处理基本类型
.sum();
long end2 = System.currentTimeMillis();
System.out.println("sumWithMapToInt: " + sumWithMapToInt + " time: " + (end2 - start2));

如果直接使用 `map()` 会导致频繁的装箱和拆箱,降低性能。
3.3 尽量使用 forEachOrdered()
在并行流中,forEach()
的执行顺序是非确定性的,如果我们希望按原来的顺序处理数据,使用 forEachOrdered()
可以保证顺序,但会稍微影响性能。
numbers.parallelStream().forEachOrdered(System.out::println);
3.4 减少链式调用中的中间操作
每个中间操作都会产生一个新的 Stream 实例,如果链式调用过多,会增加调用栈的深度,影响性能。尽量合并中间操作来减少链条长度:
// 原始链式调用
List<String> result = names.stream()
.filter(name -> name.length() > 3)
.map(String::toUpperCase)
.filter(name -> name.startsWith("A"))
.collect(Collectors.toList());
// 优化后的调用
List<String> resultOptimized = names.stream()
.filter(name -> name.length() > 3 && name.startsWith("A"))
.map(String::toUpperCase)
.collect(Collectors.toList());
通过合并 filter
的条件,可以减少 Stream 的中间操作,提升性能。
4. 从 Java 8 到 Java 17 的改进
Java 9 到 Java 17 中,Stream API 进行了多次优化和功能增强:
- Java 9 引入了
takeWhile()
和dropWhile()
方法,这些方法允许我们基于条件对 Stream 进行分割,性能上比过滤操作更高效。
List<Integer> limitedNumbers = numbers.stream()
.takeWhile(n -> n < 100)
.collect(Collectors.toList());
- Java 10 开始,
Collectors.toUnmodifiableList()
提供了一种方法来创建不可修改的集合,适用于需要更严格集合控制的场景。 - Java 16 增加了对
Stream.toList()
的支持,方便直接将流转换为不可变的List
:
List<String> immutableList = names.stream().filter(n -> n.length() > 3).toList();
- Java 17 进一步优化了 Stream 的性能,特别是在并行流的实现上,使其在多核环境下能够更高效地利用硬件资源。
5. 总结
Stream API 在 Java 8 引入后,可以说是极大地提高了代码的可读性和简洁性,但也带来了性能优化和陷阱需要注意。从 Java 8 到 Java 17 的不断优化中,我们可以看到 Stream API 逐渐变得更强大和高效。
要想充分利用 Stream API,开发者需要意识到 Stream 的惰性求值特点,避免重复计算和不必要的装箱、拆箱操作。同时,并行流的使用应在充分评估场景后进行,避免反而拖累性能。
希望这篇文章能帮助你更好地掌握 Java Stream API 的优化技巧,在开发中写出更高效、更优雅的代码!
若有勘误,烦请不吝赐教。
来源:juejin.cn/post/7419984211144736808
GPT-o3超过99.9%的程序员,码农们何去何从?
掘金2024年度人气创作者打榜中,快来帮我打榜吧~ activity.juejin.cn/rank/2024/w…
大家好,我是每天分享AI应用的萤火君!
最近OpenAI一连搞了十几天的新品直播,虽然热度不是特别高,但是也确实爆出了一些比较有意思的东西,比如GPT o3,据说编程能力已经超过 99.9%的程序员(某程序员竞赛的前200名:codeforces.com/ratings),我等码农是不是要哭晕在厕所了?
程序员的出路在哪里?
AI编程革命
谈到这一轮的AI革命,很多人会类比工业革命。在工业革命初期,随着机器的普及应用,很多手工业者丢了生计,但也有一批手动业者主动进化,学会了机器的操作方法,在新的时代里依然抢手。
所以最新的说法是:AI不会革所有人的命,AI革的是不会使用AI的人的命。
这种说法是站的住脚的。
就我个人而言,写代码已经离不开AI了,遇到不会写的代码就问AI已经是家常便饭,甚至有了什么思路之后,直接就让AI先写一个实现,感觉不好的话,再让AI重写,一般都能得出满意的结果。这比自己苦思冥想、到处去查资料要快上不少,甚至结果也往往更好。当然这里边还有一些前提条件,比如你要把自己的想法清晰的表达出来。
就像人们对商品的需求是广泛且持续存在的,软件的需求也是广泛且持续存在的。使用AI可以提升软件程序的生产效率,如果你花1天时间可以写出100分的程序,那么使用AI,很可能只需要几分钟的时间就能写出至少80分的程序,这里边的生产力差距是很明显的,所以AI对于编程而言,自然也是一场革命。
历史证明,不想被淘汰,只能积极的拥抱新的生产工具。学会使用AI编程,自然也成了程序员在AI时代的必备能力。
AI编程的要点
就像大家都写程序,有的人写的好,有的人写不出来。使用AI编程也是,有的人效率大幅提升,有的人生成的代码没法用,还耽误时间。
为什么会这样?
我个人的观点是:不得要领。
编写代码的要领是:掌握各种编程规则,并在各种解决方案中做好权衡。很多同学以代码能跑起来为最终目标,不管性能,也不考虑维护、扩展,这样很难写出优秀的程序,自然也成不了高手。
AI编程的要领是什么呢?
讲清楚
我们还是在编程,所以编程的要领还是要掌握,但是又增加了AI的方式,就需要结合AI的特性。
这里边最主要的问题就是讲不清楚,程序员们往往知道怎么去实现,但是很难给别人讲明白。而使用生成式AI,一个很重要的工作是编写提示词,也就是把你想要的东西通过文字清晰明白的表达出来。
举个简单的例子,你想要让AI设计一个商城的数据库,如果你只是简单的告诉它:帮我设计一个商城的数据库。
它很大概率上是很难生成直接可用的代码的,因为它不知道你要使用什么数据库,也不知道你的商品和订单是怎么管理的,更不清楚你的订单量是什么样的规模,这些都会影响数据库的设计。AI大概率会给你了一个路边摊的方案,然后被你鄙视一番。
要想让AI输出高质量的内容,我们必须把问题的上下文讲清楚,以AI能够理解的方式告诉它。在数据库的例子中,可能包括:你的商城都有哪些功能,商品品类如何组织,商品有哪些属性,每天的订单量如何,订单的流转过程如何,等等。
有时候对于一个复杂的系统,一轮对话是不够的,可能需要多轮对话,比如先讲一下你的商城功能有哪些、业务规模如何,然后让AI给你一些数据库的选型建议;然后再基于你选择的数据库,通过各个部分的描述,让AI给出具体领域的设计。
好模型
工欲善其事,必先利其器。
AI模型的性能也很重要,这里说的模型性能是AI模型生成结果的质量问题。
了解过大语言模型的同学应该都听说过OpenAI,作为大语言模型的领导者,它的ChatGPT是顶好的,对于同样的问题,ChatGPT往往能理解的更为准确,也能给出质量更高的答案。对于编程来说,就是它输出的代码能正常跑起来,而很多模型给出的答案经常跑不起来,各种报错。
根据我的测试体验,目前 GPT 4o 和 Claude 3.5 Sonnet 在编程方面的能力比较领先,国产的 通义灵码 也不错,但是有时给出的方案还是差强人意,有点路边摊的感觉,输出过多不太重要的东西,沟通效率上差一些,应该还是基础模型的能力不够强。
当下,从效率方面看,使用更好的模型确实可以节约一些时间。
程序员的未来
所有的工作都是基于需求产生的,程序员也不例外。
计算机出现以后,人们需要使用计算机来完成大量繁复的计算任务,随着计算能力的增强和计算范围的拓展,人们又需要使用计算机来运行各种各样的软件程序。但是人和机器打交道特别困难,最开始使用0和1的机器码,这不是一般人能玩的溜的,后来虽然又搞出了各种各样的先进技术和编程语言,但是计算任务的复杂度也进一步提升,对专业人士的需求不减反增,程序员就是这类专业人士。
现在大语言模型来了,AI编程革命愈演愈烈,程序员何去何从?
绝对数量的减少
我们还是从需求出发。
首先假设计算任务的需求并没有明显减少,也没有明显增多,也就是程序的需求没有减少;但是使用AI之后编写程序的效率会提升,也就是说个体生产力会提升;在总体需求不变的情况下,个体生产力的提升就会减少对个体数量的需求。在这个假设中,一些程序员不得不出局。
那么这个假设是否成立呢?
当前国内有一个现象很突出,很多厂子经历了一轮又一轮的裁员,很多同学离职后有很长时间的空窗期,整个社会对程序员的需求明显在减少。但是AI的发展必然又带来新的工作机会,比如提示工程师、模型训练工程师、AI应用开发工程师,如果我们把这些新的岗位也划到程序员的行列,但是它们足以抵消社会对传统程序员的需求减少吗?
我想不能够,因为使用AI的效率更高,则需要人贡献的力量就会更少。也许AI会创造更多的其它工种,但是对以编程为生的程序员的需求一定是减少的。
需求和技术的平衡
随着AI模型能力的持续增强,手写代码的机会越来越少,但是AI的能力长时间内始终有所欠缺,比如:理解用户的需求,在各种技术方案中做出权衡等,而且AI的伦理问题一时半会也很难解决,程序出了问题还要有人来背锅。
所以程序员的大部分工作将是:
理解用户需求,然后让AI实现用户的需求,并对生成的结果进行调整和把关。
理解用户需求:注意这里说的是理解用户需求,而不是理解产品经理给出的产品设计。当下,程序员编写程序本来也是需要理解产品设计的,但是好的程序只是理解了产品经理的输出还是不够的,产品经理的主要问题是缺乏技术的敏感度,这需要程序员来补齐,并在产品中有所体现。而且如果程序员写代码的时间少了,那老板自然不会让你闲着,直接对接用户可能是一个方向。
让AI实现用户的需求:这里关注的是程序员的语言表达能力,你得能把问题说清楚,知道如何与AI进行交互,让AI更好的完成编码工作。这是一项需要重点打造的能力。
对生成的结果进行调整和把关: AI对整体的把握能力仍然十分有限,虽然很多模型都号称支持多少K的上下文,但是真正用于实际的时候还是会丢三拉四,不够可靠。人要来把关的话,对技术的理解仍然十分钟重要,因为人要做决策,什么能做,什么不能做,什么情况适合采用什么样的方案,都要考虑清楚。
现在也有一些公司在做AI程序员,让一帮AI来扮演各种角色,来实现一个软件程序的开发。似乎程序员马上就要失业了,但是对于一个有着几年开发经验的同学来说,应该能够理解:一个包含各种复杂逻辑的业务系统,AI还是很难搞清楚的,短期内也很难代替人来做决策。
总结
AI的引入显著提高了编程效率,使得高质量代码的生成速度大大加快。面对未来,尽管传统编程岗位的需求可能减少,但新的职业机会也在不断涌现,程序员同学们要积极适应变化,充分利用AI的优势,掌握清晰表达需求的能力,了解如何有效沟通以获得最佳的AI输出,同时还要持续提升自身技术素养,以便更好地理解用户需求、指导AI工作及审核最终结果。
来源:juejin.cn/post/7451171562878287909
耗时6个月做的可视化大屏编辑器, 开源!
hi, 大家好, 我是徐小夕.
5年前就开始着手设计和研发可视化大屏编辑器, 当时低代码在国内还没有现在那么火, 有人欢喜有人哀, 那个时候我就比较坚定的认为无码化搭建未来一定是个趋势, 能极大的帮助企业提高研发效率和降低研发成本, 所以 all in 做了2年, 上线了一个相对闭环的MVP可视化大屏搭建平台——V6.Dooring.
通过在技术社区不断的分享可视化搭建的技术实践和设计思路, 也参与了很多线上线下的技术分享, 慢慢市场终于“热了”起来.(机缘巧合)
从V6.Dooring的技术架构的设计, 到团队组建, 再到帮助企业做解决方案, 当时几乎所有的周末都花在这上面了, 想想收获还是挺大的, 接触到了形形色色的企业需求, 也不断完整着可视化大屏编辑器的功能, 最后推出了一个还算通用的解决方案:
当然上面介绍的还都不是这篇文章的重点.
重点是, 时隔4年, 我们打算把通用的可视化大屏解决方案, 开源!
一方面是供大家学习参考, 更好的解决企业自身的业务需求, 另一方面可以提供一个技术交流的平台, 大家可以对可视化搭建领域的技术实践, 提出自己的想法和观点, 共同打造智能化, 体验更好的搭建产品.
先上github地址: github.com/MrXujiang/v…
V6.Dooring开源大屏编辑器演示
其实最近几年我在掘金专栏分享了很多零代码和可视化搭建的技术实现和产品设计:
这里为了让大家更近一步了解V6-Dooring可视化大屏编辑器, 我还是会从技术设计到产品解决方案设计的角度, 和大家详细分享一下, 让大家在学习我们可视化大屏开源方案的过程中, 对可视化搭建技术产品, 有更深入的理解.
如果大家觉得有帮助, 不要忘记点赞 + 收藏哦, 后面我会持续分享最干的互联网干货.
你将收获
- 可视化大屏产品设计思路
- 主流可视化图表库技术选型
- 大屏编辑器设计思路
- 大屏可视化编辑器Schema设计
- 用户数据自治探索
方案实现
1.可视化大屏产品设计思路
目前很多企业或多或少的面临“信息孤岛”问题,各个系统平台之间的数据无法实现互通共享,难以实现一体化的数据分析和实时呈现。
相比于传统手工定制的图表与数据仪表盘,可视化大屏制作平台的出现,可以打破抵消的定制开发, 数据分散的问题,通过数据采集、清洗、分析到直观实时的数据可视化展现,能够多方位、多角度、全景展现各项指标,实时监控,动态一目了然。
针对以上需求, 我们设计了一套可视化大屏解决方案, 具体包含如下几点:
上图是笔者4个月前设计的基本草图, 后期会持续更新. 通过以上的设计分解, 我们基本可以搭建一个可自己定制的数据大屏.
2.主流可视化图表库技术选型
目前我调研的已知主流可视化库有:
- echart 一个基于 JavaScript 的老牌开源可视化图表库
- D3.js 一个数据驱动的可视化库, 可以不需要其他任何框架独立运行在现代浏览器中,它结合强大的可视化组件来驱动 DOM 操作
- antv 包含一套完整的可视化组件体系
- Chart.js 基于 HTML5 的 简单易用的 JavaScript 图表库
- metrics-graphics 建立在D3之上的可视化库, 针对可视化和布置时间序列数据进行了优化
- C3.js 通过包装构造整个图表所需的代码,使生成基于D3的图表变得容易
我们使用以上任何一个库都可以实现我们的可视化大屏搭建的需求, 各位可以根据喜好来选择.
3.大屏编辑器设计思路
在上面的分析中我们知道一个大屏编辑器需要有个编辑器核心, 主要包含以下部分:
- 组件库
- 拖拽(自由拖拽, 参考线, 自动提示)
- 画布渲染器
- 属性编辑器
如下图所示:
组件库我们可以用任何组件封装方式(react/vue等), 这里沿用H5-Dooring的可视化组件设计方式, 对组件模型进行优化和设计.
类似的代码如下:
import { Chart } from '@antv/f2';
import React, { memo, useEffect, useRef } from 'react';
import styles from './index.less';
import { IChartConfig } from './schema';
const XChart = (props:IChartConfig) => {
const { data, color, size, paddingTop, title } = props;
const chartRef = useRef(null);
useEffect(() => {
const chart = new Chart({
el: chartRef.current || undefined,
pixelRatio: window.devicePixelRatio, // 指定分辨率
});
// step 2: 处理数据
const dataX = data.map(item => ({ ...item, value: Number(item.value) }));
// Step 2: 载入数据源
chart.source(dataX);
// Step 3:创建图形语法,绘制柱状图,由 genre 和 sold 两个属性决定图形位置,genre 映射至 x 轴,sold 映射至 y 轴
chart
.interval()
.position('name*value')
.color('name');
// Step 4: 渲染图表
chart.render();
}, [data]);
return (
<div className={styles.chartWrap}>
<div className={styles.chartTitle} style={{ color, fontSize: size, paddingTop }}>
{title}
</div>
<canvas ref={chartRef}></canvas>
</div>
);
};
export default memo(XChart);
以上只是一个简单的例子, 更具业务需求的复杂度我们往往会做更多的控制, 比如动画(animation), 事件(event), 数据获取(data inject)等.
当然实际应用中大屏展现的内容和形式远比这复杂, 我们从上图可以提炼出大屏页面的2个直观特征:
- 可视化组件集
- 空间坐标关系
因为我们可视化大屏载体是页面, 是html
, 所以还有另外一个特征: 事件/交互。综上我们总结出了可视化大屏的必备要素:
我们只要充分的理解了可视化大屏的组成和特征, 我们才能更好的设计可视化大屏搭建引擎, 基于以上分析, 我设计了一张基础引擎的架构图:
接下来我就带大家一起来拆解并实现上面的搭建引擎。
大屏搭建引擎核心功能实现
俗话说: “好的拆解是成功的一半”, 任何一个复杂任务或者系统, 我们只要能将其拆解成很多细小的子模块, 就能很好的解决并实现它. (学习也是一样)
接下来我们就逐一解决上述基础引擎的几个核心子模块:
- 拖拽器实现
- 物料中心设计
- 动态渲染器实现
- 配置面板设计
- 控制中心概述
- 功能辅助设计
1.拖拽器实现
拖拽器是可视化搭建引擎的核心模块, 也是用来解决上述提到的大屏页面特征中的“空间坐标关系”这一问题。我们先来看一下实现效果:
组件拖拽可以采用市面已有的 Dragable 等插件, 也可以采用 H5-Dooring 的智能网格拖拽. 这里笔者选择自由拖拽来实现. 已有的有:
- rc-drag
- sortablejs
- react-dnd
- react-dragable
- vue-dragable
等等. 具体拖拽呈现流程如下:
具体拖拽流程就是:
- 使用H5 dragable API拖拽左侧组件(component data)进入目标容器(targetBox)
- 监听拖拽结束事件拿到拖拽事件传递的
data
来渲染真实的可视化组件 - 可视化组件挂载,
schema
注入编辑面板, 编辑面板渲染组件属性编辑器 - 拖拽, 属性修改, 更新
- 预览, 发布
组件的schema
参考H5-Dooring DSL设计.
2.物料中心设计
物料中心主要为大屏页面提供 “原材料”。为了设计健壮且通用的物料, 我们需要设计一套标准组件结构和属性协议。并且为了方便物料管理和查询, 我们还需要对物料进行分类, 我的分类如下:
- 可视化组件 (柱状图, 饼图, 条形图, 地图可视化等)
- 修饰型组件 (图片, 轮播图, 修饰素材等)
- 文字类组件 (文本, 文本跑马灯, 文字看板)
具体的物料库演示如下:
这里我拿一个可视化组件的实现来举例说明:
import React, { memo, useEffect } from 'react'
import { Chart } from '@antv/g2'
import { colors } from '@/components/BasicShop/common'
import { ChartConfigType } from './schema'
interface ChartComponentProps extends ChartConfigType {
id: string
}
const ChartComponent: React.FC<ChartComponentProps> = ({
id, data, width, height,
toggle, legendPosition, legendLayout, legendShape,
labelColor, axisColor, multiColor, tipEvent, titleEvent,
dataType, apiAddress, apiMethod, apiData, refreshTime,
}) => {
useEffect(() => {
let timer:any = null;
const chart = new Chart({
container: `chart-${id}`,
autoFit: true,
width,
height
})
// 数据过滤, 接入
const dataX = data.map(item => ({ ...item, value: Number(item.value) }))
chart.data(dataX)
// 图表属性组装
chart.legend(
toggle
? {
position: legendPosition,
layout: legendLayout,
marker: {
symbol: legendShape
},
}
: false,
)
chart.tooltip({
showTitle: false,
showMarkers: false,
})
// 其他图表信息源配置, 方法雷同, 此处省略
// ...
chart.render()
}, [])
return <div id={`chart-${id}`} />
}
export default memo(ChartComponent)
以上就是我们的基础物料的实现模式, 可视化组件采用了g2
, 当然大家也可以使用熟悉的echart
, D3.js
等. 不同物料既有通用的 props
, 也有专有的 props
, 取决于我们如何定义物料的Schema
。
在设计 Schema
前我们需要明确组件的属性划分, 为了满足组件配置的灵活性和通用性, 我做了如下划分:
- 外观属性 (组件宽高, 颜色, 标签, 展现模式等)
- 数据配置 (静态数据, 动态数据)
- 事件/交互 (如单击, 跳转等)
有了以上划分, 我们就可以轻松设计想要的通用Schema
了。我们先来看看实现后的配置面板:
这些属性项都是基于我们定义的schema
配置项, 通过 解析引擎 动态渲染出来的, 有关 解析引擎 和配置面板, 我会在下面的章节和大家介绍。我们先看看组件的 schema
结构:
const Chart: ChartSchema = {
editAttrs: [
{
key: 'layerName',
type: 'Text',
cate: 'base',
},
{
key: 'y',
type: 'Number',
cate: 'base',
},
...DataConfig, // 数据配置项
...eventConfig, // 事件配置项
],
config: {
width: 200,
height: 200,
zIndex: 1,
layerName: '柱状图',
labelColor: 'rgba(188,200,212,1)',
// ... 其他配置初始值
multiColor: ['rgba(91, 143, 249, 1)', 'rgba(91, 143, 249, 1)', 'rgba(91, 143, 249,,1)', 'rgba(91, 143, 249, 1)'],
data: [
{
name: 'A',
value: 25,
},
{
name: 'B',
value: 66,
}
],
},
}
其中 editAttrs 表示可编辑的属性列表, config 为属性的初始值, 当然大家也可以根据自己的喜好, 设计类似的通用schema
。
我们通过以上设计的标准组件和标准schema
, 就可以批量且高效的生产各种物料, 还可以轻松集成任何第三方可视化组件库。
3.动态渲染器实现
我们都知道, 一个页面中元素很多时会影响页面整体的加载速度, 因为浏览器渲染页面需要消耗CPU / GPU。对于可视化页面来说, 每一个可视化组件都需要渲染大量的信息元, 这无疑会对页面性能造成不小的影响, 所以我们需要设计一种机制, 让组件异步加载到画布上, 而不是一次性加载几十个几百个组件(这样的话页面会有大量的白屏时间, 用户体验极度下降)。
动态加载器就是提供了这样一种机制, 保证组件的加载都是异步的, 一方面可以减少页面体积, 另一方面用户可以更早的看到页面元素。目前我们熟的动态加载机制也有很多, Vue
和 React
生态都提供了开箱即用的解决方案(虽然我们可以用 webpack
自行设计这样的动态模型, 此处为了提高行文效率, 我们直接基于现成方案封装)。我们先看一下动态渲染组件的过程:
上面的演示可以细微的看出从左侧组件菜单拖动某个组件图标到画布上后, 真正的组件才开始加载渲染。
这里我们以 umi3.0
提供的 dynamic
函数来最小化实现一个动态渲染器. 如果不熟悉 umi
生态的朋友, 也不用着急, 看完我的实现过程和原理之后, 就可以利用任何熟悉的动态加载机制实现它了。实现如下:
import React, { useMemo, memo, FC } from 'react'
import { dynamic } from 'umi'
import LoadingComponent from '@/components/LoadingComponent'
const DynamicFunc = (cpName: string, category: string) => {
return dynamic({
async loader() {
// 动态加载组件
const { default: Graph } = await import(`@/components/materies/${cpName}`)
return (props: DynamicType) => {
const { config, id } = props
return <Graph {...config} id={id} />
}
},
loading: () => <LoadingComponent />
})
}
const DynamicRenderEngine: FC<DynamicType> = memo((props) => {
const {
type,
config,
// 其他配置...
} = props
const Dynamic = useMemo(() => {
return DynamicFunc(config)
}, [config])
return <Dynamic {...props} />
})
export default DynamicRenderEngine
是不是很简单? 当然我们也可以根据自身业务需要, 设计更复杂强大的动态渲染器。
4.配置面板设计
实现配置面板的前提是对组件 Schema
结构有一个系统的设计, 在介绍组件库实现中我们介绍了通用组件 schema
的一个设计案例, 我们基于这样的案例结构, 来实现 动态配置面板。
由上图可以知道, 动态配置面板的一个核心要素就是 表单渲染器。表单渲染器的目的就是基于属性配置列表 attrs
来动态渲染出对应的表单项。我之前写了一篇文章详细的介绍了表单设计器的技术实现的文章, 大家感兴趣也可以参考一下: Dooring可视化之从零实现动态表单设计器。
我这里来简单实现一个基础的表单渲染器模型:
const FormEditor = (props: FormEditorProps) => {
const { attrs, defaultValue, onSave } = props;
const onFinish = (values: Store) => {
// 保存配置项数据
onSave && onSave(values);
};
const handlechange = (value) => {
// 更新逻辑
}
const [form] = Form.useForm();
return (
<Form
form={form}
{...formItemLayout}
onFinish={onFinish}
initialValues={defaultValue}
onValuesChange={handlechange}
>
{
attrs.map((item, i) => {
return (
<React.Fragment key={i}>
{item.type === 'Number' && (
<Form.Item label={item.name} name={item.key}>
<InputNumber />
</Form.Item>
)}
{item.type === 'Text' && (
<Form.Item label={item.name} name={item.key}>
<Input placeholder={item.placeholder} />
</Form.Item>
)}
{item.type === 'TextArea' && (
<Form.Item label={item.name} name={item.key}>
<TextArea rows={4} />
</Form.Item>
)}
// 其他配置类型
</React.Fragment>
);
})}
</Form>
);
};
如果大家想看更完整的配置面板实现, 可以参考开源项目 H5-Dooring | H5可视化编辑器
我们可以看看最终的配置面板实现效果:
5.控制中心概述 & 功能辅助设计
控制中心的实现主要是业务层的, 没有涉及太多复杂的技术, 所以这里我简单介绍一下。因为可视化大屏页面展示的信息有些可能是私密数据, 只希望一部分人看到, 所以我们需要对页面的访问进行控制。其次由于企业内部业务战略需求, 可能会对页面进行各种验证, 状态校验, 数据更新频率等, 所以我们需要设计一套控制中心来管理。最基本的就是访问控制, 如下:
功能辅助设计 主要是一些用户操作上的优化, 比如快捷键, 画布缩放, 大屏快捷导航, 撤销重做等操作, 这块可以根据具体的产品需求来完善。大家后期设计搭建产品时也可以参考实现。
可视化大屏数据自治探索
目前我们实现的搭建平台可以静态的设计数据源, 也可以注入第三方接口, 如下:
我们可以调用内部接口来实时获取数据, 这块在可视化监控平台用的场景比较多, 方式如下:
参数(params
)编辑区可以自定义接口参数. 代码编辑器笔者这里推荐两款, 大家可以选用:
- react-monaco-editor
- react-codemirror2
使用以上之一可以实现mini
版vscode
, 大家也可以尝试一下.
辅助功能
可视化大屏一键截图 一键截图功能还是沿用H5-Dooring 的快捷截图方案, 主要用于对大屏的分享, 海报制作等需求, 我们可以使用以下任何一个组件实现:
- dom-to-image
- html2canvas
撤销重做撤销重做功能我们可以使用已有的库比如react-undo
, 也可以自己实现, 实现原理:有点链表的意思, 我们将每一个状态存储到数组中, 通过指针来实现撤销重做的功能, 如果要想更健壮一点, 我们可以设计一套“状态淘汰机制”, 设置可保留的最大状态数, 之前的自动淘汰(删除, 更高大上一点的叫出栈). 这样可以避免复杂操作中的大量状态存储, 节约浏览器内存.
标尺参考线 标尺和参考线这里我们自己实现, 通过动态dom渲染来实现参考线在缩放后的动态收缩, 实现方案核心如下:
arr.forEach(el => {
let dom = [...Array.from(el.querySelectorAll('.calibrationNumber'))][0] as HTMLElement;
if (dom) {
dom.style.transform = `translate3d(-4px, -8px, 0px) scale(${(multiple + 0.1).toFixed(
1,
)})`;
}
});
详细源码可参考: H5-Dooring | 参考线设计源码
如果大家有好的建议也欢迎随时交流反馈, 开源不易, 别忘了star哦~
github地址: github.com/MrXujiang/v…
来源:juejin.cn/post/7451246345568387091
从前端的角度出发,目前最具性价比的全栈路线是啥❓❓❓
今年大部分时间都是在编码上和写文章上,但是也不知道自己都学到了啥,那就写篇文章来盘点一下目前的技术栈吧,也作为下一年的参考目标,方便知道每一年都学了些啥。
我的技术栈
首先我先来对整体的技术做一个简单的介绍吧,然后后面再对当前的一些技术进行细分吧。
React、Typescript、React Native、mysql、prisma、NestJs、Redis、前端工程化。
React
React 这个框架我花的时间应该是比较多的了,在校期间已经读了一遍源码了,对这些原理已经基本了解了。在随着技术的继续深入,今年毕业后又重新开始阅读了一遍源码,对之前的认知有了更深一步的了解。
也写了比较多跟 React 相关的文章,包括设计模式,原理,配套生态的使用等等都有一些涉及。
在状态管理方面,redux,zustand 我都用过,尤其在 Zustand 的使用上,我特别喜欢 Zustand,它使得我能够快速实现全局状态管理,同时避免了传统 Redux 中繁琐的样板代码,且性能更优。也对 Zustand 有比较深入的了解,也对其源码有过研究。
NextJs
Next.js 是一个基于 React 的现代 Web 开发框架,它为开发者提供了一系列强大的功能和工具,旨在优化应用的性能、提高开发效率,并简化部署流程。Next.js 支持多种渲染模式,包括服务器端渲染(SSR)、静态生成(SSG)和增量静态生成(ISR),使得开发者可以根据不同的需求选择合适的渲染方式,从而在提升页面加载速度的同时优化 SEO。
在路由管理方面,Next.js 采用了基于文件系统的路由机制,这意味着开发者只需通过创建文件和文件夹来自动生成页面路由,无需手动配置。这种约定优于配置的方式让路由管理变得直观且高效。此外,Next.js 提供了动态路由支持,使得开发者可以轻松实现复杂的 URL 结构和参数化路径。
Next.js 还内置了 API 路由,允许开发者在同一个项目中编写后端 API,而无需独立配置服务器。通过这种方式,前后端开发可以在同一个代码库中协作,大大简化了全栈开发流程。同时,Next.js 对 TypeScript 提供了原生支持,帮助开发者提高代码的可维护性和可靠性。
Typescript
今年所有的项目都是在用 ts 写了,真的要频繁修改的项目就知道用 ts 好处了,有时候用 js 写的函数修改了都不知道怎么回事,而用了 ts 之后,哪里引用到的都报红了,修改真的非常方便。
今年花了一点时间深入学习了一下 Ts 类型,对一些高级类型以及其实现原理也基本知道了,明年还是多花点时间在类型体操上,除了算法之外,感觉类型体操也可以算得上是前端程序员的内功心法了。
React Native
不得不说,React Native 不愧是接活神器啊,刚学完之后就来了个安卓和 ios 的私活,虽然没有谈成。
React Native 和 Expo 是构建跨平台移动应用的两大热门工具,它们都基于 React,但在功能、开发体验和配置方式上存在一些差异。React Native 是一个开放源代码的框架,允许开发者使用 JavaScript 和 React 来构建 iOS 和 Android 原生应用。Expo 则是一个构建在 React Native 之上的开发平台,它提供了一套工具和服务,旨在简化 React Native 开发过程。
React Native 的核心优势在于其高效的跨平台开发能力。通过使用 React 语法和组件,开发者能够一次编写应用的 UI 和逻辑,然后部署到 iOS 和 Android 平台。React Native 提供了对原生模块的访问,使开发者能够使用原生 API 来扩展应用的功能,确保性能和用户体验能够接近原生应用。
Expo 在此基础上进一步简化了开发流程。作为一个开发工具,Expo 提供了许多内置的 API 和组件,使得开发者无需在项目中进行繁琐的原生模块配置,就能够快速实现设备的硬件访问功能(如摄像头、位置、推送通知等)。Expo 还内置了一个开发客户端,使得开发者可以实时预览应用,无需每次都进行完整的构建和部署。
另外,Expo 提供了一个完全托管的构建服务,开发者只需将应用推送到 Expo 服务器,Expo 就会自动处理 iOS 和 Android 应用的构建和发布。这大大简化了应用的构建和发布流程,尤其适合不想处理复杂原生配置的开发者。
然而,React Native 和 Expo 也有各自的局限性。React Native 提供更大的灵活性和自由度,开发者可以更自由地集成原生代码或使用第三方原生库,但这也意味着需要更多的配置和维护。Expo 则封装了很多功能,简化了开发,但在需要使用某些特定原生功能时,开发者可能需要“弹出”Expo 的托管环境,进行额外的原生开发。
样式方案的话我使用的是 twrnc,大部分组件都是手撸,因为有 cursor 和 chatgpt 的加持,开发效果还是杠杠的。
rn 原理也争取明年能多花点时间去研究研究,不然对着盲盒开发还是不好玩。
Nestjs
NestJs 的话没啥好说的,之前也都写过很多篇文章了,感兴趣的可以直接观看:
对 Nodejs 的底层也有了比较深的理解了:
Prisma & mysql
Prisma 是一个现代化的 ORM(对象关系映射)工具,旨在简化数据库操作并提高开发效率。它支持 MySQL 等关系型数据库,并为 Node.js 提供了类型安全的数据库客户端。在 NestJS 中使用 Prisma,可以让开发者轻松定义数据库模型,并通过自动生成的 Prisma Client 执行类型安全的查询操作。与 MySQL 配合时,Prisma 提供了一种简单、直观的方式来操作数据库,而无需手动编写复杂的 SQL 查询。
Prisma 的核心优势在于其强大的类型安全功能,所有的数据库操作都能通过 Prisma Client 提供的自动生成的类型来进行,这大大减少了代码中的错误,提升了开发的效率。它还包含数据库迁移工具 Prisma Migrate,能够帮助开发者方便地管理数据库结构的变化。此外,Prisma Client 的查询 API 具有很好的性能,能够高效地执行复杂的数据库查询,支持包括关系查询、聚合查询等高级功能。
与传统的 ORM 相比,Prisma 使得数据库交互更加简洁且高效,减少了配置和手动操作的复杂性,特别适合在 NestJS 项目中使用,能够与 NestJS 提供的依赖注入和模块化架构很好地结合,提升整体开发体验。
Redis
Redis 和 mysql 都仅仅是会用的阶段,目前都是直接在 NestJs 项目中使用,都是已经封装好了的,直接传参调用就好了:
import { Injectable, Inject, OnModuleDestroy, Logger } from "@nestjs/common";
import Redis, { ClientContext, Result } from "ioredis";
import { ObjectType } from "../types";
import { isObject } from "@/utils";
@Injectable()
export class RedisService implements OnModuleDestroy {
private readonly logger = new Logger(RedisService.name);
constructor(@Inject("REDIS_CLIENT") private readonly redisClient: Redis) {}
onModuleDestroy(): void {
this.redisClient.disconnect();
}
/**
* @Description: 设置值到redis中
* @param {string} key
* @param {any} value
* @return {*}
*/
public async set(
key: string,
value: unknown,
second?: number
): Promise<Result<"OK", ClientContext> | null> {
try {
const formattedValue = isObject(value)
? JSON.stringify(value)
: String(value);
if (!second) {
return await this.redisClient.set(key, formattedValue);
} else {
return await this.redisClient.set(key, formattedValue, "EX", second);
}
} catch (error) {
this.logger.error(`Error setting key ${key} in Redis`, error);
return null;
}
}
/**
* @Description: 获取redis缓存中的值
* @param key {String}
*/
public async get(key: string): Promise<string | null> {
try {
const data = await this.redisClient.get(key);
return data ? data : null;
} catch (error) {
this.logger.error(`Error getting key ${key} from Redis`, error);
return null;
}
}
/**
* @Description: 设置自动 +1
* @param {string} key
* @return {*}
*/
public async incr(
key: string
): Promise<Result<number, ClientContext> | null> {
try {
return await this.redisClient.incr(key);
} catch (error) {
this.logger.error(`Error incrementing key ${key} in Redis`, error);
return null;
}
}
/**
* @Description: 删除redis缓存数据
* @param {string} key
* @return {*}
*/
public async del(key: string): Promise<Result<number, ClientContext> | null> {
try {
return await this.redisClient.del(key);
} catch (error) {
this.logger.error(`Error deleting key ${key} from Redis`, error);
return null;
}
}
/**
* @Description: 设置hash结构
* @param {string} key
* @param {ObjectType} field
* @return {*}
*/
public async hset(
key: string,
field: ObjectType
): Promise<Result<number, ClientContext> | null> {
try {
return await this.redisClient.hset(key, field);
} catch (error) {
this.logger.error(`Error setting hash for key ${key} in Redis`, error);
return null;
}
}
/**
* @Description: 获取单个hash值
* @param {string} key
* @param {string} field
* @return {*}
*/
public async hget(key: string, field: string): Promise<string | null> {
try {
return await this.redisClient.hget(key, field);
} catch (error) {
this.logger.error(
`Error getting hash field ${field} from key ${key} in Redis`,
error
);
return null;
}
}
/**
* @Description: 获取所有hash值
* @param {string} key
* @return {*}
*/
public async hgetall(key: string): Promise<Record<string, string> | null> {
try {
return await this.redisClient.hgetall(key);
} catch (error) {
this.logger.error(
`Error getting all hash fields from key ${key} in Redis`,
error
);
return null;
}
}
/**
* @Description: 清空redis缓存
* @return {*}
*/
public async flushall(): Promise<Result<"OK", ClientContext> | null> {
try {
return await this.redisClient.flushall();
} catch (error) {
this.logger.error("Error flushing all Redis data", error);
return null;
}
}
/**
* @Description: 保存离线通知
* @param {string} userId
* @param {any} notification
*/
public async saveOfflineNotification(
userId: string,
notification: any
): Promise<void> {
try {
await this.redisClient.lpush(
`offline_notifications:${userId}`,
JSON.stringify(notification)
);
} catch (error) {
this.logger.error(
`Error saving offline notification for user ${userId}`,
error
);
}
}
/**
* @Description: 获取离线通知
* @param {string} userId
* @return {*}
*/
public async getOfflineNotifications(userId: string): Promise<any[]> {
try {
const notifications = await this.redisClient.lrange(
`offline_notifications:${userId}`,
0,
-1
);
await this.redisClient.del(`offline_notifications:${userId}`);
return notifications.map((notification) => JSON.parse(notification));
} catch (error) {
this.logger.error(
`Error getting offline notifications for user ${userId}`,
error
);
return [];
}
}
/**
* 获取指定 key 的剩余生存时间
* @param key Redis key
* @returns 剩余生存时间(秒)
*/
public async getTTL(key: string): Promise<number> {
return await this.redisClient.ttl(key);
}
}
前端工程化
前端工程化这块花了很多信息在 eslint、prettier、husky、commitlint、github action 上,现在很多项目都是直接复制之前写好的过来就直接用。
后续应该是投入更多的时间在性能优化、埋点、自动化部署上了,如果有机会的也去研究一下 k8s 了。
全栈性价比最高的一套技术
最近刷到一个帖子,讲到了
我目前也算是一个小全栈了吧,我也来分享一下我的技术吧:
- NextJs
- React Native
- prisma
- NestJs
- taro (目前还不会,如果有需求就会去学)
剩下的描述也是和他下面那句话一样了(毕业后对技术态度的转变就是什么能让我投入最小,让我最快赚到钱的就是好技术)
总结
学无止境,任重道远。
最后再来提一下这两个开源项目,它们都是我们目前正在维护的开源项目:
如果你想参与进来开发或者想进群学习,可以添加我微信 yunmz777
,后面还会有很多需求,等这个项目完成之后还会有很多新的并且很有趣的开源项目等着你。
来源:juejin.cn/post/7451483063568154639
一年多三次考试,总算过了系统架构师
前言
2024/12/27更新:实体证书也出来啦,如下:
2024/12/20更新:电子证书出来啦,如下:
算上这次,我其实已经参加了三次考试,先贴上这三次的成绩,相信大家也能感受到我的心情:
虽然这次总算通过了考试,但看到综合知识的成绩还是心有余悸,由于前两次考试的打击(都是差一门案例没有通过,上半年只差了两分),这次考试前只写了两套半的综合知识真题和在考前一天准备了大半天(背论文模板和知识点集锦的 pdf),而综合知识也是自己信心最足的一门,结果这次压线通过,所以还是建议大家只要报名了考试,还是要认真准备,至少把往年综合知识的真题刷一刷,避免最后发现只有综合知识未通过而追悔莫及。
我也在Github上分享了几个我备考用到的文档资料,大家自行取用。
PS:这次考试通过还要感谢我女友的祝福,我们在今年5.23相识(上半年考试前两天,然后考试也是差两分),再加上这次的压线通过,感受到了冥冥之中自有天意(❁´◡`❁)。
考试注意点
从去年下半年开始,软考统一由笔试改为机考,虽然不用再担心写字速度太慢或者不美观导致论文扣分,但要注意的是键盘只能使用考场提供的,因此很多人可能不太习惯。就我这几次的考试经验来说,两个小时写论文还是比较紧凑的,剩余时间都不超过10分钟,还要用这点时间去通读检查一遍论文有没有什么错别字,因此在考前准备一个论文模板还是十分必要的,这样就可以在写模板内容的同时去构思正文,对于时间充分的小伙伴来说,也可以计时去练习写几篇论文。另外需要注意从2024年开始,系统架构师改为一年两考(不通过也可以趁热打铁立刻准备下一次的考试了),上午考综合知识和案例(总共四小时,分别两小时,综合知识写完可提前半小时交卷去写案例),下午考论文(两个小时),考试时间安排如下:
考试时间 | 考试科目 |
---|---|
8:30—12:30 | 综合知识、案例分析 |
14:30—16:30 | 论文 |
备考经历
第一次(2 ~ 3个月):看完某赛视频全集(无大数据相关)+ 某赛知识点集锦 + 写完历年综合知识真题 + 案例论文对着答案看一遍(写了几道质量属性和数据库相关的案例题)+ 准备并背诵一个论文模板。
第二次(0.5 ~ 1个月):这次将上次的看视频改为了看教材(把考试重点的几个章节内容都混了个眼熟),然后其他准备都差不多,只是准备时间有相应减少。
第三次(1 ~ 2天):两套半综合知识真题 + 大致浏览一遍知识点集锦 + 背诵论文模板。
备考主要有以下注意点:
- 视频课不管是哪一家都无所谓,但需要注意架构师考试在22年12月更新了考试大纲,所以需要留意视频的版本不可太老,然后就是不管是在B站、闲鱼还是原价购买都不会有什么差别,只需保证视频内容完整即可。
- 各个机构的模拟题不要过多在意,尤其是考纲之外的题目,可作为对个人学习情况的测试。
- 近三次的考试由于是机考,只能在网上找到部分回忆版,不再有完整版真题,这个可自行搜索了解。
- 如果是第一次备考,建议还是至少 2 ~ 3 个月,除非基础特别好,不然还是建议将视频课看完(至少看完核心内容,计算机基础部分的优先级最低),这样至少可以保证综合知识问问拿下,还有就是真题特别特别特别重要。
备考方式
综合知识
就我的经验来讲,我觉得综合知识是最可控的部分,只需将视频课 + 重要知识点集锦 + 历年综合知识真题过一遍,综合知识是完全不需要担心的。还有就是遇到考纲之外的真题,比如今年有一道题是:一项外观设计专利里面相似设计最多有几个,像这种基本无再考可能的题,只需要看到答案后混个眼熟就可以。除此之外还有一部分反复考的知识点:构件、4 + 1视图、ABSD、DSSA、架构评估(质量属性)、系统架构风格、项目时间和成本计算以及软件测试,这些内容需要格外留意,有时间的话,可以把教材上相关知识的内容过一遍。除此之外,一定要记得考试时相信自己的第一感觉,不确定的题目不要修改答案。
案例分析
案例分析的题型变化比较大,更考验平时的技术积累,不过第一道必选题近几年都是和质量属性相关(除了23年下半年是大数据),然后就是 Redis 的考频也比较高,近三次考试有两次涉及(以往也有涉及),在24年上半年甚至精确到了命令的考察。此外,近几次案例也都考到了关于技术架构图的填空题,所以建议练习一下往年的相关题型,再到 ProcessOn 之类的平台找几个技术架构图看看。
案例考察的范围比较广,因此建议在高频考点上多加复习和准备。然后遇到不熟悉的知识点也不要慌,更不要空着不写,可以分点试着写一些或者硬凑一些相关的内容,能得一分是一分。如果时间充足,还是建议把往年的案例真题按照时间由近到远认真看一看,即使是一些视频中说的考试概率很低的知识点(Hibernate和设计模式)在前两次的考试和论文中也都有涉及,尤其是项目和技术经验不是那么丰富的小伙伴(比如我自己)需要注意这点。
论文
虽然看到很多小伙伴都说论文难写还会卡分,但因为我三次考试也都只有案例未过,论文虽然分数不高,但也都过了合格线,这里也分享一下我的写作经验。
我觉得写论文只需要记住真实项目 + 技术点讨论 + 论点点题并结合项目分析 + 项目中遇到的问题点这几点即可,即使内容有点流水账也无伤大雅,最重要的是写的让项目看起来真实,是自己做的,除了摘要和开头结尾可以找模板进行参考,正文部分还是需要自己结合论点去写,不能全是理论而没有一点技术点(使用到的各种工具和服务也都可以说,例如代码评审使用和项目管理相关的)的讨论。就以我这次的论文结构为例,首先是摘要部分(250字以内):
2022年12月,我所在公司承接了某区xxx的开发项目。我在该项目中担任系统架构设计师的职务,负责需求分析和系统的架构设计等工作。该项目主要提供xxx、xxx和xxx功能。本文将结合作者的实践,以xxx项目为例,论述xxx在系统开发中的具体应用。在xxx模块使用了xxx,解决了xxx问题。在xxx模块使用了xxx,解决了xxx问题。在xxx模块使用了xxx,解决了xxx问题。实践证明,采用xxx,提升了软件的开发效率和质量。整个项目历时一年多,于今年6月正式上线运行,整个系统运行稳定,达到了预期的目标的要求。
然后是开头和结尾(800字左右):
......。(项目背景,150字左右)
正是在这一背景下,2022年12月,我们公司承接了xxx项目,在本项目中,我担任系统架构师的职务,负责需求分析和系统的架构设计等工作。经过对项目的调研和对用户需求的分析,我们确认了系统应当具有以下功能:xxx,xxx,xxx。基于以上的需求,我们采用xxx解决了xxx问题。(300字左右,这部分介绍功能的部分可以和摘要内容有重合)
经过团队的共同努力,本项目按时交付,于今年6月顺利交付并上线,到目前运行稳定,不管是xxx使用xxx,还是xxx使用xxx都反馈良好。但在实施的过程中也遇到了一些问题,xxx。而如何让xxx更xxx是一项长期的工作,还有很多问题需要在实践中不断探索,在理论中深入研究并加以解决。只有这样,xxx才能不断地优化和发展,xxx。(350字左右)
最后是正文,由于我写的是软件维护(具体包含完善性维护、预防性维护、改正性维护、适应性维护),所以我首先用200 ~ 300字描述了这四种维护的具体含义(可以用自己的语言去描述,不需要和书上完全一致)。然后针对每种维护,再分四段用250 ~ 300字去结合项目和技术点具体去讨论我在每种维护中所做的工作。
当然上面只是我的一些论文写作经验,至少最近三次都是按照这个模板和套路去写,也都通过了。不过大家还是要结合自己的项目去做一些修改,建议多找几个论文综合一下,然后结合自己的语言去写一个属于自己的模板( •̀ ω •́ )✧。
感想
经过这三次的备考和考试经历,我觉得除了一些实力外,运气也占了一部分。就像这次的案例考了我熟悉的也简单的质量属性和 Cache Aside 缓存策略,前两次都有涉及到大数据这个我不熟悉的相关知识,也是我挂在案例的原因之一,所以大家如果考试遇到不熟悉的题或者分数还差一点,不妨再试一两次,相信自己可以的(●'◡'●)。如果大家有什么问题,也可以留言交流讨论。
来源:juejin.cn/post/7449570539884265524
SpringBoot 中实现订单30分钟自动取消
在涉及到支付的业务时,通常需要实现一个功能:如果用户在生成订单的一定时间内未完成支付,系统将自动取消订单。本文将基于Spring Boot框架实现订单30分钟内未支付自动取消的几种方案,并提供实例代码。
方案一:定时任务
利用@Scheduled注解,我们可以轻松实现定时任务,周期性扫描订单记录,检查未支付的订单,如果有满足三十分钟则进行关闭。
@Component
public class OrderSchedule {
@Autowired
private OrderService orderService;
@Scheduled(cron = "0 0/1 * * * ?")
public void cancelUnpaidOrders() {
LocalDateTime now = LocalDateTime.now();
List<Integer> idList = new ArrayList<Integer>();
List<OrderEntity> orderList = orderService.getOrderList();
orderList.forEach(order -> {
if (order.getWhenCreated().plusMinutes(30).isBefore(now)) {
idList.add(order.getId());
}
});
orderService.cancelOrderList(idList);
}
}
方案二:延迟队列
使用消息队列的延迟队列,当订单生成时将订单ID推送到延迟队列,设置30分钟后过期,过期后消费该消息,判断订单状态,如果未支付则取消订单。
@Service
public class OrderService {
@Autowired
private RabbitTemplate rabbitTemplate;
public void createOrder(Order order) {
// 保存数据库
saveOrder(order);
// 将订单ID推送至延迟队列
rabbitTemplate.convertAndSend("orderDelayExchange", "orderDelayKey", order.getId(), message -> {
message.getMessageProperties().setDelay(30 * 60 * 1000); // 设置延迟时间
return message;
});
}
}
@Component
public class OrderDelayConsumer {
@Autowired
private OrderService orderService;
@RabbitHandler
@RabbitListener(queues = "orderDelayQueue")
public void cancelOrder(String orderId) {
// 取消订单
orderService.cancelOrder(orderId);
}
}
方案三:redis过期事件
使用redis的key过期事件,当订单创建时在Redis中存储一个key,设置30分钟过期,key过期时通过redis的过期事件通知功能触发订单取消。
@Service
public class OrderService {
@Autowired
private StringRedisTemplate redisTemplate;
public void createOrder(Order order) {
// 保存订单至数据库
saveOrder(order);
// 在redis中存储一个key,设置30分钟过期
redisTemplate.opsForValue().set("order:" + order.getId(), order.getId(), 30, TimeUnit.MINUTES);
}
// 当key过期时,redis会自动调用该方法(需要配置redis的过期事件通知)
public void onOrderKeyExpired(String orderId) {
cancelOrder(orderId);
}
}
注:redis的key过期通知是一种典型的发布-订阅模式。在redis中,我们可以订阅到某些特定的事件。key过期事件就是其中之一。但想要使用这个功能,需要redis服务器开启相关配置。具体如何配置会在后期的文章里分享给大家。
最后总结:
三种方案都可以实现订单在30分钟内未支付则自动取消的需求。根据实际业务需求、系统负载和其他因素,可以选择最适合自己系统的实现方案。每种方案都有其优缺点,需要根据具体情况权衡。
来源:juejin.cn/post/7340907184640065536
📊 弃用 Echarts!这一次我选择 - Vue Data UI!
大家好,我是
xy
👨🏻💻。今天,我要向大家隆重推荐一款令人惊艳的可视化图表库——Vue Data UI
,一个赋予用户权力的数据可视化Vue3
组件库!🎉
🌈 前言
Vue Data UI
诞生于一个问题:如果你的仪表板这么好,为什么你的用户要求 CSV 导出功能?
这个开源库
的目的是为最终用户提供一组围绕图表和表格的内置工具,以减少重新计算导出数据的麻烦。当然,Vue Data UI 保留了导出为 CSV 和 PDF 的选项,以防万一。
数据,是现代商业决策的基石。但如何将复杂的数据转化为直观、易理解的视觉信息?这正是 Vue Data UI
致力于解决的问题。
🚀 丰富的图表类型,颜值爆表
探索数据的无限可能,Vue Data UI
带你领略数据之美!目前官方共提供 54 种 可视化组件,满足您的各种需求:
- 🌟 迷你图表:小巧精致,适合快速展示数据。
- 📈 折线图:流畅的线条,清晰展现数据趋势。
- 🍕 饼图:直观展示数据占比,一目了然。
- 📋 仪表盘:动态展示关键指标,提升决策效率。
- 🔍 雷达图:全面展示多变量数据,洞察数据全貌。
- 🎨 3D 图表:立体展示数据,增强视觉冲击力。
- 🚀 其它:更多组件查看-vue-data-ui.graphieros.com/examples。
📊 强大的图表生成器
告别繁琐,迎接效率!Vue Data UI
提供了一款超强大的图表可视化生成器
,可视化编辑,所见即所得
- 通过直观的可视化界面,编写数据集,调整配置设置。
- 一切配置皆可可视化,无需再翻阅大量 API 文档。
- 直接复制组件代码,快速集成到您的项目中。
一键复制
组件代码,重点:组件代码
📈 提供高定制化 APi
Vue Data UI
不仅仅是一个图表库,它是您项目中的定制化利器
。提供了丰富的 API
和 插槽
属性,确保您的每一个独特需求都能得到满足。
- 利用提供的 API,您可以对图表的每一个细节进行精细调整。
- 插槽属性让您能够插入自定义的
HTML
或Vue
组件,实现真正的个性化设计。
比如我们需要在一个图表中注入另外一个图表:
注入一个箭头:
🛠️ 易于集成,快速上手
官方文档有很显眼的一句:1 import , 3 props , 54 components
安装
npm i vue-data-ui
# or
yarn add vue-data-ui
组件使用
<script setup>
import { ref } from "vue";
import { VueDataUi } from "vue-data-ui";
import "vue-data-ui/style.css";
const dataset = ref([...]);
const config = ref({...});
</script>
<template>
<div style="width:600px;">
<VueDataUi
component="VueUiXy"
:dataset="dataset"
:config="config"
/>
</div>
</template>
如果您也是一名前端开发
,请一定要尝试下这个可视化组件库
,因为这个可视化库真的太酷啦!
最后给大家送上官网地址:vue-data-ui.graphieros.com/
写在最后
如果觉得本文对你有帮助,希望能够给我点赞支持一下哦 💪 也可以关注wx公众号:
前端开发爱好者
回复加群,一起学习前端技能 公众号内包含很多实战
精选资源教程,欢迎关注
来源:juejin.cn/post/7419272082595708955
舒服了,学习了,踩到一个 Lombok 的坑!
你好呀,我是歪歪。
踩坑了啊,最近踩了一个 lombok 的坑,有点意思,给你分享一波。
我之前写过一个公共的服务接口,这个接口已经有好几个系统对接并稳定运行了很长一段时间了,长到这个接口都已经交接给别的同事一年多了。
因为是基础服务嘛,相对稳定,所以交出去之后他也一直没有动过这部分代码。
但是有一天有新服务要对接这个接口,同事反馈说遇到一个诡异的问题,这个新服务调用的时候,接口里面报了一个空指针异常。
根据日志来看,那一行代码大概是这样的:
//为了脱敏我用field1、2、3来代替了
if(reqDto.getField1()
&& reqDto.getField2()!=null
&& reqDto.getField3()!=null){
//满足条件则执行对应业务逻辑
}
reqDto 是接口入参对象,有好多字段。具体到 field1、2、3 大概是这样的:
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ReqDto {
private Boolean field1 = true;
private String field2;
private String field3;
}
所以看到这一行抛出了空指针异常,我直接就给出了一个结论:首先排除 field1 为 null,因为有默认值。那只可能 reqDto 传进来的就是 null,导致在 get 字段的时候出现了空指针异常。
但是很不幸,这个结论一秒就被推翻了。
因为 reqDto 是请求入参,在方法入口处选了几个关键字段进行打印。
如果 reqDto 是 null 的话,那么日志打印的时候就会先抛出空指针异常了。
然后我又开始怀疑是部署的代码版本和我们看的版本不一致,可能并不是这一行报错。
和测试同学确认之后,也排除了这个方向。
盯着报错的那一行代码又看了几秒,排除所有不可能之后,我又下了一个结论:调用的时候,传递进来的 field1 主动设值为了 null。
也就是说调用方有这样的代码:
ReqDto reqDto = new ReqDto();
reqDto.setField1(null);
我知道,这样的代码看起来很傻,但是确实只剩下这一种可能了。
于是我去看了调用方构建参数的写法,准备吐槽一波为什么要写设置为 null 这样的坑爹代码。
然而,当时我就被打脸了,调用方的代码是这样的:
ReqDto reqDto = ReqDto.builder()
.field2("why")
.field3("max")
.build();
用的是 builder 模式构建的对象,并不是直接 new 出来的对象。
我一眼看着这个代码也没有发现毛病,虽然没有对 Boolean 类型的 field1 进行设值,但是我有默认值啊。
问调用方为什么不设值,对方的回答也是一句话:我看你有默认值,我本来也是想传 true,但是一看你的默认值就是 true,所以就没有给值了。
对啊,这逻辑无懈可击啊,难道......
是 builder 在里面搞事情了?
于是我里面写了一个代码进行了验证:
好你个浓眉大眼的 @Builder,果然是你在搞事情。
问题现象基本上就算是定位到了,用 @Builder 注解的时候,丢失默认值了。
所以拿着 “@Builder 默认值” 这样的关键词一搜:
立马就能找到这样的一个注解:@Builder.Default
对应到我的案例应该是这样的:
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ReqDto {
@Builder.Default
private Boolean field1 = true;
private String field2;
private String field3;
}
这样,再次运行 Demo 就会发现有默认值了:
同时我们从两个写法生成的 class 文件中也可以看出一些端倪。
没有@Builder.Default 注解的时候,class 文件中 ReqDtoBuilder 类中关于 field1 字段是这样的:
但是有 @Builder.Default 注解的时候,是这样的:
明显是不同的处理方式。
反正,网上一搜索,加上 @Builder.Default 注解,问题就算是解决了。
但是紧接着我想到了另外一个问题:为什么?
为什么我明明给了默认值,@Builder 不使用,非得给再显示的标记一下呢?
于是我带着这个问题在网上冲了一大圈,不说没有找到权威的回答了,甚至没有找到来自“民间”的回答。
所以我也只能个人猜测一下,我觉得可能是 Lombok 觉得这样的赋默认值的写法是 Java 语言的规范:
private Boolean field1 = true;
规范我 Lombok 肯定遵守,但是我怎么知道你这个字段有没有默认值呢?
我肯定是有手段去检查的,但是我必须要每个字段都盲目的去瞅一眼,这个方案对我不友好啊。
这样,我给使用者定一个规范:你给我打个标,主动告诉我那些字段是有默认值的。对于打了标的字段,我才去解析对应的默认值,否则我就不管了。
如果你直接 new 对象,那是 Java 的规范,我管不了。
但是如果你使用 Builder 模式,你就得遵守我的规范。不然出了问题也别赖我,谁叫你不准守我的规范。
打个标,就是 @Builder.Default。
必须要强调的是,这个观点是歪师傅纯粹的个人想法,不保真。如果你有其他的看法也可以提出来一起交流,学习一波。
吃个瓜
虽然我没有找到关于 @Builder.Default 注解存在的意义的官方说明,但是我在 github 上找到了这个一个链接:
里面的讨论的问题和我们这个注解有点关系,而且我认为这是一个非常明确的 bug,但是官方却当做 feature 给处理了。
简单的一起吃个瓜。
2017 年 3 月 29 日的时候,一个老哥抛出了一个问题。
首先我们看一下提出问题的老哥给的代码:
就上面这个代码,如果我们这样去创建对象:
MyClass myClass = new MyClass();
按照 Java 规范来说,我们附了默认值的,调用 myClass.getEntitlements() 方法返回的肯定是一个空集合嘛。
但是,这个老哥说当 new MyClass 对象的时候,这个字段变成了 null:
他就觉得很奇怪,于是抛出了这个问题。
然后另外有人立马补充了一下。说不仅是 list/set/map,任何其他 non-primitive 类型都会出现这个问题:
啥意思呢,拿我们前面的案例来说就是,你用 1.16.16 这个版本,不加 @Builder.Default 注解,运行结果是符合预期的:
但是加上 @Builder.Default 注解,运行结果会变成这样:
build 倒是正确了,但是 new 对象的时候,你把默认值直接给干没了。
看到这个运行结果的第一个感觉是很奇怪,第二个感觉是这肯定是 lombok 的 BUG。
问题抛出来之后,紧接着就有老哥来讨论了:
这个哥们直接喊话官方:造孽啊,这么大个 BUG 还有没有人管啦?
同时他还抛出了一个观点:老实说,为字段生成默认值的最直观方法就是从字段初始化中获取值,而不是需要额外的 Builder.Default 注解来标记。
这个观点,和我前面的想法倒是不谋而合。但是还是那句话:一切解释权归官方所有,你要用,就得遵守我制定的规范。
那么到底是改了啥导致产生了这么一个奇怪的 BUG 呢?
注意 omega09 这个老哥的发言的后半句:field it will be initialized twice.
initialized twice,初始化两次,哪来的两次?
我们把目光放到这里来:
@NoArgsConstructor,这是个啥东西?
这不就是让 lombok 给我们搞一个无参构造函数吗?
搞无参构造函数的时候,不是得针对有默认值的字段,进行一波默认值的初始化吗?
这个算一次了。
前面我们分析了 @Builder.Default 也要对有默认值的字段初始化一次。
所以是 twice,而且这两次干得都是同一个活。
开发者一看,这不行啊,得优化啊。
于是把 @NoArgsConstructor 的初始化延迟到了 @Builder.Default 里面去,让两次合并为一次了。
这样一看,用 Builder 模式的时候确实没问题了,但是用 new 的时候,默认值就没了。
这是一种经典的顾头不顾尾的解决问题的方式。
作者可能也没想到,大家在使用的时候会把 @Builder 和 @NoArgsConstructor 两个注解放在一起用。
作者可能还觉得委屈呢:这明明就是两种不同的对象构建方式啊,二选一就行了,你要放在一起?哎哟,你干嘛~
接着一个叫做 davidje13 的老哥接过了话茬,顺着 omega09 老哥的话往下说,他除了解释两个注解放在一起使用的场景外,还提到了一个词:least-surprise。
least-surprise,是一个软件设计方面的词汇,翻译过来就是最小惊吓原则。
简单来说就是我们的程序所表现出的行为,应该尽量满足在其领域内具有一致性、显而易见、可预测、遵循惯例。
比如我们认为的惯例是 new 对象的时候,如果有默认值会附上默认值。
结果你这个就搞没了,就不遵循惯例了。
当然,你还是可以拿出那句万金油的话:一切解释权归官方所有,你要用,就得遵守我制定的规范。我的规范就是不让你们混用。
这就是纯纯的耍无赖了,相当于是做了一个违背祖宗的决定。
然而这个问题似乎并没有官方人员参与讨论,直到这个时候,2018 年 3 月 27 日:
rspiller 就是官方人员,他说:我们正在调查此事。
此时,距离这个问题提出的时间已经过去了一年。
我是比较吃惊的,因为我认为这是一个比较严重的 BUG 了,程序员在使用的时候会遇到一些就类似于我认为这个字段一定是有默认值的,但是实际上却变成了 null 这种莫名其妙的问题。
在官方人员介入之后,这个问题再次活跃起来。
一位 xak2000 老哥也发表了自己的看法,并艾特了官方人员:
他的观点我是非常认同的,给你翻译一波。
他说,导致这个问题的原因是为了消除可能出现的重复初始化。但实际上,与修改 POJO 字段的默认初始化这种完全出乎意料的行为相比,重复初始化的问题要小得多。
当然,解决这个问题的最佳方法是以某种方式摆脱双重初始化,同时又不破坏字段初始化器。
但如果这不可能,或者太难,或者时间太长,那么,就让重复初始化发生吧!
然后把“重复初始化”写到 @Builder.Default javadocs 中,大不了再给这几个字加个粗。
如果有人确实写了一些字段初始化比较复杂的程序,这可能会导致一些问题,但比起该初始化却没有初始化带来的问题要少得多。
在当前的这个情况下,当突然抛出一个空指针异常的时候,我真的很蒙蔽啊。
当然了,也有人提出了不一样的看法:
这个哥们的核心思路刚刚相反,就是呼吁大家不要把 @Builder 和 @NoArgsConstructor 混着用。
从“点赞数”你也能看出来,大家都不喜欢这个方案。
而这个 BUG 是在 2018 年 7 月 26 日,1.18.2 版本中才最终解决的:
此时,距离这个问题提出,已经过去了一年又四个月。
值得注意的是,在官方的描述里面,用的是 FEATURE 而不是 BUGFIX。
个中差异,你可以自己去品一品。
但是现在 Lombok 都已经发展到 1.18.32 版本了,1.16.x 版本应该没有人会去使用了。
所以,大家大概率是不会踩到这个坑的。
我觉得这个事情,了解“坑”具体是啥不重要,而是稍微走进一下开源项目维护者的内心世界。
开源不易,有时候真的就挺崩溃的。
编译时注解
既然聊到 Lombok 了,顺便也简单聊聊它的工作原理。
Lombok 的核心工作原理就是编译时注解,这个你知道吧?
不知道其实也很正常,因为我们写业务代码的时候很少自定义编译时注解,顶天了搞个运行时注解就差不多了。
其实我了解的也不算深入,只是大概知道它的工作原理是什么样的,对于源码没有深入研究。
但是我可以给你分享一下两个需要注意的地方和可以去哪里了解这个玩意。
以 Lombok 的日志相关的注解为例。
首先第一个需要注意的地方是这里:
log 相关注解的源码位于这个部分,可以看到很奇怪啊,这些文件是以 SCL.lombok 结尾的,这是什么玩意?
这是 lombok 的小心思,其实这些都是 class 文件,但是为了避免污染用户项目,它做了特殊处理。
所以你打开这类文件的时候选择以 class 文件的形式打开就行了,就可以看到里面的具体内容。
比如你可以看看这个文件:
lombok.core.handlers.LoggingFramework
你会发现你们就像是枚举似的,写了很多日志的实现:
这个里面把每个注解需要生成的 log 都硬编码好了。正是因为这样,Lombok 才知道你用什么日志注解,应该给你生成什么样的 log。
比如 log4j 是这样的:
private static final org.apache.logging.log4j.Logger log = org.apache.logging.log4j.LogManager.getLogger(TargetType.class);
而 SLF4J 是这样的:
private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(TargetType.class);
第二个需要注意的地方是找到入口:
这些 class 文件加载的入口在于这个地方,是基于 Java 的 SPI 机制:
AnnotationProcessorHider 这个类里面有两行静态内部类,我们看其中一个, AnnotationProcessor ,它是继承自 AbstractProcessor 抽象类:
javax.annotation.processing.AbstractProcessor
这个抽象类,就是入口中的入口,核心中的核心。
在这个入口里面,初始化了一个类加载器,叫做 ShadowClassLoader:
它干的事儿就是加载那些被标记为 SCL.lombok 的 class 文件。
然后我是怎么知道 Lombok 是基于编译时注解的呢?
其实这玩意在我看过的两本书里面都有写,有点模糊的印象,写文章的时候我又翻出来读了一遍。
首先是《深入理解 Java 虚拟机(第三版)》的第四部分程序编译与代码优化的第 10 章:前端编译与优化一节。
里面专门有一小节,说插入式注解的:
Lombok 的主要工作地盘,就在 javac 编译的过程中。
在书中的 361 页,提到了编译过程的几个阶段。
从 Java 代码的总体结构来看,编译过程大致可以分为一个准备过程和三个处理过程:
- 1.准备过程:初始化插入式注解处理器。
- 2.解析与填充符号表过程,包括:
- 词法、语法分析。将源代码的字符流转变为标记集合,构造出抽象语法树。
- 填充符号表。产生符号地址和符号信息。
- 3.插入式注解处理器的注解处理过程:插入式注解处理器的执行阶段,本章的实战部分会设计一个插入式注解处理器来影响Javac的编译行为。
- 4.分析与字节码生成过程,包括:
- 标注检查。对语法的静态信息进行检查。
- 数据流及控制流分析。对程序动态运行过程进行检查。
- 解语法糖。将简化代码编写的语法糖还原为原有的形式。(java中的语法糖包括泛型、变长参数、自动装拆箱、遍历循环foreach等,JVM运行时并不支持这些语法,所以在编译阶段需要还原。)
- 字节码生成。将前面各个步骤所生成的信息转换成字节码。
如果说 javac 编译的过程就是 Lombok 的工作地盘,那么其中的“插入式注解处理器的注解处理过程”就是它的工位了。
书中也提到了 Lombok 的工作原理:
第二本书是《深入理解 JVM 字节码》,在它的第 8 章,也详细的描述了插件化注解的处理原理,其中也提到了 Lombok:
最后画了一个示意图,是这样的:
如果你看懂了书中的前面的十几页的描述,那么看这个图就会比较清晰了。
总之,Lombok 的核心原理就是在编译期对于 class 文件的魔改,帮你生成了很多代码。
如果你有兴趣深入了解它的原理的话,可以去看看我前面提到的这两本书,里面都有手把手的实践开发。
我就不写了,一个原因是因为确实门槛较高,写出来生涩难懂,对我们日常业务开发帮助也不大。
另外一个原因那不是因为我懒嘛。
荒腔走板
周末去了一趟都江堰。
问道青城山,拜水都江堰。读大学的时候就知道这句话了,所以从大学算起,都江堰景区去过的次数,没有十次也有七八次了。
之前每次去就是觉得:哇,好大的山;哇,好急的水;哇,这个一点也不像鱼嘴的地方为什么叫鱼嘴;哇,这个鱼嘴看介绍很牛逼,但是我感觉我上我也行的样子。
这次去的时候,我和 Max 同学算是自己做了一次攻略,看了相关的介绍视频,比较系统的了解了一下鱼嘴、飞沙堰、宝瓶口的作用。
如果你也有兴趣的话,推荐看看 B 站“星球研究所”有一期将都江堰的视频,简短且直观,很不错。
看视频的时候才知道原来这里面有这么多门道,并惊叹于古人的智慧和劳动能力。顺应自然规律,因时制宜,建造了都江堰水利工程,并一直沿用了约 2300 年。
当我们真的走进景区,看到鱼嘴、飞沙堰、宝瓶口就在眼前的时候,才真正明白了视频里面说的“四六分水、二八排沙”是怎么回事,“深淘摊,低作堰”又是怎么回事。
水旱从人,不知饥谨,时无荒年,天下谓之天府也。
成都不能没有都江堰。
李冰父子,配享太庙。
来源:juejin.cn/post/7349569626341490740
为什么很多人不推荐你用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
Java中使用for而不是forEach遍历List的10大理由
首发公众号:【赵侠客】
引言
我相信作为一名java开发者你一定听过或者看过类似《你还在用for循环遍历List吗?》、《JDK8都10岁了,你还在用for循环遍历List吗?》这类鄙视在Java中使用for循环遍历List的水文。这类文章说的其实就是使用Java8中的Stream.foreach()
来遍历元素,在技术圈感觉使用新的技术就高大上,开发者们也都默许接受新技术的很多缺点,而使用老的技术或者传统的方法就会被人鄙视,被人觉得Low,那么使用forEach()
真的很高大上吗?它真的比传统的for
循环好用吗?本文就列出10大推荐使用for
而不是forEach()
的理由。
理由一、for性能更好
在我的固有认知中我是觉得for
的循环性能比Stream.forEach()
要好的,因为在技术界有一条真理:
越简单越原始的代码往往性能也越好
而且搜索一些文章或者大模型都是这么觉得的,可时我并没有找到专业的基准测试证明此结论。那么实际测试情况是不是这样的呢?虽然这个循环的性能差距对我们的系统性能基本上没有影响,不过为了证明for
的循环性能真的比Stream.forEach()
好我使用基准测试用专业的实际数据来说话。我的测试代码非常的简单,就对一个List<Integer> ids
分别使用for
和Stream.forEach()
遍历出所有的元素,以下是测试代码:
@State(Scope.Thread)
public class ForBenchmark {
private List<Integer> ids ;
@Setup
public void setup() {
ids = new ArrayList<>();
//分别对10、100、1000、1万、10万个元素测试
IntStream.range(0, 10).forEach(i -> ids.add(i));
}
@TearDown
public void tearDown() {
ids = new ArrayList<>();
}
@Benchmark
public void testFor() {
for (int i = 0; i <ids.size() ; i++) {
Integer id = ids.get(i);
}
}
@Benchmark
public void testStreamforEach() {
ids.stream().forEach(x->{
Integer id=x;
});
}
@Test
public void testMyBenchmark() throws Exception {
Options options = new OptionsBuilder()
.include(ForBenchmark.class.getSimpleName())
.forks(1)
.threads(1)
.warmupIterations(1)
.measurementIterations(1)
.mode(Mode.Throughput)
.build();
new Runner(options).run();
}
}
我使用ArrayList分对10、100、1000、1万,10万个元素进行测试,以下是使用JMH基准测试的结果,结果中的数字为吞吐量,单位为ops/s,即每秒钟执行方法的次数:
方法 | 十 | 百 | 千 | 万 | 10万 |
---|---|---|---|---|---|
forEach | 45194532 | 17187781 | 2501802 | 200292 | 20309 |
for | 127056654 | 19310361 | 2530502 | 202632 | 19228 |
for对比 | ↑181% | ↑12% | ↑1% | ↓1% | ↓5% |
从使用Benchmark基准测试结果来看使用for遍历List比Stream.forEach性能在元素越小的情况下优势越明显,在10万元素遍历时性能反而没有Stream.forEach好了,不过在实际项目开发中我们很少有超过10万元素的遍历。
所以可以得出结论:
在小List(万元素以内)遍历中for性能要优于Stream.forEach
理由二、for占用内存更小
Stream.forEach()会占用更多的内存,因为它涉及到创建流、临时对象或者对中间操作进行缓存。for 循环则更直接,操作底层集合,通常不会有额外的临时对象。可以看如下求和代码,运行时增加JVM参数-XX:+PrintGCDetails -Xms4G -Xmx4G
输出GC日志:
- 使用for遍历
List<Integer> ids = IntStream.range(1,10000000).boxed().collect(Collectors.toList());
int sum = 0;
for (int i = 0; i < ids.size(); i++) {
sum +=ids.get(i);
}
System.gc();
//GC日志
[GC (System.gc()) [PSYoungGen: 392540K->174586K(1223168K)] 392540K->212100K(4019712K), 0.2083486 secs] [Times: user=0.58 sys=0.09, real=0.21 secs]
从GC日志中可以看出,使用for遍历List在GC回收前年轻代使用了392540K,总内存使用了392540K,回收耗时0.20s
- 使用stream
List<Integer> ids = IntStream.range(1,10000000).boxed().collect(Collectors.toList());
int sum = ids.stream().reduce(0,Integer::sum);
System.gc();
//GC日志
[GC (System.gc()) [PSYoungGen: 539341K->174586K(1223168K)] 539341K->212118K(4019712K), 0.3747694 secs] [Times: user=0.55 sys=0.83, real=0.38 secs]
从GC日志中可以看出,回收前年轻代使用了539341K,总内存使用了539341K,回收耗时0.37s ,从内存占用情况来看使用for会比Stream.forEach()占用内存少37%,而且Stream.foreach() GC耗时比for多了85%。
理由三、for更易控制流程
我们使用for遍历List可以很方便的使用break
、continue
、return
来控制循环,而使用Stream.forEach在循环中是不能使用break
、continue
,特别指出的使用return
是无法中断Stream.forEach循环的,如下代码:
List<Integer> ids = IntStream.range(1,4).boxed().collect(Collectors.toList());
ids.stream().forEach(i->{
System.out.println(""+i);
if(i>1){
return;
}
});
System.out.println("==");
for (int i = 0; i < ids.size(); i++) {
System.out.println(""+ids.get(i));
if(ids.get(i)>1){
return;
}
}
输出:
forEach-1
forEach-2
forEach-3
==
for-1
for-2
从输出结果可以看出在Stream.forEach中使用return后循环还会继续执行的,而在for循环中使用return将中断循环。
理由四、for访问变量更灵活
这点我想是很多人在使用Stream.forEach中比较头疼的一点,因为在Stream.forEach中引用的变量必须是final类型,也就是说不能修改forEach循环体之外的变量,但是我们很多业务场景就是修改循环体外的变量,如以下代码:
Integer sum=0;
for (int i = 0; i < ids.size(); i++) {
sum++;
}
ids.stream().forEach(i -> {
//报错
sum++;
});
像上面的这样的代码在实际中是很常见的,sum++在forEach中是不被允许的,有时为了使用类似的方法我们只能把变量变成一个引用类型:
AtomicReference<Integer> sum= new AtomicReference<>(0);
ids.stream().forEach(i -> {
sum.getAndSet(sum.get() + 1);
});
所以在访问变量方面for会更加灵活。
理由五、for处理异常更方便
这一点也是我使用forEach比较头疼的,在forEach中的Exception必须要捕获处理,如下代码:
public void testException() throws Exception {
List<Integer> ids = IntStream.range(1, 4).boxed().collect(Collectors.toList());
for (int i = 0; i < ids.size(); i++) {
//直接抛出Exception
System.out.println(div(i, i - 1));
}
ids.stream().forEach(x -> {
try {
//必须捕获Exception
System.out.println(div(x, x - 1));
} catch (Exception e) {
throw new RuntimeException(e);
}
});
}
private Integer div(Integer a, Integer b) throws Exception {
return a / b;
}
我们在循环中调用了div()方法,该方法抛出了Exception,如果是使用for循环如果不想处理可以直接抛出,但是使用forEach就必须要自己处理异常了,所以for在处理异常方面会更加灵活方便。
理由六、for能对集合添加、删除
在for循环中可以直接修改原始集合(如添加、删除元素),而 Stream 不允许修改基础集合,会抛出 ConcurrentModificationException,如下代码:
List<Integer> ids = IntStream.range(0, 4).boxed().collect(Collectors.toList());
for (int i = 0; i < ids.size(); i++) {
if(i<1){
ids.add(i);
}
}
System.out.println(ids);
List<Integer> ids2 = IntStream.range(0, 4).boxed().collect(Collectors.toList());
ids2.stream().forEach(x -> {
if(x<1){
ids2.add(x);
}
});
System.out.println(ids2);
输出:
[0, 1, 2, 3, 0]
java.util.ConcurrentModificationException
如果你想在循环中添加或者删除元素foreach是无法完成了,所以for处理集合更方便。
理由七、for Debug更友好
Stream.forEach()使用了Lambda表达示,一行代码可以搞定很多功能,但是这也给Debug带来了困难,如下代码:
List<Integer> ids = IntStream.range(0, 4).boxed().collect(Collectors.toList());
for (int i = 0; i < ids.size(); i++) {
System.out.println(ids.get(i));
}
List<Integer> ids2 = IntStream.range(0, 4).boxed().collect(Collectors.toList());
ids2.stream().forEach(System.out::println);
以下是DeBug截图:
我们可以看出使用for循环Debug可以一步一步的跟踪程序执行步骤,但是使用forEach却做不到,所以for可以更方便的调试你的代码,让你更快捷的找到出现问题的代码。
理由八、for代码可读性更好
Lambda表达示属于面向函数式编程,主打的就是一个抽象,相比于面向对象或者面向过程编程代码可读性是非常的差,有时自己不写的代码过段时间后自己都看不懂。就比如我在文章《解密阿里大神写的天书般的Tree工具类,轻松搞定树结构!》一文中使用函数式编程写了一个Tree工具类,我们可以对比一下面向过程和面向函数式编程代码可读性的差距:
- 使用for面向过程编程代码:
public static List<MenuVo> makeTree(List<MenuVo> allDate,Long rootParentId) {
List<MenuVo> roots = new ArrayList<>();
for (MenuVo menu : allDate) {
if (Objects.equals(rootParentId, menu.getPId())) {
roots.add(menu);
}
}
for (MenuVo root : roots) {
makeChildren(root, allDate);
}
return roots;
}
public static MenuVo makeChildren(MenuVo root, List<MenuVo> allDate) {
for (MenuVo menu : allDate) {
if (Objects.equals(root.getId(), menu.getPId())) {
makeChildren(menu, allDate);
root.getSubMenus().add(menu);
}
}
return root;
}
- 使用forEach面向函数式编程代码:
public static <E> List<E> makeTree(List<E> list, Predicate<E> rootCheck, BiFunction<E, E, Boolean> parentCheck, BiConsumer<E, List<E>> setSubChildren) {
return list.stream().filter(rootCheck).peek(x -> setSubChildren.accept(x, makeChildren(x, list, parentCheck, setSubChildren))).collect(Collectors.toList());
}
private static <E> List<E> makeChildren(E parent, List<E> allData, BiFunction<E, E, Boolean> parentCheck, BiConsumer<E, List<E>> children) {
return allData.stream().filter(x -> parentCheck.apply(parent, x)).peek(x -> children.accept(x, makeChildren(x, allData, parentCheck, children))).collect(Collectors.toList());
}
对比以上两段代码,可以看出面向过程的代码思路非常的清晰,基本上可以一眼看懂代码要做什么,反观面向函数式编程的代码,我想大都人一眼都不知道代码在干什么的,所以使用for的代码可读性会更好。
理由九、for更好的管理状态
for循环可以轻松地在每次迭代中维护状态,这在Stream.forEach中可能需要额外的逻辑来实现。这一条可理由三有点像,我们经常需要通过状态能控制循环是否执行,如下代码:
boolean flag = true;
for (int i = 0; i < 10; i++) {
if(flag){
System.out.println(i);
flag=false;
}
}
AtomicBoolean flag1 = new AtomicBoolean(true);
IntStream.range(0, 10).forEach(x->{
if (flag1.get()){
flag1.set(false);
System.out.println(x);
}
});
这个例子说明了在使用Stream.forEach时,为了维护状态,我们需要引入额外的逻辑,如使用AtomicBoolean,而在for循环中,这种状态管理是直接和简单的。
理由十、for可以使用索引直接访问元素
在某些情况下,特别是当需要根据元素的索引(位置)来操作集合中的元素时,for就可以直接使用索引访问了。在Stream.forEach中就不能直接通过索引访问,比如我们需要将ids中的数字翻倍:
List<Integer> ids = IntStream.range(0, 4).boxed().collect(Collectors.toList());
for (int i = 0; i < ids.size(); i++) {
ids.set(i,i*2);
}
List<Integer> ids2 = IntStream.range(0, 4).boxed().collect(Collectors.toList());
ids2=ids2.stream().map(x->x*2).collect(Collectors.toList());
我们使用for循环来遍历这个列表,并在每次迭代中根据索引i来修改列表中的元素。这种操作直接且直观。而使用Stream.foreach()不能直接通过索引下标访问元素的,只能将List转换为流,然后使用map操作将每个元素乘以2,最后,我们使用Collectors.toList()将结果收集回一个新的List。
总结
本文介绍了在实际开发中更推荐使用for循环而不是Stream.foreach()来遍历List的十大理由,并给出了具体的代码和测试结果,当然这并不是说就一定要使用传统的for循环,要根据自己的实际情况来选择合适的方法。通过此案件也想让读者明白在互联网世界中你所看到的东西都是别人想让你看到的,这个世界是没有真相的,别人想让你看到的就是所谓的”真相“,做为吃瓜群众一定不能随波逐流,要有鉴别信息真假的能力和培养独立思考的能力。
来源:juejin.cn/post/7416848881407524902
一文讲清DTO、BO、PO、VO,为什么可以不需要VO?
DTO、BO、PO、VO是什么
在讨论这些是什么的时候,建议先看看我的这篇文章:写好业务代码的经典案例 - 掘金 (juejin.cn)
在上面我的这篇文章中提到的缺乏模型抽象,无边界控制,就是正好对应的DTO BO PO VO这些模型的概念
如何对模型进行抽象,控制边界,可用看看我的这篇文章 :为啥建议用MapperStruct,不建议用BeanUtils.copyProperties拷贝数据? - 掘金 (juejin.cn)
在后端开发中,比如传统的MVC架构和现在流行的DDD架构,经常会使用到下列几种对象的概念
- DTO (Data Transfer Object) 数据传输对象: DTO设计模式用于将数据从服务端传输到客户端,或者在不同的服务之间传递。通常,DTO包含了特定业务场景需要的数据结构,并且不包含任何业务逻辑。它简化了不同服务或模块之间的交互,使得各个层之间的耦合度降低。
- BO (Business Object) 业务对象: BO代表了业务逻辑层中的对象,封装了与某个业务相关的数据以及针对这些数据的操作逻辑。一个BO可能由多个实体属性组成,并处理涉及多个实体的复杂业务逻辑。
- PO (Persistent Object) 持久化对象: PO主要用来表示数据库表的一条记录,它的属性和数据库表的字段相对应。通常在持久层(如Hibernate、JPA等ORM框架)中使用,主要用于操作数据库,如保存、更新和查询数据。
- VO (Value Object) 值对象: VO是视图层的对象,通常用于封装展示给用户的数据,它可以和数据库表对应,也可以根据UI界面需求进行定制。VO的主要目的是在页面展示时只携带必要的数据,从而避免把大量不必要的数据暴露给前端。
举个实际代码的例子,这里暂不给出VO,在最后的总结会讲这个VO
- 这个就是PO
@Data
public class User implements Serializable{
private Long id;
private String username;
private String password;
private String identityCard;
private String gender;
private String location;
private String userImage;
private String phoneNumber;
private String createTime;
private String updateTime;
@TableLogic
private int isDelete;
}
- UserDTO
@Data
public class UserDTO implements Serializable{
private Long id;
private String username;
private String password;
private String identityCard;
private String gender;
private String location;
private String userImage;
private String phoneNumber;
}
- UserLoginBO、UserUpdateBO ...
@Data
public class UserLoginBO implements Serializable{
private String username;
private String password;
}
@Data
public class UserUpdateBO implements Serializable{
private Long id;
private String username;
private String password;
private String identityCard;
private String gender;
private String location;
private String userImage;
private String phoneNumber;
}
从上面这个例子大家能看出来区别不
UserDTO是一个大的入口,它可以接收整个模块的参数
BO则是在进入Service层之前对UserDTO的数据进行过滤,并且对边界进行控制
最后在进入infra层之前转为PO
其实BO也可以像UserDTO那样,直接一个UserBO包含UserLoginBO和UserUpdateBO,单纯的做模型转换,不做值过滤也可以
在后端开发中怎么用的
总结
为什么我们通篇没有讲关于VO的事情呢?
我个人的理解是DTO能解决的事情没有必要再加一个VO,我们可以弄一个全局配置,将DTO里面为null值的字段全都过滤掉
这样就没有说将数据传给前端的时候需要加多一个VO
给出代码示例,这样配置就可以把DTO中为null值过滤掉,不会序列化发给前端
@Configuration
public class GlobalConfig extends WebMvcConfigurationSupport {
@Override
protected void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
super.configureMessageConverters(converters);
converters.add(mappingJackson2HttpMessageConverter());
}
/**
* 自定义mappingJackson2HttpMessageConverter
* 目前实现:空值忽略,空字段可返回
*/
private MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter() {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false);
objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
return new MappingJackson2HttpMessageConverter(objectMapper);
}
}
来源:juejin.cn/post/7334691453833166848
极狐 GitLab 双重风波,Git 私服该如何选择?
极狐 GitLab 的双重风波
(一)间谍风波
前两天,极狐 GitLab 陷入了员工实名举报公司高管为美国间谍的漩涡之中。这一事件犹如一颗重磅炸弹,在业界引起了轩然大波。尽管目前尚未被实锤,但此消息一经传出,便迅速吸引了众多目光,也让极狐 GitLab 的企业形象蒙上了一层阴影。这一事件不仅引发了内部员工的震动,也使得外界对其公司的信任度产生了动摇,其后续发展仍有待进一步观察。
(二)绝户网计划
就在今天早上,极狐 GitLab 的 “绝户网计划” 也浮出水面。据爆料,极狐 GitLab 要求销售人员在与使用 GitLab CE(社区版)免费版的用户交流时,引导用户明确表达正在使用免费版,并将此作为证据存档,以便未来可能的发函或起诉。 从其告知函来看,极狐 GitLab 强调其核心产品 GitLab 受法律保护,指出用户涉嫌未经授权使用软件,违反相关法律法规。公司内部对此计划也存在分歧,部分销售和技术同事反对,认为这会得罪潜在客户,影响长期生意,但公司高层却决定推行,寄希望于小部分害怕被起诉的用户付费来提升今年业绩。此计划引发了广泛争议,因为 GitLab 的免费版在全球范围内被大量程序员使用,且一直以来被认为是可商用的,这一举措无疑打破了以往的认知,让许多用户感到不满和担忧。
告知函内容如下:
GitLab 替代品分析
巧合的是,前不久我刚好对 GitLab 私服替代品进行了一波调研,同时成功将 GitLab 仓库迁移到新的仓库中,这次正好分享一下替代品的优缺点。
(一)Gitea
- 优点:
- 轻量级:资源占用较低,对于硬件配置要求不高,适合小型团队或个人开发者在有限资源环境下搭建 Git 私服。
- 功能较为完善:能够满足基本的代码托管、版本控制、分支管理等常见需求,支持多种主流操作系统的部署。
- 社区活跃:有大量的开发者参与社区贡献,遇到问题时能够在社区中较快地获取解决方案和技术支持。
- 缺点:
- 在大规模团队协作和复杂项目管理场景下,一些高级功能可能相对薄弱,例如在代码审查流程的精细化管理方面不如一些大型商业 Git 工具。
- 与一些大型企业级工具相比,其集成能力可能稍逊一筹,在与其他企业内部系统如 CI/CD 平台、项目管理软件等的深度整合上存在一定局限性。
- Gitea 的域名和商标在社区不知情、未经社区批准的情况下被转让给一家营利性公司,有一定开源风险。
(二)Gogs
- 优点:
- 易于安装和使用:安装过程简单,即使是技术基础相对薄弱的用户也能快速上手搭建自己的 Git 私服。
- 性能表现不错:在处理中等规模的代码仓库和团队协作时,能够保持较为稳定的运行速度和响应效率。
- 界面简洁:对于注重简洁操作界面的用户来说,Gogs 的界面设计较为友好,易于操作和管理。
- 缺点:
- 功能扩展性相对有限:虽然基本功能齐全,但在面对一些特殊需求或新兴技术场景时,可能难以通过插件或扩展机制快速实现功能增强。
- 社区规模和活跃度不如一些头部的 Git 工具,这可能导致在长期发展过程中,功能更新和问题修复的速度相对较慢。
(三)OneDev
- 优点:
- 强大的项目管理功能:除了基本的 Git 代码托管功能外,OneDev 在项目管理方面表现出色,提供了丰富的项目进度跟踪、任务分配、团队协作等功能,适合以项目为导向的团队使用。
- 支持多语言:能够很好地适应不同语言环境下的开发团队需求,方便国际化团队协作。
- 可定制性强:用户可以根据自己团队的特定需求,对 OneDev 的功能和界面进行一定程度的定制,以提高工作效率。
- 缺点:
- 学习成本相对较高:由于其功能丰富且较为复杂,新用户需要花费一定时间来熟悉和掌握其操作流程和功能配置。
- 部署相对复杂:相比一些轻量级的 Git 私服工具,OneDev 的部署过程需要更多的配置和环境依赖,对于运维人员的技术要求较高。
(四)GitBucket
- 优点:
- 与 GitHub 风格相似:对于熟悉 GitHub 操作的用户来说,GitBucket 的界面和操作方式具有较高的相似度,降低了用户的迁移成本。
- 支持多种数据库:可以灵活选择数据库类型,如 MySQL、PostgreSQL 等,方便根据现有技术架构进行整合。
- 插件丰富:提供了大量的插件来扩展其功能,例如代码质量检测、代码统计等插件,能够满足不同团队的多样化需求。
- 缺点:
- 性能优化方面可能存在不足:在处理大规模代码库和高并发请求时,可能会出现性能瓶颈,需要进行额外的性能调优工作。
- 社区文档相对不够完善:在一些复杂功能的使用和问题排查上,由于社区文档的不全面,可能会给用户带来一定困扰。
(五)Gitblit
- 优点:
- 专注于 Git 核心功能:对 Git 的核心功能支持得非常稳定和高效,如代码托管、分支管理、权限管理等,适合那些只需要基本 Git 功能且追求稳定性的团队。
- 轻量级且资源占用少:在硬件资源有限的情况下,能够稳定运行,不会对服务器资源造成过大压力。
- 安全性能较高:提供了较为完善的权限管理和安全机制,能够有效保护代码仓库的安全。
- 缺点:
- 功能相对单一:缺乏一些现代 Git 工具所具备的高级项目管理和团队协作功能,如敏捷项目管理工具集成等。
- 用户界面相对简陋:在美观度和交互体验上不如一些新兴的 Git 私服工具,可能会影响用户的使用感受。
Forgejo 的选择理由
经过个人调研和综合考量,最终选择了 Forgejo 替代 GitLab。Forgejo 是 Gitea 的一个硬分叉,它继承了 Gitea 的所有优点,如轻量级、功能完善、社区活跃等。同时,Forgejo 还具有自身独特的优势,其界面美观,给用户带来了良好的视觉体验;部署简单,降低了迁移成本和技术门槛,即使是非专业运维人员也能轻松上手;加载速度快,能够提高团队成员的工作效率,减少等待时间。
在迁移项目时,可以参考我之前写的迁移教程:
- 迁移 GitLab 仓库到 Forgejo 的详细步骤:juejin.cn/post/743970…
- 批量迁移 GitLab 仓库到 Forgejo 的教程:juejin.cn/post/744008…。
综上所述,个人认为 Forgejo 在应对极狐 GitLab 近期风波所带来的不确定性时,是一个较为理想的 Git 私服替代品。
相关资料
- GitLab DevOps 架构师原文: zhuanlan.zhihu.com/p/116657489…
- OSC开源社区: mp.weixin.qq.com/s/aASB7SHgp…
来源:juejin.cn/post/7446578471901626420
为什么Spring官方不推荐使用 @Autowired ?
大家好,我是苏三,又跟大家见面了。
前言
很多人刚接触 Spring 的时候,对 @Autowired
绝对是爱得深沉。
一个注解,轻松搞定依赖注入,连代码量都省了。
谁不爱呢?
但慢慢地,尤其是跑到稍微复杂点的项目里,@Autowired
就开始给你整点幺蛾子。
于是,官方Spring 4.0开始:不建议无脑用 @Autowired
,而是更推荐构造函数注入。
为什么?
是 @Autowired
不行吗?并不是。
它可以用,但问题是:它不是无敌的,滥用起来容易埋坑。
下面就来聊聊为啥官方建议你慎用 @Autowired
,顺便再带点代码例子,希望对你会有所帮助。
苏三最近开源了一个基于 SpringBoot+Vue+uniapp 的商城项目,欢迎访问和star。
1. 容易导致隐式依赖
很多小伙伴在工作中喜欢直接写:
@Service
public class MyService {
@Autowired
private MyRepository myRepository;
}
看着挺简单,但问题来了:类的依赖关系藏得太深了。
- 你看这段代码,
MyService
和MyRepository
的关系其实是个“隐形依赖”,全靠@Autowired
来注入。 - 如果有个同事刚接手代码,打开一看,完全不知道
myRepository
是啥玩意儿、怎么来的,只有通过 IDE 或运行时才能猜出来。
隐式依赖的结果就是,代码看起来简单,但维护起来费劲。
后期加个新依赖,或者改依赖顺序,分分钟把人搞糊涂。
怎么破?
用 构造函数注入 替代。
@Service
public class MyService {
private final MyRepository myRepository;
// 构造函数注入,依赖一目了然
public MyService(MyRepository myRepository) {
this.myRepository = myRepository;
}
}
这样做的好处是:
- 依赖清晰: 谁依赖谁,直接写在构造函数里,明明白白。
- 更易测试: 构造函数注入可以手动传入 mock 对象,方便写单元测试。
2. 会导致强耦合
再举个例子,很多人喜欢直接用 @Autowired
注入具体实现类,比如:
@Service
public class MyService {
@Autowired
private SpecificRepository specificRepository;
}
表面上没毛病,但这是硬邦邦地把 MyService
和 SpecificRepository
绑死了。
万一有一天,业务改了,需要切换成另一个实现类,比如 AnotherSpecificRepository
,你得改代码、改注解,连带着测试也崩。
怎么破?
用接口和构造函数注入,把依赖解耦。
@Service
public class MyService {
private final Repository repository;
public MyService(Repository repository) {
this.repository = repository;
}
}
然后通过 Spring 的配置文件或者 @Configuration
类配置具体实现:
@Configuration
public class RepositoryConfig {
@Bean
public Repository repository() {
return new SpecificRepository();
}
}
这么搞的好处是:
- 灵活切换: 改实现类时,不用动核心逻辑代码。
- 符合面向接口编程的思想: 降低耦合,提升可扩展性。
3. 容易导致 NullPointerException
有些小伙伴喜欢这么写:
@Service
public class MyService {
@Autowired
private MyRepository myRepository;
public void doSomething() {
myRepository.save(); // 啪!NullPointerException
}
}
问题在哪?如果 Spring 容器还没来得及注入依赖,你的代码就跑了(比如在构造函数或初始化方法中直接调用依赖),结果自然就是 NullPointerException
。
怎么破?
用构造函数注入,彻底干掉 null
的可能性。
@Service
public class MyService {
private final MyRepository myRepository;
public MyService(MyRepository myRepository) {
this.myRepository = myRepository; // 确保依赖在对象初始化时就已注入
}
public void doSomething() {
myRepository.save();
}
}
构造函数注入的另一个优点是:依赖注入是强制的,Spring 容器不给你注入就报错,让问题早暴露。
4.自动装配容易搞出迷惑行为
Spring 的自动装配机制有时候是“黑魔法”,尤其是当你的项目里有多个候选 Bean 时。比如:
@Service
public class MyService {
@Autowired
private Repository repository; // 容器里有两个 Repository 实现类,咋办?
}
如果有两个实现类,比如 SpecificRepository
和 AnotherRepository
,Spring 容器直接报错。解决方法有两种:
- 指定
@Primary
。 - 用
@Qualifier
手动指定。
但这些方式都让代码看起来更复杂了,还可能踩坑。
怎么破?
构造函数注入 + 显式配置。
@Configuration
public class RepositoryConfig {
@Bean
public Repository repository() {
return new SpecificRepository();
}
}
你明确告诉 Spring 该用哪个实现类,别让容器帮你猜,省得以后“配错药”。
最近就业形势比较困难,为了感谢各位小伙伴对苏三一直以来的支持,我特地创建了一些工作内推群, 看看能不能帮助到大家。
你可以在群里发布招聘信息,也可以内推工作,也可以在群里投递简历找工作,也可以在群里交流面试或者工作的话题。
添加苏三的私人微信:su_san_java,备注:掘金+所在城市,即可加入。
5. 写单元测试非常痛苦
最后,聊聊测试的事儿。
@Autowired
依赖 Spring 容器才能工作,但写单元测试时,大家都不想起 Spring 容器(麻烦、慢)。结果就是:
- 字段注入: 没法手动传入 mock 对象。
- 自动装配: 有时候不清楚用的 Bean 是哪个,测试难搞。
怎么破?
构造函数注入天生就是为单元测试设计的。
public class MyServiceTest {
@Test
public void testDoSomething() {
MyRepository mockRepository = mock(MyRepository.class);
MyService myService = new MyService(mockRepository);
// 测试逻辑
}
}
看见没?
直接传入 mock 对象,测试简单、优雅。
总结
简单总结下问题:
- 隐式依赖让代码可读性差。
- 强耦合违背面向接口编程。
- 字段注入容易 NPE。
- 自动装配有坑。
- 单元测试不好写。
那到底咋办?用 构造函数注入,清晰、稳健、测试友好,官方推荐不是没道理的。
但话说回来,@Autowired
也不是不能用,只是你得分场景。
开发中,养成用构造函数注入的习惯,能让你的代码更健壮,少挖坑,多干活!
最后说一句(求关注,别白嫖我)
如果这篇文章对您有所帮助,或者有所启发的话,帮忙关注一下我的同名公众号:苏三说技术,您的支持是我坚持写作最大的动力。
求一键三连:点赞、转发、在看。
关注公众号:【苏三说技术】,在公众号中回复:进大厂,可以免费获取我最近整理的10万字的面试宝典,好多小伙伴靠这个宝典拿到了多家大厂的offer。
来源:juejin.cn/post/7442346963302203407
SpringBoot中使用LocalDateTime踩坑记录
@[toc]
前言
近日心血来潮想做一个开源项目,目标是做一款可以适配多端、功能完备的模板工程,包含后台管理系统和前台系统,开发者基于此项目进行裁剪和扩展来完成自己的功能开发。
本项目基于Java21和SpringBoot3开发,序列化工具使用的是默认的Jackson,使用Spring Data Redis操作Redis缓存。
在定义实体类过程中,日期时间类型的属性我使用了java.time
包下的LocalDate
和LocalDateTime
类,而没有使用java.util
包下的Date
类。
但在使用过程中遇到了一些问题,于是在此记录下来与诸位分享。
一、为什么推荐使用java.time包的LocalDateTime而不是java.util的Date?
LocalDateTime和Date是Java中表示日期和时间的两种不同的类,它们有一些区别和特点。
- 类型:LocalDateTime是Java 8引入的新类型,属于Java 8日期时间API(java.time包)。而Date是旧版Java日期时间API(java.util包)中的类。
- 不可变性:LocalDateTime是不可变的类型,一旦创建后,其值是不可变的,对该类对象的加减等计算操作不会修改原对象,而是会返回一个新的LocalDateTime对象。而Date是可变的类型,可以通过方法修改其值。
- 线程安全性:LocalDateTime是线程安全的,多个线程可以同时访问和操作不同的LocalDateTime实例。而Date是非线程安全的,如果多个线程同时访问和修改同一个Date实例,可能会导致不可预期的结果。
- 时间精度:LocalDateTime提供了纳秒级别的时间精度,可以表示更加精确的时间。而Date只能表示毫秒级别的时间精度。
- 时区处理:LocalDateTime默认不包含时区信息,表示的是本地日期和时间。而Date则包含时区信息,它的实际值会受到系统默认时区的影响。
由于LocalDateTime是Java 8及以上版本的新类型,并提供了更多的功能和灵活性,推荐在新的项目中使用LocalDateTime来处理日期和时间。
对于旧版Java项目,仍然需要使用Date类,但在多线程环境下需要注意其线程安全性。
如果需要在LocalDateTime和Date之间进行转换,可以使用相应的方法进行转换,例如通过LocalDateTime的atZone()方法和Date的toInstant()方法进行转换。
二、使用LocalDateTime和LocalDate时遇到了哪些坑?
2.1 Redis序列化报错
2.1.1 问题现象
在使用RedisTemplate向Redis中插入数据时,遇到了如下报错:
2024-01-11T21:33:25.233+08:00 ERROR 13212 --- [nio-8080-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception
org.springframework.data.redis.serializer.SerializationException: Could not write JSON: Java 8 date/time type `java.time.LocalDateTime` not supported by default: add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling (through reference chain: java.util.ArrayList[0]->com.fast.alden.data.model.SysApiResource["createdTime"])
at org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer.serialize(Jackson2JsonRedisSerializer.java:157) ~[spring-data-redis-3.2.0.jar:3.2.0]
at org.springframework.data.redis.core.AbstractOperations.rawValue(AbstractOperations.java:128) ~[spring-data-redis-3.2.0.jar:3.2.0]
at org.springframework.data.redis.core.DefaultValueOperations.set(DefaultValueOperations.java:236) ~[spring-data-redis-3.2.0.jar:3.2.0]
2.1.2 问题分析
在使用Redis缓存含有LocalDateTime类型变量的实体类时会产生序列化问题,因为Jackson库在默认情况下不支持Java8的LocalDateTime类型的序列化和反序列化。
错误堆栈中也给出了解决方案,添加 com.fasterxml.jackson.datatype:jackson-datatype-jsr310
依赖,但光添加依赖是不够的,还我们需要自定义序列化和反序列化的行为。
2.1.3 解决方案
- 添加maven依赖
<dependency>
<groupId>com.fasterxml.jackson.datatypegroupId>
<artifactId>jackson-datatype-jsr310artifactId>
<version>2.13.0version>
dependency>
- 修改RedisSerializer Bean配置
在定义RedisSerializer Bean的代码中自定义ObjectMapper对象处理时间属性时的序列化和反序列化行为,LocalDate
、LocalDateTime
、LocalTime
的序列化和反序列化都要自定义,还要禁用将日期序列化为时间戳。
@Configuration
public class RedisConfig {
@Bean
public RedisSerializer
程序员设计不出精美的 UI 界面?让 V0 来帮你
大家好,我是双越,也是 wangEditor 作者。
今年我致力于开发一个 Node 全栈 AIGC 知识库 划水AI,包括 AI 写作、多人协同编辑。复杂业务,真实上线,大家可以去注册试用。
本文分享一下前端实用的 AI 工具 v0.dev 以及我在 划水AI 中的实际应用经验,非常推荐这款工具。
不同 AI 工具写代码
ChatGPT 不好直接写代码
去年 ChatGPT 发布,但它一直是一个聊天工具,直接让它来写代码,用一问一答的形式,体验其实并不是非常友好。
可以让它来生成一些单一的代码或工具,例如 生成一个 nodejs 发送 Email 的函数
。然后我们把生成的代码复制粘贴过来,自己调整一下。
它可以作为一个导师或助理,指导你如何写代码,但它没法直接帮你写,尤其是在一个项目环境中。
PS. 这里只是说 ChatGPT 这种问答方式不适合直接写代码,但 ChatGPT 背后的 LLM 却未后面各种 AI 写代码工具提供了支持。
Cursor 非专业程序员
Cursor 其实去年我就试用过,它算是 AI 工具 + VSCode ,付费试用。没办法,AI 接口服务现在都是收费的。
前段时间 Cursor 突然在社区中很火爆,国内外都有看过它的宣传资料,我记得看过一个国外的 8 岁小女孩,用 Cursor 写 AI 聊天工具的视频,非常有意思,我全程看完了。
Cursor 可能会更加针对于非专业编程人员,去做一些简单的 demo ,主要体验编程的逻辑和过程,不用关心其中的 bug 。
例如,对于公司的 PM UI 人员,或者创业公司的老板。它真的可以产生价值,所以它也可以收费。
Copilot 针对专业程序员
我们是专业程序员,我更加推荐 Copilot ,直接在 vscode 安装插件即可。
我一直在使用 Copilot ,而且我现在都感觉自己有点依赖它了,每次写代码的时候都会停顿下来等待它帮我生成。
在一些比较明确的问题上,它的生成是非常精准的,可以大大节省人力,提高效率。
如果你遇到 Copilot 收费的问题,可以试试 Amazon CodeWhisper ,同样的功能,目前是免费的,未来不知道是否收费。
UI 很重要!!!
对于一个前端人员,有 UI 设计稿让他去还原开发这并不难,但你让他从 0 设计一个精美的 UI 页面,这有点困难。别说精美,能做到 UI 的基本美观就已经很不容易了。
举个例子,这是我偶遇一个笔记软件,这个 UI 真的是一言难尽:左上角无端的空白,左侧不对齐,icon 间距过大,字号不统一,tab 间距过小 …… 这种比较随性的 UI 设计,让人看了就没有任何试用的欲望。
可以在对比看一下 划水AI 的 UI 界面,看颜色、字号、艰巨、icon 等这些基础的 UI ,会否更加舒适一些?专业一些?
PS. 无意攻击谁(所以打了马赛克),只是做一个对比,强调 UI 的重要性。
V0 专业生成 UI 代码
V0 也是专业写代码的,不过它更加专注于一个方向 —— 生成 UI 代码 ,能做到基本的美观、舒适、甚至专业。
给一个指令 a home page like notion.com
生成了右侧的 UI 界面,我觉得已经非常不错了。要让我自己设计,我可设计不出来。
这一点对于很多人来说都是极具价值的,例如中小公司、创业公司的前端人员,他们负责开发 UI 但是没有专业的 UI 设计师,或者说他们开发的是一些 toB 的产品,也不需要招聘一个专职的 UI 设计师。
你可以直接拷贝 React 代码,也可以使用 npx
命令一键将代码转移到你自己的项目中。
它甚至还会考虑到响应式布局和黑白主题,这一点很惊艳
再让 V0 生成一个登录页,看看能做到啥效果。在首页输入指令 A login form like Github login page
等待 1-2 分钟,生成了如下效果,我个人还是挺满意的。如果让我自己写,我还得去翻阅一些 UI 组件库文档,看 form 表单怎么写,怎么对齐,宽度多少合适 …… 光写 UI 也得搞半天。
划水AI 中“我的首页” 就是 V0 生成的,虽然这个页面很简洁,但是我个人对 UI 要求很高,没有工具帮助,我无法短时间做到满意。
最后
任何行业和领域,看它是否成熟、是否能发展壮大,一个很重要的特点就是:是否有庞大的细分领域。例如现代医学、现代制造业、计算机领域…… 专业细分及其周密,大家各司其职,整个领域才能欣欣向荣。
AI 领域也是一样,AI 编程将是一个细分领域,再往下还有更多细分领域,像针对 UI 的、针对数据库的、针对云服务的,未来会有更多这方面的发展。
来源:juejin.cn/post/7438647233219903542
stream().toList()的大坑,你真的了解吗
stream().toList()
下面这两行代码相同吗?
List<Integer> list1 = list.stream().toList();
List<Integer> list2 = list.stream().collect(Collectors.toList());
在Idea里,Idea还会提醒你可以替换,难道真的是相同的api吗?
我们直接打印一下它们的Class
List<Integer> list1 = list.stream().toList();
List<Integer> list2 = list.stream().collect(Collectors.toList());
System.out.println(list1.getClass());
System.out.println(list2.getClass());
class java.util.ImmutableCollections$ListN
class java.util.ArrayList
发现一个是ImmutableCollection,一个是ArrayList
从名字中就可以看出来list1是不可变的,remove一下果然抛出了异常
// all mutating methods throw UnsupportedOperationException
@Override public void add(int index, E element) { throw uoe(); }
@Override public boolean addAll(int index, Collection<? extends E> c) { throw uoe(); }
@Override public E remove(int index) { throw uoe(); }
@Override public void replaceAll(UnaryOperator<E> operator) { throw uoe(); }
@Override public E set(int index, E element) { throw uoe(); }
@Override public void sort(Comparator<? super E> c) { throw uoe(); }
来源:juejin.cn/post/7436938110023958565
雷军又添一员猛将!!
就在刚过去的十一月,小米又搞了一项大动作,那就是:
小米成立了「AI平台部」,同时也进行了一系列相关的人事变动。
这个消息最先由雷峰网披露,后来在业内也引发了广泛关注和热议。
从部门层级关系上来说,那这次的新「AI平台部」是由小米「基础技术平台部」成立,而「基础技术平台部」在组织上则隶属于「小米技术委员会」。
而担任此次小米AI平台部负责人的则是业内大名鼎鼎的技术大牛:张铎。
如此一来,如网友们所言,雷总又添一员猛将!
提到张铎,应该不少从事大数据或者云计算相关的同学会比较熟悉,同时他也从事过多年的开源工作。
其实这次并不是张铎第一次入职小米,他与小米之间的渊源甚至可以说颇深。
早在2016年~2021年,其实张铎就曾经在小米待过5年,在职期间负责小米开源工作的规划与推进,并参与过小米多个核心项目的建设。
那时候的张铎就已经是 Apache HBase 项目的 Committer,而且也是国内第一位 HBase 的 PMC Member,后来一直升到 HBase 项目 PMC 主席。
在 HBase 项目中,他的贡献数量在全球排名前列,为 HBase 社区的发展做出过巨大贡献。
2018年,张铎在 Apache 软件基金会旗下近7000个 Committer 中,总贡献数排到了全球第3。
因此,雷军还曾亲口称赞其为:大神。
2021年,张铎从工作了5年的小米公司离职,为此他还在知乎上专门发过一篇帖子来记录关于从小米离职的一些复盘。
从小米离职之后,张铎选择了加入神策数据,这是一家大数据分析和营销科技服务提供商。
在神策数据工作期间,张铎担任基础研发部负责人和首席架构师,主要负责整个基础研发部的管理和技术选型。
直到今年9月,张铎在朋友圈又晒出了自己“新员工”的身份。
至此,张铎再次宣布回归小米。
大家都知道,小米这个公司以产品见长。
提到小米产品,大家基本上都耳熟能详。从智能手机到影音数码,从家电生活到智能家居,另外各种软件、平台、云服务、OS等都在持续发力,包括现在又在全速力推新能源智能汽车,软硬件产品线可以说覆盖得非常广泛。
但是说到小米技术,大家反而可能没有什么特别深的印象。
不过提到小米技术,就不得不提:小米集团技术委员会,其诞生于2019年初。
当时的2018年Q4季度,国内智能手机市场前五曾分别为:华为、OPPO、VIVO、苹果、小米。
其中前三家国产手机厂商出货量增幅均位正增长,而排名第5的小米则出现了较大跌幅。
彼时的雷军开始在内部会议中强调的一件事情就是:技术事关小米生死存亡。
不久后的2019年2月,小米就专门成立了集团技术委员会,主要负责把握集团的技术方向,预研前沿技术,以及推动技术创新和成果转化。
小米技术委员会在小米的科技创新和产品研发中扮演着重要的角色,而首任挂帅的正是小米的技术大牛:崔宝秋。
而聊起小米的技术研发,他是绕不开的人。
崔宝秋和雷军都是武汉大学计算机系的同学,据说还是同寝室室友。
在加入小米之前,崔宝秋曾在IBM、雅虎和LinkedIn等知名公司负责研发,积累了丰富的技术和管理经验。
2012年,他应雷军之邀回国加入小米,成为小米首席架构师。
提到崔宝秋,有网友称其为“小米的技术教父”。
确实,崔宝秋在小米期间担任过多个重要职务,包括首席架构师、人工智能与云平台副总裁、集团技术委员会主席和集团学习发展部总经理等。
除此之外,他在小米期间还主导了“云计算-大数据-人工智能”的技术变革主线,为小米的技术发展做出了重要贡献。
然而,根据小米发布的内部全员信,崔宝秋2022年底从小米离职,至此也结束了这段长达十年的在小米的职业生涯。
而这十年,也是小米这家公司从蓄力到发展,从厚积到薄发的十年。
包括这次小米成立AI平台部的消息一出,大家都在猜测,看来小米在人工智能方向要搞大动作了?
不管怎么样,作为一个家里大小电器基本都来自于小米的用户,还是期待小米后续有更多的发展,同时带来更多感动人心的产品和体验。
注:本文在GitHub开源仓库「编程之路」 github.com/rd2coding/R… 中已经收录,里面有我整理的6大编程方向(岗位)的自学路线+知识点大梳理、面试考点、我的简历、几本硬核pdf笔记,以及程序员生活和感悟,欢迎star。
来源:juejin.cn/post/7444504964511350784
面试官问我String能存储多少个字符?
- 首先String的length方法返回是int。所以理论上长度一定不会超过int的最大值。
- 编译器源码如下,限制了字符串长度大于等于65535就会编译不通过
private void checkStringConstant(DiagnosticPosition var1, Object var2) {
if (this.nerrs == 0 && var2 != null && var2 instanceof String && ((String)var2).length() >= 65535) {
this.log.error(var1, "limit.string", new Object[0]);
++this.nerrs;
}
}
Java中的字符常量都是使用UTF8编码的,UTF8编码使用1~4个字节来表示具体的Unicode字符。所以有的字符占用一个字节,而我们平时所用的大部分中文都需要3个字节来存储。
//65534个字母,编译通过
String s1 = "dd..d";
//21845个中文”自“,编译通过
String s2 = "自自...自";
//一个英文字母d加上21845个中文”自“,编译失败
String s3 = "d自自...自";
对于s1,一个字母d的UTF8编码占用一个字节,65534字母占用65534个字节,长度是65534,长度和存储都没超过限制,所以可以编译通过。
对于s2,一个中文占用3个字节,21845个正好占用65535个字节,而且字符串长度是21845,长度和存储也都没超过限制,所以可以编译通过。
对于s3,一个英文字母d加上21845个中文”自“占用65536个字节,超过了存储最大限制,编译失败。
- JVM规范对常量池有所限制。量池中的每一种数据项都有自己的类型。Java中的UTF-8编码的Unicode字符串在常量池中以CONSTANTUtf8类型表示。CONSTANTUtf8的数据结构如下:
CONSTANT_Utf8_info {
u1 tag;
u2 length;
u1 bytes[length];
}
我们重点关注下长度为 length 的那个bytes数组,这个数组就是真正存储常量数据的地方,而 length 就是数组可以存储的最大字节数。length 的类型是u2,u2是无符号的16位整数,因此理论上允许的的最大长度是2^16-1=65535。所以上面byte数组的最大长度可以是65535
- 运行时限制
String 运行时的限制主要体现在 String 的构造函数上。下面是 String 的一个构造函数:
public String(char value[], int offset, int count) {
...
}
上面的count值就是字符串的最大长度。在Java中,int的最大长度是2^31-1。所以在运行时,String 的最大长度是2^31-1。
但是这个也是理论上的长度,实际的长度还要看你JVM的内存。我们来看下,最大的字符串会占用多大的内存。
(2^31-1)*16/8/1024/1024/1024 = 2GB
所以在最坏的情况下,一个最大的字符串要占用 2GB的内存。如果你的虚拟机不能分配这么多内存的话,会直接报错的。
补充 JDK9以后对String的存储进行了优化。底层不再使用char数组存储字符串,而是使用byte数组。对于LATIN1字符的字符串可以节省一倍的内存空间。
来源:juejin.cn/post/7343883765540831283
🌿一个vue3指令让el-table自动轮播
前言
本文开发的工具,是vue3 element-plus ui库专用的,需要对vue3指令概念有一定的了解
最近开发的项目中,需要对项目中大量的列表实现轮播效果,经过一番折腾.最终决定不使用第三方插件,手搓一个滚动指令.
效果展示
实现思路
第一步先确定功能
- 列表自动滚动
- 鼠标移入停止滚动
- 鼠标移出继续滚动
- 滚轮滚动完成,还可以继续在当前位置滚动
- 元素少于一定条数时,不滚动
滚动思路
通过观察el-table
的结构可以发现el-scrollbar__view
里面放着所有的元素,而el-scrollbar__wrap
是一个固定高度的容器,那么只需要获取到el-scrollbar__wrap
这个DOM,并且再给一个定时器,不断的改变它的scrollTop
值,就可以实现自动滚动的效果,这个值必须要用一个变量来存储,不然会失效
停止和继续滚动思路
设置一个boolean
类型变量,每次执行定时器的时候判断一下,true
就滚动,否则就不滚动
滚轮事件思路
为了每次鼠标在列表中滚动之后,我们的轮播还可以在当前滚动的位置,继续轮播,只需要在鼠标移出的时候,将当前el-scrollbar__wrap
的scrollTop
赋给前面存储的变量,这样执行定时器的时候,就可以继续在当前位置滚动
不滚动的思路
只需要判断el-scrollbar__view
这个容器的高度,是否大于el-scrollbar__wrap
的高度,是就可以滚动,不是就不滚动。
大致的思路是这样的,下面上源码
实现代码
文件名:tableAutoScroll.ts
interface ElType extends HTMLElement {
timer: number | null
isScroll: boolean
curTableTopValue: number
}
export default {
created(el: ElType) {
el.timer = null
el.isScroll = true
el.curTableTopValue = 0
},
mounted(el: ElType, binding: { value?: { delay?: number } }) {
const { delay = 15 } = binding.value || {}
const tableDom = el.getElementsByClassName(
'el-scrollbar__wrap'
)[0] as HTMLElement
const viewDom = el.getElementsByClassName(
'el-scrollbar__view'
)[0] as HTMLElement
const onMouseOver = () => (el.isScroll = false)
const onMouseOut = () => {
el.curTableTopValue = tableDom.scrollTop
el.isScroll = true
}
tableDom.addEventListener('mouseover', onMouseOver)
tableDom.addEventListener('mouseout', onMouseOut)
el.timer = window.setInterval(() => {
const viewDomClientHeight = viewDom.scrollHeight
const tableDomClientHeight = el.clientHeight
if (el.isScroll && viewDomClientHeight > tableDomClientHeight) {
const curScrollPosition = tableDom.clientHeight + el.curTableTopValue
el.curTableTopValue =
curScrollPosition === tableDom.scrollHeight
? 0
: el.curTableTopValue + 1
tableDom.scrollTop = el.curTableTopValue
}
}, delay)
},
unmounted(el: ElType) {
if (el.timer !== null) {
clearInterval(el.timer)
}
el.timer = null
const tableDom = el.getElementsByClassName(
'el-scrollbar__wrap'
)[0] as HTMLElement
tableDom.removeEventListener('mouseover', () => (el.isScroll = false))
tableDom.removeEventListener('mouseout', () => {
el.curTableTopValue = tableDom.scrollTop
el.isScroll = true
})
},
}
上面代码中,我在 created中初始化了三个变量,分别用于存储,定时器对象 、是否滚动判断、滚动当前位置。
在 mounted中我还获取了一个options,主要是为了可以定制滚动速度
用法
- 将这段代码放在你的文件夹中
- 在
main.ts
中注册这个指令
import tableAutoScroll from './modules/tableAutoScroll.ts'
const directives: any = {
tableAutoScroll,
}
/**
* @function 批量注册指令
* @param app vue 实例对象
*/
export const install = (app: any) => {
Object.keys(directives).forEach((key) => {
app.directive(key, directives[key]) // 将每个directive注册到app中
})
}
我这边是将自己的弄了一个批量注册,正常使用就像官网里面注册指令就可以了
在需要滚动的el-table
上使用这个指令就可以
<!-- element 列表滚动指令插件 -->
<template>
<div class="container">
<el-table v-tableAutoScroll :data="tableData" height="300">
<el-table-column prop="date" label="时间" />
<el-table-column prop="name" label="名称" />
<el-table-column prop="address" label="Address" />
</el-table>
<!-- delay:多少毫秒滚动一次 -->
<el-table
v-tableAutoScroll="{
delay: 50,
}"
:data="tableData"
height="300"
>
<el-table-column prop="date" label="时间" />
<el-table-column prop="name" label="名称" />
<el-table-column prop="address" label="Address" />
</el-table>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const tableData = ref<any>([])
onMounted(() => {
tableData.value = Array.from(Array(100), (item, index) => ({
date: '时间' + index,
name: '名称' + index,
address: '地点' + index,
}))
console.log('👉 ~ tableData.value=Array.from ~ tableData:', tableData)
})
</script>
<style lang="scss" scoped>
.container {
height: 100%;
display: flex;
align-items: flex-start;
justify-content: center;
gap: 100px;
.el-table {
width: 500px;
}
}
</style>
上面这个例子,分别演示两种调用方法,带参数和不带参数
最后
做了这个工具之后,突然有很多思路,打算后面再做几个,做成一个开源项目,一个开源的vue3指令集
来源:juejin.cn/post/7452667228006678540
这年头不会还有谁没碰过minio的吧?这可太...🤡
🏆本文收录于「滚雪球学Spring Boot」专栏,专业攻坚指数级提升持续更新中,up!up!up!!
🥝 前言:文件存储那些“坑”,你踩过几个?
想象一下,你正在开发一个新项目,老板突然拍着桌子跟你说:“咱这个项目得支持海量文件存储,用户随时上传随时下载,成本要低,性能要高,安全也不能落下!”你抓了抓头发,盯着屏幕陷入沉思,传统文件系统?太笨重。云存储?预算超标。就在你一筹莫展时,MinIO横空出世,仿佛一道曙光,照亮了你前行的路。
MinIO,这款开源的对象存储系统,以其高性能、易扩展、S3兼容性等优点,迅速成为开发者圈中的“香饽饽”。如果你用Spring Boot开发项目,想要高效管理文件存储,那么接下来的内容会让你大呼过瘾。

🍇 MinIO是什么?
MinIO,是一款以高性能、轻量级著称的对象存储服务。它完全兼容Amazon S3 API,支持大规模非结构化数据的存储,适合图片、视频、日志、备份等海量数据的管理需求。
简单点说,它就是你的“私人云存储”,但没有昂贵的费用和复杂的运维。不论是几百GB还是上百TB的数据,MinIO都能轻松搞定。
🍒 MinIO的“秘密武器”
- 开源免费:没有隐藏费用,企业也能无压力使用。
- S3 API兼容:现有的S3工具可以无缝衔接。
- 性能炸裂:每秒高达数十GB的吞吐量,轻松应对高并发。
- 易部署,易维护:几行命令搞定,开发小白也能轻松上手。
🍅 为什么选择MinIO?
有人可能会问:“为啥不用传统的文件系统?” 传统文件系统确实在小规模存储中还算凑合,但当你面对动辄几百GB甚至TB级的数据时,传统方案的缺点就暴露无遗了。管理难、性能低、扩展性差……而MinIO正是为了解决这些痛点而生。

🥝 MinIO能给你什么?
- 超高性价比:无需支付昂贵的存储服务费用,MinIO让你拥有“云存储”的体验,却不需要“云存储”的钱包。
- 弹性扩展:无论是初创团队还是大型企业,MinIO都能根据业务规模灵活扩展,绝不让存储成为发展瓶颈。
- 高可用性:MinIO支持分布式部署,即使某个节点故障,数据依然安全无忧。
选择MinIO,就是选择一种面向未来的存储方式。
🥑 MinIO核心概念
● 对象(Object):对象是实际的数据单元,例如:上传的图片。
● 存储桶(Bucket):存储桶是用于组织对象的名称空间,类似于文件夹。每个存储桶可以包含多个对象(文件)。
● 端点(Endpoint):MinIO服务器的网络地址,用于访问存储桶和对象。例如:http://192.168.10.100:9000 , 注意:9000为 MinIO的API默认端口。
● AccessKey 和Secret Key:
- AccessKey:用于标识和验证访问者身份的唯一标识符,相当于用户名。
- Secret Key:与AccessKey关联的密码,用于验证访问者的身份。

🌽 MinIO客户端实操
🥬 创建bucket
这里的bucket存储桶是用于组织对象的名称空间,类似于我们所说的文件夹。
🥜 测试文件上传
然后来测试一下,文件上传。
上传文件,点击"upload",选择上传的文件即可。
🥖 设置匿名用户的访问权限
将匿名用户权限设置为只读。
🧆 创建 Access Key
这里的Access Key用于标识和验证访问者身份的唯一标识符,相当于用户名。
如上操作完后,我们便来进行此期的真正的干货了,直接上手实操。

🌯 Spring Boot集成MinIO的实操指南
🫔 环境准备
首先,确保你的开发环境已经配置好以下工具:
- JDK 1.8
- Spring Boot 2.6+
- MinIO服务(可使用Docker快速部署)
docker run -p 9000:9000 -p 9001:9001 --name minio \
-e "MINIO_ROOT_USER=admin" \
-e "MINIO_ROOT_PASSWORD=password123" \
minio/minio server /data --console-address ":9001"
这段命令会在本地启动MinIO服务,你只需要打开浏览器,输入http://localhost:9001
,用设置的账号密码登录,即可看到管理界面。
或者你也可以参考Linux常规搭建,可看这篇《Linux零基础安装Minio,手把手教学,一文搞定它!(超详细)》,妥妥傻瓜式教学。
🫑 引入依赖
接下来,修改pom.xml
,引入MinIO的Java SDK依赖:
<!--minio oss服务-->
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.5.12</version>
</dependency>
🍌 定义MinIO连接信息
我们需要先将minio的连接信息配置到我们的配置类中,方便修改及动态配置。
故我们需要先去minio的客户端先创建于一个access key,然后将access-key 与 secret-key 填写到 yml 配置文件中。
具体配置如下,你们直接改成你们的即可。
# minio文件存储
minio:
access-key: Ro2ypdSShhmqQYgHWyDP
secret-key: 6XOaQsYXBKflV10KDcjgcwE9lvekcN4KYfE85fBL
url: http://10.66.66.143:9000
bucket-name: hpy-files
属性解读:
如上这段代码配置的是MinIO文件存储的连接信息,具体内容如下:
- access-key:
Ro2ypdSShhmqQYgHWyDP
— 这是MinIO的访问密钥(类似于用户名),用于身份验证。 - secret-key:
6XOaQsYXBKflV10KDcjgcwE9lvekcN4KYfE85fBL
— 这是MinIO的密钥(类似于密码),用于进行身份验证。 - url:
http://10.66.66.143:9000
— 这是MinIO服务器的地址,表示文件存储服务的主机IP地址和端口。 - bucket-name:
hpy-files
— 这是用于存储文件的桶(bucket)名称。在MinIO中,文件是按桶来存储和组织的。

🍐 配置MinIO客户端
我们需要为Spring Boot项目配置一个MinIO客户端。新建MinioConfig.java
:
/**
* @author: bug菌
* @date: 2024-10-21 11:59
*/
@Data
@Configuration
@ConfigurationProperties(prefix = "minio")
public class MinioConfig {
private String accessKey;
private String secretKey;
private String url;
private String bucketName;
@Bean
public MinioClient minioClient() {
return MinioClient.builder()
.region("cn-north-1")
.endpoint(url)
.credentials(accessKey, secretKey)
.build();
}
}
配置完成后,MinIO客户端就已经准备好为我们的Spring Boot项目服务了。

🍌 创建文件工具类
接下来,我们需要创建一个MinioUtil类,该类的目的是为了封装和简化与 MinIO 文件存储服务的交互,提供一系列的操作方法,使得我们能够轻松地进行文件上传、下载、删除、获取文件信息等常见的文件存储操作。具体意义如下:
- 与 MinIO 交互的封装:
类中封装了与 MinIO 存储服务进行交互的代码,包括检查存储桶是否存在、文件上传、下载、删除等常见的操作。这样,业务逻辑代码无需直接操作 MinIO API,提升了代码的复用性和可维护性。 - 自动化存储桶管理:
在@PostConstruct
注解的init()
方法中,会自动检查并创建存储桶(bucket)。确保在程序启动时,指定的存储桶已经存在,避免了在使用过程中因存储桶不存在而导致的错误。 - 支持文件的 URL 生成:
提供了生成文件访问 URL 的功能,包括带过期时间的预签名 URL。这是为了允许用户在一定时间内访问文件,避免文件暴露或在外部用户访问时需要额外认证。 - 文件下载支持:
类中提供了文件下载的功能,包括标准下载(通过 HTTP ServletResponse)和流式下载(获取文件流)。它可以处理文件的大小、编码等问题,保证文件的正确下载。 - 文件操作的错误处理与日志:
通过Logger
对操作进行记录,且所有可能抛出异常的操作都进行了捕获和处理,避免了程序因为 MinIO 服务故障等原因而直接崩溃。确保系统的稳定性和错误反馈。 - 文件夹与文件的存在性检查:
该类提供了检查文件或文件夹是否存在的方法,有助于在上传或删除文件前进行状态验证,避免重复操作。 - 简化 API 调用:
通过抽象出一层高层次的操作接口,开发者不需要直接关注 MinIO 底层的复杂实现,只需调用简洁的方法即可完成文件存储操作。
总结而言,MinioUtil
类通过封装 MinIO 的常见文件操作,提供便捷的接口,降低与 MinIO 交互的复杂性,并通过统一的错误处理和日志记录,增强了系统的健壮性和可维护性。

代码实操:
/**
* 文件工具类
*
* @author: bug菌
* @date: 2024-10-21 12:02
* @desc:
*/
@Service
public class MinioUtil {
private static final Logger log = LoggerFactory.getLogger(MinioUtil.class);
@Autowired
private MinioClient minioClient;
@Autowired
private MinioConfig minioConfig;
@PostConstruct
public void init() {
existBucket(minioConfig.getBucketName());
}
/**
* 判断bucket是否存在,不存在则创建
*/
public boolean existBucket(String bucketName) {
boolean exists;
try {
exists = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
if (!exists) {
minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
exists = true;
}
} catch (Exception e) {
e.printStackTrace();
exists = false;
}
return exists;
}
/**
* 上传文件
*/
public void upload(MultipartFile file, String fileName) {
// 使用putObject上传一个文件到存储桶中。
InputStream inputStream = null;
try {
inputStream = file.getInputStream();
minioClient.putObject(PutObjectArgs.builder()
.bucket(minioConfig.getBucketName())
.object(fileName)
.stream(inputStream, file.getSize(), -1)
.contentType(file.getContentType())
.build());
inputStream.close();
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 获取文件访问地址
*/
public String getFileUrl(String fileName) {
try {
return minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder()
.method(Method.GET)
.bucket(minioConfig.getBucketName())
.object(fileName)
.build()
);
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
/**
* 下载一个文件(返回文件流)
*/
public InputStream download(String objectName) throws Exception {
InputStream stream = minioClient.getObject(
GetObjectArgs.builder().bucket(minioConfig.getBucketName()).object(objectName).build());
return stream;
}
/**
* 下载文件
*/
public void download(HttpServletResponse response, String newFileName, String saveFileName) {
InputStream in = null;
try {
// 获取对象信息
StatObjectResponse stat = minioClient.statObject(StatObjectArgs.builder()
.bucket(minioConfig.getBucketName())
.object(saveFileName)
.build());
// 设置请求头Content-Type
response.setContentType(stat.contentType());
// 确保使用 UTF-8 编码
// String encodedFileName = encodeFilename(newFileName);
String encodedFileName = URLEncoder.encode(newFileName, "UTF-8").replaceAll("\\+", "%20");
response.setHeader("Content-Disposition", "attachment; filename=\"" + encodedFileName + "\"");
// 设置禁用缓存
response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
response.setHeader("Pragma", "no-cache");
response.setHeader("Expires", "0");
// 设置文件大小
long fileSize = stat.size();
response.setContentLengthLong(fileSize);
// 获取文件输入流
in = minioClient.getObject(GetObjectArgs.builder()
.bucket(minioConfig.getBucketName())
.object(saveFileName)
.build());
// 文件下载
IOUtils.copy(in, response.getOutputStream());
} catch (Exception e) {
e.printStackTrace();
try {
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "File download failed: " + e.getMessage());
} catch (IOException ioException) {
ioException.printStackTrace();
}
} finally {
if (in != null) {
try {
in.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
/**
* 删除文件
*/
public void delete(String fileName) {
try {
minioClient.removeObject(RemoveObjectArgs.builder().bucket(minioConfig.getBucketName()).object(fileName).build());
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 判断文件是否存在
*
* @param objectName
*/
public boolean isFileExist(String objectName) {
boolean exist = true;
try {
minioClient.statObject(StatObjectArgs.builder().bucket(minioConfig.getBucketName()).object(objectName).build());
} catch (Exception e) {
log.error("[Minio工具类]>>>> 判断文件是否存在, 异常:", e);
exist = false;
}
return exist;
}
}

📝 文件上传/下载/预览/删除实战
🧁 1.文件上传
🍆 示例代码
/**
* @author: bug菌
* @date: 2024-10-21 12:07
*/
@Api(tags = "Minio文件管理")
@RestController
@RequestMapping("/file")
public class UploadFileController extends BaseController {
@Autowired
private MinioUtil minioUtil;
/**
* 上传文件
*/
@GetMapping(value = "/upload")
@ApiOperation("上传文件")
public R upload(MultipartFile file) {
// 获取到上传文件的完整名称,包括文件后缀
String fileName = file.getOriginalFilename();
// 获取不带后缀的文件名
String baseName = FilenameUtils.getBaseName(fileName);
// 获取文件后缀
String extension = FilenameUtils.getExtension(fileName);
//创建一个独一的文件名(存于服务器名),格式为 name_时间戳.后缀
String saveFileName = baseName + "_" + System.currentTimeMillis() + "." + extension;
minioUtil.upload(file, saveFileName);
return R.ok("上传成功!存放文件名为:" + saveFileName);
}
}
🥔 示例测试
Postman接口测试上传接口如下:
校验文件是否真正上传到minio中,我们可以上客户端查验下。根据登录查看确实是我们测试时所上传的文件。
🍓 示例代码解析
在本次的代码演示中,我将会深入剖析每句代码,详细阐述其背后的设计思想和实现逻辑。通过这样的讲解方式,我希望能够引导同学们逐步构建起对代码的深刻理解。我会先从代码的结构开始,逐步拆解每个模块的功能和作用,并指出关键的代码段,并解释它们是如何协同运行的。通过这样的讲解和实践相结合的方式,我相信每位同学都能够对代码有更深入的理解,并能够早日将其掌握,应用到自己的学习和工作中。
如上提供的这段代码是一个用于文件上传的控制器,使用 Spring Boot 构建,负责处理文件的上传操作。以下是代码的详细解析:

- 类注解:
@Api(tags = "Minio文件管理")
:使用 Swagger API 文档工具生成接口文档,并为该类提供了一个标签“Minio文件管理”,用于描述文件管理相关的接口。@RestController
:该注解表示这是一个控制器类,并且返回的内容会被自动序列化为 JSON 格式。它是@Controller
和@ResponseBody
的组合。@RequestMapping("/file")
:设置该类的基础请求路径为/file
,所有该类中的请求都会以/file
开头。
- 依赖注入:
@Autowired
:自动注入MinioUtil
类的实例,MinioUtil
是一个封装了 MinIO 操作的工具类,用于处理与 MinIO 存储服务的交互。
- 方法注解:
@GetMapping(value = "/upload")
:处理 HTTP GET 请求,路径为/file/upload
。尽管通常文件上传使用 POST 请求,但这里使用 GET 请求可能是简化了请求示例,实际应用中可能使用 POST。@ApiOperation("上传文件")
:Swagger 文档生成的描述,表示该接口用于上传文件。
- 上传文件操作:
MultipartFile file
:表示前端传递的文件。Spring 会自动将请求中的文件映射到该参数。String fileName = file.getOriginalFilename();
:获取上传文件的原始文件名,包括文件扩展名。String baseName = FilenameUtils.getBaseName(fileName);
:使用 Apache Commons IO 库的FilenameUtils
类,获取文件的基本名称(不包含扩展名)。String extension = FilenameUtils.getExtension(fileName);
:获取文件的扩展名。String saveFileName = baseName + "_" + System.currentTimeMillis() + "." + extension;
:生成一个新的唯一文件名。通过文件的基本名称加上当前的时间戳(毫秒级),确保文件名不重复。minioUtil.upload(file, saveFileName);
:调用MinioUtil
类中的upload
方法,将文件上传到 MinIO 存储服务,保存为saveFileName
。
- 返回结果:
return R.ok("上传成功!存放文件名为:" + saveFileName);
:返回上传成功的响应,R.ok()
是一个自定义的响应方法,表示操作成功并返回相应的信息,saveFileName
作为返回信息的一部分,告知客户端上传文件后的存储文件名。
小结:
该控制器类用于处理文件上传请求,接收文件并生成一个唯一的文件名,通过 MinioUtil
工具类将文件上传至 MinIO 存储。它结合了文件名生成、上传及响应返回等功能,实现了简单的文件上传管理。

🍬 2.文件下载
🍆 示例代码
/**
* 根据文件ID下载文件
*/
@GetMapping("/download")
@ApiOperation("根据文件ID下载文件")
public void downloadById(@RequestParam("fileName") String fileName, @RequestParam("saveFileName") String saveFileName, HttpServletResponse response) {
// 下载文件,传递存储文件名和显示文件名
minioUtil.download(response, fileName, saveFileName);
return;
}
🥔 示例测试
Postman接口测试上传接口如下:
🍓 示例代码解析
在本次的代码演示中,我将会深入剖析每句代码,详细阐述其背后的设计思想和实现逻辑。通过这样的讲解方式,我希望能够引导同学们逐步构建起对代码的深刻理解。我会先从代码的结构开始,逐步拆解每个模块的功能和作用,并指出关键的代码段,并解释它们是如何协同运行的。通过这样的讲解和实践相结合的方式,我相信每位同学都能够对代码有更深入的理解,并能够早日将其掌握,应用到自己的学习和工作中。
如上提供的这段代码是用于根据文件ID下载文件的控制器方法。以下是对代码的详细解析:
- 方法注解:
@GetMapping("/download")
:该方法处理 HTTP GET 请求,路径为/download
。该请求用于根据文件ID下载文件。@ApiOperation("根据文件ID下载文件")
:Swagger 文档生成的描述,表明该接口用于根据文件ID下载文件。
- 方法参数:
@RequestParam("fileName") String fileName
:从请求中获取名为fileName
的请求参数,并将其绑定到fileName
变量。这个参数通常表示文件在存储中的实际名称。@RequestParam("fileName") String saveFileName
:这个参数也是从请求中获取名为fileName
的请求参数。由于参数名称重复,可能会导致问题。正确的做法是使用不同的名字,例如fileName
和saveFileName
,用来分别传递存储文件名和显示文件名。HttpServletResponse response
:Spring MVC 自动注入的HttpServletResponse
对象,用于设置响应信息,发送文件内容到客户端。
- 下载文件操作:
minioUtil.download(response, fileName, saveFileName);
:调用MinioUtil
类中的download
方法。该方法接收HttpServletResponse
对象、存储文件名(fileName
)和显示文件名(saveFileName
)作为参数。download
方法将从 MinIO 存储中获取指定的文件并通过 HTTP 响应将其返回给客户端。
- 方法结束:
return;
:该方法没有返回任何内容,因为文件内容通过HttpServletResponse
被直接流式传输到客户端。
小结:
该方法用于处理根据文件ID下载文件的请求。它通过传递文件名参数,调用 MinioUtil
的下载方法,将文件从 MinIO 存储下载并返回给客户端。

🍩 3.文件预览
🍓 示例代码
@GetMapping("/preview")
@ApiOperation("根据文件ID预览文件")
public String previewFileById(@RequestParam("fileName") String fileName) {
return minioUtil.getFileUrl(fileName);
}
🥔 示例测试
Postman接口测试上传接口如下:
通过接口可直接给你返回该文件的预览地址,我们只需要在浏览器输入该地址便可预览。
🍆 示例代码解析
在本次的代码演示中,我将会深入剖析每句代码,详细阐述其背后的设计思想和实现逻辑。通过这样的讲解方式,我希望能够引导同学们逐步构建起对代码的深刻理解。我会先从代码的结构开始,逐步拆解每个模块的功能和作用,并指出关键的代码段,并解释它们是如何协同运行的。通过这样的讲解和实践相结合的方式,我相信每位同学都能够对代码有更深入的理解,并能够早日将其掌握,应用到自己的学习和工作中。
如上提供的这段代码是用于根据文件ID预览文件的控制器方法。以下是详细解析:
- 方法注解:
@GetMapping("/preview")
:该方法处理 HTTP GET 请求,路径为/preview
,用于根据文件ID预览文件。@ApiOperation("根据文件ID预览文件")
:Swagger 文档生成的描述,表明该接口用于根据文件ID预览文件。
- 方法参数:
@RequestParam("fileName") String fileName
:从请求中获取名为fileName
的请求参数,并将其绑定到fileName
变量。这个参数通常表示要预览的文件在存储中的文件名。
- 文件预览操作:
minioUtil.getFileUrl(fileName)
:调用MinioUtil
类中的getFileUrl
方法,该方法使用文件名从 MinIO 存储生成文件的预览 URL。返回的 URL 通常是一个可以直接访问该文件的链接,可以在客户端浏览器中打开进行预览。
- 返回值:
- 方法返回
String
类型的文件预览 URL,这个 URL 可以直接访问文件并在浏览器中预览。
- 方法返回
小结:
该方法用于处理根据文件ID预览文件的请求。它通过文件名生成一个文件的预览 URL,并将该 URL 返回给客户端,客户端可以使用该 URL 访问文件进行预览。

🍭 4.文件删除
🍓 示例代码
/**
* 根据文件ID删除文件
*/
@GetMapping("/delete")
@ApiOperation("根据文件ID删除文件")
public R deleteById(@RequestParam("fileName") String fileName) {
minioUtil.delete(fileName);
return R.ok();
}
🥔 示例测试
Postman接口测试上传接口如下:
接着我们上客户端查验下,该文件是否真被删除了。
根据时间倒序排序,确实该文件被删除了。
🍆 示例代码解析
在本次的代码演示中,我将会深入剖析每句代码,详细阐述其背后的设计思想和实现逻辑。通过这样的讲解方式,我希望能够引导同学们逐步构建起对代码的深刻理解。我会先从代码的结构开始,逐步拆解每个模块的功能和作用,并指出关键的代码段,并解释它们是如何协同运行的。通过这样的讲解和实践相结合的方式,我相信每位同学都能够对代码有更深入的理解,并能够早日将其掌握,应用到自己的学习和工作中。
如上提供的这段代码是用于根据文件ID删除文件的控制器方法。以下是详细解析:
- 方法注解:
@GetMapping("/delete")
:该方法处理 HTTP GET 请求,路径为/delete
,用于根据文件ID删除文件。@ApiOperation("根据文件ID删除文件")
:Swagger 文档生成的描述,表明该接口用于根据文件ID删除文件。
- 方法参数:
@RequestParam("fileName") String fileName
:从请求中获取名为fileName
的请求参数,并将其绑定到fileName
变量。这个参数通常表示要删除的文件在存储中的文件名。
- 删除文件操作:
minioUtil.delete(fileName)
:调用MinioUtil
类中的delete
方法,该方法会根据提供的fileName
删除 MinIO 存储中的对应文件。
- 返回值:
- 方法返回
R.ok()
:表示操作成功,返回一个响应对象,R.ok()
是一种常见的封装返回成功的方式,可能会带有自定义的状态码或消息。
- 方法返回
小结:
该方法处理根据文件ID删除文件的请求。它通过文件名调用 MinioUtil
删除对应的文件,并返回一个成功的响应。

🫐 MinIO与云原生架构的完美契合
MinIO不仅是一个存储工具,它更是云原生架构中不可或缺的一部分。与Kubernetes无缝整合,让微服务架构下的数据管理变得轻松自如。不论是CI/CD流水线还是大数据分析,MinIO都能应对自如。
🍐 总结与思考
通过这篇文章,你应该对Spring Boot与MinIO的结合有了一个全面的了解。这种现代化的文件存储方案不仅让开发更高效,也为未来业务的扩展奠定了坚实基础。既然已经Get到这么棒的技能,何不立即尝试一下,让你的项目也能“飞”起来?

🥕 附录相关报错及方案解决
🫛1、okhttp3包冲突
如果你遇到你的项目集成 minio 8.5.4 遇到 okhttp3包冲突,比如报错如下所示,可见我这篇《SpringBoot项目集成 minio 8.5.4 遇到 okhttp3包冲突,如何解决?》带你解决此问题:
🍏2、启动报错
如果你启动后遇到如下问题,比如报错如下所示,可见我这篇《集成minio启动报错:Caused by:java.lang.IllegalArgumentException:invalid hostname 10.66.66.143:9000...| 亲测有效》带你解决此问题:
ok,本期内容我就暂聊到这里,哇,一口气给大家输出完,我我我我...头发又脱落了一撮。

📣 关于我
我是bug菌,CSDN | 掘金 | InfoQ | 51CTO | 华为云 | 阿里云 | 腾讯云 等社区博客专家,C站博客之星Top30,华为云2023年度十佳博主,掘金多年度人气作者Top40,掘金等各大社区平台签约作者,51CTO年度博主Top12,掘金/InfoQ/51CTO等社区优质创作者;全网粉丝合计 30w+;硬核微信公众号「猿圈奇妙屋」,欢迎你的加入!免费白嫖最新BAT互联网公司面试真题、4000G PDF电子书籍、简历模板等海量资料,你想要的我都有,关键是你不来拿哇。

-End-
来源:juejin.cn/post/7443658338867134518
一个 Bug JDK 居然改了十年?
问题现象
今天偶然看到了一个 JDK 的 Bug,给大家分享一下。
假设现在有如下的代码:
List<String> list = new ArrayList<>();
list.add("1");
Object[] array = list.toArray();
array[0] = 1;
System.out.println(Arrays.toString(array));
上面的代码是可以正常支执行的,如下图所示:
修改代码为如下代码:
List<String> list = Arrays.asList("1");
Object[] array = list.toArray();
array[0] = 1;
System.out.println(Arrays.toString(array));
再次执行代码,结果就会抛出 ArrayStoreException
异常,这个异常表明这里并不能把一个 Integer
类型的对象存放到这个数组里面。如下图所示:
查看 Arrays
的静态内部类 ArrayList
的 toArray()
方法的返回值就是 Object[]
类型的,如下图所示:
这里就会引发一个疑问: 为啥使用 java.lang.util.ArrayList
代码就可以正常运行?但是使用 Arrays
的静态内部类 ArrayList
就会报错了?
原因分析
首先看下 java.lang.util.ArrayList
类的 toArray()
方法的实现逻辑:
从上面可以看出 toArray()
方法是拷贝了一个 ArrayList
内部的数组对象,然后返回的。而 elementData
这个数组在实际初始化的时候,就是 new 了 Object
类型的数组。如下图所示:
那么经过拷贝之后返回的还是一个实际类型为Object
类型的数组。既然这里是一个 Object
类型的数组,那么往里面放一个 Integer
类型的数据是合法的,因为 Object
是 Integer
类型的父类。
然后再看下 Arrays
的静态内部类 ArrayList
的 toArray()
方法的实现逻辑。这里返回的是 a
这个数组的一个克隆。如下图所示:
而这个 a
数组声明的类型是 E[]
,根据泛型擦除后的原则,这里实际上声明的类型也变成了 Object[]
。 如下图所示:
那接下来再看看 a
实际的类型是什么? 由于 Arrays
的静态内部类 ArrayList
的构造函数是包级访问的,因此只能通过 Arrays.asList()
静态方法来构造一个这个对象。如下图所示:
而 Arrays.asList()
方法的签名是变长参数类型,这个是 Java 的一个语法糖,实际对应的是一个数组,泛型擦除后就变成了 Object[]
类型。如下图所示:
而在代码实际调用处,实际上会 new
一个 String
类型的数组,也就是说 「a
的实际类型是一个 String
类型的数组」。 那么 a 调用了 clone()
方法之后返回的类型也是一个 String 类型的数组,克隆嘛,类型一样才叫克隆。如下图所示:
经过上面的分析,答案就呼之欲出了。a
的实际类型是一个 String
类型的数组,那么往这个数组里面放一个 Integer
类型的对象那肯定是要报错的。等效代码如下图所示:
为什么是个Bug ?
查看 Collection
接口的方法签名,方法声明明确是要返回的是一个 Object[]
类型的数组,因为方法明确声明了返回的是一个 Object[]
类型的数组,但是实际上在获取到了这个返回值后把它当作一个 Object[]
类型的数组使用某些情况下是不满足语义的。
同时这里要注意一下,返回的这个数组要是一个 「安全」的数组,安全的意思就是「集合本身不能持有对返回的数组的引用」,即使集合的内部是用数组实现的,也不能直接把这个内部的数组直接返回。这就是为什么上面两个 toArray()
方法的实现要么是把原有的数组复制了一份,要么是克隆了一份,本质上都是新建了一个数组。如下图所示:
在 OpenJDK 的 BugList 官网上很早就有人提出这个问题了,从时间上看至少在 2005 年就已经发现这个 Bug 了,这个 Bug 真正被解决是在 2015 年的时候,整整隔了 10 年时间。花了 10 年时间修这个 Bug,真是十年磨一剑啊!
如何修正的这个 Bug ?
JDK 9 中的实现修改为了新建一个 Object
类型的数组,然后把原有数组中的元素拷贝到这个数组里面,然后返回这个 Object
类型的数组,这样的话就和 java.util.ArrayList
类中的实现方法一样了。
在 java.util.ArrayList
类的入参为 Collection\<? exends E>
类型的构造函数中就涉及到可能调用 Arrays
的静态内部类 ArrayList
的 toArray()
方法,JDK 在实现的时候针对这个 Bug 还做了特殊的处理,不同厂商发行的 JDK 处理方式还有细微的不同。
Oracel JDK 8 版本的实现方式:
Eclipse Temurin Open JDK 8 版本的实现方式:
之所以在 java.util.ArrayList
对这个 Bug 做特殊的处理是因为 Sun 公司在当时选择不修复改这个Bug,因为怕修复了之后已有的代码就不能运行了。如下图所示:
比如在修复前有如下的代码,这个代码在 JDK 8 版本是可以正常运行的,如下图所示:
String[] strings = (String[]) Arrays.asList("foo", "bar").toArray();
for (String string : strings) {
System.out.println(string);
}
但是如果升级到 JDK 9 版本,就会报 ClassCastException
异常了,如下图所示:
因为修复了这个 Bug 之后,编译器并不能告诉你原来的代码存在问题,甚至连新的警告都没有。假设你从 JDK 8 升级到 JDK 9 了,代码也没有改,但是突然功能就用不了,这个时候你想不想骂人,哈哈哈哈。这也许就是 Sun 公司当年不愿意修复这个 Bug 的原因之一了。当然,如果你要问我为什么要升级的话,我会说:你发任你发,我用 Java 8 !
题外话
阿里巴巴的 Java开发手册对 toArray(T[] array)
方法的调用有如下的建议:
这里以 java.util.ArrayList
类的源码作为参考,源码实现如下:
// ArrayList 的 toArray() 方法实现:
public <T> T[] toArray(T[] a) {
if (a.length < size) // 如果传入的数组的长度小于 size
// Make a new array of a's runtime type, but my contents:
return (T[]) Arrays.copyOf(elementData, size, a.getClass());
System.arraycopy(elementData, 0, a, 0, size);
if (a.length > size)
a[size] = null;
return a;
}
// Arrays 的 coypyOf 方法实现:
public static <T,U> T[] copyOf(U[] original, int newLength, Class<? extends T[]> newType) {
@SuppressWarnings("unchecked")
T[] copy = ((Object)newType == (Object)Object[].class)
? (T[]) new Object[newLength]
: (T[]) Array.newInstance(newType.getComponentType(), newLength);
System.arraycopy(original, 0, copy, 0,
Math.min(original.length, newLength));
return copy;
}
当调用 toArray()
方法时传入的数组长度为 0 时,方法内部会根据传入的数组类型动态创建一个和当前集合 size 相同的数组,然后把集合的元素复制到这个数组里面,然后返回。
当调用 toArray()
方法时传入的数组长度大于 0,小于 ArrayList
的 size 时,走的逻辑和上面是一样的,也会进入到 Arays
的 copyOf
方法的调用中,但是调用方法传入的新建的数组相当于新建之后没有被使用,白白浪费了,需要等待 GC 回收。
当调用 toArray()
方法时传入的数组长度大于等于 ArrayList
的 size 时,则会直接把集合的元素拷贝到这个数组中。如果是大于的情况,还会把数组中下标为 size
的元素设置为 null,但是 size
下标后面的元素保持不变。如下所示:
List<String> list = new ArrayList<>();
list.add("1");
String[] array = new String[3];
array[1] = "2";
array[2] = "3";
String[] toArray = list.toArray(array);
System.out.println(array == toArray);
System.out.println(Arrays.toString(toArray));
手册中提到的在高并发的情况下,传入的数组长度等于 ArrayList
的 size 时,如果 ArrayList 的 size 在数组创建完成后变大了,还是会走到重新新建数组的逻辑里面,仍然会导致调用方法传入的新建的数组没有被使用,而且这里因为调用方法时新建的数组和 ArrayList
之前的 size 相同,会造成比传入长度为 0 的数组浪费多得多的空间。但是我个人觉得,因为 ArrayList
不是线程安全的,如果存在数据竞争的情况就不应该使用。
参考
Arrays.asList(x).toArray().getClass() should be Object[].class
array cast Java 8 vs Java 9
toArray方法的小陷阱,写开发手册的大佬也未能幸免
.toArray(new MyClass[0]) or .toArray(new MyClass[myList.size()])?
Arrays of Wisdom of the Ancients
Java开发手册(黄山版).pdf
来源:juejin.cn/post/7443746761846374439
大屏适配方案--scale
CSS3的scale等比例缩放
宽度比率 = 当前网页宽度 / 设计稿宽度
高度比率 = 当前网页高度 / 设计稿高度
设计稿: 1920 * 1080
适配屏幕:1920 * 1080 3840 * 2160(2 * 2) 7680 * 2160(4 * 2)
方案一:根据宽度比率
进行缩放(超宽屏比如9/16的屏幕会出现滚动条)
方案二:动态计算网页的宽高比,决定根据宽度比率
还是高度比率
进行缩放
首先基于1920 * 1080进行基础的布局,下面针对两种方案进行实现
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<style>
body,
ul {
margin: 0;
padding: 0;
}
body {
width: 1920px;
height: 1080px;
box-sizing: border-box;
/* 在js中添加translate居中 */
position: relative;
left: 50%;
/* 指定缩放的原点在左上角 */
transform-origin: left top;
}
ul {
width: 100%;
height: 100%;
list-style: none;
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
li {
width: 33.333%;
height: 50%;
box-sizing: border-box;
border: 2px solid rgb(198, 9, 135);
font-size: 30px;
}
</style>
</head>
<body>
<ul>
<li>1</li>
<li>2</li>
<li>3</li>
<li>4</li>
<li>5</li>
<li>6</li>
</ul>
<script>
// ...实现适配方案
</script>
</body>
</html>
方案一:根据宽度比率
进行缩放
// 设计稿尺寸以及宽高比
let targetWidth = 1920;
// html的宽 || body的宽
let currentWidth =
document.documentElement.clientWidth || document.body.clientWidth;
console.log(currentWidth);
// 按宽度计算缩放比率
let scaleRatio = currentWidth / targetWidth;
// 进行缩放
document.body.style = `transform: scale(${scaleRatio})`;
实现效果如下:
这时我们发现在7680 * 2160尺寸下,屏幕根据宽度缩放会出现滚动条,为了解决这个问题,我们就要动态的选择根据宽度缩放还是根据高度缩放。
方案二:动态计算网页的宽高比,决定根据宽度比率
还是高度比率
进行缩放
// 设计稿尺寸以及宽高比
let targetWidth = 1920;
let targetHeight = 1080;
let targetRatio = 16 / 9; // targetWidth /targetHeight
// 当前屏幕html的宽 || body的宽
let currentWidth =
document.documentElement.clientWidth || document.body.clientWidth;
// 当前屏幕html的高 || body的高
let currentHeight =
document.documentElement.clientHeight || document.body.clientHeight;
// 当前屏幕宽高比
let currentRatio = currentWidth / currentHeight;
// 默认 按宽度计算缩放比率
let scaleRatio = currentWidth / targetWidth;
if (currentRatio > targetRatio) {
scaleRatio = currentHeight / targetHeight;
}
// 进行缩放
document.body.style = `transform: scale(${scaleRatio}) translateX(-50%);`;
效果如下:
这样就可以解决在超宽屏幕下出现滚动条的问题,另外我们做了居中的样式处理,这样在超宽屏幕时,两边留白,内容居中展示显得更加合理些。
来源:juejin.cn/post/7359077652416725018
Java循环操作哪个快?
开发的时候我发现个问题,就是在学习玩streamAPI和lambda表达式后,我就变得越来越喜欢直接使用streamAPI,而不是使用for循环这种方式了,但是这种方式也有一定的缺点,但是直到某一次代码review,我的同事点醒了我,“小火汁,你的stream流写的是挺好,但是问题是为什么从同一个源取相似的对象,要分别写两次stream,你不觉得有点多余了吗?程序员不只是写代码,反而是最初的设计阶段就要把全局流程想好,要避免再犯这种错误哦~”,这句话点醒了我,所以我打算先看一下stream遍历、for循环、增强for循环、迭代器遍历、并行流parallel stream遍历的时间消耗,查看一下这几种方式的异同。
使用stream主要是在做什么?
此时我们先准备一个类
@Data
@Accessors(chain = true)
@AllArgsConstructor
@NoArgsConstructor
class Item {
private Integer name;
private Integer value;
}
- list转成map
list.stream().collect(Collectors.toMap(Item::getName, Item::getValue, (newValue, oldValue) -> newValue))
- List过滤,返回新List
List- collect = list.stream().filter(x -> x.getValue() > 50).collect(Collectors.toList());
- 模拟多次stream,因为我在开发中经常出现这种问题
Map collect = list.stream().collect(Collectors.toMap(Item::getName, Item::getValue, (newValue, oldValue) -> newValue));
Map collect3 = list.stream().collect(Collectors.toMap(Item::getName, Item::getValue, (newValue, oldValue) -> newValue));
- 取出list<类>中某一个属性的值,转成新的list
List collect = list.stream().map(Item::getValue).collect(Collectors.toList());
- list<类>中进行一组操作,并且转成新的list
List- collect1 = list.stream().parallel().map(x -> {
Integer temp = x.getName();
x.setName(x.getValue());
x.setValue(temp);
return x;
}).collect(Collectors.toList());
实际消耗
选择1、10、100、100_00、100_000的原因
1、10、100主要是业务决定的,实际代码编写中这块的数据量是占大头的,10_000,100_000是因为为了查看实际的大数据量情况下的效果。
结果结论如下:
- 如果只是用filter的API,则建议只使用普通for循环,其他情况下数据量较少时,虽然stream和for循环都是10ms以内,但是性能上会差着3-4倍
- 普通for循环可以使用for (Item item : list),因为这个是for (int i = 0; i < ; i++)的语法糖
- 增强for循环底层是Iterator接口,但是实际的验证时发现特别慢,暂时没发现原因,但是不推荐使用
- stream串行流转成并行流操作后普遍还是不如串行流快,速度如下:执行时间:串行流转并行流>串行流>并行流,所以串行流转并行流不推荐使用
- 串行流转并行流和并行流都会使用ForkJoinsPool.commonPool(),这是个进程共用的CPU型线程池,且数据不方便修改,我记得是需要在启动的时候进行修改
- 串行流转并行流和并行流均会产生线程争抢资源与线程安全问题
- 在单次stream多次中继操作的情况下,执行速度和单次中继操作差不多
总结
- 写一次stream操作耗时较少,但是会导致开发人员无意之间多次使用stream流做类似操作(如从订单类中多次取不一致但是相似的一组对象),从而导致可读性变差,不利于后续拓展
- 尽量使用普通for循环做遍历,迭代器循环做删除或者使用collection的remove、removeIf等API实现(如果只需要删除的话)
- 使用普通for循环比stream流节省时间,因此在提高性能的角度看开发中尽量使用普通for循环。
来源:juejin.cn/post/7427173759713951753
WebSocket太笨重?试试SSE的轻量级魅力!
一、 前言
Hello~ 大家好。我是秋天的一阵风~
关注我时间长一点的同学们应该会了解,我最近算是跟旧项目 “较上劲” 了哈哈哈。
刚发布了一篇清除项目里的“僵尸”文件文章,这不,我又发现了旧项目上的一个问题。请听我慢慢说来~
在2024年12月18日的午后,两点十八分,阳光透过窗帘的缝隙,洒在键盘上。我像往常一样,启动了那个熟悉的本地项目。浏览器的network
面板静静地打开,准备迎接那个等待修复的bug。就在这时,一股尿意突然袭来,我起身,走向了厕所。
当我回来,坐回那把椅子,眼前的一幕让我愣住了。network
面板上,不知何时,跳出了一堆http请求
,它们像是一场突如其来的雨,让人措手不及。我的头皮开始发麻,那种麻,是那种从心底里涌上来的,让人无法忽视的麻。这堆请求,它们似乎在诉说着什么,又或许,它们只是在提醒我,这个世界,有时候,比我们想象的要复杂得多。
好了,矫情的话咱不说了,直接步入正题。😄😄😄

在查看代码以后发现这些频繁的请求是因为我们的项目首页有一个待办任务数量和消息提醒数量的展示,所以之前的同事使用了定时器,每隔十秒钟发送一次请求到后端接口拿数据,这也就是我们常说的轮询做法。
1. 轮询的缺点
我们都知道轮询的缺点有几种:
资源浪费:
- 网络带宽:频繁的请求可能导致不必要的网络流量,增加带宽消耗。
- 服务器负载:每次请求都需要服务器处理,即使是空返回,也会增加服务器的CPU和内存负载。
用户体验:
- 界面卡顿:频繁的请求和更新可能会造成用户界面的卡顿,影响用户体验。
2. websocket的缺点
那么有没有替代轮询的做法呢? 聪明的同学肯定会第一时间想到用websocket
,但是在目前这个场景下我觉得使用websocket
是显得有些笨重。我从以下这几方面对比:
- 客户端实现:
- WebSocket 客户端实现需要处理连接的建立、维护和关闭,以及可能的重连逻辑。
- SSE 客户端实现相对简单,只需要处理接收数据和连接关闭。
- 适用场景:
- WebSocket 适用于需要双向通信的场景,如聊天应用、在线游戏等。
- SSE 更适合单向数据推送的场景,如股票价格更新、新闻订阅等。
- 实现复杂性:
- WebSocket 是一种全双工通信协议,需要在客户端和服务器之间建立一个持久的连接,这涉及到更多的编程复杂性。
- SSE 是单向通信协议,实现起来相对简单,只需要服务器向客户端推送数据。
- 浏览器支持:
- 尽管现代浏览器普遍支持 WebSocket,但 SSE 的支持更为广泛,包括一些较旧的浏览器版本。
- 服务器资源消耗:
- WebSocket 连接需要更多的服务器资源来维护,因为它们是全双工的,服务器需要监听来自客户端的消息。
- SSE 连接通常是单向的,服务器只需要推送数据,减少了资源消耗。
二、 详细对比
对于这三者的详细区别,你可以参考下面我总结的表格:
以下是 WebSocket、轮询和 SSE 的对比表格:
特性 | WebSocket | 轮询Polling | Server-Sent Events (SSE) |
---|---|---|---|
定义 | 全双工通信协议,支持服务器和客户端之间的双向通信。 | 客户端定期向服务器发送请求以检查更新。 | 服务器向客户端推送数据的单向通信协议。 |
实时性 | 高,服务器可以主动推送数据。 | 低,依赖客户端定时请求。 | 高,服务器可以主动推送数据。 |
开销 | 相对较高,需要建立和维护持久连接。 | 较低,但频繁请求可能导致高网络和服务器开销。 | 相对较低,只需要一个HTTP连接,服务器推送数据。 |
浏览器支持 | 现代浏览器支持,需要额外的库来支持旧浏览器。 | 所有浏览器支持。 | 现代浏览器支持良好,旧浏览器可能需要polyfill。 |
实现复杂性 | 高,需要处理连接的建立、维护和关闭。 | 低,只需定期发送请求。 | 中等,只需要处理服务器推送的数据。 |
数据格式 | 支持二进制和文本数据。 | 通常为JSON或XML。 | 仅支持文本数据,通常为JSON。 |
控制流 | 客户端和服务器都可以控制消息发送。 | 客户端控制请求发送频率。 | 服务器完全控制数据推送。 |
安全性 | 需要wss://(WebSocket Secure)来保证安全。 | 需要https://来保证请求的安全。 | 需要SSE通过HTTPS提供,以保证数据传输的安全。 |
适用场景 | 需要双向交互的应用,如聊天室、实时游戏。 | 适用于更新频率不高的场景,如轮询邮箱。 | 适用于服务器到客户端的单向数据流,如股票价格更新。 |
跨域限制 | 默认不支持跨域,需要服务器配置CORS。 | 默认不支持跨域,需要服务器配置CORS。 | 默认不支持跨域,需要服务器配置CORS。 |
重连机制 | 客户端可以实现自动重连逻辑。 | 需要客户端实现重连逻辑。 | 客户端可以监听连接关闭并尝试重连。 |
服务器资源 | 较高,因为需要维护持久连接。 | 较低,但频繁的请求可能增加服务器负担。 | 较低,只需要维护一个HTTP连接。 |
这个表格概括了 WebSocket、轮询和 SSE 在不同特性上的主要对比点。每种技术都有其适用的场景和限制,选择合适的技术需要根据具体的应用需求来决定。
三、 SSE(Server-Sent Events)介绍
我们先来简单了解一下什么是Server-Sent Events
?
Server-Sent Events (SSE)
是一种允许服务器主动向客户端浏览器推送数据的技术。它基于 HTTP 协议
,但与传统的 HTTP 请求-响应模式不同,SSE 允许服务器在建立连接后,通过一个持久的连接不断地向客户端发送消息。
工作原理
- 建立连接:
- 客户端通过一个普通的 HTTP 请求订阅一个 SSE 端点。
- 服务器响应这个请求,并保持连接打开,而不是像传统的 HTTP 响应那样关闭连接。
- 服务器推送消息:
- 一旦服务器端有新数据可以发送,它就会通过这个持久的连接向客户端发送一个事件。
- 每个事件通常包含一个简单的文本数据流,遵循特定的格式。
- 客户端接收消息:
- 客户端监听服务器发送的事件,并在收到新数据时触发相应的处理程序。
- 连接管理:
- 如果连接由于任何原因中断,客户端可以自动尝试重新连接。
著名的计算机科学家林纳斯·托瓦兹(Linus Torvalds) 曾经说过:talk is cheap ,show me your code
。
我们直接上代码看看效果:
java代码
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
@RestController
@RequestMapping("platform/todo")
public class TodoSseController {
private final ExecutorService executor = Executors.newCachedThreadPool();
@GetMapping("/endpoint")
public SseEmitter refresh(HttpServletRequest request) {
final SseEmitter emitter = new SseEmitter(Long.MAX_VALUE);
executor.execute(() -> {
try {
while (true) { // 无限循环发送事件,直到连接关闭
// 发送待办数量更新
emitter.send(SseEmitter.event().data(5));
// 等待5秒
TimeUnit.SECONDS.sleep(5);
}
} catch (IOException e) {
emitter.completeWithError(e);
} catch (InterruptedException e) {
// 当前线程被中断,结束连接
Thread.currentThread().interrupt();
emitter.complete();
}
});
return emitter;
}
}
前端代码
beforeCreate() {
const eventSource = new EventSource('/platform/todo/endpoint');
eventSource.onmessage = (event) => {
console.log("evebt:",event)
};
eventSource.onerror = (error) => {
console.error('SSE error:', error);
eventSource.close();
};
this.$once('hook:beforeDestroy', () => {
if (eventSource) {
eventSource.close();
}
});
},
改造后的效果


可以看到,客户端只发送了一次http请求,后续所有的返回结果都可以在event.data
里面获取,先不谈性能,对于有强迫症的同学是不是一个很大改善呢?
总结
虽然 SSE
(Server-Sent Events)因其简单性和实时性在某些场景下提供了显著的优势,比如在需要服务器向客户端单向推送数据时,它能够以较低的开销维持一个轻量级的连接,但 SSE 也存在一些局限性。例如,它不支持二进制数据传输,这对于需要传输图像、视频或复杂数据结构的应用来说可能是一个限制。此外,SSE 只支持文本格式的数据流,这可能限制了其在某些数据传输场景下的应用。还有,SSE 的兼容性虽然在现代浏览器中较好,但在一些旧版浏览器中可能需要额外的 polyfill 或者降级方案。
考虑到这些优缺点,我们在选择数据通信策略时,应该基于项目的具体需求和上下文来做出决策。如果项目需要双向通信或者传输二进制数据,WebSocket 可能是更合适的选择。
如果项目的数据更新频率不高,或者只需要客户端偶尔查询服务器状态,传统的轮询可能就足够了。
而对于需要服务器频繁更新客户端数据的场景,SSE 提供了一种高效的解决方案。
总之,选择最合适的技术堆栈需要综合考虑项目的需求、资源限制、用户体验和未来的可维护性。
来源:juejin.cn/post/7451991754561880115
为什么现在连Date类都不建议使用了?
本文已经授权【稀土掘金技术社区】官方公众号独家原创发布。
一、有什么问题吗java.util.Date
?
java.util.Date
(Date
从现在开始)是一个糟糕的类型,这解释了为什么它的大部分内容在 Java 1.1 中被弃用(但不幸的是仍在使用)。
设计缺陷包括:
- 它的名称具有误导性:它并不代表 a
Date
,而是代表时间的一个瞬间。所以它应该被称为Instant
——正如它的java.time
等价物一样。 - 它是非最终的:这鼓励了对继承的不良使用,例如
java.sql.Date
(这意味着代表一个日期,并且由于具有相同的短名称而也令人困惑) - 它是可变的:日期/时间类型是自然值,可以通过不可变类型有效地建模。可变的事实
Date
(例如通过setTime
方法)意味着勤奋的开发人员最终会在各处创建防御性副本。 - 它在许多地方(包括)隐式使用系统本地时区,
toString()
这让许多开发人员感到困惑。有关此内容的更多信息,请参阅“什么是即时”部分 - 它的月份编号是从 0 开始的,是从 C 语言复制的。这导致了很多很多相差一的错误。
- 它的年份编号是基于 1900 年的,也是从 C 语言复制的。当然,当 Java 出现时,我们已经意识到这不利于可读性?
- 它的方法命名不明确:
getDate()
返回月份中的某一天,并getDay()
返回星期几。给这些更具描述性的名字有多难? - 对于是否支持闰秒含糊其辞:“秒由 0 到 61 之间的整数表示;值 60 和 61 仅在闰秒时出现,即使如此,也仅在实际正确跟踪闰秒的 Java 实现中出现。” 我强烈怀疑大多数开发人员(包括我自己)都做了很多假设,认为 for 的范围
getSeconds()
实际上在 0-59 范围内(含)。 - 它的宽容没有明显的理由:“在所有情况下,为这些目的而对方法给出的论据不必落在指定的范围内; 例如,日期可以指定为 1 月 32 日,并被解释为 2 月 1 日。” 多久有用一次?
原文如下:为什么要避免使用Date类?
二、为啥要改?
我们要改的原因很简单,我们的代码缺陷扫描规则认为这是一个必须修改的缺陷,否则不给发布,不改不行,服了。
解决思路:避免使用
java.util.Date
与java.sql.Date
类和其提供的API,考虑使用java.time.Instant
类或java.time.LocalDateTime
类及其提供的API替代。
三、怎么改?
只能说这种基础的类改起来牵一发动全身,需要从DO实体类看起,然后就是各种Converter,最后是DTO。由于我们还是微服务架构,业务服务依赖于基础服务的API,所以必须要一起改否则就会报错。这里就不细说修改流程了,主要说一下我们在改造的时候遇到的一些问题。
1. 耐心比对数据库日期字段和DO的映射
(1)确定字段类型
首先你需要确定数据对象中的 Date
字段代表的是日期、时间还是时间戳。
- 如果字段代表日期和时间,则可能需要使用
LocalDateTime
。 - 如果字段仅代表日期,则可能需要使用
LocalDate
。 - 如果字段仅代表时间,则可能需要使用
LocalTime
。 - 如果字段需要保存时间戳(带时区的),则可能需要使用
Instant
或ZonedDateTime
。
(2)更新数据对象类
更新数据对象类中的字段,把 Date
类型改为适当的 java.time
类型。
2. 将DateUtil中的方法改造
(1)替换原来的new Date()和Calendar.getInstance().getTime()
原来的方式:
Date nowDate = new Date();
Date nowCalendarDate = Calendar.getInstance().getTime();
使用 java.time
改造后:
// 使用Instant代表一个时间点,这与Date类似
Instant nowInstant = Instant.now();
// 如果需要用到具体的日期和时间(例如年、月、日、时、分、秒)
LocalDateTime nowLocalDateTime = LocalDateTime.now();
// 如果你需要和特定的时区交互,可以使用ZonedDateTime
ZonedDateTime nowZonedDateTime = ZonedDateTime.now();
// 如果你需要转换回java.util.Date,你可以这样做(假设你的代码其他部分还需要使用Date)
Date nowFromDateInstant = Date.from(nowInstant);
// 如果需要与java.sql.Timestamp交互
java.sql.Timestamp nowFromInstant = java.sql.Timestamp.from(nowInstant);
一些注意点:
Instant
表示的是一个时间点,它是时区无关的,相当于旧的Date
类。它通常用于表示时间戳。LocalDateTime
表示没有时区信息的日期和时间,它不能直接转换为时间戳,除非你将其与时区结合使用(例如通过ZonedDateTime
)。ZonedDateTime
包含时区信息的日期和时间,它更类似于Calendar
,因为Calendar
也包含时区信息。- 当你需要将
java.time
对象转换回java.util.Date
对象时,可以使用Date.from(Instant)
方法。这在你的代码需要与旧的API或库交互时非常有用。
(2)一些基础的方法改造
a. dateFormat
原来的方式
public static String dateFormat(Date date, String dateFormat) {
SimpleDateFormat formatter = new SimpleDateFormat(dateFormat);
return formatter.format(date);
}
使用java.time
改造后
public static String dateFormat(LocalDateTime date, String dateFormat) {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern(dateFormat);
return date.format(formatter);
}
b. addSecond、addMinute、addHour、addDay、addMonth、addYear
原来的方式
public static Date addSecond(Date date, int second) {
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
calendar.add(13, second);
return calendar.getTime();
}
public static Date addMinute(Date date, int minute) {
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
calendar.add(12, minute);
return calendar.getTime();
}
public static Date addHour(Date date, int hour) {
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
calendar.add(10, hour);
return calendar.getTime();
}
public static Date addDay(Date date, int day) {
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
calendar.add(5, day);
return calendar.getTime();
}
public static Date addMonth(Date date, int month) {
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
calendar.add(2, month);
return calendar.getTime();
}
public static Date addYear(Date date, int year) {
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
calendar.add(1, year);
return calendar.getTime();
}
使用java.time
改造后
public static LocalDateTime addSecond(LocalDateTime date, int second) {
return date.plusSeconds(second);
}
public static LocalDateTime addMinute(LocalDateTime date, int minute) {
return date.plusMinutes(minute);
}
public static LocalDateTime addHour(LocalDateTime date, int hour) {
return date.plusHours(hour);
}
public static LocalDateTime addDay(LocalDateTime date, int day) {
return date.plusDays(day);
}
public static LocalDateTime addMonth(LocalDateTime date, int month) {
return date.plusMonths(month);
}
public static LocalDateTime addYear(LocalDateTime date, int year) {
return date.plusYears(year);
}
c. dateToWeek
原来的方式
public static final String[] WEEK_DAY_OF_CHINESE = new String[]{"周日", "周一", "周二", "周三", "周四", "周五", "周六"};
public static String dateToWeek(Date date) {
Calendar cal = Calendar.getInstance();
cal.setTime(date);
return WEEK_DAY_OF_CHINESE[cal.get(7) - 1];
}
使用java.time
改造后
public static final String[] WEEK_DAY_OF_CHINESE = new String[]{"周日", "周一", "周二", "周三", "周四", "周五", "周六"};
public static String dateToWeek(LocalDate date) {
DayOfWeek dayOfWeek = date.getDayOfWeek();
return WEEK_DAY_OF_CHINESE[dayOfWeek.getValue() % 7];
}
d. getStartOfDay和getEndOfDay
原来的方式
public static Date getStartTimeOfDay(Date date) {
if (date == null) {
return null;
} else {
LocalDateTime localDateTime = LocalDateTime.ofInstant(Instant.ofEpochMilli(date.getTime()), ZoneId.systemDefault());
LocalDateTime startOfDay = localDateTime.with(LocalTime.MIN);
return Date.from(startOfDay.atZone(ZoneId.systemDefault()).toInstant());
}
}
public static Date getEndTimeOfDay(Date date) {
if (date == null) {
return null;
} else {
LocalDateTime localDateTime = LocalDateTime.ofInstant(Instant.ofEpochMilli(date.getTime()), ZoneId.systemDefault());
LocalDateTime endOfDay = localDateTime.with(LocalTime.MAX);
return Date.from(endOfDay.atZone(ZoneId.systemDefault()).toInstant());
}
}
使用java.time
改造后
public static LocalDateTime getStartTimeOfDay(LocalDateTime date) {
if (date == null) {
return null;
} else {
// 获取一天的开始时间,即00:00
return date.toLocalDate().atStartOfDay();
}
}
public static LocalDateTime getEndTimeOfDay(LocalDateTime date) {
if (date == null) {
return null;
} else {
// 获取一天的结束时间,即23:59:59.999999999
return date.toLocalDate().atTime(LocalTime.MAX);
}
}
e. betweenStartAndEnd
原来的方式
public static Boolean betweenStartAndEnd(Date nowTime, Date beginTime, Date endTime) {
Calendar date = Calendar.getInstance();
date.setTime(nowTime);
Calendar begin = Calendar.getInstance();
begin.setTime(beginTime);
Calendar end = Calendar.getInstance();
end.setTime(endTime);
return date.after(begin) && date.before(end);
}
使用java.time
改造后
public static Boolean betweenStartAndEnd(Instant nowTime, Instant beginTime, Instant endTime) {
return nowTime.isAfter(beginTime) && nowTime.isBefore(endTime);
}
我这里就只列了一些,如果有缺失的可以自己补充,不会写的话直接问问ChatGPT,它最会干这事了。最后把这些修改后的方法替换一下就行了。
四、小结一下
这个改造难度不高,但是复杂度非常高,一个地方没改好,轻则接口报错,重则启动失败,非常耗费精力,真不想改。
文末小彩蛋,自建摸鱼网站,各大网站热搜一览,上班和摸鱼很配哦!
来源:juejin.cn/post/7343161506699313162
Android 新一代图片加载库 - Coil
Coil 是 Android 的新一代图片加载库,它的全名叫做 Coroutine Image Loader,即协程图片加载器,用于显示网络或本地图像资源。
特点
- 快速:执行了多项优化,包括内存和磁盘缓存,图像降采样,自动暂停/取消请求等。
- 轻量级:依赖于 Kotlin,协程和 Okio,并与谷歌的 R8 等代码缩减器无缝协作。
- 易于使用:API 利用 Kotlin 的语言特性来实现简洁性和最小化的样板代码。
- 现代化:以 Kotlin 为首要语言,并与协程,Okio,Ktor 和 OkHttp 等现代库实现互操作。
加载图片
先引入依赖
implementation(libs.coil)
最简单的加载方法就是使用这个扩展函数了
inline fun ImageView.load(
data: Any?,
imageLoader: ImageLoader = context.imageLoader,
builder: ImageRequest.Builder.() -> Unit = {}
): Disposable {
val request = ImageRequest.Builder(context)
.data(data)
.target(this)
.apply(builder)
.build()
return imageLoader.enqueue(request)
}
使用扩展函数来加载本地或网络中的图片
// 加载网络图片
binding.imageView.load("https://img2.huashi6.com/images/resource/2020/07/12/h82924904p0.jpg")
// 加载资源图片
binding.imageView.load(R.drawable.girl)
// 加载文件中的图片
val file = File(requireContext().getExternalFilesDir(null), "saved_image.jpg")
binding.imageView.load(file.absolutePath)
支持设置占位图,裁剪变换,生命周期关联等
binding.imageView.load("https://img2.huashi6.com/images/resource/2020/07/12/h82924904p0.jpg") {
crossfade(true) //渐进渐出
crossfade(1000) //渐进渐出时间
placeholder(R.mipmap.sym_def_app_icon) //加载占位图
error(R.mipmap.sym_def_app_icon) //加载失败占位图
allowHardware(true) //硬件加速
allowRgb565(true) //支持565格式
lifecycle(lifecycle) //生命周期关联
transformations(CircleCropTransformation()) //圆形裁剪变换
}
变为圆角矩形
binding.imageView.load("https://img2.huashi6.com/images/resource/2020/07/12/h82924904p0.jpg") {
lifecycle(lifecycle)
transformations(RoundedCornersTransformation(20f))
}
可以创建自定义的图片加载器,为其添加一些日志拦截器等。
class LoggingInterceptor : Interceptor {
companion object {
private const val TAG = "LoggingInterceptor"
}
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
val url = chain.request.data.toString()
val width = chain.size.width.toString()
val height = chain.size.height.toString()
Log.i(TAG, "url: $url, width: $width, height: $height")
return chain.proceed(chain.request)
}
}
class MyApplication : Application(), ImageLoaderFactory {
override fun newImageLoader() =
ImageLoader.Builder(this.applicationContext).components { add(LoggingInterceptor()) }
.build()
}
替换 Okhttp 实例
val okHttpClient = OkHttpClient.Builder()
.retryOnConnectionFailure(true)
.connectTimeout(30, TimeUnit.SECONDS)
.writeTimeout(20, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
.build()
val imageLoader = ImageLoader.Builder(requireContext()).okHttpClient {
okHttpClient
}.build()
Coil.setImageLoader(imageLoader)
binding.imageView.load("https://img2.huashi6.com/images/resource/2020/07/12/h82924904p0.jpg")
加载 gif
添加依赖
implementation(libs.coil.gif)
按照官方的做法,设置 ImageLoader。
val imageLoader = ImageLoader.Builder(requireContext())
.components {
if (SDK_INT >= 28) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
}.build()
Coil.setImageLoader(imageLoader)
binding.imageView.load(GIF_URL)
下载监听
可以监听下载过程
binding.imageView.load(IMAGE_URL) {
listener(
onStart = {
Log.i(TAG, "onStart")
},
onError = { request, throwable ->
Log.i(TAG, "onError")
},
onSuccess = { request, result ->
Log.i(TAG, "onSuccess")
},
onCancel = { request ->
Log.i(TAG, "onCancel")
}
)
}
取消下载
val disposable = binding.imageView.load(IMAGE_URL)
disposable.dispose()
对 Jetpack Compose 的支持
引入依赖:
implementation(libs.coil.compose)
使用 AsyncImage
@Composable
@NonRestartableComposable
fun AsyncImage(
model: Any?,
contentDescription: String?,
modifier: Modifier = Modifier,
transform: (State) -> State = DefaultTransform,
onState: ((State) -> Unit)? = null,
alignment: Alignment = Alignment.Center,
contentScale: ContentScale = ContentScale.Fit,
alpha: Float = DefaultAlpha,
colorFilter: ColorFilter? = null,
filterQuality: FilterQuality = DefaultFilterQuality,
clipToBounds: Boolean = true,
modelEqualityDelegate: EqualityDelegate = DefaultModelEqualityDelegate,
)
比如显示一张网络图片,就可以这样干。
@Composable
fun DisplayPicture() {
AsyncImage(
model = "https://img2.huashi6.com/images/resource/2020/07/12/h82924904p0.jpg",
contentDescription = null
)
}
支持设置占位图,过程监听,裁剪等
@Composable
fun DisplayPicture() {
AsyncImage(
modifier = Modifier
.clip(CircleShape)
.size(200.dp),
onSuccess = {
Log.i(TAG, "onSuccess")
},
onError = {
Log.i(TAG, "onError")
},
onLoading = {
Log.i(TAG, "onLoading")
},
model = ImageRequest.Builder(LocalContext.current)
.data("https://img2.huashi6.com/images/resource/2020/07/12/h82924904p0.jpg")
.crossfade(true)
.placeholder(R.drawable.default_image)
.error(R.drawable.default_image)
.build(),
contentScale = ContentScale.Crop,
contentDescription = null
)
}
这里介绍一下这个 ContentScale,它是用来指定图片如何适应其容器大小的,有以下几个值:
- ContentScale.FillBounds:图片会被拉伸或压缩以完全填充其容器的宽度和高度,这可能会导致图片的宽高比失真。
- ContentScale.Fit:图片会保持其原始宽高比,并尽可能大地缩放以适应容器,同时确保图片的任一边都不会超出容器的边界,这可能会导致容器的某些部分未被图片覆盖。
- ContentScale.Crop:图片会被裁剪以完全覆盖其容器的宽度和高度,同时保持图片的宽高比,这通常用于需要确保整个容器都被图片覆盖的场景,但可能会丢失图片的一部分内容。
- ContentScale.FillWidth:图片会保持其原始宽高比,并调整其高度以完全填充容器的宽度,这可能会导致图片的高度超出容器的高度,从而被裁剪或需要额外的布局处理。
- ContentScale.FillHeight:图片会保持其原始宽高比,并调整其宽度以完全填充容器的高度,这可能会导致图片的宽度超出容器的宽度,从而需要相应的处理。
- ContentScale.Inside:图片会保持其原始宽高比,并缩放以确保完全位于容器内部,同时其任一边都不会超出容器的边界。
- ContentScale.:图片将以其原始尺寸显示,不会进行任何缩放或裁剪。
来源:juejin.cn/post/7403546034763235378
延迟双删如此好用,为何大厂从来不用
摘要: 在绝大多数介绍缓存与数据库一致性方案的文章中,随着 Cache-aside 模式的数据变更几乎无例外的推荐使用删除缓存的策略,为进一步降低数据不一致的风险通常会配合延迟双删的策略。但是令人意外的是,在一些互联网大厂中的核心业务却很少使用这种方式。这背后的原因是什么呢?延迟双删策略有什么致命缺陷么?以及这些大厂如何选择缓存与数据库一致性保障的策略呢?如果你对此同样抱有有疑问的话,希望本文能为你答疑解惑。
当数据库(主副本)数据记录变更时,为了降低缓存数据不一致状态的持续时间,通常会选择主动 失效 / 更新 缓存数据的方式。绝大多数应用系统的设计方案中会选择通过删除缓存数据的方式使其失效。但同样会出现数据不一致的情况,具体情况参见下图:
所以延迟双删又成为了组合出现的常见模式。延迟双删最复杂的技术实现在于对延迟时间的确定上,间隔时间久的话数据不一致的状态持续时间会变长,如果间隔时间过短可能无法起到一致性保障的作用。所以基于经验会将这个时间设定在秒级,如 1-2 秒后执行第二次删除操作。
延迟双删的致命缺陷
但是延迟时间最大的问题不在于此,而是两次删除缓存数据引起的缓存穿透,短时间对数据库(主副本)造成的流量与负载压力。绝大多数应用系统本身流量与负载并不高,使用缓存通常是为了提升系统性能表现,数据库(主副本)完全可以承载一段时间内的负载压力。对于此类系统延迟双删是一个完全可以接受的高性价比策略。
现实世界中的系统响应慢所带来的却是流量的加倍上涨。回想一下当你面对 App 响应慢的情况,是如何反应与对待便能明白,几乎所有用户的下意识行为都是如出一辙。
所以对于那些流量巨大的应用系统而言,短时的访问流量穿透缓存访问数据库(主副本),恐怕很难接受。为了应对这种流量穿透的情况,通常需要增加数据库(主副本)的部署规格或节点。而且这类应用系统的响应变慢的时候,会对其支持系统产生影响,如果其支持系统较多的情况下,会存在影响的增溢。相比延迟双删在技术实现上带来高效便捷而言,其对系统的影响与副作用则变得不可忽视。
Facebook(今 Meta)解决方案
早在 2013 年由 Facebook(今 Meta)发表的论文 “Scaling Memcache at Facebook” 中便提供了其内部的解决方案,通过提供一种类似 “锁” 的 “leases”(本文译为“租约”)机制防止并发带来的数据不一致现象。
租约机制实现方法大致如下:
当有多个请求抵达缓存时,缓存中并不存在该值时会返回给客户端一个 64 位的 token ,这个 token 会记录该请求,同时该 token 会和缓存键作为绑定,该 token 即为上文中租约的值,客户端在更新时需要传递这个 token ,缓存验证通过后会进行数据的存储。其他请求需要等待这个租约过期后才可申请新的租约。
可结合下图辅助理解其作用机制。也可阅读缓存与主副本数据一致性系统设计方案(下篇)一文中的如何解决并发数据不一致,又能避免延迟双删带来的惊群问题章节进一步了解。
简易参考实现
接下来我们以 Redis 为例,提供一个 Java 版本的简易参考实现。本文中会给出实现所涉及的关键要素与核心代码,你可以访问 Github 项目 来了解整个样例工程,并通过查阅 Issue 与 commits 来了解整个样例工程的演化进程。
要想实现上述租约机制,需要关注的核心要素有三个:
- 需要复写 Redis 数据获取操作,当 Redis 中数据不存在时增加对租约的设置;
- 需要复写 Redis 数据设置操作,当设置 Redis 中数据时校验租约的有效性;
- 最后是当数据库(主副本)数据变更时,删除 Redis 数据同时要连带删除租约信息。
同时为了保障 Redis 操作的原子性,我们需要借助 Lua 脚本来实现上述三点。这里以字符串类型为例,对应脚本分别如下:
Redis 数据获取操作
返回值的第二个属性作为判断是否需要执行数据获取的判断依据。当为 false 时表示 Redis 中无对应数据,需要从数据库中加载,同时保存了当前请求与 key 对应的租约信息。
local key = KEYS[1]
local token = ARGV[1]
local value = redis.call('get', key)
if not value then
redis.replicate_commands()
local lease_key = 'lease:'..key
redis.call('set', lease_key, token)
return {false, false}
else
return {value, true}
end
Redis 数据设置操作
返回值的第二个属性作为判断是否成功执行数据设置操作的依据。该属性为 false 表示租约校验失败,未成功执行数据设置操作。同时意味着有其他进程/线程 执行数据查询操作并对该 key 设置了新的租约。
local key = KEYS[1]
local token = ARGV[1]
local value = ARGV[2]
local lease_key = 'lease:'..key
local lease_value = redis.call('get', lease_key)
if lease_value == token then
redis.replicate_commands()
redis.call('set', key, value)
return {value, true}
else
return {false, false}
end
Redis 数据删除操作
当数据库变更进程/线程 完成数据变更操作后,尝试删除缓存需要同时清理对应数据记录的 key 以及其关联租约 key。防止数据变更前的查询操作通过租约校验,将旧数据写入 Redis 。
local key = KEYS[1]
local token = ARGV[1]
local lease_key = 'lease:'..key
redis.call('del', key, leask_key)
该方案主要的影响在应用层实现,主要在集中在三个方面:
- 应用层不能调用 Redis 数据类型的原始操作命令,而是改为调用 EVAL 命令;
- 调用 Redis 返回结果数据结构的变更为数组,需要解析数组;
- 应用层对于 Redis 的操作变复杂,需要生成租约用的 token,并根据每个阶段返回结果进行后续处理;
为应对上述三点变化,对应操作 Redis 的 Java 实现如下:
封装返回结果
为便于后续操作,首先是对脚本返回结果的封装。
public class EvalResult {
String value;
boolean effect;
public EvalResult(List<?> args) {
value = (String) args.get(0);
if (args.get(1) == null) {
effect = false;
} else {
effect = 1 == (long) args.get(1);
}
}
}
组件设计
封装 Redis 操作
因为在样例工程中独立出了一个 Query Engine 组件,所以需要跨组件传递 token,这里为了实现简单采用了 ThreadLocal 进行 token 的传递,具体系统可查阅样例工程中的用例。
public class LeaseWrapper extends Jedis implements CacheCommands {
private final Jedis jedis;
private final TokenGenerator tokenGenerator;
private final ThreadLocal<String> tokenHolder;
public LeaseWrapper(Jedis jedis) {
this.jedis = jedis;
this.tokenHolder = new ThreadLocal<>();
this.tokenGenerator = () -> UUID.randomUUID().toString();
}
@Override
public String get(String key) {
String token = this.tokenGenerator.get();
tokenHolder.set(token);
Object result = this.jedis.eval(LuaScripts.leaseGet(), List.of(key), List.of(token));
EvalResult er = new EvalResult((List<?>) result);
if (er.effect()) {
return er.value();
}
return null;
}
@Override
public String set(String key, String value) {
String token = tokenHolder.get();
tokenHolder.remove();
Object result = this.jedis.eval(LuaScripts.leaseSet(), List.of(key), List.of(token, value));
EvalResult er = new EvalResult((List<?>) result);
if (er.effect()) {
return er.value();
}
return null;
}
}
补充
在上面的简易参考实现中,我们并没有实现其他请求需要等待这个租约过期后才可申请新的租约。该功能主要是防止惊群问题,进一步降低可能对数据库造成的访问压力。要实现该功能需要在 Redis 数据获取操作中改进脚本:
local key = KEYS[1]
local token = ARGV[1]
local value = redis.call('get', key)
if not value then
redis.replicate_commands()
local lease_key = 'lease:'..key
local current_token = redis.call('get', lease_key)
if not current_token or token == current_token then
redis.call('set', lease_key, token)
return {token, false}
else
return {current_token, false}
end
else
return {value, true}
end
同时也可以为租约数据设定一个短时 TTL,并在应用层通过对 EvalResult 的 effect 判断为 false 的情况下等待一段时间后再次执行。
上述实现的复杂点在于租约过期的时间的选取,以及超过设定时间的逻辑处理。我们可以实现类似自旋锁的机制,在最大等待时间内随时等待一个间隙向 Redis 发起查询请求,超过最大等待时间后直接查询数据库(主副本)获取数据。
Uber 解决方案
在 Uber 今年 2 月份发表的一篇技术博客 “How Uber Serves Over 40 Million Reads Per Second from Online Storage Using an Integrated Cache” 中透露了其内部的解决方案,通过比对版本号的方式避免将旧数据写入缓存。
版本号比对机制实现方法大致如下:
将数据库中行记录的时间戳作为版本号,通过 Lua 脚本通过 Redis EVAL 命令提供类似 MSET 的更新操作,基于自定义编解码器提取 Redis 记录中的版本号,在执行数据设置操作时进行比对,只写入较新的数据。
其中 Redis 的数据记录对应的 Key-Value 编码格式如所示:
简易参考实现
接下来我们以 Redis 为例,提供一个 Java 版本的简易参考实现。本文中会给出实现所涉及的关键要素与核心代码,你可以访问 Github 项目 来了解整个样例工程,并通过查阅 Issue 与 commits 来了解整个样例工程的演化进程。
我们这里不采取定制数据格式,而是通过额外的缓存 Key 存储数据版本,要想实现类似版本号比对机制,需要关注的核心要素有两个:
- 需要复写 Redis 数据设置操作,当设置 Redis 中数据时校验版本号;
- 在版本号比对通过后需要绑定版本号数据,与主数据同步写入 Redis 中。
同时为了保障 Redis 操作的原子性,我们需要借助 Lua 脚本来实现上述两点。这里以字符串类型为例,对应脚本分别如下:
Redis 数据设置操作
返回值的第二个属性作为判断是否成功执行数据设置操作的依据。该属性为 false 表示数据未成功写入 Redis。同时意味当前 进程/线程 执行写入的数据为历史数据,在次过程中数据已经发生变更并又其他数据写入。
local key = KEYS[1]
local value = ARGV[1]
local current_version = ARGV[2]
local version_key = 'version:'..key
local version_value = redis.call('get', version_key)
if version_value == false or version_value < current_version then
redis.call('mset', version_key, current_version, key, value)
return {value, true}
else
return {false, false}
end
该方案主要的影响在应用层实现,需要在调用 Redis 的 EVAL 命令前从数据实体中提取时间戳作为版本号,同时需要保障数据实体中包含时间戳相关属性。
封装 Redis 操作
结合我们的样例工程代码,我们通过实现 VersionWrapper 对 Redis 的操作进行如下封装。
public class VersionWrapper extends Jedis implements CacheCommands {
private final Jedis jedis;
public VersionWrapper(Jedis jedis) {
this.jedis = jedis;
}
@Override
public String set(String key, String value, String version) {
Object result = this.jedis.eval(LuaScripts.versionSet(), List.of(key), List.of(value, version));
EvalResult er = new EvalResult((List<?>) result);
if (er.effect()) {
return er.value();
}
return null;
}
}
补充
透过该方案我们推测 Uber 采取的并非数据变更后删除缓存的策略,很可能是更新缓存的策略(在 Uber 的技术博客中也间接的提到了更新缓存的策略)。
因为整个版本号比对的方式与删除缓存的逻辑相悖。我们抛开 Uber CacheFront 的整体架构,仅仅将该方案应用在简单架构模型中。采取删除缓存的策略,可能会产生如下图所示的结果,此时应用服务 Server - 2 因为查询缓存未获取到值,而从数据库加载并写入缓存,但是此时缓存中写入的为历史旧值,而在该数据过期前或者下次数据变更前,都不会再触发更新了。
当然对于更新缓存的策略同样面临这个问题,因为当数据变更发生期间,缓存中并没有该数据记录时,通常我们不会采取主动刷新缓存的策略,那么则依然会面对上面的问题。
而 Uber 的 CacheFront 基于企业内部的 Flux 技术组件实现对缓存的异步处理,通过阅读文章我们也可以发现这个异步延迟在秒级,那么在如此长的时间间隙后,无论采用删除还是更新策略想要产生上图中的不一致现象都比较难,因为对应用系统来说,进程/线程阻塞 2-3 秒是很难以忍受的现象,所以通常不会出现如此漫长的阻塞与卡顿。
如果你想进一步了解如何实现与 Uber 利用 Flux 实现缓存异步处理的内容,也可阅读我们此前缓存与主副本数据一致性系统设计方案(下篇)文章中更新主副本数据后更新缓存并发问题解决方案章节。
总结
本文并非对延迟双删的全盘否定,而是强调在特殊场景下,延迟双删策略的弊端会被放大,进而完全盖过其优势。对于那些业务体量大伴随着流量大的应用系统,必应要从中权衡取舍。
每一种策略都仅适配应用系统生命周期的一段。只不过部分企业随着业务发展逐步壮大,其研发基础设施的能力也更完善。从而为系统设计带来诸多便捷,从而使得技术决策变得与中小研发团队截然不同。
所以当我们在学习他人经验的过程中,到了落地执行环节一定要结合实际团队背景、业务需求、开发周期与资金预算进行灵活适配。如果你希望了解更多技术中立(排除特定基础设施)的系统设计方案,欢迎你关注我的账号或订阅我的系统设计实战:常用架构与模式详解专栏,我将在其中持续更新技术中立的系统设计系列文章。如果您发现文章内容中任何不准确或遗漏的部分。非常希望您能评论指正,我将尽快修正疏漏,为大家提供优质技术内容。
相关阅读
- 缓存与主副本数据一致性系统设计方案
- System-Design-Codebase
- Scaling Memcache at Facebook
- How Uber Serves Over 40 Million Reads Per Second from Online Storage Using an Integrated Cache
你好,我是 HAibiiin,一名探索技术之外更多可能性的 Product Engineer。如果本篇文章对你有所启发或提供了一定价值,还请不要吝啬点赞、收藏和关注。
来源:juejin.cn/post/7447033901657096202
我这🤡般的7年开发生涯
前两天线上出了个漏洞,导致线上业务被薅了 2w 多块钱。几天晚上没咋睡,问 ChatGPT,查了几晚资料,复盘工作这么久来犯下的错误。
我在公司做的大部分是探索性、创新性的需求,行内人都知道这些活都是那种脏活累活,需求变化大,经常一句话;需求功能多,看着简单一细想全是漏洞;需求又紧急,今天不上线业务就要没。
所以第一个建议就是大家远离这些需求,否则你会和我一样变得不幸。
但是👴🐂🍺啊,接下来也就算了,还全干完了。正常评估一个月的需求,我 tm 半个月干完上线;你给我一句话,我干完一整条链路上的事;你说必须今天上线,那就加班加点干上线。
就这样干了几年,黄了很多,也有做起来的。但是不管业务怎么发展,这样做时间长了会出现很多致命问题。
开发忙成狗
一句话需求太多,到最后只有开发最了解业务,所有人所有事都来找开发,开发也是人,开发还要写代码呢。最先遇到的问题就是时间严重不够,产品跟个摆设一样,什么忙都帮不上,我成了产品开发结合体。
bug 来了
开发一忙,节奏就乱了,乱则生 bug,再加上原本需求上逻辑不完整的深坑,坑上叠坑,出 bug 是迟早的事。
形象崩塌
一旦出现 bug,人设就毁了。记住一句话,没人会感谢你把原本一个月的需求只用半个月上线,大家都觉得这玩意本来就半个月工时。慢慢的开始以半个月的工时要求你。
那些 bug 自己回头,慢慢做都是可以避免的,就像考试的时候做完了卷子复查一遍,很多问题回头看一下都能发现,结果因为前期赶工,没时间回看,而且有很多图快的写法,后期都是容易出问题的。
形象崩塌在职场中是最恐怖的,正所谓好事不出门,坏事传千里。
一旦出了问题,团队、领导、所有人对你的体感,那都是直线下降,你之前做的所有好事,就跟消失了一样,别人对你的印象,一提起来说的都是,这不是当时写出 xxx bug 的人吗?这还怎么在职场生存?脸都没了,项目好处也跟自己没关系了。
我 tm 真是愣头青啊蠢的💊💩,从入职开始都想的是多学点多干点,结果干的越多错的越多,现在心态干崩了,身体干垮了,钱还没混子多,还背了一身骂名和黑锅。
之前我看同事写代码贼慢,鼠标点来点去,打字也慢一拍,我忍不住说他你这写代码速度太慢了,可以用 xxx 快捷键等等,现在回想起来,我说他不懂代码,其实是我不懂职场。
我真是个纯纯的可悲🤡。
提桶跑路
bug 积累到一定程度,尤其是像我这样出现点资金的问题,那也差不多离走人不远了,我感觉我快到这个阶段了,即使不走,扣钱扣绩效也是在所难免的,综合算下来,还没那些混子赚的多。
我亲自接触的联调一哥们儿,一杯茶,一包烟,一个 bug 修一天。是真真正正的修了一天,从早到晚。那天我要上线那个需求,我不停的催他,后来指着代码说着逻辑让他写,最终半夜转点上线。我累的半死不活,我工资和他差不多,出了问题我还要背锅。
我现在听到 bug 都 PTSD 了,尤其是资金相关的,整个人就那种呆住,大脑空白,心脏像被揪住,我怀疑我有点心理问题了都。
为什么别人可以那么安心的摸鱼?为什么我要如此累死累活还不讨好?我分析出几点我的性格问题。
责任心过强
什么事都觉得跟自己有关系,看着别人做的不好,我就自己上手。
到后期产品真 tm 一句话啊,逻辑也不想,全等着我出开发方案,产品流程图,我再告诉她哪里要改动。不是哥们?合着我自己给出需求文档再自己写代码?
为人老实
不懂拒绝,不懂叫板。
运营的需求,来什么做什么,说什么时候上线就什么时候上线。不是哥们?我都还不知道要做什么,你们把上线时间都定了?就 tm 两字,卑微。
用力过猛
十分力恨不得使出十一分,再加一分吃奶的劲儿。一开始就领导很高的期望,后面活越来越多,而且也没什么晋升机会了,一来的门槛就太高了知道吧,再想提升就很难了。
先总结这么多吧,我现在心情激荡的很,希望给各位和我性格差不多一点提醒,别像我这样愣头青,吃力不讨好,还要遭人骂。后面再写写改进办法。
来源:juejin.cn/post/7450047052804161576
被阿里抛弃的那个项目,救活了!
众所周知,上个月的时候,GitHub 知名开源项目 EasyExcel 曾对外发布公告将停止主动更新,当时这个消息在网上还引发了不少讨论。
至此,这个运营了 6 年,在 GitHub 上累计收获 32k+ star 的知名项目基本就宣告停更了,大家都觉得挺可惜的。
然而,就在阿里官宣停更的同时,EasyExcel 的原作者个人当即也站出来向大家透露了一个新项目计划,表示自己将会继续接手,并重启一个新项目开源出来。
那现在,这个承诺已经如期兑现了!
就在上周,EasyExcel 作者自己也正式发文表示,EasyExcel 的替代方案正式来了,相信不少同学也看到了这个消息。
作者把新项目定名为:FastExcel(ps:作者一开始初定叫 EasyExcel-Plus,后来改名为了 FastExcel),用于取代已经被阿里官方停更的 EasyExcel 项目。
新项目 FastExcel 同样定位在一个基于 Java 的、快速、简洁、解决大文件内存溢出的 Excel 处理工具。
并且新项目将兼容老的已停更的 EasyExcel 项目,并提供项目维护、性能优化、以及bugFix。
同时作者还表示,新项目 FastExcel 将始终坚持免费开源,并采用最开放的 MIT 协议,使其适用于任何商业化场景,以便为广大开发者和企业继续提供极大的自由度和灵活性。
不得不说,作者的格局还是相当打开的。
大家都知道,其实 EasyExcel 项目的作者原本就是工作在阿里,负责主导并维护着这个项目。
然而就在去年,Easyexcel 作者就从阿里离职出来创业了。
所以在上个月 EasyExcel 被宣布停更的时候,当时就有不少网友猜测各种原因。当然背后的真相我们不得而知,不过作为用户的角度来看,Easyexcel 以另外一种形式被继续开源和更新也何尝不是一件利好用户的好消息。
之前的 EasyExcel 之所以受到开发者们的广泛关注,主要是因为它具备了许多显著的特点和优势,而这次的新项目 FastExcel 更可谓是有过之而无不及。
- 首先,FastExcel 同样拥有卓越的读写性能,能够高效地处理大规模的Excel数据,这对于需要处理大量数据的开发者来说依然是一大福音。
- 其次,FastExcel 的 API设计简洁直观,开发者可以轻松上手,无需花费大量时间学习和熟悉。
- 再者,FastExcel 同样支持流式读取,可以显著降低内存占用,避免在处理大规模数据时可能出现的内存溢出问题。
- 此外,新项目 FastExcel 完全兼容原来 EasyExcel 的功能和特性,用户可以在项目中无缝过渡,从 EasyExcel 迁移到 FastExcel 只需更换包名和依赖即可完成升级。
FastExcel 的安装配置也非常简单。
对于使用 Maven 或 Gradle 进行构建的项目来说,只需在相应的配置文件中添加如下所示的 FastExcel 的依赖即可。
- Maven项目
<dependency>
<groupId>cn.idev.excel</groupId>
<artifactId>fastexcel</artifactId>
<version>1.0.0</version>
</dependency>
- Gradle项目
dependencies {
implementation 'cn.idev.excel:fastexcel:1.0.0'
}
在实际使用中,以读取Excel文件为例,开发者只需定义一个数据类和一个监听器类,然后在主函数中调用 FastExcel 的读取方法,并传入数据类和监听器类即可。
FastExcel 会自动解析 Excel 文件中的数据,并将其存储到数据类的实例中,同时触发监听器类中的方法,让开发者可以对解析到的数据进行处理。
// 实现 ReadListener 接口,设置读取数据的操作
public class DemoDataListener implements ReadListener<DemoData> {
@Override
public void invoke(DemoData data, AnalysisContext context) {
System.out.println("解析到一条数据" + JSON.toJSONString(data));
}
@Override
public void doAfterAllAnalysed(AnalysisContext context) {
System.out.println("所有数据解析完成!");
}
}
public static void main(String[] args) {
String fileName = "demo.xlsx";
// 读取 Excel 文件
FastExcel.read(fileName, DemoData.class, new DemoDataListener()).sheet().doRead();
}
同样地,写 Excel 文件也非常简单,开发者只需定义一个数据类,并填充要写入的数据,然后调用 FastExcel 的写入方法即可。
// 示例数据类
public class DemoData {
@ExcelProperty("字符串标题")
private String string;
@ExcelProperty("日期标题")
private Date date;
@ExcelProperty("数字标题")
private Double doubleData;
@ExcelIgnore
private String ignore;
}
// 填充要写入的数据
private static List<DemoData> data() {
List<DemoData> list = new ArrayList<>();
for (int i = 0; i < 10; i++) {
DemoData data = new DemoData();
data.setString("字符串" + i);
data.setDate(new Date());
data.setDoubleData(0.56);
list.add(data);
}
return list;
}
public static void main(String[] args) {
String fileName = "demo.xlsx";
// 创建一个名为“模板”的 sheet 页,并写入数据
FastExcel.write(fileName, DemoData.class).sheet("模板").doWrite(data());
}
过程可谓是清晰易懂、直观明了,所以这对于开发者来说,在使用 FastExcel 时可以轻松上手。
新项目 FastExcel 刚开源不久,目前在 GitHub 上的 star 标星就已经突破了 2000!这也可见其受欢迎程度。
而且伴随着新项目的开源上线,开发者们的参与热情也是十分高涨的。
这才多少天,项目就已经收到上百条issue了。
仔细看了一下会发现,其中一大部分是开发者们对于新项目所提的需求或反馈。
而还有另外一部分则是对于新项目 FastExcel 以及作者的肯定与鼓励。
文章的最后也感谢项目作者的辛勤维护,大家有需要的话也可以上去提需求或者反馈一些意见,另外感兴趣的同学也可以上去研究研究相关的代码或者参与项目,尤其是数据处理这一块,应该会挺有收获的。
注:本文在GitHub开源仓库「编程之路」 github.com/rd2coding/R… 中已经收录,里面有我整理的6大编程方向(岗位)的自学路线+知识点大梳理、面试考点、我的简历、几本硬核pdf笔记,以及程序员生活和感悟,欢迎star。
来源:juejin.cn/post/7450088304001728523
面试官:GROUP BY和DISTINCT有什么区别?
在 MySQL 中,GR0UP BY 和 DISTINCT 都是用来处理查询结果中的重复数据,并且在官方的描述文档中也可以看出:在大多数情况下 DISTINCT 是特殊的 GR0UP BY,如下图所示:
官方文档地址:dev.mysql.com/doc/refman/…
但二者还是有一些细微的不同,接下来一起来看。
1.DISTINCT 介绍
- 用途:DISTINCT 用于从查询结果中去除重复的行,确保返回的结果集中每一行都是唯一的。
- 语法:通常用于 SELECT 语句中,紧跟在 SELECT 关键字之后。例如以下 SQL:
SELECT DISTINCT column1, column2 FROM table_name;
- 工作机制:DISTINCT 会对整个结果集进行去重,即只要结果集中的某一行与另一行完全相同,就会被去除。
2.GR0UP BY 介绍
- 用途:GR0UP BY 主要用于对结果集按照一个或多个列进行分组,通常与聚合函数(如 COUNT, SUM, AVG, MAX, MIN 等)一起使用,以便对每个组进行统计。
- 语法:GR0UP BY 通常跟在 FROM 或 WHERE 子句之后,在 SELECT 语句的末尾部分。例如以下 SQL:
SELECT column1, COUNT(*) FROM table_name GR0UP BY column1;
- 工作机制:GR0UP BY 将数据按指定的列进行分组,每个组返回一行数据。
3.举例说明
3.1 使用 DISTINCT
假设有一个表 students,包含以下数据:
id | name | age |
---|---|---|
1 | Alice | 20 |
2 | Bob | 22 |
3 | Alice | 20 |
使用 DISTINCT 去除重复行:
SELECT DISTINCT name, age FROM students;
结果:
name | age |
---|---|
Alice | 20 |
Bob | 22 |
3.2 使用 GR0UP BY
假设还是上面的表 students,我们想要统计每个学生的数量:
SELECT name, COUNT(*) AS count FROM students GR0UP BY name;
结果:
name | count |
---|---|
Alice | 2 |
Bob | 1 |
4.主要区别
- 功能不同:DISTINCT 用于去除重复行,而 GR0UP BY 用于对结果集进行分组,通常与聚合函数一起使用。
- 返回结果不同:DISTINCT 返回去重后的结果集,查询结果集中只能包含去重的列信息,有其他列信息会报错;GR0UP BY 返回按指定列分组后的结果集,可以展示多列信息,并可以包含聚合函数的计算结果。
- 应用场景不同:DISTINCT 更适合单纯的去重需求,GR0UP BY 更适合分组统计需求。
- 性能略有不同:如果去重的字段有索引,那么 GR0UP BY 和 DISTINCT 都可以使用索引,此情况它们的性能是相同的;而当去重的字段没有索引时,DISTINCT 的性能就会高于 GR0UP BY,因为在 MySQL 8.0 之前,GR0UP BY 有一个隐藏的功能会进行默认的排序,这样就会触发 filesort 从而导致查询性能降低。
课后思考
count(*)、count(1) 和 count(字段) 有什么区别?
本文已收录到我的面试小站 http://www.javacn.site,其中包含的内容有:Redis、JVM、并发、并发、MySQL、Spring、Spring MVC、Spring Boot、Spring Cloud、MyBatis、设计模式、消息队列等模块。
来源:juejin.cn/post/7415914114650685481
2024年的安卓现代开发
大家好 👋🏻, 新的一年即将开始, 我不想错过这个机会, 与大家分享一篇题为《2024年的安卓现代开发》的文章. 🚀
如果你错过了我的上一篇文章, 可以看看2023年的安卓现代开发并回顾一下今年的变化.
免责声明
📝 本文反映了我的个人观点和专业见解, 并参考了 Android 开发者社区中的不同观点. 此外, 我还定期查看 Google 为 Android 提供的指南.
🚨 需要强调的是, 虽然我可能没有明确提及某些引人注目的工具, 模式和体系结构, 但这并不影响它们作为开发 Android 应用的宝贵替代方案的潜力.
Kotlin 无处不在 ❤️
Kotlin是由JetBrains开发的一种编程语言. 由谷歌推荐, 谷歌于 2017 年 5 月正式发布(查看这里). 它是一种与 Java 兼容的现代编程语言, 可以在 JVM 上运行, 这使得它在 Android 应用开发中的采用非常迅速.
无论你是不是安卓新手, 都应该把 Kotlin 作为首选, 不要逆流而上 🏊🏻 😎, 谷歌在 2019 年谷歌 I/O 大会上宣布了这一做法. 有了 Kotlin, 你就能使用现代语言的所有功能, 包括协程的强大功能和使用为 Android 生态系统开发的现代库.
请查看Kotlin 官方文档
Kotlin 是一门多用途语言, 我们不仅可以用它来开发 Android 应用, 尽管它的流行很大程度上是由于 Android 应用, 我们可以从下图中看到这一点.
KotlinConf ‘23
Kotlin 2.0 要来了
另一个需要强调的重要事件是Kotlin 2.0
的发布, 它近在眼前. 截至本文发稿之日, 其版本为 2.0.0-beta4
新的 K2 编译器 也是 Kotlin 2.0 的另一个新增功能, 它将带来显著的性能提升, 加速新语言特性的开发, 统一 Kotlin 支持的所有平台, 并为多平台项目提供更好的架构.
请查看 KotlinConf '23 的回顾, 你可以找到更多信息.
Compose 🚀
Jetpack Compose 是 Android 推荐的用于构建本地 UI 的现代工具包. 它简化并加速了 Android 上的 UI 开发. 通过更少的代码, 强大的工具和直观的 Kotlin API, 快速实现你的应用.
Jetpack Compose 是 Android Jetpack 库的一部分, 使用 Kotlin 编程语言轻松创建本地UI. 此外, 它还与 LiveData 和 ViewModel 等其他 Android Jetpack 库集成, 使得构建具有强反应性和可维护性的 Android 应用变得更加容易.
Jetpack Compose 的一些主要功能包括
- 声明式UI
- 可定制的小部件
- 与现有代码(旧视图系统)轻松集成
- 实时预览
- 改进的性能.
资源:
- Jetpack Compose 文档
- Compose 与 Kotlin 的兼容性图谱
- Jetpack Compose 路线图
- 课程
- Jetpack Compose 中
@Composable
的API 指南
Android Jetpack ⚙️
Jetpack是一套帮助开发者遵循最佳实践, 减少模板代码, 编写在不同Android版本和设备上一致运行的代码的库, 这样开发者就可以专注于他们的业务代码.
其中最常用的工具有:
Material You / Material Design 🥰
Material You 是在 Android 12 中引入并在 Material Design 3 中实现的一项新的定制功能, 它使用户能够根据个人喜好定制操作系统的视觉外观. Material Design 是一个由指南, 组件和工具组成的可调整系统, 旨在坚持UI设计的最高标准. 在开源代码的支持下, Material Design 促进了设计师和开发人员之间的无缝协作, 使团队能够高效地创造出令人惊叹的产品.
目前, Material Design 的最新版本是 3, 你可以在这里查看更多信息. 此外, 你还可以利用Material Theme Builder来帮助你定义应用的主题.
代码仓库
SplashScreen API
Android 中的SplashScreen API对于确保应用在 Android 12 及更高版本上正确显示至关重要. 不更新会影响应用的启动体验. 为了在最新版本的操作系统上获得一致的用户体验, 快速采用该 API 至关重要.
Clean架构
Clean架构
的概念是由Robert C. Martin提出的. 它的基础是通过将软件划分为不同层次来实现责任分离.
特点
- 独立于框架.
- 可测试.
- 独立于UI
- 独立于数据库
- 独立于任何外部机构.
依赖规则
作者在他的博文Clean代码中很好地描述了依赖规则.
依赖规则是使这一架构得以运行的首要规则. 这条规则规定, 源代码的依赖关系只能指向内部. 内圈中的任何东西都不能知道外圈中的任何东西. 特别是, 外圈中声明的东西的名称不能被内圈中的代码提及. 这包括函数, 类, 变量或任何其他命名的软件实体.
- 博文Clean代码
安卓中的Clean架构
:
Presentation 层: Activitiy, Composable, Fragment, ViewModel和其他视图组件.
Domain层: 用例, 实体, 仓库接口和其他Domain组件.
Data层: 仓库的实现类, Mapper, DTO 等.
Presentation层的架构模式
架构模式是一种更高层次的策略, 旨在帮助设计软件架构, 其特点是在一个可重复使用的框架内为常见的架构问题提供解决方案. 架构模式与设计模式类似, 但它们的规模更大, 解决的问题也更全面, 如系统的整体结构, 组件之间的关系以及数据管理的方式等.
在Presentation层中, 我们有一些架构模式, 我想重点介绍以下几种:
- MVVM
- MVI
我不想逐一解释, 因为在互联网上你可以找到太多相关信息. 😅
此外, 你还可以查看应用架构指南.
依赖注入
依赖注入是一种软件设计模式, 它允许客户端从外部获取依赖关系, 而不是自己创建依赖关系. 它是一种在对象及其依赖关系之间实现控制反转(IoC)的技术.
模块化
模块化是一种软件设计技术, 可将应用划分为独立的模块, 每个模块都有自己的功能和职责.
模块化的优势
可重用性: 拥有独立的模块, 就可以在应用的不同部分甚至其他应用中重复使用.
严格的可见性控制: 模块可以让你轻松控制向代码库其他部分公开的内容.
自定义交付: Play特性交付 使用应用Bundle的高级功能, 允许你有条件或按需交付应用的某些功能.
可扩展性: 通过独立模块, 可以添加或删除功能, 而不会影响应用的其他部分.
易于维护: 将应用划分为独立的模块, 每个模块都有自己的功能和责任, 这样就更容易理解和维护代码.
易于测试: 有了独立的模块, 就可以对它们进行隔离测试, 从而便于发现和修复错误.
改进架构: 模块化有助于改进应用的架构, 从而更好地组织和结构代码.
改善协作: 通过独立的模块, 开发人员可以不受干扰地同时开发应用的不同部分.
构建时间: 一些 Gradle 功能, 如增量构建, 构建缓存或并行构建, 可以利用模块化提高构建性能.
更多信息请参阅官方文档.
网络
序列化
在本节中, 我想提及两个我认为非常重要的工具: 与 Retrofit 广泛结合使用的Moshi, 以及 JetBrains 的 Kotlin 团队押宝的Kotlin Serialization.
Moshi 和 Kotlin Serialization 是用于 Kotlin/Java 的两个序列化/反序列化库, 可将对象转换为 JSON 或其他序列化格式, 反之亦然. 这两个库都提供了友好的接口, 并针对移动和桌面应用进行了优化. Moshi 主要侧重于 JSON 序列化, 而 Kotlin Serialization 则支持包括 JSON 在内的多种序列化格式.
图像加载
要从互联网上加载图片, 有几个第三方库可以帮助你处理这一过程. 图片加载库为你做了很多繁重的工作; 它们既处理缓存(这样你就不用多次下载图片), 也处理下载图片并将其显示在屏幕上的网络逻辑.
_ 官方安卓文档
响应/线程管理
说到响应式编程和异步进程, Kotlin协程凭借其suspend函数和Flow脱颖而出. 然而, 在 Android 应用开发中, 承认 RxJava 的价值也是至关重要的. 尽管协程和 Flow 的采用率越来越高, 但 RxJava 仍然是多个项目中稳健而受欢迎的选择.
对于新项目, 请始终选择Kotlin协程
❤️. 可以在这里探索一些Kotlin协程相关的概念.
本地存储
在构建移动应用时, 重要的一点是能够在本地持久化数据, 例如一些会话数据或缓存数据等. 根据应用的需求选择合适的存储选项非常重要. 我们可以存储键值等非结构化数据, 也可以存储数据库等结构化数据. 请记住, 这一点并没有提到我们可用的所有本地存储类型(如文件存储), 只是提到了允许我们保存数据的工具.
建议:
- S̶h̶a̶r̶e̶d̶P̶r̶e̶f̶e̶r̶e̶n̶c̶e̶s̶
- DataStore
- EncryptedSharedPreferences
测试 🕵🏼
软件开发中的测试对于确保产品质量至关重要. 它能检测错误, 验证需求并确保客户满意. 下面是一些最常用的测试工具::
截屏测试 📸
Android 中的截屏测试涉及自动捕获应用中各种 UI 元素的屏幕截图, 并将其与基线图像进行比较, 以检测任何意外的视觉变化. 它有助于确保不同版本和配置的应用具有一致的UI外观, 并在开发过程的早期捕捉视觉回归.
R8 优化
R8 是默认的编译器, 可将项目的 Java 字节码转换为可在 Android 平台上运行的 DEX 格式. 通过缩短类名及其属性, 它可以帮助我们混淆和减少应用的代码, 从而消除项目中未使用的代码和资源. 要了解更多信息, 请查看有关 缩减, 混淆和优化应用 的 Android 文档. 此外, 你还可以通过ProGuard
规则文件禁用某些任务或自定义 R8 的行为.
- 代码缩减
- 缩减资源
- 混淆
- 优化
第三方工具
- DexGuard
Play 特性交付
Google Play 的应用服务模式称为动态交付, 它使用 Android 应用Bundle为每个用户的设备配置生成并提供优化的 APK, 因此用户只需下载运行应用所需的代码和资源.
自适应布局
随着具有不同外形尺寸的移动设备使用量的增长, 我们需要一些工具, 使我们的 Android 应用能够适应不同类型的屏幕. 因此, Android 为我们提供了Window Size Class, 简单地说, 就是三大类屏幕格式, 它们是我们开发设计的关键点. 这样, 我们就可以避免考虑许多屏幕设计的复杂性, 从而将可能性减少到三组, 它们是 Compat, Medium和Expanded.
Window Size Class
我们拥有的另一个重要资源是Canonical Layout, 它是预定义的屏幕设计, 可用于 Android 应用中的大多数场景, 还为我们提供了如何将其适用于大屏幕的指南.
其他相关资源
本地化 🌎
本地化包括调整产品以满足不同地区不同受众的需求. 这包括翻译文本, 调整格式和考虑文化因素. 其优势包括进入全球市场, 增强用户体验, 提高客户满意度, 增强在全球市场的竞争力以及遵守当地法规.
注: BCP 47 是安卓系统使用的国际化标准.
参考资料
性能 🔋⚙️
在开发 Android 应用时, 我们必须确保用户体验更好, 这不仅体现在应用的开始阶段, 还体现在整个执行过程中. 因此, 我们必须使用一些工具, 对可能影响应用性能的情况进行预防性分析和持续监控:
应用内更新
当你的用户在他们的设备上不断更新你的应用时, 他们可以尝试新功能, 并从性能改进和错误修复中获益. 虽然有些用户会在设备连接到未计量的连接时启用后台更新, 但其他用户可能需要提醒才能安装更新. 应用内更新是 Google Play 核心库的一项功能, 可提示活跃用户更新你的应用*.
运行 Android 5.0(API 等级 21)或更高版本的设备支持应用内更新功能. 此外, 应用内更新仅支持 Android 移动设备, Android 平板电脑和 Chrome OS 设备.
- 应用内更新文档
应用内评论
Google Play 应用内评论 API 可让你提示用户提交 Play Store 评级和评论, 而无需离开你的应用或游戏.
一般来说, 应用内评论流程可在用户使用应用的整个过程中随时触发. 在流程中, 用户可以使用 1-5 星系统对你的应用进行评分, 并添加可选评论. 一旦提交, 评论将被发送到 Play Store 并最终显示出来.
*为保护用户隐私并避免 API 被滥用, 你的应用应严格遵守有关何时请求应用内评论和评论提示的设计的规定.
- 应用内评论文档
可观察性 👀
在竞争日益激烈的应用生态系统中, 要获得良好的用户体验, 首先要确保应用没有错误. 确保应用无错误的最佳方法之一是在问题出现时立即发现并知道如何着手解决. 使用 Android Vitals 来确定应用中崩溃和响应速度问题最多的区域. 然后, 利用 Firebase Crashlytics 中的自定义崩溃报告获取更多有关根本原因的详细信息, 以便有效地排除故障.
工具
辅助功能
辅助功能是软件设计和构建中的一项重要功能, 除了改善用户体验外, 还能让有辅助功能需求的人使用应用. 这一概念旨在改善的不能包括: 有视力问题, 色盲, 听力问题, 灵敏性问题和认知障碍等.
考虑因素:
- 增加文字的可视性(颜色对比度, 可调整文字大小)
- 使用大而简单的控件
- 描述每个UI元素
更多详情请查看辅助功能 - Android 文档
安全性 🔐
在开发保护设备完整性, 数据安全性和用户信任的应用时, 安全性是我们必须考虑的一个方面, 甚至是最重要的方面.
- 使用凭证管理器登录用户: 凭据管理器 是一个 Jetpack API, 在一个单一的 API 中支持多种登录方法, 如用户名和密码, 密码匙和联合登录解决方案(如谷歌登录), 从而简化了开发人员的集成.
- 加密敏感数据和文件: 使用EncryptedSharedPreferences 和EncryptedFile.
- 应用基于签名的权限: 在你可控制的应用之间共享数据时, 使用基于签名的权限.
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.myapp">
<permission android:name="my_custom_permission_name"
android:protectionLevel="signature" />
- 不要将密钥, 令牌或应用配置所需的敏感数据直接放在项目库中的文件或类中. 请使用
local.properties
. - 实施 SSL Pinning: 使用 SSL Pinning 进一步确保应用与远程服务器之间的通信安全. 这有助于防止中间人攻击, 并确保只与拥有特定 SSL 证书的受信任服务器进行通信.
res/xml/network_security_config.xml
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config>
<domain includeSubdomains="true">example.com</domain>
<pin-set expiration="2018-01-01">
<pin digest="SHA-256">ReplaceWithYourPin</pin>
<!-- backup pin -->
<pin digest="SHA-256">ReplaceWithYourPin</pin>
</pin-set>
</domain-config>
</network-security-config>
- 实施运行时应用自我保护(RASP): 这是一种在运行时保护应用免受攻击和漏洞的安全技术. RASP 的工作原理是监控应用的行为, 并检测可能预示攻击的可疑活动:
- 代码混淆.
- 根检测.
- 篡改/应用钩子检测.
- 防止逆向工程攻击.
- 反调试技术.
- 虚拟环境检测
- 应用行为的运行时分析.
想了解更多信息, 请查看安卓应用中的运行时应用自我保护技术(RASP). 还有一些 Android 安全指南.
版本目录
Gradle 提供了一种集中管理项目依赖关系的标准方式, 叫做版本目录; 它在 7.0 版中被试验性地引入, 并在 7.4 版中正式发布.
优点:
- 对于每个目录, Gradle 都会生成类型安全的访问器, 这样你就可以在 IDE 中通过自动补全轻松添加依赖关系.
- 每个目录对构建过程中的所有项目都是可见的. 它是声明依赖项版本的中心位置, 并确保该版本的变更适用于每个子项目.
- 目录可以声明 dependency bundles, 即通常一起使用的 "依赖关系组".
- 目录可以将依赖项的组和名称与其实际版本分开, 而使用版本引用, 这样就可以在多个依赖项之间共享一个版本声明.
请查看更多信息
Secret Gradle 插件
Google 强烈建议不要将 API key 输入版本控制系统. 相反, 你应该将其存储在本地的 secrets.properties
文件中, 该文件位于项目的根目录下, 但不在版本控制范围内, 然后使用 Secrets Gradle Plugin for Android 来读取 API 密钥.
日志
日志是一种软件工具, 用于记录程序的执行信息, 重要事件, 错误调试信息以及其他有助于诊断问题或了解程序运行情况的信息. 日志可配置为将信息写入不同位置, 如日志文件, 控制台, 数据库, 或将信息发送到日志记录服务器.
Linter / 静态代码分析器
Linter 是一种编程工具, 用于分析程序源代码, 查找代码中的潜在问题或错误. 这些问题可能是语法问题, 代码风格不当, 缺乏文档, 安全问题等, 它们会对代码的质量和可维护性产生影响.
Google Play Instant
Google Play Instant 使本地应用和游戏无需安装即可在运行 Android 5.0(API 等级 21)或更高版本的设备上启动. 你可以使用 Android Studio 构建这类体验, 称为即时应用和即时游戏. 通过允许用户运行即时应用或即时游戏(即提供即时体验), 你可以提高应用或游戏的发现率, 从而有助于吸引更多活跃用户或安装.
新设计中心
安卓团队提供了一个新的设计中心, 帮助创建美观, 现代的安卓应用, 这是一个集中的地方, 可以了解安卓在多种形态因素方面的设计.
点击查看新的设计中心
人工智能
Gemini
和PalM 2
是谷歌开发的两个最先进的人工智能(AI)模型, 它们将改变安卓应用开发的格局. 这些模型具有一系列优势, 将推动应用的效率, 用户体验和创新.
人工智能编码助手工具
Studio Bot
Studio Bot 是你的 Android 开发编码助手. 它是 Android Studio 中的一种对话体验, 通过回答 Android 开发问题帮助你提高工作效率. 它采用人工智能技术, 能够理解自然语言, 因此你可以用简单的英语提出开发问题. Studio Bot 可以帮助 Android 开发人员生成代码, 查找相关资源, 学习最佳实践并节省时间.
Github Copilot
GitHub Copilot 是一个人工智能配对程序员. 你可以使用 GitHub Copilot 直接在编辑器中获取整行或整个函数的建议.
Amazon CodeWhisperer
这是亚马逊的一项服务, 可根据你当前代码的上下文生成代码建议. 它能帮助你编写更高效, 更安全的代码, 并发现新的 API 和工具.
Kotlin Multiplatform 🖥 📱⌚️ 🥰 🚀
最后, 同样重要的是, Kotlin Multiplatform 🎉 是本年度最引人注目的新产品. 它是跨平台应用开发领域的有力竞争者. 虽然我们的主要重点可能是 Android 应用开发, 但 Kotlin Multiplatform 为我们提供了利用 KMP 框架制作完全本地 Android 应用的灵活性. 这一战略举措不仅为我们的项目提供了未来保障, 还为我们提供了必要的基础架构, 以便在我们选择多平台环境时实现无缝过渡. 🚀
如果你想深入了解 Kotlin Multiplatform, 我想与你分享这几篇文章. 在这些文章中, 探讨了这项技术的现状及其对现代软件开发的影响:
来源:juejin.cn/post/7342861726000791603
Java 8 魔法:利用 Function 接口告别冗余代码,打造高效断言神器
前言
在 Java
开发的征途中,我们时常与重复代码不期而遇。这些重复代码不仅让项目显得笨重,更增加了维护成本。幸运的是,Java 8
带来了函数式编程的春风,以 Function
接口为代表的一系列新特性,为我们提供了破除这一难题的利剑。本文将以一个实际应用场景为例,即使用 Java 8
的函数式编程特性来重构数据有效性断言逻辑,展示如何通过 SFunction
(基于 Java 8
的 Lambda
表达式封装)减少代码重复,从而提升代码的优雅性和可维护性。
背景故事:数据校验的烦恼
想象一下,在一个复杂的业务系统中,我们可能需要频繁地验证数据库中某个字段值是否有效,是否符合预期值。传统的做法可能充斥着大量相似的查询逻辑,每次都需要手动构建查询条件、执行查询并处理结果,这样的代码既冗长又难以维护。
例如以下两个验证用户 ID 和部门 ID 是否有效的方法,虽然简单,但每次需要校验不同实体或不同条件时,就需要复制粘贴并做相应修改,导致代码库中充满了大量雷同的校验逻辑,给维护带来了困扰。
// 判断用户 ID 是否有效
public void checkUserExistence(String userId) {
User user = userDao.findById(userId);
if (user == null) {
throw new RuntimeException("用户ID无效");
}
}
// 判断部门 ID 是否有效
public void checkDeptExistence(String deptId) {
Dept dept = deptDao.findById(deptId);
if (dept == null) {
throw new RuntimeException("部门ID无效");
}
}
Java 8 的魔法棒:函数式接口
Java 8 引入了函数式接口的概念,其中 Function<T, R>
是最基础的代表,它接受一个类型 T
的输入,返回类型 R
的结果。而在 MyBatis Plus
等框架中常用的 SFunction
是对 Lambda
表达式的进一步封装,使得我们可以更加灵活地操作实体类的属性。
实战演练:重构断言方法
下面的 ensureColumnValueValid
方法正是利用了函数式接口的魅力,实现了对任意实体类指定列值的有效性断言:
/**
* 确认数据库字段值有效(通用)
*
* @param <V> 待验证值的类型
* @param valueToCheck 待验证的值
* @param columnExtractor 实体类属性提取函数
* @param queryExecutor 单条数据查询执行器
* @param errorMessage 异常提示信息模板
*/
public static <T, R, V> void ensureColumnValueValid(V valueToCheck, SFunction<T, R> columnExtractor, SFunction<LambdaQueryWrapper<T>, T> queryExecutor, String errorMessage) {
if (valueToCheck == null) return;
LambdaQueryWrapper<T> wrapper = new LambdaQueryWrapper<>();
wrapper.select(columnExtractor);
wrapper.eq(columnExtractor, valueToCheck);
wrapper.last("LIMIT 1");
T entity = queryExecutor.apply(wrapper);
R columnValue = columnExtractor.apply(entity);
if (entity == null || columnValue == null)
throw new DataValidationException(String.format(errorMessage, valueToCheck));
}
这个方法接受一个待验证的值、一个实体类属性提取函数、一个单行数据查询执行器和一个异常信息模板作为参数。通过这四个参数,不仅能够进行针对特定属性的有效性检查,而且还能生成具有一致性的异常信息。
对比分析
使用 Function
改造前
// 判断用户 ID 是否有效
public void checkUserExistence(String userId) {
User user = userDao.findById(userId);
if (user == null) {
throw new RuntimeException("用户ID无效");
}
}
// 判断部门 ID 是否有效
public void checkDeptExistence(String deptId) {
Dept dept = deptDao.findById(deptId);
if (dept == null) {
throw new RuntimeException("部门ID无效");
}
}
使用 Function
改造后
public void assignTaskToUser(AddOrderDTO dto) {
ensureColumnValueValid(dto.getUserId(), User::getId, userDao::getOne, "用户ID无效");
ensureColumnValueValid(dto.getDeptId(), Dept::getId, deptDao::getOne, "部门ID无效");
ensureColumnValueValid(dto.getCustomerId(), Customer::getId, customerDao::getOne, "客户ID无效");
ensureColumnValueValid(dto.getDeptId(), Dept::getId, deptDao::getOne, "部门ID无效");
ensureColumnValueValid(dto.getSupplieId(), Supplie::getId, supplierDao::getOne, "供应商ID无效");
// 现在可以确信客户存在
Customer cus = customerDao.findById(dto.getCustomerId());
// 创建订单的逻辑...
}
对比上述两段代码,我们发现后者不仅大幅减少了代码量,而且通过函数式编程,表达出更为清晰的逻辑意图,可读性和可维护性都有所提高。
优点
- 减少重复代码: 通过
ensureColumnValueValid
方法,所有涉及数据库字段值有效性检查的地方都可以复用相同的逻辑,将变化的部分作为参数传递,大大减少了因特定校验逻辑而产生的代码量。 - 增强代码复用: 抽象化的校验方法适用于多种场景,无论是用户ID、订单号还是其他任何实体属性的校验,一套逻辑即可应对。
- 提升可读性和维护性: 通过清晰的函数签名和 Lambda 表达式,代码意图一目了然,降低了后续维护的成本。
- 灵活性和扩展性: 当校验规则发生变化时,只需要调整
ensureColumnValueValid
方法或其内部实现,所有调用该方法的地方都会自动受益,提高了系统的灵活性和扩展性。
举一反三:拓展校验逻辑的边界
通过上述的实践,我们见识到了函数式编程在简化数据校验逻辑方面的威力。但这只是冰山一角,我们可以根据不同的业务场景,继续扩展和完善校验逻辑,实现更多样化的校验需求。以下两个示例展示了如何在原有基础上进一步深化,实现更复杂的数据比较和验证功能。
断言指定列值等于预期值
首先,考虑一个场景:除了验证数据的存在性,我们还需确认查询到的某列值是否与预期值相符。这在验证用户角色、状态变更等场景中尤为常见。为此,我们设计了 validateColumnValueMatchesExpected
方法:
/**
* 验证查询结果中指定列的值是否与预期值匹配
*
* @param <T> 实体类型
* @param <R> 目标列值的类型
* @param <C> 查询条件列值的类型
* @param targetColumn 目标列的提取函数,用于获取想要验证的列值
* @param expectedValue 期望的列值
* @param conditionColumn 条件列的提取函数,用于设置查询条件
* @param conditionValue 条件列对应的值
* @param queryMethod 执行查询的方法引用,返回单个实体对象
* @param errorMessage 验证失败时抛出异常的错误信息模板
* @throws RuntimeException 当查询结果中目标列的值与预期值不匹配时抛出异常
*/
public static <T, R, C> void validateColumnValueMatchesExpected(
SFunction<T, R> targetColumn, R expectedValue,
SFunction<T, C> conditionColumn, C conditionValue,
SFunction<LambdaQueryWrapper<T>, T> queryMethod,
String errorMessage) {
// 创建查询包装器,选择目标列并设置查询条件
LambdaQueryWrapper<T> wrapper = new LambdaQueryWrapper<>();
wrapper.select(targetColumn);
wrapper.eq(conditionColumn, conditionValue);
// 执行查询方法
T one = queryMethod.apply(wrapper);
// 如果查询结果为空,则直接返回,视为验证通过(或忽略)
if (one == null) return;
// 获取查询结果中目标列的实际值
R actualValue = targetColumn.apply(one);
// 比较实际值与预期值是否匹配,这里假设notMatch是一个自定义方法用于比较不匹配情况
boolean doesNotMatch = notMatch(actualValue, expectedValue);
if (doesNotMatch) {
// 若不匹配,则根据错误信息模板抛出异常
throw new RuntimeException(String.format(errorMessage, expectedValue, actualValue));
}
}
// 假设的辅助方法,用于比较值是否不匹配,根据实际需要实现
private static <R> boolean notMatch(R actual, R expected) {
// 示例简单实现为不相等判断,实际情况可能更复杂
return !Objects.equals(actual, expected);
}
这个方法允许我们指定一个查询目标列(targetColumn)、预期值(expectedValue)、查询条件列(conditionColumn)及其对应的条件值(conditionValue),并提供一个查询方法(queryMethod)来执行查询。如果查询到的列值与预期不符,则抛出异常,错误信息通过 errorMessage 参数定制。
应用场景:
例如在一个权限管理系统中,当需要更新用户角色时,系统需要确保当前用户的角色在更新前是 “普通用户”,才能将其升级为 “管理员”。此场景下,可以使用 validateColumnValueMatchesExpected
方法来验证用户当前的角色是否确实为“普通用户”。
// 当用户角色不是 “普通用户” 时抛异常
validateColumnValueMatchesExpected(User::getRoleType, "普通用户", User::getId, userId, userMapper::getOne, "用户角色不是普通用户,无法升级为管理员!");
断言指定值位于期望值列表内
进一步,某些情况下我们需要验证查询结果中的某一列值是否属于一个预设的值集合。例如,验证用户角色是否合法。为此,我们创建了 validateColumnValueMatchesExpectedList
方法:
/**
* 验证查询结果中指定列的值是否位于预期值列表内
*
* @param <T> 实体类型
* @param <R> 目标列值的类型
* @param <C> 查询条件列值的类型
* @param targetColumn 目标列的提取函数,用于获取想要验证的列值
* @param expectedValueList 期望值的列表
* @param conditionColumn 条件列的提取函数,用于设置查询条件
* @param conditionValue 条件列对应的值
* @param queryMethod 执行查询的方法引用,返回单个实体对象
* @param errorMessage 验证失败时抛出异常的错误信息模板
* @throws RuntimeException 当查询结果中目标列的值不在预期值列表内时抛出异常
*/
public static <T, R, C> void validateColumnValueInExpectedList(
SFunction<T, R> targetColumn, List<R> expectedValueList,
SFunction<T, C> conditionColumn, C conditionValue,
SFunction<LambdaQueryWrapper<T>, T> queryMethod,
String errorMessage) {
LambdaQueryWrapper<T> wrapper = new LambdaQueryWrapper<>();
wrapper.select(targetColumn);
wrapper.eq(conditionColumn, conditionValue);
T one = queryMethod.apply(wrapper);
if (one == null) return;
R actualValue = targetColumn.apply(one);
if (actualValue == null) throw new RuntimeException("列查询结果为空");
if (!expectedValueList.contains(actualValue)) {
throw new RuntimeException(errorMessage);
}
}
这个方法接受一个目标列(targetColumn)、一个预期值列表(expectedValueList)、查询条件列(conditionColumn)及其条件值(conditionValue),同样需要一个查询方法(queryMethod)。如果查询到的列值不在预期值列表中,则触发异常。
应用场景: 在一个电商平台的订单处理流程中,系统需要验证订单状态是否处于可取消的状态列表里(如 “待支付”、“待发货”)才允许用户取消订单。此时,validateColumnValueInExpectedList
方法能有效确保操作的合法性。
// 假设 OrderStatusEnum 枚举了所有可能的订单状态,cancelableStatuses 包含可取消的状态
List<String> cancelableStatuses = Arrays.asList(OrderStatusEnum.WAITING_PAYMENT.getValue(), OrderStatusEnum.WAITING_DELIVERY.getValue());
// 验证订单状态是否在可取消状态列表内
validateColumnValueInExpectedList(Order::getStatus, cancelableStatuses, Order::getOrderId, orderId, orderMapper::selectOne, "订单当前状态不允许取消!");
通过这两个扩展方法,我们不仅巩固了函数式编程在减少代码重复、提升代码灵活性方面的优势,还进一步证明了通过抽象和泛型设计,可以轻松应对各种复杂的业务校验需求,使代码更加贴近业务逻辑,易于理解和维护。
核心优势
- 代码复用:通过泛型和函数式接口,该方法能够适应任何实体类和属性的校验需求,大大减少了重复的查询逻辑代码。
- 清晰表达意图:方法签名直观表达了校验逻辑的目的,提高了代码的可读性和可维护性。
- 灵活性:使用者只需提供几个简单的 Lambda 表达式,即可完成复杂的查询逻辑配置,无需关心底层实现细节。
- 易于维护与扩展:
- 当需要增加新的实体验证时,仅需调用
ensureColumnValueValid
并传入相应的参数,无需编写新的验证逻辑,降低了维护成本。 - 修改验证规则时,只需调整
ensureColumnValueValid
内部实现,所有调用处自动遵循新规则,便于统一管理。 - 异常处理集中于
ensureColumnValueValid
方法内部,统一了异常抛出行为,避免了在多个地方处理相同的逻辑错误,减少了潜在的错误源。
- 当需要增加新的实体验证时,仅需调用
函数式编程的力量
通过这个实例,我们见证了函数式编程在简化代码、提高抽象层次上的强大能力。在 Java 8 及之后的版本中,拥抱函数式编程思想,不仅能够使我们的代码更加简洁、灵活,还能在一定程度上促进代码的正确性和可测试性。因此,无论是日常开发还是系统设计,都值得我们深入探索和应用这一现代编程范式,让代码如魔法般优雅而高效。
来源:juejin.cn/post/7384256110280572980
让同事用Cesium写一个测量工具并支持三角测量,他说有点难。。
大家好,我是日拱一卒的攻城师不浪,致力于技术与艺术的融合。这是2024年输出的第39/100篇文章。
可视化&Webgis交流群+V:brown_7778(备注来意)
前言
最近在开发智慧城市的项目,产品想让同事基于Cesium
开发一个测量工具,需要支持长度测量
、面积测量
以及三角测量
,但同事挠了挠头,说做这个有点费劲,还反问了产品:做这功能有啥意义?
产品经理:测量工具在智慧城市中发挥了重要的作用,通过对城市道路,地形,建筑物,场地等的精确测量,确保施工规划能够与现实场景精准吻合,节省人力以及施工成本。
对桥梁、隧道、地铁、管网等城市基础设施进行结构健康监测,安装传感器,实时监测结构体震动以及结构体偏移量
等数据,确保设施安全运行并能够提前发现问题,防患于未然。
开发同事听完,觉得还蛮有道理,看向我:浪浪,如何应对?
我:呐,拿走直接抄!下班请吃铜锅涮肉!
三角测量
先来了解下三角测量
:是一种基于三角形
几何原理的测量方法,用于确定未知点的位置。它通过已知基线(即两个已知点之间的距离)和从这两个已知点测量的角
度,计算出目标点的精确位置。
例如在建筑施工中,工程师使用三角测量法来测量楼体高度
、桥梁等结构的位置
和角度
,确保建筑的精准施工。
代码解析
接下来看下这个MeasureTool
类,主要包含以下功能:
- 坐标转换:整理了地理坐标(WGS84)与笛卡尔坐标(Cartesian)之间的转换功能。
- 拾取功能:通过屏幕坐标拾取场景中的三维位置,并判断该位置是位于模型上、地形上还是椭球体表面。
- 距离测量:绘制线段,并在场景中显示起点和终点之间的距离。
- 面积测量:通过给定的一组坐标,计算它们组成的多边形面积。
- 三角测量:绘制一个三角形来测量水平距离、直线距离和高度差。
坐标转换功能
transformWGS84ToCartesian
: 将WGS84坐标(经度、纬度、高度)转换为Cesium中的三维笛卡尔坐标。transformCartesianToWGS84
: 将Cesium的三维笛卡尔坐标转换为WGS84坐标。
核心代码:
transformWGS84ToCartesian(position, alt) {
return position
? Cesium.Cartesian3.fromDegrees(
position.lng || position.lon,
position.lat,
(position.alt = alt || position.alt),
Cesium.Ellipsoid.WGS84
)
: Cesium.Cartesian3.ZERO;
}
transformCartesianToWGS84(cartesian) {
var ellipsoid = Cesium.Ellipsoid.WGS84;
var cartographic = ellipsoid.cartesianToCartographic(cartesian);
return {
lng: Cesium.Math.toDegrees(cartographic.longitude),
lat: Cesium.Math.toDegrees(cartographic.latitude),
alt: cartographic.height,
};
}
Cesium的Cartesian3.fromDegrees
和Ellipsoid.WGS84.cartesianToCartographic
方法分别用于实现经纬度与笛卡尔坐标系的相互转换。
拾取功能
拾取功能允许通过屏幕像素坐标来获取3D场景中的位置。主要依赖scene.pickPosition
和scene.globe.pick
来实现拾取。
核心代码:
getCatesian3FromPX(px) {
var picks = this._viewer.scene.drillPick(px);
var cartesian = this._viewer.scene.pickPosition(px);
if (!cartesian) {
var ray = this._viewer.scene.camera.getPickRay(px);
cartesian = this._viewer.scene.globe.pick(ray, this._viewer.scene);
}
return cartesian;
}
这里首先尝试从3D模型或地形上拾取位置,如果未能拾取到模型或地形上的点,则尝试通过射线投射到椭球体表面。
距离测量
通过拾取点并记录每个点的坐标,计算相邻两个点的距离,并显示在Cesium场景中。通过ScreenSpaceEventHandler
来捕获鼠标点击和移动事件。
核心代码:
drawLineMeasureGraphics(options = {}) {
var positions = [];
var _handlers = new Cesium.ScreenSpaceEventHandler(this._viewer.scene.canvas);
_handlers.setInputAction(function (movement) {
var cartesian = this.getCatesian3FromPX(movement.position);
positions.push(cartesian);
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
_handlers.setInputAction(function (movement) {
var cartesian = this.getCatesian3FromPX(movement.endPosition);
positions.pop();
positions.push(cartesian);
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
_handlers.setInputAction(function () {
_handlers.destroy();
}, Cesium.ScreenSpaceEventType.RIGHT_CLICK);
}
测距的基本思想是通过鼠标点击获取多个点的坐标,然后计算每两个相邻点的距离。
面积测量
面积测量通过计算多个点围成的多边形的面积,基于Cesium的PolygonHierarchy
实现多边形绘制。
核心代码:
getPositionsArea(positions) {
let ellipsoid = Cesium.Ellipsoid.WGS84;
let area = 0;
positions.push(positions[0]); // 闭合多边形
for (let i = 1; i < positions.length; i++) {
let p1 = ellipsoid.cartographicToCartesian(this.transformWGS84ToCartographic(positions[i - 1]));
let p2 = ellipsoid.cartographicToCartesian(this.transformWGS84ToCartographic(positions[i]));
area += p1.x * p2.y - p2.x * p1.y;
}
return Math.abs(area) / 2.0;
}
这里通过一个简单的多边形面积公式(叉乘)来计算笛卡尔坐标下的面积。
三角测量
三角测量通过拾取三个点,计算它们之间的直线距离
、水平距离
以及高度差
,构建一个三角形并在场景中显示这些信息。
核心代码:
drawTrianglesMeasureGraphics(options = {}) {
var _positions = [];
var _handler = new Cesium.ScreenSpaceEventHandler(this._viewer.scene.canvas);
_handler.setInputAction(function (movement) {
var position = this.getCatesian3FromPX(movement.position);
_positions.push(position);
if (_positions.length === 3) _handler.destroy();
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
}
该方法核心思想是获取三个点的坐标,通过高度差来构建水平线和垂线,然后显示相应的距离和高度差信息。
使用
封装好,之后,使用起来就非常简单了。
import MeasureTool from "@/utils/cesiumCtrl/measure.js";
const measure = new MeasureTool(viewer);
**
* 测距
*/
const onLineMeasure = () => {
measure.drawLineMeasureGraphics({
clampToGround: true,
callback: (e) => {
console.log("----", e);
},
});
};
/**
* 测面积
*/
const onAreaMeasure = () => {
measure.drawAreaMeasureGraphics({
clampToGround: true,
callback: () => {},
});
};
/**
* 三角量测
*/
const onTrianglesMeasure = () => {
measure.drawTrianglesMeasureGraphics({
callback: () => {},
});
};
最后
这些测量工具都是依赖于Cesium提供的坐标转换、拾取以及事件处理机制,核心思路是通过ScreenSpaceEventHandler
捕捉鼠标事件,获取坐标点,并通过几何算法计算距离、面积和高度。
【完整源码
地址】:github.com/tingyuxuan2…
如果认为有帮助,希望可以给我们一个免费的star
,激励我们持续开源更多代码。
如果想系统学习Cesium,可以看下作者的Cesium系列教程
《Cesium从入门到实战》
,将Cesium的知识点进行串联,让不了解Cesium的小伙伴拥有一个完整的学习路线,学完后直接上手做项目,+作者:brown_7778(备注来意)了解课程细节。
另外有需要进
可视化&Webgis交流群
可以加我:brown_7778(备注来意),也欢迎数字孪生可视化领域
的交流合作。
来源:juejin.cn/post/7424902468243669029
因离线地图引发的惨案
小王最近接到了一个重要的需求,要求在现有系统上实现离线地图功能,并根据不同的经纬度在地图上显示相应的标记(marks)。老板承诺,如果小王能够顺利完成这个任务,他的年终奖就有着落了。
为了不辜负老板的期望并确保自己能够拿到年终奖,小王开始了马不停蹄的开发工作。他查阅了大量文档,研究了各种离线地图解决方案,并一一尝试。经过48小时的连续奋战,凭借着顽强的毅力和专业的技术能力,小王终于成功完成了需求。
他在系统中集成了离线地图,并实现了根据经纬度显示不同区域标记的功能。每个标记都能准确地反映地理位置的信息,系统的用户体验得到了极大的提升。小王的心中充满了成就感和对未来奖励的期待。
然而,天有不测风云。当小王准备向老板汇报工作成果时,却得知一个令人震惊的消息:老板因涉嫌某些违法行为(爬取不当得利)被逮捕了,公司也陷入了一片混乱。年终奖的承诺随之泡汤,甚至连公司未来的发展都蒙上了一层阴影。
尽管如此,小王并没有因此而气馁。这次通过技术让老板成功的获得了编制,他深知只有不断技术的积累和经验的增长才能更好的保护老板。
1.离线地图
首先需要怎么做呢,你需要一个地图瓦片生成器(爬取谷歌、高德、百度等各个平台的地图瓦片,其实就是一张张缩小的图片,这里爬取可以用各种技术手段,但是违法偶,老板就是这么进去的),有个工具推荐:
链接:pan.baidu.com/s/1nflY8-KL…
提取码:yqey
下载解压打开下面的文件
打开了界面就长这样
可以调整瓦片样式
下载速度龟慢,建议开启代理,因为瓦片等级越高数量越多,需要下载的包越大,这里建议下载到11-16级别,根据自己需求
下载完瓦片会保存在自己定义的文件夹,这里不建议放在c盘,会生成以下文件
使用一个文件服务去启动瓦片额静态服务,可以使用http-server
安装http-server
yarn add http-server -g
cd到下载的mapabc目录下
http-server roadmap
本地可以这么做上线后需要使用nginx代理这个静态服务
server {
listen 80;
server_name yourdomain.com; # 替换为你的域名或服务器 IP
root /var/www/myapp/public; # 设置根目录
index index.html; # 设置默认文件
location / {
try_files $uri $uri/ =404;
}
# 配置访问 roadmap 目录下的地图瓦片
location ^~/roloadmap/{
alias /home/d5000/iot/web/roloadmap/;
autoindex on; # 如果你想列出目录内容,可以开启这个选项
}
# 配置其他静态文件的访问(可选)
location /static/ {
alias /var/www/myapp/public/static/;
}
# 其他配置,例如反向代理到应用服务器等
# location /api/ {
# proxy_pass http://localhost:3000;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# }
}
配置完重启一下ngix即可
对于如何将瓦片结合成一张地图并在vue2中使用,这里采用vueLeaflet,它是在leaflet基础上进行封装的
这个插件需要安装一系列包
yarn add leaflet vue2-leaflet leaflet.markercluster
<l-tile-layer url="http://192.168.88.211:8080/{z}/{x}/{y}.png" ></l-tile-layer>
这里的url就是上面启动的服务,包括端口和ip,要能访问到瓦片
编写代码很简单
<template>
<div class="map">
<div class="search">
<map-search @input_val="inputVal" @select_val="selectVal" />
</div>
<div class="map_container">
<l-map
:zoom="zoom"
:center="center"
:max-bounds="bounds"
:min-zoom="9"
:max-zoom="15"
:key="`${center[0]}-${center[1]}-${zoom}`"
style="height: 100vh; width: 100%"
>
<l-tile-layer
url="http://192.168.88.211:8080/{z}/{x}/{y}.png"
></l-tile-layer>
<l-marker-cluster>
<l-marker
v-for="(marker, index) in markers"
:key="index"
:lat-lng="marker.latlng"
:icon="customIcon"
@click="handleMarkerClick(marker)"
>
<l-tooltip :offset="tooltipOffset">
<div class="popup-content">
<p>设备名称: {{ marker.regionName }}</p>
<p>主线设备数量: {{ marker.endNum }}</p>
<p>边缘设备数量: {{ marker.edgNum }}</p>
</div>
</l-tooltip>
</l-marker>
</l-marker-cluster>
</l-map>
</div>
</div>
</template>
<script>
import { LMap, LTileLayer, LMarker, LPopup, LTooltip, LMarkerCluster } from "vue2-leaflet";
import mapSearch from "./search.vue";
import "leaflet/dist/leaflet.css";
import L from "leaflet";
// import geojsonData from "./city.json"; // 确保这个路径是正确的
import geoRegionData from "./equip.json"; // 确保这个路径是正确的
// 移除默认的图标路径
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: require("leaflet/dist/images/marker-icon-2x.png"),
iconUrl: require("leaflet/dist/images/marker-icon.png"),
shadowUrl: require("leaflet/dist/images/marker-shadow.png"),
});
export default {
name: "Map",
components: {
LMap,
LTileLayer,
LMarker,
LPopup,
LTooltip,
mapSearch,
LMarkerCluster
},
data() {
return {
zoom: 9,
center: [32.0617, 118.7636], // 江苏省的中心坐标
bounds: [
[30.7, 116.3],
[35.1, 122.3],
], // 江苏省的地理边界
markers: geoRegionData,
customIcon: L.icon({
iconUrl: require("./equip.png"), // 自定义图标的路径
iconSize: [21, 27], // 图标大小
iconAnchor: [12, 41], // 图标锚点
popupAnchor: [1, -34], // 弹出框相对于图标的锚点
shadowSize: [41, 41], // 阴影大小(如果有)
shadowAnchor: [12, 41], // 阴影锚点(如果有)
}),
tooltipOffset: L.point(10, 10), // 调整偏移值
};
},
methods: {
inputVal(val) {
// 处理输入值变化
this.center = val;
this.zoom = 15;
},
selectVal(val) {
// 处理选择值变化
this.center = val;
this.zoom = 15;
},
handleMarkerClick(marker) {
this.center = marker.latlng;
this.zoom = 15;
},
},
};
</script>
<style scoped lang="less">
@import "~leaflet/dist/leaflet.css";
@import "~leaflet.markercluster/dist/MarkerCluster.css";
@import "~leaflet.markercluster/dist/MarkerCluster.Default.css";
.map {
width: 100%;
height: 100%;
position: relative;
.search {
position: absolute;
z-index: 1000;
left: 20px;
top: 10px;
padding: 10px; /* 设置内边距 */
}
}
.popup-content {
font-family: Arial, sans-serif;
text-align: left;
}
.popup-content h3 {
margin: 0;
font-size: 16px;
font-weight: bold;
}
.popup-content p {
margin: 4px 0;
font-size: 14px;
}
/deep/.leaflet-control {
display: none !important; /* 隐藏默认控件 */
}
/deep/.leaflet-control-zoom {
display: none !important; /* 隐藏默认控件 */
}
</style>
这里使用遇到一个坑,需要切换地图中心center,需要给l-map绑定一个key="${center[0]}-${center[1]}-${zoom}
",不然每次切换第一次会失败,第二次才能成功
可以给行政区添加范围,这里需要geojson数据,可以在阿里云数据平台上获取
通过组件加载即可
<l-geo-json :geojson="geojson"></l-geo-json>
效果如下
以上方法,不建议使用,如果是商业使用,不建议使用,不然容易被告侵权,最好能是使用官方合法的地图api,例如谷歌、百度、腾讯、高德,这里我使用高德api给兄弟们们展示一下
2.高德在线地图
2.1首先需要在高德的开放平台申请一个账号
创建一个项目,如下,我们需要使用到这个key和密钥,这里如果是公司使用可以使用公司的信息注册一个账号,公司的账号权限高于个人,具体区别如下参看官网
developer.amap.com/api/faq/acc…
2.2如何在框架中使用
因为不想在创建一个react应用了,这里还是用vue2演示,vue2需要下载一个高德提供的npm包
yarn add @amap/amap-jsapi-loader
编写代码
<template>
<div class="map">
<div class="serach">
<map-search @share_id="shareId" @input_val="inputVal" @select_val="selectVal" @change_theme="changeTheme" />
</div>
<div class="map_container" id="container"></div>
</div>
</template>
<script>
import AMapLoader from "@amap/amap-jsapi-loader";
import mapSearch from "./search.vue";
import cityJson from "../../assets/area.json";
window._AMapSecurityConfig = {
//这里是高德开放平台创建项目时生成的密钥
securityJsCode: "xxxx",
};
export default {
name: "mapContainer",
components: { mapSearch },
mixins: [],
props: {},
data() {
return {
map: null,
autoOptions: {
input: "",
},
auto: null,
AMap: null,
placeSearch: null,
searchPlaceInput: "",
polygons: [],
positions: [],
//地图样式配置
inintMapStyleConfig: {
//设置地图容器id
viewMode: "3D", //是否为3D地图模式
zoom: 15, //初始化地图级别
rotateEnable: true, //是否开启地图旋转交互 鼠标右键 + 鼠标画圈移动 或 键盘Ctrl + 鼠标左键画圈移动
pitchEnable: true, //是否开启地图倾斜交互 鼠标右键 + 鼠标上下移动或键盘Ctrl + 鼠标左键上下移动
mapStyle: "amap://styles/whitesmoke", //设置地图的显示样式
center: [118.796877, 32.060255], //初始化地图中心点位置
},
//地图配置
mapConfig: {
key: "xxxxx", // 申请好的Web端开发者Key,首次调用 load 时必填
version: "2.0", // 指定要加载的 JSAPI 的版本,缺省时默认为 1.4.15
plugins: [
"AMap.AutoComplete",
"AMap.PlaceSearch",
"AMap.Geocoder",
"AMap.DistrictSearch",
], // 需要使用的的插件列表,如比例尺'AMap.Scale'等
},
// 实例化DistrictSearch配置
districtSearchOpt: {
subdistrict: 1, //获取边界不需要返回下级行政区
extensions: "all", //返回行政区边界坐标组等具体信息
},
//这里是mark中的设置
icon: {
type: "image",
image: require("../../assets/equip.png"),
size: [15, 21],
anchor: "bottom-center",
fitZoom: [14, 20], // Adjust the fitZoom range for scaling
scaleFactor: 2, // Zoom scale factor
maxScale: 2, // Maximum scale
minScale: 1 // Minimum scale
}
};
},
created() {
this.initMap();
},
methods: {
//初始化地图
async initMap() {
this.AMap = await AMapLoader.load(this.mapConfig);
this.map = new AMap.Map("container", this.inintMapStyleConfig);
//根据地理位置查询经纬度
this.positions = await Promise.all(cityJson.map(async item => {
try {
const dot = await this.queryGeocodes(item.cityName, this.AMap);
return {
...item,
dot: dot
};
} catch (error) {
}
}));
//poi查询
this.addMarker();
//显示安徽省的区域
this.drawBounds("安徽省");
},
//查询地理位置
async queryGeocodes(newValue, AMap) {
return new Promise((resolve, reject) => {
//加载行政区划插件
const geocoder = new AMap.Geocoder({
// 指定返回详细地址信息,默认值为true
extensions: 'all'
});
// 使用地址进行地理编码
geocoder.getLocation(newValue, (status, result) => {
if (status === 'complete' && result.geocodes.length) {
const geocode = result.geocodes[0];
const latitude = geocode.location.lat;
const longitude = geocode.location.lng;
resolve([longitude, latitude]);
} else {
reject('无法获取该地址的经纬度');
}
});
});
},
//结合输入提示进行POI搜索
shareId(val) {
this.autoOptions.input = val;
},
//根据设备搜索
inputVal(val) {
if (val?.length === 0) {
//poi查询
this.addMarker();
//显示安徽省
this.drawBounds("安徽省");
return;
}
var position = val
this.icon.size = [12, 18]
this.map.setCenter(position)
this.queryPoI()
this.map.setZoom(12, true, 1);
},
//修改主题
changeTheme(val) {
const styleName = "amap://styles/" + val;
this.map.setMapStyle(styleName);
},
//区域搜索
selectVal(val) {
if (val && val.length > 0) {
let vals = val[val?.length - 1];
vals = vals.replace(/\s+/g, '');
this.queryPoI()
this.placeSearch.search(vals);
this.drawBounds(vals);
this.map.setZoom(15, true, 1);
}
},
//添加marker
addMarker() {
const icon = this.icon
let layer = new this.AMap.LabelsLayer({
zooms: [3, 20],
zIndex: 1000,
collision: false,
});
// 将图层添加到地图
this.map.add(layer);
// 普通点
let markers = [];
this.positions.forEach((item) => {
const content = `
<div class="custom-info-window">
<div class="info-window-header"><b>${item.cityName}</b></div>
<div class="info-window-body">
<div>边设备数 : ${item.edgNum} 台</div>
<div>端设备数 : ${item.endNum} 台</div>
</div>
</div>
`;
let labelMarker = new AMap.LabelMarker({
position: item.dot,
icon: icon,
rank: 1, //避让优先级
});
const infoWindow = new AMap.InfoWindow({
content: content, //传入字符串拼接的 DOM 元素
anchor: "top-left",
});
labelMarker.on('mouseover', () => {
infoWindow.open(this.map, item.dot);
});
labelMarker.on('mouseout', () => {
infoWindow.close();
});
labelMarker.on('click', () => {
this.map.setCenter(item.dot)
this.queryPoI()
this.map.setZoom(15, true, 1);
})
markers.push(labelMarker);
});
// 一次性将海量点添加到图层
layer.add(markers);
},
//POI查询
queryPoI() {
this.auto = new this.AMap.AutoComplete(this.autoOptions);
this.placeSearch = new this.AMap.PlaceSearch({
map: this.map,
}); //构造地点查询类
this.auto.on("select", this.select);
this.addMarker();
},
//选择数据
select(e) {
this.placeSearch.setCity(e.poi.adcode);
this.placeSearch.search(e.poi.name); //关键字查询查询
this.map.setZoom(15, true, 1);
},
// 行政区边界绘制
drawBounds(newValue) {
//加载行政区划插件
if (!this.district) {
this.map.plugin(["AMap.DistrictSearch"], () => {
this.district = new AMap.DistrictSearch(this.districtSearchOpt);
});
}
//行政区查询
this.district.search(newValue, (_status, result) => {
if (Object.keys(result).length === 0) {
this.$message.warning("未查询到该地区数据");
return
}
if (this.polygons != null) {
this.map.remove(this.polygons); //清除上次结果
this.polygons = [];
}
//绘制行政区划
result?.districtList[0]?.boundaries?.length > 0 &&
result.districtList[0].boundaries.forEach((item) => {
let polygon = new AMap.Polygon({
strokeWeight: 1,
path: item,
fillOpacity: 0.1,
fillColor: "#22886f",
strokeColor: "#22886f",
});
this.polygons.push(polygon);
});
this.map.add(this.polygons);
this.map.setFitView(this.polygons); //视口自适应
});
},
},
};
</script>
<style lang="less" scoped>
.map {
width: 100%;
height: 100%;
position: relative;
.map_container {
width: 100%;
height: 100%;
}
.serach {
position: absolute;
z-index: 33;
left: 20px;
top: 10px;
}
}
</style>
<style>
//去除高德的logo
.amap-logo {
right: 0 !important;
left: auto !important;
display: none !important;
}
.amap-copyright {
right: 70px !important;
left: auto !important;
opacity: 0 !important;
}
/* 自定义 infoWindow 样式 */
.custom-info-window {
font-family: Arial, sans-serif;
padding: 10px;
border-radius: 8px;
background-color: #ffffff;
max-width: 250px;
}
</style>
在子组件中构建查询
<template>
<div class="box">
<div class="input_area">
<el-input placeholder="请输入设备名称" :id="search_id" v-model="input" size="mini" class="input_item" />
<img src="../../assets/input.png" alt="" class="img_logo" />
<span class="el-icon-search search" @click="searchMap"></span>
</div>
<div class="select_area">
<el-cascader :options="options" size="mini" placeholder="选择地域查询" :show-all-levels="false" :props="cityProps"
clearable v-model="cityVal" @change="selectCity"></el-cascader>
</div>
<div class="date_area">
<el-select v-model="themeValue" placeholder="请选择地图主题" size="mini" @change="changeTheme">
<el-option v-for="item in themeOptions" :key="item.value" :label="item.label" :value="item.value">
</el-option>
</el-select>
</div>
</div>
</template>
<script>
import cityRegionData from "../../assets/area"
import cityJson from "../../assets/city.json";
export default {
name: "search",
components: {},
mixins: [],
props: {},
data() {
return {
search_id: "searchId",
input: "",
options: cityRegionData,
cityProps: {
children: "children",
label: "business_name",
value: "business_name",
checkStrictly: true
},
cityVal: "",
themeOptions: [
{ label: "标准", value: "normal" },
{ label: "幻影黑", value: "dark" },
{ label: "月光银", value: "light" },
{ label: "远山黛", value: "whitesmoke" },
{ label: "草色青", value: "fresh" },
{ label: "雅士灰", value: "grey" },
{ label: "涂鸦", value: "graffiti" },
{ label: "马卡龙", value: "macaron" },
{ label: "靛青蓝", value: "blue" },
{ label: "极夜蓝", value: "darkblue" },
{ label: "酱籽", value: "wine" },
],
themeValue: ""
};
},
computed: {},
watch: {},
mounted() {
this.sendId();
},
methods: {
sendId() {
this.$emit("share_id", this.search_id);
},
searchMap() {
console.log(this.input,'ssss');
if (!this.input) {
this.$emit("input_val", []);
return
}
let val = cityJson.find(item => item.equipName === this.input)
if (val) {
this.$emit("input_val", val.dot);
return
}
this.$message.warning("未查询到该设备,请输入正确的设备名称");
},
selectCity() {
this.$emit("select_val", this.cityVal);
},
changeTheme(val) {
this.$emit("change_theme", val);
}
},
};
</script>
<style lang="less" scoped>
.box {
display: flex;
.input_area {
position: relative;
width: 170px;
height: 50px;
display: flex;
align-items: center;
.input_item {
width: 100%;
/deep/ .el-input__inner {
padding-left: 30px !important;
}
}
.img_logo {
position: absolute;
left: 5px;
top: 50%;
transform: translateY(-50%);
width: 20px;
height: 20px;
margin-right: 10px;
}
span {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
font-size: 16px;
color: #ccc;
cursor: pointer;
}
}
.select_area {
width: 150px;
display: flex;
align-items: center;
height: 50px;
margin-left: 10px;
}
.date_area {
width: 150px;
display: flex;
align-items: center;
height: 50px;
margin-left: 10px;
}
}
</style>
效果如下
来源:juejin.cn/post/7386650134744596532
用Three.js搞个炫酷雷达扩散和扫描特效
1.画点建筑模型
添加光照,开启阴影
//开启renderer阴影
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
//设置环境光
const light = new THREE.AmbientLight(0xffffff, 0.6); // soft white light
this.scene.add(light);
//夜晚天空蓝色,假设成蓝色的平行光
const dirLight = new THREE.DirectionalLight(0x0000ff, 3);
dirLight.position.set(50, 50, 50);
this.scene.add(dirLight);
平行光设置阴影
//开启阴影
dirLight.castShadow = true;
//阴影相机范围
dirLight.shadow.camera.top = 100;
dirLight.shadow.camera.bottom = -100;
dirLight.shadow.camera.left = -100;
dirLight.shadow.camera.right = 100;
//阴影影相机远近
dirLight.shadow.camera.near = 1;
dirLight.shadow.camera.far = 200;
//阴影贴图大小
dirLight.shadow.mapSize.set(1024, 1024);
- 平行光的阴影相机跟正交相机一样,因为平行光的光线是平行的,就跟视线是平行一样,切割出合适的阴影视角范围,用于计算阴影。
- shadow.mapSize设置阴影贴图的宽度和高度,值越高,阴影的质量越好,但要花费计算时间更多。
增加建筑
//添加一个平面
const pg = new THREE.PlaneGeometry(100, 100);
//一定要用受光材质才有阴影效果
const pm = new THREE.MeshStandardMaterial({
color: new THREE.Color('gray'),
transparent: true,//开启透明
side: THREE.FrontSide//只有渲染前面
});
const plane = new THREE.Mesh(pg, pm);
plane.rotateX(-Math.PI * 0.5);
plane.receiveShadow = true;//平面接收阴影
this.scene.add(plane);
//随机生成建筑
this.geometries = [];
const helper = new THREE.Object3D();
for (let i = 0; i < 100; i++) {
const h = Math.round(Math.random() * 15) + 5;
const x = Math.round(Math.random() * 50);
const y = Math.round(Math.random() * 50);
helper.position.set((x % 2 ? -1 : 1) * x, h * 0.5, (y % 2 ? -1 : 1) * y);
const geometry = new THREE.BoxGeometry(5, h, 5);
helper.updateWorldMatrix(true, false);
geometry.applyMatrix4(helper.matrixWorld);
this.geometries.push(geometry);
}
//长方体合成一个形状
const mergedGeometry = BufferGeometryUtils.mergeGeometries(this.geometries, false);
//建筑贴图
const texture = new THREE.TextureLoader().load('assets/image.jpg');
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
const material = new THREE.MeshStandardMaterial({ map: texture,transparent: true });
const cube = new THREE.Mesh(mergedGeometry, material);
//形状产生阴影
cube.castShadow = true;
//形状接收阴影
cube.receiveShadow = true;
this.scene.add(cube);
效果就是很多高楼大厦的样子,为什么楼顶有窗?别在意这些细节,有的人就喜欢开天窗呢~
2.搞个雷达扩散和扫描特效
改变建筑材质shader,计算建筑的俯视uv
material.onBeforeCompile = (shader, render) => {
this.shaders.push(shader);
//范围大小
shader.uniforms.uSize = { value: 50 };
shader.uniforms.uTime = { value: 0 };
//修改顶点着色器
shader.vertexShader = shader.vertexShader.replace(
'void main() {',
` uniform float uSize;
varying vec2 vUv;
void main() {`
);
shader.vertexShader = shader.vertexShader.replace(
'#include <fog_vertex>',
`#include <fog_vertex>
//计算相对于原点的俯视uv
vUv=position.xz/uSize;`
);
//修改片元着色器
shader.fragmentShader = shader.fragmentShader.replace(
'void main() {',
`varying vec2 vUv;
uniform float uTime;
void main() {`
);
shader.fragmentShader = shader.fragmentShader.replace(
'#include <dithering_fragment>',
`#include <dithering_fragment>
//渐变颜色叠加
gl_FragColor.rgb=gl_FragColor.rgb+mix(vec3(0,0.5,0.5),vec3(1,1,0),vUv.y);`
);
};
然后你将同样的onBeforeCompile函数赋值给平面的时候,没有对应的效果。
因为平面没有z,只有xy,而且经过了-90度旋转后,坐标位置也要对应反转,由此可以得出平面的uv计算公式
vUv=vec2(position.x,-position.y)/uSize;
至此,建筑和平面的俯视uv一致了。
雷达扩散特效
- 雷达扩散就是一段渐变的环,随着时间扩大。
- 顶点着色器不变,改一下片元着色器,增加扩散环颜色uColor,对应shader.uniforms也要添加
shader.uniforms.uColor = { value: new THREE.Color('#00FFFF') };
const fragmentShader1 = `varying vec2 vUv;
uniform float uTime;
uniform vec3 uColor;
uniform float uSize;
void main() {`;
const fragmentShader2 = `#include <dithering_fragment>
//计算与中心的距离
float d=length(vUv);
if(d >= uTime&&d<=uTime+ 0.1) {
//扩散圈
gl_FragColor.rgb = gl_FragColor.rgb+mix(uColor,gl_FragColor.rgb,1.0-(d-uTime)*10.0 )*0.5 ;
}`;
shader.fragmentShader = shader.fragmentShader.replace('void main() {', fragmentShader1);
shader.fragmentShader = shader.fragmentShader.replace(
'#include <dithering_fragment>',
fragmentShader2);
//改变shader的时间变量,动起来
animateAction() {
if (this.shaders?.length) {
this.shaders.forEach((shader) => {
shader.uniforms.uTime.value += 0.005;
if (shader.uniforms.uTime.value >= 1) {
shader.uniforms.uTime.value = 0;
}
});
}
}
噔噔噔噔,完成啦!是立体化的雷达扩散,看起来很酷的样子。
雷达扫描特效
跟上面雷达扩散差不多,只要修改一下片元着色器
- 雷达扫描是通过扇形渐变形成的,还要随着时间旋转角度,shaderToy参考链接
const fragmentShader1 = `varying vec2 vUv;
uniform float uTime;
uniform vec3 uColor;
uniform float uSize;
//旋转角度矩阵
mat2 rotate2d(float angle)
{
return mat2(cos(angle), - sin(angle),
sin(angle), cos(angle));
}
//雷达扫描渐变扇形
float vertical_line(in vec2 uv)
{
if (uv.y > 0.0 && length(uv) < 1.2)
{
float theta = mod(180.0 * atan(uv.y, uv.x)/3.14, 360.0);
float gradient = clamp(1.0-theta/90.0,0.0,1.0);
return 0.5 * gradient;
}
return 0.0;
}
void main() {`;
const fragmentShader2 = `#include <dithering_fragment>
mat2 rotation_matrix = rotate2d(- uTime*PI*2.0);
//将雷达扫描扇形渐变混合到颜色中
gl_FragColor.rgb= mix( gl_FragColor.rgb, uColor, vertical_line(rotation_matrix * vUv)); `;
GitHub地址
来源:juejin.cn/post/7349837128508964873
粒子特效particles.js
效果图
版本:"particles.js": "^2.0.0"
npm i particles.js
Vue2版本
组件代码:src/App.vue
<template>
<div class="particles-js-box">
<div id="particles-js"></div>
</div>
</template>
<script>
import particlesJs from "particles.js";
import particlesConfig from "./assets/particles.json";
export default {
data() {
return {};
},
mounted() {
this.init();
},
methods: {
init() {
particlesJS("particles-js", particlesConfig);
document.body.style.overflow = "hidden";
},
},
};
</script>
<style scoped>
.particles-js-box {
position: fixed;
width: 100%;
height: 100%;
top: 0;
left: 0;
z-index: -1;
}
#particles-js {
background-color: #18688d;
width: 100%;
height: 100%;
}
</style>
代码里的json数据:
目录:src/assets/particles.json
{
"particles": {
"number": {
"value": 60,
"density": {
"enable": true,
"value_area": 800
}
},
"color": {
"value": "#ddd"
},
"shape": {
"type": "circle",
"stroke": {
"width": 0,
"color": "#000000"
},
"polygon": {
"nb_sides": 5
},
"image": {
"src": "img/github.svg",
"width": 100,
"height": 100
}
},
"opacity": {
"value": 0.5,
"random": false,
"anim": {
"enable": false,
"speed": 1,
"opacity_min": 0.1,
"sync": false
}
},
"size": {
"value": 3,
"random": true,
"anim": {
"enable": false,
"speed": 40,
"size_min": 0.1,
"sync": false
}
},
"line_linked": {
"enable": true,
"distance": 150,
"color": "#ffffff",
"opacity": 0.4,
"width": 1
},
"move": {
"enable": true,
"speed": 4,
"direction": "none",
"random": false,
"straight": false,
"out_mode": "out",
"bounce": false,
"attract": {
"enable": false,
"rotateX": 100,
"rotateY": 1200
}
}
},
"interactivity": {
"detect_on": "Window",
"events": {
"onhover": {
"enable": true,
"mode": "grab"
},
"onclick": {
"enable": true,
"mode": "push"
},
"resize": true
},
"modes": {
"grab": {
"distance": 140,
"line_linked": {
"opacity": 1
}
},
"bubble": {
"distance": 400,
"size": 40,
"duration": 2,
"opacity": 8,
"speed": 3
},
"repulse": {
"distance": 200,
"duration": 0.4
},
"push": {
"particles_nb": 4
},
"remove": {
"particles_nb": 2
}
}
},
"retina_detect": true
}
Vue3版本
{
"name": "vue3-test",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --fix",
"format": "prettier --write src/"
},
"dependencies": {
"particles.js": "^2.0.0",
"vue": "^3.5.13"
},
"devDependencies": {
"@eslint/js": "^9.14.0",
"@vitejs/plugin-vue": "^5.2.1",
"@vue/eslint-config-prettier": "^10.1.0",
"eslint": "^9.14.0",
"eslint-plugin-vue": "^9.30.0",
"prettier": "^3.3.3",
"sass-embedded": "^1.83.0",
"vite": "^6.0.1",
"vite-plugin-vue-devtools": "^7.6.5"
}
}
需要修改 /node_modules/particles.js/particles.js 的代码
修改此 2 处,
我是把它拷贝到 src 目录下
组件使用:跟 vue2 一样,就是上面第8行引入不一样
import particlesJs from "@/particles.js";
来源:juejin.cn/post/7452931747785883684
为什么Rust 是 web 开发的理想选择
为什么Rust 是 web 开发的理想选择
Rust 经常被视为仅仅是一种系统编程语言,但实际上它是一种多用途的通用语言。像 Tauri(用于桌面应用)、Leptos(用于前端开发)和 Axum(用于后端开发)这样的项目表明 Rust 的用途远不止于系统编程。
当我开始学习 Rust 时,我构建了一个网页应用来练习。因为我主要是一名后端工程师,这是我最为熟悉的领域。很快我就意识到 Rust 非常适合做网页开发。它的特性让我有信心构建出可靠的应用程序。让我来解释一下为什么 Rust 是网页编程的理想选择。
错误处理
一段时间以前,我开始学习 Python,那时我被机器学习的热潮所吸引,甚至是在大型语言模型(LLM)热潮之前。我需要让机器学习模型可以被使用,因此我选择了编写一个 Django REST API。在 Django 中获取请求体,你可能会这样写代码:
class User(APIView):
def post(self, request):
body = request.data
这段代码大多数时候都能正常工作。然而,当我意外地发送了一个格式不正确的请求体时,它就不再起作用了。访问数据时抛出了异常,导致返回了 500 状态码的响应。我没有意识到这种访问可能会抛出异常,并且也没有明确的提示。
Rust 通过不抛出异常而是以 Result 形式返回错误作为值来处理这种情况。Result 同时包含值和错误,你必须处理错误才能访问该值。
let body: RequestBody = serde_json::from_slice(&requestData)?;
问号 (?) 表示你想在调用函数中处理错误,将错误向上一级传播。
我认为任何将错误作为值来处理的语言都是正确处理错误的方式。这种方法允许你编写代码时避免出现意外的情况,就像 Python 示例中的那样。
默认不可变性
最近,我的一位同事在我们的一个开源项目上工作,他需要替换一个客户端库为另一个。这是他使用的代码:
newClient(
WithHTTPClient(httpClient), // &http.Client{}
WithEndpoint(config.ApiBasePath),
)
突然间,集成测试开始抛出竞态条件错误,他搞不清楚为什么会这样。他向我求助,我们一起追踪问题回到了这行代码。我们在其他客户端之间共享了这个HTTP客户端,这导致了错误的发生。多个协程在读取客户端,而 WithHttpClient 函数修改了客户端的状态。在同一资源上同时有读线程和写线程会导致未定义的行为或在 Go 语言中引发恐慌。
这又是一个令人不悦的意外。而在 Rust 中,所有变量默认是不可变的。如果你想修改一个变量,你需要显式地声明它,使用 mut 关键字。这有助于 API 客户端理解发生了什么,并避免了意外的修改。
fn with_httpclient(client: &mut reqwest::Client) {}
宏
在像 Java 和 Python 这样的语言中,你们有注解;而在 Rust 中,我们使用宏。注解可以在某些环境下如 Spring 中带来优雅的代码,其中大量的幕后工作是通过反射完成的。虽然 Rust 的宏提供的“魔法”较少,但也同样能产生更清晰的代码。这里有一个 Rust 宏的例子:
sqlx::query_as!(Student, "DELETE FROM student WHERE id = ? RETURNING *", id)
Rust 中的宏会在后台生成代码,编译器在构建过程中会检查这些代码的正确性。通过宏,你甚至可以在编译时扩展编译器检查并验证 SQL 查询,方法是在编译期间生成运行查询的真实数据库上的代码。
这种能够在编译时检查代码正确性的能力开辟了新的可能性,特别是在 web 开发中,我们经常编写原始的数据库语句或 HTML 和 CSS 代码。它帮助我们写出更少 bug 的代码。
这里提到的宏被称为声明式宏。Rust 还有过程式宏,它们更类似于其他语言中的注解。
#[instrument(name = "UserRepository::begin")]
pub async fn begin(&self) {}
核心思想保持不变:在后台生成代码,并在方法调用前后执行一些逻辑,从而确保代码更加健壮和易于维护。
Chaining
来看看这段在 Rust 中优雅的代码:
let key_value = request.int0_inner()
.key_value
.ok_or_else(|| ServerError::InvalidArgument("key_value must be set".to_string()))?;
与这种更为冗长的方法相比:
Optional<KeyValue> keyValueOpt = request.getInner().getKeyValue();
if (!keyValueOpt.isPresent()) {
throw new IllegalArgumentException("key_value must be set");
}
KeyValue keyValue = keyValueOpt.get();
在 Rust 中,我们可以将操作链接在一起,从而得到简洁且易读的代码。但是,为了实现这种流畅的语法,我们通常需要实现诸如 From 这样的特质。
功能性技术大佬们可能会认识并欣赏这种方法,他们有这样的见解是有道理的。我认为任何允许混合函数式和过程式编程的语言都是走在正确的道路上。它为开发者提供了灵活性,让他们可以选择最适合其特定应用场景的方式。
线程安全
这里有没有人曾经因为竞态条件而在生产环境中触发过程序崩溃?我羞愧地承认,我有过这样的经历。是的,这是一个技能问题。当你在启动多个线程的同时对同一内存地址进行修改和读取时,很难不去注意到这个问题。但让我们考虑这样一个例子:
type repo struct {
m map[int]int
}
func (r *repo) Create(i int) {
r.m[i] = i
}
type Server struct {
repo *repo
}
func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request) {
s.repo.Create(1)
}
没有显式启动任何线程,乍一看,一切似乎都很好。然而实际上,HTTP 服务器是在多个线程上运行的,这些线程被抽象隐藏了起来。在 web 开发中,这种抽象可能会掩盖与多线程相关的潜在问题。现在,让我们用 Rust 实现相同的功能:
struct repo {
m: std::collections::HashMap<i8, i8>
}
#[post("/maps")]
async fn crate_entry(r: web::Data<repo>) -> HttpResponse {
r.m.insert(1, 2);
HttpResponse::Ok().json(MessageResponse {
message: "good".to_string(),
})
}
当我们尝试编译这个程序时,Rust 编译器将会抛出一个错误:
error[E0596]: cannot
borrow data in an
`Arc` as mutable
--> src\main.rs:117:5
|
117 | r.m.insert(1, 2);
| ^^^ cannot borrow as mutable
|
= help: trait `DerefMut` is required to modify through a dereference, but it is not implemented for `Arc<repo>`
很多人说 Rust 的错误信息通常是很有帮助的,这通常是正确的。然而,在这种情况下,错误信息可能会让人感到困惑并且不是立即就能明白。幸运的是,如果知道如何解决,修复方法很简单:只需要添加一个小的互斥锁:
struct repo {
m: HashMap<i8, i8>
}
#[post("/maps")]
async fn create_entry(r: web::Data<Mutex<repo>>) -> HttpResponse {
let mut map = r.lock().await();
map.m.insert(1, 2);
HttpResponse::Ok().json(MessageResponse {
message: "good".to_string(),
})
}
确实非常美妙,编译器能够帮助我们避免这些问题,让我们的代码保持安全和可靠。
空指针解引用
大多数人认为这个问题只存在于 C 语言中,但你也会在像 Java 或 Go 这样的语言中遇到它。这里是一个典型问题的例子:
type valueObject struct {
value *int
}
func getValue(vo *valueObject) int {
return *vo.value
}
你可能会说,“在使用值之前检查它是否为 nil 就好了。”这是 Go 语言中最大的陷阱之一 —— 它的指针机制。有时候我们会优化内存分配,有时候我们使用指针来表示可选值。
空指针解引用的风险在处理接口时尤其明显。
type Repository interface {
Get() int
}
func getValue(r Repository) int {
return r.Get()
}
func main() {
getValue(nil)
}
在许多语言中,将空值作为接口的有效选项传递是可以的。虽然代码审查通常会发现这类问题,但我还是见过一些空接口进入开发阶段的情况。在 Rust 中,这类问题是不可能发生的,这是对我们错误的另一层保护:
trait Repository {
fn get(&self) -> i32;
}
fn get_value(r: impl Repository) -> i32 {
r.get()
}
fn main() {
get_value(std::ptr::null());
}
Not to mention that it does not compile.
更不用说这段代码根本无法编译。
我承认,我是端口和适配器模式的大粉丝,这些模式包括了一些抽象概念。根据复杂度的不同,这些抽象可能是必要的,以便在你的应用程序中创建清晰的边界,从长远来看提高单元测试性和可维护性。批评者的一个论点是性能会下降,因为通常需要动态调度,因为在编译时无法确定具体的接口实现。让我们来看一个 Java 的例子:
@Service
public class StudentServiceImpl implements StudentService {
private final StudentRepository studentRepository;
@Autowired
public StudentServiceImpl(StudentRepository studentRepository) {
this.studentRepository = studentRepository;
}
}
Spring 为我们处理了很多幕后的事务。其中一个特性就是使用 @Autowired 注解来进行依赖注入。当应用程序启动时,Spring 会进行类路径扫描和反射。然而,这种便利性却伴随着性能成本。
在 Rust 中,我们可以创建这些清晰的抽象而不付出性能代价,这得益于所谓的零成本抽象:
struct ServiceImpl<T: Repository> {
repo: T,
}
trait Service{}
fn new_service<T: Repository>(repo: T) -> impl Service {
ServiceImpl { repo: repo }
}
这些抽象在编译时就被处理好,确保在运行时不会有任何性能开销。这使我们能够在不牺牲性能的情况下保持代码的整洁和高效。
数据转换
在企业级应用中,我们经常使用端口和适配器模式来处理复杂的业务需求。这种模式涉及将数据转换成不同层次所需的表示形式。我们可能通过异步通信接收到用户数据作为事件,或者通过同步通信接收到用户数据作为请求。然后,这些数据被转换成领域模型并通过服务和适配器层进行传递。
这就提出了一个问题:数据转换的逻辑应该放在哪里?应该放在领域包中吗?还是放在数据映射所在的包中?我们应该如何调用方法来转换数据?这些问题常常导致整个代码库中出现不一致性。
Rust 在提供一种清晰的方式来处理数据转换方面表现出色,使用 From 特质。如果我们需要将数据从适配器转换到领域,我们只需在适配器中实现 From 特质:
impl From<UserRequest> for domain::DomainUser {
fn from(user: UserRequest) -> Self {
domain::DomainUser {}
}
}
impl From<domain::DomainUser> for UserResponse {
fn from(user: domain::DomainUser) -> Self {
UserResponse {}
}
}
fn create_user(user: UserRequest) -> Result<()> {
let domain_user = domain::upsert_user(user.int0());
send_response(domain_user.int0())?;
Ok(())
}
通过在需要的地方实现 From 特质,Rust 提供了一种一致且直接的方式来处理数据转换,减少了不一致性,并使代码库更加易于维护。
性能
当然,Rust 很快这一点毋庸置疑,但它实际上给我们带来了哪些好处呢?我记得第一次将我的 Django 应用部署到 Kubernetes 上,并使用 kubectl top pods 命令来检查 CPU 和内存使用情况的时候。我很震惊地发现,即使没有任何负载,这个应用也几乎占用了 1GB 的 RAM。Java 也没好到哪里去。后来我发现了像 Rust 和 Go 这样的新语言,意识到事情可以做得更高效。
我查找了一些性能和资源使用方面的基准测试,并发现使用能够高效利用资源的语言可以节省很多成本。这里有一个例子:
Link to the original article
Link to the original article
想象一下,有一个 Lambda 函数被创建用来列出 AWS 账户中的所有存储桶,并确定每个存储桶所在的区域。你可能会认为,进行一些 REST API 调用并使用 for 循环在性能上不会有太大的区别。任何语言都应该能够合理地处理这个任务,对吧?
然而,测试显示 Rust 在执行这项任务时比 Python 快得多,并且使用更少的内存来达到这些执行时间。事实上,他们每百万次调用节省了 6 美元。
来自 web 和 Kubernetes 的背景,在那里我们根据用户负载进行扩缩容,我可以确认高效的资源使用能够节省成本并提高系统的可靠性。每个副本使用较少的资源意味着更多的容器可以装入一个节点。如果每个副本能够处理更多的请求,则总体上需要的副本数量就会减少。高性能和高效的资源利用对于构建成本效益高且可靠的系统至关重要。
我已经在 web 开发领域使用 Rust 三年了,对此我非常满意。那些具有挑战性的方面,比如编写异步代码或宏,都被我们使用的库很好地处理了。例如,如果你研究过 Tokio 库,你会知道它可能相当复杂。但在 web 开发中,我们专注于业务逻辑并与外部系统(如数据库或消息队列)交互,我们得以享受更简单的一面,同时受益于 Rust 出色的安全特性。
试试 Rust 吧;你可能会喜欢它,甚至成为一名更好的程序员。
来源:juejin.cn/post/7399288740908531712
我:CSS,你怎么变成紫色了?CSS:别管这些,都快2025了,这些新特性你还不知道吗?🤡
事情起因是这样的,大二的苦逼学生在给老外做页面的时候,做着做着无意间瞥见了css的图标。
wait!你不是我认识的css!你是谁?
我的天呐,你怎么成了这种紫色方块?(如果只关心为什么图标换了,可以直接跳到文章末尾)
这提起了我的兴趣,立马放下手中的工作去了解。查才知道,这是有原因的,而且在这之间CSS也更新了很多新特性。
不过看了许多博客,发现没啥人说这件事(也可能是我没找到),所以到我来更新了!😄
这里主要谈谈我认为还算有点用的新特性,全文不长,如果对您有用的话,麻烦给个赞和收藏加关注呗🙏!如果可以的话,掘金人气作者评选活动给孩子投一票吧😭
先叠个甲:所有观点纯本菜🐔意淫,各位大佬地方看个乐呵就行。
参考文献:张鑫旭的个人主页 » 张鑫旭-鑫空间-鑫生活
和MDN Web Docs
块级元素居中新方式:Align Content for Block Elements
元素垂直居中对齐终于有了专门的CSS属性,之前Flex布局和Grid布局中使用的align-content
属性,现在已经可以在普通的block块级元素中使用。
垂直居中的快级元素不再需要 flex 或 grid,注意是在垂直居中!!!
display: block; <-非块级元素请加上这个代码
align-content: center;
不过我好像以前用过,不过用的很少,不知道是不是发生了改变造成了这种效果🤡
请看如下代码
<style>
.father {
display: block;
align-content: center;
background-color: aqua;
width: 300px;
height: 300px
}
.son {
width: 100px;
height: 100px;
background-color: red;
}
</style>
可以发现是div是垂直居中显示的
实现效果和用flex是一样的
<style>
.father {
display: flex;
align-item:center
background-color: aqua;
width: 300px;
height: 300px
}
.son {
width: 100px;
height: 100px;
background-color: red;
}
</style>
提醒一下,目前普通元素并不支持justify-content属性,必须Flex布局或者Grid布局。
subgrid
额,这个特性似乎国内没有很多文章讲解,但是我记得之前看过一个统计这个特性在老外那里很受欢迎,所以我还是讲解一下。
subgrid并不是一个CSS属性,而是 grid-template-columns
和grid-template-rows
属性支持的关键字,其使用的场景需要外面已经有个Grid布局,否则……嗯,虽然语法上不会识别为异常,但是渲染结果上却是没有区别的。
例如
.container {
display: grid;
}
.item {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: subgrid;
}
那我们什么时候使用它呢?🤔
当我们想实现这种效果
Grid布局负责大的组织结构,而里面更细致的排版对齐效果,则可以使用subgrid布局。,这对于复杂的嵌套布局特别有用,在 subgrid 出现之前,嵌套网格往往会导致 CSS 变得复杂冗长。(其实你用flex也可以)
子网格允许子元素与父网格无缝对齐,从而简化了这一过程。
.container {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
}
.item {
display: grid;
grid-template-rows: subgrid;
grid-row: span 4;
gap: .5rem;
}
/* 以下CSS与布局无关 */
.item {
padding: .75rem;
background: #f0f3f9;
}
.item blockquote {
background-color: skyblue;
}
.item h4 {
background-color: #333;
color: #fff;
}
<div class="container">
<section class="item">
<h4>1</h4>
<p>负责人:张三</p>
<blockquote>脑子和我只能活一个</blockquote>
<footer>3人参与 12月21日</footer>
</section>
<section class="item">
<h4>1</h4>
<p>负责人:张三</p>
<blockquote>脑子和我只能活一个</blockquote>
<footer>3人参与 12月21日</footer>
</section>
</div>
效果
@property
@property规则属于CSS Houdini中的一个特性,可以自定义CSS属性的类型,这个特性在现代CSS开发中还是很有用的,最具代表性的例子就是可以让CSS变量支持动画或过渡效果。
我个人认为这个东西最大的作用就是在我们写颜色渐变的时候很好避免使用var()
不小心造成颜色继承的,而导致效果不理想。
用法
@property --rotation {
syntax: "<angle>";
inherits: false;
initial-value: 45deg;
}
描述符
syntax
描述已注册自定义属性允许的值类型的字符串。可以是数据类型名称(例如<color>
、<length>
或<number>
等),带有乘数(+
、#
)和组合符(|
),或自定义标识。inherits
一个布尔值,控制指定的自定义属性注册是否默认@property
继承。initial-value
设置属性的起始值的值。
描述
注意
简单演示
@property --box-pink {
syntax: "<color>";
inherits: false;
initial-value: pink;
}
.box {
width: 100px;
height: 100px;
background-color: var(--box-pink);
}
使用它进行颜色渐变
@property --colorA {
syntax: "<color>";
inherits: false;
initial-value: red;
}
@property --colorB {
syntax: "<color>";
inherits: false;
initial-value: yellow;
}
@property --colorC {
syntax: "<color>";
inherits: false;
initial-value: blue;
}
.box {
width: 300px;
height: 300px;
background: linear-gradient(45deg,
var(--colorA),
var(--colorB),
var(--colorC));
animation: animate 3s linear infinite alternate;
}
@keyframes animate {
20% {
--colorA: blue;
--colorB: #F57F17;
--colorC: red;
}
40% {
--colorA: #FF1744;
--colorB: #5E35B1;
--colorC: yellow;
}
60% {
--colorA: #E53935;
--colorB: #1E88E5;
--colorC: #4CAF50;
}
80% {
--colorA: #76FF03;
--colorB: teal;
--colorC: indigo;
}
}
</style>
transition-behavior让display none也有动画效果
大家都知道我们在设置一个元素隐藏和出现是一瞬间的,那有没有办法让他能出现类似于淡入淡出的动画效果呢?
这里我们就要介绍transition-behavior了,但是也有其他方法,这里就只介绍它。
语法如下:
transition-behavior: allow-discrete;
transition-behavior: normal;
allow-discrete
表示允许离散的CSS属性也支持transition
过渡效果,其中,最具代表性的离散CSS属性莫过于display属性了。
使用案例
仅使用transition
属性,实现元素从 display:inline ↔ none 的过渡效果。
img {
transition: .25s allow-discrete;
opacity: 1;
height: 200px;
}
img[hidden] {
opacity: 0;
}
<button id="trigger">图片显示与隐藏</button>
<img id="target" src="./1.jpg" />
trigger.onclick = function () {
target.toggleAttribute('hidden');
};
这里我们可以发现消失的时候是有淡出效果的,但是出现却是一瞬间的,这是为什么?
原因是:
display:none
到display:block
的显示是突然的,在浏览器的渲染绘制层面,元素display计算值变成block和opacity设为1是在同一个渲染帧完成的,由于没有起始opacity,所以看不到动画效果。
那有没有什么办法能解决呢?🤔
使用@starting-style规则声明过渡起点
@starting-style
顾名思义就是声明起始样式,专门用在transition过渡效果中。
例如上面的例子,要想让元素display显示的时候有淡出效果,很简单,再加三行代码就可以了:
img {
transition: .25s allow-discrete;
opacity: 1;
@starting-style {
opacity: 0;
}
}
或者不使用CSS嵌套语法,这样写也是可以的:
img {
transition: .25s allow-discrete;
opacity: 1;
}
@starting-style {
img {
opacity: 0;
}
}
此时,我们再点击按钮让图片显示,淡入淡出效果就都有了。
注意:
@starting-style
仅与 CSS 过渡相关。使用CSS 动画实现此类效果时,@starting-style
就不需要。
light-dark
先说明一下,我认为 CSS 的新 light-dark() 函数是 2024 年实现暗模式的最佳方式!
你自 2019 年以来,开发人员只需一行 CSS 就可以为整个站点添加暗模式?只需在 :root 中添加 color-scheme: light dark;,就可以获得全站暗模式支持——但它只适用于未指定颜色的元素,因此使用默认的浏览器颜色。
如果你想让自定义颜色的暗模式生效(大多数网站都需要),你需要将每个颜色声明包装在笨拙的 @media (prefers-color-scheme: ...) 块中:
@media (prefers-color-scheme: dark) {
body {
color: #fff;
background-color: #222;
}
}
@media (prefers-color-scheme: light) {
body {
color: #333;
background-color: #fff;
}
}
基本上,你需要把每个颜色声明写两遍。糟糕!这种冗长的语法使得编写和维护都很麻烦。因此,尽管 color-scheme
已发布五年,但从未真正流行起来。
light-dark很好解决了这个问题
基本语法
/* Named color values */
color: light-dark(black, white);
/* RGB color values */
color: light-dark(rgb(0 0 0), rgb(255 255 255));
/* Custom properties */
color: light-dark(var(--light), var(--dark));
body {
color-scheme: light dark; /* 启用浅色模式和深色模式 */
color: light-dark(#333, #fff); /* 文本浅色和深色颜色 */
background-color: light-dark(#fff, #222); /* 背景浅色和深色颜色 */
}
在这个示例代码中,正文文本在浅色模式下定义为 #333
,在深色模式下定义为 #fff
,而背景色则分别定义为 #fff
和 #222
。就这样!浏览器会根据用户的系统设置自动选择使用哪种颜色。
无需 JavaScript 逻辑、自定义类或媒体查询。一切都能正常工作!
:user-vaild pseudo class
:user-valid
CSS伪类表示任何经过验证的表单元素,其值根据其验证约束正确验证。然而,与:valid
此不同的是,它仅在用户与其交互后才匹配。
有什么用呢?🤔
这就很好避免了我们在进行表单验证的时候,信息提示在你交互之前出现的尴尬。
<form>
<label for="email">Email *: </label>
<input
id="email"
name="email"
type="email"
value="test@example.com"
required />
<span></span>
</form>
input:user-valid {
border: 2px solid green;
}
input:user-valid + span::before {
content: "😄";
}
在以下示例中,绿色边框和😄仅在用户与字段交互后显示。我们将电子邮件地址更改为另一个有效的电子邮件地址就可以看到了
interpolate-size
interpolate-size
和calc-size()
函数属性的设计初衷是一致的,就是可以让width、height等尺寸相关的属性即使值是auto,也能有transition过渡动画效果。
最具代表性的就是height:auto的过渡动画实现。
p {
height: 0;
transition: height .25s;
overflow: hidden;
}
.active+p {
height: auto;
height: calc-size(auto, size);
}
<button onClick="this.classList.toggle('active');">点击我</button>
<p>
<img src="./1.jpg" width="256" />
</p>
其实,要让height:auto
支持transition过渡动画,还有个更简单的方法,就是在祖先容器上设置:
interpolate-size: allow-keywords;
换句话说,calc-size()
函数是专门单独设置,而interpolate-size
是全局批量设置。
interpolate-size: allow-keywords;
interpolate-size: numeric-only;
/* 全局设置 */
/* :root {
interpolate-size: allow-keywords;
} */
div {
width: 320px;
padding: 1em;
transition: width .25s;
/* 父级设置 */
interpolate-size: allow-keywords;
background: deepskyblue;
}
.active+div {
width: 500px;
}
</style>
<button onClick="this.classList.toggle('active');">点击我</button>
<div>
<img src="./1.jpg" width="256" />
</div>
全新的CSS相对颜色语法-使用from
from的作用我认为是简化了我们让文字自动适配背景色的步骤
我们先来看看用法
p {
color: rgb(from red r g b / alpha);
}
原因:r g b 以及 alpha 实际上是对red
的解构,其计算值分别是255 0 0 / 1(或者100%)。
注意:实际开发,我们不会使用上面这种用法,这里只是为了展示语法作用。
使用from让文字适配背景色
<button id="btn" class="btn">我是按钮</button>
<p>请选择颜色:<input
type="color"
value="#2c87ff"
onInput="btn.style.setProperty('--color', this.value);"
></p>
<p>请选择颜色:<input
type="color"
value="#2c87ff"
onInput="btn.style.setProperty('--color', this.value);"
></p>
rebecca purple(#663399)
好了,最重要的东西来了,关于为什么变成了紫色,其实他们把它叫做rebecca紫,为什么叫这个名字呢?这其实是一个令人悲伤的故事😭。
在关于css这个新颜色以及logo的时候,内部发生了许多争议。
但是相信大部分人都读过CSS The Definitive guide,他的作者Eric A.Myer的女儿在这期间因为癌症去世了
在她去世的前几周,Rebecca说她即将成为一个六岁大的女孩,而becca是一个婴儿的名字。六岁后,他希望每个人都叫他Rebecca,而不是becca。
而那个女孩和病魔抗争,一直坚持到她到六岁,
我无法想象假如我是父亲,失去一个那么可爱的一个六岁的孩子,那个心情有多么痛苦。
最终社区被他的故事感动了,css的logo也就变成了这样。
总结
新特性多到让人麻木,真的非常非常多!!!!😵💫
这些新特性出现的速度比某四字游戏出皮肤的速度还快🚀,关键这些特性浏览器支持情况参差不齐,短时间无法在生产环境应用。
我真的看了非常都非常久,从早上五点起来开始看文档,除去吃饭上课,加上去写文章一直弄到凌晨三点,才选出这么几个我认为还算有点作用的新特性。
而且现有的JavaScript能力已经足够应付目前所有的交互场景,很多新特性没有带来颠覆性的改变,缺少迫切性和必要性,很难被重视。
最后的最后,希望大家的身边的亲人身体都健健康康的,也希望饱受癌症折磨的人们能够早日康复🙏
来源:juejin.cn/post/7450434330672234530
Nuxt 3手写一个搜索页面
Nuxt 3手写一个搜索页面
前言
前面做了一个小型搜索引擎,虽然上线了,但总体来说还略显粗糙。所以最近花了点时间打磨了一下这个搜索引擎的前端部分,使用的技术是Nuxt
,UI组件库使用的是Vuetify
,关于UI组件库的选择,你也可以查看我之前写的这篇对比。
本文不会介绍搜索引擎的其余部分,算是一篇前端技术文...
重要的:开源地址,应用部分的代码我也稍微整理了一下开源了,整体来说偏简单,毕竟只有一个页面,算是真正的“单页面应用”了🤣🤣🤣
演示
为什么要重写
这次重写的目的如下:
- 之前写的代码太乱了,基本一个页面就只写了一个文件,维护起来有点困难;
- 之前的后端使用nest单独写的,其实就调调API,单独起一个后端服务感觉有点重;
- 最后一点也是最重要的:使用SSR来优化一下SEO
具体如下:
- 比如当用户输入搜索之后,对应的url路径也会发生变化,比如ssgo.app/?page=1&que…,
- 如果用户将该url分享到其他平台被搜索引擎抓取之后,搜索引擎得到的数据将不再是空白的搜索框,而是包含相关资源的结果页,
- 这样有可能再下一次用户在其他搜索引擎搜索对应资源的时候,有可能会直接跳转到该应用的搜索结果页,这样就可以大大提高该应用的曝光率。
这样,用户之后不仅可以通过搜索“阿里云盘搜索引擎”能搜到这个网站,还有可能通过其他资源的关键词搜索到该网站
页面布局
首先必须支持移动端,因为从后台的访问数据看,移动端的用户更多,所以整体布局以竖屏为主,至于宽屏PC,则增加一个类似于max-width
的效果。
其次为了整体实现简单,采取的还是搜索框与搜索结果处在一个页面,而非google\baidu之类的搜索框主页与结果页分别是两个页面,笔者感觉主页也没啥存在的必要(单纯对于搜索功能而言)
页面除了搜索框、列表项,还应该有logo,菜单,最终经过排版如下图所示:
左右两边为移动端的效果演示图,中间为PC端的效果演示。
nitro服务端部分
这里只需要实现两个API:
- 搜索接口,如
/api/search
- 搜索建议的接口,如
/api/search/suggest
说到这里就不得不夸一下nuxt的开发者体验,新建一个API是如此的方便:
对比nest-cli中新建一个service/controller要好用不少,毕竟我在nest-cli中基本要help
一下。
回到这里,我的server目录结构如下:
├─api
│ └─search # 搜索接口相关
│ index.ts # 搜索
│ suggest.ts # 搜索建议
│
└─elasticsearch
index.ts # es客户端
在elasticsearch
目录中,我创建了一个ES的客户端,并在search
中使用:
// elasticsearch/index.ts
import { Client } from '@elastic/elasticsearch';
export const client = new Client({
node: process.env.ES_URL,
auth: {
username: process.env.ES_AUTH_USERNAME || '',
password: process.env.ES_AUTH_PASSWORD || ''
}
});
然后使用,使用部分基本上没有做任何的特殊逻辑,就是调用ES client提供的api,然后组装了一下参数就OK了:
// api/search/index
import { client } from "~/server/elasticsearch";
interface ISearchQuery {
pageNo: number;
pageSize: number;
query: string;
}
export default defineEventHandler(async (event) => {
const { pageNo = 1, pageSize = 10, query }: ISearchQuery = getQuery(event);
const esRes = await client.search({
index: process.env.ES_INDEX,
body: {
from: (pageNo - 1) * pageSize, // 从哪里开始
size: pageSize, // 查询条数
query: {
match: {
title: query, // 搜索查询到的内容
},
},
highlight: {
pre_tags: ["<span class='highlight'>"],
post_tags: ['</span>'],
fields: {
title: {},
},
fragment_size: 40,
},
},
});
const finalRes = {
took: esRes.body.took,
total: esRes.body.hits.total.value,
data: esRes.body.hits?.hits.map((item: any) => ({
title: item._source.title,
pan_url: item._source.pan_url,
extract_code: item._source.extract_code,
highlight: item.highlight?.title?.[0] || '',
})),
};
return finalRes;
});
// api/search/suggest
import { client } from "~/server/elasticsearch";
interface ISuggestQuery {
input: string;
}
export default defineEventHandler(async (event) => {
const { input }: ISuggestQuery = getQuery(event);
const esRes = await client.search({
index: process.env.ES_INDEX,
body: {
suggest: {
suggest: {
prefix: input,
completion: {
field: "suggest"
}
}
}
},
});
const finalRes = esRes.body.suggest.suggest[0]?.options.map((item: any) => item._source.suggest)
return finalRes;
});
值得注意的是,客户端的ES版本需要与服务端的ES版本相互对应,比如我服务端使用的是ES7,这路也当然得使用ES7,如果你是ES8,这里需要安装对应版本得ES8,并且返回参数有些变化,ES8中上述esRes
就没有body属性,而是直接使用后面的属性
page界面部分
首先为了避免出现之前所有代码均写在一个文件中,这里稍微封装了几个组件以使得page/index
这个组件看起来相对简单:
/components
BaseEmpty.vue
DataList.vue
LoadingIndicator.vue
MainMenu.vue
PleaseInput.vue
RunSvg.vue
SearchBar.vue
具体啥意思就不赘述了,基本根据文件名就能猜得大差不差了...
然后下面就是我的主页面部分:
<template>
<div
class="d-flex justify-center bg-grey-lighten-5 overflow-hidden overflow-y-hidden"
>
<v-sheet
class="px-md-16 px-2 pt-4"
:elevation="2"
height="100vh"
:width="1024"
border
rounded
>
<v-data-iterator :items="curItems" :page="curPage" :items-per-page="10">
<template #header>
<div class="pb-4 d-flex justify-space-between">
<span
class="text-h4 font-italic font-weight-thin d-flex align-center"
>
<RunSvg style="height: 40px; width: 40px"></RunSvg>
<span>Search Search Go...</span>
</span>
<MainMenu></MainMenu>
</div>
<SearchBar
:input="curInput"
@search="search"
@clear="clear"
></SearchBar>
</template>
<template #default="{ items }">
<v-fade-transition>
<DataList
v-if="!pending"
:items="items"
:total="curTotal"
:page="curPage"
@page-change="pageChange"
></DataList>
<LoadingIndicator v-else></LoadingIndicator>
</v-fade-transition>
</template>
<template #no-data>
<template v-if="!curInput || !pending">
<v-slide-x-reverse-transition>
<BaseEmpty v-if="isInput"></BaseEmpty>
</v-slide-x-reverse-transition>
<v-slide-x-transition>
<PleaseInput v-if="!isInput"></PleaseInput>
</v-slide-x-transition>
</template>
</template>
</v-data-iterator>
</v-sheet>
</div>
</template>
<script lang="ts" setup>
const route = useRoute();
const { query = "", page = 1 } = route.query;
const router = useRouter();
const defaultData = { data: [], total: 0 };
const descriptionPrefix = query ? `正在搜索“ ${query} ”... ,这是` : "";
useSeoMeta({
ogTitle: "SearchSearchGo--新一代阿里云盘搜索引擎",
ogDescription: `${descriptionPrefix}一款极简体验、优雅、现代化、资源丰富、免费、无需登录的新一代阿里云盘搜索引擎,来体验找寻资源的快乐吧~`,
ogImage: "https://ssgo.app/logobg.png",
twitterCard: "summary",
});
interface IResultItem {
title: string;
pan_url: string;
extract_code: string;
highlight: string;
}
interface IResult {
data: IResultItem[];
total: number;
}
const curPage = ref(+(page || 1));
const curInput = ref((query || "") as string);
const isInput = computed(() => !!curInput.value);
let { data, pending }: { data: Ref<IResult>; pending: Ref<boolean> } =
await useFetch("/api/search", {
query: { query: curInput, pageNo: curPage, pageSize: 10 },
immediate: !!query,
});
data.value = data.value || defaultData;
const curItems = computed(() => data.value.data);
const curTotal = computed(() => data.value.total);
function search(input: string) {
curPage.value = 1;
curInput.value = input;
router.replace({ query: { ...route.query, query: input, page: 1 } });
}
function pageChange(page: number) {
curPage.value = page;
router.replace({ query: { ...route.query, page: page } });
}
function clear() {
curInput.value = "";
data.value = defaultData;
// 这里就不替换参数了,保留上一次的感觉好一些
}
</script>
大部分代码都是调用相关的子组件,传递参数,监听事件之类的,这里也不多说了。比较关键的在于这两部分代码:
useSeoMeta({
ogTitle: "SearchSearchGo--新一代阿里云盘搜索引擎",
ogDescription: `${descriptionPrefix}一款极简体验、优雅、现代化、资源丰富、免费、无需登录的新一代阿里云盘搜索引擎,来体验找寻资源的快乐吧~`,
ogImage: "https://ssgo.app/logobg.png",
twitterCard: "summary",
});
这里的SEO显示的文字是动态的,比如当前用户正在搜索AI
,那么url路径参数也会增加AI
,分享出去的页面描述就会包含AI
,在twitter中的显示效果如下:
还有部分代码是这一部分:
let { data, pending }: { data: Ref<IResult>; pending: Ref<boolean> } =
await useFetch("/api/search", {
query: { query: curInput, pageNo: curPage, pageSize: 10 },
immediate: !!query,
});
其中immediate: !!query
表示如果当前路径包含搜索词,则会请求数据,渲染结果页,否则不立即执行该请求,而是等一些响应式变量如curInput
、 curPage
发生变化后执行请求。
子组件部分这里就不详细解释了,具体可以查看源码,整体来说并不是很复杂。
其他
除此之外,我还增加了google analytics和百度 analytics,代码都非常简单,在plugins/
目录下,如果你需要使用该项目,记得将对应的id改为你自己的id。
最后
这次也算是第一次使用nuxt来开发一个应用,总体来说安装了nuxt插件之后的开发体验非常不错,按照目录规范写代码也可以少掉很多导入导出的一串串丑代码。
关于笔者--justin3go.com
来源:juejin.cn/post/7327938054240780329
又报gyp ERR!为什么有那么多人被node-sass 坑过?
前言
node-sass: Command failed.
, gyp ERR! build error
这几个词相信很多小伙伴一定看着眼熟,当你的终端出现这些词时那么毫无疑问,你的项目跑不起来了。。。。。。
你可能通过各种方式去解决了这个报错,但是应该没有人去深究到底是咋回事,接下来让我们彻底弄懂,再遇到类似问题时能够举一反三,顺利解决。
关键词:node-sass
libsass
node-gyp
先来看个截图感受一下
熟悉吧?截图里我们看到了几个关键词 node-sass
libsass
node-gyp
.cpp
,我们一一来解释一下
node-sass
node-sass
是一个用于在 Node.js 中编译 Sass
文件的库,node-sass
可以将 .scss 或 .sass 文件编译为标准的 .css 文件,供浏览器或其他工具使用。
- node-sass 可以看作是 libsass 的一个包装器(wrapper)或者说是 nodejs 和 libsass 之间的桥梁。
- 它提供了一个 Node.js 友好的接口来使用 libsass 的功能。
- 当你使用 node-sass 时,实际的 Sass 编译工作是由底层的 libsass 完成的。
当你的项目中使用 sass 来写样式时,会直接或间接地引入这个这个库。
libsass
一个用 C++
编写的高性能 Sass 编译器,这就是为什么你能在终端日志中看到 .cpp 的文件。
注意,搞这么麻烦就是为了高性能编译sass。
node-gyp
node-sass引入了 c++ 编写的库,那么直接在 node 中肯定是使用不了的,毕竟是两种不同的语言嘛,那么就需要 node-gyp 登场了。
node-gyp 是一个用于编译和构建原生
Node.js 模块的工具,这些原生模块通常是用 C++ 编写的。
node-sass 需要在安装时编译 libsass 的 C++ 代码,以生成可以在本地机器上运行的二进制文件。在这个编译过程中,node-gyp
就被用作构建工具,它负责调用 C++ 编译器(如 g++ 或 clang++),并将 libsass 的源代码编译为与当前系统兼容的二进制文件。
node-gyp 本身是一个用 JavaScript 编写的工具。它使用 Node.js 的标准模块(如 fs 和 path)来处理构建和配置任务,但是需要一些外部工具来实际进行编译和构建,例如 make、gcc等,这些工具必须安装在系统中,node-gyp 只是协调和使用这些工具。
普通模块(JavaScript/TypeScript)
普通模块是用 JavaScript 或 TypeScript 编写的,Node.js 本身可以直接执行或通过编译(如 TypeScript 编译器)转换为可执行代码,Node.js 使用 V8 引擎执行 JavaScript 代码。
原生模块(C/C++ 编写)
原生模块是用 C/C++ 编写的代码,这些模块通常用于高性能需求或需要直接与底层系统 API 交互的场景。它们通过 node-gyp 进行编译,并在 Node.js 中以二进制文件的形式加载。
例如以下模块:
- node-sass:编译 Sass 文件,依赖 libsass 进行高性能的样式编译。
- sharp:处理图像文件,使用 libvips(c++库) 提供高效的图像操作。
- bcrypt:进行加密处理,提供安全的密码哈希功能。
- fsevents:用于 macOS 系统的文件系统事件监听。
许多现有的高性能库和工具是用 C++ 编写的。为了利用这些库的功能,Node.js 可以通过模块接口调用这些 C++ 库,尤其某些高级功能,如加密算法、图像处理等,已经在 C++ 中得到了成熟的实现,Node.js 可以直接集成这些功能,而不必再重新造轮子而且性能还肯定不如用 c++ 写的库。
所谓的原生模块接口就是 node-gyp(还有Node-API (N-API)等),感兴趣的可以看看 node-gyp 的实现,或者了解下包信息
npm info node-gyp
node-gyp 基于 gyp,并在其之上添加了 Node.js 特定的功能,以支持 Node.js 模块的编译。
gyp
gyp(Generate Your Projects)是一个用于生成构建配置文件的工具,它负责将源代码(C++ 代码)转换为特定平台的构建配置文件(如 Makefile、Visual Studio 项目文件等),生成构建文件后,使用生成的构建文件来编译项目。例如,在 Unix 系统上,运行 make;在 Windows 上,使用 Visual Studio 编译项目。
从前端的角度来看,你可以把gyp理解成 webpack,而 make 命令则是 npm run build
为什么报错?
现在,我们已经了解了一些基本概念,并且知道了c++原生模块是需要在安装时编译才能使用的,那么很明显,上面的错误就是在编译时出错了,一般出现这个错误都是因为我们有一些老的项目的 node 版本发生了变化,例如上面的报错就是,在 node14 还运行的好好的,到了 node16 就报错了,我们再来看一下报错细节
这个错误发生在编译 node-sass 的过程中,具体是在编译 binding.cpp 文件时。
binding.cpp 是 node-sass 的一部分,用于将 libsass 与 Node.js 接口连接起来。
报错发生在 v8-internal.h 文件里,大概意思是这个c++头文件里使用了比较高级的语法,node-sass中的c++标准不支持这个语法,导致报错,就相当于你在 js 项目里引入一个三方库,这个三方库使用了 es13 的新特性(未编译为es5),但是你的项目只支持 es6,从而导致你的项目报错。
所以解决思路就很清晰了,要么降级 node 版本,要么升级 node-sass,要么干脆就不用node-sass了
解决方案
rebuild 大法
npm rebuild node-sass
重新编译模块,对于其他编译错误该方法可能会有效果,但是如果 node-sass 和 node 版本不匹配那就没得办法了,适合临时使用,不推荐。
升级node-sass
以下是 node-sass 支持的最低和最高版本的指南,找对应的版本升级就好了
但是!node-sass 与node版本强绑定,还得去想着保持版本匹配,心智负担较重,所以使用 sass
更好
更换为 sass
官方也说了,不再释放新版本,推荐使用 Dart Sass
Sass 和 node-sass 的区别
实现语言:
- Sass:Sass 是一个用 Ruby 编写的样式表语言,最初是基于 Ruby 的实现(ruby-sass)。在 Ruby 版本被弃用后,Sass 社区推出了 Dart 语言实现的版本,称为 dart-sass。这个版本是当前推荐的实现,提供了最新的功能和支持。
- node-sass:node-sass 封装了 libsass,一个用 C++ 编写的 Sass 编译器。node-sass 使用 node-gyp 编译 C++ 代码和链接 libsass 库。
构建和编译:
- Sass:dart-sass 是用 Dart 语言编写的,直接提供了一个用 JavaScript 实现的编译器,因此不需要 C++ 编译过程。它在 Node.js 环境中作为纯 JavaScript 模块运行,避免了编译问题。
- node-sass:node-sass 依赖于 libsass(C++ 编写),需要使用 node-gyp 进行编译。很容易导致各种兼容性问题。
功能和维护:
- Sass:dart-sass 是 Sass 的官方推荐实现,拥有最新的功能和最佳的支持。它的更新频繁,提供了对最新 Sass 语言特性的支持。
- node-sass:由于 node-sass 依赖于 libsass,而 libsass 的维护在 2020 年已停止,因此 node-sass 逐渐不再接收新的功能更新。它的功能和特性可能滞后于 dart-sass。
性能:
- Sass:dart-sass 的性能通常比 node-sass 更佳,因为 Dart 编译器的性能优化更加现代。
- node-sass:虽然 libsass 在性能上曾经表现良好,但由于它不再更新,可能不如现代的 Dart 实现高效。
总结
gyp ERR!
和 node-sass
问题是由 node-sass 与 node 版本兼容引起的编译问题,推荐使用 sass
替代,如果老项目不方便改的话就采用降级 node 版本或升级 node-sass 的办法。
其实这种问题没太大必要刨根究底的,但是如果这次简单解决了下次遇到类似的问题还是懵逼,主要还是想培养自己解决问题的思路,再遇到类似天书一样的坑时不至于毫无头绪,浪费大量时间。
来源:juejin.cn/post/7408606153393307660
记一次让我压力大到失控的 bug
事情经过
前两天线上发生了结算的漏洞,这里的代码是我写的,出问题的时候是周日晚上,那天大领导打电话过来问我具体的损失情况。
最后查出来是有两个人逮到了系统漏洞,一共 87 笔订单出现了多结算的问题,薅了大概 2.6 w,有个人当时已经跑了,还有个账户里面只有几百块钱。
发现问题后紧急停止提现,其他的明天上班再处理。
但我当晚已经无法入睡了,压力非常非常大。
普通开发和项目负责人最大的区别,可能是后者要承担风险和责任,但这个项目就我一个开发,我要同时兼顾两种角色。
周一早上去上班,前一天晚上也没睡,精神状态可想而知,浑浑噩噩的,先把问题和场景分析清楚,最终发现是用户在加价的时候回写了订单状态,导致重复结算。紧急加上各种校验,然后把所有相关的接口全部加强校验。
代码还没写完,就被拉去会议室开批斗会了,直属领导和大领导怼着我一顿骂,诸如什么高级开发、别人犯这种错误早滚蛋了,我还不识趣的解释了下具体原因,结果被骂的更凶了。
所以记住了,以后犯错了被骂,不要说为什么出错,只要认错,再加上解决时间。
心态更差了,压力可想而知,花了一天干到凌晨,修复上线。
结果过了几天,又出现重复问题了。
仔细一看真是倒了血霉,底层架构分布式锁写的有问题,没锁住请求,在 finally 用了 redission 的 forceunlock,导致并发场景下当前线程的锁被别的线程释放了。
然后就有了第二次批斗会,这次简短扼要得多,大致就说了两句,今天必须修复完成,再出问题自己提离职申请。
回到工位先修好了分布式锁,又在资金流水加了道数据库锁。又是干到凌晨,加上中间还有个比较紧急的需求,有几次干到了凌晨一两天。
到今天事情过去了快一周。
回想那几天的经历,晚上基本上没睡觉,压力非常大,到凌晨三四点都是常态,对身体和精神都是非常强烈的折磨,辞职的念头此起彼伏。对 bug 也有了 PTSD。
昨天晚上商务同事告诉我已经追回了 1.5w,预计可以追回 2.4w,后面的分批追回。
真心感谢商务同事,也给别人造成了不少麻烦。
心理感悟
不知道你如果遇到我经历的情况有什么感受。希望你不要有我这么大的压力。不过这次压力也让我更深刻的体会到了一些事。
就一份工作而已
就一份工作而已,大不了辞职不干了嘛,不要给自己那么大的压力,压垮了身体,公司也不会负责,受伤的还是自己。
人得自私一点,多为自己想想,少点集体荣誉感责任心什么的,任何一份工作都是自己成长的垫脚石。
要成为工作的主人,不要反被工作拿捏了。
我之前觉得公司给我工资了,我得好好为它卖命。
现在看看自己这些年写的系统,今年已经给公司创造了 600w+ 的GMV,尽管还有其他商务、运营同事共同点功劳,但我的工资是远远远远不及这个的,从这个角度看,公司应该感谢我这么多年辛苦工作。
最重要永远是身体、亲人和朋友
人生说长不长,说短不短。大家都是几十年时间,身外之物,生不带来,死不带走。
自己的身体健康才是最重要的,不要等身体垮了再追悔莫及,很多健康问题是无法恢复只能缓解的,医学没我们想的那么发达。
其次是家人、伴侣、朋友。想想自己出生和去世的时候,身边会有哪些人?这时候工作在哪里呢?
我以前对家人很不耐烦,现在我对他们有耐心了很多,连老婆都觉得我 “好欺负” 了哈哈哈,或许说再多都不会有用,直到自己刻骨铭心的经历一波。
业务、公司、地球没了你照样转,但是亲人、爱人、朋友没了你,他们会很伤心。
心态非常重要
出天大的 bug,业务也不会凉,公司也不会倒闭,天也不会塌,地球不会停止自转,太阳不会熄灭,更重要的是,写 bug 的人不会死。
极大的压力、焦虑并不会让我把事情做的更好,像我这种搞的几晚上睡不了的人,再去修 bug,效率真不如美美睡一觉再去。
现在回过头看,自己那么大压力完全是自己给的,像是自残。人得对自己好一点,不要给自己那么大的压力。
还有领导说的什么自己提离职、这么低级的错误等等骂人的话,别太往心里去,他也是气头上找个东西宣泄下,或者是想告诉你这很重要必须搞好,这是一种情绪的表达。
你别管他说什么,你现在就是他的出气筒,等他骂完你只要表达你必须解决点问题的态度和解决的时间点就行了。
给他点情绪价值。领导也有更大的领导要汇报内容,到时候指不定他被骂的更凶呢哈哈。
付出行动
纠正完认知和心态,还是要回归到行动上,如何避免类似的情况再发生,从失败中学习。
像这次结算出问题后,我真正理解了结算所涉及的各种细节和要点,以后再写的任何项目,结算都不可能再出问题了。
感兴趣的同学可以翻翻我前两篇动态,有关于结算系统设计的文档,后面我还会模拟订单场景,结合建表语句做个详细的结算架构设计。
对我来说,事情解决才会让我安心。
就像我必须把系统里涉及结算的场景、代码重新梳理清楚,搞明白了,保证下次不可能再出问题了,我才会真正的没有压力。
我希望你知道这很重要,只是把心态调整好而不去真正的解决问题,下次还是会出问题,甚至会有不知道什么时候会出问题的恐惧。
解决问题需要付出精力和行动,这可能更难,但这才是人成长和进步的原因。
加油,共勉。
来源:juejin.cn/post/7450700990389305396
Tauri+MuPDF 实现 pdf 文件裁剪,侄子再也不用等打印试卷了🤓
基于
MuPDF.js
实现的 PDF 文件 A3 转 A4 小工具。(其实就是纵切分成2份🤓)
开发背景
表哥最近经常找我给我侄子的试卷 pdf
文件 A3 转 A4(因为他家只有 A4 纸,直接打印字太小了)。
WPS
提供了pdf
的分割工具,不过这是会员功能,我也不是总能在电脑前操作。于是我想着直接写一个小工具,拿Tauri
打包成桌面应用就好了。
在掘金里刷到了柒八九大佬的文章:Rust 赋能前端:PDF 分页/关键词标注/转图片/抽取文本/抽取图片/翻转... 。发现MuPDF.js
这个包有截取pdf
文件的API
,并且提供了编译好的wasm
文件,这意味着可以在浏览器中直接体验到更高的裁切性能,于是我果断选择了基于MuPDF
开发我的小工具。
项目简介
MuPDF-Crop-Kit
是一个基于MuPDF.js
、React
、Vite
和Tauri
开发的小工具,用于将 PDF 文件从 A3 纸张大小裁切为 A4 纸张大小。它具有以下特点:
- 免费使用:无需任何费用;
- 无需后台服务:可以直接在浏览器中运行,无需依赖服务器;
- 高性能:利用 WebAssembly (WASM) 技术,提供高效的文件裁切性能;
- 轻量级桌面应用:通过 Tauri 打包成桌面软件,安装包体积小,方便部署;
- 开源项目:欢迎社区贡献代码和建议,共同改进工具。
项目代码地址
Github
:MuPDF-Crop-Kit
开发过程与踩坑
MuPDF.js
只支持ESM
,官网中给出的要么使用.mjs
文件,要么需要项目的type
改成module
:
npm pkg set type=module
我在我的
Rsbuild
搭建的项目中都没有配置成功🤷♂️,最后发现用Vite
搭建的项目直接就可以用...- 因为没有直接提供我想要的功能,肯定是要基于现有的
API
手搓了。但是截取页面的API
会修改原页面,那么自然想到是要复制一份出来,一个截左边一个截右边了。但是MuPDF.js
的copyPage
复制出来的pdf
页修改之后,原pdf
页居然也会被修改。
于是我想到了,一开始就new
两个PDFDocument
对象,一个截左边一个截右边,最后再合并到一起,我很快实现了两个文档的分别截取,并通过转png
图片之后再合并,完成了裁切后的文档的浏览器预览。
然后我考虑直接使用jspdf
把png
图片转pdf
文件,结果2MB
的原文件转换后变成了12MB
,并且如果原文件是使用扫描全能王
扫描出来的,生成的pdf
文件会很糊。
最后,终于让我在文档中发现merge
方法:
不过依赖包提供的方法很复杂:
merge(sourcePDF, fromPage = 0, toPage = -1, startAt = -1, rotate = 0, copyLinks = true, copyAnnotations = true) {
if (this.pointer === 0) {
throw new Error("document closed");
}
if (sourcePDF.pointer === 0) {
throw new Error("source document closed");
}
if (this === sourcePDF) {
throw new Error("Cannot merge a document with itself");
}
const sourcePageCount = sourcePDF.countPages();
const targetPageCount = this.countPages();
// Normalize page numbers
fromPage = Math.max(0, Math.min(fromPage, sourcePageCount - 1));
toPage = toPage < 0 ? sourcePageCount - 1 : Math.min(toPage, sourcePageCount - 1);
startAt = startAt < 0 ? targetPageCount : Math.min(startAt, targetPageCount);
// Ensure fromPage <= toPage
if (fromPage > toPage) {
[fromPage, toPage] = [toPage, fromPage];
}
for (let i = fromPage; i <= toPage; i++) {
const sourcePage = sourcePDF.loadPage(i);
const pageObj = sourcePage.getObject();
// Create a new page in the target document
const newPageObj = this.addPage(sourcePage.getBounds(), rotate, this.newDictionary(), "");
// Copy page contents
const contents = pageObj.get("Contents");
if (contents) {
newPageObj.put("Contents", this.graftObject(contents));
}
// Copy page resources
const resources = pageObj.get("Resources");
if (resources) {
newPageObj.put("Resources", this.graftObject(resources));
}
// Insert the new page at the specified position
this.insertPage(startAt + (i - fromPage), newPageObj);
if (copyLinks || copyAnnotations) {
const targetPage = this.loadPage(startAt + (i - fromPage));
if (copyLinks) {
this.copyPageLinks(sourcePage, targetPage);
}
if (copyAnnotations) {
this.copyPageAnnotations(sourcePage, targetPage);
}
}
}
}
而且在循环调用这个
MuPDF.js
提供的merge
方法时,wasm
运行的内存被爆了🤣。
仔细阅读代码发现其核心实现就是:
addPage
新增页面;put("Resources")
复制原文档页面中的内容到新页面;insertPage
将新增的页面插入到指定文档中。
因为我并没有后续添加的
link
和annotation
,所以经过设计后,决定使用一个空的pdf
文档,逐页复制原文档两次到空白文档中。主要逻辑如下:
- 加载 PDF 文件:读取并解析原始 A3 PDF 文件。
- 复制页面:创建两个新的 PDF 文档,分别截取每页的左半部分和右半部分。
- 合并页面:将两个新文档中的页面合并到一个新的 PDF 文档中。
- 设置裁剪框:根据 A4 纸张尺寸设置裁剪框(CropBox)和修整框(TrimBox)。
export function merge(
targetPDF: mupdfjs.PDFDocument,
sourcePage: mupdfjs.PDFPage
) {
const pageObj = sourcePage.getObject();
const [x, y, width, height] = sourcePage.getBounds();
// Create a new page in the target document
const newPageObj = targetPDF.addPage(
[x, y, width, height],
0,
targetPDF.newDictionary(),
""
);
// Copy page contents
const contents = pageObj.get("Contents");
if (contents) newPageObj.put("Contents", targetPDF.graftObject(contents));
// Copy page resources
const resources = pageObj.get("Resources");
if (resources) newPageObj.put("Resources", targetPDF.graftObject(resources));
// Insert the new page at the specified position
targetPDF.insertPage(-1, newPageObj);
}
export function generateNewDoc(PDF: mupdfjs.PDFDocument) {
const count = PDF.countPages();
const mergedPDF = new mupdfjs.PDFDocument();
for (let i = 0; i < count; i++) {
const page = PDF.loadPage(i);
merge(mergedPDF, page);
merge(mergedPDF, page);
}
for (let i = 0; i < count * 2; i++) {
const page = mergedPDF.loadPage(i); // 使用 mergedPDF 的页码
const [x, y, width, height] = page.getBounds();
if (i % 2 === 0)
page.setPageBox("CropBox", [x, y, x + width / 2, y + height]);
else page.setPageBox("CropBox", [x + width / 2, y, x + width, y + height]);
page.setPageBox("TrimBox", [0, 0, 595.28, 841.89]);
}
return mergedPDF;
}
完成以上核心方法后,便可以成功将我侄子的试卷裁切为
A4
大小进行打印了✅。
体验与安装使用
浏览器版
- 直接访问网页链接(MuPdf-Crop-Kit)。
桌面版
- 下载并安装
Tauri
打包的桌面应用(Release tauri-x64-exe-0.1.0 · HyaCiovo/MuPdf-Crop-Kit); - 使用源码自行打包需要的安装包。
使用教程
使用本工具非常简单,只需几个步骤即可完成 PDF 文件的裁切:
- 选择需要裁切的 A3 PDF 文件;
- 点击裁切按钮;
- 下载裁切后的 A4 PDF 文件。
不足
- 项目所使用的
wasm
文件大小有10MB
,本工具真正用到的并没有那么多,但是优化需要修改原始文件并重新编译; - 浏览器端的性能受限,并且
wasm
运行可以使用的内存也是有限的; - 没有使用
Web Worker
,理论上转换这种高延迟的任务应当放在Woker
线程中进行来防止堵塞主线程。
替代方案
如果在使用过程中遇到问题或需要更多功能,可以尝试以下在线工具:
- Split PDF Down the Middle A3 to A4 Online:每小时可以免费转换3次,作者亲测好用👍。
来源:juejin.cn/post/7451252126255382543
9个要改掉的TypeScript坏习惯
为了提升TypeScript技能并避免常见的坏习惯,以下是九个需要改掉的坏习惯,帮助你编写更高效和规范的代码。
1. 不使用严格模式
错误做法: 不启用tsconfig.json中的严格模式。
正确做法: 启用严格模式。
原因: 更严格的规则有助于未来代码的维护,修复代码的时间会得到回报。
2. 使用 || 确定默认值
错误做法: 使用 || 处理可选值。
正确做法: 使用 ?? 运算符或在参数级别定义默认值。
原因: ?? 运算符只对 null 或 undefined 进行回退,更加精确。
3. 使用 any 作为类型
错误做法: 使用 any 类型处理不确定的数据结构。
正确做法: 使用 unknown 类型。
原因: any 禁用类型检查,可能导致错误难以捕获。
4. 使用 val as SomeType
错误做法: 强制编译器推断类型。
正确做法: 使用类型守卫。
原因: 类型守卫确保所有检查都是明确的,减少潜在错误。
5. 在测试中使用 as any
错误做法: 在测试中创建不完整的替代品。
正确做法: 将模拟逻辑移到可重用的位置。
原因: 避免在多个测试中重复更改属性,保持代码整洁。
6. 可选属性
错误做法: 将属性定义为可选。
正确做法: 明确表达属性的组合。
原因: 更明确的类型可以在编译时捕获错误。
7. 单字母泛型
错误做法: 使用单字母命名泛型。
正确做法: 使用描述性的类型名称。
原因: 描述性名称提高可读性,便于理解。
8. 非布尔判断
错误做法: 直接将值传递给 if 语句。
正确做法: 明确检查条件。
原因: 使代码逻辑更清晰,避免误解。
9. 感叹号操作符
错误做法: 使用 !! 将非布尔值转换为布尔值。
正确做法: 明确检查条件。
原因: 提高代码可读性,避免混淆。
来源:juejin.cn/post/7451586771781861426
低成本创建数字孪生场景-数据篇
众所周知在做数字孪生相关项目的时候,会划出相当一部分费用会用于做建模工作,然而有一天老板跑过来告诉我,由于客户地处于偏远经济欠发达地区,没有多少项目经费,因此不会花很大的价钱做建模,换言之如果这个项目连建模师都请不起了,阁下该如何应对。
常规的场景建模方式无非就是CAD、激光点云辅助、倾斜摄影建模,人工建模的成本自不必说,几百平方公理的场景如果用无人机倾斜等等建模也是一笔不小的开销,在客户对场景还原度和模型精细度不高的前提下,最省成本的办法还是尽肯能让程序自动建模。经过几天摸索,我终于找到一些稍微靠谱的应对方法,本篇着重讲述如何获取场景的数据,以及处理为展示所需的数据格式。
准备工作
使用的工具
工具 | 用途 | 成本 |
---|---|---|
水经注 | 获取全国GIS数据 | 部分免费 |
地理空间数据云 | 获取地形高程图 | 免费 |
QGIS | 空间数据编辑 | 开源 |
cityEingine | 自动生成建筑和道路模型 | 部分免费 |
CesiumLab3 | 转换地理模型 | 部分免费 |
CesiumJS | 3D地图引擎,用于做最终场景展示 | 开源 |
QGIS(全称Quantum GIS)是一个开源的地理信息系统, 提供了一种可视化、编辑和分析地理数据的途径。它支持各种矢量、栅格和数据库格式,并且支持丰富的GIS功能,包括地图制作、空间数据编辑、地图浏览等。
CesiumLab3是一款强大的3D地理信息数据处理软件,可以将各种类型的数据,如点云、模型、影像、地形等,转换为3D Tiles格式,以便在CesiumJS中可视化。此外,CesiumLab3还提供了一套完整的数据管理和分发服务,使得大规模3D地理信息数据的存储、管理和服务变得更加便捷。
CesiumJS是一款开源的JavaScript库,它用于创建世界级的3D地球和地图的Web应用程序。无论是高精度的地形、影像,还是高度详细的3D模型,CesiumJS都可以轻松地将它们集成到一个统一的地理空间上下文中。该库提供了丰富的接口和功能,让开发者可以轻松实现地理信息可视化、虚拟地球等应用。
基础数据
图层 | 数据形态 | 数据格式 | 文件格式 |
---|---|---|---|
卫星影像 | 图片 | 栅格 | TIF |
水域分布 | 多边形 | 矢量 | SHP |
建筑面轮廓 | 多边形 | 矢量 | SHP |
植被分布 | 散点 | 矢量 | SHP |
地形高程图 | 图片 | 栅格 | TIF |
数据处理
1. 影像和地形
带有地形信息的卫星影像作为底图可以说是非常重要,所有其他图层都是它的基础上进行搭建,因此我们必须尽可保证在处理数据时的底图和最终展示时使用的底图是一致的,至少空间参考坐标系必须是一致的。
由于在后面步骤用到的cityEgine工具可选坐标系里没有ESPG43226,这里使用EPSG32650坐标系。
在编辑过程中需要用到TIF格式的卫星底图,可以使用QGIS对栅格图层,图层的数据在平面模式下进行编辑,主要工作是对齐数据、查漏补缺。
地形处理步驟如下:
- 打开地理空间数据云,登录并进入“高级检索”
- 设置过数据集和过滤条件后会自动返回筛选结果,点击结果项右侧的下载图标即可下载。
- 在 QGIS 中打开菜单栏 Raster > Miscellaneous > Merge,并将下载的高程文件添加到 Input layers 中。
- 可以使用 Processing Toolbox 中的 Clip raster by mask layer 工具来裁剪高程图层,处理好之后导出TIF格式备用
- 使用esiumlab3做地形转换,在这里会涉及到转换算法的选择,VCG算法适合用于小范围,对精度要求高的地形;CTD算法则适合用于大范围,对精度要求低的地形。根据具体情况选择即可。地形具体步骤看 高程地形切片和制作水面效果
2. 建筑轮廓
建筑轮廓通常为矢量数据,在2D模式下它就是由多个内封闭多边形组成的图层,而在3D模式下可以对这些多边形进行挤压形成体力几何体,并在此基础上做变形和贴图,形成大量城市建筑的视觉效果。
这部分数据很难在互联网上拿到,即使有免费的渠道(比如OMS下载),数据也是较为老旧的或者残缺的,有条件的话建议让业主提供或者购买水经注VIP会员下载。即使这样,我们还需要使用QGIS等工具进行二次编辑。
操作步骤如下:
- 在QGIS处理数据,对数据进行筛选、补充、填充基本信息等处理,导出SHP格式数据,建议将卫星影像和地形底图一并导出,用于做模型位置对齐。为方便后面的自动化建模,需要将每个建筑面的基本信息(比如名称和建筑高度)录入到每个多边形中,如果对建筑高度要求没有那么精细也可以用QGIS自带的方法随机生成高度数值。
- 在cityEngine中新建工程(存放各种数据、图层、规则文件)和场景(从工程中抽取素材搭建场景)
- 使用cityEngine进行自动程序化建模,有了建筑面轮廓和高度,就可以直接使用规则生成建筑了,建筑的风格也是可以灵活配置的,比如商业区住宅区,或者CBD风格城乡结合部风格等等。 具体操作看 CityEngine-建筑自动化程序建模
- 将模型导出为FBX,并使用cesiumlab3转换为3Dtiles ,具体操作见 常见3D模型转3Dtiles文件。这里要注意空间参考必须设置为与在QGIS处理数据时的坐标系一致,这里有个坑的地方就是非授权(收费)用户是无法使用ENU之外的其他坐标系将FBX或OBJ文件转为3DTiles的,只能用SHP转出3DTiles白模。这就是为什么很多人反映导出3DTiles后放到地图里又对不上的原因。
3. 绿地和植被图层
绿地和植被图层为我们展示了特定区域内的自然或人工绿地及植被分布情况,包括公园、森林、草地、田地等各类植被区域。获取数据通常可以通过遥感卫星图像,或者通过地面调查和采集。这些数据经过处理后,可以以矢量或栅格的形式在GIS中进行展示和分析。
在本文案例中为了制作立体茂密树林的视觉效果,我们将植被图层数据转换为LOD图层,即在不同的地图缩放尺度下有不同的细节呈现。基本原理就是在指定的范围内随机生成一个树模型的大量实例操作步骤如下:
- 获取植被区域多边形,使用QGIS通过数据导入或手绘的方式得到植被的覆盖区域,可以給区域增加一些快速计算的属性,比如area
- 在覆盖区域内生成随机分布点,调整点的数量和密度达到满意状态即可
- 如有需要,可以手动调整随机生成的点数据,确认无误后导出文件shp
- 准备好带LOD的树模型(cesiumlab3\tools\lodmodel\yangshuchun有自带一个示例模型可用)和地形高程信息文件(.pak格式)
- 使用cesiumlab3创建实例模型切片,具体的流程可以看这里 CesiumLab3实例模型切片
以上方法适合创建模型单一、更新频度低、且数据量巨大的模型图层,比如树木、城市设备如路灯、垃圾桶、井盖等等。
4. 水域分布
地理上水域包括湖泊、河流、海洋和各种人工水体,在专业领域项目水域的分布对研究环境生态有重大意义,而比较通用的场景就是跟卫星影像、地形底图结合展示,我们同样需要矢量数据绘制多边形,并加上动态材质出效果。
由于最终展示的地图引擎cesium自带水域材质效果,这里的操作也变得简单,只要把水域多边形获取到手就行:
- 打开QGIS,导入从水经注下载的水域数据或者对着卫星影像地图手动绘制水域数据,导出为shp文件格式
- 在cesiumlab3 生成地形切片,在cesium里,水域是作为地形的一部分进行处理的,所以将地形高程图tif文件和水域shp文件一起上传处理即可。具体步骤看 高程地形切片和制作水面效果
组合数据
至此数据篇就介绍完了,由于cesiumlab3自带分发服务,我们可以直接在上面新建一个场景,将上文生成的数据图层组合到一个场景里作展示。另外还可以测试一些场景效果比如天气、轮廓、泛光等等,还挺有意思的。后续的单模型加载、可视化图层加载、鼠标事件交互等等就留在开发篇吧,今天就先这样。
- 叠加地形、建筑白模、植被实例切片图层
- 测试建筑模型积雪效果
相关链接
来源:juejin.cn/post/7329322608212885555