1. 存储器层次(The Memory Hierarchy)

1.1 现代系统中的存储器

image-20241107185003120

其中包括L1、L2、L3和DRAM

1.2 存储器的局限

理想存储器的需求如下:

  • 零延迟
  • 容量无限
  • 零成本
  • 带宽无限
  • 零功耗

但理想存储器的需求彼此冲突:

  • 容量更大的存储器意味着更大的延迟:需要花更长的时间来确定数据所在位置
  • 更快的存储器价格更贵
    • 存储器工艺:SRAM vs. DRAM vs. Disk vs. Tape
  • 更高带宽的存储器更贵
    • 需要更多的banks,更多的端口,更快的工作频率,更快的工艺

1.3 为什么使用存储器分层结构

我们想要存储器又快,容量又大,这并不能通过单层存储器实现。

Idea:
多层次存储(离处理器越远的存储器容量越大,速度越慢)并且确保处理器所需的大部分数据能够保持在较快的层次中。

Memory System:

image-20241107190608552
image-20241107190838875

只要拥有良好的
引用局部性
,存储器层次结构可以使得存储器又快,容量又大。

1.4 存储器分层结构

image-20241107191126551

从小到大,从快到慢依次包括:
寄存器(Register File)

缓存(Cache)

主存储器(Main Memory)

硬盘(Hard Disk)

1.5 Locality(局部性)

  • 通过一个人不久的过去能够预测其不远的将来

  • 时间局部性(Temporal Locality):
    如果你刚好干了某事,那么你极有可能很快再干同样的事情。


    • 你今天在这间教室,那么将有很大可能你将会有规律地一次又一次出现在这个教室。
  • 空间局部性(Spatial Locality):
    如果你做了某件事情,你很可能会做类似/相关的事情(在空间上)。


    • 每次我在这间教室找到你时,你可能都和同样的人坐得很近,或者坐在很近的座位上。

1.6 内存局部性

  • 一个”典型“程序在内存引用方面有很多局部性

    • loop
      组成的程序
  • 时间:
    程序往往会在一小段时间内多次引用同一内存位置
  • 空间:
    程序倾向于在一个时间窗口内引用附近的内存位置
    • 指令内存引用 → 通常是顺序/流式(顺序执行指令)
    • 对数组/向量的引用 → 通常是流式/串式的

1.7 缓存基础

1.7.1 利用时间局部性

  • Idea:
    将最近访问过的数据存储在自动管理的快速内存(称为缓存(cache))
  • 预期结果:
    相同的存储器地址将很快被访问

1.7.2 利用空间局部性

  • Idea:
    在自动管理的快速存储器中,将数据存储在与最近访问的地址相邻的地址中
    • 逻辑上将内存划分为大小相等的区块
    • 将访问的区块整个提取到缓存中
  • 预期结果:
    与当前访问内存位置相邻的内存位置将很快被访问

2. 缓存分层结构

2.1 流水线设计中的缓存

  • 缓存需要与流水线紧密集成


    • 理想情况下希望能在1个周期内访问数据,这样依赖load的操作才不会停滞
  • 然而高频流水线 → 不能让 cache 太大


    • 但是,我们需要大的 cache 和流水线设计
  • Idea:缓存层次结构

image-20241107200809335

其中,一级缓存(L1)设计决策主要由处理器的频率决定,并且L1非常小;二级缓存(L2)比L1大,但访问速度也比L1慢。

2.2 最基本的Cache结构

image-20241107203143500

其中包括一个标签存储(Tag Store)和一个数据存储(Data Store)。

当处理器需要查询缓存时,它需要向Tag Store发送一个地址。

如果Tag Store显示该地址存在缓存中,那么你可以从Data Store中获取数据。

如果Tag Store显示该地址不在缓存中,那么你必须去主内存取数据。

2.3 存储器分层结构设计分析

image-20241107204140331

2.3.1 分层延迟分析

  • 程序查询该层,会得到访问数据命中或未命中(Hit/Miss),这意味着数据在该层或不在。

  • 假设给定的存储器分层结构第
    \(i\)
    层具有
    固有访问时间(technology-intrinsic access time)
    \(t_i\)

  • 但实际的
    感知访问时间(perceived access time)
    \(T_i\)

    要比
    \(t_i\)
    长。

  • 除了最外层的层次结构外,在查找给定地址时,有以下几种情况


    • 当你命中”hit“的时候(命中率 hit-rate
      \(h_i\)
      ),访问时间为
      \(t_i\)
    • 当你未命中”miss“的时候(未命中率 miss-rate
      \(m_i\)
      ),访问时间为
      \(t_i + T_{i+1}\)
    • \(h_i + m_i = 1\)
  • 因此
    \(T_i = h_i \cdot t_i + m_i \cdot (t_i + T_{i+1})\)
    ,即
    \(T_i = t_i + m_i \cdot T_{i+1}\)

2.3.2 分层结构设计考虑因素

  • 递归延迟方程


    \[T_i = t_i + m_i \cdot T_{i+1}
    \]

  • 目标:
    在允许的成本范围内实现所需的
    \(T_1\)

  • 希望
    \(T_i \approx t_i\)
    ,虽然这是不可能的

  • 降低未命中率
    \(m_i\)


    • 增加容量
      \(C_i\)
      可降低
      \(m_i\)
      ,但这样会增加固有访问时间
      \(t_i\)
    • 通过更智能的缓存管理(替换预测不需要的内容,预取预测需要的内容)降低
      \(m_i\)
  • 降低下一层级的感知访问时间
    \(T_{i+1}\)


    • 使用更快的层级,但要注意成本的增加
    • 引入中间层次作为妥协

2.3.3 举例:Intel奔腾 4

  • 90nm P4, 3.6 GHz
  • L1 D-cache
    • \(C_1\)
      = 16 kB
    • \(t_1\)
      = 4 cyc int / 9 cycle fp
  • L2 D-cache
    • \(C_2\)
      = 1024 kB
    • \(t_2\)
      = 18 cyc int / 18 cyc fp
  • Main memory
    • \(t_3\)
      = ~ 50ns or 180 cyc

\(T_i = t_i + m_i\cdot T_{i+1}\)

  • if
    \(m_1=0.1, m_2=0.1\)
    • \(T_2=t_2 + m_2T_3 = 18+0.1\times 180 =36cyc\)
    • \(T_1=t_1+m_1T_2=4+0.1\times36=7.6cyc\)
  • if
    \(m_1=0.01, m_2=0.01\)
    • \(T_1=4.2, T_2=19.8\)
  • if
    \(m_1=0.05, m_2=0.01\)
    • \(T_1=5.00, T_2=19.8\)
  • if
    \(m_1=0.01, m_2=0.50\)
    • \(T_1=5.08, T_2=108\)

3. 缓存基础和操作

3.1 缓存

  • 任何 "存储" 已使用(或已生成)数据的结构
    • 以避免重复从头开始复制/获取数据所需的长延时操作
    • e.g. a web cache
  • 在处理器设计中最常见的是:自动管理的内存结构
    • e.g. 在快速 SRAM 中存储最频繁或最近访问过的 DRAM 内存位置,以避免重复支付 DRAM 访问延迟费用
    • 管理策略:
      • What data bring to cache?
      • What data keep in cache?

3.2 基本硬件缓存

  • 我们将从基本的硬件缓存示例和缓存操作开始
  • 然后,我们将正式确定一些缓存的基本概念
  • 最后,我们将研究各种想法和创新,以提高缓存性能

3.2.1 访问缓存

image-20241107225134473

  • 缓存大小:
    缓存的总大小是64字节,由8个8字节的存储单元(8-byte words)组成,每个存储单元可以存储8个字节的数据。

  • 字节地址(Byte Address):
    后3位表示字节的
    Offset
    ,由于每个存储单元为8-byte。

  • 索引位(Index Bits):
    有3位
    index bits
    ,由于有8个缓存行(
    \(2^3 = 8\)
    ),每行可以存储一个8字节的数据块。

直接映射缓存(Direct-mapped Cache)
:每个主存地址直接映射到缓存中的唯一位置。地址中的索引位决定数据在缓存中的位置,所以每个地址都会对应到一个唯一的缓存行。

3.2.2 Tag Array(标签数组)

image-20241107230424748

当主存地址映射到某一缓存行时,系统会将该地址的Tag值与标记数组中的Tag值进行比较,以判断数据是否在缓存中。
如果Tag值匹配,说明数据已在缓存中(命中);如果不匹配,则说明需要从主存中加载该数据(未命中)。

3.2.3 增加 Block 大小

image-20241107231201653

