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

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

传统上,应用程序的定义取决于它们使用的核心技术。如果你正在构建一个Spring MVC应用程序,我们称之为“Java应用”。由于我们的应用程序主要由Java组件构成,我们倾向于固步自封,不怎么与其他技术打交道,直到被迫交互为止。我们设置基于Java的应用服务器,并且倾向于首先考虑用Java语言来解决应用程序中的问题,而不管Java是否是最佳选择。通常,维护多个应用程序运行时环境太困难了,所以我们仅仅是由于惯性而将自己限制在一个技术栈内。

Cloud Foundry颠覆了这种模式,因为它不再需要我们为了“各取所需”而使用不方便的工具。我们不再被迫将应用程序限制在一个类型中(“Java应用”或“Node应用”)。如果我们只需要高吞吐量、非阻塞式处理并支持XHR长轮询,我们可以为应用程序的这部分使用Node.js。如果我们希望利用Spring系列项目提供的灵活性和丰富的库支持,我们可以通过在应用程序的这部分使用Java来轻松实现。如果我们既需要一个用于缓存或事件总线的快速键值存储,又需要一个功能强大的文档存储来持久化数据,我们可以在同一个应用程序中同时使用它们,而无需担心单独设置和管理这些服务的后勤工作(或者将它们推给我们已经焦头烂额的运维团队)。

此外,无论是哪种类型的应用程序,只需从我们喜欢的Shell中执行一个“push”命令即可部署,这也无损于我们的优势。

多语言编程 ^ N

那句老话说得好?“值得做的事情,就值得做得过分”?这个示例应用程序就是这种心态的典型代表!

这个应用程序包含几个组件:

  1. 一个定时触发的事件,用于生成随机的股票代码数据并将其发送到事件总线。
  2. 一个Node.js应用程序,用于驱动一个Web前端,该前端使用Socket.IO实现长轮询Ajax的便捷功能。
  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环境中获取配置信息。“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阶段,它还不支持完整的WebSocket(但已在计划中)。为了实现这一点,我们将指定Socket.IO使用长轮询,因为我们知道否则动态路由基础设施将无法正常工作。我们还必须每10秒重置一次连接,以防止超时问题导致连接被中断。随着Cloud Foundry平台的演进,这可能会变得无关紧要。但目前,在使用Ajax推送与Cloud Foundry集成时,请记住这些注意事项。


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

事件发射器

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

为了将这些事件发布到另一个监听应用程序,我们需要使用Redis的发布/订阅功能作为事件总线。在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应用程序的路由相当稀疏。我们需要渲染带有用于驱动UI的JavaScript魔法的主页,并提供一个用于从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应用程序也有同样的需求。由于已经有一个功能强大的Java版Cloud Foundry运行时库,我们将使用它来提取连接到已配置的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的配置文件功能将使我们的配置更加容易。

为了配置我们的MongoDB连接,我们在本地运行时将使用命名空间配置助手,而在云中运行时将使用它的类似配置助手。在我们的“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>

正如您可能已经注意到的,我们已配置服务的属性遵循convention 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层内部细节的控制器方法可能会有意义。

是否够清楚了?

我不知道您怎么想,但我对许多示例和教程过于简单的性质感到有点厌烦。诚然,这个示例应用程序并不简单!这可能有点像喝消防水龙带,但目标是为您提供足够的内容,让您在探索Cloud Foundry并掌握云[海上]航行技巧时有事可做。

示例应用程序已在Cloud Foundry上运行

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

要快速掌握Cloud Foundry,您可以访问论坛:

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

前面提到的Node.js模块(用于更轻松地访问Cloud Foundry环境变量)实际上是示例应用程序的一部分,直到Node.js版的Cloud Foundry完全版发布为止。

祝您编码愉快!

获取 Spring 新闻通讯

通过 Spring 新闻通讯保持联系

订阅

领先一步

VMware 提供培训和认证,助您加速进步。

了解更多

获得支持

Tanzu Spring 提供 OpenJDK™、Spring 和 Apache Tomcat® 的支持和二进制文件,只需一份简单的订阅。

了解更多

即将举行的活动

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

查看所有