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

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


  • 首页

  • 归档

  • 搜索

【微电平台】-高并发实战经验-奇葩问题解决及流程优化之旅 微

发表于 2024-04-23

微电平台

微电平台是集电销、企业微信等于一体的综合智能SCRM SAAS化系统,涵盖多渠道管理、全客户生命周期管理、私域营销运营等主要功能,承接了京东各业务线服务,专注于为业务提供职场外包式的一站式客户管理及一体化私域运营服务。



导读

本文介绍电销系统【客户名单离线打标流程】遇到jmq消费端吞吐下降40%、jsf接口服务机器假死问题时,从排查、反复验证到最终解决问题并额外提升50%吞吐的过程,适合服务端研发同学,提供生产环遇到一些复杂问题时排查思路及解决方案,正确使用京东内部例如sgm、jmq、jsf等工具抓到问题根因并彻底解决,用技术为业务发展保驾护航~

下面开始介绍电销系统实际生产环境遇到的离线黑名单打标流程吞吐问题。

事件背景

1、概述

每日凌晨夜间会为电销系统亿级客户名单进行黑名单打标,平均速度为95万名单/分钟,黑名单jsf服务总tps约2万,tp99在100~110ms,若夜间没有完成标记加工操作,会导致白天职场无法正常作业,并且存在客户骚扰隐患、降低职场运营效率的问题,因外部接口依赖数量较多打标程序只能凌晨启动和结束。

2、复杂度

面向业务提供千人千面的配置功能,底层基于规则引擎设计实现,调用链路中包含众多外部接口,例如金融刷单标记、风控标记、人群画像标记、商城风控标记、商城实名标记等,包含的维度多、复杂度较高。





3、问题

早上通过监控发现黑名单打标没有执行完成,表现为jmq消费端tp99过高、进而降低了打标程序吞吐,通过临时扩容、下掉4台“问题机器” (上帝视角:其实是程序导致的问题机器)提高吞吐,加速完成黑名单打标。

但,为什么会频繁有机器问题?为什么少量机器有问题会降低40%吞吐?后续如何避免此类情况?

带着上述问题,下面开启问题根因定位及解决之旅~

抓出幕后黑手

1、为什么几台机器出问题就会导致吞吐急剧下降?



如上图所示,每有一条消息消费报错(在本案例中是打到“问题机器”上),jmq会本地尝试sleep后重新消费拉下来的所有消息(本案例中jmq的batchSize=10),即每次报错产生的总耗时至少会增加一千毫秒,一共80台机器,jsf使用默认负载均衡算法,服务请求打到4台问题机器的概率是5%,jmq一次拉下来10条消息,只要有一条消息命中“问题机器”就会极大降低吞吐。





综上所述,少量机器有问题吞吐就会急剧降低的原因是jsf随机负载均衡算法下每个实例的命中率相同以及报错后jmq consumer重试时默认休眠1秒的机制导致。

解决:当然consumer不报错是完全可以规避问题,那如果保证不了不报错,可以通过:

1)修改jmq的重试次数、重试延迟时间来尽可能的减少影响

1
2
3
ini复制代码<jmq:consumer id="cusAttributionMarkConsumer" transport="jmqTransport">
<jmq:listener topic="${jmq.topic}" listener="jmqListener" retryDelay="10" maxRetryDelay="20" maxRetrys="1"/>
</jmq:consumer>

2)修改jsf负载均衡算法

配置样例:

1
ini复制代码    <jsf:consumer loadbalance="shortestresponse"/>

原理图:





上图中的consumer图是从jsf wiki里摘取,上面的红字是看jsf代码提取的关键信息,总而言之就是:默认的random是完全随机算法,shortestresponse是基于响应时间、请求数加权进行负载均衡,所以使用shortestresponse算法可以很大程度上规避此类问题,类似于熔断的作用(本次解决过程中也使用了jsf的实例熔断、预热能力,具体看jsf wiki,在此不过多介绍)。

2、如何判定是实例问题、找出有问题的实例ip?

通过监控观察,耗时高的现象只存在于4台机器上,第一反应确实是以为机器问题,但结合之前(1月份有过类似现象)的情况,觉得此事必有蹊跷。

下图是第一反应认为是机器问题的日志(对应sgm监控这台机器耗时也是连续高),下掉此类机器确实可以临时解决问题:





综上所述,当时间段内耗时高或失败的都是某个ip,此时可以判定该ip对应的实例有问题(例如网络、硬件等),如果是大量ip存在类似现象,判定不是机器本身的问题,本案例涉及到的问题不是机器本身问题而是程序导致的现象,继续往下看揭晓答案。

3、是什么导致机器频繁假死、成为故障机器?

通过上述分析可以得知,问题机器报错为jsf线程池打满,机器出问题期间tps几乎为0,期间有请求过来就会报jsf线程池(非业务线程池)打满,此时有两种可能,一是jsf线程池有问题,二是jsf线程池的线程一直被占用着,抱着信任中间件的思路,选择可能性二继续排查。

通过行云进行如下操作:

1)dump内存对象



无明显问题,内存占用也不大,符合监控上的少量gc现象,继续排查堆栈

2)jstack堆栈



从此来看与问题机器表象一致了,基本得出结论:所有jsf线程都在waiting,所以有流量进来就会报jsf线程池满错误,并且与机器cpu、内存都很低现象相符,继续看详细栈信息,抽取两个比较有代表的jsf线程。

线程编号JSF-BZ-22000-92-T-200:

1
2
3
4
5
6
7
8
9
10
css复制代码stackTrace:
java.lang.Thread.State: WAITING (parking)
at sun.misc.Unsafe.park(Native Method)
- parking to wait for <0x00000007280021f8> (a java.util.concurrent.FutureTask)
at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175)
at java.util.concurrent.FutureTask.awaitDone(FutureTask.java:429)
at java.util.concurrent.FutureTask.get(FutureTask.java:191)
at com.jd.jr.scg.service.common.crowd.UserCrowdHitResult.isHit(UserCrowdHitResult.java:36)
at com.jd.jr.scg.service.impl.BlacklistTempNewServiceImpl.callTimes(BlacklistTempNewServiceImpl.java:409)
at com.jd.jr.scg.service.impl.BlacklistTempNewServiceImpl.hit(BlacklistTempNewServiceImpl.java:168)

线程编号JSF-BZ-22000-92-T-199:

1
2
3
4
5
6
7
8
css复制代码stackTrace:
java.lang.Thread.State: WAITING (parking)
at sun.misc.Unsafe.park(Native Method)
- parking to wait for <0x00000007286c9a68> (a java.util.concurrent.FutureTask)
at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175)
at java.util.concurrent.FutureTask.awaitDone(FutureTask.java:429)
at java.util.concurrent.FutureTask.get(FutureTask.java:191)
at com.jd.jr.scg.service.biz.BlacklistBiz.isBlacklist(BlacklistBiz.java:337)

推断:线程编号JSF-BZ-22000-92-T-200在UserCrowdHitResult的36行等待,线程编号JSF-BZ-22000-92-T-199在BlacklistBiz的337行等待,查到这,基本能推断出问题原因是线程等待,造成问题的类似代码场景是1)main线程让线程池A去执行一个任务X;2)任务X中又让同一个线程池去执行另一个任务,当第二步获取不到线程时就会一直等,然后第一步又会一直等第二步执行完成,就是造成线程互相等待的假死现象。

小结:查到这基本可以确认问题,但因代码维护人离职以及程序错综复杂,此时为验证结论先修改业务线程池A线程数:50->200(此处是为了验证没有线程等待现象时的吞吐表现),再进行验证,结论是tps会有小范围抖动,但不会出现tps到0或是大幅降低的现象。

单机tps300~500,流量正常了,即未产生线程等待问题时可以正常提供服务,如图:





印证推断:通过堆栈定位到具体代码,代码逻辑如下:

1
2
3
rust复制代码BlacklistBiz->【线程池A】blacklistTempNewService.hit
blacklistTempNewService.hit->callTimes
callTimes->userCrowdServiceClient.isHit->【线程池A】crowdIdServiceRpc.groupFacadeHit

小结:BlacklistBiz作为主线程通过线程池A执行了blacklistTempNewService.hit任务,然后blacklistTempNewService.hit中又使用线程池A执行了crowdIdServiceRpc.groupFacadeHit造成了线程等待、假死现象,与上述推断一致,至此,问题已完成定位。

解决:办法很简单,额外新增一个线程池避免线程池嵌套使用。

4、意外收获,发现一个影响黑名单服务性能的问题点

查看堆栈信息时发现存在大量waiting to lock的信息:





问题:通过上述堆栈进而排查代码发现一个服务链路中的3个方法使用了同一把锁,性能不降低都怪了





解决:通过引入caffeine本地缓存替换掉原来使用同步锁维护的手写本地缓存。

5、额外收获,你知道jsf线程池满的时候报RpcException客户端不会进行重试吗?

这个让我挺意外的,之前看jsf代码以及和jsf架构师交流得到的信息是:所有RpcException都会进行重试,重试的时候通过负载均衡算法重新找provider进行调用,但在实际验证过程中发现若服务端报:handlerRequest error msg:[JSF-23003]Biz thread pool of provider has bean exhausted, the server port is 22001,客户端不会发起重试,此结论最终与jsf架构师达成一致,所以此类场景想要重试,需要在客户端程序中想办法,实现比较简单,这里不贴样例了。

追加一些细节,感谢京东物流大佬@史建刚 进行了详细论证:目前的jsf只有在consumer抛出的RpcException才会进行重试,其他都不会重试(如上述provider抛出的异常会被当做正常返回报文处理、consumer不会重试),详情移步: joyspace.jd.com/pages/IKhKS…

总结

问题是打标流程没有执行完,表象是jmqtp99升高、吞吐降低,这个时候如果打标jsf服务tp99飙高就能说明是服务问题,但恰恰相反(看着像请求量小而不是打标jsf服务本身问题),此时打标jsf服务tps低且tp99正常、内存及cpu等指标也都处于空闲状态,但频繁有jsf线程池耗尽的错误,所以进一步排查了堆栈、内存快照等信息,最终定位到是业务线程等待导致服务响应时长高、进而导致打标服务jsf线程池耗尽报错,也就是说jmq的tp99高实际不是打标jsf服务耗时高导致、而是打标jsf服务报错触发了jmq重试机制(延迟及休眠)导致了jmq吞吐降低的表象,最终定位及解决了线程等待问题,额外解决了一个影响打标jsf服务性能的锁,并通过了解jmq、jsf一些配置原理及调优,达到了即使服务报错也不至于大幅度影响mq吞吐的效果。

事件回顾

通过本次的问题解决,不但彻底解决了问题,还对影响性能的因素做了优化,最终效果有:

1、解决黑名单jsf服务线程等待安全隐患、去掉同步锁提升吞吐,tps从2万提升至3万的情况下,tp99从100ms降低至65ms;

2、jmq重试等待及延迟时间调优,规避重试时吞吐大幅降低:tp99从1100ms降低至300ms;

3、jsf负载均衡算法调优,规避机器故障时仍然大量请求打到机器上,效果是服务相对稳定;

最终从8点多执行完提前至5点前完成,整体时间缩减了57%,并且即使机器出现“问题”也不会大幅降低整体吞吐,收益比较明显。

优化后的运行图如下:





写在最后

微电平台虽说不在黄金链路,但场景复杂度(业务复杂度、rpa等机器人用户复杂度)以及流量量级使我们经常面临各种挑战,好在我们都解决了,这里共勉一句话:“在前进的路上总会有各种意想不到的情况,但是,都会拨云见日”。

本文转载自: 掘金

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

iOS - 多线程的安全隐患 iOS - 多线程的安全隐患

发表于 2024-04-23

iOS - 多线程的安全隐患

  • 资源共享
    • 1块资源可能会被多个线程共享,也就是多个线程可能会访问同一块资源
    • 比如多个线程访问同一个对象、同一个变量、同一个文件
  • 当多个线程访问同一块资源时,很容易引发数据错乱和数据安全问题
  1. 卖票案例

假设总共15张票,使用3个异步线程并发执行,每个线程卖5张票,观察最后的打印结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
@interface ViewController ()

@property (nonatomic, assign) int ticketsCount;

@end

@implementation ViewController

- (void)viewDidLoad {
[super viewDidLoad];
// Do any additional setup after loading the view.
}

- (void)saleTicket {
int oldTicketsCount = self.ticketsCount;
sleep(0.2);
oldTicketsCount--;
self.ticketsCount = oldTicketsCount;

NSLog(@"还剩 %d 张票", oldTicketsCount);
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
self.ticketsCount = 15;
dispatch_queue_t queue = dispatch_get_global_queue(0, 0);
dispatch_async(queue, ^{
for (int i = 0; i < 5; i++) {
[self saleTicket];
}
});

dispatch_async(queue, ^{
for (int i = 0; i < 5; i++) {
[self saleTicket];
}
});

dispatch_async(queue, ^{
for (int i = 0; i < 5; i++) {
[self saleTicket];
}
});
}

@end


出现票重复卖票,最后没卖完的情况

时序图示意:

分析图:

  1. 多线程安全隐患的解决方案

  • 解决方案:使用线程同步技术(同步,就是协同步调,按预定的先后次序进行)
  • 常见的线程同步技术是:加锁

2.1 iOS中的线程同步方案

  • OSSpinLock
  • os_unfair_lock
  • pthread_mutex
  • dispatch_semaphore
  • dispatch_queue(DISPATCH_QUEUE_SERIAL)
  • NSLock
  • NSRecursiveLock
  • NSCondition
  • NSConditionLock
  • @synchronized

2.2 同步方案的使用

为了方便调试,我们做个简单封装,将需要加锁的代码放在ZSXBaseDemo中,同时暴漏方法给子类重写:

    • (void)__saveMoney;
    • (void)__drawMoney;
    • (void)__saleTicket;

每一把锁将创建一个ZSXBaseDemo子类,然后重写上述方法,实现各自的加锁方式

ZSXBaseDemo.h

1
2
3
4
5
6
7
8
9
10
11
12
@interface ZSXBaseDemo : NSObject

- (void)moneyTest;
- (void)ticketTest;
- (void)otherTest;

#pragma mark - 暴露给子类去使用
- (void)__saveMoney;
- (void)__drawMoney;
- (void)__saleTicket;

@end

ZSXBaseDemo.m

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
@interface ZSXBaseDemo()

@property (assign, nonatomic) int money;
@property (assign, nonatomic) int ticketsCount;

@end

@implementation ZSXBaseDemo

- (void)otherTest {}

/**
存钱、取钱演示
*/
- (void)moneyTest
{
self.money = 100;

dispatch_queue_t queue = dispatch_get_global_queue(0, 0);

dispatch_async(queue, ^{
for (int i = 0; i < 10; i++) {
[self __saveMoney];
}
});

dispatch_async(queue, ^{
for (int i = 0; i < 10; i++) {
[self __drawMoney];
}
});
}

/**
存钱
*/
- (void)__saveMoney
{
int oldMoney = self.money;
sleep(.2);
oldMoney += 50;
self.money = oldMoney;

NSLog(@"存50,还剩%d元 - %@", oldMoney, [NSThread currentThread]);
}

/**
取钱
*/
- (void)__drawMoney
{
int oldMoney = self.money;
sleep(.2);
oldMoney -= 20;
self.money = oldMoney;

NSLog(@"取20,还剩%d元 - %@", oldMoney, [NSThread currentThread]);
}

/**
卖1张票
*/
- (void)__saleTicket
{
int oldTicketsCount = self.ticketsCount;
sleep(.2);
oldTicketsCount--;
self.ticketsCount = oldTicketsCount;
NSLog(@"还剩%d张票 - %@", oldTicketsCount, [NSThread currentThread]);
}

/**
卖票演示
*/
- (void)ticketTest
{
self.ticketsCount = 15;

dispatch_queue_t queue = dispatch_get_global_queue(0, 0);

// for (int i = 0; i < 10; i++) {
// [[[NSThread alloc] initWithTarget:self selector:@selector(__saleTicket) object:nil] start];
// }

dispatch_async(queue, ^{
for (int i = 0; i < 5; i++) {
[self __saleTicket];
}
});

dispatch_async(queue, ^{
for (int i = 0; i < 5; i++) {
[self __saleTicket];
}
});

dispatch_async(queue, ^{
for (int i = 0; i < 5; i++) {
[self __saleTicket];
}
});
}

@end

2.2.1 OSSpinLock

  • OSSpinLock叫做"自旋锁",等待锁的线程会处于忙等(busy-wait)状态,一直占用着CPU资源
  • 目前已经不再安全,可能会出现优先级反转问题
    • 如果等待锁的线程优先级较高,它会一直占用着CPU资源,优先级低的线程就无法释放锁
    • 需要导入头文件#import <libkern/OSAtomic.h>
2.2.1.1 使用方法:
1
2
3
4
5
6
7
8
9
10
11
// 初始化
OSSpinLock lock = OS_SPINLOCK_INIT;

