开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

Spring Boot 整合 Camunda 实现工作流

发表于 2024-01-18

工作流是我们开发企业应用几乎必备的一项功能,工作流引擎发展至今已经有非常多的产品。最近正好在接触Camunda,所以来做个简单的入门整合介绍。如果您也刚好在调研或者刚开始计划接入,希望本文对您有所帮助。如果您是一名Java开发或Spring框架爱好者,欢迎关注我程序猿DD,持续非常技术干货。

Camunda简介

Camunda是一个灵活的工作流和流程自动化框架。其核心是一个运行在Java虚拟机内部的原生BPMN 2.0流程引擎。它可以嵌入到任何Java应用程序和任何运行时容器中。

  • 官网网站: www.camunda.org/
  • 入门文档: docs.camunda.org/get-started…

动手整合Camunda

下面就来一步步动手尝试一下吧。

准备工作

  1. 使用Camunda提供的项目初始化工具Camunda Automation Platform 7 Initializr

如上图,包名之类的根据自己需要做好配置,最后输入管理账号和密码,点击Generate Project按钮,自动下载工程。

  1. 解压下载后的工程,使用IntelliJ IDEA打开,其项目结构

  1. 打开pom.xml文件,添加camunda依赖:
1
2
3
4
5
6
7
8
9
xml复制代码<dependency>
<groupId>org.camunda.connect</groupId>
<artifactId>camunda-connect-core</artifactId>
</dependency>

<dependency>
<groupId>org.camunda.bpm</groupId>
<artifactId>camunda-engine-plugin-connect</artifactId>
</dependency>

由于Camunda Automation Platform 7 Initializr默认的Spring Boot版本已经是3.1了,所以如果要做一些降级调整,可以手工修改pom.xml中dependencyManagement配置,比如下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
xml复制代码<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>2.6.4</version>
<type>pom</type>
<scope>import</scope>
</dependency>

<dependency>
<groupId>org.camunda.bpm</groupId>
<artifactId>camunda-bom</artifactId>
<version>7.15.0</version>
<scope>import</scope>
<type>pom</type>
</dependency>
</dependencies>
</dependencyManagement>
  1. 打开配置文件application.yaml,可以看到类似下面的内容
1
2
3
4
5
yaml复制代码spring.datasource.url: jdbc:h2:file:./camunda-h2-database

camunda.bpm.admin-user:
id: transduck
password: 111111
  • spring.datasource.url:工作流引擎使用的数据库配置,您也可以根据官网文档去调整到其他数据库中(尤其生产环境)。
  • camunda.bpm.admin-user:管理员账户配置,可以在这里修改用户名和密码

创建一个简单的工作流

下面我们尝试创建一个简单的工作流:

第一步,我们将请求用户提供两个输入:name和message
第二步,我们将这些输入传递给我们的服务以创建消息输出

开始编码:

  1. 创建第一步提到的数据模型
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
java复制代码public class model {

private String message;
private String name;

public model() { }

public String getMessage() {
return message;
}

public void setMessage(String message) {
this.message = message;
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

@Override
public String toString() {
return "" + message + ", " + name;
}
}
  1. 根据第二步,创建接收消息的接口
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java复制代码@RequestMapping("/")
@RestController
public class controller {

Logger logger = Logger.getLogger(this.getClass().getName());

@PostMapping("/message")
public model createMessage(@RequestBody model model) {
logger.info("-------Message Creator Initialized-------");

model m = new model();
m.setMessage(model.getMessage());
m.setName(model.getName());

logger.info("Message created --> " + m.toString());
return m;
}
}
  1. 下面我们可以开始创建工作流程图。在Camunda Modeler中打开我们项目的resources下的process.bpmn,我们将看到类似下面的流程图:

图中带有小人的框称为User Tasks,是执行与用户相关的操作的步骤。如前面部分所述,在工作流程的第一步中,我们将请求用户输入两个输入:姓名和消息。无需添加新任务,更新现有的User Tasks即可解决问题。单击User Tasks,打开属性面板,在打开的面板中定义适合我们案例内容。

  1. 完成基本信息填写后,转到Form选项卡。

这是定义呈现给用户的表单选项卡。由于我们需要用户输入姓名和消息,因此我们定义两个名为“name”和“message”的表单字段。要定义表单字段,请单击“表单字段”旁边的加号图标。在打开的表单中,相应地填写 ID、类型和标签字段。对每个表单字段重复相同的步骤。

  1. 开始配置第二步,调用我们的接口。添加Service Task。

具体操作方法:单击左侧菜单中的Create Task图标,然后将任务拖放到随机位置。单击任务后,单击Change Type图标,然后从菜单中选择Service Task。

  1. 填写基本信息

  1. 切换到Connector选项卡。这是定义 HTTP 信息和有关服务的数据的选项卡,在这里配置刚才定义的接口,具体如下图所示:

  1. 将Service Task连接到工作流程中。先删除User Tasks和End Event之间的箭头。然后,单击User Tasks并从菜单中选择箭头图标。将箭头连接到Service Task。最后,再连接Service Task和End Event。

启动测试

在完成了上面的编码和工作流程配置后,我们就可以在调试模式下运行项目了。

启动完成后,在浏览器上访问地址http://localhost:8080/,您将看到 Camunda 登录页面:

输入您在application.yaml中配置的管理员配置信息,进入后台:

从应用程序主页中选择Tasklist,可看到如下界面:

然后在任务列表页面上单击Add a simple filter选项。单击后,您将看到名为All Tasks (0)的过滤器已添加到列表中,继续单击Start process选项来运行我们准备好的工作流程。

选择您的工作流进程,然后单击Start button,无需提供任何其他信息。

最后,单击Created下列出的Get Input任务。如果您没有看到该任务,请刷新页面。

您将看到我们在第一步中定义的表单。要填写表格,请单击右上角Claim选项。然后,根据您的喜好填写表格并单击Complete按钮。

当工作流执行Service Task并且服务运行时,您将看到列表再次变空。如果工作流成功执行了第二步,我们应该能够在控制台中看到输出。

小结

本文介绍了使用Spring Boot和Camunda创建一个简单工作流的完整步骤,希望对您有所帮助。如果您学习过程中如遇困难?可以加入我们超高质量的Spring技术交流群,参与交流与讨论,更好的学习与进步!更多Spring Boot教程可以点击直达!,欢迎收藏与转发支持!

欢迎关注我的公众号:程序猿DD。第一时间了解前沿行业消息、分享深度技术干货、获取优质学习资源

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

优化接口速度真实案例【百万级别数据量】

发表于 2024-01-16

场景

在高并发的数据处理场景中,接口响应时间的优化显得尤为重要。本文将分享一个真实案例,其中一个数据量达到200万+的接口的响应时间从30秒降低到了0.8秒内。
这个案例不仅展示了问题诊断的过程,也提供了一系列有效的优化措施。

交易系统中,系统需要针对每一笔交易进行拦截(每一笔支付或转账就是一笔交易),拦截时需要根据定义好的规则拦截,这次需要优化的接口是一个统计规则拦截率的接口。

问题诊断

最初,接口的延迟非常高,大约需要30秒才能完成。为了定位问题,我们首先排除了网络和服务器设备因素,并打印了关键代码的执行时间。经过分析,发现问题出在SQL执行上。

发现Sql执行时间太久,查询200万条数据的执行时间竟然达到了30s,下面是是最耗时的部分相关代码逻辑:

  • 查询代码(其实就是使用Mybatis查询,看起来正常的很)
1
java复制代码List<Map<String, Object>> list = transhandleFlowMapper.selectDataTransHandleFlowAdd(selectSql);
  • 统计当天的Id号(programhandleidlist字段)
1
sql复制代码SELECT programhandleidlist FROM anti_transhandle WHERE create_time BETWEEN '2024-01-08 00:00:00.0' AND '2024-01-09 00:00:00.0';
  • 表结构(Postgresql)
字段名 数据类型 描述
id serial 主键,自增
create_time timestamp(6) with time zone 创建时间
programhandleidlist character varying[] 程序处理ID列表(数组类型)

我以为是Sql写的有问题,先拿着sql执行了一边,发现只执行sql的执行时间是大约800毫秒,和30秒差距巨大。

Sql层面分析

  • 使用EXPLAIN ANALYZE函数分析sql。
1
2
sql复制代码EXPLAIN ANALYZE
SELECT programhandleidlist FROM anti_transhandle WHERE create_time BETWEEN '2024-01-08 00:00:00.0' AND '2024-01-09 00:00:00.0';

分析结果
在这里插入图片描述
看来是代码的部分有问题。

代码层面分析

1
java复制代码List<Map<String, Object>> list = transhandleFlowMapper.selectDataTransHandleFlowAdd(selectSql);

Map的Key是programhandleIdList,Map的value是每一行的值。

在这里插入图片描述

在这里插入图片描述

在Java层面,每条数据都创建了一个Map对象,对于200万+的数据量来说,这显然是非常耗时的操作,速度是被创建了大量的Map集合给拖垮的。。为了解决这个问题,我们尝试了将200万行数据转换为单行返回,使用PostgreSQL的array_agg和unnest函数来优化查询。

第一次遇到Mybatis查询返回导致接口速度慢的问题。

优化措施

1. SQL优化

我的思路是将200万行转为一行返回。

要将 PostgreSQL 中查询出的 programhandleidlist 字段(假设这是一个数组类型)的所有元素拼接为一行,您可以使用数组聚合函数 array_agg 结合 unnest 函数。这样做可以先将数组展开为多行,然后将这些行再次聚合为一个单一的数组。如果您希望最终结果是一个字符串,而不是数组,您还可以使用 string_agg 函数。

以下是相应的 SQL 语句:

1
2
3
4
5
6
sql复制代码SELECT array_agg(elem) AS concatenated_array
FROM (
SELECT unnest(programhandleidlist) AS elem
FROM anti_transhandle
WHERE create_time BETWEEN '2024-01-08 00:00:00.0' AND '2024-01-09 00:00:00.0'
) sub;

在这个查询中:

  • unnest(programhandleidlist) 将 programhandleidlist 数组展开成多行。
  • string_agg(elem) 将这些行聚合成一个以逗号分隔的字符串。

这将返回一个包含所有元素的单一数组。

查询结果由多行,拼接为了一行。
在这里插入图片描述

再测试,现在是正常速度了,但是查询时间依旧很高。Sql查询时间0.8秒,代码中平均1秒8左右,还有优化的空间。
在这里插入图片描述
将一列数据转换为了数组类型,查看一下内存占用,这一段占用了54比特,虽然占用不大,但是不知道为什么会mybatis处理时间这么久。

  • 因为mybatis不知道数组的大小,先给数组设定一个初始大小,如果超出了数组长度,因为数组不能扩容,增加长度只能再复制一份到另一块内存中,复制的次数多了也就增加了计算时间。
  • 数据需要在两个设备之间传输,磁盘和网络都需要时间。

在这里插入图片描述

2. 部分业务逻辑转到数据库中计算

再次优化sql,将一部分的逻辑放到Sql中处理,减少数据量。
业务上我需要统计programhandleidlist字段中id出现的次数,所以我直接在sql中做统计。

要统计每个数组中元素出现的次数,您需要首先使用 unnest 函数将数组展开为单独的行,然后使用 GROUP BY 和聚合函数(如 count)来计算每个元素的出现次数。这里是修改后的 SQL 语句:

1
2
3
4
5
6
7
sql复制代码SELECT elem, COUNT(*) AS count
FROM (
SELECT unnest(programhandleidlist) AS elem
FROM anti_transhandle
WHERE create_time BETWEEN '2024-01-08 00:00:00.0' AND '2024-01-09 00:00:00.0'
) sub
GROUP BY elem;

在这个查询中:

  • unnest(programhandleidlist) 将每个 programhandleidlist 数组展开成多个行。
  • GROUP BY elem 对每个独立的元素进行分组。
  • COUNT(*) 计算每个分组(即每个元素)的出现次数。

这个查询将返回两列:一列是元素(elem),另一列是该元素在所有数组中出现的次数(count)。

在这里插入图片描述
这条sql在代码中执行时间是0.7秒,还是时间太长,毕竟数据库的数据量太大,搜了很多方法,已经是我能做到的最快查询了。

关系型数据库 不适合做海量数据计算查询。

这个业务场景牵扯到了海量数据的统计,并不适合使用关系型数据库,如果想要真正的做到毫秒级的查询,需要从设计上改变数据的存储结果。比如使用cilckhouse、hive等存储计算。

3. 引入缓存机制

减少查询数据库的次数,决定引入本地缓存机制。选择了Caffeine作为缓存框架,易于与Spring集成。
分析业务后,当天的统计数据必须查询数据库,但是查询历史日期的采用缓存的方式。如果业务中对时效性不敏感,也可以缓存当天的数据,每隔一段时间更新一次。我这里采用缓存历史日期的数据。

  1. 引入Caffeine依赖
1
2
3
4
5
xml复制代码        <dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>3.1.8</version>
</dependency>
  1. 配置Caffeine缓存:创建一个专门的Caffeine缓存配置。使用本地缓存选择淘汰策略很重要,由于我的业务场景使根据实现来查询,所以Caffeine将按照最近最少使用(LRU)的策略来淘汰旧数据成符合业务。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
java复制代码import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.concurrent.TimeUnit;

@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager();
cacheManager.setCaffeine(Caffeine.newBuilder()
.maximumSize(500)
.expireAfterWrite(60, TimeUnit.MINUTES));
return cacheManager;
}
}
  1. 修改ruleHitRate方法来使用Caffeine缓存:在计算昨天命中率的逻辑前加入缓存检查和更新的逻辑。

