實例講解基於 Flask+React 的全棧開發和部署

簡介

我有時在 Web 上瀏覽信息時,會瀏覽 Github Trending, Hacker News稀土掘金 等技術社區的資訊或文章,但以爲逐個去看很費時又不靈活。後來我發現國外有一款叫 Panda 的產品,它聚合了互聯網大多數領域的信息,使用起來確實很不錯,惟一的遺憾就是沒有互聯網中文領域的信息,因而我就萌生了一個想法:寫個爬蟲,把常常看的網站的資訊爬下來,並顯示出來。css

有了想法,接下來就是要怎麼實現的問題了。雖然有很多解決方法,但後來爲了嘗試使用 React,就採用了 Flask + React + Redux 的技術棧。其中:html

  • Flask 用於在後臺提供 api 服務前端

  • React 用於構建 UIpython

  • Redux 用於數據流管理react

目前項目已經實現了基本功能,項目源碼:Github 地址。目前界面大概以下:nginx

home

前端開發

前端的開發主要涉及兩大部分:ReactRedux,React 做爲「顯示層」(View layer) 用,Redux 做爲「數據層」(Model layer) 用。git

咱們先整體瞭解一下 React+Redux 的基本工做流程,一圖勝千言(該說的基本都在圖裏面了):github

咱們能夠看到,整個數據流是單向循環的npm

Store(存放狀態) -> View layer(顯示狀態) -> Action -> Reducer(處理動做)
 ^                                                        |
 |                                                        |
 --------------------返回新的 State-------------------------

其中:json

  • 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 對應的原型以下:

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);

其中,

  • 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 {};
  }
}

能夠看到,action 就是一個普通的 JS 對象,它有一個屬性 type 是必須的,用來標識 action。

reducer 是一個含有 switch 的函數,接收當前 state 和 action 做爲參數,返回一個新的 state,好比:

import { SELECT_ITEM } 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 SELECT_ITEM:
      return _.sortBy([
        {
          item: action.item,
          boardId: action.id,
        },
        ...state.filter(element =>
            element.boardId !== action.id
        ),
      ], 'boardId');
    default:
      return state;
  }
}

再來看一下 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);
}

其中,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
    )

部署

本項目的部署採用 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'

GITHUB_PATH = 'https://github.com/ethan-funny/react-news-board'

@contextmanager
def source_virtualenv():
    with prefix("source {}".format(VENV_PATH)):
        yield

def build():
    with lcd("{}/client".format(PROJECT_LOCAL_DIR)):
        local("npm run build")

def deploy():
    print green("Start to Deploy the Project")
    print green("=" * 40)

    # 1. Create directory
    print blue("create the deploy directory")
    print blue("*" * 40)
    mkdir(path=DEPLOY_DIR)
    mkdir(path=LOG_DIR)

    # 2. Get source code
    print blue("get the source code from remote")
    print blue("*" * 40)
    with cd(DEPLOY_DIR):
        with settings(warn_only=True):
            rm(path=PROJECT_DIR)
        run("git clone {}".format(GITHUB_PATH))

    # 3. Install python virtualenv
    print blue("install the virtualenv")
    print blue("*" * 40)
    sudo("apt-get install python-virtualenv")

    # 4. Install nginx
    print blue("install the nginx")
    print blue("*" * 40)
    sudo("apt-get install nginx")
    sudo("cp {}/nginx.conf /etc/nginx/".format(CONFIG_DIR))
    sudo("cp {}/nginx_geekvi.conf /etc/nginx/sites-enabled/".format(CONFIG_DIR))

    # 5. Install python requirements
    with cd(DEPLOY_DIR):
        if not exists(VENV_DIR):
            run("virtualenv {}".format(VENV_DIR))
        with settings(warn_only=True):
            with source_virtualenv():
                sudo("pip install -r {}/requirements.txt".format(PROJECT_DIR))

    # 6. Config supervisor
    sudo("supervisord -c {}/supervisor.conf".format(CONFIG_DIR))
    sudo("supervisorctl -c {}/supervisor.conf reload".format(CONFIG_DIR))
    sudo("supervisorctl -c {}/supervisor.conf status".format(CONFIG_DIR))
    sudo("supervisorctl -c {}/supervisor.conf start all".format(CONFIG_DIR))

其中,env.hosts 指定了遠程服務器,env.key_filename 指定了私鑰的路徑,這樣咱們就能夠免密碼登陸服務器了。根據實際狀況修改上面的相關參數,好比服務器地址,用戶名,服務器端口和項目路徑等,就可使用了。注意,在部署以前,咱們應該先對前端的資源進行加載和構建,在 deploy 目錄使用以下命令:

$ fab build

固然,你也能夠直接到 client 目錄下,運行命令:

$ npm run build

若是構建沒有出現錯誤,就能夠進行部署了,在 deploy 目錄使用以下命令進行部署:

$ fab deploy

總結

  • 本項目前端使用 React+Redux,後端使用 Flask,這也算是一種比較典型的開發方式了,固然,你也可使用 Node.js 來作後端。

  • 前端的開發須要知道數據的流向:

flow

  • 後端的開發主要是爬蟲,Flask 在本項目只是做爲一個後臺框架,對外提供 api 服務;

本文由 funhacks 發表於我的博客,採用 Creative Commons BY-NC-ND 4.0(自由轉載-保持署名-非商用-禁止演繹)協議發佈。
非商業轉載請註明做者及出處。商業轉載請聯繫做者本人。
本文標題爲: 實例講解基於 Flask+React 的全棧開發和部署
本文連接爲: https://funhacks.net/2016/12/...

參考資料

相關文章
相關標籤/搜索