構建基于Git為數據中心的CMS
或許你也用過Hexo / Jekyll / Octopress這樣的靜態博客,他們的原理都是類似的。我們有一個代碼庫用于生成靜態頁面,然后這些靜態頁面會被PUSH到Github Pages上。
從我們設計系統的角度來說,我們會在Github上有三個主要代碼庫:
- Content。用于存放編輯器生成的JSON文件,這樣我們就可以GET這些資源,并用Backbone / Angular / React 這些前端框架來搭建SPA。
- Code。開發者在這里存放他們的代碼,如主題、靜態文件生成器、資源文件等等。
- Builder。在這里它是運行于Travis CI上的一些腳本文件,用于Clone代碼,并執行Code中的腳本。
以及一些額外的服務,當且僅當你有一些額外的功能需求的時候。
- Extend Service。當我們需要搜索服務時,我們就需要這樣的一些服務。如我正考慮使用Python的whoosh來完成這個功能,這時候我計劃用Flask框架,但是只是計劃中——因為沒有合適的中間件。
- Editor。相比于前面的那些知識這一步適合更重要,也就是為什么生成的格式是JSON而不是Markdown的原理。對于非程序員來說,要熟練掌握Markdown不是一件容易的事。于是,一個考慮中的方案就是使用 Electron + Node.js來生成API,最后通過GitHub API V3來實現上傳。
- Mobile App。
So,這一個過程是如何進行的。
用戶場景
整個過程的Pipeline如下所示:
- 編輯使用他們的編輯器來編輯的內容并點擊發布,然后這個內容就可以通過GitHub API上傳到Content這個Repo里。
- 這時候需要有一個WebHooks監測到了Content代碼庫的變化,便運行Builder這個代碼庫的Travis CI。
- 這個Builder腳本首先,會設置一些基本的git配置。然后clone Content和Code的代碼,接著運行構建命令,生成新的內容。
- 然后Builder Commit內容,并PUSH內容。
在這種情形中,編輯能否完成工作就不依賴于網站——脫稿又少了 個借口。這時候網站出錯的概率太小了——你不需要一個緩存服務器、HTTP服務器,由于沒有動態生成的內容,你也不需要守護進程。這些內容都是靜態文件,你可以將他們放在任何可以提供靜態文件托管的地方——CloudFront、S3等等。或者你再相信自己的服務器,Nginx可是全球第二好(第一還沒出現)的靜態文件服務器。
開發人員只在需要的時候去修改網站的一些內容。So,你可能會擔心如果這時候修改的東西有問題了怎么辦。
- 使用這種模式就意味著你需要有測試來覆蓋這些構建工具、生成工具。
- 相比于自己的代碼,別人的CMS更可靠?
需要注意的是如果你上一次構建成功,你生成的文件都是正常的,那么你只需要回滾開發相關的代碼即可。舊的代碼仍然可以工作得很好。其次,由于生成的是靜態文件,查錯的成本就比較低。最后,重新放上之前的靜態文件。
Code: 生成靜態頁面
Assemble是一個使用Node.js,Grunt.js,Gulp,Yeoman 等來實現的靜態網頁生成系統。這樣的生成器有很多,Zurb Foundation, Zurb Ink, Less.js / lesscss.org, Topcoat, Web Experience Toolkit等組織都使用這個工具來生成。這個工具似乎上個Release在一年多以前,現在正在開始0.6。雖然,這并不重要,但是還是順便一說。
我們所要做的就是在我們的Gruntfile.js中寫相應的生成代碼。
assemble: { options: { flatten: true, partials: ['templates/includes/*.hbs'], layoutdir: 'templates/layouts', data: 'content/blogs.json', layout: 'default.hbs' }, site: { files: {'dest/': ['templates/*.hbs']} }, blogs: { options: { flatten: true, layoutdir: 'templates/layouts', data: 'content/*.json', partials: ['templates/includes/*.hbs'], pages: pages }, files: [ { dest: './dest/blog/', src: '!*' } ] } }
配置中的site用于生成頁面相關的內容,blogs則可以根據json文件的文件名生成對就的html文件存儲到blog目錄中。
生成后的目錄結果如下圖所示:
. ├── about.html ├── blog │ ├── blog-posts.html │ └── blogs.html ├── blog.html ├── css │ ├── images │ │ └── banner.jpg │ └── style.css ├── index.html └── js ├── jquery.min.js └── script.js 7 directories, 30 files
這里的靜態文件內容就是最后我們要發布的內容。
還需要做的一件事情就是:
grunt.registerTask('dev', ['default', 'connect:server', 'watch:site']);
用于開發階段這樣的代碼就夠了,這個和你使用WebPack + React 似乎相差不了多少。
Builder: 構建生成工具
Github與Travis之間,可以做一個自動部署的工具。相信已經有很多人在Github上玩過這樣的東西——先在Github上生成Token,然后用travis加密:
travis encrypt-file ssh_key --add
加密后的Key就會保存到.travis.yml文件里,然后就可以在Travis CI上push你的代碼到Github上了。
接著,你需要創建個deploy腳本,并且在after_success執行它:
after_success: - test $TRAVIS_PULL_REQUEST == "false" && test $TRAVIS_BRANCH == "master" && bash deploy.sh
在這個腳本里,你所需要做的就是clone content和code中的代碼,并執行code中的生成腳本,生成新的內容后,提交代碼。
#!/bin/bash set -o errexit -o nounset rev=$(git rev-parse --short HEAD) cd stage/ git init git config user.name "Robot" git config user.email "robot@phodal.com" git remote add upstream "https://$GH_TOKEN@github.com/phodal-archive/echeveria-deploy.git" git fetch upstream git reset upstream/gh-pages git clone https://github.com/phodal-archive/echeveria-deploy code git clone https://github.com/phodal-archive/echeveria-content content pwd cp -a content/contents code/content cd code npm install npm install grunt-cli -g grunt mv dest/* ../ cd ../ rm -rf code rm -rf content touch . if [ ! -f CNAME ]; then echo "deploy.baimizhou.net" > CNAME fi git add -A . git commit -m "rebuild pages at ${rev}" git push -q upstream HEAD:gh-pages
這就是這個builder做的事情——其中最主要的一個任務是grunt,它所做的就是:
grunt.registerTask('default', ['clean', 'assemble', 'copy']);
Content:JSON格式
在使用Github和Travis CI完成Content的時候,發現沒有一個好的Webhook。雖然我們的Content只能存儲一些數據,但是放一個trigger腳本也是可以原諒的。
var Travis = require('travis-ci'); var repo = "phodal-archive/echeveria-deploy"; var travis = new Travis({ version: '2.0.0' }); travis.authenticate({ github_token: process.env.GH_TOKEN }, function (err, res) { if (err) { return console.error(err); } travis.repos(repo.split('/')[0], repo.split('/')[1]).builds.get(function (err, res) { if (err) { return console.error(err); } travis.requests.post({ build_id: res.builds[0].id }, function (err, res) { if (err) { return console.error(err); } console.log(res.flash[0].notice); }); }); });
這里主要依賴于Travis CI來完成這部分功能,這時候我們還需要數據。
從Schema到數據庫
我們在我們數據庫中定義好了Schema——對一個數據庫的結構描述。在《 編輯-發布-開發分離
》一文中我們說到了echeveria-content的一個數據文件如下所示:
{ "title": "白米粥", "author": "白米粥", "url": "baimizhou", "date": "2015-10-21", "description": "# Blog post \n > This is an example blog post \n Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. ", "blogpost": "# Blog post \n > This is an example blog post \n Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. \n Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." }
比起之前的直接生成靜態頁面這里的數據就是更有意思地一步了,我們從數據庫讀取數據就是為了生成一個JSON文件。何不直接以JSON的形式存儲文件呢?
我們都定義了這每篇文章的基本元素:
- title
- author
- date
- description
- content
- url
即使我們使用NoSQL我們也很難逃離這種模式。我們定義這些數據,為了在使用的時候更方便。存儲這些數據只是這個過程中的一部分,下部分就是取出這些數據并對他們進行過濾,取出我們需要的數據。
Web的骨架就是這么簡單,當然APP也是如此。難的地方在于存儲怎樣的數據,返回怎樣的數據。不同的網站存儲著不同的數據,如淘寶存儲的是商品的信息,Google存儲著各種網站的數據——人們需要不同的方式去存儲這些數據,為了更好地存儲衍生了更多的數據存儲方案——于是有了GFS、Haystack等等。運營型網站想盡辦法為最后一公里努力著,成長型的網站一直在想著怎樣更好的返回數據,從更好的用戶體驗到機器學習。而數據則是這個過程中不變的東西。
盡管,我已經想了很多辦法去盡可能減少元素——在最開始的版本里只有標題和內容。然而為了滿足我們在數據庫中定義的結構,不得不造出來這么多對于一般用戶不友好的字段。如鏈接名是為了存儲的文件名而存在的,即這個鏈接名在最后會變成文件名:
repo.write('master', 'contents/' + data.url + '.json', stringifyData, 'Robot: add article ' + data.title, options, function (err, data) { if(data.commit){ that.setState({message: "上傳成功" + JSON.stringify(data)}); that.refs.snackbar.show(); that.setState({ sending: 0 }); } });
然后,上面的數據就會變成一個對象存儲到“數據庫”中。
今天 ,仍然有很多人用Word、Excel來存儲數據。因為對于他們來說,這些軟件更為直接,他們簡單地操作一下就可以對數據進行排序、篩選。數據以怎樣的形式存儲并不重要,重要的是他們都以文件的形式存儲著。
git作為NoSQL數據庫
不同的數據庫會以不同的形式存儲到文件中去。blob是git中最為基本的存儲單位,我們的每個content都是一個blob。redis可以以rdb文件的形式存儲到文件系統中。完成一個CMS,我們并不需要那么多的查詢功能。
這些上千年的組織機構,只想讓人們知道他們想要說的東西。
我們使用NoSQL是因為:
- 不使用關系模型
- 在集群中運行良好
- 開源
- 無模式
- 數據交換格式
我想其中只有兩點對于我來說是比較重要的集群與數據格式。但是集群和數據格式都不是我們要考慮的問題。。。
我們也不存在數據格式的問題、開源的問題,什么問題都沒有。。除了,我們之前說到的查詢——但是這是可以解決的問題,我們甚至可以返回不同的歷史版本的。在這一點上git做得很好,他不會像WordPress那樣存儲多個版本。
JSON文件 + Nginx就可以變成這樣一個合理的API,甚至是運行方式。我們可以對其進行增、刪、改、查,盡管就當前來說查需要一個額外的軟件來執行,但是為了實現一個用得比較少的功能,而去花費大把的時間可能就是在浪費。
git的“API”提供了豐富的增、刪、改功能——你需要commit就可以了。我們所要做的就是:
- git commit
- git push
于是,就會有一個很忙的Travis-Github Robot在默默地為你工作。

