2024年8月

CUDA常见编译器配置问题一览

关注TechLead,复旦博士,分享云服务领域全维度开发技术。拥有10+年互联网服务架构、AI产品研发经验、团队管理经验,复旦机器人智能实验室成员,国家级大学生赛事评审专家,发表多篇SCI核心期刊学术论文,阿里云认证的资深架构师,上亿营收AI产品研发负责人。

file

编译器配置问题

正确配置编译器是确保CUDA程序顺利编译和运行的关键步骤。在Linux系统中,编译器配置问题常常会导致编译错误和性能问题。本文将详细列举常见的编译器配置问题及其解决方案,帮助正确配置和使用CUDA编译器。

编译器版本不兼容

问题描述

  • CUDA与GCC版本不兼容
    :不同版本的CUDA Toolkit与GCC编译器有特定的兼容要求。如果使用不兼容的GCC版本,可能导致编译错误。
  • 默认GCC版本不符合要求
    :系统默认安装的GCC版本不符合CUDA的要求,导致编译失败。

解决方案

gcc --version
  • 安装兼容版本的GCC
    :根据CUDA Toolkit的要求,安装兼容版本的GCC。
sudo apt-get install gcc-<version> g++-<version>
  • 切换GCC版本
    :使用update-alternatives工具切换到指定版本的GCC。
sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-<version> 60
sudo update-alternatives --install /usr/bin/g++ g++ /usr/bin/g++-<version> 60
sudo update-alternatives --config gcc
sudo update-alternatives --config g++

编译选项配置错误

问题描述

  • 编译选项设置不当
    :在编译CUDA程序时,未正确配置编译选项,导致编译失败或生成的二进制文件性能不佳。
  • nvcc编译器参数配置问题
    :nvcc编译器的参数配置错误,导致编译过程中出现问题。

解决方案

  • 正确设置编译选项
    :根据具体需求设置适当的编译选项,例如优化选项和调试选项。
nvcc -O3 -arch=sm_<compute_capability> -o my_program my_program.cu
  • 使用Makefile管理编译选项
    :通过Makefile集中管理编译选项,确保配置的统一和简化。
CUDA_PATH ?= /usr/local/cuda
NVCC := $(CUDA_PATH)/bin/nvcc
TARGET := my_program
SRC := my_program.cu

$(TARGET): $(SRC)
    $(NVCC) -O3 -arch=sm_<compute_capability> -o $@ $^

clean:
    rm -f $(TARGET)

动态库和链接问题

问题描述

  • 动态库无法找到
    :在编译和运行CUDA程序时,系统无法找到所需的动态库,导致链接错误或运行时错误。
  • 链接选项配置错误
    :编译时未正确配置链接选项,导致链接失败。

解决方案

  • 设置LD_LIBRARY_PATH环境变量
    :确保LD_LIBRARY_PATH包含CUDA库路径。
export LD_LIBRARY_PATH=/usr/local/cuda/lib64${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}
  • 在Makefile中配置链接选项
    :在Makefile中明确指定链接选项,确保正确链接CUDA库。
LDFLAGS := -L/usr/local/cuda/lib64 -lcudart -lcublas -lcurand

编译过程中的常见错误

问题描述

  • 未定义引用错误
    :编译时出现未定义引用错误,通常是由于未正确链接所需的库。
  • 编译器内部错误
    :编译过程中出现编译器内部错误,可能是由于编译器或驱动程序的bug。

解决方案

  • 检查链接选项
    :确保编译时正确链接所有所需的库。
nvcc -o my_program my_program.cu -lcudart -lcublas -lcurand
  • 更新编译器和驱动程序
    :确保使用最新版本的编译器和驱动程序,避免已知的bug。
sudo apt-get update
sudo apt-get install gcc g++
sudo apt-get upgrade nvidia-driver-<version>

交叉编译问题

问题描述

  • 交叉编译配置错误
    :在交叉编译CUDA程序时,未正确配置交叉编译环境,导致编译失败。
  • 目标平台库缺失
    :交叉编译时,目标平台所需的库文件缺失,导致链接错误。

解决方案

  • 正确配置交叉编译环境
    :设置交叉编译工具链和目标平台库路径。
export CROSS_COMPILE=<cross-compiler-prefix>
export SYSROOT=<target-sysroot-path>
  • 使用CMake管理交叉编译
    :通过CMake脚本集中管理交叉编译配置。
cmake_minimum_required(VERSION 3.10)
project(MyCUDAProject)

set(CMAKE_C_COMPILER ${CROSS_COMPILE}gcc)
set(CMAKE_CXX_COMPILER ${CROSS_COMPILE}g++)
set(CMAKE_SYSROOT ${SYSROOT})

find_package(CUDA REQUIRED)
include_directories(${CUDA_INCLUDE_DIRS})
link_directories(${CUDA_LIBRARIES})

add_executable(my_program my_program.cu)
target_link_libraries(my_program ${CUDA_LIBRARIES})

通过以上方法,可以有效解决在Linux系统中编译器配置问题,确保CUDA程序的正确编译和高效运行。

如有帮助,请多关注
TeahLead KrisChang,10+年的互联网和人工智能从业经验,10年+技术和业务团队管理经验,同济软件工程本科,复旦工程管理硕士,阿里云认证云服务资深架构师,上亿营收AI产品业务负责人。

Opentelemetry collector用法

image

Opentelemetry collector包含如下几个
组件

  • receiver
  • processor
  • exporter
  • connector
  • Service

注意这里只是定义了各个组件,若要真正生效,则需要将其添加到
service
中.

官方的
opentelemetry-collector

opentelemetry-collector-contrib
两个库给出了大量Collector组件实现。前者是opentelemetry-collector的
核心
配置,用于提供vendor无关的collector配置,后者则由
不同的vendor提供
,如aws、aure、kafka等。在使用时可以通过结合二者功能来满足业务需求。另外值得注意的是,两个仓库的各个
组件目录
中都提供了
README.md
帮助文档,如
otlpreceiver

prometheusremotewriteexporter
等。

Service

service字段用于组织启用receivers, processors, exporters和 extensions 组件。一个service包含如下子字段:

  • Extensions
  • Pipelines
  • Telemetry:支持配置
    metric

    log

    • 默认情况下,opentelemetry会在
      http://127.0.0.1:8888/metrics
      下暴露metrics,可以通过
      telemetry.metrics.address
      指定暴露metrics的地址。可以使用
      level
      字段控制暴露的metrics数(这里给出了各个level下的
      metrics
      ):


      • none
        : 不采集遥测数据
      • basic
        : 采集基本的遥测数据
      • normal
        : 默认级别,在basic之上增加标准的遥测数据
      • detailed
        : 最详细的级别,包括dimensions 和 views.
    • log的默认级别为
      INFO
      ,支持
      DEBUG

      WARN

      ERROR

Extensions

可以使用extensions实现Collector的认证、健康监控、服务发现或数据转发等。大部分extensions都有默认配置。

service:
  extensions: [health_check, pprof, zpages]
  telemetry:
    metrics:
      address: 0.0.0.0:8888
      level: normal

healthcheckextension

可以为pod的probe提供健康检查:

    extensions:
      health_check:
        endpoint: ${env:MY_POD_IP}:13133

Pipelines

一个pipeline包含receivers、processors和exporters集,相同的receivers、processors和exporters可以放到多个pipeline中。

配置pipeline,类型为:

  • traces
    : 采集和处理trace数据
  • metrics
    :采集和处理metric数据
  • logs
    :采集和处理log数据

注意processors的位置顺序决定了其处理顺序。

service:
  pipelines:
    metrics:
      receivers: [opencensus, prometheus]
      processors: [batch]
      exporters: [opencensus, prometheus]
    traces:
      receivers: [opencensus, jaeger]
      processors: [batch, memory_limiter]
      exporters: [opencensus, zipkin]

下面主要介绍几种常见的组件配置。

receiver

用于接收遥测数据。

可以通过
<receiver type>/<name>
为一类receiver配置多个receivers,确保receiver的名称唯一性。collector中至少需要配置一个receiver。

receivers:
  # Receiver 1.
  # <receiver type>:
  examplereceiver:
    # <setting one>: <value one>
    endpoint: 1.2.3.4:8080
    # ...
  # Receiver 2.
  # <receiver type>/<name>:
  examplereceiver/settings:
    # <setting two>: <value two>
    endpoint: 0.0.0.0:9211

OTLP Receiver

使用
OTLP
格式接收gRPC或HTTP流量,这种为
push模式
,即需要client将遥测数据push到opentelemetry:

receivers:
  otlp:
    protocols:
      grpc:
      http:

k8s下可以使用如下方式定义otlp receiver:

receivers:
  otlp:
    protocols:
      grpc:
        endpoint: ${env:MY_POD_IP}:4317 #定义接收grpc数据格式的server
      http:
        endpoint: ${env:MY_POD_IP}:4318 #定义接收http数据格式的server

Receiver本身支持push和pull模式,如
haproxyreceiver
就是pull模式:

receivers:
  haproxy:
    endpoint: http://127.0.0.1:8080/stats
    collection_interval: 1m
    metrics:
      haproxy.connection_rate:
        enabled: false
      haproxy.requests:
        enabled: true

prometheus receiver

prometheusreceiver支持使用prometheus 的方式
pull
metrics数据,但需要注意的是,该方式
目前处于开发阶段

官方给出了
注意事项

不支持的特性

receivers:
    prometheus:
      config:
        scrape_configs:
          - job_name: 'otel-collector'
            scrape_interval: 5s
            static_configs:
              - targets: ['0.0.0.0:8888']
          - job_name: k8s
            kubernetes_sd_configs:
            - role: pod
            relabel_configs:
            - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape]
              regex: "true"
              action: keep
            metric_relabel_configs:
            - source_labels: [__name__]
              regex: "(request_duration_seconds.*|response_duration_seconds.*)"
              action: keep

filelog receiver

filelog-receiver
用于从文件中采集日志。

Processor

根据各个processor定义的规则或配置来修改或转换receiver采集的数据,如过滤、丢弃、重命名等操作。Processors的执行顺序取决于
service.pipelines
中定义的Processors顺序。推荐的processors如下:

  • memory_limiter
  • sampling processors 或初始的filtering processors
  • 依赖
    Context
    发送源的processor,如
    k8sattributes
  • batch
  • 其他processors

数据归属

image

由于一个receiver可能会附加到多个pipelines上,因此可能存在多个processors同时处理来自同一个receiver的数据,这里涉及到
数据归属权
的问题。从pipelines的角度看有两种数据归属模式:

  • 独占数据:这种模式下,pipeline会复制从receiver接收到的数据,各个pipeline之间不会相互影响。
  • 共享数据:这种模式下,pipeline不会复制从receiver接收到的数据,多个pipeline共享同一份数据,且数据是
    只读
    的,无法修改。可以通过设置
    MutatesData=false
    来避免独占模式下的数据拷贝。

注意:在官方的
文档
中有如下警告,即当多个pipelines引用了同一个receiver时,只能保证各个pipeline的数据是独立的,但由于整个流程使用的是同步调用方式,因此如果一个pipeline阻塞,则会导致其他使用使用相同receiver的pipelines也被阻塞

Important

When the same receiver is referenced in more than one pipeline, the Collector creates only one receiver instance at runtime that sends the data to a fan-out consumer. The fan-out consumer in turn sends the data to the first processor of each pipeline. The data propagation from receiver to the fan-out consumer and then to processors is completed using a synchronous function call. This means that if one processor blocks the call, the other pipelines attached to this receiver are blocked from receiving the same data, and the receiver itself stops processing and forwarding newly received data.

memory limiter processor

用于防止collector OOM。该processor会周期性地检查内存情况,如果使用的内存大于设置的阈值,则执行一次
runtime.GC()

memorylimiterprocessor
有两个阈值:
soft limit

hard limit
。当内存用量超过
soft limit
时,processor将拒绝接收数据并返回错误(因此要求能够重试发生数据,否则会有数据丢失),直到内存用量低于
soft limit
;如果内存用量超过
hard limit
,则会强制执行一次GC。

推荐将
memorylimiterprocessor
设置为第一个processor

