持续交付技术8:从代码到制品,Java编译构建基础知识

文摘   2024-07-29 21:26   贵州  
构建是CI/CD中重要的一环,构建的规范,效率将直接影响持续集成和交付的效率,本文将介绍Java语言的编译构建过程,构建工具的应用,包括依赖管理和构建加速,以及构建过程可信要关注的问题,包括二进制一致性和构建结果树SBOM的生成和管理。
一、Java编译过程

Java 的编译过程是将高级的、人类可读的 Java 源代码转换为低级的、机器可执行的字节码的过程。这个过程主要是由 Java 编译器(如 javac)完成的。以下是 Java 编译过程的基本步骤:
源代码文件
开始于一个或多个带有 
.java 扩展名的文件,这些文件包含了 Java 的类定义和其他代码。
词法分析
Java 编译器首先执行词法分析,将源代码分解为一系列的词元或令牌。这个过程将源代码转换为更结构化的、编译器可以理解的格式。
语法分析
在这个阶段,编译器会根据 Java 的语法规则检查令牌,并将其组合成一个抽象语法树(AST)。AST 是源代码的图形表示,反映了其结构和语法。
语义分析
编译器检查代码的语义,确保它符合 Java 的语言规则。这包括类型检查、变量的绑定等。如果发现任何错误(如类型不匹配),编译器会报告。
字节码生成
一旦 AST 被验证并且没有错误,编译器会将其转换为字节码。字节码是一种中间表示形式,它既不是源代码,也不是机器代码。它是独立于平台的,可以在任何装有 Java 虚拟机(JVM)的机器上执行。
输出 .class 文件
生成的字节码会被保存在 
.class 文件中。这个文件包含了一个或多个 Java 类的字节码。
运行字节码
使用 Java 运行时环境(JRE)中的 Java 虚拟机(JVM)来执行 
.class 文件中的字节码。JVM 在运行时将字节码转换为特定于平台的机器代码。
总的来说,Java 的编译过程将 .java 源文件转换为 .class 字节码文件,然后这些字节码文件可以在 JVM 上运行,实现“一次编译,到处运行”的理念。
二、Java构建:基于Maven依赖管理和高效构建

每种语言都有对应的构建工具,支持构建和依赖管理等内容,比如java的Maven,JavaScript的Webpack,C++的CMake,下面主要介绍Java相关的构建工具和技术

1、Java构建工具的前世今生

在上古时代,Java的构建都在使用make,编写makefile来进行Java构建有非常多别扭与不便的地方。
紧接着Apache Ant诞生了,Ant可以灵活的定义清理编译测试打包等过程,但是由于没有依赖管理的功能,以及需要编写复杂的xml,还是存在着诸多的不便。
随后Apache Maven诞生了,Maven是一个依赖项管理和构建自动化工具,遵循着约定大于配置的规则。虽然也需要编写xml,但是对于复杂工程更加容易管理,有着标准化的工程结构,清晰的依赖管理。此外,由于Maven本质上是一个插件执行框架,也提供了一定的开放性的能力,我们可以通过Maven的插件开发,为构建构成创造一定的灵活性。
但是由于采用约定大于配置的方式,丧失了一定的灵活性,同时由于采用xml管理构建过程与依赖,随着工程的膨胀,配置管理还是会带来不小的复杂度,在这个背景下,集合了Ant与Maven各自优势的Gradle诞生了。
Gradle也是一个集合了依赖管理与构建自动化的工具。首要的他不再使用XML而是基于Groovy的DSL来描述任务串联起整个构建过程,同时也支持插件提供类似于Maven基于约定的构建。除了在构建依赖管理上的诸多优势之外,Gradle在构建速度上也更具优势,提供了强大的缓存与增量构建的能力。
除了以上Java构建工具之外,Google在2015年开源了一款强大,但上手难度较大的分布式构建工具Bazel,具有多语言、跨平台、可靠增量构建的特点,在构建上可以成倍提高构建速度,因为它只重新编译需要重新编译的文件。Bazel也提供了分布式远程构建和远程构建缓存两种方式来帮助提升构建速度。
目前业内使用Ant的人已经比较少,主要都在用Maven、Gradle和Bazel,如何真正基于这三款工具的特点发挥出他们最大的效用,是这个系列文章要帮大家解决的问题。先从Maven说起。

