1. 内存架构和子系统

1.1 如何控制访问?

image-20241113103538311

访问控制

  • 存储单元的访问是通过
    访问晶体管(access transistors)
    进行控制的。访问晶体管像开关一样,可以连接或断开存储单元和位线(bitline)的连接。
  • 存取控制由
    字线(wordline)
    控制。当字线激活时,访问晶体管开启,允许存储单元的数据流入或流出位线。

DRAM(Dynamic random access memory)的结构

  • DRAM 中的存储单元通常由
    一个电容和一个晶体管组成。电容用来存储数据(1或0)
    ,而晶体管作为访问开关。
  • 由于
    电容会泄漏电荷

    DRAM 需要周期性刷新数据来保持信息的完整性。

SRAM 的结构

  • SRAM 中的存储单元由
    交叉耦合反相器(cross-coupled inverters)
    组成,通常是两个反相器互相连接,形成一个稳定的双稳态结构。
  • SRAM 需要
    4 个晶体管用于存储,2 个晶体管用于访问。
  • SRAM 不需要像 DRAM 那样周期性刷新数据
    ,因为它的电路结构使其能够长时间保持数据,直到被改写。

差异

  • DRAM
    的电路结构相对简单,占用的物理空间小,因此具有更高的存储密度,但需要刷新电路。
  • SRAM
    的电路结构更复杂,占用更多的空间,因此存储密度较低,但其访问速度较快且不需要刷新。

1.2 内存架构

image-20241113104147042

DIMM:
背面和正面装有
DRAM 芯片
的印刷电路板。

Rank:一组 DRAM 芯片
,它们协同工作以响应请求并保持数据总线满载。

64位
数据总线需要
8x8 DRAM 芯片

4x16 DRAM 芯片
...

Bank
:在
一次请求期间
忙碌的一个
rank
的子集。

行缓冲区(Row buffer):
从组中读取的最后一行(如 8 KB),作用类似于
缓存。

image-20241113104732682

通道(Channel)
:每个通道通过命令总线(cmd bus)、地址总线(addr bus)和数据总线(data bus)与处理器相连,用于发送命令、地址和数据。这些总线允许
处理器并行地与多个内存模块进行交互
,从而提高系统的
并行性

内存控制器(MemCtrl)
:处理器通过内存控制器(MemCtrl)来管理对内存的访问。内存控制器负责
内存请求的调度,并通过通道将数据发送到内存的特定区域。

Rank
:在存储器模块中,
每个通道包含一个或多个rank

rank是由多个bank组成的
,它们在处理数据时可以
同时访问

Bank

每个rank包含多个bank,bank是rank的子集,每次访问期间只有一个bank处于繁忙状态。
每个bank可以
独立
进行数据的存储和访问,使得系统能够在
不同的bank之间并行处理数据
,进一步提高内存的效率和带宽。

这种分层结构允许内存系统在
不同的bank和rank之间进行并行访问
,提高了内存带宽和数据处理效率。这种设计被广泛应用于DRAM中,以减少延迟并提高数据吞吐量。

1.3 DRAM 阵列访问

image-20241113105156088

这是一个
\(16Mb\)
的DRAM阵列,即有
\(4096\times4096\)
阵列的bits。

行访问选通 (Row Access Strobe, RAS):
由于该DRAM阵列有4096行,因此有
\(log_{2}^{4096}=12 bits\)
行地址,在访问数据时,
12bits 行地址位最先到达。

列访问选通(Column Access Strobe,CAS):
由于该DRAM阵列有4096列,因此有
\(log_{2}^{4096}=12 bits\)
列地址,在访问数据时,
12bits 列地址位比行地址位后到达。

行访问选通到达后,DRAM读取一行数据(即4096bits)至
行缓冲区(Row buffer)
,随后列访问选通到达,经过
列解码器(Column decoder)
从 Row buffer 读取数据返回 CPU。

1.4 内存 Bank 的组织和运行

image-20241113111737048

读取访问顺序:

  1. 解码行地址并驱动字线(word-lines)
  2. 选定位并驱动位线(bit-lines) - 整行读取
  3. 放大行数据
  4. 解码列地址并选择行的子集 - 发送至输出端
  5. 位线(bit-lines)预充电
    - 用于下一次访问

1.5 DRAM 主存储器

  • 主存储器存储在 DRAM 单元中,其存储密度要高得多

  • DRAM 单元会随着时间的推移而丢失状态 - 必须定期刷新,因此被称为动态存储器

  • DRAM 存取时间长,能源开销大

1.6 DRAM vs. SRAM

DRAM:

  • 访问速度较慢
    (电容器)
  • 密度较高(1T 1C cell)
  • 成本较低
  • 需要刷新
    (功率、性能、电路)
  • 制造时需要将电容器和逻辑器件放在一起

SRAM:

  • 访问速度较快(无电容器)
  • 密度较低
    (6T cell)
  • 成本较高
  • 无需刷新
  • 制造时与逻辑工艺兼容(无电容器)

1.7 Rank 的组织结构

  • DIMM、rank、bank、array
    -> 在存储组织中形成一个层次结构

  • 由于电气限制,
    总线上只能连接几个 DIMM

  • 一个 DIMM 可有 1~4 ranks


  • 提高能效
    ,应使用
    宽输出
    DRAM 芯片 ——
    每次请求只激活
    \(4\times16bits\)
    芯片比激活
    \(16\times 4bits\)
    芯片更好


  • 高容量
    ,应使用
    窄输出
    DRAM 芯片 —— 由于通道上的
    rank 数有限
    ,使用
    \(16 \times 4bits\ 2Gb\)
    芯片比使用
    \(4 \times 16bits\ 2Gb\)
    芯片可提高每个 rank 的容量

1.8 Banks 和 Arrays 的组织结构

  • 一个 rank 被分成多个 banks(4~16 个)
    ,以提高 rank 内的并行性,通过在不同的bank之间进行操作,可以实现并行访问。
  • ranks 和 banks 提供内存级并行性
    ,通过将数据分散在不同的ranks和banks中,内存系统能够同时处理多个内存请求,从而提升内存的并行处理能力。
  • 一个 bank 由多个 arrays(subarrays、tiles、mats)组成
  • 为了最大限度地提高密度,bank 中的 arrays 要做得很大:
    为了在有限空间内存储更多数据,每个 bank 内的 array 被设计得很大。这意味着 array 中的行很宽,因此
    行缓冲区(row buffer)
    也很宽。例如,当内存请求为
    \(64Bytes\)
    时,实际上可能会读取 $8KBytes $的数据(称为
    过量读取(overfetch)
    ),以充分利用宽行缓冲区的特性。
  • 每个array每个周期向输出引脚提供一位数据
    :为了实现更高的存储密度,每个 array 在每个时钟周期内只提供1位数据到输出引脚。这种设计虽然降低了单次传输的数据量,但提高了系统的总存储密度,适合需要存储大量数据的情况。

这种组织方式通过多个层次(ranks、banks、arrays)的划分,实现了高密度的存储,同时也通过并行访问多个banks和ranks,提升了内存系统的并行性和性能。

1.9 行缓冲区(Row Buffers)

  • 每个 bank 都有一个行缓冲器
  • 行缓冲区在DRAM中充当缓存的作用。
    • 行缓冲器命中(Row buffer hit):
      约 20 ns 访问时间(只需将数据从行缓冲区移至引脚)
    • 空行缓冲区访问(Empty row buffer access)
      :约 40ns(首先需要读取array,然后将数据从行缓冲区移到引脚)。
    • 行缓冲区冲突(Row buffer conflict)
      :约 60ns(首先需要预充电 bit-lines,然后读取新行,再将数据移到引脚)。
  • 另外,还需在队列中等待(数十ns),并且还要经历地址/命令/数据传输延迟(约10ns)。