// 尝试加锁
bool result = OSSpinLockTry(&lock);

// 加锁
OSSpinLockLock(&lock);

// 解锁
OSSpinLockUnlock(&lock);
2.2.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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
#import "OSSpinLockDemo.h"
#import <libkern/OSAtomic.h>

@interface OSSpinLockDemo ()

// High-level lock
// 自旋锁,有优先级反转问题
@property (nonatomic, assign) OSSpinLock moneyLock;
@property (nonatomic, assign) OSSpinLock ticketLock;

@end

@implementation OSSpinLockDemo

- (instancetype)init {
if (self = [super init]) {
_moneyLock = OS_SPINLOCK_INIT;
_ticketLock = OS_SPINLOCK_INIT;

//使用方法
// 初始化
OSSpinLock lock = OS_SPINLOCK_INIT;

// 尝试加锁
bool result = OSSpinLockTry(&lock);

// 加锁
OSSpinLockLock(&lock);

// 解锁
OSSpinLockUnlock(&lock);
}
return self;
}

#pragma mark reload
- (void)__drawMoney {
OSSpinLockLock(&_moneyLock);

[super __drawMoney];

OSSpinLockUnlock(&_moneyLock);
}

- (void)__saveMoney {
OSSpinLockLock(&_moneyLock);

[super __saveMoney];

OSSpinLockUnlock(&_moneyLock);
}

- (void)__saleTicket {
OSSpinLockLock(&_ticketLock);

[super __saleTicket];

OSSpinLockUnlock(&_ticketLock);
}
#pragma mark end

@end

对于卖票,只有__saleTicket方法中需要使用锁,因此也可以使用static关键字创建静态变量,无需声明为属性

1
2
3
4
5
6
7
8
- (void)__saleTicket {
static OSSpinLock ticketLock = OS_SPINLOCK_INIT;
OSSpinLockLock(&ticketLock);

[super __saleTicket];

OSSpinLockUnlock(&ticketLock);
}

2.2.2 os_unfair_lock

  • os_unfair_lock用于取代不安全的OSSpinLock ,从iOS10开始才支持
  • 从底层调用看,等待os_unfair_lock锁的线程会处于休眠状态,并非忙等
  • 需要导入头文件#import <os/lock.h>
2.2.2.1 使用方法:
1
2
3
4
5
6
7
8
9
10
11
// 初始化
os_unfair_lock lock = OS_UNFAIR_LOCK_INIT;

// 尝试加锁
bool result = os_unfair_lock_trylock(&lock);

// 加锁
os_unfair_lock_lock(&lock);

// 解锁
os_unfair_lock_unlock(&lock);
2.2.2.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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
#import "OSUnfairLockDemo.h"
#import <os/lock.h>

@interface OSUnfairLockDemo()
// Low-level lock
// ll lock
// lll
// Low-level lock的特点等不到锁就休眠
@property (assign, nonatomic) os_unfair_lock moneyLock;
@property (assign, nonatomic) os_unfair_lock ticketLock;
@end

@implementation OSUnfairLockDemo

- (instancetype)init {
if (self = [super init]) {
_moneyLock = OS_UNFAIR_LOCK_INIT;
_ticketLock = OS_UNFAIR_LOCK_INIT;

//使用方法
// 初始化
os_unfair_lock lock = OS_UNFAIR_LOCK_INIT;

// 尝试加锁
bool result = os_unfair_lock_trylock(&lock);

// 加锁
os_unfair_lock_lock(&lock);

// 解锁
os_unfair_lock_unlock(&lock);
}
return self;
}

#pragma mark reload
- (void)__drawMoney {
os_unfair_lock_lock(&_moneyLock);

[super __drawMoney];

os_unfair_lock_unlock(&_moneyLock);
}

- (void)__saveMoney {
os_unfair_lock_lock(&_moneyLock);

[super __saveMoney];

os_unfair_lock_unlock(&_moneyLock);
}

- (void)__saleTicket {
os_unfair_lock_lock(&_ticketLock);

[super __saleTicket];

os_unfair_lock_unlock(&_ticketLock);
}
#pragma mark end

@end

2.2.3 pthread_mutex

  • mutex叫做”互斥锁”,等待锁的线程会处于休眠状态
  • 需要导入头文件#import <pthread.h>
2.2.3.1 使用方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 初始化锁
pthread_mutex_init(mutex, NULL); //相当于设置属性"PTHREAD_MUTEX_DEFAULT"

//初始化属性并设置属性
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_DEFAULT);

//初始化锁
pthread_mutex_init(mutex, &attr);

//加锁
pthread_mutex_lock(&_mutex);

//尝试加锁
bool result = pthread_mutex_trylock(&_mutex);

//解锁
pthread_mutex_unlock(&_mutex);

//销毁相关资源
pthread_mutexattr_destroy(&attr); //销毁属性
pthread_mutex_destroy(&_mutex); //销毁锁
2.2.3.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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
@interface MutexDemo ()

@property (assign, nonatomic) pthread_mutex_t ticketMutex;
@property (assign, nonatomic) pthread_mutex_t moneyMutex;

@end

@implementation MutexDemo

- (void)__initMutex:(pthread_mutex_t *)mutex {
// 初始化锁
pthread_mutex_init(mutex, NULL);
}

- (instancetype)init {
if (self = [super init]) {
[self __initMutex:&_ticketMutex];
[self __initMutex:&_moneyMutex];
}
return self;
}

#pragma mark reload
- (void)__drawMoney {
pthread_mutex_lock(&_moneyMutex);

[super __drawMoney];

pthread_mutex_unlock(&_moneyMutex);
}

- (void)__saveMoney {
pthread_mutex_lock(&_moneyMutex);

[super __saveMoney];

pthread_mutex_unlock(&_moneyMutex);
}

- (void)__saleTicket {
pthread_mutex_lock(&_ticketMutex);

[super __saleTicket];

pthread_mutex_unlock(&_ticketMutex);
}
#pragma mark end

- (void)dealloc {
pthread_mutex_destroy(&_moneyMutex);
pthread_mutex_destroy(&_ticketMutex);
}

@end
2.2.3.3 pthread_mutex-递归锁

递归:允许同一个线程对一把锁重复加锁

在otherTest方法中,假设该方法中已经加锁,同时会调用另一个也需要加锁的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
- (void)otherTest {
pthread_mutex_lock(&_mutex);

NSLog(@"%s", __func__);

[self otherTest2];

pthread_mutex_unlock(&_mutex);
}

- (void)otherTest2 {
pthread_mutex_lock(&_mutex);

NSLog(@"%s", __func__);

pthread_mutex_unlock(&_mutex);
}

此时执行代码

代码只会执行到[self otherTest2];。此时出现死锁,是因为otherTest方法加锁后,调用otherTest2,otherTest2方法开始执行时也会加锁,此时因为otherTest方法还未解锁,otherTest2则进入等待解锁状态,而otherTest需要等待otherTest2方法执行完才继续,所以产生死锁

使用pthread_mutexattr_t配置该锁为递归锁:
PTHREAD_MUTEX_RECURSIVE

1
2
3
4
5
6
7
8
9
10
11
// 初始化锁
// pthread_mutex_init(mutex, NULL);

//初始化属性
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE);
//初始化锁
pthread_mutex_init(mutex, &attr);
//销毁属性
pthread_mutexattr_destroy(&attr);

此时可以正常执行完otherTest、otherTest2方法

如果otherTest方法里面是递归调用otherTest自身

1
2
3
4
5
6
7
8
9
- (void)otherTest {
pthread_mutex_lock(&_mutex);

NSLog(@"%s", __func__);

[self otherTest];

pthread_mutex_unlock(&_mutex);
}

这时候会死循环调用otherTest

若增加一个计数,即可控制递归调用的次数

1
2
3
4
5
6
7
8
9
10
11
12
13
- (void)otherTest {
pthread_mutex_lock(&_mutex);

NSLog(@"%s", __func__);

static int count = 0;
if (count < 5) {
count++;
[self otherTest];
}

pthread_mutex_unlock(&_mutex);
}

执行结果:

2.2.3.4 pthread_mutex-条件锁

业务场景中,可能需要不同线程间的依赖关系,比如线程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
- (void)otherTest {
[[[NSThread alloc] initWithTarget:self selector:@selector(__remove) object:nil] start];

[[[NSThread alloc] initWithTarget:self selector:@selector(__add) object:nil] start];
}

- (void)__remove {
NSLog(@"%s", __func__);
pthread_mutex_lock(&_mutex);

[self.data removeLastObject];
NSLog(@"删除一个元素");

pthread_mutex_unlock(&_mutex);
}

- (void)__add {
NSLog(@"%s", __func__);
pthread_mutex_lock(&_mutex);

[self.data addObject:@"test"];
NSLog(@"添加一个元素");

pthread_mutex_unlock(&_mutex);
}

使用多线程调用__remove和__add,两者执行顺序不确定。但是希望__remove是在__add之后执行,保证先加、再减。

这时候可以使用条件锁来实现

2.2.3.4.1 使用方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 初始化锁
pthread_t mutex;
pthread_mutex_init(&mutex, NULL);
// 初始化条件
pthread_cond_t condition;
pthread_cond_init(&condition, NULL);
// 等待条件(进入休眠,放开mutex 锁;被唤醒后,会再次对mutex加锁)
pthread_cond_wait(&condition, &mutex);
// 激活一个等待条件的线程
pthread_cond_signal(&condition);
// 激活所有等待条件的线程
pthread_cond_broadcast(&condition);
// 销毁资源
pthread_mutex_destroy(&mutex);
pthread_cond_destroy(&condition);
2.2.3.4.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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
#import "MutexDemo3.h"
#import <pthread.h>

@interface MutexDemo3 ()

@property (nonatomic, strong) NSMutableArray *data;
@property (assign, nonatomic) pthread_mutex_t mutex;
@property (nonatomic, assign) pthread_cond_t cond;

@end

@implementation MutexDemo3

- (instancetype)init {
if (self = [super init]) {
_data = [[NSMutableArray alloc] init];

//初始化属性
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE);
//初始化锁
pthread_mutex_init(&_mutex, &attr);
//初始化条件
pthread_cond_init(&_cond, NULL);
//销毁属性
pthread_mutexattr_destroy(&attr);
}
return self;
}

- (void)otherTest {
[[[NSThread alloc] initWithTarget:self selector:@selector(__remove) object:nil] start];

[[[NSThread alloc] initWithTarget:self selector:@selector(__add) object:nil] start];
}

- (void)__remove {
NSLog(@"%s", __func__);
pthread_mutex_lock(&_mutex);
if (self.data.count == 0) {
//等待条件(进入休眠,放开mutex 锁;被唤醒后,会再次对mutex加锁)
pthread_cond_wait(&_cond, &_mutex);
}
[self.data removeLastObject];
NSLog(@"删除一个元素");

pthread_mutex_unlock(&_mutex);
}

- (void)__add {
NSLog(@"%s", __func__);
pthread_mutex_lock(&_mutex);

[self.data addObject:@"test"];
NSLog(@"添加一个元素");

//激活等待条件
pthread_cond_signal(&_cond);

pthread_mutex_unlock(&_mutex);
}

- (void)dealloc {
pthread_mutex_destroy(&_mutex);
pthread_cond_destroy(&_cond);
}

@end

执行结果:

可以保证先添加,再删除

2.2.4 dispatch_semaphore

2.2.5 dispatch_queue(DISPATCH_QUEUE_SERIAL)

2.2.6 NSLock、NSRecursiveLock

2.2.6.1 NSLock是对mutex普通锁的封装
2.2.6.1.1 使用方式

2.2.6.2 NSRecursiveLock也是对mutex递归锁的封装,API跟NSLock基本一致
2.2.6.3 案例
2.2.6.3.1 NSLock
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
#import "NSLockDemo.h"

@interface NSLockDemo()
@property (strong, nonatomic) NSLock *ticketLock;
@property (strong, nonatomic) NSLock *moneyLock;
@end

@implementation NSLockDemo


- (instancetype)init
{
if (self = [super init]) {
self.ticketLock = [[NSLock alloc] init];
self.moneyLock = [[NSLock alloc] init];
}
return self;
}

- (void)__saleTicket
{
[self.ticketLock lock];

[super __saleTicket];

[self.ticketLock unlock];
}

- (void)__saveMoney
{
[self.moneyLock lock];

[super __saveMoney];

[self.moneyLock unlock];
}

- (void)__drawMoney
{
[self.moneyLock lock];

[super __drawMoney];

[self.moneyLock unlock];
}

@end
2.2.6.3.2 NSRecursiveLock
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
#import "NSLockDemo2.h"

@interface NSLockDemo2()

@property (strong, nonatomic) NSRecursiveLock *recursiveLock;

@end

@implementation NSLockDemo2


- (instancetype)init
{
if (self = [super init]) {
self.recursiveLock = [[NSRecursiveLock alloc] init];
}
return self;
}

- (void)otherTest {
[self.recursiveLock lock];

NSLog(@"%s", __func__);

static int count = 0;
if (count < 5) {
count++;
[self otherTest];
}

[self.recursiveLock unlock];
}

- (void)otherTest2 {
[self.recursiveLock lock];

NSLog(@"%s", __func__);

[self.recursiveLock unlock];
}

@end

执行结果:

2.2.7 NSCondition

  • NSCondition是对mutex和cond的封装

2.2.7.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
65
66
#import "NSConditionDemo.h"

@interface NSConditionDemo()

@property (strong, nonatomic) NSCondition *condition;
@property (strong, nonatomic) NSMutableArray *data;

@end

@implementation NSConditionDemo

- (instancetype)init
{
if (self = [super init]) {
self.condition = [[NSCondition alloc] init];
self.data = [NSMutableArray array];
}
return self;
}

- (void)otherTest
{
[[[NSThread alloc] initWithTarget:self selector:@selector(__remove) object:nil] start];

[[[NSThread alloc] initWithTarget:self selector:@selector(__add) object:nil] start];
}

// 生产者-消费者模式

// 线程1
// 删除数组中的元素
- (void)__remove
{
[self.condition lock];
NSLog(@"__remove - begin");

if (self.data.count == 0) {
// 等待
[self.condition wait];
}

[self.data removeLastObject];
NSLog(@"删除了元素");

[self.condition unlock];
}

// 线程2
// 往数组中添加元素
- (void)__add
{
[self.condition lock];

sleep(1);

[self.data addObject:@"Test"];
NSLog(@"添加了元素");
// 信号
[self.condition signal];

// 广播
// [self.condition broadcast];
[self.condition unlock];

}
@end

执行结果:

2.2.8 NSConditionLock

  • NSConditionLock是对NSCondition的进一步封装,可以设置具体的条件值

2.2.8.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
#import "NSConditionLockDemo.h"

@interface NSConditionLockDemo()

@property (strong, nonatomic) NSConditionLock *conditionLock;

@end

@implementation NSConditionLockDemo

- (instancetype)init
{
if (self = [super init]) {
self.conditionLock = [[NSConditionLock alloc] initWithCondition:1];
}
return self;
}

- (void)otherTest
{
[[[NSThread alloc] initWithTarget:self selector:@selector(__one) object:nil] start];

[[[NSThread alloc] initWithTarget:self selector:@selector(__two) object:nil] start];

[[[NSThread alloc] initWithTarget:self selector:@selector(__three) object:nil] start];
}

- (void)__one {
[self.conditionLock lockWhenCondition:1];

NSLog(@"%s", __func__);
sleep(1);

[self.conditionLock unlockWithCondition:2];
}

- (void)__two {
[self.conditionLock lockWhenCondition:2];

NSLog(@"%s", __func__);
sleep(1);

[self.conditionLock unlockWithCondition:3];
}

- (void)__three {
[self.conditionLock lockWhenCondition:3];

NSLog(@"%s", __func__);

[self.conditionLock unlock];
}

@end

执行结果:

通过设置Condition,可以实现按想要的顺序执行任务,或者说任务之间的依赖关系

condition默认值是0

即:

使用[[NSConditionLock alloc] init]初始化,condition为 0

使用- (void)lock代表直接加锁

即:

[self.conditionLock lock]

2.2.9 dispatch_queue - 串行队列

  • 直接使用GCD的串行队列,也是可以实现线程同步的

2.2.9.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
#import "SerialQueueDemo.h"

@interface SerialQueueDemo()

@property (nonatomic, strong) dispatch_queue_t ticketQueue;
@property (nonatomic, strong) dispatch_queue_t moneyQueue;

@end

@implementation SerialQueueDemo

- (instancetype)init {
if (self = [super init]) {
self.ticketQueue = dispatch_queue_create("ticketQueue", DISPATCH_QUEUE_SERIAL);
self.moneyQueue = dispatch_queue_create("moneyQueue", DISPATCH_QUEUE_SERIAL);
}
return self;
}

