系列目录
- NodeJS与Django协同应用开发(0) node.js基础知识
- NodeJS与Django协同应用开发(1)原型搭建
- NodeJS与Django协同应用开发(2)业务框架
- NodeJS与Django协同应用开发(3)测试与优化
- NodeJS与Django协同应用开发(4)部署
前文我们介绍了node.js还有socket.io的基础知识,这篇文章我们来说一下如何将node.js与Django一起使用,并且搭建一个简单的原型出来。
原本我们的项目全部都基于Django框架,并且也能够满足基本需求了,但是后来新增了实时需求,在Django框架下比较难做,为了少挖点坑,多省点时间,我们选择使用node.js。
基本框架
在没有node.js之前,我们的结构是这样的:
增加的node.js系统应该是与原本的Django系统平行的,而我们使用node.js的初衷是将它作为实时需求的服务器,不承担或者只承担一小部分的业务逻辑,且完全不需要和数据库有交互。所以之后的结构就是这样的:
数据库依然只有Django负责连接,这和一般的系统并没有什么区别,所以文章里就不涉及具体读写数据库的实现了。
于是问题的关键就在于django和node.js该如何交互。
Django和node.js几乎是两种风格的网络框架,语言也不同,所以我们需要一个通信手段。而系统间通信不外乎就是靠网络请求(部署在本机的不同系统不在此列,也不值得讨论),或是另一个可以用作通信的系统。通常来说对于node.js和django之间交互的话,一般有3种手段可选:
- HTTP Request
- Redis publish/subscribe
- RPC
三种都是可行的方案,但是也有各自的应用场景。
原型实现(1) HTTP Request
首先是http request。先来看一下django代码:
[urls.py]
from django.conf.urls import url
urlpatterns = [
url(r'^get_data/$', 'backend.views.get_data'),
]
[backend.views.py]
from django.http import JsonResponse
from django.views.decorators.http import require_http_methods
@require_http_methods(["GET"])
def get_data(request):
data = {
'data1': 123,
'data2': 'abc',
}
return JsonResponse(data, safe=False)
这里我们定义了一个叫get_data
的api,方便起见我们使用JSON格式作为返回类型,返回一个整型一个字符串。
然后再来看一下node.js代码:
[django_request.js]
var http = require('http');
var default_protocol = 'http://'
var default_host = 'localhost';
var default_port = 8000;
exports.get = function get(path, on_data_callback, on_err_callback) {
var url = default_protocol + default_host + ':' + default_port + path;
var req = http.get(url, function onDjangoRequestGet(res) {
res.setEncoding('utf-8');
res.on('data', function onDjangoRequestGetData(data) {
on_data_callback(JSON.parse(data));
});
res.resume();
}).on('error', function onDjangoRequestGetError(e) {
if (on_err_callback)
on_err_callback(e);
else
throw "error get " + url + ", " + e;
});
}
[app.js]
var django_request = require('./django_request');
django_request.get('/get_data/', function(data){
console.log('get_data response: %j',data);
}, function(err) {
console.log('error get_data: '+e);
});
在django_request.js里面我们写了一个通用的get方法,可以用来向django发起http get请求。运行app.js以后我们就看到结果了。
alfred@workstation:~/Documents/node_django/nodeapp$ node app.js
get_data response: {"data1":123,"data2":"abc"}
非常简单,但是别急,还有post请求。
普通的post请求和get类似,非常简单,用过http库的同学都应该会写,但是这年头已经没有普通的post了,大家的安全意识越来越高,没有哪个网站会不防跨域请求了,所以我们的post还需要解决跨域的问题。
默认配置下django的中间件是包含CsrfViewMiddleware的,也就是会在用户访问网页时向cookie中添加csrf_token。所以我们就写一个简单的页面,顺便把socket.io也使用起来。
在django的views中添加名为post_data
的api,以及为页面准备的view函数。
[backend.views.py]
import json
def index(request):
return render_to_response('index.html', RequestContext(request, {}))
def get_post_args(request, *args):
try:
args_info = json.loads(request.body)
except Exception, e:
args_info = {}
return [request.POST.get(item, None) or args_info.get(item, None) for item in args]
@require_http_methods(["POST"])
def post_data(request):
data1, data2 = get_post_args(request, 'data1', 'data2')
response = {
'status': 'success',
'data1': data1,
'data2': data2,
}
return JsonResponse(response, safe=False)
[urls.py]
urlpatterns = [
url(r'^$', 'backend.views.index'),
url(r'^get_data/$', 'backend.views.get_data'),
url(r'^post_data/$', 'backend.views.post_data'),
]
socket.io监听9000端口。
[app.js]
var http = require('http');
var sio = require('socket.io');
var chatroom = require('./chatroom');
var server = http.createServer();
var io = sio.listen(server, {
log: true,
});
chatroom.init(io);
var port = 9000;
server.listen(9000, function startapp() {
console.log('Nodejs app listening on ' + port);
});
定义通用的post方法。
[django_request.js]
var cookie = require('cookie');
exports.post = function post(user_cookie, path, values, on_data_callback, on_err_callback) {
var cookies = cookie.parse(user_cookie);
var values = querystring.stringify(values);
var options = {
hostname: default_host,
port: default_port,
path: path,
method: 'POST',
headers: {
'Cookie': user_cookie,
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': values.length,
'X-CSRFToken': cookies['csrftoken'],
}
};
var post_req = http.request(options, function onDjangoRequestPost(res) {
res.setEncoding('utf-8');
res.on('data', function onDjangoRequestPostData(data) {
on_data_callback(data);
});
}).on('error', function onDjangoRequestPostError(e) {
console.log(e);
if (on_err_callback)
on_err_callback(e);
else
throw "error get " + url + ", " + e;
});
post_req.write(values);
post_req.end();
}
为get和post事件设定handler。
[chatroom.js]
var cookie_reader = require('cookie');
var django_request = require('./django_request');
function initSocketEvent(socket) {
socket.on('get', function() {
console.log('event: get');
django_request.get('/get_data/', function(res){
console.log('get_data response: %j',res);
}, function(err) {
//经指正这里应该是err而不是e,保留BUG以此为鉴
console.log('error get_data: '+e);
});
});
socket.on('post', function(data) {
console.log('event: post');
django_request.post(socket.handshake.headers.cookie, '/post_data/', {'data1':123, 'data2':'abc', function(res){
console.log('post_data response: %j', res);
}, function(err){
console.log('error post_data: '+e);
});
});
};
exports.init = function(io) {
io.on('connection', function onSocketConnection(socket) {
console.log('new connection');
initSocketEvent(socket);
});
};
简单的html页面。
[index.html]
...
<div>
<button id="btn" style="width:200px;height:150px;">hit me</button>
</div>
<div id="content"></div>
<script type="text/javascript" src="/static/backend/js/jquery-1.9.1.min.js"></script>
<script type="text/javascript" src="/static/backend/js/socket.io.min.js"></script>
<script type="text/javascript">
(function() {
socket = io.connect('http://localhost:9000/');
socket.on('connect', function() {
console.log('connected');
});
$('#btn').click(function() {
socket.emit('get');
socket.emit('post');
});
})();
</script>
实现post的重点在于cookie的设置。socket.io在客户端连接的时候默认就会带上浏览器的cookie,这帮我们省去了不少功夫,也省去了显示传递csrftoken的烦恼。但是在node.js中向django发起post请求时不能只设定X-CSRFToken,也不能只设定cookie。看一下django的源码(django.middleware.csrf)就能够了解到是同时获取cookie和HTTP_X_CSRFTOKEN的。所以我们必须把cookie传给post函数,这样才能成功发起请求。
顺便一提,这同时也解决了sessionid的问题,如果是登录用户,django是能够获取到user信息的。
以上是node.js端向django端发起请求,但是这仅仅只是由node.sj主动而已,还缺少django向node.js发起HTTP请求的部分。
所以我们在app.js中添加如下代码
[app.js]
function onGetData(request, response){
if (request.method == 'GET'){
response.writeHead(200, {"Content-Type": "application/json"});
jsonobj = {
'data1': 123,
'data2': 'abc'
}
response.end(JSON.stringify(jsonobj));
} else {
response.writeHead(403);
response.end();
}
}
function onPostData(request, response){
if (request.method == 'POST'){
var body = '';
request.on('data', function (data) {
body += data;
if (body.length > 1e6)
request.connection.destroy();
});
request.on('end', function () {
var post = qs.parse(body);
response.writeHead(200, {'Content-Type': 'application/json'});
jsonobj = {
'data1': 123,
'data2': 'abc',
'post_data': post,
}
response.end(JSON.stringify(jsonobj));
});
} else {
response.writeHead(403);
response.end();
}
}
然后我们写一小段python代码来测试一下
[http_test.py]
import urllib
import urllib2
httpClient = None
try:
headers = {"Content-type": "application/x-www-form-urlencoded",
"Accept": "text/plain"}
data = urllib.urlencode({'post_arg1': 'def', 'post_arg2': 456})
get_request = urllib2.Request('http://localhost:9000/node_get_data/', headers=headers)
get_response = urllib2.urlopen(get_request)
get_plainRes = get_response.read().decode('utf-8')
print(get_plainRes)
post_request = urllib2.Request('http://localhost:9000/node_post_data/', data, headers)
post_response = urllib2.urlopen(post_request)
post_plainRes = post_response.read().decode('utf-8')
print(post_plainRes)
except Exception, e:
print e
然后就能看到成功的输出:
[nodejs]
Nodejs app listening on 9000
url: /node_get_data/, method: GET
url: /node_post_data/, method: POST
[python]
{"data1":123,"data2":"abc"}
{"data1":123,"data2":"abc","post_data":{"post_arg1":"def","post_arg2":"456"}}
到此双向的HTTP Request就建立起来了。只不过node.js端并没有csrf认证。而在我们的django端,csrf认证和api都是已经部署了的线上模块,所以不需要在这方面花精力。
然而如果最终决定采用双向HTTP Reqeust的话,那node.js端的csrf认证必须要做好,因为HTTP API都是向外暴露的,这是这种方式最大的缺点。并不是所有的系统间调用都需要向公网露接口,一旦被他人知道了一些非公开的api路径,那很有可能引发安全问题。
并且HTTP是要走外网的,这还带来了一些额外的开销。
原型实现(2) Redis Publish/Subscribe
相比HTTP Request,这种方式的代码量要少的多。(关于Redis Pub/Sub,请移步相关文档)
要实现双向通信,无非是两边同时建立pub与sub channel。而subscribe需要持续监听,关于这一点,我们先看代码再说。
首先是node.js端,npm安装redis库,库里已经包含了所有我们需要的了。
[app.js]
var redis = require('redis');
// subscribe
var sub = redis.createClient();
sub.subscribe('test_channel');
sub.on('message', function onSubNewMessage(channel, data) {
console.log(channel, data);
});
// publish
var pub = redis.createClient();
pub.publish('test_channel', 'nodejs data published to test_channel');
node.js是事件驱动的异步非阻塞框架,pub/sub这种方式的实现和它本身的代码风格非常相近,所以8行代码就实现了sub与pub的功能。
再来看python代码
[redis_test.py]
import redis
r = redis.StrictRedis(host='localhost', port=6379)
# publish
r.publish('test_channel', 'python data published to test_channel');
# subscribe
sub = r.pubsub()
sub.subscribe('test_channel')
for item in sub.listen():
if item['type'] == 'message':
print(item['data'])
代码中的channel名是可以自定义的。实际应用中可以按照不同的需求管理不同的channel,这样就不会造成消息的混乱。
多看几眼代码,细心的同学会发现,python的sub代码只会执行一次,也就是说如果需要持续监听的话,至少要新开一个线程。也就是说对于django,我们还需要额外做线程间通信的工作。这种做法并不是说不可以,只是与django原本的风格不太吻合,并不是非常推荐。
(顺便一提,不要将开启线程的工作放在views函数中,因为views的执行是多线程的,线程数量会随着访问压力增大而增加,放在views中会导致重复开心线程,这个坑我爬过。)
原型实现(3) RPC
在我的另一篇文章(ZeroRPC应用)中提到过项目所使用的RPC系统。这个系统的建立是在node.js应用之前的,非常庆幸当时选用的是zerorpc,正好可以无缝接合node.js。。
类似于HTTP Request,如果要实现双向通信那就需要在两端同时建立server。
python端的代码可以看我的那篇文章里所写的内容,这边我们就来说一下node.js端的调用和建立server。
[app.js]
var zerorpc = require("zerorpc");
var client = new zerorpc.Client();
client.connect("tcp://127.0.0.1:4242");
client.invoke("test_connection", "arg1", "arg2", function(error, res, more) {
if (!error){
console.log(res, more);
} else {
console.log(error);
}
});
var rpcserver = new zerorpc.Server({
test_connection: function(arg1, arg2, reply) {
reply(null, True, arg1, arg2);
}
});
rpcserver.bind("tcp://0.0.0.0:5353");
和python一样,在node.js里写zerorpc也可以返回多个值,这就是invoke的回调函数里的more参数的作用。res表示返回的第一个值,而more包含了其他的返回值。
rpc方式的概念和HTTP Request的方式一样,不过比HTTP Request好在不需要暴露API,因为完全可以在内网下部署,并把外网端口禁封。但是他们又有一个共同的缺点,那就是对于node.js来说,我们需要一个额外的消息分发机制。为什么呢?因为我们接受消息的入口是统一的。
考虑这个情况:
在node.js里我们有2个子系统,子系统A和子系统B,他们分别为功能I和功能II服务,各自也都有需要和django交互的地方。如果此时功能I和功能II分别有一条消息到来,那我们就必须要区分消息的送达对象。这里就又是额外的工作量了。
这个情况在使用redis时就不会出现。redis下我们可以只subscribe自己关心的channel,也就是说只会收到与自身系统相关的消息。
总结
对于三种方式的优缺点,我们总结如下:
实现方式 | 优点 | 缺点 |
---|---|---|
HTTP Request | 方便和现有系统集成 | 暴露外网API,流量走外网,需要额外安全工作 |
Redis | 切合node.js风格,容易按channel名管理 | django端subscribe需要额外工作量 |
RPC | 流量走内网,不暴露API | node.js端分发消息需要额外工作量 |
工作中我们可以按照实际需求来组合使用,我的项目里原本是使用HTTP Request实现的原型,后来也是因为其暴露API的缺点以及node.js端需要csrf认证才放弃用django向node.js发起HTTP请求。
目前我们项目中django向node.js发消息使用的是redis,node.js向django请求数据或发送消息使用的是rpc。这么做没有什么额外的工作量,可以让我专注于业务逻辑。
业务逻辑涉及到node.js端的架构设计,关于这部分的内容我们就下篇文章再说。