Code Review实践小结

hresh 444 0

Code Review实践小结

Code Review(代码审查)是软件开发中的最佳实践之一,可以有效提高整体代码质量,及时发现代码中可能存在的问题。包括像Google、微软这些公司,Code Review 都是基本要求,代码合并之前必须要有人审查通过才行。

虽然 Code Review 很重要,但认真做 Code Review 的很少,有的流于形式,有的可能根本就没有 Code Review 的环节,代码质量只依赖于事后的测试。也有些团队想做好代码审查,但不知道怎么做比较好。

本文主要结合自己的经验,总结整理了一下 Code Review 的最佳实践,希望能对大家做好 Code Review 有所帮助。

命名风格

关于命名可以借鉴参考《阿里巴巴Java开发手册》,这里提供一个在线文档地址。除了命名,文档上的其他内容也很有意义。

个人认为还有一点需要注意,就是变量名、方法名甚至是文件名必须简单直观,最好让人仅通过名称就明白方法的业务含义,提高代码阅读性。

API格式

API 要求是 RESTFul 风格,具体参考这篇文章

REST 的关键原则是将 API 分离成逻辑资源。这些资源使用 HTTP 请求进行操作,其中的方法(GET、POST、PUT、PATCH、DELETE)具有特定的意义。

那么我们把什么作为资源呢?首先这些应该是名词,从 API 消费者的角度来看是有意义的,而不是动词。说白了,名词是一个东西,动词是你对它所做的事情。Enchant 的一些名词是票据、用户和客户。

但要注意的是。尽管你的内部模型可以整齐地映射到资源上,但它不一定是一对一的映射。这里的关键是不要把不相关的实现细节泄露给你的API! 你的API资源需要从API消费者的角度来看是有意义的。

一旦你定义了你的资源,你需要确定哪些动作适用于它们,以及这些动作如何映射到你的API。RESTful 原则提供了使用 HTTP 方法处理CRUD 动作的策略,映射如下。

  • GET /tickets - Retrieves a list of tickets
  • GET /tickets/12 - Retrieves a specific ticket
  • POST /tickets - Creates a new ticket
  • PUT /tickets/12 - Updates ticket #12
  • PATCH /tickets/12 - Partially updates ticket #12
  • DELETE /tickets/12 - Deletes ticket #12

关于上述资源在 API 中是单数还是复数?这里推荐选择复数,保证了 URL 格式的一致性,不至于单数和复数同时存在的情况。

参数声明和校验

@RequestParams和@PathVariables

1、要使用 Java Validation API,我们必须添加validation-api依赖项:

<dependency>
    <groupId>javax.validation</groupId>
    <artifactId>validation-api</artifactId>
    <version>2.0.1.Final</version>
</dependency>

如果是 SpringBoot 项目,可以引入如下依赖:

<!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-validation -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
    <version>2.7.0</version>
</dependency>

2、通过添加 @Validated 注解来启用控制器中的@RequestParams 和@PathVariables 的验证:

@RestController
@RequestMapping("/")
@Validated
public class Controller {
    // ...
}

3、案例

比如说 dayOfWeek 的值在1到7之间,我们使用@Min和@Max注解

@GetMapping("/name-for-day")
public String getNameOfDayByNumber(@RequestParam @Min(1) @Max(7) Integer dayOfWeek) {
    // ...
}

验证String参数不是空且长度小于或等于10

@GetMapping("/valid-name/{name}")
public void test(@PathVariable("name") @NotBlank @Size(max = 10) String username) {
    // ...
}

@RequestBody

既可以使用 @Valid,也可以使用 @Validated,后者额外具备分组校验功能,比如说某个类对象新增时需要校验这三个字段,修改时需要校验另外四个字段。

关于两个注解的区别参考:SpringBoot @Valid 和 @Validated 的区别及使用方法

注意注解位置顺序:

@RestController
public class MembershipController{
    @PostMapping("/create-membership-remark")
    public MembershipRemarkOutDto createMembershipRemark(
        @RequestBody @Valid CreateMembershipRemarkInDto aCreateMembershipRemarkInDto) {
        return membershipFacade.createMembershipRemark(aCreateMembershipRemarkInDto);
    }
}

测试

本项目测试类型主要选择单元测试、集成测试和契约测试。关于测试类型的选择要根据项目实际进行选择,一般来说,项目都会有测试覆盖率这个指标,各种测试方法的组合旨在提高测试覆盖率,并使项目尽早发现错误,尽可能地没有错误。

单元测试

单元测试是一种白盒测试技术,通常由开发人员在编码阶段完成,目的是验证软件代码中的每个模块(方法或类等)是否符合预期,即尽早尽量小的范围内暴露问题。

俗话说,“问题发现得越早,修复的代价越小。”单元测试的颗粒度要足够小,有助于精确定位问题。 单测粒度至多是类级别, 一般是
方法级别。后续进行集成测试以及重构代码时,更让人放心。