如果优雅高效地用好Maven,需要关注以下两个问题,如何优雅的管理依赖,以及如何加速我们的构建测试过程

2、Maven的依赖管理

1)优雅的依赖管理
在依赖管理中,有以下几个实践原则,可以帮助我们优雅高效的实现不同场景下的依赖管理。
● 在父模块中使用dependencyManagement,配置依赖
● 在子模块中使用dependencies,使用依赖
● 使用profiles,进行多环境管理
以我在日常开发中维护的一个标准的spring-boot多模块Maven工程为例。
工程内各个module之间的依赖关系如下,通常这也是标准的 spring-boot restful api多模块工程的结构。
2)便捷的依赖升级
通常我们在依赖升级的时候会遇到以下问题:
● 多个依赖关联升级
● 多个模块需要一起升级
在父模块的pom.xml中,我们配置了基础的spring-boot依赖,也配置了日志输出需要的logback依赖,可以看出,我们遵循了以下的原则:
(1)在所有子模块的父模块中的pom中配置dependencyManagement,统一管理依赖版本。在子模块中直接配置依赖,不用再纠缠于具体的版本,避免潜在的依赖版本冲突。
(2)把groupId相同的依赖,配置在一起,比如groupId为org.springframework.boot,我们配置在了一起。
(3)把groupId相同,但是需要一组依赖共同提供功能的artifactId,配置在一起,同时将版本号抽取成变量,便于后续一组功能共同的版本升级。比如spring-boot依赖的版本抽取成了spring-boot.version。
在子模块build-engine-api的pom.xml中,由于在父pom中配置了 dependencyManagement中依赖的spring-boot相关依赖的版本,因此在子模块的pom中,只需要在dependencies中直接声明依赖,确保了依赖版本的一致性。
3)合理的依赖范围
Maven依赖有依赖范围(scope)的定义,compile/provieded/runtime/test/system/import,原则上,只按照实际情况配置依赖的范围,在必要的阶段,只引入必要的依赖。
90%的Java程序员应该都使用过org.projectlombok:lombok来简化我们的代码,其原理就是在编译过程中将注解转化为Java实现。因此该依赖的scope为provided,也就是编译时需要,但在构建出最终产物时又需要被排除。
当你的代码需要使用jdbc连接一个mysql数据库,通常我们会希望针对标准 JDBC 抽象进行编码,而不是直接错误的使用 MySQL driver实现。这个时候依赖的scope就需要设置为runtime。这意味着我们在编译时无法使用该依赖,该依赖会被包含在最终的产物中,在程序最终执行时可以在classpath下找到它。
更多关于scope的使用,可以参考官方帮助文档。
4)多环境支持
举个简单的例子,当我们的服务在公有云部署时,我们使用了一个云上版本为8.0的MySQL,而当我们要进行专有云部署时,用户提供一个自运维的版本为5.7的MySQL。因此,我们在不同的环境中使用不同的 mysql:mysql-connector-java 版本。
类似的,在项目实际的开发过程中,我们经常会面临同一套代码。在多套环境中部署,存在部分依赖不一致的情况。
关于profiles的更多用法,可以参考官方帮助文档
5)依赖纠错
如果你已经在父pom中使用dependencyManagement来锁定依赖版本,大概率的,你几乎很少会碰到依赖冲突的情况。
但是当你还是意外的看到了NoSuchMethodError,ClassNotFoundException 这两个异常的时候,有以下两个方法可以快速的帮你纠错。
(1)通过依赖分析找到冲突的依赖
(2)通过添加stdout代码找到冲突的类实际是从哪个依赖中查找的
通过具体的路径中对应的版本信息,找到对应的版本并校正。
当然这个方法也可以纠出一些依赖被错误的加载到classpath下,非工程本身依赖配置引起的冲突。
3、Maven构建加速技巧

