2021领域驱动设计最佳实践实践——验证

Wesley13
• 阅读 454

领域模型设计为复杂问题的解决提供了一套方法,但其理论往往非常抽象,本系列文单旨在提供一些最佳实践。您需要首先认识到,软件的设计过程主观性很强,我希望能够提供一个设计思想让您在入门中有一个感性的认识,莫要陷入到“教条主义”中。

2021领域驱动设计最佳实践实践——验证

领域驱动设计:强调的是战略,是宏观的,它为复杂业务的解决提供了指导思想。在实践中,无论是“面向过程”还是“面向对象”的设计方式,都是领域驱动思路的一种实现方式,要根据不同的场景使用不同的方式,请您不要陷入自我怀疑,否认“面向过程”。

  程序研发过程中,往往涉及到验证。不论是采用哪种实现方式(面向过程的方式或者对象驱动),原则上,公有方法中的第一件事情是验证参数。何时,何地,如何做验证是一个开发者要面临的挑战。做一个合格的软件设计师(在这里,我喜欢用设计师而非码农或程序员,那是对于自己的不尊重),当您想要写一份干净、整洁及可读性很强的代码时,细节上的转变会让您的作品看起来更加赏心悦目、更有自信、代码可维护性更强。

代码是给人看的,不是机器

软件设计不存在固定的规则,如果您在开发过程始终坚持某些原则就产生了规则

1、基于视图模型的验证

  在展开书写之前,我们需要假设一个非常简单的业务场景:用户的每一个操作,都需要为其增加一个操作日志。在此处,我们遵循如下设计原则:1)涉及新建、更新、复杂查询业务时,Service层公有方法都接收视图模型作为参数,而非拆成一个个独立的参数;2)所有公有方法的要做的第一件事是验证。视图模型代码如下。

public class OperationLogInfo {
    private String module;
    private String operatorId;
    private String operatorName;
    private String action;

    public String getModule() {
        return module;
    }

    public void setModule(String module) {
        this.module = module;
    }

    public String getOperatorId() {
        return operatorId;
    }

    public void setOperatorId(String operatorId) {
        this.operatorId = operatorId;
    }

    public String getOperatorName() {
        return operatorName;
    }

    public void setOperatorName(String operatorName) {
        this.operatorName = operatorName;
    }

    public String getAction() {
        return action;
    }

    public void setAction(String action) {
        this.action = action;
    }
}

  接下来为Service层代码。

@Service
public class OperationLogService {
    private static Logger logger = LoggerFactory.getLogger(OperationLogService.class);
    
    
    /**
     * 保存操作日志
     * @param operationLogInfo 操作日志信息
     */
    public void save(OperationLogInfo operationLogInfo) {
        try {
            if (operationLogInfo == null) {
                throw new IllegalArgumentException(OperationMessages.NO_OPERATION_LOG);
            }
            if (StringUtils.isEmpty(operationLogInfo.getModule())) {
                throw new IllegalArgumentException(OperationMessages.NO_MODULE_INFO);
            }
            if (StringUtils.isEmpty(operationLogInfo.getOperatorId())) {
                throw new IllegalArgumentException(OperationMessages.NO_OPERATOR_ID_TYPE);
            }
            if (StringUtils.isEmpty(operationLogInfo.getOperatorName())) {
                throw new IllegalArgumentException(OperationMessages.NO_OPERATOR_NAME_TYPE);
            }
            if (StringUtils.isEmpty(operationLogInfo.getAction())) {
                throw new IllegalArgumentException(OperationMessages.NO_ACTION_TYPE);
            }
            OperationLogDataEntity entity = OperationLogDataEntity.of(operationLogInfo);
            this.operationLogDao.save(entity);
        } catch (IllegalArgumentException e) {
            logger.error(e.getMessage(), e);
        } catch (Exception e) {
            logger.error(OperationMessages.SAVE_LOG_FAILED, e);
        }
    }
}

  为了保持代码的干净,我引入了一个新的类”OperationMessages“,将所有的”报错信息“或者”操作提示“以静态常量的形式保存在一个统一的地方供后续代码引用。如果您的服务中有多个包,建议为每一个包中都加入这样一个包含常量的类,用于区分不同业务的操作提示。下面为这个常量的代码片段。

/**
 * 操作提示
 */
