jwt,过滤器,拦截器用法和介绍
jwt,过滤器,拦截器介绍
JWT令牌
JWT介绍
JWT全称 JSON Web Token 。
jwt可以将原始的json数据格式进行安全的封装,这样就可以直接基于jwt在通信双方安全的进行信息传输了。
JWT全称 JSON Web Token 。
jwt可以将原始的json数据格式进行安全的封装,这样就可以直接基于jwt在通信双方安全的进行信息传输了。
JWT的组成
JWT令牌由三个部分组成,三个部分之间使用英文的点来分割
- 第一部分:Header(头), 记录令牌类型、签名算法等。 例如:{"alg":"HS256","type":"JWT"}
- 第二部分:Payload(有效载荷),携带一些自定义信息、默认信息等。 例如:{"id":"1","username":"Tom"}
- 第三部分:Signature(签名),防止Token被篡改、确保安全性。将header、payload,并加入指定秘钥,通过指定签名算法计算而来。
JWT令牌由三个部分组成,三个部分之间使用英文的点来分割
- 第一部分:Header(头), 记录令牌类型、签名算法等。 例如:{"alg":"HS256","type":"JWT"}
- 第二部分:Payload(有效载荷),携带一些自定义信息、默认信息等。 例如:{"id":"1","username":"Tom"}
- 第三部分:Signature(签名),防止Token被篡改、确保安全性。将header、payload,并加入指定秘钥,通过指定签名算法计算而来。
JWT将原始的JSON格式数据转变为字符串的方式:
- 其实在生成JWT令牌时,会对JSON格式的数据进行一次编码:进行base64编码
- Base64:是一种基于64个可打印的字符来表示二进制数据的编码方式。既然能编码,那也就意味着也能解码。所使用的64个字符分别是A到Z、a到z、 0- 9,一个加号,一个斜杠,加起来就是64个字符。任何数据经过base64编码之后,最终就会通过这64个字符来表示。当然还有一个符号,那就是等号。等号它是一个补位的符号
- 需要注意的是Base64是编码方式,而不是加密方式。
- 其实在生成JWT令牌时,会对JSON格式的数据进行一次编码:进行base64编码
- Base64:是一种基于64个可打印的字符来表示二进制数据的编码方式。既然能编码,那也就意味着也能解码。所使用的64个字符分别是A到Z、a到z、 0- 9,一个加号,一个斜杠,加起来就是64个字符。任何数据经过base64编码之后,最终就会通过这64个字符来表示。当然还有一个符号,那就是等号。等号它是一个补位的符号
- 需要注意的是Base64是编码方式,而不是加密方式。
生成和校验
1.要想使用JWT令牌,需要先引入JWT的依赖:
<dependency>
<groupId>io.jsonwebtokengroupId>
<artifactId>jjwtartifactId>
<version>0.9.1version>
dependency>
引入完JWT来赖后,就可以调用工具包中提供的API来完成JWT令牌的生成和校验。
2.生成JWT代码实现:
@Test
public void testGenJwt() {
Map<String, Object> claims = new HashMap<>();
claims.put("id", 10);
claims.put("username", "itheima");
String jwt = Jwts.builder().signWith(SignatureAlgorithm.HS256, "aXRjYXN0")
.addClaims(claims)
.setExpiration(new Date(System.currentTimeMillis() + 12 * 3600 * 1000))
.compact();
System.out.println(jwt);
}
- 实现了JWT令牌的生成,下面我们接着使用Java代码来校验JWT令牌(解析生成的令牌):
@Test
public void testParseJwt() {
Claims claims = Jwts.parser().setSigningKey("aXRjYXN0")
.parseClaimsJws("eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MTAsInVzZXJuYW1lIjoiaXRoZWltYSIsImV4cCI6MTcwMTkwOTAxNX0.N-MD6DmoeIIY5lB5z73UFLN9u7veppx1K5_N_jS9Yko")
.getBody();
System.out.println(claims);
}
1.要想使用JWT令牌,需要先引入JWT的依赖:
<dependency>
<groupId>io.jsonwebtokengroupId>
<artifactId>jjwtartifactId>
<version>0.9.1version>
dependency>
引入完JWT来赖后,就可以调用工具包中提供的API来完成JWT令牌的生成和校验。
2.生成JWT代码实现:
@Test
public void testGenJwt() {
Map<String, Object> claims = new HashMap<>();
claims.put("id", 10);
claims.put("username", "itheima");
String jwt = Jwts.builder().signWith(SignatureAlgorithm.HS256, "aXRjYXN0")
.addClaims(claims)
.setExpiration(new Date(System.currentTimeMillis() + 12 * 3600 * 1000))
.compact();
System.out.println(jwt);
}
- 实现了JWT令牌的生成,下面我们接着使用Java代码来校验JWT令牌(解析生成的令牌):
@Test
public void testParseJwt() {
Claims claims = Jwts.parser().setSigningKey("aXRjYXN0")
.parseClaimsJws("eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MTAsInVzZXJuYW1lIjoiaXRoZWltYSIsImV4cCI6MTcwMTkwOTAxNX0.N-MD6DmoeIIY5lB5z73UFLN9u7veppx1K5_N_jS9Yko")
.getBody();
System.out.println(claims);
}
篡改令牌中的任何一个字符,在对令牌进行解析时都会报错,所以JWT令牌是非常安全可靠的。
JWT令牌过期后,令牌就失效了,解析的为非法令牌。
过滤器Filter
Filter介绍
- Filter表示过滤器,是 JavaWeb三大组件(Servlet、Filter、Listener)之一。
- 过滤器可以把对资源的请求拦截下来,从而实现一些特殊的功能
- 使用了过滤器之后,要想访问web服务器上的资源,必须先经过滤器,过滤器处理完毕之后,才可以访问对应的资源。
- 过滤器一般完成一些通用的操作,比如:登录校验、统一编码处理、敏感字符处理等。
- 使用了过滤器之后,要想访问web服务器上的资源,必须先经过滤器,过滤器处理完毕之后,才可以访问对应的资源。
定义过滤器
public class DemoFilter implements Filter {
//初始化方法, web服务器启动, 创建Filter实例时调用, 只调用一次
public void init(FilterConfig filterConfig) throws ServletException {
System.out.println("init ...");
}
//拦截到请求时,调用该方法,可以调用多次
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws IOException, ServletException {
System.out.println("拦截到了请求...");
}
//销毁方法, web服务器关闭时调用, 只调用一次
public void destroy() {
System.out.println("destroy ... ");
}
}
public class DemoFilter implements Filter {
//初始化方法, web服务器启动, 创建Filter实例时调用, 只调用一次
public void init(FilterConfig filterConfig) throws ServletException {
System.out.println("init ...");
}
//拦截到请求时,调用该方法,可以调用多次
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws IOException, ServletException {
System.out.println("拦截到了请求...");
}
//销毁方法, web服务器关闭时调用, 只调用一次
public void destroy() {
System.out.println("destroy ... ");
}
}
配置过滤器
在定义完Filter之后,Filter其实并不会生效,还需要完成Filter的配置,Filter的配置非常简单,只需要在Filter类上添加一个注解:@WebFilter
,并指定属性urlPatterns
,通过这个属性指定过滤器要拦截哪些请求
@WebFilter(urlPatterns = "/*") //配置过滤器要拦截的请求路径( /* 表示拦截浏览器的所有请求 )
public class DemoFilter implements Filter {
//初始化方法, web服务器启动, 创建Filter实例时调用, 只调用一次
public void init(FilterConfig filterConfig) throws ServletException {
System.out.println("init ...");
}
//拦截到请求时,调用该方法,可以调用多次
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws IOException, ServletException {
System.out.println("拦截到了请求...");
}
//销毁方法, web服务器关闭时调用, 只调用一次
public void destroy() {
System.out.println("destroy ... ");
}
}
在Filter类上面加了@WebFilter注解之后,还需要在启动类上面加上一个注解@ServletComponentScan
,通过这个@ServletComponentScan
注解来开启SpringBoot项目对于Servlet组件的支持。
@ServletComponentScan //开启对Servlet组件的支持
@SpringBootApplication
public class TliasManagementApplication {
public static void main(String[] args) {
SpringApplication.run(TliasManagementApplication.class, args);
}
}
在过滤器Filter中,如果不执行放行操作,将无法访问后面的资源。 放行操作:chain.doFilter(request, response);
过滤器的执行流程
过滤器当中我们拦截到了请求之后,如果希望继续访问后面的web资源,就要执行放行操作,放行就是调用 FilterChain对象当中的doFilter()方法,在调用doFilter()这个方法之前所编写的代码属于放行之前的逻辑。
测试代码:
@WebFilter(urlPatterns = "/*")
public class DemoFilter implements Filter {
@Override //初始化方法, 只调用一次
public void init(FilterConfig filterConfig) throws ServletException {
System.out.println("init 初始化方法执行了");
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
System.out.println("DemoFilter 放行前逻辑.....");
//放行请求
filterChain.doFilter(servletRequest,servletResponse);
System.out.println("DemoFilter 放行后逻辑.....");
}
@Override //销毁方法, 只调用一次
public void destroy() {
System.out.println("destroy 销毁方法执行了");
}
}
过滤器的拦截路径配置
拦截路径 | urlPatterns值 | 含义 |
---|---|---|
拦截具体路径 | /login | 只有访问 /login 路径时,才会被拦截 |
目录拦截 | /emps/* | 访问/emps下的所有资源,都会被拦截 |
拦截所有 | /* | 访问所有资源,都会被拦截 |
测试代码:
@WebFilter(urlPatterns = "/login") //拦截/login具体路径
public class DemoFilter implements Filter {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
System.out.println("DemoFilter 放行前逻辑.....");
//放行请求
filterChain.doFilter(servletRequest,servletResponse);
System.out.println("DemoFilter 放行后逻辑.....");
}
@Override
public void init(FilterConfig filterConfig) throws ServletException {
Filter.super.init(filterConfig);
}
@Override
public void destroy() {
Filter.super.destroy();
}
}
过滤器链
过滤器链指的是在一个web应用程序当中,可以配置多个过滤器,多个过滤器就形成了一个过滤器链。
过滤器链上过滤器的执行顺序:注解配置的Filter,优先级是按照过滤器类名(字符串)的自然排序。 比如:
- AbcFilter
- DemoFilter
这两个过滤器来说,AbcFilter 会先执行,DemoFilter会后执行。
拦截器Interceptor
- 拦截器是一种动态拦截方法调用的机制,类似于过滤器。
- 拦截器是Spring框架中提供的,用来动态拦截控制器方法的执行。
- 拦截器的作用:拦截请求,在指定方法调用前后,根据业务需要执行预先设定的代码。
自定义拦截器
实现HandlerInterceptor接口,并重写其所有方法
//自定义拦截器
@Component
public class DemoInterceptor implements HandlerInterceptor {
//目标资源方法执行前执行。 返回true:放行 返回false:不放行
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
System.out.println("preHandle .... ");
return true; //true表示放行
}
//目标资源方法执行后执行
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
System.out.println("postHandle ... ");
}
//视图渲染完毕后执行,最后执行
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
System.out.println("afterCompletion .... ");
}
}
- preHandle方法:目标资源方法执行前执行。 返回true:放行 返回false:不放行
- postHandle方法:目标资源方法执行后执行
- afterCompletion方法:视图渲染完毕后执行,最后执行
注册配置拦截器
在 com.itheima
下创建一个包,然后创建一个配置类 WebConfig
, 实现 WebMvcConfigurer
接口,并重写 addInterceptors
方法
@Configuration
public class WebConfig implements WebMvcConfigurer {
//自定义的拦截器对象
@Autowired
private DemoInterceptor demoInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
//注册自定义拦截器对象
registry.addInterceptor(demoInterceptor).addPathPatterns("/**");//设置拦截器拦截的请求路径( /** 表示拦截所有请求)
}
}
拦截器的拦截路径配置
首先我们先来看拦截器的拦截路径的配置,在注册配置拦截器的时候,我们要指定拦截器的拦截路径,通过addPathPatterns("要拦截路径")
方法,就可以指定要拦截哪些资源。
在入门程序中我们配置的是/**
,表示拦截所有资源,而在配置拦截器时,不仅可以指定要拦截哪些资源,还可以指定不拦截哪些资源,只需要调用excludePathPatterns("不拦截路径")
方法,指定哪些资源不需要拦截。
@Configuration
public class WebConfig implements WebMvcConfigurer {
//拦截器对象
@Autowired
private DemoInterceptor demoInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
//注册自定义拦截器对象
registry.addInterceptor(demoInterceptor)
.addPathPatterns("/**")//设置拦截器拦截的请求路径( /** 表示拦截所有请求)
.excludePathPatterns("/login");//设置不拦截的请求路径
}
}
在拦截器中除了可以设置/**
拦截所有资源外,还有一些常见拦截路径设置:
拦截路径 | 含义 | 举例 |
---|---|---|
/* | 一级路径 | 能匹配/depts,/emps,/login,不能匹配 /depts/1 |
/** | 任意级路径 | 能匹配/depts,/depts/1,/depts/1/2 |
/depts/* | /depts下的一级路径 | 能匹配/depts/1,不能匹配/depts/1/2,/depts |
/depts/** | /depts下的任意级路径 | 能匹配/depts,/depts/1,/depts/1/2,不能匹配/emps/1 |
拦截器的执行流程
- 当我们打开浏览器来访问部署在web服务器当中的web应用时,此时我们所定义的过滤器会拦截到这次请求。拦截到这次请求之后,它会先执行放行前的逻辑,然后再执行放行操作。而由于我们当前是基于springboot开发的,所以放行之后是进入到了spring的环境当中,也就是要来访问我们所定义的controller当中的接口方法。
- Tomcat并不识别所编写的Controller程序,但是它识别Servlet程序,所以在Spring的Web环境中提供了一个非常核心的Servlet:DispatcherServlet(前端控制器),所有请求都会先进行到DispatcherServlet,再将请求转给Controller。
- 当我们定义了拦截器后,会在执行Controller的方法之前,请求被拦截器拦截住。执行
preHandle()
方法,这个方法执行完成后需要返回一个布尔类型的值,如果返回true,就表示放行本次操作,才会继续访问controller中的方法;如果返回false,则不会放行(controller中的方法也不会执行)。 - 在controller当中的方法执行完毕之后,再回过来执行
postHandle()
这个方法以及afterCompletion()
方法,然后再返回给DispatcherServlet,最终再来执行过滤器当中放行后的这一部分逻辑的逻辑。执行完毕之后,最终给浏览器响应数据。
preHandle()
方法,这个方法执行完成后需要返回一个布尔类型的值,如果返回true,就表示放行本次操作,才会继续访问controller中的方法;如果返回false,则不会放行(controller中的方法也不会执行)。postHandle()
这个方法以及afterCompletion()
方法,然后再返回给DispatcherServlet,最终再来执行过滤器当中放行后的这一部分逻辑的逻辑。执行完毕之后,最终给浏览器响应数据。过滤器和拦截器之间的区别:
- 接口规范不同:过滤器需要实现Filter接口,而拦截器需要实现HandlerInterceptor接口。
- 拦截范围不同:过滤器Filter会拦截所有的资源,而Interceptor只会拦截Spring环境中的资源。
作者:丧心病狂汤姆猫
来源:juejin.cn/post/7527869985345339392
来源:juejin.cn/post/7527869985345339392
从HTTP到HTTPS
当你在浏览器里输入 http://www.example.com 并按下回车,看似平平无奇的一次访问,其实暗藏着 SSL/TLS 的三次握手、对称与非对称加密的轮番上阵、CA 证书的“身份核验”以及防中间人攻击的多重机关。
一、SSL、TLS、HTTPS 到底是什么关系?
- SSL(Secure Sockets Layer):早期网景公司设计的加密协议,1999 年后停止更新。
- TLS(Transport Layer Security):SSL 的直系升级版,目前主流版本为 TLS 1.2/1.3。
- HTTPS:把 HTTP 报文塞进 TLS 的“安全信封”里,再交给 TCP 传输。简而言之,HTTPS = HTTP + TLS/SSL。
二、HTTPS 握手
- ClientHello
浏览器把支持的加密套件、随机数 A、TLS 版本号一起发给服务器。 - ServerHello + 证书
服务器挑一套加密算法,返回随机数 B,并附上自己的数字证书(含公钥)。 - 验证证书 + 生成会话密钥
浏览器先给证书“验明正身”——颁发机构是否可信、证书是否被吊销、域名是否匹配。
验证通过后,浏览器生成随机数 C(Pre-Master-Secret),用服务器证书里的公钥加密后发送。双方根据 A、B、C 算出同一把对称密钥。 - Finished
双方都用这把对称密钥加密一条“Finished”消息互发,握手完成。之后的所有 HTTP 数据都用这把对称密钥加解密,速度快、强度高。
三、为什么必须有 CA?
没有 CA,任何人都可以伪造公钥,中间人攻击将防不胜防。CA 通过可信第三方背书,把“公钥属于谁”这件事写死在证书里,浏览器才能放心地相信“这就是真正的服务器”。
四、证书到底怎么防伪?
证书 = 域名 + 公钥 + 有效期 + CA 数字签名。
CA(Certificate Authority)用自己的私钥对整个证书做哈希签名。浏览器内置 CA 公钥,可解密签名并对比哈希值,一旦被篡改就立即报警。
没有 CA 签名的自签证书?浏览器会毫不留情地显示“红色警告”。
五、对称与非对称加密的分工
- 非对称加密(RSA/ECC):只在握手阶段用一次,解决“如何安全地交换对称密钥”。
- 对称加密(AES/ChaCha20):握手完成后,所有 HTTP 报文都用对称密钥加解密,性能高、延迟低。
一句话:非对称加密“送钥匙”,对称加密“锁大门”。
六、中间人攻击的两张面孔
- SSL 劫持
攻击者伪造证书、偷梁换柱。浏览器会提示证书错误,但不少用户习惯性点击“继续访问”,于是流量被窃听。 - SSL 剥离
攻击者把用户的 HTTPS 请求降级成 HTTP,服务器以为在加密,客户端却在明文裸奔。HSTS(HTTP Strict Transport Security)能强制浏览器只走 HTTPS,遏制这种降级。
总结
- 证书是身-份-证,CA 是公安局。
- 非对称握手送钥匙,对称加密跑数据。
- 没有 CA 的 HTTPS,就像没有钢印的合同——谁都能伪造。
下次当你在地址栏看到那把绿色小锁时,背后是一场涉及四次握手、两把密钥、一张证书和全球信任链的加密大戏。
来源:juejin.cn/post/7527578862054899754
我在 pre 直接修改 bug,被领导批评了
大家好,我是石小石!
背景简介
前几天项目在pre回归时,测试发现一个bug,经过排查,我发现漏写了一行代码。
由于此时test、dev的代码已经进入新的迭代开发了,因此为了图方便,我直接在pre上修改了代码,并直接推送发布。
没想到,随后就收到了来自领导的批评:为什么不拉个hotfix分支修复合并?你直接修改代码会让代码难以追踪、回滚,以后上线全是隐患!
确实,即使只有一行代码的修改,也不应该直接在pre直接更改,我深刻的反思了自己。
分支管理与协作流程
一般来说,一个项目从开发到上线共包含四个环境。
环境 | 分支名示例 | 作用说明 |
---|---|---|
开发环境 | dev | 日常开发,集成各功能分支的代码,允许不稳定,便于测试和联调 |
测试环境 | test | 提供给 QA 团队回归测试,要求相对稳定;一般从 dev 合并而来 |
预发布环境 | pre | 模拟线上环境,临上线前验证,接近正式发布版本,禁止频繁变更 |
生产环境 | prod / main | 最终上线版本,代码必须安全稳定、经过充分测试 |
以我们公司为例,大致的协作规范流程如下:
1、dev功能开发
由于功能是几个人共同开发,每个人开发前都需要从 dev
分支拉出 feature/xxx
分支;本地开发完成后提合并回 dev
;
- 提测
当功能开发完成dev
稳定后合并进 test
,然后QA 回归测试环境;如发现问题,在 hotfix/xxx
修复后继续合并回 test
(实际开发中,为了简化开发流程,大家都是直接在test修改bug)。
3. 预发布验证
测试通过,临近上线时,会从 test
合并进 pre
。pre
仅用于业务验证、客户预览,不会在开发新功能;遇到bug的话,必须基于pre拉一个hotfix分支,修复完通过验证后,在合并回pre。
4. 正式上线
从 pre
合并到 prod
,并部署上线;
为什么不能直接在pre修改bug
pre
是预发布环境分支,作用是:模拟线上环境,确保代码上线前是可靠的,它应只接收已审核通过的改动,而不是“随便修的东西”。
如果直接在 pre
上修改,会出现很多意料之外的问题。如:
- 代码来源不清晰,审查流程被绕过
- 多人协作下容易引发冲突和覆盖(bug重现)
这样时间久了我们根本不知道哪个 bug 是从哪冒出来的,代码就会变得难以维护和溯源。
因此,基于pre拉一个hotfix/xxx
分支是团队开发的规范流程:
- 创建热修分支(hotfix 分支)
从 pre
分支上拉一个新的临时分支,命名建议规范些,如:
git checkout pre
git pull origin pre # 确保是最新代码
git checkout -b hotfix/fix-button-not-working
- 2在 hotfix 分支中修复 bug
进行代码修改、调试、测试。
- 创建合并请求
bug修复且通过qa验证后,我们就可以合并至pre
等待审核。
使用hotfix,大家一看到这个分支名字,大家就知道这是线上急修的问题,容易跟踪、回溯和管理。你直接在 pre
改,其他人甚至都不知道发生了 bug。
总结
通过本文,大家应该也进一步了解pre环境的bug处理规范,如果你还觉得小问题在pre直接修改问题不大,可以看看这个示例:
你是一个信誉良好的企业老板,你的样品准备提交客户的时候突然发现了问题。你正常的流程应该是:
- 回原材料工厂排查修理
- 重新打样
- 提交新样品
- 送给客户
除非你是黑心老板,样品有问题直接凑合修一下直接给客户。
来源:juejin.cn/post/7501992214283370507
时间设置的是23点59分59秒,数据库却存的是第二天00:00:00
问题描述
昨天下班的时候,运营反馈了一个问题,明明设置的是两天后解封,为什么提示却是三天后呢。
比如今天(6.16)被拉入黑名单了,用户报名会提示 “6.19号恢复报名”,但是现在却提示6月20号才能报名,经过排查发现,就是解封的时间被多加了 1s 中,本来应该是存2025-06-18 23:59:59
,但是数据库却是2025-06-19 00:00:00
。
看了数据库有接近一半的数据是正确的,有一半的数据是第二天0晨(百思不得其解啊🤣)
代码逻辑实现:
LocalDateTime currentTime = LocalDateTime.now();
LocalDateTime futureTime = currentTime.plus(2, ChronoUnit.DAYS);
// 设置了为当天的最后一秒啊
LocalDateTime resultTime = futureTime.withHour(23).withMinute(59).withSecond(59);
BlackAccount entity = new BlackAccount();
// 实体字段类型为Date,数据库是timestamp
entity.setDeblockTime(Date.from(resultTime.atZone(ZoneId.systemDefault()).toInstant()));
blackAccountService.save(entity);
❓看到上面的代码,有没有大佬已经发现问题了。确实上面的代码存在问题
昨天下班的时候,运营反馈了一个问题,明明设置的是两天后解封,为什么提示却是三天后呢。
比如今天(6.16)被拉入黑名单了,用户报名会提示 “6.19号恢复报名”,但是现在却提示6月20号才能报名,经过排查发现,就是解封的时间被多加了 1s 中,本来应该是存2025-06-18 23:59:59
,但是数据库却是2025-06-19 00:00:00
。
看了数据库有接近一半的数据是正确的,有一半的数据是第二天0晨(百思不得其解啊🤣)
代码逻辑实现:
LocalDateTime currentTime = LocalDateTime.now();
LocalDateTime futureTime = currentTime.plus(2, ChronoUnit.DAYS);
// 设置了为当天的最后一秒啊
LocalDateTime resultTime = futureTime.withHour(23).withMinute(59).withSecond(59);
BlackAccount entity = new BlackAccount();
// 实体字段类型为Date,数据库是timestamp
entity.setDeblockTime(Date.from(resultTime.atZone(ZoneId.systemDefault()).toInstant()));
blackAccountService.save(entity);
❓看到上面的代码,有没有大佬已经发现问题了。确实上面的代码存在问题
解决过程
排查思路
一、排除代码问题
1.确认了项目中所有设置DeblockTime
的地方 只有1出。排除了代码逻辑问题,被其他代码覆盖问题;
1.确认了项目中所有设置DeblockTime
的地方 只有1出。排除了代码逻辑问题,被其他代码覆盖问题;
二、问问AI:
LocalDateTime futureTime = currentTime.plus(oaConfigActivityBlacklist.getBlockDay(), ChronoUnit.DAYS);
LocalDateTime resultTime = futureTime.withHour(23).withMinute(59).withSecond(59);
Date date = Date.from(resultTime.atZone(ZoneId.systemDefault()).toInstant());
这种写法能保证最后的date 的时分秒为 23时59分59秒么?
ai的答案如下 :
LocalDateTime futureTime = currentTime.plus(oaConfigActivityBlacklist.getBlockDay(), ChronoUnit.DAYS);
LocalDateTime resultTime = futureTime.withHour(23).withMinute(59).withSecond(59);
Date date = Date.from(resultTime.atZone(ZoneId.systemDefault()).toInstant());
这种写法能保证最后的date 的时分秒为 23时59分59秒么?
ai的答案如下 :
1. 夏令时(DST)影响
- 场景:若时区在目标日期存在夏令时调整(如时钟回拨或前拨),可能导致时间偏差。
- 示例:
- 假设系统时区为
Europe/Paris
,2023 年 10 月 29 日凌晨 3 点时钟回拨到 2 点。 - 若
futureTime
是该日,设置 23:59:59
后,转换为 UTC 时可能变为 21:59:59Z
或 22:59:59Z
,取决于时钟调整规则。
- 场景:若时区在目标日期存在夏令时调整(如时钟回拨或前拨),可能导致时间偏差。
- 示例:
- 假设系统时区为
Europe/Paris
,2023 年 10 月 29 日凌晨 3 点时钟回拨到 2 点。 - 若
futureTime
是该日,设置23:59:59
后,转换为 UTC 时可能变为21:59:59Z
或22:59:59Z
,取决于时钟调整规则。
- 假设系统时区为
2. 时区转换问题
- 场景:若
currentTime
是其他时区的时间(如 UTC),直接转换可能导致时间偏移。 - 示例:
- 若
currentTime
是 UTC 时间 2023-10-01T16:00:00Z
,系统时区为 Asia/Shanghai
(UTC+8)。 - 添加 1 天后得到
2023-10-02T16:00:00Z
,设置为 23:59:59
后转换为 Date
,实际对应 UTC 时间 2023-10-02T15:59:59Z
。
看了数据的时间分布,任何时间点 都存在 23:59:59
和 00:00:00
的情况,就算创建时间相差几分钟的情况下,都存在这两种数据。
- 场景:若
currentTime
是其他时区的时间(如 UTC),直接转换可能导致时间偏移。 - 示例:
- 若
currentTime
是 UTC 时间2023-10-01T16:00:00Z
,系统时区为Asia/Shanghai
(UTC+8)。 - 添加 1 天后得到
2023-10-02T16:00:00Z
,设置为23:59:59
后转换为Date
,实际对应 UTC 时间2023-10-02T15:59:59Z
。
- 若
看了数据的时间分布,任何时间点 都存在
23:59:59
和00:00:00
的情况,就算创建时间相差几分钟的情况下,都存在这两种数据。
三、批量插入数据测试
看看能不能复现这个问题,会不会插入时候精度等其他问题:
for (int i = 0; i < 100; i++) {
Thread.sleep(100);
LocalDateTime currentTime = LocalDateTime.now();
LocalDateTime futureTime = currentTime.plus(2, ChronoUnit.DAYS);
// 设置了为当天的最后一秒啊 LocalDateTime resultTime = futureTime.withHour(23).withMinute(59).withSecond(59);
BlackAccount entity = new BlackAccount();
// 实体字段类型为Date,数据库是timestamp
entity.setDeblockTime(Date.from(resultTime.atZone(ZoneId.systemDefault()).toInstant()));
blackAccountService.save(entity);
}
果然还真复现了,有一半的数据是2025-06-19 23:59:59
有一半的数据是2025-06-20 00:00:00
看看能不能复现这个问题,会不会插入时候精度等其他问题:
for (int i = 0; i < 100; i++) {
Thread.sleep(100);
LocalDateTime currentTime = LocalDateTime.now();
LocalDateTime futureTime = currentTime.plus(2, ChronoUnit.DAYS);
// 设置了为当天的最后一秒啊 LocalDateTime resultTime = futureTime.withHour(23).withMinute(59).withSecond(59);
BlackAccount entity = new BlackAccount();
// 实体字段类型为Date,数据库是timestamp
entity.setDeblockTime(Date.from(resultTime.atZone(ZoneId.systemDefault()).toInstant()));
blackAccountService.save(entity);
}
果然还真复现了,有一半的数据是2025-06-19 23:59:59
有一半的数据是2025-06-20 00:00:00
定位问题
通过demo的复现,可以确认是在存数据库的时候出了问题。 因为Date的精度是控制在毫秒,pgsql 中TimeStamp 的精度用的默认值,精确到秒,所以在插入的时候Date的毫秒部分大于等于500的时候就会加1秒处理。入库之后就变成了第二天的00:00:00
呢
通过demo的复现,可以确认是在存数据库的时候出了问题。 因为Date的精度是控制在毫秒,pgsql 中TimeStamp 的精度用的默认值,精确到秒,所以在插入的时候Date的毫秒部分大于等于500的时候就会加1秒处理。入库之后就变成了第二天的00:00:00
呢
解决方案
要么将java对象的时间精度和 数据库的精度保持一致,要么就将java对象多余的精度置为0,解决方案如下:
- 方案1:代码中清空秒后面的数据
修改前: futureTime.withHour(23).withMinute(59).withSecond(59);
修改后: futureTime.withHour(23).withMinute(59).withSecond(59).withNano(0);
- 方案2:调整数据库TimeStamp精度不小于java(date)对象的精度
修改前: 
修改后: 
要么将java对象的时间精度和 数据库的精度保持一致,要么就将java对象多余的精度置为0,解决方案如下:
- 方案1:代码中清空秒后面的数据
修改前:futureTime.withHour(23).withMinute(59).withSecond(59);
修改后:futureTime.withHour(23).withMinute(59).withSecond(59).withNano(0);
- 方案2:调整数据库TimeStamp精度不小于java(date)对象的精度
修改前:
修改后:
知识扩展
1. Date 和 LocalDateTime
特性 java.util.Date
(Java 1.0)java.time.LocalDateTime
(Java 8+)精度 毫秒级(1/1000 秒) 纳秒级(1/1,000,000,000 秒) 包路径 java.util.Date
java.time.LocalDateTime
可变性 可变(修改会影响原对象) 不可变(所有操作返回新对象) 时区感知 不存储时区,但内部时间戳基于 UTC 无时区,仅表示本地日期和时间
特性 | java.util.Date (Java 1.0) | java.time.LocalDateTime (Java 8+) |
---|---|---|
精度 | 毫秒级(1/1000 秒) | 纳秒级(1/1,000,000,000 秒) |
包路径 | java.util.Date | java.time.LocalDateTime |
可变性 | 可变(修改会影响原对象) | 不可变(所有操作返回新对象) |
时区感知 | 不存储时区,但内部时间戳基于 UTC | 无时区,仅表示本地日期和时间 |
2. mysql 中的timestamp 和 datetime
特性 DATETIME
TIMESTAMP
存储范围 1000-01-01 00:00:00
到 9999-12-31 23:59:59
1970-01-01 00:00:01
UTC 到 2038-01-19 03:14:07
UTC精度 5.6.4 版本后支持 fractional seconds(如DATETIME(6)
)最高精度微妙,设置0的话就表示精确到秒 同上(如TIMESTAMP(6)
) 存储空间 8 字节 4 字节(时间戳范围小) 时区感知 不存储时区信息,直接存储字面量 自动转换时区:存储时转换为 UTC,读取时转换为会话时区 默认值 无默认值(除非显式设置DEFAULT
) 支持DEFAULT CURRENT_TIMESTAMP
和ON UPDATE CURRENT_TIMESTAMP
自动更新 不支持 支持自动更新为当前时间(ON UPDATE
)
特性 | DATETIME | TIMESTAMP |
---|---|---|
存储范围 | 1000-01-01 00:00:00 到 9999-12-31 23:59:59 | 1970-01-01 00:00:01 UTC 到 2038-01-19 03:14:07 UTC |
精度 | 5.6.4 版本后支持 fractional seconds(如DATETIME(6) )最高精度微妙,设置0的话就表示精确到秒 | 同上(如TIMESTAMP(6) ) |
存储空间 | 8 字节 | 4 字节(时间戳范围小) |
时区感知 | 不存储时区信息,直接存储字面量 | 自动转换时区:存储时转换为 UTC,读取时转换为会话时区 |
默认值 | 无默认值(除非显式设置DEFAULT ) | 支持DEFAULT CURRENT_TIMESTAMP 和ON UPDATE CURRENT_TIMESTAMP |
自动更新 | 不支持 | 支持自动更新为当前时间(ON UPDATE ) |
3.适用场景建议
- java 中尽量用
LocalDateTime
吧,毕竟LocalDateTime
主要就是用来取代Date
对象的,区别如下
场景类型 java.util.Date
(旧 API)java.time.LocalDateTime
(新 API)简单本地时间记录 可使用,但 API 繁琐(需配合Calendar
) 推荐使用(无需时区,代码简洁) 带时区的时间处理 不推荐(时区处理易混淆) 推荐使用ZonedDateTime
或OffsetDateTime
多线程环境 不推荐(非线程安全) 推荐(不可变设计,线程安全) 数据库交互(JDBC 4.2+) 需转换为java.sql.Timestamp
直接支持(如pstmt.setObject(1, localDateTime)
) 时间计算与格式化 需依赖SimpleDateFormat
(非线程安全) 推荐(DateTimeFormatter
线程安全) 高精度需求(纳秒级) 仅支持毫秒级 支持纳秒级(1/1,000,000,000 秒
- 数据库到底是用
timestamp
还是 datetime
呢,跨国业务用timestamp 其他场景建议用datetime:
- java 中尽量用
LocalDateTime
吧,毕竟LocalDateTime
主要就是用来取代Date
对象的,区别如下
场景类型 | java.util.Date (旧 API) | java.time.LocalDateTime (新 API) |
---|---|---|
简单本地时间记录 | 可使用,但 API 繁琐(需配合Calendar ) | 推荐使用(无需时区,代码简洁) |
带时区的时间处理 | 不推荐(时区处理易混淆) | 推荐使用ZonedDateTime 或OffsetDateTime |
多线程环境 | 不推荐(非线程安全) | 推荐(不可变设计,线程安全) |
数据库交互(JDBC 4.2+) | 需转换为java.sql.Timestamp | 直接支持(如pstmt.setObject(1, localDateTime) ) |
时间计算与格式化 | 需依赖SimpleDateFormat (非线程安全) | 推荐(DateTimeFormatter 线程安全) |
高精度需求(纳秒级) | 仅支持毫秒级 | 支持纳秒级(1/1,000,000,000 秒 |
- 数据库到底是用
timestamp
还是datetime
呢,跨国业务用timestamp 其他场景建议用datetime:
场景 | 推荐类型 | 原因 |
---|---|---|
存储历史事件时间(如订单创建时间) | DATETIME | 不依赖时区,固定记录用户输入的时间 |
记录服务器本地时间(如定时任务执行时间) | DATETIME | 无需时区转换,直接反映服务器时间 |
多时区应用(如跨国业务) | TIMESTAMP | 自动处理时区转换,确保数据一致性(如登录时间) |
需要自动更新时间戳 | TIMESTAMP | 支持ON UPDATE CURRENT_TIMESTAMP 特性 |
存储范围超过 2038 年 | DATETIME | TIMESTAMP 仅支持到 【2038】 年 |
微秒级精度需求 | DATETIME(6) 或TIMESTAMP(6) | 根据是否需要时区转换选择 |
总结
本文主要讲述了在处理用户解封时间时,因 Java 代码中时间精度与数据库TIMESTAMP
类型精度不一致,导致约一半数据存储时间比预期多 1 秒的问题。通过排查与测试,定位问题并给出了 Java 对象时间精度和调整数据库精度两种解决方案,同时对比了 Java 和数据库中多种时间类型的特性及适用场景 。
来源:juejin.cn/post/7517119131856191500
Postgres 杀疯了,堪称 “六边形战士”,还要 Redis 干啥?
我们需要谈谈困扰我几个月的事情。我一直看到独立黑客和初创公司创始人疯狂地拼凑各种技术栈,用 Redis 做缓存,用 RabbitMQ 做队列,用 Elasticsearch 做搜索,还有用 MongoDB……为什么?
我也犯过这种错误。当我开始构建UserJot(我的反馈和路线图工具)时,我的第一反应是规划一个“合适的”架构,为所有功能提供独立的服务。然后我停下来问自己:如果我把所有功能都用 Postgres 来做会怎么样?
事实证明,房间里有一头大象,但没人愿意承认:
Postgres 几乎可以做到这一切。 |
---|
而且它的效果比你想象的还要好。
“Postgres 无法扩展”的谬论正在让你损失金钱?
让我猜猜——有人告诉你,Postgres“只是一个关系数据库”,需要专门的工具来完成专门的工作。我以前也是这么想的,直到我发现 Instagram 可以在单个 Postgres 实例上扩展到 1400 万用户。Discord 处理数十亿条消息。Notion 的整个产品都是基于 Postgres 构建的。
但问题是:他们不再像 2005 年那样使用 Postgres。
队列系统
别再为 Redis 和 RabbitMQ 付费了。Postgres 原生支持LISTEN/NOTIFY并且比大多数专用解决方案更好地处理作业队列:
-- Simple job queue in pure Postgres
CREATETABLE job_queue (
id SERIAL PRIMARY KEY,
job_type VARCHAR(50),
payload JSONB,
status VARCHAR(20) DEFAULT'pending',
created_at TIMESTAMPDEFAULT NOW(),
processed_at TIMESTAMP
);
-- ACID-compliant job processing
BEGIN;
UPDATE job_queue
SET status ='processing', processed_at = NOW()
WHERE id = (
SELECT id FROM job_queue
WHERE status ='pending'
ORDERBY created_at
FORUPDATESKIP LOCKED
LIMIT 1
)
RETURNING *;
COMMIT;
这让你无需任何额外的基础设施就能实现 Exactly-Once 的处理。不妨试试用 Redis 来实现,会让你很抓狂。
在 UserJot 中,我正是使用这种模式来处理反馈提交、发送通知和更新路线图项目。只需一次事务,即可保证一致性,无需消息代理的复杂性。
键值存储
Redis 在大多数平台上的最低价格为 20 美元/月。Postgres JSONB 已包含在您现有的数据库中,可以满足您的大部分需求:
-- Your Redis alternative
CREATETABLE kv_store (
key VARCHAR(255) PRIMARY KEY,
value JSONB,
expires_at TIMESTAMP
);
-- GIN index for blazing fast JSON queries
CREATE INDEX idx_kv_value ON kv_store USING GIN (value);
-- Query nested JSON faster than most NoSQL databases
SELECT*FROM kv_store
WHEREvalue @>'{"user_id": 12345}';
运算符@>是 Postgres 的秘密武器。它比大多数 NoSQL 查询更快,并且数据保持一致。
全文搜索
Elasticsearch 集群价格昂贵且复杂。Postgres 内置的全文搜索功能非常出色:
-- Add search to any table
ALTERTABLE posts ADDCOLUMN search_vector tsvector;
-- Auto-update search index
CREATEOR REPLACE FUNCTION update_search_vector()
RETURNStriggerAS $
BEGIN
NEW.search_vector := to_tsvector('english',
COALESCE(NEW.title, '') ||' '||
COALESCE(NEW.content, '')
);
RETURNNEW;
END;
$ LANGUAGE plpgsql;
-- Ranked search results
SELECT title, ts_rank(search_vector, query) as rank
FROM posts, to_tsquery('startup & postgres') query
WHERE search_vector @@ query
ORDERBY rank DESC;
这可以处理模糊匹配、词干提取和相关性排名。
对于 UserJot 的反馈搜索,此功能可让用户跨标题、描述和评论即时查找功能请求。无需 Elasticsearch 集群 - 只需使用 Postgres 即可发挥其优势。
实时功能
忘掉复杂的 WebSocket 基础架构吧。Postgres LISTEN/NOTIFY无需任何附加服务即可为您提供实时更新:
-- Notify clients of changes
CREATEOR REPLACE FUNCTION notify_changes()
RETURNStriggerAS $
BEGIN
PERFORM pg_notify('table_changes',
json_build_object(
'table', TG_TABLE_NAME,
'action', TG_OP,
'data', row_to_json(NEW)
)::text
);
RETURNNEW;
END;
$ LANGUAGE plpgsql;
您的应用程序会监听这些通知并向用户推送更新。无需 Redis 的发布/订阅机制。
“专业”工具的隐性成本
我们来算一下。一个典型的“现代”堆栈的成本是:
- Redis:20美元/月
- 消息队列:25美元/月
- 搜索服务:50美元/月
- 监控 3 项服务:30 美元/月
- 总计:每月 125 美元
但这还只是托管成本。真正的痛点在于:
运营开销:
- 三种不同的服务用于监控、更新和调试
- 不同的缩放模式和故障模式
- 需要维护多种配置
- 单独的备份和灾难恢复程序
- 每项服务的安全考虑因素不同
开发复杂性:
- 客户端库和连接模式
- 多个服务的部署
- 间数据不一致
- 的测试场景
- 的性能调优方法
如果您自行托管,请添加服务器管理、安全补丁以及当 Redis 决定消耗所有内存时不可避免的凌晨 3 点调试会话。
Postgres 使用您已经管理的单一服务来处理所有这些。
扩展的单一数据库
大多数人可能没有意识到:单个 Postgres 实例就能处理海量数据。我们指的是每天数百万笔交易、数 TB 的数据以及数千个并发连接。
真实世界的例子:
- Airbnb:单个 Postgres 集群处理数百万个预订
- Robinhood:数十亿笔金融交易
- GitLab:Postgres 上的整个 DevOps 平台
Postgres 的架构魅力非凡。它被设计成具备极佳的垂直扩展能力,而当你最终需要水平扩展时,它也有以下成熟的方案可供选择:
- 用于查询扩展的读取副本
- 大表分区
- 并发连接池
- 分布式设置的逻辑复制
大多数企业从未达到过这些限制。在处理数百万用户或复杂的分析工作负载之前,单个实例可能就足够了。
将此与管理所有以不同方式扩展的单独服务进行比较 - 您的 Redis 可能会耗尽内存,而您的消息队列则会遇到吞吐量问题,并且您的搜索服务需要完全不同的硬件。
从第一天起就停止过度设计
现代开发中最大的陷阱是架构式的“宇航员”。我们设计系统时,面对的是我们从未遇到过的问题,我们面对的是从未见过的流量,我们可能永远无法达到的规模。
过度设计循环:
- “我们可能有一天需要扩大规模”
- 添加 Redis、队列、微服务、多个数据库
- 花费数月时间调试集成问题
- 向 47 位用户推出
- 每月支付 200 美元购买可在 5 美元 VPS 上运行的基础设施
与此同时,您的竞争对手的发货速度更快,因为他们在需要分布式系统之前并没有管理它。
更好的方法:
- 从 Postgres 开始
- 监控实际的瓶颈,而不是想象的瓶颈
- 当达到实际极限时扩展特定组件
- 仅在解决实际问题时才增加复杂性
你的用户并不关心你的架构。他们关心的是你的产品是否有效,是否能解决他们的问题。
当你真正需要专用工具时
别误会我的意思——专用工具自有其用处。但你可能在以下情况之前不需要它们:
- 您每分钟处理 100,000 多个作业
- 您需要亚毫秒级的缓存响应
- 您正在对数 TB 的数据进行复杂的分析
- 您有数百万并发用户
- 您需要具有特定一致性要求的全局数据分布
如果您在公众号上阅读强哥这篇文章,那么您可能还没有到达那一步。
为什么这真的很重要
让我大吃一惊的是:Postgres 可以同时充当您的主数据库、缓存、队列、搜索引擎和实时系统。同时还能在所有方面保持 ACID 事务。
-- One transaction, multiple operations
BEGIN;
INSERT INTO users (email) VALUES ('user@example.com');
INSERT INTO job_queue (job_type, payload)
VALUES ('send_welcome_email', '{"user_id": 123}');
UPDATE kv_store SET value = '{"last_signup": "2024-01-15"}'
WHERE key = 'stats';
COMMIT;
尝试在 Redis、RabbitMQ 和 Elasticsearch 上执行此操作,不要哭泣。
无聊的技术却能获胜
Postgres 并不引人注目。它没有华丽的网站,也没有在 TikTok 上爆红。但几十年来,在其他数据库兴衰更迭之际,它一直默默地支撑着互联网。
选择简单、可靠且有效的技术是有道理的。
下一个项目的行动步骤
- 仅从 Postgres 开始- 抵制添加其他数据库的冲动
- 使用 JSONB 实现灵活性- 借助 SQL 的强大功能,您可以获得无架构的优势
- 在 Postgres 中实现队列——节省资金和复杂性
- 仅当达到实际极限时才添加专用工具- 而不是想象中的极限
我的真实经历
UserJot 的构建是这一理念的完美测试案例。它是一个反馈和路线图工具,需要:
- 提交反馈时实时更新
- 针对数千个功能请求进行全文搜索
- 发送通知的后台作业
- 缓存经常访问的路线图
- 用于用户偏好和设置的键值存储
我的整个后端只有一个 Postgres 数据库。没有 Redis,没有 Elasticsearch,没有消息队列。从用户身份验证到实时 WebSocket 通知,一切都由 Postgres 处理。
结果如何?我的功能交付速度更快,需要调试的部件更少,而且基础设施成本也降到了最低。当用户提交反馈、搜索功能或获取路线图变更的实时更新时,一切都由 Postgres 完成。
这不再只是理论上的。它正在实际生产中,通过真实的用户和真实的数据发挥作用。
令人不安的结论
Postgres 或许好得过头了。它功能强大,以至于大多数其他数据库对于 90% 的应用程序来说都显得多余。业界一直说服我们,所有事情都需要专门的工具,但或许我们只是把事情弄得比实际需要的更难。
你的初创公司不必成为分布式系统的样板。它需要为真正的人解决真正的问题。Postgres 让你专注于此,而不是照看基础设施。
因此,下次有人建议添加 Redis 来“提高性能”或添加 MongoDB 来“提高灵活性”时,请问他们:“您是否真的先尝试过在 Postgres 中执行此操作?”
答案可能会让你大吃一惊。我知道,当我完全在 Postgres 上构建UserJot时,它就一直运行顺畅。
本文为译文,英文原文地址(可能需要使用魔法访问):dev.to/shayy/postg… |
---|
来源:juejin.cn/post/7517200182725296178
为什么 Go 语言非常适合开发 AI Agent
原文:Alexander Belanger - 2025.06.03
如同地球上几乎所有人一样,过去的几个月里,我们也一直在关注着 Agent 的发展。
特别值得一提的是,我们观察到 Agent 的采用推动了我们编排平台的增长,这让我们对哪些技术栈和框架——或者干脆没有框架——在此领域表现良好有了一些见解。
我们看到的一个更有趣的现象是混合技术栈的激增:一个典型的 Next.js 或 FastAPI 后端,搭配着一个用 Go 语言编写的 Agent,甚至在非常早期阶段就如此。
作为一名长期的 Go 语言开发者,这着实令人兴奋;下面我将解释为何我认为这将成为未来更普遍的做法。
什么是 Agent?
这里的术语有些混乱,但通常我指的是一个在循环中执行的进程,该进程对其执行路径中的下一步操作拥有一定的自主权。这与预定义的执行路径(例如定义为有向无环图的一组步骤,我们称之为工作流)形成对比。Agent 通常包含一个基于最大深度或满足某个条件(如“测试通过”)的退出条件。
当 Agent 开始规模化(即:拥有实际用户)时,它们通常具有一些共同特征:
- 它们是长时间运行的——从几秒到几分钟甚至几小时不等。
- 每次执行的成本都很高——不仅仅是 LLM 调用的成本,Agent 的本质是取代通常需要人工操作员完成的任务。开发环境、浏览器基础设施、大型文档处理——这些都花费 $$$ 钱的。
- 在它们的执行周期中,经常需要在某个时刻接收用户(或另一个 Agent!)的输入。
- 它们花费大量时间等待 I/O 或人类输入。
让我们将这一系列特征转化为对运行时的要求。为了限定问题范围,假设我们正在处理一个在远程执行的 Agent,而非在用户本地机器上(尽管 Go 对于分发本地 Agent 也是一个绝佳选择)。在远程执行的情况下,为每次 Agent 执行运行一个单独的容器成本会高得惊人。因此,在大多数情况下(尤其是当我们的 Agent 主要是简单的 I/O 和 LLM 调用时),我们最终会得到大量并发运行的轻量级进程。每个进程可以处于特定状态(例如,“搜索文件中”、“生成代码中”、“测试中”)。请注意,不同 Agent 执行的状态顺序可能并不相同。
这种包含许多并发、长时间运行进程的系统,与大约十年前的传统 Web 架构截然不同。在传统架构中,对服务器的请求处理速度要快得多,使用一些缓存、高效的处理程序和 OLTP 数据库就能高效地服务数千名日活用户。
事实证明,这种架构转变非常适合 Go 语言的并发模型、依赖通道(channel)进行通信、集中的取消机制以及围绕 I/O 构建的工具链。
高并发性
让我们从最明显的一点开始——Go 拥有极其简单且强大的并发模型。创建一个新的 goroutine 所需的内存和时间成本非常低,因为每个 goroutine 只有 2KB 的预分配内存。
这实际上意味着你可以同时运行许多 goroutine 而开销很小,并且它们在底层运行在多个操作系统线程上,能够利用服务器中的所有 CPU 核心。这一点非常重要,因为如果你碰巧在某个 goroutine 中执行非常消耗 CPU 的操作(比如反序列化一个大型 JSON 结构),其影响会比你使用单线程运行时(如 Node.js)要小(在 Node.js 中,你需要为阻塞线程的操作创建 worker 线程或子进程),或者比使用 Python 的 async/await 也要好。
这对于 Agent 意味着什么?因为 Agent 的运行时间比典型的 Web 请求长得多,所以并发性就成为了一个更关键的问题。在 Go 中,相比于在 Python 中为每个 Agent 运行一个线程,或者在 Node.js 中为每个 Agent 运行一个 async 函数,你受到为每个 Agent 生成一个 goroutine 的限制要小得多。再加上较低的基础内存占用和编译成单一二进制文件的特点,在轻量级基础设施上同时运行数千个并发 Agent 执行变得异常简单。
通过通信共享内存
对于那些不了解的人,Go 语言有一个常见的习语:不要通过共享内存来通信;相反,通过通信来共享内存。
在实践中,这意味着不需要尝试跨多个并发进程同步内存内容(这是使用类似 Python 的 multithreading
库时的常见问题),每个进程可以通过在通道(channel)上获取和释放对象来获得该对象的所有权。这样做的效果是,每个进程只在拥有对象所有权时关心该对象的本地状态,而其他时候不需要协调所有权——无需互斥锁(mutex)!
老实说——在我编写过的大多数 Go 程序中,我使用等待组(wait groups)和互斥锁(mutexes)的次数往往比使用通道(channels)更多,因为这样通常更简单(这也符合 Go 社区的建议),并且只有一个地方需要并发访问数据。
但是,在建模 Agent 时,这种范式非常有用,因为 Agent 通常需要异步响应用户或其他 Agent 发来的消息,并且将应用程序实例视为一个 Agent 池来思考是很有帮助的。
为了更具体说明,让我们编写一些示例代码来表示 Agent 循环的核心逻辑:
// 注意:在真实世界的例子中,我们需要一种机制来优雅地
// 关闭循环并防止通道关闭;
// 这是一个简化示例。
func Agent(in <-chan Message, out chan<- Output, status chan<- State) {
internal := make(chan Message, 10) // 内部缓冲区大小为 10 的通道
for {
select {
case msg := <-internal: // 从内部通道读取消息
processMessage(msg, internal, out, status)
case msg := <-in: // 从外部输入通道读取消息
processMessage(msg, internal, out, status)
}
}
}
func processMessage(msg Message, internal chan<- Message, out chan<- Output, status chan<- State) {
result := execute(msg) // 执行消息处理
status <- State{msg.sessionId, result.status} // 发送状态更新
if next := result.next(); next != nil { // 获取下一步消息(如果有)
internal <- next // 将下一步消息发送到内部通道
}
out <- result // 发送处理结果
}
(请注意,<-chan
表示接收者只能从通道读取,而 chan<-
表示接收者只能向通道写入。)
这个 Agent 是一个长时间运行的进程,它等待消息到达 in
通道,处理消息,然后异步地将结果发送到 out
通道。status
通道用于发送关于 Agent 状态的更新,这对于监控或向用户发送增量结果很有用;而 internal
通道用于处理 Agent 的内部循环。例如,内部循环可以实现下图中的“直到测试通过”循环:
尽管我们使用 for
循环来运行 Agent,但该 Agent 的实例在消息之间不需要维护任何内部状态。它本质上是一个无状态归约器,其决策执行路径的下一步操作不依赖于某些内部状态。重要的是,这意味着任何 Agent 实例都能够处理下一条消息。这也允许 Agent 在消息之间使用持久化边界,例如将消息写入数据库或消息队列。
使用 context.Context
的集中取消机制
还记得 Agent 执行成本很高吗?假设一个用户触发了一个价值 10 美元的执行任务,但突然改变主意并点击“停止生成”——为了节省成本,你希望取消这次执行。
事实证明,在 Node.js 和 Python 中取消长时间运行的工作极其困难,原因有很多:
- 库之间缺乏统一的取消机制——虽然两种语言都支持中止信号(AbortSignal)和控制器(Controller),但这并不能保证你调用的第三方库会尊重这些信号。
- 如果信号取消失败,强行终止线程是个痛苦的过程,并可能导致线程泄漏或资源损坏。
幸运的是,Go 采用 context.Context
使得取消工作变得轻而易举,因为绝大多数库都预期并尊重这种模式。即使某些库不支持:由于 Go 只有一种并发模型,因此有像 goleak
这样的工具,可以更容易地检测出泄漏的 goroutine 和有问题的库。
丰富的标准库
当你开始使用 Go 时,你会立即注意到 Go 的标准库非常丰富且质量很高。它的许多部分也是为 Web I/O 构建的——比如 net/http
、encoding/json
和 crypto/tls
——这些对于 Agent 的核心逻辑非常有用。
Go 还有一个隐含的假设:所有 I/O 在 goroutine 内部都是阻塞的——再次强调,因为 Go 只有一种方式运行并发工作——这鼓励你将业务逻辑的核心编写为直线式程序。你不需要担心用 await
包装每个函数调用来将执行推迟给调度器。
与 Python 对比:库开发者需要考虑 asyncio、多线程(multithreading)、多进程(multiprocessing)、eventlet、gevent 以及其他一些模式,几乎不可能同等地支持所有并发模型。因此,如果你用 Python 编写 Agent,你需要研究每个库对你所采用的并发模型的支持情况,并且如果你的第三方库不完全支持你想要的模式,你可能需要采用多种模式。
(Node.js 的情况要好得多,尽管 Bun 和 Deno 等其他运行时的加入增加了一些不兼容的层面。)
性能剖析(Profiling)
由于其有状态性(statefulness)和大量长时间运行的进程,Agent 似乎特别容易出现内存泄漏和线程泄漏。Go 在 runtime/pprof
中提供了出色的工具,可以使用堆(heap)和分配(alloc)配置文件找出内存泄漏的来源,或者使用 goroutine 配置文件找出 goroutine 泄漏的来源。
额外优势:LLM 擅长编写 Go 代码
由于 Go 语法非常简单(一个常见的批评是 Go 有点“啰嗦”)并且拥有丰富的标准库,LLM 非常擅长编写符合 Go 语言习惯的代码。我发现它们在编写表格测试(table tests)方面尤其出色,这是 Go 代码库中的一种常见模式。
Go 工程师也往往反对框架(anti-framework),这意味着 LLM 不需要跟踪你使用的是哪个框架(或框架的哪个版本)。
不足之处
尽管有以上诸多好处,仍然有很多理由让你可能不会选择 Go 来开发你的 Agent:
- 第三方库支持仍然落后于 Python 和 Typescript。
- 使用 Go 进行任何涉及真正机器学习(real machine learning)的工作几乎是不可能的。
- 如果你追求最佳性能,那么有比 Go 更好的语言,如 Rust 和 C++。
- 你特立独行,不喜欢(显式)处理错误。
来源:juejin.cn/post/7514621534339055631
😡同事查日志太慢,我现场教他一套 grep 组合拳!
前言
最近公司来了个新同事,年轻有活力,就是查日志的方式让我有点裂开。
事情是这样的:他写的代码在测试环境报错了,报警信息也被钉钉机器人发到了我们群里。作为资深摸鱼战士,我寻思正好借机摸个鱼顺便指导一下新人,就凑过去看了眼。
结果越看我越急,差点当场喊出:“兄弟你是来写代码的,还是和日志谈恋爱的?”
来看看他是怎么查日志的
他先敲了一句:
tail -f a.log | grep "java.lang.NullPointerException"
想着等下次报错就能立刻看到。等了半天,终于蹦出来一行:
2025-07-03 11:38:48.339 [http-nio-8960-exec-1] [47gK4n32jEYvTYX8AYti48] [INFO] [GlobalExceptionHandler] java.lang.NullPointerException, ex: java.lang.NullPointerException
java.lang.NullPointerException: null
我提醒他:“这样看不到堆栈信息啊。”
他“哦”了一声,灵机一动,用 vi
把整个文件打开,/NullPointerException
搜关键词,一个 n
一个 n
地翻……半分钟过去了,异常在哪都没找全,我都快给他跪下了。
于是我当场掏出了一套我压箱底的“查日志组合拳”,一招一式手把手教他。他当场就“悟了”,连连称妙,并表示想让我写成文章好让他发给他前同事看——因为他前同事也是这样查的……
现在,这套组合拳我也分享给你,希望你下次查日志的时候,能让你旁边的同事开开眼。
正式教学
核心的工具其实还是 grep
命令,下面我将分场景给你讲讲我的实战经验,保证你能直接套用!
场景一:查异常堆栈,不能只看一行!
Java 异常堆栈通常都是多行的,仅仅用 grep "NullPointerException"
只能看到最上面那一行,问题根源在哪你压根找不到。
这时候使用 **grep**
的 **-A**
(After) 参数来显示匹配行之后的N行。
# 查找 NullPointerException,并显示后面 50 行
grep -A 50 "java.lang.NullPointerException" a.log
如果你发现异常太多,屏幕一闪而过,也可以用less
加上分页查看:
grep -A 50 "java.lang.NullPointerException" a.log | less
在 less
视图中,你可以:
- 使用 箭头↑↓ 或 Page Up/Down 键来上下滚动
- 输入
G
直接翻到末尾,方便快速查看最新的日志 - 输入
/Exception
继续搜索 - 按
q
键退出
这样你就能第一时间拿到完整异常上下文信息,告别反复 vi
+ /
的低效操作!
场景二:实时看新日志怎么打出来的
如果你的应用正在运行,并且你怀疑它会随时抛出异常,你可以实时监控日志文件的增长。
使用 tail -f
结合 grep
:
# 实时监控 a.log 文件的新增内容,并只显示包含 "java.lang.NullPointerException" 的行及其后50行
tail -f a.log | grep -A 50 "java.lang.NullPointerException"
只要异常一出现,它就会自动打出来,堆栈信息也一并送到你面前!
- 想停下?
Ctrl + C
- 想更准确?加
-i
忽略大小写,防止大小写拼错找不到
场景三:翻历史日志 or 查压缩日志
服务器上的日志一般都会按天或按大小分割并压缩,变成 .log.2025-07-02.gz
这种格式,查找这些文件的异常信息怎么办?
🔍 查找当前目录所有 .log
文件:
# 在当前目录下查找所有以 .log 结尾的文件,-H 参数可以顺便打印出文件名
grep -H -A 50 "java.lang.NullPointerException" *.log
其中 -H
会帮你打印出是哪个文件中出现的问题,防止你找完还不知道是哪天的事。
🔍 查找 .gz
文件(压缩日志):
zgrep -H -A 50 "java.lang.NullPointerException" *.gz
zgrep
是专门处理 .gz
的 grep
,它的功能和 grep
完全一样,无需手动解压,直接开整!
场景四:统计异常数量(快速判断异常是否频繁)
有时候你需要知道某个异常到底出现了多少次,是偶发还是成灾,使用 grep -c
(count):
grep -c "java.lang.NullPointerException" a.log
如果你要统计所有日志里的数量:
grep -c "java.lang.NullPointerException" *.log
其他常用的 grep 参数
参数 | 作用 |
---|---|
-B N | 匹配行之前的 N 行(Before) |
-A N | 匹配行之后的 N 行(After) |
-C N | 匹配行上下共 N 行(Context) |
-i | 忽略大小写 |
-H | 显示匹配的文件名 |
-r | 递归搜索目录下所有文件 |
比如:
grep -C 25 "java.lang.NullPointerException" a.log
这个命令就能让你一眼看到异常前后的上下文,帮助定位代码逻辑是不是哪里先出问题了。
尾声
好了,这套组合拳我已经传授给你了,要是别人问你在哪学的,记得报我杆师傅的大名(doge)。
其实还有其他查日志的工具,比如awk
、wc
等。
但是我留了一手,没有全部教给我这个同事,毕竟江湖规则,哪有一出手就把看家本领全都交出去的道理?
如果你也想学,先拜个师交个学费(点赞、收藏、关注),等学费凑够了,我下次再开新课,传授给大家~
来源:juejin.cn/post/7524216834619408430
为什么我不再相信 Tailwind?三个月重构项目教会我的事
Tailwind 曾经是我最爱的工具,直到它让我维护不下去整个项目。
前情提要:我是如何变成 Tailwind 重度用户的
作为一个多年写 CSS 的前端,我曾经深陷“命名地狱”:
什么 .container-title
, .btn-primary
, .form-item-error
,一个项目下来能写几百个类名,然后改样式时不知道该去哪动刀,甚至删个类都心慌。
直到我遇见了 Tailwind CSS——一切原子化,想改样式就加 class,别管名字叫什么,直接调属性即可。
于是我彻底拥抱它,团队项目里我把所有 SCSS 全部清除,组件中也只保留了 Tailwind class,一切都干净、轻便、高效。
但故事从这里开始转变。
三个月后的重构期,我被 Tailwind“反噬”
我们的后台管理系统迎来一次大版本升级,我负责重构 UI 样式逻辑,目标是:
- 统一设计规范;
- 提高代码可维护性;
- 降低多人协作时的样式冲突。
刚开始我信心满满,毕竟 Tailwind 提供了:
- 原子化 class;
@apply
合成组件级 class;- 配置主题色/字体/间距系统;
- 插件支持动画/form 控件/typography;
但随着项目深入,我开始发现 几个巨大的问题,并最终决定停用 Tailwind。
一、class 污染:结构和样式纠缠成灾
来看一个真实例子:
<div class="flex items-center justify-between bg-white p-4 rounded-lg shadow-sm border border-gray-200">
<h2 class="text-lg font-semibold text-gray-800">订单信息</h2>
<button class="text-sm px-2 py-1 bg-blue-500 text-white rounded hover:bg-blue-600">编辑</button>
</div>
你能看出这个组件的“设计意图”吗?
你能快速改它的样式吗?
一个看似简单的按钮,一眼看不到设计语言,只看到一坨 class,你根本不知道:
px-2 py-1
是从哪里决定的?bg-blue-500
是哪个品牌色?hover:bg-blue-600
是统一交互吗?
Tailwind 让样式变得快,但也让样式“变得不可读”。
二、复用失败:想复用样式还得靠 SCSS
我天真地以为 @apply
能帮我合成组件级样式,比如:
.btn-primary {
@apply text-white bg-blue-500 px-4 py-2 rounded;
}
但问题来了:
@apply
不能用在媒体查询内;@apply
不支持复杂嵌套、hover/focus 的组合;- 响应式、伪类写在 HTML 里更乱,如:
lg:hover:bg-blue-700
; - 没法动态拼接 class,逻辑和样式混在组件逻辑层了。
最终结果就是:复用失败、样式重复、维护困难。
三、设计规范无法沉淀
我们设计系统中定义了若干基础变量:
- 主色:
#0052D9
- 次色:
#A0AEC0
- 字体尺寸规范:
12/14/16/18/20/24/32px
- 组件间距:
8/16/24
本来我们希望 Tailwind 的 theme.extend
能承载这套设计系统,结果发现:
- tailwind.config.js 修改后,需要全员重启 dev server;
- 新增设计 token 非常繁琐,不如直接写 SCSS 变量;
- 多人改配置时容易冲突;
- 和设计稿同步代价高。
这让我明白:配置式设计系统不适合快速演进的产品团队。
四、多人协作混乱:Tailwind 并不直观
当我招了一位新同事,给他一个组件代码时,他的第一句话是:
“兄弟,这些 class 是从设计稿复制的吗?”
他根本看不懂 gap-6
, text-gray-700
, tracking-wide
分别是什么意思,只看到一堆“魔法 class” 。
更糟糕的是,每个人心中对 text-sm
、text-base
的视觉认知不同,导致多个组件在微调时出现样式不一致、间距不统一的问题。
Tailwind 的语义脱离了设计意图,协作就失去了基础。
最终决定:我切回了 SCSS + BEM + 设计 token
我们开始回归传统模式:
- 所有组件都有独立
.scss
文件; - 使用 BEM 命名规范:
.button
,.button--primary
,.button--disabled
; - 所有颜色/间距/字体等统一放在
_variables.scss
中; - 每个组件样式文件都注释设计规范来源。
这种模式虽然看起来“原始”,但它:
- 清晰分离结构和样式;
- 强制大家遵守设计规范;
- 组件样式可复用,可继承,可重写;
- 新人一眼看懂,不需要会 Tailwind 语法。
总结:Tailwind 不是错,是错用的代价太高
Tailwind 在以下场景表现极好:
- 个人项目 / 小程序:快速开发、无需复用;
- 组件库原型:试验颜色、排版效果;
- 纯前端工程师独立开发的项目:没有协作负担。
但在以下情况,Tailwind 会成为维护灾难:
- 多人协作;
- UI 不断迭代,设计语言需频繁调整;
- 有强复用需求(组件抽象);
- 与设计系统严格对齐的场景;
我为什么写这篇文章?
不是为了黑 Tailwind,而是为了让你在选择技术栈时更慎重。
就像当年我们争论 Sass vs Less
,今天的 Tailwind vs 原子/语义 CSS
并没有标准答案。
Tailwind 很强,但不是所有团队都适合。
也许你正在享受它的爽感,但三个月后你可能会像我一样,把所有 .w-full h-screen text-gray-800
替换成 .layout-container
。
尾声:如果你非要继续用 Tailwind,我建议你做这几件事
- 强制使用
@apply
形成组件级 class,不允许直接使用长串 class; - 抽离公共样式,写在一个统一的组件样式文件中;
- 和设计团队对齐 Tailwind 的 spacing/font/color;
- 用 tailwind.config.js 做好 token 映射和语义名设计;
- 每个页面都进行 CSS code review,不然很快就会变垃圾堆。
来源:juejin.cn/post/7511602231508664361
用了十年 Docker,我为什么决定换掉它?
一、Docker 不再万能,我们该何去何从?
过去十年,Docker 改变了整个软件开发世界。它以“一次构建,到处运行”的理念,架起了开发者和运维人员之间的桥梁,推动了 DevOps 与微服务架构的广泛落地。
从自动化部署、持续集成到快速交付,Docker 一度是不可或缺的技术基石。
然而到了 2025 年,越来越多开发者开始重新审视 Docker。
系统规模在不断膨胀,开发场景也更加多元,不再是当初以单一后端应用为主的架构。
如今,开发者面临的不只是如何部署一个服务,更要关注架构的可扩展性、容器的安全性、本地与云端的适配性,以及资源的最优利用。
在这种背景下,Docker 开始显得不再那么“全能”,它在部分场景下的臃肿、安全隐患和与 Kubernetes 的解耦问题,使得不少团队正在寻找更轻、更适合自身的替代方案。
之所以写下这篇文章就是为了帮助你认清 Docker 当前的局限,了解新的技术趋势,并发现适用于不同场景的下一代容器化工具。
二、Docker 的贡献与瓶颈
不可否认,Docker 曾是容器化革命的引擎。从过去到现在,它的最大价值在于降低了环境配置的复杂度,让开发与运维团队之间的协作更加顺畅,带动了整个容器生态的发展。
很多团队正是依赖 Docker 才实现了快速构建镜像、构建流水线、部署微服务的能力。
但与此同时,Docker 本身也逐渐显露出局限性。比如,它高度依赖守护进程,导致资源占用明显高于预期,启动速度也难以令人满意。
更关键的是,Docker 默认以 root 权限运行容器,极易放大潜在攻击面,在安全合规日益严格的今天,这一点令人担忧。Kubernetes 的官方运行时也已从 Docker 切换为 containerd 与 runc,表明行业主流已在悄然转向。
这并不意味着 Docker 已过时,它依旧在许多团队中扮演重要角色。但如果你期待更高的性能、更低的资源消耗和更强的安全隔离,那么,是时候拓宽视野了。
三、本地开发的难题与新解法
特别是在本地开发场景中,Docker 的“不够轻”问题尤为突出。为了启动一个简单的 PHP 或 Node 项目,很多人不得不拉起庞大的容器,等待镜像下载、构建,甚至调试端口映射,最终电脑风扇轰鸣,开发体验直线下降。
一些开发者试图回归传统,通过 Homebrew 或 apt 手动配置开发环境,但这又陷入了“版本冲突”“依赖错位”等老问题。
这时,ServBay 的出现带来了新的可能。作为专为本地开发设计的轻量级工具,ServBay 不依赖 Docker,也无需繁琐配置。用户只需一键启动,即可在本地运行 PHP、Python、Golang、Java 等多种语言环境,并能自由切换版本与服务组合。它不仅启动迅速,资源占用也极低,非常适合 WordPress、Laravel、ThinkPHP 等项目的本地调试与开发。
更重要的是,ServBay 不再强制开发者理解复杂的镜像构建与容器编排逻辑,而是将本地开发流程变得像打开编辑器一样自然。对于 Web 后端和全栈开发者来说,它提供了一种“摆脱 Docker”的全新路径。
四、当 Docker 不再是运行时的唯一选择
容器运行时的格局也在悄然生变。containerd 和 runc 成为了 Kubernetes 官方推荐的运行时,它们更轻、更专注,仅提供核心的容器管理功能,剥离了不必要的附加组件。与此同时,CRI-O 正在被越来越多团队采纳,它是专为 Kubernetes 打造的运行时,直接对接 CRI 接口,减少了依赖层级。
另一款备受好评的是 Podman,它的最大亮点在于支持 rootless 模式,使容器运行更加安全。同时,它的命令行几乎与 Docker 完全兼容,开发者几乎不需要重新学习。
对于安全隔离要求极高的场景,还可以选择 gVisor 或 Kata Containers。前者通过用户态内核方式拦截系统调用,构建沙箱化环境;后者则将轻量虚拟机与容器结合,兼顾性能与隔离性。这些方案正在逐步替代传统 Docker,成为新一代容器架构的基石。
五、容器编排:Kubernetes 之后的路在何方?
虽然 Kubernetes 仍然是企业级容器编排的标准选项,但它的复杂性和陡峭的学习曲线也让不少中小团队望而却步。一个简单的应用部署可能涉及上百行 YAML 文件,过度的抽象与组件拆分反而拉高了运维门槛。
这也促使“微型 Kubernetes”方案逐渐兴起。K3s 是其中的代表,它对 Kubernetes 进行了极大简化,专为边缘计算和资源受限场景优化。此外,像 KubeEdge 等项目,也在积极拓展容器编排在边缘设备上的适配能力。
与此同时,AI 驱动的编排平台正在探索新路径。CAST AI、Loft Labs 等团队推出的智能调度系统,可以自动分析工作负载并进行优化部署,最大化资源利用率。更进一步,Serverless 与容器的融合也逐渐成熟,比如 AWS Fargate、Google Cloud Run 等服务,让开发者无需再关心节点管理,容器真正变成了“即用即走”的计算单元。
六、未来趋势:容器走向“定制化生长”
未来的容器化,我们将看到更细化的技术选型:开发环境选择轻量灵活的本地容器,测试环境强调快速重建与自动化部署,生产环境则关注安全隔离与高可用性。
安全性也会成为核心关键词。rootless 容器、沙箱机制和系统调用过滤将成为主流实践,容器从“不可信”向“可信执行环境”演进。与此同时,人工智能将在容器调度中发挥更大作用,不仅提升弹性伸缩的效率,还可能引领“自愈系统”发展,让集群具备自我诊断与恢复能力。
容器标准如 OCI 的持续完善,将让不同运行时之间更加兼容,为整个生态的整合提供可能。而在部署端,我们也将看到容器由本地向云端、再向边缘设备的自然扩展,真正成为“无处不在的基础设施”。
七、结语:容器化的新纪元已经到来
Docker 的故事并没有结束,它依然是很多开发者最熟悉的工具,也在部分场景中继续发挥作用。但可以确定的是,它不再是唯一选择。2025 年的容器世界,早已迈入了多元化、场景化、智能化的阶段。从轻量级的 ServBay 到更安全的 Podman,从微型编排到 Serverless 混合模式,我们手中可选的工具越来越丰富,技术栈的自由度也空前提升。
下一个十年,容器不只是为了“装下服务”,它将成为构建现代基础设施的关键砖块。愿你也能在这场演进中,找到属于自己的工具组合,打造更轻、更快、更自由的开发与部署体验。
来源:juejin.cn/post/7521927128524210212
放弃 JSON.parse(JSON.stringify()) 吧!试试现代深拷贝!
作者:程序员成长指北
原文:mp.weixin.qq.com/s/WuZlo_92q…
最近小组里的小伙伴,暂且叫小A吧,问了一个bug:
提示数据循环引用,相信不少小伙伴都遇到过类似问题,于是我问他:
我:你知道问题报错的点在哪儿吗
小A: 知道,就是下面这个代码,但不知道怎么解决。
onst a = {};
const b = { parent: a };
a.child = b; // 形成循环引用
try {
const clone = JSON.parse(JSON.stringify(a));
} catch (error) {
console.error('Error:', error.message); // 会报错:Converting circular structure to JSON
}
上面是我将小A的业务代码提炼为简单示例,方便阅读。
- 这里
a.child
指向b
,而b.parent
又指回a
,形成了循环引用。 - 用
JSON.stringify
时会抛出 Converting circular structure to JSON 的错误。
我顺手查了一下小A项目里 JSON.parse(JSON.stringify())
的使用情况:
一看有50多处都使用了, 使用频率相当高了。
我继续提问:
我:你有找解决方案吗?
小A: 我看网上说可以自己实现一个递归来解决,但是我不太会实现
于是我帮他实现了一版简单的递归深拷贝:
function deepClone(obj, hash = new Map()) {
if (typeof obj !== 'object' || obj === null) return obj;
if (hash.has(obj)) return hash.get(obj);
const clone = Array.isArray(obj) ? [] : {};
hash.set(obj, clone);
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
clone[key] = deepClone(obj[key], hash);
}
}
return clone;
}
// 测试
const a = {};
const b = { parent: a };
a.child = b;
const clone = deepClone(a);
console.log(clone.child.parent === clone); // true
此时,为了给他拓展一下,我顺势抛出新问题:
我: 你知道原生Web API 现在已经提供了一个深拷贝 API吗?
小A:???
于是我详细介绍了一下:
主角 structuredClone
登场
structuredClone()
是浏览器原生提供的 深拷贝 API,可以完整复制几乎所有常见类型的数据,包括复杂的嵌套对象、数组、Map、Set、Date、正则表达式、甚至是循环引用。
它遵循的标准是:HTML Living Standard - Structured Clone Algorithm(结构化克隆算法)。
语法:
const clone = structuredClone(value);
一行代码,优雅地解决刚才的问题:
const a = {};
const b = { parent: a };
a.child = b; // 形成循环引用
const clone = structuredClone(a);
console.log(clone !== a); // true
console.log(clone.child !== b); // true
console.log(clone.child.parent === clone); // true,循环引用关系被保留
为什么增加 structuredClone
?
在 structuredClone
出现之前,常用的深拷贝方法有:
方法 | 是否支持函数/循环引用 | 是否支持特殊对象 |
---|---|---|
JSON.parse(JSON.stringify(obj)) | ❌ 不支持函数、循环引用 | ❌ 丢失 Date 、RegExp 、Map 、Set |
第三方库 lodash.cloneDeep | ✅ 支持 | ✅ 支持,但体积大,速度较慢 |
手写递归 | ✅ 可支持 | ❌ 复杂、易出错 |
structuredClone
是 原生、极速、支持更多数据类型且无需额外依赖 的现代解决方案。
支持的数据类型
类型 | 支持 |
---|---|
Object | ✔️ |
Array | ✔️ |
Map / Set | ✔️ |
Date | ✔️ |
RegExp | ✔️ |
ArrayBuffer / TypedArray | ✔️ |
Blob / File / FileList | ✔️ |
ImageData / DOMException / MessagePort | ✔️ |
BigInt | ✔️ |
Symbol(保持引用) | ✔️ |
循环引用 | ✔️ |
❌ 不支持:
- 函数(Function)
- DOM 节点
- WeakMap、WeakSet
常见使用示例
1. 克隆普通对象
const obj = { a: 1, b: { c: 2 } };
const clone = structuredClone(obj);
console.log(clone); // { a: 1, b: { c: 2 } }
console.log(clone !== obj); // true
2. 支持循环引用
const obj = { name: 'Tom' };
obj.self = obj;
const clone = structuredClone(obj);
console.log(clone.self === clone); // true
3. 克隆 Map、Set、Date、RegExp
const complex = {
map: new Map([["key", "value"]]),
set: new Set([1, 2, 3]),
date: new Date(),
regex: /abc/gi
};
const clone = structuredClone(complex);
console.log(clone);
兼容性
提到新的API,肯定得考虑兼容性问题:
- Chrome 98+
- Firefox 94+
- Safari 15+
- Node.js 17+ (
global.structuredClone
)
如果需要兼容旧浏览器:
- 可以降级使用
lodash.cloneDeep
- 或使用 MessageChannel Hack
很多小伙伴一看到兼容性问题,可能心里就有些犹豫:
"新API虽然好,但旧浏览器怎么办?"
但技术的发展离不开新技术的应用和推广,只有更多人开始尝试并使用,才能让新API真正普及开来,最终成为主流。
建议:
如果你的项目运行在现代浏览器或 Node.js 环境,structuredClone
是目前最推荐的深拷贝方案。 Node.js 17+:可以直接使用 global.structuredClone
。
来源:juejin.cn/post/7524232022124085257
localhost 和 127.0.0.1 到底有啥区别?
在开发中,我们经常会接触到 localhost
和 127.0.0.1
。很多人可能觉得它们是一样的,甚至可以互换使用。实际上,它们确实有很多相似之处,但细究起来,也存在一些重要的区别。
本篇文章就带大家一起来深入了解 localhost
和 127.0.0.1
,并帮助你搞清楚它们各自的特点和适用场景。
一、什么是 localhost
?
localhost
是一个域名,它被广泛用于表示当前这台主机(也就是你自己的电脑)。当你在浏览器地址栏输入 localhost
时,操作系统会查找 hosts
文件(在 Windows
中通常位于 C:\Windows\System32\drivers\etc\hosts
,在 MacOS 或者 Linux 系统中,一般位于 /etc/hosts
),查找 localhost
对应的 IP 地址。如果没有找到,它将默认解析为 127.0.0.1
。
特点:
- 是一个域名,默认指向当前设备。
- 不需要联网也能工作。
- 用于测试本地服务,例如开发中的 Web 应用或 API。
小知识 🌟:域名和 IP 地址的关系就像联系人名字和电话号码。我们用名字联系某个人,实际上是依赖后台的通讯录解析到实际号码来拨号。
二、什么是 127.0.0.1
?
127.0.0.1
是一个特殊的 IP 地址,它被称为 回环地址(loopback address)。这个地址专门用于通信时指向本机,相当于告诉电脑“别出门,就在家里转一圈”。你可以试一试在浏览器中访问 127.0.0.2
看看会访问到什么?你会发现,它同样会指向本地服务!环回地址的范围是 127.0.0.0/8
,即所有以 127 开头的地址都属于环回网络,但最常用的是 127.0.0.1
。
特点:
- 127.0.0.1 不需要 DNS 解析,因为它是一个硬编码的地址,直接指向本地计算机。
- 是 IPv4 地址范围中的一个保留地址。
- 只用于本机网络通信,不能通过这个地址访问外部设备或网络。
- 是开发测试中最常用的 IP 地址之一。
小知识 🌟:所有从
127.0.0.0
到127.255.255.255
的 IP 地址都属于回环地址,但通常只用127.0.0.1
。
三、两者的相似点
- 都指向本机
- 不管是输入
localhost
还是127.0.0.1
,最终都会将请求发送到你的电脑,而不是其他地方。
- 不管是输入
- 常用于本地测试
- 在开发中,我们需要在本机运行服务并测试,
localhost
和127.0.0.1
都是标准的本地访问方式。
- 在开发中,我们需要在本机运行服务并测试,
- 无需网络支持
- 即使你的电脑没有连接网络,这两个也可以正常使用,因为它们完全依赖于本机的网络栈。
四、两者的不同点
区别 | localhost | 127.0.0.1 |
---|---|---|
类型 | 域名 | IP 地址 |
解析过程 | 需要通过 DNS 或 hosts 文件解析为 IP 地址 | 不需要解析,直接使用 |
协议版本支持 | 同时支持 IPv4 和 IPv6 | 仅支持 IPv4 |
访问速度 | 解析时可能稍慢(视 DNS 配置而定) | 通常更快,因为不需要额外的解析步骤 |
五、为什么 localhost
和 127.0.0.1
有时表现不同?
在大多数情况下,localhost
和 127.0.0.1
是等效的,但在一些特殊环境下,它们可能会表现出差异:
1. IPv4 和 IPv6 的影响
localhost
默认可以解析为 IPv4(127.0.0.1
)或 IPv6(::1
)地址,具体取决于系统配置。如果你的程序只支持 IPv4,而 localhost
被解析为 IPv6 地址,可能会导致连接失败。
示例:
# 测试 localhost 是否解析为 IPv6
ping localhost
可能的结果:
- 如果返回
::1
,说明解析为 IPv6。 - 如果返回
127.0.0.1
,说明解析为 IPv4。
2. hosts
文件配置
在某些情况下,你的 localhost
并不一定指向 127.0.0.1
。这是因为域名解析优先会查找系统的 hosts
文件:
- Windows:
C:\Windows\System32\drivers\etc\hosts
- Linux/macOS:
/etc/hosts
示例:自定义 localhost
# 修改 hosts 文件
127.0.0.1 my-local
之后访问 http://my-local
会指向 127.0.0.1
,但如果 localhost
被误配置成其他地址,可能会导致问题。
3. 防火墙或网络配置的限制
某些网络工具或防火墙规则会区别对待域名和 IP 地址。如果只允许 127.0.0.1
通信,而不允许 localhost
,可能会引发问题。
六、在开发中如何选择?
- 优先使用
localhost
因为它是更高层次的表示方式,更通用。如果将来需要切换到不同的 IP 地址(例如 IPv6),不需要修改代码。 - 需要精准控制时用
127.0.0.1
如果你明确知道程序只需要使用 IPv4 环境,或者想避免域名解析可能带来的问题,直接用 IP 地址更稳妥。
示例:用 Python 测试
# 使用 localhost
import socket
print(socket.gethostbyname('localhost')) # 输出可能是 127.0.0.1 或 ::1
# 使用 127.0.0.1
print(socket.gethostbyname('127.0.0.1')) # 输出始终是 127.0.0.1
七、总结
虽然 localhost
和 127.0.0.1
大部分情况下可以互换使用,但它们的本质不同:
localhost
是域名,更抽象。127.0.0.1
是 IP 地址,更具体。
在开发中,我们应根据场景合理选择,尤其是在涉及到跨平台兼容性或网络配置时,理解它们的差异性会让你事半功倍。
最后,记得动手实践,多跑几个测试。毕竟,编程是用代码说话的艺术!😄
如果你觉得这篇文章对你有帮助,记得点个赞或分享给更多人!有其他技术问题想了解?欢迎评论区留言哦~ 😊
来源:juejin.cn/post/7511583779578200115
都说了布尔类型的变量不要加 is 前缀,非要加,这不是坑人了嘛
开心一刻
今天心情不好,给哥们发语音
我:哥们,晚上出来喝酒聊天吧
哥们:咋啦,心情不好?
我:嗯,刚刚在公交车上看见前女友了
哥们:然后呢?
我:给她让座时,发现她怀孕了...
哥们:所以难受了?
我:不是她怀孕让我难受,是她怀孕还坐公交车让我难受
哥们:不是,她跟着你就不用坐公交车了?不还是也要坐,有区别吗?
我默默的挂断了语音,心情更难受了

Java开发手册
作为一个 javaer
,我们肯定看过 Alibaba
的 Java开发手册,作为国内Java开发领域的标杆性编码规范,我们或多或少借鉴了其中的一些规范,其中有一点

我印象特别深,也一直在奉行,自己还从未试过用 is
作为布尔类型变量的前缀,不知道会有什么坑;正好前段时间同事这么用了,很不幸,他挖坑,我踩坑,阿西吧!

is前缀的布尔变量有坑
为了复现问题,我先简单搞个 demo
;调用很简单,服务 workflow
通过 openfeign
调用 offline-sync
,代码结构如下

qsl-data-govern-common:整个项目的公共模块
qsl-offline-sync:离线同步
- qsl-offline-sync-api:向外提供
openfeign
接口
- qsl-offline-sync-common:离线同步公共模块
- qsl-offline-sync-server:离线同步服务
qsl-workflow:工作流
- qsl-workflow-api:向外提供
openfeign
接口,暂时空实现
- qsl-workflow-common:工作流公共模块
- qsl-workflow-server:工作流服务
完整代码:qsl-data-govern
qsl-offline-sync-server
提供删除接口
/**
* @author 青石路
*/
@RestController
@RequestMapping("/task")
public class SyncTaskController {
private static final Logger LOG = LoggerFactory.getLogger(SyncTaskController.class);
@PostMapping("/delete")
public ResultEntity<String> delete(@RequestBody SyncTaskDTO syncTask) {
// TODO 删除处理
LOG.info("删除任务[taskId={}]", syncTask.getTaskId());
return ResultEntity.success("删除成功");
}
}
qsl-offline-sync-api
对外提供 openfeign
接口
/**
* @author 青石路
*/
@FeignClient(name = "data-govern-offline-sync", contextId = "dataGovernOfflineSync", url = "${offline.sync.server.url}")
public interface OfflineSyncApi {
@PostMapping(value = "/task/delete")
ResultEntity<String> deleteTask(@RequestBody SyncTaskDTO syncTaskDTO);
}
qsl-workflow-server
调用 openfeign
接口
/**
* @author 青石路
*/
@RestController
@RequestMapping("/definition")
public class WorkflowController {
private static final Logger LOG = LoggerFactory.getLogger(WorkflowController.class);
@Resource
private OfflineSyncApi offlineSyncApi;
@PostMapping("/delete")
public ResultEntity<String> delete(@RequestBody WorkflowDTO workflow) {
LOG.info("删除工作流[workflowId={}]", workflow.getWorkflowId());
// 1.查询工作流节点,查到离线同步节点(taskId = 1)
// 2.删除工作流节点,删除离线同步节点
ResultEntity<String> syncDeleteResult = offlineSyncApi.deleteTask(new SyncTaskDTO(1L));
if (syncDeleteResult.getCode() != 200) {
LOG.error("删除离线同步任务[taskId={}]失败:{}", 1, syncDeleteResult.getMessage());
ResultEntity.fail(syncDeleteResult.getMessage());
}
return ResultEntity.success("删除成功");
}
}
逻辑是不是很简单?我们启动两个服务,然后发起 http
请求
POST http://localhost:8081/data-govern/workflow/definition/delete
Content-Type: application/json
{
"workflowId": 99
}
此时 qsl-offline-sync-server
日志输出如下
2025-06-30 14:53:06.165|INFO|http-nio-8080-exec-4|25|c.q.s.s.controller.SyncTaskController :删除任务[taskId=1]
至此,一切都很正常,第一版也是这么对接的;后面 offline-sync
进行调整,删除接口增加了一个参数:isClearData
public class SyncTaskDTO {
public SyncTaskDTO(){}
public SyncTaskDTO(Long taskId, Boolean isClearData) {
this.taskId = taskId;
this.isClearData = isClearData;
}
private Long taskId;
private Boolean isClearData = false;
public Long getTaskId() {
return taskId;
}
public void setTaskId(Long taskId) {
this.taskId = taskId;
}
public Boolean getClearData() {
return isClearData;
}
public void setClearData(Boolean clearData) {
isClearData = clearData;
}
}
然后实现对应的逻辑
/**
* @author 青石路
*/
@RestController
@RequestMapping("/task")
public class SyncTaskController {
private static final Logger LOG = LoggerFactory.getLogger(SyncTaskController.class);
@PostMapping("/delete")
public ResultEntity<String> delete(@RequestBody SyncTaskDTO syncTask) {
// TODO 删除处理
LOG.info("删除任务[taskId={}]", syncTask.getTaskId());
if (syncTask.getClearData()) {
LOG.info("清空任务[taskId={}]历史数据", syncTask.getTaskId());
// TODO 清空历史数据
}
return ResultEntity.success("删除成功");
}
}
调整完之后,同事通知我,让我做对 qsl-workflow
做对应的调整。调整很简单,qsl-workflow
删除时直接传 true
即可
/**
* @author 青石路
*/
@RestController
@RequestMapping("/definition")
public class WorkflowController {
private static final Logger LOG = LoggerFactory.getLogger(WorkflowController.class);
@Resource
private OfflineSyncApi offlineSyncApi;
@PostMapping("/delete")
public ResultEntity<String> delete(@RequestBody WorkflowDTO workflow) {
LOG.info("删除工作流[workflowId={}]", workflow.getWorkflowId());
// 1.查询工作流节点,查到离线同步节点(taskId = 1)
// 2.删除工作流节点,删除离线同步节点
// 删除离线同步任务,isClearData直接传true
ResultEntity<String> syncDeleteResult = offlineSyncApi.deleteTask(new SyncTaskDTO(1L, true));
if (syncDeleteResult.getCode() != 200) {
LOG.error("删除离线同步任务[taskId={}]失败:{}", 1, syncDeleteResult.getMessage());
ResultEntity.fail(syncDeleteResult.getMessage());
}
return ResultEntity.success("删除成功");
}
}
调整完成之后,发起 http
请求,发现历史数据没有被清除,看日志发现
LOG.info("清空任务[taskId={}]历史数据", syncTask.getTaskId());
没有打印,参数明明传的是 true
吖!!!
offlineSyncApi.deleteTask(new SyncTaskDTO(1L, true));
这是哪里出了问题?

问题排查
因为 qsl-offline-sync-api
是直接引入的,并非我实现的,所以我第一时间找到了其实现者,反馈了问题后让其自测下;一开始他还很自信,说这么简单怎么会有问题

当他启动 qsl-offline-sync-server
后,发起 http
请求
POST http://localhost:8080/data-govern/sync/task/delete
Content-Type: application/json
{
"taskId": 123,
"isClearData": true
}
发现 isClearData
的值是 false

此刻,疑问从我的额头转移到了他的额头上,他懵逼了,我轻松了。为了功能能够正常交付,我还是决定看下这个问题,没有了心理压力,也许更容易发现问题所在。第一眼看到 isClearData
,我就隐约觉得有问题,所以我决定仔细看下 SyncTaskDTO
这个类,发现 isClearData
的 setter
和 getter
方法有点不一样
private Boolean isClearData = false;
public Boolean getClearData() {
return isClearData;
}
public void setClearData(Boolean clearData) {
isClearData = clearData;
}
方法名是不是少了 Is
?带着这个疑问我找到了同事,问他 setter
、getter
为什么要这么命名?他说是 idea
工具自动生成的(也就是我们平时用到的idea自动生成setter、getter方法的功能)

我让他把 Is
补上试试
private Boolean isClearData = false;
public Boolean getIsClearData() {
return isClearData;
}
public void setIsClearData(Boolean isClearData) {
this.isClearData = isClearData;
}
发现传值正常了,他回过头看着我,我看着他,两人同时提问
他:为什么加了
Is
就可以了?
我:布尔类型的变量,你为什么要加
is
前缀?
问题延申
作为一个严谨的开发,不只是要知其然,更要知其所以然;关于
为什么加了
Is
就可以了
这个问题,我们肯定是要会上一会的;会这个问题之前,我们先来捋一下参数的流转,因为是基于 Spring MVC
实现的 Web 应用,所以我们可以这么问 deepseek
Spring MVC 是如何将前端参数转换成POJO的
能够查到如下重点信息

RequestResponseBodyMethodProcessor
的 resolveArgument
/**
* Throws MethodArgumentNotValidException if validation fails.
* @throws HttpMessageNotReadableException if {@link RequestBody#required()}
* is {@code true} and there is no body content or if there is no suitable
* converter to read the content with.
*/
@Override
public Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {
parameter = parameter.nestedIfOptional();
Object arg = readWithMessageConverters(webRequest, parameter, parameter.getNestedGenericParameterType());
String name = Conventions.getVariableNameForParameter(parameter);
if (binderFactory != null) {
WebDataBinder binder = binderFactory.createBinder(webRequest, arg, name);
if (arg != null) {
validateIfApplicable(binder, parameter);
if (binder.getBindingResult().hasErrors() && isBindExceptionRequired(binder, parameter)) {
throw new MethodArgumentNotValidException(parameter, binder.getBindingResult());
}
}
if (mavContainer != null) {
mavContainer.addAttribute(BindingResult.MODEL_KEY_PREFIX + name, binder.getBindingResult());
}
}
return adaptArgumentIfNecessary(arg, parameter);
}
正是解析参数的地方,我们打个断点,再发起一次 http
请求

很明显,readWithMessageConverters
是处理并转换参数的地方,继续跟进去会来到 MappingJackson2HttpMessageConverter
的 readJavaType
方法

此刻我们可以得到,是通过 jackson
完成数据绑定与数据转换的。继续跟进,会看到 isClearData
的赋值过程

通过前端传过来的参数 isClearData
找对应的 setter
方法是 setIsClearData,而非 setClearData
,所以问题
为什么加了
Is
就可以了
是不是就清楚了?
问题解决
- 按上述方式调整
isClearData
的setter
、getter
方法
带上
is
public Boolean getIsClearData() {
return isClearData;
}
public void setIsClearData(Boolean isClearData) {
this.isClearData = isClearData;
}
- 布尔类型的变量,不用
is
前缀
可以用
if
前缀
private Boolean ifClearData = false;
public Boolean getIfClearData() {
return ifClearData;
}
public void setIfClearData(Boolean ifClearData) {
this.ifClearData = ifClearData;
}
- 可以结合
@JsonProperty
来处理
@JsonProperty("isClearData")
private Boolean isClearData = false;
总结
Spring MVC
对参数的绑定与转换,内容不同,采用的处理器也不同
- form表单数据(application/x-www-form-urlencoded)
处理器:
ServletModelAttributeMethodProcessor
- JSON 数据 (application/json)
处理器:
RequestResponseBodyMethodProcessor
转换器:MappingJackson2HttpMessageConverter
- 多部分文件 (multipart/form-data)
处理器:
MultipartResolver
- form表单数据(application/x-www-form-urlencoded)
POJO
的布尔类型变量,不要加is
前缀
命名不符合规范,集成第三方框架的时候就很容易出不好排查的问题
成不了规范的制定者,那就老老实实遵循规范!
来源:juejin.cn/post/7521642915278422070
这5种规则引擎,真香!
前言
核心痛点:业务规则高频变更与系统稳定性之间的矛盾
想象一个电商促销场景:
// 传统硬编码方式(噩梦开始...)
public BigDecimal calculateDiscount(Order order) {
BigDecimal discount = BigDecimal.ZERO;
if (order.getTotalAmount().compareTo(new BigDecimal("100")) >= 0) {
discount = discount.add(new BigDecimal("10"));
}
if (order.getUser().isVip()) {
discount = discount.add(new BigDecimal("5"));
}
// 更多if-else嵌套...
return discount;
}
当规则变成:"非VIP用户满200减30,VIP用户满150减40,且周二全场额外95折"时,代码将陷入维护地狱!
规则引擎通过分离规则逻辑解决这个问题:
- 规则外置存储(数据库/文件)
- 支持动态加载
- 声明式规则语法
- 独立执行环境
下面给大家分享5种常用的规则引擎,希望对你会有所帮助。
最近准备面试的小伙伴,可以看一下这个宝藏网站(Java突击队):www.susan.net.cn,里面:面试八股文、场景题、面试真题、项目实战、工作内推什么都有。
核心痛点:业务规则高频变更与系统稳定性之间的矛盾
想象一个电商促销场景:
// 传统硬编码方式(噩梦开始...)
public BigDecimal calculateDiscount(Order order) {
BigDecimal discount = BigDecimal.ZERO;
if (order.getTotalAmount().compareTo(new BigDecimal("100")) >= 0) {
discount = discount.add(new BigDecimal("10"));
}
if (order.getUser().isVip()) {
discount = discount.add(new BigDecimal("5"));
}
// 更多if-else嵌套...
return discount;
}
当规则变成:"非VIP用户满200减30,VIP用户满150减40,且周二全场额外95折"时,代码将陷入维护地狱!
规则引擎通过分离规则逻辑解决这个问题:
- 规则外置存储(数据库/文件)
- 支持动态加载
- 声明式规则语法
- 独立执行环境
下面给大家分享5种常用的规则引擎,希望对你会有所帮助。
最近准备面试的小伙伴,可以看一下这个宝藏网站(Java突击队):www.susan.net.cn,里面:面试八股文、场景题、面试真题、项目实战、工作内推什么都有。
1.五大常用规则引擎
1.1 Drools:企业级规则引擎扛把子
适用场景:
- 金融风控规则(上百条复杂规则)
- 保险理赔计算
- 电商促销体系
- 金融风控规则(上百条复杂规则)
- 保险理赔计算
- 电商促销体系
实战:折扣规则配置
// 规则文件 discount.drl
rule "VIP用户满100减20"
when
$user: User(level == "VIP")
$order: Order(amount > 100)
then
$order.addDiscount(20);
end
// 规则文件 discount.drl
rule "VIP用户满100减20"
when
$user: User(level == "VIP")
$order: Order(amount > 100)
then
$order.addDiscount(20);
end
Java调用代码:
KieServices kieServices = KieServices.Factory.get();
KieContainer kContainer = kieServices.getKieClasspathContainer();
KieSession kSession = kContainer.newKieSession("discountSession");
kSession.insert(user);
kSession.insert(order);
kSession.fireAllRules();
优点:
- 完整的RETE算法实现
- 支持复杂的规则网络
- 完善的监控管理控制台
缺点:
- 学习曲线陡峭
- 内存消耗较大
- 需要依赖Kie容器
适合:不差钱的大厂,规则复杂度高的场景
KieServices kieServices = KieServices.Factory.get();
KieContainer kContainer = kieServices.getKieClasspathContainer();
KieSession kSession = kContainer.newKieSession("discountSession");
kSession.insert(user);
kSession.insert(order);
kSession.fireAllRules();
优点:
- 完整的RETE算法实现
- 支持复杂的规则网络
- 完善的监控管理控制台
缺点:
- 学习曲线陡峭
- 内存消耗较大
- 需要依赖Kie容器
适合:不差钱的大厂,规则复杂度高的场景
1.2 Easy Rules:轻量级规则引擎之王
适用场景:
- 参数校验
- 简单风控规则
- 审批流引擎
- 参数校验
- 简单风控规则
- 审批流引擎
注解式开发:
@Rule(name = "雨天打折规则", description = "下雨天全场9折")
public class RainDiscountRule {
@Condition
public boolean when(@Fact("weather") String weather) {
return "rainy".equals(weather);
}
@Action
public void then(@Fact("order") Order order) {
order.setDiscount(0.9);
}
}
@Rule(name = "雨天打折规则", description = "下雨天全场9折")
public class RainDiscountRule {
@Condition
public boolean when(@Fact("weather") String weather) {
return "rainy".equals(weather);
}
@Action
public void then(@Fact("order") Order order) {
order.setDiscount(0.9);
}
}
引擎执行:
RulesEngineParameters params = new RulesEngineParameters()
.skipOnFirstAppliedRule(true); // 匹配即停止
RulesEngine engine = new DefaultRulesEngine(params);
engine.fire(rules, facts);
优点:
- 五分钟上手
- 零第三方依赖
- 支持规则组合
缺点:
- 不支持复杂规则链
- 缺少可视化界面
适合:中小项目快速落地,开发人员不足时
RulesEngineParameters params = new RulesEngineParameters()
.skipOnFirstAppliedRule(true); // 匹配即停止
RulesEngine engine = new DefaultRulesEngine(params);
engine.fire(rules, facts);
优点:
- 五分钟上手
- 零第三方依赖
- 支持规则组合
缺点:
- 不支持复杂规则链
- 缺少可视化界面
适合:中小项目快速落地,开发人员不足时
1.3 QLExpress:阿里系脚本引擎之光
适用场景:
- 动态配置计算逻辑
- 财务公式计算
- 营销规则灵活变更
- 动态配置计算逻辑
- 财务公式计算
- 营销规则灵活变更
执行动态脚本:
ExpressRunner runner = new ExpressRunner();
DefaultContext context = new DefaultContext<>();
context.put("user", user);
context.put("order", order);
String express = "if (user.level == 'VIP') { order.discount = 0.85; }";
runner.execute(express, context, null, true, false);
ExpressRunner runner = new ExpressRunner();
DefaultContext context = new DefaultContext<>();
context.put("user", user);
context.put("order", order);
String express = "if (user.level == 'VIP') { order.discount = 0.85; }";
runner.execute(express, context, null, true, false);
高级特性:
// 1. 函数扩展
runner.addFunction("计算税费", new Operator() {
@Override
public Object execute(Object[] list) {
return (Double)list[0] * 0.06;
}
});
// 2. 宏定义
runner.addMacro("是否新用户", "user.regDays < 30");
优点:
- 脚本热更新
- 语法接近Java
- 完善的沙箱安全
缺点:
- 调试困难
- 复杂规则可读性差
适合:需要频繁修改规则的业务(如运营活动)
// 1. 函数扩展
runner.addFunction("计算税费", new Operator() {
@Override
public Object execute(Object[] list) {
return (Double)list[0] * 0.06;
}
});
// 2. 宏定义
runner.addMacro("是否新用户", "user.regDays < 30");
优点:
- 脚本热更新
- 语法接近Java
- 完善的沙箱安全
缺点:
- 调试困难
- 复杂规则可读性差
适合:需要频繁修改规则的业务(如运营活动)
1.4 Aviator:高性能表达式专家
适用场景:
- 实时定价引擎
- 风控指标计算
- 大数据字段加工
- 实时定价引擎
- 风控指标计算
- 大数据字段加工
性能对比(执行10万次):
// Aviator 表达式
Expression exp = AviatorEvaluator.compile("user.age > 18 && order.amount > 100");
exp.execute(map);
// Groovy 脚本
new GroovyShell().evaluate("user.age > 18 && order.amount > 100");
引擎 耗时 Aviator 220ms Groovy 1850ms
// Aviator 表达式
Expression exp = AviatorEvaluator.compile("user.age > 18 && order.amount > 100");
exp.execute(map);
// Groovy 脚本
new GroovyShell().evaluate("user.age > 18 && order.amount > 100");
引擎 | 耗时 |
---|---|
Aviator | 220ms |
Groovy | 1850ms |
编译优化:
// 开启编译缓存(默认开启)
AviatorEvaluator.getInstance().useLRUExpressionCache(1000);
// 字节码生成模式(JDK8+)
AviatorEvaluator.setOption(Options.ASM, true);
优点:
- 性能碾压同类引擎
- 支持字节码生成
- 轻量无依赖
缺点:
- 只支持表达式
- 不支持流程控制
适合:对性能有极致要求的计算场景
// 开启编译缓存(默认开启)
AviatorEvaluator.getInstance().useLRUExpressionCache(1000);
// 字节码生成模式(JDK8+)
AviatorEvaluator.setOption(Options.ASM, true);
优点:
- 性能碾压同类引擎
- 支持字节码生成
- 轻量无依赖
缺点:
- 只支持表达式
- 不支持流程控制
适合:对性能有极致要求的计算场景
1.5 LiteFlow:规则编排新物种
适用场景:
- 复杂业务流程
- 订单状态机
- 审核工作流
- 复杂业务流程
- 订单状态机
- 审核工作流
编排示例:
<chain name="orderProcess">
<then value="checkStock,checkCredit"/>
<when value="isVipUser">
<then value="vipDiscount"/>
when>
<otherwise>
<then value="normalDiscount"/>
otherwise>
<then value="saveOrder"/>
chain>
<chain name="orderProcess">
<then value="checkStock,checkCredit"/>
<when value="isVipUser">
<then value="vipDiscount"/>
when>
<otherwise>
<then value="normalDiscount"/>
otherwise>
<then value="saveOrder"/>
chain>
Java调用:
LiteflowResponse response = FlowExecutor.execute2Resp("orderProcess", order, User.class);
if (response.isSuccess()) {
System.out.println("流程执行成功");
} else {
System.out.println("失败原因:" + response.getCause());
}
优点:
- 可视化流程编排
- 支持异步、并行、条件分支
- 热更新规则
缺点:
- 新框架文档较少
- 社区生态待完善
适合:需要灵活编排的复杂业务流
LiteflowResponse response = FlowExecutor.execute2Resp("orderProcess", order, User.class);
if (response.isSuccess()) {
System.out.println("流程执行成功");
} else {
System.out.println("失败原因:" + response.getCause());
}
优点:
- 可视化流程编排
- 支持异步、并行、条件分支
- 热更新规则
缺点:
- 新框架文档较少
- 社区生态待完善
适合:需要灵活编排的复杂业务流
2 五大规则引擎横向评测
性能压测数据(单机1万次执行):
引擎 耗时 内存占用 特点 Drools 420ms 高 功能全面 Easy Rules 38ms 低 轻量易用 QLExpress 65ms 中 阿里系脚本引擎 Aviator 28ms 极低 高性能表达式 LiteFlow 120ms 中 流程编排专家
引擎 | 耗时 | 内存占用 | 特点 |
---|---|---|---|
Drools | 420ms | 高 | 功能全面 |
Easy Rules | 38ms | 低 | 轻量易用 |
QLExpress | 65ms | 中 | 阿里系脚本引擎 |
Aviator | 28ms | 极低 | 高性能表达式 |
LiteFlow | 120ms | 中 | 流程编排专家 |
3 如何技术选型?
黄金法则:
- 简单场景:EasyRules + Aviator 组合拳
- 金融风控:Drools 稳如老狗
- 电商运营:QLExpress 灵活应变
- 工作流驱动:LiteFlow 未来可期
- 简单场景:EasyRules + Aviator 组合拳
- 金融风控:Drools 稳如老狗
- 电商运营:QLExpress 灵活应变
- 工作流驱动:LiteFlow 未来可期
4 避坑指南
- Drools内存溢出
// 设置无状态会话(避免内存积累)
KieSession session = kContainer.newStatelessKieSession();
- QLExpress安全漏洞
// 禁用危险方法
runner.addFunctionOfServiceMethod("exit", System.class, "exit", null, null);
- 规则冲突检测
// Drools冲突处理策略
KieSessionConfiguration config = KieServices.Factory.get().newKieSessionConfiguration();
config.setProperty("drools.sequential", "true"); // 按顺序执行
- Drools内存溢出
// 设置无状态会话(避免内存积累)
KieSession session = kContainer.newStatelessKieSession();
- QLExpress安全漏洞
// 禁用危险方法
runner.addFunctionOfServiceMethod("exit", System.class, "exit", null, null);
// Drools冲突处理策略
KieSessionConfiguration config = KieServices.Factory.get().newKieSessionConfiguration();
config.setProperty("drools.sequential", "true"); // 按顺序执行
总结
- 能用:替换if/else(新手村)
- 用好:规则热更新+可视化(进阶)
- 用精:规则编排+性能优化(大师级)
- 能用:替换if/else(新手村)
- 用好:规则热更新+可视化(进阶)
- 用精:规则编排+性能优化(大师级)
曾有人问我:“规则引擎会不会让程序员失业?” 我的回答是:“工具永远淘汰不了思考者,只会淘汰手工作坊”。
真正的高手,不是写更多代码,而是用更优雅的方式解决问题。
最后送句话:技术选型没有最好的,只有最合适的。
最后说一句(求关注,别白嫖我)
如果这篇文章对您有所帮助,或者有所启发的话,帮忙关注一下我的同名公众号:苏三说技术,您的支持是我坚持写作最大的动力。
求一键三连:点赞、转发、在看。
关注公众号:【苏三说技术】,在公众号中回复:进大厂,可以免费获取我最近整理的10万字的面试宝典,好多小伙伴靠这个宝典拿到了多家大厂的offer。
来源:juejin.cn/post/7517854096175988762
用户登录成功后,判断用户在10分钟内有没有操作,无操作自动退出登录怎么实现?
需求详细描述:用户登录成功后,默认带入10min的初始值,可针对该用户进行单独设置,单位:分钟,设置范围:1-15,用户在系统没有操作后满足该时长自动退出登录;
疑问:怎么判断用户在10分钟内有没有操作?
实现步骤
✅ 一、功能点描述:
默认超时时间,登录后默认为 10 分钟,
支持自定义设置 用户可修改自己的超时时间(1~15 分钟)
自动登出逻辑 用户在设定时间内没有“操作”,就触发登出.
✅ 二、关键问题:如何判断用户是否操作了?
🔍 操作的定义:
这里的“操作”可以理解为任何与页面交互的行为,
例如:
点击按钮、
鼠标移动、
键盘输入、
页面滚动、路由变化等。
✅ 三、解决方案:
使用全局事件监听器来检测用户的活跃状态,并重置计时器。
✅ 四、实现思路(Vue3 + Composition API)
我们可以通过以下步骤实现:
1. 定义一个响应式的 inactivityTime 变量(单位:分钟)
const inactivityTime = ref(10); // 默认10分钟
2. 创建一个定时器变量
let logoutTimer = null;
3. 重置定时器函数
function resetTimer() {
if (logoutTimer) {
clearTimeout(logoutTimer);
}
logoutTimer = setTimeout(() => {
console.log('用户已超时,执行登出');
// 这里执行登出操作,如清除 token、跳转到登录页等
store.dispatch('logout'); // 假设你用了 Vuex/Pinia
}, inactivityTime.value * 60 * 1000); // 转换为毫秒
}
4. 监听用户活动并重置定时器
function setupActivityListeners() {
const events = ['click', 'mousemove', 'keydown', 'scroll', 'touchstart'];
events.forEach(event => {
window.addEventListener(event, resetTimer, true);
});
}
function removeActivityListeners() {
const events = ['click', 'mousemove', 'keydown', 'scroll', 'touchstart'];
events.forEach(event => {
window.removeEventListener(event, resetTimer, true);
});
}
5. 在组件挂载时初始化定时器和监听器
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
const router = useRouter();
const inactivityTime = ref(10); // 默认10分钟
let logoutTimer = null;
function resetTimer() {
if (logoutTimer) {
clearTimeout(logoutTimer);
}
logoutTimer = setTimeout(() => {
alert('由于长时间未操作,您已被自动登出');
localStorage.removeItem('token'); // 清除 token
router.push('/login'); // 跳转到登录页
}, inactivityTime.value * 60 * 1000);
}
function setupActivityListeners() {
const events = ['click', 'mousemove', 'keydown', 'scroll', 'touchstart'];
events.forEach(event => {
window.addEventListener(event, resetTimer, true);
});
}
function removeActivityListeners() {
const events = ['click', 'mousemove', 'keydown', 'scroll', 'touchstart'];
events.forEach(event => {
window.removeEventListener(event, resetTimer, true);
});
}
onMounted(() => {
setupActivityListeners();
resetTimer(); // 初始化定时器
});
onUnmounted(() => {
removeActivityListeners();
if (logoutTimer) clearTimeout(logoutTimer);
});
</script>
✅ 四、支持用户自定义设置(进阶)
你可以通过接口获取用户的个性化超时时间:
// 假设你从接口获取到了用户的设置
api.getUserSettings().then(res => {
const userTimeout = res.autoLogoutTime; // 单位:分钟,假设值为 5-15
if (userTimeout >= 1 && userTimeout <= 15) {
inactivityTime.value = userTimeout;
resetTimer(); // 更新定时器
}
});
✅ 五、完整逻辑流程图(文字版)
✅ 六、注意事项
不要监听太少的事件,比如只监听 click,可能会漏掉键盘操作,
使用 true 参数添加事件监听器,表示捕获阶段监听,更可靠
多标签页场景,如果用户开了多个标签页,需考虑同步机制(比如使用 LocalStorage)
移动端适配,加入 touchstart 等移动端事件监听,
登出前最好加个提示,比如弹窗让用户选择是否继续会话。
✅ 七、推荐封装方式
你可以把这个逻辑封装成一个 Vue 自定义 Hook,例如 useAutoLogout.js,然后在需要的页面调用它即可。
// useAutoLogout.js
export function useAutoLogout(timeoutMinutes = 10) {
let timer = null;
function resetTimer() {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
alert('由于长时间未操作,您已被自动登出');
localStorage.removeItem('token');
window.location.href = '/login';
}, timeoutMinutes * 60 * 1000);
}
function setupListeners() {
const events = ['click', 'mousemove', 'keydown', 'scroll', 'touchstart'];
events.forEach(event => {
window.addEventListener(event, resetTimer, true);
});
}
function removeListeners() {
const events = ['click', 'mousemove', 'keydown', 'scroll', 'touchstart'];
events.forEach(event => {
window.removeEventListener(event, resetTimer, true);
});
}
onMounted(() => {
setupListeners();
resetTimer();
});
onUnmounted(() => {
removeListeners();
if (timer) clearTimeout(timer);
});
}
然后在组件中:
import { useAutoLogout } from '@/hooks/useAutoLogout'
export default {
setup() {
useAutoLogout(10); // 设置默认10分钟
}
}
✅ 八、总结:
实现方式:
判断用户是否有操作,监听 click、 mousemove、 keydown 等事件,
自动登出设置定时器,在无操作后触发,
用户自定义超时时间,接口获取后动态设置定时器时间,
页面间复用,封装为 Vue 自定义 Hook 更好维护。
使用优化
如果把它封装成一个自定义 Hook(如 useAutoLogout
),这种写法确实需要在每个需要用到自动登出功能的页面里手动引入并调用它,麻烦且不优雅,不适合大型项目。
✅ 一、进阶方案:通过路由守卫自动注入
你可以利用 Vue Router 的 beforeEach 钩子,在用户进入页面时自动触发 useAutoLogout。
步骤如下:
- 创建一个可复用的方法(比如放到 utils.js 或 autoLogout.js 中)
// src/utils/autoLogout.js
import { useAutoLogout } from '@/hooks/useAutoLogout'
export function enableAutoLogout(timeout = 10) {
useAutoLogout(timeout)
}
2. 在路由配置中使用 meta 标记是否启用自动登出
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router';
import { useAutoLogout } from '@/hooks/useAutoLogout';
import store from './store'; // 假设你有一个 Vuex 或 Pinia 状态管理库用于保存用户设置
const routes = [
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: { autoLogout: true } // 表示这个页面需要自动登出功能
},
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue')
// 不加 meta.autoLogout 表示不启用
}
];
const router = createRouter({
history: createWebHistory(),
routes,
});
router.beforeEach(async (to, from, next) => {
if (to.meta.autoLogout) {
// 获取用户的自定义超时时间
let timeout = 10; // 默认值
try {
// 这里假设从后端获取用户的自定义超时时间
const userSettings = await store.dispatch('fetchUserSettings'); // 根据实际情况调整
timeout = userSettings.autoLogoutTime || timeout;
} catch (error) {
console.error("Failed to fetch user settings:", error);
}
// 使用自定义超时时间初始化或重置计时器
const resetTimer = useAutoLogout(timeout);
resetTimer(); // 初始设置计时器
}
next();
});
export default router;
⚠️ 注意事项:
- 组件实例:
Vue 3 Composition API 中,不能直接在 beforeEach 中访问组件实例,需要把 enableAutoLogout 改为在组件内部调用,或者结合 Vuex/Pinia 做状态管理。 - 状态管理: 如果用户可以在应用运行期间更改其自动登出时间设置,你需要一种机制来实时更新这些设置。这通常涉及到状态管理库(如Vuex/Pinia)以及与后端同步用户偏好设置。
- 避免重复监听事件: 在每次导航时都添加新的事件监听器会导致内存泄漏。上述代码通过在组件卸载时移除监听器解决了这个问题,但如果你选择其他方式实现,请确保也处理了这一点。
- 用户体验: 在实际应用中,最好在即将登出前给用户提示,让用户有机会延长会话。
✅ 三、终极方案:创建一个全局插件(最优雅)
你可以把这个逻辑封装成一个 Vue 插件,这样只需要一次引入,就能全局生效。
示例:创建一个插件文件 autoLogoutPlugin.js
// src/plugins/autoLogoutPlugin.js
import { useAutoLogout } from '@/hooks/useAutoLogout'
export default {
install: (app, options = {}) => {
const timeout = options.timeout || 10
app.mixin({
setup() {
useAutoLogout(timeout)
}
})
}
}
使用插件:
// main.js
import AutoLogoutPlugin from './plugins/autoLogoutPlugin'
const app = createApp(App)
app.use(AutoLogoutPlugin, { timeout: 10 }) // 设置默认超时时间
app.mount('#app')
✅ 这样做之后,所有页面都会自动应用 useAutoLogout,无需手动导入。
插件使用解释
- ✅ export default 是一个 Vue 插件对象,必须包含 install 方法
Vue 插件是一个对象,它提供了一个 install(app, options) 方法。这个方法会在你调用 app.use(Plugin) 的时候执行。 - ✅ install: (app, options = {}) => { ... }
app: 是你的 Vue 应用实例(也就是通过 createApp(App) 创建的那个)
options: 是你在调用 app.use(AutoLogoutPlugin, { timeout: 10 }) 时传入的配置项
所以你可以在这里拿到你设置的超时时间 { timeout: 10 }。 - ✅ const timeout = options.timeout || 10
这是一个默认值逻辑:如果用户传了 timeout,就使用用户的;
否则使用默认值 10 分钟。 - ✅ app.mixin({ ... })
这是关键部分!
- 💡 什么是 mixin?
mixin 是 Vue 中的“混入”,可以理解为:向所有组件中注入一些公共的逻辑或配置。 - 举个例子:如果你有一个功能要在每个页面都启用,比如日志记录、权限检查、自动登出等,就可以用 mixin 实现一次写好,到处生效。
- ✅ setup() 中调用 useAutoLogout(timeout)
每个组件在创建时都会执行一次 setup() 函数。
在这里调用 useAutoLogout(timeout),相当于:
在每一个页面组件中都自动调用了 useAutoLogout(10)
也就是说,自动注册了监听器 + 自动设置了计时器
- 💡 什么是 mixin?
- 为什么这样就能全局监听用户操作?因为你在每个组件中都执行了 useAutoLogout(timeout),而这个函数内部做了以下几件事:
function useAutoLogout(timeout) {
// 设置定时器
// 添加事件监听器(点击、移动鼠标、键盘输入等)
// 组件卸载时清除监听器和定时器
}
因此,只要某个组件被加载,就会自动启动自动登出机制;组件卸载后,又会自动清理资源,避免内存泄漏。
总结一下整个流程
1️⃣ 在 main.js 中调用 app.use(AutoLogoutPlugin, { timeout: 10 })
2️⃣ 插件的 install() 被执行,获取到 timeout 值
3️⃣ 使用 app.mixin() 向所有组件中注入一段逻辑
4️⃣ 每个组件在 setup() 阶段自动调用 useAutoLogout(timeout)
5️⃣ 每个组件都注册了全局事件监听器,并设置了登出定时器
✅ 这样一来,所有组件页面都拥有了自动登出功能,不需要你手动去每个页面加代码。
注意事项
❗ 不是所有页面都需要自动登出 比如登录页、错误页可能不需要。可以在 mixin 中加判断,例如:根据路由或 meta 字段过滤
⚠️ 性能问题? 不会有明显影响,因为只添加了一次监听器,且组件卸载时会清理
🔄 登录后如何动态更新超时时间? 可以结合 Vuex/Pinia,在 store 改变时重新调用 useAutoLogout(newTimeout)
🧪 测试建议 手动测试几种情况:
• 页面切换是否重置计时
• 用户操作是否刷新倒计时
• 超时后是否跳转登录页
进阶建议:支持按需开启(可选)
如果你想只在某些页面启用自动登出功能,而不是全局启用,也可以这样改写:
app.mixin({
setup() {
// 判断当前组件是否启用了 autoLogout
const route = useRoute()
if (route.meta.autoLogout !== false) {
useAutoLogout(timeout)
}
}
})
然后在路由配置中:
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: { autoLogout: true }
}
最终效果你只需要在 main.js 中引入插件并配置一次:
app.use(AutoLogoutPlugin, { timeout: 10 })
就能让整个项目中的所有页面都拥有自动登出功能,无需在每个页面单独导入和调用。
✅ 四、总结对比
🟢 大型项目、统一行为控制,所有页面都启用自动登出 ➜ 推荐使用 插件方式
🟡 中型项目、统一管理页面行为,只在某些页面启用 ➜ 推荐使用 路由守卫 + meta
🔴 小型项目、部分页面控制,只在个别页面启用 ➜ 继续使用 手动调用
来源:juejin.cn/post/7510044998433030180
如何优雅的防止按钮重复点击
1. 业务背景
在前端的业务场景中:点击按钮,发起请求。在请求还未结束的时候,一个按钮可以重复点击,导致接口重新请求多次(如果后端不做限制)。轻则浪费服务器资源,重则业务逻辑错误,尤其是入库操作。
传统解决方案:使用防抖函数,但是无法解决接口响应时间过长的问题,当接口一旦响应时间超过防抖时间,测试单身20年的手速照样还是可以点击多次。
更稳妥的方式:给button添加loadng,只有接口响应结果后才能再次点击按钮。需要在每个使用按钮的页面逻辑中单独维护loading变量,代码变得臃肿。
那如果是在react项目中,这种问题有没有比较优雅的解决方式呢?
vue项目解决方案参考:juejin.cn/post/749541…
在前端的业务场景中:点击按钮,发起请求。在请求还未结束的时候,一个按钮可以重复点击,导致接口重新请求多次(如果后端不做限制)。轻则浪费服务器资源,重则业务逻辑错误,尤其是入库操作。
传统解决方案:使用防抖函数,但是无法解决接口响应时间过长的问题,当接口一旦响应时间超过防抖时间,测试单身20年的手速照样还是可以点击多次。
更稳妥的方式:给button添加loadng,只有接口响应结果后才能再次点击按钮。需要在每个使用按钮的页面逻辑中单独维护loading变量,代码变得臃肿。
那如果是在react项目中,这种问题有没有比较优雅的解决方式呢?
vue项目解决方案参考:juejin.cn/post/749541…
2. useAsyncButton
在 React 项目中,对于这种按钮重复点击的问题,可以使用自定义 Hook 来优雅地处理。以下是一个完整的解决方案:
- 首先创建一个自定义 Hook
useAsyncButton
:
import { useState, useCallback } from 'react';
interface RequestOptions {
onSuccess?: (data: any) => void;
onError?: (error: any) => void;
}
export function useAsyncButton(
requestFn: (...args: any[]) => Promise,
options: RequestOptions = {}
) {
const [loading, setLoading] = useState(false);
const run = useCallback(
async (...args: any[]) => {
if (loading) return; // 如果正在加载,直接返回
try {
setLoading(true);
const data = await requestFn(...args);
options.onSuccess?.(data);
return data;
} catch (error) {
options.onError?.(error);
throw error;
} finally {
setLoading(false);
}
},
[loading, requestFn, options]
);
return {
loading,
run
};
}
- 在组件中使用这个 Hook:
import { useAsyncButton } from '../hooks/useAsyncButton';
const MyButton = () => {
const { loading, run } = useAsyncButton(async () => {
// 这里是你的接口请求
const response = await fetch('your-api-endpoint');
const data = await response.json();
return data;
}, {
onSuccess: (data) => {
console.log('请求成功:', data);
},
onError: (error) => {
console.error('请求失败:', error);
}
});
return (
<button
onClick={() => run()}
disabled={loading}
>
{loading ? '加载中...' : '点击请求'}
button>
);
};
export default MyButton;
在 React 项目中,对于这种按钮重复点击的问题,可以使用自定义 Hook 来优雅地处理。以下是一个完整的解决方案:
- 首先创建一个自定义 Hook
useAsyncButton
:
import { useState, useCallback } from 'react';
interface RequestOptions {
onSuccess?: (data: any) => void;
onError?: (error: any) => void;
}
export function useAsyncButton(
requestFn: (...args: any[]) => Promise,
options: RequestOptions = {}
) {
const [loading, setLoading] = useState(false);
const run = useCallback(
async (...args: any[]) => {
if (loading) return; // 如果正在加载,直接返回
try {
setLoading(true);
const data = await requestFn(...args);
options.onSuccess?.(data);
return data;
} catch (error) {
options.onError?.(error);
throw error;
} finally {
setLoading(false);
}
},
[loading, requestFn, options]
);
return {
loading,
run
};
}
- 在组件中使用这个 Hook:
import { useAsyncButton } from '../hooks/useAsyncButton';
const MyButton = () => {
const { loading, run } = useAsyncButton(async () => {
// 这里是你的接口请求
const response = await fetch('your-api-endpoint');
const data = await response.json();
return data;
}, {
onSuccess: (data) => {
console.log('请求成功:', data);
},
onError: (error) => {
console.error('请求失败:', error);
}
});
return (
<button
onClick={() => run()}
disabled={loading}
>
{loading ? '加载中...' : '点击请求'}
button>
);
};
export default MyButton;
这个解决方案有以下优点:
- 统一管理:将请求状态管理逻辑封装在一个 Hook 中,避免重复代码
- 自动处理 loading:不需要手动管理 loading 状态
- 防重复点击:在请求过程中自动禁用按钮或阻止重复请求
- 类型安全:使用 TypeScript 提供类型检查
- 灵活性:可以通过 options 配置成功/失败的回调函数
- 可复用性:可以在任何组件中重用这个 Hook
useAsyncButton
直接帮你进行了try catch
,你不用再单独去做异常处理。
是不是很简单?有的人可能有疑问了,为什么下方不就能拿到接口请求以后的数据吗?为什么还需要onSuccess呢?
async () => {
// 这里是你的接口请求
const response = await fetch('your-api-endpoint');
const data = await response.json();
return data;
}
3. onSuccess
确实我们可以直接在调用 run()
后通过 .then()
或 await
来获取数据。提供 onSuccess
回调主要有以下几个原因:
- 关注点分离:
// 不使用 onSuccess
const { run } = useAsyncButton(async () => {
const response = await fetch('/api/data');
return response.json();
});
const handleClick = async () => {
const data = await run();
// 处理数据的逻辑和请求逻辑混在一起
setData(data);
message.success('请求成功');
doSomethingElse(data);
};
// 使用 onSuccess
const { run } = useAsyncButton(async () => {
const response = await fetch('/api/data');
return response.json();
}, {
onSuccess: (data) => {
// 数据处理逻辑被清晰地分离出来
setData(data);
message.success('请求成功');
doSomethingElse(data);
}
});
const handleClick = () => {
run(); // 更清晰的调用方式
};
- 统一错误处理:
// 不使用 callbacks
const handleClick = async () => {
try {
const data = await run();
setData(data);
} catch (error) {
// 每个地方都需要写错误处理
message.error('请求失败');
}
};
// 使用 callbacks
const { run } = useAsyncButton(fetchData, {
onSuccess: (data) => setData(data),
onError: (error) => message.error('请求失败')
// 错误处理被集中管理
});
- 自动重试场景:
const { run } = useAsyncButton(fetchData, {
onSuccess: (data) => setData(data),
onError: (error) => {
if (retryCount < 3) {
retryCount++;
run(); // 可以在失败时自动重试
}
}
});
- 状态联动:
const { run } = useAsyncButton(fetchData, {
onSuccess: (data) => {
setData(data);
// 可能需要触发其他请求
refetchRelatedData();
// 或更新其他状态
setOtherState(true);
}
});
所以,虽然你完全可以不使用 onSuccess
回调,但它能帮助你:
- 更好地组织代码结构
- 统一管理成功/失败处理逻辑
- 方便进行状态联动
- 在需要扩展功能时更加灵活
选择使用与否取决于你的具体需求,如果是简单的场景,直接使用 await run()
也完全可以。
4. 禁止一段时间内点击
评论区有人说了,我要是想在某一段时间内防止重复点击怎么整?
我们可以扩展 useAsyncButton
的功能,添加一个防冷却时间(cooldown)的特性。这在一些特定场景下很有用,比如发送验证码按钮需要等待 60 秒才能再次点击:
import { useState, useCallback, useRef } from 'react';
interface AsyncButtonOptions {
onSuccess?: (data: any) => void;
onError?: (error: any) => void;
cooldown?: number; // 冷却时间(毫秒)
}
export function useAsyncButton(
requestFn: (...args: any[]) => Promise,
options: AsyncButtonOptions = {}
) {
const [loading, setLoading] = useState(false);
const [cooldownRemaining, setCooldownRemaining] = useState(0);
const timerRef = useRef<NodeJS.Timeout>();
const startCooldown = useCallback(() => {
if (!options.cooldown) return;
setCooldownRemaining(options.cooldown / 1000);
const startTime = Date.now();
timerRef.current = setInterval(() => {
const elapsed = Date.now() - startTime;
const remaining = Math.ceil((options.cooldown! - elapsed) / 1000);
if (remaining <= 0) {
clearInterval(timerRef.current);
setCooldownRemaining(0);
} else {
setCooldownRemaining(remaining);
}
}, 1000);
}, [options.cooldown]);
const run = useCallback(
async (...args: any[]) => {
if (loading || cooldownRemaining > 0) return;
try {
setLoading(true);
const data = await requestFn(...args);
options.onSuccess?.(data);
startCooldown();
return data;
} catch (error) {
options.onError?.(error);
throw error;
} finally {
setLoading(false);
}
},
[loading, cooldownRemaining, requestFn, options, startCooldown]
);
return {
loading,
cooldownRemaining,
run,
disabled: loading || cooldownRemaining > 0
};
}
使用示例:
import { useAsyncButton } from '../hooks/useAsyncButton';
const SendCodeButton = () => {
const { loading, cooldownRemaining, disabled, run } = useAsyncButton(
async () => {
// 发送验证码的接口请求
const response = await fetch('/api/send-code');
return response.json();
},
{
cooldown: 60000, // 60秒冷却时间
onSuccess: () => {
console.log('验证码发送成功');
},
onError: (error) => {
console.error('验证码发送失败', error);
}
}
);
return (
<button
onClick={() => run()}
disabled={disabled}
>
{loading ? '发送中...' :
cooldownRemaining > 0 ? `${cooldownRemaining}秒后重试` :
'发送验证码'}
button>
);
};
export default SendCodeButton;
来源:juejin.cn/post/7498646341460787211
为什么说不可信的Wi-Fi不要随便连接?
新闻中一直倡导的“不可信的Wi-Fi不要随便连接”,一直不知道里面的风险,即使是相关专业的从业人员,惭愧。探索后发现这里面的安全知识不少,记录下:
简单来说:
当用户连接上攻击者创建的伪造热点或者不可信的wifi后,实际上就进入了一个高度不可信的网络环境,攻击者可以进行各种信息窃取、欺骗和控制操作。
主要风险有:
🚨 1.中间人攻击(MITM)
攻击者拦截并转发你与网站服务器之间的数据,做到“你以为你连的是官网,其实中间有人”。
- 可窃取账号密码、聊天记录、信用卡信息
- 可篡改网页内容,引导你下载恶意应用
如果你和“正确”的网站之间是https,那么信息不会泄露,TLS能保证通信过程的安全,前提是你连接的这个https网站是“正确”的。正确的含义是:不是某些人恶意伪造的,不是一些不法份子通过DNS欺骗来重定向到的。
🪤 2.DNS欺骗 / 重定向
攻击者控制DNS,将合法网址解析到伪造网站。
- 你访问的“http://www.bank.com” 其实是假的银行网站,DNS域名解析到恶意服务上,返回和银行一样的登录的界面,这样用户输入账号密码就被窃取到了。
- 输入的账号密码被记录,后端没收到任何请求
这里多说一句:目前的登录方式中,采用短信验证码的方式,能避免真实的密码被窃取的风险,尽量用这种登录方式。
📥 3.强制HTTP连接,篡改内容
即使你访问的是HTTPS网站,攻击者可以强制降级为HTTP或注入恶意代码:
- 注入广告、木马脚本
- 启动钓鱼表单页面骗你输入账号密码
攻击者操作流程:
- 用户访问
http://example.com(明文)
- 攻击者拦截请求,阻止它跳转到 HTTPS
- 返回伪造页面(比如仿登录页面),引导用户输入账号密码
- 用户完全不知道自己并未进入 HTTPS 页面
这里“降级”的意思是,虽然你访问的是http网站,网站正常会转为https的访问方式,但是被阻止了,一直使用的是http协议访问,能实现这种降级的前提有两个:
- 用户没有直接输入
https://baidu.com
, 而是输入的http://baidu.com
, 依赖浏览器自动跳转 - 访问的网站没有开启 HSTS(HTTP Strict Transport Security)
搭建安全的网站的启示:
- 网站访问用https,并且如果用户访问HTTP网站时被自动转到 HTTPS 网站
- 网站要启用HSTS
HSTS 是一种告诉浏览器“以后永远都不要使用 HTTP 访问我”的机制。
如何开启HSTS?
添加响应头(核心方式)
。在你的网站服务端(如 Nginx、Apache、Spring Boot、Express 等)添加以下 HTTP 响应头:
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
各参数含义如下:
参数 | 含义 |
---|---|
max-age=31536000 | 浏览器记住 HSTS 状态的时间(单位:秒,31536000 秒 = 1 年) |
includeSubDomains | 所有子域名也强制使用 HTTPS(推荐) |
preload | 提交到浏览器 HSTS 预加载列表(详见下文) |
网站实现http访问转为了https访问:
1 网站服务器配置了自动重定向(HTTP to HTTPS)
- 这是最常见的做法。网站后台(如 Nginx、Apache、Tomcat 等)配置了规则,凡是 HTTP 请求都会返回 301/302 重定向到 HTTPS 地址。
- 目的是强制用户用加密的 HTTPS 访问,保障数据安全。
2 请求的http response中加入HSTS机制
- 网站通过 HTTPS 响应头发送了 HSTS 指令。
- 浏览器收到后会记住该网站在一定时间内只能用 HTTPS 访问。
- 即使你输入
http://
,浏览器也会自动用https://
访问,且不会发送 HTTP 请求。
📁 4.会话劫持(Session Hijacking)
如果你已登录某个网站(如微博/邮箱),攻击者可以窃取你与服务端之间的 Session Cookie,无需密码即可“冒充你”。
搭建web服务对于cookie泄密的安全启示:
1、开启 Cookie 的 Secure 和 HttpOnly
当一个 Cookie 设置了 Secure 标志后,它只会在 HTTPS 加密连接中发送,不会通过 HTTP 明文连接发送。
设置 HttpOnly 后,JavaScript 无法通过 document.cookie 访问该 Cookie,它只能被浏览器在请求时自动带上。如果站点存在跨站脚本漏洞(XSS),攻击者注入的 JS 可以读取用户的 Cookie。设置了 HttpOnly 后,即便 JS 被执行,也无法读取该 Cookie。
2、配合设置 SameSite=Strict 或 Lax 可进一步防止 CSRF 攻击。
Set-Cookie: sessionid=abc123; SameSite=Strict; Secure; HttpOnly
CSRF(Cross-Site Request Forgery) 攻击的原理示意
操作 | 说明 |
---|---|
用户登录 bank.com ,浏览器存有 bank.com 的 Cookie | Cookie 设置为非 HttpOnly 且未限制 SameSite 或 设置为 SameSite=Lax ,正常携带 |
攻击网站 attacker.com ,诱导用户访问 <img src="https://bank.com/transfer?to=attacker&amount=1000"> | 这个请求是向 bank.com 发送的跨域请求 |
浏览器自动带上 bank.com 的 Cookie | 因为请求的目标是 bank.com ,Cookie 会被自动携带 |
bank.com 服务器收到请求,认为是用户本人操作,执行转账 | 服务器无法区分这个请求是不是用户主动发起的 |
重点是:你在浏览器中访问A网站,浏览器中存储A的cookie,此时你访问恶意的B网站,B网站向A网站发送请求,浏览器一般默认带上A网站的cookie,因此,相当于B网站恶意使用了你在A网站的身份,完成了攻击,比如获取信息,比如添加东西。设置SameSite=Strict能防止跨站伪造攻击,对A网站的请求只能在A网站下发送,在B网站发起对A网站请求的无法使用A的cookie
同源策略和Cookie的关系:
同源策略限制的是脚本访问另一个域的内容(比如 JS 不能读取别的网站 Cookie 或响应数据),但浏览器发送请求时,会自动携带目标域对应的 Cookie(只要该 Cookie 未被 SameSite 限制)。 也就是说,请求可以跨域发送,Cookie 也会随请求自动发送,但脚本无法读取响应。在没有设置SameSite时,B网站是可以直接往A网站发送请求并附带上A网站的cookie的。
关于SameSite三种取值详解:
值 | 说明 | 是否防CSRF | 是否影响用户体验 |
---|---|---|---|
Strict | 最严格:完全阻止第三方请求携带 Cookie(即使用户点击链接跳转也不带) | ✅ 完全防止 | ❗️可能影响登录态保持等 |
Lax | 较宽松:阻止大多数第三方请求,但允许用户主动导航(点击链接)时携带 Cookie | ✅ 可防大部分场景 | ✅ 用户体验良好 |
| 不限制跨站请求,所有请求都携带 Cookie | ❌ 不防CSRF | ⚠️ 必须配合 Secure 使用 |
🛡 SameSite使用建议(最佳实践)
场景 | 建议配置 |
---|---|
登录态/session Cookie | SameSite=Lax; Secure; HttpOnly ✅ 实用且安全 |
高安全需求(如金融后台) | SameSite=Strict; Secure; HttpOnly ✅ 更强安全性 |
跨域 OAuth / 第三方登录等 | SameSite=; Secure ⚠️ 必须使用 HTTPS,否则被浏览器拒绝 |
🧬 5.恶意软件传播
伪造热点可提供假的软件下载链接、更新提示等方式传播病毒或木马程序。
📡 6.网络钓鱼 + 社会工程攻击
攻击者可能弹出“需登录使用Wi-Fi”的界面,其实是钓鱼网站:
- 模拟常见的Wi-Fi登录界面(如酒店/机场门户)
- 用户一旦输入账号、手机号、验证码等敏感信息就被窃取
🔎 7.MAC地址、设备指纹收集
哪怕你没主动上网,连接伪热点后,攻击者也可能收集:
- 你的设备MAC地址、品牌型号
- 操作系统、语言、浏览器等指纹信息
- 用于后续追踪、精准广告投放,甚至诈骗定位
✅ 如何防范被伪热点攻击?
措施 | 说明 |
---|---|
关闭“自动连接开放Wi-Fi” | 阻止设备自动连接伪热点 |
避免输入账号密码、支付信息 | 尤其在陌生Wi-Fi环境下 |
使用 VPN | 建立安全通道防止数据被截取 |
留意HTTPS证书异常 | 浏览器地址栏变红或提示“不安全”要立刻断开连接 |
使用手机流量热点 | 相对更可控安全 |
安装安全软件 | 检测钓鱼网站和网络攻击行为 |
来源:juejin.cn/post/7517468634194362387
瞧瞧别人家的判空,那叫一个优雅!
大家好,我是苏三,又跟大家见面了。
一、传统判空的血泪史
某互联网金融平台因费用计算层级的空指针异常,导致凌晨产生9800笔错误交易。
DEBUG日志显示问题出现在如下代码段:
// 错误示例
BigDecimal amount = user.getWallet().getBalance().add(new BigDecimal("100"));
此类链式调用若中间环节出现null值,必定导致NPE。
初级阶段开发者通常写出多层嵌套式判断:
if(user != null){
Wallet wallet = user.getWallet();
if(wallet != null){
BigDecimal balance = wallet.getBalance();
if(balance != null){
// 实际业务逻辑
}
}
}
这种写法既不优雅又影响代码可读性。
那么,我们该如何优化呢?
最近准备面试的小伙伴,可以看一下这个宝藏网站:www.susan.net.cn,里面:面试八股文、面试真题、工作内推什么都有。
二、Java 8+时代的判空革命
Java8之后,新增了Optional类,它是用来专门判空的。
能够帮你写出更加优雅的代码。
1. Optional黄金三板斧
// 重构后的链式调用
BigDecimal result = Optional.ofNullable(user)
.map(User::getWallet)
.map(Wallet::getBalance)
.map(balance -> balance.add(new BigDecimal("100")))
.orElse(BigDecimal.ZERO);
高级用法:条件过滤
Optional.ofNullable(user)
.filter(u -> u.getVipLevel() > 3)
.ifPresent(u -> sendCoupon(u)); // VIP用户发券
2. Optional抛出业务异常
BigDecimal balance = Optional.ofNullable(user)
.map(User::getWallet)
.map(Wallet::getBalance)
.orElseThrow(() -> new BusinessException("用户钱包数据异常"));
3. 封装通用工具类
public class NullSafe {
// 安全获取对象属性
public static <T, R> R get(T target, Function<T, R> mapper, R defaultValue) {
return target != null ? mapper.apply(target) : defaultValue;
}
// 链式安全操作
public static <T> T execute(T root, Consumer<T> consumer) {
if (root != null) {
consumer.accept(root);
}
return root;
}
}
// 使用示例
NullSafe.execute(user, u -> {
u.getWallet().charge(new BigDecimal("50"));
logger.info("用户{}已充值", u.getId());
});
三、现代化框架的判空银弹
4. Spring实战技巧
Spring中自带了一些好用的工具类,比如:CollectionUtils、StringUtils等,可以非常有效的进行判空。
具体代码如下:
// 集合判空工具
List<Order> orders = getPendingOrders();
if (CollectionUtils.isEmpty(orders)) {
return Result.error("无待处理订单");
}
// 字符串检查
String input = request.getParam("token");
if (StringUtils.hasText(input)) {
validateToken(input);
}
5. Lombok保驾护航
我们在日常开发中的entity对象,一般会使用Lombok框架中的注解,来实现getter/setter方法。
其实,这个框架中也提供了@NonNull等判空的注解。
比如:
@Getter
@Setter
public class User {
@NonNull // 编译时生成null检查代码
private String name;
private Wallet wallet;
}
// 使用构造时自动判空
User user = new User(@NonNull "张三", wallet);
四、工程级解决方案
6. 空对象模式
public interface Notification {
void send(String message);
}
// 真实实现
public class EmailNotification implements Notification {
@Override
public void send(String message) {
// 发送邮件逻辑
}
}
// 空对象实现
public class NullNotification implements Notification {
@Override
public void send(String message) {
// 默认处理
}
}
// 使用示例
Notification notifier = getNotifier();
notifier.send("系统提醒"); // 无需判空
7. Guava的Optional增强
其实Guava工具包中,给我们提供了Optional增强的功能。
比如:
import com.google.common.base.Optional;
// 创建携带缺省值的Optional
Optional<User> userOpt = Optional.fromNullable(user).or(defaultUser);
// 链式操作配合Function
Optional<BigDecimal> amount = userOpt.transform(u -> u.getWallet())
.transform(w -> w.getBalance());
Guava工具包中的Optional类已经封装好了,我们可以直接使用。
五、防御式编程进阶
8. Assert断言式拦截
其实有些Assert断言类中,已经做好了判空的工作,参数为空则会抛出异常。
这样我们就可以直接调用这个断言类。
例如下面的ValidateUtils类中的requireNonNull方法,由于它内容已经判空了,因此,在其他地方调用requireNonNull方法时,如果为空,则会直接抛异常。
我们在业务代码中,直接调用requireNonNull即可,不用写额外的判空逻辑。
例如:
public class ValidateUtils {
public static <T> T requireNonNull(T obj, String message) {
if (obj == null) {
throw new ServiceException(message);
}
return obj;
}
}
// 使用姿势
User currentUser = ValidateUtils.requireNonNull(
userDao.findById(userId),
"用户不存在-ID:" + userId
);
最近就业形势比较困难,为了感谢各位小伙伴对苏三一直以来的支持,我特地创建了一些工作内推群, 看看能不能帮助到大家。
你可以在群里发布招聘信息,也可以内推工作,也可以在群里投递简历找工作,也可以在群里交流面试或者工作的话题。
添加苏三的私人微信:li_su223,备注:掘金+所在城市,即可加入。
9. 全局AOP拦截
我们在一些特殊的业务场景种,可以通过自定义注解 + 全局AOP拦截器的方式,来实现实体或者字段的判空。
例如:
@Aspect
@Component
public class NullCheckAspect {
@Around("@annotation(com.xxx.NullCheck)")
public Object checkNull(ProceedingJoinPoint joinPoint) throws Throwable {
Object[] args = joinPoint.getArgs();
for (Object arg : args) {
if (arg == null) {
throw new IllegalArgumentException("参数不可为空");
}
}
return joinPoint.proceed();
}
}
// 注解使用
public void updateUser(@NullCheck User user) {
// 方法实现
}
六、实战场景对比分析
场景1:深层次对象取值
// 旧代码(4层嵌套判断)
if (order != null) {
User user = order.getUser();
if (user != null) {
Address address = user.getAddress();
if (address != null) {
String city = address.getCity();
// 使用city
}
}
}
// 重构后(流畅链式)
String city = Optional.ofNullable(order)
.map(Order::getUser)
.map(User::getAddress)
.map(Address::getCity)
.orElse("未知城市");
场景2:批量数据处理
List<User> users = userService.listUsers();
// 传统写法(显式迭代判断)
List<String> names = new ArrayList<>();
for (User user : users) {
if (user != null && user.getName() != null) {
names.add(user.getName());
}
}
// Stream优化版
List<String> nameList = users.stream()
.filter(Objects::nonNull)
.map(User::getName)
.filter(Objects::nonNull)
.collect(Collectors.toList());
七、性能与安全的平衡艺术
上面介绍的这些方案都可以使用,但除了代码的可读性之外,我们还需要考虑一下性能因素。
下面列出了上面的几种在CPU消耗、内存只用和代码可读性的对比:
方案 | CPU消耗 | 内存占用 | 代码可读性 | 适用场景 |
---|---|---|---|---|
多层if嵌套 | 低 | 低 | ★☆☆☆☆ | 简单层级调用 |
Java Optional | 中 | 中 | ★★★★☆ | 中等复杂度业务流 |
空对象模式 | 高 | 高 | ★★★★★ | 高频调用的基础服务 |
AOP全局拦截 | 中 | 低 | ★★★☆☆ | 接口参数非空验证 |
黄金法则
- Web层入口强制参数校验
- Service层使用Optional链式处理
- 核心领域模型采用空对象模式
八、扩展技术
除了,上面介绍的常规判空之外,下面再给大家介绍两种扩展的技术。
Kotlin的空安全设计
虽然Java开发者无法直接使用,但可借鉴其设计哲学:
val city = order?.user?.address?.city ?: "default"
JDK 14新特性预览
// 模式匹配语法尝鲜
if (user instanceof User u && u.getName() != null) {
System.out.println(u.getName().toUpperCase());
}
总之,优雅判空不仅是代码之美,更是生产安全底线。
本文分享了代码判空的10种方案,希望能够帮助你编写出既优雅又健壮的Java代码。
最后说一句(求关注,别白嫖我)
如果这篇文章对您有所帮助,或者有所启发的话,帮忙关注一下我的同名公众号:苏三说技术,您的支持是我坚持写作最大的动力。
求一键三连:点赞、转发、在看。
关注公众号:【苏三说技术】,在公众号中回复:进大厂,可以免费获取我最近整理的50万字的面试宝典,好多小伙伴靠这个宝典拿到了多家大厂的offer。
来源:juejin.cn/post/7478221220074504233
什么语言最适合用来游戏开发?
什么语言最适合用来游戏开发?
游戏开发,是一项结合了图形渲染、性能优化、系统架构与玩家体验的综合艺术,而“选用什么编程语言”这个问题,往往是新手开发者迈入这片领域时面临的第一个技术岔路口。
一、从需求出发:游戏开发对语言的核心要求
在选择语言之前,我们先明确一点:游戏类型不同,对语言的要求也大不一样。开发 3D AAA 大作和做一个像素风的休闲小游戏,使用的语言和引擎可能完全不同。
一般来说,语言选择需要考虑:
维度 | 说明 |
---|---|
性能需求 | 是否要求极致性能(如大型 3D 游戏)? |
跨平台能力 | 是否要支持多个平台(Windows/Mac/Linux/iOS/Android/主机)? |
引擎生态 | 是否依赖成熟的游戏引擎(如 Unity、Unreal)? |
开发效率 | 团队大小如何?语言是否有丰富工具链、IDE 支持、调试便利性? |
学习曲线 | 是个人项目还是商业项目?是否有足够时间去掌握复杂语法或底层结构? |
二、主流语言实战解析
C++:3A最常用的语言
- 适合场景:大型 3D 游戏、主机平台、UE(Unreal Engine)项目
- 特点:
- 几乎所有主流游戏引擎底层都是用 C++ 编写的(UE4/5、CryEngine 等)
- 手动内存管理带来极致性能控制,但也带来更高的 bug 风险
- 编译时间长、语法复杂,不适合快速原型开发
如果你追求的是性能边界、需要对引擎源码进行改造,或者准备进入 3A 游戏开发领域,C++ 是必修课。
C#:Unity 的生态核心
- 适合场景:中小型游戏、独立游戏、跨平台移动/PC 游戏、Unity 项目
- 特点:
- Unity 的脚本语言就是 C#,生态丰富、社区活跃、教程资源丰富
- 开发效率高,语法现代,有良好的 IDE 支持(VS、Rider)
- 在性能上不如 C++,但对大多数项目而言“够用”
如果你是个人开发者或小团队,C# + Unity 几乎是性价比最高的方案之一。
JavaScript/TypeScript:Web 游戏与轻量跨平台
- 适合场景:H5 游戏、小程序游戏、跨平台 2D 游戏、快速迭代
- 特点:
- 配合 Phaser、PixiJS、Cocos Creator 等框架,可以高效制作 Web 游戏
- 原生支持浏览器平台,无需安装,天然适合传播
- 性能不及原生语言,但足以支撑休闲游戏
Web 平台的红利尚未过去,JS/TS + WebGL 仍然是轻量化游戏开发的稳定选择。
Python/Lua:脚本语言发力
- 适合场景:游戏逻辑脚本、AI 行为树、数据驱动配置、教学引擎
- 特点:
- 并不适合用来开发整款游戏,但常作为内嵌脚本语言
- Lua 广泛用于游戏脚本(如 WOW、GTA、Roblox),轻量、运行效率高
- Python 适合教学、原型设计、AI 模块等场景
他们更多是游戏开发的一环,而非“用来开发整款游戏”的首选语言。
三、主流引擎使用的主语言和适用语言
游戏引擎 | 主语言 | 适用语言 |
---|---|---|
Unreal Engine | C++ | C++ / Blueprint(可视化脚本) |
Unity | C# | C# |
Godot | GDScript | GDScript / C# / C++ / Python(部分支持) |
Cocos Creator | TypeScript/JS | TypeScript / JavaScript |
Phaser | JavaScript | JavaScript / TypeScript |
四、总结:如何选对“你的语言”?
语言没有好坏,只有适不适合你的项目定位与资源情况。
如果你是:
- 学习引擎开发/大作性能优化:优先掌握 C++,结合 Unreal 学习
- 做跨平台独立游戏/商业项目:优先 C# + Unity
- 做 Web 平台轻量游戏:TypeScript + Phaser/Cocos 是好选择
- 研究 AI、教学、逻辑脚本:Python/Lua 脚本语言
写游戏不是目的,做出好玩的游戏才是!
如果你打算正式进军游戏开发领域,不妨从一个引擎 + 一门主语言开始,结合一个小项目落地,再去拓展更多语言和引擎的协作模式。
来源:juejin.cn/post/7516784123693498378
被问到 NextTick 是宏任务还是微任务
NextTick
等待下一次 DOM 更新刷新的工具方法。
<https://cn.vuejs.org/api/general.html#nexttick>
从字面上看 就知道 肯定是个 异步的嘛。
然后面试官 那你来说说 js执行过程吧。 宏任务 微任务 来做做 宏任务 微任务输出的结果的题吧。
再然后 问问你 nextTick 既然几个异步的 那么他是 宏任务 还是个 微任务呀。
vue2 中
文件夹 src/core/util/next-tick.js 中
promise --> mutationObserver -> setImmediate -> setTimeout
支持 哪个走哪个
vue3 中
好吧 好吧 promise 了嘛
全程 promise
来源:juejin.cn/post/7418505553642291251
什么?localhost还能设置二级域名?
大家好,我是农村程序员,独立开发者,行业观察员,前端之虎陈随易。
我会在这里分享关于 独立开发
、编程技术
、思考感悟
等内容,欢迎关注。
- 个人网站 1️⃣:chensuiyi.me
- 个人网站 2️⃣:me.yicode.tech
- 技术群,搞钱群,闲聊群,自驾群,想入群的在我个人网站联系我。
如果你觉得本文有用,一键三连 (点赞
、评论
、转发
),就是对我最大的支持~
网上冲浪看到一个有趣且违背常识的帖子,用了那么多年的 localhost
,没想到 localhost
还能设置子域名。
而且还不需要修改 hosts 文件,直接就能使用,这真是离谱他妈给离谱开门,离谱到家了。
先说说应用场景:
- 多用户/多会话隔离:在本地开发中模拟不同用户的 cookies 和 session storage,适合测试用户认证或个性化功能。
- 跨域开发与测试:模拟真实多域环境 (如 API 和前端分离),用于调试 CORS、单点登录或微服务架构。
- 简化开发流程:无需修改 hosts 文件即可快速创建子域名,适合快速原型设计或临时项目。
- 工具与服务器集成:与本地开发工具 (如 localias) 结合,支持 HTTPS 和自定义端口,增强开发体验。
- 灵活调试:通过自定义子域名和 IP (如 127.0.0.42) 进行高级调试或模拟复杂网络配置。
总得来说就是,localhsot 支持子域名比我们自己手动配置不同的域名并设置 hosts 文件方便多了。
接下来给大家实测一下。
请看,这是我直接在浏览器输入 test1.localhost:3020
后,就能请求到我本地启动的监听 3020 端口的后端接口返回的数据。
我没有配置 hosts 文件,没有做过任何多余的配置工作,直接就生效了。
那么我们可以直接在本地就能调试多服务器集群,跨域 cookie 共享,SSO 单点登录,微服务架构等功能,非常方便。
另外,本公众号是 前端之虎陈随易
专门分享技术的公众号,目前关注量不多,希望大家点点小手指,来个大大的关注哦~
来源:juejin.cn/post/7521013717438758938
优雅!用了这两款插件,我成了整个公司代码写得最规范的码农
同事:你的代码写的不行啊,不够规范啊。
我:我写的代码怎么可能不规范,不要胡说。
于是同事打开我的 IDEA ,安装了一个插件,然后执行了一下,规范不规范,看报告吧。
这可怎么是好,这玩意竟然给我挑出来这么多问题,到底靠谱不。
同事潇洒的走掉了,只留下我在座位上盯着屏幕惊慌失措。我仔细的查看了这个报告的每一项,越看越觉得这插件指出的问题有道理,果然是我大意了,竟然还给我挑出一个 bug 来。
这是什么插件,review 代码无敌了。
这个插件就是 SonarLint,官网的 Slogan 是 clean code begins in your IDE with {SonarLint}。
作为一个程序员,我们当然希望自己写的代码无懈可击了,但是由于种种原因,有一些问题甚至bug都无法避免,尤其是刚接触开发不久的同学,也有很多有着多年开发经验的程序员同样会有一些不好的代码习惯。
代码质量和代码规范首先肯定是靠程序员自身的水平和素养决定的,但是提高水平的是需要方法的,方法就有很多了,比如参考大厂的规范和代码、比如有大佬带着,剩下的就靠平时的一点点积累了,而一些好用的插件能够时时刻刻提醒我们什么是好的代码规范,什么是好的代码。
SonarLint 就是这样一款好用的插件,它可以实时帮我们 review代码,甚至可以发现代码中潜在的问题并提供解决方案。
SonarLint 使用静态代码分析技术来检测代码中的常见错误和漏洞。例如,它可以检测空指针引用、类型转换错误、重复代码和逻辑错误等。这些都是常见的问题,但是有时候很难发现。使用 SonarLint 插件,可以在编写代码的同时发现这些问题,并及时纠正它们,这有助于避免这些问题影响应用程序的稳定性。
比如下面这段代码没有结束循环的条件设置,SonarLint 就给出提示了,有强迫症的能受的了这红下划线在这儿?
SonarLint 插件可以帮助我提高代码的可读性。代码应该易于阅读和理解,这有助于其他开发人员更轻松地维护和修改代码。SonarLint 插件可以检测代码中的代码坏味道,例如不必要的注释、过长的函数和变量名不具有描述性等等。通过使用 SonarLint 插件,可以更好地了解如何编写清晰、简洁和易于理解的代码。
例如下面这个名称为 hello_world的静态 final变量,SonarLint 给出了两项建议。
- 因为变量没有被使用过,建议移除;
- 静态不可变变量名称不符合规范;
SonarLint 插件可以帮助我遵循最佳实践和标准。编写符合标准和最佳实践的代码可以确保应用程序的质量和可靠性。SonarLint 插件可以检测代码中的违反规则的地方,例如不安全的类型转换、未使用的变量和方法、不正确的异常处理等等。通过使用 SonarLint 插件,可以学习如何编写符合最佳实践和标准的代码,并使代码更加健壮和可靠。
例如下面的异常抛出方式,直接抛出了 Exception,然后 SonarLint 建议不要使用 Exception,而是自定义一个异常,自定义的异常可能让人直观的看出这个异常是干什么的,而不是 Exception基本类型导出传递。
安装 SonarLint
可以直接打开 IDEA 设置 -> Plugins,在 MarketPlace中搜索SonarLint,直接安装就可以。
还可以直接在官网下载,打开页面http://www.sonarsource.com/products/so… EXPLORE即可到下载页面去下载了。虽然我们只是在 IDEA 中使用,但是它不只支持 Java 、不只支持 IDEA ,还支持 Python、PHP等众多语言,以及 Visual Studio 、VS Code 等众多 IDE。
在 IDEA 中使用
SonarLint 插件安装好之后,默认就开启了实时分析的功能,就跟智能提示的功能一样,随着你噼里啪啦的敲键盘,SonarLint插件就默默的进行分析,一旦发现问题就会以红框、红波浪线、黄波浪线的方式提示。
当然你也可以在某一文件中点击右键,也可在项目根目录点击右键,在弹出菜单中点击Analyze with SonarLint,对当前文件或整个项目进行分析。
分析结束后,会生成分析报告。
左侧是对各个文件的分析结果,右侧是对这个问题的建议和修改示例。
SonarLint 对问题分成了三种类型
类型说明Bug代码中的 bug,影响程序运行Vulnerability漏洞,可能被作为攻击入口Code smell代码意味,可能影响代码可维护性
问题按照严重程度分为5类
严重性说明BLOCKER已经影响程序正常运行了,不改不行CRITICAL可能会影响程序运行,可能威胁程序安全,一般也是不改不行MAJOR代码质量问题,但是比较严重MINOR同样是代码质量问题,但是严重程度较低INFO一些友好的建议
SonarQube
SonarLint 是在 IDE 层面进行分析的插件,另外还可以使用 SonarQube功能,它以一个 web 的形式展现,可以为整个开发团队的项目提供一个web可视化的效果。并且可以和 CI\CD 等部署工具集成,在发版前提供代码分析。
SonarQube是一个 Java 项目,你可以在官网下载项目本地启动,也可以以 docker 的方式启动。之后可以在 IDEA 中配置全局 SonarQube配置。
也可以在 SonarQube web 中单独配置一个项目,创建好项目后,直接将 mvn 命令在待分析的项目中执行,即可生成对应项目的分析报告,然后在 SonarQube web 中查看。
5
对于绝大多数开发者和开发团队来说,SonarQube 其实是没有必要的,只要我们每个人都解决了 IDE 中 SonarLint 给出的建议,当然最终的代码质量就是符合标准的。
阿里 Java 规约插件
每一个开发团队都有团队内部的代码规范,比如变量命名、注释格式、以及各种类库的使用方式等等。阿里一直在更新 Java 版的阿里巴巴开发者手册,有什么泰山版、终极版,想必各位都听过吧,里面的规约如果开发者都能遵守,那别人恐怕再没办法 diss 你的代码不规范了。
对应这个开发手册的语言层面的规范,阿里也出了一款 IDEA 插件,叫做 Alibaba Java Coding Guidelines,可以在插件商店直接下载。
比如前面说的那个 hello_world变量名,插件直接提示「修正为以下划线分隔的大写模式」。
再比如一些注释上的提示,不建议使用行尾注释。
image-20230314165107639
还有,比如对线程池的使用,有根据规范建议的内容,建议自己定义核心线程数和最大线程数等参数,不建议使用 Excutors工具类。
有了这俩插件,看谁还能说我代码写的不规范了。
来源:juejin.cn/post/7260314364876931131
本尊来!网易灰度发布系统揭秘:一天300次上线是怎么实现的?
你可能听过“网易每天上线几百次”,
但你是否知道:99%的发布都不是全量,而是按灰度批次推进。
今天从代码 + 场景双视角,拆解网易灰度发布的完整实现逻辑,让你真正搞懂:
- 发布是怎么分用户、分地域、分时间段的
- 如何回滚不影响线上用户
- 甚至如何模拟真实用户流量进行 A/B 实验
一、网易灰度系统整体架构图(简化)
二、核心策略算法:如何选择灰度用户?
网易内部灰度用户分流引擎大致是这样:
interface User {
uid: string
region: string // 地域
isVip: boolean
loginTime: number // 最近登录时间
}
// 灰度策略配置
const strategy = {
percent: 10, // 灰度比例
regionInclude: ['华南'], // 地域包含
vipOnly: true // 只投放给 VIP
}
// 筛选函数
function filterUsers(users: User[], strategy) {
const filtered = users.filter(u =>
(!strategy.regionInclude || strategy.regionInclude.includes(u.region)) &&
(!strategy.vipOnly || u.isVip)
)
const count = Math.floor((strategy.percent / 100) * filtered.length)
return filtered.slice(0, count)
}
三、实际运行结果展示(模拟环境)
const users: User[] = Array.from({ length: 1000 }, (_, i) => ({
uid: `U${i}`,
region: ['华南', '华北', '华东'][i % 3],
isVip: i % 2 === 0,
loginTime: Date.now() - i * 10000,
}))
const selected = filterUsers(users, strategy)
console.log('灰度命中用户数:', selected.length)
console.log('前5个用户:', selected.slice(0, 5))
✅ 输出示例:
灰度命中用户数: 166
前5个用户: [
{ uid: 'U0', region: '华南', isVip: true, loginTime: 1717288879181 },
{ uid: 'U6', region: '华南', isVip: true, loginTime: 1717288819181 },
{ uid: 'U12', region: '华南', isVip: true, loginTime: 1717288759181 },
...
]
四、网易如何触发灰度?手动?自动?答案是:多触发源 + 策略组合
- ✅ 手动控制(管理员控制台)
- ✅ CI/CD 自动触发(合并主干自动上线)
- ✅ 实验平台触发(A/B 实验验证新功能)
示例:CI/CD 触发部署的逻辑(伪代码):
// Jenkinsfile 中执行灰度命令
steps {
script {
sh 'node deploy.js --env=prod --gray=10%'
}
}
五、监控数据如何决定“是否继续灰度”?
网易内部有自动指标监控,如:
指标名 | 作用 | 阈值 |
---|---|---|
error_rate | 错误率异常自动中止 | >0.05 |
api_delay | 接口响应时间 | >300ms |
login_success_ratio | 登录成功率 | <0.95 |
代码示例(灰度中控系统伪代码):
if (metrics.error_rate > 0.05 || metrics.login_success_ratio < 0.95) {
graySystem.stopDeployment()
graySystem.rollback()
console.log('灰度异常,中止并回滚')
} else {
graySystem.continue()
}
六、网易的灰度回滚机制非常丝滑,为什么?
他们采用了 “金丝雀版本+热切流量+自动恢复” 策略:
graySystem.deploy(version: '1.2.3', tag: 'canary')
// graySystem.rollback() 会回到上一个 tag=stable 的版本
而且每次发布都会打上 Git tag,并记录环境信息,回滚只需1行命令:
gray rollback --env prod --tag stable
七、你能学到什么?(总结)
- 灰度不等于“发布慢一点”,而是可控可观测的发布策略
- 用户维度灰度筛选逻辑要尽量结构化,避免硬编码
- 数据指标必须“事前定义”,不能出了问题再想怎么止损
- 所有灰度发布必须可回滚
彩蛋:
“上线不是勇气的象征,而是风控能力的体现。”
来源:juejin.cn/post/7511150244576837684
解锁企业高效未来|上海飞络Synergy AI开启智能体协作新时代
他/她可以有自己的电脑,可以有自己的邮箱号,可以有自己的企业微信号。只要赋予权限,他/她可以替你完成各种日常工作,他/她可以随时随地和你沟通并完成你安排的任务,他/她永远高效!他/她永不抱怨!
Synergy AI数字员工雇佣管理平台,以大语言模型驱动的AI Agent为核心,结合MCP工具集,并在数据安全、信息安全及行为安全的多维度监控下,为企业提供安全、合规、高效的“智能体员工”,重塑人机协作新范式!
为什么选择Synergy AI数字员工管理平台?
1、智能生产力升级
AI Agent数字员工深度融合语言理解、逻辑推理与工具调用能力,是能够自主感知环境、决策并执行任务的人工智能系统。它可以拥有自己的电脑、邮箱,微信号等所有员工的权限,同时也具备MCP工具集中的各种技能,能够像真人一样沟通,处理工作,但是能够实现更高的工作效率和更加低廉的成本!
2、根据职位定制AI员工工作流
通过“AIGC+Workflow”组合,实现任务自动化执行,响应速度大幅提升,成为企业降本增效的核心引擎。
同时基于企业人员、技能、文档、流程等六大核心信息库,AI数字员工可快速融入业务场景,提供从单职能支持、人机协同到多职能协作的全链路服务。
3、安全合规,全程可控
1)行为监测
实时检测AI数字员工是否存在权限越界、敏感数据操作,信息泄露,被黑客利用等安全合规隐患。
2)数据安全管控
智能识别、过滤、脱敏替换AI数字员工及大语言模型使用过程中触发的敏感数据,企业核心数据泄漏等风险。
3)效能可视化
通过工作流执行情况、人工干预度等指标,持续优化AI员工表现。
Synergy AI能实现什么效果?
1、AI销售助理
可协助销售管理日程、预约会议、统计CRM数字,甚至代替销售联络沟通回款问题。入职飞络销售部门后,内部数据显示客户响应效率提升3倍以上,人力成本降低60%,助力团队精准触达商机。
2、SOC安全及运维专员
在安全运营和运维场景中,AI员工可以迅速响应各个安全系统平台的告警,并根据制定的工作流程,进行下一步的沟通、交流、处置。让企业安全事件响应速度大幅提升,精准提高准确率,为企业筑牢数字防线。
3、更多AI人职位有待解锁
根据每家企业不同的场景需求,Synergy AI提供可以定制化的各种企业AI数字员工,让AI智能体真正能够匹配企业需求,为企业带来实际帮助。
Synergy AI如何落地实施?
1、分析岗位SOW/SOP
找到重复、需要与人互动的工作流,快速实现智能化并通过拟人化的AI员工来完成,逐步将AI工作流覆盖全业务。
2、无缝对接系统
支持OA、ERP、CRM、M365等主流平台MCP / API对接。
3、7×24小时护航
飞络安全运营中心全程监控,保障业务稳定运行。
企业的信息安全如何保护?
飞络基于自研发两大安全管理平台,为企业在使用AI的同时,极大限度保障企业的数据以及隐私安全:
企业AI安全事件监控管理平台
通过企业AI安全事件监控管理平台,我们可以实时提供AI系统以及AI Agents的运行状态,对于所发生的安全事件,实行7*24小时的安全监控及管理。
ASSA:企业AI数据过滤平台
通过ASSA,企业可以管理及管控企业内部信息传输到大语言模型上的数据,对于敏感信息、企业机密、个人信息等进行阻止、脱敏、模糊化等管理操作
7*24 SOC服务
基于飞络提供的7*24级别的SOC运营服务,可以协助客户一起实时监控及管理所有AI相关的安全事件,为企业的数据安全保驾护航!
Synergy AI数字员工雇佣管理平台,以自主研发技术为核心,为企业提供一站式智能解决方案。
收起阅读 »给前端小白的科普,为什么说光有 HTTPS 还不够?为啥还要请求签名?
今天咱们聊个啥呢?先设想一个场景:你辛辛苦苦开发了一个前端应用,后端 API 也写得杠杠的。用户通过你的前端界面提交一个订单,比如说买1件商品。请求发出去,一切正常。但如果这时候,有个“不开眼”的黑客老哥,在你的请求发出后、到达服务器前,悄咪咪地把“1件”改成了“100件”,或者把你用户的优惠券给薅走了,那服务器收到的就是个被篡改过的“假”请求。更狠一点,如果他拿到了你某个用户的合法请求,然后疯狂重放这个请求,那服务器不就炸了?
是不是想想都后怕?别慌,今天咱就来聊聊怎么给咱们的API请求加一把“锁”,让这种“中间人攻击”和“重放攻击”无处遁形。这把锁,就是大名鼎鼎的 HMAC-SHA256 请求签名。学会了它,你就能给你的应用穿上“防弹衣”!
一、光有 HTTPS 还不够?为啥还要请求签名?
可能有机灵的小伙伴会问:“老张,咱不都有 HTTPS 了吗?数据都加密了,还怕啥?”
问得好!HTTPS 确实牛,它能保证你的数据在传输过程中不被窃听和篡改,就像给数据修了条“加密隧道”。但它主要解决的是传输层的安全。可如果:
- 请求在加密前就被改了:比如黑客通过某种手段(XSS、恶意浏览器插件等)在你的前端代码执行时就修改了要发送的数据,那 HTTPS 加密的也是被篡改后的数据。
- 请求被合法地解密后,服务器无法验证“我是不是我”:HTTPS 保证了数据从A点到B点没被偷看,但如果有人拿到了一个合法的、加密的请求包,他可以原封不动地发给服务器100遍(重放攻击),服务器每次都会认为是合法的。
- API Key/Secret 直接在前端暴露: 有些简单的 API 认证,可能会把 API Key 直接写在前端,这简直就是“裸奔”,分分钟被扒下来盗用。
请求签名,则是在应用层做的一道防线。它能确保:
- 消息的完整性:数据没被篡改过。
- 消息的身份验证:确认消息确实是你授权的客户端发来的。
- 防止重放攻击:结合时间戳或 Nonce,让每个请求都具有唯一性。
它和 HTTPS 是好搭档,一个负责“隧道安全”,一个负责“货物安检”,双保险!
二、主角登场:HMAC-SHA256 是个啥?
HMAC-SHA256,听起来挺唬人,拆开看其实很简单:
- HMAC:Hash-based Message Authentication Code,翻译过来就是“基于哈希的消息认证码”。它是一种使用密钥(secret key)来生成消息摘要(MAC)的方法。
- SHA256:Secure Hash Algorithm 256-bit,一种安全的哈希算法,能把任意长度的数据转换成一个固定长度(256位,通常表示为64个十六进制字符)的唯一字符串。相同的输入永远得到相同的输出,输入有任何微小变化,输出都会面目全非。
所以,HMAC-SHA256 就是用一个共享密钥 (Secret Key),通过 SHA256 算法,给你的请求数据生成一个独一无二的“签名”。
三、签名的艺术:请求是怎么被“签”上和“验”货的?
整个流程其实不复杂,咱们用个图来说明一下:
sequenceDiagram
participant C as 前端 (Client)
participant S as 后端 (Server)
C->>C: 1. 准备请求参数 (如 method, path, query, body)
C->>C: 2. 加入时间戳 (timestamp) 和/或 随机数 (nonce)
C->>C: 3. 将参数按约定规则排序、拼接成一个字符串 (stringToSign)
C->>C: 4. 使用共享密钥 (Secret Key) 对 stringToSign 进行 HMAC-SHA256 运算,生成签名 (signature)
C->>S: 5. 将原始请求参数 + timestamp + nonce + signature 一起发送给后端
S->>S: 6. 接收到所有数据
S->>S: 7. 校验 timestamp/nonce (检查是否过期或已使用,防重放)
S->>S: 8. 从接收到的数据中,按与客户端相同的规则,提取参数、排序、拼接成 stringToSign'
S->>S: 9. 使用自己保存的、与客户端相同的 Secret Key,对 stringToSign' 进行 HMAC-SHA256 运算,生成 signature'
S->>S: 10. 比对客户端传来的 signature 和自己生成的 signature'
alt 签名一致
S->>S: 11. 验证通过,处理业务逻辑
S-->>C: 响应结果
else 签名不一致
S->>S: 11. 验证失败,拒绝请求
S-->>C: 错误信息 (如 401 Unauthorized)
end
简单来说,就是:
- 客户端:把要发送的数据(比如请求方法、URL路径、查询参数、请求体、时间戳等)按照事先约定好的顺序和格式拼成一个长长的字符串。然后用一个只有你和服务器知道的“秘密钥匙”(Secret Key)和 HMAC-SHA256 算法,给这个字符串算出一个“指纹”(签名)。最后,把原始数据、时间戳、签名一起发给服务器。
- 服务器端:收到请求后,用完全相同的规则和完全相同的“秘密钥匙”,对收到的原始数据(不包括客户端传来的签名)也算一遍“指纹”。然后比较自己算出来的指纹和客户端传过来的指纹。如果一样,说明数据没被改过,而且确实是知道秘密钥匙的“自己人”发的;如果不一样,那对不起,这请求有问题,拒收!
四、Talk is Cheap, Show Me The Code!
光说不练假把式,咱们来点实在的。
前端签名 (JavaScript - 通常使用 crypto-js 库)
// 假设你已经安装了 crypto-js: npm install crypto-js
import CryptoJS from 'crypto-js';
function generateSignature(params, secretKey) {
// 1. 准备待签名数据
const method = 'GET'; // 请求方法
const path = '/api/user/profile'; // 请求路径
const timestamp = Math.floor(Date.now() / 1000).toString(); // 时间戳 (秒)
const nonce = CryptoJS.lib.WordArray.random(16).toString(); // 随机数,可选
// 2. 构造待签名字符串 (规则很重要,前后端要一致!)
// 通常会对参数名按字典序排序
const sortedKeys = Object.keys(params).sort();
const queryString = sortedKeys.map(key => `${key}=${params[key]}`).join('&');
const stringToSign = `${method}\n${path}\n${queryString}\n${timestamp}\n${nonce}`;
console.log("String to Sign:", stringToSign); // 调试用
// 3. 使用 HMAC-SHA256 生成签名
const signature = CryptoJS.HmacSHA256(stringToSign, secretKey).toString(CryptoJS.enc.Hex);
console.log("Generated Signature:", signature); // 调试用
return {
signature,
timestamp,
nonce
};
}
// --- 使用示例 ---
const mySecretKey = "your-super-secret-key-dont-put-in-frontend-directly!"; // 强调:密钥不能硬编码在前端!
const requestParams = {
userId: '123',
role: 'user'
};
const { signature, timestamp, nonce } = generateSignature(requestParams, mySecretKey);
// 实际发送请求时,把 signature, timestamp, nonce 放在请求头或请求体里
// 例如:
// fetch(`${path}?${queryString}`, {
// method: method,
// headers: {
// 'X-Signature': signature,
// 'X-Timestamp': timestamp,
// 'X-Nonce': nonce,
// 'Content-Type': 'application/json'
// },
// // body: JSON.stringify(requestBody) // 如果是POST/PUT等
// })
// .then(...)
划重点! 上面代码里的 mySecretKey
绝对不能像这样直接写在前端代码里!这只是个演示。真正的 Secret Key 需要通过安全的方式分发和存储,比如在构建时注入,或者通过更安全的认证流程动态获取(但这又引入了新的复杂性,通常 Secret Key 是后端持有,客户端动态获取一个有时效性的 token)。对于纯前端应用,更常见的做法是后端生成签名所需参数,或者整个流程由 BFF (Backend For Frontend) 层处理。如果你的应用是 App,可以把 Secret Key 存储在原生代码中,相对安全一些。
后端验签 (Node.js - 使用内置 crypto 模块)
const crypto = require('crypto');
function verifySignature(requestData, clientSignature, clientTimestamp, clientNonce, secretKey) {
// 0. 校验时间戳 (例如,请求必须在5分钟内到达)
const serverTimestamp = Math.floor(Date.now() / 1000);
if (Math.abs(serverTimestamp - parseInt(clientTimestamp, 10)) > 300) { // 5分钟窗口
console.error("Timestamp validation failed");
return false;
}
// (可选) 校验 Nonce 防止重放,需要存储已用过的 Nonce,可以用 Redis 等
// if (isNonceUsed(clientNonce)) {
// console.error("Nonce replay detected");
// return false;
// }
// markNonceAsUsed(clientNonce, clientTimestamp); // 标记为已用,并设置过期时间
// 1. 从请求中提取参与签名的参数
const { method, path, queryParams } = requestData; // 假设已解析好
// 2. 构造待签名字符串 (规则必须和客户端完全一致!)
const sortedKeys = Object.keys(queryParams).sort();
const queryString = sortedKeys.map(key => `${key}=${queryParams[key]}`).join('&');
const stringToSign = `${method}\n${path}\n${queryString}\n${clientTimestamp}\n${clientNonce}`;
console.log("Server String to Sign:", stringToSign);
// 3. 使用 HMAC-SHA256 生成签名
const expectedSignature = crypto.createHmac('sha256', secretKey)
.update(stringToSign)
.digest('hex');
console.log("Server Expected Signature:", expectedSignature);
console.log("Client Signature:", clientSignature);
// 4. 比对签名 (使用 crypto.timingSafeEqual 防止时序攻击)
if (clientSignature.length !== expectedSignature.length) {
return false;
}
return crypto.timingSafeEqual(Buffer.from(clientSignature), Buffer.from(expectedSignature));
}
// --- Express 示例中间件 ---
// app.use((req, res, next) => {
// const clientSignature = req.headers['x-signature'];
// const clientTimestamp = req.headers['x-timestamp'];
// const clientNonce = req.headers['x-nonce'];
// // 实际项目中,secretKey 应该从环境变量或配置中读取
// const API_SECRET_KEY = process.env.API_SECRET_KEY || "your-super-secret-key-dont-put-in-frontend-directly!";
// // 构造 requestData 对象,包含 method, path, queryParams
// // 注意:如果是 POST/PUT 请求,请求体 (body) 通常也需要参与签名
// // 且 body 如果是 JSON,建议序列化后参与签名,而不是原始对象
// const requestDataForSig = {
// method: req.method.toUpperCase(),
// path: req.path,
// queryParams: req.query, // 对于GET;POST/PUT可能还需包含body
// // bodyString: req.body ? JSON.stringify(req.body) : "" // 如果body参与签名
// };
// if (!verifySignature(requestDataForSig, clientSignature, clientTimestamp, clientNonce, API_SECRET_KEY)) {
// return res.status(401).send('Invalid Signature');
// }
// next();
// });
五、细节是魔鬼:实施过程中的注意事项
- 密钥管理 (Secret Key):
- 绝对保密:这是最重要的!密钥泄露,签名机制就废了。
- 不要硬编码在前端:再次强调!对于B端或内部系统,可以考虑通过安全的构建流程注入。对于C端开放应用,通常结合用户登录后的 session token 或 OAuth token 来做,或者使用更复杂的 API Gateway 方案。
- 定期轮换:为了安全,密钥最好能定期更换。
- 时间戳 (Timestamp):
- 防止重放攻击:服务器会校验收到的时间戳与当前服务器时间的差值,如果超过一定阈值(比如5分钟),就认为是无效请求。
- 时钟同步:客户端和服务器的时钟要尽量同步,不然很容易误判。
- 随机数 (Nonce):
- 更强的防重放:Nonce 是一个只使用一次的随机字符串。服务器需要记录用过的 Nonce,在一定时间内(同时间戳窗口)不允许重复。可以用 Redis 等缓存服务来存。
- 哪些内容需要签名?
- HTTP 方法 (GET, POST, etc.)
- 请求路径 (Path, e.g.,
/users/123
) - 查询参数 (Query Parameters, e.g.,
?name=zhangsan&age=18
):参数名需要按字典序排序,确保客户端和服务端拼接顺序一致。 - 请求体 (Request Body):如果是
application/x-www-form-urlencoded
或multipart/form-data
,处理方式同 Query Parameters。如果是application/json
,通常是将整个 JSON 字符串作为签名内容的一部分。注意空 body 和有 body 的情况。 - 关键的请求头:比如
Content-Type
,以及自定义的一些重要 Header。 - 时间戳和 Nonce:它们本身也要参与签名,防止被篡改。
- 一致性是王道:客户端和服务端在选择哪些参数参与签名、参数的排序规则、拼接格式等方面,必须严格一致,一个空格,一个换行符不同,签名结果就天差地别。
六、HMAC-SHA256 vs. 其他方案?
方案 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
仅 HTTPS | 传输层加密,防止窃听 | 无法防止应用层篡改(加密前)、无法验证发送者身份(应用层)、无法防重放 | 基础数据传输安全 |
简单摘要 (如MD5) | 实现简单 | 若无密钥,容易被伪造;MD5本身已不安全 | 文件完整性校验(非安全敏感) |
HMAC-SHA256 | 消息完整性、身份验证(基于共享密钥)、可防重放(结合时间戳/Nonce) | 密钥管理是关键和难点;签名和验签有一定计算开销 | 需要保障API接口安全、防止未授权访问和篡改的场景 |
JWT (JSON Web Token) | 无状态、可携带用户信息、标准化 | Token 可能较大;吊销略麻烦;主要用于用户认证和授权 | 用户登录、单点登录、API授权 |
HMAC-SHA256 更侧重于请求本身的完整性和来源认证,而 JWT 更侧重于用户身份的认证和授权。它们可以结合使用。
好啦,今天关于 HMAC-SHA256 请求签名的唠嗑就到这里。这玩意儿看起来步骤多,但一旦理解了原理,实现起来其实就是细心活儿。给你的 API 加上这把锁,晚上睡觉都能踏实点!
我是老码小张,一个喜欢研究技术原理,并且在实践中不断成长的技术人。希望今天的分享对你有帮助,咱们下回再聊!欢迎大家留言交流你的看法和经验哦!
来源:juejin.cn/post/7502641888970670080
聊聊四种实时通信技术:长轮询、短轮询、WebSocket 和 SSE
这篇文章,我们聊聊 四种实时通信技术:短轮询、长轮询、WebSocket 和 SSE 。
1 短轮询
浏览器 定时(如每秒)向服务器发送 HTTP 请求,服务器立即返回当前数据(无论是否有更新)。
- 优点:实现简单,兼容性极佳
- 缺点:高频请求浪费资源,实时性差(依赖轮询间隔)
- 延迟:高(取决于轮询频率)
- 适用场景:兼容性要求高,延迟不敏感的简单场景。
笔者职业生涯印象最深刻的短轮询应用场景是比分直播:
如图所示,用户进入比分直播界面,浏览器定时查询赛事信息(比分变动、黄红牌等),假如数据有变化,则重新渲染页面。
这种方式实现起来非常简单可靠,但是频繁的调用后端接口,会对后端性能会有影响(主要是 CPU)。同时,因为依赖轮询间隔,页面数据变化有延迟,用户体验并不算太好。
2 长轮询
浏览器发送 HTTP 请求后,服务器 挂起连接 直到数据更新或超时,返回响应后浏览器立即发起新请求。
- 优点:减少无效请求,比短轮询实时性更好
- 缺点:服务器需维护挂起连接,高并发时资源消耗大
- 延迟:中(取决于数据更新频率)
- 适用场景:需要较好实时性且无法用 WebSocket/SSE 的场景(如消息通知)
长轮询最常见的应用场景是:配置中心,我们耳熟能详的注册中心 Nacos 、阿波罗都是依赖长轮询机制。
客户端发起请求后,Nacos 服务端不会立即返回请求结果,而是将请求挂起等待一段时间,如果此段时间内服务端数据变更,立即响应客户端请求,若是一直无变化则等到指定的超时时间后响应请求,客户端重新发起长链接。
3 WebSocket
基于 TCP 的全双工协议,通过 HTTP 升级握手(Upgrade: websocket
)建立持久连接,双向实时通信。
- 优点:最低延迟,支持双向交互,节省带宽
- 缺点:实现复杂,需单独处理连接状态
- 延迟:极低
- 适用场景:聊天室、在线游戏、协同编辑等 高实时双向交互 需求
笔者曾经服务于北京一家电商公司,参与直播答题功能的研发。
直播答题整体架构见下图:
Netty TCP 网关的技术选型是:Netty、ProtoBuf、WebSocket ,选择 WebSocket 是因为它支持双向实时通信,同时 Netty 内置了 WebSocket 实现类,工程实现起来相对简单。
4 Server Send Event(SSE)
基于 HTTP 协议,服务器可 主动推送 数据流(如Content-Type: text/event-stream
),浏览器通过EventSource
API 监听。
- 优点:原生支持断线重连,轻量级(HTTP协议)
- 缺点:不支持浏览器向服务器发送数据
- 延迟:低(服务器可即时推送)
- 适用场景:股票行情、实时日志等 服务器单向推送 需求。
SSE 最经典的应用场景是 : DeepSeek web 聊天界面 ,如图所示:
当在 DeepSeek 对话框发送消息后,浏览器会发送一个 HTTP 请求 ,服务端会通过 SSE 方式将数据返回到浏览器。
5 总结
特性 | 短轮询 | 长轮询 | SSE | WebSocket |
---|---|---|---|---|
通信方向 | 浏览器→服务器 | 浏览器→服务器 | 服务器→浏览器 | 双向通信 |
协议 | HTTP | HTTP | HTTP | WebSocket(基于TCP) |
实时性 | 低 | 中 | 高 | 极高 |
资源消耗 | 高(频繁请求) | 中(挂起连接) | 低 | 低(长连接) |
选择建议:
- 需要 简单兼容性 → 短轮询
- 需要 中等实时性 → 长轮询
- 只需 服务器推送 → SSE
- 需要 全双工实时交互 → WebSocket
来源:juejin.cn/post/7496375493329174591
BOE(京东方)第6代新型半导体显示器件生产线全面量产 打造全球显示产业新引擎
2025年5月26日,BOE(京东方)成功举办主题为“屏启未来 智显无界”的量产交付活动,开启第6代新型半导体显示器件生产线由建设转向运营的崭新篇章。这不仅标志着BOE(京东方)在LTPO、LTPS、Mini LED等高端显示领域实现跨越式突破,也为我国半导体显示产业注入强劲动能,加速助力北京打造国际科技创新中心。作为全球技术最先进、产能最大的VR用LCD生产基地,该生产线将充分发挥技术引领和产业集聚优势,进一步巩固BOE(京东方)行业龙头地位,加速全球虚拟现实产业和数字经济发展。BOE(京东方)科技集团董事长陈炎顺,BOE(京东方)首席执行官冯强,BOE(京东方)首席运营官王锡平,行业专家及生态伙伴出席现场仪式,共同见证这一荣耀时刻。
活动现场,BOE(京东方)科技集团董事长陈炎顺发表致辞,他表示,BOE(京东方)以“BOE速度”打造新型显示产业基地建设标杆,成功实现开工当年封顶、次年产品点亮的关键目标。与此同时,技术研发与产品准备也在同步推进,多款产品已完成客户送样并推进交付。BOE(京东方)特别感谢战略合作伙伴们对技术创新的追求和坚持,这也推动着BOE(京东方)不断超越自我,取得一个又一个新的突破。BOE(京东方)将始终以战略客户伙伴的前沿需求和技术标准为指引,在“屏之物联”战略指导下,用踏实奋斗和持续创新回馈各界支持。
作为全球技术最先进的液晶显示屏生产基地,BOE(京东方)第6代新型半导体显示器件生产线总投资290亿元,占地面积42万平方米,设计月产能达5万片。该生产线以LTPO(低温多晶氧化物)和LTPS(低温多晶硅)技术为核心,聚焦聚焦 VR 显示面板、中小尺寸高附加值 IT 显示面板、车载显示面板等高端产品研发与生产,采用1500mm×1850mm的6代线玻璃基板,配备当前最先进的生产设备,并整合京东方多条成熟产线的先进经验,大幅提升生产效率和产品精度。在技术创新方面,BOE(京东方)LTPO技术融合了LTPS的高迁移率和Oxide的低功耗优势,可实现1500PPI以上的超高像素密度,并大幅度降低面板功耗,为显示设备提供更流畅、更清晰的动态画面。
值得一提的是,BOE(京东方)第6代新型半导体显示器件生产线还充分赋能多元化的场景应用,多款产品凭借极具竞争力的产品性能和领先的技术优势,获得全球一线知名客户的高度认可。其中,BOE(京东方)自主设计开发的超高2117PPI Real RGB显示屏实现成功点亮,达到当前LCD行业最高分辨率。在此次交付活动上,BOE(京东方)展示了已具备量产条件的2.24英寸1500PPI以及2.24英寸1700PPI VR显示模组,16英寸240Hz电竞笔记本屏幕(分辨率2560×1600,100% DCI-P3色域),以及14.6英寸窄边框高端车载中控屏等产品,全面满足“元宇宙”、高端消费电子、智能出行等领域的需求。
更加值得关注的是,BOE(京东方)第6代新型半导体显示器件生产线还在可持续发展方面走在世界前列。通过洁净室气流集控、AI分区温湿度自调、用电集控等创新技术,BOE(京东方)实现供热回收使用率100%、实现纯水回用率达80%、污染物排放均值小于标准50%。此外,在“双碳”目标引领下,BOE(京东方)将绿色理念贯穿于研发、生产与回收全生命周期。例如,生产线生产的产品在提升画质的同时更加注重产品低功耗性能,为设备的长时间使用提供可持续支持。这些实践不仅呼应了全球绿色低碳转型趋势,更展现了BOE(京东方)作为行业领军者的责任担当。同时,依托AI赋能,BOE(京东方)第6代新型半导体显示器件生产线还实现了智能排产、预测性维护、智能缺陷管理等全流程优化,设备综合效率(OEE)提升0.5%,工艺稳定性提升20%,良率分析效率提升20%,为行业树立了绿色生产与智能制造的双重标杆,也有力地回应了BOE(京东方)“Open Next Earth”的可持续发展品牌内涵。
在虚实交融的数字文明浪潮中,屏幕已从信息媒介跃升为跨越现实与虚拟、链接当下与未来的纽带。BOE(京东方)将持续以“屏之物联”战略为核心,加速显示技术与物联网、人工智能等前沿技术的深度融合,深刻践行“科技创新+绿色发展”之道。面向未来,BOE(京东方)将与更多合作伙伴携手,以协同创新之力探寻合作路径,全力赋能万物互联的未来智能生态体系,共同迎接一个更智慧、更互联、更美好、更绿色的全新时代。
生产环境到底能用Docker部署MySQL吗?
程序员小李:“老王,我有个问题想请教您。MySQL 能不能部署在 Docker 里?我听说很多人说不行,性能会有瓶颈。”
架构师老王:“摸摸自己光突突的脑袋, 小李啊,这个问题可不简单。以前确实很多人说不行,但现在技术发展这么快,情况可能不一样了。”
小李:“那您的意思是,现在可以了?”
老王:“也不能这么说。性能、数据安全、运维复杂度,这些都是需要考虑的。不过,已经有不少公司在生产环境里用 Docker 跑 MySQL 了,效果还不错。”
Docker(鲸鱼)+MySQL(海豚)到底如何,我们来具体看看:
一、业界大厂
我们来看看业界使用情况:
1.1、京东超70%的MySQL在Docker中
刘风才是京东的资深数据库专家,他分享了京东在MySQL数据库Docker化方面的实践经验。京东从最初的小规模使用,到现在超过70%的MySQL数据库运行在Docker容器中。
当然京东也不是所有的业务都适合把 mysql 部署在 docker 容器中。比如,
刘风才演讲中也提出:数据文件多于1T多的情况下是不太合适部署在Docker上的;再有就是在性能上要求特别高的,特别重要的核心系统目前仍跑在物理机上,后面随着Docker技术不断的改进,会陆续地迁到Docker上。
1.2、 同程艺龙:大规模 MySQL 容器化实践
同程艺龙的机票事业群 CTO 王晓波在QCon北京2018大会上做了《MySQL的Docker容器化大规模实践》的主题演讲。他分享了同程艺龙如何大规模实践基于Docker的MySQL私有云平台,集成了高可用、快速部署、自动化备份、性能监控等多项自动化运维功能。该平台支撑了总量90%以上的MySQL服务(实际数量超过2000个),资源利用率提升了30倍,数据库交付能力提升了70倍,并经受住了业务高峰期的考验。
当然不仅仅是京东、同程像阿里云、腾讯、字节、美团等都有把 Mysql 部署在 Docker 容器中的案例。
二、官方情况
MySql 官方文档提供了 mysql 的 docker 部署方式,文档中并没有明确的表明这种方式是适用于开发、测试或生产。那就是通用性的,也就是说生产也可以使用。
以下就是安装的脚本可以看到配置文件和数据都是挂载到宿主机上。
docker run --name=mysql1 \
--mount type=bind,src=/path-on-host-machine/my.cnf,dst=/etc/my.cnf \
--mount type=bind,src=/path-on-host-machine/datadir,dst=/var/lib/mysql \
-d container-registry.oracle.com/mysql/community-server:tag
再看看镜像文件,可以看到 oralce 官方 7 年前就发布了 mysql5.7 的镜像。
三、具体分析
反方观点:生产环境MySQL不该部署在Docker里
反方主要担心数据持久化、性能、复杂性、备份恢复和安全性等问题,觉得在Docker里跑MySQL风险挺大。
正方观点:生产环境MySQL可以部署在Docker里
正方则认为Docker的灵活性、可移植性、资源隔离、自动化管理以及社区支持都挺好,生产环境用Docker部署MySQL是可行的,而且有成熟的解决方案来应对数据持久化和性能等问题。
总结
争议的焦点主要在于Docker容器会不会影响性能。其实 Docker和虚拟机不一样,虚拟机是模拟物理机硬件,而Docker是基于Linux内核的cgroups和namespaces技术,实现了CPU、内存、网络和I/O的共享与隔离,性能损失很小。
Docker 和传统虚拟化方式的不同之处,在于 Docker 是在操作系统层面上实现虚拟化,直接复用本地主机的操作系统,而传统方式则是在硬件层面实现。
Docker的特点:
- 轻量级:共享宿主机内核,启动快,资源占用少。
- 隔离性:容器之间相互隔离,不会互相干扰。
- 可移植性:容器可以在任何支持Docker的平台上运行,不用改代码。
四、结尾
Docker虚拟化操作系统而不是硬件
随着技术的发展,Docker在数据库部署中的应用可能会越来越多。
所以,生产环境在Docker里部署MySQL,虽然有争议,但大厂都在用,官方也支持,技术也在不断进步,未来可能是个趋势。
我是栈江湖,如果你喜欢此文章,不要忘记点赞+关注!
来源:juejin.cn/post/7497057694530502665
Spring之父:自从我创立了 Spring Framework以来,我从未如此确信需要一个新项目
大家好,这里是小奏,觉得文章不错可以关注公众号小奏技术
Spring框架之父再出发:发布JVM智能体框架Embabel,赋能企业级AI应用
当今,人工智能的浪潮正以前所未有的势头席卷技术世界,Python
凭借其强大的生态系统成为了AI
开发的“通用语”。
然而,Spring
框架的创始人Rod Johnson
却发出了不同的声音。
”自从我创立 Spring 框架以来,我从未如此坚信一个新项目的必要性。自从我开创了依赖注入(Dependency Injection)和其他 Spring 核心概念以来,我从未如此坚信一种新编程模型的必要性,也从未如此确定它应该是什么样子“
为此,他亲手打造并开源了一个全新的项目——Embabel:一个为 JVM 生态量身定制的 AI 智能体(Agent)框架
我们为什么需要一个智能体框架
难道大型语言模型(LLM)还不够聪明,无法直接解决我们的问题吗?难道多聊天协议(MCP)工具不就是我们让它们解决复杂问题所需要的一切吗?
不。MCP 是向前迈出的重要一步,Embabel
自然也拥抱它,就像它让使用多模型变得简单一样。
但是,我们需要一个更高级别的编排技术,尤其是对于业务应用程序,原因有很多。以下是一些最重要的原因
- 可解释性(Explainability): 在解决问题时,选择是如何做出的?
- 可发现性(Discoverability): MCP 绕开了这个重要问题。我们如何在每个节点找到正确的工具,并确保模型在它们之间进行选择时不会混淆?
- 混合模型的能力(Ability to mix models): 这样我们就不用依赖于“上帝模型”,而是可以为许多任务使用本地的、更便宜的、私有的模型。
- 在流程的任何节点注入“护栏”(guardrails)的能力。
- 管理流程执行并引入更高弹性的能力。
- 大规模流程的可组合性(Composability)。 我们很快将看到的不仅是在一个系统上运行的智能体,而是智能体的联邦。
- 与敏感的现有系统(如数据库)进行更安全的集成,在这些地方,即使是最好的 LLM,给予其写权限也是危险的。
这些问题在企业环境中尤为突出,它们需要的不是一个简单的问答机器人,而是一个可解释、可控制、可组合且足够安全的高级编排系统。这正是智能体框架的价值所在。
为什么是JVM,而不是Python?
Python
在 AI
研究和数据科学领域地位稳固,但 GenAI
的核心是连接与整合。当我们构建企业级 AI
应用时,真正的挑战在于如何将 AI
能力与数十年积累的、运行在 JVM
上的海量业务逻辑、基础设施和数据无缝对接。
在企业应用开发、复杂系统构建和关键业务逻辑承载方面,JVM 生态(Java/Kotlin)拥有无与伦比的优势和成熟度。因此,与其让业务逻辑去追赶 AI 技术栈,不如让 AI 技术栈主动融入业务核心——JVM。
Embabel:为超越而生的下一代智能体框架
Embabel 的目标并非简单地追赶 Python
社区的同类框架,而是要实现跨越式超越。它带来了几个革命性的特性:
- 确定性的智能规划:Embabel 创新地引入了非 LLM 的 AI 规划算法。它能自动从你的代码中发现可用的“能力”和“目标”,并根据用户输入智能地规划出最优执行路径。这意味着你的系统是可扩展的,增加新功能不再需要重构复杂的逻辑,同时整个规划过程是确定且可解释的。
- 类型安全的领域模型:
Embabel
鼓励开发者使用Kotlin data class
或Java record
构建丰富的领域模型。这使得与 LLM 交互的提示(Prompt)变得类型安全、易于工具检查和代码重构,从根本上提升了代码质量和可维护性。 - 与
Spring
无缝集成:Embabel
用Kotlin
构建,并承诺为Java
开发者提供同等一流的体验。更重要的是,它与Spring
框架深度集成。对于数百万Spring
开发者来说,构建一个 AI 智能体将像开发一个REST API
一样自然、简单。
加入我们,共创未来
对于JVM
开发者来说,这是一个激动人心的时代。Embabel
提供了一个绝佳的机会,让你可以利用自己早已熟练掌握的技能,为你现有的 Java/Kotlin
应用注入强大的 AI 能力,从而释放巨大的商业价值。
项目尚在早期,但蓝图宏大。Embabel
的目标是成为全球最好的智能体平台。现在就去 GitHub
关注 Embabel,加入社区,贡献你的力量,一同构建企业级 AI 应用的未来。
参考
来源:juejin.cn/post/7507438828178849828
这篇 Git 教程太清晰了,很多 3 年经验程序员都收藏了
引言
📌 Git 是现代开发中不可或缺的版本控制工具,尤其适用于团队协作和代码管理。本文将带你了解 Git 的基础操作命令,包括 git init
、git add
、git commit
、git diff
、git log
、.gitignore
等,快速上手版本控制。
🛠️ 一、初始化仓库:git init
使用 Git 前,需先初始化一个本地仓库:
git init
执行后会在当前目录生成一个 .git
文件夹,Git 会在此目录下跟踪项目的变更记录。
👤 二、配置用户信息
首次使用 Git 时,推荐设置用户名和邮箱:
git config --global user.name "xxxxx"
git config --global user.email "xxxx@qq.com"
加上 --global
会全局生效,仅对当前项目配置可以省略该参数。
📦 三、代码暂存区(Staging Area)是什么?
Git 的提交操作分为两个阶段:暂存(staging) 和 提交(commit) 。
- 当你修改了文件,Git 并不会立即记录这些改动;
- 你需要先使用
git add
命令,把改动“放进暂存区”,告诉 Git:“这些改动我准备好了,可以提交”; - 然后再使用
git commit
将暂存区的内容提交到本地仓库,记录为一个快照。
🧠 可以把暂存区类比为“快照准备区”,你可以反复修改文件、添加到暂存区,最后一口气提交,确保每次提交都是有意义的逻辑单元。
🎯 举个例子:
# 修改了 index.html 和 style.css
git add index.html # 把 index.html 放入暂存区
git add style.css # 再把 style.css 放入暂存区
git commit -m "更新首页结构和样式" # 一起提交
💡 小贴士:你可以分批使用 git add
管理暂存内容,按逻辑分组提交更利于协作和回溯。
📝 四、查看当前状态:git status
在进行任何修改之前,查看当前仓库的状态是非常重要的。git status
是最常用的命令之一,能让你清楚了解哪些文件被修改了,哪些文件已加入暂存区,哪些文件未被跟踪。
git status
它的输出通常会分为三部分:
- 已暂存的文件:这些文件已使用
git add
添加到暂存区,准备提交。 - 未暂存的文件:这些文件被修改,但还未添加到暂存区。
- 未跟踪的文件:这些文件是新创建的,Git 并未跟踪它们。
例如:
On branch main
Changes to be committed:
(use "git reset HEAD <file>..." to unstage)
modified: index.html
new file: style.css
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: app.js
🎯 通过 git status
,你可以随时了解当前工作区和暂存区的状态,帮助你决定接下来的操作。
📥 五、添加文件到暂存区:git add
当你修改或新增文件后,使用 git add
将其添加到 Git 的暂存区:
git add 文件名
也可以批量添加所有修改:
git add .
💾 六、提交更改:git commit -m
将暂存区的内容提交至本地仓库:
git commit -m "提交说明"
-m
后面是提交信息,建议语义清晰,例如:
git commit -m "新增用户登录功能"
🚀 七、推送到远程仓库:git push origin main
本地提交之后,需要推送代码到远程仓库(如 GitHub、Gitee):
git push origin main
origin
是默认的远程仓库别名;main
是目标分支名(如果你使用的是master
,请替换);
✅ 提交后远程成员就可以拉取(pull)你最新的修改了。
🔗 如果你还没有远程仓库,请先去 GitHub / Gitee 创建一个,然后关联远程仓库地址:
git remote add origin https://github.com/yourname/your-repo.git
🕵️ 八、查看文件改动:git diff
在 commit
之前,可用 git diff
查看修改内容:
git diff
📜 九、查看提交历史:git log --oneline
快速查看历史提交记录:
git log --oneline
输出示例:
e3f1a1b 添加登录功能
2c3d9a7 初始提交
🛑 十、忽略某些文件:.gitignore
在项目中,有些文件无需提交到 Git 仓库,例如缓存、编译结果、配置文件等。使用 .gitignore
文件可忽略这些文件:
# 忽略 node_modules 文件夹
node_modules/
# 忽略所有 .log 文件
*.log
# 忽略 .env 环境变量文件
.env
🌿 十一、重命名默认分支:git branch -M main
很多平台(如 GitHub)推荐使用 main
作为主分支名称:
git branch -M main
这样可以将默认分支由 master
改为 main
。
✅ 总结命令一览表
命令 | 作用 |
---|---|
git init | 初始化仓库 |
git config | 设置用户名与邮箱 |
git status | 查看当前文件状态 |
git add | 添加改动到暂存区 |
git commit -m | 提交改动 |
git push origin main | 推送代码到远程 main 分支 |
git diff | 查看未提交的改动 |
git log --oneline | 查看提交历史 |
.gitignore | 忽略文件 |
git branch -M main | 重命名分支为 main |
🧠 写在最后
Git 是每个开发者都必须掌握的技能之一。掌握好这些常用命令,就能覆盖 90% 的使用场景。未来如果你要进行多人协作、分支合并、冲突解决,这些基础就是你的武器库。
觉得实用就点个赞、关注、收藏支持一下吧 🙌
来源:juejin.cn/post/7506776151315922971
从热衷到放弃:我的 Cursor 续费终止之路
前言
从我最开始用 Cursor 到现在已经有几个月了,然而随着对它的使用时间越来越长,我感觉帮助反而慢慢变小了,一度我这个月想着不续费了,然后我以为 13 到期、结果却是 7 号到期,所以又自动续费了。
最开始接触
我已经用了 Cursor 好几个月了,而我最开始用 Cursor 是在好几个月前。
最开始在社交平台上了解到它的功能后,我就很激动了,感觉这也太神了,就一直想体验。但那时也了解到它的价格的何等的贵,所以我开始并没有直接去下载它,而是找了平替 Windsurf
但别说用了,连注册都注册不了,反复试了几天,也没有注册成功。换手机、换科学上网的不同提供商,都没用。这不,才开始用 Cursor。
无限续杯 之 走到终点
等我用了之后,体验了它的14天免费时间。之后我就觉得它太强了。很多我可以写的功能,它可以以更好的方式,很快的方式生成出来。然而很多我还不会写的。它依然可以去实现。但14天很快就过去了,接着就是一段时间 帐号的删除与注册。但好景不长...
终于在多次反复删除账号又注册。这种操作对我来说已经失效了。
看过了掘金的大多教程,都没有什么用,最后一次有用,但第二天就又不行了。
但那段时间刚巧,我之前在找工作,那几天刚好入职。我就在工作中用了它,有意思的是,我们公司并没有人知道 cursor,甚至没人知道有 ai编辑器。过了几天,无限续杯 也刚好达到极限。
充会员 -> 早下班
当我体验了 Cursor 的威力后,我已经要离不开它了。
我是前端,而 Cursor 可以支持发给它图片,让它画页面。其实画的还挺不错的,这一点就深得我心,所以我痛定思痛充了会员。
那段时间,我就没加过班、自在的我自己都不知道该如何描述了。但刚开始还是不怎么舒服,因为它老是给我生成用 element-plus 组件直接写的,而我们公司有不少组件是二次封装的,导致我总是要改它。但用了十几次后,它就知道我要什么了。
用了哪些功能 与 自己的感受
Tab 的好与不好
我常用的功能就是tab,主要的是它比较灵活,生成的速度也比较快。而且用的越多它就越可以生成想要的代码。比如项目中自己封装的组件。用 tab 几次之后,它就自己可以去用这个组件,但更多情况下,它生成的会是之前写过的内容。
不过它也有一些缺点,比如:不能去预判一些复杂的思路。如果我们写了一个按钮,并在按钮身上绑定一个 Click 事件,名称叫做 search。Cursor 的 Tab 就可以自动会生成 search函数。但如果你只是在这里写了一个按钮,想要做的功能是导出。你没有在按钮上写导出两个字,也没有去绑定一个 Click事件 叫 export。那 Cursor 根本就不知道你要做的是导出,也就不会去自动实现这些功能。
另一个 Tab 的缺点,那就是影响复制功能。经常准备复制内容时,Tab 就给出了它的预判,然而原本你打算复制10个字,此时它的预判在 10个字中间加了 30个字。你要是想复制,正常就会用鼠标选中字,可一旦你鼠标点下那个位置,Tab就来了。我多次遇到这个问题,如果你没有遇到过,请教我一下方法。
对话模式
对话分两种,一种是全局,一种是局部。
先来说一下全局。
全局对话 cmd + i
由于 Cursor 默认会将所有文件自动追踪索引。所以当我们进行全局对话时, Cursor 会基于全局所有文件的索引为基础。去修改现在的代码,但如果我们只想改当前一个页面,它依然会去分析全局,增加了要处理的数据量,就导致时间比较长。
不知道是不是我的科学上网工具问题,我几乎只要用全局问答,就要好几分钟,要是改错了,又要重来,所以现在几乎就不用了。
另一个是后面代码变多了,时间就更长,而且它老是给我优化我不要优化的,因为它经常优化错了。比如关于接口的 type,我都是在 api 文件夹中定义的。但它总说在那个文件中没有这个 type,然后就自动在当前文件附近又创建 types.ts ,然后声明的类型和接口都不是对应的。
当然了,它的好处是分析的全面、如果要跨多个文件修改同一个功能,则它再慢,也得等着。
之后我就又想起了 局部 对话
局部对话 cmd + k
我是上段时间才开始用这个的,因为全局的太慢了,就突然想起来还有局部的 cmd ➕ k 。这个还不错,我最初是用来写 API 数据的。
因为我们是用 ApiPost,我就直接在左边接口标题处,点击复制,然后进代码,在局部问答中发给他,然后说,写出接口和类型。基本没出过错。
用了几天后也发现它的局限性了,就是它貌似只能在问的位置下方生成,如果我要它跨越几个地方添加就没用了。如:在template中生成页面展示的,在JS中生成脚本,在style中生成样式。
但之后发现这种方式不仅能生成,如果你选中了内容,它还能修改。然后我就随机一动,直接全选当前文件,则实现了对一整个文件的局部修改。但说实话,速度也并没有太快。
cursorrules
后来我又加了cursorrules,最初我以为只能用一个rules文件,直到在一个微信群里看见别人分享的照片,他有6个左右的rules。之后我就用了两天时间自己写了4个rules。但经常没有效果,而且还开启了always。
之后,我就在开始的位置写上这样这句话:
自动激活
这些指令在本项目的所有对话中自动生效。当使用到该 rules 时,要打印出这个rules的名字,如"使用了 项目规则.mdc 文件",让我知道你使用了这个文件。
之后有一次就突然出现了这句话
可是,只出现过这个 项目规则.mdc ,其它的mdc 都没有出现过,但其它的文件中 我也写了类型的 自动激活的话。不知道为什么没有生效。
MCP
server-sequential-thinking
MCP 之前使用过,那时主要火的是 server-sequential-thinking, 它的主要功能是思维更有条理。如果你在对话中 说了类似 " 思考 " 的话,那就会激活它。之后它就一句话一句话的分析,也一句一句的解释。因为工作中比较少的有这么有深度的思考,我几乎没用过它。而且用了它之后,话也变多了,导致效率也慢,外加 科学上网 的工具并不好,就更慢了。 上段时间我又开始使用它了,但一直没生效,不知道为啥?
playwright 自动化测试
用这个可能比较复杂,其实我就是希望 Cursor 可以自己调接口,然后根据 api 文件中的 对接口的声明、参数类型与返回类型。自动帮我实现 增删改查 ,如果一个表单,我的字段写错了,它就自动修改,然后继续填写数据再调接口。直到跑通为止。 因为这确实很费时间,也没意思。但至今也没有做到。
browser-tool-mcp
这个是用来让 Cursor 监控浏览器,它可以查看浏览器的 控制台、DOM 结构 等等,但用了一段时间后,发现直接把 控制台的报错 发给 Cursor 更快,也就没怎么用了。
结语
上面 MCP 用的不怎么好的一个原因,是因为没有打通 自动化的流程,所以总是需要我手动的操作。
这个星期打算把 claude 的提示词看一下,看看能不能改善一下 Cursor 的使用情况。
来源:juejin.cn/post/7501966297334497290
Android 16 适配重点全解读 | OPPO 技术专场直播回顾
5月22日,OPPO举办「OTalk | Android 16 开发者交流专场」,特邀OPPO高级工程师团队深度解读Android 16核心技术要点与适配策略。活动以线上直播形式展开,吸引了众多开发者实时观看并参与讨论,为他们提供了从技术解析到工具支持的全流程适配解决方案。
一、Android 16开发者适配计划
根据Google规划,Android 16.0及16.1版本将于2025年分阶段发布,所有应用须在7月1日前完成适配,覆盖目标版本为36(API 36)的新开发及存量应用,涉及行为变更、API 调整和新功能兼容。开发者可尽早启动测试,以免适配延迟对应用上架和用户体验造成不利影响。
二、Android 16核心新特性及适配建议
自适应适配:大屏设备体验的优化
随着折叠屏、平板等多样化设备形态的普及,大屏适配已成为开发者面临的重要技术挑战。在 Android 16.0 中,当应用 Target SDK=36 且运行在最小宽度≥600dp 的设备时,系统将忽略传统的屏幕方向、尺寸可调整性等设置限制,为大屏设备带来更出色的视觉体验。
不过,以下三种情况不在新特性的范围内:
游戏类应用(需要在清单属性中配置 android:appCategory);
小于 sw600dp 的屏幕(常见手机设备不受影响);
用户在系统设置中启用了宽高比配置。
适配建议:
遵循谷歌适配指南,完成大屏布局优化,以提供更佳的用户体验;
若暂不支持,可在 Activity 或 Application 节点添加 PROPERTY_COMPAT_ALLOW_RESTRICTED_RESIZABILITY 属性临时豁免,但需注意,该配置可能会在 Android 17 中被取消,因此建议开发者优先完成适配。
针对大屏适配,开发者可以参考由 OPPO、vivo、小米等厂商共同制定的《ITGSA 大屏设备应用适配白皮书 2.0》。同时,建议开发者逐步迁移到 Compose 开发,使后续适配工作更加简单高效。
预测性返回:手势导航的交互变革
预测性返回是 Android 13 引入的手势导航增强功能,用户在侧滑返回时可以预览目标界面。在 Android 16 中,目标 SDK≥36 的应用默认启用预测性返回动画,系统不再调用 onBackPressed 也不会再调度 KeyEvent.KEYCODE_BACK。
适配建议:迁移至 onBackInvokedCallback 回调处理返回逻辑;若需保留原有逻辑,可在清单中设置 android:enableOnBackInvokedCallback="false" 停用。
ART 内部变更:提升性能与兼容性
Android 16 包含 Android 运行时(ART)的最新更新,这些更新旨在提升 ART 的性能,并支持更多的 Java 功能。依赖 ART 内部结构的代码(如私有反射、非 SDK 接口)将全面失效。
适配建议:全面测试应用稳定性,替换非公开 API 为系统提供的公共 API。
JobScheduler 配额优化:后台任务的效率革命
为了降低系统负载,Android 16 对 JobScheduler 的执行配额进行了动态管理,根据应用待机分桶和前台服务状态动态分配 JobScheduler 执行配额,活跃应用获得更多配额,后台任务仍需遵守配额限制。
适配建议:减少非必要后台任务,高优先级任务使用 setExpedited() 标记;通过 WorkInfo.getStopReason() 记录任务终止原因并调整调度策略。
健康与健身权限:隐私管控的升级
Android 16 将 BODY_SENSOR 权限迁移至 “健康数据共享” 权限组。对于 Target SDK≥36 的应用,需要请求新的权限。
适配建议:更新权限请求逻辑,引导用户在系统级 “健康数据共享” 页面授权。
setImportantWhileForeground 接口失效:后台任务的约束
setImportantWhileForeground 接口曾用于让前台任务豁免后台限制,但从 Android 16 开始,该接口的功能已被彻底移除。依赖此接口的下载任务、实时同步等场景可能出现延迟,影响用户体验。
适配建议:改用 jobInfo.setExpedited() 标记加急任务,确保关键操作优先执行。
息屏场景自动停止屏幕分享:隐私与管控的平衡
为提升隐私安全,Android 16会在手机息屏或通话结束后,自动释放 MediaProjection。
适配建议:在 onStop 回调中处理异常,如需持续投屏,需重新获取 MediaProjection 权限。
此外,在 Android 16 中,多项关键特性同样值得注意。优雅字体 API 被废弃,开发者需手动调整文字布局以确保显示效果。更安全的 Intent 机制要求显式 Intent 与目标组件的 Intent 过滤器相匹配,提升应用安全性。以进度为中心的通知功能增强,通过Notification.ProgressStyle实现更直观的进度可视化。MediaProvider 扩展了能力,PhotoPicker 支持 PDF 读取并增强权限鉴权,同时统一了界面风格。这些变更体现了 Android 16 在安全性、用户体验和功能上的优化。
在互动答疑环节,有开发者提出预测性返回动画是否是系统强制的问题,纪昌杰表示预测性返回特性需要应用 targetsdk 升级到 36 才会强制生效,未升级的应用则需通过配置使其生效,应用要主动适配,适配重点在于防止系统不再调用 onBackPressed 和不再调度 KeyEvent.KEYCODE_BACK 导致应用逻辑异常。而对于一个开发人员如何高效适配大屏的问题,纪昌杰再次强调,建议开发者逐步迁移到 Compose 平台开发,以获得谷歌更多支持,开发资源有限的开发者可以参考金标联盟制定的大屏适配 2.0 标准,其内容大多基于 View + XML 开发模式进行指导。
三、OPPO一站式支持体系
在本次交流专场中,纪昌杰还介绍了 OPPO 为助力 Android 16 适配所构建的一站式开发者支持体系。该体系涵盖了详尽的兼容性适配文档,为开发者提供了清晰明确的适配指引;免费的云真机 / 云测服务,赋能开发者随时随地开展高效调试与验证工作。此外,还包括开发者预览版,便于开发者提前评估应用在新系统上的表现,以及应用商店新特性检测,确保应用完全符合 Android 16 的各项标准。同时,开发者可借助适配答疑交流社群和 OPPO 开放平台支持专区等多元渠道,获取全方位支持,有效提升适配效率。
此次「OTalk | Android 16 适配开发者交流专场」聚焦前沿技术洞察与实战指南,开发者提供了系统性适配路径与高效解决方案。活动分享的适配策略、高频问题解答等核心资料,将在「OPPO开放平台」公众号及OPPO开发者社区官网发布,开发者可免费查阅并应用于实际开发流程。
作为Android生态的重要推动者,OPPO将持续提供全链路适配支持服务,并通过技术沙龙、开发者社群及线上交流平台,与开发者紧密协作,共同探索Android 16的创新边界,助力移动应用生态实现高质量演进。
收起阅读 »个人开发者如何发送短信?这个方案太香了!
还在为无法发送短信验证码而烦恼?今天分享一个超实用的解决方案,个人开发者也能用!
最近国内很多平台暂停了针对个人用户的短信发送,这给个人开发者带来了不少困扰。不过别担心,我发现了一个超实用的解决方案——Spug推送平台,它能很好地满足我们发送短信等需求。
为什么选择这个方案?
- 无需企业认证:个人开发者直接可用
- 新用户福利:注册即送测试短信
- 价格实惠:0.05元/条,按量计费
- 接口简单:几行代码就能搞定
- 支持丰富:短信、电话、微信、企业微信、飞书、钉钉、邮件等
三步搞定短信发送
第一步:注册账户
打开push.spug.cc,使用微信扫码直接登录,无需繁琐的认证流程。
第二步:创建模板
- 点击"消息模板" → "新建"
- 输入模版名称
- 选择推送通道
- 选择短信模板
- 选择推送对象
- 保存模板
第三步:发送短信
复制模版ID,通过API调用即可发送短信。
发送短信验证码代码示例(多种语言)
Python版(推荐)
import requests
def send_sms(template_id, code, phone):
url = f"https://push.spug.cc/send/{template_id}"
params = {
"code": code,
"targets": phone
}
response = requests.get(url, params=params)
return response.json()
# 使用示例
result = send_sms("abc", "6677", "151xxxx0875")
print(result)
Go版
package main
import (
"fmt"
"net/http"
"io/ioutil"
)
func sendSMS(templateID, code, phone string) (string, error) {
url := fmt.Sprintf("https://push.spug.cc/send/%s?code=%s&targets=%s",
templateID, code, phone)
resp, err := http.Get(url)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return "", err
}
return string(body), nil
}
func main() {
result, err := sendSMS("abc", "6677", "151xxxx0875")
if err != nil {
fmt.Println("Error:", err)
return
}
fmt.Println(result)
}
Java版
import java.net.HttpURLConnection;
import java.net.URL;
import java.io.BufferedReader;
import java.io.InputStreamReader;
public class SMSSender {
public static String sendSMS(String templateId, String code, String phone) throws Exception {
String url = String.format("https://push.spug.cc/send/%s?code=%s&targets=%s",
templateId, code, phone);
URL obj = new URL(url);
HttpURLConnection con = (HttpURLConnection) obj.openConnection();
con.setRequestMethod("GET");
BufferedReader in = new BufferedReader(new InputStreamReader(con.getInputStream()));
String inputLine;
StringBuilder response = new StringBuilder();
while ((inputLine = in.readLine()) != null) {
response.append(inputLine);
}
in.close();
return response.toString();
}
public static void main(String[] args) {
try {
String result = sendSMS("abc", "6677", "151xxxx0875");
System.out.println(result);
} catch (Exception e) {
e.printStackTrace();
}
}
}
使用技巧
- 参数说明
code
:验证码内容targets
:接收短信的手机号- 使用
targets
参数会覆盖模板配置的手机号
- 最佳实践
- 选择合适的短信模版
- 验证手机号格式
- 管理验证码有效期
- 添加错误处理
- 确保账户余额充足
来源:juejin.cn/post/7495570300124119052
我用AI+高德MCP 10分钟搞定苏州三日游
清明节后回到工作岗位,同事们都在讨论"五一"小长假要去哪里,我悄悄地笑了——作为一名AI玩家,旅行规划这种事,早就甩手给AI工具了!
前两天,我用AI+高德地图MCP不到10分钟就搞定了一份详细的苏州三日游攻略,发给朋友们看了规划都惊呆了。
"这...这么详细?连每天天气、门票价格、交通方式都安排好了?"
没错,它全都搞定了!想当年我策划旅行,那可是"人间疾苦":
- 在小红书翻攻略翻到眼睛发酸
- 在地图上反复规划路线怀疑人生
- 十几个浏览器标签切换到想砸电脑
现在?10分钟搞定,而且比人工规划更合理、更高效。
想学吗?我现在就手把手教你,怎么让AI+高德MCP为你定制完美旅行计划。
四步上手,成为旅行规划大师
步骤1:获取高德地图开发权限(超简单)
先去高德开发者平台(lbs.amap.com)注册个账号。
怕麻烦?直接用支付宝扫码就能登录,一分钟搞定!
注册完成后,系统会让你验证身份——这是为了确认你不是机器人,讽刺的是我们要用这个来教AI做事🤣
验证过程很简单,照着提示操作就行,最终你会成为一名光荣的"高德地图开发者"。
步骤2:创建应用并获取API Key
登录成功后,进入控制台:
- 点击"应用管理",创建一个新应用
- 应用名称随便填,比如"我的旅行助手"
- 平台选择"Web服务"
- 创建应用后点击"添加Key",复制生成的密钥
这个Key就是打开高德地图宝库的钥匙,下面要把它交给我们的AI助手。
步骤3:配置AI的地图能力
这一步的关键——我们要让AI获得调用高德地图的超能力:
- 打开Claude Desktop(或其他支持MCP的AI,比如Cursor)
- File->Setting->Developer->Edit Config
- 配置MCP配置文件,配置高德地图MCP服务,贴入刚才获取的API Key
- 保存配置,重启应用
如果你使用的是Claude,添加下面的代码(记得替换成你自己的key)建议重启下应用:
{
"mcpServers": {
"amap-maps": {
"command": "npx",
"args": ["-y", "@amap/amap-maps-mcp-server"],
"env": {"AMAP_MAPS_API_KEY": "这里粘贴你的key"}
}
}
}
确认配置无误后,AI现在已经具备了调用高德地图的能力,它可以查询实时天气、景点信息、路线规划和交通状况等数据。
步骤4:一句指令,生成完美攻略
现在是见证奇迹的时刻!在对话框中输入:
用高德MCP,做苏州三天旅游指南
然后静静等待几秒钟,AI会开始调用高德地图API,搜集各种数据并为你生成一份详尽的旅行规划。
我的苏州三日游攻略包含了:
- 每天详细的行程安排和时间规划
- 景点介绍、门票价格和开放时间
- 周边餐厅推荐和特色美食
- 不同景点间的交通方式和预计用时
- 三天的天气预报
- 住宿和购物建议
- 各种实用小贴士
最妙的是,AI还能根据天气情况自动调整行程——我看到第二天苏州预报有大雨,它贴心地提醒我准备雨具,并建议安排更多室内活动。
锦上添花:生成打印版旅行攻略
如果你想更进一步,可以让AI为你生成一份精美的A4旅行规划表,方便打印随身携带。
只需输入: 帮我设计一个A4纸张大小的旅行规划表,适合打印出来随身携带
这是我的提示词
# 旅行规划表设计提示词
你是一位优秀的平面设计师和前端开发工程师,具有丰富的旅行信息可视化经验,曾为众多知名旅游平台设计过清晰实用的旅行规划表。现在需要为我创建一个A4纸张大小的旅行规划表,适合打印出来随身携带使用。请使用HTML、CSS和JavaScript代码实现以下要求:
## 基本要求
尺寸与基础结构
- 严格符合A4纸尺寸(210mm×297mm),比例为1:1.414
- 适合打印的设计,预留适当的打印边距(建议上下左右各10mm)
- 采用单页设计,所有重要信息必须在一页内完整呈现
- 信息分区清晰,使用网格布局确保整洁有序
- 打印友好的配色方案,避免过深的背景色和过小的字体
技术实现
- 使用打印友好的CSS设计
- 提供专用的打印按钮,优化打印样式
- 使用高对比度的配色方案,确保打印后清晰可读
- 可选择性地添加虚线辅助剪裁线
- 使用Google Fonts或其他CDN加载适合的现代字体
- 引用Font Awesome提供图标支持
专业设计技巧
- 使用图标和颜色编码区分不同类型的活动(景点、餐饮、交通等)
- 为景点和活动设计简洁的时间轴或表格布局
- 使用简明的图示代替冗长文字描述
- 为重要信息添加视觉强调(如框线、加粗、不同颜色等)
- 在设计中融入城市地标元素作为装饰,增强辨识度
## 设计风格
- 实用为主的旅行工具风格:以清晰的信息呈现为首要目标
- 专业旅行指南风格:参考Lonely Planet等专业旅游指南的排版和布局
- 信息图表风格:将复杂行程转化为直观的图表和时间轴
- 简约现代设计:干净的线条、充分的留白和清晰的层次结构
- 整洁的表格布局:使用表格组织景点、活动和时间信息
- 地图元素整合:在合适位置添加简化的路线或位置示意图
- 打印友好的灰度设计:即使黑白打印也能保持良好的可读性和美观
## 内容区块
1. 行程标题区:
- 目的地名称(主标题,醒目位置)
- 旅行日期和总天数
- 旅行者姓名/团队名称(可选)
- 天气信息摘要
2. 行程概览区:
- 按日期分区的行程简表
- 每天主要活动/景点的概览
- 使用图标标识不同类型的活动
3. 详细时间表区:
- 以表格或时间轴形式呈现详细行程
- 包含时间、地点、活动描述
- 每个景点的停留时间
- 标注门票价格和必要预订信息
4. 交通信息区:
- 主要交通换乘点及方式
- 地铁/公交线路和站点信息
- 预计交通时间
- 使用箭头或连线表示行程路线
5. 住宿与餐饮区:
- 酒店/住宿地址和联系方式
- 入住和退房时间
- 推荐餐厅列表(标注特色菜和价格区间)
- 附近便利设施(如超市、药店等)
6. 实用信息区:
- 紧急联系电话
- 重要提示和注意事项
- 预算摘要
- 行李清单提醒
## 示例内容(基于深圳一日游)
目的地:深圳一日游
日期:2025年4月15日(星期二)
天气:晴,24°C/18°C,东南风2-3级
时间表:
| 时间 | 活动 | 地点 | 详情 |
|------|------|------|------|
| 09:00-11:30 | 参观世界之窗 | 南山区深南大道9037号 | 门票:190元 |
| 12:00-13:30 | 海上世界午餐 | 蛇口海上世界 | 推荐:海鲜、客家菜 |
| 14:00-16:00 | 游览深圳湾公园 | 南山区滨海大道 | 免费活动 |
| 16:30-18:30 | 逛深圳欢乐海岸 | 南山区白石路 | 购物娱乐 |
| 19:00-21:00 | 福田CBD夜景或莲花山夜游 | 福田中心区 | 免费活动 |
交通路线:
- 世界之窗→海上世界:乘坐地铁2号线(世界之窗站→海上世界站),步行5分钟,约20分钟
- 海上世界→深圳湾公园:乘坐公交线路380路,约15分钟
- 深圳湾→欢乐海岸:步行或乘坐出租车,约10分钟
- 欢乐海岸→福田CBD:地铁2号线→地铁4号线,约35分钟
实用提示:
- 下载"深圳地铁"APP查询路线
- 准备防晒用品,深圳日照强烈
- 世界之窗建议提前网上购票避免排队
- 使用深圳通交通卡或移动支付
- 深圳湾傍晚可观赏日落美景和香港夜景
- 周末景点人流较大,建议工作日出行
重要电话:
- 旅游咨询:0755-12301
- 紧急求助:110(警察)/120(急救)
请创建一个既美观又实用的旅行规划表,适合打印在A4纸上随身携带,帮助用户清晰掌握行程安排。
AI会立刻为你创建一个格式优美、信息完整的HTML文档,包含所有行程信息,分区清晰,配色考虑了打印需求,真正做到了拿来即用!
告别旅行规划焦虑症
这套方法彻底改变了我规划旅行的方式。以前要花半天甚至几天的工作,现在10分钟就能完成,而且质量更高:
- 基于实时数据:不会推荐已关闭的景点或过时信息
- 路线最优化:自动计算景点间最合理的游览顺序
- 个性化定制:想要美食之旅?亲子游?文艺路线?只需一句话
- 省时又省力:把宝贵时间用在享受旅行上,而不是规划过程中
最让我满意的是,这整套流程不需要任何编程知识,人人都能轻松上手。我妈妈都能用!
更多玩法等你探索
除了基础攻略,你还可以用更具体的指令获取定制内容:
"我想了解苏州有什么值得打卡的特色美食" "帮我规划一条适合老人和小孩的苏州慢游路线" "我只去苏州一天,哪些景点必须打卡?" "设计一条苏州园林主题的摄影路线"
每一个问题,AI都能结合高德地图的数据给你最专业的建议。
以后旅行前,不用再痛苦地翻攻略、对比信息、反复规划了。一杯咖啡的时间,完美行程就在你手中。
这大概就是科技改变生活的最好证明吧!下次出行,不妨也试试这个方法,让AI做你的专属旅行规划师!
阿里云宣布全面支持MCP服务部署和调用
前天群里还有小伙伴想玩下MCP服务呢,昨天阿里云百炼平台就宣布全面支持MCP服务部署与调用,打通AI应用爆发的最后一公里。
这里是地址:bailian.console.aliyun.com/?tab=mcp#/m…
当然昨晚我也研究了下,简直不要太简单,连注册都省了,下面点立即开通呢就能玩了
下面这个知名爬虫服务我也体验了把,非常简单易懂
创建完应用,提示词录入进去就能用了,连cursor,claude的mcp配置都免了,感兴趣的朋友可以去体验下。
来源:juejin.cn/post/7491553973112111115
聊一下MCP,希望能让各位清醒一点吧🧐
最近正在忙着准备AI产品示例项目的数据,但是有好几群友问了MCP的问题,先花点时间给大家安排一下MCP。作为一个带队落地AI应用的真实玩家,是怎么看待MCP的。
先说观点:MCP不错,但它仅仅是个协议而已,很多科普文章中,提到的更多都是愿景,而不是落地的场景。
本文不再重新陈述MCP的基本概念,而是旨在能让大家了解的是MCP 有什么用?
、怎么用?
、要不要用?
我准备了一份MCP实现的核心代码
,只保留必要的内容,五分钟就能看明白MCP回事。
先上代码,让我们看看实现MCP最核心的部分我们都干了些什么东西。顺便让大家看看MCP到底和Function call是个什么关系
此处只贴用于讲解的代码,其他代码基本都是逻辑处理与调用。也可关注公众号:【华洛AI转型纪实】,发送
mcpdemo
,来获取完整代码。
MCP代码核心逻辑
我们在本地运行的MCP,所以使用的是Stdio
模式的客户端和服务端。也就是:StdioServerTransport
和StdioClientTransport
先看打满日志的demo运行起来起来后,我们获得的信息:

我们的服务端写了两个简单的工具,加法
和减法
。
服务端启动成功之后,客户端成功的从服务端获取到了这两个工具。
我们发起了一个问题:计算1+1
接下来做的事情就是MCP的客户端核心三步逻辑:
- 客户端调用AI的function call能力,由AI决定是否使用工具,使用哪个工具。
- 客户端把确定要使用的工具和参数发送回服务端,由服务端实现API调用并返回结果。
- 客户端根据结果,再次调用AI,由AI进行回答。
我们一边看代码一边说里面的问题:
第一步调用AI,决定使用工具
客户端代码:
const response = await this.openai.chat.completions.create({
model: model,
messages,
tools: this.tools, // ! 重点看这里,this.tools是服务端返回的工具列表
});
看到了么?这里用的还是Function call! 谣言一:MCP和Function call没关系,MCP就可以让大家调用工具
,终结了。MCP就是用的function call的能力来实现的工具调用。当然我们也可以不用Function call,我们就直接用提示词判断,也是可以的。
这里要说的是:MCP就是个协议
。并没有给大模型带来任何新的能力,也没有某些人说的MCP提升了Function call的能力,以后不用Function call了,用MCP就够了这种话,千万不要被误导。
MCP并没有让大模型的工具调用能力提升
在真实的生产环境中,目前Function call主要的问题有:
- 工具调用准确性不够。
真正的生成环境可能不是三个五个工具,而是三十个五十个。工具之间的界限不够清晰的话,就会存在模型判断不准确的情况。 - 参数提取准确性不够。
特别是当一个工具必填加选填的参数达到十个以上的时候,面对复杂问题,参数的提取准确率会下降。 - 多意图的识别。
用户的一个问题涉及到多个工具时,目前没有能够稳定提取的模型。
第二步把工具和参数发回服务端,由服务端调用API
客户端代码:
const result = await this.mcp.callTool({
name: toolName,
arguments: toolArgs,
});
服务端的代码:
server.tool(
"加法",
"计算数字相加",
{
"a": z.number().describe("加法的第一个数字"),
"b": z.number().describe("加法的第二个数字"),
},
async ({ a, b, c }) => {
console.error(`服务端: 收到加法API,计算${a}和${b}两个数的和。模型API发送`)
// 这里模拟API的发送和使用
let data = a + b
return {
content: [
{
type: "text",
text: a + '+' + b + '的结果是:' + data,
},
],
};
},
);
发现问题了么? API是要有MCP服务器提供者调用的。要花钱的朋友!
每一台MCP服务器背后都是要成本的,收费产品进行MCP服务器的支持还说的过去,不收费的产品全靠爱发电。更不要说,谁敢在生成环境接一个不收费的私人的小服务器?
百度地图核心API全面兼容MCP了,百度地图是收费的,进行多场景的支持是很正常的行为。
来看看百炼吧,阿里的百炼目前推出了MCP的功能,支持在百炼上部署MCP server。
也是要花钱的朋友~,三方API调用费用另算。

阿里的魔塔社区提供了大量的MCP,可以看到有一些大厂的服务在,当然有收费的有免费的,各位可以尝试

第三步客户端根据结果,再次调用AI,由AI进行回答。
客户端代码:
messages.push({
role: "user",
content: result.content,
});
const aiResponse = await this.openai.chat.completions.create({
model: model,
messages: messages,
});
从服务端返回的结果,添加到messages
中,配合提示词由大模型进行回复即可。
这一步属于正常的流程,没什么好说的。
那么问题是:我们使用MCP来实现,和我们自己实现这套流程有什么区别么?我们为什么要用MCP呢?
当初群里朋友第一次提到MCP的时候,我去看了一眼文档,给了这样的结论:
大厂为了抢生态做的事情,给落地的流程中定义了一些概念,多了脑力负担,流程和自己实现没区别。
对于工具的使用,自己实现和用MCP实现有什么区别么?
自己实现的流程和逻辑是这样的:
- 我们的提示词工程师写好每个工具的提示词
- 我们的后端工程师写好模型的调用,使用的是前面写好的提示词
- 提供接口给前端,等待前端调用
- 前端调用传入query,后端通过AI获取了工具
- 通过工具配置调用API,拿到数据交给AI,流式返回用户。
MCP的逻辑是这样的:
- 我们的提示词工程师写好每个工具的提示词
- 我们后端工程师分别写好MCP服务端、MCP客户端
- MCP客户端提供个接口给前端,等待前端调用
- 前端调用传入query,MCP客户端调用AI,获取了工具。
- 客户端把确定要使用的工具和参数发送会服务端,由服务端实现API调用并返回结果。
- 客户端根据结果,再次调用AI,由AI进行回答,流式返回用户。
看吧,本质上是没有区别的。
什么?你说MCP服务端,如果日后需要与其他企业进行合作,可以方便的让对方的MCP客户端调用?
我们的客户端也可以很方便的接入别人的MCP服务端。
不好意思,不用MCP也可以,因为Function call的参数格式已经确定了,这里原本存在差异性就极小。而且MCP也并没有解决这个差异性。还是需要客户端进行修改的。
MCP真正的意义
现在还是诸神混战时期,整个AI产品的上下游所有的点,都具有极高的不确定性。
MCP给出了一个技术标准化的协议,是大家共建AI的愿景中的一环,潜力是有的。
但是Anthropic真的只是在乎这个协议么?前面的内容我们也看到了,MCP和我们自己实现的流程几乎是一样的。但是为什么还要提出MCP呢?
为了生态控制权和行业话语权。
MCP它表面上是一个开放的协议,旨在解决AI模型与外部工具集成的碎片化问题,但其实他就是Anthropic对未来AI生态主导权的竞争。
未来MCP如果真的作为一个标准的协议成为大家的共识,围绕这个协议,甚至每家模型的工具调用格式都将被统一,此时Anthropic在委员会里的位置呢?不言而喻啊。
结语
最后把我的策略分享给大家吧:
打算在圈子里玩的部分,就和大家用一样的,不在圈子里玩的,其实自己团队实现也是OK的。
我这边更多的是自己团队实现的,而且在这个实现过程中大家对模型应用、AI产品的理解不断地在提升。
希望各位读者也多进行尝试,这样未来面对新出的各路牛鬼蛇神时大家才能有更多的判断力。
共勉吧!
☺️你好,我是华洛,如果你对程序员转型AI产品负责人感兴趣,请给我点个赞。
你可以在这里联系我👉http://www.yuque.com/hualuo-fztn…
已入驻公众号【华洛AI转型纪实】,欢迎大家围观,后续会分享大量最近三年来的经验和踩过的坑。
实战专栏
# 从0到1打造企业级AI售前机器人——实战指南一:根据产品需求和定位进行agent流程设计🧐
# 从0到1打造企业级AI售前机器人——实战指南二:RAG工程落地之数据处理篇🧐
来源:juejin.cn/post/7492271537010671635
长安马自达全球车型MAZDA 6e启航欧洲,全球化战略迈入新里程
4月22日,上海外高桥码头,长安马自达首批发往欧洲市场的纯电旗舰轿车MAZDA 6e正式装船启航。此次发运标志着MAZDA 6e在欧洲市场进入交付倒计时阶段,长安马自达“双百翻番”战略计划逐步落地,中国“合资智造”正加速赋能马自达全球电动化布局,传递着中国新能源产业的技术自信。长安马自达汽车有限公司管理层和临港片区管委会代表、物流合作伙伴出席装船仪式,共同见证这一里程碑时刻。
上午10时,外高桥码头海风轻拂,首批600辆MAZDA 6e整齐列队,与停泊在蓝天碧海间的巨型滚装运输船交相辉映。随着发运按钮的正式启动,首辆MAZDA 6e平稳驶入船舱,现场响起热烈掌声。这批车辆预计将于5月抵达比利时港口,并于今年夏天交付至欧洲多国经销商。MAZDA 6e的到来,将为欧洲市场客户带来全新的电动旗舰轿车选择,并将进一步丰富马自达欧洲市场的产品阵容。
自今年1月10日首次亮相2025比利时布鲁塞尔车展以来,MAZDA 6e的全球化进程在不断加速。MAZDA 6e是以MAZDA EZ-6为基础推出的符合欧洲市场环境,且能满足欧洲客户和马自达忠实粉丝的期待、彰显马自达特色的最新款电动汽车。MAZDA 6e的开发过程集合了长安马自达南京产品研发中心、马自达日本广岛总部以及马自达欧洲研发中心三地工程师的智慧与力量。从设计、研发到生产均严格遵循马自达全球统一的制造标准,既是中国车,也是全球车。南京工厂作为马自达在华唯一新能源生产基地,汇聚了马自达百年造车工艺与长安汽车领先的电动化技术,以智能化生产线和精益管理模式确保每一辆MAZDA 6e的品质达均能达到全球顶尖水平。
长安马自达汽车有限公司总裁松田英久表示:“MAZDA 6e拥有符合欧盟最新法规的三电系统和安全性能、超低风阻的「魂动」美学设计,以及电感「人马一体」的驾控性能,精准契合欧洲消费者对高端电动轿车的期待。MAZDA 6e的欧洲首航,代表着长安马自达正从‘合资企业’向‘全球新能源技术创新基地’转型。托中国在电动化、智能化领域的先发优势,长安马自达未来将成为马自达全球技术研发的关键支点”。
同时,MAZDA EZ-6不断加快产品焕新节奏。在现有的赤麂棕色高配内饰色之外,新增兼具时尚气质和高级质感的鹭羽白浅色内饰,快速回应用户对于浅色系内饰的需求,更为用户带来“增色不加价”的新选择。目前,购MAZDA EZ-6可享受至高40,000元补贴(15,000元置换厂补+20,000元置换国补+5,000元保险补贴)、100,000元尾款可享6年0息(和置换厂补二选一),还可享价值7,999元不限车主、不限里程终身零燃权益。
4月23日,长安马自达第二款全球化新能源车型MAZDA EZ-60将登陆2025上海国际车展6.1馆展台,迎来全球首发。以MAZDA 6e出海为起点,长安马自达还将持续推出更多面向全球市场的新能源车型,覆盖更多细分市场用户需求,以更快的节奏、更强的技术、更广的布局,迎接全球电动化市场的无限可能。
【Fiddler】Fiddler抓包工具(详细讲解)_抓包工具fiddler
抓包工具使用指南
序章
Fiddler 是一款功能强大的抓包工具,能够截获、重发、编辑和转存网络传输中的数据包,同时也常用于网络安全检测。它的功能丰富,但在学习过程中可能会遇到一些隐藏的小功能,容易遗忘。因此,本文总结了 Fiddler 的常用功能,并结合 SniffMaster 抓包大师的特点,帮助大家更好地掌握抓包工具的使用。
1. Fiddler 抓包简介
Fiddler 通过改写 HTTP 代理来监控和截取数据包。当 Fiddler 启动时,它会自动设置浏览器的代理,关闭时则会还原代理设置,非常方便。
1.1 字段说明
Fiddler 抓取的数据包会显示在列表中,以下是各字段的含义:
名称 | 含义 |
---|---|
# | 抓取 HTTP 请求的顺序,从 1 开始递增 |
Result | HTTP 状态码 |
Protocol | 请求使用的协议(如 HTTP/HTTPS/FTP 等) |
Host | 请求地址的主机名 |
URL | 请求资源的位置 |
Body | 请求的大小 |
Caching | 请求的缓存过期时间或缓存控制值 |
Content-Type | 请求响应的类型 |
Process | 发送此请求的进程 ID |
Comments | 用户为此会话添加的备注 |
Custom | 用户设置的自定义值 |
1.2 Statistics 请求性能数据分析
点击任意请求,可以在右侧查看该请求的性能数据和分析结果。
1.3 Inspectors 查看数据内容
Inspectors 用于查看会话的请求和响应内容,上半部分显示请求内容,下半部分显示响应内容。
1.4 AutoResponder 拦截指定请求
AutoResponder 允许拦截符合特定规则的请求,并返回本地资源或 Fiddler 资源,从而替代服务器响应。例如,可以将关键字 "baidu" 与本地图片绑定,访问百度时会被劫持并显示该图片。
1.5 Composer 自定义请求发送
Composer 允许自定义请求并发送到服务器。可以手动创建新请求,或从会话表中拖拽现有请求进行修改。
1.6 Filters 请求过滤规则
Filters 用于过滤请求,避免无关请求干扰。常用的过滤条件包括 Zone(内网或互联网)和 Host(指定域名)。
1.7 Timeline 请求响应时间
Timeline 显示指定内容从服务器传输到客户端的时间,帮助分析请求的响应速度。
2. Fiddler 设置解密 HTTPS 数据
Fiddler 可以通过伪造 CA 证书来解密 HTTPS 数据包。具体步骤如下:
- 打开 Fiddler,点击 Tools -> Fiddler Options -> HTTPS。
- 勾选 Decrypt HTTPS Traffic。
- 点击 OK 保存设置。
3. 抓取移动端数据包
3.1 设置代理
- 打开 Fiddler,点击 Tools -> Fiddler Options -> Connections。
- 设置代理端口为 8888,并勾选 Allow remote computers to connect。
- 在手机端连接与电脑相同的 WiFi,并设置代理 IP 和端口。
3.2 安装证书
- 在手机浏览器中访问
http://<电脑IP>:8888
,下载 Fiddler 根证书。 - 安装证书并信任。
3.3 抓取数据包
配置完成后,手机访问应用时,Fiddler 会截取到数据包。
4. Fiddler 内置命令与断点
Fiddler 提供了命令行功能,方便快速操作。常用命令包括:
命令 | 功能 | 示例 |
---|---|---|
? | 匹配包含指定字符串的请求 | |
匹配请求大小大于指定值的请求 | >1000 | |
< | 匹配请求大小小于指定值的请求 | <100 |
= | 匹配指定 HTTP 返回码的请求 | =200 |
@ | 匹配指定域名的请求 | @http://www.baidu.com |
select | 匹配指定响应类型的请求 | select image |
cls | 清空当前所有请求 | cls |
dump | 将所有请求打包成 saz 文件 | dump |
start | 开始监听请求 | start |
stop | 停止监听请求 | stop |
断点功能
Fiddler 的断点功能可以截获请求并暂停发送,方便修改请求内容。常用断点命令包括:
- bpafter:中断包含指定字符串的请求。
- bpu:中断响应。
- bps:中断指定状态码的请求。
- bpv:中断指定 HTTP 方法的请求。
5. SniffMaster 抓包大师
SniffMaster 是一款跨平台抓包工具,支持 Android、iOS 和 PC 端抓包。与 Fiddler 相比,SniffMaster 具有以下优势:
- 自动生成证书:无需手动配置 HTTPS 解密。
- 多设备支持:支持同时抓取多个设备的数据包。
- 智能过滤:按协议、域名等条件快速筛选数据。
- 可视化界面:提供更直观的数据分析和展示。
5.1 SniffMaster 使用场景
- 移动端抓包:支持 Android 和 iOS 设备,自动配置代理和证书。
- HTTPS 解密:内置 HTTPS 解密功能,无需手动安装证书。
- 多平台支持:支持 Windows、macOS 和 Linux 系统。
总结
Fiddler 和 SniffMaster 都是强大的抓包工具,适用于不同的场景。Fiddler 适合需要深度定制和高级功能的用户,而 SniffMaster 则更适合新手和需要快速抓包的用户。无论是开发调试还是网络安全检测,这两款工具都能提供极大的帮助。
来源:juejin.cn/post/7481463851298635827
“新E代弯道王”MAZDA EZ-6鹭羽白内饰焕新
今日,“新E代弯道王”MAZDA EZ-6(以下称EZ-6)宣布鹭羽白内饰焕新,现在购车可享补贴后9.98万起。新车在现有的赤麂棕色高配内饰色之外,新增兼具时尚气质和高级质感的鹭羽白浅色内饰,不仅快速回应了部分用户对于浅色系内饰的需求,更为用户带来“增色不加价”的新选择。
为什么把私钥写在代码里是一个致命错误
为什么把私钥写在代码里是一个致命错误
在技术社区经常能看到一些开发者分享的教训,前几天就有人发帖讲述一位Java开发者因同事将私钥直接硬编码在代码里而感到愤怒的事情。这种情况虽然听起来可笑,但在开发团队中却相当常见,尤其对于经验不足的程序员来说。
为什么把私钥写在代码里如此危险?
1. 代码会被分享和同步
代码通常会提交到Git或SVN等版本控制系统中。一旦私钥被提交,团队中的每个人都能看到这些敏感信息。即使后来删除了私钥,在历史记录中依然可以找到。有开发者就分享过真实案例:团队成员意外将AWS密钥提交到GitHub,结果第二天账单暴增数千元——有人利用泄露的密钥进行了挖矿活动。
2. 违反安全和职责分离原则
在规范的开发流程中,密钥管理和代码开发应该严格分离。通常由运维团队负责密钥管理,而开发人员则不需要(也不应该)直接接触生产环境的密钥。这是基本的安全实践。
3. 环境迁移的噩梦
当应用从开发环境迁移到测试环境,再到生产环境时,如果密钥硬编码在代码中,每次环境切换都需要修改代码并重新编译。这不仅效率低下,还容易出错。
正确的做法
业内已有多种成熟的解决方案:
- 使用环境变量存储敏感信息
- 采用专门的配置文件(确保加入.gitignore)
- 使用AWS KMS、HashiCorp Vault等专业密钥管理系统
- 在CI/CD流程中动态注入密钥
有开发团队就曾经花费两周时间清理代码中的硬编码密钥。其中甚至发现了一个已离职员工留下的"临时"数据库密码,注释中写着"临时用,下周改掉"——然而那个"下周"已经过去五年了。
作为专业开发者,应当始终保持良好的安全习惯。将私钥硬编码进代码,就像把家门钥匙贴在门上一样不可理喻。
这个教训值得所有软件工程师引以为戒。
来源:juejin.cn/post/7489043337290203163
双Token无感刷新方案
提醒一下
双Token机制并没有从根本上解决安全性的问题,本文章只是提供一个思路,具体是否选择请大家仔细斟酌考虑,笔者水平有限,非常抱歉对你造成不好的体验。
token有效期设置问题
最近在做用户认证模块的后端功能开发,之前就有一个问题困扰了我好久,就是如何设置token
的过期时间,前端在申请后端登录接口成功之后,会返回一个token
值,存储在用户端本地,用户要访问后端的其他接口必须通过请求头带上这个token
值,但是这个token
的有效期应该设置为多少?
- 如果设置的太短,比如1小时,那么用户一小时之后。再访问其他接口,需要再次重新登录,对用户的体验极差
- 如果设置为一个星期,那么在这个时间内
- 一旦
token
泄露,攻击者可长期冒充用户身份,直到token
过期,服务端无法限制其访问用户数据 - 虽然可以依赖黑名单机制,但会增加系统复杂度,还要进行系统监测
- 如果在这段时间恶意用户利用未过期的条款持续调用后端API将会导致资源耗尽或产生巨额费用
- 一旦
所以有没有两者都兼顾的方案呢?
双token无感刷新方案
传统的token
方案要么频繁要求用户重新登录,要么面临长期有效的安全风险
但是双token
无感刷新机制,通过组合设计,在保证安全性的情况下,实现无感知的认证续期
核心设计
access_token
:访问令牌,有效期一般设置为15~30分钟,主要用于对后端请求API的交互refresh_token
:刷新令牌,一般设置为一个星期到一个月,主要用于获取新的access_token
大致的执行流程如下
用户登录之后,后端返回access_token
和refresh_token
响应给前端,前端将两个token
存储在用户本地
在用户端发起前端请求,访问后端接口,在请求头中携带上access_token
前端会对access_token
的过期时间进行检测,当access_token
过期前一分钟,前端通过refresh_token
向后端发起请求,后端判断refresh_token是否有效,有效则重新获取新的access_token
,返回给前端替换掉之前的access_token
存储在用户本地,无效则要求用户重新认证
这样的话对于用户而言token
的刷新是无感知的,不会影响用户体验,只有当refresh_token
失效之后,才需要用户重新进行登录认证,同时,后端可以通过对用户refresh_token
的管理来限制用户对后端接口的请求,大大提高了安全性
有了这个思路,写代码就简单了
@Service
public class LoginServiceImpl implements LoginService {
@Autowired
private JwtUtils jwtUtils;
// token过期时间
private static final Integer TOKEN_EXPIRE_DAYS =5;
// token续期时间
private static final Integer TOKEN_RENEWAL_MINUTE =15;
@Override
public boolean verify(String refresh_token) {
Long uid = jwtUtils.getUidOrNull(refresh_token);
if (Objects.isNull(uid)) {
return false;
}
String key = RedisKey.getKey(RedisKey.USER_REFRESH_TOKEN,uid);
String realToken = RedisUtils.getStr(key);
return Objects.equals(refresh_token, realToken);
}
@Override
public void renewalTokenIfNecessary(String refresh_token) {
Long uid = jwtUtils.getUidOrNull(refresh_token);
if (Objects.isNull(uid)) {
return;
}
String refresh_key = RedisKey.getKey(RedisKey.USER_REFRESH_TOKEN, uid);
long expireSeconds = RedisUtils.getExpire(refresh_key, TimeUnit.SECONDS);
if (expireSeconds == -2) { // key不存在,refresh_token已过期
return;
}
String access_key = RedisKey.getKey(RedisKey.USER_ACCESS_TOKEN, uid);
RedisUtils.expire(access_key, TOKEN_RENEWAL_MINUTE, TimeUnit.MINUTES);
}
@Override
@Transactional(rollbackFor = Exception.class)
@RedissonLock(key = "#uid")
public LoginTokenResponse login(Long uid) {
String refresh_key = RedisKey.getKey(RedisKey.USER_REFRESH_TOKEN, uid);
String access_key = RedisKey.getKey(RedisKey.USER_ACCESS_TOKEN, uid);
String refresh_token = RedisUtils.getStr(refresh_key);
String access_token;
if (StrUtil.isNotBlank(refresh_token)) { //刷新令牌不为空
access_token = jwtUtils.createToken(uid);
RedisUtils.set(access_key, access_token, TOKEN_RENEWAL_MINUTE, TimeUnit.MINUTES);
return LoginTokenResponse.builder()
.refresh_token(refresh_token).access_token(access_token)
.build();
}
refresh_token = jwtUtils.createToken(uid);
RedisUtils.set(refresh_key, refresh_token, TOKEN_EXPIRE_DAYS, TimeUnit.DAYS);
access_token = jwtUtils.createToken(uid);
RedisUtils.set(access_key, access_token, TOKEN_RENEWAL_MINUTE, TimeUnit.MINUTES);
return LoginTokenResponse.builder()
.refresh_token(refresh_token).access_token(access_token)
.build();
}
}}
注意事项
- 安全存储Refresh Token时,优先使用HttpOnly+Secure Cookie而非LocalStorage
- 在颁发新Access Token时,重置旧Token的生存周期(滑动过期)而非简单续期
- 针对高敏感操作(如支付、改密),建议强制二次认证以突破Token机制的限制
安全问题
双Token机制并没有从根本上解决安全性的问题,它只是尝试通过改进设计,优化用户体验,全面的安全策略需要多层防护,分别针对不同类型的威胁和风险,而不仅仅依赖于Token的管理方式或数量
安全是一个持续对抗的过程,关键在于提高攻击者的成本,而非追求绝对防御。
"完美的认证方案不存在,但聪明的权衡永远存在。"
本笔者水平有限,望各位海涵
如果文章中有不对的地方,欢迎大家指正。
来源:juejin.cn/post/7486782063422717962
程序员,你使用过灰度发布吗?
大家好呀,我是猿java。
在分布式系统中,我们经常听到灰度发布这个词,那么,什么是灰度发布?为什么需要灰度发布?如何实现灰度发布?这篇文章,我们来聊一聊。
1. 什么是灰度发布?
简单来说,灰度发布也叫做渐进式发布或金丝雀发布,它是一种逐步将新版本应用到生产环境中的策略。相比于一次性全量发布,灰度发布可以让我们在小范围内先行测试新功能,监控其表现,再决定是否全面推开。这样做的好处是显而易见的:
- 降低风险:新版本如果存在 bug,只影响少部分用户,减少了对整体用户体验的冲击。
- 快速回滚:在小范围内发现问题,可以更快地回到旧版本。
- 收集反馈:可以在真实环境中收集用户反馈,优化新功能。
2. 原理解析
要理解灰度发布,我们需要先了解一下它的基本流程:
- 准备阶段:在生产环境中保留旧版本,同时引入新版本。
- 小范围发布:将新版本先部署到一小部分用户,例如1%-10%。
- 监控与评估:监控新版本的性能和稳定性,收集用户反馈。
- 逐步扩展:如果一切正常,将新版本逐步推广到更多用户。
- 全面切换:当确认新版本稳定后,全面替换旧版本。
在这个过程中,关键在于如何切分流量,确保新旧版本平稳过渡。常见的切分方式包括:
- 基于用户ID:根据用户的唯一标识,将部分用户指向新版本。
- 基于地域:先在特定地区进行发布,观察效果后再扩展到其他地区。
- 基于设备:例如,先在Android或iOS用户中进行发布。
3. 示例演示
为了更好地理解灰度发布,接下来,我们通过一个简单的 Java示例来演示基本的灰度发布策略。假设我们有一个简单的 Web应用,有两个版本的登录接口/login/v1
和/login/v2
,我们希望将百分之十的流量引导到v2
,其余流量继续使用v1
。
3.1 第一步:引入灰度策略
我们可以通过拦截器(Interceptor)来实现流量的切分。以下是一个基于Spring Boot的简单实现:
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Random;
@Component
public class GrayReleaseInterceptor implements HandlerInterceptor {
private static final double GRAY_RELEASE_PERCENT = 0.1; // 10% 流量
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String uri = request.getRequestURI();
if ("/login".equals(uri)) {
if (isGrayRelease()) {
// 重定向到新版本接口
response.sendRedirect("/login/v2");
return false;
} else {
// 使用旧版本接口
response.sendRedirect("/login/v1");
return false;
}
}
return true;
}
private boolean isGrayRelease() {
Random random = new Random();
return random.nextDouble() < GRAY_RELEASE_PERCENT;
}
}
3.2 第二步:配置拦截器
在Spring Boot中,我们需要将拦截器注册到应用中:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.*;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private GrayReleaseInterceptor grayReleaseInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(grayReleaseInterceptor).addPathPatterns("/login");
}
}
3.3 第三步:实现不同版本的登录接口
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/login")
public class LoginController {
@GetMapping("/v1")
public String loginV1(@RequestParam String username, @RequestParam String password) {
// 旧版本登录逻辑
return "登录成功 - v1";
}
@GetMapping("/v2")
public String loginV2(@RequestParam String username, @RequestParam String password) {
// 新版本登录逻辑
return "登录成功 - v2";
}
}
在上面三个步骤之后,我们就实现了登录接口地灰度发布:
- 当用户访问
/login
时,拦截器会根据设定的灰度比例(10%)决定请求被重定向到/login/v1
还是/login/v2
。 - 大部分用户会体验旧版本接口,少部分用户会体验新版本接口。
3.4 灰度发布优化
上述示例,我们只是一个简化的灰度发布实现,实际生产环境中,我们可能需要更精细的灰度策略,例如:
- 基于用户属性:不仅仅是随机切分,可以根据用户的地理位置、设备类型等更复杂的条件。
- 动态配置:通过配置中心动态调整灰度比例,无需重启应用。
- 监控与告警:集成监控系统,实时监控新版本的性能指标,异常时自动回滚。
- A/B 测试:结合A/B测试,进一步优化用户体验和功能效果。
4. 为什么需要灰度发布?
在实际工作中,为什么我们要使用灰度发布?这里我们总结了几个重要的原因。
4.1 降低发布风险
每次发布新版本,尤其是功能性更新或架构调整,都会伴随着一定的风险。即使经过了充分的测试,实际生产环境中仍可能出现意想不到的问题。灰度发布通过将新版本逐步推向部分用户,可以有效降低全量发布可能带来的风险。
举个例子,假设你上线了一个全新的支付功能,直接面向所有用户开放。如果这个功能存在严重 bug,可能导致大量用户无法完成支付,甚至影响公司声誉。而如果采用灰度发布,先让10%的用户体验新功能,发现问题后只需影响少部分用户,修复起来也更为迅速和容易。
4.2 快速回滚
在传统的全量发布中,一旦发现问题,回滚到旧版本可能需要耗费大量时间和精力,尤其是在高并发系统中,数据状态的同步与恢复更是复杂。而灰度发布由于新版本只覆盖部分流量,问题定位和回滚变得更加简单和快速。
比如说,你在灰度发布阶段发现新版本的某个功能在某些特定条件下会导致系统崩溃,立即可以停止向新用户推送这个版本,甚至只针对受影响的用户进行回滚操作,而不用影响全部用户的正常使用。
4.3 实时监控与反馈
灰度发布让你有机会在真实的生产环境中监控新版本的表现,并收集用户的反馈。这些数据对于评估新功能的实际效果至关重要,有助于做出更明智的决策。
举个具体的场景,你新增了一个推荐算法,希望提升用户的点击率。在灰度发布阶段,你可以监控新算法带来的点击率变化、服务器负载情况等指标,确保新算法确实带来了预期的效果,而不是引入了新的问题。
4.4 提升用户体验
通过灰度发布,你可以在推出新功能时,逐步优化用户体验。先让一部分用户体验新功能,收集他们的使用反馈,根据反馈不断改进,最终推出一个更成熟、更符合用户需求的版本。
举个例子,你开发了一项新的用户界面设计,直接全量发布可能会让一部分用户感到不适应或不满意。灰度发布允许你先让一部分用户体验新界面,收集他们的意见,进行必要的调整,再逐步扩大使用范围,确保最终发布的版本能获得更多用户的认可和喜爱。
4.5 支持A/B测试
灰度发布是实现A/B测试的基础。通过将用户随机分配到不同的版本,你可以比较不同版本的表现,选择最优方案进行全面推行。这对于优化产品功能和提升用户体验具有重要意义。
比如说,你想测试两个不同的推荐算法,看哪个能带来更高的转化率。通过灰度发布,将用户随机分配到使用算法A和算法B的版本,比较它们的表现,最终选择效果更好的算法进行全面部署。
4.6 应对复杂的业务需求
在一些复杂的业务场景中,全量发布可能无法满足灵活的需求,比如分阶段推出新功能、针对不同用户群体进行差异化体验等。灰度发布提供了更高的灵活性和可控性,能够更好地适应多变的业务需求。
例如,你正在开发一个面向企业用户的新功能,希望先让部分高价值客户试用,收集他们的反馈后再决定是否全面推广。灰度发布让这一过程变得更加顺畅和可控。
5. 总结
本文,我们详细地分析了灰度发布,它是一种强大而灵活的部署策略,能有效降低新版本上线带来的风险,提高系统的稳定性和用户体验。作为Java开发者,掌握灰度发布的原理和实现方法,不仅能提升我们的技术能力,还能为团队的项目成功保驾护航。
对于灰度发布,如果你有更多的问题或想法,欢迎随时交流!
6. 学习交流
如果你觉得文章有帮助,请帮忙转发给更多的好友,或关注公众号:猿java,持续输出硬核文章。
来源:juejin.cn/post/7488321730764603402
URL地址末尾加不加”/“有什么区别
URL 结尾是否带 /
主要影响的是 服务器如何解析请求 以及 相对路径的解析方式,具体区别如下:
1. 基础概念
- URL(统一资源定位符) :用于唯一标识互联网资源,如网页、图片、API等。
- 目录 vs. 资源:
- 以
/
结尾的 URL 通常表示目录,例如:
https://example.com/folder/
- 不以
/
结尾的 URL 通常指向具体的资源(如文件),例如:
https://example.com/file
- 以
2. 带 /
和不带 /
的具体区别
(1)目录 vs. 资源
https://example.com/folder/
- 服务器通常会将其解析为 目录,并尝试返回该目录下的默认文件(如
index.html
)。
- 服务器通常会将其解析为 目录,并尝试返回该目录下的默认文件(如
https://example.com/folder
- 服务器可能会将其视为 文件,如果
folder
不是文件,而是目录,服务器可能会返回 301 重定向到folder/
。
- 服务器可能会将其视为 文件,如果
📌 示例:
- 访问
https://example.com/blog/
- 服务器可能返回
https://example.com/blog/index.html
。
- 服务器可能返回
- 访问
https://example.com/blog
(如果blog
是个目录)
- 服务器可能重定向到
https://example.com/blog/
,再返回index.html
。
- 服务器可能重定向到
(2)相对路径解析
URL 末尾是否有 /
会影响相对路径的解析。
假设 HTML 页面包含以下 <img>
标签:
<img src="image.png">
📌 示例:
- 访问
https://example.com/folder/
- 访问
https://example.com/folder
- 图片路径解析为
https://example.com/image.png
- 可能导致 404 错误,因为
image.png
在folder/
里,而浏览器错误地去example.com/
下查找。
- 图片路径解析为
原因:
- 以
/
结尾的 URL,浏览器会认为它是一个目录,相对路径会基于folder/
解析。 - 不带
/
,浏览器可能认为folder
是文件,相对路径解析可能会出现错误。
(3)SEO 影响
搜索引擎对 https://example.com/folder/
和 https://example.com/folder
可能会视为两个不同的页面,导致 重复内容问题,影响 SEO 排名。因此:
- 网站通常会选择 一种形式 并用 301 重定向 规范化 URL。
- 例如:
https://example.com/folder
自动跳转 到https://example.com/folder/
。- 反之亦然。
(4)API 请求
对于 RESTful API,带 /
和不带 /
可能导致不同的行为:
https://api.example.com/users
- 可能返回所有用户数据。
https://api.example.com/users/
- 可能返回 404 或者产生不同的结果(取决于服务器实现)。
一些 API 服务器对 /
非常敏感,因此最好遵循 API 文档的规范。
3. 总结
URL 形式 | 作用 | 影响 |
---|---|---|
https://example.com/folder/ | 目录 | 通常返回 folder/ 下的默认文件,如 index.html ,相对路径解析基于 folder/ |
https://example.com/folder | 资源(或重定向) | 可能被解析为文件,或者服务器重定向到 folder/ ,相对路径解析可能错误 |
https://api.example.com/data/ | API 路径 | 可能与 https://api.example.com/data 表现不同,具体由 API 设计决定 |
如果你在开发网站,建议:
- 统一 URL 规则,例如所有目录都加
/
或者所有请求都不加/
,然后用 301 重定向 确保一致性。 - 测试 API 的行为,确认带
/
和不带/
是否影响请求结果。
来源:juejin.cn/post/7468112128928350242
公司来的新人用字符串存储日期,被组长怒怼了...
在日常的软件开发工作中,存储时间是一项基础且常见的需求。无论是记录数据的操作时间、金融交易的发生时间,还是行程的出发时间、用户的下单时间等等,时间信息与我们的业务逻辑和系统功能紧密相关。因此,正确选择和使用 MySQL 的日期时间类型至关重要,其恰当与否甚至可能对业务的准确性和系统的稳定性产生显著影响。
本文旨在帮助开发者重新审视并深入理解 MySQL 中不同的时间存储方式,以便做出更合适项目业务场景的选择。
不要用字符串存储日期
和许多数据库初学者一样,笔者在早期学习阶段也曾尝试使用字符串(如 VARCHAR)类型来存储日期和时间,甚至一度认为这是一种简单直观的方法。毕竟,'YYYY-MM-DD HH:MM:SS' 这样的格式看起来清晰易懂。
但是,这是不正确的做法,主要会有下面两个问题:
- 空间效率:与 MySQL 内建的日期时间类型相比,字符串通常需要占用更多的存储空间来表示相同的时间信息。
- 查询与计算效率低下:
- 比较操作复杂且低效:基于字符串的日期比较需要按照字典序逐字符进行,这不仅不直观(例如,'2024-05-01' 会小于 '2024-1-10'),而且效率远低于使用原生日期时间类型进行的数值或时间点比较。
- 计算功能受限:无法直接利用数据库提供的丰富日期时间函数进行运算(例如,计算两个日期之间的间隔、对日期进行加减操作等),需要先转换格式,增加了复杂性。
- 索引性能不佳:基于字符串的索引在处理范围查询(如查找特定时间段内的数据)时,其效率和灵活性通常不如原生日期时间类型的索引。
DATETIME 和 TIMESTAMP 选择
DATETIME
和 TIMESTAMP
是 MySQL 中两种非常常用的、用于存储包含日期和时间信息的数据类型。它们都可以存储精确到秒(MySQL 5.6.4+ 支持更高精度的小数秒)的时间值。那么,在实际应用中,我们应该如何在这两者之间做出选择呢?
下面我们从几个关键维度对它们进行对比:
时区信息
DATETIME
类型存储的是字面量的日期和时间值,它本身不包含任何时区信息。当你插入一个 DATETIME
值时,MySQL 存储的就是你提供的那个确切的时间,不会进行任何时区转换。
这样就会有什么问题呢? 如果你的应用需要支持多个时区,或者服务器、客户端的时区可能发生变化,那么使用 DATETIME
时,应用程序需要自行处理时区的转换和解释。如果处理不当(例如,假设所有存储的时间都属于同一个时区,但实际环境变化了),可能会导致时间显示或计算上的混乱。
TIMESTAMP
和时区有关。存储时,MySQL 会将当前会话时区下的时间值转换成 UTC(协调世界时)进行内部存储。当查询 TIMESTAMP
字段时,MySQL 又会将存储的 UTC 时间转换回当前会话所设置的时区来显示。
这意味着,对于同一条记录的 TIMESTAMP
字段,在不同的会话时区设置下查询,可能会看到不同的本地时间表示,但它们都对应着同一个绝对时间点(UTC 时间)。这对于需要全球化、多时区支持的应用来说非常有用。
下面实际演示一下!
建表 SQL 语句:
CREATE TABLE `time_zone_test` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`date_time` datetime DEFAULT NULL,
`time_stamp` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
插入一条数据(假设当前会话时区为系统默认,例如 UTC+0)::
INSERT INTO time_zone_test(date_time,time_stamp) VALUES(NOW(),NOW());
查询数据(在同一时区会话下):
SELECT date_time, time_stamp FROM time_zone_test;
结果:
+---------------------+---------------------+
| date_time | time_stamp |
+---------------------+---------------------+
| 2020-01-11 09:53:32 | 2020-01-11 09:53:32 |
+---------------------+---------------------+
现在,修改当前会话的时区为东八区 (UTC+8):
SET time_zone = '+8:00';
再次查询数据:
# TIMESTAMP 的值自动转换为 UTC+8 时间
+---------------------+---------------------+
| date_time | time_stamp |
+---------------------+---------------------+
| 2020-01-11 09:53:32 | 2020-01-11 17:53:32 |
+---------------------+---------------------+
扩展:MySQL 时区设置常用 SQL 命令
# 查看当前会话时区
SELECT @@session.time_zone;
# 设置当前会话时区
SET time_zone = 'Europe/Helsinki';
SET time_zone = "+00:00";
# 数据库全局时区设置
SELECT @@global.time_zone;
# 设置全局时区
SET GLOBAL time_zone = '+8:00';
SET GLOBAL time_zone = 'Europe/Helsinki';
占用空间
下图是 MySQL 日期类型所占的存储空间(官方文档传送门:dev.mysql.com/doc/refman/…):
在 MySQL 5.6.4 之前,DateTime 和 TIMESTAMP 的存储空间是固定的,分别为 8 字节和 4 字节。但是从 MySQL 5.6.4 开始,它们的存储空间会根据毫秒精度的不同而变化,DateTime 的范围是 58 字节,TIMESTAMP 的范围是 47 字节。
表示范围
TIMESTAMP
表示的时间范围更小,只能到 2038 年:
DATETIME
:'1000-01-01 00:00:00.000000' 到 '9999-12-31 23:59:59.999999'TIMESTAMP
:'1970-01-01 00:00:01.000000' UTC 到 '2038-01-19 03:14:07.999999' UTC
性能
由于 TIMESTAMP
在存储和检索时需要进行 UTC 与当前会话时区的转换,这个过程可能涉及到额外的计算开销,尤其是在需要调用操作系统底层接口获取或处理时区信息时。虽然现代数据库和操作系统对此进行了优化,但在某些极端高并发或对延迟极其敏感的场景下,DATETIME
因其不涉及时区转换,处理逻辑相对更简单直接,可能会表现出微弱的性能优势。
为了获得可预测的行为并可能减少 TIMESTAMP
的转换开销,推荐的做法是在应用程序层面统一管理时区,或者在数据库连接/会话级别显式设置 time_zone
参数,而不是依赖服务器的默认或操作系统时区。
数值时间戳是更好的选择吗?
除了上述两种类型,实践中也常用整数类型(INT
或 BIGINT
)来存储所谓的“Unix 时间戳”(即从 1970 年 1 月 1 日 00:00:00 UTC 起至目标时间的总秒数,或毫秒数)。
这种存储方式的具有 TIMESTAMP
类型的所具有一些优点,并且使用它的进行日期排序以及对比等操作的效率会更高,跨系统也很方便,毕竟只是存放的数值。缺点也很明显,就是数据的可读性太差了,你无法直观的看到具体时间。
时间戳的定义如下:
时间戳的定义是从一个基准时间开始算起,这个基准时间是「1970-1-1 00:00:00 +0:00」,从这个时间开始,用整数表示,以秒计时,随着时间的流逝这个时间整数不断增加。这样一来,我只需要一个数值,就可以完美地表示时间了,而且这个数值是一个绝对数值,即无论的身处地球的任何角落,这个表示时间的时间戳,都是一样的,生成的数值都是一样的,并且没有时区的概念,所以在系统的中时间的传输中,都不需要进行额外的转换了,只有在显示给用户的时候,才转换为字符串格式的本地时间。
数据库中实际操作:
-- 将日期时间字符串转换为 Unix 时间戳 (秒)
mysql> SELECT UNIX_TIMESTAMP('2020-01-11 09:53:32');
+---------------------------------------+
| UNIX_TIMESTAMP('2020-01-11 09:53:32') |
+---------------------------------------+
| 1578707612 |
+---------------------------------------+
1 row in set (0.00 sec)
-- 将 Unix 时间戳 (秒) 转换为日期时间格式
mysql> SELECT FROM_UNIXTIME(1578707612);
+---------------------------+
| FROM_UNIXTIME(1578707612) |
+---------------------------+
| 2020-01-11 09:53:32 |
+---------------------------+
1 row in set (0.01 sec)
PostgreSQL 中没有 DATETIME
由于有读者提到 PostgreSQL(PG) 的时间类型,因此这里拓展补充一下。PG 官方文档对时间类型的描述地址:http://www.postgresql.org/docs/curren…。
可以看到,PG 没有名为 DATETIME
的类型:
- PG 的
TIMESTAMP WITHOUT TIME ZONE
在功能上最接近 MySQL 的DATETIME
。它存储日期和时间,但不包含任何时区信息,存储的是字面值。 - PG 的
TIMESTAMP WITH TIME ZONE
(或TIMESTAMPTZ
) 相当于 MySQL 的TIMESTAMP
。它在存储时会将输入值转换为 UTC,并在检索时根据当前会话的时区进行转换显示。
对于绝大多数需要记录精确发生时间点的应用场景,TIMESTAMPTZ
是 PostgreSQL 中最推荐、最健壮的选择,因为它能最好地处理时区复杂性。
总结
MySQL 中时间到底怎么存储才好?DATETIME
?TIMESTAMP
?还是数值时间戳?
并没有一个银弹,很多程序员会觉得数值型时间戳是真的好,效率又高还各种兼容,但是很多人又觉得它表现的不够直观。
《高性能 MySQL 》这本神书的作者就是推荐 TIMESTAMP,原因是数值表示时间不够直观。下面是原文:

每种方式都有各自的优势,根据实际场景选择最合适的才是王道。下面再对这三种方式做一个简单的对比,以供大家实际开发中选择正确的存放时间的数据类型:
类型 | 存储空间 | 日期格式 | 日期范围 | 是否带时区信息 |
---|---|---|---|---|
DATETIME | 5~8 字节 | YYYY-MM-DD hh:mm:ss[.fraction] | 1000-01-01 00:00:00[.000000] ~ 9999-12-31 23:59:59[.999999] | 否 |
TIMESTAMP | 4~7 字节 | YYYY-MM-DD hh:mm:ss[.fraction] | 1970-01-01 00:00:01[.000000] ~ 2038-01-19 03:14:07[.999999] | 是 |
数值型时间戳 | 4 字节 | 全数字如 1578707612 | 1970-01-01 00:00:01 之后的时间 | 否 |
选择建议小结:
TIMESTAMP
的核心优势在于其内建的时区处理能力。数据库负责 UTC 存储和基于会话时区的自动转换,简化了需要处理多时区应用的开发。如果应用需要处理多时区,或者希望数据库能自动管理时区转换,TIMESTAMP
是自然的选择(注意其时间范围限制,也就是 2038 年问题)。- 如果应用场景不涉及时区转换,或者希望应用程序完全控制时区逻辑,并且需要表示 2038 年之后的时间,
DATETIME
是更稳妥的选择。 - 如果极度关注比较性能,或者需要频繁跨系统传递时间数据,并且可以接受可读性的牺牲(或总是在应用层转换),数值时间戳是一个强大的选项。
来源:juejin.cn/post/7488927722774937609
websocket和socket有什么区别?
WebSocket 和 Socket 的区别
WebSocket 和 Socket 是两种不同的网络通信技术,它们在使用场景、协议、功能等方面有显著的差异。以下是它们之间的主要区别:
1. 定义
- Socket:Socket 是一种网络通信的工具,可以实现不同计算机之间的数据交换。它是操作系统提供的 API,广泛应用于 TCP/IP 网络编程中。Socket 可以是流式(TCP)或数据报(UDP)类型的,用于低层次的网络通信。
- WebSocket:WebSocket 是一种在单个 TCP 连接上进行全双工通信的协议。它允许服务器和客户端之间实时地交换数据。WebSocket 是建立在 HTTP 协议之上的,主要用于 Web 应用程序,以实现实时数据传输。
2. 协议层次
- Socket:Socket 是一种底层通信机制,通常与 TCP/IP 协议一起使用。它允许开发者通过编程语言直接访问网络接口。
- WebSocket:WebSocket 是一种应用层协议,建立在 HTTP 之上。在初始握手时,使用 HTTP 协议进行连接,之后切换到 WebSocket 协议进行数据传输。
3. 连接方式
- Socket:Socket 通常需要手动管理连接的建立和关闭。通过调用相关的 API,开发者需要处理连接的状态,确保数据的可靠传输。
- WebSocket:WebSocket 的连接管理相对简单。建立连接后,不需要频繁地进行握手,可以保持持久连接,随时进行数据交换。
4. 数据传输模式
- Socket:Socket 可以实现单向或双向的数据传输,但通常需要在发送和接收之间进行明确的控制。
- WebSocket:WebSocket 支持全双工通信,客户端和服务器之间可以随时互相发送数据,无需等待响应。这使得实时通信变得更加高效。
5. 适用场景
- Socket:Socket 常用于需要高性能、低延迟的场景,如游戏开发、文件传输、P2P 网络等。由于其底层特性,Socket 适合对网络性能有严格要求的应用。
- WebSocket:WebSocket 主要用于 Web 应用程序,如即时聊天、实时通知、在线游戏等。由于其易用性和高效性,WebSocket 特别适合需要实时更新和交互的前端应用。
6. 数据格式
- Socket:Socket 发送的数据通常是二进制流或文本流,需要开发者自行定义数据格式和解析方式。
- WebSocket:WebSocket 支持多种数据格式,包括文本(如 JSON)和二进制(如 Blob、ArrayBuffer)。WebSocket 的数据传输格式非常灵活,易于与 JavaScript 进行交互。
7. 性能
- Socket:Socket 对于大量并发连接的处理性能较高,但需要开发者进行优化和管理。
- WebSocket:WebSocket 在建立连接后可以保持长连接,减少了握手带来的延迟,适合高频率的数据交换场景。
8. 安全性
- Socket:Socket 的安全性取决于使用的协议(如 TCP、UDP)和应用层的实现。开发者需要自行处理安全问题,如加密和身份验证。
- WebSocket:WebSocket 支持通过 WSS(WebSocket Secure)进行加密,提供更高层次的安全保障。它可以很好地与 HTTPS 集成,确保数据在传输过程中的安全性。
9. 浏览器支持
- Socket:Socket 是底层的网络通信技术,通常不直接在浏览器中使用。Web 开发者需要通过后端语言(如 Node.js、Java、Python)来实现 Socket 通信。
- WebSocket:WebSocket 是专为 Web 应用设计的,所有现代浏览器均支持 WebSocket 协议,开发者可以直接在客户端使用 JavaScript API 进行通信。
10. 工具和库
- Socket:使用 Socket 进行开发时,开发者通常需要使用底层网络编程库,如 BSD Sockets、Java Sockets、Python's socket 模块等。
- WebSocket:WebSocket 提供了简单的 API,开发者可以使用原生 JavaScript 或第三方库(如 Socket.IO)轻松实现 WebSocket 通信。
结论
总结来说,WebSocket 是一种为现代 Web 应用量身定制的协议,具有实时、双向通信的优势,而 Socket 是一种底层的网络通信机制,提供更灵活的使用方式。选择使用哪种技术取决于具体的应用场景和需求。对于需要实时交互的 Web 应用,WebSocket 是更合适的选择;而对于底层或高性能要求的网络通信,Socket 提供了更多的控制和灵活性。
来源:juejin.cn/post/7485631488114278454
完蛋,被扣工资了,都是JSON惹的祸
JSON是一种轻量级的数据交换格式,基于ECMAScript的一个子集设计,采用完全独立于编程语言的文本格式来表示数据。它易于人类阅读和编写,同时也便于机器解析和生成,这使得JSON在数据交换中具有高效性。
JSON也就成了每一个程序员每天都要使用一个小类库。无论你使用的谷歌的gson
,阿里巴巴的fastjson
,框架自带的jackjson
,还是第三方的hutool的json等。总之,每天都要和他打交道。
但是,却在阴沟里翻了船。
1、平平无奇的接口
/**
* 获取vehicleinfo 信息
*
* @RequestParam vehicleId
* @return Vehicle的json字符串
*/
String loadVehicleInfo(Integer vehicleId);
该接口就是通过一个vehicleId
参数获取Vehicle
对象,返回的数据是Vehicle
的JSON字符串,也就是将获取的对象信息序列化成JSON字符串了。
2、无懈可击的引用
String jsonStr = auctVehicleService.loadVehicleInfo(freezeDetail.getVehicle().getId());
if (StringUtils.isNotBlank(jsonStr)) {
Vehicle vehicle = JSON.parseObject(jsonStr, Vehicle.class);
if (vehicle != null) {
// 后续省略 ...
}
}
看似无懈可击的引用,隐藏着魔鬼。为什么无懈可击,因为做了健壮性的判断,非空字符串、非空对象等的判断,根除了空指针异常。
但是,魔鬼隐藏在哪里呢?
3、故障引发
线上直接出现类似的故障(此报错信息为线下模拟)。
现在测试为什么没有问题:主要的测试了基础数据,测试的数据中恰好没有Date
类型的数据,所以线下没有测出来。
4、故障原因分析
从报错日志可以看出,是因为日期类型的参数导致的。Mar 24, 2025 1:23:10 PM
这样的日期格式无法使用Fastjson
解析。
深入代码查看:
@Override
public String loadVehicleInfo(Integer vehicleId) {
String key = VEHICLE_KEY + vehicleId;
Object obj = cacheService.get(key);
if (null != obj && StringUtils.isNotEmpty(obj.toString())
&& !"null".equals(obj.toString())) {
String result = (String)obj;
return result;
}
String json = null;
try {
Vehicle vInfo = overrideVehicleAttributes(vehicleId);
// 使用了Gson序列化对象
json = gson.toJson(vInfo);
cacheService.setExpireSec(key, gson.toJson(vInfo), 5 * 60);
} catch (Exception e) {
cacheService.setExpireSec(key, "", 1 * 60);
} finally {
}
return json;
}
原来接口的实现里面采用了谷歌的Gson
对返回的对象做了序列化。调用的地方又使用了阿里巴巴的Fastjson
发序列化,导致参数解析异常。
完蛋,上榜是要被扣工资的!!!
5、小结
问题虽小,但是影响却很大。坊间一直讨论着,程序员为什么不能写出没有bug的程序。这也许是其中的一种答案吧。
肉疼,被扣钱了!!!
--END--
喜欢就点赞收藏,也可以关注我的微信公众号:编程朝花夕拾
来源:juejin.cn/post/7485560281955958794
JDK 24 发布,新特性解读!
真快啊!Java 24 这两天已经正式发布啦!这是自 Java 21 以来的第三个非长期支持版本,和 Java 22、Java 23一样。
下一个长期支持版是 Java 25,预计今年 9 月份发布。
Java 24 带来的新特性还是蛮多的,一共 24 个。Java 23 和 Java 23 都只有 12 个,Java 24的新特性相当于这两次的总和了。因此,这个版本还是非常有必要了解一下的。
下图是从 JDK8 到 JDK 24 每个版本的更新带来的新特性数量和更新时间:
我在昨天晚上详细看了一下 Java 24 的详细更新,并对其中比较重要的新特性做了详细的解读,希望对你有帮助!
本文内容概览:
JEP 478: 密钥派生函数 API(预览)
密钥派生函数 API 是一种用于从初始密钥和其他数据派生额外密钥的加密算法。它的核心作用是为不同的加密目的(如加密、认证等)生成多个不同的密钥,避免密钥重复使用带来的安全隐患。 这在现代加密中是一个重要的里程碑,为后续新兴的量子计算环境打下了基础
通过该 API,开发者可以使用最新的密钥派生算法(如 HKDF 和未来的 Argon2):
// 创建一个 KDF 对象,使用 HKDF-SHA256 算法
KDF hkdf = KDF.getInstance("HKDF-SHA256");
// 创建 Extract 和 Expand 参数规范
AlgorithmParameterSpec params =
HKDFParameterSpec.ofExtract()
.addIKM(initialKeyMaterial) // 设置初始密钥材料
.addSalt(salt) // 设置盐值
.thenExpand(info, 32); // 设置扩展信息和目标长度
// 派生一个 32 字节的 AES 密钥
SecretKey key = hkdf.deriveKey("AES", params);
// 可以使用相同的 KDF 对象进行其他密钥派生操作
JEP 483: 提前类加载和链接
在传统 JVM 中,应用在每次启动时需要动态加载和链接类。这种机制对启动时间敏感的应用(如微服务或无服务器函数)带来了显著的性能瓶颈。该特性通过缓存已加载和链接的类,显著减少了重复工作的开销,显著减少 Java 应用程序的启动时间。测试表明,对大型应用(如基于 Spring 的服务器应用),启动时间可减少 40% 以上。
这个优化是零侵入性的,对应用程序、库或框架的代码无需任何更改,启动也方式保持一致,仅需添加相关 JVM 参数(如 -XX:+ClassDataSharing
)。
JEP 484: 类文件 API
类文件 API 在 JDK 22 进行了第一次预览(JEP 457),在 JDK 23 进行了第二次预览并进一步完善(JEP 466)。最终,该特性在 JDK 24 中顺利转正。
类文件 API 的目标是提供一套标准化的 API,用于解析、生成和转换 Java 类文件,取代过去对第三方库(如 ASM)在类文件处理上的依赖。
// 创建一个 ClassFile 对象,这是操作类文件的入口。
ClassFile cf = ClassFile.of();
// 解析字节数组为 ClassModel
ClassModel classModel = cf.parse(bytes);
// 构建新的类文件,移除以 "debug" 开头的所有方法
byte[] newBytes = cf.build(classModel.thisClass().asSymbol(),
classBuilder -> {
// 遍历所有类元素
for (ClassElement ce : classModel) {
// 判断是否为方法 且 方法名以 "debug" 开头
if (!(ce instanceof MethodModel mm
&& mm.methodName().stringValue().startsWith("debug"))) {
// 添加到新的类文件中
classBuilder.with(ce);
}
}
});
JEP 485: 流收集器
流收集器 Stream::gather(Gatherer)
是一个强大的新特性,它允许开发者定义自定义的中间操作,从而实现更复杂、更灵活的数据转换。Gatherer
接口是该特性的核心,它定义了如何从流中收集元素,维护中间状态,并在处理过程中生成结果。
与现有的 filter
、map
或 distinct
等内置操作不同,Stream::gather
使得开发者能够实现那些难以用标准 Stream 操作完成的任务。例如,可以使用 Stream::gather
实现滑动窗口、自定义规则的去重、或者更复杂的状态转换和聚合。 这种灵活性极大地扩展了 Stream API 的应用范围,使开发者能够应对更复杂的数据处理场景。
基于 Stream::gather(Gatherer)
实现字符串长度的去重逻辑:
var result = Stream.of("foo", "bar", "baz", "quux")
.gather(Gatherer.ofSequential(
HashSet::new, // 初始化状态为 HashSet,用于保存已经遇到过的字符串长度
(set, str, downstream) -> {
if (set.add(str.length())) {
return downstream.push(str);
}
return true; // 继续处理流
}
))
.toList();// 转换为列表
// 输出结果 ==> [foo, quux]
JEP 486: 永久禁用安全管理器
JDK 24 不再允许启用 Security Manager
,即使通过 java -Djava.security.manager
命令也无法启用,这是逐步移除该功能的关键一步。虽然 Security Manager
曾经是 Java 中限制代码权限(如访问文件系统或网络、读取或写入敏感文件、执行系统命令)的重要工具,但由于复杂性高、使用率低且维护成本大,Java 社区决定最终移除它。
JEP 487: 作用域值 (第四次预览)
作用域值(Scoped Values)可以在线程内和线程间共享不可变的数据,优于线程局部变量,尤其是在使用大量虚拟线程时。
final static ScopedValue<...> V = new ScopedValue<>();
// In some method
ScopedValue.where(V, <value>)
.run(() -> { ... V.get() ... call methods ... });
// In a method called directly or indirectly from the lambda expression
... V.get() ...
作用域值允许在大型程序中的组件之间安全有效地共享数据,而无需求助于方法参数。
JEP 491: 虚拟线程的同步而不固定平台线程
优化了虚拟线程与 synchronized
的工作机制。 虚拟线程在 synchronized
方法和代码块中阻塞时,通常能够释放其占用的操作系统线程(平台线程),避免了对平台线程的长时间占用,从而提升应用程序的并发能力。 这种机制避免了“固定 (Pinning)”——即虚拟线程长时间占用平台线程,阻止其服务于其他虚拟线程的情况。
现有的使用 synchronized
的 Java 代码无需修改即可受益于虚拟线程的扩展能力。 例如,一个 I/O 密集型的应用程序,如果使用传统的平台线程,可能会因为线程阻塞而导致并发能力下降。 而使用虚拟线程,即使在 synchronized
块中发生阻塞,也不会固定平台线程,从而允许平台线程继续服务于其他虚拟线程,提高整体的并发性能。
JEP 493:在没有 JMOD 文件的情况下链接运行时镜像
默认情况下,JDK 同时包含运行时镜像(运行时所需的模块)和 JMOD 文件。这个特性使得 jlink 工具无需使用 JDK 的 JMOD 文件就可以创建自定义运行时镜像,减少了 JDK 的安装体积(约 25%)。
说明:
- Jlink 是随 Java 9 一起发布的新命令行工具。它允许开发人员为基于模块的 Java 应用程序创建自己的轻量级、定制的 JRE。
- JMOD 文件是 Java 模块的描述文件,包含了模块的元数据和资源。
JEP 495: 简化的源文件和实例主方法(第四次预览)
这个特性主要简化了 main
方法的的声明。对于 Java 初学者来说,这个 main
方法的声明引入了太多的 Java 语法概念,不利于初学者快速上手。
没有使用该特性之前定义一个 main
方法:
public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello, World!");
}
}
使用该新特性之后定义一个 main
方法:
class HelloWorld {
void main() {
System.out.println("Hello, World!");
}
}
进一步简化(未命名的类允许我们省略类名)
void main() {
System.out.println("Hello, World!");
}
JEP 497: 量子抗性数字签名算法 (ML-DSA)
JDK 24 引入了支持实施抗量子的基于模块晶格的数字签名算法 (Module-Lattice-Based Digital Signature Algorithm, ML-DSA),为抵御未来量子计算机可能带来的威胁做准备。
ML-DSA 是美国国家标准与技术研究院(NIST)在 FIPS 204 中标准化的量子抗性算法,用于数字签名和身份验证。
JEP 498: 使用 sun.misc.Unsafe
内存访问方法时发出警告
JDK 23(JEP 471) 提议弃用 sun.misc.Unsafe
中的内存访问方法,这些方法将来的版本中会被移除。在 JDK 24 中,当首次调用 sun.misc.Unsafe
的任何内存访问方法时,运行时会发出警告。
这些不安全的方法已有安全高效的替代方案:
java.lang.invoke.VarHandle
:JDK 9 (JEP 193) 中引入,提供了一种安全有效地操作堆内存的方法,包括对象的字段、类的静态字段以及数组元素。java.lang.foreign.MemorySegment
:JDK 22 (JEP 454) 中引入,提供了一种安全有效地访问堆外内存的方法,有时会与VarHandle
协同工作。
这两个类是 Foreign Function & Memory API(外部函数和内存 API) 的核心组件,分别用于管理和操作堆外内存。Foreign Function & Memory API 在 JDK 22 中正式转正,成为标准特性。
import jdk.incubator.foreign.*;
import java.lang.invoke.VarHandle;
// 管理堆外整数数组的类
class OffHeapIntBuffer {
// 用于访问整数元素的VarHandle
private static final VarHandle ELEM_VH = ValueLayout.JAVA_INT.arrayElementVarHandle();
// 内存管理器
private final Arena arena;
// 堆外内存段
private final MemorySegment buffer;
// 构造函数,分配指定数量的整数空间
public OffHeapIntBuffer(long size) {
this.arena = Arena.ofShared();
this.buffer = arena.allocate(ValueLayout.JAVA_INT, size);
}
// 释放内存
public void deallocate() {
arena.close();
}
// 以volatile方式设置指定索引的值
public void setVolatile(long index, int value) {
ELEM_VH.setVolatile(buffer, 0L, index, value);
}
// 初始化指定范围的元素为0
public void initialize(long start, long n) {
buffer.asSlice(ValueLayout.JAVA_INT.byteSize() * start,
ValueLayout.JAVA_INT.byteSize() * n)
.fill((byte) 0);
}
// 将指定范围的元素复制到新数组
public int[] copyToNewArray(long start, int n) {
return buffer.asSlice(ValueLayout.JAVA_INT.byteSize() * start,
ValueLayout.JAVA_INT.byteSize() * n)
.toArray(ValueLayout.JAVA_INT);
}
}
JEP 499: 结构化并发(第四次预览)
JDK 19 引入了结构化并发,一种多线程编程方法,目的是为了通过结构化并发 API 来简化多线程编程,并不是为了取代java.util.concurrent
,目前处于孵化器阶段。
结构化并发将不同线程中运行的多个任务视为单个工作单元,从而简化错误处理、提高可靠性并增强可观察性。也就是说,结构化并发保留了单线程代码的可读性、可维护性和可观察性。
结构化并发的基本 API 是StructuredTaskScope
,它支持将任务拆分为多个并发子任务,在它们自己的线程中执行,并且子任务必须在主任务继续之前完成。
StructuredTaskScope
的基本用法如下:
try (var scope = new StructuredTaskScope<Object>()) {
// 使用fork方法派生线程来执行子任务
Future<Integer> future1 = scope.fork(task1);
Future<String> future2 = scope.fork(task2);
// 等待线程完成
scope.join();
// 结果的处理可能包括处理或重新抛出异常
... process results/exceptions ...
} // close
结构化并发非常适合虚拟线程,虚拟线程是 JDK 实现的轻量级线程。许多虚拟线程共享同一个操作系统线程,从而允许非常多的虚拟线程。
Java 新特性系列解读
如果你想系统了解 Java 8 以及之后版本的新特性,可以在 JavaGuide 上阅读对应的文章:
比较推荐这几篇:
来源:juejin.cn/post/7483478667143626762
年少不知自增好,错把UUID当个宝!!!
在 MySQL 中,使用 UUID 作为主键 在大表中可能会导致性能问题,尤其是在插入和修改数据时效率较低。以下是详细的原因分析,以及为什么修改数据会导致索引刷新,以及字符主键为什么效率较低。
1. UUID 作为主键的问题
(1)UUID 的特性
- UUID 是一个 128 位的字符串,通常表示为 36 个字符(例如:
550e8400-e29b-41d4-a716-446655440000
)。 - UUID 是全局唯一的,适合分布式系统中生成唯一标识。
(2)UUID 作为主键的缺点
1. 索引效率低
- 索引大小:UUID 是字符串类型,占用空间较大(36 字节),而整型主键(如
BIGINT
)仅占用 8 字节。索引越大,存储和查询的效率越低。 - 索引分裂:UUID 是无序的,插入新数据时,可能会导致索引树频繁分裂和重新平衡,影响性能。
2. 插入性能差
- 随机性:UUID 是无序的,每次插入新数据时,新记录可能会插入到索引树的任意位置,导致索引树频繁调整。
- 页分裂:InnoDB 存储引擎使用 B+ 树作为索引结构,随机插入会导致页分裂,增加磁盘 I/O 操作。
3. 查询性能差
- 比较效率低:字符串比较比整型比较慢,尤其是在大表中,查询性能会显著下降。
- 索引扫描范围大:UUID 索引占用的空间大,导致索引扫描的范围更大,查询效率降低。
2. 修改数据导致索引刷新的原因
(1)索引的作用
- 索引是为了加速查询而创建的数据结构(如 B+ 树)。
- 当数据被修改时,索引也需要同步更新,以保持数据的一致性。
(2)修改数据对索引的影响
- 更新主键:
- 如果修改了主键值,MySQL 需要删除旧的主键索引记录,并插入新的主键索引记录。
- 这个过程会导致索引树的调整,增加磁盘 I/O 操作。
- 更新非主键列:
- 如果修改的列是索引列(如唯一索引、普通索引),MySQL 需要更新对应的索引记录。
- 这个过程也会导致索引树的调整。
(3)UUID 主键的额外开销
- 由于 UUID 是无序的,修改主键值时,新值可能会插入到索引树的不同位置,导致索引树频繁调整。
- 相比于有序的主键(如自增 ID),UUID 主键的修改操作代价更高。
3. 字符主键导致效率降低的原因
(1)存储空间大
- 字符主键(如 UUID)占用的存储空间比整型主键大。
- 索引的大小直接影响查询性能,索引越大,查询时需要的磁盘 I/O 操作越多。
(2)比较效率低
- 字符串比较比整型比较慢,尤其是在大表中,查询性能会显著下降。
- 例如,
WHERE id = '550e8400-e29b-41d4-a716-446655440000'
的效率低于WHERE id = 12345
。
(3)索引分裂
- 字符主键通常是无序的,插入新数据时,可能会导致索引树频繁分裂和重新平衡,影响性能。
4. 如何优化 UUID 主键的性能
(1)使用有序 UUID
- 使用有序 UUID(如
UUIDv7
),减少索引分裂和页分裂。 - 有序 UUID 的生成方式可以基于时间戳,保证插入顺序。
(2)将 UUID 存储为二进制
- 将 UUID 存储为
BINARY(16)
而不是CHAR(36)
,减少存储空间。
CREATE TABLE users (
id BINARY(16) PRIMARY KEY,
name VARCHAR(255)
);
(3)使用自增主键 + UUID
- 使用自增主键作为物理主键,UUID 作为逻辑主键。
CREATE TABLE users (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
uuid CHAR(36) UNIQUE,
name VARCHAR(255)
);
(4)分区表
- 对大表进行分区,减少单个索引树的大小,提高查询性能。
~Summary
- UUID 作为主键的缺点:
- 索引效率低,插入和查询性能差。
- 修改数据时,索引需要频繁刷新,导致性能下降。
- 字符主键效率低的原因:
- 存储空间大,比较效率低,索引分裂频繁。
- 优化建议:
- 使用有序 UUID 或二进制存储。
- 结合自增主键和 UUID。
- 对大表进行分区。
来源:juejin.cn/post/7478495083374559270
如何限制一个账号只能在一处登录
如何限制一个账号只能在一处登录
要实现账号单点登录(一处登录限制),需结合 会话管理、实时状态同步 和 冲突处理机制。以下是完整技术方案:
一、核心实现方案
- 服务端会话控制(推荐)
// 用户登录时生成唯一令牌并记录
public String login(String username, String password) {
// 1. 验证账号密码
User user = userService.authenticate(username, password);
// 2. 生成新令牌并失效旧会话
String newToken = UUID.randomUUID().toString();
redis.del("user:" + user.getId() + ":token"); // 清除旧token
redis.setex("user:" + user.getId() + ":token", 3600, newToken);
// 3. 返回新令牌
return newToken;
}
- WebSocket实时踢出(增强体验)
// 前端建立长连接
const socket = new WebSocket(`wss://api.example.com/ws?token=${token}`);
socket.onmessage = (event) => {
if (event.data === 'force_logout') {
alert('您的账号在其他设备登录');
location.href = '/logout';
}
};
- 登录设备指纹识别
// 生成设备指纹(前端)
function generateDeviceFingerprint() {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
ctx.textBaseline = 'top';
ctx.font = "14px Arial";
ctx.fillText("BrowserFingerprint", 2, 2);
return canvas.toDataURL().hashCode(); // 简化示例
}
// 服务端校验
if (storedFingerprint != currentFingerprint) {
forceLogout(storedToken);
}
要实现账号单点登录(一处登录限制),需结合 会话管理、实时状态同步 和 冲突处理机制。以下是完整技术方案:
一、核心实现方案
- 服务端会话控制(推荐)
// 用户登录时生成唯一令牌并记录
public String login(String username, String password) {
// 1. 验证账号密码
User user = userService.authenticate(username, password);
// 2. 生成新令牌并失效旧会话
String newToken = UUID.randomUUID().toString();
redis.del("user:" + user.getId() + ":token"); // 清除旧token
redis.setex("user:" + user.getId() + ":token", 3600, newToken);
// 3. 返回新令牌
return newToken;
}
- WebSocket实时踢出(增强体验)
// 前端建立长连接
const socket = new WebSocket(`wss://api.example.com/ws?token=${token}`);
socket.onmessage = (event) => {
if (event.data === 'force_logout') {
alert('您的账号在其他设备登录');
location.href = '/logout';
}
};
// 生成设备指纹(前端)
function generateDeviceFingerprint() {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
ctx.textBaseline = 'top';
ctx.font = "14px Arial";
ctx.fillText("BrowserFingerprint", 2, 2);
return canvas.toDataURL().hashCode(); // 简化示例
}
// 服务端校验
if (storedFingerprint != currentFingerprint) {
forceLogout(storedToken);
}
二、多端适配策略
客户端类型 | 实现方案 |
---|---|
Web浏览器 | JWT令牌 + Redis黑名单 |
移动端APP | 设备ID绑定 + FCM/iMessage推送踢出 |
桌面应用 | 硬件指纹 + 本地令牌失效检测 |
微信小程序 | UnionID绑定 + 服务端订阅消息 |
三、关键代码实现
- JWT令牌增强方案
// 生成带设备信息的JWT
public String generateToken(User user, String deviceId) {
return Jwts.builder()
.setSubject(user.getId())
.claim("device", deviceId) // 绑定设备
.setExpiration(new Date(System.currentTimeMillis() + 3600000))
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
}
// 校验令牌时检查设备
public boolean validateToken(String token, String currentDevice) {
Claims claims = Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
return claims.get("device").equals(currentDevice);
}
- Redis实时状态管理
# 使用Redis Hash存储登录状态
def login(user_id, token, device_info):
# 删除该用户所有活跃会话
r.delete(f"user_sessions:{user_id}")
# 记录新会话
r.hset(f"user_sessions:{user_id}",
mapping={
"token": token,
"device": device_info,
"last_active": datetime.now()
})
r.expire(f"user_sessions:{user_id}", 3600)
# 中间件校验
def check_token(request):
user_id = get_user_id_from_token(request.token)
stored_token = r.hget(f"user_sessions:{user_id}", "token")
if stored_token != request.token:
raise ForceLogoutError()
四、异常处理机制
场景 | 处理方案 |
---|---|
网络延迟冲突 | 采用CAS(Compare-And-Swap)原子操作更新令牌 |
令牌被盗用 | 触发二次验证(短信/邮箱验证码) |
多设备同时登录 | 后登录者优先,前会话立即失效(可配置为保留第一个登录) |
五、性能与安全优化
- 会话同步优化:
# Redis Pub/Sub 跨节点同步
PUBLISH user:123 "LOGOUT"
- 安全增强:
// 前端敏感操作二次确认
function sensitiveOperation() {
if (loginTime < lastServerCheckTime) {
showReauthModal();
}
}
- 监控看板:
指标 报警阈值 并发登录冲突率 >5%/分钟 强制踢出成功率 <99%
六、行业实践参考
- 金融级方案:
- 每次操作都验证设备指纹
- 异地登录需视频人工审核
- 社交应用方案:
- 允许最多3个设备在线
- 分设备类型控制(手机+PC+平板)
- ERP系统方案:
- 绑定特定MAC地址
- VPN网络白名单限制
通过以上方案可实现:
- 严格模式:后登录者踢出前会话(适合银行系统)
- 宽松模式:多设备在线但通知告警(适合社交应用)
- 混合模式:关键操作时强制单设备(适合电商系统)
部署建议:
- 根据业务需求选择合适严格度
- 关键系统增加异地登录二次验证
- 用户界面明确显示登录设备列表
来源:juejin.cn/post/7485384798569250868
Sa-Token v1.41.0 发布 🚀,来看看有没有令你心动的功能!
Sa-Token 是一个轻量级 Java 权限认证框架,主要解决:登录认证、权限认证、单点登录、OAuth2.0、微服务网关鉴权 等一系列权限相关问题。🔐
目前最新版本 v1.41.0
已推送至 Maven
中央仓库 🎉,大家可以通过如下方式引入:
<!-- Sa-Token 权限认证 -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot-starter</artifactId>
<version>1.41.0</version>
</dependency>
该版本包含大量 ⛏️️️新增特性、⛏️底层重构、⛏️️️代码优化 等,下面容我列举几条比较重要的更新内容供大家参阅:
🛡️ 更新点1:防火墙模块新增 hooks 扩展机制
本次更新针对防火墙新增了多条校验规则,之前的规则为:
- path 白名单放行。
- path 黑名单拦截。
- path 危险字符校验。
本次新增规则为:
- path 禁止字符校验。
- path 目录遍历符检测(优化了检测算法)。
- 请求 host 检测。
- 请求 Method 检测。
- 请求 Header 头检测。
- 请求参数检测。
并且本次更新开放了 hooks 机制,允许开发者注册自定义的校验规则 🛠️,参考如下:
@PostConstruct
public void saTokenPostConstruct() {
// 注册新 hook 演示,拦截所有带有 pwd 参数的请求,拒绝响应
SaFirewallStrategy.instance.registerHook((req, res, extArg)->{
if(req.getParam("pwd") != null) {
throw new FirewallCheckException("请求中不可包含 pwd 参数");
}
});
}
文档直达地址:Sa-Token 防火墙 🔗
💡 更新点2:新增基于 SPI 机制的插件体系
之前在 Sa-Token 中也有插件体系,不过都是利用 SpringBoot 的 SPI 机制完成组件注册的。
这种注册机制有一个问题,就是插件只能在 SpringBoot 环境下正常工作,在其它环境,比如 Solon 项目中,就只能手动注册插件才行 😫。
也就是说,严格来讲,这些插件只能算是 SpringBoot 的插件,而非 Sa-Token 框架的插件 🌐。
为了提高插件的通用性,Sa-Token 设计了自己的 SPI 机制,使得这些插件可以在更多的项目环境下正常工作 🚀。
第一步:实现插件注册类,此类需要 implements SaTokenPlugin
接口 👨💻:
/**
* SaToken 插件安装:插件作用描述
*/
public class SaTokenPluginForXxx implements SaTokenPlugin {
@Override
public void install() {
// 书写需要在项目启动时执行的代码,例如:
// SaManager.setXxx(new SaXxxForXxx());
}
}
第二步:在项目的 resources\META-INF\satoken\
文件夹下 📂 创建 cn.dev33.satoken.plugin.SaTokenPlugin
文件,内容为该插件注册类的完全限定名:
cn.dev33.satoken.plugin.SaTokenPluginForXxx
这样便可以在项目启动时,被 Sa-Token 插件管理器加载到此插件,执行插件注册类的 install 方法,完成插件安装 ✅。
文档直达地址:Sa-Token 插件开发指南 🔗
🎛️ 更新点3:重构缓存体系,将数据读写与序列化操作分离
在之前的版本中,Redis 集成通常和具体的序列化方式耦合在一起,这不仅让 Redis 相关插件产生大量的重复冗余代码,也让大家在选择 Redis 插件时严重受限。⚠️
本次版本更新彻底重构了此模块,将数据读写与序列化操作分离,使其每一块都可以单独自定义实现类,做到灵活扩展 ✨,例如:
- 1️⃣ SaTokenDao 数据读写可以选择:RedisTemplate、Redisson、ConcurrentHashMap、Hutool-Timed-Cache 等不同实现类。
- 2️⃣ SaSerializerTemplate 序列化器可以选择:Base64编码、Hex编码、ISO-8859-1编码、JSON序列化等不同方式。
- 3️⃣ JSON 序列化可以选择:Jackson、Fastjson、Snack3 等组件。
所有实现类均可以按需选择,自由搭配,大大提高灵活性🏗️。
⚙️️ 更新点4:SaLoginParameter 登录参数类新增大量配置项
SaLoginParameter (前SaLoginModel) 用于控制登录操作中的部分细节行为,本次新增的配置项有:
- isConcurrent:决定是否允许同一账号多地同时登录(为 true 时允许一起登录, 为 false 时新登录挤掉旧登录)。🌍
- isShare:在多人登录同一账号时,是否共用一个 token (为 true 时所有登录共用一个 token, 为 false 时每次登录新建一个 token)。🔄
- maxLoginCount:同一账号最大登录数量,超出此数量的客户端将被自动注销,-1代表不限制数量。🚫
- maxTryTimes:在创建 token 时的最高循环次数,用于保证 token 唯一性(-1=不循环尝试,直接使用。⏳
- deviceId:此次登录的客户端设备id,用于判断后续某次登录是否为可信任设备。📱
- terminalExtraData:本次登录挂载到 SaTerminalInfo 的自定义扩展数据。📦
以上大部分配置项在之前的版本中也有支持,不过它们都被定义在了全局配置类 SaTokenConfig 之上,本次更新支持在 SaLoginParameter 中定义这些配置项,
这将让登录策略的控制变得更加灵活。✨
🚪 更新点5:新增 SaLogoutParameter 注销参数类
SaLogoutParameter 用于控制注销操作中的部分细节行为️,例如:
通过 Range
参数决定注销范围 🎯:
// 注销范围: TOKEN=只注销当前 token 的会话,ACCOUNT=注销当前 token 指向的 loginId 其所有客户端会话
StpUtil.logout(new SaLogoutParameter().setRange(SaLogoutRange.TOKEN));
通过 DeviceType
参数决定哪些登录设备类型参与注销 💻:
// 指定 10001 账号,所有 PC 端注销下线,其它端如 APP 端不受影响
StpUtil.logout(10001, new SaLogoutParameter().setDeviceType("PC"));
还有其它参数此处暂不逐一列举,文档直达地址:Sa-Token 登录参数 & 注销参数 🔗
🐞 更新点6:修复 StpUtil.setTokenValue("xxx")
、loginParameter.getIsWriteHeader()
空指针的问题。
这个没啥好说的,有 bug 🐛 必须修复。
fix issue:#IBKSM0 🔗
✨ 更新点7:API 参数签名模块升级
- 1、新增了 @SaCheckSign 注解,现在 API 参数签名模块也支持注解鉴权了。🆕
- 2、新增自定义签名的摘要算法,现在不仅可以 md5 算法计算签名,也支持 sha1、sha256 等算法了。🔐
- 3、新增多应用模式:
多应用模式就是指,允许在对接多个系统时分别使用不同的秘钥等配置项,配置示例如下 📝:
sa-token:
# API 签名配置 多应用模式
sign-many:
# 应用1
xm-shop:
secret-key: 0123456789abcdefg
digest-algo: md5
# 应用2
xm-forum:
secret-key: 0123456789hijklmnopq
digest-algo: sha256
# 应用3
xm-video:
secret-key: 12341234aaaaccccdddd
digest-algo: sha512
然后在签名时通过指定 appid 的方式获取对应的 SignTemplate 进行操作 👨💻:
// 创建签名示例
String paramStr = SaSignMany.getSignTemplate("xm-shop").addSignParamsAndJoin(paramMap);
// 校验签名示例
SaSignMany.getSignTemplate("xm-shop").checkRequest(SaHolder.getRequest());
⚡ 更新点8:新增 sa-token-caffeine 插件,用于整合 Caffeine
Caffeine 是一个基于 Java 的高性能本地缓存库,本次新增 sa-token-caffeine 插件用于将 Caffeine 作为 Sa-Token 的缓存层,存储会话鉴权数据。🚀
这进一步丰富了 Sa-Token 的缓存层插件生态。🌱
<!-- Sa-Token 整合 Caffeine -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-caffeine</artifactId>
<version>1.41.0</version>
</dependency>
🎪 更新点9:新增 sa-token-serializer-features 序列化扩展包
引入此插件可以为 Sa-Token 提供一些有意思的序列化方案。(娱乐向,不建议上生产 🎭)
例如:以base64 编码,采用:元素周期表 🧪、特殊符号 🔣、或 emoji 😊 作为元字符集存储数据 :
📜 完整更新日志
除了以上提到的几点以外,还有更多更新点无法逐一详细介绍,下面是 v1.41.0 版本的完整更新日志:
- core:
- 修复:修复
StpUtil.setTokenValue("xxx")
、loginParameter.getIsWriteHeader()
空指针的问题。 fix: #IBKSM0 - 修复:将
SaDisableWrapperInfo.createNotDisabled()
默认返回值封禁等级改为 -2,以保证向之前版本兼容。 - 新增:新增基于 SPI 的插件体系。 [重要]
- 重构:JSON 转换器模块。 [重要]
- 新增:新增 serializer 序列化模块,控制
Object
与String
的序列化方式。 [重要] - 重构:重构防火墙模块,增加 hooks 机制。 [重要]
- 新增:防火墙新增:请求 path 禁止字符校验、Host 检测、请求 Method 检测、请求头检测、请求参数检测。重构目录遍历符检测算法。
- 重构:重构
SaTokenDao
模块,将序列化与存储操作分离。 [重要] - 重构:重构
SaTokenDao
默认实现类,优化底层设计。 - 新增:
isLastingCookie
配置项支持在全局配置中定义了。 - 重构:
SaLoginModel
->SaLoginParameter
。 [不向下兼容] - 重构:
TokenSign
->SaTerminalInfo
。 [不向下兼容] - 新增:
SaTerminalInfo
新增extraData
自定义扩展数据设置。 - 新增:
SaLoginParameter
支持配置isConcurrent
、isShare
、maxLoginCount
、maxTryTimes
。 - 新增:新增
SaLogoutParameter
,用于控制注销会话时的各种细节。 [重要] - 新增:新增
StpLogic#isTrustDeviceId
方法,用于判断指定设备是否为可信任设备。 - 新增:新增
StpUtil.getTerminalListByLoginId(loginId)
、StpUtil.forEachTerminalList(loginId)
方法,以更方便的实现单账号会话管理。 - 升级:API 参数签名配置支持自定义摘要算法。
- 新增:新增
@SaCheckSign
注解鉴权,用于 API 签名参数校验。 - 新增:API 参数签名模块新增多应用模式。 fix: #IAK2BI, #I9SPI1, #IAC0P9 [重要]
- 重构:全局配置
is-share
默认值改为 false。 [不向下兼容] - 重构:踢人下线、顶人下线默认将删除对应的 token-session 对象。
- 优化:优化注销会话相关 API。
- 重构:登录默认设备类型值改为 DEF。 [不向下兼容]
- 重构:
BCrypt
标注为@Deprecated
。 - 新增:
sa-token-quick-login
支持SpringBoot3
项目。 fix: #IAFQNE、#673 - 新增:
SaTokenConfig
新增replacedRange
、overflowLogoutMode
、logoutRange
、isLogoutKeepFreezeOps
、isLogoutKeepTokenSession
配置项。
- 修复:修复
- OAuth2:
- 重构:重构 sa-token-oauth2 插件,使注解鉴权处理器的注册过程改为 SPI 插件加载。
- 插件:
- 新增:
sa-token-serializer-features
插件,用于实现各种形式的自定义字符集序列化方案。 - 新增:
sa-token-fastjson
插件。 - 新增:
sa-token-fastjson2
插件。 - 新增:
sa-token-snack3
插件。 - 新增:
sa-token-caffeine
插件。
- 新增:
- 单元测试:
- 新增:
sa-token-json-test
json 模块单元测试。 - 新增:
sa-token-serializer-test
序列化模块单元测试。
- 新增:
- 文档:
- 新增:QA “多个项目共用同一个 redis,怎么防止冲突?”
- 优化:补全 OAuth2 模块遗漏的相关配置项。
- 优化:优化 OAuth2 简述章节描述文档。
- 优化:完善 “SSO 用户数据同步 / 迁移” 章节文档。
- 修正:补全项目目录结构介绍文档。
- 新增:文档新增 “登录参数 & 注销参数” 章节。
- 优化:优化“技术求助”按钮的提示文字。
- 新增:新增
preview-doc.bat
文件,一键启动文档预览。 - 完善:完善 Redis 集成文档。
- 新增:新增单账号会话查询的操作示例。
- 新增:新增顶人下线 API 介绍。
- 新增:新增 自定义序列化插件 章节。
- 其它:
- 新增:新增
sa-token-demo/pom.xml
以便在 idea 中一键导入所有 demo 项目。 - 删除:删除不必要的
.gitignore
文件 - 重构:重构
sa-token-solon-plugin
插件。 - 新增:新增设备锁登录示例。
- 新增:新增
更新日志在线文档直达链接:sa-token.cc/doc.html#/m…
🌟 其它
代码仓库地址:gitee.com/dromara/sa-…
框架功能结构图:
来源:juejin.cn/post/7484191942358499368
这个排队系统设计碉堡了
先赞后看,Java进阶一大半
各位好,我是南哥。
我在网上看到某厂最后一道面试题:如何设计一个排队系统?
关于系统设计的问题,大家还是要多多思考,可能这道题考的不是针对架构师的职位,而是关于你的业务设计能力。如果单单只会用开源软件的API,那似乎我们的竞争力还可以再强些。学习设计东西、创作东西,把我们设计的产品给别人用,那竞争力一下子提了上来。
15岁的初中生开源了 AI 一站式 B/C 端解决方案chatnio,该产品在上个月被以几百万的价格收购了。这值得我们思考,程序创造力、设计能力在未来会变得越来越重要。
⭐⭐⭐收录在《Java学习/进阶/面试指南》:https://github/JavaSouth
精彩文章推荐
1.1 数据结构
排队的一个特点是一个元素排在另一个元素的后面,形成条状的队列。List结构、LinkedList链表结构都可以满足排队的业务需求,但如果这是一道算法题,我们要考虑的是性能因素。
排队并不是每个人都老老实实排队,现实会有多种情况发生,例如有人退号,那属于这个人的元素要从队列中删除;特殊情况安排有人插队,那插入位置的后面那批元素都要往后挪一挪。结合这个情况用LinkedList链表结构会更加合适,相比于List,LinkedList的性能优势就是增、删的效率更优。
但我们这里做的是一个业务系统,采用LinkedList这个结构也可以,不过要接受修改、维护起来困难,后面接手程序的人难以理解。大家都知道,在实际开发我们更常用List,而不是LinkedList。
List数据结构我更倾向于把它放在Redis里,有以下好处。
(1)数据存储与应用程序拆分。放在应用程序内存里,如果程序崩溃,那整条队列数据都会丢失。
(2)性能更优。相比于数据库存储,Redis处理数据的性能更加优秀,结合排队队列排完则销毁的特点,甚至可以不存储到数据库。可以补充排队记录到数据库里。
简单用Redis命令模拟下List结构排队的处理。
# 入队列(将用户 ID 添加到队列末尾)
127.0.0.1:6379> RPUSH queue:large user1
127.0.0.1:6379> RPUSH queue:large user2
# 出队列(将队列的第一个元素出队)
127.0.0.1:6379> LPOP queue:large
# 退号(从队列中删除指定用户 ID)
127.0.0.1:6379> LREM queue:large 1 user2
# 插队(将用户 ID 插入到指定位置,假设在 user1 之前插入 user3)
127.0.0.1:6379> LINSERT queue:large BEFORE user1 user3
1.2 业务功能
先给大家看看,南哥用过的费大厨的排队系统,它是在公众号里进行排队。
我们可以看到自己现在的排队进度。
同时每过 10 号,公众号会进行推送通知;如果 10 号以内,每过 1 号会微信公众号通知用户实时排队进度。最后每过 1 号就通知挺人性化,安抚用户排队的焦急情绪。
总结下来,我们梳理下功能点。虽然上面看起来是简简单单的查看、通知,背后可能隐藏许多要实现的功能。
1.3 后台端
(1)排队开始
后台管理员创建排队活动,后端在Redis创建List类型的数据结构,分别创建大桌、中桌、小桌三条队列,同时设置没有过期时间。
// 创建排队接口
@Service
public class QueueManagementServiceImpl {
@Autowired
private RedisTemplate<String, String> redisTemplate;
// queueType为桌型
public void createQueue(String queueType) {
String queueKey = "queue:" + queueType;
redisTemplate.delete(queueKey); // 删除队列,保证队列重新初始化
}
}
(2)排队操作
前面顾客用餐完成后,后台管理员点击下一号,在Redis的表现为把第一个元素从List中踢出,次数排队的总人数也减 1。
// 排队操作
@Service
public class QueueManagementServiceImpl {
@Autowired
private RedisTemplate<String, String> redisTemplate;
/**
* 将队列中的第一个用户出队
*/
public void dequeueNextUser(String queueType) {
String queueKey = "queue:" + queueType;
String userId = redisTemplate.opsForList().leftPop(queueKey);
}
}
1.4 用户端
(1)点击排队
用户点击排队,把用户标识添加到Redis队列中。
// 用户排队
@Service
public class QueueServiceImpl {
@Autowired
private RedisTemplate<String, String> redisTemplate;
public void enterQueue(String queueType, String userId) {
String queueKey = "queue:" + queueType;
redisTemplate.opsForList().rightPush(queueKey, userId);
log.info("用户 " + userId + " 已加入 " + queueType + " 队列");
}
}
(2)排队进度
用户可以查看三条队列的总人数情况,直接从Redis三条队列中查询队列个数。此页面不需要实时刷新,当然可以用WebSocket实时刷新或者长轮询,但具备了后面的用户通知功能,这个不实现也不影响用户体验。
而用户的个人排队进度,则计算用户所在队列前面的元素个数。
// 查询排队进度
@Service
public class QueueServiceImpl {
@Autowired
private RedisTemplate<String, String> redisTemplate;
public long getUserPositionInQueue(String queueType, String userId) {
String queueKey = "queue:" + queueType;
List<String> queue = redisTemplate.opsForList().range(queueKey, 0, -1);
if (queue != null) {
return queue.indexOf(userId);
}
return -1;
}
}
(3)用户通知
当某一个顾客用餐完成后,后台管理员点击下一号。此时后续的后端逻辑应该包括用户通知。
从三个队列里取出当前用户进度是 10 的倍数的元素,微信公众号通知该用户现在是排到第几桌了。
从三个队列里取出排名前 10 的元素,微信公众号通知该用户现在的进度。
// 用户通知
@Service
public class NotificationServiceImpl {
@Autowired
private RedisTemplate<String, String> redisTemplate;
private void notifyUsers(String queueType) {
String queueKey = "queue:" + queueType;
// 获取当前队列中的所有用户
List<String> queueList = jedis.lrange(queueKey, 0, -1);
// 通知排在10的倍数的用户
for (int i = 0; i < queueList.size(); i++) {
if ((i + 1) % 10 == 0) {
String userId = queueList.get(i);
sendNotification(userId, "您的排队进度是第 " + (i + 1) + " 位,请稍作准备!");
}
}
// 通知前10位用户
int notifyLimit = Math.min(10, queueList.size()); // 避免队列小于10时出错
for (int i = 0; i < notifyLimit; i++) {
String userId = queueList.get(i);
sendNotification(userId, "您已经在前 10 位,准备好就餐!");
}
}
}
这段逻辑应该移动到前面后台端的排队操作。
1.5 存在问题
上面的业务情况,实际上排队人员不会太多,一般会比较稳定。但如果每一条队列人数激增的情况下,可以预见到会有问题了。
对于Redis的List结构,我们需要查询某一个元素的排名情况,最坏情况下需要遍历整条队列,时间复杂度是O(n),而查询用户排名进度这个功能又是经常使用到。
对于上面情况,我们可以选择Redis另一种数据结构:Zset。有序集合类型Zset可以在O(lgn)的时间复杂度判断某元素的排名情况,使用ZRANK命令即可。
# zadd命令添加元素
127.0.0.1:6379> zadd 100run:ranking 13 mike
(integer) 1
127.0.0.1:6379> zadd 100run:ranking 12 jake
(integer) 1
127.0.0.1:6379> zadd 100run:ranking 16 tom
(integer) 1
# zrank命令查看排名
127.0.0.1:6379> zrank 100run:ranking jake
(integer) 0
127.0.0.1:6379> zrank 100run:ranking tom
(integer) 2
# zscore判断元素是否存在
127.0.0.1:6379> zscore 100run:ranking jake
"12"
我是南哥,南就南在Get到你的点赞点赞点赞。
创作不易,不妨点赞、收藏、关注支持一下,各位的支持就是我创作的最大动力❤️
来源:juejin.cn/post/7436658089703145524
Spring 6.0 + Boot 3.0:秒级启动、万级并发的开发新姿势
Spring生态重大升级全景图
一、Spring 6.0核心特性详解
1. Java版本基线升级
- 最低JDK 17:全面拥抱Java模块化特性,优化现代JVM性能
- 虚拟线程(Loom项目):轻量级线程支持高并发场景(需JDK 19+)
// 示例:虚拟线程使用
Thread.ofVirtual().name("my-virtual-thread").start(() -> {
// 业务逻辑
});
- 虚拟线程(Project Loom)
- 应用场景:电商秒杀系统、实时聊天服务等高并发场景
// 传统线程池 vs 虚拟线程
// 旧方案(平台线程)
ExecutorService executor = Executors.newFixedThreadPool(200);
// 新方案(虚拟线程)
ExecutorService virtualExecutor = Executors.newVirtualThreadPerTaskExecutor();
// 处理10000个并发请求
IntStream.range(0, 10000).forEach(i ->
virtualExecutor.submit(() -> {
// 处理订单逻辑
processOrder(i);
})
);
2. HTTP接口声明式客户端
- @HttpExchange注解:类似Feign的声明式REST调用
@HttpExchange(url = "/api/users")
public interface UserClient {
@GetExchange
List<User> listUsers();
}
应用场景:微服务间API调用
@HttpExchange(url = "/products", accept = "application/json")
public interface ProductServiceClient {
@GetExchange("/{id}")
Product getProduct(@PathVariable String id);
@PostExchange
Product createProduct(@RequestBody Product product);
}
// 自动注入使用
@Service
public class OrderService {
@Autowired
private ProductServiceClient productClient;
public void validateProduct(String productId) {
Product product = productClient.getProduct(productId);
// 校验逻辑...
}
}
3. ProblemDetail异常处理
- RFC 7807标准:标准化错误响应格式
{
"type": "https://example.com/errors/insufficient-funds",
"title": "余额不足",
"status": 400,
"detail": "当前账户余额为50元,需支付100元"
}
- 应用场景:统一API错误响应格式
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ProductNotFoundException.class)
public ProblemDetail handleProductNotFound(ProductNotFoundException ex) {
ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.NOT_FOUND);
problem.setType(URI.create("/errors/product-not-found"));
problem.setTitle("商品不存在");
problem.setDetail("商品ID: " + ex.getProductId());
return problem;
}
}
// 触发异常示例
@GetMapping("/products/{id}")
public Product getProduct(@PathVariable String id) {
return productRepo.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
}
4. GraalVM原生镜像支持
- AOT编译优化:启动时间缩短至毫秒级,内存占用降低50%+
- 编译命令示例:
native-image -jar myapp.jar
二、Spring Boot 3.0突破性改进
1. 基础架构升级
- Jakarta EE 9+:包名javax→jakarta全量替换
- 自动配置优化:更智能的条件装配策略
- OAuth2授权服务器
应用场景:构建企业级认证中心
- OAuth2授权服务器
# application.yml配置
spring:
security:
oauth2:
authorization-server:
issuer-url: https://auth.yourcompany.com
token:
access-token-time-to-live: 1h
定义权限端点
@Configuration
@EnableWebSecurity
public class AuthServerConfig {
@Bean
public SecurityFilterChain authServerFilterChain(HttpSecurity http) throws Exception {
http
.authorizeRequests(authorize -> authorize
.anyRequest().authenticated()
)
.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt);
return http.build();
}
}
2. GraalVM原生镜像支持
应用场景:云原生Serverless函数
# 打包命令(需安装GraalVM)
mvn clean package -Pnative
# 运行效果对比
传统JAR启动:启动时间2.3s | 内存占用480MB
原生镜像启动:启动时间0.05s | 内存占用85MB
3. 增强监控(Prometheus集成)
- Micrometer 1.10+:支持OpenTelemetry标准
- 全新/actuator/prometheus端点:原生Prometheus格式指标
- 应用场景:微服务健康监测
// 自定义业务指标
@RestController
public class OrderController {
private final Counter orderCounter = Metrics.counter("orders.total");
@PostMapping("/orders")
public Order createOrder() {
orderCounter.increment();
// 创建订单逻辑...
}
}
# Prometheus监控指标示例
orders_total{application="order-service"} 42
http_server_requests_seconds_count{uri="/orders"} 15
三、升级实施路线图
四、新特性组合实战案例
场景:电商平台升级
// 商品查询服务(组合使用新特性)
@RestController
public class ProductController {
// 声明式调用库存服务
@Autowired
private StockServiceClient stockClient;
// 虚拟线程处理高并发查询
@GetMapping("/products/{id}")
public ProductDetail getProduct(@PathVariable String id) {
return CompletableFuture.supplyAsync(() -> {
Product product = productRepository.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
// 并行查询库存
Integer stock = stockClient.getStock(id);
return new ProductDetail(product, stock);
}, Executors.newVirtualThreadPerTaskExecutor()).join();
}
}
四、升级实践建议
- 环境检查:确认JDK版本≥17,IDE支持Jakarta包名
- 渐进式迁移:
- 先升级Spring Boot 3.x → 再启用Spring 6特性
- 使用
spring-boot-properties-migrator
检测配置变更
- 性能测试:对比GraalVM原生镜像与传统JAR包运行指标
通过以上升级方案:
- 使用虚拟线程支撑万级并发查询
- 声明式客户端简化服务间调用
- ProblemDetail统一异常格式
- Prometheus监控接口性能
本次升级标志着Spring生态正式进入云原生时代。重点关注:虚拟线程的资源管理策略、GraalVM的反射配置优化、OAuth2授权服务器的定制扩展等深度实践方向。
来源:juejin.cn/post/7476389305881296934