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

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


  • 首页

  • 归档

  • 搜索

阿里二面,Redis宕机了,如何恢复数据 背景 为什么要做持

发表于 2024-03-05

背景

有个同学阿里二面,面试官问:redis宕机了,如何恢复数据? 这位同学当时一脸懵,不知道如何回答。

分析分析这个问题,redis宕机,要想恢复数据,首先redis的数据有没有做持久化,用的是哪种策略,这种策略的机制是什么,有趣点是什么,以及你们是从什么方面考虑用着中机制的

其实面试官就是想考察,你们业务中redis的持久化策略,以及你对持久化策略有没有了解过,还是就直接使用,不管数据会回丢失,反正丢失了都是运维的锅,那你这样基本上GG了

为什么要做持久化

Redis是个基于内存的数据库。那服务一旦宕机,内存中的数据将全部丢失。通常的解决方案是从后端数据库恢复这些数据,但后端数据库有性能瓶颈,如果是大数据量的恢复,

  1. 会对数据库带来巨大的压力,严重可能导致mysql宕机
  2. 数据库的性能不如Redis。导致程序响应慢。所以对Redis来说,实现数据的持久化,避免从后端数据库中恢复数据,是至关重要的。

持久化策略

官方支持的持久化有四种,如下:

  1. RDB(Redis 数据库):RDB 持久性以指定的时间间隔执行数据集的时间点快照。
  2. AOF(仅追加文件):AOF 持久性记录服务器接收到的每个写操作。然后可以在服务器启动时再次重播这些操作,从而重建原始数据集。命令使用与 Redis 协议本身相同的格式进行记录。
  3. RDB + AOF:您还可以在同一个实例中组合 AOF 和 RDB。
  4. 无持久性:您可以完全禁用持久性。这种策略,一般很少有人使用吧

下面我们对这几种策略,进行详细梳理下

RDB

RDB 就是 Redis DataBase 的缩写,中文名为快照/内存快照,RDB持久化是把当前进程数据生成快照保存到磁盘上的过程,由于是某一时刻的快照,那么快照中的值要早于或者等于内存中的值。

默认情况下,Redis 将数据集的快照保存在磁盘上名为 dump.rdb 的二进制文件中。

Redis 提供了两个命令来生成 RDB 文件,分别是 save 和 bgsave。

  • save:在主线程中执行,会导致阻塞;
  • bgsave:创建一个子进程,专门用于写入 RDB 文件,避免了主线程的阻塞,这也是 Redis RDB 文件生成的默认配置。

一般通过 bgsave 命令来执行全量快照,这既提供了数据的可靠性保证,也避免了对 Redis 的性能影响。

redis.conf中配置RDB

内存快照虽然可以通过技术人员手动执行SAVE或BGSAVE命令来进行,但生产环境下多数情况都会设置其周期性执行条件。

1
2
3
4
5
6
7
8
9
10
bash复制代码# 周期性执行条件的设置格式为
save <seconds> <changes>

# 默认的设置为:
save 900 1
save 300 10
save 60 10000

# 以下设置方式为关闭RDB快照功能
save ""

以上三项默认信息设置代表的意义是:

  • 如果900秒内有1条Key信息发生变化,则进行快照;
  • 如果300秒内有10条Key信息发生变化,则进行快照;
  • 如果60秒内有10000条Key信息发生变化,则进行快照。

Copy-On-Write, COW

redis在执行bgsave生成快照的期间,将内存中的数据同步到硬盘的过程可能就会持续比较长的时间,而实际情况是这段时间Redis服务一般都会收到数据写操作请求。那么如何保证快照的完整性呢?

可能会说,为了保证快照完整性,redis只能处理读操作,不能修改正在执行快照的数据。你想如果这样?为了快照而暂停写操作,同时候你的业务会受到很大的影响,是不可接受的,那有其他方案吗?

Redis 就会借助操作系统提供的写时复制技术(Copy-On-Write, COW),在执行快照的同时,正常处理写操作。

bgsave 子进程是由主线程 fork 生成的,可以共享主线程的所有内存数据。bgsave 子进程运行后,开始读取主线程的内存数据,并把它们写入 RDB 文件。

此时,如果主线程对这些数据也都是读操作(例如图中的键值对 A),那么,主线程和 bgsave 子进程相互不影响。但是,如果主线程要修改一块数据(例如图中的键值对 C),那么,这块数据就会被复制一份,生成该数据的副本(键值对 C’)。然后,主线程在这个数据副本上进行修改。同时,bgsave 子进程可以继续把原来的数据(键值对 C)写入 RDB 文件。

写时复制机制保证快照期间数据可修改

这既保证了快照的完整性,也允许主线程同时对数据进行修改,避免了对正常业务的影响。

快照的频率如何把握

对于快照来说,所谓“连拍”就是指连续地做快照。这样一来,快照的间隔时间变得很短,即使某一时刻发生宕机了,因为上一时刻快照刚执行,丢失的数据也不会太多。但是,这其中的快照间隔时间就很关键了。如下图:

为了尽可能保证在宕机的情况下,保证数据尽量不丢失,比如:一秒一次快照,那丢失的数据也是一秒。这看上去很美好,其实为带来很大的问题,如果频繁地执行全量快照,也会带来两方面的开销

  • 一方面,频繁将全量数据写入磁盘,会给磁盘带来很大压力,多个快照竞争有限的磁盘带宽,前一个快照还没有做完,后一个又开始做了,容易造成恶性循环。
  • 另一方面,bgsave 子进程需要通过 fork 操作从主线程创建出来。虽然,子进程在创建后不会再阻塞主线程,但是,fork 这个创建过程本身会阻塞主线程,而且主线程的内存越大,阻塞时间越长。如果频繁 fork 出 bgsave 子进程,这就会频繁阻塞主线程了

那这个频率怎么控制呢?这需要根据业务自身的情况,决定快照的频率。比如笔者:我们目前的使用的策略是,关闭系统的自动快照功能,就是 设置 save “” , 定时凌晨连接redis,手动执行bgsave,进行快照生成。可能有人说,如果执行这样的策略,数据丢失就是一天的,对,你说的对,但是我们的业务丢失一天的数据也没关系,这是业务能容忍的 ,在生产的情况下,redis的稳定性相当高,基本上不会宕机,出现宕机的情况,也是因为服务器自身的问题,导致机器重启,redis产生数据丢失。

优缺点

优点

  • RDB文件是某个时间节点的快照,默认使用LZF算法进行压缩,压缩后的文件体积远远小于内存大小,适用于备份、全量复制等场景;
  • Redis加载RDB文件恢复数据要远远快于AOF方式;

缺点

  • RDB方式实时性不够,无法做到秒级的持久化;
  • 每次调用bgsave都需要fork子进程,fork子进程属于重量级操作,频繁执行成本较高;
  • RDB文件是二进制的,没有可读性,AOF文件在了解其结构的情况下可以手动修改或者补全;

总结:rdb数据恢复速度非常快,就是无法做到秒级的持久化

那有其他方式做到秒级的持久化吗?Aof

AOF

AOF 持久性记录服务器接收到的每个写操作。然后可以在服务器启动时再次重播这些操作,从而重建原始数据集。命令使用与 Redis 协议本身相同的格式进行记录

Redis 是先执行命令,把数据写入内存,然后才记录日志

AOF日志内容

我们以 Redis 收到“set testkey 1”命令后记录的日志为例,看看 AOF 日志的内容,

日志格式说明
*3表示当前命令有三个部分,每部分都是由$+数字开头,后面紧跟着具体的命令、键或值。这里,数字表示这部分中的命令、键或值一共有多少字节。例如,$3 set表示这部分有 3 个字节,也就是set命令

redis.conf中配置AOF

默认情况下,Redis是没有开启AOF的,可以通过配置redis.conf文件来开启AOF持久化,关于AOF的配置如下:

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
yaml复制代码# appendonly参数开启AOF持久化
appendonly no

# AOF持久化的文件名,默认是appendonly.aof
appendfilename "appendonly.aof"

# AOF文件的保存位置和RDB文件的位置相同,都是通过dir参数设置的
dir ./

# 同步策略
# appendfsync always
appendfsync everysec
# appendfsync no

# aof重写期间是否同步
no-appendfsync-on-rewrite no

# 重写触发配置
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb

# 加载aof出错如何处理
aof-load-truncated yes

# 文件重写策略
aof-rewrite-incremental-fsync yes

写回策略

AOF 机制给我们提供了三个选择,也就是 AOF 配置项 appendfsync 的三个可选值。

  • Always,同步写回:每个写命令执行完,立马同步地将日志写回磁盘;
  • Everysec,每秒写回:每个写命令执行完,只是先把日志写到 AOF 文件的内存缓冲区,每隔一秒把缓冲区中的内容写入磁盘;
  • No,操作系统控制的写回:每个写命令执行完,只是先把日志写到 AOF 文件的内存缓冲区,由操作系统决定何时将缓冲区内容写回磁盘。

针对避免主线程阻塞和减少数据丢失问题,这三种写回策略都无法做到两全其美。我们来分析下其中的原因。

  • “同步写回”可以做到基本不丢数据,但是它在每一个写命令后都有一个慢速的落盘操作,不可避免地会影响主线程性能;
  • 虽然“操作系统控制的写回”在写完缓冲区后,就可以继续执行后续的命令,但是落盘的时机已经不在 Redis 手中了,只要 AOF 记录没有写回磁盘,一旦宕机对应的数据就丢失了;
  • “每秒写回”采用一秒写回一次的频率,避免了“同步写回”的性能开销,虽然减少了对系统性能的影响,但是如果发生宕机,上一秒内未落盘的命令操作仍然会丢失。所以,这只能算是,在避免影响主线程性能和避免数据丢失两者间取了个折中。

我把这三种策略的写回时机,以及优缺点汇总在了一张表格里,以方便你随时查看。

根据系统对高性能和高可靠性的要求,来选择使用哪种写回策略了。总结一下就是:

  • 想要获得高性能,就选择 No 策略;
  • 如果想要得到高可靠性保证,就选择 Always 策略;
  • 如果允许数据有一点丢失,又希望性能别受太大影响的话,那么就选择 Everysec 策略。

虽然AOF策略,能保证秒级数据丢失,但是随着redis的长时间运行,aof文件会越来越大,如果宕机,进行数据恢复的时候速度是特别慢,影响业务,那有什么好的发案处理吗?aof日志重写

AOF日志重写

AOF 文件是以追加的方式,逐一记录接收到的写命令的。当一个键值对被多条写命令反复修改时,AOF 文件会记录相应的多条命令。但是,在重写的时候,是根据这个键值对当前的最新状态,为它生成对应的写入命令。这样一来,一个键值对在重写日志中只用一条命令就行了,而且,在日志恢复时,只用执行这条命令,就可以直接完成这个键值对的写入了。

重写机制具有“多变一”功能。所谓的“多变一”,也就是说,旧日志文件中的多条命令,在重写后的新日志中变成了一条命令,例如:

我们对列表先后做了 6 次修改操作后,列表的最后状态是[“D”, “C”, “N”],此时,只用 LPUSH u:list “N”, “C”, “D”这一条命令就能实现该数据的恢复,这就节省了五条命令的空间。对于被修改过成百上千次的键值对来说,重写能节省的空间当然就更大了。

不过,虽然 AOF 重写后,日志文件会缩小,但是,要把整个数据库的最新数据的操作日志都写回磁盘,仍然是一个非常耗时的过程。那这个过程,会阻塞主线程吗

AOF重写会阻塞吗

AOF重写过程是由后台进程bgrewriteaof来完成的。主线程fork出后台的bgrewriteaof子进程,fork会把主线程的内存拷贝一份给bgrewriteaof子进程,这里面就包含了数据库的最新数据。然后,bgrewriteaof子进程就可以在不影响主线程的情况下,逐一把拷贝的数据写成操作,记入重写日志。

优缺点

优点

数据能做到秒级丢失,也就是说使用了aof这种机制,能做到最多丢失一秒的数据

缺点

恢复数据比较慢,虽然aof日志重写,可以减小文件,但是速度还是很慢

那有没有一种机制,能做到秒级丢失,恢复速度又比较快呢?RDB和AOF混合方式

RDB和AOF混合方式

Redis 4.0 中提出了一个混合使用 AOF 日志和内存快照的方法。简单来说,内存快照以一定的频率执行,在两次快照之间,使用 AOF 日志记录这期间的所有命令操作。

这样一来,快照不用很频繁地执行,这就避免了频繁 fork 对主线程的影响。而且,AOF 日志也只用记录两次快照间的操作,也就是说,不需要记录所有操作了,因此,就不会出现文件过大的情况了,也可以避免重写开销。

如下图所示,T1 和 T2 时刻的修改,用 AOF 日志记录,等到第二次做全量快照时,就可以清空 AOF 日志,因为此时的修改都已经记录到快照中了,恢复时就不再用日志了。

内存快照和AOF混合使用

这个方法既能享受到 RDB 文件快速恢复的好处,又能享受到 AOF 只记录操作命令的简单优势,颇有点“鱼和熊掌可以兼得”的感觉,建议你在实践中用起来。

总结

Rdb、Aof两种持久化机制各有优缺点,需要根据自己的实际业务来衡量,到底使用哪种机制,最能满足当下业务,我的建议

  • 数据不能丢失时,内存快照和 AOF 的混合使用是一个很好的选择;
  • 如果允许分钟级别的数据丢失,可以只使用 RDB;
  • 如果只用 AOF,优先使用 everysec 的配置选项,因为它在可靠性和性能之间取了一个平衡。

