中大型app的架构设计

移动架构

Posted by River on January 18, 2022

前言

从事移动端基础技术良久,浅谈架构。先给个结论,架构设计为了解决工程中的痛点; 降低工程复杂度,保障工程质量而生;不能为了架构而架构。

市面常见的app架构图如下,显然不是我要说的重点;常见mvc,mpv,mvvm更像是一种编码的设计。

A/B测试系统架构图

1、架构历史

我们来看下面两段代码,分别是机器码和汇编代码如下:

    768+12288-1280的机器代码
    101100000000000000000011
    000001010000000000110000
    001011010000000000000101
    
    描述4+6的汇编代码
    .section .data  
    a: .int 10  
    b: .int 20  
    format: .asciz "%d\n"
    .section .text
    .global _start
    _start:  
    movl a, %edx    
    addl b, %edx    
    pushl %edx  
    pushl $format  
    call printf  
    movl $0, (%esp)  
    call exit

不言而喻,用这样的语言可以承载程序的复杂度是极其低下的;于是以Fortran,LISP,Cobol为代表的第一批高级语言问世暂时缓解了危机。 好景不长,最典型的例子莫过于IBM的System/360 的操作系统开发,花费了 5000 人一年的工作量, 写出将近 100 万行的源码,总共投入5亿美元。尽管如此,但项目进度却一再延迟,软件质量也得不到保障。 畅销的软件工程书籍《人月神话》就是基于这个项目写的。

为了解决这个问题,第一个结构化的程序语言Pascal也在此时诞生,并迅速流行起来。危机再次缓解。
随着硬件技术的迅猛发展,编程应用领域越来越多,业务复杂度越来越大,结构化的程序语言Pascal并不能解决业务复杂后带来的扩展性要求; 在这种背景下面向对象的思想开始流行起来。真正开始流行在80年代,C++为代表,后来的 Java、C# 把面向对象推向了新的高峰,解决了业务复杂后的 程序扩展性问题。

从90年代开始,软件架构在Rational和Microsoft开始流行,94年的一篇文章An Introduction to Software Architecture中写道:
When systems are constructed from many components, the 
organization of the overall system-the software architecture-presents a 
new set of design problems。
翻译过来就是:
随着软件系统规模的增加,计算相关的算法和数据结构不再构成主要的设计问题;
当系统由许多部分组成时,整个系统的组织;
也就是所说的“软件架构”,导致了一系列新的设计问题。

总结一下:
软件架构思想本质上都是对达到一定规模的软件进行拆分,差别只是在于随着软件的复杂度不断增加,拆分的粒度越来越粗,
拆分的层次越来越高;解决无外乎是扩展性和复杂度的问题。

2、中大型app中的模块的拆分

首先我们看下后端的工程结构,可以发现工程的主体结构分为bucket-server实体层和bucket-iface协议层。 当其他微服务通过RPC通信时,引用的就是bucket-iface。

app架构图

为什么先说后端呢,其实从技术层面讲;后端无论从复杂度还是深度都远超客户端,具有很强的参考价值。 现在回到客户端,当app庞大模块的划分类似于后端服务的划分;只是客户端通常以业务线的维度做划分, 在工程上可以实现跟后端工程结构极其相似的结构,如下图:

app架构图

service_accout,service_platform等都是对外暴露的协议层,类似后端的bucket-iface;内部主要定义一些 接口和数据结构,常量。

实现协议层service_xxx和实体层business_xxx的通信框架很多,这里推荐一个美团开源的,写的挺好:
https://github.com/meituan/WMRouter/blob/master/docs/user-manual.md

总结一下:模块化的拆分和组件化框架的选择目的是为了降低业务庞大后的复杂度,提供了拆分后的通信方式。

2.1、中大型app中的模块的拆分wmrouter精彩回顾之生成路由表

当你翻阅源码的时候,会发现路由表(Map)真正初始化在首次路由访问的时候;


public class ServiceLoader<I> {
    private static final Map<Class, ServiceLoader> SERVICES = new HashMap();
    private static final LazyInitHelper sInitHelper = new LazyInitHelper("ServiceLoader") {
        protected void doInit() {
            try {
                Class.forName("com.sankuai.waimai.router.generated.ServiceLoaderInit").getMethod("init").invoke((Object)null);
                Debugger.i("[ServiceLoader] init class invoked", new Object[0]);
            } catch (Exception var2) {
                Debugger.fatal(var2);
            }

        }
    };
}