使用Caffeine缓存:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
java复制代码@Autowired
private CacheManager cacheManager; // 注入Spring的CacheManager

private static final String YESTERDAY_HIT_RATE_CACHE = "hitRateCache"; // 缓存名称

@Override
public RuleHitRateResponse ruleHitRate(LocalDate currentDate) {
// ... 其他代码 ...

// 使用缓存获取昨天的命中率
double hitRate = cacheManager.getCache(YESTERDAY_HIT_RATE_CACHE).get(currentDate.minusDays(1), () -> {
// 查询数据库
Map<String, String> hitRateList = dataTunnelClient.selectTransHandleFlowByTime(currentDate.minusDays(1));

// ... 其他代码 ...
// 返回计算后的结果
return hitRate;
});
// ... 其他代码 ...
}

总结

最后,测试接口,成功将接口从30秒降低到了0.8秒以内。
这次优化让我重新真正审视了关系型数据库的劣势。选择哪种类型的数据库,取决于具体的应用场景和需求。

  • 关系型数据库(Mysql、Oracle等)适合事务性强、数据一致性和完整性要求高的应用,
  • 列式数据库(HBase、ClickHouse等)则适合大数据量的分析和统计,特别是在读取性能方面有显著优势。

此次的业务场景显然更适合使用列式数据库,所以导致使用关系型数据库无论如何也不能够达到足够高的性能。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

SpringBoot 整合 Spring Security

发表于 2024-01-16

)

此文章用到的版本

1
2
yaml复制代码spring-boot : 2.6.8
java 1.8

引入依赖包(gradle) maven 请自行转换

1
2
3
4
5
6
java复制代码
dependencies {
compile group: 'io.jsonwebtoken', name: 'jjwt', version: '0.9.1'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
}

先说说原理

UsernamePasswordAuthenticationFilter 继承 AbstractAuthenticationProcessingFilter

AbstractAuthenticationProcessingFilter.doFilter() 会执行 抽象方法attemptAuthentication ()

通过观察发现 UsernamePasswordAuthenticationFilter 会拦截 POST /login 的请求

然后通过会通过Http parameter 获取 username 和 password 参数的值执行鉴权认证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
java复制代码	public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username";

public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password";

private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER = new AntPathRequestMatcher("/login",
"POST");

private String usernameParameter = SPRING_SECURITY_FORM_USERNAME_KEY;

private String passwordParameter = SPRING_SECURITY_FORM_PASSWORD_KEY;

private boolean postOnly = true;

public UsernamePasswordAuthenticationFilter() {
super(DEFAULT_ANT_PATH_REQUEST_MATCHER);
}

public UsernamePasswordAuthenticationFilter(AuthenticationManager authenticationManager) {
super(DEFAULT_ANT_PATH_REQUEST_MATCHER, authenticationManager);
}

@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
if (this.postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}
String username = obtainUsername(request);
username = (username != null) ? username : "";
username = username.trim();
String password = obtainPassword(request);
password = (password != null) ? password : "";
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
// Allow subclasses to set the "details" property
setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}

@Nullable
protected String obtainUsername(HttpServletRequest request) {
return request.getParameter(this.usernameParameter);
}

成功会执行 SavedRequestAwareAuthenticationSuccessHandler 重定向到指定url

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
java复制代码public class SavedRequestAwareAuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

protected final Log logger = LogFactory.getLog(this.getClass());

private RequestCache requestCache = new HttpSessionRequestCache();

@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws ServletException, IOException {
SavedRequest savedRequest = this.requestCache.getRequest(request, response);
if (savedRequest == null) {
super.onAuthenticationSuccess(request, response, authentication);
return;
}
String targetUrlParameter = getTargetUrlParameter();
if (isAlwaysUseDefaultTargetUrl()
|| (targetUrlParameter != null && StringUtils.hasText(request.getParameter(targetUrlParameter)))) {
this.requestCache.removeRequest(request, response);
super.onAuthenticationSuccess(request, response, authentication);
return;
}
clearAuthenticationAttributes(request);
// Use the DefaultSavedRequest URL
String targetUrl = savedRequest.getRedirectUrl();
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}

public void setRequestCache(RequestCache requestCache) {
this.requestCache = requestCache;
}

}

失败会执行 SimpleUrlAuthenticationFailureHandler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
java复制代码	@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) throws IOException, ServletException {
if (this.defaultFailureUrl == null) {
if (this.logger.isTraceEnabled()) {
this.logger.trace("Sending 401 Unauthorized error since no failure URL is set");
}
else {
this.logger.debug("Sending 401 Unauthorized error");
}
response.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());
return;
}
saveException(request, exception);
if (this.forwardToDestination) {
this.logger.debug("Forwarding to " + this.defaultFailureUrl);
request.getRequestDispatcher(this.defaultFailureUrl).forward(request, response);
}
else {
this.redirectStrategy.sendRedirect(request, response, this.defaultFailureUrl);
}
}

这种方式不兼容json方式提交的登录 而且不能返回token 供前端使用 所以我们需要改造此Filter

实现思路:

  1. 拦截Post /login 请求
  1. 获取请求中的body参数 username 以及password
  1. 返回 UsernamePasswordAuthenticationFilter 不携带权限集合
  2. 重写 UserDetailsService 查询数据库
  3. 重写 AuthenticationSuccessHandler 登录成功后返回jwt token令牌
  4. 重写 AuthenticationFailureHandler 失败返回失败原因 例如:密码错误,账户锁定, 账户不存在

定义token常量

1
2
3
4
5
6
7
8
9
10
11
java复制代码public class SecurityConstants
{
public static final long EXPIRATION_TIME = 864_000_000; // 10 days
public static final String TOKEN_PREFIX = "Bearer ";
public static final String HEADER_STRING = "Authorization";

private SecurityConstants()
{
throw new IllegalStateException("Utility class");
}
}

实现工具类 JWTUtils 用户生成、解析token

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
java复制代码@Component
public class JwtUtil
{

/**
* 签名用的密钥
*/
private static String signKey = "nacl";


/**
* 用户登录成功后生成Jwt
* 使用Hs256算法
*
* @param exp jwt过期时间
* @param claims 保存在Payload(有效载荷)中的内容
* @return token字符串
*/
public static String createJWT(Date exp, Map<String, Object> claims)
{
//指定签名的时候使用的签名算法
SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;

//生成JWT的时间
long nowMillis = System.currentTimeMillis();
Date now = new Date(nowMillis);

//创建一个JwtBuilder,设置jwt的body
JwtBuilder builder = Jwts.builder()
//保存在Payload(有效载荷)中的内容
.setClaims(claims)
//iat: jwt的签发时间
.setIssuedAt(now)
//设置过期时间
.setExpiration(exp)
//设置签名使用的签名算法和签名使用的秘钥
.signWith(signatureAlgorithm, signKey);

return builder.compact();
}

/**
* 解析token,获取到Payload(有效载荷)中的内容,包括验证签名,判断是否过期
*
* @param token
* @return
*/
public static Claims parseJWT(String token)
{
//得到DefaultJwtParser
return Jwts.parser()
//设置签名的秘钥
.setSigningKey(signKey)
//设置需要解析的token
.parseClaimsJws(token).getBody();
}

}

开始实现身份认证过滤器(JWTAuthenticationFilter) 继承 UsernamePasswordAuthenticationFilter 重写 attemptAuthentication 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
java复制代码public class JWTAuthenticationFilter extends UsernamePasswordAuthenticationFilter
{


@Override
public Authentication attemptAuthentication(HttpServletRequest req,
HttpServletResponse res)
{
Map<String, String> creds = new HashMap<>();
try
{
creds = new ObjectMapper().readValue(req.getInputStream(), Map.class); // 获取body中的参数
} catch (IOException e)
{
e.printStackTrace();
}
return this.getAuthenticationManager().authenticate(
new UsernamePasswordAuthenticationToken(
creds.get("username"),
creds.get("password"),
new ArrayList<>())
);
}
}

重写UserDetailService

返回一个测试用户 用户名:123 密码:123 角色权限: ROLE_ADMIN

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
java复制代码@Service
public class UserDetailsService implements org.springframework.security.core.userdetails.UserDetailsService
{

@Override
public UserDetails loadUserByUsername(String username)
{
Collection<SimpleGrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority("ROLE_ADMIN")); // 设定权限
return new User(
"123", // username
new BCryptPasswordEncoder().encode("123") , // password
true, // enabled – set to true if the user is enabled
true, // accountNonExpired – set to true if the account has not expired
true, // credentialsNonExpired – set to true if the credentials have not expired
true, // accountNonLocked – set to true if the account is not locked
authorities // authorities – the authorities that should be granted to the caller if they presented the
);
}
}

重写 AuthenticationSuccessHandler 成功后将生成的 token 放入 response header

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
java复制代码@Component
public class CustomAuthenticateSuccessHandler implements AuthenticationSuccessHandler
{

@Override
public void onAuthenticationSuccess(
HttpServletRequest request,
HttpServletResponse response,
Authentication auth) throws IOException, ServletException
{
Map<String, Object> claims = new HashMap<>();
claims.put("username", ((User) auth.getPrincipal()).getUsername());

String token = JwtUtil.createJWT(
new Date(System.currentTimeMillis() + SecurityConstants.EXPIRATION_TIME),
claims
);

response.addHeader(SecurityConstants.HEADER_STRING, SecurityConstants.TOKEN_PREFIX + token);
}
}

重写 AuthenticationFailureHandler 返回账户失败原因

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
java复制代码@Component
public class CustomAuthenticateFailureHandler implements AuthenticationFailureHandler
{
@Override
public void onAuthenticationFailure(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException failed
) throws IOException, ServletException
{
String returnData = "";
// 账号过期
if (failed instanceof AccountExpiredException)
{
returnData = "账号过期";
}
// 密码错误
else if (failed instanceof BadCredentialsException)
{
returnData = "密码错误";
}
// 密码过期
else if (failed instanceof CredentialsExpiredException)
{
returnData = "密码过期";
}
// 账号不可用
else if (failed instanceof DisabledException)
{
returnData = "账号不可用";
}
//账号锁定
else if (failed instanceof LockedException)
{
returnData = "账号锁定";
}
// 用户不存在
else if (failed instanceof InternalAuthenticationServiceException)
{
returnData = "用户不存在";
}
// 其他错误
else
{
returnData = "未知异常";
}

// 处理编码方式 防止中文乱码
response.setContentType("text/json;charset=utf-8");
// 将反馈塞到HttpServletResponse中返回给前台
response.getWriter().write(returnData);
}
}

改造BasicAuthenticationFilter基于JWT解析 实现权限认证

认证过滤器 BasicAuthenticationFilter

header里头有Authorization,而且value是以Basic开头的,则走BasicAuthenticationFilter,提取参数构造UsernamePasswordAuthenticationToken进行认证,成功则填充SecurityContextHolder的Authentication

