TDD开发容器化的Python微服务应用(二)

本节课我们将我们的项目拆分成3个工程,我们也会增加一些集成测试来确保每一个服务都能够正确的运行,引入持续集成概念,最后,我们还将添加一个ReactJS的客户端。

有人问我为什么这么长的文章不分拆成几篇文章啊?这样阅读起来也方便啊,然而在我自己学习的过程中,这种整个一篇文章把一件事情从头到尾讲清楚的形式是最好的,能给读者提供一种沉浸式的学习体验,阅读完整个文章后有种酣畅淋漓的感觉,所以我选择这种一篇文章的形式。

扫描下面的二维码(或微信搜索iEverything)添加我微信好友(注明python),然后可以加入到我们的python讨论群里面共同学习 qrcode

目录

  1. 结构
  2. 目标
  3. 代码覆盖
  4. 代码质量
  5. 持续集成
  6. React 应用
  7. React 测试
  8. React 表单
  9. 容器化 React 应用

1. 结构

  • flask-microservices-main - Docker Compose 文件、Nginx、管理脚本等
  • flask-microservices-users - Flask 应用
  • flask-microservices-client - 客户端

2. 目标

本节课结束后,你能学习到:

  • 用一个Docker Compose文件管理多个git仓库的服务
  • 在容器中运行单元测试和集成测试
  • 每个服务不依赖Docker运行
  • 为集成测试配置Travis CI
  • 在容器中运行React应用
  • React组件构建一个单页面应用

3. 代码覆盖

代码覆盖是查找未被测试执行的代码区域的过程。不过要记住的是这并不能说明你测试代码的有效性。

requirements.txt文件中添加依赖包:

coverage==4.4.2

然后,我们在manage.py中新增一个命令:

import coverage
COV = coverage.coverage(
    branch=True,
    include='project/*',
    omit=[
        'project/tests/*'
    ]
)
COV.start()

@manager.command
def cov():
    """Runs the unit tests with coverage."""
    tests = unittest.TestLoader().discover('project/tests')
    result = unittest.TextTestRunner(verbosity=2).run(tests)
    if result.wasSuccessful():
        COV.stop()
        COV.save()
        print('Coverage Summary:')
        COV.report()
        COV.html_report()
        COV.erase()
        return 0
    return 1

因为新增了依赖包,所以需要重新构建容器:

(tdd3)$ docker-compose -f docker-compose.yml up -d --build

构建完成后执行代码覆盖命令:

(tdd3)$ docker-compose -f docker-compose.yml run users-service python manage.py cov

然后你能看到测试下面的信息:

Coverage Summary:
Name                    Stmts   Miss Branch BrPart  Cover
---------------------------------------------------------
project/__init__.py        12      5      0      0    58%
project/api/models.py      13     10      0      0    23%
project/api/views.py       55      0     10      0   100%
project/config.py          16      0      0      0   100%
---------------------------------------------------------
TOTAL                      96     15     10      0    86%

然后我们可以看到项目根目录中多了一个htmlcov的文件夹,该目录下面是自动生成的代码覆盖的结果页面,我们可以在浏览器中打开htmlcov/index.html,可以看到一个页面: code-cv-result 另外要记得将htmlcov文件夹添加到.gitignore文件中~

4. 代码质量

Liniting是一个检查你代码风格和编码错误的一个过程,对于python中有很多流行的linting工具,我们这里使用Flake8 - 融合了pep8pyflakes两种linting工具。

首先,在requrements.txt文件中添加flake8依赖包:

flake8==3.5.0

同样的,重新构建容器:

(tdd3)$ docker-compose -f docker-compose.yml up -d --build

构建成功后运行flake8命令检测代码:

(tdd3)$ $ docker-compose -f docker-compose.yml run users-service flake8 project
Starting users-db ... done
project/__init__.py:2:1: F401 'flask.jsonify' imported but unused
project/tests/test_users.py:28:80: E501 line too long (83 > 79 characters)

可以看到检测出两条信息,一条是flask.jsonify没有使用,另外一条是一行语句太长,我们先去修复一下,然后重新执行代码检测,直到没有错误信息,然后重新提交代码。

5. 持续集成

持续集成的目的,是让产品可以快速迭代,同时还能保持高质量。它的核心措施是,代码集成到主干之前,必须通过自动化测试。只要有一个测试用例失败,就不能集成。由于我们的代码托管在github上面,所以选择Travis CI来做持续集成是一个不错的选择。

要触发构建工作,需要在项目根目录下面添加一个.travis.yml的文件:

sudo: required

services:
  - docker

env:
  DOCKER_COMPOSE_VERSION: 1.14.0

before_install:
  - sudo rm /usr/local/bin/docker-compose
  - curl -L https://github.com/docker/compose/releases/download/${DOCKER_COMPOSE_VERSION}/docker-compose-`uname -s`-`uname -m` > docker-compose
  - chmod +x docker-compose
  - sudo mv docker-compose /usr/local/bin

before_script:
  - docker-compose -f docker-compose.yml up --build -d

script:
  - docker-compose -f docker-compose.yml run users-service python manage.py test
  - docker-compose -f docker-compose.yml run users-service flake8 project

after_script:
  - docker-compose -f docker-compose.yml down

上面的脚本文件看上去还是很容易的,首先安装docker-compose,然后在执行脚本之前构建镜像,然后执行测试、代码质量检测等操作,最后做一些清理工作。

然后我们登录Travis CI,在个人页面,确保将当前项目激活 travis-item

然后我们可以提交代码,推送到GitHub上,这应该会触发一次构建。然后我们同样的可以前往Travis CI查看构建状态: travis-status

通过后,你最好能够添加一个Travis的状态徽章在README.md文件中,这样能让别人明白你的项目状态。

