0%

一、需求背景

本周负责做调账线上化,其中一个业务需求比较棘手:提交调账申请后,需在数据表记录对应的调账类型;而在调账复核页面,列表筛选项-调账类型,需支持多选。简单枚举如下:

1
2
3
4
5
6
7
8
9
10
11
public enum AdjustTypeEnum {
/**
* 调账类型枚举; 一次调账申请可能对应多种调账类型,在数据表中枚举值之间以英文 , 分隔
*/
FINE(0, "应还罚息"),
TOTAL_REPAYMENT(1, "应还总额"),
FACT_REPAYMENT(2, "实还总额"),
FACT_PAYMENT_DATE(3, "实还日期"),
OVERDUE_DAYS(4, "逾期天数"),
PAYMENT_STATUS(5, "还款状态");
}

假如调账申请A的调账类型包括应还罚息和应还总额,则数据表相应字段值为0,1。在复核页面,若调账类型复选项中包含应还罚息或应还总额,则需查询到该申请。此时,我们发现SQL内置函数FIND_IN_SET只支持单选项查询,而无法灵活拆分筛选条件和存储数据。因此,有必要调整一下数据的存储规则和查询规则。

二、位与运算的妙用

首先,我们需要进一步抽象:一方面,为了不浪费存储空间,科学地设计数据表,我们需要将目标数据列表整合存储在一个字段中;另一方面,数据库引擎需要灵活使用整合后的字段,以执行相应的查询语句。说白了,就是看起来像做了整合,实际上还是原始的目标数据列表。

事实证明,上面提出的方案根本满足不了业务需求,是一个非常差的设计。除了以逗号分隔枚举值的存储方式,还有一种更为科学的存储方式——二进制。简单做几个位与运算:

1
2
3
4
5
6
7
8
// 存储值: 3; 查询条件: 1
// 3 == 2 + 1
// 1 & 3 == 1
0001
&
0011
=
0001
1
2
3
4
5
6
7
8
// 存储值: 7; 查询条件: 1
// 7 == 1 + 2 + 4
// 1 & 7 == 1
0111
&
0001
=
0001
1
2
3
4
5
6
7
8
9
// 存储值: 7; 查询条件: 3
// 7 == 1 + 2 + 4
// 3 == 1 + 2
// 3 & 7 == 3
0111
&
0011
=
0011
1
2
3
4
5
6
7
8
9
// 存储值: 7; 查询条件: 15
// 7 == 1 + 2 + 4
// 15 == 1 + 2 + 4 + 8
// 15 & 7 == 7
0111
&
1111
=
0111

从以上位与运算实例,可以发现一个有趣的现象:若将枚举值设置为2n次幂,则只需将目标数据列表加合,即可保证每一个目标数据对应到二进制中的一位。

此时,对于任何一个查询条件,只需要将其与整合值进行位与运算,即可得到匹配的值。

由此,我们调整枚举为:

1
2
3
4
5
6
7
8
9
10
11
public enum AdjustTypeEnum {
/**
* 调账类型枚举; 一次调账申请可能对应多种调账类型,在数据表中存储各值之和
*/
FINE(1, "应还罚息"),
TOTAL_REPAYMENT(2, "应还总额"),
FACT_REPAYMENT(4, "实还总额"),
FACT_PAYMENT_DATE(8, "实还日期"),
OVERDUE_DAYS(16, "逾期天数"),
PAYMENT_STATUS(32, "还款状态");
}

调整SQL为:

1
2
3
4
5
SELECT
*
FROM `boss-account`.acct_adjust_account_record
WHERE
adjust_type_sum & #{调账类型筛选条件值之和} > 0

一、技术背景

实现发布订阅的中间件有许多,包括时下最热的KafkaRabbitMQActiveMQ,不太常见的Guava下的EventBus,以及Redis等。关于MQ之流的分析已经够多了,今天详细介绍下Redis的发布订阅机制及其实现。事实上,Redis的发布订阅机制在具体项目中的应用非常少,主要有两方面原因:其一,Redis的可靠性比较差,一旦出现断网等情况,则发布的消息便全部丢失;其二,Redis的消息处理方式是通过单线程循环遍历实现的,若存在大量的消息发布,则可能导致输出缓冲区膨胀,甚至服务崩溃。但对数据安全性和稳定性要求不高的场景来说,Redis不失为最佳的选择。

