在单个 Cloud Foundry 应用中使用 MongoDB、Redis、Node.js 和 Spring MVC

工程 | Jon Brisbin | 2011 年 5 月 3 日 | ...

传统上,应用程序由其主要使用的技术来定义。如果你正在构建一个 Spring MVC 应用程序,我们称之为“Java 应用”。由于我们的应用程序主要由 Java 组件组成,我们倾向于固守自己的领域,直到被迫与邻居互动时才愿意合作。我们搭建基于 Java 的应用服务器,并倾向于首先考虑使用 Java 语言来解决应用程序中的问题,无论这种语言是否是最佳选择。为应用程序维护多套运行时环境通常太困难了,所以我们只是出于惯性而将自己局限于单一领域。

Cloud Foundry 彻底改变了这种局面,因为它不再妨碍你为特定任务选择合适的工具。我们再也不必强迫自己的应用程序局限于单一类型(无论是“Java 应用”还是“Node 应用”)。如果我们需要支持 XHR 长轮询的超高容量、非阻塞吞吐量,我们可以为应用程序的这一部分使用 Node.js。如果我们需要 Spring 项目系列中提供的灵活性和深厚的库支持,我们可以轻松地为应用程序的这一部分使用 Java 来利用它们。如果我们需要一个用于缓存或事件总线的快速键值存储,同时也需要一个强大的文档存储来持久化数据,我们可以在同一个应用程序中同时使用这两者,而无需担心单独设置这些服务并自行管理的复杂性(或者把它们一股脑扔给我们已经焦头烂额的运维人员)。

更重要的是,部署任一类型的应用程序都像从我们最喜欢的 shell 中发出一个“push”命令一样简单。

多语言编程 ^ N

那句老话怎么说的来着?“如果值得做,就值得做过头”?这个示例应用程序正是这种想法的典范!

这个应用程序有几个组成部分:

  1. 一个定期事件,用于生成随机股票行情数据并将其发送到事件总线。
  2. 一个 Node.js 应用程序,提供一个使用 Socket.IO 进行长轮询 Ajax 功能的 Web 前端。
  3. 一个 Spring MVC 应用程序,用于从事件总线读取单个数据点,并将这些数据点汇总存储到 MongoDB 中的一个文档里。

第 1 点和第 2 点在同一个应用程序中处理:即提供 Web 前端的 Node.js 应用。第 3 点是一个标准的 Spring MVC 应用程序,它使用 Spring Data 项目系列中的 NoSQL 支持,在一个辅助类中连接 Redis 和 MongoDB。

Node.js

我们使用 Node.js 是因为它:a) 轻量、快速且非阻塞,以及 b) 它是 Web 世界里的连帽衫(现在所有酷炫的开发者都在用它)。

说正经的,Node.js 是部署 Web 前端的绝佳选择。我们使用它通过 Socket.IO 将行情事件异步发送到浏览器,使用 Mongoose MongoDB 库发送到数据库,并通过我们应用程序的事件总线(本例中是 Redis)发送出去,供运行在另一个应用程序中的代码消费。

这里内容很多,我们将分块讲解。

配置

在深入应用程序之前,我们需要讨论如何从 Cloud Foundry 环境中获取配置信息。连接到已配置服务所需的 hostname、port、user 和 password 都编码在一个名为 "VCAP_SERVICES" 的环境变量中存储的 JSON 文档中。目前正在出现各种辅助工具来帮助开发者使用这些配置值(或在本地运行应用程序时的默认值)。我们这里使用的 Node.js 模块不一定反映我们撰写本文时正在开发的官方 Node.js Cloud Foundry 运行时模块。

连接到 MongoDB

在 Cloud Foundry 中运行时,要获取连接到 MongoDB 实例所需的配置信息,请如下引用 "cloudfoundry" 模块:


var cf       = require("cloudfoundry");
var mongoConfig = cf.getServiceConfig("ticker-analysis")
		|| { username: "admin", password: "password", hostname: "localhost", port: 27017, db: "tickeranalysis" };

