南京大学分布式系统第二次作业

南京大学分布式系统第二次实验作业个人报告

一、本次作业要求:

本次作业实际上是MIT6.824的 LAB2A、B、C的三部分内容,每部分内容如下:

Part 1:需要我们完成Raft算法中最基本的 Election 功能,以及Heartbeat部分

Part 2:需要我们完成Leader与Follower,以及日志相关内容。这需要完成 AppendEntry RPC 的构建

Part 3:实现持久化。要求 Raft 保持在重启后仍存在的持久状态。通过完成Persist(),并在合适的地方Persist。

这个实验的难度特别大,想要明白其原理需要好好下一番功夫。

二、认识Raft算法

分布式一致性算法 Raft - 知乎

Raft算法详解 - 知乎

MIT 6.824 Lab 2 Raft详细实现思路及过程_mit6.824-CSDN博客

本实验完全围绕着Raft展开。因此,我们首先需要对Raft算法有详细的了解。

Raft算法是2014年论文《In Search of Understandable Consensus Algorithm》所提出的一种分布式算法。

一系列分布式算法所要考虑的关键问题都是“一致性”,而 Raft 算法将一致性分解为多个子问题:Leader选举(Leader Election)、日志同步Log Replication)、安全性(Safety)、日志压缩(Log Compaction)、成员变更(Membership Change)等。

状态与状态转换

首先,Raft会将系统中的角色分为领导者(Leader)、跟从者(Follower)和候选人(Candidate)三个类型:

Leader与Follower

  • Leader:负责接受客户端请求,并向 Follower 同步请求日志,当日志同步到大多数节点上后,会告诉 Follower提交日志。系统在任意时刻最多只有一个Leader。

  • Follower:接受并持久化 Leader 同步的日志,在 Leader 告之日志可以提交之后,提交日志。

  • Candidate:临时角色,仅存在于 Leader 选举过程中。

这三种角色之间通过选举Election来实现角色状态转换,原论文中给出的示意图如下图。

img

具体是怎么实现的呢?

Follower:

  • 默认节点。

  • 开始阶段和 leader 通信超时,Follower 会发起选举,变成 Candidate,然后去竞选 leader。

  • 如果收到其他 Candidate 的竞选投票请求,按照先来先得 & 每个任期只能投票一次 的投票原则投票;

Candidate:

  • Follower 发起选举后,会立刻变为 Candidate,会向其他节点所要选票vote。Candidate 的票会投给自己,不会向其他节点投票
  • 如果获得超过半数的投票,Candidate 会立刻变成 Leader,然后立刻与其他节点通信,表明自己的 Leader 的地位;
  • 如果选举超时,重新发起选举;
  • 如果遇到更高任期 Term 的 Leader 的通信请求,则转化为 Follower

Leader:

  • Leader 节点接受客户端的数据请求,负责日志同步。
  • 遇到更高任期 Term 的 Candidate 的通信请求,说明 Candidate 正在竞选 Leader,此时之前任期的 Leader 转化为 Follower,且完成投票;

Term:

上面介绍三个角色的内容时,我们提到了Term。Term是 Raft 算法中非常重要的概念,用于标识时间顺序。

每个 Term 代表的是一个选举周期。在每一个Term中,集群中的节点都会选举,产生Leader。

如果选举成功,该Term 将持续到Leader失效或发现更高 Term 的心跳消息。如果选举失败,Term将结束,新的Term将开始。

Leader在其 Term 内,会周期性向其他 Follower 节点发送 Heartbeat 。Follower 如果发现心跳超时,会认为 Leader 节点宕机或不存在。在等待一定时间后,Follower 就会发起选举,变成 Candidate,然后去竞选 Leader。