二、基本操作

Redis 的发布订阅模式包括普通订阅,普通订阅取消,模式订阅,模式订阅取消这四个场景。用命令实现如下:

  • 启动 Redis 服务(以 Windows 平台为例)

    1
    2
    3
    C:\Tools\Redis>redis-server.exe redis.windows.conf
    ...
    The server is now ready to accept connections on port 6379
  • 创建客户端client1,并以普通订阅渠道channel1

    1
    2
    3
    4
    5
    6
    redis-cli.exe -h 127.0.0.1 -p 6379
    127.0.0.1:6379> SUBSCRIBE channel1
    Reading messages... (press Ctrl-C to quit)
    1) "subscribe"
    2) "channel1"
    3) (integer) 1
  • 创建客户端client2,并普通订阅渠道channel2

    1
    2
    3
    4
    5
    6
    redis-cli.exe -h 127.0.0.1 -p 6379
    127.0.0.1:6379> SUBSCRIBE channel2
    Reading messages... (press Ctrl-C to quit)
    1) "subscribe"
    2) "channel2"
    3) (integer) 1
  • 创建客户端client3,并模式订阅渠道channel*

    1
    2
    3
    4
    5
    6
    redis-cli.exe -h 127.0.0.1 -p 6379
    127.0.0.1:6379> PSUBSCRIBE channel*
    Reading messages... (press Ctrl-C to quit)
    1) "psubscribe"
    2) "channel*"
    3) (integer) 1
  • 向渠道channel2发布一条消息

    1
    2
    3
    4
    redis-cli.exe -h 127.0.0.1 -p 6379
    127.0.0.1:6379> PUBLISH channel2 "msg from channel2"
    (integer) 2
    127.0.0.1:6379>
  • client2client3 接收到消息

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    # ----------client2---------
    1) "message"
    2) "channel2"
    3) "msg from channel2"

    # ----------client3---------
    1) "pmessage"
    2) "channel*"
    3) "channel2"
    4) "msg from channel2"

因此,我们可以猜测,消息发布者与消息订阅者之间是通过渠道连接的,包括精准匹配(普通订阅)和模糊匹配(模式订阅)。经过分析其结构设计,可表示为用例:

三、原理分析

Redis发布订阅的核心实现在pubsub.c文件。从头文件server.h中可以读取相关函数声明:

1
2
3
4
5
6
void subscribeCommand(client *c);      /* 普通订阅 */
void unsubscribeCommand(client *c); /* 普通订阅取消 */
void psubscribeCommand(client *c); /* 模式订阅 */
void punsubscribeCommand(client *c); /* 模式订阅取消 */
void publishCommand(client *c);
void pubsubCommand(client *c);
  • 普通订阅模式:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#define CLIENT_PUBSUB (1<<18)      /* Client is in Pub/Sub mode. */
/*-----------------------------------------------------------------------------
* Pubsub commands implementation
*----------------------------------------------------------------------------*/

void subscribeCommand(client *c) {
int j;
/* 分析client结构,c->argv[0]为命令本身,第2位开始为渠道参数 */
for (j = 1; j < c->argc; j++)
/* 订阅一个渠道 */
pubsubSubscribeChannel(c,c->argv[j]);
/* 设置客户端为发布订阅模式,此模式在processCommand中为校验值 */
c->flags |= CLIENT_PUBSUB;
}

