系列目錄
- 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端的架構設計,關于這部分的內容我們就下篇文章再說。