[转]C#操作INI文件 - rainbow70626 - 博客园

mikel阅读(746)

来源: [转]C#操作INI文件 – rainbow70626 – 博客园

在很多的程序中,我们都会看到有以.ini为后缀名的文件,这个文件可以很方便的对程序配置的一些信息进行设置和读取,比如说我们在做一个程序后台登陆的时候,需要自动登录或者是远程配置数据库连接,及保存密码设置等等(在Winform程序中),若在ASP.NET程序中有另外的解决方法,此C#操作INI文件的文章仅在winform程序中进行写入和读取操作。

为了方便起见,现在以一个简单的小实例来对C#操作INI文件进行讲解:

窗体的大致布局如下

当点击写入按钮的时候就会把文本框中输入的值写入到INI文件中,结果会如图所示

当点击读取按钮的时候就会把INI文件中的节点信息的值填充到窗体中的文本框中

以上就是用C#操作INI文件的整个流程,现在来介绍后台代码是怎样实现的:

在项目名称空间的上方要添加以下的引用:

using System.Runtime.InteropServices;//引用命名空间

然后再程序的后台声明一些系统函数的变量,代码如下

复制代码
声明变量

  #region "声明变量"

        /// <summary>
        /// 写入INI文件
        /// </summary>
        /// <param name="section">节点名称[如[TypeName]]</param>
        /// <param name="key">键</param>
        /// <param name="val">值</param>
        /// <param name="filepath">文件路径</param>
        /// <returns></returns>
        [DllImport("kernel32")]
        private static extern long WritePrivateProfileString(string section,string key,string val,string filepath);
        /// <summary>
        /// 读取INI文件
        /// </summary>
        /// <param name="section">节点名称</param>
        /// <param name="key">键</param>
        /// <param name="def">值</param>
        /// <param name="retval">stringbulider对象</param>
        /// <param name="size">字节大小</param>
        /// <param name="filePath">文件路径</param>
        /// <returns></returns>
        [DllImport("kernel32")]
        private static extern int GetPrivateProfileString(string section,string key,string def,StringBuilder retval,int size,string filePath);

        private string strFilePath = Application.StartupPath + "\\FileConfig.ini";//获取INI文件路径
        private string strSec =""; //INI文件名
         
        #endregion
复制代码

先说明下我的INI配置文件是放在程序的Debug文件夹下的,然后单击写入按钮,在写入前没有进行写入数值的验证,代码如下:

复制代码
写入事件

 //写入按钮事件
        private void btnWrite_Click(object sender, EventArgs e)
        {
            try
            {
 
                    //根据INI文件名设置要写入INI文件的节点名称
                    //此处的节点名称完全可以根据实际需要进行配置
                    strSec = Path.GetFileNameWithoutExtension(strFilePath);
                    WritePrivateProfileString(strSec, "Name", txtName.Text.Trim(), strFilePath);
                    WritePrivateProfileString(strSec, "Sex", txtSex.Text.Trim(), strFilePath);
                    WritePrivateProfileString(strSec, "Age", txtAge.Text.Trim(), strFilePath);
                    WritePrivateProfileString(strSec, "Address", txtAddress.Text.Trim(), strFilePath);
                    MessageBox.Show("写入成功");
             
            }catch(Exception ex){
                MessageBox.Show(ex.Message.ToString());
            
            }
        }
复制代码

 

此时运行此实例就会把数值写入到INI文件中,写入的结果就像第二个截图效果显示的那样。然后我们在单击读取按钮事件,把INI文件中的信息填充到窗体的文本框中,代码如下:

复制代码
读取事件

  //读取按钮事件
        private void btnRead_Click(object sender, EventArgs e)
        {
            if (File.Exists(strFilePath))//读取时先要判读INI文件是否存在
            {

                strSec = Path.GetFileNameWithoutExtension(strFilePath);
                txtName.Text = ContentValue(strSec, "Name");
                txtSex.Text = ContentValue(strSec, "Sex");
                txtAge.Text = ContentValue(strSec, "Age");
                txtAddress.Text = ContentValue(strSec, "Address");

            }
            else {

                MessageBox.Show("INI文件不存在");
            
            }
        }
复制代码

 

在读取的时候用到了自定义读取函数的方法,在该方法中调用了系统函数,

复制代码
}
      /// <summary>
      /// 自定义读取INI文件中的内容方法
      /// </summary>
      /// <param name="Section">键</param>
      /// <param name="key">值</param>
      /// <returns></returns>
        private string ContentValue(string Section,string key) {

            StringBuilder temp = new StringBuilder(1024);
            GetPrivateProfileString(Section, key, "", temp, 1024, strFilePath);
            return temp.ToString();
        }
复制代码

 

以上所述的就是简单的用C#语言操作INI文件的过程,只用到了系统函数中的两个(写入函数和读取函数)还有其他的函数比如说时删除INI文件函数等等,删除INI文件函数其实就是把键对应的值设置为null就可以了。

自动登录和连接设置都用到了INI文件,文章到此结束。

参考链接:柄棋先生的博文:《C#操作INI文件

c#分页读取GB文本文件 - tneduts - 博客园

mikel阅读(631)

来源: c#分页读取GB文本文件 – tneduts – 博客园

应用场景:

a.我在做BI开发测试的时候,有可能面对source文件数GB的情况,如果使用一般的文本编辑器,则会卡死,或要等很久才能显示出来。

b.有时候,我们使用ascii(01)或ascii(02)作为行或列的分隔符,这样的临时文件用于导数据到DB,如果文件导入过程中有错误,需要查看文件 的时候,普通的编辑器不支持换行,则会很恐怖。

为解决这两个需求,我使用C#完成了一个简单的winform的应用程序。

功能列表:

1.根据配置的行数,写测试文件,指定行终止符,列分隔符暂时没有使用上。

2根据指定的行终止符,和pagesize,分页读取文件内容,而且可以在text和byte间转换。

Sourcecode:

主要的代码如下,使用了一个迭代器:

复制代码
private IEnumerator<string> ReadLines(string filename)

{

/*

string line;

using (TextReader reader = File.OpenText(filename))

{

while ((line = reader.ReadLine()) != null)

yield return line;

}

*/

StringBuilder sb = new StringBuilder();

using (FileStream fs = File.OpenRead(filename))

{

int b = 0;

while ((b=fs.ReadByte())!=-1)

{

//textbox3 store the row terminator

if (b.ToString() == textBox3.Text.Trim())

{

yield return sb.ToString();

sb.Clear();

}

else

sb.Append(UnicodeEncoding.ASCII.GetString(new byte[] { byte.Parse(b.ToString()) }));

}

}

}
复制代码

 

 

 

示例截图:

我测试了一次,生成了1个亿的数据行,在系统中显示占用6GB的空间,

我尝试读写,无卡顿情况。

可以进一步更新:

根据列行分隔符显示到gridview中,这样更清晰。

可以从后往前读文件。

使用FastReport报表工具生成标签打印文档 - 伍华聪 - 博客园

mikel阅读(919)

来源: 使用FastReport报表工具生成标签打印文档 – 伍华聪 – 博客园

在我们实际开发报表的时候,我们需要按一定的业务规则组织好报表的模板设计,让报表尽可能的贴近实际的需求,在之前的随笔中《使用FastReport报表工具生成报表PDF文档》介绍了FastReport生成常规报表的处理,本篇随笔回顾常规报表的处理效果,并介绍基于FastReport生成标签纸打印需要的报表格式。

1、常规报表的处理

我们一般处理报表的时候,大多数情况碰到的是明细报表,或者有主从表这种样式的报表格式,明细报表就是只需要设计一个表头,按列表展示即可,如下所示格式。

 

或者类似一个基于XtraReport报表的功能界面,如下面图示所示。

这里面涉及的字段,包括字符型、日期型,数值型、枚举类型等,还有统计值、打印时间(参数),因此也算一个比较完整的报表展示了。

或者一些有一条记录构建成的报表信息,如下报表所示,这些也只是设计模板上的差异,在模板里面绑定对应的字段或者参数即可实现。

还有一种是主表有信息,从表有明细的数据展示方式,这个在随笔《使用FastReport报表工具生成报表PDF文档》有介绍过。

 

2、基于标签信息报表的打印处理

之前在随笔《在Winform开发中使用Grid++报表》和《在Bootstrap开发框架中使用Grid++报表》中介绍了使用锐浪报表来设计展现标签打印报表的处理,如下效果所示。

本篇随笔介绍基于FastReport报表工具生成标签打印文档的操作。

其实如果细心查找,FastReport也提供了很多Demo案例,其中就有标签的案例介绍,设计效果如下所示。

 

报表预览效果如下所示。

 

我们要做的类似,不过我们需要增加二维码、条码上去进行打印而已。

首先我们需要设计一个报表模板,设计格式如下所示。

 

在其中我们需要加入一个数据源进行绑定和测试预览效果,我们选择SQLServer一个表的数据进行创建数据源,如下所示。

 

设计报表,我们需要根据实际标签纸张预先设计好报表页面大小,如下所示。

 

如果需要展示多少列,可以通过页面设置中的列进行指定划分多少列,如下所示。

 

不过在实际测试的时候,这样的属性设置,报表渲染的时候,是按指定高度,从左列到右列进行依次展现的,如果我们需要按实际渲染高度,那么这里可以设置为1,然后由数据区进行设置列的数量即可。如下所示。

 

数据区设置列数为实际需要展现的列数,这个根据宽度预览看大概设计多少列合适,让页面高宽尽可能利用好即可。

二维码标签报表格式设计效果大概如下所示。

 

我们使用数据源的数据预览下效果,效果还是杠杠的。

 

报表模板弄好了,我们就需要如何生成FastReport报表或者导出PDF了。

我在之前的随笔《使用FastReport报表工具生成报表PDF文档》里面介绍了FastReport报表的处理代码,这里做法依旧差不多,绑定数据源即可展示或者导出PDF了。

最主要的代码如下所示。

复制代码
//生成PDF报表文档到具体文件
Report report = new Report();
report.Load(reportPath);

//定义参数和数据格式
var dict = new Dictionary<string, object>();
var dt = DataTableHelper.CreateTable("ID,Name,CurrDept,Code,UsePerson,KeepAddr");
if (list != null)
{
    foreach (var info in list)
    {
        var dr = dt.NewRow();
        dr["ID"] = info.ID;
        dr["Name"] = info.Name;
        dr["CurrDept"] = info.CurrDept;
        dr["Code"] = info.Code;
        dr["UsePerson"] = info.UsePerson;
        dr["KeepAddr"] = info.KeepAddr;
        dt.Rows.Add(dr);
    }
}

//刷新数据源
foreach (string key in dict.Keys)
{
    report.SetParameterValue(key, dict[key]);
}
report.RegisterData(dt, "T_Asset");

//运行报表
report.Prepare();
复制代码

如果需要导出PDF,那么代码增加部分导出处理即可。

//导出PDF报表
PDFExport export = new PDFExport();
report.Export(export, realPath);
report.Dispose();

我们来看看实际在浏览器预览的效果,如下所示。

以上效果结合了pdfJS的在线预览PDF操作,如果需要了解PDF的在线处理,参考下随笔《实现在线预览PDF的几种解决方案》。

Kubernetes+Docker+Istio 容器云实践 - 宜信技术 - 博客园

mikel阅读(679)

来源: Kubernetes+Docker+Istio 容器云实践 – 宜信技术 – 博客园

随着社会的进步与技术的发展,人们对资源的高效利用有了更为迫切的需求。近年来,互联网、移动互联网的高速发展与成熟,大应用的微服务化也引起了企业的热情关注,而基于Kubernetes+Docker的容器云方案也随之进入了大众的视野。开普勒云是一个基于Kubernetes+Docker+Istio的微服务治理解决方案。

一、Microservices

1.1 解决大应用微服务化后的问题

现在各大企业都在谈论微服务,在微服务的大趋势之下技术圈里逢人必谈微服务,及微服务化后的各种解决方案。

1.2 当我们在讨论微服务的时候我们在讨论什么?

使用微服务架构有很多充分的理由,但天下没有免费的午餐,微服务虽有诸多优势,同时也增加了复杂性。团队应该积极应对这种复杂性,前提是应用能够受益于微服务。

1.2.1 如何微服务化的问题

  • 微服务要如何拆分
  • 业务API规则
  • 数据一致性保证
  • 后期可扩展性考虑

当然这不是本文主要讨论的问题,我不讲微服务具体要如何拆分,每个企业每个应用的情况都不太一样,适合自己的方案就是最好的拆分方案。我们主要来解决微服务化后所带来的一些问题。

