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

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


  • 首页

  • 归档

  • 搜索

利用AspectJ + Gradle 来实现非 Spring

发表于 2021-11-27

有些时候我们有一个需求,就是能够统计增加了某个注解方法的执行时间。很自然的,我们能够想到用AOP的方法。

由于我们熟悉编写的代码是在 Spring 下面的编写的 AOP 注解,但是很多时候,我们可能并不一定需要去一个庞大的Spring 环境,才能够实现 AOP 的功能。因为我仅仅想做的就是一个在多线程下的方法性能测试,我只是想启动最少量的代码,来实现我需要的切面功能。

搜索了一番之后终于找到在 Gradle 项目中,不启动 Spring 环境,来使用 AOP 的方案。

下面的代码将实现以@ExecutionTime 注解来修饰方法,获得方法的统计时间。

Gradle的配置

目前暂时没有找到一个官方支持的AspectJ Gradle 插件。如果你用的是 Gradle5 以上的版本,建议你跟我一样使用 arendd/AspectjGradlePlugin: AspectJ Plugin for Gradle 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
bash复制代码project.ext {
aspectjVersion = '1.9.7'
}

buildscript {
repositories {
maven {
url "https://plugins.gradle.org/m2/"
}
}
dependencies {
classpath "gradle.plugin.de:AspectjGradlePlugin:0.0.6"
}
}

apply plugin: "aspectj.AspectjGradlePlugin"
group 'top.ilovestudy.tools'
version 'unspecified'

dependencies {
implementation "org.aspectj:aspectjrt:${aspectjVersion}"
implementation "org.aspectj:aspectjweaver:${aspectjVersion}"
implementation "org.aspectj:aspectjtools:${aspectjVersion}"
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.7.0'
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.7.0'
}
java {
sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_11
}
test {
useJUnitPlatform()
}

增加一个 ExecutionTime 注解

1
2
3
4
5
less复制代码@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExecutionTime {
ChronoUnit unit() default ChronoUnit.NANOS;
}

增加一个 aspect 实现类 ExecutionTimeAspect

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
less复制代码@Aspect
public class ExecutionTimeAspect {

@Pointcut("@annotation(executionTime)")
public void callAt(ExecutionTime executionTime) {
}


@Around("callAt(executionTime)")
public Object around(ProceedingJoinPoint pjp, ExecutionTime executionTime) throws Throwable {
long start = System.nanoTime();
Object proceed = pjp.proceed();
long end = System.nanoTime();
System.out.println(String.format("[%s] method [%s] execution time: %s %s",
Thread.currentThread().getName(),
pjp.getSignature().getName(),
Duration.of(end - start, ChronoUnit.NANOS).get(executionTime.unit()),
executionTime.unit().name()));
return proceed;
}

}

使用

1
2
3
4
5
6
7
csharp复制代码    @ExecutionTime
void add10K() {
int idx = 0;
while (idx++ < maxCountNum) {
addOne();
}
}

然后,不管我们在 Junit5 测试框架中,还是实现的代码中,调用 add10K 的方法,都会获得一个日志的记录(线程,方法,执行时间),表示运行 add10K 耗费了多少时间。例如:

1
2
3
4
5
6
less复制代码[calc7] method [add10K] execution time: 3841505 NANOS
[calc0] method [add10K] execution time: 48612486 NANOS
[calc1] method [add10K] execution time: 53342401 NANOS
[calc3] method [add10K] execution time: 55629095 NANOS
[calc5] method [add10K] execution time: 56005109 NANOS
[calc7] method [add10K] execution time: 56238950 NANOS

参考文档

  1. arendd/AspectjGradlePlugin: AspectJ Plugin for Gradle 5+
  2. Javing: AspectJ + Custom Annotation + Gradle (Without Spring)

本文转载自: 掘金

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

java HashMap 详解 -- (默认常量和构造函数)

发表于 2021-11-27

「这是我参与11月更文挑战的第27天,活动详情查看:2021最后一次更文挑战」

1.前言

HashMap 是我们在java 中用的比较多的一种数据结构,它主要以 key-value 键值对的形式存贮数据,尤其是在查询数据的时候时间复杂度能达到 O(1)。那今天我们就来讲讲HashMap。

2.构造方法

静态常量 含义
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4 默认初始化容量:16
static final int MAXIMUM_CAPACITY = 1 << 30 设置的初始化容量最大值 1073741824
static final float DEFAULT_LOAD_FACTOR = 0.75f 默认的负载因子 0.75
static final int TREEIFY_THRESHOLD = 8 桶中bin的数量超过该阈值 由链表转换成树 默认是 8
static final int UNTREEIFY_THRESHOLD = 6 当执行resize操作时,当桶中bin的数量小于该阈值 6时,使用链表来替代树存储
static final int MIN_TREEIFY_CAPACITY = 64 桶中bin 被树化时,最小的hash表容量,默认为 64 。当散列表容量小于该阈值,即使桶中bin的数量超过了 treeify_threshold ,也不会进行树化,只会进行扩容操作。 min_treeify_capacity 至少是 treeify_threshold 的4倍

2.1 new HashMap() 无参构造

图片.png

我们可以看到上面注释中说过 它会创建一个null的hashMap,默认初始化容量 16 ,默认的负载因子0.75 ( 即当前存储的数据量 > 初始容量*负载因子 时就会扩容 )

2.2 new HashMap(int n)

图片.png

这里面主要调用另外一个有参构造方法,负载因子还是传默认的

2.3 new HashMap(int n, float f)

图片.png

  • 这里首先校验下 initialCapactity ,当它小于0时,抛出异常,大于 MAXIMUM_CAPACITY 时,取MAXIMUM_CAPACITY。
  • 校验负载因子,当负载因子<0,或传入负载因子无效,则抛出异常。
  • 把传入的负载因子赋给 this.loadFactor
  • tableSizeFor(initialCapactity) : 返回给定目标容量大小的2的次方。详解见 2.2.1
  • 把处理后的值 赋给 this.threshold(要调整大小的下一个大小值:capacity * load factor,如果尚未分配表数组,则字段保存初始数组容量,这里就表示初始数组容量)

1. tableSizeFor(initialCapactity)

返回给定目标容量大小的2的次方
图片.png
这里为什么 cap -1,如果不减1,当我们传入的cap 是1时,这里会解析成2;当我们传过来的是2,这里会解析成4;当我们传过是 4,就会解析成8,这里使用-1 可以帮我们节省很多空间,这里最终计算的结果是2的整数次幂。

2.4 new HashMap(Map<? extends K, ? extends V> m)

这里直接使用一个Map 创建一个新的 HashMap
图片.png

这里可以看到 负载因子 使用的还是默认的 0.75,然后调用 putMapEntries(m, false) 方法,详解见 2.4.1

1. putMapEntries(Map<? extends K, ? extends V> m, boolean evict)

图片.png

  • 首先它会获取 m的size,如果 s<0 ,则说明 m 为null,不用进行任何处理,下面讨论不为null的情况。
  • table : 表,在第一次使用时初始化,并根据需要调整大小。在分配时,长度总是2的幂。(我们也在一些操作中允许长度为零,以允许当前不需要的自举机制。)
    图片.png
  • 如果table 为 null
    • 则用 m的size 除以 负载因子,向上取整 得到 ft
    • 这里把ft转成 int类型;当它大于 MAXIMUM_CAPACITY 取 MAXIMUM_CAPACITY,否则取它本身,这里t实际相当于前面的 initialCapactity。然后调用 tableSizeFor(t) 赋值给 this.threshold
  • 如果 table 不为 null,则说明 this.threshold 有值
    • 判断当 m的size > this.threshold,则说明需要扩容,就调用 resize() 方法,主要作用是:对当前hashMap 中的table 进行初始化或扩容加倍。后续详讲
  • 遍历 m,分别取出它的key 和 Value,放到当前初始化的hashMap 中去,完成初始化。

3.小思考

这里大家可以思考一下,为什么一定要转成 2 的 幂数?

本文转载自: 掘金

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

mybatis 是怎样防止sql注入的

发表于 2021-11-27

最近项目交付,扫描出sql注入漏洞,我寻思spring开发框架底层应该解决了这些问题,就想研究一下是怎么解决注入的
学习mybatis时都知道 #{} 可以防注入,${}是可以注入,分别写下面两个方法

1
2
3
4
5
6
7
8
xml复制代码<mapper namespace="com.example.ssm.mapper.UserMapper">
<select id="login1" resultType="integer">
select count(*) from user where username = #{username} and password = #{password}
</select>
<select id="login2" resultType="integer">
select count(*) from user where username = '${username}' and password = '${password}'
</select>
</mapper>

数据库连接字符串,注意别用ssl,否则抓包内容无法识别

1
bash复制代码jdbc:mysql://localhost:3306/ssm?characterEncoding=utf8&serverTimezone=GMT%2B8&useSSL=false

两个 mapper 方法,根据用户名和密码查询表,如果有记录就返回 true,模拟简单的登录逻辑。前端通过 ' or '1 = 1 构造注入

image.png
login1 返回false,注入失败

image.png
login2 返回true, 注入成功

请求抓包

通过 Wireshark 来抓包,如果使用本机的数据库就选则回环网络,外网的就选联网的网卡

image.png

过滤器是 tcp.dstport == 3306 or tcp.srcport == 3306

login1 请求

8885f160c7ea5987bc9c570eee6e585.png
select count(*) from user where username = 'admin' and password = 'dd'' or ''1 = 1 '
这个sql在or两边增加了单引号,这样后面整体就是一个字符串,没有构成注入

login2 请求

f0524322a8e8d1ed027c42065b7556d.png
select count(*) from user where username = 'admin' and password = 'dd' or '1 = 1 '

sql预编译

通过debug第一个请求,跟踪到

1
2
java复制代码package org.apache.ibatis.executor.statement;
public class RoutingStatementHandler implements StatementHandler