而我们要做的是 header里头有Authorization,而且value是以Bearer开头的, 解析jwt token填充SecurityContextHolder的Authentication

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
java复制代码  @Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
try {
UsernamePasswordAuthenticationToken authRequest = this.authenticationConverter.convert(request);
if (authRequest == null) {
this.logger.trace("Did not process authentication request since failed to find "
+ "username and password in Basic Authorization header");
chain.doFilter(request, response);
return;
}
String username = authRequest.getName();
this.logger.trace(LogMessage.format("Found username '%s' in Basic Authorization header", username));
if (authenticationIsRequired(username)) {
Authentication authResult = this.authenticationManager.authenticate(authRequest);
SecurityContextHolder.getContext().setAuthentication(authResult);
if (this.logger.isDebugEnabled()) {
this.logger.debug(LogMessage.format("Set SecurityContextHolder to %s", authResult));
}
this.rememberMeServices.loginSuccess(request, response, authResult);
onSuccessfulAuthentication(request, response, authResult);
}
}
catch (AuthenticationException ex) {
SecurityContextHolder.clearContext();
this.logger.debug("Failed to process authentication request", ex);
this.rememberMeServices.loginFail(request, response);
onUnsuccessfulAuthentication(request, response, ex);
if (this.ignoreFailure) {
chain.doFilter(request, response);
}
else {
this.authenticationEntryPoint.commence(request, response, ex);
}
return;
}

chain.doFilter(request, response);
}

@Override
public UsernamePasswordAuthenticationToken convert(HttpServletRequest request) {
String header = request.getHeader(HttpHeaders.AUTHORIZATION);
if (header == null) {
return null;
}
header = header.trim();
if (!StringUtils.startsWithIgnoreCase(header, AUTHENTICATION_SCHEME_BASIC)) {
return null;
}
if (header.equalsIgnoreCase(AUTHENTICATION_SCHEME_BASIC)) {
throw new BadCredentialsException("Empty basic authentication token");
}
byte[] base64Token = header.substring(6).getBytes(StandardCharsets.UTF_8);
byte[] decoded = decode(base64Token);
String token = new String(decoded, getCredentialsCharset(request));
int delim = token.indexOf(":");
if (delim == -1) {
throw new BadCredentialsException("Invalid basic authentication token");
}
UsernamePasswordAuthenticationToken result = new UsernamePasswordAuthenticationToken(token.substring(0, delim),
token.substring(delim + 1));
result.setDetails(this.authenticationDetailsSource.buildDetails(request));
return result;
}

开始实现 继承BasicAuthenticationFilter 并重写 doFilterInternal 方法

getAuthentication 获取 request header中的token 解析成username 并调用Userservice中的loadUserByName方法返回User鉴权信息

装入SecurityContextHolder

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
java复制代码public class JWTAuthorizationFilter extends BasicAuthenticationFilter
{

public JWTAuthorizationFilter(AuthenticationManager authManager)
{
super(authManager);
}

@Override
protected void doFilterInternal(HttpServletRequest req,
HttpServletResponse res,
FilterChain chain) throws IOException, ServletException
{
String header = req.getHeader(SecurityConstants.HEADER_STRING);

if (header == null || !header.startsWith(SecurityConstants.TOKEN_PREFIX))
{
chain.doFilter(req, res);
return;
}
try
{
UsernamePasswordAuthenticationToken authentication = getAuthentication(req);
SecurityContextHolder.getContext().setAuthentication(authentication);
chain.doFilter(req, res);
} catch (ExpiredJwtException e)
{
res.getWriter().write("token expired");
} catch (JwtException e)
{
res.getWriter().write("token invalid");
}

}

private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request)
{
String token = request.getHeader(SecurityConstants.HEADER_STRING);
if (token != null)
{
// parse the token.
Claims claims = JwtUtil.parseJWT(token.replace(SecurityConstants.TOKEN_PREFIX, ""));

if (claims != null)
{
UserDetailsService userDetailsService = new UserDetailsService();
User u = (User) userDetailsService.loadUserByUsername((String) claims.get("username"));
return new UsernamePasswordAuthenticationToken(u.getUsername(), u.getPassword(), u.getAuthorities());
}
}
return null;
}
}

CustomAccessDeniedHandler 非匿名下的错误拦截器

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler
{
@Override
public void handle(
HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException, ServletException
{
response.setContentType("text/json;charset=utf-8");
response.getWriter().write("权限错误");
}
}

CustomAuthenticationEntryPoint 匿名下的错误拦截器

1
2
3
4
5
6
7
8
9
java复制代码@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint
{
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException
{
response.getWriter().write("no login");
}
}

OK 准备大功告成, 最后设定一下Spring Security的配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
java复制代码@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter
{

private final UserDetailsService userDetailsService;
private final BCryptPasswordEncoder bCryptPasswordEncoder;
private final CustomAccessDeniedHandler customAccessDeniedHandler;
private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
private final CustomAuthenticateSuccessHandler customAuthenticateSuccessHandler;
private final CustomAuthenticateFailureHandler customAuthenticateFailureHandler;

public SpringSecurityConfig(
UserDetailsService userDetailsService,
BCryptPasswordEncoder bCryptPasswordEncoder,
CustomAccessDeniedHandler customAccessDeniedHandler,
CustomAuthenticationEntryPoint customAuthenticationEntryPoint,
CustomAuthenticateSuccessHandler customAuthenticateSuccessHandler,
CustomAuthenticateFailureHandler customAuthenticateFailureHandler
)
{
this.userDetailsService = userDetailsService;
this.bCryptPasswordEncoder = bCryptPasswordEncoder;
this.customAccessDeniedHandler = customAccessDeniedHandler;
this.customAuthenticationEntryPoint = customAuthenticationEntryPoint;
this.customAuthenticateFailureHandler = customAuthenticateFailureHandler;
this.customAuthenticateSuccessHandler = customAuthenticateSuccessHandler;
}

@Override
public void configure(HttpSecurity http) throws Exception
{
http
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and().csrf().disable()
.authorizeRequests().antMatchers("/sign-up").permitAll()
.anyRequest().authenticated()
.and()
.exceptionHandling()
.accessDeniedHandler(customAccessDeniedHandler)
.authenticationEntryPoint(customAuthenticationEntryPoint)
.and()
.addFilter(jwtAuthenticationFilter())
.addFilter(jwtAuthorizationFilter());
}

@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception
{
auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder);
}

@Bean
public JWTAuthenticationFilter jwtAuthenticationFilter() throws Exception
{
JWTAuthenticationFilter filter = new JWTAuthenticationFilter();
filter.setAuthenticationManager(authenticationManager());
filter.setAuthenticationSuccessHandler(customAuthenticateSuccessHandler);
filter.setAuthenticationFailureHandler(customAuthenticateFailureHandler);
return filter;
}

@Bean
public JWTAuthorizationFilter jwtAuthorizationFilter () throws Exception
{
return new JWTAuthorizationFilter(authenticationManager());
}
}

编写测试Controller

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
java复制代码@RestController
public class TestController
{
@PostMapping("/sign-up")
public String signUp ()
{
return "1111";
}

@PostMapping("/admin")
@PreAuthorize("hasRole('ADMIN')")
public String admin ()
{
return "222";
}

@PostMapping("/user")
@PreAuthorize("hasRole('USER')")
public String user ()
{
return "333";
}
}

测试 /login 登录

输入错误的账号密码

)编辑

输入正确的账号密码

)编辑

放行请求/sign-up

)编辑

身份鉴权认证

无token

)编辑

错误token

)编辑

正确token 无权限

)编辑

正确token 有权限

)编辑

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

探索个人IP&副业一个月的经历与思考,愿我们都能找到自己的热

发表于 2024-01-16

前言

Hi 你好,我是东东拿铁,95后奶爸程序员。

文章开头,我想先引用《明朝那些事儿》这本书,结尾的一段话,作为本文的开头

是的,这就是我想说的,这就是我想通过徐霞客所表达的,足以藐视所有王侯将相,最完美的结束语:
成功只有一个–按照自己的方式,去度过人生。

《明朝那些事儿》这本书读完大概有4、5年了,里面文天祥、于谦这些著名历史人物的故事,虽然让我心潮澎湃,但过去这么久,故事的内容已经渐渐忘记了。反而是这个结尾,我每当想起这本书,或者看到别人提起这本书的时候,我总会想起来。

本文想结合自己这一段时间的探索,聊聊最近的一点想法。

努力探寻

一个月前,趁着年底,我写下了这篇文章,工作6年的程序员,如何挖掘内心,找到自己想要的东西

文章有一些朋友们看到了,也收获了一些点赞评论,对我而言是极大的正反馈。

WechatIMG147.jpg

我是一个一直喜欢自己写点东西的人,大学期间买了一本牛皮手帐,断断续续写了4年,里面有我的很多日记。

后来开始在印象笔记写,又断断续续写了大概2年的时间。但写的那些东西,无非是自己的碎碎念,没有整理发到网上,为别人带来一些价值或启发。
总量9.jpg

工作6年来,我自己根本不知道要去做些什么,或者自己感兴趣的到底是什么。我按部就班的好好工作,升职加薪,是这个时代和身边的人要求的,毕竟你不好好上进,那你似乎就对不起这个时代一样。

2022年,我的孩子出生了,从一个无忧无虑的青年,成为了一名父亲。过去20多年来,我身上的角色一直在增加,小时候我是父母的孩子,后来是老婆的伴侣,到现在,我又成了孩子的父亲。

俗话说 :“三十而立”,离着自己的30岁,只有不到四百天了,身份的叠加,给自己带来了更多的压力,同时我不甘心就这样碌碌无为下去。

相信很多人都和我一样,你做了很多,唯独却很少做过自己。

想都是问题,做才有答案

人如果这辈子能找到自己的天赋,是一件很幸运的事情。

开始行动吧。

尝试技术IP

混迹掘金这么久,相信你看过许多优秀的技术文章,各类大佬云集。

我自己在过去的面试过程中,也得益于网上的很多面试内容,看到了许多干货,做足了很多准备。

但是,我真的适合去这样做吗?

我想告诉大家的是,我现在也一直在面试别人,我也许会在在面试的时候,问问他synchronized的原理与底层实现,但是你让我再花时间去把他的原理整理出来,我可以很确定地说,我不会去做了。

因为我实在是没有这么感兴趣,我了解技术原理是为了解决问题,或者面试而已,我喜欢学习,但我不会主动钻研。

诶?那你怎么还写了几篇架构文章呢?

其实为了把我这几年来,学习过,但没有真正理解并吸收的东西,在拿出来重新学习,而写出的笔记罢了。输出可以极大的提升你自己的学习效率和吸收程度,这个我一会在下面的写作去讲。

副业,似乎没有这么简单

副业似乎越来越成为探寻出路的另一种途径,前有微商卖货,现在有抖音、小红书的无货源电商。

如果你真的感兴趣,还有生财等社群,里面有更多的副业玩法,比如流量主、视频号,你花钱的地方,就有人在赚钱。

最近下水尝试了闲鱼卖货,也是无货源的一种,身边的好兄弟,一年时间已经靠闲鱼,赚了10W+了。

但我做了大概半个多月,虽然啥也没卖出去,但是感觉过程挺有意思,有几点看法和大家分享下。

  1. 如果你还没有尝试过,可以玩一玩,任何副业,都要从小生意开始,程序员容易有一些偏见,觉着这些副业还不如主业,所以可以尝试一下。
  2. 深入了解了之后,发现看似卖闲置的平台,有很多厂家、个人在卖全新,而且持续投入,很赚钱。
  3. 货源及其重要!货源及其重要!货源及其重要!没有稳定和价格低的货源,无法当作长期来做。

写作,需要持续的刻意练习

将近一个月的时间,我写了以下文章。

掘金文章.jpg

为什么要写作?写作是一个基础能力。

最初是想打造个人IP,没有好的输出能力,是很难产出有价值的内容的,就无法吸引志同道合的朋友。

还有一点,通过输出倒逼自己输入,回想起来,自己已经很久没有研究过技术的内容了。

最后,写作还能够提升对事情的理解,也许大家也听过一句话,能把别人讲明白,你才是真的会了

曝光不少,阅读不高,点赞评论,就更不要说了。

我最近在学习《学会写作2.0》,发现写文章还有这么多门道

1.标题要起好,不然会影响转化