一鍵發布:編輯器
為了實現之前說到的編輯-發布-開發分離的CMS,我還是花了兩天的時間打造了一個面向普通用戶的編輯器。效果截圖如下所示:

作為一個普通用戶,這是一個很簡單的軟件。除了Electron + Node.js + React作了一個140M左右的軟件,盡管壓縮完只有40M左右 ,但是還是會把用戶嚇跑的。不過作為一個快速構建的原型已經很不錯了——構建速度很快、并且運行良好。
- Electron
- React
- Material UI
- Alloy Editor
盡管這個界面看上去還是稍微復雜了一下,還在試著想辦法將鏈接名和日期去掉——問題是為什么會有這兩個東西?
Webpack 打包
if (process.env.HOT) { mainWindow.loadUrl('file://' + __dirname + '/app/hot-dev-app.html'); } else { mainWindow.loadUrl('file://' + __dirname + '/app/app.html'); }
上傳代碼
repo.write('master', 'content/' + data.url + '.json', stringifyData, 'Robot: add article ' + data.title, options, function (err, data) { if(data.commit){ that.setState({message: "上傳成功" + JSON.stringify(data)}); that.refs.snackbar.show(); that.setState({ sending: 0 }); } });
當我們點下發送的時侯,這個內容就直接提交到了Content Repo下,如上上圖所示。
當我們向Content Push代碼的時候,就會運行一下Trigger腳本:
after_success: - node trigger-build.js
腳本的代碼如下所示:
var Travis = require('travis-ci'); var repo = "phodal-archive/echeveria-deploy"; var travis = new Travis({ version: '2.0.0' }); travis.authenticate({ github_token: process.env.GH_TOKEN }, function (err, res) { if (err) { return console.error(err); } travis.repos(repo.split('/')[0], repo.split('/')[1]).builds.get(function (err, res) { if (err) { return console.error(err); } travis.requests.post({ build_id: res.builds[0].id }, function (err, res) { if (err) { return console.error(err); } console.log(res.flash[0].notice); }); }); });
由于,我們在這個過程我們的Content提交的是JSON數據,我們可以直接用這些數據做一個APP。
移動應用
為了快速開發,這里我們使用了Ionic + ngCordova來開發 ,最后效果圖如下所示:

在這個代碼庫里,主要由兩部分組成:
- 獲取全部文章
- 獲取特定文章
為了獲取全部文章就意味著,我們在Builder里,需要一個task來合并JSON文件,并刪掉其中的一些無用的內容,如articleHTML和article。最后,將生成一個名為articles.json。
if (!grunt.file.exists(src)) throw "JSON source file \"" + chalk.red(src) + "\" not found."; else { var fragment; grunt.log.debug("reading JSON source file \"" + chalk.green(src) + "\""); try { fragment = grunt.file.readJSON(src); } catch (e) { grunt.fail.warn(e); } fragment.description = sanitizeHtml(fragment.article).substring(0, 200); delete fragment.article; delete fragment.articleHTML; json.push(fragment); }
接著,我們就可以獲取所有的文章然后顯示~~。在這里又順便加了一個pullToRefresh。
.controller('ArticleListsCtrl', function ($scope, Blog) { $scope.articles = null; $scope.blogOffset = 0; $scope.doRefresh = function () { Blog.async('http://deploy.baimizhou.net/api/blog/articles.json').then(function (results) { $scope.articles = results; }); $scope.$broadcast('scroll.refreshComplete'); $scope.$apply() }; Blog.async('http://deploy.baimizhou.net/api/blog/articles.json').then(function (results) { $scope.articles = results; }); })
最后,當我們點擊特定的url,將跳轉到相應的頁面:
<ion-item class="item item-icon-right" ng-repeat="article in articles" type="item-text-wrap" href="#/app/article/{{article.url}}"> <h2>{{article.title}}</h2> <i class="icon ion-ios-arrow-right"></i> </ion-item>
就會交由相應的Controller來處理。
.controller('ArticleCtrl', function ($scope, $stateParams, $sanitize, $sce, Blog) { $scope.article = {}; Blog.async('http://deploy.baimizhou.net/api/' + $stateParams.slug + '.json').then(function (results) { $scope.article = results; $scope.htmlContent = $sce.trustAsHtml($scope.article.articleHTML); }); });
小結
盡管沒有一個更成熟的環境可以探索這其中的問題,但是我想對于當前這種情況來說,它是非常棒的解決方案。我們面向的不是那些技術人員,而是一般的用戶。他們能熟練使用的是:編輯器和APP。
- 不會因為后臺的升級來困擾他們,也不會受其他組件的影響。
- 開發人員不需要擔心,某個功能影響了編輯器的使用。
- Ops不再擔心網站的性能問題——然后要么轉為DevOps、要么被Fire。
其他
最后的代碼庫:
- Content: https://github.com/phodal-archive/echeveria-content
- Code: https://github.com/phodal-archive/echeveria-deploy
- 移動應用: https://github.com/phodal-archive/echeveria-mobile
- 桌面應用: https://github.com/phodal/echeveria-editor
- Github Pages: https://github.com/phodal-archive/echeveria-deploy/tree/gh-pages