在一个 Term 的选举过程中,一个 Candidate 可能发生如下三种情况:

  1. 获取超过半数投票,赢得选举:

    当 Candidate 获得选票超过半数时,代表自己赢得了选举,会立刻转化为 Leader。此时,它会马上向其他节点发送请求,从而确认自己的 Leader 地位,阻止新一轮的选举;多个 Candidate 竞选 Leader 时:一个任期内,follower 只会投一次票,且投票先来显得;

  2. 投票未超过半数,选举失败:

    一个 Candidate 没有获得超过半数的投票时,说明多个 Candidate 竞争投票导致过于分散,或者出现了丢包。则认为当期任期选举失败,任期 Term,会发起新一轮选举;

    我们观察这个机制,会发现一个问题:上述机制可能出现多个 Candidate 竞争投票,导致每个 Candidate 一直得不到超过半数的票,会导致选举投票循环。为了解决这个问题, Raft 会给每个 Candidate 在固定时间内随机的一个超时时间(一般为 150-300ms)。这么做可以尽量避免新的一次选举出现多个 Candidate 竞争投票的现象。

  3. 收到其他 Leader 通信请求:

    如果 Candidate 收到其他声称自己是 Leader 的请求的时候,通过任期 TermId 来判断是否处理。

    如果请求的任期 TermId 不小于 Candidate 当前任期 TermId,那么 Candidate 会承认该 Leader 的合法地位并转化为 Follower;否则,拒绝这次请求,并继续保持 Candidate;

日志条目

在Leader选出后,就开始接收客户端的请求。Leader把请求作为日志条目加入到它的日志中,然后并行的向其他服务器发起 AppendEntries RPC 来复制日志条目。当这条日志被复制到大多数服务器上,Leader将这条日志应用到它的状态机并向客户端返回执行结果。

日志复制这部分内容仍会出现一系列问题。在正常情况下,Leader 和 Follower 的日志复制能够保证整个集群的一致性,但是遇到 Leader 崩溃的时候,Leader 和 Follower 日志可能出现了不一致的状态,此时 Follower 相比 Leader 就会缺少部分日志。

为了解决数据不一致性,Raft 算法规定 Follower 强制复制 Leader 的日志,即 Follower 不一致的日志都会被 Leader 的日志覆盖,最终 Follower 和 Leader 的日志会保持一致。除此之外还有其他的安全性问题,这里不一一赘述了。

这实际上是 Part 2 需要实现的内容,需要在框架中实现追加新日志条目的代码。

在 Part 3 中还包括持久化的实现。在该实验中,要求从Persister对象保存和恢复持久状态,从 Persister 初始化的状态,并且在状态改变时使用 Persister 对象来保存自己的持久状态。

总之,Raft算法可归纳如下:

  • 选出 Leader,Leader 节点负责接收外部的数据更新/删除请求;
  • 然后将日志复制到其他 Follower 节点,同时通过安全性的准则来保证整个日志复制的一致性;
  • 如果遇到 Leader 故障,Followers 会重新发起选举出新的 Leader;

那么,在本次实验中,我们如何实现这一系列内容呢?论文中提出的 API 如下图所示:

raft-figure2

三、整个实验的实现

三个角色功能的实现

原论文中已经给出了一系列我们在Raft结构体中需要准备的一系变量,包括currentTerm、voteFor、Log等一系列变量。每个变量具体作用我已列在注释中。

除此之外,由于一个节点自己本身具有状态属性,所以还为其设定了状态 state 变量,用以表明其目前的角色。