2.开头很重要,吸引读者往下阅读

3.结构要搭建,让读者理清思路,让文章脉络清晰

4.结尾要引人共鸣,调动情绪,有助于你文章的分享

书我还没读完,我已经感觉我不会写文章了(笑哭),标题我都不会取了,标题起码纠结半天,真是知道的越多,发现自己不知道的越多。

上面的几种尝试,是我作为一个普通人,可以想到去0成本尝试的几条路了,但到底适合自己吗,我不知道。

逃不过的焦虑,只是还没找到自己的热爱

在上面的尝试过程中,极大的开阔了自己的眼界,了解到了很多圈子,也发现自媒体行业,有这么多优秀的人:

  1. 有的00后,通过互联网项目,已经年入百万了。
  2. 也有优秀的技术博主,通过写技术文章积攒了自己的第一波粉丝。
  3. 更有会玩流量的大佬,经营着自己的私域流量,推出自己的产品,建立自己的社群,做到一呼百应。

了解的越多,越焦虑。

就在我焦虑的那段时间,我妈妈突然给我发了几句话,完全没有上下文,不知道她从哪里看到视频有感而发了。

WechatIMG154.jpg

结合我这一个月的经历,感触良多。

我突然就想清楚了一点,如果我还是亦步亦趋,那和过去十几年按部就班的生活,又有什么区别呢?

我也突然明白,见过这么多优秀的人,我焦虑的,并不是他们已经赚到了多少多少钱(或许多多少少有一点),而是羡慕他们有一份自己热爱的事业。

工作这几年,我的主业就是以收入为目的,而在能够照顾家庭的前提下,我现在我只想找到自己的兴趣,或者找到有复利的事情,来去试着寻找属于自己的事业了。

说在最后

听我碎碎念了这么多,很高兴你能看到这里。为什么我会写这篇文章,也是因为在探索的过程中,了解到了一种测试叫做“盖洛普测试”。

盖洛普测试是美国盖洛普咨询公司研发的产品,测的是一个人本能的心智模式(思维模式+行为模式),也就是行为处事方式,也是一个人的使用说明书。

这一阵子的迷茫,让我发现我对自己其实一无所知,对自己的不了解,也就更不知道要怎么做,才能在做事情的时候扬长避短。毕竟,你咬牙坚持的东西,别人可能轻轻松松就可以做到。

虽然目前还没有一个明确的方向,但我会持续探索,把探索的过程分享出来。

最后,不知道你是不是已经找到自己热爱的事情呢?欢迎点赞评论,每一个评论我都会认真回答。也欢迎加我的wx:Ldhrlhy10,加我交流,一起进步,成为更好的自己。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

2023年,我创业了

发表于 2024-01-16

如标题所述,2023 年最后一个月,我选择了创业,内容是知识付费赛道小程序。

本篇文章会详细和大家聊聊小程序是做什么的,以及做的过程中发生的有意思的事。当然,还有大家可能比较关心的收益情况。如何进入小程序详情见文末。

本文目录大纲:

  • 一个人写小程序?
  • 为什么做小程序?
  • 小程序主要功能有什么?
  • 上线后收益如何?

一个人写小程序?

如果是熟悉我的老读者可能会清楚,我自己的开发习惯是喜欢单兵作战。

开源框架如 12306、CongoMall 以及 SaaS 短链接系统基本上是我独立完成的从零到一的工作,也曾迅速从零开始搭建开源动态线程池框架 Hippo4j。

如果放到两年前,就自己撸起袖子干了,主打一个慢慢写迟早能搞定。但最近这两年意识到了,自己不能一味的埋头写代码,得把精力放到类似于打磨和销售产品方面。因为代码别人也能写,但是初期的销售,还得是我先把路走出来才行。

另外,专业的事交给专业的人做,我自己就算能 Cove 多端角色,但是前端一直是我比较薄弱的点,写出的 UI 一直被自己所嫌弃。为了写一个流行的软件,就得摒弃之前的习惯,不能降低基本要求。

自己也是比较幸运,身边有一帮靠谱的朋友,在和大家沟通完想法后,迅速把想法加以落地。

团队成员如下:

  • 产品:亮哥
  • 前端:男哥
  • 后端:光哥
  • 测试:花哥
  • 产品+前端+后端+基测+压测+运维:小马

默默为最后一位小马默哀,说好听点算是能者多劳,真实情况我是一块砖,哪里需要往哪搬,基本上全流程都会涉及。

显而易见,团队的战斗力非常抗打,最终展现出了现在大话面试的成品。

为什么做小程序?

我本来要做的大话面试,是一个小册子,就是包揽市场上常见的面试题,做面试题精品回答,可以直接和面试官 Battle 那种。

看到这里可能会比较疑问:就这?搞个语雀,然后放星球、小商店或者小鹅通上卖不就行了,和做小程序有什么关系?

其实,构思大话面试的开始,我最最最不希望搞得就是自己独立建站,因为这会增加被攻击的风险,远不如直接使用 SaaS 产品。

后来想了挺多,如果只是写八股文面试题答案,是不需要大动干戈组团队写小程序。但当我在写这个的过程中,有了一些其他的灵感:能不能收集企业真实的面经,搭配上我写的八股文回答,让用户享受考试卷子带答案的体验?

如果能做成这件事情,我想会是一件很酷的事情。

考虑了很久,也和团队里的成员沟通了多次,觉得这是个不错的想法。做“伟大”的事需要冲动,某个晚上下定了决心,那就做!

小程序主要功能有什么?

上面铺垫了挺久,相信大家都等不及想看看这玩意到底是做什么的了。

总得来说,大话面试能做的事很多,但是根本还是面经和八股回答。

1. 企业真实面经

试想,当你要去面试一家公司前,如果能有一份高质量的岗位面经,那该有多好。如果这个面经还配有答案,是不是感觉随时有拿下面试的可能。

这上面存在两个问题:

  • 市场上的面经比较混乱,发布者也不会给你整理的很详细,完整性得不到保证。
  • 面经只有问题,答案需要面试者自己去各大网站上去整理,精力比较分散。

为此,大话面试开发的面经充分考虑到了以上需求,做出了让大家满意的功能。

首页上展示的是招聘岗位对应的企业面经,值得注意的是,这里面经的粒度并不是公司级别,而是公司下的子公司下的某个岗位,真正做到了岗位精准匹配。

岗位下的信息是本场面试中被问到频率最高的几个技术栈。

每一个面经都会标记着对应的面试难度,一共分为四个等级:简单、一般、困难和地域。

这个面试难度可能是面试者给我的,或者是我自己评估的。

点击某个面经进去,会出现面经的一些基本资料、面试评价以及具体的问题。

其中,被大家认为很有用的信息是面试评价,因为这可以有效帮你分辨常规面试或者是 KPI 面试,以及面试官的水平和侧重点问题。

再往下的话,就是面试岗位的重点,多轮面试都被问了哪些具体问题。

如果问题显示暂未收录,代表当前大话面试中还没有写出对应匹配的答案。

如果问题显示知识标签,点击该问题可以直接跳转到具体的文章详情中,查看标准答案。

有同学可能就有疑问了,这个岗位面经什么意思?

顾名思义,该公司下的子公司下的具体招聘岗位,共被大话面试收录了多少面经数量。点击列表上的内容,可以直接跳转面经详情。

这个功能是很实用的,一场面试可能无法得出面试问题侧重点,如果多场那就基本能得出结论了。

2. 八股文标准回答

目前大话面试已有精品八股回答 74 篇,可以保证这些绝对都是原创!字数 11w 字,并且这个八股文章的维护数量还在持续增加。

我的目标是大话面试八股手册成为技术人员面试必备,完成这个看似“遥远”的目标需要走很长的路。

为了尽快实现自己夸下的“海口”,在写这个小册子之前,规划好“脱圈”三范式:

  • 内容描述同时包含精简干练以及技术深度,虽然听着有些矛盾,但你看下去就懂了。
  • 文章插图“见图知意”,每篇文章至少一张配图起步。
  • 每个面试问题配套对应的视频回答(规划中),帮助面试者从大量文献中总结问题精要。

2.1. 面试回答核心亮点

我觉得大话面试后面之所以能火,重点在于你读了每一篇文章后,可以直接通过“背”话术的方式“吊打”面试官。

有很多同学对比众多网上技术文章后,发现《大话面试》的文字和排版看着很舒服?

每一个知识库都值得遵守的写作规范:GitHub - ruanyf/document-style-guide: 中文技术文档的写作规范

2.2. 为什么区分答题思路和回答话术?

大家可能会疑惑,为什么一篇面试八股中有两个面试回答?因为这分别对应着不同的场景:

  • 答题思路:精准回答每一个面试问题最直接的答案,不会在此基础上进行深度扩展。
  • 回答话术:在标准答案的基础上,进行深度扩展,比如 Redis 数据结构优化这一块,在回答话术的体现上就是,会提起字符串和跳表这两种典型结构的底层是如何优化的。这样既能向面试官展现自己的技术深度,同时又不至于过于啰嗦。

2.3. 回答面试官答题思路还是回答话术?

通过我之前面试以及在拿个offer社群中了解,面试官大致分为两种:

  • 喜欢自己问:回答标准答案,然后让面试官在我们的回答中或者他自己的逻辑中,问这个问题更深入的底层知识。
  • 喜欢听别人讲:这种我们就直接说回答话术,尽可能的将技术深度讲到位。你说的越多,他对你的技术功底越了解。

2.4. 持续更新

为了让大话面试回答涵盖绝大部分面试中常问的八股,所以我们会持续更新这个知识库,直到 Java 被“淘汰”。在这期间,会严格按照高质量的方式写好每一篇八股问答。

我承诺这个小册子将会是我付费项目中维护时间最为长远的内容。100篇?200篇?这些都不会是小册子的终点,直到解析完所有面试中常问八股、场景题为止。

2.5. 小册子更新顺序

面试频率最高的八股类别分别是:缓存、数据库、消息队列、场景题等,再细分的话,分别是:Redis、缓存、消息队列、RocketMQ、数据库、MySQL、分库分表等。大话面试的第一优先级将会以“最热”的八股开始写,其它类别再逐步更新。

上线后收益如何?

介绍完具体功能后,终于能说说收益情况了。不过,大家还得等一哈。在说具体收益之前,先和大家聊聊小程序的盈利机制。

1. 小程序如何盈利?

小程序收益主要来自两部分,大头都来自于会员订阅,还有一部分是付费专栏。在这里着重介绍下会员订阅功能。

会员订阅分为两个等级,标准会员和专业会员。

1.1. 标准会员

标准会员具备三项权益,进入小程序免除广告、大话面试社群以及解锁全部八股文回答文章。

其中最为重要的就是解锁全部八股文章了,因为普通用户每天能查看 6 篇八股回答,最多查看 18 篇,而且有些付费类型的八股回答是没办法查看的。

开通标准会员后,可以随意查看任意回答,并且可以拉你进大话面试内部沟通群。

1.2. 专业会员

专业会员的权益相比对标准会员,在此基础上增加了三项,部分专栏免费观看、全部专栏八折购买以及云中间件共享使用。

  • 部分专栏免费观看:专栏分为两类型,专业会员专属以及单独付费订阅的。如果是专业会员,前者可免费加入学习。
  • 专栏八折购买:全场需要单独付费订阅的专栏,一律八折购买。
  • 云中间件:我购买几台公共云中间件,从里面虚拟化一些 MySQL、Redis、RocketMQ 以及 Kafka 等,帮助大家节省本地启动内存。

2. 上线当天收益

昨天晚上八点左右上线的,当天晚上在 B 站直播了两个小时,和一部分同学沟通了下小程序,加上 B 站的自然流量,一共十几位同学加入大话面试会员。在这里非常感谢各位的信任!

文末总结

文笔不好,篇幅有限,几千字远无法描述这段时间的心路历程。就像文中说的,做一件事就得需要冲动,要想知道一件事行与不行,实践是唯一标准。

感觉我写的大话面试小程序还可以,欢迎关注,下一篇和大家聊聊小程序的部署架构。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

前任开发在代码里下毒了,支付下单居然没加幂等

发表于 2024-01-16

分享是最有效的学习方式。

故事

又是一个风和日丽没好的一天,小猫戴着耳机,安逸地听着音乐,撸着代码,这种没有会议的日子真的是巴适得板。

