页面组件化的架构设计与思考

本文主要用于分享前后端组件化架构设计的实践经验与思考。

现状

职责不清:前后端没有明显的业务边界,业务逻辑耦合严重;

职责不清会让需求拆解和分析造成困扰,也会造成逻辑的前后端的高度耦合,提高后期的维护成本。

由于页面的任何改动都需要前后端的支持,整体改动的开发联调工作量以及排期冲突问题都难以接受。

页面性能:前后端交互逻辑由于年久失修,存在大量的无效请求和重复请求;

由于页面存在大量的无效业务逻辑和资源请求,导致页面渲染性能较差,影响用户体验;

后端的无效资源请求对服务器和数据库都会造成不必要的压力,属于资源的浪费

目标

O1:开发提效

从根本上解决前后端耦合的问题,前端仅关注组件化通用能力的建设,业务逻辑全部内聚在后端;

页面的动态渲染功能实现配置化、定制化,用于满足不同改动量的需求;

O2:体验优化

后端接口调用链路优化,提高资源利用率,减少无效请求和无效的处理逻辑;

页面资源依赖优化,从整体页面的视角来分析解决页面渲染性能的问题;

落地

考虑到页面逻辑比较复杂,需要人肉来分析识别有效的业务逻辑,因此拆分两个阶段来完成目标:

阶段1:梳理前端的业务逻辑,优化无效的资源请求,业务逻辑内聚在后端

阶段2:页面组件化schema设计,实现组件spi扩展、资源自动装配等能力

阶段1

阶段1主要解决了页面渲染层面业务逻辑的耦合问题,初步实现了前后端分离的目标。

  • 前后端业务逻辑梳理汇总
  • 页面抽象和页面组件化拆分
  • 后端资源加载内聚统一

业务逻辑的梳理是关键,为了避免逻辑的遗漏,通过开发和测试的不同视角来分析和对比业务逻辑:

  • 开发:从代码层面,阅读前后端的代码逻辑并梳理出一套页面业务逻辑;
  • 测试:从功能层面,覆盖页面的所有case用例输出一套页面的测试case;

为了实现页面的自动降级和分段加载,需要对页面整体做一次组件化抽象,拆分思路如下:

  • 按职责拆分:以功能属性来拆分组件,组件功能内聚(职责单一);
  • 按顺序加载:以功能的优先级来分组组件,分段来加载不同组件,实现页面加载的体验优化;

基于上下文的思想实现页面依赖资源的统一管理,避免资源的重复加载造成的性能问题;

1
2
3
4
5
6
7
8
9
10
public class Context {

private AResource aResource;
private AResource bResource;
private AResource cResource;

public Context(AResource aResource, ...) {
....
}
}
阶段2

阶段2主要解决组件的扩展能力以及资源管理的进一步优化。

  • 组件的通用化设计
  • 基于SPI的扩展能力
  • 写时复制的资源管理

组件的通用化设计不仅包含展示层面的组件设计,也包含表单、联动的设计模式。

组件的抽象定义是为了解决组件的复用性,通过定义通用的展示、表单组件,实现自定义的页面组件渲染能力。

为了提高页面的扩展能力,对于通用组建提供基于SPI的扩展点,业务上可以很快的实现组件的重载和扩展,同时对不同的业务实现业务的隔离。

在第一阶段,虽然把资源管理独立出来,但对于不同场景下的资源加载并没有单独优化,因此考虑从资源管理上入手,利用写时复制的思想来解决资源惰性加载的能力,并通过线程cache的手段避免资源的频繁加载,最终实现一个无需使用者关心的资源中心。

Schema加载

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
// Schema解析起接口定义,通过实现ISchemaParser接口来注册加载新的组件
public interface ISchemaParser {
// Schema枚举
SchemaEnum getSchemaEnum();
// Schema结构
SchemaStructure get();
}

// Schema配置
@Component
public class SchemaParserConfiguration implements InitializingBean {

@Autowired
private List<ISchemaParser> schemaParsers; // 利用Spring自动注入

private Map<SchemaEnum, ISchemaParser> mapping = new HashMap<>();

public SchemaStructure getBySchemaEnum(SchemaEnum schemaEnum) {
if (mapping.containsKey(schemaEnum)) {
return mapping.get(schemaEnum).get();
}
return null;
}

// 利用InitializingBean初始化映射mapping
@Override
public void afterPropertiesSet() throws Exception {
if (CollectionUtils.isEmpty(schemaParsers)) {
return;
}
for (ISchemaParser schemaParser : schemaParsers) {
mapping.put(schemaParser.getSchemaEnum(), schemaParser);
}
}
}