/* 一个客户端订阅一个渠道。若返回 1,则订阅成功;若返回 0,则该客户端已订阅该渠道 */
int pubsubSubscribeChannel(client *c, robj *channel) {
dictEntry *de;
list *clients = NULL;
int retval = 0;

/* 将该渠道添加到该客户端的pubsub_channels哈希表中; key: channel,value: NULL*/
if (dictAdd(c->pubsub_channels,channel,NULL) == DICT_OK) {
retval = 1;
/* 引用自增 1 */
incrRefCount(channel);
/* 在服务端pubsub_channels中查找该channel */
de = dictFind(server.pubsub_channels,channel);
/* 若该channel不存在,则新建 */
if (de == NULL) {
clients = listCreate();
/* 将该客户端添加到该服务端的pubsub_channels哈希表中; key: channel,value: client*/
dictAdd(server.pubsub_channels,channel,clients);
/* 引用自增 1 */
incrRefCount(channel);
} else {
clients = dictGetVal(de);
}
/* 将客户端c记录在订阅客户端列表 */
listAddNodeTail(clients,c);
}
/* 通知客户端订阅结果 */
addReplyPubsubSubscribed(c,channel);
return retval;
}

普通订阅模式主要做了两件事:将订阅渠道添加至客户端pubsub_channels哈希表中;将订阅客户端添加至服务端的pubsub_channels哈希表中。可如下表示:



今天特别想吃我妈做的油馍,也就是大家常说的葱油饼。虽然出租屋的条件有限,但我打算尝试一次。


一、原料

小麦粉,小香葱,香油,花生油,食盐,佐料(看个人喜好选择添加)。

二、步骤

1. 和面

加入适量面粉(尽量选高筋粉),边用筷子搅拌边兑温水,待面结成絮状,手动和成面团。建议面团和软一点,保证很好的延展性。和好后,套上保鲜膜,醒面20分钟。

N1pDh9.jpg

2. 擀面

面醒好后,揉捏成条状。

N19cCj.jpg

葱油饼是多层叠加起来的,所以需要尽可能地将面擀薄。

N1C3Mq.jpg

3. 加料

将适量香油滴在面皮上,然后不断对折面皮,直至香油全部铺开。

N1PkY4.jpg

然后,均匀撒上葱花,食盐少量。

N1P4NF.jpg

4. 成型

将铺满葱花的面从一边角叠起。

N1ZtPK.jpg

将面擀平实。

N1VM7t.jpg

5. 煎饼

注意控制油温。家用燃气灶,小火慢热即可。

N1VaBn.jpg

N1VUns.jpg

N1VtXj.jpg


一、线程的使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public static void main(String[] args) {
Thread thread1 = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("执行线程1");
}
});
thread1.start();

Thread thread2 = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("执行线程2");
}
});
thread2.run();
}

创建以上两个线程,执行结果:

1
2
执行线程1
执行线程2

线程1与线程2都执行了 run(),但线程1是通过 start() 执行的,线程2是直接调用 run() 执行的。

二、start() 与 run() 的区别

为了更精准地找到两种调用之间的区别,需要打印线程相关的信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public static void main(String[] args) {
Thread thread1 = new Thread(new Runnable() {
@Override
public void run() {
Thread currentThread = Thread.currentThread();
System.out.println("执行线程1; 线程名称:" + currentThread.getName());
}
});
thread1.start();

Thread thread2 = new Thread(new Runnable() {
@Override
public void run() {
Thread currentThread = Thread.currentThread();
System.out.println("执行线程2; 线程名称:" + currentThread.getName());
}
});
thread2.run();
}

执行结果:

1
2
执行线程1; 线程名称:Thread-0
执行线程2; 线程名称:main

从执行结果来看,start() 调用,是通过开启一个新线程来执行 run() 的;而直接调用 run(),是主线程直接执行方法内容的。

此外,我们还可以做另一种尝试:多次执行 run() 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public static void main(String[] args) {
Thread thread1 = new Thread(new Runnable() {
@Override
public void run() {
Thread currentThread = Thread.currentThread();
System.out.println("执行线程1; 线程名称:" + currentThread.getName());
}
});
thread1.run();
thread1.run();

Thread thread2 = new Thread(new Runnable() {
@Override
public void run() {
Thread currentThread = Thread.currentThread();
System.out.println("执行线程2; 线程名称:" + currentThread.getName());
}
});

thread2.start();
thread2.start();
}

执行结果:

1
2
3
4
5
6
执行线程1; 线程名称:main
执行线程1; 线程名称:main
执行线程2; 线程名称:Thread-1
Exception in thread "main" java.lang.IllegalThreadStateException
at java.lang.Thread.start(Thread.java:708)
at com.example.springbatchdemo.job.Test.main(Test.java:29)