1.10 构建更大的存储器

我们需要更大的存储阵列,但是大阵列意味着访问速度慢。

如何在保证存储器容量大的同时不使其变得非常慢?

Idea:
将存储器划分为更小的阵列
,并将这些阵列与输入/输出总线互连。

  • 大容量存储器通常是分层的阵列结构。
  • DRAM的分层结构:通道(Channel)→ Rank → 存储体 → 子阵列(Subarrays)→ 矩阵(Mats)

2. DRAM 子系统组织

image-20241113120452070

2.1 通用内存结构

image-20241113121111881

上图展示了一个通用的内存结构,主要包括以下组件:

  1. Memory Controller(内存控制器)
    :负责管理内存数据的读写操作。图中显示了两个内存控制器,每个控制器通过一个通道(channel)与内存模块相连。
  2. Channel(通道)
    :内存控制器和内存之间的数据传输通道,允许多个控制器访问不同的内存模块,提高内存的带宽。
  3. DRAM(动态随机存取存储器)
    :DRAM模块通过多个“rank”组织,每个rank又包含若干个“bank”,进一步提高并行性和效率。
  4. Rank
    :内存中的一个逻辑组织单元,由多个物理内存芯片组成,便于内存控制器的访问管理。
  5. Bank
    :内存的更小单位,支持多路并发访问。每个bank内又包含多个行(row)和列(column)。
  6. Row(行)和 Column(列)
    :bank内存储数据的基本单位,通过行和列的地址确定具体的数据位置。
  7. Cache Line(缓存行)
    :CPU读取数据的基本单位,一次读取的数据块大小。通过cache line的设计提高数据传输效率。

2.2 通用原则:交替访问(Banking)

交替访问(Interleaving)(banking 存储库)

  • Problem
    :一个单一的大型内存阵列访问时间长,且无法实现并行的多次访问。
  • Goal
    :降低内存阵列的访问延迟,并实现并行的多次访问。
  • Idea
    :将一个大型阵列划分为多个可以独立访问的存储库(bank),这些 bank 可以在同一个周期或连续的周期内进行访问。
    • 每个 bank 都比整个内存存储要小。
    • 对不同 bank 的访问可以重叠。
    • 访问延迟是可控的。

2.3 内存 Banking 示例

  • 内存分为多个存储库(banks)
    :这些存储库可以独立访问,从而实现并行的多次访问。这种结构将内存划分为多个bank,每个bank可以独立完成数据的读写操作,减少了等待时间。
  • 共享地址和数据总线
    :多个存储库共享地址总线和数据总线,这样设计可以
    减少内存芯片的引脚数量
    ,从而降低硬件复杂度和成本。
  • 每周期完成一个bank的访问
    :通过并行访问不同的bank,在每个周期内可以启动和完成对一个存储库的访问,提升了整体的内存访问效率。
  • 支持N个并发访问
    :如果所有N个访问请求都指向不同的bank,那么系统可以支持N个并发的内存访问请求。这意味着如果内存访问请求均匀分布到不同的bank上,可以充分利用内存带宽,实现高效的并行处理。

image-20241113121924489

  • 图中显示了16个存储库(Bank 0 至 Bank 15)。
  • 每个bank都有其独立的
    内存数据寄存器(MDR)

    内存地址寄存器(MAR)
    ,用于存储正在传输的数据和地址信息。
  • 这些存储库通过数据总线与CPU相连,数据总线负责数据的传输,地址总线负责地址信息的传输。

2.4 DRAM 子系统

image-20241113122454253

图中间的处理器通过两个独立的内存通道与内存模块相连,
每个通道内可以插入多条内存条(DIMM,即双列直插内存模块)
,通道负责将数据传输到内存和处理器之间。

2.4.1 分解 DIMM(模块)

image-20241113123047830

上图中展示了DIMM的
正面

背面

  • Rank 0
    :位于DIMM的正面,由8个芯片组成。
  • Rank 1
    :位于DIMM的背面,也由8个芯片组成。

2.4.2 分解 Rank

image-20241113123439602

每个
Memory channel
包括:

  • Addr/Cmd
    :表示地址和指令信号,用于向不同的Rank发送内存访问请求。
  • CS(Chip Select)
    :选择信号,用于选择哪个Rank进行操作。在图中,CS <0:1> 表示控制信号,选择Rank 0 或 Rank 1。
  • Data <0:63>
    :数据总线,提供64位数据通道,用于传输数据。

image-20241113134945294

一个内存 Rank 内部是由
多个芯片(Chip)
构成的:

Rank 0被分解为多个芯片,从Chip 0到Chip 7。每个芯片负责部分数据位:

  • Chip 0
    负责数据位0到7(<0:7>)。
  • Chip 1
    负责数据位8到15(<8:15>)。
  • 依此类推,直到
    Chip 7
    负责数据位56到63(<56:63>)。

64位数据通道
:所有芯片通过各自负责的8位数据位共同组成了64位的数据通道(Data <0:63>),从而实现并行数据的传输。这种设计允许Rank内多个芯片同时工作,提高了数据访问效率。

2.4.3 分解 Chip

image-20241113135401115

一个内存 Chip 内部是由
多个存储库(Banks)
构成的:

Chip 0
:图的左侧的 Chip 0,它负责8位的数据通道(<0:7>),表示该芯片只传输数据的0到7位。

内部存储单元(Banks)
:在右侧的放大图中,Chip 0进一步被分解为8个独立的存储单元(称为“Banks”)。每个Bank都可以独立地存储和读取数据,允许芯片同时处理多个数据请求,提高数据传输的并行性。

2.4.4 分解 Bank

image-20241113135739648

一个内存 Bank 内部是由
多个大小为 1Byte 的 Arrays
构成的:

  • 先读取一个行,将该行缓存到 Row-buffer 中
  • 再根据 column 地址从 Row-buffer 中取出大小为 1Byte(即 8bits)的数据

2.4.5 深入挖掘:DRAM Bank 操作

第1次访问(Row,Column 0):

Step 1:
Row address 0到达,word-lines 被激活,bit-lines 被连接到感测放大器。

image-20241113141418354

Step 2:
感测放大器感知该行内容,捕捉数据放入 Row Buffer。

image-20241113141741481

Step 3:
Column address 0到达,选择列。

image-20241113142842806

Step 4:
最后数据被读出。

image-20241113142927434

第2次访问(Row 0,Column 1):

由于
Row Buffer HIT
,数据很快被读取出。

image-20241113143304271

第3次访问(Row 0,Column 85):

同样由于
Row Buffer HIT
,数据很快被读取出。

image-20241113143436434

第4次访问(Row 1,Column 0):

出现
Row Buffer Conflict(冲突)

image-20241113143617153

Step 1:

行写回
,即
预充电
,使得下次访问的数据可靠,
增加了延迟

image-20241113143838280

Step 2:
Row address 1到达,word-lines 被激活,bit-lines 被连接到感测放大器。

image-20241113144012392

Step 3:
感测放大器感知该行内容,捕捉数据放入 Row Buffer。

image-20241113144113002

Step 4:
Column address 0到达,选择列。

image-20241113144255737