。设置参数如下:

  • check_interval
    (默认 0s): 内存检查周期,推荐值为1s。如果Collector内存有尖刺,则可以降低
    check_interval
    或增加
    spike_limit_mib
    ,以避免内存超过
    hard limit
  • limit_mib
    (默认 0): 定义
    hard limit
    ,进程堆申请的最大内存值,单位MiB。注意,通常总内存会高于该值约50MiB。
  • spike_limit_mib
    (默认为20%的
    limit_mib
    ): 测量内存使用之间预期的最大峰值,必须小于
    limit_mib
    .。
    soft limit
    等于 (
    limit_mib
    -
    spike_limit_mib
    ),
    spike_limit_mib
    的推荐值为20%
    limit_mib
  • limit_percentage
    (默认 0): 通过百分比来定义进程堆申请的最大内存,其优先级低于
    limit_mib
  • spike_limit_percentage
    (默认 0): 通过百分比来测量内存使用之间预期的最大峰值,只能和
    limit_percentage
    配合使用。

使用方式如下:

processors:
  memory_limiter:
    check_interval: 1s
    limit_mib: 4000
    spike_limit_mib: 800
processors:
  memory_limiter:
    check_interval: 1s
    limit_percentage: 50
    spike_limit_percentage: 30

batch processor

batch processor可以接收spans, metrics,或logs,通过压缩数据来降低数据传输所需的连接。

推荐在每个Collector上都配置batch processor,并将其放到
memory_limiter
和sampling processors之后

。有根据大小和间隔时间两种batch发送模式。

配置参数如下:

  • send_batch_size
    (默认 8192): 定义发送batch的(spans, metric data points或 log records的)数目,超过该数值后会发送一个batch。
  • timeout
    (默认 200ms): 定义发送batch的超时时间,超过该时间会发送一个batch。如果设置为0,则会忽略
    send_batch_size
    并只根据
    send_batch_max_size
    来发送数据。
  • send_batch_max_size
    (默认 0): batch的大小上限,必须大于或等于
    send_batch_size

    0
    表示没有上限。
  • metadata_keys
    (默认为空): 如果设置了该值,则processor会为每个不同组合的
    client.Metadata
    值创建一个batcher实例。
    注意
    使用元数据执行batch会增加batch所需要的内存。
  • metadata_cardinality_limit
    (默认1000): 当
    metadata_keys
    非空,该值限制了需要处理的metadata key的组合数目。

下面定义了一个默认的batch processor和一个自定义的batch processor。
注意这里只是声明,若要生效还需在
service
中引用。

processors:
  batch:
  batch/2:
    send_batch_size: 10000
    timeout: 10s

attributes processor
&&
Resource Processor

Resource Processor可以看作是attributes processor的子集,用于修改资源( span、log、metric)属性。

attributes processor有两个主要功能:
修改资源属性
,以及
数据过滤
。通常用于修改资源属性,数据过滤可以考虑使用
filterprocessor

下面是常见的修改资源属性的方式,类似prometheus修改label。更多参见官方
例子

processors:
  attributes/example:
    actions:
      - key: db.table
        action: delete
      - key: redacted_span
        value: true
        action: upsert
      - key: copy_key
        from_attribute: key_original
        action: update
      - key: account_id
        value: 2245
        action: insert
      - key: account_password
        action: delete
      - key: account_email
        action: hash
      - key: http.status_code
        action: convert
        converted_type: int

filter processor

用于丢弃Collector采集的spans、span events、metrics、datapoints和logs。filterprocessor会使用OTTL语法来创建是否需要
丢弃
遥测数据的conditions,如果匹配任意condition,则丢弃。

traces.span Span
traces.spanevent SpanEvent
metrics.metric Metric
metrics.datapoint DataPoint
logs.log_record Log

如下面
丢弃
所有HTTP spans:

processors:
  filter:
    error_mode: ignore
    traces:
      span:
        - attributes["http.request.method"] == nil

此外filter processor还支持
OTTL Converter functions
。如

# Drops metrics containing the 'bad.metric' attribute key
filter/keep_good_metrics:
  error_mode: ignore
  metrics:
    metric:
      - 'HasAttrKeyOnDatapoint("bad.metric")'

k8s attributes processor

该processor可以自动发现k8s资源,然后将所需的metadata信息注入span、metrics和log中,作为
resources
属性。

k8sattributesprocessor在接收到数据(log, trace or metric)时,会尝试将数据和pod进行匹配,如果匹配成功,则将相关的pod metadata注入该数据。默认情况下,k8sattributesprocessor使用入站的连接IP 和Pod IP进行关联,但也可以通过
resource_attribute
自定义关联方式:

每条规则包含一对
from
(表示规则类型)和
name
(如果
from

resource_attribute
,则表示属性名称)。

from
有两种类型:

  • connection
    :使用连接上下午中的IP属性匹配数据。
    使用此类型时,该processor必须位于任何batching或tail sampling之前
  • resource_attribute
    :从接收的资源中指定用于匹配数据的属性。只能使用metadata的属性。
pod_association:
  # below association takes a look at the datapoint's k8s.pod.ip resource attribute and tries to match it with
  # the pod having the same attribute.
  - sources:
      - from: resource_attribute
        name: k8s.pod.ip
  # below association matches for pair `k8s.pod.name` and `k8s.namespace.name`
  - sources:
      - from: resource_attribute
        name: k8s.pod.name
      - from: resource_attribute
        name: k8s.namespace.name

默认情况下会提取并添加如下属性,可以通过
metadata
修改默认值:

  • k8s.namespace.name
  • k8s.pod.name
  • k8s.pod.uid
  • k8s.pod.start_time
  • k8s.deployment.name
  • k8s.node.name

k8sattributesprocessor支持从pods、namespaces和nodes的
labels

annotations
上提取(
extract
)资源属性。

extract:
  annotations:
    - tag_name: a1 # extracts value of annotation from pods with key `annotation-one` and inserts it as a tag with key `a1`
      key: annotation-one
      from: pod
    - tag_name: a2 # extracts value of annotation from namespaces with key `annotation-two` with regexp and inserts it as a tag with key `a2`
      key: annotation-two
      regex: field=(?P<value>.+)
      from: namespace
    - tag_name: a3 # extracts value of annotation from nodes with key `annotation-three` with regexp and inserts it as a tag with key `a3`
      key: annotation-three
      regex: field=(?P<value>.+)
      from: node
  labels:
    - tag_name: l1 # extracts value of label from namespaces with key `label1` and inserts it as a tag with key `l1`
      key: label1
      from: namespace
    - tag_name: l2 # extracts value of label from pods with key `label2` with regexp and inserts it as a tag with key `l2`
      key: label2
      regex: field=(?P<value>.+)
      from: pod
    - tag_name: l3 # extracts value of label from nodes with key `label3` and inserts it as a tag with key `l3`
      key: label3
      from: node

完整例子如下,由于k8sattributesprocessor本身也是一个k8s Controller,因此需要通过
filter
指定listwatch的范围:

k8sattributes:
k8sattributes/2:
  auth_type: "serviceAccount"
  passthrough: false
  filter:
    node_from_env_var: KUBE_NODE_NAME
  extract:
    metadata:
      - k8s.pod.name
      - k8s.pod.uid
      - k8s.deployment.name
      - k8s.namespace.name
      - k8s.node.name
      - k8s.pod.start_time
   labels:
     - tag_name: app.label.component
       key: app.kubernetes.io/component
       from: pod
  pod_association:
    - sources:
        - from: resource_attribute
          name: k8s.pod.ip
    - sources:
        - from: resource_attribute
          name: k8s.pod.uid
    - sources:
        - from: connection

Tail Sampling Processor

基于预定义的策略来采样traces。注意,为了有效执行采样策略,
必须在相同的Collector实例中处理一个trace下的所有spans

必须将该processor放到依赖context的processors(如
k8sattributes
)之后,否则重组会导致丢失原始的context。

在执行采样之前,会根据
trace_id
对spans进行分组,因此无需
groupbytraceprocessor
就可以直接使用tail sampling processor。

tailsamplingprocessor

and
是一个比较特别的策略,它会使用
AND逻辑串联多条策略
。例如下面例子中的
and
串联了多条策略,用于:

  1. 过滤出
    service.name

    [service-1, service-2, service-3]
    的数据
  2. 然后从上述3个服务的数据中过滤出
    http.route

    [/live, /ready]
    的数据
  3. 最后将来自
    [service-1, service-2, service-3]
    服务的
    [/live, /ready]
    的数据的采样率设置为0.1
        and:
          {
            and_sub_policy: # and逻辑的策略集
              [
                {
                  # filter by service name
                  name: service-name-policy,
                  type: string_attribute,
                  string_attribute:
                    {
                      key: service.name,
                      values: [service-1, service-2, service-3],
                    },
                },
                {
                  # filter by route
                  name: route-live-ready-policy,
                  type: string_attribute,
                  string_attribute:
                    {
                      key: http.route,
                      values: [/live, /ready],
                      enabled_regex_matching: true, #启用正则表达式
                    },
                },
                {
                  # apply probabilistic sampling
                  name: probabilistic-policy,
                  type: probabilistic,
                  probabilistic: { sampling_percentage: 0.1 },
                },
              ],
          },

更多参见官方
例子

transform processor

该processor包含一系列与
Context 类型
相关的conditions和statements,并按照配置顺序,对接收的遥测数据执行conditions和statements。它使用了一种名为
OpenTelemetry Transformation Language
的类SQL语法。

transform processor可以trace、metrics和logs配置多个context statements,
context
指定了statements使用的
OTTL Context

Telemetry OTTL Context
Resource Resource
Instrumentation Scope Instrumentation Scope
Span Span
Span Event SpanEvent
Metric Metric
Datapoint DataPoint
Log Log

trace、metric和log支持的Context如下:

Signal Context Values
trace_statements resource
,
scope
,
span
, and
spanevent
metric_statements resource
,
scope
,
metric
, and
datapoint
log_statements resource
,
scope
, and
log

每个statement可以包含一个
Where
子语句来校验是否执行statement。

transform processor 还支持一个可选字段,
error_mode
,用于确定processor如何应对statement产生的错误。

error_mode description
ignore processor忽略错误,记录日志,并继续执行下一个statement,推荐模式。
silent processor忽略错误,不记录日志,并继续执行下一个statement。
propagate processor向pipeline返回错误,导致Collector丢弃payload。默认选项。

此外transform processor还支持OTTL
函数
可以添加、删除、修改遥测数据。

如下面例子中,如果attribute
test
不存在,则将attribute
test
设置为
pass

transform:
  error_mode: ignore
  trace_statements:
    - context: span
      statements:
        # accessing a map with a key that does not exist will return nil. 
        - set(attributes["test"], "pass") where attributes["test"] == nil

debug

通过在Collector启用debug日志来进行定位:

receivers:
  filelog:
    start_at: beginning
    include: [ test.log ]

processors:
  transform:
    error_mode: ignore
    log_statements:
      - context: log
        statements:
          - set(resource.attributes["test"], "pass")
          - set(instrumentation_scope.attributes["test"], ["pass"])
          - set(attributes["test"], true)

exporters:
  debug:

service:
  telemetry:
    logs:
      level: debug
  pipelines:
    logs:
      receivers:
        - filelog
      processors:
        - transform
      exporters:
        - debug

routing processor

将logs, metrics 或 traces路由到指定的exporter。此processor需要根据入站的HTTP请求(gRPC)首部或资源属性值来将trace信息路由到特定的exporters。

注意:

  • 该processor会终结pipeline的后续processors,如果在该processor之后定义了其他processors,则会发出告警。
  • 如果在pipeline中添加了一个exporter,则需要将其也添加到该processor中,否则不会生效。
  • 由于该processor依赖HTTP首部或资源属性,因此需要谨慎在pipeline中使用aggregation processors(
    batch

    groupbytrace
    )

配置的必须参数如下:

  • from_attribute
    : HTTP header名称或资源属性名称,用于获取路由值。
  • table
    : processor的路由表
  • table.value
    :
    FromAttribute
    字段的可能值
  • table.exporters
    : 如果
    FromAttribute
    字段值匹配
    table.value
    ,则使用此处定义的exporters。

可选字段如下:

  • attribute_source
    : 定义
    from_attribute
    的属性来源:
    • context
      (默认) - 查询
      context
      (包含HTTP headers)。
      默认的
      from_attribute
      的数据来源

      ,可以手动注入,或由第三方服务(如网关)注入。
    • resource
      - 查询资源属性
  • drop_resource_routing_attribute
    - 是否移除路由所用的资源属性。
  • default_exporters
    :无法匹配路由表的数据的exporters。

举例如下:

processors:
  routing:
    from_attribute: X-Tenant
    default_exporters:
    - jaeger
    table:
    - value: acme
      exporters: [jaeger/acme]
exporters:
  jaeger:
    endpoint: localhost:14250
  jaeger/acme:
    endpoint: localhost:24250

Exporter

