揭秘大模型背后的“专家天团”:混合专家模型 (MoE) 入门指南

我们接着上一篇 Transformer 的介绍,来聊一聊当前大模型领域一个非常火热且高效的技术:混合专家模型(Mixture-of-Experts, MoE)

如果你已经理解了 Transformer,那么理解 MoE 会容易得多,因为它正是对 Transformer 结构的一个巧妙“升级”。


揭秘大模型背后的“专家天团”:混合专家模型 (MoE) 入门指南

想象一下,一个标准的大型语言模型(比如 GPT-3)就像一个无所不知的“超级通才”。无论你问它关于物理、历史、烹饪还是编程的问题,它都会调动整个庞大的“大脑”(即全部的神经网络参数)来思考和回答。

这种模式的痛点:

  • 成本高昂:模型越大,参数越多,每次计算的开销就越大。这就像让一位诺贝尔物理学奖得主用他全部的脑力去计算“1+1=?”,非常浪费资源。
  • 扩展困难:想让模型更强大,就得把它的“大脑”做得更大,训练成本和推理延迟会急剧上升。

MoE 的核心思想:组建一个“专家委员会”
MoE 提出了一种更聪明的方式:我们不需要一个包揽一切的超级通才,而是可以组建一个“专家委员会”。

  • 这个委员会有很多**专家 (Experts)**,每个专家都擅长某个特定的领域(比如,有的擅长处理代码,有的擅长处理诗歌,有的擅长处理事实性知识)。
  • 委员会里还有一个非常重要的角色——**门控网络 (Gating Network)**,它就像一个聪明的“任务分配官”或“路由器”。

当一个任务(比如,一个需要处理的单词/Token)进来时,这位“分配官”会快速判断:“嗯,这个任务看起来跟编程有关,我应该把它交给第 3 号和第 8 号专家去处理。”

于是,只有被选中的这几个专家需要“开动脑筋”(被激活计算),其他所有专家都可以继续“休息”。

通过这种方式,模型可以拥有海量的总参数(所有专家的参数加起来),但在处理任何单个任务时,实际动用的计算量却很小。这就是所谓的**“稀疏激活” (Sparse Activation)**。


一、MoE 在 Transformer 中的位置

MoE 并不是一个独立的全新模型,而是对 Transformer 结构的一个“插件化”改造。它通常被用来替换 Transformer 层中的前馈神经网络(Feed-Forward Network, FFN)部分

回忆一下我们之前讲的 Transformer 编码器层结构:

  1. 输入
  2. 多头自注意力 (Multi-Head Attention)
  3. Add & Norm
  4. 前馈神经网络 (Feed-Forward Network, FFN)
  5. Add & Norm
  6. 输出

在 MoE 架构中,第 4 步的那个单一、稠密的 FFN,被替换成了一个 MoE 模块。

现在,每个 Token 在通过注意力层之后,不再是只有一个 FFN 可以去,而是会面对一个拥有“门控网络”和多个可选 FFN“专家”的 MoE 模块。


二、深入原理:MoE 是如何工作的?

我们来拆解一下 MoE 模块内部的运作流程。

假设我们有 8 个专家,并且设定每次只选择最好的 2 个专家(即 Top-2 路由)。

  1. 接收输入:一个 Token 的向量表示(比如 $x$)从注意力层传来,进入 MoE 模块。

  2. **门控网络做决策 (Gating Network)**:

    • 门控网络本身是一个小型的神经网络(通常就是一个简单的线性层)。
    • 它接收 Token 向量 $x$,然后输出一个包含 8 个分数的列表(logit),每个分数对应一个专家。这个分数代表了门控网络认为该专家与当前任务的“匹配度”。
    • logits = GatingNetwork(x) -> [1.2, -0.5, 3.1, ..., 2.5] (8个分数)
  3. 选择 Top-K 专家

    • 我们从这 8 个分数中选出最高的 2 个。比如,第 3 个和第 8 个专家的分数最高。
    • 这意味着,接下来的计算将只由 Expert 3 和 Expert 8 完成。
  4. **计算专家权重 (Softmax)**:

    • 我们只拿出被选中的那 2 个分数(3.1 和 2.5),然后通过一个 Softmax 函数。
    • Softmax 会将这两个分数转换成两个权重,加起来等于 1。比如,[0.65, 0.35]
    • 这代表,在最终结果中,我们应该 65% 听 Expert 3 的,35% 听 Expert 8 的。
  5. 专家处理与加权合并

    • Token 向量 $x$ 被同时发送给 Expert 3 和 Expert 8。
    • Expert 3 输出结果 $y_3 = \text{Expert}_3(x)$。
    • Expert 8 输出结果 $y_8 = \text{Expert}_8(x)$。
    • 最终的输出是这两个结果的加权和:final_output = (y_3 * 0.65) + (y_8 * 0.35)

就这样,MoE 模块完成了它的工作。虽然我们有 8 个专家,但只激活了 2 个,大大节省了计算。


三、MoE 的优势与挑战

优势

  1. 高效的计算成本:总参数量可以非常大(模型容量大,能学到更多知识),但处理每个 Token 的计算成本(活跃参数量)却可以保持很低。这是 MoE 最核心的优点。
  2. 更快的推理速度:由于计算量减少,模型的推理速度比同等总参数量的稠密模型要快得多。
  3. 巨大的模型容量:理论上可以加入非常多的专家来扩展模型的知识边界,而不会让计算成本失控。例如,Mixtral 8x7B 模型,总参数量约 47B,但每次推理只激活约 13B 参数。

挑战

  1. 训练不稳定:门控网络有可能会“偏心”,总是把任务交给某几个它喜欢的“明星专家”,导致其他专家得不到训练。
  2. 负载均衡:为了解决上述问题,需要引入一个额外的**“负载均衡损失函数” (Load Balancing Loss)**,来鼓励门控网络尽可能将任务均匀地分配给所有专家。这给训练增加了一点复杂性。
  3. 高内存需求:虽然计算是稀疏的,但所有专家(无论是否被激活)的参数都必须加载到 GPU 显存中。这导致 MoE 模型对硬件的显存要求非常高。
  4. 通信开销:在分布式训练(用多张 GPU 卡训练)时,如果专家分布在不同的卡上,那么 Token 在被路由到不同专家时会产生大量的通信,可能会成为瓶颈。

四、简单实现思路 (以 PyTorch 为例)

同样,我们用概念性的代码来展示其结构。

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
import torch
import torch.nn as nn
import torch.nn.functional as F

# 定义一个简单的专家(就是个FFN)
class Expert(nn.Module):
def __init__(self, d_model, d_hidden):
super().__init__()
self.network = nn.Sequential(
nn.Linear(d_model, d_hidden),
nn.ReLU(),
nn.Linear(d_hidden, d_model)
)
def forward(self, x):
return self.network(x)

# 核心的MoE层
class MoELayer(nn.Module):
def __init__(self, d_model, num_experts, top_k):
super().__init__()
self.top_k = top_k

# 专家列表
self.experts = nn.ModuleList([Expert(d_model, d_model * 4) for _ in range(num_experts)])

# 门控网络,输出每个专家的分数
self.gating_network = nn.Linear(d_model, num_experts)

def forward(self, x):
# x 的形状: [batch_size, sequence_length, d_model]

# 1. 通过门控网络获取分数
# reshape 成 [batch_size * sequence_length, num_experts]
gating_logits = self.gating_network(x.view(-1, x.shape[-1]))

# 2. 选择 Top-K 专家
# `topk`会返回权重和索引
weights, indices = torch.topk(gating_logits, self.top_k, dim=-1)

# 3. 将分数通过 Softmax 转换成最终权重
weights = F.softmax(weights, dim=-1, dtype=torch.float).to(x.dtype)

# 4. 准备输出
final_output = torch.zeros_like(x)

# 这是一个简化的、低效的循环,仅为演示原理
# 实际高效实现会使用复杂的索引和矩阵乘法来避免循环
flat_x = x.view(-1, x.shape[-1])
for i, (w, idx) in enumerate(zip(weights, indices)):
# 对每个 token,获取其选择的专家输出
expert_outputs = [self.experts[expert_idx](flat_x[i]) for expert_idx in idx]

# 加权求和
weighted_output = torch.stack(expert_outputs, dim=-1) * w.unsqueeze(-1)
final_output.view(-1, x.shape[-1])[i] = torch.sum(weighted_output, dim=-1)

return final_output