Step 5:
最后数据被读出。

image-20241113144410131

2.5 DRAM Bank 内部有 Sub-Banks

image-20241113144958401

左图 (a) 是逻辑抽象图,展示了一个 DRAM bank 的传统表示法,其中包含行(row)、行解码器(row-decoder)和一个大的行缓冲区(row-buffer)。在逻辑抽象中,bank 似乎是一个整体,有着多达 32K 行的数据。

右图 (b) 是实际的物理实现。一个 DRAM bank 实际上被分割为多个子阵列 (Subarray),每个子阵列拥有自己的局部行缓冲区 (local row-buffer),并且每个子阵列由512行组成。这里显示了从第1个子阵列到第64个子阵列的结构。这些子阵列通过全局解码器 (global decoder) 连接到整个 bank 的全局行缓冲区 (global row-buffer)。

  • 逻辑抽象
    :在逻辑上,bank 被认为是一个单一的整体结构,所有行共享一个行缓冲区。
  • 物理实现
    :实际上,bank 被划分成多个子阵列以提升访问效率。每个子阵列具有自己的局部行缓冲区,使得在不同子阵列之间可以并行处理数据,以提高并行度和性能。

这种设计通过将 bank 分为多个子阵列来增强访问速度和并行性,从而减少等待时间并提升DRAM性能。

2.6 Example:传输缓存块

image-20241113145432165

  • 传输一个 64B 的高速缓存块需要 8 个 I/O 周期。
  • 在此过程中,8 列按顺序被读取。

image-20241113145505258

Step 1:

image-20241113145609008

Step 2:

image-20241113145631827

Step 3:

image-20241113145705599

Step 4:

image-20241113145733585

3. 内存控制器

3.1 打开/关闭页策略(Open/Closed Page Policies)

  • 如果访问流具有
    局部性
    ,则
    行缓冲区会保持打开状态
    • 行缓冲命中成本低
      (打开页策略(open-page policy))
    • 行缓冲未命中是存储库冲突,代价高昂,因为预充电在关键路径上
  • 如果访问流
    几乎没有局部性
    ,则
    位线(bit-lines)在访问后立即进行预充电(关闭页策略(close-page policy))
    • 几乎每次访问都是行缓冲未命中
    • 预充电通常不在关键路径上
  • 现代内存控制器策略介于这两者之间(通常是专有的)

3.2 读写操作

  • 读取和写入操作使用同一条总线。

  • 在切换读取和写入操作时,必须反转总线方向;这需要时间,并导致总线空闲。

  • 因此,写入操作通常以突发方式进行;写缓冲区会存储待处理的写入,直到达到高水位标记。

  • 写入操作会一直进行,直到达到低水位标记。

  • 高水位标记(High Water Mark)
    :这是缓冲区中数据达到的一个预定的上限。当缓冲区中的数据量达到这个标记时,系统就会触发某些操作,例如开始写入数据(如在存储器中)或停止进一步的数据写入到缓冲区中,以避免缓冲区溢出。

  • 低水位标记(Low Water Mark)
    :这是缓冲区中的数据量达到的一个预定的下限。当缓冲区中的数据量减少到这个标记时,系统可以重新开启数据写入或进行其他操作,确保缓冲区不会因为数据不足而影响性能。

3.3 地址映射策略

  • 可将连续的缓存行放置在
    同一行
    中,以
    提高行缓冲区命中率
  • 可将连续的缓存行放置在
    不同行
    中,以
    提高并行性
  • 地址映射策略示例:
    • row : rank : ​bank : ​channel : column : blkoffset
    • row : column : rank : bank : channel : blkoffset

3.4 调度策略

  • FCFS(先到先服务)
    :处理队列中第一个可以执行的读或写请求。


    • 在FCFS策略中,系统按照请求到达的顺序依次执行。只要一个请求准备好(符合执行条件),就会立即被执行。这个方法简单,但在内存访问中可能
      无法充分利用行缓冲(row buffer)命中
      ,导致性能不高。
  • First Ready - FCFS(优先行缓冲命中 - 先到先服务)
    :优先处理行缓冲命中的请求,如果可能的话。


    • 这个策略首先检查是否有请求可以命中行缓冲(即当前行已经在内存的行缓冲区中)。如果有,就优先处理这些命中请求,因为这样可以
      减少行开销,提高访问速度
      。如果没有行缓冲命中,则按照先到先服务的方式处理。
  • Stall Time Fair(等待时间公平)
    :优先处理行缓冲命中的请求,除非其他线程被忽略了。

  • 这个策略在优先行缓冲命中的基础上增加了公平性。如果多个线程在竞争内存访问,则该策略会在尽量优先行缓冲命中的同时,保证所有线程都能得到公平的访问机会,不让某些线程一直被延迟。这种方法有助于平衡多线程环境下的资源分配。

3.5 刷新(Refresh)

DRAM(动态随机存取存储器)中的每个存储单元都由电容存储电荷来表示数据。由于电容电荷会随时间逐渐泄漏,因此需要定期刷新来补充电荷,以确保数据不丢失。

  • 刷新时间窗口
    :所有DRAM单元必须在64毫秒内刷新一次,防止数据因电荷泄漏而丢失。

  • 自动刷新
    :当对某一行执行读或写操作时,该行会自动进行刷新,帮助延长数据保持时间。

  • 刷新指令的影响
    :每次刷新指令会刷新一定数量的行。在刷新过程中,内存暂时不可用,这可能导致微小的延迟。

  • 刷新频率
    :内存控制器通常会平均每7.8微秒发出一次刷新指令,以分散刷新负担,避免集中刷新带来的性能影响。

【引言】

本文将介绍如何使用鸿蒙NEXT框架开发一个简单的光强仪应用,该应用能够实时监测环境光强度,并给出相应的场景描述和活动建议。

【环境准备】

电脑系统:windows 10

开发工具:DevEco Studio NEXT Beta1 Build Version: 5.0.3.806

工程版本:API 12

真机:mate60 pro

语言:ArkTS、ArkUI

【功能实现】

1. 项目结构

本项目主要由以下几个部分组成:

  • LightIntensityItem 类
    :用于定义光强度范围及其相关信息,包括光强度的起始值、终止值、类型、描述和建议活动。通过构造函数初始化这些属性,便于后续使用。
  • LightIntensityMeter 组件
    :这是光强仪的核心,包含状态管理、传感器初始化和光强度更新等功能。组件使用
    @State
    装饰器来管理当前光强度值和类型,并在组件即将出现时获取传感器列表。
  • 传感器数据处理
    :通过监听环境光传感器的数据,实时更新当前光强度值,并根据光强度范围更新当前类型。这一过程确保了用户能够获得最新的环境光信息。

2. 界面布局

光强仪的用户界面使用了鸿蒙系统的布局组件,包括
Column

Row
。界面展示了当前光强度值和类型,并通过仪表组件直观地显示光强度。用户可以清晰地看到光强度的变化,并获得相应的场景描述和活动建议。

  • 仪表组件
    :用于显示当前光强度值,采用了动态更新的方式,确保用户能够实时看到光强度的变化。
  • 信息展示
    :通过遍历光强度范围列表,展示每个类型的光强度范围、描述和建议活动。这一部分为用户提供了实用的信息,帮助他们根据环境光条件做出相应的决策。

3. 总结

通过本案例,开发者可以学习到如何在鸿蒙系统中使用传感器服务和组件化开发方式,构建一个功能完整的光强仪应用。该应用不仅能够实时监测光强度,还能根据不同的光强度范围提供实用的建议,提升用户体验。