这要么从 VCAP_SERVICES 环境变量中拉取我们的配置信息,要么提供一组在本地运行时使用的默认值。

为 JavaScript 实体设置映射

我们使用 Mongoose MongoDB 映射库连接到数据库。我们既保存自己的单个行情事件,也读取 Spring MVC 应用程序保存的事件。使用文档存储来持久化数据的好处是它提供了全面的跨语言支持。我们可以使用 Spring Data 映射基础设施保存一个对象,稍后在我们的 Node.js 应用程序中使用 Mongoose 读取该对象。

要配置 Mongoose 库,我们需要定义我们的模型:


var mongoose = require("mongoose"),
    Schema   = mongoose.Schema,
    ObjectId = Schema.ObjectId,
    DocumentObjectId = mongoose.Types.ObjectId;

var TickerEvent = new Schema({
	symbol: { type: String },
	 price: { type: Number },
	volume: { type: Number }
});
mongoose.model('TickerEvent', TickerEvent);
var TickerSummary = new Schema({
	      _id: { type: String },
	timestamp: { type: Number },
	      max: { type: Number },
	      min: { type: Number },
	  average: { type: Number },
	   volume: { type: Number }
});
mongoose.model('TickerSummary', TickerSummary);

Java 端的对应领域对象看起来像这样:


@Document(collection = "tickersummary")
public class Summary {

	@Id
	private final String symbol;
	private final Long timestamp;
	private Float total = new Float(0);
	private Integer samples = 0;
	private Float min = Float.MAX_VALUE;
	private Float average = new Float(0);
	private Float max = Float.MIN_VALUE;
	private Integer volume = 0;

  // Constructors, getters, and setters...
}
Express.js

为了提供 Web 前端,我们将使用 Node.js 的 express.js Web 框架。在这段代码中值得注意的是,我们使用了 Cloud Foundry Node.js 模块上的一个特殊方法来判断我们是否正在云中运行。如果是在云中运行,我们就不会像在开发环境中运行时那样将异常倾倒到浏览器。


var express  = require("express");
var app      = express.createServer();
app.configure(function() {
  
  // Standard express setup
	app.use(express.methodOverride());
	app.use(express.bodyParser());
	app.use(app.router);	
	app.use(express.static(__dirname + '/public'));
	
	// Use the Jade template engine
	app.set('view engine', 'jade');
	app.set('running in cloud', cf.isRunningInCloud());
	
  // Don't give away information about our environment in production
	if(!cf.isRunningInCloud()) {
		app.use(express.errorHandler({ dumpExceptions: true, showStack: true }));
	}
	
});
Socket.IO

我们使用 Socket.IO 进行 Ajax 长轮询,将我们的服务器端事件传输到监听的浏览器。由于 Cloud Foundry 尚处于 Beta 阶段,它还不支持完整的 websockets(这在路线图上)。为了进行设置,我们将指定 Socket.IO 使用长轮询,因为我们已经知道否则动态路由基础设施不会喜欢我们。我们还必须在 10 秒后重置此连接,以防止超时机制关闭我们的连接。随着 Cloud Foundry 平台的演进,这可能成为一个无关紧要的问题。但目前,如果在 Cloud Foundry 中使用 Ajax 推送,请记住这些注意事项。


var io = require("socket.io").listen(app, {
	transports: ['xhr-polling'], 
	transportOptions: {
		'xhr-polling': {duration: 10000} 
	} 
});

事件发射器

为了生成实际的数据点,我们可以选择订阅任何公开可用的股票行情源。鉴于在这种情况下数据的构成方式并不重要,而且通过随机生成行情数据我们可以更好地从更深层次上展示跨运行时集成,因此我们将这样做。

要将这些事件发布到另一个监听应用程序,我们需要使用 Redis 的 pub/sub 功能作为事件总线。在 Node.js 中,我们为此设置了两个独立的 Redis 客户端实例。一个用于监听要发送到浏览器的事件,另一个将用作向外发布的客户端。