著名的 MoE 模型

  • Google GLaM, Switch Transformer: 学术界早期的重要 MoE 模型。
  • Mistral AI 的 Mixtral 8x7B: 开源社区的明星模型,以其卓越的性能和相对较低的推理成本而闻名。
  • Groq 正在使用的模型: 据信 Groq 的超快推理芯片背后也部署了 MoE 架构。

总结

MoE 是一种优雅的“缩放法则” (Scaling Law) 实践。它通过“专家分工”和“稀疏激活”的策略,巧妙地解决了大模型无限增长带来的计算困境。它允许我们构建参数量极其庞大的模型,同时将实际计算成本控制在可接受的范围内,是通往更强大、更高效 AI 的一条关键路径。

好的,我们继续深入,探讨一个更前沿、更强大的概念:多模态混合专家模型(Multimodal Mixture-of-Experts, MMoE)

这正是像 Google Gemini 这样先进模型背后的核心技术之一,它让 AI 真正具备了“看、听、读”的综合能力。


当AI学会“看听读”:多模态混合专家(Multimodal MoE)原理解析

我们首先回顾一下:

  • Transformer 是一次性处理所有输入(如单词)并理解它们之间关系的强大架构。
  • 混合专家模型 (MoE) 是对 Transformer 的一项优化,它用一个“专家委员会”代替了单个庞大的前馈网络(FFN)。一个“任务分配官”(门控网络)只将任务(Token)路由给最相关的少数几个专家,从而在保持巨大模型容量的同时,极大地降低了计算成本。

到目前-为止,我们讨论的 MoE 主要还是在单一模态(Unimodal),特别是文本领域。专家们虽然各有所长,但处理的都是同一种类型的“文件”——文本 Token。

然而,真实世界是多模态 (Multimodal) 的,充满了图像、声音、文字、视频等各种信息。要让 AI 像人类一样理解世界,就必须教会它同时处理这些不同类型的数据。

多模态 MoE 的核心任务:
如何让“专家委员会”不仅能处理文本报告,还能看懂图片、听懂录音,并理解它们之间的关联?


一、从单模态 MoE 到多模态 MoE 的演进

想象一下我们的“专家委员会”:

  • 单模态 MoE:委员会里都是语言学家、作家、程序员等文本专家。他们擅长处理各种文本,但你给他们一张梵高的《星空》,他们可能会束手无策。
  • 多模态 MoE:委员会进行了扩招和改组!现在,里面不仅有文本专家,还加入了图像分析师、声学工程师、视频剪辑师等。这个委员会现在能处理各种类型的“文件”。

关键挑战:不同模态的数据结构天差地别。

  • 文本是离散的单词序列。
  • 图像是像素网格。
  • 音频是连续的波形。

在送入 MoE 模块之前,我们必须先把这些五花八门的数据转换成一种通用的“语言”——**向量 (Vector)**。

这通过各模态专用的编码器 (Encoder) 来实现:

  • 图像:通过一个视觉 Transformer (ViT) 编码器,将图片切成小块(Patches),每个小块转换成一个向量。
  • 文本:通过一个文本 Tokenizer 和词嵌入模型,将单词转换成向量。
  • 音频:通过一个音频编码器(如处理梅尔频谱图),将音频片段转换成向量。

所有数据都被转换成统一格式的 Token 向量后,就可以送入多模态 MoE 的核心层进行处理了。


二、多模态 MoE 的核心架构与实现思路

一旦所有数据都变成了统一的 Token 向量,接下来的问题是:专家们应该如何组织?门控网络又该如何路由?

这里主要有两种主流的设计思路:

思路一:模态专属专家 (Modality-Specific Experts)

这是一种直接且清晰的思路。

  • 架构:我们将专家分成几个“部门”,比如“视觉部”、“语言部”、“听觉部”。每个部门内部有多个专家。
  • 路由:门控网络知道每个 Token 的“出身”(是图像、文本还是音频)。当一个图像 Token 进来时,门控网络只会从“视觉部”的专家中选择 Top-K 个进行激活。文本 Token 则路由给“语言部”的专家。
  • 优点
    • 专业化:每个专家都可以深度专注于自己模态的特定模式,不会被其他模态的信息“干扰”。
    • 易于理解和训练:结构清晰,任务明确。
  • 缺点
    • 融合不足:跨模态的深层融合发生得比较晚。专家们在自己的部门里“闭门造车”,只有在更高层次的模块(比如注意力层)中,不同模态的信息才能真正互动。

思路二:共享与混合专家 (Shared / Hybrid Experts)

这是目前更前沿、更强大的思路,也是 Gemini 这类模型被认为采用的方案。

  • 架构:我们不再设立严格的“部门”,而是只有一个巨大的、混合的专家池
  • 路由:门控网络不再关心 Token 的“出身”,只关心它的内容和概念。它会根据 Token 向量本身所代表的抽象含义,将其路由到最合适的专家,无论这个 Token 最初来自图像还是文本。
  • **涌现的奇迹 (Emergence)**:
    • 一个描述天空的文本 Token “sky” 和一个包含蓝天的图像 Token,可能因为它们的向量在语义空间中很接近,而被门控网络路由到同一个专家。这个专家慢慢就学会了处理“天空”这个抽象概念,而不仅仅是某个特定模态的模式。
    • 同理,可能会有专家专门处理“纹理”概念,另一个专家处理“对称性”概念,这些都是跨模态的通用知识。
  • 优点
    • 深度融合:在模型的极深层次就实现了跨模态的知识共享与融合。
    • 更高的参数效率:通过学习跨模态的通用概念,模型可以更有效地利用其参数。
  • 缺点
    • 训练更复杂:如何引导模型学习到这种有意义的跨模态概念,是一个巨大的挑战。
    • 需要海量且高质量的数据:需要大量配对好的图文、音视频数据,才能让模型学会这种跨模态的对应关系。

三、优势与前沿挑战

多模态 MoE 的巨大优势

  1. 极致的扩展性:这是目前已知最能有效扩展模型规模,同时容纳多种模态信息的架构。理论上可以构建拥有数万亿参数的巨型模型。
  2. 前所未有的综合理解能力:模型能够执行复杂的跨模态推理任务。例如:
    • 看一段视频,回答关于画面和声音的问题。
    • 上传一份带图表的财报 PDF,让它总结关键信息。
    • 给一张食材图片,让它生成一份菜谱文本。
  3. 计算效率:继承了 MoE 的核心优点,无论模型总参数多大,处理单个任务的计算成本都保持在可控范围内。

面临的前沿挑战

  1. **数据对齐 (Data Alignment)**:如何让模型确信,猫的图片、”cat” 这个词、猫的叫声(喵~)都指向同一个实体?这需要精心设计的数据集和训练策略。
  2. 复杂的负载均衡:不仅要确保任务在专家之间均匀分配,还要考虑模态间的平衡。不能让模型在训练中只偏爱处理更容易的文本 Token,而冷落了复杂的图像 Token。
  3. 路由策略:设计更智能的门控网络至关重要。也许未来的路由是层级式的:一个高级路由器先判断模态,再由次级路由器进行概念路由。
  4. 评估与可解释性:如何系统地评估一个多模态大模型的能力?当它犯错时,我们如何知道是视觉理解错了、还是语言推理错了,或者是二者融合时出了问题?

四、总结与展望

多模态混合专家模型(MMoE)是通往通用人工智能(AGI) 的一条关键技术路径。它通过模仿人类社会“专家委员会”的高效协作模式,成功地将 Transformer 的强大表征能力和 MoE 的高效扩展性结合起来,并将其应用到了复杂多变的多模态世界。

从只能处理文本的“语言学家”,到能够看、听、读、思的“全能数字助理”,MMoE 架构正在驱动 AI 完成一次深刻的进化。未来,随着硬件的发展和算法的优化,我们将会看到更加庞大、能力更加全面的多模态模型,它们将能以更接近人类的方式来理解和与我们周围的世界互动。

当收藏夹爆满,人生却原地踏步:破解“万事皆懂,然并卵”的终极方案


知行合一:破解“万事皆懂,然并卵”的时代困局

我们活在一个知识过剩的时代。“收藏夹”早已不堪重负,大脑被各路“干货”填充得满满当当,但身体却仿佛被无形的缰绳捆绑在原地。那句“道理都懂,但还是过不好这一生”的自嘲,与其说是戏谑,不如说是这个时代深沉的集体叹息。

我们与理想人生的距离,并非隔着未知的知识,而是横亘在“知道”与“做到”之间的巨大鸿沟。