【完整代码】

import { sensor } from '@kit.SensorServiceKit'; // 导入传感器服务套件
import { BusinessError } from '@kit.BasicServicesKit'; // 导入业务错误类

// 定义一个光强度项类,用于存储不同光强度范围的信息
class LightIntensityItem {
  luxStart: number; // 光感强度范围起点
  luxEnd: number; // 光感强度范围终点
  type: string; // 类型
  description: string; // 场景描述
  recommendation: string; // 建议活动

  // 构造函数,初始化对象属性
  constructor(luxStart: number, luxEnd: number, type: string, description: string, recommendation: string) {
    this.luxStart = luxStart;
    this.luxEnd = luxEnd;
    this.type = type;
    this.description = description;
    this.recommendation = recommendation;
  }
}

// 使用装饰器定义组件,该组件是光强度计
@Entry
@Component
struct LightIntensityMeter {
  @State currentType: string = ""; // 当前光强度类型
  @State currentIntensity: number = 0; // 当前光强度值
  @State lightIntensityList: LightIntensityItem[] = [// 不同光强度范围的列表
    new LightIntensityItem(0, 1, '极暗', '夜晚户外,几乎没有光源。', '不宜进行任何活动,适合完全休息。'),
    new LightIntensityItem(1, 10, '很暗', '夜晚室内,只有微弱的灯光或月光。', '只适合睡觉,避免使用电子设备。'),
    new LightIntensityItem(10, 50, '暗', '清晨或傍晚,自然光较弱。', '轻松休闲,避免长时间阅读,适合放松。'),
    new LightIntensityItem(50, 100, '较暗', '白天阴天,室内光线柔和。', '日常生活,短时间阅读,适合轻度活动。'),
    new LightIntensityItem(100, 300, '适中', '白天多云,室内光线适中。', '工作学习,适度阅读,适合大部分室内活动。'),
    new LightIntensityItem(300, 500, '较亮', '白天晴朗,室内光线充足。', '正常工作学习,长时间阅读,适合大部分活动。'),
    new LightIntensityItem(500, 1000, '亮', '阴天室外,自然光较强。', '户外活动,注意防晒,适合户外休闲。'),
    new LightIntensityItem(1000, 100000, '爆表了', '夏季正午直射阳光,自然光极其强烈。',
      '尽可能避免直视太阳,户外活动需戴太阳镜,注意防晒。'),
  ];

  // 当组件即将出现时调用的方法
  aboutToAppear(): void {
    sensor.getSensorList((error: BusinessError) => { // 获取传感器列表
      if (error) { // 如果有错误
        console.error('获取传感器列表失败', error); // 打印错误信息
        return;
      }
      this.startLightIntensityUpdates(); // 没有错误则开始监听光强度变化
    });
  }

  // 开始监听环境光传感器的数据
  private startLightIntensityUpdates(): void {
    sensor.on(sensor.SensorId.AMBIENT_LIGHT, (data) => { // 监听环境光传感器
      console.info(`data.intensity: ${data.intensity}`); // 打印光强度值
      this.currentIntensity = data.intensity; // 更新当前光强度值
      for (const item of this.lightIntensityList) { // 遍历光强度列表
        if (data.intensity >= item.luxStart && data.intensity <= item.luxEnd) { // 判断当前光强度属于哪个范围
          this.currentType = item.type; // 更新当前光强度类型
          break;
        }
      }
    }, { interval: 10000000 }); // 设置传感器更新间隔,单位为纳秒(10000000纳秒=1秒)
  }

  // 组件构建方法
  build() {
    Column() { // 创建一个垂直布局容器
      Text("光强仪")// 显示标题
        .width('100%')// 设置宽度为100%
        .height(44)// 设置高度为44
        .backgroundColor("#fe9900")// 设置背景颜色
        .textAlign(TextAlign.Center)// 设置文本对齐方式为中心
        .fontColor(Color.White); // 设置字体颜色为白色

      Row() { // 创建一个水平布局容器
        Gauge({
          // 创建一个仪表组件
          value: this.currentIntensity > 1000 ? 1000 : this.currentIntensity, // 设置仪表值
          min: 0, // 最小值
          max: 1000 // 最大值
        }) { // 仪表内部布局
          Column() { // 创建一个垂直布局容器
            Text(`${Math.floor(this.currentIntensity)}`)// 显示当前光强度值
              .fontSize(25)// 设置字体大小
              .fontWeight(FontWeight.Medium)// 设置字体粗细
              .fontColor("#323232")// 设置字体颜色
              .height('30%')// 设置高度为父容器的30%
              .textAlign(TextAlign.Center)// 设置文本对齐方式为中心
              .margin({ top: '22.2%' })// 设置上边距
              .textOverflow({ overflow: TextOverflow.Ellipsis })// 设置文本溢出处理方式
              .maxLines(1); // 设置最大行数为1

            Text(`${this.currentType}`)// 显示当前光强度类型
              .fontSize(16)// 设置字体大小
              .fontColor("#848484")// 设置字体颜色
              .fontWeight(FontWeight.Regular)// 设置字体粗细
              .width('47.4%')// 设置宽度为父容器的47.4%
              .height('15%')// 设置高度为父容器的15%
              .textAlign(TextAlign.Center)// 设置文本对齐方式为中心
              .backgroundColor("#e4e4e4")// 设置背景颜色
              .borderRadius(5); // 设置圆角半径
          }.width('100%'); // 设置列宽度为100%
        }
        .startAngle(225) // 设置仪表起始角度
        .endAngle(135) // 设置仪表结束角度
        .height(250) // 设置仪表高度
        .strokeWidth(18) // 设置仪表边框宽度
        .description(null) // 设置描述为null
        .trackShadow({ radius: 7, offsetX: 7, offsetY: 7 }) // 设置阴影效果
        .padding({ top: 30 }); // 设置内边距
      }.width('100%').justifyContent(FlexAlign.Center); // 设置行宽度为100%并居中对齐

      Column() { // 创建一个垂直布局容器
        ForEach(this.lightIntensityList, (item: LightIntensityItem, index: number) => { // 遍历光强度类型数组
          Row() { // 创建一个水平布局容器
            Text(`${item.luxStart}~${item.luxEnd}Lux `)// 显示每个类型的光强度范围
              .fontSize('25lpx')// 设置字体大小
              .textAlign(TextAlign.Start)// 设置文本对齐方式为左对齐
              .fontColor("#3d3d3d")// 设置字体颜色
              .width('220lpx') // 设置宽度

            Text(`${item.description}\n${item.recommendation}`)// 显示每个类型的描述和建议活动
              .fontSize('23lpx')// 设置字体大小
              .textAlign(TextAlign.Start)// 设置文本对齐方式为左对齐
              .fontColor("#3d3d3d")// 设置字体颜色
              .layoutWeight(1) // 设置布局权重
          }.width('660lpx') // 设置行宽度
          .padding({ bottom: 10, top: 10 }) // 设置上下内边距
          .borderWidth({ bottom: 1 }) // 设置下边框宽度
          .borderColor("#737977"); // 设置下边框颜色
        });
      }.width('100%'); // 设置列宽度为100%
    }
    .height('100%') // 设置容器高度为100%
    .width('100%'); // 设置容器宽度为100%
  }
}

说明

该文章是属于OverallAuth2.0系列文章,每周更新一篇该系列文章(从0到1完成系统开发)。

