實例講解基于 Flask+React 的全棧開發和部署
簡介
我有時在 Web 上瀏覽信息時,會瀏覽 Github Trending , Hacker News 和 稀土掘金 等技術社區的資訊或文章,但覺得逐個去看很費時又不靈活。后來我發現國外有一款叫 Panda 的產品,它聚合了互聯網大多數領域的信息,使用起來確實很不錯,唯一的遺憾就是沒有互聯網中文領域的信息,于是我就萌生了一個想法:寫個爬蟲,把經常看的網站的資訊爬下來,并顯示出來。
有了想法,接下來就是要怎么實現的問題了。雖然有不少解決方法,但后來為了嘗試使用 React ,就采用了 Flask + React + Redux 的技術棧。其中:
-
Flask 用于在后臺提供 api 服務
-
React 用于構建 UI
-
Redux 用于數據流管理
前端開發
前端的開發主要涉及兩大部分: React 和 Redux ,React 作為「顯示層」(View layer) 用,Redux 作為「數據層」(Model layer) 用。
我們先總體了解一下 React+Redux 的基本工作流程,一圖勝千言(該說的基本都在圖里面了):
我們可以看到, 整個數據流是單向循環的 :
Store(存放狀態) -> View layer(顯示狀態) -> Action -> Reducer(處理動作)
^ |
| |
--------------------返回新的 State-------------------------
其中:
-
React 提供應用的 View 層,表現為組件,分為容器組件(container)和普通顯示組件(component);
-
Redux 包含三個部分:Action,Reducer 和 Store:
-
Action 本質上是一個 JS 對象,它至少需要一個元素:type,用于標識 action;
-
Middleware(中間件)用于在 Action 發起之后,到達 Reducer 之前做一些操作,比如異步 Action,Api 請求等;
-
Reducer 是一個函數: (previousState, action) => newState ,可理解為動作的處理中心,處理各種動作并生成新的 state,返回給 Store;
-
Store 是整個應用的狀態管理中心,容器組件可以從 Store 中獲取所需要的狀態;
項目前端的源碼在 client 目錄中,下面是一些主要的目錄:
client ├── actions # 各種 action ├── components # 普通顯示組件 ├── containers # 容器組件 ├── middleware # 中間間,用于 api 請求 ├── reducers # reducer 文件 ├── store # store 配置文件
React 開發
React 部分的開發主要涉及 container 和 component:
-
container 負責接收 store 中的 state 和發送 action,一般和 store 直接連接;
-
component 位于 container 的內部,它們一般不和 store 直接連接,而是從父組件 container 獲取數據作為 props,所有操作也是通過回調完成,component 一般會多次使用;
在本項目中,container 對應的原型如下:
而 component 則主要有兩個:一個是選擇組件,一個是信息顯示組件,如下:
這些 component 會被多次使用。
下面,我們主要看一下容器組件 (對應 App.js) 的代碼(只顯示部分重要的代碼):
import React, { Component, PropTypes } from 'react'; import { connect } from 'react-redux';
import Posts from '../../components/Posts/Posts'; import Picker from '../../components/Picker/Picker'; import { fetchNews, selectItem } from '../../actions';
require('./App.scss');
class App extends Component { constructor(props) { super(props); this.handleChange = this.handleChange.bind(this); }
componentDidMount() { for (const value of this.props.selectors) { this.props.dispatch(fetchNews(value.item, value.boardId)); } }
componentWillReceiveProps(nextProps) { for (const value of nextProps.selectors) { if (value.item !== this.props.selectors[value.boardId].item) { nextProps.dispatch(fetchNews(value.item, value.boardId)); } } }
handleChange(nextItem, id) { this.props.dispatch(selectItem(nextItem, id)); }
render() { const boards = []; for (const value of this.props.selectors) { boards.push(value.boardId); } const options = ['Github', 'Hacker News', 'Segment Fault', '開發者頭條', '伯樂頭條']; return ( <div className="mega"> <main> <div className="desk-container"> { boards.map((board, i) => <div className="desk" style={{ opacity: 1 }} key={i}> <Picker value={this.props.selectors[board].item} onChange={this.handleChange} options={options} id={board} /> <Posts isFetching={this.props.news[board].isFetching} postList={this.props.news[board].posts} id={board} /> </div> ) } </div> </main> </div> ); } }
function mapStateToProps(state) { return { news: state.news, selectors: state.selectors, }; }
export default connect(mapStateToProps)(App);</code></pre>
其中,
-
constructor(props) 是一個構造函數,在創建組件的時候會被調用一次;
-
componentDidMount() 這個方法在組件加載完畢之后會被調用一次;
-
componentWillReceiveProps() 這個方法在組件接收到一個新的 prop 時會被執行;
上面這幾個函數是組件生命周期(react component lifecycle)函數,更多的組件生命周期函數可 在此 查看。
-
react-redux 這個庫的作用從名字就可看出,它用于連接 react 和 redux,也就是連接容器組件和 store;
-
mapStateToProps 這個函數用于建立一個從(外部的)state 對象到 UI 組件的 props 對象的映射關系,它會訂閱 Store 中的 state,每當有 state 更新時,它就會自動執行,重新計算 UI 組件的參數,從而觸發 UI 組件的重新渲染;
Redux 開發
上文說過,Redux 部分的開發主要包含:action,reducer 和 store,其中,store 是應用的狀態管理中心,當收到新的 state 時,會觸發組件重新渲染,reducer 是應用的動作處理中心,負責處理動作并產生新的狀態,將其返回給 store。
在本項目中,有兩個 action,一個是站點選擇(如 Github,Hacker News),另一個是信息獲取,action 的部分代碼如下:
export const FETCH_NEWS = 'FETCH_NEWS'; export const SELECT_ITEM = 'SELECT_ITEM';
export function selectItem(item, id) { return { type: SELECT_ITEM, item, id, }; }
export function fetchNews(item, id) { switch (item) { case 'Github': return { type: FETCH_NEWS, api:
/api/github/repo_list
, method: 'GET', id, }; case 'Segment Fault': return { type: FETCH_NEWS, api:/api/segmentfault/blogs
, method: 'GET', id, }; default: return {}; } }</code></pre>可以看到,action 就是一個普通的 JS 對象,它有一個屬性 type 是必須的,用來標識 action。
reducer 是一個含有 switch 的函數,接收當前 state 和 action 作為參數,返回一個新的 state,比如:
import { SELECTITEM } from '../actions'; import from 'lodash';
const initialState = [ { item: 'Github', boardId: 0, }, { item: 'Hacker News', boardId: 1, } ];
export default function reducer(state = initialState, action = {}) { switch (action.type) { case SELECTITEM: return .sortBy([ { item: action.item, boardId: action.id, }, ...state.filter(element => element.boardId !== action.id ), ], 'boardId'); default: return state; } }</code></pre>
再來看一下 store:
import { createStore, applyMiddleware, compose } from 'redux'; import thunk from 'redux-thunk'; import api from '../middleware/api'; import rootReducer from '../reducers';
const finalCreateStore = compose( applyMiddleware(thunk), applyMiddleware(api) )(createStore);
export default function configureStore(initialState) { return finalCreateStore(rootReducer, initialState); }</code></pre>
其中, applyMiddleware() 用于告訴 redux 需要用到那些中間件,比如異步操作需要用到 thunk 中間件,還有 api 請求需要用到我們自己寫的中間件。
后端開發
后端的開發主要是爬蟲,目前的爬蟲比較簡單,基本上是靜態頁面的爬蟲,主要就是 HTML 解析和提取。如果要爬取 稀土掘金 和 知乎專欄 等網站,可能會涉及到 登錄驗證 , 抵御反爬蟲 等機制,后續也將進一步開發。
后端的代碼在 server 目錄:
server ├── __init__.py ├── app.py # 創建 app ├── configs.py # 配置文件 ├── controllers # 提供 api 服務 └── spiders # 爬蟲文件夾,幾個站點的爬蟲
后端通過 Flask 以 api 的形式給前端提供數據,下面是部分代碼:
# -- coding: utf-8 --
import flask from flask import jsonify
from server.spiders.github_trend import GitHubTrend from server.spiders.toutiao import Toutiao from server.spiders.segmentfault import SegmentFault from server.spiders.jobbole import Jobbole
news_bp = flask.Blueprint( 'news', name, url_prefix='/api' )
@news_bp.route('/github/repo_list', methods=['GET']) def get_github_trend(): gh_trend = GitHubTrend() gh_trend_list = gh_trend.get_trend_list()
return jsonify( message='OK', data=gh_trend_list )
@news_bp.route('/toutiao/posts', methods=['GET']) def get_toutiao_posts(): toutiao = Toutiao() post_list = toutiao.get_posts()
return jsonify( message='OK', data=post_list )
@news_bp.route('/segmentfault/blogs', methods=['GET']) def get_segmentfault_blogs(): sf = SegmentFault() blogs = sf.get_blogs()
return jsonify( message='OK', data=blogs )
@news_bp.route('/jobbole/news', methods=['GET']) def get_jobbole_news(): jobbole = Jobbole() blogs = jobbole.get_news()
return jsonify( message='OK', data=blogs )</code></pre>
部署
本項目的部署采用 nginx+gunicorn+supervisor 的方式,其中:
-
nginx 用來做反向代理服務器:通過接收 Internet 上的連接請求,將請求轉發給內網中的目標服務器,再將從目標服務器得到的結果返回給 Internet 上請求連接的客戶端(比如瀏覽器);
-
gunicorn 是一個高效的 Python WSGI Server,我們通常用它來運行 WSGI (Web Server Gateway Interface,Web 服務器網關接口) 應用(比如本項目的 Flask 應用);
-
supervisor 是一個進程管理工具,可以很方便地啟動、關閉和重啟進程等;
項目部署需要用到的文件在 deploy 目錄下:
deploy ├── fabfile.py # 自動部署腳本 ├── nginx.conf # nginx 通用配置文件 ├── nginx_geekvi.conf # 站點配置文件 └── supervisor.conf # supervisor 配置文件
本項目采用了 Fabric 自動部署神器,它允許我們不用直接登錄服務器就可以在本地執行遠程操作,比如安裝軟件,刪除文件等。
fabfile.py 文件的部分代碼如下:
# -- coding: utf-8 --
import os from contextlib import contextmanager from fabric.api import run, env, sudo, prefix, cd, settings, local, lcd from fabric.colors import green, blue from fabric.contrib.files import exists
env.hosts = ['deploy@111.222.333.44:12345'] env.key_filename = '~/.ssh/id_rsa'
env.password = '12345678'
path on server
DEPLOY_DIR = '/home/deploy/www' PROJECT_DIR = os.path.join(DEPLOY_DIR, 'react-news-board') CONFIG_DIR = os.path.join(PROJECT_DIR, 'deploy') LOG_DIR = os.path.join(DEPLOY_DIR, 'logs') VENV_DIR = os.path.join(DEPLOY_DIR, 'venv') VENV_PATH = os.path.join(VENV_DIR, 'bin/activate')
path on local
PROJECT_LOCAL_DIR = '/Users/Ethan/Documents/Code/react-news-board'
-