明代大儒王阳明一针见血:“知而不行,只是未知。” 这句话为我们提供了终极的诊断书:所有停留在脑海中、未能转化为具体行动的“道理”,都只是虚假的认知幻影。 我们误将“听懂了”当作“学会了”,将“理解了”等同于“拥有了”,而大脑的原始本能又倾向于节约能量、固守旧习。于是,我们在理性的认同与本能的抗拒之间反复拉扯,最终在内耗中败下阵来。

要走出这个困境,与其苦苦寻觅下一个“终极道理”,不如着手为自己搭建一座坚实的桥梁,一座跨越知行鸿沟的行动之桥。这座桥由三个关键的桥墩构成:

一、 微步启动:用“小到可笑”的行动,为改变破冰

行动最大的敌人,是宏大目标带来的心理压迫感。当“我要减重20斤”或“我要读完100本书”这样的念头升起时,大脑的预警系统会瞬间拉响,拖延与逃避便是它最本能的防御。

真正的行动智慧,在于“以小胜大”。请将你的雄心壮志,拆解成一个“小到可笑”、几乎无需动用意志力的“微步动作”。

  • 想健身? 你的第一步不是“冲进健身房锻炼一小时”,而是“穿上运动鞋,在家门口站一分钟”。这个动作的全部意义,在于向你的潜意识宣告:“看,我们已经开始了。”
  • 想阅读? 别强迫自己啃下一整个章节。你的任务是“翻开书,读完眼前这一段话,哪怕只有三行”。让“开始”变得像呼吸一样轻而易举。
  • 想学习新技能? 忘掉那些系统性课程。你的启动仪式是“打开学习软件,看完那个三分钟的介绍视频”。

这正是曾国藩所言“大处着眼,小处下手”的精髓。微步行动如同一根微小的船舵,看似无力,却能撬动整艘巨轮的方向。每一次微小成功的达成,都会释放出一剂名为“多巴胺”的奖赏,为我们注入宝贵的信心。正是这涓滴的积极反馈,汇聚成了驱动我们持续前行的滔滔江河。

二、 正念觉察:做自己心念的主人,而非情绪的奴隶

很多时候,阻碍我们的并非懒惰,而是自动化运行的旧有模式。在疲惫、焦虑或无聊的情绪扳机扣动时,我们便会无意识地滑向即时满足的避风港——刷短视频、吃垃圾食品、冲动消费。

要打破这个循环,我们需要从“无意识的反应”切换到“有意识的选择”。这需要我们修炼“正念觉察”的功夫,在刺激与回应之间,为自己创造一个黄金缓冲地带。

  • 识别情绪扳机: 当你即将滑向旧有模式的瞬间,按下内心的“暂停键”。问自己:“此刻,我内在的感受是什么?是什么触发了这个念头?” 单纯地观察和命名情绪(例如:“哦,这是焦虑感”),就能极大地削弱它对你的控制力。
  • 植入“认知锚点”: 将你学到的道理,转化为特定场景下的“灵魂拷问”。打开购物App前,问自己:“这是‘我想要的’,还是‘我真正需要的’?”在即将发怒时,问自己:“情绪化能解决问题吗?” 这个小小的认知介入,就是理性在潜意识层面打下的楔子。
  • 拥抱不完美,用慈悲代替苛责: 当行动受挫时,最大的内耗来自于自我批判。请用“自我关怀”取代它。告诉自己:“这只是万里长征中的一步,失败是数据的积累,而非人格的审判。我能从中学到什么?” 正如曾子“吾日三省吾身”,其真意并非自我鞭挞,而是温和而持续的自我修正与反馈。

三、 环境设计:让世界成为你的盟友,而非对手

我们往往高估了意志力的神话,却严重低估了环境对行为的塑造力。一个成熟的行动者,从不与环境和人性硬碰硬,而是聪明地设计一个能“助推”自己前行的外部系统。

  • 增加“好行为”的便利性: 想多喝水,就把一个精致的水杯放在显示器旁;想练吉他,就把它从琴箱里解放出来,放在客厅最显眼的位置;想看书,就把书放在你的枕边。让正确的行为变得“毫不费力”。
  • 增加“坏行为”的阻力: 想戒掉零食,第一步就是清空家里的库存;想减少手机干扰,就在工作时段将它放在另一个房间,或为娱乐App设置复杂的密码和使用时限。让错误的行为变得“兴师动众”。
  • 构建社会支持系统: “孟母三迁”的古老智慧至今依然闪光。将你的目标分享给积极、可信赖的伙伴,让他们成为你的“成长合伙人”。加入一个有共同目标的社群,同伴的鼓励和榜样的力量,是你对抗孤独与懈怠最坚实的后盾。

晚清名臣郭嵩焘曾哀叹自己终日埋首书斋,满腹经纶却无法付诸救国之行动,最终“徒然成为纸上孤愤”。这个跨越百年的遗憾,至今仍警醒着我们。

“道理都懂,却过不好这一生”的终极解药,不在于寻找更玄妙的道理,而在于回归最朴素的行动。

从一个微不足道的善意行动开始,用正念觉察照亮内心的迷雾,并聪明地借助环境的力量,我们就能开启一个“认知 → 行为 → 反馈 → 再认知”的良性循环。在这个不断上升的螺旋中,知识不再是冰冷的概念,而是融入血液的本能;成长不再是遥远的彼岸,而是你脚下每一步坚实的印记。

知行合一,并非一个一劳永逸的终点,而是一种日复一日、持续进行的生命状态。当你开始行动的那一刻,无论多么微小,你便已经走在了“过好这一生”的康庄大道上。

愿我们都能成为一个温和而坚定的行动者,在时光的长河里,亲手雕刻出那个更从容、更强大的自己。


最后:如何避免读懂了这篇文章,却依然无动于衷?

读到这里,你或许心潮澎湃,深有共鸣。但最危险的时刻,也恰恰是现在——当你心满意足地准备关掉这个页面,然后……生活照旧。

这本身,就是“知行不一”最讽刺的现场演示。为了不让这篇文章沦为又一个“懂了却没用”的道理,请你与我完成最后一个,也是最关键的一步:一个“一分钟行动契约”。

不要思考,不要犹豫,请在接下来的一分钟内,完成以下三件事:

  1. 锁定一个最小战场: 从你最想改变的无数件事情中,只选一件。不是“健康”,而是“喝水”;不是“学习”,而是“读那本买来很久的书”。把目标缩小到极致。

  2. 定义一个“即刻行动”: 为这个最小战场,设计一个此刻、马上、身体一动就能完成的“微步动作”。它的标准是:毫无难度,甚至有点可笑。

  • 如果你选了“喝水”,你的“即刻行动”是:站起来,去给自己倒一杯水,然后放在手边。
  • 如果你选了“读那本书”,你的“即刻行动”是:走过去,把那本书从书架(或箱子)里拿出来,翻开到第一页,放在你的枕头边或电脑旁。
  • 如果你选了“整理房间”,你的“即刻行动”是:捡起视野范围内的三件垃圾或杂物,扔进垃圾桶或放回原位。
  1. 立即执行,现在!

请立即放下手机或离开电脑屏幕,去完成你刚才定义的那个动作。
对,就是现在。我在这里等你一分钟。

...

欢迎回来。

当你完成那个微不足道的动作时,恭喜你,你已经完成了最艰难的一步——**你将这篇文章从一个“认知层面的信息”,转化成了一个“身体层面的经验”。**

刚才那个起身倒水的你,那个拿起书本的你,已经不再是几分钟前那个仅仅“知道”的你了。你已经是一个“做到”的人,哪怕只做到了万分之一。

**这,就是知行合一真正的起点。**

请记住刚才完成那个微小行动时的感觉。这份微小的成就感,才是这篇文章送给你最宝贵的礼物。从现在起,忘记那些宏大的道理,只专注于你的下一个“一分钟行动”。

真正的改变,从不发生于“恍然大悟”的瞬间,而发生于“身体力行”的此刻。

Transformer 架构详解:写给初学者的入门指南

这是一份写给初学者的 Transformer 架构系统性介绍。我们将用尽可能通俗易懂的语言、恰当的比喻和清晰的结构,来剖析这个当今人工智能领域最重要的模型之一。


想象一下,你在做一篇很长的英文阅读理解。传统的做法(就像旧的 AI 模型 RNN/LSTM)是你一个词一个词地读,读到后面可能会忘记前面的细节。但如果让你先把整篇文章通读一遍,然后在回答问题时,你可以随时回头查看文章的任何部分,并重点关注与问题最相关的句子,效率和准确性是不是就高多了?