1.2.2 微服务化后带来的问题

  • 环境一致性
  • 如何对资源快速分配
  • 如何快速度部署
  • 怎么做基本监控
  • 服务注册与发现
  • 负载均衡如何做

以上都是大应用微服务化所需要解决的基础问题,如果还按照传统的方式使用虚拟机来实现,资源开支将会非常大。那么这些问题要怎么解决呢?比如: 

  • 流量管理
  • 服务降级
  • 认证、授权

当然面对上述这些问题我们广大的猿友们肯定是有解决方案的。

1.3 Service governance

1.3.1 Java 体系

假设我们是Java体系的应用,那解决起来就很方便了,比如我们可以考虑使用SpringCloud全家桶系列。也可以拆分使用: 

  • Eureka
  • Hystrix
  • Zuul
  • Spring-cloud
  • Spring-boot
  • ZipKin

Java体系下能很方便的做以我们微服务化后的基础部分,但依然不能非常舒服地解决环境一致性,并且如果有其他语系的服务将很难融入进去。

我们来看基础编程语言一般有什么组合方式来解决基础问题。

1.3.2 其他体系

  • Consul
  • Kong
  • Go-kit
  • Jaeger/Zipkin

假设我们是使用Golang语言,这里再捧一下Golang语言。go语言简直就是天生为微服务而生的语言,实在不要太方便了。高效的开发速度及相当不错的性能,简单精悍。

跑题了~我们使用上面这些工具也可以组成一套还不错的微服务架构。

  • Consul: 当作服务发现及配置中心来使
  • Kong: 作为服务网关
  • Jaeger: 作为链路追踪来使
  • Go-kit: 开发组件

但是这种方案也有问题,对服务的侵入性太强了,每个服务都需要嵌入大量代码,这还是很头疼的。

二、Docker & Kubernetes

基于Docker+k8s搭建平台的实践方案。

2.1 Docker

Docker 是一个非常强大的容器。

  • 资源利用率的提升
  • 环境一致性、可移植性
  • 快速度扩容伸缩
  • 版本控制

使用了Docker之后,我们发现可玩的东西变多了,更加灵活了。不仅仅是资源利用率提升、环境一致性得到了保证,版本控制也变得更加方便了。

以前我们使用Jenkins进行构建,需要回滚时,又需要重新走一次jenkins Build过程,非常麻烦。如果是Java应用,它的构建时间将会变得非常长。

使用了Docker之后,这一切都变得简单了,只需要把某个版本的镜像拉下来启动就完事了(如果本地有缓存直接启动某个版本就行了),这个提升是非常高效的。

(图片来源网络)

既然使用了Docker容器作为服务的基础,那我们肯定需要对容器进行编排,如果没有编排那将是非常可怕的。而对于Docker容器的编排,我们有多种选择:Docker Swarm、Apache Mesos、Kubernetes,在这些编排工具之中,我们选择了服务编排王者Kubernetes。

2.1.1 Docker VS VM

  • VM: 创建虚拟机需要1分钟,部署环境3分钟,部署代码2分钟。
  • Docker: 启动容器30秒内。

2.2 Why choose Kubernetes

我们来对比这三个容器编排工具。

2.2.1 Apache Mesos

Mesos的目的是建立一个高效可扩展的系统,并且这个系统能够支持各种各样的框架,不管是现在的还是未来的框架,它都能支持。这也是现今一个比较大的问题:类似Hadoop和MPI这些框架都是独立开的,这导致想要在框架之间做一些细粒度的分享是不可能的。

但它的基础语言不是Golang,不在我们的技术栈里,我们对它的维护成本将会增高,所以我们首先排除了它。

2.2.2 Docker Swarm

Docker Swarm是一个由Docker开发的调度框架。由Docker自身开发的好处之一就是标准Docker API的使用。Swarm的架构由两部分组成:

(图片来源网络)

它的使用,这里不再具体进行介绍。

2.2.3 Kubernetes

Kubernetes是一个Docker容器的编排系统,它使用label和pod的概念来将容器换分为逻辑单元。Pods是同地协作(co-located)容器的集合,这些容器被共同部署和调度,形成了一个服务,这是Kubernetes和其他两个框架的主要区别。相比于基于相似度的容器调度方式(就像Swarm和Mesos),这个方法简化了对集群的管理.

不仅如此,它还提供了非常丰富的API,方便我们对它进行操作,及玩出更多花样。其实还有一大重点就是符合我们的Golang技术栈,并且有大厂支持。

Kubernetes 的具体使用这里也不再过多介绍,网站上有大把资料可以参考。

2.3 Kubernetes in kubernetes

kubernetes(k8s)是自动化容器操作的开源平台,这些操作包括部署、调度和节点集群间扩展。

  • 自动化容器的部署和复制
  • 随时扩展或收缩容器规模
  • 将容器组织成组,并且提供容器间的负载均衡
  • 很容易地升级应用程序容器的新版本
  • 提供容器弹性,如果容器失效就替换它,等等…

2.4 Kubernetes is not enough either

到这里我们解决了以下问题:

  • Docker: 环境一致性、快速度部署。
  • Kubernetes: 服务注册与发现、负载均衡、对资源快速分配。

当然还有监控,这个我们后面再说。我们先来看要解决一些更高层次的问题该怎么办呢?

在不对服务进行侵入性的代码修改的情况下,服务认证、链路追踪、日志管理、断路器、流量管理、错误注入等等问题要怎么解决呢?

这两年非常流行一种解决方案:Service Mesh。

三、Service Mesh

处理服务间通信的基础设施层,用于在云原生应用复杂的服务拓扑中实现可靠的请求传递。

  • 用来处理服务间通讯的专用基础设施层,通过复杂的拓扑结构让请求传递的过程变得更可靠。
  • 作为一组轻量级高性能网络代理,和程序部署在一起,应用程序不需要知道它的存在。

在云原生应用中可靠地传递请求可能非常复杂,通过一系列强大技术来管理这种复杂性: 链路熔断、延迟感知、负载均衡,服务发现、服务续约及下线与剔除。

市面上的ServiceMesh框架有很多,我们选择了站在风口的Istio。

3.1 Istio

连接、管理和保护微服务的开放平台。

  • 平台支持: Kubernetes, Mesos, Cloud Foundry。
  • 可观察性:Metrics, logs, traces, dependency 。visualisation。
  • Service Identity & Security: 为服务、服务到服务的身份验证提供可验证的标识。
  • Traffic 管理: 动态控制服务之间的通信、入口/出口路由、故障注入。
  • Policy 执行: 前提检查,服务之间的配额管理。

3.2 我们为什么选择Istio?

因为有大厂支持~其实主要还是它的理念是相当好的。

虽然它才到1.0版本,我们是从 0.6 版本开始尝试体验,测试环境跑,然后0.7.1版本出了,我们升级到0.7.1版本跑,后来0.8.0LTS出了,我们开始正式使用0.8.0版本,并且做了一套升级方案。

目前最新版已经到了1.0.4, 但我们并不准备升级,我想等到它升级到1.2之后,再开始正式大规模应用。0.8.0LTS在现在来看小规模还是可以的。

3.3 Istio 架构

我们先来看一下Istio的架构。

其中Istio控制面板主要分为三大块,Pilot、Mixer、Istio-Auth。

  • Pilot: 主要作为服务发现和路由规则,并且管理着所有Envoy,它对资源的消耗是非常大的。
  • Mixer: 主要负责策略请求和配额管理,还有Tracing,所有的请求都会上报到Mixer。
  • Istio-Auth: 升级流量、身份验证等等功能,目前我们暂时没有启用此功能,需求并不是特别大,因为集群本身就是对外部隔离的。

每个Pod都会被注入一个Sidecar,容器里的流量通过iptables全部转到Envoy进行处理。

四、Kubernetes & Istio

Istio可以独立部署,但显然它与Kuberntes结合是更好的选择。基于Kubernetes的小规模架构。有人担心它的性能,其实经过生产测试,上万的QPS是完全没有问题的。

4.1 Kubernetes Cluster

在资源紧缺的情况下,我们的k8s集群是怎么样的?

4.1.1 Master集群

  • Master Cluster:
    • ETCD、Kube-apiserver、kubelet、Docker、kube-proxy、kube-scheduler、kube-controller-manager、Calico、 keepalived、 IPVS。

4.1.2 Node节点

  • Node:
    • Kubelet、 kube-proxy 、Docker、Calico、IPVS。

(图片来源网络)

我们所调用的Master的API都是通过 keepalived 进行管理,某一master发生故障,能保证顺滑的飘到其他master的API,不影响整个集群的运行。

当然我们还配置了两个边缘节点。

4.1.3 Edge Node

  • 边缘节点
  • 流量入口

边缘节点的主要功能是让集群提供对外暴露服务能力的节点,所以它也不需要稳定,我们的IngressGateway 就是部署在这两个边缘节点上面,并且通过Keeplived进行管理。

4.2 外部服务请求流程

最外层是DNS,通过泛解析到Nginx,Nginx将流量转到集群的VIP,VIP再到集群的HAproxy,将外部流量发到我们的边缘节点Gateway。

每个VirtualService都会绑定到Gateway上,通过VirtualService可以进行服务的负载、限流、故障处理、路由规则及金丝雀部署。再通过Service最终到服务所在的Pods上。

这是在没有进行Mixer跟策略检测的情况下的过程,只使用了Istio-IngressGateway。如果使用全部Istio组件将有所变化,但主流程还是这样的。

4.3 Logging

日志收集我们采用的是低耦合、扩展性强、方便维护和升级的方案。

  • 节点Filebeat收集宿主机日志。
  • 每个Pods注入Filebeat容器收集业务日志。

Filebeat会跟应用容器部署在一起,应用也不需要知道它的存在,只需要指定日志输入的目录就可以了。Filebeat所使用的配置是从ConfigMap读取,只需要维护好收集日志的规则。

上图是我们可以从Kibana上看到所采集到的日志。

4.4 Prometheus + Kubernetes

  • 基于时间序列的监控系统。
  • 与kubernetes无缝集成基础设施和应用等级。
  • 具有强大功能的键值数据模型。
  • 大厂支持。

4.4.1 Grafana

4.4.2 Alarm

目前我们支持的报警有Wechat、kplcloud、Email、IM。所有报警都可在平台上配置发送到各个地方。

4.4.3 整体架构

整个架构由外围服务及集群内的基础服务组成,外围服务有:

  • Consul作为配置中心来使用。
  • Prometheus+Grafana用来监控K8s集群。
  • Zipkin提供自己定义的链路追踪。
  • ELK日志收集、分析,我们集群内的所有日志会推送到这里。
  • Gitlab代码仓库。
  • Jenkins用来构建代码及打包成Docker镜像并且上传到仓库。
  • Repository 镜像仓库。

集群有:

  • HAProxy+keeprlived 负责流量转发。
  • 网络是Calico, Calico对kube-proxy的ipvs代理模式有beta级支持。如果Calico检测到kube-proxy正在该模式下运行,则会自动激活Calico ipvs支持,所以我们启用了IPVS。
  • 集群内部的DNS是 CoreDNS。
  • 我们部署了两个网关,主要使用的是Istio的 IngressGateway,TraefikIngress备用。一旦IngressGateway挂了我们可以快速切换到TraefikIngress。
  • 上面是Istio的相关组件。
  • 最后是我们的APP服务。
  • 集群通过Filebeat收集日志发到外部的ES。
  • 集群内部的监控有:
    • State-Metrics 主要用来自动伸缩的监控组件
    • Mail&Wechat 自研的报警服务
    • Prometheus+Grafana+AlertManager 集群内部的监控,主要监控服务及相关基础组件
    • InfluxDB+Heapster 流数据库存储着所有服务的监控信息

4.5 有了Kubernetes那怎么部署应用呢?

4.5.1 研发打包成镜像、传仓库、管理版本

  • 学习Docker。
  • 学习配置仓库、手动打包上传麻烦。
  • 学习k8s相关知识。

4.5.2 用Jenkins来负责打包、传镜像、更新版本

  • 运维工作增加了不少,应用需要进行配置、服务需要做变更都得找运维。
  • 需要管理一堆的YAML文件。

有没有一种傻瓜式的,不需要学习太多的技术,可以方便使用的解决方案?

五、Kplcloud platform

5.1 开普勒云平台

