2024年1月

FastReport是一个非常不错的报表组件,在Winform应用中常常使用它进行报表的设计、预览展现、打印或者导出文件(PDF、Excel)等,可以设计打印各种各样的报表,本篇随笔继续介绍当前最新的FastReport报表模块,其中FastReport.WPF是专门针对WPF的报表组件。

1、回顾FastReport应用

我们回顾一下,我之前在随笔中总结的一些FastReport应用案例,虽然WPF使用上有所差异,不过报表的格式是同样的,处理规则也是大同小异。


使用FastReport报表工具生成标签打印文档
》、《
使用FastReport报表工具生成图片格式文档
》、《
使用FastReport报表工具生成报表PDF文档
》、《
使用FastReport报表工具实现信封套打功能
》、《
在Winform开发中使用FastReport创建报表
》、《
在Vue&Element前端项目中,使用FastReport + pdf.js生成并展示自定义报表
》、《
利用FastReport传递图片参数,在报表上展示签名信息
》,可以说在Winform报表处理中,我还是比较喜欢FastReport的的纯.net开发的报表组件。

我们可以看到,FastReport报表还是非常强大的,可以处理各种不同的报表展示。

2、FastReport.WPF报表模块的使用

FastReport.WPF 报表模块是2023年底才出来的一款专门针对WPF的报表应用,我们在Nugget上搜索FastReport.WPF,添加对应的FastReport.WPF.Demo模块就可以在我们的WPF应用中添加测试版本的FastReport.WPF组件了,如果我们有正式版本,也可以安装后直接添加本地引用文件即可。

报表的预览和设计可以通过直接在WPF后端代码打开对应的报表组件实现相应的效果,也可以通过在页面中展示对应组件(预览组件、设计组件)来承载报表信息,我们针对两种情况分别进行相关的说明。

我们在WPF的应用端中添加一个报表的测试页面,用来测试FastReport的报表处理,如下界面所示。

一般的FastReport报表的处理,差不多就是那几个步骤:

1、创建报表对象

2、加载报表文件

3、加载报表相关数据或者参数

4、调用报表设计或者预览处理。

我们来看看简单的按钮单击调用报表的处理,如下代码所示。

/// <summary>
///报表预览/// </summary>
private async void btnPreviewReport_Click(objectsender, RoutedEventArgs e)
{
var reportFile = Path.Combine(baseDir, "Report/Simple List.frx");using (var report = newReport())
{
//加载报表 report.Load(reportFile);//创建数据源 var ds =GetNorthWindDataSet();//绑定数据源 report.RegisterData(ds, "NorthWind");//运行报表 report.Show();
}
}
/// <summary> ///报表设计/// </summary> private void btnDesignReport_Click(objectsender, RoutedEventArgs e)
{
var reportFile = Path.Combine(baseDir, "Report/Simple List.frx");using (var report = newReport())
{
//加载报表 report.Load(reportFile);//创建数据源 var ds =GetNorthWindDataSet();//绑定数据源 report.RegisterData(ds, "NorthWind");//运行报表 report.Design();
}
}

当然我们也可以在Xaml中定义Command来处理对应的按钮事件。

Xam界面代码如下所示:

<ui:CardActionGrid.Row="0"Grid.Column="0"Margin="0,0,14,0"Padding="0"VerticalAlignment="Stretch"Command="{Binding DataContext.PreviewReportCommand, Mode=OneWay}"CommandParameter="Simple List.frx"IsChevronVisible="False">
    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinitionWidth="Auto" />
            <ColumnDefinitionWidth="*" />
        </Grid.ColumnDefinitions>
        <ImageWidth="60"Margin="24,0,0,0"Source="/Assets/dashboardItem1.png" />
        <StackPanelGrid.Column="1"Margin="24"VerticalAlignment="Center">
            <ui:TextBlockFontTypography="BodyStrong"Text="报表打印预览"TextWrapping="WrapWithOverflow" />
            <ui:TextBlockAppearance="Tertiary"Text="NorthWind报表预览"TextWrapping="WrapWithOverflow" />
        </StackPanel>
    </Grid>
</ui:CardAction>

Command的处理后端代码如下所示。

/// <summary>
///预览报表/// </summary>
/// <param name="path">报表路径</param>
[RelayCommand]private void PreviewReport(stringpath)
{
var reportFile = Path.Combine(Environment.CurrentDirectory, $"Report/{path}");if(File.Exists(reportFile))
{
using (var report = newReport())
{
//加载报表 report.Load(reportFile);//创建数据源 var ds =GetNorthWindDataSet();//绑定数据源 report.RegisterData(ds, "NorthWind");//运行报表 report.ShowAsync();
}
}
}

除了我们这里使用MVVM的处理定义,代码逻辑上和单击事件的处理差不多。

FastReport报表可以导出Excel、HTML、PDF等文档格式的文件,如下代码是导出PDF的处理代码,可以把报表导出和预览效果一样的PDF文件。