[![Build Status](https://travis-ci.org/cnych/flask-microservices-users.svg?branch=master)](https://travis-ci.org/cnych/flask-microservices-users)

就目前来说,虽然我们的项目还相对比较简单,但是基本上我们的工作流已经能够跑得非常顺畅了:

  • 本地编码
  • 提交推送到GitHub
  • 通过Travis来完成自动化测试

6. React 应用

React是一个声明式、基于组件的javascript库,可以非常轻松地创建用户交互界面。为你应用的每一个状态设计简洁的视图,在数据改变时React也可以高效地更新渲染界面。

如果你还没接触过React的话,建议你先阅读Why did we build React?React 快速入门两篇文章,当然我也建议你能学学BabelWebpack的相关知识,这些知识点结合在一起可以非常完美的帮助你进行React应用的开发。

首先确保你的系统中已经安装了NodeNPM

$ tdd100  node -v
v8.5.0
$ tdd100  npm -v
5.4.2

如果你没安装的话,根据上面两个连接你也可以非常方便的安装,这里就不详细说明了。

6.1 初始化项目

这里我们使用一个非常牛逼的创建React项目的脚手架工具:Create React App。 首先我们安装Create React App工具到全局环境中:

$ npm install create-react-app --global

安装完成后,我们在我们的flask-microservices-users项目根目录下面创建一个新的文件夹:client,然后初始化我们的项目结构:

$ mkdir client && cd client
$ create-react-app .

项目结构创建完成后,相关的依赖包也已经安装好,完成后,我们可以启动服务:

$ npm start

服务启动完成后,Create React App工具会自动用默认的浏览器打开:http://localhost:3000react-index

确保上面这些都正常后,关掉服务(Crtl + C),为了简化我们的开发过程,我们可以通知npm不要为项目创建package-lock.json文件:

$ echo 'package-lock=false' >> .npmrc

现在来开始创建创我们的第一个组件吧~~~

6.2 第一个组件

为了让我们的项目结构看起来更加简单,我们移除src目录下面的App.cssApp.jsApp.test.js以及index.css文件,然后更新index.js文件:

import React from 'react';
import ReactDOM from 'react-dom';

const App = () => {
    return (
      <div className="container">
        <div className="row">
          <div className="col-md-4">
            <br />
            <h1>All Users</h1>
            <hr /><br />
          </div>
        </div>
      </div>
    )
};

ReactDOM.render(
  <App />,
  document.getElementById('root')
);

上面代码做了几件事:

  • 导入ReactReactDom类后,我们创建了一个叫App的函数,这是ES6里面的箭头函数的写法,然后该函数返回的是一个JSX格式的对象。
  • 我们用ReactDOMrender方法将我们的App组件挂载到了一个ID为rootHTML元素上。

注意public目录下面的index.html文件的<div id="root"></div>,这就是被挂载的地方。另外对于还不太了解ES6的同学,不用担心,实际上是可以直接跳过javascript的语法,直接学习ES6的(虽然不太推荐这样),ES6比原生的javascript更加系统、更加容易学习、也更加接近你所学习过的其他编程语言(点击这里前去学习吧)

然后在public目录下的index.html文件的head区域添加bootstrapcss样式文件:

<link rel="stylesheet" href="https://cdn.bootcss.com/bootstrap/3.3.7/css/bootstrap.min.css">

6.3 基于类的组件

现在我们来将我们的App组件更改成基于类的形式:

import React, {Component} from 'react';
import ReactDOM from 'react-dom';

class App extends Component {
    constructor() {
        super();
    }
    render() {
        return (
          <div className="container">
            <div className="row">
              <div className="col-md-4">
                <br />
                <h1>All Users</h1>
                <hr /><br />
              </div>
            </div>
          </div>
        )
    }
}

ReactDOM.render(
  <App />,
  document.getElementById('root')
);

然后我们执行npm start可以发现其实上面的基于类的组件和前面的函数组件,最后的输出都是一模一样的,我们在后面会慢慢发现二者之间的区别的。

6.4 AJAX请求

为了连接客户端和服务端数据,我们在App类中增加一个getUsers()的方法,我们用一个非常流行的库:Axios来进行网络请求: 首先先安装axios包:

$ npm install axios --save

然后在App类中添加getUsers函数:

import React, {Component} from 'react';
import ReactDOM from 'react-dom';
import axios from 'axios';

class App extends Component {
    constructor() {
        super();
    }
    getUsers() {
      axios.get(`${process.env.REACT_APP_USERS_SERVICE_URL}/users`).then(res => {
        console.log(res);
      }).catch(err => {
        console.log(err);
      });
    }
    render() {
        return (
          <div className="container">
            <div className="row">
              <div className="col-md-4">
                <br />
                <h1>All Users</h1>
                <hr /><br />
              </div>
            </div>
          </div>
        )
    }
}

ReactDOM.render(
  <App />,
  document.getElementById('root')
);

当然不要忘记在顶部导入axios包哦~

为了验证上面的功能,我们要先开启我们的服务端,打开一个新的终端窗口,然后定位到flask-microservices-users项目根目录,还记得前面的启动docker-compose的命令吗?

$ docker-compose -f docker-compose.yml up -d

为了确保我们服务端的代码是正常工作的,我们还需要执行下我们的测试命令:

$ docker-compose -f docker-compose.yml run users-service python manage.py test

测试通过后,我们回到React项目,从上面的代码中可以看出我们需要增加一个process.env.REACT_APP_USERS_SERVICE_URL的环境变量,首先杀掉React的服务(Ctrl + C),然后执行下面的命令:

$ export REACT_APP_USERS_SERVICE_URL=http://127.0.0.1:5001

注意所有的自定义的环境变量必须要已REACT_APP_开头,更多信息可以查看官方文档

现在为了验证getUsers()方法,我们可以在构造函数constructor()中先调用:

constructor() {
  super();
  this.getUsers();
}

这样当我们的App组件被实例化的时候就会调用getUsers()方法了。我们来运行命令:npm start打开我们的React应用,然后打开Chrome 开发者工具(强烈推荐把chrome浏览器设置为默认浏览器,这货对于前端开发者来说真的是神器,在页面右键选择审查元素即可打开),然后打开JavaScript Console控制台,你将能看到下面的错误信息: react error 简单来说,就是我们正在发起一个跨域AJAX请求(从http://localhost:3000http://127.0.0.1:5001),这是违反浏览器的同源策略的,所以该请求被拒绝了。幸运的时候我们可以通过Flask-CORS扩展在服务端来处理跨域的请求。

回到flaks-users-service的目录,增加Flask-CORS包在requirements.txt文件中:

flask-cors==3.0.3

为方便我们开发调试,我们设置所有的请求都运行跨域操作(切记,生产环境绝对不能这样做),更新flaks-users-service/project/__init__.py文件的create_app()方法:

from flask_cors import CORS
def create_app():
    # 初始化应用
    app = Flask(__name__)

    # 运行跨域
    CORS(app)

    # 环境配置
    app_settings = os.getenv('APP_SETTINGS')
    app.config.from_object(app_settings)
    # 安装扩展
    db.init_app(app)
    # 注册blueprint
    from project.api.views import users_blueprint
    app.register_blueprint(users_blueprint)
    return app

不要忘记在文件的顶部导入flask_cors包:

from flask_cors import CORS

由于我们新增了依赖包,所以我们需要重新构建我们的镜像:

$ docker-compose -f docker-compose.yml up -d --build

然后更新、初始化数据库:

$ docker-compose -f docker-compose.yml run users-service python manage.py recreate_db
$ docker-compose -f docker-compose.yml run users-service python manage.py seed_db

然后将我们的React应用服务打开(npm start),一样的操作在浏览器中打开JavaScript Console控制终端,这下我们应该可以看到正常的网络请求的打印结果:console.log(res)react console1

我们前面在写获取用户列表的API的时候,返回的数据结构是这样的:

{
  'status': 'success',
  'data': {
      'users': users_list
  }
}

还记得吗(project/api/views.py文件中的get_users方法)?我们来更改axios获取成功后的打印语句,可以很方便的拿到用户列表数据:

getUsers() {
  axios.get(`${process.env.REACT_APP_USERS_SERVICE_URL}/users`)
  .then((res) => { console.log(res.data.data.users); })
  .catch((err) => { console.log(err); })
}

(想想为什么是res.data.data.users?)现在你可以在Javascript Console终端中看到两个用户对象的数组打印出来了:

[
  {"created_at":"Sat, 13 Jan 2018 06:12:40 GMT","email":"qikqiak@gmail.com","id":1,"username":"cnych"},
  {"created_at":"Sat, 13 Jan 2018 06:12:40 GMT","email":"icnych@gmail.com","id":2,"username":"chyang"}
]

现在有个问题是,我们是在构造函数中调用的getUsers()方法,而构造函数constructor()是在组件被挂载到DOM之前调用的,如果AJAX请求在组件被挂载完成之前比预期花费了更多的时间会出现什么情况呢?这有可能会造成竞争危害,什么意思?就是页面上的数据可能现在能够渲染出来,另外一次又可能渲染不出来(因为这个时候数据在组件挂载完成后还没完成请求,明白了吗?),这取决与我们的AJAX请求是否能够在组件挂载完成之前完成请求,对吧。不过不用担心,React定义了一系列的生命周期函数,可以很方便的来解决这个问题。

6.5 组件声明周期

基于类的组件有一个特定的函数,它们在组件的生命周期各个阶段执行。这些函数被称作生命周期方法,我们可以先花点时间看看官方文档来简单的学习下每个声明周期方法,看下这些方法都是在什么地方被调用的。 简单总结下:方法中带有前缀will的在特定环节之前被调用,而带有前缀did的方法则会在特定环节之后被调用。

挂载

下面这些方法会在组件实例被创建和插入DOM中时被调用:

  • constructor()
  • componentWillMount():会在组件render之前执行,并且永远都只执行一次。
  • render()
  • componentDidMount():会在组件加载完毕之后立即执行。这个时候组件已经生成了对应的DOM结构

更新

属性或者状态的改变会触发一次更新。当一个组件在被重新渲染时,下面这些方法会被调用:

  • componentWillReceiveProps():在组件接收到一个新的prop时被执行
  • shouldComponentUpdate()
  • componentWillUpdate()
  • render()
  • componentDidUpdate()

卸载

当一个组件从DOM中移除时,会调用下面的方法:

  • componentWillUnmount()

说了这么多,我们应该在哪个方法里面来做我们的网络请求呢?实际上ES6中的构造函数和componentWillMount函数是一致的,上面我们已经知道构造函数中执行网络请求甚至是所有的异步操作都不是好的选择,在componentDidMount函数中执行异步操作是最好的时机,可以通过Where to Fetch Data: componentWillMount vs componentDidMount了解到原因。

更改client/src/index.js代码,在App类中增加方法:

class App extends Component {
  constructor() {
    super();
  };
  componentDidMount() {
    this.getUsers();
  };
  getUsers() {
    axios.get(`${process.env.REACT_APP_USERS_SERVICE_URL}/users`)
    .then((res) => { console.log(res.data.data.users); })
    .catch((err) => { console.log(err); })
  };
  render() {
    return (
      <div className="container">
        <div className="row">
          <div className="col-md-4">
            <br/>
            <h1>All Users</h1>
            <hr/><br/>
          </div>
        </div>
      </div>
    )
  }
};

ReactDOM.render(
  <App />,
  document.getElementById('root')
);

确保上面的React应用仍然能够正常工作

6.6 State(状态)

stateReact中非常重要的一个概念,一个组件的显示形态可以由它的数据状态和配置参数来决定,一个组件可以拥有自己的状态,状态的改变可以让React高效的更新界面。 现在我们为组件App增加一个users的状态数据,然后我们可以使用setState()方法来更新状态数据,更新getUsers()方法:

getUsers() {
  axios.get(`${process.env.REACT_APP_USERS_SERVICE_URL}/users`).then(res => {
    this.setState({
        users: res.data.data.users
    });
  }).catch(err => {
    console.log(err);
  });
}

然后我们在构造函数中增加状态users

constructor() {
    super();
    this.state = {
        users: []
    }
}

我们可以看到,默认初始化的时候users数据是一个空数组,在getUsers()方法调用成功后,我们调用setState()方法更新了users状态。

查看官方文档学习正确的使用state

然后我们就可以更新rendor()方法来将状态数据渲染到页面中:

render() {
    return (
      <div className="container">
        <div className="row">
          <div className="col-md-4">
            <br />
            <h1>All Users</h1>
            <hr /><br />
            {
                this.state.users.map(user => {
                    return (
                        <h4
                          key={ user.id }
                          className="well"
                        >{ user.username }
                        </h4>
                    )
                })
            }
          </div>
        </div>
      </div>
    )
}

上面的render()函数:

  • 我们循环(用的ES6的map方法)users状态数据,每次循环中创建了一个新的H4元素,这也是为什么我们初始化的users是一个空数组的原因,避免初始化的时候出错。
  • 注意H4元素中我们增加了一个属性keyReact通过该值来跟踪每一个元素,每个key对应一个组件,相同的keyReact会认为是同一个组件,这样后续相同的key对应组件都不会被创建,简单的来说就是该属性是用来保证React高效的一个重要标识,在循环中一定要加上该属性。查看官方文档了解更多关于key的概念。

到这里,其实我们已经可以看到React应用的效果了,如下图: react-app-index

6.7 组件

我们知道React是一个组件化的库,我们这里可以将用户列表做成一个组件,这样其他任何地方要使用用户列表的话,只需要将这个组件引入就行了,是不是很方便~~~

首先在src目录下面新建components目录,在该目录下新建文件:UserList.jsx

import React from 'react';

const UserList = (props) => {
    return (
        <div>
          {
            props.users.map(user => {
              return (
                <h4
                  key={ user.id }
                  className="well"
                >{ user.username }
                </h4>
              )
            })
          }
        </div>
    )
};

export default UserList;

注意这里,为什么我们使用一个函数组件而不是基于类的组件呢?注意在该组件中我们使用props代替了state

  • Props:数据通过props向下传递,是只读的
  • State:数据绑定到一个组件上,是可读写的

可以查看文章ReactJS: Props vs. State了解更多属性和状态的区别。

限制基于类的(有状态)组件数量是一个好的习惯,因为它们可以操作状态,因为不太可预测,不太可控制。如果你只需要渲染数据(就像我们的UserList组件),使用一个函数类的组件是一个更好的选择。

现在我们需要在父组件中通过状态数据传递给子组件,首先在index.js文件中先引入我们的用户列表组件:

import UserList from './components/UserList';

然后更新render()方法:

render() {
    return (
      <div className="container">
        <div className="row">
          <div className="col-md-4">
            <br />
            <h1>All Users</h1>
            <hr /><br />
            <UserList users={this.state.users} />
          </div>
        </div>
      </div>
    )
}

注意看上面是怎样引入用户列表组件的:,这样App组件将状态数据users通过属性的形式传递给了子组件UserList,然后子组件中通过属性users进行数据渲染,整个流程明白了吗?

到这里请确保React应用能够正常的工作,在浏览器中打开http://localhost:3000能得到上面相同的结果,然后我们对我们的代码进行一些Review,然后提交到github上去。

7. React 测试

在了解上面的一些React基本概念过后,我们就可以来使用TDD的思想来进行我们开发了。

Create React App默认使用Jest进行测试,所以你需要先安装jest-cli,进入到client根目录:

$ npm install jest-cli@20.0.4 --save-dev

在安装的时候最后和我这里的版本保持一致,因为其他的版本可能会有其他问题,就需要自己去踩坑了哦~~~

另外我们还需要使用Enzyme - Airbnb出品的一个专门用于测试React组件的非常好用的库,同样的安装Enzyme

$ npm install --save-dev enzyme@3.1.0 enzyme-adapter-react-16@1.0.2

为了让Enzyme配置React 16的adapter,在src目录下面新增一个setupTests.js的文件:

import { configure } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';

configure({adapter: new Adapter()});

关于Enzyme的更多用法,可以前往官方文档查看。

配置完成后,就可以运行测试命令了:

$ npm test
No tests found related to files changed since last commit.

默认上面的测试命令运行在watch模式下面,所以你每次更改了代码后都会重新执行一遍。

112/5000 与Jest一起,我们将使用Enzyme,一个专门用于测试React组件的梦幻般的实用程序库。

组件测试

components文件夹下面新建文件UserList.test.js

import React from 'react';
import { shallow } from 'enzyme';
import UserList from './UserList';

const users = [
    {
        'active': true,
        'email': 'icnych@gmail.com',
        'id': 1,
        'username': 'cnych'
    }, {
        'active': true,
        'email': 'qikqiak@gmail.com',
        'id': 2,
        'username': 'qikqiak'
    }
];

test('UserList renders property', () => {
    const wrapper = shallow(<UserList users={users} />);
    const element = wrapper.find('h4');
    expect(element.length).toBe(2);
    expect(element.get(0).props.className).toBe('well');
    expect(element.get(0).props.children).toBe('cnych');
});

上面的测试代码中,我们使用了一个shallow的方法来创建UserList组件,然后我们就可以对它的输出进行断言测试了,使用shallow可以完全的隔离组件进行测试,这有助于子组件不会对测试结果产生影响。

有关shallow的更多信息,以及组件渲染的其他方法,可以参阅StackOverflow 这篇文章,当然你也可以到官网查看。

然后继续运行测试,看是否正常通过:

$ npm run test
 PASS  src/components/UserList.test.js
  ✓ UserList renders property (11ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        2.38s
Ran all test suites related to changed files.

如果测试没通过,请检查下测试代码,已经测试依赖包的版本是否兼容。

快照测试

上面的测试通过后,我们可以增加一个快照测试来保证UI界面不会改变。增加下面的代码到UserList.test.js文件中:

test('UserList renders a snapshot properly', () => {
  const tree = renderer.create(<UsersList users={users}/>).toJSON();
  expect(tree).toMatchSnapshot();
});

记得在文件顶部导入renderer

import renderer from 'react-test-renderer';

然后继续运行测试命令,在第一次测试运行时,会将组件输出的快照保存到__snapshots__文件夹中。 然后在以后的测试中,将新输出的快照与保存的快照进行比较,如果不同,则测试失败。

由于测试在watch模式下,我们将UserList组件中的**{user.username}更改为{user.email}**,保存后触发新的测试,我们可以看到测试失败了:

Snapshot Summary
1 snapshot test failed in 1 test suite. Inspect your code changes or press `u` to update them.

Test Suites: 1 failed, 1 total
Tests:       2 failed, 2 total
Snapshots:   1 failed, 1 total
Time:        1.609s
Ran all test suites related to changed files.

根据上面的提示,如果我们的目的就是要更改上面的组件,那么就需要更新快照,我们只需要输入u键即可:

Watch Usage
 › Press a to run all tests.
 › Press u to update failing snapshots.
 › Press p to filter by a filename regex pattern.
 › Press t to filter by a test name regex pattern.
 › Press q to quit watch mode.
 › Press Enter to trigger a test run.

输入u键过后我们可以看到快照测试通过了,当然了我们还需要相应的更改上面组件测试中的期望值,确保测试通过后,将文件夹__snapshots__加入到**.gitignore**文件中,然后提交代码。

8. React 表单

这一节,我们将创建一个添加用户的功能组件。在client/src/components目录下面添加两个新文件:

  • AddUser.jsx
  • AddUser.test.js

添加测试代码:

import React from 'react';
import { shallow } from 'enzyme';
import renderer from 'react-test-renderer';

import AddUser from './AddUser';

test('AddUser renders properly', () => {
  const wrapper = shallow(<AddUser />);
  const element = wrapper.find('form');
  expect(element.find('input').length).toBe(3);
  expect(element.find('input').get(0).props.name).toBe('username');
  expect(element.find('input').get(1).props.name).toBe('email');
  expect(element.find('input').get(2).props.name).toBe('submit');
});

现在运行测试代码肯定会报错的,然后根据上面我们的测试代码,来编写AddUser组件代码:

import React from 'react';

const AddUser = (props) => {
  return (
    <form>
      <div className="form-group">
        <input
          type="text" name="username"
          className="form-control input-lg"
          placeholder="Enter a username"
          required
        />
      </div>
      <div className="form-group">
        <input
          name="email"
          className="form-control input-lg"
          type="email"
          placeholder="Enter an email address"
          required
        />
      </div>
      <input
        type="submit"
        className="btn btn-primary btn-lg btn-block"
        value="Submit"
      />
    </form>
  );
};

export default AddUser;

然后我们在index.js文件中引入AddUser组件:

import AddUser from './components/AddUser';

然后在**render()**方法中增加组件:

render() {
  return (
    <div className="container">
      <div className="row">
        <div className="col-md-6">
          <br/>
          <h1>All Users</h1>
          <hr/><br/>
          <AddUser/>
          <br/>
          <UsersList users={this.state.users}/>
        </div>
      </div>
    </div>
  )
};

确保我们之前的后端服务是启动的状态:

$ docker-compose -f docker-compose.yml up -d --build

然后定位到 client 目录下面,先导入我们的环境变量:

$ export REACT_APP_USERS_SERVICE_URL=http://127.0.0.1:5001
$ npm run start

然后在自动打开的浏览器中http://localhost:3000/应该能看到下面的界面: index 然后在AddUser.test.js文件中新增一个测试:

test('AddUser renders a snapshot properly', () => {
    const tree = renderer.create(<AddUser/>).toJSON();
    expect(tree).toMatchSnapshot();
});

然后我们执行测试,确保测试能够正常通过

$ npm run test

由于这是一个单页面应用程序,接下来我们希望在提交表单的时候阻止浏览器刷新页面,这样的用户体验会好很多的。要完成该功能需要以下4步:

  • 处理表单提交事件
  • 获取用户输入
  • 发送AJAX请求
  • 更新页面

处理表单提交事件

为了让我们自己能够处理submit提交事件,只需要在AddUser.jsx文件中更新下form表单元素即可:

<form onSubmit={(event) => event.preventDefault()}>

大家记住event.preventDefault()这个方法,是用来阻止元素的默认处理事件的,以后肯定会用到的,然后我们随意输入一个用户名或邮件地址,然后尝试提交一次表单,我们可以看到页面没有任何反应,这正是我们所希望的,因为我们阻止了正常的事件行为。

然后我们在client/src/index.js文件中,为App组件添加一个新的方法:

addUser(event) {
  event.preventDefault();
  console.log('sanity check!');
};

由于 AddUser 是一个函数组件,所以我们需要通过props来传递上面的添加方法,更新 render 方法下面的 AddUser 元素,如下所示:

<AddUser addUser={this.addUser} />

然后我们需要更新 App 组件的构造函数:

constructor() {
    super();
    this.state = {
        users: []
    };
    this.addUser = this.addUser.bind(this);
}

注意上面的bind方法,我们通过 bind 方法来手动绑定 this 的上下文。如果没有绑定的话,方法内的上下文是不正确的。

关事件处理的更多信息,可以查看 React 关于事件处理的官方文档

没有它,这个方法内的上下文将不会有正确的上下文。 想要测试这个? 只需将console.log(this)添加到addUser(),然后提交表单即可。 什么是上下文? 删除绑定并再次测试。 现在的情况是什么?

然后在更新AddUser组件中的 form 元素:

<form onSubmit={(event) => props.addUser(event)}>

然后切换到浏览器中,随便输入用户名和邮件,点击提交按钮,在JavaScript Console终端(还记得怎么查看吗?)中可以看到sanity check!打印出来。

获取用户输入

我们将使用受控组件来获取用户提交的数据。先增加两个新的属性在 App 组件的 state 对象上:

this.state = {
  users: [],
  username: '',
  email: ''
};

然后通过属性传递给 AddUser 组件:

<AddUser
  username={this.state.username}
  email={this.state.email}
  addUser={this.addUser}
/>

现在我们在 AddUser 组件中就可以通过props来访问 username 和 email 了,更新 AddUser 组件:

import React from 'react';

const AddUser = (props) => {
    return (
      <form onSubmit={(event) => props.addUser(event)}>
          <div className="form-group">
            <input
              type="text" name="username"
              className="form-control input-lg"
              placeholder="Enter a username"
              required
              value={props.username}
            />
          </div>
          <div className="form-group">
            <input
              name="email"
              className="form-control input-lg"
              type="email"
              placeholder="Enter an email address"
              required
              value={props.email}
            />
          </div>
          <input
            type="submit"
            className="btn btn-primary btn-lg btn-block"
            value="Submit"
          />
        </form>
    );
};

export default AddUser;

然后我们可以到浏览器中测试下,随意输入一些值,你会发现什么都不能输入,这是为什么呢?仔细看上面的 input 组件的 value 值,是通过props来获取的,而传递给该组件的值是通过父组件的 state 传递过来的,因为父组件的 state 对象中的 username 和 email 一直都是空字符串,所以我们这里的输入值没有任何效果。

将父组件中的 state 对象中的 username 默认值更改成其他字符串,再看看效果呢?是不是和我们的分析是一致的。

那么我们怎么来更新父组件中的状态值呢,这样当我们在输入框中输入文本的时候就可以看到变化了。

首先,在 App 组件中新增一个handleChange的方法:

handleChange(event){
  const obj = {};
  obj[event.target.name] = event.target.value;
  this.setState(obj);
}

然后同样的,在构造函数中添加一个 bind:

this.handleChange = this.handleChange.bind(this);

然后通过props将该方法传递给AddUser组件:

<AddUser
  username={this.state.username}
  email={this.state.email}
  handleChange={this.handleChange}
  addUser={this.addUser}
/>

然后,我们就需要更新 AddUser 组件了,我们监听 input 元素的 onChange 方法,当 input 元素发送改变的时候,就会触发该调用:

import React from 'react';

const AddUser = (props) => {
    return (
      <form onSubmit={(event) => props.addUser(event)}>
          <div className="form-group">
            <input
              type="text" name="username"
              className="form-control input-lg"
              placeholder="Enter a username"
              required
              value={props.username}
              onChange={props.handleChange}
            />
          </div>
          <div className="form-group">
            <input
              name="email"
              className="form-control input-lg"
              type="email"
              placeholder="Enter an email address"
              required
              value={props.email}
              onChange={props.handleChange}
            />
          </div>
          <input
            type="submit"
            className="btn btn-primary btn-lg btn-block"
            value="Submit"
          />
        </form>
    );
};

export default AddUser;

现在我能在去浏览器中测试下,已经正常工作了。我们可以在 addUser 方法中打印下 state 对象的值,方便我们来了解 state 对象:

addUser(event) {
  event.preventDefault();
  console.log('sanity check!');
  console.log(this.state);
};

然后我们可以到浏览器中输入用户名和邮件,点击提交按钮,我们可以在浏览器的 Console 终端中看到如下的信息了: index

现在我们获得了输入的值,接下来我们就可以发送一个AJAX请求,将我们的输入添加到数据库中,然后更新 DOM 树…

发送 AJAX 请求

还记得我们前面的 users-service 服务中的add_user方法吗(project/api/views.py文件下)?我们添加一个新的用户需要发送 username 和 email 两项数据:

db.session.add(User(username=username, email=email))

现在我们在 React 代码中用Axios来发送添加用户的 POST 请求,修改client/src/index.js中 App 组件的addUser方法:

addUser(event) {
  event.preventDefault();
  console.log('sanity check!');
  const data = {
    username: this.state.username,
    email: this.state.email
  };
  axios.post(`${process.env.REACT_APP_USERS_SERVICE_URL}/users`, data).then(res => {
    console.log(res);
  }).catch(err => {
    console.log(err);
  });
}

然后我们切换到浏览器中添加一条数据,只要输入的 email 地址是唯一的,测试应该都会通过的。如果我们连续点击提交两次,我们就可以看到在 Chrome 浏览器的终端中看到有一个400的错误码打印出来,因为我们第二次提交的 email 邮箱已经存在了,而我们前面的users-service服务在 email 存在的情况下会返回 400 状态码。 status400

更新页面

最后当添加用户的表单提交成功后我们来更新用户列表,并且清空表单,同样更新addUser方法:

addUser(event) {
  event.preventDefault();
  console.log('sanity check!');
  const data = {
    username: this.state.username,
    email: this.state.email
  };
  axios.post(`${process.env.REACT_APP_USERS_SERVICE_URL}/users`, data).then(res => {
    this.getUsers();
    this.setState({ username: '', email: '' });
  }).catch(err => {
    console.log(err);
  });
}

我们前面知道获取用户列表是通过getUsers()方法的,所以当我们添加用户的请求发送成功后,是不是重新请求下getUsrs()方法,是不是就能够把数据库中的所有用户获取到了啊?要清空表单该怎么做呢?当然是操作状态了,我们知道只需要更改 state 对象下的 username 和 email,对应的元素就会发送变化,我们不需要自己手动去更新 DOM 元素,只需要操作 state 就行,是不是很方便了啊?

然后我们切换到浏览器中测试一下,添加一个唯一的邮箱,是不是提交完成后下面的列表也更新了,表单也清空了。然后我们可以运行下我们的测试代码:

$ npm run test

我们会发现测试是没有通过的,出现下面的错误提示,这是因为我们的 UI 界面已经发生了改变,和之前我们的快照是不一样的,所以这个时候我们只需要输入u更新下快照即可。

Snapshot Summary
1 snapshot test failed in 1 test suite. Inspect your code changes or press `u` to update them.

快照更新后,正常情况下测试代码都会通过的。如果有其他问题,请仔细查看下错误日志,仔细排查,然后提交代码到github

9. 容器化 React 应用

这节课让我们来容器化上面完成的 React 应用。不过在开始前,我们先重构下我们的项目吧。

重构

在项目根目录下面新增一个叫services的文件夹,然后将nginx文件夹移动到 services 目录下面,将client文件夹移动到 services 目录下面,然后在 services 目录下面新建一个 users文件夹,将根目录下面的project文件夹、Dockerfile、manage.py、requirements.txt 文件都移动到services/users目录下面,操作完成后,你看到的我们的代码结构应该是这样的:

$ flask-microservices-users [master] ⚡ ls -la
total 88
drwxr-xr-x  12 ych  staff    384  1 26 12:12 .
drwxr-xr-x   4 ych  staff    128 12 28 16:08 ..
drwxr-xr-x  14 ych  staff    448  1 26 12:27 .git
-rw-r--r--   1 ych  staff     23  1  5 17:56 .gitignore
-rw-r--r--   1 ych  staff      5 12 28 15:19 .python-version
-rw-r--r--   1 ych  staff    645  1  5 18:13 .travis.yml
-rw-r--r--   1 ych  staff  11357 12 29 10:44 LICENSE
-rw-r--r--   1 ych  staff    206  1  5 18:23 README.md
-rw-r--r--   1 ych  staff    922  1 26 12:11 docker-compose-prod.yml
-rw-r--r--   1 ych  staff    755  1 26 12:15 docker-compose.yml
drwxr-xr-x   6 ych  staff    192  1 26 12:09 services
$ flask-microservices-users [master] ⚡ ls -la services
total 16
drwxr-xr-x   6 ych  staff   192  1 26 12:09 .
drwxr-xr-x  12 ych  staff   384  1 26 12:12 ..
drwxr-xr-x  10 ych  staff   320  1 16 17:37 client
drwxr-xr-x   4 ych  staff   128 12 30 15:15 nginx
drwxr-xr-x   7 ych  staff   224  1 26 12:14 users
$ flask-microservices-users [master] ⚡ ls -la services/users
total 40
drwxr-xr-x  7 ych  staff   224  1 26 12:14 .
drwxr-xr-x  6 ych  staff   192  1 26 12:09 ..
-rw-r--r--  1 ych  staff   329 12 29 10:47 Dockerfile
-rw-r--r--  1 ych  staff  1366  1  5 17:46 manage.py
drwxr-xr-x  9 ych  staff   288 12 31 10:56 project
-rw-r--r--  1 ych  staff   159  1 13 14:28 requirements.txt

然后我们来更新docker-compose.yml文件:

version: '3.3'

services:
  users-db:
    container_name: users-db
    build:
      context: ./services/users/project/db
      dockerfile: Dockerfile
    ports:
      - 3307:3306
    environment:
      - MYSQL_ROOT_PASSWORD=root321

  users-service:
    container_name: users-service
    build:
      context: ./services/users
      dockerfile: Dockerfile-dev
    volumes:
      - './services/users:/usr/src/app'
    ports:
      - 5001:5000 # 暴露端口 - 主机:容器
    environment:
      - APP_SETTINGS=project.config.DevelopmentConfig
      - DATABASE_URL=mysql+pymysql://root:root321@users-db:3306/users_dev
      - DATABASE_TEST_URL=mysql+pymysql://root:root321@users-db:3306/users_test
    depends_on:
      - users-db
    links:
      - users-db

同样的,更新docker-compose-prod.yml文件:

version: '3.3'

services:
  users-db:
    container_name: users-db
    build:
      context: ./services/users/project/db
      dockerfile: Dockerfile
    ports:
      - 3307:3306
    environment:
      - MYSQL_ROOT_PASSWORD=root321

  users-service:
    container_name: users-service
    build:
      context: ./services/users
      dockerfile: Dockerfile-prod
    expose:
      - '5000' # 只暴露容器端口
    environment:
      - APP_SETTINGS=project.config.ProductionConfig
      - DATABASE_URL=mysql+pymysql://root:root321@users-db:3306/users_prod
      - DATABASE_TEST_URL=mysql+pymysql://root:root321@users-db:3306/users_test
    command: gunicorn -b 0.0.0.0:5000 manage:app
    depends_on:
      - users-db
    links:
      - users-db

  nginx:
    container_name: nginx
    build: ./services/nginx
    restart: always
    ports:
      - 8080:80
    depends_on:
      - users-service
    links:
      - users-service

注意上面的docker-compose文件我们更新到3.3版本,注意看和之前的2.1版本是有部分区别的。然后重新构建下我们的容器:

$ docker-compose -f docker-compose.yml up -d --build

确保上面的构建过程是正确的,我们可以通过查看users-service服务的日志来确认:

$ docker-compose logs -f users-service
Attaching to users-service
users-service    |  * Running on http://0.0.0.0:5000/ (Press CTRL+C to quit)
users-service    |  * Restarting with stat
users-service    |  * Debugger is active!
users-service    |  * Debugger PIN: 170-528-240
users-service    | 172.18.0.1 - - [26/Jan/2018 04:22:08] "GET /ping HTTP/1.1" 200 -

然后我们可以跑一遍我们的测试用例,确保的业务逻辑也是符合我们的预期的:

$ docker-compose -f docker-compose.yml run users-service python manage.py test

如果一切正常,上面的测试也会正常通过的。

本地开发环境

services/client目录下面添加Dockerfile-dev文件:

FROM node:9.4
MAINTAINER "qikqiak@gmail.com"

# 设置工作目录
RUN mkdir /usr/src/app
WORKDIR /usr/src/app

# 添加node_modules/.bin目录到 $PATH 环境下
ENV PATH /usr/src/app/node_modules/.bin:$PATH

# 安装并缓存依赖包(用silent选项可以减少一些不必要的错误)
ADD package.json /usr/src/app/package.json
RUN npm install --silent
RUN npm install react-scripts@1.0.17 -g --silent

# 启动应用
CMD ["npm", "start"]

然后我们在docker-compose.yml中新增 React 应用的服务:

client:
  container_name: client
  build:
    context: ./services/client
    dockerfile: Dockerfile-dev
  volumes:
    - './services/client:/usr/src/app'
  ports:
    - '3007:3000'
  environment:
    - NODE_ENV=development
    - REACT_APP_USERS_SERVICE_URL=${REACT_APP_USERS_SERVICE_URL}
  depends_on:
    - users-service
  links:
    - users-service

在确保users-service服务正常启动后,在终端中导入环境变量:

$ export REACT_APP_USERS_SERVICE_URL=http://127.0.0.1:5001

然后重新构建镜像启动新的容器:

$ docker-compose -f docker-compose.yml up --build -d client

新容器启动完成后,运行 React 客户端测试代码:

$ docker-compose -f docker-compose.yml run client npm test

测试通过后,我们可以在本地浏览器中打开http://127.0.0.1:3007访问 React 应用了,当然如果测试没通过的话,我们得根据错误信息去修复我们的测试代码,直到测试通过为止。

更新services/nginx/flask.conf配置:

server {
  listen 80;

  location / {
    proxy_pass http://client:3000;
    proxy_redirect    default;
    proxy_set_header  Host $host;
    proxy_set_header  X-Real-IP $remote_addr;
    proxy_set_header  X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header  X-Forwarded-Host $server_name;
  }

  location /users {
    proxy_pass        http://users-service:5000;
    proxy_redirect    default;
    proxy_set_header  Host $host;
    proxy_set_header  X-Real-IP $remote_addr;
    proxy_set_header  X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header  X-Forwarded-Host $server_name;
  }
}

上面的nginx配置文件做了什么工作?

  • 首先在location块中定义了反向代理规则。
  • 当请求的 URI 与location区域中的 URI 匹配的时候,Nginx将请求转发给了后面的 React 服务(client)或者 Flask 服务(users-service)。

同样的,我们将Nginx服务也定义到docker-compose.yml文件中:

nginx:
  container_name: nginx
  build: ./services/nginx
  restart: always
  ports:
    - 8080:80
  depends_on:
    - users-service
    - client
  links:
    - users-service

然后更新容器:

$ docker-compose -f docker-compose.yml up -d --build

注意我们这里用的主机端口为8080,因为我们本机的80端口已经被占用,在实际线上部署的时候应该用80端口,然后再根据域名进行匹配进行测试。

容器启动完成后,打开浏览器测试下我们的服务,确保下面的两个请求都是正常的:

  • http://127.0.0.1:8080
  • http://127.0.0.1:8080/users

我们可以通过下面的命令查看运行中的容器日志:

$ docker-compose -f docker-compose.yml logs -f

然后我们可以改变下 App 组件的 state 对象:

this.state = {
  users: [],
  username: 'test001',
  email: ''
};

当我们保存的时候可以看到日志中输出了编译成功的信息,而且这个时候浏览器也自动刷新了,并且将 test001 填充在了用户名的输入框中。 change state 当然测试过后,要记得将上面的 state 对象的值更改回去哦~

生产环境

在将 React 应用上到生产环境之前我们需要将应用进行打包构建用于生成一系列的静态文件,我们在services/client目录下面的package.json文件中可以看到有一个build的脚本,该脚本就是用于我们对 React 应用进行打包的,在该目录下面执行命令:

$ npm run build

> client@0.1.0 build /Users/ych/devs/workspace/yidianzhishi/tdd100/flask-microservices-users/services/client
> react-scripts build

Creating an optimized production build...
Compiled successfully.

构建完成后我们可以发现在该目录下面多了一个build的目录,该目录下面打包过后的静态资源文件。我们需要一个静态服务器来访问这些静态资源,执行下面的命令:

$ npm install -g serve
$ serve -s build
   ┌──────────────────────────────────────────────────┐
   │                                                  │
   │   Serving!                                       │
   │                                                  │
   │   - Local:            http://localhost:5000      │
   │   - On Your Network:  http://192.168.31.9:5000   │
   │                                                  │
   │   Copied local address to clipboard!             │
   │                                                  │
   └──────────────────────────────────────────────────┘

我们可以根据上面的提示访问http://localhost:5000,然后我们可以在进行一次相关的测试,确保没有任何错误。

然后在client的根目录下面新增一个用于生产环境的 Dockerfile 文件:(Dockerfile-prod)

# build environment
FROM node:9.4 as builder
RUN mkdir /usr/src/app
WORKDIR /usr/src/app
ENV PATH /usr/src/app/node_modules/.bin:$PATH
ARG REACT_APP_USERS_SERVICE_URL
ARG NODE_ENV
ENV NODE_ENV $NODE_ENV
ENV REACT_APP_USERS_SERVICE_URL $REACT_APP_USERS_SERVICE_URL
COPY package.json /usr/src/app/package.json
RUN npm install --silent
RUN npm install react-scripts@1.1.0 -g --silent
COPY . /usr/src/app
RUN npm run build

# production environment
FROM nginx:1.13.5-alpine
COPY --from=builder /usr/src/app/build /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

我们可以看到上面的Dockerfile文件和之前的不太一样,出现了两个基础镜像,这是Docker提供的多阶段构建功能,详细的可以参考我之前的一篇博文:Docker 的多阶段构建

当构建镜像的时候,参数通过ARG指令传递给Dockerfile,然后就可以将其用做环境变量。npm run build命令将生成静态文件,然后通过Nginx暴露80端口提供服务。

接下来我们不使用Docker Compose来进行一些简单的测试。首先,在services/client目录下面构建镜像,记得使用--build-arg标记来传递合适的参数:

$ docker build -f Dockerfile-prod -t "test" ./ --build-arg NODE_ENV=development --build-arg REACT_APP_USERS_SERVICE_URL=http://127.0.0.1:5001

记得将上面的REACT_APP_USERS_SERVICE_URL替换成你自己的实际的 IP。

上面的构建命令是用services/client下面的Dockerfile-prod文件来构建一个叫 test 的镜像。

镜像构建完成后我们使用上面的 test 镜像来启动一个名为 test01 的容器,将主机的9000端口映射到容器的80端口:

$ docker run --name test01 -d -p 9000:80 test

容器启动成功后,我们可以在浏览器中打开链接http://localhost:9000/来进行相关的测试。 然后我们可以停止容器:

$ docker stop test01
$ docker rm test01

最好,删除上面的镜像:

$ docker rmi test

上面我们对Dockerfile-prod进行了相关的设置和测试,现在我们将该服务添加到docker-compose-prod.yml

client:
  container_name: client
  build:
    context: ./services/client
    dockerfile: Dockerfile-prod
    args:
      - NODE_ENV=production
      - REACT_APP_USERS_SERVICE_URL=${REACT_APP_USERS_SERVICE_URL}
  ports:
    - '3007:80'
  depends_on:
    - users
  links:
    - users

注意这里我们使用的是args,而不是环境变量了。另外,我们的这个 client 服务需要在 nginx 服务启动之前启动,所以我们相应的更新docker-compose-prod.yml

nginx:
  container_name: nginx
  build: ./services/nginx
  restart: always
  ports:
    - 80:80
  depends_on:
    - users
    - client

TODO…

扫描下面的二维码(或微信搜索iEverything)添加我微信好友(注明python),然后可以加入到我们的python讨论群里面共同学习 qrcode

微信公众号

扫描下面的二维码关注我们的微信公众帐号,在微信公众帐号中回复◉加群◉即可加入到我们的 kubernetes 讨论群里面共同学习。

wechat-account-qrcode