开普勒云平台是一个轻量级的PaaS平台。

  • 为微服务化的项目提供一个可控的管理平台。
  • 实现每个服务独立部署、维护、扩展。
  • 简化流程,不再需要繁琐的申请流程,最大限度的自动化处理。
  • 实现微服务的快速发布、独立监控、配置。
  • 实现对微服务项目的零侵入式的服务发现、服务网关、链路追踪等功能。
  • 提供配置中心,统一管理配置。
  • 研发、产品、测试、运维甚至是老板都可以自己发布应用。

5.2 在开普勒平台部署服务

为了降低学习成本及部署难度,在开普勒平台上部署应用很简单,只需要增加一个Dockerfile 就好了。

Dockerfile 参考:

以上是普通模式,Jenkins代码Build及Docker build。

这是一种相对自由的部署方式,可以根据自己的需求进行定制,当然有学习成本。

5.2.1 为什么不自动生成Dockerfile呢?

其实完全可以做到自动生成Dockerfile,但每个服务的要求可能不一样,有些需要增加文件、有些在Build时需要增加参数等等。我们不能要求所有的项目都是一样的,这会阻碍技术的发展。所以退而求其次,我们给出模版,研发根据自己的需求调整。

5.3 工具整合

  • 开普勒云平台整合了 gitlab,Jenkins,repo,k8s,istio,promtheus,email,WeChat 等API。
  • 实现对服务的整个生命周期的管理。
  • 提供服务管理、创建、发布、版本、监控、报警、日志已及一些周边附加功能,消息中心、配置中心、还能登陆到容器,服务下线等等。
  • 可对服务进行一健调整服务模式、服务类型、一键扩容伸缩,回滚服务API管理以及存储的管理等操作。

5.4 发布流程

用户把自己的Dockerfile跟代码提交到Gitlab,然后在开普勒云平台填写一些参数创建自己的应用。

应用创建完后会在Jenkins创建一个Job,把代码拉取下来并执行Docker build(如果没有选择多阶构建会先执行go build或mvn),再把打包好的Docker image推送到镜像仓库,最后回调平台API或调用k8s通知拉取最新的版本。

用户只需要在开普勒云平台上管理好自己的应用就可以,其他的全部自动化处理。

5.5 从创建一个服务开始

我们从创建一个服务开始介绍平台。

平台主界面:

点击“创建服务”后进入创建页面。

填写基本信息:

填写详细信息: 

基本信息以Golang为例,当选择其他语言时所需填写的参数会略有不同。

如果选择了对外提供服务的话,会进入第三步,第三步是填写路由规则,如没有特殊需求直接默认提交就行了。

5.5.1 服务详情

Build 升级应用版本: 

调用服务模式,可以在普通跟服务网格之间调整。

服务是否提供对外服务的能力: 

扩容调整CPU、内存:

调整启动的Pod数量:

网页版本的终端:

5.5.2 定时任务

5.5.3 持久化存储

管理员创建StorageClass跟PersistentVolumeClaim,用户只需要在自己服务选择相关的PVC进行绑写就行了。

存储使用的是NFS。

5.5.4 Tracing

5.5.5 Consul

Consul当作配置中心来使用,并且我们提供Golang的客户端。

$ go get github.com/lattecake/consul-kv-client

 

它会自动同步consul的目录配置存在内存,获取配置只需要直接从内存拿就行了。

5.5.6 Repository

作者:王聪

首发:宜技之长

钢铁B2B电商案例:供应链金融如何解决供应链金融痛点 - 宜信技术 - 博客园

mikel阅读(819)

来源: 钢铁B2B电商案例:供应链金融如何解决供应链金融痛点 – 宜信技术 – 博客园

一、区块链是什么

区块链是一种按照时间顺序将数据块以特定的顺序相连的方式组合成的链式数据结构,其上存储了系统诞生以来所有交易的记录。区块链上的数据由全网节点共同维护并共同存储,同时以密码学方式保证区块数据不可篡改和不可伪造。所以区块链本质是一个分布式共享数据库。 区块链让参与系统中的任意多个节点,通过密码学方法产生相关联数据块(即区块,block),每个数据块中都包含了一定时间内的系统全部信息交流的数据,并按照时间顺序将数据区块组合成一种链式数据结构。

区块链是分布式数据存储、点对点传输、共识机制、加密算法等计算机技术在互联网时代的创新应用模式,是一种解决信任问题、降低信任成本的信息技术方案。区块链技术的应用,可以取缔传统的信任中介,颠覆传统上存在了几千年的中心化旧模式,在不需要中心化信任中介的情况下,解决陌生人之间的信任问题,大幅降低信任成本。

二、名词解释

  • 分布式:相对于集中式而言,分布式是区块链的典型特征之一,对应的英文是Decentralized,完整的表达形式是不依赖与中心服务器(集群)、利用分布式的计算机资源进行计算的模式。
  • 共识机制:区块链系统中实现不同节点间建立信任、获取权益的数学算法。
  • 分布式数据库:一个可以在多个站点、不同地理位置或者多个机构组成的网络中分享的数据库。

2.1 区块+链=历史+验证

区块结构有两个非常重要的特点:

每个区块的块头包含了前一区块的交易信息的哈希值,因此从创世区块到当前区块形成了链条; 每个区块主体上的交易记录前一区块创建后、该区块创建前发生的所有价值交换活动; 绝大多数情况下,新区块创建成功被加入到链中,该区块的数据记录则不可被改变或更改。

图片描述

2.2 区块链的特点

  • 去中心化

区块链的分布式结构使得数据并不是记录和存储在中心化的电脑或主机上,而是让每一个参与数据交易的节点都记录并存储下所有的数据信息。为此,区块链系统采用了开源的、去中心化的协议来保证数据的完备记录和存储。区块链构建了一整套协议机制,让全网络的每个节点在参与记录数据的同时,也参与验证其他节点记录结果的正确性。只有当全网大部分节点(甚至所有节点)都确认记录的正确性时,该数据才会被写入区块。在区块链的分布式结构的网络系统中,参与记录的网络节点会实时更新并存放全网系统中的所有数据。因此,即使部分节点遭到攻击或破坏,也不会影响这个系统的数据更新和存储。

  • 不可伪造

区块链记录原理需要所有参与记录的节点,来共同验证交易记录的正确性。由于所有节点都在记录全网的每一笔交易,因此,一旦出现某节点记录的信息与其他节点的不符,其他节点就不会承认该记录,该记录也就不会写入区块。

  • 不可篡改

改变某一区块及区块内的交易信息几乎是不可能的。如果该区块被改变,那么之后的每一个区块都将被改变。因此试图篡改数据的人必须同时入侵至少全球参与记录的51%的节点并篡改数据。从技术上讲,这几乎是不可能的。

  • 数学加密

每笔交易需要一个有效签名才会被存储在区块中。只有有效的数字秘钥才能生成有效签名。密钥是成对出现的,由一个私钥和一个公钥组成。其中,公钥是公开的,私钥仅限拥有者可见并使用,用于交易签名,以证明数字身份。

三、区块链如何解决供应链金融痛点

供应链金融是以核心客户为依托,以真实贸易背景为前提,运用自偿性贸易融资的方式,通过应收账款质押登记、第三方监管等专业手段封闭资金流或控制物权,对供应链上下游企业提供的综合性金融产品和服务。

供应链金融的基础,又是供应链。供应链涉及信息流、资金流、物流和商流,天然是个多主体、多协作的业务模式。在这种情况下,要进行贸易融资,首先会遇到很多真实性的问题,比如交易的真实性,单据的真实性,这都需要多方确认,耗费大量的人力物力;其次,涉及的多主体,存在互联互通难的问题,例如每个主体用的供应链管理系统、企业资源管理系统,甚至是财务系统都有所不相同,导致对接难。就算对接上了,会由于数据格式、数据字典不统一,而导致信息共享很难。

供应链金融还存在一些行业痛点:

  • 第一,如前所述,供应链上存在很多信息孤岛,企业间信息的不互通制约了很多融资信息的验证;
  • 第二,核心企业信用并不能有效传递,根据合同法,核心企业是跟一级供应商签订合同,但是一级供应商和二级供应商签订合同时并没有核心企业参与,并不能传递相关的核心企业的信用到多级供应商;
  • 第三,银行缺乏中小企业的可信数据。在现存的银行风控体系下,中小企业无法证实贸易关系的存在,难以获得银行资金。相对地,银行业无法渗透入供应链进行获客和放款;
  • 第四,融资难、融资贵现象突出,在目前赊销模式盛行的市场背景下,供应链上游的供应商往往存在较大资金缺口,然而没有核心企业的背书,他们难以获得银行的优质贷款;
  • 第五,结算并不能自动完成。现在很多约定结算没有自动完成,涉及多级供应商结算时,不确定性因素更多。

因此,供应链金融需要数据穿透和信息共享,通过把资金流、信息流、物流,商流等融合在一起,来提升信息的真实性、信用的可传递和融资的高效率。

基于区块链的供应链金融,通过区块链技术将各个相关方链入一个平台,通过多方记账确权数据存储,实现数据的横向共享,进而实现核心企业的信任传递。基于物权法、电子合同法和电子签名法的约束,借助核心企业信用额度,提升中小企业的融资效率,降低小微企业的融资成本,加速实现普惠金融。

引入区块链带来哪些优势呢?

  • 第一,解决信息孤岛问题,多个利益相关方可以提前设定好规则,加速数据的互通和信息的共享;
  • 第二,根据物权法、电子合同法、电子签名法等,核心企业的应收账款凭证可以通过区块链转化为可流转、可融资的确权凭证,使得核心企业信用能沿着可信的贸易链路传递。基于相互的确权,整个凭证可以衍生出拆分、溯源等多种操作;
  • 第三,提供可信贸易数据,比如在区块链架构下提供线上化的基础合同、单证、支付等结构严密、完整的记录,提升了信息透明度,实现可穿透式的监管;
  • 第四,实现资本降本增效。核心企业信用传递后,中小企业可以使用核心企业的信贷授信额度,降低融资成本,提升融资效率;
  • 第五,实现合约智能清算。基于智能合约的自动清结算,减少人工干预,降低操作风险,保障回款安全。

总而言之,从整个信息流转来看,从以前的信息孤岛变成现在全链条的信息打通,从传统的核心企业只能覆盖一级供应商,变成能够覆盖多级供应商。基于加密数据的交易确权、基于存证的交易真实证明、基于共享账本的信任传递和基于智能合约的合约执行,形成回款封闭可控、穿透式监管、全链条数据打通的新生态,主要是有助于中小微企业解决“融资难,融资贵”的难题。

四、区块链应用案例-大大买钢网解决方案

翼启云服构建的Blockworm Baas(blockchain as a service)平台,是提供区块链服务的云平台,可以帮助用户快速构建区块链基础设施,将业务数据上链。 翼启云服以区块链上的数据为依托,为供应链上下游的中小企业提供金融服务。 平台采用区块链多链结构,B2B平台、供货商、采购方、仓储机构、物流机构作为数据录入节点,将供应链中的信息流、商流、物流数据存储在区块链上。首先,区块链打通了各个参与方的信息系统,提供了可信的协作环境,提高了交易协作的效率。其次,多个参与方基于交易本身协作,共同见证了交易的过程,为交易的真实性提供保障。最后,金融机构作为授信方和资金提供方,基于可信的数据源,利用数据分析等手段,为企业进行授信,放款。简化了融资流程,提高了融资效率,降低了融资成本。 以下为钢铁B2B电商大大买钢在Blockworm平台利用区块链进行业务数据记录的案例。 各方交易从订单生成的时刻起,包括仓储、物流过程中的关键节点都进行数据记录,数据存储在交易链上,整个交易可进行溯源,并根据各方信息交叉验证,防止虚假交易,保障交易真实性。

图片描述

图1. 交易全过程关键节点记录在交易链上

4.1 具体流程

  • 大大买钢网的新增订单的信息被记录在区块链上;
  • 大大买钢变更订单状态为待出库;
  • 第三方仓储在链上发现待出库订单,对货品进行出库操作,并标记订单状态为已出库;
  • 承接货品运输工作的第三方物流公司开始运送货物,并更新订单状态为运输中;
  • 下游买家收到货物,将订单状态标记为已签收。

所有交易中产生的应收账款、票据等可以作为融资标的,登记在资产链上,形成数字资产,此过程被称为资产数字化,这些数字资产本质是供应链中企业的债权。资产链记录所有资产的融资过程。部分过程可由链上的智能合约自动执行,节省人力成本。

图片描述

图2.融资链记录融资资产产生的过程