当节点参加选举时,需要统计其选票数目,所以还需要 votes 变量,用以确定其目前的选票数目。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
type Raft struct {
mu sync.Mutex // 互斥锁,用于保护Raft节点的状态
peers []*labrpc.ClientEnd // 其他Raft节点的RPC客户端
persister *Persister // 持久化存储接口
me int // 当前节点的索引, index into peers[]

// Your data here.
// Look at the paper's Figure 2 for a description of what
// state a Raft server must maintain.
currentTerm int // 当前任期
votedFor int // 当前任期内投票给的Candidate ID
log []LogEntry // 日志条目

commitIndex int // 已提交的最大日志条目索引
lastApplied int // 已应用到状态机的最大日志条目索引

nextIndex []int // 每个节点的下一个要发送的日志条目索引
matchIndex []int // 每个节点已复制的最大日志条目索引

// others in Raft
state int // 当前节点的状态(Leader、Candidate、Follower)
votes int // 当前任期内收到的投票数
timer *time.Timer // 选举超时定时器

GetState函数用于确定目前的 Term 以及 确定当前节点是否为 Leader。事实上实现起来也很容易,只需要返回当前节点的 term 并判断其state是否为 Leader即可。

1
2
3
4
5
6
7
8
9
10
11
// return currentTerm and whether this server
// believes it is the leader.
func (rf *Raft) GetState() (int, bool) {

var term int
var isleader bool
// Your code here.
term = rf.currentTerm
isleader = (rf.state == Leader) // 判断节点是不是Leader
return term, isleader
}

Make函数则用来开启整个选举的过程。首先,针对当前节点,需要进行一系列初始化操作,具体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func Make(peers []*labrpc.ClientEnd, me int,
persister *Persister, applyCh chan ApplyMsg) *Raft {
rf := &Raft{}
rf.peers = peers
rf.persister = persister
rf.me = me

// Your initialization code here.
num_peers := len(peers) // 获取 peers 数目
rf.votedFor = -1 // 初始化,不投票
// 初始化日志、日志条目索引、最大日志条目索引
rf.log = make([]LogEntry, 1)
rf.nextIndex = make([]int, n)
rf.matchIndex = make([]int, n)
rf.state = Follower // 注意,初始化状态为 Follower
rf.timer = time.NewTimer(randTime())
......
}

初始化之后,需要模拟投票的情形。这部分内容较为复杂,我用另一个函数 ticker() 来进行模拟。

ticker所模拟的就是我们(二)中提到的,在投票阶段,三种角色需要做的事情:

Follower 角色需要转换为 Candidate 并参与竞选,同时timer.Reset设置为0,意味着当前作为 Follower 的节点失效,立刻进行角色的转换。

1
2
3
4
5
6
7
8
9
10
11
12
func (rf *Raft) ticker() {
for rf.me != -1 {
<-rf.timer.C
switch rf.state {
// Follower上锁后,转换为Candidate并重置计时器
case Follower:
rf.mu.Lock()
rf.state = Candidate
rf.timer.Reset(0)
rf.mu.Unlock()


Candidate 角色需要参与竞选,把任期增加后,自己给自己投一票,并向他的 n 个 peers 发起投票请求 sendRequestVote。等待 n 个节点回应完毕后,即进行票数统计,计算是否转换为 Leader。

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
// Candidiate状态,发起投票请求
case Candidate:
rf.mu.Lock()
rf.currentTerm++ // 增加当前任期
rf.votedFor = rf.me //把票投给自己
rf.persist()
rf.votes = 1
rf.timer.Reset(randTime())
num_logs := len(rf.log)
num_peers := len(rf.peers)
rf.mu.Unlock()

ch := make(chan bool)
// 随机遍历n个节点
for _, i := range rand.Perm(num_peers) {
rf.mu.Lock()
// 如果当前节点仍是Candidate,则向节点i发起投票请求
if rf.me != -1 && rf.state == Candidate && i != rf.me {
// 准备参数,向节点 i 发送投票请求
args := RequestVoteArgs{rf.currentTerm, rf.me,
num_logs - 1, rf.log[num_logs-1].Term}
reply := RequestVoteReply{}
go rf.sendRequestVote(i, args, &reply, ch)
}
rf.mu.Unlock()
}

wait(num_peers, ch) // 等待peers中n个节点的回应

rf.mu.Lock()
// 收到的选票数目大于一半的peers,则成功竞选
if rf.me != -1 && rf.state == Candidate && 2*rf.votes > num_peers {
rf.state = Leader
rf.timer.Reset(0)
// 更新 nextIndex
for i := 0; i < num_peers; i++ {
rf.nextIndex[i] = num_logs
}
}
rf.mu.Unlock()

Leader则需要不断地向Follower发送函数,来确保自己的Leader地位,同时,Leader还承担着更新日志条目的功能, Leader需要检查并更新 commitIndex,确保日志复制到大多数节点后才可以提交。具体实现如下所示:

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
case Leader:
rf.mu.Lock()
// 定期触发心跳超时的逻辑,确保Follower不会触发选举。
rf.timer.Reset(heartbeatTime)
// 日志条目数量
num_logs := len(rf.log)
num_peers := len(rf.peers)
rf.mu.Unlock()

ch := make(chan bool)
for _, i := range rand.Perm(num_peers) {
rf.mu.Lock()
if rf.me != -1 && rf.state == Leader && i != rf.me {
// 准备相应参数,注意,nil意味着发送心跳函数
args := AppendEntriesArgs{rf.currentTerm, rf.me,
rf.nextIndex[i] - 1, rf.log[rf.nextIndex[i]-1].Term,
nil, rf.commitIndex}
// Leader 有新的日志条目需要发送给 Follower,Follower的nextIndex小于日志条目
if rf.nextIndex[i] < num_logs {
args.Entries = make([]LogEntry, num_logs-rf.nextIndex[i])
copy(args.Entries, rf.log[rf.nextIndex[i]:num_logs])
}
// 向一系列异步发送Append请求
reply := AppendEntriesReply{}
go rf.sendAppendEntries(i, args, &reply, ch)
}
rf.mu.Unlock()
}

wait(num_peers, ch) // 等待所有发收成功或超时

RequestVote 相关

RequestVote是在选举过程中,当前节点向自己的 peers 发送请求所需要的。对方节点通过 RequestVote 函数回应相关操作,包括是否投票、是否转换为 Leader 等一系列操作。而当前节点则需要 sendRequestVote 函数来接收 RequestVote 这一回应,以此进行下一步行为。

首先需要定义一系列 RequestVote相关的结构体,这个在Raft论文的那张图中也给出了相关变量及其作用。同时需要 RequestVoteReply 结构来表明对方节点的回应。具体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type RequestVoteArgs struct {
// Your data here.
Term int // Candidate 任期
CandidateId int // Candidate ID
LastLogIndex int // 最后日志条目索引
LastLogTerm int // 最后日志条目任期
}

// example RequestVote RPC reply structure.
type RequestVoteReply struct {
// Your data here.
Term int // 当前任期
VoteGranted bool // 是否投票给Candidate
}

sendRequestVote函数是当前节点发送给其他 n 个 peers的。对方节点通过 RequestVote表达自己的回应reply(是否投票,自己任期比当前节点高,自己应当是 Leader ),当前节点根据 reply做出相应的决策。具体细节如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func (rf *Raft) sendRequestVote(server int, args RequestVoteArgs, reply *RequestVoteReply, ch chan bool) {
ok := rf.peers[server].Call("Raft.RequestVote", args, reply) // 接收对方节点的回应
if !ok {
return
}
rf.mu.Lock()
// 对方任期比当前节点高,则转换为对方节点的Follower
if reply.Term > rf.currentTerm {
rf.currentTerm = reply.Term
rf.votedFor = -1
rf.persist()
rf.state = Follower
}
// 当前节点是 Candidate,且对方节点愿意投票给当前节点
if rf.state == Candidate && reply.VoteGranted {
rf.votes += 1
}
rf.mu.Unlock()
ch <- true
}

对应地,对方节点需要 RequestVote 函数,判断是否投票给当前节点。这部分需要判断自己任期与当前节点对比,并判断日志情况。具体实现细节如下所示。

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

func (rf *Raft) RequestVote(args RequestVoteArgs, reply *RequestVoteReply) {
// Your code here.
rf.mu.Lock()
defer rf.mu.Unlock()
reply.Term = rf.currentTerm

if args.Term > rf.currentTerm {
rf.currentTerm = args.Term // 将currentTerm提升到最新的term
rf.votedFor = -1 // 不投票给其他人
rf.persist()
rf.state = Follower
}
// 满足基本条件且日志一样新,就投票给当前节点
// 比较内容:1、对方任期更高 2、还没投票或已经支持了Candidate 3、Candidate日志是否比自己的更新
if args.Term >= rf.currentTerm &&
(rf.votedFor == -1 || rf.votedFor == args.CandidateId) &&
(args.LastLogTerm > rf.log[len(rf.log)-1].Term ||
args.LastLogTerm == rf.log[len(rf.log)-1].Term &&
args.LastLogIndex >= len(rf.log)-1) {
rf.votedFor = args.CandidateId
rf.persist()
rf.timer.Reset(randTime()) // 重置选举计时
reply.VoteGranted = true // 愿意投票给当前节点
}
}

AppendEntry + heartbeat相关

在这里,我设置了与AppendEntry结构体相关的功能有两个:

  1. 维持心跳: 确保Leader和Follower之间的连接没有断开,从而阻止Follower进入Candidate状态。
  2. 日志同步: 将 Leader的日志条目发送给 Follower,从而保持日志的一致性。

因此,我们需要AppendEntry相关的结构体来完成上述功能。具体实现如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
type AppendEntriesArgs struct {
Term int // 当前任期
LeaderId int // Leader的ID
PrevLogIndex int // 前一个日志条目的索引
PrevLogTerm int // 前一个日志条目的任期
Entries []LogEntry // 要追加的日志条目
LeaderCommit int // Leader的commitIndex
}

type AppendEntriesReply struct {
Term int
Success bool // 是否追加日志相关
}

和上面的 RequestVote 类似,AppendEntry 这部分内容也包含 ApplyEntries 与 sendApplyEntries两部分内容,ApplyEntries用于

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
func (rf *Raft) AppendEntries(args AppendEntriesArgs, reply *AppendEntriesReply) {
rf.mu.Lock()
defer rf.mu.Unlock()
// 确定,对方节点是 Leader
reply.Term = rf.currentTerm
if args.Term == rf.currentTerm {
rf.state = Follower
rf.timer.Reset(randTime()) // 重置选举计时,防止超时后重新发起选举
}
if args.Term > rf.currentTerm {
rf.currentTerm = args.Term // 将currentTerm提升到最新的term
rf.votedFor = -1
rf.persist()
rf.state = Follower
}
// 日志条目是否匹配
// 比较 Leader指定的Index和Term是否能找到
if args.Term >= rf.currentTerm &&
args.PrevLogIndex < len(rf.log) &&
args.PrevLogTerm == rf.log[args.PrevLogIndex].Term {
// 2、修改Follower的日志
if args.PrevLogIndex+1 != len(rf.log) || args.Entries != nil {
// 删除不相关的日志,并追加args.Entries
rf.log = append(rf.log[:args.PrevLogIndex+1], args.Entries...)
rf.persist()
}
// 更新日志相关
if args.LeaderCommit > rf.commitIndex {
// Follower更新为LeaderCommit或自己的日志长度
// Leader的日志可能还没有全发送给Follower,因此这里需要比较大小
if args.LeaderCommit < len(rf.log) {
rf.commitIndex = args.LeaderCommit
} else {
rf.commitIndex = len(rf.log)
}
}
reply.Success = true
}
}

sendAppendEntries 的功能也与 sendRequestVote 类似,Leader节点会通过该函数不断地向对方节点发送请求,在接受对方的节点的回应的同时,作出相应的举措。

但同时,该函数充当了”心跳函数“的功能,告知它们当前 Leader 节点仍然活跃,从而防止 Follower 超时触发选举。具体如下所示:

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
func (rf *Raft) sendAppendEntries(server int, args AppendEntriesArgs, reply *AppendEntriesReply, ch chan bool) {
ok := rf.peers[server].Call("Raft.AppendEntries", args, reply)
if !ok {
return
}
rf.mu.Lock()
// 对方的Term比自己的大,则转变为对方的Follower
if reply.Term > rf.currentTerm {
rf.currentTerm = reply.Term // 将currentTerm提升到最新的term
rf.votedFor = -1
rf.persist()
rf.state = Follower
}
// 更新成功,更新nextIndex和matchIndex
if rf.state == Leader && reply.Success {
rf.nextIndex[server] = args.PrevLogIndex + len(args.Entries) + 1
rf.matchIndex[server] = rf.nextIndex[server] - 1
} else if rf.state == Leader && !reply.Success {
// 日志匹配失败
rf.nextIndex[server]--
for rf.nextIndex[server] >= 0 && rf.log[rf.nextIndex[server]].Term == reply.Term {
rf.nextIndex[server]-- // 跳过相同term的索引,从不同的开始重新sendAppendEntries
}
}
rf.mu.Unlock()
ch <- true
}

那么,如何判断 sendAppendEntries在这部分发挥的作用呢?

  • 如果 args.Entries 为空,说明这次调用是心跳。
  • 如果 args.Entries 不为空,说明这次调用是日志复制。

具体见ticker() 中的 case Leader 部分,如下所示,最初 args.Entries 初始化为 nil,而如果 Follower的nextIndex(即rf.nextIndex[i])小于 num_logs,那么就意味着需要更新日志,sendAppendEntries充当复制日志的作用;否则意味着不需要更新日志,sendAppendEntries 的作用只是发送心跳信号。

1
2
3
4
5
6
7
8
9
args := AppendEntriesArgs{rf.currentTerm, rf.me,
rf.nextIndex[i] - 1, rf.log[rf.nextIndex[i]-1].Term,
nil, rf.commitIndex
}
// Leader 有新的日志条目需要发送给 Follower,Follower的nextIndex小于日志条目
if rf.nextIndex[i] < num_logs {
args.Entries = make([]LogEntry, num_logs-rf.nextIndex[i])
copy(args.Entries, rf.log[rf.nextIndex[i]:num_logs])
}

当然,还需要注意的是,Leader接收到日志后,还需要发送给上层的状态机。为此,我们需要再次建立一个函数 RecordLog,将接收到的日志发送到上层。具体如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func (rf *Raft) RecordLog(applyCh chan ApplyMsg) {
// 一直监听
for rf.me != -1 {
time.Sleep(checkTimeout)
rf.mu.Lock()
// 持续监控commitIndex和lastApplied之间的差距
for rf.me != -1 && rf.commitIndex > rf.lastApplied {
rf.lastApplied++
// 通过applyCh将对应日志条目发送出去
applyCh <- ApplyMsg{Index: rf.lastApplied, Command: rf.log[rf.lastApplied].Command}
}
rf.mu.Unlock()
}
}

持久化

持久化涉及到Persist结构体。raft.go中我们需要自行补充Persist结构体中的内容。实现难度并不大,简单来说,persist函数实现了加密功能,而readPersist函数则是对数据进行解码。具体如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func (rf *Raft) persist() {
// Your code here.
w := new(bytes.Buffer)
e := gob.NewEncoder(w)
// 对关键信息编码
e.Encode(rf.currentTerm)
e.Encode(rf.votedFor)
e.Encode(rf.log)
// 编码后数据,持久化存储
data := w.Bytes()
rf.persister.SaveRaftState(data)
}

// restore previously persisted state.
func (rf *Raft) readPersist(data []byte) {
// Your code here.
// 获得解码数据解码
r := bytes.NewBuffer(data)
d := gob.NewDecoder(r)
d.Decode(&rf.currentTerm)
d.Decode(&rf.votedFor)
d.Decode(&rf.log)
}

还有一个问题是,我们需要在哪些地方使用persist()函数呢?

事实上,特别简单。

我们观察persist() 函数,可以发现,persist()操作作用的变量只有三个:currentTerm、voteFor 和 log,因此,只要在我们的整个代码中,这三个变量发生改变时,应用持久化函数rf.persist()即可。如:

1
2
3
4
// currentTerm与voteFor变量发生了改变,因此需要persist()记录。
rf.currentTerm = reply.Term
rf.votedFor = -1
rf.persist()

这就是整个代码的逻辑。

Part 1 通过截图如下所示:

Pass Part1

Part 2 通过截图如下所示:

Pass Part2

Part 3 通过截图如下所示:

Pass Part3

四、总结

这个实验难度比较大,但也是通过这个实验,我明白了Raft的原理,感觉还是特别有意思的。收获很大。

  • Copyright: Copyright is owned by the author. For commercial reprints, please contact the author for authorization. For non-commercial reprints, please indicate the source.

请我喝杯咖啡吧~

支付宝
微信