login1 和 login2 执行过程中,在构造 StatementHandler 时选择的都是 PreparedStatementHandler
但 login1 的 boundSql 是带问号的,而 login2 的已经是拼接好参数的 sql。

7677d593df3c44d1fab3a7e9784257e.png
所以login2对已经对注入成功的sql进行预编译,就达不到防注入的效果了。
image.png

看了这个类的方法 instantiateStatement,里面有预编译的内容,打断点,可以看到会将带问号的sql进行预编译

image.png

查看 connection 的类型发现是 com.zaxxer.hikari.poolProxyConnection,实际是调用

1
2
3
java复制代码package com.mysql.cj.jdbc;
public class ConnectionImpl implements JdbcConnection, SessionEventListener, Serializable
public java.sql.PreparedStatement prepareStatement(String sql, int resultSetType, int resultSetConcurrency) throws SQLException

image.png

跟踪到

1
2
3
scala复制代码package com.mysql.cj.jdbc
public class ClientPreparedStatement extends com.mysql.cj.jdbc.StatementImpl implements JdbcPreparedStatement
public ClientPreparedStatement(JdbcConnection conn, String sql, String db, ParseInfo cachedParseInfo) throws SQLException

image.png

1
2
3
arduino复制代码package com.mysql.cj
public class ParseInfo
public ParseInfo(String sql, Session session, String encoding, boolean buildRewriteInfo)

ParseInfo 会根据问号把sql分成三段,存到 byte[][] staticSql 这个二维数组中,显示的都是对应的
ASCII的十进制

问号的index会添加到 endpointList 这个数组,
image.png
然后再循环这个数组将sql分成三段

1
arduino复制代码this.staticSql = new byte[endpointList.size()][];

4d42241dd38e33fc65ce53cf8e07229.png
跟踪到最后调用底层socket发送数据

1
2
3
scala复制代码package com.mysql.cj;
public abstract class AbstractPreparedQuery<T extends QueryBindings<?>> extends AbstractQuery implements PreparedQuery<T>
public <M extends Message> M fillSendPacket(QueryBindings<?> bindings)

在发送第二段sql时,bindValues[i] 中 or (111,114) 两边会加上单引号 (39)
image.png

下面查看这个引号是怎么加上的

参数处理

1
2
3
scala复制代码package com.mysql.cj;
public class ClientPreparedQueryBindings extends AbstractQueryBindings<ClientPreparedQueryBindValue>
public void setString(int parameterIndex, String x)

有个判断 isEscapeNeededForString

image.png

这个方法是判读参数中是否有 \n \r \\ \' " \032,如果有就会循环在这些符号的位置分别处理
07de7a08b4368c5a769eb15113d4757.png
在单引号出会额外添加一个单引号,这就是我们上面发现发送第二段sql,or 的两边都加了两个单引号
image.png

总结:
mybatis在向mysql发送执行sql前,会进行客户端的预编译(还有服务器端的预编译),使用#{}表达式会将其替换为问好进行预编译,而${}则是进行参数替换后的sql进行预编译,在发送请求拼接sql时,会将参数中产生注入的地方通过处理,使其在服务器端当作一个参数,消除注入风险。在看很多文章时都再说mybatis会对sql进行预编译,但是查看抓包都是一个完整sql的请求,一步一步查找,原来是通过对sql进行切割,然后拼接处理注入风险后的参数,达到预编译的效果。

参考:

  1. wireshark抓包分析mybatis的sql参数化查询
  2. MyBatis预编译机制详解
  3. 什么是MYSQL的预编译?

本文转载自: 掘金

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

让面试者谈之色变的性能测试基础加油包,它终于来了,建议收藏!

发表于 2021-11-27

一 QPS,每秒查询

QPS:每秒查询数的意思是“每秒查询率”,这是服务器每秒可以进行的相应查询数,它是特定查询服务器在指定时间内可以处理多少流量的度量。在互联网中,一台机器作为域名系统服务器的性能往往是以每秒的查询率来衡量的。(每秒处理的请求数,请注意这是已处理的)

二 TPS,每秒事务

TPS:是transactions personsecond的缩写,即每秒的事务数。它是软件测试结果的度量单位。事务是客户端向服务器发送请求,服务器做出响应的过程。客户端在发送请求时开始计时,在收到服务器的响应后结束计时,从而计算使用的时间和完成的事务数。QPS vs TPS:QPS与TPS基本相似,不同的是一次访问一个页面就形成了TPS;但是,一个页面请求可能会向服务器生成多个请求,服务器可以将这些请求计入“QPS”。例如,要访问一个页面,您将请求服务器两次,一次访问将产生一个“T”和两个“Q”。

三 RT,响应时间

TPS:是transactions personsecond的缩写,即每秒的事务数。它是软件测试结果的度量单位。事务是客户端向服务器发送请求,服务器做出响应的过程。客户端在发送请求时开始计时,在收到服务器的响应后结束计时,从而计算使用的时间和完成的事务数。QPS vs TPS:QPS与TPS基本相似,不同的是一次访问一个页面就形成了TPS;但是,一个页面请求可能会向服务器生成多个请求,服务器可以将这些请求计入“QPS”。例如,要访问一个页面,您将请求服务器两次,一次访问将产生一个“T”和两个“Q”。

四 并发数

并发数是指系统同时能处理的请求数量,这个也是反应了系统的负载能力。

五 吞吐量

并发数= QPS*平均响应时间

六 QPS,每秒查询

让我们通过一个例子把上面的概念串起来。根据第28定律,如果每天80%的时间就集中在20%的时间,这20%的时间称为高峰时间。

公式:(总光伏* 80%)/(每天秒数* 20%) =高峰时间每秒请求数(QPS)

机器:单台机器的峰值时间QPS每秒/QPS =所需机器

(1)单台机器每天300瓦光伏,这台机器需要多少QPS?

(3000000 * 0.8)/(86400 * 0.2)= 139(QPS)

(2)如果一台机器的QPS是58,需要多少台机器来支持?

139 / 58 = 3

七 最佳线程数、QPS、RT

(1)单线QPS公式:QPS = 1000毫秒/室温

对于同一系统,支持的线程越多,QPS越高。假设一个RT是80毫秒,QPS很容易计算,QPS = 1000/80 = 12.5。

在多线程的场景下,如果服务器上的线程数增加到2,那么整个系统的QPS就是2*(1000/80) = 25,这说明QPS是随着线程数的增加而线性增加的,所以听起来很合理,公司也有道理,但往往不是这样。

(2)QPS与RT的真实关系

我们想象的QPS和RT之间的关系是一个凹曲线,其中QPS随着RT的增加而逐渐减小

QPS和RT之间的实际关系是一条凸曲线,其中QPS随着RT的增加而逐渐减小

(3)最佳线程数

消耗服务器瓶颈资源的临界线程数如下

最佳线程数=((线程等待时间+线程cpu时间)/线程cpu时间)* CPU数

特征:

当达到最佳线程数时,线程数将继续增加,QPS不会改变,而响应时间将变长。如果线程数量继续增加,QPS将开始减少。

每个系统都有其最佳线程数,但在不同的条件下,最佳线程数会发生变化。

瓶颈资源可以是CPU、内存或锁资源、IO资源:超过最佳线程数——导致资源竞争,超过最佳线程数——增加响应时间。

互联网的数据术语

一 VV

访问量/访问量(VV): VV = Visitview:记录所有访问者在一天内访问您的网站的次数,同一访问者可能会多次访问您的网站。从访问者来到您的网站到网站的所有页面最终关闭,这算一次访问。如果访客连续30分钟没有打开并刷新页面,或者访客关闭浏览器,则算作本次访问结束。然后上图A显示了从搜索词“宫外孕有哪些症状”329到网站的访问量。

二 UV

UV(unique visitor)是独立访问者的数量,指访问一个网站或点击一个网页的不同IP地址的人数。当天UV只记录第一次进入网站的拥有独立IP的访客,不统计当天再次访问网站的访客。UV提供一定时期内不同受众的统计指标,但不反映网站的整体活动。

三 PV

即PV页面浏览量或点击量,是衡量一个网站或网页的用户访问量。具体来说,PV值是一个网站或网页在24小时内(0: 00到24: 00)被所有访问者浏览的页数。PV指的是页面刷新的次数,每次页面刷新都算作PV流量。测量方法是从浏览器向网络服务器发送请求。网络服务器收到请求后,会向浏览器发送与请求对应的页面,从而生成PV。所以在这里,只要这个请求发送到浏览器,不管这个页面是否完全打开(下载完成),都应该算作一个PV。

四 IP

独立IP数:一天内(00:00-24:00)访问网站的唯一IP数。一天内多次访问相同IP地址的网站只算一次。无论同一个IP访问了多少页面,独立IP的访问次数都是1次。

性能测试影响因素

一 响应时间

响应时间:对请求作出相应所需要的时间

网络传输时间:N1+N2+N3+N4

应用服务器处理时间:A1+A3

数据库服务器处理时间:A2

响应时间:N1+N2+N3+N4+A1+A3+A2

二 并发用户数的计算公式

系统中的用户数:系统中的额定用户数。

同时在线用户数:一定时间范围内同时在线用户数最多。

平均并发用户数的计算:C = nl/T

其中,c为平均并发用户数,n为平均每天访问系统(登录会话)的用户数,l为用户一天内从登录到登录会话的平均时间,t为巡检时间长度(用户一天使用系统多长时间)。

并发用户峰值计算:C约等于C+3 * √ C。

其中c为并发用户峰值,c为平均并发用户数,公式遵循泊松分布理论。

三 吞吐量计算公式

在没有性能瓶颈的情况下,吞吐量与虚拟用户数之间存在一定的关系,可以通过以下公式计算:f = vu * r/t。

f是吞吐量,VU代表虚拟用户数,R代表每个虚拟用户发出的请求数,T代表性能测试的时间。

四 性能计数器