Transformer 架构就是后面这种“聪明的读者”。它彻底改变了 AI 处理序列数据(尤其是文本)的方式。

一、核心思想:告别“按顺序”,拥抱“全局视野”

在 Transformer 出现之前,主流的模型如循环神经网络(RNN)和长短期记忆网络(LSTM)都是顺序处理文本的。它们像一个一个地读单词,试图在脑中维持一个“记忆”来理解上下文。

RNN/LSTM 的两大痛点:

  1. 效率低下:必须一个词处理完才能处理下一个,无法并行计算,处理长文本时速度很慢。
  2. 长期依赖问题:当句子很长时,模型很难记住最开始的信息。比如,“我出生在法国……(中间省略一万字)……所以我最擅长的语言是法语。” 模型可能已经忘记了开头的“法国”。

Transformer 的革命性思想:

  1. 并行计算:一次性读取所有单词,就像把整篇文章铺在桌上。
  2. **自注意力机制 (Self-Attention)**:通过一种绝妙的机制,让模型在处理每个单词时,都能“关注”到句子中所有其他单词,并判断它们之间的关联性强弱。

二、宏观架构:一个高效的翻译系统

Transformer 最初是为机器翻译任务设计的。它的经典结构是一个编码器-解码器 (Encoder-Decoder) 架构。

  • **编码器 (Encoder)**:左侧部分。它的任务是“理解”输入的句子。比如输入“I am a student”,编码器会阅读并消化这句话,将其转换成一堆包含丰富语义信息的数字向量(可以理解为“思想精华”)。
  • **解码器 (Decoder)**:右侧部分。它的任务是根据编码器提炼的“思想精华”,生成目标语言的句子。比如生成“我是一个学生”。

编码器和解码器都不是单一的组件,而是由 N 层(原论文中 N=6)完全相同的结构堆叠而成。这就像把一篇文章让 6 个专家轮流阅读和批注,每一层都会在前一层的基础上进行更深入的理解。


三、深入内部:三大关键组件(以编码器为例)

让我们打开一个编码器层(Encoder Layer),看看里面到底有什么。每个编码器层主要由两大部分组成:多头自注意力机制前馈神经网络

1. 准备工作:词嵌入 (Word Embedding) 与位置编码 (Positional Encoding)

在进入编码器之前,输入的文本需要做两步预处理。

  • **词嵌入 (Word Embedding)**:计算机不认识单词,只认识数字。词嵌入就是用一个向量(一串数字)来表示一个单词。例如,“猫”可能被表示为 [0.1, -0.5, 1.2, ...],“狗”可能被表示为 [0.2, -0.4, 1.1, ...]。意思相近的词,它们的向量也更接近。
  • 位置编码 (Positional Encoding):由于 Transformer 一次性看所有词,它本身不知道词的顺序。但语序至关重要(“我打你”和“你打我”完全不同)。位置编码就是给每个词的向量再额外加上一个代表其位置信息的“标签”向量。这样,模型既知道了每个词的意思,也知道了它们的顺序。

2. 核心引擎:自注意力机制 (Self-Attention)

这是 Transformer 最核心、最天才的部分。它让模型知道在理解一个词时,应该重点关注句子中的哪些其他词。

工作原理(Q, K, V类比法):
想象你在图书馆查资料。

  • **Query (Q, 查询)**:你当前正在研究的主题(比如,你想理解句子中的 “it” 这个词)。
  • **Key (K, 键)**:图书馆里每本书的书名或标签(句子中的每一个词都有一个 Key)。
  • **Value (V, 值)**:书本的具体内容(句子中的每一个词也都有一个 Value,通常是它的词嵌入向量)。

过程如下:

  1. 生成 Q, K, V:对于输入句子中的每个词,我们都通过三个不同的权重矩阵,从它的词嵌入向量生成三个新的向量:Query 向量、Key 向量和 Value 向量。
  2. 计算注意力分数:要理解 “it” 这个词 (它的 Q),你需要将它的 Q 向量与句子中所有词的 K 向量进行点积计算。这个得分代表了 “it” 与其他每个词的关联程度。
  3. **归一化 (Softmax)**:将这些分数通过 Softmax 函数转换成 0到1 之间的权重,且所有权重加起来等于1。权重越高的词,说明关联性越强。
  4. 加权求和:将每个词的 V 向量乘以它对应的权重,然后全部加起来。

最终得到的这个加权平均向量,就是 “it” 这个词在当前语境下的全新表示。如果句子是 “The animal didn’t cross the street because it was too tired”,那么 “animal” 这个词的 V 向量会被赋予很高的权重,最终的新向量就会包含大量 “animal” 的信息,模型从而知道 “it” 指的是 “animal”。

3. 升级版:多头注意力机制 (Multi-Head Attention)

如果只用一套 Q, K, V,就好比你只有一个角度去理解句子。但句子的关系是多维度的。比如,“我”和“打”是主谓关系,“打”和“你”是动宾关系。

多头注意力 就是雇佣多个“注意力头”(比如 8 个),让它们各自学习自己的一套 Q, K, V 权重。

  • 头1 可能关注主谓关系。
  • 头2 可能关注代词指代关系。
  • 头3 可能关注形容词修饰关系…

每个头都独立进行一次完整的自注意力计算,得出一个结果向量。最后,我们将这 8 个头的结果拼接起来,再通过一个线性层进行整合。这样,模型就能从多个角度和维度更全面地理解句子。

4. 辅助组件:前馈网络 (Feed-Forward) 和 Add & Norm

  • 前馈神经网络:在多头注意力层之后,每个词的输出向量会再经过一个简单的前馈神经网络。你可以把它看成是一个“加工厂”,对注意力层提炼出的信息进行进一步的非线性变换和加工,增强模型的表达能力。
  • **Add & Norm (残差连接和层归一化)**:
    • Add (残差连接):在每个主要组件(如多头注意力和前馈网络)的输出上,都把它加上该组件的输入。这相当于走了一条“捷径”,保证了原始信息不会在多层处理中丢失,极大地稳定了训练过程。
    • **Norm (层归一化)**:对每个残差连接后的输出进行归一化,使其数据分布更加稳定,好比是统一了度量衡,让模型训练起来更快、更稳定。

四、解码器 (Decoder) 的特殊之处

解码器与编码器结构非常相似,但有两点关键不同:

  1. 带掩码的自注意力 (Masked Self-Attention):解码器在生成译文时,是逐词生成的。在预测第 3 个词时,它只能看到已经生成的第 1、2 个词,不能偷看后面的正确答案。这个“掩码”机制就是用来遮盖未来信息的。
  2. 编码器-解码器注意力 (Encoder-Decoder Attention):这是解码器层中的第二个注意力层。它的 Q 来自解码器自身(前一层的输出),但 K 和 V 来自编码器的最终输出。这一步是解码器“查阅”原始句子“思想精华”的过程。比如,在翻译到某个动词时,它会去关注原始句子中的主语和宾语,以确保翻译的准确性。

五、简单实现思路 (以 PyTorch 为例)

对于初学者,无需从零手写所有数学细节。可以利用深度学习框架中封装好的模块来搭建。

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
import torch
import torch.nn as nn

# 1. 关键模块
# 词嵌入
embedding = nn.Embedding(vocab_size, d_model)
# 多头注意力 (包含了Q,K,V的生成和计算)
multihead_attn = nn.MultiheadAttention(embed_dim=d_model, num_heads=8)
# 前馈网络
feed_forward = nn.Sequential(
nn.Linear(d_model, ff_hidden_dim),
nn.ReLU(),
nn.Linear(ff_hidden_dim, d_model)
)
# 层归一化
layer_norm = nn.LayerNorm(d_model)

# 2. 搭建一个编码器层
class EncoderLayer(nn.Module):
def __init__(self):
super().__init__()
# ... 初始化上面的模块

def forward(self, x, mask):
# 多头注意力 + Add & Norm
attn_output, _ = self.multihead_attn(x, x, x, attn_mask=mask)
x = self.layer_norm(x + attn_output)

# 前馈网络 + Add & Norm
ff_output = self.feed_forward(x)
x = self.layer_norm(x + ff_output)

return x

# 3. 搭建完整的 Transformer
class Transformer(nn.Module):
def __init__(self):
super().__init__()
# ...
# 实例化 N 个编码器层
self.encoder_layers = nn.ModuleList([EncoderLayer() for _ in range(N)])
# 实例化 N 个解码器层
self.decoder_layers = nn.ModuleList([DecoderLayer() for _ in range(N)])
# ...