从每行存储8-byte数据变为每行存储32-byte数据:

  • 缓存大小:
    缓存的总大小是256字节,由8个32字节的存储单元(32-byte words)组成,每个存储单元可以存储32个字节的数据。

  • 字节地址(Byte Address):
    后5位表示字节的
    Offset
    ,由于每个存储单元为32-byte。

  • 索引位(Index Bits):
    有3位
    index bits
    ,由于有8个缓存行(
    \(2^3 = 8\)
    ),每行可以存储一个32字节的数据块。

更大的缓存行的影响

  • 较小的标签数组(Tag Array)
    :因为每个缓存行可以容纳更多数据,因此只需较少的行数来覆盖同样的数据范围,减少了Tag数组的大小。
  • 减少缓存缺失
    :由于
    空间局部性(Spatial Locality)
    ,访问某个数据块后,往往会访问附近的数据。较大的缓存行可以一次性加载更多相邻数据,减少后续访问时的缺失率。

3.2.4 关联性(Associativity)

image-20241107232437313

两路组相联缓存(2-way set associative cache)的结构:

  • 标记数组(Tag Array)
    :由于是两路组相联缓存,每个地址会对应多个”路“(Way)。在这个例子中有两条路(Way-1 和 Way-2),因此标记数组包含两组标签,以允许每组缓存行可以存储不同的数据块。
  • 数据数组(Data Array)
    :数据分为两条路(Way-1 和 Way-2),每个路都有自己的数据存储块。相比于直接映射缓存,组相联缓存能够容纳更多的相同索引的数据块,从而减少缓存冲突。
  • 比较(Compare)
    :当进行读取操作时,Tag数组中的两个标签(对应 Way-1 和 Way-2)都需要与请求的地址Tag进行比较,以确定是否有缓存命中。若其中一个路的标签与请求的地址匹配,则表示缓存命中,数据可以从对应的路中读取。

组相联的优势与劣势

  • 优势
    :减少了缓存冲突,因为多个数据块可以映射到同一个缓存位置,解决了直接映射缓存中的冲突问题。
  • 劣势
    :功耗增加,因为需要同时读取多个标签和数据块,以进行匹配和选择。

3.2.5 Example

32 KB 4 路组关联缓存阵列,行大小为 32 字节,假设地址为 40 位

  • How many sets?

    \(32KB / (32Byte\times4)=32KB/128Byte=256set\)

  • How many index bits, offset bits, tag bits?

    index bits(8):
    \(2^8=256set\)

    offset bits(5):
    \(2^{5}=32Byte\)

    tag bits(27):
    \(40-5-8=27bits\)

  • How large is the tag array?

\(27bit \times 256 \times 4=27Kb\)

3.3 缓存 Block/Line

  • Block(line)(缓存块):缓存中的存储单元
    • 内存在逻辑上被划分为缓存块,这些缓存块映射到缓存中的位置
    • 一个固定大小的数据集合,
      包含所请求的字
      ,从主存储器中取出并放入缓存中