schema转换

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
// Schema转换接口定义,通过实现ISchemaTransfer来实现转换器的注册
public interface ISchemaTransfer<T extends IView, R, C extends BaseContext> {
ViewTypeE viewType();
R transfer(T t, C context);
}

// Schema转换配置
@Component
public class SchemaConfiguration implements InitializingBean {

@Autowired
private List<ISchemaTransfer> schemaTransfers;
private Map<ViewTypeE, ISchemaTransfer> mapping = new HashMap<>();

public ISchemaTransfer getByType(ViewTypeE viewTypeE) {
return mapping.getOrDefault(viewTypeE, null);
}

@Override
public void afterPropertiesSet() throws Exception {
if (schemaTransfers != null) {
for (ISchemaTransfer schemaTransfer : schemaTransfers) {
mapping.put(schemaTransfer.viewType(), schemaTransfer);
}
}
}
}

资源上下文管理

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
public class Context {

private Map<String, Object> req; // 存储入参

private IAContextA aContext; // 绑定资源,通过外部Spring注入后作为初始化参数引入
private IBContext bContext;
...

// 初始化资源
public static Context init(SpingBean springBean) {
IAContext aContext = new DefaultAContext();
aContext.setSpringBean(springBean);

Context context = new Context();
context.setAContext(aContex);
return context;
}
}

public class IAContext {
Resp method(Req req);
}

public class DefaultAContext {

private SpingBean springBean;
...

public Resp method(Req req) {
// 线程级别的缓存,避免资源重复调用
return threadCached(cachedPrefix, uniqueKey, () -> a.method(req));
}

}

基于spi的模块化设计

1、定义展示类型,由于区分不同场景下使用的模块信息;

1
2
3
public interface IView {
ViewTypeE viewType();
}

2、定义组件spi,以预下单为例,抽象出标题、价格、数量、规格等能力,并指定页面类型为预下单;

1
2
3
4
5
6
7
8
9
10
public interface IPreOrderView<C extends BaseContext> extends IView, ICustomMatch {
@Override
default ViewTypeE viewType() {
return ViewTypeE.PRE_ORDER_PAGE;
}
String getTitle(C context);
String getTotalPrice(C context);
String getTotalCount(C context);
String getGoodsSpecification(C context);
}

3、实现spi,主要是利用资源上下文来获取相关资源,渲染模块信息;

1
2
3
4
5
6
7
8
@DefaultView
public class DefaultPreOrderView implements IPreOrderView<SingleOrderContext> {
@Override
public <T extends BaseContext> boolean matched(IView view, T context) {
return true;
}
...
}

4、为了实现扩展性,按照默认+扩展的设计思路,优先定义了两种实现策略;

1
2
3
4
5
6
7
8
9
10
11
12
13
// 默认实现
@Component
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface DefaultView {
}

// 扩展实现,可增加优先级控制
@Component
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface ExtensionView {
}

5、基于构造器的思想,利用资源上线来构件通用的组件,

1
2
3
public interface IViewBuilder<T extends IView, C extends ViewContext> {
List<T> build(C context);
}

页面定义,利用多个spi来组装一个完整的页面结构

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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
// 使用构造器来构造页面
public interface IViewBuilder {
<C extends BaseContext> List<IView> build(C context);
}