final public class OperationMessages {
    public static final String NO_OPERATION_LOG = "无操作日志信息";
}

  返回到我的应用服务代码,嗯……整体来看比较整洁,不过代码读起来不舒服,70%全是验证,我的业务代码已经淹没在验证的海洋里。还好只有4个字段的信息,否则……让我们来优化一下,把验证类代码全部移到一个方法中。

@Service
public class OperationLogService {
    private static Logger logger = LoggerFactory.getLogger(OperationLogService.class);
    
    
    /**
     * 保存操作日志
     * @param operationLogInfo 操作日志信息
     */
    public void save(OperationLogInfo operationLogInfo) {
        try {
            this.validate(operationLogInfo);
            OperationLogDataEntity entity = OperationLogDataEntity.of(operationLogInfo);
            this.operationLogDao.save(entity);
        } catch (IllegalArgumentException e) {
            logger.error(e.getMessage(), e);
        } catch (Exception e) {
            logger.error(OperationMessages.SAVE_LOG_FAILED, e);
        }
    }


    private void validate(OperationLogInfo operationLogInfo) {
        if (operationLogInfo == null) {
            throw new IllegalArgumentException(OperationMessages.NO_OPERATION_LOG);
        }
        if (StringUtils.isEmpty(operationLogInfo.getModule())) {
            throw new IllegalArgumentException(OperationMessages.NO_MODULE_INFO);
        }
        if (StringUtils.isEmpty(operationLogInfo.getOperatorId())) {
            throw new IllegalArgumentException(OperationMessages.NO_OPERATOR_ID_TYPE);
        }
        if (StringUtils.isEmpty(operationLogInfo.getOperatorName())) {
            throw new IllegalArgumentException(OperationMessages.NO_OPERATOR_NAME_TYPE);
        }
        if (StringUtils.isEmpty(operationLogInfo.getAction())) {
            throw new IllegalArgumentException(OperationMessages.NO_ACTION_TYPE);
        }
    }
}

  假如我需要在类”OperationLogInfo“中再加入一个新的字段,对应的”validate“方法也需要变更。希望你的记性好一点不要忘了这处的变更,要不然可能要开始”事故报告“之旅,谁知道您的DB中是否设置了字段必填或有某个长度的限制。而外,从信息内聚的角度来看,把验证代码写到服务层貌似差了一点,毕竟类”OperationLogInfo“中有什么字段,只有它自己最知道,所以我们希望它可以承担“信息专家”的角色。因此,我们把验证的方法上升到DTO模型“OperationLogInfo”中。 此外,考虑到参数验证大多数时是必需的,所以我们做一个视图模型的父类。

信息专家”:给对象分配职责时,应该把职责分配给具有完成该职责所需要信息的那个类

视图模型”:对于视图模型,相信每个人都会有自己的理解,比较通用的解释是“承载用于在页面上显示的信息的模型”。但我个人对于视图模型有另外的解释。举一些例子:“相亲时,我想要呈现给对方一些关于自己的信息”,“买书的时候,封面上通常会有一些内容的介绍”。这些都可以被称之为我的或书的视图,是某个对象想要让另外的对象了解自身情况的一种信息载体。假如,订单模块如果要获取账户模块的信息,最好可通过获取对方的视图模型来实现。这里面存在一个设计的技巧:涉及到两个包之间的交互,建议都通过视图模型来实现;在一个包内的,如果代码内聚性很好,使用数据模型也很方便。这种通过视图模型交互的方式,有利于后续项目的拆分。比如早期项目要求快速研发,订单模型与账户模块在一个单体项目中,后续如果想把这两个模块分离,由于视图模型的存在,拆分工作会非常简易。这里存在另外一个原则“不同的包之间,只能通过Service去访问彼此”,不要为了图省事直接调用对方的DAO。此外,有的工程师习惯称呼“OperationLogInfo”为DTO,DTO其实是一种统称,数据模型、视图模型、命令、事件都可称之为DTO,这样的叫法比较泛泛。

  新引入的父类叫“VOBase”,类“OperationLogInfo”继承于它。原因很简单,“OperationLogInfo”的来源可能是另外的包,也可能是通过REST传进的参数,是一种信息的缩影,设计为视图模型还是比较自然的。

public interface Validatable {

    /**
     * 验证
     * @return 验证结果
     */
    ParameterValidationResult validate();
}


public abstract class VOBase implements Validatable {