Java 语言的测试技术选型

单元测试中的技术框架通常包括单元测试框架、Mock 代码框架、断言等。

  • 单元测试框架:和开发语言直接相关,最常用的单元测试框架是 Junit 和 TestNG,总体来说,Junit 比较轻量级,它天生是做单测的,而 TestNG 则提供了更丰富的测试功能,测试人员对它并不陌生,这里不多做介绍。
  • Mock 代码框架:常见的有 EasyMock、Mockito、Jmockit、Powermock 等。
  • 断言:Junit 和 TestNG 自身都支持断言,除此还有专门用于断言的 Hamcrest 和 assertJ。

关于它们的优劣网络上已有非常多的文章,这里不再赘述。综合来看,个人比较推荐使用Junit+Mockito+assertJ,你也可以根据自己的需求稍作改动。推荐使用 Junit5,使用最新框架,junit5(JUnit 5 = JUnit Platform + JUnit Jupiter + JUnit Vintage)。

测试数据

单元测试既可以采用真实数据(此处的真实数据既可以是测试数据库的内容,也可以是内存数据库中构造的数据),也可以使用 mock 数据。

1、使用测试数据库

比如说,我们编写这样一个测试基类:

@SpringBootTest
@ExtendWith(SpringExtension.class)
@Rollback
@Transactional
public class SpringbootUnitTest {

}

其中 @Rollback 设置测试后回滚,默认属性为true,即回滚。保证测试完后,自测数据不污染数据库,从而保证测试案例可以重复执行。如果想要体验真实数据,可以参考本文

2、使用内存数据库

内存数据库首选使用 h2,至于为什么选择使用 h2 数据库,可以参考这篇文章:使用H2数据库进行单元测试

比如说我们在引入 h2 依赖后,创建一个公共的测试数据创建类,之后可以在具体测试类中被 @BeforeEach 修饰的方法中调用。

@UtilityClass
public class TestDataUtil {

  public void prepareProductData(JdbcTemplate jdbc) {

    jdbc.update("insert into person(id,name,age) values (1,'hresh',25)");
  }
}

3、使用 Mock 数据

我们来演示一个简单的案例。

接口的实现类,find 方法被ling

@Service
public class FirstServiceImpl implements FirstService {
    @Autowired
    FirstDao firstDao;

    @Override
    public List<User> find(User user) {
        List<User> list= firstDao.find(user);
        return list;
    }

}

@Service
public class CustomerService{
  @Autowired
  FirstService firstService;

  public void getNames(User user){
    List<User> list = firstService.find(user);
    //.......
  }
}

单元测试代码

@ExtendWith(SpringExtension.class)
@SpringBootTest(classes = Application.class)
public class FirstServiceTest {
  @MockBean
  FirstService firstService;

  @InjectMocks
  @Autowired
  private CustomerService customer;

  @Test
  public void getUserTest1(){
    //准备mock返回的数据
    User user = new User();
    user.setId(1L);
    user.setName("姓名");
    user.setAge("16");

    List<User> userList=new ArrayList<>();
    userList.add(user);
    //mock服务或者类中的某个方法,当参数是什么时,返回值是什么
    Mockito.when(firstService.find(any())).thenReturn(userList);
    User user1 = new User();
    user1.setId(1L);
    user1.setName("姓名");
    user1.setAge("16");
    //执行单元测试逻辑
        customer.getNames(user1);
    //.....
  }
}

集成测试

集成测试发生在单元测试之后,各模块联调测试。集成测试是一种黑盒测试,从接口规范开始,集中在各模块间的数据流和控制流是否按照设计实现其功能、以及结果的正确性验证等。

单元测试既可以采用真实数据,也可以使用 mock 数据,但集成测试就必须要真实数据,才能保证各组件逻辑功能验证的准确性。

这里简单演示一下 SpringBoot 项目下的集成测试案例。

首先有这样一个接口:

@RestController
@RequiredArgsConstructor
public class MangaController {

  private final MangaService mangaService;

  @GetMapping("/sync/mangas")
  public MangaResponse querySync() {
    return mangaService.queryAll();
  }
}

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class MangaResponse {

  List<Manga> mangas;
}

然后来写集成测试代码:

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest
@ExtendWith(SpringExtension.class)
public class MangaControllerIntegrationTest {

  @Autowired
  protected WebApplicationContext context;

  private MockMvc mockMvc;

  @BeforeEach
  void setup() {
    //初始化MockMvc对象,有两种实现方式
    /**
     *StandaloneMockMvcBuilder和DefaultMockMvcBuilder,前者继承了后者。
     * ① MockMvcBuilders.webAppContextSetup(WebApplicationContext context):指定WebApplicationContext,将会从该上下文获取相应的控制器并得到相应的MockMvc;
     * ② MockMvcBuilders.standaloneSetup(Object... controllers):通过参数指定一组控制器,这样就不需要从上下文获取了,
     * 比如this.mockMvc = MockMvcBuilders.standaloneSetup(this.controller).build();
     */
    mockMvc = MockMvcBuilders.webAppContextSetup(context).build();
  }

