Developing node-mdb SimpleDB emulation using Node.js and GT.M Rob Tweed M/Gateway Developments Ltd http://www.mgateway.com...
Could you translate that title? <ul><li>SimpleDB: </li></ul><ul><ul><li>Amazon’s NoSQL cloud database </li></ul></ul><ul><...
SimpleDB <ul><li>Amazon’s cloud database </li></ul><ul><ul><li>Pay as you go </li></ul></ul><ul><li>Secure HTTP interface ...
Why emulate SimpleDB? <ul><li>Because I could! </li></ul><ul><li>Kind of cool project </li></ul>
Why emulate SimpleDB? <ul><li>To provide a free, locally-available database that behaved identically to SimpleDB </li></ul...
Why emulate SimpleDB? <ul><li>To perform local tests prior to committing to production on SimpleDB </li></ul><ul><li>To pr...
Why the GT.M database? <ul><li>I’m familiar with it </li></ul><ul><li>Free Open Source NoSQL database </li></ul><ul><li>Sc...
Why write it using Node.js? <ul><li>M/DB originally written in late 2008 </li></ul><ul><ul><li>Implemented using GT.M’s na...
Why Node.js? <ul><li>Conclusion: </li></ul><ul><ul><li>Re-implementing M/DB using Node.js should provide better performanc...
How does SimpleDB work? HTTP Server Authenticate Request (HMacSHA) Security Key Id Secret Key Execute API Action Generate ...
Node.js can emulate all this HTTP Server Authenticate Request (HMacSHA) Security Key Id Secret Key Execute API Action Gene...
GT.M can emulate this HTTP Server Authenticate Request Security Key Id Secret Key Execute API Action Generate HTTP Respons...
Node.js characteristics <ul><li>Single threaded process </li></ul><ul><li>Event loop </li></ul><ul><li>Non-blocking I/O </...
Result: deeply nested call-backs HTTP Server Authenticate Request Security Key Id Secret Key Execute API Action Generate H...
Flattening the call-back nesting processSDBRequest() http server executeAPI() sendResponse() http.createServer(function(re...
Node.js HTTP Server http.createServer(function(request, response) { request.content = ''; request.on(&quot;data&quot;, fun...
processSDBRequest() var processSDBRequest = function(SDB) { var accessKeyId = SDB.nvps.AWSAccessKeyId; if (!accessKeyId) {...
validateSDBRequest() var validateSDBRequest = function(SDB, secretKey) { var type = ‘HmacSHA256’; var stringToSign = creat...
stringToSign() POST {lf} 192.168.1.134:8081 {lf} / {lf} AWSAccessKeyId= rob &Action=ListDomains& MaxNumberOfDomains=100&Si...
digest() var crypto = require(&quot;crypto&quot;); var digest = function(string, secretKey, type) { var hmac = crypto.crea...
Ready to execute an API! HTTP Server Authenticate Request Security Key Id Secret Key Execute API Action Generate HTTP Resp...
SimpleDB APIs (Actions) <ul><li>CreateDomain </li></ul><ul><li>ListDomains </li></ul><ul><li>DeleteDomain </li></ul><ul><l...
Accessing the GT.M Database <ul><li>Accessed via  node-mwire </li></ul><ul><ul><li>TCP-based wire protocol </li></ul></ul>...
GT.M Globals <ul><li>Globals = unit of persistent storage </li></ul><ul><ul><li>Schema-free </li></ul></ul><ul><ul><li>Hie...
GT.M Globals <ul><li>A Global has: </li></ul><ul><ul><li>A name </li></ul></ul><ul><ul><li>0, 1 or more subscripts </li></...
SDB Domain in Globals CreateDomain AWSAccessKeyId = ‘rob’ DomainName = ‘books’
Multiple Domains in Globals MDB ‘rob’ ‘domains’ ‘name’ ‘domainIndex’ ‘created’ 1304956337618 ‘books’ ‘modified’ 1304956337...
Creating a new domain (1) MDB ‘rob’ ‘domains’ ‘name’ ‘domainIndex’ ‘created’ 1304956337618 ‘books’ ‘modified’ 130495633761...
Creating a new domain (2) MDB ‘rob’ ‘domains’ ‘name’ ‘domainIndex’ ‘created’ 1304956337618 ‘books’ ‘modified’ 130495633761...
Key Node.js async patterns for db I/O <ul><li>Dependent pattern: </li></ul><ul><ul><li>Can’t set the global nodes until th...
Dependent pattern MDB ‘rob’ ‘domains’ ‘name’ ‘created’ 1304956337618 ‘books’ ‘modified’ 1304956337618 1 2 MDB.increment([a...
Dependent pattern MDB ‘rob’ ‘domains’ ‘name’ ‘created’ 1304956337618 ‘books’ ‘modified’ 1304956337618 1 2 MDB.increment([a...
Parallel Pattern (semaphore) var count = 0; MDB.setGlobal([accessKeyId, 'domains', id, 'name'], domainName, function (erro...
New domain nodes created MDB ‘rob’ ‘domains’ ‘name’ ‘domainIndex’ ‘created’ 1304956337618 ‘books’ ‘modified’ 1304956337618...
Send CreateDomain Response HTTP Server Authenticate Request Security Key Id Secret Key Execute API Action Generate HTTP Re...
CreateDomain Response <?xml version=&quot;1.0&quot;?> <CreateDomainResponse xmlns=&quot;http://sdb.amazonaws.com/doc/2009-...
Node.js HTTP Server Response <ul><li>http.createServer(function(request, response) { </li></ul><ul><li>//…numerous call-ba...
Demo using Bolso <ul><li>List Domains </li></ul><ul><li>Create Domain </li></ul><ul><li>Add an item (row) and some attribu...
Node.js Gotchas <ul><li>Async programming is not immediately intuitive! </li></ul><ul><li>Loops </li></ul><ul><ul><li>Call...
Example <ul><li>BatchPutAttributes </li></ul><ul><ul><li>Intuitively a for .. in loop around PutAttributes </li></ul></ul>...
Conclusions <ul><li>node-mdb  is now nearly complete </li></ul><ul><li>Only  BatchDeleteAttributes  not implemented </li><...
Upcoming SlideShare
Loading in …5
×

Developing node-mdb: a Node.js - based clone of SimpleDB

2,130 views

Published on

Talk given at the London Ajax Users Group, June 14 2011

Published in: Technology
  • Be the first to comment

  • Be the first to like this

Developing node-mdb: a Node.js - based clone of SimpleDB

  1. 1. Developing node-mdb SimpleDB emulation using Node.js and GT.M Rob Tweed M/Gateway Developments Ltd http://www.mgateway.com Twitter: @rtweed
  2. 2. Could you translate that title? <ul><li>SimpleDB: </li></ul><ul><ul><li>Amazon’s NoSQL cloud database </li></ul></ul><ul><li>Node.js: </li></ul><ul><ul><li>evented server-side Javascript (using V8) </li></ul></ul><ul><li>GT.M: </li></ul><ul><ul><li>Open source global-storage based NoSQL database </li></ul></ul><ul><li>node-mdb </li></ul><ul><ul><li>Open source emulation of SimpleDB </li></ul></ul>
  3. 3. SimpleDB <ul><li>Amazon’s cloud database </li></ul><ul><ul><li>Pay as you go </li></ul></ul><ul><li>Secure HTTP interface </li></ul><ul><li>Schema-free NoSQL database </li></ul><ul><li>Spreadsheet-like database model </li></ul><ul><ul><li>Domains (= tables) </li></ul></ul><ul><ul><ul><li>Items (= rows) </li></ul></ul></ul><ul><ul><ul><ul><li>Attributes (=cells) </li></ul></ul></ul></ul><ul><ul><ul><ul><ul><li>Values (1+ per attribute allowed) </li></ul></ul></ul></ul></ul><ul><li>SQL-like query API </li></ul>
  4. 4. Why emulate SimpleDB? <ul><li>Because I could! </li></ul><ul><li>Kind of cool project </li></ul>
  5. 5. Why emulate SimpleDB? <ul><li>To provide a free, locally-available database that behaved identically to SimpleDB </li></ul><ul><ul><li>Lots of off-the-shelf available clients </li></ul></ul><ul><ul><ul><li>Standalone </li></ul></ul></ul><ul><ul><ul><ul><li>Bolso </li></ul></ul></ul></ul><ul><ul><ul><ul><li>Mindscape’s SimpleDB Management Tools </li></ul></ul></ul></ul><ul><ul><ul><li>Language-specific clients </li></ul></ul></ul><ul><ul><ul><ul><li>boto (Python) </li></ul></ul></ul></ul><ul><ul><ul><ul><li>Official AWS clients for Java, .Net </li></ul></ul></ul></ul><ul><ul><ul><ul><li>Node.js </li></ul></ul></ul></ul><ul><ul><ul><ul><li>etc… </li></ul></ul></ul></ul>
  6. 6. Why emulate SimpleDB? <ul><li>To perform local tests prior to committing to production on SimpleDB </li></ul><ul><li>To provide a live, local backup database </li></ul><ul><li>A SimpleDB database for private clouds </li></ul><ul><li>To provide an immediately-consistent SimpleDB database </li></ul><ul><ul><li>SimpleDB is “eventually consistent” </li></ul></ul>
  7. 7. Why the GT.M database? <ul><li>I’m familiar with it </li></ul><ul><li>Free Open Source NoSQL database </li></ul><ul><li>Schema-free </li></ul><ul><li>“ Globals”: </li></ul><ul><ul><li>Sparse persistent multi-dimensional arrays </li></ul></ul><ul><ul><ul><li>Hierarchical database </li></ul></ul></ul><ul><ul><ul><li>Completely dynamic storage </li></ul></ul></ul><ul><ul><ul><ul><li>No pre-declaration or specification needed </li></ul></ul></ul></ul><ul><li>Result: trivial to model SimpleDB in globals </li></ul><ul><li>node-mdb : Good way to demonstrate the capabilities of the otherwise little-known GT.M </li></ul><ul><li>More info – Google: </li></ul><ul><ul><li>“ GT.M database” </li></ul></ul><ul><ul><li>“ universalnosql” </li></ul></ul>
  8. 8. Why write it using Node.js? <ul><li>M/DB originally written in late 2008 </li></ul><ul><ul><li>Implemented using GT.M’s native scripting language (M) </li></ul></ul><ul><ul><li>Apache + m_apache gateway to GT.M for HTTP interface </li></ul></ul><ul><li>I’ve been working with Node.js for about a year now </li></ul><ul><ul><li>Rewriting M/DB in Javascript would make it more widely interesting and comprehensible </li></ul></ul><ul><li>Some performance issues reported with M/DB when being pushed hard </li></ul>
  9. 9. Why Node.js? <ul><li>Conclusion: </li></ul><ul><ul><li>Re-implementing M/DB using Node.js should provide better performance and scalability </li></ul></ul><ul><ul><li>Fewer moving parts: </li></ul></ul><ul><ul><ul><li>Apache + m_apache + GT.M / multi-threaded </li></ul></ul></ul><ul><ul><ul><li>Node.js + GT.M as child processes / single-thread </li></ul></ul></ul><ul><ul><li>Cool Node.js project to attempt </li></ul></ul><ul><ul><li>Great example of non-trivial use of Node.js + database </li></ul></ul>
  10. 10. How does SimpleDB work? HTTP Server Authenticate Request (HMacSHA) Security Key Id Secret Key Execute API Action Generate HTTP Response SimpleDB Database Copy 1 SimpleDB Database Copy 2 SimpleDB Database Copy n SimpleDB Database Copy 2 SimpleDB Database Copy 2 Incoming SDB HTTP Request Outgoing SDB HTTP Response Error Success and/or data/results
  11. 11. Node.js can emulate all this HTTP Server Authenticate Request (HMacSHA) Security Key Id Secret Key Execute API Action Generate HTTP Response SimpleDB Database Copy 1 SimpleDB Database Copy 2 SimpleDB Database Copy n SimpleDB Database Copy 2 SimpleDB Database Copy 2 Incoming SDB HTTP Request Outgoing SDB HTTP Response Error Success and/or data/results
  12. 12. GT.M can emulate this HTTP Server Authenticate Request Security Key Id Secret Key Execute API Action Generate HTTP Response SimpleDB Database Copy 1 Incoming SDB HTTP Request Outgoing SDB HTTP Response Error Success and/or data/results
  13. 13. Node.js characteristics <ul><li>Single threaded process </li></ul><ul><li>Event loop </li></ul><ul><li>Non-blocking I/O </li></ul><ul><ul><li>Asynchronous calls to functions that handle I/O </li></ul></ul><ul><ul><li>Event-driven call-back functions when function completes </li></ul></ul><ul><ul><ul><li>Data fetched </li></ul></ul></ul><ul><ul><ul><li>Data saved </li></ul></ul></ul>
  14. 14. Result: deeply nested call-backs HTTP Server Authenticate Request Security Key Id Secret Key Execute API Action Generate HTTP Response Error Success and/or data/results
  15. 15. Flattening the call-back nesting processSDBRequest() http server executeAPI() sendResponse() http.createServer(function(req,res) {..} var processSDBRequest = function() {…}; var executeAPI = function() {…};
  16. 16. Node.js HTTP Server http.createServer(function(request, response) { request.content = ''; request.on(&quot;data&quot;, function(chunk) { request.content += chunk; }); request.on(&quot;end&quot;, function(){ var SDB = {startTime: new Date().getTime(), request: request, response: response }; var urlObj = url.parse(request.url, true); if (request.method === 'POST') { SDB.nvps = parseContent(request.content); } else { SDB.nvps = urlObj.query; } var uri = urlObj.pathname; if ((uri.indexOf(sdbURLPattern) !== -1)||(uri.indexOf(mdbURLPattern) !== -1)) { processSDBRequest(SDB); } else { var uriString = 'http://' + request.headers.host + request.url; var error = {code:'InvalidURI', message: 'The URI ' + uriString + ' is not valid',status:400}; returnError(SDB ,error); } }); }).listen(httpPort);
  17. 17. processSDBRequest() var processSDBRequest = function(SDB) { var accessKeyId = SDB.nvps.AWSAccessKeyId; if (!accessKeyId) { var error = {code:'AuthMissingFailure', message: 'AWS was not able to authenticate the request: access credentials are missing',status:403}; returnError(SDB, error); } else { MDB.getGlobal('MDBUAF', ['keys', accessKeyId], function (error, results) { if (!error) { if (results.value !== '') { accessKey[accessKeyId] = results.value; validateSDBRequest(SDB, results.value); } else { var error = {code:'AuthMissingFailure', message: 'AWS was not able to authenticate the request: access credentials are missing',status:403}; returnError(SDB, error); } } }); } };
  18. 18. validateSDBRequest() var validateSDBRequest = function(SDB, secretKey) { var type = ‘HmacSHA256’; var stringToSign = createStringToSign(SDB, true); var hash = digest(stringToSign, secretKey, type); if (hash === SDB.nvps.Signature) { processSDBAction(SDB); } else { errorResponse('SignatureDoesNotMatch', SDB) } };
  19. 19. stringToSign() POST {lf} 192.168.1.134:8081 {lf} / {lf} AWSAccessKeyId= rob &Action=ListDomains& MaxNumberOfDomains=100&SignatureMethod=HmacSHA1& SignatureVersion=2& Timestamp=2011-06-06T22%3A39%3A30%2 B00%3A00& Version=2009-04-15 ie: reconstruct the same string that the SDB client used to sign the request then use rob ’s secret key to sign it:
  20. 20. digest() var crypto = require(&quot;crypto&quot;); var digest = function(string, secretKey, type) { var hmac = crypto.createHmac(type, secretKey); hmac.update(string); return hmac.digest('base64'); };
  21. 21. Ready to execute an API! HTTP Server Authenticate Request Security Key Id Secret Key Execute API Action Generate HTTP Response SimpleDB Database Copy 1 SimpleDB Database Copy 2 SimpleDB Database Copy n SimpleDB Database Copy 2 SimpleDB Database Copy 2 Incoming SDB HTTP Request Outgoing SDB HTTP Response Error Success and/or data/results
  22. 22. SimpleDB APIs (Actions) <ul><li>CreateDomain </li></ul><ul><li>ListDomains </li></ul><ul><li>DeleteDomain </li></ul><ul><li>PutAttributes (BatchPutAttributes) </li></ul><ul><li>GetAttributes </li></ul><ul><li>DeleteAttributes (BatchDeleteAttributes) </li></ul><ul><li>Select </li></ul><ul><li>DomainMetaData </li></ul>
  23. 23. Accessing the GT.M Database <ul><li>Accessed via node-mwire </li></ul><ul><ul><li>TCP-based wire protocol </li></ul></ul><ul><ul><li>Extension of Redis protocol </li></ul></ul><ul><ul><li>Adapted redis-node module </li></ul></ul><ul><li>APIs allow you to set/get/delete/edit Globals </li></ul>
  24. 24. GT.M Globals <ul><li>Globals = unit of persistent storage </li></ul><ul><ul><li>Schema-free </li></ul></ul><ul><ul><li>Hierarchically structured </li></ul></ul><ul><ul><li>Sparse </li></ul></ul><ul><ul><li>Dynamic </li></ul></ul><ul><ul><li>“ persistent associative array” </li></ul></ul>
  25. 25. GT.M Globals <ul><li>A Global has: </li></ul><ul><ul><li>A name </li></ul></ul><ul><ul><li>0, 1 or more subscripts </li></ul></ul><ul><ul><li>String value </li></ul></ul><ul><ul><li>globalName[subscript1,subscript2,..subscript n ]=value </li></ul></ul>
  26. 26. SDB Domain in Globals CreateDomain AWSAccessKeyId = ‘rob’ DomainName = ‘books’
  27. 27. Multiple Domains in Globals MDB ‘rob’ ‘domains’ ‘name’ ‘domainIndex’ ‘created’ 1304956337618 ‘books’ ‘modified’ 1304956337618 ‘books’ 1 1 ‘’ ‘name’ ‘created’ 1304956337423 ‘accounts’ ‘modified’ 1304956337423 2 ‘accounts’ 2 ‘’
  28. 28. Creating a new domain (1) MDB ‘rob’ ‘domains’ ‘name’ ‘domainIndex’ ‘created’ 1304956337618 ‘books’ ‘modified’ 1304956337618 ‘books’ 1 1 ‘’ 2 increment()
  29. 29. Creating a new domain (2) MDB ‘rob’ ‘domains’ ‘name’ ‘domainIndex’ ‘created’ 1304956337618 ‘books’ ‘modified’ 1304956337618 ‘books’ 1 1 ‘’ ‘name’ ‘created’ 1304956337423 ‘accounts’ ‘modified’ 1304956337423 2 ‘accounts’ 2 ‘’ setGlobal()
  30. 30. Key Node.js async patterns for db I/O <ul><li>Dependent pattern: </li></ul><ul><ul><li>Can’t set the global nodes until the value of the increment() is returned </li></ul></ul><ul><li>Parallel pattern: </li></ul><ul><ul><li>Global nodes can be created in parallel </li></ul></ul><ul><ul><li>No interdependence </li></ul></ul><ul><ul><li>BUT: </li></ul></ul><ul><ul><ul><li>Need to know when they’re all completed </li></ul></ul></ul>
  31. 31. Dependent pattern MDB ‘rob’ ‘domains’ ‘name’ ‘created’ 1304956337618 ‘books’ ‘modified’ 1304956337618 1 2 MDB.increment([accessKeyId, 'domains'], 1, function (error, results) { var id = results.value; //….now create the other global nodes inside callback }); IncrBy
  32. 32. Dependent pattern MDB ‘rob’ ‘domains’ ‘name’ ‘created’ 1304956337618 ‘books’ ‘modified’ 1304956337618 1 2 MDB.increment([accessKeyId, 'domains'], 1, function (error, results) { var id = results.value; //….now create the other global nodes inside callback });
  33. 33. Parallel Pattern (semaphore) var count = 0; MDB.setGlobal([accessKeyId, 'domains', id, 'name'], domainName, function (error, results) { count++; if (count === 4) sendCreateDomainResponse(count, SDB); }); MDB.setGlobal([accessKeyId, 'domains', id, 'created'], now, function (error, results) { count++; if (count === 4) sendCreateDomainResponse(count, SDB); }); MDB.setGlobal([accessKeyId, 'domains', id, 'modified'], now, function (error, results) { count++; if (count === 4) sendCreateDomainResponse(count, SDB); }); MDB.setGlobal([accessKeyId, 'domainIndex', nameIndex, id], '', function (error, results) { count++; if (count === 4) sendCreateDomainResponse(count, SDB); });
  34. 34. New domain nodes created MDB ‘rob’ ‘domains’ ‘name’ ‘domainIndex’ ‘created’ 1304956337618 ‘books’ ‘modified’ 1304956337618 ‘books’ 1 1 ‘’ ‘name’ ‘created’ 1304956337423 ‘accounts’ ‘modified’ 1304956337423 2 ‘accounts’ 2 ‘’
  35. 35. Send CreateDomain Response HTTP Server Authenticate Request Security Key Id Secret Key Execute API Action Generate HTTP Response SimpleDB Database Copy 1 SimpleDB Database Copy 2 SimpleDB Database Copy n SimpleDB Database Copy 2 SimpleDB Database Copy 2 Incoming SDB HTTP Request Outgoing SDB HTTP Response Error Success and/or data/results
  36. 36. CreateDomain Response <?xml version=&quot;1.0&quot;?> <CreateDomainResponse xmlns=&quot;http://sdb.amazonaws.com/doc/2009-04-15/&quot;> <ResponseMetadata> <RequestID>e4e9fa45-f9dc-4e5b-8f0a-777acce6505e</RequestID> <BoxUsage>0.0020000000</BoxUsage> </ResponseMetadata> </CreateDomainResponse> var okResponse = function(SDB) { var nvps = SDB.nvps; var xml = responseStart({action: nvps.Action, version: nvps.Version}); xml = xml + responseEnd(nvps.Action, SDB.startTime, false); responseHeader(200, SDB.response); SDB.response.write(xml); SDB.response.end(); };
  37. 37. Node.js HTTP Server Response <ul><li>http.createServer(function(request, response) { </li></ul><ul><li>//…numerous call-backs deep: </li></ul><ul><ul><ul><ul><ul><li>response.writeHead(status, { </li></ul></ul></ul></ul></ul><ul><ul><ul><ul><ul><li>&quot;Server&quot;: &quot;Amazon SimpleDB&quot;, </li></ul></ul></ul></ul></ul><ul><ul><ul><ul><ul><li>&quot;Content-Type&quot;: &quot;text/xml&quot;, </li></ul></ul></ul></ul></ul><ul><ul><ul><ul><ul><li>&quot;Date&quot;: dateNow.toUTCString()}); </li></ul></ul></ul></ul></ul><ul><ul><ul><ul><ul><li>response.write('<?xml version=&quot;1.0&quot;?>n'); </li></ul></ul></ul></ul></ul><ul><ul><ul><ul><ul><li>response.write(xml); </li></ul></ul></ul></ul></ul><ul><ul><ul><ul><ul><li>response.end(); </li></ul></ul></ul></ul></ul><ul><li>}); </li></ul><ul><li>Entire request/response SDB round-trip completed </li></ul>
  38. 38. Demo using Bolso <ul><li>List Domains </li></ul><ul><li>Create Domain </li></ul><ul><li>Add an item (row) and some attributes (columns + cells) </li></ul>
  39. 39. Node.js Gotchas <ul><li>Async programming is not immediately intuitive! </li></ul><ul><li>Loops </li></ul><ul><ul><li>Calling functions that use call-backs inside a for..in loop will go horribly wrong! </li></ul></ul><ul><li>Understanding closures </li></ul><ul><ul><li>How externally-defined variables can be used inside call-back functions </li></ul></ul>
  40. 40. Example <ul><li>BatchPutAttributes </li></ul><ul><ul><li>Intuitively a for .. in loop around PutAttributes </li></ul></ul><ul><ul><li>Had to be serialised </li></ul></ul><ul><ul><ul><li>Completion of one PutAttributes calls the next </li></ul></ul></ul><ul><ul><li>Copy state of SDB object and use for..in? </li></ul></ul><ul><ul><ul><li>var SDBx = SDB; </li></ul></ul></ul><ul><ul><ul><li>SDBx is a pointer to SDB, not a clone of it! </li></ul></ul></ul>
  41. 41. Conclusions <ul><li>node-mdb is now nearly complete </li></ul><ul><li>Only BatchDeleteAttributes not implemented </li></ul><ul><li>Other APIs emulate SimpleDB 100% </li></ul><ul><li>Free Open Source </li></ul><ul><ul><li>https://github.com/robtweed/node-mdb </li></ul></ul><ul><ul><li>Give it a try! </li></ul></ul><ul><ul><li>Use mdb.js for examples to build your own Node.js database applications </li></ul></ul><ul><li>Check out GT.M! </li></ul><ul><li>Follow me on Twitter at @rtweed </li></ul><ul><li>Slides: http://www.mgateway.com/node-mdb-pres.html </li></ul>

×