    @Override
    public ParameterValidationResult validate() {
        return ParameterValidationResult.success();
    }

    public String toJson() {
        return JsonUtils.toJson(this);
    }
}

  变更后的“OperationLogInfo”和“OperationLogService”代码如下。

public class OperationLogInfo extends VOBase {
    private String module;
    private String operatorId;
    private String operatorName;
    private String action;
    
    
    @Override
    public ParameterValidationResult validate() {        
        if (StringUtils.isEmpty(operationLogInfo.getModule())) {
            return ParameterValidationResult.failed(OperationMessages.NO_MODULE_INFO);
        }
        if (StringUtils.isEmpty(operationLogInfo.getOperatorId())) {
            return ParameterValidationResult.failed(OperationMessages.NO_OPERATOR_ID_TYPE);
        }
        if (StringUtils.isEmpty(operationLogInfo.getOperatorName())) {
            return ParameterValidationResult.failed(OperationMessages.NO_OPERATOR_NAME_TYPE);
        }
        if (StringUtils.isEmpty(operationLogInfo.getAction())) {
            return ParameterValidationResult.failed(OperationMessages.NO_ACTION_TYPE);
        }                

        return ParameterValidationResult.success();
    }

    //省略其它get、set方法
}

@Service
public class OperationLogService {
    private static Logger logger = LoggerFactory.getLogger(OperationLogService.class);
    
    
    /**
     * 保存操作日志
     * @param operationLogInfo 操作日志信息
     */
    public void save(OperationLogInfo operationLogInfo) {
        try {
            if (operationLogInfo == null) {
                throw new IllegalArgumentException(OperationMessages.NO_OPERATION_LOG);
            }
            ParameterValidationResult validation = operationLogInfo.validate();
            if (!validation.isSuccess()) {
                throw new IllegalArgumentException(validation.getMessage());
            }
            OperationLogDataEntity entity = OperationLogDataEntity.of(operationLogInfo);
            this.operationLogDao.save(entity);
        } catch (IllegalArgumentException e) {
            logger.error(e.getMessage(), e);
        } catch (Exception e) {
            logger.error(OperationMessages.SAVE_LOG_FAILED, e);
        }
    }


    //其它代码省略
}

  如果您有“代码强迫症”,可以将此处的验证放到下面另起一个方法。不过此处的分离与前面设计的分离意义不同,我们引入了“信息专家”(OperationLogInfo)的概念,把验证的责任进行了约束。

内验:基于“信息专家”理论,将验证的过程放到待验证的对象中

基于视图模型的内验设计,建议:1)可使用Spring框架提供的验证框架;2)不可以在验证方法中引入其它的Service、DAO、远程调用工具等,要保证视图模型的纯粹。

2、基于业务模型的验证

  基于领域模型的验证,通过使用一些很小的设计技巧可以实现非常优雅的验证。很多工程师喜欢使用如“AXON”这类框架,觉得使用起来非常的酷。实际上,应用模型驱动时我个人不是特别建议使用那类开源框架,一是依赖性太强;二是框架为了支撑各类模式,设计的非常复杂,造成您的代码性能不是很高。此外,无论是EDA还是CARS模式,都属于局部模式,不要在系统中全面应用。一些逻辑简单的场景使用面向过程设计效果很好;复杂的业务则要根据其业务形态使用不同的设计模式。

  引入模型驱动,说明您的业务比较复杂,那设计出的对象也不会很简单。如何保证一个对象的合法性是您需要首先考虑的内容。对象的生成,一个是通过外部参数新创建,另外则通过查询数据库进行加载。无论是哪种方式,数据都是不可信的。所以,验证规则一定是非常非常的多,那么是否有一种方式能让我们专注于业务开发,而非为验证头痛呢?这里面引入了两个问题:1)如何验证;2)何时验证。

2.1、业务模型内验的实现

  业务模型的内验,可以通过引入一些小的设计技巧完成。如果把代码的所有实现细节全部都展现出来,对于阅读者来说也是一件比较痛苦的事情,所以在此进行一些简化,仅贴一些核心代码供参考。此处的业务场景为“服务部署审批”流程,简单来说就是每一次的服务上线需要通过一轮轮的审核,只有都通过后方能进行实施。

//业务模型:部署审批单
public class DeploymentApprovalForm extends ApprovalFormBase {