从执行结果可知,可以多次调用 run(),而 start() 只能被执行一次,这是为什么呢?我们看下 start 方法说明:

1
2
3
4
5
6
7
8
9
10
public synchronized void start() {
/**
* This method is not invoked for the main method thread or "system"
* group threads created/set up by the VM. Any new functionality added
* to this method in the future may have to also be added to the VM.
*
* A zero status value corresponds to state "NEW".
*/
if (threadStatus != 0)
throw new IllegalThreadStateException();

调用 start(),首先需要判断当前线程的状态是否为 0 (NEW),如果不是,则抛异常。当第一次调用 start() 时,线程状态从 NEW (新建)变为 RUNNABLE(就绪),当第二次调用 start() 时,线程状态肯定不是 NEW。

三、总结

Thread 的 start() 与 run() 都可以完成任务执行,主要区别在:

  • 作用机制不同:直接调用 run(),相当于在当前线程中执行普通方法;调用 start(),是开启一个新的线程来执行 run();
  • 执行速度不同:调用 run() 会立即执行任务,调用 start() 是将线程的状态改为就绪状态,等待空闲 CPU,不会立即执行;
  • 调用次数不同:run() 可以被重复调用,而 start() 只能被调用一次。


一、 NexT 集成第三方搜索服务

根据官方文档, NexT 主题框架集成的搜索方式有四种:

  • SwiftType
  • 微搜索
  • Local Search
  • Algolia

其中,Local Search 最为简单方便,本文将进一步介绍其配置与使用。

  • 安装插件 hexo-generator-searchdb

    1
    npm install hexo-generator-searchdb --save
  • 编辑站点配置文件,新增以下配置:

    1
    2
    3
    4
    5
    search:
    path: search.xml
    field: post
    format: html
    limit: 10000
  • 编辑主题配置文件,启用本地搜索功能:

    1
    2
    3
    4
    # Local Search
    # Dependencies: https://github.com/theme-next/hexo-generator-searchdb
    local_search:
    enable: true

部署博客,访问首页,可以看到新增一个搜索控件:

使用搜索功能,查看所有跟 Spring 相关的博客:

可以看到,Local Search 不仅支持标题检索,还支持内容检索,十分好用。

食材准备

鱼,玉米油,佐料(大葱、姜、蒜头、生抽、老抽、醋、料酒、蚝油、豉油、花椒、八角、桂树皮、黄豆酱)

GO4pTK.jpg

1. 煎鱼

GO4kSH.jpg

为了不糊锅,建议事先在鱼的身上糊上一层面粉。入锅之前,需要提起鱼来,把多余的面粉抖掉,防止热油变苦。煎鱼的目的有两个:高油温可以锁住水分,使鱼肉外焦里嫩;鱼本身的腥味主要来自于表皮和腹腔,高油温可以迅速使鱼皮和腹腔角质化,起到去腥的作用。

GO4MtS.jpg

待鱼炸至两面金黄后捞出,放凉备用。

GO4t00.jpg

2. 烧鱼

GO4g76.jpg

热油锅将佐料翻炒出味,兑水至 2/3 满锅。

GO4ojA.jpg

大火烧开,约 10 分钟后转至文火慢炖。约 1 小时后,大火收汁,出锅。

GO5VCF.jpg

3. 心得

红烧鱼是我最常做的荤菜,我也试过很多做法,有的味道很好,有的就做砸了。综合实操经验来看,我觉得做鱼的关键,并不是那盘佐料,也不是热油锅炸至两面金黄,而是时间——煮鱼的时间。我试过将一条鲜鱼直接放入一锅水中,然后只加入油盐酱醋,慢煮 2 小时后,别是一番风味——鱼的原味。所以,以上很多步骤可能只是花拳绣腿,关键还是在 2/3 满锅的水和 1 小时的文火慢炖。大家都可以尝试下,感受下那 1 小时内到底发生了什么。



又是个美好的周末,照例要吃点好的。今天做了虎皮蛋和红烧鱼。老是写技术博客也没啥意思,不如放点食谱上来吧,毕竟饮食男女嘛。这里先说虎皮蛋的制法。

制作流程

食材准备

新鲜鸡蛋,玉米油,佐料(大葱、小葱、姜、蒜头、生抽、老抽、蚝油、花椒、八角)

GOoYnA.jpg

1. 煮蛋

将鸡蛋在开水中煮 20 分钟(一定要煮熟,否则做不出沙沙的蛋黄),然后捞出放入流动冷水下冲洗3分钟,方便剥壳。

GOoHBR.jpg

2. 剥蛋

尽量剥得完整,防止油炸的时候出现裂痕。沥干鸡蛋表皮的水分,防止入油锅时发生爆炸。

GOoON6.jpg

3. 炸蛋

玉米油的味道最轻,对鸡蛋原味的影响最小,推荐。油入锅后,大火升温;将木筷子插入热油中,当出现气泡后,可将鸡蛋下入油中。调至文火,慢炸 20 分钟,直至鸡蛋表面布满虎皮。捞出后放入凉水中浸泡,然后沥干水分。

GOTZ8S.jpg

GOT35V.jpg

4. 卤蛋

将佐料放入油中炸至金黄,再加入生抽老抽蚝油,兑水熬卤汁。卤水翻开后,放入虎皮蛋,调至文火慢炖 30 分钟。大火收汁,捞出放入碗中。留存的卤汁需要没过鸡蛋。

GOTar9.jpg

5. 腌蛋

GOTy8O.jpg

卤制好的蛋,不要着急吃。虎皮蛋的精髓不在外面的虎皮,而在里面的蛋黄。一颗合格的虎皮蛋,除去 Q 弹的虎皮,还有饱满多汁、沙瓤的蛋黄。要达到这种效果,必须将做好的虎皮蛋静置 4 至 6 小时。天气暖和的时候,需放入冰箱,防止变质。


一、背景介绍

Spring 容器是 Spring 框架的核心。容器负责配置对象,注入对象,管理对象,并完成整个生命周期。Spring 容器依赖控制反转(Inversion of Control),也即依赖注入(Dependency Injection)来配置并注入对象。这样的对象被成为 Bean。具体过程可抽象为:Spring 读取程序中的 Bean 配置信息,并据此在容器中生成一份 Bean 配置注册表,然后再根据注册表实例化 Bean ,装配好待用。

Spring 提供了两种容器:BeanFactoryApplicationContextBeanFactorySpring Ioc 功能提供了底层的实现基础,但为了实现框架化,Spring 新增了ApplicationContext 接口。 ApplicationContext 继承于 BeanFactory,并涵盖其所有功能。官方对两个容器做出比较:

特征 BeanFactory ApplicationContext
Bean 实例化 / 连接
集成生命周期管理
BeanPostProcessor 自动注册
BeanFactoryPostProcessor 自动注册
利用 MessageSource 进行国际化
嵌入式 ApplicationEvent 发布机制

本文分析 ApplicationContext 容器。Spring 源码参考版本 5.2.5.RELEASE

二、ApplicationContext 容器涉及的类关系图

ApplicationContext 有很多实现类,这里我们以 Java EE web (采用 Spring 框架)应用的启动过程为例。为了更直观地描述初始化过程,我们有必要认识 XmlWebApplicationContext 的关联类图:

三、ApplicationContext 容器初始化

Java EE Web 项目启动,会触发 ContextLoaderListener 监听器,完成 WebApplicationContext -根容器的初始化。具体实现方法如下:

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
/**
* web.xml 中 <context>contextLoaderListener</context> 触发根容器初始化
*
* @param servletContext 整个应用的上下文; 一个应用只有一个 ServletContext
* @return 根容器
*/
public WebApplicationContext initWebApplicationContext(ServletContext servletContext) {
// 根容器挂载在 ServletContext 下,有且仅有一个根容器
if (servletContext
.getAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE)
!= null) {
throw new IllegalStateException("...");
}
servletContext.log("Initializing Spring root WebApplicationContext");
Log logger = LogFactory.getLog(ContextLoader.class);
if (logger.isInfoEnabled()) {
logger.info("Root WebApplicationContext: initialization started");
}
long startTime = System.currentTimeMillis();
try {
if (this.context == null) {
// 默认生成的根容器为 XmlWebApplicationContext
this.context = createWebApplicationContext(servletContext);
}
if (this.context instanceof ConfigurableWebApplicationContext) {
ConfigurableWebApplicationContext cwac
= (ConfigurableWebApplicationContext) this.context;
// 根容器还没有被刷新
if (!cwac.isActive()) {
// 添加父容器
if (cwac.getParent() == null) {
// 对于 web 应用, 父容器默认为空
ApplicationContext parent = loadParentContext(servletContext);
cwac.setParent(parent);
}
// 配置并刷新容器
configureAndRefreshWebApplicationContext(cwac, servletContext);
}
}
// 将根容器挂载在 ServletContext 下,指定属性 name 值,方便在应用内部随时使用
servletContext
.setAttribute(WebApplicationContext
.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE, this.context);
// 获取当前线程的上下文加载器
ClassLoader ccl = Thread.currentThread().getContextClassLoader();
if (ccl == ContextLoader.class.getClassLoader()) {
currentContext = this.context;
} else if (ccl != null) {
// 若上下文加载器不是 ContextLoader 加载器,则存至 currentContextPerThread 静态域
currentContextPerThread.put(ccl, this.context);
}
if (logger.isInfoEnabled()) {
long elapsedTime = System.currentTimeMillis() - startTime;
logger.info("Root WebApplicationContext initialized in "
+ elapsedTime + " ms");
}
return this.context;
} catch (RuntimeException | Error ex) {
logger.error("Context initialization failed", ex);
// 若发生异常,则将异常挂载到 ServletContext 上下文中,此后不再初始化
servletContext
.setAttribute(WebApplicationContext
.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE, ex);
throw ex;
}
}