/// <summary>
///导出PDF/// </summary>
/// <param name="path"></param>
[RelayCommand]private void ReportExport(stringpath)
{
var reportFile = Path.Combine(baseDir, $"Report/{path}");if(File.Exists(reportFile))
{
using (var report = newReport())
{
//加载报表 report.Load(reportFile);//创建数据源 var ds =GetNorthWindDataSet();//绑定数据源 report.RegisterData(ds, "NorthWind");//运行报表 report.Prepare();//导出PDF报表 var file = FileDialogHelper.SavePdf("result.pdf");if (!string.IsNullOrEmpty(file))
{
var export = newPDFExport();
report.Export(export, file);
}
//打开PDF if(File.Exists(file))
{
Process.Start(
"explorer.exe", file);
}
}
}
}

导出文件后,我们在WPF程序中,可以通过 Process.Start("explorer.exe", file); 命令进行打开PDF文档。

这种通过FastReport内置类的Design或者Show方法展示的报表,是一个独立的窗口打开的。

有时候为了方便,我们可能需要设计一个页面或者窗口来放置我们的设计或者预览报表,那么我们可以通过在页面中展示对应组件(预览组件、设计组件)来承载报表信息。

需要在页面中添加对应的命名空间,如下代码所示。

<Pagex:Class="WHC.SugarProject.WpfUI.Views.Pages.FastReportPage"xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"xmlns:Design="clr-namespace:FastReport.Design;assembly=FastReport.WPF"
xmlns:Preview="clr-namespace:FastReport.Preview;assembly=FastReport.WPF"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"xmlns:local="clr-namespace:WHC.SugarProject.WpfUI.Views.Pages"xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"Title="FastReportPage"d:DesignHeight="850"d:DesignWidth="800"mc:Ignorable="d">

在页面中可以放置对应的组件对象,如下代码所示。

<StackPanelx:Name="reportPanel"Grid.Row="2"Grid.ColumnSpan="3"Margin="10">
    <Preview:WpfPreviewControlx:Name="previewControl"Height="600"Margin="10"ToolbarVisible="False" />
    <Design:WpfDesignerControlx:Name="designerControl"Height="600"Margin="10" />
</StackPanel>

展示报表的时候,后台代码处理如下所示。

