Yolov5代码解析(输入端、BackBone、Neck、输出端)) - 湾仔码农 - 博客园

mikel阅读(1129)

来源: Yolov5代码解析(输入端、BackBone、Neck、输出端)) – 湾仔码农 – 博客园

 


【深度学习】总目录

  • 输入端:数据增强、锚框计算等。
  • backbone:进行特征提取。常用的骨干网络有VGG,ResNet,DenseNet,MobileNet,EfficientNet,CSPDarknet 53,Swin Transformer等。(其中yolov5s采用CSPDarknet 53作为骨干网)应用到不同场景时,可以对模型进行微调,使其更适用于特定的场景。
  • neck:neck的设计是为了更好的利用backbone提取的特征,在不同阶段对backbone提取的特征图进行在加工和合理利用。常用的结构有FPN,PANet,NAS-FPN,BiFPN,ASFF,SFAM等。(其中yolov5采用PAN结构)共同点是反复使用各种上下采样,拼接,点和和点积来设计聚合策略。
  • Head:骨干网作为一个分类网络,无法完成定位任务,Head通过骨干网提取的特征图来检测目标的位置和类别。

1 输入端

1.1 数据增强

LoadImagesAndLabels类自定义了数据集的处理过程,该类继承pytorch的Dataset类,需要实现父类的__init__方法, __getitem__方法和__len__方法, 在每个step训练的时候,DataLodar迭代器通过__getitem__方法获取一批训练数据。自定义数据集的重点是 __getitem__函数,各种数据增强的方式就是在这里进行的。

1.1.1 MixUp数据增强

论文(ICLR2018收录):mixup: BEYOND EMPIRICAL RISK MINIMIZATION

Mixup数据增强核心思想是从每个Batch中随机选择两张图片,并以一定比例混合生成新的图像,训练过程全部采用混合的新图像训练,原始图像不再参与训练。

假设图像1坐标为(xi,yi),图像2坐标为(xj,yj),混合图像坐标为(x’,y’),则混合公式如下:

λ∈[0,1],为服从Beta分布(参数都为α)的随机数。

从原文实验结果中可以看出,mixup在ImageNet-2012上面经过200 epoch后在几个网络上提高了1.2 ~ 1.5个百分点。在CIFAR-10上提高1.0 ~ 1.4个百分点,在CIFAR-100上提高1.9 ~ 4.5个百分点。

Yolov5中的mixup实现

1
2
3
4
5
6
def mixup(im, labels, im2, labels2):
    # Applies MixUp augmentation https://arxiv.org/pdf/1710.09412.pdf
    = np.random.beta(32.032.0)  # mixup ratio, alpha=beta=32.0
    im = (im * + im2 * (1 - r)).astype(np.uint8)  # 混合图像
    labels = np.concatenate((labels, labels2), 0)  # 标签直接concate更加简单
    return im, labels

 

1.1.2 Cutout数据增强

Cutout论文:Improved Regularization of Convolutional Neural Networks with Cutout

CNN具有非常强大的能力,然而,由于它的学习能力非常强,有时会导致过拟合现象的出现。为了解决这个问题,文章提出了一种简单的正则化方法:cutout。它的原理是在训练时随机地屏蔽输入图像中的方形区域。类似于dropout,但有两个主要的区别:(1)它丢弃的是输入图像的数据。(2)它丢弃的是一整块区域,而不是单个神经元。这能够有效地帮助CNN关注不同的特征,因为去除一个区域的神经元可以很好地防止被去除的神经元信息通过其它渠道向下传递。同时,dropout由于(1)卷积层拥有相较于全连接层更少的参数,因此正则化的效果相对欠佳;(2)图像的相邻元素有着很强的相关性的原因,在卷积层的效果不好。而cutout因为去除了一块区域的神经元,且它相比更接近于数据增强。因此在卷积层的效果要相对更好。cutout不仅容易实现,且实验证明,它能够与其它的数据增强方法一起作用,来提高模型的表现。作者发现,比起形状,cutout区域的大小更为重要。因此为了简化,他们选择了方形,且如果允许cutout区域延伸到图像外,效果反而会更好。

