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

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


  • 首页

  • 归档

  • 搜索

刷了四百道算法题,我在项目里用过哪几道呢? 1版本比较:比

发表于 2024-01-08

大家好,我是老三,今天和大家聊一个话题:项目中用到的力扣算法。

不知道从什么时候起,算法已经成为了互联网面试的标配,在十年前,哪怕如日中天的百度,面试也最多考个冒泡排序。后来,互联网越来越热,涌进来的人越来越多,整个行业越来越内卷的,算法也慢慢成了大小互联网公司面试的标配,力扣现在已经超过3000题了,那么这些题目有多少进入了面试的考察呢?

以最爱考算法的字节跳动为例,看看力扣的企业题库,发现考过的题目已经有1850道——按照平均每道题花20分钟来算,刷完字节题库的算法题需要37000分钟,616.66小时,按每天刷满8小时算,需要77.08天,一周刷五天,需要15.41周,按一个月四周,需要3.85个月。也就是说,在脱产,最理想的状态下,刷完力扣的字节题库,需要差不多4个月时间。

字节题库

那么,我在项目里用过,包括在项目中见过哪些力扣上的算法呢?我目前刷了400多道题,翻来覆去盘点了一下,发现,也就这么几道。

刷题数量

1.版本比较:比较客户端版本

场景

在日常的开发中,我们很多时候可能面临这样的情况,兼容客户端的版本,尤其是Android和iPhone,有些功能是低版本不支持的,或者说有些功能到了高版本就废弃掉。

这时候就需要进行客户端的版本比较,客户端版本号通常是这种格式6.3.40,这是一个字符串,那就肯定不能用数字类型的比较方法,需要自己定义一个比较的工具方法。

某app版本

题目

165. 比较版本号

这个场景对应LeetCode: 165. 比较版本号。

  • 题目:165. 比较版本号 (leetcode.cn/problems/co…)
  • 难度:中等
  • 标签:双指针 字符串
  • 描述:

给你两个版本号 version1 和 version2 ,请你比较它们。

版本号由一个或多个修订号组成,各修订号由一个 '.' 连接。每个修订号由 多位数字 组成,可能包含 前导零 。每个版本号至少包含一个字符。修订号从左到右编号,下标从 0 开始,最左边的修订号下标为 0 ,下一个修订号下标为 1 ,以此类推。例如,2.5.33 和 0.1 都是有效的版本号。

比较版本号时,请按从左到右的顺序依次比较它们的修订号。比较修订号时,只需比较 忽略任何前导零后的整数值 。也就是说,修订号 1 和修订号 001 相等 。如果版本号没有指定某个下标处的修订号,则该修订号视为 0 。例如,版本 1.0 小于版本 1.1 ,因为它们下标为 0 的修订号相同,而下标为 1 的修订号分别为 0 和 1 ,0 < 1 。

返回规则如下:

+ 如果 `*version1* > *version2*` 返回 `1`,
+ 如果 `*version1* < *version2*` 返回 `-1`,
+ 除此之外返回 `0`。**示例 1:**
1
2
3
arduino复制代码输入:version1 = "1.01", version2 = "1.001"
输出:0
解释:忽略前导零,"01" 和 "001" 都表示相同的整数 "1"

示例 2:

1
2
3
ini复制代码输入:version1 = "1.0", version2 = "1.0.0"
输出:0
解释:version1 没有指定下标为 2 的修订号,即视为 "0"

示例 3:

1
2
3
arduino复制代码输入:version1 = "0.1", version2 = "1.1"
输出:-1
解释:version1 中下标为 0 的修订号是 "0",version2 中下标为 0 的修订号是 "1" 。0 < 1,所以 version1 < version2

提示:

+ `1 <= version1.length, version2.length <= 500`
+ `version1` 和 `version2` 仅包含数字和 `'.'`
+ `version1` 和 `version2` 都是 **有效版本号**
+ `version1` 和 `version2` 的所有修订号都可以存储在 **32 位整数** 中

解法

那么这道题怎么解呢?这道题其实是一道字符串模拟题,就像标签里给出了了双指针,这道题我们可以用双指针+累加来解决。

在这里插入图片描述

  • 两个指针遍历version1和version2
  • 将. 作为分隔符,通过累加获取每个区间代表的数字
  • 比较数字的大小,这种方式正好可以忽略前导0

来看看代码:

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
arduino复制代码class Solution {
   public int compareVersion(String version1, String version2) {
       int m = version1.length();
       int n = version2.length();

       //两个指针
       int p = 0, q = 0;

       while (p < m || q < n) {
           //累加version1区间的数字
           int x = 0;
           while (p < m && version1.charAt(p) != '.') {
               x += x * 10 + (version1.charAt(p) - '0');
               p++;
          }

           //累加version2区间的数字
           int y = 0;
           while (q < n && version2.charAt(q) != '.') {
               y += y * 10 + (version2.charAt(q) - '0');
               q++;
          }

           //判断
           if (x > y) {
               return 1;
          }
           if (x < y) {
               return -1;
          }

           //跳过.
           p++;
           q++;
      }
       //version1等于version2
       return 0;
  }
}

应用

这段代码,直接CV过来,就可以直接当做一个工具类的工具方法来使用:

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
arduino复制代码public class VersionUtil {

   public static Integer compareVersion(String version1, String version2) {
       int m = version1.length();
       int n = version2.length();

       //两个指针
       int p = 0, q = 0;

       while (p < m || q < n) {
           //累加version1区间的数字
           int x = 0;
           while (p < m && version1.charAt(p) != '.') {
               x += x * 10 + (version1.charAt(p) - '0');
               p++;
          }

           //累加version2区间的数字
           int y = 0;
           while (q < n && version2.charAt(q) != '.') {
               y += y * 10 + (version2.charAt(q) - '0');
               q++;
          }

           //判断
           if (x > y) {
               return 1;
          }
           if (x < y) {
               return -1;
          }

           //跳过.
           p++;
           q++;
      }
       //version1等于version2
       return 0;
  }
}

前面老三分享过一个规则引擎:这款轻量级规则引擎,真香!

比较版本号的方法,还可以结合规则引擎来使用:

  • 自定义函数:利用AviatorScript的自定义函数特性,自定义一个版本比较函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
typescript复制代码    /**
    * 自定义版本比较函数
    */
   class VersionFunction extends AbstractFunction {
       @Override
       public String getName() {
           return "compareVersion";
      }

       @Override
       public AviatorObject call(Map<String, Object> env, AviatorObject arg1, AviatorObject arg2) {
           // 获取版本
           String version1 = FunctionUtils.getStringValue(arg1, env);
           String version2 = FunctionUtils.getStringValue(arg2, env);
           return new AviatorBigInt(VersionUtil.compareVersion(version1, version2));
      }
  }
  • 注册函数:将自定义的函数注册到AviatorEvaluatorInstance中
1
2
3
4
5
6
7
8
9
10
11
12
13
14
csharp复制代码    /**
    * 注册自定义函数
    */
   @Bean
   public AviatorEvaluatorInstance aviatorEvaluatorInstance() {
       AviatorEvaluatorInstance instance = AviatorEvaluator.getInstance();
       // 默认开启缓存
       instance.setCachedExpressionByDefault(true);
       // 使用LRU缓存,最大值为100个。
       instance.useLRUExpressionCache(100);
       // 注册内置函数,版本比较函数。
       instance.addFunction(new VersionFunction());
       return instance;
  }
  • 代码传递上下文:在业务代码里传入客户端、客户端版本的上下文
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
typescript复制代码    /**
    * @param device 设备
    * @param version 版本
    * @param rule   规则脚本
    * @return 是否过滤
    */
   public boolean filter(String device, String version, String rule) {
       // 执行参数
       Map<String, Object> env = new HashMap<>();
       env.put("device", device);
       env.put("version", version);
       //编译脚本
       Expression expression = aviatorEvaluatorInstance.compile(DigestUtils.md5DigestAsHex(rule.getBytes()), rule, true);
       //执行脚本
       boolean isMatch = (boolean) expression.execute(env);
       return isMatch;
  }
  • 编写脚本:接下来我们就可以编写规则脚本,规则脚本可以放在数据库,也可以放在配置中心,这样就可以灵活改动客户端的版本控制规则
1
2
3
4
5
6
7
8
9
10
kotlin复制代码if(device==bil){
return false;
}

## 控制Android的版本
if (device=="Android" && compareVersion(version,"1.38.1")<0){
return false;
}

return true;

2.N叉数层序遍历:翻译商品类型

场景

一个跨境电商网站,现在有这么一个需求:把商品的类型进行国际化翻译。

某电商网站商品类型国际化

商品的类型是什么结构呢?一级类型下面还有子类型,字类型下面还有子类型,我们把结构一画,发现这就是一个N叉树的结构嘛。

商品树

翻译商品类型,要做的事情,就是遍历这棵树,翻译节点上的类型,这不妥妥的BFS或者DFS!

题目

429. N 叉树的层序遍历

这个场景对应LeetCode:429. N 叉树的层序遍历。

  • 题目:429. N 叉树的层序遍历(leetcode.cn/problems/n-…)
  • 难度:中等
  • 标签:树 广度优先搜索
  • 描述:

给定一个 N 叉树,返回其节点值的层序遍历。(即从左到右,逐层遍历)。

树的序列化输入是用层序遍历,每组子节点都由 null 值分隔(参见示例)。

示例 1:

img

1
2
ini复制代码输入:root = [1,null,3,2,4,null,5,6]
输出:[[1],[3,2,4],[5,6]]

示例 2:

img

1
2
csharp复制代码输入:root = [1,null,2,3,4,5,null,null,6,7,null,8,null,9,10,null,null,11,null,12,null,13,null,null,14]
输出:[[1],[2,3,4,5],[6,7,8,9,10],[11,12,13],[14]]

提示:

+ 树的高度不会超过 `1000`
+ 树的节点总数在 `[0, 10^4]` 之间

解法

BFS想必很多同学都很熟悉了,DFS的秘诀是栈,BFS的秘诀是队列。

层序遍历的思路是什么呢?

使用队列,把每一层的节点存储进去,一层存储结束之后,我们把队列中的节点再取出来,孩子节点不为空,就把孩子节点放进去队列里,循环往复。

N叉树层序遍历示意图

代码如下:

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
ini复制代码class Solution {
public List<List<Integer>> levelOrder(Node root) {
List<List<Integer>> result = new ArrayList<>();
if (root == null) {
return result;
}

//创建队列并存储根节点
Deque<Node> queue = new LinkedList<>();
queue.offer(root);

while (!queue.isEmpty()) {
//存储每层结果
List<Integer> level = new ArrayList<>();
int size = queue.size();
for (int i = 0; i < size; i++) {
Node current = queue.poll();
level.add(current.val);
//添加孩子
if (current.children != null) {
for (Node child : current.children) {
queue.offer(child);
}
}
}
//每层遍历结束,添加结果
result.add(level);
}
return result;
}
}

应用

商品类型翻译这个场景下,基本上和这道题目大差不差,不过是两点小区别:

  • 商品类型是一个属性多一些的树节点
  • 翻译过程直接替换类型名称即可,不需要返回值

来看下代码:

  • ProductCategory:商品分类实体
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
arduino复制代码public class ProductCategory {
/**
* 分类id
*/
private String id;
/**
* 分类名称
*/
private String name;
/**
* 分类描述
*/
private String description;
/**
* 子分类
*/
private List<ProductCategory> children;

//省略getter、setter

}
  • translateProductCategory:翻译商品类型方法
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
arduino复制代码   public void translateProductCategory(ProductCategory root) {
if (root == null) {
return;
}

Deque<ProductCategory> queue = new LinkedList<>();
queue.offer(root);

//遍历商品类型,翻译
while (!queue.isEmpty()) {
int size = queue.size();
//遍历当前层
for (int i = 0; i < size; i++) {
ProductCategory current = queue.poll();
//翻译
String translation = translate(current.getName());
current.setName(translation);
//添加孩子
if (current.getChildren() != null && !current.getChildren().isEmpty()) {
for (ProductCategory child : current.getChildren()) {
queue.offer(child);
}
}
}
}
}

3.前缀和+二分查找:渠道选择

场景

在电商的交易支付中,我们可以选择一些支付方式,来进行支付,当然,这只是交易的表象。

某电商支付界面

在支付的背后,一种支付方式,可能会有很多种支付渠道,比如Stripe、Adyen、Alipay,涉及到多个渠道,那么就涉及到决策,用户的这笔交易,到底交给哪个渠道呢?

这其实是个路由问题,答案是加权随机,每个渠道有一定的权重,随机落到某个渠道,加权随机有很多种实现方式,其中一种就是前缀和+二分查找。简单说,就是先累积所有元素权重,再使用二分查找来快速查找。

题目

先来看看对应的LeetCode的题目,这里用到了两个算法:前缀和和二分查找。

704. 二分查找

  • 题目:704. 二分查找(leetcode.cn/problems/bi…)
  • 难度:简单
  • 标签:数组 二分查找
  • 描述:

给定一个 n 个元素有序的(升序)整型数组 nums 和一个目标值 target ,写一个函数搜索 nums 中的 target,如果目标值存在返回下标,否则返回 -1。

示例 1:

1
2
3
makefile复制代码输入: nums = [-1,0,3,5,9,12], target = 9
输出: 4
解释: 9 出现在 nums 中并且下标为 4

示例 2:

1
2
3
makefile复制代码输入: nums = [-1,0,3,5,9,12], target = 2
输出: -1
解释: 2 不存在 nums 中因此返回 -1

提示:

1. 你可以假设 `nums` 中的所有元素是不重复的。
2. `n` 将在 `[1, 10000]`之间。
3. `nums` 的每个元素都将在 `[-9999, 9999]`之间。

解法

二分查找可以说我们都很熟了。

数组是有序的,定义三个指针,left、right、mid,其中mid是left、right的中间指针,每次中间指针指向的元素nums[mid]比较和target比较:

二分查找示意图

  • 如果nums[mid]等于target,找到目标
  • 如果nums[mid]小于target,目标元素在(mid,right]区间;
  • 如果nums[mid]大于target,目标元素在[left,mid)区间

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
sql复制代码class Solution {
public int search(int[] nums, int target) {
int left=0;
int right=nums.length-1;

while(left<=right){
int mid=left+((right-left)>>1);
if(nums[mid]==target){
return mid;
}else if(nums[mid]<target){
//target在(mid,right]区间,右移
left=mid+1;
}else{
//target在[left,mid)区间,左移
right=mid-1;
}
}
return -1;
}
}

二分查找,有一个需要注意的细节,计算mid的时候:int mid = left + ((right - left) >> 1);,为什么要这么写呢?

因为这种写法int mid = (left + right) / 2;,可能会因为left和right数值太大导致内存溢出。同时,使用位运算,也是除以2最高效的写法。

——这里有个彩蛋,后面再说。

303. 区域和检索 - 数组不可变

不像二分查找,在LeetCode上,前缀和没有直接的题目,因为本身前缀和更多是一种思路,一种工具,其中303. 区域和检索 - 数组不可变 是一道典型的前缀和题目。

  • 题目:303. 区域和检索 - 数组不可变(leetcode.cn/problems/ra…)
  • 难度:简单
  • 标签:设计 数组 前缀和
  • 描述:

给定一个整数数组 nums,处理以下类型的多个查询:

1. 计算索引 `left` 和 `right` (包含 `left` 和 `right`)之间的 `nums` 元素的 **和** ,其中 `left <= right`实现 `NumArray` 类:


+ `NumArray(int[] nums)` 使用数组 `nums` 初始化对象
+ `int sumRange(int i, int j)` 返回数组 `nums` 中索引 `left` 和 `right` 之间的元素的 **总和** ,包含 `left` 和 `right` 两点(也就是 `nums[left] + nums[left + 1] + ... + nums[right]` )**示例 1:**
1
2
3
4
5
6
7
8
9
10
11
scss复制代码输入:
["NumArray", "sumRange", "sumRange", "sumRange"]
[[[-2, 0, 3, -5, 2, -1]], [0, 2], [2, 5], [0, 5]]
输出:
[null, 1, -1, -3]

解释:
NumArray numArray = new NumArray([-2, 0, 3, -5, 2, -1]);
numArray.sumRange(0, 2); // return 1 ((-2) + 0 + 3)
numArray.sumRange(2, 5); // return -1 (3 + (-5) + 2 + (-1))
numArray.sumRange(0, 5); // return -3 ((-2) + 0 + 3 + (-5) + 2 + (-1))

提示:

+ `1 <= nums.length <= 104`
+ `-105 <= nums[i] <= 105`
+ `0 <= i <= j < nums.length`
+ 最多调用 `104` 次 `sumRange` 方法

解法

这道题,我们如果不用前缀和的话,写起来也很简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
arduino复制代码class NumArray {
private int[] nums;

public NumArray(int[] nums) {
this.nums=nums;
}

public int sumRange(int left, int right) {
int res=0;
for(int i=left;i<=right;i++){
res+=nums[i];
}
return res;
}
}

当然时间复杂度偏高,O(n),那么怎么使用前缀和呢?

  • 构建一个前缀和数组,用来累积 (0……i-1)的和,这样一来,我们就可以直接计算[left,right]之间的累加和

前缀和数组示意图

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
ini复制代码class NumArray {
private int[] preSum;

public NumArray(int[] nums) {
int n=nums.length;
preSum=new int[n+1];
//计算nums的前缀和
for(int i=0;i<n;i++){
preSum[i+1]=preSum[i]+nums[i];
}
}

//直接算出区间[left,right]的累加和
public int sumRange(int left, int right) {
return preSum[right+1]-preSum[left];
}
}

可以看到,通过前缀和数组,可以直接算出区间[left,right]的累加和,时间复杂度O(1),可以说非常高效了。

应用

了解了前缀和和二分查找之后,回归我们之前的场景,使用前缀和+二分查找来实现加权随机,从而实现对渠道的分流选择。

渠道分流选择

  • 需要根据渠道和权重的配置,生成一个前缀和数组,来累积权重的值,渠道也通过一个数组进行分配映射
  • 用户的支付请求进来的时候,生成一个随机数,二分查找找到随机数载前缀和数组的位置,映射到渠道数组
  • 最后通过渠道数组的映射,找到选中的渠道

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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
ini复制代码/**
* 支付渠道分配器
*/
public class PaymentChannelAllocator {
//渠道数组
private String[] channels;
//前缀和数组
private int[] preSum;
private ThreadLocalRandom random;

/**
* 构造方法
*
* @param channelWeights 渠道分流权重
*/
public PaymentChannelAllocator(HashMap<String, Integer> channelWeights) {
this.random = ThreadLocalRandom.current();
// 初始化channels和preSum数组
channels = new String[channelWeights.size()];
preSum = new int[channelWeights.size()];

// 计算前缀和
int index = 0;
int sum = 0;
for (String channel : channelWeights.keySet()) {
sum += channelWeights.get(channel);
channels[index] = channel;
preSum[index++] = sum;
}
}

/**
* 渠道选择
*/
public String allocate() {
// 生成一个随机数
int rand = random.nextInt(preSum[preSum.length - 1]) + 1;

// 通过二分查找在前缀和数组查找随机数所在的区间
int channelIndex = binarySearch(preSum, rand);
return channels[channelIndex];
}

/**
* 二分查找
*/
private int binarySearch(int[] nums, int target) {
int left = 0;
int right = nums.length - 1;

while (left <= right) {
int mid = left + ((right - left) >> 2);
if (nums[mid] == target) {
return mid;
} else if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
// 当找不到确切匹配时返回大于随机数的最小前缀和的索引
return left;
}
}

测试一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ini复制代码    @Test
void allocate() {
HashMap<String, Integer> channels = new HashMap<>();
channels.put("Adyen", 50);
channels.put("Stripe", 30);
channels.put("Alipay", 20);

PaymentChannelAllocator allocator = new PaymentChannelAllocator(channels);

// 模拟100次交易分配
for (int i = 0; i < 100; i++) {
String allocatedChannel = allocator.allocate();
System.out.println("Transaction " + (i + 1) + " allocated to: " + allocatedChannel);
}
}

彩蛋

在这个渠道选择的场景里,还有两个小彩蛋。

二分查找翻车

我前面提到了一个二分查找求mid的写法:

1
sql复制代码int mid=left+((right-left)>>1);

这个写法机能防止内存溢出,用了位移运算也很高效,但是,这个简单的二分查找写出过问题,直接导致线上cpu飙升,差点给老三吓尿了。

吓惨了

1
sql复制代码int mid = (right - left) >> 2 + left;

就是这行代码,看出什么问题来了吗?

——它会导致循环结束不了!

为什么呢?因为>>运算的优先级是要低于+的,所以这个运算实际上等于:

1
sql复制代码int mid = (right - left) >> (2 + left);

在只有两个渠道的时候没有问题,三个的时候就寄了。

当然,最主要原因还是没有充分测试,所以大家知道我在上面为什么特意写了单测吧。

加权随机其它写法

这里用了前缀和+二分查找来实现加权随机,其实加权随机还有一些其它的实现方法,包括别名方法、树状数组、线段树 、 随机列表扩展 、 权重累积等等方法,大家感兴趣可以了解一下。

加权随机的实现

印象比较深刻的是,有场面试被问到了怎么实现加权随机,我回答了权重累积、前缀和+二分查找,面试官还是不太满意,最后面试官给出了他的答案——随机列表扩展。

什么是随机列表扩展呢?简单说,就是创建一个足够大的列表,根据权重,在相应的区间,放入对应的渠道,生成随机数的时候,就可以直接获取对应位置的渠道。

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
arduino复制代码public class WeightedRandomList {
private final List<String> expandedList = new ArrayList<>();
private final Random random = new Random();

public WeightedRandomList(HashMap<String, Integer> weightMap) {
// 填充 expandedList,根据权重重复元素
for (String item : weightMap.keySet()) {
int weight = weightMap.get(item);
for (int i = 0; i < weight; i++) {
expandedList.add(item);
}
}
}

public String getRandomItem() {
// 生成随机索引并返回对应元素
int index = random.nextInt(expandedList.size());
return expandedList.get(index);
}

public static void main(String[] args) {
HashMap<String, Integer> items = new HashMap<>();
items.put("Alipay", 60);
items.put("Adyen", 20);
items.put("Stripe", 10);

WeightedRandomList wrl = new WeightedRandomList(items);

// 演示随机选择
for (int i = 0; i < 10; i++) {
System.out.println(wrl.getRandomItem());
}
}
}

这种实现方式就是典型的空间换时间,空间复杂度O(n),时间复杂度O(1)。优点是时间复杂度低,缺点是空间复杂度高,如果权重总和特别大的时候,就需要一个特别大的列表来存储元素。

当然这种写法还是很巧妙的,适合元素少、权重总和小的场景。

刷题随想

上面就是我在项目里用到过或者见到过的LeetCode算法应用,416:4,不足1%的使用率,还搞出过严重的线上问题。

……

在力扣社区里关于算法有什么的贴子里,有这样的回复:

“最好的结构是数组,最好的算法是遍历”。

“最好的算法思路是暴力。”

……

坦白说,如果不是为了面试,我是绝对不会去刷算法的,上百个小时,用在其他地方,绝对收益会高很多。

从实际应用去找刷LeetCode算法的意义,本身没有太大意义,算法题的最大意义就是面试。

刷了能过,不刷就挂,仅此而已。

这些年互联网行业红利消失,越来越多的算法题,只是内卷的产物而已。

当然,从另外一个角度来看,考察算法,对于普通的打工人,可能是个更公平的方式——学历、背景都很难卷出来,但是算法可以。

我去年面试的真实感受,“没机会”比“面试难”更令人绝望。

写到这,有点难受,刷几道题缓一下!


参考:

[1].leetcode.cn/circle/disc…

[2].36kr.com/p/121243626…

[3].leetcode.cn/circle/disc…

[4].leetcode.cn/circle/disc…


备注:涉及敏感信息,文中的代码都不是真实的投产代码,作者进行了一定的脱敏和演绎。


本文转载自: 掘金

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

Flutter 小技巧之升级适配 Xcode15 Crash

发表于 2024-01-08

美好的 2024 从「适配」开始,按照苹果的尿性,2024 春季开始大家将不得使用 Xcode15 来构建 App ,另外根据《2024 的 iOS 的隐私清单》 要求,使用 Flutter 的开发者是无法逃避适配 Xcode15 更新的命运。

另外,众所周知,安装 Xcode15 需要系统升级到 macOS Sonoma ,而 Sonoma 版本无法直接使用 Xcode14 ,所以升级到 Sonoma 系统后你会看到 Xcode 无法打开,不要急,因为升级 Xcode15 现在只要 3G+ ,模拟器(7G+)部分可以下载完成后再手动二次下载,老板再也不用当心我更新 Xcode 时「摸鱼」了。

PS,如果因为特殊情况,你想先升级 Sonoma 又暂时想使用 Xcode14,但是不想降级系统 ,可以在命令行通过 /Applications/Xcode.app/Contents/MacOS/Xcode 执行激活 14 安装,然后通过命令行编译。

那么,接下来开始适配 Xcode15 吧~

Crash 问题

使用 Xcode 15 构建 Flutter 的时候,你可能会有低于 iOS 17 的真机设备上发现 App 运行崩溃,这个问题提交在 #136060 ,直接引发问题的点大部分来自引入的 Plugin,例如 connectivity_plus ,而核心问题其实算是 Xcode 本身的一个 bug。

解决问题的点很简单,就是将 IPHONEOS_DEPLOYMENT_TARGET 设置为 12.0 , 另外有时候 Xcode 可能会删除不受支持的IPHONEOS_DEPLOYMENT_TARGET 值,而导致使用了最新的 (17.0),这将导致二进制文件只能在 iOS 17+ 上启动。

类似问题也体现在如 connectivity_plus 4.xx 的 IPHONEOS_DEPLOYMENT_TARGET 为11.0,而现在connectivity_plus 5.0.0 中也调整到 12 从而规避问题。

另外,如果 Plugin 的 IPHONEOS_DEPLOYMENT_TARGET 影响编译,你也可以在 Profile 下添加 config.build_settings.delete 来强制移除。

1
2
3
4
5
arduino复制代码post_install do |installer|
installer.pods_project.targets.each do |target|
  flutter_additional_ios_build_settings(target)
  target.build_configurations.each do |config|
      config.build_settings.delete 'IPHONEOS_DEPLOYMENT_TARGET' <--- add this

目前这个问题在模拟器上运行一般不会出现,主要是 Xcode 15 带有 IPHONEOS_DEPLOYMENT_TARGET 的 iOS 11(以前 Flutter 默认最低版本)在使用该 Networking 框架时会崩溃 ,具体表现在:

  • 16.x -> 崩溃
  • 17.0.x -> 正常

所以在升级到 Xcode 15 的时候,最好将 App 运行到 16.x 的真机上测试一下是否存在同样问题,目前看来主要是 iOS 的 Network 在存在 target iOS 11 导致,能够用 NWProtocolIP.Metadata ,NWEndpoint.hostPort 去复现,其实编译时也会有一些警告,只是一般都被大家忽略:

1
python复制代码…/Test737672.xcodeproj The iOS deployment target 'IPHONEOS_DEPLOYMENT_TARGET' is set to 11.0, but the range of supported deployment target versions is 12.0 to 17.0.99.

Flutter/Flutter.h file not found

Flutter 在 Xcode 15 上的这个问题提交于 #135099 ,其实算是 Cocoapods 的问题,这个问题可能涉及DT_TOOLCHAIN_DIR cannot be used to evaluate LIBRARY_SEARCH_PATHS, use TOOLCHAIN_DIR instead 。

根据反馈,基本上就是你升级 Cocoapods 升级到 v1.13.0 之后的版本就可以解决,注意升级之后记得手动重新运行 pod install 来确保正确执行,当然,如果你因为某些原因不想升级 Cocoapods ,那么可以临时通过CocoaPods 的 #12012#issuecomment-1659803356 ,在 Profile 下添加相关路径:

1
2
3
4
5
6
7
8
9
10
11
lua复制代码post_install do |installer|
installer.pods_project.targets.each do |target|
  flutter_additional_ios_build_settings(target)
  target.build_configurations.each do |config|
  xcconfig_path = config.base_configuration_reference.real_path
  xcconfig = File.read(xcconfig_path)
  xcconfig_mod = xcconfig.gsub(/DT_TOOLCHAIN_DIR/, "TOOLCHAIN_DIR")
  File.open(xcconfig_path, "w") { |file| file << xcconfig_mod }
  end
end
end

PS ,如果更新 pod 的时候,不知道卡在那里,可以通过 gem install cocoapods -v 1.13.0 -V 后面的 -V 看详细操作日志,如果是网络问题,可以通过 gem sources –add gems.ruby-china.com/ –remove rubygems.org/ 来解决,可以通过 gem sources -l 查看镜像地址。

Library ‘iconv.2.4.0’ not found

如果你在 Xcode 15 上运行发现如下所示错误,不要相信什么 other link flags add "-ld64" ,而是应该在 Build Phases > Link Binary With Libraries 下找到 iconv.2.4.0 ,然后删除它,然后添加 iconv.2,因为在 Xcode15 里,现在只有 iconv.2 。

1
2
3
vbnet复制代码Error (Xcode): Library 'iconv.2.4.0' not found

Error (Xcode): Linker command failed with exit code 1 (use -v to see invocation)

如果还有问题,可以全局搜索 ‘iconv.2.4.0’,在出来的文件里将 iconv.2.4.0 替换为 iconv.2 即可。

最后

好了,Xcode 15 的适配上主要就这些问题,更多的还是《2024 的 iOS 的隐私清单》 部分的适配,属于审核要求,相比起代码上能解决的,平台要求就需要精神领会了,因为这些的要求内容其实很主观理解,总的来说, Flutter & Xcode 15 ,跑得通,可以上。

本文转载自: 掘金

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

localhost和127001的区别是什么?

发表于 2024-01-07

今天在网上逛的时候看到一个问题,没想到大家讨论的很热烈,就是标题中这个:

localhost和127.0.0.1的区别是什么?

前端同学本地调试的时候,应该没少和localhost打交道吧,只需要执行 npm run 就能在浏览器中打开你的页面窗口,地址栏显示的就是这个 http://localhost:xxx/index.html

可能大家只是用,也没有去想过这个问题。

联想到我之前合作过的一些开发同学对它们俩的区别也没什么概念,所以我觉得有必要普及下。

localhost是什么呢?

localhost是一个域名,和大家上网使用的域名没有什么本质区别,就是方便记忆。

只是这个localhost的有效范围只有本机,看名字也能知道:local就是本地的意思。

张三和李四都可以在各自的机器上使用localhost,但获取到的也是各自的页面内容,不会相互打架。

从域名到程序

要想真正的认清楚localhost,我们还得从用户是如何通过域名访问到程序说起。

以访问百度为例。

1、当我们在浏览器输入 baidu.com 之后,浏览器首先去DNS中查询 baidu.com 的IP地址。

为什么需要IP地址呢?打个比方,有个人要寄快递到你的公司,快递单上会填写:公司的通讯地址、公司名称、收件人等信息,实际运输时快递会根据通信地址进行层层转发,最终送到收件人的手中。网络通讯也是类似的,其中域名就像公司名称,IP地址就像通信地址,在网络的世界中只有通过IP地址才能找到对应的程序。

DNS就像一个公司黄页,其中记录着每个域名对应的IP地址,当然也有一些域名可能没做登记,就找不到对应的IP地址,还有一些域名可能会对应多个IP地址,DNS会按照规则自动返回一个。我们购买了域名之后,一般域名服务商会提供一个域名解析的功能,就是把域名和对应的IP地址登记到DNS中。

这里的IP地址从哪里获取呢?每台上网的电脑都会有1个IP地址,但是个人电脑的IP地址一般是不行的,个人电脑的IP地址只适合内网定位,就像你公司内部的第几栋第几层,公司内部人明白,但是直接发给别人,别人是找不到你的。如果你要对外部提供服务,比如百度这种,你就得有公网的IP地址,这个IP地址一般由网络服务运营商提供,比如你们公司使用联通上网,那就可以让联通给你分配一个公网IP地址,绑定到你们公司的网关服务器上,网关服务器就像电话总机,公司内部的所有网络通信都要通过它,然后再在网关上设置转发规则,将网络请求转发到提供网络服务的机器上。

2、有了IP地址之后,浏览器就会向这个IP地址发起请求,通过操作系统打包成IP请求包,然后发送到网络上。网络传输有一套完整的路由协议,它会根据你提供的IP地址,经过路由器的层层转发,最终抵达绑定该IP的计算机。

3、计算机上可能部署了多个网络应用程序,这个请求应该发给哪个程序呢?这里有一个端口的概念,每个网络应用程序启动的时候可以绑定一个或多个端口,不同的网络应用程序绑定的端口不能重复,再次绑定时会提示端口被占用。通过在请求中指定端口,就可以将消息发送到正确的网络处理程序。

但是我们访问百度的时候没有输入端口啊?这是因为默认不输入就使用80和443端口,http使用80,https使用443。我们在启动网络程序的时候一定要绑定一个端口的,当然有些框架会自动选择一个计算机上未使用的端口。

localhost和127.0.0.1的区别是什么?

有了上边的知识储备,我们就可以很轻松的搞懂这个问题了。

localhost是域名,上文已经说过了。

127.0.0.1 呢?是IP地址,当前机器的本地IP地址,且只能在本机使用,你的计算机不联网也可以用这个IP地址,就是为了方便开发测试网络程序的。我们调试时启动的程序就是绑定到这个IP地址的。

这里简单说下,我们经常看到的IP地址一般都是类似 X.X.X.X 的格式,用”.”分成四段。其实它是一个32位的二进制数,分成四段后,每一段是8位,然后每一段再转换为10进制的数进行显示。

那localhost是怎么解析到127.0.0.1的呢?经过DNS了吗?没有。每台计算机都可以使用localhost和127.0.0.1,这没办法让DNS来做解析。

那就让每台计算机自己解决了。每台计算机上都有一个host文件,其中写死了一些DNS解析规则,就包括 localhost 到 127.0.0.1 的解析规则,这是一个约定俗成的规则。

如果你不想用localhost,那也可以,随便起个名字,比如 wodehost,也解析到 127.0.0.1 就行了。

甚至你想使用 baidu.com 也完全可以,只是只能自己自嗨,对别人完全没有影响。

域名的等级划分

localhost不太像我们平常使用的域名,比如 www.juejin.cn 、baidu.com、csdn.net, 这里边的 www、cn、com、net都是什么意思?localhost为什么不需要?

域名其实是分等级的,按照等级可以划分为顶级域名、二级域名和三级域名…

顶级域名(TLD):顶级域名是域名系统中最高级别的域名。它位于域名的最右边,通常由几个字母组成。顶级域名分为两种类型:通用顶级域名和国家顶级域名。常见的通用顶级域名包括表示工商企业的.com、表示网络提供商的.net、表示非盈利组织的.org等,而国家顶级域名则代表特定的国家或地区,如.cn代表中国、.uk代表英国等。

二级域名(SLD):二级域名是在顶级域名之下的一级域名。它是由注册人自行选择和注册的,可以是个性化的、易于记忆的名称。例如,juejin.cn 就是二级域名。我们平常能够申请到的也是这种。目前来说申请 xxx.com、xxx.net、xxx.cn等等域名,其实大家不太关心其顶级域名com\net\cn代表的含义,看着简短好记是主要诉求。

三级域名(3LD):三级域名是在二级域名之下的一级域名。它通常用于指向特定的服务器或子网。例如,在blog.example.com中,blog就是三级域名。www是最常见的三级域名,用于代表网站的主页或主站点,不过这只是某种流行习惯,目前很多网站都推荐直接使用二级域名访问了。

域名级别还可以进一步细分,大家可以看看企业微信开放平台这个域名:developer.work.weixin.qq.com,com代表商业,qq代表腾讯,weixin代表微信,work代表企业微信,developer代表开发者。这种逐层递进的方式有利于域名的分配管理。

按照上边的等级定义,我们可以说localhost是一个顶级域名,只不过它是保留的顶级域,其唯一目的是用于访问当前计算机。

多网站共用一个IP和端口

上边我们说不同的网络程序不能使用相同的端口,其实是有办法突破的。

以前个人博客比较火的时候,大家都喜欢买个虚拟主机,然后部署个开源的博客程序,抒发一下自己的感情。为了挣钱,虚拟主机的服务商会在一台计算机上分配N多个虚拟主机,大家使用各自的域名和默认的80端口进行访问,也都相安无事。这是怎么做到的呢?

如果你有使用Nginx、Apache或者IIS等Web服务器的相关经验,你可能会接触到主机头这个概念。主机头其实就是一个域名,通过设置主机头,我们的程序就可以共用1个网络端口。

首先在Nginx等Web程序中部署网站时,我们会进行一些配置,此时在主机头中写入网站要使用的域名。

然后Nginx等Web服务器启动的时候,会把80端口占为己有。

然后当某个网站的请求到达Nginx的80端口时,它会根据请求中携带的域名找到配置了对应主机头的网络程序。

然后再转发到这个网络程序,如果网络程序还没有启动,Nginx会把它拉起来。

私有IP地址

除了127.0.0.1,其实还有很多私有IP地址,比如常见的 192.168.x.x。这些私有IP地址大部分都是为了在局域网内使用而预留的,因为给每台计算机都分配一个独立的IP不太够用,所以只要局域网内不冲突,大家就可劲的用吧。你公司可以用 192.168.1.1,我公司也可以用192.168.1.1,但是如果你要访问我,就得通过公网IP进行转发。

大家常用的IPv4私有IP地址段分为三类:

A类:从10.0.0.0至10.255.255.255

B类:从172.16.0.0至172.31.255.255

C类:从192.168.0.0至192.168.255.255。

这些私有IP地址仅供局域网内部使用,不能在公网上使用。

–

除了上述三个私有的IPv4地址段外,还有一些保留的IPv4地址段:

用于本地回环测试的127.0.0.0至127.255.255.255地址段,其中就包括题目中的127.0.0.1,如果你喜欢也可以给自己分配一个127.0.0.2的IP地址,效果和127.0.0.1一样。

用于局域网内部的169.254.0.0至169.254.255.255地址段,这个很少接触到,如果你的电脑连局域网都上不去,可能会看到这个IP地址,它是临时分配的一个局域网地址。

这些地址段也都不能在公网上使用。

–

近年来,还有一个现象,就是你家里或者公司里上网时,光猫或者路由器对外的IPv4地址也不是公网IP了,这时候获得的可能是一个类似 100.64.x.x 的地址,这是因为随着宽带的普及,运营商手里的公网IP也不够了,所以运营商又加了一层局域网,而100.64.0.0 这个网段是专门分给运营商做局域网用的。如果你使用阿里云等公有云,一些云产品的IP地址也可能是这个,这是为了将客户的私有网段和公有云厂商的私有网段进行有效的区分。

–

其实还有一些不常见的专用IPv4地址段,完整的IP地址段定义可以看这里:www.iana.org/assignments…

IPv6

你可能也听说过IPv6,因为IPv4可分配的地址太少了,不够用,使用IPv6甚至可以为地球上的每一粒沙子分配一个IP。只是喊了很多年,大家还是喜欢用IPv4,这里边原因很多,这里就不多谈了。

IPv6地址类似:XXXX:XXXX:XXXX:XXXX:XXXX:XXXX:XXXX:XXXX

它是128位的,用”:”分成8段,每个X是一个16进制数(取值范围:0-F),IPv6地址空间相对于IPv4地址有了极大的扩充。比如:2001:0db8:3c4d:0015:0000:0000:1a2f:1a2b 就是一个有效的IPv6地址。

关于IPv6这里就不多说了,有兴趣的可以再去研究下。

关注萤火架构,加速技术提升!

本文转载自: 掘金

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

一个提升代码可读性的小技巧

发表于 2024-01-07

写在开头

如何写好代码,一千个人眼中有一千个哈姆雷特,但是我认为有一点的是写好代码的公理,不可撼动,即对代码可读性的追求。最近在工作中面对了太多可读性不佳的代码,使得对于旧有代码的维护和分析困难重重,心力憔悴。

基于“严于律己,宽以待人”的思想,遇到这种旧有代码尚可以接受,但为了避免未来这种可读性不佳的代码出于己手,我最近开始认真的思考这个问题——如何提高代码的可读性,使得代码变得整洁,甚至赏心悦目。

下面我就从“控制流”的角度分享一下我对提高代码可读性的一些思考。

什么是控制流

什么是控制流?狭义上理解来说控制流是计算机程序中的一种基本概念,它指的是程序执行的顺序和方式。在编码中,控制流用于控制程序的执行流程,包括条件判断、循环、函数调用等。

狭义上的理解过于底层,但从本质上来说,控制流其实就是一段执行流程。如果站在业务系统层面对控制流进行广义层面理解:控制流是对于任意一个系统的任意系统行为的步骤化拆解。

例如对于用户详细信息查询行为进行步骤化拆解,可以拆解为:查询条件校验 -> 查询用户基本信息 -> 根据用户基本信息查询用户详细信息 -> 结果校验 -> 返回。以上对于系统行为的步骤拆解方式,拆解出来的步骤1 -> 步骤2 -> 步骤3 -> ……,总结起来其实就是行为的控制流。

yuque_diagram (5).jpg

即任何系统行为都可以拆解为一段控制流。 而控制流具有以下特性:

  1. 控制流描述目的或动机,对于控制流中的任意流程节点,其只关心该步骤的目的或者动机,与实现目的的过程没有关系,于此相反,控制节点反而是对一段过程的总结。
  2. 控制流与实现细节无关, 对于控制流中的任意流程节点,无论你如何实现它,流程节点的目的和意义都不会发生变化。例如对于用户详情查询控制流中的“查询用户基本信息”节点而言,无论你的实现细节是从缓存中查询还是从数据库中查询,其目的就是“输出用户信息交付给控制流下一流程节点”,不会随着存储源的变化而发生改变。

代码整洁小技巧:基于控制流编程

基于控制流编程可以极大的提升代码的可读性。下面通过一个简单的例子来看看如何找到控制流以此对一个冗长混沌的代码一步一步进行可读性的优化的。

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
java复制代码public class UserService {

@Autowired
private UserInfoDAO userInfoDao;

@Autowired
private UserDetailDAO userDetailDao;

public User queryUserDetail(UserDO queryCondition) {
String username = queryCondition.getUsername();
String userId = queryCondition.getUserId();
if (username == null) {
throw new IllegalArgumentException("用户名不能为空");
}
if (userId == null) {
throw new IllegalArgumentException("用户Id不能为空");
}

Map<String, Object> param = new HashMap<>();
param.put("username", username);
param.put("userId", userId);
UserDO userDO = userInfoDao.queryUserByCondition(param);
if (userDO == null) {
return null;
}

param = new HashMap<>();
param.put("userId", userDO.getId());
UserDetailDO userDetailDO = userDetailDao.queryUserDetailByCondition(param);
if (userDetailDO == null) {
throw new IllegalArgumentException("用户详情查询失败");
}

UserDetail userDetail = new UserDetail();
userDetail.setAddress(userDetailDO.getAddress());
userDetail.setNickName(userDetailDO.getNickName());
userDetail.setPhone(userDetailDO.getPhone());
return new User(userDO.getId(), userDO.getUsername(), userDetail)
}
}

上述用户信息查询代码是我在工作中遇到的一个真实的例子的简化改编,这种“流水账”似的代码在工程开发中比比皆是,造成代码流水账的原因往往是二次扩展时采取在代码的原有基础上进一步堆叠逻辑的方式,让方法的进一步熵增,逐渐混沌,导致一个方法几百上千行,失去维护价值。

描述目的与动机

基于控制流编程,首先是找到一个系统行为的控制流。将系统行为翻译为控制流,这是第一步。如何将一个系统行为翻译成为一段控制流。这其中其实分为两种模式:正向拆解、逆向还原。

正向拆解

正向拆解比较好理解,我们只需要对系统行为进行合理的推演,再根据过往的经验,总结出一段控制流程即可。比如还是这个例子:用户信息的查询行为,根据数据查询的通用模版,分为前置校验、查询、后置校验、返回结果几个步骤,我们可以得到用户查询行为的控制流:查询条件校验 -> 查询用户基本信息 -> 根据用户基本信息查询用户详细信息 -> 结果校验 -> 返回。

逆向还原

逆向还原稍微复杂一点,非常考验程序员的抽象总结能力,即给你一段冗长混沌的流水账过程代码,对其中的控制流进行总结和提取,这种情况一般发生在对一段“散发着陈年香气”的老代码进行重构的时候。但万变不离其宗,依旧是对代码进行整体分析,对动机一致、目的一致的段落进行提取,抽象为控制流节点。将整段又臭又长的代码总结为几个关键步骤,组合成为控制流。

yuque_diagram (6).jpg

套用这个方法,可以通过控制流构建对于上述冗长代码进行可读性上的进行极大优化:

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
java复制代码public class UserService {

@Autowired
private UserInfoDAO userInfoDao;

@Autowired
private UserDetailDAO userDetailDao;

// ----------- 系统行为 -----------

public User queryUserDetail(User queryCondition) {
// 1. 参数校验
validateQueryCondition(queryCondition);
// 2. 查询用户基本信息
UserDO userDO = queryUserInfo(queryCondition.getUsername(), queryCondition.getUserId());
// 3. 查询用户详细信息
UserDetailDO userDetailDO = queryUserDetail(userDO.getId());
// 4. 校验查询结果
validateQueryResult(userDetailDO);
// 5. 返回查询结果
return buildUserResult(userDO.getId(), userDO.getUsername(), userDetailDO);
}

// ------------ 私有方法:步骤实现细节 -----------

private void validateQueryCondition(User queryCondition) {
String username = queryCondition.getUsername();
String userId = queryCondition.getUserId();
if (username == null) {
throw new IllegalArgumentException("用户名不能为空");
}
if (userId == null) {
throw new IllegalArgumentException("用户Id不能为空");
}
}

private UserDO queryUserInfo(String name, String userId) {
Map<String, Object> param = new HashMap<>();
param.put("username", name);
param.put("userId", userId);
UserDO userDO = userInfoDao.queryUserByCondition(param);
if (userDO == null) {
throw new IllegalArgumentException("用户详情查询失败");
}
return userDO;
}

private UserDetailDO queryUserDetail(String userId) {
Map<String, Object> param = new HashMap<>();
param.put("userId", userId);
return userDetailDao.queryUserDetailByCondition(param);
}

private void validateQueryResult(UserDetailDO userDetail) {
if (userDetail == null) {
throw new IllegalArgumentException("用户详情查询失败");
}
}

private User buildUserResult(String id, String username, UserDetailDO userDetailDO) {
UserDetail userDetail = new UserDetail();
userDetail.setAddress(userDetailDO.getAddress());
userDetail.setNickName(userDetailDO.getNickName());
userDetail.setPhone(userDetailDO.getPhone());
return new User(userDetail);
}
}

上述代码看似只是抽了几个方法抽象,实则不然,深挖这背后的动机,这其实隐含着解构过程的思想。为什么声明式编程比命令式编程更加具有优势,究其本质就是其面向目的而不是过程的宗旨,这不仅仅是避免了复杂的过程带来的副作用,而且增加了代码整体的可读性。而此处我们将过程细节进行分类封装于以目的命名的方法中,而在主流程中只留下各种以目的作为命名的方法的编排,使得系统行为的控制流程得以凸显,能够让代码读者迅速抓取整段代码前后逻辑步骤。这其实和声明式编程的内核不谋而合。

以上,我们可以得到提高代码可读性一个很重要的技巧:以描述目的和动机的方式解构过程,而不是盲目堆叠过程

藏在暗处的维护隐患

但仅此而已吗,只是做了几个方法的拆分就足够吗。此时我们只要往代码的长尾运维角度进行思索,就能立刻找到破绽——“流程怎么进行扩展呢?”。目前我们控制流实现方式存在一个很大的隐患:控制流节点的实现依赖于源码细节。

这一点几乎是致命的,由于控制流节点只描述目的和动机,过程的实现方式必然不止一种,假如后期运维时需要对控制流节点的过程进行二次扩展,就会立刻回到堆叠过程的混沌状态,可读性急剧下降。关于这一点,控制流的第二特性:细节无关原则给出了解法。

细节无关原则

早在三四十年前,当面向对象编程还没有兴起的时候,那时的程序员都是采用面向过程的C语言进行技术研发。对于一段程序的控制流来说,想要实现细节无关原则是一件极其困难的事情。缺少了面向对象的多态特性,接口概念的缺失让一段控制代码无可避免的会依赖于源码细节。而正是由于面向对象的一系列编程语言的出现让控制流脱离源码细节成为了可能。这其中的关键秘诀就是依赖反转。

yuque_diagram (8).jpg

在面向过程编程的时代,控制流的特性就是控制流的方向和实现细节的依赖方向是相同的。而不幸的是,我们第一版的控制流的依赖状态也是控制流程和实现细节的依赖方向吻合,也就是说,我们的初版代码依旧没有摆脱面向过程编程的思想桎梏。

yuque_diagram (7).jpg

但别忘了,我们可是使用的java这一面向对象编程的语言利器,我们完全可以通过多态将这种依赖关系进行反转。

yuque_diagram (9).jpg

利用依赖反转实现细节无关原则,体现到代码上如下所示:

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
java复制代码public class UserService {

@Autowired
private UserInfoQueryRepository userInfoQueryRepository;

@Autowired
private UserDetailQueryRepository userDetailQueryRepository;

@Autowired
private UserQueryValidator userQueryValidator;

// ----------- 系统行为 -----------

public User queryUserDetail(User queryCondition) {
// 1. 参数校验
validateQueryCondition(queryCondition);
// 2. 查询用户基本信息
User user = queryUserInfo(queryCondition.getUsername(), queryCondition.getPassword());
// 3. 查询用户详细信息
UserDetail userDetail = queryUserDetail(user.getId());
// 4. 校验查询结果
validateQueryResult(userDetail);
// 5. 返回查询结果
return new User(userDetail);
}

// ------------ 私有方法:步骤实现细节 -----------

private void validateQueryCondition(User queryCondition) {
userQueryValidator.validateQueryCondition(queryCondition);
}

private User queryUserInfo(String userId, String userName) {
User user = userInfoQueryRepository.findUser(userId, userName);
if (Objects.isNull(user)) {
throw new IllegalArgumentException("用户详情查询失败");
}
return user;
}

private UserDetail queryUserDetail(String userId) {
return userDetailQueryRepository.findByUserId(userId);
}

private void validateQueryResult(UserDetail userDetail) {
userQueryValidator.validateUserDetail(userDetail);
}
}

这段代码里面我利用多态做了两件事:

  1. 将校验逻辑抽象为Validator接口,其背后的动机就是将系统中出现的所有校验逻辑内聚至一处单独管理,以此封装所有的校验细节并将细节脱离于控制流,提升系统核心流程的整洁度。但其实是否需要这样做见仁见智,很多时候过度设计并不见得是一件好事。在校验逻辑不会过于复杂且修改频率较低的情况下,保留控制流对于校验逻辑细节的强依赖其实无伤大雅。
  2. 将DAO封装为Repository仓储层,其背后的动机相较于第一点其实更加复杂一点,除了IO逻辑内聚之外,更重要的是对设备无关原则的遵循。

关于设备无关原则,这里展开来多说一点。

为什么UNIX操作系统会将IO设备设计成插件形式呢,因为自20世纪50年代末期以来,我们学到了一个重要经验:程序应该与设备无关。这个经验从何而来呢?因为一度所有程序都是设备相关的,但是后来我们逐渐发现其实真正需要的事情是在不同的设备上实现同样的功能。

——《架构整洁之道》

为什么我一定坚持DAO不能直接侵入系统的核心运行逻辑,因为DAO是外部存储源在业务系统中的集成层,其中避免不了存在和存储源相关的定制化操作逻辑,且这些操作逻辑不包含系统的任何领域信息。直接集成DAO相当于直接依赖于特定的存储设备的源码细节,不仅违背了设备无关原则,且由于缺乏领域信息,所以在调用DAO的时候还要使用大量的硬编码将领域信息翻译为查询操作。

举个例子:

1
2
3
4
5
6
7
8
9
10
java复制代码private UserDO queryUserInfo(String name, String userId) {
Map<String, Object> param = new HashMap<>();
param.put("username", name);
param.put("userId", userId);
UserDO userDO = userInfoDao.queryUserByCondition(param);
if (userDO == null) {
throw new IllegalArgumentException("用户详情查询失败");
}
return userDO;
}

以上代码将查询参数put到map中的操作就是在将领域信息翻译构建为关系型数据库的查询操作。

因此,以设备无关原则作为出发点,同时考虑留落系统领域信息,仓储层在系统中应该作为外部存储的准入标准而存在。我们通过分析系统IO交互诉求在仓储层中构建具体的、包含动机和目的的访问接口,外部存储必须实现仓储层以集成到系统的核心领域中。

yuque_diagram (10).jpg

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
java复制代码public interface UserInfoQueryRepository {
/**
* 根据userId、userName查询用户信息
* @param userId user id
* @param userName user name
* @return user info
*/
User findUser(String userId, String userName);
}

public class UserInfoQueryMysqlRepository implements UserInfoQueryRepository {

private UserInfoDao userInfoDao;

private UserModelConverter modelConverter;

@Override
public User findUser(String userId, String userName) {
Map<String, Object> param = new HashMap<>();
param.put("username", userName);
param.put("userId", userId);
UserDO userDO = userInfoDao.queryUserByCondition(param);
if (userDO == null) {
throw new IllegalArgumentException("用户详情查询失败");
}
return modelConverter.convert(userDO);
}
}

以上代码将系统的用户信息查询诉求抽象为了repository的方法,规定的入参和出参,以此在仓储层留落领域信息,如果需要通过关系型数据库实现用户信息查询功能,就应该实现该仓储层接口,于此封装存储过程的源码细节,而不是直接将源码细节耦合在系统核心领域中。这样的话后期如果我们需要扩展用户信息的IO方式,比如从缓存中获取,可以非常轻松的进行扩展并替换核心领域中的存储来源。

所以仓储层是由其存在于系统中并扮演关键性角色的价值的,但很可惜的是,我在工作中接触的很多工程系统都没有认真构建仓储层,导致后期扩展出现困难,以至于加一个缓存需要大刀阔斧的对系统核心流程进行重构。

这里扯远了,我们回到正题上来,通过多态进行依赖反转,我们使得源码细节脱离了控制流的实现,这样做有什么好处呢?好处就是脱离了控制流实现的源码也可以通过控制流进行过程解构,比如脱离了控制流的Validator、Repository可以分别提炼控制流,以此面对后续可能出现的能力扩展,举个例子,如果Repository层需要进行多级缓存实现用户信息查询功能,我们如何做控制流拆解,如下列代码所示:

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
java复制代码public class UserInfoQueryRepositoryImpl implements UserInfoQueryRepository {
/**
* mysql store
*/
private UserInfoQueryMysqlRepository userInfoQueryMysqlRepository;
/**
* local cache store
*/
private UserInfoQueryLocalCacheRepository userInfoQueryLocalCacheRepository;
/**
* redis store
*/
private UserInfoQueryRedisRepository userInfoQueryRedisRepository;

@Override
public User findUser(String userId, String userName) {
// 1. 在各存储源中查询用户信息
User user = findUserFromMultilevelCache(Arrays.asList(
userInfoQueryLocalCacheRepository,
userInfoQueryRedisRepository,
userInfoQueryMysqlRepository
), userId, userName);

// 2. 校验查询结果
validateResult(user);

// 3. 返回结果
return user;
}

private User findUserFromMultilevelCache(List<UserInfoQueryRepository> repositories, String userId, String userName) {
for (UserInfoQueryRepository repository : repositories) {
User user = repository.findUser(userId, userName);
if (Objects.nonNull(user)) {
return user;
}
}
return null;
}

private void validateResult(User user) {
if (Objects.isNull(user)) {
throw new IllegalArgumentException("结果查询失败");
}
}
}

控制流对于过程的解构其实就是一个递归的过程,逐步将长流程解构为一个个独立存在,保证可扩展性的流程子节点。

yuque_diagram (11).jpg

以上,我们可以得到提高代码可读性一个很重要的技巧:依赖反转,细节无关。

见微知著:流程编排

我们在满足了“描述目的和动机”、“细节无关原则”的控制流实现基础上再深入的思考一层,其实控制流的流程节点本身何尝不是一种细节,只要是细节就有破除硬编码形态、灵活化的余地。此时我们自然而然就会想到流程节点可以通过动态标准化编排的方式出现在系统行为中,而不是直接以硬编排的方式毫无标准规范的留落于方法的实现类。

于是基于流程编排的系统构建方式便浮现出来。基于流程编排,我们可以对控制流的流程节点进行标准化,规范入参、出参、上下文,同时规范流程节点之间的数据流动格式以及流程的触发逻辑。以此实现流程的可配置化,甚至是可视化。

基于流程编排,用户信息的查询逻辑可能就变成了如下所示:

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

private ProcessorFactory processorFactory;

private ProcessorChainBuilder processorChainBuilder;

private ChainInvoker chainInvoker;

// ----------- 系统行为 -----------

public User queryUserDetail(User queryCondition) {
ProcessorChain<User, User> processorChain = processorChainBuilder
.process(processorFactory.get("queryConditionValidateProcessor"))
.process(processorFactory.get("queryUserInfoProcessor"))
.process(processorFactory.get("queryUserDetailProcessor"))
.process(processorFactory.get("queryResultValidateProcessor"))
.build();

return chainInvoker.invoke(queryCondition, processorChain);
}
}

当然,还有更多更好的编排方式,比如基于xml配置化或者页面可视化配置。

关于流程编排多说一点,流程编排是一套非常重的系统构建体系,是否选择使用流程编排要经过深思熟虑的考量,不是所有系统,所有应用场景都适合流程编排,比如上述颗粒度细化到方法实现层面的流程编排非常容易落入过度设计的陷阱之中,反而无故增加系统复杂度,加重了运维负担。流程编排最适用的应用方式我认为是业务用例维度的,即系统行为的组合场景下使用,并且该用例有着较为明显的控制流程,不需要经过研发人员耗费大量精力寻找流程节点之间的边界,比如电商平台的商家入驻、网站的用户注册等流程化明显的业务用例。

写在最后

上述的所有内容涉及到了4种代码的组织方式:

  • 过程堆叠模式
  • 基于目的和动机描述的控制流模式
  • 基于目的和动机描述并且细节无关的控制流模式
  • 基于流程编排的控制流模式

对于这四种代码组织方式应该辩证看待,不是说后一种方式就比前一种好,细节无关的控制流就一定优于细节相关的控制流,因为系统复杂度随着代码过程的抽象和拆解增加了,而系统的复杂度的增加不一定就意味着可读性的提升,两者不是正相关关系,而是非线性的:随着系统复杂度的增加可读性会提升,但随着系统复杂度达到一个阈值,复杂度继续增加时可读性反而降低了。

image.png

对于简单的方法,过程堆叠无伤大雅;如果一个方法的过程堆叠到了几百行,可以试试对过程基于动机和目的进行过程拆解;如果扩展频率很高,试着用依赖反转对细节进行抽象和封装;如果说系统的业务用例流程化明显,不妨采用流程编排的方式构建系统。

我们对于系统代码组织的方式其实并不固定,但唯一不变的宗旨是对高可读性代码的追求。

以上。

本文转载自: 掘金

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

手把手教你实现js懒加载

发表于 2024-01-06

js懒加载问题是个非常容易被问到的面试点,如果你还不清楚如何实现,不妨一起学习下

懒加载

所谓懒加载就是需要使用你的时候再加载,通常就是浏览网页的时候,你下拉很多图片的网页,边拉边加载,你可以看得出来下面的图片在展现你面前之前是没有加载出来的

GIF 2023-12-30-星期六 19-03-54.gif

自己实现

我们自行去网络上搜10张图,然后给10张图加个样式,让它们成一个块级元素,从上到下显示

