[干货]实例讲解基于 Flask+React 的全栈开发和部署

简介

我有时在 Web 上浏览信息时,会浏览 Github Trending, Hacker News稀土掘金 等技术社区的资讯或文章,但觉得逐个去看很费时又不灵活。后来我发现国外有一款叫 Panda 的产品,它聚合了互联网大多数领域的信息,使用起来确实很不错,唯一的遗憾就是没有互联网中文领域的信息,于是我就萌生了一个想法:写个爬虫,把经常看的网站的资讯爬下来,并显示出来。

有了想法,接下来就是要怎么实现的问题了。虽然有不少解决方法,但后来为了尝试使用 React,就采用了 Flask + React + Redux 的技术栈。其中:

  • Flask 用于在后台提供 api 服务
  • React 用于构建 UI
  • Redux 用于数据流管理

目前项目已经实现了基本功能,项目源码:Github 地址。目前界面大概如下:

home
home

前端开发

前端的开发主要涉及两大部分:ReactRedux,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 对应的原型如下:

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
flow
  • 后端的开发主要是爬虫,Flask 在本项目只是作为一个后台框架,对外提供 api 服务;

参考资料

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345

推荐阅读更多精彩内容