本文转载自: 掘金

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

米哈游(原神)首超腾讯,登顶榜首(内含算法原题)

发表于 2024-03-05

米哈游首超腾讯

近日,第三方机构 data.ai 公布 2023 年中国游戏厂商及应用出海收入 30 强。

其中米哈游超越腾讯,首次登顶年度出海收入榜榜首。

国内共有 27 家手游发行商海外营收超 1 亿美元,米哈游和腾讯则是仅有的「海外营收超 10 亿美元」的两家。

米哈游在 2023 大获成功,主要是依靠于其 2023 年 4 月份推出的《崩坏:星穹铁道》。

该游戏位于 2023 年度手游榜单中的第三名,属于米哈游登顶收入总榜的核心因素。

…

回归主线。

来做一道「米哈游」相关的面试原题。

题目描述

平台:LeetCode

题号:481

神奇字符串 s 仅由 '1' 和 '2' 组成,并需要遵守下面的规则:

  • 神奇字符串 s 的神奇之处在于,串联字符串中 '1' 和 '2' 的连续出现次数可以生成该字符串。

s 的前几个元素是 s = "1221121221221121122……" 。如果将 s 中连续的若干 1 和 2 进行分组,可以得到 "1 22 11 2 1 22 1 22 11 2 11 22 ......" 。

每组中 1 或者 2 的出现次数分别是 "1 2 2 1 1 2 1 2 2 1 2 2 ......" 。上面的出现次数正是 s 自身。

给你一个整数 n ,返回在神奇字符串 s 的前 n 个数字中 1 的数目。

示例 1:

1
2
3
4
5
ini复制代码输入:n = 6

输出:3

解释:神奇字符串 s 的前 6 个元素是 “122112”,它包含三个 1,因此返回 3 。

示例 2:

1
2
3
ini复制代码输入:n = 1

输出:1

提示:

  • 1<=n<=1051 <= n <= 10^51<=n<=105

双指针 + 构造 + 打表

我们将相关的字符串分为三类:题目描述的神奇字符串 s 称为“原串”,对 s 进行连续段划分所得的串叫“划分串”,对划分串进行计数的串叫“计数串”。

解题的核心思路:由于划分串是对原串的划分,同时计数串又与原串相同,因此可得三类串均只有 1 和 2 两种数值。即可知划分串的每段长度只能是「长度为 1」或「长度为 2」,利用划分串的每段构造长度有限,我们可以通过「简单分情况讨论」的方式进行构造。

具体的,我们需要利用「原串和计数串的相同的性质」对 s 进行构造:不难发现计数串总是不长于原串,因此我们可以使用变量 i 来记录当前构造到原串位置,使用变量 j 来记录计数串对应到的实际位置。

不失一般性假设当前构造到 s 中的某一位为 last,而计数串对应的实际位置为 t,由于两者均只有 1 和 2 两种可能,我们可以对其进行简单的分情况讨论(可见代码注释)。

