注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

全局异常统一处理很好,但建议你谨慎使用

思考,输出,沉淀。用通俗的语言陈述技术,让自己和他人都有所收获。 作者:毅航😜 在SpringBoot应用开发中,利用@ControllerAdvice 结合 @ExceptionHandler来实现对后端服务的统一异常管理似乎已经成为了开发者约定俗成的规...
继续阅读 »



思考,输出,沉淀。用通俗的语言陈述技术,让自己和他人都有所收获。

作者:毅航😜





SpringBoot应用开发中,利用@ControllerAdvice 结合 @ExceptionHandler来实现对后端服务的统一异常管理似乎已经成为了开发者约定俗成的规范了,为此网上也有很多文章来阐述如何更加优雅的来实现统一异常管理,但这样做真的好吗?


前言


SpringBoot中的全局统一异常管理通常是指利用@ControllerAdvice 结合 @ExceptionHandler来自定义一个全局的异常处理器,从而避免了在程序中频繁写书写try-catch来对异常进行处理。但结合笔者最近惨痛debug旧有项目的经历来看,笔者不推荐这样去做。


在讨论之前我们不妨先来看看实际开发中都有哪些地方可能出现的异常。


全局异常所带来的困惑


目前,大部分应用在开发时,通常会将代码划分为控制层,服务层,数据访问层三层结构,每个模块负责自己独立的逻辑。简单来看,控制层主要作用在于对外暴露 ur1访问地址,并将前台的处理请求委托给服务层来处理;而对于服务层来说其主要是业务逻辑处理的地方,以登录请求为例,用户名、密码的校验通常会放在服务层来处理;数据访问层则主要用于对数据库进行访问。如下这张图直观的反映了三层架构下各个模块所肩负的责任。


image.png


知晓了软件开发过程中的分层架构模式后。我们再来看每个模块可能产生的异常信息。其中:



  • 控制层主要用于处理实现前后端交互逻辑,其主要负责信息收禁、参数校验等,抛出的异常也多是参数校验、请求服务异常等异常信息。

  • 服务层主要用于处理业务逻辑,以及各种外部服务调用、访问数据作、缓存处理、消息处理等处理操作,这部分可能抛出的逻辑就非常多了。例如,数据库访问异常、服务调用异常等问题。

  • 数据访问层则主要负责数据访问实现,其一般没有业务逻辑。由于目前持久层使用技术通常为Mybatis,所以这一层抛出的异常多是Mybatis框架内部所抛出的异常。


不难发现,其中每一层所抛出的异常类型有着很大的差异。而我们在使用全局异常管理时,通常使用如下
代码逻辑:



@ControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler(Exception.class)
public ResponseEntity<String> handleGlobalException(Exception ex) {
// 在这里实现异常处理逻辑
log.error(ex.getMessage());//在控制台打印错误信息
return Result.error(ex.getMessage());
}
}



即我们通过在ExceptionHandler执行捕获异常为Exception来尽可能捕获业务中可能遇到的各种异常,相信网上已经有很多博主都在不断讲授这样的写法。


但这样做真的好吗?在笔者最近接手的旧项目中,当后端无法处理前端请求后会返回如下信息:


{
"message": “服务器异常”,
"code":602
}

后台服务通常会打印出如下信息:


image.png


(注:此处仅为展示,真实环境出现的异常远比此复杂)


不难发现,通过网上所盛传全局异常处理逻辑,我根本不知道问题出在哪里。我只知道当前程序出现异常,且异常信息为/ by zero。除此之外我无法得到任何有用的信息。


此外,通过在@ExceptionHandler配置Exception.class使得程序可以捕获多种异常信息,但这样粗粒度做法所导致的直接问题就是无法精确的定位异常问题发生所在地,极大的提升了问题定位的难度笔者项目同事debug经历来看,当项目无法正常运行时,组内的程序员通常会通过打日志的方法来一行一行定位问题所在。


破解之道


其实造成这样调试困难得本质原因在于全局异常机制的滥用,如下这张图真实的反映了引入全局异常机制后,异常的处理逻辑。


image.png


不难发现,当引入全局异常处理后,所有的异常信息都会交由RestExceptionHandler来进行处理。当程序遇到异常时,会将异常信息抛给RestExceptionHandler来处理,并由其定义错误的处理逻辑。


分析到此处,其实你已经发现了。对于全局异常处理逻辑而言,其更适合做异常的兜底工作。即如果当前层出现异常,并且不断上抛的仍然无法解决的话,不妨通过全局统一的异常管理来进行处理,以对这些未处理的异常进行捕获


此外,异常处理不应该进行像很多博客说的那样,仅是通过e.getMessage打印异常信息就可以了。这对于排查问题没有以一丁点的帮助,可以说是百害而无一利。


对于此,笔者更推荐在打印异常信息时,记录异常以及当前 URL、执行方法等信息以便后期方便问题排查。具体可参考如下代码:



@Slf4j
@RestControllerAdvice
public class GlobExceptionHandler {


@ExceptionHandler(ArithmeticException.class)//ArithmeticException异常类型通过注解拿到
public String exceptionHandler(HttpServletRequest request,ArithmeticException exception){
// 打印详细信息
log(request,exception);
return exception.getMessage();

}


public void log(HttpServletRequest request, Exception exception) {
//换行符
String lineSeparatorStr = System.getProperty("line.separator");

StringBuilder exStr = new StringBuilder();
StackTraceElement[] trace = exception.getStackTrace();
// 获取堆栈信息并输出为打印的形式
for (StackTraceElement s : trace) {
exStr.append("\tat " + s + "\r\n");
}
//打印error级别的堆栈日志
log.error("访问地址:" + request.getRequestURL() + ",请求方法:" + request.getMethod() +
",远程地址:" + request.getRemoteAddr() + lineSeparatorStr +
"错误堆栈信息如下:" + exception.toString() + lineSeparatorStr + exStr);
}
}

当程序发生错误时,其打印的日志信息如下:


image.png


不难发现,其完整的打印出了url、方法信息,错误参数、请求地址等信息,极大的降低了线上Bug的排查难度。


总结


技术本身并没有什么对与错之分,只不过有时我们用的方式和时机不对,进而使得本该提效的工具,反而在不断拖垮我们的效率。就如同本文分析的全局异常处理机制一样,其确实可以帮助我们降低try-catch的使用,但错误且不加考虑的乱用只会使得当系统出现问题时,我们只能两眼一抹黑,然后一行一行打日志来定位问题。


最后,对于代码中异常的捕获处理,笔者认为全局异常应该作为异常处理的都兜底操作,而不应该成为异常处理的灵丹妙药! 此外,全局异常处理过程不应该仅是简单的 e.getMessage()打印异常消息即可,其更应记录更加有助于异常排查的信息例如,方法,请求的url,请求参数等信息。



如果觉文章对你有帮助,不妨点赞+关注,不错过笔者之后的每一次更新!



作者:毅航
来源:juejin.cn/post/7291555600854106147
收起阅读 »

实现 Springboot 程序加密,禁止 jadx 反编译

背景 toB 的本地化 java 应用程序,通常是部署在客户机器上,为了保护知识产权,我们需要将核心代码(例如 Lience,Billing,Pay 等)进行加密或混淆,防止使用 jadx 等工具轻易反编译。同时,为了更深层的保护程序,也要防止三方依赖细节被窥...
继续阅读 »

背景


toB 的本地化 java 应用程序,通常是部署在客户机器上,为了保护知识产权,我们需要将核心代码(例如 Lience,Billing,Pay 等)进行加密或混淆,防止使用 jadx 等工具轻易反编译。同时,为了更深层的保护程序,也要防止三方依赖细节被窥探;


业界方案



  1. ProGuard

    • 简介:开源社区有名的免费混淆工具,相较于字节码加密,对性能基本无影响;

    • 优势:打包阶段混淆字节码,各种变量方法名都变成了abcdefg 等等无意义的符号,字节码可被反编译,但几乎无法阅读,通常被 Android App 用来防止逆向;

    • 不足1:只能混淆部分代码,打包阶段较为耗时,对于三方包混淆,并没有什么好办法。

    • 不足2:混淆后的代码,会影响 arthas 工具的使用,导致排查问题变慢。

    • 不足3:配置比较复杂,曾经在我司 T 项目上用过,令人眼花缭乱。

    • 不足4:无法加密三方依赖所有信息;



  2. jar-protect

    • 简介:一款国人开发的 springboot jar 加密工具;需要配合 javaagent 解密;

    • 优势:打包阶段使用 javassist 重写 class 文件;jadx 反编译后看到的都是空方法。反编译后只能看到类信息和方法签名,无法看到具体内容。

    • 不足1:使用 DES 方案,对于几百个三方 jar 的场景,加密手段过重,且加密后的不够完整;

    • 不足2:类文件放在一个目录(META-INF/.encode/),非常容易类冲突;

    • 不足3:无法加密三方依赖所有信息;



  3. GraalVM

    • 简介:Oracle GraalVM 提前将 Java 应用程序编译为独立的二进制文件。与在 Java 虚拟机 (JVM) 上运行的应用程序相比,这些二进制文件更小,启动速度提高了 100 倍,无需预热即可提供峰值性能,并且使用的内存和 CPU 更少, 并且无法反编译。

    • 不足:无法支持我司业务程序框架。



  4. core-lib/xjar

    • 简介:国人开源的,基于 golang 的加密工具。使用 maven 插件加密,启动时 golang 解密;性能影响未知。

    • 优势:可对所有 class 文件加密。

    • 不足1: 加密后 jar 文件体积翻倍;

    • 不足2:依赖 golang 编译,依赖 golang 启动;

    • 不足3:无法加密三方依赖所有信息;

    • 不足4:开源项目,3年未有新提交。




思考:


我们的需求到底是什么?a:保护知识产权。具体手段为:



  1. 对本司项目代码进行加密,使其无法被 jadx 工具轻易反编译,

  2. 对本司三方依赖进行加密,使其无法窥探我司三方依赖细节;


但上面的几个项目,基本都是围绕着 class 加密(除了GraalVM),这无法实现我们的第二个需求。


我们的方案


设计目标:



  1. 将项目三方依赖 jar 进行加密,使其无法使用 jadx 反编译,但运行时会生成解密后的临时文件。

  2. 将项目本身的 class 进行加密,使其无法使用 jadx 反编译运行时解密后的文件。

  3. 加密策略要灵活,轻量,对启动速度,包体积,内存消耗,接口性能的影响要控制在 5% 以内;


设计方案:



  1. 加密jar时,使用 maven 打包工具,repackage fat jar;将其内部 lib 目录的依赖进行加密;使 jadx 无法反编译;

  2. 加密class时,对于核心业务代码,使用 javassist 工具将其重写,清空方法体,重置属性值;

  3. 解密jar时,将指定目录的 加密包 解密 到指定目录,并将其放入 springboot classloader classpath 里。

  4. 解密class时,agent 配合判断是否是加密 class,如果是,则寻找加密 class 文件,找到后解密,返回解密后的 classBytes。


逻辑如下:



注意点:



  1. javassist 重写方法体时,需要将 lib 里的所有代码都加入 classpool 的 classpath 里。

  2. javassist 加密后的类,需将其放入到当前 lib 的单独目录进行个例,防止类冲突。

  3. agent 解密要轻量,不能影响程序性能;

  4. 三方包的加解密重新打包后,jar 顺序发生变化,较小可能会导致类冲突(比如 log4j)。需要在测试环境验证,如果存在冲突,则需要排包。


End


通过以上方案,我们实现了一个极其轻量的 maven 加密,agent 解密插件。他能够将三方包彻底加密,使 jadx 等工具无法反编译 ,屏蔽我们的三方依赖细节,同时,该插件也可以加密我们的业务 class 代码,使 jadx 无法反编译运行时生成的代码,从而一定程度的保护我们的知识产权;


另外,私有的加密算法,在性能,体积,内存等方便的影响都控制在 5% 以内。


为了防止混淆后的代码影响 arthas 的使用和 bug patch 的应用,我们放弃了混淆方案,只能说是一种权衡与取舍吧。


从软件防破解的角度来理解,通常只能是加大破解的难度,铁了心想要破解的话,就算是 ProGuard 混淆,也无法解决。也许只能用 GraalVM,但不是每一个客户都会用这个。


推荐


Java 扩展点/插件系统,支持热插拔,旨在解决大部分软件的功能定制问题


作者:莫那鲁道
来源:juejin.cn/post/7289661061984469051
收起阅读 »

应用容器化后为什么性能下降这么多?

1. 背景 随着越来越多的公司拥抱云原生,从原先的单体应用演变为微服务,应用的部署方式也从虚机变为容器化,容器编排组件k8s也成为大多数公司的标配。然而在容器化以后,我们发现应用的性能比原先在虚拟机上表现更差,这是为什么呢?。 2. 压测结果 2.1 容器化之...
继续阅读 »

1. 背景


随着越来越多的公司拥抱云原生,从原先的单体应用演变为微服务,应用的部署方式也从虚机变为容器化,容器编排组件k8s也成为大多数公司的标配。然而在容器化以后,我们发现应用的性能比原先在虚拟机上表现更差,这是为什么呢?。


2. 压测结果


2.1 容器化之前的表现


应用部署在虚拟机下,我们使用wrk工具进行压测,压测结果如下:


image.png


从压测结果看,平均RT1.68msqps716/s\color{red}{平均RT为1.68ms,qps为716/s},我们再来看下机器的资源使用情况,cpu基本已经被打满。
image.png


2.2 容器化后的表现


使用wrk工具进行压测,结果如下:
image.png


从压测结果看,平均RT2.11msqps554/s\color{red}{平均RT为2.11ms,qps为554/s},我们再来看下机器的资源使用情况,cpu基本已经被打满。
image.png


2.3 性能对比结果


性能对比虚拟机容器
RT1.68ms2.11ms
QPS716/s554/s


总体性能下降:RT(25%)、QPS(29%)



3. 原因分析


3.1 架构差异


由于应用在容器化后整体架构的不同、访问路径的不同,将可能导致应用容器化后性能的下降,于是我们先来分析下两者架构的区别。我们使用k8s作为容器编排基础设施,网络插件使用calico的ipip模式,整体架构如下所示。


x3.png


这里需要说明,虽然使用calico的ipip模式,由于pod的访问为service的nodePort模式,所以不会走tunl0网卡,而是从eth0经过iptables后,通过路由到calico的calixxx接口,最后到pod。


3.2性能分析


在上面压测结果的图中,我们容器化后,cpu的软中断si使用率明显高于原先虚拟机的si使用率,所以我们使用perf继续分析下热点函数。


image.png
为了进一步验证是否是软中断的影响,我们使用perf进一步统计软中断的次数。


image.png



我们发现容器化后比原先软中断多了14%,到这里,我们能基本得出结论,应用容器化以后,需要更多的软中断的网络通信导致了性能的下降。



3.3 软中断原因


由于容器化后,容器和宿主机在不同的网络namespace,数据需要在容器的namespace和host namespace之间相互通信,使得不同namespace的两个虚拟设备相互通信的一对设备为veth pair,可以使用ip link命令创建,对应上面架构图中红色框内的两个设备,也就是calico创建的calixxx和容器内的eth0。我们再来看下veth设备发送数据的过程


static netdev_tx_t veth_xmit(struct sk_buff *skb, struct net_device *dev)
{
...
if (likely(veth_forward_skb(rcv, skb, rq, rcv_xdp)
...
}

static int veth_forward_skb(struct net_device *dev, struct sk_buff *skb,
struct veth_rq *rq, bool xdp)
{
return __dev_forward_skb(dev, skb) ?: xdp ?
veth_xdp_rx(rq, skb) :
netif_rx(skb);//中断处理
}


/* Called with irq disabled */
static inline void ____napi_schedule(struct softnet_data *sd,
struct napi_struct *napi)
{
list_add_tail(&napi->poll_list, &sd->poll_list);
//发起软中断
__raise_softirq_irqoff(NET_RX_SOFTIRQ);
}

通过虚拟的veth发送数据和真实的物理接口没有区别,都需要完整的走一遍内核协议栈,从代码分析调用链路为veth_xmit -> veth_forward_skb -> netif_rx -> __raise_softirq_irqoff,veth的数据发送接收最后会使用软中断的方式,这也刚好解释了容器化以后为什么会有更多的软中断,也找到了性能下降的原因。


4. 优化策略


原来我们使用calico的ipip模式,它是一种overlay的网络方案,容器和宿主机之间通过veth pair进行通信存在性能损耗,虽然calico可以通过BGP,在三层通过路由的方式实现underlay的网络通信,但还是不能避免veth pari带来的性能损耗,针对性能敏感的应用,那么有没有其他underly的网络方案来保障网络性能呢?那就是macvlan/ipvlan模式,我们以ipvlan为例稍微展开讲讲。


4.1 ipvlan L2 模式


IPvlan和传统Linux网桥隔离的技术方案有些区别,它直接使用linux以太网的接口或子接口相关联,这样使得整个发送路径变短,并且没有软中断的影响,从而性能更优。如下图所示:


ipvlan l2 mode


上图是ipvlan L2模式的通信模型,可以看出container直接使用host eth0发送数据,可以有效减小发送路径,提升发送性能。


4.2 ipvlan L3 模式


ipvlan L3模式,宿主机充当路由器的角色,实现容器跨网段的访问,如下图所示:


ipvlan L3 mode


4.3 Cilium


除了使用macvlan/ipvlan提升网络性能外,我们还可以使用Cilium来提升性能,Cilium为云原生提供了网络、可观测性、网络安全等解决方案,同时它是一个高性能的网络CNI插件,高性能的原因是优化了数据发送的路径,减少了iptables开销,如下图所示:


cilium netwok


虽然calico也支持ebpf,但是通过benchmark的对比,Cilium性能更好,高性能名副其实,接下来我们来看看官网公布的一些benchmark的数据,我们只取其中一部分来分析,如下图:


xxxx2
xxxx3


无论从QPS和CPU使用率上Cilium都拥有更强的性能。


5. 总结


容器化带来了敏捷、效率、资源利用率的提升、环境的一致性等等优点的同时,也使得整体的系统复杂度提升一个等级,特别是网络问题,容器化使得整个数据发送路径变长,排查难度增大。不过现在很多网络插件也提供了很多可观测性的能力,帮助我们定位问题。


我们还是需要从实际业务场景出发,针对容器化后性能、安全、问题排查难度增大等问题,通过优化架构,增强基础设施建设才能让我们在云原生的路上越走越远。


最后,感谢大家观看,也希望和我讨论云原生过程中遇到的问题。


5. 参考资料


docs.docker.com/network/dri…


cilium.io/blog/2021/0…


作者:云之舞者
来源:juejin.cn/post/7268663683881828413
收起阅读 »

SpringBoot获取不到用户真实IP怎么办

今天周六,Binvin来总结一下上周开发过程中遇到的一个小问题,项目部署后发现服务端无法获取到客户端真实的IP地址,这是怎么回事呢?给我都整懵逼了,经过短暂的思考,我发现了问题的真凶,那就是我们使用了Nginx作的请求转发,这才导致了获取不到客户端真实的IP地...
继续阅读 »

今天周六,Binvin来总结一下上周开发过程中遇到的一个小问题,项目部署后发现服务端无法获取到客户端真实的IP地址,这是怎么回事呢?给我都整懵逼了,经过短暂的思考,我发现了问题的真凶,那就是我们使用了Nginx作的请求转发,这才导致了获取不到客户端真实的IP地址,害,看看我是怎么解决的吧!


a00e8833034583c6895e1582c899a2f3.png


问题原因


客户端请求数据时走的是Nginx反向代理,默认情况下客户端的真实IP地址会被其过滤,使得SpringBoot程序无法直接获得真实的客户端IP地址,获取到的都是Nginx的IP地址。


解决方案:


通过更改Nginx配置文件将客户端真实的IP地址加到请求头中,这样就能正常获取到客户端的IP地址了,下面我一步步带你看看如何配置和获取。


修改Nginx配置文件


在需要做请求转发的配置里添加下面的配置


#这个参数设置了HTTP请求头的Host字段,host表示请求的Host头,也就是请求的域名。通过这个设置,Nginx会将请求的Host头信息传递给后端服务。
proxy_set_header Host $host;
#这个参数设置了HTTP请求头的X−Real−IP字段,remote_addr表示客户端的IP地址。通过这个设置,Nginx会将客户端的真实IP地址传递给后端服务
proxy_set_header X-Real-IP $remote_addr;
#这个参数设置了HTTP请求头的 X-Forwarded-For字段,"X-Forwarded-For"是一个标准的HTTP请求头,用于表示HTTP请求经过的代理服务器链路信息,proxy_add_x_forwarded_for表示添加额外的服务器链路信息。
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

修改后我的nginx.conf中的server如下所示


server {
listen 443 ssl;
server_name xxx.com;

ssl_certificate "ssl证书pem文件";
ssl_certificate_key "ssl证书key文件";
ssl_session_timeout 5m;
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:HIGH:!aNULL:!MD5:!RC4:!DHE;
ssl_prefer_server_ciphers on;

location / {
root 前端html文件目录;
index index.html index.htm;
}

error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}
# 关键在下面这个配置,上面的配置自己根据情况而定就行
location /hello{
proxy_pass http://127.0.0.1:8090;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}

SpringBoot代码实现


第一种方式:在代码中直接通过X-Forwarded-For获取到真实IP地址


@Slf4j
public class CommonUtil {
/**
* <p> 获取当前请求客户端的IP地址 </p>
*
* @param request 请求信息
* @return ip地址
**/

public static String getIp(HttpServletRequest request) {
if (request == null) {
return null;
}
String unknown = "unknown";
// 使用X-Forwarded-For就能获取到客户端真实IP地址
String ip = request.getHeader("X-Forwarded-For");
log.info("X-Forwarded-For:" + ip);
if (ip == null || ip.length() == 0 || unknown.equalsIgnoreCase(ip)) {
ip = request.getHeader("Proxy-Client-IP");
log.info("Proxy-Client-IP:" + ip);
}
if (ip == null || ip.length() == 0 || unknown.equalsIgnoreCase(ip)) {
ip = request.getHeader("WL-Proxy-Client-IP");
log.info("WL-Proxy-Client-IP:" + ip);
}
if (ip == null || ip.length() == 0 || unknown.equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_X_FORWARDED_FOR");
log.info("HTTP_X_FORWARDED_FOR:" + ip);
}
if (ip == null || ip.length() == 0 || unknown.equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_X_FORWARDED");
log.info("HTTP_X_FORWARDED:" + ip);
}
if (ip == null || ip.length() == 0 || unknown.equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_X_CLUSTER_CLIENT_IP");
log.info("HTTP_X_CLUSTER_CLIENT_IP:" + ip);
}
if (ip == null || ip.length() == 0 || unknown.equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_CLIENT_IP");
log.info("HTTP_CLIENT_IP:" + ip);
}
if (ip == null || ip.length() == 0 || unknown.equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_FORWARDED_FOR");
log.info("HTTP_FORWARDED_FOR:" + ip);
}
if (ip == null || ip.length() == 0 || unknown.equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_FORWARDED");
log.info("HTTP_FORWARDED:" + ip);
}
if (ip == null || ip.length() == 0 || unknown.equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_VIA");
log.info("HTTP_VIA:" + ip);
}
if (ip == null || ip.length() == 0 || unknown.equalsIgnoreCase(ip)) {
ip = request.getHeader("REMOTE_ADDR");
log.info("REMOTE_ADDR:" + ip);
}
if (ip == null || ip.length() == 0 || unknown.equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
log.info("getRemoteAddr:" + ip);
}
return ip;
}

第二种方式:在application.yml文件中加以下配置,直接通过request.getRemoteAddr()并可以获取到真实IP


server:
port: 8090
tomcat:
#Nginx转发 获取客户端真实IP配置
remoteip:
remote-ip-header: X-Real-IP
protocol-header: X-Forwarded-Proto

作者:rollswang
来源:juejin.cn/post/7266040474321027124
收起阅读 »

小知识分享:控制层尽量别暴露这样的接口,避免横向越权。

前言 谈不上是多么厉害的知识,但可能确实有人不清楚或没见过。 我还是分享一下,就当一个小知识点。 如果知道的,就随便逛逛,不知道的,Get到了记得顺手点个赞哈。 正文 1、接口别随便暴露 当一个项目的维护周期拉长的时候,不断有新增的需求,如果经手的人也越...
继续阅读 »

前言



谈不上是多么厉害的知识,但可能确实有人不清楚或没见过。


我还是分享一下,就当一个小知识点。


如果知道的,就随便逛逛,不知道的,Get到了记得顺手点个赞哈。



正文


1、接口别随便暴露



当一个项目的维护周期拉长的时候,不断有新增的需求,如果经手的人也越来越多,接口是会肉眼可见增多的。




此时,如果一个团队没有良好的规范和代码审查机制,就会导致许多不安全的接口被暴露出来。




比如下面这种接口:



/**
* 根据ID查询患者信息
*/

@GetMapping("/{id}")
public AjaxResult getById(@PathVariable("id") Long id) {
PersonInfo personInfo = personInfoService.selectPersonInfoById(id);
return AjaxResult.success(personInfo);
}



这种接口是我们部门以前审查出来的其中一个,类似这样的接口还有很多。




这些接口都是不同的同事在紧凑的工作任务中写的,慢慢就积累出了一堆。




还有些是为了方便,直接通过代码生成器生成的,而代码生成器是把常用的CRUD接口都给你生成出来,如果研发人员没有责任心,可能就直接不管了,想着以后哪一天也许会用上呢。




别以为这种想法的人少啊,你整个职业生涯很可能就会遇见。




这就导致了,一堆用不上又不安全的接口出现了。




服务过政务机构、企事业单位、医疗等行业的工程师应该就知道,这些单位对于安全性的要求其实挺高的,尤其是这些年,会找专门的信息安全公司做攻防演练。




最近两年,很多省市甚至会自发组织全市的信息安全攻防演练,在当前大环境下这也是符合国情的。




而攻防演练的目的之一就是找系统安全漏洞,这里面就会有一个我本章要讲的典型漏洞,接口的横向越权。



2、什么是横向越权



广义的解释就是,该越权行为允许用户获取他们正常情况下无权访问的信息或执行操作。




如果纯粹从理论上理解,是很抽象的,所以我才把这个案例捞出来,让你一次就懂。




我们再回过头看看上面我贴出来的那段很正常的代码,就是根据id获取用户信息,你一定曾经在一些项目中见过这种接口,提供给前端直接调用,比如用户详情、订单详情,只要是和详情有关的,很可能前端会需要这么一个接口。




那么,问题在这里,我们的id是不是有规则的呢?比如下面这样:



1.jpg



可以看出来,id是自增的,增量是2。其实很多中小企业现在用MySQL都喜欢这样设置自增id,有些会设置增量,有些干脆就默认。




试想一下,我如果知道了id=865的用户信息,我也知道大部分中小企业喜欢用自增id,是不是就等于知道了1-1000000的用户信息,而用户信息可能包含身-份-证、手机号、详细住址等非常敏感的内容。




这就是典型的横向越权之一,我明明只应该拿到id=865这个用户的信息,但是通过非正常的方式,我暴力获取了其他100万个用户信息。




一旦真的发生这样的事故,不管最终结果如何,这家公司基本上就进黑名单了,从此在行业中消失。



3、权限控制不了吗



一定会有人产生疑惑,SpringBoot接口怎么可能直接放出来,一定都是有权限控制的,没有权限是根本不可能访问到的。




我打个比方,如果是后台管理这种,他是有登录的,登录后会产生token,token中是可以包含角色权限的,那么这种是没有问题的。




但如果没有登录操作呢,比如小程序这种,你打开就直接是首页各种信息,前端调接口很可能传递的只有网关层的token,又该如何呢。




尤其是小程序雨后春笋一般涌现的那几年,我曾经打开过很多小程序,都是没什么权限校验的,就是直接点来点去。




直到近几年,这种现象才慢慢消失,很多小程序打开后,会提示你授权登录,比如微信小程序,你一定遇到过打开小程序后让你授权登录的场景,如果不授权登录,你绝对做不了很多操作,这是很多互联网企业的安全意识都加强的结果。




我所在的公司早年刚进入医疗行业就经历过这种事情,为了占坑拿下了很多项目,但缺乏安全意识和管理规范,程序员也是来来走走,你写两个我写两个,导致不少接口都存在安全隐患。




直到被攻防演练攻破,甲方下发整改通知,还要我们写事故报告、原因、解决方案等等一大堆,我们才慌了。




连夜开会讨论出一套基本的安全整改思路,然后开始加班加点做安全改造。




我印象最深的就是其中这个接口横向越权,只传递了网关层的token,而没有细化到个人的权限控制,导致被信息安全公司通过抓包等一些我不了解的技术把token拿到了,然后直接横向获取到了很多用户敏感信息。




当时这个事情闹得很厉害,考虑到只是攻防演练,同时客户方对公司还保留信任,才只要求我们限期整改,否则就直接替换了。




所以,记得以后写接口的时候别只考虑业务逻辑,安全性也是考量之一。



4、如何防范



防范的方式,我归纳了这么几点:


1、不用的接口尽量删掉,这样也避免了多余接口埋下的安全隐患;


2、团队要有安全规范,比如敏感字段加密,引入代码审查机制,缩小安全隐患出现的范围;


3、带登录的终端,除了网关层校验,要精确控制登录用户的角色及权限;


4、不带登录的终端,除了网关层校验,要根据用户的唯一信息,来做授权登录,授权不成功不允许做其他操作,这也是现在比较流行的方式。


我个人理解,第4点和第3点本质一样,因为不带登录,所以要想办法制造登录,而目前比较友好的方式还是一键授权登录,不管是根据openid、手机号等等,总之要找到一个规则,这样省去了用户手动操作登录的时间。




总之,一定要控制用户只能看到属于自己的内容,避免横向越权。



总结



如果写的不好,还望大家原谅,只是分享了曾经工作中发生过的和安全改造有关的事情。


现在的程序员其实了解和接收的知识技术是挺多的,许多人其实都知道这些。


希望不知道的人,能够因为我的文章得到一点点帮助。


最后,大家其实可以去试一试,打开微信小程序,搜索下你们所在城市的某某中心医院,看看这样的医疗小程序打开后是什么样的,是不是有授权登录,或者其他方式来控制权限,搞不好一部分人能遇到有意思的事情。


作者:程序员济癫
来源:juejin.cn/post/7276467933235642405
收起阅读 »

很容易中招的一种索引失效场景,一定要小心

快过年,我的线上发布出现故障 “五哥,你在上线吗?”,旁边有一个声音传来。 “啊,怎么了?”。真是要命,在上线发布时候,我最讨厌别人叫我的名字 。我慌忙站起来,看向身后,原来是 建哥在问我。我慌忙的问,怎么回事。 “DBA 刚才在群里说,Task数据库 cpu...
继续阅读 »

快过年,我的线上发布出现故障


“五哥,你在上线吗?”,旁边有一个声音传来。


“啊,怎么了?”。真是要命,在上线发布时候,我最讨厌别人叫我的名字 。我慌忙站起来,看向身后,原来是 建哥在问我。我慌忙的问,怎么回事。


“DBA 刚才在群里说,Task数据库 cpu 负载增加!有大量慢查询”,建哥来我身边,跟我说。


慢慢的,我身边聚集着越来越多的人


image.png


“你在上线Task服务吗?改动什么内容了,看看要不要立即回滚?”旁边传来声音。此时,我的心开始怦怦乱跳,手心发痒,紧张不已。


我检查着线上机器的日志,试图证明报警的原因不是出在我这里。


我对着电脑,微微颤抖地回答大家:“我只是升级了基础架构的Jar包,其他内容没有改动啊。”此时我已分不清是谁在跟我说话,只能对着电脑作答……


这时DBA在群里发送了一条SQL,他说这条SQL导致了大量的慢查询。


我突然记起来了,我转过头问林哥:“林哥,你上线了什么内容?”这次林哥有代码的变更,跟我一起上线。我觉得可能是他那边有问题。


果然,林哥看着代码发呆。他嘟囔道:“我添加了索引啊,怎么会有慢查询呢?”原来慢查询的SQL是林哥刚刚添加的,这一刻我心里的石头放下了,问题并不在我,我轻松了许多。


“那我先回滚吧”,幸好我们刚发布了一半,现在回滚还来得及,我尝试回滚机器。此刻我的紧张情绪稍稍平静下来,手也不再发抖。


既然不是我的问题,我可以以吃瓜的心态,暗中观察事态的发展。我心想:真是吓死我了,幸好不是我的错。


然而我也有一些小抱怨:为什么非要和我一起搭车上线,出了事故,还得把我拖进来。


故障发生前的半小时


2年前除夕前的一周,我正准备着过年前的最后一次线上发布,这时候我刚入职两个月,自然而然会被分配一些简单的小活。这次上线的内容是将基础架构的Jar包升级到新版本。一般情况下,这种配套升级工作不会出问题,只需要按部就班上线就行。


“五哥,你是要上线 Task服务吗?”,工位旁的林哥问我,当时我正做着上线前的准备工作。


“对啊,马上要发布,怎么了?”,我转身回复他。


“我这有一个代码变更,跟你搭车一起上线吧,改动内容不太多。已经测试验证过了”,林哥说着,把代码变更内容发给我,简单和我说了下代码变更的内容。我看着改动内容确实不太多,新增了一个SQL查询,于是便答应下来。我重新打包,准备发布上线。


半小时以后,便出现了文章开头的情景。新增加的SQL 导致大量慢查询,数据库险些被打挂。


为什么加了索引,还会出现慢查询呢?


”加了索引,为什么还有慢查询?“,这是大家共同的疑问。


事后分析故障的原因,通过 mysql explain 命令,查看该SQL 确实没有命中索引,从而导致慢查询。


这个SQL 大概长这个样子!我去掉了业务相关的部分。


select * from order_discount_detail where orderId = 1123;


order_discount_detailorderId 这一列上确实加了索引,不应该出现慢查询,乍一看,没有什么问题。我本能的想到了索引失效的几种场景。难道是类型不匹配,导致索引失效?


果不其然, orderId 在数据库中的类型 是 varchar 类型,而传参是按照 long 类型传的。


复习一下: 类型转换导致索引失效


类型转换导致索引失效,是很容易犯的错误


因为在某些特殊场景下要对接外部订单,存在订单Id为字符串的情况,所以 orderId被设计成 varchar 字符串类型。然而出问题的场景比较明确,订单id 就是long类型,不可能是字符串类型。


所以林哥,他在使用Mybatis 时,直接使用 long 类型的 orderId字段传参,并且没有意识到两者数据类型不对。


因为测试环境数据量比较小,即使没有命中索引,也不会有很严重的慢查询,并且测试环境请求量比较低,该慢查询SQL 执行次数较少,所以对数据库压力不大,测试阶段一直没有发现性能问题。


直到代码发布到线上环境————数据量和访问量都非常高的环境,差点把数据库打挂。


mybatis 能避免 “类型转换导致索引失效” 的问题吗?


mybatis能自动识别数据库和Java类型不一致的情况吗?如果发现java类型和数据库类型不一致,自动把java 类型转换为数据库类型,就能避免索引失效的情况!


答案是不能。我没找到 mybatis 有这个能力。


mybatis 使用 #{} 占位符,会自动根据 参数的 Java 类型填充到 SQL中,同时可以避免SQL注入问题。


例如刚才的SQL 在 mybatis中这样写。


select * from order_discount_detail where orderId = #{orderId};


orderId 是 String 类型,SQL就变为


select * from order_discount_detail where orderId = ‘1123’;


mybatis 完全根据 传参的java类型,构建SQL,所以不要认为 mybatis帮你处理好java和数据库的类型差异问题,你需要自己关注这个问题!


再次提醒,"类型转换导致索引失效"的问题,非常容易踩坑。并且很难在测试环境发现性能问题,等到线上再发现问题就晚了,大家一定要小心!小心!


险些背锅


可能有朋友疑问,为什么发布一半时出现慢查询,单机发布阶段不能发现这个问题吗?


之所以没发现这个问题,是因为 新增SQL在 Kafka消费逻辑中,由于单机发布机器启动时没有争抢到 kafka 分片,所以没有走到新代码逻辑。


此外也没有遵循降级上线的代码规范,如果上线默认是降级状态,上线过程中就不会有问题。放量阶段可以通过降级开关快速止损,避免回滚机器过程缓慢而导致的长时间故障。


不是我的问题,为什么我也背了锅


因为我在发布阶段没有遵循规范,按照规定的流程应该在单机发布完成后进行引流压测。引流压测是指修改机器的Rpc权重,将Rpc请求集中到新发布的单机上,这样就能提前发现线上问题。


然而由于我偷懒,跳过了单机引流压测。由于发布的第一台机器没有抢占到Kafka分片,因此无法执行新代码逻辑。即使进行了单机引流压测,也无法提前发现故障。虽然如此,但我确实没有遵循发布规范,错在我。


如果上线时没有出现故障,这种不规范的上线流程可能不会受到责备。但如果出现问题,那只能怪我倒霉。在复盘过程中,我的领导抓住了这件事,给予了重点批评。作为刚入职的新人,被指责确实让我感到不舒服。


快要过年了,就因为搭车上线,自己也要承担别人犯错的后果,让我很难受。但是自己确实也有错,当时我的心情复杂而沉重。


两年前的事了,说出来让大家吃个瓜,乐呵一下。如果这瓜还行,东东发财的小手点个赞


作者:五阳
来源:juejin.cn/post/7305572311812636683
收起阅读 »

什么?你设计接口什么都不考虑?

后端接口设计 如果让你设计一个接口,你会考虑哪些问题? 1.接口参数校验 接口的入参和返回值都需要进行校验。 入参是否不能为空,入参的长度限制是多少,入参的格式限制,如邮箱格式限制 返回值是否为空,如果为空的时候是否返回默认值,这个默认值需要和前端协商 ...
继续阅读 »

后端接口设计


如果让你设计一个接口,你会考虑哪些问题?


image.png


1.接口参数校验


接口的入参和返回值都需要进行校验。



  • 入参是否不能为空,入参的长度限制是多少,入参的格式限制,如邮箱格式限制

  • 返回值是否为空,如果为空的时候是否返回默认值,这个默认值需要和前端协商


2.接口扩展性


举个例子,比如用户在进行某些操作之后,后端需要进行消息推送,那么是直接针对这个业务流程来开发一个专门为这个业务流程服务的消息推送功能呢?还是说将消息推送整合为一个通用的接口,其他流程都可以进行调用,并非针对特定业务。


这个场景可能光靠说不是很能理解,大家想想策略工厂设计模式,是不是可以根据不同的策略,来选择不同的实现方式呢?再结合上面的这个例子,是否对扩展性有了进一步的理解呢?


3.接口幂等设计


什么是幂等呢?幂等是指多次调用接口不会改变业务状态,可以保证重复调用的结果和单次调用的结果一致


举个例子,在购物商场里面你用手机下单,要买某个商品,你需要去支付,然后你点击了支付,但是因为网速问题,始终没有跳转到


支付界面,于是你又连点了几次支付,那在没有做接口幂等的时候,是不是你点击了多少次支付,我们就需要执行多少次支付操作?


所以接口幂等到的是什么?防止用户多次调用同一个接口



  • 对于查询和删除类型的接口,不论调用多少次,都是不会产生错误的业务逻辑和数据的,因此无需幂等处理

  • 对于新增和修改,例如转账等操作,重复提交就会导致多次转账,这是很严重的,影响业务的接口需要做接口幂等的处理,跟前端约定好一个固定的token接口,先通过用户的id获取全局的token,写入到Redis缓存,请求时带上Token,后端做处理


image.png


4.关键接口日志打印


关键的业务代码,是需要打印日志进行监测的,在入参和返回值或者如catch代码块中的位置进行日志打印



  • 方便排查和定位线上问题,划清责任

  • 生产环境是没有办法进行debug的,必须依靠日志查问题,看看到底是出现了什么异常情况


5.核心接口要进行线程池隔离


分类查询啊,首页数据等接口,都有可能使用到线程池,某些普通接口也可能会使用到线程池,如果不做线程池隔离,万一普通接口出现bug把线程池打满了,会导致你的主业务受到影响


image.png


6.第三方接口异常重试


如果有场景出现调用第三方接口,或者分布式远程服务的话,需要考虑的问题



  • 异常处理


    比如你在调用别人提供的接口的时候,如果出现异常了,是要进行重试还是直接就是当做失败


  • 请求超时


    有时候如果对方请求迟迟无响应,难道就一直等着吗?肯定不是这样的,需要设法预估对方接口响应时间,设置一个超时断开的机制,以保护接口,提高接口的可用性,举个例子,你去调用别人对外提供的一个接口,然后你去发http请求,始终响应不回来,此时你又没设置超时机制,最后响应方进程假死,请求一直占着线程不释放,拖垮线程池。


  • 重试机制


    如果调用对外的接口失败了或者超时了,是否需要重新尝试调用呢?还是失败了就直接返回失败的数据?



7.接口是否需要采用异步处理


举个例子,比如你实现一个用户注册的接口。用户注册成功时,发个邮件或者短信去通知用户。这个邮件或者发短信,就更适合异步处理。总不能一个通知类的失败,导致注册失败吧。 那我们如何进行异步操作呢?可以使用消息队列,就是用户注册成功后,生产者产生一个注册成功的消息,消费者拉到注册成功的消息,就发送通知。


image.png


8.接口查询优化,串行优化为并行


假设我们要开发一个网站的首页,我们设计了一个首页数据查询的接口,这个接口需要查用户信息,需要查头部信息,需要查新闻信息


等等之类的,最简单的就是一个一个接口串行调用,那要是想要提高性能,那就采取并行调用的方式,同时查询,而不是阻塞


可以使用CompletableFuture(推荐)或者FutureTask(不推荐)


        Map<Long, List<SubjectLabelBO>> map = new HashMap<>();
      List<CompletableFuture<Map<Long, List<SubjectLabelBO>>>> completableFutureList =
      categoryBOList.stream().map(category ->
              CompletableFuture.supplyAsync(() -> getLabelBOList(category), labelThreadPool)
      ).collect(Collectors.toList());

      completableFutureList.forEach(future -> {
          try {
              Map<Long, List<SubjectLabelBO>> resultMap = future.get(); //这里会阻塞
              map.putAll(resultMap);
          } catch (Exception e) {
              e.printStackTrace();
          }
      });
       
public Map<Long, List<SubjectLabelBO>> getLabelBOList(SubjectCategoryBO category) {...}

9.高频接口注意限流


自定义注解 + AOP


@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimiter {
   int value() default 1;
   int durationInSeconds() default 1;
}

@Aspect
@Component
public class RateLimiterAspect {

   private final ConcurrentHashMap<String, RateLimiter> rateLimiters = new ConcurrentHashMap<>();

   @Pointcut("@annotation(RateLimiter)")
   public void rateLimiterPointcut(RateLimiter rateLimiterAnnotation) {
  }

   @Around("rateLimiterPointcut(rateLimiterAnnotation)")
   public Object around(ProceedingJoinPoint joinPoint, RateLimiter rateLimiterAnnotation) throws Throwable {
       int permits = rateLimiterAnnotation.value();
       int durationInSeconds = rateLimiterAnnotation.durationInSeconds();

       // 使用方法签名作为 RateLimiter 的 key
       String key = joinPoint.getSignature().toLongString();
       com.google.common.util.concurrent.RateLimiter rateLimiter = rateLimiters.computeIfAbsent(key, k -> com.google.common.util.concurrent.RateLimiter.create((double) permits / durationInSeconds));

       // 尝试获取令牌,如果获取到则执行方法,否则抛出异常
       if (rateLimiter.tryAcquire()) {
           return joinPoint.proceed();
      } else {
           throw new RuntimeException("Rate limit exceeded.");
      }
  }
}

@RestController
public class ApiController {

   @GetMapping("/api/limited")
   @RateLimiter(value = 10, durationInSeconds = 60) //限制为每分钟 10 次请求
   public String limitedEndpoint() {
       return "This API has a rate limit of 10 requests per minute.";
  }

   @GetMapping("/api/unlimited")
   public String unlimitedEndpoint() {
       return "This API has no rate limit.";
  }
}

10.保障接口安全


配置黑白名单,用Bloom过滤器实现黑白名单的配置


具体代码不贴出来了,大家可以去看看布隆过滤器的具体使用


11.接口控制锁粒度


在高并发场景下,为了防止超卖等情况,我们会对共享资源进行加锁的操作来保证线程安全的问题,但是如果加锁的粒度过大,是会影响


到接口性能的。那什么是加锁粒度呢?举一个例子,你带了一封情书回家,但是不想被爸妈发现,然后你偷偷回到房间里放到一个可以锁


住的抽屉里面,而不用把房间的门锁给锁上。 无论是使用synchronized加锁还是redis分布式锁,只需要在共享临界资源加锁即可,不涉


及共享资源的,就不必要加锁。



  • 锁粒度过大:


    把方法A和方法B全部进行加锁,但是实际上我只是想要对A加锁,这就是锁粒度过大



void test(){
   synchronized (this) {
      B();
      A();
  }
}


  • 缩小锁粒度


void test(){
      B();
   synchronized (this) {
      A();
  }
}

12.避免长事务问题


长事务期间可能伴随cpu、内存升高、严重时会导致服务端整体响应缓慢,导致在线应用无法使用


产生长事务的原因除了sql本身可能存在问题外,和应用层的事务控制逻辑也有很大的关系。



  • 如何尽可能的避免长事务问题呢?


    1.RPC远程调用不要放到事务里面


    2.一些查询相关的操作如果可用,尽量放到事务外面


    3.并发场景下,尽量避免使用@Transactional注解来操作事务,使用TransactionTemplate的编排式事务来灵活控制事务的范围



在原先使用@Transactional来管理事务的时候是这样的


@Transactional
public int createUser(User user){
   //保存用户信息
   userDao.save(user);
   passCertDao.updateFlag(user.getPassId());
   // 该方法为远程RPC接口
   sendEmailRpc(user.getEmail());
   return user.getUserId();
}

使用TransactionTemplat进行编排式事务


@Resource
private TransactionTemplate transactionTemplate;

public int createUser(User user){
   transactionTemplate.execute(transactionStatus -> {
     try {
        userDao.save(user);
        passCertDao.updateFlag(user.getPassId());
    } catch (Exception e) {
        // 异常手动设置回滚
        transactionStatus.setRollbackOnly();
    }
     return true;
  });
// 该方法为远程RPC接口
   sendEmailRpc(user.getEmail());
   return user.getUserId();
}

作者:radient
来源:juejin.cn/post/7343548913034133523
收起阅读 »

如何给application.yml文件的敏感信息加密?

Hello,大家好,我是云帆。在我们传统的基于SpringBoot开发的项目中,在配置文件里,或多或少的都会有一些敏感信息,这样就会丢失一定的安全性,所以我们就需要,对敏感信息进行加密。我们可以使用jasypt工具进行加密。 好了废话不多少,直接进入正题: 1...
继续阅读 »

Hello,大家好,我是云帆。在我们传统的基于SpringBoot开发的项目中,在配置文件里,或多或少的都会有一些敏感信息,这样就会丢失一定的安全性,所以我们就需要,对敏感信息进行加密。我们可以使用jasypt工具进行加密。


好了废话不多少,直接进入正题:


1. 导入依赖


<dependency>  
<groupId>com.github.ulisesbocchio</groupId>
<artifactId>jasypt-spring-boot-starter</artifactId>
<version>3.0.5</version>
</dependency>

我的Demo里使用的是SpringBoot3.0之后的版本,所以大家如果像我一样都是基于SpringBoot3.0之后的,jasypt一定要使用3.0.5以后的版本。


2. 使用jasypt


我们在配置文件里写几行配置


jasypt:  
encryptor:
password: sdjsdbshdbfuasd
property:
prefix: ENC(
suffix: )


password是加密密码,必须配置这一项,值可以随便输入。
prefixsuffix是默认配置,也可以自定义,默认值就是ENC(),这个是自动解密使用的。



2.1. 加/解密


jasypt 提供了一个工具类接口,StringEncryptor,这个接口提供了加解密方法。下面是他的源码。


public interface StringEncryptor {  

/**
* 加密输入信息
*
* @param 要加密的信息
* @return 加密结果
*/

public String encrypt(String message);


/**
* 解密加密信息
*
* @param 加密信息(encryptedMessage) 要解密的加密信息
* @return 解密结果
*/

public String decrypt(String encryptedMessage);

}

我们在 test 测试类中,将要进行加密的文本使用encrypt方法进行加密


@SpringBootTest  
@Slf4j
class JasryptApplicationTests {

@Autowired
private StringEncryptor stringEncryptor;

@Test
void contextLoads() {
String username = stringEncryptor.encrypt("root");
String password = stringEncryptor.encrypt("root");
log.info("username encrypt is {}", username);
log.info("password encrypt is {}", password);
log.info("username decrypt is {}", stringEncryptor.decrypt(username));
log.info("password decrypt is {}", stringEncryptor.decrypt(password));
}

}

上边代码,加密的内容是,MySQL的用户名密码,同时对它们进行加密和解密,你当然可以对任意配置信息进行加解密操作。看看输出内容:


2023-07-23T18:59:50.621+08:00  INFO 9489 --- [           main] c.e.jasrypt.JasryptApplicationTests      : username encrypt is 61zSoixtNayUruXt5x84kEKO9jGnZObTGCa1+k5Yg9F7qSUiZvp5fG31AMuVqrot
2023-07-23T18:59:50.621+08:00 INFO 9489 --- [ main] c.e.jasrypt.JasryptApplicationTests : password encrypt is a6snCZCkbQFKkQqxN2bS18ags04yZxH+THwIL5RjGocEjG9sLkJvvasPFFVxEBWv
2023-07-23T18:59:50.623+08:00 INFO 9489 --- [ main] c.e.jasrypt.JasryptApplicationTests : username decrypt is root
2023-07-23T18:59:50.630+08:00 INFO 9489 --- [ main] c.e.jasrypt.JasryptApplicationTests : password decrypt is root

加密默认使用的是PBEWITHHMACSHA512ANDAES_256加密
我们将密文,替换到数据源,配置:


spring:  
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/honey?useUnicode=true&zeroDateTimeBehavior=convertToNull&autoReconnect=true&characterEncoding=utf-8
username: ENC(61zSoixtNayUruXt5x84kEKO9jGnZObTGCa1+k5Yg9F7qSUiZvp5fG31AMuVqrot)
password: ENC(a6snCZCkbQFKkQqxN2bS18ags04yZxH+THwIL5RjGocEjG9sLkJvvasPFFVxEBWv)

⚠️注意别忘了加上前缀和后缀,如上边代码。


这个时候就已经完成了,但是官方不建议我们将加密密码放到配置文件中,我们应作为系统属性、命令行参数或环境变量传递,只要其名称是 jasypt.encryptor.password,就能正常工作。


我们可以将项目打为jar包然后使用 java -jar命令


java -jar jasrypt-0.0.1-SNAPSHOT.jar --jasypt.encryptor.password=加密密码

⚠️加密密码必须与之前给属性加密时用的加密密码一致。


3. 结尾


好了,分享到这里就结束了,希望小伙伴们多多点赞,如果有建议,欢迎留言。


此致


作者:寒江雪369
来源:juejin.cn/post/7258850748149203000
收起阅读 »

作为前端开发,感受下 nginx 带来的魅力!🔥🔥

引言:纯干货分享,汇总了我在工作中八年遇到的各种 Nginx 使用场景,对这篇文章进行了细致的整理和层次分明的讲解,旨在提供简洁而深入的内容。希望这能为你提供帮助和启发! 对于前端开发人员来说,Node.js 是一种熟悉的技术。虽然 Nginx 和 Node...
继续阅读 »

引言:纯干货分享,汇总了我在工作中八年遇到的各种 Nginx 使用场景,对这篇文章进行了细致的整理和层次分明的讲解,旨在提供简洁而深入的内容。希望这能为你提供帮助和启发!
1802c30a7bb47ccc7cd70314829ac04796140850.jpeg


对于前端开发人员来说,Node.js 是一种熟悉的技术。虽然 Nginx 和 Node.js 在某些理念上有相似之处,比如都支持 HTTP 服务、事件驱动和异步非阻塞操作,但两者并不冲突,各有各自擅长的领域:



  • Nginx:擅长处理底层的服务器端资源,如静态资源处理、反向代理和负载均衡。

  • Node.js:更擅长处理上层的具体业务逻辑。


而两者的结合可以实现更加高效和强大的应用服务架构,下面我们就来看一下。借助文章目录阅读,效率更高。目前您可能还用不到这篇文章,不过可以先收藏起来。希望将来它能为您提供所需的帮助!


Nginx 是什么?


Nginx 是一个高性能的HTTP和反向代理服务器,由俄罗斯程序员Igor Sysoev于 2004 年使用 C 语言开发。它最初设计是为了应对俄罗斯大型门户网站的高流量挑战。


1667274211133.jpg


反向代理是什么?(🔥面试会问)


让我们先从代理说起。Nginx 常被用作反向代理,那么什么是正向代理呢?



  • 正向代理:客户端知道要访问的服务器地址,但服务器只知道请求来自某个代理,而不清楚具体的客户端。正向代理隐藏了真实客户端的信息。例如,当无法直接访问国外网站时,我们通过代理服务器访问特定网址。






  • 反向代理:多个客户端向反向代理服务器发送请求,Nginx 根据一定的规则将请求转发至不同的服务器。客户端不知道具体请求将被转发至哪台服务器,反向代理隐藏了后端服务器的信息。





Nginx 的核心特性


Nginx包含以下七个核心特性,使它成为处理高并发和大数据量请求的理想选择:


1. 事件驱动:Nginx采用高效的异步事件模型,利用 I/O 多路复用技术。这种模型使 Nginx 能在占用最小内存的同时处理大量并发连接。


2. 高度可扩展:Nginx能够支持数千乃至数万个并发连接,非常适合大型网站和高并发应用。例如:为不同的虚拟主机设置不同的 worker 进程数,以增加并发处理能力:


http {
worker_processes auto; # 根据系统CPU核心数自动设置worker进程数
}

3. 轻量级:相较于传统的基于进程的Web服务器(如Apache),Nginx的内存占用更低,得益于其事件驱动模型,每个连接只占用极小的内存空间。


4. 热部署:Nginx支持热部署功能,允许在不重启服务器的情况下更新配置和模块。例如:在修改了 Nginx 配置文件后,可以快速热部署 Nginx 配置:


sudo nginx -s reload

5. 负载均衡:Nginx内置负载均衡功能,通过upstream模块实现客户端请求在多个后端服务器间的分配,从而提高服务的整体处理能力。以下是一个简单的upstream配置,它将请求轮询分配到三个后端服务器:


upstream backend {
server backend1.example.com;
server backend2.example.com;
server backend3.example.com;
}

server {
location / {
proxy_pass http://backend; # 将请求转发到upstream定义的backend组
}
}

6. 高性能:Nginx 在多项 Web 性能测试中表现卓越,能快速处理静态文件、索引文件及代理请求。比如:配置 Nginx 作为反向代理服务器,为大型静态文件下载服务:


location /files/ {
alias /path/to/files/; # 设置实际文件存储路径
expires 30d; # 设置文件过期时间为30天
}

7. 安全性:Nginx支持SSL/TLS协议,能够作为安全的Web服务器或反向代理使用。


server {
listen 443 ssl;
ssl_certificate /path/to/fullchain.pem; # 证书路径
ssl_certificate_key /path/to/privatekey.pem; # 私钥路径
ssl_protocols TLSv1.2 TLSv1.3; # 支持的SSL协议
}

搭建 Nginx 服务


1-48.jpeg


如何安装?


在 Linux 系统中,可以使用包管理器来安装 Nginx。例如,在基于 Debian 的系统上,可以使用 apt


sudo apt update
sudo apt install nginx

在基于 Red Hat 的系统上,可以使用 yum 或 dnf


sudo yum install epel-release
sudo yum install nginx

在安装完成后,通常可以通过以下命令启动 Nginx 服务:


sudo systemctl start nginx

设置开机自启动:


sudo systemctl enable nginx

启动成功后,在浏览器输入服务器 ip 地址或者域名,如果看到 Nginx 的默认欢迎页面,说明 Nginx 运行成功。


常用命令有哪些


在日常的服务器管理和运维中,使用脚本来管理 Nginx 是很常见的。这可以帮助自动化一些常规任务,如启动、停止、重载配置等。以下是一些常用的 Nginx 脚本命令,这些脚本通常用于 Bash 环境下:



  1. 启动 Nginx:nginx

  2. 停止 Nginx:nginx -s stop

  3. 重新加载 Nginx:nginx -s reload

  4. 检查 Nginx 配置文件:nginx -t(检查配置文件的正确性)

  5. 查看 Nginx 版本:nginx -v


其他常用的配合脚本命令:



  1. 查看进程命令:ps -ef | grep nginx

  2. 查看日志,在logs目录下输入指令:more access.log


。。。还有哪些常用命令,评论区一起讨论下!


配置文件构成(🔥核心重点,一定要了解)


Nginx配置文件主要由指令组成,这些指令可以分布在多个上下文中,主要上下文包括:



  1. main: 全局配置,影响其他所有上下文。

  2. events: 配置如何处理连接。

  3. http: 配置HTTP服务器的参数。

    • server: 配置虚拟主机的参数。

      • location: 基于请求的URI来配置特定的参数。






worker_processes auto;   # worker_processes定义Nginx可以启动的worker进程数,auto表示自动检测 

# 定义Nginx如何处理连接
events {
worker_connections 1024; # worker_connections定义每个worker进程可以同时打开的最大连接数
}

# 定义HTTP服务器的参数
http {
include mime.types; # 引入mime.types文件,该文件定义了不同文件类型的MIME类型
default_type application/octet-stream; # 设置默认的文件MIME类型为application/octet-stream
sendfile on; # 开启高效的文件传输模式
keepalive_timeout 65; # 设置长连接超时时间

# 定义一个虚拟主机
server {
listen 80; # 指定监听的端口
server_name localhost; # 设置服务器的主机名,这里设置为localhost

# 对URL路径进行配置
location / {
root /usr/share/nginx/html; # 指定根目录的路径
index index.html index.htm; # 设置默认索引文件的名称,如果请求的是一个目录,则按此顺序查找文件
}

# 错误页面配置,当请求的文件不存在时,返回404错误页面
error_page 404 /404.html;

# 定义/40x.html的位置
location = /40x.html {
# 此处可以配置额外的指令,如代理、重写等,但在此配置中为空
}

# 错误页面配置,当发生500、502、503、504等服务器内部错误时,返回相应的错误页面
error_page 500 502 503 504 /50x.html;

# 定义/50x.html的位置
location = /50x.html {
# 同上,此处可以配置额外的指令
}
}
}

这个配置文件设置了Nginx监听80端口,使用root指令指定网站的根目录,并为404和50x错误页面提供了位置。其中,userworker_processes指令在main上下文中,events块定义了事件处理配置,http块定义了HTTP服务器配置,包含一个server块,该块定义了一个虚拟主机,以及两个location块,分别定义了对于404和50x错误的处理。


进入正题,详细看下如何配置


a0e2c2ce0c28044531b1589f5e3fb83263cb690c.jpeg


打开 Nginx 配置世界大门


下面这段是 Nginx 配置定义了一个服务器块(server block),它指定了如何处理发往特定域名的 HTTP 请求。


server {
listen 80; # 监听80端口,HTTP请求的默认端口
client_max_body_size 100m; # 设置客户端请求体的最大大小为100MB
index index.html; # 设置默认的索引文件为index.html
root /user/project/admin; # 设置Web内容的根目录为/user/project/admin

# 路由配置,处理所有URL路径
location ~ /* {
proxy_pass http://127.0.0.1:3001; # 将请求代理到本机的3001端口
proxy_redirect off; # 关闭代理重定向

# 设置代理请求头,以便后端服务器可以获取客户端的原始信息
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

# 定义代理服务器失败时的行为,如遇到错误、超时等,尝试下一个后端服务器
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504;
proxy_max_temp_file_size 0; # 禁止代理临时文件写入

# 设置代理连接、发送和读取的超时时间
proxy_connect_timeout 90;
proxy_send_timeout 90;
proxy_read_timeout 90;

# 设置代理的缓冲区大小
proxy_buffer_size 4k;
proxy_buffers 4 32k;
proxy_busy_buffers_size 64k;
proxy_temp_file_write_size 64k;
}

# 对图片文件设置缓存过期时间,客户端可以在1天内使用本地缓存
location ~ .*.(gif|jpg|jpeg|png|swf)$ {
expires 1d;
}

# 对JavaScript和CSS文件设置缓存过期时间,客户端可以在24小时内使用本地缓存
location ~ .*.(js|css)?$ {
expires 24h;
}

# 允许访问/.well-known目录下的所有文件,通常用于WebFinger、OAuth等协议
location ~ /.well-known {
allow all;
}

# 禁止访问隐藏文件,即以点开头的文件或目录
location ~ /. {
deny all;
}

# 指定访问日志的路径,日志将记录在/user/logs/admin.log文件中
access_log /user/logs/admin.log;
}

注意:Nginx 支持使用正则表达式来匹配 URI,这极大地增强了配置的灵活性。在 Nginx 配置中,正则表达式通过 ~ 来指定。


例如,location ~ /* 可以匹配所有请求。另一个例子是 location ~ .*.(gif|jpg|jpeg|png|swf)$,这个表达式用于匹配以 gif、jpg、jpeg、png 或 swf 这些图片文件扩展名结尾的请求。


Nginx 配置实战(🔥可以复制,直接拿来使用)


以下是一些常见的 Nginx 配置实战案例:


1、静态资源服务:前端web


server {
listen 80;
server_name example.com;
location / {
root /path/to/your/static/files;
index index.html index.htm;
}
location ~* \.(jpg|png|gif|jpeg)$ {
expires 30d;
add_header Cache-Control "public";
}
}

在这个案例中,Nginx 配置为服务静态文件,如 HTML、CSS、JavaScript 和图片等。通过设置 root 指令,指定了静态文件的根目录。同时,对于图片文件,通过 expires 指令设置了缓存时间为 30 天,减少了服务器的负载和用户等待时间。


2、反向代理


server {
listen 80;
server_name api.example.com;
location / {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}

这个案例展示了如何配置 Nginx 作为反向代理服务器。当客户端请求 api.example.com 时,Nginx 会将请求转发到后端服务器集群。通过设置 proxy_set_header,可以修改客户端请求的头部信息,确保后端服务器能够正确处理请求。


3、负载均衡


http {
upstream backend {
server backend1.example.com;
server backend2.example.com;
server backend3.example.com;
}
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
}

在这个负载均衡的案例中,Nginx 将请求分发给多个后端服务器。通过 upstream 指令定义了一个服务器组,然后在 location 块中使用 proxy_pass 指令将请求代理到这个服务器组。Nginx 支持多种负载均衡策略,如轮询(默认)、IP 哈希等。


4、HTTPS 配置


server {
listen 443 ssl;
server_name example.com;
ssl_certificate /path/to/your/fullchain.pem;
ssl_certificate_key /path/to/your/private.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384';
ssl_prefer_server_ciphers on;
location / {
root /path/to/your/https/static/files;
index index.html index.htm;
}
}

这个案例展示了如何配置 Nginx 以支持 HTTPS。通过指定 SSL 证书和私钥的路径,以及设置 SSL 协议和加密套件,可以确保数据传输的安全。同时,建议使用 HTTP/2 协议以提升性能。


5、安全防护


server {
listen 80;
server_name example.com;
location / {
# 防止 SQL 注入等攻击
rewrite ^/(.*)$ /index.php?param=$1 break;
# 限制请求方法,只允许 GET 和 POST
if ($request_method !~ ^(GET|POST)$ ) {
return 444;
}
# 防止跨站请求伪造
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
add_header X-XSS-Protection "1; mode=block";
}
}

通过 rewrite 指令,可以防止一些常见的 Web 攻击,如 SQL 注入。这种限制请求方法,可以减少服务器被恶意利用的风险。同时,添加了一些 HTTP 头部来增强浏览器安全,如防止点击劫持和跨站脚本攻击(XSS)等。


63d12faf8e9f0972ed2f0d90_1024.jpeg


Nginx 深入学习-负载均衡


在负载均衡的应用场景中,Nginx 通常作为反向代理服务器,接收客户端的请求并将其分发到一组后端服务器。这样做不仅可以分散负载、提升网站的响应速度,更能提高系统的可用性。


健康检查


Nginx 能够监测后端服务器的健康状态。如果服务器无法正常工作,Nginx 将自动将请求重新分配给其他健康的服务器。


http {
upstream myapp1 { # 定义了后端服务器组
server srv1.example.com;
server srv2.example.com;
server srv3.example.com;

# 健康检查配置。
# 每10秒进行一次健康检查,如果连续3次健康检查失败,则认为服务器不健康;
# 如果连续2次健康检查成功,则认为服务器恢复健康。
check interval=10s fails=3 passes=2;
}

server {
listen 80;

location / {
proxy_pass http://myapp1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
}


check interval=10s fails=3 passes=2; 这样的配置语法在开源版本的 NGINX 上是不支持的。这是 ngx_http_upstream_check_module 模块的特有语法,而该模块不包含在 NGINX 的开源版本中,需要自行下载、编译和安装。该模块是开源免费的,具体详情请参见 ngx_http_upstream_check_module 文档



Nginx 会定期向定义的服务器发送健康检查请求。如果服务器响应正常,则认为服务器健康;如果服务器没有响应或者响应异常,则认为服务器不健康。当服务器被标记为不健康时,Nginx 将不再将请求转发到该服务器,直到它恢复健康。


负载均衡算法


Nginx 支持多种负载均衡算法,可以适应不同的应用场景。以下是几种常见的负载均衡算法的详细说明和示例:


1、Weight轮询(默认):权重轮询算法是 Nginx 默认的负载均衡算法。它按顺序将请求逐一分配到不同的服务器上。通过设置服务器权重(weight)来调整不同服务器上请求的分配率。


upstream backend {
server backend1.example.com weight=3; # 设置backend1的权重为3
server backend2.example.com; # backend2的权重为默认值1
server backend3.example.com weight=5; # 设置backend3的权重为5
}

如果某一服务器宕机,Nginx会自动将该服务器从队列中剔除,请求代理会继续分配到其他健康的服务器上。


2、IP Hash 算法: 根据客户端IP地址的哈希值分配请求,确保客户端始终连接到同一台服务器。


upstream backend {
ip_hash; # 启用IP哈希算法
server backend1.example.com;
server backend2.example.com;
}

根据客户端请求的IP地址的哈希值进行匹配,将具有相同IP哈希值的客户端分配到指定的服务器。这样可以确保同一客户端的请求始终被分配到同一台服务器,有助于保持用户的会话状态。


3、fair算法: 根据服务器的响应时间和负载来分配请求。


upstream backend {
fair; # 启用公平调度算法
server backend1.example.com;
server backend2.example.com;
server backend3.example.com;
}

它结合了轮询和IP哈希的优点,但Nginx默认不支持公平调度算法,需要安装额外的模块(upstream_fair)来实现。


4、URL Hash 算法: 根据请求的 URL 的哈希值分配请求,每个请求的URL会被分配到指定的服务器,有助于提高缓存效率。


upstream backend {
hash $request_uri; # 启用URL哈希算法
server backend1.example.com;
server backend2.example.com;
}
# 根据请求的URL哈希值来决定将请求发送到backend1还是backend2。

这种方法需要安装Nginx的hash软件包。


开源模块


Nginx拥有丰富的开源模块,有很多还有待我们探索,除了一些定制化的需求需要自己开发,大部分的功能都有开源。大家可以在 NGINX 社区、GitHub 上搜索 "nginx module" 可以找到。


image (1).png


总结


虽然前端人员可能不经常直接操作 Nginx,但了解其基本概念和简单的配置操作是必要的。这样,在需要自行配置 Nginx 的情况下,前端人员能够知晓如何进行基本的设置和调整。


作者:Sailing
来源:juejin.cn/post/7368433531926052874
收起阅读 »

别再用后缀判断文件类型了,来认识一下魔数头

引言 最近公司整改,招了个安全测试,安全测试的同事搞了个危险的文件,悄咪咪加了个.png的后缀,就丢到我们的生产服务器上面去了,这么勇,你敢信? 不管你信不信,事实他就是发生了,就问你怕不怕。 好了,这里也就暴露了一个很大的问题,我们的开发同事判断文件类型,...
继续阅读 »

引言



最近公司整改,招了个安全测试,安全测试的同事搞了个危险的文件,悄咪咪加了个.png的后缀,就丢到我们的生产服务器上面去了,这么勇,你敢信?


不管你信不信,事实他就是发生了,就问你怕不怕。


好了,这里也就暴露了一个很大的问题,我们的开发同事判断文件类型,是使用文件后缀来判断的,所以被直接跳过。咱来看看更加科学的识别方式。



一、认识魔数


魔数:也被称为签名或文件签名,是一种用于识别文件类型和格式的短序列字节。它们通常位于文件的开头,并被设计为易于识别,以便软件可以快速确定文件是否是它所支持的格式。


魔数是一种简单的识别机制,它由一系列字节组成,这些字节在特定的文件格式中是唯一的。当一个程序打开一个文件时,它会检查文件的开始处是否包含这些特定的字节。如果找到,程序就认为文件是该格式的,并按照相应的规则进行解析和处理。


二、文件类型检测的原理



文件类型检测通常基于文件的“魔数”(magic number),也称为签名或文件签名。魔数是文件开头的字节序列,用于标识文件格式。以下是文件类型检测的原理和步骤:



2.1 文件头的读取方法


image.png


打开文件:首先,需要以二进制模式打开文件,以便能够读取文件的原始字节。


读取字节:接着,读取文件开头的一定数量的字节(通常是前几个字节)。


关闭文件:读取完成后,关闭文件以释放资源。


2.2 如何通过文件头识别文件类型


image.png


比较魔数:将读取的字节与已知的文件类型魔数进行比较。


匹配类型:如果字节序列与某个文件类型的魔数匹配,则可以确定文件类型。


处理异常:如果字节序列与任何已知魔数都不匹配,可能需要进一步的分析或返回未知文件类型。


三、Java实现文件类型检测


当需要通过文件头(魔数头)判断文件类型时,可以按照以下文字描述的流程进行实现:


graph LR
F[打开文件] --> B[读取文件头]
B --> C[判断文件类型]
C --> D[比较文件头]
D --> E[输出文件类型]

style B fill:#FFC0CB,stroke:#FFC0CB,stroke-width:2px
style C fill:#FFA07A,stroke:#FFA07A,stroke-width:2px
style D fill:#FFFFE0,stroke:#FFFFE0,stroke-width:2px
style E fill:#98FB98,stroke:#98FB98,stroke-width:2px
style F fill:#B2FFFF,stroke:#B2FFFF,stroke-width:2px


  1. 打开文件:使用文件输入流(FileInputStream)打开待判断类型的文件。

  2. 读取文件头:从文件中读取一定长度的字节数据作为文件头。通常,文件头的长度为固定的几个字节,一般是 2-8 个字节。

  3. 判断文件类型:根据不同文件类型的魔数头进行判断。魔数头是文件中特定位置的字节序列,用于标识文件类型。每种文件类型都有不同的魔数头。

  4. 比较文件头:将读取到的文件头与已知文件类型的魔数头进行比较。如果匹配成功,则确定文件类型。

  5. 输出文件类型:根据匹配的文件类型,输出相应的文件类型描述信息。


3.1 具体实现方法


3.1.1 定义枚举类


/**
* 文件类型魔数枚举
* 使用场景:用于判断文件类型
* 使用方法:FileUtils.isFileType(new FileInputStream(file), FileTypeEnum.XLSX)
*
* @author bamboo panda
* @version 1.0
* @date 2024/5/23 19:37
*/

public enum FileTypeEnum {
/**
* JPEG
*/

JPEG("JPEG", "FFD8FF"),

/**
* PNG
*/

PNG("PNG", "89504E47"),

/**
* GIF
*/

GIF("GIF", "47494638"),

/**
* TIFF
*/

TIFF("TIFF", "49492A00"),

/**
* Windows bitmap
*/

BMP("BMP", "424D"),

/**
* CAD
*/

DWG("DWG", "41433130"),

/**
* Adobe photoshop
*/

PSD("PSD", "38425053"),

/**
* Rich Text Format
*/

RTF("RTF", "7B5C727466"),

/**
* XML
*/

XML("XML", "3C3F786D6C"),

/**
* HTML
*/

HTML("HTML", "68746D6C3E"),

/**
* Outlook Express
*/

DBX("DBX", "CFAD12FEC5FD746F "),

/**
* Outlook
*/

PST("PST", "2142444E"),

/**
* doc;xls;dot;ppt;xla;ppa;pps;pot;msi;sdw;db
*/

OLE2("OLE2", "0xD0CF11E0A1B11AE1"),

/**
* Microsoft Word/Excel
*/

XLS_DOC("XLS_DOC", "D0CF11E0"),

/**
* Microsoft Access
*/

MDB("MDB", "5374616E64617264204A"),

/**
* Word Perfect
*/

WPB("WPB", "FF575043"),

/**
* Postscript
*/

EPS_PS("EPS_PS", "252150532D41646F6265"),

/**
* Adobe Acrobat
*/

PDF("PDF", "255044462D312E"),

/**
* Windows Password
*/

PWL("PWL", "E3828596"),

/**
* ZIP Archive
*/

ZIP("ZIP", "504B0304"),

/**
* ARAR Archive
*/

RAR("RAR", "52617221"),

/**
* WAVE
*/

WAV("WAV", "57415645"),

/**
* AVI
*/

AVI("AVI", "41564920"),

/**
* Real Audio
*/

RAM("RAM", "2E7261FD"),

/**
* Real Media
*/

RM("RM", "2E524D46"),

/**
* Quicktime
*/

MOV("MOV", "6D6F6F76"),

/**
* Windows Media
*/

ASF("ASF", "3026B2758E66CF11"),

/**
* MIDI
*/

MID("MID", "4D546864"),
/**
* xlsx
*/

XLSX("XLSX", "504B0304"),
/**
* xls
*/

XLS("XLS", "D0CF11E0A1B11AE1");

private String key;
private String value;

FileTypeEnum(String key, String value) {
this.key = key;
this.value = value;
}

public String getValue() {
return value;
}

public String getKey() {
return key;
}
}

3.1.2 文件类型判断工具类


import java.io.IOException;
import java.io.InputStream;

/**
* 文件类型判断工具类
*
* @author bamboo panda
* @version 1.0
* @date 2024/5/23 19:38
*/

public class FileTypeUtils {

/**
* 获取文件头
*
* @param inputStream 输入流
* @return 16 进制的文件投信息
* @throws IOException io异常
*/

private static String getFileHeader(InputStream inputStream) throws IOException {
byte[] b = new byte[28];
inputStream.read(b, 0, 28);
inputStream.close();
return bytes2hex(b);
}

/**
* 将字节数组转换成16进制字符串
*
* @param src 文件字节数组
* @return 16进制字符串
*/

private static String bytes2hex(byte[] src) {
StringBuilder stringBuilder = new StringBuilder("");
if (src == null || src.length <= 0) {
return null;
}
for (byte b : src) {
int v = b & 0xFF;
String hv = Integer.toHexString(v);
if (hv.length() < 2) {
stringBuilder.append(0);
}
stringBuilder.append(hv);
}
return stringBuilder.toString();
}

/**
* 判断指定输入流是否是指定文件格式
*
* @param inputStream 输入流
* @param fileTypeEnum 文件格式枚举
* @return true 是; false 否
* @throws IOException io异常
*/

public static boolean isFileType(InputStream inputStream, FileTypeEnum fileTypeEnum) throws IOException {
if (null == inputStream) {
return false;
}
String fileHeader = getFileHeader(inputStream);
return fileHeader.toUpperCase().startsWith(fileTypeEnum.getValue());
}

}

3.1.3 测试方法


import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;

/**
* 测试:判断文件是否是excel
*
* @author bamboo panda
* @version 1.0
* @date 2024/5/23 19:33
*/

public class Test {

public static void main(String[] args) {
File file = new File("C:\Users\Admin\Desktop\temp\Import file.xlsx");
try (FileInputStream fileInputStream = new FileInputStream(file)) {
if (!FileTypeUtils.isFileType(fileInputStream, FileTypeEnum.XLSX) || !FileTypeUtils.isFileType(fileInputStream, FileTypeEnum.XLS)) {
System.out.println(true);
} else {
System.out.println(false);
}
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}

}

四、其它注意事项


在处理文件类型检测和数据保护时,安全性和隐私是两个非常重要的考虑因素。以下是一些相关的安全性问题和最佳实践:


4.1 魔数检测的安全性问题


graph LR
F(文件类型判断)
B(魔数检测的安全性问题)
C(误报和漏报)
D(恶意文件伪装)
E(更新和维护)

F ---> B
B ---> C
B ---> D
B ---> E

style B fill:#FFC0CB,stroke:#FFC0CB,stroke-width:2px
style C fill:#FFA07A,stroke:#FFA07A,stroke-width:2px
style D fill:#FFFFE0,stroke:#FFFFE0,stroke-width:2px
style E fill:#98FB98,stroke:#98FB98,stroke-width:2px
style F fill:#B2FFFF,stroke:#B2FFFF,stroke-width:2px


  1. 误报和漏报:魔数检测可能因为文件损坏或不完整而产生误报或漏报。一些恶意软件可能会模仿合法文件的魔数来逃避检测。

  2. 恶意文件伪装:攻击者可能故意在文件中嵌入合法的魔数,使得恶意文件看起来像是合法的文件类型。

  3. 更新和维护:随着新文件类型的出现和旧文件类型的淘汰,魔数列表需要定期更新,否则检测系统可能会变得不准确或过时。


4.2 数据保护和隐私的最佳实践


graph LR
I(文件类型判断)

A(数据保护和隐私的最佳实践)

G(最小权限原则)
B(数据加密)
C(安全的数据传输)
D(访问控制)
E(定期审计和测试)
F(数据最小化)

I ---> A
A ---> B
A ---> C
A ---> D
A ---> E
A ---> F
A ---> G

style B fill:#FFC0CB,stroke:#FFC0CB,stroke-width:2px
style C fill:#FFA07A,stroke:#FFA07A,stroke-width:2px
style D fill:#FFFFE0,stroke:#FFFFE0,stroke-width:2px
style E fill:#98FB98,stroke:#98FB98,stroke-width:2px
style F fill:#B2FFFF,stroke:#B2FFFF,stroke-width:2px
style G fill:#ADD8E6,stroke:#ADD8E6,stroke-width:2px
style A fill:#E6E6FA,stroke:#E6E6FA,stroke-width:2px
style I fill:#EEDD82,stroke:#EEDD82,stroke-width:2px


  1. 最小权限原则:确保应用程序只请求执行其功能所必需的权限,不要求额外的权限。

  2. 数据加密:对敏感数据进行加密,无论是在传输中还是存储时,都应使用强加密标准。

  3. 安全的数据传输:使用安全的协议(如HTTPS)来保护数据在网络中的传输。

  4. 访问控制:实施严格的访问控制措施,确保只有授权用户才能访问敏感数据。

  5. 定期审计和测试:定期进行安全审计和渗透测试,以发现和修复潜在的安全漏洞。

  6. 数据最小化:只收集完成服务所必需的最少数据量,避免收集不必要的个人信息。


4.3 魔数的局限性和风险


魔数判断文件类型是一种常用的方法,但也存在一些局限性和风险,包括以下几点:


graph LR
I(文件类型判断)

A(魔数的局限性和风险)

B(可伪造性)
C(文件类型扩展性)
D(文件损坏或篡改)
E(多重文件类型)
F(文件类型模糊性)

I ---> A
A ---> B
A ---> C
A ---> D
A ---> E
A ---> F

style B fill:#FFC0CB,stroke:#FFC0CB,stroke-width:2px
style C fill:#FFA07A,stroke:#FFA07A,stroke-width:2px
style D fill:#FFFFE0,stroke:#FFFFE0,stroke-width:2px
style E fill:#98FB98,stroke:#98FB98,stroke-width:2px
style F fill:#B2FFFF,stroke:#B2FFFF,stroke-width:2px
style A fill:#E6E6FA,stroke:#E6E6FA,stroke-width:2px
style I fill:#EEDD82,stroke:#EEDD82,stroke-width:2px


  1. 可伪造性:魔数头是文件中的特定字节序列,攻击者可以通过修改文件的魔数头来伪装文件类型。这可能导致误判文件类型或绕过文件类型检测。

  2. 文件类型扩展性:随着新的文件类型的出现,魔数头的定义可能需要不断更新,以适应新的文件类型。如果应用程序不及时更新对新文件类型的判断逻辑,可能无法正确识别新的文件类型。

  3. 文件损坏或篡改:如果文件的魔数头部分被损坏或篡改,可能导致无法正确判断文件类型,或者将文件错误地归类为不正确的类型。

  4. 多重文件类型:某些文件可能具有多重文件类型,即使使用魔数头判断了其中一种类型,也可能存在其他类型。这可能导致文件类型的混淆和判断的不准确性。

  5. 文件类型模糊性:某些文件类型可能具有相似或相同的魔数头,这可能导致在这些类型之间进行区分时出现困难。这可能增加了误判文件类型的风险。


五、总结


好了,到这里魔数怎么用的就说明白了。


魔数的广泛的应用在在文件类型检测中。魔数是文件开头的特定字节序列,帮助软件快速识别文件格式。


然而,魔数检测存在安全性问题,如误报、恶意伪装等,需定期更新魔数库。此外,应用魔数检测时要考虑文件损坏、多重类型等局限性,结合实际情况采取综合措施,如数据加密、访问控制等,确保安全性和准确性。



希望本文对您有所帮助。如果有任何错误或建议,请随时指正和提出。


同时,如果您觉得这篇文章有价值,请考虑点赞和收藏。这将激励我进一步改进和创作更多有用的内容。


感谢您的支持和理解!



作者:竹子爱揍功夫熊猫
来源:juejin.cn/post/7372100124636381194
收起阅读 »

我有点想用JDK17了

大家好呀,我是summo,JDK版本升级的非常快,现在已经到JDK20了。JDK版本虽多,但应用最广泛的还得是JDK8,正所谓“他发任他发,我用Java8”。 其实我也不太想升级JDK版本,感觉投入高,收益小,不过有一次我看到了一些使用JDK17新语法写的代码...
继续阅读 »

大家好呀,我是summo,JDK版本升级的非常快,现在已经到JDK20了。JDK版本虽多,但应用最广泛的还得是JDK8,正所谓“他发任他发,我用Java8”。


其实我也不太想升级JDK版本,感觉投入高,收益小,不过有一次我看到了一些使用JDK17新语法写的代码,让我改变了对升级JDK的看法,因为这些新语法我确实想用!


废话不多说,上代码!


一、JDK17语法新特性


1. 文本块



这个更新非常实用。在没有这个特性之前,编写长文本非常痛苦。虽然IDEA等集成开发工具可以自动处理,但最终效果仍然丑陋,充满拼接符号。现在,通过字符串块,我们可以轻松编写JSON、HTML、SQL等内容,效果更清爽。这个新特性值得五颗星评价,因为它让我们只需关注字符串本身,而无需关心拼接操作。



原来的写法


/**
* 使用JDK8返回HTML文本
*
* @return 返回HTML文本
*/

public static final String getHtmlJDK8() {
return "<html>\n" +
" <body>\n" +
" <p>Hello, world</p>\n" +
" </body>\n" +
"</html>";
}

新的写法


/**
* 使用JDK17返回HTML文本
*
* @return 返回HTML文本
*/

public static final String getHtmlJDK17() {
return """
<html>
<body>
<p>Hello, world</p>
</body>
</html>
"""
;
}


推荐指数:⭐️⭐️⭐️⭐️⭐️



2. NullPointerException增强



这一功能非常强大且实用,相信每位Java开发者都期待已久。空指针异常(NPE)一直是Java程序员的痛点,因为报错信息无法直观地指出哪个对象为空,只抛出一个NullPointerException和一堆堆栈信息,定位问题耗时且麻烦。尤其在遇到喜欢级联调用的代码时,逐行排查更是令人头疼。如果在测试环境中,可能还需通过远程调试查明空对象,费时费力。为此,阿里的编码规范甚至不允许级联调用,但这并不能彻底解决问题。Java17终于在这方面取得了突破,提供了更详细的空指针异常信息,帮助开发者迅速定位问题源头。



public static void main(String[] args) {
try {
//简单的空指针
String str = null;
str.length();
} catch (Exception e) {
e.printStackTrace();
}
try {
//复杂一点的空指针
var arr = List.of(null);
String str = (String)arr.get(0);
str.length();
} catch (Exception e) {
e.printStackTrace();
}
}

运行结果



推荐指数:⭐️⭐️⭐️⭐️⭐️



3. Records



在Java中,POJO对象(如DO、PO、VO、DTO等)通常包含成员变量及相应的Getter和Setter方法。尽管可以通过工具或IDE生成这些代码,但修改和维护仍然麻烦。Lombok插件为此出现,能够在编译期间自动生成Getter、Setter、hashcode、equals和构造函数等代码,使用起来方便,但对团队有依赖要求。
为此,Java引入了标准解决方案:Records。它通过简洁的语法定义数据类,大大简化了POJO类的编写,如下所示。虽然hashcode和equals方法仍需手动编写,但IDE能够自动生成。这一特性有效解决了模板代码问题,提升了代码整洁度和可维护性。



package com.summo.jdk17;

/**
* 3星
*
* @param stuId 学生ID
* @param stuName 学生名称
* @param stuAge 学生年龄
* @param stuGender 学生性别
* @param stuEmail 学生邮箱
*/

public record StudentRecord(Long stuId,
String stuName,
int stuAge,
String stuGender,
String stuEmail)
{
public StudentRecord {
System.out.println("构造函数");
}

public static void main(String[] args) {
StudentRecord record = new StudentRecord(1L, "张三", 16, "男", "xxx@qq.com");
System.out.println(record);
}
}


推荐指数:⭐️⭐️⭐️⭐️



4. 全新的switch表达式



有人可能问了,Java语言不早已支持switch了嘛,有什么好提的?讲真,这次的提升还真有必要好好地来聊一聊了。在Java12的时候就引入了switch表达式,注意这里是表达式,而不是语句,原来的switch是语句。如果不清楚两者的区别的话,最好先去了解一下。主要的差别就是就是表达式有返回值,而语句则没有。再配合模式匹配,以及yield和“->”符号的加入,全新的switch用起来爽到飞起来。



package com.summo.jdk17;

public class SwitchDemo {
/**
* 在JDK8中获取switch返回值方式
*
* @param week
* @return
*/

public int getByJDK8(Week week) {
int i = 0;
switch (week) {
case MONDAY, TUESDAY:
i = 1;
break;
case WEDNESDAY:
i = 3;
break;
case THURSDAY:
i = 4;
break;
case FRIDAY:
i = 5;
break;
case SATURDAY:
i = 6;
break;
case SUNDAY:
i = 7;
break;
default:
i = 0;
break;
}

return i;
}

/**
* 在JDK17中获取switch返回值
*
* @param week
* @return
*/

public int getByJDK17(Week week) {
// 1, 现在的switch变成了表达式,可以返回值了,而且支持yield和->符号来返回值
// 2, 再也不用担心漏写了break,而导致出问题了
// 3, case后面支持写多个条件
return switch (week) {
case null -> -1;
case MONDAY -> 1;
case TUESDAY -> 2;
case WEDNESDAY -> 3;
case THURSDAY -> {yield 4;}
case FRIDAY -> 5;
case SATURDAY, SUNDAY -> 6;
default -> 0;
};
}

private enum Week {
MONDAY,
TUESDAY,
WEDNESDAY,
THURSDAY,
FRIDAY,
SATURDAY,
SUNDAY
}
}


推荐指数:⭐️⭐️⭐️⭐️



5. 私有接口方法



从Java8开始,允许在interface里面添加默认方法,其实当时就有些小困惑,如果一个default方法体很大怎么办,拆到另外的类去写吗?实在有些不太合理,所以在Java17里面,如果一个default方法体很大,那么可以通过新增接口私有方法来进行一个合理的拆分了,为这个小改进点个赞。



public interface PrivateInterfaceMethod {
    /**
     * 接口默认方法
     */

    default void defaultMethod() {
        privateMethod();
    }

    // 接口私有方法,在Java8里面是不被允许的,不信你试试
    private void privateMethod() {
    }
}


推荐指数:⭐️⭐️⭐️



6. 模式匹配



在JDK 17中,模式匹配主要用于instanceof表达式。模式匹配增强了instanceof的语法和功能,使类型检查和类型转换更加简洁和高效。在传统的Java版本中,我们通常使用instanceof结合类型转换来判断对象类型并进行处理,这往往会导致冗长的代码。



原来的写法


/**
* 旧式写法
*
* @param value
*/

public void matchByJDK8(Object value) {
if (value instanceof String) {
String v = (String)value;
System.out.println("遇到一个String类型" + v.toUpperCase());
} else if (value instanceof Integer) {
Integer v = (Integer)value;
System.out.println("遇到一个整型类型" + v.longValue());
}
}

新的写法


/**
* 转换并申请了一个新的变量,极大地方便了代码的编写
*
* @param value
*/

public void matchByJDK17(Object value) {
if (value instanceof String v) {
System.out.println("遇到一个String类型" + v.toUpperCase());
} else if (value instanceof Integer v) {
System.out.println("遇到一个整型类型" + v.longValue());
}
}


推荐指数:⭐️⭐️⭐️⭐️



7. 集合类的工厂方法



在Java8的年代,即便创建一个很小的集合,或者固定元素的集合都是比较麻烦的,为了简洁一些,有时我甚至会引入一些依赖。



原来的写法


Set<String> set = new HashSet<>();
set.add("a");
set.add("b");
set.add("c"

新的写法


Set<String> set = Set.of("a", "b", "c");


推荐指数:⭐️⭐️⭐️⭐️⭐️



二、其他的新特性


1. 新的String方法



  • repeat:重复生成字符串

  • isBlank:不用在引入第三方库就可以实现字符串判空了

  • strip:去除字符串两边的空格,支持全角和半角,之前的trim只支持半角

  • lines:能根据一段字符串中的终止符提取出行为单位的流

  • indent:给字符串做缩进,接受一个int型的输入

  • transform:接受一个转换函数,实现字符串的转换


2. Stream API的增强



增加takeWhile, dropWhile, ofNullable, iterate以及toList的API,越来越像一些函数式语言了。用法举例如下。



// takeWhile 顺序返回符合条件的值,直到条件不符合时即终止继续判断,
// 此外toList方法的加入,也大大减少了节省了代码量,免去了调用collect(Collectors::toList)方法了
List<Integer> list = Stream.of(2,2,3,4,5,6,7,8,9,10)
        .takeWhile(i->(i%2==0)).toList(); // 返回2, 2

// dropWhile 顺序去掉符合条件的值,直到条件不符合时即终止继续判断
List<Integer> list1 = Stream.of(2,2,3,4,5,6,7,8,9,10)
        .dropWhile(i->(i%2==0)).toList(); //返回3, 4, 5, 6, 7, 8, 9, 10

// ofNullable,支持传入空流,若没有这个且传入一个空流,那么将会抛NPE
var nullStreamCount = Stream.ofNullable(null).count(); //返回0

// 以下两行都将输出0到9
Stream.iterate(0, n -> n < 10, n -> n + 1).forEach(x -> System.out.println(x));
Stream.iterate(0, n -> n + 1).limit(10).forEach(x -> System.out.println(x));

3. 全新的HttpClient



这个API首次出现在9之中,不过当时并非是一个稳定版本,在Java11中正式得到发布,所以在Java17里面可以放心地进行使用。原来的JDK自带的Http客户端真的非常难用,这也就给了很多像okhttp、restTemplate、Apache的HttpClient和feign这样的第三方库极大的发挥空间,几乎就没有人愿意去用原生的Http客户端的。但现在不一样了,感觉像是新时代的API了。FluentAPI风格,处处充满了现代风格,用起来也非常地方便,再也不用去依赖第三方的包了,就两个字,清爽。



// 同步请求
HttpClient client = HttpClient.newBuilder()
        .version(Version.HTTP_1_1)
        .followRedirects(Redirect.NORMAL)
        .connectTimeout(Duration.ofSeconds(20))
        .proxy(ProxySelector.of(new InetSocketAddress("proxy.example.com", 80)))
        .authenticator(Authenticator.getDefault())
        .build();
   HttpResponse<String> response = client.send(request, BodyHandlers.ofString());
   System.out.println(response.statusCode());
   System.out.println(response.body());
// 异步请求
HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create("https://foo.com/"))
        .timeout(Duration.ofMinutes(2))
        .header("Content-Type", "application/json")
        .POST(BodyPublishers.ofFile(Paths.get("file.json")))
        .build();
   client.sendAsync(request, BodyHandlers.ofString())
        .thenApply(HttpResponse::body)
        .thenAccept(System.out::println);
 

4. jshell



在新的JDK版本中,支持直接在命令行下执行java程序,类似于python的交互式REPL。简而言之,使用 JShell,你可以输入代码片段并马上看到运行结果,然后就可以根据需要作出调整,这样在验证一些简单的代码的时候,就可以通过jshell得到快速地验证,非常方便。



5. java命令直接执行java文件



在现在可以直接通过执行“java xxx.java”,即可运行该java文件,无须先执行javac,然后再执行java,是不是又简单了一步。



6. ZGC



在ParallelOldGC、CMS和G1之后,JDK 11引入了全新的ZGC(Z Garbage Collector)。这个名字本身就显得很牛。官方宣称ZGC的垃圾回收停顿时间不超过10ms,能支持高达16TB的堆空间,并且停顿时间不会随着堆的增大而增加。那么,ZGC到底解决了什么问题?Oracle官方介绍它是一个可伸缩的低延迟垃圾回收器,旨在降低停顿时间,尽管这可能会导致吞吐量的降低。不过,通过横向扩展服务器可以解决吞吐量问题。官方已建议ZGC可用于生产环境,这无疑将成为未来的主流垃圾回收器。要了解更多,请参阅官方文档



三、小结一下


作为程序员,持续学习和充电非常重要。随着Java8即将停止免费官方支持,越来越多的项目将转向Java17,包括大名鼎鼎的Spring Boot 3.0,它在2022年1月20日发布的第一个里程碑版本(M1)正是基于Java17构建的。该项目依赖的所有组件也将快速升级,未来如果想利用某些新特性,在Java8下将无法通过编译.,到这时候再换就真的晚了... ...


作者:summo
来源:juejin.cn/post/7376444924424241162
收起阅读 »

对象存储URL被刷怕了,看我这样处理

个人项目:社交支付项目(小老板) 作者:三哥,j3code.cn 文档系统:admire.j3code.cn/note 社交支付类的项目,怎么能没有图片上传功能呢! 涉及到文件存储我第一时间就想到了 OSS 对象存储服务(腾讯叫 COS),但是接着我又想到了...
继续阅读 »

个人项目:社交支付项目(小老板)


作者:三哥,j3code.cn


文档系统:admire.j3code.cn/note



社交支付类的项目,怎么能没有图片上传功能呢!


涉及到文件存储我第一时间就想到了 OSS 对象存储服务(腾讯叫 COS),但是接着我又想到了”OSS 被刷 150 T 的流量,1.5 W 瞬间就没了?“。


本来想着是自己搭建一套 MinIO ,但后来一想服务器的开销又要大了,还是作罢了。就在此时,我脑袋突然灵光了一下,既然对象存储的流量是由于资源 url 泄漏导致的外界不停的访问 url 使公网流量剧增从而引起巨额消费,那我能不能不泄露这个 url 呢!


理论上是可以不直接给用户云存储的 url ,那用户如何访问资源?



转换,当用户上传图片时,将云存储的 url 保存入库,而返回用户一个本系统的资源访问接口。当用户访问该接口时,系统从库中获取真实 url 进行资源访问,并返回资源给用户,完成一次转换。


虽然可以解决 url 泄漏问题,但是也是有性能消耗(从直接访问,变为间接访问,而且系统挂了,资源就不可用)。



方案,虽然曲折了点,但为了 money ,牺牲一点是值得的(后来思考了一下,觉得还是有些问题,文章最后会说)。而且即使有人通过刷系统的接口访问资源,也没事,系统有很强的限流和黑名单处理,不会产生过多的公网流量费用的。


那下面我们就先开通相关功能,然后再编码实现。


1、腾讯云对象存储创建


地址:console.cloud.tencent.com/cos


开通对象存储的步骤还是非常简单的,具体步骤如下:


1)开通功能


Snipaste_2023-07-14_15-27-24.png


2)配置存储桶


Snipaste_2023-07-14_15-28-24.png


下一步


Snipaste_2023-07-14_15-30-35.png


下一步


Snipaste_2023-07-14_15-33-10.png


3)创建访问的密钥


腾讯的所有 API 接口都需要这个访问密钥,如果以前创建过就可以直接拿来使用


Snipaste_2023-07-14_15-34-05.png


下一步


Snipaste_2023-07-14_15-35-26.png


基本的功能我们已经开通了,而且以后我们只需向这个存储桶中上传图片即可。


2、SpringBoot 对接对象存储


既然准备工作都已经完成了,那就开始编写上传文件的代码吧!当然,这里我们还是要借助官方文档,便于我们开发,地址如下:



cloud.tencent.com/document/pr…



2.1 配置准备


先来思考一下,对于腾讯 COS 文件上传需要那些配置:



  1. 云 API 的 SecretId 和 SecretKey

  2. 桶名称

  3. 文件上传大小限制

  4. 再加一个 cos 上传后的访问域名


ok,大致就这些,那咱们就先来写个配置文件:application-cos.yml


tx.cos:
# 云 API 的 SecretId
secret-id: ENC(X7Uu6Y0QD6aCeUmNhyqv1jcr8fSN+fqM/FSP/rqhM+6pkbte2LW5gR3wntsm24n3NAg6sIwBC3pqm1lSNWwElc3iuGe3lE4L/k3zih+EstM=)
# 云 API 的 SecretKey
secret-key: ENC(ui3jqYJpyTRtPAizYdtll2Zc1EVzUjK28vjTyD+t3AIydQO6I+JQOVacc5+NJVybsbFptELswKhY55OQLW+BKfujNTOYEM/zb4CMi+AK80w=)
# 域名访问
domain: ENC(oRsaRjwRCVLEYfcNB0CjPGyqSMxGM5uzWnSpSifauLF7c5YMt5hZFi7xAthJI4CjmOLVA810Jbgy8lnkKrXUH0g1ee14cr67xSdtPRy1ZaJOXQOMlBgCKNO2wDBg2YW2)
# 文件上传的桶名称
bucket-name: ENC(TUsQfDEFx6KSAOpRwG7UYOJbGwnFT0Z9tjS4h+/HeenAE3XbhKsCwn3TTo80n5tUUP9Dzrnu+Ck84FNSYQk5fw==)

spring:
servlet:
multipart:
# 限制文件上传大小
max-request-size: 5MB
max-file-size: 5MB

注:这里,我的配置值是加密的,所以你们需要配置自己的值


再根据这个配置文件,写一个对应的配置类:


地址:cn.j3code.common.config


@Slf4j
@Data
@Configuration
@ConfigurationProperties(prefix = "tx.cos")
public class TxCosConfig {
/**
* 访问域名
*/

private String domain;

/**
* 桶名称
*/

private String bucketName;

/**
* api密钥中的secretId
*/

private String secretId;

/**
* api密钥中的应用密钥
*/

private String secretKey;
}

2.2 上传文件代码


这里,我们先实现单个文件的上传,那来思考一下,上传文件应该需要那些步骤:



  1. 校验文件名称

  2. 重新生成一个新文件名称

  3. 腾讯 COS 文件存储路径生成

  4. 文件上传

  5. 拼接文件访问 url


对应此步骤的流程图,如下:


Snipaste_2023-07-16_17-31-32.jpg


1)controller 编写


位置:cn.j3code.other.api.v1.controller


@Slf4j
@AllArgsConstructor
@ResponseResult
@RestController
@RequestMapping(UrlPrefixConstants.WEB_V1 + "/image/upload")
public class ImageUploadController {

private final FileService fileService;


/**
* 图片上传
* @param file 文件
* @return 返回文件 url
*/

@PostMapping("")
public String upload(@RequestParam("file") MultipartFile file){
return fileService.imageUpload(file);
}
}

2)service 编写


位置:cn.j3code.other.service


public interface FileService {
String imageUpload(MultipartFile file);
}

@Slf4j
@AllArgsConstructor
@Service
public class FileServiceImpl implements FileService {

/**
* 允许上传的图片类型
*/

public static final Set<String> IMG_TYPE = Set.of("jpg", "jpeg", "png", "gif");

/**
* 腾讯 cos 配置
*/

private final TxCosConfig txCosConfig;
private final UrlKeyService urlKeyService;

/**
* 图片上传
*
* @param file
* @return
*/

@Override
public String imageUpload(MultipartFile file) {
// 文件名称
String newFileName = getNewFileName(file);

DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
String format = formatter.format(LocalDate.now());
// key = /用户id/年月日/文件
String key = SecurityUtil.getUserId() + "/" + format + "/" + newFileName;

String prefix = newFileName.substring(0, newFileName.lastIndexOf(".") - 1);
String suffix = newFileName.substring(newFileName.lastIndexOf(".") + 1);
File tempFile = null;
File rename = null;
try {
// 生成临时文件
tempFile = File.createTempFile(prefix, "." + suffix);
file.transferTo(tempFile);
// 重命名文件
rename = FileUtil.rename(tempFile, newFileName, true, true);
// 上传
upload(new FileInputStream(rename), key);
} catch (Exception e) {
log.error("imageUpload-error:", e);
} finally {
if (Objects.nonNull(tempFile)) {
FileUtil.del(tempFile);
}
if (Objects.nonNull(rename)) {
FileUtil.del(rename);
}
}
// 返回访问链接
return initUrl(key);
}

/**
* 初始化图片文件访问 url(本地url和第三方url)
*
* @param key 路径
* @return
*/

private String initUrl(String key) {
// 组装第三方 url
String imageUrl = txCosConfig.getDomain() + "/" + key;

// 保存 url 到 数据库
UrlKey urlKey = new UrlKey()
.setUrl(imageUrl)
.setKey(RandomUtil.randomString(16) + RandomUtil.randomString(16) + RandomUtil.randomString(16))
.setUserId(SecurityUtil.getUserId());

// 保存成功,返回本地中转的 url 出去
boolean save = Boolean.FALSE;
try {
save = urlKeyService.save(urlKey);
} catch (Exception e) {
}

if (save) {
return CallbackUrlConstants.IMAGE_OPEN_URL + urlKey.getKey();
}
// 保存失败,直接把第三方 url 返回给用户
return imageUrl;
}

/**
* 文件上传到第三方
*
* @param fileStream 文件流
* @param path 路径
*/

private void upload(InputStream fileStream, String path) {
PutObjectResult putObjectResult = COSClientUtil.getCosClient(txCosConfig)
.putObject(new PutObjectRequest(txCosConfig.getBucketName(), path, fileStream, null));
log.info("upload-result:{}", JSON.toJSONString(putObjectResult));
}

/**
* 生成一个新文件名称
* 会校验文件名称和类型
*
* @param file 文件
* @return
*/

private String getNewFileName(MultipartFile file) {
String originalFilename = file.getOriginalFilename();
if (StringUtil.isEmpty(originalFilename)) {
throw new SysException("文件名称获取失败!");
}
String suffix = originalFilename.substring(originalFilename.lastIndexOf("."));

if (!IMG_TYPE.contains(suffix.substring(1))) {
throw new SysException(String.format("仅允许上传这些类型图片:%s", JSON.toJSONString(IMG_TYPE)));
}

return RandomUtil.randomString(8) + SnowFlakeUtil.getId() + suffix;
}
}

代码写的很详细了,应该能看懂,但,有两点我没有提,就是:COSClientUtil 和 UrlKeyService,下面就来结介绍。


2.2.1 cos 客户端配置提取


系统中肯定有很多的文件上传,难道是每上传一次,就配置一次 cos 客户端吗?显然不是,这个 cos 客户端肯定是要抽出来的,全局系统中我们只配置一次。也即只有第一次过来是创建 cos 客户端,后续过来的文件上传请求直接返回创建好的 cos 客户端就行。


COSClientUtil 类就是我抽的公共 cos 客户获取类,具体实现如下:


位置:cn.j3code.other.util


public class COSClientUtil {

/**
* 统一 cos 上传客户端
*/

private static COSClient cosClient;

public static COSClient getCosClient(TxCosConfig txCosConfig) {
if (Objects.isNull(cosClient)) {
synchronized (COSClient.class) {
if (Objects.isNull(cosClient)) {
// 1 初始化身份
COSCredentials cred = new BasicCOSCredentials(txCosConfig.getSecretId(), txCosConfig.getSecretKey());
// 2 创建配置,及设置地域
ClientConfig clientConfig = new ClientConfig(new Region("ap-guangzhou"));
// 3 生成 cos 客户端。
cosClient = new COSClient(cred, clientConfig);
}
}
}
return cosClient;
}
}

私有构造器,且之对外提供 getCosClient 方法获取 COSClient 对象,保证全局只有一个 cos 客户端配置。


2.2.2 隐藏云存储 URL 处理


还记得 FileServiceImpl 类中有个 UrlKeyService 属性嘛,这个类就是做 云存储 URL 隐藏及中转功能的。


具体做法如图:


Snipaste_2023-07-16_18-14-59.jpg


文件上传部分我们已经写好了,不过有点超前的意思了,不过没关系,看整体就行。


从上面我们要开始抓住一个细节了,就是映射关系,即 key 和 url 的映射。这里我用的是 MySQL 保存,也即用表来存,并没有用 Redis。这里我的考虑是,后续可以把表中的数据定时刷到 Redis 中,接着访问的顺序是从 Redis 中找映射,没有再去 MySQL 中找。


不过,我们首先还是把数据先存表再说,先来看看映射表结构字段:



id


user_id


key


url


create_time


update_time



ok,就这些字段,把用户 id 加上是为了好回溯看看是谁上传了图片。


SQL 如下:


CREATE TABLE `sb_url_key` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`key` varchar(64) COLLATE utf8_unicode_ci NOT NULL COMMENT 'key',
`url` varchar(200) COLLATE utf8_unicode_ci NOT NULL COMMENT '资源url',
`user_id` bigint(20) DEFAULT NULL COMMENT '上传用户',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '修改时间',
PRIMARY KEY (`id`),
KEY `key` (`key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci

紧接着就是通过 MyBatisX 插件生成对应的实体、service、mapper 代码了,不过多赘述。那,现在就来开发用户访问图片资源,咱们如何去请求第三方,然后返回用户图片 byte[] 资源数组吧!


1)controller 编写


位置:cn.j3code.other.api.v1.controller


@Slf4j
@AllArgsConstructor
@RestController
@RequestMapping(UrlPrefixConstants.OPEN + "/resource/image")
public class ImageResourceController {

private final UrlKeyService urlKeyService;

/**
* 获取图片 base64
*
* @param key
* @return
* @throws Exception
*/

@GetMapping("/base64/{key}")
public String imageBase64(@PathVariable("key") String key) throws Exception {
UrlKey urlKey = urlKeyService.oneByKey(key);
return "data:image/jpg;base64," + Base64Encoder.encode(IoUtil.readBytes(new URL(urlKey.getUrl()).openStream()));
}


/**
* 获取图片 byte 数组
*
* @param key
* @return
* @throws Exception
*/

@GetMapping(value = "/io/{key}", produces = MediaType.IMAGE_JPEG_VALUE)
public byte[] imageIo(@PathVariable("key") String key) throws Exception {
UrlKey urlKey = urlKeyService.oneByKey(key);

return IoUtil.readBytes(new URL(urlKey.getUrl()).openStream());
}
}

注意:这里写了两个方法,目的是返回两种不同形式的图片资源:base64 和 byte[]。且,这种资源访问的接口,我们系统的相关拦截器请放行,如:认证,ip 记录等拦截器。


2)service 编写


位置:cn.j3code.other.service


public interface UrlKeyService extends IService<UrlKey> {
UrlKey oneByKey(String key);
}
@Service
public class UrlKeyServiceImpl extends ServiceImpl<UrlKeyMapper, UrlKey>
implements UrlKeyService {

@Override
public UrlKey oneByKey(String key) {
UrlKey urlKey = lambdaQuery().eq(UrlKey::getKey, key).one();
if (Objects.isNull(urlKey)) {
throw new SysException(SysExceptionEnum.NOT_DATA_ERROR);
}
return urlKey;
}
}

ok,这样咱们就处理好了,但是仔细想想这种中转的方法有什么问题。


2.3 思考


2.2 节我们已经实现了文件上传和防止 cos 访问 url 泄露的操作,但是我留了个问题,就是思考这种方式有什么问题。


下面是我的思考:



  1. 用户上传的图片,访问时每次都会经过本系统,造成了本系统的压力

  2. 如果一个页面需要回显的图片过多,那页面响应会不会很慢

  3. 如果系统崩溃了或者服务崩溃了,会导致图片不可访问,但其实第三方 url 是没有问题的


好吧,其实上面总结就两个问题,即:性能可用性


这里的解决方法是,如果资金充裕而且 COS 做了黑白名单等之类的防御措施可以直接把 COS 的原始 url 返回出去,没必要把图片资源压力给我我们本系统。如果你不是这种情况,那么就给图片访问接口增加部署资源,即升级服务器增加内存和带款,提高资源访问效率及系统性能。


以上就是本节内容,如果文章的中转方法有啥不足或者您有什么意见,欢迎一起讨论研究。


作者:J3code
来源:juejin.cn/post/7256306281538928701
收起阅读 »

MyBatis居然也有并发问题

为了节省dalaos时间先说结论:确实是个问题,issue链接:github.com/mybatis/myb… 下面就是源码分析环节,及处理过程,感兴趣的可以看看。 bug,任何时候都要解决!不解决不行,你们想想,你早上刚到公司,打开电脑,写着需求听着歌,突...
继续阅读 »

为了节省dalaos时间先说结论:确实是个问题,issue链接:github.com/mybatis/myb…


下面就是源码分析环节,及处理过程,感兴趣的可以看看。



bug,任何时候都要解决!不解决不行,你们想想,你早上刚到公司,打开电脑,写着需求听着歌,突然就被ding了……所以没有bug的日子才是好日子!



日志


上了服务器一看,Mybatis报错,接口还是个相当频繁的接口,一想,完了,绩效大概率不保。


2023-08-08 09:52:05,386|aaaaaaaaa|XXXXXXXXXXXXXX|unknown exception occurred
org.mybatis.spring.MyBatisSystemException: nested exception is org.apache.ibatis.builder.BuilderException: Error evaluating expression 'projects != null and projects.size() > 0 '. Cause: org.apache.ibatis.ognl.MethodFailedException: Method "size" failed for object [aaa,bbb,ccc] [java.lang.IllegalAccessException: Class org.apache.ibatis.ognl.OgnlRuntime can not access a member of class java.util.Arrays$ArrayList with modifiers "public"]
at org.mybatis.spring.MyBatisExceptionTranslator.translateExceptionIfPossible(MyBatisExceptionTranslator.java:75) ~[mybatis-spring-1.2.2.jar:1.2.2]
at org.mybatis.spring.SqlSessionTemplate$SqlSessionInterceptor.invoke(SqlSessionTemplate.java:371) ~[mybatis-spring-1.2.2.jar:1.2.2]
at com.sun.proxy.$Proxy57.selectList(Unknown Source) ~[na:na]
at org.mybatis.spring.SqlSessionTemplate.selectList(SqlSessionTemplate.java:198) ~[mybatis-spring-1.2.2.jar:1.2.2]
at org.apache.ibatis.binding.MapperMethod.executeForMany(MapperMethod.java:119) ~[mybatis-3.2.8.jar:3.2.8]
at org.apache.ibatis.binding.MapperMethod.execute(MapperMethod.java:63) ~[mybatis-3.2.8.jar:3.2.8]
at org.apache.ibatis.binding.MapperProxy.invoke(MapperProxy.java:52) ~[mybatis-3.2.8.jar:3.2.8]
at com.sun.proxy.$Proxy102.queryExperienceCardOrder(Unknown Source) ~[na:na]
// 业务相关堆栈,保险起见不贴了
at org.springframework.cglib.proxy.MethodProxy.invoke(MethodProxy.java:204) ~[spring-core-4.3.6.RELEASE.jar:4.3.6.RELEASE]
at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:652) ~[spring-aop-4.3.6.RELEASE.jar:4.3.6.RELEASE]
at com.xxxxxxxxxxxxxxxxxxxxxx$$EnhancerBySpringCGLIB$$b85a94bd.queryHasExperienceCardNew(<generated>) ~[zuhao-user-service-1.0.0.jar:na]
at sun.reflect.GeneratedMethodAccessor564.invoke(Unknown Source) ~[na:na]
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:1.8.0_131]
at java.lang.reflect.Method.invoke(Method.java:498) ~[na:1.8.0_131]
at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:333) ~[spring-aop-4.3.6.RELEASE.jar:4.3.6.RELEASE]
at org.springframework.aop.framework.ReflectiveMethodInvocation.invokeJoinpoint(ReflectiveMethodInvocation.java:190) [spring-aop-4.3.6.RELEASE.jar:4.3.6.RELEASE]
at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:157) [spring-aop-4.3.6.RELEASE.jar:4.3.6.RELEASE]
at xxxxxxxxxxxxx.common.interceptor.ApiInterceptor.invoke(ApiInterceptor.java:79) ~[common-0.0.9-20211228.052440-12.jar:na]
at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:179) [spring-aop-4.3.6.RELEASE.jar:4.3.6.RELEASE]
at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:213) [spring-aop-4.3.6.RELEASE.jar:4.3.6.RELEASE]
at com.sun.proxy.$Proxy185.queryHasExperienceCardNew(Unknown Source) [na:na]
at com.alibaba.dubbo.common.bytecode.Wrapper36.invokeMethod(Wrapper36.java) [na:2.5.3]
at com.alibaba.dubbo.rpc.proxy.javassist.JavassistProxyFactory$1.doInvoke(JavassistProxyFactory.java:46) [dubbo-2.5.3.jar:2.5.3]
at com.alibaba.dubbo.rpc.proxy.AbstractProxyInvoker.invoke(AbstractProxyInvoker.java:72) [dubbo-2.5.3.jar:2.5.3]
at com.alibaba.dubbo.rpc.protocol.InvokerWrapper.invoke(InvokerWrapper.java:53) [dubbo-2.5.3.jar:2.5.3]
at com.alibaba.dubbo.rpc.filter.ExceptionFilter.invoke(ExceptionFilter.java:64) [dubbo-2.5.3.jar:2.5.3]
at com.alibaba.dubbo.rpc.protocol.ProtocolFilterWrapper$1.invoke(ProtocolFilterWrapper.java:91) [dubbo-2.5.3.jar:2.5.3]
at com.alibaba.dubbo.monitor.support.MonitorFilter.invoke$original$LFhJaVNd(MonitorFilter.java:65) [dubbo-2.5.3.jar:2.5.3]
at com.alibaba.dubbo.monitor.support.MonitorFilter.invoke$original$LFhJaVNd$accessor$urPnHrIw(MonitorFilter.java) [dubbo-2.5.3.jar:2.5.3]
at com.alibaba.dubbo.monitor.support.MonitorFilter$auxiliary$RJHyKBeq.call(Unknown Source) [dubbo-2.5.3.jar:2.5.3]
at org.apache.skywalking.apm.agent.core.plugin.interceptor.enhance.InstMethodsInter.intercept(InstMethodsInter.java:86) [skywalking-agent.jar:8.16.0]
at com.alibaba.dubbo.monitor.support.MonitorFilter.invoke(MonitorFilter.java) [dubbo-2.5.3.jar:2.5.3]
at com.alibaba.dubbo.rpc.protocol.ProtocolFilterWrapper$1.invoke(ProtocolFilterWrapper.java:91) [dubbo-2.5.3.jar:2.5.3]
at com.alibaba.dubbo.rpc.filter.TimeoutFilter.invoke(TimeoutFilter.java:42) [dubbo-2.5.3.jar:2.5.3]
at com.alibaba.dubbo.rpc.protocol.ProtocolFilterWrapper$1.invoke(ProtocolFilterWrapper.java:91) [dubbo-2.5.3.jar:2.5.3]
at com.alibaba.dubbo.rpc.protocol.dubbo.filter.TraceFilter.invoke(TraceFilter.java:78) [dubbo-2.5.3.jar:2.5.3]
at com.alibaba.dubbo.rpc.protocol.ProtocolFilterWrapper$1.invoke(ProtocolFilterWrapper.java:91) [dubbo-2.5.3.jar:2.5.3]
at com.alibaba.dubbo.rpc.filter.ContextFilter.invoke(ContextFilter.java:60) [dubbo-2.5.3.jar:2.5.3]
at com.alibaba.dubbo.rpc.protocol.ProtocolFilterWrapper$1.invoke(ProtocolFilterWrapper.java:91) [dubbo-2.5.3.jar:2.5.3]
at com.alibaba.dubbo.rpc.filter.GenericFilter.invoke(GenericFilter.java:112) [dubbo-2.5.3.jar:2.5.3]
at com.alibaba.dubbo.rpc.protocol.ProtocolFilterWrapper$1.invoke(ProtocolFilterWrapper.java:91) [dubbo-2.5.3.jar:2.5.3]
at com.alibaba.dubbo.rpc.filter.ClassLoaderFilter.invoke(ClassLoaderFilter.java:38) [dubbo-2.5.3.jar:2.5.3]
at com.alibaba.dubbo.rpc.protocol.ProtocolFilterWrapper$1.invoke(ProtocolFilterWrapper.java:91) [dubbo-2.5.3.jar:2.5.3]
at com.alibaba.dubbo.rpc.filter.EchoFilter.invoke(EchoFilter.java:38) [dubbo-2.5.3.jar:2.5.3]
at com.alibaba.dubbo.rpc.protocol.ProtocolFilterWrapper$1.invoke(ProtocolFilterWrapper.java:91) [dubbo-2.5.3.jar:2.5.3]
at com.alibaba.dubbo.rpc.protocol.dubbo.DubboProtocol$1.reply(DubboProtocol.java:108) [dubbo-2.5.3.jar:2.5.3]
at com.alibaba.dubbo.remoting.exchange.support.header.HeaderExchangeHandler.handleRequest(HeaderExchangeHandler.java:84) [dubbo-2.5.3.jar:2.5.3]
at com.alibaba.dubbo.remoting.exchange.support.header.HeaderExchangeHandler.received(HeaderExchangeHandler.java:170) [dubbo-2.5.3.jar:2.5.3]
at com.alibaba.dubbo.remoting.transport.DecodeHandler.received(DecodeHandler.java:52) [dubbo-2.5.3.jar:2.5.3]
at com.alibaba.dubbo.remoting.transport.dispatcher.ChannelEventRunnable.run(ChannelEventRunnable.java:82) [dubbo-2.5.3.jar:2.5.3]
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1142) [na:1.8.0_131]
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617) [na:1.8.0_131]
at java.lang.Thread.run(Thread.java:748) [na:1.8.0_131]
Caused by: org.apache.ibatis.builder.BuilderException: Error evaluating expression 'projects != null and projects.size() > 0 '. Cause: org.apache.ibatis.ognl.MethodFailedException: Method "size" failed for object [u号租, 租号牛, 租号酷] [java.lang.IllegalAccessException: Class org.apache.ibatis.ognl.OgnlRuntime can not access a member of class java.util.Arrays$ArrayList with modifiers "public"]
at org.apache.ibatis.scripting.xmltags.OgnlCache.getValue(OgnlCache.java:47) ~[mybatis-3.2.8.jar:3.2.8]
at org.apache.ibatis.scripting.xmltags.ExpressionEvaluator.evaluateBoolean(ExpressionEvaluator.java:32) ~[mybatis-3.2.8.jar:3.2.8]
at org.apache.ibatis.scripting.xmltags.IfSqlNode.apply(IfSqlNode.java:33) ~[mybatis-3.2.8.jar:3.2.8]
at org.apache.ibatis.scripting.xmltags.MixedSqlNode.apply(MixedSqlNode.java:32) ~[mybatis-3.2.8.jar:3.2.8]
at org.apache.ibatis.scripting.xmltags.TrimSqlNode.apply(TrimSqlNode.java:54) ~[mybatis-3.2.8.jar:3.2.8]
at org.apache.ibatis.scripting.xmltags.MixedSqlNode.apply(MixedSqlNode.java:32) ~[mybatis-3.2.8.jar:3.2.8]
at org.apache.ibatis.scripting.xmltags.TrimSqlNode.apply(TrimSqlNode.java:54) ~[mybatis-3.2.8.jar:3.2.8]
at org.apache.ibatis.scripting.xmltags.MixedSqlNode.apply(MixedSqlNode.java:32) ~[mybatis-3.2.8.jar:3.2.8]
at org.apache.ibatis.scripting.xmltags.DynamicSqlSource.getBoundSql(DynamicSqlSource.java:40) ~[mybatis-3.2.8.jar:3.2.8]
at org.apache.ibatis.mapping.MappedStatement.getBoundSql(MappedStatement.java:278) ~[mybatis-3.2.8.jar:3.2.8]
at com.github.pagehelper.PageInterceptor.intercept(PageInterceptor.java:83) ~[pagehelper-5.1.4.jar:na]
at org.apache.ibatis.plugin.Plugin.invoke(Plugin.java:60) ~[mybatis-3.2.8.jar:3.2.8]
at com.sun.proxy.$Proxy234.query(Unknown Source) ~[na:na]
at org.apache.ibatis.session.defaults.DefaultSqlSession.selectList(DefaultSqlSession.java:108) ~[mybatis-3.2.8.jar:3.2.8]
at org.apache.ibatis.session.defaults.DefaultSqlSession.selectList(DefaultSqlSession.java:102) ~[mybatis-3.2.8.jar:3.2.8]
at sun.reflect.GeneratedMethodAccessor347.invoke(Unknown Source) ~[na:na]
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:1.8.0_131]
at java.lang.reflect.Method.invoke(Method.java:498) ~[na:1.8.0_131]
at org.mybatis.spring.SqlSessionTemplate$SqlSessionInterceptor.invoke(SqlSessionTemplate.java:358) ~[mybatis-spring-1.2.2.jar:1.2.2]
... 54 common frames omitted
Caused by: org.apache.ibatis.ognl.MethodFailedException: Method "size" failed for object [aaa,bbb,ccc]
at org.apache.ibatis.ognl.OgnlRuntime.callAppropriateMethod(OgnlRuntime.java:837) ~[mybatis-3.2.8.jar:3.2.8]
at org.apache.ibatis.ognl.ObjectMethodAccessor.callMethod(ObjectMethodAccessor.java:61) ~[mybatis-3.2.8.jar:3.2.8]
at org.apache.ibatis.ognl.OgnlRuntime.callMethod(OgnlRuntime.java:860) ~[mybatis-3.2.8.jar:3.2.8]
at org.apache.ibatis.ognl.ASTMethod.getValueBody(ASTMethod.java:73) ~[mybatis-3.2.8.jar:3.2.8]
at org.apache.ibatis.ognl.SimpleNode.evaluateGetValueBody(SimpleNode.java:170) ~[mybatis-3.2.8.jar:3.2.8]
at org.apache.ibatis.ognl.SimpleNode.getValue(SimpleNode.java:210) ~[mybatis-3.2.8.jar:3.2.8]
at org.apache.ibatis.ognl.ASTChain.getValueBody(ASTChain.java:109) ~[mybatis-3.2.8.jar:3.2.8]
at org.apache.ibatis.ognl.SimpleNode.evaluateGetValueBody(SimpleNode.java:170) ~[mybatis-3.2.8.jar:3.2.8]
at org.apache.ibatis.ognl.SimpleNode.getValue(SimpleNode.java:210) ~[mybatis-3.2.8.jar:3.2.8]
at org.apache.ibatis.ognl.ASTGreater.getValueBody(ASTGreater.java:49) ~[mybatis-3.2.8.jar:3.2.8]
at org.apache.ibatis.ognl.SimpleNode.evaluateGetValueBody(SimpleNode.java:170) ~[mybatis-3.2.8.jar:3.2.8]
at org.apache.ibatis.ognl.SimpleNode.getValue(SimpleNode.java:210) ~[mybatis-3.2.8.jar:3.2.8]
at org.apache.ibatis.ognl.ASTAnd.getValueBody(ASTAnd.java:56) ~[mybatis-3.2.8.jar:3.2.8]
at org.apache.ibatis.ognl.SimpleNode.evaluateGetValueBody(SimpleNode.java:170) ~[mybatis-3.2.8.jar:3.2.8]
at org.apache.ibatis.ognl.SimpleNode.getValue(SimpleNode.java:210) ~[mybatis-3.2.8.jar:3.2.8]
at org.apache.ibatis.ognl.Ognl.getValue(Ognl.java:333) ~[mybatis-3.2.8.jar:3.2.8]
at org.apache.ibatis.ognl.Ognl.getValue(Ognl.java:310) ~[mybatis-3.2.8.jar:3.2.8]
at org.apache.ibatis.scripting.xmltags.OgnlCache.getValue(OgnlCache.java:45) ~[mybatis-3.2.8.jar:3.2.8]
... 72 common frames omitted


赶紧查了下这个接口的调用情况,大部分没问题,偶尔冒了这么个错(还好还好)


根据堆栈反查错误位置,有点想不通,这里会有问题?那就只能翻源码了



源码分析


经过排查,ognl表达式中用到的方法,会通过反射,获取method,并缓存至静态变量中,所以,存在多线程状态中,产生并发问题,往下看



这里是缓存方法的逻辑,org.apache.ibatis.ognl.OgnlRuntime#getMethods(java.lang.Class, boolean)感兴趣的可以自己看


这里就是bug点,如果调用一旦多,存在A线程修改成true,还没调用方法,B线程就修改成false,此时调用失败,这不是个坑吗


image.png
mybatis中一搜果然有这个issue:github.com/mybatis/myb…



作者给的方案呢是升级mybatis。




你以为就这样结束了?


升级是不可能升级的,这辈子都不可能升级的,代码这么稳定,行行都像诗句[狗头]


开个玩笑,看看如何避免



原因呢就是Arrays.asList返回的是内部类,是private。所以导致了(!Modifier.isPublic(method.getModifiers()) || !Modifier.isPublic(method.getDeclaringClass().getModifiers()))这个条件为true,进入了设置
accessible的逻辑,后面又给设置回原样


总结



  • 问题:如果需要ognl的对象的方法和类不是public,那么会存在并发问题

  • 解决1:针对并发问题,升级Mybatis

  • 解决2:Lists.newArrayList或者其他写法代替,反正看下,内部类是不是private


有问题希望留言指出哈


作者:山间小僧
来源:juejin.cn/post/7264921613551730722
收起阅读 »

在Spring Boot中使用Sa-Token实现路径拦截和特定接口放行

在Spring Boot中使用Sa-Token实现路径拦截和特定接口放行 很喜欢的一段话:别想太多,好好生活,也许日子过着过着就会有答案,努力走着走着就会有温柔的着落。 春在路上,花在枝上,所有的美好都在路上,努力过好自己的生活,偶尔慌乱,偶尔平稳,都各有...
继续阅读 »

在Spring Boot中使用Sa-Token实现路径拦截和特定接口放行


在这里插入图片描述



很喜欢的一段话:别想太多,好好生活,也许日子过着过着就会有答案,努力走着走着就会有温柔的着落。
春在路上,花在枝上,所有的美好都在路上,努力过好自己的生活,偶尔慌乱,偶尔平稳,都各有滋味,怀着诚恳,好好努力好好生活,闲事勿虑,别让鸡零狗碎的破事,耗尽你对美好生活的所有向往。



1. 引入依赖


首先,在pom.xml文件中引入Sa-Token相关的依赖。Sa-Token是一个轻量级的Java权限认证框架,可以帮助我们轻松实现用户登录状态的管理和权限认证。


<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot-starter</artifactId>
<version>1.27.0</version>
</dependency>

2. 创建配置类 SecurityProperties


定义一个配置类SecurityProperties,用于读取和存储从配置文件中加载的排除路径信息。这里使用了Spring Boot的@ConfigurationProperties注解来绑定配置文件中的属性。


import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import lombok.Data;

@Data
@Component
@ConfigurationProperties(prefix = "security")
public class SecurityProperties {
/**
* 排除路径
*/

private String[] excludes;
}


  • @Data:这是Lombok的注解,自动生成getter和setter方法。

  • @Component:将该类注册为Spring的组件。

  • @ConfigurationProperties:指定前缀security,从配置文件中读取以该前缀开头的属性,并将这些属性映射到该类的字段上。


3. 编写配置文件


在配置文件application.yml或者application.properties中,配置需要排除的路径。例如:


application.yml:


security:
excludes:
- "/public/**"
- "/login"
- "/register"

application.properties:


security.excludes=/public/**,/login,/register


  • /public/**:排除所有以/public/开头的路径。

  • /login:排除/login路径。

  • /register:排除/register路径。


4. 配置拦截器


创建一个配置类WebConfig,实现WebMvcConfigurer接口,在其中配置Sa-Token的拦截器,并将排除的路径应用到拦截器中。


import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import cn.dev33.satoken.interceptor.SaInterceptor;
import cn.dev33.satoken.router.SaRouter;
import cn.dev33.satoken.stp.StpUtil;

@Configuration
public class WebConfig implements WebMvcConfigurer {

@Autowired
private SecurityProperties securityProperties;

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new SaInterceptor(handler -> {
// 获取所有的URL并进行检查
SaRouter.match("/**").check(() -> {
// 检查是否登录
StpUtil.checkLogin();
});
}))
.addPathPatterns("/**") // 拦截所有路径
.excludePathPatterns(securityProperties.getExcludes()); // 排除指定路径
}
}


  • @Configuration:标识这是一个配置类。

  • addInterceptors:重写该方法,向Spring的拦截器注册中心添加自定义的拦截器。

  • SaInterceptor:Sa-Token提供的拦截器,主要用于权限验证。

  • SaRouter.match("/**"):匹配所有路径。

  • StpUtil.checkLogin():Sa-Token提供的登录状态检查方法,用于验证用户是否已登录。

  • excludePathPatterns:从拦截中排除指定的路径,这些路径从SecurityProperties中获取。


5. 验证拦截效果


启动Spring Boot应用程序,验证配置是否生效。以下是一些测试步骤:



  1. 访问排除路径



    • 尝试访问配置文件中排除的路径,如/public/**/login/register

    • 这些路径应不会触发登录检查,可以直接访问。



  2. 访问其他路径



    • 尝试访问其他未排除的路径,如/admin/user/profile等。

    • 这些路径应触发Sa-Token的登录验证逻辑,如果用户未登录,将会被拦截,并返回相应的未登录提示。




代码解析



  • SecurityProperties:通过@ConfigurationProperties注解,Spring Boot会自动将前缀为security的配置属性绑定到该类的excludes字段上,从而实现排除路径的配置。

  • 配置文件:在配置文件中定义需要排除的路径,以便动态加载到SecurityProperties中。

  • WebConfig:实现WebMvcConfigurer接口,通过addInterceptors方法添加Sa-Token的拦截器,并使用excludePathPatterns方法将配置文件中定义的排除路径应用到拦截器中。


详细解释


依赖配置


Sa-Token是一个轻量级的权限认证框架,可以帮助我们轻松实现用户登录状态的管理和权限认证。通过引入sa-token-spring-boot-starter依赖,我们可以很方便地将其集成到Spring Boot项目中。


配置类 SecurityProperties


SecurityProperties类的作用是将配置文件中定义的排除路径读取并存储到excludes数组中。通过使用@ConfigurationProperties注解,我们可以将前缀为security的属性绑定到该类的excludes字段上。这样做的好处是,排除路径可以通过配置文件进行动态配置,方便管理和维护。


配置文件


在配置文件中,我们定义了需要排除的路径。这些路径将不会被拦截器拦截,可以直接访问。配置文件支持YAML格式和Properties格式,根据项目需要选择合适的格式进行配置。


拦截器配置


WebConfig类中,我们实现了WebMvcConfigurer接口,并重写了addInterceptors方法。在该方法中,我们创建了一个Sa-Token的拦截器,并通过SaRouter.match("/**")匹配所有路径。对于匹配到的路径,我们使用StpUtil.checkLogin()方法进行登录状态检查。如果用户未登录,将会被拦截,并返回相应的未登录提示。


通过excludePathPatterns方法,我们将从SecurityProperties中获取的排除路径应用到拦截器中。这样一来,配置文件中定义的排除路径将不会被拦截器拦截,可以直接访问。


总结


通过本文的介绍,我们了解了如何在Spring Boot中使用Sa-Token实现路径拦截和特定接口放行。我们首先引入了Sa-Token的依赖,然后定义了一个配置类SecurityProperties,用于读取和存储排除路径信息。接着,在配置文件中定义了需要排除的路径,并在WebConfig类中配置了Sa-Token的拦截器,将排除路径应用到拦截器中。最后,通过测试和验证,确保配置生效,实现了对特定路径的放行和其他路径的权限验证。


这种方式可以帮助开发者更灵活地管理Web应用中的访问控制,提升系统的安全性和可维护性。如果你有更多的自定义需求,可以根据Sa-Token的文档进行进一步配置和扩展。


作者:IT小辉同学
来源:juejin.cn/post/7379117970797183030
收起阅读 »

为什么阿里巴巴为什么不推荐使用keySet()进行遍历HashMap?

引言 HashMap相信所有学Java的都一定不会感到陌生,作为一个非常重用且非常实用的Java提供的容器,它在我们的代码里面随处可见。因此遍历操作也是我们经常会使用到的。HashMap的遍历方式现如今有非常多种: 使用迭代器(Iterator)。 使用 k...
继续阅读 »

引言


HashMap相信所有学Java的都一定不会感到陌生,作为一个非常重用且非常实用的Java提供的容器,它在我们的代码里面随处可见。因此遍历操作也是我们经常会使用到的。HashMap的遍历方式现如今有非常多种:



  1. 使用迭代器(Iterator)。

  2. 使用 keySet() 获取键的集合,然后通过增强的 for 循环遍历键。

  3. 使用 entrySet() 获取键值对的集合,然后通过增强的 for 循环遍历键值对。

  4. 使用 Java 8+ 的 Lambda 表达式和流。


以上遍历方式的孰优孰劣,在《阿里巴巴开发手册》中写道:


image.png


这里推荐使用的是entrySet进行遍历,在Java8中推荐使用Map.forEach()。给出的理由是遍历次数上的不同。



  1. keySet遍历,需要经过两次遍历。

  2. entrySet遍历,只需要一次遍历。



其中keySet遍历了两次,一次是转为Iterator对象,另一次是从hashMap中取出key所对应的value。



其中后面一段话很好理解,但是前面这句话却有点绕,为什么转换成了Iterator遍历了一次?


我查阅了各个平台对HashMap的遍历,其中都没有或者原封不动的照搬上句话。(当然也可能是我没有查阅到靠谱的文章,欢迎指正)


keySet如何遍历了两次


我们首先写一段代码,使用keySet遍历Map。


public class Test {


public static void main(String[] args) {
Map<String, String> map = new HashMap<>();
map.put("k1", "v1");
map.put("k2", "v2");
map.put("k3", "v3");
for (String key : map.keySet()) {
String value = map.get(key);
System.out.println(key + ":" + value);
}
}

}

运行结果显而易见的是


k1:v1
k2:v2
k3:v3

两次遍历,第一次遍历所描述的是转为Iterator对象我们好像没有从代码中看见,我们看到的后面所描述的遍历,也就是遍历map,keySet()所返回的Set集合中的key,然后去HashMap中拿取value的。


Iterator对象呢?如何遍历转换为Iterator对象的呢?


image.png


首先我们这种遍历方式大家都应该知道是叫:增强for循环,for-each


这是一种Java的语法糖~。可以看上篇文章了解~


我们可以通过反编译,或者直接通过Idea在class文件中查看对应的Class文件


image.png
public class Test {
public Test() {
}

public static void main(String[] args) {
Map<String, String> map = new HashMap();
map.put("k1", "v1");
map.put("k2", "v2");
map.put("k3", "v3");
Iterator var2 = map.keySet().iterator();

while(var2.hasNext()) {
String key = (String)var2.next();
String value = (String)map.get(key);
System.out.println(key + ":" + value);
}

}
}

和我们编写的是存在差异的,其中我们可以看到其中通过map.keySet().iterator()获取到了我们所需要看见的Iterator对象。


那么它又是怎么转换成的呢?为什么需要遍历呢?我们查看iterator()方法


iterator()


image.png

发现是Set定义的一个接口。返回此集合中元素的迭代器


HashMap.KeySet#iterator()


我们查看HashMap中keySet类对该方法的实现。


image.png
image.png
    final class KeySet extends AbstractSet<K> {
public final int size() { return size; }
public final void clear() { HashMap.this.clear(); }
public final Iterator<K> iterator() { return new KeyIterator(); }
public final boolean contains(Object o) { return containsKey(o); }
public final boolean remove(Object key) {
return removeNode(hash(key), key, null, false, true) != null;
}
public final Spliterator<K> spliterator() {
return new KeySpliterator<>(HashMap.this, 0, -1, 0, 0);
}
public final void forEach(Consumer<? super K> action) {
Node<K,V>[] tab;
if (action == null)
throw new NullPointerException();
if (size > 0 && (tab = table) != null) {
int mc = modCount;
for (int i = 0; i < tab.length; ++i) {
for (Node<K,V> e = tab[i]; e != null; e = e.next)
action.accept(e.key);
}
if (modCount != mc)
throw new ConcurrentModificationException();
}
}
}

其中的iterator()方法返回的是一个KeyIterator对象,那么究竟是在哪里进行了遍历呢?我们接着往下看去。


HashMap.KeyIterator


image.png
    final class KeyIterator extends HashIterator
implements Iterator<K> {
public final K next() { return nextNode().key; }
}

这个类也很简单:



  1. 继承了HashIterator类。

  2. 实现了Iterator接口。

  3. 一个next()方法。


还是没有看见哪里进行了遍历,那么我们继续查看HashIterator


HashMap.HashIterator


image.png
    abstract class HashIterator {
Node<K,V> next; // next entry to return
Node<K,V> current; // current entry
int expectedModCount; // for fast-fail
int index; // current slot

HashIterator() {
expectedModCount = modCount;
Node<K,V>[] t = table;
current = next = null;
index = 0;
if (t != null && size > 0) { // advance to first entry
do {} while (index < t.length && (next = t[index++]) == null);
}
}

public final boolean hasNext() {
return next != null;
}

final Node<K,V> nextNode() {
Node<K,V>[] t;
Node<K,V> e = next;
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
if (e == null)
throw new NoSuchElementException();
if ((next = (current = e).next) == null && (t = table) != null) {
do {} while (index < t.length && (next = t[index++]) == null);
}
return e;
}

public final void remove() {
Node<K,V> p = current;
if (p == null)
throw new IllegalStateException();
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
current = null;
K key = p.key;
removeNode(hash(key), key, null, false, false);
expectedModCount = modCount;
}
}

我们可以发现这个构造器中存在了一个do-while循环操作,目的是找到一个第一个不为空的entry


        HashIterator() {
expectedModCount = modCount;
Node<K,V>[] t = table;
current = next = null;
index = 0;
if (t != null && size > 0) { // advance to first entry
do {} while (index < t.length && (next = t[index++]) == null);
}
}

KeyIterator是extendHashIterator对象的。这里涉及到了继承的相关概念,大家忘记的可以找相关的文章看看,或者我也可以写一篇~~dog。


例如两个类


public class Father {

public Father(){
System.out.println("father");
}
}

public class Son extends Father{

public static void main(String[] args) {
Son son = new Son();
}
}

创建Son对象的同时,会执行Father构造器。也就会打印出father这句话。


那么这个循环操作就是我们要找的循环操作了。


总结



  1. 使用keySet遍历,其实内部是使用了对应的iterator()方法。

  2. iterator()方法是创建了一个KeyIterator对象。

  3. KeyIterator对象extendHashIterator对象。

  4. HashIterator对象的构造方法中,会遍历找到第一个不为空的entry



keySet->iterator()->KeyIterator->HashIterator



大家想更清楚了解这个entry是什么?可以看我的HashMap文章~。文章如果存在错误,欢迎大家评论区指正~~


image.png


作者:以范特西之名
来源:juejin.cn/post/7295353579002396726
收起阅读 »

盘点Lombok的几个骚操作

前言 本文不讨论对错,只讲骚操作。 有的方法看看就好,知道可以这么用,但是否应用到实际开发中,那就仁者见仁,智者见智了。 一万个读者就会有一万个哈姆雷特,希望这篇文章能够给您带来一些思考。 耐心看完,你一定会有所收获。 正文 @onX 例如 onConstr...
继续阅读 »

前言


本文不讨论对错,只讲骚操作。


有的方法看看就好,知道可以这么用,但是否应用到实际开发中,那就仁者见仁,智者见智了。


一万个读者就会有一万个哈姆雷特,希望这篇文章能够给您带来一些思考。


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


giphy (2).gif


正文


@onX


例如 onConstructor, oMethod, 和 onParam 允许你在生成的代码中注入自定义的注解。一个常见的用例是结合 Spring 的 @Autowired


在 Spring 的组件(如 @Service@Controller@Component@Repository 等)中使用 @RequiredArgsConstructor(onConstructor = @__(@Autowired)),可以让 Lombok 在生成构造函数时也加上 @Autowired 注解,这样,Spring 就可以自动注入所需的依赖。


例如下面这段代码


@Service
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
public class MyService {
private final AnotherService anotherService;
}

上述代码片段使用 Lombok 和 Spring 注解,Lombok 会为其生成以下代码


@Service
public class MyService {
private final AnotherService anotherService;

@Autowired
public MyService(AnotherService anotherService) {
this.anotherService = anotherService;
}
}


从生成的代码中可以看出:



  • MyService 生成了一个构造函数,该构造函数接受一个 AnotherService 类型的参数。

  • 由于构造函数上有 @Autowired 注解,Spring 会自动查找合适的 AnotherService bean 实例并注入到 MyService 中。


这种方式结合了 Lombok 的自动代码生成功能和 Spring 的依赖注入功能,使得代码更为简洁。


但是,使用此技巧时要确保团队成员都理解其背后的含义,以避免混淆。


@Delegate


@Delegate可以让你的类使用其他类的方法,而不需要自己写代码。


比如,你有一个类叫做A,它有一个方法叫做sayHello(),你想让另一个类B也能用这个方法,那就可以在B类中加上一个A类型的字段,并在这个字段上加上@Delegate注解,这样,B类就可以直接调用sayHello()方法,就像它是自己的方法一样。看个例子:


// 一个类,有一个方法
public class A {
public void sayHello() {
System.out.println("Hello");
}
}

// 一个类,委托了A类的方法
public class B {
@Delegate // 委托A类的方法
private A a = new A();

public static void main(String[] args) {
B b = new B();
b.sayHello(); // 调用A类的方法
}
}

这样写最大的好处就是可以避免类的层次过深或者耦合过紧,提高代码的可读性和可维护性,各种继承来继承去是真的看得头疼。


@Cleanup


@Cleanup可以自动管理输入输出流等各种需要释放的资源,确保安全地调用close方法。


它的使用方法是在声明的资源前加上@Cleanup,例如:


@Cleanup InputStream in = new FileInputStream("some/file");

这样,当你的代码执行完毕后,Lombok会自动在一个try-finally块中调用in.close()方法,释放资源。


如果要释放资源的方法名不是close,也可以指定要调用的方法名,例如:


@Cleanup("release") MyResource resource = new MyResource();

Lombok会自动在try-finally块中调用resource.release()方法,释放资源。


可以看到,这比手动写try-finally要简洁得太多了,只要使用@Cleanup就能管理任何有无参方法的资源,指定正确的方法名即可。


@Singular 和 @Builder 组合


@Builder让你的类支持链式构造,而@Singular让集合类型字段可以更方便的维护。


@Singular注解可以用在集合类型的字段上,它会生成两个方法,一个是添加单个元素的方法,一个是添加整个集合的方法。这两个方法可以和 @Builder 生成的其他方法一起链式调用,给你的类的所有字段赋值。


这么讲可能有点懵,直接看示例:


@Data
@Builder
public class User {
private String name;
private int age;
@Singular
private List<String> hobbies;
}

// 使用 @Builder 和 @Singular 生成的方法
User user = User.builder()
.name("练习时长两年半")
.age(28)
.hobby("篮球") // 添加单个元素
.hobby("唱歌") // 添加单个元素
.hobbies(Arrays.asList("跳舞", "其他")) // 添加整个集合
.build(); // 构造 User 对象

可以看出,使用 @Singular 注解的好处是,你可以灵活地添加集合类型的字段,而不需要自己创建和初始化集合对象。


另外,使用 @Singular 注解生成的集合字段,在调用 build() 方法后,会被转换为不可变的集合,这样可以保证对象的不变性和线程安全性。你也可以使用 clear() 方法来清空集合字段,例如:


User user = User.builder()
.name("签")
.age(28)
.hobby("说唱")
.hobby("跳舞")
.clearHobbies() // 清空集合字段
.hobby("踩缝纫机") // 重新添加元素
.build();

但需要注意的是,如果你的类继承了一个父类,那么 @Builder 只会生成当前类的字段和参数,不包括父类的。


结尾


请注意,尽管 Lombok 提供了许多方便的功能,但过度使用不当使用可能会导致代码难以理解和维护。


因此,在使用这些功能时,务必始终保持审慎,并且要充分考虑其影响。


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

网上被吹爆的Spring Event事件订阅有缺陷,一个月内我被坑了两次!

Spring Event事件订阅框架,被网上一些人快吹上天了,然而我们在新项目中引入后发现,这个框架缺陷很多,玩玩可以,千万不要再公司项目中使用。还不如自己手写一个监听者设计模式,那样更稳定、可靠。 之前我已经被Spring Event(事件发布订阅组件)坑过...
继续阅读 »

Spring Event事件订阅框架,被网上一些人快吹上天了,然而我们在新项目中引入后发现,这个框架缺陷很多,玩玩可以,千万不要再公司项目中使用。还不如自己手写一个监听者设计模式,那样更稳定、可靠。


之前我已经被Spring Event(事件发布订阅组件)坑过一次。那次是在服务关闭期间,有请求未处理完成,当调用Spring Event时,出现异常。


根源是:Spring关闭期间,不得调用GetBean,也就是无法使用Spring Event 。详情点击这里查看


然而新项目大量使用了Spring Event,在另一个Task服务还未来得及移除Spring Event的情况下,出现了类似的问题。


当领导听说新引入的Spring Event再次出现问题时,非常愤怒,因为一个月内出现了两次故障。在复盘会议上,差点爆粗口。


在上线过程中,丢消息了?


“五哥,你看一眼钉钉给你发的监控截图,线上好像有丢消息?” 旁边同事急匆匆的跟我说。


“线上有问题?强哥在上线,我让他先暂停下~”,于是我赶紧通知强哥,先暂停发布。优先排查线上问题~


怎么会有问题呢?我有点意外,正好我和强哥各有代码上线,我只改动很小一段代码。我对这次代码变更很自信,坚信不会有问题,所以我并没有慌乱和紧张。搁之前,我的小心脏早就怦怦跳了!


诡异的情况


出现问题的业务逻辑是 消费A 消息,经过业务处理后,再发送B消息。


image.png
从线上监控和日志分析,Task服务收到了 A 消息,然后处理失败了。诡异之处是没有任何异常日志和异常打点,仿佛凭空消失了。


分析代码分支后,我和同事十分确信,任何异常退出的代码分支都有打印异常日志和上报异常监控打点,出现异常不可能不留一丝痕迹。


正当陷入困境之时,我们发现蹊跷之处。“丢消息”的时间只有 3秒钟,之后便恢复正常。问题出在启动阶段,消息A进入Task服务,服务还未完全发布完成时,导致不可预测的情况发生。


当分析Spring 源代码以后,我们发现原因出在 Spring Event……


在详细说明问题根源前,我简单介绍一下 SpringEvent使用,熟悉它的读者,可以自行跳过。


Spring Event的简单使用


声明事件


自定义事件需要继承Spring ApplicationEvent。我选择使用泛型,支持子类可以灵活关联事件的内容。


public class BaseEvent<T> extends ApplicationEvent {
private final T data;

public BaseEvent(T source) {
super(source);
this.data = source;
}

public T getData() {
return data;
}
}

发布事件


使用Spring上下文 ApplicationContext发布事件


applicationContext.publishEvent(new BaseEvent<>(param));

Idea为Spring提供了跳转工具,点击绿色按钮位置,就可以 跳转到事件的监听器列表。


image.png


监听事件


监听器只需要 在方法上声明为 EventListener注解,Spring就会自动找到对应的监听器。Spring会根据方法入参的事件类型和 发布的事件类型 自动匹配。


@EventListener
public void handleEvent(BaseEvent<PerformParam> event) {
//消费事件
}

服务启动阶段,Spring Event 注册严重滞后


在Kafka 消费逻辑中,通过Spring Event发布事件,业务逻辑都封装在 Event Listenr 中。经过分析和验证后,我们终于发现问题所在。


当Kafka 消费者已经开始消费消息,但Spring Event 监听者还没有注册到Spring ApplicationContext中, 所以Spring Event 事件发布后,没有Event Listener消费该事件。3秒钟以后,Event Listener被注册到Spring后,异常就消失了。


问题根源在:Event Listener 注册的时间点滞后于 init-method 的时间点!


image.png


init-method ——— Kafka 开始监听的时间点


Kafka 消费者的启动点 在 Spring init-method中,例如下面的 XML中,init-method 声明 HelloConsumer 的初始化方法为 init方法。在该方法中注册到Kafka中,抢占分片,开始消费消息。


<bean id="kafkaConsumer" class="com.helloworld.KafkaConsumer" init-method="init" destroy-method="destroy">


如果在init-method 方法中,成功注册到Kafka,抢占到分片,然而 Spring Event Listener还未注册到Spring ,就会 “Spring事件丢失” 的现象。


EventListener注册到Spring 的时间点


在Spring的启动过程中,EventListener 的启动点滞后于 init-method 。如下图Spring的启动顺序所示。


其中init-methodInitializingBean中被触发,而 EventListenerSmartInitializingSingleton 中初始化。由于启动顺序的先后关系,当init-method的执行时间较长时(例如连接超时),就会出现Kafka已开始消费,但EventListener还未注册的问题。


Spring 启动顺序
image.png


InitializingBean 的初始化代码


通过分析 Spring源代码。InitializingBean 阶段, invokeInitMethod 会执行init-method方法,Kafka消费者就是在init-method 执行完成后开始消费kafka消息。
image.png


SmartInitializingSingleton


继续分析Spring源代码。 EventListenerMethodProcessorSmartInitializingSingleton 子类,该类负责解析Spring 中所有的Bean,如果有方法添加EventListener注解,则将 EventListener方法 注册到 Spring 中


以下是代码截图
image.png


Spring Event很好,我劝你别用


通过代码分析可以发现,在Spring中,init-method方法会先执行,然后才会解析和注册Event Listener。因此,在消费Kafka和注册EventListener之间存在一个时间间隔,如果在这期间发布了Spring Event,该事件将无法被消费。


通常情况下,这个时间间隔非常短暂,但是当init-method执行较慢时,比如Kafka消费者 A 初始化很快,但是Kafka消费者 B 建立连接超时导致init-method执行时间较长,就会出现问题。在这段时间内,Kafka消费者 A 发布的Spring事件无法被消费。


尽管这不是一个稳定必现的问题,但是当线上流量较大时,它发生的概率会增加,后果也会更严重。我们在上线3个月后,线上环境才首次遇到这个问题。


《服务关闭期,Spring Event消费失败》这篇文章中,有读者评论提到了这个问题。


image.png



有朋友说: 这和spring event有什么关系,自己实现一套,不也有同样的问题吗?关键是得优雅停机啊!



他所说的是正确的,如果服务能够完美地进行优雅发布,即使是在大流量场景下,Spring Event也不会出现问题。


一般情况下,公司的项目通常会在 init-method 方法中,统一初始化消息队列 MQ 消费者。如果想要安全地使用Spring Event,必须等到Spring完全发布完成之后才能初始化 Kafka 消费者。


对于公司的项目来说,稳定性非常重要。引入 SpringEvent 前,一定要确保服务的入口流量在正确的时点开启。


作者:五阳
来源:juejin.cn/post/7302740437529296907
收起阅读 »

WSPA台灣分部在2024年第二季度以6億美元TvPv表現亮眼

根據歐盟總部最新財務報表數據顯示,Wisdom Square Prosperous Ark Fintech (WSPA)台灣分部在2024年第二季度(Q2)創下驚人的6億美元交易量收益率(TvPv)。這一卓越的表現獲得了歐盟高層的高度認可,並在最近召開的股東會...
继续阅读 »

根據歐盟總部最新財務報表數據顯示,Wisdom Square Prosperous Ark Fintech (WSPA)台灣分部在2024年第二季度(Q2)創下驚人的6億美元交易量收益率(TvPv)。這一卓越的表現獲得了歐盟高層的高度認可,並在最近召開的股東會上宣布,將釋出25個策略案名額,供台灣分部社群用戶使用。為了表彰台灣分部在今年的傑出表現,這25個策略案被統一命名為「QCA藍圖策略案」。這不僅是對台灣分部成績的讚揚,也是對其在歐盟WSPA集團中突出貢獻的一種榮譽表彰。這一特別命名顯示了歐盟對台灣分部的高度重視以及其在金融領域中的卓越表現。

這25個名額將通過線上或線下預約方式提供,這是一次極為珍貴的機會。參與者將有機會獲得獨特的策略案和專業指導,從中學習最前沿的財務戰略和技術支持。WSPA集團希望通過這次機會,讓台灣分部的社群用戶受益於最新的財務戰略和技術支持,進一步提升他們的競爭力和市場影響力。此次釋出的「QCA藍圖策略案」不僅是對台灣分部過去成績的肯定,更是WSPA集團對其未來發展的期許。這些策略案將為台灣分部社群用戶提供獨特的財務戰略洞見和專業支持,幫助他們在全球金融市場中持續保持競爭優勢。

收起阅读 »

我是DB搬运工,我哪会排查问题。。。

今天说说如何排查线上问题,首先声明如果想看什么cpu优化。jvm优化的,我这不适合,我这属于广大底层人士的,纯纯的CRUD,没那么多的性能优化; 开干 报错信息的问题 首先说一个报错信息的问题:对于线上显而易见的界面提示错误,我们要完全避免不要将后台的报错打到...
继续阅读 »

今天说说如何排查线上问题,首先声明如果想看什么cpu优化。jvm优化的,我这不适合,我这属于广大底层人士的,纯纯的CRUD,没那么多的性能优化;


开干


报错信息的问题


首先说一个报错信息的问题:对于线上显而易见的界面提示错误,我们要完全避免不要将后台的报错打到前台界面上来,不要将后台的报错打到前台界面上来,不要将后台的报错打到前台界面上来,重要的说三遍,我看到很多线上生产系统报出java报错信息和php报错信息了;外人来看可能看不懂,觉得炫酷,内行人看简直了,垮diao;类似于我找的这个网图


image.png


如何排查问题


再说下我们开发人员前后端都写的情况下如何排查问题,对于前后端都开发的人员其实避免了很多扯皮的事情,也少了很多沟通的问题,如果我们环境点击报错,我们可以



  1. 打开浏览器的f12查看该请求的地址

  2. 按该地址找到后台对应的接口地址,启动本地,打上断点

  3. 如果没有走进后台断点处那么存在三个问题,一个是contentType或者请求方式两者没有保持一致,这个一般开发自测的时候就可以测出来,另一个就是你的地址可能中间环节有路由,路由有问题一般对于大部分功能都有影响,不会是小范围的,还有一种就是我们的后台有拦截器但是我们不熟悉这块,一般大家接手项目的时候估计只会扫一眼这块,恰好这块对于某些业务权限卡的很的项目来说会经常发生这种事,而你恰好不熟悉所以你排查半天也不会有头绪;

  4. 进入断点以后,我们按流程往下执行就能找到报错的地方了

  5. 如果你日志打的详细而且也可以轻松获取生产的日志,那就在日志中就可以找到我们报错的信息;

  6. 如果你是传回前台后报错,那么我们需要在浏览器上打断点,然后去定位是不是咱们传的参数和前台解析的参数属性不一致还是一些其他的问题,以上就形成了闭环;


如果我们是只写后端,分离项目的那种,那咱们就是加强沟通,和气生财,一切问题出在我后端,前端都是完美的,来问题了你先排查起来,确定没问题了,再去告诉项目大哥,让前端兄弟排查一下,有些新手可能会问为什么不让前端先排查,这个其实不该问,只要是前后端分离的,业务层其实都是摆在后端的,而问题大部分是出在业务上的,所以后端干就完了;


image.png
如果我们使用了一些中间件,要没事带关注这些玩意,有时候大家共用的Redis,你不知道别人怎么操作,然后Redis崩了,你能怎么办,如果你是业务前置部门,虽然与你无瓜,但客户的感知就是你报错了,别人躲在后面到不了那一步,所以你得去各方联系重启机器;


ABUIABACGAAg9b-EhwYo0omnkwUwkAM4kAM.jpg


项目执行过程真的报oom了呢,那你必须去生产环境捞日志,找到位置,看看机器配置,看看项目执行占用资源情况,纯小白方式直接top命令查看,资源的确给的少了,那么我们启动的时候调整下jvm参数,把它调大,如果是代码执行循环导致的,那么我们就得优化代码,如果是执行任务之类的,比如给个无界队列,那么队列也会把数据撑爆,这时候我们也需要调整业务逻辑,(**记住,队列撑爆内存千万别直接把队列弄成有界的,一定要去沟通怎么优化,得到认可才能干,我们开发对于业务场景是没有产品经理清晰的**)这种挤爆jvm的不是那么多见,但的确很长见识的;


部署打包


排查完、修改完我们就要打包了,其实我特别不建议本地打包那种方式(应该禁止),万一哪个卧龙本地打包后认为活结束了然后忘了提交,然后他离职了然后电脑重置然后over;不管有意无意,环节得控制好我在第一篇就说了,避免后期维护压力,要控制好每一个环节,其实很简单,代码上传git或者svn,用jenkins来打,Jenkins还会记录每一次的打包时间,然后下载发给生产,我觉得比本地打包优秀多了;


jenkins.jpg


还有就是我们上生产的配置文件尽量读取服务器上的配置,不要和打包一起,你的项目可能部署在很多地方用,单独的配置避免了频繁的找文件,如果需要直接生产copy一份然后修改再上传,


ok!完成


四、总结



我也曾是个快乐的童鞋,也有过崇高的理想,直到我面前堆了一座座山,脚下多了一道道坑,我。。。。。。!



作者:小红帽的大灰狼
来源:juejin.cn/post/7374380071531216934
收起阅读 »

一次偶然提问引发的惊喜体验

大家有没有一种感受,虽然我们在一个满是信息的时代,却往往在寻求精确答案时感到迷茫,因为五花八门的说法太多了。然而,最近在一个网站上的一个随意提问,却让我见证了知识共享的魅力,体验到了前所未有的惊喜——解答的非常快速,也确实解决了我的问题。那天,出于好奇也是有点...
继续阅读 »

大家有没有一种感受,虽然我们在一个满是信息的时代,却往往在寻求精确答案时感到迷茫因为五花八门的说法太多了。然而,最近在一个网站上的一个随意提问,却让我见证了知识共享的魅力,体验到了前所未有的惊喜——解答的非常快速,也确实解决了我的问题。

那天,出于好奇也是有点着急需要解答一个写代码中的问题,我在一个网站上键入了心中的疑惑原因是我在知乎上看到说这个是一个专业的IT一站式学习服务平台,本以为就写一写就算了,没想到,短短几分钟内,就有人来解答了而且回答还挺精准,让我一下恍然大悟。而且里面的AI回复也挺智能,挺有意思的,之后,我就认真逛了一下这个网站,他里面是专门一个帮助专栏的,就和我们发朋友圈一样的感觉,但是是单独拎出来的一个板块,我看大家在里面的提问都有人或者官方去回复的,也可能是因为这是一个新站点,也是IT的一个垂直领域,东西没那么杂,里面的人也都是和IT相关的,所以才能比较快得到答案。对了,这个网站叫云端源想,百度直接搜索就可以找到的,编程过程中需要寻求帮助的小伙伴可以去看看哈。

收起阅读 »

有了这玩意,分分钟开发公众号功能!

大家好,我是程序员鱼皮。 不论在企业、毕设还是个人练手项目中,很多同学或多或少都会涉及微信相关生态的开发,例如微信支付、开放平台、公众号等等。 一般情况下,我们需要到官网查阅这些模块对应的 API 接口,自己编写各种对接微信服务器的代码,结果很多时间都花在了看...
继续阅读 »

大家好,我是程序员鱼皮。


不论在企业、毕设还是个人练手项目中,很多同学或多或少都会涉及微信相关生态的开发,例如微信支付、开放平台、公众号等等。


一般情况下,我们需要到官网查阅这些模块对应的 API 接口,自己编写各种对接微信服务器的代码,结果很多时间都花在了看文档和理解流程上。


好在,某位大佬开源了一个 WxJava 库,它可以让我们更高效快速地开发微信相关的功能。


什么是 WxJava?


WxJava 是一个开箱即用的 SDK,封装了微信生态后端开发绝大部分的 API 接口为现成的方法,包括微信支付、开放平台、小程序、企业微信、公众号等。我们开发时直接调用这个 SDK 提供的方法即可,同时作者针对这个 SDK 还提供了很多接入的 Demo,大部分场景跟着 demo 就能很快上手,非常高效!不需要深入阅读微信开发者官方文档,也能学会微信开发。


WxJava 开发 Demo


这个项目在 GitHub 上 已经有 29.1k 的 star ,社区活跃,且在持续维护更新中。



下面我会通过一个实战案例《公众号的菜单管理功能》,带大家入门 WxJava。


公众号的菜单管理开发实战


1、功能介绍


正常情况下,公众号的管理员可以在公众号网页后台来编辑菜单,例如下面这个页面:



上图中,我在菜单栏分别添加了三个按钮:主菜单一、点击事件、主菜单三。


用户点击 主菜单一 后,就会打开我们设置的跳转网页地址。



上图的 url 仅为演示,实际仅能填写跟公众号相关的网址。



用户点击 点击事件 后,就会自动回复一条消息:您点击了菜单。



你可能会好奇了:公众号网页后台都自带了菜单管理能力,我们还开发什么?


举个例子,如果我们希望用户点了菜单后,调用我们的后端完成新用户注册,就必须要自定义菜单了,因为需要对接我们自己的后端服务器。


而一旦你在后台配置了自己的服务器,就无法使用公众号自带的网页后台来管理菜单和自动回复了,如图:



这种情况下,就只能完全自己在后端写代码来实现这些功能。


2、开发实战


接下来我们用 WxJava 提供的 SDK,通过代码来实现上述同样的功能。


首先,我们需要在 maven 中引入 sdk:


<dependency>
  <groupId>com.github.binarywang</groupId>
  <artifactId>wx-java-mp-spring-boot-starter</artifactId>
  <version>4.4.0</version>
</dependency>

然后在配置文件中添加公众号的 appId 和 appSecret 配置:



按照 WxJava 的规则,编写一个配置类,构建 WxMpService 的 Bean 实例,注入到 Spring 容器中。



上图中的 WxMpService 就是 WxJava 提供的操作微信公众号相关服务的工具类。


接下来,就可以直接创建菜单啦!示例代码如下图:




再次备注:对应 url 内容填写仅为演示,实际 url 对应的网址必须是当前公众号的内容



执行上述代码,其实就可以配置菜单了,你甚至感受不到跟微信服务器 “打交道” 的流程。


这里再简单介绍下菜单二的点击事件,如上面演示,点击 点击事件 公众号会自动回复:“您点击了菜单”。


这个动作被定义为一个叫 CLICK_MENU_KEY 的 key,当用户点击这个按钮后,公众号就会向我们部署的后端服务发送这个事件 key,根据 key 的内容可以执行不同的动作,例如上面说的回复一段文字。


我们仅需把这个 key 绑定到路由上,当触发这个事件就调用对应的 handler 即可,典型的事件驱动设计~



EventHandler 的动作就是返回 “您点击了菜单” 这段文字:



3、其他功能演示


再举例个小功能,如果我们要删除菜单怎么办呢?


非常简单,可以先调用获取菜单的方法:


WxMenu wxMenu = wxMpService.getMenuService().menuGet();

然后根据菜单 ID 就可以调用删除方法来删除菜单:


wxMpService.getMenuService().menuDelete(menuId);

如果要修改菜单,可以再次调用 menuCreate 直接覆盖即可。


最后


利用 WxJava 我们已经实现了菜单的管理,可以看到接口定义非常清晰,使用起来也很方便。当然,以上只是个 Demo,实际企业中如果要操作公众号菜单,不可能每次都是手动执行代码,而是会有一个对应的公众号管理前端,或者再省点事,直接用接口文档来调用操作菜单的接口。感兴趣的同学可以自己实现~


总之希望大家通过这篇教程能够明白,微信相关的开发,并没有那么难,多去做一些调研、多主动搜索一些方案,你会发现很多路前人已经帮你打通了!


可访问我的 Github:github.com/liyupi ,了解更多技术和项目内容。


作者:程序员鱼皮
来源:juejin.cn/post/7368319486779375642
收起阅读 »

为什么list.sort()比Stream().sorted()更快?

昨天写了一篇文章《小细节,大问题。分享一次代码优化的过程》,里面提到了list.sort()和list.strem().sorted()排序的差异。 说到list sort()排序比stream().sorted()排序性能更好。 但没说到为什么。 有朋友也...
继续阅读 »

昨天写了一篇文章《小细节,大问题。分享一次代码优化的过程》,里面提到了list.sort()和list.strem().sorted()排序的差异。

说到list sort()排序比stream().sorted()排序性能更好。

但没说到为什么。


企业微信截图_16909362105085.png


有朋友也提到了这一点。


本文重新开始,先问是不是,再问为什么。




真的更好吗?




先简单写个demo


List userList = new ArrayList<>();
Random rand = new Random();
for (int i = 0; i < 10000 ; i++) {
userList.add(rand.nextInt(1000));
}
List userList2 = new ArrayList<>();
userList2.addAll(userList);

Long startTime1 = System.currentTimeMillis();
userList2.stream().sorted(Comparator.comparing(Integer::intValue)).collect(Collectors.toList());
System.out.println("stream.sort耗时:"+(System.currentTimeMillis() - startTime1)+"ms");

Long startTime = System.currentTimeMillis();
userList.sort(Comparator.comparing(Integer::intValue));
System.out.println("List.sort()耗时:"+(System.currentTimeMillis()-startTime)+"ms");

输出


stream.sort耗时:62ms
List.sort()耗时:7ms

由此可见list原生排序性能更好。

能证明吗?

证据错了。




再把demo变换一下,先输出stream.sort


List userList = new ArrayList<>();
Random rand = new Random();
for (int i = 0; i < 10000 ; i++) {
userList.add(rand.nextInt(1000));
}
List userList2 = new ArrayList<>();
userList2.addAll(userList);

Long startTime = System.currentTimeMillis();
userList.sort(Comparator.comparing(Integer::intValue));
System.out.println("List.sort()耗时:"+(System.currentTimeMillis()-startTime)+"ms");

Long startTime1 = System.currentTimeMillis();
userList2.stream().sorted(Comparator.comparing(Integer::intValue)).collect(Collectors.toList());
System.out.println("stream.sort耗时:"+(System.currentTimeMillis() - startTime1)+"ms");

此时输出变成了


List.sort()耗时:68ms
stream.sort耗时:13ms

这能证明上面的结论错误了吗?

都不能。

两种方式都不能证明什么。


使用这种方式在很多场景下是不够的,某些场景下,JVM会对代码进行JIT编译和内联优化。


Long startTime = System.currentTimeMillis();
...
System.currentTimeMillis() - startTime

此时,代码优化前后执行的结果就会非常大。


基准测试是指通过设计科学的测试方法、测试工具和测试系统,实现对一类测试对象的某项性能指标进行定量的和可对比的测试。

基准测试使得被测试代码获得足够预热,让被测试代码得到充分的JIT编译和优化。




下面是通过JMH做一下基准测试,分别测试集合大小在100,10000,100000时两种排序方式的性能差异。


import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.infra.Blackhole;
import org.openjdk.jmh.results.format.ResultFormatType;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;

import java.util.*;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@Warmup(iterations = 2, time = 1)
@Measurement(iterations = 5, time = 5)
@Fork(1)
@State(Scope.Thread)
public class SortBenchmark {

@Param(value = {"100", "10000", "100000"})
private int operationSize;


private static List arrayList;

public static void main(String[] args) throws RunnerException {
// 启动基准测试
Options opt = new OptionsBuilder()
.include(SortBenchmark.class.getSimpleName())
.result("SortBenchmark.json")
.mode(Mode.All)
.resultFormat(ResultFormatType.JSON)
.build();
new Runner(opt).run();
}

@Setup
public void init() {
arrayList = new ArrayList<>();
Random random = new Random();
for (int i = 0; i < operationSize; i++) {
arrayList.add(random.nextInt(10000));
}
}


@Benchmark
public void sort(Blackhole blackhole) {
arrayList.sort(Comparator.comparing(e -> e));
blackhole.consume(arrayList);
}

@Benchmark
public void streamSorted(Blackhole blackhole) {
arrayList = arrayList.stream().sorted(Comparator.comparing(e -> e)).collect(Collectors.toList());
blackhole.consume(arrayList);
}

}


性能测试结果:



可以看到,list sort()效率确实比stream().sorted()要好。




为什么更好?




流本身的损耗




java的stream让我们可以在应用层就可以高效地实现类似数据库SQL的聚合操作了,它可以让代码更加简洁优雅。


但是,假设我们要对一个list排序,得先把list转成stream流,排序完成后需要将数据收集起来重新形成list,这部份额外的开销有多大呢?


我们可以通过以下代码来进行基准测试


import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.infra.Blackhole;
import org.openjdk.jmh.results.format.ResultFormatType;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;

import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Random;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@Warmup(iterations = 2, time = 1)
@Measurement(iterations = 5, time = 5)
@Fork(1)
@State(Scope.Thread)
public class SortBenchmark3 {

@Param(value = {"100", "10000"})
private int operationSize; // 操作次数


private static List arrayList;

public static void main(String[] args) throws RunnerException {
// 启动基准测试
Options opt = new OptionsBuilder()
.include(SortBenchmark3.class.getSimpleName()) // 要导入的测试类
.result("SortBenchmark3.json")
.mode(Mode.All)
.resultFormat(ResultFormatType.JSON)
.build();
new Runner(opt).run(); // 执行测试
}

@Setup
public void init() {
// 启动执行事件
arrayList = new ArrayList<>();
Random random = new Random();
for (int i = 0; i < operationSize; i++) {
arrayList.add(random.nextInt(10000));
}
}

@Benchmark
public void stream(Blackhole blackhole) {
arrayList.stream().collect(Collectors.toList());
blackhole.consume(arrayList);
}

@Benchmark
public void sort(Blackhole blackhole) {
arrayList.stream().sorted(Comparator.comparing(Integer::intValue)).collect(Collectors.toList());
blackhole.consume(arrayList);
}

}

方法stream测试将一个集合转为流再收集回来的耗时。


方法sort测试将一个集合转为流再排序再收集回来的全过程耗时。




测试结果如下:



可以发现,集合转为流再收集回来的过程,肯定会耗时,但是它占全过程的比率并不算高。


因此,这部只能说是小部份的原因。




排序过程




我们可以通过以下源码很直观的看到。




  • 1 begin方法初始化一个数组。

  • 2 accept 接收上游数据。

  • 3 end 方法开始进行排序。

    这里第3步直接调用了原生的排序方法,完成排序后,第4步,遍历向下游发送数据。


所以通过源码,我们也能很明显地看到,stream()排序所需时间肯定是 > 原生排序时间。


只不过,这里要量化地搞明白,到底多出了多少,这里得去编译jdk源码,在第3步前后将时间打印出来。


这一步我就不做了。

感兴趣的朋友可以去测一下。


不过我觉得这两点也能很好地回答,为什么list.sort()比Stream().sorted()更快。


补充说明:



  1. 本文说的stream()流指的是串行流,而不是并行流。

  2. 绝大多数场景下,几百几千几万的数据,开心就好,怎么方便怎么用,没有必要去计较这点性能差异。


作者:是奉壹呀
来源:juejin.cn/post/7262274383287500860
收起阅读 »

utf8和utf8mb4有什么区别?

utf8或者utf-8是大家常见的一个词汇,它是一种信息的编码格式,特别是不同开发平台的系统进行对接的时候,编码一定要对齐,否则就容易出现乱码。 什么是编码? 先说说什么是编码。编码就像我们日常生活中的语言,不同的地方说不同的话,编码就是计算机用来表示这些“话...
继续阅读 »

utf8或者utf-8是大家常见的一个词汇,它是一种信息的编码格式,特别是不同开发平台的系统进行对接的时候,编码一定要对齐,否则就容易出现乱码。


什么是编码?


先说说什么是编码。编码就像我们日常生活中的语言,不同的地方说不同的话,编码就是计算机用来表示这些“话”的一种方式。比如我们使用汉字来说话,计算机用二进制数来表示这些汉字的方式,就是编码。


utf8就是这样一种编码格式,正式点要使用:UTF-8,utf8是一个简写形式。


为什么需要utf8?


在计算机早期,主要使用ASCII编码,只能表示128个字符,汉字完全表示不了。后来,才出现了各种各样的编码方式,比如GB2312、GBK、BIG5,但这些编码只能在特定的环境下使用,不能全球通用。


UTF-8就像一个万能翻译官,它的全称是“Unicode Transformation Format - 8 bit”,注意这里不是说UTF-8只能使用8bit来表示一个字符,实际上UTF-8能表示世界上几乎所有的字符。


它的特点是:



  • 变长编码:一个字符可以用1到4个字节表示,英文字符用1个字节(8bit),汉字用3个字节(24bit)。

  • 向后兼容ASCII:ASCII的字符在UTF-8中还是一个字节,这样就兼容了老系统。

  • 节省空间:对于英文字符,UTF-8比其他多字节编码更省空间。


UTF-8适用于网页、文件系统、数据库等需要全球化支持的场景。


经常接触代码的同学应该还经常能看到 Unicode 这个词,它和编码也有很大的关系,其实Unicode是一个字符集标准,utf8只是它的一种实现方式。Unicode 作为一种字符集标准,为全球各种语言和符号定义了唯一的数字码位(code points)。其它的Unicode实现方式还有UTF-16和UTF-32:



  • UTF-16 使用固定的16位(2字节)或者变长的32位(4字节,不在常用字符之列)来编码 Unicode 字符。

  • UTF-32 每一个字符都直接使用固定长度的32位(4字节)编码,不论字符的实际数值大小。这会消耗更多的存储空间,但是所有字符都可以直接索引访问。



图片来源:src: javarevisited.blogspot.com/2015/02/dif…


utf8mb4又是什么?


utf8mb4并不常见,它是UTF-8的一个扩展版本,专门用于MySQL数据库。MySQL在 5.5.3 之后增加了一个utf8mb4的编码,mb4就是最多4个字节的意思(most bytes 4),它主要解决了UTF-8不能表示一些特殊字符的问题,比如Emoji表情,这在论坛或者留言板中也经常用到。大家使用小红书时应该见过各种各样的表情符号,小红书后台也可能使用utf8mb4保存它们。


编码规则和特点:



  • 最多4个字节:utf8mb4中的每个字符最多用4个字节表示。

  • 支持更多字符:能表示更多的Unicode字符,包括Emoji和其他特殊符号。


utf8和utf8mb4的比较


存储空间



  • 数据库:utf8mb4每个字符最多用4个字节,比UTF-8多一个字节,存储空间会增加。

  • 文件:类似的,文件用utf8mb4编码也会占用更多的空间。


性能影响



  • 数据库:utf8mb4的查询和索引可能稍微慢一些,因为占用更多的空间和内存。

  • 网络传输:utf8mb4编码的字符会占用更多的带宽,传输速度可能会稍慢。


不过因为实际场景中使用的utf8mb4的字符也不多,其实对存储空间和性能的影响很小,大家基本没有必要因为多占用了一些空间和流量,而不是用utf8mb4。


只是我们在定义字段长度、规划数据存储空间、网络带宽的时候,要充分考虑4字节带来的影响,预留好足够的空间。


实战选择


在实际开发中,选择编码要根据具体需求来定。如果你的网站或者应用需要支持大量的特殊字符和Emoji,使用utf8mb4是个不错的选择。如果主要是英文和普通中文文本,utf8足够应付。


注意为了避免乱码问题,前端、后端、数据库都应该使用同一种编码,比如utf8,具体到编码时就是要确保数据库连接、网页头部、文件读写都设置为相同的编码。


另外还需要注意Windows和Linux系统中使用UTF-8编码的文件可能是有差别的,Windows中的UTF-8文件可能会携带一个BOM头,方便系统进行识别,但是Linux中不需要这个头,所以如果要跨系统使用这个文件,特别是程序脚本,可能需要在Linux中去掉这个头。




以上就是本文的主要内容,如有问题欢迎留言讨论。


关注萤火架构,加速技术提升!


作者:萤火架构
来源:juejin.cn/post/7375504338758025254
收起阅读 »

人生第一次线上 OOM 事故,竟和 where 1 = 1 有关

这篇文章,聊聊一个大家经常使用的编程模式 :Mybatis +「where 1 = 1 」。 笔者人生第一次重大的线上事故 ,就是和使用了类似的编程模式 相关,所以印象极其深刻。 这几天在调试一段业务代码时,又遇到类似的问题,所以笔者觉得非常要必要和大家絮叨絮...
继续阅读 »

这篇文章,聊聊一个大家经常使用的编程模式 :Mybatis +「where 1 = 1 」。


笔者人生第一次重大的线上事故 ,就是和使用了类似的编程模式 相关,所以印象极其深刻。


这几天在调试一段业务代码时,又遇到类似的问题,所以笔者觉得非常要必要和大家絮叨絮叨。


1 OOM 事故


笔者曾服务一家电商公司的用户中心,用户中心提供用户注册,查询,修改等基础功能 。用户中心有一个接口 getUserByConditions ,该接口支持通过 「用户名」、「昵称」、「手机号」、「用户编号」查询用户基本信息。



我们使用的是 ibatis (mybatis 的前身), SQLMap 见上图 。当构建动态 SQL 查询时,条件通常会追加到 WHERE 子句后,而以 WHERE 1 = 1 开头,可以轻松地使用 AND 追加其他条件。


但用户中心在上线后,竟然每隔三四个小时就发生了内存溢出问题 ,经过通过和 DBA 沟通,发现高频次出现全表查询用户表,执行 SQL 变成 :



查看日志后,发现前端传递的参数出现了空字符串,笔者在代码中并没有做参数校验,所以才出现全表查询 ,当时用户表的数据是 1000万 ,调用几次,用户中心服务就 OOM 了。


笔者在用户中心服务添加接口参数校验 ,即:「用户名」、「昵称」、「手机号」、「用户编号」,修改之后就再也没有产生这种问题了。


2 思维进化


1、前后端同时做接口参数校验


为了提升开发效率,我们人为的将系统分为前端、后端,分别由两拨不同的人员开发 ,经常出现系统问题时,两拨人都非常不服气,相互指责。



有的时候,笔者会觉得很搞笑,因为这个本质是个规约问题。


要想系统健壮,前后端应该同时做接口参数校验 ,当大家都遵循这个规约时,出现系统问题的风险大大减少。


2、复用和专用要做平衡


笔者写的这个接口 getUserByConditions ,支持四种不同参数的查询,但是因为代码不够严谨,导致系统出现 OOM 。


其实,在业务非常明确的场景,我们可以将复用接口,拆分成四个更细粒度的接口 :



  • 按照用户 ID 查询用户信息

  • 按照用户昵称查询用户信息

  • 按照手机号查询用户信息

  • 按照用户名查询用户信息


比如按照用户 ID 查询用户信息 , SQLMAP 就简化为:



通过这样的拆分,我们的接口设计更加细粒度,也更容易维护 , 同时也可以规避 where 1 =1 产生的问题。


有的同学会有疑问:假如拆分得太细,会不会增加我编写 接口和 SQLMap 的工作量 ?


笔者的思路是:通过代码生成器动态生成,是绝对可以做到的 ,只不过需要做一丢丢的定制。


3、编写代码时,需要考虑资源占用量,做好预防性编程


笔者刚入行的时候,只是机械性的完成任务,并没有思考代码后面的资源占用,以及有没有可能产生恶劣的影响。


随着见识更多的系统,学习开源项目,笔者慢慢培养了一种习惯:



  • 这段代码会占用多少系统资源

  • 如何规避风险 ,做好预防性编程。


其实,这和玩游戏差不多 ,在玩游戏的时,我们经常说一个词,那就是意识。



上图,后裔跟墨子在压对面马可蔡文姬,看到小地图中路铠跟小乔的视野,方向是往下路来的,这时候我们就得到了一个信息。


知道对面的人要来抓,或者是协防,这种情况我们只有两个人,其他的队友都不在,只能选择避战,强打只会损失两名“大将”。


通过小地图的信息,并且想出应对方法,就是叫做“猜测意识”。


编程也是一样的,我们思考代码可能产生的系统资源占用,以及可能存在的风险,并做好防御性编程,就是编程的意识


4 写到最后


当我们在使用 :Mybatis +「where 1 = 1 」编程模式时,需要如下三点:



  1. 前后端同时做好接口参数校验 ;

  2. 复用和专用要做平衡,条件允许情况下将复用 SQLMap 拆分成更细粒度的 SQLMap ;

  3. 编写代码时,需要考虑资源占用量,做好预防性编程 ;




文章片段推荐:



生命就是这样一个过程,一个不断超越自身局限的过程,这就是命运,任何人都是一样,在这过程中我们遭遇痛苦、超越局限、从而感受幸福。


所以一切人都是平等的,我们毫不特殊。


--- 史铁生





作者:勇哥Java实战
来源:juejin.cn/post/7375345204046266368
收起阅读 »

因为git不熟练,我被diss了

浅聊一下 在百度实习快一个月了,公司的需求从提需到上线也是走过一遍了,个人认为最基础的也是最重要的就是git了...为什么这么说?因为本人git不熟练挨diss了😭😭😭,虽然之前也会使用git将代码提交到github,但是会使用到也就几条指令,今天就来总结一下...
继续阅读 »

浅聊一下


在百度实习快一个月了,公司的需求从提需到上线也是走过一遍了,个人认为最基础的也是最重要的就是git了...为什么这么说?因为本人git不熟练挨diss了😭😭😭,虽然之前也会使用git将代码提交到github,但是会使用到也就几条指令,今天就来总结一下在公司使用git的常规操作,刚进厂的掘友可以参考一下...


git


什么是git?


Git 是一款免费、开源的分布式版本控制系统,用于敏捷高效地处理任何或小或大的项目。是的,我对git的介绍就一条,想看简介的可以去百度一下😘😘😘


为什么要用git?


OK,想象一下,我是一名作家,现在我要开始写一本小说了,我想要将我的小说每天都发布到“github小说网”上,一日两更。我想要一个工具,它要具备的功能如下:



  1. 将我每天写的小说章节发布

  2. 我发现昨天写的章节有问题,它可以帮我撤回

  3. 一周后,我又想找到上周我撤回的章节,它能帮我找到

  4. 我想写一个“漫威宇宙的系列”,我需要雇人和我一起写,它可以帮我们同步进度

  5. 我想要查看每个人写了什么,什么时候写的

  6. ...


想要的有点多了,我知道很难满足,但是git就能满足我的一切需求...


写小说


我进厂写小说了,厂长说:你先下一个git。那我必须得下一个git


下载git


直接跑到这个git官网http://www.git-scm.com/downloads ,可以搜个教程跟着安装,这里就不细说了


基本配置


把git下载下来了,那我不得登录一下,免得到时候小说写的有问题都不知道是谁写的,为了不背锅!


$ git config --global user.name 
$ git config --global user.email

参与写小说


来到“github小说网”,要将之前的章节全部拷贝到你的电脑上,才能开始续写


image.png


使用git clone 命令来完成


$ git clone https://github.com/vuejs/vue.git

这样就将代码克隆到你的本地了


小说版本


我们的小说每天都在迭代更新,master分支就是我们的主分支,也就是目前发布的最新的小说内容


image.png


每当我们向master提交代码,master都会向前移动一步。


想象一个场景,有十个人都在写同一本小说,那么十个人都同时向master提供代码,会发生什么事情?



  • 并行开发受限:没有分支意味着无法支持并行开发,因为每个人都只能基于master进行工作,这可能会导致团队成员之间的代码冲突。

  • 代码管理困难:由于所有更改都直接应用于master,代码管理会变得混乱,很难跟踪谁提交了哪些更改,以及何时进行了更改。

  • 风险高:由于没有分支,每次更改都直接影响master,这可能增加了引入错误或破坏现有功能的风险。

  • 难以撤销更改:没有分支意味着难以进行实验性更改或回滚到先前的版本,因为没有办法轻松地隔离或恢复更改。


所以我们每个人都需要创建自己的分支,最后再将自己的分支与master合并


当我们创建了新的分支,比如叫 myBranch ,git 就会新建一个指针叫 myBranch,指向 master 相同的提交,在把 HEAD 指向 myBranch,就表示当前分支在 myBranch 上。


image.png


从现在开始,对工作区的修改和提交都是针对 myBranch 分支了,如果我们修改后再提交一次,myBranch指针就会向前移动一步,而master指针不变,当我们将myBranch开发完毕以后,再将它与master合并



  • 查看当前分支


$ git branch


  • 创建分支


$ git checkout -b 分支名

git checkout 命令加上-b参数,表示创建分支并切换,它相当于下面的两个命令:


$ git branch dev        //创建分支
$ git checkout dev //切换到创建的分支

提交


在上面,我们已经创建好了一个分支myBranch,我们一天要写两章小说,当我每写完一章以后,我要将它先存入暂存区,当一天的工作完毕以后,统一将暂存区的代码提交到本地仓库,最后再上传到远程仓库,并且合并



  • 上传暂存区


$ git add .    //将修改的文件全部上传
$ git add xxx //将xxx文件上传


  • 提交到本地仓库


git commit -m '提交代码的描述'


  • 提交到远程仓库的对应分支


$ git push origin xxx    //xxx是对应分支名


  • 合并分支


$ git checkout master    //首先切换分支到master
$ git merge mybranch


  • 删除分支


当你合并完分支以后,mybranch分支就可以删除了


$ git branch -d mybranch

解决冲突


Git 合并分支产生冲突的原因通常是因为两个或多个分支上的相同部分有了不同的修改。这可能是因为以下几个原因:



  1. 并行开发:团队中的不同成员在不同的分支上同时开发功能或修复 bug。如果他们修改了相同的文件或代码行,就会导致合并冲突。

  2. 分支基于旧版本:当从一个旧的提交创建分支,然后在原始分支上进行了更改时,可能会导致冲突。这是因为在创建分支后,原始分支可能已经有了新的提交。

  3. 重命名或移动文件:如果一个分支重命名或移动了一个文件,而另一个分支对同一文件进行了修改,就会导致冲突。

  4. 合并冲突的解决方法不同:在合并分支时,有时会使用不同的合并策略或解决方法,这可能会导致冲突。

  5. 历史分叉:如果两个分支的历史分叉很远,可能会存在较大的差异,从而导致合并时出现冲突。


于是我们需要将冲突解决再重新合并分支,解决冲突也就是查看文件新增了哪些代码,你需要保留哪些代码,把不需要的删去就可以了...


我们还需养成一个好习惯,就是在开发之前先git pull 一下,更新一下自己本地的代码确保版本是最新的。


添砖加瓦


如果我已经使用git commit -m 'xxx'将代码提交到了本地仓库,但是我后续还想向这个提交中添加文件,那我该怎么办呢?



  1. 首先将你想添加到文件使用git add xxx加入暂存区

  2. 然后运行以下命令:


$ git commit --amend

这将会打开一个编辑器,让你编辑上一次提交的提交信息。如果你只是想要添加文件而不改变提交信息,你可以直接保存并关闭编辑器。



  1. Git 将会创建一个新的提交,其中包含之前的提交内容以及你刚刚添加的文件。


您撤回了一次push


代码推送到远程仓库的master上以后,我发现有bug,挨批是不可避免了,批完还得接着解决...



  1. 撤销最新的提交并保留更改


$ git reset HEAD^

这会将最新的提交从 master 分支中撤销,但会保留更改在工作目录中。你可以修改这些更改,然后重新提交。



  1. 撤销最新的提交并丢弃更改


$ git reset --hard HEAD^

这会完全撤销最新的提交,并丢弃相关的更改。慎用,因为这将永久丢失你的更改



  1. 创建新的修复提交


如果你不想删除最新的提交,而是创建一个新的提交来修复问题,可以进行如下操作:



  • 在 master 分支上创建一个新的分支来进行修复:


$ git checkout -b fix-branch master


  • 在新分支上进行修改,修复代码中的问题。

  • 提交并推送修复:


$ git add .
$ git commit -m "Fixing the issue"
$ git push origin fix-branch

结尾


当你学会以上操作的时候, 你就可以初步参加公司的代码开发了,从挨批中进步!!!


作者:滚去睡觉
来源:juejin.cn/post/7375928754147246107
收起阅读 »

Git 代码提交规范,feat、fix、chore 都是什么意思?

写在前面 经常看到别人提交的代码记录里面包含一些feat、fix、chore等等,而我在提交时也不会区分什么,直接写下提交信息,今天就来看一下怎么个事,就拿 element-plus/ant-design 来看一下。 其实这么写是一种代码提交规范,当然不是...
继续阅读 »

写在前面


经常看到别人提交的代码记录里面包含一些feat、fix、chore等等,而我在提交时也不会区分什么,直接写下提交信息,今天就来看一下怎么个事,就拿 element-plus/ant-design 来看一下。



image.png
其实这么写是一种代码提交规范,当然不是为了炫技,主要目的是为了提高提交记录的可读性和自动化处理能力。


当然如果团队没有要求,不这么写也可以。


git 提交规范


commit message = subject + :+ 空格 + message 主体


例如: feat:增加用户注册功能


常见的 subject 种类以及含义如下:



  1. feat: 新功能(feature)



    • 用于提交新功能。

    • 例如:feat: 增加用户注册功能



  2. fix: 修复 bug



    • 用于提交 bug 修复。

    • 例如:fix: 修复登录页面崩溃的问题



  3. docs: 文档变更



    • 用于提交仅文档相关的修改。

    • 例如:docs: 更新README文件



  4. style: 代码风格变动(不影响代码逻辑)



    • 用于提交仅格式化、标点符号、空白等不影响代码运行的变更。

    • 例如:style: 删除多余的空行



  5. refactor: 代码重构(既不是新增功能也不是修复bug的代码更改)



    • 用于提交代码重构。

    • 例如:refactor: 重构用户验证逻辑



  6. perf: 性能优化



    • 用于提交提升性能的代码修改。

    • 例如:perf: 优化图片加载速度



  7. test: 添加或修改测试



    • 用于提交测试相关的内容。

    • 例如:test: 增加用户模块的单元测试



  8. chore: 杂项(构建过程或辅助工具的变动)



    • 用于提交构建过程、辅助工具等相关的内容修改。

    • 例如:chore: 更新依赖库



  9. build: 构建系统或外部依赖项的变更



    • 用于提交影响构建系统的更改。

    • 例如:build: 升级webpack到版本5



  10. ci: 持续集成配置的变更



    • 用于提交CI配置文件和脚本的修改。

    • 例如:ci: 修改GitHub Actions配置文件



  11. revert: 回滚



    • 用于提交回滚之前的提交。

    • 例如:revert: 回滚feat: 增加用户注册功能




总结


使用规范的提交消息可以让项目更加模块化、易于维护和理解,同时也便于自动化工具(如发布工具或 Changelog 生成器)解析和处理提交记录。


通过编写符合规范的提交消息,可以让团队和协作者更好地理解项目的变更历史和版本控制,从而提高代码维护效率和质量。


作者:JacksonChen
来源:juejin.cn/post/7374295163625521161
收起阅读 »

请一定要使用常量和枚举

1.魔法值和硬编码 在代码编写的场景中,会遇到提示避免去使用 魔法值(magic numbers)和硬编码(hardcoding)。 魔法值就是在代码中直接使用的,没有提供任何注释或解释说明其用途和含义的常数值。 硬编码指的是在程序中直接使用特定的值或信息,...
继续阅读 »

1.魔法值和硬编码


在代码编写的场景中,会遇到提示避免去使用 魔法值(magic numbers)和硬编码(hardcoding)。



  • 魔法值就是在代码中直接使用的,没有提供任何注释或解释说明其用途和含义的常数值。

  • 硬编码指的是在程序中直接使用特定的值或信息,而不是通过变量、常量或其他可配置的方式来表示。这些值通常是字面量字符串、数字或其他原始数据类型,在代码中写死了,无法修改。


缺点:


不便于维护:如果需要修改值,必须手动在代码中查找并替换,会增加代码修改的复杂度和风险。


可读性差:硬编码的值缺乏描述和注释,不易于理解和解释。在工作中,协作开发,其他开发人员在阅读代码时可能无法理解这些值的含义和作用。


维护困难:当需要修改值的时候,需要在代码中找到所有使用该值的地方进行手动修改。这样容易出错,而且增加了代码维护的复杂性。


2.定义常量


场景:设π取小数点后五位数(即3.14159)计算圆的面积


Java常量定义是指在Java程序中定义一个不可修改的值,Java常量的定义使用关键字final,一般与static关键字一起使用。


此时可以通过定义一个常量作为π


public class MyClass {  
//圆周率π
public static final double PI = 3.14159;
}

上面这个定义在类中的常量称为 类常量,可以通过类名访问。


通过定义常量,就避免在代码中直接使用没有明确含义的硬编码数字。取而代之,将这些数字赋值给具有描述性名称的常量。


3.if - else if - else if - else if.....else


在项目中看过这面这段代码,通过判断天气给出建议


public void handleWeather(String weather) {  
if (weather.equals("晴天")) {
System.out.println("做好防晒");
} else if (weather.equals("阴天")) {
System.out.println("户外活动");
} else if (weather.equals("小雨")) {
System.out.println("带雨伞");
} else if (weather.equals("雷雨")) {
System.out.println("避免户外活动");
} else {
System.out.println("未知天气");
}
}

这段代码的判断条件 "晴天"、"阴天"、"小雨"等,这些条件在项目不止使用到了一次,比如在另外一个方法中也有一个判断,但是判断执行的方法体不同,如下


public void handleWeather(String weather) {  
if (weather.equals("晴天")) {
System.out.println("出太阳");
} else if (weather.equals("阴天")) {
System.out.println("有乌云");
}
....
}

现在如果需要 把 晴天 这个天气情况修改为 高温天,那么就需要修改两处地方,在实际项目中可能更多。


所以这里必须要定义枚举提高代码的可维护性


4.定义枚举


定义枚举类如下


public enum WeatherType {  
SUNNY("晴天"),
CLOUDY("阴天"),
LIGHT_RAIN("小雨"),
THUNDERSTORM("雷雨"),
UNKNOWN("未知天气");

private final String message;

WeatherType(String message) {
this.message = message;
}

public String getMessage() {
return message;
}
}

将代码用枚举结合switch case来替换


public void handleWeather(String weather) {  
WeatherType weatherType = WeatherType.valueOf(weather);
switch (weatherType) {
case SUNNY:
System.out.println("做好防晒");
break;
case CLOUDY:
System.out.println("户外活动");
break;
case LIGHT_RAIN:
System.out.println("带雨伞");
break;
case THUNDERSTORM:
System.out.println("避免户外活动");
break;
case UNKNOWN:
System.out.println("未知天气");
break;
}
}

5.结语


在日常工作中,会有很多状态类型的字段,比如淘宝订单,状态可以为:待付款、待发货、已发货、已签收、交易成功等,真实场景状态可能更多。


而状态也会被很多代码给使用到,所以必须通过集中统一的方式来定义。


通过常量、枚举,可以很好的解决问题,一旦状态有新增、修改、删除都只需要修改一处地方,其它代码直接引用就行。


作者:CoderMagic
来源:juejin.cn/post/7273875079657160743
收起阅读 »

运营:别再让你的页面一直loading 了

运营:别再让你的页面一直loading 了 第一轮 battle Q: 我想下载一个大文件,界面一直转圈,很耽误时间,我想在下载的时候还做点其他事情 A:一直转圈就一直等呗,反正还能摸会(奈何小姐姐太想做牛马了) 第二轮 battle Q: 不行,为什么别人...
继续阅读 »

运营:别再让你的页面一直loading 了


May-17-2024 15-36-38.gif


第一轮 battle


Q: 我想下载一个大文件,界面一直转圈,很耽误时间,我想在下载的时候还做点其他事情


A:一直转圈就一直等呗,反正还能摸会(奈何小姐姐太想做牛马了)


第二轮 battle


Q: 不行,为什么别人的浏览器,下载软件/文件 就能操作界面,你这就一直转圈,什么都做不了


A: 我们js 是单线程,一个时间只能做一件事,你不能在下载文件的时候,还操作界面吧...逐渐语无伦次,行,我给你试着优化优化..


image.png


最终效果


save.gif


无敌.gif


可以看到,下载文件 页面不再转圈,并且可以在界面操作,但是在点击操作1,2,到3的时候,会卡顿一下,下面会说为什么会卡这一下


开始分析



  1. 执行文件下载操作,把转圈逻辑去掉不就行了,


but: 是不转圈了,下载的时候,依然操作不了界面



  1. js 是一个单线程,一个时间只能做一件事,密集的cpu 计算,导致网站反应迟钝,就像卡了一样


resolve: 把下载文件这个耗时操作,放在其他线程操作,等到操作完毕,再通知主线程,执行完了。就像发布订阅模式一样,主线程不用执行密集的计算,也不用特意等密集计算的结果,执行完,告诉我就行了


技术使用 Web Workers


摘自 MDN developer.mozilla.org/zh-CN/docs/…


Web Worker 为 Web 内容在后台线程中运行脚本提供了一种简单的方法。线程可以执行任务而不干扰用户界面。此外,它们可以使用 XMLHttpRequest(尽管 responseXML 和 channel 属性总是为空)或 fetch(没有这些限制)执行 I/O。一旦创建,一个 worker 可以将消息发送到创建它的 JavaScript 代码,通过将消息发布到该代码指定的事件处理器(反之亦然)。


为什么要用它:worker 的一个优势在于能够执行处理器密集型的运算



不会阻塞 UI 线程


不会阻塞 UI 线程


不会阻塞 UI 线程


不会阻塞 UI 线程


重要的事情说三遍 🤣🤣🤣🤣🤣🤣🤣🤣🤣🤣🤣🤣🤣🤣🤣🤣🤣🤣🤣🤣🤣🤣


基本使用


主线程生成一个专用 worker


const myWorker = new Worker("worker.js"); // worker.js 是一个脚本的 URI 来执行 worker 线程

专用 worker 中消息的接收和发送


就俩主要方法 postMessage onmessage


引入脚本与库


Worker 线程能够访问一个全局函数 importScripts() 来引入脚本,该函数接受 0 个或者多个 URI 作为参数来引入资源;以下例子都是合法的:


importScripts(); /* 什么都不引入 */
importScripts("foo.js"); /* 只引入 "foo.js" */
importScripts("foo.js", "bar.js"); /* 引入两个脚本 */
importScripts("//example.com/hello.js"); /* 你可以从其他来源导入脚本 */

 ESModule 模式


const worker = new Worker('worker.js', 
{ type: 'module' // 指定 worker.js 的类型 }
);

文件下载代码



  • baseCode


import { writeFile, utils } from 'xlsx'
/**模拟生成大文件数据 */
const generateLargeFileData = () => {
const data = []
for (let i = 0; i < 10000; i++) {
data.push({
id: i + 1,
name: `User ${i + 1}`,
email: `user${i + 1}@example.com`,
age: Math.floor(Math.random() * 100) + 1
})
}
return data
}


  • 一只转圈的代码


/**下载大文件 */
const downloadExcel = async () => {
// 模拟生成大文件数据
const data = generateLargeFileData()
loading.value = true
// 模拟一段短暂的等待时间,确保状态更新
await delay(1000)
// 卡死的罪魁祸者
// 将数据转换为 Excel 格式
const ws = utils.json_to_sheet(data)
const wb = utils.book_new()
utils.book_append_sheet(wb, ws, 'Sheet1')
writeFile(wb, 'test.xlsx')
loading.value = false
}


  • 使用webworker,将耗时计算放到 webworker 线程,解决阻塞ui的问题


主线程



const myWorker = new Worker('downloadWorker.js')
myWorker.onmessage = (event) => {
let wb = event.data
// 这里也会占用主线程的ui渲染,所以会卡一下
writeFile(wb, 'test.xlsx')
ElMessage.success('下载任务已在后台运行,可以继续操作界面其他任务')
}

/**下载大文件 */
const downloadExcel = async () => {
const data = generateLargeFileData()
myWorker.postMessage(data)
}


worker 线程


image.png


// 非模块化文件, public 打包本身就是线上文件了
importScripts("./xlsx.js"); // 线上地址,或者本地地址

self.onmessage = (e) => {
// 将数据转换为 Excel 格式
const ws = XLSX.utils.json_to_sheet(e.data)
const wb = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(wb, ws, 'Sheet1')
// writeFile(wb, 'test.xlsx') // 这里会操作dom, 所以将操作dom放到 主线程做
self.postMessage(wb)
self.close()
}

细节补充



  1. 本文主要介绍了专用worker,其实还有 共享 worker【主要做多页面标签通信】, ServiceWorkers 【主要做网络拦截,可以看一下之前写的pwa文章【https://juejin.cn/post/7062681470116036616】,离线缓存就是使用ServiceWorkers】

  2. 在主线程中使用时,onmessage 和 postMessage() 必须挂在 worker 对象上,而在 worker 中使用时不用这样做。原因是,在 worker 内部,worker 是有效的全局作用域(就像window.xxx ,window 一般可以不写)

  3. worker的关闭


// main.js(主线程)
const myWorker = new Worker('/worker.js'); // 创建worker
myWorker.terminate(); // 关闭worker

// worker.js(worker线程) 
self.close(); // 直接执行close方法就ok了


  1. worker 错误监听 messageerror

  2. 关于主线程里的 new Worker('downloadWorker.js')


这个脚本,必须是本地/或者网络地址,这里写的是项目运行地址 匹配相应的worker。这里大家也会发现一个问题,就是这个worker是全局性的,放在public 是一个不错的选择,再者打包后,public 下本身也是会放在服务器上



  1. 用完worker, 要及时关闭,他是不会自己结束的。选择 在worker 关闭,或者主线程关闭,会有区别

  2. 其实小文件下载,用worker 有点画蛇添足,本身使用worker 也是一种消耗



详细的参考资料以及代码地址


MDN



MDN code仓库



可以下载下来直接调试,最好是起一个本地服务: http-server


image.png





代码地址


gitee.com/Big_Cat-AK-…





作者:赵小川
来源:juejin.cn/post/7369633749418934335
收起阅读 »

MybatisPlus 使用技巧与隐患

前言 MP 从出现就一直有争议 感觉一直 都存在两种声音 like: 很方便啊 通过函数自动拼接 Sql 不需要去 XML 再去使用标签 之前一分钟写好的 Sql 现在一秒钟就能写好 简直不要太方便 dislike: 侵入 Service 层 不好维护 可读性...
继续阅读 »

前言


MP 从出现就一直有争议 感觉一直 都存在两种声音


like:


很方便啊 通过函数自动拼接 Sql 不需要去 XML 再去使用标签 之前一分钟写好的 Sql 现在一秒钟就能写好 简直不要太方便


dislike:


侵入 Service 层 不好维护 可读性差 代码耦合 效率不行 sql 优化比较难


之前也有前辈说少用 MP 理由就是不好维护 但是这个东西真的是方便 只要不是强制不让用 就还是会去使用 存在集合里 最近也确实有一些体会 就从两个角度去看一下 MP


优点


操作简洁


就从我们编码中最常用的增删改查去说


按照我们之前去使用 Mybatis 的喜欢我们就要去建立一个 XML 文件 去编写 Sql 语句 算是半自动 我们可以直接去操控 Sql 语句 但是会比较麻烦 很多简单的数据查询我们都要去写一个标签 感觉这种没有意义的操作还是比较烦的 那么 MP 里面怎么实现


第一种: 最简单我们就是直接去使用提供的方法 我们非常简单就能做到这些操作 但是这个就有一个问题


nodeMapper.selectById(1);
nodeMapper.deleteById(2);
nodeMapper.updateById(new Node());
nodeMapper.insert(new Node());

维护性差 以查询为例 这个默认提供的方法都是查询所有字段我们都知道在编写 Sql 的时候第一条优化准则就是不要使用 Select * 因为这种写法是很 Low


这个就是上面selectById执行的结果


SELECT Id,name,pid FROM node WHERE Id=?

这种 Sql 肯定是不好的所以我们在使用 MP 的时候尽量不要去使用自带的快捷查询 我们可以去使用它里面的构造器


nodeMapper.selectOne(new QueryWrapper().eq("id",1).select("id"));

这汇总写法 我们可以通过后面的 select() 去指定我们需要查询的字段 算是解决上面那个问题吗 但是这个就完事了吗? 这还有一个问题


我们在开发中经常会说一个叫魔法值的东西


//这个就是魔法值 
if ("变成派大星".equals(node.getName())){
   System.out.println("魔法值");
}

之所以不要多用魔法值就是为了后期维护 我们建议使用枚举 或者建一个常量类 通过 Static final 修饰


上面那段代码是不是也有同样问题 "id"算不算魔法值呢 这种构造器产生的问题就是 不好维护


假设 我们的这Node类是高度使用的 我们到处都在写


nodeMapper.selectOne(new QueryWrapper().eq("id",1).select("id"));

刚开始没事 我们乐呵呵的 但是一旦我去修改 Id 的字段名怎么办



我修改成 test(数据库同步修改) 现在这个实体类中没有这个字段 我们再去看我们的代码



没有什么反应 没有给我提示报错 我这个时候去运行怎么办 我要一个个去找这个错误吗 这明显很费时间


这个确实是一个问题 但是也是可以解决的


Node node = nodeMapper.selectOne(new LambdaQueryWrapper().eq(Node::getId, 1).select(Node::getId));

上面这种代码就可以去解决这个问题 我们在使用的时候可以多用这个东西



一旦修改字段就会立马报错


但是 这就万事大吉了吗 NO No NO 我们要是处理稍微复杂的语句怎么办? 比如如我们字段求和 这个 LambdaQueryWrapper 还是存在限制的


如果我们想实现这种 怎么去做呢


select SUM(price_count) from  bla_order_data LIMIT 100

首先这种写法肯定是不太行的 编译不通过



除非去使用QueryWrapper



还有就是分页查询


// 条件查询
LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(UserInfo::getAge, 20);
// 分页对象
Page queryPage = new Page<>(page, limit);
// 分页查询
IPage iPage = userInfoMapper.selectPage(queryPage , queryWrapper);
// 数据总数
Long total = iPage.getTotal();
// 集合数据
List list = iPage.getRecords();

这个还是非常简单的


简单总结


MP 在做一些简单的单表查询可以去使用但是对于一些复杂的 SQl 操作还是不要用


1、SQL 侵入 Service 的问题我们可以仿照 Mybatis 建一个专门存放 MP 查询的包


2、关于维护性 我们可以尽量去使用 LambdaQueryWrapper 去构造


3、MP 是有内置的主键生成策略


4、内置分页插件:基于 Mybatis 物理分页,开发者无需关心具体操作,配置好插件之后,写分页等同于普通 List 查询。


缺点


我就说一个最大的缺点就是对于复杂 Sql 的操作性很不舒服 比如我们去多表查询 你怎么去写呢


看一个例子




就是通过


@Select 注解

Mp的查询条件嵌入进去
${ew.customSqlSegment}


咱就是一整个大问号 联表老老实实去写 XML 吧 这种真的不要去用 太丑了


总结


没有过多的东西 基本都是最近看到的东西


1、复杂语句不推荐使用 MP 能用最好也别用 可读性差 难维护 使用刚开始没感觉 后期业务扩充 真的恶心的


2、可以使用 MP 中的分页 比较舒服 逐渐生成策略也舒服


3、尽量不要去使用 MP 中自带的selectById 等全表查询的方法


4、尽量使用LambdaQueryWrapper的书写形式 至少比较好维护


5、简单重复 Sql 可以用 MP。复杂 SQL 不要用




作者:臻大虾
来源:juejin.cn/post/7265624177774854204
收起阅读 »

入职第一天,看了公司代码,牛马沉默了

入职第一天就干活的,就问还有谁,搬来一台N手电脑,第一分钟开机,第二分钟派活,第三分钟干活,巴适。。。。。。打开代码发现问题不断读取配置文件居然读取两个配置文件,一个读一点,不清楚为什么不能一个配置文件进行配置 一边获取WEB-INF下的配置文件,一...
继续阅读 »

入职第一天就干活的,就问还有谁,搬来一台N手电脑,第一分钟开机,第二分钟派活,第三分钟干活,巴适。。。。。。

4f7ca8c685324356868f65dd8862f101~tplv-k3u1fbpfcp-jj-mark_3024_0_0_0_q75.jpg

打开代码发现问题不断

  1. 读取配置文件居然读取两个配置文件,一个读一点,不清楚为什么不能一个配置文件进行配置

image.png

image.png

image.png 一边获取WEB-INF下的配置文件,一边用外部配置文件进行覆盖,有人可能会问既然覆盖,那可以全在外部配置啊,问的好,如果全用外部配置,咱们代码获取属性有的加上了项目前缀(上面的两个put),有的没加,这样配置文件就显得很乱不可取,所以形成了分开配置的局面,如果接受混乱,就写在外部配置;不能全写在内部配置,因为

prop_c.setProperty(key, value);

value获取外部配置为空的时候会抛出异常;properties底层集合用的是hashTable

public synchronized V put(K key, V value) {
// Make sure the value is not null
if (value == null) {
throw new NullPointerException();
}
}
  1. 很多参数写死在代码里,如果有改动,工作量会变得异常庞大,举例权限方面伪代码
role.haveRole("ADMIN_USE")
  1. 日志打印居然sout和log混合双打

image.png

image.png

先不说双打的事,对于上图这个,应该输出包括堆栈信息,不然定位问题很麻烦,有人可能会说e.getMessage()最好,可是生产问题看多了发现还是打堆栈好;还有如果不是定向返回信息,仅仅是记录日志,完全没必要catch多个异常,一个Exception足够了,不知道原作者这么写的意思是啥;还是就是打印日志要用logger,用sout打印在控制台,那我日志文件干啥;

4.提交的代码没有技术经理把关,下发生产包是个人就可以发导致生产环境代码和本地代码或者数据库数据出现不一致的现象,数据库数据的同步是生产最容易忘记执行的一个事情;比如我的这家公司上传文件模板变化了,但是没同步,导致出问题时开发环境复现问题真是麻烦;

5.随意更改生产数据库,出不出问题全靠开发的职业素养;

6.Maven依赖的问题,Maven引pom,而pom里面却是另一个pom文件,没有生成的jar供引入,是的,我们可以在dependency里加上

<type>pom

来解决这个问题,但是公司内的,而且实际也是引入这个pom里面的jar的,我实在不知道这么做的用意是什么,有谁知道;求教 a972880380654b389246a3179add2cca~tplv-k3u1fbpfcp-jj-mark_3024_0_0_0_q75.jpg

以上这些都是我最近一家公司出现的问题,除了默默接受还能怎么办;

那有什么优点呢:

  1. 不用太怎么写文档
  2. 束缚很小
  3. 学到了js的全局调用怎么写的(下一篇我来写,顺便巩固一下)

解决之道

怎么解决这些问题呢,首先对于现有的新项目或升级的项目来说,spring的application.xml/yml 完全可以写我们的配置,开发环境没必要整外部文件,如果是生产环境我们可以在脚本或启动命令添加 nohup java -Dfile.encoding=UTF-8 -Dspring.config.location=server/src/main/config/application.properties -jar xxx.jar & 来告诉jar包引哪里的配置文件;也可以加上动态配置,都很棒的,

其次就是规范代码,养成良好的规范,跟着节奏,不要另辟蹊径;老老实实的,如果原项目上迭代,不要动源代码,追加即可,没有时间去重构的;

我也曾是个快乐的童鞋,也有过崇高的理想,直到我面前堆了一座座山,脚下多了一道道坑,我。。。。。。!


作者:小红帽的大灰狼
来源:juejin.cn/post/7371986999164928010
收起阅读 »

用了这么久SpringBoot却还不知道的一个小技巧

前言 你可能调第三方接口喜欢启动application,修改,再启动,再修改,顺便还有个不喜欢写JUnitTest的习惯。 你可能有一天想要在SpringBoot启动后,立马想要干一些事情,现在没有可能是你还没遇到。 那么SpringBoot本身提供...
继续阅读 »

前言



你可能调第三方接口喜欢启动application,修改,再启动,再修改,顺便还有个不喜欢写JUnitTest的习惯。




你可能有一天想要在SpringBoot启动后,立马想要干一些事情,现在没有可能是你还没遇到。




那么SpringBoot本身提供了一个小技巧,很多人估计没用过。



正文


1、效果



废话不多说,先写个service和controller展示个效果最实在。




来个简单的service



@Service
public class TestService {

public String test() {

System.err.println("Hello,Java Body ~");
return "Hello,Java Body ~";
}
}


再来个简单的controller



@RestController
@RequestMapping("/api")
@AllArgsConstructor
public class TestController {

private final TestService testService;

@GetMapping("/test")
public ResponseEntity test() {
return ResponseEntity.ok().body(testService.test());
}
}


接下来是不是以为要启动调接口了,No,在SpringBoot的启动类中加这么个玩意儿



@SpringBootApplication
public class JavaAboutApplication {

public static void main(String[] args) {
SpringApplication.run(JavaAboutApplication.class, args);
}

@Bean
CommandLineRunner lookupTestService(TestService testService) {
return args -> {

// 1、test接口
testService.test();

};
}

}


启动看下效果



4.png



可以发现,SpringBoot启动后,自动加载了service的执行程序。




这个小案例是想说明什么呢,其实就是CommandLineRunner这么个东西。



2、它是什么



CommandLineRunner是一个接口,用于在Spring Boot应用程序启动后执行一些特定的任务或代码块。当应用程序启动完成后,Spring Boot会查找并执行实现了CommandLineRunner接口的Bean。




说白了,就是SpringBoot启动后,我立马想干的事,都可以往里写。



3、我用它做过什么



我的话,和很多厂家对接过接口,在前期不会直接开始写业务,而是先调通接口,再接入业务中。




比如webservice这种,我曾经使用CommandLineRunner直接调对方接口来测试,还挺舒适,也节省了IDEA资源,但要注意调试完成后注释掉,本地测试的时候再打开就行。



5.png


4、它还有哪些用途



除了可以拿来调试第三方接口,它还有什么用途吗?




其实开头已经说过,它就是SpringBoot启动后,你立马想干的事,都可以在里面写,所以你完全可以发挥想象去用。




我这里,提供几个思路作为参考。



1)、数据库初始化


你可以使用CommandLineRunner来执行应用程序启动时的数据库初始化操作,例如创建表格、插入初始数据等。



2)、缓存预热


CommandLineRunner在应用程序启动后预热缓存,加载常用的数据到缓存中,提高应用程序的响应速度。



3)、加载外部资源


加载一些外部资源,例如配置文件、静态文件或其他资源。CommandLineRunner可以帮助你在启动时读取这些资源并进行相应的处理。



4)、任务初始化


使用CommandLineRunner来初始化和配置某些定时任务,确保它们在应用程序启动后立即开始运行。



5)、日志记录


SpringBoot启动后记录一些必要的日志信息,如应用程序版本、环境配置、甚至启动时间等等,这个看具体需求。



6)、组件初始化


你可能需要按照特定的顺序初始化一些组件,CommandLineRunner可以帮助你控制初始化顺序,只需要将它们添加到不同的CommandLineRunner实现类中,并使用@Order注解指定它们的执行顺序即可。



总结



其实,能用的地方挺多,我最后再举个例子,netty启动时,往往是绑定了端口并以同步形式启动。




但如果要和SpringBoot整合,我们不可能还那么做,而是交给SpringBoot来控制netty的启动和关闭,当SpringBoot启动后,netty启动,当SpringBoot关闭时,netty自然也关闭了,这样才比较优雅。




那么,我们完全可以将netty的启动执行程序放到CommandLineRunner中,这样就可以达到目的了。




没用过的xdm,今天学会一个新知识点了不,可以自己下去试试哦。


作者:程序员济癫
来源:juejin.cn/post/7273434389404893239
收起阅读 »

如何优雅的将MultipartFile和File互转

我们在开发过程中经常需要接收前端传来的文件,通常需要处理MultipartFile格式的文件。今天来介绍一下MultipartFile和File怎么进行优雅的互转。 前言 首先来区别一下MultipartFile和File: MultipartFile是 S...
继续阅读 »

我们在开发过程中经常需要接收前端传来的文件,通常需要处理MultipartFile格式的文件。今天来介绍一下MultipartFile和File怎么进行优雅的互转。


前言


首先来区别一下MultipartFile和File:



  • MultipartFile是 Spring 框架的一部分,File是 Java 标准库的一部分。

  • MultipartFile主要用于接收上传的文件,File主要用于操作系统文件。


MultipartFile转换为File


使用 transferTo


这是一种最简单的方法,使用MultipartFile自带的transferTo 方法将MultipartFile转换为File,这里通过上传表单文件,将MultipartFile转换为File格式,然后输出到特定的路径,具体写法如下。


transferto.png


使用 FileOutputStream


这是最常用的一种方法,使用 FileOutputStream 可以将字节写入文件。具体写法如下。


FileOutputStream.png


使用 Java NIO


Java NIO 提供了文件复制的方法。具体写法如下。


copy.png


File装换为MultipartFile


从File转换为MultipartFile 通常在测试或模拟场景中使用,生产环境一般不这么用,这里只介绍一种最常用的方法。


使用 MockMultipartFile


在转换之前先确保引入了spring-test 依赖(以Maven举例)


<dependency>
<groupId>org.springframeworkgroupId>
<artifactId>spring-testartifactId>
<version>versionversion>
<scope>testscope>
dependency>

通过获得File文件的名称、mime类型以及内容将其转换为MultipartFile格式。具体写法如下。


multi.png


作者:程序员老J
来源:juejin.cn/post/7295559402475667492
收起阅读 »

面试官问我String能存储多少个字符?

首先String的length方法返回是int。所以理论上长度一定不会超过int的最大值。 编译器源码如下,限制了字符串长度大于等于65535就会编译不通过 private void checkStringConstant(DiagnosticPosition...
继续阅读 »

  1. 首先String的length方法返回是int。所以理论上长度一定不会超过int的最大值。

  2. 编译器源码如下,限制了字符串长度大于等于65535就会编译不通过


    private void checkStringConstant(DiagnosticPosition var1, Object var2) {
    if (this.nerrs == 0 && var2 != null && var2 instanceof String && ((String)var2).length() >= 65535) {
    this.log.error(var1, "limit.string", new Object[0]);
    ++this.nerrs;
    }
    }

    Java中的字符常量都是使用UTF8编码的,UTF8编码使用1~4个字节来表示具体的Unicode字符。所以有的字符占用一个字节,而我们平时所用的大部分中文都需要3个字节来存储。


    //65534个字母,编译通过
    String s1 = "dd..d";

    //21845个中文”自“,编译通过
    String s2 = "自自...自";

    //一个英文字母d加上21845个中文”自“,编译失败
    String s3 = "d自自...自";

    对于s1,一个字母d的UTF8编码占用一个字节,65534字母占用65534个字节,长度是65534,长度和存储都没超过限制,所以可以编译通过。


    对于s2,一个中文占用3个字节,21845个正好占用65535个字节,而且字符串长度是21845,长度和存储也都没超过限制,所以可以编译通过。


    对于s3,一个英文字母d加上21845个中文”自“占用65536个字节,超过了存储最大限制,编译失败。


  3. JVM规范对常量池有所限制。量池中的每一种数据项都有自己的类型。Java中的UTF-8编码的Unicode字符串在常量池中以CONSTANTUtf8类型表示。CONSTANTUtf8的数据结构如下:


    CONSTANT_Utf8_info {
    u1 tag;
    u2 length;
    u1 bytes[length];
    }

    我们重点关注下长度为 length 的那个bytes数组,这个数组就是真正存储常量数据的地方,而 length 就是数组可以存储的最大字节数。length 的类型是u2,u2是无符号的16位整数,因此理论上允许的的最大长度是2^16-1=65535。所以上面byte数组的最大长度可以是65535


  4. 运行时限制


    String 运行时的限制主要体现在 String 的构造函数上。下面是 String 的一个构造函数:


    public String(char value[], int offset, int count) {
    ...
    }

    上面的count值就是字符串的最大长度。在Java中,int的最大长度是2^31-1。所以在运行时,String 的最大长度是2^31-1。


    但是这个也是理论上的长度,实际的长度还要看你JVM的内存。我们来看下,最大的字符串会占用多大的内存。


    (2^31-1)*16/8/1024/1024/1024 = 2GB

    所以在最坏的情况下,一个最大的字符串要占用 2GB的内存。如果你的虚拟机不能分配这么多内存的话,会直接报错的。





补充 JDK9以后对String的存储进行了优化。底层不再使用char数组存储字符串,而是使用byte数组。对于LATIN1字符的字符串可以节省一倍的内存空间。


作者:念念清晰
来源:juejin.cn/post/7343883765540831283
收起阅读 »

面试官:为什么忘记密码要重置,而不是告诉我原密码?

Hello,大家好,我是 Sunday。 最近有个同学在面试中遇到了一个很有意思的问题,我相信大多数的同学可能都没有遇到过。 面试官提问说:“为什么很多网站忘记密码需要重置,而不是直接告诉用户原密码?” 很有意思的问题对不对。很多网站中都有“忘记密码”的功能,...
继续阅读 »

Hello,大家好,我是 Sunday。


最近有个同学在面试中遇到了一个很有意思的问题,我相信大多数的同学可能都没有遇到过。


面试官提问说:“为什么很多网站忘记密码需要重置,而不是直接告诉用户原密码?


很有意思的问题对不对。很多网站中都有“忘记密码”的功能,但是为什么当我们点击忘记密码,经过一堆验证之后,网站会让我们重置密码,而不是直接告诉我们原密码呢?


所以,今天咱们就来说一说这个问题。


防止信息泄露



2022年11月1日,Termly 更新了《98个最大的数据泄露、黑客和曝光事件》(98 Biggest Data Breaches, Hacks, and Exposures)。其中包括很多知名网站,比如:Twitter



所以,你保存在网站中的数据可能并没有那么安全。那么这样的数据泄露后会对用户产生什么影响呢?


对大多数人来说最相关的经历(网上看到的)应该是诈骗电话,他们甚至可以很清楚的告诉你你的所有个人信息。那么这些信息是怎么来的呢?


有些同学可能说是因为“网站贩卖了我的个人信息”,其实不是的。相信我 大多数的网站不会做这样的事情


出现这样事情的原因,大部分都是由于数据泄露,导致你所有的个人信息都被别人知道了。


那么,同理。既然他们可以获取到你的私人信息,那么你的账户和密码信息是不是也有可能被盗取?


而对于大多数的同学来说,为了防止密码太多忘记,所以很多时候 大家都会使用统一的密码! 也就是说你的多个账号可能都是同一个密码。所以,一旦密码泄露,那么可能会影响到你的多个账号,甚至是银彳亍卡账号。


因此,对于网站(特别是一些大网站)来说,保护用户数据安全就是至关重要的一件事情。那么他们一般会怎么做呢?


通常的处理方式就是 加密。并且这种加密可能会在多个不同的阶段进行多次。比如常见的:SHA256、加盐、md5、RSA 等等


这样看起来好像是很安全的,但是还有一个问题,开发人员知道如何解密他们。或者有些同学会认为 数据库中依然存在着正确的密码 呀?一旦出现信息泄露,不是依然会有密码泄露的问题吗?


是的,所以为了解决这个问题,网站本身也不知道你的密码是什么。


网站本身也不知道你的密码是什么


对于网站(或者其他应用)来说,它们是 不应该 存储你的原密码的。而是通过一些系列的操作来保存你加密之后的代码。并且这个加密是在前端传输到服务端时就已经进行了,并且是 不可逆 的加密操作,例如:MD5 + 加盐



我们举一个简单的例子:


比如有个用户的密码是 123456,通过 md5 加密之后是:E10ADC3949BA59ABBE56E057F20F883E


md5 理论上是不可逆的,所以从理论上来说这个加密后的代码是不可解析的。但是 md5 有个比较严重的问题就是:同样的字符串加密之后会得到同样的结果


这也就意味着:E10ADC3949BA59ABBE56E057F20F883E 代表的永远都会是 123456


所以,如果有一个很大的 md5 密码库,那么理论上就可以解析出所有的 md5 加密后的字符串。就像下图一样:




因此,在原有的 md5 加密之上,很多网站又增加了 加盐 的操作。所谓加盐指的就是:在原密码的基础上增加一些字符串,然后进行 md5 加密


比如:



  1. 原密码为 123456

  2. 在这个密码基础上增加固定字符“LGD_Sunday!”

  3. 得到的结果就是:“LGD_Sunday!123456”

  4. 然后用该字符进行 md5 加密,结果是:E1FC8CB7B54BED0FDC8711530236BA4D

  5. 此时尝试解密,会发现 解密失败



这样大家是否就可以理解,为什么很多网站在让我们输入密码的时候 ,要求包含 大小写+符号+ 字母 + 数字 了吧。本质上就是为了防止被轻松解密。


而服务端拿到的就是 “E1FC8CB7B54BED0FDC8711530236BA4D” 这样的一个加密后的结果。然后服务端再次对密码进行加密操作,从而得到的是一个 被多次加密 的数据,保存到服务端。


所以说:网站无法告知你密码,因为它也不知道原密码是什么。


目前很多网站或应用为了保证用户安全,都已经采取 扫码登录、验证码登录 等方式进行登录验证,这种无密码的方式,会更大程度的保证你的账号安全。


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

Mybatis-Plus的insert执行之后,id是怎么获取的?

在日常开发中,会经常使用Mybatis-Plus 当简单的插入一条记录时,使用mapper的insert是比较简洁的写法 @Data public class NoEo { Long id; String no; } NoEo noEo = ...
继续阅读 »

在日常开发中,会经常使用Mybatis-Plus


当简单的插入一条记录时,使用mapper的insert是比较简洁的写法


@Data
public class NoEo {
Long id;
String no;
}

NoEo noEo = new NoEo();
noEo.setNo("321");
noMapper.insert(noEo);
System.out.println(noEo);

这里可以注意到一个细节,就是不管我们使用的是什么类型的id,好像都不需要去setId,也能执行insert语句


不仅不需要setId,在insert语句执行完毕之后,我们还能通过实体类获取到这条insert的记录的id是什么


image.png


image.png


这背后的原理是什么呢?


自增类型ID


刚学Java的时候,插入了一条记录还要再select一次来获取这条记录的id,比较青涩


后面误打误撞才发现可以直接从insert的实体类中拿到这个id


难道框架是自己帮我查了一次嘛


先来看看自增id的情况


首先要先把yml中的mp的id类型设置为auto


mybatis-plus:
global-config:
db-config:
id-type: auto

然后从insert语句开始一直往下跟进


noMapper.insert(noEo);

后面会来到这个方法


// com.baomidou.mybatisplus.core.executor.MybatisSimpleExecutor#doUpdate
@Override
public int doUpdate(MappedStatement ms, Object parameter) throws SQLException {
Statement stmt = null;
try {
Configuration configuration = ms.getConfiguration();
StatementHandler handler = configuration.newStatementHandler(this, ms, parameter, RowBounds.DEFAULT, null, null);
stmt = prepareStatement(handler, ms.getStatementLog(), false);
return stmt == null ? 0 : handler.update(stmt);
} finally {
closeStatement(stmt);
}
}

在执行了下面这个方法之后


handler.update(stmt)

实体类的id就赋值上了


继续往下跟


// org.apache.ibatis.executor.statement.PreparedStatementHandler#update
@Override
public int update(Statement statement) throws SQLException {
PreparedStatement ps = (PreparedStatement) statement;
ps.execute();
int rows = ps.getUpdateCount();
Object parameterObject = boundSql.getParameterObject();
KeyGenerator keyGenerator = mappedStatement.getKeyGenerator();
keyGenerator.processAfter(executor, mappedStatement, ps, parameterObject);
return rows;
}

image.png


最后的赋值在这一行


keyGenerator.processAfter

可以看到会有一个KeyGenerator做一个后置增强,它具体的实现类是Jdbc3KeyGenerator


// org.apache.ibatis.executor.keygen.Jdbc3KeyGenerator#processAfter
@Override
public void processAfter(Executor executor, MappedStatement ms, Statement stmt, Object parameter) {
processBatch(ms, stmt, parameter);
}

// org.apache.ibatis.executor.keygen.Jdbc3KeyGenerator#processBatch
public void processBatch(MappedStatement ms, Statement stmt, Object parameter) {
final String[] keyProperties = ms.getKeyProperties();
if (keyProperties == null || keyProperties.length == 0) {
return;
}
try (ResultSet rs = stmt.getGeneratedKeys()) {
final ResultSetMetaData rsmd = rs.getMetaData();
final Configuration configuration = ms.getConfiguration();
if (rsmd.getColumnCount() < keyProperties.length) {
// Error?
} else {
assignKeys(configuration, rs, rsmd, keyProperties, parameter);
}
} catch (Exception e) {
throw new ExecutorException("Error getting generated key or setting result to parameter object. Cause: " + e, e);
}
}

// org.apache.ibatis.executor.keygen.Jdbc3KeyGenerator#assignKeys
private void assignKeys(Configuration configuration, ResultSet rs, ResultSetMetaData rsmd, String[] keyProperties,
Object parameter)
throws SQLException {
if (parameter instanceof ParamMap || parameter instanceof StrictMap) {
// Multi-param or single param with @Param
assignKeysToParamMap(configuration, rs, rsmd, keyProperties, (Map<String, ?>) parameter);
} else if (parameter instanceof ArrayList && !((ArrayList<?>) parameter).isEmpty()
&& ((ArrayList<?>) parameter).get(0) instanceof ParamMap) {
// Multi-param or single param with @Param in batch operation
assignKeysToParamMapList(configuration, rs, rsmd, keyProperties, (ArrayList<ParamMap<?>>) parameter);
} else {
// Single param without @Param
// 当前case会走这里
assignKeysToParam(configuration, rs, rsmd, keyProperties, parameter);
}
}

// org.apache.ibatis.executor.keygen.Jdbc3KeyGenerator#assignKeysToParam
private void assignKeysToParam(Configuration configuration, ResultSet rs, ResultSetMetaData rsmd,
String[] keyProperties, Object parameter)
throws SQLException {
Collection<?> params = collectionize(parameter);
if (params.isEmpty()) {
return;
}
List<KeyAssigner> assignerList = new ArrayList<>();
for (int i = 0; i < keyProperties.length; i++) {
assignerList.add(new KeyAssigner(configuration, rsmd, i + 1, null, keyProperties[i]));
}
Iterator<?> iterator = params.iterator();
while (rs.next()) {
if (!iterator.hasNext()) {
throw new ExecutorException(String.format(MSG_TOO_MANY_KEYS, params.size()));
}
Object param = iterator.next();
assignerList.forEach(x -> x.assign(rs, param));
}
}

// org.apache.ibatis.executor.keygen.Jdbc3KeyGenerator.KeyAssigner#assign
protected void assign(ResultSet rs, Object param) {
if (paramName != null) {
// If paramName is set, param is ParamMap
param = ((ParamMap<?>) param).get(paramName);
}
MetaObject metaParam = configuration.newMetaObject(param);
try {
if (typeHandler == null) {
if (metaParam.hasSetter(propertyName)) {
// 获取主键的类型
Class<?> propertyType = metaParam.getSetterType(propertyName);
// 获取主键类型处理器
typeHandler = typeHandlerRegistry.getTypeHandler(propertyType,
JdbcType.forCode(rsmd.getColumnType(columnPosition)));
} else {
throw new ExecutorException("No setter found for the keyProperty '" + propertyName + "' in '"
+ metaParam.getOriginalObject().getClass().getName() + "'.");
}
}
if (typeHandler == null) {
// Error?
} else {
// 获取主键的值
Object value = typeHandler.getResult(rs, columnPosition);
// 设置主键值
metaParam.setValue(propertyName, value);
}
} catch (SQLException e) {
throw new ExecutorException("Error getting generated key or setting result to parameter object. Cause: " + e,
e);
}
}

// com.mysql.cj.jdbc.result.ResultSetImpl#getObject(int, java.lang.Class<T>)
@Override
public <T> T getObject(int columnIndex, Class<T> type) throws SQLException {
// ...
else if (type.equals(Long.class) || type.equals(Long.TYPE)) {
checkRowPos();
checkColumnBounds(columnIndex);
return (T) this.thisRow.getValue(columnIndex - 1, this.longValueFactory);

}
// ...
}

image.png


最后可以看到这个自增id是在ResultSet的thisRow里面


然后后面的流程就是去解析这个字节数据获取这个long的id


就不往下赘述了


雪花算法ID


yml切换回雪花算法


mybatis-plus:
global-config:
db-config:
id-type: assign_id

在使用雪花算法的时候,也是会走到这个方法


// com.baomidou.mybatisplus.core.executor.MybatisSimpleExecutor#doUpdate
@Override
public int doUpdate(MappedStatement ms, Object parameter) throws SQLException {
Statement stmt = null;
try {
Configuration configuration = ms.getConfiguration();
StatementHandler handler = configuration.newStatementHandler(this, ms, parameter, RowBounds.DEFAULT, null, null);
stmt = prepareStatement(handler, ms.getStatementLog(), false);
return stmt == null ? 0 : handler.update(stmt);
} finally {
closeStatement(stmt);
}
}

但是不同的是,执行完这一行之后,实体类的id字段就已经赋值上了


StatementHandler handler = configuration.newStatementHandler(this, ms, parameter, RowBounds.DEFAULT, null, null);

image.png


继续往下跟进


// org.apache.ibatis.session.Configuration#newStatementHandler
public StatementHandler newStatementHandler(Executor executor, MappedStatement mappedStatement, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) {
StatementHandler statementHandler = new RoutingStatementHandler(executor, mappedStatement, parameterObject, rowBounds, resultHandler, boundSql);
statementHandler = (StatementHandler) interceptorChain.pluginAll(statementHandler);
return statementHandler;
}

// org.apache.ibatis.executor.statement.RoutingStatementHandler#RoutingStatementHandler
public RoutingStatementHandler(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) {

switch (ms.getStatementType()) {
// ...
case PREPARED:
delegate = new PreparedStatementHandler(executor, ms, parameter, rowBounds, resultHandler, boundSql);
break;
// ...
}

}

最后跟进到一个构造器,会有一个processParameter的方法


// com.baomidou.mybatisplus.core.MybatisParameterHandler#MybatisParameterHandler
public MybatisParameterHandler(MappedStatement mappedStatement, Object parameter, BoundSql boundSql) {
this.typeHandlerRegistry = mappedStatement.getConfiguration().getTypeHandlerRegistry();
this.mappedStatement = mappedStatement;
this.boundSql = boundSql;
this.configuration = mappedStatement.getConfiguration();
this.sqlCommandType = mappedStatement.getSqlCommandType();
this.parameterObject = processParameter(parameter);
}

在这个方法里面会去增强参数


// com.baomidou.mybatisplus.core.MybatisParameterHandler#processParameter
public Object processParameter(Object parameter) {
/* 只处理插入或更新操作 */
if (parameter != null
&& (SqlCommandType.INSERT == this.sqlCommandType || SqlCommandType.UPDATE == this.sqlCommandType)) {
//检查 parameterObject
if (ReflectionKit.isPrimitiveOrWrapper(parameter.getClass())
|| parameter.getClass() == String.class) {
return parameter;
}
Collection<Object> parameters = getParameters(parameter);
if (null != parameters) {
parameters.forEach(this::process);
} else {
process(parameter);
}
}
return parameter;
}

// com.baomidou.mybatisplus.core.MybatisParameterHandler#process
private void process(Object parameter) {
if (parameter != null) {
TableInfo tableInfo = null;
Object entity = parameter;
if (parameter instanceof Map) {
Map<?, ?> map = (Map<?, ?>) parameter;
if (map.containsKey(Constants.ENTITY)) {
Object et = map.get(Constants.ENTITY);
if (et != null) {
entity = et;
tableInfo = TableInfoHelper.getTableInfo(entity.getClass());
}
}
} else {
tableInfo = TableInfoHelper.getTableInfo(parameter.getClass());
}
if (tableInfo != null) {
//到这里就应该转换到实体参数对象了,因为填充和ID处理都是争对实体对象处理的,不用传递原参数对象下去.
MetaObject metaObject = this.configuration.newMetaObject(entity);
if (SqlCommandType.INSERT == this.sqlCommandType) {
populateKeys(tableInfo, metaObject, entity);
insertFill(metaObject, tableInfo);
} else {
updateFill(metaObject, tableInfo);
}
}
}
}

最终生成id并赋值的操作是在populateKeys中


// com.baomidou.mybatisplus.core.MybatisParameterHandler#populateKeys
protected void populateKeys(TableInfo tableInfo, MetaObject metaObject, Object entity) {
final IdType idType = tableInfo.getIdType();
final String keyProperty = tableInfo.getKeyProperty();
if (StringUtils.isNotBlank(keyProperty) && null != idType && idType.getKey() >= 3) {
final IdentifierGenerator identifierGenerator = GlobalConfigUtils.getGlobalConfig(this.configuration).getIdentifierGenerator();
Object idValue = metaObject.getValue(keyProperty);
if (StringUtils.checkValNull(idValue)) {
if (idType.getKey() == IdType.ASSIGN_ID.getKey()) {
if (Number.class.isAssignableFrom(tableInfo.getKeyType())) {
metaObject.setValue(keyProperty, identifierGenerator.nextId(entity));
} else {
metaObject.setValue(keyProperty, identifierGenerator.nextId(entity).toString());
}
} else if (idType.getKey() == IdType.ASSIGN_UUID.getKey()) {
metaObject.setValue(keyProperty, identifierGenerator.nextUUID(entity));
}
}
}
}

在tableInfo中可以得知Id的类型


如果是雪花算法类型,那么生成雪花id;UUID同理


image.png


总结


insert之后,id被赋值到实体类的时机要根据具体情况具体讨论:


如果是自增类型的id,那么要在插入数据库完成之后,在ResultSet的ByteArrayRow中获取到这个id


如果是雪花算法id,那么在在插入数据库之前,会通过参数增强的方式,提前生成一个雪花id,然后赋值给实体类


作者:我爱果汁
来源:juejin.cn/post/7319541656399102002
收起阅读 »

当程序员写代码就行了,为什么还要画图

相信很多人选择当程序员除了这个行业起步阶段薪资比其他行业高一些之外,还有一个很大的因素是觉得做研发类的工作只要代码写的好,跟电脑这个“直男”打交道就可以了。 但是还没走出校门呢,毕业这一关就得边做毕业设计边写毕业论文。写毕业论文可不是直接把自己的实现代码从ID...
继续阅读 »

相信很多人选择当程序员除了这个行业起步阶段薪资比其他行业高一些之外,还有一个很大的因素是觉得做研发类的工作只要代码写的好,跟电脑这个“直男”打交道就可以了。


但是还没走出校门呢,毕业这一关就得边做毕业设计边写毕业论文。写毕业论文可不是直接把自己的实现代码从IDE粘贴到Word里,但凡代码多一点就会被老师要求修改,老师会告诉你要把你做的毕业设计的功能、设计思路、关键部分的实现细节用绘图结合文字表达清楚。


这时很多同学就犯难了,匆匆拿出大三的教材《软件程序设计》看看上面的那些的图都怎么画的,再找找网上的例子,模仿着能画出个类似下面的流程图。



上面这个图乍一看还可以,但是网上关于流程图的语法好几个版本,每个人画出来都不一样,而且用这种形式表达大一些的流程,就完全感觉眼花缭乱。


画图的底层逻辑是沟通


其实走到工作岗位上之后我们仍然面临着这样的问题,除了专心写代码外,我们的工作构成中还有大量的需求评审、需求分析、技术评审、系统方案设计这样的需要人和人进行沟通的环节,讲逻辑、讲实现方案、讲设计思路的沟通环节。究其原因就是因为IT行业分工明细,它像其他行业的工作流水线一样涉及大量工种的合作,但又因为交付的软件并不像工业流水线一样是标准件,所以上面列举的每个环节都需要良好的沟通,让各职能人员之间建立共识、建立统一语言后才能完成高效协作交付产品。


既然需要良好的沟通建立统一语言,那么我们在需求分析、技术评审、系统方案设计文档上就不能使用太主观的语言,也不能把实现代码直接往文档上粘,那样的话且不说有的岗位不写代码,即使同岗位的其他同事也没有那个精力一行一行的仔细看完你的代码、理解你的思路。


所以这就需要我们能够简洁、高效地用人们都能看懂的专业图形描述出软件开发这些环节中需要重点沟通的需求逻辑和技术的关键细节。


我们都知道从事理工专业的人,可能对构图、色彩这些不太擅长,那么有没有一种图形不需要美术基础就能掌握,足够专业让图形能突出我们想表达的技术细节,同时还足够简洁即使是不太懂技术的人也能完全看懂呢?在IT领域还真有,那就是UML。


比如同样是表达需求的业务流程,用流程图表达的就是上面图-1的那个样子,但是用表达力更强,更注重语法的UML活动图表达流程的话就是下面这个样子。



关于怎么用活动图分析表达流程,后面会有专门的章节去给大家讲解。


程序员画图难的成因


说到UML,无论是大学里还是市面上讲解UML的书籍中对UML的讲解都太过枯燥了,它们通常都是以技术和软件设计的角度来讲述UML的,通常上来会先讲解一大堆图,哪些是结构建模,哪些是行为建模,紧接着就是各种图的一堆语法(画法),或者是给出的示例太过于技术化,完全脱离日常生活让人无法理解。


这就给我们这样一开始不太懂的人一种UML太过专业太过复杂,不好用的印象。想的那么清楚画出图来,代码早写好了。典型的例子就是如果画类图把类的各个属性和方法都想好画出来也太费时间了,况且需求多变还要经常改,还有就是那些类图表示的类的关系一会儿是箭头、一会儿是虚线、不明白他们都什么区别,看多了就头疼。


其实上面这个现象完全就是误区,UML完全不是必须那么复杂--把所有细节都表示出来才算完事,我们完全可以从需求分析阶段开始就开始使用,在分析的过程中构思业务的结构并画出来它大概的样貌。



后面随着对需求的进一步了解再去补全或者调整其中的内容。写技术文档常用的UML图除了能像上面这样使用类图分析业务的结构,还有活动图、顺序图、状态机图从不同角度分析业务的行为,而且是循序渐进的使用,不是上来把这些都用上。


早期对业务知晓不够透彻时UML图可以画的粗略些,流程分析也只先分析明白大流程即可,随着使用UML分析业务的过程对业务逐渐了解后再逐渐细化以及使用不同的图形从不同角度描述业务。 UML家族里提供的各种图,也不局限于只能用于技术分析,甚至需求用例、系统架构、IT架构方面的需求也能够使用UML进行描述。


掌握UML让自己有更多可能


无论是一线研发,还是已经转型项目经理、产品经理或者团队管理的人员或者是想要上车入行的萌新程序员,本课程都能让你收益颇多,让你掌握产品经理写需求的一些基本技能,也让你轻松应对项目经理参与竞标和项目管理时的文案编写工作。


同时还能让你管理项目质量时找到“抓手”,通过在项目团队建立技术评审、方案设计等相关机制--融合团队成员对UML的使用,让团队成员的思维性创造更容易被周知也让这些内容更容易被Review,从而达到项目开发期间高效的沟通和良好的质量保证。


职场上的“汇报困境”


除了上面讲的这些我们工作中干活需要用到的各种图形外,在职场上班和在学校上学有一个重要的区别就是我们时不时的就要被拿出来评比、通晒、述职,这些场合都会要求我们做汇报。


针对这个程序员在职场中的普遍痛点,推荐一下我用大半年的时间沉淀,汇集了我多年职场经验的画图课,解决程序员普遍只愿意埋头写代码,不会做需求分析、不会做技术评审、不会画架构图、述职汇报做不好,等等这些需要画图和表达能力的事情的时候就犯难的问题,帮助大家摆脱代码的单一维度,从多维度提升自己,建立自信,让你在工作中更游刃有余


课程最后一部分还会扩展一些互联网开发人员在职场中应对各种汇报的策略,讲述一些写汇报PPT的主旨思路,侧重点和注意事项。同时也讲一些使用堆砖块画法(我自己总结的)给汇报PPT进行配图的思路,怎么通过这些图快速抓住听众的眼球建立共识,以及怎么使用一些配图讲解规划给上级“画饼”来获得他们的支持从而进一步获得他们后续在资源上的支持,更好地开展工作,这些技巧我们在课程最后一部分都会讲到。


相关推荐


现有有两种订阅方式


方式1微信专栏:程序员的全能画图课


方式2小报童专栏:程序员的全能画图课


作者:kevinyan
来源:juejin.cn/post/7370615140242472998
收起阅读 »

为什么很多人不推荐你用JWT?

为什么很多人不推荐你用JWT? 如果你经常看一些网上的带你做项目的教程,你就会发现 有很多的项目都用到了JWT。那么他到底安全吗?为什么那么多人不推荐你去使用。这个文章将会从全方面的带你了解JWT 以及他的优缺点。 什么是JWT? 这个是他的官网JSON We...
继续阅读 »

为什么很多人不推荐你用JWT?


如果你经常看一些网上的带你做项目的教程,你就会发现 有很多的项目都用到了JWT。那么他到底安全吗?为什么那么多人不推荐你去使用。这个文章将会从全方面的带你了解JWT 以及他的优缺点。


什么是JWT?


这个是他的官网JSON Web Tokens - jwt.io


这个就是JWT


img


JWT 全称JSON Web Token


如果你还不熟悉JWT,不要惊慌!它们并不那么复杂!


你可以把JWT想象成一些JSON数据,你可以验证这些数据是来自你认识的人。


当然如何实现我们在这里不讲,有兴趣的可以去自己了解。


下面我们来说一下他的流程:



  1. 当你登录到一个网站,网站会生成一个JWT并将其发送给你。

  2. 这个JWT就像是一个包裹,里面装着一些关于你身份的信息,比如你的用户名、角色、权限等。

  3. 然后,你在每次与该网站进行通信时都会携带这个JWT

  4. 每当你访问一个需要验证身份的页面时,你都会把这个JWT带给网站

  5. 网站收到JWT后,会验证它的签名以确保它是由网站签发的,并且检查其中的信息来确认你的身份和权限。

  6. 如果一切都通过了验证,你就可以继续访问受保护的页面了。


JWT Session


为什么说JWT很烂?


首先我们用JWT应该就是去做这些事情:



  • 用户注册网站

  • 用户登录网站

  • 用户点击并执行操作

  • 本网站使用用户信息进行创建、更新和删除 信息


这些事情对于数据库的操作经常是这些方面的



  • 记录用户正在执行的操作

  • 将用户的一些数据添加到数据库中

  • 检查用户的权限,看看他们是否可以执行某些操作


之后我们来逐步说出他的一些缺点


大小


这个方面毋庸置疑。


比如我们需要存储一个用户ID 为xiaou


如果存储到cookie里面,我们的总大小只有5个字节。


如果我们将 ID 存储在 一个 JWT 里。他的大小就会增加大概51倍


image-20240506200449402


这无疑就增大了我们的宽带负担。


冗余签名


JWT的主要卖点之一就是其加密签名。因为JWT被加密签名,接收方可以验证JWT是否有效且可信。


但是,在过去20年里几乎每一个网络框架都可以在使用普通的会话cookie时获得加密签名的好处。


事实上,大多数网络框架会自动为你加密签名(甚至加密!)你的cookie。这意味着你可以获得与使用JWT签名相同的好处,而无需使用JWT本身。


实际上,在大多数网络身份验证情况下,JWT数据都是存储在会话cookie中的,这意味着现在有两个级别的签名。一个在cookie本身上,一个在JWT上。


令牌撤销问题


由于令牌在到期之前一直有效,服务器没有简单的方法来撤销它。


以下是一些可能导致这种情况危险的用例。


注销并不能真正使你注销!


想象一下你在推特上发送推文后注销了登录。你可能会认为自己已经从服务器注销了,但事实并非如此。因为JWT是自包含的,将在到期之前一直有效。这可能是5分钟、30分钟或任何作为令牌一部分设置的持续时间。因此,如果有人在此期间获取了该令牌,他们可以继续访问直到它过期。


可能存在陈旧数据


想象一下用户是管理员,被降级为权限较低的普通用户。同样,这不会立即生效,用户将继续保持管理员身份,直到令牌过期。


JWT通常不加密


因此任何能够执行中间人攻击并嗅探JWT的人都拥有你的身份验证凭据。这变得更容易,因为中间人攻击只需要在服务器和客户端之间的连接上完成


安全问题


对于JWT是否安全。我们可以参考这个文章


JWT (JSON Web Token) (in)security - research.securitum.com


同时我们也可以看到是有专门的如何攻击JWT的教程的


高级漏洞篇之JWT攻击专题 - FreeBuf网络安全行业门户


总结


总的来说,JWT适合作为单次授权令牌,用于在两个实体之间传输声明信息。


但是,JWT不适合作为长期持久数据的存储机制,特别是用于管理用户会话。使用JWT作为会话机制可能会引入一系列严重的安全和实现上的问题,相反,对于长期持久数据的存储,更适合使用传统的会话机制,如会话cookie,以及建立在其上的成熟的实现。


但是写了这么多,我还是想说,如果你作为自己开发学习使用,不考虑安全,不考虑性能的情况下,用JWT是完全没有问题的,但是一旦用到生产环境中,我们就需要避免这些可能存在的问题。


作者:小u
来源:juejin.cn/post/7365533351451672612
收起阅读 »

我是没想到是还可以这样秒出答案 ...

起因 晚上在休闲游戏中,一网友发来信息求问,一道编程题。 咋一看,嘿 2023年1月浙江选考题(信息技术),挺新鲜,那就来看看吧。 聊了一下才知道,这是中考高考(6月28日晚23:05更正)选题。中考高考(6月28日晚23:05更正)就考这样的了吗? ...
继续阅读 »

起因


晚上在休闲游戏中,一网友发来信息求问,一道编程题。



咋一看,嘿 2023年1月浙江选考题(信息技术),挺新鲜,那就来看看吧。
聊了一下才知道,这是中考高考(6月28日晚23:05更正)选题。中考高考(6月28日晚23:05更正)就考这样的了吗?



image.png
image.png


一、题目



image.png



二、解析


因为题解想半天,没看明白要做的,就先直接上手代码去测试实验。通过足够多的次数去请求,就可以知道正确答案了(不符合出现的)。



后面恍然大悟会进一步讲解内容



二、代码测试


把该代码转成 java 对应的代码内容,并进行测试


public static void main(String[] args) {
// 答案记录
Map ansMap = new HashMap<>();
ansMap.put("AB##CD#", 0); // 选项A 答案
ansMap.put("#######", 0); // 选项B 答案
ansMap.put("#B##CDA", 0); // 选项C 答案
ansMap.put("###ABCD", 0); // 选项D 答案
for (int i = 0; i < 100000; i++) { // 10万次执行,看看 ABCD 答案是哪个一直没有出现
String res = runWork(); // 出现的结果
if (ansMap.get(res) == null){ // 出现和选项答案不一致的跳过
continue;
}
// 出现一致的进行+1
ansMap.put(res, ansMap.get(res) + 1);
}
// 输出结果
System.out.println(ansMap.toString());
}

public static String runWork() {
char[] a = {'A', 'B', '#', '#', 'C', 'D', '#'};
char[] stk = new char[a.length];
int top = -1;
Random random = new Random();

for (int i = 0; i < a.length; i++) {
int op = random.nextInt(2);
if (op == 1 && a[i] != '#') {
top++;
stk[top] = a[i];
a[i] = '#';
} else if (op == 0 && top != -1 && a[i] == '#') {
a[i] = stk[top];
top--;
}
}
return String.valueOf(a);
}

三、测试结果



微信图片_20230627210300.png


截图中可以看到,测试中,A、B、C 选项都出现了,不符合的是 D 选项,因此,正确答案是选项 D。

四、恍然大悟(真正解析)


仔细瞧命名, stk ,是栈(stack)的简写!可恶,这道题可以直接利用栈的知识去看选项去解了啊...



原字符数组是 'A', 'B', '#', '#', 'C', 'D', '#'

栈,就是先进后出。



选项内容解析
AAB##CD#对 a 字符数组都不进行拿出拿入,stk 字符数组就是空,
也就是不变,那么结果可以出现
B#######对 a 字符数组的ABCD都拿走,最终 stk 字符数组里就是 DCBA,
那么结果也可以出现
C#B##CDA对 a 字符数组都只拿A,并在最后一个的时候拿出最上层的。
最上层只有一个 A ,那就拿出 A ,
此时 stk 字符数组就为空了,那么结果可以出现
D###ABCD对 a 字符数组先拿A,stk 里就有 A ,但是B也需要拿,
且 A 要放在 B 拿之前的后面,不能实现,那么结果是不可以出现的!


图解:


ans.gif



那么最终,也就能明白这套代码的意思了,就是随机可能去拿去里面的字母,ABCD,放到栈里再实现放到原数组中去。对栈的理解与使用解释了一下。答案选 D ,只有 D 不符合栈的进出。




作者:南方者
来源:juejin.cn/post/7249288803532947517
收起阅读 »

【禁止血压飙升】如何拥有一个优雅的 controller

前言 见过几千行代码的 controller吗?我见过。 见过全是 try catch 的 controller 吗,我见过。 见过全是字段校验的 controller 吗,我见过。 见过全是业务代码的 controller 吗?不好意思,我们公司很多业务写在...
继续阅读 »

前言


见过几千行代码的 controller吗?我见过。


见过全是 try catch 的 controller 吗,我见过。


见过全是字段校验的 controller 吗,我见过。


见过全是业务代码的 controller 吗?不好意思,我们公司很多业务写在 controller 的。


看见这些我真的血压高。


正文


不优雅的 controller



@RestController
@RequestMapping("/user/test")
public class UserController {

private static Logger logger = LoggerFactory.getLogger(UserController.class);

@Autowired
private UserService userService;

@Autowired
private AuthService authService;

@PostMapping
public CommonResult userRegistration(@RequestBody UserVo userVo) {
if (StringUtils.isBlank(userVo.getUsername())){
return CommonResult.error("用户名不能为空");
}
if (StringUtils.isBlank(userVo.getPassword())){
return CommonResult.error("密码不能为空");
}
logger.info("注册用户:{}" , userVo.getUsername());
try {
userService.registerUser(userVo.getUsername());
return CommonResult.ok();
}catch (Exception e){
logger.error("注册用户失败:{}", userVo.getUsername(), e);
return CommonResult.error("注册失败");
}
}

@PostMapping("/login")
@PermitAll
@ApiOperation("使用账号密码登录")
public CommonResult<AuthLoginRespVO> login(@RequestBody AuthLoginReqVO reqVO) {
if (StringUtils.isBlank(reqVO.getUsername())){
return CommonResult.error("用户名不能为空");
}
if (StringUtils.isBlank(reqVO.getPassword())){
return CommonResult.error("密码不能为空");
}
try {
return success(authService.login(reqVO));
}catch (Exception e){
logger.error("注册用户失败:{}", reqVO.getUsername(), e);
return CommonResult.error("注册失败");
}
}

}


优雅的controller


@RestController
@RequestMapping("/user/test")
public class UserController1 {

private static Logger logger = LoggerFactory.getLogger(UserController1.class);

@Autowired
private UserService userService;

@Autowired
private AuthService authService;

@PostMapping("/userRegistration")
public CommonResult userRegistration(@RequestBody @Valid UserVo userVo) {
userService.registerUser(userVo.getUsername());
return CommonResult.ok();
}

@PostMapping("/login")
@PermitAll
@ApiOperation("使用账号密码登录")
public CommonResult<AuthLoginRespVO> login(@RequestBody @Valid AuthLoginReqVO reqVO) {
return success(authService.login(reqVO));
}

}


代码量直接减一半呀,这还不算上有些直接把业务逻辑写在 controller 的,看到这些我真的直接吐血



改造流程


校验方式



这个 if 校验看得我哪哪都不爽。好歹给我写一个断言吧。Assert.notNull(userVo.getUsername(), "用户名不能为空");


这不香吗?确实不香。


使用 spring 提供的@Valid




  • 在入参时使用@Valid注解,并且在 vo 中使用校验注解,如AuthLoginReqVO


@ApiModel(value = "管理后台 - 账号密码登录 Request VO")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class AuthLoginReqVO {

@ApiModelProperty(value = "账号", required = true, example = "user")
@NotEmpty(message = "登录账号不能为空")
@Length(min = 4, max = 16, message = "账号长度为 4-16 位")
@Pattern(regexp = "^[A-Za-z0-9]+$", message = "账号格式为数字以及字母")
private String username;

@ApiModelProperty(value = "密码", required = true, example = "password")
@NotEmpty(message = "密码不能为空")
@Length(min = 4, max = 16, message = "密码长度为 4-16 位")
private String password;

}

@Valid


在SpringBoot中,@Valid是一个非常有用的注解,主要用于数据校验。以下是关于@Valid的一些详细信息:



  1. 为什么使用 @Valid 来验证参数:在编写接口时,我们经常需要验证请求参数。通常,我们可能会写大量的 if 和 if else 代码来进行判断。但这样的代码不仅不优雅,而且如果存在大量的验证逻辑,这会使代码看起来混乱,大大降低代码可读性。为了简化这个过程,我们可以使用 @Valid 注解来帮助我们简化验证逻辑。

  2. @Valid 注解的作用:@Valid 的主要作用是用于数据效验,可以在定义的实体中的属性上,添加不同的注解来完成不同的校验规则,而在接口类中的接收数据参数中添加 @valid 注解,这时你的实体将会开启一个校验的功能。

  3. @Valid 的相关注解:在实体类中不同的属性上添加不同的注解,就能实现不同数据的效验功能。

  4. 使用 @Valid 进行参数效验步骤:整个过程如下,用户访问接口,然后进行参数效验,因为 @Valid 不支持平面的参数效验(直接写在参数中字段的效验)所以基于 GET 请求的参数还是按照原先方式进行效验,而 POST 则可以以实体对象为参数,可以使用 @Valid 方式进行效验。如果效验通过,则进入业务逻辑,否则抛出异常,交由全局异常处理器进行处理。

  5. @Validated与@Valid的区别@Validated@Valid 的变体。通过声明实体中属性的 groups ,再搭配使用 @Validated ,就能决定哪些属性需要校验,哪些不需要校验。


全局异常处理



  • 这个全局异常处理,可以根据自己的异常,自定义异常处理,并设置一个兜底的异常处理



@ResponseBody
@RestControllerAdvice
public class ExceptionHandlerAdvice {
protected Logger logger = LoggerFactory.getLogger(getClass());

@ExceptionHandler(MethodArgumentNotValidException.class)
public CommonResult<Object> handleValidationExceptions(MethodArgumentNotValidException ex) {
logger.error("[handleValidationExceptions]", ex);
StringBuilder sb = new StringBuilder();
ex.getBindingResult().getAllErrors().forEach(error -> {
String fieldName = ((org.springframework.validation.FieldError) error).getField();
String errorMessage = error.getDefaultMessage();
sb.append(fieldName).append(":").append(errorMessage).append(";");
});
return CommonResult.error(sb.toString());
}

/**
* 处理系统异常,兜底处理所有的一切
*/

@ExceptionHandler(value = Exception.class)
public CommonResult<?> defaultExceptionHandler(Throwable ex) {
logger.error("[defaultExceptionHandler]", ex);
// 返回 ERROR CommonResult
return CommonResult.error(INTERNAL_SERVER_ERROR.getCode(), INTERNAL_SERVER_ERROR.getMsg());
}

}


就这么多,搞定,这样就拥有了漂流优雅的 controller 了



在日常开发中,还有那些血压飙升瞬间



  • 我拿出下图阁下如何面对


image-20240411185003067.png



  • 这个阁下又如何面对,我不说,你能知道这个什么吗【狗头】


image-20240411185134843.png


总结



  • 不是很明白为什么有些喜欢在 controller 写业务逻辑的,曾经有个同事问我(就是喜欢在 controller 写业务的),你这个接口写在那里,我需要调一下你这个接口。我满脸问号??不是隔壁的模块吗,为什么要调我的接口?直接引用的我的 service 去调方法就好了。

  • 这个就是痛点,各写各的,冗余代码一堆。

  • 曾经看到一个同事写一个保存的方法,虽然逻辑挺多,我滑动了好久都还没有方法还没有结束。一个方法整整几百行……

  • 看过 spring 源码都知道,spring 源码难啃,就是因为 spring 无限往下套娃,基本每个方法干每个方法的事情。比如我保存用户时,就只是保存用户,至于什么校验丢给校验的方法处理,什么发送消息丢给发送消息处理,这些就不能耦合在一起。

  • 对于看到一些 if 下面一丢逻辑,然后 if 再一丢逻辑,看代码时很多情况不需要知道这个逻辑怎么实现的,知道入参出参就大概这里做什么了。即使想知道详细情况点进去就知道了。突出这个当前方法要做的事情就好了。

  • 阿里的开发手册就推荐一个方法不能超过 80 行,超过可以根据业务具体调整一下。


作者:小塵
来源:juejin.cn/post/7357172505961578511
收起阅读 »

来,实现一下这个报表功能,速度要快,要嘎嘎快

我们有一段业务,类似一个报表,就是获取用户的订单汇总,邮费汇总,各种手续费汇总,然后拿时间噶一卡,显示在页面。 但是呢,这几个业务没啥实际关系,数据也是分开的,一个一个获取会有点慢,我开始就是这样写的,老板嫌页面太慢,让我改,可是页面反应慢,关我后端程序什么事...
继续阅读 »

我们有一段业务,类似一个报表,就是获取用户的订单汇总,邮费汇总,各种手续费汇总,然后拿时间噶一卡,显示在页面。


但是呢,这几个业务没啥实际关系,数据也是分开的,一个一个获取会有点慢,我开始就是这样写的,老板嫌页面太慢,让我改,可是页面反应慢,关我后端程序什么事,哥哥别打了,错了错了,我改,我改。那么最好的方案就是多线程分别获取然后汇总到一起返回。


在Java中获取异步线程的结果通常可以使用FutureCallableCompletableFutureFutureTask等类来实现。这些类可以用来提交任务到线程池,并在任务完成后获取结果。这就是我们想要的结果,那么这里来深入研究分析一下这三个方案。


使用FutureCallable


package com.luke.designpatterns.demo;

import java.util.concurrent.*;

public class demo {
public static void main(String[] args) throws InterruptedException, ExecutionException {
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<Integer> future = executor.submit(new Callable<Integer>() {
public Integer call() throws Exception {
// 获取各种汇总的代码,返回结果
return 42;
}
});
// 获取异步任务的结果
Integer result = future.get();
System.out.println("异步任务的结果是" + result);
executor.shutdown();
}
}

image.png


它们的原理是通过将任务提交到线程池执行,同时返回一个Future对象,该对象可以在未来的某个时刻获取任务的执行结果。



  1. Callable 接口Callable 是一个带泛型的接口,它允许你定义一个返回结果的任务,并且可以抛出异常。这个接口只有一个方法 call(),在该方法中编写具体的任务逻辑。

  2. Future 接口Future 接口代表一个异步计算的结果。它提供了方法来检查计算是否完成、等待计算的完成以及检索计算的结果。Future 提供了一个 get() 方法,它会阻塞当前线程直到计算完成,并返回计算的结果。



Callable 接口本身并不直接启动线程,它只是定义了一个可以返回结果的任务。要启动一个 Callable 实例的任务,通常需要将其提交给 ExecutorService 线程池来执行。



ExecutorService 中,可以使用 submit(Callable<T> task) 方法提交 Callable 任务。这个方法会返回一个 Future 对象,它可以用来获取任务的执行结果。


启动 Callable 任务的原理可以概括为以下几个步骤:



  1. 创建 Callable 实例:首先需要创建一个实现了 Callable 接口的类,并在 call() 方法中定义具体的任务逻辑,包括要执行的代码和返回的结果。

  2. 创建 ExecutorService 线程池:使用 Executors 类的工厂方法之一来创建一个 ExecutorService 线程池,例如 newFixedThreadPool(int nThreads)newCachedThreadPool() 等。

  3. 提交任务:将 Callable 实例通过 ExecutorServicesubmit(Callable<T> task) 方法提交到线程池中执行。线程池会为任务分配一个线程来执行。

  4. 异步执行ExecutorService 线程池会在后台异步执行任务,不会阻塞当前线程,使得主线程可以继续执行其他操作。

  5. 获取结果:通过 Future 对象的 get() 方法获取任务的执行结果。如果任务尚未完成,get() 方法会阻塞当前线程直到任务完成并返回结果。


总的来说,Callable 启动线程的原理是将任务提交给 ExecutorService 线程池,线程池会负责管理线程的执行,执行任务的过程是在独立的线程中进行的,从而实现了异步执行的效果。


使用CompletableFuture


import java.util.concurrent.CompletableFuture;

public class Main {
public static void main(String[] args) throws InterruptedException, ExecutionException {
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
// 获取各种汇总的代码,返回结果
return 43;
});

// 获取异步任务的结果
Integer result = future.get();

System.out.println("异步任务的结果:" + result);
}
}

image.png


CompletableFuture 是 Java 8 引入的一个类,用于实现异步编程和异步任务的组合。它的原理是基于"Completable"(可以完成的)和"Future"(未来的结果)的概念,提供了一种方便的方式来处理异步任务的执行和结果处理。


CompletableFuture 的原理可以简单概括为以下几点:



  1. 异步执行CompletableFuture 允许你以异步的方式执行任务。你可以使用 supplyAsync()runAsync() 等方法提交一个任务给 CompletableFuture 执行,任务会在一个独立的线程中执行,不会阻塞当前线程。

  2. 回调机制CompletableFuture 提供了一系列的方法来注册回调函数,这些回调函数会在任务执行完成时被调用。例如,thenApply(), thenAccept(), thenRun() 等方法可以分别处理任务的结果、完成时的操作以及任务执行异常时的处理。

  3. 组合多个任务CompletableFuture 支持多个任务的组合,可以使用 thenCombine()thenCompose()thenAcceptBoth() 等方法来组合多个任务,实现任务之间的依赖关系。

  4. 异常处理CompletableFuture 允许你对任务执行过程中抛出的异常进行处理,可以使用 exceptionally()handle() 等方法来处理异常情况。

  5. 等待任务完成:与 Future 类似,CompletableFuture 也提供了 get() 方法来等待任务的完成并获取结果。但与传统的 Future 不同,CompletableFutureget() 方法不会阻塞当前线程,因为任务的执行是异步的。


总的来说,CompletableFuture 的原理是基于回调和异步执行的机制,提供了一种方便的方式来处理异步任务的执行和结果处理,同时支持任务的组合和异常处理。


使用FutureTask


import java.util.concurrent.*;

public class Main {
public static void main(String[] args) throws InterruptedException, ExecutionException {
FutureTask<Integer> futureTask = new FutureTask<>(() -> {
// 获取各种汇总的代码,返回结果
return 44;
});

Thread thread = new Thread(futureTask);
thread.start();

// 获取异步任务的结果
Integer result = futureTask.get();
System.out.println("异步任务的结果:" + result);
}
}

image.png


FutureTask 是 Java 中实现 Future 接口的一个基本实现类,同时也实现了 Runnable 接口,因此可以被用作一个可运行的任务。FutureTask 的原理是将一个可调用的任务(CallableRunnable)封装成一个异步的、可取消的任务,它提供了一个机制来获取任务的执行结果。


FutureTask 的原理可以简要概括如下:



  1. 封装任务FutureTask 接受一个 CallableRunnable 对象作为构造函数的参数,并将其封装成一个异步的任务。

  2. 执行任务FutureTask 实现了 Runnable 接口,因此可以作为一个可运行的任务提交给 Executor(通常是 ExecutorService)来执行。当 FutureTask 被提交到线程池后,线程池会在一个独立的线程中执行该任务。

  3. 获取结果:通过 Future 接口的方法,可以等待任务执行完成并获取其结果。FutureTask 实现了 Future 接口,因此可以调用 get() 方法来获取任务的执行结果。如果任务尚未完成,get() 方法会阻塞当前线程直到任务完成并返回结果。

  4. 取消任务FutureTask 提供了 cancel(boolean mayInterruptIfRunning) 方法来取消任务的执行。可以选择是否中断正在执行的任务。一旦任务被取消,get() 方法会立即抛出 CancellationException 异常。


总的来说,FutureTask 的原理是将一个可调用的任务封装成一个异步的、可取消的任务,并通过 Future 接口来提供获取任务执行结果和取消任务的机制。


这些方法中,get()方法会阻塞当前线程,直到异步任务完成并返回结果。如果任务抛出异常,get()方法会将异常重新抛出。


我们平时常用的方法就是这四种,都能实现我的需求,随便找一个哐哐干上去就好啦。


作者:奔跑的毛球
来源:juejin.cn/post/7350557995895701531
收起阅读 »

连公司WiFi后,无法访问外网,怎么回事,如何解决?

问题描述 从甲方项目组返回公司后,我习惯性连上公司WiFi,准备百度一个bug,突然我发现无打开百度,F5刷新了好几次也没用,浏览器报了下面的错误信息 尝试ping了一下 http://www.badu.com,好家伙,直接丢包 然后运行 ipconfig/...
继续阅读 »

问题描述


从甲方项目组返回公司后,我习惯性连上公司WiFi,准备百度一个bug,突然我发现无打开百度,F5刷新了好几次也没用,浏览器报了下面的错误信息


523d43c309e5d5b786dff74cc54894bf.png


尝试ping了一下 http://www.badu.com,好家伙,直接丢包


然后运行 ipconfig/all 命令看了一下本机的DNSF服务器信息


b75ff8dab79e97b5698daa4e060e24af.png


我的本机DNS地址是192.168.0.1


通常,本机DNS地址若为192.168.0.1,说明所连WiFi的路由器可能被设定为执行DNS转发职责,或者是期望客户端直接使用路由器作为DNS解析的入口点。而192.168.0.1一般是路由器的默认IP地址,并非一个标准的公共DNS服务器地址。在这种情况下,访问不了外网,例如百度,新浪微博等,有可能是路由器的DNS转发功能没有正常工作,或者路由器自身没有被配置正确以访问外部的DNS服务器


最简单直接的解决方法是手动设置主机的DNS地址为公共的DNS服务器地址



  • Google DNS:8.8.8.8 & 8.8.4.4

  • Cloudflare DNS: 1.1.1.1

  • 中国电信:114.114.114.114

  • 中国联通:223.5.5.5


7785e7beed4c08710c07617232dad576.png




OK,可以正常访问百度了



45db3372360d9ecab863ffb1da9e1246.png


这让我产生了非常浓烈的好奇,从浏览器上输入URL到显示页面,中间究竟发生了什么?


image.png


问题探究



这是一道面试题



1716627344750.png


从浏览器中输入URL并按下回车键后,直到网页内容完全显示在屏幕上,这个过程中发生了一系列复杂的步骤,大致可以概括如下:



  1. URL解析:浏览器首先解析输入的URL,提取出协议、域名、路径以及查询字符串等信息。

  2. 检查缓存:在发起网络请求之前,浏览器会检查本地缓存(包括浏览器缓存、系统缓存乃至路由器缓存),看看是否已经存储了该请求的资源。如果有且未过期,则直接使用缓存内容,无需继续下面的步骤。

  3. DNS解析:如果缓存中没有所需资源,浏览器会通过DNS(域名系统)将网址的域名转换为IP地址,因为网络通信是基于IP地址的。这个过程中可能涉及递归查询和迭代查询,直至找到域名对应的IP地址。

  4. TCP连接建立:获得服务器IP后,浏览器使用TCP协议与服务器建立连接。这通常涉及TCP三次握手过程,确保数据传输的可靠性和连接的双方都准备好通信。

  5. 发起HTTP/HTTPS请求:建立连接后,浏览器构造HTTP或HTTPS请求报文,包含请求方法(如GET或POST)、请求头(携带浏览器信息、请求资源的位置等)以及可能的请求体,然后发送给服务器。

  6. 服务器处理请求:服务器接收到请求后,根据请求的内容处理并准备响应,这可能涉及数据库查询、服务器端脚本执行等操作。

  7. 响应浏览器:服务器将处理好的响应数据(包括状态码、响应头、响应体等)封装成HTTP响应报文,发送回浏览器。

  8. 浏览器接收响应:浏览器接收响应数据,如果响应中有新的资源(如CSS、JavaScript、图片等),浏览器会根据需要再次发起请求获取这些资源。

  9. 渲染页面:浏览器开始解析HTML文档,构建DOM(文档对象模型)树,同时解析CSS文件构建CSSOM(CSS对象模型)树,结合这两棵树形成渲染树(Render Tree)。接着进行布局(Layout)和绘制(Painting),即确定每个节点在屏幕上的位置和外观,最终将页面内容呈现给用户。

  10. 执行JavaScript:页面中的JavaScript代码会被解析和执行,它可能修改DOM和CSSOM,导致重新布局和绘制。此外,异步请求如Ajax也可以在这个阶段发起,动态更新页面内容。

  11. 页面交互:页面加载完毕后,用户可以与页面进行交互,触发事件处理程序,进一步的JavaScript执行可能会改变页面状态。

  12. 连接关闭:当所有数据传输完毕,TCP连接会通过四次挥手的过程优雅地关闭。


上述过程中涉及到了多个层次的技术和协议,从应用层的HTTP/HTTPS、运输层的TCP、网络层的IP到链路层的以太网协议等,共同协作完成了从简单的URL输入到复杂页面展示的任务。


cbacfb95186577a2e2d92fe72fa8d0c5.png


基于上述分析,问题发生在第③步(DNS解析)上,要想回答何为DNS解析,就必须弄明白何为DNS。


何为DNS?


DNS,英文全称为Domain Name System,即域名系统。当我们在浏览器输入一个 URL 地址时,浏览器要向这个 URL 的主机名对应的服务器发送请求,就得知道这个服务器对应的 IP地址,而对于浏览器来说,DNS 的作用就是将主机名转换成 IP 地址【正向解析】。以下定义概念摘自《计算机网络:自顶向下方法》:




  1. 一个由分层的 DNS 服务器( DNS server) 实现的分布式数据库

  2. 一个使得主机能够查询分布式数据库的应用层协议



分布式,层次数据库


如何理解分布式?


随着互联网的快速发展,主机日益增多且数量庞大,采用单一DNS服务器上集中响应的设计并不可取,这种设计容易造成单点故障维护困难通信容量受限等问题。


为了应对上述问题和扩展性, DNS 使用了大量的 DNS 服务器并分布在全世界范围内。因为没有一台 DNS 服务器可以存放Internet上所有主机的映射数据, 相反,该映射数据被分布存储在所有的 DNS 服务器上。


如何理解层次?


DNS服务器采用层次组织,大致说来,有3种类型的 DNS 服务器:根 DNS 服务器、 顶级域 (Top- Level Domain , TLD) DNS 服务器和权威 DNS 服务器。它们的层次结构方式如下所示:


1716628019691.png


图片来源:《计算机网络:自顶向下方法》



  • 根DNS服务器


    我们首先要明确根域名是什么,它没有特定的名称,仅由一个点(.)表示。在技术层面上,它是所有域名查询的起点,负责指引域名解析过程中的查询请求到相应的顶级DNS(TLD)服务器,如.com.net.org等。而在实际的网址中,根域名通常隐含而不显示,例如com.baidu.com.,后面的点一般不会显示。


    根DNS服务器是互联网基础设施的关键部分,全球共有13组根DNS服务器,它们存储了顶级DNS服务器的地址信息,从而帮助我们将域名转换为用于网络通信的IP地址。根DNS的管理由国际互联网名称与数字地址分配机构(ICANN)负责。


  • 顶级域服务器


    这些服务器负责顶级域名,如comorgnetedugov,以及所有国家的顶级域名如uk、r、ca和jp。TLD提供了它的下一级,也就是权威 DNS 服务器的 IP 地址。




  • 权威DNS服务器



    在因特网上具有公共可访问主机(如Wb服务器和邮件服务器)的每个组织机构必须提供公共可访问的DNS记录,这些记录将这些主机的名字映射为IP地址。



    以上内容摘自《计算机网络:自顶向下方法》,比较绕口,通俗来讲就是提供最终的主机—IP映射



本地DNS服务器


在上一节的DNS层次结构中,眼尖的小伙伴会发现,并未提及本地DNS服务器,那为什么呢?一个本地DNS服务器,从严格说来,它并不属于上述DNS服务器的层次结构,但它对DNS层次结构0是至关重要的


每个ISP(Internet Service Provider,即网络业务提供商)都有一台本地DNS服务器(也叫默认名字服务器)。当主机与某个ISP连接时,例如一个小区的ISP,一个学校的ISP等,该ISP会提供一台主机的IP地址,该主机具有一台或多台其本地DNS服务器的IP地址,通常主机的本地DNS服务器会临近主机,当主机发出DNS请求时,该请求被发往本地DNS服务器,它起着代理的作用,并将该请求转发到DNS服务器层次结构中。


迭代查询,递归查询


如下图所示,假设主机abc.net想要获取主机xyz.edu的IP地址,大致会进行如下步骤:


1716647441277.png



  1. 主机abc.net首先向它的本地DNS服务器发送一个查询报文,该报文会含有被转换的主机名xyz.edu。

  2. 本地DNS服务器会将该报文转发给根DNS服务器。

  3. 该根DNS服务器注意到其edu前缀并向本地DNS服务器返回负责edu的TLD(顶级域服务器)的IP地址列表。

  4. 该本地DNS服务器则再次向这些TLD 服务器中的其中一台发送查询报文。

  5. 该 TLD 服务器注意到 xyz. edu 前缀,并把权威DNS服务器的IP地址响应给该本地DNS服务器。

  6. 本地 DNS 服务器直接向权威DNS服务器中的其中一台重发查询报文。

  7. 该权威服务器会用xyz.edu的lP地址进行响应。

  8. 本地DNS服务器会将主机xyz.edu及其IP地址的映射数据响应给主机abc.net,主机abc.net拿到它的IP就能给主机xyz.edu发送请求。


在上图例子中,主机abc.net向本地DNS服务器发出的查询是递归查询因为该查询请求是以主机abc.net以自己的名义获得该映射。 而后继的3 个查询是迭代查询,因为所有的回答都是直接返回给本地DNS服务器。 即第①步是递归查询 ,第②,④,⑥步是迭代查询。


那所有的DNS查询都遵循迭代 + 递归的方式吗?


答案并非如此,虽然在理论上,任何DNS查询既可以是迭代的,也能是递归的。


如下图,所有的DNS查询是都是递归的,因为所有的查询请求是以主机abc.net以自己的名义获得该映射。


1716650770678.png


DNS缓存


实际上,为了改善时延性能并减少在Internet上到处传输的 DNS报文数量,DNS 广泛使用了缓存技术。 DNS 缓存的原理非常简单。 在一个请求链中,当某 DNS服务器接收一个 DNS 回答(例如,包含主机名到IP地址的映射)时,它能将该回答中的信息缓存在本地中。 下次查询时便可直接用缓存里的内容。


注意,缓存并不是永久的,每一条映射记录都有一个对应的生存时间,通常设置为两天时间,一旦过了生存时间,这条记录就会从缓存移出。


有了缓存,本地 DNS 服务器可以立即返回所要解析主机的IP地址,而不必查询任何其他DNS服务器。 而本地 DNS服务器也能够缓存TLD服务器的地址,因而经常绕过查询链中的根 DNS服务器。


参考资料


计算机网络:自顶向下方法(原书第8版) (豆瓣) (douban.com)


作者:Jormungand581
来源:juejin.cn/post/7372456890344243215
收起阅读 »

Git提交错了,于是我把锅甩给了新来的baby

又是一遭悲惨的遭遇,git提交了一连串代码之后,发现提交错了。其实是把给老婆发的消息打到了comment里,然后还提交上去了。怎么办,这被看到岂不是要社死了。 一连串的研究之后,找到了几个解决方案。接下来我们一起搞搞这种错误提交的弥补方案。其中最离谱的是第三...
继续阅读 »

又是一遭悲惨的遭遇,git提交了一连串代码之后,发现提交错了。其实是把给老婆发的消息打到了comment里,然后还提交上去了。怎么办,这被看到岂不是要社死了。


image.png


一连串的研究之后,找到了几个解决方案。接下来我们一起搞搞这种错误提交的弥补方案。其中最离谱的是第三个方案。哈哈。


赛前准备


这里模拟一下这个操作,毕竟不能直接看我们的代码记录。我们新建一个项目,新建一个文件,起名001。


image.png


然后依次改为 002 003 004 005,每次都提交一次,在005的时候,执行异常提交。


最终我们得到一个005的文件


image.png


gitee上看是这样的


image.png


对于我们来说,现在是想删除这个异常提交,不仅删除代码,还想删除记录


也就是说,期待的是,文件变为004,而且这个提交记录删除掉。


方案1 交互式 rebase


首先我们尝试一下 git rebase -i HEAD~3,这样会取出最后的三条提交记录供我们编辑。


image.png


我们可以看到顶上有三条记录,这时候,我们删除这个异常的提交5


image.png


保存之后,会返回


git rebase -i HEAD~3
Successfully rebased and updated refs/heads/master.

这时候查看记录


image.png


异常提交已经没有了。


但是若是我们直接git push 会报错


image.png


告诉我们,我们当前的分支的版本是落后于远程分支的,不能提交。


这时候就需要git push --force这个命令,强制推送!!!


需要注意的是,强制推送会覆盖远程仓库中的历史记录,因此请确保你知道这个命令是个啥,并且有必要的话,需要通知团队其他成员协调好操作。


image.png


可以看到,git push --force 是可以成功的,而且再看gitee的记录


image.png


异常提交5已经不见了。并且本地的文件已经变为了004


image.png


其实在git rebase -i HEAD~3这个命令打开的交互框里是可以更改提交的顺序的,但是不能针对同一个文件的同一行,会冲突。


方案2 git reset


git reset 其实之前写文章讲过Git reset到底该如何使用,一文读懂系列 这次我们就直接为达目的,直接使用。
我们在上边的基础上,再提交一个异常提交5,使其恢复最初的情况。


image.png


然后gitee的情况:


image.png


这时候我们执行


git reset --hard HEAD~1

这个命令将删除最近的一个提交,包括提交所做的更改。请注意,这种方法可能会导致丢失未提交的更改,也就是说,本地写的没提交的代码就没了。所以请谨慎使用。


image.png


执行之后,我们可以看到异常提交5不见了


image.png


提交的时候也需要git push --force这个命令,强制推送!!!为啥每次都使用三个!!!呢,我只想告诉你,这个命令很恐怖,一定要慎之又慎。


这时候查看gitee记录


image.png


异常提交5没有了。


使用 git revert


还有小伙伴会说,为啥不用git revert呢,这不是git专门用来回滚代码的吗?


我们恢复异常提交005,再试试


image.png


我们执行 git revert f3d8db 并且 push


image.png


可以看到,文件是从005变为004了。但是从提交记录来看,不仅没有删除记录,还多了一条。其实,除非提交的注释特别社死,不然一般用的就是git revert,因为它不仅可以保存记录,还能确保版本是往前走的。


image.png


方案3 git filter-branch(谨慎使用)


查资料的时候,还看到一个这个命令,可以来一波骚的了。那既然提错了,把这锅甩给新人不就行了,哇咔咔咔咔咔。


git filter-branch --commit-filter '
if git log --format="%B" -n 1 $GIT_COMMIT | grep -q "异常提交"; then
GIT_AUTHOR_NAME="new baby";
GIT_COMMITTER_NAME="new baby";
git commit-tree "$@";
else
git commit-tree "$@";
fi'
-- --all

然后就是这样的


image.png


image.png


可以看到名字变了。当然邮箱也是可以改的。哇咔咔,这异常不就与我没关系了么。。。但是,极其不建议这么瞎折腾哈。


这个命令会根据条件重写整个历史。操作之前备份一下吧,别折腾坏了。而且一定先和其他的小伙伴商量一下,尤其是新人哈。


在此,就研究完毕了。正常来说使用第一种或者第二种方案都是可以的。不怕挨打的话,第三种方案也行。


git rebase 和 git reset 的区别



  • git rebase 命令用于将一个分支的提交移动到另一个分支上,或者重新应用一系列的提交。它的主要作用是改变提交的基础,即重新设置提交的起点。

  • git reset 命令用于修改当前分支的 HEAD 引用,或者用于撤销之前的提交操作。


也就是说git rebase 用于重新整理提交历史,而 git reset 用于调整当前分支的位置或撤销更改。关于这两个详细的使用,git reset已经写过了,有关git rebase的我会新开一篇文章,有关将一个分支的提交移动到另一个分支上这个操作虽不常用,但总有需要用到的时候。


作者:奔跑的毛球
来源:juejin.cn/post/7365414174217355314
收起阅读 »

Spring Boot 3 集成 Jasypt详解

随着信息安全的日益受到重视,加密敏感数据在应用程序中变得越来越重要。Jasypt(Java Simplified Encryption)作为一个简化Java应用程序中数据加密的工具,为开发者提供了一种便捷而灵活的加密解决方案。本文将深入解析Jasypt的工作原...
继续阅读 »

随着信息安全的日益受到重视,加密敏感数据在应用程序中变得越来越重要。Jasypt(Java Simplified Encryption)作为一个简化Java应用程序中数据加密的工具,为开发者提供了一种便捷而灵活的加密解决方案。本文将深入解析Jasypt的工作原理,以及如何在Spring Boot项目中集成和使用Jasypt来保护敏感信息。


springboot-jasypt.jpg


springboot-jasypt.jpg


Jasypt简介


Jasypt(Java Simplified Encryption)是一个专注于简化Java加密操作的工具。它提供了一种简单而强大的方式来处理数据的加密和解密,使开发者能够轻松地保护应用程序中的敏感信息,如数据库密码、API密钥等。


Jasypt的设计理念是简化加密操作,使其对开发者更加友好。它采用密码学强度的加密算法,支持多种加密算法,从而平衡了性能和安全性。其中,Jasypt的核心思想之一是基于密码的加密(Password Based Encryption,PBE),通过用户提供的密码生成加密密钥,然后使用该密钥对数据进行加密和解密。


该工具还引入了盐(Salt)的概念,通过添加随机生成的盐值,提高了加密的安全性,防止相同的原始数据在不同的加密过程中产生相同的结果,有效抵御彩虹表攻击。


Jasypt与Spring Boot天然契合,可以轻松集成到Spring Boot项目中,为开发者提供了更便捷的数据安全解决方案。通过Jasypt,开发者可以在不深入了解底层加密算法的情况下,轻松实现数据的安全保护,使得应用程序更加可靠和安全。


官网地址: http://www.jasypt.org/


github地址: github.com/ulisesbocch…


Spring Boot 3 集成 Jasypt


添加依赖


在pom文件中添加一下依赖


<dependency>
<groupId>com.github.ulisesbocchio</groupId>
<artifactId>jasypt-spring-boot</artifactId>
<version>3.0.5</version>
</dependency>

添加配置文件


未指定前后缀的话默认格式ENC()括号里面是加密后的密文 然后实现自动解密


spring:
# 数据源配置
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://192.168.10.106:3306/xj_doc?characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: ENC(BLC3UQBxshlcA9tnMyJL7w==)

# 加密配置
jasypt:
encryptor:
# 指定加密密钥,生产环境请放到启动参数里面
password: 0f7b0a5d-46bc-40fd-b8ed-3181d21d644f
# 指定解密算法,需要和加密时使用的算法一致
algorithm: PBEWithMD5AndDES

iv-generator-classname: org.jasypt.iv.NoIvGenerator

# property:
# # 算法识别的前后缀,默认ENC(),包含在前后缀的加密信息,会使用指定算法解密
# prefix: ENC@[
# suffix: ]

启动类添加注解


在启动类上添加注解@EnableEncryptableProperties注解来开启自动解密


@SpringBootApplication
@MapperScan("cn.xj.xjdoc.**.mapper")
@EnableEncryptableProperties //开启自动解密功能
public class XjdocApplication {
public static void main(String[] args) {
SpringApplication.run(XjdocApplication.class, args);
}
}

测试类


public class JasyptUtil {

public static void main(String[] args){
StandardPBEStringEncryptor standardPBEStringEncryptor =new StandardPBEStringEncryptor();
/*配置文件中配置如下的算法*/
standardPBEStringEncryptor.setAlgorithm("PBEWithMD5AndDES");
/*配置文件中配置的password*/
standardPBEStringEncryptor.setPassword("0f7b0a5d-46bc-40fd-b8ed-3181d21d644f");
//加密
String jasyptPasswordEN =standardPBEStringEncryptor.encrypt("xj2022");
//解密
String jasyptPasswordDE =standardPBEStringEncryptor.decrypt(jasyptPasswordEN);
System.out.println("加密后密码:"+jasyptPasswordEN);
System.out.println("解密后密码:"+jasyptPasswordDE);
}
}

生产环境安全处理


jasypt的password值放在配置文件中在生产环境中是不安全的,我们可以将password值放到启动命令中,删除配置文件中password 的配置行,启动命令如下所示:


java -Djasypt.encryptor.password=password -jar jasypt-spring-boot-demo-0.0.1-SNAPSHOT.jar

或者


java -jar jasypt-spring-boot-demo-0.0.1-SNAPSHOT.jar --jasypt.encryptor.password=password

总结


Jasypt作为一个简单而强大的加密工具,为Java应用程序提供了便捷的数据保护方案。通过与Spring Boot的集成,开发者可以在应用程序中轻松地加密和解密敏感信息。在实际项目中,选择合适的加密方式、安全存储密码以及与Spring Security等安全框架的集成,都是保障应用程序安全的关键步骤。希望本文能够帮助读者更深入地了解Jasypt,并在实际项目中合理地运用加密技术。


作者:修己xj
来源:juejin.cn/post/7318616887415717924
收起阅读 »

Springboot3 + SpringSecurity + JWT + OpenApi3 实现认证授权

Springboot3 + SpringSecurity + JWT + OpenApi3 实现双token 目前全网最新的 Spring Security + JWT 实现双 Token 的案例!收藏就对了,欢迎各位看友学习参考。此项目由作者个人创作,可以供...
继续阅读 »

Springboot3 + SpringSecurity + JWT + OpenApi3 实现双token


目前全网最新的 Spring Security + JWT 实现双 Token 的案例!收藏就对了,欢迎各位看友学习参考。此项目由作者个人创作,可以供大家学习和项目实战使用,创作不易,转载请注明出处!


该项目使用目前最新的 Sprin Boot3 版本,采用目前市面上最主流的 JWT 认证方式,实现双token刷新。



温馨提示:SpringBoot3 版本必须要使用 JDK11 或 JDK19



SpringBoot3 新特性


Spring Boot3 是一个非常重要的版本,将会面临一个新的发展征程!Sprin Boot 3.0 包含了 12 个月以来,151 个人的 5700+ 次 commit 的贡献。这是自 4 年半前发布的 2.0 版本以来的第一次重大修订,这也是第一个支持 Spring Framework 6.0 和 GraaIVM 的 Spring Boot GA 版本。


Spring Boot 3.0 新版本的主要亮点:



  1. 最低要求为 Java 17 ,兼容 Java 19

  2. 支持用 GraalVM 生成原生镜像,代替了 Spring Native

  3. 通过 Micrometer 和 Micrometer 追踪提高应用可观察性

  4. 支持具有 EE 9 baseline 的 Jakarta EE 10


为什么采用双 Token刷新?


**场景假设:**星期四小金上班的时候摸鱼,准备在某APP 上面追剧,已经深深的陷入了角色中无法自拔,此时如果 Token 过期了 ,小金就不得不重新返回登录界面,重新进行登录,那么这样小金的一次完整的追剧体验就被打断了,这种设计带给小金的体验并不好,于是就需要使用双 Token 来解决。


**如何使用:**在小金首次登陆 APP 时,APP 会返回两个 Token 给小金,一个 accessToken,一个 refreshToken,其中 accessToken 的过期时间比较短,refreshToken 的时间比较长。当 accessToken 失效后,会通过 refreshToken 去重新获取 accessToken,这样一来就可以在不被察觉的情况下仍然使小金保持登录状态,让小金误以为自己一直是登录的状态。并且每次使用refreshToken 后会刷新,每一次刷新后的 refreshToken 都是不相同的。


**优势说明:**小金能够有一次完整的追剧体验,除非摸鱼时被老板发现了。accessToken 的存在,保证了登录的正常验证,因为 accessToken 的过期时间比较短,所以也可以保证账号的安全性。refreshToken 的存在,保证了小金无需在短时间内反复的登录来保持 Token 的有效性,同时也保证了活跃用户的登录状态可以一直延续而不需要重新登录,反复刷新也防止了某些不怀好意的人获取 refreshToken 后对用户账号进行不良操作。


一图胜千言:


image-20230604084837740


项目准备


项目采用 Spring Boot 3 + Spring Security + JWT + MyBatis-Plus + Lombok 进行搭建。


创建数据库


user 表


image-20230603220205094


token 表


在实际中应该把 token 信息保存到 redis


image-20230603220333914


创建 Spring Boot 项目


创建一个 Spring Boot 3 项目,一定要选择 Java 17 或者 Java 19


引入依赖


<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-securityartifactId>
<version>3.0.4version>
dependency>

<dependency>
<groupId>io.jsonwebtokengroupId>
<artifactId>jjwt-apiartifactId>
<version>0.11.5version>
dependency>
<dependency>
<groupId>io.jsonwebtokengroupId>
<artifactId>jjwt-implartifactId>
<version>0.11.5version>
dependency>
<dependency>
<groupId>io.jsonwebtokengroupId>
<artifactId>jjwt-jacksonartifactId>
<version>0.11.5version>
dependency>

编写配置文件


server:
port: 8417
spring:
application:
name: Spring Boot 3 + Spring Security + JWT + OpenAPI3
datasource:
url: jdbc:mysql://localhost:3306/w_admin
username: root
password: jcjl417
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
table-prefix: t_
id-type: auto
type-aliases-package: com.record.security.entity
mapper-locations: classpath:mapper/*.xml
application:
security:
jwt:
secret-key: VUhJT0pJT0hVWUlHRFVGVFdPSVJISVVHWUZHVkRVR0RISVVIREJZI1VJSEZTVUdZR0ZTVVk=
expiration: 86400000 # 1天
refresh-token:
expiration: 604800000 # 7 天
springdoc:
swagger-ui:
path: /docs.html
tags-sorter: alpha
operations-sorter: alpha
api-docs:
path: /v3/api-docs

项目实现


准备项目所需要的一系列代码,如 entity、controller 、service、mapper 等


系统角色 Role


定义一个角色(Role)枚举,详细代码参考文章结尾处的项目源码


public enum Role {

// 用户
USER(Collections.emptySet()),
// 一线人员
CHASER( ... ),
// 部门主管
SUPERVISOR( ... ),
// 系统管理员
ADMIN( ... ),
;

@Getter
private final Set permissions;

public List getAuthorities() {
var authorities = getPermissions()
.stream()
.map(permission -> new SimpleGrantedAuthority(permission.getPermission()))
.collect(Collectors.toList());
authorities.add(new SimpleGrantedAuthority("ROLE_" + this.name()));
return authorities;
}
}

User 实现 UserDetails


温馨提示:


由于 Spring Security 源码设计的时候 ,将用户名和密码属性定义为 username 和 password,所以我们看到的大部分教程都会遵循源码中的方式,习惯性的将用户名定义为 username,密码定义为 password。


其实我们大可不必遵守这个规则,在我的系统中使用邮箱登录,也即是将邮箱(email)作为 Security 中的用户名(username),那么我必须要将用户输入的 email 作为 username 来存放,这会使我感到非常的不适,因为我的系统中正真的 username 将会 用另外一个单词来命名。


如何避免登录时的字段必须设置为 username 和 password 呢?



重写 getter方法, 只有你的系统中登录的用户名和密码属性不是 username 和 password 的情况下 ,你进行重写才会看到下面红色框中的提示。


202306032035283

重写 username 和 password 的 getter方法


@Override
public String getUsername() {
return email;
}

@Override
public String getPassword() {
return password;
}

Security 配置文件



需要注意的是 WebSecurityConfigurerAdapter 在 Spring Security 中已经被弃用和移除


下面将采用新的配置文件



@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
@EnableMethodSecurity
public class SecurityConfiguration {

private final JwtAuthenticationFilter jwtAuthFilter;
private final AuthenticationProvider authenticationProvider;
private final LogoutHandler logoutHandler;
private final RestAuthorizationEntryPoint restAuthorizationEntryPoint;
private final RestfulAccessDeniedHandler restfulAccessDeniedHandler;

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.csrf()
.disable()
.authorizeHttpRequests()
.requestMatchers(
"/api/v1/auth/**",
"/api/v1/test/**",
"/v2/api-docs",
"/v3/api-docs",
"/v3/api-docs/**",
"/swagger-resources",
"/swagger-resources/**",
"/configuration/ui",
"/configuration/security",
"/swagger-ui/**",
"/doc.html",
"/webjars/**",
"/swagger-ui.html",
"/favicon.ico"
).permitAll()
.requestMatchers("/api/v1/supervisor/**").hasAnyRole(SUPERVISOR.name(), ADMIN.name())

.requestMatchers(GET, "/api/v1/supervisor/**").hasAnyAuthority(SUPERVISOR_READ.name(), ADMIN_READ.name())
.requestMatchers(POST, "/api/v1/supervisor/**").hasAnyAuthority(SUPERVISOR_CREATE.name(), ADMIN_CREATE.name())
.requestMatchers(PUT, "/api/v1/supervisor/**").hasAnyAuthority(SUPERVISOR_UPDATE.name(), ADMIN_UPDATE.name())
.requestMatchers(DELETE, "/api/v1/supervisor/**").hasAnyAuthority(SUPERVISOR_DELETE.name(), ADMIN_DELETE.name())

.requestMatchers("/api/v1/chaser/**").hasRole(CHASER.name())

.requestMatchers(GET, "/api/v1/chaser/**").hasAuthority(CHASER_READ.name())
.requestMatchers(POST, "/api/v1/chaser/**").hasAuthority(CHASER_CREATE.name())
.requestMatchers(PUT, "/api/v1/chaser/**").hasAuthority(CHASER_UPDATE.name())
.requestMatchers(DELETE, "/api/v1/chaser/**").hasAuthority(CHASER_DELETE.name())

.anyRequest()
.authenticated()
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authenticationProvider(authenticationProvider)
//添加jwt 登录授权过滤器
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.logout()
.logoutUrl("/api/v1/auth/logout")
.addLogoutHandler(logoutHandler)
.logoutSuccessHandler((request, response, authentication) -> SecurityContextHolder.clearContext())

;
//添加自定义未授权和未登录结果返回
http.exceptionHandling()
.accessDeniedHandler(restfulAccessDeniedHandler)
.authenticationEntryPoint(restAuthorizationEntryPoint);

return http.build();
}
}

OpenApi 配置文件


OpenApi 依赖


<dependency>
<groupId>org.springdocgroupId>
<artifactId>springdoc-openapi-starter-webmvc-uiartifactId>
<version>2.1.0version>
dependency>

OpenApiConfig 配置


OpenApi3 生成接口文档,主要配置如下



  • Api Gr0up(分组)

  • Bearer Authorization(认证)

  • Customer(自定义请求头等)


@Configuration
public class OpenApiConfig {

@Bean
public OpenAPI customOpenAPI(){
return new OpenAPI()
.info(info())
.externalDocs(externalDocs())
.components(components())
.addSecurityItem(securityRequirement())
;
}

private Info info(){
return new Info()
.title("京茶吉鹿的 Demo")
.version("v0.0.1")
.description("Spring Boot 3 + Spring Security + JWT + OpenAPI3")
.license(new License()
.name("Apache 2.0") // The Apache License, Version 2.0
.url("https://www.apache.org/licenses/LICENSE-2.0.html"))
.contact(new Contact()
.name("京茶吉鹿")
.url("http://localost:8417")
.email("jc.top@qq.com"))
.termsOfService("http://localhost:8417")
;
}

private ExternalDocumentation externalDocs() {
return new ExternalDocumentation()
.description("京茶吉鹿的开放文档")
.url("http://localhost:8417/docs");
}

private Components components(){
return new Components()
.addSecuritySchemes("Bearer Authorization",
new SecurityScheme()
.name("Bearer 认证")
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")
.in(SecurityScheme.In.HEADER)
)
.addSecuritySchemes("Basic Authorization",
new SecurityScheme()
.name("Basic 认证")
.type(SecurityScheme.Type.HTTP)
.scheme("basic")
)
;

}

private SecurityRequirement securityRequirement() {
return new SecurityRequirement()
.addList("Bearer Authorization");
}

private List security(Components components) {
return components.getSecuritySchemes()
.keySet()
.stream()
.map(k -> new SecurityRequirement().addList(k))
.collect(Collectors.toList());
}


/**
* 通用接口
*
@return
*/

@Bean
public Gr0upedOpenApi publicApi(){
return Gr0upedOpenApi.builder()
.group("身份认证")
.pathsToMatch("/api/v1/auth/**")
// 为指定组设置请求头
// .addOperationCustomizer(operationCustomizer())
.build();
}

/**
* 一线人员
*
@return
*/

@Bean
public Gr0upedOpenApi chaserApi(){
return Gr0upedOpenApi.builder()
.group("一线人员")
.pathsToMatch("/api/v1/chaser/**",
"/api/v1/experience/search/**",
"/api/v1/log/**",
"/api/v1/contact/**",
"/api/v1/admin/user/update")
.pathsToExclude("/api/v1/experience/search/id")
.build();
}

/**
* 部门主管
*
@return
*/

@Bean
public Gr0upedOpenApi supervisorApi(){
return Gr0upedOpenApi.builder()
.group("部门主管")
.pathsToMatch("/api/v1/supervisor/**",
"/api/v1/experience/**",
"/api/v1/schedule/**",
"/api/v1/contact/**",
"/api/v1/admin/user/update")
.build();
}

/**
* 系统管理员
*
@return
*/

@Bean
public Gr0upedOpenApi adminApi(){
return Gr0upedOpenApi.builder()
.group("系统管理员")
.pathsToMatch("/api/v1/admin/**")
// .addOpenApiCustomiser(openApi -> openApi.info(new Info().title("京茶吉鹿接口—Admin")))
.build();
}
}

image-20230603224928028


Security 接口赋权的方式


hasRole及hasAuthority的区别?



hasAuthority能通过的身份必须与字符串一模一样,而hasRole能通过的身前缀必须带有ROLE_,同时可以通过两种字符串,一是带有前缀ROLE_,二是不带前缀ROLE_



通过配置文件


在配置文件中指明访问路径的权限


.requestMatchers("/api/v1/supervisor/**").hasAnyRole(SUPERVISOR.name(), ADMIN.name())
.requestMatchers(GET, "/api/v1/supervisor/**").hasAnyAuthority(SUPERVISOR_READ.name(), ADMIN_READ.name())
.requestMatchers(POST, "/api/v1/supervisor/**").hasAnyAuthority(SUPERVISOR_CREATE.name(), ADMIN_CREATE.name())
.requestMatchers(PUT, "/api/v1/supervisor/**").hasAnyAuthority(SUPERVISOR_UPDATE.name(), ADMIN_UPDATE.name())
.requestMatchers(DELETE, "/api/v1/supervisor/**").hasAnyAuthority(SUPERVISOR_DELETE.name(), ADMIN_DELETE.name())


.requestMatchers("/api/v1/chaser/**").hasRole(CHASER.name())
.requestMatchers(GET, "/api/v1/chaser/**").hasAuthority(CHASER_READ.name())
.requestMatchers(POST, "/api/v1/chaser/**").hasAuthority(CHASER_CREATE.name())
.requestMatchers(PUT, "/api/v1/chaser/**").hasAuthority(CHASER_UPDATE.name())
.requestMatchers(DELETE, "/api/v1/chaser/**").hasAuthority(CHASER_DELETE.name())

通过注解


@RestController
@RequestMapping("/api/v1/admin")
@PreAuthorize("hasRole('ADMIN')")
@Tag(name = "系统管理员权限测试")
public class AdminController {

@GetMapping
@PreAuthorize("hasAuthority('admin:read')")
public String get() {
return "GET |==| AdminController";
}


@PostMapping
@PreAuthorize("hasAuthority('admin:create')")
public String post() {
return "POST |==| AdminController";
}
}

测试


我们登录认证成功后,系统会为我们返回 access_token 和 refresh_token。


image-20230604082145598




作者:京茶吉鹿
来源:juejin.cn/post/7241399184594993208
收起阅读 »

一个巧妙的分库分表设计:异构索引表

前言 最近计划参与一个换书活动,翻到《企业IT架构转型之道阿里巴巴中台战略思想与架构实战》这本书时,回想起令我印象比较深刻的一个知识点:“异构索引表”,所以在此记录并分享,和大家共同学习交流。 异构索引表的作用 如果《一致性哈希在分库分表的应用》说的是分库分表...
继续阅读 »

前言


最近计划参与一个换书活动,翻到《企业IT架构转型之道阿里巴巴中台战略思想与架构实战》这本书时,回想起令我印象比较深刻的一个知识点:“异构索引表”,所以在此记录并分享,和大家共同学习交流。


异构索引表的作用


如果《一致性哈希在分库分表的应用》说的是分库分表的方法和策略,那么本文所探讨的“异构索引表”,则是在实施分库分表过程中一个非常巧妙的设计,可以有效的解决分库分表的查询问题。


分库分表的查询问题


问题说明


在哈希分库分表时,为了避免分布不均匀造成的“数据倾斜”,通常会选择一些数据唯一的字段进行哈希操作,比如ID。


以订单表为例,通常有(id、uid、status、amount)等字段,通过id进行哈希取模运算分库分表之后,效果如下图


哈希分库分表效果


这样分库分表的方法没有问题,但是,在后期的开发和维护过程中,可能会存在潜在的问题。


举个例子:现在要查询uid为1的记录,应该去哪个表或库去查询?


对于用户来讲,这个场景可以说是非常频繁的。


这个时候就会发现,要想查询uid为1的记录,只能去所有的库或分表上进行查询,也就是所谓的“广播查询”。


整个查询过程大概是这样的


分库分表查询


性能问题


显然,整个查询过程需要进行全库扫描,涉及到多次的网络数据传输,一定会导致查询速度的降低和延迟的增加


数据聚合问题


另外,当这个用户有成千上万条数据时,不得已要在一个节点进行排序、分页、聚合等计算操作,需要消耗大量的计算资源和内存空间。对系统造成的负担也会影响查询性能。


这是一个非常典型的“事务边界大”的案例,即“一条SQL到所有的数据库去执行”。



那么如何解决这一痛点?



解决分库分表的查询问题


本文重点:“异构索引表”是可以解决这个问题的。


引入异构索引表


简单来说,“异构索引表”是一个拿空间换时间的设计。具体如下:


添加订单数据时,除了根据订单ID进行哈希取模运算将订单数据维护到对应的表中,还要对uid进行哈希取模运算,将uid和订单id维护在另一张表中,如图所示。


异构索引表


引入“异构索引表”后,因为同一个uid经过哈希取模运算后得到的结果是一致的,所以,该uid所有的订单id也一定会被分布到同一张user_order表中。


当查询uid为1的订单记录时,就可以有效地解决数据聚合存在的计算资源消耗全库扫描的低效问题了。


接下来,通过查询过程,看看这两个问题是怎么解决的。


引入后的查询过程


引入“异构索引表”后,查询uid为1的订单记录时,具体过程分为以下几步:



  1. 应用向中间件发送select * from order where uid = 1,请求查询uid为1的订单记录。

  2. 中间件根据uid路由到“异构索引表”:user_order,获得该uid相关的订单ID列表(排序、分页可以在此sql操作)。

  3. 中间件根据返回的订单ID,再次准确路由到对应的订单表:order

  4. 中间件将分散的订单数据进行聚合返回给应用。


引入异构索引表查询


看上去引入“异构索引表”之后,多了一个查询步骤,但换来的是:



  1. 根据订单ID准确路由到订单表,避免了全库扫描。

  2. user_order表进行了排序、分页等操作,避免大量数据回到中间件去计算。


异构索引表解决不了的场景


“异构索引表”只适合简单的分库分表查询场景,如果存在复杂的查询场景,还是需要借助搜索引擎来实现。


总结


异构索引表作为一种巧妙的设计,避免了分库分表查询存在的两个问题:全库扫描不必要的计算资源消耗


但是,异构索引表并不适用所有场景,对于复杂的查询场景可能需要结合其他技术或策略来解决问题。


作者:王二蛋呀
来源:juejin.cn/post/7372070947820109851
收起阅读 »

表设计的18条军规

前言 对于后端开发同学来说,访问数据库,是代码中必不可少的一个环节。 系统中收集到用户的核心数据,为了安全性,我们一般会存储到数据库,比如:mysql,oracle等。 后端开发的日常工作,需要不断的建库和建表,来满足业务需求。 通常情况下,建库的频率比建表要...
继续阅读 »

前言


对于后端开发同学来说,访问数据库,是代码中必不可少的一个环节。


系统中收集到用户的核心数据,为了安全性,我们一般会存储到数据库,比如:mysql,oracle等。


后端开发的日常工作,需要不断的建库和建表,来满足业务需求。


通常情况下,建库的频率比建表要低很多,所以,我们这篇文章主要讨论建表相关的内容。


如果我们在建表的时候不注意细节,等后面系统上线之后,表的维护成本变得非常高,而且很容易踩坑。


今天就跟大家一起聊聊,数据库建表的18个小技巧。


文章中介绍的很多细节,我在工作中踩过坑,并且实践过的,非常有借鉴意义,希望对你会有所帮助。


图片


1.名字


建表的时候,给字段索引起个好名字,真的太重要了。


1.1 见名知意


名字就像字段索引的一张脸,可以给人留下第一印象。


好的名字,言简意赅,见名知意,让人心情愉悦,能够提高沟通和维护成本。


坏的名字,模拟两可,不知所云。而且显得杂乱无章,看得让人抓狂。


反例:


用户名称字段定义成:yong_hu_ming、用户_name、name、user_name_123456789

你看了可能会一脸懵逼,这是什么骚操作?


正例:


用户名称字段定义成:user_name


温馨提醒一下,名字也不宜过长,尽量控制在30个字符以内。



1.2 大小写


名字尽量都用小写字母,因为从视觉上,小写字母更容易让人读懂。


反例:


字段名:PRODUCT_NAME、PRODUCT_name

全部大写,看起来有点不太直观。而一部分大写,一部分小写,让人看着更不爽。


正例:


字段名:product_name

名字还是使用全小写字母,看着更舒服。


1.3 分隔符


很多时候,名字为了让人好理解,有可能会包含多个单词。


那么,多个单词间的分隔符该用什么呢?


反例:


字段名:productname、productName、product name、product@name

单词间没有分隔,或者单词间用驼峰标识,或者单词间用空格分隔,或者单词间用@分隔,这几种方式都不太建议。


正例:


字段名:product_name

强烈建议大家在单词间用_分隔。


1.4 表名


对于表名,在言简意赅,见名知意的基础之上,建议带上业务前缀


如果是订单相关的业务表,可以在表名前面加个前缀:order_


例如:order_pay、order_pay_detail等。


如果是商品相关的业务表,可以在表名前面加个前缀:product_


例如:product_spu,product_sku等。


这样做的好处是为了方便归类,把相同业务的表,可以非常快速的聚集到一起。


另外,还有有个好处是,如果哪天有非订单的业务,比如:金融业务,也需要建一个名字叫做pay的表,可以取名:finance_pay,就能非常轻松的区分。


这样就不会出现同名表的情况。


1.5 字段名称


字段名称是开发人员发挥空间最大,但也最容易发生混乱的地方。


比如有些表,使用flag表示状态,另外的表用status表示状态。


可以统一一下,使用status表示状态。


如果一个表使用了另一个表的主键,可以在另一张表的名后面,加_id_sys_no,例如:


在product_sku表中有个字段,是product_spu表的主键,这时候可以取名:product_spu_id或product_spu_sys_no。


还有创建时间,可以统一成:create_time,修改时间统一成:update_time。


删除状态固定为:delete_status。


其实还有很多公共字段,在不同的表之间,可以使用全局统一的命名规则,定义成相同的名称,以便于大家好理解。


1.6 索引名


在数据库中,索引有很多种,包括:主键、普通索引、唯一索引、联合索引等。


每张表的主键只有一个,一般使用:id或者sys_no命名。


普通索引和联合索引,其实是一类。在建立该类索引时,可以加ix_前缀,比如:ix_product_status。


唯一索引,可以加ux_前缀,比如:ux_product_code。


2.字段类型


在设计表时,我们在选择字段类型时,可发挥空间很大。


时间格式的数据有:date、datetime和timestamp等等可以选择。


字符类型的数据有:varchar、char、text等可以选择。


数字类型的数据有:int、bigint、smallint、tinyint等可以选择。


说实话,选择很多,有时候是一件好事,也可能是一件坏事。


如何选择一个合适的字段类型,变成了我们不得不面对的问题。


如果字段类型选大了,比如:原本只有1-10之间的10个数字,结果选了bigint,它占8个字节。


其实,1-10之间的10个数字,每个数字1个字节就能保存,选择tinyint更为合适。


这样会白白浪费7个字节的空间。


如果字段类型择小了,比如:一个18位的id字段,选择了int类型,最终数据会保存失败。


所以选择一个合适的字段类型,还是非常重要的一件事情。


以下原则可以参考一下:



  1. 尽可能选择占用存储空间小的字段类型,在满足正常业务需求的情况下,从小到大,往上选。

  2. 如果字符串长度固定,或者差别不大,可以选择char类型。如果字符串长度差别较大,可以选择varchar类型。

  3. 是否字段,可以选择bit类型。

  4. 枚举字段,可以选择tinyint类型。

  5. 主键字段,可以选择bigint类型。

  6. 金额字段,可以选择decimal类型。

  7. 时间字段,可以选择timestamp或datetime类型。


3.字段长度


前面我们已经定义好了字段名称,选择了合适的字段类型,接下来,需要重点关注的是字段长度了。


比如:varchar(20),biginit(20)等。


那么问题来了,varchar代表的是字节长度,还是字符长度呢?


答:在mysql中除了varcharchar是代表字符长度之外,其余的类型都是代表字节长度。


biginit(n) 这个n表示什么意思呢?


假如我们定义的字段类型和长度是:bigint(4),bigint实际长度是8个字节。


现在有个数据a=1,a显示4个字节,所以在不满4个字节时前面填充0(前提是该字段设置了zerofill属性),比如:0001。


当满了4个字节时,比如现在数据是a=123456,它会按照实际的长度显示,比如:123456。


但需要注意的是,有些mysql客户端即使满了4个字节,也可能只显示4个字节的内容,比如会显示成:1234。


所以bigint(4),这里的4表示显示的长度为4个字节,实际长度还是占8个字节。


4.字段个数


我们在建表的时候,一定要对字段个数做一些限制。


我之前见过有人创建的表,有几十个,甚至上百个字段,表中保存的数据非常大,查询效率很低。


如果真有这种情况,可以将一张大表拆成多张小表,这几张表的主键相同。


建议每表的字段个数,不要超过20个。


5. 主键


在创建表时,一定要创建主键


因为主键自带了主键索引,相比于其他索引,主键索引的查询效率最高,因为它不需要回表。


此外,主键还是天然的唯一索引,可以根据它来判重。


单个数据库中,主键可以通过AUTO_INCREMENT,设置成自动增长的。


但在分布式数据库中,特别是做了分库分表的业务库中,主键最好由外部算法(比如:雪花算法)生成,它能够保证生成的id是全局唯一的。


除此之外,主键建议保存跟业务无关的值,减少业务耦合性,方便今后的扩展。


不过我也见过,有些一对一的表关系,比如:用户表和用户扩展表,在保存数据时是一对一的关系。


这样,用户扩展表的主键,可以直接保存用户表的主键。


6.存储引擎


mysql8以前的版本,默认的存储引擎是myisam,而mysql8以后的版本,默认的存储引擎变成了innodb


之前我们还在创建表时,还一直纠结要选哪种存储引擎?


myisam的索引和数据分开存储,而有利于查询,但它不支持事务和外键等功能。


innodb虽说查询性能,稍微弱一点,但它支持事务和外键等,功能更强大一些。


以前的建议是:读多写少的表,用myisam存储引擎。而写多读多的表,用innodb。


但虽说mysql对innodb存储引擎性能的不断优化,现在myisam和innodb查询性能相差已经越来越小。


所以,建议我们在使用mysql8以后的版本时,直接使用默认的innodb存储引擎即可,无需额外修改存储引擎。


7. NOT NULL


在创建字段时,需要选择该字段是否允许为NULL


我们在定义字段时,应该尽可能明确该字段NOT NULL


为什么呢?


我们主要以innodb存储引擎为例,myisam存储引擎没啥好说的。


主要有以下原因:



  1. 在innodb中,需要额外的空间存储null值,需要占用更多的空间。

  2. null值可能会导致索引失效。

  3. null值只能用is null或者is not null判断,用=号判断永远返回false。


因此,建议我们在定义字段时,能定义成NOT NULL,就定义成NOT NULL。


但如果某个字段直接定义成NOT NULL,万一有些地方忘了给该字段写值,就会insert不了数据。


这也算合理的情况。


但有一种情况是,系统有新功能上线,新增了字段。上线时一般会先执行sql脚本,再部署代码。


由于老代码中,不会给新字段赋值,则insert数据时,也会报错。


由此,非常有必要给NOT NULL的字段设置默认值,特别是后面新增的字段。


例如:


alter table product_sku add column  brand_id int(10not null default 0;

8.外键


在mysql中,是存在外键的。


外键存在的主要作用是:保证数据的一致性完整性


例如:


create table class (
  id int(10primary key auto_increment,
  cname varchar(15)
);

有个班级表class。


然后有个student表:


create table student(
  id int(10primary key auto_increment,
  name varchar(15not null,
  gender varchar(10not null,
  cid int,
  foreign key(cid) references class(id)
);

其中student表中的cid字段,保存的class表的id,这时通过foreign key增加了一个外键。


这时,如果你直接通过student表的id删除数据,会报异常:


a foreign key constraint fails

必须要先删除class表对于的cid那条数据,再删除student表的数据才行,这样能够保证数据的一致性和完整性。



顺便说一句:只有存储引擎是innodb时,才能使用外键。



如果只有两张表的关联还好,但如果有十几张表都建了外键关联,每删除一次主表,都需要同步删除十几张子表,很显然性能会非常差。


因此,互联网系统中,一般建议不使用外键。因为这类系统更多的是为了性能考虑,宁可牺牲一点数据一致性和完整性。


除了外键之外,存储过程触发器也不太建议使用,他们都会影响性能。


9. 索引


在建表时,除了指定主键索引之外,还需要创建一些普通索引


例如:


create table product_sku(
  id int(10primary key auto_increment,
  spu_id int(10not null,
  brand_id int(10not null,
  name varchar(15not null
);

在创建商品表时,使用spu_id(商品组表)和brand_id(品牌表)的id。


像这类保存其他表id的情况,可以增加普通索引:


create table product_sku (
  id int(10primary key auto_increment,
  spu_id int(10not null,
  brand_id int(10not null,
  name varchar(15not null,
  KEY `ix_spu_id` (`spu_id`USING BTREE,
  KEY `ix_brand_id` (`brand_id`USING BTREE
);

后面查表的时候,效率更高。


但索引字段也不能建的太多,可能会影响保存数据的效率,因为索引需要额外的存储空间。


建议单表的索引个数不要超过:5个。


如果在建表时,发现索引个数超过5个了,可以删除部分普通索引,改成联合索引


顺便说一句:在创建联合索引的时候,需要使用注意最左匹配原则,不然,建的联合索引效率可能不高。


对于数据重复率非常高的字段,比如:状态,不建议单独创建普通索引。因为即使加了索引,如果mysql发现全表扫描效率更高,可能会导致索引失效。


如果你对索引失效问题比较感兴趣,可以看看我的另一篇文章《聊聊索引失效的10种场景,太坑了》,里面有非常详细的介绍。


10.时间字段


时间字段的类型,我们可以选择的范围还是比较多的,目前mysql支持:date、datetime、timestamp、varchar等。


varchar类型可能是为了跟接口保持一致,接口中的时间类型是String。


但如果哪天我们要通过时间范围查询数据,效率会非常低,因为这种情况没法走索引。


date类型主要是为了保存日期,比如:2020-08-20,不适合保存日期和时间,比如:2020-08-20 12:12:20。


datetimetimestamp类型更适合我们保存日期和时间


但它们有略微区别。



  • timestamp:用4个字节来保存数据,它的取值范围为1970-01-01 00:00:01 UTC ~ 2038-01-19 03:14:07。此外,它还跟时区有关。

  • datetime:用8个字节来保存数据,它的取值范围为1000-01-01 00:00:00 ~ 9999-12-31 23:59:59。它跟时区无关。


优先推荐使用datetime类型保存日期和时间,可以保存的时间范围更大一些。



温馨提醒一下,在给时间字段设置默认值是,建议不要设置成:0000-00-00 00:00:00,不然查询表时可能会因为转换不了,而直接报错。



11.金额字段


mysql中有多个字段可以表示浮点数:float、double、decimal等。


floatdouble可能会丢失精度,因此推荐大家使用decimal类型保存金额。


一般我们是这样定义浮点数的:decimal(m,n)。


其中n是指小数的长度,而m是指整数加小数的总长度。


假如我们定义的金额类型是这样的:decimal(10,2),则表示整数长度是8位,并且保留2位小数。


12. json字段


我们在设计表结构时,经常会遇到某个字段保存的数据值不固定的需求。


举个例子,比如:做异步excel导出功能时,需要在异步任务表中加一个字段,保存用户通过前端页面选择的查询条件,每个用户的查询条件可能都不一样。


这种业务场景,使用传统的数据库字段,不太好实现。


这时候就可以使用MySQL的json字段类型了,可以保存json格式的结构化数据。


保存和查询数据都是非常方便的。


MySQL还支持按字段名称或者字段值,查询json中的数据。


13.唯一索引


唯一索引在我们实际工作中,使用频率相当高。


你可以给单个字段,加唯一索引,比如:组织机构code。


也可以给多个字段,加一个联合的唯一索引,比如:分类编号、单位、规格等。


单个的唯一索引还好,但如果是联合的唯一索引,字段值出现null时,则唯一性约束可能会失效。


关于唯一索引失效的问题,感兴趣的小伙伴可以看看我的另一篇文章《明明加了唯一索引,为什么还是产生重复数据?》。



创建唯一索引时,相关字段一定不能包含null值,否则唯一性会失效。



14.字符集


mysql中支持的字符集有很多,常用的有:latin1、utf-8、utf8mb4、GBK等。


这4种字符集情况如下:图片


latin1容易出现乱码问题,在实际项目中使用比较少。


GBK支持中文,但不支持国际通用字符,在实际项目中使用也不多。


从目前来看,mysql的字符集使用最多的还是:utf-8utf8mb4


其中utf-8占用3个字节,比utf8mb4的4个字节,占用更小的存储空间。


但utf-8有个问题:即无法存储emoji表情,因为emoji表情一般需要4个字节。


由此,使用utf-8字符集,保存emoji表情时,数据库会直接报错。


所以,建议在建表时字符集设置成:utf8mb4,会省去很多不必要的麻烦。


15. 排序规则


不知道,你关注过没,在mysql中创建表时,有个COLLATE参数可以设置。


例如:


CREATE TABLE `order` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `code` varchar(20COLLATE utf8mb4_bin NOT NULL,
  `name` varchar(30COLLATE utf8mb4_bin NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `un_code` (`code`),
  KEY `un_code_name` (`code`,`name`USING BTREE,
  KEY `idx_name` (`name`)
ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin

它是用来设置排序规则的。


字符排序规则跟字符集有关,比如:字符集如果是utf8mb4,则字符排序规则也是以:utf8mb4_开头的,常用的有:utf8mb4_general_ciutf8mb4_bin等。


其中utf8mb4_general_ci排序规则,对字母的大小写不敏感。说得更直白一点,就是不区分大小写。


而utf8mb4_bin排序规则,对字符大小写敏感,也就是区分大小写。


说实话,这一点还是非常重要的。


假如order表中现在有一条记录,name的值是大写的YOYO,但我们用小写的yoyo去查,例如:


select * from order where name='yoyo';

如果字符排序规则是utf8mb4_general_ci,则可以查出大写的YOYO的那条数据。


如果字符排序规则是utf8mb4_bin,则查不出来。


由此,字符排序规则一定要根据实际的业务场景选择,否则容易出现问题。


最近就业形式比较困难,为了感谢各位小伙伴对苏三一直以来的支持,我特地创建了一些工作内推群, 看看能不能帮助到大家。


你可以在群里发布招聘信息,也可以内推工作,也可以在群里投递简历找工作,也可以在群里交流面试或者工作的话题。


image.png


进群方式


添加苏三的私人微信:su_san_java,备注:掘金+所在城市,即可加入。


16.大字段


我们在创建表时,对一些特殊字段,要额外关注,比如:大字段,即占用较多存储空间的字段。


比如:用户的评论,这就属于一个大字段,但这个字段可长可短。


但一般会对评论的总长度做限制,比如:最多允许输入500个字符。


如果直接定义成text类型,可能会浪费存储空间,所以建议将这类字段定义成varchar类型的存储效率更高。


当然,我还见过更大的字段,即该字段直接保存合同数据。


一个合同可能会占几Mb


在mysql中保存这种数据,从系统设计的角度来说,本身就不太合理。


像合同这种非常大的数据,可以保存到mongodb中,然后在mysql的业务表中,保存mongodb表的id。


17.冗余字段


我们在设计表的时候,为了性能考虑,提升查询速度,有时可以冗余一些字段。


举个例子,比如:订单表中一般会有userId字段,用来记录用户的唯一标识。


但很多订单的查询页面,或者订单的明细页面,除了需要显示订单信息之外,还需要显示用户ID和用户名称。


如果订单表和用户表的数据量不多,我们可以直接用userId,将这两张表join起来,查询出用户名称。


但如果订单表和用户表的数据量都非常多,这样join是比较消耗查询性能的。


这时候我们可以通过冗余字段的方案,来解决性能问题。


我们可以在订单表中,可以再加一个userName字段,在系统创建订单时,将userId和userName同时写值。


当然订单表中历史数据的userName是空的,可以刷一下历史数据。


这样调整之后,后面只需要查询订单表,即可查询出我们所需要的数据。


不过冗余字段的方案,有利也有弊。


对查询性能有利。


但需要额外的存储空间,还可能会有数据不一致的情况,比如用户名称修改了。


我们在实际业务场景中,需要综合评估,冗余字段方案不适用于所有业务场景。


18.注释


我们在做表设计的时候,一定要把表和相关字段的注释加好。


例如下面这样的:


CREATE TABLE `sys_dept` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT 'ID',
  `name` varchar(30NOT NULL COMMENT '名称',
  `pid` bigint NOT NULL COMMENT '上级部门',
  `valid_status` tinyint(1NOT NULL DEFAULT 1 COMMENT '有效状态 1:有效 0:无效',
  `create_user_id` bigint NOT NULL COMMENT '创建人ID',
  `create_user_name` varchar(30NOT NULL COMMENT '创建人名称',
  `create_time` datetime(3DEFAULT NULL COMMENT '创建日期',
  `update_user_id` bigint DEFAULT NULL COMMENT '修改人ID',
  `update_user_name` varchar(30)  DEFAULT NULL COMMENT '修改人名称',
  `update_time` datetime(3DEFAULT NULL COMMENT '修改时间',
  `is_del` tinyint(1DEFAULT '0' COMMENT '是否删除 1:已删除 0:未删除',
  PRIMARY KEY (`id`USING BTREE,
  KEY `index_pid` (`pid`USING BTREE
ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='部门';

表和字段的注释,都列举的非常详细。


特别是有些状态类型的字段,比如:valid_status字段,该字段表示有效状态, 1:有效 0:无效。


让人可以一目了然,表和字段是干什么用的,字段的值可能有哪些。


最怕的情况是,你在表中创建了很多status字段,每个字段都有1、2、3、4、5、6、7、8、9等多个值。


没有写什么注释。


谁都不知道1代表什么含义,2代表什么含义,3代表什么含义。


可能刚开始你还记得。


但系统上线使用一年半载之后,可能连你自己也忘记了这些status字段,每个值的具体含义了,埋下了一个巨坑。


由此,我们在做表设计时,一定要写好相关的注释,并且经常需要更新这些注释。




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

接口设计的18条军规

大家好,我是苏三,又跟大家见面了。 前言 之前写过一篇文章《表设计的18条军规》,发表之前,在全网广受好评。 今天延续设计的话题,给大家总结了接口设计的18条军规,希望对你会有所帮助。 1. 签名 为了防止API接口中的数据被篡改,很多时候我们需要对API接...
继续阅读 »

大家好,我是苏三,又跟大家见面了。


前言


之前写过一篇文章《表设计的18条军规》,发表之前,在全网广受好评。


今天延续设计的话题,给大家总结了接口设计的18条军规,希望对你会有所帮助。


图片


1. 签名


为了防止API接口中的数据被篡改,很多时候我们需要对API接口做签名


接口请求方将请求参数 + 时间戳 + 密钥拼接成一个字符串,然后通过md5等hash算法,生成一个前面sign。


然后在请求参数或者请求头中,增加sign参数,传递给API接口。


API接口的网关服务,获取到该sign值,然后用相同的请求参数 + 时间戳 + 密钥拼接成一个字符串,用相同的m5算法生成另外一个sign,对比两个sign值是否相等。


如果两个sign相等,则认为是有效请求,API接口的网关服务会将给请求转发给相应的业务系统。


如果两个sign不相等,则API接口的网关服务会直接返回签名错误。


问题来了:签名中为什么要加时间戳?


答:为了安全性考虑,防止同一次请求被反复利用,增加了密钥没破解的可能性,我们必须要对每次请求都设置一个合理的过期时间,比如:15分钟。


这样一次请求,在15分钟之内是有效的,超过15分钟,API接口的网关服务会返回超过有效期的异常提示。


目前生成签名中的密钥有两种形式:


一种是双方约定一个固定值privateKey。


另一种是API接口提供方给出AK/SK两个值,双方约定用SK作为签名中的密钥。AK接口调用方作为header中的accessKey传递给API接口提供方,这样API接口提供方可以根据AK获取到SK,而生成新的sgin。


2. 加密


有些时候,我们的API接口直接传递的非常重要的数据,比如:用户的登录密码、银彳亍卡号、转账金额、用户身-份-证等,如果将这些参数,直接明文,暴露到公网上是非常危险的事情。


由此,我们需要对数据进行加密


比如:用户注册接口,用户输入了用户名和密码之后,需要将密码加密。


我们可以使用AES对称加密算法。


在前端使用公钥对用户密码加密。


然后注册接口中,可以使用密钥解密,做一些业务需求校验。然后再换成其他的加密方式加密,保存到数据库当中。


3. ip白名单


为了进一步加强API接口的安全性,防止接口的签名或者加密被破解了,攻击者可以在自己的服务器上请求该接口。


需求限制请求ip,增加ip白名单


只有在白名单中的ip地址,才能成功请求API接口,否则直接返回无访问权限。


ip白名单也可以加在API网关服务上。


但也要防止公司的内部应用服务器被攻破,这种情况也可以从内部服务器上发起API接口的请求。


这时候就需要增加web防火墙了,比如:ModSecurity等。


4. 限流


如果你的API接口被第三方平台调用了,这就意味着着,调用频率是没法控制的。


第三方平台调用你的API接口时,如果并发量一下子太高,可能会导致你的API服务不可用,接口直接挂掉。


由此,必须要对API接口做限流


限流方法有三种:



  1. 对请求ip做限流:比如同一个ip,在一分钟内,对API接口总的请求次数,不能超过10000次。

  2. 对请求接口做限流:比如同一个ip,在一分钟内,对指定的API接口,请求次数不能超过2000次。

  3. 对请求用户做限流:比如同一个AK/SK用户,在一分钟内,对API接口总的请求次数,不能超过10000次。


我们在实际工作中,可以通过nginxredis或者gateway实现限流的功能。


5. 参数校验


我们需要对API接口做参数校验,比如:校验必填字段是否为空,校验字段类型,校验字段长度,校验枚举值等等。


这样做可以拦截一些无效的请求。


比如在新增数据时,字段长度超过了数据字段的最大长度,数据库会直接报错。


但这种异常的请求,我们完全可以在API接口的前期进行识别,没有必要走到数据库保存数据那一步,浪费系统资源。


有些金额字段,本来是正数,但如果用户传入了负数,万一接口没做校验,可能会导致一些没必要的损失。


还有些状态字段,如果不做校验,用户如果传入了系统中不存在的枚举值,就会导致保存的数据异常。


由此可见,做参数校验是非常有必要的。


在Java中校验数据使用最多的是hiberateValidator框架,它里面包含了@Null、@NotEmpty、@Size、@Max、@Min等注解。


用它们校验数据非常方便。


当然有些日期字段和枚举字段,可能需要通过自定义注解的方式实现参数校验。


6. 统一返回值


我之前调用过别人的API接口,正常返回数据是一种json格式,比如:


{
    "code":0,
    "message":null,
    "data":[{"id":123,"name":"abc"}]
},

签名错误返回的json格式:


{
    "code":1001,
    "message":"签名错误",
    "data":null
}

没有数据权限返回的json格式:


{
    "rt":10,
    "errorMgt":"没有权限",
    "result":null
}

这种是比较坑的做法,返回值中有多种不同格式的返回数据,这样会导致对接方很难理解。


出现这种情况,可能是API网关定义了一直返回值结构,业务系统定义了另外一种返回值结构。如果是网关异常,则返回网关定义的返回值结构,如果是业务系统异常,则返回业务系统的返回值结构。


但这样会导致API接口出现不同的异常时,返回不同的返回值结构,非常不利于接口的维护。


其实这个问题我们可以在设计API网关时解决。


业务系统在出现异常时,抛出业务异常的RuntimeException,其中有个message字段定义异常信息。


所有的API接口都必须经过API网关,API网关捕获该业务异常,然后转换成统一的异常结构返回,这样能统一返回值结构。


7. 统一封装异常


我们的API接口需要对异常进行统一处理。


不知道你有没有遇到过这种场景:有时候在API接口中,需要访问数据库,但表不存在,或者sql语句异常,就会直接把sql信息在API接口中直接返回。


返回值中包含了异常堆栈信息数据库信息错误代码和行数等信息。


如果直接把这些内容暴露给第三方平台,是很危险的事情。


有些不法分子,利用接口返回值中的这些信息,有可能会进行sql注入或者直接脱库,而对我们系统造成一定的损失。


因此非常有必要对API接口中的异常做统一处理,把异常转换成这样:


{
    "code":500,
    "message":"服务器内部错误",
    "data":null
}

返回码code500,返回信息message服务器内部异常


这样第三方平台就知道是API接口出现了内部问题,但不知道具体原因,他们可以找我们排查问题。


我们可以在内部的日志文件中,把堆栈信息、数据库信息、错误代码行数等信息,打印出来。


我们可以在gateway中对异常进行拦截,做统一封装,然后给第三方平台的是处理后没有敏感信息的错误信息。


8. 请求日志


在第三方平台请求你的API接口时,接口的请求日志非常重要,通过它可以快速的分析和定位问题。


我们需要把API接口的请求url、请求参数、请求头、请求方式、响应数据和响应时间等,记录到日志文件中。


最好有traceId,可以通过它串联整个请求的日志,过滤多余的日志。


当然有些时候,请求日志不光是你们公司开发人员需要查看,第三方平台的用户也需要能查看接口的请求日志。


这时就需要把日志落地到数据库,比如:mongodb或者elastic search,然后做一个UI页面,给第三方平台的用户开通查看权限。这样他们就能在外网查看请求日志了,他们自己也能定位一部分问题。


9. 幂等设计


第三方平台极有可能在极短的时间内,请求我们接口多次,比如:在1秒内请求两次。有可能是他们业务系统有bug,或者在做接口调用失败重试,因此我们的API接口需要做幂等设计


也就是说要支持在极短的时间内,第三方平台用相同的参数请求API接口多次,第一次请求数据库会新增数据,但第二次请求以后就不会新增数据,但也会返回成功。


这样做的目的是不会产生错误数据。


我们在日常工作中,可以通过在数据库中增加唯一索引,或者在redis保存requestId和请求参来保证接口幂等性。


对接口幂等性感兴趣的小伙伴,可以看看我的另一篇文章《高并发下如何保证接口的幂等性?》,里面有非常详细的介绍。


10. 限制记录条数


对于对我提供的批量接口,一定要限制请求的记录条数


如果请求的数据太多,很容易造成API接口超时等问题,让API接口变得不稳定。


通常情况下,建议一次请求中的参数,最多支持传入500条记录。


如果用户传入多余500条记录,则接口直接给出提示。


建议这个参数做成可配置的,并且要事先跟第三方平台协商好,避免上线后产生不必要的问题。


对于一次性查询的数据太多的情况,我们需要将接口设计成分页查询返回的。


11. 压测


上线前我们务必要对API接口做一下压力测试,知道各个接口的qps情况。


以便于我们能够更好的预估,需要部署多少服务器节点,对于API接口的稳定性至关重要。


之前虽说对API接口做了限流,但是实际上API接口是否能够达到限制的阀值,这是一个问号,如果不做压力测试,是有很大风险的。


比如:你API接口限流1秒只允许50次请求,但实际API接口只能处理30次请求,这样你的API接口也会处理不过来。


我们在工作中可以用jmeter或者apache benc对API接口做压力测试。


12. 异步处理


一般的API接口的逻辑都是同步处理的,请求完之后立刻返回结果。


但有时候,我们的API接口里面的业务逻辑非常复杂,特别是有些批量接口,如果同步处理业务,耗时会非常长。


这种情况下,为了提升API接口的性能,我们可以改成异步处理


在API接口中可以发送一条mq消息,然后直接返回成功。之后,有个专门的mq消费者去异步消费该消息,做业务逻辑处理。


直接异步处理的接口,第三方平台有两种方式获取到。


第一种方式是:我们回调第三方平台的接口,告知他们API接口的处理结果,很多支付接口就是这么玩的。


第二种方式是:第三方平台通过轮询调用我们另外一个查询状态的API接口,每隔一段时间查询一次状态,传入的参数是之前的那个API接口中的id集合。


13. 数据脱敏


有时候第三方平台调用我们API接口时,获取的数据中有一部分是敏感数据,比如:用户手机号、银彳亍卡号等等。


这样信息如果通过API接口直接保留到外网,是非常不安全的,很容易造成用户隐私数据泄露的问题。


这就需要对部分数据做数据脱敏了。


我们可以在返回的数据中,部分内容用星号代替。


已用户手机号为例:182****887


这样即使数据被泄露了,也只泄露了一部分,不法分子拿到这份数据也没啥用。


14. 完整的接口文档


说实话,一份完整的API接口文档,在双方做接口对接时,可以减少很多沟通成本,让对方少走很多弯路。


接口文档中需要包含如下信息:



  1. 接口地址

  2. 请求方式,比如:post或get

  3. 请求参数和字段介绍

  4. 返回值和字段介绍

  5. 返回码和错误信息

  6. 加密或签名示例

  7. 完整的请求demo

  8. 额外的说明,比如:开通ip白名单。


接口文档中最好能够统一接口和字段名称的命名风格,比如都用驼峰标识命名。


统一字段的类型和长度,比如:id字段用Long类型,长度规定20。status字段用int类型,长度固定2等。


统一时间格式字段,比如:time用String类型,格式为:yyyy-MM-dd HH:mm:ss。


接口文档中写明AK/SK和域名,找某某单独提供等。


最近建了一些高质量的粉丝群,里面可以交流技术,有工作内推,有粉丝福利。


进群方式


添加苏三的私人微信:su_san_java,备注:掘金+粉丝,即可加入。


15. 请求方式


接口支持的请求方式有很多,比如:GET、POST、PUT、DELETE等等。


我们在设计接口的时候,要根据实际情况选择使用哪种请求方式。


实际工作中使用最多的是:GETPOST,这两种请求方式。


如果没有输入参数的接口,可以使用GET请求方式,问题不大。


如果有输入参数的接口,推荐使用POST请求方式,坑更少。


主要原因有下面两点:



  1. POST请求方式更容易扩展参数,特别是在Fegin调用接口的场景下,比如:增加一个参数,调用方可以不用修改代码。而GET请求方式,需要修改代码,否则编译会出错。

  2. GET请求方式的参数,有长度限制,最长是5000个字符,而POST请求方式对参数的长度没有做限制,可以传入更长的参数值。


16. 请求头


对于一些公共的功能,比如:接口的权限验证,或者接口的traceId参数。


我们在设计接口的时候,不用把所有的参数,都放入接口的请求参数中。


有些参数可以放到Header请求头中。


比如:我们需求记录每个请求的traceId,不用在所有接口中都加traceId字段。


而改成让用户在header中传入traceId,在服务端使用统一的拦截器解析header,即可获取该traceId了。


17. 批量


我们在设计接口的时候,无论是查询数据、添加数据、修改数据,还是删除的场景,都应该考虑一下能否设计成批量的。


很多时候,需要通过id查询数据详情,比如:通过订单id,查询订单详情。


如果你的接口只支持,通过一个id,查询一个订单的详情。


那么,后面需要通过多个id,查询多个订单详情的时候,就需要额外增加接口了。


如果你添加数据的接口,只支持一条数据一条数据的添加。


后面,有个job需要一次性添加1000条数据的时候,这时在代码中循环1000次,一个个添加,这种做法效率比较低。


为了让你的接口设计的更加通用,满足更多的业务场景,能设计成批量的,尽量别设计成单个的。


18. 职责单一


我之前见过有些小伙伴设计的接口,在入参中各种条件都支持,在Service层有N多的if...else判断。


而且返回的实体类中,包含了各种场景下的返回值字段,字段很多很全。


接口上线一年之后,自己可能都忘了,在哪些业务场景下,要传入哪些字段,返回值是哪些字段。


这类接口的维护成本非常高,而且又不敢轻易重构,怕改了A业务场景,影响B业务场景的功能,这种接口让人非常痛苦的。


好的接口设计原则是:职责单一


比如用户下单的场景,有web端和移动端。


而每个端都有普通下单和快速下单,两种不同的业务场景。


我们在设计接口的时候,可以将web端和移动端的接口在controller层完全分开。


/web/v1/order
/mobile/v1/order

并且将普通下单和快速下单也分开:


/web/v1/order/create
/web/v1/order/fastCreate
/mobile/v1/order/create
/mobile/v1/order/fastCreate

这样可以设计成4个接口。


业务逻辑更清晰一些,方便后面维护。




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

好烦啊,我真的不想写增删改查了!

大家好,我是程序员鱼皮。 很想吐槽:我真的不想写增删改查这种重复代码了! 大学刚做项目的时候,就在写增删改查,万万没想到 7 年后,还在和增删改查打交道。因为增删改查是任何项目的基础功能,每次带朋友们做新项目时,为了照顾更多同学,我都会带大家把增删改查的代码再...
继续阅读 »

大家好,我是程序员鱼皮。


很想吐槽:我真的不想写增删改查这种重复代码了!


大学刚做项目的时候,就在写增删改查,万万没想到 7 年后,还在和增删改查打交道。因为增删改查是任何项目的基础功能,每次带朋友们做新项目时,为了照顾更多同学,我都会带大家把增删改查的代码再编写并讲解一遍。


不开玩笑地说,我绝对有资格在简历上写 “自己精通增删改查的编写” 了!



相信很多已经在工作中的小伙伴,80% 甚至更多的时间也在天天写增删改查这种重复代码,也会因此感到烦恼。那大家有没有思考过:如何提高写增删改查的效率?让自己有更多时间进步(愉快摸鱼)呢?


其实有很多种方法,鱼皮分享下自己的提效小操作,看看朋友们有没有实践过~


如何提高增删改查的编写效率?


方法 1、复制粘贴


复制粘贴前人的代码是一种最简单直接的方法,估计大多数开发者在实际工作中都是这么干的。



但这种方式存在的问题也很明显,如果对复制的代码本身不够理解,很有可能出现细节错误。而且不同数据表的字段和校验规则是不同的,往往复制后的代码还要经过大量的人工修改。


还有很多 “小迷糊”,经常复制完代码后忘了修改一些变量名称和注释,出现类似下面代码的名场面:


// 帖子接口
class UserController {
}

方法 2、使用模板


一般新项目都是要基于模板开发的,而不是每次都重复编写一大堆的通用代码。比如我之前给编程导航同学编写的 Spring Boot 后端万用模板,内置了用户注册、账号密码登录、公众号登录等通用能力。基于这种模板二次开发,能够大大提高开发效率,也有助于开发同学遵循一致的规范。


模板支持的功能


然而,使用模板也存在一些风险,如果模板本身有功能存在漏洞,那么所有基于这个模板开发的项目可能都会存在风险。而且别人的模板也不是万能的,建议还是根据自己的开发经验,自己沉淀和维护一套模板。对团队来说,沉淀模板是必须要做的事。


方法 3、AI 工具


利用 AI 工具来生成增删改查的代码是一种新兴的方法。只需要甩给 AI 要生成代码的表结构,然后精准地编写要生成的代码要求,就可以让 AI 快速生成了。



这种方式的优点是非常灵活,能帮开发者提供一些灵感;缺点就是对编写 prompt(提示词)的要求会比较高,而且生成后的代码还是得仔细检查一遍的。


方法 4、超级抽象


这是一种更高级别的代码复用方法。通过设计 通用的 数据模型和操作接口,实现用一套代码满足多种不同业务场景下的增删改查操作。


举个例子,如果有帖子、评论、回答等多个资源需要支持用户收藏功能,系统规模不大的情况下,不需要编写 3 张不同的收藏表、并分别编写增删改查代码。而是可以设计 1 张通用的收藏表,通过 type 字段来区分不同类型的资源,从而实现统一的收藏操作。


像点赞、消息通知、日志、数据收集等业务场景,都可以采用这种方式,通过极致的复用完成快速开发。


但也要注意,千万不要把区别较大的功能强行合并到一起,反而会增加开发者的理解成本;而且如果系统数据量较大,分开维护表更有利于系统的性能和稳定性。


方法 5、代码生成器


这也是非常典型的一种提高增删改查效率的方法。后端可以使用 MyBatis X 插件来生成数据模型和数据访问层的 Mapper 代码,前端可以用 OpenAPI 工具生成请求函数和 TS 类型代码等。


不过用别人的生成器难免会出现无法满足需求的情况,生成后的代码一般还是要自己再修改一下的。


所以,我建议可以使用模板引擎技术,自己开发一套更灵活、更适合自己业务的代码生成器。


比如鱼皮给后端万用模板补充了代码生成器功能,使用 FreeMarker 模板引擎技术实现,定制了 Controller、Service、数据包装类的代码模板。用户只需要指定几个参数,就可以在指定位置生成代码了~ 昨天 AI 答题应用平台的开发中,就是用了这个代码生成器,几分钟写好一套功能。



可以在代码小抄阅读生成器的核心实现代码:http://www.codecopy.cn/post/edkpo4 。之前我从 0 到 1 直播带大家开发过一个代码生成器共享平台,感兴趣的同学也可以学习下,保证能把代码生成玩得很熟练~


方法 6、云服务


这种方式也比较新颖了,利用某些云服务提供的现成的数据库和操作数据库的接口,都不需要自己去编写增删改查了!


比如我之前用过的腾讯云开发 Cloudbase,开通服务后,只要在平台上建号数据表,就能自动得到数据管理页面,可以直接通过 HTTP 请求或 SDK 实现增删改查,尤其适合前端同学使用。


但这种方式的缺点也很明显,灵活性相对差了一些,而且会产生一些额外的费用。


所以还是那句话,没有最好的技术,只有最适合自身需求和业务场景的技术。




作者:程序员鱼皮
来源:juejin.cn/post/7369094945154711578
收起阅读 »

😰我被恐吓了,对方扬言要压测我的网站

大家好我是聪,昨天真是水逆,在技术群里交流问题,竟然被人身攻击了!骂的话太难听具体就不加讨论了,人身攻击我可以接受,我接受不了他竟然说要刷我接口!!!!这下激发我的灵感来写一篇如何抵御黑子的压测攻击,还真得要谢谢他。 🔥本次的自动加入黑名单拦截代码已经上传到...
继续阅读 »

大家好我是聪,昨天真是水逆,在技术群里交流问题,竟然被人身攻击了!骂的话太难听具体就不加讨论了,人身攻击我可以接受,我接受不了他竟然说要刷我接口!!!!这下激发我的灵感来写一篇如何抵御黑子的压测攻击,还真得要谢谢他。


image-20240523081706355.png


🔥本次的自动加入黑名单拦截代码已经上传到短链狗,想学习如何生成一个短链可以去我的 Github 上面查看哦,项目地址:github.com/lhccong/sho…


思维发散


如果有人要攻击我的网站,我应该从哪些方面开始预防呢,我想到了以下几点,如何还有其他的思路欢迎大家补充:



  1. 从前端开始预防!


    聪 A🧑:确实是一种办法,给前端 ➕ 验证码、短信验证,或者加上谷歌认证(用户说:我谢谢你哈,消防栓)。


    聪 B🧑:再次思考下还是算了,这次不想动我的前端加上如何短信验证还消耗我的💴,本来就是一个练手项目,打住❌。


  2. 人工干预!


    聪 A🧑:哇!人工干预很累的欸,拜托。


    聪 B🧑:那如果是定时人工检查进行干预处理,辅助其他检测手段呢,是不是感觉还行!


  3. 使用网关给他预防!


    聪 A🧑:网关!好像听起来不错。


    聪 B🧑:不行!我项目都没有网关,单单为了黑子增加一个网关,否决❌。


  4. 日志监控!


    聪 A🧑:日志监控好像还不错欸,可以让系统日志的输出到时候统一监控,然后发短信告诉我们。


    聪 B🧑:日志监控确实可以,发短信还是算了,拒绝一切花销哈❌。


  5. 我想到了!后端 AOP 拦截访问限流,通过自动检测将 IP + 用户ID 加入黑名单,让黑子无所遁形。


    聪 A🧑:我觉得可以我们来试试?


    聪 B🧑:还等什么!来试试吧!



功能实现


设置 AOP 注解


1)获取拦截对象的标识,这个标识可以是用户 ID 或者是其他。


2)限制频率。举个例子:如果每秒超过 10 次就直接给他禁止访问 1 分钟或者 5 分钟。


3)加入黑名单。举个例子:当他多次触发禁止访问机制,就证明他还不死心还在刷,直接给他加入黑名单,可以是永久黑名单或者 1 天就又给他放出来。


4)获取后面回调的方法,会用反射来实现接口的调用。


有了以上几点属性,那么注解设置如下:


/**
* 黑名单拦截器
*
*
@author cong
*
@date 2024/05/23
*/

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface BlacklistInterceptor {

   /**
    * 拦截字段的标识符
    *
    *
@return {@link String }
    */

   String key() default "default";;

   /**
    * 频率限制 每秒请求次数
    *
    *
@return double
    */

   double rageLimit() default 10;

   /**
    * 保护限制 命中频率次数后触发保护,默认触发限制就保护进入黑名单
    *
    *
@return int
    */

   int protectLimit() default 1;

   /**
    * 回调方法
    *
    *
@return {@link String }
    */

   String fallbackMethod();
}

设置切面具体实现


@Aspect
@Component
@Slf4j
public class RageLimitInterceptor {
   private final Redisson redisson;

   private RMapCache blacklist;
   // 用来存储用户ID与对应的RateLimiter对象
   private final Cache userRateLimiters = CacheBuilder.newBuilder()
          .expireAfterWrite(1, TimeUnit.MINUTES)
          .build();

   public RageLimitInterceptor(Redisson redisson) {
       this.redisson = redisson;
       if (redisson != null) {
           log.info("Redisson object is not null, using Redisson...");
           // 使用 Redisson 对象执行相关操作
           // 个人限频黑名单24h
           blacklist = redisson.getMapCache("blacklist");
           blacklist.expire(24, TimeUnit.HOURS);// 设置过期时间
      } else {
           log.error("Redisson object is null!");
      }
  }


   @Pointcut("@annotation(com.cong.shortlink.annotation.BlacklistInterceptor)")
   public void aopPoint() {
  }

   @Around("aopPoint() && @annotation(blacklistInterceptor)")
   public Object doRouter(ProceedingJoinPoint jp, BlacklistInterceptor blacklistInterceptor) throws Throwable {
       String key = blacklistInterceptor.key();

       // 获取请求路径
       RequestAttributes requestAttributes = RequestContextHolder.currentRequestAttributes();
       HttpServletRequest httpServletRequest = ((ServletRequestAttributes) requestAttributes).getRequest();
       //获取 IP
       String remoteHost = httpServletRequest.getRemoteHost();
       if (StringUtils.isBlank(key)) {
           throw new BusinessException(ErrorCode.NO_AUTH_ERROR, "拦截的 key 不能为空");
      }
       // 获取拦截字段
       String keyAttr;
       if (key.equals("default")) {
           keyAttr = "SystemUid" + StpUtil.getLoginId().toString();
      } else {
           keyAttr = getAttrValue(key, jp.getArgs());
      }

       log.info("aop attr {}", keyAttr);

       // 黑名单拦截
       if (blacklistInterceptor.protectLimit() != 0 && null != blacklist.getOrDefault(keyAttr, null) && (blacklist.getOrDefault(keyAttr, 0L) > blacklistInterceptor.protectLimit()
               ||blacklist.getOrDefault(remoteHost, 0L) > blacklistInterceptor.protectLimit())) {
           log.info("有小黑子被我抓住了!给他 24 小时封禁套餐吧:{}", keyAttr);
           return fallbackMethodResult(jp, blacklistInterceptor.fallbackMethod());
      }

       // 获取限流
       RRateLimiter rateLimiter;
       if (!userRateLimiters.asMap().containsKey(keyAttr)) {
           rateLimiter = redisson.getRateLimiter(keyAttr);
           // 设置RateLimiter的速率,每秒发放10个令牌
           rateLimiter.trySetRate(RateType.OVERALL, blacklistInterceptor.rageLimit(), 1, RateIntervalUnit.SECONDS);
           userRateLimiters.put(keyAttr, rateLimiter);
      } else {
           rateLimiter = userRateLimiters.getIfPresent(keyAttr);
      }

       // 限流拦截
       if (rateLimiter != null && !rateLimiter.tryAcquire()) {
           if (blacklistInterceptor.protectLimit() != 0) {
               //封标识
               blacklist.put(keyAttr, blacklist.getOrDefault(keyAttr, 0L) + 1L);
               //封 IP
               blacklist.put(remoteHost, blacklist.getOrDefault(remoteHost, 0L) + 1L);
          }
           log.info("你刷这么快干嘛黑子:{}", keyAttr);
           return fallbackMethodResult(jp, blacklistInterceptor.fallbackMethod());
      }

       // 返回结果
       return jp.proceed();
  }

   private Object fallbackMethodResult(JoinPoint jp, String fallbackMethod) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
       Signature sig = jp.getSignature();
       MethodSignature methodSignature = (MethodSignature) sig;
       Method method = jp.getTarget().getClass().getMethod(fallbackMethod, methodSignature.getParameterTypes());
       return method.invoke(jp.getThis(), jp.getArgs());
  }

   /**
    * 实际根据自身业务调整,主要是为了获取通过某个值做拦截
    */

   public String getAttrValue(String attr, Object[] args) {
       if (args[0] instanceof String) {
           return args[0].toString();
      }
       String filedValue = null;
       for (Object arg : args) {
           try {
               if (StringUtils.isNotBlank(filedValue)) {
                   break;
              }
               filedValue = String.valueOf(this.getValueByName(arg, attr));
          } catch (Exception e) {
               log.error("获取路由属性值失败 attr:{}", attr, e);
          }
      }
       return filedValue;
  }

   /**
    * 获取对象的特定属性值
    *
    *
@param item 对象
    *
@param name 属性名
    *
@return 属性值
    *
@author tang
    */

   private Object getValueByName(Object item, String name) {
       try {
           Field field = getFieldByName(item, name);
           if (field == null) {
               return null;
          }
           field.setAccessible(true);
           Object o = field.get(item);
           field.setAccessible(false);
           return o;
      } catch (IllegalAccessException e) {
           return null;
      }
  }

   /**
    * 根据名称获取方法,该方法同时兼顾继承类获取父类的属性
    *
    *
@param item 对象
    *
@param name 属性名
    *
@return 该属性对应方法
    *
@author tang
    */

   private Field getFieldByName(Object item, String name) {
       try {
           Field field;
           try {
               field = item.getClass().getDeclaredField(name);
          } catch (NoSuchFieldException e) {
               field = item.getClass().getSuperclass().getDeclaredField(name);
          }
           return field;
      } catch (NoSuchFieldException e) {
           return null;
      }
  }


}

这段代码主要实现了几个方面:



  • 获取限流对象的唯一标识。如用户 Id 或者其他。

  • 将标识来获取是否触发限流 + 黑名单 如果是这两种的一种,直接触发预先设置的回调(入参要跟原本接口一致喔)。

  • 通过反射来获取回调的属性以及方法名称,触发方法调用。

  • 封禁 标识 、IP 。


代码测试


@BlacklistInterceptor(key = "title", fallbackMethod = "loginErr", rageLimit = 1L, protectLimit = 10)
   @PostMapping("/login")
   public String login(@RequestBody UrlRelateAddRequest urlRelateAddRequest) {
       log.info("模拟登录 title:{}", urlRelateAddRequest.getTitle());
       return "模拟登录:登录成功 " + urlRelateAddRequest.getTitle();
  }

   public String loginErr(UrlRelateAddRequest urlRelateAddRequest) {
       return "小黑子!你没有权限访问该接口!";
  }


  • key:需要拦截的标识,用来判断请求对象。

  • fallbackMethod:回调的方法名称(这里需要注意的是入参要跟原本接口保持一致)。

  • rageLimit:每秒限制的访问次数。

  • protectLimit:超过每秒访问次数+1,当请求超过 protectLimit 值时,进入黑名单封禁 24 小时。


以下是具体操作截图:


Snipaste_2024-05-23_11-28-41.png


到这里这个黑名单的拦截基本就实现啦,大家还有什么具体的补充点都可以提出来,一起学习一下,经过这次”恐吓风波“,让我知道互联网上的人戾气还是很重的,只要坚持好做自己,管他别人什么看法!!


作者:cong_
来源:juejin.cn/post/7371761447696121866
收起阅读 »

一条SQL差点引发离职

文章首发于微信公众号:云舒编程 关注公众号获取: 1、大厂项目分享 2、各种技术原理分享 3、部门内推 背景        最近组里的小伙伴在开发一个更新功能时踩了MySQL的一个类型转换的坑,差点造成线上故障。 本来是一个很简单的逻辑,就是根据唯一的id去...
继续阅读 »

文章首发于微信公众号:云舒编程

关注公众号获取:
1、大厂项目分享
2、各种技术原理分享
3、部门内推



背景


       最近组里的小伙伴在开发一个更新功能时踩了MySQL的一个类型转换的坑,差点造成线上故障。

本来是一个很简单的逻辑,就是根据唯一的id去更新对应的MySQL数据,代码简化后如下:


var updates []*model.Goods
for id, newGoods := range update {
 if err := model.GetDB().Model(&model.Goods{}).Where("id = ?", id).Updates(map[string]interface{}{
  "selling_price":  newGoods.SellingPrice,
  "sell_type":      newGoods.SellType,
  "status":         newGoods.Status,
  "category_id":    newGoods.CategoryID,
 }).Error; err != nil {
  return nil, err
 }
}

很明显,updates[]model.Goods\color{red}{updates []*model.Goods}本来应该是想声明为 map[string]model.Goods\color{red}{map[string]*model.Goods}类型的,然后key是唯一id。这样下面的更新逻辑才是对的,否则拿到的id其实是数组的下标。

但是code review由于跟着一堆代码一起评审了,并且这段更新很简单,同时测试的时候也测试过了(能测试通过也是“机缘巧合”),所以没有发现这段异常。

发到线上后,进行了灰度集群的测试,这个时候发现只要调用了这个接口,灰度集群的数据全部都变成了一样,回滚后正常。


分析


       回滚后在本地进行复现,由于本地环境是开启了SQL打印的,于是看到了这么一条SQL:很明显是拿数组的下标去比较了


update db_name set selling_price = xx,sell_type = xx where id = 0;

       由于我们的id是全部是通过uuid生成的,所以下意识的认为这条sql应该啥也不会更新才对,但是本地的确只执行了这条sql,没有别的sql,并且db中的数据全部都被修改了。

这个时候想起福尔摩斯的名言“排除一切不可能的,剩下的即使再不可能,那也是真相”\color{blue}{“排除一切不可能的,剩下的即使再不可能,那也是真相”} ,于是抱着试一试的心态直接拿这条sql去db控制台执行了一遍,发现果然所有的数据又都被修改了。

也就是 whereid=0\color{red}{where id = 0}  这个条件对于所有的记录都是恒为true,就会导致所有记录都被更新。在这个时候,想起曾经看到过MySQL对于不同类型的比较会有 【隐式转换】\color{red}{【隐式转换】},难道是这个原因导致的?


隐式转换规则


在MySQL官网找到了不同类型比较的规则:



最后一段的意思是:对于其他情况,将按照浮点(双精度)数进行比较。例如,字符串和数字的比较就按照浮点数规则进行比较。

也就是id会首先被转换成浮点数,然后再跟0进行比较。


MySQL字符转为浮点数时会按照如下规则进行:


1.如果字符串的第一个字符就是非数字的字符,那么转换结果就是0;

2.如果字符串以数字开头:

(1)如果字符串都是数字,转换结果就是整个字符串对应的数字;

(2)如果字符串中存在非数字,转换结果就是开头的那些数字对应的值;

举例说明:

"test" -> 0

"1test" -> 1

"12test12" -> 12

由于我们生成的uuid没有数字开头的字符串,于是都会转变成0。那么这条SQL就变成了:


update db_name set selling_price = xx,sell_type = xx where 0 = 0;

就恒为true了。

修复就很简单了,把取id的逻辑改成正确的就行。


为什么测试环境没有发现


       前面有提到这段代码在测试环境是测试通过了的,这是因为开发和测试同学的环境里都只有一条记录,每次更新他发现都能正常更新就认为是正常的了。同时由于逻辑太简单了,所以都没有重视这块的回归测试。

幸好在灰度集群就发现了这个问题,及时进行了回滚,如果发到了线上影响了用户数据,可能就一年白干了。


最后


代码无小事,事事需谨慎啊。一般致命问题往往是一行小小的修改导致的。


作者:云舒编程
来源:juejin.cn/post/7275550679790960640
收起阅读 »