def forward(self, src, tgt, ...):
# 1. 对 src (源句子) 进行词嵌入和位置编码
# 2. 将结果送入编码器栈
# 3. 对 tgt (目标句子) 进行词嵌入和位置编码
# 4. 将编码器输出和处理过的 tgt 送入解码器栈
# 5. 最终通过一个线性层和 Softmax 得到预测的下一个单词的概率
# ...

对于初学者,最好的学习方式是阅读并运行一份带有详细注释的实现代码,例如 PyTorch 官方的 Transformer 教程


六、总结与展望

Transformer 的成功关键:

  • 自注意力机制:实现了对全局上下文的有效建模。
  • 并行计算能力:极大地提高了训练和推理效率,使得处理海量数据和构建超大规模模型成为可能。

正是因为这两个特点,Transformer 不仅仅局限于机器翻译,它已经成为现代 AI 的基石。

  • BERT 系列模型使用 Transformer 的编码器进行语言理解。
  • GPT 系列模型(包括 ChatGPT)使用 Transformer 的解码器进行文本生成。
  • DALL-E, Midjourney 等图像生成模型,也将图像块(patches)视为一种“单词”,用 Transformer 来理解和生成图片。

希望这份介绍能帮你打开 Transformer 的大门。它初看可能有些复杂,但只要理解了其核心的“全局视野”和“自注意力”思想,其他部分就会变得顺理成章。


更新网站ssl证书导致java httpclient请求出错的问题

错误

httpClient.executeMethod(method)出错如下:

1
2
3
4
5
javax.net.ssl.SSLHandshakeException: 
sun.security.validator.ValidatorException:
PKIX path building failed:
sun.security.provider.certpath.SunCertPathBuilderException:
unable to find valid certification path to requested target

原因

由于Mozilla更新了其根证书信任策略,即对于全球所有CA的可信根证书生成后最少15年更换一次,超过时间的可信根将会逐步被Mozilla停止信任,因此Digicert的部分老根证书将会在2023年07月01日左右逐步升级为Digicert Global Root G2。

也就是说新证书的根证书变了。我的老java应用的jre带的security/cacerts没有自带Digicert Global Root G2

解决方法

从浏览器导出”Digicert Global Root G2.crt”,然后导入到用到的java jre中:

1
keytool -importcert -file '/pathto/DigiCert Global Root G2.crt' -alias mykey1 -keystore '/pathto/jre/lib/security/cacerts' -storepass changeit

然后重启java应用即可。

freessl.cn 申请的免费证书也有类似的问题

只是根证书改为:TrustAsia ECC DV TLS CA G3

参考

js实现羽毛球比赛中的八人转和多人转

羽毛球爱好者熟知的八人转,就是N个人轮转进行双打比赛,大家的机会均等、比较公平。一轮打下来的输赢积分较能客观反映实际。

八人转基本规则就是:每人和其他人都组队搭档一次,每队至少上场一次,各人轮换上场,每人上场次数要相同。

编程实现N人转对阵编排的算法思路:
1、找出所有的组队,即N个人中取2人的组合C
2、所有组队两两对阵比赛,即C组队中取2对的组合,但要去除人员冲突的对阵(自己不能和自己打),剩下的对阵仍然可能太多,人多了不可能都打一遍
3、为了公平轮换,只要找上场最少的人和队优先打即可
4、每队都上场一次后,每人上场次数一样时就可以结束轮转,也可以继续打更多局,但总要在每人上场次数一样时结束。

按照上面的思路,用js实现的算法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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
//组合可能的搭档/组队
function combo(N) {
let pairs = []
for (let a = 1; a <= N; a++) {//从1开始,好看一些
for (let b = a + 1; b <= N; b++) {
pairs.push([a, b, 0])//a和b搭档:[a, b, 上场次数]
}
}
return pairs
}
function isConflict(A, B) {//判断两个组队人员是否冲突
return A == B || A[0] == B[0] || A[0] == B[1] || A[1] == B[0] || A[1] == B[1]
}

//匹配可能的对局
function match(pairs) {
let vs = [], P = pairs.length
for (let i = 0; i < P; i++) {
let A = pairs[i]
for (let j = i + 1; j < P; j++) {
let B = pairs[j]
if (isConflict(A, B)) continue//跳过冲突的对局
vs.push([A, B])//A队和B队对局/对打v:[A,B]
}
}
return vs
}

//N人转,至少打M局的对阵编排
//公平轮转:每人和其他人都搭档一次,每队至少上场一次,各人轮换上场,每人上场次数要相同
function main(N, M) {
if (N < 4) return console.error(`人数不足(${N}<4)`)
if (N > 20) return console.error(`人数太多啦!`)
let plays = new Array(N).fill(0)//记录玩家上场次数
function tires(v) {//计算候选对局的疲劳度
let A = v[0], B = v[1]
return (A[2] + 1) * (plays[A[0] - 1] + plays[A[1] - 1]) + (plays[B[0] - 1] + plays[B[1] - 1]) * (B[2] + 1)
}
let pairs = combo(N)//获取可能的组队
let allvs = match(pairs)//获取所有的对局
let vs = []//对阵上场次序数组
console.log(`${N}人,${pairs.length}队,${M>0?('打'+M+'局'):''}对阵:`)
for (let i = 0; allvs.length > 0 ; i++) {
let v = allvs.shift()//取第一对上场
let A = v[0], B = v[1]//更新对阵参与者
A[2]++, plays[A[0] - 1]++, plays[A[1] - 1]++
B[2]++, plays[B[0] - 1]++, plays[B[1] - 1]++
console.log(`${i + 1}. (${A[0]},${A[1]}) x (${B[0]},${B[1]})`)
vs.push(v)
if (!M || i+1 >= M){
if (pairs.every(p => p[2]>0)){//每队都上场过
if (plays.every(c => c==plays[0])) break//每人上场次数都一样
}
}
allvs = allvs.sort((a, b) => tires(a) - tires(b))//把最少上场的排到第一位
}
console.log(`每人上场${plays[0]}次.\n`)
return vs
}

// 试一下
main(3),main(4),main(5)
main(6),main(6, 15)
main(7),main(7, 21)
main(8),main(8, 16),main(8, 18)
main(9),main(9, 27)
main(10),main(100)

改写成一个类:

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
//N人转对阵编排
class CMatch {
#N //人数
#plays //每人上场次数
#pairs //所有搭档组合
#allvs //所有可能对局

constructor(N) {
this.#N = N;
}

play(M) {//至少M局的对阵编排,不指定M则按最少局数编排
const N = this.#N
if (N < 4) return console.error(`人数不足(${N}<4)`)
if (N > 20) return console.error(`人数太多啦!`)
let plays = this.#genPlays(true)//每人上场次数
let pairs = this.#genPairs(true)//获取可能的组队
let allvs = this.#genAllvs(true)//获取所有的对局
let vs = []//对阵上场次序数组
console.log(`${N}人,${pairs.length}队,${M>0?('打'+M+'局'):''}对阵:`)
for (let i = 0; allvs.length > 0 ; i++) {
let v = allvs.shift()//取第一对上场
let A = v[0], B = v[1]//更新对阵参与者
this.#updatePlay(A)
this.#updatePlay(B)
vs.push(v)
console.log(`${i + 1}. (${A[0]},${A[1]}) x (${B[0]},${B[1]})`)
if (!M || i+1 >= M){
if (pairs.every(p => p[2]>0)){//每队都上场过
if (plays.every(c => c==plays[0])) break//每人上场次数都一样
}
}
allvs = allvs.sort((a, b) => this.#calcTires(a) - this.#calcTires(b))//把最少上场的排到第一位
}
console.log(`每人上场${plays[0]}次。\n`)
return this
}

#genPlays(reset) {//生成每人上场次数数组
if (!this.#plays){
this.#plays = new Array(this.#N).fill(0)
}else if (reset){
this.#plays.fill(0)
}
return this.#plays
}

#genPairs(reset) {//可能的搭档组合
const N = this.#N
if (!this.#pairs){
this.#pairs = []
for (let a = 1; a <= N; a++) {//从1开始,好看一些
for (let b = a + 1; b <= N; b++) {
this.#pairs.push([a, b, 0])//a和b搭档:[a, b, 上场次数]
}
}
}else if (reset){
this.#pairs.forEach(p => p[2] = 0)//重置上场次数
}
return this.#pairs
}