- (void)__saveMoney {
dispatch_sync(self.moneyQueue, ^{
[super __saveMoney];
});
}

- (void)__drawMoney {
dispatch_sync(self.moneyQueue, ^{
[super __drawMoney];
});
}

- (void)__saleTicket {
dispatch_sync(self.ticketQueue, ^{
[super __saleTicket];
});
}

@end

执行结果:

2.2.10 dispatch_semaphore - 信号量

  • semaphore叫做”信号量”
  • 信号量的初始值,可以用来控制线程并发访问的最大数量
  • 信号量的初始值为1,代表同时只允许1条线程访问资源,保证线程同步

2.2.10.1 控制最大并发数

- (void)otherTest方法循环创建20个线程执行- (void)test方法,semaphore初始值设置为5

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#import "SemaphoreDemo.h"

@interface SemaphoreDemo()

@property (nonatomic, strong) dispatch_semaphore_t semaphore;

@end

@implementation SemaphoreDemo

- (instancetype)init {
if (self = [super init]) {
self.semaphore = dispatch_semaphore_create(5);
}
return self;
}

- (void)otherTest {
for (int i = 0; i < 20; i++) {
[[[NSThread alloc] initWithTarget:self selector:@selector(test) object:nil] start];
}
}

- (void)test {
dispatch_semaphore_wait(self.semaphore, DISPATCH_TIME_FOREVER);

sleep(2);
NSLog(@"test - %@", [NSThread currentThread]);

dispatch_semaphore_signal(self.semaphore);
}

@end

运行结果:

实现了控制最大并发数5

2.2.10.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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
@interface SemaphoreDemo()

@property (nonatomic, strong) dispatch_semaphore_t semaphore;
@property (nonatomic, strong) dispatch_semaphore_t tacketSemaphore;
@property (nonatomic, strong) dispatch_semaphore_t moneySemaphore;

@end

@implementation SemaphoreDemo

- (instancetype)init {
if (self = [super init]) {
self.semaphore = dispatch_semaphore_create(5);
self.tacketSemaphore = dispatch_semaphore_create(1);
self.moneySemaphore = dispatch_semaphore_create(1);
}
return self;
}

- (void)otherTest {
for (int i = 0; i < 20; i++) {
[[[NSThread alloc] initWithTarget:self selector:@selector(test) object:nil] start];
}
}

- (void)test {
dispatch_semaphore_wait(self.semaphore, DISPATCH_TIME_FOREVER);

sleep(2);
NSLog(@"test - %@", [NSThread currentThread]);

dispatch_semaphore_signal(self.semaphore);
}


- (void)__saveMoney {
dispatch_semaphore_wait(self.moneySemaphore, DISPATCH_TIME_FOREVER);

[super __saveMoney];

dispatch_semaphore_signal(self.moneySemaphore);
}

- (void)__drawMoney {
dispatch_semaphore_wait(self.moneySemaphore, DISPATCH_TIME_FOREVER);

[super __drawMoney];

dispatch_semaphore_signal(self.moneySemaphore);
}

- (void)__saleTicket {
dispatch_semaphore_wait(self.tacketSemaphore, DISPATCH_TIME_FOREVER);

[super __saleTicket];

dispatch_semaphore_signal(self.tacketSemaphore);
}

@end

执行结果:

虽然打印结果已经保证线程同步,但是窗口收到了警告

2.2.10.2.1 使用信号量可能会造成线程优先级反转,且无法避免

QoS (Quality of Service),用来指示某任务或者队列的运行优先级;

  1. 记录了持有者的api都可以自动避免优先级反转,系统会通过提高相关线程的优先级来解决优先级反转的问题,如 dispatch_sync, 如果系统不知道持有者所在的线程,则无法知道应该提高谁的优先级,也就无法解决反转问题。
  2. 慎用dispatch_semaphore做线程同步

dispatch_semaphore容易造成优先级反转,因为api没有记录是哪个线程持有了信号量,所以有高优先级的线程在等待锁的时候,内核无法知道该提高那个线程的优先级(QoS);

  1. dispatch_semaphore不能避免优先级反转的原因

在调用dispatch_semaphore_wait()的时候,系统不知道哪个线程会调用 dispatch_semaphore_signal()方法,系统无法知道owner信息,无法调整优先级。dispatch_group和semaphore类似,在调用enter()方法的时候,无法预知谁会leave(),所以系统也不知道owner信息

2.2.11 @synchronized

  • @synchronized是对mutex递归锁的封装
  • 源码查看:objc4中的objc-sync.mm文件
  • @synchronized(obj)内部会生成obj对应的递归锁,然后进行加锁、解锁操作

2.2.11.1 底层原理

使用哈希表结构,将穿进去的obj作为key,找到低层封装mutex的锁,再进行加锁、解锁操作

@synchronized (obj):obj如果相同,则代表使用同一把锁

2.2.11.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
#import "SynchronizedDemo.h"

@implementation SynchronizedDemo

- (void)__saveMoney {
// 取钱、存钱 共用一把锁
@synchronized (self) {
[super __saveMoney];
}
}

- (void)__drawMoney {
@synchronized (self) {
[super __drawMoney];
}
}

- (void)__saleTicket {
// 买票 - 单独创建一把锁
static NSObject *lock;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
lock = [[NSObject alloc] init];
});
@synchronized (lock) {
[super __saleTicket];
}
}

@end
  1. 拓展

3.1 iOS线程同步方案性能比较

性能从高到低排序

  • os_unfair_lock
  • OSSpinLock
  • dispatch_semaphore
  • pthread_mutex
  • dispatch_queue(DISPATCH_QUEUE_SERIAL)
  • NSLock
  • NSCondition
  • pthread_mutex(recursive)
  • NSRecursiveLock
  • NSConditionLock
  • @synchronized

性能排行仅供参考,不同环境实际效果可能不一样

3.2 自旋锁、互斥锁比较

3.2.1 什么情况使用自旋锁比较划算?

  • 预计线程等待锁的时间很短
  • 加锁的代码(临界区)经常被调用,但竞争情况很少发生
  • CPU资源不紧张
  • 多核处理器

3.2.2 什么情况使用互斥锁比较划算?

  • 预计线程等待锁的时间较长
  • 单核处理器
  • 临界区有IO操作
  • 临界区代码复杂或者循环量大
  • 临界区竞争非常激烈

@oubijiexi

本文转载自: 掘金

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

超详细讲解:http强缓存和协商缓存 引言 内容协商 后端返

发表于 2024-04-23

引言

大家是否有在面试的过程中,被问到为何第二次打开百度的官网时,比第一次打开时的速度会快,这其实就是http缓存的问题,本期就带大家了解强缓存和协商缓存,并且他们是如何实现的。

首先,先给大家介绍一下请求头和响应头,这能帮大家更好地去理解本文。

1
2
3
4
5
6
7
8
9
10
11
12
13
js复制代码const http = require('http')
const url = require('url') // url模块 做url路径的解析

const server = http.createServer((req, res) => {
const { pathname } = url.parse(`http://${req.headers.host}${req.url}`)
if (pathname === '/') {
res.end('<h1>Hello world</h1>')
}
})

server.listen(3000, () => {
console.log('listening on port 3000');
})

这里我们使用node中自带的两个模块http和url,然后我们通过pathname去获得前端请求数据的url,然后判断,如果我们后端访问的是根路径/,就返回<h1>hellow world</h1>给前端。

我们访问http://localhost:3000/:

image.png

如图,我们拿到了数据Hello World,并且以html的形式出现在浏览器上。

为什么我们后端返回的数据为<h1>Hello world</h1>,怎么被浏览器解析出来了呢?因为我们在后端并没有设置响应头的格式,而浏览器会默认将它以html的格式解析出来。

如果我们想要将后端返回的数据以json形式去加载的话,我们可以在响应头上设置一个字段:'Content-Type': 'application/json',那么浏览器就会以这个格式进行加载:

image.png

上述,这样我们就实现了一个简单的http服务。添加响应头和请求头的特性是在HTTP/1.0版本被发展出来的。 因为Http/1.0支持多种类型文件的传输,那么就需要告诉浏览器需要以哪种方式去加载这些文件,通过引入请求头和响应头来让客户端和服务端更加深入的交流,key-value形式。

关于http的发展史,大家可以看看我写的文章:说一说http的发展史

内容协商

内容协商是什么呢?很简单,就是前后端沟通好以何种方式去解析数据。

前端是可以告诉后端它所想要拿到的数据的格式的,通过在请求头中设置accept来期望后端返回的是一个什么样的数据类型,如果不写就是默认的,我们可以在请求头中看到:

image.png

而在后端中我们也可以拿到前端期望的格式:

1
2
js复制代码const accept = req.headers.accept
console.log(accept)

image.png

因此,在后端获取到这个accept时,可以知道前端想要拿到哪些格式,如果前端想要json格式的数据,那就返还json格式过去。

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
js复制代码const { log } = require('console');
const http = require('http');
const url = require('url');

const responseData = {
ID: 'zhangsan',
Name: '张三',
RegisterDate: '2024年3月28日'
}
function toHTML(data) {
return `
<ul>
<li><span>账号:</span> <span>${data.ID}</span></li>
<li><span>昵称:</span> <span>${data.Name}</span></li>
<li><span>注册时间:</span> <span>${data.RegisterDate}</span></li>
</ul>
`
}



const server = http.createServer((req, res) => {
const { pathname } = url.parse(`http://${req.headers.host}${req.url}`)
console.log(pathname);
if (pathname === '/') {
const accept = req.headers.accept
console.log(accept);
if (accept.indexOf('application/json') !== -1) {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(responseData))
} else {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
res.end(toHTML(responseData))
}
} else {
res.writeHead(404, { 'Content-Type': 'text/html' })
res.end('<h1>Not Found</h1>')
}
});

server.listen(3000, () => {
console.log('Server is running on port 3000');
});

如上述代码,后端通过从获得的accept中去查找前端是否期望传json的格式过去,如果有,就传json过去,如果没有,我们就传html格式过去。

所以页面最终输出:

image.png

后端返回静态资源给前端

现在我们所讲跟强缓存和协商缓存并没有什么必然联系,但是写的详细一些希望能帮大家更好地去理解node及两个缓存

首先,我们写一份html文件,文件里面有一个文本和一张图片,我们现在的目的是当前端访问http://localhost:3000/index.html时,可以看到这个页面。

1
2
3
4
5
6
7
8
9
10
11
12
html复制代码<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<h1>来颗奇趣蛋</h1>
<img src="assets/logo.jpg" alt="">
</body>
</html>

这是我们的目录结构:图片存在assets当中。

image.png

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
js复制代码const http = require('http');
const url = require('url');
// 绝对路径相对路径
const path = require('path');
// node 自带的文件系统
const fs = require('fs');

// 前后端不分离,把一个静态资源返回给前端
const server = http.createServer((req, res) => {
// __dirname当前js文件的绝对路径
// 将前端请求的地址转换成url格式,再拼接www这个路径,最后读取整个文件的绝对路径
let filePath = path.resolve(__dirname, path.join('www', url.fileURLToPath(`file:/${req.url}`)))
// console.log(filePath);
if (fs.existsSync(filePath)) {
const stats = fs.statSync(filePath)//读取文件的详细参数

const isDir = stats.isDirectory()// 用来判断读到的是文件还是文件夹
if (isDir) {
filePath = path.join(filePath, 'index.html')
}
if (!isDir || fs.existsSync(filePath)) {
// 读取资源文件向前端返回
const content = fs.readFileSync(filePath) // 读取文件
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
return res.end(content)
}
}

res.writeHead(404, { 'Content-Type': 'text/html' })
return res.end('<h1>Not Found</h1>')

})

server.listen(3000, () => {
console.log('server is running at port 3000')
});

这里我们先需要拿到前端的url,再通过path模块去拿到文件的一个绝对路径,我们这里的filePath就会打印:D:\codespace\面试题\http缓存-2\www\index.html。然后我们通过引入node自带的文件模块fs,去判断filePath这个文件资源是否存在,如果存在,会去读取文件的详细参数,而这个详细参数就比如文件的大小,创造时间等。然后再去读取文件,在node环境中,一个文件是会被默认读取 buffer 16进制数据流的,然后将这个数据流向前端输出。并且我们要在响应头告诉前端如何解析这个buffer数据流,要不然前端不会知道如何去解析这个16进制数据流。

所以,当我们访问http://localhost:3000/index.html,就可以看到页面:

image.png

但是我们又发现一个问题:

我们来看图片的响应:

image.png

我们发现乱码了,这是因为图片是在html文件里面再次请求的,我们并没有给它设置读取的格式,所以会乱码。而图片能加载出来是因为谷歌浏览器太强大了。

解决图片乱码

在上面我们看到,图片乱码了,这是因为我们并没有给浏览器设置读取的格式,我们来解决一下:

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
js复制代码const http = require('http');
const url = require('url');
// 绝对路径相对路径
const path = require('path');
// node 自带的文件系统
const fs = require('fs');
const mime = require('mime-types');

// 前后端不分离,把一个静态资源返回给前端
const server = http.createServer((req, res) => {
// __dirname当前js文件的绝对路径
// 将前端请求的地址转换成url格式,再拼接www这个路径,最后读取整个文件的绝对路径
let filePath = path.resolve(__dirname, path.join('www', url.fileURLToPath(`file:/${req.url}`)))
// console.log(filePath);
if (fs.existsSync(filePath)) {
const stats = fs.statSync(filePath)//读取文件的详细参数

const isDir = stats.isDirectory()// 用来判断读到的是文件还是文件夹
if (isDir) {
filePath = path.join(filePath, 'index.html')
}
if (!isDir || fs.existsSync(filePath)) {
// 读取资源文件向前端返回
const content = fs.readFileSync(filePath) // 读取文件
const { ext } = path.parse(filePath)
console.log(ext);
if (ext === '.jpg') {
res.writeHead(200, { 'Content-Type': 'image/jpg' })
} else {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
}
return res.end(content)


}
}

res.writeHead(404, { 'Content-Type': 'text/html' })
return res.end('<h1>Not Found</h1>')

})

server.listen(3000, () => {
console.log('server is running at port 3000')
});

我们的图片为jpg格式,const { ext } = path.parse(filePath),我们通过解构拿到ext,我们打印一下ext来看一下:

image.png

这样后端就拿到了请求了文件的格式,然后我们通过if语句去判断,告诉前端该以何种格式去加载。

image.png

这样图片就没出现乱码了。

但是这样还会出现一个问题,如果我的图片格式是png,或者是jpeg等等格式呢?那么难道我要写很多if语句去进行判断吗?

mime-types

其实官方提供了一个工具给我们去匹配格式,我们需要去安装一个依赖: npm i mime-types。后端就可以去返回对应的格式告诉浏览器。

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
js复制代码const http = require('http');
const url = require('url');
// 绝对路径相对路径
const path = require('path');
// node 自带的文件系统
const fs = require('fs');
const mime = require('mime-types');

// 前后端不分离,把一个静态资源返回给前端
const server = http.createServer((req, res) => {
// __dirname当前js文件的绝对路径
// 将前端请求的地址转换成url格式,再拼接www这个路径,最后读取整个文件的绝对路径
let filePath = path.resolve(__dirname, path.join('www', url.fileURLToPath(`file:/${req.url}`)))
// console.log(filePath);
if (fs.existsSync(filePath)) {
const stats = fs.statSync(filePath)//读取文件的详细参数

const isDir = stats.isDirectory()// 用来判断读到的是文件还是文件夹
if (isDir) {
filePath = path.join(filePath, 'index.html')
}
if (!isDir || fs.existsSync(filePath)) {
// 读取资源文件向前端返回
const content = fs.readFileSync(filePath) // 读取文件
const { ext } = path.parse(filePath)
console.log(ext);

res.writeHead(200, { 'Content-Type': mime.lookup(ext) })

return res.end(content)
}
}

res.writeHead(404, { 'Content-Type': 'text/html' })
return res.end('<h1>Not Found</h1>')

})

server.listen(3000, () => {
console.log('server is running at port 3000')
});

好的,现在我们就正式开始来讲解:

首先我们先来看看百度:

image.png

请求百度的图片是0ms,没有耗费任何时间,我们知道只要网络请求一定是需要花费时间的,那么就说明这些图片压根就没有发送请求,就没有向百度的服务器去要这些图片,那么这些图片就一定被缓存起来了,这就是http缓存。

强缓存

image.png

我们看上图,发现响应头里有一个字段Cache-Control,发现它的max-age=315360000,单位为s,这其实就是缓存的时间,我们换算一下,计算得出为10年,也就是说,当我们第一次访问百度页面,在10年内拿图片就不用再去发接口请求了。这就是强缓存。

实现强缓存

如何去实现强缓存是十分简单的,我们只需要在后端的响应头当中添加一个字段'Cache-control': 'max-age=86400',缓存为一天时间:

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
js复制代码const http = require('http');
const url = require('url');
const path = require('path');
const fs = require('fs');
const mime = require('mime-types');