/**
* 创建根容器 WebApplicationContext
*
* @param sc ServletContext 应用上下文; 一个应用只有一个应用上下文
* @return WebApplicationContext 根容器
*/
WebApplicationContext createWebApplicationContext(ServletContext sc) {
// 判断根容器的实现类
Class<?> contextClass = determineContextClass(sc);
if (!ConfigurableWebApplicationContext.class.isAssignableFrom(contextClass)) {
throw new ApplicationContextException("...");
}
// 实例化根容器
return (ConfigurableWebApplicationContext) BeanUtils.instantiateClass(contextClass);
}

/**
* 判断根容器的实现类
*
* @param servletContext 应用上下文; 一个应用只有一个应用上下文
* @return 根容器的实现类; 默认为 XmlWebApplicationContext
*/
Class<?> determineContextClass(ServletContext servletContext) {
// 读取web.xml中的配置 <context-param>contextClass</context-param>
String contextClassName = servletContext.getInitParameter(CONTEXT_CLASS_PARAM);
if (contextClassName != null) {
try {
return ClassUtils.forName(contextClassName,
ClassUtils.getDefaultClassLoader());
} catch (ClassNotFoundException ex) {
throw new ApplicationContextException(
"Failed to load custom context class [" + contextClassName + "]", ex);
}
} else {
// 若未配置, 则创建默认的根容器 XmlWebApplicationContext
contextClassName =
defaultStrategies.getProperty(WebApplicationContext.class.getName());
try {
return ClassUtils.forName(contextClassName,
ContextLoader.class.getClassLoader());
} catch (ClassNotFoundException ex) {
throw new ApplicationContextException(
"Failed to load default context class [" + contextClassName + "]", ex);
}
}
}

