产品代码都给你看了,可别再说不会DDD(五):请求处理流程 | 码如云文档中心

mikel阅读(366)

来源: 产品代码都给你看了,可别再说不会DDD(五):请求处理流程 | 码如云文档中心

这是一个讲解DDD落地的文章系列,作者是《实现领域驱动设计》的译者滕云。本文章系列以一个真实的并已成功上线的软件项目——码如云https://www.mryqr.com)为例,系统性地讲解DDD在落地实施过程中的各种典型实践,以及在面临实际业务场景时的诸多取舍。

本系列包含以下文章:

  1. DDD入门
  2. DDD概念大白话
  3. 战略设计
  4. 代码工程结构
  5. 请求处理流程(本文)
  6. 聚合根与资源库
  7. 实体与值对象
  8. 应用服务与领域服务
  9. 领域事件
  10. CQRS

案例项目介绍 #

既然DDD是“领域”驱动,那么我们便不能抛开业务而只讲技术,为此让我们先从业务上了解一下贯穿本文章系列的案例项目 —— 码如云(不是马云,也不是码云)。如你已经在本系列的其他文章中了解过该案例,可跳过。

码如云是一个基于二维码的一物一码管理平台,可以为每一件“物品”生成一个二维码,并以该二维码为入口展开对“物品”的相关操作,典型的应用场景包括固定资产管理、设备巡检以及物品标签等。

在使用码如云时,首先需要创建一个应用(App),一个应用包含了多个页面(Page),也可称为表单,一个页面又可以包含多个控件(Control),比如单选框控件。应用创建好后,可在应用下创建多个实例(QR)用于表示被管理的对象(比如机器设备)。每个实例均对应一个二维码,手机扫码便可对实例进行相应操作,比如查看实例相关信息或者填写页面表单等,对表单的一次填写称为提交(Submission);更多概念请参考码如云术语

在技术上,码如云是一个无代码平台,包含了表单引擎、审批流程和数据报表等多个功能模块。码如云全程采用DDD完成开发,其后端技术栈主要有Java、Spring Boot和MongoDB等。

码如云的源代码是开源的,可以通过以下方式访问:

码如云源代码:https://github.com/mryqr-com/mry-backend

请求处理流程 #

在上一篇代码工程结构中,我们从宏观层面讲到了DDD项目的目录结构,但并未触及到实际的代码。在本文中,我们将深入到代码中,逐一讲解DDD中对各种请求类型的典型处理流程。

在本系列的DDD概念大白话我们提到,DDD中的所有组件都是围绕着聚合根展开的,其中有些本身即是聚合根的一部分,比如实体和值对象;有些是聚合根的客户,比如应用服务;有些则是对聚合根的辅助或补充,比如领域服务和工厂。反观当下流行的各种软件架构,无论是分层架构、六边形架构还是整洁架构,它们都有一个共同点,即在架构中心都有一个核心存在,这个核心正是领域模型,而DDD的聚合根则存在于领域模型之中。

不难看出,既然每种架构中都有为领域模型预留的位置,这也意味着DDD可采用任何一种软件架构。事实也的确如此,DDD并不要求采用哪种特定架构,如果你真要说DDD项目应该采用某种架构的话,那么应该“以领域模型为中心的软件架构”。

如果我们把软件系统当做一个黑盒的话,其外界是各种形态的客户端,比如浏览器,手机APP或者第三方调用方等,盒子内部则是我们精心构建的领域模型。不过,领域模型是不能直接被外界访问的,主要原因有以下两点:

  • 客户端的演进和领域模型的演进是不同步的,比如网页端所需要展示的信息量比手机端更多,但是他们所使用的领域模型却是相同的,因此在建模时我们通常会将领域模型和客户端解耦开来,以利于各自的建模和演进
  • 软件除了处理领域模型这种业务复杂度之外,还需要处理技术复杂度,以及业务和技术的衔接复杂度,比如有些请求通过HTTP协议完成,而有些则通过RPC完成,因此除了领域模型,我们还需要适配各种形式的外部客户端

接下来,让我们来看看DDD项目是如何衔接外部请求和内部领域模型的。既然聚合根是领域模型中的一等公民,那么按照对聚合根的操作类型不同,DDD项目中主要存在以下4种类型的请求:

  • 聚合根创建流程
  • 聚合根更新流程
  • 聚合根删除流程
  • 查询流程

咋一看,你可能会说这不就是CRUD么?本质上这的确是CRUD,但是这里的CRUD可不是仅仅操作数据库那么简单,你如果阅览过本系列的上一篇代码工程结构的话,便知道在码如云中领域模型的代码量占比远远高出数据库访问相关的代码量。

本文主要讲解DDD对请求的处理流程,并不讲解聚合根本身的设计和实现,而是假设聚合根(以及领域模型中的工厂和领域服务等)已经实现就位了,关于聚合根本身的讲解请参考本系列的聚合根与资源库一文。此外,为了突出重点,本文只着重讲解请求处理流程的主干,而忽略与之关系不大的其他细节,比如我们将忽略应用服务中的事务处理和权限管理等功能,为此读者可参考应用服务与领域服务

聚合根创建流程 #

聚合根的创建通常通过工厂类完成,请求流经路线为:控制器(Controller) -> 应用服务(Application Service) -> 工厂(Factory) -> 资源库(Repository)。

在码如云中,当用户提交表单后,系统后台将创建一份提交(Submission),这里的Submission便是一个聚合根对象。在整个“创建Submission”的处理流程中,请求先通过HTTP协议到达Spring MVC中的Controller:

//SubmissionController

@PostMapping
@ResponseStatus(CREATED)
public ReturnId newSubmission(@RequestBody @Valid NewSubmissionCommand command,
                              @AuthenticationPrincipal User user) {
    String submissionId = submissionCommandService.newSubmission(command, user);
    return returnId(submissionId);
}

源码出处:com/mryqr/core/submission/SubmissionController.java

Controller的作用只是为了衔接技术和业务,因此其逻辑应该相对简单,在本例中,SubmissionControllernewSubmission()方法仅仅将请求代理给应用服务SubmissionCommandService即完成了其自身的使命。这里的NewSubmissionCommand表示命令对象,用于携带请求数据,比如对于“创建Submission”来说,NewSubmissionCommand对象中至少应该包含表单的提交内容等数据。命令对象是外部客户端传入的数据,因此需要将其与领域模型解耦,也即命令对象不能进入到领域模型的内部,其所能到达的最后一站是应用服务。

处理流程的下一站是应用服务,应用服务是整个领域模型的门面,无论什么类型的客户端,只要业务用例相同,那么所调用的应用服务的方法也应相同,也即应用服务和技术设施也是解耦的。

//SubmissionCommandService

@Transactional
public String newSubmission(NewSubmissionCommand command, User user) {
    AppedQr appedQr = qrRepository.appedQrById(command.getQrId());
    App app = appedQr.getApp();
    QR qr = appedQr.getQr();

    Page page = app.pageById(command.getPageId());
    SubmissionPermissions permissions = permissionChecker.permissionsFor(user, appedQr);
    permissions.checkPermissions(app.requiredPermission(), page.requiredPermission());

    Set<Answer> answers = command.getAnswers();
    Submission submission = submissionFactory.createNewSubmission(
            answers,
            qr,
            page,
            app,
            permissions.getPermissions(),
            command.getReferenceData(),
            user
    );

    submissionRepository.houseKeepSave(submission, app);
    log.info("Created submission[{}].", submission.getId());

    return submission.getId();
}

源码出处:com/mryqr/core/submission/command/SubmissionCommandService.java

在以上的SubmissionCommandService应用服务中,首先做权限检查,然后调用工厂SubmissionFactory.createNewSubmission()完成Submission的创建,最后调用资源库SubmissionRepository.houseKeepSave()将新建的Submission持久化到数据库中。从中可见,应用服务主要用于协调各方以完成一个业务用例,其本身并不包含业务逻辑,业务逻辑在工厂中完成。

//SubmissionFactory

public Submission createNewSubmission(Set<Answer> answers,
                                      QR qr,
                                      Page page,
                                      App app,
                                      Set<Permission> permissions,
                                      String referenceData,
                                      User user) {
    if (page.isOncePerInstanceSubmitType()) {
        submissionRepository.lastInstanceSubmission(qr.getId(), page.getId())
                .ifPresent(submission -> {
                    throw new MryException(SUBMISSION_ALREADY_EXISTS_FOR_INSTANCE,
                            "当前页面不支持重复提交,请尝试更新已有表单。",
                            mapOf("qrId", qr.getId(),
                                    "pageId", page.getId()));
                });
    }

    //...此处忽略更多业务逻辑

    //只有需要登录的页面才记录user
    User finalUser = page.requireLogin() ? user : ANONYMOUS_USER;
    Map<String, Answer> checkedAnswers = submissionDomainService.checkAnswers(answers,
            qr,
            page,
            app,
            permissions);

    return new Submission(checkedAnswers,
            page.getId(),
            qr, app,
            referenceData,
            finalUser);
}

源码出处:com/mryqr/core/submission/domain/SubmissionFactory.java

虽然工厂用于创建聚合根,但并不是直接调用聚合根的构造函数那么简单,从SubmissionFactory.createNewSubmission()可以看出,在创建Submission之前,需要根据表单类型检查是否可以创建新的Submission,而这正是业务逻辑的一部分。因此,工厂也属于领域模型的一部分,本质上工厂可以认为是一种特殊形式的领域服务。

请求流程的最后,应用服务调用资源库submissionRepository.houseKeepSave()完成对新建Submission的持久化。更多关于资源库的内容,请参考聚合根与资源库一文。

聚合根更新流程 #

对聚合根的更新流程通常可以通过“经典三部曲”完成:

  1. 调用资源库获得聚合根
  2. 调用聚合根上的业务方法,完成对聚合根的更新
  3. 再次调用资源库保存聚合根

此时的请求流经路线为:控制器(Controller) -> 应用服务(Application Service) -> 资源库(Repository) -> 聚合根(Aggregate Root)。

码如云中,当表单开启了审批功能过后,管理员可对Submission进行审批操作,本质上则是在更新Submission。在“审批Submission”的过程中,请求依然是首先到达Controller:

//SubmissionController

@ResponseStatus(CREATED)
@PostMapping(value = "/{submissionId}/approval")
public ReturnId approveSubmission(@PathVariable("submissionId") @SubmissionId @NotBlank String submissionId,
                                  @RequestBody @Valid ApproveSubmissionCommand command,
                                  @AuthenticationPrincipal User user) {
    submissionCommandService.approveSubmission(submissionId, command, user);
    return returnId(submissionId);
}

源码出处:com/mryqr/core/submission/SubmissionController.java

与“创建聚合根”相似,SubmissionController直接将请求代理给应用服务SubmissionCommandService.approveSubmission()

//SubmissionCommandService

@Transactional
public void approveSubmission(String submissionId,
                              ApproveSubmissionCommand command,
                              User user) {
    Submission submission = submissionRepository.byIdAndCheckTenantShip(submissionId, user);

    App app = appRepository.cachedById(submission.getAppId());
    Page page = app.pageById(submission.getPageId());
    SubmissionPermissions permissions = permissionChecker.permissionsFor(user,
            app,
            submission.getGroupId());
    permissions.checkCanApproveSubmission(submission, page, app);

    submission.approve(command.isPassed(),
            command.getNote(),
            page,
            user);

    submissionRepository.houseKeepSave(submission, app);

    log.info("Approved submission[{}].", submissionId);
}

源码出处:com/mryqr/core/submission/command/SubmissionCommandService.java

应用服务SubmissionCommandService先通过资源库SubmissionRepositorybyIdAndCheckTenantShip()方法获取到需要操作的Submission,然后进行权限检查,再调用Submission.approve()方法完成对Submission的更新,最后调用资源库SubmissionRepositoryhouseKeepSave()方法将更新后的Submission保存到数据库。这里的重点在于:需要保证所有的业务逻辑均放在Submission.approve()中:

//Submission

public void approve(boolean passed,
                    String note,
                    Page page,
                    User user) {

    if (isApproved()) {
        throw new MryException(SUBMISSION_ALREADY_APPROVED,
                "无法完成审批,先前已经完成审批。",
                "submissionId", this.getId());
    }

    this.approval = SubmissionApproval.builder()
            .passed(passed)
            .note(note)
            .approvedAt(now())
            .approvedBy(user.getMemberId())
            .build();

    raiseEvent(new SubmissionApprovedEvent(this.getId(),
            this.getQrId(),
            this.getAppId(),
            this.getPageId(),
            this.approval,
            user));

    addOpsLog(passed ?
            "审批" + page.approvalPassText() :
            "审批" + page.approvalNotPassText(), user);
}

源码出处:com/mryqr/core/submission/domain/Submission.java

可以看到,Submission.approve()先检查Submission是否已经被审批过了,如果尚未审批才继续审批操作,审批过程还会发出“提交已审批”(SubmissionApprovedEvent)领域事件(更多关于领域事件的内容,请参考本系列的领域事件一文)。Submission.approve()中的代码量虽然不多,但是却体现了核心的业务逻辑:“已经完成审批的提交不能再次审批”。

当然,并不是所有的业务用例都适合“经典三部曲”,有时聚合根自身无法完成所有的业务逻辑,此时我们则需要借助领域服务(Domain Service)来完成请求的处理。比如,常见的使用领域服务的场景是需要进行跨聚合查询的时候。此时的请求流经路线则为:控制器(Controller) -> 应用服务(Application Service) -> 资源库(Repository) -> 聚合根(Aggregate Root) ->领域服务(Domain Service)。

在码如云中,管理员可以对既有的Submission进行编辑更新,但是由于更新时可能涉及到检查手机号或者邮箱等控件填值的唯一性,因此在更新时需要跨Submission进行查询,此时光靠Submission自身便无法完成了,为此我们可以创建领域服务SubmissionDomainService用于跨Submission操作:

//SubmissionCommandService

@Transactional
public void updateSubmission(String submissionId,
                             UpdateSubmissionCommand command,
                             User user) {

    Submission submission = submissionRepository.byIdAndCheckTenantShip(submissionId, user);
    AppedQr appedQr = qrRepository.appedQrById(submission.getQrId());
    App app = appedQr.getApp();
    QR qr = appedQr.getQr();

    Page page = app.pageById(submission.getPageId());
    SubmissionPermissions permissions = submissionPermissionChecker.permissionsFor(user,
            app,
            submission.getGroupId());
    permissions.checkCanUpdateSubmission(submission, page, app);

    submissionDomainService.updateSubmission(submission,
            app,
            page,
            qr,
            command.getAnswers(),
            permissions.getPermissions(),
            user
    );

    submissionRepository.houseKeepSave(submission, app);
    log.info("Updated submission[{}].", submissionId);
}

源码出处:com/mryqr/core/submission/command/SubmissionCommandService.java

在本例中,应用服务SubmissionCommandService并未直接调用聚合根Submission中的方法,而是将Submission作为参数传入了领域服务SubmissionDomainServiceupdateSubmission()方法中,在SubmissionDomainService完成了对Submission的更新后,SubmissionCommandService再调用SubmissionRepository.houseKeepSave()方法将Submission保存到数据库中。SubmissionDomainService.updateSubmission()实现如下:

//SubmissionDomainService
    
public void updateSubmission(Submission submission,
                             App app,
                             Page page,
                             QR qr,
                             Set<Answer> answers,
                             Set<Permission> permissions,
                             User user) {

    Map<String, Answer> checkedAnswers = checkAnswers(answers,
            qr,
            page,
            app,
            submission.getId(),
            permissions);

    Set<String> submittedControlIds = answers.stream()
            .map(Answer::getControlId)
            .collect(toImmutableSet());

    submission.update(submittedControlIds, checkedAnswers, user);
}

源码出处:com/mryqr/core/submission/domain/answer/SubmissionDomainService.java

可以看到,SubmissionDomainService.updateSubmission()首先调用业务方法checkAnswers()对表单内容进行检查(其中便包含上文提到的对手机号或邮箱的重复性检查),再调用Submission.update()以完成对Submission的更新,相当于SubmissionDomainServiceSubmission做了业务上的加工。

这里,领域服务SubmissionDomainService的职责范围仅包含对聚合根Submission的更新,并不负责持久化Submission,持久化的职责依然在应用服务SubmissionCommandService上。这种方式的好处在于:(1)与“经典三部曲”保持一致,将所有持久化操作均集中到应用服务中,不至于过于分散;(2)使领域服务的职责尽量单一。

聚合根删除流程 #

聚合根删除流程相对简单,此时的请求流经路线为:控制器(Controller) -> 应用服务(Application Service) -> 资源库(Application Service) -> 聚合根(Aggregate Root) 。

删除请求首先到达Controller:

//SubmissionController

@DeleteMapping(value = "/{submissionId}")
public ReturnId deleteSubmission(@PathVariable("submissionId") @SubmissionId @NotBlank String submissionId,
                                 @AuthenticationPrincipal User user) {
    submissionCommandService.deleteSubmission(submissionId, user);
    return returnId(submissionId);
}

源码出处:com/mryqr/core/submission/SubmissionController.java

Controller将请求进一步代理给应用服务SubmissionCommandService

//SubmissionCommandService

@Transactional
public void deleteSubmission(String submissionId, User user) {
    Submission submission = submissionRepository.byIdAndCheckTenantShip(submissionId, user);
    Group group = groupRepository.cachedById(submission.getGroupId());
    managePermissionChecker.checkCanManageGroup(user, group);

    submission.onDelete(user);
    submissionRepository.delete(submission);
    log.info("Deleted submission[{}].", submissionId);
}

源码出处:com/mryqr/core/submission/command/SubmissionCommandService.java

应用服务SubmissionCommandService通过SubmissionRepository加载出需要删除的Submission后,再调用Submission.onDelete()以完成删除前的一些操作,在本例中onDelete()将发出“提交已删除”(SubmissionDeletedEvent)领域事件:

//Submission
    
public void onDelete(User user) {
    raiseEvent(new SubmissionDeletedEvent(this.getId(),
            this.getQrId(),
            this.getAppId(),
            this.getPageId(),
            user));
}

源码出处:com/mryqr/core/submission/domain/Submission.java

最后,应用服务SubmissionCommandService调用SubmissionRepository.delete()完成对聚合根的删除操作。

查询流程 #

在本系列的CQRS一文中,我们将专门讲到在DDD中如何做查询操作。

总结 #

在本文中,我们分别对聚合根的新建、更新和删除的典型请求处理流程做了详细介绍。在这些流程中,我们以聚合根为中心,围绕之形成了恰如其分的软件架构。在下一篇聚合根与资源库中,我们将对聚合根本身的设计与实现做详细讲解。

产品代码都给你看了,可别再说不会DDD(四):代码工程结构 | 码如云文档中心

mikel阅读(315)

来源: 产品代码都给你看了,可别再说不会DDD(四):代码工程结构 | 码如云文档中心

这是一个讲解DDD落地的文章系列,作者是《实现领域驱动设计》的译者滕云。本文章系列以一个真实的并已成功上线的软件项目——码如云https://www.mryqr.com)为例,系统性地讲解DDD在落地实施过程中的各种典型实践,以及在面临实际业务场景时的诸多取舍。

本系列包含以下文章:

  1. DDD入门
  2. DDD概念大白话
  3. 战略设计
  4. 代码工程结构(本文)
  5. 请求处理流程
  6. 聚合根与资源库
  7. 实体与值对象
  8. 应用服务与领域服务
  9. 领域事件
  10. CQRS

案例项目介绍 #

既然DDD是“领域”驱动,那么我们便不能抛开业务而只讲技术,为此让我们先从业务上了解一下贯穿本文章系列的案例项目 —— 码如云(不是马云,也不是码云)。如你已经在本系列的其他文章中了解过该案例,可跳过。

码如云是一个基于二维码的一物一码管理平台,可以为每一件“物品”生成一个二维码,并以该二维码为入口展开对“物品”的相关操作,典型的应用场景包括固定资产管理、设备巡检以及物品标签等。