不料祸从天降,组长火急火燎地跑过来找到了小猫。“快排查一下,目前有A公司用户反馈积分被多扣了”。

小猫回忆了一下“不对啊,这接口我也没动过啊,前几天对外平台的老六直接找我要个支付接口,我就给他了的,以前的代码,我都没有动过的……”。

于是小猫一边疑惑一边翻看着以前的代码,越看脸色越差……

42175B273A64E95B1B5B66D392256552.jpg

小猫做的是一个标准的积分兑换商城,以前和客户合作的时候,客户直接用的是小猫单位自己定制的h5页面。这次合作了一家公司有点特殊,由于公司想要定制化自己个性化的H5,加上本身A公司自己有开发能力,所以经过讨论就以接口的方式直接将相关接口给出去,A客户H5开发完成之后自己来对接。

慢慢地,原因也水落石出,之前好好的业务一直没有问题是因为商城的本身H5页面做了防重复提交,由于量小,并且一般对接方式用的都是纯H5,所以都没有什么问题,然后这次是直接将接口给出去了,完了接口居然没有加幂等……

小猫躺枪,数据订正当然是少不了了,事故报告当然也少不了了。

正所谓前人挖坑,后人遭殃,前人锅后人背。

聊聊幂等

接口幂等梗概

这个案例其实就是一个典型的接口幂等案例。那么老猫就和大家从以下几个方面好好剖析一下接口幂等吧。

interfacemd.png

什么是接口幂等

比较专业的术语:其任意多次执行所产生的影响均与第一次执行的影响相同。
大白话:多次调用的情况下,接口最终得到的结果是一致的。

那么为什么需要幂等呢?

  1. 用户进行提交动作的时候,由于网络波动等原因导致后端同步响应不及时,这样用户就会一直点点点,这样机会发生重复提交的情况。
  2. 分布式系统之间调用的情况下,例如RPC调用,为了防止网络波动超时等造成的请求失败,都会添加重试机制,导致一个请求提交多次。
  3. 分布式系统经常会用到消息中间件,当由于网络原因,mq没有收到ack的情况下,就会导致消息的重复投递,从而就会导致重复提交行为。
  4. 还有就是恶意攻击了,有些业务接口做的比较粗糙,黑客找到漏洞之后会发起重复提交,这样就会导致业务出现问题。打个比方,老猫曾经干过,邻居小孩报名了一个画画比赛,估计是机构培训发起的,功能做的也差,需要靠投票赢得某些礼品,然后老猫抓到接口信息之后就模拟投票进行重复刷了投票。

那么哪些接口需要做幂等呢?

首先我们说是不是所有的接口都需要幂等?是不是加了幂等就好呢?显然不是。
因为接口幂等的实现某种意义上是要消耗系统性能的,我们没有必要针对所有业务接口都加上幂等。

这个其实并不能做一个完全的定义说哪个就不用幂等,因为很多时候其实还是得结合业务逻辑一起看。但是其中也是有规律可循的。

既然我们说幂等就是多次调用,接口最终得到结果一致,那么很显然,查询接口肯定是不要加幂等的,另外一些简单删除数据的接口,无论是逻辑删除还是物理删除,看场景的情况下其实也不用加幂等。

但是大部分涉及到多表更新行为的接口,咱们最好还是得加上幂等。

接口幂等实战方案

前端防抖处理

前端防抖主要可以有两种方案,一种是技术层面的,一种是产品层面的:

  1. 技术层面:例如提交控制在100ms内,同一个用户最多只能做一次订单提交的操作。
  2. 产品层面:当然用户点击提交之后,按钮直接置灰。

基于数据库唯一索引

  1. 利用数据库唯一索引。我们具体来看一下流程,咱们就用小猫遇到的例子。如下:

unique-key.png

过程描述:

  • 建立一张去重表,其中某个字段需要建立唯一索引,例如小猫这个场景中,咱们就可以将订单提交流水单号作为唯一索引存储到我们的数据库中,就模型上而言,可以将其定义为支付请求流水表。
  • 客户端携带相关流水信息到后端,如果发现编号重复,那么此时就会插入失败,报主键冲突的错误,此时我们针对该错误做一下业务报错的二次封装给到客户另一个友好的提示即可。

数据库乐观锁实现

什么是乐观锁,它假设多用户并发的事务在处理时不会彼此互相影响,各事务能够在不产生锁的情况下处理各自影响的那部分数据。
说得直白一点乐观锁就是一个马大哈。总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,只在更新的时候会判断一下在此期间别人有没有去更新这个数据。

例如提交订单的进行支付扣款的时候,本来可能更新账户金额扣款的动作是这样的:

1
sql复制代码update Account set balance = balance-#{payAmount} where accountCode = #{accountCode}

加上版本号之后,咱们的代码就是这样的。

1
sql复制代码update Account set balance = balance-#{payAmount},version=version +1 where accountCode = #{accountCode} and version = #{currVersion}

这种情况下其实就要求客户端每次在请求支付下单的时候都需要上层客户端指定好当前的版本信息。
不过这种幂等的处理方式,老猫用的比较少。

数据库悲观锁实现

悲观锁的话具有强烈的独占和排他特性。大白话谁都不信的主。所以我们就用select … for update这样的语法进行行锁,当然老猫觉得单纯的select … for update只能解决同一时刻大并发的幂等,所以要保证单号重试这样非并发的幂等请求还是得去校验当前数据的状态才行。就拿当前的小猫遇到的场景来说,流程如下:

pessimistic.png

1
2
3
4
5
6
7
8
9
10
java复制代码begin;  # 1.开始事务
select * from order where order_code='666' for update # 查询订单,判断状态,锁住这条记录
if(status !=处理中){
//非处理中状态,直接返回;
return ;
}
## 处理业务逻辑
update order set status='完成' where order_code='666' # 更新完成
update stock set num = num - 1 where spu='xxx' # 库存更新
commit; # 5.提交事务

这里老猫一再想要强调的是在校验的时候还是得带上本身的业务状态去做校验,select … for update并非万能幂等。

后端生成token

这个方案的本质其实是引入了令牌桶的机制,当提交订单的时候,前端优先会调用后端接口获取一个token,token是由后端发放的。当然token的生成方式有很多种,例如定时刷新令牌桶,或者定时生成令牌并放到令牌池中,当然目的只有一个就是保住token的唯一性即可。

生成token之后将token放到redis中,当然需要给token设置一个失效时间,超时的token也会被删除。

当后端接收到订单提交的请求的时候,会先判断token在缓存中是否存在,第一次请求的时候,token一定存在,也会正常返回结果,但是第二次携带同一个token的时候被拒绝了。

流程如下:

token.png

有个注意点大家可以思考一下:
如果用户用程序恶意刷单,同一个token发起了多次请求怎么办?
想要实现这个功能,就需要借助分布式锁以及Lua脚本了,分布式锁可以保证同一个token不能有多个请求同时过来访问,lua脚本保证从redis中获取令牌->比对令牌->生成单号->删除令牌这一系列行为的原子性。

分布式锁+状态机(订单状态)

现在很多的业务服务都是分布式系统,所以就拿分布式锁来说,关于分布式锁,老猫在此不做赘述,之前老猫写过redis的分布式锁和实现,还有zk锁和实现,具体可见链接:

  1. 锁的演化
  2. 手撕redis分布式锁
  3. 手撸ZK锁

当然和上述的数据库悲观锁类似,咱们的分布式锁也只能保证同一个订单在同一时间的处理。其次也是要去校订单的状态,防止其重复支付的,也就是说,只要支付的订单进入后端,都要将原先的订单修改为支付中,防止后续支付中断之后的重复支付。

在上述小猫的流程中还没有涉及到现金补充,如果涉及到现金补充的话,例如对接了微信或者支付宝的情况,还需要根据最终的支付回调结果来最终将订单状态进行流转成支付完成或者是支付失败。

总结

在我们日常的开发中,一些重要的接口还是需要大家谨慎对待,即使是前任开发留下的接口,没有任何改动,当有人咨询的时候,其实就要好好去了解一下里面的实现,看看方案有没有问题,看看技术实现有没有问题,这应该也是每一个程序员的基本素养。

另外的,在一些重要的接口上,尤其是资金相关的接口上,幂等真的是相当的重要。小伙伴们,你们觉得呢?如果大家还有好的解决方案,或者有其他思考或者意见也欢迎大家的留言。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Spring解决泛型擦除的思路不错,现在它是我的了。

发表于 2024-01-15

你好呀,我是歪歪。

Spring 的事件监听机制,不知道你有没有用过,实际开发过程中用来进行代码解耦简直不要太爽。

但是我最近碰到了一个涉及到泛型的场景,常规套路下,在这个场景中使用该机制看起来会很傻,但是最终了解到 Spring 有一个优雅的解决方案,然后去了解了一下,感觉有点意思。

和你一起盘一盘。

Demo

首先,第一步啥也别说,先搞一个 Demo 出来。

需求也很简单,假设我们有一个 Person 表,每当 Person 表新增或者修改一条数据的时候,给指定服务同步一下。

伪代码非常的简单:

1
2
3
4
5
scss复制代码boolean success = addPerson(person)
if(success){
    //发送person,add代表新增
    sendToServer(person,"add");
}

这代码能用,完全没有任何问题。

但是,你仔细想,“发给指定服务同步一下”这样的动作按理来说,不应该和用户新增和更新的行为“耦合”在一起,他们应该是两个独立的逻辑。

所以从优雅实现的角度出发,我们可以用 Spring 的事件机制进行解耦。

比如改成这样:

1
2
3
4
scss复制代码boolean success = addPerson(person)
if(success){
    publicAddPersonEvent(person,"add");
}

addPerson 成功之后,直接发布一个事件出去,然后“发给指定服务同步一下”这件事情就可以放在事件监听器去做。

对应的代码也很简单,新建一个 SpringBoot 工程。

首先我们先搞一个 Person 对象:

1
2
3
4
5
6
7
8
arduino复制代码@Data
public class Person {
    private String name;

    public Person(String name) {
        this.name = name;
    }
}

由于我们还要告知是新增还是修改,所以还需要搞个对象封装一层:

1
2
3
4
5
6
7
8
9
10
11
12
kotlin复制代码@Data
public class PersonEvent {

    private Person person;

    private String addOrUpdate;

    public PersonEvent(Person person, String addOrUpdate) {
        this.person = person;
        this.addOrUpdate = addOrUpdate;
    }
}

然后搞一个事件发布器:

1
2
3
4
5
6
7
8
9
10
11
12
less复制代码@Slf4j
@RestController
public class TestController {

    @Resource
    private ApplicationContext applicationContext;

    @GetMapping("/publishEvent")
    public void publishEvent() {
        applicationContext.publishEvent(new PersonEvent(new Person("why"), "add"));
    }
}

最后来一个监听器:

1
2
3
4
5
6
7
8
9
10
less复制代码@Slf4j
@Component
public class EventListenerService {

    @EventListener
    public void handlePersonEvent(PersonEvent personEvent) {
        log.info("监听到PersonEvent: {}", personEvent);
    }

}

Demo 就算是齐活了,你把代码粘过去,也用不了一分钟吧。

启动服务跑一把:

看起来没有任何毛病,在监听器里面直接就监听到了。

这个时候假设,我还有一个对象,叫做 Order,每当 Order 表新增或者修改一条数据的时候,也要给指定服务同步一下。

怎么办?

这还不简单?

照葫芦画瓢呗。

先来一个 Order 对象:

1
2
3
4
5
6
7
8
arduino复制代码@Data
public class Order {
    private String orderName;

    public Order(String orderName) {
        this.orderName = orderName;
    }
}

再来一个 OrderEvent 封装一层:

1
2
3
4
5
6
7
8
9
10
11
12
vbnet复制代码@Data
public class OrderEvent {
    
    private Order order;

    private String addOrUpdate;

    public OrderEvent(Order order, String addOrUpdate) {
        this.order = order;
        this.addOrUpdate = addOrUpdate;
    }
}

然后再发布一个对应的事件:

新增一个对应的事件监听:

发起调用:

完美,两个事件都监听到了。

那么问题又来了,假设我还有一个对象,叫做 Account,每当 Account 表新增或者修改一条数据的时候,也要给指定服务同步一下。

