在单个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环境获取配置信息。连接到您已配置服务的所需主机名、端口、用户和密码被编码到存储在名为“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映射基础架构保存对象,稍后可以使用Mongoose在我们的Node.js应用程序中读取该对象。

要配置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仅处于测试阶段,因此它尚不支持完整的WebSockets(它已列入路线图)。为了设置它,我们将指定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应用程序的路由非常稀疏。我们需要使用其中的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。但是,http://blog.springsource.com/2011/02/14/spring-3-1-m1-introducing-profile/" "关于配置文件的博文">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>

您会注意到,我们已配置服务的属性遵循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客户端提供摘要数据。

话虽如此,我们可能希望添加一个简单的控制器,让我们了解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 社区中所有即将举行的活动。

查看全部