注意opentelemetry的Exporter
大部分是push模式
,需要发送到后端。

debug exporter

调试使用,可以将遥测数据输出到终端,配置参数如下:

  • verbosity
    :(默认
    basic
    ),可选值为
    basic
    (输出摘要信息)、
    normal
    (输出实际数据)、
    detailed
    (输出详细信息)
  • sampling_initial
    :(默认
    2
    ),一开始每秒内输出的消息数
  • sampling_thereafter
    :(默认
    1
    ),在
    sampling_initial
    之后的采样率,
    1
    表示禁用该功能。

    每秒内输出前
    sampling_initial
    个消息,然后再输出第
    sampling_thereafter
    个消息,丢弃其余消息。
exporters:
  debug:
    verbosity: detailed
    sampling_initial: 5
    sampling_thereafter: 200

otlp exporter

使用
OTLP
格式,通过gRPC
发送数据
,注意这是
push
模式,默认需要TLS。可以选择设置
retry和queue

exporters:
  otlp:
    endpoint: otelcol2:4317
    tls:
      cert_file: file.cert
      key_file: file.key
  otlp/2:
    endpoint: otelcol2:4317
    tls:
      insecure: true

otlp http exporter

通过HTTP
发送
OTLP
格式的数据

endpoint: "https://1.2.3.4:1234"
tls:
  ca_file: /var/lib/mycert.pem
  cert_file: certfile
  key_file: keyfile
  insecure: true
timeout: 10s
read_buffer_size: 123
write_buffer_size: 345
sending_queue:
  enabled: true
  num_consumers: 2
  queue_size: 10
retry_on_failure:
  enabled: true
  initial_interval: 10s
  randomization_factor: 0.7
  multiplier: 1.3
  max_interval: 60s
  max_elapsed_time: 10m
headers:
  "can you have a . here?": "F0000000-0000-0000-0000-000000000000"
  header1: "234"
  another: "somevalue"
compression: gzip

prometheus exporter

使用
Prometheus 格式
暴露metrics,
pull模式

  • endpoint
    :暴露metrics的地址,路径为
    /metrics
  • const_labels
    : 为每个metrics追加的key/values
  • namespace
    : 如果设置,则指标暴露为
    <namespace>_<metrics>
  • send_timestamps
    : 默认
    false
    ,是否在响应中发送metrics的采集时间
  • metric_expiration
    :默认
    5m
    ,定义暴露的metrics无需更新的时长
  • resource_to_telemetry_conversion
    :默认
    false
    ,如果启用,则会将所有resource attributes转变为metric labels
  • enable_open_metrics
    :默认
    false
    ,如果启用,则会使用OpenMetrics格式暴露metrics,可以支持Exemplars功能。
  • add_metric_suffixes
    :默认
    true
    ,如果false,则不会启用type和unit后缀。
exporters:
  prometheus:
    endpoint: "1.2.3.4:1234" # 暴露地址为:https://1.2.3.4:1234/metrics
    tls:
      ca_file: "/path/to/ca.pem"
      cert_file: "/path/to/cert.pem"
      key_file: "/path/to/key.pem"
    namespace: test-space
    const_labels:
      label1: value1
      "another label": spaced value
    send_timestamps: true
    metric_expiration: 180m
    enable_open_metrics: true
    add_metric_suffixes: false
    resource_to_telemetry_conversion:
      enabled: true

推荐使用transform processor来将最常见的resource attribute设置为metric labels。

processor:
  transform:
    metric_statements:
      - context: datapoint
        statements:
        - set(attributes["namespace"], resource.attributes["k8s.namespace.name"])
        - set(attributes["container"], resource.attributes["k8s.container.name"])
        - set(attributes["pod"], resource.attributes["k8s.pod.name"])

prometheus remote write exporter

支持
HTTP 设置

Retry 和 timeout 设置

用于将opentelemetry metrics发送到兼容prometheus remote wirte的后端,如Cortex、Mimir和thanos等。

配置参数如下:

  • endpoint
    :remote write URL
  • tls
    :默认必须配置TLS
    • insecure
      :默认
      false
      。如需启动TLS,则需要配置
      cert_file

      key_file
  • external_labels
    :为每个metric添加额外的label name和value
  • headers
    :为每个HTTP 请求添加额外的header。
  • add_metric_suffixes
    :默认
    true
    ,如果false,则不会启用type和unit后缀。
  • send_metadata
    :默认
    false
    ,如果
    true
    ,则会生成并发送prometheus metadata
  • remote_write_queue
    :配置remote write的队列和发送参数
    • enabled
      : 启动发送队列,默认
      true
    • queue_size
      : 入队列的OTLP指标数,默认
      10000
    • num_consumers
      : 发送请求的最小workers数,默认
      5
  • resource_to_telemetry_conversion
    :默认
    false
    ,如果
    true
    ,则会将所有的resource attribute转变为metric labels。
  • target_info
    :默认
    false
    ,如果
    true
    ,则会为每个resource指标生成一个
    target_info
    指标
  • max_batch_size_bytes
    :默认
    3000000
    ->
    ~2.861 mb
    。发送给远端的batch采样数。如果一个batch大于该值,则会给切分为多个batches。
exporters:
  prometheusremotewrite:
    endpoint: "https://my-cortex:7900/api/v1/push"
    external_labels:
      label_name1: label_value1
      label_name2: label_value2
    resource_to_telemetry_conversion:
      enabled: true # Convert resource attributes to metric labels

推荐使用transform processor来将最常见的resource attribute设置为metric labels。

processor:
  transform:
    metric_statements:
      - context: datapoint
        statements:
        - set(attributes["namespace"], resource.attributes["k8s.namespace.name"])
        - set(attributes["container"], resource.attributes["k8s.container.name"])
        - set(attributes["pod"], resource.attributes["k8s.pod.name"])

loadbalancing exporter

基于
routing_key
实现spans, metrics 和 logs的负载均衡。如果不配置
routing_key
,则traces的默认值为
traceID
,metrics的默认值为
service
,即
相同
traceID
(或
service.name
(当
service
作为
routing_key
))的spans会被发送到相同的后端

。特别适用于tail-based samplers或red-metrics-collectors这种需要基于
完整trace
的后端。

需要注意的是负载均衡仅基于Trace ID或Service名称,且不会考虑实际后端的负载,也不会执行轮询负载均衡。

routing_key
的可选值为:

routing_key can be used for
service logs, spans, metrics
traceID logs, spans
resource metrics
metric metrics
streamID metrics

可以通过
静态

DNS
的方式配置后端。当更新后端时,会根据R/N(路由总数/后端总数)重新路由。如果后端经常变动,可以考虑使用
groupbytrace
processor。

需要注意的是,如果后端出现异常,此时
loadbalancingexporter
并不会尝试重新发送数据,存在数据丢失的可能,因此
要求在exporter上配置queue和retry机制

  • 当resolver为
    static
    时,
    如果一个后端不可用,则会所有后端的数据负载均衡失败
    ,直到该后端恢复正常或从
    static
    列表中移除。
    dns
    resolver也遵循相同的原则。
  • 当使用
    k8s

    dns
    时,拓扑变更会最终反映到
    loadbalancingexporter
    上。

主要配置参数如下:

  • otlp
    :用于配置
    OTLP exporter
    。注意此处无需配置
    endpoint
    ,该字段会被resolver的后端覆盖。
  • resolver
    :可以配置一个
    static
    ,一个
    dns
    ,以及一个
    k8s

    aws_cloud_map
    ,但不能同时指定4个resolvers。
    • dns
      中的
      hostname
      用于获取IP地址列表,
      port
      指用于导入traces的端口,默认为4317;
      interval
      指定解析间隔,如
      5s
      ,
      1d
      ,
      30m
      ,默认
      5s

      timeout
      指定解析超时时间,如
      5s
      ,
      1d
      ,
      30m
      ,默认
      1s
    • k8s
      中的
      service
      指kubernetes的service域名,如
      lb-svc.lb-ns

      port
      指用于导入traces的端口,默认为4317,如果指定了多个端口,则会在loadbalancer中添加对应的backend,就像不同的pods一样;
      timeout
      指定解析超时时间,如
      5s
      ,
      1d
      ,
      30m
      ,默认
      1s
  • routing_key
    :用于数据(spans或metrics)路由。目前仅支持
    trace

    metrics
    类型。支持如下参数:
    • service
      :基于service 名称进行路由。非常适用于span metrics,这样每个服务的所有spans都会被发送到一致的metrics Collector中。否则相同服务的metrics可能会被发送到不同的Collectors上,造成聚合不精确。
    • traceID
      :根据
      traceID
      路由spans。metrics无效。
    • metric
      :根据metric名称路由metrics。spans无效。
    • streamID
      :根据数据的streamID路由metrics。streamID为对attributes和resource、scope和metrics数据哈希产生的唯一值。

在下面例子中可以确保相同traceID的spans发送到相同的后端(Pod)上, :

    receivers:
      otlp/external:
        protocols:
          grpc:
            endpoint: ${env:MY_POD_IP}:4317
          http:
            endpoint: ${env:MY_POD_IP}:4318
      otlp/internal:
        protocols:
          grpc:
            endpoint: ${env:MY_POD_IP}:14317
          http:
            endpoint: ${env:MY_POD_IP}:14318
            
    exporters:
      loadbalancing/internal:
        protocol:
          otlp:
            sending_queue:
              queue_size: 50000
            timeout: 1s
            tls:
              insecure: true
        resolver:
          k8s:
            ports:
            - 14317
            service: infrastructure-opentelemetry-tracingcollector.infrastructure-opentelemetry
            timeout: 10s
      otlphttp/tempo:
        endpoint: http://infrastructure-tracing-tempo.net:14252/otlp
        sending_queue:
          queue_size: 50000
        tls:
          insecure: true

    service:
      pipelines:
        traces:
          exporters:
          - loadbalancing/internal
          processors:
          - memory_limiter
          - resource/metadata
          receivers:
          - otlp/external
        traces/loadbalancing:
          exporters:
          - otlphttp/tempo
          processors:
          - memory_limiter
          - resource/metadata
          - tail_sampling
          receivers:
          - otlp/internal

image

Connector

Connector可以将两个pipelines连接起来,使其中一个pipeline作为exporter,另一个作为receiver。connector可以看作一个exporter,从一个pipeline的尾部消费数据,从将数据发送到另一个pipeline开始处的receiver上。可以使用connector消费、复制或路由数据。

下面表示将
traces
的数据导入
metrics
中:

receivers:
  foo/traces:
  foo/metrics:
exporters:
  bar:
connectors:
  count:
service:
  pipelines:
    traces:
      receivers: [foo/traces]
      exporters: [count]
    metrics:
      receivers: [foo/metrics, count]
      exporters: [bar]

roundrobin connector

用于使用轮询方式实现负载均衡,适用于可扩展性不是很好的exporter,如
prometheusremotewrite
,下面用于将接收到的数据(
metrics
)以轮询方式分发到不同的prometheusremotewrite(
metrics/1

metrics/2
):

receivers:
  otlp:
processors:
  resourcedetection:
  batch:
exporters:
  prometheusremotewrite/1:
  prometheusremotewrite/2:
connectors:
  roundrobin:
service:
  pipelines:
    metrics:
      receivers: [otlp]
      processors: [resourcedetection, batch]
      exporters: [roundrobin]
    metrics/1:
      receivers: [roundrobin]
      exporters: [prometheusremotewrite/1]
    metrics/2:
      receivers: [roundrobin]
      exporters: [prometheusremotewrite/2]

span metrics connector

