Sdudoc后端开发个人总结
前言
在参与Sdudoc后端开发之前,我几乎对于后端的开发技术和框架都只还在有所耳闻的层面,对于开发一个完整的后端需要如何设计架构,怎么构架实体,我还并不清楚。在参与项目开发的初期,我首先花费了好几天,啃了一本Springboot后端开发的书籍,把后端开发的技术路线和操作方法论大致掌握了一些,这才有信心能接下Sdudoc后端开发这份重任,我相信以自己的学习能力和动手能力,胜任这份工作问题不大。
我怀揣着这份自信接手了学长的项目,从GitHub上面将项目扒下来。不曾想到,满怀期待地打开项目一看的同时,一盆冷水直接迫在了我的头上。首先映入眼帘的是好几个模块,并且还有很多我之前没有接触到的技术,比如Spring Security、Solr搜索引擎等等。模块数量大概在七个左右。当我打开一个模块来看的时候,又是铺面而来的七八个包裹,包裹下面又是若干个实体。我心想,这下有的看了。
学长的设计思路有点颠覆了当时的我对于后端开发的设计思想。我花了一个多星期摸清楚了整个项目的架构后,惊奇的发现,学长虽然没有使用现在流行的Spring Cloud微服务的框架,但是他却模拟出了一个微服务的模式:利用一个模块来专门接收前端的请求,这个模块相应的也集成了Spring Security鉴权,而其他模块并不会设计鉴权操作。学长原先的思路是只有一个模块来与前端交互,而这个模块功能是转发请求到对应的服务模块,而自身并不会有额外的功能。
但是这种手动打出来的“微服务”逐渐再学长后续的开发中变了味道,从git上的信息可以看出,学长逐渐对于模块的功能划分不再明确了,模块之间的耦合程度也提高,一个模块会调用另一个非公共模块的内容,比如实体。并且作为分发模块的“网关”也写了自己对外的接口,将自己打造成了一个功能性模块。除开这些违背设计初衷的地方,项目中还有不少错误,比如说接口的设计和前端具体所需大相径庭,接口之间存在不少Map传参的极度影响可读性和可维护性的操作,甚至连数据库都能访问错(比如doc模块访问的197服务器上的数据库,也就是服务部署的本机的数据库,而mysql模块访问的是199服务器上的数据库)。甚至还有搜索模块无法使用,搜索引擎宕机等一系列问题摆在眼前。整个后端虽然还有这代码的样子,但是已经完全运行不起来了,像一个濒危的患者。我大概就是面临这样一个濒危的患者,而我本身还只是一个见习的医生(可能还没有到见习的程度)。为了拯救它,我的策略是,给他一副新的身体,也就是,重构。
抢救
在重构之前我需要先将项目跑起来,以基于前端的同学后端的服务。大概花了一两天时间把数据库等基础设施的配置解决完毕,并且成功将项目中的一些影响运行的BUG修复,并将项目部署到服务器上。期间也进行了一些非功能性的代码整改,例如对于项目架构的冗余设计的整改:
当时项目中存在很多的没有意义的方法转接,比如从类1的insert方法直接调用类2的insert方法,类2insert直接调用调用类三的insert,十分的单一且冗余。这样调用完全失去了层层封装,不同层次不同分工的意义。我们应该消除掉这些冗余的调用,转为更加简洁的层次结构。
示例
既然 solrClient 是官方给予的 Solr 搜索引擎的客户端接口,那么就不需要自己再封装一个 SolrInput 来做无意义的转接。利用一个 Service 层专门服务于Solr的CRUD操作会更加的直接。再者,对于里面的一些字符串常量,应该将其封装为 static final 常量,以防止出现不必要的错误,并且增加可读性。
另外,参数里用了 Object 这样高度抽象的数据类型,会影响代码的可读性,应该将这个方法划分为对于具体类型的处理逻辑:
期间我的同学跟我说,这些规整代码的操作其实根本不必要,能跑就行,反正之后还会重构的。但是我并不认为规整代码毫无意义,在规整的过程中,我可以通过审视别人代码的问题来反问自己是否也会出现这样的问题,有则改之无则加勉。其次,在规整的过程中,我会频繁地采用设计模式里的各种思想,更加加深了我对设计模式的理解。再者,规整的过程中,我也能将学长之前的数据流动设计了解透彻,从而为我后续的重构打下基础。
收获
在规整学长的代码的过程中,我会不断地往里添加一些高效的设计思路。在这个过程中,我的收获还是非常多的:
-
规范化对于前端传参的检测。对于和前端交互的
Controller
层,灵活使用注解是非常重要的,比如说如果我希望Spring boot自动检测某一个字符串参数是否为空或为空白字符串,则可以调用@NotNull()
或@NotBlank()
注解,并且在其message
字段中给出报错的提示信息。对于前端传来的json
自动转换成实例,我可以在定义这一参数类的时候标识出某一个字段的硬性要求,比如title
不能为空字符,然后在controller
的参数列表中利用@validate
注解配合@RequestBody
注解自动解析并且检测前端传来的json
字符串:@ApiModelProperty("新建文章") @ResponseBody @RequestMapping(value = "/create_article", method = RequestMethod.POST) public CommonResult<SdudocArticle> createArticle( @Validated @RequestBody ArticleDTO rawArticle ) { System.out.println(rawArticle); SdudocArticle newArticle = articleService.createNewArticle(rawArticle); return CommonResult.success(newArticle, "成功创建了一个新的文章"); } //---- // 实体 //---- public class ArticleDTO implements Serializable { @NotBlank(message = "文章名称不能为空") private String title; private String dynasty; private String author; private String bookId; ... }
-
利用全局的
ExceptionHandler
处理报错信息,从而控制各种异常情况的错误提示。有时候后端内部会检测出一些异常,但是不对这些异常出现控制的话将会引起后端的崩溃,如果处理的话,每处理一个异常就要进行一次try catch
,还要考虑如何将错误信息传递给前端,这在之前是一个很头疼的事情。后来在自己探索的过程中发现,可以设置一个全局的ExceptionHandler
,这个Handler
中可以对于每一个异常类型进行处理,并且直接返回给前端:// ----------- // Doc模块中的ExceptionHandler缩略 // ----------- @Slf4j @RestControllerAdvice public class DocServerGlobalExceptionHandler { @ExceptionHandler(ConstraintViolationException.class) public CommonResult<String> constraintViolationExceptionHandler(ConstraintViolationException e) { log.info("400 : 参数请求错误 " + e.getMessage()); List<String> messageCollection = e.getConstraintViolations() .stream() .map(ConstraintViolation::getMessage) .collect(Collectors.toList()); return CommonResult.failed(messageCollection.toString()); } @ExceptionHandler(Exception.class) public CommonResult<String> exceptionHandler(Exception e) { log.info("500 : 服务器内部错误, 信息:" + e.getMessage()); e.printStackTrace(); return CommonResult.failed(e.getMessage()); } }
通过这样的管理,一旦后端在处理的时候遇到了什么意外错误,只需要抛出一个
Exceotion
,并且传入错误信息就可以了,将处理错误的操作交给handler
,实现解耦。(这样做也省了不少代码量)。特别地,之前提到的参数异常也可以通过这个Hander
来处理,对应ConstraintViolationException
。 -
此外还有一些新概念的了解,比如什么是
Cors
,如何使用JPA
框架,对于处理结果如何封装,如何模拟一个Http请求等等。
输入法模块
在抢救了基于学长设计的架构的项目后,我迎来了一个新的需求:输入法。不得不说,听到这个需求的时候我还是非常兴奋的,不仅是因为这个模块将会脱离学长之前的架构而单独存在,不会再有束缚,而且还是因为这将是我第一次独立设计一个完整的模块架构。
和项目的其他成员探讨需求后我了解到,我这个输入法并不一般,它的出现是为了解决对于古籍中一些未在各种国际编码规则中注册的古文字的键盘输入操作,我们希望这些古文字能够像普通的文字一样能够通过键盘输入而显示在输入框中。那么对于这个输入法的功能,我们可以进行一些具体的描述:
首先,我们必须明确一下几个关键点:
- 展示古文字
- 通过键盘输入获取到古文字
其次,我们可以考虑如下的功能:
- 根据用户打字习惯来对文字的输出进行排序,优先将用户常用的文字排在前头
- 多种键盘输入到古文字的映射
另外,输入法中的古文字实际上就是对于文档中的古文字的建模,我们需要完善模型的成员,以使得它能够包含古文字的所有信息。
对于实体,我们进行如下定义:
InputChar
属性名称 | 类型 | 描述 |
---|---|---|
id | String | 对于古文字的唯一性标识 |
char_svg | String | 该古文字的SVG信息 |
char_pron | String | 古文字发音,或者可以认为是查找古文字所有的字符序列 |
char_image_path | String | 古文字的图片路径 |
article_link | String | 古文字查找文章所用的反向索引 |
pass_audit | int | 该古文字是否通过审核 |
由于InputChar中有大量文本信息,所以我们将采用MongoDB进行实体的存储。
对于用户打字习惯的记录,我们利用redis做高速逆向索引,对于每一个用户,我们为其创建一个zset,zset的key为以用户id为基础生成,value为古文字id,通过不断记录使用次数来进行排序。由于用户打字习惯的记录并非硬性需求,所以虽然redis存在数据丢失的可能性,但是也处在可以接受的范围之内,主要是高速提供了非常高的效率。
ImageHeader
属性名称 | 类型 | 描述 |
---|---|---|
id | 图片头信息的唯一性标识 | |
checkSum | String | 图片字节码的校验信息,可以判断两个图片是否相同,防止图片存储的冗余 |
url | String | 图片路径,用于访问图片 |
pointCount | String | 图片被引用的次数 |
由于图片是一个静态资源,它的相关信息我们必须利用一个头文件来说明,这就像是操作系统中文件的头文件或者进程的PCB。
由于一个文字可能会包含多个读音或者标识,而对于不同的读音,同一个古文字会相应建模不同的InputChar,但是他们的图片信息却是一致的,这也就意味着我们要防止图片重复插入。相应的,当某一个古文字被移除后,其指向的图片不能立刻就被删除,这时因为这个图片可能还会被其他的古文字引用,如果删除就会出现严重的空指针错误。所以这里我采用的类似于Java中的对象内存管理的策略:
- 当一个古文字被创建出来后,其对应的图片信息需要存储在云端
- 存储该图片之前我们先根据其字节信息生成校验和,利用校验和去图片头文件库中查找是否有相同的校验和,如果有,则取出该头文件信息,让该古文字与这一头文件信息绑定,并且该图片头文件引用次数+1;如果没有找到,则创建一个新的图片头文件,并且初始化其引用次数为1。
- 当删除一个古文字时,需要将其指向的图片头文件引用次数减一,如果减完图片头文件引用次数为0,则删除该头文件信息并且清除对应的云端静态资源。
通过这样的管理机制就能够保证图片信息不会重复插入,并且不会出现无人指向的孤儿图片问题。
Sdudoc-input模块的架构大致如下:
花了几天左右的时间折腾出了这个Input模块。Input模块相比于其他模块而言,他独立性非常高,高内聚,可以脱离其他模块而存在,符合微服务的特性。其次,Input模块也是我第一次编写的后端模块,其中的每一个接口,每一种设计方式,都是由我自己设计并实现,相比于之前在学长的代码上修修补补,感觉效率高了太多。当多有接口的经过了ApiPost的测试并通过之后,内心的成就感还是非常大的。这也为我后续重写其他模块奠定了技术基础、理论基础和信心。此外,由于我个人对代码的简洁的追求,Input模块中的各个功能模块高度内聚,代码复用率高,这也导致了最终代码量看上去不多(浓缩是精华嘛)。代码量在1012行左右。
微服务架构
在写完Input模块之后,后端陷入了一段较长的停滞期。停滞期的到来并不是毫无征兆,前端的两个同学有其他的事情要忙,我的另外两位后端的同学要抓紧打OPPO杯,我这学期的计算机图形学的课程实验也布置了下来。前端的同学苦于学长遗留下来的编辑器引擎,这个引擎虽然是学长手写的,而且架构非常庞大,里面的设计也独具特色。但是,在前端的同学看来,编写这个引擎的游戏引擎思维他们并不熟悉,并且引擎扩展性并不高,很多部分为了开发的方便,是耦合起来的。这就像是一个巨石挡在了两位前端的同学的道路上,虽然它是巨石,不得不放任它在路上堵着——因为它是前端项目的核心。关于这个引擎,可能只有开发他的学长才能修改它,但是那位学长现在就职于腾讯的游戏部门,根本无暇照顾,本来说好要写的在线协作模块也因为工程量太大而半途而废。对于我而言,我非常喜欢图形学,我可以为了整天整天的坐在电脑前学习着图形学的内容,不知疲倦。闲暇之余可能会重写一下项目的模块,但是总体来说,进度还是缓慢。由于一直得不到前端同学的反馈,我的进度也是一拖再拖。
转机伴随着一位结束了OPPO杯比赛的同学的参与而到来。这位同学对于微服务架构和鉴权比较熟悉,他立刻就发现了学长的架构的不合理之处,并且认为现在的鉴权系统过于冗余,且不高效,正常都不会这么做。他大概花了一两个星期重构了用户系统和鉴权,配置了Nginx网关,引入了Nacos管理模块配置,Swagger管理接口文档,另外写了一个云端存储的小接口。它这么一调整,就如同为一个白血病患者换了一套新鲜血液一般,虽然并不是一些可以反映在前端那边的服务,但是对于后续的开发可谓是事半功倍,犹如砍树前磨锋利了的刀。
文档管理模块
过了几个星期,前端的同学终于是攻克了难关,开始给新的需求到后端来,需求内容大概是文档归档,文档管理,文档新建,文档页面标注管理等,以及一个由PDF转为文档的功能需求。基于之前完成过Input模块,除了PDF转换为文档的需求之外,其他的功能实现起来比较轻松。
在需求里面,文档是对于一个可阅读的文章的抽象,文章在显示时是由若干个页面构成,所以页面应该有其父文档存在。其次,对于每一个页面,其要包含需要渲染的Json字符串,这个Json字符串是引擎渲染页面的基础,后端不能对其随意更改。除此之外,Page还包含用户对其的标注信息,还应该包含文档内容等等…对此,我进行了如下建模:
SdudocArticle
属性名称 | 类型 | 描述 |
---|---|---|
id | String | 唯一性标识 |
title | String | 文章标题 |
author | String | 作者 |
bookId | 文档所属的书籍的ID | |
dynasty | String | 朝代 |
creatorId | String | 创建文档的用户的ID |
SdudocBook
属性名称 | 类型 | 描述 |
---|---|---|
id | String | 唯一性标识 |
bookAuthor | String | 书籍作者 |
dynasty | String | 朝代 |
bookName | String | 书籍名称 |
creatorId | String | 创建者ID |
SdudocImageHeader
属性名称 | 类型 | 描述 |
---|---|---|
id | String | 唯一性表述 |
url | String | 图片路径 |
上面三个由于是作为头信息保存起来,并不包含具体内容,而是类似于索引的形式,关联性强,需要强事务,所以采用MySql存储。
此外还有两个实体,SdudocPage与SdudocPDF,其内部包含具体的内容,内容比较大且全为字符串,故采用MongoDB存储:
@Document("sdudoc_page")
public class SdudocPage implements Serializable {
@Indexed(unique = true)
@ApiModelProperty("唯一性标识")
private String id;
@ApiModelProperty("标注后图片HeaderId")
@Field("image_src")
private String markedImageHeaderId;
@ApiModelProperty("原始图片HeaderId")
@Field("origin_img_src")
private String originImageHeaderId;
@ApiModelProperty("图片宽度")
@Field("width")
private int width;
@ApiModelProperty("图片高度")
@Field("height")
private int height;
@ApiModelProperty("json字符串信息")
@Field("json_string")
private String jsonString;
@ApiModelProperty("文字内容")
@Field("content")
private String content;
@ApiModelProperty("父文章ID")
@Field("parent_article_id")
private String articleId;
@ApiModelProperty("注释列表")
@Field("annotations")
private List<String> annotations;
}
@Document("sdudoc_pdf")
public class SdudocPDF implements Serializable {
// 唯一性标识
@Indexed(unique = true)
private String id;
// pdf名称
@Field(name = "name")
private String name;
// pdf路径
@Field(name = "src")
private String src;
// pdf转换成的图片的路径集
@Field(name = "imageHeaderIdList")
private List<String> imageHeaderIdList;
// 上传者ID
@Field(name = "uploaderId")
private String uploaderId;
}
在上述的模型之下,通过CRUD,就能够实现功能。
然而功能性需求完成了以后,我突然发现了一个非功能性的需求,由于文档数量的增多,很多请求总是需要访问完缓存后没有然后访问数据库,造成了一些性能的浪费,如何快速判断一个数据是否存在是一个问题。对此,我写了一个过滤器。
对于判断一个数据是否存在,过滤器可以这么设置:利用一个哈希函数和一个哈希表,如果将这个数据哈希过后发现其在表内并不存在对应的项,则意味着缓存里面不存在该数据,直接尝试请求数据库即可:
但是由于请求的数量庞大,很有可能会出现重复的哈希从而造成误判断,并且这个过滤器还对于哈希算法要求比较高,要是这个哈希算法并不是良好的哈希算法,那么就会出现较差的表现,对此,我才用了一个方法来解决:利用多个哈希方法,每个哈希方法能得到一个哈希值,如果这三个哈希值在哈希表中均有注册,则我们可以以非常高的置信度认为这个数据存在在缓存中,否则,就认为这个数据不在缓存中:
这样做就能让哈希的精度不在于哈希的质量,而是在于哈希算法的数量,对于大数量级的输入而言,会有比较可观的效果。
实现的逻辑如下:
public interface CacheFilter {
/**
* 是否存在该item
*/
boolean hasItem(String item);
/**
* 在过滤器中注册该item
*/
void registerItem(String item);
}
@Component
public class CommonRedisFilter implements CacheFilter {
static final HashMethod[] hashList = new HashMethod[] {
new BKDRHash(),
new APHash(),
new FNVHash()
};
static final int hashVolume = 10000;
int[] hashcodeContainer;
public CommonRedisFilter() {
hashcodeContainer = new int[hashVolume];
}
public boolean hasItem(String requestString) {
boolean result = true;
int[] hashCodes = getHashCodes(requestString);
for (int hashcode : hashCodes) {
if (!hasItemBitFromHashcode(hashcode)) {
result = false;
break;
}
}
return result;
}
public void registerItem(String requestString) {
int[] hashCodes = getHashCodes(requestString);
for (int hashcode : hashCodes) {
registerCodeIntoContainer(hashcode);
}
}
private boolean hasItemBitFromHashcode(int hashcode) {
int max_value = hashVolume * 32;
hashcode %= max_value;
int slotIndex = hashcode / 32;
int offset = hashcode % 32;
int slot = hashcodeContainer[slotIndex];
return ((slot >> offset) & 1) == 1;
}
private void registerCodeIntoContainer(int hashcode) {
int max_value = hashVolume * 32;
hashcode %= max_value;
int slotIndex = hashcode / 32;
int offset = hashcode % 32;
hashcodeContainer[slotIndex] |= (1 << offset);
}
private int[] getHashCodes(String source) {
int[] hashCodes = new int[hashList.length];
for (int i = 0 ; i < hashCodes.length ; i++) {
hashCodes[i] = hashList[i].getHashCode(source);
}
return hashCodes;
}
}
接着,我又思考了一个问题,如何快速的判断一个请求不在数据库中,我想可以利用两个CacheFilter来解决这个事情,一个Filter记录一定存在的数据,一个Filter记录一定不存在的数据。具体如下:
public interface EnhancedCacheFilter extends CacheFilter{
/**
* 从过滤器中移除该item
* 如果该item之前存在,返回true,否则返回false
*/
boolean removeItem(String item);
/**
* 声明该item不存在
*/
void declareNotExist(String key);
/**
* 声明该item存在
*/
void declareExistence(String key);
}
/**
* Removable 试图实现准确的将不存在于缓存和数据库中的请求拒之门外
* 直到相关数据被更新入了数据库
*/
@Component
public class RemovableRedisCacheFilter implements EnhancedCacheFilter{
private final CacheFilter nonExistenceFilter = new CommonRedisFilter();
private final CacheFilter existenceFilter = new CommonRedisFilter();
/**
* 如果item既不存在于existenceFilter,也不存在于nonExistenceFilter
* 说明可能在数据库中,而不在缓存中,允许通过
* 如果item不存在于existenceFilter,但是存在于nonExistenceFilter
* 说明该数据一定不存在,拒绝请求
* 如果同时存在,则认为数据可能存在,允许通过
*/
@Override
public boolean hasItem(String item) {
return ((existenceFilter.hasItem(item))
||
(!existenceFilter.hasItem(item) && !nonExistenceFilter.hasItem(item)));
}
@Override
public void registerItem(String item) {
declareExistence(item);
}
@Override
public boolean removeItem(String item) {
if (!hasItem(item)) return false;
declareNotExist(item);
return true;
}
@Override
public void declareNotExist(String key) {
nonExistenceFilter.registerItem(key);
}
@Override
public void declareExistence(String key) {
existenceFilter.registerItem(key);
}
}
另外,在重写doc的时候,也逐渐了解到了DO、BO、DTO这三个概念,我之前就有想到,给前端的数据的建模不一定要和数据库里的建模一致,二者之间应该是转换的关系,业务之间的调用也不需要将完整的数据库实体发给对方,只需要将必要的内容进行传递即可。
-
DTO(Data Transfer Object)数据传输对象
这个传输通常指的前后端之间的传输
-
PO(Persistant Object)持久对象
描述的是数据库中的实例,通常只有get和set方法
-
BO(Business Object)业务对象
xistenceFilter.registerItem(key);
}
@Override
public void declareExistence(String key) {
existenceFilter.registerItem(key);
}
}
另外,在重写doc的时候,也逐渐了解到了DO、BO、DTO这三个概念,我之前就有想到,给前端的数据的建模不一定要和数据库里的建模一致,二者之间应该是转换的关系,业务之间的调用也不需要将完整的数据库实体发给对方,只需要将必要的内容进行传递即可。
- DTO(Data Transfer Object)数据传输对象
这个传输通常指的前后端之间的传输
- PO(Persistant Object)持久对象
描述的是数据库中的实例,通常只有get和set方法
- BO(Business Object)业务对象