资源利用率:指系统中各种资源的利用率,如CPU利用率68%,内存利用率55%。一般用“资源实际使用量/可用资源总量”来形成资源利用率。

性能计数器是描述服务器或操作系统性能的一些数据指示器。

五 思考时间的计算公式

Think Time,从业务角度来说,这个时间是指用户操作时每个请求之间的时间间隔,在性能测试中,为了模拟这样的时间间隔,引入think time的概念,更真实地模拟用户的操作。

在吞吐量的公式中,(F=VU*R/T)表明吞吐量F是VU数、每个用户发出的requesTS数R和时间T的函数,其中R可以由时间T和用户思考时间TS计算得出:R=T/TS。

本文转载自: 掘金

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

网络协议-OSI七层模型、TCP/IP四层模型和五层协议模型

发表于 2021-11-27

本文正在参与 “网络协议必知必会

山有峰顶,海有彼岸,漫漫长路,终有回转,余味苦涩,终会有回甘。别被眼前的磨难打败了,或许光明就在你放弃前的那一刻。带着愉快的心情做一个愉快的梦,醒来后,又是新的一天。

世界上任何的书籍都不能带给你好运,但是它们能让你悄悄的成为你自己的

前言

网络模型是很基础但是很重要的网络基础知识.现在常说的都是OSI七层模型和TCP/IP四层模型和五层协议模型.

OSI七层模型

一般叫做OSI(Open System Interconnection) 模型或者叫七层模型.他是国际标准化(ISO)定的一个用于计算机或通信系统间互联的标准体系.协议将计算机网络体系结构划分为7层.

每一层实现各自的功能和协议,并完成与相邻层的接口通信.每一层提供的服务就是该层及其以下层的协作完成的.

层级 OSI模型 解释
7 应用层(Application Layer) 网络服务与最终用户的一个接口
6 表示层(Presentation Layer) 数据的格式化,转换,加密
5 会话层(Session Layer) 不同机器之间建立、管理、终止会话
4 传输层(Transport Layer) 定义传输数据的协议端口号,以及流控和差错校验
3 网络层(Network Layer) 进行逻辑地址寻址,实现不同网络之间的路径选择
2 数据链路层(Data Link Layer) 提供介质访问和链路管理
1 物理层(Physical Layer) 建立、维护、断开传输二进制数据的物理连接

TCP/IP四层模型和五层协议模型

实际上ICP/IP是四层模型,但是后来为了网络原理的理解方便,把七层模型和四层模型综合了一下,就出来了一个五层模型. 五层模型只是将四层模型中的网络接口层分成了两层数据链路层和物理层

层级 ICP/IP模型 解释
4 应用层 为用户提供所需要的各种服务
3 传输层 为应用层实体提供端到端的通信功能,保证了数据包的顺序传送及数据的完整性
2 网络层 主要解决主机到主机的通信问题
1 网络接口层 负责监视数据在主机和网络之间的交换

三种模型的类比

TCP/IP四层模型 TCP/IP四层模型 OSI模型
应用层 应用层 应用层
表示层
会话层
传输层 传输层 传输层
网络层 网络层 网络层
网络接口层 数据链路层 数据链路层
物理层 物理层

相同点

  1. OSI和TCP/IP模型都采用了层次结构的概念
  2. 都能够提供面向连接和无连接两种通信服务机制。

不同点

  1. TCP/IP参考模型的网络接口层实际上并没有真正的定义,只是一些概念性的描述.而OSI参考模型不仅分了两层,而且每一层的功能都很详尽.
  2. OSI模型是在协议开发前设计的,具有通用性。TCP/IP是先有协议集然后建立模型,不适用于非TCP/IP网络

本文转载自: 掘金

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

Go语言 基于gin框架从0开始构建一个bbs server

发表于 2021-11-27

完善登录流程

上一篇文章 我们已经完成了注册的流程,现在只要 照着之前的方法 完善我们的登录机制 即可

定义登录的参数

1
2
3
4
c复制代码type ParamLogin struct {
UserName string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}

定义 登录的controller

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
javascript复制代码func LoginHandler(c *gin.Context) {
p := new(models.ParamLogin)

if err := c.ShouldBindJSON(p); err != nil {
zap.L().Error("LoginHandler with invalid param", zap.Error(err))
// 因为有的错误 比如json格式不对的错误 是不属于validator错误的 自然无法翻译,所以这里要做类型判断
errs, ok := err.(validator.ValidationErrors)
if !ok {
c.JSON(http.StatusOK, gin.H{
"msg": err.Error(),
})
} else {
c.JSON(http.StatusOK, gin.H{
"msg": removeTopStruct(errs.Translate(trans)),
})
}
return
}

// 业务处理
err := logic.Login(p)
if err != nil {
// 可以在日志中 看出 到底是哪些用户一直在尝试登录
zap.L().Error("login failed", zap.String("username", p.UserName), zap.Error(err))
c.JSON(http.StatusOK, gin.H{
"msg": "用户名或密码不正确",
})
return
}
// 返回响应
c.JSON(http.StatusOK, "login success")
}

定义 登录的logic

1
2
3
4
5
6
7
go复制代码func Login(login *models.ParamLogin) error {
user := models.User{
Username: login.UserName,
Password: login.Password,
}
return mysql.Login(&user)
}

最后 看下登录的dao层

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
go复制代码func Login(user *models.User) error {
oldPassword := user.Password
sqlStr := `select user_id,username,password from user where username=?`
err := db.Get(user, sqlStr, user.Username)
if err == sql.ErrNoRows {
return errors.New("该用户不存在")
}
if err != nil {
return err
}
if encryptPassword(oldPassword) != user.Password {
return errors.New("密码不正确")
}
return nil
}

封装我们的响应方法

前面完成了登录和注册的方法以后 我们会发现 流程上 还有点冗余,响应方法有些重复 代码,这里 尝试优化一下

首先定义我们的 response code

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
go复制代码package controllers

type ResCode int64

const (
CodeSuccess ResCode = 1000 + iota
CodeInvalidParam
CodeUserExist
CodeInvalidPassword
CodeServerBusy
)

var codeMsgMap = map[ResCode]string{
CodeSuccess: "success",
CodeInvalidParam: "请求参数错误",
CodeUserExist: "用户已存在",
CodeInvalidPassword: "用户名或密码不正确",
CodeServerBusy: "服务繁忙 请稍后再试",
}

func (c ResCode) Msg() string {
msg, ok := codeMsgMap[c]
if !ok {
msg = codeMsgMap[CodeServerBusy]
}
return msg
}

然后定义我们的response函数

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
go复制代码package controllers

import (
"net/http"

"github.com/gin-gonic/gin"
)

type Response struct {
Code ResCode `json:"code"`
Msg interface{} `json:"msg"`
Data interface{} `json:"data"`
}

func ResponseError(c *gin.Context, code ResCode) {
c.JSON(http.StatusOK, &Response{
Code: code,
Msg: code.Msg(),
Data: nil,
})
}

func ResponseErrorWithMsg(c *gin.Context, code ResCode, msg interface{}) {
c.JSON(http.StatusOK, &Response{
Code: code,
Msg: msg,
Data: nil,
})
}

func ResponseSuccess(c *gin.Context, data interface{}) {

c.JSON(http.StatusOK, &Response{
Code: CodeSuccess,
Msg: CodeSuccess.Msg(),
Data: data,
})
}

顺便要去dao层 把我们的 错误 定义成常量

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
70
71
72
73
go复制代码package mysql

import (
"crypto/md5"
"database/sql"
"encoding/hex"
"errors"
"go_web_app/models"

"go.uber.org/zap"
)

const serect = "wuyue.com"

// 定义 error的常量方便判断
var (
UserAleadyExists = errors.New("用户已存在")
WrongPassword = errors.New("密码不正确")
UserNoExists = errors.New("用户不存在")
)

// dao层 其实就是将数据库操作 封装为函数 等待logic层 去调用她

func InsertUser(user *models.User) error {
// 密码要加密保存
user.Password = encryptPassword(user.Password)
sqlstr := `insert into user(user_id,username,password) values(?,?,?)`
_, err := db.Exec(sqlstr, user.UserId, user.Username, user.Password)
if err != nil {
zap.L().Error("InsertUser dn error", zap.Error(err))
return err
}
return nil
}

//
func Login(user *models.User) error {
oldPassword := user.Password
sqlStr := `select user_id,username,password from user where username=?`
err := db.Get(user, sqlStr, user.Username)
if err == sql.ErrNoRows {
return UserNoExists
}
if err != nil {
return err
}
if encryptPassword(oldPassword) != user.Password {
return WrongPassword
}
return nil
}

// CheckUserExist 检查数据库是否有该用户名
func CheckUserExist(username string) error {
sqlstr := `select count(user_id) from user where username = ?`
var count int
err := db.Get(&count, sqlstr, username)
if err != nil {
zap.L().Error("CheckUserExist dn error", zap.Error(err))
return err
}
if count > 0 {
return UserAleadyExists
}
return nil
}

// 加密密码
func encryptPassword(password string) string {
h := md5.New()
h.Write([]byte(serect))
return hex.EncodeToString(h.Sum([]byte(password)))
}

最后 看下controller层如何处理

这里主要是关注一下 errors.Is 这个写法

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
70
71
72
73
74
go复制代码package controllers

import (
"errors"
"go_web_app/dao/mysql"
"go_web_app/logic"
"go_web_app/models"

"github.com/go-playground/validator/v10"

"go.uber.org/zap"

"github.com/gin-gonic/gin"
)