1
2
3
4
5
6
7
xml复制代码<style>
.img-item {
display: block;
height: 300px;
margin-top: 50px;
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
ini复制代码<body>
<img class='img-item' src="https://th.bing.com/th?id=ORMS.5053ecdbef05fa7726aa489d27b52e40&pid=Wdp&w=612&h=304&qlt=90&c=1&rs=1&dpr=1.25&p=0" alt="">
<img class='img-item' src="https://th.bing.com/th?id=ORMS.c5db2c88af1a76f18d0efe02fcded91d&pid=Wdp&w=612&h=304&qlt=90&c=1&rs=1&dpr=1.25&p=0" alt="">
<img class='img-item' src="https://th.bing.com/th?id=ORMS.c5129de8701c4a933d92cd6bf832b233&pid=Wdp&w=300&h=156&qlt=90&c=1&rs=1&dpr=1.25&p=0" alt="">
<img class='img-item' src="https://th.bing.com/th?id=ORMS.afe7f6448d6eba0055cd8ce9ac9fdf62&pid=Wdp&w=300&h=156&qlt=90&c=1&rs=1&dpr=1.25&p=0" alt="">
<img class='img-item' src="https://th.bing.com/th?id=ORMS.e168b9c5da30772083104ed0f4ef0ecf&pid=Wdp&w=612&h=304&qlt=90&c=1&rs=1&dpr=1.25&p=0" alt="">
<img class='img-item' src="https://th.bing.com/th?id=ORMS.8025ce5a977b3826589022cede69e110&pid=Wdp&w=300&h=156&qlt=90&c=1&rs=1&dpr=1.25&p=0" alt="">
<img class='img-item' src="https://th.bing.com/th?id=ORMS.a58ae29e32e20a27d498eed19528ee3c&pid=Wdp&w=300&h=156&qlt=90&c=1&rs=1&dpr=1.25&p=0" alt="">
<img class='img-item' src="https://th.bing.com/th?id=ORMS.2049b527600b31b2cd863a380be59848&pid=Wdp&w=300&h=156&qlt=90&c=1&rs=1&dpr=1.25&p=0" alt="">
<img class='img-item' src="https://th.bing.com/th?id=ORMS.9f51912b8b6c19a9891b380ad526db85&pid=Wdp&w=612&h=304&qlt=90&c=1&rs=1&dpr=1.25&p=0" alt="">
<img class='img-item' src="https://th.bing.com/th?id=ORMS.1b6375ea147b5704f9d073a326e1fc2a&pid=Wdp&w=300&h=156&qlt=90&c=1&rs=1&dpr=1.25&p=0" alt="">
</body>

1.png

想要让这些图片进入到可视区内加载,进入之前不加载,就需要去动src,src有值了就一定会去加载,所以我们可以先把url放到另一个伪属性中去,等到了图片进入到可视区内再去把伪属性的url赋给src

如何获取元素的几何信息?

2.png

top和bottom都是自己到最顶部的距离,均已浏览器顶部为准

querySelector是获取第一个元素,你会发现getBoundingClientRect给你拿到了bottom和top属性

只要每个图片的top值比窗口的高度小,那么就代表着图片进入可视区范围内

1
2
3
4
5
6
ini复制代码imgs.forEach(el => {
let rect = el.getBoundingClientRect()
if(rect.top < viewHeight) {
// 赋值逻辑……
}
})

data-original的值赋给src需要先交给第三方,这个第三方也需要是image标签,正常情况是需要createElement添加一个标签的,但是image标签非常之特殊,它本身就具有构造函数

1
2
ini复制代码let image = new Image()
image.src = el.dataset.original

data-有个专有写法就是dataset,因此data-original就是dataset.original

接下来,通过load事件来确保image这个标签在创建完成之前是不会执行赋值的

1
2
3
ini复制代码image.onload = function() {
el.src = image.src
}

这里肯定有小朋友要提出一个问题了,为何不直接写el.src = el.dataset.original。确实,这样写也没有问题,只不过浏览器对图片是有个缓存机制的,靠第三方的image标签纯粹是为了onload,看图片是否被浏览器加载完毕,加载完毕才把值赋过去,如果直接赋值,加载的时候是空白的,加载是需要时间的。Image对象的src属性设置为要加载的url时,浏览器会异步开始加载该图片,这样图片可能已经被缓存了,从而提高了加载速度

图片一旦加载就不需要二次加载了,所以我们把data-original这个属性移除掉

1
arduino复制代码el.removeAttribute('data-original')

因为imgs数组获取的时候就是获取带有data-original属性的图片

接下来就需要让用户滚动页面去触发这个事件,所以来一个监听滚动事件

1
javascript复制代码document.addEventListener('scroll', lazyLoad)

最终代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
xml复制代码<script>
let viewHeight = window.innerHeight
function lazyLoad() {
let imgs = document.querySelectorAll('img[data-original]')
imgs.forEach(el => {
let rect = el.getBoundingClientRect()
if(rect.top < viewHeight) {
let image = new Image()
image.src = el.dataset.original
image.onload = function() {
el.src = image.src
}
el.removeAttribute('data-original')
}
})
}
lazyLoad() // 页面初始加载时调用一次
document.addEventListener('scroll', lazyLoad)
</script>

边下划,src边被赋值,最终实现效果如下

GIF 2023-12-30-星期六 19-09-56.gif

最后

因为图片是非常占用浏览器性能的,懒加载的意义就是提高页面加载速度,降低资源浪费,提升用户体验。

懒加载的核心原理其实就是通过一个伪src属性,当图片到达了可视区范围内,就将伪src属性赋值给src

另外有不懂之处欢迎在评论区留言,如果觉得文章对你学习有所帮助,还请”点赞+评论+收藏“一键三连,感谢支持!

本次学习代码已上传至本人GitHub学习仓库:github.com/DolphinFeng…

本文转载自: 掘金

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

华为今年的校招薪资。。。

发表于 2024-01-06

华为薪资情况更新与分析

这是一篇临时加更的文章,主要对小伙伴的纠结进行一些答疑。

至于原定今天要谈的,阿里和京东那小小的十个亿,也只能稍稍往后了。

节前我们分享了最新的 华为校招开奖情况,当时时间是 2023/12/23,冬至前后。

当时给出的薪资岗位统计数据,来源于牛客网在小红书上的分享。

我这里调选一些和咱们小伙伴相关性高的岗位,再简单提一下,

  • AI 工程师 - 32k \ (14-16) - 年包约 48W(不算加班费)- 211硕士 - 上海*
  • 软件开发 - 25k \ (14-16) - 年包约 36W(不算加班费)- 985硕士 - 上海*
  • 大模型研究员 - 年包 30W(不算加班费)- 211硕士 - 武汉
  • OD - 12k - 年包 14.4W(不算加班费)- 985本 - 深圳

在此基础上,增加 1-2k 的城市浮动:

如果参考资料 base 是上海,那么折算到非一线城市要往下减 2k;

如果参考资料 base 是东莞,那么折算到一线城市可以往上加 2k。

另外,这里特殊强调了一下,这是不算加班费统计出来的年包收入。

因为上次将开奖情况发出之后,不少华为的小伙伴现身说法:加班有加班费(OD 也有),但加班是长期的,且强制的。

因此拉长到一年来看,这部分收入对总收入影响是较大的。

在这种节奏氛围下,offer 的抉择是需要结合自身的承受能力来判定的。

对于「毕业不久」或是「经济环境想要通过就业来提升一个阶级」的小伙伴,有付出就有回报,且时薪不算低的华为,确实可以作为排名前几的候选方案。

但需要清晰认识到:这种愿意给时间就给你换钱的企业,既然有这种规则,那么交换的权利自然并不在员工手上,进入了这样的环境,意味着你需要长期用 5×85 \times 85×8 以外的时间去换钱。这可不是一次两次的事情,是一个长期关系。

这里可能会有涉世未深的同学发问:为啥华为要这么搞,反正都要给加班费,为什么要把人的工作量拉满,用加班的钱多请一个人不行吗?

这是一个很有意思的问题。

具体的数据没有,但用简单的「劳动市场规则」和「经济原则」认知就可以分析出答案。

公司的用人成本除了可划分的工资以外,还有另外一部分成本是无法划分,跟人头绑定的,以及人数增加带来的其他隐形成本。

我们可以先忽略其他隐形成本,既然华为决定决定给加班费而不是多请一个人,必然是”基本满足”如下不等式:

A人头成本+A工资≤B人头成本+B工资+C人头成本+C工资A人头成本 + A工资 \leq B人头成本 + B工资 + C人头成本 + C工资A人头成本+A工资≤B人头成本+B工资+C人头成本+C工资
因此华为才不会把 A 的活拆成两份,去请成 B 和 C 两个人。

更残忍的现实是,哪怕有一天上述公式的关系发生翻转,华为也大概率不会转向节奏。

因为,在质量保证的情况下,一个活由两个人共同完成相比于由一个人独自完成,沟通成本和维护依赖链,都会成倍增加。

更何况,控制人数接近于”刚好”的范围,可以最大化提高岗位薪资,这对于「降低老员工流失率」以及「提高对新员工的吸引力」都有正面作用。

在进一步的对外企业形象上,也会因为这样的节奏氛围被贴上,至少在资本看来是正面的”狼性”标签。

因此,上述其实只是一个伪等式,上面只是给大家一个大概印象,我写的也只是”基本满足”。

如果华为现在转型为养老厂,将会有大量好处被变为成本负债,出现在等式右边,最终成为否决转型为养老厂的经济理由。

综上,其实华为作为一家成熟企业,而且可能是全中国对外承受压力最大的企业,选择把弦拉满绷紧,其实并不难理解。

对我们普通人而言来说,在钟意华为薪资待遇的同时而又埋怨节奏氛围,是没有意义的。

因为是有了会扎人的蜜蜂,才有的蜜糖。

我们只要理解清楚这种劳务关系,搞清楚自己能否接受这样的长期关系,就可以了。

……

一转眼,又到了我们的主线任务,看到一道和「华为」相关的题目。

除了华为,近期还在「字节算法岗」一面要求手撕。

题目描述

平台:LeetCode

题号:739

给定一个整数数组 temperatures,表示每天的温度,返回一个数组 answer,其中 answer[i] 是指对于第 i 天,下一个更高温度出现在几天后。如果气温在这之后都不会升高,请在该位置用 0 来代替。

示例 1:

1
2
3
ini复制代码输入: temperatures = [73,74,75,71,69,72,76,73]

输出: [1,1,4,2,1,1,0,0]

示例 2:

1
2
3
ini复制代码输入: temperatures = [30,40,50,60]

输出: [1,1,1,0]

提示:

  • 1<= temperatures.length<=1051 <= temperatures.length <= 10^51<= temperatures.length<=105
  • 30<= temperatures[i] <=10030 <= temperatures[i] <= 10030<= temperatures[i] <=100

单调栈

抽象题意为 : 求解给定序列中每个位置(右边)最近一个比其大的位置,可使用「单调栈」来进行求解。

具体的,我们可以从前往后处理所有的 temperatures[i]temperatures[i]temperatures[i],使用某类容器装载我们所有的「待更新」的位置(下标),假设当前处理到的是 temperatures[i]temperatures[i]temperatures[i]:

  • 若其比容器内的任意位置(下标)对应温度要低,其必然不能更新任何位置(下标),将其也加入容器尾部(此时我们发现,若有一个新的位置(下标)加入容器,其必然是当前所有待更新位置(下标)中的温度最低的,即容器内的温度单调递减);
  • 若其价格高于容器内的任一位置(下标)对应温度,其能够更新容器位置(下标)的答案,并且由于我们容器满足单调递减特性,我们必然能够从尾部开始取出待更新位置来进行更新答案,直到处理完成或遇到第一个无法更新位置。

由于我们需要往尾部添加和取出元素,因此容器可使用「栈」。

Java 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Java复制代码class Solution {
public int[] dailyTemperatures(int[] temperatures) {
int n = temperatures.length;
int[] ans = new int[n];
Deque<Integer> d = new ArrayDeque<>();
for (int i = 0; i < n; i++) {
while (!d.isEmpty() && temperatures[d.peekLast()] < temperatures[i]) {
int idx = d.pollLast();
ans[idx] = i - idx;
}
d.addLast(i);
}
return ans;
}
}

C++ 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
C++复制代码class Solution {
public:
vector<int> dailyTemperatures(vector<int>& temperatures) {
int n = temperatures.size();
vector<int> ans(n);
deque<int> d;
for (int i = 0; i < n; i++) {
while (!d.empty() && temperatures[d.back()] < temperatures[i]) {
int idx = d.back();
ans[idx] = i - idx;
d.pop_back();
}
d.push_back(i);
}
return ans;
}
};

Python3 代码:

1
2
3
4
5
6
7
8
9
10
11
12
Python复制代码class Solution:
def dailyTemperatures(self, temperatures: List[int]) -> List[int]:
n, he, ta = len(temperatures), 0, 0
ans, stk = [0] * n, [-1] * n
for i in range(n):
while he < ta and temperatures[stk[ta - 1]] < temperatures[i]:
ta -= 1
idx = stk[ta]
ans[idx] = i - idx
stk[ta] = i
ta += 1
return ans

TypeScript 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
TypeScript复制代码function dailyTemperatures(temperatures: number[]): number[] {
const n = temperatures.length
const ans = new Array<number>(n).fill(0)
const stk = new Array<number>(n).fill(-1)
let he = 0, ta = 0
for (let i = 0; i < n; i++) {
while (he < ta && temperatures[stk[ta - 1]] < temperatures[i]) {
const idx = stk[--ta]
ans[idx] = i - idx
}
stk[ta++] = i
}
return ans
};
  • 时间复杂度:O(n)O(n)O(n)
  • 空间复杂度:O(n)O(n)O(n)

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

欢迎关注,明天见。

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

本文转载自: 掘金

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

支付行业黑话:支付相关必知术语一网打尽 1 通用 2 支

发表于 2024-01-06

每个行业都自己的行业黑话,官方名词叫“术语”。如果你是支付行业的新手或正尝试深入了解这个复杂但又充满机遇的领域,那么掌握行业术语是打开这扇门的钥匙。

支付系统无论需求文档还是技术方案文档,都充斥着专业词汇和行业黑话,这些术语有的直观易懂,有的则晦涩难解。在这篇文章中,我们将揭开支付系统中常用术语的神秘面纱,用最通俗的语言解释这些专业词汇,帮助你一网打尽这些必知的术语。

需要补充说明的是:这些术语是个人从业经验的总结,多用于聊需求或设计时使用,严谨性可能不及一些权威书籍,但是足够实用。此外,一些术语在不同的领域有不同含义,下面的定义只适用在线支付系统场景下。

  1. 通用

支付

Payment。用户通过在线支付系统将资金转移给PSP。

在后续的清分结算阶段,支付平台会把这笔支付的钱分成平台手续费和商户待结算款,手续费留在支付平台,待结算款会在后续结算给商户。

退款

Refund。支付系统将钱退还给用户。通常在取消交易或退回商品后发生。有全额退款和部分退款。有些平台会退款退费,有些平台退款不退费。

撤销

Cancel/Void。通常在当天(一般是日切前,还没有清算)取消一笔交易。在预授权/请款模式中,撤销用于取消预授权未请款那部分的金额。有全额撤销和部分撤销。

与退款的区别:

1)撤销一般是支付当天的逆向行为,退款一般是支付第二天的逆向行为。这里的当天一般是指会计日,而不是自然日。

2)撤销通常退手续费,退款一般不退手续费。但不是绝对的。

另外,现在大部分的电子钱包把退款和撤销合并成一个退款接口。

冲正

与撤销类似。来源于POS机时代,在交易发生当天用户申请退货退款时,或POS机支付超时的情况下,操作员发起一笔冲正,收单机构如果已经扣款就会退回。

收单产品

Acquiring Products。支付服务提供商(PSP)为商户提供的支付服务。这些支付服务需要基于交易订单,在系统中有明确的买卖行为发生。不同的公司因为商业策略不同,开出的收单产品也会有所不同。

担保交易

用户先把钱给到支付平台,用户确认收货再给钱到商家。

即时到账

付款资金直接转移到收款账户,不需要用户二次确认。

资金产品

Funds Products。通常指与处理客户资金流动相关的服务,包括充值、转账、提现、代发等功能。

充值

Topup。往在线支付系统账户增加资金的行为。比如往支付宝、微信账户充钱。