在构建这块,一个需要关注的点的是如何提升构建效率。我们先看一个简单的计算问题:

这是一个非常大的数据,也是非常大的损耗。很多时候一个项目的工程效率太低的原因就是因为构建太慢。构建耗时过长使得制品迭代非常慢,功能更新和bug修复也会受到影响。
那我们如何提升构建的效率呢?下面是我们的一些实践建议:
一个基本原则:保证构建的准确性,构建的准确性永远优于构建的效率。只有在保证准确性的前提下提升效率才有意义。
五点建议
  1. 应用瘦身:检查应用的依赖情况,应用包体积是否过大,依赖项是否过多,能否去除不必要的依赖,能否构建更小的镜像。
  2. 分层构建:底层的东西先构建出来以后被上层所复用,然后就可以做增量式的了。
  3. 构建缓存:构建过程中拉取依赖是很耗时的,要避免重复拉取。
  4. 网络优化:主要是保证代码、构建机器和制品库之间的低网络延时。代码和构建机器是否是在同一个低时延链路中。例如代码在Github上而使用云效构建,此时的延时相对于内网会高出许多。
  5. 仓库镜像:仓库镜像可以极大地减少拉取依赖项的时间。在国内的网络环境下,如果从源仓库获取依赖,可能延时会非常长,这时可以使用镜像网络降低延时。例如nodejs开发者常使用淘宝的npm镜像源,而Python开发者使用清华的镜像源。对于企业来说也可以构建自己的镜像仓库以提升可靠性与降低延时。云效也使用了镜像仓库,来减少拉取的时间。

在Maven构建期间优化Java编译器是一个很重要的问题,因为它可以显著提高构建速度和性能。以下是Maven构建加速技巧一些建议和最佳实践,以帮助您在Maven构建期间优化Java编译器:

1)依赖下载加速
通常情况下,根据Maven配置文件 ${user.home}/.m2/settings.xml 中的配置,默认情况下是缓存在${user.home}/.m2/repository/。
通常在构建过程中,依赖的下载往往会成为比较耗时的部分,但是通过一些简单的设置,我们可以有效的减少依赖的下载与更新。
● 优化updatePolicy设置
updatePolicy指定了尝试更新的频率。Maven 会将本地 POM 的时间戳(存储在存储库的 maven-metadata 文件中)与远程进行比较。选项包括:always(总是)、daily(每天,默认值)、interval:X(其中 X 是以分钟为单位的整数)、never(从不)。
● 使用离线构建
除此之外,如果构建环境已经存在缓存,可以使用Maven的offline模式进行构建,避免依赖或插件的下载更新。
直观的,日志中将不会出现类似如下Downloading相关的信息。
2)使用并行构建:通过使用-T选项,您可以在Maven构建期间使用多个线程并行编译Java代码。例如,使用-T 4可以使用4个线程并行编译。
3)使用增量构建:通过使用Incremental Build,您可以仅编译自上次构建以来已更改的源代码文件。这可以显著减少构建时间。要启用Incremental Build,请在pom.xml文件中添加以下配置:

<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
<useIncrementalCompilation>true</useIncrementalCompilation>
</configuration>
</plugin>
</plugins>
</build>
4)使用更快的编译器:考虑使用更快的Java编译器,如Eclipse的Babel编译器。要使用Babel编译器,请在pom.xml文件中添加以下配置:

<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
<compilerId>eclipse</compilerId>
<useIncrementalCompilation>false</useIncrementalCompilation>
</configuration>
<dependencies>
<dependency>
<groupId>org.codehaus.plexus</groupId>
<artifactId>plexus-compiler-eclipse</artifactId>
<version>2.8.1</version>
</dependency>
</dependencies>
</plugin>
</plugins>
</build>
5)使用构建缓存:考虑使用构建缓存,如Maven的Build Cache插件。这可以帮助您在构建期间缓存编译的类文件,从而提高构建速度。要使用Build Cache插件,请在pom.xml文件中添加以下配置:

<plugins>
<plugin>
<groupId>com.github.goldin</groupId>
<artifactId>build-helper-maven-plugin</artifactId>
<version>3.0.0</version>
<executions>
<execution>
<id>add-build-env-info</id>
<goals>
<goal>add-build-env-info</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
5)使用增量打包:如果您只想构建已更改的模块,可以使用Maven的Incremental Build插件。这可以显著减少构建时间。要使用Incremental Build插件,请在pom.xml文件中添加以下配置:

<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.2.0</version>
<executions>
<execution>
<id>default-jar</id>
<goals>
<goal>jar</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
总之,在Maven构建期间优化Java编译器可以显著提高构建速度和性能。通过使用并行构建、Incremental Build、更快的编译器、构建缓存和增量打包,您可以在Maven构建期间优化Java编译器,从而提高构建速度和性能。
6)当然,也可以用一些其他的工具提升构建效率,比如maven-mvnd
 Maven 传统的构建太慢了,所以 Maven 新起了一个 maven-mvnd 项目,它的宗旨就是,借鉴来自 Gradle 和 Takari 中的技术以提供更快的 Maven 构建速度。实测效果还是挺明显的,同样的机器同样的项目,使用传统的 mvn 需要 1 分多钟,而使用 mvnd 只要 20 多秒就完事了,这对于大工程来说还是挺能提升效率的。
需要注意的是, maven-mvnd 并不能有独立于 Maven 使用,它只是对 Maven 的一种封装和改进,可以让 Maven 的构建操作更快、更高效。
mvnd 底层实现原因:
mvnd 内嵌了 Maven,安装 mvnd 后无需单独安装 Maven。
应用会在一个长驻后台进程中构建,也就是守护进程。
一个守护进程实例可以处理 mvnd 客户端的多次连续请求。
mvnd 客户端是一个使用了 GraalVM 构建的本机可执行文件,与启动传统 JVM 相比,它启动速度更快,占用的内存更少。
如果没有空闲的守护进程,它可以并行生成多个守护进程处理构建请求。
mvnd 为什么快的原因:
不需要每次构建重新启动 JVM,大大节省时间。
持有 Maven 插件类的类加载器缓存在多个构建中,因此插件 jar 只被读取和解析一次。
由 JVM 内部的即时 (JIT) 编译器生成的本机代码也被保留。与传统的 Maven 相比,JIT 编译花费的时间更少,在重复构建期间,JIT 优化代码立即可用。
三、可信构建:编译一致性和构建结果树

1、如何保证软件制品的一致性
要保证软件制品的一致性,软件制品应该有确定的格式、唯一的版本、能够追溯到源码、能够追溯到生产和消费过程,这样才能使持续交付更好地服务于企业的制品管理与开发。
在制品构建过程中,经常会遇到一些问题。例如应用的代码库里没有Makefile,package.json,go.mod而没法确定依赖,或者制品能构建成功但缺失几个依赖,又或是在自己的开发环境运行正常而在生产环境出现了开发环境没有的bug。导致这些问题出现的原因是因为构建本身是可变的,当你构建可变时,就会带来一系列的问题。为此,我们需要通过不可变构建来使制品与预期一致。
要实现不可变构建,我们需要保证有:
  • 相同的代码
  • 相同的构建环境
  • 相同的构建脚本