同时,在交易链中,数字化资产可以进行拆分,做为债权(或资本)向上游供货商进行采购,这样依托于核心企业的信用,就可以传递到整个供应链中,为供应链中的中小企业增信。

图片描述

图3.融资资产可以在链上进行流转

整合图(1) – 图(3), 最终就会演变成多链的模式(图(4)), 首先各个交易链提供资产的真实性溯源,中间是资产链,需要进行融资的资产都可以登记到资产链中,金融机构基于资产链,对资产的真实性进行溯源,评估风险,进行有针对性的金融服务。

图片描述

图4.多链结构,交易链为资产链上的融资资产增信

作者:于明扬

宜信技术学院

树莓派搭建个人服务器 - 农码一生 - 博客园

mikel阅读(1128)

来源: 树莓派搭建个人服务器 – 农码一生 – 博客园

树莓派搭建个人服务器

前言

上一篇树莓派也跑Docker和.NET Core有对树莓派做简单的介绍。包括系统的烧录、基本的设置、docker的安装、.net core的运行等。有人问我是不是一定要跑docker,答案肯定是否定的。我之所以用docker,完全是因为它的便利(带环境)、对系统无污染和方便系统资源的查看和控制。本篇文章继续分享下我对树莓派的使用。

docker安装

mySQL安装

我说在树莓派上跑mySQL你还不信。

docker run --restart=always \
-p 3306:3306 \
--name mysql \
-v $PWD/conf:/etc/mysql/conf.d \
-v $PWD/logs:/logs \
-v $PWD/data:/var/lib/mysql \
-e MYSQL_ROOT_PASSWORD=mysqlpassword \
-d hypriot/rpi-mysql:5.5
--restart=always                   #容器自启动
3306                               #数据库端口
-v $PWD/conf:/etc/mysql/conf.d     #配置文件挂载路径
MYSQL_ROOT_PASSWORD=mysqlpassword  #数据库密码

redis安装

docker run --name redis \
-p 6379:6379 \
-v $PWD/data/redis:/data \
-d arm32v7/redis:3.2.10 redis-server --appendonly yes

gogs安装

有了.net core、mySQL和redis,如果再能跑一个git服务器那不就吊炸天了。是的,树莓派安装gogs毫无压力。有了自己的代码管理服务器,再也不会因为GitHub访问慢而烦恼了,也不会因为某Git的广告而不爽了。

docker run --name=gogs -p 122:22 \
-p 1080:3000 \
-v /var/gogs:/data gogs/gogs-rpi

其中1080是浏览器访问端口,你也可以映射到80。122是ssh提交代码的端口。
注意:gogs需要依赖于mysql,第一次启动会引导你填入数据库相关信息。后面如果重启了树莓派,记得先启动mysql,再启动gogs,不然不能正常访问。

内外穿透

上面有了应用,有了数据库,还有了代码管理服务器,可我们树莓派总归是在一个局域网内。离开了这个网络上面说的那些功能好像就没什么用了。那我们有没有办法让我们的树莓派变成真正的服务器呢,答案是肯定的。我相信大家刚接触编程的时候,都幻想过自己写的网站能直接给别人访问。那个时候应该大多折腾过花生壳什么的,那就是个内网穿透的技术。只是别人提供了免费的服务器中转了下,速度也是慢得感人。
今天我们来自己利用frp软件实现内外穿透,当然还是需要服务器的 (没有服务器的同学,可以先借朋友的玩玩) 。

下载地址(各平台的都有):https://github.com/fatedier/frp/releases
因为我们是树莓派,可以下载最新版 frp_0.29.0_linux_arm.tar.gz(装客户端)和 frp_0.29.0_windows_amd64.zip(windows服务器)或者frp_0.29.0_linux_amd64.tar.gz(linux服务器)

首先把frp_0.29.0_linux_amd64.tar.gz上传到服务器,解压,跳转到目录里,编辑frps.ini

[common]
bind_addr = 0.0.0.0
bind_port = 7000
auto_token = token

然后运行./frps运行,启动服务端监听。

把frp_0.29.0_linux_arm.tar.gz上传到树莓派,解压,跳转到目录里,编辑frpc.ini

[common]
server_addr = 服务器ip
server_port = 7000
auto_token = token

[git]
type = tcp
local_ip = 127.0.0.1
local_port = 122
remote_port = 8000

[web]
type = tcp
local_ip = 127.0.0.1
local_port = 1080
remote_port = 8001

然后运行启动./frpc

然后就可以外网访问了,www.服务器ip:8001 就可以访问git服务器了。
注意:如果是云服务器记得把对应的8000、7000、8001端口加入到安全组里面,不然会被拦截掉。

frp的详细用法请参考:https://www.cnblogs.com/sanduzxcvbnm/category/1171545.html

其他安装

#安装nginx
docker run -d -p 80:80 --name nginx arm32v7/nginx:1.16

#dokcer仪表盘管理
docker run -d -p 9000:9000 --restart=always  -v /var/run/docker.sock:/var/run/docker.sock  --name prtainer-test portainer/portainer:arm

#一些其它树莓派的docker应用
https://hub.docker.com/u/arm32v7/
https://hub.docker.com/u/hypriot?page=1
https://cloud.docker.com/u/bennyzhao/repository/docker/bennyzhao/quartzui

树莓派还能做什么

可以做物联网数据的边缘计算啊,如设备的数据采集、监控预警、数据分析等等。再买点传感器做个简陋版“小爱同学”也不是很难,甚至遥控汽车玩具什么的,能想的到的也没什么是做不到的。

资源消耗截图


24小时不关机,零噪音,一直开心的运行着。

结束

推荐一个开源物联网组件:https://github.com/zhaopeiym/IoTClientdemo下载

C# Excel导入、导出【源码下载】 - 方木一 - 博客园

mikel阅读(671)

来源: C# Excel导入、导出【源码下载】 – 方木一 – 博客园

本篇主要介绍C#的Excel导入、导出。

目录

1. 介绍:描述第三方类库NPOI以及Excel结构

2. Excel导入:介绍C#如何调用NPOI进行Excel导入,包含:流程图、NOPI以及C#代码

3. Excel导出:介绍C#如何调用NPOI进行Excel导出,包含:流程图、NOPI以、C#代码以及代码分析

4. 源码下载:展示运行图及源码下载

1. 介绍

1.1 第三方类库:NPOI

说明:NPOI是POI项目的.NET 版本,可用于Excel、Word的读写操作。

优点:不用装Office环境。

下载地址:http://npoi.codeplex.com/releases

1.2 Excel结构介绍

工作簿(Workbook):每个Excel文件可理解为一个工作簿。

工作表(Sheet):一个工作簿(Workbook)可以包含多个工作表。

行(row):一个工作表(Sheet)可以包含多个行。

2. Excel导入

2.1 操作流程

2.2 NPOI操作代码

说明:把Excel文件转换为List<T>

步骤:

①读取Excel文件并以此初始化一个工作簿(Workbook);

②从工作簿上获取一个工作表(Sheet);默认为工作薄的第一个工作表;

③遍历工作表所有的行(row);默认从第二行开始遍历,第一行(序号0)为单元格头部;

④遍历行的每一个单元格(cell),根据一定的规律赋值给对象的属性。

/// <summary>
/// 从Excel2003取数据并记录到List集合里
/// </summary>
/// <param name="cellHeard">单元头的Key和Value:{ { "UserName", "姓名" }, { "Age", "年龄" } };</param>
/// <param name="filePath">保存文件绝对路径</param>
/// <param name="errorMsg">错误信息</param>
/// <returns>转换好的List对象集合</returns>
private static List<T> Excel2003ToEntityList<T>(Dictionary<stringstring> cellHeard, string filePath, out StringBuilder errorMsg) where T : new()
{
    errorMsg = new StringBuilder(); // 错误信息,Excel转换到实体对象时,会有格式的错误信息
    List<T> enlist = new List<T>(); // 转换后的集合
    List<string> keys = cellHeard.Keys.ToList(); // 要赋值的实体对象属性名称
    try
    {
        using (FileStream fs = File.OpenRead(filePath))
        {
            HSSFWorkbook workbook = new HSSFWorkbook(fs);
            HSSFSheet sheet = (HSSFSheet)workbook.GetSheetAt(0); // 获取此文件第一个Sheet页
            for (int i = 1; i <= sheet.LastRowNum; i++) // 从1开始,第0行为单元头
            {
                // 1.判断当前行是否空行,若空行就不在进行读取下一行操作,结束Excel读取操作
                if (sheet.GetRow(i) == null)
                {
                    break;
                }
                T en = new T();
                string errStr = ""// 当前行转换时,是否有错误信息,格式为:第1行数据转换异常:XXX列;
                for (int j = 0; j < keys.Count; j++)
                {
                    // 2.若属性头的名称包含'.',就表示是子类里的属性,那么就要遍历子类,eg:UserEn.TrueName
                    if (keys[j].IndexOf(".") >= 0)
                    {
                        // 2.1解析子类属性
                        string[] properotyArray = keys[j].Split(new string[] { "." }, StringSplitOptions.RemoveEmptyEntries);
                        string subClassName = properotyArray[0]; // '.'前面的为子类的名称
                        string subClassProperotyName = properotyArray[1]; // '.'后面的为子类的属性名称
                        System.Reflection.PropertyInfo subClassInfo = en.GetType().GetProperty(subClassName); // 获取子类的类型
                        if (subClassInfo != null)
                        {
                            // 2.1.1 获取子类的实例
                            var subClassEn = en.GetType().GetProperty(subClassName).GetValue(en, null);
                            // 2.1.2 根据属性名称获取子类里的属性信息
                            System.Reflection.PropertyInfo properotyInfo = subClassInfo.PropertyType.GetProperty(subClassProperotyName);
                            if (properotyInfo != null)
                            {
                                try
                                {
                                    // Excel单元格的值转换为对象属性的值,若类型不对,记录出错信息
                                    properotyInfo.SetValue(subClassEn, GetExcelCellToProperty(properotyInfo.PropertyType, sheet.GetRow(i).GetCell(j)), null);
                                }
                                catch (Exception e)
                                {
                                    if (errStr.Length == 0)
                                    {
                                        errStr = "第" + i + "行数据转换异常:";
                                    }
                                    errStr += cellHeard[keys[j]] + "列;";
                                }
                                
                            }
                        }
                    }
                    else
                    {
                        // 3.给指定的属性赋值
                        System.Reflection.PropertyInfo properotyInfo = en.GetType().GetProperty(keys[j]);
                        if (properotyInfo != null)
                        {
                            try
                            {
                                // Excel单元格的值转换为对象属性的值,若类型不对,记录出错信息
                                properotyInfo.SetValue(en, GetExcelCellToProperty(properotyInfo.PropertyType, sheet.GetRow(i).GetCell(j)), null);
                            }
                            catch (Exception e)
                            {
                                if (errStr.Length == 0)
                                {
                                    errStr = "第" + i + "行数据转换异常:";
                                }
                                errStr += cellHeard[keys[j]] + "列;";
                            }
                        }
                    }
                }
                // 若有错误信息,就添加到错误信息里
                if (errStr.Length > 0)
                {
                    errorMsg.AppendLine(errStr);
                }
                enlist.Add(en);
            }
        }
        return enlist;
    }
    catch (Exception ex)
    {
        throw ex;
    }
}

2.3 C#逻辑操作代码

说明:对Excel转换后的List<T>进行后续操作;如:检测有效性、持久化存储等等

步骤:

①调用2.2代码,把Excel文件转换为List<T>。

②对List<T>进行有效性检测:必填项是否为空、是否有重复记录等等。

③对List<T>进行持久化存储操作。如:存储到数据库。

④返回操作结果。