// 前后端不分离,把一个静态资源返回给前端
const server = http.createServer((req, res) => {
let filePath = path.resolve(__dirname, path.join('www', url.fileURLToPath(`file:/${req.url}`)))
if (fs.existsSync(filePath)) {
const stats = fs.statSync(filePath)//读取文件的详细参数
const isDir = stats.isDirectory()// 用来判断读到的是文件还是文件夹
if (isDir) {
filePath = path.join(filePath, 'index.html')
}
// --------------------------------------------------
if (!isDir || fs.existsSync(filePath)) {
// 读取资源文件向前端返回
const content = fs.readFileSync(filePath) // 读取文件
const { ext } = path.parse(filePath)
console.log(ext);

res.writeHead(200, {
'Content-Type': mime.lookup(ext),
'Cache-control': 'max-age=86400' // 一天
})

return res.end(content)
}
}

res.writeHead(404, { 'Content-Type': 'text/html' })
return res.end('<h1>Not Found</h1>')

})

server.listen(3000, () => {
console.log('server is running at port 3000')
});

那当我们第二次访问时,请求也就变成0ms啦:

image.png

强缓存的缺点

强缓存在某些情况下是十分好用的,比如我们刚刚展示的那张百度的logo,百度的图片一般是不会进行更换的,只会在某些节假日的时候才会更换,而它们也是通过特殊手段进行更换。如果我们将某种静态资源进行强缓存了,那么当我们改变了该静态资源,页面是不会进行更新的。

比如,我们此时强缓存了一张图片,那么我们换一张图片进行展示,命名与之前一致。小伙伴们可以试一下,刷新浏览器,图片是不会进行变更的。这是因为旧图片已经被缓存到本地了,如果后端的资源发生变更,强缓存并不会知道,所以不会进行刷新。

但是我们可以进行强制刷新: shift + F5

还有一个重点,在我们强缓存图片的同时,大家有没有发现我们只缓存住了图片资源,并没有缓存住上方的index.html:

image.png

我可以明确的告诉大家,这并不是文件资源格式的问题,我们在后端设置了响应头,我们来查看一下index.html:

image.png

我们发现,在响应头中仍然是有max-age=86400的字段的,说明我们后端写的响应头是生效的,那么我们再来看看请求头:

image.png

在请求头中发现了max-age=0,这是因为浏览器默认给请求头设置了一个这个字段,它让请求头中的max-age无法生效。

为什么浏览器会给请求头默认设置max-age=0呢?

这是因为通过浏览器Url地址栏发送的get请求,无法被强缓存

所以浏览器会给请求头默认加上一个max-age=0。

首先大家要明白,在浏览器中url地址栏输入一段url发送请求,那么这个请求的类型只能是get请求。那什么是从url地址栏发送请求呢?如下图

image.png

所以,我们拿到的这份html文件是不能进行强缓存的。

在请求图片时,是浏览器获取到了html代码,加载页面时,碰到了需要进行请求的资源,比如我们这里的图片,所以此时图片是可以进行强缓存的

image.png

总结强缓存

  • 设置响应头:
    ‘Cache-Control’: ‘max-age=xxxxxx’
  • 若不改变资源文件命名,在变更同样格式的资源时,浏览器不能去加载新的资源,会从本地缓存中读取旧文件。
  • 通过浏览器Url地址栏发送的get请求,无法被强缓存

协商缓存

协商缓存出现的意义就是它可以去缓存通过浏览器Url地址栏发送的get请求,也就是说可以缓存我们这里的index.html文件。

并且协商缓存可以解决: 一旦后端资源变更了,前端能立马拿到更新的资源。

实现协商缓存

很简单,我们只需要在响应头中再添加一个字段:'Last-Modified',那么它的值是什么呢?

它的值应该是一个时间戳,它记录着资源文件上一次被修改的时间,那么我们如何获得这个文件呢?

大家是否记得,我们可以通过const stats = fs.statSync(filePath)去读取文件的详细参数,我给大家打印一下这个stats来看:

image.png

这里的mtimeMs就记录着上一次文件修改的时间。

所以,我们响应头需要这么写:

1
2
3
4
5
js复制代码 res.writeHead(status, {
'Content-Type': mime.lookup(ext),
'Cache-Control': 'max-age=86400', // 一天
'Last-Modified': stats.mtimeMs // 时间戳 资源修改的时间
})

当我们加上字段后,去浏览器,点击刷新:

image.png

响应头当中就多了一个Last-Modified的字段,它的值为文件资源最后一次修改的时间。我们再来看看请求头:

image.png

发现请求头当中出现了一个if-Modified-Since字段,而它的值为响应头的的返回值,也就是资源文件上一次修改的时间。

服务器首先返回一个时间戳给前端,它记录着资源文件的修改时间。然后浏览器自动将这份时间保存在请求头当中。

那么,写在了请求头当中的字段,它会自动的去传给后端。

我们现在来对html文件进行一些修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
html复制代码<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<h1>来颗奇趣蛋</h1>
<h2>奇趣蛋</h2>
<img src="assets/logo.jpg" alt="">
</body>
</html>

因为我的后端资源文件被改变了,那么操作系统帮我们记录的文件上一次修改的时间也会变更:

image.png

我们可以发现,这个字段的值改变了,因为我们修改了文件,那么此时,响应头中的'Last-Modified'和请求头中的if-Modified-Since就不一样了。

然后我们就可以通过比对响应头和请求头的时间戳,去判断后端的资源文件有没有改变:

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
js复制代码const http = require('http')
const url = require('url')// url模块 做字符串url路径的解析
const path = require('path')// path 解析路径 解析绝对相对
const fs = require('fs') // 文件模块
const mime = require('mime-types')

const server = http.createServer((req, res) => {
let filePath = path.resolve(__dirname, path.join('www', url.fileURLToPath(`file:/${req.url}`)))
if (fs.existsSync(filePath)) {
const stats = fs.statSync(filePath)
console.log(stats);
const isDir = stats.isDirectory()
if (isDir) {
filePath = path.join(filePath, 'index.html')
}
if (!isDir || fs.existsSync(filePath)) {
const content = fs.readFileSync(filePath)
const { ext } = path.parse(filePath)
const timeStamp = req.headers['if-modified-since']
let status = 200
if (timeStamp && Number(timeStamp) === stats.mtimeMs) { // 该资源没有被修改
status = 304 // 资源未修改
}

res.writeHead(status, {
'Content-Type': mime.lookup(ext),
'Cache-Control': 'max-age=86400', // 一天
'Last-Modified': stats.mtimeMs // 时间戳 资源修改的时间
})

return res.end(content)
}
}
res.writeHead(404, { 'Content-Type': 'text/html' })
res.end('<h1>Not Found</h1>')
})

server.listen(3000, () => {
console.log('listening on port 3000');
})

如果timeStamp && Number(timeStamp) === stats.mtimeMs,也就是后端文件没有被修改,那么我们就返回304状态码,304状态码的意思就是后端资源未修改,那么直接从缓存中去读取这个html文件。

如果判断为不相等,那么则返回最新的资源给前端。

首先我们来看看原本html文件的大小:

image.png

可以看到原本的文件大小为536B。

我们再次刷新浏览器,因为后端文件没有被修改,判断时间戳相等,返回状态304,所以直接从缓存中读取:

image.png

这样文件的大小只有203B了,我们就实现了一个协商缓存!

为什么这个index.html文件不能跟图片一样是0B,这是因为html这个类型是不可能为0的

协商缓存的缺点:

  1. 但是协商缓存仍然无法解决我们在强缓存所说的那个问题,如果后端资源的内容变了但是文件的命名没有去改变,那么浏览器还是不能去更新。
* 如上述换了一张图片但是命名不变
* **如何解决**?


    + 我们需要通过在文件名后面加一个`hash`值,只要文件的内容被更改了,那么`hash`值一定会更改,相应的文件名也会改变,这样浏览器就会重新加载。
    + 例如:

image.png

  1. 如果我们去给html文件添加一段代码,但是后面我们觉得这段代码没什么用,又把它删了,那么时间戳依然会改变,因为我们修改了文件,因此前端还是会重新发送请求。

问题的本质在于我们是通过判断文件修改的时间去决定是否进行缓存,但是只要我们进行编辑了就会刷新时间,尽管内容不变。

这里我们就需要依赖另一个字段了: etag。

etag

etag就是一个文件签名,它是通过文件的内容来生成的,也就是说如果文件的内容不变,那么etag也是不变的。

而etag跟Last-Modified差不多,浏览器也会在请求头中自动生成一个字段:if-none-match,且值为etag的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
js复制代码const checksum = require('checksum')

checksum.file(filePath, (err, sum) => {
const resStream = fs.createReadStream(filePath)
sum = `"${sum}"`
if (req.headers['if-none-match'] === sum) {
res.writeHead(status, {
'Content-Type': mime.lookup(ext),
'Cache-Control': 'max-age=86400',
'etag': sum // 签名(文件资源)也可以做协商缓存
})
} else {
res.writeHead(200, {
'Content-Type': mime.lookup(ext),
'etag': sum
})
return resStream.pipe(res)
}
})

这里我们需要安装一个依赖checksum, etag的值也就是文件签名,它可以完整的去代表这个文件,它是由文件的内容去生成的。

这个etag我们就不细讲了,感兴趣的小伙伴可以自行查阅一下。

总结:

  • 设置响应头:
    ‘Last-Modified’: stats.mtimeMs’ // 时间戳

浏览器就会自动在请求头中携带if-modified-since,且值为响应头的返回值,当后端判断前端携带的时间戳和后端文件的修改
时间一致时,返回304状态码,让浏览器从缓存中读取数据

回顾

强缓存和协商缓存在某一些方面是强大的,而他们在许多大公司的官网也是经常被用到的。因为过多的网络请求是十分耗费时间和性能的。许多的官网上放着很多的图片,就例如百度,那他们绝对不会允许当用户访问的时候需要发送如此多的网络请求,所以使用了缓存机制。

本文转载自: 掘金

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

iOS - 多线程-GCD-队列组 iOS - 多线程-GC

发表于 2024-04-23

iOS - 多线程-GCD-队列组

开发过程中,有时候想实现这样的效果

  • 多个任务并发执行
  • 所有任务执行完成后,进行下一步处理(比如回到主线程刷新UI)
  1. 队列组

可以使用GCD的队列组来实现

1.1 基本使用步骤

  • 创建队列组dispatch_group_create()
  • 创建队列dispatch_queue_create
  • 执行任务
  • 监听任务完成dispatch_group_notify
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
ini复制代码dispatch_group_t group = dispatch_group_create();
dispatch_queue_t queue = dispatch_queue_create("myqueue", DISPATCH_QUEUE_CONCURRENT);
dispatch_group_async(group, queue, ^{
for (int i = 0 ; i < 5; i++) {
NSLog(@"任务 --- 1");
}
});

dispatch_group_async(group, queue, ^{
for (int i = 0 ; i < 5; i++) {
NSLog(@"任务 --- 2");
}
});

dispatch_group_async(group, queue, ^{
for (int i = 0 ; i < 5; i++) {
NSLog(@"任务 --- 3");
}
});

dispatch_group_notify(group, queue, ^{
NSLog(@"任务 ---------- 4 ----------");
});

执行结果:

任务 1、2、3并发执行,完成后执行任务4

可以使用多个dispatch_group_notify,达到这样的效果:任务1、2、3并发执行完成后,再并发执行任务4、5

1
2
3
4
5
6
7
arduino复制代码dispatch_group_notify(group, queue, ^{
NSLog(@"任务 ---------- 4 ----------");
});

dispatch_group_notify(group, queue, ^{
NSLog(@"任务 ---------- 5 ----------");
});

@oubijiexi

本文转载自: 掘金

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

Spring Boot集成Quartz快速入门Demo

发表于 2024-04-23

1.Quartz介绍

  Quartz是OpenSymphony开源的一个项目,是一个由Java编写的开源作业调度框架。

特点

  1. 支持分布式高可用,我们需要某个定时任务在多个节点中只有某个节点可以执行时,就需要Quartz来实现,否则使用@Scheduled等方式会造成所有节点都执行一遍。
  2. 支持持久化,Quartz有专门的数据表来实现定时任务的持久化。
  3. 支持多任务调度和管理,Quartz可以在数据库中存储多个定时任务进行作业调度,可以实现定时任务的增删改查等管理。

组成

Quartz由三部分组成:

  1. 任务:JobDetail
  2. 触发器:Trigger(分为SimpleTrigger和CronTrigger)
  3. 调度器:Scheduler

2.mysql环境搭建

第一个mysql数据库

1
css复制代码docker run --name docker-mysql -e MYSQL_ROOT_PASSWORD=123456 -p 3306:3306 -d mysql

初始化数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
scss复制代码create database demo;
#
# Quartz seems to work best with the driver mm.mysql-2.0.7-bin.jar
#
# PLEASE consider using mysql with innodb tables to avoid locking issues
#
# In your Quartz properties file, you'll need to set
# org.quartz.jobStore.driverDelegateClass = org.quartz.impl.jdbcjobstore.StdJDBCDelegate
#
DROP TABLE IF EXISTS QRTZ_FIRED_TRIGGERS;
DROP TABLE IF EXISTS QRTZ_PAUSED_TRIGGER_GRPS;
DROP TABLE IF EXISTS QRTZ_SCHEDULER_STATE;
DROP TABLE IF EXISTS QRTZ_LOCKS;
DROP TABLE IF EXISTS QRTZ_SIMPLE_TRIGGERS;
DROP TABLE IF EXISTS QRTZ_SIMPROP_TRIGGERS;
DROP TABLE IF EXISTS QRTZ_CRON_TRIGGERS;
DROP TABLE IF EXISTS QRTZ_BLOB_TRIGGERS;
DROP TABLE IF EXISTS QRTZ_TRIGGERS;
DROP TABLE IF EXISTS QRTZ_JOB_DETAILS;
DROP TABLE IF EXISTS QRTZ_CALENDARS;CREATE TABLE QRTZ_JOB_DETAILS
(
SCHED_NAME VARCHAR(120) NOT NULL,
JOB_NAME VARCHAR(200) NOT NULL,
JOB_GROUP VARCHAR(200) NOT NULL,
DESCRIPTION VARCHAR(250) NULL,
JOB_CLASS_NAME VARCHAR(250) NOT NULL,
IS_DURABLE VARCHAR(1) NOT NULL,
IS_NONCONCURRENT VARCHAR(1) NOT NULL,
IS_UPDATE_DATA VARCHAR(1) NOT NULL,
REQUESTS_RECOVERY VARCHAR(1) NOT NULL,
JOB_DATA BLOB NULL,
PRIMARY KEY (SCHED_NAME,JOB_NAME,JOB_GROUP)
);

CREATE TABLE QRTZ_TRIGGERS
(
SCHED_NAME VARCHAR(120) NOT NULL,
TRIGGER_NAME VARCHAR(200) NOT NULL,
TRIGGER_GROUP VARCHAR(200) NOT NULL,
JOB_NAME VARCHAR(200) NOT NULL,
JOB_GROUP VARCHAR(200) NOT NULL,
DESCRIPTION VARCHAR(250) NULL,
NEXT_FIRE_TIME BIGINT(13) NULL,
PREV_FIRE_TIME BIGINT(13) NULL,
PRIORITY INTEGER NULL,
TRIGGER_STATE VARCHAR(16) NOT NULL,
TRIGGER_TYPE VARCHAR(8) NOT NULL,
START_TIME BIGINT(13) NOT NULL,
END_TIME BIGINT(13) NULL,
CALENDAR_NAME VARCHAR(200) NULL,
MISFIRE_INSTR SMALLINT(2) NULL,
JOB_DATA BLOB NULL,
PRIMARY KEY (SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP),
FOREIGN KEY (SCHED_NAME,JOB_NAME,JOB_GROUP)
REFERENCES QRTZ_JOB_DETAILS(SCHED_NAME,JOB_NAME,JOB_GROUP)
);

CREATE TABLE QRTZ_SIMPLE_TRIGGERS
(
SCHED_NAME VARCHAR(120) NOT NULL,
TRIGGER_NAME VARCHAR(200) NOT NULL,
TRIGGER_GROUP VARCHAR(200) NOT NULL,
REPEAT_COUNT BIGINT(7) NOT NULL,
REPEAT_INTERVAL BIGINT(12) NOT NULL,
TIMES_TRIGGERED BIGINT(10) NOT NULL,
PRIMARY KEY (SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP),
FOREIGN KEY (SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP)
REFERENCES QRTZ_TRIGGERS(SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP)
);

CREATE TABLE QRTZ_CRON_TRIGGERS
(
SCHED_NAME VARCHAR(120) NOT NULL,
TRIGGER_NAME VARCHAR(200) NOT NULL,
TRIGGER_GROUP VARCHAR(200) NOT NULL,
CRON_EXPRESSION VARCHAR(200) NOT NULL,
TIME_ZONE_ID VARCHAR(80),
PRIMARY KEY (SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP),
FOREIGN KEY (SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP)
REFERENCES QRTZ_TRIGGERS(SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP)
);

