这个技术方案是在解决口碑网前端资源发布,回滚,和多版本控制的过程中诞生的。笔者之前做的都是一些基于BS架构的办公OA,ERP,网站后台管理系统,从来都没有维护过像口碑网这样有N个业务线,有上百台机器构成的一个大型社区型网站。本文介绍的方案就是实现解决像口碑的社区网站前端代码的发布机制的,相信和口碑网有相同规模的网站也或多或少地存在相同应用场景,希望对大家有借鉴意义。
本文侧重功能需求说明,在说明过程中会稍带一些脚本代码也只是概念性的介绍,具体的代码实现不会太过深入,以免陷入不必要的纠结之中。在下一篇博客中会对这个方案的几个关键点的实现方案进行具体描述。
日常维护网站应用过程中,需要更新线上的代码。在口碑会有两个部门人员来负责更新代码,一个是前端UED部门的人员,另外一个就是后端负责编写业务逻辑代码的人员。两部分人员负责更新的内容各有特点:
UED部门管理的代码更偏向于前端,比如,一个样式修改,html元素嵌套关系改变,一个字体从宋体改成黑体。这些在日常维护过程中会经常改变,可谓两天一小改三天一大改。后端开发主要负责设计业务逻辑,比如,用户提交了申请表单服务器端应该怎么处理,如何保证数据库的一致性等等,这些逻辑相对前者来说不会经常变化。所以,UED部门对代码的更新频率比后端开发部门要高很多。
UED部门更新的代码都是jsp,css,javascript这样的脚本,且脚本之间的依赖关系较后端代码的依赖性小很多,修改一个文件可以马上看到效果,回归测试很方便,即使出错影响面也就是一个页面,马上修改就能恢复正常。而后端代码更新,往往牵扯到逻辑修改,影响面会比较广,回归测试要做得很细致,才能保证代码的正确性。
以上这两个区别,导致前端代码和后端代码在发布流程中是不一样的,后端代码每次修改需要有详尽的代码变更历史记录,开发人员不能随意发布代码,公司里需要有专职人员来负责后端代码发布。前端的代码需要频繁且随时能发布,这就需要一套有别于后端代码的发布机制来保证前端代码的发布及时,可靠,而且还要尽量地简单,方便。
笔者效力的口碑网,有十个以上的业务线,上百台的服务器。每台机器上都有一个以上的WEB应用,每个WEB应用下都会有一定数量的JSP页面。网站会定期对页面改版,或者上一些专题活动页面。因此需要可以频繁更新线上WEB应用中的JSP文件,在更新过程中需要满足以下需求:
一个网站的前端资源,也就是MVC(模型-视图-控制器模式)的V(视图)可以用不同的技术来实现,比如,JSP,FreeMark,Velocity等技术来实现,无论用哪种技术,本质都是一样的,都是将业务逻辑生成的M(模型对象)在V(视图)中以不同的格式显示出来。 在本文中会以JSP资源作为说明对象,因为JSP是J2EE规范中一个标准的视图模块,基本上所有的J2EE的WEB容器在不引入第三方类库的前提下都支持解析JSP。较早之前的解决方案
这里有必要先介绍一下早些时候的一个解决办法,大致架构如下图:
假设前台有四个业务线(真实环境远远不止),为了解决在不停机的状态下,可以实时发布JSP文件,并且能即时生效,在分布式环境中增加了一台cms.koubei.com服务器,将每台业务服务器上WEB应用的JSP资源在这cms.koubei.com中镜像。这种方式起初业务线比较少,更新不是很频繁的情况下,比较奏效。但是,随着业务线增多,更新次数及频率增加的情况下,这个方案渐渐暴露出了一些问题:
cms.koubei.com这个节点是维护所有口碑业务线前端JSP资源的节点,所有需要有即时更新效果的WEB应用都需要与这台服务器打通mount镜像。而且一个WEB应用一般有两台以上机器作负载均衡。前期需要作大量的节点配置,而且,一旦服务器配置有任何改动,就会造成文件更新不生效。
如果有N个WEB应用,在cms.koubei.com中就需要有N个镜像。 即,每个WEB应用中的JSP文件只能被单个WEB应用独享。前面说到为了保证整站有统一的布局和页面风格,在各个WEB应用中会有相同的页面元素存在。所以,如果当页面需要更新,经常会需要更新多个WEB应用中的文件,而这多份文件的内容却是相同的。
线上某次需求发布之后,如果发现发布中有文件发布错误需要回滚,必须将发布过的每个文件一个一个地去回滚,费时费力又容易出错。
优化之后的解决方案中将cms.koubei.com节点改成了SVN服务。可能你会问,SVN是一个项目开发阶段用于管理多人协同开发的项目版本工具,是的,这个认识没有错,从本质上来讲SVN是一个容器,这个容器有强大的版本维护功能,因此不妨碍将SVN作为分布式环境中作为分布式文件存储中心来使用。分布式集群中每个服务器节点虽然有防火墙设置,但是80端口一般是打通的,可以使用http协议来维护SVN系统中的文件。SVN构架在Apache http服务中,这样的架构的稳定性是经受得住大并发量的考验的。另外,使用了Http协议打通服务器节点,就不需要事先在服务之间打通mount镜像,避免不必要的服务节点的耦合。
当用户需要更新业务端服务器上的页面时,只要找到对应SVN服务器中的路径,将SVN checkout到本地,修改完成之后Commit到SVN服务中,①在SVN服务接收到用户提交的Commit请求,并且请求处理成功之后,SVN会启动一个HOOK脚本之一的post commit hook,在这个hook脚本中,会向本机的常驻进程MinaAdapter发送一条消息②,消息内容是告诉MinaAdapter进程SVN中哪些文件被更新了,当MinaAdapter接收到消息之后会继续将这个消息转发给Notify消息中间件③,消息中间件接收到了消息之后会继续将这个消息发送给之前订阅了该消息的业务服务器进程④,业务服务进程接收到更新消息之后,会将内存中生成的JSP对象更新掉,这样页面就会更新到最新的版本。
当业务服务器中需要切换页面版本可以利用版本控制中心来切换,假设“bendi”业务服务器上有一个页面test.jsp在SVN服务器中有两个版本 Revsion 111 和Revsion 112,“bendi”服务器中显示的也是112这个版本,需要将显示的版本切换成111这个版本,只要进入版本控制中心设置“bendi”业务服务器上依赖的SVN的revsion版本号,设置成功之后,版本控制中心会向消息中间件发送一条消息⑤,消息中间件会将消息推送给“Bendi”服务器④,本地服务器接到消息中间PUSH过来的消息之后将当前的test.jsp的版本切换消息中指定的112版本以上描述的是两种更新页面内容的途径,第一种是通过直接在SVN上提交更新,第二种是通过在版本控制中心中设置业务服务依赖的SVN版本。详细请参照3.2.6
这个方案最大的优点是,将原先以“推”的方式,改成了“拉”的方式,这样可以让业务端服务器和SVN服务器解除耦合,架构变得简单。
所有的J2EE容器加载JSP的方式都是通过本地加载,例如,在JSP文件中写这个标签,容器会得到标签的page属性,从web应用的webapp/目录下加载。WEB应用下,加载的资源最终是通过ServletContext的getResourceAsStream的方法来实现的。
public interface ServletContext {
public InputStream getResourceAsStream(String path);
}
getResourceAsStream的默认加载是从本地加载的,TOMCAT可以通过配置修改。在%CATALINA_HOME%conf/context.xml中可以添加一个Resources配置项目
WEB-INF/web.xml
通过这个配置项就能对请求本地资源的方法进行拦截,将默认从本地的本地加载的方式改成从远端svn中加载
Import org.apache.naming.resources.FileDirContext;
Public class KoubeiCmsDirConext extends FileDirContext {
@Override
public Object lookup(String name) throws NamingException {
if(name.startsWith("/CMS")){
return new Resource(){
@Override
public InputStream streamContent() throws IOException {
if (binaryContent == null) {
final String url = getPath();
log.info("svnResource load:" + url);
inputStream = cmsUrlClassLoader.getResourceAsStream(name);
}
return super.streamContent();
}
}
return super.lookup(name);
}
}
通过扩展tomcat默认的类org.apache.naming.resources.FileDirContext,覆写lookup方法,在方法中拦截。上例中先判断请求路径是否以“/CMS”作为前缀,如果是,则通过URLClassloader来加载。例如,在页面中出现标签,就会重定向到远端SVN服务器中加载。如何将/CMS/test.jsp这个相对路径映射成“http://”开头的绝对路径,则需要制定一个规则,这个规则可以根据不同的需要改变的。
Svn提供了一种钩子(hook)机制,详细使用方法请参照(http://blog.csdn.net/deepwishly/article/details/5366757),用户可以在提交svn请求的过程中自定义行为。例如,在pre-commit 这个钩子中可以检查提交的请求中是否填写了svn 日志信息,如果没有提交日志信息就抛出错误,终止commit操作。此方案中使用post-commit钩子脚本,脚本中向本机的一个端口发送一条消息:
#!/bin/sh echo `(echo $2 $1;sleep 0.1) | telnet localhost 9123`
变量$2是本地svn仓库的目录,变量$1是svn提交之后revsion,常驻在本地在9123端口上监听的进程会在对这条消息进行处理。
在整个系统中MinaAdapter的作用是在9123端口上负责接收并处理消息,将将收到的消息转化为业务端服务器中的JSP文件路径发送到消息中间件,告诉消息中间件那些JSP文件在SVN中更新了。 例如,从SVN post-commit发送过来一条 “/var/www/repos/svnrepos/ued 23“ 这样的消息,收到消息之后,MinaAdapter在执系统命令 svnlook changed –r 23 这样的命令,这样就能得到svn commit提交所更新到的文件列表,然后将这个列表发送给消息中间件。 有可能你会问为啥要在这里添加一个MinaAdapter来启一个端口监听,为啥不在post-commit中直接启动一个java程序,向消息中间件发送消息。原因是这样的,上线之后的会经常更新线上svn中的文件,新启一个java进程会需要初始化很多东西,比如日志系统,初始化和消息中间件连接等等,比较耗时,且需要消耗一定量的内存。所以,想到了启动一个常驻系统的进程,在9123端口(这个端口是可以设置的)上监听消息。为了保证这个进程的稳定性,在启动进程的程序进程的同时启动了一个系统的守护进程来确保应用的稳定性,这里使用了apache的daemon(http://commons.apache.org/daemon/)本方案中使用的是1.0.5这个版本,通过使用daemon,MinaAdapter进程在服务器上连续稳定运行3月都没有出任何故障。
构建于Apache之上的SVN服务已经很稳定了,但是,考虑到所有业务端服务器中需要加载的JSP文件,都是通过SVN服务来加载的,所以需要保证SVN服务有四个9的稳定性,如果能经受住飞毛腿导弹的测试那更好了。其实,要保证高稳定性,SVN本身有不错的解决方案,那就是采用一主多备的方案,所有的写操作都在主节点上,所有的读操作都在从节点上,这样可以保证在该系统中无论哪个节点挂机都不会系统整体的可用性。如果,主节点挂机的话,只是在一段时间内不能更新文件而已,不会影响从业务端读取JSP文件。如果,某一个从节点挂机的话,可以将读的请求引流到其他几个从节点上,等待修复之后再重新接受读请求。详细请参考我的另外一篇博客(http://mozhenghua.iteye.com/blog/1134116)
该方案的最初的版本没有版本控制中心模块,所有服务器中依赖的JSP文件是SVN服务器中最新版本的文件资源,也就是,当更新文件Commit到SVN库中之后,所有WEB服务器上依赖的文件如果在被更新的文件列表中的话,则WEB页面就会立即更新成最新的版本。
为了解决以上两个问题,需要在业务服务器和SVN控制中心之间添加版本控制中心,在版本控制中心中设置业务服务中依赖SVN服务的版本Revsion,这样就可以按照需要让业务服务器上显示任意svn的revsion所对应的页面。业务服务器在加载JSP资源的过程中需要通过如下步骤来确定加载SVN中的哪个版本:
有一点需要说明,在确定了需要加载的revsion之后,业务服务器需要通过url取得svn中的特定版本资源。假设在svn服务器中存在test.jsp文件,url是http://127.0.0.1/svn/repository/trunk/test.jsp 当前最新的版本是revsion是3,该链接默认取得revsion为3的test.jsp,如果向svn提交了最新版本test.jsp,revsion变成了4,那么通过http://127.0.0.1/svn/repository/!svn/ver/3/trunk/test.jsp 链接仍然可以取到历史版本3。
消息中间件的本质就是在分布式环境中实现观察者模式,分布式环境中一个节点可以向消息服务器订阅一个事件,之后如果发生了该事件,消息服务器就会将条消息主动推给订阅者。这样做的最大好处是将消息的接收者和消息的发送者解耦,消息的发送者不需要关心有多少客户端在订阅消息。消息的接收者不需要关心它所关注的消息是否发生而重复地询问消息发送者。以下是本消息系统中的主要的几个接口类:
public interface CmsRefeshListener {
/**
* 接收notify服务器jsp文件更新消息
*/
public void receiveMsg(UpdatePath updatePath);
/**
* 接收svn revision 更新的消息
*/
public void receiveSvnRevision(RevisionUpdate revision);
}
这个接口负责消息接收,会接收两种类型的消息,当svn中更新了jsp文件会,接收端会接收到一个UpdatePath类型的消息,当版本控制中心中更改了业务服务器所依赖svn版本信息,则订阅端会收到一个RevsionUpdate类型的消息。
public class PublishNotifyManager extends BasicNotifyManager {
@Override
protected String getGroupId() {
return CmsOptimConstant.PUBLISH_MESSSAGE_GROUP_ID;
}
public void publish(UpdatePath paths) {
sendMessage(paths, MESSAGE_TYPE_JSPREFESH);
}
/**
* 发布服务器最新的cmsjsp 最新版本
public void publishRevision(RevisionUpdate revision) {
sendMessage(revision, BasicNotifyManager.MESSAGE_TYPE_SVN_REVISION);
}
private void sendMessage(Serializable msg, String messageType) {
BytesMessage strMsg = new BytesMessage();
strMsg.setTopic(CmsOptimConstant.TOPIC);
strMsg.setMessageType(messageType);
strMsg.setSendOnceMessage(false);
strMsg.setPostTimeout(20000);
strMsg.setClientPostTimeout(20000);
strMsg.setTimeToLive(604800);
strMsg.setDLQTime(604800);
ByteArrayOutputStream byteoutStream = null;
ObjectOutputStream out = null;
try {
byteoutStream = new ByteArrayOutputStream();
out = new ObjectOutputStream(byteoutStream);
out.writeObject(msg);
out.flush();
strMsg.setBody(byteoutStream.toByteArray());
} catch (IOException e) {
throw new RuntimeException(e);
} finally {
try {
byteoutStream.close();
} catch (Throwable e) {
}
try {
out.close();
} catch (Throwable e) {
}
}
SendResult result = this.getNotifyManager().sendMessage(strMsg);
}
}
这个方案总的来说是对已有的产品的mashup,小的时候经常喜欢将一些废旧的玩具和一些没有用的零件重新组合一下做一个小车,虽然不需要花多少钱,但是这个过程很有意思,有时候最后的成品的价值往往超过单个零件的累加值。很多第一次了解这个方案的同学会问怎么会用SVN来部署到线上作为分布式环境中的一个存贮节点来用,一般只是用它来做多人协作开发的版本管理工具呀?其实道理很简单SVN的本质就是一个拥有版本维护功能的存储容器,从这个角度来说,用它来做线上分布式环境中的存储节点是非常合适的。
消息中间件的产品有很多,光在淘宝内部就有四五个,每个实现方案要解决的问题的侧重各有不同,有的侧重消息可靠性,有的侧重消息实时性,有的侧重消息持久化存储。我在这个方案中选择了一个消息中间件产品来用,而我在实现方案中对消息模型的subscribe和notify进行了抽象,可以隔离具体的实现方案,将来可以按照需要切换其他的消息中间件产品。这个方案已经在口碑网的“我的口碑”中部署了,从上线到现在运行已经有4个多月了,从效果来看页面维护,版本控制都达到了预想的效果。