private void btnShowReport_Click(objectsender, RoutedEventArgs e)
{
var reportFile = Path.Combine(baseDir, "Report/report.frx");var report = newReport();//加载报表 report.Load(reportFile);//创建数据源 var list =CreateBusinessObject();//绑定数据源 report.RegisterData(list, "Categories"); report.PrepareAsync(previewControl); //运行报表 }private void btnShowDesign_Click(objectsender, RoutedEventArgs e)
{
var reportFile = Path.Combine(baseDir, "Report/report.frx");var report = newReport();//加载报表 report.Load(reportFile);//创建数据源 var list =CreateBusinessObject();//绑定数据源 report.RegisterData(list, "Categories");
designerControl.Report
=report;
}

当然我们也可以单独设计一个页面来承载,如下页面所示。

通过页面的封装,我们在页面中提供一个公用的报表组件。

/// <summary>
///FastReportPreview.xaml 的交互逻辑/// </summary>
public partial classFastReportPreview : Window
{
/// <summary> ///报表对象/// </summary> public Report Report { get; set; } = new();//初始化对象 publicFastReportPreview()
{
InitializeComponent();
this.previewControl.Loaded += (s, e) =>{
Report.PrepareAsync(previewControl);
};

}
}

在调用报表页面展示的时候,对报表对象做相应的处理即可,然后调用窗口进行展示。

/// <summary>
///使用自定义窗口打开报表预览/// </summary>
private void btnShowReport2_Click(objectsender, RoutedEventArgs e)
{
var dlg = App.GetService<FastReportPreview>();if (dlg != null)
{
var reportFile = Path.Combine(baseDir, "Report/report.frx");//加载报表 dlg.Report.Load(reportFile);//创建数据源 var list =CreateBusinessObject();//绑定数据源 dlg.Report.RegisterData(list, "Categories");

dlg.ShowDialog();
}
}

同理设计报表也是一样的处理方式,不在赘述。

以上就是我WPF应用中使用FastReport.WPF报表模块的一些经验总结,结合之前的Winform案例经验,我们可以充分的利用FastReport的报表的相关功能,实现我们所需要的报表展示或者文件导出了。

最近我们的 Pulsar 存储有很长一段时间数据一直得不到回收,但消息确实已经是 ACK 了,理论上应该是会被回收的,随着时间流逝不但没回收还一直再涨,最后在没找到原因的情况下就只有一直不停的扩容。

最后磁盘是得到了回收,过程先不表,之后再讨论。

为了防止类似的问题再次发生,我们希望可以监控到磁盘维度,能够列出各个日志文件的大小以及创建时间。

这时就需要对
Pulsar
的存储模型有一定的了解,也就有了这篇文章。

image.png
讲到 Pulsar 的存储模型,本质上就是 Bookkeeper 的存储模型。

Pulsar 所有的消息读写都是通过 Bookkeeper 实现的。

Bookkeeper
是一个可扩展、可容错、低延迟的日志存储数据库,基于 Append Only 模型。(数据只能追加不能修改)

image.png

这里我利用 Pulsar 和 Bookkeeper 的 Admin API 列出了 Broker 和 BK 中 Ledger 分别占用的磁盘空间。

关于这个如何获取和计算的,后续也准备提交给社区。

背景

但和我们实际
kubernetes
中的磁盘占用量依然对不上,所以就想看看在 BK 中实际的存储日志和
Ledger
到底差在哪里。

知道 Ledger 就可以通过 Ledger 的元数据中找到对应的 topic,从而判断哪些 topic 的数据导致统计不能匹配。

Bookkeeper 有提提供一个Admin API 可以返回当前 BK 所使用了哪些日志文件的接口:
https://bookkeeper.apache.org/docs/admin/http#endpoint-apiv1bookielist_disk_filefile_typetype

从返回的结果可以看出,落到具体的磁盘上只有一个文件名称,是无法知道具体和哪些 Ledger 进行关联的,也就无法知道具体的 topic 了。

此时只能大胆假设,应该每个文件和具体的消息 ID 有一个映射关系,也就是索引。
所以需要搞清楚这个索引是如何运行的。

存储模型

我查阅了一些网上的文章和源码大概梳理了一个存储流程:

  1. BK 收到写入请求,数据会异步写入到
    Journal
    /
    Entrylog
  2. Journal 直接顺序写入,并且会快速清除已经写入的数据,所以需要的磁盘空间不多(所以从监控中其实可以看到 Journal 的磁盘占有率是很低的)。
  3. 考虑到会随机读消息,EntryLog 在写入前进行排序,保证落盘的数据中同一个 Ledger 的数据尽量挨在一起,充分利用 PageCache.
  4. 最终数据的索引通过
    LedgerId+EntryId
    生成索引信息存放到
    RockDB
    中(
    Pulsar
    的场景使用的是
    DbLedgerStorage
    实现)。
  5. 读取数据时先从获取索引,然后再从磁盘读取数据。
  6. 利用
    Journal

    EntryLog
    实现消息的读写分离。

简单来说 BK 在存储数据的时候会进行双写,
Journal
目录用于存放写的数据,对消息顺序没有要求,写完后就可以清除了。


Entry
目录主要用于后续消费消息进行读取使用,大部分场景都是顺序读,毕竟我们消费消息的时候很少会回溯,所以需要充分利用磁盘的 PageCache,将顺序的消息尽量的存储在一起。

同一个日志文件中可能会存放多个 Ledger 的消息,这些数据如果不排序直接写入就会导致乱序,而消费时大概率是顺序的,但具体到磁盘的表现就是随机读了,这样读取效率较低。

所以我们使用
Helm
部署
Bookkeeper
的时候需要分别指定
journal

ledgers
的目录

volumes:  
  # use a persistent volume or emptyDir  
  persistence: true  
  journal:  
    name: journal  
    size: 20Gi  
    local_storage: false  
    multiVolumes:  
      - name: journal0  
        size: 10Gi  
        # storageClassName: existent-storage-class  
        mountPath: /pulsar/data/bookkeeper/journal0  
      - name: journal1  
        size: 10Gi  
        # storageClassName: existent-storage-class  
        mountPath: /pulsar/data/bookkeeper/journal1  
  ledgers:  
    name: ledgers  
    size: 50Gi  
    local_storage: false  
    storageClassName: sc
    # storageClass:  
      # ...    useMultiVolumes: false  
    multiVolumes:  
      - name: ledgers0  
        size: 1000Gi  
        # storageClassName: existent-storage-class  
        mountPath: /pulsar/data/bookkeeper/ledgers0  
      - name: ledgers1  
        size: 1000Gi  
        # storageClassName: existent-storage-class  
        mountPath: /pulsar/data/bookkeeper/ledgers1



每次在写入和读取数据的时候都需要通过消息 ID 也就是 ledgerId 和 entryId 来获取索引信息。

也印证了之前索引的猜测。

所以借助于 BK 读写分离的特性,我们还可以单独优化存储。

比如写入
Journal
的磁盘因为是顺序写入,所以即便是普通的
HDD
硬盘速度也很快。

大部分场景下都是读大于写,所以我们可以单独为
Ledger
分配高性能 SSD 磁盘,按需使用。

因为在最底层的日志文件中无法直接通过 ledgerId 得知占用磁盘的大小,所以我们实际的磁盘占用率对不上的问题依然没有得到解决,这个问题我还会持续跟进,有新的进展再继续同步。

这篇文章并不讨论chan因为加锁解锁以及为了维持内存模型定义的行为而付出的运行时开销。

这篇文章要探讨的是chan在接收和发送数据时因为“复制”而产生的开销。

在做性能测试前先复习点基础知识。

本文索引

数据是如何在chan里流动的

首先我们来看看带buffer的chan,这里要分成两类来讨论。那没buffer的chan呢?后面会细说。

情况1:发送的数据有读者在读取

可能需要解释一下这节的标题,意思是:发送者正在发送数据同时另一个接收者在等待数据,看图可能更快一些↓

sending1

图里的chan是空的,发送者协程正在发送数据到channel,同时有一个接收者协程正在等待从chan里接收数据。

如果你对chan的内存模型比较了解的话,其实可以发现此时是buffered chan的一种特例,他的行为和无缓冲的chan是一样的,事实上两者的处理上也是类似的。

所以向无缓冲chan发送数据时的情况可以归类到情况1里。

在这种情况下,虽然在图里我们仍然画了chan的缓冲区,但实际上go有优化:chan发现这种情况后会使用runtime api,直接将数据写入接收者的内存(通常是栈内存),跳过chan自己的缓冲区,只复制数据一次。

这种情况下就像数据
直接
从发送者流到了接收者那里一样。

情况2:发送的数据没有读者在读取

这个情况就简单多了,基本上除了情况1之外的所有情形都属于这种:

sending2

图里描述的是最常见的情况,读者和写者在操作不同的内存。写者将数据复制进缓冲区然后返回,如果缓冲满了就阻塞到有可用的空位为止;读者从缓冲区中将数据复制到自己的内存里然后把对应位置的内存标记为可写入,如果缓冲区是空的,就阻塞到有数据可读为止。

可能有人会问,如果缓冲区满了导致发送的一方被阻塞了呢?其实发送者从阻塞恢复后需要继续发送数据,这时是逃不出情况1和情况2的,所以是否会被阻塞在这里不会影响数据发送的方式,并不重要。

在情况2中,数据先要被复制进chan自己的缓冲区,然后接收者在读取的时候在从chan的缓冲区把数据复制到自己的内存里。总体来说数据要被复制两次。

情况2中chan就像这个水池,数据先从发送者那流进水池里,过了一段时间后再从水池里流到接收者那里。

特例中的特例

这里要说的是空结构体:
struct{}
。在chan直接传递这东西不会有额外的内存开销,因为空结构体本身不占内存。和处理空结构体的map一样,go对这个特例做了特殊处理。

当然,虽然不会消耗额外的内存,但内存模型是不变的。为了方便起见你可以把这个特例想象成情况2,只是相比之下使用更少的内存。

为什么要复制

在情况1里我们看到了,runtime实际上有能力直接操作各个goroutine的内存,那么为什么不选择将数据“移动”到目标位置,而要选择复制呢?

我们先来看看如果是“移动”会发生什么。参考其他语言的惯例,被移动的对象将不可再被访问,它的数据也将处于一种不确定但可以被安全删除的状态,简单地说,一点变量里的数据被移动到其他地方,这个变量就不应该再被访问了。在一些语言里移动后变量将强制性不可访问,另一些语言里虽然可以访问但会产生“undefined behavior”使程序陷入危险的状态。go就比较尴尬了,既没有手段阻止变量在移动后继续被访问,也没有类似“undefined behavior”的手段兜底这些意外情况,随意panic不仅消耗性能更是稳定性方面的大忌。

因此移动在go中不现实。

再来看看在goroutine之间共享数据,对于可以操作goroutine内存的runtime来说,这个比移动要费事的多,但也可以实现。共享可能在cpu资源上会有些损耗,但确实能节约很多内存。

共享的可行性也比移动高一些,因为不会对现有语法和语言设计有较大的冲击,甚至可以说完全是在这套语法框架下合情合理的操作。但只要一个问题:不安全。chan的使用场景大部分情况下都是在并发编程中,共享的数据会带来严重的并发安全问题。最常见的就是共享的数据被意外修改。对于以便利且安全的并发操作为卖点的go语言来说,内置的并发原语会无时不刻生产出并发安全问题,无疑是不可接受的。

最后只剩下一个方案了,使用复制来传递数据。复制能在语法框架下使用,与共享相比也不容易引发问题(只是相对而言,chan的浅拷贝问题有时候反而是并发问题的温床)。这也是go遵循的CSP(Communicating Sequential Process)模型所提倡的。

复制导致的开销

既然复制有正当理由且不可避免,那我们只能选择接受了。因此复制会带来多大开销变得至关重要。

内存用量上的开销很简单就能计算出来,不管是情况1还是情况2,数据一个时刻最多只会有自己本体外加一个副本存在——情况1是发送者持有本体,接收者持有副本;情况2是发送者持有本体,chan的缓冲区或者接收者(从缓冲区复制过去后缓冲区置空)持有副本。当然,发送者完全可以将本体销毁这样只有一份数据留存在内存里。所以内存的消耗在最坏情况下会增加一倍。

cpu的消耗以及对速度的影响就没那么好估计了,这个只能靠性能测试了。

测试的设计很简单,选择大中小三组数据利用buffered chan来测试chan和协程直接复制数据的开销。

小的标准是2个
int64
,大小16字节,存进一个缓存行绰绰有余:

type SmallData struct {
    a, b int64
}

中型大小的数据更接**常的业务对象,大小是144字节,包含十多个字段:

type Data struct {
	a, b, c, d     int64
	flag1, flag2   bool
	s1, s2, s3, s4 string
	e, f, g, h     uint64
	r1, r2         rune
}

最后是大对象,大对象包含十个中对象,大小1440字节,我知道也许没人会这么写,也许实际项目里还有笔者更重量级的,我当然只能选个看起来合理的值用于测试:

type BigData struct {
	d1, d2, d3, d4, d5, d6, d7, d8, d9, d10 Data
}

鉴于chan会阻塞协程的特殊性,我们只能发完数据后再把它从chan里取出来,不然就得反复创建和释放chan,这样代来的杂音太大,因此数据实际上要被复制上两回,这里我们只关注内存复制的开销,其他因素控制好变量就不会有影响。完整的测试代码长这样:

import "testing"

type SmallData struct {
	a, b int64
}

func BenchmarkSendSmallData(b *testing.B) {
	c := make(chan SmallData, 1)
	sd := SmallData{
		a: -1,
		b: -2,
	}
	for i := 0; i < b.N; i++ {
		c <- sd
		<-c
	}
}

func BenchmarkSendSmallPointer(b *testing.B) {
	c := make(chan *SmallData, 1)
	sd := &SmallData{
		a: -1,
		b: -2,
	}
	for i := 0; i < b.N; i++ {
		c <- sd
		<-c
	}
}

type Data struct {
	a, b, c, d     int64
	flag1, flag2   bool
	s1, s2, s3, s4 string
	e, f, g, h     uint64
	r1, r2         rune
}

func BenchmarkSendData(b *testing.B) {
	c := make(chan Data, 1)
	d := Data{
		a:     -1,
		b:     -2,
		c:     -3,
		d:     -4,
		flag1: true,
		flag2: false,
		s1:    "甲甲甲",
		s2:    "乙乙乙",
		s3:    "丙丙丙",
		s4:    "丁丁丁",
		e:     4,
		f:     3,
		g:     2,
		h:     1,
		r1:    '测',
		r2:    '试',
	}
	for i := 0; i < b.N; i++ {
		c <- d
		<-c
	}
}

func BenchmarkSendPointer(b *testing.B) {
	c := make(chan *Data, 1)
	d := &Data{
		a:     -1,
		b:     -2,
		c:     -3,
		d:     -4,
		flag1: true,
		flag2: false,
		s1:    "甲甲甲",
		s2:    "乙乙乙",
		s3:    "丙丙丙",
		s4:    "丁丁丁",
		e:     4,
		f:     3,
		g:     2,
		h:     1,
		r1:    '测',
		r2:    '试',
	}
	for i := 0; i < b.N; i++ {
		c <- d
		<-c
	}
}

type BigData struct {
	d1, d2, d3, d4, d5, d6, d7, d8, d9, d10 Data
}

func BenchmarkSendBigData(b *testing.B) {
	c := make(chan BigData, 1)
	d := Data{
		a:     -1,
		b:     -2,
		c:     -3,
		d:     -4,
		flag1: true,
		flag2: false,
		s1:    "甲甲甲",
		s2:    "乙乙乙",
		s3:    "丙丙丙",
		s4:    "丁丁丁",
		e:     4,
		f:     3,
		g:     2,
		h:     1,
		r1:    '测',
		r2:    '试',
	}
	bd := BigData{
		d1:  d,
		d2:  d,
		d3:  d,
		d4:  d,
		d5:  d,
		d6:  d,
		d7:  d,
		d8:  d,
		d9:  d,
		d10: d,
	}
	for i := 0; i < b.N; i++ {
		c <- bd
		<-c
	}
}

func BenchmarkSendBigDataPointer(b *testing.B) {
	c := make(chan *BigData, 1)
	d := Data{
		a:     -1,
		b:     -2,
		c:     -3,
		d:     -4,
		flag1: true,
		flag2: false,
		s1:    "甲甲甲",
		s2:    "乙乙乙",
		s3:    "丙丙丙",
		s4:    "丁丁丁",
		e:     4,
		f:     3,
		g:     2,
		h:     1,
		r1:    '测',
		r2:    '试',
	}
	bd := &BigData{
		d1:  d,
		d2:  d,
		d3:  d,
		d4:  d,
		d5:  d,
		d6:  d,
		d7:  d,
		d8:  d,
		d9:  d,
		d10: d,
	}
	for i := 0; i < b.N; i++ {
		c <- bd
		<-c
	}
}

我们选择传递指针作为对比,这是日常开发中另一种常见的作法。

Windows11上的测试结果:

winbench

Linux上的测试结果:

linuxbench

对于小型数据,复制带来的开销并不是很突出。

对于中型和大型数据就没那么乐观了,性能分别下降了
20%

50%

测试结果很清晰,但有一点容易产生疑问,为什么大型数据比中型大了10倍,但复制速度上只慢了2.5倍呢?

原因是golang会对大数据启用SIMD指令增加单位时间内的数据吞吐量,因此数据大了确实复制会更慢,但不是数据量大10倍速度就会慢10倍的。

由此可见复制数据带来的开销是很难置之不理的。

如何避免开销

既然chan复制数据会产生不可忽视的性能开销,我们得想些对策来解决问题才行。这里提供几种思路。

只传小对象

多小才算小,这个争议很大。我只能说说我自己的经验谈:1个缓存行里存得下的就是小。

一个缓存行有多大?现代的x64 cpu上L1D的大小通常是32字节,也就是4个普通数据指针/
int64
的大小。

从我们的测试来看小数据的复制开销几乎可以忽略不记,因此只在chan里传递这类小数据不会有什么性能问题。

唯一要注意的是string,目前的实现一个字符串本身的大小是16字节,但这个大小是没算字符串本身数据的,也就是说一个长度256的字符串和一个长度1的字符串,自身的结构都是16字节大,但复制的时候一个要拷贝256个字符一个只用拷贝一个字符。因此字符串经常出现看着小但实际大小很大的实例。

只传指针

32字节实在是有点小,如果我需要传递2-3个缓存行大小的数据怎么办?这个也是实际开发中的常见需求。

答案实际上在性能测试的对照组里给出了:传指针给chan。

从性能测试的结果来看,只传指针的情况下,无论数据多大,耗时都是一样的,因为我们只复制了一份指针——8字节的数据。

这个作法也能节约内存:只复制了指针,指针引用的数据没有被复制。

看起来我们找到了向chan传递数据的银弹——只传指针,然而世界上并没有银弹——

  1. 传指针相当于上一节说的“共享”数据,很容易带来并发安全问题;
  2. 对于发送者,传指针给chan很可能会影响逃逸分析,不仅会在堆上分配对象,还会使情况1中的优化失去意义(调用runtime就为了写入一个指针到接收者的栈上)
  3. 对于接收者来说,操作指针引用的数据需要一次或多次的解引用,而这种解引用很难被优化掉,因此在一些热点代码上很可能会带来可见的性能影响(通常不会有复制数据带来的开销大,但一切得以性能测试为准)。
  4. 太多的指针会加重gc的负担

使用指针传递时切记要充分考虑上面列出的缺点。

使用lock-free数据结构替代chan

chan大部分时间都被用作并发安全的队列,如果chan只有固定的一个发送者和固定一个的接收者,那么可以试试这种无锁数据结构:
SPSCQueue

无锁数据结构相比chan好处在于没有mutex,且没有数据复制的开销。

缺点是只支持单一接收者和单一发送者,实现也相对复杂所以需要很高的代码质量来保证使用上的安全和运行结果的正确,找不到一个高质量库的时候我建议是最好别尝试自己写,也最好别用。(一个坏消息,go里可靠的无锁数据结构库不是很多)

开销可以接受的情况

有一类系统追求正确性和安全性,对性能损耗和资源消耗有较高的容忍度。对于这类系统来说,复制数据带来的开销一般是可接受的。

这时候明显复制传递比传指针等操作简单而安全。

另一种常见情形是:chan并不是性能瓶颈,复不复制对性能的影响微乎其微。这时候我也倾向于选择复制传递数据。

总结

总体来说chan还是很方便的,在go里又是还不得不用。

我写这篇文章不是为了吓唬大家,只是提醒大家一些使用chan时可能发生的性能陷阱和对应的解决办法。

至于你怎么用chan,除了要结合实际需求之外,性能测试是另一个重要的参考标准。

如果问我,那么我倾向于复制数据优先于指针传递,除非数据十分巨大/性能瓶颈在复制上/接收方发送方需要在同一个对象上做些协同作业。同样性能测试和profile是我采用这些方式的参考标准。

记录一次项目开发中,LayUI多个图片进行优化,需要支持多个图片上传、可删除某一个图片、支持加载上次上次图片。

页面代码:

   <divclass="layui-upload">
                    <buttontype="button"class="layui-btn layui-btn-normal"id="ID-upload-demo-files">选择多文件</button>

                    <divclass="layui-upload-list">
                        <tableclass="layui-table">
                            <colgroup>
                                <colstyle="min-width: 100px;">
                                <colwidth="100">
                                <colwidth="150">
                                <colwidth="260">
                                <colwidth="100">
                            </colgroup>
                            <thead>
                            <th>图片</th>
                            <th>文件名</th>
                            <th>大小</th>
                            <th>上传进度</th>
                            <th>操作</th>
                            </thead>@*历史数据*@<tbodyid="ID-upload-pre-files-list">@{
var filelist = ViewData["UploadedList"] as List
<MultiPics>;
if (filelist != null && filelist.Count > 0)
{
foreach (var file in filelist)
{
<trid="fileList@((filelist.IndexOf(file)+1).ToString())"> <td> <imgsrc="@file.Content"class="tdPreImg"> </td> <td> <iclass="del-img"id="@((filelist.IndexOf(file)+1).ToString())"data-src="@file.FilePath"></i>@file.FileName</td> <td>@file.FileSize KB</td> <td>已上传</td> <td>@*因为layui按钮会提交,此处后台加载的图片,不用layui按钮*@<inputtype="button"value="删除"class="btn_del"id="btn_del@((filelist.IndexOf(file)+1).ToString())"data-index="@((filelist.IndexOf(file)+1).ToString())" /> </td> </tr>}
}
}
</tbody>@*当前上传*@<tbodyid="ID-upload-demo-files-list"> </tbody> </table> </div> <buttontype="button"class="layui-btn"id="ID-upload-demo-files-action">开始上传</button> </div>

LayUI js 代码:

 layui.use(function() {var upload =layui.upload;var element =layui.element;var $ =layui.$;var deliveryId = $("#LogID").val();

            //制作多文件上传表格
            var uploadListIns =upload.render({
elem:
'#ID-upload-demo-files',
elemList: $(
'#ID-upload-demo-files-list'), //列表元素对象 url: '/Ship/OrderInfo/UploadImages?id=' +deliveryId,
accept:
'images',
multiple:
true,
number:
10,
exts:
"png|jpg|jpeg",
auto:
false,
bindAction:
'#ID-upload-demo-files-action',
choose:
function(obj) {var that = this;//将每次选择的文件追加到文件队列 var files = this.files =obj.pushFile();//读取本地文件 obj.preview(function(index, file, result) {//console.log('choose' + index) var tr = $(['<tr id="upload-' + index + '">','<td>' + '<img src=\'' + result + '\' class=\'tdPreImg\'>' + '</td>','<td>' + '<i class="del-img" id="del-'+index+'" data-src=""></i>' + file.name + '</td>','<td>' + (file.size / 1024).toFixed(1) + 'kb</td>','<td><div class="layui-progress" lay-filter="progress-demo-' + index + '">' + '<div class="layui-progress-bar" lay-percent=""></div></div>' + '</td>','<td>','<button class="layui-btn layui-btn-xs demo-reload layui-hide">重传</button>','<button class="layui-btn layui-btn-xs layui-btn-danger demo-delete">删除</button>','</td>','</tr>'].join(''));//单个重传 tr.find('.demo-reload').on('click', function() {
obj.upload(index, file);
});
//删除 tr.find('.demo-delete').on('click', function() {delete files[index]; //删除对应的文件 tr.remove(); //删除表格行 //清空 input file 值,以免删除后出现同名文件不可选 uploadListIns.config.elem.next()[0].value = '';
});

that.elemList.append(tr);
element.render(
'progress'); //渲染新加的进度条组件 });
},
done:
function (res, index, upload) { //成功的回调 //console.log('done' + index) //删除文件队列已经上传成功的文件【很重要防止之前的图片重复上传】 delete this.files[index];var that = this;var tr = that.elemList.find('tr#upload-' +index);var tds =tr.children();if (res.result) { //上传成功 $('#del-' + index).attr('data-src', res.data) //清空操作 tds.eq(3).html('');
tds.eq(
3).html('上传成功!');
}
else{this.error(index, upload);
tds.eq(
3).html('上传失败!');//弹框显示错误信息 layer.msg("上传失败!" +res.msg);//调试人员查看,暂时保留 console.log("上传失败!" + res.msg + "#" +res.data);
}
},
allDone:
function (obj) { //多文件上传完毕后的状态回调 console.log(obj);
},
error:
function (index, upload) { //错误回调 var that = this.elemList.find('tr#upload-' +index);//console.log(that); that.find(".demo-reload").removeClass('layui-hide');
},
progress:
function(n, elem, e, index) {
element.progress(
'progress-demo-' + index, n + '%'); //执行进度条。n 即为返回的进度百分比 }
});
});

