干净架构最佳实践

马丁路德
• 阅读 1424

干净架构介绍

Bob 大叔在 2012 年的一篇博文 The Clean Architecture 中提出了一种适用于复杂业务系统的软件架构方式。在干净架构出现之前,已经有一些其它架构,包括 Hexagonal ArchitectureOnion ArchitectureScreaming ArchitectureDCIBCE。这些架构在本质上都是类似的,都采用分层的方式来达到一个共同的目标,那就是分离关注。干净架构将这些架构的核心理念提取了出来,形成了一种更加通用和灵活的架构。干净架构的设计理念如下图所示:

干净架构最佳实践

采用干净架构的系统,可以达成以下目标:

  1. 框架无关性。干净架构不依赖于具体的框架和库,而仅把它们当作工具,因此不会受限于任何具体的框架和库。
  2. 可测试性。业务规则可以在没有 UI、数据库、Web 服务器等外部依赖的情况下进行测试。
  3. UI 无关性。UI 改变可以在不改动系统其它部分的情况下完成,比如把 Web UI 替换成控制台 UI。
  4. 数据库无关性。可以很容易地切换数据库类型,比如从关系型数据库 MySQL 切换到文档型数据库 MongoDB,因为业务规则并没有绑定到某种特定的数据库类型。
  5. 外部代理无关性。业务规则对外部世界一无所知,因此外部代理的变动不会影响到业务代码。

可以看到干净架构是围绕业务规则来设计的,核心就是要保证业务代码的稳定性。

向内依赖原则(Inward Dependency Rule)

干净架构最核心的原则就是代码依赖关系只能从外向内,而不能反之。干净架构的每一圈层代表软件系统的不同部分,越往里抽象程度越高。外层为机制,内层为策略。这里说的依赖关系,具体指的是内层代码不能引用外层代码的命名软件实体,包括类、方法、函数和数据类型等。

实体(Entities)

实体用于封装企业范围的业务规则。实体可以是拥有方法的对象,也可以是数据结构和函数的集合。如果没有企业,只是单个应用,那么实体就是应用里的业务对象。这些对象封装了最通用和高层的业务规则,极少会受到外部变化的影响。任何操作层面的改动都不会影响到这一层。

用例(Use Cases)

用例是特定于应用的业务逻辑,一般用来完成用户的某个操作。用例协调数据流向或者流出实体层,并且在此过程中通过执行实体的业务规则来达成用例的目标。用例层的改动不会影响到内部的实体层,同时也不会受外层的改动影响,比如数据库、UI 和框架的变动。只有而且应当应用的操作发生变化的时候,用例层的代码才随之修改。

接口适配器(Interface Adapters)

接口适配器层的主要作用是转换数据,数据从最适合内部用例层和实体层的结构转换成适合外层(比如数据持久化框架)的结构。反之,来自于外部服务的数据也会在这层转换为内层需要的结构。

框架和驱动(Frameworks and Drivers)

最外层由各种框架和工具组成,比如 Web 框架、数据库访问工具等。通常在这层不需要写太多代码,大多是一些用来跟内层通信的胶水代码。这一层包含了所有实现细节,把实现细节锁定在这一层能够减少它们的改动对整个系统造成的伤害。

关于层数

干净架构并没有定死图中的四层,可以按需增加或减少层数。前提是保证向内依赖原则,并且抽象的层级越往内越高。

跨层访问

依赖反转原则

向内依赖原则限定内层代码不能依赖外层代码,但如果内层代码确实需要调用外层代码代码怎么办?这个时候可以采用 依赖反转原则(Dependency Inversion Principle)。内层代码将其所依赖的外层服务定义为接口(Interface),外层代码实现该接口。这样依赖就反转了过来,变成了外层代码依赖内层代码。

传递数据

跨层传递的数据结构通常应比较简单。可以是语言提供的基本数据类型,简单的数据传输对象,函数参数,哈希表等。重要的是保证数据结构的隔离性和简单性,不要违反向内依赖原则。