func LoginHandler(c *gin.Context) {
p := new(models.ParamLogin)

if err := c.ShouldBindJSON(p); err != nil {
zap.L().Error("LoginHandler with invalid param", zap.Error(err))
// 因为有的错误 比如json格式不对的错误 是不属于validator错误的 自然无法翻译,所以这里要做类型判断
errs, ok := err.(validator.ValidationErrors)
if !ok {
ResponseError(c, CodeInvalidParam)
} else {
ResponseErrorWithMsg(c, CodeInvalidParam, removeTopStruct(errs.Translate(trans)))
}
return
}

// 业务处理
err := logic.Login(p)
if err != nil {
// 可以在日志中 看出 到底是哪些用户不存在
zap.L().Error("login failed", zap.String("username", p.UserName), zap.Error(err))
if errors.Is(err, mysql.WrongPassword) {
ResponseError(c, CodeInvalidPassword)
} else {
ResponseError(c, CodeServerBusy)
}
return
}
ResponseSuccess(c, "login success")
}

func RegisterHandler(c *gin.Context) {
// 获取参数和参数校验
p := new(models.ParamRegister)
// 这里只能校验下 是否是标准的json格式 之类的 比较简单
if err := c.ShouldBindJSON(p); err != nil {
zap.L().Error("RegisterHandler with invalid param", zap.Error(err))
// 因为有的错误 比如json格式不对的错误 是不属于validator错误的 自然无法翻译,所以这里要做类型判断
errs, ok := err.(validator.ValidationErrors)
if !ok {
ResponseError(c, CodeInvalidParam)
} else {
ResponseErrorWithMsg(c, CodeInvalidParam, removeTopStruct(errs.Translate(trans)))
}
return
}
// 业务处理
err := logic.Register(p)
if err != nil {
zap.L().Error("register failed", zap.String("username", p.UserName), zap.Error(err))
if errors.Is(err, mysql.UserAleadyExists) {
ResponseError(c, CodeUserExist)
} else {
ResponseError(c, CodeInvalidParam)
}
return
}
// 返回响应
ResponseSuccess(c, "register success")
}

最后看下我们的效果:

image.png

image.png

实现JWT的认证方式

关于JWT 可以自行查找相关概念,这里不重复叙述 仅实现一个JWT的 登录认证

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
go复制代码package jwt

import (
"errors"
"time"

"github.com/golang-jwt/jwt"
)

// MyClaims 注意这里不要 存储 密码之类的敏感信息哟
type MyClaims struct {
UserId int64 `json:"userId"`
UserName string `json:"userName"`
jwt.StandardClaims
}

const TokenExpireDuration = time.Hour * 2

var mySerect = []byte("wuyue is good man")

// GenToken 生成token
func GenToken(username string, userid int64) (string, error) {
c := MyClaims{
UserId: userid,
UserName: username,
StandardClaims: jwt.StandardClaims{
ExpiresAt: time.Now().Add(TokenExpireDuration).UnixNano(), //过期时间
Issuer: "bbs-project", //签发人
},
}
// 加密这个token
token := jwt.NewWithClaims(jwt.SigningMethodHS256, c)
// 用签名来 签名这个token
return token.SignedString(mySerect)
}

// ParseToken 解析token
func ParseToken(tokenString string) (*MyClaims, error) {

var mc = new(MyClaims)
token, err := jwt.ParseWithClaims(tokenString, mc, func(token *jwt.Token) (interface{}, error) {
return mySerect, nil
})
if err != nil {
return nil, err
}
// 校验token
if token.Valid {
return mc, nil
}

return nil, errors.New("invalid token")

}

剩下的就是 在登录成功的时候 返回这个token 给客户端即可

找到我们的logic层:

1
2
3
4
5
6
7
8
9
10
go复制代码func Login(login *models.ParamLogin) (string, error) {
user := models.User{
Username: login.UserName,
Password: login.Password,
}
if err := mysql.Login(&user); err != nil {
return "", err
}
return jwt.GenToken(user.Username, user.UserId)
}

在controller层 将我们的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
scss复制代码func LoginHandler(c *gin.Context) {
p := new(models.ParamLogin)

if err := c.ShouldBindJSON(p); err != nil {
zap.L().Error("LoginHandler with invalid param", zap.Error(err))
// 因为有的错误 比如json格式不对的错误 是不属于validator错误的 自然无法翻译,所以这里要做类型判断
errs, ok := err.(validator.ValidationErrors)
if !ok {
ResponseError(c, CodeInvalidParam)
} else {
ResponseErrorWithMsg(c, CodeInvalidParam, removeTopStruct(errs.Translate(trans)))
}
return
}

// 业务处理
token, err := logic.Login(p)
if err != nil {
// 可以在日志中 看出 到底是哪些用户不存在
zap.L().Error("login failed", zap.String("username", p.UserName), zap.Error(err))
if errors.Is(err, mysql.WrongPassword) {
ResponseError(c, CodeInvalidPassword)
} else {
ResponseError(c, CodeServerBusy)
}
return
}
ResponseSuccess(c, token)
}

最后看下效果:

image.png

验证token

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
go复制代码//验证jwt机制
r.GET("/ping", func(context *gin.Context) {
// 这里post man 模拟的 将token auth-token
token := context.Request.Header.Get("auth-token")
if token == "" {
controllers.ResponseError(context, controllers.CodeTokenIsEmpty)
return
}
parseToken, err := jwt.ParseToken(token)
if err != nil {
controllers.ResponseError(context, controllers.CodeTokenInvalid)
return
}

zap.L().Debug("token parese", zap.String("username", parseToken.UserName))
controllers.ResponseSuccess(context, "pong")
})

image.png

image.png

本文转载自: 掘金

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

【计算机图形学】图形变换+复杂图形组合——MATLAB实现

发表于 2021-11-27

这是我参与11月更文挑战的第4天,活动详情查看:2021最后一次更文挑战

1 引言

图形变换和观察是计算机图形学的基础内容之一,也是图形显示过程中不可缺少的一个环节

许多复杂的图形,其实可以由简单图形通过平移、旋转、缩放、镜像、投影等操作,再加以组合得到

本文将讨论二维图形各种变换,以及将变换结果组合为复杂的图形

2 思路

所有的变换功能,如平移、旋转、缩放等,均可集成在一个函数中,调用时只需改变函数的参数即可,而不需重复实现

二维图形变换主要思想为——矩阵运算,且采用右乘法为主

  • 将组成原图形的点坐标化为齐次形式
  • 平移变换矩阵为[100010TxTy1]\begin{bmatrix}1&0&0\0&1&0\T_x&T_y&1\end{bmatrix}⎣⎡​10Tx​​01Ty​​001​⎦⎤​
  • 缩放变换矩阵为[Sx000Sy0001]\begin{bmatrix}S_x&0&0\0&S_y&0\0&0&1\end{bmatrix}⎣⎡​Sx​00​0Sy​0​001​⎦⎤​
  • 旋转变换矩阵为[cosθsinθ0−sinθcosθ0001]\begin{bmatrix}cos\theta&sin\theta&0\-sin\theta&cos\theta&0\0&0&1\end{bmatrix}⎣⎡​cosθ−sinθ0​sinθcosθ0​001​⎦⎤​

Tips:

若原始图形变换基准点不在原点,需先将其平移至原点,进行相应变换后,再逆变换平移回到原始位置

这样完成所需功能,则需要多个矩阵变换矩阵相乘,即级联变换

3 过程

3.1 函数

函数调用格式为xuanzhuan(x,y,a,k,tx,ty,sx,sy)

  • 其中x,y为图形坐标
  • a为旋转角度(角度制)
  • k为模式:1为直线,2为圆或椭圆
  • tx,ty为平移参数
  • sx,sy为缩放参数

首先初始化

1
2
3
4
5
matlab复制代码[~,n]=size(x);
e=ones(1,n);
A=[x;y;e]'; %齐次化矩阵
B=A; %初始化
a1=pi/(180/a); %化为弧度制

再分别根据直线,或者圆和椭圆的情况进行变换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
matlab复制代码%直线的情况
if k==1
B=A*[1 0 0;0 1 0;-min(x) -min(y) 1]...
*[cos(a1) sin(a1) 0;-sin(a1) cos(a1) 0;0 0 1]...
*[1 0 0;0 1 0;min(x) min(y) 1];
C=B*[1 0 0;0 1 0;-min(x) -min(y) 1]...
*[sx 0 0;0 sy 0;tx ty 1]...
*[1 0 0;0 1 0;min(x) min(y) 1];
end
%圆或椭圆的情况
if k==2
xpingyi=(max(x)+min(x))/2;ypingyi=(max(y)+min(y))/2;
B=A*[1 0 0;0 1 0;-xpingyi -ypingyi 1]...
*[cos(a1) sin(a1) 0;-sin(a1) cos(a1) 0;0 0 1]...
*[1 0 0;0 1 0;xpingyi ypingyi 1];
C=B*[1 0 0;0 1 0;-xpingyi -ypingyi 1]...
*[sx 0 0;0 sy 0;tx ty 1]...
*[1 0 0;0 1 0;xpingyi ypingyi 1];
end

最后将新、旧图形一并绘出,便于进行对比

1
2
matlab复制代码%绘制新旧图形
plot(A(:,1),A(:,2),'r-',C(:,1),C(:,2),'r-');

3.2 变换过程

  1. 首先在直线、圆、椭圆绘制的基础上,得到其基本图形的x,y坐标
  2. 对一条直线进行旋转操作,再对新旧两条直线进行平移
  3. 对绘制的椭圆进行旋转和对称操作,使之拥有优美的对称形式
  4. 对圆进行缩放操作,压缩后的圆变成了椭圆,与圆叠加形成一种“行星”效果

完整代码请见GraphTransform(gitee.com)

4 结果

图形组合效果如下:

本文转载自: 掘金

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

小白都能看懂的 Spring 源码揭秘之依赖注入(DI)源码

发表于 2021-11-27

前言

在面试中,经常被问到 Spring 的 IOC 和 DI(依赖注入),很多人会觉得其实 IOC 就是 DI,但是严格上来说这两个其实并不等价,因为 IOC 注重的是存,而依赖注入注重的是取,实际上我们除了依赖注入还有另一种取的方式那就是依赖查找,可以把依赖注入和依赖查找都理解成 IOC 的实现方式。