    private LocalDateTime deploymentDate;
    private ProcessStatus status = ProcessStatus.DRAFTING;
    private PhaseType currentPhase = PhaseType.DRAFTING;

    DeploymentApprovalForm(Long id, String name, ApplierInfo applierInfo, LocalDateTime createdDate, LocalDateTime updatedDate,
                           List<ApprovalNodeBase> nodes, LocalDateTime deploymentDate, ProcessStatus status,
                           PhaseType currentPhase) {
        //代码省略
    }


    @Override
    protected void addRule(RuleManager ruleManager) {
        super.addRule(ruleManager);
        ruleManager.addRule(new ObjectNotNullRule("status", this.status, OperationMessages.INVALID_STATUS));
        ruleManager.addRule(new NotEqualsRule("status", this.status, ProcessStatus.UNKNOWN, OperationMessages.INVALID_STATUS));
        ruleManager.addRule(new ObjectNotNullRule("currentPhase", this.currentPhase, OperationMessages.INVALID_PHASE));
        ruleManager.addRule(new NotEqualsRule("currentPhase", this.currentPhase, PhaseType.UNKNOWN, OperationMessages.INVALID_PHASE));
        ruleManager.addRule(new ObjectNotNullRule("deploymentDate", this.deploymentDate, OperationMessages.INVALID_DEPLOYMENT_DATE));
    }
    
    
    //代码省略
}

  上面的“部署审批单”领域模型中,方法“addRule”为内验的具体实现,定义在父类中。

public abstract class ApprovalFormBase extends EntityModel<Long> {
    private String name;
    private ApplierInfo applierInfo;
    
    //代码省略
}

public abstract class EntityModel<TID extends Comparable> extends DomainModel implements Versionable, Deletable {

    //ID
    private TID id;
    
    //代码省略
}

public abstract class DomainModel extends ValidatableBase {

    /**
     * 初始化当前状态
     */
    public void initializeToNewCreation() {

    }
    
    
    //代码省略
}

public abstract class ValidatableBase implements Validatable {

    /**
     * 验证当前领域模型
     * @return 验证r的结果
     */
    final public ParameterValidationResult validate() {
        RuleManager ruleManager = new RuleManager(this);
        this.addRule(ruleManager);
        return ruleManager.validate();
    }

    /**
     * 增加验证规则
     * @param ruleManager 验证规则管理器
     */
    protected void addRule(RuleManager ruleManager){

    }
}

  “ObjectNotNullRule”为验证规则,定义在框架中,代码如下所示。通过定义不同类型的验证规则如“NotEqualsRule”、“RegexRule”等,可以让验证的实现变得非常灵活。如果您愿意,甚至可以定义如“and”,"or"这类的逻辑表达式。这里的验证逻辑使用了设计模式中的“规约模式”,建议找相关文章进行了解。

final public class ObjectNotNullRule extends RuleBase<Object> {

    /**
     * 规则基类
     *
     * @param nameOfTarget 验证目标的名称
     * @param target       验证的目标
     * @param errorMessage 当规验证失败时的错误提示信息
     */
    public ObjectNotNullRule(String nameOfTarget, Object target, String errorMessage) {
        super(nameOfTarget, target, errorMessage);
    }

    /**
     * 执行验证
     * @return 验证是否成功
     */
    @Override
    public ParameterValidationResult validate() {
        if(this.getTarget() == null){
            return ParameterValidationResult.failed(null);
        }
        return ParameterValidationResult.success();
    }
}

  通过引入一些简单的设计模式,可以将一些通用的功能进行封装,这样您的代码就会变得更加纯粹。开发过程中,我很喜欢用一个词来描述自己的代码,包括“整洁”,“纯粹”,“高可读性”,希望您也有类似的规则,让编程工作视为一项艺术。

2.1、业务模型内验的实现

  此处有了验证,您也许会问:“那我什么时候调用呢,每一次保存的时候?每一次操作对象的时候?”,如果真的这样,您会发现代码中有许多重复的东西。在开发过程中如果发现了重复代码通常有两类处理方式:1)封装为类中单独的方法;2)将方法提升至父类中。而验证方法的调用时机,用这两类方式都不太适合。领域模型的验证通常是在应用服务中,不太好为所有的应用服务都设计出通用的可继承的验证方法。而如果我们仔细分析一下,到底何时真的需要进行验证,您会发现调用时机其实是可枚举的:1)从数据库中加载已有模型;2)模型新创建后;3)经过一系列的模型操作后,模型被最终持久化时。您也许会问,我在业务模型的方法中可能要判断一些类字段是否为空或者是否有合适的值等,是不是每次都要调用验证方法?答案是否定的,因为在上面的三个环节中您已经保证了整体对象的合法性,这是任何业务操作的前提,在后续的业务执行过程中就不必做额外的验证。当然,如果是参数的验证则需要您在代码中实现,因为参数不是对象本身的属性,不属于内验范围。