// 抽象构造器提供了组件扫描、注册、识别的功能
public abstract class AbstractViewBuilder<T extends IView> implements IViewBuilder, ApplicationContextAware, InitializingBean {

private MatcherConfiguration matcherConfiguration = new MatcherConfiguration(); // 匹配器
private ApplicationContext applicationContext;
private List<Class<? extends T>> definitions;
private Map<Class<? extends T>, List<T>> defaultImplements = new HashMap<>();
private Map<Class<? extends T>, List<T>> extensionImplements = new HashMap<>();


protected abstract Class<T> getViewType();

protected abstract List<Class<? extends T>> getScanDefinitions();

@Override
public <C extends BaseContext> List<IView> build(C context) {
return matcherConfiguration.doFilter(definitions, defaultImplements, extensionImplements, context);
}

/**
* 初始化定义实现
* @throws Exception
*/
protected synchronized void initSpiImplements() throws Exception {
definitions = getScanDefinitions();
...
Map<String, T> beans = applicationContext.getBeansOfType(getViewType());
beans.values().stream().forEach(bean -> {
for (Class<? extends T> definition : definitions) {
if (definition.isInstance(bean)) {
if (bean.getClass().getAnnotation(DefaultView.class) != null) {
register(defaultImplements, bean, definition);
log.info("ViewBuilder For {} default {}", getViewType().getSimpleName(), definition.getSimpleName());
} else if (bean.getClass().getAnnotation(ExtensionView.class) != null) {
register(extensionImplements, bean, definition);
log.info("ViewBuilder For {} extension {}", getViewType().getSimpleName(), definition.getSimpleName());
} else {
log.info("ViewBuilder For {} ignore {}", getViewType().getSimpleName(), definition.getSimpleName());
}
}
}
});
}
...
}

// 预下单的页面组装
public class PreOrderViewBuilder extends AbstractViewBuilder {
private static List<Class<? extends IView>> spiDefinitions = new ArrayList<>();
static {
// 指定顺序
spiDefinitions.add(IPreOrderView.class);
}
@Override
protected List<Class<? extends IView>> getScanDefinitions() {
return spiDefinitions;
}
...
}

6、为了实现组件的扩展能力,实现了自定义的匹配接口;

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
public interface IMatch {
<T extends BaseContext> boolean matched(IView view, T context);
}

// 自定义匹配
public interface ICustomMatch<CC extends BaseContext> extends IMatch {
}

// 匹配逻辑
public abstract class AbstractMatcher implements IMatch {
public abstract <T extends BaseContext> boolean doMatch(IView view, T context);
@Override
public <T extends BaseContext> boolean matched(IView view, T context) {
...
}
}

// 匹配器的注册
public class MatcherConfiguration<T extends IView, R extends BaseContext> {
private static List<IMatch> MATCHERS = new ArrayList<>();
static {
MATCHERS.add(new CustomMatcher());
MATCHERS.add(new StateMatcher());
}
...
}
保障手段

为了降低重构后页面整体的风险,通过组件隔离、页面灰度、整体页面监控等多个手段来解决页面的问题发现和降级问题。

隔离

为了提高整体页面的可用性,通过隔离不同组件的加载和渲染,保证整体页面基本可用的状态。

灰度

灰度策略一般选用用户id作为灰度维度,但针对复杂的页面来说,测试很难充分的覆盖每一种业务场景,因此可以考虑基于场景来灰度,具体可以从最基础的业务场景出发进行灰度,逐渐放量到不同的业务场景。

监控

监控方面,可以从前端监控和后端监控两个方面入手:

前端监控可以覆盖用户的实际使用场景,包含页面的打开成功率、页面打开耗时、组件渲染成功率等。

后端监控可以从系统整体性能和异常发现角度来考虑,包含接口的RT、组件的渲染成功率、组件的渲染异常等角度来发现问题。

多表单联动

简单的form标单很容易通过schema这套协议来实现,但对于复杂表单联动很难通过简单的schema协议来实现;

利用后端来维护整个页面的状态来实现多表单的联动。

目前我们通过后端来维护整个页面的渲染逻辑,也就是包含多个组件的联动关系由后端整体下发,举个例子:

  • 页面分表有两个表单项,B表单项会根据A表单项的选择结果来返回;
  • 前端第一次请求后端,会返回A所有的可选项目,B表单不会返回;
  • 当用户选择好A表单后,会向后端请求一次新的页面信息,此时会下发A所有的可选项目以及A当前的选择,并且会下发B表单的可选项;
  • 当用户选择B表单后,等待用户提交表单,此时完成一次表单的提交;

经过上面这个过程,有没有发现什么问题?

页面联动的逻辑前端并不感知,依赖前端的每一次操作都会向后端请求,获取整个页面的最新状态;

由于引入了页面状态,会导致后端代码变得及其复杂,在实践过程中,前端的工作量整体上是有略微的降低,但后端的工作量会明显的提高;