依赖注入的入口方法

上一篇我们讲到了 IOC 的初始化流程,不过回想一下,是不是感觉少了点什么?IOC 的初始化只是将 Bean 的相关定义文件进行了存储,但是好像并没有进行初始化,而且假如一个类里面引用了另一个类,还需要进行赋值操作,这些我们都没有讲到,这些都属于我们今天讲解的依赖注入。

默认情况下依赖注入只有在调用 getBean() 的时候才会触发,因为 Spring 当中默认是懒加载,除非明确指定了配置 lazy-init=false,或者使用注解 @Lazy(value = false),才会主动触发依赖注入的过程。

依赖注入流程分析

在分析流程之前,我们还是看下面这个例子:

1
2
3
java复制代码ApplicationContext applicationContext = new ClassPathXmlApplicationContext("spring.xml");
applicationContext.getBean("myBean");
applicationContext.getBean(MyBean.class);

我们的分析从 getBean() 方法开始。

AbstractBeanFactory#getBean

在前面我们讲到了一个顶层接口 BeanFactory 中定义了操作 Bean 的相关方法,而 ApplicationContext 就间接实现了 BeanFactory 接口,所以其调用 getBean() 方法会进入到 AbstractBeanFactory 类中的方法:

1638019023(1).png

可以看到,这里调用之后直接就看到 doXXX 方法了。

AbstractBeanFactory#doGetBean

进入 doGetBean 这个方法进去之后呢,会有一系列判断,主要有以下几个方面:

  1. 当前类是不是单例,如果是的话而且单例已经被创建好,那么直接返回。
  2. 当前原型 bean 是否正在创建,如果是的话就认为产生了循环依赖,抛出异常。
  3. 手动通过 @DependsOn 注解或者 xml 配置中显式指定的依赖是否存在循环依赖问题,存在的话直接抛出异常。
  4. 当前的 BeanFactory 中的 beanDefinitionMap 容器中是否存在当前 bean 对应的 BeanDefinition,如果不存在则会去父类中继续获取,然后重新调用其父类对应的 getBean() 方法。

经过一系列的判断之后,会判断当前 Bean 是原型还是单例,然后走不同的处理逻辑,但是不论是原型还是单例对象,最终其都会调用 AbstractAutowireCapableBeanFactory 类中的 createBean 方法进行创建 bean 实例

1638019089(1).png

AbstractAutowireCapableBeanFactory#createBean

这个方法里面会先确认当前 bean 是否可以被实例化,然后会有两个主要逻辑:

  1. 是否返回一个代理对象,是的话返回代理对象。
  2. 直接创建一个 bean 对象实例。

这里面第一个逻辑我们不重点分析,在这里我们主要还是分析第二个逻辑,如何创建一个 bean 实例:

1638019119(1).png

AbstractAutowireCapableBeanFactory#doCreateBean

这又是一个以 do 开头的方法,说明这里面会真正创建一个 bean 实例对象,在分析这个方法之前,我们先自己来设想一下,假如是我们自己来实现,在这个方法需要做什么操作?

在这个方法中,最核心的就是做两件事:

  1. 实例化一个 bean 对象。
  2. 遍历当前对象的属性,如果需要则注入其他 bean,如果发现需要注入的 bean 还没有实例化,则需要先进行实例化。