反射调用类的com.sankuai.waimai.router.generated.ServiceLoaderInit中静态方法 init,但是翻遍组建源码未找到ServiceLoaderInit的影子; 其实是在gradle插件ASM动态生成的。

public class WMRouterTransform extends Transform {
    private void generateServiceInitClass(String directory, Set<String> classes, Set<String> deleteClass) {

        if (classes.isEmpty()) {
            WMRouterLogger.info(GENERATE_INIT + "skipped, no service found");
            return;
        }
        File dest = new File(directory, INIT_SERVICE_PATH + SdkConstants.DOT_CLASS);    
        if (!dest.exists()) {
            try {
                WMRouterLogger.info(GENERATE_INIT + "start...");
                long ms = System.currentTimeMillis();

                ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
                ClassVisitor cv = new ClassVisitor(Opcodes.ASM5, writer) {
                };
                String className = Const.SERVICE_LOADER_INIT.replace('.', '/');
                cv.visit(50, Opcodes.ACC_PUBLIC, className, null, "java/lang/Object", null);

                MethodVisitor mv = cv.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC,
                        Const.INIT_METHOD, "()V", null, null);

                mv.visitCode();

                for (String clazz : classes) {
                    String input = clazz.replace(".class", "");
                    input = input.replace(".", "/");
                    mv.visitMethodInsn(Opcodes.INVOKESTATIC, input,
                            "init",
                            "()V",
                            false);
                }
                mv.visitMaxs(0, 0);
                mv.visitInsn(Opcodes.RETURN);
                mv.visitEnd();
                cv.visitEnd();

                dest.getParentFile().mkdirs();
                new FileOutputStream(dest).write(writer.toByteArray());

                WMRouterLogger.info(GENERATE_INIT + "cost %s ms", System.currentTimeMillis() - ms);

            } catch (IOException e) {
                WMRouterLogger.fatal(e);
            }
        } else {
            try {
                modifyClass(dest, classes, deleteClass);
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
    
        private void modifyClass(File file, Set<String> items, Set<String> deleteItems) throws IOException {
                try {
                    InputStream inputStream = new FileInputStream(file);
                    byte[] sourceClassBytes = IOUtils.toByteArray(inputStream);
                    byte[] modifiedClassBytes = modifyClass(sourceClassBytes, items, deleteItems);
                    if (modifiedClassBytes != null) {
                    ClassUtils.saveFile(file, modifiedClassBytes);
                    }
                    } catch (Exception e) {
                        e.printStackTrace();
                    }

        }

        byte[] modifyClass(byte[] srcClass, Set<String> items, Set<String> deleteItems) throws IOException {
        ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_MAXS);
        ClassVisitor methodFilterCV = new ClassFilterVisitor(classWriter, items, deleteItems);
        ClassReader cr = new ClassReader(srcClass);
        cr.accept(methodFilterCV, ClassReader.SKIP_DEBUG);
        return classWriter.toByteArray();
    }    
}

public class Const {

    public static final String NAME = "WMRouter";

    public static final String PKG = "com.sankuai.waimai.router.";

    // 生成的代码
    public static final String GEN_PKG = PKG + "generated";
    public static final String GEN_PKG_SERVICE = GEN_PKG + ".service";

    public static final String SPLITTER = "_";

    /**
     * ServiceLoader初始化
     */
    public static final String SERVICE_LOADER_INIT = GEN_PKG + ".ServiceLoaderInit";

    public static final char DOT = '.';

    public static final String INIT_METHOD = "init";
        // Library中的类名
    public static final String PAGE_ANNOTATION_HANDLER_CLASS =
            PKG + "common.PageAnnotationHandler";
    public static final String PAGE_ANNOTATION_INIT_CLASS =
            PKG + "common.IPageAnnotationInit";
    public static final String URI_ANNOTATION_HANDLER_CLASS =
            PKG + "common.UriAnnotationHandler";
    public static final String URI_ANNOTATION_INIT_CLASS =
            PKG + "common.IUriAnnotationInit";
    public static final String REGEX_ANNOTATION_HANDLER_CLASS =
            PKG + "regex.RegexAnnotationHandler";
    public static final String REGEX_ANNOTATION_INIT_CLASS =
            PKG + "regex.IRegexAnnotationInit";

