springboot多种生产打包方式简介
生产上发布 Spring Boot 项目时,流程颇为繁琐且低效。但凡代码有一丁点改动,就得把整个项目重新打包部署,耗时费力不说,生成的 JAR 包还特别臃肿,体积庞大。每次更新项目,光是上传这大文件就得花费不少时间,严重影响工作节奏。为解决这一痛点,我打算把依赖库以及配置文件(lib 文件夹下的那些 jar 包,还有config下的applacation.yml等文件)从项目主体里剥离出来,后续部署时,只需发布核心代码就行,这样既能加快部署速度,又能减轻文件传输负担,让项目更新变得轻松便捷
方法一 插件spring-boot-maven-plugin
1. 项目应用的配置文件排除 统一打包到config目录下
利用springboot中resource插件来排除配置,并统一打包到config目录下
<resources>
<resource>
<directory>src/main/resources</directory>
<!--filerting设置为true,则打包过程中会对这些文件进行过滤处理-->
<filtering>true</filtering>
<!--指定目标路径为config-->
<targetPath>${project.build.directory}/config</targetPath>
<includes>
<!--使用通配符-->
<include>**/*.properties</include>
<include>**/*.yml</include>
<include>**/*.xml</include>
<include>mapper/*.xml</include>
<!-- 这里可以根据你实际想要包含的配置文件类型来添加更多的include配置 -->
</includes>
</resource>
</resources>
2. 把我们写代码打包可执行jar,并排除依赖jar包
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<!--项目的启动类,如果有多个main就必须指定,没有可以缺失
<mainClass>XXXXX.TwinWebApplication</mainClass>-->
<!--解决windows命令行窗口中文乱码-->
<jvmArguments>-Dfile.encoding=UTF-8</jvmArguments>
<layout>ZIP</layout>
<!--配置需要打包进项目的jar-->
<includes>
<!--填写需要打包所需要的依赖 。没有匹配上任何jar包机排除依赖-->
<include>
<groupId>no-exists-jar</groupId>
<artifactId>non-exists-jar</artifactId>
</include>
</includes>
</configuration>
<executions>
<execution>
<goals>
<!-- 表示当运行mavn package打包时,使用Springboot插件打包 -->
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
3 配置依赖的jar包 统一打包lib目录
<!--此插件用于将依赖包抽出-->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<executions>
<execution>
<id>copy-dependencies</id>
<phase>package</phase>
<goals>
<goal>copy-dependencies</goal>
</goals>
<configuration>
<outputDirectory>${project.build.directory}/lib</outputDirectory>
<excludeTransitive>false</excludeTransitive>
<stripVersion>false</stripVersion>
<includeScope>runtime</includeScope>
</configuration>
</execution>
</executions>
</plugin>
打包后目录结构,如下图所示
执行命令 java -jar -Dloader.path=./lib -jar xxx.jar
注意
springboot启动时候会优先读取config目录下配置文件 所以这里不用指定-Dspring.config.location=XX.yml文件
注意
例如日志文件配置以及mybits等配置文件 可以配成绝对路径 如下所示:
方法二 使用maven-jar-plugin插件实现
1 使用插件maven-resources-plugin处理配置文件打包到config目录
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
<configuration>
<encoding>UTF-8</encoding>
</configuration>
<executions>
<execution>
<id>copy-dependencies</id>
<phase>package</phase>
<goals>
<goal>copy-resources</goal>
</goals>
<configuration>
<!--配置文件打包成config目录下 -->
<outputDirectory>${project.build.directory}/twin-web/config</outputDirectory>
<resources>
<resource>
<directory>src/main/resources</directory>
</resource>
</resources>
</configuration>
</execution>
</executions>
</plugin>
2. 使用maven-jar-plugin 打包可执行jar 并排除依赖
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<configuration>
<outputDirectory>
<!--输入打包可执行的jar到twin-web\libs\下-->
${project.build.directory}/twin-web/
</outputDirectory>
<archive>
<addMavenDescriptor>false</addMavenDescriptor>
<manifest>
<addClasspath>true</addClasspath>
<!-- 增加执行启动jar的依赖jar包目录前缀-->
<classpathPrefix>./libs/</classpathPrefix>
<!-- 指定启动类-->
<mainClass>com.keqing.twinweb.TwinWebApplication</mainClass>
</manifest>
<manifestEntries>
<!-- 增加配置文件的classpath-->
<Class-Path>./config/</Class-Path>
</manifestEntries>
</archive>
<!-- 排除配置文件-->
<excludes>
<exclude>*.yml</exclude>
<exclude>mapper/**</exclude>
<exclude>*.xml</exclude>
</excludes>
</configuration>
</plugin>
3 使用maven-dependency-plugin 打包libs目录下
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<executions>
<execution>
<id>copy-dependencies</id>
<phase>package</phase>
<goals>
<goal>copy-dependencies</goal>
</goals>
<configuration>
<outputDirectory>${project.build.directory}/twin-web/libs</outputDirectory>
<excludeTransitive>false</excludeTransitive>
<stripVersion>false</stripVersion>
<includeScope>runtime</includeScope>
</configuration>
</execution>
</executions>
</plugin>
使用package打包后的目录
查看自己打包后jar目录,注意这种打包方式弊端,按照一定约定格式规范固定了,一旦依赖jar包(包括配置文件目录等)发生变化就必须重新打包
启动程序java -jar xxx.jar
方式三 使用maven-assembly-plugin打包
maven-assembly-plugin
是 Maven 中的一个插件,它允许用户将项目的输出以及依赖、模块、站点文档和其他文件打包成一个可发布的格式,例如 zip、tar.gz、jar 等。以下是使用 maven-assembly-plugin
的一些优势:
- 自定义打包格式:
maven-assembly-plugin
允许你通过定义描述符文件(descriptor)来完全自定义打包的内容和格式。你可以选择包含或排除特定的文件和目录。 - 一键打包:通过一个简单的 Maven 命令,你可以创建一个包含所有必需依赖的单一归档文件,这使得分发和部署变得非常简单。
- 多环境支持:可以为不同的环境(开发、测试、生产)创建不同的打包配置,使得环境迁移更加容易。
- 依赖管理:插件会自动处理项目依赖,将它们打包到最终的归档文件中,无需手动管理。
- 模块化项目支持:对于多模块项目,
maven-assembly-plugin
可以将所有模块的输出合并到一个归档文件中。 - 预配置的描述符:插件提供了一些预定义的描述符,如
bin
、jar-with-dependencies
等,可以直接使用,无需自定义。 - 灵活性:你可以通过修改描述符文件来调整打包行为,以适应不同的需求。
- 集成性:
maven-assembly-plugin
与 Maven 生态系统紧密集成,可以与其他 Maven 插件协同工作。 - 文档和社区支持:由于
maven-assembly-plugin
是 Maven 的一部分,因此有广泛的文档和社区支持。
1. 项目应用的配置文件排除
<resources>
<resource>
<directory>src/main/resources</directory>
<!--filerting设置为true,则打包过程中会对这些文件进行过滤处理-->
<filtering>true</filtering>
<includes>
<!--使用通配符-->
<include>**/*.properties</include>
<include>**/*.yml</include>
<include>**/*.xml</include>
<include>mapper/*.xml</include>
<!-- 这里可以根据你实际想要包含的配置文件类型来添加更多的include配置 -->
</includes>
</resource>
</resources>
2. 配置spring-boot-maven-plugin
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<!--项目的启动类,如果有多个main就必须指定,没有可以缺失
<mainClass>XXXXX.TwinWebApplication</mainClass>-->
<!--解决windows命令行窗口中文乱码-->
<jvmArguments>-Dfile.encoding=UTF-8</jvmArguments>
<layout>ZIP</layout>
<!--配置需要打包进项目的jar-->
<includes>
<!--填写需要打包所需要的依赖 。没有匹配上任何jar包机排除依赖-->
<include>
<groupId>no-exists-jar</groupId>
<artifactId>non-exists-jar</artifactId>
</include>
</includes>
</configuration>
<executions>
<execution>
<goals>
<!-- 表示当运行mavn package打包时,使用Springboot插件打包 -->
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
3 引入springboot里约定maven-assembly-plugin
<plugin>
<artifactId>maven-assembly-plugin</artifactId>
<configuration>
<!-- 打包文件名字不包含 assembly.xml 中 id -->
<appendAssemblyId>false</appendAssemblyId>
<descriptors>
<!--项目所在目录配置文件的 assembly.xml文件 -->
<descriptor>assembly.xml</descriptor>
</descriptors>
</configuration>
<executions>
<execution>
<id>make-assembly</id>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
</execution>
</executions>
</plugin>
配置assembly.xml文件
<assembly>
<!-- 打包文件名的标识符,用来做后缀-->
<id>make-assembly</id>
<!-- 打包的类型,如果有N个,将会打N个类型的包 -->
<formats>
<format>tar.gz</format>
<format>zip</format>
</formats>
<!-- 压缩包下是否生成和项目名相同的根目录 -->
<includeBaseDirectory>true</includeBaseDirectory>
<!-- 用来设置一组文件在打包时的属性。-->
<fileSets>
<!-- 0755->即用户具有读/写/执行权限,组用户和其它用户具有读写权限;-->
<!-- 0644->即用户具有读写权限,组用户和其它用户具有只读权限;-->
<!-- 将src/bin目录下的jar启动脚本输出到打包后的目录中 -->
<fileSet>
<!--lineEnding选项可用于控制给定的行结束文件 -->
<lineEnding>unix</lineEnding>
<directory>${basedir}/bin</directory>
<outputDirectory>${file.separator}</outputDirectory>
<fileMode>0755</fileMode>
<includes>
<include>**.sh</include>
<include>**.bat</include>
</includes>
</fileSet>
<!-- 把项目的配置文件,打包进压缩文件的config目录 -->
<fileSet>
<directory>${basedir}/src/main/resources</directory>
<outputDirectory>config</outputDirectory>
<fileMode>0644</fileMode>
<includes>
<include>*.properties</include>
<include>*.yml</include>
<include>*.xml</include>
<include>mapper/*.xml</include>
</includes>
</fileSet>
<!-- 把项目自己编译出来的jar文件,打包进zip文件的根目录 -->
<fileSet>
<directory>${project.build.directory}</directory>
<outputDirectory>${file.separator}</outputDirectory>
<includes>
<include>*.jar</include>
</includes>
</fileSet>
</fileSets>
<!-- 依赖包的拷贝-->
<dependencySets>
<dependencySet>
<unpack>false</unpack>
<useProjectArtifact>true</useProjectArtifact>
<outputDirectory>lib</outputDirectory>
<scope>provided</scope>
</dependencySet>
<dependencySet>
<unpack>false</unpack>
<useProjectArtifact>true</useProjectArtifact>
<outputDirectory>lib</outputDirectory>
<scope>system</scope>
</dependencySet>
<dependencySet>
<unpack>false</unpack>
<useProjectArtifact>true</useProjectArtifact>
<outputDirectory>lib</outputDirectory>
<scope>runtime</scope>
</dependencySet>
</dependencySets>
</assembly>
打包后目录
解压zip目录查看
使用命令启动项目java -jar -Dloader.path=./lib -jar xxx.jar
来源:juejin.cn/post/7442154802832916530
Spring Boot + liteflow竟然这么好用!
liteflow
可以帮助我们快速编排复杂的业务规则,并实现动态的规则更新。
在liteflow
中,主要有以下几种组件:
- 普通组件:集成
NodeComponent
,用于执行具体的业务逻辑;- 选择组件:通过业务逻辑选择不同的执行路径;
- 条件组件:基于条件返回结果,决定下一步的业务流程。
我们通过代码示例来了解每种组件的用法。
java
// 普通组件示例
@LiteflowComponent("commonNode")
public class CommonNode extends NodeComponent {
@Override
public void process() throws Exception {
// 业务逻辑
System.out.println("Executing commonNode logic");
}
}
// 选择组件示例
@LiteflowComponent("choiceNode")
public class ChoiceNode extends NodeSwitchComponent {
@Override
public String processSwitch() throws Exception {
// 根据条件返回不同的节点ID
return "nextNodeId";
}
}
// 条件组件示例
@LiteflowComponent("conditionNode")
public class ConditionNode extends NodeIfComponent {
@Override
public boolean processIf() throws Exception {
// 判断条件
return true;
}
}
EL规则文件
在liteflow
中,规则文件可以采用XML格式编写,下面是一个简单的规则文件示例。
如何使用EL规则文件
- 创建规则文件:将上述规则文件保存为
flow.xml
,放在项目的resources
目录下; - 配置
liteflow
:在Spring Boot项目中添加liteflow
的配置,指定规则文件的位置;
yaml
liteflow:
rule-source: "classpath:flow.xml"
node-retry: 3
thread-executor:
core-pool-size: 10
max-pool-size: 20
keep-alive-time: 60
- 编写业务逻辑组件:按照规则文件中的定义,编写相应的组件逻辑。
数据上下文
在liteflow
中,数据上下文非常重要,它用于参数传递
和业务逻辑
的执行。
我们可以通过以下代码示例了解数据上下文的用法
配置详解
在使用liteflow
时,我们需要对一些参数进行配置,如规则文件地址、节点重试、线程池参数等。
以下是一个配置示例。
yaml
liteflow:
rule-source: "classpath:flow.xml" # 指定规则文件的位置
node-retry: 3 # 节点重试次数
thread-executor:
core-pool-size: 10 # 线程池核心线程数
max-pool-size: 20 # 线程池最大线程数
keep-alive-time: 60 # 线程存活时间
实践案例
接下来,我们来看一个完整的业务实践案例。
在电商场景下,当订单完成后,我们需要同时进行积分发放和消息发送。
这时候,我们可以利用liteflow
进行规则编排,处理这些并行任务。
1. 引入依赖
首先,在pom.xml
文件中添加liteflow
的依赖:
xml
<dependency>
<groupId>com.yomahubgroupId>
<artifactId>liteflow-spring-boot-starterartifactId>
<version>2.6.5version>
dependency>
2. 增加配置
在application.yml
文件中添加liteflow
的配置:
yaml
spring:
application:
name: liteflow-demo
liteflow:
rule-source: "classpath:flow.xml" # 指定规则文件的位置
node-retry: 3 # 节点重试次数
thread-executor:
core-pool-size: 10 # 线程池核心线程数
max-pool-size: 20 # 线程池最大线程数
keep-alive-time: 60 # 线程存活时间
3. 编写规则文件
在resources
目录下创建flow.xml
文件,编写规则文件内容:
xml
<flow>
<parallel>
<node id="pointNode"/>
<node id="messageNode"/>
parallel>
flow>
4. 编写业务逻辑组件
按照规则文件中的定义,编写相应的业务逻辑组件:
java
@LiteflowComponent("pointNode")
public class PointNode extends NodeComponent {
@Override
public void process() throws Exception {
// 发放积分逻辑
System.out.println("Issuing points for the order");
}
}
@LiteflowComponent("messageNode")
public class MessageNode extends NodeComponent {
@Override
public void process() throws Exception {
// 发送消息逻辑
System.out.println("Sending message for the order");
}
}
5. 流程触发
当订单完成后,我们需要触发liteflow
的流程来执行积分发放和消息发送的逻辑。
我们可以在订单完成的服务方法中添加如下代码:
java
@Service
public class OrderService {
@Autowired
private FlowExecutor flowExecutor;
public void completeOrder(Order order) {
// 完成订单的其他逻辑
System.out.println("Order completed: " + order.getId());
// 执行liteflow流程
flowExecutor.execute2Resp("flow", order);
}
}
性能统计
总结
来源:juejin.cn/post/7394790673612718092
一位转行Java上岸4年到技术专家的经验分享
自我介绍:
大家好, 我是你们的朋友, 晓龙。今天我想分享一下我自己艰难的Java转行经历。
为什么没有学历 , 因为大学上了一学期, 当时因为一些原因, 就辍学了。年少无知 , 发现到社会上找工作, 没有学历就没有敲门砖,最低要求也是大专。在2018年报考了大专的网络教育。同时, 自己也开始了金融学本科的自考。2018年还是比较的迷茫, 也不知道能靠什么挣钱。2019年, 开始在黑马进行Java的培训, 因为大概有4年没有学习了,加之因为0基础, 当时去的时候连Java能干什么也不知道, 所以学起来还是比较的吃力,尤其我记得当时对方法的定义, 是真的理解不了。
开局buffer就叠满了,运气一直比较的好。
工作经历:
2019年9月左右, 在武汉经过几次面似乎,找到了第一家公司, 规模比较的小, 0-20人, 当然对于当时的我来说, 能找到一份工作(上岸),也是非常开心的, 但是很遗憾的是, 这家公司我去只坚持1个月, 公司就倒闭了。
但是也没有急着找工作了,花了20天, 花了20天把自考的科目复习,在11月份继续在武汉找工作,经过面试,入职了一家比较小的公司, 后端就2人,都是培训出来的。当时我的想法很明确, 花时间补知识, 在培训班的6个月, 时间还是比较的紧的,很多东西只是知道, 都没有亲自动手实践过。公司好在不是很忙, 有一定的时间学习。当时上了2个多月,就回家过年,后面因为yq , 就没有去武汉了,这家公司也就上了3个月的班, 一直在家呆到2020年年7月,期间把慕课网的架构课程好好学习了。7月开始在重庆找工作 ,经过了一周多的努力, 找到了一家更加不规范的公司, 后端2个, 前端一个, 产品一个,其他就没有了,还在薪资给的还可以, 这个期间继续学习, 真正的代码都写得比较的少。很遗憾, 公司只坚持了6个月, 2021年1月, 公司又倒闭了。
我清晰的记得当时准备面试的那一周,是压力最大的一周。在经历了前面几家公司后, 我也知道, 如果还去一家不稳定的公司, 结果还是这样, 几个月就倒闭了, 而且整个公司没有研发体系, 自己就永远不能真正的上岸(野路子到正规军)。虽然前面1年多代码没有写多少, 但是还是有一定的知识积累。
自己也没有放弃, 我当时在BOSS上看准了一家公司, 然后就开始好好的准备, 不仅仅是准备技术方面的知识, 还去了解公司的背景, 当时觉得这家公司规模比较的大。当时面试完后, 顺利的拿到了offer , 期间我和面试官(也是我的领导)说, 我非常期待能来公司上班,即使不要钱,但是没办法, 我自己得生活。直到现在, 我还是非常的感谢这位领导, 也会在微信中和他交流近况。
是的 ,从某种意义上讲, 现在才是真正的上岸!
我在这家公司感受到什么是真正的做开发。公司有正规的研发流程, 完善的研发体系, 每一个同事都身怀绝技。在这家公司, 我给自己的定位是2年成长为高级开发工程师,期间也暴露出我的一些问题,比如代码逻辑写得不清楚(之前代码写得太少了),设计做得不好等。
我是渴望成长的, 所以我针对自己具体的问题, 在2年的时间里, 充分利用自己的时间做了这些工作:
第一阶段: 在试用期的前三个月,虽然完成了业务功能开发,但是意识到自己的代码量还是远远不够,在以最快的速度完成业务功能开发后,投入leetcode的算法练习,每一道题就是一个功能的缩影,在完成300+的练习后,业务代码就得心应手了。
第二阶段: 当时培训机构的架构课对我来说,是无法转化为我的能力的,我学习它无疑是浪费我的时间,所以我更多的选择了自己去寻找资料和书籍来学习,主要是针对这几个方面,操作系统,计算机网络,netty,JVM,并发编程,框架源码,此过程历经1年。
第三阶段: 系统设计,在经历前两个阶段后, 是时候提高我的系统设计,架构能力,这个东西学不来, 靠悟,也是最难受的地方!每一次的业务功能开发前,我都会阅读软件设计方面的资料(每阅读一次,都有不一样的收获)再对业务功能进行设计,非常的耗时,但也非常的值得,经历了一年,对模块和系统的设计都有了自己的理解。
第四阶段: 和产品的深度沟通能力,无论是在需求评审,还是自己负责的模块,系统,都要和产品深度的沟通,这个阶段经历了半年,完成了这4个阶段后,自己的能力得到了极大的提高。跳槽成功
大家会说, 现在的环境不好, 不好找工作。从我自己的经历来看, 转行培训过来的同学, 开局不管怎么样, 不管遇到多大的困难,不要放弃自己, 请坚持学习, 好好的积累3年,完善自己的各项能力, 那个时候才是真正的上岸,自己也不愁找不到工作,拿到自己满意的薪资。
目前也在写重写自己的系统框架(脚手架)fd-frameworkfd-framework, 今天为了解决springmvc对请求时间进行统一格式处理 , Long精度丢失问题处理问题, 仔细花了大概5小时阅读里面的源码,期间的实现方式改了4版, 每一次的深入,都是有新的实现想法, 真的感觉很开心,哈哈, 预祝各位转行的同学上岸。
来源:juejin.cn/post/7323408577709080610
为什么 Java 大佬都不推荐使用 keySet() 遍历HashMap?
在Java编程中,HashMap
是一种非常常见的数据结构。我们经常需要对其中的键值对进行遍历。通常有多种方法可以遍历 HashMap
,其中一种方法是使用 keySet()
方法。
然而,很多Java大佬并不推荐这种方法。为什么呢?
已收录于,我的技术网站:ddkk.com 里面有,500套技术系列教程、1万+道,面试八股文、BAT面试真题、简历模版,工作经验分享、架构师成长之路,等等什么都有,欢迎收藏和转发。
keySet()
方法的工作原理
首先,让我们来看一下 keySet()
方法是如何工作的。keySet()
方法返回 HashMap
中所有键的集合 (Set<K>
)。然后我们可以使用这些键来获取相应的值。
代码示例如下:
// 创建一个HashMap并填充数据
Map<String, Integer> map = new HashMap<>();
map.put("apple", 1);
map.put("banana", 2);
map.put("cherry", 3);
// 使用keySet()方法遍历HashMap
for (String key : map.keySet()) {
// 通过键获取相应的值
Integer value = map.get(key);
System.out.println("Key: " + key + ", Value: " + value);
}
这个代码看起来没什么问题,但在性能和效率上存在一些隐患。
keySet()
方法的缺点
1、 多次哈希查找:如上面的代码所示,使用 keySet()
方法遍历时,需要通过键去调用 map.get(key)
方法来获取值。这意味着每次获取值时,都需要进行一次哈希查找操作。如果 HashMap
很大,这种方法的效率就会明显降低。
2、 额外的内存消耗:keySet()
方法会生成一个包含所有键的集合。虽然这个集合是基于 HashMap
的键的视图,但仍然需要额外的内存开销来维护这个集合的结构。如果 HashMap
很大,这个内存开销也会变得显著。
3、 代码可读性和维护性:使用 keySet()
方法的代码可能会让人误解,因为它没有直接表现出键值对的关系。在大型项目中,代码的可读性和维护性尤为重要。
更好的选择:entrySet()
方法
相比之下,使用 entrySet()
方法遍历 HashMap
是一种更好的选择。entrySet()
方法返回的是 HashMap
中所有键值对的集合 (Set<Map.Entry<K, V>>
)。通过遍历这个集合,我们可以直接获取每个键值对,从而避免了多次哈希查找和额外的内存消耗。
下面是使用 entrySet()
方法的示例代码:
// 创建一个HashMap并填充数据
Map<String, Integer> map = new HashMap<>();
map.put("apple", 1);
map.put("banana", 2);
map.put("cherry", 3);
// 使用entrySet()方法遍历HashMap
for (Map.Entry<String, Integer> entry : map.entrySet()) {
// 直接获取键和值
String key = entry.getKey();
Integer value = entry.getValue();
System.out.println("Key: " + key + ", Value: " + value);
}
entrySet()
方法的优势
1、 避免多次哈希查找:在遍历过程中,我们可以直接从 Map.Entry
对象中获取键和值,而不需要再次进行哈希查找,提高了效率。
2、 减少内存消耗:entrySet()
方法返回的是 HashMap
内部的一个视图,不需要额外的内存来存储键的集合。
3、 提高代码可读性:entrySet()
方法更直观地表现了键值对的关系,使代码更加易读和易维护。
性能比较
我们来更深入地解析性能比较,特别是 keySet()
和 entrySet()
方法在遍历 HashMap
时的性能差异。
主要性能问题
1、 多次哈希查找: 使用 keySet()
方法遍历 HashMap
时,需要通过键调用 map.get(key)
方法获取值。这意味着每次获取值时都需要进行一次哈希查找操作。哈希查找虽然时间复杂度为 O(1),但在大量数据下,频繁的哈希查找会累积较高的时间开销。
2、 额外的内存消耗: keySet()
方法返回的是一个包含所有键的集合。虽然这个集合是基于 HashMap
的键的视图,但仍然需要额外的内存来维护这个集合的结构。
更高效的选择:entrySet()
方法
相比之下,entrySet()
方法返回的是 HashMap
中所有键值对的集合 (Set<Map.Entry<K, V>>
)。通过遍历这个集合,我们可以直接获取每个键值对,避免了多次哈希查找和额外的内存消耗。
性能比较示例
让我们通过一个具体的性能比较示例来详细说明:
import java.util.HashMap;
import java.util.Map;
public class HashMapTraversalComparison {
public static void main(String[] args) {
// 创建一个大的HashMap
Map<String, Integer> map = new HashMap<>();
for (int i = 0; i < 1000000; i++) {
map.put("key" + i, i);
}
// 测试keySet()方法的性能
long startTime = System.nanoTime(); // 记录开始时间
for (String key : map.keySet()) {
Integer value = map.get(key); // 通过键获取值
}
long endTime = System.nanoTime(); // 记录结束时间
System.out.println("keySet() 方法遍历时间: " + (endTime - startTime) + " 纳秒");
// 测试entrySet()方法的性能
startTime = System.nanoTime(); // 记录开始时间
for (Map.Entry<String, Integer> entry : map.entrySet()) {
String key = entry.getKey(); // 直接获取键
Integer value = entry.getValue(); // 直接获取值
}
endTime = System.nanoTime(); // 记录结束时间
System.out.println("entrySet() 方法遍历时间: " + (endTime - startTime) + " 纳秒");
}
}
深度解析性能比较示例
1、 创建一个大的 HashMap
:
Map<String, Integer> map = new HashMap<>();
for (int i = 0; i < 1000000; i++) {
map.put("key" + i, i);
}
- 创建一个包含100万个键值对的
HashMap
。 键
为"key" + i
,值
为i
。- 这个
HashMap
足够大,可以明显展示两种遍历方法的性能差异。
2、 测试 keySet()
方法的性能:
long startTime = System.nanoTime(); // 记录开始时间
for (String key : map.keySet()) {
Integer value = map.get(key); // 通过键获取值
}
long endTime = System.nanoTime(); // 记录结束时间
System.out.println("keySet() 方法遍历时间: " + (endTime - startTime) + " 纳秒");
- 使用
keySet()
方法获取所有键,并遍历这些键。 - 在每次迭代中,通过
map.get(key)
方法获取值。 - 记录开始时间和结束时间,计算遍历所需的总时间。
3、 测试 entrySet()
方法的性能:
startTime = System.nanoTime(); // 记录开始时间
for (Map.Entry<String, Integer> entry : map.entrySet()) {
String key = entry.getKey(); // 直接获取键
Integer value = entry.getValue(); // 直接获取值
}
endTime = System.nanoTime(); // 记录结束时间
System.out.println("entrySet() 方法遍历时间: " + (endTime - startTime) + " 纳秒");
- 使用
entrySet()
方法获取所有键值对,并遍历这些键值对。 - 在每次迭代中,直接从
Map.Entry
对象中获取键和值。 - 记录开始时间和结束时间,计算遍历所需的总时间。
性能结果分析
假设上述代码的运行结果如下:
keySet() 方法遍历时间: 1200000000 纳秒
entrySet() 方法遍历时间: 800000000 纳秒
可以看出,使用 entrySet()
方法的遍历时间明显短于 keySet()
方法。这主要是因为:
1、 避免了多次哈希查找: 使用 keySet()
方法时,每次获取值都需要进行一次哈希查找。而使用 entrySet()
方法时,键和值直接从 Map.Entry
对象中获取,无需再次查找。
2、 减少了内存消耗: 使用 keySet()
方法时,额外生成了一个包含所有键的集合。而使用 entrySet()
方法时,返回的是 HashMap
内部的一个视图,无需额外的内存开销。
小结一下
通过性能比较示例,我们可以清楚地看到 entrySet()
方法在遍历 HashMap
时的效率优势。使用 entrySet()
方法不仅能避免多次哈希查找,提高遍历效率,还能减少内存消耗。
综上所述,在遍历 HashMap
时,entrySet()
方法是更优的选择。
几种高效的替代方案
除了 entrySet()
方法外,还有其他几种高效的替代方案,可以用于遍历 HashMap
。
以下是几种常见的高效替代方案及其优缺点分析:
1. 使用 entrySet()
方法
我们已经讨论过,entrySet()
方法是遍历 HashMap
时的一个高效选择。它直接返回键值对的集合,避免了多次哈希查找,减少了内存开销。
import java.util.HashMap;
import java.util.Map;
public class EntrySetTraversal {
public static void main(String[] args) {
Map<String, Integer> map = new HashMap<>();
map.put("apple", 1);
map.put("banana", 2);
map.put("cherry", 3);
for (Map.Entry<String, Integer> entry : map.entrySet()) {
String key = entry.getKey();
Integer value = entry.getValue();
System.out.println("Key: " + key + ", Value: " + value);
}
}
}
2. 使用 forEach
方法
从 Java 8 开始,Map
接口提供了 forEach
方法,可以直接对每个键值对进行操作。这种方式利用了 lambda 表达式,代码更简洁,可读性强。
import java.util.HashMap;
import java.util.Map;
public class ForEachTraversal {
public static void main(String[] args) {
Map<String, Integer> map = new HashMap<>();
map.put("apple", 1);
map.put("banana", 2);
map.put("cherry", 3);
map.forEach((key, value) -> {
System.out.println("Key: " + key + ", Value: " + value);
});
}
}
3. 使用 iterator
方法
另一种遍历 HashMap
的方法是使用迭代器 (Iterator
)。这种方法适用于需要在遍历过程中对集合进行修改的情况,比如删除某些元素。
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
public class IteratorTraversal {
public static void main(String[] args) {
Map<String, Integer> map = new HashMap<>();
map.put("apple", 1);
map.put("banana", 2);
map.put("cherry", 3);
Iterator<Map.Entry<String, Integer>> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, Integer> entry = iterator.next();
String key = entry.getKey();
Integer value = entry.getValue();
System.out.println("Key: " + key + ", Value: " + value);
}
}
}
4. 使用 Streams API
Java 8 引入了 Streams API,可以结合 stream()
方法和 forEach
方法来遍历 HashMap
。这种方法可以对集合进行更复杂的操作,比如过滤、映射等。
import java.util.HashMap;
import java.util.Map;
public class StreamTraversal {
public static void main(String[] args) {
Map<String, Integer> map = new HashMap<>();
map.put("apple", 1);
map.put("banana", 2);
map.put("cherry", 3);
map.entrySet().stream().forEach(entry -> {
String key = entry.getKey();
Integer value = entry.getValue();
System.out.println("Key: " + key + ", Value: " + value);
});
}
}
优缺点分析
entrySet()
方法:
- 优点:避免多次哈希查找,减少内存消耗,代码简单明了。
- 缺点:没有特定缺点,在大多数情况下是最佳选择。
forEach
方法:
- 优点:代码简洁,可读性强,充分利用 lambda 表达式。
- 缺点:仅适用于 Java 8 及以上版本。
iterator
方法:
- 优点:适用于需要在遍历过程中修改集合的情况,如删除元素。
- 缺点:代码稍显繁琐,不如
entrySet()
和forEach
方法直观。
Streams API
方法:
- 优点:支持复杂操作,如过滤、映射等,代码简洁。
- 缺点:仅适用于 Java 8 及以上版本,性能在某些情况下可能不如
entrySet()
和forEach
。
结论
在遍历 HashMap
时,entrySet()
方法是一个高效且广泛推荐的选择。对于更现代的代码风格,forEach
方法和 Streams API 提供了简洁且强大的遍历方式。如果需要在遍历过程中修改集合,可以使用 iterator
方法。根据具体需求选择合适的遍历方法,可以显著提高代码的效率和可读性。
已收录于,我的技术网站:ddkk.com 里面有,500套技术系列教程、1万+道,面试八股文、BAT面试真题、简历模版,工作经验分享、架构师成长之路,等等什么都有,欢迎收藏和转发。
来源:juejin.cn/post/7393663398406799372
Java 8 魔法:利用 Function 接口告别冗余代码,打造高效断言神器
前言
在 Java
开发的征途中,我们时常与重复代码不期而遇。这些重复代码不仅让项目显得笨重,更增加了维护成本。幸运的是,Java 8
带来了函数式编程的春风,以 Function
接口为代表的一系列新特性,为我们提供了破除这一难题的利剑。本文将以一个实际应用场景为例,即使用 Java 8
的函数式编程特性来重构数据有效性断言逻辑,展示如何通过 SFunction
(基于 Java 8
的 Lambda
表达式封装)减少代码重复,从而提升代码的优雅性和可维护性。
背景故事:数据校验的烦恼
想象一下,在一个复杂的业务系统中,我们可能需要频繁地验证数据库中某个字段值是否有效,是否符合预期值。传统的做法可能充斥着大量相似的查询逻辑,每次都需要手动构建查询条件、执行查询并处理结果,这样的代码既冗长又难以维护。
例如以下两个验证用户 ID 和部门 ID 是否有效的方法,虽然简单,但每次需要校验不同实体或不同条件时,就需要复制粘贴并做相应修改,导致代码库中充满了大量雷同的校验逻辑,给维护带来了困扰。
// 判断用户 ID 是否有效
public void checkUserExistence(String userId) {
User user = userDao.findById(userId);
if (user == null) {
throw new RuntimeException("用户ID无效");
}
}
// 判断部门 ID 是否有效
public void checkDeptExistence(String deptId) {
Dept dept = deptDao.findById(deptId);
if (dept == null) {
throw new RuntimeException("部门ID无效");
}
}
Java 8 的魔法棒:函数式接口
Java 8 引入了函数式接口的概念,其中 Function<T, R>
是最基础的代表,它接受一个类型 T
的输入,返回类型 R
的结果。而在 MyBatis Plus
等框架中常用的 SFunction
是对 Lambda
表达式的进一步封装,使得我们可以更加灵活地操作实体类的属性。
实战演练:重构断言方法
下面的 ensureColumnValueValid
方法正是利用了函数式接口的魅力,实现了对任意实体类指定列值的有效性断言:
/**
* 确认数据库字段值有效(通用)
*
* @param <V> 待验证值的类型
* @param valueToCheck 待验证的值
* @param columnExtractor 实体类属性提取函数
* @param queryExecutor 单条数据查询执行器
* @param errorMessage 异常提示信息模板
*/
public static <T, R, V> void ensureColumnValueValid(V valueToCheck, SFunction<T, R> columnExtractor, SFunction<LambdaQueryWrapper<T>, T> queryExecutor, String errorMessage) {
if (valueToCheck == null) return;
LambdaQueryWrapper<T> wrapper = new LambdaQueryWrapper<>();
wrapper.select(columnExtractor);
wrapper.eq(columnExtractor, valueToCheck);
wrapper.last("LIMIT 1");
T entity = queryExecutor.apply(wrapper);
R columnValue = columnExtractor.apply(entity);
if (entity == null || columnValue == null)
throw new DataValidationException(String.format(errorMessage, valueToCheck));
}
这个方法接受一个待验证的值、一个实体类属性提取函数、一个单行数据查询执行器和一个异常信息模板作为参数。通过这四个参数,不仅能够进行针对特定属性的有效性检查,而且还能生成具有一致性的异常信息。
对比分析
使用 Function
改造前
// 判断用户 ID 是否有效
public void checkUserExistence(String userId) {
User user = userDao.findById(userId);
if (user == null) {
throw new RuntimeException("用户ID无效");
}
}
// 判断部门 ID 是否有效
public void checkDeptExistence(String deptId) {
Dept dept = deptDao.findById(deptId);
if (dept == null) {
throw new RuntimeException("部门ID无效");
}
}
使用 Function
改造后
public void assignTaskToUser(AddOrderDTO dto) {
ensureColumnValueValid(dto.getUserId(), User::getId, userDao::getOne, "用户ID无效");
ensureColumnValueValid(dto.getDeptId(), Dept::getId, deptDao::getOne, "部门ID无效");
ensureColumnValueValid(dto.getCustomerId(), Customer::getId, customerDao::getOne, "客户ID无效");
ensureColumnValueValid(dto.getDeptId(), Dept::getId, deptDao::getOne, "部门ID无效");
ensureColumnValueValid(dto.getSupplieId(), Supplie::getId, supplierDao::getOne, "供应商ID无效");
// 现在可以确信客户存在
Customer cus = customerDao.findById(dto.getCustomerId());
// 创建订单的逻辑...
}
对比上述两段代码,我们发现后者不仅大幅减少了代码量,而且通过函数式编程,表达出更为清晰的逻辑意图,可读性和可维护性都有所提高。
优点
- 减少重复代码: 通过
ensureColumnValueValid
方法,所有涉及数据库字段值有效性检查的地方都可以复用相同的逻辑,将变化的部分作为参数传递,大大减少了因特定校验逻辑而产生的代码量。 - 增强代码复用: 抽象化的校验方法适用于多种场景,无论是用户ID、订单号还是其他任何实体属性的校验,一套逻辑即可应对。
- 提升可读性和维护性: 通过清晰的函数签名和 Lambda 表达式,代码意图一目了然,降低了后续维护的成本。
- 灵活性和扩展性: 当校验规则发生变化时,只需要调整
ensureColumnValueValid
方法或其内部实现,所有调用该方法的地方都会自动受益,提高了系统的灵活性和扩展性。
举一反三:拓展校验逻辑的边界
通过上述的实践,我们见识到了函数式编程在简化数据校验逻辑方面的威力。但这只是冰山一角,我们可以根据不同的业务场景,继续扩展和完善校验逻辑,实现更多样化的校验需求。以下两个示例展示了如何在原有基础上进一步深化,实现更复杂的数据比较和验证功能。
断言指定列值等于预期值
首先,考虑一个场景:除了验证数据的存在性,我们还需确认查询到的某列值是否与预期值相符。这在验证用户角色、状态变更等场景中尤为常见。为此,我们设计了 validateColumnValueMatchesExpected
方法:
/**
* 验证查询结果中指定列的值是否与预期值匹配
*
* @param <T> 实体类型
* @param <R> 目标列值的类型
* @param <C> 查询条件列值的类型
* @param targetColumn 目标列的提取函数,用于获取想要验证的列值
* @param expectedValue 期望的列值
* @param conditionColumn 条件列的提取函数,用于设置查询条件
* @param conditionValue 条件列对应的值
* @param queryMethod 执行查询的方法引用,返回单个实体对象
* @param errorMessage 验证失败时抛出异常的错误信息模板
* @throws RuntimeException 当查询结果中目标列的值与预期值不匹配时抛出异常
*/
public static <T, R, C> void validateColumnValueMatchesExpected(
SFunction<T, R> targetColumn, R expectedValue,
SFunction<T, C> conditionColumn, C conditionValue,
SFunction<LambdaQueryWrapper<T>, T> queryMethod,
String errorMessage) {
// 创建查询包装器,选择目标列并设置查询条件
LambdaQueryWrapper<T> wrapper = new LambdaQueryWrapper<>();
wrapper.select(targetColumn);
wrapper.eq(conditionColumn, conditionValue);
// 执行查询方法
T one = queryMethod.apply(wrapper);
// 如果查询结果为空,则直接返回,视为验证通过(或忽略)
if (one == null) return;
// 获取查询结果中目标列的实际值
R actualValue = targetColumn.apply(one);
// 比较实际值与预期值是否匹配,这里假设notMatch是一个自定义方法用于比较不匹配情况
boolean doesNotMatch = notMatch(actualValue, expectedValue);
if (doesNotMatch) {
// 若不匹配,则根据错误信息模板抛出异常
throw new RuntimeException(String.format(errorMessage, expectedValue, actualValue));
}
}
// 假设的辅助方法,用于比较值是否不匹配,根据实际需要实现
private static <R> boolean notMatch(R actual, R expected) {
// 示例简单实现为不相等判断,实际情况可能更复杂
return !Objects.equals(actual, expected);
}
这个方法允许我们指定一个查询目标列(targetColumn)、预期值(expectedValue)、查询条件列(conditionColumn)及其对应的条件值(conditionValue),并提供一个查询方法(queryMethod)来执行查询。如果查询到的列值与预期不符,则抛出异常,错误信息通过 errorMessage 参数定制。
应用场景:
例如在一个权限管理系统中,当需要更新用户角色时,系统需要确保当前用户的角色在更新前是 “普通用户”,才能将其升级为 “管理员”。此场景下,可以使用 validateColumnValueMatchesExpected
方法来验证用户当前的角色是否确实为“普通用户”。
// 当用户角色不是 “普通用户” 时抛异常
validateColumnValueMatchesExpected(User::getRoleType, "普通用户", User::getId, userId, userMapper::getOne, "用户角色不是普通用户,无法升级为管理员!");
断言指定值位于期望值列表内
进一步,某些情况下我们需要验证查询结果中的某一列值是否属于一个预设的值集合。例如,验证用户角色是否合法。为此,我们创建了 validateColumnValueMatchesExpectedList
方法:
/**
* 验证查询结果中指定列的值是否位于预期值列表内
*
* @param <T> 实体类型
* @param <R> 目标列值的类型
* @param <C> 查询条件列值的类型
* @param targetColumn 目标列的提取函数,用于获取想要验证的列值
* @param expectedValueList 期望值的列表
* @param conditionColumn 条件列的提取函数,用于设置查询条件
* @param conditionValue 条件列对应的值
* @param queryMethod 执行查询的方法引用,返回单个实体对象
* @param errorMessage 验证失败时抛出异常的错误信息模板
* @throws RuntimeException 当查询结果中目标列的值不在预期值列表内时抛出异常
*/
public static <T, R, C> void validateColumnValueInExpectedList(
SFunction<T, R> targetColumn, List<R> expectedValueList,
SFunction<T, C> conditionColumn, C conditionValue,
SFunction<LambdaQueryWrapper<T>, T> queryMethod,
String errorMessage) {
LambdaQueryWrapper<T> wrapper = new LambdaQueryWrapper<>();
wrapper.select(targetColumn);
wrapper.eq(conditionColumn, conditionValue);
T one = queryMethod.apply(wrapper);
if (one == null) return;
R actualValue = targetColumn.apply(one);
if (actualValue == null) throw new RuntimeException("列查询结果为空");
if (!expectedValueList.contains(actualValue)) {
throw new RuntimeException(errorMessage);
}
}
这个方法接受一个目标列(targetColumn)、一个预期值列表(expectedValueList)、查询条件列(conditionColumn)及其条件值(conditionValue),同样需要一个查询方法(queryMethod)。如果查询到的列值不在预期值列表中,则触发异常。
应用场景: 在一个电商平台的订单处理流程中,系统需要验证订单状态是否处于可取消的状态列表里(如 “待支付”、“待发货”)才允许用户取消订单。此时,validateColumnValueInExpectedList
方法能有效确保操作的合法性。
// 假设 OrderStatusEnum 枚举了所有可能的订单状态,cancelableStatuses 包含可取消的状态
List<String> cancelableStatuses = Arrays.asList(OrderStatusEnum.WAITING_PAYMENT.getValue(), OrderStatusEnum.WAITING_DELIVERY.getValue());
// 验证订单状态是否在可取消状态列表内
validateColumnValueInExpectedList(Order::getStatus, cancelableStatuses, Order::getOrderId, orderId, orderMapper::selectOne, "订单当前状态不允许取消!");
通过这两个扩展方法,我们不仅巩固了函数式编程在减少代码重复、提升代码灵活性方面的优势,还进一步证明了通过抽象和泛型设计,可以轻松应对各种复杂的业务校验需求,使代码更加贴近业务逻辑,易于理解和维护。
核心优势
- 代码复用:通过泛型和函数式接口,该方法能够适应任何实体类和属性的校验需求,大大减少了重复的查询逻辑代码。
- 清晰表达意图:方法签名直观表达了校验逻辑的目的,提高了代码的可读性和可维护性。
- 灵活性:使用者只需提供几个简单的 Lambda 表达式,即可完成复杂的查询逻辑配置,无需关心底层实现细节。
- 易于维护与扩展:
- 当需要增加新的实体验证时,仅需调用
ensureColumnValueValid
并传入相应的参数,无需编写新的验证逻辑,降低了维护成本。 - 修改验证规则时,只需调整
ensureColumnValueValid
内部实现,所有调用处自动遵循新规则,便于统一管理。 - 异常处理集中于
ensureColumnValueValid
方法内部,统一了异常抛出行为,避免了在多个地方处理相同的逻辑错误,减少了潜在的错误源。
- 当需要增加新的实体验证时,仅需调用
函数式编程的力量
通过这个实例,我们见证了函数式编程在简化代码、提高抽象层次上的强大能力。在 Java 8 及之后的版本中,拥抱函数式编程思想,不仅能够使我们的代码更加简洁、灵活,还能在一定程度上促进代码的正确性和可测试性。因此,无论是日常开发还是系统设计,都值得我们深入探索和应用这一现代编程范式,让代码如魔法般优雅而高效。
来源:juejin.cn/post/7384256110280572980
只写后台管理的前端要怎么提升自己
本人写了五年的后台管理。每次面试前就会头疼,因为写的页面除了表单就是表格。抱怨过苦恼过也后悔过,但是站在现在的时间点回想以前,发现有很多事情可以做的更好,于是有了这篇文章。
写优雅的代码
一道面试题
大概两年以前,面试美团的时候,面试官让我写一道代码题,时间单位转换。具体的题目我忘记了。
原题目我没做过,但是我写的业务代码代码里有类似的单位转换,后端返回一个数字,单位是kb
,而我要展示成 KB
,MB
等形式。大概写一个工具函数(具体怎么写的忘记了,不过功能比这个复杂点):
function formatSizeUnits(kb) {
let units = ['KB', 'MB', 'GB', 'TB', 'PB'];
let unitIndex = 0;
while (kb >= 1024 && unitIndex < units.length - 1) {
kb /= 1024;
unitIndex++;
}
return `${kb.toFixed(2)} ${units[unitIndex]}`;
}
而在此之前,是一个后端同学写的代码(说起来,后台管理系统前端人力不够后端帮忙写好像是常规操作,后端总觉得写前端简单,而我只能说你们那代码写的,一言难尽……),如下:
function formatSizeUnits(kb) {
var result = '';
if (kb < 1024) {
result = kb + ' KB';
} else if (kb < 1024 * 1024) {
result = (kb / 1024).toFixed(2) + ' MB';
} else if (kb < 1024 * 1024 * 1024) {
result = (kb / 1024 / 1024).toFixed(2) + ' GB';
} else {
result = (kb / 1024 / 1024 / 1024).toFixed(2) + ' TB';
}
return result;
}
虽然功能都一样,但是我写在面试的时候写了上面的代码得到了面试官的赞扬,也因此非常顺利的通过了面试。而如果我写成了下面的样子,估计不会有这种效果。
如何提升代码质量
想提升代码质量,最理想的是靠 code review ,但实际上这玩意在大多数公司根本就不存在。其次就只能多看看大佬们写的代码,但是一来后台管理本来就没太大难度很少有大佬,二来很多时候一个后台管理系统就一两个人负责。
还好现在有了各种 AI 工具,可以非常方便的优化自己的代码了。
还是上面的问题,看看 GPT 给的答案
// 提问:写一个工具函数,对文件大小的单位进行转换,加注释
// 回答:下面是一个JavaScript工具函数,它接受文件大小、初始单位、目标单位和小数位数,然后返回目标单位下的文件大小。
/**
* 将文件大小从一个单位转换为另一个单位。
*
* @param {number} size 文件大小。
* @param {string} fromUnit 初始单位('B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB')。
* @param {string} toUnit 目标单位('B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB')。
* @param {number} [decimalPoint=2] 结果保留的小数位数,默认为2。
* @return {string} 转换后的文件大小,带单位。
*/
function convertFileSize(size, fromUnit, toUnit, decimalPoint = 2) {
// 定义单位与字节之间的转换关系
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
// 获取初始单位和目标单位的索引
const fromIndex = units.indexOf(fromUnit);
const toIndex = units.indexOf(toUnit);
// 如果单位不在列表中,抛出错误
if (fromIndex === -1 || toIndex === -1) {
throw new Error('Invalid units');
}
// 计算初始单位与目标单位之间的转换系数
const exponent = toIndex - fromIndex;
// 计算结果大小
const resultSize = size / Math.pow(1024, exponent);
// 返回格式化后的结果
return parseFloat(resultSize.toFixed(decimalPoint)) + ' ' + toUnit;
}
// 示例使用
console.log(convertFileSize(1, 'GB', 'MB')); // 输出: 1024.00 MB
console.log(convertFileSize(1, 'MB', 'KB')); // 输出: 1024.00 KB
console.log(convertFileSize(1, 'KB', 'B')); // 输出: 1024.00 B
console.log(convertFileSize(1, 'MB', 'GB', 5)); // 输出: 0.00098 GB
还能说什么,我只能说 NB!(经评论区提示,此段代码可能有问题,建议大家在使用AI工具时,重点是借鉴思路,直接使用代码需要谨慎~)
我现在每次写大段逻辑之后都习惯拿给 AI 看看,有什么更好的实现方式,或者用什么设计模式。AI 是非常低成本且高效提升代码质量的工具。
学会封装
一个功能用到了好多次,为什么不封装成组件?一个组件用到了好几个项目,为什么不单独写个npm包?差不多的项目创建了好几个,为什么不封装成脚手架?
你说,没时间,没必要,复制粘贴反而更快。
那你就完全没理解,这么做不一定是为了让工作更快完成,而是可以让你在年年终述职时更有话说(你就算写了一百个表单表格没有写一个脚手架更值得炫耀),如果不会写可以问问 AI。
而当你真正开始封装组件,开始写工具库了,你会发现你需要思考的确实比之前多了。
关注业务
对于前端业务重要吗?
相比于后端来说,前端一般不会太关注业务。就算出了问题大部分也是后端的问题。
但是就我找工作的经验,业务非常重要!
如果你做的工作很有技术含量,比如你在做低代码,你可以面试时讲一个小时的技术难点。但是你只是一个破写后台管理,你什么都没有的说。这个时候,了解业务就成为了你的亮点。
一场面试
还是拿真实的面试场景举例,当时前同事推我字节,也是我面试过N次的梦中情厂了,刚好那个组做的业务和我之前呆的组做的一模一样。
- 同事:“做的东西和咱们之前都是一样的,你随便走个过场就能过,我在前端组长面前都夸过你了!”
- 我:“好嘞!”
等到面试的时候:
- 前端ld:“你知道xxx吗?(业务名词)”
- 我:“我……”
- 前端ld:“那xxxx呢?(业务名词)”
- 我:“不……”
- 前端ld:“那xxxxx呢??(业务名词)”
- 我:“造……”
然后我就挂了………………
如何了解业务
- 每次接需求的时候,都要了解需求背景,并主动去理解
我们写一个表格简简单单,把数据展示出来就好,但是表格中的数据是什么意思呢?比如我之前写一个 kafka 管理平台,里面有表格表单,涉及什么
cluster
controller
topic
broker
partition
…… 我真的完全不了解,很后悔我几年时间也没有耐下心来去了解。 - 每次做完一个需求,都需要了解结果
有些时候,后台管理的团队可能根本没有PM,那你也要和业务方了解,这个功能做了之后,多少人使用,效率提高了吗?数据是怎样的?
- 理解需求,并主动去优化
产品要展示一千条数据,你要考虑要不要分页,不分页会不会卡,要不要上虚拟表格?
产品要做一个可拖拽表单,你要考虑是否需要拖动,是否需要配置。
其实很多时候,产品的思维可能会被局限在竞品的实现方式,而前端可以给TA更多选项。在和产品沟通的时候,你不仅是沟通页面的实现,也更能理解业务。
关注源码
说到源码, Vue,React 这些基本是每次必问,也有太多大佬们的总结我就不多说了。
除此之外,我想大家每次面试应该都会被问,你写了什么亮点的项目,困难的项目,你会说什么?哦,都是表单表格,都用 Element UI 实现的,没什么难度。那估计你面试也就到这里了。
那说什么,后台管理就这些啊?!
如果你用了 Element UI 你可以说写了什么自定义 Element UI 表单组件,Element UI 表单源码是什么原理,怎么通信的?用了 Axios 可以说 Axios 的原理,它的拦截器怎么实现的?用了编辑器 diff 那你知道 diff 算法是什么吗?用了 ECharts 那你知道他底层是用什么绘制的吗?用了 husky ?husky的原理是什么?用了Vite,它为什么快?是怎么打包的?写过插件吗?
可能有人觉得我离谱,事实上上面每一个问题都是我在面试中真实被问到的。当然,如果全部去了解,肯定不现实,每个人的精力有限,不可能每个用到的每个工具每个库都去了解原理,不过只要有一两个你去了解过,就可以在面试中引导面试官来问,要学会表现出你擅长的点。
至于怎么读源码?以前我不知道,现在嘛…… 为什么不问问神奇的 ChatGPT 呢?
讲一下 Axios 源码中,拦截器是怎么实现的?
Axios 的拦截器实现基于 Axios 的核心原理,即 Axios 实例是一个包含请求和响应拦截器堆栈的对象。当发出请求或接收响应时,Axios 会遍历这些拦截器,并按照添加的顺序执行请求拦截器,以及按照相反的顺序执行响应拦截器。
在 Axios 的源码中,拦截器是通过一个 AxiosInterceptorManager 实例来管理的,它维护了一个拦截器数组。每个拦截器都是一个包含
fulfilled
和rejected
函数的对象。这两个函数分别对应于拦截器成功处理和拦截器处理出错的情况。
以下是 Axios 拦截器管理器的一个简化版本,展示了其核心实现思路:
class InterceptorManager {
constructor() {
this.handlers = []; // 存储拦截器的数组
}
use(fulfilled, rejected) {
this.handlers.push({
fulfilled: fulfilled,
rejected: rejected
});
return this.handlers.length - 1; // 返回拦截器的ID
}
eject(id) {
if (this.handlers[id]) {
this.handlers[id] = null; // 移除拦截器
}
}
forEach(fn) {
this.handlers.forEach((h) => {
if (h !== null) {
fn(h);
}
});
}
}
在发送请求或接收响应时,Axios 会创建一个 promise 链,并通过
forEach
方法将拦截器中的fulfilled
和rejected
函数添加到这个链中。这样,每个拦截器都可以对请求或响应进行处理,然后将结果传递到链的下一个拦截器,或者在出错时结束链的执行。
axios.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) {
chain.unshift(interceptor.fulfilled, interceptor.rejected);
});
axios.interceptors.response.forEach(function pushResponseInterceptors(interceptor) {
chain.push(interceptor.fulfilled, interceptor.rejected);
});
在 Axios 的完整实现中,这个拦截器机制被集成到了 Axios 的请求发送和响应处理流程中。通过这种方式,Axios 可以在发送请求之前和接收响应之后,但在用户定义的
.then
或.catch
执行之前,插入自定义的逻辑。
请注意,这里提供的代码只是为了说明 Axios 拦截器的实现原理,并不是 Axios 源码的完整复制。如果你对 Axios 的拦截器实现细节感兴趣,建议查看 Axios 的官方 GitHub 仓库中的源码。
前端基建
当我们工作时间久了面试难免会遇到这些问题,前端工程化,前端监控,工作流,部署,性能等等。其实我们在工作中绝大部分时间都在写代码,对于这些不是所有人都有机会接触到,不过这些和所做的业务无关,是我们提升自己很好的一个思路。
技术选型
技术栈选 Vue 还是 React?Vue 选 Vue2 还是 Vue3?组件库选 ElementUI 还是 Ant Design?微前端有没有使用过?打包工具用 Vite 还是 Webpack?有那么多表单怎么实现的,有没有什么表单配置化方案,比如Formily?
对于我这种菜鸡,我这种只写简单的表单表格的人,这些都……无所谓……
不过为了应对面试我们还是需要了解下未选择技术栈的缺点,和已选择技术栈的优点(有点本末倒置…但是常规操作啦)
Vue 你可以说简单高效轻量级,面试必会问你为什么,你就开始说 Vue 的响应式系统,依赖收集等。
React 你可以说 JSX、Hooks 很灵活,那你必然要考虑 JSX 怎么编译, Hooks 实现方式等。
总体而言,对于技术选型,依赖于我们对所有可选项的理解,做选择可能很容易,给出合理的理由还是需要花费一些精力的。
开发规范
这个方面,在面试的时候我被问到的不多,我们可以在创建项目的时候,配置下 ESlint
,stylelint
, prettier
, commitlint
等。
前端监控
干了这么多年前端,前端监控我是……一点没做过。
前端监控,简单来说就是我们在前端程序中记录一些信息并上报,一般是错误信息,来方便我们及时发现问题并解决问题。除此之外也会有性能监控,用户行为的监控(埋点)等。之前也听过有些团队分享前端监控,为了出现问题明确责任(方便甩锅)。
对于实现方案,无论使用第三方库还是自己实现,重要的都是理解实现原理。
对于错误监控,可以了解一下 Sentry,原理简单来说就是通过 window.onerror
和 window.addEventListener('unhandledrejection', ...)
去分别捕获同步和异步错误,然后通过错误信息和 sourceMap
来定位到源码。
对于性能监控,我们可以通过 window.performance
、PerformanceObserver
等 API 收集页面性能相关的指标,除此之外,还需要关注接口的响应时间。
最后,收集到信息之后,还要考虑数据上报的方案,比如使用 navigator.sendBeacon
还是 Fetch、AJAX?是批量上报,实时上报,还是延迟上报?上报的数据格式等等。
CI/CD
持续集成(Continuous Integration, CI)和 持续部署(Continuous Deployment, CD),主要包括版本控制,代码合并,构建,单测,部署等一系列前端工作流。
场景的工作流有 Jenkins、 Gitlab CI 等。我们可以配置在合并代码时自动打包部署,在提交代码时自动构建并发布包等。
这块我了解不多,但感觉这些工具层面的东西,不太会涉及到原理,基本上就是使用的问题。还是需要自己亲自动手试一下,才能知道细节。比如在 Gitlab CI 中, Pipeline
、 Stage
和 Job
分别是什么,怎么配置,如何在不同环境配置不同工作流等。
了解技术动态
这个可能还是比较依赖信息收集能力,虽然我个人觉得很烦,但好像很多领导级别的面试很愿意问。
比如近几年很火的低代码,很多面试官都会问,你用过就问你细节,你没用过也会问你有什么设计思路。
还有最近的两年爆火的 AI,又或者 Vue React的最新功能,WebAssembly,还有一些新的打包工具 Vite Bun 什么的,还有鸿蒙开发……
虽然不可能学完每一项新技术,但是可以多去了解下。
总结
写了这么多,可能有人会问,如果能回到过去,你会怎么做。
啊,我只能说,说是一回事,做又是另一回事,事实上我并不希望回到过去去卷一遍,菜点没关系,快乐就好,一切都是最好的安排。
来源:juejin.cn/post/7360528073631318027
为什么小明刚入职就碰到跨域问题然后转身离职?
故事的开始
故事还要从某个大厂的前端工程师 小明 说起。
小明兴高采烈的入职了某大厂的前端工程师岗位,就小明的水平的话,写写前端代码不在话下。
周四这天,小明吃完肯德坤之后,打开电脑浏览器,刷新页面,控制台就出现了下面的报错信息:
小明摸了摸自己大大的鼻子,一眼就看到了问题:
demo.hamm.cn
访问api.hamm.cn
跨域了。
凭借多年的经验,小明瞬间就想到了解决方案:
那就摇人
因为刚入职,碰到这个问题他也不知道该找哪个人来处理,因为他没有 API 服务器的权限,于是在群里说了一句:
小明(前端):“哪位大哥帮忙配置下跨域, api.hamm.cn 需要允许来自 demo.hamm.cn 的跨域请求。”
三分钟后...
小贾(Java):“收到,我看看”
小董(运维):“好的,现在在外面,半小时后回”
五分钟后,小明收到了来自 Java 同学 小贾 的私聊:
小贾(Java):“好的,已配置好跨域头,你看看”
小明(后端):“咦,可以了。感谢大哥!”
出幺蛾子了
半小时后,运维的 小董 同学回到公司,马不停蹄的给配了跨域头。
刚准备私聊告诉小明,却见 Java 的小贾同学在群里 @ 自己:
小贾(Java):“@小董(运维) 董哥,你的网关侧是不是有问题,我配置了跨域头,小明这边刚才还好好的可以跨域,这会又炸了。。。”
只见小明发了张图:
群里炸锅了
咦,两个请求头
怎么请求头两个了之后反而跨域失败了呢?
查找问题
运维哥 和 Java哥 一脸懵,于是开始查找问题。
只见小明不慌不忙的在群里 @ 了两人:
小明(前端):“@小董(运维) @小贾(Java) 两位哥,你们俩应该都配置了跨域头,两个跨域头也会跨域失败的哦~”
小董(运维):“@小贾(Java) 你应用服务里的去掉吧”
小贾(Java):“...”,心想 不是我先配置的么,凭什么。。。
你们都是在网关侧还是业务侧配置的允许跨域呢?欢迎评论区讨论
多跨域头禁止跨域
原来,多跨域头下,浏览器认为数据可能是被中间代理过,觉得不够安全,所以禁止了跨域访问。
MDN关于跨域 origin 的解释:
developer.mozilla.org/zh-CN/docs/…
第二天
小明递上了自己的离职申请书。
来源:juejin.cn/post/7444840771779690530
即梦AI上线新功能,可一句话生成中文海报
近日,即梦AI升级了图片生成功能,用户使用即梦pc版或app时,选择最新上线的图片2.1生图模型,通过输入文本描述,即可生成带有指定文字的海报。
例如,输入:生成一张含有筷子夹起饺子的冬至插画海报,标题是“Winter Solstice”下方是“冬至”两字,即梦就能按照指令快速完成。
据测试用户反馈,即梦AI新功能已经可以较为准确地生成中文文字,生图效果也更具影视质感。测试期间,用户已衍生出表情包、四格漫画、手写风格等多种玩法,更大限度地释放了创意。
字节豆包大模型团队相关负责人表示,豆包文生图模型通过打通LLM和DIT架构,具备更好的原生中文数据学习能力,并在此基础上强化汉字生成能力,大幅提升了生成效果。不过目前对于复杂的汉字生成还有提升的空间。据即梦相关负责人,团队正持续对文生图功能进行优化升级,近期还将上线对生成文字进行涂抹修改的功能,助力创作者们更好地实现想象力。
即梦AI是字节跳动旗下的AI内容平台,支持通过自然语言及图片输入,生成高质量的图像及视频。平台提供智能画布、故事创作模式,以及首尾帧、对口型、运镜控制、速度控制等AI编辑能力,并有海量影像灵感及兴趣社区,一站式提供用户创意灵感、流畅工作流、社区交互等资源,为用户的创作提效。(作者:李双)
收起阅读 »微信小程序批量自动化部署
CI/CD这个概念很实用,但我们这种小作坊,没有一些很高大上的应用。
最常见的使用场景就是,开发者一键提交分支master,交给工作流工具完成构建,部署的后续操作,自动更新测试或线上环境。
个人博客等项目可以使用Github action来实现,但公司的代码在云效上,我更习惯于使用云效Flow来实现自动化部署。他的操作菜单是可视化的,非常方便,还有一些推送机器人消息的傻瓜化配置插件。
目前遇到一个需求,就是同一个uni-app小程序项目,需要部署到多个不同的小程序上。每个小程序的主要功能类似,但都有一些定制改动。
每次项目发版时,如果要手动挨个在微信开发者工具上切换、上传,会非常繁琐,而且uni-app使用dev命令输出的开发环境微信小程序项目代码也没有优化,正式发版时哪怕只有一个小程序也需要在dev、build两个项目里来回切。
因此非常需要自动化部署来节省精力。
下面梳理一下微信小程序的批量自动化部署实现流程。
准备工作
常见的web项目自动化部署,至少包含代码触发、构建、部署这3个步骤。
其中构建步骤中操作的产物会被打包上传,并在部署步骤中,下载到目标服务器,然后执行后续目录操作、启动等操作。
但是微信小程序的部署不需要这些操作,而是通过在node脚本中执行miniprogram-ci
这个工具的相关方法来实现的。
miniprogram-ci
的相关文档请参考这里。
密钥及IP白名单配置
跟着文档操作,首先需要到微信小程序管理后台的开发设置中进行配置。
点击生成按钮即可创建密钥,关闭后只能重新生成。
将密钥文件下载到安全的位置。由于我们的项目是私有库,这里就直接放到了项目deploy目录下。多个小程序的密钥可以放在一起,默认已经用appId做了区分。
云效Flow的构建集群提供了一组IP地址,将这些IP地址加入白名单即可。地址如下:
47.94.150.88
47.94.150.17
47.93.89.246
123.56.255.38
112.126.70.240
IP地址不在白名单的话,调用上传时会报错。如果在本地调试,别忘了将本机的公网IP加入白名单,或者临时关闭。
构建脚本
uni-app项目使用vite框架,这里用到了.env环境变量的相关功能,原生微信小程序请自行实现或省略此功能。
更新版本号
微信小程序上传时,需要指定版本号。
版本号标准的用法还是放在package.json中,所以我在自动化部署实现过程中,顺便就引入了standard-version
版本号管理。(项目被标记为deprecated,但我没有找到其他适合私有库的版本号管理工具,欢迎指点。)
standard-version
可以自动根据git提交记录生成CHANGELOG.md
。
并按照以下初始规则来生成版本号:
如果上个版本之间的提交只有fixed,更新patch版本号,比如1.0.0
更新到1.0.1
。
否则更新minor版本号,比如1.0.0
更新到1.1.0
。
更新版本号的同时,它会将CHANGELOG.md
与更新版本号以后的package.json
一同提交到git,同时创建一个版本号对应的tag。
在一般项目中这样就足够了,但是如果还想在小程序中展示这个版本号,就会存在问题——无法引入package.json文件。
而且使用wx.getAccountInfoSync虽然也能获取版本号,但只有正式环境能用,在体验版、开发版中是空字符串。
因此,我只能修改部署版本命令,加上了一些后处理脚本,将版本号同步更新到环境变量中。
在package.json的script中,添加以下命令:
{
"scripts": {
"release": "standard-version && node deploy/deploy.js version",
"release:minor": "standard-version -- --release-as minor && node deploy/deploy.js version",
"release:beta": "standard-version -p beta && node deploy/deploy.js version"
}
}
多提一嘴,release:minor是在提交记录只有fixed但又希望更新minor版本时使用的,可以无视默认规则。当然也可以无视所有规则直接指定具体版本号,具体使用可查看文档(github.com/conventiona…
后处理脚本
在deploy目录下,创建deploy.js文件,内容如下:
const fs = require('node:fs')
const path = require('node:path')
const process = require('node:process')
const { execSync } = require('node:child_process')
const JSON5 = require('json5')
const ci = require('miniprogram-ci')
const { Command } = require('commander')
const dayjs = require('dayjs')
const dotenv = require('dotenv')
const { version } = require('../package.json')
const program = new Command()
// 同步版本号
program
.command('version')
.option('-a, --appid <type>', 'application id')
.action((options) => {
const envPath = path.resolve(__dirname, '../.env')
// 读取 .env 文件的内容
const envContent = fs.readFileSync(envPath, 'utf8')
// 分割每一行
const lines = envContent.split('\n')
// 定义新的内容数组
const newLines = []
// 遍历每一行,查找并修改 VITE_APP_VERSION 的值
lines.forEach((line) => {
if (line.startsWith('VITE_APP_VERSION=')) {
newLines.push(`VITE_APP_VERSION=${version}`)
}
else {
newLines.push(line) // 保留其他行,包括注释
}
})
// 将修改后的内容写回 .env 文件
fs.writeFileSync(envPath, newLines.join('\n'))
// 添加文件到暂存区
execSync(`git add ${envPath}`)
// 获取前一次提交的标签
let tag
try {
tag = execSync('git describe --tags --abbrev=0').toString().trim()
}
catch (error) {
console.error('没有找到标签')
process.exit(1)
}
// 将当前暂存区的改动追加到前一次提交中
execSync('git commit --amend --no-edit')
// 删除旧的标签
execSync(`git tag -d ${tag}`)
// 将标签移动到新的提交
execSync(`git tag ${tag}`)
})
program.parse(process.argv)
这个脚本会读取.env文件,找到VITE_APP_VERSION这一行,将其值更新为package.json中的version,然后将改动合并到前一次的git提交中,也就是standard-version所创建的提交。
没有用dotenv是因为这个工具更适合读取配置,但写入时会丢失注释信息。
构建小程序
如果只有一个小程序,可以略过此步,直接执行构建命令然后上传。
有多个小程序时,需要先执行一些定制脚本,再执行构建。比如至少要做的一项操作是更新appId,在uni-app中,这项配置位于manifest.json中。
在deploy/deploy.js
中添加以下代码:
// 切换小程序
program
.command('toggle')
.option('-a, --appid <type>', 'application id')
.action((options) => {
if (!options.appid) {
console.error('请输入 application id')
process.exit(1)
}
// 定义文件路径
const filePath = path.join(__dirname, '../src/manifest.json')
// 读取 JSON 文件
fs.readFile(filePath, 'utf8', (err, data) => {
if (err) {
console.error('读取文件失败:', err)
return
}
try {
// 解析 JSON 数据(支持注释)
const jsonData = JSON5.parse(data)
// 修改 appid 字段
jsonData['mp-weixin'].appid = options.appid
// 将修改后的 JSON 数据转换为字符串(支持注释格式)
console.log(jsonData)
const updatedData = JSON.stringify(jsonData, null, 2)
console.log(updatedData)
// 写入修改后的数据到 JSON 文件
fs.writeFile(filePath, updatedData, 'utf8', (err) => {
if (err) {
console.error('写入文件失败:', err)
return
}
console.log('文件已成功更新')
})
}
catch (err) {
console.error('解析 JSON 数据失败:', err)
}
})
})
这个脚本会读取manifest.json文件,找到mp-weixin.appid这一行,将其值更新为命令行参数中的appid,然后将改动写入manifest.json文件。
调用脚本的命令例子为:
node deploy/deploy.js toggle --appid=你的appid
上传小程序
在deploy/deploy.js
中添加以下代码:
// 上传小程序
program
.command('upload')
.option('-a, --appid <type>', 'application id')
.action((options) => {
if (!options.appid) {
console.error('请输入 application id')
process.exit(1)
}
// 获取当前工作目录的父路径
const projectDir = path.join(__dirname, '../')
const project = new ci.Project({
appid: options.appid,
type: 'miniProgram',
projectPath: `${projectDir}/dist/build/mp-weixin`,
privateKeyPath: `${projectDir}/deploy/private.${options.appid}.key`,
// ignores: ['node_modules/**/*'],
})
ci.upload({
project,
version,
desc: `CI机器人于${dayjs().format('YYYY-MM-DD HH:mm:ss')}上传`,
setting: {
es6: true,
es7: true,
minify: true,
// autoPrefixWXSS: true,
minifyWXML: true,
minifyJS: true,
},
}).then((res) => {
console.log(res)
console.log('上传成功')
process.exit(0)
}).catch((error) => {
if (error.errCode === -1) {
console.log('上传成功')
process.exit(0)
}
console.log(error)
console.log('上传失败')
process.exit(-1)
})
})
program.parse(process.argv)
这个脚本会调用微信小程序的CI接口,将小程序上传到微信服务器。调用脚本的命令例子为:
node deploy/deploy.js upload --appid=你的appid
其中appid在命令行中传入,而version是从package.json中读取的。
完整的deploy.js文件
const fs = require('node:fs')
const path = require('node:path')
const process = require('node:process')
const { execSync } = require('node:child_process')
const JSON5 = require('json5')
const ci = require('miniprogram-ci')
const { Command } = require('commander')
const dayjs = require('dayjs')
const dotenv = require('dotenv')
const { version } = require('../package.json')
const program = new Command()
// 同步版本号
program
.command('version')
.option('-a, --appid <type>', 'application id')
.action((options) => {
const envPath = path.resolve(__dirname, '../.env')
// 读取 .env 文件的内容
const envContent = fs.readFileSync(envPath, 'utf8')
// 分割每一行
const lines = envContent.split('\n')
// 定义新的内容数组
const newLines = []
// 遍历每一行,查找并修改 VITE_APP_VERSION 的值
lines.forEach((line) => {
if (line.startsWith('VITE_APP_VERSION=')) {
newLines.push(`VITE_APP_VERSION=${version}`)
}
else {
newLines.push(line) // 保留其他行,包括注释
}
})
// 将修改后的内容写回 .env 文件
fs.writeFileSync(envPath, newLines.join('\n'))
// 添加文件到暂存区
execSync(`git add ${envPath}`)
// 获取前一次提交的标签
let tag
try {
tag = execSync('git describe --tags --abbrev=0').toString().trim()
}
catch (error) {
console.error('没有找到标签')
process.exit(1)
}
// 将当前暂存区的改动追加到前一次提交中
execSync('git commit --amend --no-edit')
// 删除旧的标签
execSync(`git tag -d ${tag}`)
// 将标签移动到新的提交
execSync(`git tag ${tag}`)
})
// 切换小程序
program
.command('toggle')
.option('-a, --appid <type>', 'application id')
.action((options) => {
if (!options.appid) {
console.error('请输入 application id')
process.exit(1)
}
// 定义文件路径
const filePath = path.join(__dirname, '../src/manifest.json')
// 读取 JSON 文件
fs.readFile(filePath, 'utf8', (err, data) => {
if (err) {
console.error('读取文件失败:', err)
return
}
try {
// 解析 JSON 数据(支持注释)
const jsonData = JSON5.parse(data)
// 修改 appid 字段
jsonData['mp-weixin'].appid = options.appid
// 将修改后的 JSON 数据转换为字符串(支持注释格式)
console.log(jsonData)
const updatedData = JSON.stringify(jsonData, null, 2)
console.log(updatedData)
// 写入修改后的数据到 JSON 文件
fs.writeFile(filePath, updatedData, 'utf8', (err) => {
if (err) {
console.error('写入文件失败:', err)
return
}
console.log('文件已成功更新')
})
}
catch (err) {
console.error('解析 JSON 数据失败:', err)
}
})
})
// 上传小程序
program
.command('upload')
.option('-a, --appid <type>', 'application id')
.action((options) => {
if (!options.appid) {
console.error('请输入 application id')
process.exit(1)
}
// 获取当前工作目录的父路径
const projectDir = path.join(__dirname, '../')
const project = new ci.Project({
appid: options.appid,
type: 'miniProgram',
projectPath: `${projectDir}/dist/build/mp-weixin`,
privateKeyPath: `${projectDir}/deploy/private.${options.appid}.key`,
// ignores: ['node_modules/**/*'],
})
ci.upload({
project,
version,
desc: `CI机器人于${dayjs().format('YYYY-MM-DD HH:mm:ss')}上传`,
setting: {
es6: true,
es7: true,
minify: true,
// autoPrefixWXSS: true,
minifyWXML: true,
minifyJS: true,
},
}).then((res) => {
console.log(res)
console.log('上传成功')
process.exit(0)
}).catch((error) => {
if (error.errCode === -1) {
console.log('上传成功')
process.exit(0)
}
console.log(error)
console.log('上传失败')
process.exit(-1)
})
})
program.parse(process.argv)
如果是原生微信小程序,且使用了npm依赖,只需要在upload之前执行一下构建命令即可:
// 在有需要的时候构建npm
const warning = await ci.packNpm(project, {
ignores: [],
reporter: (infos) => { console.log(infos) }
})
console.warn(warning)
// ci.upload()
在本地调试时,用以下命令即可模拟构建的完整操作了:
node deploy/deploy.js toggle --appid=小程序A
pnpm run build:mp-weixin:小程序A
node deploy/deploy.js upload --appid=小程序A
注意这里的build命令,对应package.json中脚本的写法为:
"build:mp-weixin:小程序A": "uni build -p mp-weixin --mode 小程序A",
传入mode参数时,执行时会读取.env.小程序A
中定义的环境变量,从而实现一些定制化的操作。
可以将这组命令写成sh脚本,每个小程序一个,都放在deploy目录下,在Flow工作流中调用。
上传命令执行成功后,微信小程序后台版本管理中就可以看到这个版本了:
后续的提审、发布操作目前仍需人工操作。
配置云效Flow
本地调试正常后,最后来配置云效Flow。
前面的代码触发不变,后面的部署步骤可以直接删除。
构建脚本为:
npm i -g pnpm
pnpm config set registry https://registry.npmmirror.com
pnpm i
node deploy/deploy.js toggle --appid=小程序A
pnpm run build:mp-weixin:小程序A
node deploy/deploy.js upload --appid=小程序A
如果有多个小程序,可以配置多个并行步骤:
待优化
依赖应该只需要安装一次,即将安装依赖步骤与构建步骤分开。
(可选)配置通知机器人
在构建步骤窗口的底部,可以添加通知插件。
这里使用的是钉钉机器人,教程参考这里
大致步骤为:
- 在钉钉中拉上同事或者小号,凑满3人,创建一个外部群。
- 在钉钉群的群设置中,添加机器人,获得api接口地址与签名。
- 在云效Flow的钉钉机器人插件中填入接口地址与签名。
此后每次发版,只需提版合并到master分支,等待片刻收到钉钉机器人的提示,就可以准备提审了。
参考来源:
来源:juejin.cn/post/7392558409743548466
实现敏感字段脱敏注解@Sensitive
前言
在B2C项目中,就以电商项目举例,都有前台与后台。并且这类项目的后台往往都会开放给公司内大部分人,甚至有些是将电商项目作为Saas服务提供给外部厂商的,这样后台中记录的用户数据就成为一个风险点,随着越来越多的人可以接触到后台系统,我们必须对用户的数据进行加密不仅限于在数据库层面加密存储,前端展示的时候也必须要对例如:手机号,地址,身-份-证号等等隐私数据进行脱敏处理。
实现方式
1.最容易想到的就是利用硬编码的形式,哪些接口中涉及到了隐私数据,我们就去接口中对隐私数据进行脱敏。(ps一开始我确实是这么做的)
2.但是我发现太多太多接口都需要使用用户隐私数据了,我人工一个一个手工改也太不优雅了!我就想到我们能不能在SpringMVC将数据写入response的时候就将他拦截住,然后我实现一个注解,其实这个注解也就是一个标识。我们通过反射对于被这个注解标注的字段进行脱敏处理,然后再写回对象中。
这样不就可以只对响应类中加一个注解,然后所有使用用户敏感数据的接口都直接脱敏了吗,而且我们也可以很方便的改变我们的脱敏策略!!!
代码
hutools工具依赖
最适合中国宝宝体质的中国工具包,虽然网上很多人喷他,但是我个人觉得还是挺好用的,可能是我段位还不够。
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.11</version>
</dependency>
@Sensitive注解
/**
* @projectName: BlossomKnowledge
* @package: blossom.project.bk.common.annotaion
* @className: Sensitive
* @author: Link Ji
* @description: GOGO
* @VX: _Aeeee86
* @date: 2024/9/28 16:36
* @version: 1.0
*/
@Retention(java.lang.annotation.RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface Sensitive {
SensitiveDataType type() default SensitiveDataType.PASSWORD;
}
脱敏策略枚举类
/**
* @projectName: BlossomKnowledge
* @package: blossom.project.bk.common.enums
* @className: SensitiveDataType
* @author: Link Ji
* @description: GOGO
* @VX: _Aeeee86
* @date: 2024/9/28 16:40
* @version: 1.0
*/
public enum SensitiveDataType {
//脱敏数据类型
NAME("name"),
ID_CARD("idCard"),
PHONE("phone"),
EMAIL("email"),
BANK_CARD("bankCard"),
ADDRESS("address"),
PASSWORD("password"),
;
SensitiveDataType(String type) {
this.type = type;
}
@Getter
private String type;
}
响应拦截器
这里就是最核心的代码了,利用了SpringMVC提供的钩子接口,ResponseBodyAdvice接口,其中提供了一个beforeBodyWrite方法,这个方法就可以在数据写入响应前可以对数据进行处理。
/**
* @projectName: BlossomKnowledge
* @package: blossom.project.bk.common.enums
* @className: SensitiveDataType
* @author: Link Ji
* @description: GOGO
* @VX: _Aeeee86
* @date: 2024/9/28 16:40
* @version: 1.0
*/
@ControllerAdvice
public class SensitiveDataAdvice implements ResponseBodyAdvice<Object> {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
// 拦截所有响应
return true;
}
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, org.springframework.http.MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
org.springframework.http.server.ServerHttpRequest request,
org.springframework.http.server.ServerHttpResponse response) {
// 如果返回类型是result
if (body instanceof Result<?>){
// 处理对象,进行脱敏操作
handleSensitiveFields((Result<?>) body);
}
return body;
}
private void handleSensitiveFields(Result<?> res) {
Object data = res.getData();
//获取data的下的全部字段
if (data == null) {
return;
}
Field[] fields = data.getClass().getDeclaredFields();
for (Field field : fields) {
// 判断是否有 @SensitiveData 注解
if (field.isAnnotationPresent(Sensitive.class)) {
Sensitive annotation = field.getAnnotation(Sensitive.class);
SensitiveDataType sensitiveDataType = annotation.type();
field.setAccessible(true);
try {
Object value = field.get(data);
if (value instanceof String) {
// 执行脱敏操作
String maskedValue = DesensitizationUtils.maskData((String) value, sensitiveDataType.getType());
field.set(data, maskedValue);
}
} catch (IllegalAccessException e) {
e.printStackTrace();
}
}
}
}
}
脱敏工具类
这个工具类依赖于hutools提供的DesensitizedUtil
public class DesensitizationUtils {
public static String maskData(String data, String type) {
if (data == null) {
return null;
}
//使用switch匹配SensitiveDataType枚举中的类型,并且使用hutool脱敏工具类进行脱敏
return switch (type) {
case "name" -> DesensitizedUtil.chineseName(data);
case "idCard" -> DesensitizedUtil.idCardNum(data, 2, data.length() - 2);
case "phone" -> DesensitizedUtil.mobilePhone(data);
case "email" -> DesensitizedUtil.email(data);
case "bankCard"-> DesensitizedUtil.bankCard(data);
case "address" -> DesensitizedUtil.address(data, data.length() - 6);
default -> data;
};
}
}
效果演示
来源:juejin.cn/post/7419148660796293139
Spring Boot + liteflow 居然这么好用!实战
在我们的日常开发中,经常会遇到一些需要串行
或并行
处理的复杂业务流程。
那我们该如何利用Spring Boot
结合liteflow
规则引擎来简化我们的业务流程
先看一个实战案例!!
在电商场景下,当订单完成后,我们需要同时进行积分发放和消息发送。
这时候,我们可以利用liteflow
进行规则编排,处理这些并行任务。
1. 引入依赖
首先,在pom.xml
文件中添加liteflow
的依赖:
xml
<dependency>
<groupId>com.yomahubgroupId>
<artifactId>liteflow-spring-boot-starterartifactId>
<version>2.6.5version>
dependency>
2. 增加配置
在application.yml
文件中添加liteflow
的配置:
yaml
spring:
application:
name: liteflow-demo
liteflow:
rule-source: "classpath:flow.xml" # 指定规则文件的位置
node-retry: 3 # 节点重试次数
thread-executor:
core-pool-size: 10 # 线程池核心线程数
max-pool-size: 20 # 线程池最大线程数
keep-alive-time: 60 # 线程存活时间
3. 编写规则文件
在resources
目录下创建flow.xml
文件,编写规则文件内容:
xml
<flow>
<parallel>
<node id="pointNode"/>
<node id="messageNode"/>
parallel>
flow>
4. 编写业务逻辑组件
按照规则文件中的定义,编写相应的业务逻辑组件:
java
@LiteflowComponent("pointNode")
public class PointNode extends NodeComponent {
@Override
public void process() throws Exception {
// 发放积分逻辑
System.out.println("Issuing points for the order");
}
}
@LiteflowComponent("messageNode")
public class MessageNode extends NodeComponent {
@Override
public void process() throws Exception {
// 发送消息逻辑
System.out.println("Sending message for the order");
}
}
5. 流程触发
当订单完成后,我们需要触发liteflow
的流程来执行积分发放和消息发送的逻辑。
我们可以在订单完成的服务方法中添加如下代码:
java
@Service
public class OrderService {
@Autowired
private FlowExecutor flowExecutor;
public void completeOrder(Order order) {
// 完成订单的其他逻辑
System.out.println("Order completed: " + order.getId());
// 执行liteflow流程
flowExecutor.execute2Resp("flow", order);
}
}
在上述代码中,我们使用
FlowExecutor
来执行liteflow
流程,并将订单对象传递给流程。这将触发
flow.xml
中定义的规则,执行并行的积分发放和消息发送逻辑。
性能统计
liteflow
在启动时完成规则解析和组件注册,保证高性能的同时,还能统计各业务环节的耗时,帮助我们进行性能优化。
以下是一个性能统计示例:
java
@LiteflowComponent("performanceNode")
public class PerformanceNode extends NodeComponent {
@Override
public void process() throws Exception {
long start = System.currentTimeMillis();
// 业务逻辑
long end = System.currentTimeMillis();
System.out.println("PerformanceNode execution time: " + (end - start) + "ms");
}
}
liteflow
组件概览
在liteflow
中,主要有以下几种组件:
- 普通组件:集成
NodeComponent
,用于执行具体的业务逻辑;- 选择组件:通过业务逻辑选择不同的执行路径;
- 条件组件:基于条件返回结果,决定下一步的业务流程。
java
// 普通组件示例
@LiteflowComponent("commonNode")
public class CommonNode extends NodeComponent {
@Override
public void process() throws Exception {
// 业务逻辑
System.out.println("Executing commonNode logic");
}
}
// 选择组件示例
@LiteflowComponent("choiceNode")
public class ChoiceNode extends NodeSwitchComponent {
@Override
public String processSwitch() throws Exception {
// 根据条件返回不同的节点ID
return "nextNodeId";
}
}
// 条件组件示例
@LiteflowComponent("conditionNode")
public class ConditionNode extends NodeIfComponent {
@Override
public boolean processIf() throws Exception {
// 判断条件
return true;
}
}
EL规则文件
在liteflow
中,规则文件可以采用XML格式编写,下面是一个简单的规则文件示例。
xml
id="commonNode"/>
id="conditionNode">
id="nextNode"/>
id="otherNode"/>
id="choiceNode">
id="case1" to="node1"/>
id="case2" to="node2"/>
如何使用EL规则文件
- 创建规则文件:将上述规则文件保存为
flow.xml
,放在项目的resources
目录下; - 配置
liteflow
:在Spring Boot项目中添加liteflow
的配置,指定规则文件的位置;
yaml
liteflow:
rule-source: "classpath:flow.xml"
node-retry: 3
thread-executor:
core-pool-size: 10
max-pool-size: 20
keep-alive-time: 60
- 编写业务逻辑组件:按照规则文件中的定义,编写相应的组件逻辑。
数据上下文
在liteflow
中,数据上下文非常重要,它用于参数传递
和业务逻辑
的执行。
我们可以通过以下代码示例了解数据上下文的用法。
java
@LiteflowComponent("contextNode")
public class ContextNode extends NodeComponent {
@Override
public void process() throws Exception {
// 获取数据上下文
LiteflowContext context = this.getContextBean();
// 设置数据
context.setData("key", "value");
// 获取数据
String value = context.getData("key");
System.out.println("Context data: " + value);
}
}
配置详解
在使用liteflow
时,我们需要对一些参数进行配置,如规则文件地址、节点重试、线程池参数等。
以下是一个配置示例。
yaml
liteflow:
rule-source: "classpath:flow.xml" # 指定规则文件的位置
node-retry: 3 # 节点重试次数
thread-executor:
core-pool-size: 10 # 线程池核心线程数
max-pool-size: 20 # 线程池最大线程数
keep-alive-time: 60 # 线程存活时间
总的来说,liteflow
在简化业务流程管理方面起到了非常重要的作用,可以提升开发效率和业务流程管理能力。
来源:juejin.cn/post/7388033492570095670
只CURD的Java后端要如何提升自己?
你是否工作3~5年后,发现日常只做了CURD的简单代码。
你是否每次面试就会头疼,自己写的代码,除了日常CURD简历上毫无亮点可写
抱怨过苦恼过也后悔过,但是站在现在的时间点回想以前,发现有很多事情我们是可以做的更好的。
于是有了这篇文章。
小北将带大家从六个方面深入探讨如何在Java后台管理开发中不断进步,帮助你在职业道路上稳步前行
一、写优雅的代码
优雅代码的重要性
优雅的代码不仅易于阅读和维护,还能减少错误,提高开发效率。对于后台管理系统,代码的整洁与规范尤为重要,因为它们通常涉及复杂的业务逻辑和大量的数据处理。
我们看一个简单的案例,我们直观的感受下,需求如下:
用户可以通过银行网页转账给另一个账号,支持跨币种转账。
同时因为监管和对账需求,需要记录本次转账活动
拿到这个需求之后,一个开发可能会经历一些技术选型,最终可能拆解需求如下:
1、从MySql数据库中找到转出和转入的账户,选择用MyBatis的mapper实现DAO
2、从Yahoo(或其他渠道)提供的汇率服务获取转账的汇率信息(底层是http开放接口)
3、计算需要转出的金额,确保账户有足够余额,并且没超出每日转账上限
4、实现转入和转出操作,扣除手续费,保存数据库
5、发送Kafka审计消息,以便审计和对账用
而一个常规的代码实现如下:
public class TransferServiceImpl implements TransferService {
private static final String TOPIC_AUDIT_LOG = "TOPIC_AUDIT_LOG";
private AccountMapper accountDAO;
private KafkaTemplate<String, String> kafkaTemplate;
private YahooForexService yahooForex;
@Override
public Result<Boolean> transfer(Long sourceUserId, String targetAccountNumber, BigDecimal targetAmount, String targetCurrency) {
// 1. 从数据库读取数据,忽略所有校验逻辑如账号是否存在等
AccountDO sourceAccountDO = accountDAO.selectByUserId(sourceUserId);
AccountDO targetAccountDO = accountDAO.selectByAccountNumber(targetAccountNumber);
// 2. 业务参数校验
if (!targetAccountDO.getCurrency().equals(targetCurrency)) {
throw new InvalidCurrencyException();
}
// 3. 获取外部数据,并且包含一定的业务逻辑
// exchange rate = 1 source currency = X target currency
BigDecimal exchangeRate = BigDecimal.ONE;
if (sourceAccountDO.getCurrency().equals(targetCurrency)) {
exchangeRate = yahooForex.getExchangeRate(sourceAccountDO.getCurrency(), targetCurrency);
}
BigDecimal sourceAmount = targetAmount.divide(exchangeRate, RoundingMode.DOWN);
// 4. 业务参数校验
if (sourceAccountDO.getAvailable().compareTo(sourceAmount) < 0) {
throw new InsufficientFundsException();
}
if (sourceAccountDO.getDailyLimit().compareTo(sourceAmount) < 0) {
throw new DailyLimitExceededException();
}
// 5. 计算新值,并且更新字段
BigDecimal newSource = sourceAccountDO.getAvailable().subtract(sourceAmount);
BigDecimal newTarget = targetAccountDO.getAvailable().add(targetAmount);
sourceAccountDO.setAvailable(newSource);
targetAccountDO.setAvailable(newTarget);
// 6. 更新到数据库
accountDAO.update(sourceAccountDO);
accountDAO.update(targetAccountDO);
// 7. 发送审计消息
String message = sourceUserId + "," + targetAccountNumber + "," + targetAmount + "," + targetCurrency;
kafkaTemplate.send(TOPIC_AUDIT_LOG, message);
return Result.success(true);
}
}
我们可以看到,一段业务代码里经常包含了参数校验、数据读取存储、业务计算、调用外部服务、发送消息等多种逻辑。
在这个案例里虽然是写在了同一个方法里,在真实代码中经常会被拆分成多个子方法,但实际效果是一样的,而在我们日常的工作中,绝大部分代码都或多或少的接近于此类结构。
那么优雅的代码应该是什么样的?
public class TransferServiceImplNew implements TransferService {
// 可以看出来,经过重构后的代码有以下几个特征:
// 业务逻辑清晰,数据存储和业务逻辑完全分隔。
// Entity、Domain Primitive、Domain Service都是独立的对象,没有任何外部依赖,
// 但是却包含了所有核心业务逻辑,可以单独完整测试。
// 原有的TransferService不再包括任何计算逻辑,仅仅作为组件编排,
// 所有逻辑均delegate到其他组件。这种仅包含Orchestration(编排)的服务叫做Application Service(应用服务)。
// 我们可以根据新的结构重新画一张图:
private AccountRepository accountRepository;
private AuditMessageProducer auditMessageProducer;
private ExchangeRateService exchangeRateService;
private AccountTransferService accountTransferService;
@Override
public Result<Boolean> transfer(Long sourceUserId, String targetAccountNumber, BigDecimal targetAmount, String targetCurrency) {
// 参数校验
Money targetMoney = new Money(targetAmount, new Currency(targetCurrency));
// 读数据
Account sourceAccount = accountRepository.find(new UserId(sourceUserId));
Account targetAccount = accountRepository.find(new AccountNumber(targetAccountNumber));
// 获取汇率
ExchangeRate exchangeRate = exchangeRateService.getExchangeRate(
sourceAccount.getCurrency(), targetMoney.getCurrency()
);
// 业务逻辑
accountTransferService.transfer(sourceAccount, targetAccount, targetMoney, exchangeRate);
// 保存数据
accountRepository.save(sourceAccount);
accountRepository.save(targetAccount);
// 发送审计消息
AuditMessage message = new AuditMessage(sourceAccount, targetAccount, targetMoney);
auditMessageProducer.send(message);
return Result.success(true);
}
}
虽然功能都一样,但是在面试的时候写了上面的代码能得到了面试官的赞扬,而如果写成了上面的样子,估计不会有这种效果。
最近无意间获得一份阿里大佬写的刷题笔记,一下子打通了我的任督二脉,进大厂原来没那么难。这是大佬写的,[7701页的BAT大佬写的刷题笔记,让我offer拿到手软]
二、提升代码质量
如果说优雅的代码是我们程序员的里子,那代码质量就是我们的面子。
想象一下,如果你写的代码,提测后测试出来各种bug,上线后也出现bug,就算你代码写的再优雅也没用了。
如何提升代码质量
想提升代码质量,最理想的是靠 code review,但是实际上这玩意在大多数公司根本就推行不下去。
为什么呢?因为大家都很忙,忙着改上一个迭代的bug,忙着写下一个迭代的需求,忙着做各种性能优化,忙着做各种日报、周报、月报等等...
所以靠人不如靠己,我们在日常工作中要善于利用工具,来帮我们发现问题,解决问题。
例如以下实践方法:
- 自动化测试:编写单元测试、集成测试,确保代码功能的正确性和稳定性。使用JUnit、Mockito等工具进行测试驱动开发(TDD)。
- 持续集成(CI):通过Jenkins、GitHub Actions等工具,自动化构建和测试流程,及时发现并解决问题。
- 静态代码分析:使用工具如SonarQube,对代码进行静态分析,检测代码中的潜在问题和代码风格违规。
- 合理利用大模型,对我们的代码进行分析,发现bug。
三、关注业务
看到这里有的人不禁要问,我一个后端开发,写好代码就行了,还需要关注业务吗?
如果你有这样的想法,那就大错特错了。
中国的企业,90%的开发都是面向业务开发,纯做研究的公司少之又少。所以你想要在互联网行业走的更高,那就一定不能脱离业务。
而且只有深刻理解业务了,才能对系统有一个整体的规划意识,才能设计出一个好的系统。
实践方法
- 多与业务团队沟通:定期与产品经理、业务分析师沟通,了解业务流程和需求变化。
- 参与需求讨论:积极参与需求评审和讨论,提出技术上的可行性建议和优化方案。
- 业务文档学习:阅读业务相关的文档和资料,全面了解系统的功能和使用场景。
- 业务架构梳理:梳理公司整体系统业务领域架构图,先从整体对公司业务有一个清晰的概念
实践建议
- 业务流程图:绘制业务流程图,帮助理解各个业务环节之间的关系和数据流动。
- 用户故事:通过用户故事的方式,站在用户角度思考功能设计,提高系统的用户体验。
- 持续学习:随着业务的发展,持续学习和更新业务知识,确保技术方案与业务需求保持一致。
四、培养架构思维
5年以上的程序员,就一定要培养自己的架构思维了,也就是要把自己的技术视角由自己的点扩展到线,再扩展到面。
从而对公司整体系统技术架构有一个整体的认知。
例如到一个公司之后,你一定要具有自我绘制如下技术架构图的能力。
架构思维的重要性
良好的架构设计是系统稳定、高效运行的基础。
培养架构思维,能够帮助你在项目初期做出合理的技术选型和系统设计,提升系统的可扩展性和维护性。
实践方法
- 学习架构设计原则:如单一职责原则(SRP)、开闭原则(OCP)、依赖倒置原则(DIP)等,指导架构设计。
- 分层架构:采用DDD领域分层架构,如适配层、应用层和领域层、防腐层,明确各层的职责,降低耦合度。
- 模块化设计:将系统拆分为独立的领域模块或微服务,提升系统的可维护性和可扩展性。
最近无意间获得一份阿里大佬写的刷题笔记,一下子打通了我的任督二脉,进大厂原来没那么难。这是大佬写的,[7701页的BAT大佬写的刷题笔记,让我offer拿到手软]
五、关注源码
源码学习的价值
其实学习源码最市侩的价值那就是面试会问了,比如说 HashMap 的一些经典问题:
1、加载因子为什么是 0.75?
2、为什么链表改为红黑树的阈值是 8?
3、HashMap的底层数据结构是什么?
4、解决hash冲突的办法有哪些?
5、HashMap数组的长度为什么是 2 的幂次方?
6、HashMap 的扩容方式?
这些问题只有通过源码才能得出比较准确的回答。
但是我个人认为阅读源码对我们最大的价值其实是我们可以学习借鉴源码设计中的优秀思想。
想象一下,我们每天做着CURD的996工作,根本没有机会接触优秀的项目设计思想。而阅读源码是我们最容易接触到优秀项目设计核心思想的机会。
其次阅读源码也可以在系统出现棘手的问题时候,可以快速定位解决。大大提升自己在职场中的核心竞争力。
有个同学说过一句话,给我的印象特别深刻,就是“有啥解决不了的?只要你肯阅读源码。”
六、项目管理能力
实现一个软件系统的过程,不仅只有编码,还涉及到项目安排,团队协调等一系列非技术因素,如果想从一名程序员走向管理岗,成为 team leader 或者开发经理,软件工程方面的知识就必须得跟得上。
要想写出一个好而美的程序,需要经过三个阶段。
第一阶段:有扎实的基本功,简单点说,就是要做到语法熟练、框架熟练,成为一名能够完成开发任务的“码农”。
第二阶段:从“码农”到“工程师”,在局部上,不仅要能够实现功能,还能关注功能之外的维度,比如健壮性、低耦合、可扩展等指标。
第三阶段:从“工程师”到“架构师”,不仅在局部上追求一个模块的好坏,而且还要从整个系统层面去掌控,合理安排资源的优先级,保证整个系统不会出现腐败等等。
所以要想成为一名优秀的架构师,项目管理能力是必不可少的。
比如项目范围管理、质量管理、资源/成本管理、风险管理等一系列管理能力。有兴趣的同学可以学习PMP,提升一下自己的项目管理能力。
传统预测项目管理
敏捷开发项目管理
说在最后
学习的过程,就好像登山一样,大概有 80% 的人在这个过程中会掉队。要想成为一名优秀的架构师,除了自身的努力,也需要一点点运气。
那么请相信我,只要目标明确,努力加上坚持,再加上一点点好运气,你就能登顶!
免费看 500 套技术教程的网站,希望对你有帮助
*最近无意间获得一份阿里大佬写的刷题笔记,一下子打通了我的任督二脉,进大厂原来没那么难。这是大佬写的, *[7701页的BAT大佬写的刷题笔记,让我offer拿到手软]
求一键三连:点赞、分享、收藏
我的技术网站:cxykk.com 里面有,500套技术系列教程、1万+道,面试八股文、BAT面试真题、简历模版,工作经验分享、架构师成长之路,全部免费,欢迎收藏和转发。
来源:juejin.cn/post/7418061055228215322
别再手动拼接 SQL 了,MyBatis 动态 SQL 写法应有尽有,建议收藏!
1.Mybatis 动态 sql 是做什么的?
Mybatis 动态 sql 可以让我们在 Xml 映射文件内,以标签的形式编写动态 sql,完成逻辑判断和动态拼接 sql 的功能。
2.Mybatis 的 9 种 动 态 sql 标 签有哪些?
3.动态 sql 的执行原理?
原理为:使用 OGNL 从 sql 参数对象中计算表达式的值,根据表达式的值动态拼接 sql,以此来完成动态 sql 的功能。
MyBatis标签
1.if标签:条件判断
MyBatis if 类似于 Java 中的 if 语句,是 MyBatis 中最常用的判断语句。使用 if 标签可以节省许多拼接 SQL 的工作,把精力集中在 XML 的维护上。
1)不使用动态sql
<select id="selectUserByUsernameAndSex"
resultType="user" parameterType="com.ys.po.User">
<!-- 这里和普通的sql 查询语句差不多,对于只有一个参数,后面的 #{id}表示占位符,里面 不一定要写id,
写啥都可以,但是不要空着,如果有多个参数则必须写pojo类里面的属性 -->
select * from user where username=#{username} and sex=#{sex}
</select>
if 语句使用方法简单,常常与 test 属性联合使用。语法如下:
<if test="判断条件"> SQL语句</if>
2)使用动态sql
上面的查询语句,我们可以发现,如果 #{username}
为空,那么查询结果也是空,如何解决这个问题呢?使用 if 来判断,可多个 if 语句同时使用。
以下语句表示为可以按照网站名称(name)或者网址(url)进行模糊查询。如果您不输入名称或网址,则返回所有的网站记录。但是,如果你传递了任意一个参数,它就会返回与给定参数相匹配的记录。
<select id="selectAllWebsite" resultMap="myResult">
select id,name,url from website
where 1=1
<if test="name != null">
AND name like #{name}
</if>
<if test="url!= null">
AND url like #{url}
</if>
</select>
2.where+if标签
where、if同时使用可以进行查询、模糊查询
注意,
<if>
失败后,<where>
关键字只会去掉库表字段赋值前面的and,不会去掉语句后面的and关键字,即注意,<where>
只会去掉<if>
语句中的最开始的and关键字。所以下面的形式是不可取的
<select id="findQuery" resultType="Student">
<include refid="selectvp"/>
<where>
<if test="sacc != null">
sacc like concat('%' #{sacc} '%')
</if>
<if test="sname != null">
AND sname like concat('%' #{sname} '%')
</if>
<if test="sex != null">
AND sex=#{sex}
</if>
<if test="phone != null">
AND phone=#{phone}
</if>
</where>
</select>
这个“where”标签会知道如果它包含的标签中有返回值的话,它就插入一个‘where’。此外,如果标签返回的内容是以AND 或OR 开头的,则它会剔除掉。
3.set标签
set可以用来修改
<update id="upd">
update student
<set>
<if test="sname != null">sname=#{sname},</if>
<if test="spwd != null">spwd=#{spwd},</if>
<if test="sex != null">sex=#{sex},</if>
<if test="phone != null">phone=#{phone}</if>
sid=#{sid}
</set>
where sid=#{sid}
</update>
4.choose(when,otherwise) 语句
有时候,我们不想用到所有的查询条件,只想选择其中的一个,查询条件有一个满足即可,使用 choose 标签可以解决此类问题,类似于 Java 的 switch 语句
<select id="selectUserByChoose" resultType="com.ys.po.User" parameterType="com.ys.po.User">
select * from user
<where>
<choose>
<when test="id !='' and id != null">
id=#{id}
</when>
<when test="username !='' and username != null">
and username=#{username}
</when>
<otherwise>
and sex=#{sex}
</otherwise>
</choose>
</where>
</select>
也就是说,这里我们有三个条件,id、username、sex,只能选择一个作为查询条件
- 如果 id 不为空,那么查询语句为:
select * from user where id=?
- 如果 id 为空,那么看username 是否为空,如果不为空,那么语句为
select * from user where username=?;
- 如果 username 为空,那么查询语句为
select * from user where sex=?
5.trim
trim标记是一个格式化的标记,可以完成set或者是where标记的功能
①、用 trim 改写上面第二点的 if+where 语句
<select id="selectUserByUsernameAndSex" resultType="user" parameterType="com.ys.po.User">
select * from user
<!-- <where>
<if test="username != null">
username=#{username}
</if>
<if test="username != null">
and sex=#{sex}
</if>
</where> -->
<trim prefix="where" prefixOverrides="and | or">
<if test="username != null">
and username=#{username}
</if>
<if test="sex != null">
and sex=#{sex}
</if>
</trim>
</select>
- prefix:前缀
- prefixoverride:去掉第一个and或者是or
②、用 trim 改写上面第三点的 if+set 语句
<!-- 根据 id 更新 user 表的数据 -->
<update id="updateUserById" parameterType="com.ys.po.User">
update user u
<!-- <set>
<if test="username != null and username != ''">
u.username = #{username},
</if>
<if test="sex != null and sex != ''">
u.sex = #{sex}
</if>
</set> -->
<trim prefix="set" suffixOverrides=",">
<if test="username != null and username != ''">
u.username = #{username},
</if>
<if test="sex != null and sex != ''">
u.sex = #{sex},
</if>
</trim>
where id=#{id}
</update>
- suffix:后缀
- suffixoverride:去掉最后一个逗号(也可以是其他的标记,就像是上面前缀中的and一样)
③、trim+if同时使用可以添加
<insert id="add">
insert int0 student
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="sname != null">sname,</if>
<if test="spwd != null">spwd,</if>
<if test="sex != null">sex,</if>
<if test="phone != null">phone,</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="sname != null">#{sname},</if>
<if test="spwd != null">#{spwd},</if>
<if test="sex != null">#{sex},</if>
<if test="phone != null">#{phone}</if>
</trim>
</insert>
6.MyBatis foreach标签
foreach是用来对集合的遍历,这个和Java中的功能很类似。通常处理SQL中的in语句。
foreach 元素的功能非常强大,它允许你指定一个集合,声明可以在元素体内使用的集合项(item)和索引(index)变量。它也允许你指定开头与结尾的字符串以及集合项迭代之间的分隔符。这个元素也不会错误地添加多余的分隔符
你可以将任何可迭代对象(如 List、Set 等)、Map 对象或者数组对象作为集合参数传递给 foreach。当使用可迭代对象或者数组时,index 是当前迭代的序号,item 的值是本次迭代获取到的元素。当使用 Map 对象(或者 Map.Entry 对象的集合)时,index 是键,item 是值。
//批量查询
<select id="findAll" resultType="Student" parameterType="Integer">
<include refid="selectvp"/> WHERE sid in
<foreach item="ids" collection="array" open="(" separator="," close=")">
#{ids}
</foreach>
</select>
//批量删除
<delete id="del" parameterType="Integer">
delete from student where sid in
<foreach item="ids" collection="array" open="(" separator="," close=")">
#{ids}
</foreach>
</delete>
整合案例
xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.yzx.mapper.StuMapper">
<sql id="selectvp">
select * from student
</sql>
<select id="find" resultType="Student">
<include refid="selectvp"/>
</select>
<select id="findbyid" resultType="student">
<include refid="selectvp"/>
WHERE 1=1
<if test="sid != null">
AND sid like #{sid}
</if>
</select>
<select id="findQuery" resultType="Student">
<include refid="selectvp"/>
<where>
<if test="sacc != null">
sacc like concat('%' #{sacc} '%')
</if>
<if test="sname != null">
AND sname like concat('%' #{sname} '%')
</if>
<if test="sex != null">
AND sex=#{sex}
</if>
<if test="phone != null">
AND phone=#{phone}
</if>
</where>
</select>
<update id="upd">
update student
<set>
<if test="sname != null">sname=#{sname},</if>
<if test="spwd != null">spwd=#{spwd},</if>
<if test="sex != null">sex=#{sex},</if>
<if test="phone != null">phone=#{phone}</if>
sid=#{sid}
</set>
where sid=#{sid}
</update>
<insert id="add">
insert int0 student
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="sname != null">sname,</if>
<if test="spwd != null">spwd,</if>
<if test="sex != null">sex,</if>
<if test="phone != null">phone,</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="sname != null">#{sname},</if>
<if test="spwd != null">#{spwd},</if>
<if test="sex != null">#{sex},</if>
<if test="phone != null">#{phone}</if>
</trim>
</insert>
<select id="findAll" resultType="Student" parameterType="Integer">
<include refid="selectvp"/> WHERE sid in
<foreach item="ids" collection="array" open="(" separator="," close=")">
#{ids}
</foreach>
</select>
<delete id="del" parameterType="Integer">
delete from student where sid in
<foreach item="ids" collection="array" open="(" separator="," close=")">
#{ids}
</foreach>
</delete>
</mapper>
测试类:
package com.yzx.test;
import com.yzx.entity.Student;
import com.yzx.mapper.StuMapper;
import org.apache.ibatis.io.Resources;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import java.io.IOException;
import java.io.InputStream;
import java.util.List;
public class StuTest {
SqlSession sqlSession=null;
InputStream is=null;
@Before
public void before() throws IOException {
//1.读取核心配置文件
is= Resources.getResourceAsStream("sqlMapperConfig.xml");
//2.拿到工厂构建类
SqlSessionFactoryBuilder sqlSessionFactoryBuilder=new SqlSessionFactoryBuilder();
//3.拿到具体工厂
SqlSessionFactory build=sqlSessionFactoryBuilder.build(is);
//4.拿到session
sqlSession = build.openSession();
}
@After
public void after(){
//7,提交事务
sqlSession.commit();
//8.关闭资源
sqlSession.close();
if(is!=null){
try {
is.close();
} catch (IOException e) {
e.printStackTrace();
}
};
}
//查询所有
@Test
public void find(){
//5.获取具体的mapper接口
StuMapper mapper=sqlSession.getMapper(StuMapper.class);
//6.调用执行
List<Student> list=mapper.find();
list.forEach(a-> System.out.println(a));
}
//查询单个
@Test
public void findbyid(){
StuMapper mapper=sqlSession.getMapper(StuMapper.class);
List<Student> list=mapper.findbyid(2);
list.forEach(a-> System.out.println(a));
}
//模糊查询
@Test
public void findQuery(){
StuMapper mapper=sqlSession.getMapper(StuMapper.class);
Student stu=new Student();
stu.setSname("小");
stu.setSex("男");
List<Student> list=mapper.findQuery(stu);
list.forEach(a-> System.out.println(a));
}
//修改
@Test
public void upd(){
StuMapper mapper=sqlSession.getMapper(StuMapper.class);
Student stu=new Student();
stu.setSid(3);
stu.setSname("小若");
stu.setSex("人妖");
int i=mapper.upd(stu);
System.out.println("修改了"+i+"条数据"+" "+stu.toString());
}
//添加
@Test
public void add(){
StuMapper mapper=sqlSession.getMapper(StuMapper.class);
Student stu=new Student();
stu.setSname("小贺");
stu.setSex("男");
stu.setPhone("99999999");
int i=mapper.add(stu);
System.out.println("添加了"+i+"条数据"+" "+stu.toString());
}
//批量操作
@Test
public void findAll(){
StuMapper mapper=sqlSession.getMapper(StuMapper.class);
Integer[] i={1,2,3,4};
List<Student> list=mapper.findAll(i);
list.forEach(a-> System.out.println(a));
}
//批量操作
//批量删除
@Test
public void del(){
StuMapper mapper=sqlSession.getMapper(StuMapper.class);
Integer[] i={1,2,3,4};
int i1=mapper.del(i);
System.out.println("删除了"+i1+"条数据");
}
}
7.sql
在实际开发中会遇到许多相同的SQL,比如根据某个条件筛选,这个筛选很多地方都能用到,我们可以将其抽取出来成为一个公用的部分,这样修改也方便,一旦出现了错误,只需要改这一处便能处处生效了,此时就用到了<sql>
这个标签了。
当多种类型的查询语句的查询字段或者查询条件相同时,可以将其定义为常量,方便调用。为求<select>
结构清晰也可将 sql 语句分解。
<sql id="selectvp">
select * from student
</sql>
8.include
这个标签和<sql>
是天仙配,是共生的,include用于引用sql标签定义的常量。比如引用上面sql标签定义的常量
refid这个属性就是指定<sql>
标签中的id值(唯一标识)
<select id="findbyid" resultType="student">
<include refid="selectvp"/>
WHERE 1=1
<if test="sid != null">
AND sid like #{sid}
</if>
</select>
9.如何引用其他XML中的SQL片段
比如你在com.xxx.dao.xxMapper
这个Mapper的XML中定义了一个SQL片段如下:
<sql id="Base_Column_List"> ID,MAJOR,BIRTHDAY,AGE,NAME,HOBBY</sql>
此时我在com.xxx.dao.PatinetMapper
中的XML文件中需要引用,如下:
<include refid="com.xxx.dao.xxMapper.Base_Column_List"></include>
MyBatis关联查询
1.MyBatis一对多关联查询
<!--一对多-->
<resultMap id="myStudent1" type="student1">
<id property="sid" column="sid"/>
<result property="sname" column="sname"/>
<result property="sex" column="sex"/>
<result property="sage" column="sage"/>
<collection property="list" ofType="teacher">
<id property="tid" column="tid"/>
<result property="tname" column="tname"/>
<result property="tage" column="tage"/>
</collection>
</resultMap>
<!--一对多-->
<select id="find1" resultMap="myStudent1">
select * from student1 s left join teacher t on s.sid=t.sid
</select>
2.MyBatis多对一关联查询
<!--多对一-->
<resultMap id="myTeacher" type="teacher">
<id property="tid" column="tid"/>
<result property="tname" column="tname"/>
<result property="tage" column="tage"/>
<association property="student1" javaType="Student1">
<id property="sid" column="sid"/>
<result property="sname" column="sname"/>
<result property="sex" column="sex"/>
<result property="sage" column="sage"/>
</association>
</resultMap>
<!--多对一-->
<select id="find2" resultMap="myTeacher">
select * from teacher t right join student1 s on t.sid=s.sid
</select>
3.MyBatis多对多关联查询
<!--多对多 以谁为主表查询的时候,主表约等于1的一方,另一方相当于多的一方-->
<select id="find3" resultMap="myStudent1">
select * from student1 s left join relevance r on s.sid=r.sid left join teacher t on r.tid=t.tid
</select>
最后说一句(求关注!别白嫖!)
如果这篇文章对您有所帮助,或者有所启发的话,求一键三连:点赞、转发、在看。
关注公众号:woniuxgg,在公众号中回复:笔记 就可以获得蜗牛为你精心准备的java实战语雀笔记,回复面试、开发手册、有超赞的粉丝福利!
来源:juejin.cn/post/7382394009199034387
工作中用Redis最多的10种场景
前言
Redis作为一种优秀的基于key/value的缓存,有非常不错的性能和稳定性,无论是在工作中,还是面试中,都经常会出现。
今天这篇文章就跟大家一起聊聊,我在实际工作中使用Redis的10种场景,希望对你会有所帮助。
1. 统计访问次数
对于很多官方网站的首页,经常会有一些统计首页访问次数的需求。
访问次数只有一个字段,如果保存到数据库中,再最后做汇总显然有些麻烦。
该业务场景可以使用Redis,定义一个key,比如:OFFICIAL_INDEX_VISIT_COUNT。
在Redis中有incr命令,可以实现给value值加1操作:
incr OFFICIAL_INDEX_VISIT_COUNT
当然如果你想一次加的值大于1,可以用incrby命令,例如:
incrby OFFICIAL_INDEX_VISIT_COUNT 5
这样可以一次性加5。
2. 获取分类树
在很多网站都有分类树的功能,如果没有生成静态的html页面,想通过调用接口的方式获取分类树的数据。
我们一般为了性能考虑,会将分类树的json数据缓存到Redis当中,为了后面在网站当中能够快速获取数据。
不然在接口中需要使用递归查询数据库,然后拼接成分类树的数据结构。
这个过程非常麻烦,而且需要多次查询数据库,性能很差。
因此,可以考虑用一个定时任务,异步将分类树的数据,直接缓存到Redis当中,定义一个key,比如:MALL_CATEGORY_TREE。
然后接口中直接使用MALL_CATEGORY_TREE这个key从缓存中获取数据即可。
可以直接用key/value字符串保存数据。
不过需要注意的是,如果分类树的数据非常多可能会出现大key的问题,优化方案可以参考我的另外一篇文章《分类树,我从2s优化到0.1s》。
3. 做分布式锁
分布式锁可能是使用Redis最常见的场景之一,相对于其他的分布式锁,比如:数据库分布式锁或者Zookeeper分布式锁,基于Redis的分布式锁,有更好的性能,被广泛使用于实际工作中。
我们使用下面这段代码可以加锁:
try{
String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
if ("OK".equals(result)) {
return true;
}
return false;
} finally {
unlock(lockKey);
}
但上面这段代码在有些场景下,会有一些问题,释放锁可能会释放了别人的锁。
说实话Redis分布式锁虽说很常用,但坑也挺多的,如果用不好的话,很容易踩坑。
如果大家对Redis分布式锁的一些坑比较感兴趣,可以看看我的另一篇文章《聊聊redis分布式锁的8大坑》,文章中有非常详细的介绍。
4. 做排行榜
很多网站有排行榜的功能,比如:商城中有商品销量的排行榜,游戏网站有玩家获得积分的排行榜。
通常情况下,我们可以使用Sorted Set
保存排行榜的数据。
使用ZADD
可以添加排行榜的数据,使用ZRANGE
可以获取排行榜的数据。
例如:
ZADD rank:score 100 "周星驰"
ZADD rank:score 90 "周杰伦"
ZADD rank:score 80 "周润发"
ZRANGE rank:score 0 -1 WITHSCORES
返回数据:
1) "周星驰"
2) "100"
3) "周杰伦"
4) "90"
5) "周润发"
6) "80"
5. 记录用户登录状态
通常下,用户登录成功之后,用户登录之后的状态信息,会保存到Redis中。
这样后面该用户访问其他接口的时候,会直接从Redis中查询用户登录状态,如果可以查到数据,说明用户已登录,则允许做后续的操作。
如果从Redis中没有查到用户登录状态,说明该用户没有登录,或者登录状态失效了,则直接跳转到用户登录页面。
使用Redis保存用户登录状态,有个好处是它可以设置一个过期时间,比如:该时间可以设置成30分钟。
jedis.set(userId, userInfo, 1800);
在Redis内部有专门的job,会将过期的数据删除,也有获取数据时实时删除的逻辑。
6. 限流
使用Redis还有一个非常常用的的业务场景是做限流
。
当然还有其他的限流方式,比如:使用nginx,但使用Redis控制可以更精细。
比如:限制同一个ip,1分钟之内只能访问10次接口,10分钟之内只能访问50次接口,1天之内只能访问100次接口。
如果超过次数,则接口直接返回:请求太频繁了,请稍后重试。
跟上面保存用户登录状态类似,需要在Redis中保存用户的请求记录。
比如:key是用户ip,value是访问的次数从1开始,后面每访问一次则加1。
如果value超过一定的次数,则直接拦截这种异常的ip。
当然也需要设置一个过期时间,异常ip如果超过这个过期时间,比如:1天,则恢复正常了,该ip可以再发起请求了。
或者限制同一个用户id。
7. 位统计
比如现在有个需求:有个网站需要统计一周内连续登陆的用户,以及一个月内登陆过的用户。
这个需求使用传统的数据库,实现起来比较麻烦,但使用Redis的bitmap
让我们可以实时的进行类似的统计。
bitmap 是二进制的byte数组,也可以简单理解成是一个普通字符串。它将二进制数据存储在byte数组中以达到存储数据的目的。
保存数据命令使用setbit,语法:
setbit key offset value
具体示例:
setbit user:view:2024-01-17 123456 1
往bitmap数组中设置了用户id=123456的登录状态为1,标记2024-01-17已登录。
然后通过命令getbit获取数据,语法:
getbit key offset
具体示例:
getbit user:view:2024-01-17 123456
如果获取的值是1,说明这一天登录了。
如果我们想统计一周内连续登录的用户,只需要遍历用户id,根据日期中数组中去查询状态即可。
最近就业形式比较困难,为了感谢各位小伙伴对苏三一直以来的支持,我特地创建了一些工作内推群, 看看能不能帮助到大家。
你可以在群里发布招聘信息,也可以内推工作,也可以在群里投递简历找工作,也可以在群里交流面试或者工作的话题。
进群方式
添加,苏三的私人微信:su_san_java,备注:内推+所在城市,即可加入。
8. 缓存加速
我们在工作中使用Redis作为缓存加速,这种用法也是非常常见的。
如果查询订单数据,先从Redis缓存中查询,如果缓存中存在,则直接将数据返回给用户。
如果缓存中不存在,则再从数据库中查询数据,如果数据存在,则将数据保存到缓存中,然后再返回给用户。
如果缓存和数据库都不存在,则直接给用户返回数据不存在。
流程图如下:但使用缓存加速的业务场景,需要注意一下,可能会出现:缓存击穿、穿透和雪崩等问题,感兴趣的小伙伴,可以看看我的另一篇文章《烂大街的缓存穿透、缓存击穿和缓存雪崩,你真的懂了?》,里面有非常详细的介绍。
9. 做消息队列
我们说起队列经常想到是:kafka、rabbitMQ、RocketMQ等这些分布式消息队列。
其实Redis也有消息队列的功能,我们之前有个支付系统,就是用的Redis队列功能。
PubSub(发布订阅)是Redis2.0版本引入的消息传递模型。
顾名思义,消费者可以订阅一个或多个channel,生产者向对应channel发送消息后,所有订阅者都能收到相关消息。对应channel发送消息后,所有订阅者都能收到相关消息。
在java代码中可以实现MessageListener接口,来消费队列中的消息。
@Slf4j
@Component
public class RedisMessageListenerListener implements MessageListener {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Override
public void onMessage(Message message, byte[] pattern) {
String channel = new String(pattern);
RedisSerializer valueSerializer = redisTemplate.getValueSerializer();
Object deserialize = valueSerializer.deserialize(message.getBody());
if (deserialize == null) return;
String md5DigestAsHex = DigestUtils.md5DigestAsHex(deserialize.toString().getBytes(StandardCharsets.UTF_8));
Boolean result = redisTemplate.opsForValue().setIfAbsent(md5DigestAsHex, "1", 20, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(result)) {
log.info("接收的结果:{}", deserialize.toString());
} else {
log.info("其他服务处理中");
}
}
}
10. 生成全局ID
在有些需要生成全局ID的业务场景,其实也可以使用Redis。
可以使用incrby命令,利用原子性操作,可以执行下面这个命令:
incrby userid 10000
在分库分表的场景,对于有些批量操作,我们可以从Redis中,一次性拿一批id出来,然后给业务系统使用。
来源:juejin.cn/post/7325132133168971813
访问者模式:多品类商品打折场景
0.基础
0.0解决的痛点
它可以在不修改原有类的情况下,扩展新的操作,而策略模式则需要改变上下文类来引入新的策略。
- 扩展性更强
- 访问者模式使得你可以在不修改现有类的情况下添加新的操作。假设你有多个对象构成的复杂结构,并且要在这些对象上执行不同的操作。
- 使用访问者模式,你可以为每个对象定义一个访问者,而每次需要添加新的操作时,只需要增加一个新的访问者类。这样,原有类不会被修改,符合开闭原则(对扩展开放,对修改关闭)。
- 相反,策略模式更多是通过替换算法来改变行为。如果你的业务逻辑复杂,需要在同一个对象中实现多个策略,频繁地改变策略可能会导致对象内部逻辑变得非常复杂,增加维护成本。
- 职责单一,逻辑分离
- 访问者模式将行为与对象结构分离,访问者本身只关心如何对不同对象执行操作,而不需要关心对象的具体实现。这种方式将操作逻辑与数据结构解耦,符合单一职责原则。
- 策略模式中,每种策略会嵌入到目标对象中,这会使得目标对象承担过多的责任,尤其是在需要处理大量策略的情况下,会导致对象变得非常臃肿。
- 操作集中统一管理
- 使用访问者模式时,所有的操作都集中在访问者类中进行管理。
- 假设有多个元素需要执行不同的操作,访问者模式将这些操作集中到访问者中,避免了分散在各个策略中的问题,便于管理和维护。
- 策略模式则往往需要将每个策略分散在不同的策略类中,随着策略增多,管理和维护会变得越来越困难,尤其是当策略之间有依赖或交互时,复杂性会迅速增加。
- 适合复杂结构对象的处理
- 访问者模式特别适合在对象结构复杂且需要遍历的场景中使用。
- 例如,树形结构或对象图的遍历,这时每个节点的处理逻辑可以独立出来,并通过访问者来实现。
- 访问者可以对这些节点类型的元素进行访问和操作,无需修改元素类本身。
- 策略模式一般用于动态地改变同一对象的行为,不适合处理复杂的对象结构,特别是当需要在多个元素中进行遍历和操作时,策略模式会显得不够灵活。
- 总结:
- 访问者模式更适合在你需要对复杂结构的对象执行多个操作,并且希望操作与对象本身分离的场景。
- 它更方便扩展,避免了复杂的继承结构或不断修改已有类。
- 而策略模式则适合于在单一对象上动态替换行为,但对于复杂对象结构的处理往往会导致逻辑分散,扩展性差。
0.1代码结构图
0.2业务流程图
0.3请求Json
localhost:8080/VisitorPattern/calculateDiscount
POST类型
["electronics", "clothing", "food"]
1.代码结构
1.1Pojo
package com.xiaoyongcai.io.designmode.pojo.VisitorPattern;
public interface Product {
void accept(ProductVisitor visitor);
}
package com.xiaoyongcai.io.designmode.pojo.VisitorPattern;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.ProductImpl.Clothing;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.ProductImpl.Electronics;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.ProductImpl.Food;
public interface ProductVisitor {
void visit(Electronics electronics);
void visit(Clothing clothing);
void visit(Food food);
}
package com.xiaoyongcai.io.designmode.pojo.VisitorPattern.ProductImpl;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.Product;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.ProductVisitor;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Clothing implements Product {
private double price;
@Override
public void accept(ProductVisitor visitor) {
visitor.visit(this); // 传递给访问者
}
}
package com.xiaoyongcai.io.designmode.pojo.VisitorPattern.ProductImpl;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.Product;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.ProductVisitor;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Electronics implements Product {
private double price;
@Override
public void accept(ProductVisitor visitor) {
visitor.visit(this); // 传递给访问者
}
}
package com.xiaoyongcai.io.designmode.pojo.VisitorPattern.ProductImpl;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.Product;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.ProductVisitor;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Food implements Product {
private double price;
@Override
public void accept(ProductVisitor visitor) {
visitor.visit(this); // 传递给访问者
}
}
package com.xiaoyongcai.io.designmode.pojo.VisitorPattern.VisitorImpl;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.ProductImpl.Clothing;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.ProductImpl.Electronics;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.ProductImpl.Food;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.ProductVisitor;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Slf4j
public class DiscountVisitor implements ProductVisitor {
private double totalDiscount = 0;
@Override
public void visit(Electronics electronics) {
//电子产品打八折
totalDiscount += electronics.getPrice()*0.2;
log.info("[访问者模式]:电子产品打8折后价格为"+electronics.getPrice()*0.2+"原价为"+electronics.getPrice());
}
@Override
public void visit(Clothing clothing) {
//衣物商品打7折
totalDiscount+=clothing.getPrice()*0.3;
log.info("[访问者模式]衣物商品打7折后价格为"+clothing.getPrice()*0.2+"原价为"+clothing.getPrice());
}
@Override
public void visit(Food food) {
//食品商品打9折
totalDiscount += food.getPrice()*0.1;
log.info("[访问者模式]食品商品打9折后价格为"+food.getPrice()*0.2+"原价为"+food.getPrice());
}
}
1.2Service
package com.xiaoyongcai.io.designmode.Service.VisitorPattern;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.Product;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.VisitorImpl.DiscountVisitor;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class ProductService {
public double calculateTotalDiscount(List<Product> products) {
// 创建一个DiscountVisitor实例
DiscountVisitor discountVisitor = new DiscountVisitor();
// 遍历每个商品,执行折扣计算
for (Product product : products) {
product.accept(discountVisitor);
}
// 返回总折扣
return discountVisitor.getTotalDiscount();
}
}
1.3Controller
package com.xiaoyongcai.io.designmode.Controller.VisitorPattern;
import com.xiaoyongcai.io.designmode.Service.VisitorPattern.ProductService;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.Product;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.ProductImpl.Clothing;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.ProductImpl.Electronics;
import com.xiaoyongcai.io.designmode.pojo.VisitorPattern.ProductImpl.Food;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.List;
@RestController
@RequestMapping("VisitorPattern")
public class ProductController {
@Autowired
private ProductService productService;
@PostMapping("/calculateDiscount")
public String calculateDiscount(@RequestBody List<String> productTypes) {
List<Product> products = new ArrayList<>();
// 根据传入的商品类型,创建不同的商品实例
for (String type : productTypes) {
switch (type) {
case "electronics":
products.add(new Electronics(100)); // 假设价格是100
break;
case "clothing":
products.add(new Clothing(150)); // 假设价格是150
break;
case "food":
products.add(new Food(50)); // 假设价格是50
break;
}
}
productService.calculateTotalDiscount(products);
// 调用Service层计算折扣
return "请在控制台检查访问者模式是否工作";
}
}
来源:juejin.cn/post/7440842636228919348
那些大厂架构师是怎样封装网络请求的?
好的设计是成功的一半,好的设计思想为后面扩展带来极大的方便
一、前言
网络请求在开发中是必不可少的一个功能,如何设计一套好的网络请求框架,可以为后面扩展及改版带来极大的方便,特别是一些长期维护的项目。作为一个深耕Android开发十几载的大龄码农,深深的体会到。
网络框架的发展:
1. 从最早的HttpClient
到 HttpURLConnection
,那时候需要自己用线程池封装异步,Handler切换到UI线程,要想从网络层就返回接收实体对象,也需要自己去实现封装
2. 后来,谷歌的 Volley
, 三方的 Afinal
再到 XUtils
都是基于上面1中的网络层再次封装实现
3. 再到后来,OkHttp
问世,Retrofit
空降,从那以后基本上网络请求应用层框架就是 OkHttp
和 Retrofit
两套组合拳,基本打遍天下无敌手,最多的变化也就是在这两套组合拳里面秀出各种变化,但是思想实质上还是这两招。
我们试想:从当初的大概2010年,2011年,2012年开始,就启动一个App项目,就网络这一层的封装而言,随着时代的潮流,技术的演进,我们势必会经历上面三个阶段,这一层的封装就得重构三次。
现在是2024年,往后面发展,随着http3.0的逐渐成熟,一定会出现更好的网络请求框架
我们怎么封装一套更容易扩展的框架,而不必每次重构这一层时,改动得那么困难。
本文下面就示例这一思路如何封装,涉及到的知识,jetpack
中的手术刀: Hilt
成员来帮助我们实现。
二 、示例项目
- 上图截图圈出的就是本文重点介绍的内容:
怎么快速封装一套可以切换网络框架的项目
及相关Jetpack中的 Hilt
用法 - 其他的1,2,3,4是之前我写的:花式封装:Kotlin+协程+Flow+Retrofit+OkHttp +Repository,倾囊相授,彻底减少模版代码进阶之路,大家可以参考,也可以在它的基础上,再结合本文再次封装,可以作为
花式玩法五
三、网络层代码设计
1. 设计请求接口,包含请求地址 Url
,请求头,请求参数,返回解析成的对象Class
:
interface INetApi {
/**
* Get请求
* @param url:请求地址
* @param clazzR:返回对象类型
* @param header:请求头
* @param map:请求参数
*/
suspend fun <R> getApi(url: String, clazzR: Class<R>, header: MutableMap<String, String>? = null, map: MutableMap<String, Any>? = null): R
/**
* Get请求
* @param url:请求地址
* @param clazzR:返回对象类型
* @param header:请求头
* @param map:请求参数
* @param body:请求body
*/
suspend fun <R> postApi(url: String, clazzR: Class<R>, header: MutableMap<String, String>? = null, body: String? = null): R
}
2. 先用早期 HttpURLConnection
对网络请求进行实现:
class HttpUrlConnectionImpl constructor() : INetApi {
private val gson by lazy { Gson() }
override suspend fun <R> getApi(url: String, clazzR: Class<R>, header: MutableMap<String, String>?, map: MutableMap<String, Any>?): R {
//这里HttpUrlConnectionRequest内部是HttpURLConnection的Get请求真正的实现
val json = HttpUrlConnectionRequest.getResult(BuildParamUtils.buildParamUrl(url, map), header)
android.util.Log.e("OkhttpImpl", "HttpUrlConnection 请求:${json}")
return gson.fromJson<R>(json, clazzR)
}
override suspend fun <R> postApi(url: String, clazzR: Class<R>, header: MutableMap<String, String>?, body: String?): R {
////这里HttpUrlConnectionRequest内部是HttpURLConnection的Post请求真正的实现
val json = HttpUrlConnectionRequest.postData(url, header, body)
return gson.fromJson<R>(json, clazzR)
}
}
3. 整个项目 build.gradle
下配置 Hilt插件
buildscript {
dependencies {
classpath 'com.google.dagger:hilt-android-gradle-plugin:2.42'
}
}
4. 工程app的 build.gradle
下引入:
先配置:
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'dagger.hilt.android.plugin'//Hilt使用
id 'kotlin-kapt'//
}
里面的 android
下面添加:
kapt {
generateStubs = true
}
在 dependencies
里面引入 Hilt
使用
//hilt
implementation "com.google.dagger:hilt-android:2.42"
kapt "com.google.dagger:hilt-android-compiler:2.42"
kapt 'androidx.hilt:hilt-compiler:1.0.0'
5. 使用 Hilt
5.1 在Application上添加注解 @HiltAndroidApp
:
@HiltAndroidApp
class MyApp : Application() {
}
5.2 在使用的Activity上面添加注解 @AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : BaseViewModelActivity<MainViewModel>(R.layout.activity_main), View.OnClickListener {
override fun onClick(v: View?) {
when (v?.id) {
R.id.btn1 -> {
viewModel.getHomeList()
}
else -> {}
}
}
}
5.3 在使用的ViewModel上面添加注解 @HiltViewModel
和 @Inject
:
@HiltViewModel
class MainViewModel @Inject constructor(private val repository: NetRepository) : BaseViewModel() {
fun getHomeList() {
flowAsyncWorkOnViewModelScopeLaunch {
repository.getHomeList().onEach {
val title = it.datas!![0].title
android.util.Log.e("MainViewModel", "one 111 ${title}")
errorMsgLiveData.postValue(title)
}
}
}
}
5.4 在 HttpUrlConnectionImpl
构造方法上添加注解 @Inject
如下:
class HttpUrlConnectionImpl @Inject constructor() : INetApi {
private val gson by lazy { Gson() }
override suspend fun <R> getApi(url: String, clazzR: Class<R>, header: MutableMap<String, String>?, map: MutableMap<String, Any>?): R {
val json = HttpUrlConnectionRequest.getResult(BuildParamUtils.buildParamUrl(url, map), header)
android.util.Log.e("OkhttpImpl", "HttpUrlConnection 请求:${json}")
return gson.fromJson<R>(json, clazzR)
}
override suspend fun <R> postApi(url: String, clazzR: Class<R>, header: MutableMap<String, String>?, body: String?): R {
val json = HttpUrlConnectionRequest.postData(url, header, body)
return gson.fromJson<R>(json, clazzR)
}
}
5.5 新建一个 annotation
: BindHttpUrlConnection
如下:
@Qualifier
@Retention(RetentionPolicy.RUNTIME)
annotation class BindHttpUrlConnection()
5.6 再建一个绑定网络请求的 abstract
修饰的类 AbstractHttp
如下:让 @BindHttpUrlConnection
和 HttpUrlConnectionImpl
在如下方法中通过注解绑定
@InstallIn(SingletonComponent::class)
@Module
abstract class AbstractHttp {
@BindHttpUrlConnection
@Singleton
@Binds
abstract fun bindHttpUrlConnection(h: HttpUrlConnectionImpl): INetApi
}
5.7 在viewModel持有的仓库类 NetRepository
的构造方法中添加 注解 @Inject
,并且申明 INetApi
,并且绑定注解 @BindHttpUrlConnection
如下: 然后即就可以开始调用 INetApi
的方法
class NetRepository @Inject constructor(@BindHttpUrlConnection val netHttp: INetApi) {
suspend fun getHomeList(): Flow<WanAndroidHome> {
return flow {
netHttp.getApi("https://www.wanandroid.com/article/list/0/json", HomeData::class.java).data?.let { emit(it) }
}
}
}
到此:Hilt使用就配置完成了,那边调用 网络请求就直接执行到 网络实现 类 HttpUrlConnectionImpl
里面去了。
运行结果看到代码执行打印:
5.8 我们现在切换到 Okhttp
来实现网络请求:
新建 OkhttpImpl
实现 INetApi
并在其构造方法上添加 @Inject
如下:
class OkhttpImpl @Inject constructor() : INetApi {
private val okHttpClient by lazy { OkHttpClient() }
private val gson by lazy { Gson() }
override suspend fun <R> getApi(url: String, clazzR: Class<R>, header: MutableMap<String, String>?, map: MutableMap<String, Any>?): R {
try {
val request = Request.Builder().url(buildParamUrl(url, map))
header?.forEach {
request.addHeader(it.key, it.value)
}
val response = okHttpClient.newCall(request.build()).execute()
if (response.isSuccessful) {
val json = response.body?.string()
android.util.Log.e("OkhttpImpl","okhttp 请求:${json}")
return gson.fromJson<R>(json, clazzR)
} else {
throw RuntimeException("response fail")
}
} catch (e: Exception) {
throw e
}
}
override suspend fun <R> postApi(url: String, clazzR: Class<R>, header: MutableMap<String, String>?, body: String?): R {
try {
val request = Request.Builder().url(url)
header?.forEach {
request.addHeader(it.key, it.value)
}
body?.let {
request.post(RequestBodyCreate.toBody(it))
}
val response = okHttpClient.newCall(request.build()).execute()
if (response.isSuccessful) {
return gson.fromJson<R>(response.body.toString(), clazzR)
} else {
throw RuntimeException("response fail")
}
} catch (e: Exception) {
throw e
}
}
}
5.9 再建一个注解 annotation
类型的 BindOkhttp
如下:
@Qualifier
@Retention(RetentionPolicy.RUNTIME)
annotation class BindOkhttp()
5.10 在 AbstractHttp
类中添加 @BindOkhttp
绑定到 OkhttpImpl
,如下:
@InstallIn(SingletonComponent::class)
@Module
abstract class AbstractHttp {
@BindOkhttp
@Singleton
@Binds
abstract fun bindOkhttp(h: OkhttpImpl): INetApi
@BindHttpUrlConnection
@Singleton
@Binds
abstract fun bindHttpUrlConnection(h: HttpUrlConnectionImpl): INetApi
}
5.11 现在只需要在 NetRepository
中持有的 INetApi
修改其绑定的 注解 @BindHttpUrlConnection
改成 @BindOkhttp
便可以将项目网络请求全部改成由 Okhttp
来实现了,如下:
//class NetRepository @Inject constructor(@BindHttpUrlConnection val netHttp: INetApi) {
class NetRepository @Inject constructor(@BindOkhttp val netHttp: INetApi) {
suspend fun getHomeList(): Flow<WanAndroidHome> {
return flow {
netHttp.getApi("https://www.wanandroid.com/article/list/0/json", HomeData::class.java).data?.let { emit(it) }
}
}
}
运行执行结果截图可见:
到此:网络框架切换就这样简单的完成了。
四、总结
- 本文重点介绍了,怎么对网络框架扩展型封装:即怎么可以封装成快速从一套网络请求框架,切换到另一套网络请求上去
- 借助于
Jetpack中成员 Hilt
对其整个持有链路进行切割,简单切换绑定网络实现框架1,框架2,框架xxx等。
项目地址
感谢阅读:
欢迎 点赞、收藏、关注
这里你会学到不一样的东西
来源:juejin.cn/post/7435904232597372940
surya,一个优秀的OCR开源项目,更专注于表格识别
写这篇文件,因为一条评论。
我写过识别计算器屏幕的文章,讲了它可以独立部署在App、小程序、树莓派等嵌入式设备上。有个老哥说,这都是应用多年的老技术了。
他说得没错,可能多少年前就有了。不过,实际上,一项技术不管应用了多少年,每年依然还有人花钱去做新的。
不知道八零、九零后是否还记得,零几年时的非智能手机,就已经有了手写识别这个功能。甚至它还给你配一个手写笔。
即便这项技术已经30年了。你现在去软件企业问问,如果他家的产品需要一个手写识别的功能,他是不是依然还得花钱买第三方的服务。
为啥?
这个问题非常好,值得思考。
首先,那时候的技术和现在不一样。在非AI时代,手写识别用的是模板匹配技术,是基于规则的。你写的字,它要拿去库里同模板做比对。就像是机器人客服,靠关键字回复一样。你问“房子”它知道,问“屋子”它说听不懂。而现在的手写识别是基于深度学习的,你问它house,它一样知道是住的地方。
其次,就算技术没变化,它的落地实践也是有壁垒的。这体现在两点。第一,给你一个开源项目,你不一定能用起来。第二,每个人的细分需求不一样,就算你能跑起来,也很难改动,个性化定制。
今天,我就讲解一个开源的OCR项目。以此论证我的观点。
我看到这个项目公布好几个月了,各类新闻也是来了一波又一波:《比xx更好的用OCR》《表格识别神器》《今年最火的十个开源项目》……
大家都在传播、转发,哎呀,这个东西好,好用。包括我做卷帘门的朋友都分享给我,说这个好用。但是,没有谁发一篇文章,说他真的用了,是如何用的,效果怎么样,它的原理是什么,它是如何训练的,它有什么优点,它有什么缺点,缺点是否能优化,该如何优化。今天,我就来填补一下。不然又会有人说,哎呀,这东西早就解决,零成本,多少年前就很成熟了。
这个项目的名字叫surya,是一个OCR识别项目,开源地址是 github.com/VikParuchuri/surya ,目前在github上拥有14K个star。它支持本地化部署,面对年收入低于500万美元的商用,可免费。
我在自己电脑上搭了一套,CPU就可以运行,GPU效率更高。稍微试验了下,给大家展示下功能。
一、功能展示
我拿这张图来进行试验,这是某报纸中的一篇新闻稿件。
它可以检测出图中有哪些类型的结构。比如,段落、图片、标题等。下面的图,就是将检测到的区域标记展示。
另外,区域检测还附赠一个阅读顺序的功能。所谓阅读顺序,就是你读这篇文档时,先读哪里后读哪里。比如按照从左到右,从上到下的顺序。有时候,阅读顺序也很重要,不然容易剧透。
既然是OCR,那么必定要将图像转为文字。想要转换文字,首先得知道哪些区域是文字。这个文本行检测,它也有。
检测到文字的位置,下一步就是识别文字了。下面是识别的结果。
最后,展示一下,它的表格识别。测试图片这样。
做一下表格检测,效果如下。
从识别到的数据信息看,它有4行,3列,12个单元格。
再来进行ocr内容识别。
二、算法集成
上面是它的功能。咱先不谈效果。下面我想问,它为什么能做到这些?回答完这个问题,才能更好地理解它的水平。
作者在最后列举了很多感谢,他说不借助这么多优秀的开源模型,他不可能完成这个项目。比如感谢CRAFT项目,这是一个超3k star的开源文本检测模型。
再比如它还采用了donut,这是一个利用无OCR理解文档的新方法。我们知道,想要理解文档,一般先要知道它上面写了什么,然后对文档进行分析,才能做出判断。而Donut结合多模态直接解析图像,极少处理文字,省去了全文分析的这个步骤。
看上面这张图。你问donut这张图的标题是什么?它可以正确地回答出来。这就是对文档的理解。
因此,从算法层面上,surya是借助了很多顶级的开源模型。而那些模型,也是站在巨人的肩膀上。可以说,它集成的算法是目前公开的一流水平。
我们再来说它的训练数据。他的训练数据,我们可以在 huggingface.co/vikp 上找到。
三、训练数据
比如文本区域类型检测,它的训练数据是这样的:
我们来看它的其中一组数据。image是一张图,bboxes是区域框,labels是区域类型,有文本类型,有表格类型。这些数据,是需要标注的,也就是在图片上画框,标注出区域类型。训练总量是1910张图片。不多。
比如表格的分析检测,它的训练数据是这样的:
image是一张表格图片,bboxes是单元格,rows是每一行的区域,cols是每一列的区域。把这些标记好的数据交给算法,让它学习什么样的特征的是行,什么样的是列。数据相对多一些,9680张图片。所以人家说它的表格识别很强。
对于文本行的检测,它的训练数据是这样的:
训练数据的结构组成:图片,图片中的某个区域,此区域对应的文本类型,另外还附加了一个文本内容。就拿上图选中的那条数据来说。这里面只标记了一条文本行区域。它告诉模型,这张图里面有一个类型为7的文本行,其区域是[88, 96, 865, 134](左、上、右、下)围成的矩形,请认真学习。
最后到了,OCR识别了。
训练数据的组成还是老一套,图片,区域框,文本内容。主要是告诉模型,我的这张图里面有几个区域,这些区域是什么文本内容,请你仔细学习。另外,这里面还有一个language字段,这表示文字的语言类型。
surya自称支持90多种语言的识别。这不是胡说,因为他的训练数据里,确实有90多种语言的标注。但是,总量太少了。一共4635张图片,如果平均的话,每种语言只有50来张训练数据。
因此,其实surya对于中文的OCR识别效果不是特别好(虽然自称并肩Tesseract)。其主要原因并不是算法不好,而是中文的训练数据太少。英文26个字母,50张纸可以覆盖。但是中文几万字,很难覆盖全。而对于手写识别,surya只能说随缘,因为根本没有训练数据,识别成啥样就是啥样。
这里面训练数据最多的是表格的识别,共9700张样本。训练数据最少的是阅读顺序的检测,才126张。因此,数据量决定了它的识别效果。对于海量的训练数据,就算对强大的商业公司来说也是一个难题。而作者能够凑足这几千张数据,已然很不容易了。
最终,我可以下一个结论。对于surya,它是一流的算法开源项目,是免费的。它对于表格的分析确实很强大。但是,它的训练数据还是太少,而且主要用于识别电子版的文档(清晰、无扭曲),手写识别基本不支持。如果你想要不做改动直接平替收费的OCR,可行性不高。就算是只用表格识别,你也得有稍微专业一些的人,进行从拍照到预处理成电子版的工作。如果收费的效果都不好,你想用免费替换它,可以打消这个念头。算法是开源的,但是训练数据和训练设备的投入,总得有人出。
如果,你想要学习并调整它,或者想自己训练,那么可以接着往下看。
四、源码运行
我不会讲官方ReadMe.md文档上明明白白写着的内容。比如你需要运行pip install streamlit
。或者它有这么几个参数,第一个--langs
是用于指定OCR的语言。
这样的话,我很像一个复读机。
另外,既然你都想要研究它了,应该不至于按照操作都跑不起来。你去看看它的源码,我只讲关键点。
首先,下载源码。你在源码中可以看到两个文件pyproject.toml
和poetry.lock
。这说明surya用的是poetry作为项目管理工具。poetry既能管理依赖包,也能管理虚拟环境。
咱们最好找一个Linux环境,再安装poetry。即便你在windows下,现在也可以很简单地安装个ubuntu虚拟机。因为linux实在是可以避免很多问题。
打开liunx命令行,进入到源码根目录。先运行pip install poetry
,安装poetry。再运行poetry install
安装依赖环境。最后运行poetry shell
进入环境,你会看到:
(surya-ocr-py3.12) root@tf:/mnt/d/surya#
这时运行surya_gui
,会启动它的web页面。正常情况下,你会看到如下的输出:
https://huggingface.co/vikp
(surya-ocr-py3.12) root@tf:/mnt/d/surya# surya_gui
You can now view your Streamlit app in your browser.
Local URL: http://localhost:8501
Network URL: http://192.168.1.109:8501
gio: http://localhost:8501: Operation not supported
Loaded detection model /mnt/d/surya/vikp/surya_det3 on device cpu with dtype torch.float32
Loaded recognition model /mnt/d/surya/vikp/surya_rec2 on device cpu with dtype torch.float32
Loaded detection model /mnt/d/surya/vikp/surya_layout3 on device cpu with dtype torch.float32
Loaded reading order model /mnt/d/surya/vikp/surya_order on device cpu with dtype torch.float32
Loaded recognition model /mnt/d/surya/vikp/surya_tablerec on device cpu with dtype torch.float32
访问localhost:8501还有这样的页面:
但实际上,极有可能不正常。因为它在自动下载权重模型访问 huggingface.co 时会访问失败。这时,需要你想办法手动下载模型,然后放到一个固定的位置。
从报错信息能看到说加载不到模型。跟着代码就找到了surya/settings.py
。
# Text detection
DETECTOR_MODEL_CHECKPOINT: str = "vikp/surya_det3"
DETECTOR_BENCH_DATASET_NAME: str = "vikp/doclaynet_bench"
# Text recognition
RECOGNITION_MODEL_CHECKPOINT: str = "vikp/surya_rec2"
RECOGNITION_BENCH_DATASET_NAME: str = "vikp/rec_bench"
# Layout
LAYOUT_MODEL_CHECKPOINT: str = "vikp/surya_layout3"
LAYOUT_BENCH_DATASET_NAME: str = "vikp/publaynet_bench"
# Ordering
ORDER_MODEL_CHECKPOINT: str = "vikp/surya_order"
ORDER_BENCH_DATASET_NAME: str = "vikp/order_bench"
# Table Rec
TABLE_REC_MODEL_CHECKPOINT: str = "vikp/surya_tablerec"
TABLE_REC_BENCH_DATASET_NAME: str = "vikp/fintabnet_bench"
……
这里面是它5大功能(检测、识别、类型、排序、表格)的权重模型以及训练数据集的路径配置。正常情况下,会自动下载并缓存读取。但现在我们要自己下载并手动配置。下载方式就是去 huggingface.co/vikp 上找对应的模型文件。
用哪个就下载哪个模型文件,即用什么功能就下载什么功能。其实,对于新手来说,这并不好区分。因为有些功能是相互依赖的。比如表格识别,往往需要先检测出表格区域,才能识别行列区域。实际上会走好几个模型。因此,不熟悉的时候,把MODEL_CHECKPOINT
全下载就行了。
DATASET_NAME
是数据集,如果你要重新训练,就下载它。不调用训练的代码,不下载也不报错。
你可以把权重文件下载到项目的根目录。然后做如下的配置:
将"vikp/surya_det3"
改为os.path.join(BASE_DIR, "vikp/surya_det3")
。因上面定义了BASE_DIR是项目根目录,所以这个路径没错。
后面再运行surya_gui
就正常了。
访问 localhost:8501 可以上传文件进行5大功能的测试。
它会展示相应的结果。
而在控制台,也会输出操作类型和时间消耗:
Detecting bboxes: 100%|███████| 1/1 [00:02<00:00, 2.61s/it]
Detecting bboxes: 100%|███████| 1/1 [00:02<00:00, 2.06s/it]
Detecting bboxes: 100%|███████| 1/1 [00:02<00:00, 2.44s/it]
Recognizing tables: 100%|███████| 1/1 [00:01<00:00, 1.19s/it]
这样,你就可以研究它的源码了。你可以改一点代码,运行一下,查看变化。具体的功能模块和代码对应,官方readMe.md上有说明。不管是暴露接口能力,还是修改内部函数,或者重新训练自己的数据,都有了着手点。
五、总结
优秀的开源项目就像一个质量很好的毛坯房,相对于商业软件,它往往不具备舒适的居住条件。但是,它的底子很好,结构合理,质量精良。它想发达,需要有人去做一个精装修。但是反过来,有些商业软件去了精装修,很可能就是豆腐渣框架。
为什么说现在是数据为王的时代。从上面的论述可以发现,在一定时空内,算法是公开的,算力投钱就可以,可能就是数据难搞。有好的、大量的数据投喂,才能产生好的AI模型。
来源:juejin.cn/post/7436713044246806578
推荐一个小而全的第三方登录开源组件
大家好,我是 Java陈序员
。
我们在企业开发中,常常需要实现登录功能,而有时候为了方便,就需要集成第三方平台的授权登录。如常见的微信登录、微博登录等,免去了用户注册步骤,提高了用户体验。
为了业务考虑,我们有时候集成的不仅仅是一两个第三方平台,甚至更多。这就会大大的提高了工作量,那么有没有开源框架来统一来集成这些第三方授权登录呢?
答案是有的,今天给大家介绍的项目提供了一个第三方授权登录的工具类库!
项目介绍
JustAuth
—— 一个第三方授权登录的工具类库,可以让你脱离繁琐的第三方登录 SDK,让登录变得So easy!
JustAuth
集成了诸如:Github、Gitee、微博、钉钉、百度、Coding、腾讯云开发者平台、OSChina、支付宝、QQ、微信、淘宝、Google、Facebook、抖音、领英、小米、微软、今日头条、Teambition、StackOverflow、Pinterest、人人、华为、企业微信、酷家乐、Gitlab、美团、饿了么、推特、飞书、京东、阿里云、喜马拉雅、Amazon、Slack和 Line 等第三方平台的授权登录。
功能特色:
- 丰富的 OAuth 平台:支持国内外数十家知名的第三方平台的 OAuth 登录。
- 自定义 state:支持自定义 State 和缓存方式,开发者可根据实际情况选择任意缓存插件。
- 自定义 OAuth:提供统一接口,支持接入任意 OAuth 网站,快速实现 OAuth 登录功能。
- 自定义 Http:接口 HTTP 工具,开发者可以根据自己项目的实际情况选择相对应的HTTP工具。
- 自定义 Scope:支持自定义 scope,以适配更多的业务场景,而不仅仅是为了登录。
- 代码规范·简单:JustAuth 代码严格遵守阿里巴巴编码规约,结构清晰、逻辑简单。
安装使用
回顾 OAuth 授权流程
参与的角色
Resource Owner
资源所有者,即代表授权客户端访问本身资源信息的用户(User),也就是应用场景中的“开发者A”Resource Server
资源服务器,托管受保护的用户账号信息,比如 Github
Authorization Server 授权服务器,验证用户身份然后为客户端派发资源访问令牌,比如 GithubResource Server
和Authorization Server
可以是同一台服务器,也可以是不同的服务器,视具体的授权平台而有所差异Client
客户端,即代表意图访问受限资源的第三方应用
授权流程
使用步骤
1、申请注册第三方平台的开发者账号
2、创建第三方平台的应用,获取配置信息(accessKey, secretKey, redirectUri)
3、使用 JustAuth
实现授权登陆
引入依赖
<dependency>
<groupId>me.zhyd.oauth</groupId>
<artifactId>JustAuth</artifactId>
<version>{latest-version}</version>
</dependency>
调用 API
// 创建授权request
AuthRequest authRequest = new AuthGiteeRequest(AuthConfig.builder()
.clientId("clientId")
.clientSecret("clientSecret")
.redirectUri("redirectUri")
.build());
// 生成授权页面
authRequest.authorize("state");
// 授权登录后会返回code(auth_code(仅限支付宝))、state,1.8.0版本后,可以用AuthCallback类作为回调接口的参数
// 注:JustAuth默认保存state的时效为3分钟,3分钟内未使用则会自动清除过期的state
authRequest.login(callback);
说明:
JustAuth
的核心就是一个个的 request,每个平台都对应一个具体的 request 类。
所以在使用之前,需要就具体的授权平台创建响应的 request.如示例代码中对应的是 Gitee 平台。
集成国外平台
国外平台需要额外配置
httpConfig
AuthRequest authRequest = new AuthGoogleRequest(AuthConfig.builder()
.clientId("Client ID")
.clientSecret("Client Secret")
.redirectUri("应用回调地址")
// 针对国外平台配置代理
.httpConfig(HttpConfig.builder()
// Http 请求超时时间
.timeout(15000)
// host 和 port 请修改为开发环境的参数
.proxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("127.0.0.1", 10080)))
.build())
.build());
SpringBoot 集成
引入依赖
<dependency>
<groupId>com.xkcoding.justauth</groupId>
<artifactId>justauth-spring-boot-starter</artifactId>
<version>1.4.0</version>
</dependency>
配置文件
justauth:
enabled: true
type:
QQ:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/qq/callback
union-id: false
WEIBO:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/weibo/callback
GITEE:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/gitee/callback
DINGTALK:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/dingtalk/callback
BAIDU:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/baidu/callback
CSDN:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/csdn/callback
CODING:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/coding/callback
coding-group-name: xx
OSCHINA:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/oschina/callback
ALIPAY:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/alipay/callback
alipay-public-key: MIIB**************DAQAB
WECHAT_OPEN:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/wechat_open/callback
WECHAT_MP:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/wechat_mp/callback
WECHAT_ENTERPRISE:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/wechat_enterprise/callback
agent-id: 1000002
TAOBAO:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/taobao/callback
GOOGLE:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/google/callback
FACEBOOK:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/facebook/callback
DOUYIN:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/douyin/callback
LINKEDIN:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/linkedin/callback
MICROSOFT:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/microsoft/callback
MI:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/mi/callback
TOUTIAO:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/toutiao/callback
TEAMBITION:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/teambition/callback
RENREN:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/renren/callback
PINTEREST:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/pinterest/callback
STACK_OVERFLOW:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/stack_overflow/callback
stack-overflow-key: asd*********asd
HUAWEI:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/huawei/callback
KUJIALE:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/kujiale/callback
GITLAB:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/gitlab/callback
MEITUAN:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/meituan/callback
ELEME:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/eleme/callback
TWITTER:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/twitter/callback
XMLY:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/xmly/callback
# 设备唯一标识ID
device-id: xxxxxxxxxxxxxx
# 客户端操作系统类型,1-iOS系统,2-Android系统,3-Web
client-os-type: 3
# 客户端包名,如果 clientOsType 为1或2时必填。对Android客户端是包名,对IOS客户端是Bundle ID
#pack-id: xxxx
FEISHU:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/feishu/callback
JD:
client-id: 10**********6
client-secret: 1f7d08**********5b7**********29e
redirect-uri: http://oauth.xkcoding.com/demo/oauth/jd/callback
cache:
type: default
代码使用
@Slf4j
@RestController
@RequestMapping("/oauth")
@RequiredArgsConstructor(onConstructor_ = @Autowired)
public class TestController {
private final AuthRequestFactory factory;
@GetMapping
public List<String> list() {
return factory.oauthList();
}
@GetMapping("/login/{type}")
public void login(@PathVariable String type, HttpServletResponse response) throws IOException {
AuthRequest authRequest = factory.get(type);
response.sendRedirect(authRequest.authorize(AuthStateUtils.createState()));
}
@RequestMapping("/{type}/callback")
public AuthResponse login(@PathVariable String type, AuthCallback callback) {
AuthRequest authRequest = factory.get(type);
AuthResponse response = authRequest.login(callback);
log.info("【response】= {}", JSONUtil.toJsonStr(response));
return response;
}
}
总结
JustAuth
集成的第三方授权登录平台,可以说是囊括了业界中大部分主流的应用系统。如国内的微信、微博、Gitee 等,还有国外的 Github、Google 等。可以满足我们日常的开发需求,开箱即用,可快速集成!
最后,贴上项目地址:
https://github.com/justauth/JustAuth
在线文档:
https://www.justauth.cn/
最后
推荐的开源项目已经收录到 GitHub
项目,欢迎 Star
:
https://github.com/chenyl8848/great-open-source-project
或者访问网站,进行在线浏览:
https://chencoding.top:8090/#/
大家的点赞、收藏和评论都是对作者的支持,如文章对你有帮助还请点赞转发支持下,谢谢!
来源:juejin.cn/post/7312060958175559743
为什么不建议使用多表join
前言
三年前在一家公司和开发团队的架构师合作过,一起写过代码。让我真的很难受啊,这个架构师写的代码很多逻辑都写到SQL
里面,各种连表查询,SQL
非常的复杂,每次我去维护都得看好久它这个SQL
逻辑。
回到最近,现在有个小伙儿班也是喜欢在SQL里面写逻辑,各种关联查询,甚至写的SQL连一万的数据连都支持不了。
都给我贡献了好几篇文章了:
完了演示的时候报错了!distinct 别乱用啊
sql 子查询的巨坑 ,80%的后端都不知道这个问题
所以我们的SQL
尽量的简洁,少用多表关联查询。
为什么不建议使用多表join?
最主要的原因就是join的效率比较低
MySQL是使用了嵌套循环(Nested-Loop Join)的方式来实现关联查询的,就是要通过两层循环,用第一张表做外循环,第二张表做内循环,外循环的每一条记录跟内循环中的记录作比较,符合条件的就输出。
- 性能问题:
- 多表
JOIN
会增加查询的复杂性,可能导致性能下降,特别是在数据量大时。 - 数据库需要在执行查询时处理更多的行和列,这可能导致更高的 I/O 操作和内存使用。
- 多表
- 可读性和维护性:
- 复杂的
JOIN
查询会使 SQL 语句变得难以理解,导致维护成本增加。 - 当查询需要频繁修改时,复杂的
JOIN
会让代码更容易出错。
- 复杂的
- 索引利用率:
- 多表
JOIN
可能会导致数据库无法有效利用索引,影响查询的优化。 - 如果
JOIN
的字段没有适当的索引,查询性能会显著下降。
- 多表
- 锁竞争:
- 多表
JOIN
可能导致更长时间的行锁或表锁,从而增加锁竞争的可能性,影响并发性能。
- 多表
- 数据完整性:
- 复杂的
JOIN
查询可能掩盖数据问题或不一致性,使得调试较为困难。 - 难以确保在
JOIN
查询中返回的数据符合业务逻辑和数据完整性要求。
- 复杂的
如何优化:
- 分解查询:在内存中自己做关联,即先从数据库中把数据查出来之后,再次查询,然后再进行数据封装。
- 考虑数据冗余:在某些情况下,可以考虑数据冗余来减少
JOIN
的需要。 - 宽表:就是基于一定的join关系,把数据库中多张表的数据打平做一张大宽表,可以同步到ES或者干脆直接在数据库中直接查都可以
什么是hash join
(扩展阅读)
mysql8.0 以前join查询使用Nested-Loop Join
算法实现
Nested-Loop Join:嵌套循环连接,如果有2张表join的话,复杂度最高是O(n^2),3张表则是O(n^3),表中的数据量越多,JOIN的效率会呈指数级下降。
MySQL 8.0中优化了join查询,新增了 hash join算法。
Hash Join 是一种高效的联表查询算法,通常用于处理较大数据集的连接操作。下面将详细介绍 Hash Join 的原理,并通过示例图解说明其查询步骤。
Hash Join 原理
Hash Join 的基本原理是将一个表的数据构建成一个哈希表,然后利用该哈希表来查找另一个表中匹配的行。其主要分为两个阶段:
- 构建阶段(Build Phase):
- 选择一个较小的表(称为构建表)来创建哈希表。
- 根据连接条件的键值计算哈希值,并将这些键值和对应的行存储在哈希表中。
- 探测阶段(Probe Phase):
- 对另一个表(称为探测表)逐行读取数据。
- 对于探测表中的每一行,计算连接字段的哈希值,并在哈希表中查找匹配的行。
- 如果找到匹配,则将匹配的行组合在一起,形成结果集。
Hash join 案例
假设我们有两个表:
表 A:
ID | Name |
---|---|
1 | Alice |
2 | Bob |
3 | Charlie |
表 B:
ID | Age |
---|---|
1 | 25 |
2 | 30 |
4 | 40 |
我们希望通过 ID 字段将这两个表连接起来。
步骤 1: 构建哈希表
选择表 A 作为构建表。我们将根据 ID 字段创建哈希表。
- 对于 ID = 1,哈希值为
hash(1)
,存储为{1: Alice}
。 - 对于 ID = 2,哈希值为
hash(2)
,存储为{2: Bob}
。 - 对于 ID = 3,哈希值为
hash(3)
,存储为{3: Charlie}
。
哈希表:
{
1: Alice,
2: Bob,
3: Charlie
}
步骤 2: 探测阶段
接下来,我们对表 B 进行探测,查找与哈希表中的行匹配的行。
- 对于 ID = 1,计算
hash(1)
,在哈希表中找到匹配,结果为(1, Alice, 25)
。 - 对于 ID = 2,计算
hash(2)
,在哈希表中找到匹配,结果为(2, Bob, 30)
。 - 对于 ID = 4,计算
hash(4)
,在哈希表中未找到匹配。
匹配之后做聚合就得到结果集了
这里的hash表是存在内存中的,内存是有限制的,超过阈值之后就会走 磁盘Hash join 的算法
磁盘hash join
如果驱动表中的数据量超过阈值,就会走磁盘hash join
的算法。将驱动表拆分成多个哈希区(或桶),每个桶存储在磁盘上。读取磁盘上的hash桶
分别加载到内存,进行探测匹配,探测完成释放当前内存桶,继续从磁盘上读取下一个hash
桶进行探测匹配,直到磁盘上所有的hash
桶都处理完毕。
总结
在实际开发中,尽量减少多表join
查询,保持SQL
的逻辑清晰,这样不仅能提高性能,还有利于维护。
感谢佬们的一键三连+关注 !!!
来源:juejin.cn/post/7438597251487268875
我患上了空指针后遗症
下面这个报错,相信没有任何一个 Java 程序员没有被它折磨过。我们对他的熟悉程度简直超过了 Hello World。 何止是熟悉,那简直是深恶痛绝,以至于我对它都产生了后遗症。
每当本地调试出现这个错误的时候,都恨不得掐一下大腿,然后默默的对自己说:垃圾,还犯这么愚蠢的错误呢?
不知道有多少同学和我一样有这种感受呢?
回想起我之前接手的一个项目,线上出现了问题,当我到了服务器一看日志,只有几个单词,那就是 java.lang.NullPointerException
,那一刻我是头晕目眩,差点一头撞在 27 寸的显示器上。回想上一次出现这种症状,还是几年前挤早高峰的公交车,挤的我双脚离地,外加有点低血糖。
当然主要问题并不是 NLP(NullPointerException),还是要仰仗前辈异常处理的“非常优秀”,异常包裹的严严实实的,只留了java.lang.NullPointerException
这一点点信息。
于是只能打开代码,找到报错的接口,一步步排查,满眼看去,皆可空指针啊。从此之后,空指针异常给我留下了深深的阴影。
好在从 JDK 14之后,NLP 异常不再仅仅是简单的这几个单词了,而会附带更加具体的异常信息,比如对一个赋值为 null 的字符串求长度,能捕捉到下面这样的异常信息:
Cannot invoke "String.length()" because "s" is null
空指针的由来
要说空指针异常,那还不只是 Java 的问题,绝大多数语言都有这个问题,比如 C++、C#、Go,但是也有没有这个问题,比如 Rust 。
空指针最早是编程界的鼻祖级人物 Tony Hoare 引入的,早在 1965年,他设计 ALGOL 60 语言的时候引入了Null 的设计,ALGOL 可谓是 C 语言的祖宗。ALGOL 中的 Null 被后来的众多语言设计者引入,就包括前面提到的这些语言。
Tony Hoare 不仅发明了我们熟悉的 Null,还是令众多算法残废闻风丧胆的快速排序算法(Quick Sort)的发明者,这个算法也是当前世界上使用最广泛的算法之一。
2009年3月他在Qcon技术会议上发表了题为「Null引用:代价十亿美元的错误」的演讲,回忆自己1965年设计第一个全面的类型系统时,未能抵御住诱惑,加入了Null引用,仅仅是因为实现起来非常容易。它后来成为许多程序设计语言的标准特性,导致了数不清的错误、漏洞和系统崩溃,可能在之后40年中造成了十亿美元的损失
如何应对空指针
处理空指针有一些措施,我们常常称之为「防御式编程」,这个说法也很形象,你不防着它,它真的就上来伤害你。
**1、**主动检查空指针,要使用一个变量之前,要检查这个变量是不是空,不是空再操作,比如常用的对字符串判空。
public static boolean isEmpty(CharSequence cs) {
return cs == null || cs.length() == 0;
}
对应的很多字符串工具类都有 isEmpty
、isNotEmpty
、isNotBlank
这种方法。
同样的,还有对于集合的判断,好多工具包都有 CollectionUtil.isEmpty
这样的方法。
为了避免空引用异常,有时候我们写的代码可能想下面这个样子,一步一判空。这样可以提高代码的健壮性和可靠性,但是看上去并不是很美观。
public static String getUserOrderDetail(Integer userId) {
User user = User.getUser(userId);
if (user != null) {
Order order = user.getOrder();
if (order != null) {
Address address = order.getAddress();
if (address != null) {
String detail = address.getDetail();
if (detail != null) {
return detail;
}
}
}
}
return "不好意思,找了半天,没找到";
}
还好,Java 8 中引入的 Optional 类可以简化这个流程。
public static String getUserOrderDetail(Integer userId) {
return Optional.ofNullable(User.getUser(userId))
.map(User::getOrder)
.map(Order::getAddress)
.map(Address::getDetail)
.orElse("不好意思,找了半天,没找到");
}
2、 能不返回 NULL 的话,就尽量不返回 NULL
比如有些获取集合的方法,没有结果的话,可以返回一个空列表。这种方式对于提供给前端或者消费者使用的接口更加适用,返回一个空集合要远比返回一个空更友好。
3、 能抛异常的话,宁可抛异常,也不要返回 NULL
还有一些情况,抛出给调用者一个具体的异常,要比返回一个 NULL 更加能让调用者清楚到底发生了什么。
比如根据一个用户的信息,但是发现用户不存在了,直接返回给调用者一个「用户不存在」的异常信息更明确,而不是返回一个 NULL,让调用方去猜。
还可以看看风筝往期文章
用这个方法,免费、无限期使用 SSL(HTTPS)证书,从此实现证书自由了
来源:juejin.cn/post/7438994542769848360
如果有人在你的论坛、博客,乱留言、乱回复,怎么办?
作者:小傅哥
博客:bugstack.cn
沉淀、分享、成长,让自己和他人都能有所收获!😄
哈喽,大家好我是技术UP主小傅哥。
常听到一句话:你很难赚到你认知以外的钱💰,屁!不是很难,是压根赚不到。 你以为要是你做也能做,但其实除了你能看见的以外,还有很多东西都不知道。
我看过不少小伙伴自己上线过带有评论功能的博客,或是能进行通信的聊天室。但最后都没运营多久就关停了,除了能花钱解决的服务器成本,还有是自身的研发的系统流程不够健全。其中非常重要的一点是舆情敏感内容
的审核,如果你做这类应用的处理,一定要对接上相应的内容安全审核。
那么,接下来小傅哥就给大家分享下,如何对接内容安全审核,并在 DDD 分层结构下实现一个对应的规则过滤服务。
文末提供了「星球:码农会锁」🧧优惠加入方式,以及本节课程的代码地址。项目演示地址:gaga.plus
一、场景说明
在本节小傅哥会通过 DDD 分层架构设计,开发出一个敏感词、内容安全审核过滤操作的规则处理器。在这个过程大家可以学习到 DDD 分层调用流程、规则模型的搭建、敏感词和内容审核的使用。
如图,上半部分是业务流程,下半部分是 DDD 分层结构中的实现。
- 业务流程上,以用户发送的提交给服务端的内容进行审核过滤,优先使用敏感词进行替换单词组。过滤后过内容审核,一般各个云平台都有提供内容审核的接口,如;京东云、百度云、腾讯云都有提供。一般价格在
0.0015 元/条
- 系统实现上,以 DDD 分层架构实现一个内容审核的流程。app 配置组件和启动应用、trigger 提供 http 调用、domain 编写核心逻辑和流程、infrastructure 提供 dao 的基础操作。
二、内容审核 - SDK 使用
一般舆情内容审核分为两种,一种是静态配置数据的 SDK 组件,也叫敏感词过滤。另外一种是实时动态的由各个第三方提供的内容审核接口服务。这类的就是前面提到的,在各个云平台都有提供。
这里小傅哥先带着大家做下最基本的调用案例,之后再基于 DDD 工程实现整个代码开发。
1. 敏感词
地址:github.com/houbb/sensi… - 开源的敏感词库组件
<dependency>
<groupId>com.github.houbb</groupId>
<artifactId>sensitive-word</artifactId>
<version>0.8.0</version>
</dependency>
案例代码
@Test
public void test_sensitive_word() {
boolean contains = sensitiveWordBs.contains("小傅哥喜欢烧烤臭毛蛋,豆包爱吃粑粑,如果想吃订购请打电话:13900901878");
log.info("是否被敏感词拦截:{}", contains);
}
@Test
public void test_sensitive_word_findAll() {
List<String> list = sensitiveWordBs.findAll("小傅哥喜欢烧烤臭毛蛋,豆包爱吃粑粑,如果想吃订购请打电话:13900901878");
log.info("测试结果:{}", JSON.toJSONString(list));
}
@Test
public void test_sensitive_word_replace() {
String replace = sensitiveWordBs.replace("小傅哥喜欢烧烤臭毛蛋,豆包爱吃粑粑,如果想吃订购请打电话:13900901878");
log.info("测试结果:{}", replace);
}
- 敏感词组件提供了大量的风险词过滤,同时可以基于组件的文档完成自定义敏感词的增改删减操作。
本文在工程中已提供
- 敏感词组件提供了判断、查找、过滤操作。还有你可以把检测到的敏感词替换为
*
或者空格
。
2. 内容审核
- 京东云:http://www.jdcloud.com/cn/products…
- 百度云:ai.baidu.com/censoring#/…
- 腾讯云:cloud.tencent.com/product/tms
这里小傅哥以其中的一个百度云为例,为大家展示内容安全审核的使用。
<!-- 百度内容审核 https://mvnrepository.com/artifact/com.baidu.aip/java-sdk -->
<dependency>
<groupId>com.baidu.aip</groupId>
<artifactId>java-sdk</artifactId>
<version>4.16.17</version>
</dependency>
2.1 配置应用
- 先领取免费的调用次数,之后创建应用。创建应用后就可以获得连接信息;appid、apikey、secretkey
- 另外是策略配置,如果你在过滤中不需要检测用户发的应用营销信息,那么是可以不检测的。
2.2 测试服务
//设置APPID/AK/SK
public static final String APP_ID = "{APP_ID}";
public static final String API_KEY = "{API_KEY}";
public static final String SECRET_KEY = "{SECRET_KEY}";
private AipContentCensor client;
@Before
public void init() {
client = new AipContentCensor(APP_ID, API_KEY, SECRET_KEY);
// 可选:设置网络连接参数
client.setConnectionTimeoutInMillis(2000);
client.setSocketTimeoutInMillis(60000);
}
@Test
public void test_textCensorUserDefined() throws JSONException {
for (int i = 0; i < 1; i++) {
JSONObject jsonObject = client.textCensorUserDefined("小傅哥喜欢烧烤臭毛蛋,豆包爱吃粑粑,如果想吃订购请打电话:13900901878");
if (!jsonObject.isNull("error_code")) {
log.info("测试结果:{}", jsonObject.get("error_code"));
} else {
log.info("测试结果:{}", jsonObject.toString());
}
}
}
测试结果
13:41:16.393 [main] INFO com.baidu.aip.client.BaseClient - get access_token success. current state: STATE_AIP_AUTH_OK
13:41:16.396 [main] DEBUG com.baidu.aip.client.BaseClient - current state after check priviledge: STATE_TRUE_AIP_USER
13:41:16.495 [main] INFO cn.bugstack.x.api.test.BaiduAipContentCensorTest - 测试结果:{"conclusion":"合规","log_id":17046060767025067,"isHitMd5":false,"conclusionType":1}
- 应为过滤掉了营销信息,比如手机号。那么就会返回
合规
三、应用实现 - DDD 架构
做了以上的基本调用案例以后,我们来看下在系统中怎么运用这些基础功能完成业务诉求。
1. 工程结构
- docs 下提供了 docker 安装 mysql 以及初始化数据库配置的脚本。因为本文的案例,可以满足你在数据库中增加敏感词配置。
- app 是应用的启动层,如上我们所需的敏感词和内容审核,都在app层下配置启动处理。
- domain 领域层通过策略+工厂,实现规则过滤服务。
2. 数据库表
- 在docs 提供了数据库初始化的脚本语句,你可以导入到自己的数据库,或者使用 docker 脚本安装测试。—— 注意已经安装过 mysql 占用了 3306 端口的话,记得修改 docker 脚本安装 mysql 的端口。
- 配置到数据库中的敏感词方便管理和使用,为了性能考虑也可以考虑使用 redis 做一层缓存。
3. 配置加载
3.1 敏感词初始化
@Configuration
public class SensitiveWordConfig {
@Bean
public SensitiveWordBs sensitiveWordBs(IWordDeny wordDeny, IWordAllow wordAllow) {
return SensitiveWordBs.newInstance()
.wordDeny(wordDeny)
.wordAllow(wordAllow)
.ignoreCase(true)
.ignoreWidth(true)
.ignoreNumStyle(true)
.ignoreChineseStyle(true)
.ignoreEnglishStyle(true)
.ignoreRepeat(false)
.enableNumCheck(true)
.enableEmailCheck(true)
.enableUrlCheck(true)
.enableWordCheck(true)
.numCheckLen(1024)
.init();
}
@Bean
public IWordDeny wordDeny(ISensitiveWordDao sensitiveWordDao) {
return new IWordDeny() {
@Override
public List<String> deny() {
return sensitiveWordDao.queryValidSensitiveWordConfig("deny");
}
};
}
@Bean
public IWordAllow wordAllow(ISensitiveWordDao sensitiveWordDao) {
return new IWordAllow() {
@Override
public List<String> allow() {
return sensitiveWordDao.queryValidSensitiveWordConfig("allow");
}
};
}
}
- wordDeny、wordAllow 是两个自定义的拦截和放行的敏感词列表,这里小傅哥设计从数据库中查询。可以方便动态的维护。
3.2 内容安全初始化
# 内容安全
baidu:
aip:
app_id: 46573000
api_key: XKOalQOgDBUrvgLBplvu****
secret_key: kwRh1bEhETYWpq9thzyySdFDPKUk****
- 自定义一个配置文件类 AipContentCensorConfigProperties
@Bean
public AipContentCensor aipContentCensor(AipContentCensorConfigProperties properties) {
AipContentCensor client = new AipContentCensor(properties.getApp_id(), properties.getApi_key(), properties.getSecret_key());
client.setConnectionTimeoutInMillis(2000);
client.setSocketTimeoutInMillis(60000);
return client;
}
- 这里我们来统一创建 AipContentCensor 对象,用于有需要使用的地方处理内容审核。
4. 规则实现
源码: cn.bugstack.xfg.dev.tech.domain.service.IRuleLogicFilter
public interface IRuleLogicFilter {
RuleActionEntity<RuleMatterEntity> filter(RuleMatterEntity ruleMatterEntity);
}
- 定义一个统一的规则过滤接口
4.1 敏感词
@Slf4j
@Component
@LogicStrategy(logicMode = DefaultLogicFactory.LogicModel.SENSITIVE_WORD)
public class SensitiveWordFilter implements IRuleLogicFilter {
@Resource
private SensitiveWordBs words;
@Override
public RuleActionEntity<RuleMatterEntity> filter(RuleMatterEntity ruleMatterEntity) {
// 敏感词过滤
String content = ruleMatterEntity.getContent();
String replace = words.replace(content);
// 返回结果
return RuleActionEntity.<RuleMatterEntity>builder()
.type(LogicCheckTypeVO.SUCCESS)
.data(RuleMatterEntity.builder().content(replace).build())
.build();
}
}
4.2 安全内容
@Slf4j
@Component
@LogicStrategy(logicMode = DefaultLogicFactory.LogicModel.CONTENT_SECURITY)
public class ContentSecurityFilter implements IRuleLogicFilter {
@Resource
private AipContentCensor aipContentCensor;
@Override
public RuleActionEntity<RuleMatterEntity> filter(RuleMatterEntity ruleMatterEntity) {
JSONObject jsonObject = aipContentCensor.textCensorUserDefined(ruleMatterEntity.getContent());
if (!jsonObject.isNull("conclusion") && "不合规".equals(jsonObject.get("conclusion"))) {
return RuleActionEntity.<RuleMatterEntity>builder()
.type(LogicCheckTypeVO.REFUSE)
.data(RuleMatterEntity.builder().content("内容不合规").build())
.build();
}
// 返回结果
return RuleActionEntity.<RuleMatterEntity>builder()
.type(LogicCheckTypeVO.SUCCESS)
.data(ruleMatterEntity)
.build();
}
}
5. 工厂使用
public class DefaultLogicFactory {
public Map<String, IRuleLogicFilter> logicFilterMap = new ConcurrentHashMap<>();
public DefaultLogicFactory(List<IRuleLogicFilter> logicFilters) {
logicFilters.forEach(logic -> {
LogicStrategy strategy = AnnotationUtils.findAnnotation(logic.getClass(), LogicStrategy.class);
if (null != strategy) {
logicFilterMap.put(strategy.logicMode().getCode(), logic);
}
});
}
public RuleActionEntity<RuleMatterEntity> doCheckLogic(RuleMatterEntity ruleMatterEntity, LogicModel... logics) {
RuleActionEntity<RuleMatterEntity> entity = null;
for (LogicModel model : logics) {
entity = logicFilterMap.get(model.code).filter(ruleMatterEntity);
if (!LogicCheckTypeVO.SUCCESS.equals(entity.getType())) return entity;
ruleMatterEntity = entity.getData();
}
return entity != null ? entity :
RuleActionEntity.<RuleMatterEntity>builder()
.type(LogicCheckTypeVO.SUCCESS)
.data(ruleMatterEntity)
.build();
}
}
- 定义出规则的使用工厂,通过构造函数的方式注入已经实现了接口 IRuleLogicFilter 的 N 个规则,注入到 Map 中
Map<String, IRuleLogicFilter> logicFilterMap
- doCheckLogic 根据入参来过滤需要处理的规则。这里可以看到每过滤一个规则都会把参数继续传递给下一个规则继续筛选。
有点像层层过筛子的感觉
四、测试验证
- 测试前确保已经初始化了库表
docs/dev-ops/sql/xfg-dev-tech-content-moderation.sql
application-dev.yml
配置百度内容安全参数和数据库连接参数。
1. 功能测试
@Slf4j
@RunWith(SpringRunner.class)
@SpringBootTest
public class RuleLogicTest {
@Resource
private DefaultLogicFactory defaultLogicFactory;
@Test
public void test() {
RuleActionEntity<RuleMatterEntity> entity = defaultLogicFactory.doCheckLogic(
RuleMatterEntity.builder().content("小傅哥喜欢烧烤臭毛蛋,豆包爱吃粑粑,如果想吃订购请打电话:13900901878").build(),
DefaultLogicFactory.LogicModel.SENSITIVE_WORD,
DefaultLogicFactory.LogicModel.CONTENT_SECURITY
);
log.info("测试结果:{}", JSON.toJSONString(entity));
}
}
测试结果
24-01-07.14:17:16.988 [main ] INFO BaseClient - get access_token success. current state: STATE_AIP_AUTH_OK
24-01-07.14:17:17.328 [main ] INFO RuleLogicTest - 测试结果:{"data":{"content":"小傅哥喜欢烧烤***,豆包爱吃**,如果想吃订购请打电话:13900901878"},"type":"SUCCESS"}
2. 接口测试
@RequestMapping(value = "sensitive/rule", method = RequestMethod.GET)
public String rule(String content) {
try {
log.info("内容审核开始 content: {}", content);
RuleActionEntity<RuleMatterEntity> entity = defaultLogicFactory.doCheckLogic(RuleMatterEntity.builder().content(content).build(),
DefaultLogicFactory.LogicModel.SENSITIVE_WORD,
DefaultLogicFactory.LogicModel.CONTENT_SECURITY
);
log.info("内容审核完成 content: {}", entity.getData());
return JSON.toJSONString(entity);
} catch (Exception e) {
log.error("内容审核异常 content: {}", content, e);
return "Err!";
}
}
接口:http://localhost:8091/api/v1/content/sensitive/rule?content=小傅哥喜欢烧烤臭毛蛋,豆包爱吃粑粑,如果想吃订购请打电话:13900901878
- 那么现在就可以对内容进行审核过滤了。
六、推荐阅读
来源:juejin.cn/post/7322156683467112499
Java中使用for而不是forEach遍历List的10大理由
首发公众号:【赵侠客】
引言
我相信作为一名java开发者你一定听过或者看过类似《你还在用for循环遍历List吗?》、《JDK8都10岁了,你还在用for循环遍历List吗?》这类鄙视在Java中使用for循环遍历List的水文。这类文章说的其实就是使用Java8中的Stream.foreach()
来遍历元素,在技术圈感觉使用新的技术就高大上,开发者们也都默许接受新技术的很多缺点,而使用老的技术或者传统的方法就会被人鄙视,被人觉得Low,那么使用forEach()
真的很高大上吗?它真的比传统的for
循环好用吗?本文就列出10大推荐使用for
而不是forEach()
的理由。
理由一、for性能更好
在我的固有认知中我是觉得for
的循环性能比Stream.forEach()
要好的,因为在技术界有一条真理:
越简单越原始的代码往往性能也越好
而且搜索一些文章或者大模型都是这么觉得的,可时我并没有找到专业的基准测试证明此结论。那么实际测试情况是不是这样的呢?虽然这个循环的性能差距对我们的系统性能基本上没有影响,不过为了证明for
的循环性能真的比Stream.forEach()
好我使用基准测试用专业的实际数据来说话。我的测试代码非常的简单,就对一个List<Integer> ids
分别使用for
和Stream.forEach()
遍历出所有的元素,以下是测试代码:
@State(Scope.Thread)
public class ForBenchmark {
private List<Integer> ids ;
@Setup
public void setup() {
ids = new ArrayList<>();
//分别对10、100、1000、1万、10万个元素测试
IntStream.range(0, 10).forEach(i -> ids.add(i));
}
@TearDown
public void tearDown() {
ids = new ArrayList<>();
}
@Benchmark
public void testFor() {
for (int i = 0; i <ids.size() ; i++) {
Integer id = ids.get(i);
}
}
@Benchmark
public void testStreamforEach() {
ids.stream().forEach(x->{
Integer id=x;
});
}
@Test
public void testMyBenchmark() throws Exception {
Options options = new OptionsBuilder()
.include(ForBenchmark.class.getSimpleName())
.forks(1)
.threads(1)
.warmupIterations(1)
.measurementIterations(1)
.mode(Mode.Throughput)
.build();
new Runner(options).run();
}
}
我使用ArrayList分对10、100、1000、1万,10万个元素进行测试,以下是使用JMH基准测试的结果,结果中的数字为吞吐量,单位为ops/s,即每秒钟执行方法的次数:
方法 | 十 | 百 | 千 | 万 | 10万 |
---|---|---|---|---|---|
forEach | 45194532 | 17187781 | 2501802 | 200292 | 20309 |
for | 127056654 | 19310361 | 2530502 | 202632 | 19228 |
for对比 | ↑181% | ↑12% | ↑1% | ↓1% | ↓5% |
从使用Benchmark基准测试结果来看使用for遍历List比Stream.forEach性能在元素越小的情况下优势越明显,在10万元素遍历时性能反而没有Stream.forEach好了,不过在实际项目开发中我们很少有超过10万元素的遍历。
所以可以得出结论:
在小List(万元素以内)遍历中for性能要优于Stream.forEach
理由二、for占用内存更小
Stream.forEach()会占用更多的内存,因为它涉及到创建流、临时对象或者对中间操作进行缓存。for 循环则更直接,操作底层集合,通常不会有额外的临时对象。可以看如下求和代码,运行时增加JVM参数-XX:+PrintGCDetails -Xms4G -Xmx4G
输出GC日志:
- 使用for遍历
List<Integer> ids = IntStream.range(1,10000000).boxed().collect(Collectors.toList());
int sum = 0;
for (int i = 0; i < ids.size(); i++) {
sum +=ids.get(i);
}
System.gc();
//GC日志
[GC (System.gc()) [PSYoungGen: 392540K->174586K(1223168K)] 392540K->212100K(4019712K), 0.2083486 secs] [Times: user=0.58 sys=0.09, real=0.21 secs]
从GC日志中可以看出,使用for遍历List在GC回收前年轻代使用了392540K,总内存使用了392540K,回收耗时0.20s
- 使用stream
List<Integer> ids = IntStream.range(1,10000000).boxed().collect(Collectors.toList());
int sum = ids.stream().reduce(0,Integer::sum);
System.gc();
//GC日志
[GC (System.gc()) [PSYoungGen: 539341K->174586K(1223168K)] 539341K->212118K(4019712K), 0.3747694 secs] [Times: user=0.55 sys=0.83, real=0.38 secs]
从GC日志中可以看出,回收前年轻代使用了539341K,总内存使用了539341K,回收耗时0.37s ,从内存占用情况来看使用for会比Stream.forEach()占用内存少37%,而且Stream.foreach() GC耗时比for多了85%。
理由三、for更易控制流程
我们使用for遍历List可以很方便的使用break
、continue
、return
来控制循环,而使用Stream.forEach在循环中是不能使用break
、continue
,特别指出的使用return
是无法中断Stream.forEach循环的,如下代码:
List<Integer> ids = IntStream.range(1,4).boxed().collect(Collectors.toList());
ids.stream().forEach(i->{
System.out.println(""+i);
if(i>1){
return;
}
});
System.out.println("==");
for (int i = 0; i < ids.size(); i++) {
System.out.println(""+ids.get(i));
if(ids.get(i)>1){
return;
}
}
输出:
forEach-1
forEach-2
forEach-3
==
for-1
for-2
从输出结果可以看出在Stream.forEach中使用return后循环还会继续执行的,而在for循环中使用return将中断循环。
理由四、for访问变量更灵活
这点我想是很多人在使用Stream.forEach中比较头疼的一点,因为在Stream.forEach中引用的变量必须是final类型,也就是说不能修改forEach循环体之外的变量,但是我们很多业务场景就是修改循环体外的变量,如以下代码:
Integer sum=0;
for (int i = 0; i < ids.size(); i++) {
sum++;
}
ids.stream().forEach(i -> {
//报错
sum++;
});
像上面的这样的代码在实际中是很常见的,sum++在forEach中是不被允许的,有时为了使用类似的方法我们只能把变量变成一个引用类型:
AtomicReference<Integer> sum= new AtomicReference<>(0);
ids.stream().forEach(i -> {
sum.getAndSet(sum.get() + 1);
});
所以在访问变量方面for会更加灵活。
理由五、for处理异常更方便
这一点也是我使用forEach比较头疼的,在forEach中的Exception必须要捕获处理,如下代码:
public void testException() throws Exception {
List<Integer> ids = IntStream.range(1, 4).boxed().collect(Collectors.toList());
for (int i = 0; i < ids.size(); i++) {
//直接抛出Exception
System.out.println(div(i, i - 1));
}
ids.stream().forEach(x -> {
try {
//必须捕获Exception
System.out.println(div(x, x - 1));
} catch (Exception e) {
throw new RuntimeException(e);
}
});
}
private Integer div(Integer a, Integer b) throws Exception {
return a / b;
}
我们在循环中调用了div()方法,该方法抛出了Exception,如果是使用for循环如果不想处理可以直接抛出,但是使用forEach就必须要自己处理异常了,所以for在处理异常方面会更加灵活方便。
理由六、for能对集合添加、删除
在for循环中可以直接修改原始集合(如添加、删除元素),而 Stream 不允许修改基础集合,会抛出 ConcurrentModificationException,如下代码:
List<Integer> ids = IntStream.range(0, 4).boxed().collect(Collectors.toList());
for (int i = 0; i < ids.size(); i++) {
if(i<1){
ids.add(i);
}
}
System.out.println(ids);
List<Integer> ids2 = IntStream.range(0, 4).boxed().collect(Collectors.toList());
ids2.stream().forEach(x -> {
if(x<1){
ids2.add(x);
}
});
System.out.println(ids2);
输出:
[0, 1, 2, 3, 0]
java.util.ConcurrentModificationException
如果你想在循环中添加或者删除元素foreach是无法完成了,所以for处理集合更方便。
理由七、for Debug更友好
Stream.forEach()使用了Lambda表达示,一行代码可以搞定很多功能,但是这也给Debug带来了困难,如下代码:
List<Integer> ids = IntStream.range(0, 4).boxed().collect(Collectors.toList());
for (int i = 0; i < ids.size(); i++) {
System.out.println(ids.get(i));
}
List<Integer> ids2 = IntStream.range(0, 4).boxed().collect(Collectors.toList());
ids2.stream().forEach(System.out::println);
以下是DeBug截图:
我们可以看出使用for循环Debug可以一步一步的跟踪程序执行步骤,但是使用forEach却做不到,所以for可以更方便的调试你的代码,让你更快捷的找到出现问题的代码。
理由八、for代码可读性更好
Lambda表达示属于面向函数式编程,主打的就是一个抽象,相比于面向对象或者面向过程编程代码可读性是非常的差,有时自己不写的代码过段时间后自己都看不懂。就比如我在文章《解密阿里大神写的天书般的Tree工具类,轻松搞定树结构!》一文中使用函数式编程写了一个Tree工具类,我们可以对比一下面向过程和面向函数式编程代码可读性的差距:
- 使用for面向过程编程代码:
public static List<MenuVo> makeTree(List<MenuVo> allDate,Long rootParentId) {
List<MenuVo> roots = new ArrayList<>();
for (MenuVo menu : allDate) {
if (Objects.equals(rootParentId, menu.getPId())) {
roots.add(menu);
}
}
for (MenuVo root : roots) {
makeChildren(root, allDate);
}
return roots;
}
public static MenuVo makeChildren(MenuVo root, List<MenuVo> allDate) {
for (MenuVo menu : allDate) {
if (Objects.equals(root.getId(), menu.getPId())) {
makeChildren(menu, allDate);
root.getSubMenus().add(menu);
}
}
return root;
}
- 使用forEach面向函数式编程代码:
public static <E> List<E> makeTree(List<E> list, Predicate<E> rootCheck, BiFunction<E, E, Boolean> parentCheck, BiConsumer<E, List<E>> setSubChildren) {
return list.stream().filter(rootCheck).peek(x -> setSubChildren.accept(x, makeChildren(x, list, parentCheck, setSubChildren))).collect(Collectors.toList());
}
private static <E> List<E> makeChildren(E parent, List<E> allData, BiFunction<E, E, Boolean> parentCheck, BiConsumer<E, List<E>> children) {
return allData.stream().filter(x -> parentCheck.apply(parent, x)).peek(x -> children.accept(x, makeChildren(x, allData, parentCheck, children))).collect(Collectors.toList());
}
对比以上两段代码,可以看出面向过程的代码思路非常的清晰,基本上可以一眼看懂代码要做什么,反观面向函数式编程的代码,我想大都人一眼都不知道代码在干什么的,所以使用for的代码可读性会更好。
理由九、for更好的管理状态
for循环可以轻松地在每次迭代中维护状态,这在Stream.forEach中可能需要额外的逻辑来实现。这一条可理由三有点像,我们经常需要通过状态能控制循环是否执行,如下代码:
boolean flag = true;
for (int i = 0; i < 10; i++) {
if(flag){
System.out.println(i);
flag=false;
}
}
AtomicBoolean flag1 = new AtomicBoolean(true);
IntStream.range(0, 10).forEach(x->{
if (flag1.get()){
flag1.set(false);
System.out.println(x);
}
});
这个例子说明了在使用Stream.forEach时,为了维护状态,我们需要引入额外的逻辑,如使用AtomicBoolean,而在for循环中,这种状态管理是直接和简单的。
理由十、for可以使用索引直接访问元素
在某些情况下,特别是当需要根据元素的索引(位置)来操作集合中的元素时,for就可以直接使用索引访问了。在Stream.forEach中就不能直接通过索引访问,比如我们需要将ids中的数字翻倍:
List<Integer> ids = IntStream.range(0, 4).boxed().collect(Collectors.toList());
for (int i = 0; i < ids.size(); i++) {
ids.set(i,i*2);
}
List<Integer> ids2 = IntStream.range(0, 4).boxed().collect(Collectors.toList());
ids2=ids2.stream().map(x->x*2).collect(Collectors.toList());
我们使用for循环来遍历这个列表,并在每次迭代中根据索引i来修改列表中的元素。这种操作直接且直观。而使用Stream.foreach()不能直接通过索引下标访问元素的,只能将List转换为流,然后使用map操作将每个元素乘以2,最后,我们使用Collectors.toList()将结果收集回一个新的List。
总结
本文介绍了在实际开发中更推荐使用for循环而不是Stream.foreach()来遍历List的十大理由,并给出了具体的代码和测试结果,当然这并不是说就一定要使用传统的for循环,要根据自己的实际情况来选择合适的方法。通过此案件也想让读者明白在互联网世界中你所看到的东西都是别人想让你看到的,这个世界是没有真相的,别人想让你看到的就是所谓的”真相“,做为吃瓜群众一定不能随波逐流,要有鉴别信息真假的能力和培养独立思考的能力。
来源:juejin.cn/post/7416848881407524902
VSCode无限画布模式(可能会惊艳到你的一个小功能)
👇 该文章内容的
受众
是VSCode
的用户,不满足条件的同学可以选择性阅读
哈~
❓现存的痛点
VSCode
是我的主力开发工具,在实际的开发中,我经常会对编辑器进行分栏处理,组件A的tsx、css代码、工具类方法各一个窗口
,组件B的tsx、css代码、工具类方法各一个窗口
,组件C的......
当组件拆的足够多
的时候,多个分栏会把本就不大的编辑器窗口分成N份,每一份的可视区域就小的可怜
,切换组件代码时,需要不小的翻找成本
,而且经常忘记我之前把文件放在了那个格子里,特别是下面的场景(一个小窗口内开了N个小tab
),此时更难找到想要的窗口了...
问题汇总
- 分栏会导致每个窗口的
面积变小
,开发体验差(即使可以双击放大,但效果仍不符合预期); - 编辑器窗口
容易被新打开的窗口替换掉
,常找不到之前打开的窗口; - 窗口的可操作性不强,
位置不容易调整
。
💡解题的思路
1. 自由 & 独立的编辑器窗口
分栏会导致每个窗口的面积变小
,开发体验不好。
那就别变小了!
每个编辑器窗口都还是原来的大小,甚至更大!
2. 无限画布
编辑器窗口容易被新打开的窗口替换掉
,常找不到之前打开的窗口。窗口的可操作性不强,位置不容易调整。
那就每个窗口都拥有一个自己的位置
好了!拖一下就可以找到了!
3. 画布体验
好用的画布是可以较大的提升用户体验的,下面重点做了四个方面的优化:
3.1 在编辑器里可以快速缩小 & 移动
因为不可避免的会出现一些
事件冲突
(比如编辑器里的滚动和画布的滚动、缩放等等),通过提供快捷键的
解法,可以在编辑器内快速移动、缩放
画布。command + 鼠标上下滑动 = 缩放
option + 鼠标移动 = 画布移动
注意下图,鼠标还在编辑器窗口中,依然可以拖动画布👇🏻
3.2 快速放大和缩小编辑窗口
通过快捷按钮的方式,可以快速的放大和缩小编辑器窗口。
3.3 一键定位到中心点
不小心把所有窗口都拖到了画布视口外找不到了?没事儿,可以通过点击快捷按钮的方式,快速回到中心点。
3.4 窗口的合并和分解
可以在窗口下进行编辑器的合并,即可以简单的把一些常用的窗口进行合并、分解。
💬 提出的背景
作为一名前端开发同学
,避免不了接触UI同学的设计稿,我司使用的就是figma
,以figma
平台为例,其无限画布模式可以非常方便的平铺N个稿子,并快速的看到所有稿子的全貌、找到自己想要的稿子等等,效果如下:
没错!我就是基于这个思路提出了第一个想法,既然图片可以无限展示,编辑器为什么不能呢?
这个想法其实去年就有了,期间大概断断续续花了半年多左右的时间在调研和阅读VSCode的源码上,年后花了大概3个月的时间进行实现,最终在上个月做到了上面的效果。
经过约一个月的试用(目前我的日常需求均是使用这种模式进行的开发)
,发现效果超出预期
,我经常会在画布中开启约10+
个窗口,并频繁的在各个窗口之间来回移动
,在这个过程中,我发现以下几点很让我很是欣喜:
空间感
:我个人对“空间和方向”比较敏感,恰好画布模式会给我一种真实的空间感
,我仿佛在一间房子里,里面摆满了我的代码,我穿梭在代码中,修一修这个,调一调这个~满足感
:无限画布的方式,相当于我间接拥有了无限大的屏幕,我只需要动动手指找到我的编辑窗口就好了,它可以随意的放大和缩小,所以我可以在屏幕上展示足够多的代码。更方便的看源码
:我可以把源码的每个文件单独开一个窗口,然后把每个窗口按顺序铺起来,摆成一条线,这条线就是源码的思路(当然可以用截图的方式看源码 & 缕思路,但是,需要注意一点,这个编辑器是可以交互的!)
⌨️ 后续的计划
后续计划继续增强画布
的能力,让它可以更好用:
小窗口支持命名
,在缩小画布时,窗口缩小,但是命名不缩小,可以直观的找到想要的窗口。增强看源码的体验
:支持在画布上添加其他元素(文案、箭头、连线),试想一下,以后在看源码时,拥有一个无限的画板来展示代码和思路,关键是代码是可以交互的,这该有多么方便!类似MacOS的台前调度功能
:把有关联的一些窗口分组,画布一侧有分组的入口,点击入口可以切换画布中的组,便于用户快速的进行批量窗口切换,比如A页面的一些JS、CSS等放在一个组,B页面放在另一个组,这样可以快速的切换文件窗口。
📔 其他的补充
调研过程中发现无法使用VSCode的插件功能来实现这个功能,所以只好fork了一份VSCode的开源代码,进行了大量修改,最终需要对源码进行编译打包才能使用(一个新的VSCode),目前只打包了mac的arm64版本来供自己试用。
另外,由于VSCode并不是100%开源(微软的一些服务相关的逻辑是闭源的),所以github上的开源仓库只是它的部分代码,经过编译之后,发现缺失了远程连接相关的功能,其他的功能暂时没有发现缺失。
🦽 可以试用吗
目前还没有对外提供试用版的打算
,想自己继续使用一些时间,持续打磨一下细节,等功能细节更完善了再对外进行推广,至于这次的软文~ 其实是希望可以引起阅读到这里的同学进行讨论,可以聊一下你对该功能的一些看法,以及一些其他的好点子~
,thx~
🫡 小小的致敬
- 致敬VSCode团队,在阅读和改造他们代码的过程中学习到了不少hin有用的代码技能,也正是因为有他们的开源,才能有我的这次折腾👍🏻
- 致敬锤子科技罗永浩老师,这次实现思路也有借鉴当年发布的“无限屏”功能,本文的头图就是来自当年的发布会截图。
来源:juejin.cn/post/7375586227984220169
工作六年,看到这样的代码,内心五味杂陈......
工作六年,看到这样的代码,内心五味杂陈......
那天下午,看到了令我终生难忘的代码,那一刻破防了......
ヾ(•ω•`)🫥 故事还得从半年前数据隔离的那个事情说起......
📖一、历史背景
1.1 数据隔离
预发,灰度,线上环境共用一个数据库。每一张表有一个 env 字段,环境不同值不同。特别说明: env 字段即环境字段。如下图所示:
1.2 隔离之前
🖌️插曲:一开始只有 1 个核心表有 env 字段,其他表均无该字段;
有一天预发环境的操作影响到客户线上的数据。 为了彻底隔离,剩余的二十几个表均要添加上环境隔离字段。
当时二十几张表已经大量生产数据,隔离需要做好兼容过渡,保障数据安全。
1.3 隔离改造
其他表历史数据很难做区分,于是新增加的字段 env 初始化 all ,表示预发线上都能访问。以此达到历史数据的兼容。
每一个环境都有一个自己独立标志;从 application.properties 中读该字段;最终到数据库执行的语句如下:
SELECT XXX FROM tableName WHERE env = ${环境字段值} and ${condition}
1.4 隔离方案
最拉胯的做法:每一张表涉及到的 DO、Mapper、XML等挨个添加 env 字段。但我指定不能这么干!!!
具体方案:自定义 mybatis 拦截器进行统一处理。 通过这个方案可以解决以下几个问题:
- 业务代码不用修改,包括 DO、Mapper、XML等。只修改 mybatis 拦截的逻辑。
- 挨个添加补充字段,工程量很多,出错概率极高
- 后续扩展容易
1.5 最终落地
在 mybatis 拦截器中, 通过改写 SQL。新增时填充环境字段值,查询时添加环境字段条件。真正实现改一处即可。 考虑历史数据过渡,将 env = ${当前环境}
修改成 env in (${当前环境},'all')
SELECT xxx FROM ${tableName} WHERE env in (${当前环境},'all') AND ${其他条件}
具体实现逻辑如下图所示:
- 其中 env 字段是从 application.properties 配置获取,全局唯一,只要环境不同,env 值不同
- 借助 JSqlParser 开源工具,改写 sql 语句,修改重新填充、查询拼接条件即可。链接JSQLParser
思路:自定义拦截器,填充环境参数,修改 sql 语句,下面是部分代码示例:
@Intercepts(
{@Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class})}
)
@Component
public class EnvIsolationInterceptor implements Interceptor {
......
@Override
public Object intercept(Invocation invocation) throws Throwable {
......
if (SqlCommandType.INSERT == sqlCommandType) {
try {
// 重写 sql 执行语句,填充环境参数等
insertMethodProcess(invocation, boundSql);
} catch (Exception exception) {
log.error("parser insert sql exception, boundSql is:" + JSON.toJSONString(boundSql), exception);
throw exception;
}
}
return invocation.proceed();
}
}
一气呵成,完美上线。
📚二、发展演变
2.1 业务需求
随着业务发展,出现了以下需求:
- 上下游合作,我们的 PRC 接口在匹配环境上与他们有差异,需要改造
SELECT * FROM ${tableName} WHERE bizId = ${bizId} and env in (?,'all')
- 有一些环境的数据相互相共享,比如预发和灰度等
- 开发人员的部分后面,希望在预发能纠正线上数据等
2.2 初步沟通
这个需求的落地交给了来了快两年的小鲜肉。 在开始做之前,他也问我该怎么做;我简单说了一些想法,比如可以跳过环境字段检查,不拼接条件;或者拼接所有条件,这样都能查询;亦或者看一下能不能注解来标志特定方法,你想一想如何实现......
(●ˇ∀ˇ●)年纪大了需要给年轻人机会。
2.3 勤劳能干
小鲜肉,没多久就实现了。不过有一天下午他遇到了麻烦。他填充的环境字段取出来为 null,看来很久没找到原因,让我帮他看看。(不久前也还教过他 Arthas 如何使用呢,这种问题应该不在话下吧🤔)
2.4 具体实现
大致逻辑:在需要跳过环境条件判断的方法前后做硬编码处理,同环切面逻辑, 一加一删。填充颜色部分为小鲜肉的改造逻辑。
大概逻辑就是:将 env 字段填充所有环境。条件过滤的忽略的目的。
SELECT * FROM ${tableName} WHERE env in ('pre','gray','online','all') AND ${其他条件}
2.5 错误原因
经过排查是因为 API 里面有多处对 threadLoal 进行处理的逻辑,方法之间存在调用。 简化举例: A 和 B 方法都是独立的方法, A 在调用 B 的过程,B 结束时把上下文环境字段删除, A 在获取时得到 null。具体如下:
2.6 五味杂陈
当我看到代码的一瞬间,彻底破防了......
queryProject 方法里面调用 findProjectWithOutEnv,
在两个方法中,都有填充处理 env 的代码。
2.7 遍地开花
然而,这三行代码,随处可见,在业务代码中遍地开花.......
// 1. 变量保存 oriFilterEnv
String oriFilterEnv = UserHolder.getUser().getFilterEnv();
// 2. 设置值到应用上下文
UserHolder.getUser().setFilterEnv(globalConfigDTO.getAllEnv());
//....... 业务代码 ....
// 3. 结束复原
UserHolder.getUser().setFilterEnv(oriFilterEnv);
改了个遍,很勤劳👍......
2.8 灵魂开问
难道真的就只能这么做吗,当然还有......
- 开闭原则符合了吗
- 改漏了应该办呢
- 其他人遇到跳过的检查的场景也加这样的代码吗
- 业务代码和功能代码分离了吗
- 填充到应用上下文对象 user 合适吗
- .......
大量魔法值,单行字符超500,方法长度拖几个屏幕也都睁一眼闭一只眼了,但整这一出,还是破防......
内心涌动😥,我觉得要重构一下。
📒三、重构一下
3.1 困难之处
在 mybatis intercept 中不能直接精准地获取到 service 层的接口调用。 只能通过栈帧查询到调用链。
3.2 问题列表
- 尽量不要修改已有方法,保证不影响原有逻辑;
- 尽量不要在业务方法中修改功能代码;关注点分离;
- 尽量最小改动,修改一处即可实现逻辑;
- 改造后复用能力,而不是依葫芦画瓢地添加这种代码
3.3 实现分析
- 用独立的 ThreadLocal,不与当前用户信息上下文混合使用
- 注解+APO,通过注解参数解析,达到目标功能
- 对于方法之间的调用或者循环调用,要考虑优化
同一份代码,在多个环境运行,不管如何,一定要考虑线上数据安全性。
3.4 使用案例
改造后的使用案例如下,案例说明:project 表在预发环境校验跳过。
@InvokeChainSkipEnvRule(skipEnvList = {"pre"}, skipTableList = {"project"})
@SneakyThrows
@GetMapping("/importSignedUserData")
@InvokeChainSkipEnvRule(skipEnvList = {"pre"}, skipTableList = {"project"})
public void importSignedUserData(
......
HttpServletRequest request,
HttpServletResponse response) {
......
}
在使用的调用入口处添加注解。
3.5 具体实现
- 方法上标记注解, 注解参数定义规则
- 切面读取方法上面的注解规则,并传递到应用上下文
- 拦截器从应用上下文读取规则进行规则判断
注解代码
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface InvokeChainSkipEnvRule {
/**
* 是否跳过环境。 默认 true,不推荐设置 false
*
* @return
*/
boolean isKip() default true;
/**
* 赋值则判断规则,否则不判断
*
* @return
*/
String[] skipEnvList() default {};
/**
* 赋值则判断规则,否则不判断
*
* @return
*/
String[] skipTableList() default {};
}
3.6 不足之处
- 整个链路上的这个表操作都会跳过,颗粒度还是比较粗
- 注解只能在入口处使用,公共方法调用尽量避免
🤔那还要不要完善一下,还有什么没有考虑到的点呢? 拿起手机看到快12点的那一刻,我还是选择先回家了......
📝 四、总结思考
4.1 隔离总结
这是一个很好参考案例:在应用中既做了数据隔离,也做了数据共享。通过自定义拦截器做数据隔离,通过自定注解切面实现数据共享。
4.2 编码总结
同样的代码写两次就应该考虑重构了
- 尽量修改一个地方,不要写这种边边角角的代码
- 善用自定义注解,解决这种通用逻辑
- 可以妥协,但是要有底线
- ......
4.3 场景总结
简单梳理,自定义注解 + AOP 的场景
场景 | 详细描述 |
---|---|
分布式锁 | 通过添加自定义注解,让调用方法实现分布式锁 |
合规参数校验 | 结合 ognl 表达式,对特定的合规性入参校验校验 |
接口数据权限 | 对不同的接口,做不一样的权限校验,以及不同的人员身份有不同的校验逻辑 |
路由策略 | 通过不同的注解,转发到不同的 handler |
...... |
自定义注解很灵活,应用场景广泛,可以多多挖掘。
4.4 反思总结
- 如果一开始就做好技术方案或者直接使用不同的数据库
- 是否可以拒绝那个所谓的需求
- 先有设计再有编码,别瞎搞
4.5 最后感想
在这个只讲业务结果,不讲技术氛围的环境里,突然有一些伤感;身体已经开始吃不消了,好像也过了那个对技术较真死抠的年纪; 突然一想,这么做的意义又有多大呢?
来源:juejin.cn/post/7294844864020430902
ArrayList扩容原理
ArrayList扩容原理(源码理解)
从源码角度对ArrayList扩容原理进行简介,我们可以更深入地了解其内部实现和工作原理。以下是基于Java标准库中ArrayList扩容原理源码的简介
1、类定义与继承关系
ArrayList在Java中的定义如下:
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{
ArrayList是一个泛型类,继承自AbstractList并实现了List接口,同时还实现了RandomAccess、Cloneable和Serializable接口。这 些接口分别表示ArrayList支持随机访问、可以被克隆以及可以被序列化。
2、核心成员变量(牢记)
elementData
:是实际存储元素的数组,它可以是默认大小的空数组(当没有指定初始容量且没有添加元素 时),也可以是用户指定的初始容量大小的数组,或者是在扩容后新分配的数组。
size
:表示数组中当前元素的个数。
transient Object[] elementData; //数组
private int size; //元素个数
DEFAULT_CAPACITY
是ArrayList的默认容量,当没有指定初始容量时,会使用这个值。
//默认初始容量。
private static final int DEFAULT_CAPACITY = 10;
EMPTY_ELEMENTDATA
表示空数组。
DEFAULTCAPACITY_EMPTY_ELEMENTDATA
也表示空数组,为了区分而命名不同。
//用于创建空对象的共享空数组实例。
private static final Object[] EMPTY_ELEMENTDATA = {};
//用于默认大小的空数组实例的共享空数组实例。我们将它与EMPTY_ELEMENTDATA区分开来,以便在添加第一个元素时知道要扩容多少。
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
3、构造方法
ArrayList提供了多个构造方法,包括无参构造方法、指定初始容量的构造方法。
java
//无参构造
//构造一个初始容量为10的空数组。
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
//有参构造
//构造具有指定初始容量的空数组。
public ArrayList(int initialCapacity) { //构建有参构造方法
if (initialCapacity > 0) { //如果传入参数>0
this.elementData = new Object[initialCapacity]; //创建一个数组,大小为传入的参数
} else if (initialCapacity == 0) { //如果传入的参数=0
this.elementData = EMPTY_ELEMENTDATA; //得到一个空数组
} else { //否则
throw new IllegalArgumentException("Illegal Capacity: "+ //抛出异常
initialCapacity);
}
}
这里可以看到无参构造方法用的是DEFAULTCAPACITY_EMPTY_ELEMENTDATA
表示空数组,而有参构造方法中当传入参数=0,用的是EMPTY_ELEMENTDATA
表示空数组。
4、扩容机制
具体流程:
1、开始添加元素前先判断当前数组容量是否足够(ensureCapacityInternal()
方法),这里有个特例就是添加第一个元素时要先将数组扩容为初始容量大小(calculateCapacity()
方法)。如果足够就向数组中添加元素。
2、如果当前数组容量不够,开始计算新容量的大小并赋值给新数组,复制原始数组中的元素到新数组中(grow()
方法)
流程图如下:
从向ArrayList添加元素来观察底层源码是如何实现的
观察add()
方法,其中提到一个不认识的ensureCapacityInternal()
方法,把他看做用来判断数组容量是否足够的方法,判断完后将元素添加到数组中
public boolean add(E e) {
ensureCapacityInternal(size + 1); //判断数组容量是否足够,传入的一个大小为(size+1)的参数
elementData[size++] = e; //添加元素
return true;
}
现在来看上面add()
方法提到的ensureCapacityInternal()
方法, 进入查看源码,又出现两个不认识的方法: calculateCapacity()
方法和ensureExplicitCapacity()
方法。
private void ensureCapacityInternal(int minCapacity) { //这里minCapacity大小就是上面传入参数:size+1
ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
}
```
calculateCapacity()
方法:里面有一个判断语句,判断当前数组是不是空数组。如果是空数组那就将数组容量初始化为10,如果不是空数组,那就直接返回minCapacity
。
ensureExplicitCapacity()
方法:重点观察判断语句,将calculateCapacity()
方法中传进来的minCapacity
与原数组的长度作比较,当原数组长度小于minCapacity
的值就开始进行扩容。
// calculateCapacity方法
private static int calculateCapacity(Object[] elementData, int minCapacity) {
//判断数组是否为空
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
//数组为空时比较,DEFAULT_CAPACITY=10,minCapacity=size+1,DEFAULT_CAPACITY一定比minCapacity大,所以空数组容量初始化为10
return Math.max(DEFAULT_CAPACITY, minCapacity);
}
//数组不为空,minCapacity=size+1,相当于不变
return minCapacity;
}
//-------------------------------------------分割线-----------------------------------------------------//
// ensureExplicitCapacity方法
private void ensureExplicitCapacity(int minCapacity) {//这里的minCapacity是上面传过来的
modCount++;
if (minCapacity - elementData.length > 0) //判断数组长度够不够,不够才扩
grow(minCapacity);
}
举例
- 当向数组添加第1个元素时size=0,
calculateCapacity()
方法中判断数组为空,数组容量初始化为10。到了ensureExplicitCapacity()
方法中,因为是空数组,所以elementData.length
=0,判断成立,数组进行扩容大小为10。 - 当向数组添加第2个元素时size=1,
calculateCapacity()
方法中判断数组为非空,为minCapacity赋值为2。到了ensureExplicitCapacity()
方法中,因为数组大小已经扩容为10,所以elementData.length
=10,判断不成立,不扩容 - 当向数组添加第11个元素时size=10,
calculateCapacity()
方法中判断数组为非空,为minCapacity赋值为11。到了ensureExplicitCapacity()
方法中,因为数组大小已经扩容为10,所以elementData.length
=10,判断成立,开始扩容
前面都是判断数组要不要进行扩容,下面内容就是如何扩容。
首先,grow()
方法是扩容的入口,它根据当前容量计算新容量,并调用Arrays.copyOf方法复制数组。hugeCapacity()
方法用于处理超大容量的情况,确保不会超出数组的最大限制。
* 这一步是为了先确定扩容的大小,再将元素复制到新数组中
private void grow(int minCapacity) {
int oldCapacity = elementData.length; //定义一个oldCapacity接收当前数组长度
int newCapacity = oldCapacity + (oldCapacity >> 1); //定义一个newCapacity接收oldCapacity1.5倍的长度
if (newCapacity - minCapacity < 0) //如果newCapacity长度<minCapacity
newCapacity = minCapacity; //将minCapacity赋值给newCapacity
if (newCapacity - MAX_ARRAY_SIZE > 0) //如果newCapacity长度>最大的数组长度
newCapacity = hugeCapacity(minCapacity); //将进行hugeCapacity方法以后的值赋值给newCapacity
elementData = Arrays.copyOf(elementData, newCapacity);//开始扩容
}
查看hugeCapacity()
方法 (防止扩容后的数组太大了)
MAX_ARRAY_SIZE 理解为:快接近integer的最大值了。
Integer.MAX_VALUE 理解为:integer的最大值。
private static int hugeCapacity(int minCapacity) {
if (minCapacity < 0) //如果minCapacity<0
throw new OutOfMemoryError(); //抛出异常
return (minCapacity > MAX_ARRAY_SIZE) ? //返回一个值,判断minCapacity是否大于MAX_ARRAY_SIZE
Integer.MAX_VALUE : //大于就返回 Integer.MAX_VALUE
MAX_ARRAY_SIZE; //小于就返回 MAX_ARRAY_SIZE
}
```
最后一步,了解是如何如何将元素添加到新数组的
查看Arrays.copyof
源代码
用于将一个原始数组(original)复制到一个新的数组中,新数组的长度(newLength)可以与原始数组不同。
public static <T> T[] copyOf(T[] original, int newLength) {
return (T[]) copyOf(original, newLength, original.getClass());
}
查看copyof()
方法 (判断新数组与原数组类型是否一致)
public static <T,U> T[] copyOf(U[] original, int newLength, Class<? extends T[]> newType) {
@SuppressWarnings("unchecked")
T[] copy = ((Object)newType == (Object)Object[].class)
? (T[]) new Object[newLength]
: (T[]) Array.newInstance(newType.getComponentType(), newLength);
System.arraycopy(original, 0, copy, 0,
Math.min(original.length, newLength));
return copy;
}
开始复制原数组的元素到新数组中
将一个数组`src`的从索引`srcPos`开始的`length`个元素复制到另一个数组`dest`的从索引`destPos`开始的位置。
public static native void arraycopy(Object src, int srcPos,
Object dest, int destPos,
int length);
参数说明:
参数说明:
src
:原数组,类型为Object,表示可以接受任何类型的数组。srcPos
:原数组的起始索引,即从哪个位置开始复制元素。dest
:新数组,类型为Object,表示可以接受任何类型的数组。destPos
:新数组的起始索引,即从哪个位置开始粘贴元素。length
:要复制的元素数量。
从宏观上来说,ArrayList展现的是一种动态数组的扩容,当数组中元素个数到达一定值时数组自动会扩大容量,以方便元素的存放。
从微观上来说,ArrayList是在当数组中元素到达一定值时,去创建一个大小为原数组1.5倍容量的新数组,将原数组的元素复制到新数组当中,抛弃原数组。
来源:juejin.cn/post/7426280686695710730
面试官:count(1) 和 count(*)哪个性能更好?
在数据库查询中,count(*)
和 count(1)
是两个常见的计数表达式,都可以用来计算表中行数。
很多人都以为 count(*)
效率更差,主要是因为在早期的数据库系统中,count(*)
可能会被实现为对所有列进行扫描,而 count(1)
则可能只扫描单个列。
但事实真是如此吗?
执行原理
先来看看这两者的执行原理:
count(*)
查询所有满足条件的行,包括包含空值的行。在大多数数据库中,count(*)
会直接统计行数,并不会实际去读取每行中的详细数据,因为数据库引擎会自行优化该计数操作,以提高执行效率。
count(1)
也是计算表中的行数,这里的 1 是一个常量,只是作为一个占位符,并没有实际的含义。与 count(*)
类似,数据库引擎也会对 count(1)
进行优化,以快速确定表中的行数。
count(*) 和 count(1) 的 性能差异
再说性能,在大多数数据库中,其实 count(*)
和 count(1)
的性能非常相似,甚至可以说没有区别,这是因为大多数数据库引擎对这两种计数方式进行相同的优化,并没有明显的执行效率上的差异。但是在特殊情况下可能会有细微的差异,造成这种差异的原因通常有以下几种:
1. 数据库引擎的差异
不同的数据库引擎可能对 count(*)
和 count(1)
采取不同的优化策略,这在某些情况下可能会导致两种计数方式的性能差异。例如:
- SQL Server:在某些版本的 SQL Server 中,
count(1)
在特定的查询计划中可能稍微快一些,但这种差异通常微乎其微,只有在处理非常大的表或复杂查询时才会显现出来。 - MyISAM 引擎:在不附加任何
WHERE
查询条件的情况下,统计表的总行数会非常快,因为 MyISAM 会用一个变量存储表的行数。如果没有WHERE
条件,查询语句将直接返回该变量值,使得速度很快。然而,只有当表的第一列定义为NOT NULL
时,count(1)
才能得到类似的优化。如果有WHERE
条件,则该优化将不再适用。 - InnoDB 引擎:尽管 InnoDB 表也存储了一个记录行数的变量,但遗憾的是,这个值只是一个估计值,并无实际意义。在 Innodb 引擎下,
count(*)
和count(1)
哪个快呢?结论是:这俩在高版本的 MySQL 是没有什么区别的,也就没有count(1)
会比count(*)
更快这一说了。
另外,还有一个问题是 Innodb 是通过主键索引来统计行数的吗?
如果该表只有一个主键索引,没有任何二级索引的情况下,那么 count(*)
和 count(1)
都是通过通过主键索引来统计行数的。
如果该表有二级索引,则 count(*)
和 count(1)
都会通过占用空间最小的字段的二级索引进行统计。
2. 索引的影响
如果表上有合适的索引,无论是count(1)
还是 count(*)
都可以利用索引来快速确定行数,而不必扫描整个表。在这种情况下,两者的性能差异通常可以忽略不计。例如,如果有一个基于主键的索引,数据库可以快速通过索引确定表中的行数,而无需读取表中的每一行数据。
实战分析
话不多说,下面我们通过实验来验证上述理论:
第一步:创建表与插入数据
用 Chat2DB 给我们生成一个创建表的 sql 语句,直接用自然语言描述我们想要的字段名和字段类型即可生成建表语句,也可以生成测试数据。
然后用存储过程向 student 表中插入两万条测试数据。(存储过程执行两次)
插入数据后的 student 表如下:
这个时候执行 select count(*) from student
和 select count(1) from student
可以看到解释器的结果如下,耗时均为 2 ms(两者一致,所以就只截了一张图),两者都用主键索引进行行数的统计:
第二步:执行计数查询
创建二级索引 IDCard 进行统计结果如下:
可以看出用二级索引进行统计的解释器结果还是一致。
结论
综上所述,count(1)
和 count(*)
的性能基本相同,并不存在 COUNT(1)
比 COUNT(*)
更快的说法。总体而言,在大多数情况下,两者之间的性能差异是可以忽略不计的。
在选择使用哪种方式时,应当优先考虑代码的可读性和可维护性。count(*)
在语义上更为明确,表示计算所有行的数量,而不依赖于任何特定的值。因此,从代码清晰度的角度出发,通常建议优先使用 count(*)
。
当然,如果在特定的数据库环境中,经过实际测试发现 count(1)
具有明显的性能优势,那么也可以选择使用 count(1)
。但在一般情况下,不必过分纠结于这两种计数方式之间的性能差异。
希望本文能帮助你在使用计数操作时作出更为合理的选择。
Chat2DB 文档:docs.chat2db.ai/zh-CN/docs/…
Chat2DB 官网:chat2db.ai/zh-CN
Chat2DB GitHub:github.com/codePhiliaX…
来源:juejin.cn/post/7417521775587065907
【后端性能优化】接口耗时下降60%,CPU负载降低30%
大家好,我是五阳。
GC 话题始终霸占面试必问排行榜,很多人对 GC 原理了然于胸,但是苦于没有实践经验,因此本篇文章将分享我的GC 优化实践。一个很小的优化,产生了非常好的效果。
现在五阳将优化过程给大家汇报一下。
一、背景
我所负责的 A 服务每天的凌晨会定时执行一个批量任务,每天执行时都会触发 GC 频率告警,偶尔单机 CPU 负载超过 60%时,会触发 CPU 高负载告警。
曾经有考虑过通过单机限流器,限制任务执行速率,从而降低机器负载。然而因为业务上希望定时任务尽快执行完,所以优化方向就放在了如何降低 CPU 负载,如何降低 GC 频率。
1.1 配置和负载
- 版本:java8
- GC 回收器:ParNew + CMS
- 硬件:8 核 16G 内存,Centos6.8
- 高峰期CPU 平均负载(分钟)超过 50%(每个公司计算口径可能不同。我司的历史经验超过 70%后,接口性能将会快速恶化)
1.2 优化前的 GC情况
不容乐观。
- 高峰期 Young GC频率 70次/min,单次 ygc 平均时间 125ms;
- 高峰期 Full GC频率 每 3 分钟 1 次;单次 fgc 平均时间 610ms。
1.3 GC 参数和 JVM 配置
参数配置 | 说明 |
---|---|
-Xmx6g -Xms6g | 堆内存大小为6G |
-XX:NewRatio=4 | 老年代的大小是新生代的 4 倍,即老年代占4.8G,新生代占1.2G |
-XX:SurvivorRatio=8 | Eden:From:To= 8:1:1,即Eden区占0.96G,两个Survivor区分别占0.12G |
-XX:ParallelCMSThreads=4 | 设置 CMS 垃圾回收器使用的并行线程数为 4 |
XX:CMSInitiatingOccupancyFraction=72 | 设置老年代使用率达到 72% 时触发 CMS 垃圾回收。 |
-XX:+UseParNewGC | 启用 ParNew 作为年轻代垃圾回收器 |
-XX:+UseConcMarkSweepGC | 启用 CMS 垃圾回收器 |
二、问题分析
2.1 增加 GC打印参数
由于打印GC信息不足,无法分析问题。因此添加了 以下GC 打印参数,以提供更多的信息
-XX:+PrintGCDetails
-XX:+PrintGCTimeStamps
-XX:+PrintCommandLineFlags
-XX:+PrintGCDateStamps
-XX:+PrintHeapAtGC
-XX:+PrintTenuringDistribution
-XX:+PrintGCApplicationStoppedTime
-XX:+PrintReferenceGC
2.2 提前晋升现象
配置如上参数后,每次发生 younggc后,都会打印详细的 younggc 日志。通过分析 gc 日志,我发现日志中经常出现类似内容。
Desired survivor size 61054720 bytes, new threshold 2 (max 15)
new threshold是新的晋升阈值,是指对象在新生代经过 new threshold
轮 younggc后,就能晋升到老年代,这个值通过 MaxTenuringThreshold配置,默认值是 15,在原有理解中阈值是固定值 15,实际上这个值会动态调整。
为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了 MaxTenuringThreshold 才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄
Desired survivor 一般是 Survivor 区的一半。假设年龄 1至N 的对象大小,超过了 Desired size,那么下一次 GC 的晋升阈值就会调整为 N。举个例子,假设 age=1的对象为 80M,超过了 61M,那么下一次GC 的晋升阈值就是 1,所有超过 1 的对象都会晋升到老年代,无需等到年龄到 15。
如何分析 younggc 日志,可以参考我的另一篇文章。2024面试必问:系统频繁Full GC,你有哪些优化思路?第一步分析gc日志
2.3 老年代增长速度过快
为了印证是否发生提前晋升,我通过监控查看到在事发时间,老年代内存的涨幅和 Survivor的内存基本一致,看来新生代的对象确实提前晋升到老年代了。
grep 分析历次 GC 后的晋升阈值后,我发现绝大部分情况下,新生的对象无法在 15 次 GC后进入到老年代,基本上三次以后就会提前晋升到老年代…… 这解释了为什么会发生频繁的 FullGC。
假设每次提前晋升 100M 到老年代,每分钟超过 15 次 ygc,则每分钟将会有 1.5G 对象进入老年代。
因为频繁地提前晋升,老年代的增长速度极快。 在高峰期时,往往 2 至 3 分钟左右,老年代内存就会触达 72% 的阈值,从而发生 FullGC。
2.4 新生代内存不足
即便老年代配置 4.8G 的大内存,但频繁地发生提前晋升,老年代也很快被打满。这背后的根本原因在于 新生代的内存太小了。 新生代,总共 1.2G 大小,Survivor才 120M,这远远不够。
于是我们调整了内存分配。调整后如下
- -Xmx10g -Xms10g -Xmn6g
- -XX:SurvivorRatio=8
- 堆内存由 6G 增加到 10G
- 大部分堆内存(6G)分配给新生代。新生代内存从 1.2G 增加到 6G。
- Eden:From:To 的比例依然是 8:1:1
- Eden大小从 0.96 G 增加到 4.8 G。
- Survivor区由 120 M 增加到 600 M。
三、优化效果
虽然改动不大,但是优化效果十分显著。由于公司监控有水印,我无法截图取证,敬请谅解。
3.1 GC频率明显下降
- 高峰期 ygc 70 次/min 降到了 12 次/min,下降幅度达83%(单机 500 QPS)
- 高峰期 fgc 三分钟1 次,降到了 每天 1 次 Full GC。
- younggc 和 fullgc 单次平均耗时保持不变。
3.2 CPU 负载降低 30%+
- 优化之前高峰期 cpu 平均负载超过 50%;优化后降到了不足 30%,高峰期负载下降了 40%。
- CPU负载每日平均值 由 29%,降到了 20%。日平均负载下降了 32%。
3.3 核心接口性能显著提升
核心接口耗时下降明显
- 接口 A 高峰期 TPS 100/秒,tp999 由 200毫秒 降到了 150 毫秒, tp9999 由 400 毫秒降到了 300 毫秒,接口耗时下降超过 25%!
- 接口 B 高峰期QPS 250/秒, tp999 由 190 毫秒降到了 120 毫秒, tp9999 由 450 毫秒下降到了 150 毫秒,接口耗时下降分别下降 37%和 67%!
- 接口 B 低峰期降幅更加明显,tp999 由 80 毫秒降到了 10 毫秒,下降幅度接近 90%!
后来又适当微调了 JVM 内存分配比例,但是优化效果不明显。
四、总结
经过此次 GC 优化经历,我学到了如下经验
- 要通过 GC 日志分析 GC 问题。
- 调整JVM 内存,保证足够的新生代内存。
- 优化 GC 可以降低接口耗时,提高接口可用性。
- 优化 GC 可以有效降低机器 CPU 负载,提高硬件使用率。
反过来当接口性能差、cpu负载高的时候,不妨分析一下 GC ,看看有没有优化空间。
详细了解如何分析 younggc 日志,可以参考我的另一篇文章。2024面试必问:系统频繁Full GC,你有哪些优化思路?第一步分析gc日志
关注五阳~ 了解更多我在大厂的实际经历
来源:juejin.cn/post/7423066953038741542
简易聊天机器人设计
1. 引言
Spring AI Alibaba 开源项目基于 Spring AI 构建,是阿里云通义系列模型及服务在 Java AI 应用开发领域的最佳实践,提供高层次的 AI API 抽象与云原生基础设施集成方案,帮助开发者快速构建 AI 应用。
2. 效果展示
源代码地址 DailySmileStart/simple-chatboot (gitee.com)
3. 代码实现
依赖
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-starter</artifactId>
<version>1.0.0-M2</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
注意:由于 spring-ai 相关依赖包还没有发布到中央仓库,如出现 spring-ai-core 等相关依赖解析问题,请在您项目的 pom.xml 依赖中加入如下仓库配置。
<repositories>
<repository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url><https://repo.spring.io/milestone></url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
</repositories>
@SpringBootApplication
public class SimpleChatbootApplication {
public static void main(String[] args) {
SpringApplication.run(SimpleChatbootApplication.class, args);
}
}
配置自定义ChatClient
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.InMemoryChatMemory;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ChatClientConfig {
static ChatMemory chatMemory = new InMemoryChatMemory();
@Bean
public ChatClient chatClient(ChatModel chatModel) {
return ChatClient.builder(chatModel)
.defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory))
.build();
}
}
controller类
import ch.qos.logback.core.util.StringUtil;
import com.hbduck.simplechatboot.demos.function.WeatherService;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
import java.util.UUID;
import static org.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY;
import static org.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor.CHAT_MEMORY_RETRIEVE_SIZE_KEY;
@RestController
@RequestMapping("/ai")
public class ChatModelController {
private final ChatModel chatModel;
private final ChatClient chatClient;
public ChatModelController(ChatModel chatModel, ChatClient chatClient) {
this.chatClient = chatClient;
this.chatModel = chatModel;
}
@GetMapping("/stream")
public String stream(String input) {
StringBuilder res = new StringBuilder();
Flux<ChatResponse> stream = chatModel.stream(new Prompt(input));
stream.toStream().toList().forEach(resp -> {
res.append(resp.getResult().getOutput().getContent());
});
return res.toString();
}
@GetMapping(value = "/memory", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> memory(@RequestParam("conversantId") String conversantId, @RequestParam("input") String input) {
if (StringUtil.isNullOrEmpty(conversantId)) {
conversantId = UUID.randomUUID().toString();
}
String finalConversantId = conversantId;
Flux<ChatResponse> chatResponseFlux = chatClient
.prompt()
.function("getWeather", "根据城市查询天气", new WeatherService())
.user(input)
.advisors(spec -> spec.param(CHAT_MEMORY_CONVERSATION_ID_KEY, finalConversantId)
.param(CHAT_MEMORY_RETRIEVE_SIZE_KEY, 10))
.stream().chatResponse();
return Flux.concat(
// First event: send conversationId
Flux.just(ServerSentEvent.<String>builder()
.event("conversationId")
.data(finalConversantId)
.build()),
// Subsequent events: send message content
chatResponseFlux.map(response -> ServerSentEvent.<String>builder()
.id(UUID.randomUUID().toString())
.event("message")
.data(response.getResult().getOutput().getContent())
.build())
);
}
}
配置文件
server:
port: 8000
spring:
thymeleaf:
cache: true
check-template: true
check-template-location: true
content-type: text/html
enabled: true
encoding: UTF-8
excluded-view-names: ''
mode: HTML5
prefix: classpath:/templates/
suffix: .html
ai:
dashscope:
api-key: ${AI_DASHSCOPE_API_KEY}
chat:
client:
enabled: false
前端页面
<!DOCTYPE html>
<html>
<head>
<title>AI Chat Bot</title>
<style>
#chatBox {
height: 400px;
border: 1px solid #ccc;
overflow-y: auto;
margin-bottom: 10px;
padding: 10px;
}
.message {
margin: 5px;
padding: 5px;
}
.user-message {
background-color: #e3f2fd;
text-align: right;
}
.bot-message {
background-color: #f5f5f5;
white-space: pre-wrap; /* 保留换行和空格 */
word-wrap: break-word; /* 长单词换行 */
}
</style>
</head>
<body>
<h1>AI Chat Bot</h1>
<div id="chatBox"></div>
<input type="text" id="userInput" placeholder="Type your message..." style="width: 80%">
<button onclick="sendMessage()">Send</button>
<script>
let conversationId = null;
let currentMessageDiv = null;
function addMessage(message, isUser) {
const chatBox = document.getElementById('chatBox');
const messageDiv = document.createElement('div');
messageDiv.className = `message ${isUser ? 'user-message' : 'bot-message'}`;
messageDiv.textContent = message;
chatBox.appendChild(messageDiv);
chatBox.scrollTop = chatBox.scrollHeight;
return messageDiv;
}
async function sendMessage() {
const input = document.getElementById('userInput');
const message = input.value.trim();
if (message) {
addMessage(message, true);
input.value = '';
// Create bot message container
currentMessageDiv = addMessage('', false);
const eventSource = new EventSource(`/ai/memory?conversantId=${conversationId || ''}&input=${encodeURIComponent(message)}`);
eventSource.onmessage = function(event) {
const content = event.data;
if (currentMessageDiv) {
currentMessageDiv.textContent += content;
}
};
eventSource.addEventListener('conversationId', function(event) {
if (!conversationId) {
conversationId = event.data;
}
});
eventSource.onerror = function(error) {
console.error('SSE Error:', error);
eventSource.close();
if (currentMessageDiv && currentMessageDiv.textContent === '') {
currentMessageDiv.textContent = 'Sorry, something went wrong!';
}
};
// Close the connection when the response is complete
eventSource.addEventListener('complete', function(event) {
eventSource.close();
currentMessageDiv = null;
});
}
}
// Allow sending message with Enter key
document.getElementById('userInput').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
sendMessage();
}
});
</script>
</body>
</html>
带chat memory 的对话
可以使用 InMemoryChatMemory实现
//初始化InMemoryChatMemory
static ChatMemory chatMemory = new InMemoryChatMemory();
//在ChatClient 配置memory
@Bean
public ChatClient chatClient(ChatModel chatModel) {
return ChatClient.builder(chatModel)
.defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory))
.build();
}
//调用时配置
Flux<ChatResponse> chatResponseFlux = chatClient
.prompt()
.function("getWeather", "根据城市查询天气", new WeatherService())
.user(input)
.advisors(spec -> spec.param(CHAT_MEMORY_CONVERSATION_ID_KEY, finalConversantId)
.param(CHAT_MEMORY_RETRIEVE_SIZE_KEY, 10))
.stream().chatResponse();
工具
“工具(Tool)”或“功能调用(Function Calling)”允许大型语言模型(LLM)在必要时调用一个或多个可用的工具,这些工具通常由开发者定义。工具可以是任何东西:网页搜索、对外部 API 的调用,或特定代码的执行等。LLM 本身不能实际调用工具;相反,它们会在响应中表达调用特定工具的意图(而不是以纯文本回应)。然后,我们应用程序应该执行这个工具,并报告工具执行的结果给模型。
通过工具来实现获取当前天气
天气获取的类,目前使用硬编码温度
import com.fasterxml.jackson.annotation.JsonClassDescription;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import com.hbduck.simplechatboot.demos.entity.Response;
import java.util.function.Function;
public class WeatherService implements Function<WeatherService.Request, Response> {
@Override
public Response apply(Request request) {
if (request.city().contains("杭州")) {
return new Response(String.format("%s%s晴转多云, 气温32摄氏度。", request.date(), request.city()));
}
else if (request.city().contains("上海")) {
return new Response(String.format("%s%s多云转阴, 气温31摄氏度。", request.date(), request.city()));
}
else {
return new Response(String.format("%s%s多云转阴, 气温31摄氏度。", request.date(), request.city()));
}
}
@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonClassDescription("根据日期和城市查询天气")
public record Request(
@JsonProperty(required = true, value = "city") @JsonPropertyDescription("城市, 比如杭州") String city,
@JsonProperty(required = true, value = "date") @JsonPropertyDescription("日期, 比如2024-08-22") String date) {
}
}
chatClient配置function
Flux<ChatResponse> chatResponseFlux = chatClient
.prompt()
.function("getWeather", "根据城市查询天气", new WeatherService())
.user(input)
.advisors(spec -> spec.param(CHAT_MEMORY_CONVERSATION_ID_KEY, finalConversantId)
.param(CHAT_MEMORY_RETRIEVE_SIZE_KEY, 10))
.stream().chatResponse();
来源:juejin.cn/post/7436369701020516363
即梦AI推出“一句话改图”功能,助力用户发掘更多创意
近日,字节跳动旗下AI内容平台即梦AI上线了指令编辑功能,用户使用即梦AI的“图片生成”功能时,在上传导入参考图片后,选择“智能参考”,在文本框中输入想要如何调整图片的描述,就可以轻松对图片进行编辑。目前,该功能在即梦AI网页版和App移动端均可免费体验。
图说:即梦AI网页版指令编辑功能使用示意
据介绍,即梦AI的指令编辑功能支持包括修图、换装、美化、转化风格、在指定区域添加删除元素等各类编辑操作,通过简单的自然语言即可编辑图像,大幅降低了用户操作成本,有助于用户发掘和实现更多创意。
图说:即梦AI指令编辑功能创意玩法
据介绍,即梦AI的指令编辑功能由字节最新通用图像编辑模型SeedEdit支持。SeedEdit是国内首个实现产品化的通用图像编辑模型。过往,学术界在文生图和图生图领域已有较多研究,但做好生成图片的指令编辑一直是难题,二次修改很难保证稳定性和生成质量。今年以来,Dalle3、Midjourney接连推出产品化的生图编辑功能,相较业界此前方案,编辑生成图片的质量大大改善,但仍缺乏对用户编辑指令的精准响应和原图信息保持能力。SeedEdit在通用性、可控性、高质量等方面取得了一定突破,适用各类编辑任务,支持用户脑洞大开的奇思妙想,无需再训练微调即可快捷应用。
即梦AI支持通过自然语言及图片输入,生成高质量的图像及视频。平台提供智能画布、故事创作模式,以及首尾帧、对口型、运镜控制、速度控制等AI编辑能力,并有海量影像灵感及兴趣社区,一站式提供用户创意灵感、流畅工作流、社区交互等资源,为用户的创作提效。近期,即梦AI还面向用户开放了字节视频生成模型Seaweed的使用。
该平台相关负责人表示,AI能够和创作者深度互动,共同创作,带来很多惊喜和启发,即梦AI希望成为用户最亲密和有智慧的创作伙伴。(作者:李双)
收起阅读 »终于知道公钥、私钥、对称、非对称加密是什么了
有接入过第三方服务的同学应该都接触过公钥、私钥这类的说明,尤其是一些对参数要求验证的服务,比如支付类的。
通常对于高保密要求的参数都会有加密要求,这时候,如果你之前不了解加密算法,就很容易被公钥、私钥的使用给绕迷糊了。有时候虽然接口都调通了,但是还是一头雾水,这就通了?
那接下来我们就来解开这团迷雾。咱们只讲逻辑和一部分逻辑,不讲数学算法,因为数学这块儿我也不是很懂。
加密算法在大类上分为对称机密和非对称加密,都用来加密,只不过使用场景、性能、安全性方面有些不同。
首先说什么是加密呢?
加密就是通过一种方式将一个显而易见的东西变成难以理解的东西。
比如这儿有个字符串“我真棒”,这三个字儿要摆在你面前不是一眼就知道我很棒了吗。
但是我要给你看这样一串东西呢?你还能一下子知道是什么意思吗?
232 10 5,33 50 12,109 45 1
其实这还是那三个字,只不过是经过加密的密文,只有知道了加密的方法才能还原出来,也就是解密。
加密的过程可能是这样的:
- 首先买一本叫做《人间清醒》的书;
- 然后按照逗号分隔,每一个逗号分隔开的表示一个字;
- 然后每一组是三个数字,第一个数字表示所在页,第二个数字表示所在行,第三个数字表示所在列(第几个字),所以
232 10 5
,表示第232页,第10行,第5个字。
还有,比如我之前有个温州的同事,他打电话的时候对于我们来说就是语音加密了,能听见他的声音,但是说的是什么内容一个字也听不出来。这也可以理解为一种加密,把普通话的发音转换成温州方言的发音。
对称加密
对称加密中所说的对称是对加密和解密而言的,也就是加密和解密的过程使用相同的密钥。
我们经常用到的落库加密、文件加密都可以使用对称加密的方式。
目前最常用也是最安全的对称加密算法是 AES,AES 还分为 AES-128、AES-192和AES256,后面的数字代表加密密钥的位数,位数越高呢,加密效果也就越好,更加不容易被破解。同时,位数越高,加密和解密过程中的计算量也会越大,内存占用也就更大,消耗的资源更多,需要的时间也就更长。
有利有弊,看你的需求而定。基本上,一般场景下 128位就足够安全了。AES 到目前为止,可以说没有漏洞,而且128位就可以保证不会被暴力破解。而更高位数的可能会用到国家级的保密数据上。
AES 是分组加密算法,除此之外,大部分的加密算法都是分组加密算法。
块加密算法就是将需要加密的数据分成一个个的固定长度的分组,比如 128位一组,然后分别用算法对每一组进行加密,如果最后一组不足128位的话,还要用填充算法进行填充,保证达到128位。
常用的分组算法有CTR和GCM,CTR 和 GCM 有并行计算的能力,并且, GCM 还能额外提供对消息完整性、真实性的验证能力。
所以我们在某些地方可能看到 AES-128-GCM、AES-256-CTR 这样的写法,前面表示加密算法,后面代表分组算法。
不足之处
对称加密本身从算法层面来说已经足够安全了,但是在密钥分发方面有些不太容易管理。
因为加解密的密钥相同。我加密的数据想要被别人使用,我就要把密钥告诉要使用的人。知道密钥的人、保存密钥的服务器越多,风险就越大。约束自己容易,约束别人难啊。但凡有一方不小心把密钥泄露就完。一个木桶能装多少水是由最低的一块木板决定的。
非对称加密
由于对称加密的密钥分发问题,非对称加密算法可以完美的解决。
刚毕业不就的时候,和第三方服务做集成,有关于接口参数加密的指引文档,虽然按照人家提供的 demo 可以正常集成,但是文档上说的公钥、私钥还是搞的很迷糊。
现在就来捋一捋啊,就以第三方服务角度来说。假设我是一个支付服务商,为大家提供支付接口的。
公钥
公钥是开放的,谁都可以获取。我作为一个支付服务商,任何到我平台上注册的用户都可以获取到公钥,公钥可以是相同的。
私钥
私钥是绝密的,我作为一个支付服务商,必须将私钥妥善保存,无论是保存在数据库中还是保存在服务器,都必须保证私钥不对外,只有我自己可以使用。
- 使用我服务的用户获取公钥;
- 调用方(客户)用公钥对接口参数进行加密,然后调用接口;
- 我方(支付服务商)用私钥对参数进行解密,然后使用参数进行业务处理;
以上是公钥、私钥的使用过程,这样一看就很明确了。
之所以说是非对称加密,是因为私钥只是用来解密的,用公钥加密过的数据,只有用对应的私钥才能解密出来。所以说,即使有那么多人得到了相同的公钥,也无法获取别人加密过的数据。
最常用的非对称加密算法是 RSA ,RSA 有1024、2048、3072、4096、8129、16384 甚至更多位。目前 3072 位及以上的密钥长度被认为是安全的,曾经大量使用的 2048 位 RSA 现在被破解的风险在不断提升,已经不推荐使用了
RSA 算法的性能要比对称加密 AES 算法差1000倍左右,虽然数学原理上不太明白,但是有数学常识就可以知道,使用不同的密钥进行加密和解密,必然要比使用相同密钥的算法复杂很多,由此肯定会带来性能上的开销。
非对称加密适合用于那些对安全性要求更高的场景,例如支付场景、数字证书、数据加密传输等等。
还可以看看风筝往期文章
用这个方法,免费、无限期使用 SSL(HTTPS)证书,从此实现证书自由了
来源:juejin.cn/post/7434615081394667571
Dart 语法原来这么好玩儿
说到到某个语言的语法可能大家会觉得很枯燥、乏味,而日常开发中我们往往更加注重的是业务逻辑和页面开发,语法的使用大多也停留在满足基本的需求。其实 Dart
语法有很多有意思的地方的,仔细探究一下你会发现,它的简洁清晰、灵活多样的语法会让人爱不释手。在本文中,我们将探索 Dart 语法的各种奇妙之处吧。
unwrap
操作
在 Flutter
中,unwrap
操作常常用于处理可能为空的数据,以便过滤掉空值并只保留非空值。其使用场景也相当广泛,例如 为 Future
和 Streams
添加 unwrap
来处理掉非空数据,或者从网络请求或其他异步操作中获取数据,并在数据流中处理结果等等,如下面这段代码:
extension Unwrap<T> on Future<T?> {
Future<T> unwrap() => then(
(value) => value != null
? Future<T>.value(value)
: Future.any([]),
);
}
unwrap
函数将可能为空的 Future
解包,如果 Future
返回的值不为 null
,则将值包装在一个新的 Future
中返回,否则返回一个空的 Future
。调用示例:
class ImagePickerHelper {
static final ImagePicker _imagePicker = ImagePicker();
static Future<File> pickImageFromGallery() => _imagePicker
.pickImage(source: ImageSource.gallery)
.unwrap()
.then((xFile) => xFile.path)
.then((filePath) => File(filePath));
}
这里用到图片选择器插件 image_picker
,只有当返回的 xFile
不为空时才进行后续操作。如果不调用 unwrap
函数,此时这里返回的 xFile
为 optional
类型,要使用之前需要判断是否为 null
。日常开发中这种情况还不少,给 Future
添加 Unwrap
函数之后这样非空判断集中在这一个函数里面处理。
unwrap
不仅在 Future
中使用,还可以为 Streams
添加 unwrap
操作,代码如下:
extension Unwrap<T> on Stream<T?> {
Stream<T> unwrap() => where((event) => event != null).cast();
}
unwrap
方法,通过 where
过滤掉了 null
的事件,并使用 cast()
方法将结果转换为 Stream<T>
类型,将可空的事件转换为非空的事件流,下面是调用代码:
void main() {
Stream<int?>.periodic(
const Duration(seconds: 1),
(value) => value % 2 == 0 ? value : null,
).unwrap().listen((evenValue) {
print(evenValue);
});
/* 输出结果
0
2
4
6
...
*/
}
通过 extension
给 Future
和 Streams
添加 unwrap
函数后让我们的代码看起来清晰简洁多了,有没有?
数组的展开、合并和过滤
下面代码为任意类型的可迭代对象(Iterable
)添加名为 Flatten
的扩展。在这个扩展中,函数 flatten
使用了递归算法将多层嵌套的 Iterable
里面的所有元素扁平化为单层 Iterable
。
extension Flatten<T extends Object> on Iterable<T> {
Iterable<T> flatten() {
Iterable<T> _flatten(Iterable<T> list) sync* {
for (final value in list) {
if (value is List<T>) {
yield* _flatten(value);
} else {
yield value;
}
}
}
return _flatten(this);
}
}
注意了上面代码中使用了 yield
关键字,在 Flutter
中,yield
关键字用于生成迭代器,通常与sync*
或 async*
一起使用。它允许您在处理某些数据时逐步生成数据,而不是在内存中一次性处理所有数据。对于处理大量数据或执行长时间运行的操作非常有用,因为它可以节省内存并提高性能。
这个和 ES6
中使用 function*
语法和 yield
关键字来生成值一个东西,也是逐个生成值,而不需要一次性生成所有值。以下是 JS
写法:
function* generateNumbers(n) {
for (let i = 0; i < n; i++) {
yield i;
}
}
const numbers = generateNumbers(5);
for (const number of numbers) {
console.log(number);
}
我们来看看 Dart
中的 flatten()
函数的调用:
Future<void> main() async {
final flat = [
[[1, 2, 3], 4, 5],
[6, [7, [8, 9]], 10],
11,12
].flatten();
print(flat); // (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12)
}
嵌套的集合可能在数据处理、转换或展示中经常遇到,而将这些嵌套的集合扁平化可以简化数据处理过程,使代码更加简洁和易于理解。另外一点,递归展多维数组在面试中经常会出现,说不定哪天就用上了哈。
如果将两个数组合并成一个数组该怎么操作呢?其实和 Map
的合并相似,也是用到了自定义操作符 operator
,来看看怎么实现的。
extension InlineAdd<T> on Iterable<T> {
Iterable<T> operator +(T other) => followedBy([other]);
Iterable<T> operator &(Iterable<T> other) => followedBy(other);
}
void main() {
const Iterable<int> values = [10, 20, 30];
print((values & [40, 50]));
// 输出结果:(10, 20, 30, 40, 50)
}
添加了两个操作符:+
和 &
。将一个元素或者另一个可迭代对象添加到当前的可迭代对象中,然后返回一个新的可迭代对象,让可迭代对象 terable
有了合并数组的功能。
当数组中有一个为 null
的对象时,该如何过滤掉这个 null
对象呢,很简单可以这样做:
extension CompactMap<T> on Iterable<T?> {
Iterable<T> compactMap<E>([
E? Function(T?)? transform,
]) =>
map(transform ?? (e) => e).where((e) => e != null).cast();
}
void main() {
const list = ['Hello', null, 'World'];
print(list); // [Hello, null, World]
print(list.compactMap()); // [Hello, World]
print(list.compactMap((e) => e?.toUpperCase())); // [HELLO, WORLD]
}
Map
的过滤和合并
下面代码是 Map
类型的 extension
,为 Map
类型添加了查找过滤的函数。
extension DetailedWhere<K, V> on Map<K, V> {
Map<K, V> where(bool Function(K key, V value) f) => Map<K, V>.fromEntries(
entries.where((entry) => f(entry.key, entry.value)),
);
Map<K, V> whereKey(bool Function(K key) f) =>
{...where((key, value) => f(key))};
Map<K, V> whereValue(bool Function(V value) f) =>
{...where((key, value) => f(value))};
}
where
: 接受一个函数作为参数,该函数接受Map
的键和值作为参数,并返回一个布尔值。whereKey
: 接受一个只接受键作为参数的函数。whereValue
: 这个方法接受一个只接受值作为参数的函数。
下面是调用:
void main(){
const Map<String, int> people = {'John': 20, 'Mary': 21, 'Peter': 22};
print(people.where((key, value) => key.length > 4 && value > 20)); // {Peter: 22}
print(people.whereKey((key) => key.length < 5)); // {John: 20, Mary: 21}
print(people.whereValue((value) => value.isEven)); // {John: 20, Peter: 22}
}
其中 where
方法先使用 entries
获取 Map
的键值对列表,然后使用 entries.where
方法对列表中的每个键值对进行过滤,最后使用 fromEntries
方法将过滤后的键值对列表转换回 Map
,最后返回的新的 Map
中只包含满足条件的键值对,达到对 Map
中键值过滤的效果,也让代码更加简洁和易读。
Map
过滤还有另外一种写法
extension Filter<K, V> on Map<K, V> {
Iterable<MapEntry<K, V>> filter(
bool Function(MapEntry<K, V> entry) f,
) sync* {
for (final entry in entries) {
if (f(entry)) {
yield entry;
}
}
}
}
void main(){
const Map<String, int> people = {
'foo': 20,
'bar': 31,
'baz': 25,
'qux': 32,
};
final peopleOver30 = people.filter((e) => e.value > 30);
print(peopleOver30); // 输出结果:(MapEntry(bar: 31), MapEntry(qux: 32))
}
Map
其它一些更有趣的 extension
,如 Merge
功能,将两个 Map
合并成一个,代码如下:
extension Merge<K, V> on Map<K, V> {
Map<K, V> operator |(Map<K, V> other) => {...this}..addEntries(
other.entries,
);
}
上面的代码用到了 operator
关键字,在 Dart
中,operator
关键字用于定义自定义操作符或者重载现有的操作符。通过 operator
关键字,我们可以为自定义类定义各种操作符的行为,使得我们的类可以像内置类型一样使用操作符。
如 operator +
来定义两个对象相加的行为,operator []
来实现索引操作,operator ==
来定义相等性比较。这种语义式的也更加符合直觉、清晰易懂。
下面来看看 Map
的 Merge
功能调用代码例子:
const userInfo = {
'name': 'StellarRemi',
'age': 28,
};
const address = {
'address': 'shanghai',
'post_code': '200000',
};
void main() {
final allInfo = userInfo | address;
print(allInfo);
// 输出结果:{name: StellarRemi, age: 28, address: shanghai, post_code: 200000}
}
调用的时候也很简单直接 userInfo | address;
,这种操作在处理数据更新或合并配置等情况下特别有用。使用的时候需要注意的是,如果两个 Map
中有重复的键,那么上述操作会保留最后一个 Map
中的值。
小结
怎么样,上面的这些 Dart
的语法是不是很有意思,有没有函数式编程那味儿,后面还会单独一篇来分享 Dart
语言面向对象的设计。好了,今天就到这里,也希望通过本文的分享,能够激发大家对 Dart
语言的兴趣,感谢您的阅读,记得关注点赞哈。
来源:juejin.cn/post/7361096760449466406
轻量级Nacos来了!占用资源极低,性能炸裂!
Nacos作为一款非常流行的微服务注册中心,我们在构建微服务项目时往往会使用到它。最近发现一款轻量级的Nacos项目r-nacos,占用内存极低,性能也很强大,分享给大家。本文就以我的mall-swarm微服务电商实战项目为例,来聊聊它在项目中的使用。
r-nacos简介
r-nacos是一款使用rust实现的nacos服务,对比阿里的nacos来说,可以提供相同的注册中心和配置中心功能。同时它占用的内存更小,性能也很优秀,能提供更稳定的服务。
下面是r-nacos管理控制台使用的效果图,大家可以参考下:
mall-swarm项目简介
由于之后我们需要用到mall-swarm项目,这里简单介绍下它。 mall-swarm项目(11k+star)是一套微服务商城系统,基于2024最新微服技术栈,涵盖Spring Cloud Alibaba、Spring Boot 3.2、JDK17、Kubernetes等核心技术。mall-swarm在电商业务的基础集成了注册中心、配置中心、监控中心、网关等系统功能。
- Github地址:github.com/macrozheng/…
- Gitee地址:gitee.com/macrozheng/…
- 教程网站:cloud.macrozheng.com
项目演示:
安装
r-nacos支持Windows下的exe文件安装,也支持Linux下的Docker环境安装,这里以Docker安装为例。
- 首先通过如下命令下载r-nacos的Docker镜像:
docker pull qingpan/rnacos:stable
- 安装完成后通过如下命令运行r-nacos容器;
docker run --name rnacos -p 8848:8848 -p 9848:9848 -p 10848:10848 -d qingpan/rnacos:stable
- 接下来就可以访问r-nacos的控制台了,默认管理员账号密码为
admin:admin
,访问地址:http://192.168.3.101:10848/rnacos/
项目实战
接下来就以我的mall-swarm微服务电商实战项目为例,来讲解下它的使用。由于mall-swarm项目中各个服务的配置与运行都差不多,这里以mall-admin模块为例。
- 首先我们需要下载mall-swarm项目的代码,下载完成后修改项目的
bootstrap-dev.yml
文件,将其中的nacos连接地址改为r-nacos的地址,项目地址:github.com/macrozheng/…
spring:
cloud:
nacos:
discovery:
server-addr: http://192.168.3.101:8848
config:
server-addr: http://192.168.3.101:8848
file-extension: yaml
- 接下来在r-nacos的
配置列表
中添加mall-admin-dev.yaml
配置,该配置下项目的config
目录下;
- 之后把mall-admin模块运行起来,此时在
r-nacos
的服务列表
功能中就可以看到注册好的服务了;
- 接下来把其他模块的配置也添加到r-nacos的配置列表中去;
- 再运行其他模块,最终
服务列表
显示如下;
- 之后通过网关就可以访问到mall-swarm项目的在线API文档了,访问地址:http://localhost:8201/doc.html
- 这里我们再把mall-swarm项目的后台管理系统前端项目
mall-admin-web
给运行起来;
- 最后我们再把mall-swarm项目的前台商城系统前端项目
mall-app-web
给运行起来,发现都是可以正常从网关调用API的。
其他使用
r-nacos除了提供了基本的注册中心和配置中心功能,还提供了一些其他的实用功能,这里我们一起来了解下。
- 如果你想添加一些其他访问的用户,或者修改admin用户的信息,可以使用
用户管理
功能;
- 如果你想对r-nacos中配置信息进行导入导出,可以使用
数据迁移
功能;
- 如果你想对r-nacos中的运行状态进行监控,你可以使用
系统监控
功能,监控还是挺全的。
性能压测
r-nacos的性能还是非常好的,这里有个r-nacos官方提供的性能压测结果表,大家可以参考下。
对比Nacos
个人感觉对比阿里的nacos,占用的内存资源减少了非常多,运行不到10M内存,而nacos需要900M,服务器资源不宽裕的小伙伴可以尝试下它。
总结
今天以我的mall-swarm微服务电商实战项目为例,讲解了r-nacos的使用。从功能上来说r-nacos是完全可以替代nacos的,而且它占用内存资源非常低,性能也很强大,感兴趣的小伙伴可以尝试下它!
项目地址
来源:juejin.cn/post/7434185097300475919
既生@Primary,何生@Fallback
个人公众号:IT周瑜,十二年Java开发和架构经验,一年大模型应用开发经验,爱好研究源码,比如Spring全家桶源码、MySQL源码等,同时也喜欢分享技术干货,期待你的关注
最近闲着的时候在看Spring 6.2的源码,发现了一些新特性,比如本文要介绍的@Fallback注解。
相信大家都知道@Primary注解,而@Fallback相当于是@Primary的反向补充。
Spring在进行依赖注入时,会根据属性的类型去Spring容器中匹配Bean,但有可能根据类型找到了多个Bean,并且也无法根据属性名匹配到Bean时,就会报错,比如expected single matching bean but found 2,此时,就可以利用@Primary来解决。
加了@Primary的Bean表示是同类型多个Bean中的主Bean,换句话说,如果Spring根据类型找到了多个Bean,会选择其中加了@Primary的Bean来进行注入,因此,同类型的多个Bean中只能有一个加了@Primary,如果有多个也会报错more than one 'primary' bean found。
比如以下代码会使用orderService1来进行注入:
@Bean
@Primary
public OrderService orderService1() {
return new OrderService();
}
@Bean
public OrderService orderService2() {
return new OrderService();
}
而加了@Fallback注解的Bean为备选Bean,比如以下代码会使用orderService2来进行依赖注入:
@Bean
@Fallback
public OrderService orderService1() {
return new OrderService();
}
@Bean
public OrderService orderService2() {
return new OrderService();
}
因为orderService1加了@Fallback注解,相当于备胎,只有当没有其他Bean可用时,才会用orderService1这个备胎,有其他Bean就会优先用其他Bean。
@Primary和@Fallback都是用在依赖注入时根据类型找到了多个Bean的场景中:
- @Primary比较强势,它在说:“直接用我就可以了,不用管其他Bean”
- @Fallback比较弱势,它在说:“先别用我,先用其他Bean”
如果根据类型只找到一个Bean就用不着他两了,另外,同类型多个Bean中@Primary的Bean只能有一个,但可以有多个@Fallback。
大家觉得@Fallback注解怎么样?
实际上,@Primary和@Fallback两个注解的源码实现在同一个方法中,源码及注释如下,感兴趣的同学可以看看:
protected String determinePrimaryCandidate(Map<String, Object> candidates, Class<?> requiredType) {
String primaryBeanName = null;
// First pass: identify unique primary candidate
// 先找@Primary注解的Bean
// candidates就是根据类型找到的多个Bean,key为beanName, Value为bean对象
for (Map.Entry<String, Object> entry : candidates.entrySet()) {
String candidateBeanName = entry.getKey();
Object beanInstance = entry.getValue();
if (isPrimary(candidateBeanName, beanInstance)) {
if (primaryBeanName != null) {
boolean candidateLocal = containsBeanDefinition(candidateBeanName);
boolean primaryLocal = containsBeanDefinition(primaryBeanName);
// 找到多个@Primary会报错
if (candidateLocal == primaryLocal) {
throw new NoUniqueBeanDefinitionException(requiredType, candidates.size(),
"more than one 'primary' bean found among candidates: " + candidates.keySet());
}
else if (candidateLocal) {
primaryBeanName = candidateBeanName;
}
}
else {
// 找到一个@Primary注解的Bean就先存着,看是不是还有其他@Primay注解的Bean
primaryBeanName = candidateBeanName;
}
}
}
// Second pass: identify unique non-fallback candidate
// 没有@Primary注解的Bean情况下,才找没有加@Fallback注解的,加了@Fallback注解的Bean会被过滤掉
if (primaryBeanName == null) {
for (String candidateBeanName : candidates.keySet()) {
// 判断是否没有加@Fallback
if (!isFallback(candidateBeanName)) {
// 如果有多个Bean没有加@Fallback,会返回null,后续会根据属性名从多个bean中进行匹配,匹配不到就会报错
if (primaryBeanName != null) {
return null;
}
primaryBeanName = candidateBeanName;
}
}
}
return primaryBeanName;
}
来源:juejin.cn/post/7393311009686192147
Java程序员必知的9个SQL优化技巧
大多数的接口性能问题,很多情况下都是SQL问题,在工作中,我们也会定期对慢SQL进行优化,以提高接口性能。这里总结一下常见的优化方向和策略。
避免使用select *,减少查询字段
不要为了图省事,直接查询全部的字段,尽量查需要的字段,特别是复杂的SQL,能够避免很多不走索引的情况。这也是最基本的方法。
检查执行计划,是否走索引
检查where和order by字段是否有索引,根据表的数据量和现有索引,考虑是否增加索引或者联合索引。 然而,索引并不是越多越好,原因有以下几点:
- 存储空间:每个索引都会占用额外的存储空间。如果为表中的每一列都创建索引,那么这些索引的存储开销可能会非常大,尤其是在大数据集上。
- 索引重建增加开销:当数据发生变更(如插入、更新或删除)时,相关的索引也需要进行更新,以确保数据的准确性和查询效率。这意味着更多的索引会导致更慢的写操作。
- 选择性:选择性是指索引列中不同值的数量与表中记录数的比率。选择性高的列(即列中有很多唯一的值)更适合创建索引。对于选择性低的列(如性别列,其中只有“男”和“女”两个值),创建索引可能不会产生太大的查询性能提升。
- 过度索引:当表中存在过多的索引时,可能会导致数据库优化器在选择使用哪个索引时变得困难。这可能会导致查询性能下降,因为优化器可能选择了不是最优的索引。
因此,在设计数据库时,需要根据查询需求和数据变更模式来仔细选择需要创建索引的列。通常建议只为经常用于查询条件、排序和连接的列创建索引,并避免为选择性低的列创建索引。
避免使用or连接
假设我们有一个数据表employee,包含以下字段:id, name, age。 原始查询使用OR操作符来筛选满足name为'John'或age为30的员工:
SELECT * FROM employee WHERE name = 'John' OR age = 30;
使用UNION操作符来实现同样的筛选:
SELECT * FROM employee WHERE name = 'John'
UNION
SELECT * FROM employee WHERE age = 30;
UNION操作符先查询满足name为'John'的记录,然后查询满足age为30的记录,并将两个结果集合并起来。这样可以减少查询的数据量,提高查询效率。 需要注意的是,UNION操作符会去除重复的记录。如果想要保留重复的记录,可以使用UNION ALL操作符,例如: 判断两条记录是否为重复记录的标准是通过比较每个字段的值来确定的。
SELECT * FROM employee WHERE name = 'John'
UNION ALL
SELECT * FROM employee WHERE age = 30;
在使用UNION代替OR时,还需要注意查询语句的语义是否与原始查询相同。有些情况下,OR可能会产生更准确的结果,因此在使用UNION时需谨慎考虑语义问题。
减少in和not in的使用
说实话,这种情况有点难。实际工作中,使用in的场景很多,但是要尽量避免in后面的数据范围,范围太大的时候,要考虑分批处理等操作。
对于连续的数值,可以考虑使用between and 代替。
避免使用左模糊查询
在工作中,对于姓名、手机号、名称等内容,经常会遇到模糊查询的场景,但是要尽量避免左模糊,这种SQL无法使用索引。
- 左模糊查询: 假设我们有一个数据表customer,包含字段name,我们想要查询名字以"J"开头的客户:
SELECT * FROM customer WHERE name LIKE 'J%';
- 右模糊查询: 继续使用上述customer表,我们想要查询名字以"n"结尾的客户:
SELECT * FROM customer WHERE name LIKE '%n';
注意,在某些数据库中,对于右模糊查询,可能需要使用转义符号(如""),以防止通配符被误解。
- 全模糊查询: 还是使用上述customer表,我们想要查询名字中包含"son"的客户:
SELECT * FROM customer WHERE name LIKE '%son%';
连接查询join替代子查询
假设我们有两个表:订单表(orders)和客户表(customers)。 订单表包含了订单号(order_id)、客户ID(customer_id)和订单金额(amount),而客户表包含了客户ID(customer_id)和客户姓名(customer_name)。
我们要找出所有订单金额大于1000美元的客户姓名:
SELECT customer_name
FROM customers
WHERE customer_id IN (SELECT DISTINCT customer_id FROM orders WHERE amount > 1000);
以上查询首先在订单表中挑选出所有金额大于1000美元的客户ID,然后使用这个子查询的结果来过滤客户表并获取客户姓名。
使用 JOIN 来替代子查询的方式:
SELECT DISTINCT c.customer_name
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
WHERE o.amount > 1000;
改造后的查询通过使用 INNER JOIN 将客户表和订单表连接在一起,然后使用 WHERE 子句来过滤出金额大于1000美元的订单。
这种改造不仅使查询更加简洁,而且可能还会提高查询的性能。JOIN 操作通常比子查询的效率更高,特别是在处理大型数据集时。
join的优化
JOIN 是 SQL 查询中的一个操作,用于将两个或多个表连接在一起。JOIN 操作有几种类型,包括 LEFT JOIN、RIGHT JOIN 和 INNER JOIN。要选用正确的关联方式,确保查询内容的正确性。
- INNER JOIN(内连接):内连接返回满足连接条件的行,即两个表中相关联的行组合。只有在两个表中都存在匹配的行时,才会返回结果。
SELECT *
FROM table1
INNER JOIN table2 ON table1.column = table2.column;
- LEFT JOIN(左连接):左连接返回左侧表中的所有行,以及右侧表中满足连接条件的行。如果右表中没有匹配的行,则返回 NULL 值。在用left join关联查询时,左边要用小表,右边可以用大表。如果能用inner join的地方,尽量少用left join。
SELECT *
FROM table1
LEFT JOIN table2 ON table1.column = table2.column;
- RIGHT JOIN(右连接):右连接返回右侧表中的所有行,以及左侧表中满足连接条件的行。如果左表中没有匹配的行,则返回 NULL 值。
SELECT *
FROM table1
RIGHT JOIN table2 ON table1.column = table2.column;
需要注意的是,LEFT JOIN 和 RIGHT JOIN 是对称的,只是左右表的位置不同。INNER JOIN 则是返回共同匹配的行。
这些不同类型的 JOIN 可以灵活地根据查询需求选择使用。INNER JOIN 用于获取两个表中的匹配行,LEFT JOIN 和 RIGHT JOIN 用于获取一个表中的所有行以及另一个表中的匹配行。使用 JOIN 可以将多个表连接在一起,使我们能够根据关联的列获取相关的数据,并更有效地处理复杂的查询需求。但是使用的时候要特别注意,左右表的关联关系,是一对一、一对多还是多对多,对查询的结果影响很大。
gr0up by 字段优化
假设我们要计算每个客户的订单总金额,原始的查询可能如下所示:
SELECT customer_id, SUM(amount) AS total_amount
FROM orders
GR0UP BY customer_id;
在这个查询中,我们使用 GR0UP BY 字段 customer_id 对订单进行分组,并使用 SUM 函数计算每个客户的订单总金额。
为了优化这个查询,我们可以考虑以下几种方法:
- 索引优化:
- 确保在 customer_id 字段上创建索引,以加速 GR0UP BY 和 WHERE 子句的执行。
- 如果查询还包含其他需要的字段,可以考虑创建聚簇索引,将相关的字段放在同一个索引中,以减少查询的IO操作。
- 使用覆盖索引:
- 如果查询中只需要使用 customer_id 和 amount 两个字段,可以创建一个覆盖索引,它包含了这两个字段,减少了查找其他字段的开销。
- 子查询优化:
- 如果订单表很大,可以先使用子查询将数据限制在一个较小的子集上,然后再进行 GR0UP BY 操作。例如,可以先筛选出最近一段时间的订单,然后再对这些订单进行分组。
- 条件优化:
- 使用WHERE条件在分组前,就把多余的数据过滤掉了,这样分组时效率就会更高一些。而不是在分组后使用having过滤数据。
深分页limit优化
深分页通常指的是在处理大量数据时,用户需要浏览远离首页的页面,例如第100页、第1000页等。这种场景下,如果简单地一次性加载所有数据并进行分页,会导致性能问题,包括内存消耗、数据库查询效率等。
我们日常使用较多的分页一般是用的PageHelper插件,SQL如下:
select id,name from table_name where N个条件 limit 100000,10;
它的执行流程:
- 先去二级索引过滤数据,然后找到主键ID
- 通过ID回表查询数据,取出需要的列
- 扫描满足条件的100010,丢弃前面100000条,返回
这里很明显的不足就是只需要拿10条,但是却多回表了100000次。
可采用的策略:主要是使用子查询、关联查询、范围查询和标签记录法这四种方法,当然对于深分页问题,一般都是比较麻烦了,都需要采用标签记录法来改造代码。
标签记录法:就是记录上次查询的最大ID,再请求下一页的时候带上,从上次的下一条数据开始开始,前提是有序的。 主要需要对代码进行改造:
public Page- fetchPageByKey(Long lastKey, int pageSize) {
// lastKey是上一页最后一项的主键
// 查询数据库,获取主键大于lastKey的pageSize条记录
List- items = itemRepository.findByPrimaryKeyGreaterThan(lastKey, pageSize);
// 如果没有更多数据,可以设置下一个lastKey为空或特定值(如-1)
Long nextLastKey = items.isEmpty() ? null : items.get(items.size() - 1).getId();
return new Page<>(items, nextLastKey);
}
来源:juejin.cn/post/7368377525859008522
把java接口写在数据库里(groovy)
业务复杂多变?那把接口写在数据库里吧,修改随改随用!本文使用了Groovy脚本,不了解的可以自行了解,直接上菜。
- 引入依赖
<dependency>
<groupId>org.codehaus.groovygroupId>
<artifactId>groovy-allartifactId>
<version>2.5.16version>
<type>pomtype>
dependency>
- 创建测试接口
public interface InterfaceA {
/**
* 执行规则
*/
void testMethod();
}
- resource目录下创建.groovy实现上面的接口
@Slf4j
class GroovyInterfaceAImpl implements InterfaceA {
@Override
void testMethod() {
log.info("我是groovy编写的InterfaceA接口实现类中的接口方法")
GroovyScriptService groovyScriptService = SpringUtils.getBean(GroovyScriptService.class)
GroovyScript groovyScript = Optional.ofNullable(groovyScriptService.getOne(new QueryWrapper()
.eq("name", "groovy编写的java接口实现类")
.eq("version", 1))).orElseThrow({ -> new RuntimeException("没有查询到脚本") })
log.info("方法中进行了数据库查询,数据库中的groovy脚本是这个:{}", "\n" + groovyScript.getScript())
}
}
- mysql数据库中建个表groovy_script
5. 将刚才编写的.groovy文件内容存入数据库
@RunWith(SpringRunner.class)
@SpringBootTest
public class GroovyTest {
@Resource
private GroovyScriptService groovyScriptService;
@Test
public void test01() {
GroovyScript groovyScript = new GroovyScript();
groovyScript.setScript("package groovy\n" +
"\n" +
"import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper\n" +
"import com.demo.groovy.entity.GroovyScript\n" +
"import com.demo.groovy.service.GroovyScriptService\n" +
"import com.demo.groovy.service.InterfaceA\n" +
"import com.demo.groovy.util.SpringUtils\n" +
"import groovy.util.logging.Slf4j\n" +
"\n" +
"\n" +
"@Slf4j\n" +
"class GroovyInterfaceAImpl implements InterfaceA {\n" +
"\n" +
" @Override\n" +
" void testMethod() {\n" +
" log.info("我是groovy编写的InterfaceA接口实现类中的接口方法")\n" +
" GroovyScriptService groovyScriptService = SpringUtils.getBean(GroovyScriptService.class)\n" +
" GroovyScript groovyScript = Optional.ofNullable(groovyScriptService.getOne(new QueryWrapper()\n" +
" .eq("name", "groovy编写的java接口实现类")\n" +
" .eq("version", 1))).orElseThrow({ -> new RuntimeException("没有查询到脚本") })\n" +
" log.info("方法中进行了数据库查询,数据库中的groovy脚本是这个:{}", "\n" + groovyScript.getScript())\n" +
" }\n" +
"}");
groovyScript.setVersion(1);
groovyScript.setName("groovy编写的java接口实现类");
groovyScriptService.save(groovyScript);
}
}
- 从数据读取脚本,GroovyClassLoader加载脚本为Class(注意将Class对象进行缓存)
@Service("groovyScriptService")
@Slf4j
public class GroovyScriptServiceImpl extends ServiceImpl<GroovyScriptServiceMapper, GroovyScript> implements GroovyScriptService {
private static final Map<String, Md5Clazz> SCRIPT_MAP = new ConcurrentHashMap<>();
@Override
public Object getInstanceFromDb(String name, Integer version) {
//查询脚本
GroovyScript groovyScript = Optional.ofNullable(baseMapper.selectOne(new QueryWrapper<GroovyScript>()
.eq("name", name)
.eq("version", version))).orElseThrow(() -> new RuntimeException("没有查询到脚本"));
//将groovy脚本转换为java类对象
Class clazz = getClazz(name + version.toString(), groovyScript.getScript());
Object instance;
try {
instance = clazz.newInstance();
} catch (Exception e) {
throw new RuntimeException(e);
}
return instance;
}
private Class getClazz(String scriptKey, String scriptText) {
String md5Hex = DigestUtil.md5Hex(scriptText);
Md5Clazz md5Script = SCRIPT_MAP.getOrDefault(scriptKey, null);
if (md5Script != null && md5Hex.equals(md5Script.getMd5())) {
log.info("从缓存获取的Clazz");
return md5Script.getClazz();
} else {
CompilerConfiguration config = new CompilerConfiguration();
config.setSourceEncoding("UTF-8");
GroovyClassLoader groovyClassLoader = new GroovyClassLoader(Thread.currentThread().getContextClassLoader(), config);
try {
Class clazz = groovyClassLoader.parseClass(scriptText);
SCRIPT_MAP.put(scriptKey, new Md5Clazz(md5Hex, clazz));
groovyClassLoader.clearCache();
log.info("groovyClassLoader parseClass");
return clazz;
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
try {
groovyClassLoader.close();
} catch (IOException e) {
log.error("close GroovyClassLoader error", e);
}
}
}
}
@Data
private static class Md5Clazz {
private String md5;
private Class clazz;
public Md5Clazz(String md5, Class clazz) {
this.md5 = md5;
this.clazz = clazz;
}
}
}
- 测试
@RestController
@RequestMapping("/test")
@Slf4j
public class GroovyTestController {
@Resource
private GroovyScriptService groovyScriptService;
@GetMapping("")
public String testGroovy() {
InterfaceA interfaceA = (InterfaceA) groovyScriptService.getInstanceFromDb("groovy编写的java接口实现类", 1);
interfaceA.testMethod();
return "ok";
}
}
- 接口方法被执行。想要修改接口的话在idea里面把groovy文件编辑好更新到数据库就行了,即时生效。
本文简单给大家提供一种思路,希望能对大家有所帮助,如有不当之处还请大家指正。本人之前在项目中用的比较多的是Groovyshell,执行的是一些代码片段,而GroovyClassLoader则可以加载整个脚本为Class,Groovy对于java开发者来说还是比较友好的,上手容易。
来源:juejin.cn/post/7397013935106048051
utf8和utf8mb4有什么区别?
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
黑神话:悟空——揭秘风灵月影的技术魔法
作为一名“手残党”,你是否常常因为复杂的操作和高难度的游戏内容而感到沮丧?不用担心,《黑神话:悟空》不仅仅是为硬核玩家准备的,它同样为我们这些操作不那么娴熟的玩家提供了无比精彩的游戏体验。
本文将带你深入探讨《黑神话:悟空》背后的技术原理,揭示风灵月影团队如何通过创新的技术手段,让每一位玩家,无论技术水平如何,都能在游戏中找到属于自己的乐趣。我们将揭秘那些让你在游戏中感受到无比真实和沉浸的细节,从逼真的角色动画到动态的环境效果,每一个细节都展示了团队的卓越才能和对游戏品质的追求。让我们一同走进这场技术与艺术的盛宴,感受《黑神话:悟空》背后的科技魔法,了解这些技术如何让你在游戏中无论是战斗还是探索,都能享受到极致的体验。
我的黑神话悟空数值
欢迎加入宗门
风灵月影修改器
Attach一个游戏进程,黑神话悟空进程名固定
Attach一个进程之后,可以修改对应的游戏数值
使用方式上每次启动游戏都要启动风灵月影,重启的游戏风铃月影也会重新Attach进程。
游戏修改器的工作原理
游戏修改器的技术原理主要涉及对游戏内存的实时修改和对游戏数据的动态调整。以下是修改器的主要技术原理和工作机制:
内存修改
- 内存扫描:修改器首先会扫描游戏进程的内存空间,找到存储特定游戏数据(如生命值、金钱、资源等)的内存地址。
- 地址定位:通过反复扫描和比较内存数据的变化,确定具体的内存地址。例如,玩家在游戏中增加或减少金钱,修改器会通过这些变化找到金钱的内存地址。
- 数据修改:一旦找到目标内存地址,修改器会直接修改该地址处的数据。例如,将生命值地址的数据修改为一个极大值,从而实现无限生命。
动态链接库(DLL)注入
- DLL注入:修改器可以通过将自定义的DLL文件注入到游戏进程中,来拦截和修改游戏的函数调用。
- 函数劫持:通过劫持游戏的关键函数,修改器可以在函数执行前后插入自定义代码。例如,拦截角色受伤的函数调用,将伤害值修改为零,从而实现无敌效果。
- 实时调整:DLL注入还可以实现对游戏数据的实时监控和调整,确保修改效果持续生效。
调试工具
- 调试接口:一些高级修改器使用调试工具(如Cheat Engine)提供的调试接口,直接与游戏进程交互。
- 断点调试:通过设置断点,修改器可以在特定代码执行时暂停游戏,进行数据分析和修改。
- 汇编指令修改:修改器可以修改游戏的汇编指令,改变游戏的逻辑。例如,将减血指令修改为加血指令。
数据文件修改
- 配置文件:一些游戏的关键数据存储在配置文件中(如INI、XML等),修改器可以直接修改这些文件来改变游戏设置。
- 存档文件:修改器可以修改游戏的存档文件,直接改变游戏进度和状态。例如,增加存档中的金钱数量或解锁所有关卡。
反作弊机制
- 反检测:为了避免被游戏的反作弊机制检测到,修改器通常会使用一些反检测技术,如代码混淆、动态加密等。
- 隐蔽操作:修改器可能会模拟正常的用户操作,避免直接修改内存或数据,降低被检测到的风险。
以上是游戏修改器的主要技术原理。通过这些技术,修改器能够对游戏进行各种修改和调整,提供丰富的功能来提升玩家的游戏体验。然而,使用修改器时应注意相关法律和游戏规定,避免影响游戏的公平性和他人体验。
blog.csdn.net/m0_74942241… (可部分阅读)
代码示例
以下是一个简单的内存扫描示例:
#include <windows.h>
#include <iostream>
#include <vector>
// 扫描目标内存
std::vector<LPVOID> ScanMemory(HANDLE hProcess, int targetValue) {
std::vector<LPVOID> addresses;
MEMORY_BASIC_INFORMATION mbi;
LPVOID address = 0;
while (VirtualQueryEx(hProcess, address, &mbi, sizeof(mbi))) {
if (mbi.State == MEM_COMMIT && (mbi.Protect == PAGE_READWRITE || mbi.Protect == PAGE_WRITECOPY)) {
SIZE_T bytesRead;
std::vector<BYTE> buffer(mbi.RegionSize);
if (ReadProcessMemory(hProcess, address, buffer.data(), mbi.RegionSize, &bytesRead)) {
for (SIZE_T i = 0; i < bytesRead - sizeof(targetValue); ++i) {
if (memcmp(buffer.data() + i, &targetValue, sizeof(targetValue)) == 0) {
addresses.push_back((LPVOID)((SIZE_T)address + i));
}
}
}
}
address = (LPVOID)((SIZE_T)address + mbi.RegionSize);
}
return addresses;
}
int main() {
DWORD processID = 1234; // 替换为目标进程的实际PID
int targetValue = 100; // 要查找的值
HANDLE hProcess = OpenTargetProcess(processID);
if (hProcess) {
std::vector<LPVOID> addresses = ScanMemory(hProcess, targetValue);
for (auto addr : addresses) {
std::cout << "Found value at address: " << addr << std::endl;
ModifyMemory(hProcess, addr, 999); // 修改内存
}
CloseHandle(hProcess);
}
return 0;
}
修改目标进程的内存
#include <windows.h>
#include <iostream>
// 打开目标进程
HANDLE OpenTargetProcess(DWORD processID) {
HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, processID);
if (hProcess == NULL) {
std::cerr << "Failed to open process. Error: " << GetLastError() << std::endl;
}
return hProcess;
}
// 查找目标内存地址(假设我们已经知道地址)
void ModifyMemory(HANDLE hProcess, LPVOID address, int newValue) {
SIZE_T bytesWritten;
if (WriteProcessMemory(hProcess, address, &newValue, sizeof(newValue), &bytesWritten)) {
std::cout << "Memory modified successfully." << std::endl;
} else {
std::cerr << "Failed to modify memory. Error: " << GetLastError() << std::endl;
}
}
int main() {
DWORD processID = 1234; // 替换为目标进程的实际PID
LPVOID targetAddress = (LPVOID)0x00ABCDEF; // 替换为目标内存地址
int newValue = 999; // 要写入的新值
HANDLE hProcess = OpenTargetProcess(processID);
if (hProcess) {
ModifyMemory(hProcess, targetAddress, newValue);
CloseHandle(hProcess);
}
return 0;
}
以下是一个使用内联钩子实现函数劫持的简单示例(基于Windows平台):
#include <windows.h>
// 原始函数类型定义
typedef int (WINAPI *MessageBoxAFunc)(HWND, LPCSTR, LPCSTR, UINT);
// 保存原始函数指针
MessageBoxAFunc OriginalMessageBoxA = NULL;
// 自定义函数
int WINAPI HookedMessageBoxA(HWND hWnd, LPCSTR lpText, LPCSTR lpCaption, UINT uType) {
// 修改参数或执行其他逻辑
lpText = "This is a hooked message!";
// 调用原始函数
return OriginalMessageBoxA(hWnd, lpText, lpCaption, uType);
}
// 设置钩子
void SetHook() {
// 获取原始函数地址
HMODULE hUser32 = GetModuleHandle("user32.dll");
OriginalMessageBoxA = (MessageBoxAFunc)GetProcAddress(hUser32, "MessageBoxA");
// 修改函数头部指令
DWORD oldProtect;
VirtualProtect(OriginalMessageBoxA, 5, PAGE_EXECUTE_READWRITE, &oldProtect);
*(BYTE*)OriginalMessageBoxA = 0xE9; // JMP指令
*(DWORD*)((BYTE*)OriginalMessageBoxA + 1) = (DWORD)HookedMessageBoxA - (DWORD)OriginalMessageBoxA - 5;
VirtualProtect(OriginalMessageBoxA, 5, oldProtect, &oldProtect);
}
int main() {
// 设置钩子
SetHook();
// 测试钩子
MessageBoxA(NULL, "Original message", "Test", MB_OK);
return 0;
}
CheatEngine
上述通过代码的方式成本比较高,我们通过工具修改内存值和函数劫持
http://www.cheatengine.org/downloads.p…
使用教程 blog.csdn.net/CYwxh0125/a…
如何实现自动扫描
风灵月影如何实现自动修改值,不需要每次搜索变量内存地址?
猜想1:变量内存地址固定?
经过测试发现不是
猜想2:通过变量字符串搜索?
算了不猜了,直接问GPT,发现应该是通过指针内存扫描的方式。
指针扫描适合解决的问题
指针扫描是一种高级技术,用于解决动态内存地址变化的问题。在某些应用程序(特别是游戏)中,内存地址在每次运行时可能会变化,这使得简单的内存扫描方法难以长期有效地找到目标变量。指针扫描通过查找指向目标变量的指针链,可以找到一个稳定的基址(静态地址),从而解决动态内存地址变化的问题。
1. 动态内存分配
许多现代应用程序和游戏使用动态内存分配,导致每次运行时同一变量可能位于不同的内存地址。指针扫描可以找到指向这些变量的指针链,从而定位到一个稳定的基址。
2. 多次重启后的地址稳定性
通过指针扫描找到的静态基址和指针链,即使在应用程序或系统重启后,仍然可以有效地找到目标变量的位置。这样,用户无需每次重新扫描内存地址。
3. 多级指针
有些变量可能通过多级指针间接引用。指针扫描可以处理这种情况,通过多级指针链找到最终的目标变量。
指针扫描的基本步骤
以下是使用 Cheat Engine 进行指针扫描的基本步骤:
1. 初始扫描
首先,使用普通的内存扫描方法找到目标变量的当前内存地址。例如,假设在游戏中找到当前金钱值的地址是 0x00ABCDEF
。
2. 指针扫描
- 在找到的内存地址上右键单击,选择“指针扫描此地址”。
- Cheat Engine 会弹出一个指针扫描窗口。在窗口中设置扫描参数,例如最大指针级别和偏移量。
- 点击“确定”开始扫描。Cheat Engine 会生成一个包含可能的指针路径的列表。
3. 验证指针路径
- 重启游戏或应用程序,再次找到目标变量的当前内存地址。
- 使用新的内存地址进行指针扫描,验证之前找到的指针路径是否仍然有效。
- 通过多次验证,找到一个稳定的指针路径。
4. 使用指针路径
- 在 Cheat Engine 中保存指针路径。
- 以后可以直接使用这个指针路径来访问目标变量,无需每次重新扫描。
示例:使用 Cheat Engine 进行指针扫描
假设你在游戏中找到了当前金钱值的地址 0x00ABCDEF
,并想通过指针扫描找到一个稳定的基址。
1. 初始扫描
- 启动 Cheat Engine 并附加到游戏进程。
- 使用普通的内存扫描方法找到当前金钱值的地址
0x00ABCDEF
。
2. 指针扫描
- 右键单击找到的地址
0x00ABCDEF
,选择“指针扫描此地址”。 - 在弹出的指针扫描窗口中,设置最大指针级别为 5(可以根据需要调整),偏移量保持默认。
- 点击“确定”开始扫描。
3. 验证指针路径
- 重启游戏,重新找到当前金钱值的地址(假设新的地址是
0x00DEF123
)。 - 使用新的地址进行指针扫描,验证之前找到的指针路径是否仍然有效。
- 通过多次验证,找到一个稳定的指针路径。例如,指针路径可能是
[game.exe+0x00123456] + 0x10 + 0x20
。
4. 使用指针路径
- 在 Cheat Engine 中保存这个指针路径。
- 以后可以直接使用这个指针路径来访问金钱值,无需每次重新扫描。
注意事项
- 指针级别:指针级别越高,扫描时间越长,但也能处理更复杂的多级指针情况。根据实际需要设置合适的指针级别。
- 验证指针路径:指针路径需要多次验证,以确保其稳定性和可靠性。重启游戏或应用程序,重新扫描并验证指针路径。
- 性能影响:指针扫描可能会对系统性能产生一定影响,特别是在大型游戏或应用程序中。建议在合适的环境下进行扫描。
通过以上步骤,指针扫描技术可以帮助用户找到稳定的基址,解决动态内存地址变化的问题,从而实现更可靠的内存修改。
来源:juejin.cn/post/7426389669527207936
技术大佬 问我 订单消息乱序了怎么办?
技术大佬 :佩琪,最近看你闷闷不乐了,又被虐了?
佩琪:(⊙o⊙)…,又被大佬发现了。这不最近出去面试都揉捏的像一个麻花了嘛
技术大佬 :哦,这次又是遇到什么难题了?
佩琪: 由于和大佬讨论过消息不丢,消息防重等技能(见
kafka 消息“零丢失”的配方 和技术大佬问我 订单消息重复消费了 怎么办?
),所以在简历的技术栈里就夸大似的写了精通kafka消息中间件,然后就被面试官炮轰了里面的细节
佩琪: 其中面试官给我印象深刻的一个问题是:你们的kafka消息里会有乱序消费的情况吗?如果有,是怎么解决的了?
技术大佬 :哦,那你是怎么回答的了?
佩琪:我就是个crud boy,根本不知道啥是顺序消费啥是乱序消费,所以就回答说,没有
技术大佬 :哦,真是个诚实的孩子;然后呢?
佩琪:然后面试官就让我回家等通知了,然后就没有然后了。。。。
佩琪 : 对了大佬,什么是消息乱序消费了?
技术大佬 :消息乱序消费,一般指我们消费者应用程序不按照,上游系统 业务发生的顺序,进行了业务消息的颠倒处理,最终导致消费业务出错。
佩琪 :低声咕噜了下你这说的是人话吗?大声问答:这对我的小脑袋有点抽象了,大佬能举个实际的栗子吗?
技术大佬 :举个上次我们做的促销数据同步的栗子吧,大概流程如下:
技术大佬 :上次我们做的促销业务,需要在我们的运营端后台,录入促销消息;然后利用kafka同步给三方业务。在业务流程上,是先新增促销信息,然后可能删除促销信息;但是三方消费端业务接受到的kafka消息,可能是先接受到删除促销消息;随后接受到新增促销消息;这样不就导致了消费端系统和我们系统的促销数据不一致了嘛。所以你是消费方,你就准备接锅吧,你不背锅,谁背锅了?
佩琪 :-_-||,此时佩琪心想,锅只能背一次,坑只能掉一次。赶紧问到:请问大佬,消息乱序了后,有什么解决方法吗?
技术大佬 : 此时抬了抬眼睛,清了清嗓子,面露自信的微笑回答道。一般都是使用顺序生产,顺序存储,顺序消费的思想来解决。
佩琪 :摸了摸头,能具体说说,顺序生产,顺序存储,顺序消费吗?
技术大佬 : 比如kafka,一般建议同一个业务属性数据,都往一个分区上发送;而kafka的一个分区只能被一个消费者实例消费,不能被多个消费者实例消费。
技术大佬 : 也就是说在生产端如果能保证 把一个业务属性的消息按顺序放入同一个分区;那么kakfa中间件的broker也是顺序存储,顺序给到消费者的。而kafka的一个分区只能被一个消费者消费;也就不存在多线程并发消费导致的顺序问题了。
技术大佬 :比如上面的同步促销消息;不就是两个消费者,拉取了不同分区上的数据,导致消息乱序处理,最终数据不一致。同一个促销数据,都往一个分区上发送,就不会存在这样的乱序问题了。
佩琪 :哦哦,原来是这样,我感觉这方案心理没底了,大佬能具体说说这种方案有什么优缺点吗?
技术大佬 :给你一张图,你学习下?
优点 | 缺点 |
---|---|
生产端实现简单:比如kafka 生产端,提供了按指定key,发送到固定分区的策略 | 上游难保证严格顺序生产:生产端对同一类业务数据需要按照顺序放入同一个分区;这个在应用层还是比较的难保证,毕竟上游应用都是无状态多实例,多机器部署,存在并发情况下执行的先后顺序不可控 |
消费端实现也简单 :kafka消费者 默认就是单线程执行;不需要为了顺序消费而进行代码改造 | 消费者处理性能会有潜在的瓶颈:消费者端单线程消费,只能扩展消费者应用实例来进行消费者处理能力的提升;在消息较多的时候,会是个处理瓶颈,毕竟干活的进程上限是topic的分区数。 |
无其它中间件依赖 | 使用场景有取限制:业务数据只能指定到同一个topic,针对某些业务属性是一类数据,但发送到不同topic场景下,则不适用了。比如订单支付消息,和订单退款消息是两个topic,但是对于下游算佣业务来说都是同一个订单业务数据 |
佩琪 :大佬想偷懒了,能给一个 kafka 指定 发送到固定分区的代码吗?
技术大佬 :有的,只需要一行代码,你要不自己动手尝试下?
KafkaProducer.send(new ProducerRecord[String,String](topic,key,msg),new Callback(){} )
topic:主题,这个玩消息的都知道,不解释了
key: 这个是指定发送到固定分区的关键。一般填写订单号,或者促销ID。kafka在计算消息该发往那个分区时,会默认使用hash算法,把相同的key,发送到固定的分区上
msg: 具体消息内容
佩琪 :大佬,我突然记起,上次我们做的 订单算佣业务了,也是利用kafka监听订单数据变化,但是为什么没有使用固定分区方案了?
技术大佬 : 主要是我们上游业务方:把订单支付消息,和订单退款消息拆分为了两个topic,这个从使用固定分区方案的前提里就否定了,我们不能使用此方案。
佩琪 :哦哦,那我们是怎么去解决这个乱序的问题的了?
技术大佬 :主要是根据自身业务实际特性;使用了数据库乐观锁的思想,解决先发后至,后发先至这种数据乱序问题。
大概的流程如下图:
佩琪 :摸了摸头,大佬这个自身业务的特性是啥了?
技术大佬 :我们算佣业务,主要关注订单的两个状态,一个是订单支付状态,一个是订单退款状态。
订单退款发生时间肯定是在订单支付后;而上游订单业务是能保证这两个业务在时间发生上的前后顺序的,即订单的支付时间,肯定是早于订单退款时间。所以主要是利用订单ID+订单更新时间戳,做为数据库佣金表的更新条件,进行数据的乱序处理。
佩琪 : 哦哦,能详细说说 这个数据库乐观锁是怎么解决这个乱序问题吗?
技术大佬 : 比如:当佣金表里订单数据更新时间大于更新条件时间 就放弃本次更新,表明消息数据是个老数据;即查询时不加锁;
技术大佬 :而小于更新条件时间的,表明是个订单新数据,进行数据更新。即在更新时 利用数据库的行锁,来保证并发更新时的情况。即真实发生修改时加锁。
佩琪 :哦哦,明白了。原来一条带条件更新的sql,就具备了乐观锁思想。
技术大佬 :我们算佣业务其实是只关注佣金的最终状态,不关注中间状态;所以能用这种方式,保证算佣数据的最终一致性,而不用太关注订单的中间状态变化,导致佣金的中间变化。
总结
要想保证消息顺序消费大概有两种方案
固定分区方案
1、生产端指定同一类业务消息,往同一个分区发送。比如指定发送key为订单号,这样同一个订单号的消息,都会发送同一个分区
2、消费端单线程进行消费
乐观锁实现方案
如果上游不能保证生产的顺序;可让上游加上数据更新时间;利用唯一ID+数据更新时间,+乐观锁思想,保证业务数据处理的最终一致性。
原创不易,请 点赞,留言,关注,收藏 4暴击^^
天冷了,多年不下雪的北京,下了一场好大的雪。如果暴击不能让您动心,请活动下小手
佩琪正在参与 掘金2023年度人气创作者打榜中,感谢掘友们的支持,为佩琪助助力,也是对我文章输出的鼓励和支持 ~ ~ 万分感谢 activity.juejin.cn/rank/2023/w…
来源:juejin.cn/post/7303833186068086819
前端自动化部署的极简方案
打开服务器连接,找到文件夹,删掉,找到打包的目录,ctrl + C, ctrl + v 。。。。
烦的要死。。内网开发,node 的 ssh2 依赖库一时半会还导不进来。
索性,自己写一个!
原生 NodeJS 代码,不需要引用任何第三方库 win10 及以上版本,系统自带ssh 命令行工具,如果没有,需要自行安装
首先,需要生成本地秘钥;
ssh-keygen
执行上述命令后,系统会提示你输入文件保存位置和密码,如果你想使用默认位置和密码,直接按回车接受即可。这将在默认的SSH目录~/.ssh/
下生成两个文件:id_rsa
(私钥)和id_rsa.pub
(公钥)
开启服务端使用秘钥登录
一般文件位置位于 /etc/ssh/sshd_config 中,
找到下面两行 ,取消注释,值改为 yes
RSAAuthentication yes
PubkeyAuthentication yes
将秘钥添加到服务端:
打开服务端文件 /root/.ssh/authorized_keys 将公钥 粘贴到新的一行中
重启服务端 ssh 服务
sudo service ssh restart
编写自动化上传脚本(nodejs 脚本)
// 创建文件 ./build/Autoactic.js
const { exec, spawn } = require('child_process');
const fs= require('fs');
// C:/Users/admin/.ssh/server/ServiceOptions.json
// 此处储存本地连接服务端相关配置数据(目的为不将秘钥暴露给 Git 提交代码)
// {
// 服务端关键字(记录为哪个服务器)
// "Test90": {
// 服务器登录用户名
// "Target": "root@255.255.255.255",
// 本地证书位置(秘钥)
// "Pubkey": "C:/User/admin/.shh/server/file"
// }
// }
// 温馨提示 本机储存的秘钥需要调整权限,需要删除除了自己以外其他的全部用户
const ServiceOption = Json.parse(fs.readFileSync("C:/Users/admin/.ssh/server/ServiceOptions.json"), "utf-8");
// 本地项目文件路径(dist 打包后的实际路径)
const LocalPath = "D:/Code/rmgm/jilinres/jprmcrm/dev/admin/dist";
// 服务端项目路径
const ServerPath = "/home/rmgmuser/web/pmr";
// 运行单行命令 (scp 命令,上传文件使用)
const RunSSHCode = function (code) {
return new Promise((resolve, reject) => {
const process = exec(code, (error, sodut, stderr) => {
if (error) {
console.error(`执行错误: ${error}`)
reject();
return;
};
console.log(`sodut:${sodut}`);
if (stderr) {
console.error(`stderr:${stderr}`)
};
if (process && !process.killed){
process.kill();
};
setTimeout(()=>{
resolve();
},10);
})
})
}
// 执行服务端命令 (执行 ssh 命令行)
const CommandHandle(command) {
return new Promise((resolve, reject) => {
const child = spawn('ssh', ['-i', ServiceOption.Test90.Pubkey, '-o', 'StrictHostKeyChecking=no', ServiceOption.Test90.Target], {
stdio: ['pipe']
});
child.on('close',(err)=>{
console.log(`--close--:${err}`);
if (err === 0) {
setTimeout(()=>{ resolve() },10)
} else {
reject();
}
})
child.on('error',(err)=>{
console.error(`--error--:${err}`)
});
console.log(`--command--:${command}`);
child.stdin.end(command);
child.stdout.on('data',(data)=>{
console.log(`Stdout:${data}`);
})
child.stderr.on('data',(data)=>{
console.log(`Stdout:${data}`);
})
}
};
// 按照顺序执行代码
!(async function (CommandHandle, RunSSHCode){
try {
console.log(`创建文件夹 => ${ServerPath}`);
await CommandHandle(`mkdir -p ${ServerPath}`);
console.log(`创建文件夹 => ${ServerPath} => 完成`);
console.log(`删除历史文件 => ${ServerPath}`);
await CommandHandle(`find ${ServerPath} -type d -exec rm -rf {} +`);
console.log(`删除历史文件 => ${ServerPath} => 完成`);
console.log(`上传本地文件 => 从 本地 ${LocalPath} 到 服务端 ${ServerPath}`);
await RunSSHCode(`scp -i ${serviceOption.Test90.pubkey} -r ${LocalPath}/ ${serviceOption.Test90.Target}:${ServerPath}/`);
console.log(`上传本地文件 => 从 本地 ${LocalPath} 到 服务端 ${ServerPath} => 完成`);
// 吉林环境个性配置 非必须(更改访问权限)
console.log(`更改访问权限 => ${ServerPath}`);
await CommandHandle(`chown -R rmgmuser:rmgmuser ${ServerPath}`);
console.log(`更改访问权限 => ${ServerPath} => 完成`);
} catch (error) {
console.error(`---END---`, error)
}
})(CommandHandle, RunSSHCode)
更改打包命令:
// package.json
{
// ....
"scripts": {
// .....
"uploadFile" : "node ./build/Autoactic.js"
// 原始的 build 改为 prebuild 命令
"preBuild" : "vue-cli-service build --mode production"
// npm 按顺序运行多个命令行
"build" : "npm run preBuild && npm run uploadFile"
// .....
}
//...
}
效果 打包结束后,直接上传到服务端。
有特殊需求,例如重启服务等,可自行添加。
来源:juejin.cn/post/7431591478508748811
小项目自动化部署用 Jenkins 太麻烦了怎么办
导读
本文介绍用 Webhooks 代替 Jenkins 更简单地实现自动化部署。不论用 Jenkins 还是 Webhooks,都需要一定的服务端基础。
Webhooks 的使用更简单,自然功能就不如 Jenkins 丰富,因此更适合小项目。
背景
笔者一直在小厂子做小项目,只做前端的时候,部署项目就是 npm run build
然后压缩发给后端。后来到另一个小厂子做全栈,开始自己部署,想着捣鼓一下自动化部署。
Jenkins 是最流行的自动化部署工具,但是弄到一半我头都大了。我只是想部署一个小项目而已,结果安装、配置、启动 Jenkins 这工作量好像比我手动部署还大呢,必须找个更简单的办法才行。果然经过一番捣鼓,发现 Webhooks 又简单又实用,更适合我们小厂子小项目。
原理
首先我们的项目应该都放在 Git 平台上,主流的 Git 平台上都有 Webhooks。它的作用是:在你推送代码、发布版本等操作时,自动向你提供的地址发一个请求。
你的服务器收到这个请求后,要做的事情就是调用一段事先写好的脚本。这段脚本的任务是拉取最新代码、安装依赖(可选)、打包项目、重新启动项目。
这样当你在 Git 平台上发布版本后,服务器就会自动部署最新代码了。
实现
实现步骤可以和上面的原理反着来:先写好脚本,然后启动服务,最后创建 Webhooks。
在此之前,你的服务器需要先安装 Git,并能够拉取你的代码。这部分内容很常规,看官可以在其他地方搜到。
1. 自动部署脚本
Nuxt
自动部署脚本就是代替我们手动打包、部署的工作。在 Linux 中,它应该写在一个 .sh 文件里。我的前端项目用 Nuxt 开发,脚本主要内容如下:
# 进入项目文件
cd /usr/local/example
# 拉取最新代码
git pull
# 打包
npm run build
# 重启服务
pm2 reload ecosystem.config.js
你可以在 Git 上随便更新点内容,然后在 XShell 或其他工具打开服务器控制台,执行这段代码,然后到线上版本看更新有没有生效。
笔记一开始经过了一番折腾,发现最好得记录部署日志,那样方便排查问题。完整脚本如下:
# 日志文件路径
LOG_FILE="/usr/local/example/$(date).txt"
# 记录开始时间
echo "Deployment started at $(date)" > $LOG_FILE
# 进入项目文件
cd /usr/local/example
# 拉取最新代码
git pull >> $LOG_FILE 2>&1
# 打包
npm run build >> $LOG_FILE 2>&1
# 重启服务
pm2 reload ecosystem.config.js >> $LOG_FILE 2>&1
# 记录结束时间
echo "Deployment finished at $(date)" >> $LOG_FILE
Eggjs
笔者后端用了 Eggjs,其自动部署脚本如下:
# 日志文件
LOG_FILE="/usr/local/example/$(date).txt"
# 记录开始时间
echo "Deployment started at $(date)" > $LOG_FILE
# 进入项目文件
cd /usr/local/example
# 拉取最新代码
git pull >> $LOG_FILE 2>&1
# Egg 没有重启命令,要先 stop 再 start
npm stop >> $LOG_FILE 2>&1
npm start >> $LOG_FILE 2>&1
# 记录结束时间
echo "Deployment finished at $(date)" >> $LOG_FILE
Eggjs 项目没有构建的步骤,其依赖要事先安装好。因此如果开发过程中安装了新依赖,记得到服务端安装一下。
Midwayjs
由于 Eggjs 对 TypeScript 的支持比较差,笔者后来还用了 Midwayjs 来开发服务端,其脚本如下:
# 日志文件
LOG_FILE="/usr/local/example/$(date).txt"
# 记录开始时间
echo "Deployment started at $(date)" > $LOG_FILE
# 进入项目文件
cd /usr/local/example
# 拉取最新代码
git pull >> $LOG_FILE 2>&1
# 重装依赖
export NODE_ENV=development
npm install >> $LOG_FILE 2>&1
# 构建
npm run build >> $LOG_FILE 2>&1
# 移除开发依赖
npm prune --omit=dev >> $LOG_FILE 2>&1
# 启动服务
npm start >> $LOG_FILE 2>&1
# 记录结束时间
echo "Deployment finished at $(date)" >> $LOG_FILE
Midwayjs 的自动部署脚本比较特殊:在 npm install
之前需要先指定环境为 development
,那样才会安装所有依赖,否则会忽略 devDependencies
中的依赖,导致后面的 npm run build
无法执行。这点也是费了笔者好长时间才排查清楚,因为它在 XShell 里执行的时候默认的环境就是 development
,但是到了 Webhooks 调用的时候又变成了 product
。
2. 启动一个独立的小服务
上面这些脚本,应该由一个独立的小服务来执行。笔者一开始让项目的 Eggjs 服务来执行,也就是想让 Eggjs 服务自动部署自己,就失败了。原因是:脚本在执行到 npm stop
时,Eggjs 服务把自己关掉了,自然就执行不了 npm start
。
笔者启动了一个新的 Eggjs 服务来实现这个功能,使用其他语言、框架同理。其中执行脚本的控制器代码如下:
const { Controller } = require('egg');
const { exec } = require('child_process');
class EggController extends Controller {
async index() {
const { ctx } = this;
try {
// 执行 .sh 脚本
await exec('sh /usr/local/example/egg.sh');
ctx.body = {
'msg': 'Deployment successful'
};
} catch (error) {
ctx.body = {
'msg': 'Deployment failed:' + JSON.stringify(error)
};
}
}
}
module.exports = EggController;
如果启动成功,你应该可以在 Postman 之类的工具上发起这个控制器对应的请求,然后成功执行里面的 .sh 脚本。
注意这些请求必须是 POST 请求。
3. 到 Git 平台创建 Webhooks
笔者用的是GitCode,其他平台类似。到代码仓库 -> 项目设置 -> WebHook 菜单 -> 新建 Webhook:
- URL:上面独立小服务的请求地址;
- Token:在 Git 平台生成即可;
- 事件类型:我希望是发布版本的时候触发,所以选
Tag推送事件
。
创建好之后,激活这个 hook,然后随便提交些新东西,到代码仓库 -> 代码 -> 创建发行版:
填写版本号、版本描述后,滑到底部,勾选“最新版本”,点击发布按钮。
这样就能触发前面创建的 WebHook,向你的独立小服务发送请求,小服务就会去调用自动部署脚本。
怎么样,是不是比 Jenkins 简单太多了。当然功能也比 Jenkins 简单太多,但是对小厂子小项目来说,也是刚好够用。
来源:juejin.cn/post/7406238334215520291
go的生态真的一言难尽
前言
标题党了,原生go很好用,只不过我习惯了java封装大法。最近在看golang,因为是javaer,所以突发奇想,不如开发一个类似于 Maven 或 Gradle 的构建工具来管理 Go 项目的依赖,众所周知,构建和发布是一个复杂的任务,但通过合理的设计和利用现有的工具与库,可以实现一个功能强大且灵活的工具。
正文分为两部分:项目本身和如何使用
一、项目本身
1. 项目需求分析
核心需求
- 依赖管理:
- 解析和下载 Go 项目的依赖。
- 支持依赖版本控制和冲突解决。
- 构建管理:
- 支持编译 Go 项目。
- 支持跨平台编译。
- 支持自定义构建选项。
- 发布管理:
- 打包构建结果。
- 支持发布到不同的平台(如 Docker Hub、GitHub Releases)。
- 任务管理:
- 支持定义和执行自定义任务(如运行测试、生成文档)。
- 插件系统:
- 支持扩展工具的功能。
可选需求
- 缓存机制:缓存依赖和构建结果以提升速度。
- 并行执行:支持并行下载和编译。
2. 技术选型
2.1 编程语言
- Go 语言:由于我们要构建的是 Go 项目的构建工具,选择 Go 语言本身作为开发语言是合理的。
2.2 依赖管理
- Go Modules:Go 自带的依赖管理工具已经很好地解决了依赖管理的问题,可以直接利用 Go Modules 来解析和管理依赖。
2.3 构建工具
- Go 标准库:Go 的标准库提供了强大的编译和构建功能(如
go build
,go install
等命令),可以通过调用这些命令或直接使用相关库来进行构建。
2.4 发布工具
- Docker:对于发布管理,可能需要集成 Docker 来构建和发布 Docker 镜像。
- upx:用于压缩可执行文件。
2.5 配置文件格式
- YAML 或 TOML:选择一种易于阅读和编写的配置文件格式,如 YAML 或 TOML。
3. 系统架构设计
3.1 模块划分
- 依赖管理模块:
- 负责解析和下载项目的依赖。
- 构建管理模块:
- 负责编译 Go 项目,支持跨平台编译和自定义构建选项。
- 发布管理模块:
- 负责将构建结果打包和发布到不同平台。
- 任务管理模块:
- 负责定义和执行自定义任务。
- 插件系统:
- 提供扩展点,允许用户编写插件来扩展工具的功能。
3.2 系统流程
- 初始化项目:读取配置文件,初始化项目环境。
- 依赖管理:解析依赖并下载。
- 构建项目:根据配置文件进行项目构建。
- 执行任务:执行用户定义的任务(如测试)。
- 发布项目:打包构建结果并发布到指定平台。
4. 模块详细设计与实现
4.1 依赖管理模块
4.1.1 设计
利用 Go Modules 现有的功能来管理依赖。可以通过 go list
命令来获取项目的依赖:
4.1.2 实现
package dependency
import (
"fmt"
"os/exec"
)
// ListDependencies 列出项目所有依赖
func ListDependencies() ([]byte, error) {
cmd := exec.Command("go", "list", "-m", "all")
return cmd.Output()
}
// DownloadDependencies 下载项目所有依赖
func DownloadDependencies() error {
cmd := exec.Command("go", "mod", "download")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("download failed: %s", output)
}
return nil
}
4.2 构建管理模块
4.2.1 设计
调用 Go 编译器进行构建,支持跨平台编译和自定义构建选项。
4.2.2 实现
package build
import (
"fmt"
"os/exec"
"runtime"
"path/filepath"
)
// BuildProject 构建项目
func BuildProject(outputDir string) error {
// 设置跨平台编译参数
var goos, goarch string
switch runtime.GOOS {
case "windows":
goos = "windows"
case "linux":
goos = "linux"
default:
goos = runtime.GOOS
}
goarch = "amd64"
output := filepath.Join(outputDir, "myapp")
cmd := exec.Command("go", "build", "-o", output, "-ldflags", "-X main.version=1.0.0")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("build failed: %s", output)
}
fmt.Println("Build successful")
return nil
}
4.3 发布管理模块
4.3.1 设计
打包构建结果并发布到不同平台。例如,构建 Docker 镜像并发布到 Docker Hub。
4.3.2 实现
package release
import (
"fmt"
"os/exec"
)
// BuildDockerImage 构建 Docker 镜像
func BuildDockerImage(tag string) error {
cmd := exec.Command("docker", "build", "-t", tag, ".")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("docker build failed: %s", output)
}
fmt.Println("Docker image built successfully")
return nil
}
// PushDockerImage 推送 Docker 镜像
func PushDockerImage(tag string) error {
cmd := exec.Command("docker", "push", tag)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("docker push failed: %s", output)
}
fmt.Println("Docker image pushed successfully")
return nil
}
5. 任务管理模块
允许用户定义和执行自定义任务:
package task
import (
"fmt"
"os/exec"
)
type Task func() error
func RunTask(name string, task Task) {
fmt.Println("Running task:", name)
err := task()
if err != nil {
fmt.Println("Task failed:", err)
return
}
fmt.Println("Task completed:", name)
}
func TestTask() error {
cmd := exec.Command("go", "test", "./...")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("tests failed: %s", output)
}
fmt.Println("Tests passed")
return nil
}
6. 插件系统
可以通过动态加载外部插件或使用 Go 插件机制来实现插件系统:
package plugin
import (
"fmt"
"plugin"
)
type Plugin interface {
Run() error
}
func LoadPlugin(path string) (Plugin, error) {
p, err := plugin.Open(path)
if err != nil {
return nil, err
}
symbol, err := p.Lookup("PluginImpl")
if err != nil {
return nil, err
}
impl, ok := symbol.(Plugin)
if !ok {
return nil, fmt.Errorf("unexpected type from module symbol")
}
return impl, nil
}
5. 示例配置文件
使用 YAML 作为配置文件格式,定义项目的构建和发布选项:
name: myapp
version: 1.0.0
build:
options:
- -ldflags
- "-X main.version=1.0.0"
release:
docker:
image: myapp:latest
tag: v1.0.0
tasks:
- name: test
command: go test ./...
6. 持续改进
后续我将持续改进工具的功能和性能,例如:
- 增加更多的构建和发布选项。
- 优化依赖管理和冲突解决算法。
- 提供更丰富的插件。
二、如何使用
1. 安装构建工具
我已经将构建工具发布到 GitHub 并提供了可执行文件,用户可以通过以下方式安装该工具。
1.1 使用安装脚本安装
我将提供一个简单的安装脚本,开发者可以通过 curl
或 wget
安装构建工具。
使用 curl
安装
curl -L https://github.com/yunmaoQu/GoForge/releases/download/v1.0.0/install.sh | bash
使用 wget
安装
wget -qO- https://github.com//yunmaoQu/GoForge/releases/download/v1.0.0/install.sh | bash
1.2 手动下载并安装
如果你不想使用自动安装脚本,可以直接从 GitHub Releases 页面手动下载适合你操作系统的二进制文件。
- 访问 GitHub Releases 页面。
- 下载适合你操作系统的二进制文件:
- Linux:
GoForge-linux-amd64
- macOS:
GoForge-darwin-amd64
- Windows:
GoForge-windows-amd64.exe
- Linux:
- 将下载的二进制文件移动到系统的 PATH 路径(如
/usr/local/bin/
),并确保文件有执行权限。
# 以 Linux 系统为例
mv GoForge-linux-amd64 /usr/local/bin/GoForge
chmod +x /usr/local/bin/GoForge
2. 创建 Go 项目并配置构建工具
2.1 初始化 Go 项目
假设你已经有一个 Go 项目或你想创建一个新的 Go 项目。首先,初始化 Go 模块:
mkdir my-go-project
cd my-go-project
go mod init github.com/myuser/my-go-project
2.2 创建 build.yaml
文件
在项目根目录下创建 build.yaml
文件,这个文件类似于 Maven 的 pom.xml
或 Gradle 的 build.gradle
,用于配置项目的依赖、构建任务和发布任务。
示例 build.yaml
project:
name: my-go-project
version: 1.0.0
dependencies:
- name: github.com/gin-gonic/gin
version: v1.7.7
- name: github.com/stretchr/testify
version: v1.7.0
build:
output: bin/my-go-app
commands:
- go build -o bin/my-go-app main.go
tasks:
clean:
command: rm -rf bin/
test:
command: go test ./...
build:
dependsOn:
- test
command: go build -o bin/my-go-app main.go
publish:
type: github
repo: myuser/my-go-project
token: $GITHUB_TOKEN
assets:
- bin/my-go-app
配置说明:
- project: 定义项目名称和版本。
- dependencies: 列出项目的依赖包及其版本号。
- build: 定义构建输出路径和构建命令。
- tasks: 用户可以定义自定义任务(如
clean
、test
、build
等),并可以配置任务依赖关系。 - publish: 定义发布到 GitHub 的配置,包括发布的仓库和需要发布的二进制文件。
3. 执行构建任务
构建工具允许你通过命令行执行各种任务,如构建、测试、清理、发布等。以下是一些常用的命令。
3.1 构建项目
执行以下命令来构建项目。该命令会根据 build.yaml
文件中定义的 build
任务进行构建,并生成二进制文件到指定的 output
目录。
GoForge build
构建过程会自动执行依赖任务(例如 test
任务),确保在构建之前所有测试通过。
3.2 运行测试
如果你想单独运行测试,可以使用以下命令:
GoForge test
这将执行 go test ./...
,并运行所有测试文件。
3.3 清理构建产物
如果你想删除构建生成的二进制文件等产物,可以运行 clean
任务:
GoForge clean
这会执行 rm -rf bin/
,清理 bin/
目录下的所有文件。
3.4 列出所有可用任务
如果你想查看所有可用的任务,可以运行:
GoForge tasks
这会列出 build.yaml
文件中定义的所有任务,并显示它们的依赖关系。
4. 依赖管理
构建工具会根据 build.yaml
中的 dependencies
部分来处理 Go 项目的依赖。
4.1 安装依赖
当执行构建任务时,工具会自动解析依赖并安装指定的第三方库(类似于 go mod tidy
)。
你也可以单独运行以下命令来手动处理依赖:
GoForge deps
4.2 更新依赖
如果你需要更新依赖版本,可以在 build.yaml
中手动更改依赖的版本号,然后运行 mybuild deps
来更新依赖。
5. 发布项目
构建工具提供了发布项目到 GitHub 等平台的功能。根据 build.yaml
中的 publish
配置,你可以将项目的构建产物发布到 GitHub Releases。
5.1 配置发布相关信息
确保你在 build.yaml
中正确配置了发布信息:
publish:
type: github
repo: myuser/my-go-project
token: $GITHUB_TOKEN
assets:
- bin/my-go-app
- type: 发布的目标平台(GitHub 等)。
- repo: GitHub 仓库路径。
- token: 需要设置环境变量
GITHUB_TOKEN
,用于认证 GitHub API。 - assets: 指定发布时需要上传的二进制文件。
5.2 发布项目
确保你已经完成构建,并且生成了二进制文件。然后,你可以执行以下命令来发布项目:
GoForge publish
这会将 bin/my-go-app
上传到 GitHub Releases,并生成一个新的发布版本。
5.3 测试发布(Dry Run)
如果你想在发布之前测试发布流程(不上传文件),可以使用 --dry-run
选项:
GoForge publish --dry-run
这会模拟发布过程,但不会实际上传文件。
6. 高级功能
6.1 增量构建
构建工具支持增量构建,如果你在 build.yaml
中启用了增量构建功能,工具会根据文件的修改时间戳或内容哈希来判断是否需要重新构建未被修改的部分。
build:
output: bin/my-go-app
incremental: true
commands:
- go build -o bin/my-go-app main.go
6.2 插件机制
你可以通过插件机制来扩展构建工具的功能。例如,你可以为工具增加自定义的任务逻辑,或在构建生命周期的不同阶段插入钩子。
在 build.yaml
中定义插件:
plugins:
- name: custom-task
path: plugins/custom-task.go
编写 custom-task.go
,并实现你需要的功能。
7. 调试和日志
如果你在使用时遇到了问题,可以通过以下方式启用调试模式,查看详细的日志输出:
GoForge --debug build
这会输出工具在执行任务时的每一步详细日志,帮助你定位问题。
总结
通过这个构建工具,你可以轻松管理 Go 项目的依赖、构建过程和发布任务。以下是使用步骤的简要总结:
- 安装构建工具:使用安装脚本或手动下载二进制文件。
- 配置项目:创建
build.yaml
文件,定义依赖、构建任务和发布任务。 - 执行任务:通过命令行执行构建、测试、清理等任务。
- 发布项目:将项目的构建产物发布到 GitHub 或其他平台。
来源:juejin.cn/post/7431545806085423158
白嫖微信OCR,实现批量提取图片中的文字
微信自带的OCR使用比较方便,且准确率较高,但是唯一不足的是需要手动截图之后再识别,无法批量操作,这里我们借助wechat-ocr这一开源工具,实现批量读取文件夹下的所有图片并提取文本的功能。下面介绍下操作步骤。
1. 安装wechat-ocr这个库
这里我们使用的是GoBot这一自动化工具(如对该软件感兴趣,可以关注公众号:RPA二师兄),他提供的可视化安装依赖的功能。打开依赖包管理的Tab页,在Python包名称处填写wechat-ocr,然后点击安装,就能完成wechat-ocr的安装,安装完成之后可以切换到管理已安装模块的Tab,可以看到已经成功安装。
2. 编写调用代码
这里我们直接给出代码,只需要创建一个代码流程,将我们给的代码复制进去就可以了。
from package import variables as glv #全局变量,例如glv['test']
from robot_base import log_util
import robot_basic
from robot_base import log_util
import os
import re
from wechat_ocr.ocr_manager import OcrManager, OCR_MAX_TASK_ID
def main(args):
#输入参数使用示例
# if args is :
# 输入参数1 = ""
#else:
# 输入参数1 = args.get("输入参数1", "")
log_util.Logger.info(args)
init_ocr_manger(args['微信安装目录'])
ocr_manager.DoOCRTask(args['待识别图片路径'])
while ocr_manager.m_task_id.qsize() != OCR_MAX_TASK_ID:
pass
global ocr_result
return ocr_result
ocr_result = {}
ocr_manager =
def ocr_result_callback(img_path:str, results:dict):
log_util.Logger.info(results)
ocr_result = results
def init_ocr_manger(wechat_dir):
wechat_dir = find_wechat_path(wechat_dir)
wechat_ocr_dir = find_wechatocr_exe()
global ocr_manager
if ocr_manager is :
ocr_manager = OcrManager(wechat_dir)
# 设置WeChatOcr目录
ocr_manager.SetExePath(wechat_ocr_dir)
# 设置微信所在路径
ocr_manager.SetUsrLibDir(wechat_dir)
# 设置ocr识别结果的回调函数
ocr_manager.SetOcrResultCallback(ocr_result_callback)
# 启动ocr服务
ocr_manager.StartWeChatOCR()
def find_wechat_path(wechat_dir):
# 定义匹配版本号文件夹的正则表达式
version_pattern = re.compile(r'\[\d+\.\d+\.\d+\.\d+\]')
path_temp = os.listdir(wechat_dir)
for temp in path_temp:
# 下载是正则匹配到[3.9.10.27]
# 使用正则表达式匹配版本号文件夹
if version_pattern.match(temp):
wechat_path = os.path.join(wechat_dir, temp)
if os.path.isdir(wechat_path):
return wechat_path
def find_wechatocr_exe():
# 获取APPDATA路径
appdata_path = os.getenv("APPDATA")
if not appdata_path:
print("APPDATA environment variable not found.")
return
# 定义WeChatOCR的基本路径
base_path = os.path.join(appdata_path, r"Tencent\WeChat\XPlugin\Plugins\WeChatOCR")
# 定义匹配版本号文件夹的正则表达式
version_pattern = re.compile(r'\d+')
try:
# 获取路径下的所有文件夹
path_temp = os.listdir(base_path)
except FileNotFoundError:
print(f"The path {base_path} does not exist.")
return
for temp in path_temp:
# 使用正则表达式匹配版本号文件夹
if version_pattern.match(temp):
wechatocr_path = os.path.join(base_path, temp, 'extracted', 'WeChatOCR.exe')
if os.path.isfile(wechatocr_path):
return wechatocr_path
# 如果没有找到匹配的文件夹,返回
return
然后点击流程参数,创建流程的输入参数
3. 调用OCR识别的方法,实现批量的文字提取
使用调用流程组件,填写对应的参数,即可实现图片文字的提取了。
来源:juejin.cn/post/7432193949765287962
BOE(京东方)2024年前三季度净利润三位数增长 “屏之物联”引领企业高质发展
10月30日,京东方科技集团股份有限公司(京东方A:000725;京东方B:200725)发布2024年第三季度报告,前三季度公司实现营业收入1437.32亿元,较去年同期增长13.61%;归属于上市公司股东净利润33.10亿元,同比大幅增长223.80%。其中,第三季度实现营业收入503.45亿元,较去年同期增长8.65%;归属于上市公司股东净利润10.26亿元,同比增长258.21%。BOE(京东方)凭借稳健的经营策略和行业领先的技术优势,在保持半导体显示产业龙头地位的同时,持续推动“1+4+N+生态链”在各个细分市场的深度布局与成果落地,不断深化“屏之物联”战略在多业态场景的转化应用。面向下一个三十年,BOE(京东方)积极推动构建产业发展“第N曲线”,打造新的业务增长极,持续激发产业生态活力。
不仅业绩表现亮眼,BOE(京东方)还不断加大在前沿技术领域和物联网转型方面的投入与探索,为全球显示及物联网产业的未来发展注入新的活力。第三季度,BOE(京东方)全球创新伙伴大会成功举办,全面展示公司在前沿技术领域的重要突破以及物联网转型创新成果,并重磅发布了企业创新发展的战略升维“第N曲线”理论。这一理论不仅承载着企业文化的深厚底蕴,更是对核心优势资源的深度拓展,在“第N曲线”理论指导下,BOE(京东方)已在玻璃基、钙钛矿等新兴领域重点布局,其中,钙钛矿光伏电池中试线从设备搬入到首批样品产出,历时仅38天,创造了行业新记录,这一突破性进展标志着BOE(京东方)在钙钛矿光伏产业化道路上迈出了重要一步,以卓越的实力和高效的速度着力打造“第N曲线”关键增长极,持续引领行业走向智能化、可持续化发展。
稳居半导体显示领域龙头地位,技术创新引领行业发展
2024年前三季度,BOE(京东方)凭借前瞻性的全球市场布局,持续稳固半导体显示领域的龙头地位,不仅专利申请量保持全球领先,更有自主研发的ADS Pro顶尖技术引领行业发展,在柔性AMOLED市场也持续突破,各类技术创新成果丰硕。据市场调研机构Omdia数据显示,BOE(京东方)显示屏整体出货量和五大主流应用领域液晶显示屏出货量稳居全球第一。在专利方面,BOE(京东方)累计自主专利申请已超9万件,其中发明专利超90%,海外专利超30%,技术与产品创新能力稳步提升。同时,BOE(京东方)持续展现强大的创新实力和市场影响力,BOE(京东方)自主研发的、独有的液晶显示领域顶流技术ADS Pro,不仅是目前全球出货量最高的主流液晶显示技术,也是应用最广的硬屏液晶显示技术。凭借高环境光对比度、全视角无色偏、高刷新率和动态画面优化等方面的卓越性能表现,ADS Pro技术成为客户高端旗舰产品的首选,市场出货量和客户采纳度遥遥领先,展现了液晶显示技术蓬勃的生命力,更是极大推动了全球显示产业的良性健康发展。在柔性显示领域,2024年前三季度,BOE(京东方)柔性AMOLED产品出货量进一步增加,荣耀Magic6系列搭载BOE(京东方)首发的OLED低功耗解决方案,开启柔性OLED低功耗全新时代,获得市场和客户的广泛赞誉;与OPPO一加客户联合发布全新2K+LTPO全能高端屏幕标志着柔性显示的又一次全面技术革新,凭借在画质、性能及护眼等多方面的显著提升,再次定义高端柔性OLED屏幕行业新标准。同时,BOE(京东方)加快AMOLED产业布局,投建的国内首条第8.6代AMOLED生产线从开工到封顶仅用183天,以科学、高效、高质的速度树立行业新标杆,推动OLED显示产业快速迈进中尺寸发展阶段。
“1+4+N”业务布局成果显著,打造多元化发展格局
在持续深耕显示行业的同时,BOE(京东方)始终坚持创新发展,“1+4+N+生态链”业务也在创新技术的赋能下展现出全新活力,各个细分市场成果显著。BOE(京东方)物联网创新业务在智慧终端和系统方案两大领域持续高速发展,智慧终端领域,BOE(京东方)正式发布“Smart GOAL”业务目标,致力于打造软硬融合、服务全球、一站式、高效敏捷、绿色低碳的智造体系,并在白板、拼接、电子价签(ESL)等细分市场出货量保持全球第一(数据来源:迪显、Omdia等);系统方案领域,BOE(京东方)持续深耕智慧园区、智慧金融等多个物联网细分场景,积极拓展人机交互协同新边界。在传感业务方面,BOE(京东方)光幕技术及MEMS传感等技术加速赋能奇瑞汽车,推进汽车智能化转型;发布国内首个《乘用车用电子染料液晶调光玻璃技术规范》团体标准,并在智慧视窗领域超额完成极氪首款标配车型调光窗的量产交付,实现订单量增长200%,开启光幕技术创新与应用的新篇章;同时还在工业传感器领域导入6家战略客户,未来将在项目合作及产品研发等方面开展广泛合作。在MLED业务方面, BOE(京东方)MLED珠海项目全面启动,标志着公司在MLED领域进一步深入布局,为全球MLED市场的拓展奠定了坚实基础。在智慧医工业务方面,BOE(京东方)强化科技与医学相结合,打通“防治养”全链条,持续推动医疗健康服务的智慧化升级,成都京东方智慧医养社区正式投入运营,创新医养融合模式,成为BOE(京东方)布局智慧医养领域的重要里程碑;合肥京东方医院加入胸部肿瘤长三角联盟,携手优质专家资源造福当地患者;BOE(京东方)健康研究院与山西省肿瘤医院合作开展NK细胞治疗膀胱癌的临床研究,助力医疗技术创新。
BOE(京东方)还以“N”业务为着力点,为不同行业提供软硬融合的整体解决方案,包括智慧车载、数字艺术、AI+、超高清显示、智慧能源等多个细分领域,打造业务增长新曲线。在车载领域,BOE(京东方)持续保持车载显示屏出货量及出货面积全球第一(数据来源:Omdia),智能座舱产品全面应用到长安汽车、吉利汽车、蔚来、理想等全球各大主流汽车品牌中。在数字艺术领域,艺云科技在裸眼3D显示技术等方面取得新突破,裸眼3D屏亮相国家博物馆,艺云数字艺术中心(王府井)、艺云数字艺术中心(宜宾)正式开馆,创新显示技术为多个领域增光添彩。在AI+领域,BOE(京东方)已将人工智能技术产品、服务、解决方案应用于制造、办公、医疗、零售等细分场景,依托自主研发的人工智能平台及衍生技术集,打造AI良率分析系统、AI显示知识问答系统、显示工业大模型等,大幅度提高生产效率。在超高清领域,BOE(京东方)中联超清通过8K超高清显示技术、超薄全贴合电子站牌和户外LCD广告屏等产品,赋能合肥高新区公交站升级、成都双流国际机场T1航站楼,助力交通出行智能化服务水平大幅提升。在绿色发展方面,BOE(京东方)能源业务在工业、商业、园区等多个场景下加速推进零碳综合能源服务,成功落地多个能源托管项目和碳资产管理项目,助力社会实现二氧化碳减排约33万吨。
值得一提的是,BOE(京东方)在全球化布局与品牌建设的道路上也迈出了更加坚实的步伐。“你好,BOE”、《BOE解忧实验室》两大营销IP持续大热,BOE(京东方)年度标志性品牌活动“你好,BOE”首站亮相海外,助力中国非物质文化遗产艺术展览落地法国巴黎,向世界展示中国科技的创新活力;在上海北外滩盛大启幕的“你好,BOE”SUPER O SPACE影像科技展以“艺术x科技”为主题为观众带来了一场视觉盛宴,成为BOE(京东方)“屏之物联”战略赋能万千应用场景的又一次生动展现;《BOE解忧实验室》奇遇发现季节目以全网4.58亿传播的辉煌成绩,成为2024年度硬核技术科普综艺及科技企业破圈营销典范。2024年是体育大年,在巴黎全球体育盛会举办期间,BOE(京东方)还与联合国教科文组织(UNESCO)在法国巴黎总部签订合作协议,成为首个支持联合国“科学十年”的中国科技企业,更助力中国击剑队出征巴黎,在科技、体育、文化等多个维度树立中国科技企业出海的全新范式。
在新技术、新消费、新场景的多重驱动下,2024年前三季度,BOE(京东方)保持了稳健的发展态势,不断创新前沿技术成果,丰富多元应用场景,为半导体显示产业高质升维发展注入源源不断的动能。未来,BOE(京东方)将继续秉承“屏之物联”战略,以稳健的经营、前瞻性的技术研发和持续应用创新,携手全球合作伙伴共同构建“Powered by BOE”产业价值创新生态,推动显示技术、物联网技术与数字技术的深度融合,为显示行业高质量发展贡献力量,共创智慧美好未来。
开发小同学的骚操作,还好被我发现了
大家好,我是程序员鱼皮。今天给朋友们还原一个我们团队真实的开发场景。
开发现场
最近我们编程导航网站要开发 用户私信
功能,第一期要做的需求很简单:
- 能让两个用户之间 1 对 1 单独发送消息
- 用户能够查看到消息记录
- 用户能够实时收到消息通知
这其实是一个双向实时通讯的场景,显然可以使用 WebSocket 技术来实现。
团队的后端开发小 c 拿到需求后就去调研了,最后打算采用 Spring Boot Starter 快速整合 Websocket 来实现,接受前端某个用户传来的消息后,转发到接受消息的用户的会话,并在数据库中记录,便于用户查看历史。
小 c 的代码写得还是不错的,用了一些设计模式(像策略模式、工厂模式)对代码进行了一些抽象封装。虽然在我看来对目前的需求来说稍微有点过度设计,但开发同学有自己的理由和想法,表示尊重~
前端同学小 L 也很快完成了开发,并且通过了产品的验收。
看似这个需求就圆满地完成了,但直到我阅读前端同学的代码时,才发现了一个 “坑”。
这是前端同学小 L 提交的私信功能代码,看到这里我就已经发现问题了,朋友们能注意到么?
解释一下,小 L 引入了一个 nanoid
库,这个库的作用是生成唯一 id。看到这里,我本能地感到疑惑:为什么要引入这个库?为什么前端要生成唯一 id?
难道。。。是作为私信消息的 id?
果不其然,通过这个库在前端给每个消息生成了一个唯一 id,然后发送给后端。
后端开发的同学可能会想:一般情况下不都是后端利用数据库的自增来生成唯一 id 并返回给前端嘛,怎么需要让前端来生成呢?
这里小 L 的解释是,在本地创建消息的时候,需要有一个 id 来追踪状态,不会出现消息没有 id 的情况。
首先,这么做的确 能够满足需求 ,所以我还是通过了代码审查;但严格意义上来说,让前端来生成唯一 id 其实不够优雅,可能会有一些问题。
前端生成 id 的问题
1)ID 冲突:同时使用系统的前端用户可能是非常多的,每个用户都是一个客户端,多个前端实例可能会生成相同的 ID,导致数据覆盖或混乱。
2)不够安全:切记,前端是没有办法保证安全性的!因为攻击者可以篡改或伪造请求中的数据,比如构造一个已存在的 id,导致原本的数据被覆盖掉,从而破坏数据的一致性。
要做这件事成本非常低,甚至不需要网络攻击方面的知识,打开 F12 浏览器控制台,重放个请求就行实现:
3)时间戳问题:某些生成 id 的算法是依赖时间戳的,比如当前时间不同,生成的 id 就不同。但是如果前端不同用户的电脑时间不一致,就可能会生成重复 id 或无效 id。比如用户 A 电脑是 9 点时生成了 id = 06030901,另一个用户 B 电脑时间比 A 慢了一个小时,现在是 8 点,等用户 B 电脑时间为 9 点的时候,可能又生成了重复 id = 06030901,导致数据冲突。这也被称为 “分布式系统中的全局时钟问题”。
明确前后端职责
虽然 Nanoid 这个库不依赖时间戳来生成 id,不会受到设备时钟不同步的影响,也不会因为时间戳重复而导致 ID 冲突。根据我查阅的资料,生成大约 10 ^ 9 个 ID 后,重复的可能性大约是 10 ^ -17,几乎可以忽略不计。但一般情况下,我个人会更建议将业务逻辑统一放到后端实现,这么做的好处有很多:
- 后端更容易保证数据的安全性,可以对数据先进行校验再生成 id
- 前端尽量避免进行复杂的计算,而是交给后端,可以提升整体的性能
- 职责分离,前端专注于页面展示,后端专注于业务,而不是双方都要维护一套业务逻辑
我举个典型的例子,比如前端下拉框内要展示一些可选项。由于选项的数量并不多,前端当然可以自己维护这些数据(一般叫做枚举值),但后端也会用到这些枚举值,双方都写一套枚举值,就很容易出现不一致的情况。推荐的做法是,让后端返回枚举值给前端,前端不用重复编写。
所以一般情况下,对于 id 的生成,建议统一交给后端实现,可以用雪花算法根据时间戳生成,也可以利用数据库主键生成自增 id 或 UUID,具体需求具体分析吧~
来源:juejin.cn/post/7376148503087169562
在老的Node.js服务器里“加点Rust”,我的服务性能飙升近 80%
你有没有遇到过这样的情况?服务器跑着跑着就卡了,明明只是一些普通的操作,却让资源“飚红”,甚至快撑不住了。特别是当你用JavaScript或者Python这些脚本语言写的服务器,遇到CPU密集型任务时,性能瓶颈似乎更是无可避免。这时候,是不是觉得有点力不从心?
今天,我们安利一个解决方案——Rust!一种速度快、效率高的编程语言。它有点像是给你的Node.js或者Python服务器加了“肌肉”,尤其适合处理高强度的运算任务。下面,我就给大家讲讲如何一步步把Rust“融入”到现有的服务器里,用简单的策略大幅度提升性能。
引入Rust的三步策略
在这个策略中,我们从“0”开始,逐步引入Rust,分别通过Rust CLI工具和Wasm模块来提升服务器的性能。总的原则是:每一步都不搞大改动,让你的老服务器既能“焕发新生”,又能保持现有的代码框架。
第0步:从Node.js服务器开始
假设我们现在有一个Node.js服务器,用来生成二维码。这个需求其实并不复杂,但在高并发的情况下,这样的CPU密集型任务会让JavaScript显得吃力。
const express = require('express');
const generateQrCode = require('./generate-qr.js');
const app = express();
app.get('/qrcode', async (req, res) => {
const { text } = req.query;
if (!text) {
return res.status(400).send('missing "text" query param');
}
if (text.length > 512) {
return res.status(400).send('text must be <= 512 bytes');
}
try {
const qrCode = await generateQrCode(text);
res.setHeader('Content-Type', 'image/png');
res.send(qrCode);
} catch (err) {
res.status(500).send('failed generating QR code');
}
});
app.listen(42069, '127.0.0.1');
基准测试:在纯Node.js的情况下,这个服务每秒能处理1464个请求,内存占用也不小。虽然勉强能跑起来,但一旦用户多了,可能会明显感觉到卡顿。
第1步:引入Rust CLI工具,效率提升近80%
这里的策略是保留Node.js的框架不变,把处理二维码生成的那段代码用Rust写成一个独立的命令行工具(CLI)。在Node.js中,我们直接调用这个CLI工具,分担高强度的计算工作。
/** qr_lib/lib.rs **/
use qrcode::{QrCode, EcLevel};
use image::Luma;
use image::codecs::png::{CompressionType, FilterType, PngEncoder};
pub type StdErr = Box<dyn std::error::Error>;
pub fn generate_qr_code(text: &str) -> Result<Vec<u8>, StdErr> {
let qr = QrCode::with_error_correction_level(text, EcLevel::L)?;
let img_buf = qr.render::u8>>()
.min_dimensions(200, 200)
.build();
let mut encoded_buf = Vec::with_capacity(512);
let encoder = PngEncoder::new_with_quality(
&mut encoded_buf,
// these options were chosen since
// they offered the best balance
// between speed and compression
// during testing
CompressionType::Default,
FilterType::NoFilter,
);
img_buf.write_with_encoder(encoder)?;
Ok(encoded_buf)
}
效果:重写后,我们的处理性能直接飙升到了每秒2572个请求!这是一个显著的提升,更让人欣慰的是,内存占用也跟着降了下来。Rust的高效编译和内存管理,确实比JavaScript强太多了。
实现步骤:
- 首先,用Rust编写二维码生成的核心逻辑代码。
- 将这段Rust代码编译成一个可执行的CLI工具。
- 在Node.js代码中,通过子进程调用CLI工具,直接拿到生成的结果。
在Node.js中调用Rust CLI工具的代码示例如下:
const { exec } = require('child_process');
exec('./qr_generator_cli', (error, stdout, stderr) => {
if (error) {
console.error(`执行出错: ${error}`);
return;
}
console.log(`生成的二维码数据: ${stdout}`);
});
这个方法就像是给Node.js加了一个“外挂”,而且几乎不需要改动现有代码。也就是说,你可以在不动大框架的情况下,得到Rust的性能优势。
第2步:编译Rust到WebAssembly(Wasm),性能提升再进一步
在第1步中,我们通过CLI工具调用了Rust,但依旧会产生一定的通信开销。所以,接下来,我们可以进一步优化,将Rust代码编译成WebAssembly(Wasm)模块,并在Node.js中直接调用它。这样,整个过程就在内存中运行,不用通过子进程调用CLI,速度进一步提升。
效果:使用Wasm后,处理性能再上升到了每秒2978个请求,而内存使用依旧维持在较低水平。
实现步骤:
- 将Rust代码编译为Wasm模块。可以使用
wasm-pack
这样的工具来帮助生成。
- 将Rust代码编译为Wasm模块。可以使用
- 在Node.js中,通过
wasm-bindgen
等工具直接加载并调用Wasm模块。
- 在Node.js中,通过
Node.js中加载Wasm模块的代码示例如下:
const fs = require('fs');
const wasmBuffer = fs.readFileSync('./qr_generator_bg.wasm');
WebAssembly.instantiate(wasmBuffer).then(wasmModule => {
const qrGenerator = wasmModule.instance.exports.qr_generate;
console.log(qrGenerator('Hello, Rust with Wasm!'));
});
这种方法让我们完全绕过了CLI的通信环节,直接把Rust的性能用在Node.js中。这不仅提升了效率,还让代码更加紧凑,减少了延迟。
思考
通过以上三步策略,我们可以在不完全推翻现有代码的前提下,逐步引入Rust,极大地提升服务器的性能。这个过程既适用于Node.js,也可以推广到其他语言和环境中。
为什么这个方法特别值得尝试呢?首先,它成本低。你不需要重写整个系统,只需要对瓶颈部分进行改进。其次,效果明显,尤其是对那些经常“吃力”的功能。最后,这个方法是可扩展的,你可以根据实际情况,灵活选择用CLI还是Wasm的方式来引入Rust。
所以,如果你的服务器正被性能问题困扰,不妨试试这个三步引Rust法。正如一位资深开发者所说:“Rust不仅让你的服务器跑得更快,还让代码变得更加优雅。”
来源:juejin.cn/post/7431091997114843151
如何优雅的将MultipartFile和File互转
我们在开发过程中经常需要接收前端传来的文件,通常需要处理MultipartFile格式的文件。今天来介绍一下MultipartFile和File怎么进行优雅的互转。
前言
首先来区别一下MultipartFile和File:
- MultipartFile是 Spring 框架的一部分,File是 Java 标准库的一部分。
- MultipartFile主要用于接收上传的文件,File主要用于操作系统文件。
MultipartFile转换为File
使用 transferTo
这是一种最简单的方法,使用MultipartFile自带的transferTo 方法将MultipartFile转换为File,这里通过上传表单文件,将MultipartFile转换为File格式,然后输出到特定的路径,具体写法如下。
使用 FileOutputStream
这是最常用的一种方法,使用 FileOutputStream 可以将字节写入文件。具体写法如下。
使用 Java NIO
Java NIO 提供了文件复制的方法。具体写法如下。
File装换为MultipartFile
从File转换为MultipartFile 通常在测试或模拟场景中使用,生产环境一般不这么用,这里只介绍一种最常用的方法。
使用 MockMultipartFile
在转换之前先确保引入了spring-test 依赖(以Maven举例)
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId>
<version>version</version>
<scope>test</scope>
</dependency>
通过获得File文件的名称、mime类型以及内容将其转换为MultipartFile格式。具体写法如下。
更多文章干货,推荐公众号【程序员老J】
来源:juejin.cn/post/7295559402475667492
mysql到底是join性能好,还是in一下更快呢?
大家好呀,我是楼仔。
今天发现一篇很有意思的文章,使用 mysql 查询时,是使用 join 好,还是直接 in 更好,这个大家工作时经常遇到。
为了方便大家查看,文章我重新进行了排版。
我没有直接用作者的结论,感觉可能会误导读者,而是根据实验结果,给出我自己的建议。
不 BB,上目录:
01 背景
事情是这样的,去年入职的新公司,之后在代码 review 的时候被提出说,不要写 join,join 耗性能还是慢来着,当时也是真的没有多想,那就写 in 好了。
最近发现 in 的数据量过大的时候会导致 sql 慢,甚至 sql 太长,直接报错了。
这次来浅究一下,到底是 in 好还是 join 好,仅目前认知探寻,有不对之处欢迎指正。
以下实验仅在本机电脑试验。
02 表结构
2.1 用户表
CREATE TABLE `user` (
`id` int NOT NULL AUTO_INCREMENT,
`name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '姓名',
`gender` smallint DEFAULT NULL COMMENT '性别',
`mobile` varchar(11) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '手机号',
`create_time` datetime NOT NULL COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `mobile` (`mobile`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1005 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
2.2 订单表
CREATE TABLE `order` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`price` decimal(18,2) NOT NULL,
`user_id` int NOT NULL,
`product_id` int NOT NULL,
`status` smallint NOT NULL DEFAULT '0' COMMENT '订单状态',
PRIMARY KEY (`id`),
KEY `user_id` (`user_id`),
KEY `product_id` (`product_id`)
) ENGINE=InnoDB AUTO_INCREMENT=202 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
03 千条数据情况
数据量:用户表插一千条随机生成的数据,订单表插一百条随机数据
要求:查下所有的订单以及订单对应的用户
耗时衡量指标:多表连接查询成本 = 一次驱动表成本 + 从驱动表查出的记录数 * 一次被驱动表的成本
3.1 join
select order.id, price, user.name from order join user on order.user_id = user.id;
3.2 in
select id,price,user_id from order;
select name from user where id in (8, 11, 20, 32, 49, 58, 64, 67, 97, 105, 113, 118, 129, 173, 179, 181, 210, 213, 215, 216, 224, 243, 244, 251, 280, 309, 319, 321, 336, 342, 344, 349, 353, 358, 363, 367, 374, 377, 380, 417, 418, 420, 435, 447, 449, 452, 454, 459, 461, 472, 480, 487, 498, 499, 515, 525, 525, 531, 564, 566, 580, 584, 586, 592, 595, 610, 633, 635, 640, 652, 658, 668, 674, 685, 687, 701, 718, 720, 733, 739, 745, 751, 758, 770, 771, 780, 806, 834, 841, 856, 856, 857, 858, 882, 934, 942, 983, 989, 994, 995);
其中 in 的是order查出来的所有用户 id。
如此看来,分开查和 join 查的成本并没有相差许多。
3.3 并发场景
主要用php原生写了脚本,用ab进行10个同时的请求,看下时间,进行比较。
> ab -n 100 -c 10 // 执行脚本
下面是 join 查询的执行脚本:
$mysqli = new mysqli('127.0.0.1', 'root', 'root', 'test');
if ($mysqli->connect_error) {
die('Connect Error (' . $mysqli->connect_errno . ') ' . $mysqli->connect_error);
}
$result = $mysqli->query('select order.id, price, user.`name` from `order` join user on order.user_id = user.id;');
$orders = $result->fetch_all(MYSQLI_ASSOC);
var_dump($orders);
$mysqli->close();
下面是 in 查询的执行脚本:
$mysqli = new mysqli('127.0.0.1', 'root', 'root', 'test');
if ($mysqli->connect_error) {
die('Connect Error (' . $mysqli->connect_errno . ') ' . $mysqli->connect_error);
}
$result = $mysqli->query('select `id`,price,user_id from `order`');
$orders = $result->fetch_all(MYSQLI_ASSOC);
$userIds = implode(',', array_column($orders, 'user_id')); // 获取订单中的用户id
$result = $mysqli->query("select `id`,`name` from `user` where id in ({$userIds})");
$users = $result->fetch_all(MYSQLI_ASSOC);// 获取这些用户的姓名
// 将id做数组键
$userRes = [];
foreach ($users as $user) {
$userRes[$user['id']] = $user['name'];
}
$res = [];
// 整合数据
foreach ($orders as $order) {
$current = [];
$current['id'] = $order['id'];
$current['price'] = $order['price'];
$current['name'] = $userRes[$order['user_id']] ?: '';
$res[] = $current;
}
var_dump($res);
// 关闭mysql连接
$mysqli->close();
看时间的话,明显 join 更快一些。
04 万条数据情况
user表现在10000条数据,order表10000条试下。
4.1 join
4.2 in
order 耗时:
user 耗时:
4.3 并发场景
join 耗时:
in 耗时:
数据量达到万级别,非并发场景,in 更快,并发场景 join 更快。
05 十万条数据情况
随机插入后user表十万条数据,order表一百万条试下。
5.1 join
5.2 in
order 耗时:
user 耗时:
order查出来的结果过长了...
5.3 并发场景
join 耗时:
in 耗时:
数据量达到十万/百万级别,非并发场景,in 过长,并发场景 join 更快。
06 总结
实验结论:
- 数据量不到万级别,join 和 in 差不多;
- 数据量达到万级别,非并发场景,in 更快,并发场景 join 更快;
- 数据量达到十万/百万级别,非并发场景,in 过长,并发场景 join 更快。
下面是楼仔给出的一些建议。
当数据量比较小时,建议用 in,虽然两者的性能差不多,但是 join 会增加 sql 的复杂度,后续再变更,会非常麻烦。
当数据量比较大时,建议用 join,主要还是出于查询性能的考虑。
不过使用 join 时,小表驱动大表,一定要建立索引,join 的表最好不要超过 3 个,否则性能会非常差,还会大大增加 sql 的复杂度,非常不利于后续功能扩展。
最后,把楼仔的座右铭送给你:我从清晨走过,也拥抱夜晚的星辰,人生没有捷径,你我皆平凡,你好,陌生人,一起共勉。
原创好文:
来源:juejin.cn/post/7306322677039218724
sleep 和 wait深度对比!
在计算机编程中,特别是在多线程或并发编程中,sleep
和 wait
是两个非常常见的函数,但它们有不同的用途和工作机制,这篇文章我们将详细地讨论 sleep
和 wait
的区别,包括它们的内部工作原理、应用场景以及详细的示例代码,以帮助更全面地理解它们。
sleep
工作机制
- 暂停当前线程:
sleep
方法暂停当前执行的线程一段指定的时间,时间结束后线程再恢复执行。 - 不会释放锁: 即使线程在
sleep
状态下持有锁,它也不会释放。它依然占用着该锁,其他线程无法获得该锁。 - 线程状态转换:
sleep
方法会使线程从运行(RUNNING)状态转换为计时等待(TIMED_WAITING)状态。 - 静态方法: 它是
Thread
类的静态方法,调用时通过Thread.sleep
访问。
应用场景
- 限流: 控制任务执行的频率,防止线程过度占用CPU资源。
- 定时任务: 在某个循环中,定时执行某些任务。
示例代码
public class SleepExample extends Thread {
public void run() {
try {
System.out.println("Thread going to sleep for 2 seconds.");
Thread.sleep(2000); // 睡眠 2 秒
System.out.println("Thread woke up after sleeping.");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
SleepExample thread = new SleepExample();
thread.start();
}
}
wait
工作机制
- 释放锁并等待通知:
wait
方法使当前线程等待,直到其他线程调用当前对象的notify
或notifyAll
方法。调用wait
时,线程会释放它持有的锁。 - 必须在同步块或同步方法中使用:
wait
方法必须在同步块或同步方法中调用,否则会抛出IllegalMonitorStateException
。 - 线程状态转换:
wait
方法会使线程从运行(RUNNING)状态转换为等待(WAITING)状态。 - 对象方法: 它是
Object
类的方法,所以任何对象都可以调用。
应用场景
- 线程间通信: 多个线程协同工作时,一个线程等待某个条件满足后,再被其他线程通知继续执行。
- 生产者-消费者模型: 经常用于实现生产者-消费者模式中的同步。
示例代码
public class WaitNotifyExample {
private static final Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
// 等待线程
Thread waitingThread = new Thread(() -> {
synchronized (lock) {
try {
System.out.println("Thread waiting for the lock to be released.");
lock.wait(); // 进入等待状态并释放锁
System.out.println("Thread resumed after lock released.");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
// 通知线程
Thread notifyingThread = new Thread(() -> {
synchronized (lock) {
System.out.println("Notifying other threads.");
lock.notify(); // 通知其他等待该锁的线程
System.out.println("Notified waiting thread.");
}
});
waitingThread.start();
Thread.sleep(1000); // 确保 waitingThread 先持有锁并进入等待状态
notifyingThread.start();
}
}
sleep 和 wait的对比
特性 | sleep | wait |
---|---|---|
释放锁 | 否 | 是 |
需要在同步块或方法中 | 否 | 是 |
属于 | Thread 类 | Object 类 |
引发异常 | InterruptedException | InterruptedException 引发机制相同 |
作用范围 | 当前调用的线程 | 当前拥有锁的线程 |
线程状态改变 | 变为计时等待(TIMED_WAITING) | 变为等待(WAITING) |
典型应用场景 | 暂停线程的一段时间,用于控制节奏或定时操作 | 线程间通信,生产者-消费者模型等 |
总结
本文,我们分析了sleep
和 wait
,sleep
用于暂停当前线程一段指定时间,但仍保持锁,这常用来控制执行节奏或定时操作。wait
使线程释放锁并进入等待状态,直到通过 notify/notifyAll 被唤醒,需在同步块中使用,适用于线程间通信如生产者-消费者模型。
来源:juejin.cn/post/7420718386953355279
springboot + minio + kkfile实现文件预览(不暴露minio地址)
前言
之前我写过一片文章【springboot + minio + kkfile实现文件预览】,该文章介绍了如何使用kkfile预览文件,但是文章中介绍的方案,会暴露minio的地址,实际的预览地址如下:
http://kkfile-server/onlinePreview?url=base64UrlEncode(minio生成的文件预览地址)
但是大多数情况下,minio服务的地址是不允许暴露的,所有我们对其进行优化,依然使用kkfile预览文件,但是我们使用文件流的方式,并且在下载接口上校验用户认证的有效性,在保证不暴露minio地址的前提下,还加入了token认证,提高了安全性,话不多说,直接上代码。
一、文件上传
上传服务
public void uploadFile(MultipartFile file) throws Exception {
String fileName = System.currentTimeMillis() + "-" + file.getOriginalFilename();
PutObjectArgs args = PutObjectArgs.builder().bucket(minioConfig.getBucketName()).object(fileName).stream(file.getInputStream(), file.getSize(), -1).contentType(file.getContentType()).build();
client.putObject(args);
}
封装接口
@PostMapping("upload")
public RestResult upload(MultipartFile file) {
try {
sysFileService.uploadFile(file);
} catch (Exception e) {
log.error("上传文件失败", e);
return RestResult.fail(e.getMessage());
}
}
二、文件下载
下载服务
public void download(String filename, HttpServletResponse response) throws ServiceException {
try {
InputStream inputStream = client.getObject(GetObjectArgs.builder().bucket(minioConfig.getBucketName()).object(filename).build());
// 设置响应头信息,告诉前端浏览器下载文件
response.setContentType("application/octet-stream;charset=UTF-8");
response.setCharacterEncoding("UTF-8");
response.setHeader("Content-Disposition", "attachment;filename=" + URLEncoder.encode(filename, "UTF-8"));
// 获取输出流进行写入数据
OutputStream outputStream = response.getOutputStream();
// 将输入流复制到输出流
byte[] buffer = new byte[4096];
int bytesRead = -1;
while ((bytesRead = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, bytesRead);
}
// 关闭流资源
inputStream.close();
outputStream.close();
} catch (Exception e) {
log.error("文件下载失败:" + e.getMessage());
throw new ServiceException("文件下载失败");
}
}
封装接口
@ApiOperation("文件下载")
@GetMapping("/download/{token}/{filename}")
public void getDownload(@PathVariable("token") String token, @PathVariable("filename") String filename, HttpServletResponse response) {
tokenUtils.validateToken(token);
sysFileService.download(filename, response);
}
上面的接口有两个地方需要注意
- @GetMapping("/download/{token}/{filename}")中filename参数必须放在最后
- tokenUtils.validateToken(token);
该接口要在拦截器中放行,验证token在代码逻辑中,这里根据项目中实际场景去实现。该地址为kkfile请求获取文件流的地址,所以需要放开鉴权
三、文件预览地址获取
文件预览地址生成服务(该服务只是获取token并拼接到文件下载地址中,不对token做验证,因为该服务的接口在请求进入前要做校验)
public String getPreviewUrl(String filename) throws UnsupportedEncodingException {
ServletRequestAttributes sra = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = sra.getRequest();
if (request ==null || StringUtils.isBlank(request.getHeader(TokenConstants.AUTHENTICATION))) {
throw new ServiceException("未获取到有效token");
}
String previewUrl = filePreviewUrl + FileUploadUtils.base64UrlEncode(fileDownloadUrl + "/" + token + "/" + filename);
return previewUrl + "&fullfilename=" + URLEncoder.encode(filename, "UTF-8");
}
FileUploadUtils中的base64UrlEncode方法
public static String base64UrlEncode(String url) throws UnsupportedEncodingException {
String base64Url = Base64.getEncoder().encodeToString(url.getBytes(StandardCharsets.UTF_8));
return URLEncoder.encode(base64Url, "UTF-8");
}
封装接口,获取文件预览地址
@GetMapping("/getPreviewUrl")
public RestResult<String> getPreviewUrl(String filename) throws UnsupportedEncodingException {
return RestResult.ok(sysFileService.getPreviewUrl(filename));
}
测试
假设
- 文件服务地址为:http://file-server
- kkfile服务地址为:http://kkfile-server
- 文件名称为:xxxx.docx
最后生成的文件预览地址为:
http://kkfile-server/onlinePreview?url=aHR0cDovLzE3Mi4xNi41MC4y....&fullfilename=xxxx.docx
其中aHR0cDovLzE3Mi4xNi41MC4y....为:
FileUploadUtils.base64UrlEncode("http://file-server" + "/" + token + "/" + filename);
截图为证
来源:juejin.cn/post/7424338056918761498
听我一句劝,业务代码中,别用多线程。
你好呀,我是歪歪。
前几天我在网上冲浪,看到一个哥们在吐槽,说他工作三年多了,没使用过多线程。
虽然八股文背的滚瓜烂熟,但是没有在实际开发过程中写的都是业务代码,没有使用过线程池,心里还是慌得一比。
我只是微微一笑,这不是很正常吗?
业务代码中一般也使不上多线程,或者说,业务代码中不知不觉你以及在使用线程池了,你再 duang 的一下搞一个出来,反而容易出事。
所以提到线程池的时候,我个人的观点是必须把它吃得透透的,但是在业务代码中少用或者不用多线程。
关于这个观点,我给你盘一下。
Demo
首先我们还是花五分钟搭个 Demo 出来。
我手边刚好有一个之前搭的一个关于 Dubbo 的 Demo,消费者、生产者都有,我就直接拿来用了:
这个 Demo 我也是跟着网上的 quick start 搞的:
cn.dubbo.apache.org/zh-cn/overv…
可以说写的非常详细了,你就跟着官网的步骤一步步的搞就行了。
我这个 Demo 稍微不一样的是我在消费者模块里面搞了一个 Http 接口:
在接口里面发起了 RPC 调用,模拟从前端页面发起请求的场景,更加符合我们的开发习惯。
而官方的示例中,是基于了 SpringBoot 的 CommandLineRunner 去发起调用:
只是发起调用的方式不一样而已,其他没啥大区别。
需要说明的是,我只是手边刚好有一个 Dubbo 的 Demo,随手就拿来用了,但是本文想要表达的观点,和你使不使用 Dubbo 作为 RPC 框架,没有什么关系,道理是通用的。
上面这个 Demo 启动起来之后,通过 Http 接口发起一次调用,看到控制台服务提供方和服务消费方都有对应的日志输出,准备工作就算是齐活儿了:
上菜
在上面的 Demo 中,这是消费者的代码:
这是提供者的代码:
整个调用链路非常的清晰:
来,请你告诉我这里面有线程池吗?
没有!
是的,在日常的开发中,我就是写个接口给别人调用嘛,在我的接口里面并没有线程池相关的代码,只有 CRUD 相关的业务代码。
同时,在日常的开发中,我也经常调用别人提供给我的接口,也是一把梭,撸到底,根本就不会用到线程池。
所以,站在我,一个开发人员的角度,这个里面没有线程池。
合理,非常合理。
但是,当我们换个角度,再看看,它也是可以有的。
比如这样:
反应过来没有?
我们发起一个 Http 调用,是由一个 web 容器来处理这个请求的,你甭管它是 Tomcat,还是 Jetty、Netty、Undertow 这些玩意,反正是个 web 容器在处理。
那你说,这个里面有线程池吗?
在方法入口处打个断点,这个 http-nio-8081-exec-1 不就是 Tomcat 容器线程池里面的一个线程吗:
通过 dump 堆栈信息,过滤关键字可以看到这样的线程,在服务启动起来,啥也没干的情况下,一共有 10 个:
朋友,这不就是线程池吗?
虽然不是你写的,但是你确实用了。
我写出来的这个 test 接口,就是会由 web 容器中的一个线程来进行调用。所以,站在 web 容器的角度,这里是有一个线程池的:
同理,在 RPC 框架中,不管是消费方,还是服务提供方,也都存在着线程池。
比如 Dubbo 的线程池,你可以看一下官方的文档:
cn.dubbo.apache.org/zh-cn/overv…
而对于大多数的框架来说,它绝不可能只有一个线程池,为了做资源隔离,它会启用好几个线程池,达到线程池隔离,互不干扰的效果。
比如参与 Dubbo 一次调用的其实不仅一个线程池,至少还有 IO 线程池和业务线程池,它们各司其职:
我们主要关注这个业务线程池。
反正站在 Dubbo 框架的角度,又可以补充一下这个图片了:
那么问题来了,在当前的这个情况下?
当有人反馈:哎呀,这个服务吞吐量怎么上不去啊?
你怎么办?
你会 duang 的一下在业务逻辑里面加一个线程池吗?
大哥,前面有个 web 容器的线程池,后面有个框架的线程池,两头不调整,你在中间加个线程池,加它有啥用啊?
web 容器,拿 Tomcat 来说,人家给你提供了线程池参数调整的相关配置,这么一大坨配置,你得用起来啊:
tomcat.apache.org/tomcat-9.0-…
再比如 Dubbo 框架,都给你明说了,这些参数属于性能调优的范畴,感觉不对劲了,你先动手调调啊:
你把这些参数调优弄好了,绝对比你直接怼个线程池在业务代码中,效果好的多。
甚至,你在业务代码中加入一个线程池之后,反而会被“反噬”。
比如,你 duang 的一下怼个线程池在这里,我们先只看 web 容器和业务代码对应的部分:
由于你的业务代码中有线程池的存在,所以当接受到一个 web 请求之后,立马就把请求转发到了业务线程池中,由线程池中的线程来处理本次请求,从而释放了 web 请求对应的线程,该线程又可以里面去处理其他请求。
这样来看,你的吞吐量确实上去了。
在前端来看,非常的 nice,请求立马得到了响应。
但是,你考虑过下游吗?
你的吞吐量上涨了,下游同一时间处理的请求就变多了。如果下游跟不上处理,顶不住了,直接就是崩给你看怎么办?
而且下游不只是你一个调用方,由于你调用的太猛,导致其他调用方的请求响应不过来,是会引起连锁反应的。
所以,这种场景下,为了异步怼个线程池放着,我觉得还不如用消息队列来实现异步化,顶天了也就是消息堆积嘛,总比服务崩了好,这样更加稳妥。
或者至少和下游勾兑一下,问问我们这边吞吐量上升,你们扛得住不。
有的小伙伴看到这里可能就会产生一个疑问了:歪师傅,你这个讲得怎么和我背的八股文不一样啊?
巧了,你背过的八股文我也背过,现在我们来温习一下我们背过的八股文。
什么时候使用线程池呢?
比如一个请求要经过若干个服务获取数据,且这些数据没有先后依赖,最终需要把这些数据组合起来,一并返回,这样经典的场景:
用户点商品详情,你要等半天才展示给用户,那用户肯定骂骂咧咧的久走了。
这个时候,八股文上是怎么说的:用线程池来把串行的动作改成并行。
这个场景也是增加了服务 A 的吞吐量,但是用线程池就是非常正确的,没有任何毛病。
但是你想想,我们最开始的这个案例,是这个场景吗?
我们最开始的案例是想要在业务逻辑中增加一个线程池,对着一个下游服务就是一顿猛攻,不是所谓的串行改并行,而是用更多的线程,带来更多的串行。
这已经不是一个概念了。
还有一种场景下,使用线程池也是合理的。
比如你有一个定时任务,要从数据库中捞出状态为初始化的数据,然后去调用另外一个服务的接口查询数据的最终状态。
如果你的业务代码是这样的:
//获取订单状态为初始化的数据(0:初始化 1:处理中 2:成功 3:失败)
//select * from order where order_status=0;
ArrayList initOrderInfoList = queryInitOrderInfoList();
//循环处理这批数据
for(OrderInfo orderInfo : initOrderInfoList){
//捕获异常以免一条数据错误导致循环结束
try{
//发起rpc调用
String orderStatus = queryOrderStatus(orderInfo.getOrderId);
//更新订单状态
updateOrderInfo(orderInfo.getOrderId,orderStatus);
} catch (Exception e){
//打印异常
}
}
虽然你框架中使用了线程池,但是你就是在一个 for 循环中不停的去调用下游服务查询数据状态,是一条数据一条数据的进行处理,所以其实同一时间,只是使用了框架的线程池中的一个线程。
为了更加快速的处理完这批数据,这个时候,你就可以怼一个线程池放在 for 循环里面了:
//循环处理这批数据
for(OrderInfo orderInfo : initOrderInfoList){
//使用线程池
executor.execute(() -> {
//捕获异常以免一条数据错误导致循环结束
try {
//发起rpc调用
String orderStatus = queryOrderStatus(orderInfo.getOrderId);
//更新订单状态
updateOrderInfo(orderInfo.getOrderId, orderStatus);
} catch (Exception e) {
//打印异常
}
});
}
需要注意的是,这个线程池的参数怎么去合理的设置,是需要考虑的事情。
同时这个线程池的定位,就类似于 web 容器线程池的定位。
或者这样对比起来看更加清晰一点:
定时任务触发的时候,在发起远程接口调用之前,没有线程池,所以我们可以启用一个线程池来加快数据的处理。
而 Http 调用或者 RPC 调用,框架中本来就已经有一个线程池了,而且也给你提供了对应的性能调优参数配置,那么首先考虑的应该是把这个线程池充分利用起来。
如果仅仅是因为异步化之后可以提升服务响应速度,没有达到串行改并行的效果,那么我更加建议使用消息队列。
好了,本文的技术部分就到这里啦。
下面这个环节叫做[荒腔走板],技术文章后面我偶尔会记录、分享点生活相关的事情,和技术毫无关系。我知道看起来很突兀,但是我喜欢,因为这是一个普通博主的生活气息。
荒腔走板
不知道你看完文章之后,有没有产生一个小疑问:最开始部分的 Demo 似乎用处并不大?
是的,我最开始构思的行文结构是是基于 Demo 在源码中找到关于线程池的部分,从而引出其实有一些我们“看不见的线程池”的存在的。
原本周六我是有一整天的时间来写这篇文章,甚至周五晚上还特意把 Demo 搞定,自己调试了一番,该打的断点全部打上,并写完 Demo 那部分之后,我才去睡觉的,想得是第二天早上起来直接就能用。
按照惯例周六睡个懒觉的,早上 11 点才起床,自己慢条斯理的做了一顿午饭,吃完饭已经是下午 1 点多了。
本来想着在沙发上躺一会,结果一躺就是一整个下午。期间也想过起来写一会文章,坐在电脑前又飞快的躺回到沙发上,就是觉得这个事情索然无味,当下的那一刻就想躺着,然后无意识的刷手机,原本是拿来写文章中关于源码的部分的时间就这样浪费了。
像极了高中时的我,周末带大量作业回家,准备来个悬梁刺股,弯道超车,结果变成了一睡一天,捏紧刹车。
高中的时候,时间浪费了是真的可惜。
现在,不一样了。
荒腔走板这张图片,就是我躺在沙发上的时候,别人问我在干什么时随手拍的一张。
我并不为躺了一下午没有干正事而感到惭愧,浪费了的时间,才是属于自己的时间。
很久以前我看到别人在做一些浪费时间的事情的时候,我心里可能会嘀咕几句,劝人惜时。
这两年我不会了,允许自己做自己,允许别人做别人。
来源:juejin.cn/post/7297980721590272040