相同的代码
例如程序员开发时,不在依赖描述文件(如go.mod,package-lock.json,pom.xml,requirements.txt等)中指定依赖的版本,则会默认使用最新的版本作为依赖,这样产出的制品会随着依赖的更新而不能保持一致,这将带来完全不在预期内的风险。
相同的构建环境
对于构建环境来说,Dockerfile可以用来在容器平台下描述环境,通过Dockerfile我们能为制品使用一致的环境。很多时候我们并不需要在运行中使用构建环境的很多依赖,而构建镜像的体积往往比较惊人,这个时候我们就需要将构建环境与运行环境分开,以得到尽可能轻量的镜像制品。
相同的构建脚本
对应的,使用相同的,与代码实现无关的构建脚本也是非常重要的,在Dockerfile的环境中必须指定确定的环境依赖版本。
只有在同一份代码(及同一个依赖)、同样构建环境的描述、和同样构建脚本的环境下,所产生的软件制品才是相同的。这里强调的是说所有的东西都要保证一致性,如果说三者是一样的话,那产生出来的制品也是一样的,即使构建时间不同,产出的制品也是相同的。
做好不可变基础设施,首先要标准化最终交付制品的形态,并且明确此交付形态的运维管理方式。而要保证不可变,那首先要做好不可变的构建,然后才能有一致的软件制品。
其他一致性影响条件
如果要保持编译器编译的一致性,如下几点需要考虑:
1) 时间戳使用:DATETIMETIMESTAMP 宏,杜绝此类宏的使用编译参数添加-Werror=date-time,使用后编译会报错,如果依赖的第三方库使用的话,可以使用“-Wno-builtin-macro-redefined -D__DATE__= -D__TIME__= -D__TIMESTAMP__= ”使其失效。
2) 绝对路径的使用: FILE 宏,clang中这个宏往往跟编译时clang指定的路径有关,比如
clang -c /absolute/path/to/my/file.cc,得到的是绝对路劲,如果要使用相对路径的话,传递给clang的参数要使用绝对路径。
2、SBOM生成
1)SBOM概念
SBOM(软件物料清单)是一种正式的、结构化的记录。它不仅对软件产品的组件构成进行了详细的说明,同时还描述了这些组件之间的供应链关系。SBOM概述了应用程序中引入的包和库,以及这些包、库与其他上游项目之间的关系。这在重用代码与引入开源代码的时候非常有用。        
合适的SBOM,可以帮助组织对自己所部署的包以及这些包的版本有一个确切的了解,从而便于组织根据自己的需要来进行更新,以维护安全。 
SBOM的用途不仅限于安全领域。例如,它还可以帮助开发人员对不同类型软件组件中包含的开源代码许可证进行跟踪。
国家电信和信息管理局(NTIA)列出了任何SBOM都应该包含的七个数据字段: 
供应商名称:创建、定义和标识组件的实体名称。 
组件名称:原供应商为软件组件指定的名称 
组件版本:供应商用于表明先前版本软件做出更改的标识符。 
其他唯一标识符:用于标识组件或用于相关数据库查找的其他标识符。例如,NIST  的CPE字典中的标识符。 
依赖关系:描述软件Y中包含上游组件X的关系。这对于开源项目尤为重要。 
SBOM数据的作者:为该组件创建SBOM数据的实体名称。 
时间戳:记录SBOM数据程序集的日期和时间。
SBOMs 必须满足以下要求:
• SBOM的格式符合SPDX, CycloneDX, 或 SWID tags这三个标准中的其中一个。这样才能确保它能够被机器识别。 
• 每个新的软件版本都必须生成一个新的SBOM,以确保它们是最新的。 
• 除了依赖关系之外,SBOM还必须解释这些关系可能存在在哪些组织所不知道的的地方。
2)SBOM的构建框架
从SBOM生产者的视角出发,包括数据层、构建层、应用层三大维度,涵盖创建信息、软件信息、组件信息、文件信息、代码片段信息、生成方式、生成频率、生成深度、更新校验机制、软件资产管理、软件漏洞管理、安全事件响应等重点内容,从而构建出SBOM总体框架模型,具体如下图所示。
3)SBOM的生成
Java生成方式
在Java项目中,可以使用maven插件生成BOM文件。这样在软件编译过程中,就可以自动生成信息。一旦软件发生变动,也可以同步生成新的BOM文件。
CycloneDX格式清单生成
生成CycloneDX规范的BOM 需要用到:cyclonedx-maven-plugin插件在maven的package阶段运行插件即可。

研发效能方法论
分享内容的四大方向:研发效能和软件工程方法论,软件工程技术,平台工程设计,通用五力