// Get our Cloud Foundry config information or default to localhost
var redisConfig = cf.getServiceConfig("ticker-stream")
		|| { hostname: "localhost", port: 6379, password: false };

// Create Redis client instances
var redisClient = redis.createClient(redisConfig.port, redisConfig.hostname);
var redisPublisher = redis.createClient(redisConfig.port, redisConfig.hostname);
if(redisConfig.password) {
  // Cloud Foundry Redis instances are secured with a password
	redisClient.auth(redisConfig.password);
	redisPublisher.auth(redisConfig.password);
}

redisClient.subscribe("ticker-stream");
redisClient.on("message", function(channel, json) {
	var data = JSON.parse(json);
	
	// Save this event to the database
	var TickerEvent = db.model('TickerEvent', 'tickerdata');
	var te = new TickerEvent({
		symbol: data.symbol,
		price: data.price,
		volume: data.volume
	});
	te.save(function(err) {
		if(err) {
			throw(err);
		}
	});
	
	// Broadcast this event to the browser
	io.broadcast(json);
	
});

为了发送数据,我们有一个辅助方法,我们对其调用 setTimeout,并传递一个 3-7 秒的随机等待时间。


var tickerSender;
function sendTickerEvent() {
	var symbolInfo = {
		symbol: getRandomSymbol(), 
		price: getRandomPrice(),
		volume: getRandomVolume()
	};
	redisPublisher.publish("ticker-stream", JSON.stringify(symbolInfo));

	// Call ourselves again after 3-7 seconds
	tickerSender = setTimeout(sendTickerEvent, getRandomTimeout());
}
Express.js 路由

我们 Web 应用程序的路由相当简洁。我们需要渲染包含 JavaScript 魔力的主页来驱动 UI,并提供一个路由,以便用户点击股票代码链接时从 MongoDB 获取摘要文档并显示在页面右侧。


app.get("/", function(req, resp) {
	resp.render("home", {
		pageTitle: "Ticker Analysis Sample"
	});
});

app.get("/summary/:symbol", function(req, resp) {
	var TickerSummary = db.model("TickerSummary", "tickersummary");
	TickerSummary.findById(req.params.symbol, function(err, data) {
		if(err) {
			// Handle error
		}
		resp.send(JSON.stringify(data));
	});
});

为了初始化数据生成,我们需要确保我们的随机事件发射器正在运行。但由于我们不希望在没有人查看页面时数据库被填满,所以我们只在用户首次访问我们的应用程序时启动事件发射器。之后,它将一直运行,直到超时器 "tickerSender" 被清除(如果需要,你可以添加一个路由来实现)。


// Socket.IO-based Ticker Stream
io.on("connection", function(client) {
	if(!tickerSender) {
	  // Start the ticker stream if one hasn't been already
		sendTickerEvent();
	}
});
获取应用程序端口号

要告诉 Express.js 我们的应用程序应该在哪个端口上运行,我们需要读取环境变量 VCAP_APP_PORT。我们的 Cloud Foundry Node.js 模块上有一个专门的方法可以帮我们完成这项工作。所以我们的 listen() 调用看起来像这样:


app.listen(cf.getAppPort());

Spring MVC

我们可以将这部分计算保留在同一个技术栈中,在 Node.js 中处理摘要计算。但有时出于非常好的业务原因,你可能会选择为应用程序的一部分使用 Java/Spring 组件。我们在这里的目的是演示如何做到这一点,以便你可以为特定任务选择合适的工具。

Spring 配置

你还记得我们在处理 Node.js 部分时,在 Cloud Foundry 中运行时必须从环境中获取配置参数。我们的 Spring 应用程序也有同样的需求。但是由于已经有一个强大的 Cloud Foundry Java 运行时库,我们将使用它来提取连接到我们已配置的 MongoDB 实例所需的信息。

我们要做得第一件事是声明几个额外的命名空间。一个用于 Cloud Foundry 运行时,另一个用于 MongoDB 支持。