image-20241108104328007
image-20241108104437312

  • 对于引用:


    • HIT:
      如果在缓存中,则使用缓存数据,而不是访问内存
    • MISS:
      如果不在缓存中,则将数据块引入缓存
      • 可能需要将其他东西踢出缓存才能做到这一点 (替换
  • 一些重要的缓存设计决策


    • 放置:在缓存中何处以及如何放置/查找/索引块?
    • 替换:删除/执行/替换哪些数据以在缓存中腾出空间?
    • 管理粒度:大块还是小块?
    • 写入策略:如何处理写入?Write-back、Write-through
    • 指令/数据:我们是否将它们分开处理?

3.4 缓存 Miss 的类型

  • 强制未命中:
    首次访问内存 word 时发生——无限缓存的未命中次数
  • 容量未命中:
    发生这种情况是因为程序在重新接触同一 word 之前接触了许多其他单词——完全关联缓存的缺失量
  • 冲突未命中:
    由于两个 word 映射到高速缓存中的相同位置而发生——从完全关联高速缓存到直接映射高速缓存时产生的缺失

附注:
全关联缓存的未命中次数会比相同大小的直接映射缓存多吗?会,增加了冲突未命中

3.5 降低 Miss Rate

  • 增加缓存块大小
    :增大缓存块的大小可以减少强制性未命中(即数据首次被访问时的未命中),并且在存在空间局部性时,可以降低未命中的惩罚(因为可以一次性预取更多相关数据)。但增大块大小也带来一些负面影响,比如:


    • 不同缓存层之间的流量增加

    • 可能导致空间浪费(当只需访问其中一小部分数据时)

    • 可能增加冲突未命中(因为较大的块会占用更多缓存空间,导致其他数据无法缓存)

  • 增大缓存容量
    :更大的缓存可以减少容量未命中和冲突未命中,因为可以存储更多的数据。然而,增大缓存容量会带来访问时间的惩罚(较大的缓存通常访问速度会稍慢)。

  • 高关联度
    :增加缓存的相联度(如从1路变为2路或更高)可以减少冲突未命中,因为多个数据块可以存储在同一缓存组中,减少了因为地址冲突而导致的数据丢失。


    • 经验法则
      :容量为N/2的2路相联缓存的未命中率与容量为N的1路相联缓存相当,但高相联度会消耗更多能量。

3.6 更多 Caches 基础

  • L1缓存分为指令缓存和数据缓存,而L2和L3是统一缓存
    :在一级缓存(L1)中,将指令和数据分别存储在不同的缓存中,以提高访问效率;而在L2和L3级别,指令和数据共享同一个缓存空间,即为统一缓存。

  • L1/L2缓存层次结构可以是包容性、排他性或非包容性


    • 包容性
      :L2缓存包含所有L1缓存中的数据内容,这样如果L2有数据,L1肯定也有。这种结构便于管理,但会占用更多L2空间。

    • 排他性
      :L2缓存中的数据不在L1中,两个缓存不会重复存储相同数据,这种结构可以更有效地利用总的缓存空间。

    • 非包容性
      :L1和L2之间没有严格的包容关系,数据可以在L1或L2中单独存在。

  • 写操作时可以选择写分配或不写分配


    • 写分配
      (Write-Allocate):在写入缓存时,如果数据不在缓存中,会将其加载到缓存中后再写入。

    • 不写分配
      (Write-No-Allocate):如果数据不在缓存中,直接写到下一级缓存或主存,而不加载到缓存中。

  • 写操作时可以选择回写(Write-Back)或直写(Write-Through)


    • 回写
      (Write-Back):写入时只更新缓存中的数据,而不立即写回内存,只有当缓存行被替换时才写回内存,减少了内存总线的流量。

    • 直写
      (Write-Through):每次写入缓存时,同时更新内存,这样简化了数据一致性管理,但增加了内存访问流量。

  • 读操作优先于写操作,且写操作通常会进行缓冲
    :为了保证数据的快速读取,读操作通常具有更高优先级,而写操作会被缓冲,等待合适的时机再写入,避免影响读操作的效率。

  • L1缓存并行进行标签和数据访问,L2/L3缓存串行进行标签和数据访问


    • 在L1缓存中,标签(Tag)和数据的访问是并行的,以减少访问时间。

    • 在L2和L3缓存中,标签和数据访问是串行的,先检查标签是否命中,再决定是否读取数据,这种设计减少了电路的复杂性。

3.7 容忍罚失

在处理缓存未命中(cache miss)时,如何通过一些策略来减少等待带来的性能损失:

  • 乱序执行(Out of Order Execution)
    :在等待缓存未命中数据返回的时间内,处理器可以继续执行其他有用的工作,以提高整体的执行效率。这意味着处理器不需要因一次缓存未命中而停下来等待,而是可以继续处理其他指令。另外,乱序执行允许处理器同时处理多个缓存未命中事件,从而最大化资源利用率。
    • 多次缓存未命中(Multiple Cache Misses)
      :在这种情况下,缓存控制器需要能够跟踪多个未命中的请求,这就需要非阻塞缓存(Non-Blocking Cache)。非阻塞缓存允许处理器在等待一个未命中的数据时继续发出其他请求,而不是阻塞住当前的所有操作。
  • 硬件预取(Hardware Prefetching)
    :通过硬件将未来可能会访问的数据提前加载到预取缓冲区(Prefetch Buffer)中,以减少未来访问的等待时间。这种方法在程序具有
    空间或时间局部性
    时尤其有效,因为处理器可以预测到接下来可能需要的数据。
    • 激进预取(Aggressive Prefetching)
      :虽然预取可以减少等待时间,但如果预取过多,会增加总线(Bus)的争用压力,影响其他内存访问的性能。因此,预取的策略需要平衡数据加载和总线资源的竞争。

3.8 预取(Prefetching)

  • 硬件预取可以应用于任何缓存级别
    :预取是一种硬件技术,用来在数据实际被需要之前提前将其加载到缓存中,以减少等待时间。预取技术可以在任何缓存层次(如L1、L2、L3)中应用。
  • 预取会引起缓存污染
    :如果预取的数据并不被立即使用,可能会占用缓存中的位置,从而导致原本更有用的数据被替换掉,这种情况称为缓存污染。为避免污染,预取的数据通常放在一个单独的预取缓冲区中。这样,预取的数据不会与主缓存中的数据混合,但在访问缓存时需要并行查找这个缓冲区,以确保是否存在预取的数据。
  • 激进的预取增加“覆盖率”,但降低“准确率”
    :激进的预取意味着尽可能多地预取数据,这样可以覆盖更多的潜在数据需求,称为“增加覆盖率”。但是,这也可能导致许多预取的数据其实不会被用到,从而浪费了内存带宽,称为“降低准确率”。
  • 预取的时机很重要
    :预取数据的时机需要把握得当。预取必须足够提前,以便在数据需要时已经加载完毕,从而隐藏内存访问的延迟。但如果预取过早,数据可能在被使用前就因为缓存替换策略而被驱逐,导致缓存污染或资源浪费。

4. 缓存抽象(Abstraction)和结构(Structure)

4.1 缓存抽象和度量

image-20241108145559312

给一个地址来索引数据存储,tag store 回答寻找的地址是否在缓存中,它会发出信号Hit/miss,如果Hit,Data store会给出Data。

  • Cache hit rate = (# hits) / (# hits + # misses) = (# hits) / (# accesses)
  • Average memory access time (AMAT) = ( hit-rate * hit-latency ) + ( miss-rate * miss-latency )(include hit latency)
  • AMAT越小越好吗?

4.2 Block和缓存寻址

  • 内存在逻辑上被划分为固定大小的block
  • 每个数据块映射到缓存中的某个位置,该位置由地址中的index (索引) bits 或 set (组) bits 决定。
    • 用于索引标签和数据存储

image-20241108150340922

  • 缓存访问过程:
  1. 将索引输入 tag 和 data 存储,索引位位于地址中
  2. 检查tag store中的有效位
  3. 将地址中的 tag bits 与 tag store 中存储的标签进行比较
  • 如果数据块在缓存中(缓存命中),则存储的 tag 应
    有效valid
    ,并与数据块的 tag 相匹配

4.3 直接映射缓存:放置和访问

  • 假设字节可寻址内存:256bytes,8-byte blocks -> 32 blocks

  • 假设缓存: 64 bytes,8 blocks


    • 直接映射: 一个block只能前往一个location
  • 具有相同 index 的地址争夺相同位置


    • 导致冲突未命中

image-20241108151318826

  • 直接映射缓存:内存中映射到高速缓存中相同 index 的两个块不能同时出现在缓存中


    • 一个 index -> 一个 entry
  • 如果以
    交错方式访问
    的多个 block 映射到同一索引,则可能导致 0% 的命中率


    • 假设地址 A 和地址 B 的 index 位相同,但 tag 位不同
    • A, B, A, B, A, B, A, B, … -> 缓存 index 冲突
    • 所有访问都是
      冲突未命中

4.4 组关联性(Set Associativity)

4.4.1 2组关联

  • 在直接映射缓存中,地址 0 和 8 总是冲突的
  • 将一列 8 个 blocks 改为 2 列 4 个 blocks
  • index位减1,tag位加1

image-20241108152518390
image-20241108152638244

  • 组内关联存储器
    • 更好地适应冲突(更少的冲突未命中)
    • 更复杂、访问更慢、tag store更大

4.4.2 4组关联

image-20241108153556752

  • 冲突未命中的可能性更低
  • 更多 tag 比较器和更宽的数据复用器;更大的 tag

4.4.3 全关联

  • 完全关联式缓存
    • 一个数据块可放置在任何高速缓存位置

image-20241108153831092

4.4.4 关联性(和权衡)

  • 关联性程度:
    有多少 block 可以映射到同一个 index(或数据集)?

  • 更高的关联性


    • 更高的命中率
    • 更慢的缓存访问时间(命中延迟和数据访问延迟)
    • 更昂贵的硬件(更多的比较器)
  • 高关联度带来的收益递减

image-20241108154511095

4.5 降低未命中率(Miss Rate)的创新方法

  • Victim 缓存
  • 更好的替换策略——伪 LRU、NRU、DRRIP
    • 插入、提升、victim 选择
    • 预取、缓存压缩

4.5.1 Victim 缓存

  • 直接映射缓存会出现未命中情况,因为多个数据映射到同一位置
  • 处理器经常尝试访问最近丢弃的数据
    • 所有丢弃的数据都放在一个小的 victim 缓存中(4 或 8 个条目)
    • 在进入 L2 之前检查 victim 缓存
  • 可将其视为冲突最多的几个数据集的额外关联性

4.6 组关联缓存中的问题

  • 认为 set 中的每个block都有一个 "优先级"
    • 表示将该 block 保留在缓存中的重要性
  • 关键问题:如何确定/调整数据块的优先级?
  • 在一个 set 中有三个关键决定:
    • 插入、提升、剔除(替换)
  • 插入(Insertion):缓存填充时优先级会发生什么变化?
    • 在哪里插入进入的 block,是否插入 block
  • 提升(Promotion):缓存命中时优先级会发生什么变化?
    • 是否以及如何改变 block 的优先级
  • 驱逐/替换(Eviction/replacement):缓存未命中时优先级会发生什么变化?
    • 驱逐哪个 block 以及如何调整优先级

4.7 驱逐/更换策略

  • 在缓存 miss 时,要替换数据集中的哪个数据块?
    • 先替换任何无效 block
    • 如果所有 block 都有效,则参考替换策略
      • Random
      • FIFO
      • Least recently used (how to implement?) 最近最少使用(LRU)
      • Not most recently used 最近未使用
      • Least frequently used?使用频率最低
      • Least costly to re-fetch?重新获取的成本最低
      • Why would memory accesses have different cost?
      • Hybrid replacement policies 混合替换
      • Optimal replacement policy?

5. LRU

  • Idea
    :驱逐最近访问次数最少的区块

  • Problem:
    需要跟踪 block 的访问顺序

  • 问题:2路组关联缓存


    • 要完美实现 LRU,需要哪些条件?
      需要1bit记录访问顺序
  • 问题:4路组关联缓存


    • 完美实现 LRU 需要哪些条件?
    • 集合中的 4 个块可能有多少种不同的顺序?
      \(4\times 3\times 2\times 1 = 4! = 24\)
    • 需要多少位来编码块的 LRU 顺序?
      最少需要5bits
    • 确定 LRU victim 需要哪些逻辑?

IMG_0164(20241108-170423)

5.1 近似LRU

  • 大多数现代处理器都没有在高关联缓存中实现 "真正的 LRU"(也称为 "完美的 LRU")。


    • 因为真正的LRU是复杂的
    • LRU只是预测本地性的近似值(即不是最佳的高速缓存管理策略
  • Example:


    • NRU,非最近使用
    • DRRIP

5.2 驱逐/更换策略

  • NRU:
    集合中的每个 block 都有一个 bit;block 被访问时,该 bit 变为 0;如果所有 bit 都为 0,则将所有位变为 1;将 bit 设置为 1 的 block 驱逐出去

  • DRRIP:
    使用多个 NRU 位(3 位),将进入的 block 设置为高位,使其接近被驱逐;类似于将进入的 block 置于 LRU 列表的头部而非尾部附近

6. 缓存策略

6.1 Tag Store

Tag的组成可以有以下几部分:

  • Valid bit
  • Tag
  • Replacement policy bits(更换策略位)
  • Dirty bit(脏位)
    • 表示该数据为 write back 模式还是 write through模式

6.2 处理 Write 操作

6.2.1 写回模式(Write-back) vs. 写直达模式(Write-through)

  • 我们何时将缓存中修改过的数据写入下一级?


    • write through:
      写入时
    • write back:
      当 block 被驱逐时
  • Write-back(写回模式):
    更新缓存时,并不同步更新memory


    • 可以在驱逐前合并对同一 block 的多次写入
      • 有可能节省缓存级别之间的带宽,减少能耗
    • 需要在 tag store 中加入一个位,表明该数据块是 "脏的/修改过的"。
  • Write-through(写直达模式):
    CPU向cache写入数据时,同时向memory写


    • 简单
    • 所有级别的 Cache 都是最新的。
      一致性:
      缓存一致性更简单,因为无需检查接近处理器缓存的 Tag store 是否存在
    • 带宽更密集;不合并写入

6.2.2 写入未命中时分配(Allocate on write miss) vs. 写入未命中时不分配(No-allocate on write miss)

  • 我们是否会在
    写入未命中时分配缓存块(cache block)即是否会从缓存中驱逐其他缓存块

    • 写入未命中时分配(Allocate on write miss):是
    • 写入未命中时不分配(No-allocate on write miss):否
  • 写入未命中时分配(Allocate on write miss):将要写的地址所在的 block 先从 memory 读到 cache 中,然后写 cache
    • 可以
      合并写入
      ,而不是将每一个要写的数据每次都单独写入下一级
    • 更简单,因为
      写入未命中与读取未命中的处理方式相同
    • 需要传输整个缓存块(cache block)
  • 不分配(No-allocate):将要写的内容直接写回 memory
    • 如果写入 block 的局部性较低,则可节省缓存空间(可能会提高缓存命中率

6.3 分块(Subblocked/Sectored)缓存

  • 如果处理器在较短时间内写入整个数据块怎么办?是否有必要首先将数据块从内存带入缓存?

  • 为什么我们不能只写入数据块的一部分,即子数据块?


    • e.g. 写 64byte 中的 4byte
    • Problem:有效位和脏位与整个 64byte 相关联,而不是与每个 4byte 相关联
  • Idea:将一个 block 划分为子区块 subblocks(or 扇区sectors)


    • 每个 subblock(sector)都有单独的有效位和脏位
    • 一个请求只分配一个 subblocks(或一个 subblocks 子集)

image-20241109105032585

  • 优点:


    • 无需将整个缓存块 block 传输到缓存中(写入只需
      验证和更新一个 subblock
    • 将 subblocks 传输到缓存的自由度更大(缓存块 block 不需要完全在缓存中,只需要确定一次读取要传输多少个 subblocks?)
  • 缺点:


    • 设计更复杂;更多的有效位和脏位
    • 可能无法充分利用空间局部性

6.4 指令缓存(Instruction Cache) vs. 数据缓存(Data Cache)

  • 要分开(Separate)管理还是统一(Unified)管理

  • Unified的优缺点:


    • 动态共享缓存空间:不会出现静态分区(即独立的 Instruction 和 Data 缓存)可能出现的超额配置情况

    • 指令和数据会互相驱逐(即两者都没有充足的空间

    • Instruction 和 Data 在流水线的不同位置被访问。 我们应该把统一缓存放在哪里才能实现快速访问?
      这是大多数处理器使用单独(Separate)缓存的主要原因

  • 一级缓存几乎总是被拆分


    • 主要就是因为这个原因
      “Instruction 和 Data 在流水线的不同位置被访问。 我们应该把统一缓存放在哪里才能实现快速访问?”
  • 高级缓存几乎总是统一的


    • 这是因为
      “动态共享缓存空间:不会出现静态分区(即独立的 Instruction 和 Data 缓存)可能出现的超额配置情况”

6.5 流水线设计中的多级缓存

  • 一级缓存 L1(指令和数据):
    • 决定在很大程度上受周期时间的影响
    • 小、关联度低;延迟至关重要
    • Tag store 和 Data Store 并行访问
    • 指令和数据也是并行访问的
  • 二级缓存 L2:
    • 决策需要平衡命中率和访问延迟
    • 通常容量大,关联性强;延迟不那么重要
    • 以串行方式访问 Tag store 和 Data store
      • 延迟并不重要,重要的是命中率和节能
  • 缓存级别的串行与并行访问:
    • 串行:只有在一级缓存 miss 时才访问二级缓存
    • 第二层的访问权限与第一层不同
      • 第一层起到过滤器的作用(过滤一些时间和空间局部性)
      • 管理策略有所不同

img

7. 提高缓存性能

缓存参数与失误/命中率的关系

  • Cache 大小
  • Block 大小
  • 关联性
  • 驱逐策略
  • 插入策略

7.1 Cache 大小

  • 缓存大小:数据(不包括 tag)总容量


    • 越大越能利用时间局部性
    • 并非总是越大越好
  • 过大的缓存会对命中和未命中延迟产生不利影响


    • 越小越快 => 越大越慢
    • 访问时间可能会降低关键路径的性能
  • 缓存太小


    • 不能很好地利用时间局部性
    • 有用数据经常被替换
  • 工作集(working set)点:应用程序所引用的全部数据集可能存在于缓存中

image-20241109124348151

7.2 Block 大小

  • Block 大小是与地址 tag 关联的数据


    • 不一定是层级间的传输单位
      • Sub-blocking:一个 block 分为多个部分(每个部分都有 Valid/Dirty 位)
  • Block 太小


    • 不能很好地利用空间局部性
    • tag 开销较大
  • block 太大


    • 总 block 数过少
    • 时间局部性利用较少
    • 如果空间局部性不高,则会浪费缓存空间和带宽/能源

7.2.1 Large Block:Critical-Word and Subblocking

  • 大型缓存块可能需要很长时间才能填入缓存


    • 先填入缓存行
      关键字(Critical-Word)
    • 在完成填入之前重新启动缓存访问
  • 大型缓存块会浪费总线带宽


    • 将一个块划分为多个子块
    • 为每个子块分别设置有效位和脏位

7.3 关联性

同一索引(即组)中可以出现多少个block?

  • 更大的关联性
    • 更低的未命中率(减少冲突)
    • 更高的命中延迟和面积成本
  • 更小的关联性
    • 更低的成本
    • 更低的命中延迟
    • 对 L1 缓存尤为重要

image-20241109125250843

7.4 缓存 miss 的分类

  • 强制未命中
    • 对address(block)的首次引用总是导致未命中
    • 随后的引用应该命中,除非缓存块由于以下原因被移位
    • 解决方式:
      预取:预测哪些 block 即将需要
  • 容量未命中
    • 缓存太小,无法容纳所需的所有内容
    • 定义为即使在相同容量的完全关联缓存(具有最佳替换功能)中也会发生的缺失次数
    • 解决方式:
      • 更好地利用缓存空间:保留将被引用的 block
      • 软件管理:划分工作集和计算,使每个 "计算阶段 "都适合缓存
  • 冲突未命中
    • 定义为既非强制失误也非容量失误的任何失误
    • 解决方式:
      • 更多的关联性
      • 在不使缓存具有关联性的情况下获得更多关联性的其他方法
        • Victim cache
        • 更好的随机索引
        • 软件提示?

7.5 如何提高缓存性能

  • Reducing miss rate
    • 更多关联性
    • 关联性的替代方法/增强方法
    • victim 缓存、散列、伪关联性、倾斜关联性
    • 更好的替换/插入策略
    • 软件方法
  • Reducing miss latency or miss cost
    • 多级高速缓存
    • 关键字优先
    • 分块(subblocking)/扇区
    • 更好的替换/插入策略
    • 无阻塞高速缓存(多个高速缓存并行读取)
    • 每个周期多次访问
    • 软件方法
  • Reducing hit latency or hit cost


title: Nuxt.js 应用中的 listen 事件钩子详解
date: 2024/11/9
updated: 2024/11/9
author:
cmdragon

excerpt:
它为开发者提供了一个自由的空间可以在开发服务器启动时插入自定义逻辑。通过合理利用这个钩子,开发者能够提升代码的可维护性和调试能力。注意处理性能、错误和环境等方面的问题可以帮助您构建一个更加稳定和高效的应用。

categories:

  • 前端开发

tags:

  • Nuxt
  • 钩子
  • 开发
  • 服务器
  • 监听
  • 请求
  • 日志


image
image

扫描
二维码
关注或者微信搜一搜:
编程智域 前端至全栈交流与成长

目录

  1. 概述
  2. listen 钩子的详细说明
  3. 具体使用示例
  4. 应用场景
  5. 注意事项
  6. 总结

1. 概述

listen
钩子是在 Nuxt.js 开发服务器加载时被调用的生命周期钩子。它主要用于处理开发环境下服务器启动前后的自定义逻辑,例如监控请求动态或初始化配置。

2.
listen
钩子的详细说明

2.1 钩子的定义与作用

  • 定义

    listen
    是一个 Nuxt.js 钩子,允许开发者在开发服务器开始监听请求时执行自定义代码。
  • 作用
    :它使开发者能够在服务器启动时进行各种操作,例如初始化状态、设置事件监听器等。

2.2 调用时机

  • 执行环境
    :钩子在开发服务器启动后会被立刻调用。
  • 挂载时机
    :通常在 Nuxt 应用初始化的早期阶段,确保开发者的自定义代码能在请求处理之前执行。

2.3 参数说明

  • listenerServer
    :一个回调,用于访问服务器实例,允许执行对服务器的自定义操作。
  • listener
    :提供一个方法来设置对请求事件的监听。这通常用于监听 HTTP 请求。

3. 具体使用示例

3.1 示例:基本用法

以下是一个基本的
listen
钩子用法示例:

// plugins/serverListener.js
export default defineNuxtPlugin((nuxtApp) => {
  nuxtApp.hooks('listen', (listenerServer, listener) => {
    console.log('开发服务器已启动,准备监听请求...');

    listenerServer(() => {
      console.log('Nuxt 开发服务器已准备好接收请求!');
    });
  });
});

在这个示例中,我们定义了一个插件,在服务器启动时输出提示信息。这个钩子会在服务器准备好接受请求时被调用。

3.2 示例:请求日志记录

下面是一个示例,展示如何在接收到请求时记录请求的日志:

// plugins/requestLogger.js
export default defineNuxtPlugin((nuxtApp) => {
  nuxtApp.hooks('listen', (listenerServer, listener) => {
    console.log('开发服务器已经启动,准备监听请求...');

    listener((req, res) => {
      // 记录请求 URL 和方法
      console.log(`${req.method} 请求到: ${req.url}`);
      
      // 可以在这里添加处理请求的代码,如中间件
    });

    listenerServer(() => {
      console.log('服务器已经准备好,可以接受请求。');
    });
  });
});

4. 应用场景

4.1 初始化配置

描述
:在开发环境中,您可以在服务器启动时执行任何需要的配置任务。这包括设置数据库连接、加载环境变量等。

示例

// plugins/initConfig.js
export default defineNuxtPlugin((nuxtApp) => {
  nuxtApp.hooks('listen', async (listenerServer) => {
    console.log('初始化配置...');

    // 假设我们需要连接数据库
    await connectToDatabase();
    console.log('数据库连接成功!');
    
    listenerServer(() => {
      console.log('服务器已准备好,配置已初始化。');
    });
  });
});

// 示例的数据库连接函数
async function connectToDatabase() {
  // 模拟异步连接数据库操作
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log('数据库连接成功!');
      resolve();
    }, 1000);
  });
}

4.2 请求监控

描述
:为了确保应用程序健康,您可能需要监控进入的每个 HTTP 请求。这对于调试和性能分析非常有用。

示例

// plugins/requestMonitor.js
export default defineNuxtPlugin((nuxtApp) => {
  nuxtApp.hooks('listen', (listenerServer, listener) => {
    
    listener((req, res) => {
      const startTime = Date.now();
      res.on('finish', () => {
        const duration = Date.now() - startTime;
        console.log(`[${req.method}] ${req.url} - ${duration}ms`);
      });
    });
    
    listenerServer(() => {
      console.log('请求监控已设置。');
    });
  });
});

4.3 动态中间件

描述
:通过
listen
钩子,您可以在应用程序运行时动态地设置中间件,这使得您的应用更加灵活。

示例

// plugins/dynamicMiddleware.js
export default defineNuxtPlugin((nuxtApp) => {
  nuxtApp.hooks('listen', (listenerServer, listener) => {
    
    listener((req, res, next) => {
      // 在特定条件下应用中间件
      if (req.url.startsWith('/admin')) {
        console.log('Admin 访问:', req.url);
      }
      
      // 调用下一个中间件
      next();
    });
    
    listenerServer(() => {
      console.log('动态中间件已设置。');
    });
  });
});

5. 注意事项

5.1 性能影响

描述
:在
listen
钩子中进行繁重的计算或耗时的操作,可能会显著影响服务器的启动时间。

示例

// plugins/performanceAware.js
export default defineNuxtPlugin((nuxtApp) => {
  nuxtApp.hooks('listen', (listenerServer, listener) => {
    console.log('服务器正在启动...');

    // 不要在这里做耗时操作
    setTimeout(() => {
      console.log('启动任务完成!');
    }, 5000); // 这将影响应用启动速度
  });
});

优化建议
:确保在执行耗时操作时使用异步方式,并考虑在服务器启动后进行初始化。

5.2 错误处理

描述
:在请求处理中添加错误处理逻辑是很重要的,以免因为未捕获的错误导致服务器崩溃。

示例

// plugins/errorHandling.js
export default defineNuxtPlugin((nuxtApp) => {
  nuxtApp.hooks('listen', (listenerServer, listener) => {
    
    listener((req, res) => {
      try {
        // 处理请求逻辑...
        // 假设发生错误
        throw new Error('模拟错误');
      } catch (error) {
        console.error('请求处理出错:', error);
        res.writeHead(500);
        res.end('服务器内部错误');
      }
    });
    
    listenerServer(() => {
      console.log('错误处理已设置。');
    });
  });
});

5.3 环境检测

描述
:确保
listen
钩子逻辑仅在开发环境中运行,以避免在生产环境中产生不必要的开销和安全问题。

示例

// plugins/envCheck.js
export default defineNuxtPlugin((nuxtApp) => {
  nuxtApp.hooks('listen', (listenerServer, listener) => {
    if (process.env.NODE_ENV !== 'development') {
      console.log('此逻辑仅在开发环境中运行。');
      return;
    }

    console.log('开发环境钩子逻辑正在运行...');
    
    listenerServer(() => {
      console.log('服务器已准备好,开发环境设置完成。');
    });
  });
});

6. 总结

listen
钩子在 Nuxt.js 开发过程中非常有用,它为开发者提供了一个自由的空间可以在开发服务器启动时插入自定义逻辑。通过合理利用这个钩子,开发者能够提升代码的可维护性和调试能力。注意处理性能、错误和环境等方面的问题可以帮助您构建一个更加稳定和高效的应用。

余下文章内容请点击跳转至 个人博客页面 或者 扫码关注或者微信搜一搜:
编程智域 前端至全栈交流与成长
,阅读完整的文章:
Nuxt.js 应用中的 listen 事件钩子详解 | cmdragon's Blog

往期文章归档:

先展示下最终效果:

第一步:先安装ollama,并配置对应的开源大模型。

安装步骤可以查看上一篇博客:

ollama搭建本地ai大模型并应用调用

第二步:需要注意两个配置,页面才可以调用
1)OLLAMA_HOST= "0.0.0.0:11434"
2)若应用部署服务器后想调用,需要配置:OLLAMA_ORIGINS=*
第三步:js流式调用大模型接口方法
async startStreaming(e) {if(e.ctrkey&&e.keyCode==13){this.form.desc+='\n';
}
document.getElementById(
"txt_suiwen").disabled="true";//如果已经有一个正在进行的流式请求,则中止它 if (this.controller) {this.controller.abort();
}

