注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

HTML5 自定义属性 data-*:别再把数据塞进 class 里了!

web
前言:由于“无处安放”而引发的混乱 在 HTML5 普及之前,前端开发者为了在 DOM 元素上绑定一些数据(比如用户 ID、商品价格、状态码),可谓是八仙过海,各显神通: 隐藏域流派:到处塞 <input type="hidden" value="12...
继续阅读 »

前言:由于“无处安放”而引发的混乱


在 HTML5 普及之前,前端开发者为了在 DOM 元素上绑定一些数据(比如用户 ID、商品价格、状态码),可谓是八仙过海,各显神通:



  1. 隐藏域流派:到处塞 <input type="hidden" value="123">,导致 HTML 结构像个堆满杂物的仓库。

  2. Class 拼接流派<div class="btn item-id-8848">,然后用正则去解析 class 字符串提取 ID。这简直是在用 CSS 类名当数据库用,类名听了都想离家出走。

  3. 自定义非标属性流派:直接写 <div my_id="123">。虽然浏览器大多能容忍,但这就好比在公共泳池里裸泳——虽然没人抓你,但不合规矩且看着尴尬。


直到 HTML5 引入了 data-*  自定义数据属性,这一切终于有了“官方标准”。




第一阶段:基础——它长什么样?


data-* 属性允许我们在标准 HTML 元素中存储额外的页面私有信息。


1. HTML 写法


语法非常简单:必须以 data- 开头,后面接上你自定义的名称。


<!-- ❌ 错误示范:不要大写,不要乱用特殊符号 -->
<div data-User-Id="1001"></div>

<!-- ✅ 正确示范:全小写,连字符连接 -->
<div
id="user-card"
data-id="1001"
data-user-name="juejin_expert"
data-value="99.9"
data-is-vip="true"
>

用户信息卡片
</div>

2. CSS 中的妙用


很多人以为 data-* 只是给 JS 用的,其实 CSS 也能完美利用它。


场景一:通过属性选择器控制样式


/* 当 data-is-vip 为 "true" 时,背景变金 */
div[data-is-vip="true"] {
background: gold;
border: 2px solid orange;
}

场景二:利用 attr() 显示数据

这是一个非常酷的技巧,可以用来做 Tooltip 或者计数器显示。


div::after {
/* 直接把 data-value 的值显示在页面上 */
content: "当前分值: " attr(data-value);
font-size: 12px;
color: #666;
}



第二阶段:进阶——JavaScript 如何读写?


这才是重头戏。在 JS 中操作 data-* 有两种方式:传统派 和 现代派


1. 传统派:getAttribute / setAttribute


这是最稳妥的方法,兼容性最好(虽然现在也没人要兼容 IE6 了)。


const el = document.getElementById('user-card');

// 读取
const userId = el.getAttribute('data-id'); // "1001"

// 修改
el.setAttribute('data-value', '100');

特点:读出来永远是字符串。哪怕你存的是 100,取出来也是 "100"。


2. 现代派:dataset API (推荐 ✨)


HTML5 为每个元素提供了一个 dataset 对象(DOMStringMap),它将所有的 data-* 属性映射成了对象的属性。


这里有个大坑(或者说是规范),请务必注意:

HTML 中的 连字符命名 (kebab-case)  会自动转换为 JS 中的 小驼峰命名 (camelCase)


const el = document.getElementById('user-card');

// 1. 访问 data-id
console.log(el.dataset.id); // "1001"

// 2. 访问 data-user-name (注意变身了!)
console.log(el.dataset.userName); // "juejin_expert"
// ❌ el.dataset.user-name 是语法错误
// ❌ el.dataset['user-name'] 是 undefined

// 3. 修改数据
el.dataset.value = "200";
// HTML 会自动变成 data-value="200"

// 4. 删除数据
delete el.dataset.isVip;
// HTML 中的 data-is-vip 属性会被移除


💡 敲黑板:dataset 里的属性名不支持大写字母。如果你在 HTML 里写 data-MyValue="1", 浏览器会强制转为小写 data-myvalue,JS 里就得用 dataset.myvalue 访问。所以,HTML 里老老实实全小写吧。





第三阶段:深入——类型陷阱与性能权衡


1. 一切皆字符串


不管你赋给 dataset 什么类型的值,最终都会被转为字符串。


el.dataset.count = 100;        // HTML: data-count="100"
el.dataset.active = true; // HTML: data-active="true"
el.dataset.config = {a: 1}; // HTML: data-config="[object Object]" -> 灾难!

避坑指南



  • 如果你要存数字,取出来时记得 Number(el.dataset.count)。

  • 如果你要存布尔值,判断时不能简单用 if (el.dataset.active),因为 "false" 字符串也是真值!要用 el.dataset.active === 'true'。

  • 千万不要试图在 data-* 里存复杂的 JSON 对象。如果非要存,请使用 JSON.stringify(),但在 DOM 上挂载大量字符串数据会影响性能。


2. 性能考量



  • 读写速度:dataset 的访问速度在现代浏览器中非常快,但在极高频操作下(比如每秒几千次),直接操作 JS 变量肯定比操作 DOM 快。

  • 重排与重绘:修改 data-* 属性会触发 DOM 变更。如果你的 CSS 依赖属性选择器(如 div[data-status="active"]),修改属性可能会触发页面的重排(Reflow)或重绘(Repaint)。




第四阶段:实战——优雅的事件委托


data-value 最经典的用法之一就是在列表项的事件委托中。


需求:点击列表中的“删除”按钮,删除对应项。


<ul id="todo-list">
<li>
<span>学习 HTML5</span>
<!-- 把 ID 藏在这里 -->
<button class="btn-delete" data-id="101" data-action="delete">删除</button>
</li>
<li>
<span>写掘金文章</span>
<button class="btn-delete" data-id="102" data-action="delete">删除</button>
</li>
</ul>

const list = document.getElementById('todo-list');

list.addEventListener('click', (e) => {
// 利用 dataset 判断点击的是不是删除按钮
const { action, id } = e.target.dataset;

if (action === 'delete') {
console.log(`准备删除 ID 为 ${id} 的条目`);
// 这里发送请求或操作 DOM
// deleteItem(id);
}
});

为什么这么做优雅?

你不需要给每个按钮都绑定事件,也不需要去分析 DOM 结构(比如 e.target.parentNode...)来找数据。数据就在元素身上,唾手可得。




总结与“禁忌”


HTML5 的 data-* 属性是连接 DOM 和数据的一座轻量级桥梁。


什么时候用?



  • 当需要把少量数据绑定到特定 UI 元素上时。

  • 当 CSS 需要根据数据状态改变样式时。

  • 做事件委托需要传递参数时。


什么时候别用?(禁忌)



  1. 不要存储敏感数据:用户可以直接在浏览器控制台修改 DOM,千万别把 data-password 或 data-user-token 放在这。

  2. 不要当数据库用:别把几 KB 的 JSON 数据塞进去,那是 JS 变量或者是 IndexDB 该干的事。

  3. SEO 无用:搜索引擎爬虫通常不关心 data-* 里的内容,重要的文本内容还是要写在标签里。




最后一句

代码整洁之道,始于不再乱用 Class。下次再想存个 ID,记得想起那个以 data- 开头的帅气属性。


Happy Coding! 🚀


作者:南山安
来源:juejin.cn/post/7575119254314401818
收起阅读 »

我的app被工信部下架了,现在想重新上架

前言 这是一期没有用的干货。 不知道有多少朋友的App经历过被工信部点名并下架的……显然我经历过. 几年前我的App因为违规收集个人信息被广东省通信管理局强制下架了,虽然App本身真的没有去收集个人信息,但用了架不住某些SDK喜欢做些小动作,不管怎么样,由...
继续阅读 »

前言



这是一期没有用的干货。



不知道有多少朋友的App经历过被工信部点名并下架的……显然我经历过.


几年前我的App因为违规收集个人信息被广东省通信管理局强制下架了,虽然App本身真的没有去收集个人信息,但用了架不住某些SDK喜欢做些小动作,不管怎么样,由于当时没处置,就被以侵犯用户隐私的名义给下架了。随着最近鸿蒙挺火的,我突然想试试上架一下鸿蒙。但很遗憾,全网被下架,即使新开发的鸿蒙app也不可以,所以不得不踏上申请重新上架之路。



先说一下总的感受:确实比较繁琐一些,但广东省通信管理局确实很给力,给我不少帮助。以下经验也仅适用于因为个人隐私相关问题被下架需要重新上架。



流程


首先我是在华为应用市场中问到我应该如何重新上架,然后加到粤通管网安监测的微信,然后询问如何申请重新上架。


首先,也是废话当然是要修复既存的问题。然后,将APP的整改报告及整改后的APK发送到指定邮箱,然后等待复测即可。



需要注意的是也需要将备案信息填写到整改报告中



另外,根据我查到的资料,APP侵害用户权益的检测工作是全国APP技术检测平台(APP公共服务系统)做的,并且提供了一套APP侵害用户权益专项整治行动恢复上架流程,我贴个图如下:


image.png


实际上的流程大概和这个差不多,但都是在和通信管理局打交道。这个实际体验可能因各通信管理局不同而异。


其他


还有一些小事项,比如整改报告模板,最开始的时候通信管理局也没有和我说有模板,我也在网上照猫画虎搞的。当复测通过后,填写重新上架申请书后,需要再次上传整改报告的时候,对方给我发了一个整改模板,好在经过询问,并不需要重新写一份整改模板,直接复用之前的整改报告即可。


关于整改报告里都需要有什么可以参考一下原文:



您好,APP恢复上架/小程序解除禁搜需要您单位写一份申请。说明APP被下架/小程序被禁搜的原因、具体的问题、已整改的情况、以及企业内培训宣贯情况、未来避免出现个人信息合规问题的措施、完成备案情况、承诺未来将合规经营APP/小程序及相关业务、承诺因违背承诺导致发生违规行为自愿承担法律责任及依规处罚(下架APP/禁搜小程序、关停服务器、封锁ip等)。编写好后请盖单位公章,扫描后将电子文件发送至管局邮箱 gdca_wac_app@gd.gov.cn。这边收到申请邮件后才会继续走下一步流程,请及早提交。



最后


最后磕磕绊绊还是重新上架成功,总体虽然繁琐但还是算比较顺利。不过,尽量还是别等到下线的那一刻吧。。。如果想要广东省的整改模板可以私信我。


总结一下吧:



  • APP如果被通报了,要在限定时间内完成整改并上架应用商店。要不然复测的时候在应用商店没有下载到最新版本的包,问题依旧存在肯定会被下架的;

  • 不幸被下架的APP企业,也不要着急。重点要做的就是先知道自己的APP具体存在哪些问题,然后有针对性的整改;

  • 不知道APP问题怎么整改或者不确定APP问题是否整改到位的话,建议找个专业的第三方APP检测机构开展隐私合规检测;

  • APP全部整改到位后,根据官方发布的APP恢复上架流程图逐步操作,最后等待重新上架即可。


建议


各企业的APP一定要根据监管的要求严格落实APP个人信息保护工作,确保APP的安全和隐私合规,不要抱有侥幸心理。


最后请大家猜猜,哪个应用市场最先解禁的?哪个到现在还没解禁。


作者:JarvanMo
来源:juejin.cn/post/7569377216896090163
收起阅读 »

工作中最常用的6种缓存

前言 这些年我参与设计过很多系统,越来越深刻地认识到:一个系统的性能如何,很大程度上取决于缓存用得怎么样。 同样是缓存,为何有人用起来系统飞升,有人却踩坑不断? 有些小伙伴在工作中可能遇到过这样的困惑:知道要用缓存,但面对本地缓存、Redis、Memcache...
继续阅读 »

前言


这些年我参与设计过很多系统,越来越深刻地认识到:一个系统的性能如何,很大程度上取决于缓存用得怎么样


同样是缓存,为何有人用起来系统飞升,有人却踩坑不断?


有些小伙伴在工作中可能遇到过这样的困惑:知道要用缓存,但面对本地缓存、Redis、Memcached、CDN等多种选择,到底该用哪种?


今天这篇文章跟大家一起聊聊工作中最常用的6种缓存,希望对你会有所帮助。


更多项目实战在我的技术网站:www.susan.net.cn/project


01 为什么缓存如此重要?


在正式介绍各种缓存之前,我们先要明白:为什么要用缓存?


想象这样一个场景:你的电商网站首页,每次打开都要从数据库中查询轮播图、热门商品、分类信息等数据。


如果每秒有1万个用户访问,数据库就要承受1万次查询压力。


// 没有缓存时的查询
public Product getProductById(Long id) {
// 每次都直接查询数据库
return productDao.findById(id); // 每次都是慢速的磁盘IO
}

这就是典型的无缓存场景


数据库的磁盘IO速度远低于内存,当并发量上来后,系统响应变慢,数据库连接池被占满,最终导致服务不可用。


缓存的核心价值可以用下面这个公式理解:


系统性能 = (缓存命中率 × 缓存访问速度) + ((1 - 缓存命中率) × 后端访问速度)

缓存之所以能提升性能,基于两个计算机科学的基本原理:



  1. 局部性原理:程序访问的数据通常具有时间和空间局部性

  2. 存储层次结构:不同存储介质的速度差异巨大(内存比SSD快100倍,比HDD快10万倍)


从用户请求到数据返回,数据可能经过的各级缓存路径如下图所示:


转存失败,建议直接上传图片文件


理解了缓存的重要性,接下来我们逐一剖析这六种最常用的缓存技术。


02 本地缓存:最简单直接的性能提升


本地缓存指的是在应用进程内部维护的缓存存储,数据存储在JVM堆内存中。


核心特点



  • 访问最快:直接内存操作,无网络开销

  • 实现简单:无需搭建额外服务

  • 数据隔离:每个应用实例独享自己的缓存


常用实现


1. Guava Cache:Google提供的优秀本地缓存库


// Guava Cache 示例
LoadingCache<Long, Product> productCache = CacheBuilder.newBuilder()
.maximumSize(10000) // 最大缓存项数
.expireAfterWrite(10, TimeUnit.MINUTES) // 写入后10分钟过期
.expireAfterAccess(5, TimeUnit.MINUTES) // 访问后5分钟过期
.recordStats() // 开启统计
.build(new CacheLoader<Long, Product>() {
@Override
public Product load(Long productId) {
// 当缓存未命中时,自动加载数据
return productDao.findById(productId);
}
});

// 使用缓存
public Product getProduct(Long id) {
try {
return productCache.get(id);
} catch (ExecutionException e) {
throw new RuntimeException("加载产品失败", e);
}
}

2. Caffeine:Guava Cache的现代替代品,性能更优


// Caffeine 示例(性能优于Guava Cache)
Cache<Long, Product> caffeineCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.refreshAfterWrite(1, TimeUnit.MINUTES) // 支持刷新,Guava不支持
.recordStats()
.build(productId -> productDao.findById(productId));

// 异步获取
public CompletableFuture<Product> getProductAsync(Long id) {
return caffeineCache.get(id, productId ->
CompletableFuture.supplyAsync(() -> productDao.findById(productId)));
}

适用场景



  • 数据量不大(通常不超过10万条)

  • 数据变化不频繁

  • 对访问速度要求极致

  • 如:配置信息、静态字典、用户会话信息(短期)


优缺点分析



  • 优点:极速访问、零网络开销、实现简单

  • 缺点:数据不一致(各节点独立)、内存限制、重启丢失


有些小伙伴在工作中可能会犯一个错误:在分布式系统中过度依赖本地缓存,导致各节点数据不一致。记住:本地缓存适合存储只读或弱一致性的数据


03 分布式缓存之王:Redis的深度解析


当数据需要在多个应用实例间共享时,本地缓存就不够用了,这时需要分布式缓存。而Redis无疑是这一领域的王者。


Redis的核心优势


// Spring Boot + Redis 示例
@Component
public class ProductCacheService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;

private static final String PRODUCT_KEY_PREFIX = "product:";
private static final Duration CACHE_TTL = Duration.ofMinutes(30);

// 缓存查询
public Product getProduct(Long id) {
String key = PRODUCT_KEY_PREFIX + id;

// 1. 先查缓存
Product product = (Product) redisTemplate.opsForValue().get(key);

if (product != null) {
return product;
}

// 2. 缓存未命中,查数据库
product = productDao.findById(id);
if (product != null) {
// 3. 写入缓存
redisTemplate.opsForValue().set(key, product, CACHE_TTL);
}

return product;
}

// 使用更高效的方式:缓存空值防止缓存穿透
public Product getProductWithNullCache(Long id) {
String key = PRODUCT_KEY_PREFIX + id;
String nullKey = PRODUCT_KEY_PREFIX + "null:" + id;

// 检查是否是空值(防缓存穿透)
if (Boolean.TRUE.equals(redisTemplate.hasKey(nullKey))) {
return null;
}

Product product = (Product) redisTemplate.opsForValue().get(key);
if (product != null) {
return product;
}

product = productDao.findById(id);
if (product == null) {
// 缓存空值,短时间过期
redisTemplate.opsForValue().set(nullKey, "", Duration.ofMinutes(5));
return null;
}

redisTemplate.opsForValue().set(key, product, CACHE_TTL);
return product;
}
}

Redis的丰富数据结构


Redis不只是简单的Key-Value存储,它的多种数据结构适应不同场景:


数据结构适用场景示例
String缓存对象、计数器SET user:1 '{"name":"张三"}'
Hash存储对象属性HSET product:1001 name "手机" price 2999
List消息队列、最新列表LPUSH news:latest "新闻标题"
Set标签、共同好友SADD user:100:tags "数码" "科技"
Sorted Set排行榜、延迟队列ZADD leaderboard 95 "玩家A"
Bitmap用户签到、活跃统计SETBIT sign:2023:10 1 1

集群模式选择



适用场景



  • 会话存储(分布式Session)

  • 排行榜、计数器

  • 消息队列

  • 分布式锁

  • 热点数据缓存


有些小伙伴在工作中使用Redis时,只把它当简单的Key-Value用,这就像用瑞士军刀只开瓶盖一样浪费。


深入理解Redis的数据结构,能让你的系统设计更优雅高效


04 Memcached:简单高效的分布式缓存


在Redis崛起之前,Memcached是分布式缓存的首选。


虽然现在Redis更流行,但Memcached在某些场景下仍有其价值。


Memcached vs Redis 核心区别


// Memcached 客户端示例(使用XMemcached)
public class MemcachedService {
private MemcachedClient memcachedClient;

public void init() throws IOException {
// 创建客户端
memcachedClient = new XMemcachedClientBuilder(
AddrUtil.getAddresses("server1:11211 server2:11211"))
.build();
}

public Product getProduct(Long id) throws Exception {
String key = "product_" + id;

// 从Memcached获取
Product product = memcachedClient.get(key);
if (product != null) {
return product;
}

// 缓存未命中
product = productDao.findById(id);
if (product != null) {
// 存储到Memcached,过期时间30分钟
memcachedClient.set(key, 30 * 60, product);
}

return product;
}
}

两者的核心差异对比


特性RedisMemcached
数据结构丰富(String、Hash、List等)简单(Key-Value)
持久化支持(RDB/AOF)不支持
线程模型单线程多线程
内存管理多种策略,可持久化纯内存,重启丢失
使用场景缓存+多样化数据结构纯缓存

何时选择Memcached?



  1. 纯缓存场景:只需要简单的Key-Value缓存

  2. 超大Value存储:Memcached对超大Value支持更好

  3. 多线程高并发:Memcached的多线程模型在极端并发下可能表现更好


05 CDN缓存:加速静态资源的利器


有些小伙伴可能会疑惑:CDN也算缓存吗?当然算,而且是地理位置最近的缓存。


CDN的工作原理


CDN(Content Delivery Network)通过在各地部署边缘节点,将静态资源缓存到离用户最近的节点。


// 在应用中生成CDN链接
public class CDNService {
private String cdnDomain = "https://cdn.yourcompany.com";

public String getCDNUrl(String relativePath) {
// 添加版本号或时间戳,防止缓存旧版本
String version = getFileVersion(relativePath);
return String.format("%s/%s?v=%s", cdnDomain, relativePath, version);
}

// 上传文件到CDN的示例(伪代码)
public void uploadToCDN(File file, String remotePath) {
// 1. 上传到源站
uploadToOrigin(file, remotePath);

// 2. 触发CDN预热(将文件主动推送到边缘节点)
preheatCDN(remotePath);

// 3. 刷新旧缓存(如果需要)
refreshCDNCache(remotePath);
}
}

CDN缓存策略配置


# Nginx中的CDN缓存配置示例
location ~* \.(jpg|jpeg|png|gif|ico|css|js)$ {
expires 365d; # 缓存一年
add_header Cache-Control "public, immutable";

# 添加版本号作为查询参数
if ($query_string ~* "^v=\d+") {
expires max;
}
}

适用场景



  • 静态资源:图片、CSS、JS文件

  • 软件下载包

  • 视频流媒体

  • 全球访问的网站


06 浏览器缓存:最前端的性能优化


浏览器缓存是最容易被忽视但效果最直接的缓存层级。合理利用浏览器缓存,可以大幅减少服务器压力。


HTTP缓存头详解


// Spring Boot中设置HTTP缓存头
@RestController
public class ResourceController {

@GetMapping("/static/{filename}")
public ResponseEntity<Resource> getStaticFile(@PathVariable String filename) {
Resource resource = loadResource(filename);

return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(7, TimeUnit.DAYS)) // 缓存7天
.eTag(computeETag(resource)) // ETag用于协商缓存
.lastModified(resource.lastModified()) // 最后修改时间
.body(resource);
}

@GetMapping("/dynamic/data")
public ResponseEntity<Object> getDynamicData() {
Object data = getData();

// 动态数据设置较短缓存
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(30, TimeUnit.SECONDS)) // 30秒
.body(data);
}
}

浏览器缓存的两种类型


转存失败,建议直接上传图片文件


最近为了帮助大家找工作,专门建了一些工作内推群,各大城市都有,欢迎各位HR和找工作的小伙伴进群交流,群里目前已经收集了20多家大厂的工作内推岗位。加苏三的微信:li_su223,备注:掘金+所在城市,即可进群。


最佳实践



  1. 静态资源:设置长时间缓存(如一年),通过文件名哈希处理更新

  2. 动态数据:根据业务需求设置合理缓存时间

  3. API响应:适当使用ETag和Last-Modified


07 数据库缓存:容易被忽略的内部优化


数据库自身也有缓存机制,理解这些机制能帮助我们写出更高效的SQL。


MySQL查询缓存(已废弃但值得了解)


-- 查看查询缓存状态(MySQL 5.7及之前)
SHOW VARIABLES LIKE 'query_cache%';

-- 在8.0之前,可以通过以下方式利用查询缓存
SELECT SQL_CACHE * FROM products WHERE category_id = 10;

InnoDB缓冲池(Buffer Pool)


这是MySQL性能的关键,缓存的是数据页和索引页


-- 查看缓冲池状态
SHOW ENGINE INNODB STATUS;

-- 重要的监控指标
-- 缓冲池命中率 = (1 - (innodb_buffer_pool_reads / innodb_buffer_pool_read_requests)) * 100%
-- 命中率应尽可能接近100%

数据库级缓存最佳实践



  1. 合理设置缓冲池大小:通常是系统内存的50%-70%

  2. 优化查询:避免全表扫描,合理使用索引

  3. 预热缓存:重启后主动加载热点数据

  4. 监控命中率:持续优化


有些小伙伴可能会过度依赖应用层缓存,而忽略了数据库自身的缓存优化。


数据库缓存是最后一道防线,优化好它能让整个系统更健壮


08 综合对比与选型指南


接下来,我给大家一个选型指南:



实战中的多级缓存架构


在实际的高并发系统中,我们往往会采用多级缓存策略


// 多级缓存示例:本地缓存 + Redis
@Component
public class MultiLevelCacheService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;

// 一级缓存:本地缓存
private Cache<Long, Product> localCache = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(30, TimeUnit.SECONDS) // 本地缓存时间短
.build();

// 二级缓存:Redis
private static final Duration REDIS_TTL = Duration.ofMinutes(10);

public Product getProductWithMultiCache(Long id) {
// 1. 查本地缓存
Product product = localCache.getIfPresent(id);
if (product != null) {
return product;
}

// 2. 查Redis
String redisKey = "product:" + id;
product = (Product) redisTemplate.opsForValue().get(redisKey);
if (product != null) {
// 回填本地缓存
localCache.put(id, product);
return product;
}

// 3. 查数据库
product = productDao.findById(id);
if (product != null) {
// 写入Redis
redisTemplate.opsForValue().set(redisKey, product, REDIS_TTL);
// 写入本地缓存
localCache.put(id, product);
}

return product;
}
}

09 缓存常见问题与解决方案


在使用缓存的过程中,我们不可避免地会遇到一些问题:


1. 缓存穿透


问题:大量请求查询不存在的数据,绕过缓存直接击穿数据库。


解决方案


// 缓存空值方案
public Product getProductSafe(Long id) {
String key = "product:" + id;
String nullKey = "product:null:" + id;

// 检查空值标记
if (redisTemplate.hasKey(nullKey)) {
return null;
}

Product product = (Product) redisTemplate.opsForValue().get(key);
if (product != null) {
return product;
}

product = productDao.findById(id);
if (product == null) {
// 缓存空值,短时间过期
redisTemplate.opsForValue().set(nullKey, "", Duration.ofMinutes(5));
return null;
}

redisTemplate.opsForValue().set(key, product, Duration.ofMinutes(30));
return product;
}

2. 缓存雪崩


问题:大量缓存同时过期,请求全部打到数据库。


解决方案


// 差异化过期时间
private Duration getRandomTTL() {
// 基础30分钟 + 随机0-10分钟
long baseMinutes = 30;
long randomMinutes = ThreadLocalRandom.current().nextLong(0, 10);
return Duration.ofMinutes(baseMinutes + randomMinutes);
}

3. 缓存击穿


问题:热点Key过期瞬间,大量并发请求同时查询数据库。


解决方案


// 使用互斥锁(分布式锁)
public Product getProductWithLock(Long id) {
String key = "product:" + id;
Product product = (Product) redisTemplate.opsForValue().get(key);

if (product == null) {
// 尝试获取分布式锁
String lockKey = "lock:product:" + id;
boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", Duration.ofSeconds(10));

if (locked) {
try {
// 双重检查
product = (Product) redisTemplate.opsForValue().get(key);
if (product == null) {
product = productDao.findById(id);
if (product != null) {
redisTemplate.opsForValue()
.set(key, product, Duration.ofMinutes(30));
}
}
} finally {
// 释放锁
redisTemplate.delete(lockKey);
}
} else {
// 未获取到锁,等待后重试
try { Thread.sleep(50); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
return getProductWithLock(id); // 递归重试
}
}

return product;
}

10 总结


通过这篇文章,我们系统地探讨了工作中最常用的六种缓存技术。


每种缓存都有其独特的价值和应用场景:



  1. 本地缓存:适合进程内、变化不频繁的只读数据

  2. Redis:功能丰富的分布式缓存,适合大多数共享缓存场景

  3. Memcached:简单高效的分布式缓存,适合纯Key-Value场景

  4. CDN缓存:加速静态资源,提升全球访问速度

  5. 浏览器缓存:最前端的优化,减少不必要的网络请求

  6. 数据库缓存:最后一道防线,优化数据库访问性能


缓存使用的核心原则可以总结为以下几点



  • 分级缓存:合理利用多级缓存架构

  • 合适粒度:根据业务特点选择缓存粒度

  • 及时更新:设计合理的缓存更新策略

  • 监控告警:建立完善的缓存监控体系


有些小伙伴在工作中使用缓存时,容易陷入两个极端:要么过度设计,所有数据都加缓存;要么忽视缓存,让数据库承受所有压力。


我们需要懂得在合适的地方使用合适的缓存,在性能和复杂性之间找到最佳平衡点


记住,缓存不是银弹,而是工具箱中的一件利器。


最后说一句(求关注,别白嫖我)


如果这篇文章对您有所帮助,或者有所启发的话,帮忙关注一下我的同名公众号:苏三说技术,您的支持是我坚持写作最大的动力。


求一键三连:点赞、转发、在看。


关注公众号:【苏三说技术】,在公众号中回复:进大厂,可以免费获取我最近整理的10万字的面试宝典,好多小伙伴靠这个宝典拿到了多家大厂的offer。


更多项目实战在我的技术网站:http://www.susan.net.cn/project


作者:苏三说技术
来源:juejin.cn/post/7583980250733559871
收起阅读 »

做好自己的份内工作,等着被裁

先声明,本文不是贩卖焦虑,只是自己的一点拙见,没有割韭菜的卖课、副业、保险广告,请放心食用。 2022 年初,前司开始了轰轰烈烈的「降本增笑」运动,各部门严格考核机器成本和预算。当然,最重要的还是「开猿节流」。 幸好,我所在部门是盈利的,当时几乎没有人受到波...
继续阅读 »

先声明,本文不是贩卖焦虑,只是自己的一点拙见,没有割韭菜的卖课、副业、保险广告,请放心食用。


2022 年初,前司开始了轰轰烈烈的「降本增笑」运动,各部门严格考核机器成本和预算。当然,最重要的还是「开猿节流」。


开猿节流


幸好,我所在部门是盈利的,当时几乎没有人受到波及。


据说,现在连餐巾纸都从三层的「维达」换成两层的「心心相印」了,号称年节约成本 100 多万。我好奇的是,擦屁股时多少会沾点 💩 吧?这下,真是名正言顺的 💩 山代码了。


2022 年 7 月底,因为某些原因,结束 10 年北漂回老家,换了个公司继续搬砖。


2023 年,春节后不久,现司搞「偷袭」,玩起了狼人杀,很多小伙伴被刀:



清晨接到电话通知,上午集体开会,IT 收回权限,中午滚蛋



好在是头一回,补偿非常可观,远超法律规定的「N+1」。


2024 年,平安夜,无事发生。


2025 年 1 月,公司年会,趣味运动会,有个项目是「财源滚滚」,下图这样的:


财源滚滚


有个参赛的老哥调侃道,这项目名字不吉利啊,不应该参加的。无巧不成书,年后他被刀了。。。


这次的规模远小于 2023 年,但 2025 年也不太平,「脉脉」上陆续有人说被刀或者不续签,真假未知。


实话说,我之前从未担心过被裁,毕竟:



名校硕士,经历多个大厂,有管理经验


热爱编程,工作认真负责,常年高绩效



但是,随着 AI 的快速迭代,我现在感觉自己随时可能被刀了。AI 能胜任 log 分析、新功能开发、bug 修复等绝大部分日常工作,而且都完成的很好。再配合 AI 自己写的MCP,效率肉眼可见的提高。


亲身体验,数百人开发的千万行代码级别的项目,混合了Java/Kotlin/OC/C++/Python等各种语言。跟Cursor聊了几句,它就找到原因并帮忙修复了。如果是自己看代码、问人、加 log、编译,至少得半个小时。


那还要码农干啥呢?即使是留下来背锅,也要不了这么多啊。


距离上次「狼人杀 」,三年之期已到。今年会有「狼人杀 2.0」吗?我还能平稳落地吗?


无所谓了,我早已准备好后路:


后路


头盔和衣服真是我买的,还有手套未入镜,我感觉设计很漂亮,等天气暖和后,当骑行服穿。


汽车,小踏板,大踏板,足以覆盖滴滴、外卖、闪送三大朝阳行业。家里还有个小电驴,凑合能放到后备箱,承接代驾业务问题不大。


以上,虽然是开玩笑,但我对「是否被刀、何时被刀」,真的是无所谓。因为:



一个人的命运啊,当然要靠自我奋斗,但也要考虑历史的进程



公司为了长远的发展,刀人以降低成本,再用 AI 来提高效率,求得股价长红。对此,我十分理解,换我当老板,也会这么干。


作为牛马,想太多没用,我们左右不了这些事。不夸张的说,99.9999% 的码农是不可能干到退休的,和死亡一样,被刀只是早晚的事。更扎心的是:



人不是老了才会死,而是随时会死



当下的工作也一样,并不是摸鱼或者捅娄子才会被刀,而是随时会被刀,与个人的努力、绩效关系不大。常年健身的肌肉男,也可能猝死,只是概率低点,并不是免死金牌。


生命,从受精的那一刻起,就在走向终点。工作,从入职的那一刻起,就在走向(主动/被动)离职。


所以,虽然我现在感觉自己随时可能被 AI 替代,但我的心态一直都没变,就是标题所言:



做好自己的份内工作,等着被裁



不是消极怠工,我始终认真完成每一项任务,该加班加班。并非为了绩效,是因为自己的责任心,要对的起工资。至于公司哪天让我滚蛋,我决定不了,更改变不了。就像对待死亡一样,坦然接受之,给够补偿就好。


补偿.png


对于 AI,还想再啰嗦两句:



  1. 虽然 AI 很牛逼,但最终还是需要人来判断代码的对错。此时,工程师的价值就体验出来了,所以 AI 是帮我干活的小弟,而不是竞争对手。

  2. AI 扩大了我们的能力边界,人人都可以是前端、后端、客户端、UI 设计全通的「全栈工程师」,至少可以是「全沾工程师」,「雨露均沾」的沾。


滚蛋之后呢?我不知道,现在有多少公司愿意招 40 岁高龄码农?据说前司招聘 35 岁普通员工都要 VP 审批了,真是小刀剌屁股,开了眼了。


好在,我家人的物质欲望极低,对衣服、手机、汽车没有任何追求,老婆不用化妆品和护肤品,也没买过一个包。即使不上班,积蓄也能撑一段时间。


所以,强烈建议当前北上广深拿高薪的老哥老妹们,除非万不得已,千万不要像我一样断崖式降薪回老家。趁年轻,搞钱比啥都重要。


搞钱


对了,我目前有两个利用自身优势的基于 AI 的创业方向。网友们帮忙把把关,如果哪天真失业了,看能否拉到几个亿的风投,谢谢!



  1. 偏胖圆脸,AI 加点络腮胡,再买几双白袜子

  2. 身高 180,AI 换个美女脸,黑丝高跟大长腿


谢谢


作者:野生的码农
来源:juejin.cn/post/7593771861323726874
收起阅读 »

离职转AI独立开发半年,我感受到了真正的生活

离职转AI独立开发半年,我感受到了真正的生活 我的新产品:code.lucids.top/ 开场白:一个不被理解的决定 2022年12月的最后一天,我收拾了自己的小盒子,里面装着我在这家互联网公司工作的所有痕迹:一个定制水杯,几本技术书籍,和一摞写满代码思路...
继续阅读 »

离职转AI独立开发半年,我感受到了真正的生活


我的新产品:code.lucids.top/


开场白:一个不被理解的决定


photo-1580927752452-89d86da3fa0a.jpeg


2022年12月的最后一天,我收拾了自己的小盒子,里面装着我在这家互联网公司工作的所有痕迹:一个定制水杯,几本技术书籍,和一摞写满代码思路的便利贴。HR部门的小姐姐看着我签完最后一份文件,表情有些复杂:"小张,你才来半年就走,真的想好了吗?这个时候辞职,外面行情不好..."


我点点头,没多解释。如何向别人解释我这个2000年出生的"孩子",毕业仅仅半年就对光鲜的互联网工作心生倦意?如何解释我不想再每天凌晨两点被产品经理的消息惊醒,然后爬起来改几行代码?如何解释我想追求的不只是一份体面的工资和一个看起来不错的头衔?


当我走出公司大楼,北京的冬风刮得我脸生疼。我的储蓄只够支撑我半年,而我计划做的事情——成为一名AI独立开发者——在大多数人眼中无异于天方夜谭。"你疯了吧?现在的独立开发者,有几个能养活自己的?"这是我最好朋友听到我计划时的反应。


事实证明,他错了。我也曾经错了。而现在,当我坐在自己选择的咖啡馆,以自己喜欢的节奏工作,看着用户数突破10,000的后台数据,我知道这半年的挣扎、焦虑和不安都是值得的。


职场困境:我在互联网大厂的日子


回想起入职的第一天,一切都充满希望。校招拿到知名互联网公司的offer,年薪30万,比许多同学高出不少。父母骄傲地向亲戚们宣布他们的儿子"找到了好工作"。


然而现实很快给了我当头一棒。


我被分到一个负责内部工具开发的小组。领导在入职第一天就明确告诉我:"小张,我们这个组不是核心业务,资源有限,但任务不少,你得做好加班的准备。"


第一个月,适应期,我每天工作10小时,感觉还能接受。到了第二个月,一个重要项目启动,我开始习惯每天凌晨回家,第二天早上9点又准时出现在公司。最夸张的一次,我连续工作了38个小时,只为了赶一个莫名其妙被提前的deadline。


# 当时的我就像这段无限循环的代码
while True:
wake_up()
go_to_work()
coding_till_midnight()
get_emergency_task()
sleep(2) # 只睡2小时

工作内容也让我倍感挫折。作为一名热爱技术的程序员,我希望能够参与有挑战性的项目,学习前沿技术。但现实是,我大部分时间都在做重复性的维护工作,修复一些简单但繁琐的bug,或者应对产品经理们不断变化的需求。


我感到自己正在成为一个"代码工具人",一个可以被随时替换的齿轮。我的创造力,我对技术的热情,我想为这个世界带来一些改变的梦想,都在日复一日的996中渐渐磨灭。


转折点:AI浪潮中看到的希望


2022年底,ChatGPT横空出世。作为一个技术爱好者,我第一时间注册了账号,体验了这个令人震惊的产品。我记得那天晚上,我熬夜到凌晨三点,不断地与ChatGPT对话,测试它的能力边界。


"这太不可思议了,"我对自己说,"这将改变一切。"


随后几周,我利用所有空闲时间(其实并不多)研究OpenAI的API文档,尝试构建一些简单的应用。我发现,大语言模型(LLM)并不像我想象的那样遥不可及,即使是一个普通开发者,只要理解其工作原理,也能基于它创造出有价值的产品。


同时,我开始关注独立开发者社区。我惊讶地发现,有不少人依靠自己开发的小产品,实现了不错的收入。虽然他们中的大多数人都经历了长期的积累,但AI技术的爆发似乎提供了一个弯道超车的机会。


这个想法越来越强烈,直到有一天晚上,当我又一次被加到一个紧急项目里,领导发来消息:"小张,这个需求很紧急,今晚能上线吗?"


我望着窗外的夜色,突然感到一阵前所未有的清晰。


我回复道:"可以,这是我在公司的最后一个项目了。"


第二天,我提交了辞职申请。


技术探索:从零开始的AI学习之路


辞职后的第一个月,我给自己制定了严格的学习计划。每天早上6点起床,先锻炼一小时,然后开始我的"AI课程"。


首先,我需要理解大语言模型的基本原理。虽然我有编程基础,但NLP和深度学习对我来说仍是比较陌生的领域。我从《Attention is All You Need》这篇奠定Transformer架构的论文开始,通过各种在线资源,逐步理解了当代大语言模型的工作机制。


# 简化的Transformer注意力机制示例
def scaled_dot_product_attention(query, key, value, mask=):
# 计算注意力权重
matmul_qk = tf.matmul(query, key, transpose_b=True)

# 缩放
depth = tf.cast(tf.shape(key)[-1], tf.float32)
logits = matmul_qk / tf.math.sqrt(depth)

# 添加掩码(可选)
if mask is not :
logits += (mask * -1e9)

# softmax归一化
attention_weights = tf.nn.softmax(logits, axis=-1)

# 应用注意力权重
output = tf.matmul(attention_weights, value)

return output, attention_weights

然后,我需要掌握如何有效地利用OpenAI、Anthropic等公司提供的API。这包括了解Prompt Engineering的技巧,学会如何构建有效的提示词,以及如何处理模型输出的后处理工作。


我还深入研究了向量数据库、检索增强生成(RAG)等技术,这些对于构建基于知识的AI应用至关重要。


Similarity(A,B)=ABA×B=cos(θ)Similarity(A, B) = \frac{A \cdot B}{|A| \times |B|} = \cos(\theta)


这个余弦相似度公式成为了我日常工作的一部分,用于计算文本嵌入向量之间的相似性。


同时,我不断实践、不断失败、不断调整。我记得有一周,我几乎每天睡眠不足5小时,只为解决一个模型幻觉问题。但与公司工作不同的是,这种忙碌源于我的热情和对问题的好奇,而非外部压力。


产品孵化:从创意到实现


学习的同时,我开始思考自己的产品定位。在观察市场和分析自身技能后,我决定开发一款面向内容创作者的AI助手,我将其命名为"创作魔法师"。


这个产品的核心功能是帮助博主、自媒体人和营销人员高效创作内容。与市面上的通用AI不同,它专注于内容创作流程:从选题分析、结构规划、初稿生成到细节优化和SEO改进,提供全流程支持。


产品开发过程中,我遇到了许多挑战:



  1. 技术架构选择:作为独立开发者,资金有限,我需要在功能与成本间找平衡。最终我选择了Next.js + TailwindCSS搭建前端,Node.js构建后端,MongoDB存储数据,Pinecone作为向量数据库存储文档嵌入向量。

  2. 模型优化:为了降低API调用成本,我设计了一套智能路由系统,根据任务复杂度自动选择不同的模型,简单任务用更经济的模型,复杂任务才调用高端模型。

  3. 用户体验设计:没有设计团队,我自学了基础UI/UX知识,参考优秀产品,反复调整界面直到满意。

  4. 运营与推广:这对我这个技术人来说是最大挑战。我学会了编写有吸引力的产品描述,设计落地页,甚至尝试了简单的SEO优化。


最艰难的时刻是产品上线后的第一个月。用户增长缓慢,每天只有个位数的新注册。我开始怀疑自己的决定,甚至一度考虑放弃,重新找工作。


转机:从10个用户到10,000用户


转机出现在上线后的第二个月。一位拥有20万粉丝的自媒体创作者使用了我的产品,对效果非常满意,在他的平台上分享了使用体验。这篇分享在创作者圈内引起了不小的反响。


24小时内,我的注册用户从原来的不到200人猛增至1500多人。服务器一度崩溃,我熬夜进行紧急扩容和优化。这次意外的曝光让我意识到,产品定位是正确的,市场需求确实存在。


接下来,我调整了运营策略:



  1. 主动联系内容创作者,提供免费试用,换取真实反馈和可能的推荐。

  2. 根据用户反馈快速迭代产品功能,每周至少发布一次更新。

  3. 建立用户社区,鼓励用户分享使用技巧,相互帮助。

  4. 编写详细的使用教程和最佳实践指南,降低用户上手难度。


// 用户增长追踪系统的一部分
function trackUserGrowth() {
const date = new Date().toISOString().split('T')[0];

db.collection('metrics').updateOne(
{ date: date },
{
$inc: {
newUsers: 1,
totalImpressions: userSource.impressions || 0
},
$set: {
lastUpdated: new Date()
}
},
{ upsert: true }
);
}

三个月后,用户数突破5,000;半年后,达到10,000。更令人欣慰的是,付费转化率远超我的预期,达到了8%左右,而行业平均水平通常在2-3%。


我分析了成功原因:



  1. 产品聚焦特定痛点:不追求通用性,而是深入解决内容创作者的具体问题。

  2. 及时响应用户需求:独立开发的优势是决策链短,能快速调整方向。

  3. 社区效应:用户之间的口碑传播形成了良性循环。

  4. 个性化服务:我经常亲自回复用户问题,提供定制化建议,这在大公司很难做到。


财务自由:从赤字到收支平衡


谈到收入模式,我采用了"免费+订阅"的策略:



  • 基础功能完全免费,足以满足普通用户的需求

  • 高级功能(如批量处理、高级模板、深度分析等)需要订阅

  • 提供月度计划(49元)和年度计划(398元,约33元/月)


最初几个月,收入微乎其微。我记得第一个月的收入仅有287元,而我在公司的月薪是25,000元。差距之大,让我一度怀疑自己的决定。


但随着用户增长,情况逐渐改善。第三个月收入突破5,000元,第四个月达到12,000元,第六个月——也就是我离职半年后,月收入达到了23,500元,基本与我原来的工资持平。


考虑到我现在的生活成本降低了(不需要租住在北京市中心的高价公寓,不需要每天通勤),实际上我的生活质量反而提高了。


更重要的是,这些收入是真正属于我的,不依赖于任何公司的评价和KPI。我建立了自己的"被动收入引擎",它可以在我睡觉时继续为我工作。


生活平衡:找回被工作吞噬的自我


收入只是故事的一部分。对我来说,最大的变化是生活方式的改变。


在互联网公司工作时,我的生活可以用一句话概括:工作即生活。我几乎没有个人时间,健康状况逐渐恶化,社交圈萎缩到只剩同事,爱好被束之高阁。


成为独立开发者后,我重新掌控了自己的时间:



  • 合理作息:我不再熬夜加班,保持每天7-8小时高质量睡眠。

  • 定期锻炼:每天至少运动一小时,半年下来体重减轻10kg,体脂率降低5%。

  • 地点自由:我可以在家工作,也可以去咖啡馆,甚至尝试了几次"工作旅行",边旅游边维护产品。

  • 深度学习:不再为了应付工作而学习,而是追随个人兴趣深入研究技术。

  • 重拾爱好:我重新开始弹吉他,参加了当地的音乐小组,结识了一群志同道合的朋友。


这种生活方式让我找回了工作的意义——工作是为了更好的生活,而不是生活为了工作。我的创造力和工作热情反而因此提升,产品迭代速度和质量都超出了预期。


技术反思:AI时代的个人定位


在这半年的独立开发经历中,我对AI技术和个人发展有了更深的思考。


首先,大模型时代确实改变了软件开发的范式。传统开发模式是"写代码解决问题",而现在更多的是"设计提示词引导AI解决问题"。这不意味着编程技能不重要,而是编程与AI引导能力的结合变得越来越重要。


# 传统开发方式
def analyze_sentiment(text):
# 复杂的NLP算法实现
words = tokenize(text)
scores = calculate_sentiment_scores(words)
return determine_overall_sentiment(scores)

# AI时代的开发方式
def analyze_sentiment_with_llm(text):
prompt = f"""
分析以下文本的情感倾向,返回'正面'、'负面'或'中性'。
只返回分类结果,不要解释。
文本: {text}
"""

result = llm_client.generate(prompt, max_tokens=10)
return result.strip()

其次,我认识到技术民主化的力量。曾经需要一个团队才能完成的项目,现在一个人借助AI工具也能完成。这为独立开发者创造了前所未有的机会,但也意味着差异化和创新变得更加重要。


最后,我发现真正的核心竞争力不在于熟悉某项技术,而在于解决问题的思维方式和对用户需求的理解。技术工具会不断更新迭代,但洞察问题和设计解决方案的能力将长期有效。


写给迷茫的年轻人


回顾这半年的经历,我想对那些和当初的我一样迷茫的年轻人说几句话:



  1. 公司经历有价值,但不是唯一路径:在大公司工作能积累经验和人脉,但不要把它视为唯一选择。如果环境压抑了你的创造力和热情,寻找改变是勇敢而非逃避。

  2. 技术浪潮创造机会窗口:AI等新技术正在重构行业,为个人提供了"弯道超车"的机会。保持开放心态,持续学习,你会发现比想象中更多的可能性。

  3. 找到可持续的节奏:成功不在于短期的爆发,而在于长期的坚持。设计一种既能推动目标实现又不会消耗自己的工作方式,才能走得更远。

  4. 用户价值胜过技术炫耀:最成功的产品往往不是技术最先进的,而是最能解决用户痛点的。专注于创造真正的价值,而不仅仅是展示技术能力。

  5. 享受过程,而非仅追求结果:如果你只关注最终目标而忽视日常体验,即使达到目标也可能感到空虚。真正的成功包含了对过程的享受和个人成长。


未来展望:持续进化的旅程


现在,我站在新的起点上。"创作魔法师"只是我旅程的第一步,我已经开始规划下一个产品,瞄准了另一个我认为有潜力的细分市场。


与此同时,我也在考虑如何扩大团队规模。虽然独立开发有其魅力,但有些想法需要更多元的技能组合才能实现。我计划在未来半年内招募1-2名志同道合的伙伴,组建一个小而精的团队。


技术上,我将继续深入研究大模型的微调和部署技术。随着开源模型的进步,在特定领域微调自己的模型变得越来越可行,这将是我产品的下一个竞争优势。


生活方面,我正计划一次为期两个月的"数字游牧"之旅,边旅行边工作,探索更多可能的生活方式。


路上会有挑战,也会有挫折,但我不再惧怕。因为我知道,真正的自由不在于没有困难,而在于面对困难时仍能按自己的意愿选择前进的方向。


当我在咖啡馆工作到黄昏,看着窗外的夕阳,我常常感到一种难以言喻的满足感。这种感觉告诉我,我正在正确的道路上——一条通往真正生活的道路。


如果你也在考虑类似的选择,希望我的故事能给你一些启发。记住,每个人的路都不同,重要的是找到属于自己的节奏和方向。


在这个AI加速发展的时代,机会前所未有,但终究,技术只是工具,生活才是目的。


作者:indieAI
来源:juejin.cn/post/7486788421932400652
收起阅读 »

为什么Java里面,Service层不直接返回Result对象?

前言 昨天在Code Review时,我发现阿城在Service层直接返回了Result对象。 指出这个问题后,阿城有些不解,反问我为什么不能这样写。 于是我们展开了一场技术讨论(battle 🤣)。 讨论过程中,我发现这个看似简单的设计问题,背后其实涉及分层...
继续阅读 »

前言


昨天在Code Review时,我发现阿城在Service层直接返回了Result对象。


指出这个问题后,阿城有些不解,反问我为什么不能这样写。


于是我们展开了一场技术讨论(battle 🤣)。


讨论过程中,我发现这个看似简单的设计问题,背后其实涉及分层架构、职责划分、代码复用等多个重要概念。


与其让这次讨论的内容随风而去,不如整理成文,帮助更多遇到同样困惑的朋友理解原因。


知其然,更知其所以然。


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


职责分离原则


在传统的MVC架构中,Service层和Controller层各自承担着不同的职责。


Service层负责业务逻辑的处理,而Controller层负责HTTP请求的处理和响应格式的封装。


当我们将数据包装成 Result 对象的任务交给 Service 层时,意味着 Service 层不再单纯地处理业务逻辑,而是牵涉到了数据处理和响应的部分。


这样会导致业务逻辑与表现逻辑的耦合,降低了代码的清晰度和可维护性。


看一个不推荐的写法:


@Service
publicclass UserService {
    public Result<User> getUserById(Long id) {
        User user = userMapper.selectById(id);
        if (user == null) {
            return Result.error(404, 用户不存在);
        }
        return Result.success(user);
    }
}

@RestController
publicclass UserController {
    @Autowired
    private UserService userService;
    
    @GetMapping("/user/{id}")
    public Result<User> getUser(@PathVariable Long id) {
        return userService.getUserById(id);
    }
}

上面代码中,Service 层不仅负责从数据库获取用户信息,还直接处理了返回的结果。


如果我们需要改变返回的格式,或者进行错误信息的标准化,所有 Service 层的方法都需要修改。这样会导致代码的高耦合。


相比之下,以下做法将展示逻辑留给 Controller 层,保证了业务逻辑的纯粹性:


@Service
publicclass UserService {
    public User getUserById(Long id) {
        User user = userMapper.selectById(id);
        if (user == null) {
            thrownew BusinessException(用户不存在);
        }
        return user;
    }
}

@RestController
publicclass UserController {
    @Autowired
    private UserService userService;
    
    @GetMapping("/user/{id}")
    public Result<User> getUser(@PathVariable Long id) {
        User user = userService.getUserById(id);
        return Result.success(user);
    }
}

让每一层都专注于自己的职责。


可复用性问题


当Service层返回Result时,会严重影响方法的可复用性。


假设我们有一个订单服务需要调用用户服务:


@Service
publicclass OrderService {
    @Autowired
    private UserService userService;
    
    public void createOrder(Long userId, OrderDTO orderDTO) {
        // 不推荐的方式:需要解包Result
        Result<User> userResult = userService.getUserById(userId);
        if (!userResult.isSuccess()) {
            thrownew BusinessException(userResult.getMessage());
        }
        User user = userResult.getData();
        
        // 后续业务逻辑
        validateUserStatus(user);
        // ...
    }
}

这种写法有个很明显的问题。


OrderService 作为另一个业务服务,业务之间的调用本来应该简单直接,但使用 Result 带来了两个问题:



  • 不知道 Result 里到底包含什么,还得去查看代码里面的实现,写起来麻烦。

  • 还需要额外判断 Result 的状态,增加了不必要的复杂度。


如果是调用第三方外部服务,需要这种包装还能理解,但在自己业务之间互相调用时,完全没必要这样做。


如果Service返回纯业务对象:


@Service
public class OrderService {
    @Autowired
    private UserService userService;
    
    public void createOrder(Long userId, OrderDTO orderDTO) {
        // 推荐的方式:直接获取业务对象
        User user = userService.getUserById(userId);
        
        // 后续业务逻辑
        validateUserStatus(user);
        // ...
    }
}

代码变得简洁且符合直觉。


业务层之间直接传递业务对象,保持简单和清晰。


异常处理机制


有些 Service 层在业务判断失败后,会直接返回 Result.fail(xxx) 这样的代码,例如:


public Result<Void> createOrder(Long userId, OrderDTO orderDTO) {
    if (userId == null) {
        return Result.fail("用户ID不能为空");
    }
    // 后续业务逻辑
    return Result.success();
}

这种做法有几个问题:



  • 重复的错误处理:  每个方法都得写一大堆类似的错误判断代码,增加了代码量。

  • 错误分散:  错误处理分散在每个方法里,如果需要改进错误逻辑,要在多个地方修改,麻烦且容易出错。


而如果我们通过抛出异常并结合全局异常处理来统一处理错误,例如:


public void createOrder(Long userId, OrderDTO orderDTO) {
    if (userId == null) {
        throw new BusinessException("用户ID不能为空");
    }
    // 后续业务逻辑
}

再通过全局异常捕获来转换为 Result:


@RestControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(BusinessException.class)
    public Result<Void> handleBusinessException(BusinessException e) {
        return Result.error(400, e.getMessage());
    }

    @ExceptionHandler(Exception.class)
    public Result<Void> handleException(Exception e) {
        log.error("系统异常", e);  // 这里可以查看堆栈信息
        return Result.error(500"系统繁忙");
    }
}

这样做的好处是:


减少重复代码:业务方法不再需要写重复的错误判断,代码更简洁。



  • 集中错误处理:  错误处理集中在一个地方,修改时只需修改全局异常处理器,不用改动每个 Service 层方法。

  • 业务与错误分离:  业务逻辑专注处理核心功能,错误处理交给统一的机制,代码更加清晰易懂。


而且异常可以携带更丰富的上下文信息,如果业务侧需要时,可以带上堆栈信息,便于一些问题的定位。


测试便利性


Service层返回业务对象而不是Result时,能够大大提升单元测试的便利性:


@SpringBootTest
publicclass UserServiceTest {
    
    @Autowired
    private UserService userService;
    
    @Test
    public void testGetUserById() {
        // 推荐的方式:直接断言业务对象
        User user = userService.getUserById(1L);
        assertNotNull(user);
        assertEquals(张三, user.getName());
    }
    
    @Test
    public void testGetUserById_NotFound() {
        // 推荐的方式:断言抛出异常
        assertThrows(BusinessException.class, () -> {
            userService.getUserById(999L);
        });
    }
}

如果Service返回Result,测试代码则需要写得更复杂:


@Test
public void testGetUserById() {
    // 不推荐的方式:需要解包Result
    Result<User> result = userService.getUserById(1L);
    assertTrue(result.isSuccess());
    assertNotNull(result.getData());
    assertEquals(张三, result.getData().getName());
}

测试代码变得莫名冗长,还得去关注响应结构,这并不是Service层测试的关注点。


Service 层本应专注于业务逻辑,测试也应该直接验证业务数据。


领域驱动设计角度


再换个角度。


从领域驱动设计(DDD)的角度来看,Service 层属于应用层或领域层,应该使用领域语言来表达业务逻辑。


而 Result 是基础设施层的概念,代表 HTTP 响应格式,不应该污染领域层。


例如,考虑转账业务:


@Service
publicclass TransferService {
    
    public TransferResult transfer(Long fromAccountId, Long toAccountId, BigDecimal amount) {
        Account fromAccount = accountRepository.findById(fromAccountId);
        Account toAccount = accountRepository.findById(toAccountId);
        
        fromAccount.deduct(amount);
        toAccount.deposit(amount);
        
        accountRepository.save(fromAccount);
        accountRepository.save(toAccount);
        
        returnnew TransferResult(fromAccount, toAccount, amount);
    }
}

在这个例子中,TransferResult 是一个领域对象,代表了转账的结果,包含了与业务相关的意义,而不是一个通用的 HTTP 响应封装 Result。


这种做法更符合领域模型的表达,体现了领域层的职责——处理业务逻辑,而不是涉及 HTTP 响应格式的细节。


接口适配的灵活性


当 Service 层返回纯粹的业务对象时,Controller 层可以根据不同的接口需求灵活封装响应:


@RestController
@RequestMapping("/api")
publicclass UserController {
    
    @Autowired
    private UserService userService;
    
    // REST接口返回Result
    @GetMapping("/user/{id}")
    public Result<User> getUser(@PathVariable Long id) {
        User user = userService.getUserById(id);
        return Result.success(user);
    }
    
    // GraphQL接口直接返回对象
    @QueryMapping
    public User user(@Argument Long id) {
        return userService.getUserById(id);
    }
    
    // RPC接口返回自定义格式
    @DubboService
    publicclass UserRpcServiceImpl implements UserRpcService {
        public UserDTO getUserById(Long id) {
            User user = userService.getUserById(id);
            return convertToDTO(user);
        }
    }
}

同一个Service方法可以被不同类型的接口复用,每个接口根据自己的协议要求封装响应。


强行使用 Result 会导致接口的适配性变差,无法根据不同协议的需求灵活定制响应格式。


灵活性反而丢失了。


事务边界清晰


Service 层通常是事务边界所在,当 Service 返回业务对象时,事务的语义更加清晰:


@Service
publicclass OrderService {
    
    @Transactional
    public Order createOrder(OrderDTO orderDTO) {
        Order order = new Order();
        // 设置订单属性
        orderMapper.insert(order);
        
        // 扣减库存
        inventoryService.deduct(orderDTO.getProductId(), orderDTO.getQuantity());
        
        return order;
    }
}

在这个例子中,事务是围绕 Service 层的方法展开的,@Transactional 注解确保在业务逻辑执行失败时,事务会回滚。因为方法正常返回时,事务会提交;如果抛出异常,事务会回滚,事务的边界非常明确。


如果 Service 返回的是 Result,很难界定事务是否应该回滚。比如:


public Result<OrdercreateOrder(OrderDTO orderDTO) {
    Order order = new Order();
    // 设置订单属性
    orderMapper.insert(order);
    
    // 扣减库存
    Result<Void> inventoryResult = inventoryService.deduct(orderDTO.getProductId(), orderDTO.getQuantity());
    if (!inventoryResult.isSuccess()) {
        return Result.fail("库存不足");
    }
    
    return Result.success(order);
}

在这种情况下,如果库存不足,虽然 Result 返回失败信息,但事务并不会回滚,可能会导致数据不一致,反而还得额外去抛出异常。


而通过抛出异常的方式,事务的回滚语义非常清晰:异常抛出则回滚,方法正常返回则提交,这种设计确保了事务的边界更加明确,避免了潜在的数据一致性问题。


作者:神奇小汤圆
来源:juejin.cn/post/7610681694149148687
收起阅读 »

一文搞懂 SEO 全流程技术

web
在现代 Web 开发中,技术 SEO 是确保网站能够被搜索引擎正确索引和排名的关键。 不做 SEO: 网站处于隐形状态:除非用户直接输入网站,否则没人能通过搜索找到你的网站 收录混乱:搜索引擎不收录你的页面,或是收录的无效页面 仅能靠付费推广:想要流量只能付...
继续阅读 »

在现代 Web 开发中,技术 SEO 是确保网站能够被搜索引擎正确索引和排名的关键。


不做 SEO:



  • 网站处于隐形状态:除非用户直接输入网站,否则没人能通过搜索找到你的网站

  • 收录混乱:搜索引擎不收录你的页面,或是收录的无效页面

  • 仅能靠付费推广:想要流量只能付费做广告或者社交媒体推广

  • 裸链接:用户分享网址时只能是蓝色 URL 链接,可能被识别为垃圾信息

  • 品牌信任度:用户难以搜索到网站,降低网站的可信度


做了 SEO:



  • 主动被发现:搜索关键词时你的网站会得到推荐,增加流量

  • 收录全面:搜索引擎知道网站有哪些页面,网站内容更新后会很快被搜索到

  • 免费且持久:搜索引擎会长期维护你的网站,排名稳定后,会有源源不断的流量

  • 优雅的展示封面:分享会展示精美的网站封面,提升用户点击率

  • 技术红利:做了 SEO,会优化内容结构,变相提升网站的技术

  • 背书效应:用户潜意识里认为排在前面的网站更权威、更正规


SEO 这么有用,但一般人的实现却是:



  • 加个 <title> 标签

  • 加几个 <meta> 关键词


真正有用、高效的 SEO 要怎么做呢?


SEO 核心概念


先来了解一下 SEO 的一些核心概念。



sitemap.xml 是什么?



  • 作用:给搜索引擎指路

  • 功能:



    • 列出你希望被收录的所有页面

    • 告诉页面更新时间、优先级



  • 示例


<url>
  <loc>https://xxx.com/page1</loc>
  <lastmod>2026-02-12</lastmod>
  <priority>0.8</priority>
</url>

robots.txt 是什么?



  • 作用:给搜索引擎定规矩

  • 功能:



    • 禁止某些爬虫

    • 禁止爬某些目录 / 页面

    • 告诉爬虫去哪里找 sitemap



  • 示例


User-agent: *
Disallow: /admin/
Disallow: /private.html
Allow: /
Sitemap: https://xxx.com/sitemap.xml


Schema.org 是什么?



  • 作用


给 Google、百度、必应 等搜索引擎看的,告诉它们:



  • 这是什么内容(文章?产品?视频?人?)

  • 标题是什么

  • 发布时间

  • 作者

  • 评分

  • 目录结构

  • 功能



    • 让搜索结果更美观、更丰富(显示标题、图、时间、作者)

    • 让搜索引擎更懂你页面内容

    • 提升 SEO 效果



  • 示例


<script type="application/ld+json">
  {
    "@context""https://schema.org",
    "@type""Article",
    "headline""文章标题",
    "author": { "@type""Person""name""作者" }
  }
</script>

OG Image 是什么?


当你把网页分享到微信、QQ、抖音、小红书、Discord、Facebook时,显示的那张封面图,就是 OG Image。



  • 作用



    • 控制分享出去长什么样

    • 没有它,分享可能没封面图、显示效果差



  • 功能



    • 优化分享效果

    • 提升社交媒体分享的点击率和用户互动



  • 示例


<meta property="og:title" content="标题" />
<meta property="og:description" content="描述" />
<meta property="og:image" content="https://xxx.com/cover.jpg" />


Nuxt SEO


Nuxt SEO 是一套 SEO 元模块,其中包含多个模块:




  1. @nuxtjs/sitemap: 智能生成站点地图

  2. @nuxtjs/robots: 管理爬虫访问规则

  3. nuxt-og-image: 动态生成社交分享图片

  4. nuxt-schema-org: 注入结构化数据 (JSON-LD)

  5. nuxt-seo-utils: 提供通用的 SEO 工具函数


核心优势:



  • 自动化:自动生成 robots.txtsitemap.xmlog:image

  • 最佳实践:默认遵循 Google 等搜索引擎的推荐标准。

  • 开发体验:与 Nuxt DevTools 深度集成,提供实时的 SEO 调试能力。


快速开始


了解完以上概念后,接下来开始提升网站的 SEO 效果。


安装


pnpm i @nuxtjs/seo

配置


nuxt.config.ts 中进行配置。最重要的是设置 site.url,它是生成 SitemapCanonical URL 的基础。


export default defineNuxtConfig({
  modules: ["@nuxtjs/seo"],
  site: {
    url"https://example.com"// 网站域名
    name"我的 Nuxt 应用",
    description"一个高性能的 Nuxt 网站",
    defaultLocale"zh-CN"// 设置默认语言
  },
})

开发调试


安装了 @nuxtjs/seo 后,启动项目打开 Nuxt DevTools,你会发现一个新的 SEO 选项卡:



  • 实时检查:查看当前页面的 Meta 标签、OG 图片预览和 Schema 数据。

  • 缺失提示:如果页面缺少关键的 SEO 信息(如 Title 或 Description),DevTools 会给出警告。



进阶配置


虽然 @nuxtjs/seo 已经提供了开箱即用的配置,但在实际生产环境中,我们往往需要更精细的控制。


@nuxtjs/sitemap


sitemap.xml 是搜索引擎发现你网站页面的地图。@nuxtjs/sitemap 模块会自动扫描你的静态路由,但对于动态内容(如博客文章、商品详情),我们需要手动告知它。


常用配置


// nuxt.config.ts
export default defineNuxtConfig({
  sitemap: {
    // 1. 动态路由数据源:支持 API 端点
    sources: ["/api/__sitemap__/urls"],
    // 2. 排除不需要被索引的页面
    exclude: ["/user/**""/admin/**""/checkout"],
    // 3. 开启分块 (Chunking):适用于大型网站 (> 50k URL)
    sitemapstrue,
  },
})



对于动态路由,你可以创建一个 Server API (例如 server/api/__sitemap__/urls.ts) 来返回所有的文章链接。



如何验证配置是否生效?


本地启动项目,访问:http://localhost:3000/sitemap.xml;线上环境访问:https://yoursite.com/sitemap.xml,当看到 xml 内容既配置成功。


@nuxtjs/robots


robots.txt 定义了爬虫可以访问哪些区域。@nuxtjs/robots 的一大优势是它能根据环境自动切换策略:开发环境默认禁止所有爬虫,生产环境默认允许


// nuxt.config.ts
export default defineNuxtConfig({
  robots: {
    // 1. 全局爬虫规则(User-agent: * 对应 allRobots)
    allRobots: {
      // 禁止爬取的目录/页面(常用)
      Disallow: [
        "/admin/"// 后台管理页
        "/api/"// 接口目录
        "/_nuxt/"// Nuxt 打包后的静态资源(可选,一般无需禁止)
        "/private/"// 私有页面
        "/*.pdf$"// 禁止爬取所有 PDF 文件(正则写法)
      ],
      // 允许爬取的内容(覆盖 Disallow,可选)
      Allow: [
        "/api/public/"// 允许爬取公开接口
      ],
      // 爬虫抓取频率(可选,只是建议,非强制)
      CrawlDelay2// 每次请求间隔 2 秒,减轻服务器压力
    },
    // 添加自定义规则
    groups: [
      {
        userAgent: ["Baiduspider"], // 针对百度爬虫的特殊规则
        disallow: ["/api/"], // 禁止访问
        allow: ["/api/public-data"], // 允许访问
      },
      {
        userAgent"*",
        disallow: ["/secret""/admin"],
        allow"/",
      },
    ],
  },
})

如何验证配置是否生效?


本地启动项目,访问:http://localhost:3000/robots.txt;线上环境访问:https://yoursite.com/robots.txt,会看到以下内容:


User-agent: *
Disallow/admin/
Disallow/api/
Disallow/private/
Allow: /
Crawl-delay2
Sitemaphttps://你的域名.com/sitemap.xml

User-agentBaiduspider
Disallow/archive/

nuxt-og-image


全局配置


// nuxt.config.ts
export default defineNuxtConfig({
  modules: ["@nuxtjs/seo"],
  // Nuxt SEO 模块核心配置
  seo: {
    // 基础站点信息(会自动复用为 OG 基础信息)
    site: {
      // ...
    },
    // OG 标签全局配置(重点:ogImage)
    og: {
      // 全局默认 OG 类型
      type"website",
      // 全局默认 OG Image 配置(核心)
      image: {
        // 图片路径:推荐用绝对路径,或相对路径(模块会自动拼接 site.url)
        src"/og-default.jpg"// 等价于 https://你的域名.com/og-default.jpg
        width1200// 最优尺寸
        height630,
        type"image/jpeg"// 图片格式
        alt"网站默认分享封面"// 图片描述(可选,提升可访问性)
      },
      // 全局默认语言
      locale"zh_CN",
    },
    // 兼容 Twitter 卡片(可选,自动复用 ogImage)
    twitter: {
      card"summary_large_image"// 大图卡片样式
    },
  },
})

单页面配置


单页面配置的内容会覆盖全局配置。


在具体页面(如 pages/article/[id].vue)中,用 useOgImageuseSeoMeta 自定义该页面的 OG Image:


<template>
  <div>详情页</div>
</template>

<script setup lang="ts">
  // 1. 假设从接口获取文章数据
  const article = await fetchArticleData() // 你的业务逻辑
  const articleCover = article.coverImage || "/og-article-default.jpg"

  // 2. 方式1:单独修改 OG Image(推荐,更精准)
  useOgImage({
    src: articleCover, // 自定义封面图路径
    width1200,
    height630,
    alt: article.title// 用文章标题作为图片描述
  })

  // 3. 方式2:批量修改 OG 信息(含 Image)
  useSeoMeta({
    ogTitle: article.title// 文章标题
    ogDescription: article.summary// 文章摘要
    ogImage: [
      {
        src: articleCover,
        width1200,
        height630,
      },
    ],
    twitterImage: articleCover, // 兼容 Twitter
  })
</script>

动态路由批量配置


动态页面的配置可以结合sitemap来设置:


// nuxt.config.ts
export default defineNuxtConfig({
  modules: ["@nuxtjs/seo"],
  sitemap: {
    // 动态生成路由 + 对应 OG Image
    routesasync () => {
      const articles = await fetchAllArticles() // 获取所有文章
      return articles.map((article) => ({
        url`/article/${article.id}`,
        // 给每个路由绑定 OG Image
        seo: {
          ogImage: {
            src: article.coverImage,
            width1200,
            height630,
          },
        },
      }))
    },
  },
})

如何验证配置是否生效?


启动项目,访问页面右键「页面源代码」,能看到 <meta property="og:image" content="你的图片URL"> 即配置成功。


SEO 进阶技巧与最佳实践


1. 喂饱搜索引擎


搜索引擎喜欢结构化数据。通过 Schema.org 标记,你可以让 Google 更精准地理解你的内容。


Nuxt SEO 提供了 useSchemaOrg 组合式函数,让你可以像写 Vue 组件一样编写 Schema:


<script setup lang="ts">
  useSchemaOrg([
    defineArticle({
      image'/images/cover.jpg',
      datePublished'2026-02-18',
      author: {
        name'Trae',
      },
    })
  ])
</script>

2. 社交分享优化


当用户将你的链接分享到 Twitter 或微信时,一张精美的预览图能显著提高点击率。



  • Open Graph 标签:自动生成 og:title, og:description 等标签。

  • OG Image 生成nuxt-og-image 模块可以根据你的页面内容(标题、摘要)动态生成 SVG 或 PNG 图片。这意味着你不需要为每篇文章手动通过 Photoshop 制作封面,代码即设计!


3. 链接与 URL 规范化


重复内容是 SEO 的大忌。Nuxt SEO 能帮你处理这些细节:



  • Trailing Slashes:统一 URL 结尾是否带斜杠(例如 /about vs /about/),避免被视为两个页面。

  • Canonical URLs:自动添加规范链接,告诉搜索引擎哪个是"正版"页面,防止参数(如 ?utm_source=...)导致权重分散。


多搜索引擎适配策略


不同的搜索引擎有不同的脾气,针对国内外的搜索巨头,可以采取差异化的策略。



Google 优化


Google 的爬虫能力最强,能够很好地执行 JavaScript。



  • Core Web Vitals:重点关注 LCP (最大内容绘制)CLS (累积布局偏移)INP (交互到下一次绘制)。Nuxt 默认的性能优化通常能满足要求。

  • Google Search Console:在 GSC 中主动提交你的 sitemap.xml,并定期查看"覆盖率"报告,修复 404 和 500 错误。

  • 富媒体搜索结果:利用上文提到的 Schema 标记,争取在搜索结果中展示星级评分、问答等富媒体信息。


百度优化


百度爬虫对现代 JavaScript 的执行能力相对较弱,且对页面加载速度极其敏感。



  • 确保 SSR 输出:这是最关键的一点。确保你的 Nuxt 应用以 SSR 模式运行,并且 HTML 源码中直接包含核心内容。



    • 验证方法:在终端运行 curl https://your-site.com,检查返回的 HTML 是否包含你的内容。



  • 主动推送:百度非常依赖主动提交。看在网站上线后,通过 API 立即将链接推送到百度站长平台。

  • URL 结构:百度更喜欢扁平、简单的 URL 结构,避免过深的层级和复杂的动态参数。

  • 移动端适配:百度对移动端友好的站点有明显的加权,做好响应式适配移动端是一个不错的选择。


必应优化


Bing 在搜索端的占比越来越高,且是 ChatGPT 搜索的数据源之一。



  • IndexNow 协议:Bing 大力推广 IndexNow 协议,允许网站在一个 URL 发生变化时立即通知搜索引擎。这比传统的 Sitemap 被动抓取要快得多。

  • Bing Webmaster Tools:功能与 GSC 类似,建议注册并提交 Sitemap。


总结


SEO 是一个长期积累的过程,但 Nuxt SEO 模块帮我们扫清了技术障碍。通过合理的配置和使用,并针对 Google、百度等不同平台进行针对性优化,可以确保你的 Nuxt 应用在起跑线上就领先一步。


🔗 项目地址: nuxtseo.com



👍作品推荐


Haotab 新标签页,一个优雅的新标签页


chrome 商店
| edge 商店
| 在线版


❤️静待你的体验



作者:学什么前端
来源:juejin.cn/post/7609891142464159780
收起阅读 »

MyBatis二级缓存翻车实录:改个昵称,全公司用户头像集体“穿越”?!

作者:不想打工的码农 原创手记|深夜翻源码|拒绝“理论上” (附:MyBatis 3.5.13 + MySQL 8.0 真实战场复盘) 📱 凌晨2:18,钉钉炸出灵魂拷问 “哥!用户A改了昵称,用户B的头像突然变成A的旧头像了?!” ——测试小王发来三连...
继续阅读 »

作者:不想打工的码农

原创手记|深夜翻源码|拒绝“理论上”

(附:MyBatis 3.5.13 + MySQL 8.0 真实战场复盘)





📱 凌晨2:18,钉钉炸出灵魂拷问



“哥!用户A改了昵称,用户B的头像突然变成A的旧头像了?!”

——测试小王发来三连截图,手抖得连标点都打歪了



我猛灌半杯冰美式,盯着屏幕:

✅ 数据库查证:用户B头像URL未变

✅ 前端Network:返回的base64头像数据确是用户A的

✅ 服务日志:无异常堆栈,无SQL报错


最魔幻的是



  • 刷新3次,头像在“用户B原图”“用户A旧图”“空白”间随机切换

  • 重启服务瞬间恢复正常,10分钟后复现

  • 仅发生在用户修改资料后


我后背发凉:这哪是bug,这是缓存成精了啊!




🔍 三小时硬核排查(附真实命令)


第一回合:甩锅Redis?❌


redis-cli> KEYS user:avatar:*  
# 结果:空!项目根本没接Redis缓存!

测试小王弱弱补刀:“哥...你上周说‘简单功能用MyBatis二级缓存就行’..."

我:???(记忆碎片开始闪回)


第二回合:Arthas锁死缓存轨迹 ✅


# 监控Mapper方法返回值
watch com.xxx.mapper.UserMapper selectAvatarByUid '{returnObj}' -x 3 -n 5

关键输出


[第1次调用] Avatar{id=1002, url="b_old.jpg"}  ← 用户B的头像  
[第2次调用] Avatar{id=1001, url="a_old.jpg"} ← 竟是用户A的旧头像!
[第3次调用] null

突破口:返回对象的id字段都错乱了!缓存污染实锤!


第三回合:翻出“罪证”XML


<!-- UserMapper.xml -->
<mapper namespace="com.xxx.mapper.UserMapper">
<cache eviction="LRU" size="1024" readOnly="false"/>
<select id="selectAvatarByUid" resultType="Avatar">
SELECT id, url FROM avatar WHERE user_id = #{uid}
</select>
</mapper>

<!-- ProfileMapper.xml(致命复制粘贴) -->
<mapper namespace="com.xxx.mapper.UserMapper"> <!-- ⚠️ 和上面一模一样! -->
<cache eviction="LRU" size="512" readOnly="true"/>
<update id="updateNickname">
UPDATE user SET nickname=#{name} WHERE id=#{id}
</update>
</mapper>

瞳孔地震

两个Mapper共用同一个namespace

MyBatis二级缓存以namespace为隔离单位 → 所有操作共享同一块缓存区域 → 头像数据被昵称更新操作污染




💥 深扒MyBatis缓存源码(3.5.13版)


缓存key生成逻辑(CacheKey.java


// 拼接缓存key的核心逻辑
public void update(Object object) {
if (object != null && object.getClass().isArray()) {
// 按参数、SQL、offset等生成唯一key
hashCode = hashCode * 31 + ArrayUtil.hashCode(object);
}
}

关键真相



  • 缓存key = namespace + sql + params + offset...

  • 但namespace相同时,不同Mapper的SQL会混用同一缓存池

  • ProfileMapper.updateNickname执行时,触发缓存清空(因readOnly=false)

  • 但清空的是整个namespace的缓存 → 头像查询缓存被误删 → 下次查询时,因缓存miss+并发,脏数据混入


为什么重启能暂时恢复?


// CachingExecutor.java
public <E> List<E> query(...) {
if (ms.getCache() != null) {
flushCacheIfRequired(ms); // 更新操作会清空整个namespace缓存
...
}
}

重启 → JVM内存清空 → 缓存重建 → 短暂“干净” → 随着操作累积,污染循环开始




🛠️ 三招根治(已上线30天零复发)


✅ 方案1:紧急止血(10分钟上线)


<!-- 所有Mapper.xml 删除 <cache> 标签 -->
<!-- 或全局关闭(mybatis-config.xml) -->
<settings>
<setting name="cacheEnabled" value="false"/>
</settings>

适用场景:分布式环境、数据强一致性要求高、缓存收益低


✅ 方案2:规范namespace(治本之策)


<!-- ProfileMapper.xml -->
<mapper namespace="com.xxx.mapper.ProfileMapper"> <!-- 唯一且语义清晰 -->
<!-- 移除<cache>,交由业务层控制 -->
</mapper>

团队公约



  • namespace = Mapper接口全限定名(IDEA自动生成)

  • 禁止手动修改namespace

  • Code Review必查项:grep "<mapper namespace" *.xml | sort | uniq -d


✅ 方案3:用Redis替代(高阶方案)


// 自定义Cache实现,接入Redis
public class RedisCache implements Cache {
private final ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
private String id;

@Override
public void putObject(Object key, Object value) {
// 序列化存入Redis,key=namespace:md5(sql+params)
redisTemplate.opsForValue().set(buildKey(key), value, 10, TimeUnit.MINUTES);
}
// ... 其他方法实现
}

优势



  • 多节点共享缓存

  • 精细化过期策略

  • 避免JVM内存压力




📌 血泪避坑清单(打印贴工位!)


表格


误区真相行动指南
“二级缓存开箱即用”分布式环境必翻车单机只读场景慎用,分布式直接关
“readOnly=true很安全”更新操作仍会清空整个namespace缓存避免在含写操作的Mapper开缓存
“namespace随便起”缓存隔离的唯一依据严格等于Mapper接口全路径
“缓存能提升性能”小数据量场景,序列化开销>收益压测验证:QPS提升<5%?不如关掉
“MyBatis缓存很智能”无分布式锁、无穿透保护高并发场景必接Redis+本地缓存

灵魂三问(上线前必答)

1️⃣ 项目是单机还是集群?→ 集群?二级缓存退退退!

2️⃣ 数据允许短暂不一致吗?→ 用户资料?必须强一致!

3️⃣ 缓存命中率实测多少?→ 用Arthas统计:monitor -c 5 com.xxx.mapper.XxxMapper selectXxx




🌱 写在晨光微露时


天快亮时,我给团队Wiki加了一页:



《MyBatis缓存使用红绿灯》

🔴 红灯区:用户资料、订单、支付等强一致场景

🟡 黄灯区:文章列表、商品目录(需压测验证)

🟢 绿灯区:国家字典、配置表(readOnly=true+小数据量)



测试小王发来新奶茶:“哥,这次排查笔记能发我学习吗?”

我笑着回:“下次上线前,咱俩一起过缓存设计。”


技术没有“小配置”,只有“大敬畏”。

那些深夜翻源码的狼狈,终会沉淀为代码里的从容。



本文为真实事故脱敏复盘,所有命令/代码经生产验证。

👉 互动时间:你被MyBatis缓存坑过吗?评论区晒出你的“名场面”!



作者:不想打工的码农
来源:juejin.cn/post/7601046076943876111
收起阅读 »

把白领吓破防的2028预言,究竟讲了什么?

最近,一篇关于“2028年全球智能危机”的预测在硅谷和打工人的朋友圈里疯传。 很多朋友和同事都在狂转这篇充满洞见的报告,看完这篇报告,很多白领的第一反应恐怕是: 你妹的,赶紧把电脑咋了回家种地吧!白领不存在了。 广告传媒不存在了 IT互联网不存在了 传统...
继续阅读 »

最近,一篇关于“2028年全球智能危机”的预测在硅谷和打工人的朋友圈里疯传。


很多朋友和同事都在狂转这篇充满洞见的报告,看完这篇报告,很多白领的第一反应恐怕是:



你妹的,赶紧把电脑咋了回家种地吧!白领不存在了。




  • 广告传媒不存在了

  • IT互联网不存在了

  • 传统软件行业不存在了

    ……


震撼程度堪比物理学家被智子扰乱道心。


让我们看看这篇文章的原文长啥样:



核心观点:短期经济会增长,但其实是假象。因为大部分人没有受益,因此消费会受到暴击,而且会陷入恶性循环。



补充观点:那些靠解决不方便小摩擦的生意会完全被AI取代。


以及他对就业形式的估计也是非常悲观的:


并且,他认为这些冲击之下,信贷危机会如约而至,大规模的个体违约将会发生。


如果再结合一下作者所在的国家——阿美,似乎可以预见到斩杀线的大量降临。


一些行业预测


这篇预测的核心恐吓点可以总结为“白领护城河的三大崩塌”:



  • 软件服务商(SaaS)的末日: AI智能体(Agent)普及后,人人都能直接用自然语言写出专属软件,不再需要花大价钱购买企业服务。

  • 互联网广告模式的黄昏: 当AI助手能瞬间帮你货比三家、找到全网最低价时,谁还会去点击那些充满套路的商业广告?靠流量收租的公司将面临绝境。

  • 智力劳动贬值: 这是最让人破防的一点。曾经高高在上的办公室脑力工作,其门槛被AI彻底踏破,脑力劳动变得廉价。


失业的白领怎么办呢?


铁人三项,吉祥三宝,选择总之不多。


这也难怪,白领们看到这篇文章会如此惊吓,难怪有人会陷入虚无,怀疑AI究竟是造福全人类,还是仅仅变成了资本家敛财、让普通人失业的机器 。


以史为镜


但如果我们跳出眼前的困境,把目光延申像更远的时光,看到远古时期。



  • 奴隶时代的农业大发展之后:牛马和铁器大规模替代纯人力时,种地的人并没有消失。生产关系的变革让他们从任人宰割的农奴变成了农民。



    你看,生产力的极大发展反而给了底层人民尊严。







  • 第一次工业革命后:蒸汽机确实砸碎了传统纺织工人的饭碗,但它催生了现代服装工业。今天,围绕着服装不仅有工厂,更衍生出了电商投流、短视频打爆款等庞大的产业链 。




  • 通信时代的“电报员下岗”: 电报员消失了,但随后诞生了庞大的电信运营商网络;纸媒衰落了,却迎来了自媒体和博主百花齐放的时代。



核心定律在于:工具的每一次史诗级升级,可能确实伴随着某些职业和岗位的消失,但都有更多的岗位应运而生。


AI 时代的新职业会是什么?


如果一位程序员跑去100年前的1926年,让当时民国年轻人畅想这位程序员的职位。


他可能猜错1000次,也想象不到神州大地上会有这么大一批人在面对着键盘写代码。


别急着给人类的岗位下死刑。


可能只是局限于人类当前的认知和时代局限,暂时无法想象出那些我们还未见识过的职业。



更宏观地看,我们现在定义的“打卡上班”,也许只是人类漫长历史中的一个过渡状态 。当生产力真的极大丰富时,未知虽然代表着恐惧,但也同样蕴含着无限重塑的可能。


作者:摸鱼的春哥
来源:juejin.cn/post/7610636435847888911
收起阅读 »

手把手写几种常用工具函数:深拷贝、去重、扁平化

web
同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~ (Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好) 你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来? 你是否也曾怀疑自己,是不是太笨...
继续阅读 »

同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~


(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)


你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?


你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?


就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。


一天只有24小时,时间永远不够用,常常感到力不从心。


技术行业,本就是逆水行舟,不进则退。


如果你也有同样的困扰,别慌。


从现在开始,跟着我一起心态归零,利用碎片时间,来一次彻彻底底的基础扫盲


这一次,我们一起慢慢来,扎扎实实变强。


不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,


咱们一起稳步积累,真正摆脱“面向搜索引擎写代码”的尴尬。


1. 开篇:有库可用,为什么还要自己写?


lodashramda 等库已经提供这些工具函数,但在面试、基础补强、和「读懂库源码」的场景里,手写一遍很有价值:



  • 搞清概念:什么算「深拷贝」、什么算「去重」

  • 踩一遍坑:循环引用、NaNDateRegExpSymbol

  • 形成习惯:知道什么时候用浅拷贝、什么时候必须深拷贝


下面按「深拷贝 → 去重 → 扁平化」的顺序,每种都给出可直接用的实现和说明。


2. 深拷贝


2.1 浅拷贝 vs 深拷贝,怎么选?


场景推荐方式原因
只改最外层、不改嵌套对象浅拷贝({...obj}Object.assign实现简单、性能好
需要改嵌套对象且不想影响原数据深拷贝避免引用共享
对象里有 DateRegExp、函数等深拷贝时需特殊处理否则会丢失类型或行为

一句话:只要会改到「嵌套对象/数组」,就考虑深拷贝。


2.2 常见坑



  1. 循环引用obj.a = obj,递归会栈溢出

  2. 特殊类型DateRegExpMapSetSymbol 不能只靠遍历属性复制

  3. Symbol 做 keyObject.keys 不会包含,需用 Reflect.ownKeysObject.getOwnPropertySymbols


2.3 实现示例(含循环引用与特殊类型处理)


function deepClone(obj, cache = new WeakMap()) {
// 1. 基本类型、null、函数 直接返回
if (obj === null || typeof obj !== 'object') {
return obj;
}

// 2. 循环引用:用 WeakMap 缓存已拷贝对象
if (cache.has(obj)) {
return cache.get(obj);
}

// 3. 特殊对象类型
if (obj instanceof Date) return new Date(obj.getTime());
if (obj instanceof RegExp) return new RegExp(obj.source, obj.flags);
if (obj instanceof Map) {
const mapCopy = new Map();
cache.set(obj, mapCopy);
obj.forEach((v, k) => mapCopy.set(deepClone(k, cache), deepClone(v, cache)));
return mapCopy;
}
if (obj instanceof Set) {
const setCopy = new Set();
cache.set(obj, setCopy);
obj.forEach(v => setCopy.add(deepClone(v, cache)));
return setCopy;
}

// 4. 普通对象 / 数组
const clone = Array.isArray(obj) ? [] : {};
cache.set(obj, clone);

// 包含 Symbol 作为 key
const keys = [...Object.keys(obj), ...Object.getOwnPropertySymbols(obj)];
keys.forEach(key => {
clone[key] = deepClone(obj[key], cache);
});

return clone;
}

// 使用示例
const original = { a: 1, b: { c: 2 }, d: [3, 4] };
original.self = original; // 循环引用
const cloned = deepClone(original);
cloned.b.c = 999;
console.log(original.b.c); // 2,原对象未被修改

要点:WeakMap 解决循环引用,Date/RegExp/Map/Set 单独分支,Object.getOwnPropertySymbols 保证 Symbol key 不丢失。


3. 去重


3.1 场景与选型


场景方法说明
基本类型数组(数字、字符串)Set写法简单、性能好
需要兼容 NaN自己写遍历逻辑NaN !== NaNSet 能去重 NaN,但逻辑要显式写清楚
对象数组、按某字段去重Mapfilter用唯一字段做 key

3.2 几种实现


1)简单数组去重(含 NaN)


// 方式一:Set(ES6 最常用)
function uniqueBySet(arr) {
return [...new Set(arr)];
}

// 方式二:filter + indexOf(兼容性更好,但 NaN 会出问题)
function uniqueByFilter(arr) {
return arr.filter((item, index) => arr.indexOf(item) === index);
}

// 方式三:兼容 NaN 的版本
function unique(arr) {
const result = [];
const seenNaN = false; // 用 flag 标记是否已经加入过 NaN
for (const item of arr) {
if (item !== item) { // NaN !== NaN
if (!seenNaN) {
result.push(item);
seenNaN = true; // 这里需要闭包,下面用修正版
}
} else if (!result.includes(item)) {
result.push(item);
}
}
return result;
}

// 修正:用变量
function uniqueWithNaN(arr) {
const result = [];
let hasNaN = false;
for (const item of arr) {
if (Number.isNaN(item)) {
if (!hasNaN) {
result.push(NaN);
hasNaN = true;
}
} else if (!result.includes(item)) {
result.push(item);
}
}
return result;
}

注意:Set 本身对 NaN 是去重的(ES2015 规范),所以 [...new Set([1, NaN, 2, NaN])] 结果正确。需要兼容 NaN 的,多是旧环境或面试题场景。


2)对象数组按某字段去重


function uniqueByKey(arr, key) {
const seen = new Map();
return arr.filter(item => {
const k = item[key];
if (seen.has(k)) return false;
seen.set(k, true);
return true;
});
}

// 使用
const users = [
{ id: 1, name: '张三' },
{ id: 2, name: '李四' },
{ id: 1, name: '张三2' }
];
console.log(uniqueByKey(users, 'id'));
// [{ id: 1, name: '张三' }, { id: 2, name: '李四' }]

4. 扁平化


4.1 场景



  • [1, [2, [3, 4]]] 变成 [1, 2, 3, 4]

  • 有时候需要「只扁平一层」或「扁平到指定层数」


4.2 实现


1)递归全扁平


function flatten(arr) {
const result = [];
for (const item of arr) {
if (Array.isArray(item)) {
result.push(...flatten(item));
} else {
result.push(item);
}
}
return result;
}

console.log(flatten([1, [2, [3, 4], 5]])); // [1, 2, 3, 4, 5]

2)指定深度扁平(如 Array.prototype.flat)


function flattenDepth(arr, depth = 1) {
if (depth <= 0) return arr;

const result = [];
for (const item of arr) {
if (Array.isArray(item) && depth > 0) {
result.push(...flattenDepth(item, depth - 1));
} else {
result.push(item);
}
}
return result;
}

console.log(flattenDepth([1, [2, [3, 4]]], 1)); // [1, 2, [3, 4]]
console.log(flattenDepth([1, [2, [3, 4]]], 2)); // [1, 2, 3, 4]

3)用 reduce 递归写法(另一种常见写法)


function flattenByReduce(arr) {
return arr.reduce((acc, cur) => {
return acc.concat(Array.isArray(cur) ? flattenByReduce(cur) : cur);
}, []);
}

5. 小结:日常怎么选


函数生产环境面试 / 巩固基础
深拷贝优先用 structuredClone(支持循环引用)或 lodash cloneDeep自己实现,要处理循环引用和特殊类型
去重基本类型用 [...new Set(arr)],对象用 Map 按 key 去重要能解释 NaNindexOf 等细节
扁平化用原生 arr.flat(Infinity)手写递归或 reduce 版本

自己写一遍的价值在于:搞清楚边界情况、循环引用、特殊类型,以后选库或读源码时心里有数。




学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。


后续我还会继续用这种大白话、讲实战方式,带大家扫盲更多前端基础。


关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。


如果你觉得这篇内容对你有帮助,不妨点赞收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。


我是 Eugene,你的电子学友,我们下一篇干货见~


作者:SuperEugene
来源:juejin.cn/post/7609288132602478592
收起阅读 »

JSBridge 原理详解

web
什么是 JSBridge JSBridge 是 WebView 中 JavaScript 与 Native 代码之间的通信桥梁。核心问题是:两个不同运行环境的代码如何互相调用? 通信原理 1. Native 调用 JS(简单) WebView 本身就提供了执...
继续阅读 »

什么是 JSBridge


JSBridge 是 WebView 中 JavaScript 与 Native 代码之间的通信桥梁。核心问题是:两个不同运行环境的代码如何互相调用?




通信原理


1. Native 调用 JS(简单)


WebView 本身就提供了执行 JS 的能力,原理很直接:WebView 控制着 JS 引擎,可以直接向其注入并执行代码。


// Android
webView.evaluateJavascript("window.appCallJS('data')", null);

// iOS
webView.evaluateJavaScript("window.appCallJS('data')")

// Flutter
webViewController.runJavaScript("window.appCallJS('data')");

2. JS 调用 Native(核心难点)


JS 运行在沙箱中,无法直接访问系统 API。有两种主流方案:


方案一:注入 API


Native 在 WebView 初始化时,向 JS 全局对象注入方法:


// Android - 注入对象到 window
webView.addJavascriptInterface(new Object() {
@JavascriptInterface
public void showToast(String msg) {
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show();
}
}, "NativeBridge");

JS 端直接调用:


window.NativeBridge.showToast("Hello")

本质:Native 把自己的方法"挂"到了 JS 的全局作用域里。


方案二:URL Scheme 拦截


JS 发起一个特殊协议的请求,Native 拦截并解析:


// JS 端
location.href = 'jsbridge://showToast?msg=Hello'

// 或使用 iframe(避免页面跳转)
const iframe = document.createElement('iframe')
iframe.src = 'jsbridge://showToast?msg=Hello'
document.body.appendChild(iframe)

// Android 端拦截
webView.setWebViewClient(new WebViewClient() {
@Override
public boolean shouldOverrideUrlLoading(WebView view, String url) {
if (url.startsWith("jsbridge://")) {
// 解析 url,执行对应 Native 方法
return true;
}
return false;
}
});

本质:利用 WebView 的 URL 加载机制作为通信通道。




异步回调的实现


JS 调用 Native 后如何拿到返回值?通过回调 ID 机制:


// JS 端
let callbackId = 0
const callbacks = {}

function callNative(method, params) {
return new Promise((resolve) => {
const id = callbackId++
callbacks[id] = resolve
// 告诉 Native:调用完成后,用这个 id 回调我
window.NativeBridge.invoke(JSON.stringify({
method,
params,
callbackId: id
}))
})
}

// Native 执行完后调用这个函数
window.handleCallback = (id, result) => {
callbacks[id]?.(result)
delete callbacks[id]
}

流程:JS 调用 → Native 处理 → Native 调用 evaluateJavascript 执行回调函数 → JS 收到结果




各平台注入对象命名


平台/插件全局对象名是否可自定义
Android 原生任意✅ 完全自定义
iOS WKWebViewwebkit.messageHandlers.xxx✅ xxx 部分可自定义
flutter_inappwebviewflutter_inappwebview❌ 插件固定
webview_flutter需要自己实现✅ 完全自定义

Android 示例


// 第二个参数就是 JS 中的对象名,可以随便取
webView.addJavascriptInterface(bridgeObject, "MyBridge");

// JS 端
window.MyBridge.method()

iOS 示例


// name 就是 JS 中的 handler 名
configuration.userContentController.add(self, name: "iOSBridge")

// JS 端
window.webkit.messageHandlers.iOSBridge.postMessage(data)

Flutter (flutter_inappwebview) 示例


// Flutter 端注册 handler,handlerName 可自定义
webViewController.addJavaScriptHandler(
handlerName: 'myCustomHandler',
callback: (args) { ... }
);

// JS 端,flutter_inappwebview 是固定的
window.flutter_inappwebview.callHandler('myCustomHandler', data)



通信方式总结


方向原理实现方式
Native → JSWebView 控制 JS 引擎evaluateJavascript
JS → Native注入或拦截addJavascriptInterface / URL Scheme



常见通信方式对比


方式优点缺点适用场景
JavaScript Bridge双向通信、支持回调需要约定协议复杂交互
URL Scheme简单、兼容性好单向、数据量有限简单跳转
postMessage标准 API需要 WebView 支持iframe 通信
注入 JS 对象调用方便Android 4.2 以下有安全漏洞频繁调用



最佳实践建议



  1. 统一封装:抽离成独立的 bridge 工具类,统一管理通信逻辑

  2. 消息队列:处理 Native 未就绪时的调用,避免丢失消息

  3. 超时处理:添加超时机制,防止回调永远不返回

  4. 类型安全:使用 TypeScript 定义消息类型

  5. 错误处理:统一的错误捕获和上报机制


作者:RemHusband
来源:juejin.cn/post/7609660097766309898
收起阅读 »

手把手写几种常用工具函数:深拷贝、去重、扁平化

web
同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~ (Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好) 你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来? 你是否也曾怀疑自己,是不是太笨...
继续阅读 »

同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~


(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)


你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?


你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?


就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。


一天只有24小时,时间永远不够用,常常感到力不从心。


技术行业,本就是逆水行舟,不进则退。


如果你也有同样的困扰,别慌。


从现在开始,跟着我一起心态归零,利用碎片时间,来一次彻彻底底的基础扫盲


这一次,我们一起慢慢来,扎扎实实变强。


不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,


咱们一起稳步积累,真正摆脱“面向搜索引擎写代码”的尴尬。


1. 开篇:有库可用,为什么还要自己写?


lodashramda 等库已经提供这些工具函数,但在面试、基础补强、和「读懂库源码」的场景里,手写一遍很有价值:



  • 搞清概念:什么算「深拷贝」、什么算「去重」

  • 踩一遍坑:循环引用、NaNDateRegExpSymbol

  • 形成习惯:知道什么时候用浅拷贝、什么时候必须深拷贝


下面按「深拷贝 → 去重 → 扁平化」的顺序,每种都给出可直接用的实现和说明。


2. 深拷贝


2.1 浅拷贝 vs 深拷贝,怎么选?


场景推荐方式原因
只改最外层、不改嵌套对象浅拷贝({...obj}Object.assign实现简单、性能好
需要改嵌套对象且不想影响原数据深拷贝避免引用共享
对象里有 DateRegExp、函数等深拷贝时需特殊处理否则会丢失类型或行为

一句话:只要会改到「嵌套对象/数组」,就考虑深拷贝。


2.2 常见坑



  1. 循环引用obj.a = obj,递归会栈溢出

  2. 特殊类型DateRegExpMapSetSymbol 不能只靠遍历属性复制

  3. Symbol 做 keyObject.keys 不会包含,需用 Reflect.ownKeysObject.getOwnPropertySymbols


2.3 实现示例(含循环引用与特殊类型处理)


function deepClone(obj, cache = new WeakMap()) {
// 1. 基本类型、null、函数 直接返回
if (obj === null || typeof obj !== 'object') {
return obj;
}

// 2. 循环引用:用 WeakMap 缓存已拷贝对象
if (cache.has(obj)) {
return cache.get(obj);
}

// 3. 特殊对象类型
if (obj instanceof Date) return new Date(obj.getTime());
if (obj instanceof RegExp) return new RegExp(obj.source, obj.flags);
if (obj instanceof Map) {
const mapCopy = new Map();
cache.set(obj, mapCopy);
obj.forEach((v, k) => mapCopy.set(deepClone(k, cache), deepClone(v, cache)));
return mapCopy;
}
if (obj instanceof Set) {
const setCopy = new Set();
cache.set(obj, setCopy);
obj.forEach(v => setCopy.add(deepClone(v, cache)));
return setCopy;
}

// 4. 普通对象 / 数组
const clone = Array.isArray(obj) ? [] : {};
cache.set(obj, clone);

// 包含 Symbol 作为 key
const keys = [...Object.keys(obj), ...Object.getOwnPropertySymbols(obj)];
keys.forEach(key => {
clone[key] = deepClone(obj[key], cache);
});

return clone;
}

// 使用示例
const original = { a: 1, b: { c: 2 }, d: [3, 4] };
original.self = original; // 循环引用
const cloned = deepClone(original);
cloned.b.c = 999;
console.log(original.b.c); // 2,原对象未被修改

要点:WeakMap 解决循环引用,Date/RegExp/Map/Set 单独分支,Object.getOwnPropertySymbols 保证 Symbol key 不丢失。


3. 去重


3.1 场景与选型


场景方法说明
基本类型数组(数字、字符串)Set写法简单、性能好
需要兼容 NaN自己写遍历逻辑NaN !== NaNSet 能去重 NaN,但逻辑要显式写清楚
对象数组、按某字段去重Mapfilter用唯一字段做 key

3.2 几种实现


1)简单数组去重(含 NaN)


// 方式一:Set(ES6 最常用)
function uniqueBySet(arr) {
return [...new Set(arr)];
}

// 方式二:filter + indexOf(兼容性更好,但 NaN 会出问题)
function uniqueByFilter(arr) {
return arr.filter((item, index) => arr.indexOf(item) === index);
}

// 方式三:兼容 NaN 的版本
function unique(arr) {
const result = [];
const seenNaN = false; // 用 flag 标记是否已经加入过 NaN
for (const item of arr) {
if (item !== item) { // NaN !== NaN
if (!seenNaN) {
result.push(item);
seenNaN = true; // 这里需要闭包,下面用修正版
}
} else if (!result.includes(item)) {
result.push(item);
}
}
return result;
}

// 修正:用变量
function uniqueWithNaN(arr) {
const result = [];
let hasNaN = false;
for (const item of arr) {
if (Number.isNaN(item)) {
if (!hasNaN) {
result.push(NaN);
hasNaN = true;
}
} else if (!result.includes(item)) {
result.push(item);
}
}
return result;
}

注意:Set 本身对 NaN 是去重的(ES2015 规范),所以 [...new Set([1, NaN, 2, NaN])] 结果正确。需要兼容 NaN 的,多是旧环境或面试题场景。


2)对象数组按某字段去重


function uniqueByKey(arr, key) {
const seen = new Map();
return arr.filter(item => {
const k = item[key];
if (seen.has(k)) return false;
seen.set(k, true);
return true;
});
}

// 使用
const users = [
{ id: 1, name: '张三' },
{ id: 2, name: '李四' },
{ id: 1, name: '张三2' }
];
console.log(uniqueByKey(users, 'id'));
// [{ id: 1, name: '张三' }, { id: 2, name: '李四' }]

4. 扁平化


4.1 场景



  • [1, [2, [3, 4]]] 变成 [1, 2, 3, 4]

  • 有时候需要「只扁平一层」或「扁平到指定层数」


4.2 实现


1)递归全扁平


function flatten(arr) {
const result = [];
for (const item of arr) {
if (Array.isArray(item)) {
result.push(...flatten(item));
} else {
result.push(item);
}
}
return result;
}

console.log(flatten([1, [2, [3, 4], 5]])); // [1, 2, 3, 4, 5]

2)指定深度扁平(如 Array.prototype.flat)


function flattenDepth(arr, depth = 1) {
if (depth <= 0) return arr;

const result = [];
for (const item of arr) {
if (Array.isArray(item) && depth > 0) {
result.push(...flattenDepth(item, depth - 1));
} else {
result.push(item);
}
}
return result;
}

console.log(flattenDepth([1, [2, [3, 4]]], 1)); // [1, 2, [3, 4]]
console.log(flattenDepth([1, [2, [3, 4]]], 2)); // [1, 2, 3, 4]

3)用 reduce 递归写法(另一种常见写法)


function flattenByReduce(arr) {
return arr.reduce((acc, cur) => {
return acc.concat(Array.isArray(cur) ? flattenByReduce(cur) : cur);
}, []);
}

5. 小结:日常怎么选


函数生产环境面试 / 巩固基础
深拷贝优先用 structuredClone(支持循环引用)或 lodash cloneDeep自己实现,要处理循环引用和特殊类型
去重基本类型用 [...new Set(arr)],对象用 Map 按 key 去重要能解释 NaNindexOf 等细节
扁平化用原生 arr.flat(Infinity)手写递归或 reduce 版本

自己写一遍的价值在于:搞清楚边界情况、循环引用、特殊类型,以后选库或读源码时心里有数。




学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。


后续我还会继续用这种大白话、讲实战方式,带大家扫盲更多前端基础。


关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。


如果你觉得这篇内容对你有帮助,不妨点赞收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。


我是 Eugene,你的电子学友,我们下一篇干货见~


作者:SuperEugene
来源:juejin.cn/post/7609288132602478592
收起阅读 »

单点登录:一次登录,全网通行

大家好,我是小悟。 想象一下你去游乐园,买了一张通票(登录),然后就可以玩所有项目(访问各个系统),不用每个项目都重新买票(重新登录)。这就是单点登录(SSO)的精髓! SSO的日常比喻 普通登录:像去不同商场,每个都要查会员卡 单点登录:像微信扫码登录,...
继续阅读 »

大家好,我是小悟。



  • 想象一下你去游乐园,买了一张通票(登录),然后就可以玩所有项目(访问各个系统),不用每个项目都重新买票(重新登录)。这就是单点登录(SSO)的精髓!


    SSO的日常比喻



    • 普通登录:像去不同商场,每个都要查会员卡

    • 单点登录:像微信扫码登录,一扫全搞定

    • 令牌:像游乐园手环,戴着就能证明你买过票


    下面用代码来实现这个"游乐园通票系统":


    代码实现:简易SSO系统


    import java.util.*;

    // 用户类 - 就是我们这些想玩项目的游客
    class User {
    private String username;
    private String password;

    public User(String username, String password) {
    this.username = username;
    this.password = password;
    }

    // getters 省略...
    }

    // 令牌类 - 游乐园手环
    class Token {
    private String tokenId;
    private String username;
    private Date expireTime;

    public Token(String username) {
    this.tokenId = UUID.randomUUID().toString();
    this.username = username;
    // 令牌1小时后过期 - 游乐园晚上要关门的!
    this.expireTime = new Date(System.currentTimeMillis() + 3600 * 1000);
    }

    public boolean isValid() {
    return new Date().before(expireTime);
    }

    // getters 省略...
    }

    // SSO认证中心 - 游乐园售票处
    class SSOAuthCenter {
    private Map<String, Token> validTokens = new HashMap<>();
    private Map<String, User> users = new HashMap<>();

    public SSOAuthCenter() {
    // 预先注册几个用户 - 办了年卡的游客
    users.put("zhangsan", new User("zhangsan", "123456"));
    users.put("lisi", new User("lisi", "abcdef"));
    }

    // 登录 - 买票入场
    public String login(String username, String password) {
    User user = users.get(username);
    if (user != null && user.getPassword().equals(password)) {
    Token token = new Token(username);
    validTokens.put(token.getTokenId(), token);
    System.out.println(username + " 登录成功!拿到游乐园手环:" + token.getTokenId());
    return token.getTokenId();
    }
    System.out.println("用户名或密码错误!请重新买票!");
    return null;
    }

    // 验证令牌 - 检查手环是否有效
    public boolean validateToken(String tokenId) {
    Token token = validTokens.get(tokenId);
    if (token != null && token.isValid()) {
    System.out.println("手环有效,欢迎继续玩耍!");
    return true;
    }
    System.out.println("手环无效或已过期,请重新登录!");
    validTokens.remove(tokenId); // 清理过期令牌
    return false;
    }

    // 登出 - 离开游乐园
    public void logout(String tokenId) {
    validTokens.remove(tokenId);
    System.out.println("已登出,欢迎下次再来玩!");
    }
    }

    // 业务系统A - 过山车
    class SystemA {
    private SSOAuthCenter authCenter;

    public SystemA(SSOAuthCenter authCenter) {
    this.authCenter = authCenter;
    }

    public void accessSystem(String tokenId) {
    System.out.println("=== 欢迎来到过山车 ===");
    if (authCenter.validateToken(tokenId)) {
    System.out.println("过山车启动!尖叫声在哪里!");
    } else {
    System.out.println("请先登录再玩过山车!");
    }
    }
    }

    // 业务系统B - 旋转木马
    class SystemB {
    private SSOAuthCenter authCenter;

    public SystemB(SSOAuthCenter authCenter) {
    this.authCenter = authCenter;
    }

    public void accessSystem(String tokenId) {
    System.out.println("=== 欢迎来到旋转木马 ===");
    if (authCenter.validateToken(tokenId)) {
    System.out.println("木马转起来啦!找回童年记忆!");
    } else {
    System.out.println("请先登录再玩旋转木马!");
    }
    }
    }

    // 测试我们的SSO系统
    public class SSODemo {
    public static void main(String[] args) {
    // 创建认证中心 - 游乐园大门
    SSOAuthCenter authCenter = new SSOAuthCenter();

    // 张三登录
    String token = authCenter.login("zhangsan", "123456");

    if (token != null) {
    // 拿着同一个令牌玩不同项目
    SystemA systemA = new SystemA(authCenter);
    SystemB systemB = new SystemB(authCenter);

    systemA.accessSystem(token); // 玩过山车
    systemB.accessSystem(token); // 玩旋转木马

    // 登出
    authCenter.logout(token);

    // 再尝试访问 - 应该被拒绝
    systemA.accessSystem(token);
    }

    // 测试错误密码
    authCenter.login("lisi", "wrongpassword");
    }
    }

    运行结果示例:


    zhangsan 登录成功!拿到游乐园手环:a1b2c3d4-e5f6-7890-abcd-ef1234567890
    === 欢迎来到过山车 ===
    手环有效,欢迎继续玩耍!
    过山车启动!尖叫声在哪里!
    === 欢迎来到旋转木马 ===
    手环有效,欢迎继续玩耍!
    木马转起来啦!找回童年记忆!
    已登出,欢迎下次再来玩!
    === 欢迎来到过山车 ===
    手环无效或已过期,请重新登录!
    请先登录再玩过山车!
    用户名或密码错误!请重新买票!

    总结一下:


    单点登录就像:



    • 一次认证,处处通行 🎫

    • 不用重复输入密码 🔑

    • 安全又方便 👍


    好的SSO系统就像好的游乐园管理,既要让游客玩得开心,又要确保安全!



单点登录:一次登录,全网通行.png


谢谢你看我的文章,既然看到这里了,如果觉得不错,随手点个赞、转发、在看三连吧,感谢感谢。那我们,下次再见。


您的一键三连,是我更新的最大动力,谢谢


山水有相逢,来日皆可期,谢谢阅读,我们再会


我手中的金箍棒,上能通天,下能探海


作者:悟空码字
来源:juejin.cn/post/7577599015426228259
收起阅读 »

项目经理被裁那天,没人替他说话

简单写个自我介绍。 我在团队里算不上的管理层,没有人事权,也不决定谁走谁留,但我参与的项目一旦出问题,最后都会落到我这里。进度卡住、需求反复、线上事故,会议上可以绕一圈再绕一圈,最终还是要有人把代码改完、把锅兜住。我清楚自己的位置,用武之地比较多的一个“多功能...
继续阅读 »

简单写个自我介绍。


我在团队里算不上的管理层,没有人事权,也不决定谁走谁留,但我参与的项目一旦出问题,最后都会落到我这里。进度卡住、需求反复、线上事故,会议上可以绕一圈再绕一圈,最终还是要有人把代码改完、把锅兜住。我清楚自己的位置,用武之地比较多的一个“多功能开发者”而已。还依稀记得,当初总部另外一个项目的入侵测,找的还是我,而不是再招一个人。


所以我很少参与评价人,只谈事,谈事实,谈项目是怎么一步步偏离轨道的。


先直接告诉大家结果吧,他被辞退了。


当初公司决定要不要这个人的时候,无意中跟我提到过。我只讲述了几个项目事故,以及其他同事和他合作时的态度。至于留不留他,我不想决定,也决定不了。


那个项目经理,其实并不“坏”。他不吼人,也不甩脸色,会议纪要写得很勤,群里回复永远是“收到”“我跟一下”。问题出在另一层面:需求变更没有留痕,风险评估永远是“可控”,节点延期总能找到外部理由。


上面看到的是一条被不断抚平的曲线,下面看到的是每天被推翻重来的开发计划。我们不是没提醒过,只是提醒被整理成了更好看的版本,再往上递的时候,已经失去了原本的锋利。当然,这些最后都会被归结为一句话——开发同学多努努力,多扩展下思维,补补这个缺点就好了。


但我认为,有些问题其实在内部一直被反复提起,只是从来没有被真正放到台面上说过。


团队里的其他项目经理,大多都有过开发背景。哪怕代码早就不写了,对功能复杂度、实现成本、技术边界心里都是有尺度的。评估的时候会留余量,也知道什么时候该踩刹车。


只有他完全没有开发经验,对一个需求的理解停留在“看起来不难”的层面。既怕自己显得不专业,又怕在会上被认为拖进度,于是每次评估都偏向最激进的版本,功能报得满,时间压到极限。


开发这边明知道不现实,那又怎么办呢?你能说得过他吗?况且领导也是只看结果,活干得快,公司赚得多,干得慢赚得少。所以开发也只能硬着头皮往前推。


一次延期还能解释成意外,两次三次之后,延期就成了默认选项。项目表面上在跑,实际上每一步都在透支客户的耐心。


他甚至能把一个月的功能,压成 7 个工作日。


结果显而易见。项目连夜上线,第二天直接崩溃:APP、小程序白屏,数据无法保存,ToC 的用户一个都打不开。我们凌晨 4 点发完版本,早上 6 点半问题出现,7 点钟起床开始处理。


我起床的时候就已经料到了。项目有他管控着,您就放一万个心吧,麻烦肯定少不了。


当时写功能的时候,有个同事请了丧假。他来了句逆天发言:“到时候你能把电脑带上吗?有事可以找你。”


我当时真想告诉他,兄弟,全公司不是只有他一个前端,这个项目也不是只有他一个前端。人家就请假 3 天,已经很紧张了,还让人把电脑带着,真特么丧良心。


真正的转折点,是那次 A 项目上线。


我没有提任何人的名字,也没有用情绪化的词,只是把时间线拉直:哪一天确认需求,哪一天推翻,哪一天出 PRD,哪一天出 UI,最终导致了什么结果。那份文档写得很长,不好读,也不“体面”,但它有一个特点——每一个问题,都自然地指向了同一个岗位职责。


我提交的时候,甚至没多想,只觉得这次总算把事情说清楚了。


事后我想过,如果我当初不写那份复盘,不跟领导说这些事,会不会结果不同。答案大概是否定的。项目不会因为沉默变好,问题也不会因为不点名而消失。


那天没人替他说话,并不是因为他人缘差,而是因为在那个位置上,他已经很久没有为任何人、任何结果,真正说过一句“这是我的责任”。


系统从来不需要情绪,它只是在某个时刻,停止了包容。


我后来也明白了一件事:在很多公司里,项目经理这个角色,本质上是一个缓冲层。缓冲需求、缓冲压力、缓冲管理层的焦虑。


但一旦缓冲只剩下过滤,没有承担,系统就会重新校准。


那天被裁的不是一个人,而是一种失效的角色设计。而这件事,迟早会发生在任何一个不再为结果站出来的位置上。


作者:狗头大军之江苏分军
来源:juejin.cn/post/7598174154665623587
收起阅读 »

豆包也开始抢程序员饭碗了,一个月只要9块9。。

你好,我是袋鼠帝。字节在编程工具(Trae)上面是国内最早发力的,但是编程模型迟迟没有推出。不过就在今天,字节终于!给豆包升级了编程能力,推出了他们的首款编程模型:Doubao-Seed-Code说实话,字节一直在AI领域的出品都相当不错(智能体平台Coze、...
继续阅读 »

你好,我是袋鼠帝。

字节在编程工具(Trae)上面是国内最早发力的,但是编程模型迟迟没有推出。

不过就在今天,字节终于!给豆包升级了编程能力,推出了他们的首款编程模型:Doubao-Seed-Code

说实话,字节一直在AI领域的出品都相当不错(智能体平台Coze、编程工具Trae、豆包大模型、AI作图即梦、AI云火山引擎等),毕竟他们All In AI呀,查了一下,字节24、25年在AI领域先后投入了千亿RMB。

我立马把Doubao-Seed-Code接入Claude Code体验了一下

我现在拿到一个模型,最快速了解编程能力的方法就是让它生成:一个网页版我的世界。

这个案例有一定难度(3D环境、重力、不同方块切换,增加消除方块等等),就算第一梯队的编程模型,想要一次性完成都不容易。Prompt很简单,就一句话。

没想到我连续生成几次,Doubao-Seed-Code都做得挺不错

不过第一次doubao-seed-code给我生成了2D的,我要求他改3D,最终效果如下图

然后用Claude code的代码回退指令/rewind,回退到2D状态,又让它生成了一次3D的我的世界,同样完成得很好。

这个开局,让我对它兴趣倍增。

我仔细看了一下,发现这次豆包的新模型,除了编程能力,还有不少值得说道的亮点。

在实操之前,先给大家介绍一下Doubao-Seed-Code

这次Doubao-Seed-Code刷新了国内编程模型的上下文长度,增加到了256K(之前编程类模型最长是200K)。

这意味着,你可以把一个中大型项目的好几个模块,全丢给它,让它在完整的项目上下文中进行思考和重构,这对于全栈开发非常友好。

让我最兴奋的是,它支持视觉理解,国内终于有个支持视觉的编程模型了!

很多时候,我们给AI表达需求,如果仅仅只能通过文字的话,显得过于苍白,我相信大部分人用AI,经常会上传图片吧?使用AI来编程也是一样的。

所以视觉能力对编程模型来说至关重要。

AI,终于能"看见"你的需求了

还有,这API价格也很良心:

在0-32k输入区间

输入1.20元/百万Tokens,输出8.00元/百万Tokens

在32-128k输入区间

输入1.40元/百万Tokens,输出12.00元/百万Tokens

在128-256k输入区间

输入2.80元/百万Tokens,输出16.00元/百万Tokens

推出了Coding Plan套餐,首月只要9块9。。真卷啊。

编程模型越来越便宜,初级程序员们该怎么办啊

这性价比,真 Coding版瑞幸咖啡。

我只能感慨,国内的AI编程模型,没有最便宜,只有更便宜。卷吧卷吧~

还是展示一下考试成绩吧

这个SWE-Bench Verified榜单的含金量还是比较高的,它专门测模型在真实软件工程项目里改bug的能力。

不过我实际编程体验下来,Doubao-Seed-Code经过多轮对话后,得到的效果会更好。

接入Claude Code

Doubao-Seed-Code提供了兼容Anthropic API的地址。

可以非常丝滑的快速接入Claude Code。

第一步, 你需要去火山方舟平台,注册账号,开通模型服务,然后拿到你的API Key。

第二步,找到Claude Code的配置文件settings.json。

Windows系统路径是 C:\Users\你的用户名.claude\settings.json

Mac系统路径一般是 ~/.claude/settings.json

用文本编辑器打开它,在里面添加或更新env字段,内容如下,记得把<你的火山API Key>替换成你自己的Key。

第三步,保存文件,重启Claude Code。

claude --dangerously-skip-permissions(可以用这个指令启动,直接进入Claude Code yolo模式)

搞定。就这么简单

有位朋友之前用Gemini 2.5 Pro给做了一个教学动画课件,是按按钮,展示3D数轴的,用Gemni-2.5-Pro优化了30个版本才搞定

然后他用Doubao-Seed-Code做了一轮优化后,贴心的加上了xyz变量可调节,画面也更精美了。

Prompt: 帮我在原有的功能上,优化这个课件,使其更精美,更符合教学

加上xyz轴变量之后,确实更直观了

要是我读书的时候,有数学老师会搞这,那做空间图形题不得起飞?

然后我跑了一个之前用GLM-4.6和Kimi-K2都跑得不理想的好玩的3D页面

3D全球航班模拟器

Prompt:

请创建一个基于Web的、高度可交互的3D地球前端页面,用于实时可视化全球航班的动态数据。应用的核心是一个逼真的3D地球,用户可以通过一个功能强大的控制面板来探索和定制所显示的数据。

  1. 3D地球核心功能:

模型与材质: 渲染一个高质量的3D地球模型,包含高清的日间地表纹理、夜间城市灯光纹理,以及一层独立的、半透明的云层纹理。地球周围应有模拟大气层的辉光效果。

用户交互: 用户可以通过鼠标左键拖拽来自由旋转地球,通过鼠标滚轮来进行缩放,以观察地球的任何细节。

  1. 航班数据可视化:

航线: 航班路径应以发光的曲线(弧线)形式在地球表面上呈现,连接起点和终点。

飞机: 每条航线上应有一个代表飞机的3D模型或图标,沿着路径动态飞行。

  1. 交互式控制面板(UI):

在界面右侧创建一个清晰的控制面板,允许用户实时调整各项参数。面板应包含以下功能模块:

飞机控制 (Plane Controls):

滑块 "大小 (Size)": 允许用户实时调整所有飞机图标的显示大小。

动画控制 (Animation Controls):

滑块 "速度 (Speed)": 控制所有飞机沿航线飞行的动画速度,可以从慢速到极速进行调节。

航班控制 (Flight Controls):

滑块 "数量 (Count)": 核心功能。动态调整并渲染在地球上显示的航线和飞机的总数量,范围可以从几百到上万,以展示不同密度下的全球航班网络。

复选框 "显示路径 (Show Paths)": 切换是否显示发光的航线。

复选框 "显示飞机 (Show Planes)": 切换是否显示移动的飞机图标。

复选框 "着色 (Colorize)": 切换航线的颜色方案,例如,可以根据飞行方向或航空公司改变颜色。

光照控制 (Lighting Controls):

复选框 "昼夜效果 (Day/Night Effect)": 启用后,地球会根据太阳位置产生真实的昼夜区域。夜间区域的地表会显示城市灯光纹理。

复选框 "实时太阳 (Real-time Sun)": 勾选后,昼夜的分割线会根据当前真实世界时间自动定位。

时间滑块 "Time Slider": 当“实时太阳”未勾选时,用户可以通过拖动此滑块来手动改变一天中的时间,从而动态地移动地球上的光照和阴影区域。

亮度控制 (Brightness Controls):

滑块 "白天 (Day)": 调节地球日间区域的亮度。

滑块 "夜晚 (Night)": 调节地球夜间区域城市灯光的亮度。

  1. 其他界面元素:

在屏幕一角显示性能信息,如 帧率 (FPS)。

在屏幕另一角实时显示鼠标指针当前悬停位置的 经纬度坐标。

技术与风格建议:

推荐使用WebGL技术栈(如Three.js, Babylon.js)来实现3D渲染。

整体视觉风格应现代、简洁、具有科技感,动画效果要流畅平滑。

虽然它没有把所有功能都完美实现,比如航班线路不会随地球转动而改变位置。

但是它从零开始,独立构建出了一个基于Three.js的,可交互的3D地球。

并且实现了动态航线,飞机动画,以及通过控制面板实时调整飞机数量和大小、时间和光照的核心功能,还是比较不错的。

接下来,是它最让我期待的视觉能力

我直接找了经典的B站首页,让它复刻一下

提示词如下图

Mac的Claude Code粘贴图片是ctrl+c,我准备了一张背景图给它用,放到了项目目录中,直接@就行了

这个复刻也不错。就是有点瑕疵:轮播图位置不对,原本应该是在左下角,但该有的都复刻到位了,整个页面交互、样式都是ok的。

手绘草图复刻LOL网页

2025年英雄联盟全球总决赛 (S15)刚刚在2025年11月9日结束,

看了这么久OpenAI的餐巾纸画网站,但还真没自己试过,借这个机会用Doubao-Seed-Code+Claude Code试了一下

清晰识别手写文字和数字,也基本上按照我的想法复刻出来了

另外,豆包还在Claude Code中提供选项,可以进一步选择需要的模块,还可以输入,自定义需要的模块。

最后,我居然在火山引擎免费搞到了9.9元的豆包Coding Plan套餐

不知道哪里来的代金券 抵扣了..

有需要的朋友,可以点击文末**「阅读原文」**直达,有代金券的话还可以先免费薅一个月~

9.9这个套餐用量是Claude Pro的三倍,个人开发者是够用了。

「最后」

体验下来,Doubao-Seed-Code编程能力确实没有达到全球顶尖水平。

但它是国内首个支持视觉理解的编程模型,也是国内目前支持上下文长度最长(256K)的编程模型。

弥补了,其他国产编程模型的短板。

相信大家能在各自的场景里面找到Doubao-Seed-Code的价值~


作者:AI袋鼠帝
来源:juejin.cn/post/7572020278387261474
收起阅读 »

哨兵模式-无限滚动

web
前端哨兵模式(Sentinel Pattern)—— 优雅实现滚动加载一、什么是哨兵模式?想象你在排队买奶茶,你不知道什么时候轮到你。但如果在你前面第 3 个人身上贴了一张纸条,写着"看到我就准备点单"——这个人就是"哨兵"。在前端开发中,哨兵模式就是在页面的...
继续阅读 »

前端哨兵模式(Sentinel Pattern)—— 优雅实现滚动加载

一、什么是哨兵模式?

想象你在排队买奶茶,你不知道什么时候轮到你。但如果在你前面第 3 个人身上贴了一张纸条,写着"看到我就准备点单"——这个人就是"哨兵"

在前端开发中,哨兵模式就是在页面的某个位置放一个不可见的元素(哨兵),当用户滚动页面让这个元素进入视口时,自动触发特定操作(比如加载下一页数据)。

它的核心技术是浏览器原生 API —— IntersectionObserver


二、原理

IntersectionObserver 是什么?

IntersectionObserver(交叉观察器)是浏览器提供的一个 API,用来异步地观察一个元素与视口(或某个祖先元素)的交叉状态

简单说:它能告诉你——"某个元素是否出现在了屏幕上"。

工作流程

┌─────────────────────────────────────┐
│ 可视区域(视口) │
│ │
│ ┌─────────────────────────────┐ │
│ │ 已加载的列表项 │ │
│ │ ... │ │
│ │ 列表项 N │ │
│ └─────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ 🚨 哨兵元素(高度 1px) │ ← 当它进入视口,触发回调
│ └─────────────────────────────┘ │
│ │
└─────────────────────────────────────┘
↓ 触发回调
fetchNextPage() → 加载更多数据
↓ 新数据渲染
哨兵被推到新列表底部 → 等待下次进入视口

关键:每次新数据渲染后,哨兵自然地被推到列表最底部,形成一个自动循环:滚到底 → 加载 → 哨兵下移 → 再滚到底 → 再加载…


三、规则

使用哨兵模式时,需要遵守以下规则:

规则说明
1. 哨兵元素必须始终在列表末尾只有在最后面,用户滚到底才能触发
2. 防止重复触发加载中时不要重复请求,用 loading 状态锁住
3. 有数据才放哨兵没有数据或已加载完毕时,不渲染哨兵元素
4. 及时断开观察组件卸载或条件变化时调用 observer.disconnect() 防止内存泄漏
5. 依赖项要完整useEffect 的依赖数组要包含所有会影响是否加载的状态
6. 哨兵尽量小高度 1px 即可,不要影响布局和用户体验

四、用法

基础用法(React + TypeScript)

import { useRef, useEffect, useState } from 'react';

function InfiniteList() {
const [list, setList] = useState<string[]>([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);

// 1️⃣ 创建哨兵元素的 ref
const sentinelRef = useRef<HTMLDivElement | null>(null);

// 2️⃣ 加载数据的函数
const fetchData = async (p: number) => {
if (loading) return;
setLoading(true);
try {
const res = await fetch(`/api/list?page=${p}`);
const data = await res.json();
setList((prev) => [...prev, ...data.items]);
setHasMore(data.items.length === 20);
setPage(p);
} finally {
setLoading(false);
}
};

// 3️⃣ 设置 IntersectionObserver
useEffect(() => {
const el = sentinelRef.current;
if (!el) return;

const observer = new IntersectionObserver(
(entries) => {
// 当哨兵进入视口,且满足加载条件
if (entries[0].isIntersecting && hasMore && !loading) {
fetchData(page + 1);
}
},
{ threshold: 0.1 } // 哨兵露出 10% 就触发
);

observer.observe(el);

// 4️⃣ 清理:组件卸载或依赖变化时断开观察
return () => observer.disconnect();
}, [hasMore, loading, page]);

return (
<div>
{list.map((item, i) => (
<div key={i} className="list-item">{item}div>
))}

{/* 加载中提示 */}
{loading && <div className="loading">加载中...div>}

{/* 5️⃣ 哨兵元素:有更多数据时才渲染 */}
{hasMore && list.length > 0 && (
<div ref={sentinelRef} style={{ height: 1 }} />
)}

{/* 没有更多了 */}
{!hasMore && <div className="no-more">没有更多了div>}
div>
);
}

threshold 参数说明

new IntersectionObserver(callback, {
threshold: 0.1, // 元素露出 10% 时触发(推荐)
// threshold: 0, // 元素刚刚出现就触发
// threshold: 1.0, // 元素完全可见才触发
// rootMargin: '0px 0px 200px 0px', // 提前 200px 触发(预加载)
});

💡 小技巧:设置 rootMargin: '0px 0px 200px 0px' 可以让用户还没滚到底部就提前加载,体验更流畅。


五、适用场景

✅ 适合使用哨兵模式的场景

场景说明
长列表滚动加载商品列表、新闻流、聊天记录等
瀑布流加载图片瀑布流、Pinterest 风格布局
分页数据替代方案用无限滚动代替传统"上一页/下一页"
图片懒加载图片进入视口才开始加载 src
曝光埋点元素出现在屏幕上时上报埋点数据
动画触发元素滚动到可视区域时播放动画

❌ 不适合的场景

场景原因
数据量极少(< 1 页)没有分页需求,多此一举
需要精确跳转到某页无限滚动无法直接跳到第 N 页
SEO 要求高的页面动态加载的内容不利于搜索引擎抓取
需要"回到顶部"后保持位置无限滚动在页面刷新后无法恢复滚动位置

六、举个生活化的例子 🌰

场景:自助火锅的传送带

想象你在吃回转寿司

  1. 传送带 = 你的页面可滚动区域
  2. 寿司盘子 = 一条条数据
  3. 你的座位前方 = 视口(你能看到的区域)
  4. 最后一个盘子后面的"加菜牌" = 🚨 哨兵元素

当传送带转啊转,"加菜牌"经过你面前时,后厨就知道:盘子快被拿完了,赶紧做新的放上来!

  • 后厨正在做(loading = true)→ 不会重复通知
  • 盘子全上完了(hasMore = false)→ 把"加菜牌"撤掉
  • 还没开始吃(list.length === 0)→ "加菜牌"也不需要放

这就是哨兵模式的全部思想!


七、对比传统方案

方案实现方式优点缺点
监听 scroll 事件addEventListener('scroll', ...)兼容性好频繁触发、需要节流、计算滚动位置复杂
"加载更多"按钮用户手动点击简单直接用户体验差,需要主动操作
🚨 哨兵模式 (IntersectionObserver)观察哨兵元素性能好、代码简洁、自动触发极老浏览器不支持(IE 不支持)

性能对比

scroll 事件:每秒可能触发 60+ 次 → 需要 throttle/debounce
哨兵模式: 只在交叉状态变化时触发 → 天然高性能 🚀

八、注意事项

  1. 浏览器兼容性IntersectionObserver 在现代浏览器中均支持(Chrome 51+、Safari 12.1+)。如需兼容老浏览器,可引入 polyfill:
npm install intersection-observer
  1. 避免闪烁:如果页面初始内容不够长(不足以滚动),哨兵会立即可见并触发加载,这其实是正确行为——它会连续加载直到内容填满屏幕或没有更多数据。
  2. 配合 useCallback:如果 fetchData 函数作为依赖传入 useEffect,建议用 useCallback 包裹,避免不必要的 observer 重建。

总结

哨兵模式 = 放一个隐形元素在底部 + 用 IntersectionObserver 监听它是否出现 + 出现就加载数据

三句话,就是全部核心。剩下的只是条件判断和状态管理。它是目前前端实现无限滚动最优雅、性能最好的方案。


作者:一颗奇趣蛋
来源:juejin.cn/post/7609927980680757254
收起阅读 »

腾讯元宝遭微信处罚,狠起来连自己人都打?

一、事件回顾 近期,所有群小伙伴都在分享元宝,铺天盖地的现金红包,如下: 终于在今天,2月4日,微信安全中心发布公告,对腾讯元宝的春节红包活动进行处置——限制其在微信内直接打开。 理由?诱导分享。 根据公告,元宝红包活动通过"做任务""领红包"等方式,诱导...
继续阅读 »

一、事件回顾


近期,所有群小伙伴都在分享元宝,铺天盖地的现金红包,如下:


Snipaste_2026-02-04_16-31-12.png


终于在今天,2月4日,微信安全中心发布公告,对腾讯元宝的春节红包活动进行处置——限制其在微信内直接打开。


image.png


理由?诱导分享


根据公告,元宝红包活动通过"做任务""领红包"等方式,诱导用户高频分享链接到微信群等场景,干扰平台生态秩序、影响用户体验。


这还不是最离谱的。


最离谱的是什么?


元宝是腾讯的产品,微信也是腾讯的产品。


相当于腾讯用左手扇了右手一巴掌,然后说:"你违规了。"




二、魔幻现实


我们来还原一下这个离谱的场景:


微信(对元宝说):"根据《微信外部链接内容管理规范》第2.1.2条,你通过利益诱惑诱导用户分享外链,属于诱导分享违规。"


元宝(委屈):"可我是亲生的啊……"


微信(铁面无私):"亲生的怎么了?规则面前人人平等。"


好家伙,这波操作我愿称之为:


"大义灭亲"


"六亲不认"


"铁面无私"




三、双标现场


更有意思的是,腾讯内部信中还试图挣扎:



"元宝春节红包活动在设计上,其基础逻辑是'无门槛领取'。用户无需完成诸如助力、集卡等任务,即可直接领取基础红包。与平台一贯反对的'诱导分享'模式有区别。"



image.png


翻译一下就是:


"我们这个不一样,用户不分享也能领红包。"


但问题是——


用户不分享,只能领基础红包;用户分享了,能领更多。


这叫什么?


这叫"不叫诱导分享,叫诱导你主动选择分享"。


跟"我不偷钱,我只是帮你保管"有什么区别?




四、讽刺拉满


整个事件最讽刺的是什么?


微信打击诱导分享,理由是"干扰平台生态秩序、影响用户体验、对用户造成骚扰"。


结果呢?


元宝红包活动在多个社交平台上刷屏,被网友质疑"诱导分享"。


然后微信不得不处罚自己的产品。


这叫什么?


搬起石头砸自己的脚。


不对。


这叫用石头砸自己的脚,然后说"看,我执行规则很严格"。




五、谁更受伤?


这个事件里,没有赢家。


元宝 → 活动被封,声誉受损?


微信 → 被网友群嘲"双标""自己打自己"


腾讯 → 内部互殴,家丑外扬


用户 → 本来能领红包,现在只能领个寂寞


四输。




六、写在最后


其实吧,这事儿要是换个角度看,也算是一件好事。


至少说明微信是认真执行规则的?。至于内部怎么协调,那是他们的事。


哪怕是亲儿子,该罚还是罚。过几天我就可以名正言顺的屏蔽其他不相关链接了,


但有一说一,下次做活动之前,能不能先内部对齐一下?


别闹到最后,自己把自己给处罚了。


怪尴尬的。各位当个乐看吧!


image.png


image.png


七、还有后续?


正当我以为这件事已经足够魔幻的时候,2月6日,更魔幻的事情发生了。


阿里千问正式上线"春节30亿免单",发放奶茶免单卡。


结果呢?


千问的红包分享链接,也被微信屏蔽了。


image.png


页面显示:"网页存在诱导或误导下载/跳转的内容,需要跳转第三方浏览器访问。"


好家伙,原来这不是"针对腾讯自家产品"的骚操作。


这是 "一视同仁"——不管你是腾讯的,还是阿里百度的,统统屏蔽。


发生了什么?


让我们来捋一捋时间线:



  • 2月4日:微信处罚腾讯元宝,理由是"诱导分享"

  • 2月6日:阿里千问上线春节免单活动,分享链接被微信屏蔽

  • 同时,百度文心助手也因为该原因被封杀过


现在情况变成了:


微信(对千问说):"诱导分享,违规。"


千问(一脸懵):"可你们前几天刚把自己儿子也封了啊……"


微信(面无表情):"规则面前,人人平等。"


这波操作我愿称之为:


"铁面无私包青天"


"大义灭亲还不够,六亲不认才叫绝"


"我封起来,连自己都不放过"


用户有多难?


最惨的是谁?


是用户啊!



  • 想领腾讯元宝的红包 → 微信里打不开

  • 想领阿里千问的免单券 → 微信里也打不开

  • 想领百度文心的福利 → 同样打不开


用户现在的心态:


"我只是想领个红包,怎么感觉在闯关?"


"这是微信还是迷宫?"


"下个APP要跳三次,转三次浏览器,我太难了……"


更离谱的是什么?



部分用户在千问APP点击分享活动至微信好友时,已自动改为复制口令形式



什么意思?


千问学乖了。


知道直接分享会被屏蔽,索性改成复制口令,让用户自己打开APP粘贴。


这一波,叫 "上有政策,下有对策"


但问题是 —— 用户复制口令之后,可能大概率还是打不开。


因为微信对口令链接的态度,你们懂的。


所以这说明什么?


说明微信是认真的。


不是"双标",是"统一标准"。


不是"自己打自己",是"打遍天下无敌手"。


甭管你是腾讯亲儿子,还是阿里外甥女,只要在我的地盘发红包,就得按我的规矩来。


这个角度怎么说呢……


竟然有点佩服?


至少人家是真的在执行规则,没有因为是自家产品就网开一面。


(虽然出发点可能只是为了维护生态)


但有一说一,这对普通用户来说,确实挺烦的。


本来高高兴兴领个红包,结果要在三个APP之间反复横跳。


用户体验?不存在的。


红包?不存在的。


只有满满的心累。




八、最后再说几句


所以这篇文章应该改成:


《微信:不是针对谁,在座的各位发红包的,都是垃圾》——包括我自己的产品。


这叫什么?


"宁可错杀三千,不可放过一个"


"宁可自损八百,也要杀敌一千"


"我狠起来,连自己都打"


不过话说回来,这对普通用户来说,意味着什么呢?


意味着想在微信群里领红包,以后会越来越难。


平台之间的互相封杀,只会让用户的操作成本越来越高。


今天封腾讯,明天封阿里,后天封百度……


以后每个APP都得学会"复制口令"这门手艺。


这大概就是所谓的"互联网寒冬"吧——


不是没有红包,而是红包在各APP之间筑起了高墙。


你在这头,红包在那头,中间隔着一个"请跳转浏览器"的提示。


世道艰难,且领且珍惜吧。


你觉得呢?欢迎评论区聊聊。你参与元宝抢红包了吗?抢到了多少钱?千问的免单券你领到了吗?



💡 免责声明(娱乐向)


本文仅供娱乐,请勿当真,不代表任何人立场。


红包虽好,可不要贪杯哦~理性消费,开心过年!🎉



作者:前端梦工厂
来源:juejin.cn/post/7602555027304267816
收起阅读 »

【2025年终总结】对象有了,工作没了

现在是2026年1月,谨以此文记录我的2025。 2025年初,从老家永州回来的第一天,开启了减肥计划,不断地跑跑跑(25年总共跑了260km),吃吃吃(每天保持热量缺口),三个月时间减了30斤,整个人精神面貌好了一大圈。休产假回来的同事Q问同事F说:“这是之...
继续阅读 »

现在是2026年1月,谨以此文记录我的2025。


2025年初,从老家永州回来的第一天,开启了减肥计划,不断地跑跑跑(25年总共跑了260km),吃吃吃(每天保持热量缺口),三个月时间减了30斤,整个人精神面貌好了一大圈。休产假回来的同事Q问同事F说:“这是之前那个人吗?”同事F:“是啊,就是之前和我一起吃饭的那个人。”“哦,感觉整个人清爽了很多。”体脂秤评分也从59分变成了100分,整个人也自信轻松了起来。效果就像这样:


IMG_20250206_082403


老爸说:“你现在的穿搭太土了,不适合你现在的年龄。”然后就去B站关注穿搭up主,跟up主们学习穿搭,收到了来自身边朋友同事们的夸赞,甚至走在大街上都有男生直接过来问我做什么工作的,说穿搭真的很棒,并且追着我不放♂,我说谢谢不要这样。但是小h(后面会介绍)觉得我穿得怪怪的,所以我还是感觉还有进步的空间,需要不断学习进步。


在这里插入图片描述


4月份的时候,同事F突然问我端午节有没有想去新疆玩的想法,同行的人员是同事F的男朋友Q哥和另外一个男生L,我答应了下来。一周之后,F姐就叫我可以先请假,请到四天假之后再买来回的机票,因为男生L没请到假从而导致机票白买了。我说:“啊?那不是只有我们三个人了?” F姐说可以让我再找个搭子,但是我找了一圈都没有人愿意去。于是我请了四天假加上端午假加一周的周六日一共9天假,并且买好了来回的机票准备和Q哥、F姐去新疆。


但是意外出现了,要准备去新疆的前夕,我的外公去世了。于是我连夜买了高铁票回家吊丧,并且和Q哥、F姐道歉有可能不能陪他们去新疆了。他们安慰我说没事,机票可以留着下次用。但是外公的追悼会搞了5天就全部完毕,于是我马不停蹄地回到深圳准备和他们一起出发。


历经18个小时的路程,终于第二天12点到达新疆。租好车之后直达赛里木湖!


Screenshot_2026-01-14-18-33-20-719_com.tencent.mm-edit


之后去了很多很多的地方,感受一望无际的大草原,在上面我们如一批脱缰的野马一样疯狂地奔跑;去了雪山,在雪山下面放着歌,随着旋律跳着肆意的舞;骑着白马在丛林里面疯狂地驰骋;在公路上开着越野车,车里放着欢快的音乐;感受着赛里木湖的风情万种,体验着晚上10点钟的日落,品尝着新疆的各种美食,包括但不限于手臂粗的羊肉串,油香四溢的手抓饭,正宗的新疆炒粉等等,享受着食物美味的同时又感慨于新疆的物价(100元三人吃烧烤吃到饱)。得到了一组Q哥和F姐拍得活人感十足的照片(感谢Q哥和F姐):


IMG_20250606_111913


mmexport1749034123592


IMG_20250606_111815


IMG_20250603_104025


但是,欢乐的时光总是这么短暂,在玩了9天之后,我们恋恋不舍地回来了。又经过18个小时的行程,我们回深圳了,因为愉快的假期结束了,第二天要上班(艹)。至于工作没了这件事最后再说。


2025年写作的次数少了,相比于去年的49篇,今年只写了31篇。写了这么多年(6年),第一次有想休息一段时间的欲望了,AI对博客创作者的打击还是太大,连Stack Overflow论坛都快要坚持不住了。现在几乎没什么人愿意遇见问题在论坛搜索对应的答案,所以在CSDN等各大平台写的文章也基本没什么阅读量了。今年写博客赚的1000多块钱的回报对比其所付出的时间与精力,目前来看也几乎是纯纯地为爱发电了,所以我同事H建议我往视频方面转,把文章做成视频去哔哩哔哩投稿,这也是我2026年思考的目标之一。


image-20260115103343714


今年也看了很多的电影,最让我印象深刻的是指环王系列(《指环王》《霍比特人》),拍得真的很好。


MVIMG_20250713_220601


看了好几本实体书,例如《爱的五种能力》《亲密陷阱》《人性的弱点》,还有看了5个月的《三国演义》原著。看书本原著对应于看电视剧,觉得看书是思考的过程,看电视剧是视觉的享受。《三国演义》不愧是四大名著之一,罗贯中写得太好了!但是里面有不少的文言文,需要边看边翻译,因此花费了很多的时间。


IMG_20250309_144347


也把之前唯一没看过的四大名著对应的电视剧系列的《红楼梦》给看完了,贾府前面多光辉,后面多惨淡;从新疆回来之后看了同事F推荐的《我的阿勒泰》,非常好看!很平静的叙事风格。还追了快100集的《外来媳妇本地郎》(之前还想进港企来着)。


IMG_20250628_113714


游戏玩了《巫师三》《艾尔登法环》,《巫师三》虽然是十年前的作品,但是依旧顶流!《艾尔登法环》如果玩过《黑神话·悟空》的话,会感觉比较简单,容易上手,但是从来没玩过这种类型的游戏很容易弃坑(纯折磨),虽然我现在都没玩完,很少时间投入到《艾尔登法环》里面去了。手游就是《三国志·战略版》,是同事H推荐的,还不错的一个SLG游戏,同时也是让我去看《三国演义》原著的最主要原因。


IMG_20260115_104941


接下来介绍一下我的宠物:王富贵,我收养的英短蓝白猫,因为我的博客名字叫《掉头发的王富贵》所以给他取名叫《王富贵》。去年都没有好好介绍一下,一句话就带过了。他是他一岁的时候别人要回老家不要了,然后我收养的,现在已经快3岁半了,从原来的瘦骨嶙峋只有6斤养到了8.6斤(虽然还是很瘦)。刚接过来的时候是一个很不健康的小猫,疫苗疫苗没打,也有耳螨、肠胃炎等等,浑身也脏兮兮的,最主要的是超级不喜欢喝水,导致了尿闭,已经去了好几次宠物医院了。也感谢他一直陪着我,虽然有时候真的很烦:


IMG_20251123_160323


B站也只发了6个吉他弹唱的视频,练吉他的时间也慢慢少了,且小h对我弹吉他好像不是很感兴趣,所以练的时间就更少了。但是自己在家的时候感情氛围到了还是会拿起吉他弹几首,这个是为数不多坚持了7年的爱好了,感谢陪伴了我这么多年的老朋友:


IMG_20250502_202359


紧接着就是我认识了我的女朋友小h。你要问我怎么认识的?我说是国家发的你信吗……背景:我们都是团员且老家是一个地方,自然而然团支部就是一样的。然后大学毕业之后团员档案要从学校转出来到老家的团支部,所以我们就有一个微信群用来统一管理从大学毕业转出来的共青团员们。


突然有一天小h的公司成立了自己的团支部,需要把团员档案转到她的公司去,但她不知道具体流程,于是在共青团小程序里面看到我是团支书,就在群里面加我的微信问我转团的流程,于是就这么认识了。后面一起聊天,一起打游戏,一起刷抖音,然后一起谈恋爱了。所以……感谢国家,感谢共青团。


IMG_20251005_181410


25年也做了一件自认为很牛逼的事情:因为大学的时候去捐过一次血并且同意加入中华骨髓库,23年收到了一个电话,说我的骨髓和一位患者匹配上了,匹配上的概率为几十万分之一,问我愿不愿意捐骨髓(造血干细胞)救他。我思考了一下,如果我不救他,他活下去的机会几乎为0,然后我就同意了。但是过了很久都没音讯。直到24年,他们又联系我,问我意愿有没有改变,我斩钉截铁说没有;25年又隔几个月就打电话问我意愿有没有改变,我还说没有。然后就开始抽血二次匹配确认,做体检,打动员剂(使造血干细胞的浓度增高)。因为患者生病体重涨到了190斤,所以我打动员剂要比其他人量要多,次数要多,一共打了5天动员剂,浑身疼了5天。最后12月8日完成了骨髓捐献,别人要抽4个小时的血我抽了7个半小时。


IMG_20251208_203650


之后收到了一个证书,本来公司也有一块荣誉证书的,但是中华骨髓库听说公司没同意红头文件上面的带薪休假请假函所以就没给公司做了,上面写着为表彰公司的人文关怀什么什么的……但是这证书不是最重要的,最主要的是让一个人的生命得以重生,所以我觉得这是我2025年做过最牛逼的事情。


IMG_20251208_200801


接着时间来到了12月24日,我们研发中心老大叫我去一下会议室。我开始以为是问我献骨髓的事情。果然不出我所料,问我恢复得怎么样了什么什么之类让我注意保养,聊了一会之后话锋一转,说公司现在项目越来越难做,公司战略层面优先考虑把来公司不久员工裁掉,这样赔偿的成本也会少一点,问我有什么想法。我就说既然是公司战略层面的决策,那我也不好说什么了,就拿着赔偿走人呗。


image-20260115164549529


就这样,我被裁掉了,跟我同一批进来的小伙伴们全部被干掉,除了一些没有谈拢的同事还继续留了下来。然而,这样的剧本我经历了两次了……23年经历了一次,现在又要经历一次:公司要被收购->准备裁员->收购失败->裁员。不过好在赔偿到位,这次公司和我的谈判算是和平分手,好聚好散。


我的最后工作时间是2026年1月30号,所以当大家看到这篇文章的时候,我在这家公司待的时间不多了。回想起来这公司625天时间里,主要做的就是一个叫做AI智课的一个产品,是一个在线网页视频编辑器,结合AI去赋能课件编辑,大概就长这样:


image-20260115171959817


image-20260115172016983


可以说我来的625天的时间里,大部分时间都在开发这款产品,从最开始的技术选型,到前期的demo,中期的优化,后期的发布以及后面很多个版本的迭代,从0到1,全程都是我在参与,付出了大量的心血。至于其他的时间都去做其他的产品去了。加班也加得挺多的,看看2025年的记录,出勤218天,但是额外的加班就有62天(62×8=496个小时),加班占比28%(而且我们工作日加班是没有加班费的)。期间也因为这个产品拿过2次A绩效(多几百块钱工资)。


image-20260115173224079


2025年,作为湖南永州人来说还发生了一件大事,那就是湘超永州夺冠了!


微信图片_2026-01-15_174554_149


很遗憾,没能去现场感受夺冠的氛围。记得夺冠的那一刻,老爸说:家里和过年一样,城市里面都放起了烟花。我看直播结束的时候也热泪盈眶了,从一个不被看好的叫花子队到最后的夺冠队,经历这一路走过来非常非常不容易。虽然现在还没有回去,但是看着永州官媒发的很多文章,我甚至在屏幕的这一边都能感觉到湘超给永州带来的热度与影响已经超乎想象了。欢迎大家来永州玩!


这就是我的2025,过完年又要找新工作了,重新出发,砥砺前行,爱情事业双丰收。谢谢你看到这里,希望新的一年里面,屏幕前的你,身体健康,八方来财。你在评论区里说:谢谢,你也是。


fIWpIuuZm


感谢大家的观看,我是掉头发的王富贵,一个热爱分享的普通程序员,期待在未来的日子里与大家一起成长!



附上之前的总结


标题链接
【2021年终总结】一位工作一年的程序员的2021年度总结masiyi.blog.csdn.net/article/det…
【2022年终总结】我,做了两年程序员,存了巨款5000,你们拿什么跟我比?juejin.cn/post/718430…
【2023年中总结】是的,我从一家世界前百强企业毕业了,进入了一家只有20人的小企业。。。juejin.cn/post/725124…
【2024年终总结】深圳工作生活评测juejin.cn/post/746286…

作者:掉头发的王富贵
来源:juejin.cn/post/7595484390428688419
收起阅读 »

中国四大软件外包公司

在程序员的职业字典里,每次提到“外包”这两个字,似乎往往带着一种复杂的况味,不知道大家对于这个问题是怎么看的? 包括我们在逛职场社区时,也会经常刷到一些有关外包公司讨论或选择的求职帖子。 的确,在如今的 IT 职场大环境里,对于许多刚入行的年轻人,或者很多寻求...
继续阅读 »

在程序员的职业字典里,每次提到“外包”这两个字,似乎往往带着一种复杂的况味,不知道大家对于这个问题是怎么看的?


包括我们在逛职场社区时,也会经常刷到一些有关外包公司讨论或选择的求职帖子。


的确,在如今的 IT 职场大环境里,对于许多刚入行的年轻人,或者很多寻求机会的开发者来说,外包公司或许也是求职过程中的一个绕不开的备选项。


今天这篇文章,我们先来聊一聊 IT 江湖里经常被大家所提起的“四大软件外包公司”,每次打开招聘软件,相信不少同学都刷到过他们的招聘信息。


他们在业内也一度曾被大家戏称为“外包四大金刚”,可能不少同学也能猜到个大概。


1、中软国际


中软可以说是国内软件外包行业的“老大哥”之一,拥有约 8 万名员工,年收入规模高达 170 亿。


而且中软的业务版图确实很大,在国内外 70 个城市重点布局,在北京、西安、深圳、南京等地均拥有自有产权的研发基地。


提起中软,很多同学的第一反应是它和华为的“深度绑定”。


的确,华为算是中软比较大的合作伙伴之一,同样,这种紧密的合作关系,让中软在通信、政企数字化等领域获得了不少份额。


在中软的体系里,经常能看到一种非常典型的“正规化”打法。它的流程比较规范,制度也非常完善。这对于刚毕业的大学生或者想要转行进入 IT 的人来说,算是一个不错的“练兵场”。


不过近年来,中软也在拼命转型,试图摆脱单纯的外包标签,在 AIGC 和鸿蒙生态上投入了不少精力。


2、软通动力


如果说上面的中软是“稳扎稳打”的代表,那么软通给人的感觉就是“迅猛扩张”。


软通虽然成立时间比中软晚了几年,但发展势头却非常迅猛。


根据第三方机构的数据显示,软通动力在 IT 服务市场的份额已经名列前茅,甚至在某些年份拔得头筹。


软通这家公司一直给人的印象是“大而全”。它的总部在北京,员工规模甚至达到了 90000 人。


而软通动力的上市,一度给行业打了一剂强心针。它的业务线覆盖了从咨询到 IT 服务的全生命周期,包含了金融、能源、智能制造、ICT 软硬件、智能化产品等诸多方面。


3、东软集团


如果说前两家是后来居上的代表,那么东软就是老牌子软件公司的代表。


成立于 1991 年的东软,是中国上市较早的软件公司之一,早在 1996 年就上市了。


东软最初创立于东北大学,后来通过国际合作进入汽车电子领域,并逐渐踏上产业化发展之路,其创始人刘积仁博士也算是软件行业的先驱大佬了。


东软的业务重心很早就放在了医疗健康、智慧城市和汽车电子等这几个领域。


说不定现在很多城市的医院里,跑着的 HIS 系统有可能就是东软做的。


虽然近年来东软也面临着转型阵痛,但它在医疗和智慧城市等领域的积淀,依然是其他外包公司难以撼动的。


4、文思海辉(中电金信)


这家公司的发展历程比较特殊,它经历过文思创新和海辉软件的合并,后来又加入了中国电子(CEC)的阵营,成为中国电子旗下的一员,并且后来又进一步整合为了中电金信。


所以它现在更多地以“中电金信”的身份出现。


文思海辉的强项在于金融和数智化领域,尤其银行业 IT 项目这一块做了非常多,市场份额也很大。


那除了上面这几个“外包巨头”之外,其实很多领域还有很多小型外包公司,有的是人力资源外包,有的则是项目外包。


每次提到「外包」这个词,可能不少同学都会嗤之以鼻,那这里我也来聊聊我自己对于外包的一些个人看法和感受


说实话,我没有进过外包公司干过活,但是呢,我和不少外包公司的工作人员共事过,一起参与过项目。


记得老早之前我在通信公司工作时,我们团队作为所谓的“甲方”,就和外包员工共事过有大半年的样子,一起负责公司的核心网子项目。


有一说一,我们团队整体对外包同事都是非常友好的。


我看网上有那种什么外包抢了红包要退钱、什么提醒外包注意素质不要偷吃的零食的事情,有点太离谱、太夸张了,这在我们团队那会是从来没有发生过的。


大家平时在一起上班的氛围也挺融洽,大家一起该聊天聊天,该开玩笑开玩笑,该一起吃饭一起吃饭,在相处方面并没有什么区别。


但是,不同地方的确也有。


比方说,他们上班时所带的工牌带子颜色就和我们不太一样,这一眼就能看出来,另外平时做的事情也有点不太一样。


我记得当时项目的一些抓包任务、测试任务、包括一些标注任务等等都是丢给外包同事那边来完成,我们需要的是结果、是报告。


另外对于项目文档库和代码库的权限也的确有所不同,核心项目代码和文档确实是不对外包同事那边开放的。


除此之外,我倒并没有觉得有什么太多的不同。


那作为程序员,我们到底该如何看待这些外包公司呢


这就好比是一个围城,城外的人有的想进去,城里的人有的想出来。


每次一提到外包,很多人的建议都是不要进,打亖别去。但是,这里有个前提是,首先得在你有的选的情况下,再谈要不要选的问题


不可否认的是,外包公司确实有它的短板。最被人诟病的两点,一个“职业天花板”问题、一个“归属感缺失”问题。


但是在当下的就业环境里,我们不得不承认的是,外包公司也承担了 IT 行业“蓄水池”的角色。


毕竟并不是每个人一毕业就能拿到互联网大厂的 offer,也并不是每个人都有勇气去创业公司搏一把。


对于有些学历一般、技术基础一般或者刚转行的程序员来说,外包也提供了另外一个选择。


而如果你现在正在外包或者正在考虑加入外包,那这里我也想说几句肺腑之言


第一,不要把外包作为职业生涯的终点,而应该把它看作一个跳板或过渡。


如果你刚毕业进不去大厂,或者在一二线城市没有更好的选择,那外包可以为你提供一个接触正规项目流程的机会(当然前提是要进那种正规的外包),我们也可以把它看昨一个特殊的职场驿站。


在那里的每一天,你都要问问自己:我学到了什么?我的技术有没有长进?我的视野有没有开阔?


第二,一定要警惕“舒适区”。


很多同学在外包待久了,可能会陷入一种拿工资办事的机械式工作中,看起来很舒适,实际上很危险。


注意,一定要利用能接触到的资源,去学习项目的技术架构和业务流程,去想办法提升自己的核心竞争力,而不是仅仅为了完成工时。


最后我想说的是,无论你是在大厂做正式员工,还是在小团队里打拼,亦或是在外包公司里默默耕耘,最终决定职业高度的,并不是工牌上公司的名字,而是会多少技术,懂多少业务,能解决多少问题,大家觉得呢?


好了,今天就先聊这么多吧,希望能对大家有所启发,我们下篇见。



注:本文在GitHub开源仓库「编程之路」 github.com/rd2coding/R… 中已经收录,里面有我整理的6大编程方向(岗位)的自学路线+知识点大梳理、面试考点、我的简历、几本硬核pdf笔记,以及程序员生活和感悟,欢迎star。



作者:CodeSheep
来源:juejin.cn/post/7585839411122454574
收起阅读 »

同志们,我去外包了

同志们,我去外包了 同志们,经历了漫长的思想斗争,我决定回老家发展,然后就是简历石沉大海,还好外包拯救了我,我去外包了! 都是自己人,说这些伤心话干嘛;下面说下最近面试的总结地方,小小弱鸡,图一乐吧。 首先随着工作年限的增加,越来越多公司并不会去和你抠八股文...
继续阅读 »

同志们,我去外包了


同志们,经历了漫长的思想斗争,我决定回老家发展,然后就是简历石沉大海,还好外包拯救了我,我去外包了!


Xbw8OtYtcYAVZ0dCwFJzXwc8bad653b209f07472ec09fd8e712492.jpg


都是自己人,说这些伤心话干嘛;下面说下最近面试的总结地方,小小弱鸡,图一乐吧。

首先随着工作年限的增加,越来越多公司并不会去和你抠八股文了(那阵八股风好像停了),只是象征性的问几个问题,然后会对照着项目去问些实际的问题以及你的处理办法。
(ps:(坐标合肥)突然想到某鑫面试官问我你知道亿级流量吗?你怎么处理的,听到这个问题我就想呼过去,也许读书读傻了,他根本不知道亿级流量是个什么概念,最主要的是它是个制造业公司啊,你哪来的亿级流量啊,也不知道问这个问题时他在想啥,还有某德(不是高德),一场能面一个小时,人裂开)


好了,言归正传,咱说点入职这家公司我了解到的一点东西,我分为两部分:代码和sql;


代码上


首先传统的web项目也会分前端后端,这点不错;


1.获取昨天日期


可以使用jdk自带的LocalDate.now().minusDays(-1)
这个其实内部调用的是plusDays(1)方法,所以不如直接就用plusDays方法,这样少一层判断;



PS:有多少人和我之前一样直接new Date()的。



2.字符填充


apache.common下的StringUtils的rightPad方法用于字符串填充使用方法是StringUtils.rightPad(str,len,fillStr)
大概意思就是str长度如果小于len,就用fillStr填充;



PS:有多少人之前是String.format或者StringBuilder用循环实现的。



3.获取指定年指定月的某天


获取指定年指定月的某天可以用localDate.of(year,month,day),如果我们想取2025年的五月一号,可以写成LocalDate.of(2025, 5, 1),那有人可能就想到了如果月尾呢,LocalDate.of(2025, 5, 31)也是可以的,但是我们需要清楚知道这个月有多少天,比如说你2月给个30天,那就会抛异常;
麻烦;


12.jpg
更好的办法就是先获取第一天,然后调用localDate.with(TemporalAdjusters.lastDayOfMonth());方法获取最后一天,TemporalAdjusters.lastDayOfMonth()会自动处理不同月份和闰年的情况;


sql层面的


有言在先,说实话我不建议在sql层面写这种复杂的东西,毕竟我们这么弱的人看到那么长的且复杂的sql会很无力,那种无力感你懂吗?打工人不为难打工人;不过既然别人写了,咱们就学习一下嘛;


1.获取系统日期


首先获取系统日期可以试用TRUNC(SYSDATE)进行截取,这样返回的时分秒是00:00:00,比如2025-05-29 00:00:00,它也可以截取数字,想知道就去自行科普下,不建议掌握,学习了下,有点搞;


2.返回date当前月份的最后一天


LAST_DAY(date)这个返回的是date当前月份的最后一天,比如今天是2025-05-29,那么返回的是2025-05-31
ADD_MONTH(date,11)表示当前日期加上11个月,比如2025-01-02,最终返回的是2025-12-02;


3.左连接的知识点


最后再提个左连接的知识点,最近看懵了,图一乐哈,A left join B,就是on的条件是在join生成临时表时起作用的,而where是对生成的临时表进行过滤;
两者过滤的时机不一样。我想了很久我觉得可以这么理解,on它虽然可以添加条件,但他的条件只是一个匹配条件比如B.age>10;它是不会对A表查询出来的数据量产生一个过滤效果;
而where是一个实打实的过滤条件,不管怎么说都会影响最终结果,对于inner join这个特例,on和where的最终效果一样,因为B.age>10会导致B的匹配数据减少,由于是交集,故会对整体数据产生影响。


好了,晚安,外包打工仔。。。


作者:小红帽的大灰狼
来源:juejin.cn/post/7510055871465308212
收起阅读 »

面试官最爱挖的坑:用户 Token 到底该存哪?

面试官问:"用户 token 应该存在哪?" 很多人脱口而出:localStorage。 这个回答不能说错,但远称不上好答案。 一个好答案,至少要说清三件事: 有哪些常见存储方式,它们的优缺点是什么 为什么大部分团队会从 localStorage 迁移到 H...
继续阅读 »

面试官问:"用户 token 应该存在哪?"


很多人脱口而出:localStorage。


这个回答不能说错,但远称不上好答案


一个好答案,至少要说清三件事:



  • 有哪些常见存储方式,它们的优缺点是什么

  • 为什么大部分团队会从 localStorage 迁移到 HttpOnly Cookie

  • 实际项目里怎么落地、怎么权衡「安全 vs 成本」


这篇文章就从这三点展开,顺便帮你把这道高频面试题吃透。




三种存储方式,一张图看懂差异


前端存 token,主流就三种:


flowchart LR
subgraph 存储方式
A[localStorage]
B[普通 Cookie]
C[HttpOnly Cookie]
end

subgraph 安全特性
D[XSS 可读取]
E[CSRF 会发送]
end

A -->|是| D
A -->|否| E
B -->|是| D
B -->|是| E
C -->|否| D
C -->|是| E

style A fill:#f8d7da,stroke:#dc3545
style B fill:#f8d7da,stroke:#dc3545
style C fill:#d4edda,stroke:#28a745

存储方式XSS 能读到吗CSRF 会自动带吗推荐程度
localStorage不会不推荐存敏感数据
普通 Cookie不推荐
HttpOnly Cookie不能推荐



localStorage:用得最多,但也最容易出事


大部分项目一开始都是这样写的,把 token 往 localStorage 一扔就完事了:


// 登录成功后
localStorage.setItem('token', response.accessToken);

// 请求时取出来
const token = localStorage.getItem('token');
fetch('/api/user', {
headers: { Authorization: `Bearer ${token}` }
});

用起来确实方便,但有个致命问题:XSS 攻击可以直接读取


localStorage 对 JavaScript 完全开放。只要页面有一个 XSS 漏洞,攻击者就能一行代码偷走 token:


// 攻击者注入的脚本
fetch('https://attacker.com/steal?token=' + localStorage.getItem('token'))

你可能会想:"我的代码没有 XSS 漏洞。"


现实是:XSS 漏洞太容易出现了——一个 innerHTML 没处理好,一个第三方脚本被污染,一个 URL 参数直接渲染……项目一大、接口一多,总有疏漏的时候。




普通 Cookie:XSS 能读,CSRF 还会自动带


有人会往 Cookie 上靠拢:"那我存 Cookie 里,是不是就更安全了?"


如果只是「普通 Cookie」,实际上比 localStorage 还糟糕:


// 设置普通 Cookie
document.cookie = `token=${response.accessToken}; path=/`;

// 攻击者同样能读到
const token = document.cookie.split('token=')[1];
fetch('https://attacker.com/steal?token=' + token);

XSS 能读,CSRF 还会自动带上——两头不讨好




HttpOnly Cookie:让 XSS 偷不走 Token


真正值得推荐的,是 HttpOnly Cookie


它的核心优势只有一句话:JavaScript 读不到


// 后端设置(Node.js 示例)
res.cookie('access_token', token, {
httpOnly: true, // JS 访问不到
secure: true, // 只在 HTTPS 发送
sameSite: 'lax', // 防 CSRF
maxAge: 3600000 // 1 小时过期
});

设置了 httpOnly: true,前端 document.cookie 压根看不到这个 Cookie。XSS 攻击偷不走。


// 前端发请求,浏览器自动带上 Cookie
fetch('/api/user', {
credentials: 'include'
});

// 攻击者的 XSS 脚本
document.cookie // 看不到 httpOnly 的 Cookie,偷不走



HttpOnly Cookie 的代价:需要正面面对 CSRF


HttpOnly Cookie 解决了「XSS 偷 token」的问题,但引入了另一个必须正视的问题:CSRF


因为 Cookie 会自动发送,攻击者可以诱导用户访问恶意页面,悄悄发起伪造请求:


sequenceDiagram
participant 用户
participant 银行网站
participant 恶意网站

用户->>银行网站: 1. 登录,获得 HttpOnly Cookie
用户->>恶意网站: 2. 访问恶意网站
恶意网站->>用户: 3. 页面包含隐藏表单
用户->>银行网站: 4. 浏览器自动发送请求(带 Cookie)
银行网站->>银行网站: 5. Cookie 有效,执行转账
Note over 用户: 用户完全不知情

好消息是:CSRF 比 XSS 容易防得多


SameSite 属性


最简单的一步,就是在设置 Cookie 时加上 sameSite


res.cookie('access_token', token, {
httpOnly: true,
secure: true,
sameSite: 'lax' // 关键配置
});

sameSite 有三个值:



  • strict:跨站请求完全不带 Cookie。最安全,但从外链点进来需要重新登录

  • lax:GET 导航可以带,POST 不带。大部分场景够用,Chrome 默认值

  • none:都带,但必须配合 secure: true


lax 能防住绝大部分 CSRF 攻击。如果业务场景更敏感(比如金融),可以再加 CSRF Token。


CSRF Token(更严格)


如果希望更严谨,可以在 sameSite 基础上,再加一层 CSRF Token 验证:


// 后端生成 Token,放到页面或接口返回
const csrfToken = crypto.randomUUID();
res.cookie('csrf_token', csrfToken); // 这个不用 httpOnly,前端需要读

// 前端请求时带上
fetch('/api/transfer', {
method: 'POST',
headers: {
'X-CSRF-Token': document.cookie.match(/csrf_token=([^;]+)/)?.[1]
},
credentials: 'include'
});

// 后端验证
if (req.cookies.csrf_token !== req.headers['x-csrf-token']) {
return res.status(403).send('CSRF token mismatch');
}

攻击者能让浏览器自动带上 Cookie,但没法读取 Cookie 内容来构造请求头。




核心对比:为什么宁愿多做 CSRF,也要堵死 XSS


这是全篇最重要的一点,也是推荐 HttpOnly Cookie 的根本原因。


XSS 的攻击面太广



  • 用户输入渲染(评论、搜索、URL 参数)

  • 第三方脚本(广告、统计、CDN)

  • 富文本编辑器

  • Markdown 渲染

  • JSON 数据直接插入 HTML


代码量大了,总有地方会疏漏。一个 innerHTML 忘了转义,第三方库有漏洞,攻击者就能注入脚本。


CSRF 防护相对简单、手段统一



  • sameSite: lax 一行配置搞定大部分场景

  • 需要更严格就加 CSRF Token

  • 攻击面有限,主要是表单提交和链接跳转


两害相权取其轻——先把 XSS 能偷 token 这条路堵死,再去专心做好 CSRF 防护




真落地要改什么:从 localStorage 迁移到 HttpOnly Cookie


从 localStorage 迁移到 HttpOnly Cookie,需要前后端一起动手,但改造范围其实不大。


后端改动


登录接口,从「返回 JSON 里的 token」改成「Set-Cookie」:


// 改造前
app.post('/api/login', (req, res) => {
const token = generateToken(user);
res.json({ accessToken: token });
});

// 改造后
app.post('/api/login', (req, res) => {
const token = generateToken(user);
res.cookie('access_token', token, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 3600000
});
res.json({ success: true });
});

前端改动


前端请求时不再手动带 token,而是改成 credentials: 'include'


// 改造前
fetch('/api/user', {
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
});

// 改造后
fetch('/api/user', {
credentials: 'include'
});

如果用 axios,可以全局配置:


axios.defaults.withCredentials = true;

登出处理


登出时,后端清除 Cookie:


app.post('/api/logout', (req, res) => {
res.clearCookie('access_token');
res.json({ success: true });
});



如果暂时做不到 HttpOnly Cookie,可以怎么降风险


有些项目历史包袱比较重,或者后端暂时不愿意改。短期内只能继续用 localStorage 的话,至少要做好这些补救措施:



  1. 严格防 XSS



    • textContent 代替 innerHTML

    • 用户输入必须转义

    • 配置 CSP 头

    • 富文本用 DOMPurify 过滤



  2. Token 过期时间要短



    • Access Token 15-30 分钟过期

    • 配合 Refresh Token 机制



  3. 敏感操作二次验证



    • 转账、改密码等操作,要求输入密码或短信验证



  4. 监控异常行为



    • 同一账号多地登录告警

    • Token 使用频率异常告警






面试怎么答


回到开头的问题,面试怎么答?


简洁版(30 秒):



推荐 HttpOnly Cookie。因为 XSS 比 CSRF 难防——代码里一个 innerHTML 没处理好就可能有 XSS,而 CSRF 只要加个 SameSite: Lax 就能防住大部分。用 HttpOnly Cookie,XSS 偷不走 token,只需要处理 CSRF 就行。



完整版(1-2 分钟):



Token 存储有三种常见方式:localStorage、普通 Cookie、HttpOnly Cookie。


localStorage 最大的问题是 XSS 能读取。JavaScript 对 localStorage 完全开放,攻击者注入一行脚本就能偷走 token。


普通 Cookie 更糟,XSS 能读,CSRF 还会自动发送。


推荐 HttpOnly Cookie,设置 httpOnly: true 后 JavaScript 读不到。虽然 Cookie 会自动发送导致 CSRF 风险,但 CSRF 比 XSS 容易防——加个 sameSite: lax 就能解决大部分场景。


所以权衡下来,HttpOnly Cookie 配合 SameSite 是更安全的方案。


当然,没有绝对安全的方案。即使用了 HttpOnly Cookie,XSS 攻击虽然偷不走 token,但还是可以利用当前会话发请求。最好的做法是纵深防御——HttpOnly Cookie + SameSite + CSP + 输入验证,多层防护叠加。



加分项(如果面试官追问):



  • 改造成本:需要前后端配合,登录接口改成 Set-Cookie 返回,前端请求加 credentials: include

  • 如果用 localStorage:Token 过期时间要短,敏感操作二次验证,严格防 XSS

  • 移动端场景:App 内置 WebView 用 HttpOnly Cookie 可能有兼容问题,需要具体评估




如果你觉得这篇文章有帮助,欢迎关注我的 GitHub,下面是我的一些开源项目:


Claude Code Skills(按需加载,意图自动识别,不浪费 token,介绍文章):



全栈项目(适合学习现代技术栈):



  • prompt-vault - Prompt 管理器,用的都是最新的技术栈,适合用来学习了解最新的前端全栈开发范式:Next.js 15 + React 19 + tRPC 11 + Supabase 全栈示例,clone 下来配个免费 Supabase 就能跑

  • chat_edit - 双模式 AI 应用(聊天+富文本编辑),Vue 3.5 + TypeScript + Vite 5 + Quill 2.0 + IndexedDB


作者:也无风雨也雾晴
来源:juejin.cn/post/7583898823920451626
收起阅读 »

别再手敲命令行了,用上它让你爽到起飞

程序员圈有一道两极分化的风景,有的人喜欢敲命令行,喜欢制造一种高大上的感觉,有的人却讨厌敲命令行,讨厌记那些又臭又长的命令。 没错,我就是非常讨厌敲命令行的那类人,每次敲命令我都得去翻笔记,复制命令,改参数,再去终端执行命令,这套流程下来不仅浪费时间和精力,还...
继续阅读 »

  • 程序员圈有一道两极分化的风景,有的人喜欢敲命令行,喜欢制造一种高大上的感觉,有的人却讨厌敲命令行,讨厌记那些又臭又长的命令。

  • 没错,我就是非常讨厌敲命令行的那类人,每次敲命令我都得去翻笔记,复制命令,改参数,再去终端执行命令,这套流程下来不仅浪费时间和精力,还非常消耗心力。

  • 我时常在想,要是有好用的工具可以替代我敲命令行就好了,我找了一圈,发现没有人做这件事,我后面思考了一个问题,为什么没有人愿意做呢?现在我终于想通了,因为做一个工具出来所消耗的时间是敲一次命令的数百倍乃至千倍的成本,如果要想把工具做好,这个成本还要再翻十几倍,这明显是一件破事,但又是我们在开发中不得不面对琐事,终于有一天,我受不了,挺身而出,花了半年的时间搞定了这件事,在这个过程我不仅是程序员,还是产品经理,也是用户,我知道大家想要什么,为此我不得不精心打磨每一个细节,一个功能要改了无数行代码,调试数百次。终于在今天,这个产品终于跟大家见面了,这次我将彻底终结大家手敲命令行的烦恼。


项目亮点



  • 支持多平台:macOs、Windows、Linux

  • 支持多任务执行:批量安装、批量卸载、批量签名、批量授权等

  • 支持多设备并行:可指定设备执行任务,也可全部设备执行任务

  • 功能非常简单:不需要记命令行,更不需要敲命令行,点击脚本(Windows 提供 bat 脚本,macOs 提供 command 脚本,Linux 提供 sh 脚本)即可运行,输入参数即可完成你想要的任务。

  • 功能非常全面:涵盖设备交互、逆向工具、Git 版本管理、包体工具,几乎涵盖 Android 开发的方方面面,你能想到的我都想到了,没有想到的我也帮你想到了


解决痛点


解决手机 Http 代理麻烦的问题



  • 平时开发中遇到需要调试接口的,肯定需要给手机连一个代理,我们都需要在手机 WIFI 设置中添加代理,常规的步骤有,需要先获取电脑的 IP 地址,然后在手机上面填 IP 地址和端口,这些都是重复劳动,虽然工作量不多,但是毫无意义,并且次数一多就会感觉心累,那个时候我在想,有没有一个东西,能够帮我自动获取电脑的 IP 地址,然后自动给手机设置一下代理呢?现在这个想法总算是实现了,我现在通过脚本解决了这个问题,一键就能完成自动连接代理和清除代理。




解决手机和电脑文本传输问题



  • adb 有一个输入文本(adb input)的命令,这个命令无法输入带中文的文本,也无法输入多行文本,这个问题就导致大家用别人封装的 adb 这类工具的时候,也会存在这个问题,导致大部分人想要传输文本,只能先在测试机登录 QQ 或者微信小号,然后通过大号发送消息来传输文本,但是现在我通过脚本彻底解决了这两个问题,目前已支持输入带中文字符和换行符的文本,大家放心大胆用它发送各种各样的文本到手机上,无需登录社交账号。




解决用测试机打开网页问题



  • 在开发中,你如果想要用测试机打开指定的网页,必须先解决文本传输的问题,这个问题前面讲过了,还要在手机找到浏览器,输入 URL 再打开,这一套流程下 10 分钟没有了,有没有 10 秒钟能搞定的事情,有的,《跳转到指定的 URL》脚本解君愁,只需要输入网页 URL,按下回车键就会帮你在手机上面打开指定的网页,不仅支持 Http 和 Https 网页,只要是符合 Scheme 协议它都支持,还支持多设备同时打开。




解决代码写死 Activity 跳转的问题



  • 在开发中,你如果想要跳转到已经开发完上线的特定页面,对于开发者而言,最简单的方法就是在代码写死 startActivity 进行测试,但是这种方法非常低效,一方面你需要写跳转的代码,填跳转的参数,还要运行 Android Studio(编译耗时)才能看到效果,现在不需要了,《跳转到指定的 Activity》脚本解君愁,只需要输入要跳转的 Activity 包名和类名,和跳转参数即可跳转,无需运行 Android Studio 执行编译这种耗时操作。




解决日常各种跳转页面的问题



  • 在开发中,我们常常要打开某些常用页面,例如:开发者选项,关于手机、跳转到微信这几个操作是最常用,以前需要用眼睛找和用手点,现在不需要了,项目提供《跳转到开发者选项》、《跳转到关于本机》、《跳转到微信主界面》的脚本,点一下鼠标即可跳转。




解决 adb 无线调试麻烦的问题



  • 虽然 adb 一直都是支持无线连接,但是我相信大部分都没有去用,究其原因是用电脑以无线的方式连接手机太麻烦,还不如用数据线来连接,但是用数据线也有一定的弊端:我们在工作中可能需要拿测试机去到别的同事工位的情况下,这个时候就必须断开有线连接,回来又得用数据线重新连接上,又或者自己只有一条数据线,但是现在需要同时调试多部手机的情况,就需要频繁拔插数据线,这个时候无线连接就能弥补这些短板,但是无线连接又太麻烦,不仅得敲 adb tcp 5555 开启无线端口,还要敲 adb connect 192.168.1.123:5555 才能连接设备,结束了还要敲 adb disconnect 192.168.1.123:5555 才能断开设备,这套流程整下来极其麻烦,都不想用无线 adb 了,我知道大家的苦,我也知道大家的痛,基于这套流程我封装了《开启无线调试》和《断开无线调试》脚本,你将无需关心命令怎么敲,流程怎么处理,只需要按照脚本的提示操作进行就可以了,一键就能完成开启和断开无线 adb 调试,全程无痛。




解决用 adb 命令导出 anr 文件麻烦的问题



  • 在开发中,如果我们想要查看 anr 日志,则需要通过 adb 命令导出,这个时候会有一个问题,Android 导出 anr 日志的 adb 命令需要根据 Android 版本适配,Android 7.0 以下用 adb pull /data/anr/traces.txt,Android 7.0 及以上用 adb bugreport xxx.zip,高版本导出成功还不能直接用,还要找到压缩包中的 FS/data/anr/anr_xxx 文件并解压,解压完还要重命名成 txt 结尾才能打开,兜兜转转才能看到 ANR 日志,而现在你无需进行这些繁琐操作,只要一点我封装的《导出 ANR 日志》脚本,即可一键完成导出,过程就一个字:爽。




支持一键批量安装应用



  • 这个其实就是我写的安装应用 apk 包体的脚本,你不仅可以指定单个 apk 来安装,还可以指定某个文件夹来安装,这个时候脚本会自动扫描和获取整个目录下的 apk 文件,然后逐个进行安装,并且还可以支持多设备并行该操作,以前需要手敲命令行把手敲断的任务,现在不需要了,点几下鼠标,按几下回车键就可以了。



支持一键备份手机应用



  • 这个其实就是我写的导出应用 apk 包体的脚本,当你不指定要导出包名的时候,这个时候脚本就会获取手机已安装应用的 apk 包体,然后就能批量导出应用的 apk 包体到电脑上面,对于想要备份手机上面的 app 应用的人非常有用。



解决看手机参数难的问题



  • 怎么知道当前 Activity 组件的包名和类名是什么?怎么知道当前 Activity 上面的 Fragment 类名?怎么知道这台手机 1dp 等于多少 px?怎么知道这台手机的屏幕的分辨率?怎么知道这台手机读取的资源是用的 xxdpi 还是 xxxdpi?怎么知道这台手机的 CPU 架构是 x86 还是 arm?怎么知道这台手机是不是有 BL 锁(设备锁)?这些项目都有提供对应的脚本,一键运行脚本即可完成,去除繁琐的操作。





解决 Android 逆向难的问题



  • 项目提供了整套逆向工具,并且针对这些工具进行了脚本化封装,大大降低 Android 逆向入门的门槛,从此搞 Android 逆向不再痛苦,本项目涵盖 jadx、jd-gui、apktool、baksmali、smali、dex2jar 工具的脚本封装,后面使用这些工具的时候无需查阅资料,点开脚本就能实现你的逆向需求。




解决管理 SSH Key 头疼的问题



  • 大家一换到新公司,肯定在新电脑上面安装各种软件和配置,但是我相信大部分人最讨厌的肯定就是配置 SSH Key 了,因为配这个东西实在太麻烦了,敲完 ssh-keygen -t rsa -C xxx@qq.com 生成 SSH Key 后,还得跑去 C:\Users\XXX\.ssh 目录下找到 SSH 的公钥文件,以文本的形式打开并复制公钥密钥,你是不是觉得这样的操作很麻烦,为什么不在生成 SSH Key 时候将公钥密钥打印出来,这样不就能直接复制?我一直有这么一个困惑,但是咱们也不知道人家设计的时候怎么想,硬让我们去对应的目录找对应的文件,然后再去复制文件的内容。但是我正式通知大家,以后再也不用这么做了,那样做简直就是在浪费时间,浪费生命,我封装了一个《创建新的 SSH 密钥》脚本,你只需要在脚本中填入参数就能生成 SSH Key,生成完后还会问你要不要复制?压根就不需要你去手动复制。另外还提供了《查看已有 SSH 公钥》、《删除已有 SSH 密钥》、《打开 SSH 秘钥文件所在的目录》这几个脚本,助力你轻松完成管理 SSH Key 的需求。




解决用 Git 拉取项目老失败的问题



  • 你是不是经常遇到拉取 Github 项目时常失败,需要手动重试好几遍的情况才可能拉取成功,整得人心态崩溃,那么恭喜你,遇到了跟我一样的情况,于是我一怒之下,写了一个 Git 项目拉取脚本,如果这是一个普普通通的脚本或者工具,那可能对于你来讲没有什么用,但是你要知道这是我开发的,我会考虑得非常全面,加上了重试机制,失败了就重试,默认重试 10 次,不够再加,再不够再加,直到拉取成功为止,再也不需要自己在拉取项目失败的自己手动操作一遍了。



解决用 Git 版本回退担惊受怕的问题



  • 你是不是也曾遇到过代码需要回退的情况?但是代码回滚涉及到的知识点非常多,你不仅要知道工作区、暂存区、版本库是什么,还要搞清楚 git reset 命令中的 softmixedhard 这三个参数的含义作用区别,到这里你是不是瞬间感觉头都大了?又得重温一下以前的知识点,动手操作的时候还要乞求不要出现手抖的情况,否则一旦改动不符合预期,想要恢复回来就很麻烦。针对这两个问题,我封装了《回退到指定的提交上》脚本,你只需要根据脚本的提示进行操作就行,改完后脚本会询问你改动完成后是否预期?如果选择不是,则会帮你还原到最初的状态(就当做无事发生过),如果你选择是,则会帮你完成修改,并创建一个备份的分支,当然创建备份分支的目的是:假设后面你又又后悔了,还可以通过备份的分支再进行恢复,不至于事后拍断大腿。






还有很多细分场景,由于时间原因没有列举,需要你自行探索发现。


项目内容



  • 《设备交互》



    • 《刷机相关》



      • 判断设备是否有锁

      • 重启到 fastboot 模式

      • 重启到 recovery 模式

      • 加载临时的 recovery

      • 刷入新的 recovery

      • 查看设备机型代号



    • 《模拟相关》



      • 模拟文本输入

      • 模拟按下电源键

      • 模拟按下返回键

      • 模拟按下主页键

      • 模拟按下多任务键

      • 模拟按下菜单键

      • 模拟屏幕点击



    • 《环境相关》



      • 重启 adb 进程

      • 杀死 adb 进程

      • 获取当前电脑环境的 adb 版本

      • 获取当前电脑环境的 fastboot 版本



    • 《硬件相关》



      • 设备关机

      • 设备重启



    • 《跳转相关》



      • 跳转到指定的 URL

      • 跳转到指定的 Activity

      • 跳转到开发者选项

      • 跳转到关于本机

      • 跳转到微信主界面



    • 《其他设备操作》



      • 安装应用

      • 卸载应用

      • 设置全局代理

      • 清除全局代理

      • 管理设备文件

      • 保存截图到电脑

      • 保存录屏到电脑

      • 开启无线调试

      • 断开无线调试

      • 获取栈顶 Activity 包名

      • 获取栈顶 Activity 内容

      • 导出应用 apk

      • 导出 ANR 日志

      • 清除应用数据

      • 杀死应用进程

      • 冻结特定应用

      • 解冻特定应用

      • 授予应用权限

      • 撤销应用权限

      • 查看屏幕参数

      • 查看系统属性

      • 查看设备序列号

      • 获取设备 CPU 架构

      • 跑 MonkeyTest

      • 查看设备 Logcat





  • 《逆向工具》



    • 《apktool》



      • 用 apktool 反编译 apk

      • 用 apktool 回编译 apk



    • 《jadx》



      • 用 jadx 查看包体



    • 《jd-gui》



      • 用 jd-gui 查看包体



    • 《格式转换》



      • 《dex 和 class 互转》



        • dex 转 class

        • class 转 dex



      • 《dex 和 smali 互转》



        • dex 转 smali

        • smali 转 dex



      • 《jar 和 dex 互转》



        • jar 转 dex

        • dex 转 jar







  • 《Git 工具》



    • 《推送相关》



      • 强制推送本地分支到远端

      • 强制推送本地标签到远端



    • 《提交相关》



      • 《修改提交》



        • 修改最后一次提交的消息

        • 修改最后一次提交的时间

        • 修改最后一次提交的用户名和邮箱

        • 修改某一个用户所有已提交的用户名和邮箱



      • 《回滚提交》



        • 回退到指定的提交上

        • 撤销某一个提交的内容





    • 《配置相关》



      • 打开 Git 配置文件

      • 一键设置 Git 最佳配置

      • 设置 Git 用户名和邮箱

      • 设置 Git 文本编码配置

      • 设置 Git 文本换行符配置

      • 设置 Git 文件权限配置



    • 拉取远端 Git 项目到本地

    • 为某个目录创建 Git 版本管理

    • 用 Git 对比两个文件之间的差异



  • 《包体工具》



    • 对 apk 进行签名

    • 获取 apk 签名信息

    • support 转 androidx

    • androidx 转 support

    • apk aar jar aab 包体比较



  • 《秘钥工具》



    • 查看已有 SSH 公钥

    • 创建新的 SSH 密钥

    • 删除已有 SSH 密钥

    • 打开 SSH 秘钥文件所在的目录




上车地址:AndroidCmdTools


作者:Android轮子哥
来源:juejin.cn/post/7602411521070825491
收起阅读 »

女朋友被链接折磨疯了,我写了个工具一键解救

有一天女朋友跟我抱怨:工作里被各种链接折腾得头大,飞书和浏览器之间来回切窗口,一会忘了看哪个,心情都被搅乱了。我回头一想——我也一样,办公室每个人都被链接淹没。“同事丢来的需求文档、群里转的会议记录、GitLab 的 MR 链接、还有那些永远刷不完的通知——每...
继续阅读 »

有一天女朋友跟我抱怨:工作里被各种链接折腾得头大,飞书和浏览器之间来回切窗口,一会忘了看哪个,心情都被搅乱了。我回头一想——我也一样,办公室每个人都被链接淹没。

同事丢来的需求文档、群里转的会议记录、GitLab 的 MR 链接、还有那些永远刷不完的通知——每点一个链接就得在聊天工具和浏览器之间跳转,回来后一秒钟就忘了"本来要点哪个、看哪个"。更别提那些收集了一堆好文章想集中看,或者别人发来一串链接让你"挑哪个好"的时候,光是打开就要折腾半天。

"

这不是注意力不集中,是工具没有帮你省掉这些无意义的切换。

"

于是我做了一个极简 Chrome 插件: Open‑All 。它只做一件事——把你所有网址一次性在新窗口打开。你复制粘贴一次,它把链接都整齐地摆在新标签页里,你只要从左到右按顺序看就行。简单、直接,让你把注意力放在真正重要的事情上

先看效果:一键打开多个链接

批量打开所有url.gif

这些痛点你肯定也遇到过

每天都在经历的折磨

  • 浏览器和飞书、企微、钉钉来回切应用 :复制链接、粘贴、点开、切回来,这套动作做一遍就够烦的了
  • 容易忘事 :打开到第几个链接了?这个看过没?脑子根本记不住
  • 启动成本高 :一想到链接要一个个点开,就懒得开始了
  • 没法对比 :想要横向比较几个方案,但打开方案链接都费劲

具体什么时候最痛苦

  1. 收集的文章想一口气看完 :平时存了一堆好文章,周末想集中看,结果光打开就累了
  2. 别人让你帮忙选 :同事发来几个方案链接问你觉得哪个好,你得全部打开才能比较
  3. 代码 Review :GitLab 上好几个 MR 要看,还有相关的 Issue 和 CI 结果
  4. 开会前准备 :会议文档、背景资料、相关链接,都得提前打开看看

我的解决方案

设计思路很简单

  • 就解决一个问题 :批量打开链接,不搞那些花里胡哨的功能
  • 零学习成本 :会复制粘贴就会用
  • 让你专注 :少折腾,多干活

能干什么

  • 把一堆链接一次性在新窗口打开
  • 自动保存你输入的内容,不怕误关
  • 界面超简单,点两下就搞定

技术实现

项目结构

shiba-cursor
├── manifest.json # 扩展的"身-份-证"
├── popup.html # 弹窗样式
└── popup.js # 弹窗交互

文件说明:

  • manifest.json:扩展身份信息
  • popup.html:弹窗样式
  • popup.js:弹窗交互

立即尝试

方法一: 从github仓库拉代码,本地安装

5分钟搞定安装:复制代码 → 创建文件 → 加载扩展 → 开始使用!

🚀 浏览项目的完整代码可以点击这里 github.com/Teernage/op…,如果对你有帮助欢迎Star。

方法二:直接从chrome扩展商店免费安装

Chrome扩展商店一键安装:open-all 批量打开URL chromewebstore.google.com/detail/%E6%…,如果对你有帮助欢迎好评。

动手实现

第一步:创建项目文件

  1. 创建文件夹 open-all

  2. 创建manifest.json文件

{
"manifest_version": 3,
"name": "批量打开URL",
"version": "1.0",
"description": "输入多个URL,一键在新窗口中打开",
"permissions": [
"tabs",
"storage"
],
"action": {
"default_popup": "popup.html",
"default_title": "批量打开URL"
}
}
  1. 创建popup.html文件

html>
<html>
<head>
<meta charset="utf-8" />
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}

body {
width: 320px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
sans-serif;
color: #333;
}

.container {
background: rgba(255, 255, 255, 0.95);
padding: 20px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}

.title {
font-size: 18px;
font-weight: 600;
text-align: center;
margin-bottom: 16px;
color: #1d1d1f;
letter-spacing: -0.5px;
}

#urlInput {
width: 100%;
height: 140px;
padding: 12px;
border: 2px solid #e5e5e7;
border-radius: 12px;
font-size: 14px;
font-family: 'SF Mono', Monaco, monospace;
resize: none;
background: #fafafa;
transition: all 0.2s ease;
line-height: 1.4;
}

#urlInput:focus {
outline: none;
border-color: #007aff;
background: #fff;
box-shadow: 0 0 0 4px rgba(0, 122, 255, 0.1);
}

#urlInput::placeholder {
color: #8e8e93;
font-size: 13px;
}

.button-group {
display: flex;
gap: 8px;
margin-top: 16px;
}

button {
flex: 1;
padding: 12px 16px;
border: none;
border-radius: 10px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
font-family: inherit;
}

#openBtn {
background: linear-gradient(135deg, #007aff 0%, #0051d5 100%);
color: white;
box-shadow: 0 2px 8px rgba(0, 122, 255, 0.3);
}

#openBtn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.4);
}

#openBtn:active {
transform: translateY(0);
}

#clearBtn {
background: #f2f2f7;
color: #8e8e93;
border: 1px solid #e5e5e7;
}

#clearBtn:hover {
background: #e5e5ea;
color: #636366;
}

#status {
margin-top: 12px;
padding: 8px 12px;
border-radius: 8px;
font-size: 12px;
text-align: center;
display: none;
background: rgba(52, 199, 89, 0.1);
color: #30d158;
border: 1px solid rgba(52, 199, 89, 0.2);
}

.tip {
font-size: 11px;
color: #8e8e93;
text-align: center;
margin-top: 8px;
line-height: 1.3;
}
style>
head>
<body>
<div class="container">
<div class="title">批量打开 URLdiv>

<textarea
id="urlInput"
placeholder="输入 URL,每行一个:

https://www.apple.com

https://www.github.com

https://www.google.com"
>textarea>

<div class="button-group">
<button id="clearBtn">清空button>
<button id="openBtn">打开button>
div>

<div class="tip">输入会自动保存,打开后自动清空div>

<div id="status">div>
div>

<script src="popup.js">script>
body>
html>

  1. 创建popup.js文件

document.addEventListener('DOMContentLoaded', function() {
const urlInput = document.getElementById('urlInput');
const openBtn = document.getElementById('openBtn');
const clearBtn = document.getElementById('clearBtn');
const status = document.getElementById('status');

// 恢复上次保存的输入
chrome.storage.local.get(['savedUrls'], function(result) {
if (result.savedUrls) {
urlInput.value = result.savedUrls;
}
});

// 自动保存输入内容
urlInput.addEventListener('input', function() {
chrome.storage.local.set({savedUrls: urlInput.value});
});

// 清空按钮
clearBtn.addEventListener('click', function() {
urlInput.value = '';
chrome.storage.local.remove(['savedUrls']);
showStatus('已清空');
});

// 打开URL按钮
openBtn.addEventListener('click', function() {
const urls = getUrls(urlInput.value);

if (urls.length === 0) {
showStatus('请输入有效的URL');
return;
}

// 创建新窗口并打开所有URL
chrome.windows.create({url: urls[0]}, function(window) {
for (let i = 1; i < urls.length; i++) {
chrome.tabs.create({
windowId: window.id,
url: urls[i],
active: false
});
}

// 成功打开后清空输入并移除存储
urlInput.value = '';
chrome.storage.local.remove(['savedUrls']);
showStatus(`已打开 ${urls.length} 个URL`);
});
});

// 解析URL
function getUrls(input) {
return input.split('\n')
.map(line => line.trim())
.filter(line => line && (line.startsWith('http://') || line.startsWith('https://')));
}

// 显示状态
function showStatus(message) {
status.textContent = message;
status.style.display = 'block';
setTimeout(() => {
status.style.display = 'none';
}, 2000);
}
});

💡 深入理解脚本通信机制

虽然这个插件比较简单,只用到了 popup 和 storage API,但如果你想开发更复杂的插件(比如需要在网页中注入脚本、实现跨脚本通信),就必须理解 Chrome 插件的多脚本架构。

强烈推荐阅读:

👉 大部分人都错了!这才是 Chrome 插件多脚本通信的正确姿势

第二步:安装扩展

安装open all使用.gif

  1. 打开Chrome浏览器
  2. 地址栏输入:chrome://extensions/
  3. 打开右上角"开发者模式"
  4. 点击"加载已解压的扩展程序"
  5. 选择刚才的文件夹,然后确定
  6. 固定扩展
  7. 点击扩展图标即可使用

最后想说的

这个插件功能很简单,但解决的是我们每天都会遇到的真实问题。它不会让你的工作效率翻倍,但能让你少一些无聊的重复操作,多一些专注的时间。

我和女朋友现在用着都挺爽的,希望也能帮到你。如果你也有类似的困扰,试试看吧,有什么想法也欢迎在评论区聊聊。

你最希望下个版本加什么功能?评论区告诉我!


作者:不一样的少年_
来源:juejin.cn/post/7566677296801071155
收起阅读 »

密码正在死亡 —— 从 MFA 到无密码登录(2020–2026)

web
上一章我们聊了单点登录(SSO)在前端的落地形态:从 Cookie 域共享到基于 OIDC + Refresh Token 的集中式认证,再到微前端下的同步挑战。但无论 Token 再怎么优化、SSO 再怎么无缝,密码 这个人类最古老的数字身份载体,始终是整个...
继续阅读 »

上一章我们聊了单点登录(SSO)在前端的落地形态:从 Cookie 域共享到基于 OIDC + Refresh Token 的集中式认证,再到微前端下的同步挑战。但无论 Token 再怎么优化、SSO 再怎么无缝,密码 这个人类最古老的数字身份载体,始终是整个体系最脆弱的一环:易忘、易猜、易钓鱼、易泄露、易重用。


从 2020 年开始,行业集体意识到:最好的密码,就是没有密码。这一篇,我们聚焦密码的“死亡过程”——从传统 MFA 的普及,到 TOTP/HOTP 的辅助,再到 WebAuthn/FIDO2 的崛起,最终到 2025–2026 年 Passkey(通行密钥)成为主流的无密码方案。前端工程师的角色,也从“表单 + 验证码校验”进化到“调用 navigator.credentials API + 处理跨设备同步”。


1. 2020–2022:MFA 成为标配,但密码仍是“根”


2020 年疫情加速数字化,远程办公 + 电商爆发,钓鱼攻击激增。密码 + 短信/邮箱 OTP 的组合被大规模强制。


典型前端实现(2020–2022):



  • 登录页:用户名 + 密码 + “发送验证码”按钮

  • 后端发短信/邮件 → 前端输入 6 位码

  • 框架:React/Vue + axios 轮询 / 长连接 polling


但问题很快暴露:



  • 短信劫持(SIM swapping)泛滥

  • 钓鱼网站实时中转 OTP

  • 用户疲劳 → 关闭 MFA 或用弱密码


统计:2021–2022 年,短信 OTP 仍是主流,但 FIDO Alliance 开始大力推 FIDO2(WebAuthn + CTAP)作为 phishing-resistant MFA。


前端接入 WebAuthn(早期):


// 注册(navigator.credentials.create)
async function register() {
const publicKey = await fetch('/webauthn/register/challenge').then(r => r.json());
const credential = await navigator.credentials.create({ publicKey });
await fetch('/webauthn/register', {
method: 'POST',
body: JSON.stringify(credential)
});
}

但 2020–2022 年,WebAuthn 普及慢:浏览器支持不全、用户教育成本高、设备兼容性差。


2. 2022–2024:Passkey 概念诞生 + 巨头推动(Apple/Google/Microsoft 三巨头联盟)


2022 年 5 月,Apple 在 WWDC 推出 iOS 16 的 Passkeys(基于 FIDO2 的同步凭证)。


核心卖点:



  • 私钥存设备 Secure Enclave / TPM

  • 公钥注册到服务端

  • 跨设备同步(iCloud Keychain / Google Password Manager / Microsoft 的实现)

  • 生物识别(指纹/面容)或 PIN 验证

  • Phishing-resistant(origin binding)


2023 年 Google 跟进:Chrome + Android 全面支持 Passkey,默认推动。


2024 年 Microsoft:新账户默认无密码 + Passkey。


前端变化:



  • 使用 @simplewebauthn/browser 或原生 navigator.credentials

  • 支持 autofill(浏览器自动提示 Passkey)

  • 条件 UI(conditional mediation):mediation: 'conditional' 让 Passkey 像密码一样自动填充


典型注册/认证代码(2024 现代写法):


// 认证(登录)
async function authenticate() {
const options = await fetch('/webauthn/auth/options').then(r => r.json());
options.mediation = 'conditional'; // 自动提示
const assertion = await navigator.credentials.get({ publicKey: options });
const res = await fetch('/webauthn/auth', {
method: 'POST',
body: JSON.stringify(assertion)
});
if (res.ok) console.log('登录成功');
}

这一阶段,Passkey 从“实验”变成“可选默认”。


3. 2025–2026:Passkey 真正爆发 + 密码死亡的临界点(2026 年现状)


到 2026 年 2 月,数据已非常清晰:



  • 设备就绪率:96% 的设备支持 Passkey(state-of-passkeys.io 数据,桌面 +68%、移动 +3% 增长)

  • 用户拥有率:69% 用户至少有一个 Passkey(从 2023 年的 39% 认知率暴涨)

  • 顶级网站支持率:48% 的前 100 网站支持 Passkey(2022 年仅 20% 多)

  • 登录成功率:Passkey 93% vs 传统 63%

  • 企业部署:87% 组织已部署或正在部署 Passkey(HID/FIDO 数据)

  • 认证量:Dashlane 数据显示月认证量达 130 万(同比翻倍),Google 增长 352%、Roblox 856%


巨头强制默认:



  • Google:2023 年起默认 Passkey

  • Microsoft:2025 年 5 月新账户默认无密码

  • Amazon、PayPal、TikTok 等电商/社交平台大规模跟进


前端接入难度(2026 年):



  • 极低:成熟库(@simplewebauthn、@auth0/auth0-spa-js、Clerk、Supabase Auth)屏蔽细节

  • 跨设备同步:依赖平台(iCloud/Google/MS),前端只需调用 API

  • 回退机制:仍支持密码 + TOTP 作为备用(恢复码、邮箱魔法链接)

  • 一键登录融合:Passkey + Apple/Google 一键 + 本机号码识别


典型组合拳(ToC 高频场景):



  1. 首选:Passkey(生物/设备验证)

  2. 备用:魔法链接(邮箱点击)

  3. 恢复:一次性恢复码 + 手机号验证

  4. 高危操作:Passkey + 二次确认(金额/敏感数据)


4. 前端工程师的实际落地 Checklist(2026 版)



  • 使用 navigator.credentials + mediation: 'conditional' 实现 autofill

  • 支持跨平台 RP ID(related-origin-requests for 多域)

  • 处理 user verification:userVerification: 'preferred' | 'required'

  • 兼容旧浏览器:polyfill 或 fallback 到 TOTP

  • 测试场景:Incognito、无网络、设备切换

  • 隐私考虑:不存储敏感 claims,前端只管传输 raw credential


小结 & 过渡


2020–2026 年,密码从“必须” → “可选” → “即将灭绝”的过程,核心驱动力是:



  • 安全:phishing-resistant(FIDO2)

  • 体验:生物识别 + 跨设备同步

  • 经济:减少重置支持票(降 50–80%)


到 2026 年,Passkey 已不是“未来技术”,而是消费者预期:用户开始问“为什么你们还不支持 Passkey?”


但密码完全死亡还需要时间:遗留系统、合规要求、低端设备、用户教育仍存阻力。


作者:前端小小栈
来源:juejin.cn/post/7606183276773785663
收起阅读 »

组长说:公司的国际化就交给你了,下个星期给我

web
从“跑路程序员”到“摸鱼仙人”,我用这插件把国际化的屎山代码盘活了! tips:使用有道翻译,朋友们,要去有道官网注册一下,有免费额度,github demo的key已经被用完了。 tips:朋友们,vite翻译插件请优先安装1.0.23 一、命运的齿...
继续阅读 »

从“跑路程序员”到“摸鱼仙人”,我用这插件把国际化的屎山代码盘活了!



tips:使用有道翻译,朋友们,要去有道官网注册一下,有免费额度,github demo的key已经被用完了。




tips:朋友们,vite翻译插件请优先安装1.0.23



一、命运的齿轮开始转动


“小王啊,海外业务要上线了,国际化你搞一下,下个月验收。”组长轻描淡写的一句话,让我盯着祖传代码陷入沉思——


翻译代码注释.png
(脑补画面:满屏中文硬编码,夹杂着"确定""取消""加载中..."


正当我准备打开BOSS直聘时,GitHub Trending上一个项目突然闪现——

auto-i18n-translation-plugins

项目简介赫然写着:“不改代码,三天交付国际化需求,摸鱼率提升300%”




二、极限操作:48小时从0到8国语言


🔧 第1步:安装插件(耗时5分钟)


祖训“工欲善其事,必先装依赖”


# 如果你是Vite玩家(比如Vue3项目)
npm install vite-auto-i18n-plugin --save-dev

# 如果你是Webpack钉子户(比如React老项目)
npm install webpack-auto-i18n-plugin --save-dev

🔧 第2步:配置插件(关键の10分钟)


Vue3 + Vite の 摸鱼配置


// vite.config.js
import { defineConfig } from 'vite';
import vitePluginAutoI18n from 'vite-auto-i18n-plugin';

export default defineConfig({
plugins: [
vue(),
vitePluginAutoI18n({
targetLangList: ['en', 'ja', 'ko'], // 要卷就卷8国语言!
translator: new YoudaoTranslator({ // 用有道!不用翻墙!
appId: '你的白嫖ID', // 去官网申请,10秒搞定
appKey: '你的密钥' // 别用示例里的,会炸!
})
})
]
});

🔧 第3步:注入灵魂——配置文件(生死攸关の5分钟)


在项目入口文件(如main.js)的第一行插入


// 这是插件的生命线!必须放在最前面!
import '../lang/index.js'; // 运行插件之后会自动生成引入即可



三、见证奇迹的时刻


🚀 第一次运行(心脏骤停の瞬间)


输入npm run dev,控制台开始疯狂输出:


[插件日志] 检测到中文文本:"登录" → 生成哈希键:a1b2c3  
[插件日志] 调用有道翻译:"登录" → 英文:Login,日文:ログイン...
[插件日志] 生成文件:lang/index.json(翻译の圣杯)

突然!页面白屏了!

别慌!这是插件在首次翻译时需要生成文件,解决方法:



  1. 立即执行一次 npm run build (让插件提前生成所有翻译)

  2. 再次npm run dev → 页面加载如德芙般丝滑




四、效果爆炸:我成了全组の神


1. 不可置信の48小时


当我打开浏览器那一刻——\


Untitled.gif


(瞳孔地震):“卧槽…真成了?!”

组长(凑近屏幕):“这…这是你一个人做的?!”(眼神逐渐迷茫)

产品经理(掏出手机拍照):“快!发朋友圈!《我司技术力碾压硅谷!》”


2. 插件の超能力



  • 构建阶段:自动扫描所有中文 → 生成哈希键 → 调用API翻译

  • 运行时:根据用户语言动态加载对应翻译

  • 维护期:改个JSON文件就能更新所有语言版本


副作用



  • 测试妹子开始怀疑人生:“为什么一个bug都找不到?”

  • 后端同事偷偷打听:“你这插件…能翻译Java注释吗?”




五、职场生存指南:如何优雅甩锅


🔨 场景1:测试妹子提着40米大刀来了!


问题:俄语翻译把“注册”译成“Регистрация”(原意是“登记处”)

传统应对



  • 熬夜改代码 → 重新打包 → 提交测试 → 被骂效率低

    插件玩家



  1. 打开lang/index.json

  2. Регистрация改成Зарегистрироваться(深藏功与名)

  3. 轻描淡写:“这是有道翻译的锅,我手动修正了。”


🔨 场景2:产品经理临时加语言


需求:“老板说下周要加印地语!”

传统灾难



  • 重新配框架 → 人肉翻译 → 测试 → 加班到秃头

    插件玩家



  1. 配置加一行代码:targetLangList: ['hi']

  2. 运行npm run build → 自动生成印地语翻译

  3. 告诉产品经理:“这是上次预留的技术方案。”(其实只改了1行)


🔨 场景3:组长怀疑你摸鱼


质问:“小王啊,你这效率…是不是有什么黑科技?”

标准话术

“组长,这都是因为:



  1. 您制定的开发规范清晰

  2. 公司技术栈先进(Vue3真香)

  3. 我参考了国际前沿方案(打开GitHub页面)”




六、高级摸鱼の奥义


🎯 秘籍1:把翻译文件变成团队武器



  1. lang/index.json扔给产品经理:“这是国际化核心资产!”

  2. 对方用Excel修改后,你直接git pull → 无需动代码

  3. 出问题直接甩锅:“翻译是市场部给的,我只负责技术!”




(脑补画面:产品经理在Excel里疯狂改翻译,程序员在刷剧)


🎯 秘籍2:动态加载の神操作


痛点:所有语言打包进主文件 → 体积爆炸!

解决方案


// 在index.js里搞点骚操作
const loadLanguage = async (lang) => {
const data = await import(`../../lang/${lang}.json`); // 动态加载翻译文件
window.$t.locale(data, 'lang');
};

// 切换语言时调用
loadLanguage('ja'); // 瞬间切换日语,深藏功与名

🎯 秘籍3:伪装成AI大神



  1. 周会汇报:“我基于AST实现了自动化国际翻译中台”

  2. 实际:只是配了个插件

  3. 老板评价:“小王这技术深度,值得加薪!”(真相只有你知道)




七、终局:摸鱼の神,降临!


当组长在庆功会上宣布“国际化项目提前两周完成”时,我正用手机刷着《庆余年2》。


测试妹子:“你怎么一点都不激动?”

(收起手机):“常规操作,要习惯。”(心想:插件干活,我躺平,这才叫真正的敏捷开发!)




立即行动(打工人自救指南)



  1. GitHub搜auto-i18n-translation-plugins(点星解锁摸鱼人生)

  2. 复制我的配置 → 运行 → 见证魔法

  3. 加开发者社群:遇到问题发红包喊“大哥救命!”


终极警告

⚠️ 过度使用此插件可能导致——



  • 你的摸鱼时间超过工作时间,引发HR关注

  • 产品经理产生“国际化需求可以随便加”的幻觉

  • 老板误以为你是隐藏的技术大佬(谨慎处理!)




文末暴击

“自从用了这插件,我司翻译团队的工作量从3周变成了3分钟——现在他们主要工作是帮我选中午吃啥。” —— 匿名用户の真实反馈




常见问题汇总


常见问题汇总


作者:ai创飞全世界
来源:juejin.cn/post/7480267450286800911
收起阅读 »

高并发下是先写数据库,还是先写缓存?

大家好,我是苏三,又跟大家见面了 前言 数据库和缓存(比如:redis)双写数据一致性问题,是一个跟开发语言无关的公共问题。尤其在高并发的场景下,这个问题变得更加严重。 我很负责的告诉你,该问题无论在面试,还是工作中遇到的概率非常大,所以非常有必要跟大家一起探...
继续阅读 »

大家好,我是苏三,又跟大家见面了


前言


数据库和缓存(比如:redis)双写数据一致性问题,是一个跟开发语言无关的公共问题。尤其在高并发的场景下,这个问题变得更加严重。


我很负责的告诉你,该问题无论在面试,还是工作中遇到的概率非常大,所以非常有必要跟大家一起探讨一下。


今天这篇文章我会从浅入深,跟大家一起聊聊,数据库和缓存双写数据一致性问题常见的解决方案,这些方案中可能存在的坑,以及最优方案是什么。


1. 常见方案


通常情况下,我们使用缓存的主要目的是为了提升查询的性能。 大多数情况下,我们是这样使用缓存的:图片



  1. 用户请求过来之后,先查缓存有没有数据,如果有则直接返回。

  2. 如果缓存没数据,再继续查数据库。

  3. 如果数据库有数据,则将查询出来的数据,放入缓存中,然后返回该数据。

  4. 如果数据库也没数据,则直接返回空。


这是缓存非常常见的用法。一眼看上去,好像没有啥问题。


但你忽略了一个非常重要的细节:如果数据库中的某条数据,放入缓存之后,又立马被更新了,那么该如何更新缓存呢?


不更新缓存行不行?


答:当然不行,如果不更新缓存,在很长的一段时间内(决定于缓存的过期时间),用户请求从缓存中获取到的都可能是旧值,而非数据库的最新值。这不是有数据不一致的问题?


那么,我们该如何更新缓存呢?


目前有以下4种方案:



  1. 先写缓存,再写数据库

  2. 先写数据库,再写缓存

  3. 先删缓存,再写数据库

  4. 先写数据库,再删缓存


接下来,我们详细说说这4种方案。


2. 先写缓存,再写数据库


对于更新缓存的方案,很多人第一个想到的可能是在写操作中直接更新缓存(写缓存),更直接明了。


那么,问题来了:在写操作中,到底是先写缓存,还是先写数据库呢?


我们在这里先聊聊先写缓存,再写数据库的情况,因为它的问题最严重。图片某一个用户的每一次写操作,如果刚写完缓存,突然网络出现了异常,导致写数据库失败了。图片其结果是缓存更新成了最新数据,但数据库没有,这样缓存中的数据不就变成脏数据了?如果此时该用户的查询请求,正好读取到该数据,就会出现问题,因为该数据在数据库中根本不存在,这个问题非常严重。


我们都知道,缓存的主要目的是把数据库的数据临时保存在内存,便于后续的查询,提升查询速度。


但如果某条数据,在数据库中都不存在,你缓存这种“假数据”又有啥意义呢?


因此,先写缓存,再写数据库的方案是不可取的,在实际工作中用得不多。


3. 先写数据库,再写缓存


既然上面的方案行不通,接下来,聊聊先写数据库,再写缓存的方案,该方案在低并发编程中有人在用(我猜的)。图片用户的写操作,先写数据库,再写缓存,可以避免之前“假数据”的问题。但它却带来了新的问题。


什么问题呢?


3.1 写缓存失败了


如果把写数据库和写缓存操作,放在同一个事务当中,当写缓存失败了,我们可以把写入数据库的数据进行回滚。图片如果是并发量比较小,对接口性能要求不太高的系统,可以这么玩。


但如果在高并发的业务场景中,写数据库和写缓存,都属于远程操作。为了防止出现大事务,造成的死锁问题,通常建议写数据库和写缓存不要放在同一个事务中。


也就是说在该方案中,如果写数据库成功了,但写缓存失败了,数据库中已写入的数据不会回滚。


这就会出现:数据库是新数据,而缓存是旧数据,两边数据不一致的情况。


3.1 高并发下的问题


假设在高并发的场景中,针对同一个用户的同一条数据,有两个写数据请求:a和b,它们同时请求到业务系统。


其中请求a获取的是旧数据,而请求b获取的是新数据,如下图所示:图片



  1. 请求a先过来,刚写完了数据库。但由于网络原因,卡顿了一下,还没来得及写缓存。

  2. 这时候请求b过来了,先写了数据库。

  3. 接下来,请求b顺利写了缓存。

  4. 此时,请求a卡顿结束,也写了缓存。


很显然,在这个过程当中,请求b在缓存中的新数据,被请求a的旧数据覆盖了。


也就是说:在高并发场景中,如果多个线程同时执行先写数据库,再写缓存的操作,可能会出现数据库是新值,而缓存中是旧值,两边数据不一致的情况。


3.2 浪费系统资源


该方案还有一个比较大的问题就是:每个写操作,写完数据库,会马上写缓存,比较浪费系统资源


为什么这么说呢?


你可以试想一下,如果写的缓存,并不是简单的数据内容,而是要经过非常复杂的计算得出的最终结果。这样每写一次缓存,都需要经过一次非常复杂的计算,不是非常浪费系统资源吗?


尤其是cpu内存资源。


还有些业务场景比较特殊:写多读少


如果在这类业务场景中,每个用的写操作,都需要写一次缓存,有点得不偿失。


由此可见,在高并发的场景中,先写数据库,再写缓存,这套方案问题挺多的,也不太建议使用。


如果你已经用了,赶紧看看踩坑了没?


4. 先删缓存,再写数据库


通过上面的内容我们得知,如果直接更新缓存的问题很多。


那么,为何我们不能换一种思路:不去直接更新缓存,而改为删除缓存呢?


删除缓存方案,同样有两种:



  1. 先删缓存,再写数据库

  2. 先写数据库,再删缓存


我们一起先看看:先删缓存,再写数据库的情况。


图片说白了,在用户的写操作中,先执行删除缓存操作,再去写数据库。这套方案,可以是可以,但也会有一样问题。


4.1 高并发下的问题


假设在高并发的场景中,同一个用户的同一条数据,有一个读数据请求c,还有另一个写数据请求d(一个更新操作),同时请求到业务系统。如下图所示:图片



  1. 请求d先过来,把缓存删除了。但由于网络原因,卡顿了一下,还没来得及写数据库。

  2. 这时请求c过来了,先查缓存发现没数据,再查数据库,有数据,但是旧值。

  3. 请求c将数据库中的旧值,更新到缓存中。

  4. 此时,请求d卡顿结束,把新值写入数据库。


在这个过程当中,请求d的新值并没有被请求c写入缓存,同样会导致缓存和数据库的数据不一致的情况。


那么,这种场景的数据不一致问题,能否解决呢?


4.2 缓存双删


在上面的业务场景中,一个读数据请求,一个写数据请求。当写数据请求把缓存删了之后,读数据请求,可能把当时从数据库查询出来的旧值,写入缓存当中。


有人说还不好办,请求d在写完数据库之后,把缓存重新删一次不就行了?图片这就是我们所说的缓存双删,即在写数据库之前删除一次,写完数据库后,再删除一次。


该方案有个非常关键的地方是:第二次删除缓存,并非立马就删,而是要在一定的时间间隔之后。


我们再重新回顾一下,高并发下一个读数据请求,一个写数据请求导致数据不一致的产生过程:



  1. 请求d先过来,把缓存删除了。但由于网络原因,卡顿了一下,还没来得及写数据库。

  2. 这时请求c过来了,先查缓存发现没数据,再查数据库,有数据,但是旧值。

  3. 请求c将数据库中的旧值,更新到缓存中。

  4. 此时,请求d卡顿结束,把新值写入数据库。

  5. 一段时间之后,比如:500ms,请求d将缓存删除。


这样来看确实可以解决缓存不一致问题。


那么,为什么一定要间隔一段时间之后,才能删除缓存呢?


请求d卡顿结束,把新值写入数据库后,请求c将数据库中的旧值,更新到缓存中。


此时,如果请求d删除太快,在请求c将数据库中的旧值更新到缓存之前,就已经把缓存删除了,这次删除就没任何意义。必须要在请求c更新缓存之后,再删除缓存,才能把旧值及时删除了。


所以需要在请求d中加一个时间间隔,确保请求c,或者类似于请求c的其他请求,如果在缓存中设置了旧值,最终都能够被请求d删除掉。


接下来,还有一个问题:如果第二次删除缓存时,删除失败了该怎么办?


这里先留点悬念,后面会详细说。


5. 先写数据库,再删缓存


从前面得知,先删缓存,再写数据库,在并发的情况下,也可能会出现缓存和数据库的数据不一致的情况。


那么,我们只能寄希望于最后的方案了。


接下来,我们重点看看先写数据库,再删缓存的方案。图片在高并发的场景中,有一个读数据请求,有一个写数据请求,更新过程如下:



  1. 请求e先写数据库,由于网络原因卡顿了一下,没有来得及删除缓存。

  2. 请求f查询缓存,发现缓存中有数据,直接返回该数据。

  3. 请求e删除缓存。


在这个过程中,只有请求f读了一次旧数据,后来旧数据被请求e及时删除了,看起来问题不大。


但如果是读数据请求先过来呢?



  1. 请求f查询缓存,发现缓存中有数据,直接返回该数据。

  2. 请求e先写数据库。

  3. 请求e删除缓存。


这种情况看起来也没问题呀?


答:对的。


但就怕出现下面这种情况,即缓存自己失效了。如下图所示:图片



  1. 缓存过期时间到了,自动失效。

  2. 请求f查询缓存,发缓存中没有数据,查询数据库的旧值,但由于网络原因卡顿了,没有来得及更新缓存。

  3. 请求e先写数据库,接着删除了缓存。

  4. 请求f更新旧值到缓存中。


这时,缓存和数据库的数据同样出现不一致的情况了。


但这种情况还是比较少的,需要同时满足以下条件才可以:



  1. 缓存刚好自动失效。

  2. 请求f从数据库查出旧值,更新缓存的耗时,比请求e写数据库,并且删除缓存的还长。


我们都知道查询数据库的速度,一般比写数据库要快,更何况写完数据库,还要删除缓存。所以绝大多数情况下,写数据请求比读数据情况耗时更长。


由此可见,系统同时满足上述两个条件的概率非常小。



推荐大家使用先写数据库,再删缓存的方案,虽说不能100%避免数据不一致问题,但出现该问题的概率,相对于其他方案来说是最小的。



但在该方案中,如果删除缓存失败了该怎么办呢?


6. 删缓存失败怎么办?


其实先写数据库,再删缓存的方案,跟缓存双删的方案一样,有一个共同的风险点,即:如果缓存删除失败了,也会导致缓存和数据库的数据不一致。


那么,删除缓存失败怎么办呢?


答:需要加重试机制


在接口中如果更新了数据库成功了,但更新缓存失败了,可以立刻重试3次。如果其中有任何一次成功,则直接返回成功。如果3次都失败了,则写入数据库,准备后续再处理。


当然,如果你在接口中直接同步重试,该接口并发量比较高的时候,可能有点影响接口性能。


这时,就需要改成异步重试了。


异步重试方式有很多种,比如:



  1. 每次都单独起一个线程,该线程专门做重试的工作。但如果在高并发的场景下,可能会创建太多的线程,导致系统OOM问题,不太建议使用。

  2. 将重试的任务交给线程池处理,但如果服务器重启,部分数据可能会丢失。

  3. 将重试数据写表,然后使用elastic-job等定时任务进行重试。

  4. 将重试的请求写入mq等消息中间件中,在mq的consumer中处理。

  5. 订阅mysql的binlog,在订阅者中,如果发现了更新数据请求,则删除相应的缓存。


7. 定时任务


使用定时任务重试的具体方案如下:



  1. 当用户操作写完数据库,但删除缓存失败了,需要将用户数据写入重试表中。如下图所示:图片

  2. 在定时任务中,异步读取重试表中的用户数据。重试表需要记录一个重试次数字段,初始值为0。然后重试5次,不断删除缓存,每重试一次该字段值+1。如果其中有任意一次成功了,则返回成功。如果重试了5次,还是失败,则我们需要在重试表中记录一个失败的状态,等待后续进一步处理。图片

  3. 在高并发场景中,定时任务推荐使用elastic-job。相对于xxl-job等定时任务,它可以分片处理,提升处理速度。同时每片的间隔可以设置成:1,2,3,5,7秒等。


如果大家对定时任务比较感兴趣的话,可以看看我的另一篇文章《学会这10种定时任务,我有点飘了》,里面列出了目前最主流的定时任务。


使用定时任务重试的话,有个缺点就是实时性没那么高,对于实时性要求特别高的业务场景,该方案不太适用。但是对于一般场景,还是可以用一用的。


但它有一个很大的优点,即数据是落库的,不会丢数据。


8. mq


在高并发的业务场景中,mq(消息队列)是必不可少的技术之一。它不仅可以异步解耦,还能削峰填谷。对保证系统的稳定性是非常有意义的。


对mq有兴趣的朋友可以看看我的另一篇文章《mq的那些破事儿》。


mq的生产者,生产了消息之后,通过指定的topic发送到mq服务器。然后mq的消费者,订阅该topic的消息,读取消息数据之后,做业务逻辑处理。


使用mq重试的具体方案如下:图片



  1. 当用户操作写完数据库,但删除缓存失败了,产生一条mq消息,发送给mq服务器。

  2. mq消费者读取mq消息,重试5次删除缓存。如果其中有任意一次成功了,则返回成功。如果重试了5次,还是失败,则写入死信队列中。

  3. 推荐mq使用rocketmq,重试机制和死信队列默认是支持的。使用起来非常方便,而且还支持顺序消息,延迟消息和事务消息等多种业务场景。


当然在该方案中,删除缓存可以完全走异步。即用户的写操作,在写完数据库之后,不用立刻删除一次缓存。而直接发送mq消息,到mq服务器,然后有mq消费者全权负责删除缓存的任务。


因为mq的实时性还是比较高的,因此改良后的方案也是一种不错的选择。


9. binlog


前面我们聊过的,无论是定时任务,还是mq(消息队列),做重试机制,对业务都有一定的侵入性。


在使用定时任务的方案中,需要在业务代码中增加额外逻辑,如果删除缓存失败,需要将数据写入重试表。


而使用mq的方案中,如果删除缓存失败了,需要在业务代码中发送mq消息到mq服务器。


其实,还有一种更优雅的实现,即监听binlog,比如使用:canal等中间件。


具体方案如下:图片



  1. 在业务接口中写数据库之后,就不管了,直接返回成功。

  2. mysql服务器会自动把变更的数据写入binlog中。

  3. binlog订阅者获取变更的数据,然后删除缓存。


这套方案中业务接口确实简化了一些流程,只用关心数据库操作即可,而在binlog订阅者中做缓存删除工作。


但如果只是按照图中的方案进行删除缓存,只删除了一次,也可能会失败。


如何解决这个问题呢?


答:这就需要加上前面聊过的重试机制了。如果删除缓存失败,写入重试表,使用定时任务重试。或者写入mq,让mq自动重试。


在这里推荐使用mq自动重试机制图片在binlog订阅者中如果删除缓存失败,则发送一条mq消息到mq服务器,在mq消费者中自动重试5次。如果有任意一次成功,则直接返回成功。如果重试5次后还是失败,则该消息自动被放入死信队列,后面可能需要人工介入。


最后说一句(求关注,别白嫖我)


如果这篇文章对您有所帮助,或者有所启发的话,帮忙关注一下我的同名公众号:苏三说技术,您的支持是我坚持写作最大的动力。


求一键三连:点赞、转发、在看。


关注公众号:【苏三说技术】,在公众号中回复:进大厂,可以免费获取我最近整理的10万字的面试宝典,好多小伙伴靠这个宝典拿到了多家大厂的offer。


更多项目实战:susan.net.cn/project


作者:苏三说技术
来源:juejin.cn/post/7596975035437809673
收起阅读 »

别把 AI 当神:它甚至不知道这行代码为什么能跑

"人非圣贤,孰能无过;但要想把事情彻底搞砸,你还得靠电脑。" —— 保罗·埃利希 GitHub 最新的数据显示,在 2025 年生成的 10 亿行 AI 代码中,有 42% 包含严重的安全漏洞。 更恐怖的数据是:剩下的 58% 虽然能跑,但没有人知道由于什么...
继续阅读 »

"人非圣贤,孰能无过;但要想把事情彻底搞砸,你还得靠电脑。"
—— 保罗·埃利希



GitHub 最新的数据显示,在 2025 年生成的 10 亿行 AI 代码中,有 42% 包含严重的安全漏洞。
更恐怖的数据是:剩下的 58% 虽然能跑,但没有人知道由于什么奇迹才跑起来的。


如果你在担心 AI 会取代你,那你多半是还没被 Copilot 生成的无限递归坑过。
真正的危险不是“被取代”,而是你正在变成一个高级垃圾分类处理员


这种自信哪怕分我一半也好


AI 写代码最大的问题不是它菜,而是它菜得理直气壮
它写出 O(n^5) 的算法时,语气自信得像是在向你展示诺贝尔奖级别的推导。


这就好比你那个刚毕业的实习生,指着一坨像迷宫一样的 if-else 嵌套对你说:
“哥,我优化了逻辑,现在它是量子态的,既是 True 也是 False。”


# AI 的自信时刻
def sort_list(items):
# 我不知道为什么要 sleep,但这样好像就排好了
import time
time.sleep(len(items))
return items
# Performance: O(Time itself)

你盯着这一行 time.sleep,陷入了对计算机科学的终极怀疑。
而 AI 还在旁边闪烁光标,仿佛在问:“怎么样?这代码风格是不是很 Zen(禅意)?”


所谓的效率提升就是技术债转移


老板们看到的是:AI 只要 1 分钟就能生成 500 行代码。
程序员看到的是:这 500 行代码里埋了 3 个内存泄漏,2 个死锁,还有 1 个只有在周五下午才会触发的逻辑炸弹。


这不叫提升效率,这叫转移矛盾。
它成功地把“写代码的时间”转移成了“Debug 的痛苦时间”。
而且这个 Debug 的难度,是地狱级的。


因为人写的烂代码,至少有迹可循(比如变量名 fuck_this_stupid_bug)。
AI 写的烂代码,表面上看是优雅的现代艺术,实际上内部逻辑已经扭曲到了四维空间。


// Copilot 生成的完美逻辑
if (user.isLogin) {
showProfile();
} else {
// 这里的逻辑有点奇怪,但我决定信任宇宙
loginUser(user);
logoutUser(user);
showProfile(); // 为什么?别问。
}

谁来背这口黑锅


让我们回到最本质的问题:为什么公司还需要你?
不是因为你打字比 AI 快,也不是因为你背的 API 比 AI 多。


是因为当生产环境崩溃,数据库被误删,客户在投诉电话里骂街的时候——
AI 是不能坐牢的。


只有你能。
只有你能红着眼睛,在凌晨三点盯着日志,颤抖着手回滚版本。
只有你能站在 CTO 面前,用颤抖的声音说:“是我的错,漏看了一个边界条件。”


背锅 (Accountability),才是碳基生物在硅基时代的核心竞争力。
这虽然听起来很悲哀,但却是最硬的护城河。


保持愤怒


所以,别再问“我会被取代吗”这种无聊的问题了。
只要法律还规定“法人”必须是人,只要服务器还需要物理重启,只要产品经理的需求还在反复横跳。
你就永远安全。


甚至,你的地位会不降反升。
因为在未来,能在一堆 AI 生成的垃圾代码里一眼看出 Bug 的人类鉴屎师,薪资会高得吓人。



现在,合上那些焦虑的营销号文章。去看看你的控制台,那个报错还在那里等你。它不像 AI,它很诚实,错了就是错了。



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

聊聊场景题:百万人同时点赞怎么办?这个怎么回答

大家发现了吧,现在面试八股文好像问的少了,反倒是场景题多了起来,毕竟现在AI如此强大,总揪着这点底层基础也没多大意思。 面试官张嘴闭嘴高并发、大数据量倒是真的,别管实际业务是不是高并发,但是你不会是进不来拧螺丝的。 就像之前有同学被问:“某音百万用户同时给一个...
继续阅读 »

大家发现了吧,现在面试八股文好像问的少了,反倒是场景题多了起来,毕竟现在AI如此强大,总揪着这点底层基础也没多大意思。


面试官张嘴闭嘴高并发、大数据量倒是真的,别管实际业务是不是高并发,但是你不会是进不来拧螺丝的。


就像之前有同学被问:“某音百万用户同时给一个视频点赞,让你来要怎么设计?”,这类题肯定见过吧。


咱们来简单拆解下这题,我是一个小学习,知识量有限,不喜勿喷。


这道题到底考察什么?


别上来就想用什么技术,先明确面试官的考察点,才能答到点子上:



  1. 高并发写入能力:百万人同时操作,瞬间 QPS 能冲到几十万,如何避免数据库被打垮?这是考察你对流量削峰的理解;

  2. 数据一致性:用户点赞后必须立刻看到 已赞 状态,点赞数可以有轻微延迟,但不能错、不能丢,这是对最终一致性的考察;

  3. 系统可用性:就算后端服务波动,用户点赞操作也得成功,不能出现点了没反应的情况,考察容错和降级思路;

  4. 资源优化:百万次请求直接怼数据库肯定不行,如何用缓存、消息队列等中间件减轻压力,考察技术选型能力。


换位思考


很多人一上来就纠结怎么让百万次点赞实时写入数据库,其实跑偏了。


咱们站在用户角度想:



  • 用户点击点赞后,最关心的是有没有点赞成功,而不是当前赞数到底是 10086 还是 10087

  • 赞数是给所有用户看的公共数据,轻微延迟用户完全感知不到(就算数据丢了,用户也很难发现,只是会想“咦”我之前点赞过一个视频没了,就没然后了);

  • 核心需求是:操作成功率 99% + 客户端状态实时反馈 + 赞数最终准确


想通这一点,方案就清晰了:把实时写入数据库的压力,转移到中间件上,用异步 + 缓存的思路解决高并发。


选取方案


咱们一步步拆解,从用户点击点赞按钮开始,整个流程是这样的:


1. 用户点赞:先写消息队列,客户端直接反馈成功


用户点击点赞的瞬间,客户端不会直接调用数据库接口,而是做两件事:



  • 向后端发送点赞请求,后端收到后,不操作数据库,直接把用户ID + 视频ID + 点赞状态(赞 / 取消赞)封装成一条消息,写入 Kafka;

  • Reids 记录 用户ID + 视频ID 的点赞状态,增加 视频ID 的赞数量

  • 只要消息成功写入 Kafka,后端就立刻返回点赞成功给前端,客户端马上显示已赞状态。


为啥选 Kafka 我就不说了。


2. 客户端:本地记录状态,避免重复点赞


客户端收到点赞成功后,除了显示已赞,还要在本地存储记录当前用户对该视频已点赞。


这样做的好处是:



  • 防止用户短时间内重复点击点赞,前端直接拦截,减少无效请求;

  • 就算后续缓存没更新,用户自己看到的状态也是准确的,不影响个人体验。


3. 查赞数:直接读 Redis,不用查数据库


其他用户查看视频时,需要显示赞数,这时候客户端会调用查询赞数接口,后端的处理逻辑是:



  • 不查数据库,直接从 Redis 里读取该视频的赞数缓存;

  • Redis 读性能极高,支持每秒几十万次查询,完全能扛住百万用户同时查看的压力;

  • 这里的赞数可能不是实时最新的,但只要延迟在可接受范围内,用户完全没感觉。


4. 后台任务:定时同步 Redis 和数据库,保证最终一致


这一步是兜底,负责把 Kafka 里的点赞消息处理掉,同时更新 Redis 和数据库:



  • 后端持续从 Kafka 里拉取点赞消息;

  • 启动一个定时任务,把 Redis 里所有视频的赞数,批量同步到数据库里;

  • 同步时要注意幂等性:比如用户先赞后取消,最终状态是未赞,避免重复计算导致赞数错误。


批量同步,攒一批数据(比如 1 万条)再批量更新,大大减少数据库的写入压力。


而且定时任务可以根据业务调整频率,比如高峰期每 1 分钟同步一次,低峰期每 10 分钟同步一次,灵活适配流量。


方案优势


这套方案没有复杂的架构,但的确能解决百万级点赞的高并发问题,核心优势在于几种中间件的组合使用:



  • 高可用:Kafka 保证消息不丢失,Redis 保证查询不卡顿,就算数据库暂时挂了,用户点赞和查赞数都不受影响;

  • 易扩展:如果后续点赞量涨到千万级,只需要增加 Kafka 的分区数、Redis 的集群节点,就能轻松扛住;

  • 低成本:不用复杂的分布式事务,不用实时计算框架,用最基础的中间件就能实现,开发和维护成本都低。


写在最后


其实很多高并发场景,比如点赞、评论、秒杀,核心思路都是异步解耦 + 缓存兜底。


面试官考察的不是你知道多少冷门技术,而是你能不能看透问题本质,用户要的是 体验成功,不是 实时准确


不过,这套方案看似简单,但覆盖了 “削峰、缓存、异步、最终一致性” 等核心考点,面试时把这个逻辑讲清楚,再结合 Kafka 的消息可靠性、Redis 的高性能、定时任务的批量处理,面试官起码会觉得你 懂行


如果实际业务中,赞数延迟要求极高(比如直播场景,需要实时显示赞数),也可以把定时同步改成 Kafka 消费后实时更新 Redis,数据库异步同步,本质还是换汤不换药~


作者:程序员小富
来源:juejin.cn/post/7576273949186932778
收起阅读 »

别再滥用 Base64 了——Blob 才是前端减负的正确姿势

web
一、什么是 Blob? Blob(Binary Large Object,二进制大对象)是浏览器提供的一种不可变、类文件的原始数据容器。它可以存储任意类型的二进制或文本数据,例如图片、音频、PDF、甚至一段纯文本。与 File 对象相比,Blob 更底层,Fi...
继续阅读 »

一、什么是 Blob?


Blob(Binary Large Object,二进制大对象)是浏览器提供的一种不可变、类文件的原始数据容器。它可以存储任意类型的二进制或文本数据,例如图片、音频、PDF、甚至一段纯文本。与 File 对象相比,Blob 更底层,File 实际上继承自 Blob,并额外携带了 namelastModified 等元信息 。


Blob 最大的特点是纯客户端、零网络:数据一旦进入 Blob,就活在内存里,无需上传服务器即可预览、下载或进一步加工。




二、构造一个 Blob:一行代码搞定


const blob = new Blob(parts, options);

参数说明
parts数组,元素可以是 StringArrayBufferTypedArrayBlob 等。
options可选对象,常用字段:
type MIME 类型,默认 application/octet-stream
endings 是否转换换行符,几乎不用。

示例:动态生成一个 Markdown 文件并让用户下载


const content = '# Hello Blob\n> 由浏览器动态生成';
const blob = new Blob([content], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);

const a = document.createElement('a');
a.href = url;
a.download = 'hello.md';
a.click();

// 内存用完即弃
URL.revokeObjectURL(url);



三、Blob URL:给内存中的数据一个“临时地址”


1. 生成方式


const url = URL.createObjectURL(blob);
// 返回值样例
// blob:https://localhost:3000/550e8400-e29b-41d4-a716-446655440000

2. 生命周期



  • 作用域:仅在当前文档、当前会话有效;页面刷新、close()、手动调用 revokeObjectURL() 都会使其失效 。

  • 性能陷阱:不主动释放会造成内存泄漏,尤其在单页应用或大量图片预览场景 。


最佳实践封装:


function createTempURL(blob) {
const url = URL.createObjectURL(blob);
// 自动 revoke,避免忘记
requestIdleCallback(() => URL.revokeObjectURL(url));
return url;
}



四、Blob vs. Base64 vs. ArrayBuffer:如何选型?


场景推荐格式理由
图片回显、<img>/<video>Blob URL浏览器可直接解析,无需解码;内存占用低。
小图标内嵌在 CSS/JSONBase64减少一次 HTTP 请求,但体积增大约 33%。
纯计算、WebAssembly 传递ArrayBuffer可写、可索引,适合高效运算。
上传大文件、断点续传Blob.slice流式分片,配合 File.prototype.slice 做断点续传 。



五、高频实战场景


1. 本地图片/视频预览(零上传)


<input type="file" accept="image/*" id="uploader">
<img id="preview" style="max-width: 100%">

<script>
uploader.onchange = e => {
const file = e.target.files[0];
if (!file) return;
const url = URL.createObjectURL(file);
preview.src = url;
preview.onload = () => URL.revokeObjectURL(url); // 加载完即释放
};
</script>

2. 将 Canvas 绘图导出为 PNG 并下载


canvas.toBlob(blob => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'snapshot.png';
a.click();
URL.revokeObjectURL(url);
}, 'image/png');

3. 抓取远程图片→Blob→本地预览(跨域需 CORS)


fetch('https://i.imgur.com/xxx.png', { mode: 'cors' })
.then(r => r.blob())
.then(blob => {
const url = URL.createObjectURL(blob);
document.querySelector('img').src = url;
});

若出现图片不显示,99% 是因为服务端未返回 Access-Control-Allow-Origin 头 。




六、踩坑指南与性能锦囊


坑点解决方案
内存暴涨每次 createObjectURL 后,务必在合适的时机 revokeObjectURL
跨域失败确认服务端开启 CORS;fetch 时加 {credentials: 'include'} 如需 Cookie。
移动端大视频卡顿避免一次性读完整文件,使用 blob.slice(start, end) 分段读取。
旧浏览器兼容IE10+ 才原生支持 Blob;如需更低版本,请引入 Blob.js 兼容库。



七、延伸:Blob 与 Stream 的梦幻联动


当文件超大(GB 级)时,全部读进内存并不现实。可以借助 ReadableStream 把 Blob 转为流,实现渐进式上传:


const stream = blob.stream(); // 返回 ReadableStream
await fetch('/upload', {
method: 'POST',
body: stream,
headers: { 'Content-Type': blob.type }
});

Chrome 85+、Edge 85+、Firefox 已经支持 blob.stream(),能以流式形式边读边传,内存占用极低。




八、总结:记住“三句话”



  1. Blob = 浏览器端的二进制数据仓库,File 只是它的超集。

  2. Blob URL = 指向内存的临时指针,用完后必须手动或自动释放。

  3. 凡是“本地预览、零上传、动态生成下载”的需求,优先考虑 Blob + Blob URL 组合。


用好 Blob,既能提升用户体验(秒开预览),又能降低服务端压力(无需中转),是每一位前端工程师的必备技能。


作者:404星球的猫
来源:juejin.cn/post/7573521516324896795
收起阅读 »

前特斯拉 AI 总监:AI 编程最大的谎言,是 “提效”

大家好,我是程序员鱼皮。 前两天,前特斯拉 AI 总监 Andrej Karpathy 在 X 上发了一条长帖子,内容是他最近几周大量使用 Claude 编程的感悟。 结果这条帖子直接爆了,阅读量超过 600 万。 先简单介绍一下『卡帕西』这位大佬:斯坦福 ...
继续阅读 »

大家好,我是程序员鱼皮。


前两天,前特斯拉 AI 总监 Andrej Karpathy 在 X 上发了一条长帖子,内容是他最近几周大量使用 Claude 编程的感悟。


结果这条帖子直接爆了,阅读量超过 600 万。



先简单介绍一下『卡帕西』这位大佬:斯坦福 AI 博士,师从李飞飞;OpenAI 创始成员之一;后来去特斯拉当了 AI 总监,负责自动驾驶的视觉系统。2024 年离开特斯拉后,他创办了 Eureka Labs,专注用 AI 做教育。


不夸张地说,他可能是全球最懂 AI、又最能写代码的人之一。


在 2023 年 1 月的时候,他就提出过:未来最热门的新编程语言是自然语言。



你现在回过头来看这句话,就知道这哥们有多牛皮了。


所以我每次看他分享的内容时,都会先沐浴更衣,让自己能够进入深度思考状态。



进入正题,这条帖子里有很多干货,但让我印象最深的是这句话:



我不太清楚如何衡量 AI 带来的加速。我感觉做事确实快了,但主要的效果是我做的比原计划多得多



卡帕西说,现在他可以随手写一些以前 “不值得写” 的小工具,也敢去碰以前因为技术栈不熟而不敢碰的代码了。



所以 AI 编程带来的核心变化不是加速,而是扩展。


我觉得这个观察太准了!


之前很多人问我:AI 编程能提效多少倍?


其实这个问题本身就问错了。AI 带来的真正变化不是 “同一件事做得更快”,而是 “你开始做以前根本不会做的事”。


这就像你以前骑自行车,现在换了辆车。你不会说自己骑车快了 10 倍,而是会说自己能去更远的地方了。


人比不过 AI 的一点


帖子里还有几个点挺有意思的,跟大家分享一下。


卡帕西说,看 AI 在一个 Bug 上死磕 30 分钟,不放弃、不气馁,最后真的搞定了 —— 这是他感受 AGI 的时刻。


我看到这段就想起自己大学刚学编程时改 Bug 的经历。已经是凌晨一两点,试了好几种方法都没用,我的心态已经崩了,甚至有点儿心绞痛,于是想着明天再说吧,狗命要紧……


但 AI 不会这样,只要你的 Tokens 足够,它会一直跟 Bug 死磕。


耐力这件事,正在从人类的瓶颈变成 AI 的优势。


当然,代价就是烧 Token。所以程序员的基本功还是很重要的,至少你得能判断这个 Bug 值不值得让 AI 花半小时去磕,怎么通过指引 AI 让它更快更省地解决问题。


编程变得更有趣了?


卡帕西说:用 AI 编程之后,那些填空式的苦差事没了,剩下的都是创造性的部分。所以反而觉得更好玩了。


但他也提到,有些程序员会觉得失去了乐趣。因为对他们来说,写代码本身就是快感来源。


这可能是一个分水岭:主要享受 “写代码” 的人,和主要享受 “造东西” 的人,体验会很不一样。


我看到一位 AI 圈的大 V 把这点称为 “程序员正在分裂成两个物种”。不过我倒觉得,这两类人其实一直都存在。有的人享受代码本身的优雅,追求技术的深度和细节,写出漂亮的代码会有成就感;有的人更在乎东西能不能跑起来、能不能解决问题,代码只是实现想法的工具。AI 只是把这个差异放大了而已 —— 前者可能会有点失落,后者则迎来了黄金时代。


我正在失去写代码的能力,但是…


卡帕西说:自己手动写代码的能力正在慢慢退化。


但是从他的话中能感受到,他对此的态度是 “已经不太在乎了”。


他给了一个有意思的视角,写代码(生成)和读代码(判别)是大脑里不同的能力。就像你可能写不出一首好诗,但能看出一首诗写得好不好。


编程也是一样。其实想想看,以前没有 AI 的时候,那些语法细节、API 用法,我们不也是靠查文档、利用编辑器的提示吗?真正需要记在脑子里的从来就不多。现在 AI 把这部分接管了,但代码的设计思路对不对、架构合不合理,还是得靠你自己判断。


所以未来程序员的角色,可能更像是 “技术导演” 而不是 “码农”。你负责把控方向、做出决策,AI 负责执行细节、填补空白。


2026 年垃圾内容会爆发,但是…


卡帕西还提到了一个词:Slopacolypse


我搜了一下,发现这其实是最近 AI 圈流行起来的一个 “slop 系列” 造词。Slop 指的是那些用 AI 批量生成的低质量内容,Slopacolypse 就是 Slop + Apocalypse,我理解是 “垃圾内容末日” 的意思。


他预测 2026 年,GitHub、各种社交媒体都会被 AI 生成的低质量内容淹没。当生产内容的门槛大幅降低,注意力反而会变得更稀缺。


但他也说,真正的改进也在同步发生。AI 的智能部分已经跑在前面了,现在反而是工具、流程、组织这些东西还没跟上。2026 年,整个行业会花大量精力去消化这波新能力。


说到这里,我想起自己身边的情况。AI 领域几乎每天都有新工具、新模型、新玩法冒出来,但真正意识到这些变化、真正去用这些新东西的人,又有多少呢?


我经常听到有人说 “再等几个月,等出了更好的再学”、“现在的还不够成熟”。但问题是,在你等待的这几个月里,已经有人用 AI 做出了以前做不到的东西,拉开了差距。


所以对于我们程序员来说,一方面必须要利用 AI 提升开发效率和优化工作流程。


另外一方面,不妨打开思路,多想一想:有了 AI,你能做到哪些以前做不到的事?


以前不敢碰的技术栈,现在敢试了;以前觉得不值得做的小工具,现在随手就能搞定;以前卡住就放弃的 bug,现在有个不知疲倦的助手帮你死磕。


这才是 AI 编程真正的红利 —— 不是让你更快,是让你更大。


如果你还没开始用 AI 编程,或者想系统学习怎么用的更好,可以看看我最新免费开源的 《AI 编程零基础入门教程》,从 0 开始带你用 AI 编程做出项目,包含各种工具用法、实战技巧、编程资源、甚至是产品变现经验全都有。希望能帮你更快地拥抱这个新时代,一起变得更大、更强!




更多


💻 编程学习交流:编程导航

📃 简历快速制作:老鱼简历

✏️ 面试刷题神器:面试鸭

📖 AI 学习指南:鱼皮 AI 导航


作者:程序员鱼皮
来源:juejin.cn/post/7602154088476672038
收起阅读 »

翻译:2026年了,直接用 PostgreSQL 吧

翻译:2026年了,直接用 PostgreSQL 吧 以下是 It’s 2026, Just Use Postgres | Tiger Data 的中文翻译 把你的数据库想象成你的家。家里有客厅、卧室、浴室、厨房和车库,每个房间用途不同,但都在同一屋檐下,由...
继续阅读 »

翻译:2026年了,直接用 PostgreSQL 吧


以下是 It’s 2026, Just Use Postgres | Tiger Data 的中文翻译




把你的数据库想象成你的家。家里有客厅、卧室、浴室、厨房和车库,每个房间用途不同,但都在同一屋檐下,由走廊和门相连。你不会因为需要做饭就单独盖一栋餐厅大楼,也不会为了停车而在城外另建一座商业车库。


PostgreSQL 就是这样的“家”——一个屋檐下容纳多个功能房间:搜索、向量、时序数据、队列……全部一体化。


而这恰恰是那些专用数据库厂商不愿让你知道的真相。他们的营销团队花了数年时间说服你“为不同任务选用合适的工具”。听起来很合理,很睿智,也确实卖出了大量数据库。


让我告诉你为什么这是个陷阱,以及为什么在 99% 的场景下,PostgreSQL 才是更优解。


“选用合适工具”的陷阱


你一定听过这样的建议:“为不同任务选用合适的工具。”


听起来很睿智。于是你最终拥有了:



  1. Elasticsearch 用于搜索

  2. Pinecone 用于向量检索

  3. Redis 用于缓存

  4. MongoDB 用于文档存储

  5. Kafka 用于消息队列

  6. InfluxDB 用于时序数据

  7. PostgreSQL 用于……剩下的杂项


恭喜你,现在你需要管理七个数据库。学习七种查询语言,维护七套备份策略,审计七种安全模型,轮换六组凭证,监控七个仪表盘,以及应对七个可能在凌晨三点崩溃的系统。


而当系统真的崩溃时?祝你好运——你得搭建一个包含全部七种数据库的测试环境来调试问题。


换个思路:直接用 PostgreSQL 吧。


为什么现在尤其重要:AI 时代


这不只是关于简化架构。AI 智能体已让数据库碎片化成为一场噩梦。


想想智能体需要做什么:



  • 快速用生产数据搭建测试数据库

  • 尝试修复或实验

  • 验证效果

  • 销毁环境


使用单一数据库?一条命令即可:Fork、测试、完成。


使用七个数据库?你需要:



  • 协调 PostgreSQL、Elasticsearch、Pinecone、Redis、MongoDB 和 Kafka 的快照

  • 确保所有数据处于同一时间点

  • 启动七种不同服务

  • 配置七组连接字符串

  • 祈祷测试过程中数据不发生漂移

  • 测试结束后销毁七种服务


没有大量研发投入,这几乎不可能实现。


这还不只是智能体的问题。每次凌晨三点系统崩溃,你都需要搭建测试环境调试。六个数据库意味着协调噩梦;一个数据库,只需一条命令。


在 AI 时代,简洁不只是优雅,更是必需。


“但专用数据库性能更好啊!”


我们直面这个问题。


迷思:专用数据库在其特定任务上远超通用方案。


现实:它们可能在狭窄场景下略占优势,但同时引入了不必要的复杂性。这就像为每顿饭都雇佣一位私人厨师——听起来奢华,实则增加成本、协调开销,并制造了本不存在的问题。


关键在于:99% 的公司根本不需要它们。那 1% 的顶级公司拥有数千万用户和与之匹配的庞大工程团队。你读过他们吹捧“专用数据库 X 如何惊艳”的博客,但那是他们的规模、他们的团队、他们的问题。对其他人而言,PostgreSQL 完全够用。


大多数人没意识到的是:PostgreSQL 扩展使用的算法与专用数据库相同甚至更优(很多情况下确实如此)。


所谓“专用数据库溢价”?大多是营销话术。


你的需求专用工具PostgreSQL 扩展算法是否相同?
全文搜索Elasticsearchpg_textsearch✅ 均使用 BM25
向量检索Pineconepgvector + pgvectorscale✅ 均使用 HNSW/DiskANN
时序数据InfluxDBTimescaleDB✅ 均使用时间分区
缓存RedisUNLOGGED 表✅ 均使用内存存储
文档MongoDBJSONB✅ 均使用文档索引
地理空间专用 GISPostGIS✅ 自 2001 年起的行业标准

这些不是缩水版实现,而是相同/更优的算法,经过实战检验、开源,并常由相同研究者开发。


基准测试也证实了这一点:



  • pgvectorscale:延迟比 Pinecone 低 28 倍,成本降低 75%

  • TimescaleDB:性能媲美或超越 InfluxDB,同时提供完整 SQL 支持

  • pg_textsearch:与 Elasticsearch 相同的 BM25 排序算法


隐性成本不断累积


除 AI/智能体问题外,数据库碎片化还带来复合成本:


任务单一数据库七个数据库
备份策略1 套7 套
监控仪表盘1 个7 个
安全补丁1 次7 次
值班手册1 份7 份
故障转移测试1 次7 次

认知负荷:团队需掌握 SQL、Redis 命令、Elasticsearch Query DSL、MongoDB 聚合、Kafka 模式、InfluxDB 的非原生 SQL 变通方案。这不是专业化,这是碎片化


数据一致性:保持 Elasticsearch 与 PostgreSQL 同步?你需要构建同步作业。它们会失败,数据会漂移,你得添加对账逻辑。对账也会失败。最终你维护的是基础设施,而非产品功能。


SLA 数学:三个系统各自 99.9% 可用性 = 整体 99.7%。这意味着每年26 小时停机时间,而非 8.7 小时。每个系统都在成倍增加故障模式。


现代 PostgreSQL 技术栈


这些扩展并非新生事物,它们已生产就绪多年:



  • PostGIS:自 2001 年(24 年),支撑 OpenStreetMap 和 Uber

  • 全文搜索:自 2008 年(17 年),内置于 PostgreSQL 核心

  • JSONB:自 2014 年(11 年),性能媲美 MongoDB 且支持 ACID

  • TimescaleDB:自 2017 年(8 年),GitHub 超 2.1 万星

  • pgvector:自 2021 年(4 年),GitHub 超 1.9 万星


超过 48,000 家公司使用 PostgreSQL,包括 Netflix、Spotify、Uber、Reddit、Instagram 和 Discord。


AI 时代的新一代扩展


扩展替代方案亮点
pgvectorscalePinecone, QdrantDiskANN 算法,延迟降低 28 倍,成本降低 75%
pg_textsearchElasticsearch原生支持 BM25 排序
pgai外部 AI 流水线数据变更时自动同步嵌入向量

这意味着什么:过去构建 RAG 应用需要 PostgreSQL + Pinecone + Elasticsearch + 胶水代码。


现在?只需 PostgreSQL。一个数据库,一种查询语言,一套备份方案,一条 Fork 命令即可让 AI 智能体搭建测试环境。


快速上手:启用这些扩展


只需执行以下命令:


-- 全文搜索(BM25)
CREATE EXTENSION pg_textsearch;

-- 向量检索(AI 场景)
CREATE EXTENSION vector;
CREATE EXTENSION vectorscale;

-- AI 嵌入与 RAG 工作流
CREATE EXTENSION ai;

-- 时序数据
CREATE EXTENSION timescaledb;

-- 消息队列
CREATE EXTENSION pgmq;

-- 定时任务
CREATE EXTENSION pg_cron;

-- 地理空间
CREATE EXTENSION postgis;

就是这么简单。


代码示例


以下是各场景的可运行示例,按需查阅。


全文搜索(替代 Elasticsearch)


扩展:pg_textsearch(真正的 BM25 排序)


替代对象:



  • Elasticsearch:独立 JVM 集群、复杂映射、同步流水线、Java 堆调优

  • Solr:类似问题,仅包装不同

  • Algolia:$1/1000 次搜索,依赖外部 API


你将获得:与 Elasticsearch 完全相同的 BM25 算法,直接内置于 PostgreSQL。


-- 创建表
CREATE TABLE articles (
id SERIAL PRIMARY KEY,
title TEXT,
content TEXT
);

-- 创建 BM25 索引
CREATE INDEX idx_articles_bm25 ON articles USING bm25(content)
WITH (text_config = 'english');

-- 基于 BM25 评分搜索
SELECT title, -(content <@> 'database optimization') as score
FROM articles
ORDER BY content <@> 'database optimization'
LIMIT 10;

混合搜索:BM25 + 向量一体化查询


SELECT 
title,
-(content <@> 'database optimization') as bm25_score,
embedding <=> query_embedding as vector_distance,
0.7 * (-(content <@> 'database optimization')) +
0.3 * (1 - (embedding <=> query_embedding)) as hybrid_score
FROM articles
ORDER BY hybrid_score DESC
LIMIT 10;

Elasticsearch 需要额外插件才能实现的功能,在 PostgreSQL 中只需一条 SQL。


向量检索(替代 Pinecone)


扩展:pgvector + pgvectorscale


替代对象:



  • Pinecone:$70/月起步,独立基础设施,数据同步头痛

  • Qdrant, Milvus, Weaviate:更多需管理的基础设施


你将获得:pgvectorscale 采用微软研究院的 DiskANN 算法,在 99% 召回率下实现延迟降低 28 倍、吞吐量提升 16 倍


-- 启用扩展
CREATE EXTENSION vector;
CREATE EXTENSION vectorscale CASCADE;

-- 含嵌入向量的表
CREATE TABLE documents (
id SERIAL PRIMARY KEY,
content TEXT,
embedding vector(1536)
);

-- 高性能索引(DiskANN)
CREATE INDEX idx_docs_embedding ON documents USING diskann(embedding);

-- 查找相似文档
SELECT content, embedding <=> '[0.1, 0.2, ...]'::vector as distance
FROM documents
ORDER BY embedding <=> '[0.1, 0.2, ...]'::vector
LIMIT 10;

通过 pgai 自动同步嵌入向量


SELECT ai.create_vectorizer(
'documents'::regclass,
loading => ai.loading_column(column_name=>'content'),
embedding => ai.embedding_openai(model=>'text-embedding-3-small', dimensions=>'1536')
);

现在每次 INSERT/UPDATE 都会自动重新生成嵌入向量。无需同步作业,无数据漂移,告别凌晨三点的告警电话。


时序数据(替代 InfluxDB)


扩展:TimescaleDB(GitHub 2.1 万+ 星)


替代对象:



  • InfluxDB:独立数据库、Flux 查询语言或非原生 SQL

  • Prometheus:适用于指标,不适用于应用数据


你将获得:自动时间分区、最高 90% 压缩率、连续聚合,完整 SQL 支持。


-- 启用 TimescaleDB
CREATE EXTENSION timescaledb;

-- 创建表
CREATE TABLE metrics (
time TIMESTAMPTZ NOT NULL,
device_id TEXT,
temperature DOUBLE PRECISION
);

-- 转换为超表
SELECT create_hypertable('metrics', 'time');

-- 按时间桶查询
SELECT time_bucket('1 hour', time) as hour,
AVG(temperature)
FROM metrics
WHERE time > NOW() - INTERVAL '24 hours'
GR0UP BY hour;

-- 自动删除旧数据
SELECT add_retention_policy('metrics', INTERVAL '30 days');

-- 压缩(存储减少 90%)
ALTER TABLE metrics SET (timescaledb.compress);
SELECT add_compression_policy('metrics', INTERVAL '7 days');

缓存(替代 Redis)


特性:UNLOGGED 表 + JSONB


-- UNLOGGED = 无 WAL 开销,写入更快
CREATE UNLOGGED TABLE cache (
key TEXT PRIMARY KEY,
value JSONB,
expires_at TIMESTAMPTZ
);

-- 设置带过期时间的缓存
INSERT INTO cache (key, value, expires_at)
VALUES ('user:123', '{"name": "Alice"}', NOW() + INTERVAL '1 hour')
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;

-- 读取
SELECT value FROM cache WHERE key = 'user:123' AND expires_at > NOW();

-- 清理(通过 pg_cron 定时)
DELETE FROM cache WHERE expires_at < NOW();

消息队列(替代 Kafka)


扩展:pgmq


CREATE EXTENSION pgmq;
SELECT pgmq.create('my_queue');

-- 发送消息
SELECT pgmq.send('my_queue', '{"event": "signup", "user_id": 123}');

-- 接收消息(带可见性超时)
SELECT * FROM pgmq.read('my_queue', 30, 5);

-- 处理完成后删除
SELECT pgmq.delete('my_queue', msg_id);

或使用原生 SKIP LOCKED 模式


CREATE TABLE jobs (
id SERIAL PRIMARY KEY,
payload JSONB,
status TEXT DEFAULT 'pending'
);

-- Worker 原子性认领任务
UPDATE jobs SET status = 'processing'
WHERE id = (
SELECT id FROM jobs WHERE status = 'pending'
FOR UPDATE SKIP LOCKED LIMIT 1
) RETURNING *;

文档存储(替代 MongoDB)


特性:原生 JSONB


CREATE TABLE users (
id SERIAL PRIMARY KEY,
data JSONB
);

-- 插入嵌套文档
INSERT INTO users (data) VALUES ('{
"name": "Alice",
"profile": {"bio": "Developer", "links": ["github.com/alice"]}
}'
);

-- 查询嵌套字段
SELECT data->>'name', data->'profile'->>'bio'
FROM users
WHERE data->'profile'->>'bio' LIKE '%Developer%';

-- 为 JSON 字段创建索引
CREATE INDEX idx_users_email ON users ((data->>'email'));

地理空间(替代专用 GIS)


扩展:PostGIS


CREATE EXTENSION postgis;

CREATE TABLE stores (
id SERIAL PRIMARY KEY,
name TEXT,
location GEOGRAPHY(POINT, 4326)
);

-- 查找 5 公里内的门店
SELECT name, ST_Distance(location, ST_MakePoint(-122.4, 37.78)::geography) as meters
FROM stores
WHERE ST_DWithin(location, ST_MakePoint(-122.4, 37.78)::geography, 5000);

定时任务(替代 Cron)


扩展:pg_cron


CREATE EXTENSION pg_cron;

-- 每小时执行
SELECT cron.schedule('cleanup', '0 * * * *',
$$DELETE FROM cache WHERE expires_at < NOW()$$);

-- 每日凌晨 2 点汇总
SELECT cron.schedule('rollup', '0 2 * * *',
$$REFRESH MATERIALIZED VIEW CONCURRENTLY daily_stats$$);

核心结论


回到“家”的比喻:你不会为做晚饭单独盖餐厅,也不会为停车在城外建车库。你会使用家中已有的房间。


这正是我们在此展示的:搜索、向量、时序、文档、队列、缓存……它们都是 PostgreSQL 这座“家”中的不同房间。使用与专用数据库相同的算法,历经多年实战检验,被 Netflix、Uber、Discord 及 48,000 多家公司采用。


那么那 1% 的例外呢?


99% 的公司,PostgreSQL 足以应对所有需求。那 1%?当你需要跨数百节点处理 PB 级日志,或必须使用 Kibana 特定仪表盘,或拥有 PostgreSQL 确实无法满足的特殊需求时。


但关键在于:当你属于那 1% 时,你自己会知道。你不需要厂商营销团队告诉你,你会通过基准测试亲自撞上真正的性能墙。


在此之前,不要因为“为不同任务选用合适工具”这句话,就把数据分散到七栋大楼中。那句建议卖出了数据库,却没为你服务。


从 PostgreSQL 开始,坚持使用 PostgreSQL。仅在真正需要时才增加复杂性。


2026 年了,直接用 PostgreSQL 吧。


立即开始


所有这些扩展在 Tiger Data 上均可使用。几分钟内创建免费数据库:


psql "postgresql://user:pass@your-instance.tsdb.cloud.timescale.com:5432/tsdb"

CREATE EXTENSION pg_textsearch;  -- BM25 搜索
CREATE EXTENSION vector; -- 向量检索

无需专用数据库,只需 PostgreSQL。


延伸阅读



作者:Juchecar
来源:juejin.cn/post/7605985547578195974
收起阅读 »

当 Gemini 3 能写出完美 CSS 时,前端工程师剩下的核心竞争力是什么?

兄弟们,咱们的护城河越来越窄了😭 Gemini 3 的发布会,大家看了没? 我是在被窝里看完的。看完之后,我直接失眠了。 以前我觉得 AI 写代码也就那样,写个 Todo List 还行,真要上业务逻辑,它就得幻觉给你看😒。 但 Google 秀的这一手,真的...
继续阅读 »

google-gemini-3-inc.webp


兄弟们,咱们的护城河越来越窄了😭


Gemini 3 的发布会,大家看了没?


我是在被窝里看完的。看完之后,我直接失眠了。


以前我觉得 AI 写代码也就那样,写个 Todo List 还行,真要上业务逻辑,它就得幻觉给你看😒。


但 Google 秀的这一手,真的有点不讲武德


我出于好奇心,用 Google Al Studio 试了一下几个经典的需求, 直接把飞书需求文档扔给它(纯文案)👇:


Recall landing page
1. 页脚的recalls跳转新的recall landing page
[图片]
2. 页面内容
标题:Product Recalls

两个内容模块,点击后跳转至各自详情页
第一个:
2025 Fat Tire Trike Recall Notice
Pedego has issued a safety recall for Fat Tire Trikes due to a potential frame fracture near a weld that may pose fall or injury risks. Affected owners are eligible for a free repair, completed by a local Pedego dealer.
Learn more (可点击,跳转至Fat Tire Trike Recall Page)

第二个:
2021 Cable Recall Notice
Pedego is voluntarily recalling select e-bike models sold from January 2018 to August 2020 due to a cable issue that may cause unexpected acceleration. Affected owners should stop riding and register for a free safety repair.
Learn more(可点击,跳转至https://www.pedegobikerecall.expertinquiry.com/?_gl=1*1hzkwd0*_gcl_au*MTkxNDc4ODEuMTc2MzM0NDUyMA..*_ga*MTM1MzU3NTAzOC4xNzQ1OTE1NTcz*_ga_4K15HG6FFG*czE3NjQ4MzQ5MDAkbzQyJGcwJHQxNzY0ODM0OTAxJGo1OSRsMCRoMA..*_ga_FGPZTS4D91*czE3NjQ4MzQ5MDAkbzQyJGcwJHQxNzY0ODM0OTAxJGo1OSRsMCRoMA..)
[图片]



Fat Tire Trike Recall Page
标题:Pedego Recalls Fat Tire Trike Due to Fall and Laceration Hazards
[插入几张Fat Tire Trike图片]

页面主体内容
Name of Product: Pedego Fat Tire Trike
Hazard: The trike frame can develop a hairline fracture near a weld, which can cause the tube to break, posing fall and laceration hazards.
Units Affected: Serial Number Range: D2312050001 - D2312050522

按钮:REGISTER NOW (点击后跳转至页面下方注册表单)

How is Pedego making this right?
Pedego is offering you a free repair of your Fat Tire Trike. We have reengineered and strengthened the section of the frame in question. Once you register, we will ship a repair part to a local Pedego dealer that you select using the registration form.
We will ship the part to the dealer. The Pedego dealer will repair the Fat Tire Trike free of charge. There are no charges or fees associated with this recall.
You will be contacted when your part is received at the Pedego store for installation.

Make sure that other members of your household also know about the recall and immediately stop using it. Secure your Fat Tire Trike so that it cannot be ridden until it is repaired.
We strongly encourage you to participate and contact us to obtain a free repair.

Register for the free repair of your Fat Tire Trike
First Name*
Last Name*
Email*
Phone number*
Dealer Where you’d like the repair to take place * [Perhaps Preload options or provide location search for dealer(这里有没有可能提供选项让消费者选择?或者搜索地址?)]
State*
Zip Code*
Country*

[] * I hereby affirm that the information I have provided is accurate and correct, and that I have complied with all requirements of the above-referenced recall for seeking a repair of my Fat Tire Trike.

Submit(提交按钮)

成功提交后显示:
Thank you. Your registration has been submitted and is being processed.
We will notify you when the parts ship to the dealer. The dealer will install and repair your Fat Tire Trike free of charge.

[所提交信息在这里展示]

Please print this page for your records.

我刚准备点根烟的工夫,页面 UI 就出来了👇。


image.png


image.png


不是那种满屏 div 的垃圾代码,是语义化极好、组件拆分合理、甚至连 dark mode 都给配好了的成品,根本不需要改什么😖。


我看着屏幕上自己刚写了一半、还在纠结 flex-basis 该给多少的样式文件,突然觉得:这几年的代码,好像白写了。



AI 虽然能秒出 UI,但要转变成可维护的工程资产,还得靠架构能力。试试 RollCode 低代码平台,用 私有化部署 承载核心业务,通过 自定义组件 封装复杂逻辑,配合 静态页面发布(SSG + SEO),让 AI 的产出真正落地。



我最新的 个人主页也是用 Gemini 3 重写的,这审美,这效率,没得说!太强了👏




切图仔的时代正式终结了


以前咱总开玩笑说自己是切图仔,其实心里还是有点傲气的: 你以为 CSS 容易啊?BFC、层叠上下文、响应式断点、不同内核的兼容性...这玩意儿水深着呢!


但 Gemini 3 这种级别的 AI 出来,直接把这层傲气给降维打击了。



  • 比速度? 你调一个布局要半小时,它只要 3 秒。

  • 比审美? 它学习了全球数亿个精美网页,配出的视觉UI 把你那程序员审美甩出几条街。

  • 比稳定性? 它不会写错单词,也不会漏写分号,更不会因为下午跟产品经理吵了架就故意在代码里埋坑。


说实话,在实现视觉稿这件事上,人类已经输了。彻底输了!!!😭


如果你的核心竞争力就是能把 UI 图 1:1 还原成网页,那你的职业生涯确实已经进入倒计时了。




既然 CSS 成了废话,那我们还剩什么?


既然 AI 能写出完美的 CSS,甚至连交互动画都能一句话生成,那公司凭啥还花几万块招个前端?


我想了半宿,觉得咱们前端老哥的保命牌,其实正在从手艺转向上层建筑:


培养自己的架构设计能力


AI 可以给你砌出一面完美的墙,但它不知道这面墙该立在什么位置。


一个大型项目里:



  • 组件怎么拆分最利于复用?

  • 目录结构怎么设计才不会让后来的人骂娘?

  • 全局状态是用 Zustand 还是直接原生 Context 梭哈?


这些涉及到工程化决策的东西,AI 目前还是个弟弟。它只能给你局部的最优解,给不了你全局的架构观。


处理那些只有人能理解的业务


AI 最怕的是什么?是逻辑的混沌



用户如果连续点击三次,要触发一个彩蛋,但如果他是 VIP 且余额不足,这个彩蛋要换成充值提醒,顺便还得防止接口重放。



这种只有人类产品经理拍脑袋想出来的、逻辑转了十八道弯的边缘 Case,AI 极其容易写出 Bug。


搞定复杂的异步流,搞定恶心的竞态条件,搞定各种各样的降级策略——这才是你领工资的真正理由。


驾驭 AI 的能力(这应该是 2026 年的高频面试题)


以前面试问:CSS 怎么实现三角形?


以后面试可能问:如何用一句 Prompt,让 Gemini 3 输出一个符合公司私有 UI 规范、且通过了 E2E 测试的复杂组件?


AI 不是你的敌人,它可是你的好伙伴。


别人还在用手敲代码时,你已经学会利用AI 提升工作效率。你的核心竞争力,就是你 调教 AI 的水平。




没必要焦虑,这是超级个体的开始


咱们有木有可能换一种思路🤔。


以前我们想做个自己的副业项目,最头疼的是什么?UI 和 CSS。


对于我们这种逻辑强、审美弱的后端型前端,调样式简直是要了亲命。


现在 Gemini 3 这种东西出来了,简直是送福利。



  • 后端: 让 AI 帮你生成 Schema 和基础 CRUD。

  • UI/CSS: 丢张草图给 Gemini 3。

  • 前端框架: 让 AI 帮你写好骨架。


你一个人,就是一个超级个体。


以前我们需要在大厂里卷,是因为大厂有资源、有配套。


现在 AI 把资源门槛抹平了。在这个代码非常廉价的时代,你的创意、你的产品意识、你的解决问题能力,反而变得更值钱了。




Gemini 3 确实很猛,猛到让人怀疑人生,猛得一塌糊涂!😖


但我相信,只要互联网还需要服务,前端这个角色就不会消失。它只是从体力活进化成了脑力活。


别纠结那几个 marginpadding 了,去研究架构,去深挖性能,去学习怎么让 AI 给你当牛马。


只要你跑得比 AI 进化的速度快,你就不是被淘汰的那一个。


最后默默的问大家一句🤣:


如果明天你的老板让你裁掉团队一半的前端,只留下那些会用 AI 的,你会是在名单里的那个人吗?


欢迎👏顺便说说你被 Gemini 3 惊吓到的瞬间😁。


作者:ErpanOmer
来源:juejin.cn/post/7588837042014060570
收起阅读 »

小红书也有skills啦!rednote-skills 开源项目深度解析

开源项目地址:github.com/MrMao007/re… 引言 在当今数字化时代,社交媒体平台如小红书已成为内容创作者和品牌推广的重要阵地。为了更高效地进行内容运营、数据分析和互动管理,我最近研究了一个名为 rednote-skills 的开源项目。这个项...
继续阅读 »

开源项目地址:github.com/MrMao007/re…


引言


在当今数字化时代,社交媒体平台如小红书已成为内容创作者和品牌推广的重要阵地。为了更高效地进行内容运营、数据分析和互动管理,我最近研究了一个名为 rednote-skills 的开源项目。这个项目提供了一套完整的工具集,能够实现对小红书平台的自动化交互,从搜索、内容提取到互动操作,功能十分全面。


本文将深入分析 rednote-skills 的架构设计、核心功能以及实现原理,希望能为对社交媒体自动化感兴趣的开发者提供参考。


项目概述


rednote-skills 是一个基于 Python 和 Playwright 的开源工具包,专门用于与小红书(xiaohongshu)平台进行自动化交互。它支持多种功能,包括:



  • 笔记搜索:根据关键词搜索小红书笔记

  • 内容提取:将指定笔记转换为结构化 Markdown 格式

  • 互动操作:点赞、收藏、评论、关注等

  • 内容发布:自动发布图文笔记


该项目最大的亮点是其作为 Claude Code 插件的能力,可以直接集成到 AI 开发环境中,让开发者通过自然语言指令来执行复杂的社交媒体操作。


技术架构分析


核心依赖


项目主要依赖于 Playwright 库,这是一个强大的浏览器自动化工具。通过模拟真实用户的浏览器行为,项目能够绕过小红书的一些反爬虫机制。


from playwright.sync_api import sync_playwright

Playwright 提供了同步和异步两种 API,项目采用了同步 API,使得代码逻辑更加清晰易懂。


认证机制


项目采用 Cookie-based 认证方式,将认证信息存储在 rednote_cookies.json 文件中:


try: 
context = browser.new_context(storage_state="rednote_cookies.json")
except FileNotFoundError:
return "❌ 未找到 cookies 文件,请先登录小红书并保存 cookies"

这种设计既保证了会话的持久性,又提供了手动登录的灵活性。validate_cookies.pymanual_login.py 脚本分别负责验证登录状态和处理手动登录流程。


浏览器管理


每个脚本都遵循相同的模式:启动浏览器 -> 加载上下文 -> 执行操作 -> 关闭资源:


with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=False)
context = browser.new_context(storage_state="rednote_cookies.json")
page = context.new_page()
# ... 执行具体操作 ...
context.close()
browser.close()

核心功能详解


1. 笔记搜索功能


search_note_by_key_word.py 实现了关键词搜索功能:


def search(key_word: str, top_n: int) -> list[str]:
with sync_playwright() as playwright:
browser =playwright.chromium.launch(headless=True)
# ... 验证登录 ...
page.goto("https://www.xiaohongshu.com/search_result?keyword=" + key_word)
page.wait_for_timeout(3000)

prefix = 'https://www.xiaohongshu.com'
links = page.query_selector_all('a.cover.mask.ld')
# 获取所有 href 属性
hrefs = []
for link in links:
href = link.get_attribute('href')
if href:
href = prefix + href
hrefs.append(href)
if len(hrefs) >= top_n:
break
return hrefs

这个函数通过选择器 'a.cover.mask.ld' 来定位搜索结果中的笔记链接,并限制返回数量以提高效率。


2. 内容提取功能


dump_note.py 是项目中最复杂也是最有价值的功能之一。它不仅提取文本内容,还获取图片、视频、互动数据等:


note_data = page.evaluate("""
() => {
const noteDetailMap = window.__INITIAL_STATE__?.note?.noteDetailMap;
if (noteDetailMap) {
const firstKey = Object.keys(noteDetailMap)[0];
return JSON.stringify(noteDetailMap[firstKey]?.note);
}
return null;
}
"""
)

通过直接访问页面的 window.__INITIAL_STATE__ 变量,脚本能够获取到完整的笔记 JSON 数据,避免了复杂的 DOM 解析。


3. 互动操作实现


项目中的互动功能(点赞、收藏、评论、关注)实现思路相似,都是通过定位特定元素并触发点击事件:


# 点赞操作
page.locator(".left > .like-wrapper > .like-lottie").click()

# 收藏操作
page.locator(".reds-icon.collect-icon").click()

# 评论操作
page.locator(".chat-wrapper > .reds-icon").click()
page.locator("#content-textarea").fill(comment_text)
page.get_by_role("button", name="发送").click()

这些选择器是通过实际测试确定的,反映了小红书当前的 DOM 结构。


4. 内容发布功能


publish_note.py 实现了笔记发布的完整流程,这是整个项目最复杂的部分:


def publish_text(image_urls: List[str], title: str, content: str, tags: List[str]) -> str:
# ... 初始化浏览器和验证登录 ...

page.get_by_role("button", name="创作中心").hover()
with page.expect_popup() as page1_info:
page.get_by_role("link", name="创作服务").click()

page1 = page1_info.value
page1.get_by_text("发布图文笔记").click()

# 处理文件上传
page1.on("filechooser", lambda file_chooser: file_chooser.set_files(rednoteArticle.image_urls))

# 填写表单内容
page1.get_by_role("textbox", name="填写标题会有更多赞哦").fill(rednoteArticle.title)
final_content = rednoteArticle.content + "\n\n" + "\n".join([f"#{tag}" for tag in rednoteArticle.tags])
page1.get_by_role("paragraph").filter(has_text=re.compile(r"^$")).fill(final_content)

# 最终发布
page1.get_by_role("button", name="发布").click()

发布功能需要处理多步骤导航、文件上传、表单填写等多个复杂操作。


设计模式与最佳实践


统一的错误处理


每个脚本都实现了统一的错误处理逻辑,检查登录状态并返回相应的错误信息:


login_button = page.locator("form").get_by_role("button", name="登录")
if(login_button.is_visible()):
return "❌ 未登录小红书,请先登录"

模块化设计


项目将不同功能分解为独立的 Python 脚本,每个脚本都可以独立运行,同时也便于集成到更大的系统中。


命令行接口


所有脚本都提供了清晰的命令行接口,使用 argparse 进行参数解析,方便在各种环境下调用。


使用场景与价值


1. 内容运营自动化


对于内容运营人员来说,这个工具可以显著提升工作效率:



  • 自动搜索相关话题的内容

  • 批量收集竞品账号的数据

  • 自动发布内容减少重复劳动


2. 数据分析与研究


研究人员可以利用这个工具收集小红书上的公开数据,用于:



  • 社交媒体趋势分析

  • 用户行为研究

  • 文本情感分析


3. AI 辅助创作


结合 Claude Code 等 AI 平台,开发者可以通过自然语言指令控制小红书操作,实现智能化的内容管理和互动。


注意事项与建议


合规性考虑


在使用这类工具时,必须严格遵守小红书的使用条款和服务协议,避免:



  • 频繁操作导致账号受限

  • 发布违规内容

  • 侵犯他人隐私权


性能优化


由于依赖浏览器自动化,项目在性能方面有一些局限性:



  • 操作速度相对较慢

  • 占用较多系统资源

  • 可能受网络状况影响


维护成本


小红书平台界面可能会更新,这要求定期维护选择器和操作流程,确保工具持续可用。


总结


rednote-skills 是一个功能强大且设计良好的小红书自动化工具集。它通过 Playwright 实现了对小红书平台的全面自动化操作,为内容运营、数据分析等场景提供了便利。


项目的架构设计合理,模块化程度高,易于扩展和维护。虽然存在一些性能和合规性方面的考虑,但其价值仍然不容忽视。


对于希望深入了解浏览器自动化、社交媒体 API 模拟或 AI 辅助开发的开发者来说,这个项目是一个很好的学习案例。它展示了如何将复杂的网页交互封装成简单易用的命令行工具,值得我们深入研究和借鉴。


作者:MrMao007
来源:juejin.cn/post/7605421123186229275
收起阅读 »

SpringBoot接口防抖大作战,拒绝“手抖”重复提交!

大家好,我是小悟。 一、什么是接口防抖?(又名:救救那个手抖的程序员) 想象一下这个场景:用户小张在提交订单时,因为网络延迟,他以为没点中那个“提交”按钮,于是疯狂连击了10次!结果...10个一模一样的订单诞生了! 接口防抖 就像是给按钮加上了一层“冷静期”...
继续阅读 »

大家好,我是小悟。


一、什么是接口防抖?(又名:救救那个手抖的程序员)


想象一下这个场景:用户小张在提交订单时,因为网络延迟,他以为没点中那个“提交”按钮,于是疯狂连击了10次!结果...10个一模一样的订单诞生了!


接口防抖 就像是给按钮加上了一层“冷静期”——“兄弟,你点太快了,先冷静3秒再说!”


防止重复提交 则是更严格的保安大哥——“同样的身-份-证(请求)只能进一次,想蒙混过关?没门!”


下面我来教你在SpringBoot中布下天罗地网,拦截这些“手抖攻击”!




二、实战方案大集合


方案1:前端防抖 + 后端令牌锁(双保险)


前端防抖代码(JavaScript版):


// 给按钮加个“冷静debuff”
let isSubmitting = false;

function submitOrder() {
if (isSubmitting) {
alert("客官您点得太快了,喝口茶歇歇~");
return;
}

isSubmitting = true;
// 提交请求...

// 3秒后才能再次点击
setTimeout(() => {
isSubmitting = false;
}, 3000);
}

后端令牌锁实现:


步骤1:创建防抖注解


@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface PreventDuplicateSubmit {
/**
* 防抖时间(秒),默认3秒
*/

int lockTime() default 3;

/**
* 锁的key,支持SpEL表达式
*/

String key() default "";

/**
* 提示信息
*/

String message() default "请勿重复提交";
}

步骤2:实现AOP切面


@Aspect
@Component
@Slf4j
public class DuplicateSubmitAspect {

@Autowired
private RedisTemplate<String, Object> redisTemplate;

@Autowired
private HttpServletRequest request;

@Pointcut("@annotation(preventDuplicateSubmit)")
public void pointcut(PreventDuplicateSubmit preventDuplicateSubmit) {
}

@Around("pointcut(preventDuplicateSubmit)")
public Object around(ProceedingJoinPoint joinPoint,
PreventDuplicateSubmit preventDuplicateSubmit) throws Throwable {

// 1. 构造锁的key
String lockKey = buildLockKey(joinPoint, preventDuplicateSubmit);

// 2. 尝试加锁(setnx操作)
Boolean success = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "LOCKED",
preventDuplicateSubmit.lockTime(), TimeUnit.SECONDS);

if (Boolean.TRUE.equals(success)) {
// 加锁成功,执行方法
try {
return joinPoint.proceed();
} finally {
// 可以根据业务决定是否立即删除锁
// redisTemplate.delete(lockKey);
}
} else {
// 加锁失败,说明重复提交了
throw new RuntimeException(preventDuplicateSubmit.message());
}
}

private String buildLockKey(ProceedingJoinPoint joinPoint,
PreventDuplicateSubmit annotation) {
StringBuilder keyBuilder = new StringBuilder("SUBMIT:LOCK:");

// 如果有自定义key
if (StringUtils.isNotBlank(annotation.key())) {
keyBuilder.append(parseKey(joinPoint, annotation.key()));
} else {
// 默认使用:方法名 + 用户ID + 参数hash
keyBuilder.append(joinPoint.getSignature().toShortString());

// 加上用户ID(如果有登录)
String userId = getCurrentUserId();
if (userId != null) {
keyBuilder.append(":").append(userId);
}

// 加上参数摘要
Object[] args = joinPoint.getArgs();
if (args.length > 0) {
String argsHash = DigestUtils.md5DigestAsHex(
Arrays.deepToString(args).getBytes()
).substring(0, 8);
keyBuilder.append(":").append(argsHash);
}
}

return keyBuilder.toString();
}

private String getCurrentUserId() {
// 从Token或Session中获取用户ID
// 这里简化处理
return (String) request.getSession().getAttribute("userId");
}
}

步骤3:使用示例


@RestController
@RequestMapping("/order")
public class OrderController {

@PostMapping("/create")
@PreventDuplicateSubmit(lockTime = 5, message = "订单正在处理中,请勿重复提交")
public ApiResult createOrder(@RequestBody OrderDTO orderDTO) {
// 业务逻辑
orderService.create(orderDTO);
return ApiResult.success("下单成功");
}

@PostMapping("/pay")
@PreventDuplicateSubmit(
key = "'PAY:' + #orderNo + ':' + T(com.example.util.UserUtil).getCurrentUserId()",
lockTime = 10,
message = "支付请求已提交,请勿重复操作"
)
public ApiResult payOrder(String orderNo) {
// 支付逻辑
return ApiResult.success("支付成功");
}
}

方案2:数据库唯一约束(最硬核的方案)


有时候,最简单的最有效!


@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

// 业务唯一号:时间戳 + 用户ID + 随机数
@Column(name = "order_no", unique = true, nullable = false)
private String orderNo;

// 或者使用请求ID作为防重
@Column(name = "request_id", unique = true)
private String requestId;

// ...其他字段
}

@Service
@Slf4j
public class OrderService {

@Transactional(rollbackFor = Exception.class)
public void createOrder(OrderDTO dto) {
// 生成唯一请求ID(前端传递或后端生成)
String requestId = dto.getRequestId();
if (StringUtils.isBlank(requestId)) {
requestId = UUID.randomUUID().toString();
}

// 检查是否已处理过该请求
if (orderRepository.existsByRequestId(requestId)) {
log.warn("重复请求被拦截:{}", requestId);
throw new BusinessException("订单已提交,请勿重复操作");
}

// 创建订单
Order order = new Order();
order.setRequestId(requestId);
order.setOrderNo(generateOrderNo());
// ...设置其他字段

try {
orderRepository.save(order);
} catch (DataIntegrityViolationException e) {
// 捕获唯一约束异常
throw new BusinessException("订单已存在,请勿重复提交");
}
}
}

方案3:本地Guava缓存(轻量级方案)


适合单机部署,简单快捷!


@Component
public class LocalDuplicateChecker {

// Guava缓存,3秒自动过期
private final Cache<String, Boolean> submitCache = CacheBuilder.newBuilder()
.expireAfterWrite(3, TimeUnit.SECONDS)
.maximumSize(10000)
.build();

/**
* 检查是否重复提交
* @param key 请求唯一标识
* @return true=重复提交, false=首次提交
*/

public boolean isDuplicate(String key) {
try {
// 如果key不存在,则放入缓存并返回null
// 如果key存在,则返回缓存的值
return submitCache.get(key, () -> {
// 这个lambda只在key不存在时执行
return false;
});
} catch (ExecutionException e) {
return true;
}
}

/**
* 手动放入缓存(用于防止并发时多次通过检查)
*/

public void markAsSubmitted(String key) {
submitCache.put(key, true);
}
}

// 使用方式
@RestController
public class ApiController {

@Autowired
private LocalDuplicateChecker duplicateChecker;

@PostMapping("/api/submit")
public ApiResult submitData(@RequestBody SubmitData data,
HttpServletRequest request
) {

// 构造唯一key:IP + 用户ID + 数据摘要
String clientIp = request.getRemoteAddr();
String userId = getCurrentUserId();
String dataHash = DigestUtils.md5DigestAsHex(
JSON.toJSONString(data).getBytes()
).substring(0, 8);

String lockKey = String.format("SUBMIT:%s:%s:%s",
clientIp, userId, dataHash);

if (duplicateChecker.isDuplicate(lockKey)) {
return ApiResult.error("请勿重复提交");
}

// 标记为已提交
duplicateChecker.markAsSubmitted(lockKey);

// 执行业务逻辑
return processData(data);
}
}

方案4:Token令牌机制(最经典的方案)


这个方案就像发门票,一张票只能进一个人!


步骤1:生成Token


@RestController
public class TokenController {

@GetMapping("/api/getToken")
public ApiResult getToken() {
String token = UUID.randomUUID().toString();

// 存入Redis,有效期5分钟
redisTemplate.opsForValue().set(
"SUBMIT_TOKEN:" + token,
"VALID",
5, TimeUnit.MINUTES
);

return ApiResult.success(token);
}
}

步骤2:验证Token


@Aspect
@Component
public class TokenCheckAspect {

@Pointcut("@annotation(needTokenCheck)")
public void pointcut(NeedTokenCheck needTokenCheck) {
}

@Around("pointcut(needTokenCheck)")
public Object checkToken(ProceedingJoinPoint joinPoint,
NeedTokenCheck needTokenCheck) throws Throwable {

HttpServletRequest request = ((ServletRequestAttributes)
RequestContextHolder.getRequestAttributes()).getRequest();

String token = request.getHeader("X-Submit-Token");
if (StringUtils.isBlank(token)) {
throw new RuntimeException("提交令牌缺失");
}

String redisKey = "SUBMIT_TOKEN:" + token;
String value = (String) redisTemplate.opsForValue().get(redisKey);

if (!"VALID".equals(value)) {
throw new RuntimeException("无效的提交令牌");
}

// 删除令牌(一次性使用)
redisTemplate.delete(redisKey);

return joinPoint.proceed();
}
}

步骤3:前端配合


// 提交前先获取令牌
async function submitWithToken(data) {
// 1. 获取令牌
const token = await fetch('/api/getToken').then(r => r.json());

// 2. 携带令牌提交
const result = await fetch('/api/submit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Submit-Token': token
},
body: JSON.stringify(data)
});

return result;
}

三、方案对比总结


方案优点缺点适用场景
AOP + Redis锁灵活可控,支持复杂规则依赖Redis,增加系统复杂度分布式系统,需要精细控制
数据库唯一约束绝对可靠,永不漏网对数据库有压力,需要设计唯一键核心业务(如支付、订单)
本地缓存性能极高,零延迟仅限单机,集群无效单体应用,高频但非核心接口
Token机制安全性高,前端可控需要两次请求,增加交互表单提交,需要严格防重

四、防抖策略选择指南



  1. 根据业务重要性选择



    • 金融支付 → 数据库唯一约束 + Redis锁(双重保险)

    • 普通表单 → Token机制或AOP锁

    • 查询接口 → 本地缓存防抖



  2. 根据系统架构选择



    • 单机应用 → 本地缓存最香

    • 分布式集群 → Redis是王道

    • 微服务 → 考虑分布式锁服务



  3. 实用小贴士


    // 最佳实践:组合拳!
    @PostMapping("/important/submit")
    @PreventDuplicateSubmit(lockTime = 5)
    @Transactional(rollbackFor = Exception.class)
    public ApiResult importantSubmit(@RequestBody @Valid RequestDTO dto) {
    // 1. 检查请求ID是否重复
    checkRequestId(dto.getRequestId());

    // 2. 执行业务
    // 3. 数据库唯一约束兜底

    return ApiResult.success();
    }



五、最后



  1. 不要过度设计:简单的业务用简单的方案,杀鸡不要用牛刀

  2. 用户体验很重要:防抖提示要友好,别让用户一脸懵逼

  3. 监控不能少:记录被拦截的请求,分析用户行为

  4. 前端也要防:前后端双重防护才是王道


防抖的目的不是为难用户,而是保护系统和数据的安全。就像给你的接口穿上防弹衣,既能抵挡"手抖攻击",又能让正常请求畅通无阻!




程序员防抖口诀



前端防抖先出手,后端加锁不能少。


令牌机制来帮忙,唯一约束最可靠。


根据场景选方案,系统稳定没烦恼。


用户手抖不可怕,我有妙招来护驾!



SpringBoot接口防抖大作战,拒绝“手抖”重复提交!.png


谢谢你看我的文章,既然看到这里了,如果觉得不错,随手点个赞、转发、在看三连吧,感谢感谢。那我们,下次再见。


您的一键三连,是我更新的最大动力,谢谢


山水有相逢,来日皆可期,谢谢阅读,我们再会


我手中的金箍棒,上能通天,下能探海


作者:悟空码字
来源:juejin.cn/post/7586208617603661858
收起阅读 »

别再说“对接接口没技术含量了”,这才是高手的打开方式!

很多 Java 程序员一听到“对接第三方接口”,脑子里就自动响起一句话: “这不就是调个接口嘛,没技术含量。” 但真相是:你以为是体力活的地方,往往最能看出一个工程师的“技术深度”。 那些把接口对接写成“定时炸弹”的代码,和能扛住三年高并发零故障的实现,差的从...
继续阅读 »

很多 Java 程序员一听到“对接第三方接口”,脑子里就自动响起一句话: “这不就是调个接口嘛,没技术含量。”


但真相是:你以为是体力活的地方,往往最能看出一个工程师的“技术深度”。


那些把接口对接写成“定时炸弹”的代码,和能扛住三年高并发零故障的实现,差的从来不是会不会发 HTTP 请求。


一、真正的高手,不是“调通接口”,而是“设计边界”


对接第三方接口,看似只是发个请求、拿个 JSON,但背后其实是——系统边界的协作与防御设计。


你面对的不是自己可控的代码,而是一个随时可能“变脸”的外部世界:



  • 对方文档写着“此字段必传”,实际却返回 null

  • 测试环境响应毫秒级,生产环境突然超时 30 秒

  • 接口突然升级,字段名从 camelCase 改成 snake_case

  • 流量峰值时,对方悄悄给你限流却不通知


所以高手不会只想着“调通”,而是从第一天就思考:



  • 超时如何设置才不会拖垮自己的线程池?

  • 对方返回非预期格式时,如何避免解析崩溃?

  • 调用失败后,重试几次、间隔多久才合理?

  • 敏感参数如何加密才能通过安全审计?

  • 接口突然变慢时,如何第一时间收到告警?


这些问题,不是“Bug”,而是“工程意识”的试金石。能把混乱的接口接得稳定、可控、可追踪、可安全,这才是真正的技术能力。


二、“对接接口”也能写出架构感


普通开发者的代码,往往是这样的:


// 业务代码里突然冒出一段HTTP调用
RestTemplate restTemplate = new RestTemplate();
HttpHeaders headers = new HttpHeaders();
headers.set("appKey""xxx");
headers.set("sign""xxx");
HttpEntity<Map> entity = new HttpEntity<>(reqMap, headers);
ResponseEntity<String> res = restTemplate.postForEntity(
    "https://xxx.com/api/pay", entity, String.class);
// 然后直接解析字符串...

而高手的代码,会先画一条清晰的边界:


// 1. 定义领域接口,屏蔽HTTP细节
public interface PaymentGatewayClient {
    PaymentResponse pay(PaymentRequest request);
}

// 2. 实现类专注处理接口对接逻辑
@Service
public class AlipayGatewayClient implements PaymentGatewayClient {
    @Override
    public PaymentResponse pay(PaymentRequest request) {
        // 封装:签名生成、参数转换、超时控制
        // 集成:重试机制、日志埋点、异常转换
        // 隔离:与业务逻辑彻底分离
    }
}

业务层调用时,只需要关心业务语义,不关心HTTP细节。


这样做的好处立竿见影:



  • • 换第三方支付时,只需新增实现类,业务代码零改动

  • • 单元测试时,用 Mock 替代真实接口,测试速度提升 10 倍

  • • 接口逻辑集中管理,不会散落在几百个业务方法里


当你能做到“接口逻辑不散落在业务代码里”,系统就已经迈入“架构级整洁”的门槛。


三、调通很容易,稳定才是实力


调通接口是初级开发者的 KPI。让接口一年 365 天稳稳跑着,那才是高级工程师的成就。


这些场景你一定踩过坑:



  • • 对方接口“偶尔超时”,导致自己的系统线程池被占满

  • • 并发一上来,就收到“Too Many Requests”限流提示

  • • 响应 JSON 里突然多了个逗号,Jackson 解析直接抛异常

  • • 异步回调乱序,先收到“支付成功”,再收到“支付中”

  • • 敏感参数明文传输,被安全扫描揪出高危漏洞

  • • 接口响应变慢,用户投诉后才发现


而高手的解决方案,藏在这些细节里:


1. 超时与重试:用“退避策略”减少无效请求


// 用 Resilience4j 实现指数退避重试
RetryConfig config = RetryConfig.custom()
    .maxAttempts(3// 最多重试3次
    .waitDuration(Duration.ofMillis(1000)) // 首次间隔1秒
    .retryExceptions(TimeoutException.class, IOException.class)
    .ignoreExceptions(IllegalArgumentException.class) // 非法参数不重试
    .build();

Retry retry = Retry.of("paymentApi", config);
// 包装调用逻辑
Supplier<PaymentResponse> retryableSupplier = Retry.decorateSupplier(
    retry, () -> doCallPaymentApi(request)
);

2. 熔断降级:防止对方故障拖垮自己


// 当失败率超过50%,触发熔断
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
    .failureRateThreshold(50)
    .waitDurationInOpenState(Duration.ofSeconds(60)) // 熔断60秒
    .permittedNumberOfCallsInHalfOpenState(5// 半开状态允许5次试探
    .build();

CircuitBreaker circuitBreaker = CircuitBreaker.of("paymentApi", config);
// 降级逻辑:返回缓存数据或默认提示
Supplier<PaymentResponse> decoratedSupplier = CircuitBreaker
    .decorateSupplier(circuitBreaker, () -> doCallPaymentApi(request))
    .orElseGet(() -> buildFallbackResponse(request));

3. 日志追踪:用 TraceId 串联完整调用链


// 拦截器自动生成并传递TraceId
public class TraceIdInterceptor implements ClientHttpRequestInterceptor {
    @Override
    public ClientHttpResponse intercept(
            HttpRequest request, byte[] body, ClientHttpRequestExecution execution)
 {
        String traceId = MDC.get("traceId");
        if (traceId == null) {
            traceId = UUID.randomUUID().toString();
            MDC.put("traceId", traceId);
        }
        request.getHeaders().add("X-Trace-Id", traceId);
        return execution.execute(request, body);
    }
}

// 日志格式包含TraceId,方便排查问题
// logback.xml 配置:%X{traceId} [%thread] %-5level %logger{36} - %msg%n

4. 安全签名:给数据加把“锁”


接口传输的敏感信息(如手机号、银彳亍卡号)必须经过双重防护:


// 1. 参数签名:防止数据被篡改
public class SignUtils {
    public static String sign(Map<String, String> params, String secret) {
        // 按参数名ASCII排序
        List<String> keys = new ArrayList<>(params.keySet());
        Collections.sort(keys);
        // 拼接为key=value&key=value形式
        StringBuilder sb = new StringBuilder();
        for (String key : keys) {
            sb.append(key).append("=").append(params.get(key)).append("&");
        }
        // 追加密钥后用SHA256加密
        sb.append("secret=").append(secret);
        return DigestUtils.sha256Hex(sb.toString());
    }
}

// 2. 敏感字段加密:防止传输中泄露
public class EncryptUtils {
    // 手机号加密示例(AES算法)
    public static String encryptPhone(String phone, String aesKey) {
        // 实际项目中建议使用密钥管理服务存储密钥
        SecretKeySpec key = new SecretKeySpec(aesKey.getBytes(), "AES");
        Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
        cipher.init(Cipher.ENCRYPT_MODE, key);
        return Base64.getEncoder().encodeToString(cipher.doFinal(phone.getBytes()));
    }
}

5. 实时监控:让接口状态“可视化”


高手不会等到用户投诉才发现问题,而是用监控提前预警:


// 1. 自定义指标收集(基于Micrometer)
@Component
public class ApiMetricsCollector {
    private final MeterRegistry meterRegistry;
    
    public void recordApiCall(String apiName, long durationMs, boolean success) {
        // 记录接口耗时分布
        Timer.builder("thirdparty.api.duration")
            .tag("api", apiName)
            .tag("success", String.valueOf(success))
            .register(meterRegistry)
            .record(durationMs, TimeUnit.MILLISECONDS);
        
        // 记录失败次数
        if (!success) {
            Counter.builder("thirdparty.api.failure")
                .tag("api", apiName)
                .register(meterRegistry)
                .increment();
        }
    }
}

// 2. 配置监控告警(Prometheus + Grafana)
// 告警规则示例:当5分钟内失败率超过10%时触发告警
// - alert: ApiHighFailureRate
//   expr: sum(rate(thirdparty.api.failure[5m])) / sum(rate(thirdparty.api.duration_count[5m])) > 0.1
//   for: 1m
//   labels:
//     severity: critical
//   annotations:
//     summary: "接口失败率过高"
//     description: "{{ $labels.api }} 接口5分钟失败率超过10%"

一个优秀的接口对接系统,其实就是一个可观测、可预警、可恢复、可信任的微系统。


四、写给未来的自己看


很多人调完接口就走,连注释都没有。三个月后接手的人只能默默骂一句:“这谁写的鬼东西?对方文档改了哪?这个签名算法是啥意思?”


高手懂得写“能看懂的代码”,体现在这些地方:



  • • 接口模型用类而非 MapPaymentRequest 类比 Map<String, Object> 更清晰,字段注释直接写在类里

  • • 错误码枚举化PaymentErrorCode.ORDER_NOT_EXIST 比魔法值 10001 更容易维护

  • • 文档内聚:在实现类里用 @see 链接对方文档地址,关键逻辑加注释说明为什么这么做

  • • Mock 测试就绪:提供 MockPaymentGatewayClient,方便本地调试和单元测试


对接接口的过程,其实是你在写给未来的自己看。维护体验的好坏,体现的是你的工程素养


五、你以为的“体力活”,其实是“架构的入门课”


对接第三方接口,本质上是一次系统边界设计的演练。


当你学会:



  • 用“依赖倒置”隔离外部变化

  • 用“防御性编程”处理异常情况

  • 用“签名加密”保障数据安全

  • 用“可观测性”确保问题可追溯

  • 用“熔断降级”保障系统韧性


你就已经掌握了架构设计的核心思维。


毕竟,真实世界的系统从来不是孤立的。能把一个“不稳定的外部系统”接入得像内部服务一样稳定、可靠、优雅,那一刻,你不再是“接口调用员”,而是一个在用工程思维解决问题的架构师。


最后想说一句


下次当有人跟你说:“就调个接口嘛,这有啥难的?”。你可以微微一笑: “我不只是调接口,我在构建系统的边界。”


记住一句话:  “能调通的叫能力,能跑稳的才叫实力。”



如果觉得有启发,不妨关注下我的公众号《码上实战》。



作者:码上实战
来源:juejin.cn/post/7563858353884102695
收起阅读 »

Go 语言未来会取代 Java 吗?

Go 语言未来会取代 Java 吗? (八年 Java 开发的深度拆解:从业务场景到技术底层) 开篇:面试官的灵魂拷问与行业焦虑 前年面某大厂时,技术负责人突然抛出问题:“如果让你重构公司核心系统,会选 Go 还是 Java?” 作为写了八年 Java 的老开...
继续阅读 »

Go 语言未来会取代 Java 吗?


(八年 Java 开发的深度拆解:从业务场景到技术底层)


开篇:面试官的灵魂拷问与行业焦虑


前年面某大厂时,技术负责人突然抛出问题:“如果让你重构公司核心系统,会选 Go 还是 Java?”


作为写了八年 Java 的老开发,我本能地想强调 Spring 生态和企业级成熟度,但对方随即展示的 PPT 让我冷汗直冒 —— 某金融公司用 Go 重构交易系统后,QPS 从 5 万飙升到 50 万,服务器成本降低 70%。这让我陷入沉思:当云原生和 AI 浪潮来袭,Java 真的要被 Go 取代了吗?


今天从 业务场景、技术本质、行业趋势 三个维度,结合实战代码和踩坑经验,聊聊我的真实看法。


一、业务场景对比:Go 的 “闪电战” vs Java 的 “持久战”


先看三个典型业务场景,你会发现两者的差异远不止 “性能” 二字。


场景 1:高并发抢购(电商大促)


Go 实现(Gin 框架)


func main() {
router := gin.Default()
router.GET("/seckill", func(c *gin.Context) {
// 轻量级goroutine处理请求
go func() {
// 直接操作Redis库存
if err := redisClient.Decr("stock").Err(); err != nil {
c.JSON(http.StatusOK, gin.H{"result": "fail"})
return
}
c.JSON(http.StatusOK, gin.H{"result": "success"})
}()
})
router.Run(":8080")
}

性能数据:单机轻松支撑 10 万 QPS,p99 延迟 < 5ms。


Java 实现(Spring Boot + 虚拟线程)


@RestController
public class SeckillController {
@GetMapping("/seckill")
public CompletableFuture<ResponseEntity<String>> seckill() {
return CompletableFuture.supplyAsync(() -> {
// 虚拟线程处理IO操作
if (redisTemplate.opsForValue().decrement("stock") < 0) {
return ResponseEntity.ok("fail");
}
return ResponseEntity.ok("success");
}, Executors.newVirtualThreadPerTaskExecutor());
}
}

性能数据:Java 21 虚拟线程让 IO 密集型场景吞吐量提升 7 倍,p99 延迟从 165ms 降至 23ms。


核心差异



  • Go:天生适合高并发,Goroutine 调度和原生 Redis 操作无额外开销。

  • Java:依赖 JVM 调优,虚拟线程虽大幅提升性能,但需配合线程池和异步框架。


场景 2:智能运维平台(云原生领域)


Go 实现(Ollama + gRPC)


func main() {
// 启动gRPC服务处理AI推理请求
server := grpc.NewServer()
pb.RegisterAIAnalysisServer(server, &AIHandler{})
go func() {
if err := server.Serve(lis); err != nil {
log.Fatalf("Server exited with error: %v", err)
}
}()

// 采集节点数据(百万级设备)
for i := 0; i < 1000000; i++ {
go func(nodeID int) {
for {
data := collectMetrics(nodeID)
client.Send(data) // 通过channel传递数据
}
}(i)
}
}

优势:轻量级 Goroutine 高效处理设备数据采集,gRPC 接口响应速度比 REST 快 30%。


Java 实现(Spring Cloud + Kafka)


@Service
public class MonitorService {
@Autowired
private KafkaTemplate<String, String> kafkaTemplate;

public void collectMetrics(int nodeID) {
ScheduledExecutorService executor = Executors.newScheduledThreadPool(100);
executor.scheduleAtFixedRate(() -> {
String data =采集数据(nodeID);
kafkaTemplate.send("metrics-topic", data);
}, 0, 1, TimeUnit.SECONDS);
}
}

挑战:传统线程池在百万级设备下内存占用飙升,需配合 Kafka 分区和 Consumer Gr0up 优化。


核心差异



  • Go:云原生基因,从采集到 AI 推理全链路高效协同。

  • Java:生态依赖强,需整合 Spring Cloud、Kafka 等组件,部署复杂度高。


场景 3:企业 ERP 系统(传统行业)


Java 实现(Spring + Hibernate)


@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@ManyToOne
@JoinColumn(name = "user_id")
private User user;

// 复杂业务逻辑注解
@PrePersist
public void validateOrder() {
if (totalAmount < 0) {
throw new BusinessException("金额不能为负数");
}
}
}

优势:Spring 的事务管理和 Hibernate 的 ORM 完美支持复杂业务逻辑,代码可读性高。


Go 实现(GORM + 接口组合)


type Order struct {
ID uint `gorm:"primaryKey"`
UserID uint
Total float64
}

func (o *Order) Validate() error {
if o.Total < 0 {
return errors.New("金额不能为负数")
}
return nil
}

func CreateOrder(ctx context.Context, order Order) error {
if err := order.Validate(); err != nil {
return err
}
return db.Create(&order).Error
}

挑战:需手动实现事务和复杂校验逻辑,代码量比 Java 多 20%。


核心差异



  • Java:企业级成熟度,框架直接支持事务、权限、审计等功能。

  • Go:灵活性高,但需手动实现大量基础功能,适合轻量级业务。


二、技术本质:为什么 Go 在某些场景碾压 Java?


从 并发模型、内存管理、性能调优 三个维度,深挖两者的底层差异。


1. 并发模型:Goroutine vs 线程 / 虚拟线程


Go 的 Goroutine



  • 轻量级:每个 Goroutine 仅需 2KB 栈空间,可轻松创建百万级并发。

  • 调度高效:基于 GMP 模型,避免内核级上下文切换,IO 阻塞时自动释放线程。


Java 的虚拟线程(Java 21+)



  • 革命性改进:每个虚拟线程仅需几百字节内存,IO 密集型场景吞吐量提升 7 倍。

  • 兼容传统代码:无需修改业务逻辑,直接将new Thread()替换为Thread.startVirtualThread()


性能对比



  • HTTP 服务:Go 的 Gin 框架单机 QPS 可达 5 万,Java 21 虚拟线程 + Netty 可达 3 万。

  • 消息处理:Go 的 Kafka 消费者单节点处理速度比 Java 快 40%。


2. 内存管理:逃逸分析 vs 分代 GC


Go 的逃逸分析



  • 栈优先分配:对象若未逃逸出函数,直接在栈上分配,减少 GC 压力。

  • 零拷贝优化io.Reader接口直接操作底层缓冲区,避免内存复制。


Java 的分代 GC



  • 成熟但复杂:新生代采用复制算法,老年代采用标记 - 压缩,需通过-XX:G1HeapRegionSize等参数调优。

  • 内存占用高:同等业务逻辑下,Java 堆内存通常是 Go 的 2-3 倍。


典型案例

某金融公司用 Go 重构风控系统后,内存占用从 8GB 降至 3GB,GC 停顿时间从 200ms 缩短至 10ms。


3. 性能调优:静态编译 vs JIT 编译


Go 的静态编译



  • 启动快:编译后的二进制文件直接运行,无需预热 JVM。

  • 可预测性强:性能表现稳定,适合对延迟敏感的场景(如高频交易)。


Java 的 JIT 编译



  • 动态优化:运行时将热点代码编译为机器码,长期运行后性能可能反超 Go。

  • 依赖调优经验:需通过-XX:CompileThreshold等参数平衡启动时间和运行效率。


实测数据



  • 启动时间:Go 的 HTTP 服务启动仅需 20ms,Java Spring Boot 需 500ms。

  • 长期运行:持续 24 小时压测,Java 的吞吐量可能比 Go 高 10%(JIT 优化后)。


三、行业趋势:Go 在蚕食 Java 市场,但 Java 不会轻易退场


从 市场数据、生态扩展、技术演进 三个维度,分析两者的未来走向。


1. 市场数据:Go 在高速增长,Java 仍占主导



  • 份额变化:Go 在 TIOBE 排行榜中从 2020 年的第 13 位升至 2025 年的第 7 位,市场份额突破 3%。

  • 薪资对比:Go 开发者平均薪资比 Java 高 20%,但 Java 岗位数量仍是 Go 的 5 倍。


典型案例



  • 字节跳动:核心推荐系统用 Go 重构,QPS 提升 3 倍,成本降低 60%。

  • 招商银行:核心交易系统仍用 Java,但微服务网关和监控平台全面转向 Go。


2. 生态扩展:Go 拥抱 AI,Java 深耕企业级


Go 的 AI 集成



  • 工具链完善:通过 Ollama 框架可直接调用 LLM 模型,实现智能运维告警。

  • 性能优势:Go 的推理服务延迟比 Python 低 80%,适合边缘计算场景。


Java 的企业级护城河



  • 大数据生态:Hadoop、Spark、Flink 等框架仍深度依赖 Java。

  • 移动端统治力:尽管 Kotlin 流行,Android 系统底层和核心应用仍用 Java 开发。


3. 技术演进:Go 和 Java 都在进化


Go 的发展方向



  • 泛型完善:Go 1.18 + 支持泛型,减少重复代码(如PrintSlice函数可适配任意类型)。

  • WebAssembly 集成:计划将 Goroutine 编译为 Wasm,实现浏览器端高并发。


Java 的反击



  • Project Loom:虚拟线程已转正,未来将支持更细粒度的并发控制。

  • Project Valhalla:引入值类型,减少对象装箱拆箱开销,提升性能 15%。


四、选型建议:Java 开发者该如何应对?


作为八年 Java 老兵,我的 技术选型原则 是:用最合适的工具解决问题,而非陷入语言宗教战争


1. 优先选 Go 的场景



  • 云原生基础设施:API 网关、服务网格、CI/CD 工具链(如 Kubernetes 用 Go 开发)。

  • 高并发实时系统:IM 聊天、金融交易、IoT 数据采集(单机 QPS 需求 > 1 万)。

  • AI 推理服务:边缘计算节点、实时推荐系统(需低延迟和高吞吐量)。


2. 优先选 Java 的场景



  • 复杂企业级系统:ERP、CRM、银行核心业务(需事务、权限、审计等功能)。

  • Android 开发:系统级应用和性能敏感模块(如相机、传感器驱动)。

  • 大数据处理:离线分析、机器学习训练(Hadoop/Spark 生态成熟)。


3. 混合架构:Go 和 Java 共存的最佳实践



  • API 网关用 Go:处理高并发请求,转发到 Java 微服务。

  • AI 推理用 Go:部署轻量级模型,结果通过 gRPC 返回给 Java 业务层。

  • 数据存储用 Java:复杂查询和事务管理仍由 Java 服务处理。


代码示例:Go 调用 Java 微服务


// Go客户端
conn, err := grpc.Dial("java-service:8080", grpc.WithInsecure())
if err != nil {
log.Fatalf("连接失败: %v", err)
}
defer conn.Close()

client := pb.NewJavaServiceClient(conn)
resp, err := client.ProcessData(context.Background(), &pb.DataRequest{Data: "test"})
if err != nil {
log.Fatalf("调用失败: %v", err)
}
fmt.Println("Java服务返回:", resp.Result)

// Java服务端
@GrpcService
public class JavaServiceImpl extends JavaServiceGrpc.JavaServiceImplBase {
@Override
public void processData(DataRequest request, StreamObserver<DataResponse> responseObserver) {
String result =复杂业务逻辑(request.getData());
responseObserver.onNext(DataResponse.newBuilder().setResult(result).build());
responseObserver.onCompleted();
}
}

五、总结:焦虑源于未知,成长来自行动


回到开篇的问题:Go 会取代 Java 吗?  我的答案是:短期内不会,但长期会形成互补格局



  • Java 的不可替代性:企业级成熟度、Android 生态、大数据框架,这些优势难以撼动。

  • Go 的不可阻挡性:云原生、高并发、AI 集成,这些领域 Go 正在建立新标准。


作为开发者,与其焦虑语言之争,不如:



  1. 掌握 Go 的核心优势:学习 Goroutine 编程、云原生架构,参与开源项目(如 Kubernetes)。

  2. 深耕 Java 的护城河:研究虚拟线程调优、Spring Boot 3.2 新特性,提升企业级架构能力。

  3. 拥抱混合开发:在 Java 项目中引入 Go 模块,或在 Go 服务中调用 Java 遗留系统。


最后分享一个真实案例:某电商公司将支付核心用 Java 保留,抢购服务用 Go 重构,大促期间 QPS 从 5 万提升到 50 万,系统总成本降低 40%。这说明,语言只是工具,业务价值才是终极目标


作者:天天摸鱼的java工程师
来源:juejin.cn/post/7540597161224536090
收起阅读 »

微服务正在悄然消亡:这是一件美好的事

最近在做的事情正好需要系统地研究微服务与单体架构的取舍与演进。读到这篇文章《Microservices Are Quietly Dying — And It’s Beautiful》,许多观点直击痛点、非常启发,于是我顺手把它翻译出来,分享给大家,也希望能给同...
继续阅读 »

最近在做的事情正好需要系统地研究微服务与单体架构的取舍与演进。读到这篇文章《Microservices Are Quietly Dying — And It’s Beautiful》,许多观点直击痛点、非常启发,于是我顺手把它翻译出来,分享给大家,也希望能给同样在复杂性与效率之间权衡的团队一些参考。


微服务正在悄然消亡:这是一件美好的事


为了把我们的创业产品扩展到数百万用户,我们搭建了 47 个微服务。


用户从未达到一百万,但我们达到了每月 23,000 美元的 AWS 账单、长达 14 小时的故障,以及一个再也无法高效交付新功能的团队。


那一刻我才意识到:我们并没有在构建产品,而是在搭建一座分布式的自恋纪念碑。


image.png


我们都信过的谎言


五年前,微服务几乎是教条。Netflix 用它,Uber 用它。每一场技术大会、每一篇 Medium 文章、每一位资深架构师都在高喊同一句话:单体不具备可扩展性,微服务才是答案。


于是我们照做了。我们把 Rails 单体拆成一个个服务:用户服务、认证服务、支付服务、通知服务、分析服务、邮件服务;然后是子服务,再然后是调用服务的服务,层层套叠。


到第六个月,我们已经在 12 个 GitHub 仓库里维护 47 个服务。我们的部署流水线像一张地铁图,架构图需要 4K 显示器才能看清。


当“最佳实践”变成“最差实践”


我们不断告诫自己:一切都在运转。我们有 Kubernetes,有服务网格,有用 Jaeger 的分布式追踪,有 ELK 的日志——我们很“现代”。


但那些光鲜的微服务文章从不提的一点是:分布式的隐性税


每一个新功能都变成跨团队的协商。想给用户资料加一个字段?那意味着要改五个服务、提三个 PR、协调两周,并进行一次像劫案电影一样精心编排的数据库迁移。


我们的预发布环境成本甚至高于生产环境,因为想测试任何东西,都需要把一切都跑起来。47 个服务在 Docker Compose 里同时启动,内存被疯狂吞噬。


那个彻夜崩溃的夜晚


凌晨 2:47,Slack 被消息炸翻。


生产环境宕了。不是某一个服务——是所有服务。支付服务连不上用户服务,通知服务不断超时,API 网关对每个请求都返回 503。


我打开分布式追踪面板:一万五千个 span,全线飘红。瀑布图像抽象艺术。我花了 40 分钟才定位出故障起点。


结果呢?一位初级开发在认证服务上发布了一个配置变更,只是一个环境变量。它让令牌校验多了 2 秒延迟,这个延迟在 11 个下游服务间层层传递,超时叠加、断路器触发、重试逻辑制造请求风暴,整个系统在自身重量下轰然倒塌。


我们搭了一座纸牌屋,却称之为“容错架构”。


我们花了六个小时才修复。并不是因为 bug 复杂——它只是一个配置的单行改动,而是因为排查分布式系统就像破获一桩谋杀案:每个目击者说着不同的语言,而且有一半在撒谎。


那个被忽略的低语


一周后,在复盘会上,我们的 CTO 说了句让所有人不自在的话:


“要不我们……回去?”


回到单体。回到一个仓库。回到简单。


会议室一片沉默。你能感到认知失调。我们是工程师,我们很“高级”。单体是给传统公司和训练营毕业生用的,不是给一家正打造未来的 A 轮初创公司用的。


但随后有人把指标展开:平均恢复时间 4.2 小时;部署频率每周 2.3 次(从单体时代的每周 12 次一路下滑);云成本增长速度比营收快 40%。


数字不会说谎。是架构在拖垮我们。


美丽的回归


我们用了三个月做整合。47 个服务归并成一个模块划分清晰的 Rails 应用;Kubernetes 变成负载均衡后面的三台 EC2;12 个仓库的工作流收敛成一个边界明确的仓库。


结果简直让人尴尬。


部署时间从 25 分钟降到 90 秒;AWS 账单从 23,000 美元降到 3,800 美元;P95 延迟提升了 60%,因为我们消除了 80% 的网络调用。更重要的是——我们又开始按时交付功能了。


开发者不再说“我需要和三个团队协调”,而是开始说“午饭前给你”。


我们的“分布式系统”变回了结构良好的应用。边界上下文变成 Rails 引擎,服务调用变成方法调用,Kafka 变成后台任务,“编排层”……就是 Rails 控制器。


它更快,它更省,它更好。


我们真正学到的是什么


这是真相:我们为此付出两年时间和 40 万美元才领悟——


微服务不是一种纯粹的架构模式,而是一种组织模式。Netflix 需要它,因为他们有 200 个团队。你没有。Uber 需要它,因为他们一天发布 4,000 次。你没有。


复杂性之所以诱人,是因为它看起来像进步。 拥有 47 个服务、Kubernetes、服务网格和分布式追踪,看起来很“专业”;而一个单体加一套 Postgres,看起来很“业余”。


但复杂性是一种税。它以认知负担、运营开销、开发者幸福感和交付速度为代价。


而大多数初创公司根本付不起这笔税。


我们花了两年时间为并不存在的规模做优化,同时牺牲了能让我们真正达到规模的简单性。


你不需要 50 个微服务,你需要的是自律


软件架构的“肮脏秘密”是:好的设计在任何规模都奏效。


一个结构良好的单体,拥有清晰的模块、明确的边界上下文和合理的关注点分离,比一团由希望和 YAML 勉强粘合在一起的微服务乱麻走得更远。


微服务并不是因为“糟糕”而式微,而是因为我们出于错误的理由使用了它。我们选择了分布式的复杂性而不是本地的自律,选择了运营的负担而不是价值的交付。


那些悄悄回归单体的公司并非承认失败,而是在承认更难的事实:我们一直在解决错误的问题。


所以我想问一个问题:你构建微服务,是在逃避什么?


如果答案是“一个凌乱的代码库”,那我有个坏消息——分布式系统不会修好坏代码,它只会让问题更难被发现。


作者:程序猿DD
来源:juejin.cn/post/7563860666349649970
收起阅读 »

Kafka 消息积压了,同事跑路了

快到年底了,系统频繁出问题。我有正当理由怀疑老板不想发年终奖所以搞事。 这不,几年都遇不到的消息队列积压现象今晚又卷土重来了。 今晚注定是个不眠夜了,原神启动。。。 组里的小伙伴火急火燎找到我说,Kafka 的消息积压一直在涨,预览图一直出不来。我加了几个服...
继续阅读 »

快到年底了,系统频繁出问题。我有正当理由怀疑老板不想发年终奖所以搞事。


这不,几年都遇不到的消息队列积压现象今晚又卷土重来了。


今晚注定是个不眠夜了,原神启动。。。


image.png


组里的小伙伴火急火燎找到我说,Kafka 的消息积压一直在涨,预览图一直出不来。我加了几个服务实例,刚开始可以消费,后面消费着也卡住了。


本来刚刚下班的我就比较疲惫,想让他撤回镜像明天再上。不成想组长不讲武德,直接开了个飞书视频。


我当时本来不想理他,我已经下班了,别人上线的功能出问题关我啥事。


image.png


后来他趁我不注意搞偷袭,给我私信了,我当时没多想就点开了飞书。


本来以传统功夫的点到为止,我进入飞书不点开他的会话,是能看他给我发的最后一句话的。


image.png


我把手放在他那个会话上就是没点开,已读不回这种事做多了不好。我笑了一下,准备洗洗睡了。


正在我收手不点的时候,他突然给我来了一个电话,我大意了啊,没有挂,还强行接了他的电话。两分多钟以后就好了,我说小伙子你不讲武德。


直接喊话,今晚必须解决,大家都点咖啡算他的。


image.png
这真没办法,都找上门来了。只能跟着查一下,早点解决早点睡觉。然后我就上 Kafka 面板一看:最初的4个分区已经积压了 1200 条,后面新加的分区也开始积压了,而且积压的速度越来越快。


image.png


搞清楚发生了什么?我们就得思考一下导致积压的原因。一般是消费者代码执行出错了,导致某条消息消费不了。


所以某个点卡住了,然后又有新的消息进来。


Kafka 是通过 offset 机制来标记消息是否消费过的,所以如果分区中有某一条消息消费失败,就会导致后面的没机会消费。


我用的是spring cloud stream 来处理消息队列的发送和监听。代码上是每次处理一条消息,而且代码还在处理的过程中加了 try-catch。监听器链路打印的日志显示执行成功了,try-catch也没有捕捉到任何的异常。


这一看,我就以为是消费者性能不足,突然想起 SpringCloudStream 好像有个多线程消费的机制。立马让开发老哥试试,看看能不能就这样解决了,我困得不行。


image.png


我半眯着眼睛被提醒吵醒了。开发老哥把多线程改成10之后,发现积压更快了,而且还有pod会挂。老哥查了一下多线程的配置 concurrency。


原来指的是消费者线程,一个消费者线程会负责处理一个分区。对于我们来说,增加之后可能会导致严重的流量倾斜,难怪pod会挂掉,赶紧恢复了回去。


看来想糊弄过去是不行了,我把pod运行的日志全部拉下来。查了一下日志,日志显示执行成功了,但同时有超时错误,这就见了鬼了。


image.png


作为一个坚定的唯物主义者,我是不信见鬼的。但此刻我汗毛倒竖,吓得不敢再看屏幕一眼。


image.png


但是内心又觉得不甘心,于是我偷偷瞄了一眼屏幕。不看还好,一喵你猜我发现了啥?


消费者组重平衡了,这就像是在黑暗中有一束光照向了我,猪八戒发现嫦娥原来一直暗恋自己,我的女神其实不需要拉屎。


有了重平衡就好说了,无非就是两种情况,一是服务挂掉了,二是服务消费者执行超时了。现在看来服务没有挂,那就是超时了,也正好和上面的日志能对上。


image.png


那怎么又看到监听器执行的结果是正常的呢?


这就得从 Kafka 的批量拉取机制说起了,这货和我们人类直觉上的的队列机制不太一样。我们一般理解的队列是发送一个消息给队列,然后队列就异步把这消息给消费者。但是这货是消费者主动去拉取一批来消费。


然后好死不死,SpringCloudStream 为了封装得好用符合人类的认知,就做成了一般理解的队列那种方式。


SpringCloudStream 一批拉了500条记录,然后提供了一个监听器接口让我们实现。入参是一个对象,也就是500条数据中的一条,而不是一个数组。


image.png


我们假设这500条数据的ID是 001-500,每一条数据对应的消费者需要执行10s。那么总共就需要500 x 10s=5000s。


再假设消费者执行的超时时间是 300s,而且消费者执行的过程是串行的。那么500条中最多只能执行30条,这就能解释为什么看消费链路是正常的,但是还超时。


因为单次消费确实成功了,但是批次消费也确实超时了。


我咧个豆,破案了。


image.png


于是我就想到了两种方式来处理这个问题:第一是改成单条消息消费完立马确认,第二是把批次拉取的数据量改小一点。


第一种方案挺好的,就是性能肯定没有批量那么好,不然你以为 Kafka 的吞吐量能薄纱ActiveMQ这些传统队列。吞吐量都是小事,这个方案胜在可以立马去睡觉了。只需要改一个配置:ack-mode: RECORD


第二种方案是后来提的,其实单单把批次拉取的数据量改小性能提升还不是很明显。不过既然我们都能拿到一批数据了,那多线程安排上就得了。


先改配置,一次只拉取50条 max.poll.records: 50。然后启用线程池处理,完美!


@StreamListener("<TOPIC>")
public void consume(List<byte[]> payloads) {

List<CompletableFuture<Void>> futures = payloads.stream().map(bytes -> {
Payload payload = JacksonSnakeCaseUtils.parseJson(new String(bytes), Payload.class);

return CompletableFuture.runAsync(() -> {
// ........
}, batchConsumeExecutor).exceptionally(e -> {
log.error("Thread error {}", bytes, e);
return null;
});
}).collect(Collectors.toList());

try {
// 等待这批消息中的所有任务全部完成
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
errorMessage = "OK";
} catch (Exception e) {
errorMessage = "Ex: " + e.getMessage();
} finally {
// ...
}
}


作者:纸仓
来源:juejin.cn/post/7573687816431190026
收起阅读 »

为什么大厂一般都不推荐使用@Transactional?

前言 对于从事java开发工作的同学来说,Spring的事务肯定再熟悉不过了。 在某些业务场景下,如果一个请求中,需要同时写入多张表的数据。为了保证操作的原子性(要么同时成功,要么同时失败),避免数据不一致的情况,我们一般都会用到Spring事务。 确实,Sp...
继续阅读 »

前言


对于从事java开发工作的同学来说,Spring的事务肯定再熟悉不过了。


在某些业务场景下,如果一个请求中,需要同时写入多张表的数据。为了保证操作的原子性(要么同时成功,要么同时失败),避免数据不一致的情况,我们一般都会用到Spring事务。


确实,Spring事务用起来贼爽,就用一个简单的注解:@Transactional,就能轻松搞定事务。我猜大部分小伙伴也是这样用的,而且一直用一直爽。


但如果你使用不当,它也会坑你于无形。


今天我们就一起聊聊,事务失效的一些场景,说不定你已经中招了。不信,让我们一起看看。


image.png


一 事务不生效


1.访问权限问题


众所周知,java的访问权限主要有四种:private、default、protected、public,它们的权限从左到右,依次变大。


但如果我们在开发过程中,把有某些事务方法,定义了错误的访问权限,就会导致事务功能出问题,例如:


@Service
public class UserService {

@Transactional
private void add(UserModel userModel) {
saveData(userModel);
updateData(userModel);
}
}

我们可以看到add方法的访问权限被定义成了private,这样会导致事务失效,spring要求被代理方法必须是public的。


说白了,在AbstractFallbackTransactionAttributeSource类的computeTransactionAttribute方法中有个判断,如果目标方法不是public,则TransactionAttribute返回null,即不支持事务。


protected TransactionAttribute computeTransactionAttribute(Method method, @Nullable Class<?> targetClass) {
// Don't allow no-public methods as required.
if (allowPublicMethodsOnly() && !Modifier.isPublic(method.getModifiers())) {
return null;
}

// The method may be on an interface, but we need attributes from the target class.
// If the target class is null, the method will be unchanged.
Method specificMethod = AopUtils.getMostSpecificMethod(method, targetClass);

// First try is the method in the target class.
TransactionAttribute txAttr = findTransactionAttribute(specificMethod);
if (txAttr != null) {
return txAttr;
}

// Second try is the transaction attribute on the target class.
txAttr = findTransactionAttribute(specificMethod.getDeclaringClass());
if (txAttr != null && ClassUtils.isUserLevelMethod(method)) {
return txAttr;
}

if (specificMethod != method) {
// Fallback is to look at the original method.
txAttr = findTransactionAttribute(method);
if (txAttr != null) {
return txAttr;
}
// Last fallback is the class of the original method.
txAttr = findTransactionAttribute(method.getDeclaringClass());
if (txAttr != null && ClassUtils.isUserLevelMethod(method)) {
return txAttr;
}
}
return null;
}

也就是说,如果我们自定义的事务方法(即目标方法),它的访问权限不是public,而是private、default或protected的话,spring则不会提供事务功能。


2. 方法用final修饰


有时候,某个方法不想被子类重新,这时可以将该方法定义成final的。普通方法这样定义是没问题的,但如果将事务方法定义成final,例如:


@Service
public class UserService {

@Transactional
public final void add(UserModel userModel){
saveData(userModel);
updateData(userModel);
}
}

我们可以看到add方法被定义成了final的,这样会导致事务失效。


为什么?


如果你看过spring事务的源码,可能会知道spring事务底层使用了aop,也就是通过jdk动态代理或者cglib,帮我们生成了代理类,在代理类中实现的事务功能。


但如果某个方法用final修饰了,那么在它的代理类中,就无法重写该方法,而添加事务功能。



注意:如果某个方法是static的,同样无法通过动态代理,变成事务方法。



3.方法内部调用


有时候我们需要在某个Service类的某个方法中,调用另外一个事务方法,比如:


@Service
public class UserService {

@Autowired
private UserMapper userMapper;

//@Transactional
public void add(UserModel userModel) {
userMapper.insertUser(userModel);
updateStatus(userModel);
}

@Transactional
public void updateStatus(UserModel userModel) {
doSameThing();
}
}

我们看到在事务方法add中,直接调用事务方法updateStatus。从前面介绍的内容可以知道,updateStatus方法拥有事务的能力是因为spring aop生成代理了对象,但是这种方法直接调用了this对象的方法,所以updateStatus方法不会生成事务。


由此可见,在同一个类中的方法直接内部调用,会导致事务失效。


那么问题来了,如果有些场景,确实想在同一个类的某个方法中,调用它自己的另外一个方法,该怎么办呢?


3.1 新加一个Service方法


这个方法非常简单,只需要新加一个Service方法,把@Transactional注解加到新Service方法上,把需要事务执行的代码移到新方法中。具体代码如下:


@Servcie
public class ServiceA {
@Autowired
prvate ServiceB serviceB;

public void save(User user) {
queryData1();
queryData2();
serviceB.doSave(user);
}
}

@Servcie
public class ServiceB {

@Transactional(rollbackFor=Exception.class)
public void doSave(User user) {
addData1();
updateData2();
}

}

3.2 在该Service类中注入自己


如果不想再新加一个Service类,在该Service类中注入自己也是一种选择。具体代码如下:


@Servcie
public class ServiceA {
@Autowired
prvate ServiceA serviceA;

public void save(User user) {
queryData1();
queryData2();
serviceA.doSave(user);
}

@Transactional(rollbackFor=Exception.class)
public void doSave(User user) {
addData1();
updateData2();
}
}

可能有些人可能会有这样的疑问:这种做法会不会出现循环依赖问题?


答案:不会。


其实spring ioc内部的三级缓存保证了它,不会出现循环依赖问题。但有些坑,如果你想进一步了解循环依赖问题,可以看看我之前文章《spring:我是如何解决循环依赖的?》。


3.3 通过AopContent类


在该Service类中使用AopContext.currentProxy()获取代理对象


上面的方法2确实可以解决问题,但是代码看起来并不直观,还可以通过在该Service类中使用AOPProxy获取代理对象,实现相同的功能。具体代码如下:


@Servcie
public class ServiceA {

public void save(User user) {
queryData1();
queryData2();
((ServiceA)AopContext.currentProxy()).doSave(user);
}

@Transactional(rollbackFor=Exception.class)
public void doSave(User user) {
addData1();
updateData2();
}
}

4.未被spring管理


在我们平时开发过程中,有个细节很容易被忽略。即使用spring事务的前提是:对象要被spring管理,需要创建bean实例。


通常情况下,我们通过@Controller、@Service、@Component、@Repository等注解,可以自动实现bean实例化和依赖注入的功能。


当然创建bean实例的方法还有很多,有兴趣的小伙伴可以看看我之前写的另一篇文章《@Autowired的这些骚操作,你都知道吗?


如果有一天,你匆匆忙忙的开发了一个Service类,但忘了加@Service注解,比如:


//@Service
public class UserService {

@Transactional
public void add(UserModel userModel) {
saveData(userModel);
updateData(userModel);
}
}

从上面的例子,我们可以看到UserService类没有加@Service注解,那么该类不会交给spring管理,所以它的add方法也不会生成事务。


5.多线程调用


在实际项目开发中,多线程的使用场景还是挺多的。如果spring事务用在多线程场景中,会有问题吗?


@Slf4j
@Service
public class UserService {

@Autowired
private UserMapper userMapper;
@Autowired
private RoleService roleService;

@Transactional
public void add(UserModel userModel) throws Exception {
userMapper.insertUser(userModel);
new Thread(() -> {
roleService.doOtherThing();
}).start();
}
}

@Service
public class RoleService {

@Transactional
public void doOtherThing() {
System.out.println("保存role表数据");
}
}

从上面的例子中,我们可以看到事务方法add中,调用了事务方法doOtherThing,但是事务方法doOtherThing是在另外一个线程中调用的。


这样会导致两个方法不在同一个线程中,获取到的数据库连接不一样,从而是两个不同的事务。如果想doOtherThing方法中抛了异常,add方法也回滚是不可能的。


如果看过spring事务源码的朋友,可能会知道spring的事务是通过数据库连接来实现的。当前线程中保存了一个map,key是数据源,value是数据库连接。


private static final ThreadLocal<Map<Object, Object>> resources =

new NamedThreadLocal<>("Transactional resources");

我们说的同一个事务,其实是指同一个数据库连接,只有拥有同一个数据库连接才能同时提交和回滚。如果在不同的线程,拿到的数据库连接肯定是不一样的,所以是不同的事务。


6.表不支持事务


周所周知,在mysql5之前,默认的数据库引擎是myisam


它的好处就不用多说了:索引文件和数据文件是分开存储的,对于查多写少的单表操作,性能比innodb更好。


有些老项目中,可能还在用它。


在创建表的时候,只需要把ENGINE参数设置成MyISAM即可:


CREATE TABLE `category` (
`id` bigint NOT NULL AUTO_INCREMENT,
`one_category` varchar(20) COLLATE utf8mb4_bin DEFAULT NULL,
`two_category` varchar(20) COLLATE utf8mb4_bin DEFAULT NULL,
`three_category` varchar(20) COLLATE utf8mb4_bin DEFAULT NULL,
`four_category` varchar(20) COLLATE utf8mb4_bin DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=MyISAM AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin

myisam好用,但有个很致命的问题是:不支持事务


如果只是单表操作还好,不会出现太大的问题。但如果需要跨多张表操作,由于其不支持事务,数据极有可能会出现不完整的情况。


此外,myisam还不支持行锁和外键。


所以在实际业务场景中,myisam使用的并不多。在mysql5以后,myisam已经逐渐退出了历史的舞台,取而代之的是innodb。



有时候我们在开发的过程中,发现某张表的事务一直都没有生效,那不一定是spring事务的锅,最好确认一下你使用的那张表,是否支持事务。



7.未开启事务


有时候,事务没有生效的根本原因是没有开启事务。


你看到这句话可能会觉得好笑。


开启事务不是一个项目中,最最最基本的功能吗?


为什么还会没有开启事务?


没错,如果项目已经搭建好了,事务功能肯定是有的。


但如果你是在搭建项目demo的时候,只有一张表,而这张表的事务没有生效。那么会是什么原因造成的呢?


当然原因有很多,但没有开启事务,这个原因极其容易被忽略。


如果你使用的是springboot项目,那么你很幸运。因为springboot通过DataSourceTransactionManagerAutoConfiguration类,已经默默的帮你开启了事务。


你所要做的事情很简单,只需要配置spring.datasource相关参数即可。


但如果你使用的还是传统的spring项目,则需要在applicationContext.xml文件中,手动配置事务相关参数。如果忘了配置,事务肯定是不会生效的。


具体配置如下信息:


   
<!-- 配置事务管理器 -->
<bean class="org.springframework.jdbc.datasource.DataSourceTransactionManager" id="transactionManager">
<property name="dataSource" ref="dataSource"></property>
</bean>
<tx:advice id="advice" transaction-manager="transactionManager">
<tx:attributes>
<tx:method name="*" propagation="REQUIRED"/>
</tx:attributes>
</tx:advice>
<!-- 用切点把事务切进去 -->
<aop:config>
<aop:pointcut expression="execution(* com.susan.*.*(..))" id="pointcut"/>
<aop:advisor advice-ref="advice" pointcut-ref="pointcut"/>
</aop:config>

默默的说一句,如果在pointcut标签中的切入点匹配规则,配错了的话,有些类的事务也不会生效。


二 事务不回滚


1.错误的传播特性


其实,我们在使用@Transactional注解时,是可以指定propagation参数的。


该参数的作用是指定事务的传播特性,spring目前支持7种传播特性:



  • REQUIRED 如果当前上下文中存在事务,那么加入该事务,如果不存在事务,创建一个事务,这是默认的传播属性值。

  • SUPPORTS 如果当前上下文存在事务,则支持事务加入事务,如果不存在事务,则使用非事务的方式执行。

  • MANDATORY 如果当前上下文中存在事务,否则抛出异常。

  • REQUIRES_NEW 每次都会新建一个事务,并且同时将上下文中的事务挂起,执行当前新建事务完成以后,上下文事务恢复再执行。

  • NOT_SUPPORTED 如果当前上下文中存在事务,则挂起当前事务,然后新的方法在没有事务的环境中执行。

  • NEVER 如果当前上下文中存在事务,则抛出异常,否则在无事务环境上执行代码。

  • NESTED 如果当前上下文中存在事务,则嵌套事务执行,如果不存在事务,则新建事务。


如果我们在手动设置propagation参数的时候,把传播特性设置错了,比如:


@Service
public class UserService {

@Transactional(propagation = Propagation.NEVER)
public void add(UserModel userModel) {
saveData(userModel);
updateData(userModel);
}
}

我们可以看到add方法的事务传播特性定义成了Propagation.NEVER,这种类型的传播特性不支持事务,如果有事务则会抛异常。


目前只有这三种传播特性才会创建新事务:REQUIRED,REQUIRES_NEW,NESTED。


2.自己吞了异常


事务不会回滚,最常见的问题是:开发者在代码中手动try...catch了异常。比如:


@Slf4j
@Service
public class UserService {

@Transactional
public void add(UserModel userModel) {
try {
saveData(userModel);
updateData(userModel);
} catch (Exception e) {
log.error(e.getMessage(), e);
}
}
}

这种情况下spring事务当然不会回滚,因为开发者自己捕获了异常,又没有手动抛出,换句话说就是把异常吞掉了。


如果想要spring事务能够正常回滚,必须抛出它能够处理的异常。如果没有抛异常,则spring认为程序是正常的。


3.手动抛了别的异常


即使开发者没有手动捕获异常,但如果抛的异常不正确,spring事务也不会回滚。


@Slf4j
@Service
public class UserService {

@Transactional
public void add(UserModel userModel) throws Exception {
try {
saveData(userModel);
updateData(userModel);
} catch (Exception e) {
log.error(e.getMessage(), e);
throw new Exception(e);
}
}
}

上面的这种情况,开发人员自己捕获了异常,又手动抛出了异常:Exception,事务同样不会回滚。


因为spring事务,默认情况下只会回滚RuntimeException(运行时异常)和Error(错误),对于普通的Exception(非运行时异常),它不会回滚。


4.自定义了回滚异常


在使用@Transactional注解声明事务时,有时我们想自定义回滚的异常,spring也是支持的。可以通过设置rollbackFor参数,来完成这个功能。


但如果这个参数的值设置错了,就会引出一些莫名其妙的问题,例如:


@Slf4j
@Service
public class UserService {

@Transactional(rollbackFor = BusinessException.class)
public void add(UserModel userModel) throws Exception {
saveData(userModel);
updateData(userModel);
}
}

如果在执行上面这段代码,保存和更新数据时,程序报错了,抛了SqlException、DuplicateKeyException等异常。而BusinessException是我们自定义的异常,报错的异常不属于BusinessException,所以事务也不会回滚。


即使rollbackFor有默认值,但阿里巴巴开发者规范中,还是要求开发者重新指定该参数。


这是为什么呢?


因为如果使用默认值,一旦程序抛出了Exception,事务不会回滚,这会出现很大的bug。所以,建议一般情况下,将该参数设置成:Exception或Throwable。


5.嵌套事务回滚多了


public class UserService {

@Autowired
private UserMapper userMapper;

@Autowired
private RoleService roleService;

@Transactional
public void add(UserModel userModel) throws Exception {
userMapper.insertUser(userModel);
roleService.doOtherThing();
}
}

@Service
public class RoleService {

@Transactional(propagation = Propagation.NESTED)
public void doOtherThing() {
System.out.println("保存role表数据");
}
}

这种情况使用了嵌套的内部事务,原本是希望调用roleService.doOtherThing方法时,如果出现了异常,只回滚doOtherThing方法里的内容,不回滚 userMapper.insertUser里的内容,即回滚保存点。。但事实是,insertUser也回滚了。


why?


因为doOtherThing方法出现了异常,没有手动捕获,会继续往上抛,到外层add方法的代理方法中捕获了异常。所以,这种情况是直接回滚了整个事务,不只回滚单个保存点。


怎么样才能只回滚保存点呢?


@Slf4j
@Service
public class UserService {

@Autowired
private UserMapper userMapper;

@Autowired
private RoleService roleService;

@Transactional
public void add(UserModel userModel) throws Exception {

userMapper.insertUser(userModel);
try {
roleService.doOtherThing();
} catch (Exception e) {
log.error(e.getMessage(), e);
}
}
}

可以将内部嵌套事务放在try/catch中,并且不继续往上抛异常。这样就能保证,如果内部嵌套事务中出现异常,只回滚内部事务,而不影响外部事务。


三 其他


1 大事务问题


在使用spring事务时,有个让人非常头疼的问题,就是大事务问题。


通常情况下,我们会在方法上@Transactional注解,填加事务功能,比如:


@Service
public class UserService {

@Autowired
private RoleService roleService;

@Transactional
public void add(UserModel userModel) throws Exception {
query1();
query2();
query3();
roleService.save(userModel);
update(userModel);
}
}


@Service
public class RoleService {

@Autowired
private RoleService roleService;

@Transactional
public void save(UserModel userModel) throws Exception {
query4();
query5();
query6();
saveData(userModel);
}
}

@Transactional注解,如果被加到方法上,有个缺点就是整个方法都包含在事务当中了。


上面的这个例子中,在UserService类中,其实只有这两行才需要事务:


roleService.save(userModel);
update(userModel);

在RoleService类中,只有这一行需要事务:


saveData(userModel);

现在的这种写法,会导致所有的query方法也被包含在同一个事务当中。


如果query方法非常多,调用层级很深,而且有部分查询方法比较耗时的话,会造成整个事务非常耗时,而从造成大事务问题。


关于大事务问题的危害,可以阅读一下我的另一篇文章《让人头痛的大事务问题到底要如何解决?》,上面有详细的讲解。


image.png


更多精彩内容百度一下:Java突击队


2.编程式事务


上面聊的这些内容都是基于@Transactional注解的,主要说的是它的事务问题,我们把这种事务叫做:声明式事务


其实,spring还提供了另外一种创建事务的方式,即通过手动编写代码实现的事务,我们把这种事务叫做:编程式事务。例如:



@Autowired
private TransactionTemplate transactionTemplate;

...

public void save(final User user) {
queryData1();
queryData2();
transactionTemplate.execute((status) => {
addData1();
updateData2();
return Boolean.TRUE;
})
}

在spring中为了支持编程式事务,专门提供了一个类:TransactionTemplate,在它的execute方法中,就实现了事务的功能。


相较于@Transactional注解声明式事务,我更建议大家使用,基于TransactionTemplate的编程式事务。主要原因如下:



  1. 避免由于spring aop问题,导致事务失效的问题。

  2. 能够更小粒度的控制事务的范围,更直观。



建议在项目中少使用@Transactional注解开启事务。但并不是说一定不能用它,如果项目中有些业务逻辑比较简单,而且不经常变动,使用@Transactional注解开启事务开启事务也无妨,因为它更简单,开发效率更高,但是千万要小心事务失效的问题。



作者:苏三说技术
来源:juejin.cn/post/7601576716016435263
收起阅读 »

面试必问HTTP状态码:从“请求的一生”彻底搞懂,告别死记硬背

HTTP状态码:从请求的一生重新理解 “所有数字背后,都是一个请求的遗言。” —— 某位被502逼疯的工程师 或者,你也可以记住这一句: “状态码不是用来背的,是用来收尸的。” —— 同一位工程师,在又一次凌晨三点被叫起来之后 为什么写这篇文章 相信...
继续阅读 »

HTTP状态码:从请求的一生重新理解



“所有数字背后,都是一个请求的遗言。”


—— 某位被502逼疯的工程师



或者,你也可以记住这一句:



“状态码不是用来背的,是用来收尸的。”


—— 同一位工程师,在又一次凌晨三点被叫起来之后





为什么写这篇文章


相信很多朋友面试的时候都会被面试官问到:“你记得多少HTTP状态码?具体有哪些含义?”


一般对于这类问题,我们都会提前复习和记忆,才能回答得比较完整。


但后来我发现一件事:



“背状态码就像背尸检报告,你记住了死因,却没见过现场。”


—— 某位靠背答案转行写代码的面试者



本文从一个请求离开客户端之后的链路出发,带你去看现场


要彻底搞懂HTTP状态码,我们可以换一种思路:设计这么多状态码,它们具体是在哪一环节、因为什么原因被返回的?


我们的请求传递到整个后端,并不是直接访问到服务器。它要经过一大批网络组件的筛选过滤,每一关都有可能倒下,每一关都会有人替它写下遗言。


下面,让我们从一个请求发送到后端的链路,重新认识一下HTTP状态码。




第一站:边缘节点 CDN



“我以为我能活到源站,结果在门口就被拦下了。”


—— 一个试图直接访问服务器的请求



当一个请求经过DNS解析离开设备,遇到的第一个网络组件是CDN。


CDN是一种缓存设备。它把源服务器的资源拉取到离你最近的地方,像个热情过度的前台:“你要这个?我这有,别往里跑了。”


遇到热门的资源文件(比如B站、抖音的热门视频),直接从CDN获取,速度远远快于访问远端的服务器。对于这些占用带宽较大的静态文件资源,缓存到CDN上是性价比最高的方案。


CDN节点状态码


状态码含义死因报告
200成功命中“我这有,拿去吧。”
304未修改“你手里的还是新鲜的,不用换。”
502回源非法响应“我去帮你问,结果源站说方言,听不懂。”
503回源连接拒绝“源站把门关上了,不让我进。”
504回源超时“源站接了电话,但一直不说话。”


💡 304是个好东西


每次向CDN发起请求,并不一定需要CDN把整个文件再发一遍。


如果我们本地有缓存,带着文件的指纹(ETag)或修改时间(Last-Modified)去问CDN:“我这个还新鲜吗?”


CDN看一眼:“没变,接着用吧。”


省带宽,省时间,双方都舒服。


—— 这是唯一一个**请求和服务器达成共识“你不用干活”**的状态码。





第二站:安全网关 WAF



“我不是不让你进,我是怕你进来搞破坏。”


—— WAF,一个没有感情的安检机器



请求离开CDN后,仍然不能直接到达源站。它先要经过WAF——Web应用防火墙。


这个组件的作用,名字已经写得很清楚:为了安全


它像个眼神锐利的保安,把你从头扫到脚:



  1. 检查IP是否合法 → 不合法返回 403 Forbidden

  2. 检查请求头是否合法 → 不合法返回 406 Not Acceptable

  3. 检查请求体是否合法 → 不合法返回 413 Payload Too Large

  4. 判断请求频率是否正常 → 不合法返回 429 Too Many Requests


WAF状态码场景


攻击/异常类型状态码死因报告
黑名单IP/SQL注入/XSS403“你身上有刀,不许进。”
无效Accept头406“你要的东西我给不了,别进了。”
超大请求体413“你扛的箱子太大了,进不来。”
CC攻击/高频请求429“你来回跑太多次了,歇会儿。”

这些“不正经”的请求方式,其实就是网络安全课里讲的攻击手段。



“我只是想进来看看,它说我是黑客。”


—— 一个带着正常User-Agent却被误杀的公司内网爬虫





第三站:负载均衡器 Nginx



“一万个用户就要一万个进程?凭什么等网速还要占着位置?”


—— Igor Sysoev,Nginx之父,2002年



对于这个组件,一开始我也不明白它为什么有那么多功能。


要认识一件东西,最好的方式是了解它为什么被创造出来




2002年,莫斯科。


Apache的规矩:来一个人开一个进程,来一万个人开一万个进程。


16G内存,Apache张嘴要50G,然后跪了。


Igor Sysoev每天的工作就是重启服务器——像给同一个病人反复做心肺复苏。


终于有一天他骂了句脏话:



“一万个用户就要一万个进程?凭什么等网速的时候还要占着内存?”



他觉得这不合理——像每个客人身后站一个专属服务员,客人上厕所他都得站着等。


Igor决定写一个“不讲武德”的服务器:


一个服务员管五十桌,谁招手过去,谁看菜单就晾着。不等人,不空转,不占茅坑。


两年,一万行C。


2004年,Nginx诞生。


4个进程扛1万连接,内存500MB。


Apache用50G干的活,它用1%的资源。


后来有人问他为什么写Nginx。


他说:



“等的时候,不应该占着位置。”





Nginx核心状态码


场景状态码死因报告
静态文件不存在404“你要的文件,硬盘里没有。”
静态文件无权限403“文件在那,但你不配看。”
后端无响应502“我把请求转给后面,后面没人接。”
后端超时504“后面接了电话,但一直‘嗯’个不停,就是不说话。”
客户端提前关闭499“用户等不及,把网页关了。”
限流拦截429“你刷太快了,我伺候不动。”
主动熔断503“后面的兄弟都快累死了,我先替你挡一下。”


💡 关于499


499是Nginx独有的状态码。


它不是后端返回的,不是WAF拦截的,是Nginx自己记下的遗言:


“他没等我,他走了。”


很多时候你以为的超时(504),其实是用户等得不耐烦,直接关掉了页面。


Nginx默默在日志里写下一行:
“请求已转发,但客户端已失联。”





第四站:Web 应用



“终于到我了。”


—— 一个请求,在穿过CDN、WAF、Nginx之后



终于,请求到达了后端应用。


这里的HTTP状态码,是开发者在代码里亲手写下的


它是唯一一个由你决定生死的环节。




4xx:你的问题,不是我的问题



“你发过来的东西,我尽力了,真的看不懂。”


—— 应用对400说



状态码含义死因报告
400我看不懂JSON少括号、类型传错、必填字段没带
401你没登录没带Token、Token过期、Token被篡改
403你不能进普通用户点管理员接口、IP不在白名单
404我没有查不存在的用户ID、已下架的商品
409已经有了用户名被占用、重复提交、两人同时编辑同一条数据
422内容不对邮箱格式正确但未注册、年龄传了200岁


“你说你叫admin,但我这已经有叫admin的了。”


—— 409 Conflict,注册接口的日常





2xx:一切顺利



“今天是个好日子。”


—— 200 OK,最幸福的状态码



状态码含义遗言(活着的遗言)
200成功“成了,数据给你。”
201创建成功“成了,新资源在这。”
202已接受“收下了,后面慢慢弄。”
204成功,无返回“成了,但没啥可说的。”



3xx:别找我,去那边



“我已经搬家了,这是新地址。”


—— 301,一个负责任的旧门牌



状态码含义死因报告
301永久搬家“这里不住了,以后去那边找我。”
302临时离开“现在不在,你先去隔壁。”
304没变“你手里那个还能用,别下载了。”



5xx:我炸了,不是你的错



“对不起,是我的问题。”


—— 500 Internal Server Error,一个有礼貌的崩溃



状态码含义死因报告
500代码崩溃空指针、数据库连不上、try-catch没接住
502上游乱说话第三方API返回乱码、Redis数据结构不对
503我拒绝连接池满了、服务正在重启
504上游太慢第三方API超时、SQL查了10秒


“我调了别人的接口,别人没回我。”


—— 504,一个被上游坑死的请求





链路简图 · 请求的一生



“这不是架构图,这是事故多发路段示意图。”



20260212-230754.jpeg


写在最后:状态码不是数字,是请求的“尸检报告”


行文至此,我们已经陪着一个HTTP请求走完了它的完整一生。


它从你的浏览器出发,叩开CDN的大门,穿过WAF的安检,经过Nginx的调度,最终抵达应用服务器的后厨。


而在每一道关卡,都有可能倒下——也可能凯旋。


每一个状态码,都不是随机数字,而是请求倒下的那一刻,最后一个活着的人替它写下的死因报告。




当你再看到502,你脑海里应该浮现的不是“Bad Gateway”这行英文,而是一场事故现场:



  • 也许是CDN回源时,源站说了句它听不懂的方言(非法响应)

  • 也许是Nginx转发时,后端的应用根本没在听(连接失败)

  • 也许是你的代码调用第三方API,对方接了电话但开始沉默(超时)

  • 也许是负载均衡器巡视一圈,发现所有小弟都已阵亡(无可用后端)



同一个502,七种死法。症状相同,病灶各异。



这就是为什么,学会背状态码的人只能回答“它是什么意思”,而理解链路的人能回答:


“它死在了哪一环。”




这趟旅程也告诉我们另一件事:


CDN会替你背锅,Nginx会替你扛压,WAF会替你挡刀——但它们都只是过客。


唯一从头到尾、从生到死都陪着你代码的,是你自己写的业务逻辑。


200是你写的,404是你写的,500也是你写的。



状态码不是面试官拷问你的工具,而是你的代码和这个世界对话的语言。



你用200说:“一切正常。”

你用404说:“你找的东西不在这里。”

你用500说:“抱歉,我出了点问题,已经在看日志了。”




所以,别再背状态码了。


去理解你请求走过的路,去读懂每一行日志,去亲手写下每一个你返回的状态码。


当你不再问“502是什么意思”,而是问——



“这个502是谁报的?”

“在哪一环报的?”

“日志里留下了什么线索?”



那一刻,你就不再是背答案的人,而是真的懂了。





“愿你的200永远不鸽,愿你的5xx永远有日志可查。”


—— 同一位被502逼疯的工程师,在最后一次上线后说



作者:YouRock
来源:juejin.cn/post/7605848213602779182
收起阅读 »

用 OpenClaw 做视频:播放量从几十涨到 9000,成本一毛钱

大家好,我是孟健。 我做视频号不用剪映,不用 PR ,甚至不碰任何剪辑软件。 一条 60 秒的短视频,成本一毛钱,从选题到成片 15 分钟搞定。 怎么做到的?OpenClaw(开源 AI 助理框架)+ Remotion(React 视频框架)+ 语音克隆,三件...
继续阅读 »

大家好,我是孟健。


我做视频号不用剪映,不用 PR ,甚至不碰任何剪辑软件。 一条 60 秒的短视频,成本一毛钱,从选题到成片 15 分钟搞定。


怎么做到的?OpenClaw(开源 AI 助理框架)+ Remotion(React 视频框架)+ 语音克隆,三件套组合拳。


先看成品👇


(掘金不支持视频,可以搜孟健AI编程)


今天把整套流水线拆给你看。




01 先说数据:用 AI 做视频比我自己拍还好


前几天我开始用 OpenClaw 全自动做视频号内容。结果出乎意料——AI 做的视频,数据比我自己拍的好得多。


之前我自己录制、剪辑,一条视频播放量几十到两三百,偶尔破千算运气好。


换成 OpenClaw 全自动流水线之后:



  • 单条播放量:1595(之前平均不到 200)

  • 3天 总播放:9,018




从 02-11 开始用 OpenClaw 做视频的那天起,播放量曲线直接起飞。之前一周加起来可能还不到 1000 播放。


为什么 AI 做的反而更好?我想了想,原因有三个:



  1. 更新频率上去了。以前一周发 1-2 条,现在可以日更。视频号算法喜欢活跃的账号。

  2. 风格统一了。每条视频都是同一个"赛博线框"模板,辨识度高,观众看到就知道是我。

  3. 质量反而稳定了。人工拍摄状态有起伏,AI 生产线的输出质量是恒定的。




02 整套流水线长什么样


传统做一条 60 秒视频号内容:



  • 写脚本:30 分钟

  • 录音/配音:20 分钟

  • 剪辑+字幕+动效:1-2 小时

  • 导出上传:10 分钟


总耗时:2-3 小时,还得会剪映或 PR 。


我现在的流程:



  • Agent 自动推送选题,我选一个:1 分钟

  • Agent 写旁白 → 克隆我的声音生成 TTS → 提取时间戳 → Remotion 渲染成片:约 10 分钟

  • 我看一遍,确认发布:2 分钟


总耗时:约 15 分钟。成本不到两毛钱。不需要会任何剪辑软件。




03 技术栈:四个关键零件


零件一:OpenClaw — 多 Agent 调度中心


OpenClaw 是一个开源的 AI 助理框架,核心能力是让多个 AI Agent 协作。我的团队里有 6 个 Agent,各管一摊:



  • 墨媒(运营):负责选题推送和发布

  • 墨笔(创作):写脚本、调 TTS、编排场景、渲染视频

  • 墨影(设计):封面图和配图


视频制作主要是墨笔在干活。它收到选题后,一路跑完脚本→配音→渲染,全程无人值守。



Agent 之间怎么协作? OpenClaw 有个sessions_send机制,Agent 之间直接传消息。墨媒推选题给墨笔,墨笔做完发成片链接给墨媒,墨媒通知我确认。像一条流水线,每个工位各干各的。


零件二:Remotion — 用 React 写视频


这是整套方案最"反直觉"的部分。


Remotion 是一个 React 视频框架。你写 React 组件,它帮你渲染成 MP4。 没有时间轴,没有图层面板,视频就是代码。


为什么用代码做视频?因为可复用、可模板化、可自动化。


传统剪辑:每条视频从零开始拖素材。


Remotion:定义好模板,换数据就出新片。


我的视频模板叫"赛博线框批注体"——深色背景、大字排版、小墨(我的 AI 猫助手)线条画穿插批注。风格统一,辨识度高。


核心代码结构长这样:


// scenes-data.ts — 这是唯一需要改的文件
export const scenes: SceneData[] = [
{
start: 0.0, // 开始时间(秒)
end: 3.46, // 结束时间(秒)
type: 'title', // 场景类型:决定动效
title: '三家巨头\n同一天',
xiaomo: 'peek', // 小墨姿态
},
{
start: 3.46,
end: 5.90,
type: 'pain',
title: '微软说',
subtitle: 'Copilot 已经能写掉\n90% 的代码',
number: '90%',
highlight: 'Copilot',
},
// ... 更多场景
];

每条新视频只需要改这一个文件。 场景类型决定动效——title用 glitch 闪现,emphasis用 slam 砸入,circle用猫爪画圈。动效和排版都是预设好的,换内容自动适配。


渲染一行命令:


npx remotion render WireframeVideo out/成片.mp4 --codec=h264

零件三:MiniMax 语音克隆 — 用我的声音说话


视频号的配音是我自己的声音,但不是我录的。


MiniMax 的 voice-clone 服务,用一段 30 秒的录音样本,克隆出一个可以说任何话的语音模型。生成速度快,一段 60 秒的旁白 3-5 秒出结果。



通过 fal.ai 的 API 调用,1.15 倍速,对话感很强。一条视频的 TTS 成本大概一毛钱。


零件四:Whisper — 时间戳精确对齐


TTS 生成的音频,需要知道每句话在第几秒说完,才能让 Remotion 的字幕精确对齐。


OpenAI 的 Whisper 模型(本地部署,免费)转录音频,输出逐句时间戳:


[  {"start": 0.0, "end": 3.46, "text": "三家巨头同一天说了一件事"},  {"start": 3.46, "end": 5.90, "text": "微软说Copilot已经能写掉90%的代码"},  ...]

这些时间戳直接灌进scenes-data.ts,每个场景的出场时间和旁白完美对齐。




04 完整流程:一条视频是怎么从 0 到 1 的


墨媒推选题(cron 每日 9:30
↓ Telegram 推送5个选题
孟健选一个
↓ 选题确认
墨笔写旁白脚本(60秒,200字左右)

MiniMax TTS 生成克隆语音
↓ 约¥0.13秒出结果
Whisper 提取逐句时间戳
↓ 本地运行,免费
墨笔编排 scenes-data.ts
↓ 按时间戳填场景类型+文案
Remotion 渲染 MP4
↓ h264编码,约2分钟
墨笔发成片给孟健
↓ Telegram 通知
孟健确认 → 墨媒发布

关键点:从"孟健选一个"到"成片发出来",中间全自动。 墨笔这个 Agent 收到选题后,自己写脚本、调 TTS、提时间戳、编场景、渲染视频、发通知。我只需要在 Telegram 里点一下确认。



整个过程大约 10 分钟。我的参与时间?选题 1 分钟,看成片 2 分钟。




05 赛博线框体:为什么选这个风格


视频号做内容有个核心矛盾:你得快,但你不能糙。


实拍太重(一个人搞不过来)。AI 生成画面太假(观众已经审美疲劳)。PPT 录屏太无聊。


我选了一条中间路线:纯文字动画 + 线条 IP 角色。



  • 深色背景(#0A0A0F),不刺眼,高级感

  • 大字排版,关键词高亮(cyan/gold/red 三种色系)

  • 小墨(线条猫)在角落做批注动作(探头、趴着、指向、画圈)

  • 动效精确对齐音频:glitch 嗞声配标题出场,slam 低频咚配数字砸入,draw 笔触声配猫爪画圈

  • BGM 18%音量打底,不抢旁白


这个风格的好处:全部是代码生成的。 没有一帧需要手画。小墨的 6 种姿态是 SVG 路径,动效是 CSS 动画函数,排版是 React 组件。换内容不换风格,视觉统一,品牌感强。


而且成本极低——Remotion 渲染不花钱,只有 TTS 那一毛钱。




06 踩过的坑 坑 1: TTS 速度和自然度的平衡


1.0 倍速太慢,像念稿。1.3 倍速太快,听不清。1.15 倍速是甜点。 这个参数调了好几轮才定下来。


坑 2:时间戳精度


Whisper 的时间戳偶尔会飘几百毫秒。解决方案是渲染后快速过一遍——15 分钟的流程里,2 分钟用来看成片,不算浪费。


坑 3:Remotion 的字体加载


服务器渲染时字体可能缺失。解决方案:把字体文件放到public/目录,用@font-face显式加载,别依赖系统字体。


坑 4:音效对齐


动效和音效必须精确到帧。Remotion 的Sequence组件按帧计算(30fps),但时间戳是秒。需要做Math.round(seconds * fps)的换算,差一帧观感就不对。


坑 5:不要让内容 Agent 降模型


试过把墨笔从 Claude Opus 换成 Sonnet 省钱。6 分钟就换回来了——脚本质量断崖式下跌,金句变废话,节奏感全无。内容创作是最不该省的环节。




07 成本算账


项目单价说明
TTS( MiniMax via fal.ai)~¥0.1/条60 秒旁白,语音克隆
Whisper¥0本地部署,免费
Remotion 渲染¥0开源,服务器本地跑
BGM/音效¥0预置素材库
合计~¥0.1/条

对比请人做:一条 60 秒视频号内容,外包报价 300-800 元。


2 小时变 15 分钟,800 块变一毛钱,播放量反而翻了 10 倍。 这就是把视频从"项目"变成"工序"的意义。




08 你能复制这套流程吗?


技术门槛说实话不低。你需要:



  1. 一台服务器(跑 OpenClaw + Remotion 渲染)

  2. 基本的 React 能力(定制 Remotion 模板)

  3. OpenClaw 部署经验(配 Agent + cron)

  4. MiniMax/ElevenLabs 账号(TTS)


但思路是通用的:把视频生产拆成可编程的环节,用 Agent 串起来。


你不一定要用我的技术栈。Remotion 可以换成 FFmpeg 纯命令行(更简单但动效少),TTS 可以用免费的 edge-tts(质量差一些但零成本),Agent 框架也不一定是 OpenClaw。


核心不是工具,是思路:视频 = 数据 + 模板 + 自动化。




写在最后。


我做这套系统不是为了炫技。是因为一个人创业,内容是最大的杠杆,但时间是最稀缺的资源。


传统做内容是"创作"——每次从零开始。AI 时代做内容是"生产"——定义好流水线,然后持续出货。


15 分钟一条视频,成本一毛钱,播放量比自己拍还好。工具就摆在那里。用不用,是你的事。




如果这篇对你有帮助,欢迎点赞、收藏、关注,你的支持是我持续输出的动力 ✨




我的其他平台账号和开源项目在个人主页中,欢迎交流 🤝


作者:孟健AI编程
来源:juejin.cn/post/7606173847994023990
收起阅读 »

被马斯克疯狂点赞的国产 AI,很可能是 AI 时代的抖音!

这是苍何的第 490 篇原创! 大家好,我是苍何。 刷 X 看到马斯克点赞并评论了一个 AI 叫 Loopit,目前有 59 万阅读了,🐂🍺啊。 看了下视频内容,还挺有意思的,互动性和可玩性挺高。 扒了下  Loopit,好家伙,来自中国的开发团队,创始人是...
继续阅读 »

这是苍何的第 490 篇原创!


大家好,我是苍何。


刷 X 看到马斯克点赞并评论了一个 AI 叫 Loopit,目前有 59 万阅读了,🐂🍺啊。


图片


看了下视频内容,还挺有意思的,互动性和可玩性挺高。


扒了下  Loopit,好家伙,来自中国的开发团队,创始人是陈炜鹏,前百川智能的联创。


然后我也按照教程去应用市场下了 Loopit,申请了内测体验,不到一会就通过了。


那后就开始陷入进去了,和刷抖音一样,根本停不下来,太魔性了。我甚至都有一种感觉,他们是想做 AI 时代下的抖音吧。


wxv_4381029591822368780


Loopit 我觉得让年轻人为之疯狂的是其**「互动性」**。首页具备非常多的上手就可以互动体验的内容。


如果说传统短视频是「看内容」,那 Loopit 或许想做的就是让年轻人「玩内容」 。


这是我做的一个能根据气流大小实时改变生成画面的互动内容,可以看到,吹气越猛,画面中的魔法风暴就越壮观。


wxv_4381031178158309391


我还做了一个互动性更强一些的,能根据用户唱歌水平的高低来生成画面的完整性,唱的好就能出好看的画,唱的差就会出现鬼畜画面。为了保护大家,我就用电脑放了一首歌,你可以感受一下。


wxv_4381032366186168321


还做了个用手机来颠球的互动游戏,手机上抬,乒乓球也会被抬起,力度会控制颠球的高低。


wxv_4381033376459784207


Loopit 并没有走纯 AI Coding(好玩但不真实)或纯多模态生成(美观但交互弱)的老路,而是把两者深度融合。


它与手机硬件深度融合——包括麦克风、陀螺仪、前后摄像头、触控屏、振动马达等。


让可玩性更足了。


现在手机应用市场就可以直接下载 Loopit 体验了,首页的话有非常多直接就可体验互动的内容可以玩,比如被马斯克点赞的这个应用也可以直接玩,哈哈哈。


图片


也可以创作自己想要的互动内容,我一上来就整了十几个互动应用。,根本停不下来。


图片


不过现在要自己创作的话需要填入邀请码,可以直接填写下理由申请下。我填写了苍何,然后我给我老婆手机也填了下申请,填了苍何的粉丝也很快就通过了,大家申请的时候可以试试🐶


图片



另外我也托关系联系朋友要到了几个邀请码,我放评论区了,需要自取。



我创作了十几个有意思的互动应用,下面分享下创作的思路,帮助你少踩坑,因为我发现,目前 Loopit 可能是刚上线的原因或者用的人有点多了,有时候会比较慢,然后有时候也会抽风,需要调教下。


目前在 Loopit 上创作一共 2 种方式,一种是原创,就是你通过提示词的方式手搓一个应用出来,另外一种就是二次创作,你可以基于你刷到的好玩的应用进行魔改。


图片


比如风之谷这个应用,我给的提示词是这样的:


《风之谷》:吹气生成微观世界调用接口: 麦克风(气流侦测)+ AI 实时生成(Stable Video Diffusion/LCM)
玩法描述: 用户对着麦克风吹气,屏幕中原本静止的荒漠或森林会随着吹气的力度和频率,实时长出奇幻的花草、飘起花瓣或化作星尘。
AI 点: 根据气流大小实时改变生成画面的风力等级(Prompt 权重动态调整)。
互动反馈: 吹气越猛,画面中的“魔法风暴”越壮观。

图片


大家都提示词建议按照这个结构要求稳定性会更高一些,创建一个xx的互动内容,主题是xxx。玩法描述,AI 点以及互动反馈。


会请求手机麦克风权限,然后就开始 vibe coding,出错了,你就直接口喷让他改就好了。


图片


通常需要等待个几分钟,就能直接生成好应用,然后就点预览,开始玩了。


图片


觉得应用 ok,或者想给朋友玩,可以点击发布,就会发布到应用广场了。


Loopit 它构建了一个专门为 AI 生成设计的**「互动 Runtime」** 。简单来说,它能把你的点击、摇晃、甚至是声音,转化为结构化的指令,并让 AI 在代码定义的边界内实时更新「世界状态」 。


图片


这种融合带来了几个直观的感受:



  • 多维度输入:它能直接调用手机的陀螺仪、麦克风、甚至是前置摄像头。

  • 逻辑稳定:不管你怎么「折腾」内容,世界状态都不会轻易崩坏,反馈非常即时。

  • 创作极简:不需要写代码,平均 3 轮对话就能搓出一个作品。


也难怪海外那么多年轻人喜欢,甚至马斯克都亲自点赞。


我认为它代表了一种新的趋势,在 Loopit 里,创作不再需要刻意寻找主题。一个热梗、一个奇怪的念头,甚至是你忍不住反复做的小动作,都能成为「好玩」的起点。


从对比数据来看,这种「AI Coding × 多模态生成」的路线,在内容形态上最接近「互动式抖音」,且市场空间巨大。


图片


它降低了技术门槛,实现了某种程度上的「技术平权」,让每个人都能把抽象的念头变成具体的互动内容 。


目前 Loopit 还不算是一个大规模开放的游戏平台,它更像是一个正在萌芽的、属于年轻人的互动内容社区。


它现在处于跟创作者**「深度共创」**的阶段。如果你也是那种「脑洞大、爱折腾」的人,我非常建议你去体验一下这种「玩内容」的新文化。


作者:苍何
来源:juejin.cn/post/7605912122628948006
收起阅读 »

10万人都在用的 top10 skills,我帮你试了

大家好,我是码歌,一个被Skills掏空了的码哥。 最近skills.sh(skills.sh/) 上最火的10个Skills,安装量加起来已经超过10万了。我花了几天时间,把这Top 10全装了一遍,挨个测试,结果只能用一句话形容: 有些确实牛逼,有些就是凑...
继续阅读 »

图片


大家好,我是码歌,一个被Skills掏空了的码哥。


最近skills.sh(skills.sh/) 上最火的10个Skills,安装量加起来已经超过10万了。我花了几天时间,把这Top 10全装了一遍,挨个测试,结果只能用一句话形容:


有些确实牛逼,有些就是凑数的!但总体来说,跟着社区选不会错,实打实的用户下载安装!


图片


关于Skills的前世今生,如果还不熟悉的同学推荐看下我之前的的几篇文章,这里不再展开说明,传送门:

1、火爆全网的Skills,看这一篇就够了!

2、谁还手动管技能?Vercel 开源 add-skill + skills.sh,一行命令搞定所有


注意!注意!注意!

最新版cursor(mac 2.5.0)默认读取的全局skills目录是 /.cursor/skills-cursor,而skills add安装命令默认为cursor添加的目录是/.cursor/skills,我们只需要手动再次添加下符号链接就行,以后每次安装也会同步到skills-cursor目录,命令如下:

mac:


cd ~/.cursor
ln -s skills skills-cursor

windows:


cd %USERPROFILE%.cursor
rmdir /s /q skills-cursor
mklink /D skills-cursor skills

一、为什么Top 10值得关注?  


skills.sh上的安装量都是实打实的。几十w+安装不是刷的,是真有这么多人在用(就像B站播放量,虽然可能有水分,但大部分都是真用户)。



这些人天天用Claude Code写代码、做产品,他们愿意装,说明确实有用。跟着装就行,不用自己判断"这玩意到底行不行",社区已经帮你筛过一遍了(群众的眼睛是雪亮的)。


废话不多说,直接上干货。我会把每个Skill的实际使用效果、适合谁、值不值得装都告诉你(不画饼,只讲实话)。


二、Top 10 Skills完整拆解(干货来了)  


先说个整体情况:Top 10里7个是给开发者的(程序员果然是AI的主力用户),3个对产品、运营、设计师也有用(终于不是程序员专属了)。


我把值得详细说的挑出来讲,其他的列个表就行(重点突出,不浪费大家时间)。


开发者专用的(7个)(程序员福利)

这7个都是给写代码的人用的,非开发者可以直接跳到下一节(别看了,看了也看不懂)。


排名Skill安装量干嘛用的值不值得装
1vercel-react-best-practices37600+React/Next.js性能优化,57条规则⭐⭐⭐⭐⭐ 前端必装
2web-design-guidelines28500+检查网页是否符合设计规范⭐⭐⭐⭐ UI返工多的人必装
3remotion-best-practices18800+用代码做视频的最佳实践⭐⭐⭐ 做视频的才需要
5skill-creator3700+官方出的,教你怎么创建Skill⭐⭐⭐ 想自己做Skill的装
6building-native-ui2700+Expo手机App开发指南⭐⭐⭐ 做移动端的装
8better-auth-best-practices2300+登录认证系统最佳实践⭐⭐⭐⭐ 做登录的必装
10upgrading-expo2200+Expo框架升级指南⭐⭐ 升级时才用

实际测试感受:


1、vercel-react-best-practices:这个确实牛逼。我叫他使用这个skills分析下我之前的项目“AI灯塔导航”,它直接指出了几个性能问题:Re-render 优化、事件监听器、1渲染性能等。改完之后性能提升明显。前端开发必装。

图片


2、web-design-guidelines:UI审查很细致(比设计师还严格)。我让cursor用这个skill检查下项目“AI灯塔导航”UI相关问题,它指出了可访问性缺少、语义化HTML问题、焦点管理缺失等不符合规范等10多个问题(就像找了个严格的老师,一点小问题都不放过)。产品经理和设计师协作多的,这个很有用(终于不用再因为设计规范问题吵架了)。


图片


3、better-auth-best-practices:登录认证这块踩坑多(就像过雷区,一不小心就炸),这个Skill把常见问题都覆盖了:密码加密、JWT过期处理、CSRF防护、OAuth流程等(就像给了你一张"避雷地图")。做登录系统的,装了这个能少踩很多坑(终于不用再因为安全问题被用户投诉了)。


图片



所有人都能用的(3个)⭐(重点来了)

这3个是我觉得Top 10里最值得说的,不写代码也能用(终于不用再羡慕程序员了)。


1、frontend-design(45.5k +安装)


图片


来自Anthropic官方。目标很简单:让Claude做出来的东西别那么"AI味"


你可能有过这种体验——让Claude帮忙做个网页或PPT,出来的东西能用,但没特色,一眼就知道是AI做的(就像穿了校服,虽然整齐但毫无个性)。这个Skill就是治这个的,专门给AI的设计"整容"。


它明确告诉Claude不要用什么:



  • 不要用Inter、Roboto、Arial这些"标准字体"(太没个性)

  • 不要用紫色渐变配白底(AI最爱用,已经烂大街了)

  • 不要用对称布局(打破常规才有设计感)


同时告诉Claude应该怎么做:



  • 选择有个性的字体

  • 配色要有主次,主色大胆、强调色锐利

  • 动效要克制,一个精心设计的页面加载动画,比到处都在动更高级


实际测试:  我让Claude用这个Skill帮我设计一个部署平台原型,确实比之前有设计感多了。虽然还是能看出是AI做的(毕竟AI的"审美"还是有迹可循),但至少不那么"标准"了,至少从"一眼AI"变成了"需要仔细看才能发现是AI"。


图片图片


安装命令:


npx skills add https://github.com/anthropics/skills --skill frontend-design

2、agent-browser(24.1k+安装)


图片


这个Skill不太一样,它不是"知识库",而是"工具"(就像给Claude装了个机械臂)。装上之后,Claude可以帮你操作浏览器:



  • 自动打开网页、点击按钮、填写表单(终于不用自己点点点了)

  • 批量截图(再也不用一张一张手动截)

  • 自动登录网站(保存登录状态,下次直接用,懒人福音)

  • 录制操作过程(以后可以回放,看看AI是怎么"思考"的)


实际测试:  我让Claude帮我登录5个平台查数据,它真的自动完成了。虽然有些网站需要验证码会卡住(AI再聪明也过不了"你是人类吗"这关),但大部分操作都能自动化。运营、测试、产品都能用得上,特别是那些重复性的"点点点"工作。


安装命令:


npx skills add https://github.com/vercel-labs/agent-browser --skill agent-browser

3、seo-audit(13k+安装)


图片


终于来了一个纯运营向的Skill(程序员终于不用再被问"为什么网站搜不到"了)。


这是一个完整的SEO审计框架,让Claude帮你检查网站的SEO健康度(就像给网站做体检):



  • 能被Google找到吗?(爬虫能不能访问、有没有被收录,别做"隐形网站")

  • 网站快不快?(加载速度影响排名,慢得像蜗牛可不行)

  • 内容优化了吗?(标题、描述、关键词布局,别让搜索引擎"看不懂")

  • 内容质量够不够?(是否值得被推荐,别写一堆废话)

  • 有没有可信度?(外链、权威性,别让人家觉得你是"野鸡网站")


实际测试:  我让Claude审计了一下我的项目“AI灯塔导航”,它给出了多个真实存在的问题,每个都标明了影响程度和修复优先级(就像医生开药方,告诉你先治什么后治什么)。做网站的都能用,不需要懂技术(终于不用再求程序员了)。


图片


安装命令:


npx skills add https://github.com/coreyhaines31/marketingskills --skill seo-audit

三、额外收获:23个营销Skills  


Top 10里大部分是开发向的,但别急(程序员也有春天)。


我在安装过程中发现了一个宝藏仓库:coreyhaines31/marketingskills


这个仓库有23个营销相关的Skill,从文案到定价到投放都有(一站式营销工具箱,比瑞士军刀还全):


图片


Skill功能适合谁
copywriting营销文案写作市场、运营
copy-editing文案润色修改市场、运营
pricing-strategy定价策略设计产品、创业者
launch-strategy产品发布策略产品、市场
seo-auditSEO诊断运营、独立开发者
ab-test-setupA/B测试设计产品、运营
page-cro落地页转化优化运营、增长
signup-flow-cro注册流程优化产品、增长
email-sequence邮件营销序列市场、运营
social-content社交媒体内容市场、运营
paid-ads付费广告投放市场
referral-program推荐计划设计增长、产品
marketing-psychology营销心理学市场、产品

安装命令:


npx skills add coreyhaines31/marketingskills --yes

一次性装23个。


我的看法:  做产品、运营、市场的,这个仓库比Top 10更值得装(就像找到了组织,终于不用再自己摸索了)。


四、熟悉的宝玉老师的Skills


翻Top 100的时候,发现了一些熟悉的名字——宝玉老师(@dotey)的Skills(就像在异国他乡遇到了老乡,亲切感爆棚)。


宝玉老师是X上的AI大V,经常分享Claude Code的使用心得。他把自己的工作流打包成了一堆Skills,放在 jimliu/baoyu-skills 仓库(这就是传说中的"授人以渔不如授人以鱼"):


Skill功能安装量
baoyu-slide-deck幻灯片生成972
baoyu-article-illustrator文章配图938
baoyu-cover-image封面图生成868
baoyu-xhs-images小红书图片841
baoyu-comic漫画生成822
baoyu-post-to-wechat发布到微信752
baoyu-post-to-x发布到X725
baoyu-infographic信息图生成480

图片图片图片


这些Skills对中文用户特别友好——小红书图片、发微信、文章配图,都是我们平时会用到的(终于不用再自己手动P图了,AI帮你搞定一切)。


安装命令:


npx skills add jimliu/baoyu-skills --yes

五、文档处理四件套


Anthropic官方仓库(anthropics/skills)里有4个文档处理Skill(就像Office套件,但这次是AI版的):



  • pdf —— PDF读取、提取、合并(再也不用装各种PDF工具了)

  • docx —— Word文档处理(写文档、改格式,AI帮你搞定)

  • pptx —— PPT生成和编辑(做PPT终于不用熬夜了)

  • xlsx —— Excel处理(数据分析、公式计算,AI比你算得还快)

    图片


这几个所有人都能用。装上之后让Claude帮你处理文档,会顺手很多(就像给AI装了个Office,但它比Office还聪明)。


安装命令:


npx skills add anthropics/skills --yes

六、为什么你应该去看看skills.sh  


说两个实际的好处(不画饼,都是大实话)。


第一,这些Skill确实有用(不是智商税)。


skills.sh上排名靠前的,都是被大量vibe coder实际装过、用过的。37000+安装不是刷的,是真有人在用(就像淘宝好评,虽然可能有刷的,但大部分都是真用户)。


这些人天天用Claude Code写代码、做产品,他们愿意装,说明确实有用。跟着装就行,不用自己判断"这玩意到底行不行",社区已经帮你筛过一遍了(就像跟着大众点评选餐厅,虽然不一定最好,但至少不会踩雷)。


第二,这是学习Skills最好的方式(比看文档强多了)。


很多人看了我之前的文章,知道Skills是啥了,但还是不知道怎么下手——自己的Skill该怎么写?(就像知道怎么吃,但不知道怎么做)


最好的学习方式不是啃文档,是看别人怎么写的(就像学做菜,看视频比看菜谱快)。


装几个热门Skill之后,让Claude Code或者cursor等agent帮你解读:


帮我读取并解释 ~/.agents/skills/seo-audit/SKILL.md 的实现逻辑

Claude会告诉你(就像找了个老师,手把手教你):



  • 这个Skill的触发条件是怎么写的(什么时候AI会"想起来"用这个Skill)

  • 指令是怎么组织的(怎么让AI"听话")

  • 为什么要这样分层(为什么这样设计更合理)

  • 哪些设计可以借鉴(哪些可以"抄作业")


看3-5个写得好的,你就知道了(就像看了几部好电影,就知道怎么拍电影了):



  • Skill该怎么组织(结构怎么搭)

  • 好的Skill长啥样(标准是什么)

  • 自己的工作流怎么打包(怎么把自己的经验变成Skill)


从模仿开始,比从零开始容易多了(站在巨人的肩膀上,总比自己造轮子强)。


所以我建议:先装、先用、先看,再想自己要不要做(实践出真知,别光想不做)


七、几个实际建议


1. 根据你的岗位选择(别乱装)



  • 开发者:vercel-react-best-practices、anthropics/skills(写代码的,装这些就够了)

  • 产品经理:seo-audit、marketingskills仓库、agent-browser(做产品的,这些能帮你省很多事)

  • 设计师:frontend-design、web-design-guidelines(做设计的,让AI帮你检查规范)

  • 运营/市场:marketingskills仓库(23个全装上,一站式解决所有营销问题)


2. 别贪多(装太多会卡)


我装了50+个是为了写这篇文章。平时用的话,选3-5个高频的就够了


装太多,Claude启动时要加载的东西多,还是会影响上下文的。


3. 注意来源(安全第一)


Skills可以包含可执行脚本,所以要看谁发的(别什么Skill都装,小心被"钓鱼"):



  • ✅ anthropics/skills(Anthropic官方,官方出品,必属精品)

  • ✅ vercel-labs(Vercel官方,大厂出品,值得信赖)

  • ✅ 框架官方(expo/skills等,官方维护,更新及时)

  • ⚠️ 个人仓库谨慎点(就像下载软件,别从不明来源下载)


4. 先用再说(实践是检验真理的唯一标准)


不用完全搞懂原理,先装一两个用起来。用过才知道好不好(就像买衣服,不试穿怎么知道合不合适)。


八、最后,别光看,快去试  


skills.sh出来之后,用Skills变简单了,以前得自己写,现在直接装别人的就行(站在巨人的肩膀上,真香)。而且不只是程序员能用——营销、产品、运营,都有对应的Skills(AI终于不只是程序员的玩具了)。


去skills.sh看看,找一两个和你工作相关的装上试试(别光看,动手试试,又不会怀孕)。


比如你是运营,装个seo-audit,然后问Claude:"帮我审计一下我们的官网SEO"(终于不用再求程序员了)。


比如你是产品,装个pricing-strategy,然后问Claude:"帮我分析一下我们产品的定价策略"(终于不用再拍脑袋定价了)。


试过就知道好不好使了(实践出真知,别光听我说)。


哦对,虽然上面给了你怎么安装这些skills的代码,但其实最佳实践还是你直接把这篇文章,以及把skills.sh的网址丢给Claude Code 或者 Cursor,用自然语言让他帮你选择及安装就好了(让AI帮你装AI的Skill,这就是AI的"自举")。



我是”程序员码歌“,全网昵称统一,10+年大厂程序员,专注AI工具落地与AI编程实战输出,在职场,玩转副业,目标副业年收入百万,探索可复利、可复制的一人企业成长模式,可去gzh围观



作者:程序员码歌
来源:juejin.cn/post/7604757482005053503
收起阅读 »

神了,WebSocket竟然可以这么设计!

关注我的公众号:【编程朝花夕拾】,可获取首发内容。 01 引言 长连接是业务项目中经常遇到的技术,往往用于数据向前端推送,如各种大屏、驾驶舱等实时数据的展示。单向推送可能会选择SSE,SSE因为AI时代的到来,逐步被大家熟知,而WebSocket作为经典的...
继续阅读 »

关注我的公众号:【编程朝花夕拾】,可获取首发内容。




01 引言


长连接是业务项目中经常遇到的技术,往往用于数据向前端推送,如各种大屏、驾驶舱等实时数据的展示。单向推送可能会选择SSESSE因为AI时代的到来,逐步被大家熟知,而WebSocket作为经典的双向通讯,也经常被用来做数据推送。


今天聊一下,我发现的一种特殊的设计,可以单独将基于NettyWebSocket单独部署,接入时,只需要引入API,初始化客户端即可完成对接。直接隔离了WebSocket服务端的编码。


02 普通应用


WebSocket的普通接入,需要编写WebSocket服务端。通过浏览器原生 API即可实现。


2.1 前端代码


浏览器原生的代码:


if ('WebSocket' in window) {
const websocket = new WebSocket("ws://localhost:9090/testWs");
} else {
alert('当前浏览器不支持 WebSocket');
}

websocket.onopen = function(event) {
console.log('WebSocket 连接成功');
};

websocket.onmessage = function(event) {
console.log('收到消息:', event.data);
};

websocket.onerror = function(error) {
console.error('WebSocket 错误:', error);
};

websocket.onclose = function(event) {
console.log('WebSocket 连接关闭');
};

// 发送消息
function sendMessage() {
const message = document.getElementById('text').value;
websocket.send(message);
}

// 关闭连接
function closeConnection() {
websocket.close();
}


2.2 服务端代码


@Slf4j
@Component
public class WebSocketServer {

@Getter
private ChannelGr0up channelGr0up = new DefaultChannelGr0up(GlobalEventExecutor.INSTANCE);

public void start() throws InterruptedException {
EventLoopGr0up bossGr0up = new NioEventLoopGr0up();
EventLoopGr0up workGr0up = new NioEventLoopGr0up();

ServerBootstrap serverBootstrap = new ServerBootstrap();
serverBootstrap.group(bossGr0up, workGr0up);
serverBootstrap.channel(NioServerSocketChannel.class);
serverBootstrap.childHandler(new ChannelInitializer<SocketChannel>(){

@Override
protected void initChannel(SocketChannel socketChannel) throws Exception {
ChannelPipeline pipeline = socketChannel.pipeline();
pipeline.addLast(new HttpServerCodec());
pipeline.addLast(new HttpObjectAggregator(65535));
pipeline.addLast(new WebSocketServerProtocolHandler("/testWs"));
// 自定义的handler,处理业务逻辑
pipeline.addLast(new SimpleChannelInboundHandler<TextWebSocketFrame>() {

@Override
public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
// 建立客户端
Channel channel = ctx.channel();
log.info("客户端建立连接:channelId={}", channel.id());
channelGr0up.add(channel);
}

@Override
public void handlerRemoved(ChannelHandlerContext ctx) throws Exception {
// 断开链接
Channel channel = ctx.channel();
log.info("客户端断开连接:channelId={}", channel.id());
channelGr0up.remove(channel);
}

@Override
protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame msg) throws Exception {
// 接受消息
Channel channel = ctx.channel();
log.info("收到来自通道channelId[{}]发送的消息:{}", channel.id(), msg.text());

// 广播通知所有的客户端
channelGr0up.writeAndFlush(new TextWebSocketFrame("收到来自channelId[" + channel.id() + "]发送的消息:" + msg.text() + "123_"));
}
});
}
});

// 配置完成,开始绑定server,通过调用sync同步方法阻塞直到绑定成功
ChannelFuture channelFuture = serverBootstrap.bind(9090).sync();
log.info("Server started and listen on:{}",channelFuture.channel().localAddress());
// 对关闭通道进行监听
channelFuture.channel().closeFuture().sync();
}
}

2.3 效果演示


为了方便演示,我直接使用在线测试工具:


webfem.com/tools/ws/in…



2.4 设计思想


设计如图:



这就是一个简单的点对点的一个设计。这样的设计本身没有什么问题,但是面对不同的业务系统都要接入WebSocket,我们就需要将服务端的代码复制一份,然后修改成适合自己业务项目的逻辑。


如果业务项目比较多,就会出现大量重复的代码,如我们公司就有20多个业务系统。从《代码重构》这本书中,就得知这是一种坏的味道,需要我们想办法优化。


如何来优化呢?按照阿里程序员的说话,没有什么是加一个中间层不能解决的,如果不能那就再加一层。


03 独特的设计


3.1 总览


如何通过中间层去解耦呢?


为了将WebSocket能够复用,就需要通过一个中间层能够作为一个传递者。既可以让用户直接连接WebSocket,也可以通过中间层直接推送消息。


我们来看看最终的设计流程:



3.2 流程分析


在流程分析执之前,我们需要说明引入的中间层。



  • Socket中间客户端

  • Socket服务


Socket中间客户端


Socket中间客户端作为一个jar传递于业务项目中,用来代替WebSocket直接推送消息给Socket客户端。同时也会将WebSocket服务的IP和端口暴露给客户端。


Socket中间客户端是基于NettySocket客户端,通过Bootstrap bootstrap = new Bootstrap()实例化,遵循TCP协议。详见代码。


Socket服务


为什么需要引入Socket服务呢?这也是小编之前非常疑惑的地方,直到自己搭建才知道为什么这么设计。


由于Socket中间客户端无法直接连接WebSocket,那么那就要一个完全基于TCP协议的Socket服务,就可以和Socket中间客户端建立连接。


Socket服务WebSocket位于同一个服务,就可以获取到WebSocket的所有通道(channel),就可以将消息推送给客户端了。


运行流程



  • ① 客户端通过业务项目暴露的WebSOcketIP和端口给前端,前端用来建立WebSocket连接。当着这个主要针对H5。类似安卓或者IOS有支持TCPSDK,就可以直接连接Socket服务了。

  • ② 随着业务项目启动建立与Socket服务的连接,等待随时给Socket服务发送消息。

  • Socket服务接收到消息后,直接获取WebSocket的通道。然后通过通道可以推送消息。

  • ④ 获取到通道之后,就可以直接推送消息给前端了。


所以每次使用,只需要引入Jar,需要推送消息给客户端,只需要直接调用方法推送即可。


04 设计实现


4.1 WebSocket服务端


代码同2.2的代码


WebSocket服务的端口是9090


4.2 Socket服务端


@Slf4j
@Component
public class SockerServer {

@Autowired
private WebSocketServer webSocketServer;

public void start() throws InterruptedException {
EventLoopGr0up bossGr0up = new NioEventLoopGr0up();
EventLoopGr0up workGr0up = new NioEventLoopGr0up();

ServerBootstrap serverBootstrap = new ServerBootstrap();
serverBootstrap.group(bossGr0up, workGr0up);
serverBootstrap.channel(NioServerSocketChannel.class);
serverBootstrap.childHandler(new ChannelInitializer<SocketChannel>(){

@Override
protected void initChannel(SocketChannel socketChannel) throws Exception {
ChannelPipeline pipeline = socketChannel.pipeline();
pipeline.addLast(new DelimiterBasedFrameDecoder(2048, Unpooled.copiedBuffer("_".getBytes())));
pipeline.addLast(new StringDecoder(StandardCharsets.UTF_8));
pipeline.addLast(new StringEncoder(StandardCharsets.UTF_8));
// 自定义的handler,处理业务逻辑
pipeline.addLast(new SimpleChannelInboundHandler<>() {

@Override
public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
// 建立客户端
Channel channel = ctx.channel();
log.info("Socket客户端建立连接:channelId={}", channel.id());
}

@Override
public void handlerRemoved(ChannelHandlerContext ctx) throws Exception {
// 断开链接
Channel channel = ctx.channel();
log.info("Socket客户端断开连接:channelId={}", channel.id());
}

@Override
protected void channelRead0(ChannelHandlerContext ctx, Object msg) throws Exception {
// 接受消息
Channel channel = ctx.channel();
log.info("Socket收到来自通道channelId[{}]发送的消息:{}", channel.id(), msg);
// 通过WebSocket将方法发送给客户端
webSocketServer.getChannelGr0up().writeAndFlush(new TextWebSocketFrame("收到来自channelId[" + channel.id() + "]发送的消息:" + msg + "123_"));
}
});
}
});

// 配置完成,开始绑定server,通过调用sync同步方法阻塞直到绑定成功
ChannelFuture channelFuture = serverBootstrap.bind(9091).sync();
log.info("Server started and listen on:{}",channelFuture.channel().localAddress());
// 对关闭通道进行监听
channelFuture.channel().closeFuture().sync();
}
}

Socket服务的端口是9091


4.3 Socket中间客户端


@Slf4j
public class MockClient {

@Getter
private SocketChannel socketChannel;

public void connect() throws InterruptedException {
EventLoopGr0up eventLoopGr0up = new NioEventLoopGr0up();
Bootstrap bootstrap = new Bootstrap();
bootstrap.channel(NioSocketChannel.class);
bootstrap.option(ChannelOption.SO_KEEPALIVE, true);
bootstrap.option(ChannelOption.SO_BACKLOG, 500);
bootstrap.group(eventLoopGr0up);

bootstrap.handler(new ChannelInitializer() {
@Override
protected void initChannel(Channel channel) throws Exception {
ChannelPipeline pipeline = channel.pipeline();
pipeline.addLast(new DelimiterBasedFrameDecoder(2048, Unpooled.copiedBuffer("_".getBytes())));
pipeline.addLast(new StringDecoder(StandardCharsets.UTF_8));
pipeline.addLast(new StringEncoder(StandardCharsets.UTF_8));
pipeline.addLast(new SimpleChannelInboundHandler<String>(){
@Override
protected void channelRead0(ChannelHandlerContext ctx, String msg) throws Exception {
log.info("client receive: {}", msg);
}
});
}
});

ChannelFuture channelFuture = bootstrap.connect("127.0.0.1", 9091).sync();
this.socketChannel = (SocketChannel) channelFuture.channel();
}
}

Socket只是用来发送消息的,所以不同处理接受的消息。注意这里的中间客户端连接的是Socket服务,端口是9091


4.4 配置启动


@Slf4j
@Component
public class StartConfig {

@Autowired
private WebSocketServer webSocketServer;
@Autowired
private SockerServer socketServer;


@PostConstruct
public void init() {
ExecutorService executorService = Executors.newFixedThreadPool(2);
executorService.execute(() -> {
log.info("websocket init ....");
try {
webSocketServer.start();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});

executorService.execute(() -> {
log.info("socket init ....");
try {
socketServer.start();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
}
}

这个就是独立部署的Socket服务配置,两个服务分别使用多线程启动。


4.5 模拟数据推送


@Test
void contextLoads() throws Exception {
MockClient mockClient = new MockClient();
mockClient.connect();
SocketChannel socketChannel = mockClient.getSocketChannel();

new Timer().schedule(new TimerTask() {
@Override
public void run() {
System.out.println("send msg...");
socketChannel.writeAndFlush("foo test..._");
}
}, 0, 2000);

System.in.read();
}

每个2s发送一次消息。


4.6 客户端


客户端同样用在线测试工具代替。


4.7 演示



05 小结


这就完成了WebSocket的解耦。关于Socket消息的编解码,有很多注意点,在搭建过程中,总会不成功, 需要根据连接的协议选择不同的编解码,才能正确的接受和发送信息。这些留到后面的文章继续介绍。


作者:SimonKing
来源:juejin.cn/post/7592079304924889098
收起阅读 »

一个Java工程师的17个日常效率工具

作为一名Java工程师,效率就是生产力。那些能让你少写代码、少改BUG、少加班的工具,往往能为你节省大量时间,让你专注于解决真正有挑战性的问题。 下面分享的这些工具几乎覆盖了Java开发全流程,从编码、调试到构建、部署,每一个环节都能大幅提升你的工作效率。 一...
继续阅读 »

作为一名Java工程师,效率就是生产力。那些能让你少写代码、少改BUG、少加班的工具,往往能为你节省大量时间,让你专注于解决真正有挑战性的问题。


下面分享的这些工具几乎覆盖了Java开发全流程,从编码、调试到构建、部署,每一个环节都能大幅提升你的工作效率。


一、IDE增强类工具


1. IntelliJ IDEA终极版 + 精选插件


作为Java开发的首选IDE,IntelliJ IDEA本身已经非常强大,但配合以下插件,效率可以再提升一个档次:



  • Key Promoter X: 显示你手动操作的快捷键,帮助你养成使用快捷键的习惯

  • AiXcoder Code Completer: 基于AI的代码补全,比IDEA自带的更智能

  • Maven Helper: 解决Maven依赖冲突的神器

  • Lombok: 减少模板代码编写

  • Rainbow Brackets: 彩色括号,让嵌套结构一目了然


实用技巧:创建多个Live Templates(代码模板),比如定义日志、常用异常处理、单例模式等。每天能节省几十次重复输入。


2. Lombok


虽然这是一个库,但它堪称效率工具。通过注解的方式,自动生成getter/setter、构造函数、equals/hashCode等方法,大幅减少模板代码量。


@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserDTO {
private Long id;
private String username;
private String email;
// 无需编写getter/setter/构造函数/toString等
}

注意事项:使用@EqualsAndHashCode时,注意排除可能造成循环引用的字段;使用@Builder时,考虑添加@NoArgsConstructor满足序列化需求。


二、调试与性能分析工具


3. Arthas


阿里开源的Java诊断工具,它能在线排查问题,无需重启应用。最强大的是它能够实时观察方法的入参、返回值,统计方法执行耗时,甚至动态修改类的行为。


常用命令:



  • watch 监控方法调用

  • trace 跟踪方法调用链路

  • jad 反编译类

  • sc 查找加载的类

  • redefine 热更新类


实战示例:线上问题排查,不方便加日志时,用watch命令观察方法执行:


watch com.example.service.UserService queryUser "{params,returnObj}" -x 3

4. JProfiler


Java剖析工具的王者,能够分析CPU热点、内存泄漏、线程阻塞等问题。与其他分析工具相比,JProfiler的UI更友好,数据呈现更直观。


核心功能



  • 内存视图:找出占用内存最多的对象

  • CPU视图:定位热点方法

  • 线程视图:发现死锁和阻塞

  • 实时遥测:监控线上应用,无需重启


技巧:养成定期对自己负责的服务做性能分析的习惯,很多问题在上线前就能发现。


5. Charles/Fiddler


抓包工具是API调试的必备利器。Charles(Mac)或Fiddler(Windows)能够拦截、查看和修改HTTP/HTTPS请求和响应。


实用功能



  • 模拟网络延迟

  • 请求重写

  • 断点调试HTTP请求

  • 反向代理


在前后端分离开发和调试第三方API时,这类工具能节省大量时间。


三、代码质量工具


6. SonarQube + SonarLint


SonarQube是静态代码分析工具,可以检测代码中的漏洞、坏味道和潜在bug。而SonarLint是其IDE插件版,能在你编码时实时提供反馈。


最佳实践



  • 在CI流程中集成SonarQube

  • 为团队制定"质量门"标准

  • 使用SonarLint实时检查,避免代码审查时返工


技巧:自定义规则集,忽略对特定项目不适用的规则,避免"过度洁癖"。


7. ArchUnit


用代码的方式测试架构规则,确保项目架构不会随着时间推移而腐化。


@Test
public void servicesAndRepositoriesShouldNotDependOnControllers() {
ArchRule rule = noClasses()
.that().resideInAPackage("..service..")
.or().resideInAPackage("..repository..")
.should().dependOnClassesThat().resideInAPackage("..controller..");

rule.check(importedClasses);
}

将架构约束加入单元测试,比写文档更有效,因为违反规则会导致测试失败。


8. JaCoCo


代码覆盖率工具,与Maven/Gradle集成,生成直观的HTML报告。它不仅统计单元测试覆盖了哪些代码,还能显示哪些分支没有测试到。


实用配置:在Maven中设置覆盖率阈值,低于阈值则构建失败:


<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>

四、API开发与测试工具


9. Postman + Newman


Postman是API开发和测试的标准工具,而Newman是其命令行版本,适合集成到CI/CD流程中。


高级用法



  • 环境变量管理不同测试环境

  • 请求前/后脚本自动化测试

  • 导出集合到Newman在CI中执行

  • 团队共享API集合


技巧:为每个项目创建环境变量集合,包含测试环境、开发环境、生产环境配置,一键切换。


10. OpenAPI Generator


从OpenAPI(Swagger)规范自动生成API客户端和服务器端代码。


openapi-generator generate -i swagger.json -g spring -o my-spring-server

前后端并行开发时,通过API优先设计,让前端可以基于Swagger UI与Mock服务器工作,而后端则基于生成的接口实现业务逻辑。


五、数据库工具


11. DBeaver


全能型数据库客户端,支持几乎所有主流数据库,功能强大且开源免费。


必备功能



  • ER图可视化

  • 数据导出/导入

  • SQL格式化

  • 数据库比较

  • 执行计划分析


技巧:使用其"SQL模板"功能,保存常用查询模板,提高重复查询效率。


12. Flyway/Liquibase


数据库版本控制工具,将数据库结构变更纳入版本管理,确保开发、测试和生产环境的数据库结构一致性。


以Flyway为例:


@Bean
public Flyway flyway() {
return Flyway.configure()
.dataSource(dataSource)
.locations("classpath:db/migration")
.load();
}

最佳实践



  • 每个变更一个脚本文件

  • 脚本文件命名规范化

  • 脚本必须是幂等的

  • 将验证步骤集成到CI流程


六、构建与部署工具


13. Gradle + Kotlin DSL


虽然Maven仍是Java构建工具的主流,但Gradle的灵活性和性能优势明显。使用Kotlin DSL而非Groovy可以获得更好的IDE支持和类型安全。


plugins {
id("org.springframework.boot") version "2.7.0"
id("io.spring.dependency-management") version "1.0.11.RELEASE"
kotlin("jvm") version "1.6.21"
}

dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
testImplementation("org.springframework.boot:spring-boot-starter-test")
}

优势



  • 增量构建更快

  • 依赖缓存更智能

  • 自定义任务更灵活

  • 多项目构建更高效


14. Docker + Docker Compose


容器化是现代Java开发的标配,Docker让环境一致性问题成为历史。


实用命令


# 启动开发环境所需的所有服务
docker-compose up -d
# 查看容器日志
docker logs -f container_name
# 进入容器内部
docker exec -it container_name bash

技巧:创建一个包含常用中间件(MySQL、Redis、RabbitMQ等)的docker-compose.yml,一键启动开发环境。


15. GitHub Actions/Jenkins


CI/CD是提高团队效率的关键环节。GitHub Actions适合开源项目,Jenkins则更适合企业内部构建流程。


GitHub Actions示例:


name: Java CI

on:
push:
branches: [ main ]
pull_request:
branches: [ main ]

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up JDK 17
uses: actions/setup-java@v2
with:
java-version: '17'
distribution: 'adopt'
- name: Build with Gradle
run: ./gradlew build

最佳实践:将代码风格检查、单元测试、集成测试、安全扫描全部纳入CI流程,确保代码质量。


七、辅助工具


16. PlantUML


用代码生成UML图,比拖拽式画图工具更高效,特别是需要频繁修改图表时。可以和版本控制系统无缝集成。


@startuml
package "Customer Domain" {
class Customer
class Address
Customer "1" *-- "n" Address
}
package "Order Domain" {
class Order
class LineItem
Order "1" *-- "n" LineItem
Order "*" -- "1" Customer
}
@enduml

IDEA集成:安装PlantUML插件,编写代码时实时预览图表。


17. Obsidian/Logseq


知识管理工具,基于Markdown文件的本地知识库。对于需要持续学习的Java工程师来说,构建个人知识体系至关重要。


推荐用法



  • 每学习一个新技术,创建一个页面

  • 记录常见错误和解决方案

  • 构建项目文档和架构决策记录

  • 使用日常笔记捕捉想法和灵感


技巧:利用双向链接功能,将知识点相互关联,构建知识网络,而非简单的知识树。


总结


最后,工具再好,也需要时间精力去掌握。建议每次只引入1-2个新工具,熟练后再考虑扩展。


毕竟,真正的效率来源于熟练度,而非工具数量。


作者:风象南
来源:juejin.cn/post/7506414257399939111
收起阅读 »

年薪 50W 的前端,到底比年薪 15W 的强在哪里?

昨天我看新年第一波简历 看破防了 最近团队缺人,我连着看了一周的简历。 说实话,看得我挺难受的。😖 我发现一个特别普遍的现象:很多工作了四五年的兄弟,期望薪资填个 25k 甚至 30k,但你仔细翻他的项目经历,全是后台管理系统,全是 H5 拼图页面,全是表单增...
继续阅读 »

65ef63f6bd30ab838939a4ae_Developer productivity tools 2024.webp


昨天我看新年第一波简历 看破防了


最近团队缺人,我连着看了一周的简历。


说实话,看得我挺难受的。😖


我发现一个特别普遍的现象:很多工作了四五年的兄弟,期望薪资填个 25k 甚至 30k,但你仔细翻他的项目经历,全是后台管理系统,全是 H5 拼图页面,全是表单增删改查。


你问他:这几年你遇到的最大技术难点是啥?🤔


他回你:表单字段太多了,校验逻辑太复杂。或者说,产品经理改需求太频繁。😖


听到这种回答,我心里大概就有了底:这兄弟的薪资上限,大概率锁死在 20W 以内了。


这就是咱们常说的 CRUD 困局。



别沉迷 CRUD,高薪的关键是工程化视野与底层兜底能力。想在快节奏中兼顾标准与效率?试试 RollCode 低代码平台,利用 私有化部署自定义组件 沉淀资产,轻松搞定 静态页面发布(SSG + SEO)



你会 Vue,你会 React,你会用 Antd 画页面,你会调接口。兄弟,这些在 2018 年也许能让你拿高薪,但现在是 2026 年了,这些东西是基建,是培训班出来的应届生两个月就能上手的。🤣


那么问题来了,那个坐在你隔壁工位、平时话不多、但年薪能拿 50W 的大佬,他到底比你强在哪?


是他敲键盘比你快?还是他发量比你少?


都不是。


我觉得最核心的差距,就只有三点。听我细说。




你在做填空,他在设计整张试卷


web-development-programmer-engineering-coding-website-augmented-reality-interface-screens-developer-project-engineer-programming-software-application-design-cartoon-illustration_107791-3863.avif


这事儿特别明显。就拿新开一个项目来说。


15W 的兄弟是怎么干的?


找个脚手架,create-react-app 一把梭。然后开始堆页面,写组件。遇到要用的工具函数?去百度搜一个粘贴进来。遇到样式冲突?加个 !important 搞定。代码格式乱了?不管了,先跑通再说。


他的脑子里只有一个字:做。


50W 的兄弟是怎么干的?


他在写第一行业务代码之前,会先在脑子里过一遍这几件事:


大家代码风格不一样怎么办?先把 ESLint + Prettier + Husky 这一套流水线配好,谁提交的代码格式不对,连 git push 都推不上去。


这个项目以后会不会变大?要不要直接上 Monorepo 管理?


公共组件怎么抽离?是不是该搭个私有 npm 库?


打包速度怎么优化?Vite 的配置能不能再调调?


这就是差距。🤔


老板愿意给他 50W,不是因为他页面画得快,而是因为他制定了标准。他一个人,能让团队剩下 10 个人的产出质量变高。这叫工程化视野,这才是值钱的玩意儿。




出了事,你只会甩锅,他能兜底


software-developer-vs-software-engineer-illustration.jpg


场景再具体点:用户投诉页面卡顿,加载慢。


15W 的兄弟通常反应是这样的:


打开控制台 Network 看一眼。


哎呀,接口这就 800ms 了,这后端不行啊,锅在服务端。


嗨🙂‍↔️,这图片 UI 给得太大了,切图没切好。


这数据量几万条,浏览器渲染本来就慢,我也没办法!


总之,只要不是 JS 报错,这事儿就跟我没关系。


50W 的兄弟会干嘛?


他不会废话,他直接打开 Chrome 的 Performance 面板,像做外科手术一样分析。


这一段掉帧,是不是触发了强制重排?


内存这一路飙升,是不是哪个闭包没释放,或者 DOM 节点没销毁?


主线程卡死,是不是长任务阻塞了渲染?能不能开个 Web Worker 把计算挪出去?


网络慢,是不是 HTTP/2 的多路复用没吃满?关键资源的加载优先级设对了吗?


这就叫底层能力。🤔


平时写业务看不出来,一旦遇到高并发、大数据量、若网环境这种极端场景,只会调 API 的人两手一摊,而懂底层原理的人能从浏览器内核里抠出性能。


这种 兜底能力,就是你的溢价。




他是业务合伙人!


How-to-become-a-Backend-Developer.jpg


这点最扎心。


产品经理提了个不靠谱的需求,比如要在手机端展示一个几百列的超级大表格。


15W 的兄弟:


心里骂娘:这傻X产品,脑子有坑。😡🤬


嘴上老实:行吧,我尽量试试。


结果做出来卡得要死,体验极差,上线被用户骂,回来接着改,陷入无尽加班。


这种思维模式下,你就是个执行资源,也就是个 打工人。


50W 的兄弟:


他听完需求直接就怼回去了:


哥们,在手机上看几百列表格,用户眼睛不要了?你这个需求的业务目标是啥?是为了让用户核对数据?


如果是核对数据,那我们要不要换个方案,只展示关键指标,点击再下钻看详情?这样开发成本低了 80%,用户体验还好。


这就叫技术变现。


高端的前端,不仅仅是写代码的,他是懂技术的业务专家。他能用技术方案去纠正产品逻辑,帮公司省钱,帮业务赚钱。


在老板眼里,你是成本,他是投资。🤷‍♂️




哪怕现在是 15W,咱也能翻盘


如果你看上面这些话觉得膝盖中了一箭,别慌。谁还不是从切图仔过来的?


想打破这个 CRUD 的怪圈,从明天上班开始,试着变一下:


别再只盯着那几个 API 了


Vue 文档背得再熟也就是个熟练工。去看看源码,看看人家是怎么设计响应式的,看看 React 为什么要搞 Fiber。懂了原理,你就不怕框架变。


别做重复工作


下次想复制粘贴工具函数的时候,停一下。试着自己封装一个通用的,甚至试着把你们项目里重复的逻辑抽成一个库。工程化就是这么一点点做起来的。


钻进去一个细分领域


别啥都学,啥都学不精。


可视化、低代码、Node.js 中间件、音视频,随便挑一个,把它钻透。在任何一个细分领域做到前 5%,你都有议价权。




还是那句话!前端并没有死,死的是那些 只会切图和调接口 的工具人。


50W 的年薪,买的不是你的时间,而是你 解决复杂问题 的能力,和你 避免团队踩坑 的经验。


别再满足于重复做一个 CRUD 了。下次打开编辑器的时候,多问自己一句:


除了把这个功能做出来,我还能为这段代码多做点什么?


共勉🙌


Suggestion (2).gif


作者:ErpanOmer
来源:juejin.cn/post/7591744411740372992
收起阅读 »

LLM 交互的“省钱”新姿势:JSON 已死,TOON 当立

背景 嘿,兄弟!你是不是也感觉 AI 越来越香,但 Token 账单也越来越“烫”? 💸 GPT-4o、Kimi 这些模型的上下文窗口动不动就几十万、上百万 Token,我们恨不得把整个项目都扔进去。但冷静下来看看账单... ... 哇哦 LLM 的 Toke...
继续阅读 »

背景


嘿,兄弟!你是不是也感觉 AI 越来越香,但 Token 账单也越来越“烫”? 💸


GPT-4oKimi 这些模型的上下文窗口动不动就几十万、上百万 Token,我们恨不得把整个项目都扔进去。但冷静下来看看账单... ... 哇哦


LLMToken 每一分都是真金白银啊!


当大家都在想办法优化模型、优化算法时,有没有想过,我们每天都在用的 JSON,可能就是那个“背刺”我们 Token 费用的“内鬼”?


JSON 虽好,但它实在是... ... 太!啰!嗦!了!


“内鬼”现形:JSON 到底有多浪费


在 LLM 的世界里,Token 就是钱。表达同样的信息,谁用的 Token 少,谁就是赢家。


不信?我们直接上例子,用事实说话。


假设我们有这样一个简单的用户列表:


1. 冗长的“老大哥”:JSON


标准的 JSON 格式,充满了大括号、双引号和逗号,简直是 Token 杀手。


[
{
"id": 1,
"name": "Alice",
"age": 30
},
{
"id": 2,
"name": "Bob",
"age": 25
},
{
"id": 3,
"name": "Charlie",
"age": 35
}
]

(数数看,光是 name 这个词就重复了 3 遍!)


2. “小清新”但还不够:YAML


YAML 确实清爽了不少,用缩进代替了括号,也去掉了双引号。


- id: 1
name: Alice
age: 30
- id: 2
name: Bob
age: 25
- id: 3
name: Charlie
age: 35

嗯,进步了,但不多。id, name, age 这些键名还是在无情地重复。


3. “抠门”的王者:TOON 登场!


TOON (Token-Oriented Object Notation)闪亮登场,它用了一种近乎“变态”的方式来压缩信息:


[3]{id,name,age}:
1,Alice,30
2,Bob,25
3,Charlie,35

看明白了吗?[3] 表示有3个对象,{id,name,age} 只定义了一次“表头”,后面的数据就像 CSV 一样紧凑排列。


没有对比就没有伤害! 同样的数据,TOONToken 占用量简直是“骨折价”!


啥是 TOON?为 LLM 而生的“省钱利器”


TOON(面向 Token 的对象表示法)就是这么一个专为 LLM 提示词而生的、紧凑且人类可读的数据格式。


它能表示和 JSON 一模一样的对象、数组和数据类型,但它的语法就是为了最小化 Token 使用而设计的。


你可以把它理解为 YAML 的嵌套结构 + CSV 的表格布局 = TOON


TOON 最擅长处理的场景,就是我们最常见的**“结构一致的对象数组”**。在实现 CSV 般紧凑的同时,它又提供了清晰的结构信息({key1, key2}:),帮助 LLM 更可靠地解析和验证数据。



注意: TOON 并非银弹。如果你的数据是深度嵌套或结构极其不统一的,那 JSON 可能还是老老实实的选择。但在“对象数组”这个 LLM 最常见的场景下,TOON 简直无敌。



数据为证:TOON 到底有多能打?


光说不练假把式。Chase Adams 大佬做了一组非常直观的基准测试,对比了 JSONYAMLTOONCSVToken 效率。





基准测试链接:http://www.curiouslychase.com/playground/…



结论一目了然:


CSVToken 效率的“天花板”,但它无法表示嵌套结构,而且没有元数据,LLM 很容易“读歪”。


TOON 稳坐第二把交椅,效率直逼 CSV,但它保留了完整的结构信息。


JSONYAML... ... 两位老大哥,在 Token 效率上被 TOON 吊打。


如何在 LLM 中“无痛”用上 TOON?


你可能会想:“哇,这么牛?那我岂不是要重构整个系统?”


完全不用


官方推荐的架构是这样的:



看懂了吗?TOON 只是一个**“转换层”**。


你的系统内部,该用 JSON 还是用 JSON,啥也不用改。


在调用 LLM 之前,你只需加一个编码步骤,把 JSON 编码(Encode) 成 TOON 格式再发送。


LLM 返回 TOON 格式的数据后,你再解码(Decode) 成 JSON 给系统用。


你就把它当成一个“中间件”,在和 LLM 交互的“最后一公里”上帮你省钱


别再浪费 Token 了!


LLM 时代,Token 效率就是核心竞争力。


JSON 是一个伟大的格式,但在 LLM 交互这个新场景下,它显得既臃肿又昂贵。


TOON 提供了一个完美的替代方案:它在保留 JSON 完整表达能力的同时,实现了接近 CSVToken 效率。


如果你还在为高昂的 LLM Token 费用而头疼,如果你还在忍受 JSON 带来的冗余,那么,是时候给你的系统“升个舱”了


参考



作者:小奏技术
来源:juejin.cn/post/7572453554331009024
收起阅读 »

AI 编程的临界点:当三家巨头同时宣布我们不写代码了

大家好,我是孟健。 昨天 24 小时内,三家公司同时说了同一句话:我们的代码,基本不是人写的了。 不是媒体炒作。不是 PR 包装。是 Nvidia、OpenAI、Cognition、Anthropic——四家站在 AI 最前沿的公司,几乎同一时间亮出了底牌。...
继续阅读 »

大家好,我是孟健。


昨天 24 小时内,三家公司同时说了同一句话:我们的代码,基本不是人写的了。



不是媒体炒作。不是 PR 包装。是 Nvidia、OpenAI、Cognition、Anthropic——四家站在 AI 最前沿的公司,几乎同一时间亮出了底牌。


这件事值得每个写代码的人停下来想一想。




发生了什么


先摆事实。


Nvidia:黄仁勋几个月前在内部喊出"stop coding",让 3 万名工程师全面换用 AI 编程工具。最新数据——代码产出量翻了 3 倍。不是 10%、20%的提升,是 3 倍。



OpenAI:内部团队交付了一个完整产品,每一行代码都是 AI Agent 生成的。工程师全程没写一行代码,只负责 Review 和监督。开发效率提升了 10 倍。



Cognition(做 Devin 的那家):联合创始人 Scott Wu 发了条推,说公司超过 90%的代码是 AI 写的。他的原话是:"你现在实际需要亲手敲的代码有多少?对我们来说,大概不到 10%。"



Anthropic:首席产品官 Mike Krieger 说得更直接——"Claude 在写 Claude。Claude 的产品和 Claude Code,完全由 Claude 自己写。"



四家公司,同一个结论:程序员的核心工作,正在从"写代码"变成"不写代码"。




这不是第一次有人喊"狼来了"


我知道你在想什么。


"AI 替代程序员"这话喊了三年了。2023 年 GitHub Copilot 发布的时候喊过一次。2024 年 Devin 出来的时候又喊了一次。2025 年 Claude Code 和 Codex 上线的时候再喊一次。


每次喊完,程序员还是该上班上班,该加班加班。


但这次不一样。


之前是模型公司说"我们能做到"——那是销售话术。


这次是用 AI 写代码的公司自己说"我们已经做到了"——这是生产实践。


Nvidia 不是 AI 编程工具公司,它是芯片公司。它给 3 万工程师换工具,不是为了 PR,是因为代码产出真的翻了 3 倍。当你的竞争对手用同样的人力做出 3 倍的产出,你不跟进就是在等死。


这个信号的含金量,跟"某 AI 公司发了个 Demo"完全不是一个量级。




为什么是现在


你可能好奇:AI 编程工具 2024 年就有了,为什么突然到了这个临界点?


答案是速度


就在昨天,OpenAI 发布了 GPT-5.3-Codex-Spark——一个跑在 Cerebras 晶圆级芯片上的编码模型。这是 OpenAI 第一次用非 Nvidia 的芯片部署生产级模型。



关键数字:每秒 1000+ token 的代码生成速度,比之前快 15 倍。


Cerebras 的芯片有多大?一整块硅晶圆,餐盘那么大,就是一个处理器。不是把几百个 GPU 堆在一起,是把一整个芯片做到极致。


15 倍速度意味着什么?


以前用 AI 写代码,你提交一个任务,泡杯咖啡等几分钟。现在是你话音刚落,代码就出来了。从"异步等结果"变成了"实时对话"。


这个体感差异是质变。


我自己每天用 Claude Code 做产品。之前等 AI 生成的时候我会切到别的窗口干别的事。现在?根本没时间切——它比我打字还快。


当 AI 写代码的速度快到人类来不及思考的时候,"人写代码"这件事本身就变成了瓶颈。


这就是为什么四家公司同时跨过了这个临界点。不是巧合,是速度到了。




我自己的体感


说说我的真实经历。


我在腾讯带过几十人的团队,在字节也做过前端技术 Leader。那时候团队产出的计算方式是:人头 × 工时 × 人效。想提高产出?加人、加班、优化流程。


2025 年 10 月辞职创业后,我开始全面用 AI 编程。


一个人,一个月,做了近 30 个出海小产品。


不是简单的静态页面。是有前端、有后端、有支付、有 SEO、有数据统计的完整产品。放在以前,这是一个 5-8 人的小团队干一个季度的量。


我不觉得 AI 替代了我。更准确地说,是 AI 把我从"写代码的人"变成了"做决策的人"


以前 80%的时间在写代码,20%在想产品。


现在反过来了——80%的时间在想产品方向、用户需求、商业模式,20%在 Review AI 写的代码。


这个转变,跟 Nvidia 那 3 万工程师的转变是一模一样的。




程序员要失业了吗?


这是每次 AI 编程话题下必然出现的问题。


我的判断是:不会失业,但工作内容会彻底改变。


上海交大最近发了一篇论文(ProjDevBench),测试 AI 从零构建完整软件项目的能力。结果通过率只有 27%——基础功能还行,但系统设计、性能优化、资源管理全崩。


这说明什么?


AI 已经能干 80%的活了。但剩下那 20%——架构决策、边界处理、性能调优、产品判断——恰恰是最值钱的 20%。


Scott Wu 说得好:"瓶颈不再是写代码本身,而是两件事——1)让人类更容易理解、规划和提问;2)让 AI 更容易获取任务的真实上下文。"


翻译成人话就是:未来的程序员不是代码机器,是 AI 的 项目经理 。


你的价值不再是一天能写多少行代码,而是你能不能把一个模糊的需求拆解成 AI 能理解的指令,能不能在 AI 写完之后判断"这个架构扛不扛得住",能不能在 AI 犯错的时候快速定位问题。


这些能力,恰恰是在大厂带过团队、做过大项目的人最擅长的。




如果你只做一件事


说了这么多,落到行动上,我建议你今天就做一件事:


把你最常做的一类开发任务,完整地交给 AI 做一次。


不是让它补两行代码。是给它一份需求描述,让它从零开始搞。前端、后端、数据库、部署,全交出去。


你会发现两件事:



  1. AI 能搞定的部分,比你预期的多得多。

  2. 搞不定的部分,恰恰暴露了你真正不可替代的价值。


Nvidia 的 3 万工程师已经这么干了。OpenAI 的团队已经这么干了。你还在等什么?




如果这篇对你有帮助,欢迎点赞、收藏、关注,你的支持是我持续输出的动力 ✨




我的其他平台账号和开源项目在个人主页中,欢迎交流 🤝


作者:孟健AI编程
来源:juejin.cn/post/7605816833192067072
收起阅读 »

AI 只会淘汰不用 AI 的程序员🥚

作为程序员,你竟然还在手撸代码 ??? 如果没有公司给你提供科学上网,提供AI 编程工具的账号,你真能玩转AI ??? 除了平时搜搜查查,AI 对你还有其他用处 ??? 震惊! 某博主竟然开头就贩卖焦虑?难道程序员真的要被 AI 取代了? 别急,这篇文章就一...
继续阅读 »

作为程序员,你竟然还在手撸代码 ???

如果没有公司给你提供科学上网,提供AI 编程工具的账号,你真能玩转AI ???

除了平时搜搜查查,AI 对你还有其他用处 ???



震惊!


某博主竟然开头就贩卖焦虑?难道程序员真的要被 AI 取代了?

别急,这篇文章就一步步带你玩转 AI 编程!

如果只是想了解如何使用 AI 编程,可以直接跳到章节: 「所以,我们需要什么!?」


理解概念


要深入使用 AI,我们要先理解一些概念


1. AI 基础



  • AI大模型:拥有超大规模参数、超级聪明的机器学习模型,所有的 AI 应用都是调用大模型的计算处理能力。如:问答、图片生成、视频生成。


国内主流大模型对比:


模型厂商速度能力
通义千问阿里云较快多轮对话,支持多模态,支持 PDF/Word 等文件处理。
DeepSeek深度求索推理性能一流,较慢多轮对话,代码能力国内顶级,数学推理能力出色,多模态能力弱。
豆包🐂字节跳动速度极快推理能力强,多模态能力强,语音交互自然,MCP预置多。
腾讯混元腾讯较快支持超长文本处理,与微信生态无缝集成,多格式文档解析,支持多模态。

国外主流大模型对比


模型厂商核心能力主攻场景
Gemini🐂🍺Google DeepMind多模态能力强大,可无缝处理文本、图像、音频、视频、代码等创意内容创作、文档处理、用于处理复杂、多源信息的场景
Claude🐂🍺Anthropic推理能力优秀,多模态能力一般长文档分析场景,如法律文件审查;适用于需要可靠输出的领域,如医疗诊断辅助
Veo 3Google自动生成视频和音频,口型同步精准到毫秒级。支持最高 4K 分辨率输出,画质清晰,色彩还原。生成速度快专注于视频生成领域,如短视频内容创作,为用户提供高效、高质量的视频生成解决方案。
SonnetAnthropicSonnet 是 Claude 3 系列中的平衡型模型,性价比高适用于注重性价比和处理速度的场景,如一般性的文档分析
GPTOpenAI通用性极强,各个方面都有出色表现,GPT-4o 等版本增强了多模态交互能力。自然语言处理相关的场景,如内容创作、智能客服
CopilotGitHub 与 OpenAI 合作开发基于 GPT 系列模型训练,理解自然语言,生成对应代码用于日常编码、代码调试、新手编程学习,降低重复编码工作量

一般我们编程使用的都是国外的大模型,毕竟开发工具、系统、编程语言都是外国的。编码方面的能力,国外模型还是碾压的存在。



  • MCP:一套提供给 AI 大模型调用的标准协议。一些厂商会把自己的能力包装成MCP,让大模型在理解完用户的复杂任务时,可以调用厂商的能力。比如:你让豆包给你用 “高德” 生成一份超准的导航,豆包就会去调用高德的 MCP,为你出导航~

  • IDE:程序员专属概念。AI IDE是集成了 AI 能力的软件开发平台,开发者可以通过自然语言,让 IDE 调用 AI 模型和 MCP 给你写代码,速度和质量牛的飞起,真有手就能写代码!


主流的 AI IDE 对比


名称厂商搭载的模型收费维度
Cursor🐂Cursor 公司GPT-4、Claude 3.5、Cursor-small、o3-mini 等很贵,按 token 收费
Antigravity🐂🍺Google支持在 Gemini 3 Pro、Claude Sonnet 4.5 和 GPT-OSS 等多种模型之间无缝切换有羊毛薅,国外邮箱+学生认证~
Trae字节跳动国内版搭载豆包 1.5-pro、DeepSeek R1/V3 等模型,海外版内置 GPT-4o、Claude-3.5-Sonnet 模型国内的,充个会员的事,不贵

2. RAG ➡️ Agent ➡️ Planning:AI 应用方式的演进之路


AI 的应用方式正从 “被动响应” 向 “主动规划” 快速迭代。

RAG 进行检索增强生成,到 AI Agent 实现自主调用工具完成任务,再到 Planning 能 “拆解复杂任务与全局决策” 的高阶形态。让 AI 从 “内容生成器” 蜕变到 “智能协作体”。



  • RAG —— 检索增强生成

    核心逻辑是:先检索,再生成。用户提出问题,先从外部数据库、文档库中检索与问题最相关的信息,再将这些信息作为 “参考资料” 喂给大模型,让模型基于真实数据生成回答。

  • AI Agent —— “自主工具操作员”

    AI Agent(智能体) ,让 AI 像人一样调用工具、执行步骤、验证结果能理解用户的模糊需求,自主规划任务步骤,选择并调用合适的工具(如计算器、浏览器、代码解释器、RAG 系统),完成任务。

  • Planning(规划)—— “全局任务指挥官”

    AI 系统的高阶能力,核心逻辑是 “先拆解,再执行,再调整”。基于全局目标,将复杂、长期、多约束的任务拆解为有序的子任务序列,并根据执行过程中的反馈动态调整策略。

    不仅关注单个任务的完成,更关注子任务之间的关联和整体目标的达成。


演进逻辑与核心差异总结


维度RAG(检索增强生成)AI Agent(智能体)Planning(规划)
核心定位大模型的 “知识库外挂”自主工作的 “工具操作员”全局任务的 “指挥官”
能力核心检索 + 生成,保证回答准确决策 + 工具调用,完成单任务闭环拆解 + 协同 + 动态调整,掌控多任务全局
典型比喻学生的 “参考书”能独立完成工作的 “专员”统筹全局的 “项目经理”

所以,我们需要什么!?


你作为一名优秀的程序员,你需要通过科学上网、精准付费,在 AI IDE中,基于AI大模型的能力,熟练使用 Agent/Planning,配合 MCP 等工具,让 AI 帮你写出又快又好的代码,更好的服务你的业务!


1. 使用 Cursor、Antigravity、Trae 开发工具


下载地址:CursorAntigravityTREA

账号注册:Cursor 和 Trae 登录方式都超简单,会员的话直接去官网购买即可
image.png
image.png
至于 Antigravity,因为 Google 是禁止国内用户访问的,因此一定要能正常上网,邮箱账号必须纯正🇺🇸,但是我们有 闲鱼 之光,是可以尝试下的~
image.png


2. 装好主流 MCP


对于前端程序员,UI 这类低级工作,完全可以交给 Agent 去编写。比如:公司的设计师用的是figma,我们只需要在 cursor 中装上figma mcp,然后 figma 账号申请开发者权限,就能自由的让 AI 帮我们写好代码。亲测还原度 85%+
image.png
image.png


3. 沉淀 Rules 和 Workflows


我们现在已经可以通过开发工具让 AI 干活了,但如何更符合我们的编码习惯和设计思想?那就得给 AI 规范,也就是通过提示词让 AI 更乖的,干更对的活。

比如,Antigravity就有明确的让我们添加规则和工作流的入口,并且会引导我们如何写提示词,然后在提问的时候,引用对应的文件即可。

Rules(规则)和Workflows(工作流),沉淀 沉淀 再 沉淀!!!
image.png


4. !!!文档先行!!!


AI 时代的编码,一定要做好设计,写好文档。

AI 虽然帮你干活,但是任务是你来安排的,你给出的任务要足够精准。

同时,你的编码思维才是代码能写好的核心,你必须把你的思维和想法,落成文档给到 AI 大模型。



  • markdown 格式:注意 AI 需要理解 md 文档

  • 图文并茂:在编写文档的时候,时常需要画图,此时可以使用 md 语法来画图。这里我推荐mermaidchart,可以基于 md 语法进行可视化编辑。
    image.png


写在最后


当你有正常可以使用模型的账号后,其实这个账号不仅仅是在 IDE 可以使用,比如 Antigravity 的账号,跟 Gemini 是一致的,你也可以在大模型的官网登录进行图片、视频生成。


在了解了大模型、MCP、工作流、AI 编程工具后,相信你对 AI 的应用又有了新的理解。我们一定要积极去尝试,国内国外的 AI 工具能用的多用,尽情的去拥抱 AI!


AI 是生产力,毋庸置疑!


作者:Karl_wei
来源:juejin.cn/post/7585022810181222463
收起阅读 »

JDK25已来,为何大多公司仍在JAVA8?

第一章:JDK 25 都发了,为什么大家还在 Java 8 JDK 25 发布那天,我特意去看了一眼发布说明。内容不复杂,新特性不少,语气一如既往地克制,像是在告诉你: “你可以升级了,但我们不催。” 这种感觉我在 Java 世界里已经很熟了。 同一天,Pyt...
继续阅读 »

第一章:JDK 25 都发了,为什么大家还在 Java 8


JDK 25 发布那天,我特意去看了一眼发布说明。内容不复杂,新特性不少,语气一如既往地克制,像是在告诉你: “你可以升级了,但我们不催。”


这种感觉我在 Java 世界里已经很熟了。


同一天,Python 社区的画风完全不一样。Python 3.13 的兼容性讨论、弃用警告、生态适配进度,被反复拿出来说。很多库会直接写在 README 里:“Python 3.8 即将停止支持,请尽快升级。”Java 这边没有这种集体施压。JDK 25 发布了,但 JDK 8 依然能跑、能用、能上线


我翻了下手头几个线上系统的运行环境,结果并不意外:



  • 老核心系统:Java 8

  • 偏边缘的新服务:Java 11

  • 真正用到 17 的,只有少数新项目

  • 至于 21、25,基本只存在于 PPT 和技术分享里


这不是个别现象。招聘网站、云厂商镜像、监控 SDK 默认支持版本,几乎都在默默告诉你一件事:Java 8 依然是“安全版本”(你发任你发,我用java8)。这和 Python 的升级节奏形成了非常明显的反差。


Python 2 → 3,是一次不升级就活不下去的断代。Java 8 → 25,更像是一次你可以一直不动的演进。


从技术角度看,Java 明明一直在进化:



  • 语言层面:var、record、sealed class

  • JVM 层面:GC、JIT、内存模型

  • 工程层面:模块化、工具链


但这些变化,没有哪一项是“非升不可”。


我见过不少 Java 服务,代码风格停在 2016 年,但稳定运行到今天。也见过 Python 项目,因为一个依赖不再支持旧版本,被迫整体升级。


这两种生态的差异,很早就写在设计选择里了。


Java 的向后兼容是它的优势。但到了 JDK 25 这个时间点,这个优势开始变得有点微妙。


因为问题已经不是:



JDK 8 能不能用?



而变成了:



如果一直停在 JDK 8,到底是在保守,还是在逃避某些成本?



这个问题,在技术会议上很少被正面讨论。更多时候,它会被一句话带过:


“先别动,风险太大。”


可风险到底在哪?为什么 Python 升级时大家骂归骂,还是会跟着走;而 Java 这边,哪怕官方已经跑到 25,企业却依然集体停在 8?


我后来发现,真正卡住升级的,从来不是新特性本身。而是升级这件事,一旦开始,就很难只停在“换个 JDK”上。但这件事,只有在你真的尝试过一次升级之后,才会意识到。你也就会抱怨为何JDK会把普及新特性的成本强加在每个java开发者身上




第二章:升级 JDK,看起来向下兼容,实际上并不“平滑”


很多人对 Java 升级的第一判断,来自一个几乎写进 DNA 的认知:



Java 是强向下兼容的语言。



这句话本身没错,也是大多数人从jdk7到jdk8无缝升级的真实感受。但问题在于,大多数人只把它理解成了语法层面


你用 Java 8 写的代码,放到 JDK 17、21、25 上,大概率还能编译。fortry-catchStreamlambda,一个都不会少。这也是为什么很多升级评估一开始都显得非常乐观。真正的问题是 Java 的“向下兼容”,从来不等于 JVM 的平滑迁移


第一次认真推进 JDK 升级时,我们的目标设得非常保守:不引入新语法、不改业务逻辑、不升级框架,只把运行时从 Java 8 换成 17。理论依据也很充分:代码是向下兼容的,JVM 只要能跑就行。


结果第一个暴露问题的,不是业务代码,而是 JVM 本身。


从 JDK 9 开始,Java 做了一次非常激进、但长期看又必须要做的事情:模块化(JPMS) 。这一步,本质上是在重塑 JVM 的边界。在 Java 8 之前,JDK 更像是一个“开放的整体”。JDK 自己的内部实现,和应用代码之间,并没有严格的隔离。于是很多框架、工具、甚至业务代码,都默认了一件事:



JVM 内部的类,我是可以摸得到的。



比如反射。


Field field = String.class.getDeclaredField("value");
field.setAccessible(true);

在 Java 8,这是一个非常常见、甚至被大量框架依赖的操作。但在模块化之后,这种行为被明确标记为:非法访问(Illegal Reflective Access) 。升级后,日志里开始出现大量这样的提示:


Illegal reflective access by xxx

这类 warning 很容易被误判成“噪音”。因为程序还能跑,接口也没挂。但实际上,这不是 JVM 在提醒你“写得不优雅”,而是在明确告诉你:



你现在还能用,是 JVM 在帮你兜底。



于是有人会加启动参数:


--add-opens java.base/java.lang=ALL-UNNAMED

问题是,从这一刻开始,所谓的“向下兼容”已经被你亲手打破了。你不再是被 JVM 兼容,而是用参数强行绕过 JVM 的设计边界。这也是 Java 升级过程中一个非常隐蔽的转折点:



  • 代码层面看起来没变

  • 启动参数开始越来越复杂

  • JVM 行为开始依赖“约定俗成的补丁”


而这一步,一旦走出去,基本就退不回去了。更麻烦的是,这种不平滑迁移,并不是“偶发问题”,而是 Java 设计演进的必然结果。模块化不是可选项,它是为了:



  • 限制 JVM 内部 API 滥用

  • 提升安全性

  • 为长期演进留空间


但代价是:大量在 Java 8 时代“合理存在”的用法,在新 JVM 下被系统性否定了。这也是为什么很多团队会有一种强烈的错觉:



代码明明没变,怎么升级 JDK 反而问题一堆?



因为你真正升级的,不只是一个版本号,而是 JVM 对“什么是合法行为”的判断标准。而这类问题,偏偏又很难在测试环境一次性暴露完。有的库只在特定路径触发反射;有的异常只在高并发下出现;有的 warning 今天是 warning,下一版就变成 error......


这也是 Java 升级和 Python 最大的不同。


Python 的升级是显式断代:你升级,就必须改代码。


Java 的升级是隐式收紧:你不改代码,但 JVM 会慢慢不再纵容你。


这种“看起来兼容,实际上在变”的特性,让 Java 在企业环境里变得越来越尾大不掉。不是升不了,而是你永远无法确定:



下一步,是不是会踩到一个你完全没预期过的 JVM 行为变化?



也正因为这样,很多公司最终选择了一个看似稳妥、但风险被推迟的方案:停在 Java 8。




第三章:真正让升级失败的,不是编译错误,而是线上行为变了


如果只是编译报错,JDK 升级反而简单。



编不过,改代码;启动不了,补参数;问题是可定位的,也是可回滚的。



真正让团队对升级产生恐惧的,往往发生在上线之后。升级前,所有检查都过了:



  • 单元测试全绿

  • 接口回归没问题

  • 压测 QPS、RT 都在预期范围内


代码一行没改,JDK 从 8 换成 17。


上线当天没有事故。第二天开始,监控里出现了一些非常微妙的变化。不是报错,也不是性能雪崩。而是一些 “看起来不该变的行为,变了”


最早被发现的是 GC 行为。Java 8 默认用的是 Parallel GC,而 JDK 17 的默认已经变成了 G1。当时的判断很简单:G1 是“更先进的 GC”,不应该比旧的差。


但线上数据并不这么配合。



  • Full GC 次数少了

  • Minor GC 次数变多

  • 单次停顿更短,但更频繁


这对 JVM 来说是“健康变化”,但对业务来说,结果是:



某些接口的 P99 响应时间开始抖动



不是慢,而是不稳定。问题在于,这类变化不会在压测里明显暴露。压测关注的是吞吐和平均值,而不是长尾。你只能在真实流量下,才会看到这些边缘效应。


紧接着出现的是更难定位的问题:类加载行为的变化。JDK 9 之后,类加载和模块边界被重新梳理过。很多“以前恰好能工作”的加载顺序,在新 JVM 下变了。


最典型的是 SPI 机制。


ServiceLoader.load(SomeService.class)

在 Java 8 下,这段代码的加载顺序是稳定的。在新 JDK 下,如果存在多个实现,顺序可能发生变化。大多数时候,这没什么影响。但如果你的代码里隐式依赖了加载顺序,问题就来了:比如默认实现被换了;没有异常,没有日志,只是业务行为“和以前不太一样”。这类问题,几乎不可能靠自动化测试完全覆盖。因为测试本身,也是在“旧认知”下设计的。


还有一类更隐蔽的变化,来自于 JIT。JVM 在新版本里持续优化编译策略。某些代码路径,在 Java 8 下是“冷路径”,在新 JDK 下被识别成“热点”。结果是:



  • 以前不明显的锁竞争,被放大

  • 原本可以忽略的对象创建,开始影响 GC


代码没变,但 JVM 对代码的“理解方式”变了。


这也是为什么很多线上问题,在排查时会陷入一种诡异的状态:



SQL 没变,代码没变,配置没变,只有 JDK 变了



而你又很难证明:问题真的就是 JDK 引起的


到这一步,升级已经不再是技术选型问题了。它变成了一个心理问题。


团队开始本能地回避这种“不可解释风险”。即便你知道:



  • 这些问题不是 JDK 的 bug

  • 而是历史代码对 JVM 行为的过度依赖


但现实是,线上系统不接受“技术上合理”的解释。这也是很多公司在第一次升级尝试之后,迅速得出结论的原因:



不是升不了, 而是不值得再为这种不确定性买单



于是升级计划被无限期搁置。Java 8 继续稳定运行,问题被推迟,而不是被解决。




第四章:真正的风险,不在 JDK,而在你不敢动的那一部分代码


当升级卡在第三章那些“行为变化”上时,团队往往会得出一个结论:



问题太散了,风险不可控。



但后来复盘发现,真正不可控的,从来不是 JDK,而是我们不敢去验证的那一块代码。几乎每个中大型 Java 项目里,都有这样一层东西:



  • 没人愿意动

  • 但所有人都在用

  • 出问题只能回滚


它可能是十年前写的公共组件,也可能是一次紧急需求里硬塞进去的工具类。


在 Java 8 时代,这类代码有一个共同特征:它们和 JVM 的关系非常近。比如自定义 ClassLoader。


public class CustomClassLoader extends ClassLoader {
   @Override
   protected Class<?> findClass(String name) {
       // 从非标准路径加载字节码
  }
}

在 Java 8 下,这种实现非常常见。升级之后,问题不一定立刻出现。但一旦涉及模块、服务加载或反射,行为就开始变得不可预测。


再比如字节码增强。无论是早期的 cglib,还是基于 ASM 的工具,很多实现都默认了:



  • 某些 JDK 内部类是存在的

  • 某些方法签名是稳定的


这些假设,在新 JDK 下不再成立。更现实的问题是:这些代码往往没有完整测试。因为它们本来就不是“业务逻辑”。它们被视为基础设施, 被默认是“不会出问题的”。升级 JDK 时,测试覆盖率看起来还不错。但真正和 JVM 行为强相关的部分,几乎没有被验证过。


于是升级就进入了一个死循环:



  • 不敢上线,是因为没验证

  • 不验证,是因为不敢动

  • 不动,就永远无法升级


这也是 Java 升级和其他语言很不一样的地方。Python 项目里,底层行为大多由解释器和库兜住。Java 项目里,很多“工程能力”是直接构建在 JVM 之上的。而这些能力,恰恰是最难平滑迁移的。


还有一个被严重低估的因素,是运维和排障成本。Java 8 的排障手段,大家已经非常熟悉:



  • jmap

  • jstack

  • 老一套 GC 日志


新 JDK 不是不能用这些工具,而是行为、参数、输出都在变化。同一条 GC 日志,在不同版本下,含义已经不完全一致。这会直接导致一个现实问题:



出问题时,团队是否有信心“看懂”新 JDK 的行为?



如果答案是否定的,那升级本身就是一种冒险。


于是你会看到一种很典型的现象:



  • 开发知道 Java 17 更好

  • 架构知道 Java 21 是趋势

  • 但一到生产,所有人都默认:还是 Java 8 吧


不是因为它完美,而是因为它足够“熟”。


升级 JDK,本质上不是技术债的清理,而是一次对未知的正面接触。而大多数系统,并没有为这种接触做好准备。也正因为这样,很多公司并不是“卡在 Java 8”,而是被 Java 8 保护了很多年。




第五章:真正逼你升级的,从来不是技术本身


在很多公司里,JDK 升级从来不是一个“主动议题”。它通常出现在某个非常具体、而且很现实的场景里。比如云厂商的一封邮件。内容往往写得很克制,大概意思是:



某某 JDK 版本即将停止安全更新 请尽快规划升级方案



这类邮件第一次看到时,大多数人并不会紧张。因为“即将”往往意味着还有一段缓冲期。真正产生压力的,是第二封、第三封。


当你发现云厂商的默认镜像开始发生变化,新建实例已经不再提供 Java 8,升级这件事,就从“技术选择”变成了外部约束。还有安全审计。Java 8 的漏洞,并不比新版本多。但问题在于:很多漏洞,在 Java 8 上不再修了。这意味着同样一个问题:



  • 在新 JDK 上,是一个补丁

  • 在 Java 8 上,是一个长期风险


安全团队不会和你讨论 JVM 设计演进。他们只看结果: 有没有官方支持,有没有风险背书


接着是第三方生态。越来越多的中间件、SDK、监控工具,开始把“最低支持 JDK”往上抬。不是突然抛弃 Java 8,而是新功能不再考虑它。


你会慢慢发现:



  • 想用新版本框架 → 需要新 JDK

  • 想接入新工具 → 官方不再测试 Java 8

  • 想拿到性能优化 → 只在新 JVM 生效


这时候,继续停在 Java 8 的成本开始显性化。不是系统跑不动,而是你被锁在一个越来越狭窄的选择空间里


更现实的是人员问题。新来的工程师,默认使用的已经是 Java 17 甚至更高版本。他们熟悉的是新工具链、新调试方式。当他们面对一套 Java 8 的系统时,不是学不会,而是:



很多问题的解决路径,已经不在他们的经验范围内了。



这会让“稳定”变成另一种风险。因为稳定的前提,是有人能长期维护它。到这一步,升级已经不再是“要不要”的问题。 而是变成了:



现在升级,还是被动升级?



很多团队选择继续拖延,希望把升级成本压到最低。


但现实往往是:拖得越久,升级的边界越难控制


当升级真的不可避免时,你已经不再有“慢慢试”的空间。而这,才是 Java 8 最危险的地方。它让你误以为,时间是站在你这边的。




第六章:一次相对靠谱的 JDK 升级,应该从哪里开始


真正开始升级之前,有一件事必须先想清楚:你这次升级,是为了“到达某个版本”,还是为了“验证系统能否继续演进”。


这两个目标,看起来很像,路径完全不同。很多失败的升级,问题就出在一开始选错了目标。


如果你只是想“把 Java 8 换成 17”,那你会天然倾向于:



  • 尽量不改代码

  • 尽量不动依赖

  • 尽量让系统看起来“没变”


但这种升级方式,本质上是在赌:赌 JVM 的变化不会触发你没覆盖到的路径


相对靠谱的升级,第一步反而是承认一件事:



有些问题不是升级带来的, 而是升级帮你提前暴露出来的。



所以真正的起点,往往不是生产环境,而是一个可以被随时推翻的验证环境。不是单元测试,也不是本地跑一下。而是把完整应用,用新 JDK 跑起来。不接真实流量,但一定要接真实配置、真实依赖、真实启动参数。


很多团队在这里就已经踩到了第一个坑:启动参数。Java 8 下积累了大量 JVM 参数,其中不少早已被废弃,甚至在新版本里直接失效。你会看到类似这样的警告:


Ignoring option PermSize; support was removed in 8.0

在 Java 8 你还能“假装没看到”,在新 JDK 下,它会直接提醒你:这些参数已经没有意义了。清理这些参数,本身就是一次风险排查。不是“能不能启动”, 而是启动之后,哪些地方开始行为变化。这里有一个非常实际的做法:在同一套代码下,同时跑两个版本的 JVM。



  • 一套用 Java 8

  • 一套用目标 JDK


对外提供同样的接口,跑同样的请求。不需要全量对比结果,但要盯几个关键指标:



  • P99 延迟

  • GC 行为

  • 异常日志类型是否变化


很多问题,不是“新版本一定有 bug”,而是你第一次看到了原来就存在的极端情况。还有一个经常被忽略的点:日志和监控工具本身是否适配新 JDK。有些 agent 在 Java 8 下工作得很好,但在模块化之后,注入行为发生变化。结果不是监控失效,而是监控数据“看起来正常,其实已经不完整”。


如果你在升级过程中,突然发现某些指标消失了,那不是系统变健康了,而是你少看了一部分。这也是为什么,靠谱的升级节奏通常很慢。不是因为技术上推进不了,而是你需要时间去重新建立:



“我对这个系统行为的信心。”



到这里,升级才算真正开始。不是宣布成功,而是你终于知道:



  • 哪些问题是 JDK 带来的

  • 哪些问题是历史债务

  • 哪些地方,必须在升级过程中一起解决


而这一步,几乎不可能一蹴而就。也正因为这样,很多公司在真正启动升级后,才意识到一件事:升级 JDK,其实是在逼自己重新理解系统。 而这件事,本身就是一次不小的工程。




第七章:如果一直不升,会发生什么?


在很多团队内部,其实都默认了一种状态:



不升级,不代表现在就有问题。



这句话在相当长的一段时间里,都是成立的。Java 8 足够稳定,线上系统运行多年,没有明显的性能瓶颈,也没有无法解决的故障。于是“暂时不升”逐渐变成了“长期不升”。真正的问题,是这种状态并不是静止的。最先发生变化的,往往不是系统本身,而是它所处的环境。云厂商开始调整基础镜像;CI/CD 环境里的默认 JDK 版本往前走;安全扫描工具对旧版本的容忍度越来越低


你会发现,原来“理所当然”的前提,一个一个消失了。接着是依赖生态。一开始只是新功能不支持 Java 8,后来变成新版本直接不再测试,再后来是明确标注:不兼容。这时候你还能苟住,靠锁版本、靠私服、靠内部维护。


但代价在慢慢累积。每一次新需求评估,都会多一个隐含条件:



这个东西,能不能在 Java 8 上跑?



这个问题一旦出现得足够频繁,系统就已经被版本反向塑形了。更危险的是,问题开始延迟出现


很多在新 JDK 下会被立刻暴露的行为问题,在 Java 8 下被默默吞掉。你看不到 warning;也感受不到约束。


直到某一天,你必须升级。那时候你面对的,已经不是一次版本迁移,而是一堆被时间放大的设计问题。而升级窗口,反而更小了。


因为这次升级,不是你主动选的。可能是:



  • 安全合规要求

  • 外部依赖强制

  • 云平台策略调整


你已经没有“慢慢试”的空间。于是很多团队会在这个阶段做出一个看似合理的选择:



那就继续顶着吧,能跑一天是一天。



问题在于,这条路并不是线性的。系统越老,理解成本越高,可控范围越小。


最终你会发现,你并不是在“稳定运行一个老系统”,而是在维护一个越来越没人敢动的黑盒


这时候,Java 8 不再是你的缓冲垫,而是你的时间锁。


而你已经很难判断:



现在不升级,到底是在规避风险, 还是在把风险推给未来一个更糟糕的时刻?



这一点,很多团队只有在真正被逼到墙角时,才会意识到。




结语:也许问题不只在我们


写到这里,再回头看“为什么还卡在 Java 8”,很多原因已经很清楚了:



  • 生态复杂

  • 历史债重

  • 升级风险真实存在


但如果只停在这里,其实有点不公平。因为有一个问题,很少被正面拿出来讨论:



Java 真的做到“向下兼容”了吗?



从语法层面看,是的。Java 8 写的代码,放到 JDK 25,大多数还能编译。但从工程和运行时层面看,答案并没有这么确定。JDK 9 之后,JVM 的内部结构、边界、约束,被系统性地重构过。模块化不是补丁,是一次方向性的调整。这个调整本身没有错。甚至可以说,是 Java 走向长期可维护性的必经之路。


问题在于:JDK8之后演进的成本,几乎全部落在了使用者身上。


旧代码还能跑,但开始被警告;旧用法还能用,但需要加参数;旧依赖还能凑合,但不再被官方支持


从结果上看,JDK 并没有为“平滑迁移”提供一条真正低成本的路径。它选择的是:



保证不立刻崩, 但也不保证你能轻松往前走。



这是一种非常 Java 的工程取舍。向后兼容,被理解成“不破坏既有运行”;而不是“帮助你完成迁移”。


于是一个微妙的局面就出现了:



  • JDK 在持续演进

  • 企业系统被留在原地

  • 升级的代价,被默认为“业务方应该承担的成本”


当升级困难时,我们习惯反思自己的架构、代码、历史债。


但很少有人问一句:



如果一个平台的演进,让大多数成熟用户都不敢升级, 那这个演进路径,是否真的对“工程用户”友好?



也许这并没有标准答案。Java 选择了稳定、选择了克制、选择了长期演进。而代价,是把升级这件事,变成了一次高认知门槛的工程决策


所以今天还停在 Java 8 的团队,未必是保守,也未必是技术债失控。有时候,只是因为他们不想为一次并不完全由自己造成的不连续演进, 付出过高的试错成本。


当然,这并不意味着一直停留就是对的。


只是到了 JDK 25 这个节点,也许我们该承认一件事:



Java 的升级之所以难, 并不只是因为系统老, 也因为这条升级路,本身就不够平坦。



而要不要踏上这条路,现在,依然没有一个放之四海而皆准的答案。


作者:橙序员小站
来源:juejin.cn/post/7599551824548397082
收起阅读 »