<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
			 xmlns:cloud="http://schema.cloudfoundry.org/spring"
			 xmlns:mongo="http://www.springframework.org/schema/data/mongo"
			 xmlns:p="http://www.springframework.org/schema/p"
			 xmlns:util="http://www.springframework.org/schema/util"
			 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
			 xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-3.1.xsd
			 http://schema.cloudfoundry.org/spring http://schema.cloudfoundry.org/spring/cloudfoundry-spring-0.6.xsd
			 http://www.springframework.org/schema/data/mongo http://www.springframework.org/schema/data/mongo/spring-mongo-1.0.xsd
			 http://www.springframework.org/schema/util http://www.springframework.org/schema/util/spring-util-2.0.xsd">

特别要注意的是,我们将使用 Spring 3.1,它当时仍处于预发布状态。你不需要使用 Spring 3.1 才能使用 Cloud Foundry。但是 Spring 3.1 的http://blog.springsource.com/2011/02/14/spring-3-1-m1-introducing-profile/" "关于 profiles 的博客文章">profiles 特性将使我们的配置更容易。

为了配置我们的 MongoDB 连接,我们在本地运行时将使用 <mongo:mongo/> 命名空间配置助手,而在云中运行时将使用它的“表亲”<cloud:mongo/> 命名空间配置助手。在我们的“default”配置文件中,我们将设置一些属性来模拟在云中运行时将可用的属性——我们只需将它们指向我们的本地 MongoDB 服务器。


<!-- Use this when running locally -->
<beans profile="default">
	<util:properties id="serviceProperties">
		<prop key="ticker-analysis.db">tickeranalysis</prop>
		<prop key="ticker-analysis.username">admin</prop>
		<prop key="ticker-analysis.password">passwd</prop>
	</util:properties>
	<mongo:mongo id="mongo"/>
	<bean id="redisConnectionFactory"
				class="org.springframework.data.keyvalue.redis.connection.jedis.JedisConnectionFactory"/>
</beans>

<!-- Use this when running in the cloud -->
<beans profile="cloud">
	<cloud:service-properties id="serviceProperties"/>
	<cloud:mongo id="mongo"/>
	<cloud:redis-connection-factory id="redisConnectionFactory"/>
</beans>

<!-- MongoDB -->
<mongo:mapping-converter id="mappingConverter"/>
<bean id="mongoTemplate" class="org.springframework.data.document.mongodb.MongoTemplate"
			p:username="#{serviceProperties['ticker-analysis.username']}"
			p:password="#{serviceProperties['ticker-analysis.password']}">
	<constructor-arg ref="mongo"/>
	<constructor-arg name="databaseName" value="#{serviceProperties['ticker-analysis.db']}"/>
	<constructor-arg name="defaultCollectionName" value="tickerdata"/>
	<constructor-arg ref="mappingConverter"/>
</bean>

你会注意到,我们已配置服务的属性遵循 SERVICE_NAME.PROPERTY_NAME 的命名约定。在这个例子中,我有一个名为 "ticker-analysis" 的 MongoDB 服务。

> vmc services

============== System Services ==============
... [omitted for brevity]

=========== Provisioned Services ============

+-----------------+---------+
| Name            | Service |
+-----------------+---------+
| ticker-stream   | redis   |
| ticker-analysis | mongodb |
+-----------------+---------+

你现在可能已经猜到了,我的 Redis 连接遵循类似的模式。

选择要使用的配置文件

敏锐的读者会立刻想知道:“但它怎么知道要使用哪个配置文件呢?” 在我们的例子中,我们将使用一个 ApplicationContextInitializer,它根据是否提供了适当的环境变量来设置我们的配置文件。

下面是我们在运行时设置配置文件所需的一切,以便我们在开发期间使用“default”配置文件,而在 Cloud Foundry 中运行时使用“cloud”配置文件:


public class CloudApplicationContextInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {

	@Override
	public void initialize(ConfigurableApplicationContext applicationContext) {
		CloudEnvironment env = new CloudEnvironment();
		if (env.getInstanceInfo() != null) {
			// We're running in the cloud, set the profile accordingly
			applicationContext.getEnvironment().setActiveProfiles("cloud");
		}
		else {
			applicationContext.getEnvironment().setActiveProfiles("default");
		}
	}

}