从代码实现可以得出:

  • 一个 Web 应用,根容器只能创建一次;

  • 根容器的实现类可配置,默认为 XmlWebApplicationContext

  • 需要为根容器指定父容器,旨在实现父容器-子容器的继承架构。目前持保留实现,默认为 null

  • 根容器 WebApplicationContext 是挂载在应用上下文 ServletContext 下的,并指定属性名 WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE

下面介绍根容器的配置与刷新:

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
/**
* WebApplicationContext 根容器的配置与刷新
*
* @param wac WebApplicationContext 根容器的可配置继承
* @param sc ServletContext 应用上下文; 一个应用只有一个应用上下文
*/
void configureAndRefreshWebApplicationContext(ConfigurableWebApplicationContext wac,
ServletContext sc) {
// 1. 为根容器配置 ID,用于后面加载Spring-MVC的配置文件
if (ObjectUtils.identityToString(wac).equals(wac.getId())) {
String idParam = sc.getInitParameter(CONTEXT_ID_PARAM);
if (idParam != null) {
wac.setId(idParam);
} else {
// 生成默认的 ID
wac.setId(ConfigurableWebApplicationContext.APPLICATION_CONTEXT_ID_PREFIX +
ObjectUtils.getDisplayString(sc.getContextPath()));
}
}
// 2. 为根容器配置应用上下文
wac.setServletContext(sc);
// 3. 为根容器添加配置参数地址
// 读取web.xml中的配置 <context-param>configLocation</context-param>
String configLocationParam = sc.getInitParameter(CONFIG_LOCATION_PARAM);
if (configLocationParam != null) {
wac.setConfigLocation(configLocationParam);
}
// 4. 初始化环境属性
ConfigurableEnvironment env = wac.getEnvironment();
if (env instanceof ConfigurableWebEnvironment) {
((ConfigurableWebEnvironment) env).initPropertySources(sc, null);
}
// 5. 自定义上下文
customizeContext(sc, wac);
// 6. 刷新根容器
wac.refresh();
}