页面加载数据和提交表单js

    var main ={
@
*初始化,静态js*@
Init:
function() {
$(document).ready(
function() {
@
* Layui自带图片删除,历史图片的删除需特殊处理 *@
$(
".btn_del").click(function() {var _index = this.getAttribute("data-index");var _tr = $("#fileList" +_index);
$(
"#ID-upload-pre-files-list")[0].removeChild(_tr[0]);
})
});
},
@
* 获取参数,前台不提交文件,后台绑定 *@
SaveSignBack:
function() {
@
*图片地址列表*@var _BackImage = "";
@
*发货日志*@var _ID = $("#LogID").val();var _Note= $("#Note").val();//图片提取并限制数量 var images_ids = $('.del-img');
@
*限制上传图片数量*@if (images_ids.length > 10) {
layer.msg(
"图片最多选择10张");return false;
}

@
*以|竖线分割,拼接字符串*@if(images_ids.length) {var images = '';
$.each(images_ids,
function(index, val) {if (images == '') {
images
+= $(val).attr('data-src')
}
else{
images
+= '|' + $(val).attr('data-src')
}
});
//$(data.form).append('<input name="images" type="hidden" value="' + images + '">');//插入表单 //图片参数赋值 _BackImage=images;
}

@
*获取表单内容序列化*@//var fileForm = $("#form1").serialize(); //上传了图片,直接修改 if(_BackImage.length) {
main.FormSubmit(_LogID, _Note, _BackImage);
}
//未上传图片,弹框提示 else{
$.messager.confirm(
"提示", "您未上传图片,确定提交吗?", function(data) {//确定 if(data) {
main.FormSubmit(_LogID, _Note, _BackImage);
return;
}
//修改,不处理 else{ }
});
}
},
@* 提交后台 *@
FormSubmit:
function(_LogID, _Note, _BackImage) {
$.post(
"/ControllerName/OrderInfo/BackSubmit",
{
LogID: _LogID,
Note: _Note,
Images: _BackImage
},
function(obj) {if(obj.result) {
layer.msg(obj.msg);
//parent.refresh(); parent.location.reload();
parent.CloseWin();
}
else{
$.messager.alert(
'Info', obj.msg, 'info');
}
}
);
},
}