一些细节:由于神奇字符串起始字符固定,构造逻辑固定,因此神奇字符串唯一固定。
我们可以采取 static 代码块的方式进行打表预处理(Java 中的 static 代码块只会在类加载的过程执行一次,而 LC 的测评机制是实例化多个 Solution 对象来跑多个样例,但 Solution 类仍只会被加载一次,即 static 在多个样例测评中只会被执行一次。

Java 代码:

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
Java复制代码class Solution {
static int N = 100010;
static int[] f = new int[N];
static {
StringBuilder sb = new StringBuilder();
sb.append("01"); // 首位多加一个 0 作为哨兵
for (int i = 1, j = 1, cnt = 0; i < N; j++) {
int last = sb.charAt(sb.length() - 1) - '0', t = sb.charAt(j) - '0';
if (last == 1) {
if (t == 1) {
// 当原串当前字符是 1,而计数串当前字符为 1
// 往后构造形成的原串只能是 12,原串指针后移一位
sb.append("2");
f[i] = ++cnt; i++;
} else {
// 当原串当前字符是 1,而计数串当前字符为 2
// 往后构造形成的原串只能是 112,此时同步更新 f[i + 1],原串指针后移两位
sb.append("12");
f[i] = ++cnt; f[i + 1] = ++cnt; i += 2;
}
} else {
if (t == 1) {
// 当原串当前字符是 2,而计数串当前字符为 1
// 往后构造形成的原串只能是 21,原串指针后移一位
sb.append("1");
f[i] = cnt; i++;
} else {
// 当原串当前字符是 2,而计数串当前字符为 2
// 往后构造形成的原串只能是 221,原串指针后移两位
sb.append("21");
f[i] = f[i + 1] = cnt; i += 2;
}
}
}
}
public int magicalString(int n) {
return f[n];
}
}

C++ 代码:

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
C++复制代码class Solution {
public:
static const int N = 100010;
static vector<int> f;
Solution() {
if(!f.empty()) return;
f.resize(N);
string sb = "01"; // 首位多加一个 0 作为哨兵
for (int i = 1, j = 1, cnt = 0; i < N; j++) {
int last = sb[sb.size() - 1] - '0', t = sb[j] - '0';
if (last == 1) {
if (t == 1) {
sb += '2';
f[i++] = ++cnt;
} else {
sb += "12";
f[i++] = ++cnt; f[i++] = ++cnt;
}
} else {
if (t == 1) {
sb += '1';
f[i++] = cnt;
} else {
sb += "21";
f[i++] = f[i++] = cnt;
}
}
}
}
int magicalString(int n) {
return f[n];
}
};
vector<int> Solution::f = {};

Python 代码:

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
Python复制代码class Solution:
def magicalString(self, n: int) -> int:
ss = '01' # 首位多加一个 0 作为哨兵
i, j, cnt = 1, 1, 0
f = [0] * (n + 10)
while i <= n:
last, t = ss[i], ss[j]
if last == '1':
if t == '1':
# 当原串当前字符是 1,而计数串当前字符为 1
# 往后构造形成的原串只能是 12,原串指针后移一位
ss += '2'
f[i], cnt, i = cnt + 1, cnt + 1, i + 1
else:
# 当原串当前字符是 1,而计数串当前字符为 2
# 往后构造形成的原串只能是 112,此时同步更新 f[i + 1],原串指针后移两位
ss += '12'
f[i], f[i + 1], cnt, i = cnt + 1, cnt + 2, cnt + 2, i + 2
else:
if t == '1':
# 当原串当前字符是 2,而计数串当前字符为 1
# 往后构造形成的原串只能是 21,原串指针后移一位
ss += '1'
f[i], i = cnt, i + 1
else:
# 当原串当前字符是 2,而计数串当前字符为 2
# 往后构造形成的原串只能是 221,原串指针后移两位
ss += '21'
f[i], f[i + 1], i = cnt, cnt, i + 2
j += 1
return f[n]

TypeScript 代码:

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
TypeScript复制代码function magicalString(n: number): number {
let str = '01' // 首位多加一个 0 作为哨兵
const f = new Array<number>(n + 10).fill(0)
for (let i = 1, j = 1, cnt = 0; i <= n; j++) {
const last = str[str.length - 1], t = str[j]
if (last == '1') {
if (t == '1') {
// 当原串当前字符是 1,而计数串当前字符为 1
// 往后构造形成的原串只能是 12,原串指针后移一位
str += '2'
f[i] = ++cnt; i++
} else {
// 当原串当前字符是 1,而计数串当前字符为 2
// 往后构造形成的原串只能是 112,此时同步更新 f[i + 1],原串指针后移两位
str += '12'
f[i] = ++cnt; f[i + 1] = ++cnt; i += 2
}
} else {
if (t == '1') {
// 当原串当前字符是 2,而计数串当前字符为 1
// 往后构造形成的原串只能是 21,原串指针后移一位
str += '1'
f[i] = cnt; i++
} else {
// 当原串当前字符是 2,而计数串当前字符为 2
// 往后构造形成的原串只能是 221,原串指针后移两位
str += '21'
f[i] = f[i + 1] = cnt; i += 2
}
}
}
return f[n]
}
  • 时间复杂度:O(n)O(n)O(n),若将 static 打表逻辑放到本地进行,能够减少构造的计算量,但仍会有创建答案数组的 O(n)O(n)O(n) 开销,因此为均摊 O(1)O(1)O(1)
  • 空间复杂度:O(n)O(n)O(n)

我是宫水三叶,每天都会分享算法知识,并和大家聊聊近期的所见所闻。

欢迎关注,明天见。

更多更全更热门的「笔试/面试」相关资料可访问排版精美的 合集新基地 🎉🎉

本文转载自: 掘金

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

当说到云原生时,我们究竟在谈论什么?

发表于 2024-03-05

前言

近年来微服务与云原生、CI/CD这些概念被炒得很火,多数应用都会说自己利用了“云”资源,是一个云原生架构的应用。那么云原生到底是什么呢,或者说使用了什么样的技术的应用才能被称之为云原生应用呢?

云计算的兴起

首先云原生是和云计算分不开的,这里的云计算更多的指的是一种云上的资源,而云出现之前,市场还处在一个物理机时代,如果要启用一个新的应用,就得自己搭一台新的服务器,一直到2001年VM出现了,通过虚拟机可以在一台服务器上跑多个虚拟机来减少服务器的数量(同时也是减少了企业堆机子的钱)

而在虚拟化技术成熟之后,云计算才正式出现,第一个搞云的公司是AWS,在2008年证明了云计算是可行业务

云计算出租的模式有三种

IaaS:Infrastruture As A Service(基础设施及服务),是云服务的最底层,主要提供一些基础资源,比如服务器、存储和网络。我们常规用的云服务器就属于IaaS

PaaS:Platfrom as a Service (平台即服务),提供商为企业搭建网络基础设施及软件、硬件平台,将软件开发、管理、部署都交给第三方,开箱即用。除了底层的服务器之后,通常还具备相应的功能接口,比如阿里云可以给视频自动转码,给视频加速提高清晰度等、图床等等

SaaS:Software as a Service(软件即服务),开发者只需要关注自己的业务逻辑,不需要关心底层。使用这一层的云计算资源甚至不需要开发人员,供应商直接提供一站式服务。

Maas: Modle as a Service(模型即服务),这是大语言模型出现之后提出的一个概念,即你只需要创建对应的模型,大语言模型来替你实现,目前还只是个概念

火热的微服务

在我们开发一个应用服务时,有很多种架构供我们选择,而这些架构按照历史的时间线来看的话,其实能发现它们是在不断向低耦合,高性能,敏捷开发、快速发布、高度自治化这几个方面不断靠拢的

单体应用

单体应用通常是一个庞大的文件,部署在一台机器上,高耦合,对功能修改通常需要牵一发而动全身,想要增强单体应用的性能一般只能堆机子,不断增加服务器的数量

分层架构

最典型的就是MVC和MSC架构,分别是 model view controller和model service controller的缩写,随着时代的发展,人们发现单体应用很难抗住大流量,于是分层架构诞生了。分层架构就是将单体应用进行垂直分层,耦合依旧大,项目之间的接口大多数为数据同步,是一种很简单的架构。开发快速,经过垂直拆分之后项目不至于太大,每一层可以使用不同的技术。

SOA面向服务架构

当垂直架构的应用越来越多,就会出现多个应用都依赖的业务组件,比如数据库,而且各个应用交互越来越频繁,此时就需要把部分通用的组件拆分独立处理,于是SOA面向服务架构诞生了,它带来了模块化开发、分布式拓展部署和服务接口定义等概念。

SOA需要建立企业服务总线,外部应用通过总线调用服务,有以下特征:可从企业外部访问、随时可用、标准化的服务接口等。一般是大型企业才考虑使用SOA架构。

当业务总线崩掉,所有的服务都会挂掉。可以说业务总线的吞吐量决定着整个系统的上限。

微服务架构

在吸取了SOA的思想之后,微服务诞生了,它具有以下特点

  1. 服务层完全独立 并将服务层抽离为一个个的微服务
  2. 遵循单一原则
  3. 使用RESTful等轻量协议进行通信
  4. 一般使用容器技术进行部署 运行在自己的独立进程中

架构如图:

在微服务架构下服务的拆分粒度更细小,有利于资源的重复利用,提高开发效率,采用去中心化思想,更加轻量级

缺点:如果服务实例过多,那么治理成本就会很大,不利于维护;且服务之间相互依赖,可能形成复杂的依赖链条,往往单个服务异常,其他的服务也会受到影响

在实际开发中一般通过DDD的思想对微服务进行拆分,既不能让服务实例太多(不好治理、部署成本大、调用关系复杂),也不能让服务实例太少(每个服务量级都很大,高度耦合),一般是通过各个服务的作用域进行划分。

微服务与SOA的区别 :

微服务继承了SOA的众多优点和理念

SOA更适合与许多其他应用程序集成的大型复杂企业应用程序环境,小型的应用并不适合SOA。

微服务则更适合于较小和良好的分割式web业务系统,在微服务架构中没有SOA中的ESB进行集中化管理,而是通过轻量级通信机制相互沟通。

SOA尝试采用中心化管理来确保每个应用之间能够协同运作,而微服务则尝试部署新功能。快速有效的拓展开发团队,注重于分散管理、代码再利用与自动化执行

主流的微服务框架

Java:Spring Cloud 和Dubbo

Go:Go-kit与Go-Micro

Go-Kit:

是一个Go语言工具包的集合,提供了实现系统监控和弹性模式组件的库,比如日志记录、跟踪、限流、熔断等。

基于Go-Kit的应用程序架构由三个主要部分组成:传输层、接口层和服务层

传输层:网络通信,通常使用HTTP或者grpc等网络传输方式,Go-Kit还支持使用AMQP(提供同一消息维护的应用层协议)和Thift(接口描述语言与二进制通讯协议)等多种网络通信模式

接口层:服务器和客户端的基本构建块,在Go-kit中的每个对外提供的接口方法都会被定义成一个端点,以便在服务器和客户端之间进行网络通信

服务层:具体的业务逻辑实现,业务逻辑包括核心业务逻辑

Go-Micro

基于Go实现的插件化RPC微服务框架,提供了服务发现、负载均衡、同步传输、异步通信以及事件驱动等机制,尝试弱化分布式系统间的通信,让开发者可以专注与自身业务逻辑的开发

设计哲学:可插拔式的架构理念,提供可快速构建微服务系统的组件

Go-Micro组件

  • registy :服务发现组件 解析服务name至服务地址 支持consul etcd zookeeper dns和gossip等组件
  • selector: 基于registry的客户端负载均衡组件 client使用selector组件从registry返回的服务列表中进行负载均衡选择
  • broker: 发布和订阅组件 服务之间基于消息中间件的异步通信方式 一般使用MQ 比如kafka rabbitMQ等等
  • transport: 服务之间同步通信方式
  • codec: 服务之间消息的编码和解码
  • server: 服务主体 该组件基于上面的registry selector transport和broker组件,对外提供一个同一的服务请求入口
  • client:提供微服务的客户端

微服务六大准则

1.高内聚 低耦合 每个服务是针对于一个单一职责业务能力的封装 服务之间通过轻量级的通信方式进行通信

2.高度自治 每个服务能够独立部署并运行在独立的进程内 技术选型灵活 合适的业务问题可以选择合适的技术栈 服务与服务之间采取与语言无关的网络通信进行交互 也就是说服务之间可以用不同的语言、不同的版本、不同的工具、在不同的系统上运行

3.以业务为中心 每个服务代表了特定的业务逻辑 有明显的边界上下文

4.弹性设计 可容错、具有自我保护能力的系统,服务之间相互隔离,限制使用资源,防止级联的服务雪崩错误

5.日志与监控 必须用分布式日志系统进行日志的管理 不然每个微服务独自具有各自的日志 查找起来会很麻烦 监控各个服务的性能 比如Prometheus,进行资源的弹性化扩张与收缩

6.自动化(drone)

云原生技术及十二因素

现在对于云原生的定义通常是:有利于各组织在公有云、私有云与混合云等新型动态环境中,构建和运行可弹性拓展的应用,代表技术有容器、服务网格、微服务、不可变基础设施即声明式API

云原生的基础架构

  • 微服务:每个服务被独立部署,服务之间松耦合,这样就可以独立对每个服务进行升级、部署、拓展和重启,具有降低系统复杂度。独立部署,独立扩展和跨语言编程等优点。同时对于运维来说,难度提升,并且整个分布式系统变得更复杂,需要大量的测试机部署。还需要考虑网络延迟、容错性、消息序列化和不可靠网络等等
  • 容器:轻量级的虚拟化技术,能够在单一主机上提供多个隔离的操作系统环境,通过一系列的namespace进行进程隔离,容器分为运行时和编排两层,运行时负责容器的计算、存储、网络等,编排层负责容器集群的调度,服务发现和资源管理。另外,仅仅有容器还是不够,这样的话运维部署成本太大,为了解决容器的管理和调度问题,又引入了Kubernetes(在1.20版本Kubernetes正式抛弃docker),可以实现容器化的自动化部署、自动化扩容缩容和维护等功能
  • 服务网格(service mesh)主要有侵入式架构和非侵入式架构,侵入式指的是服务框架嵌入程序代码,开发者组合各种组件,如RPC、负载均衡。熔断等,实现微服务架构。非侵入式主要是以代理的形式和应用程序部署在一起,开发者只需要关注自身业务即可。service mesh使得系统架构的技术栈下移,解耦了应用程序的监控、追踪和服务发现。相关软件包括istio linkerd等,同时为了让service mesh有更好的底层支撑,我们又将servise mesh运行在kubernetes上
  • DevOps:包含了开发、测试和运维第三个部分,由一个团队负责,不断的更新迭代,推进产品的开发进程,可以实现快速开发与快速部署
  • 声明式API:Kubernetes的能力都是通过各种API来提供的,所谓的声明式API,就是会编写对应的API对象的YAML文件交给Kubernetes,而不是直接使用一些命令来操作API,这个YAML文件其实就是一种声明,而一个个提交命令,则是命令式API。通常声明式API具有以下特点:1.包含相对少量的相对较小的对象。2.这些对象定义应用程序或者基础结构的配置。3.对象相对更新不频繁
  • 不可变基础设施:在传统的可变基础设施(服务器)中,服务器会不断的更新和修改,这类设施的管理员可以手动升级或者降级软件包版本,调整服务器的配置,这样的服务器我们认为是可变的。他们可以在创建后修改。不可变基础设施就是另一种基础设施模式,其中的服务器在部署之后永远不会修改,如果需要以任何方式更新、修复或者修改默写内容,需要先对公共镜像进行修改,然后利用镜像构建新服务器来替换旧服务器,验证之后新的服务器会投入使用,旧的服务器会被下掉。不可变基础设施能提供更高的一致性和可靠性,更简单、更可预测的部署过程。二者被比作宠物和牛,过去我们将服务器当做宠物,如果倒下了那么服务就无法运行,而在新的方式中,服务器被编号,就像一群牛

云原生十二因素

云原生的12因素是知道开发者如何利用云平台来构建更具可靠性和扩展性,更易维护的云原生应用

1.CodeBase:基础代码,一份基准代码,多份部署,用一个代码库进行版本控制和应用程序的多次部署

2.Dependencies:显示声明依赖关系,应用程序通过适当的工具隔离依赖性

3.Config:配置文件,在环境中存储配置

4.Backing Services:后端服务,把后端服务当做附加资源(很珍贵的资源),数据库、消息队列、换出系统都被当做附加资源在不同环境中被同等的调用。听说现在很多大厂都不会直接操作数据库了,咱也不知道具体是怎么实现的,数据存在哪里。

5.Build release run:构建、发布、运行 严格分离架构和运行

6.Processes:以一个或多个无状态进程运行应用,任何需要持久化的数据都被存储在后端服务中

7.Port binding:端口绑定,通过端口绑定提供服务

8.Concurrency:并发,通过进程模型进行拓展

9.Disposability:易处理、快速启动和优雅终止

10.Dev/pro parity:开发环境与线上环境等价,尽可能保持开发、预发布和线上环境的相似性

11.Logs:将日志当做事件流,允许执行环节通过集中式服务来收集、聚合和检索分析日志

12.Admin processes:管理进程,后台管理任务当做一次性进程运行

12因素已经提出很久了,有部分因素已经无法跟上时代的发展,只能作为参考

结语

现如今的技术更迭是非常非常快的,很多东西作为学生你刚听说,觉得很新颖想去学,可能企业已经用过很久,甚至业界已经有更优的方案,把这个技术抛弃了。也有部分后端开发者自嘲道,这个东西我还没学,人家就不用了。因此,我建议大家不管什么技术,你如果感兴趣就先上手,对着官方文档看,其实任何高级、难的技术如果仅仅只是上手的话往往花不了太多时间。

本文介绍了关于云原生和微服务的演变历史,相信大家有所收获。

创作不易,如果有收获欢迎点赞、评论、收藏,您的支持就是我最大的动力。

本文转载自: 掘金

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

开发札记:基于Sa-Token构建权限系统实战 Maven依

发表于 2024-03-05

Sa-Token是一个Java权限认证框架,配置很简洁,使用方便。本文主要分享如何使用Sa-Token整合JWT实现登录鉴权和权限授权,数据持久层采用的是Redis缓存,同时本文会分析Sa-Token的相关源码。

Maven依赖和yml配置

首先引入Sa-Token的两个依赖。

1
2
3
4
5
6
7
8
9
10
xml复制代码<!-- Sa-Token 权限认证, 在线文档:http://sa-token.dev33.cn/ -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot-starter</artifactId>
</dependency>
<!-- Sa-Token 整合 jwt -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-jwt</artifactId>
</dependency>

在application.yml可以进行配置,常见配置如token名字、token有效期、是否允许并发登录、token前缀、jwt密钥等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
yml复制代码# Sa-Token配置
sa-token:
# token名称 (同时也是cookie名称)
token-name: Authorization
# token有效期 设为一天 (必定过期) 单位: 秒
timeout: 86400
# token最低活跃时间 (指定时间无操作就过期) 单位: 秒
active-timeout: 1800
# 允许动态设置 token 有效期
dynamic-active-timeout: true
# 是否允许同一账号并发登录 (为true时允许一起登录, 为false时新登录挤掉旧登录)
is-concurrent: true
# 在多人登录同一账号时,是否共用一个token (为true时所有登录共用一个token, 为false时每次登录新建一个token)
is-share: false
# 是否尝试从header里读取token
is-read-header: true
# 是否尝试从cookie里读取token
is-read-cookie: false
# token前缀
token-prefix: "Bearer"
# jwt秘钥
jwt-secret-key: abcdefghijklmnopqrstuvwxyz

自定义配置类:SaTokenConfig

作为一个权限认证框架,肯定是要实现拦截器的功能的。因此我们的配置类需要实现WebMvcConfigurer,用于添加拦截器。

在配置类中,我们做四件事情,分别是:添加拦截器SaInterceptor、注入StpLogicJwtForSimple实现JWT模式、注入权限接口实现SaPermissionImpl,注入使用Redis实现的自定义DAO层。

添加拦截器

重写void addInterceptors(InterceptorRegistry registry),添加拦截器SaInterceptor。

逻辑大致如下:

  1. 通过AllUrlHandler,可以拿到所有url路径。
  2. 使用SaRouter路由匹配操作工具类,调用match传入拦截的URL列表。
  3. 链式调用check,使用Sa-Token自带的权限认证工具类StpUtil进行校验登录。
  4. 拦截器调用excludePathPatterns放行一些静态资源等排除路径。

PS:我们自定义一个SecurityProperties,内部包含一个字符串数组,用于配置排除路径。

小插一嘴:这个AllUrlHandler参考自开源项目RuoYi-Vue-Plus,它的大致原理是通过实现InitializingBean接口,重写afterPropertiesSet方法,这个是Spring提供的扩展点,在Bean属性设置后执行,Spring MVC中的RequestMappingHandlerMapping就实现了InitializingBean接口,在afterPropertiesSet中完成了一些初始化工作,比如url和controller方法的映射。

这里AllUrlHandler就是从容器拿到RequestMappingHandlerMapping,遍历内部的RequestMappingInfo,拿到pattern并添加到集合中返回。需要稍微了解Spring Bean的生命周期,还是挺有意思的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
java复制代码    /**
* 注册sa-token的拦截器
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 注册路由拦截器,自定义验证规则
registry.addInterceptor(new SaInterceptor(handler -> {
AllUrlHandler allUrlHandler = SpringUtils.getBean(AllUrlHandler.class);
// 登录验证 -- 排除多个路径
SaRouter
// 获取所有的
.match(allUrlHandler.getUrls())
// 对未排除的路径进行检查
.check(() -> {
// 检查是否登录 是否有token
StpUtil.checkLogin();
});
})).addPathPatterns("/**")
// 排除不需要拦截的路径
.excludePathPatterns(securityProperties.getExcludes());
}

注入几个Bean实现DIY

  • 注入:StpLogicJwtForSimple,整合JWT。
  • 注入:SaPermissionImpl,实现权限管理。
  • 注入:RedisSaTokenDao,自定义DAO层存储,整合Redis。

自定义DAO层:基于Redis

SaTokenDao是Sa-Token 持久层接口,sa-token本身封装了基于内存的默认实现,因为不满足持久化的需求所以不适用。

因此,这里采用自定义持久层的方式来实现,具体实现的话,只需要实现SaTokenDao接口,重写一系列set和get方法即可。这里我选择的实现方式为基于Redission客户端封装的RedisUtils,RedisUtils是RuoYi-Vue-Plus封装的工具类,基于jackson实现序列化,覆盖了大部分Redis的使用场景。

Sa-Token如何存储token值?

这里就要介绍Sa-Token内部的几个类:

  • SaHolder:上下文持有类,用于快速获取SaRequest、SaResponse、SaStorage。
  • SaTokenContext:上下文。可以共享部分数据。
  • SaStorage:在一次请求的作用域内读写值,可以在不同方法间隐式传参

Sa-Token在登陆后将token存储在Storage和Dao层,采用的存储策略是多级缓存。

功能实现:登录验证

登录方法

SaLoginModel类:SaToken的登陆模型,决定登录的一些细节行为,包含设备信息、usedId等。

现在,我们从Controller层开始,解析login方法的执行流程。

Controller层:拿到username和password,调用service层的login,将token包装返回。

Service层:

  1. 根据用户名,查数据库拿到SysUser的对象。
  2. 调用checkLogin检查密码合法性。
  3. 如果没有抛出异常,构建LoginUser登录对象。
  4. 将用户信息存储入Redis和上下文,执行StpUtils.login方法。
  5. 采用异步+线程池方式记录日志。

这里我们注重关注第二步和第四步。

checkLogin(LoginType loginType, String username, Supplier supplier)

此方法记录用户失败重试次数,调用传入的supplier进行密码校验,一般来讲supplier传入BCrypt.checkpw(password, user.getPassword())比对密码和数据库内密码。每次错误都会将错误次数存入Redis,达到指定次数会直接抛出异常。

LoginUser是登录用户,内含用户基本信息、权限信息、菜单信息等。

如果执行到构建LoginUser,已经验证成功了,下一步就是如何生成token并将用户信息存储到Redis。

  1. 存储loginUser、userId到SaStorage。
  2. 构建SaLoginModel,将userId存到SaLoginModel。
  3. 调用StpUtils.login(Object id, SaLoginModel loginModel)
    1. 创建登录会话,使用StpLogicJwtForSimple分配token。
    2. 续期会话,添加token签名并设置到Redis
    3. 在Redis内写入token到loginId的映射关系,方便check的时候查找token合法性。
    4. 发布事件:登陆成功。用于监听后记录日志、实现在线用户功能等,实现切面操作。
    5. 存储:将TokenValue写入到Storage、Header。
  4. 调用StpUtil.getTokenSession().set(LOGIN_USER_KEY, loginUser);,将LoginUser写入SaSession缓存。

至此,登录方法分析完毕,更加深入的内容读者可以自行阅读Sa-Token源码。

SaInterceptor拦截器

checkLogin()方法

我们前面已经在SaTokenConfig配置了请求拦截器,获取所有URL并且调用checkLogin方法,现在我们深入CheckLogin方法的内部。

checkLogin方法底层调用了getLoginId获取登录会话ID,如果找不到就抛出异常。而在这个方法内部调用了getTokenValue方法,底层采用多级缓存的获取方法,先从Storage获取、然后依次从Request、Header获取。如果都获取不到,token就是null,自然无法登录。

值得注意的是,获取Token的方法并没有从DAO层获取,而是从缓存中获取。当从缓冲中得到token后,会调用getLoginIdNotHandle查找此Token对应的loginId,此时调用的是DAO层从Redis中读取,如果获取不到就证明token无效,抛出异常。

总结:调用getTokenValue从缓存拿token,调用getLoginIdNotHandle从Redis查询loginId,检查token是否有效。

SaInterceptor的preHandle方法

使用SaStrategy.instance.isAnnotationPresent.apply(method, SaIgnore.class)判断是否加了SaIgnore注解,如果加了直接返回,否则执行注解鉴权,最后调用我们传入的Lambda表达式进行鉴权拦截。

功能实现:权限功能

权限功能底层都是调用StpInterface的相关API进行获取权限,在这里我们写一个实现类重写所有方法。

主要方法包括:

  • 获取权限列表:直接从LoginUser里面拿,我们在login的时候已经将权限信息设置进入。
  • 获取角色列表

如何获取LoginUser

前面我们已经将LoginUser存入多个缓存,因此可以采用多级缓存的方式进行获取。

我们首先从Storage中拿到loginUser,然后从SaSession获取,底层还是先从多级缓存获取,然后最后从Dao层即Redis获取。

具体鉴权方式1:方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
java复制代码// 获取:当前账号所拥有的权限集合
StpUtil.getPermissionList();
// 判断:当前账号是否含有指定权限, 返回 true 或 false
StpUtil.hasPermission("user.add");
// 校验:当前账号是否含有指定权限, 如果验证未通过,则抛出异常: NotPermissionException
StpUtil.checkPermission("user.add");
// 校验:当前账号是否含有指定权限 [指定多个,必须全部验证通过]
StpUtil.checkPermissionAnd("user.add", "user.delete", "user.get");
// 校验:当前账号是否含有指定权限 [指定多个,只要其一验证通过即可]
StpUtil.checkPermissionOr("user.add", "user.delete", "user.get");
// 获取:当前账号所拥有的角色集合
StpUtil.getRoleList();
// 判断:当前账号是否拥有指定角色, 返回 true 或 false
StpUtil.hasRole("super-admin");
// 校验:当前账号是否含有指定角色标识, 如果验证未通过,则抛出异常: NotRoleException
StpUtil.checkRole("super-admin");
// 校验:当前账号是否含有指定角色标识 [指定多个,必须全部验证通过]
StpUtil.checkRoleAnd("super-admin", "shop-admin");
// 校验:当前账号是否含有指定角色标识 [指定多个,只要其一验证通过即可]
StpUtil.checkRoleOr("super-admin", "shop-admin");

鉴权方式2:注解(常用),以下列出了常用的注解

  • @SaIgnore:不验证
  • @SaCheckPermission(“monitor:logininfor:remove”):验证权限
  • @SaCheckRole(“super-admin”)
  • @SaCheckDisable(“comment”)

参考文档

  1. Sa-Token官方文档
  2. RuoYi-Vue-Plus

本文转载自: 掘金

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

【Android 13源码分析】WMS/AMS 常见方法调用

发表于 2024-03-05

整理分析 WMS/AMS 流程中经常出现的公用代码逻辑。

  1. forAllLeafTasks

以Activity启动流程 TaskDisplayArea::pauseBackTasks 调用为例。
forAllLeafTasks方法定义在WindowContainer类中,TaskDisplayArea是容器,自然也是其子类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
scss复制代码# WindowContainer

// traverseTopToBottom表示从上到下还是从下到上
// 当前案例传递的是true
void forAllLeafTasks(Consumer<Task> callback, boolean traverseTopToBottom) {
final int count = mChildren.size();
if (traverseTopToBottom) {
for (int i = count - 1; i >= 0; --i) {
// 遍历调用每个孩子的forAllLeafTasks
mChildren.get(i).forAllLeafTasks(callback, traverseTopToBottom);
}
} else {
for (int i = 0; i < count; i++) {
mChildren.get(i).forAllLeafTasks(callback, traverseTopToBottom);
}
}
}

TaskDisplayArea的孩子是 Task,从堆栈信息也知道会调用到 Task::forAllLeafTasks

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
ini复制代码# Task
@Override
void forAllLeafTasks(Consumer<Task> callback, boolean traverseTopToBottom) {
final int count = mChildren.size();
// 定义变量是否为 LeafTask
boolean isLeafTask = true;
if (traverseTopToBottom) {
for (int i = count - 1; i >= 0; --i) {
// 遍历所有子容器,如果下面还有Task,则isLeafTask = false,表示不是LeafTask然后继续递归调用
final Task child = mChildren.get(i).asTask();
if (child != null) {
isLeafTask = false;
child.forAllLeafTasks(callback, traverseTopToBottom);
}
}
} else {
for (int i = 0; i < count; i++) {
final Task child = mChildren.get(i).asTask();
if (child != null) {
isLeafTask = false;
child.forAllLeafTasks(callback, traverseTopToBottom);
}
}
}
// 如果当前是LeafTask,则执行回调
if (isLeafTask) callback.accept(this);
}

LeafTask表示是否为叶子Task,根据代码也知道就是下面没有Task了。

小结

综上forAllLeafTasks其实就是执行对当前容器下每个叶子Task执行参数的回调,那如果有多个叶子Task必然是执行多次。

  1. forAllLeafTaskFragments

以Activity启动流程 TaskDisplayArea::pauseBackTasks,参考forAllLeafTasks的调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
ini复制代码# WindowContainer
void forAllLeafTaskFragments(Consumer<TaskFragment> callback, boolean traverseTopToBottom) {
final int count = mChildren.size();
if (traverseTopToBottom) {
for (int i = count - 1; i >= 0; --i) {
mChildren.get(i).forAllLeafTaskFragments(callback, traverseTopToBottom);
}
} else {
for (int i = 0; i < count; i++) {
mChildren.get(i).forAllLeafTaskFragments(callback, traverseTopToBottom);
}
}
}

与forAllLeafTasks一样,也是调用每个子容器的forAllLeafTaskFragments,具体调用到TaskFragment

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
ini复制代码# TaskFragment
@Override
void forAllLeafTaskFragments(Consumer<TaskFragment> callback, boolean traverseTopToBottom) {
final int count = mChildren.size();
// 标记是否为LeafTaskFrag
boolean isLeafTaskFrag = true;
if (traverseTopToBottom) {
for (int i = count - 1; i >= 0; --i) {
// 如果下面没有TaskFragment,那么当前就是 叶子TaskFragment
final TaskFragment child = mChildren.get(i).asTaskFragment();
if (child != null) {
isLeafTaskFrag = false;
child.forAllLeafTaskFragments(callback, traverseTopToBottom);
}
}
} else {
for (int i = 0; i < count; i++) {
final TaskFragment child = mChildren.get(i).asTaskFragment();
if (child != null) {
isLeafTaskFrag = false;
child.forAllLeafTaskFragments(callback, traverseTopToBottom);
}
}
}
if (isLeafTaskFrag) callback.accept(this);
}

小结

综上其实就是执行对当前容器下每个叶子TaskFragment执行参数的回调,那如果有多个叶子TaskFragment必然是执行多次。

  1. Activity生命周期事务

以Activity启动流程为例

ClientTransaction 通用逻辑

ActivityTaskSupervisor::realStartActivityLocked 构建 LaunchActivityItem

ClientLifecycleManager::scheduleTransaction
ClientTransaction::schedule
ActivityThread::scheduleTransaction
ClientTransactionHandler::scheduleTransaction —ActivityThread的父类,发送消息EXECUTE_TRANSACTION
ActivityThread::handleMessage —处理消息EXECUTE_TRANSACTION
TransactionExecutor::execute
TransactionExecutor::executeCallbacks —处理 Callbacks ,比如LaunchActivityItem
ClientTransactionItem::execute – ClientTransactionItem 只是父类,具体看具体传递的对象
ClientTransactionItem::postExecute
TransactionExecutor::executeLifecycleState —处理生命周期状态相关,也就是 ResumeActivityItem 这些

以 Activity启动的调用 ActivityTaskSupervisor::realStartActivityLocked为例

  1. 容器通用逻辑提取

4.1 容器的 forAllRootTasks 流程

起点为WindowContainer.forAllRootTasks

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
arduino复制代码# WindowContainer
// Consumer 接口
void forAllRootTasks(Consumer<Task> callback) {
// 调用重载,第二个参数为true
forAllRootTasks(callback, true /* traverseTopToBottom */);
}
void forAllRootTasks(Consumer<Task> callback, boolean traverseTopToBottom) {
int count = mChildren.size();
if (traverseTopToBottom) {
for (int i = count - 1; i >= 0; --i) {
// 只有Task 重新了该方法
mChildren.get(i).forAllRootTasks(callback, traverseTopToBottom);
}
} else {
......忽略
}
}