转账

Transfer。将资金从在线支付系统一个账户转到另外一个账户的过程。可以是同一支付平台内部转,也可以是跨平台或银行转。

特殊的,个人对个人的转账,简称:P2P转账。

提现

Withdraw。用户将在线支付系统账户中的余额提取到指定的银行账户。

代发

公司或组织通过在线支付系统将资金直接转入个人账户。有代发到余额和代发到卡。

T日/T+N

T日:交易实际发生的日期。

T+N:从交易日发生之后N个工作日。比如T+1清算,就是指交易完成后,第二天进行清算。

风控

交易的风险控制。欺诈检测、信用评估、合规检查等。

  1. 支付方式与渠道

支付方式

终端用户可见的支付手段或工具。这个比较抽象,每个公司定义可能不一样。但一般来说,信用卡、借记卡、电子钱包、VA、OTC、信用付(比如花呗、白条,国外一般称为Buy Now Pay Later)等都可以抽象成支付方式。

渠道

一般指外部提供金融服务能力的金融机构。比如支付渠道,外汇渠道,流出渠道等,分别提供支付、外汇兑换、提现打款等金融服务。

快捷支付

通过在支付系统中提前绑定银行卡信息,快速完成支付交易,不需要每次都填写完整的卡详情。

代扣

个人授权商户直接去支付平台或银行进行扣款,不需要用户参与支付过程。比如水电煤代扣,滴滴打车代扣。

卡支付

使用信用卡或借记卡支付。

网银支付

需要跳转到银行提供的支付页面,输入银行账户信息进行支付。

VA支付

Virtual Account。虚拟账户是银行临时生成的一个账户,与用户和订单临时关联。一般在东南亚的支付场景,或者国际收款场景下使用得比较多。

东南亚很多人没有银行卡,但又要在线买东西,就可以临时生成一个VA。以支付流程为例:用户选择某个银行的VA支付方式,支付系统调用银行接口,先为用户订单生成一个VA号,用户拿着VA去钱下ATM机转账,银行收到钱后,通知支付系统,支付系统再通知商户,商户给用户发货。

OTC支付

Over-the-Counter。柜台支付。一般指大型连锁线下零售商提供的支付能力,比如7-11或肯德基提供的支付能力。整体流程和VA很像。区别在于VA通常指银行提供的。

同样以支付流程为例:用户选择某个OTC服务提供商的OTC支付方式,比如7-11,支付系统调用7-11接口,先为用户订单生成一个OTC码,用户拿着OTC码去钱下7-11柜台拿现金充值,7-11收到钱后,通知支付系统,支付系统再通知商户,商户给用户发货。

第三方钱包/电子钱包支付

非银行机构提供的在线支付服务。比如支付宝、微信支付,国外的PayPal等。

令牌化支付

将敏感数据(比如信用卡号)替换成唯一的识别码(令牌),在支付过程中传输令牌进行支付。减少信息泄露的风险。

可以认为这是卡组提供的绑卡支付能力。用户绑定后,卡组返回一个TOKEN给支付系统,支付系统拿着TOKEN去支付,外部支付网关先把TOKEN换成真实银行卡信息,再去发卡行进行扣款,因为发卡行只识别明文卡信息。

二维码支付

通过二维码发起支付交易。广泛应用于移动支付场景。

正扫

商户生成二维码,用户扫商户二维码。

反扫

消费者生成二维码,商户扫消费者的二维码。

  1. 子系统/子应用分类

开放网关

主要对接商户,比如下单、支付等接口入口。通常要求有比较高的安全性。部分公司可能会把移动端网关、PC门户网关、商户通知等能力集成在开放网关,也可能会单独拆出部署。

收单结算

负责把商户的单收下来,并给商户发起结算。承担的收单产品包括有:线上收单,线下收单,担保交易、即时到账等,每个公司的商业策略不同,开出的收单产品会有差异。

资金产品

承担无买卖标的的纯资金转移能力。典型的有:充值、转账、提现、代发。和支付的区分在于支付是有买卖标的,而资金产品没有。也就是在系统中没有买卖记录发生,但在线下可能有。

收银核心

渲染可用支付方式。包括查询账户是否有余额,查询营销是否有营销券,查询渠道网关是否有可用的外部渠道,最后组合成可用支付方式,供前端渲染。

支付引擎

负责真正的扣款或转账。有些公司叫支付核心,或资产交换。个人认为资产交换更合适,因为无论对于支付、退款、充值、转账等各种交易,本质都是把资产从一个账户交换到另外一个账户。

渠道网关

负责去外部渠道扣款。通常还会提供渠道路由、渠道咨询等能力,做得细的公司可能下面再细分为渠道产品,报文网关和文件网关。

会员平台

管理会员的注册、登录、密码、实名认证等。

商户平台

管理商户的入驻、登录、交易管理等。

产品中心

管理平台对外提供的产品能力。一般大的支付系统才会独立成一个子系统。

资金账务

负责账户开立,记账等。

会计中心

会计科目管理、分录管理、日切管理。

对账中心

负责明细对账和资金对账。

营销平台

提供满减、红包等营销工具。

风控平台

针对账户和交易,提供实时、离线风控,控制平台的风险。

运营平台

订单管理、渠道管理、产品管理等综合运营工具。

数据平台

主要用于数据汇总和分析。分布式部署后,数据都在各子系统中,需要汇总到数据平台用于经营分析。

卡中心

负责管理用户的绑卡信息。需要经过PCI认证。

额度中心

累计用户、商户的额度,通常有日、月、年等各种分类。

外汇平台

负责外汇报价和兑换。

流动性与调拨中心

一些跨境支付公司,在多个国家多个银行有头寸,各头寸之间经常需要做流动性管理,提高资金利用率。

差错中心

负责差错处理。比如渠道退款失败,需要通过其它的方式退给用户。

拒付中心

处理用户的拒付和举证。在跨境支付场景下,信用卡用户联系发卡行说卡被盗刷或商品没有收到,或商品有问题等,拒绝支付给商户。

  1. 监管合规

支付服务提供商

PSP,Payment Service Provider。

为商户提供支付解决方案的公司。银行、第三方支付公司都属于支付服务提供商。

收单机构

Acuquiring Institution/Acquirer。负责处理和清算商户交易的金融机构或支付服务提供商(PSP)。

牌照

License。由监管机构或政府授予的官方许可,允许持牌者在特定的法律框架和规定内经营某种金融服务或支付业务。支付相关的牌照主要有:

银行牌照:允许公司提供传统银行服务,例如接受存款、提供贷款等。

支付机构牌照:允许公司提供支付服务,如支付处理、资金转账、电子钱包服务等。

电子货币机构牌照:允许公司发行和管理电子货币,如预付卡、电子钱包中的资金等。

PA牌照

Payment Account。也就是储值牌照,用户可以先充值,后使用余额进行支付。

PG牌照

Payment Gateway。也就是支付网关牌照,只能调用外部渠道支付,不能有余额。

一般支付机构都会同时申请PA+PG牌照。

展业

在指定的区域开展业务。通常和牌照申请、监管合规等放在一起。

监管与合规

支付或金融机构遵守展业所在国家相关法律、规章、标准和行业准则的要求。需要定期向监管机构报告业务活动和财务状况,以证明企业的合规性。

反洗钱

Anti-Money Laundering, AML。旨在预防、识别和打击通过金融系统将非法所得洗白变成合法资金的行为。

反欺诈

Anti-Fraud。旨在预防、检测、调查和遏制欺诈行为。在支付领域,通常是风控系统负责。常见有信用卡盗用,账户盗用等。

  1. 卡支付

预授权

Authorization,简称Auth。对用户信用卡取得一个临时授权,对应资金将会冻结,直到请款或撤销(主动或过期自动撤销)

请款

Capture。在预授权之后,商户向发卡行请求将预授权的资金正式扣除。有全额请款和部分请款。

AUTH-CAPTURE模式

预授权-请款模式。先冻结用户的资金,在请款时再实际扣款。

SALES模式

预授权-请款在一个步骤中完成。就是直接从信用卡账户中扣款。

拒付

Chargeback。当用户对信用卡交易提出异议(比如认为被盗刷),卡组一般会先将钱退回给用户,并扣除商户的钱,同时启动调查,通知商户有拒付发生。

拒付举证

Chargeback Representment。商户收到拒付通知后,如果认为是用户真实支付行为或商品没有问题,就会提供相关证明材料给卡组,这一过程称为拒付举证。如果卡组认为商户证据有效,拒付就会被撤销,重新扣回用户的钱并结算给商户。

拒付反转

Chargeback Reverse。商户进行拒付举证后,卡组认为商户证据有效,就会把拒付扣除的资金还给商户,同时撤销用户的退款。这一过程称为拒付反转。

MOTO支付/2D支付

非现场支付,以前是通过邮件或电话进行信用卡支付。在线支付系统里,通常指不需要跳转到独立核身页面进行核身的支付方式。支付成功率高,风险较大。

3DS支付

额外增加一层安全验证,需要跳转到一个独立核身的页面输入密码、OTP、账单地址等信息验证身份。对平台来说安全性高,但是支付成功率低。

3DS1.0/3DS2.0

3DS1.0通常是由发卡行来做身份验证。

3DS2.0由独立的第三方来进行身份验证。安全性更高。率先在欧洲启动。

卡BIN

卡号前6-8位号码,用于识别发卡行和卡种类。

发卡行

发行支付卡给用户的银行或金融机构。

收单行

为商户提供收单服务的银行或金融机构。

卡组/卡品牌

信用卡或借记卡交易网络的组织。比如VISA,MASTERCARD,银联等。

PCI认证

PCI DSS(Payment Card Industry Data Security Standard)。支付卡行业数据安全标准。保存银行卡的子系统需要通过PCI认证。认证由专业的第三方机构执行,需要定期核查。

支付系统中只有PCI域可以保存用户的卡明文信息(也是需要加密存储),其它非PCI子域不能保存用户的卡明文信息(加密存储也不允许)

渠道路由

当有多个外部渠道可以支持同一笔支付交易时,选择出最优的一条渠道。通常基于成功率、成本、卡类型、金额等因子做路由决策。

  1. 外汇

锁汇

锁定汇率。在电商场景下,电商平台不愿意承担汇损风险,就直接在支付成功后,使用支付金额向外汇机构锁定一个固定汇率。

换汇

实际购汇。在电商场景下,电商平台不愿意承担汇损风险,在支付渠道清算后,使用原锁汇时的汇率,正式向外汇机构购汇,完成交割。

即期

按当前市场汇率立即交换两种货币。一般是2个工作日内交割。一般是场内交易。

远期

双方约定以当前确定汇率(远期汇率)在将来某天交换货币。一般是场外交易。

掉期

双方先通过即期交易,然后约定在远期时再换回来。一般是场外交易。

结汇入境

以入境中国为例。先在境外兑换成离岸人民币,再结算到境内银行账户或金融机构账户。

入境结汇

以入境中国为例。先以外币入境,在境内再兑换成人民币。

  1. 资金账务

会计日

标识一笔交易在会计层面的日期。交易需要跨多个子系统,完成一笔交易的时间在不同子系统中是不一样的。与自然日可能有差异,特别是交易在零点附近的时候,所以统一使用会计日来明确交易应该计入哪一天。有可能一笔交易发生在2023.12.23这天,但是交易记录在2023.12.24这个会计日内。

日切

会计日切换到下一天。在这个点之后,所有的交易会被记录到下一个会计日。日切之后,通常需要进行批处理,包括交易清算、账户余额更新、试算平衡等。

记账

交易记录到会计科目中。

复式记账

一种会计准则,要求每笔交易都要有两个或以上的账目变动来记录,使得借方和贷方的总金额相等。

账户

记录特定类型财务交易的户头。比如商户账户、会员账户、收入账户等。

科目

会计账簿中用于分类和记录财务交易的项目。实际会分为多级。比如一级科目“应收”,二级科目“应收-银行待清算”,三级科目“应收-银行待清算-CMB”。

分录

记录一笔交易在会计账簿中的具体方法,明确记录到哪个借方、哪个贷方。

内部户

不直接面向客户,用于支付系统的内部会计和资金管理。

中间户

一种特殊的内部户,比如“应付-过渡-网关过渡户”,用于刚从渠道扣款成功时临时记账。

头寸

通俗地说,就是余额情况。头寸不够,就是余额不够。

流动性调拨

在多个银行备付金账户中转账,以确保符合业务需求。比如因为一些特殊原因,所有用户当天通过CMB渠道只支付了400万,但是当天所有用户合计要在CMB渠道退款500万,就需要从其它银行的备付金账户调拨过来。

结算

收单机构把交易资金结转给商户。通常有结算到余额到结算到银行卡。

清算

机构之间进行交易资金的转移。通常会由专门的清算机构负责清算。本质和结算是一样的,只是结算通常用在收单机构与商户之间,清算用在持牌的金融机构之间。

轧差

清分过程中把当天应收和应付金额相互抵消,最终只有净额需要结算或清算。

注意这个字读(gá),很多同学读成(zhá)是不对的。这是一个多音字,前者用在结算相关领域,后者用在钢材领域。

净清算额

轧差之后,各参与方需要转移的资金总额。

对账

比对交易双方的记账或资金。比如和渠道的对账。通常有明细对账和资金对账。前者就是对交易数据,按笔核对,后者对真实打款情况,当天交易100万,是否真实打款100万。

长款/短款

对账过程中发现实际的金额高于或低于账面金额。一般有支付长短款,退款长短款。对应的有差错处理。

计收费

支付平台针对手续费的记录和汇总。一般有商户计费和渠道计费。

手续费

支付系统对于交易处理或服务收取的费用。对接外部的渠道,外部渠道也会收渠道手续费。

税费

交易中被政府收取的税收。

资损

因某些原因导致的资金损失。比如用户提现100元,平台打款了200元,就是平台资损100元。

  1. 会员与商户

商户

Merchant。销售商品的个体或企业。对接支付服务提供商的收单服务,用于完成所售商品的卖出。

会员

一般指加入支付金融机构的个人。比如支付宝、微信支付会员。

限额

一个账户在特定时间内允许交易的最大金额。一般有单笔限额、日限额、月限额等。不同的业务类型可能分开也可能共用额度。

商户入驻

新商户进入到支付系统,需要收集必要的商户信息,签署必要的协议,整个过程称为商户入驻。

冻结/解冻

冻结一般是因为某些风险问题暂时停止账户的交易活动或余额。

解冻则是恢复账户或余额的正常交易能力。

KYC/实名认证

Know Your Customer。实际就是实名认证。在国外一般称为KYC。就是“证明你是你”。

KYB

Know Your Business。有点类似商户级别的实名认证,除了验证商户的合法性,还会关联核实商户的信誉,企业法人的信誉等。

OTP/短信验证码

One-Time Password。通常就是指短信验证码。

PIN/密码

Personal Identification Number。通常就是指密码。登录密码、支付密码等。

签约/绑卡

会员在支付系统中绑定某张银行卡或某个第三方钱包账号,这样在下次支付交易时,就可以直接支付,而不需要输入完整的银行卡信息。

解约/解绑

会员取消在支付系统中绑定的某张银行卡或某个第三方钱包账号。

  1. 交互

信息流

在交易过程中产生的非资金相关的数据。比如向渠道发起一笔支付,请求和响应都是信息流,签约解约也属于信息流。

资金流

也就是资金的流转。一般分为虚拟资金流和实际资金流。