CREATE TABLE QRTZ_SIMPROP_TRIGGERS
(
SCHED_NAME VARCHAR(120) NOT NULL,
TRIGGER_NAME VARCHAR(200) NOT NULL,
TRIGGER_GROUP VARCHAR(200) NOT NULL,
STR_PROP_1 VARCHAR(512) NULL,
STR_PROP_2 VARCHAR(512) NULL,
STR_PROP_3 VARCHAR(512) NULL,
INT_PROP_1 INT NULL,
INT_PROP_2 INT NULL,
LONG_PROP_1 BIGINT NULL,
LONG_PROP_2 BIGINT NULL,
DEC_PROP_1 NUMERIC(13,4) NULL,
DEC_PROP_2 NUMERIC(13,4) NULL,
BOOL_PROP_1 VARCHAR(1) NULL,
BOOL_PROP_2 VARCHAR(1) NULL,
PRIMARY KEY (SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP),
FOREIGN KEY (SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP)
REFERENCES QRTZ_TRIGGERS(SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP)
);

CREATE TABLE QRTZ_BLOB_TRIGGERS
(
SCHED_NAME VARCHAR(120) NOT NULL,
TRIGGER_NAME VARCHAR(200) NOT NULL,
TRIGGER_GROUP VARCHAR(200) NOT NULL,
BLOB_DATA BLOB NULL,
PRIMARY KEY (SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP),
FOREIGN KEY (SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP)
REFERENCES QRTZ_TRIGGERS(SCHED_NAME,TRIGGER_NAME,TRIGGER_GROUP)
);

CREATE TABLE QRTZ_CALENDARS
(
SCHED_NAME VARCHAR(120) NOT NULL,
CALENDAR_NAME VARCHAR(200) NOT NULL,
CALENDAR BLOB NOT NULL,
PRIMARY KEY (SCHED_NAME,CALENDAR_NAME)
);

CREATE TABLE QRTZ_PAUSED_TRIGGER_GRPS
(
SCHED_NAME VARCHAR(120) NOT NULL,
TRIGGER_GROUP VARCHAR(200) NOT NULL,
PRIMARY KEY (SCHED_NAME,TRIGGER_GROUP)
);

CREATE TABLE QRTZ_FIRED_TRIGGERS
(
SCHED_NAME VARCHAR(120) NOT NULL,
ENTRY_ID VARCHAR(95) NOT NULL,
TRIGGER_NAME VARCHAR(200) NOT NULL,
TRIGGER_GROUP VARCHAR(200) NOT NULL,
INSTANCE_NAME VARCHAR(200) NOT NULL,
FIRED_TIME BIGINT(13) NOT NULL,
SCHED_TIME BIGINT(13) NOT NULL,
PRIORITY INTEGER NOT NULL,
STATE VARCHAR(16) NOT NULL,
JOB_NAME VARCHAR(200) NULL,
JOB_GROUP VARCHAR(200) NULL,
IS_NONCONCURRENT VARCHAR(1) NULL,
REQUESTS_RECOVERY VARCHAR(1) NULL,
PRIMARY KEY (SCHED_NAME,ENTRY_ID)
);

CREATE TABLE QRTZ_SCHEDULER_STATE
(
SCHED_NAME VARCHAR(120) NOT NULL,
INSTANCE_NAME VARCHAR(200) NOT NULL,
LAST_CHECKIN_TIME BIGINT(13) NOT NULL,
CHECKIN_INTERVAL BIGINT(13) NOT NULL,
PRIMARY KEY (SCHED_NAME,INSTANCE_NAME)
);

CREATE TABLE QRTZ_LOCKS
(
SCHED_NAME VARCHAR(120) NOT NULL,
LOCK_NAME VARCHAR(40) NOT NULL,
PRIMARY KEY (SCHED_NAME,LOCK_NAME)
);

commit;

说明

1
2
复制代码msyql账号root
mysql密码123456

3.代码工程

实验目的:创建一个cron的quartz定时任务,并持久化到数据库

pom.xml

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
xml复制代码<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>springboot-demo</artifactId>
<groupId>com.et</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>

<artifactId>quartz</artifactId>

<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>
<dependencies>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- implement Quartz auto config -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-quartz</artifactId>
</dependency>

<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
</dependencies>
</project>

Application.yaml

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
yaml复制代码server:
port: 8088
spring:
datasource:
quartz:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/demo?serverTimezone=GMT%2B8
username: root
password: 123456
quartz:
job-store-type: jdbc # use jdbc databse
scheduler-name: hyhScheduler # 相同 Scheduler 名字的节点,形成一个 Quartz 集群
wait-for-jobs-to-complete-on-shutdown: true # 应用关闭时,是否等待定时任务执行完成。默认为 false ,建议设置为 true
jdbc:
initialize-schema: never # 是否自动使用 SQL 初始化 Quartz 表结构。这里设置成 never ,我们手动创建表结构。
properties:
org:
quartz:
# JobStore 相关配置
jobStore:
dataSource: quartzDataSource # 使用的数据源
class: org.quartz.impl.jdbcjobstore.JobStoreTX # JobStore 实现类
driverDelegateClass: org.quartz.impl.jdbcjobstore.StdJDBCDelegate
tablePrefix: QRTZ_ # Quartz 表前缀
isClustered: true # 是集群模式
clusterCheckinInterval: 1000
useProperties: false
# 线程池相关配置
threadPool:
threadCount: 25 # 线程池大小。默认为 10 。
threadPriority: 5 # 线程优先级
class: org.quartz.simpl.SimpleThreadPool # 线程池类型

job

打印出当前线程名、当前时间及当前调用次数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
java复制代码package com.et.quartz.job;

import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;
import org.springframework.scheduling.quartz.QuartzJobBean;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.atomic.AtomicInteger;

//SimpleJob是一个简单定时任务体,用于打印出当前线程名、当前时间及当前调用次数
public class SimpleJob extends QuartzJobBean {

private final static AtomicInteger counter = new AtomicInteger(1);

@Override
protected void executeInternal(JobExecutionContext jobExecutionContext) throws JobExecutionException {
LocalDateTime now = LocalDateTime.now();
String name = Thread.currentThread().getName();
System.out.println("Execute quartz \"SimpleJob\", threadName = \"" + name +
"\", the " + counter.getAndIncrement() + "th execution, time = \"" +
now.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS")) + "\"");
}
}

Jobinit.java

job初始化,并将相关数据持久化都数据库

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
java复制代码package com.et.quartz.config;

import com.et.quartz.job.SimpleJob;
import org.quartz.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;

import java.util.HashSet;
import java.util.Set;

@Component
public class JobInit implements ApplicationRunner {

private static final String ID = "SUMMERDAY";

@Autowired
private Scheduler scheduler;

@Override
public void run(ApplicationArguments args) throws Exception {
JobDetail jobDetail = JobBuilder.newJob(SimpleJob.class)
.withIdentity(ID + " 02")
.storeDurably()
.build();
CronScheduleBuilder scheduleBuilder =
CronScheduleBuilder.cronSchedule("0/10 * * * * ? *");
//create task trigger
Trigger trigger = TriggerBuilder.newTrigger()
.forJob(jobDetail)
.withIdentity(ID + " 02Trigger")
.withSchedule(scheduleBuilder)
.startNow() //exceute Now
.build();
Set<Trigger> set = new HashSet<>();
set.add(trigger);
// boolean replace it will replace quartz's task in database when the task is started
scheduler.scheduleJob(jobDetail, set, true);
}
}

DemoApplication.java

1
2
3
4
5
6
7
8
9
10
11
12
typescript复制代码package com.et.quartz;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class DemoApplication {

public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}

以上只是一些关键代码,所有代码请参见下面代码仓库

代码仓库

  • github.com/Harries/spr…

4.测试

启动Spring Boot应用,发现每10秒运行一次定时任务

1
2
3
4
5
6
sql复制代码Execute quartz "SimpleJob", threadName = "hyhScheduler_Worker-1", the 1th execution, time = "2024-04-23 09:14:23.031"
Execute quartz "SimpleJob", threadName = "hyhScheduler_Worker-2", the 2th execution, time = "2024-04-23 09:14:30.019"
Execute quartz "SimpleJob", threadName = "hyhScheduler_Worker-3", the 3th execution, time = "2024-04-23 09:14:40.015"
Execute quartz "SimpleJob", threadName = "hyhScheduler_Worker-4", the 4th execution, time = "2024-04-23 09:14:50.017"
Execute quartz "SimpleJob", threadName = "hyhScheduler_Worker-5", the 5th execution, time = "2024-04-23 09:15:00.022"
Execute quartz "SimpleJob", threadName = "hyhScheduler_Worker-6", the 6th execution, time = "2024-04-23 09:15:10.024"

查看是否数据库有任务信息

1
2
csharp复制代码select * from QRTZ_SIMPLE_TRIGGERS qst 
select * from QRTZ_CRON_TRIGGERS qct

5.参考引用

  • www.cnblogs.com/summerday15…
  • www.liuhaihua.cn/archives/71…

本文转载自: 掘金

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

深入理解 Webpack Chunk Graph 策略

发表于 2024-04-23

作者:@JSerFeng

前言

在当前主流的 Bundler(打包工具)中,Webpack,Rollup,以及基于 native 语言的 Rspack,esbuild,farm 等,它们的 chunk 分割策略各不相同。

这里我主要介绍我比较熟悉的 Webpack,它的 chunk 策略到底是怎样的,通过这篇文章,你可以完全理解代码中,到底怎样会产生 chunk,怎样减少 chunk 体积等。

为了简便起见,这里我们将概念做适当简化,比如 module 即为你的文件,chunk 即为一堆 module 聚合的大文件。

同时 webpack 中 chunk 是没有父子关系的,但是 chunk group 有父子关系,由于 chunk group 概念牵扯到 splitChunks,这里暂且不说,我们这篇文章说的 chunk 父子关系就是 chunk group 父子关系,方便读者们理解。

Webpack 怎样执行产物

Webpack 有一套很类似 commonjs 的运行时代码,你的 module 代码都会放在一个 map 中,简化写法如下

1
2
3
4
js复制代码const __webpack_modules__ = {
"./src/index.js": function(module, exports, require) { /*index.js 的代码*/ },
"./src/foo.js": function(module, exports, require) { /*foo.js 的代码*/ },
}

在入口 chunk 中,只有从入口 module 静态 import 到的 module 才会在这个 map。
模块的执行类似于 commonjs require,不过名字叫做 webpack_require,简化写法如下

1
2
3
4
5
6
7
8
9
10
11
12
13
js复制代码const cache = {}
function __webpack_require__(moduleId) {
if (moduleId in cache) {
return cache[moduleId]
}
const module = { exports: {} }
cache[moduleId] = module.exports;
__webpack_modules__[moduleId](module, module.exports, __webpack_require__)
return module.exports
}

// 执行入口
__webpack_require__('./src/index.js')

当代码中含有动态 import 语句的时候,会去做 chunk 的加载,在加载后,继续使用 webpack_require 去执行
例如我们有一个 bar.js module 在 bar-chunk 这个 chunk 中,此时我们动态加载 bar.js,源码如下

1
js复制代码import('./src/bar.js')

会被转换成

1
2
js复制代码__ensure_chunk__('./bar-chunk.js')
.then(() => __webpack_require__('./src/bar.js'))

这里的 ensure_chunk 会去加载 bar-chunk.js,一般来说浏览器端就是给 html body 插一个 script,src 放上 bar-chunk 的 url,当加载 chunk 后,该 chunk 的所有 modules 也会被加到 webpack_modules 中去,然后调用 webpack_require 执行。

因此可以发现,module 执行顺序和 chunk 加载顺序并没有关系,只需要保证执行之前对应的 chunk 已经存在即可,
并且 module 如果在多个 chunk 也没有关系,因为同一个 module 只会执行一次,可以看上面简化代码中的 cache。

Rollup 怎样执行产物

我们再看 Rollup,Rollup 几乎没有运行时,产物就是 module 的代码内容做一些转换后拼接成一个大文件:

1
2
3
4
5
js复制代码// ./src/foo.js
console.log('foo.js 中的代码')

// ./src/index.js
import('./bar-chunk.js')

其中遇到动态 import 语句后,是直接使用 esm 的 import() 语法加载(取决于你的 output format)。

Rollup 产物的代价

初看这种产物会觉得很直观,但其实有问题,在 import 的时候就会执行 module 中的代码:

  1. Module 如果重复出现在多个 chunk 中可能会执行多次,如果 module 有一些全局副作用大部分情况下都会出现错误
  2. Module 顺序如何保证?假如从一条引用链中 module 的执行顺序是 A -> B -> C,但另一条引用链中出现了 D -> C -> B,该怎样保证 B 和 C 在不同的引用链中执行顺序正确

其中第 1 点,对于重复 module,大部分情况下 Rollup 可以把重复 module 提出去,变成单独的 Chunk,是解决了问题,但是引出了新的问题,小 Chunk 可能会非常多,因为哪怕是很小的一个 module,只要出现在多个 Chunk 中都不得不将 module 移出成单独 Chunk。而这种情况在业务开发中非常常见。

举个例子,大家写 React 通常会使用 import('./Home.tsx') 来做路由的懒加载,然后常见的大家会在代码中写一些通用的工具 utils,在多个页面中共用 utils,那么 utils 就是一个重复 module,会被单独提出成 Chunk,如果你的 utils 很小,如果是 10 k,你需要为这 10 kb 而进行一次网络请求,我们说的是这一个 utils,还有非常多的地方会造成小module 在 Chunk 中复用多次的情况。

你可能会想,我将多个重复的 module 打到一个 chunk 中是不是就能解决问题呢?
并不能,你马上就会遇到 module 执行顺序的问题,接下来会提到。

对于第 2 点,现在的 Rollup 并不能做到保证 module 执行顺序的一致性,请看 playground,这里有 2 个入口,第一个引入顺序为 a b,第二个引入顺序为 b a,如果我只想执行 home 入口,仍然只会输出 a b:

image.png

解决方案也有,拆成更小的 module,例如 a 和 b 单独拆出,那上面说的小 chunk 太多的问题就会更加严重。而且这种顺序的检测对性能可能也会有很大的影响。

Webpack 到底怎样生成 Chunk

在 Webpack module 可以重复出现,并且 Chunk 中的 module 顺序可以不用关心,那么构建 Chunk Graph 的过程就变得非常简单了。
一般情况下,不考虑各种 Worker,Module Federation,new URL 等,拆分 chunk 的情况就是 esm 的动态导入语句了,也就是 import("./path")。

Chunk Graph 生成流程

构建 Chunk 图的逻辑其实很简单,

  1. 遍历当前模块的导入语句
  2. 遇到静态 import,就将引入的 module 放到当前 Chunk 中,然后继续遍历该 module 的所有导入
  3. 遇到动态 import,就新创建一个 Chunk,将引入的 module 放到新 Chunk 中,新 Chunk 变成当前 Chunk,继续遍历 module 即可

考虑如下模块图:


其中,实线代表静态的 esm import 语句产生的引入,虚线代表动态 import() 产生的引入
首先会有一个入口 chunk,由入口 module 开始,引入 a 会将 a 放入到入口 chunk,这里外层块代表 chunk,内层的每一个块代表 module。


在 a 发现了动态 import(“./shared”),会创建一个新的 chunk,我们就叫该 chunk 为 shared-chunk-1,后面会解释为什么有个后缀 1


然后回到 index 中还有一个引入 b,将 b 加到入口 chunk


从 b 出发,发现动态 import('./shared'),此时由于该 import("./shared") 语句的位置,和之前的 import("./shared") 位置不同,该 import("./shared") 是从 b module 触发的,而之前的是从 a module 触发,此时不会复用 shared-chunk-1,而是会新创建 shared-chunk-2。


但是这里其实可以使用 webpack 中的 magic 注释来强制复用 chunk,做法是将两处的 import('./shared') 都写成 import(/* webpackChunkName: "shared" */ './shared'),这样做的话,shared chunk 只会创建一个。

此时 Chunk 创建完毕,这个时候你会发现有两个一模一样的 chunk,shared-chunk-1 和 shared-chunk-2,早期的 webpack 中就加入了 mergeDuplicateChunk 来对 chunk 进行去重,去重后就只剩下一个 shared chunk 了。


你可能会觉得这样做并不合理,为何要创建重复 chunk 再进行去重,这不是多此一举吗,我们可以考虑一种情况,多入口的情况,将模块拓扑图变一下:


其中 index 和 home 分别为两个入口,他们都共同动态引入了 shared 模块。同时 shared 模块与入口 index 都静态引入了 m 模块。

我们来分析由 index 开始,入口 chunk 中应该会有 index 和 m 两个 module。