用于从span数据中聚合Request、Error和Duration(R.E.D) metrics。

  • Request
    :

    calls{service.name="shipping",span.name="get_shipping/{shippingId}",span.kind="SERVER",status.code="Ok"}
    
  • Error
    :

    calls{service.name="shipping",span.name="get_shipping/{shippingId},span.kind="SERVER",status.code="Error"}
    
  • Duration
    :

    duration{service.name="shipping",span.name="get_shipping/{shippingId}",span.kind="SERVER",status.code="Ok"}
    

每条metric至少包含如下dimension(所有span都存在这些dimensions):

  • service.name
  • span.name
  • span.kind
  • status.code

常见参数如下:

  • histogram
    :默认
    explicit
    ,用于配置histogram,只能选择
    explicit

    exponential
    • disable
      :默认
      false
      ,禁用所有histogram metrics
    • unit
      :默认
      ms
      ,可以选择
      ms

      s
    • explicit
      :指定histogram的time bucket Duration。默认
      [2ms, 4ms, 6ms, 8ms, 10ms, 50ms, 100ms, 200ms, 400ms, 800ms, 1s, 1400ms, 2s, 5s, 10s, 15s]
    • exponential
      :正负数范围内的最大bucket数
  • dimensions
    :除默认的dimensions之外还需添加的dimensions。每个dimension必须定一个
    name
    字段来从span的attributes集合或resource attribute中进行查找,如
    ip

    host.name

    region
    。如果没有在span中找到
    name
    属性,则查找
    default
    中定义的属性,如果没有定义
    default
    ,则忽略此dimension
  • exclude_dimensions
    :从default dimensions中排除的dimensions列表。用于从metrics排除掉不需要的数据。
  • dimensions_cache_size
    :保存Dimensions的缓存大小,默认
    1000
  • metrics_flush_interval
    :flush 生成的metrics的间隔,默认60s。
  • metrics_expiration
    :如果在该时间内没有接收到任何新的spans,则不会再export metrics。默认
    0
    ,表示不会超时。
  • metric_timestamp_cache_size
    :,默认
    1000
  • events
    :配置events metrics。
    • enable
      :默认
      false
    • dimensions
      :如果enable,则该字段必须存在。event metric的额外的Dimension
  • resource_metrics_key_attributes
    :过滤用于生成resource metrics key哈希值的resource attributes,可以防止resource attributes变动影响到Counter metrics。
receivers:
  nop:

exporters:
  nop:

connectors:
  spanmetrics:
    histogram:
      explicit:
        buckets: [100us, 1ms, 2ms, 6ms, 10ms, 100ms, 250ms]
    dimensions:
      - name: http.method
        default: GET
      - name: http.status_code
    exemplars:
      enabled: true
    exclude_dimensions: ['status.code']
    dimensions_cache_size: 1000
    aggregation_temporality: "AGGREGATION_TEMPORALITY_CUMULATIVE"    
    metrics_flush_interval: 15s
    metrics_expiration: 5m
    events:
      enabled: true
      dimensions:
        - name: exception.type
        - name: exception.message
    resource_metrics_key_attributes:
      - service.name
      - telemetry.sdk.language
      - telemetry.sdk.name

service:
  pipelines:
    traces:
      receivers: [nop]
      exporters: [spanmetrics]
    metrics:
      receivers: [spanmetrics]
      exporters: [nop]

troubleshooting

  • 使用
    debug exporter
  • 使用
    pprof extension
    ,暴露端口为
    1777
    ,采集pprof数据
  • 使用
    zPages extension
    , 暴露端口为
    55679
    ,地址为
    /debug/tracez
    可以定位如下问题:
    • 延迟问题
    • 死锁和工具问题
    • 错误

扩容

何时扩容

  • 当使用
    memory_limiter
    processor时,可以通过
    otelcol_processor_refused_spans
    来检查内存是否充足
  • Collector会使用queue来保存需要发送的数据,如果
    otelcol_exporter_queue_size
    >
    otelcol_exporter_queue_capacity
    则会拒绝数据(
    otelcol_exporter_enqueue_failed_spans
    )
  • 此外特定组件也会暴露相关metrics,如
    otelcol_loadbalancer_backend_latency

如何扩容

对于扩容,可以将组件分为三类:stateless、scrapers 和 stateful。对于stateless来说只需要增加副本数即可。

scrapers

对于
hostmetricsreceiver

prometheusreceiver
这样的receivers,不能简单地增加实例数,否则会导致每个Collector都scrape系统的endpoints。可以通过
Target Allocator
来对endpoints进行分片。

stateful

对于某些将数据存放在内存中的组件来说,扩容可能会导致不同的结果。如tail-sampling processor,它会在内存中保存一定时间的spans数据,并在认为trace结束时评估采样决策。如果通过增加副本数来对此类Collector进行扩容,就会导致不同的Collectors接收到相同trace的spans,导致每个Collector都会评估是否应该对该trace进行采样,从而可能得到不同的结果(trace丢失spans)。

类似的还有span-to-metrics processor,当不同的Collectors接收到相同服务的数据时,基于service name聚合就会变得不精确。

为了避免该问题,可以在执行tail-sampling 或 span-to-metrics 前面加上 load-balancing exporter
, load-balancing exporter会根据trace ID 或service name获取哈希值,保证后端的Collector接收到一致的数据。

receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317

processors:

exporters:
  loadbalancing:
    protocol:
      otlp:
    resolver:
      dns:
        hostname: otelcol.observability.svc.cluster.local

service:
  pipelines:
    traces:
      receivers:
        - otlp
      processors: []
      exporters:
        - loadbalancing

打开 Maven仓库,左边选项栏排在第一的就是测试框架与工具,今天的文章,V 哥要来聊一聊程序员必备的测试框架JUnit 的源码实现,整理的学习笔记,分享给大家。

有人说,不就一个测试框架嘛,有必要去了解它的源码吗?确实,在平时的工作中,我们只要掌握如何使用 JUnit 框架来帮我们测试代码即可,搞什么源码,相信我,只有看了 JUnit 框架的源码,你才会赞叹,真是不愧是一款优秀的框架,它的源码设计思路与技巧,真的值得你好好研读一下,学习优秀框架的实现思想,不就是优秀程序员要干的事情吗。

JUnit 是一个广泛使用的 Java 单元测试框架,其源码实现分析可以帮助开发者更好地理解其工作原理和内部机制,并学习优秀的编码思想。

JUnit 框架的源码实现过程中体现了多种优秀的设计思想和编程技巧,这些不仅使得 JUnit 成为一个强大且灵活的测试框架,也值得程序员在日常开发中学习和借鉴。V 哥通过研读源码后,总结了以下是一些关键点:

  1. 面向对象设计
    :JUnit 充分运用了面向对象的封装、继承和多态特性。例如,
    TestCase
    类作为基类提供了共享的测试方法和断言工具,而具体的测试类继承自
    TestCase
    来实现具体的测试逻辑。

  2. 模板方法模式
    :JUnit 的
    TestCase
    类使用了模板方法设计模式,定义了一系列模板方法如
    setUp()

    runTest()

    tearDown()
    ,允许子类重写这些方法来插入特定的测试逻辑。

  3. 建造者模式
    :JUnit 在构造测试套件时使用了建造者模式,允许逐步构建复杂的测试结构。例如,
    JUnitCore
    类提供了方法来逐步添加测试类和监听器。

  4. 策略模式
    :JUnit 允许通过不同的
    Runner
    类来改变测试执行的策略,如
    BlockJUnit4ClassRunner

    Suite
    。这种设计使得 JUnit 可以灵活地适应不同的测试需求。

  5. 装饰者模式
    :在处理测试前置和后置操作时,JUnit 使用了装饰者模式。例如,
    @RunWith
    注解允许开发者指定一个
    Runner
    来装饰测试类,从而添加额外的测试行为。

  6. 观察者模式
    :JUnit 的测试结果监听器使用了观察者模式。多个监听器可以订阅测试事件,如测试开始、测试失败等,从而实现对测试过程的监控和结果的收集。

  7. 依赖注入
    :JUnit 支持使用注解如
    @Mock

    @InjectMocks
    来进行依赖注入,这有助于解耦测试代码,提高测试的可读性和可维护性。

  8. 反射机制
    :JUnit 广泛使用 Java 反射 API 来动态发现和执行测试方法,这提供了极大的灵活性,允许在运行时动态地构建和执行测试。

  9. 异常处理
    :JUnit 在执行测试时,对异常进行了精细的处理。它能够区分测试中预期的异常和意外的异常,从而提供更准确的测试结果反馈。

  10. 解耦合
    :JUnit 的设计注重组件之间的解耦,例如,测试执行器(Runner)、测试监听器(RunListener)和测试结果(Result)之间的职责清晰分离。

  11. 可扩展性
    :JUnit 提供了丰富的扩展点,如自定义的
    Runner

    TestRule

    Assertion
    方法,允许开发者根据需要扩展框架的功能。

  12. 参数化测试
    :JUnit 支持参数化测试,允许开发者为单个测试方法提供多种输入参数,这有助于用一个测试方法覆盖多种测试场景。

  13. 代码的模块化
    :JUnit 的源码结构清晰,模块化的设计使得各个部分之间的依赖关系最小化,便于理解和维护。

通过学习和理解 JUnit 框架的这些设计思想和技巧,程序员可以在自己的项目中实现更高质量的代码和更有效的测试策略。

1. 面向对象设计

JUnit 框架的
TestCase
是一个核心类,它体现了面向对象设计的多个方面。以下是
TestCase
实现过程中的一些关键点,以及源码示例和分析:

  1. 封装

    TestCase
    类封装了测试用例的所有逻辑和相关数据。它提供了公共的方法来执行测试前的准备 (
    setUp
    ) 和测试后的清理 (
    tearDown
    ),以及其他测试逻辑。
public class TestCase extends Assert implements Test {
    // 测试前的准备
    protected void setUp() throws Exception {
    }

    // 测试后的清理
    protected void tearDown() throws Exception {
    }

    // 运行单个测试方法
    public void runBare() throws Throwable {
        // 调用测试方法
        method.invoke(this);
    }
}
  1. 继承

    TestCase
    允许其他测试类继承它。子类可以重写
    setUp

    tearDown
    方法来执行特定的初始化和清理任务。这种继承关系使得测试逻辑可以复用,并且可以构建出层次化的测试结构。
public class MyTest extends TestCase {
    @Override
    protected void setUp() throws Exception {
        // 子类特有的初始化逻辑
    }

    @Override
    protected void tearDown() throws Exception {
        // 子类特有的清理逻辑
    }

    // 具体的测试方法
    public void testSomething() {
        // 使用断言来验证结果
        assertTrue("预期为真", someCondition());
    }
}
  1. 多态

    TestCase
    类中的断言方法 (
    assertEquals
    ,
    assertTrue
    等) 允许以不同的方式使用,这是多态性的体现。开发者可以针对不同的测试场景使用相同的断言方法,但传入不同的参数和消息。
public class Assert {
    public static void assertEquals(String message, int expected, int actual) {
        // 实现断言逻辑
    }

    public static void assertTrue(String message, boolean condition) {
        // 实现断言逻辑
    }
}
  1. 抽象类
    :虽然
    TestCase
    不是一个抽象类,但它定义了一些抽象概念,如测试方法 (
    runBare
    ),这个方法可以在子类中以不同的方式实现。这种抽象允许
    TestCase
    类适应不同的测试场景。
public class TestCase {
    // 抽象的测试方法执行逻辑
    protected void runBare() throws Throwable {
        // 默认实现可能包括异常处理和断言调用
    }
}
  1. 接口实现

    TestCase
    实现了
    Test
    接口,这表明它具有测试用例的基本特征和行为。通过实现接口,
    TestCase
    保证了所有测试类都遵循相同的规范。
public interface Test {
    void run(TestResult result);
}

public class TestCase extends Assert implements Test {
    // 实现 Test 接口的 run 方法
    public void run(TestResult result) {
        // 运行测试逻辑
    }
}

我们可以看到
TestCase
类的设计充分利用了面向对象编程的优势,提供了一种灵活且强大的方式来组织和执行单元测试。这种设计不仅使得测试代码易于编写和维护,而且也易于扩展和适应不同的测试需求,你get 到了吗。

2. 模板方法模式

模板方法模式是一种行为设计模式,它在父类中定义了算法的框架,同时允许子类在不改变算法结构的情况下重新定义算法的某些步骤。在 JUnit 中,
TestCase
类就是使用模板方法模式的典型例子。

以下是
TestCase
类使用模板方法模式的实现过程和源码分析:

  1. 定义算法框架

    TestCase
    类定义了测试方法执行的算法框架。这个框架包括测试前的准备 (
    setUp
    )、调用实际的测试方法 (
    runBare
    ) 以及测试后的清理 (
    tearDown
    )。
public abstract class TestCase implements Test {
    // 模板方法,定义了测试执行的框架
    public void run(TestResult result) {
        // 测试前的准备
        setUp();

        try {
            // 调用实际的测试方法
            runBare();
        } catch (Throwable e) {
            // 异常处理,可以被子类覆盖
            result.addError(this, e);
        } finally {
            // 清理资源,确保在任何情况下都执行
            tearDown();
        }
    }

    // 测试前的准备,可以被子类覆盖
    protected void setUp() throws Exception {
    }

    // 测试方法的执行,可以被子类覆盖
    protected void runBare() throws Throwable {
        for (int i = 0; i < fCount; i++) {
            runTest();
        }
    }

    // 测试后的清理,可以被子类覆盖
    protected void tearDown() throws Exception {
    }

    // 执行单个测试方法,通常由 runBare 调用
    public void runTest() throws Throwable {
        // 实际的测试逻辑
    }
}
  1. 允许子类扩展

    TestCase
    类中的
    setUp

    runBare

    tearDown
    方法都是
    protected
    ,这意味着子类可以覆盖这些方法来插入自己的逻辑。
public class MyTestCase extends TestCase {
    @Override
    protected void setUp() throws Exception {
        // 子类的初始化逻辑
    }

    @Override
    protected void runBare() throws Throwable {
        // 子类可以自定义测试执行逻辑
        super.runBare();
    }

    @Override
    protected void tearDown() throws Exception {
        // 子类的清理逻辑
    }

    // 实际的测试方法
    public void testMyMethod() {
        // 使用断言来验证结果
        assertTrue("测试条件", condition);
    }
}
  1. 执行测试方法

    runTest
    方法是实际执行测试的地方,通常在
    runBare
    方法中被调用。
    TestCase
    类维护了一个测试方法数组
    fTests

    runTest
    方法会遍历这个数组并执行每个测试方法。
public class TestCase {
    // 测试方法数组
    protected final Vector tests = new Vector();

    // 添加测试方法到数组
    public TestCase(String name) {
        tests.addElement(name);
    }

    // 执行单个测试方法
    public void runTest() throws Throwable {
        // 获取测试方法
        Method runMethod = null;
        try {
            runMethod = this.getClass().getMethod((String) tests.elementAt(testNumber), (Class[]) null);
        } catch (NoSuchMethodException e) {
            fail("Missing test method: " + tests.elementAt(testNumber));
        }
        // 调用测试方法
        runMethod.invoke(this, (Object[]) null);
    }
}

通过模板方法模式,
TestCase
类为所有测试用例提供了一个统一的执行模板,确保了测试的一致性和可维护性。同时,它也允许开发者通过覆盖特定的方法来定制测试的特定步骤,提供了灵活性。这种设计模式在 JUnit 中的成功应用,展示了它在构建大型测试框架中的价值。

3. 建造者模式

在JUnit中,建造者模式主要体现在
JUnitCore
类的使用上,它允许以一种逐步构建的方式运行测试。
JUnitCore
类提供了一系列的静态方法,允许开发者逐步添加测试类和配置选项,最终构建成一个完整的测试运行实例。以下是
JUnitCore
使用建造者模式的实现过程和源码分析:

  1. 构建测试运行器

    JUnitCore
    类提供了一个运行测试的入口点。通过
    main
    方法或
    run
    方法,可以启动测试。
public class JUnitCore {
    // 运行测试的main方法
    public static void main(String[] args) {
        runMain(new JUnitCore(), args);
    }

    // 运行测试的方法,可以添加测试类和监听器
    public Result run(Class<?>... classes) {
        return run(Request.classes(Arrays.asList(classes)));
    }

    // 接受请求对象的方法
    public Result run(Request request) {
        // 实际的测试运行逻辑
        return run(request.getRunner());
    }

    // 私有方法,执行测试并返回结果
    private Result run(Runner runner) {
        Result result = new Result();
        RunListener listener = result.createListener();
        notifier.addFirstListener(listener);
        try {
            notifier.fireTestRunStarted(runner.getDescription());
            runner.run(notifier);
            notifier.fireTestRunFinished(result);
        } finally {
            removeListener(listener);
        }
        return result;
    }
}
  1. 创建请求对象

    Request
    类是建造者模式中的建造者类,它提供了方法来逐步添加测试类和其他配置。
public class Request {
    // 静态方法,用于创建包含测试类的请求
    public static Request classes(Class<?>... classes) {
        return new Request().classes(Arrays.asList(classes));
    }

    // 向请求中添加测试类
    public Request classes(Collection<Class<?>> classes) {
        // 添加测试类逻辑
        return this; // 返回自身,支持链式调用
    }

    // 获取构建好的Runner
    public Runner getRunner() {
        // 创建并返回Runner逻辑
    }
}
  1. 链式调用

    Request
    类的方法设计支持链式调用,这是建造者模式的一个典型特征。每个方法返回
    Request
    对象的引用,允许继续添加更多的配置。
// 示例使用
Request request = JUnitCore.request()
                          .classes(MyTest.class, AnotherTest.class)
                          // 可以继续添加其他配置
                          ;
Runner runner = request.getRunner();
Result result = new JUnitCore().run(runner);
  1. 执行测试
    :一旦通过
    Request
    对象构建好了测试配置,就可以通过
    JUnitCore

    run
    方法来执行测试,并获取结果。
// 执行测试并获取结果
Result result = JUnitCore.run(request);

靓仔们,我们可以看到
JUnitCore

Request
的结合使用体现了建造者模式的精髓。这种模式允许开发者以一种非常灵活和表达性强的方式来构建测试配置,然后再运行它们。建造者模式的使用提高了代码的可读性和可维护性,并且使得扩展新的配置选项变得更加容易。

4. 策略模式

策略模式允许在运行时选择算法的行为,这在JUnit中体现为不同的
Runner
实现。每种
Runner
都定义了执行测试的特定策略,例如,
BlockJUnit4ClassRunner
是JUnit 4的默认
Runner
,而
JUnitCore
允许通过传递不同的
Runner
来改变测试执行的行为。

以下是
Runner
接口和几种实现的源码分析:

  1. 定义策略接口

    Runner
    接口定义了所有测试运行器必须实现的策略方法。
    run
    方法接受一个
    RunNotifier
    参数,它是JUnit中的一个观察者,用于通知测试事件。
public interface Runner {
    void run(RunNotifier notifier);
    Description getDescription();
}
  1. 实现具体策略
    :JUnit 提供了多种
    Runner
    实现,每种实现都有其特定的测试执行逻辑。
  • BlockJUnit4ClassRunner
    是JUnit 4 的默认运行器,它使用注解来识别测试方法,并按顺序执行它们。
public class BlockJUnit4ClassRunner extends ParentRunner<TestResult> {
    @Override
    protected void runChild(FrameworkMethod method, RunNotifier notifier) {
        runLeaf(methodBlock(method), description, notifier);
    }

    protected Statement methodBlock(FrameworkMethod method) {
        // 创建一个Statement,可能包含@Before, @After等注解的处理
    }
}
  • Suite
    是一个
    Runner
    实现,它允许将多个测试类组合成一个测试套件。
public class Suite extends ParentRunner<Runner> {
    @Override
    protected void runChild(Runner runner, RunNotifier notifier) {
        runner.run(notifier);
    }
}
  1. 上下文配置

    JUnitCore
    作为上下文,它根据传入的
    Runner
    执行测试。
public class JUnitCore {
    public Result run(Request request) {
        Runner runner = request.getRunner();
        return run(runner);
    }

    private Result run(Runner runner) {
        Result result = new Result();
        RunNotifier notifier = new RunNotifier();
        runner.run(notifier);
        return result;
    }
}
  1. 使用
    @RunWith
    注解

    :开发者可以使用
    @RunWith
    注解来指定测试类应该使用的
    Runner
@RunWith(Suite.class)
public class MyTestSuite {
    // 测试类组合
}
  1. 自定义
    Runner

    :开发者也可以通过实现自己的
    Runner
    来改变测试执行的行为。
public class MyCustomRunner extends BlockJUnit4ClassRunner {
    public MyCustomRunner(Class<?> klass) throws InitializationError {
        super(klass);
    }

    @Override
    protected Statement withBefores(FrameworkMethod method, Object target, Statement statement) {
        // 自定义@Before注解的处理
    }
}
  1. 运行自定义
    Runner

JUnitCore.runClasses(MyCustomRunner.class, MyTest.class);

通过策略模式,JUnit 允许开发者根据不同的测试需求选择不同的执行策略,或者通过自定义
Runner
来扩展测试框架的功能。这种设计提供了高度的灵活性和可扩展性,使得JUnit能够适应各种复杂的测试场景。

5. 装饰者模式

装饰者模式是一种结构型设计模式,它允许用户在不修改对象自身的基础上,向一个对象添加新的功能。在JUnit中,装饰者模式被用于增强测试类的行为,比如通过
@RunWith
注解来指定使用特定的
Runner
类来运行测试。

以下是
@RunWith
注解使用装饰者模式的实现过程和源码分析:

  1. 定义组件接口

    Runner
    接口是JUnit中所有测试运行器的组件接口,它定义了运行测试的基本方法。
public interface Runner extends Describable {
    void run(RunNotifier notifier);
    Description getDescription();
}
  1. 创建具体组件

    BlockJUnit4ClassRunner
    是JUnit中一个具体的
    Runner
    实现,它提供了执行JUnit 4测试的基本逻辑。
public class BlockJUnit4ClassRunner extends ParentRunner<T> {
    protected BlockJUnit4ClassRunner(Class<?> klass) throws InitializationError {
        super(klass);
    }

    // 实现具体的测试执行逻辑
}
  1. 定义装饰者抽象类

    ParentRunner
    类是一个装饰者抽象类,它提供了装饰
    Runner
    的基本结构和默认实现。
public abstract class ParentRunner<T> implements Runner {
    protected Class<?> fTestClass;
    protected Statement classBlock;

    public void run(RunNotifier notifier) {
        // 装饰并执行测试
    }

    // 其他公共方法和装饰逻辑
}
  1. 实现具体装饰者
    :通过
    @RunWith
    注解,JUnit允许开发者指定一个装饰者
    Runner
    来增强测试类的行为。例如,
    Suite
    类是一个装饰者,它可以运行多个测试类。
@RunWith(Suite.class)
@Suite.SuiteClasses({Test1.class, Test2.class})
public class AllTests {
    // 这个类使用SuiteRunner来运行包含的测试类
}
  1. 使用
    @RunWith
    注解

    :开发者通过在测试类上使用
    @RunWith
    注解来指定一个装饰者
    Runner
@RunWith(CustomRunner.class)
public class MyTest {
    // 这个测试类将使用CustomRunner来运行
}
  1. 自定义
    Runner

    :开发者可以实现自己的
    Runner
    来提供额外的功能,如下所示:
public class CustomRunner extends BlockJUnit4ClassRunner {
    public CustomRunner(Class<?> klass) throws InitializationError {
        super(klass);
    }

    @Override
    protected Statement withBefores(FrameworkMethod method, Object target, Statement statement) {
        // 添加@Before注解的处理
        return super.withBefores(method, target, statement);
    }

    @Override
    protected Statement withAfters(FrameworkMethod method, Object target, Statement statement) {
        // 添加@After注解的处理
        return super.withAfters(method, target, statement);
    }
}
  1. 运行时创建装饰者
    :在JUnit的运行时,根据
    @RunWith
    注解的值,使用反射来实例化对应的
    Runner
    装饰者。
public static Runner getRunner(Class<?> testClass) throws InitializationError {
    RunWith runWith = testClass.getAnnotation(RunWith.class);
    if (runWith == null) {
        return new BlockJUnit4ClassRunner(testClass);
    } else {
        try {
            // 使用反射创建指定的Runner装饰者
            return (Runner) runWith.value().getConstructor(Class.class).newInstance(testClass);
        } catch (Exception e) {
            throw new InitializationError("Couldn't create runner for class " + testClass, e);
        }
    }
}

通过使用装饰者模式,JUnit 允许开发者通过
@RunWith
注解来灵活地为测试类添加额外的行为,而无需修改测试类本身。这种设计提高了代码的可扩展性和可维护性,同时也允许开发者通过自定义
Runner
来实现复杂的测试逻辑。

6. 观察者模式

观察者模式是一种行为设计模式,它定义了对象之间的一对多依赖关系,当一个对象状态发生改变时,所有依赖于它的对象都会得到通知并自动更新。在JUnit中,观察者模式主要应用于测试结果监听器,以通知测试过程中的各个事件,如测试开始、测试失败、测试完成等。

以下是JUnit中观察者模式的实现过程和源码分析:

  1. 定义观察者接口

    TestListener
    接口定义了测试过程中需要通知的事件的方法。
public interface TestListener {
    void testAborted(Test test, Throwable t);
    void testAssumptionFailed(Test test, AssumptionViolatedException e);
    void testFailed(Test test, AssertionFailedError e);
    void testFinished(Test test);
    void testIgnored(Test test);
    void testStarted(Test test);
}
  1. 创建主题

    RunNotifier
    类作为主题,维护了一组观察者列表,并提供了添加、移除观察者以及通知观察者的方法。
public class RunNotifier {
    private final List<TestListener> listeners = new ArrayList<TestListener>();

    public void addListener(TestListener listener) {
        listeners.add(listener);
    }

    public void removeListener(TestListener listener) {
        listeners.remove(listener);
    }

    protected void fireTestRunStarted(Description description) {
        for (TestListener listener : listeners) {
            listener.testStarted(null);
        }
    }

    // 其他类似fireTestXXXStarted/Finished等方法
}
  1. 实现具体观察者
    :具体的测试结果监听器实现
    TestListener
    接口,根据测试事件执行相应的逻辑。
public class MyTestListener implements TestListener {
    @Override
    public void testStarted(Test test) {
        // 测试开始时的逻辑
    }

    @Override
    public void testFinished(Test test) {
        // 测试结束时的逻辑
    }

    // 实现其他TestListener方法
}
  1. 注册观察者
    :在测试运行前,通过
    RunNotifier
    将具体的监听器添加到观察者列表中。
RunNotifier notifier = new RunNotifier();
notifier.addListener(new MyTestListener());
  1. 通知观察者
    :在测试执行过程中,
    RunNotifier
    会调用相应的方法来通知所有注册的观察者关于测试事件的信息。
protected void run(Runner runner) {
    // ...
    runner.run(notifier);
    // ...
}
  1. 使用
    JUnitCore
    运行测试


    JUnitCore
    类使用
    RunNotifier
    来运行测试,并通知注册的监听器。
public class JUnitCore {
    public Result run(Request request) {
        Runner runner = request.getRunner();
        return run(runner);
    }

    private Result run(Runner runner) {
        Result result = new Result();
        RunNotifier notifier = new RunNotifier();
        notifier.addListener(result.createListener());
        runner.run(notifier);
        return result;
    }
}
  1. 结果监听器

    Result
    类本身也是一个观察者,它实现了
    TestListener
    接口,用于收集测试结果。
public class Result implements TestListener {
    public void testRunStarted(Description description) {
        // 测试运行开始时的逻辑
    }

    public void testRunFinished(long elapsedTime) {
        // 测试运行结束时的逻辑
    }

    // 实现其他TestListener方法
}

通过观察者模式,JUnit 允许开发者自定义测试结果监听器,以获取测试过程中的各种事件通知。这种模式提高了测试框架的灵活性和可扩展性,使得开发者可以根据自己的需求来监控和响应测试事件。

7. 依赖注入

依赖注入是一种常见的设计模式,它允许将组件的依赖关系从组件本身中解耦出来,通常通过构造函数、工厂方法或 setter 方法注入。在 JUnit 中,依赖注入主要用于测试领域,特别是与 Mockito 这样的模拟框架结合使用时,可以方便地注入模拟对象。

以下是
@Mock

@InjectMocks
注解使用依赖注入的实现过程和源码分析:

  1. Mockito 依赖注入注解


    • @Mock
      注解用于创建模拟对象。
    • @InjectMocks
      注解用于将模拟对象注入到测试类中。
  2. 使用
    @Mock
    创建模拟对象


    • 在测试类中,使用
      @Mock
      注解的字段将自动被 Mockito 框架在测试执行前初始化为模拟对象。
public class MyTest {
    @Mock
    private Collaborator mockCollaborator;
    
    // 其他测试方法...
}
  1. 使用
    @InjectMocks
    进行依赖注入


    • 当测试类中的对象需要依赖其他模拟对象时,使用
      @InjectMocks
      注解可以自动注入这些模拟对象。
@RunWith(MockitoJUnitRunner.class)
public class MyTest {
    @Mock
    private Collaborator mockCollaborator;

    @InjectMocks
    private MyClass testClass;
    
    // 测试方法...
}
  1. MockitoJUnitRunner


    • @RunWith(MockitoJUnitRunner.class)
      指定了使用 Mockito 的测试运行器,它负责设置测试环境,包括初始化模拟对象和注入依赖。
  2. Mockito 框架初始化过程


    • 在测试运行前,Mockito 框架会查找所有使用
      @Mock
      注解的字段,并创建相应的模拟对象。
    • 接着,对于使用
      @InjectMocks
      注解的字段,Mockito 会进行反射检查其构造函数和成员变量,使用创建的模拟对象进行依赖注入。
  3. Mockito 注解处理器


    • Mockito 框架内部使用注解处理器来处理
      @Mock

      @InjectMocks
      注解。这些处理器在测试执行前初始化模拟对象,并在必要时注入它们。
public class MockitoAnnotations {
    public static void initMocks(Object testClass) {
        // 查找并初始化 @Mock 注解的字段
        for (Field field : Reflections.fieldsAnnotatedWith(testClass.getClass(), Mock.class)) {
            field.setAccessible(true);
            try {
                field.set(testClass, MockUtil.createMock(field.getType()));
            } catch (IllegalAccessException e) {
                throw new RuntimeException("Unable to inject @Mock for " + field, e);
            }
        }
        // 查找并处理 @InjectMocks 注解的字段
        for (Field field : Reflections.fieldsAnnotatedWith(testClass.getClass(), InjectMocks.class)) {
            // 注入逻辑...
        }
    }
}
  1. 测试方法执行


    • 在测试方法执行期间,如果测试类中的实例调用了被
      @Mock
      注解的对象的方法,实际上是调用了模拟对象的方法,可以进行行为验证或返回预设的值。
  2. Mockito 模拟行为


    • 开发者可以使用 Mockito 提供的 API 来定义模拟对象的行为,例如使用
      when().thenReturn()

      doThrow()
      等方法。
when(mockCollaborator.someMethod()).thenReturn("expected value");

通过依赖注入,JUnit 和 Mockito 的结合使用极大地简化了测试过程中的依赖管理,使得测试代码更加简洁和专注于测试逻辑本身。同时,这也提高了测试的可读性和可维护性。

8. 反射机制

在JUnit中,反射机制是实现动态测试发现和执行的关键技术之一。反射允许在运行时检查类的信息、创建对象、调用方法和访问字段,这使得JUnit能够在不直接引用测试方法的情况下执行它们。以下是使用Java反射API来动态发现和执行测试方法的实现过程和源码分析:

  1. 获取类对象
    :首先,使用
    Class.forName()
    方法获取测试类的
    Class
    对象。
Class<?> testClass = Class.forName("com.example.MyTest");
  1. 获取测试方法列表
    :通过
    Class
    对象,使用Java反射API获取类中所有声明的方法。
Method[] methods = testClass.getDeclaredMethods();
  1. 筛选测试方法
    :遍历方法列表,筛选出标记为测试方法的
    Method
    对象。在JUnit中,这通常是通过
    @Test
    注解来标识的。
List<FrameworkMethod> testMethods = new ArrayList<>();
for (Method method : methods) {
    if (method.isAnnotationPresent(Test.class)) {
        testMethods.add(new FrameworkMethod(method));
    }
}
  1. 创建测试方法的封装对象
    :JUnit使用
    FrameworkMethod
    类来封装
    Method
    对象,提供额外的功能,如处理
    @Before

    @After
    注解。
public class FrameworkMethod {
    private final Method method;

    public FrameworkMethod(Method method) {
        this.method = method;
    }

    public Object invokeExplosively(Object target, Object... params) throws Throwable {
        try {
            return method.invoke(target, params);
        } catch (IllegalAccessException | InvocationTargetException e) {
            throw new Exception("Failed to invoke " + method, e.getCause());
        }
    }
}
  1. 调用测试方法
    :使用
    FrameworkMethod

    invokeExplosively()
    方法,在指定的测试实例上调用测试方法。
public class BlockJUnit4ClassRunner extends ParentRunner<MyClass> {
    @Override
    protected void runChild(FrameworkMethod method, RunNotifier notifier) {
        runLeaf(new Statement() {
            @Override
            public void evaluate() throws Throwable {
                Object target = new MyClass();
                method.invokeExplosively(target);
            }
        }, methodBlock(method), notifier);
    }
}
  1. 处理测试方法的执行
    :在
    invokeExplosively()
    方法中,使用
    Method
    对象的
    invoke()
    方法来执行测试方法。这个方法能够处理方法的访问权限,并调用实际的测试逻辑。

  2. 异常处理
    :在执行测试方法时,可能会抛出异常。JUnit需要捕获这些异常,并适当地处理它们,例如将测试失败通知给
    RunNotifier

  3. 整合到测试运行器
    :将上述过程整合到JUnit的测试运行器中,如
    BlockJUnit4ClassRunner
    ,它负责创建测试实例、调用测试方法,并处理测试结果。

通过使用Java反射API,JUnit能够以一种非常灵活和动态的方式来执行测试方法。这种机制不仅提高了JUnit框架的通用性和可扩展性,而且允许开发者在不修改测试类代码的情况下,通过配置和注解来控制测试的行为。反射机制是JUnit强大功能的一个重要支柱。

9. 异常处理

在JUnit中,异常处理是一个精细的过程,确保了测试执行的稳定性和结果的准确性。JUnit区分了预期的异常(如测试中显式检查的异常)和未预期的异常(如错误或未捕获的异常),并相应地报告这些异常。以下是JUnit中异常处理的实现过程和源码分析:

  1. 测试方法执行
    :在测试方法执行时,JUnit会捕获所有抛出的异常。
public void runBare() throws Throwable {
    Throwable exception = null;
    try {
        method.invoke(target);
    } catch (InvocationTargetException e) {
        exception = e.getCause();
    } catch (IllegalAccessException e) {
        exception = e;
    } catch (IllegalArgumentException e) {
        exception = e;
    } catch (SecurityException e) {
        exception = e;
    }
    if (exception != null) {
        runAfters();
        throw exception;
    }
}
  1. 预期异常的处理
    :使用
    @Test(expected = Exception.class)
    注解可以指定测试方法预期抛出的异常类型。如果实际抛出的异常与预期不符,JUnit会报告测试失败。
@Test(expected = SpecificException.class)
public void testMethod() {
    // 测试逻辑,预期抛出 SpecificException
}
  1. 断言异常

    Assert
    类提供了
    assertThrows
    方法,允许在测试中显式检查方法是否抛出了预期的异常。
public static <T extends Throwable> T assertThrows(
    Class<T> expectedThrowable, Executable executable, String message) {
    try {
        executable.execute();
        fail(message);
    } catch (Throwable actualException) {
        if (!expectedThrowable.isInstance(actualException)) {
            throw new AssertionFailedError(
                "Expected " + expectedThrowable.getName() + " but got " + actualException.getClass().getName());
        }
        @SuppressWarnings("unchecked")
        T result = (T) actualException;
        return result;
    }
}
  1. 异常的分类
    :JUnit将异常分为两种类型:
    AssertionError

    Throwable

    AssertionError
    通常表示测试失败,而
    Throwable
    可能表示测试中的严重错误。

  2. 异常的报告
    :在捕获异常后,JUnit会将异常信息报告给
    RunNotifier
    ,以便进行适当的处理。

protected void runChild(FrameworkMethod method, RunNotifier notifier) {
    runLeaf(new Statement() {
        @Override
        public void evaluate() throws Throwable {
            try {
                method.invokeExplosively(testInstance);
            } catch (Throwable e) {
                notifier.fireTestFailure(new Failure(method, e));
            }
        }
    }, describeChild(method), notifier);
}
  1. 异常的监听

    RunNotifier
    监听器可以捕获并处理测试过程中抛出的异常,例如记录失败或向用户报告错误。
public void addListener(TestListener listener) {
    listeners.add(listener);
}

// 在测试执行过程中调用
notifier.fireTestFailure(new Failure(method, e));
  1. 自定义异常处理
    :开发者可以通过实现自定义的
    TestListener
    来捕获和处理测试过程中的异常。

  2. 异常的传播
    :在某些情况下,JUnit允许异常向上传播,使得测试框架或IDE能够捕获并显示给用户。

通过精细的异常处理,JUnit确保了测试的准确性和可靠性,同时提供了灵活的错误报告机制。这使得开发者能够快速定位和解决问题,提高了开发和测试的效率。

10. 解耦合

在JUnit中,解耦合是通过将测试执行的不同方面分离成独立的组件来实现的,从而提高了代码的可维护性和可扩展性。以下是解耦合实现过程的详细分析:

  1. 测试执行器(Runner)

    Runner
    接口定义了执行测试的方法,每个具体的
    Runner
    实现负责运行测试用例的逻辑。
public interface Runner {
    void run(RunNotifier notifier);
    Description getDescription();
}
  1. 测试监听器(RunListener)

    RunListener
    接口定义了测试过程中的事件回调方法,用于监听测试的开始、成功、失败和结束等事件。
public interface RunListener {
    void testRunStarted(Description description);
    void testRunFinished(Result result);
    void testStarted(Description description);
    void testFinished(Description description);
    // 其他事件回调...
}
  1. 测试结果(Result)

    Result
    类实现了
    RunListener
    接口,用于收集和存储测试执行的结果。
public class Result implements RunListener {
    private List<Failure> failures = new ArrayList<>();

    @Override
    public void testRunFinished(Result result) {
        // 收集测试运行结果
    }

    @Override
    public void testFailure(Failure failure) {
        // 收集测试失败信息
        failures.add(failure);
    }

    // 其他RunListener方法实现...
}
  1. 职责分离

    Runner
    负责执行测试逻辑,
    RunListener
    负责监听测试事件,而
    Result
    负责收集测试结果。这三者通过接口和回调机制相互协作,但各自独立实现。

  2. 使用
    RunNotifier
    协调


    RunNotifier
    类作为协调者,维护了
    RunListener
    的注册和事件分发。

public class RunNotifier {
    private final List<RunListener> listeners = new ArrayList<>();

    public void addListener(RunListener listener) {
        listeners.add(listener);
    }

    public void fireTestRunStarted(Description description) {
        for (RunListener listener : listeners) {
            listener.testRunStarted(description);
        }
    }

    // 其他事件分发方法...
}
  1. 测试执行流程
    :在测试执行时,
    Runner
    会创建一个
    RunNotifier
    实例,然后执行测试,并在适当的时候调用
    RunNotifier
    的事件分发方法。
public class BlockJUnit4ClassRunner extends ParentRunner {
    @Override
    protected void runChild(FrameworkMethod method, RunNotifier notifier) {
        RunBefores runBefores = new RunBefores(noTestsYet, method, null);
        Statement statement = new RunAfters(runBefores, method, null);
        statement.evaluate();
    }

    @Override
    public void run(RunNotifier notifier) {
        // 初始化测试运行
        Description description = getDescription();
        notifier.fireTestRunStarted(description);
        try {
            // 执行测试
            runChildren(makeTestRunNotifier(notifier, description));
        } finally {
            // 测试运行结束
            notifier.fireTestRunFinished(result);
        }
    }
}
  1. 结果收集和报告
    :测试完成后,
    Result
    对象会包含所有测试的结果,可以被用来生成测试报告或进行其他后续处理。

  2. 解耦合的优势
    :通过将测试执行、监听和结果收集分离,JUnit允许开发者自定义测试执行流程(通过自定义
    Runner
    )、添加自定义监听器(通过实现
    RunListener
    接口)以及处理测试结果(通过操作
    Result
    对象)。

这种解耦合的设计使得JUnit非常灵活,易于扩展,同时也使得测试代码更加清晰和易于理解。开发者可以根据需要替换或扩展框架的任何部分,而不影响其他部分的功能。

11. 可扩展性

JUnit的可扩展性体现在多个方面,包括自定义
Runner

TestRule
和断言(Assertion)方法。以下是这些可扩展性点的实现过程和源码分析:

自定义 Runner

自定义
Runner
允许开发者定义自己的测试运行逻辑。以下是创建自定义
Runner
的步骤:

  1. 实现Runner接口
    :创建一个类实现
    Runner
    接口,并实现
    run
    方法和
    getDescription
    方法。
public class CustomRunner extends Runner {
    private final Class<?> testClass;

    public CustomRunner(Class<?> testClass) throws InitializationError {
        this.testClass = testClass;
    }

    @Override
    public Description getDescription() {
        // 返回测试描述
    }

    @Override
    public void run(RunNotifier notifier) {
        // 自定义测试运行逻辑
    }
}
  1. 使用@RunWith注解
    :在测试类上使用
    @RunWith
    注解来指定使用自定义的
    Runner
@RunWith(CustomRunner.class)
public class MyTests {
    // 测试方法...
}

自定义 TestRule

TestRule
接口允许开发者插入测试方法执行前后的逻辑。以下是创建自定义
TestRule
的步骤:

  1. 实现TestRule接口
    :创建一个类实现
    TestRule
    接口。
public class CustomTestRule implements TestRule {
    @Override
    public Statement apply(Statement base, FrameworkMethod method, Object target) {
        // 返回一个Statement,包装原始的测试逻辑
    }
}
  1. 使用@Rule注解
    :在测试类或方法上使用
    @Rule
    注解来指定使用自定义的
    TestRule
public class MyTests {
    @Rule
    public CustomTestRule customTestRule = new CustomTestRule();

    // 测试方法...
}

自定义 Assertion 方法

JUnit提供了一个
Assert
类,包含许多断言方法。开发者也可以添加自己的断言方法:

  1. 扩展Assert类
    :创建一个工具类,添加自定义的静态方法。
public class CustomAssertions {
    public static void assertEquals(String message, int expected, int actual) {
        if (expected != actual) {
            throw new AssertionFailedError(message);
        }
    }
}
  1. 使用自定义断言
    :在测试方法中调用自定义的断言方法。
public void testCustomAssertion() {
    CustomAssertions.assertEquals("Values should be equal", 1, 2);
}

源码分析

以下是使用自定义
Runner

TestRule
和断言方法的示例:

// 自定义Runner
public class CustomRunner extends Runner {
    public CustomRunner(Class<?> klass) throws InitializationError {
        // 初始化逻辑
    }

    @Override
    public Description getDescription() {
        // 返回测试的描述信息
    }

    @Override
    public void run(RunNotifier notifier) {
        // 自定义测试执行逻辑,包括调用测试方法和处理测试结果
    }
}

// 自定义TestRule
public class CustomTestRule implements TestRule {
    @Override
    public Statement apply(Statement base, FrameworkMethod method, Object target) {
        // 包装原始的测试逻辑,可以在测试前后执行额外的操作
        return new Statement() {
            @Override
            public void evaluate() throws Throwable {
                // 测试前的逻辑
                base.evaluate();
                // 测试后的逻辑
            }
        };
    }
}

// 使用自定义Runner和TestRule的测试类
@RunWith(CustomRunner.class)
public class MyTests {
    @Rule
    public CustomTestRule customTestRule = new CustomTestRule();

    @Test
    public void myTest() {
        // 测试逻辑,使用自定义断言
        CustomAssertions.assertEquals("Expected and actual values should match", 1, 1);
    }
}

通过这些自定义扩展,JUnit允许开发者根据特定需求调整测试行为,增强测试框架的功能,实现高度定制化的测试流程。这种可扩展性是JUnit强大适应性的关键因素之一。

12. 参数化测试

参数化测试是JUnit提供的一项功能,它允许为单个测试方法提供多种输入参数,从而用一个测试方法覆盖多种测试场景。以下是参数化测试的实现过程和源码分析:

  1. 使用
    @Parameterized
    注解

    :首先,在测试类上使用
    @RunWith(Parameterized.class)
    来指定使用参数化测试的
    Runner
@RunWith(Parameterized.class)
public class MyParameterizedTests {
    // 测试方法的参数
    private final int input;
    private final int expectedResult;

    // 构造函数,用于接收参数
    public MyParameterizedTests(int input, int expectedResult) {
        this.input = input;
        this.expectedResult = expectedResult;
    }

    // 测试方法
    @Test
    public void testWithParameters() {
        // 使用参数进行测试
        assertEquals(expectedResult, someMethod(input));
    }

    // 获取参数来源
    @Parameters
    public static Collection<Object[]> data() {
        return Arrays.asList(new Object[][] {
            { 1, 2 },
            { 2, 4 },
            { 3, 6 }
        });
    }
}
  1. 定义测试参数
    :使用
    @Parameters
    注解的方法来定义测试参数。这个方法需要返回一个
    Collection
    ,其中包含参数数组的列表。
@Parameters
public static Collection<Object[]> parameters() {
    return Arrays.asList(new Object[][] {
        // 参数列表
    });
}
  1. 构造函数注入
    :参数化测试框架会通过构造函数将参数注入到测试实例中。
public MyParameterizedTests(int param1, String param2) {
    // 使用参数初始化测试用例
}
  1. 参数化测试的执行
    :JUnit框架会为
    @Parameters
    方法中定义的每一组参数创建测试类的实例,并执行测试方法。

  2. 自定义参数源
    :除了使用
    @Parameters
    注解的方法外,还可以使用
    Parameterized.ParametersRunnerFactory
    注解来指定自定义的参数源。

@RunWith(value = Parameterized.class, runnerFactory = MyParametersRunnerFactory.class)
public class MyParameterizedTests {
    // 测试方法和参数...
}

public class MyParametersRunnerFactory implements ParametersRunnerFactory {
    @Override
    public Runner createRunnerForTestWithParameters(TestWithParameters test) {
        // 返回自定义的参数化运行器
    }
}
  1. 使用
    Arguments
    辅助类

    :在JUnit 4.12中,可以使用
    Arguments
    类来简化参数的创建。
@Parameters
public static Collection<Object[]> data() {
    return Arrays.asList(
        Arguments.arguments(1, 2),
        Arguments.arguments(2, 4),
        Arguments.arguments(3, 6)
    );
}
  1. 源码分析

    Parameterized
    类是实现参数化测试的核心。它使用
    ParametersRunnerFactory
    来创建
    Runner
    ,然后为每组参数执行测试方法。
public class Parameterized {
    public static class ParametersRunnerFactory implements RunnerFactory {
        @Override
        public Runner create(Description description) {
            return new BlockJUnit4ClassRunner(description.getTestClass()) {
                @Override
                protected List<Runner> getChildren() {
                    // 获取参数并为每组参数创建Runner
                }
            };
        }
    }
    // 其他实现...
}

通过参数化测试,JUnit允许开发者编写更灵活、更全面的测试用例,同时保持测试代码的简洁性。这种方法特别适合于需要多种输入组合来验证逻辑正确性的场景。

13. 代码的模块化

代码的模块化是软件设计中的一种重要实践,它将程序分解为独立的、可重用的模块,每个模块负责一部分特定的功能。在JUnit框架中,模块化设计体现在其清晰的包结构和类的设计上。以下是JUnit中模块化实现的过程和源码分析:

  1. 包结构
    :JUnit的源码按照功能划分为不同的包(packages),每个包包含一组相关的类。
// 核心包,包含JUnit的基础类和接口
org.junit

// 断言包,提供断言方法
org.junit.Assert

// 运行器包,负责测试套件的运行和管理
org.junit.runner

// 规则包,提供测试规则,如测试隔离和初始化
org.junit.rules
  1. 接口定义
    :JUnit使用接口(如
    Test

    Runner

    TestRule
    )定义模块的契约,确保模块间的松耦合。
public interface Test {
    void run(TestResult result);
}

public interface Runner {
    void run(RunNotifier notifier);
    Description getDescription();
}
  1. 抽象类
    :使用抽象类(如
    Assert

    Runner

    TestWatcher
    )为模块提供共享的实现,同时保留扩展的灵活性。
public abstract class Assert {
    // 断言方法的默认实现
}

public abstract class Runner implements Describable {
    // 测试运行器的默认实现
}
  1. 具体实现
    :为每个抽象类或接口提供具体的实现,这些实现类可以在不同的测试场景中重用。
public class TestCase extends Assert implements Test {
    // 测试用例的具体实现
}

public class BlockJUnit4ClassRunner extends ParentRunner {
    // 测试类的运行器实现
}
  1. 依赖倒置
    :通过依赖接口而非具体实现,JUnit的模块可以在不修改其他模块的情况下进行扩展或替换。

  2. 服务提供者接口(SPI)
    :JUnit使用服务提供者接口来发现和加载扩展模块,如测试规则(
    TestRule
    )。

public interface TestRule {
    Statement apply(Statement base, Description description);
}
  1. 模块化测试执行
    :JUnit允许开发者通过
    @RunWith
    注解指定自定义的
    Runner
    ,这允许对测试执行过程进行模块化定制。
@RunWith(CustomRunner.class)
public class MyTests {
    // ...
}
  1. 参数化测试模块
    :参数化测试通过
    @Parameters
    注解和
    Parameterized
    类实现模块化,允许为测试方法提供不同的输入参数集。
@RunWith(Parameterized.class)
public class MyParameterizedTests {
    @Parameters
    public static Collection<Object[]> data() {
        // 提供参数集
    }
}
  1. 解耦的事件监听

    RunNotifier

    RunListener
    接口的使用使得测试事件的监听和处理可以独立于测试执行逻辑。
public class RunNotifier {
    public void addListener(RunListener listener);
    // ...
}
  1. 测试结果的模块化处理

    Result
    类实现了
    RunListener
    接口,负责收集和报告测试结果,与测试执行逻辑解耦。

通过这种模块化设计,JUnit提供了一个灵活、可扩展的测试框架,允许开发者根据自己的需求添加自定义的行为和扩展功能。这种设计不仅提高了代码的可维护性,也方便了重用和测试过程的定制。

最后

以上就是V哥在 JUnit 框架源码学习时总结的13个非常值得学习的点,希望也可以帮助到你提升编码的功力,欢迎关注威哥爱编程,一起学习框架源码,提升编程技巧,我是 V哥,爱 编程,一辈子。

干系人分析与识别

5W1H 干系人分析与识别

1. 干系人是什么
  1. 直接或者间接影响专题,以及被专题影响的人和组织,用户也是属于干系人,是产品直接或者间接的使用者

  2. 又叫利益相关者,指积极参与专题或者在专题中其利益可能受积极或消极影响的个人或组织

2. 为什么要分析和识别干系人
  1. 找出对专题或者产品有重要影响的人或组织,获得他们对于专题或者产品的期望和需求

  2. 识别核心的干系人,决策者,提出者,实际使用者,重点满足核心干系人的需求,有利于项目的推动

  3. 有系统的支持者也有系统的受益者,了解干系人之间的协作方式

  4. 分析和了解干系人的信息,为用户调研和进一步工作做准备

  5. 分析把控需求的优先级,减少干系人之间的利益冲突,帮助设计者制定产品方向,目标,确保产品的实现

3. 什么人,在什么阶段,在什么场景来使用

是一种通用的思维,不局限于软件工程,都可以使用,比如:

  1. 产品经理,在需求分析阶段,需要了解干系人的需求和期望,以及干系人之间的协作关系,对齐目标,达成共识

  2. 产品经理,在用户调研准备阶段,需要了解用户信息,制定访谈的计划

  3. 开发人员,在需求实现阶段,需要明确有那些用户角色,他们的职责,以及协作关系

  4. 项目经理,在项目推进阶段,需要找到项目的决策者,管理者,了解关系,评估他们对项目的影响程度,想办法推动

4. 如何使用
1. 如何识别干系人
  1. 从组织结构,职能,岗位来识别
    • 公司的组织结构图
    • 部门人员,岗位职能
    • 钉钉,微信群
  2. 按参与方,参与者来识别
    • 购买者,消费者
    • 协作方,支持方
  3. 从干系人的访谈中识别
    • 从干系人的沟通中识别,比如财务的负责人是那位......
  4. 文档,业务流程,系统角色
    • 业务流程图
    • 系统上的用户角色关系
2. 如何分析干系人
  1. 分析利益

    分析干系人可以获得的利益,优先考虑
    核心
    干系人的利益。


    1. 设计能给用户带来了什么好处,价值
    2. 能给用户解决什么问题
  2. 分析负面影响

    项目或产品的设计使用,可能损害干系人的某些利益,如给干系人增加了工作量,收入减少,隐私泄露等。

  3. 分析期望/目标

    干系人为了获得在项目中的利益,会对项目提出要求,包括对产品需求、期望或个人价值观(通常是干系人对项目的审美、收费/免费、公平性等的看法),分析干系人的期望/目标,是把握好产品设计内容和方向的关键点,优先考虑核心干系人的期望与目标,一定程度上决定了产品的成败。

  4. 分析干系人之间的协作方式,以及干系人的角色


    1. 需求提出人
    2. 需求直接使用人,需求提出者,不一定是业务使用者,需要识别业务实际使用者
    3. 需求受影响的人
    4. 分析干系人之间是怎么协作的
  5. 明确干系人重要程度


    1. 有的干系人决定项目的成败
    2. 有的干系人很少使用和参与
  6. 明确干系人需求对业务的影响


    1. 实际使用的人,一般从自身使用的角度出发,提出需求
    2. 对业务进行管理的人,从管理和经营的角度出发,提出需求
3. 如何识别核心用户
  1. 根据
    业务指标
    对用户进行划分,比如
    使用频率,影响大小,使用时间,购买数
  2. 干系人比较多时,可以使用用户地图来,可视化的展示用户
4. 输出与整理,干系人地图
  1. 没有明确就医选择的用户的用户群体最多
    • 使用的频率偏多,使用的时间偏长,平台信息帮助用户快速的定位,医院,科室,医生,提供疾病的科普,帮助了解基础的知识
  2. 医生
    • 查看了解一些医院相关的信息,自己的简介和浏览信息的统计,以及反馈情况
  3. 了解信息的用户
    • 了解和收集成都医院,或者病症相关信息的信息
  4. 老年自主就医的用户
    • 用户群体偏少,大多数的老年人都有子女陪同
    • 考虑老年用户的信息展示和阅读的问题

越内圈的用户,越核心

EF Core 索引器属性(Indexer property)场景及应用

简介

EF Core 中的索引器属性(Indexer Property)是指通过一个特殊的属性来访问实体类中的数据,而不必明确声明实体属性。这种属性在一些动态或未预定义的场景中非常有用,比如当实体的属性名在编译时并不确定,或者属性名集合较大时。

场景及应用

1.动态属性访问
索引器属性最常见的应用场景是动态属性访问。这在处理 JSON 数据或其他半结构化数据时尤其有用。例如,当你有一个属性名称集合在编译时并不确定,或者从外部源(如配置文件、API 响应等)中获取属性名时,可以使用索引器属性来动态访问这些属性。

2.字典数据结构
如果实体类包含一个字典类型的属性,可以通过索引器属性来访问字典中的数据。例如,如果你的实体中包含了一个
Dictionary<string, object>
来存储额外的数据,使用索引器属性可以简化访问这些数据的方式。

public class DynamicEntity
{
    private Dictionary<string, object> _additionalData = new Dictionary<string, object>();

    public object this[string key]
    {
        get => _additionalData.ContainsKey(key) ? _additionalData[key] : null;
        set => _additionalData[key] = value;
    }
}

3.元数据处理
在一些应用场景中,需要将不同的元数据存储在实体中而不增加额外的列。在这种情况下,可以使用索引器属性来处理这些元数据。例如,当你需要根据业务逻辑在数据库表中存储额外的、可变的属性集合时,可以使用索引器属性来管理这些属性。

4.简化代码
索引器属性可以简化代码,减少显式声明属性的需求。在开发过程中,减少了重复代码,提高了代码的可维护性和灵活性。

EF Core 配置

在 EF Core 中使用索引器属性时,需要在模型配置阶段进行一些特殊的配置,以确保 EF Core 正确地将索引器属性映射到数据库字段。这里我们讨论几种常见的配置方法。

1.
使用
Dictionary<string, object>
的索引器属性

如果你在实体类中使用了
Dictionary<string, object>
作为索引器属性的存储机制,并希望 EF Core 将这些键值对存储在数据库表的专用列中,可以按照以下方式配置:

实体类示例

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; }

    private Dictionary<string, object> _extendedProperties = new Dictionary<string, object>();

    public object this[string key]
    {
        get => _extendedProperties.ContainsKey(key) ? _extendedProperties[key] : null;
        set => _extendedProperties[key] = value;
    }
}


