且构网

分享程序员开发的那些事...
且构网 - 分享程序员编程开发的那些事

如何写出让人抓狂的代码?(三)

更新时间:2022-10-03 19:10:14

11.在循环中远程调用


有时候,我们需要在某个接口中,远程调用第三方的某个接口。


比如:在注册企业时,需要调用天眼查接口,查一下该企业的名称和统一社会信用代码是否正确。


这时候在企业注册接口中,不得不先调用天眼查接口校验数据。如果校验失败,则直接返回。如果校验成功,才允许注册。


如果只是一个企业还好,但如果某个请求有10个企业需要注册,是不是要在企业注册接口中,循环调用10次天眼查接口才能判断所有企业是否正常呢?


public void register(List<Corp> corpList) {
  for(Corp corp: corpList) {
      CorpInfo info = tianyanchaService.query(corp);  
      if(null == info) {
         throw new RuntimeException("企业名称或统一社会信用代码不正确");
      }
  }
  doRegister(corpList);
}


这样做可以,但会导致整个企业注册接口性能很差,极容易出现接口超时问题。

那么,如何解决这类在循环中调用远程接口的问题呢?


11.1 批量操作


远程接口支持批量操作,比如天眼查支持一次性查询多个企业的数据,这样就无需在循环中查询该接口了。


但实际场景中,有些第三方不愿意提供第三方接口。


11.2 并发操作


java8以后通过CompleteFuture类,实现多个线程查天眼查接口,并且把查询结果统一汇总到一起。


12.频繁捕获异常


通常情况下,为了在程序中抛出异常时,任然能够继续运行,不至于中断整个程序,我们可以选择手动捕获异常。例如:


public void run() {
    try {
        doSameThing();
    } catch (Exception e) {
        //ignore
    }
    doOtherThing();
}


这段代码可以手动捕获异常,保证即使doSameThing方法出现了异常,run方法也能继续执行完。


但有些场景下,手动捕获异常被滥用了。


12.1 滥用场景1


不知道你在打印异常日志时,有没有写过类似这样的代码:


public void run() throws Exception {
    try {
        doSameThing();
    } catch (Exception e) {
        log.error(e.getMessage(), e);
        throw e;
    }
    doOtherThing();
}


通过try/catch关键字,手动捕获异常的目的,仅仅是为了记录错误日志,在接下来的代码中,还是会把该异常抛出。


在每个抛出异常的地方,都捕获一下异常,打印日志。


12.2 滥用场景2


在写controller层接口方法时,为了保证接口有统一的返回值,你有没有写过类似这样的代码:


@PostMapping("/query")
public List<User> query(@RequestBody List<Long> ids) {
    try {
        List<User> userList = userService.query(ids);
        return Result.ok(userList);
    } catch (Exception e) {
        log.error(e.getMessage(), e);
        return Result.fature(500, "服务器内部错误");
    }
}


在每个controller层的接口方法中,都加上了上面这种捕获异常的逻辑。


上述两种场景中,频繁的捕获异常,会让代码性能降低,因为捕获异常是会消耗性能的。


此外,这么多重复的捕获异常代码,看得让人头疼。


其实,我们还有更好的选择。在网关层(比如:zuul或gateway),有个统一的异常处理代码,既可以打印异常日志,也能统一封装接口返回值,这样可以减少很多异常被滥用的情况。


13.不正确的日志打印


在我们写代码的时候,打印日志是必不可少的工作之一。


因为日志可以帮我们快速定位问题,判断代码当时真正的执行逻辑。


但打印日志的时候也需要注意,不是说任何时候都要打印日志,比如:


@PostMapping("/query")
public List<User> query(@RequestBody List<Long> ids) {
    log.info("request params:{}", ids);
    List<User> userList = userService.query(ids);
    log.info("response:{}", userList);
    return userList;
}


对于有些查询接口,在日志中打印出了请求参数和接口返回值。


咋一看没啥问题。


但如果ids中传入值非常多,比如有1000个。而该接口被调用的频次又很高,一下子就会打印大量的日志,用不了多久就可能把磁盘空间打满。


如果真的想打印这些日志该怎么办?


@PostMapping("/query")
public List<User> query(@RequestBody List<Long> ids) {
    if (log.isDebugEnabled()) {
        log.debug("request params:{}", ids);
    }
    List<User> userList = userService.query(ids);
    if (log.isDebugEnabled()) {
        log.debug("response:{}", userList);
    }
    return userList;
}


使用isDebugEnabled判断一下,如果当前的日志级别是debug才打印日志。生产环境默认日志级别是info,在有些紧急情况下,把某个接口或者方法的日志级别改成debug,打印完我们需要的日志后,又调整回去。


方便我们定位问题,又不会产生大量的垃圾日志,一举两得。


14.没校验入参