在使用码如云时,首先需要创建一个应用(App),一个应用包含了多个页面(Page),也可称为表单,一个页面又可以包含多个控件(Control),比如单选框控件。应用创建好后,可在应用下创建多个实例(QR)用于表示被管理的对象(比如机器设备)。每个实例均对应一个二维码,手机扫码便可对实例进行相应操作,比如查看实例相关信息或者填写页面表单等,对表单的一次填写称为提交(Submission);更多概念请参考码如云术语

在技术上,码如云是一个无代码平台,包含了表单引擎、审批流程和数据报表等多个功能模块。码如云全程采用DDD完成开发,其后端技术栈主要有Java、Spring Boot和MongoDB等。

码如云的源代码是开源的,可以通过以下方式访问:

码如云源代码:https://github.com/mryqr-com/mry-backend

代码工程结构 #

码如云,我们经常会受邀去给其他公司或组织分享DDD的落地实践经验,分享期间听众一般会问很多问题,被问得最多的反倒不是限界上下文如何划分,聚合如何设计等DDD重点议题,而是DDD工程结构该怎么搭,包该怎么分这些实实在在的问题。

事实上,DDD并未对工程结构做出要求,在码如云,我们结合行业通用实践以及自身对DDD的认识搭建出了一套适合于自身的工程结构,我们认为对于多数项目也是适用的,在本文中,我们将对此做详细讲解。

在上一篇战略设计中我们提到,码如云是一个单体项目,其通过Java分包的方式划分出了3个限界上下文,即3个模块。对于正在搞微服务的读者来说,可不要被“单体”二字吓跑了,本文所讲解的绝大多数内容既适合于单体,也适合于微服务。

以上是码如云工程的目录结构,在根分包src/main/java/com/mryqr下,分出了coreintegrationmanagement3个模块包,分别对应“核心上下文”、“集成上下文”和“后台管理上下文”,对于微服务系统来说,这3个分包则不存在,因为每个分包都有自己单独的微服务项目,也即DDD的限界上下文和微服务存在一一对应的关系。与这3个模块包同级的还有一个common包,该包并不是一个业务模块,而是所有模块所共享的一些基础设施,比如Spring的配置、邮件发送机制等。在src目录下,还包含testapiTestgatling三个目录,分别对应单元测试,API测试和性能测试代码。此外,deploy目录用于存放与部署相关的文件,doc目录用于存放项目文档,gradle目录则用于存放各种Gradle配置文件。

分包原则:先业务,后技术 #

在以上提及的各种模块包中,程序员们最为关注的估计是core包之下应该如何进一步分包了,因为core是整个项目的核心业务模块。

在做分包时,一个最常见的反模式是将技术分包作为上层分包,然后在各技术分包下再划分业务包。DDD社区更加推崇的分包方式是“先业务,后技术”,即上层包先按照业务进行划分,然后在各个业务包内部可以再按照技术分包。

在码如云的core模块包中,首先是基于业务的分包,包含app、 assignment等几十个包,其中的app对应于应用聚合根,而assignment对应于任务聚合根,也即每一个业务分包对应一个聚合根。在每个业务分包下再做技术分包,其中包含以下子分包:

  • command:用于存放应用服务以及命令对象等,更多相关内容请参考应用服务与领域服务
  • domain:用于存放所有领域模型,更多相关内容请参考聚合根与资源库
  • eventhandler:用于存放领域事件处理器,更多相关内容请参考领域事件;
  • infrastructure:用于存放技术基础设施,比如对数据库的访问实现等;
  • query:用于存放查询逻辑,更多相关内容请参考CQRS

在这些分包下,可以根据实际情况进一步分包。

这种“先业务,后技术”的分包方式有以下好处:

  • 业务直观:所有的业务模块被放在一起,并且处于一个分包级别中,让人一眼即可全景式地了解一个软件项目中的所有业务。事实上,Robert C. Martin(Bob大叔)提出了一个概念叫尖叫架构(Screaming Architecture)讲的就是这个意思。尖叫即“哇的一声”的意思,比如当你看到一栋房子时,你会说“哇,好一栋漂亮的房子!”,也即你一眼就能识别出这是一套房子。
  • 便于导航:当你要查找一个功能时,你首先想到的一定是该功能属于哪个业务板块,而不是属于哪个Controller,因此你可以先找到业务分包,然后顺藤摸瓜找到相应的功能代码。
  • 便于迁移:每一个业务包都包含了从业务到技术的所有代码,因此在迁移时只需整体挪动业务包即可,比如,如果码如云以后要迁移到微服务架构,那么只需将需要迁出的业务包整体拷贝到新的工程中即可。

在以上子分包中,domain分包应是最大的一个分包,因为其中包含了所有的领域模型以及业务逻辑。在码如云项目的app业务包下,各个子分包所包含的代码量统计如下:

可以看到,domain包中所包含的代码量远远超过其他所有分包的总和。当然,我们并不是说所有DDD项目都需要满足这一点,而是强调在DDD中领域模型应该是代码的主体。

接下来,让我们来看看各个子分包中都包含哪些内容,首先来看domain分包:

domain分包中,最重要的当属App聚合根了,除此之外还包含领域服务AppDomainService,工厂AppFactory和资源库AppRepository。这里的AppRepository是一个接口,其实现在infrastructure分包中。基于内聚原则,有些密切联系的类被放置在了下一级子分包中,比如attributepage分包等。值得一提的是,用于存放领域事件的event包也被放置在了domain下,因为领域事件也是领域模型的一部分,不过领域事件的处理器类则放在了与domain同级的eventhandler包中,我们将在 领域事件中对此做详细讲解。

command包用于放置应用服务以及请求数据类,这里的“command”即CQRS中的“C”,表示外界向软件系统所发起的一次命令。

command包中,应用服务AppCommandService用于接收外界的业务请求(命令)。AppCommandService接收的输入参数为Command对象(以“Command”为后缀),Command对象通过其名称表达业务意图,比如CopyAppCommand用于拷贝应用(这里的“应用”表示业务上的应用聚合根),CreateAppCommand用于新建应用

eventhandler用于存放领域事件的处理器类,这些类的地位相当于应用服务,它们并不是领域模型的一部分,只是与应用服务相似起编排协调作用。

infrastructure用于存放基础设施类,主要包含资源库的实现类:

query用于存放与数据查询相关的类,这里的”query”也即CQRS中的“Q”,我们将在本系列的CQRS中对此做详细讲解。

自动化测试 #

自动化测试包含单元测试、API测试和性能测试。在API测试中,数据库和消息队列等基础设施均通过本地Docker完成搭建,测试时先启动整个Spring进程,然后模拟前端向各个API发送真实业务请求,最后验证返回结果,如果遇到有需访问第三方系统的情况,则通过Stub类进行代替。码如云采用的是“API测试为主,单元测试为辅”的测试策略,其API测试覆盖率达到了90%,所有的业务用例和重要分支都有API测试覆盖,单元测试主要用于测试领域模型,对于诸如应用服务、Controller以及事件处理器等结构性设施则不作单元测试要求,因为这些类并不包含太多逻辑,对这些类的测试可以消化在API测试中。

总结 #

本文主要讲解了DDD代码工程的典型目录结构,我们推荐通过“先业务,后技术”的方式进行分包,这样使得项目所体现的业务更加的直观。此外,在DDD项目中,领域模型应该是整个项目的主体,所有的领域对象和业务逻辑均应该包含在domain包下。在下文请求处理流程中,我们将对DDD项目中请求处理的全流程进行详细讲解。

产品代码都给你看了,可别再说不会DDD(三):战略设计 | 码如云文档中心

mikel阅读(328)

来源: 产品代码都给你看了,可别再说不会DDD(三):战略设计 | 码如云文档中心

这是一个讲解DDD落地的文章系列,作者是《实现领域驱动设计》的译者滕云。本文章系列以一个真实的并已成功上线的软件项目——码如云https://www.mryqr.com)为例,系统性地讲解DDD在落地实施过程中的各种典型实践,以及在面临实际业务场景时的诸多取舍。

本系列包含以下文章:

  1. DDD入门
  2. DDD概念大白话
  3. 战略设计(本文)
  4. 代码工程结构
  5. 请求处理流程
  6. 聚合根与资源库
  7. 实体与值对象
  8. 应用服务与领域服务
  9. 领域事件
  10. CQRS

案例项目介绍 #

既然DDD是“领域”驱动,那么我们便不能抛开业务而只讲技术,为此让我们先从业务上了解一下贯穿本文章系列的案例项目 —— 码如云(不是马云,也不是码云)。如你已经在本系列的其他文章中了解过该案例,可跳过。

码如云是一个基于二维码的一物一码管理平台,可以为每一件“物品”生成一个二维码,并以该二维码为入口展开对“物品”的相关操作,典型的应用场景包括固定资产管理、设备巡检以及物品标签等。

在使用码如云时,首先需要创建一个应用(App),一个应用包含了多个页面(Page),也可称为表单,一个页面又可以包含多个控件(Control),比如单选框控件。应用创建好后,可在应用下创建多个实例(QR)用于表示被管理的对象(比如机器设备)。每个实例均对应一个二维码,手机扫码便可对实例进行相应操作,比如查看实例相关信息或者填写页面表单等,对表单的一次填写称为提交(Submission);更多概念请参考码如云术语

在技术上,码如云是一个无代码平台,包含了表单引擎、审批流程和数据报表等多个功能模块。码如云全程采用DDD完成开发,其后端技术栈主要有Java、Spring Boot和MongoDB等。

码如云的源代码是开源的,可以通过以下方式访问:

码如云源代码:https://github.com/mryqr-com/mry-backend

战略设计 #

在上一篇DDD概念大白话中,我们提出了一个观点:DDD的战略设计只在解决一个问题,即软件的模块化划分的问题。在本文中,我们将对此做出详细的解释。

不过,首先让我们来看看DDD的战略设计原本包含哪些内容。战略设计中包含领域、子域、通用语言和限界上下文等概念。领域(Domain)表示一个行业中所发生的一切业务;子域(Subdomain)则表示领域中细分之后的子业务,是比领域更小的概念,子域又可细分为核心子域(Core Domain)、支撑子域(Supporting Domain)和通用子域(Generic Domain);通用语言(Ubiquitous Language)表示在领域中所有人员都使用一套相同的语言进行沟通交流;限界上下文(Bounded Context)则表示由通用语言所形成的上下文边界。读到这里,你是不是感觉好像什么都说了,又感觉什么都没说?

事实上不难看出,无论是从领域到子域,还是从通用语言到限界上下文,其中都体现了一种“分”的思想,这种思想也正是整个计算机科学中的一种基本思想——分治法(devide and conquer)。作为顶层设计的DDD战略设计来讲,这种“分”的思想的落地不正是我们在软件架构图中所看到的那些方块么?不正是软件的模块化划分么?

有了以上认识,再让我们来重新审视一下战略设计中的各种概念。软件中有些模块是业务的核心,对应着DDD中的“核心域”的概念,比如电商系统中的订单模块;有些模块对核心模块起支撑作用,对应DDD中“支撑域”,比如电商系统中的积分模块;而有些模块是通用性质的,对应着DDD中的“通用域”,比如登录管理模块。限界上下文可以看做是子域落地后的概念,因此通常与子域存在一一对应的关系,也即一个限界上下文表示一个模块。限界上下文可以这么理解:在DDD中,允许在不同的模块中存在相同名称的对象,但是它们在各自的上下文中所表示的含义是不同的,这也是“限界”一词的由来。举个例子,在电商系统中,存在交易模块和物流模块,它们都包含“订单(Order)”对象,但是交易模块中的订单和物流模块中的订单所承载的业务含义是不一样的,在交易模块中我们更专注订单的价格、数量和折扣等,而在物流模块中我们则更关注订单的重量、体积和物流状态等。

说DDD的战略设计只是模块化划分并不是要贬低战略设计的意思,事实上恰恰相反,战略设计很重要。DDD的开山鼻祖Eric Evans曾经说,如果让他重新撰写《领域驱动设计》那本书,他会将原书中的很大部分全部撕掉,然后用于撰写与限界上下文相关的内容,从此也可见战略设计的重要性。但是,我们希望做的是让读者认清其中的本质,毕竟DDD本身是一种实践性很强的学问,我们对DDD的认识不应该停留在对概念的咬文爵字上,而是真正能够产出高质量的软件。

事实上,软件的模块化划分是一个非常古老的概念,它伴随着软件的诞生而诞生,其萌芽至少可以追溯到世界上第一台通用电子计算机ENIAC的发明者之一约翰·皮斯普·埃克特(J. Presper Eckert)在一篇研究穿孔纸带的论文中所提到的“Decomposition(分解)”。

后来,软件的模块化经道格拉斯·麦克罗伊(Douglas McIlroy)和布莱德·考克斯(Brad Cox,Objective-C发明人)等人得到了进一步发展。如果我们再将眼光放开阔一些,你会发现模块化的思想存在于各个行业中,比如船舶、桥梁、建筑以及航空等领域。

因此,模块化对于接受了现代工业文明洗礼的我们来说,并不是一个陌生的词汇。然而,难点并不在于如何定义模块,而在于如何划分模块。在DDD中,这是一个见仁见智众说纷纭的话题,为此,让我们从一个小故事展开。

一个2岁的幼儿,从来没有看到人的头像简易画(下图中左边的图片),但是当你问他那是什么的时候,他可能会说“人人”。这是为什么?

幼儿能够辨认出他从来没有看到过的东西,是因为他拥有两种能力:经验抽象。他虽然没有看到过人头像简易画,但是他之前一定看到过真实的人,此所谓经验,也即我们过去所经历的事情;而他能够将人像简易画和真实的人对等起来,则是因为人类与身俱来的抽象能力。此二者,恰恰是我们划分软件模块所需要的东西,并且人人皆有。因此,你并不需要一套专门的学问来指导你完成DDD的战略设计,你需要的依然是那些在日常工作生活中我们始终在使用着的技能。

但是,经验有多有少,抽象有深有浅,导致不同的人所划分出来的模块形态也不一样。为了做好DDD战略设计,你需要有充足的经验以及对业务的深入了解。那么,经验到底到底多少算多呢?5年工作经验够不够?10年又够不够?这种按照年限来区分经验多寡的方式是不合适的,一个10年工作经验的架构师,他可能在这10年内一直在重复性地做着一件事情,而一个3年工作经验的程序员,却可能已经经历过很多项目、技术以及行业。因此,经验是根据你在自己所处的行业中所耕耘的深度和广度来计算的,而非时间。

你可能会认为经验这个东西太不可名状了,无法提炼出一套有据可循理论框架出来,的确没有。然而,这正是软件被称之为艺术的原因,它让每个人都有属于其自己的发挥空间,况且还有大哲学家和大科学家为你背书,你还那么不自信到要去追求一个咨询师没把你教会而你自己也没学会的所谓的理论框架吗?坦诚点,自信点,自豪地告诉别人:“我通过自己对业务的深入了解,外加自己的从业经验和抽象能力,搞定了DDD的战略设计!”

让我们来看个例子吧,搜索功能是多数应用网站都有的功能,在一些应用中,搜索可以简单到只是做个正则匹配的小功能点,此时的搜索固然称不上一个模块,而在另一些应用中,比如大型电商网站,搜索包含了多条件多方式查询等众多内容,其后台的软件架构和技术栈甚至都是专门设计的,此时的搜索功能你哪怕找一个毕业生来设计估计结果都是一个独立模块。这里,从功能点到功能模块的变化过程中,没有什么量化工具和理论框架可言,说得直白点,这就是架构师的一个主观认知而已。但是,这个认知却是重要的,因为它体现了架构师对于一个问题的抽象能力,以及对于软件边界的识别能力。什么是架构呢,一种解释是软件架构是项目中的资深程序员们对某个问题所达成的统一认识而已。

理论框架虽然没有,但是指导性的原则还是有的,以下原则是程序员们耳熟能详的编程原则,将其用在模块化划分上依然成立:

  • 高内聚,低耦合原则
  • 关注点分离原则
  • 单一职责原则

说到DDD,我们可能不得不说一下微服务,因为一般的理解是DDD因为微服务的兴起而重新被业界重视。事实上,DDD和微服务的关系被牵强式地放大了,DDD之于微服务,无外乎“DDD的限界上下文可以用于指导微服务的划分”,然而,在我们把限界上下文理解为模块后,这种说法也就不值得再成为一个单独的命题了。DDD的意义在于“DDD之于软件”,而不是“DDD之于微服务”。

码如云,我们采用了单体架构而非微服务,但这并不影响我们划分限界上下文(模块),我们通过Java分包的方式来划分模块。

码如云的模块关系并不复杂,仅包含3个顶层模块,一个是核心上下文(模块),其中包含各种核心的业务实体,比如应用和实例等,每个业务实体均被建模为一个聚合根;第二个是后台管理上下文(模块),用于码如云的后台运营,包含客户关系、投诉管理和订单管理等;第三个是集成上下文(模块),用于处理与第三方的API集成。在前文中我们提到,登录功能可以被看做是通用子域而建模为一个独立的模块,但是在码如云中我们并未这么划分,而是将登录功能消化在了核心上下文中,因为其粒度尚未大到需要独立为一个模块的程度。

总结 #

很多简单的东西被人为的复杂化了,当资深人士们还在高谈阔论微服务和SOA的区别时,Robert C. Martin(Bob大叔)站出来说,这俩就是一个东西。当下的DDD同样也在遭受着“被复杂化”的境遇,软件(至少企业级应用软件)向来的实践性是非常强的,结果从业者们自己把自己搞不会了,实则不应该呀!在本文中,我们说DDD的战略设计只是在解决软件的模块化划分的问题,并不是要贬低战略设计,而是希望读者看到战略设计的本质,进而从DDD中得到实实在在的好处,也推动这个行业可以健康地,不要那么浮夸式地发展。

产品代码都给你看了,可别再说不会DDD(二):DDD概念大白话 | 码如云文档中心

mikel阅读(316)

来源: 产品代码都给你看了,可别再说不会DDD(二):DDD概念大白话 | 码如云文档中心

这是一个讲解DDD落地的文章系列,作者是《实现领域驱动设计》的译者滕云。本文章系列以一个真实的并已成功上线的软件项目——码如云https://www.mryqr.com)为例,系统性地讲解DDD在落地实施过程中的各种典型实践,以及在面临实际业务场景时的诸多取舍。

本系列包含以下文章:

  1. DDD入门
  2. DDD概念大白话(本文)
  3. 战略设计
  4. 代码工程结构
  5. 请求处理流程
  6. 聚合根与资源库
  7. 实体与值对象
  8. 应用服务与领域服务
  9. 领域事件
  10. CQRS

案例项目介绍 #

既然DDD是“领域”驱动,那么我们便不能抛开业务而只讲技术,为此让我们先从业务上了解一下贯穿本文章系列的案例项目 —— 码如云(不是马云,也不是码云)。如你已经在本系列的其他文章中了解过该案例,可跳过。

码如云是一个基于二维码的一物一码管理平台,可以为每一件“物品”生成一个二维码,并以该二维码为入口展开对“物品”的相关操作,典型的应用场景包括固定资产管理、设备巡检以及物品标签等。

在使用码如云时,首先需要创建一个应用(App),一个应用包含了多个页面(Page),也可称为表单,一个页面又可以包含多个控件(Control),比如单选框控件。应用创建好后,可在应用下创建多个实例(QR)用于表示被管理的对象(比如机器设备)。每个实例均对应一个二维码,手机扫码便可对实例进行相应操作,比如查看实例相关信息或者填写页面表单等,对表单的一次填写称为提交(Submission);更多概念请参考码如云术语

在技术上,码如云是一个无代码平台,包含了表单引擎、审批流程和数据报表等多个功能模块。码如云全程采用DDD完成开发,其后端技术栈主要有Java、Spring Boot和MongoDB等。