OnModelCreating
方法中配置


DbContext
中的
OnModelCreating
方法中配置索引器属性。你可以使用
OwnsMany
来配置字典的映射。

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Product>()
        .OwnsMany(p => p._extendedProperties, a =>
        {
            a.Property<string>("Key");
            a.Property<string>("Value");
            a.WithOwner().HasForeignKey("ProductId");
            a.ToTable("ProductExtendedProperties");
        });
}

此配置将
Product
实体的扩展属性存储在一个单独的表
ProductExtendedProperties
中,该表将有三列:
ProductId

Key

Value

2.
直接将索引器属性映射到表的列

如果你希望直接将索引器属性映射到表的列(而不是将字典存储在单独的表中),你可以使用
Property
方法来配置。

实体类示例

public class MultilingualContent
{
    private Dictionary<string, string> _translations = new Dictionary<string, string>();

    public int Id { get; set; }

    public string this[string language]
    {
        get => _translations.ContainsKey(language) ? _translations[language] : null;
        set => _translations[language] = value;
    }
}


OnModelCreating
方法中配置

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<MultilingualContent>()
        .Property(e => e["en"])
        .HasColumnName("EnglishContent");

    modelBuilder.Entity<MultilingualContent>()
        .Property(e => e["fr"])
        .HasColumnName("FrenchContent");
}