/**
* 自定义上下文
*
* @param sc ServletContext 应用上下文; 一个应用只有一个应用上下文
* @param wac WebApplicationContext 根容器的可配置继承
*/
void customizeContext(ServletContext sc, ConfigurableWebApplicationContext wac) {
List<Class<ApplicationContextInitializer<ConfigurableApplicationContext>>>
initializerClasses =
determineContextInitializerClasses(sc);
for (Class<ApplicationContextInitializer<ConfigurableApplicationContext>>
initializerClass : initializerClasses) {
Class<?> initializerContextClass =
GenericTypeResolver.resolveTypeArgument(initializerClass,
ApplicationContextInitializer.class);
if (initializerContextClass != null
&& !initializerContextClass.isInstance(wac)) {
throw new ApplicationContextException(String.format("..."));
}
// 创建 Initializer 实例,并添加到 contextInitializers
this.contextInitializers.add(BeanUtils.instantiateClass(initializerClass));
AnnotationAwareOrderComparator.sort(this.contextInitializers);
for (ApplicationContextInitializer<ConfigurableApplicationContext>
initializer : this.contextInitializers) {
// 依次执行初始化程序
initializer.initialize(wac);
}
}
}

从代码实现可以得出:

  • 通过 <context-parm>configLocation</context-param> 为根容器添加配置信息;
  • 因为只要刷新上下文,就要调用环境的 initPropertySources 方法,所以需要提前初始化环境属性,以保证根容器刷新之前的一些操作,如后置处理或初始化过程,都可以直接获取到 Servlet 属性;
  • 在根容器添加配置后,刷新前,执行自定义操作。根据 ServletContextcontextInitializerClassesglobalInitializerClasses 配置加载所有的上下文初始化程序,并依次执行;
  • 自定义根容器后,执行根容器的刷新。放在下一个专栏分解。

1. 问题描述

上篇介绍了通过Blob 类文件对象实现文件下载,但具体操作下去,会发现我留下了一个坑—浏览器跨域请求问题CORS。如下图提示:

toVMsf.png

为实现应用解耦,前后端分离已然成为主流设计。生产环境中,前端请求需要经过代理和负载(如: NigixLVS)处理,才能传递到后端。此种模式下,前后端的交互实现都需要跨域。

同源安全策略是浏览器的一种安全限制,默认阻止跨域获取资源,防止跨站攻击。但 CORS 为跨域请求提供了可能。CORS 将跨域权限交给 Web 服务端,即通过配置就可以实现跨域地资源访问。

2. 跨域请求

跨域场景 示例
域名不同 spring.io zhihu.com
域名相同,端口不通 http://127.0.0.1:8080; http://127.0.0.1:8081
二级目录不同 document.spring.io; reference.spring.io