#genAllvs(reset) {//可能的对局
if (!this.#allvs || reset){
this.#allvs = []
let pairs = this.#pairs, P = pairs.length
for (let i = 0; i < P; i++) {
let A = pairs[i]
for (let j = i + 1; j < P; j++) {
let B = pairs[j]
if (CMatch.#isConflict(A, B)) continue//跳过冲突的对局
this.#allvs.push([A, B, 0])//A队和B队对局/对打v:[A,B,上场次数,比?分?]
}
}
}
return this.#allvs
}

#updatePlay(A) {//累加A队上场次数
this.#plays[A[0]-1]++, this.#plays[A[1] - 1]++, A[2]++
}

#calcTires(v) {//计算候选对局的疲劳度
let A = v[0], B = v[1], plays = this.#plays
return (A[2] + 1) * (plays[A[0] - 1] + plays[A[1] - 1]) + (plays[B[0] - 1] + plays[B[1] - 1]) * (B[2] + 1)
}

static #isConflict(A, B) {//判断两个组队人员对局是否冲突
return A == B || A[0] == B[0] || A[0] == B[1] || A[1] == B[0] || A[1] == B[1]
}

}

// 测试
new CMatch(4).play()
new CMatch(5).play()
new CMatch(6).play()
new CMatch(7).play().play(21)
new CMatch(8).play().play(16).play(18)
new CMatch(9).play().play(27)
new CMatch(10).play()

阿里云专用网络ECS安装ftp终极解决方案


安装

1
2
sudo yum install vsftpd
sudo systemctl enable vsftpd

添加用户

1
sudo adduser ftpuser/ftppassword

编辑配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
sudo vim /etc/vsftpd/vsftpd.conf
listen=YES
listen_ipv6=NO
#listen_port=21
pasv_enable=YES #被动模式
pasv_min_port=10000
pasv_max_port=10100
pasv_address=54.53.52.51 #专用网络ip变成了映射,本机无法知道自己的真实ip地址。所以必须告知,本机,你的ip地址是什么。 https://yq.aliyun.com/articles/608725
pasv_addr_resolve=yes
anonymous_enable=NO
chroot_local_user=YES
#默认chroot_list_file=/etc/vsftpd/chroot_list没有要创建,为空即可
allow_writeable_chroot=YES
local_root=/home/ftpuser
userlist_enable=YES
userlist_deny=NO
#当userlist_enable=YES时,userlist_deny=NO时:user_list是一个白名单,里面只添加ftpuser,其余默认的去掉
#ftpusers不受任何配制项的影响,它总是有效,它是一个黑名单!https://blog.csdn.net/bluishglc/article/details/42273197

启动

1
sudo systemctl restart vsftpd

开放端口

1
防火墙和安全组开放端口:20-21,10000-10100

亲测按此配置之后,ftp主动和被动模式都正常传输,filezilla等ftp工具可以正常使用,curl、wget/wput等命令行工具也能用。

使用Spring Security实现OAuth2、JWT、SSO等(笔记)

参考资料

常见问题

一、SESSION冲突

“org.springframework.security.authentication.BadCredentialsException: Could not obtain access token, Caused by: org.springframework.security.oauth2.common.exceptions.InvalidRequestException: Possible CSRF detected - state parameter was required but no state could be found”

错误原因

在同一个域名下授权服务器和资源服务器的Cookie名都是JSESSIONID,导致在跳转到授权服务器后将资源服务器的Cookie覆盖了,再次跳转回去时授权服务器的Cookie对资源服务器无效,再次跳转到登录页面,该动作一直重复,导致授权失败。StackOverflow

解决办法

  1. 为授权服务器和资源服务器配置不同的 Cookie 名称: server.servlet.session.cookie.name=AUTH_SESSIONID
  2. 修改应用的 ContextPath:server.servlet.context-path=/auth

Spring boot集成kaptcha图片验证码

kaptcha 是一个图像验证码生成和验证工具,有许多可配置项,可以简单快捷的生成各式各样的验证码,使用起来也很简便。

pom.xml添加依赖

1
2
3
4
5
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>kaptcha-spring-boot-starter</artifactId>
<version>1.1.0</version>
</dependency>

application.yaml 添加典型配置

不加用默认也可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
kaptcha:
height: 50
width: 200
content:
length: 4
source: abcdefghjklmnopqrstuvwxyz23456789
space: 2
font:
color: black
name: Arial
size: 40
background-color:
from: lightGray
to: white
border:
enabled: true
color: black
thickness: 1

在controller里使用示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.baomidou.kaptcha.Kaptcha;

@RestController
@RequestMapping("/code")
public class CodeController {
@Autowired
private Kaptcha kaptcha;

@RequestMapping("/image")
void renderImage() {
String code = kaptcha.render();
System.out.println(code);
}

@RequestMapping("/valid")
boolean validImage(@RequestParam String code) {
return kaptcha.validate(code);
}
}

测试

  • 前端访问/code/image即显示验证码图片
  • 前端访问/code/valid?code=xxxx即会返回true表示通过验证,出错表示code错误。

Spring security with thymeleaf

记录spring boot项目使用spring security的核心配置和相关组件。要点:

  1. 支持自定义页面登录
  2. 支持AJAX登录/登出
  3. 支持RBAC权限控制
  4. 支持增加多种认证方式
  5. 支持集群部署(会话共享redis存储)
  6. 支持SessionId放在Header的X-Auth-Token里

项目依赖 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
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity5</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>

相关参考:关于redis 关于thymeleaf

Security配置类 SecurityConfig.java

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
import java.util.Arrays;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.AccessDecisionManager;
import org.springframework.security.access.AccessDecisionVoter;
import org.springframework.security.access.vote.AuthenticatedVoter;
import org.springframework.security.access.vote.UnanimousBased;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.web.access.expression.WebExpressionVoter;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import org.springframework.session.web.http.HttpSessionIdResolver;

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private AuthProviderUsernamePassword authProviderUsernamePassword;
@Autowired
private AuthSuccessHandler authSuccessHandler;
@Autowired
private AuthFailureHandler authFailureHandler;
@Autowired
private ExitSuccessHandler exitSuccessHandler;

@Bean
protected AuthenticationFailureHandler authenticationFailureHandler() {
authFailureHandler.setDefaultFailureUrl("/login?error");
return authFailureHandler;
}

@Bean
protected LogoutSuccessHandler logoutSuccessHandler() {
exitSuccessHandler.setDefaultTargetUrl("/login?logout");
return exitSuccessHandler;
}

private static String[] INGORE_URLS = {"/login", "/error",};

@Override
public void configure(WebSecurity webSecurity) {
webSecurity.ignoring().antMatchers("/static/**");//忽略静态资源
webSecurity.ignoring().antMatchers("/favicon.ico");
}

@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
httpSecurity
.authorizeRequests()
.antMatchers(INGORE_URLS).permitAll()
.anyRequest().authenticated()
.accessDecisionManager(accessDecisionManager())//如果不需要权限验证,去掉这句即可
.and()
.formLogin()
.successHandler(authSuccessHandler)
.failureHandler(authFailureHandler)
.loginPage("/login")//.permitAll()
.and()
.logout()
.logoutSuccessHandler(logoutSuccessHandler())//.permitAll()
//.and().rememberMe()
.and().csrf().disable();
}

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.authenticationProvider(authProviderUsernamePassword);
//auth.authenticationProvider(authProvider2);可以增加多个认证方式,比如码验证等
}

@Bean
protected AccessDecisionManager accessDecisionManager() {
List<AccessDecisionVoter<? extends Object>> decisionVoters = Arrays.asList(
new WebExpressionVoter(),
authDecisionVoter(),//new RoleVoter(),
new AuthenticatedVoter());
return new UnanimousBased(decisionVoters);
}

@Bean
protected AuthDecisionVoter authDecisionVoter() {
return new AuthDecisionVoter();
}

@Bean
public HttpSessionIdResolver httpSessionIdResolver() {
return new HeaderCookieHttpSessionIdResolver();
}
}

登录认证类 AuthProviderUsernamePassword.java

AuthenticationProvider提供用户认证的处理方法。如果有多种认证方式,可以实现多个类一并添加到AuthenticationManagerBuilder里即可。

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 org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.stereotype.Component;

