(接上文《架构设计:系统间通信(31)——其他消息中间件及场景应用(下1)》)
5-3、解决方案二:改进半侵入式方案
5-3-1、解决方法一的问题所在
方案一并不是最好的半侵入式方案,却容易理解架构师的设计意图:至少做到业务级隔离。方案一最大的优点在于日志采集逻辑和业务处理逻辑彼此隔离,当业务逻辑发生变化的时候,并不会影响日志采集逻辑。
但是我们能为方案一列举的问题却可以远远多于方案一的优点:
需要为不同开发语言分别提供客户端API包。上文中我们介绍的示例使用JAVA语言,于是 事件/日志采集系统 就要提供JAVA语言的客户端API包。如果需要集成 事件/日志采集系统 的业务系统,都是您公司内各个业务团队开发的,那么这个问题还算不上大问题——至少您可以知道优先开发哪种语言的客户端,也知道需要开发有几种有限的语言;但如果您想将 这个采集系统发布成共享软件,或者上市进行售卖,那么这个问题将限制您产品的快速发展起来。
由于 事件/日志采集系统 的客户端代码需要在业务系统中进行编码集成。所以API包的升级也是一个问题:重大的API包升级可能就会造成之前版本的不兼容问题,导致业务系统重新更改采集系统的调用代码。同样,如果所有业务系统都在您公司内部,那么这个问题也不大。但是记住,您的目标是要将系统产品化。
虽然在业务系统中,可以通过良好的代码结构将业务逻辑和日志采集逻辑进行隔离,但是日志采集的处理过程终归集成于业务系统中,或多或少会影响业务系统的处理过程。例如:当消息生产者速度减缓时,可能就会影响到业务系统的处理效率;当待发送的消息在业务系统端大量堆积时,这些消息就会占用本该由业务数据使用的系统内存。
看来,我们需要另一种半侵入的解决方案来解决这些问题。
5-3-2、解决方法二的思路
第二种解决方案中,我们只要求业务系统在页面上加载一段JavaScript代码,就可以完成业务系统的事件/日志采集工作。事件/日志数据通过HTTP协议,跨域传输到事件/日志采集系统。
HTTP协议的优势在于它是一个业内广泛使用的协议,下到刚从学校毕业的应届生上到有20年开发经验的资深工程师,都会运用这个协议。其次,这个协议与编程语言无关,您的业务系统无论是使用JVM虚拟机系列的语言进行开发,还是使用PHP进行开发,或是使用NodeJS进行开发又或者其它开发语言进行开发。只要您需要在浏览器上呈现操作页面,就会涉及到HTTP协议。
在业务系统的页面集成JavaScript脚本实现对访问日志的采集的方式,实际上也有一定局限性:如果您需要采集的事件不是针对页面访问进行的(例如采集业务服务器在设定的定时执行器中,进行了多少订单费用结算),那么这种方案二的方式就不太适用。还好,根据上文中提到的统计需求,我们需要统计的恰好是商品订单的访问情况和商品价格走势的访问情况。
5-3-2-1 负载层设计
方案二和方案二的负载层设计完全不一样。在方案一中,由于业务系统中集成了消息队列的生产者端,所以它的负载层完全由Kafka Brokers中的分区(partition)完成。但是在方案二中,由于业务系统向采集系统发送消息的方式是通过HTTP协议完成,所以采集系统的负载层需要进行相应的调整:
上图是一个典型的基于HTTP协议的负载均衡方案。在我的另一篇博文《架构设计:负载均衡层设计方案(7)——LVS + Keepalived + Nginx安装及配置》中对这个方案有详细的介绍,这里就不再进行赘述了。如果您还觉得负载层太薄弱,还可以在其之上再加入DNS轮询等技术。
5-3-2-2 为什么还要继续使用MQ?
第二种解决方案中,在事件/日志采集系统内部我们还是使用了Apache Kafka MQ技术,在采集系统内部进行消息的发送和接受。在一些读者看来,消息已经通过HTTP协议从外部业务系统(更确切来说是从业务系统用户的浏览器端)传输到了采集系统内部,那么在采集系统内部只需要完成对这些原始日志的存储(或者送入及时分析系统)就行了,为什么还需要在采集系统内部采用消息队列机制呢?
考虑一下这种情况,当集成了采集系统的各个业务系统突然出现访问洪峰,产生大量的日志数据时。如果采集系统内部没有任何缓存机制,就会让采集系统编程整个架构中的处理瓶颈。要知道,无论您在采集系统内部采用哪一种适当的持久化存储方案,都会消耗较多的处理时间。所以在方案二中,采集系统内部使用MQ队列就是出于缓存消息的目的。
当然您也可以去掉MQ,换成其他的方案缓存来不及处理的日志消息,但一定要有这样的缓存机制。因为处理单条日志数据,采集系统一般会消耗比业务系统多的时间,毕竟业务系统只负责发送日志数据。
那么结合负载均衡层的调整和已有的Kafka消息队列的方案,我们就可以画出方案二中完整的系统架构图了:
5-3-2-3 跨域问题如何解决
在本方案中,业务系统通过呈现在浏览器上的页面,集成JavaScript脚本向采集系统发送HTTP请求。但是业务系统和采集统很可能使用不同的域名(实际情况是作为事件/日志采集系统的架构师,您不可能控制业务系统的域名)。
如上图所示,跨域的情况下业务系统的页面不能通过浏览器端的XMLHttpRequest对象向工作在另外一个域的采集系统发送HTTP请求。为了解决这个问题,我们需要找到一种在浏览器端能够完成HTTP跨域调用的方法。
好在靠谱的程序员们为我们提供了很多过往经验解决这个问题:proxy、Flash、iframe、Jsonp、CORS等等。这里我们根据采集系统的技术需求,介绍两种可以使用的解决办法:iframe和CORS。
- CORS方式:
CORS是Cross-Origin Resource Sharing(跨源资源共享)的简称。这个跨域技术主要由浏览器提供支持。当浏览器检查到XMLHttpRequest对象进行跨域调用时,CORS会首先允许本次调用,并且检查对方响应的HTTP协议的返回信息。如果返回信息的Header中存在Access-Control-Allow-Origin属性描述信息,并且允许调用域,那么就认为调用成功;否则浏览器会提示类似于:“No ‘Access-Control-Allow-Origin’ header is present on the requested resource. Origin XXXXX is therefore not allowed access.”的错误。
由于CORS方式的跨域调用需要浏览器的支持,所以存在一个浏览器版本的支持问题。以下列表摘自CORS官网(http://enable-cors.org/)列举了各种浏览器版本对CORS的支持情况:
上图中红色部分代表不支持CORS的浏览器版本、黄色图块代表部分支持CORS的浏览器版本、绿色图块代表完整支持CORS的浏览器版本。要使用CORS的支持也很简单,只需要在目标域的服务端http协议header部分写入“Access-Control-Allow-Origin”属性,如下JAVA代码所示:
- 允许任何域调用本域服务
......
response.setHeader("Access-Control-Allow-Origin", "*");
......
- 允许XXXXX域调用本域服务
......
response.setHeader("Access-Control-Allow-Origin", "XXXXX");
......
注意,如果您使用CORS方式,并且服务前存在类似Nginx一样的HTTP代理服务,那么您需要在Nginx的配置中增加对Access-Control-Allow-Origin的支持,类似如下:
http {
......
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Headers X-Requested-With;
add_header Access-Control-Allow-Methods GET,POST,OPTIONS;
......
}
- iframe标签方式:
使用iframe标签,实际上就是避免在浏览器端使用XMLHttpRequest对象。iframe标签在各个版本的浏览器上基本上都没有不支持的问题,只有部分浏览器对iframe标签的属性支持有一些不同。以下是一个使用iframe标签调用另一域上服务的示例:
......
......
display属性的作用是保证iframe标签不会有展示效果出现在最终页面上。使用iframe标签进行跨域调用是有明显缺点的:它会破坏前端开发人员既定的页面布局思路;如果不隐藏iframe标签,还会破坏开发人员在书写JavaScript脚本时的效果预判。
由于这两种方式都有一些问题,所以在实际操作中可以两种解决方法进行混用。首先判断当前浏览器版本信息,如果浏览器版本支持CORS方式,则优先采用这种方式(毕竟这种方式不会改变页面既有的html标签布局);如果浏览器版本不支持CORS方式,则使用iframe标签方式。至于日志服务器所提供HTTP的调用接口上,始终都向header增加Access-Control-Allow-Origin属性。
5-4、解决方案二编码示例
由于解决方案二中有很多技术点都和解决方案一相同,例如都使用了Apache Kafka MQ,都会使用Spring进行支撑,并且都不会影响消息消费者使用“适当的存储方案”进行存储。所以在本小节介绍方案二的代码时,我们只会给出那些不一样的,能够体现方案二工作特点的代码,其他部分的代码就不再赘述了。
5-4-1、混合采用CORS和iframe
为了便于第三方业务系统的集成,采集系统所提供的JavaScript代码段应该尽量简单,最好就只需要业务系统引用一个JavaScript文件就行了。如下代码端所以:
// 业务系统在页面上通过以下形式引用采集系统提供的脚本文件
......
......
以上代码片段中“www.logsservice.com”就是采集系统所在的域名,analysis.js就是提供给各个业务系统进行嵌入的js文件,“34ab834ea98ee838ac76ed3986347546”是一段由采集系统的“注册管理平台”生成的第三方业务系统的校验串,只有校验串所绑定的域名和当前嵌入js文件页面所在的域名相同时,采集系统才认为本次采集数据有效。
以下为“analysis.js”文件的脚本代码示例:
var _supportchromeversion = ["47","48","49","50","51","52"];
// 首先,无论使用哪种方式向采集系统发送http数据,都需要得到页面上引用本js文件时传递的校验串encrypted
// 这个encrypted参数含有相当的信息量
// 日志服务通过这个encrypted验证用户权限,业务系统域名匹配等信息
var encrypted = null;
var scripts = document.getElementsByTagName("script");
for (var index = 0; index < scripts.length; index++) {
var script = scripts[index];
// 如果条件成立,说明找到了在页面上本js文件的引用位置,并且有加密参数记录
if (script.src.indexOf("js/analysis.js") >= 0 && script.src.indexOf("?") >= 0) {
encrypted = script.src.split('?')[1];
}
}
// 如果没有传递encrypted信息,则认为是错误的js引用。不再进行处理
if(encrypted != null && encrypted != "") {
// 确定当前浏览器是否支持CORS方式
var bowersInfos = getVersion();
var supportCors = false;
// 在本示例中,我们只判断了chrome浏览器的版本信息
// 其它浏览器版本的判断原理相似
if(bowersInfos.browser == "chrome") {
var currentVersionArray = bowersInfos.ver.split(".");
var currentVersion = currentVersionArray[0];
if(contains(_supportchromeversion , currentVersion)) {
supportCors = true;
}
}
// =================
// 这里可判断其它浏览器的支持情况
// =================
// ===========================如果支持,则直接使用XMLHttpRequest发起请求
//时间戳是为了防止 HTTP 304
var timestamp = new Date().getTime();
if(supportCors) {
var req = createXmlHttpRequest();
var url = "http://127.0.0.1:9090/templateSSHProject/analysisSomething?encrypted=" + encrypted + "&" + timestamp;
req.open("GET" , url , true);
req.send(null);
}
// ===========================如果不支持,则使用iframe方式进行请求
else {
var context = "";
document.write(context);
}
}
// 获取浏览器版本的方法
// 该方法经用于测试使用。包括的浏览器并不完整
function getVersion() {
var Sys = {};
var ua = navigator.userAgent.toLowerCase();
var re =/(msie|firefox|chrome|opera|version).*?([\d.]+)/;
var m = ua.match(re);
Sys.browser = m[1].replace(/version/, "'safari");
Sys.ver = m[2];
return Sys;
}
//获取XmlHttpRequest对象
function createXmlHttpRequest() {
if(window.ActiveXObject) {
return new ActiveXObject("Microsoft.XMLHTTP");
} else if(window.XMLHttpRequest) {
return new XMLHttpRequest();
}
}
// 用于集合元素比较
function contains(collection, obj) {
var index = collection.length;
while (index--) {
if (collection[index] === obj) {
return true;
}
}
return false;
}
根据以上代码片段,如果浏览器不支持CORS方式那么脚本代码将在页面输出一个iframe标签,并通过这个iframe标签完成跨域调用(当然这个标签在页面上是不可见的)。生成的iframe标签如下所示:
如果浏览器支持CORS方式,那么脚本代码将创建XMLHttpRequest对象,并通过XMLHttpRequest对象完成跨域调用(IE下使用ActiveXObject)。
注意:为了方便调试,以上实例代码中使用了一个笔者本地可调试的url, 代替了“www.logsservice.com”。读者可以根据自己的url进行替换。
5-4-2、采集系统生产者编码
说完了采集系统为业务系统提供的JavaScript脚本文件,我们再来说说采集系统的HTTP接口层代码:
package templateSSHProject.controller;
import java.io.PrintWriter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import test.interrupter.producer.ProducerService;
/**
* spring MVC组件搭建的http控制层
* @author yinwenjie
*/
@Controller
@RequestMapping("/")
public class AnalysisController {
/**
* 这里就是消息生产者对象
* 其工作方式与方案一中的工作方式一致
*/
@Autowired
private ProducerService producerService;
/**
* 做一些分析动作
* @param request
* @param response
*/
@RequestMapping("/analysisSomething")
public void analysisSomething(HttpServletRequest request , HttpServletResponse response) {
String param = request.getParameter("encrypted");
// 利用kafka生产者端发送消息
this.producerService.sendeMessage(param);
System.out.println("public void sendeMessage(String message) : " + param);
// 输出相应信息,最关键的就是header中的设置
// 有没有body信息,都没有什么关系
response.setHeader("Access-Control-Allow-Origin", "*");
response.setCharacterEncoding("utf-8");
response.setContentType("text/html; charset=UTF-8");
PrintWriter out = null;
try {
out = response.getWriter();
} catch (Exception e) {
throw new RuntimeException(e);
}
out.print("");
}
}
采集系统保持高吞吐量的其中一个关键在于,Web控制层中所使用的Apache Kafka消费者对象producerService能够快速的将消息发送出去。可以沿用方案一中对Apache Kafka消息生产者的设置。
5-5 方案二中其他设计思考
- 保证日志收集权限
按照解决方案二的设计思路,完成设计的日志/事件采集系统,是可以作为一款产品对公众开放了。既然要开放系统就涉及到各个用户的权限问题:至少应该保证用户A集成采集系统的业务系统是一个可用的业务系统,应该保证每一个用户只能在采集平台上看到他自己的业务系统的统计信息。
采集系统可以提供业务系统注册功能,所有要使用采集系统的业务系统都首先需要通过注册页面进行注册。注册成功后,采集系统将会为这个业务系统生成一个唯一校验码。在进行日志采集时,只有校验码对应的业务系统和业务系统所注册的域名完全一致,采集系统才会认为本次数据有效。
- 卸掉流量洪峰
事件/日志采集系统架构设计的另一个重点问题,就是要保证事件/采集系统能够在多个业务系统同时出现流量洪峰的情况下,也能正常的进行日志统计,并且不影响各个业务系统的正常工作——您不可能要求使用采集系统的各个业务日均PV不能超过XXXXX的最大阀值。
除了上文提到的采用一款高吞吐量MQ作用于采集系统内部,在流量洪峰时堆积消息消费者还未来得及处理的日志消息以外(这也是方案二中依然要使用MQ组件的原因)。您还可以进一步在Kafka分区上做进行文章,例如为每一个业务系统创建独立的Topic,并视用户购买的服务套餐情况设置不同的分区规模。您还需要为整个日志采集系统安排40%左右的闲置资源,以便再出现流量洪峰的情况下,可以快速升级每个物理节点的性能或者加入新的服务节点——云化的服务器是一个不错的选择。
需要注意的是:Apache Kafka中Topic所拥有的分区数量一旦创建就不能改变的缺点会限制它的横向扩容潜力。所以如果真的要设计一款超大型,对多个高数据流量的业务系统进行完全开放的采集系统,其中是否还是采用Apache Kafka作为核心消息传递手段就需要再进行慎重考虑了。
实际上如果您已经看过笔者三个专栏中的所有文章,那么分布式系统中最关键的几个问题都已经有过介绍了(除了数据一致性问题和数据恢复问题):服务节点发现方法、服务协调和选举规则、网络IO模型、缓存和异步处理。那么为什么不自己写一个满足技术需求的MQ呢?另外,阿里的开源项目RocketMQ也是一个不错的选择哦。
- 沿用方案一的MQ的设计
和解决方案一相比,在解决方案二中的消息消费者代码,包括其中调用的“合适的存储方案”都不需要做任何的变化。日志系统为业务系统提供的HTTP调用接口是为了保证各种业务系统的调用兼容性;继续在日志系统内部使用MQ是为了保证日志系统不会成为任何外部系统的调用瓶颈。这样,在解决方案二中就进一步优化了解决方案一中遗留的设计问题。
5-6 百度站长统计工具
类似方案二这样,在浏览页面嵌入JavaScript代码进行访问日志采集的典型应用之一就是百度推出的“百度站长统计工具”(http://tongji.baidu.com/)。要使用这个统计产品,首先您需要注册一个用户信息,并且告知统计工具您需要统计的业务系统的工作域名。
接下来百度统计工具就会为您生成一段JavaScript代码,并且带有校验信息。如下图所示:
实际上,如果您仔细阅读以上生成的代码,就会发现这段代码主要做的事情是:“通过这段代码生成另一个JavaScript引用标签”。最后您只需要在您的业务系统页面上,加入这段JavaScript代码就行了。
上图是“百度站长工具”的统计结果样例。