Yolov5中的cutout实现(默认不启用)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
def cutout(im, labels, p=0.5):
    # Applies image cutout augmentation https://arxiv.org/abs/1708.04552
    if random.random() < p:
        h, w = im.shape[:2]
        scales = [0.5* 1 + [0.25* 2 + [0.125* 4 + [0.0625* 8 + [0.03125* 16  # image size fraction
        for in scales:
            mask_h = random.randint(1int(h * s))  # create random masks
            mask_w = random.randint(1int(w * s))
            # box
            xmin = max(0, random.randint(0, w) - mask_w // 2)
            ymin = max(0, random.randint(0, h) - mask_h // 2)
            xmax = min(w, xmin + mask_w)
            ymax = min(h, ymin + mask_h)
            # apply random color mask
            im[ymin:ymax, xmin:xmax] = [random.randint(64191for in range(3)]
            # return unobscured labels
            if len(labels) and s > 0.03:
                box = np.array([xmin, ymin, xmax, ymax], dtype=np.float32)
                ioa = bbox_ioa(box, labels[:, 1:5])  # intersection over area
                labels = labels[ioa < 0.60]  # remove >60% obscured labels
    return labels

CutMix

CutMix论文:CutMix:Regularization Strategy to Train Strong Classifiers with Localizable Features

  • mixup:混合后的图像在局部是模糊和不自然的,因此会混淆模型,尤其是在定位方面。
  • cutout:被cutout的部分通常用0或者随机噪声填充,这就导致在训练过程中这部分的信息被浪费掉了。

cutmix在cutout的基础上进行改进,cutout的部分用另一张图像上cutout的部分进行填充,这样即保留了cutout的优点:让模型从目标的部分视图去学习目标的特征,让模型更关注那些less discriminative的部分。同时比cutout更高效,cutout的部分用另一张图像的部分进行填充,让模型同时学习两个目标的特征。

1.1.3 Mosaic数据增强

Mosaic是YOLOV4中提出的新方法,参考2019年底提出的CutMix数据增强的方式,但CutMix只使用了两张图片进行拼接,而Mosaic数据增强则采用了4张图片,通过随机缩放、随机裁减、随机排布的方式进行拼接。Mosaic有如下优点:

(1)丰富数据集:随机使用4张图片,随机缩放,再随机分布进行拼接,大大丰富了检测数据集,特别是随机缩放增加了很多小目标,让网络的鲁棒性更好;
(2)减少GPU显存:直接计算4张图片的数据,使得Mini-batch大小并不需要很大就可以达到比较好的效果。

  • 初始化整个背景图, 大小为(2 × image_size, 2 × image_size, 3)
  • 保留一些边缘留白,随机取一个中心点
  • 基于中心点分别将4个图放到左上、右上、左下、右下,此部分可能会出现小图出界的情况,所以拼接的时候可能会进行裁剪
  • 计算真实框的偏移量,在大图中重新计算框的位置

Yolov5中的4-mosaic和9-mosaic实现

切换使用

1
2
3
4
5
6
7
8
9
10
11
mosaic = self.mosaic and random.random() < hyp['mosaic']
if mosaic:
    # Load mosaic
    img, labels = load_mosaic(self, index)  # use load_mosaic4
    # img, labels = load_mosaic9(self, index)   # use load_mosaic9
    shapes = None
    
    # MixUp augmentation
    if random.random() < hyp['mixup']:
        img, labels = mixup(img, labels, *load_mosaic(self, random.randint(0self.n - 1)))
        # img, labels = mixup(img, labels, *load_mosaic9(self, random.randint(0, self.n - 1)))

 

1.1.4 Copy paste数据增强

论文:Simple Copy-Paste is a Strong Data Augmentation Method for Instance Segmentation

中文名叫复制粘贴大法,将部分目标随机的粘贴到图片中,前提是数据要有segments数据才行,即每个目标的实例分割信息。

在COCO实例分割上,实现了49.1%mask AP和57.3%box AP,与之前的最新技术相比,分别提高了+0.6%mask AP和+1.5%box AP。

Yolov5中的copy paste实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def copy_paste(im, labels, segments, p=0.5):
    # Implement Copy-Paste augmentation https://arxiv.org/abs/2012.07177, labels as nx5 np.array(cls, xyxy)
    = len(segments)
    if and n:
        h, w, c = im.shape  # height, width, channels
        im_new = np.zeros(im.shape, np.uint8)
        for in random.sample(range(n), k=round(p * n)):
            l, s = labels[j], segments[j]
            box = - l[3], l[2], w - l[1], l[4]
            ioa = bbox_ioa(box, labels[:, 1:5])  # intersection over area
            if (ioa < 0.30).all():  # allow 30% obscuration of existing labels
                labels = np.concatenate((labels, [[l[0], *box]]), 0)
                segments.append(np.concatenate((w - s[:, 0:1], s[:, 1:2]), 1))
                cv2.drawContours(im_new, [segments[j].astype(np.int32)], -1, (255255255), cv2.FILLED)
        result = cv2.bitwise_and(src1=im, src2=im_new)
        result = cv2.flip(result, 1)  # augment segments (flip left-right)
        = result > 0  # pixels to replace
        # i[:, :] = result.max(2).reshape(h, w, 1)  # act over ch
        im[i] = result[i]  # cv2.imwrite('Debug.jpg', im)  # Debug
    return im, labels, segments

 

1.1.5 Random affine仿射变换

在yolov5中Mosaic数据增强部分的代码包括了仿射变换,如果部采用Mosaic数据增强也会单独进行仿射变换。yolov5的仿射变换包含随机旋转、平移、缩放、错切(将所有点沿某一指定方向成比例地平移)、透视操作,根据hyp.scratch-low.yaml,默认情况下只使用了Scale和Translation即缩放和平移。通过degrees设置图片旋转角度,perspective、shear设置透视变换和错切。

Yolov5中的random_perspective实现

 

1.1.6 HSV随机增强图像

Yolov5使用hsv增强的目的是令模型在训练过程中看到的数据更加的多样,而通过HSV增强获得的”多样性“也可以从3个角度来说:

  • 色调(Hue)多样:通过随机地调整色调可以模拟不同颜色风格的输入图像,比如不同滤镜,不同颜色光照等场景下的图像,从而提升模型在这些场景下的泛化能力;
  • 饱和度(Saturation)多样:通过随机调整饱和度可以提升模型对不同鲜艳程度的目标的识别的泛化能力;
  • 亮度(Value)多样:通过随机调整亮度可以提升模型应对不同光亮场景下的输入图像。

HSV增强在目标检测模型的训练中是非常常用的方法,它在不破坏图像中关键信息的前提下提高了数据集的丰富程度,且计算成本很低,是很实用的数据增强方法。

Yolov5中的augment_hsv实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def augment_hsv(im, hgain=0.5, sgain=0.5, vgain=0.5):
    # HSV color-space augmentation
    if hgain or sgain or vgain:
        = np.random.uniform(-113* [hgain, sgain, vgain] + 1  # random gains
        hue, sat, val = cv2.split(cv2.cvtColor(im, cv2.COLOR_BGR2HSV)) #由bgr转为hsv后分离三通道
        dtype = im.dtype  # uint8
        # 创建3个通道的查找表,将通过查找表将原值映射为新值
        = np.arange(0256, dtype=r.dtype)
        lut_hue = ((x * r[0]) % 180).astype(dtype)  # opencv中hue值的范围0~180
        lut_sat = np.clip(x * r[1], 0255).astype(dtype)
        lut_val = np.clip(x * r[2], 0255).astype(dtype)
        # H,S,V三个通道将原值映射至随机增减后的值,再合并
        im_hsv = cv2.merge((cv2.LUT(hue, lut_hue), cv2.LUT(sat, lut_sat), cv2.LUT(val, lut_val)))
        cv2.cvtColor(im_hsv, cv2.COLOR_HSV2BGR, dst=im)  # no return needed

 

1.1.7 随机水平翻转

Yolov5中的Flip实现

1
2
3
4
5
6
7
8
9
10
11
# Flip up-down
if random.random() < hyp['flipud']:
    img = np.flipud(img)
    if nl:
        labels[:, 2= 1 - labels[:, 2]
# Flip left-right
if random.random() < hyp['fliplr']:
    img = np.fliplr(img)
    if nl:
         labels[:, 1= 1 - labels[:, 1]   

 

1.1.8 Albumentations数据增强工具包

Albumentations工具包涵盖了绝大部分的数据增强方式,使用方法类似于pytorch的transform。不过,在Albumentations提供的数据增强方式比pytorch官方的更多,使用也比较方便。

github地址:https://github.com/albumentations-team/albumentations
docs使用文档:https://albumentations.ai/docs

YOLOv5的 Albumentations类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class Albumentations:
    # YOLOv5 Albumentations class (optional, only used if package is installed)
    def __init__(self):
        self.transform = None
        try:
            import albumentations as A
            check_version(A.__version__, '1.0.3', hard=True)  # version requirement
            = [
                A.Blur(p=0.01),                       # 随机模糊
                A.MedianBlur(p=0.01),                 # 中值滤波器模糊输入图像
                A.ToGray(p=0.01),                     # 将输入的 RGB 图像转换为灰度
                A.CLAHE(p=0.01),                      # 自适应直方图均衡
                A.RandomBrightnessContrast(p=0.0),    # 随机改变输入图像的亮度和对比度
                A.RandomGamma(p=0.0),                 # 随机伽马变换
                A.ImageCompression(quality_lower=75, p=0.0),  # 减少图像的 Jpeg、WebP 压缩
                # 可加
                A.GaussianBlur(p=0.15),               # 高斯滤波器模糊
                A.GaussNoise(p=0.15),                 # 高斯噪声应用于输入图像
                A.FancyPCA(p=0.25),                   # PCA来找出R/G/B这三维的主成分,然后随机增加图像像素强度(AlexNet)
            ]
            self.transform = A.Compose(T, bbox_params=A.BboxParams(format='yolo', label_fields=['class_labels']))
            LOGGER.info(colorstr('albumentations: '+ ', '.join(f'{x}' for in self.transform.transforms if x.p))
        except ImportError:  # package not installed, skip
            pass
        except Exception as e:
            LOGGER.info(colorstr('albumentations: '+ f'{e}')
    def __call__(self, im, labels, p=1.0):
        if self.transform and random.random() < p:
            new = self.transform(image=im, bboxes=labels[:, 1:], class_labels=labels[:, 0])  # transformed
            im, labels = new['image'], np.array([[c, *b] for c, b in zip(new['class_labels'], new['bboxes'])])
        return im, labels

 

1.2 自适应锚框计算

下面是yolov5 v7.0中的anchor,这是在coco数据集上通过聚类方法得到的。当我们的输入尺寸为640*640时,会得到3个不同尺度的输出:80×80(640/8)、40×40(640/16)、20×20(640/32)。其中,80×80代表浅层的特征图(P3),包含较多的低层级信息,适合用于检测小目标,所以这一特征图所用的anchor尺度较小;20×20代表深层的特征图(P5),包含更多高层级的信息,如轮廓、结构等信息,适合用于大目标的检测,所以这一特征图所用的anchor尺度较大。另外的40×40特征图(P4)上就用介于这两个尺度之间的anchor用来检测中等大小的目标。对于20*20尺度大小的特征图,由原图下采样32倍得到,因此先验框由640*640尺度下的 (116 × 90), (156 × 198),(373 × 326) 缩小32倍,变成 (3.625× 2.8125), (4.875× 6.1875),(11.6563×10.1875),其共有13*13个grid cell,则这每个169个grid cell都会被分配3*13*13个先验框。

在Yolov3、Yolov4中,训练不同的数据集时,计算初始锚框的值是通过单独的程序运行的。但Yolov5中将此功能嵌入到代码中,每次训练时,自适应的计算不同训练集中的最佳锚框值。当然,如果觉得计算的锚框效果不是很好,也可以在train.py中将自动计算锚框功能关闭。

Yolov5的自适应锚框计算函数kmean_anchors(位于utils/autoanchor.py)

 

1.3 自适应图片缩放

在常用的目标检测算法中,不同的图片长宽都不相同,因此常用的方式是将原始图片缩放填充到标准尺寸,再送入检测网络中。在项目实际使用时,很多图片的长宽比不同,因此缩放填充后,两端的黑边大小都不同,而如果填充的比较多,则存在信息冗余,影响推理速度。因此在Yolov5的代码中utils/augmentations.py的letterbox函数中进行了修改,对原始图像自适应的添加最少的黑边

Yolov5的letterbox函数(utils/augmentations.py)

假设图片原来尺寸为(1080, 1920),我们想要resize的尺寸为(640,640)。要想满足收缩的要求,640/1080= 0.59,640/1920 = 0.33,应该选择更小的收缩比例0.33,则图片被缩放为(360,640)。下一步则要填充灰白边至360可以被32整除,则应该填充至384,最终得到图片尺寸(384,640)。

 

2 BackBone

  • YOLOv1的Backbone总共24个卷积层2个全连接层,使用了Leaky ReLu激活函数,但并没有引入BN层。
  • YOLOv2的Backbone在YOLOv1的基础上设计了Darknet-19网络,包含19个卷积层并引入了BN层优化模型整体性能。
  • YOLOv3将YOLOv2的Darknet-19加深了网络层数,并引入了ResNet的残差思想,也正是残差思想让YOLOv3将Backbone深度大幅扩展至Darknet-53。
  • YOLOv4的Backbone在YOLOv3的基础上,受CSPNet网络结构启发,将多个CSP子模块进行组合设计成为CSPDarknet53,并且使用了Mish激活函数(除Backbone以外的网络结构依旧使用LeakyReLU激活函数)。CSPDarknet53总共有72层卷积层,遵循YOLO系列一贯的风格,这些卷积层都是3*3 大小,步长为2的设置,能起到特征提取与逐步下采样的作用。
  • YOLOv5的Backbone同样使用了YOLOv4中使用的CSP思想。YOLOv5最初版本中会存在Focus结构,在YOLOv5第六版开始后,就舍弃了这个结构改用常规卷积,其产生的参数更少,效果更好。

2.1 CSP

CSP结构的核心思想是将输入特征图分成两部分,一部分经过一个小的卷积网络(称为子网络)进行处理,另一部分则直接进行下一层的处理。然后将两部分特征图拼接起来,作为下一层的输入。Yolov4和Yolov5都使用了CSP结构,yolov4只在backbone中使用了CSP结构,yolov5有两种CSP结构,以Yolov5s网络为例,CSP1_X结构应用于Backbone主干网络,另一种CSP2_X结构则应用于Neck中。残差组件由两个CBL组成,因此两个CSP的区别在于有没有shortcut(通过BottleneckCSP类的shortcut参数设置)。

在YOLOv5 v4.0中,作者将BottleneckCSP模块转变为了C3模块,经历过残差输出后的Conv模块被去掉了。C3包含了3个标准卷积层以及多个Bottleneck模块(数量由配置文件.yaml的n和depth_multiple参数乘积决定),concat后的标准卷积模块中的激活函数也由LeakyRelu变为了SiLU。

YOLOv5中的C3类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Bottleneck(nn.Module):
    # Standard bottleneck
    def __init__(self, c1, c2, shortcut=True, g=1, e=0.5):  # ch_in, ch_out, shortcut, groups, expansion
        super().__init__()
        c_ = int(c2 * e)  # hidden channels
        self.cv1 = Conv(c1, c_, 11)
        self.cv2 = Conv(c_, c2, 31, g=g)
        self.add = shortcut and c1 == c2
    def forward(self, x):
        return + self.cv2(self.cv1(x)) if self.add else self.cv2(self.cv1(x))
class C3(nn.Module):
    # CSP Bottleneck with 3 convolutions
    def __init__(self, c1, c2, n=1, shortcut=True, g=1, e=0.5):  # ch_in, ch_out, number, shortcut, groups, expansion
        super().__init__()
        c_ = int(c2 * e)  # hidden channels
        self.cv1 = Conv(c1, c_, 11)   # Conv = conv+BN+SiLU
        self.cv2 = Conv(c1, c_, 11)
        self.cv3 = Conv(2 * c_, c2, 1)  # optional act=FReLU(c2)
        self.m = nn.Sequential(*(Bottleneck(c_, c_, shortcut, g, e=1.0for in range(n)))  # 串联n个残差结构
        # self.m = nn.Sequential(*(CrossConv(c_, c_, 3, 1, g, 1.0, shortcut) for _ in range(n)))
    def forward(self, x):
        return self.cv3(torch.cat((self.m(self.cv1(x)), self.cv2(x)), 1))

3 Neck

  • yolov1、yolov2没有使用Neck模块,yolov3开始使用。Neck模块的目的是融合不同层的特征检测大中小目标。
  • yolov3的NECK模块引入了FPN的思想,并对原始FPN进行修改。
  • yolov4的Neck模块主要包含了SPP模块和PAN模块。SPP,即空间金字塔池化。SPP的目的是解决了输入数据大小任意的问题。SPP网络用在YOLOv4中的目的是增加网络的感受野。
  • yolov5的Neck侧也使用了SPP模块和PAN模块,但是在PAN模块进行融合后,将YOLOv4中使用的CBL模块替换成借鉴CSPnet设计的CSP_v5结构,加强网络特征融合的能力。

3.1 SPP/SPPF

2014年何恺明提出了空间金字塔池化SPP,能将任意大小的特征图转换成固定大小的特征向量。在Yolov5中,SPP的目的是在不同尺度下对图像进行池化(Pooling)。这种结构可以在不同尺寸的特征图上利用ROI池化不同尺度下的特征信息,提高模型的精度和效率。在YOLOv5的实现中,SPP结构主要包含两个版本,分别为SPP和SPPF。其中,SPP代表“Spatial Pyramid Pooling”,而SPPF则代表“Fast Spatial Pyramid Pooling”。两者目的是相同的,只是在结构上略有差异,从SPP改进为SPPF后(Yolov5 6.0),模型的计算量变小了很多,模型速度提升。结构图如下图所示,下面的Conv是CBS=conv+BN+SiLU。

YOLOv5中的SPP/SPPF类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class SPP(nn.Module):
    # Spatial Pyramid Pooling (SPP) layer https://arxiv.org/abs/1406.4729
    def __init__(self, c1, c2, k=(5913)):  # 5, 9, 13为初始化的kernel size
        super().__init__()
        c_ = c1 // 2  # hidden channels
        self.cv1 = Conv(c1, c_, 11)          # 通道减半
        self.cv2 = Conv(c_ * (len(k) + 1), c2, 11)  # concat之后的CBS
        self.m = nn.ModuleList([nn.MaxPool2d(kernel_size=x, stride=1, padding=// 2for in k])
    def forward(self, x):
        = self.cv1(x)
        with warnings.catch_warnings():
            warnings.simplefilter('ignore')  # suppress torch 1.9.0 max_pool2d() warning
            return self.cv2(torch.cat([x] + [m(x) for in self.m], 1))
class SPPF(nn.Module):
    # Spatial Pyramid Pooling - Fast (SPPF) layer for YOLOv5 by Glenn Jocher
    def __init__(self, c1, c2, k=5):  # equivalent to SPP(k=(5, 9, 13))
        super().__init__()
        c_ = c1 // 2  # hidden channels
        self.cv1 = Conv(c1, c_, 11)
        self.cv2 = Conv(c_ * 4, c2, 11)
        self.m = nn.MaxPool2d(kernel_size=k, stride=1, padding=// 2)
    def forward(self, x):
        = self.cv1(x)
        with warnings.catch_warnings():
            warnings.simplefilter('ignore')  # suppress torch 1.9.0 max_pool2d() warning
            y1 = self.m(x)
            y2 = self.m(y1) # 串联k=5的池化,会获得9和13的池化,所以是等效的,但是时间更快
            return self.cv2(torch.cat((x, y1, y2, self.m(y2)), 1))

 

3.2 PAN

论文:Path Aggregation Network for Instance Segmentation

PANet是香港中文大学 2018 作品,在COCO2017的实例分割上获得第一,在目标检测任务上获得第二。作者通过研究Mask R-CNN发现底层特征难以传达到高层次,因此设计了自下而上的路径增强,如下图里的(b)所示,(c)是Adaptive feature pooling。红色线表达了图像底层特征在FPN中的传递路径,要经过100多层layers;绿色线表达了图像底层特征在PANnet 中的传递路径,只需要经过小于10层layers。

Yolov5中的PAN结构

FPN层自顶向下传达强语义特征(高层语义是经过特征提取后得到的特征信息,它的感受野较大,提取的特征抽象,有利于物体的分类,但会丢失细节信息,不利于精确分割。高层语义特征是抽象的特征)。而PAN则自底向上传达强定位特征,两两联手,从不同的主干层对不同的检测层进行参数聚合。原本的PANet网络的PAN结构中,两个特征图结合是采用shortcut操作,而Yolov4/5中则采用concat操作,特征图融合后的尺寸发生了变化。

4 输出端

4.1 正样本采样

什么是正负样本?

正负样本都是针对于算法经过处理生成的框,用于计算损失,而在预测过程和验证过程是没有这个概念的。正例用来使预测结果更靠近真实值的,负例用来使预测结果更远离除了真实值之外的值的。正负样本的比例最好为1:1到1:2左右,数量差距不能太悬殊,特别是正样本数量本来就不太多的情况下。如果负样本远多于正样本,则负样本会淹没正样本的损失,从而降低网络收敛的效率与检测精度。这就是目标检测中常见的正负样本不均衡问题,解决方案之一是增加正样本数。

yolov5通过以下三个方法增加正样本数量:
(1) 跨anchor预测
假设一个GT框落在了某个预测分支的某个网格内,该网格具有3种不同大小anchor,若GT可以和这3种anchor中的多种anchor匹配,则这些匹配的anchor都可以来预测该GT框,即一个GT框可以使用多种anchor来预测。预测边框的宽高是基于anchor来预测的,而预测的比例值是有范围的,即0-4,如果标签的真实宽高与anchor的宽高的比例超过了4,那是不可能预测成功的,所以哪些anchor能匹配上哪些标签,就看anchor的宽(高)与标签的宽(高)的比例有没有超过4,如果超过了,那就不匹配。注意,这个比例是双向的比例,比如标签宽/anchor宽>4,不匹配,而anchor宽/标签宽>4,也是不匹配的。

(2) 跨grid预测

假设一个GT框落在了某个预测分支的某个网格内,则该网格有左、上、右、下4个邻域网格,根据GT框的中心位置,将最近的2个邻域网格也作为预测网格,也即一个GT框可以由3个网格来预测。有下面5种情况(如果标签边框的中心点正好落在格子中间,就只有这个格子了):

(3) 跨分支预测
假设一个GT框可以和2个甚至3个预测分支上的anchor匹配,则这2个或3个预测分支都可以预测该GT框,即一个GT框可以由多个预测分支来预测,重复anchor匹配和grid匹配的步骤,可以得到某个GT 匹配到的所有正样本。

yolov5的正样本匹配:即找到与targets对应的所有正样本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
def build_targets(self, p, targets):
    # Build targets for compute_loss(), input targets(image,class,x,y,w,h)
    na, nt = self.na, targets.shape[0]  # na为类别数,nt为目标数
    tcls, tbox, indices, anch = [], [], [], []
    gain = torch.ones(7, device=self.device)  # normalized to gridspace gain
    ai = torch.arange(na, device=self.device).float().view(na, 1).repeat(1, nt)  # ai.shape = (na, nt),锚框的索引,第二个维度复制nt遍
    targets = torch.cat((targets.repeat(na, 11), ai[..., None]), 2)  # targets.shape = (na, nt, 7)给每个目标加上锚框索引
    = 0.5  # bias
    off = torch.tensor(
        [
            [00],
            [10],
            [01],
            [-10],
            [0-1],  # j,k,l,m
            # [1, 1], [1, -1], [-1, 1], [-1, -1],  # jk,jm,lk,lm
        ],
        device=self.device).float() * g  # offsets
    for in range(self.nl):  # self.nl为预测层也就是检测头的数量,anchor匹配需要逐层进行
        anchors = self.anchors[i]  # 该预测层上的anchor尺寸,三个尺寸
        gain[2:6= torch.tensor(p[i].shape)[[3232]]  # 比如在P3层 gain=tensor([ 1.,  1., 80., 80., 80., 80.,  1.], device='cuda:0')
        # Match targets to anchors
        = targets * gain  # shape(3,n,7) 将归一化的gtbox乘以特征图尺度,将box坐标投影到特征图上
        if nt:
            # Matches
            = t[..., 4:6/ anchors[:, None]  # 计算标签box和当前层的anchors的宽高比,即:wb/wa,hb/ha
            = torch.max(r, 1 / r).max(2)[0] < self.hyp['anchor_t']  # 将比值和预先设置的比例anchor_t对比,符合条件为True,反之False
            # j = wh_iou(anchors, t[:, 4:6]) > model.hyp['iou_t']  # iou(3,n)=wh_iou(anchors(3,2), gwh(n,2))
            = t[j]  # 筛选出符合条件target
            # Offsets
            gxy = t[:, 2:4]  # 得到相对于以左上角为坐标原点的坐标                          假设某个gt的中心点为gxy=[22.20, 19.05]
            gxi = gain[[23]] - gxy  # 得到相对于右下角为坐标原点的坐标                   此时gxi=[17.80, 20.95]
            j, k = ((gxy % 1 < g) & (gxy > 1)).T  # jk判断gxy的中心点是否更偏向左上角     g=0.5 操作%1得到小数部分,小于0.5,所以j,k均为True
            l, m = ((gxi % 1 < g) & (gxi > 1)).T  # lm判断gxy的中心点是否更偏向右下角     g=0.5 l,m均为False,该舞台中心更偏向于左上角
            = torch.stack((torch.ones_like(j), j, k, l, m))  # 网格本身是True,再加上 上下左右
            = t.repeat((511))[j]  # 这里将t复制5个,然后使用j来过滤
            offsets = (torch.zeros_like(gxy)[None+ off[:, None])[j]
        else:
            = targets[0]
            offsets = 0
        # Define
        bc, gxy, gwh, a = t.chunk(41)  # (image, class), grid xy, grid wh, anchors
        a, (b, c) = a.long().view(-1), bc.long().T  # anchors, image, class 其中,a表示当前gt box和当前层的第几个anchor匹配上了
        gij = (gxy - offsets).long()  # .long()为取整 gij是gxy的整数部分
        gi, gj = gij.T  # grid indices (gi,gj)是我们计算出来的负责预测该gt box的网格的坐标。
        # Append
        # indices中是正样本所对应的gt的信息  b表示当前正样本对应的gt属于该batch内第几张图片,a表示gtbox与anchors的对应关系,gj负责预测的网格纵坐标,gi负责预测的网格横坐标
        indices.append((b, a, gj.clamp_(0, gain[3- 1), gi.clamp_(0, gain[2- 1)))  # image, anchor, grid indices
        # tbox, anch, tcls是正样本自己的信息
        tbox.append(torch.cat((gxy - gij, gwh), 1))  # 正样本相对网格的偏移,宽高
        anch.append(anchors[a])  # 正样本对应的anchor信息
        tcls.append(c)  # 正样本的类别信息
    return tcls, tbox, indices, anch

4.2 损失计算

Yolov5官方文档:https://docs.ultralytics.com/yolov5/tutorials/architecture_description/?h=loss#3-training-strategies

损失函数的调用点如下,在train.py中

pre:网络从三个特征图上得到3*(20*20+40*40+52*52)个先验框,每个先验框由6个参数:px,py,pw,ph,po和pcls

targets:一个batch中所有的目标(如果开启开启mosaic数据增强的话,每张图就包含原本多张图中的目标),每个目标有(image,class,x,y,w,h)共6个参数,shape=[ num,6]。

损失函数分三部分:(1)分类损失Lcls (BCE loss) (2)置信度损失Lobj(BCE loss) (3)边框损失Lloc(CIOU loss)

其中置信度损失在三个预测层(P3, P4, P5)上权重不同,分别为[4.0, 1.0, 0.4]

这三者的权重都是可以设置的,在默认的data/hyps/hyp.scratch-low.yaml中,如下图

这三个损失权重会根据类别、图像大小、检测层数量进行scale

4.2.1 分类损失

按照640乘640分辨率,3个输出层来算的话,P3是80乘80个格子,P4是40乘40,P5是20乘20,一共有8400个格子,并不是每一个格子上的输出都要去做分类损失计算的,只有负责预测对应物体的格子才需要做分类损失计算(边框损失计算也是一样)。

分类损失采用nn.BCEWithLogitsLoss,即二分类损失,比如现在有4个分类:猫、狗、猪、鸡,当前标签真值为猪,那么计算损失的时候,targets就是[0, 0, 1, 0],推理结果的分类部分也会有4个值,分别是4个分类的概率,就相当于计算4次二分类损失,取均值。分类的真值也不一定是0或1,因为可以做label smoothing。

1
2
3
4
5
# Classification
if self.nc > 1:  # cls loss (only if multiple classes)
    = torch.full_like(pcls, self.cn, device=self.device)  # torch.full_like返回一个形状与pcls相同且值全为self.cn的张量
    t[range(n), tcls[i]] = self.cp  # 对应类别处为self.cp, 其余类别处为self.cn
    lcls += self.BCEcls(pcls, t)  # BCE

4.2.2 置信度损失

每一个格子都要计算置信度损失,置信度的真值并不是固定的,如果该格子负责预测对应的物体,那么置信度真值就是预测边框与标签边框的IOU。如果不负责预测任何物体,那真值就是0。

与早期版本的YOLO相比,YOLOv5架构对预测框策略进行了更改。在YOLOv2和YOLOv3中,使用最后一层的激活直接预测框坐标。如下图所示

而在YOLOv5中,用于预测框坐标的公式已经更新,以降低网格灵敏度,并防止模型预测没有边界。计算预测边界框的修订公式如下:

Yolov5预测框坐标计算,以与target的iou计算

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# pxy, pwh, _, pcls = pi[b, a, gj, gi].tensor_split((2, 4, 5), dim=1)  # faster, requires torch 1.8.0
pxy, pwh, _, pcls = pi[b, a, gj, gi].split((221self.nc), 1)  # target-subset of predictions
# Regression
pxy = pxy.sigmoid() * 2 - 0.5
pwh = (pwh.sigmoid() * 2** 2 * anchors[i]
pbox = torch.cat((pxy, pwh), 1)  # predicted box
iou = bbox_iou(pbox, tbox[i], CIoU=True).squeeze()  # iou(prediction, target)
lbox += (1.0 - iou).mean()  # iou loss
# Objectness
iou = iou.detach().clamp(0).type(tobj.dtype)
if self.sort_obj_iou:
    = iou.argsort()
    b, a, gj, gi, iou = b[j], a[j], gj[j], gi[j], iou[j]
if self.gr < 1:
    iou = (1.0 - self.gr) + self.gr * iou
tobj[b, a, gj, gi] = iou  # iou ratio

4.2.3 边框损失

Bounding Box Regeression的Loss近些年的发展过程是:Smooth L1 Loss-> IoU Loss(2016)-> GIoU Loss(2019)-> DIoU Loss(2020)->CIoU Loss(2020),Yolov5用的是CIOU。

其中,ρ预测框和真实框的中心点的欧式距离,也就是图中的d,c代表的是能够同时包含预测框和真实框的最小闭包区域的对角线距离,v测量纵横比的一致性,α是正的权衡参数,

Yolov5中IOU、CIoU、DIoU、GIoU的计算

论文:Distance-IoU Loss: Faster and Better Learning for Bounding Box Regression

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
def bbox_iou(box1, box2, xywh=True, GIoU=False, DIoU=False, CIoU=False, eps=1e-7):
    # Returns Intersection over Union (IoU) of box1(1,4) to box2(n,4)
    # Get the coordinates of bounding boxes
    if xywh:  # transform from xywh to xyxy
        (x1, y1, w1, h1), (x2, y2, w2, h2) = box1.chunk(41), box2.chunk(41)
        w1_, h1_, w2_, h2_ = w1 / 2, h1 / 2, w2 / 2, h2 / 2
        b1_x1, b1_x2, b1_y1, b1_y2 = x1 - w1_, x1 + w1_, y1 - h1_, y1 + h1_
        b2_x1, b2_x2, b2_y1, b2_y2 = x2 - w2_, x2 + w2_, y2 - h2_, y2 + h2_
    else:  # x1, y1, x2, y2 = box1
        b1_x1, b1_y1, b1_x2, b1_y2 = box1.chunk(41)
        b2_x1, b2_y1, b2_x2, b2_y2 = box2.chunk(41)
        w1, h1 = b1_x2 - b1_x1, b1_y2 - b1_y1 + eps
        w2, h2 = b2_x2 - b2_x1, b2_y2 - b2_y1 + eps
    # Intersection area
    inter = (torch.min(b1_x2, b2_x2) - torch.max(b1_x1, b2_x1)).clamp(0* \
            (torch.min(b1_y2, b2_y2) - torch.max(b1_y1, b2_y1)).clamp(0)
    # Union Area
    union = w1 * h1 + w2 * h2 - inter + eps
    # IoU
    iou = inter / union
    if CIoU or DIoU or GIoU:
        cw = torch.max(b1_x2, b2_x2) - torch.min(b1_x1, b2_x1)  # convex (smallest enclosing box) width
        ch = torch.max(b1_y2, b2_y2) - torch.min(b1_y1, b2_y1)  # convex height
        if CIoU or DIoU:  # Distance or Complete IoU https://arxiv.org/abs/1911.08287v1
            c2 = cw ** 2 + ch ** 2 + eps  # convex diagonal squared
            rho2 = ((b2_x1 + b2_x2 - b1_x1 - b1_x2) ** 2 + (b2_y1 + b2_y2 - b1_y1 - b1_y2) ** 2/ 4  # center dist ** 2
            if CIoU:  # https://github.com/Zzh-tju/DIoU-SSD-pytorch/blob/master/utils/box/box_utils.py#L47
                = (4 / math.pi ** 2* torch.pow(torch.atan(w2 / h2) - torch.atan(w1 / h1), 2)
                with torch.no_grad():
                    alpha = / (v - iou + (1 + eps))
                return iou - (rho2 / c2 + * alpha)  # CIoU
            return iou - rho2 / c2  # DIoU
        c_area = cw * ch + eps  # convex area
        return iou - (c_area - union) / c_area  # GIoU https://arxiv.org/pdf/1902.09630.pdf
    return iou  # IoU

 

 

参考:

1. YOLOv5-Lite 详解教程

2. 【YOLOv5】正样本分配详解

3. Yolov3-v5正负样本匹配机制

4. 手把手带你调参YOLOv5

5. 复制-粘贴大法(Copy-Paste):简单而有效的数据增强

6. 数据增强mixup技术

7. YOLOv5网络模型的结构原理讲解

8. Yolov5核心基础知识完整讲解

9. YOLOv5 Autoanchor 机制详解

 

一个.Net强大的Excel控件,支持WinForm、WPF、Android【强烈推荐】 - chingho - 博客园

mikel阅读(839)

来源: 一个.Net强大的Excel控件,支持WinForm、WPF、Android【强烈推荐】 – chingho – 博客园

推荐一个强大的电子表单控件,使用简单且功能强大。

项目简介

这是一个开源的表格控制组件,支持Winform、WPF和Android平台,可以方便的加载、修改和导出Excel文件,支持数据格式、大纲、公式计算、图表、脚本执行等、还支持触摸滑动,可以方便地操作表格。

总的来说是一个可以快速构建、具有高性能、良好交互、美观的UI表格控件。

控件核心功能

1、工作簿:支持多工作表、工作表选项卡控件;

2、工作表:支持合并、取消合并、单元格编辑、数据格式、自定义单元格、填充数据序列、单元格文本旋转、富文本、剪贴板、下拉列表单元格、边框、样式、分组过滤等;

3、打印:打印、分页打印;

4、图片:插入图片;

5、图表:折线、柱状、条形、面积图、饼图等;

6、文件格式:支持导出Excel、CSV、Html、RGF格式。

使用方法

下面以WinForm举例

1、安装控件

PM> Install-Package unvell.ReoGrid.dll

2、拖拉控件

图片

3、向填充单元格文本

var worksheet = reoGridControl1.CurrentWorksheet;
worksheet.Cells["A1"].Data = "Hello World!";
worksheet.Cells["A1"].Style.TextColor = Color.Red;

效果

图片

4、汇总折线图

var worksheet = reoGridControl1.CurrentWorksheet;

worksheet["A2"] = new object[,] {
  { null, 2008, 2009, 2010, 2011, 2012 },
  { "City 1", 3, 2, 4, 2, 6 },
  { "City 2", 7, 5, 3, 6, 4 },
  { "City 3", 13, 10, 9, 10, 9 },
  { "Total", "=SUM(B3:B5)", "=SUM(C3:C5)", "=SUM(D3:D5)",
"=SUM(E3:E5)", "=SUM(F3:F5)" },
};

var dataRange = worksheet.Ranges["B3:F5"];
var serialNamesRange = worksheet.Ranges["A3:A6"];
var categoryNamesRange = worksheet.Ranges["B2:F2"];

worksheet.AddHighlightRange(categoryNamesRange);
worksheet.AddHighlightRange(serialNamesRange);
worksheet.AddHighlightRange(dataRange);

var c1 = new LineChart
{
    Location = new unvell.ReoGrid.Graphics.Point(500, 50),
    Size = new Size(400, 260),

    Title = "折线图示例",

    DataSource = new WorksheetChartDataSource(worksheet, serialNamesRange, dataRange)
    {
        CategoryNameRange = categoryNamesRange,
    }
};

worksheet.FloatingObjects.Add(c1);

效果

图片

更多效果图

图片

图片

图片

项目地址

https://github.com/unvell/ReoGrid

如何兼顾性能+实时性处理缓冲数据? - Artech - 博客园

mikel阅读(708)

来源: 如何兼顾性能+实时性处理缓冲数据? – Artech – 博客园

我们经常会遇到这样的数据处理应用场景:我们利用一个组件实时收集外部交付给它的数据,并由它转发给一个外部处理程序进行处理。考虑到性能,它会将数据存储在本地缓冲区,等累积到指定的数量后打包发送;考虑到实时性,数据不能在缓冲区存太长的时间,必须设置一个延时时间,一旦超过这个时间,缓冲的数据必须立即发出去。看似简单的需求,如果需要综合考虑性能、线程安全、内存分配,要实现起来还真有点麻烦。这个问题有不同的解法,本文提供一种实现方案。

一、实例演示
二、待处理的批量数据:Batch<T>
三、感知数据处理的时机:BatchChangeToken
四、接收、缓冲、打包和处理数据:Batcher<T>

一、实例演示

我们先来看看最终达成的效果。在如下这段代码中,我们使用一个Batcher<string>对象来接收应用分发给它的数据,该对象最终会在适当的时机处理它们。 调用Batcher<string>构造函数的三个参数分别表示:

  • processor:批量处理数据的委托对象,它指向的Process方法会将当前时间和处理的数据量输出到控制台上;
  • batchSize:单次处理的数据量,当缓冲的数据累积到这个阈值时会触发数据的自动处理。我们将这个阈值设置为10
  • interval:两次处理处理的最长间隔,我们设置为5秒
var batcher = new Batcher<string>(
    processor:Process,
    batchSize:10,
    interval: TimeSpan.FromSeconds(5));
var random = new Random();
while (true)
{
    var count = random.Next(1, 4);
    for (var i = 0; i < count; i++)
    {
        batcher.Add(Guid.NewGuid().ToString());
    }
    await Task.Delay(1000);
}

static void Process(Batch<string> batch)
{
    using (batch)
    {
        Console.WriteLine($"[{DateTimeOffset.Now}]{batch.Count} items are delivered.");
    }
}

如上面的代码片段所示,在一个循环中,我们每隔1秒钟随机添加1-3个数据项。从下图中可以看出,Process方法的调用具有两种触发条件,一是累积的数据量达到设置的阈值10,另一个则是当前时间与上一次处理时间间隔超过5秒。

clip_image002

二、待处理的批量数据:Batch<T>

除了上面实例涉及的Batcher<T>,该解决方案还涉及两个额外的类型,如下这个Batch<T>类型表示最终发送的批量数据。为了避免缓冲数据带来的内存分配,我们使用了一个单独的ArrayPool<T>对象来创建池化的数组,这个功能体现在静态方法CreatePooledArray方法上。由于构建Batch<T>对象提供的数组来源于对象池,在处理完毕后必须回归对象池,所以我们让这个类型实现了IDisposable接口,并将这一操作实现在Dispose方法种。在调用ArrayPool<T>对象的Return方法时,我们特意将数组清空。由于提供的数组来源于对象池,所以并不能保证每个数据元素都承载了有效的数据,实现的迭代器和返回数量的Count属性对此作了相应的处理。

public sealed class Batch<T> : IEnumerable<T>, IDisposable where T : class
{
    private bool _isDisposed;
    private int? _count;
    private readonly T[] _data;
    private static readonly ArrayPool<T> _pool = ArrayPool<T>.Create();

    public int Count
    {
        get
        {
            if (_isDisposed) throw new ObjectDisposedException(nameof(Batch<T>));
            if(_count.HasValue) return _count.Value;
            var count = 0;
            for (int index = 0; index < _data.Length; index++)
            {
                if (_data[index] is  null)
                {
                    break;
                }
                count++;
            }
            return (_count = count).Value;
        }
    }
    public Batch(T[] data) => _data = data ?? throw new ArgumentNullException(nameof(data));
    public void Dispose()
    {
        _pool.Return(_data, clearArray: true);
        _isDisposed = true;
    }
    public IEnumerator<T> GetEnumerator() => new Enumerator(this);
    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
    public static T[] CreatePooledArray(int batchSize) => _pool.Rent(batchSize);
    private void EnsureNotDisposed()
    {
        if (_isDisposed) throw new ObjectDisposedException(nameof(Batch<T>));
    }

    private sealed class Enumerator : IEnumerator<T>
    {
        private readonly Batch<T> _batch;
        private readonly T[] _data;
        private int _index = -1;
        public Enumerator(Batch<T> batch)
        {
            _batch = batch;
            _data = batch._data;
        }
        public T Current
        {
            get { _batch.EnsureNotDisposed(); return _data[_index]; }
        }
        object IEnumerator.Current => Current;
        public void Dispose() { }
        public bool MoveNext()
        {
            _batch.EnsureNotDisposed();
            return ++_index < _data.Length && _data[_index] is not null;
        }
        public void Reset()
        {
            _batch.EnsureNotDisposed();
            _index = -1;
        }
    }
}

三、感知数据处理的时机:BatchChangeToken

Batcher具有两个触发数据处理的设置:缓冲的数据量和两次数据处理之间的最长间隔。当累积的数据量或者当前时间与上一次处理的间隔达到阈值,缓冲的数据将自动被处理。.NET Core经常利用一个IChangeToken作为通知的令牌,为此我们定义了如下这个实现了该接口的BatchChangeToken类型。如下面的代码片段所示,上述两个触发条件体现在两个CancellationToken对象上,我们利用它们创建了对应的CancellationChangeToken对象,最后利用这两个CancellationChangeToken创建了一个CompositeChangeToken对象。这个CompositeChangeToken对象最终被用来实现了IChangeToken接口的三个成员。

internal sealed class BatchChangeToken : IChangeToken
{
    private readonly IChangeToken _innerToken;
    private readonly int _countThreshold;
    private readonly CancellationTokenSource _expirationTokenSource;
    private readonly CancellationTokenSource _countTokenSource;
    private int _counter;

    public BatchChangeToken(int countThreshold, TimeSpan timeThreshold)
    {
        _countThreshold = countThreshold;
        _countTokenSource = new CancellationTokenSource();
        _expirationTokenSource = new CancellationTokenSource(timeThreshold);
        var countToken = new CancellationChangeToken(_countTokenSource.Token);
        var expirationToken = new CancellationChangeToken(_expirationTokenSource.Token);
        _innerToken = new CompositeChangeToken(new IChangeToken[] { countToken, expirationToken });
    }

    public bool HasChanged => _innerToken.HasChanged;
    public bool ActiveChangeCallbacks => _innerToken.ActiveChangeCallbacks;
    public IDisposable RegisterChangeCallback(Action<object?> callback, object? state) => _innerToken.RegisterChangeCallback(s =>
    {
        callback(s);
        _countTokenSource.Dispose();
        _expirationTokenSource.Dispose();
    }, state);
    public void Increase()
    {
        Interlocked.Increment(ref _counter);
        if (_counter >= _countThreshold)
        {
            _countTokenSource.Cancel();
        }
    }
}

上述两个CancellationToken来源于对应的CancellationTokenSource,对应的字段为_countTokenSource和_expirationTokenSource。_expirationTokenSource根据设置的数据处理时间间隔创建而成。为了确定缓冲的数据量,我们提供了一个计数器,并利用Increase方法进行计数。在超过设置的数据量时,该方法会调用_expirationTokenSource的Cancel方法。在实现的ActiveChangeCallbacks方法种,我们将针对这两个CancellationTokenSource的释放放在注册的回调中。

四、接收、缓冲、打包和处理数据:Batcher<T>

最终用于打包的Batcher类型定义如下。在构造函数中,我们除了提供上述两个阈值外,还提供了一个Action<Batch<T>>委托完成针对打包数据的处理。通过Add方法接收的数据存储在_data字段返回的数组上,它时通过Batch<T>的静态方法CreatePooledArray提供的。我们使用字段_index表示添加数据在_data数组中存储的位置,并使用InterLocked.Increase方法解决并发问题。

public sealed class Batcher<T> : IDisposable where T : class
{
    private readonly Action<Batch<T>> _processor;
    private T[] _data;
    private BatchChangeToken _changeToken = default!;
    private readonly int _batchSize;
    private int _index = -1;
    private readonly IDisposable _scheduler;

    public Batcher(Action<Batch<T>> processor, int batchSize, TimeSpan interval)
    {
        _processor = processor ?? throw new ArgumentNullException(nameof(processor));
        _batchSize = batchSize;
        _data = Batch<T>.CreatePooledArray(batchSize);
        _scheduler = ChangeToken.OnChange(() => _changeToken = new BatchChangeToken(_batchSize, interval), OnChange);

        void OnChange()
        {
            var data = Interlocked.Exchange(ref _data, Batch<T>.CreatePooledArray(batchSize));
            if (data[0] is not null)
            {
                Interlocked.Exchange(ref _index, -1);
                _ = Task.Run(() => _processor.Invoke(new Batch<T>(data)));
            }
        }
    }

    public void Add(T item)
    {
        if (item is null) throw new ArgumentNullException(nameof(item));
        var index = Interlocked.Increment(ref _index);
        if (index >= _batchSize)
        {
            SpinWait.SpinUntil(() => _index < _batchSize - 1);
            Add(item);
        }
        _data[index] = item;
        _changeToken.Increase();
    }

    public void Dispose() => _scheduler.Dispose();
}

在构造函数中,我们调用了ChangeToken的静态方法OnChange将数据处理操作绑定到创建的BatchChangeToken对象上,并确保每次发送“数据处理”后将重新创建的BatchChangeToken对象赋值到_changeToken字段上,因为Add放到需要调用它的Increase增加计数。当接收到数据处理通知后,我们会调用Batch<T>的静态方法CreatePooledArray构建一个数组将字段 ­_data引用的数组替换下来,并将其封装成Batch<T>对象进行处理(如果数据存在)。于此同时,表示添加数据存储索引的_index恢复成-1。Add方法在对_index做自增操作后,如果发现累积的数据量达到阈值,需要等待数据处理完毕。由于数据处理以异步的方式处理,这里的耗时时很低的,所以我们这里选择了自旋的方式等待它完成。

由C# yield return引发的思考 - yi念之间 - 博客园

mikel阅读(724)

来源: 由C# yield return引发的思考 – yi念之间 – 博客园

前言#

当我们编写 C# 代码时,经常需要处理大量的数据集合。在传统的方式中,我们往往需要先将整个数据集合加载到内存中,然后再进行操作。但是如果数据集合非常大,这种方式就会导致内存占用过高,甚至可能导致程序崩溃。

C# 中的yield return机制可以帮助我们解决这个问题。通过使用yield return,我们可以将数据集合按需生成,而不是一次性生成整个数据集合。这样可以大大减少内存占用,并且提高程序的性能。

在本文中,我们将深入讨论 C# 中yield return的机制和用法,帮助您更好地理解这个强大的功能,并在实际开发中灵活使用它。

使用方式#

上面我们提到了yield return将数据集合按需生成,而不是一次性生成整个数据集合。接下来通过一个简单的示例,我们看一下它的工作方式是什么样的,以便加深对它的理解

foreach (var num in GetInts())
{
    Console.WriteLine("外部遍历了:{0}", num);
}

IEnumerable<int> GetInts()
{
    for (int i = 0; i < 5; i++)
    {
        Console.WriteLine("内部遍历了:{0}", i);
        yield return i;
    }
}

首先,在GetInts方法中,我们使用yield return关键字来定义一个迭代器。这个迭代器可以按需生成整数序列。在每次循环时,使用yield return返回当前的整数。通过1foreach循环来遍历 GetInts方法返回的整数序列。在迭代时GetInts方法会被执行,但是不会将整个序列加载到内存中。而是在需要时,按需生成序列中的每个元素。在每次迭代时,会输出当前迭代的整数对应的信息。所以输出的结果为

内部遍历了:0
外部遍历了:0
内部遍历了:1
外部遍历了:1
内部遍历了:2
外部遍历了:2
内部遍历了:3
外部遍历了:3
内部遍历了:4
外部遍历了:4

可以看到,整数序列是按需生成的,并且在每次生成时都会输出相应的信息。这种方式可以大大减少内存占用,并且提高程序的性能。当然从c# 8开始异步迭代的方式同样支持

await foreach (var num in GetIntsAsync())
{
    Console.WriteLine("外部遍历了:{0}", num);
}

async IAsyncEnumerable<int> GetIntsAsync()
{
    for (int i = 0; i < 5; i++)
    {
        await Task.Yield();
        Console.WriteLine("内部遍历了:{0}", i);
        yield return i;
    }
}

和上面不同的是,如果需要用异步的方式,我们需要返回IAsyncEnumerable类型,这种方式的执行结果和上面同步的方式执行的结果是一致的,我们就不做展示了。上面我们的示例都是基于循环持续迭代的,其实使用yield return的方式还可以按需的方式去输出,这种方式适合灵活迭代的方式。如下示例所示

foreach (var num in GetInts())
{
    Console.WriteLine("外部遍历了:{0}", num);
}

IEnumerable<int> GetInts()
{
    Console.WriteLine("内部遍历了:0");
    yield return 0;

    Console.WriteLine("内部遍历了:1");
    yield return 1;

    Console.WriteLine("内部遍历了:2");
    yield return 2;
}

foreach循环每次会调用GetInts()方法,GetInts()方法的内部便使用yield return关键字返回一个结果。每次遍历都会去执行下一个yield return。所以上面代码输出的结果是

内部遍历了:0
外部遍历了:0
内部遍历了:1
外部遍历了:1
内部遍历了:2
外部遍历了:2

探究本质#

上面我们展示了yield return如何使用的示例,它是一种延迟加载的机制,它可以让我们逐个地处理数据,而不是一次性地将所有数据读取到内存中。接下来我们就来探究一下神奇操作的背后到底是如何实现的,方便让大家更清晰的了解迭代体系相关。

foreach本质#

首先我们来看一下foreach为什么可以遍历,也就是如果可以被foreach遍历的对象,被遍历的操作需要满足哪些条件,这个时候我们可以反编译工具来看一下编译后的代码是什么样子的,相信大家最熟悉的就是List<T>集合的遍历方式了,那我们就用List<T>的示例来演示一下

List<int> ints = new List<int>();
foreach(int item in ints)
{
    Console.WriteLine(item);
}

上面的这段代码很简单,我们也没有给它任何初始化的数据,这样可以排除干扰,让我们能更清晰的看到反编译的结果,排除其他干扰。它反编译后的代码是这样的

List<int> list = new List<int>();
List<int>.Enumerator enumerator = list.GetEnumerator();
try
{
    while (enumerator.MoveNext())
    {
        int current = enumerator.Current;
        Console.WriteLine(current);
    }
}
finally
{
    ((IDisposable)enumerator).Dispose();
}

可以反编译代码的工具有很多,我用的比较多的一般是ILSpydnSpydotPeek和在线c#反编译网站sharplab.io,其中dnSpy还可以调试反编译的代码。

通过上面的反编译之后的代码我们可以看到foreach会被编译成一个固定的结构,也就是我们经常提及的设计模式中的迭代器模式结构

Enumerator enumerator = list.GetEnumerator();
while (enumerator.MoveNext())
{
   var current = enumerator.Current;
}

通过这段固定的结构我们总结一下foreach的工作原理

  • 可以被foreach的对象需要要包含GetEnumerator()方法
  • 迭代器对象包含MoveNext()方法和Current属性
  • MoveNext()方法返回bool类型,判断是否可以继续迭代。Current属性返回当前的迭代结果。

我们可以看一下List<T>类可迭代的源码结构是如何实现的

public class List<T> : IList<T>, IList, IReadOnlyList<T>
{
    public Enumerator GetEnumerator() => new Enumerator(this);
 
    IEnumerator<T> IEnumerable<T>.GetEnumerator() => Count == 0 ? SZGenericArrayEnumerator<T>.Empty : GetEnumerator();
 
    IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable<T>)this).GetEnumerator();

    public struct Enumerator : IEnumerator<T>, IEnumerator
    {
        public T Current => _current!;
        public bool MoveNext()
        {
        }
    }
}

这里涉及到了两个核心的接口IEnumerable<IEnumerator,他们两个定义了可以实现迭代的能力抽象,实现方式如下

public interface IEnumerable
{
    IEnumerator GetEnumerator();
}

public interface IEnumerator
{
    bool MoveNext();
    object Current{ get; }
    void Reset();
}

如果类实现IEnumerable接口并实现了GetEnumerator()方法便可以被foreach,迭代的对象是IEnumerator类型,包含一个MoveNext()方法和Current属性。上面的接口是原始对象的方式,这种操作都是针对object类型集合对象。我们实际开发过程中大多数都是使用的泛型集合,当然也有对应的实现方式,如下所示

public interface IEnumerable<out T> : IEnumerable
{
    new IEnumerator<T> GetEnumerator();
}

public interface IEnumerator<out T> : IDisposable, IEnumerator
{
    new T Current{ get; }
}

可以被foreach迭代并不意味着一定要去实现IEnumerable接口,这只是给我们提供了一个可以被迭代的抽象的能力。只要类中包含GetEnumerator()方法并返回一个迭代器,迭代器里包含返回bool类型的MoveNext()方法和获取当前迭代对象的Current属性即可。

yield return本质#

上面我们看到了可以被foreach迭代的本质是什么,那么yield return的返回值可以被IEnumerable<T>接收说明其中必有蹊跷,我们反编译一下我们上面的示例看一下反编译之后代码,为了方便大家对比反编译结果,这里我把上面的示例再次粘贴一下

foreach (var num in GetInts())
{
    Console.WriteLine("外部遍历了:{0}", num);
}

IEnumerable<int> GetInts()
{
    for (int i = 0; i < 5; i++)
    {
        Console.WriteLine("内部遍历了:{0}", i);
        yield return i;
    }
}

它的反编译结果,这里咱们就不全部展示了,只展示一下核心的逻辑

//foeach编译后的结果
IEnumerator<int> enumerator = GetInts().GetEnumerator();
try
{
    while (enumerator.MoveNext())
    {
        int current = enumerator.Current;
        Console.WriteLine("外部遍历了:{0}", current);
    }
}
finally
{
    if (enumerator != null)
    {
        enumerator.Dispose();
    }
}

//GetInts方法编译后的结果
private IEnumerable<int> GetInts()
{
    <GetInts>d__1 <GetInts>d__ = new <GetInts>d__1(-2);
    <GetInts>d__.<>4__this = this;
    return <GetInts>d__;
}

这里我们可以看到GetInts()方法里原来的代码不见了,而是多了一个<GetInts>d__1l类型,也就是说yield return本质是语法糖。我们看一下<GetInts>d__1类的实现

//生成的类即实现了IEnumerable接口也实现了IEnumerator接口
//说明它既包含了GetEnumerator()方法,也包含MoveNext()方法和Current属性
private sealed class <>GetIntsd__1 : IEnumerable<int>, IEnumerable, IEnumerator<int>, IEnumerator, IDisposable
{
    private int <>1__state;
    //当前迭代结果
    private int <>2__current;
    private int <>l__initialThreadId;
    public C <>4__this;
    private int <i>5__1;

    //当前迭代到的结果
    int IEnumerator<int>.Current
    {
        get{ return <>2__current; }
    }

    //当前迭代到的结果
    object IEnumerator.Current
    {
        get{ return <>2__current; }
    }

    //构造函数包含状态字段,变向说明靠状态机去实现核心流程流转
    public <GetInts>d__1(int <>1__state)
    {
        this.<>1__state = <>1__state;
        <>l__initialThreadId = Environment.CurrentManagedThreadId;
    }

    //核心方法MoveNext
    private bool MoveNext()
    {
        int num = <>1__state;
        if (num != 0)
        {
            if (num != 1)
            {
                return false;
            }
            //控制状态
            <>1__state = -1;
            //自增 也就是代码里循环的i++
            <i>5__1++;
        }
        else
        {
            <>1__state = -1;
            <i>5__1 = 0;
        }
        //循环终止条件 上面循环里的i<5
        if (<i>5__1 < 5)
        {
            Console.WriteLine("内部遍历了:{0}", <i>5__1);
            //把当前迭代结果赋值给Current属性
            <>2__current = <i>5__1;
            <>1__state = 1;
            //说明可以继续迭代
            return true;
        }
        //迭代结束
        return false;
    }

    //IEnumerator的MoveNext方法
    bool IEnumerator.MoveNext()
    {
        return this.MoveNext();
    }

    //IEnumerable的IEnumerable方法
    IEnumerator<int> IEnumerable<int>.IEnumerable()
    {
        //实例化<GetInts>d__1实例
        <GetInts>d__1 <GetInts>d__;
        if (<>1__state == -2 && <>l__initialThreadId == Environment.CurrentManagedThreadId)
        {
            <>1__state = 0;
            <GetInts>d__ = this;
        }
        else
        {
            //给状态机初始化
            <GetInts>d__ = new <GetInts>d__1(0);
            <GetInts>d__.<>4__this = <>4__this;
        }
        //因为<GetInts>d__1实现了IEnumerator接口所以可以直接返回
        return <GetInts>d__;
    }

    IEnumerator IEnumerable.GetEnumerator()
    {
        //因为<GetInts>d__1实现了IEnumerator接口所以可以直接转换
        return ((IEnumerable<int>)this).GetEnumerator();
    }

    void IEnumerator.Reset()
    {
    }

    void IDisposable.Dispose()
    {
    }
}

通过它生成的类我们可以看到,该类即实现了IEnumerable接口也实现了IEnumerator接口说明它既包含了GetEnumerator()方法,也包含MoveNext()方法和Current属性。用这一个类就可以满足可被foeach迭代的核心结构。我们手动写的for代码被包含到了MoveNext()方法里,它包含了定义的状态机制代码,并且根据当前的状态机代码将迭代移动到下一个元素。我们大概讲解一下我们的for代码被翻译到MoveNext()方法里的执行流程

  • 首次迭代时<>1__state被初始化成0,代表首个被迭代的元素,这个时候Current初始值为0,循环控制变量<i>5__1初始值也为0。
  • 判断是否满足终止条件,不满足则执行循环里的逻辑。并更改装填机<>1__state为1,代表首次迭代执行完成。
  • 循环控制变量<i>5__1继续自增并更改并更改装填机<>1__state为-1,代表可持续迭代。并循环执行循环体的自定义逻辑。
  • 不满足迭代条件则返回false,也就是代表了MoveNext()以不满足迭代条件while (enumerator.MoveNext())逻辑终止。

上面我们还展示了另一种yield return的方式,就是同一个方法里包含多个yield return的形式

IEnumerable<int> GetInts()
{
    Console.WriteLine("内部遍历了:0");
    yield return 0;

    Console.WriteLine("内部遍历了:1");
    yield return 1;

    Console.WriteLine("内部遍历了:2");
    yield return 2;
}

上面这段代码反编译的结果如下所示,这里咱们只展示核心的方法MoveNext()的实现

private bool MoveNext()
{
    switch (<>1__state)
    {
        default:
            return false;
        case 0:
            <>1__state = -1;
            Console.WriteLine("内部遍历了:0");
            <>2__current = 0;
            <>1__state = 1;
            return true;
        case 1:
            <>1__state = -1;
            Console.WriteLine("内部遍历了:1");
            <>2__current = 1;
            <>1__state = 2;
            return true;
        case 2:
            <>1__state = -1;
            Console.WriteLine("内部遍历了:2");
            <>2__current = 2;
            <>1__state = 3;
            return true;
        case 3:
            <>1__state = -1;
            return false;
    }
}

通过编译后的代码我们可以看到,多个yield return的形式会被编译成switch...case的形式,有几个yield return则会编译成n+1case,多出来的一个case则代表的MoveNext()终止条件,也就是返回false的条件。其它的case则返回true表示可以继续迭代。

IAsyncEnumerable接口#

上面我们展示了同步yield return方式,c# 8开始新增了IAsyncEnumerable<T>接口,用于完成异步迭代,也就是迭代器逻辑里包含异步逻辑的场景。IAsyncEnumerable<T>接口的实现代码如下所示

public interface IAsyncEnumerable<out T>
{
    IAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken cancellationToken = default);
}

public interface IAsyncEnumerator<out T> : IAsyncDisposable
{
    ValueTask<bool> MoveNextAsync();
    T Current { get; }
}

它最大的不同则是同步的IEnumerator包含的是MoveNext()方法返回的是boolIAsyncEnumerator接口包含的是MoveNextAsync()异步方法,返回的是ValueTask<bool>类型。所以上面的示例代码

await foreach (var num in GetIntsAsync())
{
    Console.WriteLine("外部遍历了:{0}", num);
}

所以这里的await虽然是加在foreach上面,但是实际作用的则是每一次迭代执行的MoveNextAsync()方法。可以大致理解为下面的工作方式

IAsyncEnumerator<int> enumerator = list.GetAsyncEnumerator();
while (enumerator.MoveNextAsync().GetAwaiter().GetResult())
{
   var current = enumerator.Current;
}

当然,实际编译成的代码并不是这个样子的,我们在之前的文章<研究c#异步操作async await状态机的总结>一文中讲解过async await会被编译成IAsyncStateMachine异步状态机,所以IAsyncEnumerator<T>结合yield return的实现比同步的方式更加复杂而且包含更多的代码,不过实现原理可以结合同步的方式类比一下,但是要同时了解异步状态机的实现,这里咱们就不过多展示异步yield return的编译后实现了,有兴趣的同学可以自行了解一下。

foreach增强#

c# 9增加了对foreach的增强的功能,即通过扩展方法的形式,对原本具备包含foreach能力的对象增加GetEnumerator()方法,使得普通类在不具备foreach的能力的情况下也可以使用来迭代。它的使用方式如下

Foo foo = new Foo();
foreach (int item in foo)
{
    Console.WriteLine(item);
}

public class Foo
{
    public List<int> Ints { get; set; } = new List<int>();
}

public static class Bar
{
    //给Foo定义扩展方法
    public static IEnumerator<int> GetEnumerator(this Foo foo)
    {
        foreach (int item in foo.Ints)
        {
            yield return item;
        }
    }
}

这个功能确实比较强大,满足开放封闭原则,我们可以在不修改原始代码的情况,增强代码的功能,可以说是非常的实用。我们来看一下它的编译后的结果是啥

Foo foo = new Foo();
IEnumerator<int> enumerator = Bar.GetEnumerator(foo);
try
{
    while (enumerator.MoveNext())
    {
        int current = enumerator.Current;
        Console.WriteLine(current);
    }
}
finally
{
    if (enumerator != null)
    {
        enumerator.Dispose();
    }
}

这里我们看到扩展方法GetEnumerator()本质也是语法糖,会把扩展能力编译成扩展类.GetEnumerator(被扩展实例)的方式。也就是我们写代码时候的原始方式,只是编译器帮我们生成了它的调用方式。接下来我们看一下GetEnumerator()扩展方法编译成了什么

public static IEnumerator<int> GetEnumerator(Foo foo)
{
    <GetEnumerator>d__0 <GetEnumerator>d__ = new <GetEnumerator>d__0(0);
    <GetEnumerator>d__.foo = foo;
    return <GetEnumerator>d__;
}

看到这个代码是不是觉得很眼熟了,不错和上面yield return本质这一节里讲到的语法糖生成方式是一样的了,同样的编译时候也是生成了一个对应类,这里的类是<GetEnumerator>d__0,我们看一下该类的结构

private sealed class <GetEnumerator>d__0 : IEnumerator<int>, IEnumerator, IDisposable
{
    private int <>1__state;
    private int <>2__current;
    public Foo foo;
    private List<int>.Enumerator <>s__1;
    private int <item>5__2;

    int IEnumerator<int>.Current
    {
        get{ return <>2__current; }
    }

    object IEnumerator.Current
    {
        get{ return <>2__current; }
    }

    public <GetEnumerator>d__0(int <>1__state)
    {
        this.<>1__state = <>1__state;
    }

    private bool MoveNext()
    {
        try
        {
            int num = <>1__state;
            if (num != 0)
            {
                if (num != 1)
                {
                    return false;
                }
                <>1__state = -3;
            }
            else
            {
                <>1__state = -1;
                //因为示例中的Ints我们使用的是List<T>
                <>s__1 = foo.Ints.GetEnumerator();
                <>1__state = -3;
            }
            //因为上面的扩展方法里使用的是foreach遍历方式
            //这里也被编译成了实际生产方式
            if (<>s__1.MoveNext())
            {
                <item>5__2 = <>s__1.Current;
                <>2__current = <item>5__2;
                <>1__state = 1;
                return true;
            }
            <>m__Finally1();
            <>s__1 = default(List<int>.Enumerator);
            return false;
        }
        catch
        {
            ((IDisposable)this).Dispose();
            throw;
        }
    }

    bool IEnumerator.MoveNext()
    {
        return this.MoveNext();
    }

    void IDisposable.Dispose()
    {
    }

    void IEnumerator.Reset()
    {
    }

    private void <>m__Finally1()
    {
    }
}

看到编译器生成的代码,我们可以看到yield return生成的代码结构都是一样的,只是MoveNext()里的逻辑取决于我们写代码时候的具体逻辑,不同的逻辑生成不同的代码。这里咱们就不在讲解它生成的代码了,因为和上面咱们讲解的代码逻辑是差不多的。

总结#

通过本文我们介绍了c#中的yield return语法,并探讨了由它带来的一些思考。我们通过一些简单的例子,展示了yield return的使用方式,知道了迭代器来是如何按需处理大量数据。同时,我们通过分析foreach迭代和yield return语法的本质,讲解了它们的实现原理和底层机制。好在涉及到的知识整体比较简单,仔细阅读相关实现代码的话相信会了解背后的实现原理,这里就不过多赘述了。

当你遇到挑战和困难时,请不要轻易放弃。无论你面对的是什么,只要你肯努力去尝试,去探索,去追求,你一定能够克服困难,走向成功。记住,成功不是一蹴而就的,它需要我们不断努力和坚持。相信自己,相信自己的能力,相信自己的潜力,你一定能够成为更好的自己。

php随机抽奖及抽奖概率程序_php随机抽奖程序_feitianli37的博客-CSDN博客

mikel阅读(719)

来源: php随机抽奖及抽奖概率程序_php随机抽奖程序_feitianli37的博客-CSDN博客

php抽奖概率方法

$notice = ‘谢谢回顾’;
$prizeList = [
1=>10, //一等奖中奖概率10%
2=>50,//二等奖中奖概率50%
3=>20,//三等奖中奖概率20%
4=>20//四等奖中奖概率20%
]

$prizeName = [
1=>’一等奖’,
2=>’二等奖’,
3=>’三等奖’,
4=>’四等奖’,
];
//奖项的设置和概率可以手动设置化;
$total = array_sum($prizeList);

foreach($prizeList as $key=>$value) {
$randNumber = mt_rand(1,$total);
if($randNumber<=$value){
$notice = $prizeName[$key];
break;
}else{
$total -=$value;
}
}

var_dump($notice);

 

php抽奖概率程序
抽奖概率思想:

1.给每一个奖项设置要给概率数,如下面所有奖品综合设置为100,iphone5s是5,也就是5%

2.然后通过php生成随机数函数生成一个在总概率之间的随机数

如:抽第一个奖品5s的时候,因为是第一次foreach循环,产生的随机数就是在0-100之内的,判断是否中奖,则是看生成的随机数是否在0-5之内,如果在则抽中,否则就是循环到第二件奖品,笔记本是10,但是这里要注意一点,产生的随机数应该是减去之前的如5s中的5

 

<?php

$prize_arr = array(
‘0’ => array(‘id’ => 1, ‘title’ => ‘iphone5s’, ‘v’ => 5),
‘1’ => array(‘id’ => 2, ‘title’ => ‘联系笔记本’, ‘v’ => 10),
‘2’ => array(‘id’ => 3, ‘title’ => ‘音箱设备’, ‘v’ => 20),
‘3’ => array(‘id’ => 4, ‘title’ => ’30GU盘’, ‘v’ => 30),
‘4’ => array(‘id’ => 5, ‘title’ => ‘话费50元’, ‘v’ => 10),
‘5’ => array(‘id’ => 6, ‘title’ => ‘iphone6s’, ‘v’ => 15),
‘6’ => array(‘id’ => 7, ‘title’ => ‘谢谢,继续加油哦!~’, ‘v’ => 10),
);

foreach ($prize_arr as $key => $val) {
$arr[$val[‘id’]] = $val[‘v’];
}

$prize_id = getRand($arr); //根据概率获取奖品id
$data[‘msg’] = ($prize_id == 7) ? 0 : 1; //如果为0则没中
$data[‘prize_title’] = $prize_arr[$prize_id – 1][‘title’]; //中奖奖品
echo json_encode($data);
exit; //以json数组返回给前端

function getRand($proArr) { //计算中奖概率
$rs = ”; //z中奖结果
$proSum = array_sum($proArr); //概率数组的总概率精度
//概率数组循环
foreach ($proArr as $key => $proCur) {
$randNum = mt_rand(1, $proSum);
if ($randNum <= $proCur) {
$rs = $key;
break;
} else {
$proSum -= $proCur;
}
}
unset($proArr);
return $rs;
}

?>

https://blog.csdn.net/weixin_40462767/article/details/86525748

https://www.cnblogs.com/shiwenhu/p/5650269.html
————————————————
版权声明:本文为CSDN博主「feitianli37」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/han_cui/article/details/114304746

ThinkPHP3.2.3:执行原生SQL语句_tp3原生sql_李维山的博客-CSDN博客

mikel阅读(494)

来源: ThinkPHP3.2.3:执行原生SQL语句_tp3原生sql_李维山的博客-CSDN博客

【查询语句】query方法

示例:查询blog_article表中的文章标题title字段

//构造SQL语句
$SQL = “select `title` from blog_article”;

//或者下面两种,都会自动读取当前设置的表前缀
//$sql = “select `title` from __PREFIX__article”;
//$sql = “select `title` from __ARTICLE__”;

//实例化model对象,执行query方法,得到查询数据结果集
$res = M()->query($sql);

【添加、修改、删除语句】execute方法

示例:修改blog_article表中id为1的文章标题title字段为“PHP是世界上最好的语言”

//构造sql语句
$sql = “update blog_article set title=’PHP是世界上最好的语言’ where id=1”;

//或者下面两种,都会自动读取当前设置的表前缀
//$sql = “update __PREFIX__article set title=’PHP是世界上最好的语言’ where id=1”;
//$sql = “update __ARTICLE__ set title=’PHP是世界上最好的语言’ where id=1”;

//实例化model对象,执行execute方法,返回影响行数
$res = M()->execute($sql);

————————————————
版权声明:本文为CSDN博主「李维山」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/msllws/article/details/82949390

mysql查询某个id在表中是第几条数据,并且在第几页_sql 查询某条记录在第几页_小黑雷的博客-CSDN博客

mikel阅读(938)

来源: mysql查询某个id在表中是第几条数据,并且在第几页_sql 查询某条记录在第几页_小黑雷的博客-CSDN博客

对于一般的企业站内说文章不多但是设计中详情又有返回,可能很多都是记录的,这条数据在第几页。但是还有一种情况如果还有上一篇下一篇,刚好这篇文章在当前页的最后一篇那返回就没有效果了

例如我有表a,

字段有:id int(key),title varchar(64) (文章标题),category int(类别), is_top char(1)(是否置顶),toptime int(置顶时间)

解决方法1:

需要知道这篇文章在文章列表第几条

这时候需要用到mySQL一个函数@rownum 这个就是前面的序号

SELECT id, @rownum := @rownum +1 AS rowsnumber
FROM a, (SELECT@rownum :=0) r
WHERE category = ‘3’
order by is_top desc,toptime desc,id desc
现在知道了在表中第几条了

这时候需要知道第几页用到mySQL函数ceil()/celing()

假设每页5条查询id为106的在第几页结合上面的sql

select rowsnumber from (SELECT id, @rownum := @rownum +1 AS rowsnumber
FROM a, (SELECT@rownum :=0) r
WHERE category = ‘3’
order by is_top desc,toptime desc,id desc) as a where a.id=106
上一页下一页

select * from (SELECT id,title, @rownum := @rownum +1 AS rowsnumber
FROM a, (SELECT@rownum :=0) r
WHERE category = ‘3’
order by is_top desc,toptime desc,id desc) as a where
a.rowsnumber =”上一页带入上面sql返回条数-1,下一页带入上面sql返回条数+1″
解决方法2:

思路就是在详情页的时候带上第几页,那么返回按钮就知道返回第几页数据了,

上一篇下一篇获取在第几条

结合方法一@rownum用法

例如表中有1,2,3,4,5,6,7条数据,第7条置顶了刚好查看的这篇文章id为4

mysql查询第一页数据每页5条

SELECT id,title, @rownum := @rownum +1 AS rowsnumber
FROM a, (SELECT@rownum :=0) r
WHERE category = ‘3’
order by sticky desc,id desc,stickTime desc limit 0,5
在使用代码处理,下面是php代码

例如查询结果为$data

$rowsnumber=array_reduce($data,function($a,$b) use ($id){

if($b[‘id’]==$id){

$a=$b[‘rowsnumber’];

}

return $a;

},0 );
switch(true){
case $rowsnumber=5:
$lastnews=array_reduce($data,function($a,$b) use ($rowsnumber){

if($b[‘rowsnumber’]==($rowsnumber-1)){

$a=$b;

}

return $a;

},array() );
$nextnews=array();//这里药反查下一页第一条
break;
case $rowsnumber>1 and $rowsnumber<5:
foreach($data as $val){

if($val[‘rowsnumber’] == ($rowsnumber – 1)){

$lastnews=$val;

}

if($val[‘rowsnumber’] == ($rowsnumber + 1)){

$nextnews=$val;

}

}
break;
case $rowsnumber=5:
$nextnews=array_reduce($data,function($a,$b) use ($rowsnumber){

if($b[‘rowsnumber’]==($rowsnumber+1)){

$a=$b;

}

return $a;

},array() );
$lastnews=array();//这里药反查上一页最后一条
break;

}

csdn编辑器没有提示代码可能写的有错误,写的不好望见谅,只是突然想到写一下。
————————————————
版权声明:本文为CSDN博主「小黑雷」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/u010757785/article/details/85098243

通过微信公众号跳转H5页面领取现金红包_h5微信公众号发红包api_webMoonV的博客-CSDN博客

mikel阅读(1577)

来源: 通过微信公众号跳转H5页面领取现金红包_h5微信公众号发红包api_webMoonV的博客-CSDN博客

通过微信公众号跳转H5页面领取现金红包

项目目的
通过公司微信公众号领取现金奖励

面向对象
公司内部员工

产品逻辑:

1.在微信公众号底部栏加一项“领取奖励”,点击“领取奖励”进入到一个H5页面,页面内容是“输入手机号”与“输入验证码”,“获取验证码”按钮,“领取奖励”按钮。

2.在最开始时,需要将H5页面的地址给到后台人员,配置好,方便获取code。
进入页面时,前端人员在地址栏中截取code,并存储。

3.前端人员需自行判断手机号的格式是否正确。

4.用户在输入手机号后,点击“获取验证码”时,前端向后台调获取验证码的接口,此时后台会给到一个标识true或false,当前用户是否有现金奖励。

5.用户在输入验证码之后,点击“领取”奖励按钮时,如果后台前面给到的标识是false,那么出现弹框“您暂时无现金奖励”。

6.如果后台前面给到的标识是true,那么调后台接口,将手机号,验证码和code一起传给后台获取用户的openId。后台来控制红包的发放。

技术逻辑:
1.获取code的注意点
①首先是成为当前公众号的开发者。具体设置:微信公众平台–开发者工具–web开发者工具中将自己的微信号绑定为开发者。

②保存开发者ID和密码,并配置好ip白名单。具体设置:微信公众平台–基本配置–启用开发者密码。

③配置好js接口安全域名和网页授权域名。具体设置:微信公众平台–公众号设置–功能设置。需要注意:每个月只能改3次,最好一次性把开发环境,测试环境,和线上环境都配置好,然后把网页授权域名配置为开发环境,方便后期调试。

④在公众号的自定义菜单里,将url按照固定格式拼接,填上去。

⑤用开发者工具调试,每次点击回车,url都会发生改变,url中会有code字段,code每次都是不一样的。

⑥将微信返回的url转化为jsom对象,通过字符串截取的方式获得。

代码如下:

function setUrlData() {
var str = decodeURI(window.location.search);
// var str = decodeURI(‘?id=15618040519&from=wx&openId=oJ-UZ0aOWfU02oXUFxmonVgA-jvg’);
var obj = {}
var data = str.substr(1).split(‘&’);
for(var value of data ) {
obj[value.split(‘=’)[0]] = value.split(‘=’)[1]
}
return obj
}

var urldata = setUrlData();
console.log(urldata);
var code = urldata.code;

2.在点击“获取验证码”按钮时
①首先判断手机号的格式是否正确
通过正则表达式

如果手机号的格式不正确
提示“请输入正确的手机号”,验证码地方不改变
如果手机号的格式正确,
1.并且手机号符合规定,”获取验证码“====>“60s”,setInterVal(),再调后台的接口,获取验证码
2.如果手机号不符合规定,验证码地方不变,input框提示“请输入正确的手机号”

代码如下:

function judgeCode(){
code = $(“.codeInput”).val();
if(!(/^\d{4,6}$/.test(code))){
$(“.codeInput”).addClass(“default”);
$(“.codeInput”).val(“请输入正确的验证码”);
return false;
}
return true;
}

如果验证码的格式正确:
将手机号和验证码还有code一起传给后台,调后台接口
成功之后,发送红包

3.如果奖励金额大于200元,则需要发多个红包
因为微信红包最多可以发200元红包
————————————————
版权声明:本文为CSDN博主「webMoonV」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/qq_35176149/article/details/89459809

微信内网页开发 - 公众号发红包_lzqustc的博客-CSDN博客

mikel阅读(618)

来源: 微信内网页开发 – 公众号发红包_lzqustc的博客-CSDN博客

接口文档:

https://pay.weixin.qq.com/wiki/doc/api/tools/cash_coupon.php?chapter=13_4&index=3

 

一、开通现金红包权限

二、下载API证书

三、充值

以上步骤请参考:https://pay.weixin.qq.com/wiki/doc/api/tools/cash_coupon.php?chapter=13_3&index=2

 

服务端逻辑:

1、服务端先根据用户在APP中获得的红包金额,创建一条数据库记录(分配红包兑换码,用户信息,金额等等)

2、用户关注公众号,点击公众号菜单:红包兑换,菜单的链接可以是授权登录的链接,点击菜单经过服务器授权登录,获得用户的openid,然后重定向到H5兑换页面(openid作为页面参数)

 

3、用户输入兑换码,H5页面把openid和兑换码信息提交给服务器的Perl CGI脚本,例如https:/xxxx/cgi-bin/pay.pl?do=GetRedPack&redpack_code=xxxxxxx&openid=xxxxxx

 

脚本处理

if ($cgi->param(‘do’) eq “GetRedPack”) {

}

4、CGI脚本根据兑换码,调用服务端接口获取数据库记录,然后组合相关参数调用微信普通红包接口,给用户发红包

 

代码片段:

use CGI;

use warnings;

use JSON;

use utf8;

use Digest::MD5 qw/md5_hex/;

use HTTP::Request;

use HTTP::Headers;

use LWP::UserAgent;

use Encode;

use XML::Simple;

use Data::Dumper;

 

if ($cgi->param(‘do’) eq “GetRedPack”) { # ?do=GetRedPack&openid=xxx&redpack_code=xxx

my $openid = $cgi->param(‘openid’);

my $redpack_code=$cgi->param(‘redpack_code’);

 

my $redpack_info = get_redpack_by_code($redpack_code);

 

my $now_t = time();

my $wx_order_info;

$wx_order_info->{mch_id}=$MCH_ID;

$wx_order_info->{nonce_str}=nonce_str();

my $mch_billno=$MCH_ID.formateTime($now_t).$now_t;

if (length($mch_billno) > 28) {

$mch_billno = substr($mch_billno, 0, 28);

}

my $total_rmb = $redpack_info->{money_rmb} + 0;

my $total_num = $redpack_info->{total_num} + 0;

$wx_order_info->{mch_billno} = $mch_billno;

$wx_order_info->{wxappid}=$APPID;

$wx_order_info->{send_name}=”Tester”;

$wx_order_info->{re_openid}=$openid;

$wx_order_info->{total_amount}=100*$total_rmb;

$wx_order_info->{total_num}=1;

$wx_order_info->{wishing}=$redpack_info->{wishing}; #”happy new year”;

$wx_order_info->{client_ip}=”1.1.1.1″; #需要填写服务器ip

$wx_order_info->{act_name}=$redpack_info->{act_name};

$wx_order_info->{remark}=$redpack_info->{remark}; #”throw more get more”;

 

#红包金额大于200时,请求参数scene_id必传

$wx_order_info->{scene_id}=”PRODUCT_3″;

#write_log(“req_json:”.Dumper($wx_order_info));

 

$wx_order_info->{sign} = sign($wx_order_info, “false”);

#write_log(“req_sign:”.Dumper($wx_order_info));

 

my $request_xml = create_xml_data($wx_order_info);

#write_log(“req_xml:”.$request_xml);

my $header = HTTP::Headers->new( Content_Type => ‘text/xml; charset=utf8’, );

my $http_request = HTTP::Request->new( POST => “https://api.mch.weixin.qq.com/mmpaymkttransfers/sendredpack”, $header, $request_xml);

my $ua = LWP::UserAgent->new(

ssl_opts => {

verify_hostname => 0,

#SSL_verify_mode => 0x00,

#SSL_ca_file => ‘/var/redpack/rootca.pem’

SSL_use_cert => 1,

SSL_cert_file => ‘/var/redpack/apiclient_cert.pem’,

SSL_key_file => ‘/var/redpack/apiclient_key.pem’,

SSL_passwd_cb => sub { $MCH_ID },

},

); #本接口需要上传证书

my $response = $ua->request($http_request);

my $response_json;

if ($response->message ne “OK” && $response->is_success ne “1”) { #出错,或者timeout了

$response_json->{return_code} = “99999”;

$response_json->{return_msg} = $response->message;

$response_json->{err_code} = $response->is_success;

} else {

my $decode_rsp = $response->decoded_content();

write_log(“\nrsp_xml_utf8:”.$decode_rsp);

$response_json = parse_xml_response( $decode_rsp);

}

 

my $ret = update_redpack_by_code($redpack_code);

print_html_notify_rsp(“success”);

if ($response_json->{return_code} eq “SUCCESS” && $response_json->{result_code} eq “SUCCESS”) {

$redirect_url = “http://xxxx/redpack_success.html?redpack_code=$redpack_code”;

print $cgi->redirect($redirect_url); #发送成功,跳转到成功页面

} else {

$redirect_url = “http://xxxx/redpack_failed.html?redpack_code=$redpack_code”;

print $cgi->redirect($redirect_url); #发送失败,跳转到成功页面

}

exit;

}
————————————————
版权声明:本文为CSDN博主「lzqustc」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/lzqustc/article/details/84878304

【实践篇】手把手教你落地DDD - 京东云技术团队 - 博客园

mikel阅读(747)

来源: 【实践篇】手把手教你落地DDD – 京东云技术团队 – 博客园

1. 前言

常见的DDD实现架构有很多种,如经典四层架构、六边形(适配器端口)架构、整洁架构(Clean Architecture)、CQRS架构等。架构无优劣高下之分,只要熟练掌握就都是合适的架构。本文不会逐个去讲解这些架构,感兴趣的读者可以自行去了解。

本文将带领大家从日常的三层架构出发,精炼推导出我们自己的应用架构,并且将这个应用架构实现为Maven Archetype,最后使用我们Archetype创建一个简单的CMS项目作为本文的落地案例。

需要明确的是,本文只是给读者介绍了DDD应用架构,还有许多概念没有涉及,例如实体、值对象、聚合、领域事件等,如果读者对完整落地DDD感兴趣,可以到本文最后了解更多。

2. 应用架构演化

我们很多项目是基于三层架构的,其结构如图:

我们说三层架构,为什么还画了一层 Model 呢?因为 Model 只是简单的 Java Bean,里面只有数据库表对应的属性,有的应用会将其单独拎出来作为一个
Maven Module,但实际上可以合并到 DAO 层。

接下来我们开始对这个三层架构进行抽象精炼。

2.1 第一步、数据模型与DAO层合并

为什么数据模型要与DAO层合并呢?

首先,数据模型是贫血模型,数据模型中不包含业务逻辑,只作为装载模型属性的容器;

其次,数据模型与数据库表结构的字段是一一对应的,数据模型最主要的应用场景就是DAO层用来进行 ORM,给 Service 层返回封装好的数据模型,供Service 获取模型属性以执行业务;

最后,数据模型的 Class 或者属性字段上,通常带有 ORM 框架的一些注解,跟DAO层联系非常紧密,可以认为数据模型就是DAO层拿来查询或者持久化数据的,数据模型脱离了DAO层,意义不大。

2.2 第二步、Service层抽取业务逻辑

下面是一个常见的 Service 方法的伪代码,既有缓存、数据库的调用,也有实际的业务逻辑,整体过于臃肿,要进行单元测试更是无从下手。

public class Service {

    @Transactional
    public void bizLogic(Param param) {

        checkParam(param);//校验不通过则抛出自定义的运行时异常

        Data data = new Data();//或者是mapper.queryOne(param);

        data.setId(param.getId());

        if (condition1 == true) {
            biz1 = biz1(param.getProperty1());
            data.setProperty1(biz1);
        } else {
            biz1 = biz11(param.getProperty1());
            data.setProperty1(biz1);
        }

        if (condition2 == true) {
            biz2 = biz2(param.getProperty2());
            data.setProperty2(biz2);
        } else {
            biz2 = biz22(param.getProperty2());
            data.setProperty2(biz2);
        }

        //省略一堆set方法
        mapper.updateXXXById(data);
    }
}

这是典型的事务脚本的代码:先做参数校验,然后通过 biz1、biz2 等子方法做业务,并将其结果通过一堆 Set 方法设置到数据模型中,再将数据模型更新到数据库。

由于所有的业务逻辑都在 Service 方法中,造成 Service 方法非常臃肿,Service 需要了解所有的业务规则,并且要清楚如何将基础设施串起来。同样的一条规则,例如if(condition1=true),很有可能在每个方法里面都出现。

专业的事情就该让专业的人干,既然业务逻辑是跟具体的业务场景相关的,我们想办法把业务逻辑提取出来,形成一个模型,让这个模型的对象去执行具体的业务逻辑。这样Service方法就不用再关心里面的 if/else 业务规则,只需要通过业务模型执行业务逻辑,并提供基础设施完成用例即可。

将业务逻辑抽象成模型,这样的模型就是领域模型。

要操作领域模型,必须先获得领域模型,但此时我们先不管领域模型怎么得到,假设是通过loadDomain方法获得的。通过 Service方法的入参,我们调用loadDomain方法得到一个模型,我们让这个模型去做业务逻辑,最后执行的结果也都在模型里,我们再将模型回写数据库。当然,怎么写数据库的我们也先不管,假设是通过saveDomain方法。

Service层的方法经过抽取之后,将得到如下的伪代码:

public class Service {

    public void bizLogic(Param param) {

        //如果校验不通过,则抛一个运行时异常
        checkParam(param);
        //加载模型
        Domain domain = loadDomain(param);
        //调用外部服务取值
	    SomeValue someValue=this.getSomeValueFromOtherService(param.getProperty2());
        //模型自己去做业务逻辑,Service不关心模型内部的业务规则
        domain.doBusinessLogic(param.getProperty1(), someValue);
        //保存模型
        saveDomain(domain);
    }
}

根据代码,我们已经将业务逻辑抽取出来了,领域相关的业务规则封闭在领域模型内部。此时 Service方法非常直观,就是获取模型、执行业务逻辑、保存模型,再协调基础设施完成其余的操作。

抽取完领域模型后,我们工程的结构如下图:

2.3 第三步、维护领域对象生命周期

在上一步中,loadDomainsaveDomain 这两个方法还没有得到讨论,这两个方法跟领域对象的生命周期息息相关。

关于领域对象的生命周期的详细知识,读者可以自行学习了解。

不管是 loadDomain 还是 saveDomain,我们一般都要依赖于数据库,所以这两个方法对应的逻辑,肯定是要跟 DAO 产生联系的。

保存或者加载领域模型,我们可以抽象成一种组件,通过这种组件进行封装模型加载、保存的操作,这种组件就是Repository。

注意,Repository 是对加载或者保存领域模型(这里指的是聚合根,因为只有聚合根才会有Repository)的抽象,必须对上层屏蔽领域模型持久化的细节,因此其方法的入参或者出参,一定是基本数据类型或者领域模型,不能是数据库表对应的数据模型。

以下是 Repository 的伪代码:

public interface DomainRepository {

    void save(AggregateRoot root);

    AggregateRoot load(EntityId id);
}

接下来我们要考虑在哪里实现DomainRepository。既然 DomainRepository 与底层数据库有关联,但是我们现在 DAO 层并没有引入 Domain 这个包,DAO 层自然无法提供 DomainRepository的实现,我们初步考虑是不是可以将 DomainRepository 实现在 Service 层。

但是,如果我们在 Service 中实现DomainRepository,势必需要在 Service 层操作数据模型:查询出来数据模型再封装为领域模型、或者将领域模型转为数据模型再通过ORM 保存,这个过程不该是 Service 层关心的。

因此,我们决定在 DAO 层直接引入 Domain 包,并在 DAO 层提供 DomainRepository 接口的实现,DAO 层查询出数据模型之后,封装成领域模型供DomainRepository 返回。

这样调整之后, DAO 层不再向 Service 返回数据模型,而是返回领域模型,这就隐藏了数据库交互的细节,我们也把DAO层换个名字称之为Repository。

现在,我们项目的架构图是这样的了:

由于数据模型属于贫血模型,自身没有业务逻辑,并且只有Repository这个包会用到,因此我们将之合并到Repository中,接下来不再单独列举。

2.4 第四步、泛化抽象

在第三步中,我们的架构图已经跟经典四层架构非常相似了,我们再对某些层进行泛化抽象。

  • Infrastructure

Repository 仓储层其实属于基础设施层,只不过其职责是持久化和加载聚合,所以,我们将 Repository层改名为 infrastructure-persistence,可以理解为基础设施层持久化包。

之所以采取这种 infrastructure-XXX 的格式进行命名,是由于 Infrastructure 可能会有很多的包,分别提供不同的基础设施支持。

例如:一般的项目,还有可能需要引入缓存,我们就可以再加一个包,名字叫infrastructure-cache

对于外部的调用,DDD中有防腐层的概念,将外部模型通过防腐层进行隔离,避免污染本地上下文的领域模型。我们使用入口(Gateway)来封装对外部系统或资源的访问(详细见《企业应用架构模式》,18.1入口(Gateway)),因此将对外调用这一层称之为infrastructure-gateway

注意:Infrastructure 层的门面接口都应先在Domain 层定义,其方法的入参、出参,都应该是领域模型(实体、值对象)或者基本类型。

  • User Interface

Controller 层其实就是用户接口层,即 User Interface 层,我们在项目简称 ui。当然了可能很多开发者会觉得叫UI好像很别扭,认为 UI就是 UI 设计师设计的图形界面。

Controller 层的名字有很多,有的叫 Rest,有的叫 Resource,考虑到我们这一层不只是有 Rest 接口,还可能还有一系列 Web相关的拦截器,所以我一般称之为 Web。因此,我们将其改名为 ui-web,即用户接口层的 Web 包。

同样,我们可能会有很多的用户接口,但是他们通过不同的协议对外提供服务,因而被划分到不同的包中。

我们如果有对外提供的 RPC服务,那么其服务实现类所在的包就可以命名为 ui-provider

有时候引入某个中间件会同时增加 Infrastructure 和 User Interface。

例如,如果引入 Kafka 就需要考虑一下,如果是给 Service 层提供调用的,例如逻辑执行完发送消息通知下游,那么我们再加一个包infrastructure-publisher;如果是消费 Kafka 的消息,然后调用 Service 层执行业务逻辑的,那么就可以命名为 ui-subscriber

  • Application

至此,Service 层目前已经没有业务逻辑了,业务逻辑都在 Domain 层去执行了,Service 只是协调领域模型、基础设施层完成业务逻辑。

所以,我们把 Service 层改名为 Application Service 层。

经过第四步的抽象,其架构图为:

2.5 第五步、完整的包结构

我们继续对第四步中出现的包进行整理,此时还需要考虑一个问题,我们的启动类应该放在哪里?

由于有很多的 User Interface,所以启动类放在任意一个User Interface中都不合适,放置在Application Service中也不合适,因此,启动类应该存放在单独的模块中。又因为 application这个名字被应用层占用了,所以将启动类所在的模块命名为 launcher,一个项目可以存在多个launcher,按需引用User Interface。

加入启动包,我们就得到了完整的 maven 包结构。

包结构如图所示:

至此,DDD 项目的整体结构基本讲完了。

2.6 精炼后的思考

在经过前面五步精炼得到这个架构图中,经典四层架构的四层都出现了,而且长得跟六边形架构也很像。这是为什么呢?

其实,不管是经典四层架构、还是六边形架构,亦或者整洁架构,都是对系统应用的描述,也许描述的侧重点不一样,但是描述的是同一个事物。既然描述的是同一个事物,长得像才是理所当然的,不可能只是换一个描述方式,系统就从根本上发生了改变。

对于任何一个应用,都可以看成“输入-处理-输出”的过程。

“输入”环节:通过某种协议对外暴露领域的能力,这些协议可能是 REST、可能是 RPC、可能是 MQ 的订阅者,也可能是WebSocket,也可能是一些任务调度的 Task;

”处理“环节:处理环节是整个应用的核心,代表了应用具备的核心能力,是应用的价值所在,应用在这个环节执行业务逻辑,贫血模型由Service执行业务处理,充血模型则是由模型进行业务处理。

“输出”环节,业务逻辑执行完成之后将结果输出到外部。

不管我们采用的什么架构,其描述的应用的核心都是这个过程,不必生搬硬套非得用什么应用架构。

正如《金刚经》所言:一切有为法,如梦幻泡影,如露亦如电,应作如是观;凡所有相,皆是虚妄;若见诸相非相,即见如来。

3. ddd-archetype

3.1 Maven Archetype介绍

Maven Archetype是一个Maven插件,可以帮助开发人员快速创建项目的基础结构,大大减少开发人员在创建项目时所需的时间和精力,并且可以确保项目结构的一致性和可重用性,从而提高代码质量和可维护性。

我们在介绍DDD应用架构时,对项目的结构进行了介绍。我们将项目分为多个Maven Module,如果每个项目都手工创建一次,是比较繁琐的工作,也不利项目结构的统一。

我们使用Maven Archetype创建DDD项目初始化的脚手架,使其在初始化时完整实现上文第五步的应用架构。

3.2 ddd-archetype的使用

3.2.1 项目介绍

ddd-archetype是一个Maven Archetype的原型工程,我们将其克隆到本地之后,可以安装为Maven Archetype,帮助我们快速创建DDD项目脚手架。

项目链接:

https://github.com/feiniaojin/ddd-archetype

3.2.2 安装过程

以下将以IDEA为例展示ddd-archetype的安装使用过程,主要过程是:

克隆项目–>archetype:create-from-project–>install–>archetype:crawl

3.2.3 克隆项目

将项目克隆到本地:

git clone https://github.com/feiniaojin/ddd-archetype.git

直接使用主分支即可,然后使用IDEA打开该项目

3.2.4 archetype:create-from-project

配置打开IDEA的run/Debug configurations窗口,如下:

选择add new configurations,弹出以下窗口:

其中,上图中1~4各个标识的值为:

标识1 – 选择”+”号;

标识2 – 选择”Maven”;

标识3 – 命令为:

archetype:create-from-project -Darchetype.properties=archetype.properties

注意,在IDEA中添加的命令默认不需要加mvn

标识4 – 选择ddd-archetype的根目录

以上配置完成后,点击执行该命令。

3.2.5 install

上一步执行完成且无报错之后,配置install命令。

其中,上图中1~2各个标识的值为:

标识1 – 值为install

标识2 – 值为上一步运行的结果,路径为:

ddd-archetype/target/generated-sources/archetype

install配置完成之后,点击执行。

3.2.6 archetype:crawl

install执行完成且无报错,接着配置archetype:crawl命令。

其中,标识1中的值为:

archetype:crawl

配置完成,点击执行即可。

3.3 使用ddd-archetype初始化项目

  • 创建项目时,点击manage catalogs
  • 将本地的maven私服中的archetype-catalog.xml加入到catalogs中:

添加成功,如下:

  • 创建项目时,选择本地archetype-catalog,并且选择ddd-archetype,填入项目信息并创建项目:
  • 项目创建完成后:

4. 代码案例

本文提供了配套的代码案例,该案例使用DDD和本文的应用架构实现了简单的CMS系统。案例项目采用前后端分离的方式,因此有后端和前端两个代码库。

4.1 后端

后端项目使用本文的ddd-archetype创建,实现了部分CMS的功能,并落地部分DDD的概念。

GitHub链接:https://github.com/feiniaojin/ddd-example-cms

实现的DDD概念有:实体、值对象、聚合根、Factory、Repository、CQRS。

技术栈:

  • Spring Boot
  • H2内存数据库
  • Spring Data JDBC

无外部中间件依赖 ,clone到本地即可编译运行,非常方便。

4.2 前端

前端项目基于vue-element-admin开发,详细安装方式见代码库的README。

GitHub链接:https://github.com/feiniaojin/ddd-example-cms-front

4.3 运行截图

5. 总结以及进一步学习

本文通过对贫血三层架构进行精炼,推导出适合我们落地的应用架构,并且将之实现为Maven Archetype以应用到实际开发,然而应用架构只是落地DDD的一个知识点,要完整落地DDD还必须体系化地掌握限界上下文、上下文映射、充血模型、实体、值对象、领域服务、Factory、Repository等知识点。

作者:京东物流 覃玉杰

内容来源:京东云开发者社区