该系统文章,我会尽量说的非常详细,做到不管新手、老手都能看懂。

说明:OverallAuth2.0 是一个简单、易懂、功能强大的权限+可视化流程管理系统。

友情提醒:本篇文章是属于系列文章,看该文章前,建议先看之前文章,可以更好理解项目结构。

qq群:801913255

有兴趣的朋友,请关注我吧(*^▽^*)。

关注我,学不会你来打我

前言

这篇文章有点长,内容丰富,如果你对该文章感兴趣,请耐心观看。

一、什么是路由守卫,它的作用是什么

什么是路由守卫:
它是控制路由菜单访问的一种机制,当一个用户点击一个路由菜单时,那么路由守卫就会对其进行“保护”,常见的守卫方式有

beforeEach:路由菜单访问前守卫。

afterEach:路由菜单访问后守卫。

路由守卫的作用:
了解什么是路由守卫后,其实我们大致可以得出它大致有以下作用。

1、身份认证:在进入模块之前,验证用户身份是否正确,列如:登录是否过期,用户是否登录等。

2、权限控制:控制用户、角色对应模块的访问权限。

3、日志记录:由于路由守卫能监控到用户对于模块访问前和访问后的动作,那么我们可以用来记录用户的访问日志等。

4、数据预加载:在很多时候,些许数据需要在我们访问页面前,加载完成。

5、路由动画:可以在路由访问前后,加载一个过渡动画,提高用户体验。

二、路由守卫的使用

在使用之前,我们需要安装状态管理库和状态持久化插件以及路由加载进度条。它可以共享程序中的一些状态。

1:安装npm install pinia  状态存储库

2:安装npm install pinia-plugin-persistedstate  状态持久化插件

3:安装 npm install nprogress  进度条插件

4:安装 npm install @types/nprogress

书接上一篇:
Vue3中菜单和路由的结合使用,实现菜单的动态切换

创建一个路由文件index.ts,存放在指定文件夹下,由于我写的是从0到1搭建框架,我放在了以下目录中

内容如下:

import { createRouter, createWebHashHistory, NavigationGuardNext, RouteLocationNormalized } from 'vue-router'import { routes }from './module/base-routes'import NProgressfrom 'nprogress'import'nprogress/nprogress.css'NProgress.configure({ showSpinner:false})const router =createRouter({
history: createWebHashHistory(),
//开发环境 routes
})
/**
路由守卫,访问路由菜单前拦截
* @param to 目标
* @param from 来至
*/router.beforeEach((to: RouteLocationNormalized,from: RouteLocationNormalized, next: NavigationGuardNext) =>{
NProgress.start();
if(to.meta.requireAuth) {
next();
}
else if (to.matched.length == 0) {
next({ path:
'/panel'})
}
else{
next();
}
})

router.afterEach(()
=>{
NProgress.done();
})

export
default router

代码解释:

router.beforeEach:就是每次在访问路由前,都会进入的方法,在该方法中,我添加了一个进度条和路由访问后的拍断。
router.afterEach:就是每次在访问路由后,都会进入的方法,在该方法中,添加了一个进度条结束的方法。

然后我们在main中,全局注册路由和状态管理

做好以上这些,路由守卫就完成。

如果,你是按照我的系列文章所搭建的前端框架,那么你要在以下2个文件中,做出改动(没有,请忽略)。

1、在HelloWorld.vue文件中把import router, { routes } from "../router/module/base-routes";替换成import  { routes } from "../router/module/base-routes"; 并加入import router from "../router/index";

2、在base-routes.ts文件中删除以下代码

//创建路由,并且暴露出去
const router =createRouter({
history: createWebHashHistory(),
//开发环境//history:createWebHistory(),//正式环境 routes
})
export
default router

做好这些,我们启动项目,检查每次点击路由菜单,是否进入路由守护拦截中。

明白的伙伴,请抓紧去填充你的内容吧。

三、请求拦截、响应拦截


我们OverallAuth2.0使用的是Vue3+.net8 WebApi创建的项目,所以我们会使用到后端接口。那么我们前端该如何和后端建立数据交互关系?建立关系会该如何处理返回信息?不要着急,耐心往下看。

首先要安装组合式api请求插件axios

安装命令:npm install axios

然后按照下图,新建文件及文件夹

http.ts文件内容如下

import axios, { AxiosResponse, InternalAxiosRequestConfig } from 'axios';//声明模型参数
type TAxiosOption ={
timeout: number;
baseURL:
string;
}
//配置赋值 const config: TAxiosOption ={
timeout:
5000,
baseURL:
"https://localhost:44327/", //本地api接口地址 }classHttp {
service;
constructor(config: TAxiosOption) {
this.service =axios.create(config)/*请求拦截*/ this.service.interceptors.request.use((config: InternalAxiosRequestConfig) =>{//可以在这里做请求拦截处理 如:请求接口前,需要传入的token debugger;returnconfig
}, (error: any)
=>{returnPromise.reject(error);
})
/*响应拦截*/ this.service.interceptors.response.use((response: AxiosResponse<any>) =>{
debugger;
switch(response.data.code) {case 200:returnresponse.data;case 500://这里面可以写错误提示,反馈给前端 returnresponse.data;case 99991:returnresponse.data;case 99992:returnresponse.data;case 99998:returnresponse.data;default:break;
}
}, (error: any)
=>{returnPromise.reject(error)
})
}
/*GET 方法*/ get<T>(url: string, params?: object, _object = {}): Promise<any>{return this.service.get(url, { params, ..._object })
}
/*POST 方法*/post<T>(url: string, params?: object, _object = {}): Promise<any>{return this.service.post(url, params, _object)
}
/*PUT 方法*/put<T>(url: string, params?: object, _object = {}): Promise<any>{return this.service.put(url, params, _object)
}
/*DELETE 方法*/delete<T>(url: string, params?: any, _object = {}): Promise<any>{return this.service.delete(url, { params, ..._object })
}
}

export
default new Http(config)

以上代码关键点都有注释说明

user.ts中的内容如下

import Http from '../http';

export
const TestAutofac =function () {return Http.get('/api/SysUser/TestAutofac');
}

这个就是我们之前搭建后端框架,演示示例的接口地址。

做完以上工作,整个响应拦截和请求拦截的基本代码编写完成,接下来就是测试。

四、测试

在用户界面添加以下代码(也可以新建vue页面)

<template>
  <div>用户</div>
</template>

<script lang="ts">import { defineComponent, onMounted }from "vue";
import { TestAutofac }
from "../../api/module/user";
export
defaultdefineComponent({
setup() {
//初始加载 onMounted(() =>{
TestAutofacMsg();
});
//调用接口 const TestAutofacMsg = async () =>{var result = awaitTestAutofac();
console.log(result);
};
return{};
},
components: {},
});
</script>

点击用户菜单,测试请求拦截是否成功

拦截成功,但出现如下错误(跨域问题)

上面问题是跨域问题导致,我们要在接口端配置跨域地址。

打开之前我们的后端框架,创建如下文件CrossDomainPlugIn.cs,路径和jwt鉴权,全局异常捕获插件放在同一个位置。

 /// <summary>
 ///跨域配置插件/// </summary>
 public static classCrossDomainPlugIn
{
/// <summary> ///跨域/// </summary> /// <param name="services"></param> public static void InitCors(thisIServiceCollection services)
{
//允许一个或多个来源可以跨域 services.AddCors(options =>{
options.AddPolicy(
"Access-Control-Allow-Origin", policy =>{var result = AppSettingsPlugIn.GetNode("CustomCorsPolicy:WhiteList").Split(',');//设定允许跨域的来源,有多个可以用','隔开 policy.WithOrigins(result)
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();
});
});
}
}