综上: 当一个资源从与该资源本身所在的服务器不同的域、协议或端口请求一个资源时,资源会发起一个跨域 HTTP 请求。

3. 解决方案

Access-Control-Allow-Origin 是响应头中一个重要的属性,它规定浏览器可以获取哪些域的资源。既然 CORS 将跨域访问权限交给了服务器,那么只需要服务器设置 Access-Control-Allow-Origin属性即可。以 Java 为后端语言的服务中,可以做如下设置:

1
2
// 为实现服务的可扩展,采用通配符域名,即允许访问所有域的资源
response.setHeader("Access-Control-Allow-Origin", "*");

继续实现下去,我们可能又会遇到如下问题:

tol4mR.png

查阅资料发现,对于跨域请求,请求头的可获取性配置在属性 Access-Control-Expose-Headers 中,W3C 规定客户端获取的响应头字段仅限于simple response header ,包括:

  • Cache-Control
  • Content-Language
  • Content-Type
  • Expires
  • Last-Modified
  • Pragma

而文件名在后端的设置方式如下:

1
response.setHeader("fileName", encodeFileName);

因此还后端将请求头 fileName 暴露给前端,配置如下:

1
2
response.setHeader("fileName", encodeFileName);
response.setHeader("Access-Control-Expose-Headers", "fileName");

以上。


一、背景介绍

Axios 是一个基于 PromiseHTTP 库,可以用在浏览器和 node.js 中。与传统的 Ajax 相比,Axios 的优势主要体现在: 支持 Promise API 、支持并发、支持请求与响应拦截、自动转换 JSON 数据、支持防御 XSRF、符合 MVVM 设计理念。在目前大火的前端框架 Vue 中也内置了 Axios 框架

正是因为 Axios 的数据处理为 json 格式,所以只能获取文件流,但不能给与正确处理。具体表现为:

  • response.statusrepsponse.headers 与期望相同,但 response.data 为一团乱码
  • 浏览器没有自动下载文件

二、引入 Blob 容器实现文件下载

既然 Axios 无法正确处理文件流,便需要采用其他技术来达到预期效果。Blob 对象表示一个不可变、原始数据的类文件对象;从字面意思来看,Blob 也是一个可以存储二进制文件的容器。通过这项技术,可以完美地弥补 Axios 的不足。具体实现如下:

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
// 下载文件请求
export function download (param) {
return axios({
url: '/web/bill/download',
method: 'post',
data: param,
responseType: 'blob'
})
}

// 下载文件入口方法
download(this.param).then(response => {
// 执行文件下载
this.exeDownloadFile(response)
})

// 执行文件下载
exeDownloadFile (response) {
// 创建 Blob 对象
let blob = new Blob([response.data], {type: response.headers['content-type']})
// 获取文件名
let fileName = response.headers['content-disposition'].match(/filename=(.*)/)[1]
// 创建指向 Blob 对象地址的URL
let href = window.URL.createObjectURL(blob)
// 创建用于跳转至下载链接的 a 标签
let downloadElement = document.createElement('a')
// 属性配置
downloadElement.style.display = 'none'
downloadElement.href = href
downloadElement.download = fileName
// 将 a 标签挂载至当前页面
document.body.appendChild(downloadElement)
// 触发下载事件
downloadElement.click()
// 移除已挂载的 a 标签
document.body.removeChild(downloadElement)
// 释放 Blob URL
window.URL.revokeObjectURL(href)
}

虽然以上可以实现文件下载,但美中不足的是,当后台返回异常时,通过 Blob 对象依然可以下载到一个 undefined 文件。因此,在执行下载之前,需要对下载请求是否存在异常做出判断。具体实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 下载文件入口方法
download(this.param).then(response => {
let fr = new FileReader()
fr.readAsText(response.data)
fr.onload = function () {
// 新增-异常校验; 若 Blob 对象可 JSON 格式化,则说明文件下载异常
try {
let jsonRet = JSON.parse(this.result)
} catch (e) {
// 执行文件下载
this.exeDownloadFile(response)
}
}
})