NCC 后台简析

参考

  1. What is the difference between dependency injection and dependency look up?

背景情况

部门开始推一个大项目 NCC,后台几乎沿用原 NC(有近十年历史),将原来 JavaSwing 画的重量前端换成了一种“轻量前端”,其实这种轻量前端一点都不轻量;SQL 完全是自己用字符串拼接起来的;开发模式是前后端分离联调的方式,但是因为后台代码都是部署在一个服务里头的,没有司库云(微服务架构)复杂。

从增删改查流程说起

对 Controller、Service 分层的讨论放在后面。

save

保存操作有以下特点。

  1. 批量
    对普通的 CRUD 操作,最大的开销是网络传输,NCC 系统中操作的往往是多个表关联在一起的复杂结构,如果分成多次请求,一方面对用户体验不好,一方面对效率也有所影响。因此 NCC 中的做法是将同类数据放到同一页签,转换为 json 格式的数据,如下所示:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    {
    "pageid":"36650BIS_CARD",
    "head":{
    "head":{
    "areaType":"form",
    "rows":[
    {
    "values":{
    "pk_bondissueregister":{
    "display":null,
    "value":null,
    "scale":"-1"
    }
    ...其他属性
    },
    "status":"2"
    }
    ],
    "areacode":"head"
    }
    },
    "bodys":{
    "repaymentplan":{
    "rows":[
    {
    "rowid":"136270.438b34a6204411a",
    "status":"2",
    "values":{
    "pk_bondrepaymentplan_b":{
    "display":null,
    "scale":null,
    "value":""
    }
    ...其他属性
    }
    }
    ],
    "areaType":"table",
    "areacode":"repaymentplan"
    }
    }
    }
  2. 差异化
    新增和更新操作皆为调用 save 接口,通过区别传递的数据中是否有 id 来进行区分。另外,更新操作会先从数据库中查询出旧的数据,并和客户端传参进行比较,若完全一致则直接跳过此次更新。

delete

执行流程和 save 几乎一样,只是系统中不会真的删除一条数据,而是采用“逻辑删除”的方案。

query

查询操作的过滤条件是由前台给出的,我没有经历过重客户端时代 NC 的开发,但是在 Web 应用中也这么做有点奇怪。

page

分页查询的思路比较奇怪,是先按前端传递的过滤条件查出所有合适记录的 pk,然后在 Controller 层对这些 pk 进行分页:

1
2
3
4
5
6
7
8
// ids指的是查出的所有合适记录的pk
// 本页之后的数据数量
int afterCurPageSize = ids.length - pageInfo.getPageIndex() * pageInfo.getPageSize() + 1;
int targetSize = pageInfo.getPageSize() < afterCurPageSize ? pageInfo.getPageSize() : afterCurPageSize;
String[] targetIds = new String[targetSize];
System.arraycopy(ids, pageInfo.getPageIndex() * pageInfo.getPageSize(),
targetIds, 0,
targetSize);

最后再调用一个批量查询的 Service 接口进行查询。

技术点

轻量前端、分层

轻量前端指的是用 React 开发的浏览器客户端,既然是轻量前端,那么做的事情肯定不会太多了,那么很多工作就必须要转移到后端了,因此在 NCC 中又引入了一个 Action 层,作为 Controller。

读写分离

后台 Service 层将读操作和写操作划分到了两个接口内,实际上应用层可以根据读写进行复制,一般来说读操作更频繁,可以多创建几个实例提供服务,并在前头使用负载均衡器进行反向代理。

SOA

SOA 是一种架构风格,与微服务的主要区别是 SOA 多了一个 ESB(企业服务总线),在 SOA 中,当一个服务需要调用另一个服务时,它需要先从 ESB 中获取那个服务的调用方法、消息格式等,而微服务可以看作一种轻量化的 SOA,它没有集中的一个 ESB,服务之间通过公开的 RPC 接口来相互调用。
实际上 NC 没有 ESB 这玩意。首先需要将 Service 接口及其对应的实现类注册到一个 xml 文件内,服务间调用通过 NCLocator.find(class)实现:如果配置文件中没有设置服务器 URL,则直接本地反射实例化 Service 的实现类;如果配置文件中设置了服务器 URL,则使用 JDK Proxy 做动态代理,将本地方法调用转换为远程调用。

元数据

元数据是描述数据的数据,实际开发业务前需要先使用相关工具”画”出元数据,NCC 的开发极大地依赖于元数据的正确性,老的功能节点的元数据若轻易修改,很有可能导致原功能出错。

模板

模板是对页面的抽象,平台的有关部门做了一个画模板的工具,由后端人员登录到工作桌面画模板,可以减轻前端开发人员的工作压力(可能是领导觉得前端比较稀缺?)。可以从元数据导出模板,并根据原型图进行适配。
随着业务的复杂化,将许多公共组件抽象出来集中管理是必须的,尤其是 NC 这么复杂的庞然大物,其业务拿出两个小时来也没办法讲完 1%,有点夸张,但是事实是这里的需求很多都是从会计等行业转来的,一次给我们讲需求时从 NC 上满屏的标签中挑了部分讲了两小时,大家都是崩溃的…因此 NC 中引入了三大模板的概念,它们分别是:查询模板、打印模板、计算模板,分别对查询 sql、打印、???进行了抽象。