代码:

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
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
public void ImportExcel(HttpContext context)
{
    StringBuilder errorMsg = new StringBuilder(); // 错误信息
    try
    {
        #region 1.获取Excel文件并转换为一个List集合
        // 1.1存放Excel文件到本地服务器
        HttpPostedFile filePost = context.Request.Files["filed"]; // 获取上传的文件
        string filePath = ExcelHelper.SaveExcelFile(filePost); // 保存文件并获取文件路径
        // 单元格抬头
        // key:实体对象属性名称,可通过反射获取值
        // value:属性对应的中文注解
        Dictionary<stringstring> cellheader = new Dictionary<stringstring> {
            "Name""姓名" },
            "Age""年龄" },
            "GenderName""性别" },
            "TranscriptsEn.ChineseScores""语文成绩" },
            "TranscriptsEn.MathScores""数学成绩" },
        };
        // 1.2解析文件,存放到一个List集合里
        List<UserEntity> enlist = ExcelHelper.ExcelToEntityList<UserEntity>(cellheader, filePath, out errorMsg);
        #endregion
        #region 2.对List集合进行有效性校验
        #region 2.1检测必填项是否必填
        for (int i = 0; i < enlist.Count; i++)
        {
            UserEntity en = enlist[i];
            string errorMsgStr = "第" + (i + 1) + "行数据检测异常:";
            bool isHaveNoInputValue = false// 是否含有未输入项
            if (string.IsNullOrEmpty(en.Name))
            {
                errorMsgStr += "姓名列不能为空;";
                isHaveNoInputValue = true;
            }
            if (isHaveNoInputValue) // 若必填项有值未填
            {
                en.IsExcelVaildateOK = false;
                errorMsg.AppendLine(errorMsgStr);
            }
        }
        #endregion
        #region 2.2检测Excel中是否有重复对象
        for (int i = 0; i < enlist.Count; i++)
        {
            UserEntity enA = enlist[i];
            if (enA.IsExcelVaildateOK == false// 上面验证不通过,不进行此步验证
            {
                continue;
            }
            for (int j = i + 1; j < enlist.Count; j++)
            {
                UserEntity enB = enlist[j];
                // 判断必填列是否全部重复
                if (enA.Name == enB.Name)
                {
                    enA.IsExcelVaildateOK = false;
                    enB.IsExcelVaildateOK = false;
                    errorMsg.AppendLine("第" + (i + 1) + "行与第" + (j + 1) + "行的必填列重复了");
                }
            }
        }
        #endregion
        // TODO:其他检测
        #endregion
        // 3.TODO:对List集合持久化存储操作。如:存储到数据库
        
        // 4.返回操作结果
        bool isSuccess = false;
        if (errorMsg.Length == 0)
        {
            isSuccess = true// 若错误信息成都为空,表示无错误信息
        }
        var rs = new { success = isSuccess,  msg = errorMsg.ToString(), data = enlist };
        System.Web.Script.Serialization.JavaScriptSerializer js = new System.Web.Script.Serialization.JavaScriptSerializer();
        context.Response.ContentType = "text/plain";
        context.Response.Write(js.Serialize(rs)); // 返回Json格式的内容
    }
    catch (Exception ex)
    {
     throw ex;
    }
}

3. Excel导出

3.1 导出流程

3.2 NPOI操作代码

说明:把List<T>转换为Excel

步骤:

①创建一个工作簿(Workbook);

②在工作簿上创建一个工作表(Sheet);

③在工作表上创建第一行(row),第一行为列头,依次写入cellHeard的值(做为列名)。

④循环遍历List<T>集合,每循环一遍创建一个行(row),然后根据cellHeard的键(属性名称)依次从List<T>中的实体对象取值存放到单元格内。

代码:

3.3 C#逻辑操作代码

说明:对Excel转换后的List<T>进行后续操作;如:检测有效性、持久化存储等等

步骤:

①获取List<T>集合。

②调用3.2,将List<T>转换为Excel文件。

③服务器存储Excel文件并返回下载链接。

代码:

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
public void ExportExcel(HttpContext context)
{
    try
    {
        // 1.获取数据集合
        List<UserEntity> enlist = new List<UserEntity>() {
            new UserEntity{Name="刘一",Age=22,Gender="Male",TranscriptsEn=new TranscriptsEntity{ChineseScores=80,MathScores=90}},
            new UserEntity{Name="陈二",Age=23,Gender="Male",TranscriptsEn=new TranscriptsEntity{ChineseScores=81,MathScores=91} },
            new UserEntity{Name="张三",Age=24,Gender="Male",TranscriptsEn=new TranscriptsEntity{ChineseScores=82,MathScores=92} },
            new UserEntity{Name="李四",Age=25,Gender="Male",TranscriptsEn=new TranscriptsEntity{ChineseScores=83,MathScores=93} },
            new UserEntity{Name="王五",Age=26,Gender="Male",TranscriptsEn=new TranscriptsEntity{ChineseScores=84,MathScores=94} },
        };
        // 2.设置单元格抬头
        // key:实体对象属性名称,可通过反射获取值
        // value:Excel列的名称
        Dictionary<stringstring> cellheader = new Dictionary<stringstring> {
            "Name""姓名" },
            "Age""年龄" },
            "GenderName""性别" },
            "TranscriptsEn.ChineseScores""语文成绩" },
            "TranscriptsEn.MathScores""数学成绩" },
        };
        // 3.进行Excel转换操作,并返回转换的文件下载链接
        string urlPath = ExcelHelper.EntityListToExcel2003(cellheader, enlist, "学生成绩");
        System.Web.Script.Serialization.JavaScriptSerializer js = new System.Web.Script.Serialization.JavaScriptSerializer();
        context.Response.ContentType = "text/plain";
        context.Response.Write(js.Serialize(urlPath)); // 返回Json格式的内容
    }
    catch (Exception ex)
    {
        throw ex;
    }
}

3.4 代码分析

核心代码主要是cellheader与List<T>之间的映射关系:

4. 源码下载

4.1 运行图

4.2 下载地址

百度网盘: http://pan.baidu.com/s/1o69We8M

CSDN:http://download.csdn.net/download/polk6/8974195

2018/07/11更新,添加对Excel 2007的导入支持:

git:https://github.com/polk6/CSharp-Excel

记一次SQLServer的分页优化兼谈谈使用Row_Number()分页存在的问题 - 马非码 - 博客园

mikel阅读(675)

来源: 记一次SQLServer的分页优化兼谈谈使用Row_Number()分页存在的问题 – 马非码 – 博客园

最近有项目反应,在服务器CPU使用较高的时候,我们的事件查询页面非常的慢,查询几条记录竟然要4分钟甚至更长,而且在翻第二页的时候也是要这么多的时间,这肯定是不能接受的,也是让现场用SQLServerProfiler把语句抓取了上来。

用ROW_NUMBER()进行分页

我们看看现场抓上来的分页语句:

select top 20 a.*,ag.Name as AgentServerName,,d.Name as MgrObjTypeName,l.UserName as userName 
from eventlog as a 
    left join mgrobj as b on a.MgrObjId=b.Id and a.AgentBm=b.AgentBm 
    left join addrnode as c on b.AddrId=c.Id 
    left join mgrobjtype as d on b.MgrObjTypeId=d.Id 
    left join eventdir as e on a.EventBm=e.Bm 
    left join agentserver as ag on a.AgentBm=ag.AgentBm 
    left join loginUser as l on a.cfmoper=l.loginGuid 
where a.OrderNo not in  (
    select top 0 OrderNo  
    from eventlog  as a 
        left join mgrobj as b on a.MgrObjId=b.Id 
        left join addrnode as c on b.AddrId=c.Id  
    where 1=1 and a.AlarmTime>='2014-12-01 00:00:00' and a.AlarmTime<='2014-12-26 23:59:59' 
        and b.AddrId in ('02109000',……,'02109002') 
    order by  AlarmTime desc 
    )  
and 1=1 and a.AlarmTime>='2014-12-01 00:00:00' and a.AlarmTime<='2014-12-26 23:59:59' 
    and b.AddrId in ('02109000',……,'02109002') 
order by  AlarmTime DESC

这是典型的使用两次top来进行分页的写法,原理是:先查出pageSize*(pageIndex-1)(T1)的记录数,然后再TopPageSize条不在T1中的记录,就是当前页的记录。这种查询效率不高主要是使用了not in。参考我之前文章《程序猿是如何解决SQLServer占CPU100%的》提到的:“对于不使用SARG运算符的表达式,索引是没有用的”

那么改为使用ROW_NUMBER分页:

WITH cte AS(
    select a.*,ag.Name as AgentServerName,d.Name as MgrObjTypeName,l.UserName as userName,b.AddrId
            ,ROW_NUMBER() OVER(ORDER BY AlarmTime DESC) AS RowNo
        from eventlog as a WITH(FORCESEEK) 
            left join mgrobj as b on a.MgrObjId=b.Id and a.AgentBm=b.AgentBm 
            left join addrnode as c on b.AddrId=c.Id 
            left join mgrobjtype as d on b.MgrObjTypeId=d.Id 
            left join eventdir as e on a.EventBm=e.Bm 
            left join agentserver As ag on a.AgentBm=ag.AgentBm 
            left join loginUser as l on a.cfmoper=l.loginGuid 
        where a.AlarmTime>='2014-12-01 00:00:00' and a.AlarmTime<='2014-12-26 23:59:59' 
            AND b.AddrId in ('02109000',……,'02109002')
)
SELECT * FROM cte WHERE RowNo BETWEEN 1 AND 20;

执行时间从14秒提升到5秒,这说明Row_Number分页还是比较高效的,而且这种写法比top top分页优雅很多。

“欺骗”查询引擎让查询按你的期望去查询

但是为什么查询20条记录竟然要5秒呢,尤其在这个表是加上了时间索引的情况下——参考《程序猿是如何解决SQLServer占CPU100%的》中提到的索引。

我尝试去掉这句AND b.AddrId in ('02109000',……,'02109002'),结果不到1秒就把538条记录查询出来了,而加上地点限制这句,结果是204行。为什么结果集不大,花费的时间却相差这么多呢?查看执行计划,发现走的是另外的索引,而非时间索引。

把这个疑问放到了SQLServer群上,很快,高桑给了回复:要想达到跟去掉地点限制这句的效果,就使用AdddrId+'' in

什么意思?一时没看明白,是高桑没看懂我的语句?很快,有人补充,要欺骗查询引擎。“欺骗”?还是不懂,不过我照做了,把上述cte的语句原封不动的Copy出来,然后把这句AND b.AddrId in ('02109000',……,'02109002')更改为了AND b.AddrId+'' in ('02109000',……,'02109002'),一点执行,神了!!!不到1秒就执行完了。在把执行计划一对,果然走的是时间索引:

后来回味了一下,记起之前看到的查询引擎优化原理,如果你的条件中带有运算符或者使用函数等,则查询引擎会放弃优化,而执行表扫描。脑袋突然转过来了,在使用b.AddrId+''前查询引擎尝试把mgrObj表加入一起做优化,那么两个表联查,会导致预估的记录数大大增加,而使用了b.AddrId+'',查询引擎则会先按时间索引把记录刷选出来,这样就达到了效果,即强制先做cte在执行in条件,而不是在cte中进行in条件刷选。原来如此!有时候,查询引擎过度的优化,会导致相反的效果,而你如果能够知道优化的原理,那么就可以通过一些小的技巧让查询引擎按你的期望去进行优化

ROW_NUMBER()分页在页数较大时的问题

事情到这里,还没完。后面同事又跟我反应,查询到后面的页数,又卡了!what?我重新执行上述语句,把时间范围放到2011-12-01到2014-12-26,记录数限制为为19981到20000,果然,查询要30秒左右,查看执行计划,都是一样的,为什么?

高桑怀疑是key lookup过多导致的,建议先分页取出rid 再做key lookup。不懂这么一句是什么意思。把执行计划和IO打印出来:

看看IO,很明显,主要是越到后面的页数,其他的几个关联表读取的页数就越多。我推测,在Row_Number分页的时候,如果有表连接,则按排序一致到返回的记录数位置,前面的记录都是要参与表连接的,这就导致了越到后面的分页,就越慢,因为要扫描的关联表就越多。

难道就没有了办法了吗?这个时候宋桑英勇的站了出来:“你给表后加一个forceseek提示可破”。这真是犹如天籁之音,马上进行尝试。

使用forceseek提示可以强制表走索引

查了下资料:

SQL Server2008中引入的提示ForceSeek,可以用它将索引查找来替换索引扫描

那么,就在eventlog表中加上这句看看会怎样?

果然,查询计划变了,开始提示,缺少了包含索引。赶紧加上,果然,按这个方式进行查询之后查询时间变为18秒,有进步!但是查看IO,跟上面一样,并没有变少。不过,总算学会了一个新的技能,而宋桑也很热心说晚上再帮忙看看。

把其他没参与where的表放到cte外面

根据上面的IO,很快,又有人提到,把其他left join的表放到cte外面。这是个办法,于是把除eventlogmgrobjaddrnode的表放到外面,语句如下:

WITH cte AS(
    select a*,b.AddrId,b.Name as MgrObjName,b.MgrObjTypeId          
            ,ROW_NUMBER() OVER(ORDER BY AlarmTime DESC) AS RowNo
        from eventlog as a
            left join mgrobj as b on a.MgrObjId=b.Id and a.AgentBm=b.AgentBm 
            left join addrnode as c on b.AddrId=c.Id 
        where a.AlarmTime>='2011-12-01 00:00:00' and a.AlarmTime<='2014-12-26 23:59:59' 
            AND b.AddrId+'' in ('02109000',……,'02109002')
)
SELECT a.* 
    ,ag.Name as AgentServerName
    ,d.Name as MgrObjTypeName,l.UserName as userName
FROM cte a left join eventdir as e on a.EventBm=e.Bm 
            left join mgrobjtype as d on a.MgrObjTypeId=d.Id 
            left join agentserver As ag on a.AgentBm=ag.AgentBm 
            left join loginUser as l on a.cfmoper=l.loginGuid 
WHERE RowNo BETWEEN 19980 AND 20000;

果然有效,IO大大减少了,然后速度也提升到了16秒。

'loginuser'。扫描计数 1,逻辑读取 63 次,物理读取 0 次,预读 0 次,lob 逻辑读取 0 次,lob 物理读取 0 次,lob 预读 0 次。
表 'agentserver'。扫描计数 1,逻辑读取 1617 次,物理读取 0 次,预读 0 次,lob 逻辑读取 0 次,lob 物理读取 0 次,lob 预读 0 次。
表 'mgrobjtype'。扫描计数 1,逻辑读取 126 次,物理读取 0 次,预读 0 次,lob 逻辑读取 0 次,lob 物理读取 0 次,lob 预读 0 次。
表 'eventdir'。扫描计数 1,逻辑读取 42 次,物理读取 0 次,预读 0 次,lob 逻辑读取 0 次,lob 物理读取 0 次,lob 预读 0 次。
表 'addrnode'。扫描计数 1,逻辑读取 119997 次,物理读取 0 次,预读 0 次,lob 逻辑读取 0 次,lob 物理读取 0 次,lob 预读 0 次。
表 'Worktable'。扫描计数 0,逻辑读取 0 次,物理读取 0 次,预读 0 次,lob 逻辑读取 0 次,lob 物理读取 0 次,lob 预读 0 次。
表 'eventlog'。扫描计数 1,逻辑读取 5027 次,物理读取 3 次,预读 5024 次,lob 逻辑读取 0 次,lob 物理读取 0 次,lob 预读 0 次。
表 'mgrobj'。扫描计数 1,逻辑读取 24 次,物理读取 0 次,预读 0 次,lob 逻辑读取 0 次,lob 物理读取 0 次,lob 预读 0 次。

我们看到,addrNode表还是扫描计数很大。那还能不能提升,这个时候,我想到了,先把addrNodemgrobjmgrobjtype三个表联合查询,放到一个临时表,然后再和eventloginner join,然后查询结果再和其他表做left join,这样还能减少IO。

使用临时表存储分页记录在进行表连接减少IO

IF OBJECT_ID('tmpMgrObj') IS NOT NULL DROP TABLE tmpMgrObj
SELECT m.Id,AddrId,MgrObjTypeId,AgentBM,m.Name,a.Name AS AddrName 
    INTO tmpMgrObj  
    FROM dbo.mgrobj m
        INNER JOIN dbo.addrnode a ON a.Id=m.AddrId
    WHERE AddrId IN('02109000',……,'02109002');
WITH cte AS(
    select a.*,b.AddrId,b.MgrObjTypeId          
            ,ROW_NUMBER() OVER(ORDER BY AlarmTime DESC) AS RowNo
            ,ag.Name as AgentServerName
    ,d.Name as MgrObjTypeName,l.UserName as userName
        from eventlog as a
            INNER join tmpMgrObj as b on a.MgrObjId=b.Id and a.AgentBm=b.AgentBm
            left join mgrobjtype as d on b.MgrObjTypeId=d.Id 
            left join agentserver As ag on a.AgentBm=ag.AgentBm 
            left join loginUser as l on a.cfmoper=l.loginGuid 
    WHERE AlarmTime>'2011-12-01 00:00:00' AND AlarmTime<='2014-12-26 23:59:59'
) 
SELECT * FROM cte WHERE RowNo BETWEEN 19980 AND 20000
IF OBJECT_ID('tmpMgrObj') IS NOT NULL DROP TABLE tmpMgrObj

这次查询仅用了10秒。我们来看看IO:

'Worktable'。扫描计数 0,逻辑读取 0 次,物理读取 0 次,预读 0 次,lob 逻辑读取 0 次,lob 物理读取 0 次,lob 预读 0 次。
表 'mgrobj'。扫描计数 1,逻辑读取 24 次,物理读取 2 次,预读 23 次,lob 逻辑读取 0 次,lob 物理读取 0 次,lob 预读 0 次。
表 'addrnode'。扫描计数 1,逻辑读取 6 次,物理读取 3 次,预读 0 次,lob 逻辑读取 0 次,lob 物理读取 0 次,lob 预读 0 次。
----------'loginuser'。扫描计数 0,逻辑读取 24 次,物理读取 1 次,预读 0 次,lob 逻辑读取 0 次,lob 物理读取 0 次,lob 预读 0 次。
表 'Worktable'。扫描计数 0,逻辑读取 0 次,物理读取 0 次,预读 0 次,lob 逻辑读取 0 次,lob 物理读取 0 次,lob 预读 0 次。
表 'eventlog'。扫描计数 93,逻辑读取 32773 次,物理读取 515 次,预读 1536 次,lob 逻辑读取 0 次,lob 物理读取 0 次,lob 预读 0 次。
表 'tmpMgrObj'。扫描计数 1,逻辑读取 3 次,物理读取 0 次,预读 0 次,lob 逻辑读取 0 次,lob 物理读取 0 次,lob 预读 0 次。
表 'mgrobjtype'。扫描计数 1,逻辑读取 6 次,物理读取 1 次,预读 0 次,lob 逻辑读取 0 次,lob 物理读取 0 次,lob 预读 0 次。
表 'agentserver'。扫描计数 1,逻辑读取 77 次,物理读取 2 次,预读 0 次,lob 逻辑读取 0 次,lob 物理读取 0 次,lob 预读 0 次。

除了eventlog之外,其他的表的IO大大减少,有木有?

强制使用hash join

经网友提示,在大的页数时,可以强制使用hash join来减少IO,而且经过尝试,可以通过建立两个子查询来避免使用临时表。经过调整,最终优化的SQL语句如下:

SELECT  *
    ,ag.Name AS AgentServerName
    , l.UserName AS userName
FROM    ( 
    SELECT    a.*,ROW_NUMBER() OVER (ORDER BY AlarmTime DESC) AS RowNo
        , b.AddrName , b.Name AS MgrObjName
    FROM
        (SELECT    * 
            FROM      eventlog
            WHERE     AlarmTime>= '2011-12-01 00:00:00' AND AlarmTime< '2014-12-26 23:59:59') AS a
        INNER HASH JOIN (
            SELECT m.Id,AddrId,MgrObjTypeId,AgentBM,m.Name,a.Name AS AddrName,t.Name AS MgrObjTypeName
            FROM dbo.mgrobj m
                INNER JOIN dbo.addrnode a ON a.Id=m.AddrId
                INNER JOIN dbo.mgrobjtype t ON m.MgrObjTypeId=t.Id
            WHERE AddrId IN('02109000',……,'02109002')
        ) AS b ON a.MgrObjId=b.Id AND a.AgentBM=b.AgentBm
        
) tmp 
    LEFT JOIN agentserver AS ag ON tmp.AgentBm = ag.AgentBm
    LEFT JOIN eventdir AS e ON tmp.EventBm = e.Bm
    LEFT JOIN loginUser AS l ON tmp.cfmoper = l.loginGuid
WHERE tmp.RowNo BETWEEN 190001 AND 190020

在大的分页的时候,通过hash查询,不必扫描前面的页数,可以大大减少IO,但是,由于hash join是强制性的,所以使用的时候要注意,我这里应该是个特例。

查询分析器的提示:“警告: 由于使用了本地联接提示,联接次序得以强制实施。”

我们来看看对应情况下的IO:

'eventlog'。扫描计数 5,逻辑读取 5609 次,物理读取 34 次,预读 5636 次,lob 逻辑读取 0 次,lob 物理读取 0 次,lob 预读 0 次。
表 'Worktable'。扫描计数 3,逻辑读取 375 次,物理读取 0 次,预读 0 次,lob 逻辑读取 0 次,lob 物理读取 0 次,lob 预读 0 次。
表 'Worktable'。扫描计数 0,逻辑读取 0 次,物理读取 0 次,预读 0 次,lob 逻辑读取 0 次,lob 物理读取 0 次,lob 预读 0 次。
表 'mgrobj'。扫描计数 5,逻辑读取 24 次,物理读取 8 次,预读 40 次,lob 逻辑读取 0 次,lob 物理读取 0 次,lob 预读 0 次。
表 'mgrobjtype'。扫描计数 1,逻辑读取 6 次,物理读取 1 次,预读 0 次,lob 逻辑读取 0 次,lob 物理读取 0 次,lob 预读 0 次。
表 'addrnode'。扫描计数 3,逻辑读取 18 次,物理读取 6 次,预读 0 次,lob 逻辑读取 0 次,lob 物理读取 0 次,lob 预读 0 次。
表 'loginuser'。扫描计数 1,逻辑读取 60 次,物理读取 2 次,预读 0 次,lob 逻辑读取 0 次,lob 物理读取 0 次,lob 预读 0 次。
表 'eventdir'。扫描计数 1,逻辑读取 40 次,物理读取 0 次,预读 0 次,lob 逻辑读取 30 次,lob 物理读取 0 次,lob 预读 0 次。
表 'agentserver'。扫描计数 1,逻辑读取 1540 次,物理读取 1 次,预读 0 次,lob 逻辑读取 0 次,lob 物理读取 0 次,lob 预读 0 次。

这次的IO表现非常的好,没有因为查询后面的页数增大而导致较大的IO,查询时间从没有使用hash join的50秒提升为只需12秒,查询时间的开销应该耗费了在hash查找上了。

再看看对应的查询计划,这个时候,主要是因为排序的开销较大。

我们再看看他的预估的和执行的区别,为什么会让排序占如此大的开销?

很明显,预估的时候只需对刷选的结果排序,但是实际执行是对前面所有的页数进行了排序,最终排序占了大部分的开销。那么,这种情况能破吗?请留下您的回复!

其他优化参考

在另外的群上讨论时,发现使用ROW_NUMBER分页查询到后面的页数会越来越慢的这个问题的确困扰了不少的人。

有的人提出,谁会这么无聊,把页数翻到几千页以后?一开始我也是这么想的,但是跟其他人交流之后,发现确实有这么一种场景,我们的软件提供了最后一页这个功能,结果……当然,一种方法就是在设计软件的时候,就去掉这个最后一页的功能;另外一种思路,就是查询页数过半之后,就反向查询,那么查询最后一页其实也就是查询第一页。

还有一些人提出,把查询出来的内容,放到一个临时表,这个临时表中的加入自增Id的索引,这样,可以通过辨别Id来进行快速刷选记录。这也是一种方法,我打算稍后尝试。但是这种方法也是存在问题的,就是无法做到通用,必须根据每个表进行临时表的构建,另外,在超大数据查询时,插入的记录过多,因为索引的存在也是会慢的,而且每次都这么做,估计CPU也挺吃紧。但是不管怎么样,这是一种思路。

你有什么好的建议?不妨把你的想法在评论中提出来,一起讨论讨论。

总结

现在,我们来总结下在这次优化过程中学习到什么内容:

  • SQLServer中,ROW_NUMBER的分页应该是最高效的了,而且兼容SQLServer2005以后的数据库
  • 通过“欺骗”查询引擎的小技巧,可以控制查询引擎部分的优化过程
  • ROW_NUMBER分页在大页数时存在性能问题,可以通过一些小技巧进行规避
    • 尽量通过cte利用索引
    • 把不参与where条件的表放到分页的cte外面
    • 如果参与where条件的表过多,可以考虑把不参与分页的表先做一个临时表,减少IO
    • 在较大页数的时候强制使用hash join可以减少io,从而获得很好的性能
  • 使用with(forceseek)可以强制查询因此进行索引查询

最后,感谢SQLServer群的高桑、宋桑、肖桑和其他群友的大力帮助,这个杜绝吹水的群非常的棒,让我这个程序猿学到了很多数据库的知识!