码如云的源代码是开源的,可以通过以下方式访问:

码如云源代码:https://github.com/mryqr-com/mry-backend

DDD概念大白话 #

本文是本系列的第二篇文章,主要解释DDD中的各种概念,一方面让读者对DDD有个全景式的认识,另一方面也方便读者更好地理解本系列的后续文章。

DDD中的概念,说多不多,说少不少,一个新手在面对各种DDD名词的轮番轰炸时可能会被搞得晕头转向不知所措,而对于深谙其道的人来说,DDD也就那么点儿东西。除此之外,不同人对于DDD概念的理解也存在千差万别。本文尝试通过朴素的大白话解释DDD中的各种概念,不装,也不作。

DDD分为战略设计战术设计,战略设计是一种宏观的顶层设计,而战术设计则更偏向于代码落地实践。

战略设计中有通用语言、领域、子域和限界上下文等概念,这些概念不好理解得清楚,也不好讲得清楚。但是,从本质上讲,DDD的战略设计只在解决一个问题,即软件的模块化划分的问题。为此,我们将在下一篇战略设计中进行详细阐述。

当我们把软件的模块划分好(也即完成了战略设计)之后,下一步自然是编码实现了,于是乎我们也就顺理成章地进入了DDD的战术设计范畴。DDD的战术设计包括聚合根、实体和资源库等众多概念,其中最重要的当属聚合根了,那接下来我们就从聚合根讲起。

在上一篇DDD入门中我们提到,DDD的一个重要使命是实现软件中“业务复杂度”和“技术复杂度”的分离。为此,我们使用领域模型来描述业务,使之与数据库、消息队列等技术实现解耦开来。在DDD的领域模型中,最核心的则是聚合根(Aggregate Root),我们甚至可以认为整个DDD都是围绕聚合根的设计与实现展开的。

聚合根中的“聚合”即“高内聚,低耦合”中的“内聚”之意,而“根”则是“根部”的意思。事实上,并不存在一个教科书式的对聚合根的理论定义,你可以将聚合根理解为一个系统中最重要的那些名词。为了给你一个直观的理解,我们先来看看以下聚合根的例子:

  • 在一个电商系统中,一个订单(Order)对象表示一个聚合根
  • 在一个CRM系统中,一个客户(Customer)对象表示一个聚合根
  • 在一个银行系统中,一次交易(Transaction)对象表示一个聚合根

怎么样,是不是至少对聚合根有了一个感性认识?这些名词是其所在的软件系统之所以存在的原因,设想淘宝中没有了订单的概念还能称之为电商系统吗?当然,一个概念是否能成为聚合根是根据其所处的业务场景而定的,我们将在后续文章聚合根和资源库中做详细解释。

聚合根是业务逻辑的主要载体,在理想情况下应该是业务的唯一载体。但是,万事皆有但是,有时将业务逻辑放到聚合根中是不合适的,甚至是不可行的。比如,在码如云中,在更新成员手机号时,需要先行检查该手机号是否已经被占用,此时成员聚合根自身并不具备检查其他成员手机号的功能,因此要将这部分逻辑放到成员本身上则显得不合适了,我们在上一篇DDD入门中也提到了这个例子。不过不要慌,针对这种情形,DDD给出了专门的概念 —— 领域服务(Domain Service)。领域服务是聚合根自身无法完成业务逻辑时的代替品,是不得已而为之的一个概念,通常用于处理一些跨聚合操作或者需要访问技术基础设施的场景。

在下例中,领域服务MemberDomainService中的changeMyMobile()方法实现了两处聚合根(Member)无法自身实现的功能:一是调用mryPasswordEncoder检查密码是否正确,二是调用memberRepository检查手机号是否重复。

    //领域服务:MemberDomainService

    public void changeMyMobile(Member member, String newMobile, String password) {
        if (!mryPasswordEncoder.matches(password, member.getPassword())) {
            throw new MryException(PASSWORD_NOT_MATCH,
                    "修改手机号失败,密码不正确。", "memberId", member.getId());
        }

        if (Objects.equals(member.getMobile(), newMobile)) {
            return;
        }

        if (memberRepository.existsByMobile(newMobile)) {
            throw new MryException(MEMBER_WITH_MOBILE_ALREADY_EXISTS,
                    "修改手机号失败,手机号对应成员已存在。",
                    mapOf("mobile", newMobile,
                            "memberId", member.getId()));
        }

        member.changeMobile(newMobile, member.toUser());
    }

源码出处:com/mryqr/core/member/domain/MemberDomainService.java

需要提醒的是,请不要被领域服务名字中的“服务”迷惑了,领域服务依然是领域模型的一部分,因为它也实现了业务逻辑,更多关于领域服务的内容,请查看本系列的应用服务和领域服务一文。

从更广义上讲,聚合根属于实体的范畴。在DDD中,存在实体(Entity)和值对象(Value Object)是一对相互对立的概念,实体用于表示那些具有生命周期的“存在”,而值对象用于表示那些仅仅起描述性作用的东西。实体通过唯一标识进行标定,而值对象则通过其包含的所有属性进行标定。在编码实现时,最直观的区别则是实体对象有ID,而值对象没有ID;此外,实体对象一般包含比较复杂的业务逻辑,而值对象通常则是一些简单的小对象,业务逻辑相对简单。举个常见的例子,无论一对双胞胎长得多么的相像,但由于两个人的身份证号不同(即ID不同),那么两人便属于不同的实体;而对于货币来说,一张崭新的百元大钞和一张破旧的占满了细菌的百元大钞是可以等价交换的,因为他们所包含的属性值(均是100元)是一样,因此他们均属于值对象。

在码如云中,管理员可以对表单提交(Submission)进行审批(Approval),审批结果存放在SubmissionApproval对象中,该对象则是一个值对象:

@Value
@Builder
@AllArgsConstructor(access = PRIVATE)
public class SubmissionApproval {
    private final boolean passed;//审批是否通过
    private final String note;//审批意见
    private final Instant approvedAt;//审批时间
    private final String approvedBy;//审批人ID
}

源码出处:com/mryqr/core/submission/domain/SubmissionApproval.java

需要注意的是,虽然聚合根属于实体,但是实体却不只是包含聚合根。事实上,聚合根隶属于实体,同时其内部又可以包含其他实体。举个例子,汽车作为聚合根是一个实体,同时汽车内部的发动机也是一个实体,但发动机却不是聚合根。在本系列的实体和值对象中,我们将详细介绍实体和值对象的区别。

有些对象(特别是聚合根)的创建过程本身也是业务逻辑的一部分,在DDD中,为了显式化业务逻辑,也为了遵从关注点分离的原则,我们将这些对象的构建过程封装到工厂(Factory)中,落地时可以是独立的工厂类,也可以是一个对象中的工厂方法。在码如云中,所有的聚合根对象均配备有专门的工厂类,比如用于创建成员的MemberFactory如下:

@Component
@RequiredArgsConstructor
public class MemberFactory {
    private final MemberRepository memberRepository;
    private final DepartmentRepository departmentRepository;

    public Member create(String name,
                         List<String> departmentIds,
                         String mobile,
                         String email,
                         String password,
                         User user) {

        //此处只为展示工厂类,省略了具体实现细节
        return create(name, departmentIds, mobile, email, password, null, user); 
    }
}

源码出处:com/mryqr/core/member/domain/MemberFactory.java

更多关于工厂的讲解,请参考本系列的实体与值对象一文。

在DDD的领域模型中,一个业务操作通常会导致一个结果,这个结果被称为领域事件,即领域模型中已经发生的事情,比如“成员手机号已更新”便是一个领域事件。领域事件通常用于组件之间的因果关系处理,比如当“成员手机号已更新”事件产生后,我们可能会在另一个业务组件中做相应的同步操作,这里的组件粒度可以是聚合根,可以是其他业务模块,还可以是一个独立的第三方系统。

在码如云中,创建成员将产生“成员已创建”事件MemberCreatedEvent

@Getter
@TypeAlias("MEMBER_CREATED_EVENT")
@NoArgsConstructor(access = PRIVATE)
public class MemberCreatedEvent extends DomainEvent {
    private String memberId;

    public MemberCreatedEvent(String memberId, User user) {
        super(MEMBER_CREATED, user);
        this.memberId = memberId;
    }

}

源码出处:com/mryqr/core/member/domain/event/MemberCreatedEvent.java

更过关于领域事件的讲解,请参考本系列的领域事件一文。

以上的聚合根、实体、值对象、工厂、领域服务和领域事件都是针对领域模型而言的,虽然在DDD中领域模型是当之无愧的大哥大,但是在实际的软件系统中,单单有领域模型是无法正常运作的,还需要有围绕着领域模型的其他周边设施,为此DDD给出了资源库和应用服务等概念。

简单地讲,资源库(Repository)是用于保存/获取聚合根的。在此之前你可能了解过DAO对象也是用于存储对象的,但是与DAO不同的是,资源库操作的基本单位是聚合根,也即只有聚合根对象才配得上拥有资源库,其他实体对象则没有。

在码如云中,成员对象对应的资源库MemberRepository接口定义如下:

public interface MemberRepository {

    Member byId(String id);

    boolean exists(String arId);

    void save(Member member);

    void delete(Member member);

    //...此处省略其他方法
}

源码出处:com/mryqr/core/member/domain/MemberRepository.java

一般来说,资源库分为接口类和实现类,接口类属于领域模型,实现类属于基础设施。这样的好处是,领域模型只依赖于接口,而不依赖于基础设施,有利于维持领域模型的技术中立性。更过关于资源库的讲解,请查看本系列的聚合根和资源库一文。

领域模型是用来完成业务功能的,也即需要响应用户发起的各种请求,但是在软件系统中在这些请求到达领域模型之前,事实上还有很多事情需要处理,比如需要从数据库中加载数据(聚合根)、处理事务、权限管控等,在DDD中,这些操作由应用服务(Application Service)完成。应用服务可以看做是领域模型的门面,它将接收到请求派发给合适的领域模型去处理,在整个过程中,应用服务充当的是协调者和编排者的角色,就像酒店的前台一样。

在码如云中,每一个聚合根都有对应的应用服务,比如对于成员来说,应用服务MemberCommandService如下:

//由于应用服务的"Application"与码如云中的应用聚合根"App"重名,在码如云中,使用“CommandService”来表示应用服务,以示区分

@Slf4j
@Component
@RequiredArgsConstructor
public class MemberCommandService {
    
    //......

    @Transactional
    public void changeMyMobile(ChangeMyMobileCommand command, User user) {
        mryRateLimiter.applyFor(user.getTenantId(), "Member:ChangeMyMobile", 5);

        String mobile = command.getMobile();
        verificationCodeChecker.check(mobile, command.getVerification(), CHANGE_MOBILE);

        Member member = memberRepository.byId(user.getMemberId());
        memberDomainService.changeMyMobile(member, mobile, command.getPassword());
        memberRepository.save(member);
        log.info("Mobile changed by member[{}].", member.getId());
    }

    //......
}

源码出处:com/mryqr/core/member/command/MemberCommandService.java

在本系列的应用服务与领域服务一文中,我们将对应用服务做详细讲解。

总结 #

以上,我们概览式地了解了DDD战略设计和战术设计中的各种主要概念,在本系列的后续文章中,我们将针对这些概念进行逐一讲解。

产品代码都给你看了,可别再说不会DDD(一):DDD入门 | 码如云文档中心

mikel阅读(303)

来源: 产品代码都给你看了,可别再说不会DDD(一):DDD入门 | 码如云文档中心

这是一个讲解DDD落地的文章系列,作者是《实现领域驱动设计》的译者滕云。本文章系列以一个真实的并已成功上线的软件项目——码如云https://www.mryqr.com)为例,系统性地讲解DDD在落地实施过程中的各种典型实践,以及在面临实际业务场景时的诸多取舍。

本系列包含以下文章:

  1. DDD入门(本文)
  2. DDD概念大白话
  3. 战略设计
  4. 代码工程结构
  5. 请求处理流程
  6. 聚合根与资源库
  7. 实体与值对象
  8. 应用服务与领域服务
  9. 领域事件
  10. CQRS

案例项目介绍 #

既然DDD是“领域”驱动,那么我们便不能抛开业务而只讲技术,为此让我们先从业务上了解一下贯穿本文章系列的案例项目 —— 码如云(不是马云,也不是码云)。如你已经在本系列的其他文章中了解过该案例,可跳过。

码如云是一个基于二维码的一物一码管理平台,可以为每一件“物品”生成一个二维码,并以该二维码为入口展开对“物品”的相关操作,典型的应用场景包括固定资产管理、设备巡检以及物品标签等。

在使用码如云时,首先需要创建一个应用(App),一个应用包含了多个页面(Page),也可称为表单,一个页面又可以包含多个控件(Control),比如单选框控件。应用创建好后,可在应用下创建多个实例(QR)用于表示被管理的对象(比如机器设备)。每个实例均对应一个二维码,手机扫码便可对实例进行相应操作,比如查看实例相关信息或者填写页面表单等,对表单的一次填写称为提交(Submission);更多概念请参考码如云术语

在技术上,码如云是一个无代码平台,包含了表单引擎、审批流程和数据报表等多个功能模块。码如云全程采用DDD完成开发,其后端技术栈主要有Java、Spring Boot和MongoDB等。

码如云的源代码是开源的,可以通过以下方式访问:

码如云源代码:https://github.com/mryqr-com/mry-backend

DDD入门 #

本文是本系列的第一篇文章,主要讲解DDD入门知识,如果你已经对DDD有所了解,可跳过本文。

在阅读本文之前,你可能会认为DDD是整天做PPT的架构师们才应该去关注的东西;或者会认为DDD是比较顶层的东西,跟我写代码的程序员关系不大;你可能还会认为DDD是一种被咨询师们吹得天花乱坠但是却无法落地的概念炒作而已。在日常实践中,我接触过不懂装懂的言必称DDD者,也见识过声称DDD与编码毫无关系的虚无主义者,当然也接触过真正能将DDD落地者。在本系列文章中,我将向你证明,DDD正是软件工程师的工具,可以用于编写更好的代码,设计更好的架构,进而做出更好的软件。当然,我也会针对DDD中被夸大其词的那部分进行澄清,甚至批评。

DDD是什么呢?是架构思想?是方法论?还是软件之道?从某种层度上说这些都对,但是对于程序员或者架构师来讲,最接地气的回答应该是:DDD是面向对象进阶。对于写了几年代码希望在职业生涯中更上一层楼的程序员来说,学习DDD是再适合不过的了。为了能让DDD新手们更快地上手,我们还是以代码为入口展开讲解,首先让我们来看看DDD项目代码和非DDD项目代码有何不同。

实现业务逻辑的三种方式 #

在案例项目码如云中有这样一个业务需求:所有可登录的用户被称为成员(Member),成员可以自行修改自己的手机号码,修改后该成员将被标记为“手机号已识别”的状态。为了实现这个需求,我们分别通过三种方式予以实现,读者可以对照看看这些实现方式是不是和自己曾经的编码方式有相似之处。

第一种: 事务脚本 #

对于上述需求,从纯技术上讲,我们希望达到的最终目的不过是在数据库中的member表中更新2个字段而已,一个是手机号(mobile_number)字段,另一个是手机号已识别(mobile_identified)字段。为了实现这个需求,最简单直接的方式难道不是直接写个SQL语句直接更新数据库表么?的确如此,这个简单的方式其实有个专门的名词 —— 事务脚本(Transactional Script),也即通过类似编写脚本的方式完成一个业务用例,一个业务用例对应一次事务。

    @Transactional//事务边界
    public void updateMyMobile(String mobileNumber, String memberId) {
        
        //采用事务脚本的方式,直接通过SQL语句实现业务逻辑
        String sql = "update member set mobile_number = ? , mobile_identified = 1 where id = ?;";
        jdbcTemplate.update(sql, mobileNumber,memberId);
    }

这种直接通过技术手段实现业务功能的方式没有任何软件建模可言,它将原本可以分开的业务性代码和技术性代码揉杂在一起,既不利于业务的重用,也不利于系统的长期演进,因此通常被认为只适合一些小型软件项目。

第二种:贫血对象 #

看到第一种实现方式你可能会想:这都什么年代了,还在像写C语言那样编写代码,不使用点儿面向对象技术连一个刚入职的毕业生估计都不好意思。那好吧,让我们创建一个Member对象。

    @Transactional
    public void updateMyMobile(String mobileNumber) {
        String memberId = CurrentUserContext.getCurrentMemberId();
        Member member = memberRepository.findMemberById(memberId);

        //先后调用Member对象中的2个setter方法实现业务逻辑
        member.setMobileNumber(mobileNumber);
        member.setMobileIdentified(true);

        memberRepository.updateMember(member);
    }

在上例中,首先我们将数据库访问相关的逻辑全部封装在memberRepository中,从而解决了“技术性代码和业务性代码揉杂”的问题。其次,创建了Member对象,其中包含两个setter方法,setMobileNumber()用于设置手机号码,setMobileIdentified()用于标记标记手机号已识别,这应该面向对象了吧?!但是,问题恰恰出在了这两个setter方法上:此时的Member对象只是一个数据容器而已,而非真正的对象。这种只有数据没有行为的对象被称为贫血对象

问题还不止于此,本例中先后调用的两个setter方法事实上违背了软件开发的一个根本性原则 —— 内聚性。简单来讲,“设置手机号”和“标记手机号已识别”这两个步骤在业务上是紧密联系在一起的,应该由Member中的单个方法完成,而不应该由2个独立的方法完成。为了解释这里体现的内聚性,让我们再来看个需求:除了成员自己可以修改手机号外,管理员也可以为任何成员设置手机号,为此我们再实现一个updateMemberMobile()方法。

    @Transactional
    public void updateMemberMobile(String mobileNumber,String memberId) {
        Member member = memberRepository.findMemberById(memberId);

        //与updateMyMobile()相同,需要先后调用Member对象中的2个setter方法实现业务逻辑
        member.setMobileNumber(mobileNumber);
        member.setMobileIdentified(true);

        memberRepository.updateMember(member);
    }

这里,updateMemberMobile()方法也需要显式地先后调用Member的setMobileNumber()setMobileIdentified()方法,也就是说编码者需要记住必须同时调用2个方法,否则程序就会出Bug。这种方式存在以下问题:

  1. 业务逻辑的泄漏:对于维持“设置手机号”和“标记手机号已识别”同时发生的职责来说,本应该由Member对象自身完成的,结果泄漏到了Member对象的外部;
  2. 增加调用者的负担:对于作为Member客户方的updateMyMobile()updateMemberMobile()方法来讲,他们本应该将Member当做一个黑盒,但在本例中却需要了解Member的内部细节(先后调用setMobileNumber()setMobileIdentified()方法),这无疑是调用者的负担。
  3. 难于维护:如果以后业务需求有变,那么需要同时修改updateMyMobile()updateMemberMobile()2个方法,这可能不是能够轻易做到的,特别是在人员流动频繁的软件项目中。

与事务脚本相似,贫血对象除了可用于一些小的软件项目外,通常被认为是一种反模式,应该避免使用。

第三种:领域对象 #

领域对象是一个与贫血对象相对立的概念,它表示直接体现业务逻辑的一类对象,这类对象不仅包含业务数据,还包含业务行为。领域对象希望达到的理想状态是:所有业务逻辑均由领域对象完成,外界将领域对象当做一个黑盒向其发送指令(调用方法)即可。在本例中,设置手机号的同时需要标记“手机号已识别”均属业务逻辑,应该全部放到领域对象中完成。

    @Transactional
    public void updateMyMobile(String mobileNumber) {
        String memberId = CurrentUserContext.getCurrentMemberId();
        Member member = memberRepository.findMemberById(memberId);

        //只需调用Member种的updateMobile()方法即可
        member.updateMobile(mobileNumber);

        memberRepository.updateMember(member);
    }