// Predicate 接口
boolean forAllRootTasks(Predicate<Task> callback) {
return forAllRootTasks(callback, true /* traverseTopToBottom */);
}

boolean forAllRootTasks(Predicate<Task> callback, boolean traverseTopToBottom) {
int count = mChildren.size();
if (traverseTopToBottom) {
for (int i = count - 1; i >= 0; --i) {
if (mChildren.get(i).forAllRootTasks(callback, traverseTopToBottom)) {
return true;
}
}
} else {
......忽略
}
return false;
}
// 如果是用AS ,或者在源码上搜索,可知只有 Task 重写了forAllRootTasks函数。
// 所以调用子容器的forAllRootTasks ,最后只会调用到Task 类中。 根据打印的堆栈信息也确实如此。
# Task
@Override
void forAllRootTasks(Consumer<Task> callback, boolean traverseTopToBottom) {
if (isRootTask()) {
// 如果当前是 rooTask 则直接执行回调
// 对于Task,第二个参数没有使用,将自己传递给了接口函数
callback.accept(this);
}
}
@Override
boolean forAllRootTasks(Predicate<Task> callback, boolean traverseTopToBottom) {
return isRootTask() ? callback.test(this) : false;
}

其实就是相当于在 Task 这个容器里, 去调用传进来的接口回调。这个接口定义如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
csharp复制代码# Consumer
public interface Consumer<T> {
// 其实就是调用 这个了
void accept(T var1);

default Consumer<T> andThen(Consumer<? super T> after) {
Objects.requireNonNull(after);
return (t) -> {
this.accept(t);
after.accept(t);
};
}
}
# Predicate
public interface Predicate<T> {
boolean test(T var1);
// 忽略其他方法
}

所以这部分的逻辑只需要看调用 forAllRootTasks 时 看传递进来的接口实现类是哪一个,找打对应的的 accept 或者 test函数即可,另外注意的是泛型参数都是 Task

4.2容器的 forAllActivities 流程

这个是处理 Activity 的, 而 Activity 对于的容器 一般就是 ActivityRecord

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
typescript复制代码# WindowContainer
// Consumer 类型
void forAllActivities(Consumer<ActivityRecord> callback) {
// 泛型要求为 ActivityRecord,第二个参数为true
forAllActivities(callback, true /*traverseTopToBottom*/);
}
void forAllActivities(Consumer<ActivityRecord> callback, boolean traverseTopToBottom) {
if (traverseTopToBottom) {
for (int i = mChildren.size() - 1; i >= 0; --i) {
mChildren.get(i).forAllActivities(callback, traverseTopToBottom);
}
} else {
......忽略逻辑
}
}
// Predicate 类型
boolean forAllActivities(Predicate<ActivityRecord> callback) {
// 泛型要求为 ActivityRecord,第二个参数为true
return forAllActivities(callback, true /*traverseTopToBottom*/);
}

boolean forAllActivities(Predicate<ActivityRecord> callback, boolean traverseTopToBottom) {
if (traverseTopToBottom) {
for (int i = mChildren.size() - 1; i >= 0; --i) {
if (mChildren.get(i).forAllActivities(callback, traverseTopToBottom)) return true;
}
} else {
......忽略逻辑
}
return false;
}