注:经网友提示,2015-01-07 09:15做以下更新

  • 可以在记录数超过10000条,则采用hash join强制进行hash连接,减少IO(感谢27楼riccc)
  • 去掉最先给定的结果中采用left join而不是inner join的连接——left join的结果相当于没有用上addrId in ()的条件(感谢32楼夏浩)

参考文章

EasyExcel 轻松灵活读取Excel内容 - 日拱一兵 - 博客园

mikel阅读(723)

来源: EasyExcel 轻松灵活读取Excel内容 – 日拱一兵 – 博客园

写在前面

Java 后端程序员应该会遇到读取 Excel 信息到 DB 等相关需求,脑海中可能突然间想起 Apache POI 这个技术解决方案,但是当 Excel 的数据量非常大的时候,你也许发现,POI 是将整个 Excel 的内容全部读出来放入到内存中,所以内存消耗非常严重,如果同时进行包含大数据量的 Excel 读操作,很容易造成内存溢出问题

但 EasyExcel 的出现很好的解决了 POI 相关问题,原本一个 3M 的 Excel 用 POI 需要100M左右内存, 而 EasyExcel 可以将其降低到几 M,同时再大的 Excel 都不会出现内存溢出的情况,因为是逐行读取 Excel 的内容 (老规矩,这里不用过分关心下图,脑海中有个印象即可,看完下面的用例再回看这个图,就很简单了)

另外 EasyExcel 在上层做了模型转换的封装,不需要 cell 等相关操作,让使用者更加简单和方便,且看

简单读

假设我们 excel 中有以下内容:

我们需要新建 User 实体,同时为其添加成员变量

@Data
public class User {

    /**
     * 姓名
     */
    @ExcelProperty(index = 0)
    private String name;

    /**
     * 年龄
     */
    @ExcelProperty(index = 1)
    private Integer age;
}

你也许关注到了 @ExcelProperty 注解,同时使用了 index 属性 (0 代表第一列,以此类推),该注解同时支持以「列名」name 的方式匹配,比如:

@ExcelProperty("姓名")
private String name;

按照 github 文档的说明:

不建议 index 和 name 同时用,要么一个对象只用index,要么一个对象只用name去匹配

  1. 如果读取的 Excel 模板信息列固定,这里建议以 index 的形式使用,因为如果用名字去匹配,名字重复,会导致只有一个字段读取到数据,所以 index 是更稳妥的方式
  2. 如果 Excel 模板的列 index 经常有变化,那还是选择 name 方式比较好,不用经常性修改实体的注解 index 数值

所以大家可以根据自己的情况自行选择

编写测试用例

EasyExcel 类中重载了很多个 read 方法,这里不一一列举说明,请大家自行查看;同时 sheet 方法也可以指定 sheetNo,默认是第一个 sheet 的信息

上面代码的 new UserExcelListener() 异常醒目,这也是 EasyExcel 逐行读取 Excel 内容的关键所在,自定义 UserExcelListener 继承 AnalysisEventListener

@Slf4j
public class UserExcelListener extends AnalysisEventListener<User> {

    /**
     * 批处理阈值
     */
    private static final int BATCH_COUNT = 2;
    List<User> list = new ArrayList<User>(BATCH_COUNT);

    @Override
    public void invoke(User user, AnalysisContext analysisContext) {
        log.info("解析到一条数据:{}", JSON.toJSONString(user));
        list.add(user);
        if (list.size() >= BATCH_COUNT) {
            saveData();
            list.clear();
        }
    }

    @Override
    public void doAfterAllAnalysed(AnalysisContext analysisContext) {
        saveData();
        log.info("所有数据解析完成!");
    }

    private void saveData(){
        log.info("{}条数据,开始存储数据库!", list.size());
        log.info("存储数据库成功!");
    }
}

到这里请回看文章开头的 EasyExcel 原理图,invoke 方法逐行读取数据,对应的就是订阅者 1;doAfterAllAnalysed 方法对应的就是订阅者 2,这样你理解了吗?

打印结果:

从这里可以看出,虽然是逐行解析数据,但我们可以自定义阈值,完成数据的批处理操作,可见 EasyExcel 操作的灵活性

自定义转换器

这是最基本的数据读写,我们的业务数据通常不可能这么简单,有时甚至需要将其转换为程序可读的数据

性别信息转换

比如 Excel 中新增「性别」列,其性别为男/女,我们需要将 Excel 中的性别信息转换成程序信息: 「1: 男;2:女」

首先在 User 实体中添加成员变量 gender:

@ExcelProperty(index = 2)
private Integer gender;

EasyExcel 支持我们自定义 converter,将 excel 的内容转换为我们程序需要的信息,这里新建 GenderConverter,用来转换性别信息

public class GenderConverter implements Converter<Integer> {

    public static final String MALE = "男";
    public static final String FEMALE = "女";

    @Override
    public Class supportJavaTypeKey() {
        return Integer.class;
    }

    @Override
    public CellDataTypeEnum supportExcelTypeKey() {
        return CellDataTypeEnum.STRING;
    }

    @Override
    public Integer convertToJavaData(CellData cellData, ExcelContentProperty excelContentProperty, GlobalConfiguration globalConfiguration) throws Exception {
        String stringValue = cellData.getStringValue();
        if (MALE.equals(stringValue)){
            return 1;
        }else {
            return 2;
        }
    }

    @Override
    public CellData convertToExcelData(Integer integer, ExcelContentProperty excelContentProperty, GlobalConfiguration globalConfiguration) throws Exception {
        return null;
    }
}

上面程序的 Converter 接口的泛型是指要转换的 Java 数据类型,与 supportJavaTypeKey 方法中的返回值类型一致

打开注解 @ExcelProperty 查看,该注解是支持自定义 Converter 的,所以我们为 User 实体添加 gender 成员变量,并指定 converter

/**
 * 性别 1:男;2:女
 */
@ExcelProperty(index = 2, converter = GenderConverter.class)
private Integer gender;

来看运行结果:

数据按照我们预期做出了转换,从这里也可以看出,Converter 可以一次定义到处是用的便利性

日期信息转换

日期信息也是我们常见的转换数据,比如 Excel 中新增「出生年月」列,我们要解析成 yyyy-MM-dd 格式,我们需要将其进行格式化,EasyExcel 通过 @DateTimeFormat 注解进行格式化

在 User 实体中添加成员变量 birth,同时应用 @DateTimeFormat 注解,按照要求做格式化

/**
 * 出生日期
 */
@ExcelProperty(index = 3)
@DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private String birth;

来看运行结果:

如果这里你指定 birth 的类型为 Date,试试看,你得到的结果是什么?

到这里都是以测试的方式来编写程序代码,作为 Java Web 开发人员,尤其在目前主流 Spring Boot 的架构下,所以如何实现 Web 方式读取 Excel 的信息呢?

web 读

简单 Web

很简单,只是将测试用例的关键代码移动到 Controller 中即可,我们新建一个 UserController,在其添加 upload 方法

@RestController
@RequestMapping("/users")
@Slf4j
public class UserController {
    @PostMapping("/upload")
    public String upload(MultipartFile file) throws IOException {
        EasyExcel.read(file.getInputStream(), User.class, new UserExcelListener()).sheet().doRead();
        return "success";
    }
}

其实在写测试用例的时候你也许已经发现,listener 是以 new 的形式作为参数传入到 EasyExcel.read 方法中的,这是不符合 Spring IoC 的规则的,我们通常读取 Excel 数据之后都要针对读取的数据编写一些业务逻辑的,而业务逻辑通常又会写在 Service 层中,我们如何在 listener 中调用到我们的 service 代码呢?

先不要向下看,你脑海中有哪些方案呢?

匿名内部类方式

匿名内部类是最简单的方式,我们需要先新建 Service 层的信息:
新建 IUser 接口:

public interface IUser {
    public boolean saveData(List<User> users);
}

新建 IUser 接口实现类 UserServiceImpl:

@Service
@Slf4j
public class UserServiceImpl implements IUser {
    @Override
    public boolean saveData(List<User> users) {
        log.info("UserService {}条数据,开始存储数据库!", users.size());
        log.info(JSON.toJSONString(users));
        log.info("UserService 存储数据库成功!");
        return true;
    }
}

接下来,在 Controller 中注入 IUser:

@Autowired
private IUser iUser;

修改 upload 方法,以匿名内部类重写 listener 方法的形式来实现:

@PostMapping("/uploadWithAnonyInnerClass")
    public String uploadWithAnonyInnerClass(MultipartFile file) throws IOException {
        EasyExcel.read(file.getInputStream(), User.class, new AnalysisEventListener<User>(){
            /**
             * 批处理阈值
             */
            private static final int BATCH_COUNT = 2;
            List<User> list = new ArrayList<User>();

            @Override
            public void invoke(User user, AnalysisContext analysisContext) {
                log.info("解析到一条数据:{}", JSON.toJSONString(user));
                list.add(user);
                if (list.size() >= BATCH_COUNT) {
                    saveData();
                    list.clear();
                }
            }

            @Override
            public void doAfterAllAnalysed(AnalysisContext analysisContext) {
                saveData();
                log.info("所有数据解析完成!");
            }

            private void saveData(){
                iUser.saveData(list);
            }
        }).sheet().doRead();
        return "success";
    }

查看结果:

这种实现方式,其实这只是将 listener 中的内容全部重写,并在 controller 中展现出来,当你看着这么臃肿的 controller 是不是非常难受?很显然这种方式不是我们的最佳编码实现

构造器传参

在之前分析 SpringBoot 统一返回源码时,不知道你是否发现,Spring 底层源码多数以构造器的形式传参,所以我们可以将为 listener 添加有参构造器,将 Controller 中依赖注入的 IUser 以构造器的形式传入到 listener :

@Slf4j
public class UserExcelListener extends AnalysisEventListener<User> {

    private IUser iUser;

    public UserExcelListener(IUser iUser){
        this.iUser = iUser;
    }

    // 省略相应代码...

    private void saveData(){
        iUser.saveData(list); //调用 userService 中的 saveData 方法
    }
    

更改 Controller 方法:

@PostMapping("/uploadWithConstructor")
public String uploadWithConstructor(MultipartFile file) throws IOException {
    EasyExcel.read(file.getInputStream(), User.class, new UserExcelListener(iUser)).sheet().doRead();
    return "success";
}

运行结果: 同上

这样更改后,controller 代码看着很清晰,但如果后续业务还有别的 Service 需要注入,我们难道要一直添加有参构造器吗?很明显,这种方式同样不是很灵活。

其实在使用匿名内部类的时候,你也许会想到,我们可以通过 Java8 lambda 的方式来解决这个问题

Lambda 传参

为了解决构造器传参的痛点,同时我们又希望 listener 更具有通用性,没必要为每个 Excel 业务都新建一个 listener,因为 listener 都是逐行读取 Excel 数据,只需要将我们的业务逻辑代码传入给 listener 即可,所以我们需用到 Consumer<T> ,将其作为构造 listener 的参数。

新建一个工具类 ExcelDemoUtils,用来构造 listener:

我们看到,getListener 方法接收一个 Consumer<List<T>> 的参数,这样下面代码被调用时,我们的业务逻辑也就会被相应的执行了:

consumer.accept(linkedList);

继续改造 Controller 方法:

运行结果: 同上

到这里,我们只需要将业务逻辑定制在 batchInsert 方法中:

  1. 满足 Controller RESTful API 的简洁性
  2. listener 更加通用和灵活,它更多是扮演了抽象类的角色,具体的逻辑交给抽象方法的实现来完成
  3. 业务逻辑可扩展性也更好,逻辑更加清晰

总结

到这里,关于如何使用 EasyExcel 读取 Excel 信息的基本使用方式已经介绍完了,还有很多细节内容没有讲,大家可以自行查阅 EasyExcel Github 文档去发现更多内容。灵活使用 Java 8 的函数式接口,更容易让你提高代码的复用性,同时看起来更简洁规范

除了读取 Excel 的读取,还有 Excel 的写入,如果需要将其写入到指定位置,配合 HuTool 的工具类 FileWriter 的使用是非常方便的,针对 EasyExcel 的使用,如果大家有什么问题,也欢迎到博客下方探讨

完整代码请在公众号回复「demo」,点开链接,查看「easy-excel-demo」文件夹的内容即可,另外个人博客由于特殊原因暂时关闭首页,其他目录访问一切正常,更多文章可以从 https://dayarch.top/archives 入口查看

感谢

非常感谢 EasyExcel 的作者 🌹🌹,让 Excel 的读写更加方便

灵魂追问