干净架构简化

干净架构原图里涉及的概念比较多,容易造成大家理解上出现偏差。再加上干净架构并没有规定具体实施细节,因此在实施的时候不同的人会有不同的选择,有些选择甚至是错误的。为了简化和规范干净架构的实施,笔者对干净架构进行了一些简化,并增加了一些约束。下面是精简后的干净架构图:

干净架构最佳实践

Entities

实体层跟原设计基本一致。对于没有企业级别业务规则的单个产品应用来说,实体层就是一些业务对象。为了简化各层之间数据的传递,允许在各层之间传递实体对象,以避免创建额外的数据传输对象(DTO,Data Transfer Object)。

Use Cases

用例层是整个应用的核心,是产品业务逻辑所在之处。它通过执行一系列的业务规则来完成用户请求的操作,其中可能会调用各种外部服务。这里的服务不局限于网络服务,比如说数据库服务、API 服务,还可以是第三方软件库包所提供的函数和方法。为了避免直接依赖这些外部服务,需要将其抽象为接口并放在用例层,这样就转换成了内部依赖,外层适配层需要实现这些接口。

Interface Adapters

适配器层在内层用例层和外层界面层之间进行适配,无论是界面层执行用例,还是用例层调用界面层里的外部服务,都需要在这里进行适配。

在这一层需要实现用例层定义的外部服务接口,一般会借助于第三方提供的驱动、库、包等,这相当于是将第三方软件所提供的接口适配为用例层要求的接口。数据库访问中所使用的对象仓库(Object Repository)或数据访问对象(DAO,Data Access Object)属于这层。

为了将用例在界面层暴露给用户使用,需要使用像控制器(Controller)或呈现器(Presenter)这样的组件来将用例层提供的接口适配为界面层所需的接口。服务端的控制器会检查用户身份和权限,校验用户输入并转换为用例层所需类型,然后执行用例来完成用户请求的操作,最后将用例层返回的结果转换为界面层需要的类型后返回。客户端的呈现器负责响应用户操作(页面展示、表单提交等),它可能会委托一个或多个用例来操作服务端业务状态,或者直接操作本地业务状态。在操作完成后,呈现器还要负责更新 UI,不过呈现器并不直接操作 UI 元素,而是调用 UI 接口来完成。

Interfaces

这里 Interfaces 应理解为界面而不是接口,界面指的是软件系统暴露给外部的操作界面。界面层里的界面是双向的,应用提供给外部的,以及外部提供给应用的。

应用提供给外部的界面是为了让用户能跟应用交互,以便执行用例,一般会借助于某种(Web、UI、CLI 等)应用框架(Frameworks)来实现。对于服务端应用这个界面通常是 API,对于客户端应用可以是图形界面(GUI)或命令行界面(CLI)。在服务端应用里,界面层大部分的工作都交由框架来完成,因此开发人员需要编写的代码很少,比如加载配置、将控制器注册到某个路由、初始化和启动服务等。对于客户端应用来说,界面开发的工作量一般比较大,特别是对于那些界面比较复杂的应用。注意对于用户操作(交互)的处理应放到适配层的呈现器里,它负责控制界面变化。

外部提供给应用的界面包括驱动(Drivers)、库(Libraries)、包(Packages)等软件,这里不需要我们在界面层做什么,只需要在适配层将其适配为用例层所需接口即可。

可以看到这一层包含了各种框架和驱动,因此在干净架构原图里称之为框架和驱动层(Frameworks and Drivers)。不过个人觉得界面层更为贴切,因为适配层(Interface Adapters)就是在外层 Interfaces 和内层 Use Cases 之间做适配(Adapt)。

干净架构在服务端应用中的实践

下面我们通过一个使用 Spring Boot 框架开发的 API 服务来讲解如何在服务端应用里实施干净架构,相关代码可从 GitHub 获取 Spring Boot in Practice

目录结构

软件架构最重要的表现形式就是代码的目录结构。下面是该 API 服务的目录结构:

.
├── adapter # 适配层
│   ├── controller # 控制器,将用例适配为 Rest API
│   ├── encoder # 编码器,实现 usecase/port/encoder 下的接口
│   ├── generator # 生成器,实现 usecase/port/generator 下的接口
│   ├── graphql # GraphQL Resolver,将用例适配为 GraphQL API
│   ├── repository # 对象仓库,实现 usecase/port/repository 下的接口
│   └── service # 第三方服务,实现 usecase/port/service 下的接口
├── api # 界面层
│   ├── SbipApplication.java # Spring 应用
│   ├── config # 应用配置
│   ├── exception # 界面层异常
│   └── security # Spring Security 自定义
├── entity # 实体层
│   ├── ...
│   └── UserEntity.java # 用户实体
└── usecase # 用例层
    ├── ...
    ├── UserUsecases.java # 用户模块相关用例
    ├── exception # 用例层异常
    └── port # 用例层依赖的外部服务接口定义 

上面的结构大体与干净架构一致,但更为细致,我们来一一看下。

实体层

我们的 API 服务没有什么企业级别的业务规则,因此实体简化成了 POJO 对象。实体是用例层要操作的对象,无论是从用例层流出还是流入用例层都传递的是实体对象。其它层需要按需转化成自己内部使用的类型,比如适配层里的对象仓库如果使用 Spring Data JPA 来实现,那么就需要在数据对象(DO,Data Object)和实体之间相互转化。

下面是用户实体的定义:

package net.jaggerwang.sbip.entity;

import java.time.LocalDateTime;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserEntity {
    private Long id;

    private String username;

    private String password;

    private String mobile;

    private String email;

    private Long avatarId;

    private String intro;

    private LocalDateTime createdAt;

    private LocalDateTime updatedAt;
} 

用例层

为了防止用例太多导致类的数量很多,这里按照功能模块来组织用例,同一个模块的所有用例放在一个类里,每个用例对应一个方法。用例方法执行过程中如果出现业务异常,需要抛出 exception 目录下定义的某种异常来告知上层。在用例里面我们只需处理业务相关的异常,系统异常(比如程序 bug、数据库服务不可用、第三方服务响应超时等)一般不用处理,任其向上继续抛出即可,因为通常来说在用例层不知道如何处理这类异常。

用例层除了依赖内层实体层,不再有其它依赖(第三方依赖包也要尽量避免使用),因此不受外层所用工具和框架的影响,非常稳定也很容易进行单元测试。单元测试的时候可以根据需要给用例注入真实服务对象或者模拟对象。用例层依赖的外部服务接口定义统一放在 port 目录下,这里 port 表示门,意思是只有通过这道门用例才能访问到外部世界。

下面是用户模块的相关用例:

package net.jaggerwang.sbip.usecase;

import java.util.HashMap;
import java.util.List;
import net.jaggerwang.sbip.entity.UserEntity;
import net.jaggerwang.sbip.usecase.exception.NotFoundException;
import net.jaggerwang.sbip.usecase.exception.UsecaseException;
import net.jaggerwang.sbip.usecase.port.encoder.PasswordEncoder;
import net.jaggerwang.sbip.usecase.port.generator.RandomGenerator;
import net.jaggerwang.sbip.usecase.port.repository.UserRepository;

public class UserUsecases extends BaseUsecases {
    private UserRepository userRepository;
    private RandomGenerator randomGenerator;
    private PasswordEncoder passwordEncoder;

    public UserUsecases(UserRepository userRepository, RandomGenerator randomGenerator,
            PasswordEncoder passwordEncoder) {
        this.userRepository = userRepository;
        this.randomGenerator = randomGenerator;
        this.passwordEncoder = passwordEncoder;
    }

    public UserEntity register(UserEntity userEntity) {
        if (userRepository.findByUsername(userEntity.getUsername()).isPresent()) {
            throw new UsecaseException("用户名重复");
        }

        var user = UserEntity.builder().username(userEntity.getUsername())
                .password(passwordEncoder.encode(userEntity.getPassword())).build();
        return userRepository.save(user);
    }