目前也只有 ActivityRecord 重写了 forAllActivities

1
2
3
4
5
6
7
8
9
typescript复制代码# ActivityRecord
@Override
void forAllActivities(Consumer<ActivityRecord> callback, boolean traverseTopToBottom) {
callback.accept(this);
}
@Override
boolean forAllActivities(Predicate<ActivityRecord> callback, boolean traverseTopToBottom) {
return callback.test(this);
}

可以看到逻辑与forAllRootTasks类似, 找到对应类型的接口看 对应的 accept或者test方法实现即可,泛型参数都是 ActivityRecord

本文转载自: 掘金

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

这波操作看麻了!十亿行数据,从71s到17s的优化之路。

发表于 2024-03-04

你好呀,我是歪歪。

春节期间关注到了一个关于 Java 方面的比赛,很有意思。由于是开源的,我把项目拉下来试图学(白)习(嫖)别人的做题思路,在这期间一度让我产生了一个自我怀疑:

他们写的 Java 和我会的 Java 是同一个 Java 吗?

不能让我一个人怀疑,所以这篇文章我打算带你盘一下这个比赛,并且试图让你也产生怀疑。

赛题

在 2024 年 1 月 1 日,一个叫做 Gunnar Morling 的帅哥,发了这样一篇文章:

www.morling.dev/blog/one-bi…

文章的标题叫做《The One Billion Row Challenge》,十亿行挑战,简称就是 1BRC,挑战的时间是一月份整个月。

赛题的内容非常简单,你只需要看懂这个文件就行了:

文件的每一行记录的是一个气象站的温度值。气象站和温度分号分隔,温度值只会保留一位小数。

参赛者只需要解析这个文件,然后并计算出每个气象站的最小、最大和平均温度。按照字典序的格式输出就行了:

出题人还配了一个简图:

需求非常明确、简单,对不对?

为了让你彻底明白,我再给你举一个具体的例子。

假设文件中的内容是这样的:

chengdu;12.0

guangzhou;7.2;

chengdu;6.3

beijing;-3.6;

chengdu;23.0

shanghai;9.8;

chengdu;24.3

beijing;17.8;

那么 chengdu (成都)的最低气温是 6.3,最高气温是 24.3,平均气温是(12.0+6.3+23.0+24.3)/4=16.4,就是这么朴实无华的计算方式。

最终结果输出的时候,再注意一下字典序就行。

这有啥好挑战的呢?

难点在于出题人给出的这个文件有 10 亿行数据。

在我的垃圾电脑上,光是跑出题人提供的数据生成的脚本,就跑了 20 分钟:

跑出来之后文件大小都有接近 13G,记事本打都打不开:

所以挑战点就在于“十亿行”数据。

具体的一些规则描述和细节补充,都在 github 上放好了:

github.com/gunnarmorli…

针对这个挑战,出题人还提供了一个基线版本:

github.com/gunnarmorli…

首先封装了一个 MeasurementAggregator 对象,里面放的就是要记录的最小温度、最大温度、总温度和总数。

整个核心代码就二三十行,使用了流式编程:

首先是一行行的读取文本,接着每一行都按照分号进行拆分,取出对应的气象站和温度值。

然后按照气象站维度进行 groupingBy 聚合,并且计算最大值、最小值和平均值。

在计算平均值的时候,为了避免浮点计算,还特意将温度乘 10,转换为 int 类型。

最后用 TreeMap 按字典序输出各个气象站的温度数据。

这个基线版本官方的数据是在跑分环境下,2 分钟内可以运行完毕。

而在我的电脑上跑了接近 14 分钟:

很正常,毕竟人家的测评环境配置都是很高的:

Results are determined by running the program on a Hetzner AX161 dedicated server (32 core AMD EPYC™ 7502P (Zen2), 128 GB RAM).

参加挑战的各路大神,最终拿出的 TOP 10 成绩是这样的:

当时看到这个成绩的瞬间,我人都是麻的,第一个疑问是:我靠,13G 的文件啊?1.5s 内完成了读取、解析、计算的过程?这不可能啊,光是读取 13G 大小的文件,也需要一点时间吧?

但是需要注意的是,歪师傅有这个想法是走入了一个小误区,就是我以为这 13G 的文件一次性加载不完成,怎么快速的从硬盘把文件读取到内存中也是一个考点。

后来发现是我多虑了,人家直接就说了,不用考虑这一点,跑分成绩运行的时候,文件直接就在内存中:

所以,最终的成绩中不包含读取文件的时间。

但是也很牛逼了啊,毕竟有十亿条数据。

第一名

我尝试着看了一下第一名的代码:

github.com/gunnarmorli…

过于硬核,实在是看不懂。我只能通过作者写的一点注释、方法名称、代码提交记录去尝试理解他的代码。

在他的代码开头的部分,有这样的一段描述:

这是他的破题思路,结合了这些信息之后再去看代码,稍微好一点,但是我发现他里面还是有非常多的微操、太多针对性的优化导致代码可读性较差,虽然他的代码加上注释一共也才 400 多行,然而我看还是看不懂。

我随便截个代码片段吧:

问 GPT 这个哥们,他也是能说个大概出来:

所以我放弃了理解第一名的代码,开始去看第二名,发现也是非常的晦涩难懂,再到第三名…

最后,我产生了文章开始时的疑问:他们写的 Java 和我会的 Java 是同一个 Java 吗?

但是有一说一,虽然我看不懂他们的某些操作,但是会发现他们整体的思路都几乎是一致。

虽然我没有看懂第一名的代码,但是我还是专门列出了这一个小节,给你指个路,有兴趣你可以去看看。

另外,获得第一名的老哥,其实是一个巨佬:

是 GraalVM 项目的负责人之一:

巨人肩膀

在官方的 github 项目的最后,有这样的一个部分:

其中最后一篇文章,是一个叫做 Marko Topolnik 的老哥写的。

我看了一下,这个哥们的官方成绩是 2.332 秒,榜单第九名:

但是按照他自己的描述,在比赛结束后他还继续优化了代码,最终可以跑到 1.7s,排名第四。

在他的文章中详细的描述了他的挑战过程和思路。

我就站在巨人的肩膀上,带大家看看这位大佬从 71s 到 1.7s 的破题之道:

questdb.io/blog/billio…

最常规的代码

首先,他给了一个常规实现的代码,和基线版本的代码大同小异,只不过是使用了并行流来处理:

github.com/mtopolnik/b…

平时看到流式编程我是有点头疼的,需要稍微的反应一下,但是在看了前三名的最终代码后再看这个代码,我觉得很亲切。

根据作者的描述,这段代码:

  • 使用并行 Java 流,将所有 CPU 核心都用起来了。
  • 也不会陷入任何已知的性能陷阱,比如 Java 正则表达式

在一台装有 OpenJDK 21.0.2 的 Hetzner CCX33 机器上,跑完需要的时间为 71 秒。

第 0 版优化:换个好的 JVM

叫做第 0 版优化的原因是作者对于代码其实啥也没动,只是换了一个 JVM:

默认使用 GraalVM 之后,最常规的代码,运行时间从 71s 到了 66s,相当于白捡了 5s,我问就你香不香。

同时作者还提到一句话:

When we get deeper into optimizing and bring down the runtime to 2-3 seconds, eliminating the JVM startup provides another 150-200ms in relief. That becomes a big deal.

当我们把程序优化到运行时间只需要 2-3 秒的时候,使用 GraalVM,会消除 JVM 的启动时间,从而提供额外的 150-200ms 的提升。

到那个时候,这个就变得非常重要了。

数据指标很重要

在正式进入优化之前,作者先介绍了他使用到的三个非常重要的工具:

关于工具我就不过多介绍了,这里单独提一嘴主要是想表达一个贯穿整个优化过程的中心思想:数据指标很重要。

你只有收集到了当前程序足够多的运行指标,才能对你进行下一步优化时提供直观的、优化方向上的指导。

工欲善其事必先利其器,就是这个道理。

第一版优化:并行 I/O 搞起来

通过查看当前代码对应的火焰图:

questdb.io/html/blog/p…

通过火焰图以及观察 GC 情况,作者发现当前耗时的地方注意是这三个地方:

  1. BufferedReader 将每行文本输出为字符串
  2. 处理每一行的字符串
  3. 垃圾收集 (GC):使用 VisualGC 可以看到,差不多每秒要 GC 10 次甚至更多。

可以发现 BufferedReader 占用了大量的性能,因为当前读取文件还是一行行读取的嘛,性能很差。

于是大多数人意识到的第一件事就是采用并行化 I/O。

所以,我们需要把待处理的文件分块。分多少块呢?

有多少个线程就分成多少个块,每个线程各自处理一个块,这样性能就上去了。

文件分块读取,大家自然而然的就想到了 mmap 相关的方法。

mmap 可以用 ByteBuffer API 来搞事情,但是使用的索引是 int 类型,所以可映射的大小有 2GB 的限制。

前面说了,在这个挑战中,光是文件大小就有 13G,所以 2GB 是捉襟见肘的。

但是在 JDK 21 中,支持一个叫做 MemorySegment 的东西,也可以干 mmap 一样的事情,但是它的索引使用的是 long,相当于没有内存限制了。

除了使用 MemorySegment 外,还有一些细节的处理,比如找到正确的分割文件的位置、启动线程、等待线程处理完成等等。

处理这些细节会导致这一版的代码从最初的 17 行增加到了 120 行。

这是优化后的代码地址:

github.com/mtopolnik/b…

在这个赛题下,我们肯定是需要再循环中进行数据的解析和处理的,所以循环就是非常重要的一个点。

我们可以关注一下代码中的循环部分,这里面有一个小细节:

这个循环是每个线程在按块读取文件大小,里面用到了 findByte 方法和 stringAt 方法。

在第一个版本中,我们是用的 BufferedReader 把一行内容以字符串的形式读进来,然后按照分号分隔,并生成城市和温度两个字符串。

这个过程就涉及到三个字符串了。

但是这个哥们的思路是啥?

自定义一个 findByte 方法,先找到分号的位置,然后把下标返回回去。

再用自定义的 stringAt 方法,结合前面找到的下标,直接解析出“城市和温度”这两个字符串,减少了整行读取的内存消耗。

相当于少了十亿个字符串,在字符串处理和 GC 方面取得了不错的表现。

这一波操作下来,处理时间直接从 66s 下降到了 17s:

然后再看火焰图:

questdb.io/html/blog/p…

可以发现 GC 的时间几乎消失了。

CPU 现在大部分时间都花在自定义的 stringAt 上。还有相当多的时间花在 Map.computeIfAbsent 方法 、Double.parseDouble 方法和 findByte 方法

其中 Double.parseDouble 方法是解析温度用的。

作者打算先把这个地方给攻下来。

第二版优化:优化温度解析方法

在这版优化中,作者直接将温度解析为整数。

首先,目前的做法是,首先分配一个字符串,然后对其调用 parseDouble() 方法,最后转换为整数以进行高效的存储和计算。

但是,其实我们应该直接创建整数出来,没必要走字符串绕一圈。

同时我们知道,温度的取值范围是 [-99.9,99.9],所以针对这个范围,我们搞个自定义方法就行了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
ini复制代码private int parseTemperature(long semicolonPos) {
    long off = semicolonPos + 1;
    int sign = 1;
    byte b = chunk.get(JAVA_BYTE, off++);
    if (b == '-') {
        sign = -1;
        b = chunk.get(JAVA_BYTE, off++);
    }
    int temp = b - '0';
    b = chunk.get(JAVA_BYTE, off++);
    if (b != '.') {
        temp = 10 * temp + b - '0';
        // we found two integer digits. The next char is definitely '.', skip it:
        off++;
    }
    b = chunk.get(JAVA_BYTE, off);
    temp = 10 * temp + b - '0';
    return sign * temp;
}

这波操作下来,处理时间又减少了 6s,来到了 11s:

再看对应火焰图:

questdb.io/html/blog/p…

温度解析部分的耗时占比从 21.43% 降低到 6%,说明是一次正确的优化。

接下来,可以再搞一搞 stringAt 方法了。

第三版优化:自定义哈希表

首先,要优化 stringAt 方法,我们得知道它是干啥的。

我们看一眼代码:

在经历了上一波优化之后,stringAt 目前在代码中的唯一作用就是为了获取气象站的名称。

而获取到这个名称的唯一目的是看看当前的 HashMap 中有没有这个气象站的数据,如果没有就新建一个 StationStats 对象,如果有就把之前的 StationStats 对象拿出来进行数据维护。

此外,在赛题中还有这样的一个信息,虽然有十亿行数据,但是只有 413 个气象站:

既然 key 的大小是可控的,那基于这个条件,作者想了一个什么样的骚操作呢?

他直接不用 HashMap 了,自定义了一个哈希表,长这样的:

github.com/mtopolnik/b…

主要看一下代码中的 findAcc 方法,你就能明白它是干啥的了:

通过 hash 方法计算出指定字符串,即气象站名称的 hash 值之后,从自定义的 hashtable 中取出该位置的数据。

首先标号为 ① 的地方,如果没有取到数据,则说明没有这个气象站的数据,新建一个放好,返回就完事。

如果取到了数据,来到标号为 ② 的地方,看看取到的数据和当前要放的数据对应的气象站名称是不是一样的。

如果是则说明已经有了,取出来,然后返回。

如果不是,说明啥情况?

说明 hash 冲突了,来到标号为 ③ 的地方进行下标加一的动作。

然后再次进行循环。

来,你告诉我,这是什么手法?

这不就是开放寻址来解决 hash 冲突吗?