  1. 除了 Consumer,如果需要返回值的业务逻辑,需要用到哪个函数式接口呢?
  2. 当出现复杂表头的时候要如何处理呢?
  3. 将 DB 数据写入到 Excel 并下载,如何实现呢?
  4. 从 EasyExcel 的设计上,你学到了什么,欢迎博客下方留言讨论

提高效率工具

C#调用RabbitMQ实现消息队列 - kiba518 - 博客园

mikel阅读(934)

来源: C#调用RabbitMQ实现消息队列 – kiba518 – 博客园

前言

我在刚接触使用中间件的时候,发现,中间件的使用并不是最难的,反而是中间件的下载,安装,配置才是最难的。

所以,这篇文章我们从头开始学习RabbitMq,真正的从头开始。

关于消息队列

其实消息队列没有那么神秘,我们这样想一下,用户访问网站,最终是要将数据以HTTP的协议的方式,通过网络传输到主机的某个端口上的。

那么,接收数据的方式是什么呢?自然是端口监听啦。

那消息队列是什么就很好解释了?

它就是端口监听,接到数据后,将数据排列起来。

那这件事,我们不用中间件能做吗?

当然能做啦,写个TCP/UDP/Socket的软件就可以做啦。

举个简单的例子,如下图:

既然自己可以做消息队列,那为什么要用RabbitMQ?

因为,RabbitMQ成熟的开源中间件,可靠性有保证,bug少,性能也非常好。

C#代码默认是使用托管内存的,所以,想写出媲美RabbitMQ性能的消息队列,就必须离开我们常用的托管内存,使用非托管内存,但这个代价就太大了;而且最终能否达到RabbitMQ的性能水平还是个未知数。

还有就是RabbitMQ除了基础的消息队列管理,还有很多很强大的额外功能,而自己开发消息队列,很难如此尽善尽美。

—————————————————————————————————-

我们还会发现,在消息队列里有很多概念,什么消息总线啊,什么工作队列啊等等。

要怎么理解这些概念呢?

很简单,不要去理解。这些概念其实是人家代码架构的模式,不要去理解他们,【记】就完了,人家的中间件就是按照这个模式工作的。

比如,我写了一个接收消息的总控制器,然后我为他命名为总线,那这个控制器就是总线,没有理由,这就是定义。

准备工作

首先,我们访问官网【https://www.rabbitmq.com/】,点击Get Started。

然后,网站会自动跳转到当前首页Get Started的锚点位置,如下图:

Get Started锚点:

然后我们点击DownLoad+Installation,进入到下载界面。

在下载页面中,我们找到安装指南,然后在点击官网推荐的Windows系统的安装包,如下图:

现在,我们进入了Windows安装指南界面了。

首先,我们看一下预览信息,如下图:

在预览里,我们得知,安装RabbitMQ有两种方法,一种是使用Chocolatey安装,一种是使用官方安装包安装。

Chocolatey是什么呢?随手百度一下,原来他是一个软件包管理工具,也就是说,Chocolatey是类似于Nuget的一种工具。

由于Chocolatey的使用,我不是很熟悉,所以,这里选择使用官方安装包安装。

点击【Using the official installer】,我们进入了【Using the official installer】对应的锚点,如下图。

在【Using the official installer】段落里找到有推荐标志的安装包,然后下载。

下载完成后,我们可以得到这样一个安装包,如下图:

除了下载安装包,我们还会发现,在【Using the official installer】段落里,有提醒我们,RabbitMQ是有依赖的,依赖一个Erlang语言的框架(类似于C#语言的NetFramework)。

我们可以发现,在依赖的段落里,官网非常坑的给出了三个链接网址,如下:

supported version of Erlang:https://www.rabbitmq.com/which-erlang.html

Windows installer:https://www.erlang.org/downloads

Erlang Solutions:https://www.erlang-solutions.com/resources/download.html

因为,我们是无法通过文字描述来判断,哪一个是真的依赖框架的下载地址,所以只好每个都点击进去看看。。。

打开网址后发现,在后两个网址中都可以找到框架下载地址,但第二个地址明显更友好一点,所以我们在第二个网址内下载Erlang的框架。

下载完成得到如下图文件:

PS:这里下载的是OTP的22.1的版本,我的理解是Erlang等于C#语言,而OTP等于NetFramework。

安装Erlang\OTP

首先,我们运行otp_win64_22.1.exe,安装依赖框架Erlang\OTP。

安装完成后,设置环境变量如下:

然后运行CMD,输入erl,测试安装是否成功,如下图:

安装成功。

安装rabbitmq-server

安装完依赖后,我们接着安装rabbitmq-server-3.8.0.exe。

【rabbitmq-server-3.8.0.exe】?从这个文件名上,我们发现了一个问题,那就是,我们即将安装的RabbitMQ,是一个服务端啊。

什么?服务端?难道还有客户端???

其实这也很好理解,想一下最开始我举的那个例子,消息队列是需要一个监听端口的服务端的,然后客户端向这个服务端发送请求。

这样是不是就很好的理解RabbitMQ了呢:)

—————————————————————————————————-

安装完RabbitMQ服务端后,我们还是启动CMD,用命令行来查看下安装状态。

首先输入下面的命令,将路径定位到RabbitMQ的路径下:

【CD /D C:\Program Files\RabbitMQ Server\rabbitmq_server-3.8.0\sbin】

然后输入rabbitmqctl status查看状态。

启动管理工具的命令行:rabbitmq-plugins enable rabbitmq_management。

启动成功后,在浏览器输入地址http://127.0.0.1:15672/,进入管理页面,账户密码都是guest。

RabbitMQ还有很多常用命令,大家可以自行百度。

—————————————————————————————————-

到此,RabbitMQ服务端的环境配置好了,正常情况,这些配置应该在服务器进行,但我为了测试方便,就把服务端也安装在本机了,因此我下面调用RabbitMQ时,连接的主机IP都是localhost。

RabbitMQ应用

首先创建两个控制台应用程序,KibaRabbitMQSend和KibaRabbitMQReceived。

然后引入RabbitMQ的开源类库。

在C#里使用RabbitMQ开源类库非常简单,可以去官网下载一个.NET版本的RabbitMQ客户端类库,也可以直接在Nuget上搜索RabbitMQ,然后安装,如下图:

KibaRabbitMQSend

安装完RabbitMQ开源类库后,我们编写代码,实现向RabbitMQ服务器发送消息,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
static void Main(string[] args)
{
    var factory = new ConnectionFactory();
    factory.HostName = "localhost";//主机名,Rabbit会拿这个IP生成一个endpoint,这个很熟悉吧,就是socket绑定的那个终结点。
    factory.UserName = "guest";//默认用户名,用户可以在服务端自定义创建,有相关命令行
    factory.Password = "guest";//默认密码
    using (var connection = factory.CreateConnection())//连接服务器,即正在创建终结点。
    {
        //创建一个通道,这个就是Rabbit自己定义的规则了,如果自己写消息队列,这个就可以开脑洞设计了
        //这里Rabbit的玩法就是一个通道channel下包含多个队列Queue
        using (var channel = connection.CreateModel())
        {
             channel.QueueDeclare("kibaQueue"falsefalsefalsenull);//创建一个名称为kibaqueue的消息队列
             var properties = channel.CreateBasicProperties();
             properties.DeliveryMode = 1;
             string message = "I am Kiba518"//传递的消息内容
             channel.BasicPublish("""kibaQueue", properties, Encoding.UTF8.GetBytes(message)); //生产消息
             Console.WriteLine($"Send:{message}");
        }
    }
}

运行代码。

然后我们使用命令行rabbitmqctl list_queues,去RabbitMQ的服务器查看当前消息队列,如下图:

可以看到,我们的消息已经发送成功了。

KibaRabbitMQReceived

现在我们编写接收消息代码,如下:

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
static void Main(string[] args)
{
    var factory = new ConnectionFactory();
    factory.HostName = "localhost";
    factory.UserName = "guest";
    factory.Password = "guest";
    using (var connection = factory.CreateConnection())
    {
        using (var channel = connection.CreateModel())
        {
            channel.QueueDeclare("kibaQueue"falsefalsefalsenull);
            /* 这里定义了一个消费者,用于消费服务器接受的消息
             * C#开发需要注意下这里,在一些非面向对象和面向对象比较差的语言中,是非常重视这种设计模式的。
             * 比如RabbitMQ使用了生产者与消费者模式,然后很多相关的使用文章都在拿这个生产者和消费者来表述。
             * 但是,在C#里,生产者与消费者对我们而言,根本算不上一种设计模式,他就是一种最基础的代码编写规则。
             * 所以,大家不要复杂的名词吓到,其实,并没那么复杂。
             * 这里,其实就是定义一个EventingBasicConsumer类型的对象,然后该对象有个Received事件,
             * 该事件会在服务接收到数据时触发。
             */
            var consumer = new EventingBasicConsumer(channel);//消费者
            channel.BasicConsume("kibaQueue"true, consumer);//消费消息
            consumer.Received += (model, ea) =>
            {
                var body = ea.Body;
                var message = Encoding.UTF8.GetString(body);
            };
        }
    }
}

运行代码。

然后我们使用命令行rabbitmqctl list_queues,去RabbitMQ的服务器查看当前消息队列,如下图:

可以看到,消息已经被使用了。

—————————————————————————————————-

现在我们在发送代码出做一个for循环,看看消息接收速度是什么样的,代码如下,for循环了100次,每次间隔3秒。

1
2
3
4
5
6
7
8
9
10
for (int i = 0; i < 100; i++)
{
    channel.QueueDeclare("kibaQueue"falsefalsefalsenull);//创建一个名称为kibaQueue的消息队列
    var properties = channel.CreateBasicProperties();
    properties.DeliveryMode = 1;
    string message = "I am Kiba518"//传递的消息内容
    channel.BasicPublish("""kibaQueue", properties, Encoding.UTF8.GetBytes(message)); //生产消息
    Console.WriteLine($"Send:{message}");
    Thread.Sleep(3000);
}

效果图如下:

可以看到,发送消息和接收消息,几乎是同步的,效果非常理想。

服务器端应用

在上文,我们的RabbitMQ服务是安装在我的本机上的;现在我们把服务移植到服务器上,然后再来测试一下。

在服务器端安装RabbitMQ和在本机安装的步骤是一样的,但是安装完成后,我们需要设置下防火墙的入站规则和出站规则,将5672的UDP端口开放一下。

为什么要开放端口是5672?因为RabbitMQ的默认的消息接收和发送端口就是5672,我们可以使用断点查看一下。

如上图,可以看到,在我们没有设置端口的时候,Endpoint的端口的默认值是5672。

配置完端口后,我们修改代码中的HostName为我们的服务器地址,如下。

1
factory.HostName = "1.1.1.1";

重新运行代码,会发现在运行到factory.CreateConnection()的时候,系统提示一个异常【RabbitMQ.Client.Exceptions.BrokerUnreachableException:“None of the specified endpoints were reachable”】,如下图:

这是因为我们使用的账号是guest,guest账号默认是不支持远程连接的。

解决办法很简单,新建一个账户即可。

创建用户

在服务器端打开浏览器,输入http://127.0.0.1:15672/,进入管理页面。

点击菜单栏的Admin选项,进入用户管理界面创建用户kiba,密码123456,如下图:

创建完用户后,得到如下界面。

如上图所示,刚刚创建的用户还没有任何访问权限。

现在我们点击用户名,进入权限管理页面设置权限。

如上图所示,页面默认为我们设置了一个可读,可写,可管理配置的权限;所以,我们只要点击Set premission就可以了。

设置完权限,我们回到用户管理页面。

如上图所示,权限设置成功。

现在我们回到代码,修改用户名密码如下。

1
2
3
factory.HostName = "1.1.1.1";
factory.UserName = "kiba";
factory.Password = "123456";

运行代码,不再抛异常,接受发送消息正常。

—————————————————————————————————-

设置用户权限也可以通过命令的方式设置,如下:

rabbitmqctl set_permissions -p “/” kiba “.” “.” “.*”

—————————————————————————————————-

到此C#调用RabbitMQ实现消息队列就讲完了。

代码已经传到Github上了,欢迎大家下载。

Github地址:https://github.com/kiba518/KibaRabbitMQ

—————————————————————————————————-

注:此文章为原创,任何形式的转载都请联系作者获得授权并注明出处!
若您觉得这篇文章还不错,请点击下方的【推荐】,非常感谢!

https://www.cnblogs.com/kiba/p/11703073.html