关于对象的创建,模型驱动的设计方式通过有两类方法:1)通过对象的构造函数,适合参数很少的场景;2)通过对象工厂。实践中发现,“实体类型”模型的创建都比较复杂,要求的内嵌对象和参数通常比较多,所以工厂的方式非常常见。

  我们继续分析上面三个验证时机。在此,我们为项目加入限制:业务模型的创建必须通过工厂。这样的话,1)和2)两个场景中都可以将验证方法放至对象工厂中,保证我们创建对象的过程或者返回一个合法的对象或者直接抛创建异常。下面代码展示了实现细节。

final public class DeploymentApprovalFormFactory {
    public final static DeploymentApprovalFormFactory INSTANCE = new DeploymentApprovalFormFactory();


    private DeploymentApprovalFormFactory() {
    }


    public DeploymentApprovalForm create(DeploymentApprovalFormInfo deploymentApprovalFormInfo)
            throws DeploymentApprovalFormCreationException {
        if (deploymentApprovalFormInfo == null) {
            throw new DeploymentApprovalFormCreationException(OperationMessages.INVALID_APPROVAL_FROM_INFO);
        }
        //代码省略
        PhaseType currentPhase = PhaseType.getPhaseType(deploymentApprovalFormInfo.getCurrentPhase());
        if (currentPhase == PhaseType.UNKNOWN) {
            currentPhase = PhaseType.DRAFTING;
        }
        //代码省略
        DeploymentApprovalForm deploymentApprovalForm = new DeploymentApprovalForm(deploymentApprovalFormInfo.getId(),
                deploymentApprovalFormInfo.getName(), applier, createdDate, updatedDate, nodes, deploymentDate, status,
                currentPhase);
        ParameterValidationResult validationResult = deploymentApprovalForm.validate();
        if (!validationResult.isSuccess()) {
            throw new DeploymentApprovalFormCreationException(validationResult.getMessage());
        }
        return deploymentApprovalForm;
    }
}

  “DeploymentApprovalFormInfo”这个对象是一个视图模型,其值可以来自前端,也可以在“Repository”中通过调用“DAO”从数据库中查询信息后构建。

  针对场景3),如果我们每次持久化时都显示的调用一次验证,会出现大量的重复的代码。所以,我们引入了一个新的模式“工作单元”,工作单元是在使用面向对象设计时非常实用的一种模式,下面代码给出片段,建议在网上找一些文章进行详细学习。

public abstract class UnitOfWorkBase implements UnitOfWork {

    private static final Logger logger = LoggerFactory.getLogger(UnitOfWorkBase.class);
    

    /**
     * 提交所有改变的对象至事务
     *
     */
    @Override
    public CommitHandlingResult commit() {
        CommitHandlingResult result = new CommitHandlingResult();
        try {
            this.validate();
            this.persist();
        } catch(ValidationException e) {
            logger.error(e.getMessage(), e);
            result = new CommitHandlingResult(false, e.getMessage());
        } catch(Exception e) {
            logger.error(e.getMessage(), e);
            result = new CommitHandlingResult(false, OperationMessages.COMMIT_FAILED);
        } finally {
            this.clear();
        }
        return result;
    }
    
    
    //验证对象
    protected void validate() throws ValidationException {
        CompositeParameterValidateResult result = new CompositeParameterValidateResult();        
        for (EntityModel entityModel : this.entityModels) {
            ParameterValidationResult validationResult = entityModel.validate();
            if (!validationResult.isSuccess()){
                result.addValidationResult(validationResult);
                result.fail();
            }
        }
        if (!result.isSuccess()) {
            throw new ValidationException(result.getMessage(), result);
        }
    }
    
    //代码省略
}