所以 findAcc 方法,就可以替代 computeIfAbsent 方法。

通过自定义的 StatsAcc 哈希表来代替原生的 HashMap。

而且前面说了,key 的大小是可控的,如果自定义 hash 表的初始化大小控制的合适,那么整个 hash 冲突的情况也不会非常严重。

这一波组合拳下来,运行时间来到了 6.6s,火焰图变成了这样:

questdb.io/html/blog/p…

大量的时间花在了前面分析的 findAcc 方法上。

同时作者提到了这样一句话:

同样的代码,如果放到 OpenJDK 上跑需要运行 9.6s,比 GraalVM 慢了 3.3s。

我滴个乖乖,这就是一个 45% 的性能提升啊。

第四版优化:使用 Unsafe 和 SWAR

在这一版优化开始之前,作者先写了这样一段话:

大概意思就是说,到目前为止,我们用到的都是常规且有效的解决方案,并且是 Java 标准、安全的用法。

即使止步于此也能学到很多优化技巧,可以在实际的项目中进行使用。

如果你继续往下探索,那么:

Readability and maintainability also take a big hit, while providing diminishing returns in performance. But, a challenge is a challenge, and the contestants pressed on without looking back!

可读性和可维护性也会受到重创,同时性能的收益会递减。但是,挑战就是挑战,参赛者们继续努力,没有回头!

简单来说,作者的意思就是打个预防针:接下来就要开始上强度了。

所以,在这个版本中,作者应用一些排名靠前的选上都在用的方案:

  • 使用 sun.misc.Unsafe 而不是 MemorySegment,来避免边界检查
  • 避免重新读取相同的输入字节:重复使用加载的值进行哈希和分号搜索
  • 每次处理 8 个字节的数据,使用 SWAR 技术找到分号分隔符。
  • 使用 merykitty 老哥提供的牛逼的 SWAR(SIMD Within A Register)代码解析温度。

这是这一版的代码:

github.com/mtopolnik/b…

比如其中关于循环处理数据的部分,看起来就很之前很不一样了:

然后你再看里面 semicolonMatchBits、nameLen、maskWord、dotPos、parseTemperature 这些方法的调用,直接就是一个懵逼的状态,看着头都大了:

但是你仔细看,你会发现这几个方法是作者从其他人那边学来的:

比如这个叫做 merykitty 的老哥,提供了解析温度的代码,虽然作者加入了大量的注释说明,但是我也只是大概就看懂了不到三层吧。

这里面大量的使用了位运算的技巧,同时你仔细看:几乎没有 if 判断的存在。这是重点,用直接的位运算替换了分支指令,从而减少了分支预测错误的成本。

此外,还有很多我第一次见、叫不上名字的奇技淫巧。

通过这一波“我看不懂,但是我大受震撼”的操作搞下来,时间降低到了 2.4s:

第五版优化:统计学用起来

现在,我们的火焰图变成了这样:

questdb.io/html/blog/p…

耗时主要还是在于 findAcc 方法:

而 findAcc 方法的耗时在于 nameEquals 方法,判断当前气象站名称是否出现过:

但是这个方法里面有个 if 判断,以字节为单位比较两个字符串的内容,每次比较 8 个字节。

首先,它通过循环逐步比较两个字符串中的对应字节。在每次迭代中,它使用 getLong 方法从输入字符串中获取一个 64 位的长整型值,并与另一个字符串中的相应位置进行比较。如果发现不相等的字节,则返回 false,表示两个字符串不相等。

如果循环结束后没有发现不相等的字节,它会继续检查是否已经比较了输入字符串的所有字节,或者最后一个输入字符串的字节与相应位置的字符串字节相等,那么表示两个字符串相等,则返回 true。

那么问题就来了?

如果气象站名称长度全都是小于 8 个字节,会出现啥情况?

假设有这样的一个前提条件,是不是我们就不用在 for 循环中进行 if 判断了,直接一把就比较完成了?

很可惜,没有这样一个提前条件。

但是,如果在数据集中,气象站名称长度绝大部分都小于 8 个字节那是不是就可以单独处理一下?

那到底数据分布是怎么样的呢?

这个问题问题出去的一瞬间,统计学啪的一下就站出来了:这个老子在行,我算算。

所以,作者写了一个程序来统计分析数据集中气象站名称的长度:

github.com/mtopolnik/b…

基于程序运行结果,最终的结论如下:

通过分析作者发现,赛题的数据集中气象站名称长度几乎均匀分布在 8 字节以上和 8 字节以下。

运行 Statistics.branchPrediction 方法,当条件是 nameLen > 8 时导致了 50% 的分支预测失败。

也就是说,十亿数据中有一半的数据,都是小于 8 字节的,都是不用特意进行 if 判断的。

但如果将条件更改为 nameLen > 16,那么预测失败率将降至 2.5%。

根据这一发现,很明显,如果要进一步优化代码,就需要编写一些特定的代码来避免在 nameLen > 8 上使用任何 if 判断,直接使用 nameLen > 16 就行。

这是这一版的最终代码,可读性越来越差了:

github.com/mtopolnik/b…

但是最终的成绩是 1.8s:

哦,对了,如果你对于分支预测技术不太清楚,那你可能看得比较懵。

但是分支预测,在性能挑战中,特别是最后大家比分都咬的非常紧的情况下,每次都是屡立奇功,战功赫赫,属于高手间过招杀手锏级别的优化手段。

继续优化

再后面作者还有这两个部分。

消除启动/清理成本:

使用更小的文件分块和工作窃取机制:

这后面就完全是基于这个赛题进行定制化的优化,可移植性不强了,作者就没有进行详细描述,再加上一个我也是没怎么看明白,就不展开讲了。

反正这两个组合拳下来,又搞了 0.1s 的时间下来,最终的成绩为 1.7s:

我实在是学不动了,有兴趣的同学可以自己去看看原文的对应部分。

写在后面

其实关于这篇文章,我原想法是看懂前三名的代码,然后对代码进行解析、对比,找到他们思路的共同点和差异点,但是后来他们的代码确实我看不懂,所以我放弃了这个想法。

但是我知道,只要我愿意花时间、有足够的时间,我肯定可以慢慢地把他们的这几百行代码啃透,但是我也只是想了想而已,很快就放弃了这个思路。

我想如果是大学的时候,我看到这个比赛,我会觉得,真牛逼,我得好好研究一下。

然而现在不一样了,参加工作了,看到了这个比赛,我还是会觉得,真牛逼,但是对我写业务代码帮助不大,就不深究了,浅尝辄止。

大学的时候学习是靠自己无穷的精力和对于掌握新知识的乐趣撑着,现在学习主要靠一时冲动。

但是我还是强烈建议感兴趣的朋友,按照我问中提到的地址,自己去研究一波别人提交的代码。

也许你也会产生一样的疑问:他们写的 Java 和我会的 Java 是同一个 Java 吗?

我的答案是:不是的,他们写的 Java 是自己热爱的 Java,我们写的 Java 只是挣钱的 Java。

没有高低贵贱之分,但是能让你不经意间,从业务代码的深海中抬头看一眼,看到自己熟悉的领域中,更广阔的世界。

本文转载自: 掘金

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

别再这么写POST请求了~

发表于 2024-03-04
大家好,我是石头~


今天在进行组内code review,发现有一位同学在使用POST方式进行接口调用传参的时候,并不是按照HTTP规范,将参数写入到请求体中进行传输,而是拼接到接口URL上面。


那么,POST请求,是否适宜将参数拼接到URL中呢?

图片

POST请求与参数传递的标准机制

在讨论这个问题之前,我们先了解一下POST请求参数传递的正确方式是怎样的?


按照HTTP协议规定,POST请求主要服务于向服务器提交数据的操作,这类数据通常包含表单内容、文件上传等。标准实践中,这些数据应封装于请求体(Request Body)内,而非附加在URL上。这是出于POST请求对数据容量和安全性的考量,URL因其长度限制和透明性特点并不适合作为大型或敏感数据的载体。

图片

URL参数拼接的风险

从上所述,URL参数拼接并不是POST请求参数传递的正确方式,但是既然这样做也是可以正常进行请求的,对方服务端也能正常获取到参数,那么,URL参数拼接又有什么风险?
  • URL长度限制:URL长度并非无限制,大多数浏览器和服务器都有最大长度限制,一般在2000字符左右,若参数过多或过大,可能导致URL截断,进而使服务端无法完整接收到所有参数
  • 安全性隐患:将参数拼接到URL中,可能导致敏感信息泄露,如密码、密钥等。此外,URL中的参数容易被浏览器历史记录、缓存、代理服务器等记录,增加了信息泄露的风险
  • 不符合HTTP规范:POST请求通常将数据放在请求体中,而非URL中,违反这一规范可能导致与某些服务器或中间件的兼容性问题。

图片

POST传参正确写法

以下是一个使用Java的HttpURLConnection发送Post请求并将数据放在请求体中的示例:
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复制代码import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;

public class PostRequestExample {

public static void sendPostRequest(String requestUrl, String postData) throws Exception {
URL url = new URL(requestUrl);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("POST");
connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); // 设置请求头,表明请求体的内容类型

connection.setDoOutput(true); // 表示要向服务器写入数据
try (OutputStream os = connection.getOutputStream()) {
byte[] input = postData.getBytes("UTF-8"); // 将参数转换为字节数组,此处假设postData是已编码好的参数字符串
os.write(input, 0, input.length); // 将参数写入请求体
}

int responseCode = connection.getResponseCode();
if (responseCode == HttpURLConnection.HTTP_OK) {
// 处理响应...
} else {
// 错误处理...
}
}

public static void main(String[] args) throws Exception {
String requestUrl = "http://example.com/api/endpoint";
String postData = "param1=value1&param2=value2"; // 参数以键值对的形式编码
sendPostRequest(requestUrl, postData);
}
}

**MORE | 更多精彩文章**

  • JWT重放漏洞如何攻防?你的系统安全吗?
  • JWT vs Session:到底哪个才是你的菜?
  • JWT:你真的了解它吗?
  • H5推送,为什么都用WebSocket?
  • 揭秘布谷鸟过滤器:一场数据过滤中的“鸠占鹊巢”大戏!

本文转载自: 掘金

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

Flutter&Flame 游戏开发 - 零 文章总集

发表于 2024-03-04

注:本文会随着教程博客的发布而 持续更新。

系列文章发布于掘金社区,可能会更新、修正,一切以 掘金文章 版本为准。


Flutter&Flame 游戏开发系列前言:

该系列是 [张风捷特烈] 的 Flame 游戏开发教程。Flutter 作为 全平台 的 原生级 渲染框架。官方对休闲游戏的宣传越来越多,以 Flame 游戏引擎为基础,Flutter 有游戏方向发展的前景。本系列教程旨在让更多的开发者了解 Flutter 游戏开发。

第一季:30 篇短文章,快速了解 Flame 基础。[已完结]

第二季:从休闲游戏实战,进阶 Flutter&Flame 游戏开发。

两季知识是独立存在的,第二季 不需要 第一季作为基础。本系列教程源码地址在 【toly1994328/toly_game】,系列文章列表可在《文章总集》 或 【github 项目首页】 查看。


第一季文章列表

这是一套 张风捷特烈 出品的 Flutter&Flame 系列教程,本系列源码于 【toly_game】和 【pinball】 ,如果本系列对你有所帮助,希望点赞支持,本系列文章一览:

  • 【Flutter&Flame 游戏 - 壹】开启新世界的大门
  • 【Flutter&Flame 游戏 - 贰】操纵杆与角色移动
  • 【Flutter&Flame 游戏 - 叁】键盘事件与手势操作
  • 【Flutter&Flame 游戏 - 肆】精灵图片加载方式
  • 【Flutter&Flame 游戏 - 伍】Canvas 参上 | 角色的血条
  • 【Flutter&Flame 游戏 - 陆】暴击 Dash | 文字构件的使用
  • 【Flutter&Flame 游戏 - 柒】人随指动 | 动画点触与移动
  • 【Flutter&Flame 游戏 - 捌】装弹完毕 | 角色武器发射
  • 【Flutter&Flame 游戏 - 玖】探索构件 | Component 是什么
  • 【Flutter&Flame 游戏 - 拾】探索构件 | Component 生命周期回调
  • 【Flutter&Flame 游戏 - 拾壹】探索构件 | Component 使用细节
  • 【Flutter&Flame 游戏 - 拾贰】探索构件 | 角色管理
  • 【Flutter&Flame 游戏 - 拾叁】碰撞检测 | CollisionCallbacks
  • 【Flutter&Flame 游戏 - 拾肆】碰撞检测 | 之前代码优化
  • 【Flutter&Flame 游戏 - 拾伍】粒子系统 | ParticleSystemComponent
  • 【Flutter&Flame 游戏 - 拾陆】粒子系统 | 粒子的种类
  • 【Flutter&Flame 游戏 - 拾柒】构件特效 | 了解 Effect 体系
  • 【Flutter&Flame 游戏 - 拾捌】构件特效 | ComponentEffect 一族
  • 【Flutter&Flame 游戏 - 拾玖】构件特效 | 了解 EffectController 体系
  • 【Flutter&Flame 游戏 - 贰拾】构件特效 | 其他 EffectControler
  • 【Flutter&Flame 游戏 - 贰壹】视差组件 | ParallaxComponent
  • 【Flutter&Flame 游戏 - 贰贰】菜单、字体和浮层
  • 【Flutter&Flame 游戏 - 贰叁】 资源管理与国际化
  • 【Flutter&Flame 游戏 - 贰肆】pinball 源码分析 - 项目结构介绍
  • 【Flutter&Flame 游戏 - 贰伍】pinball 源码分析 - 资源加载与 Loading
  • 【Flutter&Flame 游戏 - 贰陆】pinball 源码分析 - 游戏主菜单界面
  • 【Flutter&Flame 游戏 - 贰柒】pinball 源码分析 - 角色选择与玩法面板
  • 【Flutter&Flame 游戏 - 贰捌】pinball 源码分析 - 游戏主场景的构成
  • 【Flutter&Flame 游戏 - 贰玖】pinball 源码分析 - 视口与相机

