多版本 Web 静态资源的一种管理方案

随着云服务的不断发展,客户对部署在云平台上的Web 服务应用程序有了更高的要求。用户新的需求需要尽快得到响应,也就是产品上线的周期越来越短。一般情况下,一个版本的应用服务正在线上满足用户的需求。下一个新版本的应用服务,经过了功能测试,性能测试,模拟产品环境测试,随时准备上产品线。值得注意的是,如果您正在应用微服务的架构方案,会和本文讲述的有所不同。

为了加快云平台上Web服务的迭代开发周期,充分利用云平台的软件和硬件资源,进而更快更好的满足更多用户的使用需求,云平台需要在一段时间内,可以有新旧两个不同版本的Web 服务应用程序同时满足用户的需求。用户感觉不到新产品的上线,运维时间也大大缩小。比如,已经打开了的、正在使用的应用服务可以继续连接到旧版本的服务器上,用户新打开的应用服务则连接到部署了最新版本的应用服务器上。但这样也就带来了一个问题,如何管理部署在云平台上同一个Web应用服务的多个版本的静态资源。

不同版本静态资源的管理问题

我们把正在线上满足用户需求的软硬件资源、Web应用服务看成是活着的(active),标记为SideA。那么随时准备上线的这一套云平台软硬件资源是不活着的(inactive),标记为SideB。经过优化后,两套环境都可以是活着的(active-active)。现在云平台上部署Web应用服务都是集群化的,根据业务逻辑增长的不同需求会添加、减少部署Web应用服务的服务器节点数目。对Web应用服务的功能请求也会使用例如F5, Nginx 等负载均衡服务来加以管理。

云平台环境SideA和SideB 都活着的,同时向用户提供服务。那么负载均衡服务器会同时统一管理原属于SideA 和SideB 的Web应用服务的节点。这样用户对Web应用服务的功能请求会路由到SideA或SideB 环境中的任何一个部署Web应用服务的节点。负载均衡服务并不是对Web 应用服务的每一个请求都会保持持久化连接(session affinity)。如果部署版本相同的话,在任何一个服务器上请求到的Javascript,html,样式表CSS,图片等静态资源文件都是一样的,也就不存在静态资源多版本管理的问题。显而易见,如果静态资源请求中包含不同版本的时间戳,Web 应用服务器会返回404 资源找不到的Http 请求错误。请求不到资源,这样Web应用服务的功能就不能正常工作了。

多个版本静态资源的解决方案

大道至简。我们把不同版本的Web静态资源放置到云平台环境SideA 和SideB 都能访问到的NFS 文件服务器上。在Web 应用程序中,调用Servlet 主动响应对静态资源的请求。程序发现是本地的静态资源,则读取本地的文件并缓存到内存中。不是本地的静态资源,则读取NFS 服务器上对应版本的静态资源文件。返回给客户端并缓存到内存中。接下来,我们分别进行详细介绍。

SampleServlet 响应对静态资源的请求

通常开发人员不用写程序来处理客户端静态资源的请求,Web容器(Tomcat, Weblogic , WebSphere 等)本身会提供缺省的实现。为了解决这个多版本的问题,我们需要自己写SampleServlet来处理静态资源文件的请求。清单1给出了web.xml 中SampleServlet的配置示例信息。从URL mapping 中,我们可假定对Web 服务静态资源的请求类似于下面的URL:"http://domain/{contextRoot}/static/20170509-1101/js/SampleApp.js"。

清单 1. SampleServlet 在web.xml中的配置

<servlet>
<servlet-name>SampleServlet</servlet-name>
<servlet-class>com.ibm.developerworks.paper.SampleServlet</servlet-class>
<init-param>
    		<param-name>isDebug</param-name>
    		<param-value>false</param-value>
    	</init-param>    	