    public UserEntity info(Long id) {
        var userEntity = userRepository.findById(id);
        if (!userEntity.isPresent()) {
            throw new NotFoundException("用户未找到");
        }

        return userEntity.get();
    }

    ...
} 

可以看到除了内层实体,用例没有依赖其它层的类型和对象。

适配层

适配层首先要提供用例层里各个依赖服务的实现。以用户对象仓库为例,下面是其接口定义:

package net.jaggerwang.sbip.usecase.port.repository;

import java.util.List;
import java.util.Optional;

import net.jaggerwang.sbip.entity.UserEntity;

public interface UserRepository {
    UserEntity save(UserEntity userEntity);

    Optional<UserEntity> findById(Long id);

    Optional<UserEntity> findByUsername(String username);

    ...
} 

下面是用户对象仓库的实现:

package net.jaggerwang.sbip.adapter.repository;

import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import net.jaggerwang.sbip.adapter.repository.jpa.UserRepo;
import net.jaggerwang.sbip.adapter.repository.jpa.entity.UserDO;
import net.jaggerwang.sbip.adapter.repository.jpa.entity.UserFollowDO;
import net.jaggerwang.sbip.adapter.repository.jpa.UserFollowRepo;
import net.jaggerwang.sbip.entity.UserEntity;
import net.jaggerwang.sbip.usecase.port.repository.UserRepository;

@Component
public class UserRepositoryImpl implements UserRepository {
    @Autowired
    private UserRepo userRepo;

    @Override
    public UserEntity save(UserEntity userEntity) {
        return userRepo.save(UserDO.fromEntity(userEntity)).toEntity();
    }

    @Override
    public Optional<UserEntity> findById(Long id) {
        return userRepo.findById(id).map(userDO -> userDO.toEntity());
    }

    @Override
    public Optional<UserEntity> findByUsername(String username) {
        return userRepo.findByUsername(username).map(userDO -> userDO.toEntity());
    }

    ...
} 

上面代码中自动注入的 UserRepo 是一个 Spring Data JPA 仓库。用户对象仓库的各个方法基本上都是委托 UserRepo 的对应方法来完成操作,不过它需要完成 UserEntityUserDO 之间的类型转换,因为不能直接将底层的持久化对象暴露给用例层。除了使用 Spring Data JPA,还可以使用其它的数据库访问技术,比如 JdbcTemplateMyBatis,不过这些选择都不会影响到用例层。

适配层的另外一个责任就是把用例适配为界面层的 API。我们的 API 服务提供了两套 API,一套是传统的 Rest API,另外一套是新兴的 GraphQL API,分别通过 ControllerResolver 来完成适配。

下面是 Rest API 的 UserController

package net.jaggerwang.sbip.adapter.controller;

import java.util.Map;
import java.util.stream.Collectors;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import net.jaggerwang.sbip.adapter.controller.dto.JsonDTO;
import net.jaggerwang.sbip.adapter.controller.dto.UserDTO;
import net.jaggerwang.sbip.entity.UserEntity;
import net.jaggerwang.sbip.usecase.exception.UsecaseException;

@RestController
@RequestMapping("/user")
public class UserController extends BaseController {
    @PostMapping("/register")
    public JsonDTO register(@RequestBody UserDTO userDTO) {
        var userEntity = userUsecases.register(userDTO.toEntity());

        login(userDTO.getUsername(), userDTO.getPassword());

        metricUsecases.increment("registerCount", 1L);

        return new JsonDTO().addDataEntry("user", UserDTO.fromEntity(userEntity));
    }

    ...
} 

下面是 GraphQL API 的 QueryResolver

package net.jaggerwang.sbip.adapter.graphql;