或者说,我有几十张表,对应几十个对象,都要做类似的同步。

请问阁下又该如何应对?

你当然可以按照前面处理 Order 的方式,继续依葫芦画瓢。

但是这样势必会来带的一个问题是对象的膨胀,你想啊,毕竟每一个对象都需要一个对应的 xxxxEvent 封装对象。

这样的代码过于冗余,丑,不优雅。

怎么办?

自然而然的我们能想到泛型,毕竟人家干这个事儿是专业的,放一个通配符,管你多少个对象,通通都是“T”,也就是这样的:

1
2
3
4
5
6
7
8
9
10
11
kotlin复制代码@Data
class BaseEvent<T> {
    private T data;
    private String addOrUpdate;

    public BaseEvent(T data, String addOrUpdate) {
        this.data = data;
        this.addOrUpdate = addOrUpdate;
    }
    
}

对应的事件发布的地方也可以用 BaseEvent 来代替:

这样用一个 BaseEvent 就能代替无数的 xxxEvent,做到通用,这是它的好处。

同时对应的监听器也需要修改:

启动服务,跑一把。

发起调用之后你会发现控制台正常输出:

但是,注意我要说但是了。

但是监听这一坨代码我感觉不爽,全部都写在一个方法里面了,需要用非常多的 if 分支去做判断。

而且,假设某些对象在同步之前,还有一些个性化的加工需求,那么都会体现在这一坨代码中,不够优雅。

怎么办呢?

很简单,拆开监听:

但是再次重启服务,发起调用你会发现:控制台没有输出了?怎么回事,怎么监听不到了呢?

官网怎么说?

在 Spring 的官方文档中,关于泛型类型的事件通知只有寥寥数语,但是提到了两个解决方案:

docs.spring.io/spring-fram…

首先官网给出了这样的一个泛型对象:EntityCreatedEvent

然后说比如我们要监听 Person 这个对象创建时的事件,那么对应的监听器代码就是这样的:

1
2
3
4
typescript复制代码@EventListener
public void onPersonCreated(EntityCreatedEvent<Person> event) {
 // ...
}

和我们 Demo 里面的代码结构是一样的。

那么怎么才能触发这个监听呢?

第一种方式是:

1
csharp复制代码class PersonCreatedEvent extends EntityCreatedEvent<Person> { … }).

也就是给这个对象创造一个对应的 xxxCreatedEvent,然后去监听这个 xxxCreatedEvent。

和我们前面提到的 xxxxEvent 封装对象是一回事。

为什么我们必须要这样做呢?

官网上提到了这几个词:

Due to type erasure

type erasure,泛型擦除。

因为泛型擦除,所以导致直接监听 EntityCreatedEvent 事件是不生效的,因为在泛型擦除之后,EntityCreatedEvent 变成了 EntityCreatedEvent<?>。

封装一个对象继承泛型对象,通过他们之间一一对应的关系从而绕开泛型擦除这个问题,这个方案确实是可以解决问题。

但是,前面说了,不够优雅。

官网也觉得这个事情很傻:

它怎么说的呢?

In certain circumstances, this may become quite tedious if all events follow the same structure.

在某些情况下,如果所有事件都遵循相同的结构,这可能会变得相当 tedious。

好,那么 tedious,是什么意思?哪个同学举手回答一下?

这是个四级词汇,得认识,以后考试的时候要考:

quite tedious,相当啰嗦。

我们都不希望自己的程序看起来是 tedious 的。

所以,官方给出了另外一个解决方案:ResolvableTypeProvider。

我也不知道这是在干什么,反正我拿到了代码样例,那我们就白嫖一下嘛:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
kotlin复制代码@Data
class BaseEvent<T> implements ResolvableTypeProvider {
    private T data;
    private String addOrUpdate;

    public BaseEvent(T data, String addOrUpdate) {
        this.data = data;
        this.addOrUpdate = addOrUpdate;
    }

    @Override
    public ResolvableType getResolvableType() {
        return ResolvableType.forClassWithGenerics(getClass(), ResolvableType.forInstance(getData()));
    }
}

再次启动服务,你会发现,监听器又好使了:

那么问题又来了。

这是为什么呢?

为什么?

我也不知道为什么,但是我知道源码之下无秘密。

所以,先打上断点再说。

关于 @EventListener 注解的原理和源码解析,我之前写过一篇相关的文章:《扯下@EventListener这个注解的神秘面纱。》

有兴趣的可以看看这篇文章,然后再试着按照文章中的方式去找对应的源码。

我这篇文章就不去抽丝剥茧的一点点找源码了,直接就是一个大力出奇迹。

因为我们已知是 ResolvableTypeProvider 这个接口在搞事情,所以我只需要看看这个接口在代码中被使用的地方有哪些:

除去一些注释和包导入的地方,整个项目中只有 ResolvableType 和 MultipartHttpMessageWriter 这个两个中用到了。

直觉告诉我,应该是在 ResolvableType 用到的地方打断点,因为另外一个类看起来是 Http 相关的,和我的 Demo 没啥关系。

所以我直接在这里打上断点,然后发起调用,程序果然就停在了断点处:

org.springframework.core.ResolvableType#forInstance

我们观察一下,发现这几行代码核心就干一个事儿:判断 instance 是不是 ResolvableTypeProvider 的子类。

如果是则返回一个 type,如果不是则返回 forClass(instance.getClass())。

通过 Debug 我们发现 instance 是 BaseEvent:

巧了,这就是 ResolvableTypeProvider 的子类,所以返回的 type 是这样式儿的:

com.example.elasticjobtest.BaseEvent<com.example.elasticjobtest.Person>

是带具体的类型的,而这个类型就是通过 getResolvableType 方法拿到的。

前面我们在实现 ResolvableTypeProvider 的时候,就重写了 getResolvableType 方法,调用了 ResolvableType.forClassWithGenerics,然后用 data 对应的真正的 T 对象实例的类型,作为返回值,这样泛型对应的真正的对象类型,就在运行期被动态的获取到了,从而解决了编译阶段泛型擦除的问题。

如果没有实现 ResolvableTypeProvider 接口,那么这个方法返回的就是 BaseEvent<?>:

com.example.elasticjobtest.BaseEvent<?>

看到这里你也就猜到个七七八八了。

都已经拿到具体的泛型对象了,后面再发起对应的事件监听,那不是顺理成章的事情吗?

好,现在你在第一个断点处就收获到了一个这么关键的信息,接下来怎么办呢?

接着断点处往下调试,然后把整个链路都梳理清楚呗。

再往下走,你会来到这个地方:

org.springframework.context.event.AbstractApplicationEventMulticaster#getApplicationListeners

从 cache 里面获取到了一个 null。

因为这个缓存里面放的就是在项目启动过程中已经触发过的框架自带的 listener 对象:

调用的时候,如果能从缓存中拿到对应的 listener,则直接返回。而我们 Demo 中的自定义 listener 是第一次触发,所以肯定是没有的。

因此关键逻辑就这个方法的最后一行:retrieveApplicationListeners 方法里面

org.springframework.context.event.AbstractApplicationEventMulticaster#retrieveApplicationListeners

这个地方再往下写,就是我前面我提到的这篇文章中我写过的内容了《扯下@EventListener这个注解的神秘面纱。》。

和泛型擦除的关系已经不大了,我就不再写一次了。

只是给大家看一下这个方法在我们的 Demo 中,最终返回的 allListeners 就是我们自定义的这个事件监听器:

com.example.elasticjobtest.EventListenerService#handlePersonEvent

为什么是这个?

因为我当前发布的事件的主角就是 Person 对象:

同理,当 Order 对象的事件过来的时候,这里肯定就是对应的 handleOrderEvent 方法:

如果我们把 BaseEvent 的 ResolvableTypeProvider 接口拿掉,那么你再看对应的 allListeners,你就会发现找不到我们对应的自定义 Listener 了:

为什么?

因为当前事件对应的 ResolvableType 是这样的:

org.springframework.context.PayloadApplicationEvent<com.example.elasticjobtest.BaseEvent<?>>

而我们并没有自定义一个这样的 Listener:

1
2
3
4
typescript复制代码@EventListener
public void handleAllEvent(BaseEvent<?> orderEvent) {
    log.info("监听到Event: {}", orderEvent);
}

所以,这个事件发布了,但是没有对应的消费。

大概就是这么个意思。

核心逻辑就在 ResolvableTypeProvider 接口里面,重写了 getResolvableType 方法,在运行期动态的获取泛型对应的真正的对象类型,从而解决了编译阶段泛型擦除的问题。

很好,现在摸清楚了,是个很简单的思路,之前是 Spring 的,现在它是我的了。

为什么需要发布订阅模式 ?

既然写到 Spring 的事件通知机制了,那么就顺便聊聊这个发布订阅模式。

也许在看的过程中,你会冒出这样一个问题:为什么要搞这么麻烦?把这些事件监听的业务逻辑直接写在对应的数据库操作语句之后不行么?

要回答这个问题,我们可以先总结一下事件通知机制的使用场景。

  1. 数据变化之后同步清除缓存,这是一种简单可靠的缓存更新方式。只有在清除失败,或者数据库主从同步间隙被脏读才有可能出现缓存脏数据,概率比较小,一般业务上也是可以接受的。
  2. 通过某种方式告诉下游系统数据变化,比如往消息队列里面扔消息。
  3. 数据的统计、监控、异步触发等场景。当然这动作似乎用 AOP 也可以做,但是实际上在某些业务场景下,做切面统计,反而没有通过发布订阅机制来得直接,灵活度也更好。

除了上面这些外,肯定还有一些其他的场景,但是这些场景都有一个共同点:与核心业务关系不大,但是又具备一定的普适性。

比如完成用户注册之后给用户发一个短信,或者发个邮件啥的。这个事情用发布订阅机制来做是再合适不过的了。

编码过程中牢记单一职责原则,要知道一个类该干什么不该干什么,这是面向对象编程 的关键点之一。

当你一个类中注入了大量的 Service 的时候,你就要考虑考虑,是不是有什么做的不合适的地方了,是不是有些 Service 其实不应该注入进来的。

是不是该用用发布订阅了?

另外,当你的项目中真的出现了文章最开始说的,各种各样的 xxxEvent 事件对应的封装的时候,任何一个来开发的人都觉得这样写是不是有点冗余的时候,你就应该考虑一下是不是有更加优雅的解决方案。

假设这个方案由于某些原因不能使用或者不敢使用是一回事。

但是知不知道这个方案,是另一回事。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Docker真的被Kubernetes放弃了吗?

发表于 2024-01-15

首先,要明确的是,Kubernetes 并没有完全放弃 Docker,而是改变了对 Docker 的使用方式。

这一改变主要是因为 Kubernetes 1.20 版本开始,宣布弃用了 Docker 作为容器运行时的支持(Dockershim 的移除)。这意味着,虽然在 Kubernetes 集群中可以运行用 Docker 构建的容器镜像,但 Kubernetes 将不再使用 Docker 作为容器运行时。

本文已收录于,我的技术网站 ddkk.com,有大厂完整面经,工作技术,架构师成长之路,等经验分享

1、Docker 是什么:

Docker 是一个开源的应用容器引擎,它允许开发者打包他们的应用及依赖包到一个可移植的容器中,然后发布到任何流行的 Linux 机器上,也可以实现虚拟化。容器是完全使用沙盒机制,相互之间不会有任何接口(类似 iPhone 的 app),更重要的是容器性能开销极低。

Docker 使用的是客户端-服务器 (C/S) 架构模式,使用远程API来管理和创建Docker容器。Docker 容器通过 Docker 镜像来创建。镜像可以看作是容器的“模版”,而容器则是这些模版的实例化对象。Docker 提供了一个非常便捷的镜像使用方式,除了可以使用本地镜像外,还可以从 Docker Hub 上下载数以万计的镜像使用。

2、Kubernetes 是什么:

Kubernetes(也称为 K8s)是用于自动部署、扩展和管理容器化应用程序的开源系统。它由 Google 设计并捐赠给 Cloud Native Computing Foundation 来维护。Kubernetes 提供了一个用于部署应用程序的框架,支持应用程序的扩展和故障处理等功能,还提供了一系列的工具和服务以实现各种需求。