setTimeout(()
=>{this.scrollToBottom();
},
50);var mymsg=this.form.desc.trim();if(mymsg.length>0){this.form.desc='';this.message.push({
user:
this.username,
msg:mymsg
})
this.message.push({
user:
'GPT',
msg:
'',
dot:
''});//创建一个新的 AbortController 实例 this.controller = newAbortController();
const signal
= this.controller.signal;this.arequestData.messages.push({role:"user",content:mymsg});try{
const response
= await fetch('http://127.0.0.1:11434/api/chat', {
method:
'POST',
headers: {
'Content-Type': 'application/json'},
body:JSON.stringify(
this.arequestData),
signal
});
if (!response.body) {this.message[this.message.length-1].msg='ReadableStream not yet supported in this browser.';throw new Error('ReadableStream not yet supported in this browser.');
}

const reader
=response.body.getReader();
const decoder
= newTextDecoder();
let result
= '';this.message[this.message.length-1].dot='⚪';while (true) {
const { done, value }
=await reader.read();if(done) {break;
}
result
+= decoder.decode(value, { stream: true});//处理流中的每一块数据,这里假设每块数据都是完整的 JSON 对象 const jsonChunks = result.split('\n').filter(line =>line.trim());//console.log(result) for(const chunk of jsonChunks) {try{
const data
=JSON.parse(chunk);//console.log(data.message.content) this.message[this.message.length-1].msg+=data.message.content;
setTimeout(()
=>{this.scrollToBottom();
},
50);
}
catch(e) {//this.message[this.message.length-1].msg=e; //处理 JSON 解析错误 //console.error('Failed to parse JSON:', e); }
}
//清空 result 以便处理下一块数据 result = '';
}
}
catch(error) {if (error.name === 'AbortError') {
console.log(
'Stream aborted');this.message[this.message.length-1].msg='Stream aborted';
}
else{
console.error(
'Streaming error:', error);this.message[this.message.length-1].msg='Stream error'+error;
}
}
this.message[this.message.length-1].dot='';this.arequestData.messages.push({
role:
'assistant',//this.message[this.message.length-1].user,//"GPT", content: this.message[this.message.length-1].msg
})
setTimeout(()
=>{this.scrollToBottom();
},
50);

}
else{this.form.desc='';
}
document.getElementById(
"txt_suiwen").disabled="";
document.getElementById(
"txt_suiwen").focus();
}
}