然后 index 动态引入 shared,因此会产生 shared-chunk-1,并且 shared module 中引入了 m,shared-chunk-1 中会含有 m,如图:


此时在看第二个 entry:home,home 自己是入口 chunk 中,并且 home module 也动态引入了 shared,因此最终 chunk 如下:


此时 removeAvailableModules 优化就派上用场了,你会发现,shared chunk 1 以及他的父 chunk 都包含了 m module,而 shared chunk 1 必定是在他父亲加载后进行加载的,那此时 m module 必定已经由父 chunk 加载过了,因此可以安全的将 m 从 shared chunk 1 中删除了,删除后如下:


你会发现现在两个 entry 之间的 chunk 没有交集,不同入口加载的时候只加载当前所需要的 chunk,假设两个入口加载 shared 的方式是用户点击某个按钮后进行加载,那么对于 home 的首屏来说就不需要加载 m,而对于 index 来说加载 shared 的时候不需要加载 m。

Rollup 以及 esbuild

如果你用过 Rollup,esbuild,他们都会尽量保证 0 重复 module,对于 import("./shared") 只会创建一个 chunk,使用 Rollup 来打包,我们会发现最终的 chunk 如下
(假设我们的 m module 是一个只包含一行 console.log(42) 小模块)


初看很好,0 重复 module,chunk 关系非常清晰。

但为什么一个非常小的 m module 需要放到一个单独的 chunk 呢?

因为重复 module 是 Rollup 等的大忌。其实现代 Webpack 默认也是生成这种形式,只是当 m 体积够大才会是上面的结构,如果 m 是个非常小的 module 的时候 webpack 就不会单独拆出 chunk

splitChunks

splitChunks 可以精确控制 module 分配到 chunk 的策略,如果我们想要实现类似 Rollup 效果的策略,只需要打开默认的 splitChunks 规则,稍作修改即可。

1
2
3
4
5
6
7
8
js复制代码module.exports = {
optimization: {
splitChunks: {
chunks: 'all',
minSize: 0 //真实项目不用管这个选项
}
}
}

这里 chunks: 'all' 表示对所有的 chunk 都可以进行拆分,是可以拆分,不是强制拆分。

minSize: 0 在你们的真实项目并不用开启,这里开启只是因为对于特别小的 module,webpack 并不会拆分出去,拆分反而影响加载性能,有一个默认的拆分体积阈值,这里改成 0 只是为了演示。

这样操作后,对于任意的 module,只要他同时出现在 2 个或以上 chunk 中,就会被抽离成单独的 chunk,我们看看经过 split 前的 chunk graph:


其中重复 module 有 m 和 shared,他们会被抽离出去成单独的 chunk,抽离后的关系如下:


shared chunk 1 以及 shared chunk 2 变成了空 chunk,在后面构建阶段中,webpack 会移除空 chunk,最终形成:


但 webpack 却不会出现 Rollup 那样的 module 执行顺序问题,webpack 的 chunk 只是相当于一个 module map,真正的 module 执行顺序依赖的是源码中的 import 顺序,经过任意拆分都可以使用保证正确的执行顺序。

