URI和前后端协约、注释
注意本文如不特殊说明,均将API和URI视作同义语。 URI应该怎么命名呢? 这是我刚开始学Java EE的第一个问题,那个时候命名大多还是遵循见名知义规则。命名大致都是getXX insert等等。后来了解到restful规范,就是用请求方式来表达对资源采取的动作,其实对资源的更新也就四种,也就是CRUD, 新增对应post,R是读对应get,U是更新,D是删除。但是有了restful规范之后,在是微服务之后,我们的接口大致都是: /项目名/v1/表名/。然后一个典型的场景就是表名下面一个单词表达不了这个接口的作用该怎么办,再往下一层,有的时候这样会让URL很长,还是驼峰命名,还是下划线命名?
- /项目名/v1/表名/a/b
- /项目名/v1/表名/a_b
- /项目名/v1/表名/aB 该如何选择呢? 我觉得这个问题是通用的,于是就去找了Alibaba出品的《Java开发手册》嵩山版,嵩山版是目前最新的,有前后端API的协定。 Alibaba出品的《Java开发手册》嵩山版这么描述:
- 【强制】前后端交互的 API,需要明确协议、域名、路径、请求方法、请求内容、状态码、响 应体。 说明: 1 协议:生产环境必须使用 HTTPS。 2 路径:每一个 API 需对应一个路径,表示 API 具体的请求地址: a 代表一种资源,只能为名词,推荐使用复数,不能为动词,请求方法已经表达动作意义。 b URL 路径不能使用大写,单词如果需要分隔,统一使用下划线。 c 路径禁止携带表示请求内容类型的后缀,比如".json",".xml",通过 accept 头表达即可。 3 请求方法:对具体操作的定义,常见的请求方法如下: a GET:从服务器取出资源。 b POST:在服务器新建一个资源。 c PUT:在服务器更新资源。 d DELETE:从服务器删除资源。 我们的问题解决了,选择应该是/项目名/v1/表名/A_B,那我还是有问题:
- 为什么/项目名/v1/表名/aB被否决?
- 为什么不是中划线、上划线? 驼峰式的被否决的一个原因是在输入时要求输入大小写,增加输入难度,也容易输错,URL还对大小写敏感。 那下划线还要用shift呢?唯一来说抛弃驼峰命名法的原因就是行业内不成文的惯例,习惯上全用小写。 URL路径有三种命名方案:
- 驼峰(行业内采用的不多)
- 下划线分割(也称蛇形命名法,淘宝系采用比较多)
- 中划线命名法(脊柱命名法, github,stackOverFlow采用) 我看知乎是既有脊柱命名也有蛇形命名,脊柱命名。每种命名代表不同的不同的含义吗? 是中划线,还是下划线,我觉得团队内统一就好,应当尽量避免太长。
API中的版本号
如果你留心的话,会发现有的网站的API会带v1或v2。 比如掘金:如果你留心响应的话,我们还可以从响应头中看出该网站使用的web服务器是哪一个。 比如掘金:可能你对一些知名的WEB服务器比如: Tomcat、Nginx,比较熟悉了。 那这个Tengine是什么鬼?Tengine是Nginx的改装版,完全兼容Nginx。顺便提一下,Tengine有Nginx的教程,有兴趣的同学可以看下。
那么问题来了,为什么API中要有版本号呢? 这是一种扩展性和复用性设计,假设你写了一个接口供别人调用,然后你完成了接口的设计,对外暴露出去,然后很快需求变更来了,你需要改动你的接口,但是又不想影响之前的调用者,这个是时候通常有两种方案:
- 向前兼容
- 重新写一个 一般情况下,程序员大多都会选择自己重新写一个,毕竟不断的向前兼容会导致代码不断的膨胀,不方便维护。 重新写一个,那URI的命名呢,事实上我只改动了上一个接口的某个流程,从使用含义上他们可以是一个,这也就是引出了版本号的概念。 我们再举一个场景,就以地图类程序为例吧,高德地图发布了一些接口,许多程序也调用了,那么随着技术的发展,高德地图发现有些接口可以更优雅,速度更快。那这个时候怎么办,直接升级旧的接口?到时候如果导致使用高德地图的系统出了问题,那些系统的设计者估计也是要骂娘的,事实上也无法保证百分之百完全兼容,要做到百分之兼容最好就是不改动,那我重写的借口呢? 你就别重名了,加个版本号吧。 简单而又实惠。
那如何优雅的加版本号又跟RestFul规范兼容呢? 一般来说有以下几种方式:
//i.snssdk.com/log/sentry/v2/api/slardar/batch/ (掘金的加法)
- 在请求中加入版本号(找了几个比较知名的网站,发现都没有往请求头中放版本号的)
两种设计哪种比较好呢? 众说纷纭,没有一个一致的答案,一般情况下,我是习惯在URI上加版本号,更加清晰一点。
擅用库,尽量避免自己造轮子
在刚学Java的时候,当时讲课的时候,老师讲的是Java的生态好,框架多,我当时还没什么体会。后来参加工作之后,才慢慢的有体会了,各种各样的库,当时意识里面不懂的用库,用的主要还是JDK中带的。后来写了一些代码,看了一些代码,发现成熟的程序员还是善于用库的。举一个例子, 就比如说集合判空的话,可能很多人下意识的会用集合的size属性去做,这样做的风险是假设集合是null,你用size属性去判空就会空指针异常。那么其实你可以再写一步,先判断是不是null,再用size属性去判断。我当时是这么写的,然后有一天就看到了一个前辈的代码,他用的是Apache出品的一个工具类: CollectionUtils,大致是这么写的:
CollectionUtils.isNotEmpty() 复制代码
我觉得这个很优雅,事后我在写一些判定的时候,就会有意识的借助工具类。Java领域有不少类似的工具类,你能想到的,你想不到的都有,目前来说我日常比较喜欢的使用的工具类库有以下两个:
- Guava 固定顺序排序
有个需求是这样的,按指定顺序排序,比如指定顺序是南风、北风、西风、东风。输入的可能是这四个的任意一个,你需要将他们总是排成指定顺序。后来我找到Guava下有一个叫固定排序器的,Apache commons下面也有。
- Apache commons
commons系列有很多,像commons-lang(主要是对Java.lang下面的操纵),commons-Collections(主要是对集合的操纵)。 逗号分隔转集合,集合转逗号分隔。以前我不知道的时候,都是自己写,虽然不是很难,还是花了一部分时间的。 关于这两个类库这里不详细介绍了,因为相对来说比较庞大,有兴趣的同学可以自己去查些相关的资料。
异常该怎么处理?
关于异常,我在《走进异常》已经充分讨论过了,对异常还有点不大清楚的可以看一下。 Java里面的异常一共有两种,事实上我们应该说可抛出(Throwable)的意外,为什么说是意外呢? 因为没有程序员希望程序出错,但是出错又是不可避免的,于是我们就希望程序在出错的时候,能把错误描述的详细一点,以方便程序员定位问题,这是可抛出的意外设计的思想,这样讲可能有些拗口,我们在说Java里面的异常的时候一般说的都是Exception,但是Exception继承至Throwable,Throwable有两个子类Error和Exception,一般来说一个设计良好的程序是不应该处理Error的,在测试和设计阶段应当将Error都消除掉,我们比较熟悉的是NoSuchMethodError、NoSuchFieldError。
所以一般来说,我们的关注点都在Excepiton上,关于Exception又可以分两类:
- 检查异常 checkedException
通俗的说, 编译器强制让你抛出的,不抛出编译不给过的,就是检查异常。 比如FileNotFoundException通常在使用IO流的时候经常出现。
- 非检查异常 unCheckedException
RuntimeException和它的子类 关于异常来说,我们的处理的方式一般是两种:
- try catch 捕获
- throws 自己处理
《Java开发手册》对于异常如是描述: 捕获异常是为了处理它,不要捕获了却什么都不处理而抛弃之,如果不想处理它,请 将该异常抛给它的调用者。最外层的业务使用者,必须处理异常,将其转化为用户可以理解的内容。 这段描述属于强制级别,关于这段,对于运行时异常,我是理解的,但是对于检查性异常,我就有些不理解了。
关于讨论检查时异常和非检查时异常,我们需要再考虑一下这两类异常的使用场景,我个人认为检查时异常是没有必要的,我认为检查时异常某种意义上应当算作运行时异常,不是很理解检查时异常的意义。
一个支持检查型异常的理由是: 知道程序在运行中可能会发生过什么,并处理他,增强程序的健壮性。如果取消掉检查性异常,那么程序员可能就忽略掉一些关键的异常没有捕获导致程序的健壮性降低。这是个相当充分的理由,事实上就算是有了检查性异常,我们的程序依旧可能会出现这些异常,一般的java编码人员好像并没有从这种检查型异常学到多少,在操作文件的时候,运行时抛出一个FileNotFoundException也是理所应当的,同样运行在JVM上的Kotlin就抛弃了检查型异常这个设计。
通用准则是: 如果判断客户端能从异常中合理的恢复,那么他应当是检查型异常。如果客户端如果采取任何错事都无法从异常恢复到正常,那么应当就是非检查型异常。
我个人认为关于I/O方面的异常,是非常常见的,一般情况下都是合理的恢复不了的,文件都找不到了,我还怎么恢复,一般代码这个的时候都是无能为力的,最快还是程序员们检查路径。 还有反射中的检查型异常,比反射的时候根据名字去获取对应的字段有一个NoSuchFieldException让我处理他,这是能合理恢复的吗? 这不合理,我觉得不应该处理这类异常,但是你不处理,在调用的时候也要抛,最后代码就会变得十分丑陋,每层都会带上这种异常。假设我写的方法出现了一些检查型异常,然后我就只能自己处理他,假设我要抛出去,可能顶层调用者也不知道该怎么处理他。所以我只得自己处理检查型异常,
Java社区也在思考检查型异常是否是一个失败的设计,有兴致的同学可以去查下对应的资料。一般情况下出于代码的简洁性,避免代码膨胀过度,《Thinking In Java》推荐的作法是将检查型异常转换为非检查型异常,这也是Spring采用的作法,是Spring将访问数据时出现的检查型异常转换为非检查型异常,那么如何转呢?
catch(Exception e){ throw new RuntimeException(e); } 复制代码
对于检查性的异常,我们处于简洁代码的考虑,我们将其捕获,或者转为非检查型的异常。 那么对于非检查型的异常呢,我们应该怎么处理呢? 我觉得我们总是办法杜绝所有的意外,而生活总会有意外,但是频发的我们应当尽量避免。Dubbo的开发指南的《魔鬼在细节》就如是说到: 防止空指针和下标越界,这是我最不喜欢看到的异常,尤其在核心框架中,我更愿看到信息详细的参数不合法异常。这也是一个编写健壮程序的开发人员,在写每一行代码都应在潜意识中防止的异常。基本上要能确保每一次写完的代码,在不测试的情况下,都不会出现这两个异常才算合格。 Dubbo的开发文档有谈及设计相关的东西,有兴致的话可以去Dubbo的官网看下。
日志该怎么打?
程序出错的时候,我们总是希望信息能够尽量详细一些,以方便我们定位问题。有的开发人员可能在没接触日志的时候喜欢使用: System.out.println()。 缺点是没有带有时间,有人说我可以再加入时间,事实上我们不必重复造轮子,日志更为详细。 这些《Java开发手册》描述的更为详细,我们这里只挑比较重要的讲一下: Java的不同的日志级别有不同的级别,我们比较关注的是:
- info 一般处理业务逻辑的时候使,用于说明此处干什么。(不要滥用,在值得注意的点加入即可)
- debug 一般放于程序的某个关键点的地方,用于打印一个变量值或者一个方法返回的信息之类的信息
- error 用户程序报错,必须解决的时候使用此级别打印日志。
- warn 警告,不会影响程序的运行,但是值得注意。 记录日志时请思考:这些日志真的有人看吗?看到这条日志你能做什么?能不能给问题排查带来好处?
- 异常信息应该包括两类信息:案发现场信息和异常堆栈信息。如果不处理,那么通过关键字 throws 往上抛出. 推荐写法:
logger.error("inputParams:{} and errorMessage:{}", 各类参数或者对象 toString(), e.getMessage(), e); 复制代码
- 日志打印时禁止直接用 JSON 工具将对象转换成 String。
如果对象里某些 get 方法被覆写,存在抛出异常的情况,则可能会因为打印日志而影响正常业务流 程的执行
幂等性简介
为什么我们要了解幂等性呢? 我们先来看一个例子,双十一的抢购,我们很可能一次购买失败,然后又发起了一次,然后多次发起。尽管它多次发起了购物请求,但是你扣多份款,消费者肯定会不愿意的。这个时候我们就可以采用幂等策略。我们的库里存了一个字段记录它被修改的次数,当用户请求过来的时候,我们先拿用户的请求携带的修改次数和库里面的做对比。 如果相等说明还没扣款,如果不等说明已经扣款成功。这是幂等的一个应用场景,避免重复提交的影响数据。
第二个应用场景就是保证客户端看到的数据是最新的,还是秒杀场景,我们以网页端为例,才秒杀场景下,商品数量可能更新不及时,假设最后就剩一件了,然后很多客户端都看见了,那我们的程序就只能承认第一个到达的请求,商品数量也不能成负数。
总结一下
以上就是我认为一个Java服务端工程师应该具备的基本素养,我是将爱护自己的身体放到第一位,因为身体是革命的本钱。 本来打算一篇就搞定的,没想到篇幅越拉越长,只好将SQL和代码提交规范拆到另一篇中。