@Component
public class AuthProviderUsernamePassword implements AuthenticationProvider {
@Autowired
AuthUserService authUserService;

@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username = authentication.getName();
String password = authentication.getCredentials().toString();
AuthUser userDetails = authUserService.loadUserByUsername(username);
if(userDetails == null){
throw new BadCredentialsException("账号或密码错误");
}
if (!authUserService.checkPassword(userDetails, password)) {
throw new BadCredentialsException("账号或密码不正确");
}
//认证校验通过后,封装UsernamePasswordAuthenticationToken返回
return new UsernamePasswordAuthenticationToken(userDetails, password, authUserService.fillUserAuthorities(userDetails));
}

@Override
public boolean supports(Class<?> authentication) {
return true;
}
}

登录成功处理 AuthSuccessHandler.java

配置于formLogin().successHandler(),可选。

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
import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
import org.springframework.security.web.savedrequest.HttpSessionRequestCache;
import org.springframework.security.web.savedrequest.RequestCache;
import org.springframework.security.web.savedrequest.SavedRequest;
import org.springframework.stereotype.Component;

@Component
public class AuthSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
private RequestCache requestCache = new HttpSessionRequestCache();

@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws ServletException, IOException {
//登录成功处理,比如记录登录日志
String ip = request.getRemoteAddr();
String targetUrl = "";
SavedRequest savedRequest = requestCache.getRequest(request, response);
if (savedRequest != null) {
targetUrl = savedRequest.getRedirectUrl();
}
AuthUser aUser = (AuthUser) authentication.getPrincipal();
System.out.printf("User %s login, ip: %s, url: ", aUser.getUsername(), ip, targetUrl);

if (WebUtils.isAjaxReq(request)) {//ajax登录
response.sendError(200, "success");
return;
}
super.onAuthenticationSuccess(request, response, authentication);
}
}

登录成功处理 AuthFailureHandler.java

配置于formLogin().failureHandler(),可选。

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 java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
import org.springframework.security.web.savedrequest.HttpSessionRequestCache;
import org.springframework.security.web.savedrequest.RequestCache;
import org.springframework.security.web.savedrequest.SavedRequest;
import org.springframework.stereotype.Component;

@Component
public class AuthFailureHandler extends SimpleUrlAuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) throws IOException, ServletException {
String uaSummary = WebUtils.getUserAgentSummary(request);
String ip = request.getRemoteAddr();
String username = request.getParameter("username");
System.out.printf("User %s login failed, ip: %s, ua: %s", username, ip, uaSummary);
super.saveException(request, exception);
if (WebUtils.isAjaxReq(request)) {//ajax登录
//为什么用sendError会导致302重定向到login页面?
//--When you invoke sendError it will dispatch the request to /error (it the error handling code registered by Spring Boot. However, Spring Security will intercept /error and see that you are not authenticated and thus redirect you to a log in form.
response.sendError(403, exception.getMessage());
return;
}
response.sendRedirect("login?error");
}
}

登出成功处理 ExitSuccessHandler.java

配置于logout().logoutSuccessHandler(),可选。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.logout.SimpleUrlLogoutSuccessHandler;
import org.springframework.stereotype.Component;

@Component
public class ExitSuccessHandler extends SimpleUrlLogoutSuccessHandler {
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
throws IOException, ServletException {
if (WebUtils.isAjaxReq(request)) {//ajax登录
response.sendError(200, "success");
return;
}
super.onLogoutSuccess(request, response, authentication);
}
}

解析SessionId的类 HeaderCookieHttpSessionIdResolver.java

增加优先从Header里找X-Auth-Token作为SessionId,以适应不支持Cookie的情况。
这个类就是把CookieHttpSessionIdResolver和HeaderHttpSessionIdResolver柔和在一起而已。
对应配置@Bean httpSessionIdResolver。

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
import java.util.List;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.session.web.http.CookieHttpSessionIdResolver;
import org.springframework.session.web.http.HeaderHttpSessionIdResolver;
import org.springframework.session.web.http.HttpSessionIdResolver;

public class HeaderCookieHttpSessionIdResolver implements HttpSessionIdResolver {
protected HeaderHttpSessionIdResolver headerResolver = HeaderHttpSessionIdResolver.xAuthToken();
protected CookieHttpSessionIdResolver cookieResolver = new CookieHttpSessionIdResolver();

@Override
public List<String> resolveSessionIds(HttpServletRequest request) {
List<String> sessionIds = headerResolver.resolveSessionIds(request);
if (sessionIds.isEmpty()) {
sessionIds = cookieResolver.resolveSessionIds(request);
}
return sessionIds;
}

@Override
public void setSessionId(HttpServletRequest request, HttpServletResponse response, String sessionId) {
headerResolver.setSessionId(request, response, sessionId);
cookieResolver.setSessionId(request, response, sessionId);
}

@Override
public void expireSession(HttpServletRequest request, HttpServletResponse response) {
headerResolver.expireSession(request, response);
cookieResolver.expireSession(request, response);
}
}

认证用户类 AuthUser.java

用户实体类,实现UserDetails接口。

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
import java.io.Serializable;
import java.util.Collection;
import java.util.List;
import javax.persistence.Id;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.util.StringUtils;
import lombok.Data;

@Data
public class AuthUser implements UserDetails, Serializable {
private static final long serialVersionUID = -1572872798317304041L;

@Id
private Long id;
private String username;
private String password;

private Collection<? extends GrantedAuthority> authorities;

public Collection<? extends GrantedAuthority> fillPerms(List<String> perms) {
String authorityString = StringUtils.collectionToCommaDelimitedString(perms);
authorities = AuthorityUtils.commaSeparatedStringToAuthorityList(authorityString);
return authorities;
}

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}

@Override
public boolean isAccountNonExpired() {
return true;
}

@Override
public boolean isAccountNonLocked() {
return true;
}

@Override
public boolean isCredentialsNonExpired() {
return true;
}

@Override
public boolean isEnabled() {
return true;
}
}

认证用户服务类 AuthUserService.java

提供根据用户名获取用户的方法loadUserByUsername();提供用户的权限fillUserAuthorities()。

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
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import org.joda.time.LocalDateTime;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Service
public class AuthUserService implements UserDetailsService {
@Override
public AuthUser loadUserByUsername(String username) throws UsernameNotFoundException {
//读取用户,一般是从数据库读取,这里随便new一个
AuthUser user = new AuthUser();// userDao.findByUsername(username);
user.setId(System.currentTimeMillis());
user.setUsername(username);
user.setPassword(username);
return user;
}

public boolean checkPassword(AuthUser user, String pwd) {
//判断用户密码,这里简单判断相等
if (pwd != null && pwd.equals(user.getPassword())) {
return true;
}
return false;
}

public Collection<? extends GrantedAuthority> fillUserAuthorities(AuthUser aUser) {
//获取用户权限,一般从数据库读取,并缓存。这里随便拼凑
List<String> perms = new ArrayList<>(); //permDao.findPermByUserId(aUser.getId());
LocalDateTime now = LocalDateTime.now();
perms.add("P"+now.getHourOfDay());
perms.add("P"+now.getMinuteOfHour());
perms.add("P"+now.getSecondOfMinute());
return aUser.fillPerms(perms);
}
}

模拟用户示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"id": 1598515192490,
"username": "test",
"password": "test",
"authorities": [{
"authority": "P15"
}, {
"authority": "P59"
}, {
"authority": "P52"
}
]
}

认证入口 AuthControll.java

这里提供loginPage配置的路径”/login”。如果暂不想自定义登录界面,去掉loginPage配置即可。

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 org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

@Controller
public class AuthController {
@RequestMapping("/login")//登录入口
String login(String username, Model model) {
model.addAttribute("username", username);
return "login";
}

@RequestMapping("/")//主页
@ResponseBody
Object home(@AuthenticationPrincipal AuthUser currentUser) {
return currentUser;
}

@RequestMapping("/{path}")//测试用
@ResponseBody
Object url1(@PathVariable String path) {
if (path.contains("0")) {//模拟错误
path = String.valueOf(1/0);
}
return path;
}
}

权限验证类 AuthDecisionVoter.java

配置AccessDecisionManager用于自定义权限验证投票器。验证的前提是获取待访问资源(url)相关的权限(getPermissionsByUrl)。验证的方法是,看用户所拥有的权限是否能够匹配url的权限。

Spring security另一种常用的权限控制方式是配置@EnableGlobalMethodSecurity(prePostEnabled = true),在方法上使用@PreAuthorize(“hasPermission(‘PXX’)”)。但用这种方法注解的url,不支持用在thymeleaf模板的sec:authorize-url中。