    public static final String URI_HANDLER_CLASS =
            PKG + "core.UriHandler";
    public static final String URI_INTERCEPTOR_CLASS =
            PKG + "core.UriInterceptor";
    public static final String SERVICE_LOADER_CLASS =
            PKG + "service.ServiceLoader";
            
}

2.2、wmrouter在plugin动态生成路由表借鉴之启动器并行异步任务的实现

基于拓扑排序的启动框架可能很多人都知道,阿里有开源的Alpha。 我们自研的启动器相比Alpha除了借助CountDownLatch可以控制异步任务 在Application执行完成以外,还有一大杀器并行异步任务; 其实现思想就是借鉴了WMRouter的ASM动态生成字节码。

组建aar的初始化实现

    public class HLPlatformServiceLoader<T> {

    public static final String SERVICE_LOADER_INIT = "com.hellobike.platform.generated.HLPlatformServiceInit";
    public static final String INIT_METHOD = "init";

    static {
        try {
            // 反射调用Init类,避免引用的类过多,导致main dex capacity exceeded问题
            Class.forName(SERVICE_LOADER_INIT)
                    .getMethod(INIT_METHOD)
                    .invoke(null);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

plugin实现,原理类似(ASM6.0的版本)

public class HLServiceLoaderTransform extends Transform { 
    
        @Override
    public void transform(TransformInvocation invocation) {
         ...
    `    File dest = invocation.getOutputProvider().getContentLocation(
                "hlServiceLoader", TransformManager.CONTENT_CLASS,
                ImmutableSet.of(QualifiedContent.Scope.PROJECT), Format.DIRECTORY);
        generateServiceInitClass(dest.getAbsolutePath(), initClasses);
    }


    private void generateServiceInitClass(String directory, Set<String> classes) {

        if (classes.isEmpty()) {
            HLServiceLoaderLogger.info(GENERATE_INIT + "skipped, no service found");
            return;
        }

        try {
            HLServiceLoaderLogger.info(GENERATE_INIT + "start...");
            long ms = System.currentTimeMillis();
            //COMPUTE_FRAMES使用ClassWriter,会忽略我们在代码中visitMaxs()方法和visitFrame()<br>
            //方法传入的具体参数值;换句话说,无论我们传入的参数值是否正确,<br>
            //ASM会帮助我们从新计算一个正确的值,代替我们在代码中传入的参数。
            ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
            ClassVisitor cv = new ClassVisitor(Opcodes.ASM5, writer) {
            };
            String className = Const.SERVICE_LOADER_INIT.replace('.', '/');
            cv.visit(50, Opcodes.ACC_PUBLIC, className, null, "java/lang/Object", null);

            MethodVisitor mv = cv.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC,
                    Const.INIT_METHOD, "()V", null, null);

            mv.visitCode();

            for (String clazz : classes) {
                mv.visitMethodInsn(Opcodes.INVOKESTATIC, clazz.replace('.', '/'),
                        "init",
                        "()V",
                        false);
            }
            mv.visitMaxs(0, 0);
            mv.visitInsn(Opcodes.RETURN);
            mv.visitEnd();
            cv.visitEnd();
            // public static final String DOT_CLASS = ".class"; //$NON-NLS-1$
            File dest = new File(directory, className + SdkConstants.DOT_CLASS);
            dest.getParentFile().mkdirs();
            new FileOutputStream(dest).write(writer.toByteArray());

            HLServiceLoaderLogger.info(GENERATE_INIT + "cost %s ms", System.currentTimeMillis() - ms);

        } catch (IOException e) {
            HLServiceLoaderLogger.fatal(e);
        }
    }
}
    

3、中大型app中的模块拆分后的工程处理

3.1 依赖扁平化,所见及所得

什么叫依赖扁平化,所见及所得呢;就是强制依赖,比如A业务线在自己业务的协议层增加了一个访问接口, 你也更新了对方协议组件的版本,调试的时候还是报noMethodError;你是不是很郁闷,为什么我明明升级了版本, 怎么还报错。这需求依赖组件的统一管理,这可以放在gradle插件统一实现,细节后续补充。

3.2 反向依赖,支持源码调试

什么是反向依赖呢,就是切换aar的引用和源码引用;在源码引用的情况下可以直接调试。 这也可以在gradle插件统一实现,细节后续补充。

3.3 抽离调试工程

既然需要源码调试,那也需要一个调试工程;这个工程最好足够简洁,几乎不改动。同时集成了依赖扁平,支持 反向依赖的调试模式,也是CI/CD打apk的工程;调试工程基本只有一个极其简洁Java文件。

public class AppImpl extends MainApp {
    public AppImpl(@Nullable Application application) {
        super(application);
    }

    @Override
    protected String initEnv() {
        return BuildConfig.envTag;
    }

}

3.4 其他细节处理

1、使用AutoRegister(https://github.com/luckybilly/AutoRegister/)替换wmrouter自带的插件,可以更好的支持增量编译;
2、测试环境的包关闭部分耗时插件的使用,必须的exclude的配置等;
3、插件集成了源码上传aar快照的方法uploadArchives,命令gradlew :business_xxx:uploadArchives

强制依赖和反向依赖的部分核心实现,gradle的classpath版本3.3.2,distributionUrl版本gradle-5.1.1-all.zip ``` configurations.all {
        resolutionStrategy {
            rootProject.dep.all().each { dependency ->// 强制依赖
                def module = "${dependency.group}:${dependency.artifact}:${dependency.version}"
                force module.endsWith('@aar') ? module.substring(0, module.length() - '@aar'.length()) : module
            }

            def localProjects = rootProject.local.projects()

            dependencySubstitution {
                localProjects.each { localProject ->// 反向依赖
                    substitute module("${localProject.group}:${localProject.artifact}") with project(":${localProject.module}")
                }
            }

        }

        resolutionStrategy.cacheDynamicVersionsFor 0, 'seconds'
        resolutionStrategy.cacheChangingModulesFor 0, 'seconds'

    }

文件格式

组件版本的信息定义在两份yaml文件中,文件格式如下


version: 1.0.0
dependencies:
- module: com_google_protobuf__protobuf_java // 引用头
  description: protobuf-java                 // 描述
  department: null                           // 部分名称
  group: com.google.protobuf                 // aar的group
  artifact: protobuf-java                    // aar的artifact
  version: 3.9.0                             // aar的version
  configuration: implementation              // 引用方式
  git: null                                  // git仓库地址
  commit: null                               // commitId
  release: true                              // false表示线上包需要exclude
  source: false                              // true表示切换对应的源码依赖
  
  
业务构建中引用方式如下:
implementation rootProject.dep.com_google_protobuf__protobuf_java

4、分布式集成发布

说分布式集成前说说集中式集成,刚开始的时候公司业务线不是很多;两周一个迭代,只有一个发布计划,发布计划中集成了 这次迭代所有改动的组件,集成的组件有SNAPSHOT版本和release版本,如下图其实组件列表很长。

app架构图

随着业务线越来越多,公司业务线逐步扩大到十条以上;同时集成到一个发布计划,就出现了时长因某某同学改错了东西, 钉钉群里时常有人喊,打包出错,app闪退,业务流程被卡住等等问题;严重影响了开发,测试的工作效率。

集中式集成流程图 app架构图

为了解决上述问题,我们可以稍做修改;把一个发布计划拆成多个,最后再合并成一个就能解决上诉的问题; 具体执行这样,发布计划还是保留一个跟版本绑定;创造一个中间产物测试计划,测试计划可以让开发自由创建, 结束后需要关闭测试计划,开发人员可以自由创建测试计划,对每个业务线进行中的测试计划数量坐限制,比如10个。

分布式集成流程图 app架构图

实现后测试计划的效果图 app架构图 app架构图

实现后发布计划的效果图 app架构图 app架构图 app架构图

总结

  1. 中大型app在大规模人员协作的下,架构设计不仅仅是端上的工程化;很重要的一环就在持续集成上。
  2. 架构设计为的是解决业务发展痛点,不能为了架构而架构;比较好的衡量方式就是成本收益。
  3. 本文仅仅罗列了主管,实际开发起来还有大量的细节需要处理,工程量极大。
  4. 细节包括比如后端表结构定义,发布结束后yaml文件重新生成,自动合并以后合并失败的检查,漏发布的release组件提示等等。