$(
function() {
main.Init();
})

后台C#上传代码,表单提交后台代码就不贴了

    public ActionResult UploadImages(int ID = 0)
    {
////防止异常加载图片覆盖,延时半秒 SaveAs //System.Threading.Thread.Sleep(500); try{#region 数据校验 //登录状态校验 if (CurrentUser.Id == 0)
{
return Json(new { result = false, msg = "登录失效!", data = ""});
}
//获取回签单图片列表 HttpFileCollectionBase files =HttpContext.Request.Files;//图片非空校验 if (files.Count == 0)
{
return Json(new { result = false, msg = "上传失败!请上传回签单图片!", data = ""});
}
//单个图片轮询上传,只能单张上传 if (files.Count > 1)
{
return Json(new { result = false, msg = "参数错误!", data = ""});
}
#endregion //上传文件计数 var successCount = 0;//图片相对路径(用于数据库保存) string FilePath = string.Empty;//循环保存图片,实际单个图片上传 for (int i = 0; i < files.Count; i++)
{
#region 拼接文件名(不含路径) //文件类型 var fileType = string.Empty;//获取文件类型 if (files[i].ContentType == "image/jpeg" || files[i].ContentType == "image/jpg")
{
fileType
= ".jpg";
}
else if (files[i].ContentType == "image/png")
{
fileType
= ".png";
}
//生成随机4位数字 var rand = (new Random()).Next(1000, 10000).ToString();//文件名 var _name = ID + "_" + CurrentUser.Id + "_" + DateTime.Now.ToString("yyMMddHHmmsss") + "_" +rand;//拼接文件名 回签图片名称格式:发货日志Id_上传人_年月日_4位随机数 var book = _name +fileType;#endregion //获取配置文件的回签单保存路径 SignBackUrl string savePath = System.Configuration.ConfigurationManager.AppSettings["FileUrl"];//按月分文件夹 FilePath = DateTime.Now.ToString("yyyyMM") + "/" +book;//文件完整路径 string fileFullPath = savePath + "/" +FilePath;//没有文件夹则创建 if (!Directory.Exists(savePath + "/" + DateTime.Now.ToString("yyyyMM")))
{
Directory.CreateDirectory(savePath
+ "/" + DateTime.Now.ToString("yyyyMM"));
}
#region 文件大小校验 //保存图片到服务器上 files[i].SaveAs(fileFullPath);//创建文件 获取文件大小 var fileInfo = newFileInfo(fileFullPath);//获取文件大小,单位KB 1KB=1024byte(字节) decimal fileSize = (decimal)(fileInfo.Length > 0 ? (fileInfo.Length / 1024) : 0);//2MB转成KB var _2mb = (decimal)2 * 1024;//获取大小异常 if (fileSize == 0)
{
//计算文件大小异常 return Json(new { result = false, msg = "计算文件大小异常 !", data =FilePath });
}
else if (fileSize != 0 && fileSize >_2mb)
{
//文件大小超出2MB return Json(new { result = false, msg = "文件大小超出2MB,请修改后重试 !", data =FilePath });
}
#endregion//累计成功计数 successCount++;
}
if (successCount ==files.Count)
{
//成功返回,回调图片地址列表(相对地址) return Json(new { result = true, msg = "上传成功 !", data =FilePath }); }else{//失败 return Json(new { result = false, msg = "上传失败 !", data = "[上传文件数:" + successCount + "]"});
}
}
catch(Exception ex)
{
//抛出异常 return Json(new { result = false, msg = "上传异常,请重试 !", data = "[Exception:" + ex.Message + "]"});
}
}