Kubernetes 的核心功能包括:

  • 自动化容器的部署和复制
  • 随时扩展或缩减容器数量
  • 将容器组织成组并提供容器间的负载均衡
  • 服务发现和负载均衡
  • 自动挂载存储系统
  • 自动化的滚动更新
  • 自我修复,如重新启动失败的容器

3、Docker 和 Kubernetes 的区别:

虽然 Docker 和 Kubernetes 都是与容器化技术相关的工具,但它们在某些方面有着明显的不同。

使用范围和目的:Docker 主要关注的是容器的打包和运行,简化了应用程序的交付。而 Kubernetes 更加关注的是容器的协调和管理,包括自动部署、扩展、运行和调度容器。

设计和架构:Docker 使用简单的设计,易于理解和使用。它可以在单机上运行,也可以结合 Docker Swarm 在多机上协同工作。Kubernetes 则更加复杂,提供更多的功能和更高的灵活性,它是为在集群上大规模运行和管理容器化应用设计的。

功能和特性:Docker 直接处理容器的创建和运行,而 Kubernetes 提供了更复杂的调度器和集群管理工具。Kubernetes 能够管理和调度多个容器组成的应用,具有自动扩展、自愈等高级特性。

生态系统和社区:虽然 Docker 和 Kubernetes 都拥有强大的社区支持,但 Kubernetes 在云计算和微服务领域的生态系统更为丰富,支持更多的云平台和产品集成。

总的来说,Docker 更专注于单个容器的生命周期,而 Kubernetes 更关注容器集群的整体管理。在微服务和云原生应用的趋势下,两者往往是相辅相成的关系,Docker 用于容器化应用,而 Kubernetes 用于管理这些容器化的应用。

Kubernetes 对 Docker 的使用方式改变:

首先,要明确的是,Kubernetes 并没有完全放弃 Docker,而是改变了对 Docker 的使用方式。这一改变主要是因为 Kubernetes 1.20 版本开始,宣布弃用了 Docker 作为容器运行时的支持(Dockershim 的移除)。这意味着,虽然在 Kubernetes 集群中可以运行用 Docker 构建的容器镜像,但 Kubernetes 将不再使用 Docker 作为容器运行时。

这个改变背后的原因是 Docker 和 Kubernetes 之间的技术差异。Docker 是一个包含多种功能的大型应用,除了容器运行时之外,还包括图像管理、存储、网络等。而 Kubernetes 实际上只需要容器运行时这一部分功能。因此,为了减轻 Kubernetes 的负担,更高效地管理容器,Kubernetes 开始支持更加轻量级和标准化的容器运行时接口(CRI)。

Kuberetes 并没有淘汰 Docker:

这并不意味着 Docker 被淘汰,因为 Docker 构建的容器镜像仍然可以在 Kubernetes 中运行。只是 Kubernetes 会使用其他容器运行时(如 containerd 或 CRI-O)来直接运行这些镜像。Docker 镜像本身符合 OCI(Open Container Initiative)标准,因此可以被任何标准的容器运行时使用。

Docker 在 Kubernetes 生态中的角色:

在这种情况下,Docker 更像是一个开发工具,而不是在生产环境中的容器运行时。开发者仍然可以使用 Docker 来构建、测试容器镜像,然后将这些镜像部署到 Kubernetes 集群中。实际上,这种变化让 Kubernetes 变得更加高效,因为它可以直接与底层容器运行时接口交互,减少了不必要的中间层。

两个代码示例来展示如何在 Kubernetes 环境中使用容器。第一个示例是一个 Dockerfile,用于创建一个简单的 Docker 镜像;第二个示例是一个 Kubernetes 的部署配置文件(Deployment)用于在 Kubernetes 集群中部署这个镜像。这两个示例将展示如何将一个 Docker 容器化的应用部署到 Kubernetes 集群中。

示例 1:Dockerfile

这个 Dockerfile 示例将创建一个简单的 Node.js 应用的 Docker 镜像。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
bash复制代码# 使用官方的 Node.js 基础镜像作为构建环境
FROM node:14

# 设置工作目录为 /app
WORKDIR /app

# 将 package.json 和 package-lock.json 复制到容器中
COPY package*.json ./

# 安装应用依赖
RUN npm install

# 将应用的源代码复制到容器中
COPY . .

# 应用运行时监听的端口
EXPOSE 8080

# 定义容器启动时运行的命令
CMD ["node", "server.js"]

在这个 Dockerfile 中,我们基于 Node.js 的官方镜像创建一个新的镜像,安装了应用的依赖,并设置容器启动时执行的命令。

示例 2:Kubernetes Deployment 配置文件

这个 YAML 文件示例定义了一个 Kubernetes Deployment,用于部署上面创建的 Docker 镜像。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
yaml复制代码apiVersion: apps/v1
kind: Deployment
metadata:
name: nodejs-app
labels:
app: nodejs-app
spec:
replicas: 2 # 创建两个副本
selector:
matchLabels:
app: nodejs-app
template:
metadata:
labels:
app: nodejs-app
spec:
containers:
- name: nodejs-container
image: your-dockerhub-username/nodejs-app:latest # 指定 Docker 镜像
ports:
- containerPort: 8080 # 容器应用监听的端口

在这个 YAML 文件中,我们定义了一个名为 nodejs-app 的 Deployment。它将部署两个副本的容器,每个容器都运行 your-dockerhub-username/nodejs-app:latest 镜像(这里你需要替换成你自己的 Docker Hub 用户名和镜像名)。这个部署配置指定了容器内部的应用监听端口为 8080。

结合这两个示例,你可以看到 Docker 和 Kubernetes 如何一起工作来容器化和部署一个应用。首先,使用 Docker 构建一个应用的镜像,然后通过 Kubernetes 部署配置在集群中部署这个镜像。

总结,Docker真的被Kubernetes放弃了吗?

所以,我们可以说 Kubernetes 改变了对 Docker 的使用方式,而不是完全放弃了 Docker。这种变化更多地反映了 Kubernetes 向标准化、高效化发展的趋势,同时也保留了 Docker 在容器技术领域的核心价值和广泛使用。对于开发者来说,这意味着他们仍然可以在开发过程中使用 Docker,而 Kubernetes 则更专注于容器的编排和管理。

本文已收录于,我的技术网站 ddkk.com,有大厂完整面经,工作技术,架构师成长之路,等经验分享

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

大多数人并没你想的那么厉害!

发表于 2024-01-15

生活中,大多人和事物都是草台班子,并没有你想的那么厉害,如果你非要觉得他们特别厉害,那只能证明你没有想通,或者你自己太菜!

每个月都有一部分人因为我的开源项目加我,然后都会说:大佬这么厉害,做出这么NB的项目。

那么我真的厉害吗?

我想说,一点都不厉害,而且菜得不能再菜,如果我真的厉害,那么我现在可能早都不在公司写代码了,不做程序员了。

而我现在还在写代码,写文档,加班,还没有选择做自己想做事情的能力,那么就证明我能力不行,这不是自嘲,事实就是如此。

从前几年起,我就开始转变自己的思维,转变自己对社会群体的看法,加上工作这么些年,从职场中,人与人交往中,我开始觉得,那些你看似很牛逼的人,实际上也就那样!

上大学那会,一开始我因为贪玩,没有去好好学习,所以知识就落后于很多人,后面因为一些原因,自己开始好好学习,刚开始学得很痛苦。

看到比我们高两级的那些学长参加了各种比赛,在学校做了各种项目,那时候就觉得他们好牛逼啊,我和同学经常在一起说:某某学长是真的牛逼,要是能有它这么牛逼就好了。

但是多年过后,随着我们知识的不断增强,技术的不断深入,我才开始觉得,那时候觉得他们牛逼,只是因为自己太菜,其实他们一点都不牛逼,只是他们的知识比你先走了一步。

我们在学生时代,总会看到很多人学习很好,考上了名牌大学,我们觉得他们就是天之骄子,但是后面依旧混得不行,其实他们根本就不是什么天之骄子,没啥厉害的。

当参加工作后,因为有时候要协同开发,然后有人就说,那个团队很牛逼的,人家做了某某案例,但实际上你参与进来后,也就那样,出了一个错就拼命去搜索引擎上找,然后直接copy过来。

很多你觉得很厉害的人,其实他们也是搬运工,CV大法运用得炉火纯青,只是他们戴了一个光环而已。

当然,在中小公司里面,肯定也有很厉害的人,但是大多数都是很菜的。

为啥要说这些呢,其实无非就是想说:不要去害怕谁,不要惧怕权威,不要把自己想得很差劲。

因为我们大多数人心中有一面墙:总觉得自己不配。

比如想面试一个不错的公司,然后心里就会觉得自己太菜了,不配进去,记得一个朋友,他的一个亲戚在大厂,然后类推他进去,但是他害怕,直接不面试。

为什么会这样呢?

难道是不想去更好的平台发展吗?小公司舒服吗?

我想都不是。

一切都源于自我否定,因为觉得自己不过就是一个普通二本,技术肯定没人家厉害,学历没人家高,人家怎么会要你呢?

但是是这样的吗?

里面的人都是天之骄子吗,未必,里面很多部门做的事依然是很简单的,甚至还不如你在小厂做的有难度,你自己完全能胜任。

但是你已经自我否定了啊。

前两年,一个女大学生找我给她解决问题,当然,我们现在成为了朋友,她说不敢加我们这种“大佬”,最后还是鼓起勇气加的。

我和她聊了很久,可能后面她才知道,其实我并不是什么大佬,我也是一个水货而已,只是我在一些领域稍微懂一点,我们之间有信息差而已。

我以前也和她一样,看到一些自己觉得很厉害的人,也不敢去和人家说话。

还没有毕业的时候,去一个科技展览,看到自己感兴趣的东西,于是就想上去和人家交流一下,但是心里在想:自己这么菜,有啥资格去和人家流。

后面鼓起勇气去,我们交流了很久,旁边还有好多人在看,但是交流下来后,我发现自己也没有那么水,小姐姐也没有我想的那么厉害!

而且我还明白一个道理,这个时代表达太重要呀,只要自己想表达,不要去惧怕平台,更不要去惧怕权威。

这些年每当面临上台演讲的时候,即使下面是总裁,是大领导。

我都在告诉自己:没啥的,把他们想成自己的同事,朋友就行,他们并不厉害,并不权威。

基于这样的自我安慰,我每次上台前我都会先在心里暗示一下自己,效果出奇的好,思路也不会混乱,气氛也不尴尬。

讲了了这么多,其实就是想表达一个观点。

生活中,无论是个人,公司,还是体制中,其实大多都是草台班子,并没有你想的那么NB,面对他们的时候,没必要给自己施压。

我们并不是一无是处,别人也并不是那么厉害,如果觉得别人厉害,那一定是自己太菜!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

JSPDF + html2canvas A4分页截断

发表于 2024-01-15

引言

最近在业务上遇到了一个问题是要将页面打印输出成pdf文件,通过点击一个按钮,就能够将页面写在一个pdf上,并下载下来,需要保证pdf的内容具有很好的可读性。

经评估要实现这个需求,一种可行的方案是将HTML页面转为PDF,并实现下载。通过技术调研,最终的方案确定为通过html2canvas + jspdf这两个库来实现,通过使用html2canvas提供的方法,将页面元素转为base64图片流,然后将其插入jspdf插件中,实现保存并下载pdf。

html2canvas + jspdf方案是前端实现页面打印的一种常用方案,但是在实践过程中,遇到的最大问题就是分页截断的问题:当页面元素超过一页A4纸的时候,连续的页面就会因为分页而导致内容被截断,进而影响了pdf的可读性。

由于网上关于分页截断的解决思路比较少,所以特意将此次的解决方案记录下来。

使用 JSPDF 和 html2canvas 创建简单的 PDF文件

首先,我们开始使用 JSPDF 和 html2canvas 生成一个简单的 PDF文件。

创建一个 JSPDF 实例

创建一个 JSPDF 实例,设置页面的大小、方向和其他参数。参考官网可以写一个很简单的实例