vue完整代码如下:
<template> 
  <el-row:gutter="12"class="demo-radius">
    <divclass="radius":style="{
borderRadius: 'base'
}"
> <divclass="messge"id="messgebox"ref="scrollDiv"> <ul> <liv-for="(item, index) in message":key="index"style="list-style-type:none;"> <divv-if="item.user == username"class="mymsginfo"style="float:right"> <div> <el-avatarstyle="float: right;margin-right: 30px;background: #01bd7e;"> <!--{{ item.user.substring(0, 2) }}--> <img:alt="item.user.substring(0, 2)":src=userphoto/> </el-avatar> </div><divstyle="float: right;margin-right: 10px;margin-top:10px;width:80%;text-align: right;"> {{ item.msg }} </div> </div> <divv-else class="chatmsginfo" > <div> <el-avatarstyle="float: left;margin-right: 10px;"> {{ item.user }} </el-avatar> </div> <divstyle="float: left;margin-top:10px;width:80%;"> <imgalt="loading"v-if="item.msg == ''"class="loading"src="../../assets/loading.gif"/> <MdPreviewstyle="margin-top:-20px;":autoFoldThreshold="9999":editorId="id":modelValue=" item.msg + item.dot " /> <!--{{ item.msg }}--> </div> </div> </li> </ul> </div> <divclass="inputmsg"> <el-form:model="form" > <el-form-item> <el-avatarstyle="float: left;background: #01bd7e;margin-bottom: -44px;margin-left: 4px;z-index: 999;width: 30px;height: 30px;"> <imgalt="jin":src=userphoto/> </el-avatar> <el-inputid="txt_suiwen":prefix-icon="userphoto"resize="none"autofocus="true":autosize="{ minRows: 1, maxRows: 2 }"v-model="form.desc"placeholder="说说你想问点啥....按Enter键可直接发送"@keydown.enter.native.prevent="startStreaming($event)"type="textarea" /> </el-form-item> </el-form> </div> </div> </el-row> </template> <scriptsetup>import { MdPreview, MdCatalog } from'md-editor-v3';
import
'md-editor-v3/lib/preview.css';

