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依赖项:
javax.validation
validation-api
2.0.1.Final
如果是 SpringBoot 项目,可以引入如下依赖:
org.springframework.boot
spring-boot-starter-validation
2.7.0
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(com.h2database:h2:1.4.200),至于为什么选择使用 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 数据
我们来演示一个简单的案例,使用 Mock 数据来验证业务代码逻辑。
@Service
public class FirstServiceImpl implements FirstService {
@Autowired
FirstDao firstDao;
@Override
public List find(User user) {
List list= firstDao.find(user);
return list;
}
}
@Service
public class CustomerService{
@Autowired
FirstService firstService;
public void getNames(User user){
List 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 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);
//.....
}
}
集成测试
集成测试发生在单元测试之后,各模块联调测试。集成测试是一种黑盒测试,从接口规范开始,集中在各模块间的数据流和控制流是否按照设计实现其功能、以及结果的正确性验证等。
与单元测试不同,集成测试通常需要在更真实的环境中执行,可能依赖于真实的外部资源和依赖项。集成测试可以帮助发现组件之间的集成问题、接口错误和协作错误,以及整个系统中的一致性问题。
这里简单演示一下 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 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):验证当前服务与外部服务之间的交互,以表明它符合消费者服务所期望的契约。通过“契约”来降低服务和服务之间的依赖。
主要应用场景如下:
如果我们想测试应用程序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 的结果进行总结的,回头看这些问题并没有那么复杂,完全是可以避免的,可是自己还是会犯错。只能每次写代码时多加注意,发现问题——解决问题——总结经验,凡事都会有这样一个过程,从现在开始,对犯的问题进行归纳总结,迟早会转换成习惯的。