</servlet>
<servlet-mapping>
    	<servlet-name>SampleServlet</servlet-name>
    	<url-pattern>/static/*</url-pattern>
</servlet-mapping>

在实现SampleServlet的过程中,我们需要解决以下几个问题。这些问题涉及到了如何更好的组织、管理这些静态资源文件,进而响应用户的请求?用户的请求是不是需要准确的知道资源文件的内容类型? 资源文件是不是需要在内存中缓存?资源文件更新了,SampleServlet如何及时响应?

正确返回请求资源的Mime 类型信息

HttpServletResponse 的实例要调用setContentType方法正确返回请求资源的Mime 类型信息。如果不是这样的话,显而易见浏览器不知道返回来的资源文件是什么东西。举例来说,浏览器请求到了Javascript 文件,如果SampleServlet 在响应头中不主动的设置contentType 属性为"application/javascript",它的响应类型默认是"text/plain"。那么出于安全方面的原因该javascript 文件就不能被浏览器加载运行。清单 2 给出了常见的静态资源文件的Mime 类型信息。示例中,我们用静态HashMap来存储, 在SampleServlet 类第一次加载的时候初始化。读者可以根据需要把资源及其Mime类型信息存储为xml 文件、json 文件或者properties 配置文件。

清单 2. 静态资源Mime类型示例

// test/javascript is no long used by html 5
mimeTypes.put("js", "application/javascript");
mimeTypes.put("json", "application/json");
mimeTypes.put("css", "text/css");
mimeTypes.put("ico", "image/x-icon");
mimeTypes.put("gif", "image/gif");
mimeTypes.put("jpg", "image/jpeg");
mimeTypes.put("jpeg", "image/jpeg");
mimeTypes.put("jpe", "image/jpeg");
mimeTypes.put("png", "image/png");
mimeTypes.put("html", "text/html");
mimeTypes.put("htm", "text/html");
mimeTypes.put("xml", "text/xml");
......
response.setContentType(resource.getMimeType());

SampleServlet 的缓存机制

如果应用程序本身来管理静态资源文件,SampleSevlet就需要文件I/O 操作读取请求的文件内容到内存中,并把结果返回给客户端。为了减少不必要的文件读取,提高响应速度,SampleServlet 需要缓存机制缓存文件内容。一个最重要的原因是,SampleServlet 会从NFS 上读取其他版本的Web静态资源,文件I/O操作会很耗时。另外,在上面的清单1中,SampleServlet 初始化配置参数中有isDebug参数变量,在Debug的环境中,开发人员如果不需要这个缓存机制,就把其设置为true。

正确返回Http 304 状态信息

对于每一次Web静态资源的请求,Web 服务应用的SampleServlet 通过文件最后一次被改动的时间戳和Http请求中相关信息来确认此资源文件是否被修改过。如果没有被修改过的话,返回HttpServletResponse.SC_NOT_MODIFIED,也就是http code 304状态告诉浏览器请使用本地缓存信息。如果此资源文件已经更新了,SampleServlet 会从硬盘上读取该资源文件,并更新缓存信息。清单 3 示例代码如下:

清单 3. 正确返回Http 304 状态信息

if (checkcache(request, resource.getLastModified())){
   // 如果在浏览器缓存中,返回 HTTP status code 304
   response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
   return;
 }
 private boolean checkcache(HttpServletRequest req, long FileModified){
   if (isdebug) //开发环境需要重新加载静态资源文件
     return false;
   long ModifiedSince = -1L;
   try{
     ModifiedSince = req.getDateHeader("If-Modified-Since");
   }
   catch (IllegalArgumentException iae){
     ModifiedSince = -1L;
   }
   long systemTime = System.currentTimeMillis();
   if (ModifiedSince != -1L){
     if (ModifiedSince / 1000L == FileModified / 1000L){
       return true; // 返回304
     }
     if ((systemTime >= ModifiedSince) && (ModifiedSince > FileModified)){
       return true; // 返回304
     }
     if (systemTime < ModifiedSince){
       LOG.log(Level.FINEST, "The IfModifiedSince date is later than the server's current
       time so it is invalid, ignore.");
     }
   }
   return false;
 }

用NFS 服务器存储多版本的静态资源文件

这个Web 应用服务器知道部署在本地的静态资源文件在哪里,通常是一个war 包或ear 包。如果用户的请求URL中的版本时间戳和本地部署的版本时间戳一致,那么SampleSevlet 很容易读取本地文件内容,缓存文件内容,并返回给客户端。

在SampleServlet 的doGet 方法中,我们发现资源文件的版本时间戳不是本地Web服务器部署的版本时间戳,就会向NFS 文件服务器发出请求。这需要Web 服务器事先能够访问NFS 文件服务器,否则时间开销会很大,用户请求的响应会很慢。通常在部署Web服务应用的脚本里面或在应用服务启动的过程中挂载到NFS 服务器。清单4给出了示例代码。

清单 4. 请求NFS资源文件

File file = new File(nfsfilepath);
if (file.isFile())
{
  long lastModified = file.lastModified();//获取文件的被修改的时间戳
  resource.setLastModified(lastModified);
  LOG.log(Level.INFO, "The web resource from NFS server is " + nfsfilepath + " and the last
  modified time is " + lastModified);
  if (isdebug || !cache.containsKey(resourcePath))//查看缓存信息
  {
    try
    {
      byte[] bytes = NFSFileUtil.nfs_getFileBytes(file, NFSFileUtil.NFS_RETRY_SECONDS);
      resource.setData(bytes);//从NFS 服务器上读取到资源文件
      LOG.log(Level.INFO, "Obtained web resource from NFS server for - " + nfsfilepath);
}
catch (IOException e)
    {
      LOG.log(Level.SEVERE, "Failed to obtain web resource from NFS server for - " +
      nfsfilepath, e);
      return null;
    }
  }
}
else
{
  LOG.log(Level.SEVERE, "Failed to obtain web resource's lastModified and data from NFS  
  server for - " + nfsfilepath);
  return null;
}

zooKeeper 管理静态资源文件

读到这里,您一定会考虑这些问题:什么时机把新版本的静态资源文件拷贝到NFS 服务器上?为什么不把NFS 服务器上的资源文件下载到Web 服务器上呢? 什么时机去下载?如果NFS 服务器上的版本越来越多该怎么办?如何管理?这里面涉及到时间复杂度和空间复杂度的算法问题。一个最根本的指导原则,用户请求的响应要及时,妥善处理好程序的性能问题。一个最根本的价值观就是客户为要。

在云平台Web 服务器集群的复杂环境中,我们通过zooKeeper 服务器来管理NFS服务器上哪些静态资源版本在线,哪些版本已经下线,进而及时清理硬盘空间。清单5 给出了python脚本的示例代码:

清单 5. zooKeeper管理资源文件代码示例

def updateZookeeperNode():	
  print "The online version is %s" % ONLINE_VERSION #打印当前准备上线的版本号
  if (ONLINE_VERSION in [None,'']):
    logging.error("Severe error - the version to be onlined is empty.")
    return
  offline = None
  try:
    baseTN = registryParser.getBaseTopologyName() #获取云平台的名称
    side = registryParser.getTopologyName() #获取云平台环境即将上线的面,值是A 或 B
    versionPath = '/%s/data/SampleApp/version/%s' %(baseTN, side) #版本路径信息
sideValue = zooKeeperClient.getData(versionPath)#获得该环境上已经上线的版本号
if (sideValue == "null" or sideValue in [None,'']): #节点上还没有上线的版本号,第一次上线      
  print "Create node %s on zookeeper and its value is %s" % (versionPath, ONLINE_VERSION)
      logging.info("Create node %s on zookeeper and its value is %s" % (versionPath, ONLINE_VERSION))
      zooKeeperClient.createNode(versionPath, ONLINE_VERSION) #节点上存储上线的版本号
      return
    else:
      logging.info("Old %s SampleApp version is %s" % (side, sideValue))#输出旧的版本号
      if(sideValue != ONLINE_VERSION):
        zooKeeperClient.setData(versionPath, ONLINE_VERSION) #新版本上线
        logging.info("New %s SampleApp version is %s" % (side, ONLINE_VERSION))
        children = zooKeeperClient.getChildNodes('/%s/data/SampleApp/version' %(baseTN))
        offlineFlag = True
        for node in children: # 循环查看即将下线的版本在其他云平台环境中有没有被用到
          #Filter itself and empty side name
          if(node == side or node == ''):
            continue
          else:
            data = zooKeeperClient.getData('/%s/data/SampleApp/version/%s' %(baseTN, node))
            if(data == sideValue):
              offlineFlag = False #其他环境正在使用该版本号,不删除其在NFS上的资源文件
              break
        if(offlineFlag):
          offline = sideValue # 没有其他环境用到,旧版本准备下线
        else:
          logging.info("The obsolete version %s is still using by anther side." % sideValue)
      else:
        print "Flip failover case - no new version is online."
        logging.info("Flip failover case - no new version is online.")
    if (offline is not None):
      logging.info("The offline version is %s" % offline)
      global OFFILE_VERSION
      OFFILE_VERSION = offline
  except Exception,e:
    logging.error(e)
    raise

在部署Web应用程序的时候,调用清单5的示例脚本把资源文件信息拷贝到NFS服务器上。在集群环境中,不需要在部署每一个服务节点的过程中都这么做。示例中,我们通过zookeeper选举出一个节点来完成静态资源到NFS 服务器的拷贝任务。

方案的架构

下面我们从宏观架构的角度进一步深入浅出的讲解本文的解决方案。框架如下图1所示:

图 1. 框架示例

图1中:SWR, static web resource 的英文首字母简写。AA,active active的简写。SS,SampleServlet的简写。SF,Side Flip 的简写。就是部署完成后,准备上线的程序逻辑。只有被zooKeeper选中的节点才会执行此程序逻辑。

浏览器对静态资源的请求发送到了负载均衡服务器F5,F5根据路由规则(active-active 的iRule)把请求发给SideA 或SideB。SideA 部署的是旧版本的SampleApp应用程序,版本号是20170509-1101。SideB 部署的是新版本的SampleApp 应用程序,版本号是20170609-1704。

假定请求URL是http://sampleCompany/sample/static/20170509-1101/js/SampleApp.js,如果SideA 中的结点服务器收到了请求,SampleServlet匹配版本号,从本地加载文件返回客户端。如果SideB中的结点服务器收到了请求,SampleServlet 访问NFS 文件服务器加载文件并返回给客户端。

细心的读者可能会问,既然不同的Web服务应用版本同时存在,有没有可能新上线的应用服务中返回了旧版本的SampleApp.js 文件,而已经在线的应用服务请求得到了新上线的SampleApp.js文件呢?答案是:不可能存在这种情况。第一次请求被负载均衡服务器路由到了某个应用服务器,该应用服务器返回给客户端HTML主页面(通常采用Ajax 富客户端开发的应用程序,只有一个HTML 主页面),它负责加载与应用服务器上版本相对应的javascript,css,图片等静态资源文件。进而通过Session, cookie 等技术让客户端保持对应用服务器的持久化连接。一言以蔽之,想要的静态资源文件从哪里来不打紧,只要是对应版本的就可以了。

正如本文开篇所说的那样,我们期望,用户已经打开的应用程序可继续连接到旧版本SideA 上。新版本SideB 已经上线了,用户登陆后打开应用程序,会得到最新的应用服务。旧版本持续服务一段时间就会升级成和SideB 一样的版本。

小结

本文深入浅出的讲解了多个版本静态资源文件管理问题的由来及解决方案。多个版本的静态资源可同时存在于应用服务器集群中。这种部署策略加快了云上产品新功能服务上线的周期,降低了云上产品运维的风险,提供了更好的用户体验。解决方案则简单易行,开发人员需要自己动手写Servlet 响应静态资源的请求。先从结点本地去获取静态资源文件,如果不能找到则去NFS 服务器上下载对应版本的资源文件,缓存后,返回给客户端。在部署的过程中,应用zookeeper 选择一个应用服务器结点完成对NFS 服务器上静态资源文件的上传、删除等管理操作。希望读者能从本文的讲解中获益,圆满解决多版本静态资源文件管理的问题。

参考资源 (resources)

我来评几句
登录后评论

已发表评论数()