上传图片时,出现上传图片名称和图片不能对应,数据串了问题。但是能想到可能是前一张图片没有保存,后面一张图片已经执行到保存方法,导致覆盖了。尝试了增加延时、后台记录id等一系列操作后,才发现是生成图片名称只到天。

后加了到秒并加了四位随机数,才得以解决。

Layui实际上是每上传一次图片,调用一次后台上传方法。

这里表格id " ID-upload-demo-files-list"是layui指定表格Id。已经上传的历史数据,不能用这个Id显示,单独加了一个tbody id="ID-upload-pre-files-list"用于显示已上传图片。需要删除,找到这行Dom移除即可。

提交表单

Spring Boot + Vue中的Token续签机制

在现代的全栈应用开发中,Spring Boot作为后端框架和Vue.js作为前端框架的组合非常流行。在这种架构中实现Token续签是保障应用安全的关键部分。本文旨在提供一个基于Spring Boot和Vue的长短Token续签示例。

1. Spring Boot后端

1.1 长Token的生成

在Spring Boot中,我们首先需要一个方法来生成JWT Token。这里我们使用
jjwt
库。

引入依赖


pom.xml
文件中加入以下依赖:

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>

生成Token

创建一个服务
JwtTokenService
来生成和解析Token。

import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import java.util.Date;