ps1.thymeleaf 提供了前端判断权限的扩展,参见 thymeleaf-extras-springsecurity & thymeleaf sec:标签的使用

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
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import org.springframework.security.access.AccessDecisionVoter;
import org.springframework.security.access.ConfigAttribute;
import org.springframework.security.access.SecurityConfig;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.web.FilterInvocation;
import org.springframework.util.StringUtils;

public class RbacDecisionVoter implements AccessDecisionVoter<Object> {
static final String permitAll = "permitAll";

@Override
public boolean supports(ConfigAttribute attribute) {
return true;
}

@Override
public boolean supports(Class<?> clazz) {
return true;
}

@Override
public int vote(Authentication authentication, Object object, Collection<ConfigAttribute> attributes) {
if (authentication == null) {
return ACCESS_DENIED;
}

if (attributes != null) {
for (ConfigAttribute attribute : attributes) {
if (permitAll.equals(attribute.toString())) {// skip permitAll
return ACCESS_ABSTAIN;
}
}
}

String requestUrl = ((FilterInvocation) object).getRequestUrl();// 当前请求的URL
Collection<ConfigAttribute> urlPerms = getPermissionsByUrl(requestUrl);// 能访问URL的权限
if (urlPerms == null || urlPerms.isEmpty()) {
return ACCESS_ABSTAIN;
}

int result = ACCESS_ABSTAIN;
Collection<? extends GrantedAuthority> userAuthorities = authentication.getAuthorities(); // 当前用户的权限
for (ConfigAttribute attribute : urlPerms) {
String urlPerm = attribute.getAttribute();
if (StringUtils.isEmpty(urlPerm)) {
continue;
}

result = ACCESS_DENIED;
// Attempt to find a matching granted authority
for (GrantedAuthority authority : userAuthorities) {
if (urlPerm.equals(authority.getAuthority())) {
return ACCESS_GRANTED;
}
}
}
return result;
}

Collection<ConfigAttribute> getPermissionsByUrl(String url) {
// 获取url的访问权限,一般从数据库读取,并缓存。这里随便拼凑
if ("/".equals(url)) {
return null;//根路径不限权
}
String n1 = url.substring(url.length()-1);
String n2 = url.substring(url.length()-2);
return SecurityConfig.createList("P"+n1, "P"+n2);
}
}

自定义登录界面 login.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
<!DOCTYPE html>
<html lang="zh" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
<title>登录</title>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, shrink-to-fit=no"/>
<link rel="stylesheet" href="//cdn.bootcdn.net/ajax/libs/twitter-bootstrap/4.3.1/css/bootstrap.min.css"/>
<style type="text/css">
body{padding-top:40px; padding-bottom:40px; background-color:#eee;}
.form-signin{max-width:330px; padding:15px; margin:0 auto;}
</style>
</head>
<body>
<div id="root" class="container">
<form class="form-signin" method="post" th:action="@{/login}">
<h2 class="form-signin-heading">请登录</h2>
<div th:if="${param.logout}" class="alert alert-success" role="alert"><span>您已退出登录</span></div>
<div th:if="${param.error}" class="alert alert-danger" role="alert"><span th:utext="${session['SPRING_SECURITY_LAST_EXCEPTION'].message}">密码错误</span></div>
<p>
<label for="username" class="sr-only">用户账号:</label>
<input type="text" id="username" name="username" class="form-control" placeholder="请输入账号" required autofocus>
</p>
<p>
<label for="password" class="sr-only">用户密码:</label>
<input type="password" name="password" class="form-control" placeholder="请输入密码" required>
</p>
<button class="btn btn-lg btn-primary btn-block" type="submit">确定</button>
</form>
</div>
</body>
</html>

自定义错误信息 CustomErrorAttributes.java

403-没有权限、404-找不到页面等所有错误和异常,都会被SpringBoot默认的BasicErrorController处理。如果有需要,可定制ErrorAttributes。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import java.util.Map;
import org.springframework.boot.web.servlet.error.DefaultErrorAttributes;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.WebRequest;

@Component
public class CustomErrorAttributes extends DefaultErrorAttributes {
@Override
public Map<String, Object> getErrorAttributes(WebRequest webRequest, boolean includeStackTrace) {
Map<String, Object> errorAttributes = super.getErrorAttributes(webRequest, includeStackTrace);
errorAttributes.put("code", errorAttributes.getOrDefault("status", 0));//自定义code属性
Throwable error = super.getError(webRequest);
if (error != null && error.getMessage() != null) {
String message = (String)errorAttributes.getOrDefault("message", "");
if (!message.equals(error.getMessage())) {
errorAttributes.put("message", message+" "+error.getMessage());//增强message属性
}
}
return errorAttributes;
}
}

非浏览器访问(produces=”text/html”)出错时,返回json数据,示例:

1
2
3
4
5
6
7
8
{
"timestamp": "2020-08-27T09:05:11.178+0000",
"status": 500,
"error": "Internal Server Error",
"message": "/ by zero",
"path": "/demo/015",
"code": 500
}

浏览器访问(produces=”text/html”)出错时,返回html页面。

自定义错误页面 error/4xx.html

SpringBoot默认的Whitelabel Error Page需要定制,只要把错误页面模板放在error路径下即可。模板中可使用上述ErrorAttributes中的字段。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!DOCTYPE html>
<html lang="zh" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, shrink-to-fit=no"/>
<link rel="stylesheet" href="//cdn.bootcdn.net/ajax/libs/twitter-bootstrap/4.3.1/css/bootstrap.min.css"/>
</head>
<body>
<div id="root" class="container">
<div class="main">
<br/><h2 class="text-center"><span th:text="${status}">404</span>-<span th:text="${error}">Not Found</span></h2><br/>
<p class="text-center" th:if="${message}"><span th:text="${message}"></span></p>
<p class="text-center" th:if="${exception}"><span th:text="${exception}"></span></p>
<p class="text-center"><a class="btn btn-primary" th:href="@{'/'}">Home</a></p>
</div>
</div>
</body>
</html>

自定义错误页面 error/5xx.html

类似5xx.html,略。

Spring boot mail with thymeleaf template

发送邮件是网站必备的功能,如注册验证,忘记密码或者是给用户发送通知信息。早期我们使用JavaMail相关api来写发送邮件。后来spring推出了JavaMailSender简化了邮件发送的过程,再之后springboot对此进行了封装。

复杂的邮件内容一般使用html,thymeleaf模板可以简化html的生成。

pom.xml

1
2
3
4
5
6
7
8
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>

application.propertis

1
2
3
4
5
6
7
8
9
10
11
12
spring.mail.host=smtp.exmail.qq.com
spring.mail.username=noreply@qyqq.com
spring.mail.password=password123456
spring.mail.default-encoding=UTF-8
#spring.mail.properties.mail.smtp.starttls.enable=true
#spring.mail.properties.mail.smtp.starttls.required=true
#spring.mail.properties.mail.smtp.socketFactory.port=465
#spring.mail.properties.mail.smtp.socketFactory.class=javax.net.ssl.SSLSocketFactory
#spring.mail.properties.mail.smtp.ssl.trust=smtp.exmail.qq.com
#spring.mail.properties.mail.smtp.connectiontimeout=30000
#spring.mail.properties.mail.smtp.timeout=30000
#spring.mail.properties.mail.smtp.writetimeout=20000

MailService.java

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
import javax.mail.MessagingException;
import javax.mail.internet.MimeMessage;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;

@Service
public class MailService {
@Value("${spring.mail.username}")//from address must be same as authorization user
String mailFrom;

@Autowired
JavaMailSender mailSender;

public void sendHtml(String mailTo, String subject, String html) throws MessagingException{
MimeMessage mime = mailSender.createMimeMessage();
MimeMessageHelper mail = new MimeMessageHelper(mime);
mail.setFrom(mailFrom);
mail.setTo(mailTo.split(";"));//支持多个接收者
mail.setSubject(subject);
mail.setText(html, true);
mailSender.send(mime);
}
}

NotifyService.java

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
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;

@Service
public class NotifyService {
private static final String MAIL_TPL_NOTIFY = "mail/notify";//邮件模板.html
@Autowired
private MailService mailService;
@Autowired
private TemplateEngine templateEngine;

public void sendNotify(String mailTo, String subject, Context context) {
new Thread(() -> {//开启线程异步发送邮件
try {
String html = templateEngine.process(MAIL_TPL_NOTIFY, context);
mailService.sendHtml(mailTo, subject, html);
//TODO: 发送成功
} catch (Exception e) {
//TODO: 发送失败
e.printStackTrace();
}
}).start();
}
}