1
2
3
4
5
6
7
8
jsx复制代码var doc = new jsPDF({
orientation: 'landscape',
unit: 'in',
format: [4, 2]
}

doc.text('Hello world!', 1, 1)
doc.save('two-by-four.pdf')

生成一个pdf文件,并且在文件中写入一定内容,其实JSPDF这个库就能做到。

但是很多业务场景下,我们的目标内容会更复杂,而且还要考虑样式,所以最好的方式是引入html2canvas这个库,将页面元素转换成base64数据,然后贴在pdf中(使用addImage方法),这样就能保证页面的内容。

引入了html2canvas库后,我们更多关注是利用现成组件库、框架或者原生html和css实现更复杂的页面内容。

引入 html2canvas

使用 html2canvas 捕捉 HTML 内容或特定的 HTML 元素,并将其转换为 Canvas。其中,html2canvas 函数的主要用法是:

1
jsx复制代码html2canvas(element, options);
  • element: 要渲染为 canvas 的 HTML 元素。这可以是一个 DOM 元素,也可以是一个选择器字符串,表示需要渲染的元素。
  • options(可选): 一个包含配置选项的对象,用于定制 html2canvas 的行为。

以下是一些常见的配置选项:

  • allowTaint(默认值: false): 是否允许加载跨域的图片,默认为 false。如果设为 true,html2canvas 将尝试加载跨域的图片,但在某些情况下可能会受到浏览器的限制。
  • backgroundColor(默认值: #ffffff): canvas 的背景颜色。
  • useCORS(默认值: false): 是否使用 CORS(Cross-Origin Resource Sharing)来加载图片。如果设置为 true,则 html2canvas 将尝试使用 CORS 来加载图片。
  • logging(默认值: false): 是否输出日志信息到控制台。
  • width 和 height: canvas 的宽度和高度。如果未指定,则默认为目标元素的宽度和高度。
  • scale(默认值: window.devicePixelRatio): 缩放因子,决定 canvas 的分辨率。

下面是一个简单的demo,可以看到html2canvas能够将dom元素转化为一张base64图片,将鼠标选中元素,可以感受到图片和文字的不同。

1
2
3
html复制代码<div id="capture" style="padding: 10px; background: #f5da55">
<h4 style="color: #000; ">Hello world!</h4>
</div>
1
2
3
jsx复制代码html2canvas(document.querySelector("#capture")).then(canvas => {
document.body.appendChild(canvas)
});

Untitled.png

将html2canvas转化的图片放到pdf中

这一步我们需要使用JSPDF 的addImage方法,其语法如下:

1
jsx复制代码addImage(imageData, format, x, y, width, height, alias, compression)
  • imageData - 要添加的图像数据。可以是图像的 URL、图像的 base64 编码字符串或图像的二进制数据
  • format - 图像的格式。可以是 “JPEG”、”PNG” 或 “TIFF”。
  • x - 图像在 PDF 文档中的 x 坐标。
  • y - 图像在 PDF 文档中的 y 坐标。
  • width - 图像的宽度。
  • height - 图像的高度。
  • alias - 图像的别名。此别名可用于在 PDF 文档中引用图像。
  • compression - 图像的压缩级别。可以是 “NONE“、”FAST“ 或 “SLOW“。

下面是一串示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
jsx复制代码import jsPDF from 'jspdf';

export default function addImageUsage() {
const doc = new jsPDF();
const imageData = 【替换成base64数据流】;
doc.addImage(imageData, 'png', 0, 0, 10, 10);
doc.addImage(imageData, 'png', 100, 100, 10, 10);
doc.addImage(imageData, 'png', 200, 200, 10, 10);

drawNet(doc);

doc.save('output.pdf');
}

const drawNet = (doc) => {
const gap = 10;
const start = [0, 0];
const end = [595.28, 841.89];

// 所有横线
for (let i = start[0]; i < end[0]; i = i + gap) {
doc.line(i, 0, i, end[0]);
}
// 所有纵线
for (let j = start[1]; j < end[1]; j = j + gap) {
doc.line(0, j, end[1], j);
}
};

此示例将在 PDF 文档(默认是A4纸大小,宽高为[595.28, 841.89]像素)的 (10, 10) 、(100, 100) 、(200, 200) 坐标处,添加一张png 图像。图像的宽度和高度将分别为 10 和 10 像素,为了了解pdf中的坐标系统,此示例还在pdf文档中生成了间距为10px的网格系统。

Untitled1.png

JSPDF 和 html2canvas结合起来用

了解了上面的三个关键点,接下来我们将这三个步骤串联起来,实现一个基本的html→pdf的方案。大致步骤如下:

  1. 写一个基本html页面
  2. 创建jspdf实例
  3. 获取页面的dom节点,使用html2canvas将其转化为base64数据流
  4. 将base64数据流装载到jspdf提供的addImage方法中
  5. 保存pdf

基于这5个步骤,可以实现基本的页面打印。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
jsx复制代码import html2canvas from 'html2canvas';
import jsPDF, { RGBAData } from 'jspdf';

// 将元素转化为canvas元素
// 通过 放大 提高清晰度
// width为内容宽度
async function toCanvas(element: HTMLElement) {
if (!element) return { width: 0, height: 0 };

// canvas元素
const canvas = await html2canvas(element, {
scale: window.devicePixelRatio * 2, // 增加清晰度
useCORS: true // 允许跨域
});

// 获取canvas转化后的宽高
const { width: canvasWidth, height: canvasHeight } = canvas;

// 转化成图片Data
const canvasData = canvas.toDataURL('image/jpeg', 1.0);

return { width: canvasWidth, height: canvasHeight, data: canvasData };
}

/**
* 生成pdf(A4多页pdf截断问题, 包括页眉、页脚 和 上下左右留空的护理)
*/
export async function generatePDF({
/** pdf内容的dom元素 */
element,

/** pdf文件名 */
filename
}) {
if (!(element instanceof HTMLElement)) {
return;
}

const pdf = new jsPDF();

// 一页的高度, 转换宽度为一页元素的宽度
const {
width: imageWidth,
height: imageHeight,
data
} = await toCanvas(element);

// 添加图片
function addImage(
_x: number,
_y: number,
pdfInstance: jsPDF,
base_data:
| string
| HTMLImageElement
| HTMLCanvasElement
| Uint8Array
| RGBAData,
_width: number,
_height: number
) {
pdfInstance.addImage(base_data, 'JPEG', _x, _y, _width, _height);
}

addImage(0, 0, pdf, data!, imageWidth, imageHeight);

return pdf.save(filename);
}

多页:比例缩放+循环移位

通常,在我们的实践中,会发现2个问题:

  • 生成的pdf内容与实际的页面元素比例不一致
  • 页面内容超出一页pdf的高度,但是生成的pdf只有一页,没有展示全部的页面信息

这两个问题的解决方案是等比例缩放+循环移位:

  • 等比例缩放

通过比例缩放,实现页面内容等比例展示在pdf文档中

令页面元素的宽高为x, y(转化成canvas图片的宽高),pdf文档的宽高为w, h。因为高度可以通过加页延伸,所以可以按照宽度进行缩放,缩放后的图片高度可以通过下列公式计算

y_scaled=(w/x)\yy\_{scaled} = (w / x) \ yy_scaled=(w/x)\*y

  • 循环移位

如果页面的高度超出了pdf文档的高度,即y > h,使用addPage方法添加一页即可。但是在新的一页中,我们的图片内容的高度需要调整。

假设y = 2 * h,这意味我们需要两页才能完整得展示页面内容。在一页pdf中,图片在起始位置插入即可,即

1
jsx复制代码 PDF.addImage(pageData, 'JPEG', 0, 0, x, y)// 注意x,y 是缩放后的大小

在第二页pdf中,图片的纵向位置需要调整一页pdf的高度,即

1
jsx复制代码 PDF.addImage(pageData, 'JPEG', 0, -h, x, y)// 注意x,y 是缩放后的大小

通过循环计算剩余高度,然后不停调整纵向位置移动base64的图片位置,可以解决多页的问题。

分页截断的挑战

尽管 JSPDF 和 html2canvas 是功能强大的工具,但是他们也有很多槽点,比如得手动分页,手动处理分页截断的问题。等你实践到这一步,就开始面临分页截断的问题,类似的问题也有网友在Github上提出,但是底下依然没有很好的解决思路。

好在掘金上有人分享了一个不错的方法:

jsPDF + html2canvas A4分页截断 完美解决方案(含代码 + 案例) - 掘金

概括一下,其处理分页截断的原理就是在使用addImage之前,将html进行分页,通过维护一个高度位置数据,来记录每次循环迭代addImage的位置。

从高到低遍历维护一个分页数组pages,该数组记录每一页的起始位置,如:pages[0] 对应 第一页起始位置,pages[1] 对应第二页起始位置

Untitled2.png

接下来我们重点讨论如何将页面进行切割,然后生成pages这个数组。

假设页面的高度是1500,pdf宽高是[500, 900],如果不用处理分页截断的问题,我们可以想到第一页(0-900)是用来承载页面从高度为0到900的信息;

第二页(900-1800)是用来承载页面从高度900到1500的,所以pages数组为[0, 900]。

如果要处理分页截断呢,这时候就需要计算页面元素的距离pdf文档起始位置的高度h1,以及该元素的内部高度h2,通过这两个高度来判断这个元素要不要放在下一页,防止截断,示意图如下:

Untitled4.png

如果h1 + h2 > 页面高度, 这时候说明这个元素不处理的就会被分页截断,所以应该要把这个元素放到第二页去渲染,这就意味着pages记录的数据要变化,示意图如下,可以看到pages[1]我们往上调整了,比第二页pdf的起始位置更高。

Untitled5.png

说明渲染第二页pdf的时候,要从h1开始渲染,pages数组为[0, h1],解释为第一页pdf渲染页面高度区域为0-900, 第二页pdf渲染html高度区域为h1-1500。注意到第一页渲染的时候到尾部的时候,**会有部分内容和第二页头部内容重合。**因为h1到900这部分的内容肯定会渲染,这部分内容一直都是页面元素,我们改变pages[1]的值的原因只是创建一个副本,让页面看起来内容没有被截断。

为了解决这个问题(为了美观),我们用填充一块白色区域遮掉它!此处使用jspdf的rect和setFillColor方法,把重合的区域遮白处理。

1
2
jsx复制代码pdf.setFillColor(255, 255, 255);
pdf.rect(x, y, Math.ceil(_width), Math.ceil(_height), 'F');

如何获得h1和h2

上面我们谈到了h1和h2,其中h1是元素盒子的上边距到打印区域的高度(比例缩放后的高度),h2是元素盒子的内部高度。

计算h1: getBoundingClientRect方法

1
2
3
jsx复制代码const rect = contentElement.getBoundingClientRect() || {};
const topDistance = rect.top;
return topDistance;

Untitled6.png

计算h2: offsetHeight方法

Untitled7.png

值得注意的是,因为打印区域的html元素不一定是从窗口顶部开始,所以为了计算实际的h1(元素到打印区域的顶部距离),可以采用这样的方法:

  • 用getBoundingClientRect方法计算元素到窗口顶部的距离
  • 循环打印之前将pages信息针对第一个元素进行一个高度校准。
1
2
3
jsx复制代码// 对pages进行一个值的修正,因为pages生成是根据根元素来的,根元素并不是我们实际要打印的元素,而是element,
// 所以要把它修正,让其值是以真实的打印元素顶部节点为准
const newPages = pages.map((item) => item - pages[0]);

在线demo演示和源代码

上述即是在实现前端页面生成pdf的过程中遇到的问题,以及解决思路。

为了更直观得感受效果,本文也给出了不同场景(单页、多页、多页截断、自定义页眉页脚、横向)下的pdf生成效果,可以通过此链接体验:pdf-demo-phi.vercel.app/

此demo的源代码如下:pdf-demo

与现有文章不同的是,本仓库的代码特点在于:

  • 支持设置pdf打印的方向,比如横向
  • 修正了高度计算问题,解决了多出一个空白页问题。掘金那篇文章计算元素高度时候没有减去容器距离顶部高度,所以导致很多新手使用那份代码的时候,会发现自己的页面顶部被裁剪到了,原因就是这个
  • 支持自定义页眉页脚
  • 支持扩展自定义分页方法,如果遇到复杂的组件,可以自定扩展逻辑计算高度

最后

📚 小茗文章推荐:

  • Formily JSON Schema 渲染流程及案例浅析
  • 古茗是如何做前端数据中心的
  • 你一定要知道的「React组件」两种调用方式

关注公众号「Goodme前端团队」,获取更多干货实践,欢迎交流分享~

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…626364…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%