虚拟资金流

支付系统内部各个账户之间资金的流转。比如用户余额减少,商户待结算余额增加等。

实际资金流

通常指银行账户之间资金的流转,比如用户存款账户余额减少,支付平台备付金账户余额增加等。

API接口

一般指系统间实时交互数据的一组协议。比如通过http实时接口调用银行渠道进行支付、退款等操作,就是API接口。API接口需要规定各字段的明确含义,签名验签以及加密解密的算法等。

文件接口

通过文件来交换数据,一般实时性不高。主要有清算文件、结算文件、流出交易文件。

清算文件

金融机构之间进行资金清算的交易记录文件。一般包括交易日下所有支付、退款等明细数据。

结算文件

金融机构与商户进行资金结算的交易记录文件。一般包括交易日下所有支付、退款等明细数据。

  1. 结束语

本章主要讲了一些支付相关的常见的术语,通过了解这些术语,让我们在谈论支付系统时是同频状态。

  1. 传送门

支付系统设计与实现是一个专业性非常强的领域,里面涉及到的很多设计思路和理论也可以应用到其它行业的软件设计中,比如幂等性,加解密,领域设计思想,状态机设计等。

在《百图解码支付系统设计与实现》的知识宇宙,每一篇深入浅出的文章都是一颗既独立但又彼此强关联的星球,有必要提供一个传送门以便让大家即刻到达想要了解的文章。

专栏地址 : 百图解码支付系统设计与实现

领域相关:

行业黑话与术语:支付行业黑话:支付系统必知术语一网打尽

基本概念与概要设计:跟着图走,学支付:在线支付系统设计的图解教程

收单结算设计:支付交易的三重奏:收单、结算与拒付在支付系统中的协奏曲

技术专题:

与数据库自增ID不同的业务ID:交易流水号的艺术:掌握支付系统的业务ID生成指南

签名验签:揭密支付安全:为什么你的交易无法被篡改

加密解密:金融密语:揭秘支付系统的加解密艺术

日志格式设计规范:支付系统日志设计完全指南:构建高效监控和问题排查体系的关键基石

幂等性设计:避免重复扣款:分布式支付系统的幂等性原理与实践

本文转载自: 掘金

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

避免重复扣款:分布式支付系统的幂等性原理与实践 1 什么是

发表于 2024-01-05

这是《百图解码支付系统设计与实现》专栏系列文章中的第(6)篇。

本文主要讲清楚什么是幂等性原理,在支付系统中的重要应用,业务幂等、全部幂等这些不同的幂等方案选型带来的收益和复杂度权衡,幂等击穿场景及可能的严重后果。

这也是支付公司面试的必考题目之一。

专栏地址: 百图解码支付系统设计与实现

  1. 什么是幂等性原理

幂等性是一个数学和计算机科学术语,用于描述无论操作执行多少次,都产生相同结果的属性。在软件行业,应用极其广泛,当我们说一个接口支持幂等时,无论调用多少次,对系统造成的结果是一致的。

注意这里说的“对系统造成的结果是一致的”是指系统内部数据或状态的变更,不是指返回值。不同的系统设计,返回值可能是不一样的。

举个例子,你在淘宝免密支付10元,淘宝针对这笔订单调用支付宝支付接口进行支付,无论是调用1次,还是调用100次,最终只扣了你10元。但是第二次有可能返回“重复请求”,也有可能返回“支付成功”,这个取决于接口设计。也就是支付宝内部只扣了你10元,但是接口可能返回给商户是是不同的结果。

我个人倾向于方案一,如果等幂等,就返回:重复请求。减少误解,虽然两种方案中系统都只扣了一次钱。

  1. 为什么幂等性在支付系统中极其重要

支付系统必须以最高的可靠性和准确性处理交易,这对于用户信任至关重要。如果一个支付系统不能保证幂等性,可能会导致多次扣除同一笔费用,引发用户不满和法律责任,严重时就会有舆情风险,甚至会被吊销牌照。

一般情况下,支付系统的幂等性能力要求比电商系统要求更高,如果用户在电商下单多了,只要没有支付,用户还是可以忍受的,但一旦多扣了用户的钱,后果就会比较严重。

这也是为什么幂等性会是支付系统招人的面试必考题目之一。

  1. 支付系统中应用幂等性的场景

幂等是针对重复请求的,支付系统一般会面临以下几个重复请求的场景:

  1. 用户多次点击支付按钮:在网络较差或系统过载情况下,用户由于不确定交易是否完成而重复点击。
  2. 自动重试机制:系统在超时或失败时重试请求,可能导致同一支付多次尝试。
  3. 网络数据包重复:数据包在网络传输过程中,复制出了多份,导致支付平台收到多次一模一样的请求。
  4. 异常恢复:在系统升级或崩溃后,未决事务需要根据已有记录恢复和完成。内部系统重发操作。
  1. 幂等解决方案

4.1. 业务幂等

所谓业务幂等,就是由各域自己把唯一性的交易ID作为数据库唯一索引,这样可以保证不会重复处理。

在数据库前面可以加一层缓存来提高性能,但是缓存只用于查询,查到数据认为就返回幂等成功,但是但不到,需要尝试插入数据库,插入成功后再刷新数据到缓存。

为什么要使用数据库的唯一索引做为兜底,是因为缓存是可能失效的。

在面临时经常有同学只回答到“使用redis分布式锁来实现幂等”,这是不对的。因为缓存有可能失效,分布式锁只是用于防并发操作的一种手段,无法根本性解决幂等问题,幂等一定是依赖数据库的唯一索引解决。

大部分简单的支付系统只要有业务幂等基本也够用了。

4.2. 通用幂等组件

每个域都要做幂等处理,那就单独出一个独立的幂等组件,各子业务系统通过引用这个公共JAR包解决。

适用场景:应用部署不太多的时候。如果应用非常多,独立幂等DB的连接池就不够用。

这个时候,可以把幂等组件的代码共用,但是幂等数据库表使用业务系统的DB资源。解决独立幂等DB导致的连接数不够用的场景。

4.3. 通用幂等服务

解决DB连接数不够用的第二个解决方案:幂等组件服务化。这样的坏处就是复杂度和耗时都会增加。

4.4. 全局幂等

在多机房部署情况下,需要解决机房之间的幂等服务。这就使用到了全局幂等概念。

所谓全局幂等,就是多个机房共用一份幂等数据,这里面涉及的技术比较复杂,后面单独开一个章节讲。除了极少数全球部署的多活支付系统都用不上。

4.5. 通用幂等数据库表设计

核心字段:

uniqueKey:幂等主键,由各应用自定义,需要保证全局唯一性,使用这个uniqueKey做hash后分库分表。比如商户的收单ID,上游的ID等。

appName: 应用名称,比如收单,支付等。

siteId:站点ID

extInfoMap:扩展字段,由各应用自定义,比如保存我方单号。

4.6. 方案选型建议

简单的支付系统,只需要使用业务幂等就够。

中型的支付系统,推荐使用通用幂等组件。这样方便运维。

全局幂等方案只有极少数公司会考虑。

  1. 分布式场景下实现幂等性的挑战及应对

分布式支付系统面临的幂等性挑战核心有两点:

  1. 如何保证分布于不同地理位置数据中心的系统数据的一致性。
  2. 幂等数据和业务数据跨库事务一致性。比如幂等已经入库成功,但是业务数据库入库失败。

为了解决这些挑战,可以采取以下解决方案:

  1. 使用全局唯一的交易ID,跟踪每次支付请求,防止重复处理。
  2. 幂等住了之后,还需要继续查询业务数据,如果查询失败,仍然执行业务操作。
  3. 构建强大的状态机推进能力,严格定义事务各个状态的转换。
  4. 幂等服务的高可靠性。
  1. 幂等被击穿场景及可能的严重后果

尽管有了上述措施,幂等性仍然可能因为以下原因失效:

  1. 在分布式系统中,由于同步延迟,导致多个节点未能即时识别重复请求。
  2. 请求流量切换。原本应该路由A机房的数据路由到了B机房,但是B机房的幂等数据缺失。
  3. 生成全局唯一ID的算法出现故障或人为变更,同一笔业务可能出现了2个业务ID。

在支付系统中,只要幂等被击穿,基本上都会出现资损事件。 有时候是用户资损,有时候是平台资损。曾经碰到一个真实案例,上游域把某个幂等字段组成规则的取值变了,但是下游不知道,导致下游幂等失败,对同一笔业务处理了2次,直接资损数十万美金。

  1. 结束语

幂等性是分布式支付系统的基本要求,对于确保交易的正确性和避免重复扣费至关重要。除开支付系统外,很多互联网应用基本上都需要有幂等能力。

有机会再单独讲讲全局幂等。

  1. 传送门

支付系统设计与实现是一个专业性非常强的领域,里面涉及到的很多设计思路和理论也可以应用到其它行业的软件设计中,比如幂等性,加解密,领域设计思想,状态机设计等。

在《百图解码支付系统设计与实现》的知识宇宙,每一篇深入浅出的文章都是一颗既独立但又彼此强关联的星球,有必要提供一个传送门以便让大家即刻到达想要了解的文章。

专栏地址 : 百图解码支付系统设计与实现

领域相关:

行业黑话与术语:支付行业黑话:支付系统必知术语一网打尽

基本概念与概要设计:跟着图走,学支付:在线支付系统设计的图解教程

收单结算设计:支付交易的三重奏:收单、结算与拒付在支付系统中的协奏曲

技术专题:

与数据库自增ID不同的业务ID:交易流水号的艺术:掌握支付系统的业务ID生成指南

签名验签:揭密支付安全:为什么你的交易无法被篡改

加密解密:金融密语:揭秘支付系统的加解密艺术

日志格式设计规范:支付系统日志设计完全指南:构建高效监控和问题排查体系的关键基石

幂等性设计:避免重复扣款:分布式支付系统的幂等性原理与实践

本文转载自: 掘金

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

面试官:请问js事件触发过程是怎样的

发表于 2024-01-05

js事件流流程如下:

  1. 在window上往事件触发处传播,遇到注册的“捕获事件”会触发
  2. 到达事件触发处
  3. 从事件触发处往window上传播,遇到注册的“冒泡事件”会触发

js事件流默认都在冒泡的过程触发

js事件流

js事件流描述的就是js事件传播的一个顺序,上面的答案就是对应的三个阶段:捕获阶段、目标阶段、冒泡阶段。如何理解这三个阶段?下面我用一个小demo展示下

我给三个div盒子,层层嵌套 app> wrapper> box

1
2
3
4
5
6
7
bash复制代码<div id="app">
<div id="wrapper">
<div id="box">

</div>
</div>
</div>

再给点样式,app最大,wrapper其次,box最小,效果如下

1.png

我再给这三个盒子都分别绑定一个点击事件,从大到小绑定,并且每个点击事件都会打印自己的id,比如app容器绑定点击事件如下

1
2
3
4
5
javascript复制代码let app = document.getElementById("app");

app.addEventListener('click', (e) => {
console.log('app')
})

我们给一个元素绑定一个事件,也可以说是注册,或者订阅一个事件

我们现在在app容器内点击,就一定会打印app,在wrapper内点击就一定会打印wrapper,在box内点击就一定会打印box,由于box被wrapper和app包含,点击box,就三个打印都会触发,那么这个触发的顺序是什么样的呢?这就是我们要讨论的话题,js事件触发的顺序就是js事件流。

我们先看下他会如何打印,是从里往外还是从外往里?

2.png

浏览器告诉我们是从里往外的,从里往外就是对应着js事件流第三步,冒泡事件。冒泡事件是什么?又为何直接到第三步了?下面一一带你认识下

捕获事件

我们点击最里面的容器的时候,会顺带点击到中间的容器以及最外面的容器,js是这样的,事件传播顺序一定是从外向内,最外层是window,然后是html,然后是body,然后就是app,wrapper,box,很好理解,就是从外向内。

捕获的含义其实就是父容器包裹一个子容器,捕获的过程碰到的事件就是捕获事件。

冒泡事件

当我们到达了事件触发处的时候,从事件触发处往window上传播。这就是从内向外。冒泡也非常的形象,从里往外,水里的泡泡因为压强的原因,深处的泡泡上浮的过程就是越来越大。刚好对应着这里的div盒子,从里向外,从小到大。

冒泡过程中碰到的事件我们就称之为冒泡事件


js事件流是先从外向里走一遍,在默认情况下,这个被称之为捕获的过程,是不会去触发事件的,到达了目标阶段后,开始往外冒泡,这个过程碰到的事件就是会触发的,这才有了刚才的打印顺序。

既然都说默认了,肯定有方法去更改这个js事件流,如何实现在捕获阶段触发呢?这个时候就要谈到addEventListener的第三个参数了

addEventListener的第三个参数

我们看看上面绑定事件时的addEventListener

1
2
3
javascript复制代码app.addEventListener('click', (e) => { 
console.log('app')
})

第一个参数就是事件名,第二个参数是回调函数,第三个参数可选,他是个布尔值,控制该事件是在捕获过程触发的还是在冒泡过程触发的,默认为false,所以false就代表着冒泡触发,true就代表让该事件在捕获过程被触发

所以我现在给容器最大的app加上第三个参数为true,那么app这个容器的事件触发将会被改为捕获阶段触发

1
2
3
javascript复制代码app.addEventListener('click', (e) => {
console.log('app')
}, true)

最终点击最小的容器,触发顺序为,捕获阶段先触发最大的app,然后到达了最小的容器后开始触发默认的冒泡事件,先是最小的box,然后是第二大的wrapper

3.png

现实中还有另外一个很常见的情形,就是我点击子容器,并不希望触发父容器。就拿掘金为栗,我可以在首页给一个文章点赞,而不会进入文章。

GIF 2023-12-30-星期六 10-37-36.gif

这个效果的实现我们就需要看下事件event原型中的一个方法:event.stopPropagation了,打印下看看

1
2
3
javascript复制代码app.addEventListener('click', (e) => { 
console.log(e)
}, true)

4.png

event.stopPropagation和event.stopImmediatePropagation

Propagation就是传播的意思,所以stopPropagation就是阻止传播,并且冒泡和捕获阶段都可以进行阻止

要实现只触发子容器的点击事件很简单,我们只需要保证大家都是默认的冒泡事件,然后到了事件触发处,也就是最小的容器,开始冒泡给他阻止掉就可以,所以我们给最小的容器加个阻止传播就可以了

1
2
3
4
5
6
7
8
9
10
11
12
javascript复制代码app.addEventListener('click', (e) => {
console.log('app')
})

wrapper.addEventListener('click', () => {
console.log('wrapper')
})

box.addEventListener('click', (e) => {
console.log('box')
e.stopPropagation()
})

5.png

我们给所有容器都改成捕获阶段触发,也就是给第三个参数为true,并且给最大的容器加上阻止传播,也可以实现仅打印最大的容器,这是为了证明可以阻止捕获传播,大家可以自行去试,这里就不作演示了。

现在再聊下event.stopImmediatePropagation,event.stopPropagation能实现的效果,他也能实现,区别在于它可以阻止同一个dom结构绑定的多个相同事件,不同事件的不行

刚才讲的效果你可以自己换成immediate再试一遍,效果是一样的。

我们现在只给最小的容器绑定多个事件

1
2
3
4
5
6
7
javascript复制代码box.addEventListener('click', (e) => { 
console.log('box')
})

box.addEventListener('click', (e) => {
console.log('box2')
})