import java.util.List;
import java.util.Optional;
import com.coxautodev.graphql.tools.GraphQLQueryResolver;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import net.jaggerwang.sbip.api.security.annotation.PermitALL;
import net.jaggerwang.sbip.entity.FileEntity;
import net.jaggerwang.sbip.entity.PostEntity;
import net.jaggerwang.sbip.entity.UserEntity;

@Component
public class QueryResolver extends BaseResolver implements GraphQLQueryResolver {
    public UserEntity userLogout() {
        var userEntity = userUsecases.info(loggedUserId());
        SecurityContextHolder.getContext().setAuthentication(null);

        return userEntity;
    }

    @PermitALL
    public Optional<UserEntity> userLogged() {
        var auth = SecurityContextHolder.getContext().getAuthentication();
        if (auth instanceof AnonymousAuthenticationToken || !auth.isAuthenticated()) {
            return Optional.empty();
        }

        return Optional.of(userUsecases.info(loggedUserId()));
    }

    ...
} 

无论是 UserController 还是 QueryResolver,它们都执行相同的用例来完成用户请求的某个操作,不同的地方在于请求参数的解析和响应结果的生成。另外对于 Rest API,为了避免返回给客户端的数据对象在结构上跟实体对象紧耦合(通常来说它们会有一些差异),使用了专门的数据传输对象(DTO)来跟客户端交换数据。而对于 GraphQL API,由于其天然支持客户端自定义响应数据的结构,因此就没必要再使用额外的数据传输对象。

界面层

因为我们开发的是一个 API 服务,因此把界面层目录命名为了 api,也可以根据应用类型命名成 uicli 等。本 API 服务的界面层比较简单,只是创建和配置 Spring Boot 应用,以便对外提供服务。

干净架构在客户端应用中的实践

虽然干净架构更适合业务逻辑比较复杂、外部依赖服务较多的服务端应用,不过这也并不妨碍在客户端应用里使用它。使用干净架构并不会增加什么成本,但能使代码结构更清晰,还能在切换应用框架时复用之前的业务代码,这点对于前端和客户端这种框架变化比较快的场景尤其有用。

下面还是以一个实际的使用 Flutter 框架开发的移动应用来讲解,参考代码可从 GitHub 获取 Flutter in Practice

目录结构

下面是该 Flutter 应用的代码目录结构:

.
├── adapter # 适配层
│   ├── presenter # 呈现器,负责处理用户操作,包括执行用例和更新 UI
│   └── service # 后端服务,实现 usecase/port/service 下的接口
├── config.dart # 应用配置
├── container.dart # IoC 容器
├── entity # 实体层
│   ├── ...
│   └── user.dart # 用户实体
├── main.dart # 应用入口
├── ui # 界面层
│   ├── app.dart # 应用根组件
│   ├── component # 各页面中复用的组件
│   ├── form # 表单
│   ├── page # 页面
│   ├── redux # Redux 状态管理
│   └── theme.dart # 主题配置
├── usecase # 用例层
│   ├── exception # 用例层异常
│   ├── port # 用例层依赖的外部服务接口定义
│   ├── ...
│   └── user.dart # 用户模块相关用例
└── util # 业务无关的小工具 

上面的目录结构跟前面的 API 服务相差无几,除了使用 ui 目录替换了 api 目录。为了节省篇幅,下面只重点说一下在客户端应用里实施干净架构跟服务端应用的不同之处,具体实施细节可查阅参考代码。

实体层

本移动应用的实体对象是服务端 API 返回的数据传输对象,不是服务端的实体对象。对于没什么本地业务逻辑的应用,可以把它看作是视图对象(VO,View Object),其主要目的是用于界面渲染。

用例层

本移动应用没什么本地业务逻辑,因此用例层很薄,只是简单地调用服务端 API。为了避免代码重复,这里直接让呈现器调用服务端 API,而无需经过用例转发。对于服务端 API,因其属于外部服务,所以需要抽象为接口,这样也方便执行单元测试的时候使用模拟 API。

适配层