参数校验是接口必不可少的功能之一,一般情况下,提供给第三方调用的接口,需要做严格的参数校验。


以前我们是这样校验参数的:


@PostMapping("/add")
public void add(@RequestBody User user) {
    if(StringUtils.isEmpty(user.getName())) {
        throw new RuntimeException("name不能为空");
    }
    if(null != user.getAge()) {
        throw new RuntimeException("age不能为空");
    }
    if(StringUtils.isEmpty(user.getAddress())) {
        throw new RuntimeException("address不能为空");
    }
    userService.add(user);
}


需要手动写校验的代码,如果作为入参的实体中字段非常多,光是写校验的代码,都需要花费大量的时间。而且这些校验代码,很多都是重复的,会让人觉得恶心。


好消息是使用了hibernate的参数校验框架validate之后,参数校验一下子变得简单多了。


我们只需要校验的实体类User中使用validation框架的相关注解,比如:@NotEmpty、@NotNull等,定义需要校验的字段即可。


@NoArgsConstructor
@AllArgsConstructor
@Data
public class User {
    
    private Long id;
    @NotEmpty
    private String name;
    @NotNull
    private Integer age;
    @NotEmpty
    private String address;
}


然后在controller类上加上@Validated注解,在接口方法上加上@Valid注解。


@Slf4j
@Validated
@RestController
@RequestMapping("/user")
public class UserController {
    @Autowired
    private UserService userService;
    @PostMapping("/add")
    public void add(@RequestBody @Valid User user) {
        userService.add(user);
    }
}


这样就能自动实现参数校验的功能。


然而,现在需求改了,需要在User类上增加了一个参数Role,它也是必填字段,并且它的roleName和tag字段都不能为空。


但如果我们在校验参数时,不小心把代码写成这样:


@NoArgsConstructor
@AllArgsConstructor
@Data
public class User {
    private Long id;
    @NotEmpty
    private String name;
    @NotNull
    private Integer age;
    @NotEmpty
    private String address;
    @NotNull
    private Role role;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Role {
    @NotEmpty
    private String roleName;
    @NotEmpty
    private String tag;
}


结果就悲剧了。


你心里可能还乐呵呵的认为写的代码不错,但实际情况是,roleName和tag字段根本不会被校验到。


如果传入参数:


{
  "name": "tom",
  "age":1,
  "address":"123",
  "role":{}
}


即使role字段传入的是空对象,但该接口也会返回成功。


那么如何解决这个问题呢?


@NoArgsConstructor
@AllArgsConstructor
@Data
public class User {
    private Long id;
    @NotEmpty
    private String name;
    @NotNull
    private Integer age;
    @NotEmpty
    private String address;
    @NotNull
    @Valid
    private Role role;
}


需要在Role字段上也加上@Valid注解。


温馨的提醒一声,使用validate框架校验参数一定要自测,因为很容易踩坑。


15.返回值格式不统一


我之前对接某个第三方时,他们有部分接口的返回值结构是这样的:


{
   "ret":0,
   "message":null,
   "data":[]
}


另一部分接口的返回值结构是这样的:


{
   "code":0,
   "msg":null,
   "success":true,
   "result":[]
}


整得我有点懵逼。


为啥没有一个统一的返回值?


我需要给他们的接口写两套返回值解析的代码,后面其他人看到了这些代码,可能也会心生疑问,为什么有两种不同的返回值解析?


唯一的解释是一些接口是新项目的,另外一些接口是老项目的。


但如果不管是新项目,还是老项目,如果都有一个统一的对外网关服务,由这个服务进行鉴权和统一封装返回值。


{
   "code":0,
   "message":null,
   "data":[]
}


就不会有返回值结构不一致的问题。


温馨的提醒一下,业务服务不要捕获异常,直接把异常抛给网关服务,由它来统一全局捕获异常,这样就能统一异常的返回值结构。


16.提交到git的代码不完整


我们写完代码之后,把代码提交到gitlab上,也有一些讲究。


最最忌讳的是代码还没有写完,因为赶时间(着急下班),就用git把代码提交了。例如:


public void test() {
   String userName="苏三";
   String password=
}


这段代码中的password变量都没有定义好,项目一运行起来必定报错。


这种错误的代码提交方式,一般是新手会犯。但还有另一种情况,就是在多个分支merge代码的时候,有时候会出问题,merge之后的代码不能正常运行,就被提交了。


好的习惯是:用git提交代码之前,一定要在本地运行一下,确保项目能正常启动才能提交。


宁可不提交代码到远程仓库,切勿因为一时赶时间,提交了不完整的代码,导致团队的队友们项目都启动不了。


17.不处理没用的代码


有些时候,我们为了偷懒,对有些没用的代码不做任何处理。


比如:


@Slf4j
@Service
public class UserService {
    @Autowired
    private UserMapper userMapper;
    public void add(User user) {
        System.out.println("add");
    }
    public void update(User user) {
        System.out.println("update");
    }
    public void query(User user) {
        System.out.println("query");
    }
}


本来UserService类中的add、update、query方法都在用的。后来,某些功能砍掉了,现在只有add方法真正在用。


某一天,项目组来了一个新人,接到需求需要在user表加一个字段,这时候他是不是要把add、update、query方法都仔细看一遍,评估一下影响范围?


后来发现只有add方法需要改,他心想前面的开发者为什么不把没用的代码删掉,或者标记出来呢?


在java中可以使用@Deprecated表示这个类或者方法没在使用了,例如:


@Slf4j
@Service
public class UserService {
    @Autowired
    private UserMapper userMapper;
    public void add(User user) {
        System.out.println("add");
    }
    @Deprecated
    public void update(User user) {
        System.out.println("update");
    }
    @Deprecated
    public void query(User user) {
        System.out.println("query");
    }
}


我们在阅读代码时,可以先忽略标记了@Deprecated注解的方法。这样一个看似简单的举手之劳,可以给自己,或者接手该代码的人,节省很多重复查代码的时间。


建议我们把没用的代码优先删除掉,因为gitlab中是有历史记录的,可以找回。但如果有些为了兼容调用方老版本的代码,不能删除的情况,建议使用@Deprecated注解相关类或者接口。


18.随意修改接口名和参数名


不知道你有没有遇到过这种场景:你写了一个接口,本来以为没人使用,后来觉得接口名或参数名不对,偷偷把它们改了。比如:


@PostMapping("/query")
public List<User> query(@RequestBody List<Long> ids) {
    return userService.query(ids);
}


接口名改了:


@PostMapping("/queryUser")
public List<User> queryUser(@RequestBody List<Long> ids) {
    return userService.query(ids);
}


结果导致其他人的功能报错,原来他已经在调用该接口了。


大意了。。。


所以在修改接口名、参数名、修改参数类型、修改参数个数时,一定要先询问一下相关同事,有没有使用该接口,免得以后出现不必要的麻烦。


对于已经在线上使用的接口,尽量不要修改接口名、参数名、修改参数类型、修改参数个数,还有请求方式,比如:get改成post等。宁可新加一个接口,也尽量不要影响线上功能。


19.使用map接收参数


我之前见过有些小伙伴,在代码中使用map接收参数的。例如:


@PostMapping("/map")
public void map(@RequestBody Map<String, Object> mapParam){
    System.out.println(mapParam);
}


在map方法中使用mapParam对象接收参数,这种做法确实很方便,可以接收多种json格式的数据。


例如:


{
  "id":123,
  "name":"苏三",
  "age":18,
  "address":"成都"
}


或者:


{
  "id":123,
  "name":"苏三",
  "age":18,
  "address":"成都",
  "role": {
    "roleName":"角色",
    "tag":"t1"
  }
}


这段代码可以毫不费劲的接收这两种格式的参数,so cool。


但同时也带来了一个问题,那就是:参数的数据结构你没法控制,有可能你知道调用者传的json数据格式是第一种,还是第二种。但如果你没有写好注释,其他的同事看到这段代码,可能会一脸懵逼,map接收的参数到底是什么东东?


项目后期,这样的代码变得非常不好维护。有些同学接手前人的代码,时不时吐槽一下,是有原因的。


那么,如果优化这种代码呢?


我们应该使用有明确含义的对象去接收参数,例如:


@PostMapping("/add")
public void add(@RequestBody @Valid User user){
    System.out.println(user);
}


其中的User对象是我们已经定义好的对象,就不会存在什么歧义了。


20.从不写单元测试


因为项目时间实在太紧了,系统功能都开发不完,更何况是单元测试呢?


大部分人不写单元测试的原因,可能也是这个吧。


但我想告诉你的是,不写单元测试并不是个好习惯。


我见过有些编程高手是测试驱动开发,他们会先把单元测试写好,再写具体的业务逻辑。


那么,我们为什么要写单元测试呢?


  1. 我们写的代码大多数是可维护的代码,很有可能在未来的某一天需要被重构。试想一下,如果有些业务逻辑非常复杂,你敢轻易重构不?如果有单元测试就不一样了,每次重构完,跑一次单元测试,就知道新写的代码有没有问题。
  2. 我们新写的对外接口,测试同学不可能完全知道逻辑,只有开发自己最清楚。不像页面功能,可以在页面上操作。他们在测试接口时,很有可能覆盖不到位,很多bug测不出来。


建议由于项目时间非常紧张,在开发时确实没有写单元测试,但在项目后期的空闲时间也建议补上。


本文结合自己的实际工作经验,用调侃的方式,介绍了在编写代码的过程中,不太好的地方和一些优化技巧,给用需要的朋友们一个参考。