然后再Program.cs文件中,添加

//跨域配置
builder.Services.InitCors();和app.UseCors("Access-Control-Allow-Origin");代码

appsettings.json配置中添加如下配置,地址是前端访问地址

/*跨越设置*/
"AllowedHosts": "*",
"CustomCorsPolicy": {
"WhiteList": "http://localhost:8080"
},

启动后端接口,在测试下

以上就是本篇文章的全部内容,感谢耐心观看

后端WebApi
预览地址:http://139.155.137.144:8880/swagger/index.html

前端vue 预览地址:http://139.155.137.144:8881

关注公众号:发送【权限】,获取前后端代码

有兴趣的朋友,请关注我微信公众号吧(*^▽^*)。

关注我:一个全栈多端的宝藏博主,定时分享技术文章,不定时分享开源项目。关注我,带你认识不一样的程序世界

Spring AI + ollama 本地搭建聊天 AI

不知道怎么搭建 ollama 的可以查看上一篇
Spring AI 初学

项目可以查看
gitee

前期准备

添加依赖

创建 SpringBoot 项目,添加主要相关依赖(spring-boot-starter-web、spring-ai-ollama-spring-boot-starter)

Spring AI supports Spring Boot 3.2.x and 3.3.x

Spring Boot 3.2.11 requires at least Java 17 and is compatible with versions up to and including Java 23

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-ollama-spring-boot-starter</artifactId>
    <version>1.0.0-M3</version>
</dependency>

配置文件

application.properties、yml配置文件中添加,也可以在项目中指定模型等参数,具体参数可以参考 OllamaChatProperties

# properties,模型 qwen2.5:14b 根据自己下载的模型而定
spring.ai.ollama.chat.options.model=qwen2.5:14b

#yml
spring:
  ai:
    ollama:
      chat:
        model: qwen2.5:14b

聊天实现

主要使用 org.springframework.ai.chat.memory.ChatMemory 接口保存对话信息。

一、采用 Java 缓存对话信息

支持功能:聊天对话、切换对话、删除对话

controller
import com.yb.chatai.domain.ChatParam;
import jakarta.annotation.Resource;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.InMemoryChatMemory;
import org.springframework.ai.ollama.OllamaChatModel;
import org.springframework.ai.ollama.api.OllamaApi;
import org.springframework.ai.ollama.api.OllamaOptions;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;

import java.util.UUID;

/*
 *@title Controller
 *@description 使用内存进行对话
 *@author yb
 *@version 1.0
 *@create 2024/11/12 14:39
 */
@Controller
public class ChatController {

    //注入模型,配置文件中的模型,或者可以在方法中指定模型
    @Resource
    private OllamaChatModel model;

    //聊天 client
    private ChatClient chatClient;

    // 模拟数据库存储会话和消息
    private final ChatMemory chatMemory = new InMemoryChatMemory();

    //首页
    @GetMapping("/index")
    public String index(){
        return "index";
    }

    //开始聊天,生成唯一 sessionId
    @GetMapping("/start")
    public String start(Model model){
        //新建聊天模型
//        OllamaOptions options = OllamaOptions.builder();
//        options.setModel("qwen2.5:14b");
//        OllamaChatModel chatModel = new OllamaChatModel(new OllamaApi(), options);
        //创建随机会话 ID
        String sessionId = UUID.randomUUID().toString();
        model.addAttribute("sessionId", sessionId);
        //创建聊天client
        chatClient = ChatClient.builder(this.model).defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory, sessionId, 10)).build();
        return "chatPage";
    }

    //聊天
    @PostMapping("/chat")
    @ResponseBody
    public String chat(@RequestBody ChatParam param){
        //直接返回
        return chatClient.prompt(param.getUserMsg()).call().content();
    }

    //删除聊天
    @DeleteMapping("/clear/{id}")
    @ResponseBody
    public void clear(@PathVariable("id") String sessionId){
        chatMemory.clear(sessionId);
    }

}
效果图

gif

二、采用数据库保存对话信息

支持功能:聊天对话、切换对话、删除对话、撤回消息

实体类
import lombok.Data;

import java.util.Date;

@Data
public class ChatEntity {

    private String id;

    /** 会话id */
    private String sessionId;

    /** 会话内容 */
    private String content;

    /** AI、人 */
    private String type;

    /** 创建时间 */
    private Date time;

    /** 是否删除,Y-是 */
    private String beDeleted;

    /** AI会话时,获取人对话ID */
    private String userChatId;

}
configuration
import com.yb.chatai.domain.ChatEntity;
import com.yb.chatai.service.IChatService;
import jakarta.annotation.Resource;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.MessageType;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.context.annotation.Configuration;

import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

/*
 *@title DBMemory
 *@description 实现 ChatMemory,注入 spring,方便采用 service 方法
 *@author yb
 *@version 1.0
 *@create 2024/11/12 16:15
 */
@Configuration
public class DBMemory implements ChatMemory {

    @Resource
    private IChatService chatService;

    @Override
    public void add(String conversationId, List<Message> messages) {
        for (Message message : messages) {
            chatService.saveMessage(conversationId, message.getContent(), message.getMessageType().getValue());
        }
    }

    @Override
    public List<Message> get(String conversationId, int lastN) {
        List<ChatEntity> list = chatService.getLastN(conversationId, lastN);
        if(list != null && !list.isEmpty()) {
            return list.stream().map(l -> {
                Message message = null;
                if (MessageType.ASSISTANT.getValue().equals(l.getType())) {
                    message = new AssistantMessage(l.getContent());
                } else if (MessageType.USER.getValue().equals(l.getType())) {
                    message = new UserMessage(l.getContent());
                }
                return message;
            }).collect(Collectors.<Message>toList());
        }else {
            return new ArrayList<>();
        }
    }

    @Override
    public void clear(String conversationId) {
        chatService.clear(conversationId);
    }
}
services实现类
import com.yb.chatai.domain.ChatEntity;
import com.yb.chatai.service.IChatService;
import org.springframework.ai.chat.messages.MessageType;
import org.springframework.stereotype.Service;

import java.util.*;

/*
 *@title ChatServiceImpl
 *@description 保存用户会话 service 实现类
 *@author yb
 *@version 1.0
 *@create 2024/11/12 15:50
 */
@Service
public class ChatServiceImpl implements IChatService {

    Map<String, List<ChatEntity>> map = new HashMap<>();

    @Override
    public void saveMessage(String sessionId, String content, String type) {
        ChatEntity entity = new ChatEntity();
        entity.setId(UUID.randomUUID().toString());
        entity.setContent(content);
        entity.setSessionId(sessionId);
        entity.setType(type);
        entity.setTime(new Date());
        //改成常量
        entity.setBeDeleted("N");
        if(MessageType.ASSISTANT.getValue().equals(type)){
            entity.setUserChatId(getLastN(sessionId, 1).get(0).getId());
        }
        //todo 保存数据库
        //模拟保存到数据库
        List<ChatEntity> list = map.getOrDefault(sessionId, new ArrayList<>());
        list.add(entity);
        map.put(sessionId, list);
    }