第一季完结,谢谢支持 ~


第二季文章列表

  • # Flutter&Flame游戏实践#01 | Trex-角色登场
  • # Flutter&Flame游戏实践#02 | Trex-物理运动

本文转载自: 掘金

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

Flutter&Flame游戏实践#01 Trex-角色

发表于 2024-03-04

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!


Flutter&Flame 游戏开发系列前言:

该系列是 [张风捷特烈] 的 Flame 游戏开发教程。Flutter 作为 全平台 的 原生级 渲染框架,兼具 全端 跨平台和高性能的特点。目前官方对休闲游戏的宣传越来越多,以 Flame 游戏引擎为基础,Flutter 有游戏方向发展的前景。本系列教程旨在让更多的开发者了解 Flutter 游戏开发。

第一季:30 篇短文章,快速了解 Flame 基础。[已完结]

第二季:从休闲游戏实践,进阶 Flutter&Flame 游戏开发。

两季知识是独立存在的,第二季 不需要 第一季作为基础。本系列教程源码地址在 【toly1994328/toly_game】,系列文章列表可在《文章总集》 或 【github 项目首页】 查看。


Trex 小游戏介绍

Chrome 在断网时,会有一个小恐龙跳跃躲避障碍物的小游戏,也可以在 chrome://dino/ 地址访问。这个游戏 麻雀虽小五脏俱全 ,是体验游戏开发很好的切入点。

它包含以下几个要点:

  • 角色呈现
  • 跳跃移动
  • 碰撞检测
  • 分数记录

这个小游戏将作为 Flutter&Flame 第二季的先锋。通过对恐龙跳跃小游戏的逐步实现,来初步体验 Flame 开发一个小游戏的基本工作流程。下面开始进入游戏开发的世界吧~


一、地面、云朵和障碍物的呈现

本小结你将收获的技能点: 本节源码见 [lib/trex/01]

[1]. 资源加载 : 运行 Flame 的项目代码,加载图片资源。

[2]. 角色的呈现: 如何通过精灵图将角色展示到场景中。

[3]. 角色的定位:如何控制角色在场景中的位置。

在 Flame 中,场景中的一切都是 Component 对象的组合,为了区分Flutter 中的 Widget (组件),文中一律称之为 构件 (游戏的构成零件) 。比如下图中的小恐龙、云朵、地面、分数、障碍物,都是一个个被加入到游戏主类中的构件:

image.png

本小节我们将读取图片资源,展示地面、云朵和障碍物三个静态的角色,了解一下 Component 的基本使用。


1.游戏主类和资源图片加载

Flame 中通过 GameWidget 组件呈现,其中传入一个 FlameGame 的派生类作为游戏的入口。这里游戏中的所有资源通过 精灵图 的方式集合在一起,如下所示:

TrexGame 可以在 onLoad 回调 中异步加载资源;游戏的图片资源可以通过 Flame.images.load 方法加载。复写 backgroundColor 方法,可以修改游戏的背景色(默认是黑色)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
dart复制代码---->[lib/trex/01/main.dart]----
main() => runApp(GameWidget(game: TrexGame()));

---->[lib/trex/01/trex_game.dart]----
class TrexGame extends FlameGame {
late final Image spriteImage;

@override
Future<void> onLoad() async{
spriteImage = await Flame.images.load( 'trex/trex.png' );
}

@override
Color backgroundColor() {
return const Color(0xffffffff);
}
}

2. 静态角色的呈现: 云朵

拿云朵来说,它在游戏中的也是以 Component 的身份呈现在场景中的。SpriteComponent 可以展示一个精灵资源,对于 精灵图 来说,我们可以通过顶点坐标 srcPosition 和尺寸 srcSize 来确定某一个精灵,如下示意:

image.png

下面定义 CloudComponent 继承自 SpriteComponent,在 onLoad 回调中根据图片资源对象创建 Sprite ,并为 sprite 赋值即可:

tips: with HasGameReference<TrexGame> 后,类中可以通过 game 得到 TrexGame 对象.

1
2
3
4
5
6
7
8
9
10
11
dart复制代码---->[lib/trex/01/heroes/cloud_component.dart]----
class CloudComponent extends SpriteComponent with HasGameReference<TrexGame>{
@override
Future<void> onLoad() async {
sprite = Sprite(
game.spriteImage,
srcPosition: Vector2(166.0, 2.0),
srcSize: Vector2(92.0, 28.0),
);
}
}

有人可能会问,我怎么能知道坐标和尺寸的确切数值?

  • 精灵图制作时,工具会给出坐标相关的配置信息(如下图),可以解析 json 文件得到精灵尺寸和位置。
  • 如果你是拿别人的精灵图,且没有配置信息,可以自己用 PhotoShop 量一下。

image.png


云朵的构件已经准备完毕,接下来把它 "挂在" 屏幕上。TrexGame#onLoad 方法中通过 add 方法添加 Component 进行展示。构件默认会定位在场景的 左上角 :

image.png

1
2
3
4
5
6
dart复制代码---->[lib/trex/01/trex_game.dart]----
@override
Future<void> onLoad() async {
spriteImage = await Flame.images.load('trex/trex.png');
add(CloudComponent());
}

3. 构件的定位: 地面和障碍物

如下所示,我们先把地面放在场景中。同样定义一个 GroundComponent 的构件,在 onLoad 时设置地面对于的精灵图。默认会在左上角,SpriteComponent 派生类中,可以通过 x,y 决定构件的位置:

image.png

onGameResize 回调会 在窗口尺寸变化时 或者构件加载完后 触发,其中的 size 是窗口尺寸。这里想让地面在中间偏下一点,只要将 y 赋值即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
dart复制代码class GroundComponent extends SpriteComponent with HasGameReference<TrexGame>{

final double groundHeight = 24;

@override
void onGameResize(Vector2 size) {
super.onGameResize(size);
y = size.y / 2 + groundHeight/2;
}

@override
Future<void> onLoad() async {
sprite = Sprite(
game.spriteImage,
srcPosition: Vector2(2, 104.0),
srcSize: Vector2(2400, groundHeight),
);
}
}

接下来把第一个障碍物放到场景的中间,同理创建一个 ObstacleComponent 构件表示障碍物。在 onLoad 回调 中创建 Sprite ; 在 onGameResize 回调 中设置偏移量:

image.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
dart复制代码class ObstacleComponent extends SpriteComponent
with HasGameReference<TrexGame> {

@override
void onGameResize(Vector2 size) {
super.onGameResize(size);
y = size.y / 2 - 55.0 + 21;
x = size.x / 2 - width / 2;
}

@override
Future<void> onLoad() async {
sprite = Sprite(
game.spriteImage,
srcPosition: Vector2(446.0, 2.0),
srcSize: Vector2(34.0, 70.0),
);
}
}

通过云朵、地面、障碍物三个图片精灵的展示,大家应该对如何呈现一个图片资源有了清晰地认知。

小思考: 如何在场景中添加多个障碍物和云朵? (稍后介绍)


二、小恐龙的呈现与状态变化

本小结你将收获的技能点: 本节源码见 [lib/trex/02]

[1]. 多状态精灵 : 一个构建中如何拥有多种状态,并支持切换。

[2]. 键盘和手势 : 通过点击事件和键盘回调事件,切换小恐龙的展示状态。

224.gif


1. 多状态精灵图片的处理

场地已经在界面上了,那么接下来让小恐龙登场吧! 在游戏中,小恐龙有 不同状态, 使用需要展示不同的图片资源,这里将它的状态通过 PlayerState 表示:

1
2
3
4
5
6
7
8
dart复制代码---->[lib/trex/02/heroes/player.dart]----
enum PlayerState {
waiting, // 等待
running, // 奔跑
jumping, // 跳跃
down, // 趴下
crashed, // 死亡
}

像这种不同状态有不同图片,而且某些状态需要有 序列帧动画 的角色。可以通过 SpriteAnimationGroupComponent 构件进行展示,它支持一个 泛型 T 表示状态。创建 Player 类型如下:

1
2
3
4
dart复制代码---->[lib/trex/02/heroes/player.dart]----
class Player extends SpriteAnimationGroupComponent<PlayerState> with HasGameReference<TrexGame>{
// TODO
}

上面的 SpriteComponent 通过 sprite 对象展示静态的精灵图片,这里 SpriteAnimationGroupComponent 有一个映射 animations 对象:

以状态 T 为键,以 SpriteAnimation 为值。我们需要完成对 animations 映射赋值的工作。

其中 SpriteAnimation 就是序列帧图片,用来展示角色,每一帧图片在对应精灵图的一个矩形区域。下面简单封装 loadAnimation 方法来加载:

  • 传入角色的尺寸 size 和位置列表 frames,来确定矩形区域。
  • stepTime 表示帧动画的间隔时间秒数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
dart复制代码---->[lib/trex/02/heroes/player.dart]----
SpriteAnimation loadAnimation({
required Vector2 size,
required List<Vector2> frames,
double stepTime = double.infinity,
}) {
return SpriteAnimation.spriteList(
frames.map((vector) => Sprite(
game.spriteImage,
srcSize: size,
srcPosition: vector,
)).toList(),
stepTime: stepTime,
);
}

2. 映射关系的初始化和呈现

在 Player 构件的 onLoad 回到中通过 _initAnimations 方法来初始化映射关系:

1
2
3
4
5
scss复制代码---->[lib/trex/02/heroes/player.dart]----
@override
Future<void> onLoad() async {
_initAnimations();
}

将不同的 PlayerState 状态,对应为不同的 SpriteAnimation 资源。其中 frames 表示图片的序列帧起点坐标,有多个就表示当前状态具有动画效果:

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
dart复制代码---->[lib/trex/01/heroes/player.dart]----
void _initAnimations(){
animations = {
PlayerState.running: loadAnimation(
size: Vector2(88.0, 90.0),
frames: [Vector2(1514.0, 4.0), Vector2(1602.0, 4.0)],
stepTime: 0.2,
),
PlayerState.waiting: loadAnimation(
size: Vector2(88.0, 90.0),
frames: [Vector2(76.0, 6.0)],
),
PlayerState.jumping: loadAnimation(
size: Vector2(88.0, 90.0),
frames: [Vector2(1338.0, 4.0)],
),
PlayerState.crashed: loadAnimation(
size: Vector2(88.0, 90.0),
frames: [Vector2(1778.0, 4.0)],
),
PlayerState.down: loadAnimation(
size: Vector2(114.0, 90.0),
frames: [Vector2(1866, 6.0), Vector2(1984, 6.0)],
stepTime: 0.2,
),
};
current = PlayerState.waiting;
}

然后在 TrexGame 中创建 Player 对象,在 onLoad 方法中通过 add 添加构建,此时角色精灵就可以展示出来了。

image.png

SpriteAnimationGroupComponent 中同样可以通过 x 和 y 数值设置构件的位置。在 onGameResize 回调中根据窗口尺寸进行设置:

image.png

1
2
3
4
5
6
7
8
9
10
11
dart复制代码---->[lib/trex/02/heroes/player.dart]----
class Player extends SpriteAnimationGroupComponent<PlayerState> with HasGameReference<TrexGame> {
double get centerY => (game.size.y / 2) - height / 2;

@override
void onGameResize(Vector2 size) {
super.onGameResize(size);
y = centerY;
x = 60;
}
/// 略同...

3.键盘所示与 Player 状态切换

SpriteAnimationGroupComponent 中的 current 表示当前的状态,更新该值就可以展示对应状态的图片资源。如下所示,在 toggleState 方法中轮换状态值:

1
2
3
4
5
6
dart复制代码---->[lib/trex/02/heroes/player.dart]----
void toggleState() {
int nextIndex = (current?.index ?? 0) + 1;
nextIndex = nextIndex % PlayerState.values.length;
current = PlayerState.values[nextIndex];
}

然后只要在合适的时机触发 Player#toggleState 方法即可切换小恐龙的状态。通过混入:

  • KeyboardEvents 监听键盘事件。
  • TapCallbacks 监听点击手势事件。

下面代码中,监听到按键 a 以及 onTapDown 事件时,触发 player.toggleState():

40.gif

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
dart复制代码---->[lib/trex/02/heroes/player.dart]----
import 'package:flutter/widgets.dart' hide Image;

class TrexGame extends FlameGame with KeyboardEvents, TapCallbacks{

/// 略同...

@override
KeyEventResult onKeyEvent( RawKeyEvent event, Set<LogicalKeyboardKey> keysPressed ) {
if (keysPressed.contains(LogicalKeyboardKey.keyA)) {
player.toggleState();
}
return KeyEventResult.handled;
}

@override
void onTapDown(TapDownEvent event) {
player.toggleState();
}
}

SpriteAnimationGroupComponent 可以通过 debugMode=true 展示调试信息,包括矩形的边界和位置信息。如下所示,切换是否展示信息也就是切换 debugMode 的真假:

image.png

这里在 Player 中添加一个 toggleDebugMode 方法,切换 debugMode 值,并且在键盘 D :

image.png

1
2
3
4
dart复制代码---->[lib/trex/02/heroes/player.dart]----
void toggleDebugMode() {
debugMode = !debugMode;
}

三、文字的展示

界面呈现中处理图片之外,最重要的就是文字。Flame 中通过 TextComponent 展示文字,本节就来介绍一下文字的展现方式。

[1]. 使用文字 :通过文本展示小恐龙的状态信息以及提示信息。

[2]. 精灵字体 :通过 SpriteFont 展示分数的图片像素文字。


1.文字信息的展示

虽然现在呈现了小恐龙的状态变化,但是看起来并不是很清晰,如果界面上可以展示一些提示文字,就可以清晰地自动当前案例的作用。比如当前操作的按键作用以及小恐龙的状态信息:

image.png


flame 中一切的表现都是 Component , 为了方便展示维护提示信息,可以像 Player 那样将其视为一个角色加入游戏场景中。 如下所示,定义 HelpText 继承自 PositionComponent ,让其拥有定位能力;在 onLoad 回调中加入两个 TextComponent 分别展示状态和提示文字。并提供 changeState 方法更新状态文字的内容:

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
dart复制代码class HelpText extends PositionComponent with HasGameReference<TrexGame>  {
TextStyle stateStyle = const TextStyle(fontSize: 12, color: Colors.blue);
TextStyle infoStyle = const TextStyle(fontSize: 12, color: Colors.grey);

final String _info = '提示信息:\n'
'键盘a/点击: 切换恐龙状态\n'
'键盘 d: 切换展示边框信息' ;

String initState = '' ;

HelpText(this.initState);

double get centerY {
return (game.size.y / 2) - height / 2;
}

@override
void onGameResize(Vector2 size) {
super.onGameResize(size);
y = centerY;
x = 60;
}

late TextComponent _stateText;

void changeState(String state) {
_stateText.text = state;
}

@override
Future<void> onLoad() async {
_stateText = TextComponent(
text: initState,
position: Vector2(0,68),
textRenderer: TextPaint(style: stateStyle),
);
add( _stateText);
add(TextComponent(
position: position.translated(0, 68+20),
text: _info,
textRenderer: TextPaint(style: infoStyle),
));
}
}

然后在 TrexGame 中将 HelpText 像 Player 那样加入到场景中。当点击和按键事件时,通过 changeState 方法修改状态文字即可:

1
2
3
4
5
6
7
8
9
10
11
12
dart复制代码class TrexGame extends FlameGame with KeyboardEvents, TapCallbacks {
/// 略同...
late final HelpText helpText;

@override
Future<void> onLoad() async {
spriteImage = await Flame.images.load( 'trex/trex.png' );
add(player);
String initState = player.current.toString();
helpText = HelpText(initState);
add(helpText);
}

2. 精灵字体 SpriteFont

Flame 中提供了 SpriteFont 方便展示精灵图中的字体。在项目精灵图中,有数字和字母相关的图片作为分数。通过精灵字体,就可以将对应的字符串 映射为 精灵图片列表 展示:

image.png

比如下面的 1024 HI 2048 字符串,就可以访问到对应的精灵图片,展示文字:

image.png

同样,这里也定义一个 ScoreComponent 负责维护分数角色的展示, 在 onLoad 回调中创建并添加 TextComponent 。通过 SpriteFont 来建立字符集合图片区域的映射 Glyph 对象。这样对应的字符在渲染时就可以找到对应区域的图片精灵,完成展示:

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
dart复制代码class ScoreComponent extends PositionComponent with HasGameReference<TrexGame> {

late TextComponent _score;

@override
Future<void> onLoad() async {
const chars = '0123456789HI ';
final renderer = SpriteFontRenderer.fromFont(
SpriteFont(
source: game.spriteImage,
size: 23,
ascent: 23,
glyphs: [
for (var i = 0; i < chars.length; i++)
Glyph(chars[i], left: 954.0 + 20 * i, top: 0, width: 20),
],
),
letterSpacing: 2,
);
_score = TextComponent( textRenderer: renderer);
_score.text = '1024 HI 2048';
add(_score);
}

@override
void onGameResize(Vector2 size) {
super.onGameResize(size);
x = size.x - _score.width -20;
y = 20;
}
}

到这里已经人物登场啦,第一集的内容就介绍完毕了。下面整理了一下本集的知识。大家可以自己根据每一项思考一下具体内容:

image.png

下一章将继续推进,学习如何让画面动起来。

本文转载自: 掘金

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

formily原来是这样解决这些表单难题

发表于 2024-03-04

作者:陈南晓

背景

古茗在中后台的场景中大量的使用 formily 来解决问题。在小陈首次使用 formily 时第一感受,这玩意儿咋那么难用,能不用吗?(现在改成 antd 来写还来得及不)跟老李反馈了初识 formily 遇到的难处,老李听后细心解答了古茗的中后台在使用表单始终会存在几个问题(表单数据管理、表单字段依赖、表单精准更新等等)。

IMG_2076.GIF

formily 仅仅作为一个数据载体帮我们去处理这类问题,不使用 formily 也照样存在你反馈的难处(但上手成本会低点🐶)。小陈回头一想是那么一回事,OK,I’m fine 是真的被他打败。那我们来看一下 formily 是如何解决中后台表单数据管理、字段依赖、精准更新问题吧~

表单数据管理

当使用 createForm 创建出一个表单模型,主要包括 FormGraph 和 FormHeart 两个部分。

其中:

  • FormGraph:表单是一个 Form 对象,表单字段是一个 Field 对象(每一个表单字段都对应一个独立的 Field ),它们的状态都由各自内部维护。这些都可以看成一个个的节点,最终都统一由 FormGraph 进行管理。
  • FormHeart:管理的是表单的生命周期,里面包括了表单的挂载,字段状态改变等等生命周期事件。

我们知道在 Input 组件中输入一个值,Input 组件对应的 Field 字段的 value 会更新以及 Form 中的 value 也会更新,反过来 Form 或 Field 中的 value 改变也会更新 Input 组的值,我们来看下它们是如何进行通信的


其中:

  • formState:维护着表单所有字段的 value
  • fieldState:维护着当前字段的 value
  • component:是每个字段对应的展示层组件,可以是 Input 或者 Select,也可以是其它的自定义组件

Form和Field数据通信

Form 和 Field 是通过发布订阅的模式进行通信,当 field.value 改变会通知 form 表单更新 value , form 中的 value 改变也会通知 field 更新 value 。

formily 内部实现

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
jsx复制代码// form.value 变化通知 field
const makeReactive = {
const triggerFormValuesChange = (form: Form, change: DataChange) => {
// 比较 form.value 是否存在变动
if(contains(form.values, change.object)) {
// 通知 field 组件,form.value 改变
form.notify(LifeCycleTypes.ON_FORM_VALUES_CHANGE)
}
}

observe(form,
(change) => {
xxxx
triggerFormValuesChange(form, change) },
true)
}

// field.value 变动通知表单
const onInput = (...args) => {
const values = getValues(args)
const value = values[0]
this.inputValue = value
this.value = value
// 通知表单 field.value 改变
this.notify(LifeCycleTypes.ON_FIELD_INPUT_VALUE_CHANGE)
}

Field与Component数据通信

一个 Field 对应一个 Component ,当用户在 Component 中输入时,会触发对应的 onChange 事件,该事件内部把用户输入的值传递给 Field 里。在 Field 值变动时,会将 field.value 通过 props.value 的形式传递给 Component,最终达到双向绑定的效果。

formily 内部实现

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
jsx复制代码const renderComponent = () => {
const events = {} as Record<string, any>
// 设置 field 中的 onChange 事件
events.change = (...args: any[]) => {
if (!isVoidField(field)) field.onInput(...args)
originChange?.(...args)
}
const componentData = {
attrs: {
// 获取 field 中的value
value: !isVoidField(field) ? field.value : undefined,
},
on: events,
}
// 渲染 field 的 component 组件
return h(component, componentData, mergedSlots)
}

// field.value
class Field {
construct(props) {
this.value = props.value;
this.makeObservable()
}

makeObservable() {
define(this,{
...,
// 将 this.value 变成响应式
value: observable.computed,
})
}
}

这里的 onChange 事件 field.onInput 会把用户输入的 value 赋值给 field.value 。反过来 field.value 改变会通过响应式知道哪个组件依赖它,并对其重新渲染。

表单字段联动

字段联动是指表单中一个字段依赖于其他字段的值,当其值发生变化时,相关联的表单元素会做出相应的变化或更新。那在 formily 中是如何实现表单联动的呢?
在 formily 中表单的联动效果,本质上也是一个“响应式”模型,实现原理借助了 formily/core ➕ formily/reactive 。

  1. 依赖收集

在 formily/core 中实例化 Field 实例中会执行 createReactions ,实现对依赖的收集

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
jsx复制代码const createReactions = (field: GeneralField) => {
const reactions = toArr(field.props.reactions)
// 在表单中注册该字段值变动的生命周期
field.form.addEffects(field, () => {
reactions.forEach((reaction) => {
if (isFn(reaction)) {
field.disposers.push(
// 收集依赖
....
autorun(reaction(field))
)
}
})
})
}

我们再进一步看看 autorun 是个啥玩意,怎么就能够收集依赖了呢?这里实现简易版的 autorun(关于“响应式”模型感兴趣的小伙伴可以看看这篇从零开始撸一个「响应式」框架

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
jsx复制代码let ReactionStack;
const RawReactionsMap = new WeakMap();

export function observable(value) {
return new Proxy(value, baseHandler);
}

const baseHandler: any = {
get(target, key) {
const result = target[key];
const current = ReactionStack
if (current) {
// 当前存在响应器
addRawReactionsMap(target, key, current);
}
return result;
},
set(target, key, value) {
target[key] = value;
RawReactionsMap.get(target)
?.get(key)
?.forEach((reaction) => reaction());
return true;
},
};

function addRawReactionsMap(target, key, reaction) {
const reactionsMap = RawReactionsMap.get(target);

if (reactionsMap) {
const reactions = reactionsMap.get(key);
if (reactions) {
reactions.push(reaction);
} else {
reactionsMap.set(key, [reaction]);
}
return reactionsMap;
} else {
const reactionsMap = new Map();
reactionsMap.set(key, [reaction]);
RawReactionsMap.set(target, reactionsMap);
return reactionsMap;
}
}

export function autorun(tracker) {
// reaction作为响应器,它也是一个函数
const reaction = () => {
ReactionStack = reaction;
tracker();
ReactionStack = null;
};
reaction();
}

由于 reaction(field) 中有引用 fieldState ,所以触发 baseHandler 中的 get 属性,实现对依赖的收集。

  1. 依赖监听

在上述 autorun 中实现的 get 属性实现了对依赖的收集,其中的 set 属性就是对依赖的监听,当依赖的值发生改变时会触发 set,执行 reaction 更新表单字段。
表单的联动在表单中是非常重要的,包含了字段间的各种关系。同时字段与字段关联时,还要保证不影响表单性能,下面我们再走进 formily 深处,看看它凭啥是表单高性能的解决方案。

表单精准更新

表单的精准更新本质上也是响应式原理,类似于表单组件里面引用了子组件,该如何正确的知道是哪个组件依赖了当前表单字段,另外当表单字段更新时如何做到只更新对应的子组件。
formily 中把上述的 autorun 中的全局变量 ReactionStack 改成一个栈形式,记录调用依赖的函数。下面我们来看一个栗子,假如 A 组件中引用了 B 组件,B 组件中引用了 C 组件。最终的形式 ,然而只有 C 组件使用的该依赖值,我们只需要重新渲染 C 组件即可,A、B 组件不需要重新渲染。
在依次执行 A、B、C 组件时,当执行到 C 组件时,发现有对依赖进行引用,对其进行依赖收集,即 ReactionStack[ReactionStack.length - 1] 此时为 C 组件,初始化结束后依次按顺序出栈。之后依赖的值更改时由于收集到的依赖是 C 组件,所以当依赖值改变时,只会重新渲染 C 组件。

我的影片.gif

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
jsx复制代码// 由于代码过多,只列出如何实现精准刷新的与上文的区别
let ReactionStack = [];

export function observable(value) {
return new Proxy(value, baseHandler);
}

const baseHandler: any = {
get(target, key) {
const result = target[key];
// current 表示当前依赖所在的执行函数
const current = ReactionStack[ReactionStack.length - 1]
if (current) {
// 当前存在响应器
addRawReactionsMap(target, key, current);
}
return result;
},
...
};

export function autorun(tracker) {
// reaction作为响应器,它也是一个函数
const reaction = () => {
ReactionStack.push(reaction);
tracker();
ReactionStack.pop();
};
reaction();
}

总结

在中后台的业务开发中与表单的邂逅是不可避免的,社区上也有各式各样的表单解决方案,正式练习快满一年的小陈同学从一开始对 formily 的疯狂 diss ,到现在发现更多只是使用方式的差别。🙏大家的阅读,如文章中有错误👏评论,还有如果有建议或者不同的想法,欢迎留言 也可以期待一下我们的下一篇文章 salute🫡~~

附录

  • formily
  • 前端简洁表单模型
  • 🍓中台表单技术选型实践(表单实践)

最后

🌟 招聘信息:

  • 25 / 26 届前端开发实习生

📚 小茗文章推荐:

  • 古茗是如何将小程序编译速度提升3倍的
  • 钉钉小程序实现签名板
  • 小程序主包体积的优化方案与技术实现

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

本文转载自: 掘金

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

1…525354…956

开发者博客

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