依赖查找

Spring 的核心原理是 IoC,而 IoC 的主要实现方式是 DI 或依赖查找。DI 我们比较眼熟,主要有”属性注入”、”构造器注入”及”方法注入”。NCC 后台为 NC,已经有 10 来年历史,其中实现了一套依赖查找的模式,如下代码所示。

1
IXxxService xxxService = ServiceLocator.find(IXxxService.class);

当然接口和实现类之间不是自动关联在一起的,还需要定义一个 upm 文件来配置他们之间的关联关系。
这实际上和比较原始的 Spring 用法有点像,如下代码所示。

1
XxxBean bean = application.getBean(XxxBean.class);

内存锁

排他锁

就是普通的锁。

1
2
3
4
5
6
7
8
9
10
11
boolean isOK=false;
try{
isOK = PKLock.getInstance().acquireBatchLock(new String[]{ PK }, userid);
if(!isOK)
throw new BusinessException("并发操作");
// TODO: 业务逻辑
} finally {
if(isOK){
PKLock.getInstance().releaseBatchLock (new String[]{ PK }, userid);
}
}

共享锁

共享锁适用于读者写者的情景,即某类操作可以并存、而和其他类操作不能的情况。
使用方法和排他锁差不多:

1
2
PKLock.getInstance().acquireBatchLock(
new String[]{ PK + IPKLockBS.STR_SHARED_LOCK }, userid );

对同一 PK 来说,共享锁和排他锁是不能共存的。

动态锁

动态锁为一种特殊的锁,进行加锁的人员不用自己释放,其调用序列一般为:
pklock.acquireLock… pklock.addDynamicLock…
如果是远程调用,远程调用结束后系统会自动释放所有的动态锁
动态锁其实是对前面两种锁的封装,不过动态锁不需要主动解锁,中间件会在后台事务结束前进行统一解锁;并且在同一次中间件事务中,可以重复申请相同 PK 的锁

1
boolean isOK = PKLock.getInstance().addDynamicLock( PK ); 

动态锁的实现原理是事件机制,动态锁最后会发出一个解锁事件,业务操作执行结束后会触发这个事件。

时间戳校验

有了锁机制可以保证并发操作不会破坏数据一致性,但是不能保证用户的操作是对最新版本的数据进行的,因此需要引入时间戳机制。
主要通过比较当前对象的ts 值(时间戳)与数据库中该对象的 TS 值来判断用户修改的是否为最新版本的数据。在每次对数据进行修改操作时,都需要更新相应数据的时间戳标志 ts,这个一般由系统维护,不需要在业务中特殊处理。

动态代理

上面提到了动态锁机制但是没有具体说解锁的时机,假设所有含写操作的业务操作都需要加锁,难道要把加锁解锁逻辑都写一遍?NC 中为了解决这个问题也实现了一套 AOP 机制,原理是 JDK Proxy,NC 会为所有在配置文件中注册的 Service 创建动态代理,在动态代理中可以对锁等公共功能进行操作,从而大大简化业务代码。在这方面来讲,NC 也可以看作 Spring 的弱化版。

跨组件调用

NCC 后台是传统的单体式应用,所有模块几乎都放在一个服务里(用户买什么就把什么包含进来),跨模块的组件之间可能存在相互引用的关系,比如 a 需要将某些信息存入 b 中,就需要调用 b 提供的接口,而 b 在某些情况下也需要从 a 中获取某些信息,但 b 所在的 B 模块是一个更通用的组件,它不会为每个下游业务都新增代码。在 NCC 中,面对这种情况一般是由 a 来提供一个回调接口,将这个接口名保存到数据库中,然后告诉 b:”这就是 a 的接口啦”,类似于 RPC,不过将耦合的部分转移到了数据库中。

逻辑删除

数据库里的数据不会被直接删除,而是采取逻辑删除的策略,每条数据都有一个额外的dr(delete remark)字段用于标识一条数据是否被删除,查询时需要根据这个字段进行过滤。

Oracle

采用 Oracle 作为数据库,一是因为客户主要是大企业,安全、稳定有更高要求;二,数据量也确实不大,可能也用不着进行分库分表;三,作为延续了十几年的系统,当时“去 IOE 化”还没有提出,Oracle 仍是数据库领域最好的选择;最后,业务十分复杂,且底层 ORM 代码中存在大量直接使用字符串拼接出来的 SQL,想要完全移植到 MySQL 等数据库是不大现实的。

总结

以上我对最近工作中涉及到的东西进行了简单总结,嗯…产品底层仍有很多我未知的部分,一些与业务关联较大的代码也不适合拿出来分享,于是暂时告一段落吧。