    @Override
    public List<ChatEntity> getLastN(String sessionId, Integer lastN) {
        //todo 从数据库获取
        //模拟从数据库获取
        List<ChatEntity> list = map.get(sessionId);
        return list != null ? list.stream().skip(Math.max(0, list.size() - lastN)).toList() : List.of();
    }

    @Override
    public void clear(String sessionId) {
        //todo 数据库更新 beDeleted 字段
        map.put(sessionId, new ArrayList<>());
    }

    @Override
    public void deleteById(String id) {
        //todo 数据库直接将该 id 数据 beDeleted 改成 Y
        for (Map.Entry<String, List<ChatEntity>> next : map.entrySet()) {
            List<ChatEntity> list = next.getValue();
            list.removeIf(chat -> id.equals(chat.getId()) || id.equals(chat.getUserChatId()));
        }
    }
}
controller
import com.yb.chatai.configuration.DBMemory;
import com.yb.chatai.domain.ChatEntity;
import com.yb.chatai.domain.ChatParam;
import com.yb.chatai.service.IChatService;
import jakarta.annotation.Resource;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.ollama.OllamaChatModel;
import org.springframework.ai.ollama.api.OllamaApi;
import org.springframework.ai.ollama.api.OllamaOptions;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.UUID;

/*
 *@title ChatController2
 *@description 使用数据库(缓存)进行对话
 *@author yb
 *@version 1.0
 *@create 2024/11/12 16:12
 */
@Controller
public class ChatController2 {

    //注入模型,配置文件中的模型,或者可以在方法中指定模型
    @Resource
    private OllamaChatModel model;

    //聊天 client
    private ChatClient chatClient;

    //操作聊天信息service
    @Resource
    private IChatService chatService;

    //会话存储方式
    @Resource
    private DBMemory dbMemory;

    //开始聊天,生成唯一 sessionId
    @GetMapping("/start2")
    public String start(Model model){
        //新建聊天模型
//        OllamaOptions options = OllamaOptions.builder();
//        options.setModel("qwen2.5:14b");
//        OllamaChatModel chatModel = new OllamaChatModel(new OllamaApi(), options);
        //创建随机会话 ID
        String sessionId = UUID.randomUUID().toString();
        model.addAttribute("sessionId", sessionId);
        //创建聊天 client
        chatClient = ChatClient.builder(this.model).defaultAdvisors(new MessageChatMemoryAdvisor(dbMemory, sessionId, 10)).build();
        return "chatPage2";
    }

    //切换会话,需要传入 sessionId
    @GetMapping("/exchange2/{id}")
    public String exchange(@PathVariable("id")String sessionId){
        //切换聊天 client
        chatClient = ChatClient.builder(this.model).defaultAdvisors(new MessageChatMemoryAdvisor(dbMemory, sessionId, 10)).build();
        return "chatPage2";
    }

    //聊天
    @PostMapping("/chat2")
    @ResponseBody
    public List<ChatEntity> chat(@RequestBody ChatParam param){
        //todo 判断 AI 是否返回会话,从而判断用户是否可以输入
        chatClient.prompt(param.getUserMsg()).call().content();
        //获取返回最新两条,一条用户问题(用户获取用户发送ID),一条 AI 返回结果
        return chatService.getLastN(param.getSessionId(), 2);
    }

    //撤回消息
    @DeleteMapping("/revoke2/{id}")
    @ResponseBody
    public void revoke(@PathVariable("id") String id){
        chatService.deleteById(id);
    }

    //清空消息
    @DeleteMapping("/del2/{id}")
    @ResponseBody
    public void clear(@PathVariable("id") String sessionId){
        dbMemory.clear(sessionId);
    }

}
效果图

db

总结

主要实现 org.springframework.ai.chat.memory.ChatMemory 方法,实际项目过程需要实现该接口重写方法。

我在前面介绍的系统界面功能,包括菜单工具栏、业务表的数据,开始的时候,都是基于模拟的数据进行测试,数据采用JSON格式处理,通过辅助类的方式模拟实现数据的加载及处理,这在开发初期是一个比较好的测试方式,不过实际业务的数据肯定是来自后端,包括本地数据库,SqlServer、Mysql、Oracle、Sqlite、PostgreSQL等,或者后端的WebAPI接口获取,本篇随笔逐步介绍如何对后端的数据接口进行建模以及提供本地WebAPI代理接口类的处理过程。

1、定义Web API接口类并测试API调用基类

我在随笔《
使用wxpython开发跨平台桌面应用,动态工具的创建处理
》中介绍了关于工具栏和菜单栏的数据类,以及模拟方式获得数据进行展示,如下界面所示。

如菜单数据的类信息,如下所示。

classMenuInfo:
id: str
#菜单ID pid: str #父菜单ID label: str #菜单名称 icon: str = None #菜单图标 path: str = None #菜单路径,用来定位视图 tips: str = None #菜单提示 children: list["MenuInfo"] = None

这些数据和后端数据接口的定义一致,那么就很容易切换到动态的接口上。

在系统开发的初期,我们可以先试用模拟方式获得数据集合,如通过一个工具来来获得数据,如下所示。

为了更好的符合实际的业务需求,我们往往需要根据服务端的接口定义来定义调用Web API接口的信息。

我们为了全部采用Python语言进行开发,包括后端的内容,采用
基于SqlAlchemy+Pydantic+FastApi
的后端框架

该后端接口采用统一的接口协议,标准协议如下所示。

{"success": false,"result":  T ,"targetUrl": "string","UnAuthorizedRequest": false,"errorInfo": {"code": 0,"message": "string","details": "string"}
}

其中的result是我们的数据返回,有可能是基本类型(如字符串、数值、布尔型等),也有可能是类集合,对象信息,字典信息等等。

如果是分页查询返回结果集合,其结果如下所示。

展开单条记录明细如下所示。

如果我们基于Pydantic模型定义,我们的Python对象类定义代码如下所示

from pydantic importBaseModelfrom typing importGeneric, Type, TypeVar, Optional
T
= TypeVar("T")#自定义返回模型-统一返回结果 classAjaxResponse(BaseModel, Generic[T]):
success: bool
=False
result: Optional[T]
=None
targetUrl: Optional[str]
=None
UnAuthorizedRequest: Optional[bool]
=False
errorInfo: Optional[ErrorInfo]
= None

也就是结合泛型的方式,这样定义可以很好的抽象不同的业务类接口到基类BaseApi中,这样增删改查等处理的接口都可以抽象到BaseApi里面了。

权限模块我们涉及到的用户管理、机构管理、角色管理、菜单管理、功能管理、操作日志、登录日志等业务类,那么这些类继承BaseApi,就会具有相关的接口了,如下所示继承关系。

2、对异步调用进行测试和接口封装

为了理解客户端Api类的处理,我们先来介绍一些简单的pydantic 入门处理,如下我们先定义一些实体类用来承载数据信息,如下所示。

from typing importList, TypeVar, Optional, Generic, Dict, Anyfrom datetime importdatetimefrom pydantic importBaseModel, Field
T
= TypeVar("T")classAjaxResult(BaseModel, Generic[T]):"""测试统一接口返回格式"""success: bool=True
message: Optional[str]
=None
result: Optional[T]
=NoneclassPagedResult(BaseModel, Generic[T]):"""分页查询结果"""total: int
items: List[T]
classCustomer(BaseModel):"""客户信息类"""name: str
age: int

一般业务的结果是对应的记录列表,或者实体类对象格式,我们先来测试解析下它们的JSON数据,有助于我们理解。