@Service
public class JwtTokenService {

    private String secretKey = "your_secret_key";

    public String generateLongToken(String username) {
        long expiration = 7 * 24 * 60 * 60 * 1000; // 7 days
        return Jwts.builder()
                .setSubject(username)
                .setExpiration(new Date(System.currentTimeMillis() + expiration))
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();
    }

    // ... 其他方法 ...
}

1.2 短Token的生成

短Token的生成方式类似,只是过期时间较短。

public String generateShortToken(String username) {
    long expiration = 15 * 60 * 1000; // 15 minutes
    return Jwts.builder()
            .setSubject(username)
            .setExpiration(new Date(System.currentTimeMillis() + expiration))
            .signWith(SignatureAlgorithm.HS256, secretKey)
            .compact();
}

1.3 Token续签

当用户进行操作时,可以根据需要续签Token。

public String renewToken(String oldToken) {
    // 解析旧Token获取用户名
    String username = Jwts.parser()
            .setSigningKey(secretKey)
            .parseClaimsJws(oldToken)
            .getBody()
            .getSubject();

    // 生成新的短Token
    return generateShortToken(username);
}

2. Vue前端

在Vue应用中,我们需要确保每次发送请求时附加Token,并在需要时更新Token。

2.1 设置Axios拦截器

在Vue项目中,我们使用Axios来发送HTTP请求。通过设置拦截器,我们可以在每个请求中自动添加Token。

import axios from 'axios';

axios.interceptors.request.use(
    config => {
        const token = localStorage.getItem('token');
        if (token) {
            config.headers.Authorization = `Bearer ${token}`;
        }
        return config;
    },
    error => {
        return Promise.reject(error);
    }
);