要激活这个 ApplicationContextInitializer,我们将其添加到我们的 web.xml 中:


<?xml version="1.0" encoding="UTF-8"?>
<web-app version="2.5" xmlns="http://java.sun.com/xml/ns/javaee"
	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd">

	<context-param>
		<param-name>contextInitializerClasses</param-name>
		<param-value>org.cloudfoundry.services.CloudApplicationContextInitializer</param-value>
	</context-param>
	
</web-app>
Spring 层

我们的 Spring 层相当简单。我们有一个辅助类,它利用了Spring Data Redis 支持中的 MessageListenerAdapter。每当 Redis 接收到该事件的消息时,我们的 bean 就会被调用。在该处理器内部,我们将使用Spring Data MongoDB 支持将 POJO 映射到该文档,以便我们可以更新最小值、最大值和平均值。


public void handleMessage(String json) throws IOException {

  // Use the Jackson ObjectMapper to turn a JSON document into a POJO
	TickerEvent event = mapper.readValue(new StringReader(json), TickerEvent.class);

  // Load the existing document or start a new one
	Summary summ = mongoTemplate.findOne(query(where("_id").is(event.getSymbol())), Summary.class);
	if (null == summ) {
		summ = new Summary(event.getSymbol(), System.currentTimeMillis());
	}
	// Recalculate min, max, and average
	summ.addTickerEvent(event);

  // Save the modified document back
	mongoTemplate.save(summ);
	
}
如果需要,提供 REST 端点

我们不需要将 Spring 层中的任何内容暴露给 Web。它离线工作,不需要用户输入,也不直接向 Web 客户端提供汇总数据。

尽管如此,我们可能希望添加一个简单的 Controller,以便了解我们的 Java 辅助类内部发生了什么。我们在示例应用程序中创建了这样一个类。


@Controller
@RequestMapping("/summaries")
public class SummariesController {

	@Autowired
	private SummaryService summaryService;

	@RequestMapping(value = "/", method = RequestMethod.GET)
	public @ResponseBody List<Summary> summaries() {
	  // Return all summaries
		return summaryService.getSummaries();
	}

	@RequestMapping(value = "/{symbol}", method = RequestMethod.GET)
	public @ResponseBody Summary summary(@PathVariable String symbol) {
	  // Return a specific summary document
		return summaryService.getSummary(symbol);
	}
}

这不是你在生产应用程序中应该做的事情。但在 Cloud Foundry 上进行开发,并对有时感觉像黑盒子的内容有所了解时,添加一些公开 Spring 层内部细节的 controller 方法可能是有意义的。

明白了吗?

我不知道你怎么样,但我对许多示例和教程的简单性感到有点厌倦。诚然,这个示例应用程序一点也不简单!这可能有点像从消防水带饮水,但目标是为你提供足够多的实质内容,让你在研究 Cloud Foundry 和适应云环境时有事可做。

示例应用程序已在 Cloud Foundry 上线

所有源代码都在 GitHub 上的 Cloud Foundry 示例仓库中

要获取关于快速掌握 Cloud Foundry 的帮助,你可以访问论坛

全面的文档仍在变化中,因为坦率地说,Cloud Foundry 平台也是如此。目前它有点像一个移动的目标。不过,社区正在在 github 上维护一些 wiki 页面,这应该会有所帮助。

之前提到的Node.js 模块(用于更方便地访问 Cloud Foundry 环境变量)实际上是示例应用程序的一部分,直到 Cloud Foundry 发布完整的 Node.js 运行时。

愉快地编码!

订阅 Spring 时事通讯

通过 Spring 时事通讯保持联系

订阅

快人一步

VMware 提供培训和认证,助你快速提升。

了解更多

获取支持

Tanzu Spring 通过一个简单的订阅提供 OpenJDK™、Spring 和 Apache Tomcat® 的支持和二进制文件。

了解更多

即将举办的活动

查看 Spring 社区所有即将举办的活动。

查看全部