适配层除了需要实现用例层依赖的服务端 API 接口,更多的工作是呈现器的开发。呈现器负责响应用户操作,它控制着界面的变化,其作用相当于是服务端的控制器。当用户点击链接跳转到一个新页面时,它会从服务端请求相关数据,并传递给 UI 去渲染。当用户提交表单时,它会把用户输入提交到服务端,并把提交结果展示给用户。

界面层

本移动应用的界面层提供了运行于 Android 和 iOS 上的图形操作界面。借助于 Flutter 框架,我们可以采用响应式方式来实现 UI。传统的 MVP(Model-View-Presenter)模式里,View 需要提供大量接口给 Presenter,以便 Presenter 可以操控 UI 变化。在响应式 UI 里,组件通过更新其状态来自动触发 UI 更新,这就避免了呈现器去调用 UI 接口。对于把状态保存在内部的组件,呈现器只需返回数据给组件,组件会使用该数据去更新其内部状态,从而触发 UI 更新。如果组件状态保存在像 Redux 这样的状态管理器里,呈现器还需要负责发送 Action 去更新保存在 Redux Store 里的状态,以便监听了该部分状态的组件可以得到更新。

另外为了提升呈现器方法的可复用性,本应用里的呈现器方法并没有跟组件的交互事件回调函数一一对应。只是把各个组件中公共的交互处理逻辑提取到呈现器里,特定于每个组件的处理逻辑还是保留在组件中。不过有了呈现器提供的辅助,这些回调函数都非常简单,无非是执行一些页面跳转、错误显示、消息提示等操作。

参考资料

  1. The Clean Architecture
  2. Spring Boot in Practice
  3. Flutter in Practice

本文转自 https://blog.jaggerwang.net/clean-architecture-in-practice/,如有侵权,请联系删除。

点赞
收藏
评论区
推荐文章
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中是否包含分隔符'',缺省为
待兔 待兔
5个月前
手写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 )
Wesley13 Wesley13
3年前
mysql设置时区
mysql设置时区mysql\_query("SETtime\_zone'8:00'")ordie('时区设置失败,请联系管理员!');中国在东8区所以加8方法二:selectcount(user\_id)asdevice,CONVERT\_TZ(FROM\_UNIXTIME(reg\_time),'08:00','0
Wesley13 Wesley13
3年前
00:Java简单了解
浅谈Java之概述Java是SUN(StanfordUniversityNetwork),斯坦福大学网络公司)1995年推出的一门高级编程语言。Java是一种面向Internet的编程语言。随着Java技术在web方面的不断成熟,已经成为Web应用程序的首选开发语言。Java是简单易学,完全面向对象,安全可靠,与平台无关的编程语言。
Stella981 Stella981
3年前
Django中Admin中的一些参数配置
设置在列表中显示的字段,id为django模型默认的主键list_display('id','name','sex','profession','email','qq','phone','status','create_time')设置在列表可编辑字段list_editable
Stella981 Stella981
3年前
Docker 部署SpringBoot项目不香吗?
  公众号改版后文章乱序推荐,希望你可以点击上方“Java进阶架构师”,点击右上角,将我们设为★“星标”!这样才不会错过每日进阶架构文章呀。  !(http://dingyue.ws.126.net/2020/0920/b00fbfc7j00qgy5xy002kd200qo00hsg00it00cj.jpg)  2
Wesley13 Wesley13
3年前
MySQL部分从库上面因为大量的临时表tmp_table造成慢查询
背景描述Time:20190124T00:08:14.70572408:00User@Host:@Id:Schema:sentrymetaLast_errno:0Killed:0Query_time:0.315758Lock_
Python进阶者 Python进阶者
11个月前
Excel中这日期老是出来00:00:00,怎么用Pandas把这个去除
大家好,我是皮皮。一、前言前几天在Python白银交流群【上海新年人】问了一个Pandas数据筛选的问题。问题如下:这日期老是出来00:00:00,怎么把这个去除。二、实现过程后来【论草莓如何成为冻干莓】给了一个思路和代码如下:pd.toexcel之前把这