这里,updateMyMobile()方法只需调用Member中的updateMobile()方法即可,然后由Member自行处理具体的业务逻辑:

    //由Member对象自身处理同时更新mobileNumber和mobileIdentified字段
    public void updateMobile(String mobileNumber) {
        this.mobileNumber = mobileNumber;
        this.mobileIdentified = true;
    }

在本例中,除了将数据和行为同时放到Member对象之外,我们还会考虑如何设计和安排这些行为才最得当,比如将高内聚的mobileNumbermobileIdentified放到同一个方法中,此时的Member便是一个行为饱满的领域对象,并开始变得有些“领域驱动”的意味了,所谓的”DDD是面向对象进阶”这个说法也正体现于此。事实上,在DDD中Member对象也被称为聚合根,而“更新mobileNumber的同时需要一并更新mobileIdentified”则被称为聚合根的不变条件,我们将在后续文章中对此做详细讲解。

看到这里,你可能会问:领域对象的实现方式不就是将贫血对象中的业务逻辑实现挪了个位置吗?的确,但是这一挪,便挪出了编程的讲究与思考,挪出了模型的设计与原则,挪出了软件的发展与进步。就像云计算早年被认为不过是将本地的计算资源搬移到网络上一样,我们将很多看似并不具有颠覆性的微小创新合在一起,便可将理想编织成一个个能够为行业为社会带来实际进步的美好现实。

你可能还会说,领域对象这种实现方式我平时就是这么做的呀!?没错,我们平时编程的很多做法其实已经包含了DDD中的某些思想或实践,因为DDD并不是什么全新的东西要把你所写的代码全部推翻重来,而是很多具有逻辑归因性的东西其实大家都能总结出来,只是那些大牛总结得比我们更早,更系统,更全面而已。

对于以上三种实现方式,我们在前面提到事务脚本和贫血对象只适合一些小型的软件项目,那么问题来了,到底多小才算小呢?这个问题没有标准答案,就像你问微服务多小算小一样,It depends!然而,但凡是企业中立过项的软件项目,都不会是实现一个Code Kata这么简单,都不能被定义为“小型项目”。因此,对于几乎所有企业级软件系统来说,使用领域对象进而DDD都不会是个错误的选择。

真实产品代码 #

由于本文是入门性质的文章,故到目前为止所使用的代码均不是码如云的产品代码。接下来,让我们来看看真实的产品代码,对于“成员修改自己的手机号”的业务功能,码如云代码库中的实现如下:

    @Transactional
    public void changeMyMobile(ChangeMyMobileCommand command, User user) {
        //API限流器,与DDD无关,读者可忽略
        mryRateLimiter.applyFor(user.getTenantId(), "Member:ChangeMyMobile", 5);

        //将所有请求相关的数据封装到Command对象中
        String mobile = command.getMobile();

        //修改手机号时,需要验证发往新手机号的验证码
        verificationCodeChecker.check(mobile, command.getVerification(), CHANGE_MOBILE);

        Member member = memberRepository.byId(user.getMemberId());

        //这里调用了MemberDomainService中的方法,而不是直接调用Member,因为需要检查手机号是否重复,而Member自身无法完成该检查
        memberDomainService.changeMyMobile(member, mobile, command.getPassword());

        memberRepository.save(member);
        log.info("Mobile changed by member[{}].", member.getId());
    }

源码出处:com/mryqr/core/member/command/MemberCommandService.java

为了让读者能对代码有更加详尽的了解,我们在源代码中加上了注释,建议读者通过阅读这些注释来理解代码的意图。(真实的码如云代码库中是很少有注释的,因为我们坚持“代码即是设计”的原则,让代码本身直接体现业务意图)

在本例中,首先使用限流器MryRateLimiter对请求进行限流处理,然后使用VerificationCodeChecker对手机号验证码进行检查,最后才调用MemberDomainService完成实际的业务逻辑。你可能有些纳闷儿,为什么不像前文中那样直接调用Member对象中的方法,而是调用MemberDomainService呢?事实上,这里的MemberDomainService在DDD中被称为领域服务,用于处理领域对象自身无法处理的业务逻辑。在本例中,成员在修改手机号时,系统需要检查该手机号是否已经被其他成员所占用,这部分逻辑是无法通过单个Member自身完成的,只能通过一个可以跨多个MemberMemberDomainService完成。

对于诸如限流器MryRateLimiter这些与DDD无关的代码,我们将在后续文章的代码中予以删除,以使代码集中在对DDD的阐述上。

MemberDomainService.changeMyMobile()方法实现如下:

    public void changeMyMobile(Member member, String newMobile, String password) {
        //修改手机号时,需要验证密码
        if (!mryPasswordEncoder.matches(password, member.getPassword())) {
            throw new MryException(PASSWORD_NOT_MATCH, "修改手机号失败,密码不正确。", "memberId", member.getId());
        }

        if (Objects.equals(member.getMobile(), newMobile)) {
            return;
        }

        //检查手机号是否已被占用
        if (memberRepository.existsByMobile(newMobile)) {
            throw new MryException(MEMBER_WITH_MOBILE_ALREADY_EXISTS, "修改手机号失败,手机号对应成员已存在。",
                    mapOf("mobile", newMobile, "memberId", member.getId()));
        }

        //调用Member对象中的方法,完成对手机号的修改
        member.changeMobile(newMobile, member.toUser());
    }

源码出处:com/mryqr/core/member/domain/MemberDomainService.java

可以看到,MemberDomainService调用了MemberRepository.existsByMobile()用于检查手机号是否已经被占用,如果是,则抛出异常。

最后,MemberDomainService调用Member.changeMobile()方法完成对手机号的修改:

public void changeMobile(String mobile, User user) {
        if (Objects.equals(this.mobile, mobile)) {
            return;
        }

        //同时设置mobile字段和mobileIdentified的值,高度内聚
        this.mobile = mobile;
        this.mobileIdentified = true;
        
        this.addOpsLog("修改手机号为[" + mobile + "]", user);
    }

源码出处:com/mryqr/core/member/domain/Member.java

如前文所述,mobilemobileIdentified是高度内聚的,因此放在Member的同一个方法changeMobile()中完成更新。以后,无论通过什么业务渠道修改成员的手机号,都只需要调用相同的Member.changeMobile()方法即可。

DDD书籍推荐 #

我基本上参阅完了市面上所有的DDD书籍(截止到2023年3月份),在这些书籍中,真正值得推崇的有以下4本书:

  • 《领域驱动设计:软件核心复杂性应对之道》(蓝皮书,从左往右第一本,首版时间2003年):DDD的开山之作,对于初学者来说阅读起来有些晦涩,不建议初学者直接阅读该书
  • 《实现领域驱动设计》(红皮书,从左往右第二本,首版时间2013年):这本是讲DDD落地的经典书籍,其中包含大量代码示例,很多人都是通过这本书才真正进入DDD的世界
  • 《领域驱动设计模式、原理与实践》(从左往右第三本,首版时间2015年):这也是一本能够帮你系统的完成DDD落地的书籍
  • 《解构领域驱动设计》(首版时间2021年):国内第一本关于DDD的专著,作者张逸在DDD社区具有比较大的影响力

对于英文书籍,建议大家如果有条件的话,一定阅读英文原版,因为那才是第一手资料,中文翻译始终存在漏译错译等无法表达原书本意的情况。

总结 #

本文从事务脚本、贫血对象和领域对象三种实现业务逻辑的方式为入口,一步一步地引入DDD的概念,希望能让DDD新手们平滑地开启DDD的学习之路。在下一篇:DDD概念大白话文章中,我们将通过大白话的方式给大家讲解DDD中的各种概念,以让读者对DDD有个全景式的认识。

C#反射实现插件式开发 - 码农阿亮 - 博客园

mikel阅读(381)

来源: C#反射实现插件式开发 – 码农阿亮 – 博客园

前言

插件式架构,一种全新的、开放性的、高扩展性的架构体系。插件式架构设计好处很多,把扩展功能从框架中剥离出来,降低了框架的复杂度,让框架更容易实现。扩展功能与框架以一种很松的方式耦合,两者在保持接口不变的情况下,可以独立变化和发布。基于插件设计并不神秘,相反它比起一团泥的设计更简单,更容易理解。

项目介绍

书写4个插件类库,分别传参实现“加减乘除”运算,调用插件的客户端采用Winform窗体程序。

目标框架:.NET Framework 4.6.1

项目架构和窗体布局:

image

客户端程序:

  • PluginApp:反射调用插件

插件描述:

  • PluginBase:规范插件的基类,定义抽象类,开发的插件的类需要继承此类,代表遵守这个规范。
  • CustomPlugInA:实现加法的插件
  • CustomPlugInB:实现减法的插件
  • CustomPlugInC:实现乘法的插件
  • CustomPlugInD:实现除法的插件

代码实现

插件基类
 /// <summary>
    ///插件基类
    /// </summary>
    public abstract class Base
    {
        /// <summary>
        /// 插件名称
        /// </summary>
        /// <returns></returns>
        public abstract string Name();
       /// <summary>
       /// 插件描述
       /// </summary>
       /// <returns></returns>
        public abstract string Desc();
        /// <summary>
        /// 执行方法
        /// </summary>
        /// <param name="param1">参数1</param>
        /// <param name="param2">参数2</param>
        /// <returns></returns>
        public abstract string Run(int param1, int param2);
        /// <summary>
        /// 版本 
        /// </summary>
        public string Version
        {
            get { return "1.0.0"; }
        }
    }
PlugInA
    public class PlugInA: Base
    {

        public override string Name()
        {
            return "PlugInA";
        }

        public override string Desc()
        {
            return "加法";
        }

        public override string Run(int param1,int param2)
        {
            return (param1 + param2) + "";
        }
    }
}
PlugInB
    public class PlugInB : Base
    {

        public override string Name()
        {
            return "PlugInB";
        }

        public override string Desc()
        {
            return "减法";
        }

        public override string Run(int param1, int param2)
        {
            return (param1 - param2) + "";
        }
    }
PlugInC
  public class PlugInC : Base
    {

        public override string Name()
        {
            return "PlugInC";
        }

        public override string Desc()
        {
            return "乘法";
        }

        public override string Run(int param1, int param2)
        {
            return (param1 * param2) + "";
        }
    }
PlugInD
 public class PlugInD : Base
    {

        public override string Name()
        {
            return "PlugInD";
        }

        public override string Desc()
        {
            return "除法";
        }

        public override string Run(int param1, int param2)
        {
            return (param1 / param2) + "";
        }
    }
客户端核心代码:
   public partial class FrmMain : Form
    {
        public FrmMain()
        {
            InitializeComponent();
            dgrvPlugins.AutoGenerateColumns = false;
        }

        List<PluginModel> List = new List<PluginModel>();

        readonly string PlugInPath = Application.StartupPath + "\\PlugIns";

        /// <summary>
        /// 载入插件
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void btLoadPlugins_Click(object sender, EventArgs e)
        {

            if (!Directory.Exists(PlugInPath))
            {
                Directory.CreateDirectory(PlugInPath);
            }
            List.Clear();
            string[] files = Directory.GetFiles(PlugInPath);
            foreach (string file in files)
            {
                if (file.ToLower().EndsWith(".dll"))
                {
                    try
                    {
                        Assembly assembly = Assembly.LoadFrom(file);
                        Type[] types = assembly.GetTypes();
                        foreach (Type type in types)
                        {
                            if (type.BaseType.FullName == "PlugInBase.Base")
                            {
                                object obj = assembly.CreateInstance(type.FullName);
                                string name = type.GetMethod("Name").Invoke(obj, null).ToString();
                                string desc = type.GetMethod("Desc").Invoke(obj, null).ToString();
                                string version = type.GetProperty("Version").GetValue(obj).ToString();

                                List.Add(new PluginModel
                                {
                                    Name = name,
                                    Desc = desc,
                                    Version = version,
                                    type = type,
                                    Obj = obj
                                });
                            }
                        }
                    }
                    catch (Exception ex)
                    {
                        throw ex;
                    }
                }
            }

            dgrvPlugins.DataSource = new BindingList<PluginModel>(List);
        }

        /// <summary>
        /// 打开插件目录
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void btOpenPluginDir_Click(object sender, EventArgs e)
        {
            Process.Start(PlugInPath);
        }


        /// <summary>
        /// 执行选中插件
        /// </summary>
        /// <param name="sender"></param>
        /// <param name="e"></param>
        private void btExcute_Click(object sender, EventArgs e)
        {
            //获取选择的插件信息
            int index = dgrvPlugins.CurrentRow.Index;
            object obj = List[index].Obj;
            Type type = List[index].type;
            //参数
            object[] inParams = new object[2];
            inParams[0] =Convert.ToInt32( dgrvPlugins.CurrentRow.Cells[2].Value);
            inParams[1] = Convert.ToInt32(dgrvPlugins.CurrentRow.Cells[3].Value);
            object value = type.GetMethod("Run").Invoke(obj, inParams);
            MessageBox.Show(Convert.ToString(value),"结果",MessageBoxButtons.OK);
        }
    }

项目配置

插件生成配置

编译生成项目的时候需要注意,此处的调用插件是通过反射调用.dll中类和方法,所以首先要找到这个.dll的文件,所以此处我们在Winform客户端程序下建立一个存放类库dll的文件PlugIns,在插件类库项目生成后事件命令中,填入如下命令:

copy /Y "$(TargetDir)$(ProjectName).dll" "$(SolutionDir)\PlugIns"

以上命令代表,在项目的类库生成后,将类库copy到解决方案的路径子文件夹PlugIns,也就是我们建立存放自定义插件的文件夹。当然,如果不怕麻烦,每次生成后,手动复制到此文件夹也可以,直接复制到客户端程序的..\bin\PlugIns文件夹下。

image

插件路径配置

全选这些类库,把这些类库设置为”如果较新则复制”,这样每次在编译客户端程序,如果自定义插件有更新,则同步会复制到bin目录下

image

插件基类配置

插件基类提供了规范,需要在类库的生成后事件,添加命令:

copy /Y "$(TargetDir)$(ProjectName).dll" "$(SolutionDir)\bin\Debug"

将生成的dll文件,拷贝到客户端程序的bin路径下

image

调用演示

CustomPlugInA

image

CustomPlugInB

image

CustomPlugInC

image

CustomPlugInD

image

插件开发优缺点

优点
  • 把扩展功能从框架中剥离出来,降低了框架的复杂度,让框架更容易实现
  • 宿主中可以对各个模块解析,完成插件间、插件和主程序间的通信。
  • 插件开发的可扩展性,灵活性比较高,而且可以进行定制化开发。
缺点
  • 每一个插件被编译成了dll,各模块无法单独运行,必须依托于主程序。
  • 修改插件时,由于生成的是dll,无法快速直观的查看修改以及调试。
  • 每一个插件必须依赖于某一个规范。

源码

百度网盘

链接:https://pan.baidu.com/s/1Ev1s14IvggrCVBvbu-NW7g?pwd=mnal
提取码:mnal

码云

https://gitee.com/mingliang_it/PlugInsApp.git

Llama2-Chinese项目:2.1-Atom-7B预训练 - 扫地升 - 博客园

mikel阅读(322)

来源: Llama2-Chinese项目:2.1-Atom-7B预训练 – 扫地升 – 博客园

虽然Llama2的预训练数据相对于第一代LLaMA扩大了一倍,但是中文预训练数据的比例依然非常少,仅占0.13%,这也导致了原始Llama2的中文能力较弱。为了能够提升模型的中文能力,可以采用微调和预训练两种路径,其中:

  • 微调需要的算力资源少,能够快速实现一个中文Llama的雏形。但缺点也显而易见,只能激发基座模型已有的中文能力,由于Llama2的中文训练数据本身较少,所以能够激发的能力也有限,治标不治本。
  • 基于大规模中文语料进行预训练,成本高,不仅需要大规模高质量的中文数据,也需要大规模的算力资源。但是优点也显而易见,就是能从模型底层优化中文能力,真正达到治本的效果,从内核为大模型注入强大的中文能力。

下面从主要目标、训练数据、权重更新、数据转换和预处理、任务类型、示例应用和典型场景7个方面进行比较,如下所示(ChatGPT):

特征 Pretraining(预训练) Continuous Pretraining(继续预训练) Fine-tuning(微调) Post-Pretrain(预训练之后)
主要目标 学习通用的表示 在通用表示上继续学习 在特定任务上调整模型 在预训练之后的额外学习和任务
训练数据 大规模文本数据集 额外的文本数据集 特定任务的数据集 额外的优化、领域适应或任务迁移
权重更新 固定模型参数,不进行权重更新 继续更新模型参数 在任务数据上进行权重更新 针对特定需求进行权重更新
数据转换和预处理 通常包括数据标准化、掩码预测等 与预训练相似的预处理 根据任务需求进行调整 针对特定需求进行数据处理和优化
任务类型 无监督学习、自监督学习 通常是自监督学习 监督学习 可以包括优化、领域适应、任务迁移等多种任务
示例应用 BERT、GPT等 额外的预训练 文本分类、命名实体识别等 模型优化、领域适应、多任务学习等
典型场景 语言理解和生成 继续模型学习 特定文本任务 后续步骤和任务,用于定制和优化模型

说明:本文环境为Windows10,Python3.10,CUDA 11.8,GTX 3090(24G),内存24G。

一.模型预训练脚本
模型预训练脚本中的参数较多,只能在实践中来消化了。因为用的Windows10系统,所以运行shell脚本较麻烦,这部分不做过多介绍。如下所示:

train/pretrain/pretrain.sh  
output_model=/mnt/data1/atomgpt                                  # output_model:输出模型路径
if [ ! -d ${output_model} ];then                                 # -d:判断是否为目录,如果不是目录则创建
    mkdir ${output_model}                                        # mkdir:创建目录
fi
cp ./pretrain.sh ${output_model}                                 # cp:复制文件pretrain.sh到output_model目录下
cp ./ds_config_zero*.json ${output_model}                        # cp:复制文件ds_config_zero*.json到output_model目录下

deepspeed --num_gpus 1 pretrain_clm.py \                         # deepspeed:分布式训练,num_gpus:使用的gpu数量,pretrain_clm.py:训练脚本
    --model_name_or_path L:/20230903_Llama2/Llama-2-7b-hf \      # model_name_or_path:模型名称或路径
    --train_files ../../data/train_sft.csv \                     # train_files:训练数据集路径
                ../../data/train_sft_sharegpt.csv \
    --validation_files  ../../data/dev_sft.csv \                 # validation_files:验证数据集路径
                         ../../data/dev_sft_sharegpt.csv \
    --per_device_train_batch_size 10 \                           # per_device_train_batch_size:每个设备的训练批次大小
    --per_device_eval_batch_size 10 \                            # per_device_eval_batch_size:每个设备的验证批次大小
    --do_train \                                                 # do_train:是否进行训练
    --output_dir ${output_model} \                               # output_dir:输出路径
    --evaluation_strategy  steps \                               # evaluation_strategy:评估策略,steps:每隔多少步评估一次
    --use_fast_tokenizer false \                                 # use_fast_tokenizer:是否使用快速分词器
    --max_eval_samples 500 \                                     # max_eval_samples:最大评估样本数,500:每次评估500个样本
    --learning_rate 3e-5 \                                       # learning_rate:学习率
    --gradient_accumulation_steps 4 \                            # gradient_accumulation_steps:梯度累积步数
    --num_train_epochs 3 \                                       # num_train_epochs:训练轮数
    --warmup_steps 10000 \                                       # warmup_steps:预热步数
    --logging_dir ${output_model}/logs \                         # logging_dir:日志路径
    --logging_strategy steps \                                   # logging_strategy:日志策略,steps:每隔多少步记录一次日志
    --logging_steps 2 \                                          # logging_steps:日志步数,2:每隔2步记录一次日志
    --save_strategy steps \                                      # save_strategy:保存策略,steps:每隔多少步保存一次
    --preprocessing_num_workers 10 \                             # preprocessing_num_workers:预处理工作数
    --save_steps 500 \                                           # save_steps:保存步数,500:每隔500步保存一次
    --eval_steps 500 \                                           # eval_steps:评估步数,500:每隔500步评估一次
    --save_total_limit 2000 \                                    # save_total_limit:保存总数,2000:最多保存2000个
    --seed 42 \                                                  # seed:随机种子
    --disable_tqdm false \                                       # disable_tqdm:是否禁用tqdm
    --ddp_find_unused_parameters false \                         # ddp_find_unused_parameters:是否找到未使用的参数
    --block_size 4096 \                                          # block_size:块大小
    --overwrite_output_dir \                                     # overwrite_output_dir:是否覆盖输出目录
    --report_to tensorboard \                                    # report_to:报告给tensorboard
    --run_name ${output_model} \                                 # run_name:运行名称
    --bf16 \                                                     # bf16:是否使用bf16
    --bf16_full_eval \                                           # bf16_full_eval:是否使用bf16进行完整评估
    --gradient_checkpointing \                                   # gradient_checkpointing:是否使用梯度检查点
    --deepspeed ./ds_config_zero3.json \                         # deepspeed:分布式训练配置文件
    --ignore_data_skip true \                                    # ignore_data_skip:是否忽略数据跳过
    --ddp_timeout 18000000 \                                     # ddp_timeout:ddp超时时间,18000000:18000000毫秒
    | tee -a ${output_model}/train.log                           # tee:将标准输出重定向到文件,-a:追加到文件末尾
    
    # --resume_from_checkpoint ${output_model}/checkpoint-20400 \# resume_from_checkpoint:从检查点恢复训练

 