同时由于存在 splitChunks,在配置 chunks: 'all' 之后,其实可以关闭 `mergeDuplicateModules 优化,splitChunks 的功能可以完全 cover 住 mergeDuplicateModules,而且 mergeDuplicateModules 其实挺吃性能的,算法复杂度并不低

另外 removeAvailableModules 优化同样可以关闭以提高编译性能,一方面 splitChunks 抽离同样 module,也相当于删除了多余 module。另一方面这个配置其实并没有任何作用,早期该配置是控制是否添加 webpack 的内置插件 RemoveParentModulesPlugin 的,但后来 webpack 的 code splitting 实现了一样的功能,并且性能更好好,而且 code splitting 该行为也关闭不了,在构建 chunk 的过程中就将可删除的 module 全部删除了

concatenateModules

也有人称作 scope hoisting,直译过来是连接 module,我们知道很多时候大家觉得 webpack 不够好的原因一般有3点

  1. 产物太难看了,第一次看 webpack 产物会发现充满了意义不明的注释作为缩进,以及一堆 webpack 特有的 runtime 函数,并且每个模块包在一个函数中,调用起来像是跑 cjs 一样,总感觉性能也不会好
  2. 构建性能太差了,大项目打包时间10分钟往上的也不少
  3. 配置项太细节太多(作为底层的构建工具不好说这就是缺点

这其中第一点是可以改善的。

类似 Rollup esbuild 等轻量 runtime 的打包工具,chunk 就是 module 拼接起来而已,因此看着干净,实际上 webpack 在生产环境也是这样的,对于纯 esm 模块,webpack 也会简单的将 module 拼接在一起,产物和 Rollup 等其实是一样的。而对于 cjs 模块,Rollup 需要 commonjs 插件提供少量 cjs runtime(将 cjs module 用函数包一层,require 的时候就相当于调用该函数,获取函数返回值,以及一些 cjs 和 esm 交互的 runtime),esbuild 则自带 cjs runtime

给大家看一看开启 concatenateModules 配置后,development 模式下的产物:

image.png

在结合 splitChunks 和 concatenateModules 优化后,产物基本上和 Rollup 等是一样干净的。值得注意的是,Rspack 也支持了相同的 concatenateModule 优化,参考 #5237。

附录

  • Rspack:github.com/web-infra-d…
  • 画图工具:www.doodleboard.pro/app/RlAuShK…

本文转载自: 掘金

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

【Nextjs 14】使用 Docker 进行项目部署 1

发表于 2024-04-23
  1. 概念

Docker 有三个核心概念:Image、Container、Repository

Image: 镜像是Docker的基础构建块。镜像包含了创建Docker容器所需的所有文件和配置信息,如操作系统、信用程序、依赖项等。

Container: 容器是从镜像创建的运行实例。容器是一个沙箱环境,它包含了应用程序及其所有依赖项

Repository:仓库是集中存储和分发镜像的地方,类似于github。用户可以从仓库中拉取所需的镜像。常用的公共仓库有 Docker Hub: hub.docker.com/ 。也可搭建自己的私有镜像仓库,如阿里云容器镜像服务

  1. 安装

Mac:docs.docker.com/desktop/ins…

Windows:docs.docker.com/desktop/ins…

Linux:docs.docker.com/desktop/ins…

提示:在windows环境中,如果以前安装过Docker,然后又重新安装最新的Docker。可能会因为wsl版本过低,导致Docker安装后一直打不开。此时可在cmd中执行:wsl –update,执行完后重启电脑

  1. 设置镜像源

与配置npm为淘宝源相似,docker同样需要设置镜像源,否则可能会因网络问题导致包下载失败

在windows中配置:docker desktop -> 设置 -> Docker Engine 添加配置

1
js复制代码"registry-mirrors": ["https://dockerproxy.com"]
  1. 自动化构建

  1. next.config.mjs

修改next.config.mjs,将生产环境所需要的安装包都打包进去(不包含devDependencies中的包)

1
2
3
js复制代码const nextConfig = {
output: 'standalone',
};

配置之后,不需要在服务器上另外安装项目的node_modules

  1. .dockerignore

根目录新增.dockerignore,过滤掉不需要包含在镜像中的文件和目录

1
2
3
4
5
6
7
js复制代码Dockerfile
.dockerignore
node_modules
npm-debug.log
README.md
.next
.git
  1. Dockerfile

根目录新增Dockerfile,Dockerfile 包含了一系列用于构建Docker镜像的指令和配置信息

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
js复制代码FROM node:18-alpine AS base

# 可选,自定义参数变量,在docker build时通过--build-arg ENV=参数
# ARG ENV

# Install dependencies only when needed
FROM base AS deps
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.ustc.edu.cn/g' /etc/apk/repositories
RUN apk update
RUN apk add --no-cache libc6-compat
WORKDIR /app

COPY package.json ./
COPY package-lock.json ./
RUN npm config set registry https://registry.npmmirror.com \
&& npm_config_platform=linux npm_config_arch=x64 npm_config_libc=glibc npm ci

# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

# 可使用自定义参数变量来打包
# RUN npm run ${ENV}

# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app

ENV NEXT_TELEMETRY_DISABLED 1

COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.env /app
COPY --from=builder /app/public /app/public
COPY --from=builder /app/.next/static /app/.next/static

EXPOSE 3000

ENV PORT 3000

ENV HOSTNAME="0.0.0.0"

CMD node server.js

该配置文件主要有四个阶段:

阶段一,基础镜像:提供 Node.js 18 运行环境

阶段二,依赖安装:基于package-lock.json文件进行依赖安装,生成node_modules

阶段三,构建阶段:拷贝deps阶段的node_modules和本地文件,并运行npm run build进行打包

阶段四,运行阶段:将builder阶段的standalone,.env 等生产环境需要的文件拷贝到相应目录,配置完端口号后,执行node server.js运行Next.js程序

问题1:阶段二中为什么不直接拷贝本地的node_modules,而是重新在Docker镜像构建阶段去安装?

原因:为了兼容运行环境。例如Next.js的Image组件依赖的sharp包,在Windows安装的sharp包,运行到服务器Linux上会报错。因此在Docker构建阶段,配置npm_config_platform=linux,设置 npm 的平台环境变量为 linux

问题2:ENV NEXT_TELEMETRY_DISABLED 1配置的作用

作用:这个环境变量的作用是禁用 Next.js 应用程序的遥测功能。Next.js 默认会收集一些匿名的使用数据,并将其发送回 Vercel (Next.js 的开发公司)的服务器

问题3:如何减小生成环境包的体积

方法:配置中将public文件与static文件都拷贝到了镜像中,这些资源可以放到cdn进行访问

  1. 构建镜像

在项目根目录运行docker build,将会依据Dockerfile文件的配置内容进行镜像构建

1
js复制代码docker build -t next .

-t:给镜像命名,这里镜像名为next

. 基于当前目录的Dockerfile来构建镜像

如果需要传入自定义参数

1
js复制代码docker build --build-arg ENV=build:dev -t next .
  1. 查看镜像

1. 查看镜像

1
js复制代码docker images

2. 查看某个特定的镜像

过滤并显示与“next”相关的Docker镜像

1
js复制代码docker images | grep next
  1. 删除镜像

1. 删除镜像

1
js复制代码docker rmi <image_name>

rmi:remove image的缩写

<image_name>:要删除的镜像名称或者镜像id

2. 删除所有没有标签的镜像

1
js复制代码docker image prune

3. 删除所有未被容器使用的镜像

1
js复制代码docker image prune -a
  1. 推送镜像

  1. 私有镜像库设置

docker默认只支持https协议,如果私有库是http协议或者ip地址,需设置insecure-registries

windows配置,docker desktop -> 设置 -> Docker Engine 添加配置

1
2
js复制代码//域名或ip地址,不用加协议
"insecure-registries": ["www.test.com","11.11.11.11"]
  1. 阿里云镜像库

除了搭建私有镜像仓库,还可以使用阿里云镜像库

阿里云容器镜像服务:cr.console.aliyun.com/cn-hangzhou…

  1. 登录镜像库

1
js复制代码docker login --username=caowj registry.cn-hangzhou.aliyuncs.com

–username:用户名

registry.cn-hangzhou.aliyuncs.com:指定镜像仓库地址,如果未指定,默认为官方仓库 Docker Hub

  1. 镜像标签

给镜像打标签

1
js复制代码docker tag SOURCE_IMAGE[:TAG] TARGET_IMAGE[:TAG]

SOURCE_IMAGE[:TAG]:源镜像

TARGET_IMAGE[:TAG]:更改后的镜像名([]代表可选,不传为latest)

例如:

1
js复制代码docker tag next nodejs/next
  1. 推送镜像

1
js复制代码docker push NAME[:TAG]

NAME:要推送的镜像名称

[:TAG]: 可选的镜像标签。如果不指定标签,默认使用 latest 标签

例如:

1
js复制代码docker push nodejs/next
  1. 退出登录

1
js复制代码docker logout
  1. 构建容器

1. 拉取镜像

容器都是基于镜像构建的,在服务器先拉取刚刚推送的镜像

1
js复制代码docker pull nodejs/next

2. 构建容器

1
js复制代码docker run -di --name=next -p 3000:3000 -v /data:/app/.next/cache nodejs/next

-d:容器在后台运行

-i:允许你对容器内的 STDIN 进行交互

–name:启动的容器名

-p [主机]:[容器]:端口映射

-v /data:/app/.next/cache:将/app/.next/cache的文件与主机/data目录关联起来,实现docker数据持久化保存

nodejs/next:启动的镜像名

  1. 查看容器

1. 进入容器内部

1
js复制代码docker exec -it next /bin/sh

docker exec:进入容器内部

-it:以交互式模式进入容器

/bin/sh:进入容器后使用shell

2. 查看容器结构

1
js复制代码ls -a

ls:列出当前目录下的所有文件和目录

-a:显示包括隐藏文件(以 . 开头的文件)在内的所有内容

3. 退出容器

1
js复制代码exit
  1. 删除容器

1. 查看容器

查看运行中的容器

1
js复制代码docker ps

查看所有容器

1
js复制代码docker ps -a

2. 删除容器

根据 id 删除容器

1
js复制代码docker rm -f e31f

rm:remove缩写

-f:强制删除,即使容器在运行

3. 删除所有停止的容器

1
js复制代码docker container prune

结尾

本文只介绍了Next.js + Docker的基本使用,除此以外还有更多的用法,例如通过yml文件简化docker run命令等。后续有时间将会另起一篇进行介绍。也可自行去了解,Docker文档地址:docs.docker.com/guides/get-…

对Next.js感兴趣的,可先关注我,后续将继续更新相关内容

本文转载自: 掘金

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

lerna-lite 轻量化 monorepo 管理利器

发表于 2024-04-23

写作背景

微前端是一个新旧项结合挺常见的一种技术,我司也成功借助京东前端团队推出的 micro-app 完成了一主两从 3 个独立项目的完美结合。但随着项目整体 sass 化逐步转型开始,迭代差异化增加就,相对应的造成了项目依赖安装、启动、编译等一系列事项的频率变高,解决这个拖慢研发节奏的问题我想到的方案就是引入 Monorepo 单仓库的管理。

lerna-lite 介绍

lerna-lite 是用来管理和发布同一仓库多 JavaScript/TypeScript 包的一款工具,与 lerna 相比 lerna-lite 具有更轻量化和模块化的特点,同时 lerna-lite 也是 lerna 的一个有限子集。我们在实际项目中可以采用渐进式的方式按需安装使用。需要注意的一点是 lerna-lite 是不包括 bootstrap、add、create 和 link 命令的,所以需要正式使用 lerna-lite 之前配置好项目的包管理器(npm、pnpm、yarn)。

快速开始:

首先要将@lerna-lite/cli作为开发依赖安装到项目中:

1
2
3
4
5
6
7
8
bash复制代码# 创建一个空项目
mkdir lerna-repo
cd lerna-repo
npm init -y

# 安装 cli 依赖并执行 init 命令
npm install -D @lerna-lite/cli
node_modules/.bin/lerna init

执行 init 命令初始化工作空间,得到一个 lerna.json 配置文件和一个 packages 文件夹;

1
2
3
4
bash复制代码lerna-repo            
├─ packages
├─ lerna.json
└─ package.json

如果你不打算使用 npm 作为项目的包管理器的话需要更新 lerna.json 配置文件中的 npmClient;

  1. 使用 yarn 配置:"npmClient": "yarn"
  2. 使用 pnpm 配置:"npmClient": "pnpm"

命令列表:

命令 安装 介绍
☁️ publish npm i -D @lerna-lite/publish 发布软件包
📑 version npm i -D @lerna-lite/version 为软件包创建新版本
🕜 changed npm i -D @lerna-lite/changed 查看上一个版本发布以来更改的软件包
🌓 diff npm i -D @lerna-lite/diff 查看上一个版本发布以来软件包发生的变化
👷 exec npm i -D @lerna-lite/exec 运行 shell 命令
📖 list npm i -D @lerna-lite/list 列出工作区中的所有本地软件包
🏃 run npm i -D @lerna-lite/run 运行script 脚本
👓 watch npm i -D @lerna-lite/watch 监听所有软件包的变更并执行自定义命令

PS: 由于 publish 命令依赖于 version 命令,所以在安装 @lerna-lite/publish后即可获得这两个命令。

一起操练起来:

image.png
首先会创建三个独立的前端应用,接着会使用 micro-app 将 Angualr16 的项目改造为微前端的主应用,Vue3 + Vite 和 React + Vite 两个项目当做子应用接入,最后在升级为 Monorepo 。
lerna-lite 将会以渐进式的方式在整个过程中逐步引入。

独立前端应用(Multirepo风格):

Angualr16 应用:

  • 创建应用:
1
2
3
4
bash复制代码# 创建命令
$ npx @angular/cli@16 new angular-app
? Would you like to add Angular routing? Yes
? Which stylesheet format would you like to use? CSS
  • 分配启动端口: 修改 start 脚本 ,增加 --port 选项,指明端口号;
1
2
3
json复制代码{
"start": "ng serve --port 10010"
}
  • 通过路由组织页面:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
typescript复制代码app                                
├─ pages
│ ├─ sub-react 添加 react 子应用页面
│ │ ├─ sub-react.component.css
│ │ ├─ sub-react.component.html
│ │ └─ sub-react.component.ts
│ └─ sub-vue 添加 vue 子应用页面
│ ├─ sub-vue.component.css
│ ├─ sub-vue.component.html
│ └─ sub-vue.component.ts
├─ app-routing.module.ts 添加页面对应的路由
├─ app.component.css
├─ app.component.html 左右布局(aside + article)
└─ app.module.ts 添加新页面的组件声明

image.png
PS:文末通过访问 Github 查看项目中的变更文件,已用注释说明;

Vue3 应用:

  • 创建应用
1
2
3
4
5
bash复制代码# 创建命令
$ npm create vite@latest
✔ Project name: vue-app
✔ Select a framework: › Vue
✔ Select a variant: › TypeScript
  • 分配启动端口:修改 vite.config.ts 配置文件,增加 server.port 选项,指明端口号;
1
2
3
4
5
javascript复制代码{
server: {
port: 10011,
}
}

React 应用:

  • 创建应用
1
2
3
4
5
bash复制代码# 创建命令
$ npm create vite@latest
✔ Project name: react-app
✔ Select a framework: › React
✔ Select a variant: › TypeScript
  • 分配启动端口:同 Vue3 应用,指明端口号;
1
2
3
4
5
javascript复制代码{
server: {
port: 10012,
}
}

微前端改造(MicroApp):

以下针对对前端的改造全部在 Angualr16 主应用中进行;

安装并初始化:

安装npm i @micro-zoe/micro-app --save;

1
2
3
4
typescript复制代码// main.ts
import microApp from '@micro-zoe/micro-app';

microApp.start();

支持 WebComponent:

1
2
3
4
5
6
typescript复制代码// app/app.module.ts
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';

@NgModule({
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})

页面嵌入子应用:

1
2
3
4
5
6
7
html复制代码<!-- vue3 + vite -->
<!-- pages/sub-vue/sub-vue.component.html -->
<micro-app name="sub-vue" url="http://localhost:10011" iframe></micro-app>

<!-- react + vite -->
<!-- pages/sub-react/sub-react.component.html -->
<micro-app name="sub-react" url="http://localhost:10012" iframe></micro-app>

PS:子应用使用 vite 作为基础框架,需要主动切换到 iframe 沙箱;
2024-04-22 15.41.58.gif

lerna-lite(Monorepo风格):

按快速开始的的流程创建 simple-micro-app 项目且默认使用 npm 包管理器,执行 init 命令后将独立的三个前端应用移动到对应的目录。

1
2
3
4
5
6
7
lua复制代码simple-micro-app       
├─ packages
│ ├─ main-angular-app 对应 angular-app
│ ├─ sub-react-app 对应 react-app
│ └─ sub-vue-app 对应 vue-app
├─ lerna.json
└─ package.json

PS:pnpm包管理器需要依据团队使用熟练度来进行落地。

查看本地应用:

  • 安装:npm i -D @lerna-lite/list;
  • 添加脚本:
1
2
3
4
5
json复制代码{
"scripts": {
"list": "lerna ls -la"
}
}

PS:查看包括私有的所有 JavaScript/TypeScript 包;

删除 node_module:

  • 安装:npm i -D @lerna-lite/exec;
  • 添加脚本:
1
2
3
4
5
json复制代码{
"scripts": {
"clear": "lerna exec -- rm -rf ./node_modules"
}
}

PS:一次性删除 packages 中每个应用的 node_module 文件夹;

安装应用依赖:

  • 添加脚本:
1
2
3
4
5
json复制代码{
"scripts": {
"install": "lerna exec -- npm install"
}
}

PS:一次性安装 packages 中每个应用的依赖;

启动所有应用:

  • 安装:npm i -D @lerna-lite/run;
  • 添加脚本:
1
2
3
4
5
json复制代码{
"scripts": {
"dev": "lerna run dev --parallel"
}
}

PS:需要将 angular16 项目中的 start 脚本名修改为 dev,与其他两个应用保持一致的启动命令;

创建新版本:

  • 安装:npm i -D @lerna-lite/version;
  • 添加脚本:
1
2
3
4
5
json复制代码{
"scripts": {
"version": "lerna version"
}
}

PS:执行 version 脚本前需要保证所有的变更都已经提交;

  • 操作过程:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
bash复制代码$ npm run version

> simple-micro-app@1.0.0 version
> lerna version

lerna-lite notice cli v3.3.3
lerna-lite info current project version 0.0.0
lerna-lite info Assuming all packages changed
? Select a new version (currently 0.0.0) Prepatch (0.0.1-alpha.0)

Changes (3 packages):
- angular-app: 0.0.0 => 0.0.1-alpha.0 (private)
- react-app: 0.0.0 => 0.0.1-alpha.0 (private)
- vue-app: 0.0.0 => 0.0.1-alpha.0 (private)

? Are you sure you want to create these versions? Yes
lerna-lite info git Pushing tags...
lerna-lite info execute Skipping releases
lerna-lite success version finished

生成 CHANGELOG:

  • 修改 version 脚本:
1
2
3
4
5
json复制代码{
"scripts": {
"version": "lerna version --conventional-commits --changelog-preset angular"
}
}

PS:使用 angular 预设在创建新版本时生成 CHANGELOG.md 文件;

查看变更的应用:

  • 安装:npm i -D @lerna-lite/changed;
  • 添加脚本:
1
2
3
4
5
json复制代码{
"scripts": {
"changed": "lerna changed --all"
}
}

PS:执行 changed 查看距离上次发布版本所有包;

  • 操作过程:
1
2
3
4
5
6
7
8
9
bash复制代码$ npm run changed

> simple-micro-app@1.0.0 changed
> lerna changed --all

lerna-lite notice cli v3.3.3
lerna-lite info Looking for changed packages since v0.0.2
angular-app (PRIVATE)
lerna-lite success found 1 package ready to publish

查看变更的内容:

  • 安装:npm i -D @lerna-lite/diff;
  • 添加脚本:
1
2
3
4
5
json复制代码{
"scripts": {
"diff": "lerna diff"
}
}

PS:执行 diff 查看距离上次发布版本所有变化的内容;

  • 操作过程:
1
2
3
4
5
6
7
8
9
10
11
12
13
bash复制代码$ npm run diff

diff --git a/packages/main-angular-app/src/app/app.component.html b/packages/main-angular-app/src/app/app.component.html
<main>
<aside>
- <a [routerLink]="['/sub-vue']" routerLinkActive="active">sub-vue</a>
- <a [routerLink]="['/sub-react']" routerLinkActive="active">sub-react</a>
+ <a [routerLink]="['/sub-vue']" routerLinkActive="active">vue3</a>
+ <a [routerLink]="['/sub-react']" routerLinkActive="active">react</a>
</aside>
<article>
<router-outlet></router-outlet>
~

总结:

从一开始的三个独立的前端应用通过 micro-app 将代码从业务的层面整合到了一起,但由于此时还是 Multirepo 风格,所以对于开发维护和管理上还是存在一定的负担,每一次的迭代、BUG 修复或提交代码都需要多次 cd 到项目路径,逐个执行不同的命令,通过 lerna-lite 将它们从代码仓库的层面继续整合将大大优化拖慢研发节奏的问题。

  • micro-app 在不同的技术栈和不同的基础框架会有不一样的要求,更多内容可以访问 github.com/micro-zoe/m… 查看学习。
  • lerna-lite 的 publish 和 watch 两个命名以及其他命令的更多选项可以到 github.com/lerna-lite/… 查看学习。

PS:源码访问 simple-micro-app 获取;

本文转载自: 掘金

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

【Milvus&向量搜索】落地文档搜索业务的探索

发表于 2024-04-23

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

halo大家好,我是广州小井。本文我们继续回到文档搜索业务,跟大家一起探讨向量搜索、Milvus数据库在文档搜索这块业务领域的落地可能性。这里,我们将了解到 Mivlus 的基本使用,将有助于你对其落地当前搜索业务可行性有一个基本的判断。

前文回顾

回顾之前的那篇文章,我给大家通过举例的方式讲解了向量搜索的基本原理,这里再跟大家重温一下,以便更好的开启本篇文章的内容讲解。

首先,如下图所示:

image.png

其中,我们通过 NLP 将自然语言(文本)数据转化成结构化数据——n维向量,这样的过程简单理解为文本向量化。当我们将所有的文档数据都转化成向量后,我们便可以利用向量这个点对我们的目标数据进行搜索了。

怎么理解利用向量来搜索这句话呢?我们接着通过图例来加强理解:

向量搜索原理.png

上图是一个二维平面,假设我们的目标数据 b 的坐标为 [1.1, 1.1],它是由“开心”经过 embedding 后得到的向量值。而 a 的坐标为 [1.05, 1.05],它是“高兴”经过 embedding 后得到得向量值,当我们把 a 的向量值丢到 Milvus 数据库时,它就能通过计算“向量相似度”从而找到目标数据 b。

其原理相信大家都能想到,不就是坐标点之间的位置计算嘛?当我们把搜索数据都向量化处理后,再需要搜索内容的时候,只需要寻找这个距离这个向量最近的点,便是目标数据。

Milvus 基础准备

之前对向量搜索的讲解一直都是比较虚幻的,毕竟我只是通过图例的方式来大概说明它的基本原理,至于它是怎么在实际中使用的并没有提及。于是从这一小节开始,我们就来认识一下真实的 Milvus。

这里,我打算通过 Milvus 提供的一个开源的可视化 Web 工具——Attu来作为实战演示,感兴趣的佬们可以点进超链里面看看。当然,有需要的就自己捣鼓一下如何私有化部署在本地玩玩吧,这里我就不延申了。

接着,我会在 Attu 上做 Milvus 实现向量搜索功能的前期准备,包括集合创建,一些基础配置的讲解。不过我依然不会拓展开,就以最简配置来讲,主要的是跟大家一起探讨其搜索的效果,精细化的配置大家实战中自己再深入研究即可。

1. 创建集合

当我们点击创建集合后,会看到这个弹窗:

image.png

这里我们仅需要关注我圈起来的两个值就行(其他的配置项大家自行了解吧):

  • Name:集合名称
  • Dimension:向量维度

关于维度值怎么定?这个我个人认为就看需求吧,也并不是越高越好。比如说三维空间比平面多了个维度,所以其数据比二维更具体,理论上搜索质量更可靠,但其相比二维空间,在计算距离远近的基础上算力又增加了…所以这个东西取决于实际需求就好。

当然,拿不准这个维度值的可以自己拿真实的数据来测试测试效果,然后自己决定。而我这边因为用的模型是转成 1024 维的,所以这里我就直接配置 1024 维了。

最后扯多一句,向量维度那里的配置其实就像是我们数据库新增字段一样,有对应的字段名、值类型等等。当我们点击弹窗那里的”+“号时,便可以添加更多的字段,但是向量类型的字段(也就是要设置维度的字段)有且仅有一个。

2. 向量索引

当我们创建完集合后,便可点进去集合的详情里面查看对应的 schema,如下图所示:

image.png

这里会显示我们刚才创建的所有字段信息,然后大家会看到我圈起来的地方,这里便是给向量字段添加索引的。点击进去后会有一个配置弹窗,我们简单来了解一下要怎么配置。

image.png

这里的索引类型和度量标准是我们需要重点关注的,因为他们会影响距离的计算方式和二者之间的兼容性。不过在 Attu 这里,它已经通过交互的方式解决了兼容的问题(前端的作用凸显有木有!!),当选择不同的 Index Type 的时候,会联动到度量标准那边的表单变化。

简单地介绍一下我选择的 Index Type —— FLAT,只要查询的数据规模小,无脑用就行了,数据量大了再根据需求考虑其他类型。这里贴出 知识库检索匹配的服务化实践 这个文章,里面有比较详细地介绍每个 Type 地使用场景,大家可以去看看。或者大家直接看官网文档也是一个不错的选择~

另外关于 Metric Type 度量标准的值,那个计算公式、法则啥的大家还是自己了解吧(反正我是没细看了),反正也看不懂~不过在 Milvus 中文文档 中找到有可以看懂(讲的是人话)的介绍:

image.png

那既然我们现在所要处理的就是自然语言领域方面的,所以无脑 IP(内积) 就好了,管他呢~

3. load 进内存

image.png

如上图所示,Milvus 的所有搜索操作都需要 load 到内存里面去,所以当我们把集合、字段等都配置好了后,就要把集合 load 到内存里面去,这一步很重要!

image.png

好了,到这一步,基本的准备工作算是搞完了,接下来我们就往里面搞点数据,试试 Milvus 搜索的效果吧!

Mivlus 小试牛刀

这一小节,我们就开始写入一些数据并开始做一个简单的搜索测试吧。

ps:这里我不会展开词嵌入(emb)的过程,因为我暂时也是调别人接口做的词嵌入(后文把 embedding 、词嵌入统称为”词嵌入“)。等后续有空了,我会自己去部署一个模型来做 emb,那时候我再另写一篇文章来分享吧。(当然大家站内随便搜一下,都有很多现成的模型部署教学文章~)

这里我们直接拿我们的案例来试一试,分别是三个词:“开心”、“高兴”、“快乐”。我会先拿这三个词去做词嵌入的操作,最后会得到一个 1024 维的向量数据。这个向量数据其实就是一个 1024 长度的浮点数据,给其中一个词嵌入的结果大家看看:

image.png

这里我是通过 Milvus 提供的 node.js 的一个 sdk 调用去插入的数据,具体的操作就不一步步演示了,大家在官网是可以找到对应的 demo 的。另外用的 sdk 是——milvus-sdk-node 这个,感兴趣的哥们可以点进去 github 看看~

数据插入 Milvus 完成后,我们可以看到现在总共有三条数据:

image.png

我们趁热打铁,马上进行搜索试试看效果。首先我们拿“开心”的词嵌入结果去搜一下试试看效果:

image.png

如上图所示,当我们用“开心”的词嵌入结果去搜索后,可以发现第一条召回结果的 distance 为 0。这个很好理解,用同一个坐标去搜索同一个坐标,他们之间的距离肯定是0,因为本质上就是同一个点。

再仔细一点看,其实会发现,“快乐”的 distance 值比起“高兴”,更接近 “开心” 的位置,所它排名第二。讲到这里,我相信大家已经茅厕顿开了,基于向量搜索通过距离的计算方式来搜索结果,确实存在优化语义模糊情况下搜索的可能,特别是这种近义词,短句之类的!

那么为了印证以上的这一点,我决定给 Mivlus 再多插入几条数据给大家看看效果。比如说我插入跟开心反着来的数据,如“不开心”、“生气”、“难过”。插入后数据大概是这样:

image.png

这时候,我再通过 “开心” 的词嵌入结果去搜一搜,一探究竟!搜索结果如下图所示:

image.png

结果有没有在你的意料范围之内?显然,“不开心”、“生气”、“难过”这三个跟“开心”反着干的词语,毫无意外排在了靠后的位置,并且他们距离“开心”点的距离已经比较遥远了,最近的“不”开心都去到了0.56。而跟开心语义比较接近的另外两个词语,依然是跟前文中一样的距离,这次搜索的整体排名结果也比较符合我们预期。

通过上述简单的一个小 demo 的探索,我们大概可以领略到向量搜索在关键词搜索中,确实有缓解语义模糊情况下的搜索质量的效果的。或者说比起 ES,向量搜索在这一个领域里更有优势(但还是那句话,排除 ES 额外使用自定义词典,配置同义词近义词等操作,我知道 ES 很强大,所以大家不用杠)。

因此,看到这里的你,我想你大概会有一定的底气去尝试向量搜索在文档搜索领域的落地了。而我当时,也是通过这样的一些小测试,小尝试,最终决定落地其到文档搜索业务中去的。

最后还想提一句,向量搜索的过程中,Milvus 只是充当了向量搜索的角色,而将这些非结构性数据——自然语言转换为结构性数据的步骤一样重要,也就是本文多次提及的“词嵌入”(emb)。但其实,这一步看似是实现向量搜索中难的一步,其实也没大家想得这么复杂。现在有比较多成熟的开源模型可以拿来部署,而我们最终也只是调用这些模型来帮我们做词嵌入处理而已~anyway,等我有空了亲自部署了一个模型后,会另外写一篇文章来分享。

写在最后

虽然说 Mivlus 算是这几年出的,是一个比较新的产物,但其实据我自己的经历来说,Milvus 和 向量搜索 已经有一定的业务落地情况了,不仅仅是做文档搜索,还有做知识库、图片检索等。就我了解过(待过)的团队,都有其在业务上的落地,只是在网上的实战参考还是比较少。反正我当时刚定下来要用 Milvus 来做文档搜索优化的时候,基本没有找到很详细实战的参考资料(连官网的文档搜索应用 demo 都是一个幌子),这对于前端狗来说真的太难了,好长一段时间都无从下手。所以,我会慢慢把自己的实战内容写成文章来分享,只要有时间就写!!!

关注我,下一篇文章我将会展开在文档实战中的一些细节点。会具体跟大家一起探讨以下几点,比如说:

  1. 对文段、标题等不同类型的数据处理。
  2. 怎么把控词嵌入的文本长度?用一整段、一句话或者一个词呢?
  3. 不同长度的语句做词嵌入后对搜索有什么影响?

参考文献

  • 知识库检索匹配的服务化实践
  • Milvus Docs
  • Milvus中文文档

本文转载自: 掘金

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

支持刷新加载的鸿蒙动态分组列表组件 一、背景 二、简介 三、

发表于 2024-04-23

一、背景

目前货拉拉作为首批和鸿蒙合作适配的厂商 之一,已经在内部开始适配鸿蒙版货拉拉用户端

在鸿蒙开发适配过程中发现,项目中存有列表+分组的场景,按目前已有实现方式存在如下问题:

  1. 官方文档上推荐实现的分组列表是使用ListItemGroup的方式来实现分组
  2. ListItemGroup适用于静态分组,例如已经获取了全部数据之后通讯录或者城市列表分组显示

不太适用于

  1. 需要动态加载更多数据之后给数据动态分组
  2. 需要实时监听item滑动位置的上拉加载更多的场景

因为ListItemGroup被当做一个整体的item,难以实时监听到内部item的滑动位置,所以难以判断需要上拉加载更多

本文的PullToRefresh组件在开源的下拉刷新组件的基础上同时实现下拉刷新、上拉加载更多、列表动态分组功能

二、简介

PullToRefreshFor是鸿蒙下可同时实现动态分组列表进行下拉刷新、上拉加载的组件

在以下版本验证通过:

  • DevEco Studio: 4.1 Canary(4.1.3.500), SDK: API11 (4.1.0)

理论上也支持API 9、10的版本

三、功能特性

  • 特性1:支持下拉刷新和上拉加载更多数据
  • 特性2:同时支持动态分组列表

和这个gitee.com/openharmony…

  1. 监听手势事件的方式不同:PullToRefresh 使用parallelGesture方法获取触摸手势事件,本组件使用onTouch方法获取手势
  2. 灵活度不同:PullToRefresh把整个组件进行一个大的封装,由外部传入 List 组件和数据请求函数即可,优点是使用上手简单,缺点是不太容易定制。本组件则是把下拉刷新、上拉加载、Head 作为单独的组件供外部使用,优点是可自由定制如实现本次分组列表,缺点是需要多处声明

四、安装指南

1
bash复制代码ohpm install @huolala/pull-refresh

五、效果示例

六、代码示例

1、头部刷新部分及头部刷新逻辑

头部下拉刷新UI视图组件为CustomRefreshLoadLayout,当需要下拉刷新时,传入PullRefreshModel里的refreshLayoutConfig,然后添加此组件即可预设刷新 UI

通过@state 注解的 PullRefreshModel 类,当满足相应条件时,自动更新是否可见、刷新时的图片资源、刷新时的文案,控件高度

如当外部更改为可见时则使用预设控件高度显示,否则高度置为 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
scss复制代码 // 下拉刷新
CustomRefreshLoadLayout({ config: this.dataModel.refreshLayoutConfig })

@Observed
export class PullRefreshModel {
//...
refreshLayoutConfig: CustomRefreshLoadLayoutConfig = new CustomRefreshLoadLayoutConfig(false)
//...
}

@Component
export default struct CustomLayout {
@ObjectLink customRefreshLoadClass: CustomRefreshLoadLayoutClass;

build() {
Row() {
// UI 视图,跟随状态是动态获取
// ....省略具体UI
}
.clip(true)
.width(Const.FULL_WIDTH)
.justifyContent(FlexAlign.Center)
// 这里通过获取刷新组件是否可见的值,来动态控制的高度是否为 0
.height(this.customRefreshLoadClass.isVisible == true ? this.customRefreshLoadClass.heightValue : 0)
.animation({
duration: 300
})
}
}

触发下拉刷新的方式,则是通过监听控件的 onTouch方法,传入 TouchEvent 触摸数据到组件内部,通过判断下滑偏移量来更新下拉刷新组件的PullRefreshModel类的属性值,最后通过数据更新 UI 到上面的CustomRefreshLoadLayout中

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
ini复制代码export  function touchMovePullRefresh(dataModel: PullRefreshModel, event: TouchEvent) {
if (dataModel.startIndex === 0) {
// 表示已经可以操作下拉刷新
dataModel.isPullRefreshOperation = true;
let height = vp2px(dataModel.pullDownRefreshHeight);
dataModel.offsetY = event.touches[0].y - dataModel.downY;
// 偏移达到刷新的值.
if (dataModel.offsetY >= height) {
pullRefreshState(dataModel, RefreshState.Release);
dataModel.offsetY = height + dataModel.offsetY * Const.Y_OFF_SET_COEFFICIENT;
} else {
// 偏移没达到刷新的值.继续显示“下拉刷新”
pullRefreshState(dataModel, RefreshState.DropDown);
}
if (dataModel.offsetY < 0) {
dataModel.offsetY = 0;
dataModel.isPullRefreshOperation = false;
}
}
}

export function pullRefreshState(dataModel: PullRefreshModel, state: number) {
switch (state) {
case RefreshState.DropDown:
dataModel.refreshLayoutConfig.textValue = $r('app.string.pull_down_refresh_text');
dataModel.refreshLayoutConfig.imageSrc = $r('app.media.client_ic_pull_down_refresh');
dataModel.isCanRefresh = false;
dataModel.isRefreshing = false;
dataModel.refreshLayoutConfig.isVisible = true;
break;
case RefreshState.Release:
dataModel.refreshLayoutConfig.textValue = $r('app.string.release_refresh_text');
dataModel.refreshLayoutConfig.imageSrc = $r('app.media.client_ic_pull_up_refresh');
dataModel.isCanRefresh = true;
dataModel.isRefreshing = false;
break;
//...
}
}

当松开手指后,根据此前下拉滑动时记录的已满足下拉刷新的标记isCanRefresh,满足则回调请求数据,即完成一次下拉刷新

1
2
3
4
5
6
7
8
9
10
11
12
scss复制代码export  function touchUpPullRefresh(dataModel: PullRefreshModel, getDataCallBack: (isLoadMore: boolean) => void) {
if (dataModel.isCanRefresh === true) {
// 满足可以刷新请求数据
dataModel.offsetY = vp2px(dataModel.pullDownRefreshHeight);
pullRefreshState(dataModel, RefreshState.Refreshing);
// 页码置为 1
dataModel.currentPage = 1;
getDataCallBack(false)
} else {
closeRefresh(dataModel, false);
}
}

2、占位head及列表head部分及交互逻辑

由于使用 ListItemGroup 会无法监听到 ListItemGroup 内部的 Item,但业务场景仍然需要分组的 UI,所以这里使用单独的占位 head 去作为分组标题的来显示

占位 head 总共有两处,一处是在 List 列表布局外面,一个是 List列表首条 Item 里

这两条 head 的用处分别是,第一条 head 用于在滑动的时候,始终悬浮在最顶部,并且通过onScrollIndex方法获取到当前首条 Item,数据来动态更新占位 head 的数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
scss复制代码Row() {
// 1. 假的占位 head 头
this.itemHead()
}
.visibility(this.showFakeHead? Visibility.Visible : Visibility.None)

List({space:20, scroller: this.scroller }) {
ListItem() {
Row() {
// 2. 列表的head头
this.itemHead()
}.visibility(!this.showFakeHead? Visibility.Visible : Visibility.None)
}
}

3、列表动态分组实现逻辑

动态分组是指在获取到数据之后才能去实现分组,而不是像通讯录那种可以一次获取所有列表数据和分组数据

如果是后端给的数据已经实现分组,则可以直接按照给的分组进行 UI 渲染,然后直接进行下一页获取即可。但如果是后端给的数据里没有包含任何分组数据,则需要由我们来进行动态分组和更新数据来渲染 UI

具体做法是构建一个用来展示的 model 类的数据集合,在拿到原始数据的时候,判断每一条 head 的数据和之前记录的 head 数据是否相符,如果不符,则手动插入一条 head 数据,这条数据仅用来显示分组的标题,如果相同则继续添加原来的数据进去新的集合,只是这是一条普通的 Item 数据,最后取新的集合展示数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
ini复制代码let currentHead: string = ""
private getList(data: ListData): ListDisplayBean[] {
let listDisplay: ListDisplayBean[] = []

if (data.list == null || data.list == undefined) {
return orderList
}
for (let i = 0; i < data.list.length; i++) {
let item = data.list[i]
if (this.currentHead != item.head) {
//
bean.isMonth = true
orderList.push(bean)
}

let bean = new ListDisplayBean()
bean.item = item
listDisplay.push(bean)
this.currentHead = item.head
}
return orderList
}

4、底部加载更多部分及加载更多逻辑

底部上拉加载视图为CustomRefreshLoadLayout,和下拉刷新一样,复用同样的一个UI组件,只是传入的数据不一样

与下拉刷新不同的是,必须是有下一页数据时才会显示这个组件,是否有下一页数据,则在每次请求完数据的时候根据条数确定,否则显示没有更多数据的组件

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
scss复制代码/**
* 上拉加载更多组件
*/
@Component
export struct LoadMoreLayout {
@ObjectLink loadMoreLayoutClass: CustomRefreshLoadLayoutClass;

build() {
Column() {
CustomRefreshLoadLayout({
customRefreshLoadClass: new CustomRefreshLoadLayoutClass(this.loadMoreLayoutClass.isVisible,
this.loadMoreLayoutClass.imageSrc, this.loadMoreLayoutClass.textValue, this.loadMoreLayoutClass.heightValue)
})
}
}
}

/**
* 没有更多数据组件.
*/
@Component
export struct NoMoreLayout {
build() {
Row() {
Text('没有更多数据了')
.margin({ left: Const.NoMoreLayoutConstant_NORMAL_PADDING })
.fontSize(Const.NoMoreLayoutConstant_TITLE_FONT)
.textAlign(TextAlign.Center)
}
.width(Const.FULL_WIDTH)
.justifyContent(FlexAlign.Center)
.height(Const.CUSTOM_LAYOUT_HEIGHT)
}
}

实现上拉加载更多逻辑,需要先获取是否当前已经滑动到当前页的最后一条数据了,获取的方法是通过.onScrollIndex里当前滚动数据的角标,如果最后一条数据角标大于当前该页全部的数据的大小,则表示已经滑到该页最后一条数据。然后继续判断是否已经达到上拉触发的滑动阈值,达到就修改标记为已触发上拉加载更多

1
matlab复制代码export  function  touchMoveLoadMore ( dataModel: PullRefreshModel, event: TouchEvent ) {  if (dataModel. endIndex >= dataModel. dataSize - 1 ) { dataModel. offsetY = event. touches [ 0 ]. y - dataModel. downY ;  if ( Math . abs (dataModel. offsetY ) > vp2px (dataModel. pullUpLoadHeight ) / 2 ) { dataModel. isCanLoadMore = true ; dataModel. loadMoreLayoutConfig . isVisible = true ; dataModel. offsetY = - vp2px (dataModel. pullUpLoadHeight ) + dataModel. offsetY * Const . Y_OFF_SET_COEFFICIENT ; } } }

获取到上面的标记之后,则在手指松开之后,会调用获取下一页的数据,这样就完成了上拉加载更多

1
lua复制代码export  function  touchUpLoadMore ( dataModel: PullRefreshModel, getDataCallBack: (isLoadMore: boolean ) => void ) {  let  self : PullRefreshModel = dataModel;  animateTo ({  duration : Const . ANIMATION_DURATION , }, () => { self. offsetY = 0 ; })    // isCanLoadMore 为 true 表示当前已经到第一页最后一条数据并且手势上滑到了阈值    // hasMore 为 true 表示数据还有下一页,默认是 true   if ((self. isCanLoadMore === true ) && (self. hasMore === true )) { self. isLoading = true ;  getDataCallBack ( true ) } else {  closeLoadMore (self); } }

5、整个列表的逻辑部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
scss复制代码@State data: GroupData[] = [];
@State headTitle: GroupData = new GroupData()
@State showFakeHead: boolean = true
// 需绑定列表或宫格组件
private scroller: Scroller = new Scroller();
@State private dataModel: PullRefreshModel = new PullRefreshModel()
private itemDataGroupNew: GroupData[] = [....]// 假数据省略

@Builder
private getListView() {
// 列表首条 Item
CustomRefreshLoadLayout({ config: this.dataModel.refreshLayoutConfig })


// 1. 假的占位 head 头
Row() {
this.itemHead()
}
.visibility(this.showFakeHead? Visibility.Visible : Visibility.None)

List({space:20, scroller: this.scroller }) {
ListItem() {
Row() {
// 2. 列表的head头
this.itemHead()
}.visibility(!this.showFakeHead? Visibility.Visible : Visibility.None)
}

ForEach(this.data, (item: GroupData) => {
ListItem() {
Column() {
Row() {
// 3. 列表中不悬浮的 head
Text(item.head)
.fontSize(20)
.height(50)
.backgroundColor('#FF667075')
.width('100%')
}.visibility(item.isHead ? Visibility.Visible : Visibility.None)

Text(item.content)
.width('100%')
.height(150)
.fontSize(20)
.textAlign(TextAlign.Center)
.backgroundColor('#FF6600')
.visibility(!item.isHead ? Visibility.Visible : Visibility.None)
}
}
})
// 列表末条 Item
ListItem() {
if (this.dataModel.hasMore) {
CustomRefreshLoadLayout({ config: this.dataModel.loadMoreLayoutConfig })
} else {
NoMoreLayout()
}
}
}
.onTouch((event: TouchEvent | undefined) => {
if (event) {
if (this.dataModel.pageState === PageState.Success) {
listTouchEvent(this.dataModel, event, (isLoadMore: boolean) => {
this.getData(isLoadMore)
});
}
}
})
.onScrollIndex((start: number, end: number) => {
console.log(`headfloat start:${start}`)
if (this.data.length > start) {
let startValue = this.data[start]
// 4. 赋值 head 数据
this.headTitle = startValue
}
let yOffset: number = this.scroller.currentOffset().yOffset
if (yOffset >= -0.01) {
// 5. 控制 head 头展示
this.showFakeHead = true
} else {
this.showFakeHead = false
}
this.dataModel.startIndex = start;
this.dataModel.endIndex = end;
})
.backgroundColor('#eeeeee')
.edgeEffect(EdgeEffect.None) // 必须设置列表为滑动到边缘无效果
}

七、原理说明

通过分别构造滑动时假 head 头和未滑动时的 head 头,第一个 head 头在滑动后,通过监听onScrollIndex首条出现的ListItem 的角标动态设置数据,并且该控件处在 UI在 List 控件之上,达到悬停的效果

第二个 head 头与第一个 head 头互斥出现,滑动后即消失,在视觉上就像是通讯录分组一样的效果

八、类接口说明

  1. RefreshLayout:下拉刷新的UI控件,可定制
  2. itemHead:分组 head 头
  3. LoadMoreLayout:上拉加载更多 UI 空间,可定制
  4. NoMoreLayout:没有更多 UI 空间,可定制
  5. PullRefreshModel:用于控制下拉刷新和上拉加载状态记录的 model 类
属性 类型 释义 默认值
dataSize number 数据大小 0
currentPage number 当前页码 1
pageSize number 每页大小 20
pullDownRefreshHeight number 下拉刷新组件的高度 70
pullUpLoadText Resource 上拉加载时的文案 加载中..
offsetY number Y 轴偏移值 0
pageState number 当前刷新组件状态,如加载中,加载完成 loading 状态
startIndex number 列表的第一条角标值 0
endIndex number 列表的最后一条角标值 0
downY number 按下屏幕时的 Y 坐标 0
lastMoveY number 移动手指时最新的 Y 坐标 0
isRefreshing boolean 当前是否正在下拉刷新中 false
isCanRefresh boolean 是否已经满足松开手指触发刷新 fasle
isPullRefreshOperation boolean 当前正在下拉操作 false
isLoading boolean 是否正在上拉加载更多数据中 false
hasMore boolean 是否有下一页 false
isCanLoadMore boolean 是否可以加载下一页 false
refreshLayoutConfig CustomRefreshLoadLayoutConfig 下拉刷新组件内部使用的属性值 -
loadMoreLayoutConfig CustomRefreshLoadLayoutConfig 上拉加载组件内部使用的数值 -

九、开源地址

github.com/HuolalaTech…

本文转载自: 掘金

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

1…293031…956

开发者博客

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