创建 bean 实例(AbstractAutowireCapableBeanFactory#createBeanInstance)

在 doCreateBean 方法中,会调用 createBeanInstance 方法来实例化一个 bean。这里面也会有一系列逻辑去处理,比如判断这个类是不是具有 public 权限等等,但是最终还是会通过反射去调用当前 bean 的无参构造器或者有参构造器来初始化一个 bean 实例,然后再将其封装成一个 BeanWrapper 对象返回。

不过如果这里调用的是一个有参构造器,而这个参数也是一个 bean,那么也会触发先去初始化参数中的 bean,初始化 bean 实例除了有参构造器形式之外,相对还是比较容易理解,我们就不过多去分析细节,主要重点是分析依赖注入的处理方式。

依赖注入(AbstractAutowireCapableBeanFactory#populateBean)

在上面创建 Bean 实例完成的时候,我们的对象并不完整,因为还只是仅仅创建了一个实例,而实例中的注入的属性却并未进行填充,所以接下来就还需要完成依赖注入的动作,那么在依赖注入的时候,如果发现需要注入的对象尚未初始化,还需要触发注入对象的初始化动作,同时在注入的时候也会分为按名称注入和按类型注入(除此之外还有构造器注入等方式):

1638019155(1).png

我们在依赖注入的时候最常用的是 @Autowired 和 @Resource 两个注解,而这连个注解的区别之一就是一个按照类型注入,另一个优先按照名称注入(没有找到名称就会按照类型注入),但是实际上这两个注解都不会走上面的按名称注入和按类型注入的逻辑,而是都是通过对应的 AutowiredAnnotationBeanPostProcessor 和 CommonAnnotationBeanPostProcessor 两个 Bean 的后置处理器来实现的,而且 @Resource 注解当无法通过名称找到 Bean 时也会根据类型去注入,在这里具体的处理细节我们就不过多展开分析,毕竟我们今天的目标是分析整个依赖注入的流程,如果过多纠结于这些分支细节,反而会使大家更加困惑。

上面通过根据名称或者根据属性解析出依赖的属性之后,会将其封装到对象 MutablePropertyValues(即:PropertyValues 接口的实现类) 中,最后会再调用 applyPropertyValues() 方法进行真正的属性注入:

1638019195(1).png

处理完之后,最后会再调用 applyPropertyValues() 方法进行真正的属性注入。

循环依赖问题是怎么解决的

依赖注入成功之后,整个 DI 流水就算结束了,但是有一个问题我们没有提到,那就是循环依赖问题,循环依赖指的是当我们有两个类 A 和 B,其中 A 依赖 B,B 又依赖了 A,或者多个类也一样,只要形成了一个环状依赖那就属于循环依赖,比如下面的配置就是一个典型的循环依赖配置:

1
2
xml复制代码<bean id="classA" class="ClassA" p:beanB-ref="classB"/>
<bean id="classB" class="ClassB" p:beanA-ref="classA"/>

而我们前面讲解 Bean 的初始化时又讲到了当我们初始化 A 的时候,如果发现其依赖了 B,那么会触发 B 的初始化,可是 B 又依赖了 A,导致其无法完成初始化,这时候我们应该怎么解决这个问题呢?

在了解 Spring 中是如何解决这个问题之前,我们自己先想一下,如果换成我们来开发,我们会如何解决这个问题呢?其实方法也很简单,大家应该都能想到,那就是当我们把 Bean 初始化之后,在没有注入属性之前,就先缓存起来,这样,就相当于缓存了一个半成品 Bean 来提前暴露出来供注入时使用。

不过解决循环依赖也是有前提的,以下三种情形就无法解决循环依赖问题:

  • 构造器注入产生的循环依赖。通过构造器注入产生的循环依赖会在第一步初始化就失败,所以也无法提前暴露出来。
  • 非单例模式 Bean,因为只有在单例模式下才会对 Bean 进行缓存。
  • 手动设置了 allowCircularReferences=false,则表示不允许循环依赖。

而在 Spring 当中处理循环依赖也是这个思路,只不过 Spring 中为了考虑设计问题,并非仅仅只采用了一个缓存,而是采用了三个缓存,这也就是面试中经常被问到的循环依赖相关的三级缓存问题(这里我个人意见是不太认同三级缓存这种叫法的,毕竟这三个缓存是在同一个类中的三个不同容器而已,并没有层级关系,这一点和 MyBatis 中使用到的两级缓存还是有区别的,不过既然大家都这么叫,咱一个凡人也就随波逐流了)。

Spring 中解决循环依赖的三级缓存

如下图所示,在 Spring 中通过以下三个容器(Map 集合)来缓存单例 Bean:

1638019226(1).png

  • singletonObjects

这个容器用来存储成品的单例 Bean,也就是所谓的第一级缓存。

  • earlySingletonObjects

这个用来存储半成品的单例 Bean,也就是初始化之后还没有注入属性的 Bean,也就是所谓的第二级缓存。

  • singletonFactories

存储的是 Bean 工厂对象,可以用来生成半成品的 Bean,这也就是所谓的三级缓存。

为什么需要三级缓存才能解决循环依赖问题

看了上面的三级缓存,不知道大家有没有疑问,因为第一级缓存和第二级缓存都比较好理解,一个成品一个半成品,这个都没什么好说的,那么为什么又需要第三级缓存呢,这又是出于什么考虑呢?

回答这个问题之前,我梳理了有循环依赖和没有循环依赖两种场景的流程图来进行对比分析:

没有循环依赖的创建 Bean A 流程:

1638019251(1).png

有循环依赖的创建 Bean A 流程(A 依赖 B,B 依赖 A):

1638019277(1).png

对比这两个流程其实有一个比较大的区别,我在下面这个有循环依赖的注入流程标出来了,那就是在没有循环依赖的情况下一个类是会先完成属性的注入,才会调用 BeanPostProcessor 处理器来完成一些后置处理,这也比较符合常理也符合 Bean 的生命周期,而一旦有循环依赖之后,就不得不把 BeanPostProcessor 提前进行处理,这样在一定程度上就破坏了 Bean 的生命周期。

但是到这里估计大家还是有疑问,因为这并不能说明一定要使用三级缓存的理由,那么这里就涉及到了 Spring Aop 了,当我们使用了 Spring Aop 之后,那么就不能使用原生对象而应该换成用代理对象,那么代理对象是什么时候创建的呢?

实际上 Spring Aop 的代理对象也是通过 BeanPostProcessor 来完成的,下图就是一个使用了 Spring Aop 的实例对象所拥有的所有 BeanPostProcessor:

1638019300(1).png

在这里有一个 AnnotationAwareAspectJAutoProxyCreator 后置处理器,也就是 Spring Aop 是通过后置处理器来实现的。

知道了这个问题,我们再来确认另一个问题,Spring 中为了解决循环依赖问题,在初始化 Bean 之后,还未注入属性之前就会将单例 Bean 先放入缓存,但是这时候也不能直接将原生对象放入二级缓存,因为这样的话如果使用了 Spring Aop 就会出问题,其他类可能会直接注入原生对象而非代理对象。

那么这里我们能不能直接就创建代理对象存入二级缓存呢?答案是可以,但是直接创建代理对象就必须要调用 BeanPostProcessor 后置处理器,这样就使得调用后置处理器在属性注入之前了,违背了 Bean 声明周期。

在提前暴露单例之前,Spring 并不知道当前 Bean 是否有循环依赖,所以为了尽可能的延缓 BeanPostProcessor 的调用,Spring 才采用了三级缓存,存入一个 Objectactory 对象,并不创建,而是当发生了循环依赖的时候,采取三级缓存获取到三级缓存来创建对象,因为发生了循环依赖的时候,不得不提前调用 BeanPostProcessor 来完成实例的初始化。

我们看下加入三级缓存的逻辑:

1638019324(1).png

加入三级缓存是将一个 lambda 表达式存进去,目的就是延缓创建,最后发生循环依赖的时候,从一二级缓存都无法获取到 Bean 的时候,会获取三级缓存,也就是调用 ObjectFactory 的 getObject() 方法,而这个方法实际上就是调用下面的 getEarlyBeanReference ,这里就会提前调用 BeanPostProcessor 来完成实例的创建。

1638019346(1).png

总结

本文主要分析了 Spinrg 依赖注入的主要流程,而依赖注入中产生的循环依赖问题又是其中比较复杂的处理方式,在本文分析过程中略去了详细的逻辑,只关注了主流程。本文主要是结合了网上一些资料然后自己 debug 调试过程得到的自己对 Spring 依赖注入的一个主要流程,如果有理解错误的地方,欢迎留言交流。

本文转载自: 掘金

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

Go基于WebSocket聊天程序

发表于 2021-11-27

后端代码:
github.com/kone-net/go…
前端代码:
github.com/kone-net/go…

go-chat

使用Go基于WebSocket的通讯聊天软件。

功能列表:

  • 登录注册
  • 修改头像
  • 群聊天
  • 群好友列表
  • 单人聊天
  • 添加好友
  • 添加群组
  • 文本消息
  • 剪切板图片
  • 图片消息
  • 文件发送
  • 语音消息
  • 视频消息
  • 屏幕共享(基于图片)
  • 视频通话(基于WebRTC的p2p视频通话)

后端

代码仓库
go中协程是非常轻量级的。在每个client接入的时候,为每一个client开启一个协程,能够在单机实现更大的并发。同时go的channel,可以非常完美的解耦client接入和消息的转发等操作。

通过go-chat,可以掌握channel的和Select的配合使用,ORM框架的使用,web框架Gin的使用,配置管理,日志操作,还包括proto buffer协议的使用,等一些列项目中常用的技术。

后端技术和框架

  • web框架Gin
  • 长连接WebSocket
  • 日志框架Uber的zap
  • 配置管理viper
  • ORM框架gorm
  • 通讯协议Google的proto buffer
  • makefile 的编写
  • 数据库MySQL
  • 图片文件二进制操作

前端

基于react,UI和基本组件是使用ant design。可以很方便搭建前端界面。

界面选择单页框架可以更加方便写聊天界面,比如像消息提醒,可以在一个界面接受到消息进行提醒,不会因为换页面或者查看其他内容影响消息接受。
前端代码仓库:
github.com/kone-net/go…

前端技术和框架

  • React
  • Redux状态管理
  • AntDesign
  • proto buffer的使用
  • WebSocket
  • 剪切板的文件读取和操作
  • 聊天框发送文字显示底部
  • FileReader对文件操作
  • ArrayBuffer,Blob,Uint8Array之间的转换
  • 获取摄像头视频(mediaDevices)
  • 获取麦克风音频(Recorder)
  • 获取屏幕共享(mediaDevices)
  • WebRTC的p2p视频通话

截图

  • 语音,文字,图片,视频消息

go-chat-panel.jpeg

  • 视频通话

screenshot-20211127-092057.png

  • 屏幕共享

screenshot-20211127-092410.png

消息协议

protocol buffer协议

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
go复制代码syntax = "proto3";
package protocol;

message Message {
string avatar = 1; //头像
string fromUsername = 2; // 发送消息用户的用户名
string from = 3; // 发送消息用户uuid
string to = 4; // 发送给对端用户的uuid
string content = 5; // 文本消息内容
int32 contentType = 6; // 消息内容类型:1.文字 2.普通文件 3.图片 4.音频 5.视频 6.语音聊天 7.视频聊天
string type = 7; // 如果是心跳消息,该内容为heatbeat
int32 messageType = 8; // 消息类型,1.单聊 2.群聊
string url = 9; // 图片,视频,语音的路径
string fileSuffix = 10; // 文件后缀,如果通过二进制头不能解析文件后缀,使用该后缀
bytes file = 11; // 如果是图片,文件,视频等的二进制
}

选择协议原因

通过消息体能看出,消息大部分都是字符串或者整型类型。通过json就可以进行传输。那为什么要选择google的protocol buffer进行传输呢?

  • 一方面传输快
    是因为protobuf序列化后的大小是json的10分之一,是xml格式的20分之一,但是性能却是它们的5~100倍.
  • 另一方面支持二进制
    当我们看到消息体最后一个字段,是定义的bytes,二进制类型。
    我们在传输图片,文件,视频等内容的时候,可以将文件直接通过socket消息进行传输。
    当然我们也可以将文件先通过http接口上传后,然后返回路径,再通过socket消息进行传输。但是这样只能实现固定大小文件的传输,如果我们是语音电话,或者视频电话的时候,就不能传输流。

快速运行

运行go程序

go环境的基本配置
…

拉取后端代码

1
shell复制代码git clone https://github.com/kone-net/go-chat

进入目录

1
shell复制代码cd go-chat

拉取程序所需依赖

1
shell复制代码go mod download

MySQL创建数据库

1
mysql复制代码CREATE DATABASE chat;

修改数据库配置文件

1
2
3
4
5
6
7
8
9
10
11
shell复制代码vim config.toml

[mysql]
host = "127.0.0.1"
name = "chat"
password = "root1234"
port = 3306
table_prefix = ""
user = "root"

修改用户名user,密码password等信息。

创建表

1
shell复制代码将chat.sql里面的sql语句复制到控制台创建对应的表。

在user表里面添加初始化用户

1
shell复制代码手动添加用户。

运行程序

1
shell复制代码go run cmd/main.go

运行前端代码

配置React基本环境,比如nodejs
…

拉取代码

1
shell复制代码git clone https://github.com/kone-net/go-chat-web

安装前端基本依赖

1
shell复制代码npm install

如果后端地址或者端口号需要修改

1
shell复制代码修改src/common/param/Params.jsx里面的IP_PORT

运行前端代码默认启动端口是3000

1
shell复制代码npm start

访问前端入口

1
arduino复制代码http://127.0.0.1:3000/login

代码结构

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
arduino复制代码├── Makefile       代码编译,打包,结构化等操作
├── README.md
├── api
│   └── v1 controller类,对外的接口,如添加好友,查找好友等。所有http请求的入口
├── bin
│   └── chat 打包的二进制文件
├── chat.sql 整个项目的SQL
├── cmd main函数入口,程序启动
├── common
│   ├── constant 常量
│   └── util 工具类
├── config 配置初始化类
├── config.toml 配置文件
├── dao
│   └── pool 数据库连接池
├── errors 封装的异常类
├── global
│   └── log 封装的日志类,使用时不会出现第三方的包依赖
├── go.mod
├── go.sum
├── logs 日志文件
├── model 数据库模型,和表一一对应
│   ├── request 请求的实体类
│   ├── response 响应的实体类
├── protocol 消息协议
│   ├── message.pb.go protoc buffer自动生成的文件
│   └── message.proto 定义的protoc buffer字段
├── response 全局响应,通过http请求的,都包含code,msg,data三个字段
├── router gin和controller类进行绑定
├── server WebSocket中消息的接受和转发的主要逻辑
├── service controller调用的服务类
├── static 静态文件,图片等
│   ├── img
│   └── screenshot markdown用到的截图文件
└── test 测试文件

Makefile

程序打包

在根目录下执行make命令
mac

1
2
3
4
bash复制代码make build-darwin

实际执行命令是Makefile下的
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o bin/chat cmd/main.go

linux

1
2
3
4
bash复制代码make build

实际执行命令是Makefile下的
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bin/chat cmd/main.go

后端proto文件生成

如果修改了message.proto,就需要重新编译生成对应的go文件。
在根目录下执行

1
2
3
4
bash复制代码make proto

实际执行命令是Makefile下的
protoc --gogo_out=. protocol/*.proto

如果本地没有安装proto文件,需要先进行安装,不然找不到protoc命令。
使用gogoprotobuf

安装protobuf库文件

1
bash复制代码go get github.com/golang/protobuf/proto

安装protoc-gen-gogo

1
bash复制代码go get github.com/gogo/protobuf/protoc-gen-gogo

安装gogoprotobuf库文件

1
bash复制代码go get github.com/gogo/protobuf/proto

在根目录测试:

1
bash复制代码protoc --gogo_out=. protocol/*.proto

前端proto文件生成

前端需要安装protoc buffer库

1
bash复制代码npm install protobufjs

生成protoc的js文件到目录

1
2
3
4
bash复制代码npx pbjs -t json-module -w commonjs -o src/chat/proto/proto.js  src/chat/proto/*.proto

src/chat/proto/proto.js 是生成的文件的目录路径及其文件名称
src/chat/proto/*.proto 是自己写的字段等

代码说明

WebSocket

该文件是gin的路由映射,将普通的get请求,Upgrader为socket连接

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
go复制代码// router/router.go
func NewRouter() *gin.Engine {
gin.SetMode(gin.ReleaseMode)

server := gin.Default()
server.Use(Cors())
server.Use(Recovery)

socket := RunSocekt

group := server.Group("")
{
...

group.GET("/socket.io", socket)
}
return server
}

这部分对请求进行升级为WebSocket。

  • c.Query(“user”)用户登录后,会获取用户的uuid,在连接到socket时会携带用户的uuid。
  • 通过该uuid和connection进行关联。
  • server.MyServer.Register <- client将每个client实例,通过channel进行传达,Server实例的Select会对该实例进行保存。
  • client.Read(),client.Write()通过协程让每个client对自己独有的channel进行消息的读取和发送
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
go复制代码// router/socket.go
var upGrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
}

func RunSocekt(c *gin.Context) {
user := c.Query("user")
if user == "" {
return
}
log.Info("newUser", zap.String("newUser", user))
ws, err := upGrader.Upgrade(c.Writer, c.Request, nil) //升级协议为WebSocket
if err != nil {
return
}

client := &server.Client{
Name: user,
Conn: ws,
Send: make(chan []byte),
}

server.MyServer.Register <- client
go client.Read()
go client.Write()
}

这是Server的三个channel,

  • 用户登录后,将用户和connection绑定存放在map中
  • 用户离线后,将用户从map中剔除
  • 所有消息,每个client将消息获取后放入该channel中,统一在这里进行消息的分发
  • 分发消息:
    • 如果是单聊,直接根据前端发送的uuid找到对应的client进行发送。
    • 如果是群聊,需要在数据库查询该群所有的成员,在根据uuid找到对应的client进行发送。
    • 如果消息为普通文本消息,可以直接转发到对应的客户端。
    • 如果消息为视频文件,普通文件,照片之类的,需要先将文件进行保存,然后返回文件名称,前端根据名称调用接口获取文件。
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
go复制代码// server/server.go
func (s *Server) Start() {
log.Info("start server", log.Any("start server", "start server..."))
for {
select {
case conn := <-s.Register:
log.Info("login", log.Any("login", "new user login in"+conn.Name))
s.Clients[conn.Name] = conn
msg := &protocol.Message{
From: "System",
To: conn.Name,
Content: "welcome!",
}
protoMsg, _ := proto.Marshal(msg)
conn.Send <- protoMsg

case conn := <-s.Ungister:
log.Info("loginout", log.Any("loginout", conn.Name))
if _, ok := s.Clients[conn.Name]; ok {
close(conn.Send)
delete(s.Clients, conn.Name)
}

case message := <-s.Broadcast:
msg := &protocol.Message{}
proto.Unmarshal(message, msg)
...
...
}
}
}

剪切板图片上传

上传剪切板的文件,首先我们需要获取剪切板文件。
如以下代码:

  • 通过在聊天输入框,绑定粘贴命令,获取粘贴板的内容。
  • 我们只获取文件信息,其他文字信息过滤掉。
  • 先获取文件的blob格式。
  • 通过FileReader,将blob转换为ArrayBuffer格式。
  • 将ArrayBuffer内容转换为Uint8Array二进制,放在消息体。
  • 通过protobuf将消息转换成对应协议。
  • 通过socket进行传输。
  • 最后,将本地的图片追加到聊天框里面。
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
javascript复制代码bindParse = () => {
document.getElementById("messageArea").addEventListener("paste", (e) => {
var data = e.clipboardData
if (!data.items) {
return;
}
var items = data.items

if (null == items || items.length <= 0) {
return;
}

let item = items[0]
if (item.kind !== 'file') {
return;
}
let blob = item.getAsFile()

let reader = new FileReader()
reader.readAsArrayBuffer(blob)

reader.onload = ((e) => {
let imgData = e.target.result

// 上传文件必须将ArrayBuffer转换为Uint8Array
let data = {
fromUsername: localStorage.username,
from: this.state.fromUser,
to: this.state.toUser,
messageType: this.state.messageType,
content: this.state.value,
contentType: 3,
file: new Uint8Array(imgData)
}
let message = protobuf.lookup("protocol.Message")
const messagePB = message.create(data)
socket.send(message.encode(messagePB).finish())

this.appendImgToPanel(imgData)
})

}, false)
}

上传录制的视频

上传语音同原理

  • 获取视频调用权限。
  • 通过mediaDevices获取视频流,或者音频流,或者屏幕分享的视频流。
  • this.recorder.start(1000)设定每秒返回一段流。
  • 通过MediaRecorder将流转换为二进制,存入dataChunks数组中。
  • 松开按钮后,将dataChunks中的数据合成一段二进制。
  • 通过FileReader,将blob转换为ArrayBuffer格式。
  • 将ArrayBuffer内容转换为Uint8Array二进制,放在消息体。
  • 通过protobuf将消息转换成对应协议。
  • 通过socket进行传输。
  • 最后,将本地的视频,音频追加到聊天框里面。

特别注意: 获取视频,音频,屏幕分享调用权限,必须是https协议或者是localhost,127.0.0.1 本地IP地址,所有本地测试可以开启几个浏览器,或者分别用这两个本地IP进行2tab测试

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
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
javascript复制代码/**
* 当按下按钮时录制视频
*/
dataChunks = [];
recorder = null;
startVideoRecord = (e) => {
navigator.getUserMedia = navigator.getUserMedia ||
navigator.webkitGetUserMedia ||
navigator.mozGetUserMedia ||
navigator.msGetUserMedia; //获取媒体对象(这里指摄像头)

let preview = document.getElementById("preview");
this.setState({
isRecord: true
})

navigator.mediaDevices
.getUserMedia({
audio: true,
video: true,
}).then((stream) => {
preview.srcObject = stream;
this.recorder = new MediaRecorder(stream);

this.recorder.ondataavailable = (event) => {
let data = event.data;
this.dataChunks.push(data);
};
this.recorder.start(1000);
});
}

/**
* 松开按钮发送视频到服务器
* @param {事件} e
*/
stopVideoRecord = (e) => {
this.setState({
isRecord: false
})

let recordedBlob = new Blob(this.dataChunks, { type: "video/webm" });

let reader = new FileReader()
reader.readAsArrayBuffer(recordedBlob)

reader.onload = ((e) => {
let fileData = e.target.result

// 上传文件必须将ArrayBuffer转换为Uint8Array
let data = {
fromUsername: localStorage.username,
from: this.state.fromUser,
to: this.state.toUser,
messageType: this.state.messageType,
content: this.state.value,
contentType: 3,
file: new Uint8Array(fileData)
}
let message = protobuf.lookup("protocol.Message")
const messagePB = message.create(data)
socket.send(message.encode(messagePB).finish())
})

this.setState({
comments: [
...this.state.comments,
{
author: localStorage.username,
avatar: this.state.user.avatar,
content: <p><video src={URL.createObjectURL(recordedBlob)} controls autoPlay={false} preload="auto" width='200px' /></p>,
datetime: moment().fromNow(),
},
],
}, () => {
this.scrollToBottom()
})
if (this.recorder) {
this.recorder.stop()
this.recorder = null
}
let preview = document.getElementById("preview");
preview.srcObject.getTracks().forEach((track) => track.stop());
this.dataChunks = []
}

本文转载自: 掘金

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

Netty编程(八)—— 粘包半包(二)

发表于 2021-11-27

这是我参与11月更文挑战的第26天,活动详情查看:2021最后一次更文挑战

在上一篇博客Netty编程(七)—— 粘包半包(一) - 掘金 (juejin.cn)中介绍了一下什么是粘包和半包,这篇博客将继续介绍Netty如何处理粘包半包问题。

短链接

短链接的思路是客户端每次向服务器发送数据以后,就与服务器断开连接,此时的消息边界为连接建立到连接断开。这时便无需使用滑动窗口等技术来缓冲数据,则不会发生粘包现象。但如果一次性数据发送过多,接收方无法一次性容纳所有数据,还是会发生半包现象,所以短链接无法解决半包现象,短链接这种方式人为地设置了消息边界,不过很明显这种方法效率低,而它能解决粘包问题但不能解决半包问题。

客户端代码需要修改channelActive方法,每次发完数据后就关闭连接:

1
2
3
4
5
6
7
8
java复制代码public void channelActive(ChannelHandlerContext ctx) throws Exception {
log.debug("sending...");
ByteBuf buffer = ctx.alloc().buffer(16);
buffer.writeBytes(new byte[]{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15});
ctx.writeAndFlush(buffer);
// 使用短链接,每次发送完毕后就断开连接
ctx.channel().close();
}

客户端使用短链接的方式向服务端连续发送10次消息,运行结果如下,不会出现粘包问题:

在这里插入图片描述

定长解码器

定长解码器的思路是客户端和服务器约定一个最大长度,保证客户端每次发送的数据长度都不会大于该长度。若发送数据长度不足则需要补齐至该长度

服务器接收数据时,将接收到的数据按照约定的最大长度进行拆分,即使发送过程中产生了粘包,也可以通过定长解码器将数据正确地进行拆分。服务端需要用到FixedLengthFrameDecoder对数据进行定长解码,具体使用方法如下

ch.pipeline().addLast(new FixedLengthFrameDecoder(16))

客户端代码需要保证每次发送的数据长度为约定大小,客户端发送数据的代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java复制代码// 约定最大长度为16
final int maxLength = 16;
// 被发送的数据
char c = 'a';
// 向服务器发送10个报文
for (int i = 0; i < 10; i++) {
ByteBuf buffer = ctx.alloc().buffer(maxLength);
// 定长byte数组,未使用部分会以0进行填充
byte[] bytes = new byte[maxLength];
// 生成长度为0~15的数据
for (int j = 0; j < (int)(Math.random()*(maxLength-1)); j++) {
bytes[j] = (byte) c;
}
buffer.writeBytes(bytes);
c++;
// 将数据发送给服务器
ctx.writeAndFlush(buffer);
}

服务器中需要使用FixedLengthFrameDecoder对粘包数据进行拆分,该handler需要添加在LoggingHandler之前,保证数据被打印时已被拆分

1
2
3
java复制代码// 通过定长解码器对粘包数据进行拆分
ch.pipeline().addLast(new FixedLengthFrameDecoder(16));
ch.pipeline().addLast(new LoggingHandler(LogLevel.DEBUG));

运行结果如下:

在这里插入图片描述

行解码器

行解码器的是通过分隔符对数据进行拆分来解决粘包半包问题的,可以通过LineBasedFrameDecoder(int maxLength)来拆分以换行符(\n)为分隔符的数据,也可以通过DelimiterBasedFrameDecoder(int maxFrameLength, ByteBuf... delimiters)来指定通过什么分隔符来拆分数据(可以传入多个分隔符), 两种解码器都需要传入数据的最大长度,若超出最大长度,会抛出TooLongFrameException异常

下面的示例代码以换行符 \n 为分隔符,客户端代码需要在发送数据的结尾加上换行符\n作为为分隔符

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
java复制代码// 约定最大长度为 64
final int maxLength = 64;
// 被发送的数据
char c = 'a';
for (int i = 0; i < 10; i++) {
ByteBuf buffer = ctx.alloc().buffer(maxLength);
// 生成长度为0~62的数据
Random random = new Random();
StringBuilder sb = new StringBuilder();
for (int j = 0; j < (int)(random.nextInt(maxLength-2)); j++) {
sb.append(c);
}
// 数据以 \n 结尾
sb.append("\n");
buffer.writeBytes(sb.toString().getBytes(StandardCharsets.UTF_8));
c++;
// 将数据发送给服务器
ctx.writeAndFlush(buffer);
}

服务器代码需要使用DelimiterBasedFrameDecoder对数据进行拆分,该handler需要添加在LoggingHandler之前,保证数据被打印时已被拆分

1
2
3
4
java复制代码// 通过行解码器对粘包数据进行拆分,以 \n 为分隔符
// 需要指定最大长度
ch.pipeline().addLast(new DelimiterBasedFrameDecoder(64));
ch.pipeline().addLast(new LoggingHandler(LogLevel.DEBUG));

下面示例代码以自定义分隔符 \c 为分隔符:

客户端代码

1
2
3
4
5
java复制代码...   
// 数据以 \c 结尾
sb.append("\\c");
buffer.writeBytes(sb.toString().getBytes(StandardCharsets.UTF_8));
...

服务器代码在使用DelimiterBasedFrameDecoder时需要指定分隔符

1
2
3
4
5
java复制代码// 将分隔符放入ByteBuf中
ByteBuf bufSet = ch.alloc().buffer().writeBytes("\\c".getBytes(StandardCharsets.UTF_8));
// 通过行解码器对粘包数据进行拆分,以 \c 为分隔符
ch.pipeline().addLast(new DelimiterBasedFrameDecoder(64, ch.alloc().buffer().writeBytes(bufSet)));
ch.pipeline().addLast(new LoggingHandler(LogLevel.DEBUG));

运行结果:

在这里插入图片描述

长度字段解码器

在传送数据时可以在数据中添加一个用于表示有用数据长度的字段,在解码时读取出这个用于表明长度的字段,同时读取其他相关参数,即可知道最终需要的数据是什么样子的

LengthFieldBasedFrameDecoder解码器可以提供更为丰富的拆分方法,其构造方法有五个参数

1
2
3
4
java复制代码public LengthFieldBasedFrameDecoder(
int maxFrameLength,
int lengthFieldOffset, int lengthFieldLength,
int lengthAdjustment, int initialBytesToStrip )

参数解析

  • maxFrameLength 数据最大长度
    • 表示数据的最大长度(包括附加信息、长度标识等内容)
  • lengthFieldOffset 数据长度标识的起始偏移量
    • 用于指明数据第几个字节开始是用于标识有用字节长度的,因为前面可能还有其他附加信息
  • lengthFieldLength 数据长度标识所占字节数(用于指明有用数据长度的字节数)
    • 数据中用于表示有用数据长度的标识所占的字节数,==注意不是内容长度,而是长度字段的长度==
  • lengthAdjustment 长度表示与有用数据的偏移量
    • 用于指明数据长度标识和有用数据之间的距离,因为两者之间还可能有附加信息
  • initialBytesToStrip 数据读取起点
    • 读取起点,不读取 0 ~ initialBytesToStrip 之间的数据

参数图解

在这里插入图片描述

例子

1
2
3
4
5
6
7
8
9
10
ini复制代码lengthFieldOffset   = 0
lengthFieldLength = 2
lengthAdjustment = 0
initialBytesToStrip = 0 (= do not strip header)

BEFORE DECODE (14 bytes) AFTER DECODE (14 bytes)
+--------+----------------+ +--------+----------------+
| Length | Actual Content |----->| Length | Actual Content |
| 0x000C | "HELLO, WORLD" | | 0x000C | "HELLO, WORLD" |
+--------+----------------+ +--------+----------------+

从0开始即为长度标识,长度标识长度为2个字节

0x000C 即为后面 HELLO, WORLD的长度


1
2
3
4
5
6
7
8
9
10
ini复制代码lengthFieldOffset   = 0
lengthFieldLength = 2
lengthAdjustment = 0
initialBytesToStrip = 2 (= the length of the Length field)

BEFORE DECODE (14 bytes) AFTER DECODE (12 bytes)
+--------+----------------+ +----------------+
| Length | Actual Content |----->| Actual Content |
| 0x000C | "HELLO, WORLD" | | "HELLO, WORLD" |
+--------+----------------+ +----------------+

从0开始即为长度标识,长度标识长度为2个字节,读取时从第二个字节开始读取(此处即跳过长度标识),这里可以理解成解码时去掉前两个字节

因为跳过了用于表示长度的2个字节,所以此处直接读取HELLO, WORLD


1
2
3
4
5
6
7
8
9
10
ini复制代码lengthFieldOffset   = 2 (= the length of Header 1)
lengthFieldLength = 3
lengthAdjustment = 0
initialBytesToStrip = 0

BEFORE DECODE (17 bytes) AFTER DECODE (17 bytes)
+----------+----------+----------------+ +----------+----------+----------------+
| Header 1 | Length | Actual Content |----->| Header 1 | Length | Actual Content |
| 0xCAFE | 0x00000C | "HELLO, WORLD" | | 0xCAFE | 0x00000C | "HELLO, WORLD" |
+----------+----------+----------------+ +----------+----------+----------------+

长度标识前面还有2个字节的其他内容(0xCAFE),第三个字节开始才是长度标识,长度表示长度为3个字节(0x00000C)

Header1中有附加信息,读取长度标识时需要跳过这些附加信息来获取长度


1
2
3
4
5
6
7
8
9
10
ini复制代码lengthFieldOffset   = 0
lengthFieldLength = 3
lengthAdjustment = 2 (= the length of Header 1)
initialBytesToStrip = 0

BEFORE DECODE (17 bytes) AFTER DECODE (17 bytes)
+----------+----------+----------------+ +----------+----------+----------------+
| Length | Header 1 | Actual Content |----->| Length | Header 1 | Actual Content |
| 0x00000C | 0xCAFE | "HELLO, WORLD" | | 0x00000C | 0xCAFE | "HELLO, WORLD" |
+----------+----------+----------------+ +----------+----------+----------------+

从0开始即为长度标识,长度标识长度为3个字节,长度标识之后还有2个字节的其他内容(0xCAFE)

长度标识(0x00000C)表示的是从其后lengthAdjustment(2个字节)开始的数据的长度,即HELLO, WORLD,不包括0xCAFE


1
2
3
4
5
6
7
8
9
10
ini复制代码lengthFieldOffset   = 1 (= the length of HDR1)
lengthFieldLength = 2
lengthAdjustment = 1 (= the length of HDR2)
initialBytesToStrip = 3 (= the length of HDR1 + LEN)

BEFORE DECODE (16 bytes) AFTER DECODE (13 bytes)
+------+--------+------+----------------+ +------+----------------+
| HDR1 | Length | HDR2 | Actual Content |----->| HDR2 | Actual Content |
| 0xCA | 0x000C | 0xFE | "HELLO, WORLD" | | 0xFE | "HELLO, WORLD" |
+------+--------+------+----------------+ +------+----------------+

长度标识前面有1个字节的其他内容,后面也有1个字节的其他内容,读取时从长度标识之后3个字节处开始读取,即读取 0xFE HELLO, WORLD


使用

通过 EmbeddedChannel 对 handler 进行测试

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
java复制代码public class EncoderStudy {
public static void main(String[] args) {
// 模拟服务器
// 使用EmbeddedChannel测试handler
EmbeddedChannel channel = new EmbeddedChannel(
// 数据最大长度为1KB,长度标识前后各有1个字节的附加信息,长度标识长度为4个字节(int)
new LengthFieldBasedFrameDecoder(1024, 1, 4, 1, 0),
new LoggingHandler(LogLevel.DEBUG)
);

// 模拟客户端,写入数据
ByteBuf buffer = ByteBufAllocator.DEFAULT.buffer();
send(buffer, "Hello");
channel.writeInbound(buffer);
send(buffer, "World");
channel.writeInbound(buffer);
}

private static void send(ByteBuf buf, String msg) {
// 得到数据的长度
int length = msg.length();
byte[] bytes = msg.getBytes(StandardCharsets.UTF_8);
// 将数据信息写入buf
// 写入长度标识前的其他信息
buf.writeByte(0xCA);
// 写入数据长度标识
buf.writeInt(length);
// 写入长度标识后的其他信息
buf.writeByte(0xFE);
// 写入具体的数据
buf.writeBytes(bytes);
}
}

运行结果

在这里插入图片描述

本文转载自: 掘金

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

1…149150151…956

开发者博客

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