二.预训练实现代码
Llama中文社区供了Llama模型的预训练代码,以及中文语料(参考第六部分)。本文在meta发布的Llama-2-7b基础上进行预训练,pretrain_clm.py代码的中文注释参考[0],执行脚本如下所示:

python pretrain_clm.py --output_dir ./output_model  --model_name_or_path L:/20230903_Llama2/Llama-2-7b-hf  --train_files ../../data/train_sft.csv ../../data/train_sft_sharegpt.csv  --validation_files  ../../data/dev_sft.csv ../../data/dev_sft_sharegpt.csv --do_train --overwrite_output_dir  

说明:使用GTX 3090 24G显卡,还是报了OOM错误,但是并不影响调试学习,输出日志参考[2]。
1.代码结构

(1)ModelArguments:模型参数类
(2)DataTrainingArguments:数据训练参数类
(3)TrainingArguments:训练参数类
2.model_args, data_args, training_args = parser.parse_args_into_dataclasses()
解析:加载模型参数、数据训练参数和训练参数,如下所示:

3.raw_datasets = load_dataset(...)
解析:加载原始数据集,如下所示:

4.config = AutoConfig.from_pretrained(model_args.model_name_or_path, **config_kwargs)
解析:加载config,如下所示:

5.tokenizer = AutoTokenizer.from_pretrained(model_args.model_name_or_path, **tokenizer_kwargs)
解析:加载tokenizer,如下所示:

6.model = AutoModelForCausalLM.from_pretrained()
解析:加载model,这一步非常耗时,如下所示:

7.tokenized_datasets = raw_datasets.map()
解析:原始数据集处理,比如编码等,如下所示:

8.trainer = Trainer()
解析:实例化一个trainer,用于后续的训练或评估。跑到这一步的时候就报OOM了。如下所示:

trainer = Trainer( # 训练器
    model=model, # 模型
    args=training_args, # 训练参数
    train_dataset= IterableWrapper(train_dataset) if training_args.do_train else None, # 训练数据集
    eval_dataset= IterableWrapper(eval_dataset) if training_args.do_eval else None, # 评估数据集
    tokenizer=tokenizer, # 分词器
    # Data collator will default to DataCollatorWithPadding, so we change it.
    # 翻译:数据收集器将默认为DataCollatorWithPadding,因此我们将其更改。
    data_collator=default_data_collator, # 默认数据收集器
    compute_metrics=compute_metrics if training_args.do_eval and not is_torch_tpu_available() else None, # 计算指标
    preprocess_logits_for_metrics=preprocess_logits_for_metrics if training_args.do_eval and not is_torch_tpu_available() else None, # 为指标预处理logits
    # callbacks=([SavePeftModelCallback] if isinstance(model, PeftModel) else None),
)

说过:阅读pretrain_clm.py代码有几个疑问:中文词表是什么扩展的?上下文长度如何扩增?如何预训练text数据?后续再写文章分享。

三.DeepSpeed加速
DeepSpeed是一个由微软开发的开源深度学习优化库,旨在提高大规模模型训练的效率和可扩展性。它通过多种技术手段来加速训练,包括模型并行化、梯度累积、动态精度缩放、本地模式混合精度等。DeepSpeed还提供了一些辅助工具,如分布式训练管理、内存优化和模型压缩等,以帮助开发者更好地管理和优化大规模深度学习训练任务。此外,deepspeed基于pytorch构建,只需要简单修改即可迁移。DeepSpeed已经在许多大规模深度学习项目中得到了应用,包括语言模型、图像分类、目标检测等等。
DeepSpeed主要包含三部分:

  • Apis:提供易用的api接口,训练模型、推理模型只需要简单调用几个接口即可。其中最重要的是initialize接口,用来初始化引擎,参数中配置训练参数及优化技术等。配置参数一般保存在config.json文件中。
  • Runtime:运行时组件,是DeepSpeed管理、执行和性能优化的核心组件。比如部署训练任务到分布式设备、数据分区、模型分区、系统优化、微调、故障检测、checkpoints保存和加载等。该组件使用Python语言实现。
  • Ops:用C++和CUDA实现底层内核,优化计算和通信,比如ultrafast transformer kernels、fuse LAN kernels、cusomary deals等。

1.Windows10安装DeepSpeed
解析:管理员启动cmd:

build_win.bat
python setup.py bdist_wheel

2.安装编译工具
在Visual Studio Installer中勾选”使用C++的桌面开发”,如下所示:

3.error C2665: torch::empty: 没有重载函数可以转换所有参数类型

解决办法如下所示:

4.元素”1″: 从”size_t”转换为”_Ty”需要收缩转换
解析:具体错误如下所示:

csrc/transformer/inference/csrc/pt_binding.cpp(536): error C2398: 元素"1": 从"size_t"转换为"_Ty"需要收缩转换


解析方案如下所示:

536:hidden_dim * (unsigned)InferenceContext
537:k * (int)InferenceContext
545:hidden_dim * (unsigned)InferenceContext
546:k * (int)InferenceContext
1570input.size(1), (int)mlp_1_out_neurons


编译成功如下所示:

5.安装类库

PS L:\20230903_Llama2\whl文件\DeepSpeed\dist> pip3 install .\deepspeed-0.10.4+180dd397-cp310-cp310-win_amd64.whl

说明:由于DeepSpeed在Windows操作不友好,这部分只做学习使用。
6.单卡训练和多卡训练
(1)对于单卡训练,可以采用ZeRO-2的方式,参数配置见train/pretrain/ds_config_zero2.json

{
    "fp16": {                      // 混合精度训练
        "enabled": "auto",         // 是否开启混合精度训练
        "loss_scale": 0,           // 损失缩放
        "loss_scale_window": 1000, // 损失缩放窗口
        "initial_scale_power": 16, // 初始损失缩放幂
        "hysteresis": 2,           // 滞后
        "min_loss_scale": 1        // 最小损失缩放
    },
    "optimizer": {                 // 优化器
        "type": "AdamW",           // 优化器类型
        "params": {                // 优化器参数
            "lr": "auto",          // 学习率
            "betas": "auto",       // 衰减因子
            "eps": "auto",         // 除零保护
            "weight_decay": "auto" // 权重衰减
        }
    },

    "scheduler": {                       // 学习率调度器
        "type": "WarmupDecayLR",         // 调度器类型
        "params": {                      // 调度器参数
            "last_batch_iteration": -1,  // 最后批次迭代
            "total_num_steps": "auto",   // 总步数
            "warmup_min_lr": "auto",     // 最小学习率
            "warmup_max_lr": "auto",     // 最大学习率
            "warmup_num_steps": "auto"   // 热身步数
        }
    },

    "zero_optimization": {              // 零优化
        "stage": 2,                     // 零优化阶段
        "offload_optimizer": {          // 优化器卸载
            "device": "cpu",            // 设备
            "pin_memory": true          // 锁页内存
        },
        "offload_param": {              // 参数卸载
            "device": "cpu",            // 设备
            "pin_memory": true          // 锁页内存
        },
        "allgather_partitions": true,   // 全收集分区
        "allgather_bucket_size": 5e8,   // 全收集桶大小
        "overlap_comm": true,           // 重叠通信
        "reduce_scatter": true,         // 减少散射
        "reduce_bucket_size": 5e8,      // 减少桶大小
        "contiguous_gradients": true    // 连续梯度
    },
    "activation_checkpointing": {                   // 激活检查点
        "partition_activations": false,             // 分区激活
        "cpu_checkpointing": false,                 // CPU检查点
        "contiguous_memory_optimization": false,    // 连续内存优化
        "number_checkpoints": null,                 // 检查点数量
        "synchronize_checkpoint_boundary": false,   // 同步检查点边界
        "profile": false                            // 档案
    },
    "gradient_accumulation_steps": "auto",          // 梯度累积步骤
    "gradient_clipping": "auto",                    // 梯度裁剪
    "steps_per_print": 2000,                        // 每次打印步骤
    "train_batch_size": "auto",                     // 训练批次大小
    "min_lr": 5e-7,                                 // 最小学习率
    "train_micro_batch_size_per_gpu": "auto",       // 每个GPU的训练微批次大小
    "wall_clock_breakdown": false                   // 墙上时钟分解
}

(2)对于多卡训练,可以采用ZeRO-3的方式,参数配置见train/pretrain/ds_config_zero3.json

{
    "fp16": {                         // 混合精度训练
        "enabled": "auto",            // 是否开启混合精度训练
        "loss_scale": 0,              // 损失缩放
        "loss_scale_window": 1000,    // 损失缩放窗口
        "initial_scale_power": 16,    // 初始缩放幂
        "hysteresis": 2,              // 滞后
        "min_loss_scale": 1,          // 最小损失缩放
        "fp16_opt_level": "O2"        // 混合精度优化级别
    },

    "bf16": {                         // 混合精度训练
        "enabled": "auto"             // 是否开启混合精度训练
    }, 

    "optimizer": {                    // 优化器
        "type": "AdamW",              // 优化器类型
        "params": {                   // 优化器参数
            "lr": "auto",             // 学习率
            "betas": "auto",          // 衰减因子
            "eps": "auto",            // 除零保护
            "weight_decay": "auto"    // 权重衰减
        }
    },

    "scheduler": {                       // 学习率调度器
        "type": "WarmupDecayLR",         // 学习率调度器类型
        "params": {                      // 学习率调度器参数
            "last_batch_iteration": -1,  // 最后批次迭代
            "total_num_steps": "auto",   // 总步数
            "warmup_min_lr": "auto",     // 最小学习率
            "warmup_max_lr": "auto",     // 最大学习率
            "warmup_num_steps": "auto"   // 热身步数
        }
    },

    "zero_optimization": {                                 // 零优化
        "stage": 3,                                        // 零优化阶段
        "overlap_comm": true,                              // 重叠通信
        "contiguous_gradients": true,                      // 连续梯度
        "sub_group_size": 1e9,                             // 子组大小
        "reduce_bucket_size": "auto",                      // 减少桶大小
        "stage3_prefetch_bucket_size": "auto",             // 阶段3预取桶大小
        "stage3_param_persistence_threshold": "auto",      // 阶段3参数持久性阈值
        "stage3_max_live_parameters": 1e9,                 // 阶段3最大活动参数
        "stage3_max_reuse_distance": 1e9,                  // 阶段3最大重用距离
        "gather_16bit_weights_on_model_save": true         // 在模型保存时收集16位权重
    },
    "gradient_accumulation_steps": "auto",                 // 梯度累积步数
    "gradient_clipping": "auto",                           // 梯度裁剪
    "steps_per_print": 2000,                               // 每次打印步数
    "train_batch_size": "auto",                            // 训练批次大小
    "train_micro_batch_size_per_gpu": "auto",              // 训练每个GPU的微批次大小
    "wall_clock_breakdown": false                          // 墙上时钟分解
}

ZeRO-2和ZeRO-3间的比较如下所示(ChatGPT):

特征 Zero2(0.2版本) Zero3(0.3版本)
内存占用优化
动态计算图支持 不支持 支持
性能优化 一般 更好
模型配置选项 有限 更多
分布式训练支持
具体应用 非动态计算图模型 动态计算图模型

 

四.训练效果度量指标
accuracy.py代码的中文注释参考[1],主要在预训练评估的时候用到了该文件,如下所示:

train/pretrain/accuracy.py  
metric = evaluate.load("accuracy.py") # 加载指标

 

五.中文测试语料
中文测试语料数据格式如下所示:

<s>Human: 问题</s><s>Assistant: 答案</s>

多轮语料将单轮的拼接在一起即可,如下所示:

<s>Human: 内容1\n</s><s>Assistant: 内容2\n</s><s>Human: 内容3\n</s><s>Assistant: 内容4\n</s>


Llama2-Chinese项目中提供的train和dev文件共有3个,如下所示:

data\dev_sft.csv  
data\dev_sft_sharegpt.csv  
data\train_sft.csv  