这种配置将索引器属性中不同语言的内容直接映射到
MultilingualContent
表中的不同列(如
EnglishContent

FrenchContent
)。

3.
映射到 JSON 列

EF Core 5.0 开始支持将复杂类型映射到 JSON 列中。如果你使用索引器属性存储复杂对象,可以将其映射为 JSON。

实体类示例

public class Configuration
{
    private Dictionary<string, string> _settings = new Dictionary<string, string>();

    public int Id { get; set; }

    public string this[string key]
    {
        get => _settings.ContainsKey(key) ? _settings[key] : null;
        set => _settings[key] = value;
    }
}


OnModelCreating
方法中配置

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Configuration>()
        .Property(e => e._settings)
        .HasColumnType("jsonb")
        .HasColumnName("Settings");
}

这种配置将整个字典映射为一个 JSON 字段,可以灵活存储复杂和动态的数据结构。

完整实例之-多语言支持

在处理多语言支持的案例中,配置好 EF Core 后,你可以通过索引器属性动态地访问和更新不同语言的内容。下面将详细说明如何调用和请求使用这个多语言支持的模型。

1.
设置数据库上下文和实体

首先,假设你已经按照前面的指导配置好了
MultilingualContent
实体和数据库上下文。这里是完整的实体类和上下文的代码:

实体类
MultilingualContent

public class MultilingualContent
{
    private Dictionary<string, string> _translations = new Dictionary<string, string>();

    public int Id { get; set; }

    public string this[string language]
    {
        get => _translations.ContainsKey(language) ? _translations[language] : null;
        set => _translations[language] = value;
    }
}

数据库上下文
AppDbContext

public class AppDbContext : DbContext
{
    public DbSet<MultilingualContent> MultilingualContents { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<MultilingualContent>()
            .Property(e => e["en"])
            .HasColumnName("EnglishContent");

        modelBuilder.Entity<MultilingualContent>()
            .Property(e => e["fr"])
            .HasColumnName("FrenchContent");

        // 配置其他语言...
    }
}

2.
添加数据

假设你需要为一段内容添加英语和法语版本。你可以使用索引器属性来设置这些语言的内容。

using (var context = new AppDbContext())
{
    var content = new MultilingualContent();
    content["en"] = "Hello, world!";
    content["fr"] = "Bonjour, le monde!";

    context.MultilingualContents.Add(content);
    context.SaveChanges();
}

上面的代码会在数据库中插入一条记录,其中包含英语和法语的文本。

3.
检索数据

假设你想要根据语言检索某段内容。可以使用索引器属性来获取相应语言的文本。

using (var context = new AppDbContext())
{
    var content = context.MultilingualContents.FirstOrDefault(c => c.Id == 1);

    if (content != null)
    {
        string englishText = content["en"];
        string frenchText = content["fr"];

        Console.WriteLine($"English: {englishText}");
        Console.WriteLine($"French: {frenchText}");
    }
}

这段代码会从数据库中检索 ID 为 1 的内容,并输出其英语和法语版本。

4.
更新数据

你可以使用索引器属性来更新某个语言的内容。

using (var context = new AppDbContext())
{
    var content = context.MultilingualContents.FirstOrDefault(c => c.Id == 1);

    if (content != null)
    {
        content["en"] = "Hello, everyone!";
        content["fr"] = "Bonjour, tout le monde!";

        context.SaveChanges();
    }
}

这段代码会更新 ID 为 1 的内容,将英语和法语文本分别更新为新的内容。

5.
删除数据

删除操作和普通的实体一样,可以使用 EF Core 提供的标准方法。

using (var context = new AppDbContext())
{
    var content = context.MultilingualContents.FirstOrDefault(c => c.Id == 1);

    if (content != null)
    {
        context.MultilingualContents.Remove(content);
        context.SaveChanges();
    }
}

这段代码会从数据库中删除 ID 为 1 的内容及其所有语言版本的文本。

总结

EF Core 的索引器属性对于处理
动态
属性、元数据、或
结构化但不固定
的属性集合非常有用。它能够提高代码的灵活性和可维护性,特别是在处理需要
存储可变属性的场景
时。

END 欢迎关注 "ShareFlow" 公众号