基于业务模型的内验设计,建议:1)不可以使用框架的验证框架,会产生强依赖;2)不可以在验证方法中引入其它的Service、DAO、远程调用工具等,会破坏您的架构完整性。通常情况下,您在使用模型驱动的设计方式时,应用的是一个“洋葱”架构,业务模型居于架构的核心中,其它的组件依赖于模型而非反向的依赖。

  本文介绍了验证模式中的“内验证”,后续会针对“外验证”进行说明。

原文链接:https://www.cnblogs.com/skevin/p/14376066.html

如果觉得本文对你有帮助,可以关注一下我公众号,回复关键字【面试】即可得到一份Java核心知识点整理与一份面试大礼包!另有更多技术干货文章以及相关资料共享,大家一起学习进步!

2021领域驱动设计最佳实践实践——验证

点赞
收藏
评论区
推荐文章
blmius blmius
3年前
MySQL:[Err] 1292 - Incorrect datetime value: ‘0000-00-00 00:00:00‘ for column ‘CREATE_TIME‘ at row 1
文章目录问题用navicat导入数据时,报错:原因这是因为当前的MySQL不支持datetime为0的情况。解决修改sql\mode:sql\mode:SQLMode定义了MySQL应支持的SQL语法、数据校验等,这样可以更容易地在不同的环境中使用MySQL。全局s
皕杰报表之UUID
​在我们用皕杰报表工具设计填报报表时,如何在新增行里自动增加id呢?能新增整数排序id吗?目前可以在新增行里自动增加id,但只能用uuid函数增加UUID编码,不能新增整数排序id。uuid函数说明:获取一个UUID,可以在填报表中用来创建数据ID语法:uuid()或uuid(sep)参数说明:sep布尔值,生成的uuid中是否包含分隔符'',缺省为
待兔 待兔
4个月前
手写Java HashMap源码
HashMap的使用教程HashMap的使用教程HashMap的使用教程HashMap的使用教程HashMap的使用教程22
Jacquelyn38 Jacquelyn38
3年前
2020年前端实用代码段,为你的工作保驾护航
有空的时候,自己总结了几个代码段,在开发中也经常使用,谢谢。1、使用解构获取json数据let jsonData  id: 1,status: "OK",data: 'a', 'b';let  id, status, data: number   jsonData;console.log(id, status, number )
Easter79 Easter79
3年前
Twitter的分布式自增ID算法snowflake (Java版)
概述分布式系统中,有一些需要使用全局唯一ID的场景,这种时候为了防止ID冲突可以使用36位的UUID,但是UUID有一些缺点,首先他相对比较长,另外UUID一般是无序的。有些时候我们希望能使用一种简单一些的ID,并且希望ID能够按照时间有序生成。而twitter的snowflake解决了这种需求,最初Twitter把存储系统从MySQL迁移
Stella981 Stella981
3年前
Django中Admin中的一些参数配置
设置在列表中显示的字段,id为django模型默认的主键list_display('id','name','sex','profession','email','qq','phone','status','create_time')设置在列表可编辑字段list_editable
Wesley13 Wesley13
3年前
00_设计模式之语言选择
设计模式之语言选择设计模式简介背景设计模式是一套被反复使用的、多数人知晓的、经过分类编目的、代码设计经验的总结。设计模式(Designpattern)代表了最佳的实践,通常被有经验的面向对象的软件开发人员所采用。设计模式是软件开发人员在软件开发过程中面临的
Wesley13 Wesley13
3年前
MySQL部分从库上面因为大量的临时表tmp_table造成慢查询
背景描述Time:20190124T00:08:14.70572408:00User@Host:@Id:Schema:sentrymetaLast_errno:0Killed:0Query_time:0.315758Lock_
为什么mysql不推荐使用雪花ID作为主键
作者:毛辰飞背景在mysql中设计表的时候,mysql官方推荐不要使用uuid或者不连续不重复的雪花id(long形且唯一),而是推荐连续自增的主键id,官方的推荐是auto_increment,那么为什么不建议采用uuid,使用uuid究
Python进阶者 Python进阶者
10个月前
Excel中这日期老是出来00:00:00,怎么用Pandas把这个去除
大家好,我是皮皮。一、前言前几天在Python白银交流群【上海新年人】问了一个Pandas数据筛选的问题。问题如下:这日期老是出来00:00:00,怎么把这个去除。二、实现过程后来【论草莓如何成为冻干莓】给了一个思路和代码如下:pd.toexcel之前把这