更多的语料可从Llama中文社区(https://llama.family/)链接下载:

六.中文语料
Atom-7B是一个基于Llama2架构的预训练语言模型,Llama中文社区将基于大规模中文语料,从预训练开始对Llama2模型进行中文能力的持续迭代升级。通过以下数据来优化Llama2的中文能力:

类型 描述
网络数据 互联网上公开的网络数据,挑选出去重后的高质量中文数据,涉及到百科、书籍、博客、新闻、公告、小说等高质量长文本数据。
Wikipedia 中文Wikipedia的数据
悟道 中文悟道开源的200G数据
Clue Clue开放的中文预训练数据,进行清洗后的高质量中文长文本数据
竞赛数据集 近年来中文自然语言处理多任务竞赛数据集,约150个
MNBVC MNBVC 中清洗出来的部分数据集

说明:除了网络数据和竞赛数据集这2个没有提供链接,其它的4个都提供了数据集的链接。

参考文献:
[0]https://github.com/ai408/nlp-engineering/blob/main/20230916_Llama2-Chinese/train/pretrain/pretrain_clm.py
[1]https://github.com/ai408/nlp-engineering/blob/main/20230916_Llama2-Chinese/train/pretrain/accuracy.py
[2]https://github.com/ai408/nlp-engineering/blob/main/20230916_Llama2-Chinese/train/pretrain/pretrain_log/pretrain_log
[3]https://huggingface.co/meta-llama/Llama-2-7b-hf/tree/main
[4]https://huggingface.co/spaces/ysharma/Explore_llamav2_with_TGI
[5]https://huggingface.co/meta-llama/Llama-2-70b-chat-hf
[6]https://huggingface.co/blog/llama2
[7]https://developer.nvidia.com/rdp/cudnn-download
[8]https://github.com/jllllll/bitsandbytes-windows-webui
[9]https://github.com/langchain-ai/langchain
[10]https://github.com/AtomEcho/AtomBulb
[11]https://github.com/huggingface/peft
[12]全参数微调时,报没有target_modules变量:https://github.com/FlagAlpha/Llama2-Chinese/issues/169
[13]https://huggingface.co/FlagAlpha
[14]Win10安装DeepSpeed:https://zhuanlan.zhihu.com/p/636450918

SQL查询中的小技巧:SELECT 1 和 LIMIT 1 替代 count(*) - 程序员济癫 - 博客园

mikel阅读(337)

来源: SQL查询中的小技巧:SELECT 1 和 LIMIT 1 替代 count(*) – 程序员济癫 – 博客园

前言

在写SQL查询时,常规做法是使用SELECT count(*)来统计符合条件的记录数。

然而,在某些情况下,我们只关心是否存在符合条件的记录,而不需要知道具体的记录数。

为了优化性能,可以改用使用SELECT 1LIMIT 1的方式查询。

在业务代码中,直接判断查询结果是否非空即可,不再需要使用count来获取记录数。

实战

我们使用Java和MyBatis演示优化方案的代码示例。

假设我们有一个名为User的数据库表,其中包含idnameage字段。我们想要检查是否存在年龄大于等于18岁的用户。

  1. 创建一个UserMapper接口,定义一个方法来执行查询操作:
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface UserMapper {
    Integer existUsersWithAgeGreaterThan(int age);
}

  1. UserMapper.xml中实现这个方法,使用优化的SQL语句:
<?xml version="1.0" encoding="UTF-8"?>
<mapper namespace="com.example.mapper.UserMapper">

    <select id="existUsersWithAgeGreaterThan" resultType="java.lang.Integer">
        SELECT 1 FROM users WHERE age >= #{age} LIMIT 1
    </select>

</mapper>

  1. 然后,在业务代码中调用existUsersWithAgeGreaterThan方法进行判断:
import com.example.mapper.UserMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class UserService {

    private final UserMapper userMapper;

    @Autowired
    public UserService(UserMapper userMapper) {
        this.userMapper = userMapper;
    }

    public void checkUsersWithAgeGreaterThan(int age) {
        Integer exist = userMapper.existUsersWithAgeGreaterThan(age);
        if (exist != null) {
            // 当存在满足条件的用户时,执行这里的代码
            System.out.println("存在符合条件的用户");
        } else {
            // 当不存在满足条件的用户时,执行这里的代码
            System.out.println("不存在符合条件的用户");
        }
    }
}

通过调用existUsersWithAgeGreaterThan方法获取查询结果,并根据结果是否为空来判断是否存在满足条件的用户。

优化

既然讲到这里,也就顺便提一下数据库查询的一般性能调优做法,在数据量十分庞大的情况下,这里给出几点建议:

  1. 索引优化:确保在查询条件列上存在适当的索引,以加快查询速度。比如当前的示例,可以在age列上创建索引;
  2. 分页查询:如果只关心是否存在满足条件的记录,但不需要具体的记录内容,可以考虑使用分页查询的方式进行优化。通过限制返回结果的数量,可以减少查询的开销;
  3. 缓存机制:如果查询结果相对稳定或者经常被重复查询,可以考虑使用缓存来避免重复的数据库查询操作,从而提高响应速度,比如把前几页缓存下来,因为很多用户可能不会一直往后点;
  4. 数据库调优:对数据库进行性能调优,包括优化查询计划、调整内存设置、合理配置数据库连接池等,可以提升整体查询性能。

总结

其实案例中的优化方案在查询结果集较大时已经非常有效,可以说是肉眼可见的性能提升,在某些情况下还可以减少联合索引的创建。

总而言之,通过使用SELECT 1LIMIT 1代替SELECT count(*),可以提高查询性能并简化业务代码,特别适用于仅需判断是否存在符合条件的记录的场景。

好了,今天这个小知识,你学会了吗?

每日一练:无感刷新页面(附可运行的前后端源码,前端vue,后端node) - 菜鸟小何 - 博客园

mikel阅读(311)

来源: 每日一练:无感刷新页面(附可运行的前后端源码,前端vue,后端node) – 菜鸟小何 – 博客园

1、前言

想象下,你正常在网页上浏览页面。突然弹出一个窗口,告诉你登录失效,跳回了登录页面,让你重新登录。你是不是很恼火。这时候无感刷新的作用就体现出来了。

2、方案

2.1 redis设置过期时间

在最新的技术当中,token一般都是在Redis服务器存着,设置过期时间。只要在有效时间内,重新发出请求,Redis中的过期时间会去更新,这样前只需要一个token。这个方案一般是后端做。

2.2 双token模式

2.21 原理

  • 用户登录向服务端发送账号密码,登录失败返回客户端重新登录。登录成功服务端生成 accessToken 和 refreshToken,返回生成的 token 给客户端。
  • 在请求拦截器中,请求头中携带 accessToken 请求数据,服务端验证 accessToken 是否过期。token 有效继续请求数据,token 失效返回失效信息到客户端。
  • 客户端收到服务端发送的请求信息,在二次封装的 axios 的响应拦截器中判断是否有 accessToken 失效的信息,没有返回响应的数据。有失效的信息,就携带 refreshToken 请求新的 accessToken。
  • 服务端验证 refreshToken 是否有效。有效,重新生成 token, 返回新的 token 和提示信息到客户端,无效,返回无效信息给客户端。
  • 客户端响应拦截器判断响应信息是否有 refreshToken 有效无效。无效,退出当前登录。有效,重新存储新的 token,继续请求上一次请求的数据。

2.22 上代码

后端:node.js、koa2服务器、jwt、koa-cors等(可使用koa脚手架创建项目,本项目基于koa脚手架创建。完整代码可见文章末尾github地址)

  1. 新建utils/token.js (双token)
const jwt=require('jsonwebtoken')

const secret='2023F_Ycb/wp_sd'  // 密钥
/*
expiresIn:5 过期时间,时间单位是秒
也可以这么写 expiresIn:1d 代表一天
1h 代表一小时
*/
// 本次是为了测试,所以设置时间 短token5秒 长token15秒
const accessTokenTime=5
const refreshTokenTime=15

// 生成accessToken
const accessToken=(payload={})=>{  // payload 携带用户信息
    return jwt.sign(payload,secret,{expiresIn:accessTokenTime})
}
//生成refreshToken
const refreshToken=(payload={})=>{
    return jwt.sign(payload,secret,{expiresIn:refreshTokenTime})
}

module.exports={
    secret,
    accessToken,
    refreshToken
}
  1. router/index.js 创建路由接口
const router = require('koa-router')()
const jwt = require('jsonwebtoken')
const { accessToken, refreshToken, secret }=require('../utils/token')
router.get('/', async (ctx, next) => {
  await ctx.render('index', {
    title: 'Hello Koa 2!'
  })
})

router.get('/string', async (ctx, next) => {
  ctx.body = 'koa2 string'
})

router.get('/json', async (ctx, next) => {
  ctx.body = {
    title: 'koa2 json'
  }
})
/*登录接口*/
router.get('/login',(ctx)=>{
  let code,msg,data=null
  code=2000
  msg='登录成功,获取到token'
  data={
    accessToken:accessToken(),
    refreshToken:refreshToken()
  }
  ctx.body={
    code,
    msg,
    data
  }
})

/*用于测试的获取数据接口*/
router.get('/getTestData',(ctx)=>{
  let code,msg,data=null
  code=2000
  msg='获取数据成功'
  ctx.body={
    code,
    msg,
    data
  }
})

/*验证长token是否有效,刷新短token
  这里要注意,在刷新短token的时候回也返回新的长token,延续长token,
  这样活跃用户在持续操作过程中不会被迫退出登录。长时间无操作的非活
  跃用户长token过期重新登录
*/
router.get('/refresh',(ctx)=>{

  let code,msg,data=null
  //获取请求头中携带的长token
  let r_tk=ctx.request.headers['pass']
  //解析token 参数 token 密钥 回调函数返回信息
  jwt.verify(r_tk,secret,(error)=>{
    if(error){
      code=4006,
      msg='长token无效,请重新登录'
    }
    else{
      code = 2000,
      msg = '长token有效,返回新的token'
      data = {
        accessToken: accessToken(),
        refreshToken: refreshToken()
      }
    }
    ctx.body={
      code,
      msg:msg?msg:null,
      data
    }
  })
})



module.exports = router

3.新建utils/auth.js (中间件)

const { secret } = require('./token')
const jwt = require('jsonwebtoken')

/*白名单,登录、刷新短token不受限制,也就不用token验证*/
const whiteList=['/login','/refresh']
const isWhiteList=(url,whiteList)=>{
    return whiteList.find(item => item === url) ? true : false
}

/*中间件
 验证短token是否有效
*/
const auth = async (ctx,next)=>{
    let code, msg, data = null
    let url = ctx.path
    if(isWhiteList(url,whiteList)){
        // 执行下一步
        return await next()
    } else {
        // 获取请求头携带的短token
        const a_tk=ctx.request.headers['authorization']
        if(!a_tk){
            code=4003
            msg='accessToken无效,无权限'
            ctx.body={
                code,
                msg,
                data
            }
        } else{
            // 解析token
            await jwt.verify(a_tk,secret,async (error)=>{
                if(error){
                    code=4003
                    msg='accessToken无效,无权限'
                    ctx.body={
                        code,
                        msg,
                        data
                    }
                } else {
                    // token有效
                    return await next()
                }
            })
        }
    }
}
module.exports=auth
  1. app.js
const Koa = require('koa')
const app = new Koa()
const views = require('koa-views')
const json = require('koa-json')
const onerror = require('koa-onerror')
const bodyparser = require('koa-bodyparser')
const logger = require('koa-logger')
const cors=require('koa-cors')

const index = require('./routes/index')
const users = require('./routes/users')
const auth=require('./utils/auth')

// error handler
onerror(app)

// middlewares
app.use(bodyparser({
  enableTypes:['json', 'form', 'text']
}))
app.use(json())
app.use(logger())
app.use(require('koa-static')(__dirname + '/public'))
app.use(cors())
app.use(auth)

app.use(views(__dirname + '/views', {
  extension: 'pug'
}))

// logger
app.use(async (ctx, next) => {
  const start = new Date()
  await next()
  const ms = new Date() - start
  console.log(`${ctx.method} ${ctx.url} - ${ms}ms`)
})

// routes
app.use(index.routes(), index.allowedMethods())
app.use(users.routes(), users.allowedMethods())

// error-handling
app.on('error', (err, ctx) => {
  console.error('server error', err, ctx)
});

module.exports = app

前端:vite、vue3、axios等 (完整代码可见文章末尾github地址)

  1. 新建config/constants.js
export const ACCESS_TOKEN = 'a_tk' // 短token字段
export const REFRESH_TOKEN = 'r_tk' // 短token字段
export const AUTH = 'Authorization'  // header头部 携带短token
export const PASS = 'pass' // header头部 携带长token
  1. 新建config/storage.js
import * as constants from "./constants"

// 存储短token
export const setAccessToken = (token) => localStorage.setItem(constants.ACCESS_TOKEN, token)
// 存储长token
export const setRefreshToken = (token) => localStorage.setItem(constants.REFRESH_TOKEN, token)
// 获取短token
export const getAccessToken = () => localStorage.getItem(constants.ACCESS_TOKEN)
// 获取长token
export const getRefreshToken = () => localStorage.getItem(constants.REFRESH_TOKEN)
// 删除短token
export const removeAccessToken = () => localStorage.removeItem(constants.ACCESS_TOKEN)
// 删除长token
export const removeRefreshToken = () => localStorage.removeItem(constants.REFRESH_TOKEN)

3.新建utils/refresh.js

export {REFRESH_TOKEN,PASS} from '../config/constants.js'
import { getRefreshToken, removeRefreshToken, setAccessToken, setRefreshToken} from '../config/storage'
import server from "./server";

let subscribes=[]
let flag=false // 设置开关,保证一次只能请求一次短token,防止客户多此操作,多次请求

/*把过期请求添加在数组中*/
export const addRequest = (request) => {
    subscribes.push(request)
}

/*调用过期请求*/
export const retryRequest = () => {
    console.log('重新请求上次中断的数据');
    subscribes.forEach(request => request())
    subscribes = []
}

/*短token过期,携带token去重新请求token*/
export const refreshToken=()=>{
    console.log('flag--',flag)
    if(!flag){
        flag = true;
        let r_tk = getRefreshToken() // 获取长token
        if(r_tk){
            server.get('/refresh',Object.assign({},{
                headers:{PASS : r_tk}
            })).then((res)=>{
                //长token失效,退出登录
                if(res.code===4006){
                    flag = false
                    removeRefreshToken(REFRESH_TOKEN)
                } else if(res.code===2000){
                    // 存储新的token
                    setAccessToken(res.data.accessToken)
                    setRefreshToken(res.data.refreshToken)
                    flag = false
                    // 重新请求数据
                    retryRequest()
                }
            })
        }
    }
}

4.新建utils/server.js

import axios from "axios";
import * as storage from "../config/storage"
import * as constants from '../config/constants'
import { addRequest, refreshToken } from "./refresh";

const server = axios.create({
    baseURL: 'http://localhost:3000', // 你的服务器
    timeout: 1000 * 10,
    headers: {
        "Content-type": "application/json"
    }
})

/*请求拦截器*/
server.interceptors.request.use(config => {
    // 获取短token,携带到请求头,服务端校验
    let aToken = storage.getAccessToken(constants.ACCESS_TOKEN)
    config.headers[constants.AUTH] = aToken
    return config
})

/*响应拦截器*/
server.interceptors.response.use(
    async response => {
        // 获取到配置和后端响应的数据
        let { config, data } = response
        console.log('响应提示信息:', data.msg);
        return new Promise((resolve, reject) => {
            // 短token失效
            if (data.code === 4003) {
                // 移除失效的短token
                storage.removeAccessToken(constants.ACCESS_TOKEN)
                // 把过期请求存储起来,用于请求到新的短token,再次请求,达到无感刷新
                addRequest(() => resolve(server(config)))
                // 携带长token去请求新的token
                refreshToken()
            } else {
                // 有效返回相应的数据
                resolve(data)
            }

        })

    },
    error => {
        return Promise.reject(error)
    }
)
export default  server

5.新建apis/index.js

import server from "../utils/server.js";
/*登录*/
export const login = () => {
    return server({
        url: '/login',
        method: 'get'
    })
}
/*请求数据*/
export const getList = () => {
    return server({
        url: '/getTestData',
        method: 'get'
    })
}

6.修改App.vue

<script setup>
  import {login,getList} from "./apis";
  import {setAccessToken,setRefreshToken} from "./config/storage";
  const getToken=()=>{
    login().then(res=>{
      setAccessToken(res.data.accessToken)
      setRefreshToken(res.data.refreshToken)
    })
  }
  const getData = ()=>{
    getList()
  }
</script>

<template>
    <button @click="getToken">登录</button>
    <button @click="getData">请求数据</button>

</template>

<style scoped>
.logo {
  height: 6em;
  padding: 1.5em;
  will-change: filter;
  transition: filter 300ms;
}
.logo:hover {
  filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.vue:hover {
  filter: drop-shadow(0 0 2em #42b883aa);
}
</style>

2.23 效果图

3、完整项目代码

3.1 地址

https://github.com/heyu3913/doubleToken

3.2 运行

后端:

cd server
pnpm i
pnpm start

前端

cd my-vue-app
pnpm i
pnpm dev

4 PS: 这里附送大家一个免费的gpt地址,自己搭的,不收费。注册即用:

https://www.hangyejingling.cn

.NET Core 实现Excel的导入导出 - TomLucas - 博客园

mikel阅读(308)

来源: .NET Core 实现Excel的导入导出 – TomLucas – 博客园

 

前言

我们在日常开发中对Excel的操作可能会比较频繁,好多功能都会涉及到Excel的操作。在.Net Core中大家可能使用Npoi比较多,这款软件功能也十分强大,而且接近原始编程。但是直接使用Npoi大部分时候我们可能都会自己封装一下,毕竟根据二八原则,我们百分之八十的场景可能都是进行简单的导入导出操作,这里就引出我们的主角Npoi。

NPOI简介

NPOI是指构建在POI 3.x版本之上的一个程序,NPOI可以在没有安装Office的情况下对Word或Excel文档进行读写操作。NPOI是一个开源的C#读写Excel、WORD等微软OLE2组件文档的项目。

一、安装相对应的程序包

在 .Net Core 中使用NPOI首先必须先安装NPOI;如下图所示:
右键项目,选择“管理 NuGet 程序包”菜单

1.1、在 “管理NuGet程序包” 中的浏览搜索:“NPOI”

相关安装包的安装
点击安装以上两个即可,安装完成之后最好重新编译一下项目以防出错

二、新建Excel帮助类

在项目中新建“ExcelHelper”类;此类用于封装导入导出以及其他配置方法。代码如下:

using System;
using System.Collections.Generic;
using System.Data;
using System.IO;
using NPOI;
using System.Text;
using NPOI.HSSF.UserModel;
using NPOI.XSSF.UserModel;
using NPOI.SS.Formula.Eval;
using NPOI.SS.UserModel;
using NPOI.SS.Util;
using System.Text.RegularExpressions;
using System.Reflection;
using System.Collections;
using NPOI.HSSF.Util;

namespace WebApplication1 //命名空间依据自己的项目进行修改
{
    /// <summary>
    /// Excel帮助类
    /// 功能:
    ///   1、导出数据到Excel文件中
    ///   2、将Excel文件的数据导入到List<T>对象集合中
    /// </summary>
    public static class ExcelHelper
    {
        /// <summary>
        /// 导出列名
        /// </summary>
        public static SortedList ListColumnsName;

        #region 从DataTable导出到excel文件中,支持xls和xlsx格式

        #region 导出为xls文件内部方法

        /// <summary>
        /// 从DataTable 中导出到excel
        /// </summary>
        /// <param name="strFileName">excel文件名</param>
        /// <param name="dtSource">datatabe源数据</param>
        /// <param name="strHeaderText">表名</param>
        /// <param name="sheetnum">sheet的编号</param>
        /// <returns></returns>
        static MemoryStream ExportDT(string strFileName, DataTable dtSource, string strHeaderText, Dictionary<string, string> dir, int sheetnum)
        {
            //创建工作簿和sheet
            IWorkbook workbook = new HSSFWorkbook();
            using (Stream writefile = new FileStream(strFileName, FileMode.OpenOrCreate, FileAccess.Read))
            {
                if (writefile.Length > 0 && sheetnum > 0)
                {
                    workbook = WorkbookFactory.Create(writefile);
                }
            }
            ISheet sheet = null;
            ICellStyle dateStyle = workbook.CreateCellStyle();
            IDataFormat format = workbook.CreateDataFormat();
            dateStyle.DataFormat = format.GetFormat("yyyy-mm-dd");
            int[] arrColWidth = new int[dtSource.Columns.Count];
            foreach (DataColumn item in dtSource.Columns)
            {
                arrColWidth[item.Ordinal] = Encoding.GetEncoding(936).GetBytes(Convert.ToString(item.ColumnName)).Length;
            }
            for (int i = 0; i < dtSource.Rows.Count; i++)
            {
                for (int j = 0; j < dtSource.Columns.Count; j++)
                {
                    int intTemp = Encoding.GetEncoding(936).GetBytes(Convert.ToString(dtSource.Rows[i][j])).Length;
                    if (intTemp > arrColWidth[j])
                    {
                        arrColWidth[j] = intTemp;
                    }
                }
            }
            int rowIndex = 0;
            foreach (DataRow row in dtSource.Rows)
            {
                #region 新建表,填充表头,填充列头,样式
                if (rowIndex == 0)
                {
                    string sheetName = strHeaderText + (sheetnum == 0 ? "" : sheetnum.ToString());
                    if (workbook.GetSheetIndex(sheetName) >= 0)
                    {
                        workbook.RemoveSheetAt(workbook.GetSheetIndex(sheetName));
                    }
                    sheet = workbook.CreateSheet(sheetName);
                    #region 表头及样式
                    {
                        sheet.AddMergedRegion(new CellRangeAddress(0, 0, 0, dtSource.Columns.Count - 1));
                        IRow headerRow = sheet.CreateRow(0);
                        headerRow.HeightInPoints = 25;
                        headerRow.CreateCell(0).SetCellValue(strHeaderText);
                        ICellStyle headStyle = workbook.CreateCellStyle();
                        headStyle.Alignment = HorizontalAlignment.Center;
                        IFont font = workbook.CreateFont();
                        font.FontHeightInPoints = 20;
                        font.Boldweight = 700;
                        headStyle.SetFont(font);
                        headerRow.GetCell(0).CellStyle = headStyle;

                        rowIndex = 1;
                    }
                    #endregion

                    #region 列头及样式

                    if (rowIndex == 1)
                    {
                        IRow headerRow = sheet.CreateRow(1);//第二行设置列名
                        ICellStyle headStyle = workbook.CreateCellStyle();
                        headStyle.Alignment = HorizontalAlignment.Center;
                        IFont font = workbook.CreateFont();
                        font.FontHeightInPoints = 10;
                        font.Boldweight = 700;
                        headStyle.SetFont(font);
                        //写入列标题
                        foreach (DataColumn column in dtSource.Columns)
                        {
                            headerRow.CreateCell(column.Ordinal).SetCellValue(dir[column.ColumnName]);
                            headerRow.GetCell(column.Ordinal).CellStyle = headStyle;
                            //设置列宽
                            sheet.SetColumnWidth(column.Ordinal, (arrColWidth[column.Ordinal] + 1) * 256 * 2);
                        }
                        rowIndex = 2;
                    }
                    #endregion
                }
                #endregion

                #region 填充内容

                IRow dataRow = sheet.CreateRow(rowIndex);
                foreach (DataColumn column in dtSource.Columns)
                {
                    NPOI.SS.UserModel.ICell newCell = dataRow.CreateCell(column.Ordinal);
                    string drValue = row[column].ToString();
                    switch (column.DataType.ToString())
                    {
                        case "System.String": //字符串类型
                            double result;
                            if (isNumeric(drValue, out result))
                            {
                                //数字字符串
                                double.TryParse(drValue, out result);
                                newCell.SetCellValue(result);
                                break;
                            }
                            else
                            {
                                newCell.SetCellValue(drValue);
                                break;
                            }

                        case "System.DateTime": //日期类型
                            DateTime dateV;
                            DateTime.TryParse(drValue, out dateV);
                            newCell.SetCellValue(dateV);

                            newCell.CellStyle = dateStyle; //格式化显示
                            break;
                        case "System.Boolean": //布尔型
                            bool boolV = false;
                            bool.TryParse(drValue, out boolV);
                            newCell.SetCellValue(boolV);
                            break;
                        case "System.Int16": //整型
                        case "System.Int32":
                        case "System.Int64":
                        case "System.Byte":
                            int intV = 0;
                            int.TryParse(drValue, out intV);
                            newCell.SetCellValue(intV);
                            break;
                        case "System.Decimal": //浮点型
                        case "System.Double":
                            double doubV = 0;
                            double.TryParse(drValue, out doubV);
                            newCell.SetCellValue(doubV);
                            break;
                        case "System.DBNull": //空值处理
                            newCell.SetCellValue("");
                            break;
                        default:
                            newCell.SetCellValue(drValue.ToString());
                            break;
                    }
                }
                #endregion
                rowIndex++;
            }
            using (MemoryStream ms = new MemoryStream())
            {
                workbook.Write(ms, true);
                ms.Flush();
                ms.Position = 0;
                return ms;
            }
        }

        #endregion

        #region 导出为xlsx文件内部方法

        /// <summary>
        /// 从DataTable 中导出到excel
        /// </summary>
        /// <param name="dtSource">DataTable数据源</param>
        /// <param name="strHeaderText">表名</param>
        /// <param name="fs">文件流</param>
        /// <param name="readfs">内存流</param>
        /// <param name="sheetnum">sheet索引</param>
        static void ExportDTI(DataTable dtSource, string strHeaderText, FileStream fs, MemoryStream readfs, Dictionary<string, string> dir, int sheetnum)
        {
            IWorkbook workbook = new XSSFWorkbook();
            if (readfs.Length > 0 && sheetnum > 0)
            {
                workbook = WorkbookFactory.Create(readfs);
            }
            ISheet sheet = null;
            ICellStyle dateStyle = workbook.CreateCellStyle();
            IDataFormat format = workbook.CreateDataFormat();
            dateStyle.DataFormat = format.GetFormat("yyyy-mm-dd");

            //取得列宽
            int[] arrColWidth = new int[dtSource.Columns.Count];
            foreach (DataColumn item in dtSource.Columns)
            {
                arrColWidth[item.Ordinal] = Encoding.GetEncoding(936).GetBytes(Convert.ToString(item.ColumnName)).Length;
            }
            for (int i = 0; i < dtSource.Rows.Count; i++)
            {
                for (int j = 0; j < dtSource.Columns.Count; j++)
                {
                    int intTemp = Encoding.GetEncoding(936).GetBytes(Convert.ToString(dtSource.Rows[i][j])).Length;
                    if (intTemp > arrColWidth[j])
                    {
                        arrColWidth[j] = intTemp;
                    }
                }
            }
            int rowIndex = 0;

            foreach (DataRow row in dtSource.Rows)
            {
                #region 新建表,填充表头,填充列头,样式

                if (rowIndex == 0)
                {
                    #region 表头及样式
                    {
                        string sheetName = strHeaderText + (sheetnum == 0 ? "" : sheetnum.ToString());
                        if (workbook.GetSheetIndex(sheetName) >= 0)
                        {
                            workbook.RemoveSheetAt(workbook.GetSheetIndex(sheetName));
                        }
                        sheet = workbook.CreateSheet(sheetName);
                        sheet.AddMergedRegion(new CellRangeAddress(0, 0, 0, dtSource.Columns.Count - 1));
                        IRow headerRow = sheet.CreateRow(0);
                        headerRow.HeightInPoints = 25;
                        headerRow.CreateCell(0).SetCellValue(strHeaderText);

                        ICellStyle headStyle = workbook.CreateCellStyle();
                        headStyle.Alignment = HorizontalAlignment.Center;
                        IFont font = workbook.CreateFont();
                        font.FontHeightInPoints = 20;
                        font.Boldweight = 700;
                        headStyle.SetFont(font);
                        headerRow.GetCell(0).CellStyle = headStyle;
                    }
                    #endregion

                    #region 列头及样式
                    {
                        IRow headerRow = sheet.CreateRow(1);
                        ICellStyle headStyle = workbook.CreateCellStyle();
                        headStyle.Alignment = HorizontalAlignment.Center;
                        IFont font = workbook.CreateFont();
                        font.FontHeightInPoints = 10;
                        font.Boldweight = 700;
                        headStyle.SetFont(font);
                        foreach (DataColumn column in dtSource.Columns)
                        {
                            headerRow.CreateCell(column.Ordinal).SetCellValue(dir[column.ColumnName]);
                            headerRow.GetCell(column.Ordinal).CellStyle = headStyle;
                            //设置列宽
                            sheet.SetColumnWidth(column.Ordinal, (arrColWidth[column.Ordinal] + 1) * 256 * 2);
                        }
                    }

                    #endregion

                    rowIndex = 2;
                }
                #endregion

                #region 填充内容
                IRow dataRow = sheet.CreateRow(rowIndex);
                foreach (DataColumn column in dtSource.Columns)
                {
                    NPOI.SS.UserModel.ICell newCell = dataRow.CreateCell(column.Ordinal);
                    string drValue = row[column].ToString();
                    switch (column.DataType.ToString())
                    {
                        case "System.String": //字符串类型
                            double result;
                            if (isNumeric(drValue, out result))
                            {
                                double.TryParse(drValue, out result);
                                newCell.SetCellValue(result);
                                break;
                            }
                            else
                            {
                                newCell.SetCellValue(drValue);
                                break;
                            }
                        case "System.DateTime": //日期类型
                            DateTime dateV;
                            DateTime.TryParse(drValue, out dateV);
                            newCell.SetCellValue(dateV);

                            newCell.CellStyle = dateStyle; //格式化显示
                            break;
                        case "System.Boolean": //布尔型
                            bool boolV = false;
                            bool.TryParse(drValue, out boolV);
                            newCell.SetCellValue(boolV);
                            break;
                        case "System.Int16": //整型
                        case "System.Int32":
                        case "System.Int64":
                        case "System.Byte":
                            int intV = 0;
                            int.TryParse(drValue, out intV);
                            newCell.SetCellValue(intV);
                            break;
                        case "System.Decimal": //浮点型
                        case "System.Double":
                            double doubV = 0;
                            double.TryParse(drValue, out doubV);
                            newCell.SetCellValue(doubV);
                            break;
                        case "System.DBNull": //空值处理
                            newCell.SetCellValue("");
                            break;
                        default:
                            newCell.SetCellValue(drValue.ToString());
                            break;
                    }
                }
                #endregion
                rowIndex++;
            }
            workbook.Write(fs,true);
            fs.Close();
        }

        #endregion

        #region 导出excel表格

        /// <summary>
        ///  DataTable导出到Excel文件,xls文件
        /// </summary>
        /// <param name="dtSource">数据源</param>
        /// <param name="strHeaderText">表名</param>
        /// <param name="strFileName">excel文件名</param>
        /// <param name="dir">DataTable和excel列名对应字典</param>
        /// <param name="sheetRow">每个sheet存放的行数</param>
        public static void ExportDTtoExcel(DataTable dtSource, string strHeaderText, string strFileName, Dictionary<string, string> dir, bool isNew, int sheetRow = 50000)
        {
            int currentSheetCount = GetSheetNumber(strFileName);//现有的页数sheetnum
            if (sheetRow <= 0)
            {
                sheetRow = dtSource.Rows.Count;
            }
            string[] temp = strFileName.Split('.');
            string fileExtens = temp[temp.Length - 1];
            int sheetCount = (int)Math.Ceiling((double)dtSource.Rows.Count / sheetRow);//sheet数目
            if (temp[temp.Length - 1] == "xls" && dtSource.Columns.Count < 256 && sheetRow < 65536)
            {
                if (isNew)
                {
                    currentSheetCount = 0;
                }
                for (int i = currentSheetCount; i < currentSheetCount + sheetCount; i++)
                {
                    DataTable pageDataTable = dtSource.Clone();
                    int hasRowCount = dtSource.Rows.Count - sheetRow * (i - currentSheetCount) < sheetRow ? dtSource.Rows.Count - sheetRow * (i - currentSheetCount) : sheetRow;
                    for (int j = 0; j < hasRowCount; j++)
                    {
                        pageDataTable.ImportRow(dtSource.Rows[(i - currentSheetCount) * sheetRow + j]);
                    }

                    using (MemoryStream ms = ExportDT(strFileName, pageDataTable, strHeaderText, dir, i))
                    {
                        using (FileStream fs = new FileStream(strFileName, FileMode.Create, FileAccess.Write))
                        {

                            byte[] data = ms.ToArray();
                            fs.Write(data, 0, data.Length);
                            fs.Flush();
                        }
                    }
                }
            }
            else
            {
                if (temp[temp.Length - 1] == "xls")
                    strFileName = strFileName + "x";
                if (isNew)
                {
                    currentSheetCount = 0;
                }
                for (int i = currentSheetCount; i < currentSheetCount + sheetCount; i++)
                {
                    DataTable pageDataTable = dtSource.Clone();
                    int hasRowCount = dtSource.Rows.Count - sheetRow * (i - currentSheetCount) < sheetRow ? dtSource.Rows.Count - sheetRow * (i - currentSheetCount) : sheetRow;
                    for (int j = 0; j < hasRowCount; j++)
                    {
                        pageDataTable.ImportRow(dtSource.Rows[(i - currentSheetCount) * sheetRow + j]);
                    }
                    FileStream readfs = new FileStream(strFileName, FileMode.OpenOrCreate, FileAccess.Read);
                    MemoryStream readfsm = new MemoryStream();
                    readfs.CopyTo(readfsm);
                    readfs.Close();
                    using (FileStream writefs = new FileStream(strFileName, FileMode.Create, FileAccess.Write))
                    {

                        ExportDTI(pageDataTable, strHeaderText, writefs, readfsm, dir, i);
                    }
                    readfsm.Close();
                }
            }
        }

        /// <summary>
        /// 导出Excel(//超出10000条数据 创建新的工作簿)
        /// </summary>
        /// <param name="dtSource">数据源</param>
        /// <param name="dir">导出Excel表格的字段名和列名的字符串字典实例;例如:dir.Add("IllegalKeywords", "姓名");</param>
        public static XSSFWorkbook ExportExcel(DataTable dtSource, Dictionary<string, string> dir)
        {
            XSSFWorkbook excelWorkbook = new XSSFWorkbook();

            //int columnsCount = columnsNames.GetLength(0);
            int columnsCount = dir.Count;
            if (columnsCount > 0)
            {
                ListColumnsName = new SortedList(new NoSort());
                //for (int i = 0; i < columnsCount; i++)
                //{
                //    ListColumnsName.Add(columnsNames[i, 0], columnsNames[i, 1]);
                //}
                foreach (KeyValuePair<string,string> item in dir)
                {
                    ListColumnsName.Add(item.Key, item.Value);
                }
                if (ListColumnsName == null || ListColumnsName.Count == 0)
                {
                    throw (new Exception("请对ListColumnsName设置要导出的列明!"));
                }
                else
                {
                    excelWorkbook = InsertRow(dtSource);
                }
            }
            else
            {
                throw (new Exception("请对ListColumnsName设置要导出的列明!"));
            }
            return excelWorkbook;
        }

        #endregion

        /// <summary>
        /// 创建Excel文件
        /// </summary>
        /// <param name="filePath"></param>
        private static XSSFWorkbook CreateExcelFile()
        {
            XSSFWorkbook xssfworkbook = new XSSFWorkbook();

            //右击文件“属性”信息
            #region 文件属性信息
            {
                POIXMLProperties props = xssfworkbook.GetProperties();
                props.CoreProperties.Creator = "Joy";//Excel文件的创建作者
                props.CoreProperties.Title = "";//Excel文件标题
                props.CoreProperties.Description = "";//Excel文件备注
                props.CoreProperties.Category = "";//Excel文件类别信息
                props.CoreProperties.Subject = "";//Excel文件主题信息
                props.CoreProperties.Created = DateTime.Now;//Excel文件创建时间
                props.CoreProperties.Modified = DateTime.Now;//Excel文件修改时间
                props.CoreProperties.SetCreated(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"));
                props.CoreProperties.LastModifiedByUser = "Joy";//Excel文件最后一次保存者
                props.CoreProperties.SetModified(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"));//Excel文件最后一次保存日期
            }
            #endregion

            return xssfworkbook;
        }

        /// <summary>
        /// 创建excel表头
        /// </summary>
        /// <param name="dgv"></param>
        /// <param name="excelSheet"></param>
        private static void CreateHeader(XSSFSheet excelSheet, XSSFWorkbook excelWorkbook, XSSFCellStyle cellStyle)
        {
            int cellIndex = 0;
            //循环导出列
            foreach (System.Collections.DictionaryEntry de in ListColumnsName)
            {
                XSSFRow newRow = (XSSFRow)excelSheet.CreateRow(0);
                XSSFCellStyle? headTopStyle = CreateStyle(excelWorkbook, cellStyle,HorizontalAlignment.Center, VerticalAlignment.Center, 18, true, true, "宋体", true, false, false, true, FillPattern.SolidForeground, HSSFColor.Grey25Percent.Index, HSSFColor.Black.Index,FontUnderlineType.None, FontSuperScript.None, false);
                XSSFCell newCell = (XSSFCell)newRow.CreateCell(cellIndex);
                newCell.SetCellValue(de.Value.ToString());
                newCell.CellStyle = headTopStyle;
                cellIndex++;
            }
        }

        /// <summary>
        /// 插入数据行
        /// </summary>
        private static XSSFWorkbook InsertRow(DataTable dtSource)
        {
            XSSFWorkbook excelWorkbook = CreateExcelFile();
            int rowCount = 0;
            int sheetCount = 1;
            XSSFSheet newsheet = null;

            //循环数据源导出数据集
            newsheet = (XSSFSheet)excelWorkbook.CreateSheet("Sheet" + sheetCount);
            XSSFCellStyle headCellStyle = (XSSFCellStyle)excelWorkbook.CreateCellStyle(); //创建列头单元格实例样式
            CreateHeader(newsheet, excelWorkbook, headCellStyle);
            //单元格内容信息
            foreach (DataRow dr in dtSource.Rows)
            {
                rowCount++;
                //超出10000条数据 创建新的工作簿
                if (rowCount == 10000)
                {
                    rowCount = 1;
                    sheetCount++;
                    newsheet = (XSSFSheet)excelWorkbook.CreateSheet("Sheet" + sheetCount);
                    CreateHeader(newsheet, excelWorkbook, headCellStyle);
                }
                XSSFRow newRow = (XSSFRow)newsheet.CreateRow(rowCount);
                XSSFCellStyle cellStyle = (XSSFCellStyle)excelWorkbook.CreateCellStyle(); //创建单元格实例样式
                XSSFCellStyle? style = CreateStyle(excelWorkbook, cellStyle, HorizontalAlignment.Center, VerticalAlignment.Center, 14, true, false);
                InsertCell(dtSource, dr, newRow, style, excelWorkbook);
            }
            //自动列宽
            //for (int i = 0; i <= dtSource.Columns.Count; i++)
            //{
            //    newsheet.AutoSizeColumn(i, true);
            //}
            return excelWorkbook;
        }

        /// <summary>
        /// 导出数据行
        /// </summary>
        /// <param name="dtSource"></param>
        /// <param name="drSource"></param>
        /// <param name="currentExcelRow"></param>
        /// <param name="excelSheet"></param>
        /// <param name="excelWorkBook"></param>
        private static void InsertCell(DataTable dtSource, DataRow drSource, XSSFRow currentExcelRow, XSSFCellStyle cellStyle, XSSFWorkbook excelWorkBook)
        {
            for (int cellIndex = 0; cellIndex < ListColumnsName.Count; cellIndex++)
            {
                //列名称
                string columnsName = ListColumnsName.GetKey(cellIndex).ToString();
                XSSFCell newCell = null;
                System.Type rowType = drSource[columnsName].GetType();
                string drValue = drSource[columnsName].ToString().Trim();
                switch (rowType.ToString())
                {
                    case "System.String"://字符串类型
                        drValue = drValue.Replace("&", "&");
                        drValue = drValue.Replace(">", ">");
                        drValue = drValue.Replace("<", "<");
                        newCell = (XSSFCell)currentExcelRow.CreateCell(cellIndex);
                        newCell.SetCellValue(drValue);
                        newCell.CellStyle = cellStyle;
                        break;
                    case "System.DateTime"://日期类型
                        DateTime dateV;
                        DateTime.TryParse(drValue, out dateV);
                        newCell = (XSSFCell)currentExcelRow.CreateCell(cellIndex);
                        newCell.SetCellValue(dateV);
                        newCell.CellStyle = cellStyle;
                        break;
                    case "System.Boolean"://布尔型
                        bool boolV = false;
                        bool.TryParse(drValue, out boolV);
                        newCell = (XSSFCell)currentExcelRow.CreateCell(cellIndex);
                        newCell.SetCellValue(boolV);
                        newCell.CellStyle = cellStyle;
                        break;
                    case "System.Int16"://整型
                    case "System.Int32":
                    case "System.Int64":
                    case "System.Byte":
                        int intV = 0;
                        int.TryParse(drValue, out intV);
                        newCell = (XSSFCell)currentExcelRow.CreateCell(cellIndex);
                        newCell.SetCellValue(intV.ToString());
                        newCell.CellStyle = cellStyle;
                        break;
                    case "System.Decimal"://浮点型
                    case "System.Double":
                        double doubV = 0;
                        double.TryParse(drValue, out doubV);
                        newCell = (XSSFCell)currentExcelRow.CreateCell(cellIndex);
                        newCell.SetCellValue(doubV);
                        newCell.CellStyle = cellStyle;
                        break;
                    case "System.DBNull"://空值处理
                        newCell = (XSSFCell)currentExcelRow.CreateCell(cellIndex);
                        newCell.SetCellValue("");
                        newCell.CellStyle = cellStyle;
                        break;
                    default:
                        throw (new Exception(rowType.ToString() + ":类型数据无法处理!"));
                }
            }
        }

        /// <summary>
        /// 行内单元格常用样式设置
        /// </summary>
        /// <param name="workbook">Excel文件对象</param>
        /// <param name="cellStyle">Excel文件中XSSFCellStyle对象</param>
        /// <param name="hAlignment">水平布局方式</param>
        /// <param name="vAlignment">垂直布局方式</param>
        /// <param name="fontHeightInPoints">字体大小</param>
        /// <param name="isAddBorder">是否需要边框</param>
        /// <param name="boldWeight">字体加粗 (None = 0,Normal = 400,Bold = 700</param>
        /// <param name="fontName">字体(仿宋,楷体,宋体,微软雅黑...与Excel主题字体相对应)</param>
        /// <param name="isAddBorderColor">是否增加边框颜色</param>
        /// <param name="isItalic">是否将文字变为斜体</param>
        /// <param name="isLineFeed">是否自动换行</param>
        /// <param name="isAddCellBackground">是否增加单元格背景颜色</param>
        /// <param name="fillPattern">填充图案样式(FineDots 细点,SolidForeground立体前景,isAddFillPattern=true时存在)</param>
        /// <param name="cellBackgroundColor">单元格背景颜色(当isAddCellBackground=true时存在)</param>
        /// <param name="fontColor">字体颜色</param>
        /// <param name="underlineStyle">下划线样式(无下划线[None],单下划线[Single],双下划线[Double],会计用单下划线[SingleAccounting],会计用双下划线[DoubleAccounting])</param>
        /// <param name="typeOffset">字体上标下标(普通默认值[None],上标[Sub],下标[Super]),即字体在单元格内的上下偏移量</param>
        /// <param name="isStrikeout">是否显示删除线</param>
        /// <param name="dataFormat">格式化日期显示</param>
        /// <returns></returns>
        public static XSSFCellStyle CreateStyle(XSSFWorkbook workbook, XSSFCellStyle cellStyle, HorizontalAlignment hAlignment, VerticalAlignment vAlignment, short fontHeightInPoints, bool isAddBorder, bool boldWeight, string fontName = "宋体", bool isAddBorderColor = true, bool isItalic = false, bool isLineFeed = true, bool isAddCellBackground = false, FillPattern fillPattern = FillPattern.NoFill, short cellBackgroundColor = HSSFColor.Yellow.Index, short fontColor = HSSFColor.Black.Index, FontUnderlineType underlineStyle =
            FontUnderlineType.None, FontSuperScript typeOffset = FontSuperScript.None, bool isStrikeout = false,string dataFormat="yyyy-MM-dd HH:mm:ss")
        {
            cellStyle.Alignment = hAlignment; //水平居中
            cellStyle.VerticalAlignment = vAlignment; //垂直居中
            cellStyle.WrapText = isLineFeed;//自动换行

            //格式化显示
            XSSFDataFormat format = (XSSFDataFormat)workbook.CreateDataFormat();
            cellStyle.DataFormat = format.GetFormat(dataFormat);

            //背景颜色,边框颜色,字体颜色都是使用 HSSFColor属性中的对应调色板索引,关于 HSSFColor 颜色索引对照表,详情参考:https://www.cnblogs.com/Brainpan/p/5804167.html

            //TODO:引用了NPOI后可通过ICellStyle 接口的 FillForegroundColor 属性实现 Excel 单元格的背景色设置,FillPattern 为单元格背景色的填充样式

            //TODO:十分注意,要设置单元格背景色必须是FillForegroundColor和FillPattern两个属性同时设置,否则是不会显示背景颜色
            if (isAddCellBackground)
            {
                cellStyle.FillForegroundColor = cellBackgroundColor;//单元格背景颜色
                cellStyle.FillPattern = fillPattern;//填充图案样式(FineDots 细点,SolidForeground立体前景)
            }
            else
            {
                cellStyle.FillForegroundColor = HSSFColor.White.Index;//单元格背景颜色
            }

            //是否增加边框
            if (isAddBorder)
            {
                //常用的边框样式 None(没有),Thin(细边框,瘦的),Medium(中等),Dashed(虚线),Dotted(星罗棋布的),Thick(厚的),Double(双倍),Hair(头发)[上右下左顺序设置]
                cellStyle.BorderBottom = BorderStyle.Thin;
                cellStyle.BorderRight = BorderStyle.Thin;
                cellStyle.BorderTop = BorderStyle.Thin;
                cellStyle.BorderLeft = BorderStyle.Thin;
            }

            //是否设置边框颜色
            if (isAddBorderColor)
            {
                //边框颜色[上右下左顺序设置]
                cellStyle.TopBorderColor = XSSFFont.DEFAULT_FONT_COLOR;//DarkGreen(黑绿色)
                cellStyle.RightBorderColor = XSSFFont.DEFAULT_FONT_COLOR;
                cellStyle.BottomBorderColor = XSSFFont.DEFAULT_FONT_COLOR;
                cellStyle.LeftBorderColor = XSSFFont.DEFAULT_FONT_COLOR;
            }

            /**
             * 设置相关字体样式
             */
            var cellStyleFont = (XSSFFont)workbook.CreateFont(); //创建字体

            //假如字体大小只需要是粗体的话直接使用下面该属性即可
            //cellStyleFont.IsBold = true;

            cellStyleFont.IsBold = boldWeight; //字体加粗
            cellStyleFont.FontHeightInPoints = fontHeightInPoints; //字体大小
            cellStyleFont.FontName = fontName;//字体(仿宋,楷体,宋体 )
            cellStyleFont.Color = fontColor;//设置字体颜色
            cellStyleFont.IsItalic = isItalic;//是否将文字变为斜体
            cellStyleFont.Underline = underlineStyle;//字体下划线
            cellStyleFont.TypeOffset = typeOffset;//字体上标下标
            cellStyleFont.IsStrikeout = isStrikeout;//是否有删除线

            cellStyle.SetFont(cellStyleFont); //将字体绑定到样式
            return cellStyle;
        }

        #endregion

        #region 从excel文件中将数据导出到List<T>对象集合

        /// <summary>
        /// 将制定sheet中的数据导出到DataTable中
        /// </summary>
        /// <param name="sheet">需要导出的sheet</param>
        /// <param name="HeaderRowIndex">列头所在行号,-1表示没有列头</param>
        /// <param name="dir">excel列名和DataTable列名的对应字典</param>
        /// <returns></returns>
        static DataTable ImportDt(ISheet sheet, int HeaderRowIndex, Dictionary<string, string> dir)
        {
            DataTable table = new DataTable();
            IRow headerRow;
            int cellCount;
            try
            {
                //没有标头或者不需要表头用excel列的序号(1,2,3..)作为DataTable的列名
                if (HeaderRowIndex < 0)
                {
                    headerRow = sheet.GetRow(0);
                    cellCount = headerRow.LastCellNum;

                    for (int i = headerRow.FirstCellNum; i <= cellCount; i++)
                    {
                        DataColumn column = new DataColumn(Convert.ToString(i));
                        table.Columns.Add(column);
                    }
                }
                //有表头,使用表头做为DataTable的列名
                else
                {
                    headerRow = sheet.GetRow(HeaderRowIndex);
                    cellCount = headerRow.LastCellNum;
                    for (int i = headerRow.FirstCellNum; i <cellCount; i++)
                    {
                        //如果excel某一列列名不存在:以该列的序号作为DataTable的列名,如果DataTable中包含了这个序列为名的列,那么列名为重复列名+序号
                        if (headerRow.GetCell(i) == null)
                        {
                            if (table.Columns.IndexOf(Convert.ToString(i)) > 0)
                            {
                                DataColumn column = new DataColumn(Convert.ToString("重复列名" + i));
                                table.Columns.Add(column);
                            }
                            else
                            {
                                DataColumn column = new DataColumn(Convert.ToString(i));
                                table.Columns.Add(column);
                            }

                        }
                        //excel中的某一列列名不为空,但是重复了:对应的DataTable列名为“重复列名+序号”
                        else if (table.Columns.IndexOf(headerRow.GetCell(i).ToString()) > 0)
                        {
                            DataColumn column = new DataColumn(Convert.ToString("重复列名" + i));
                            table.Columns.Add(column);
                        }
                        else
                        //正常情况,列名存在且不重复:用excel中的列名作为DataTable中对应的列名
                        {
                            string aaa = headerRow.GetCell(i).ToString();
                            string colName = dir.Where(s => s.Value == headerRow.GetCell(i).ToString()).First().Key;
                            DataColumn column = new DataColumn(colName);
                            table.Columns.Add(column);
                        }
                    }
                }
                int rowCount = sheet.LastRowNum;
                for (int i = (HeaderRowIndex + 1); i <= sheet.LastRowNum; i++)//excel行遍历
                {
                    try
                    {
                        IRow row;
                        if (sheet.GetRow(i) == null)//如果excel有空行,则添加缺失的行
                        {
                            row = sheet.CreateRow(i);
                        }
                        else
                        {
                            row = sheet.GetRow(i);
                        }

                        DataRow dataRow = table.NewRow();

                        for (int j = row.FirstCellNum; j <= cellCount; j++)//excel列遍历
                        {
                            try
                            {
                                if (row.GetCell(j) != null)
                                {
                                    switch (row.GetCell(j).CellType)
                                    {
                                        case CellType.String://字符串
                                            string str = row.GetCell(j).StringCellValue;
                                            if (str != null && str.Length > 0)
                                            {
                                                dataRow[j] = str.ToString();
                                            }
                                            else
                                            {
                                                dataRow[j] = default(string);
                                            }
                                            break;
                                        case CellType.Numeric://数字
                                            if (DateUtil.IsCellDateFormatted(row.GetCell(j)))//时间戳数字
                                            {
                                                dataRow[j] = DateTime.FromOADate(row.GetCell(j).NumericCellValue);
                                            }
                                            else
                                            {
                                                dataRow[j] = Convert.ToDouble(row.GetCell(j).NumericCellValue);
                                            }
                                            break;
                                        case CellType.Boolean:
                                            dataRow[j] = Convert.ToString(row.GetCell(j).BooleanCellValue);
                                            break;
                                        case CellType.Error:
                                            dataRow[j] = ErrorEval.GetText(row.GetCell(j).ErrorCellValue);
                                            break;
                                        case CellType.Formula://公式
                                            switch (row.GetCell(j).CachedFormulaResultType)
                                            {
                                                case CellType.String:
                                                    string strFORMULA = row.GetCell(j).StringCellValue;
                                                    if (strFORMULA != null && strFORMULA.Length > 0)
                                                    {
                                                        dataRow[j] = strFORMULA.ToString();
                                                    }
                                                    else
                                                    {
                                                        dataRow[j] = null;
                                                    }
                                                    break;
                                                case CellType.Numeric:
                                                    dataRow[j] = Convert.ToString(row.GetCell(j).NumericCellValue);
                                                    break;
                                                case CellType.Boolean:
                                                    dataRow[j] = Convert.ToString(row.GetCell(j).BooleanCellValue);
                                                    break;
                                                case CellType.Error:
                                                    dataRow[j] = ErrorEval.GetText(row.GetCell(j).ErrorCellValue);
                                                    break;
                                                default:
                                                    dataRow[j] = "";
                                                    break;
                                            }
                                            break;
                                        default:
                                            dataRow[j] = "";
                                            break;
                                    }
                                }
                            }
                            catch (Exception exception)
                            {
                                //loger.Error(exception.ToString());
                            }
                        }
                        table.Rows.Add(dataRow);
                    }
                    catch (Exception exception)
                    {
                        //loger.Error(exception.ToString());
                    }
                }
            }
            catch (Exception exception)
            {
                //loger.Error(exception.ToString());
            }
            return table;
        }

        /// <summary>
        /// DataTable 转换为List<T>对象集合
        /// </summary>
        /// <typeparam name="TResult">类型</typeparam>
        /// <param name="dt">DataTable</param>
        /// <returns></returns>
        public static List<TResult> DataTableToList<TResult>(this DataTable dt) where TResult : class, new()
        {
            //创建一个属性的列表
            List<PropertyInfo> prlist = new List<PropertyInfo>();
            //获取TResult的类型实例  反射的入口
            Type t = typeof(TResult);
            //获得TResult 的所有的Public 属性 并找出TResult属性和DataTable的列名称相同的属性(PropertyInfo) 并加入到属性列表
            Array.ForEach<PropertyInfo>(t.GetProperties(), p => { if (dt.Columns.IndexOf(p.Name) != -1) prlist.Add(p); });
            //创建返回的集合
            List<TResult> oblist = new List<TResult>();
            foreach (DataRow row in dt.Rows)
            {
                //创建TResult的实例
                TResult ob = new TResult();
                //找到对应的数据  并赋值
                prlist.ForEach(p => { if (row[p.Name] != DBNull.Value) p.SetValue(ob, row[p.Name], null); });
                //放入到返回的集合中.
                oblist.Add(ob);
            }
            return oblist;
        }

        /// <summary>
        /// DataTable转化为List集合
        /// </summary>
        /// <typeparam name="T">实体对象</typeparam>
        /// <param name="dt">datatable表</param>
        /// <param name="isStoreDB">是否存入数据库datetime字段,date字段没事,取出不用判断</param>
        /// <returns>返回list集合</returns>
        private static List<T> DataTableToList<T>(DataTable dt, bool isStoreDB = true)
        {
            List<T> list = new List<T>();
            Type type = typeof(T);
            //List<string> listColums = new List<string>();
            PropertyInfo[] pArray = type.GetProperties(); //集合属性数组
            foreach (DataRow row in dt.Rows)
            {
                T entity = Activator.CreateInstance<T>(); //新建对象实例 
                foreach (PropertyInfo p in pArray)
                {
                    if (!dt.Columns.Contains(p.Name) || row[p.Name] == null || row[p.Name] == DBNull.Value)
                    {
                        continue;  //DataTable列中不存在集合属性或者字段内容为空则,跳出循环,进行下个循环   
                    }
                    if (isStoreDB && p.PropertyType == typeof(DateTime) && Convert.ToDateTime(row[p.Name]) < Convert.ToDateTime("1753-01-01"))
                    {
                        continue;
                    }
                    try
                    {
                        var obj = Convert.ChangeType(row[p.Name], p.PropertyType);//类型强转,将table字段类型转为集合字段类型  
                        p.SetValue(entity, obj, null);
                    }
                    catch (Exception)
                    {
                        // throw;
                    }             
                }
                list.Add(entity);
            }
            return list;
        }

        /// <summary>
        /// DataSet 转换成List
        /// </summary>
        /// <typeparam name="T"></typeparam>
        /// <param name="ds"></param>
        /// <param name="tableIndext"></param>
        /// <returns></returns>
        private static List<T> DataTable2List<T>(DataTable dt)
        {
            //确认参数有效 
            if (dt == null || dt.Rows.Count <= 0)
            {
                return null;
            }

            IList<T> list = new List<T>(); //实例化一个list 
                                           // 在这里写 获取T类型的所有公有属性。 注意这里仅仅是获取T类型的公有属性,不是公有方法,也不是公有字段,当然也不是私有属性 
            PropertyInfo[] tMembersAll = typeof(T).GetProperties();

            for (int i = 0; i < dt.Rows.Count; i++)
            {
                //创建泛型对象。为什么这里要创建一个泛型对象呢?是因为目前我不确定泛型的类型。 
                T t = Activator.CreateInstance<T>();

                //获取t对象类型的所有公有属性。但是我不建议吧这条语句写在for循环里,因为没循环一次就要获取一次,占用资源,所以建议写在外面 
                //PropertyInfo[] tMembersAll = t.GetType().GetProperties();
                for (int j = 0; j < dt.Columns.Count; j++)
                {
                    //遍历tMembersAll 
                    foreach (PropertyInfo tMember in tMembersAll)
                    {
                        //取dt表中j列的名字,并把名字转换成大写的字母。整条代码的意思是:如果列名和属性名称相同时赋值 
                        if (dt.Columns[j].ColumnName.ToUpper().Equals(tMember.Name.ToUpper()))
                        {
                            //dt.Rows[i][j]表示取dt表里的第i行的第j列;DBNull是指数据库中当一个字段没有被设置值的时候的值,相当于数据库中的“空值”。 
                            if (dt.Rows[i][j] != DBNull.Value)
                            {
                                //SetValue是指:将指定属性设置为指定值。 tMember是T泛型对象t的一个公有成员,整条代码的意思就是:将dt.Rows[i][j]赋值给t对象的tMember成员,参数详情请参照http://msdn.microsoft.com/zh-cn/library/3z2t396t(v=vs.100).aspx/html
                                tMember.SetValue(t, Convert.ToString(dt.Rows[i][j]), null);
                            }
                            else
                            {
                                tMember.SetValue(t, null, null);
                            }
                            break;//注意这里的break是写在if语句里面的,意思就是说如果列名和属性名称相同并且已经赋值了,那么我就跳出foreach循环,进行j+1的下次循环 
                        }
                    }
                }
                list.Add(t);
            }
            dt.Dispose();
            return list.ToList();

        }

        /// <summary>
        /// 读取Excel文件特定名字sheet的内容到List<T>对象集合
        /// </summary>
        /// <param name="strFileName">excel文件路径</param>
        /// <param name="dir">excel列名和DataTable列名的对应字典</param>
        /// <param name="SheetName">excel表名</param>
        /// <param name="HeaderRowIndex">列头所在行号,-1表示没有列头</param>
        /// <returns></returns>
        public static List<T> ImportExceltoDt<T>(string strFileName, Dictionary<string, string> dir, string SheetName, int HeaderRowIndex = 0)
        {
            DataTable table = new DataTable();
            using (FileStream file = new FileStream(strFileName, FileMode.Open, FileAccess.Read))
            {
                if (file.Length > 0)
                {
                    IWorkbook wb = WorkbookFactory.Create(file);
                    ISheet isheet = wb.GetSheet(SheetName);
                    table = ImportDt(isheet, HeaderRowIndex, dir);
                    isheet = null;
                }
            }
            List<T> results = DataTableToList<T>(table);
            table.Dispose();
            return results;
        }

        /// <summary>
        /// 读取Excel文件某一索引sheet的内容到DataTable
        /// </summary>
        /// <param name="strFileName">excel文件路径</param>
        /// <param name="sheet">需要导出的sheet序号</param>
        /// <param name="HeaderRowIndex">列头所在行号,-1表示没有列头</param>
        /// <param name="dir">excel列名和DataTable列名的对应字典</param>
        /// <returns></returns>
        public static List<T> ImportExceltoDt<T>(string strFileName, Dictionary<string, string> dir, int HeaderRowIndex = 0, int SheetIndex = 0)
        {
            DataTable table = new DataTable();
            using (FileStream file = new FileStream(strFileName, FileMode.Open, FileAccess.Read))
            {
                if (file.Length > 0)
                {
                    IWorkbook wb = WorkbookFactory.Create(file);
                    ISheet isheet = wb.GetSheetAt(SheetIndex);
                    table = ImportDt(isheet, HeaderRowIndex, dir);
                    isheet = null;
                }
            }
            List<T> results = DataTableToList<T>(table);
            table.Dispose();
            return results;
        }

        #endregion

        /// <summary>
        /// 获取excel文件的sheet数目
        /// </summary>
        /// <param name="outputFile"></param>
        /// <returns></returns>
        public static int GetSheetNumber(string outputFile)
        {
            int number = 0;
            using (FileStream readfile = new FileStream(outputFile, FileMode.OpenOrCreate, FileAccess.Read))
            {
                if (readfile.Length > 0)
                {
                    IWorkbook wb = WorkbookFactory.Create(readfile);
                    number = wb.NumberOfSheets;
                }
            }
            return number;
        }

        /// <summary>
        /// 判断内容是否是数字
        /// </summary>
        /// <param name="message"></param>
        /// <param name="result"></param>
        /// <returns></returns>
        public static bool isNumeric(String message, out double result)
        {
            Regex rex = new Regex(@"^[-]?\d+[.]?\d*$");
            result = -1;
            if (rex.IsMatch(message))
            {
                result = double.Parse(message);
                return true;
            }
            else
                return false;
        }

        /// <summary>
        /// 验证导入的Excel是否有数据
        /// </summary>
        /// <param name="excelFileStream"></param>
        /// <returns></returns>
        public static bool HasData(Stream excelFileStream)
        {
            using (excelFileStream)
            {
                IWorkbook workBook = new HSSFWorkbook(excelFileStream);
                if (workBook.NumberOfSheets > 0)
                {
                    ISheet sheet = workBook.GetSheetAt(0);
                    return sheet.PhysicalNumberOfRows > 0;
                }
            }
            return false;
        }

    }

    /// <summary>
    /// 排序实现接口 不进行排序 根据添加顺序导出
    /// </summary>
    public class NoSort : IComparer
    {
        public int Compare(object x, object y)
        {
            return -1;
        }
    }

}

三、调用

3.1、增加一个“keywords”模型类,用作导出

public class keywords
{
    [Column("姓名")]
    public string IllegalKeywords { get; set; }
}

3.2、添加一个控制器

3.3、编写导入导出的控制器代码

3.3.1、重写“Close”函数

在导出时,为了防止MemoryStream无法关闭从而报错,所以我们继承MemoryStream;代码如下:

namespace WebApplication1    //命名空间依据自己的项目进行修改
{
    public class NpoiMemoryStream : MemoryStream
    {
        public NpoiMemoryStream()
        {
            AllowClose = true;
        }

        public bool AllowClose { get; set; }

        public override void Close()
        {
            if (AllowClose)
                base.Close();
        }
    }
}

3.3.2、添加控制器代码

/// <summary>
/// 本地环境
/// </summary>
private IHostingEnvironment _hostingEnv;


/// <summary>
/// Excel导入的具体实现
/// </summary>
/// <returns></returns>
public IActionResult import_excel()
{
    string filepath = _hostingEnv.WebRootPath + "/在线用户20230324.xlsx";//导入的文件地址路径,可动态传入
    Dictionary<string, string> dir = new Dictionary<string, string>();//申明excel列名和DataTable列名的对应字典
    dir.Add("IllegalKeywords","姓名");
    List<keywords> keyWordsList = ExcelHelper.ImportExceltoDt<keywords>(filepath, dir,"Sheet1",0);
    #region 将List动态添加至数据库
    //……
    #endregion
    return Json(new { code = 200, msg = "导入成功" });
}

/// <summary>
/// Excel导出的具体实现
/// </summary>
/// <returns></returns>
public IActionResult export_excel()
{
    #region 添加测试数据
    List<keywords> keys = new List<keywords>();
    for (int i = 0; i < 6; i++)
    {
        keywords keyword = new keywords();
        keyword.IllegalKeywords = "测试_" + i;
        keys.Add(keyword);
    }
    #endregion
    #region 实例化DataTable并进行赋值
    DataTable dt = new DataTable();
    dt = listToDataTable(keys);//List<T>对象集合转DataTable
    #endregion
    string filename = DateTime.Now.ToString("在线用户yyyyMMdd") + ".xlsx";
    Dictionary<string, string> dir = new Dictionary<string, string>();
    dir.Add("IllegalKeywords", "姓名");
    XSSFWorkbook book= ExcelHelper.ExportExcel(dt,  dir);
    dt.Dispose();//释放DataTable所占用的数据资源

    NpoiMemoryStream ms = new NpoiMemoryStream();
    ms.AllowClose = false;
    book.Write(ms, true);
    ms.Flush();
    ms.Position = 0;
    ms.Seek(0, SeekOrigin.Begin);
    ms.AllowClose = true;
    book.Dispose();//使用由XSSFWorkbook所占用的资源

    return File(ms, "application/vnd.ms-excel", Path.GetFileName(filename));//进行浏览器下载
}

3.3.3、Excel导出效果

Excel导出效果

3.3.4、Excel导入效果

导入后的List再根据需求调用添加方法实现数据的添加
Excel导入效果