#对返回结果数据格式的处理
json_data = """{
"total": 100,
"items": [
{"name": "Alice", "age": 25},
{"name": "Bob", "age": 30},
{"name": "Charlie", "age": 35}
]
}
"""paged_result=PagedResult.model_validate_json(json_data)print(paged_result.total)print(paged_result.items)

以上正常解析到数据,输出结果如下所示。

100[{'name': 'Alice', 'age': 25}, {'name': 'Bob', 'age': 30}, {'name': 'Charlie', 'age': 35}]
True

如果我们换为统一返回的结果进行测试,如下所示。

json_data = """{
"success": true,
"message": "success",
"result": {
"total": 100,
"items": [
{"name": "Alice", "age": 25},
{"name": "Bob", "age": 30},
{"name": "Charlie", "age": 35}
]
}
}
"""ajax_result=AjaxResult[PagedResult].model_validate_json(json_data)print(ajax_result.success)print(ajax_result.message)print(ajax_result.result.total)print(ajax_result.result.items)

同样的可以获得正常的输出。

True
success
100[{'name': 'Alice', 'age': 25}, {'name': 'Bob', 'age': 30}, {'name': 'Charlie', 'age': 35}]

我们通过 model_validate_json 接口可以转换字符串内容为对应的业务类对象,而通过 model_validate 函数可以转换JSON格式为业务类对象。

而对于接口的继承处理,我们采用了泛型的处理,可以极大的减少基类代码的编写,如下基类定义和子类定义,就可以简单很多,所有逻辑放在基类处理即可。

classBaseApi(Generic[T]):def test(self) ->AjaxResult[Dict[str, Any]]:
json_data
= """{
"success": true,
"message": "success",
"result": {"name": "Alice", "age": 25}
}
"""result=AjaxResult[Dict[str, Any]].model_validate_json(json_data)returnresultdef get(self, id: int) ->AjaxResult[T]:
json_data
= """{
"success": true,
"message": "success",
"result": {"name": "Alice", "age": 25}
}
"""result=AjaxResult[T].model_validate_json(json_data)returnresultdef getlist(self) ->AjaxResult[List[T]]:
json_data
= """{
"success": true,
"message": "success",
"result": [
{"name": "Alice", "age": 25},
{"name": "Bob", "age": 30},
{"name": "Charlie", "age": 35}
]
}
"""result=AjaxResult[List[T]].model_validate_json(json_data)returnresultclass UserApi(BaseApi[Customer]):passuser_api=UserApi()
result
=user_api.getlist()print(result.success)print(result.message)print(result.result)

result
= user_api.get(1)print(result.success)print(result.message)print(result.result)

result
=user_api.test()print(result.success)print(result.message)print(result.result)

可以看到,子类只需要明确好继承关系即可,不需要编写任何多余的代码,但是又有了具体的接口处理。

3、实际HTTTP请求的封装处理

一般对于服务端接口的处理,我们可能需要引入 aiohttp 来处理请求,并结合Pydantic的模型处理,是的数据能够正常的转换,和上面的处理方式一样。

首先我们需要定义一个通用HTTP请求的类来处理常规的HTTP接口数据的返回,如下所示。

classApiClient:
_access_token
= None #类变量,用于全局共享 access_token @classmethoddefset_access_token(cls, token):"""设置全局 access_token"""cls._access_token=token

@classmethod
defget_access_token(cls):"""获取全局 access_token""" returncls._access_tokendef_get_headers(self):
headers
={}ifself.get_access_token():
headers[
"Authorization"] = f"Bearer {self.get_access_token()}" returnheaders

async
def get(self, url, params=None):
async with aiohttp.ClientSession() as session:
async with session.get(
url, headers
=self._get_headers(), params=params
) as response:
returnawait self._handle_response(response)

async
def post(self, url, json_data=None):
async with aiohttp.ClientSession() as session:
async with session.post(
url, headers
=self._get_headers(), json=json_data
) as response:
returnawait self._handle_response(response)

async
def put(self, url, json_data=None):
async with aiohttp.ClientSession() as session:
async with session.put(
url, headers
=self._get_headers(), json=json_data
) as response:
returnawait self._handle_response(response)

async
def delete(self, url, params=None):
async with aiohttp.ClientSession() as session:
async with session.delete(
url, headers
=self._get_headers(), params=params
) as response:
returnawait self._handle_response(response)

async
def_handle_response(self, response):if response.status == 200:returnawait response.json()else:
response.raise_for_status()

这些我来基于通用ApiClient的辅助类,对业务接口的调用进行一个简单基类的封装,命名为BaseApi,接受泛型类型定义,如下所示。

classBaseApi(Generic[T]):
base_url
= "http://jsonplaceholder.typicode.com/"client: ApiClient=ApiClient()

async
def getall(self, endpoint, params=None) ->List[T]:
url
= f"{self.base_url}{endpoint}"json_data= await self.client.get(url, params=params)#print(json_data) returnlist[T](json_data)

async
def get(self, endpoint, id) ->T:
url
= f"{self.base_url}{endpoint}/{id}"json_data=await self.client.get(url)#return parse_obj_as(T,json_data) adapter =TypeAdapter(T)returnadapter.validate_python(json_data)

async
def create(self, endpoint, data) ->bool:
url
= f"{self.base_url}{endpoint}"await self.client.post(url, data)returnTrue

async
def update(self, endpoint, id, data) ->T:
url
= f"{self.base_url}{endpoint}/{id}"json_data=await self.client.put(url, data)

adapter
=TypeAdapter(T)returnadapter.validate_python(json_data)

async
def delete(self, endpoint, id) ->bool:
url
= f"{self.base_url}{endpoint}/{id}"json_data=await self.client.delete(url)#print(json_data) return True

我这里使用了一个 测试API接口很好的网站:
https://jsonplaceholder.typicode.com/
,它提供了很多不同业务对象的接口信息,如下所示。

统一提供GET/POST/PUT/DELETE等常规Restful动作的处理

如我们获取列表数据的接口如下,返回对应的JSON集合。

通过对应的业务对象不同的动作处理,我们可以测试各种接口。

注意,我们上面的接口都是采用了async/awati的对应异步标识来处理异步的HTTP接口请求。

上面我们定义了BaseApi,具有常规的getall/get/create/update/delete的接口,实际开发的时候,这些会根据后端接口请求扩展更多基类接口。

基于基类BaseApi定义,我们创建其子类PostApi,用来获得具体的对象定义接口。

classPostApi(BaseApi[post]):#该业务接口类,具有基类所有的接口

    #并增加一个自定义的接口
    async def test(self) ->Db:
url
= "http://my-json-server.typicode.com/typicode/demo/db"json_data=await self.client.get(url)#print(json_data) return Db.model_validate(json_data)

这里PostApi 具有基类所有的接口:getall/get/create/update/delete的接口, 并可以根据实际情况增加自定义接口,如test 接口定义。

测试代码如下所示。

async defmain():post_api =PostApi()
result
= await post_api.getall("posts")print(len(result))

result
= await post_api.get("posts", 1)print(result)

result
=await post_api.create("posts", {"title": "test", "body": "test body", "userId": 1}
)
print(result)

result
=await post_api.update("posts", 1, {"title": "test2", "body": "test body2", "userId": 1, "id": 1}
)
print(result)

result
= await post_api.delete("posts", 1)print(result)

result
=await post_api.test()print(result)if __name__ == "__main__":
asyncio.run(main())

运行例子,输出如下结果。