  @Test
  void testSearchSync() throws Exception {

    mockMvc.perform(get("/sync/mangas").contentType(MediaType.APPLICATION_JSON))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.mangas[0].title").value("test1"));
  }
}

上述测试代码中验证的数据来源于测试数据库中,当然也可以在内存数据库构造相关数据。

MockMvc 实现了对Http请求的模拟,能够直接使用网络的形式,转换到Controller的调用,这样可以使得测试速度快、不依赖网络环境,而且提供了一套验证的工具,这样可以使得请求的验证统一而且很方便。

在使用过程中涉及到 JsonPath,这里可以学习一下 JsonPath 语法手册

在 SpringBoot 项目中使用 MockMvc 的具体案例可以参考本文

契约测试

在微服务架构下,契约(Contract)是指服务的消费者(Consumer)与服务的提供者(Provider)之间交互协作的约定。契约主要包括两部分。

  • 请求(Request):指消费者发出的请求,通常包括请求头(Header)、请求内容(URI、Path、HTTP Verb)、请求参数及取值类型和范围等。
  • 响应(Response):指提供者返回的响应。可能包括响应的状态码(Status Word)、响应体的内容(XML/JSON) 或者错误的信息描述等。

契约测试(Contract Test)是将契约作为中间标准,对消费者与提供者间的协作进行的验证。根据测试对象的不同,又分为两种类型:消费者驱动 和 提供者驱动。最常用的是消费者驱动的契约测试(Consumer-Driven Contract Test,简称 CDC)。核心思想是从消费者业务实现的角度出发,由消费者端定义需要的数据格式以及交互细节,生成一份契约文件。然后生产者根据契约文件来实现自己的逻辑,并在持续集成环境中持续验证该实现结果是否正确。

简单地说:契约测试(Contracts Test):验证当前服务与外部服务之间的交互,以表明它符合消费者服务所期望的契约。通过“契约”来降低服务和服务之间的依赖

主要应用场景如下:

Code Review实践小结

如果我们想测试应用程序v1,如果它可以与其他服务通信,那么我们可以做两件事之一:

  • 部署所有微服务器并执行端到端测试
  • 模拟其他微型服务单元/集成测试

第二件事就可以用契约测试,通过模拟其他应用程序的结果,来降低应用程序 v1 对其他程序的依赖。

在契约测试领域也有不少测试框架,其中两个比较成熟的企业级测试框架:

  • Spring Cloud Contract,它是 Spring 应用程序的消费者契约测试框架;
  • Pact 系列框架,它是支持多种语言的框架。

这里我们选择 Spring Cloud Contract,简单演示一下。

1、创建测试基类

@ExtendWith(SpringExtension.class)
@SpringBootTest(classes = Application.class)
abstract class HelloBase {

  @Autowired
  WebApplicationContext context;

  @BeforeEach
  public void setup() {
    RestAssuredMockMvc.webAppContextSetup(this.context);
  }

}

2、代测试的接口

@RestController
@RequestMapping("/hello-world")
public class HelloWorldResource {

  @GetMapping
  public String helloWorld() {
    return "Hello world!";
  }

}

3、添加契约

合同默认位置 src/test/resources/contracts,支持 Groovy 或 yaml 编写的合同定义语言(DSL)。

package hello

import org.springframework.cloud.contract.spec.Contract

Contract.make {
    description "try to say hello"

    request {
        url "/hello-world"
        method GET()
    }

    response {
        status OK()
        body """Hello world!"""
    }
}

build 后生成的测试文件。

public class HelloTest extends HelloBase {

    @Test
    public void validate_get_hell_world() throws Exception {
        // given:
            MockMvcRequestSpecification request = given();


        // when:
            ResponseOptions response = given().spec(request)
                    .get("/hello-world");

        // then:
            assertThat(response.statusCode()).isEqualTo(200);

        // and:
            String responseBody = response.getBody().asString();
            assertThat(responseBody).isEqualTo("Hello world!");
    }

}

上述生成的 validate 开头的方法是通过 build 生成的,或者直接执行对应的 groovy 文件,则会更新对应的方法。这就意味着 groovy 文件其实是测试代码的模版,两者的数据是一致的。然后调用接口 api,返回的数据应该完全和 groovy 文件中的 response 一致。

小结

关于上述三种测试类型的案例并不完整,只演示了核心部分的代码,后续会创建一个项目来完整演示测试用例。

总结

上文是根据平时 Code Review 的结果进行总结的,回头看这些问题并没有那么复杂,完全是可以避免的,可是自己还是会犯错。只能每次写代码时多加注意,发现问题——解决问题——总结经验,凡事都会有这样一个过程,从现在开始,对犯的问题进行归纳总结,迟早会转换成习惯的。

发表评论 取消回复
表情 图片 链接 代码

分享