因为代码从上到下的执行缘故,他会打印box,box2

6.png

我们现在给box加上一个immediate阻止

1
2
3
4
5
6
7
8
javascript复制代码box.addEventListener('click', (e) => { 
console.log('box')
e.stopImmediatePropagation()
})

box.addEventListener('click', (e) => {
console.log('box2')
})

他就会阻止后面的相同事件,仅打印box

7.png

应用场景:项目是多个人开发的,别人也可以对该dom结构绑定点击事件,你可以给自己添加一个阻止事件,这样就不会引起冲突

当然,相同dom结构绑定不同的事件,是无法阻止的

1
2
3
4
5
6
7
8
javascript复制代码box.addEventListener('click', (e) => { 
console.log('box')
e.stopImmediatePropagation()
})

box.addEventListener('mouseleave', (e) => {
console.log('box2')
})

这里我点击box后,再鼠标移除box依旧是可以触发鼠标移出事件的。因此这个方法只能阻止同一dom绑定的多个相同事件

最后

以上这些知识点就是js非常基础的事件流,整个流程是先捕获后冒泡,触发过程默认情况是冒泡,也就是像泡泡一样,从里到外,想要改变成为捕获传播就需要动用addEventListener的第三个参数,将其设置成true就是捕获事件,想要阻止事件传播就要调用stopPropagation或者stopImmediatePropagation方法

另外有不懂之处欢迎在评论区留言,如果觉得文章对你学习有所帮助,还请”点赞+评论+收藏“一键三连,感谢支持!

本次学习代码已上传至本人GitHub学习仓库:github.com/DolphinFeng…

本文转载自: 掘金

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

输入url到页面渲染后半段:回流,重绘,优化【一次性带你搞明

发表于 2024-01-05

回顾

简单回顾一下url到页面渲染上半段:

url输入到页面渲染整个过程清楚的透彻与否,可以代表你对前端领域开发的熟悉与否

前端向后端要数据的过程:

  1. DNS解析:谁告诉你服务器在哪里
  2. 建立连接,三次握手,数据传输
  3. 断开连接,四次挥手

http的各个版本以及每个版本解决了什么问题都是需要去认识的,以后再出期文章专门聊聊

等断开连接之后才是我们今天要聊的话题,此时浏览器已经拿到了数据

我们可以以访问百度为例,刷新百度页面的时候,检查其网络,点击全部,查看名称,可以看到所有的网络请求

GIF 2024-1-4-星期四 14-05-44.gif

这些全部都是前端向后端发送的请求,不过并不是所有的请求都是需要向服务器去要的,有些图片是缓存起来的,从本地读取,这个以后再聊。

比如我们看下第一个接口www.baidu.com

1.png

这个就是百度后端提供出来的接口,我们请求的数据就是这个样子,肯定有小伙伴就要疑惑了,数据不是一般都是json对象啥的吗,怎么是html结构。没有人规定数据只能是对象数组,百度的本地就是后端写的,这是个手段,可以解决vue首屏加载过慢问题

我们访问任何一个网站,浏览器会先拿到html代码,无外乎两种情况,一种是前后端分离式开发:前端代码部署到了服务器上,我们访问网站时,先从前端服务器上把前端的代码加载到,该过程中会触发ajax请求,又继续向后端要数据;另一种就是前后端不分离式开发:比如jsp,把模板塞到java中,直接向浏览器输出

这时浏览器已经拿到了html代码,然后还需要拿到css代码,js代码。一般html,css,js都是分文件写的,还有图片需要加载。

好,现在我们浏览器已经拿到了html,css,js代码,网页又是如何被整成布局这么精良的样子给你看的呢,这个过程就是页面渲染

浏览器加载到了资源(html,css……)

过程如下:

html是负责结构的,css是负责样式的

  1. 解析html代码,生成一个dom树
  2. 解析css代码,生成CSSOM树
  3. 将dom树和CSSOM树结合,去除不可见的元素,生成Render Tree
  4. 计算布局,(回流 | 重排)根据Render Tree进行布局计算,得到每一个节点的几何信息
  5. 绘制页面,(重绘)GPU根据布局信息绘制

为了方便大家理解树,这里写一个简版的dom树,用js模拟下,(肯定不是这样子,只是为了帮助理解)

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
python复制代码<div id="app">
<div class="name">Tom</div>
<div class="age">18</div>
</div>

// 上面的这段html理解成树大致如下:

let node = {
tag: 'div',
id: 'app',
class: '',
x: 0,
y: 100,
children: [
{
tag: 'div',
id: '',
class: 'name',
text: 'Tom',
……
},
{
tag: 'div',
id: '',
class: 'age',
text: '18',
……
}
]
}

为何要转换成dom树?

屏幕的物理发光点想要发光就必须清楚在哪里发光,也就是结构,转换成树结构就方便去绘制页面,清楚这个了你就会理解转换成CSSOM树的意义,这样就方便绘制颜色等等

知道了两棵树还必须联合起来,属性,类名之间进行对应,这个过程非常消耗性能

dom树和cssom树结合形成的树称之为渲染树,GPU会控制物理发光点亮与否,亮什么颜色,在绘制之前会计算下布局,就像是画画上色之前,我们需要素描一下。最后才是GPU绘制整个页面

以上就是当你输入url到页面渲染的整个后过程,别看页面出现的很快,其实浏览器在背后做出了非常多的努力。

接下来,我们详细谈下第四个和第五个过程,计算布局我们称之为回流或者重排,绘制页面我们称之为重绘

前三个步骤无法进行优化,所以我们单独领出来后两个步骤

噢!不对,css是可以进行优化,这里穿插一下,我们看下下面两个写法哪个性能更高

1
2
3
4
5
6
7
8
arduino复制代码// 写法一
.a .b .c {

}
// 写法二
.c {

}

对于开发人员,肯定是会觉得写法一更好,更优雅,因为直接从a定位到b,再定位到c,写法二会影响所有的c这个类名的容器。但是对于生成CSSOM树而言,写法一需要先找到a容器,再找到b容器,最后才找到c容器,而写法二直接找到c容器,写法二更好。讨论这个问题之前,类名必须是唯一的。

其实这个优化微乎其微,下面我们重新认识下步骤四和步骤五,分别对应着回流和重绘,这两个过程开销的性能比较大

回流

浏览器计算页面布局的过程就叫做回流

只要页面有容器几何信息发生变更就会发生回流,也就是影响了它的排版,所以回流太常见了,有以下几种:

  1. 改变窗口的尺寸
  2. 改变元素的尺寸
  3. display: none | block;(增加或删除可见元素)
  4. 页面初次渲染

容器脱离文档流是不会发生回流的,当然这是针对影响其他元素而言,比如你增删一个可见元素,是会影响下面容器的几何信息的。对于脱离文档流容器本身而言,肯定是发生回流的

重绘

GPU将已经计算好几何信息的容器在屏幕上亮起来就是重绘

所以只要元素的非几何属性发生变化时,就会发生重绘

  1. 修改背景颜色
  2. 修改背景图片
  3. 边框颜色
  4. 字体颜色
  5. 回流

注意,既然发生了回流,就一定会带来重绘,重绘不一定带来回流

如何减少回流,重绘

回流重绘这两个过程回流因为涉及到计算,所以它开销的性能会更多

我们看下面这个案例

1
2
3
4
5
6
7
8
9
10
11
ini复制代码<div id="app">Hello</div>

<script>
let app = document.getElementById('app')

app.style.position = 'relative';
app.style.width = '100px'
app.style.height = '200px'
app.style.left = '10px'
app.style.top = '10px'
</script>

请问以上这段js代码执行会有几次回流?(不算浏览器初次加载)

发生四次回流,因为只有四行代码影响了几何信息。没错,以前老版本浏览器是这样的,现在浏览器执行这个只会发生一次回流。

老版本浏览器一般为了减少回流就会这样写

1
css复制代码app.style.cssText = 'width: 100px; height: 200px; left: 10px; top: 10px'

放在一起执行就相当于一次性回流所有,或者也可以动态绑定一个类名,发生某个事件的时候给类名放上去,这样也是一次回流

浏览器的优化策略

相比较以前的浏览器,现在的浏览器是有一个优化策略的,它执行js的时候会维护一个渲染队列,改变一个容器的样式,导致需要发生回流的时候,这个操作会进入渲染队列,如果还有相同行为,继续进入队列,直到下面没有样式修改,浏览器会批量化地执行渲染队列中的回流过程,这只发生一次回流

那我对上面的那个栗子进行修改一下,每个样式下面都将属性值打印出来,这样会有几次回流

1
2
3
4
5
6
7
8
ini复制代码app.style.width = '100px'
console.log(app.offsetWidth)
app.style.height = '200px'
console.log(app.offsetHeight)
app.style.left = '10px'
console.log(app.offsetLeft)
app.style.top = '10px'
console.log(app.offsetTop)

offsetWidth就是读取容器的宽度,clientWidth也是读取容器的高度,不过前者会包含边框,innerWidth是获取window的宽度

这种情况浏览器会发生四次回流,这些代码都是同步代码,因此从上往下执行,执行打印语句的时候无法跳过它,并且当你要读取几何信息的时候是会打断它入队列的,因为读取几何信息就是一个计算布局,也就是回流或重排,重排是非常昂贵的,因此浏览器需要专注此时的重排,所以它会暂停渲染队列的任务,等待重排完毕后再去执行此时的渲染队列,因此碰到计算布局的时候一定会带来一次渲染队列的执行或刷新,也就是带来一次回流

只要是读取几何信息都会引起渲染队列的强制执行,如下方法都是:

1
2
3
复制代码offsetWidth, offsetHeight, offsetTop, offsetLeft 
clientWidth, clientHeight, clientTop, clientLeft
scrollWidth, scrollHeight, scrollTop, scrollLeft

所以上面的那个写法,我可以把所有的读取几何信息的方法全部丢到后面去,这样就是一次回流了

1
2
3
4
5
6
7
8
9
ini复制代码app.style.width = '100px'
app.style.height = '200px'
app.style.left = '10px'
app.style.top = '10px'

console.log(app.offsetLeft)
console.log(app.offsetHeight)
console.log(app.offsetWidth)
console.log(app.offsetTop)

执行第一个打印语句的时候,渲染队列就空了,空了的队列无法引起回流,读取值是不引起回流的,读取值引起的执行队列才会引起回流

所以说,我们为了减少回流,重绘,可以合理利用浏览器的优化策略,少去读取几何信息,或者统一放到最后读取

那么还有什么办法减少回流重绘呢?

既然都是队列了,那么它一定有大小,所以只要队列满了就一定会执行一遍,这样才有空间去存后面的几何样式,当然这不是方法

我们可以观察页面渲染的第三步,生成Render Tree是不会带入display: none;的元素,所以我们可以先将一个元素display: none;后统一修改其样式,然后再将其display: block;回来。这也是个方法,这样就是发生两次回流,none,block分别一次。

优化实例

给你一段代码你来进行优化

1
2
3
4
5
6
7
8
9
10
11
12
ini复制代码<ul id="box"></ul>

<script>
const ul = document.getElementById("box");

for (let i = 1; i<=100; i++) {
let li = document.createElement("li");
let text = document.createTextNode(i) // 创建文本节点
li.appendChild(text) // 这个不算回流,因为text添加到li上时,li还没出现在页面上
ul.appendChild(li);
}
</script>

这段代码就是循环创建100个li,并且每个li都有文本,这其实就是新建一个元素,每次新建一个就是回流一次,不算页面初次加载就是100次回流。

none,block优化

现在我们用none,block方法进行优化

1
2
3
4
5
6
7
8
9
10
11
12
ini复制代码<script>
const ul = document.getElementById("box");
ul.style.display = "none";

for (let i = 1; i<=100; i++) {
let li = document.createElement("li");
let text = document.createTextNode(i) // 创建文本节点
li.appendChild(text)
ul.appendChild(li); // 100次
}
ul.style.display = "block"
</script>

这样就是两次回流了

Fragment文档碎片优化

Fragment是一种机制,用于在内存中创建一个轻量级的文档碎片,这个文档碎片可以包含多个节点,它不涉及dom结构的实际插入,因此不会触发回流,最后带有批量节点的文档碎片插入到文档中,这样可以减少回流的次数

1
2
3
4
5
6
7
8
9
10
11
12
13
ini复制代码<script>
const ul = document.getElementById("box");
const fragment = document.createDocumentFragment(); // 虚拟的文档片段

for (let i = 1; i<=100; i++) {
let li = document.createElement("li");
let text = document.createTextNode(i)
li.appendChild(text)
fragment.appendChild(li); // 虚拟片段不会回流
}

ul.appendChild(fragment)
</script>

这样就只会引起一次回流

clone克隆优化

这里的克隆是克隆节点,克隆过程发生在内存中,不会影响到文档布局,因此我们对副本进行操作,不引起回流,并且克隆是深拷贝,不会影响原体,克隆节点插入到文档中才会引起回流

1
2
3
4
5
6
7
8
9
10
11
12
13
ini复制代码<script>
const ul = document.getElementById("box");
const clone = ul.cloneNode(true) // 克隆一份ul,必定是深拷贝,原ul不受影响

for (let i = 1; i<=100; i++) {
let li = document.createElement("li");
let text = document.createTextNode(i) // 创建文本节点
li.appendChild(text)
clone.appendChild(li);
}

ul.parentNode.replaceChild(clone, ul) // (替代品, 被替代品)
</script>

这样只会引起一次回流

最后来个字节面试题收尾

字节面试题

字节面试要是问到回流重绘问题,你就应该开心了,非常简单

1
2
3
4
5
6
7
ini复制代码<div id="app"></div>

<script>
let el = document.getElementById('app')
el.style.width = (el.offsetWidth + 1) + 'px'
el.style.width = 1 + 'px'
</script>

面试官:请问这三行js代码发生了几次回流

第一行获取dom结构不造成回流,第二行第三行的设置宽高一定会有回流。看第二行,从左往右执行,左边会进入渲染队列,但是右边会获取几何信息,因此这里按道理也会发生回流,但是这里情况特殊一点,offsetWidth是强制触发渲染队列的执行,而非强制回流。也就是说渲染队列里面没有东西去让你执行,因此执行第二行,第三行会连续入渲染队列,最后只发生一次回流,当然,如果是老版本浏览器,就是两次回流,因为两次设置width

一般字节一面大概30-45min,如果能一直聊到1h,基本上一面就稳了,30min内会问js基础问题3-5个,进阶问题(项目难点,大文件上传……),回答问题的时候最好把该知识能聊的东西全给聊一遍

最后

清楚回流重绘,那么浏览器后半段的过程你基本上就全部掌握了,后半段整个过程就是五个阶段,生成dom树,生成cssom树,两树合并,然后回流,重绘,回流因为要计算属性,需要考虑到如何去对他进行优化,浏览器有个优化策略,就是渲染队列,只要没有计算信息的干预,会一直进行入队列,批量执行,计算信息的出现就会导致一次刷新队列,也就是回流,当然前提队列是有东西的,文章最后的字节面试题就是来坑你这里的。本文还给大家介绍了三种优化策略,none,block,文档碎片,节点克隆。

另外有不懂之处欢迎在评论区留言,如果觉得文章对你学习有所帮助,还请”点赞+评论+收藏“一键三连,感谢支持!

本次学习代码已上传至本人GitHub学习仓库:github.com/DolphinFeng…

本文转载自: 掘金

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

1…646566…956

开发者博客

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