const id
= 'preview-only';</script> <script>exportdefault{
data() {
return{
form: {
desc:
''},
message:[],
username:sessionStorage.name,
userphoto:sessionStorage.photo,
loadingtype:false,
controller:
null,
arequestData : {
model:
"qwen2",//"llama3.1", messages: []
}
}
},
mounted() {
},
methods: {
scrollToBottom() {
let elscroll
=this.$refs["scrollDiv"];
elscroll.scrollTop
=elscroll.scrollHeight+30},
clearForm(formName){
this.form.desc='';
},
async startStreaming(e) {
if(e.ctrkey&&e.keyCode==13){this.form.desc+='\n';
}
document.getElementById(
"txt_suiwen").disabled="true";//如果已经有一个正在进行的流式请求,则中止它 if(this.controller) {this.controller.abort();
}

setTimeout(()
=>{this.scrollToBottom();
},
50);varmymsg=this.form.desc.trim();if(mymsg.length>0){this.form.desc='';this.message.push({
user:
this.username, msg:mymsg
})
this.message.push({
user:
'GPT', msg:'',
dot:
''});//创建一个新的 AbortController 实例 this.controller= newAbortController();
const signal
= this.controller.signal;this.arequestData.messages.push({role:"user",content:mymsg});try{
const response
=await fetch('http://127.0.0.1:11434/api/chat', {
method:
'POST',
headers: {
'Content-Type':'application/json'},
body:JSON.stringify(
this.arequestData),
signal
});
if(!response.body) {this.message[this.message.length-1].msg='ReadableStream not yet supported in this browser.';throw newError('ReadableStream not yet supported in this browser.');
}

const reader
=response.body.getReader();
const decoder
= newTextDecoder();
let result
= '';this.message[this.message.length-1].dot='';while(true) {
const { done, value }
=await reader.read();if(done) {break;
}
result
+=decoder.decode(value, { stream:true});//处理流中的每一块数据,这里假设每块数据都是完整的 JSON 对象 const jsonChunks=result.split('\n').filter(line=>line.trim());//console.log(result) for(const chunk of jsonChunks) {try{
const data
=JSON.parse(chunk);//console.log(data.message.content) this.message[this.message.length-1].msg+=data.message.content;
setTimeout(()
=>{this.scrollToBottom();
},
50);
}
catch(e) {//this.message[this.message.length-1].msg=e; //处理 JSON 解析错误 //console.error('Failed to parse JSON:', e); }
}
//清空 result 以便处理下一块数据 result= '';
}
}
catch(error) {if(error.name=== 'AbortError') {
console.log(
'Stream aborted');this.message[this.message.length-1].msg='Stream aborted';
}
else{
console.error(
'Streaming error:', error);this.message[this.message.length-1].msg='Stream error'+error;
}
}
this.message[this.message.length-1].dot='';this.arequestData.messages.push({
role:
'assistant',//this.message[this.message.length-1].user,//"GPT", content:this.message[this.message.length-1].msg
})
setTimeout(()
=>{this.scrollToBottom();
},
50);

}
else{this.form.desc='';
}
document.getElementById(
"txt_suiwen").disabled="";
document.getElementById(
"txt_suiwen").focus();
}
},
beforeDestroy() {
//组件销毁时中止流式请求 if(this.controller) {this.controller.abort();
}
}
}
</script> <stylescoped>.radius{margin:0 auto; }.demo-radius .title{color:var(--el-text-color-regular);font-size:18px;margin:10px 0; }.demo-radius .value{color:var(--el-text-color-primary);font-size:16px;margin:10px 0; }.demo-radius .radius{min-height:580px;height:85vh;width:70%;border:1px solid var(--el-border-color);border-radius:14px;margin-top:10px; }.messge{width:96%;height:84%; /*border:1px solid red;*/margin:6px auto;overflow:hidden;overflow-y:auto; }.inputmsg{width:96%;height:12%; /*border:1px solid blue;*/border-top:2px solid #ccc;margin:4px auto;padding-top:10px; }.mymsginfo{width:100%;height:auto;min-height:50px; }::-webkit-scrollbar{width:6px;height:5px; }::-webkit-scrollbar-track{background-color:rgba(0, 0, 0, 0.2);border-radius:10px; }::-webkit-scrollbar-thumb{background-color:rgba(0, 0, 0, 0.5);border-radius:10px; }::-webkit-scrollbar-button{background-color:#7c2929;height:0;width:0px; }::-webkit-scrollbar-corner{background-color:black; } </style> <style>.el-textarea__inner{padding-left:45px;padding-top:.75rem;padding-bottom:.75rem; } </style>

概述

SpringMVC 中的 MVC 即模型-视图-控制器,该框架围绕一个 DispatcherServlet 改计而成,DispatcherServlet 会把请求分发给各个处理器,并支持可配置的处理器映射和视图渲染等功能

SpringMVC 的工作流程如下所示:

  1. 客户端发起 HTTP 请求:客户端将请求提交到 DispatcherServlet
  2. 寻找处理器:DispatcherServlet 控制器查询一个或多个 HandlerMapping,找到处理该请求的 Controller
  3. 调用处理器:DispatcherServlet 将请求提交到 Controller
  4. 调用业务处理逻辑并返回结果:Controller 在调用业务处理逻辑后,返回 ModelAndView
  5. 处理视图映射并返回模型:DispatcherServlet 查询一个或多个 ViewResolver 视图解析器,找到 ModelAndView 指定的视图
  6. HTTP 响应:视图负责将结果在客户端浏览器上谊染和展示


DispatcherServlet

在 Java 中可以使用 Servlet 来处理请求,客户端每次发出请求,Servlet 会调用 service 方法来处理,SpringMVC 通过创建 DispatchServlet 来统一接收请求并分发处理

1. 创建 DispatcherServlet

在 Tomcat 中创建 DispatcherServlet 的方式有两种:

第一种方式是通过 web.xml,Tomcat 会在启动时加载根路径下 /WEB-INF/web.xml 配置文件,根据其中的配置加载 Servlet,Listener,Filter 等,下面是 SpringMVC 的常见配置:

<servlet>
    <servlet-name>dispatcher</servlet>
    <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
    <init-param>
        <!--DispatchServlet 持有的 WebApplicationContext-->
        <param-name>contextConfigLocation</param-name>
        <param-value>/WEB-INF/applicationContext.xml</param-value>
        <!-- 1:tomcat 启动时创建 DispatcherServlet,0:tomcat 启动时不创建 DispatcherServlet,接收到请求才创建 -->
        <load-on-startup>1</load-on-startup>
    </init-param>
</servlet>

<servlet-mapping>
    <servlet-name>dispatch</servlet-name>
    <servlet-pattern>/*</servlet-pattern>
</servlet-mapping>

第二种方式是通过 WebApplicationInitializer,简单来说就是 Tomcat 会探测并加载 ServletContainerInitalizer 的实现类,并调用他的 onStartup 方法,而 SpringMVC 提供了对应的实现类 SpringServletContainerInitializer。而 SpringServletContainerInitializer 又会探测并加载 ClassPath 下 WebApplicationContextInitializer 的实现类,调用它的 onStartUp 方法

因此我们可以继承 WebApplicationContextInitializer 实现 onStartUp 方法,在其中以代码的方式配置 DispatchServlet

public class MyWebAppInitializer implements WebApplicationInitializer {
 
    @Override
    public void onStartup(ServletContext container) {

        // 创建 dispatcher 持有的上下文容器
        AnnotationConfigWebApplicationContext dispatcherContext = new AnnotationConfigWebApplicationContext();
        dispatcherContext.register(DispatcherConfig.class);

        // 注册、配置 dispatcher servlet
        ServletRegistration.Dynamic dispatcher = container.addServlet("dispatcher", new DispatcherServlet(dispatcherContext));
        dispatcher.setLoadOnStartup(1);
        dispatcher.addMapping("/*");
    }
}

在创建 DispatcherServlet 时,其内部会创建一个 Spring 容器 WebApplicationContext,目的是通过 Bean 的方式管理 Web 应用中的对象

2. DispatcherServlet 初始化

DispatcherServlet 是 Servlet 的实现类,Servlet的生命周期分为三个阶段:初始化、运行和销毁。初始化阶段会调用 init() 方法,DispatcherServlet 经过一系列封装,最终会调用 initStrategies 方法进行初始化,在这里我们重点关注 initHandlerMappings 和 initHandlerAdapters

protected void initStrategies(ApplicationContext context) {
    initMultipartResolver(context);
    initLocaleResolver(context);
    initThemeResolver(context);
    initHandlerMappings(context);
    initHandlerAdapters(context);
    initHandlerExceptionResolvers(context);
    initRequestToViewNameTranslator(context);
    initViewResolvers(context);
    initFlashMapManager(context);
}

initHandlerMappings 方法负责加载 HandlerMappings 也就是处理器映射器,如果程序员没有配置,那么 SpringMVC 也有默认提供的 HandlerMapping。每个 HandlerMapping 会以 Bean 的形式保持在容器,并执行各自的初始化方法。

默认的 HandlerMapping 有以下两种:

  • RequestMappingHandlerMapping:根据请求 URL 映射到对应 @RequestMapping 方法
  • BeanNameUrlHandlerMapping:根据请求 URL 映射到对应的 Bean 的名称(如该 Bean 的名称为 /test),这个 Bean 会提供一个处理请求逻辑的方法

RequestMappingHandlerMapping 在初始化的过程中会从处理器 bean(即被 @Controller 注解)中找出所有的处理方法(即被 @RequestMapping 注解),把处理方法的 @RequestMapping 注解解析成 RequestMappingInfo 对象,再把处理方法对象包装成 HandlerMethod 对象。然后把 RequestMappingInfo 和 HandlerMethod 对象以 map 的形式缓存起来,key 为 RequestMappingInfo,value 为 HandlerMethod,日后将请求映射到处理器时会使用到

BeanNameUrlHandlerMapping 在初始化的过程中会扫描 Spring 容器中所有的 bean,获取每个 bean 的名称以及对应的 Bean 保持起来。将每个 bean 的名称与请求的 URL 路径进行匹配,如果 bean 的名称与 URL 路径匹配(忽略大小写),那么就以匹配的 Bean 作为处理该请求的处理器。匹配 Bean 的实现如下:

@Componet("/welcome*")
public class WelcomeController implements Controller {

    @Override
    public void handleRequest(HttpServletRequest request, HttpServletResponse response)   {
        ...
    }
}

或者

@Componet("/welcome*")
public class WelcomeController implements HttpRequestHandler {

    @Override
    public void handleRequest(HttpServletRequest request, HttpServletResponse response)   {
        ...
    }
}

initHandlerAdapters 方法负责加载适配器,同样以 Bean 的形式保持在容器并执行初始化方法。如果程序员没有配置,那么 SpringMVC 也有默认提供的 HandlerAdapter。处理请求时,根据请求找到对应的处理器对象后,就会适配得到一个 HandlerAdapter,由 HandlerAdapter 执行处请求

SpringMVC 默认的适配器有:

  • RequestMappingHandlerAdapter:适配处理器是 HandlerMethod 对象
  • HandlerFunctionAdapter:适配处理器是HandlerFunction对象
  • HttpRequestHandlerAdapter:适配处理器是 HttpRequestHandler 对象
  • SimpleControerHandlerAdapter:适配处理器是 Controller 对象


父子容器

前面提到过,初始化 DispatcherServlet 时其内部会跟着创建一个 Spring 容器,那如果在 web.xml 中配置了两个不同的 DispatcherServlet,那么就会有两个分属不同 DispatcherServlet 的 Spring 容器

<!-- 第一个 DispatcherServlet -->
<servlet>
    <servlet-name>app1</servlet>
    <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
    <init-param>
        <param-name>contextConfigLocation</param-name>
        <param-value>/WEB-INF/spring1.xml</param-value>
        <load-on-startup>1</load-on-startup>
    </init-param>
</servlet>

<servlet-mapping>
    <servlet-name>app1</servlet-name>
    <servlet-pattern>/app1/*</servlet-pattern>
</servlet-mapping>

<!-- 第二个 DispatcherServlet -->
<servlet>
    <servlet-name>app2</servlet>
    <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
    <init-param>
        <param-name>contextConfigLocation</param-name>
        <param-value>/WEB-INF/spring2.xml</param-value>
        <load-on-startup>1</load-on-startup>
    </init-param>
</servlet>

<servlet-mapping>
    <servlet-name>app2</servlet-name>
    <servlet-pattern>/app2/*</servlet-pattern>
</servlet-mapping>

出现多个 DispatcherServlet 一般是解决多版本的问题,比如有一个 TestV1Controller 在 app1 这个 DispatcherServlet,现在多了一个升级版 TestV2Controller,就可以放在 app2,使用不同的映射路径

而有时候我们只希望区分不同的 Controller,而通用的 Service 并不需要在每个容器都保存一份,就可以配置父容器,将 Service 放在父容器。DispatcherServlet 初始化时会自动寻找是否存在父容器。

<web-app>

    <listener>
        <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
    </listener>

    <context-param>
        <param-name>contextConfigLocation</param-name>
        <param-value>/WEB-INF/root-spring.xml</param-value>
    </context-param>

    <servlet>
        <servlet-name>app1</servlet-name>
        <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
        <init-param>
            <param-name>contextConfigLocation</param-name>
            <param-value>/WEB-INF/spring1.xml</param-value>
        </init-param>
        <load-on-startup>1</load-on-startup>
    </servlet>

    <servlet-mapping>
        <servlet-name>app1</servlet-name>
        <url-pattern>/app1/*</url-pattern>
    </servlet-mapping>

</web-app>

ContextLoaderListener 被配置到监听器列表,ServletContext 初始化时会使用 context-param 中参数名为 contextConfigLocation 设置的配置文件初始化父容器


SpringMVC 处理请求

SpringMVC 处理请求流程可分如下步骤:

  1. 根据路径找到对应的 Handler
  2. 解析参数并绑定
  3. 执行方法
  4. 解析返回值

1. 根据请求寻找 Handler

请求到来会执行 DispatcherServlet 的 getHandler 方法。遍历所有 HanlderMapping,每个 HandlerMapping 都是根据请求寻找 Handler,但寻找的方式不一样,比如 RequestMappingHandlerMapping 就是根据请求路径寻找 HandlerMethod, BeanNameUrlHandlerMapping 则是将请求路径映射到对应的 Bean 的名称。通过遍历 HandlerMapping,直到请求能找到对应的 Handler

不同的 HanlderMapping 所对应的 Handler 类型也不同,因此要找到对应类型的适配器。遍历所有 HandlerAdapter,如果找对适配的 HandlerAdapter 就返回,执行适配器的 handle 方法

2. 解析参数并执行方法

以 RequestMappingHandlerMapping 为例,Handler 的实际类型是 HandlerMethod,适配的是 RequestMappingHandlerAdapter。执行 invokeHandlerMethod 方法,解析 @initBinder 注解的方法并保存,解析 @SessionAttributes 注解设置的键值对,解析 @ModelAttribute 注解的方法,上述解析的结果将保存在 ModelFactory 对象,ModelFactory 用来初始化 Model 对象,初始化时将 @SessionAttributes 和 @ModelAttribute 设置的值保存到 Model 对象

接下来是创建参数解析器 argumentResolvers 和返回值解析器 returnValueHandlers。解析器有多种类型,对应不同的场景,例如使用 @PathVariable 注解传参就使用 PathVariableMethodArgumentResolver 解析器对象,返回值是 ModelAndView 对象则用 ModelAndViewMethodReturnValueHandler 解析器对象

获取方法参数,方法参数的类型是 MethodParameter,不仅包含了参数的名称,还包括参数的信息,比如是否有 @ReqeustParam 注解。遍历方法参数,并逐一用参数解析器遍历,找到适用的解析器进行解析,再根据参数名称从请求中获取参数值。如果定义了类型转换器,那就对参数类型进行转换。最后使用反射执行真正的方法逻辑

3. 解析返回值

拿到返回值后也是遍历寻找合适的返回值解析器进行处理,比如开发中经常会使用 @ResponseBody 注解返回 json,就会使用 RequestResponseBodyMethodProcessor 处理器进行处理,该处理器同时还承担了参数解析的作用。解析的过程中需要用到消息转换器 HttpMessageConverter,其作用是将方法的返回值转换为接收端(如浏览器)能接受的响应类型,SpringMVC 同样提供了默认的转换器。比如使用 @ResponseBody 注解的方法返回了 String 类型的返回值,那么就会遍历判断哪个消息转换器能处理 String 类型的返回值,在 RequestResponseBodyMethodProcessor 处理器中默认使用 StringHttpMessageConverter。接下来是内容协商,即是找到客户端能接受并且服务端能提供的内容类型,比如客户端希望优先返回 text/plain 类型的内容,而 StringHttpMessageConverter 能支持该类型,那么就使用 StringHttpMessageConverter 将方法返回值写入响应报文返回给客户端。如果我们希望方法直接返回对象类型并自动序列化为 json,那么就需要自定义消息转换器,此时 SpringMVC 将不再提供默认的转换器而是直接使用自定义的转换器,比如引入 MappingJackson2HttpMessageConverter 便能支持对象类型返回值转换为 json 并返回给客户端

如果不使用 @ResponseBody 注解,那么就会使用 ModelAndView 保存视图路径和数据。SpringMVC 同样提供了默认的视图解析器 ViewResolver,它会根据方法返回的 url 在 tomcat内部(不经由 DispatcherServlet 转发,而是使用原生 Servlet)进行一次转发请求到对应的视图文件如 jsp。如果 url 带有前缀 forward: 就表示这是一次转发请求,比如 forward:/app/test,SpringMVC 会去掉该前缀,使用 /app/test 重新交由 DispatcherServlet 转发交由对应处理器处理。如果 url 带有前缀 redirect:,比如 redirect:/test,SpringMVC 会去掉该前缀,给客户端的响应写上重定向头以及重定向地址即 /test,客户端会重新发送请求。转发和重定向的区别在于:转发请求是同一个,重定向则每次都是新的请求。转发时由于经过 DispatcherServlet,所以每次都会新建 Model,而重定向则会自动将 Model 中的参数拼接到重定向的 url


@EnableWebMvc

使用 @EnableWebMvc 注解可以帮助我们在代码中自定义 SpringMVC 配置,比如添加拦截器。使用 @EnableWebMvc 注解的配置类必须继承 WebMvcConfigurer 类

需要注意的是,@EnableWebMvc 是较旧的配置 SpringMVC 的方式。如果使用 SpringBoot,它提供了自动配置,通常不需要显式使用 @EnableWebMvc,只需要在配置文件配置即可

@Configuration
@EnableWebMvc
public class AppConfig implements WebMvcConfigurer {

    @Autowired
    private BeforMethodInteceptor beforMethodInteceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {    
        // 注册自定义拦截器,添加拦截路径和排除拦截路径
        registry.addInterceptor(beforMethodInteceptor) //添加拦截器
                   .addPathPatterns("/**") //添加拦截路径
                   .excludePathPatterns(  //添加排除拦截路径
                           "/index",
                           "/login",
                           ...
                           );
        super.addInterceptors(registry);        
    }

    @Override
    public void configureViewResolvers(ViewResolverRegistry registry) {
        // 配置视图解析器
        InternalResourceViewResolver viewResolver = new InternalResourceViewResolver();
        viewResolver.setPrefix("");
        viewResolver.setSuffix(".html");
        viewResolver.setCache(false);
        viewResolver.setContentType("text/html;charset=UTF-8");
        viewResolver.setOrder(0);        
        registry.viewResolver(viewResolver);
        super.configureViewResolvers(registry);
    }

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        // 定义静态资源位置和 URL 映射规则
        // 例如,将所有以 /static/ 开头的 URL 映射到 /resources/ 目录下的静态资源
        registry.addResourceHandler("/static/**")
                .addResourceLocations("/resources/");
    }

    @Override
    public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
        // 添加 JSON 消息转换器
        converters.add(new MappingJackson2HttpMessageConverter());
    }

   @Override
    public void addCorsMappings(CorsRegistry registry) {
        // 跨域配置 
        registry.addMapping("/**")  // 配置允许跨域的路径
                .allowedOrigins("*")  // 配置允许访问的跨域资源的请求域名
                .allowedMethods("PUT,POST,GET,DELETE,OPTIONS")  // 配置允许访问该跨域资源服务器的请求方法
                .allowedHeaders("*"); // 配置允许请求 header 的访问
        super.addCorsMappings(registry);
    }
}

@EnableWebMvc 注解导入了 DelegatingWebMvcConfiguration 配置类,该类会将所有 WebMvcConfigurer 接口的实现类找到并保存起来。DelegatingWebMvcConfiguration 配置类还实现了 Aware 回调接口,因此会在 Spring 容器生命周期过程中调用回调接口,从而实现自定义配置

你好,我是 Kagol,个人公众号:
前端开源星球

自从 TinyVue 组件库去年开源以来,一直有小伙伴反馈我们的 UI 不够美观,风格陈旧,不太满足现阶段审美。

“TinyVue 给我的感觉就是一个没啥审美能力、但是很努力的老程序员开发的”

看到这个评价,我是哭笑不得,一方面对小伙伴们真诚、友好的反馈充满感激,另一方面也为我们没有做好 UI 感到惭愧。

于是我们和设计师同事携手一起,对 TinyVue 组件做了全面的 UI 升级,适配了一套更符合现代审美的设计规范:OpenTiny Design,这套全新的设计规范,是我们的设计师同事结合华为云的业务特点和最新的设计趋势打磨出来的,目前 TinyVue 所有组件均已支持 OpenTiny Design 设计规范。

当然这套设计规范也不是静止不变的,后续还是会不断迭代和优化,也欢迎广大的开发和设计师朋友给我们提出宝贵的意见。

访问 TinyVue 组件库官网即可进行体验:

https://opentiny.design/tiny-vue

整体组件效果

整体视觉风格以黑蓝为主,稳重又现代,并且更加圆润,看着非常舒服。

新旧效果对比

我们再来看下新旧效果对比。

按钮、表单类组件

除了颜色上的变化,按钮的变化比较明显,变成了全圆角,其他组件也更加圆润。

复选框按钮组件变化比较大,在右上角增加了对勾效果,勾选效果更明显,不容易和按钮混淆。

数字输入框组件的优化效果也很明显,原先细长细长的,感觉不太协调,优化之后更符合现代风格。

输入、下拉类组件

所有的输入框类的组件边框颜色都比之前淡一些,不会太突兀,并且也更圆润。

日期选择框是一个很复杂的组件,我们花了很多精力进行优化,很多都是细节上的打磨,虽然每个细节的优化都不起眼,但是所有细节优化合起来,整体给人的感觉就有很大的不同,大家可以体验下日期选择框这个组件。

https://opentiny.design/tiny-vue/zh-CN/os-theme/components/date-picker

弹窗组件

弹窗组件主要是整体宽度、圆角、阴影的调整,看起来调整的东西不多,但每一处调整都起了画龙点睛的效果。

警告组件

警告组件比较明显的变化是颜色和图标,颜色的层次更加分明,图标的表意也更加准确,比如警告图标,之前是圆形的,现在改成三角形,就更加符合大家的共识。

表格组件

表格组件看起来变化不大,但细看也有很多优化,比如整体线条颜色更浅,更能突出单元格中的核心内容,表头颜色和高度也有一定的调整。

其他组件

滑块组件的优化也非常明显,之前的滑块手柄给人一种很古老的感觉,难怪大家都说风格陈旧,现在改成圆形效果好多了,看起来就像是现代的风格。

大家觉得这次 TinyVue 的视觉升级效果怎么样呢?欢迎在评论区留言。

联系我们

GitHub:
https://github.com/opentiny/tiny-vue
(欢迎 Star ⭐)

官网:
https://opentiny.design/tiny-vue

B站:
https://space.bilibili.com/15284299

个人博客:
https://kagol.github.io/blogs

小助手微信:opentiny-official

公众号:OpenTiny