本文意在对持续交付最佳实践之一的"只生成一次制品"进行思考和践行,提供Java侧的解决思路。(虽然这里是以Java为例,但正如标题所言,其思路是通用的)
1. 前言
对于Java应用开发,针对不同环境下的不同配置,最容易找到的解决方案通常都是借助Maven等提供的profile特性来在编译时将其固定在制品包中。
以上方法看似解决了不同环境下配置不同的需求,但无疑存在着不少隐忧:
- 看似是对同一份源码进行的打包,但实际生产过程中没有人敢100%保证在不同时间点打出来的两个包只有配置上的差异。
- 仅仅因为些许配置上的差异导致的重复打包,制品间存在着大量的重复。而在软件领域,重复就是最大的问题。
随着对于持续交付中相关理念的了解,笔者结合查找到的相关资料,汇总整理了一套通用的解决方案。
2. 解决方案选择
在正式开始介绍解决方案前,让我们针对不同的场景来整理下相应的应对方案(仅为一家之言,欢迎指正):
- 少量的配置项差异,使用替换的方式来完成。 例如编写自定义脚本实现指定配置项的替换(该自定义脚本直接存放在项目的版本库中,脚本的编写由各个项目组成员完成。部署流水线(例如Ansible或Jenkins)只负责按照约定好的传入参数顺序和意义,在约定的时机回调脚本即可)。
- 数量比较多的配置项,推荐按profile划分文件夹结构,并将原本的配置文件读取路径中增加一个关于profile的占位符。形如:
├─src │ ├─main │ │ ├─java │ │ │ ├─... // Java业务代码 │ │ └─resources │ │ └─profiles // 根据不同的环境配置好不同的配置项 │ │ ├─dev │ │ │ application.properties │ │ │ db.properties │ │ │ │ │ ├─prod │ │ │ application.properties │ │ │ db.properties │ │ │ │ │ └─test │ │ application.properties │ │ db.properties
- 以上方法适合于对现有系统的改造,对于全新的项目,则推荐进行完全的二进制应用包和运行时配置剥离,通过系统环境变量(例如 {PROJECT_NAME}_CONFIG_DIR )设置配置文件所在根目录等方式进行关联,然后在系统中读取相关配置项的时候要求按照规范,例如必须使用Spring方式,或者指定的工具类(工具类中进行profile判断)读取配置项。(此方案参考自《精通Spring4.x - 企业应用开发实战》- 18.11 如何规划便于部署的Web项目属性文件)。
- 使用配置中心。
3. 解决方案落地
对于现在的大部分Java Web应用来说,十有八九都是基于Spring的,笔者所在的公司也不例外,因此本文接下来的部分将主要以Spring框架作为基础进行讲解。
针对以上列举的方案清单,我们挑选出部分给出实现示例:
- 自定义脚本实现少量配置项部署时自动替换。
- 配置文件按profile文件夹分开存储。
3.1 自定义脚本实现部署时自动替换
准备阶段:
- Maven打包时候,确保profileImpl.ps1和最终生成的 JAR/WAR 同目录。
profileImpl.ps1
脚本内容:<# $args[0] 当前profile #> cd $PSScriptRoot $profile = $args[0] # 抽取profiles文件夹 jar -xf .\xxx-yy.jar profiles/ # 抽取需要进行替换的文件 jar -xf .\xxx-yy.jar static/i18n/yy_zh-CN.properties <# 至此, 文件夹结构如下: profiles/ static/ i18n/ wwsb_zh-CN.properties xxx-yy.jar #> $profileProps = convertfrom-stringdata (get-content ("./profiles/{0}.properties" -f $profile) -raw) Write-Host $profileProps['api_url'] # 替换 $TEMP_FILE = "static/i18n/yy_zh-CN.properties-temp" Get-Content static/i18n/yy_zh-CN.properties -Encoding utf8 | %{if($_ -match '^api_url=(?<num2>.*)$'){ ($_ -replace $Matches.num2, $profileProps['api_url']) | Out-File -Encoding utf8 -Append $TEMP_FILE } else { $_ | Out-File -Encoding utf8 -Append $TEMP_FILE } } mv $TEMP_FILE static/i18n/yy_zh-CN.properties -Force # 写回JAR jar -uf .\xxx-yy.jar static/i18n/yy_zh-CN.properties # 还原现场 rm -Force -Recurse profiles/ rm -Force -Recurse static/
部署阶段:
-
Ansible部署脚本。
### Ansible脚本, 这里只列出与自定义脚本相关的部分 --- - hosts: "{{ TARGET | default(windows_80) }}" - name: prepare ... - name: check if custom ps1 script exist win_stat: path: "{{WORKPATH}}/profileImpl.ps1" register: file_info - name: execute custom ps1 script when exist win_command: "powershell.exe -ExecutionPolicy ByPass -File {{WORKPATH}}/profileImpl.ps1 {{PROFILE_INNER}} >> {{WORKPATH}}/profileImpl.log" when: file_info.stat.exists - name: deploy app ...
3.2 配置文件按profile文件夹分开存储
正如上面的解决方案清单中所述,本方案适合环境相关的配置项较多的场景。
这里我们就以常见的SpringMVC框架为例,陈述下其中的实现思路。
- 首先是配置文件的存储布局,上面已经展示了,这里再重复一次:
├─src
│ ├─main
│ │ ├─java
│ │ │ ├─... // Java业务代码
│ │ └─resources
│ │ │ common.properties // 与环境无关的通用配置
│ │ │
│ │ └─profiles // 根据不同的环境配置好不同的配置项
│ │ ├─dev
│ │ │ application.properties
│ │ │ db.properties
│ │ │
│ │ ├─prod
│ │ │ application.properties
│ │ │ db.properties
│ │ │
│ │ └─test
│ │ application.properties
│ │ db.properties
│ │ db.properties
-
接着是应用中的配置读取,在SpringMVC中我们通常采用的是XML配置方式。打开项目中相应的spring和springmvc配置文件最底部配置如下beans:
<!-- 开发环境配置文件 --> <beans profile="dev"> <context:property-placeholder location="classpath*:*.properties, classpath*:profiles/dev/*.properties"/> </beans> <!-- 测试环境配置文件 --> <beans profile="test"> <context:property-placeholder location="classpath*:*.properties, classpath*:profiles/test/*.properties"/> </beans> <!-- 生产环境配置文件 --> <beans profile="prod"> <context:property-placeholder location="classpath*:*.properties, classpath*:profiles/prod/*.properties"/> </beans>
-
最后一步就是告知Spring应该加载哪个profile了。相关的配置项为
spring.profiles.active
。所以我们可以通过三种方式达到我们的目的:
a. 直接修改web.xml
文件;
b. 实现Servlet3.x中的ServletContainerInitializer
接口。(在SpringMVC中可转而借助实现WebApplicationInitializer
接口);
c. 设置JVM参数。综合考虑之下我们决定暂时采用第一种方式来达到目的。相应的Ansible部署脚本部分如下:
- name: configure profile for spring application win_lineinfile: path: "{{TOMCAT_INSTALL_WIN_DIR}}/apache-tomcat-{{TOMCAT_VERSION}}/webapps/{{PROJECT_NAME_INNER}}/WEB-INF/web.xml" state: present regex: '^.*<context-param><param-name>spring.profiles.active</param-name><param-value>\w+</param-value>' line: ' <context-param><param-name>spring.profiles.active</param-name><param-value>{{PROFILE_INNER}}</param-value>'
-
以上第2,3步,在SpringBoot的实现中相对就简单很多了。只需要一个配置:
spring: config: additional-location: classpath:/profiles/${spring.profiles.active}/ # 指定配置文件的额外位置 (和spring.config.location配置项冲突, 源码参考 ConfigFileApplicationListener.getSearchLocations() )
4. 最后
以上解决方案即是目前笔者对于"持续交付"最佳实践——"只生成一次制品"的理解。
稍微深刻一些就可以看出来:所有方案的目的都是为了实现制品与配置分离,更具体一点是将配置项与配置值分离。即部署脚本只存放配置项,配置值应该由类似于配置中心的地方提供。
所以综合来看,笔者更推荐最开始列举的方案清单中的"基于系统变量约定配置文件所在根目录"的方式,这种简约版的配置中心方式可以彻底剥离应用与配置,最大化地避免应用更新对于配置的影响,减轻运维人员的心智负担。
5. Links
- 《持续交付2.0》
- 《持续交付36讲》 第18课 - 如何做好容器镜像的个性化及合规检查?
- 《Jenkins2.x》第18.3.2章节
- Office Site - Spring Boot - Externalized Configuration
- SpringBoot2.X打包——一站式教程之分离配置和依赖
- springMVC的多环境配置_基于springprofile