十一、使用 Docker 容器构建和部署 React 应用

在本书中提到这一点之前,您一直在开发模式下运行 React 应用,使用您所学习的各种工具。在本章中,我们将把重点转向生产环境工具。总体目标是能够将 React 应用部署到生产环境中。谢天谢地,有很多工具可以帮助您完成这项工作,您将在本章中熟悉这些工具。您在本章中的目标是:

  • 构建利用 API 的基本消息传递 React 应用
  • 使用节点容器运行 React 应用
  • 将应用拆分为可部署的服务,这些服务在容器中运行
  • 为生产环境使用静态 React 构建

构建消息传递应用

在没有任何上下文的情况下,很难谈论用于部署 React 应用的工具。为此,您将集成一个基本的消息传递应用。在本节中,您将看到应用是如何工作的以及它是如何构建的。然后,您将为后面的章节做好准备,在这些章节中,您将学习如何将应用部署为一组容器。

该应用的基本思想是能够登录并向联系人发送消息,以及接收消息。我们将保持它超级简单。就功能而言,它几乎无法与 SMS 功能相媲美。事实上,这可能就是应用标题——几乎没有短信。我们的想法是拥有一个 React 应用,该应用具有足够的移动部件,可以在生产环境中进行测试,以及一个稍后可以在容器中部署的服务器。

对于视觉外观,我们将使用材质 UI(https://material-ui-next.com/ 组件库。但是,UI 组件的选择不应影响本章的内容。

刚开始发短信

为了熟悉短信,让我们在您的终端上启动它,就像您在本书中一直做的那样。一旦您切换到本书附带的源代码包中的building-a-messaging-app目录,您就可以像启动任何其他create-react-app项目一样启动开发服务器:

npm start

在另一个终端窗口或选项卡中,您可以通过在同一目录中运行以下命令来启动SMS的 API 服务器:

npm run api

这将启动基本快车(http://expressjs.com/ 应用。服务器启动并侦听请求后,您将看到以下输出:

API server listening on port 3001!  

现在您可以登录了。

登录

当您第一次加载 UI 时,您应该看到如下所示的登录屏幕:

以下模拟用户作为 API 的一部分存在:

  • user1
  • user2
  • user3
  • user4
  • user5

密码实际上并没有根据任何东西进行验证,因此将其留空或输入胡言乱语都应该对前面的任何用户进行身份验证。让我们看一下呈现这个页面的 AUT0T0.组件:

import React, { Component } from 'react';

import { withStyles } from 'material-ui/styles';
import TextField from 'material-ui/TextField';
import Button from 'material-ui/Button';

import { login } from './api';

const styles = theme => ({
  container: {
    display: 'flex',
    flexWrap: 'wrap'
  },
  textField: {
    marginLeft: theme.spacing.unit,
    marginRight: theme.spacing.unit,
    width: 200
  },
  button: {
    margin: theme.spacing.unit
  }
});

class Login extends Component {
  state = {
    user: '',
    password: ''
  };

  onInputChange = name => event => {
    this.setState({
      [name]: event.target.value
    });
  };

  onLoginClick = () => {
    login(this.state).then(resp => {
      if (resp.status === 200) {
        this.props.history.push('/');
      }
    });
  };

  componentWillMount() {
    this.props.setTitle('Login');
  }

  render() {
    const { classes } = this.props;
    return (
      <div className={classes.container}>
        <TextField
          id="user"
          label="User"
          className={classes.textField}
          value={this.state.user}
          onChange={this.onInputChange('user')}
          margin="normal"
        />
        <TextField
          id="password"
          label="Password"
          className={classes.textField}
          value={this.state.password}
          onChange={this.onInputChange('password')}
          type="password"
          autoComplete="current-password"
          margin="normal"
        />
        <Button
          variant="raised"
          color="primary"
          className={classes.button}
          onClick={this.onLoginClick}
        >
          Login
        </Button>
      </div>
    );
  }
}
export default withStyles(styles)(Login);

这里有很多重要的 UI,但是可以忽略其中的大部分。重要的位是从api模块导入的login()函数。用于调用/api/login端点。从生产部署的角度来看,这是相关的,因为这是一个与服务的交互,服务可能被部署为自己的容器。

主页

如果您能够成功登录,您将被带到应用的主页。您应该会看到如下页面:

短信首页显示用户当前在线的联系人。在这种情况下,显然还没有其他用户在线。现在让我们来看看 PosiT00.组件源:

import React, { Component } from 'react';

import { withStyles } from 'material-ui/styles';
import Paper from 'material-ui/Paper';
import Avatar from 'material-ui/Avatar';
import IconButton from 'material-ui/IconButton';

import ContactMail from 'material-ui-icons/ContactMail';
import Message from 'material-ui-icons/Message';

import List, {
  ListItem,
  ListItemAvatar,
  ListItemText,
  ListItemSecondaryAction
} from 'material-ui/List';

import EmptyMessage from './EmptyMessage';
import { getContacts } from './api';

const styles = theme => ({
  root: {
    margin: '10px',
    width: '100%',
    maxWidth: 500,
    backgroundColor: theme.palette.background.paper
  }
});

class Home extends Component {
  state = {
    contacts: []
  };

  onMessageClick = id => () => {
    this.props.history.push(`/newmessage/${id}`);
  };

  componentWillMount() {
    const { setTitle, history } = this.props;

    setTitle('Barely SMS');

    const refresh = () =>
      getContacts().then(resp => {
        if (resp.status === 403) {
          history.push('/login');
        } else {
          resp.json().then(contacts => {
            this.setState({
              contacts: contacts.filter(contact => contact.online)
            });
          });
        }
      });

    this.refreshInterval = setInterval(refresh, 5000);
    refresh();
  }

  componentWillUnmount() {
    clearInterval(this.refreshInterval);
  }

  render() {
    const { classes } = this.props;
    const { contacts } = this.state;
    const { onMessageClick } = this;

    return (
      <Paper className={classes.root}>
        <EmptyMessage coll={contacts}>
          No contacts online
        </EmptyMessage>
        <List component="nav">
          {contacts.map(contact => (
            <ListItem key={contact.id}>
              <ListItemAvatar>
                <Avatar>
                  <ContactMail />
                </Avatar>
              </ListItemAvatar>
              <ListItemText primary={contact.name} />
              <ListItemSecondaryAction>
                <IconButton onClick={onMessageClick(contact.id)}>
                  <Message />
                </IconButton>
              </ListItemSecondaryAction>
            </ListItem>
          ))}
        </List>
      </Paper>
    );
  }
}

export default withStyles(styles)(Home);

componentWillMount()生命周期方法中,使用getContacts()函数获取 contacts API 端点。然后使用间隔重复此操作,以便当您的联系人登录时,他们将显示在此处。卸载组件时,间隔将被清除。

为了测试这一点,我将打开 Firefox(实际上使用哪种浏览器并不重要,只要它与您作为user1登录的位置不同)。从这里,我可以以user2的身份登录,这是user1的联系人,反之亦然:

我一登录到这里,就看到用户 1 在另一个浏览器中联机:

现在,如果我返回以用户 1 身份登录的 Chrome,我应该会看到我的用户 2 联系人已登录:

此应用将在其他页面上遵循类似的刷新模式,使用间隔从 API 服务端点获取数据。

联系人页面

如果您想查看所有联系人,而不仅仅是当前在线的联系人,则必须转到“联系人”页面。要到达目的地,您必须通过单击标题左侧的汉堡包按钮来展开导航菜单:

单击“联系人”链接时,您将进入“联系人”页面,如下所示:

此页面与主页非常相似,只是它显示所有联系人。您可以向任何用户发送消息,而不仅仅是当前在线的用户。让我们来看一看:

import React, { Component } from 'react';

import { withStyles } from 'material-ui/styles';
import Paper from 'material-ui/Paper';
import Avatar from 'material-ui/Avatar';
import IconButton from 'material-ui/IconButton';

import ContactMail from 'material-ui-icons/ContactMail';
import Message from 'material-ui-icons/Message';

import List, {
  ListItem,
  ListItemAvatar,
  ListItemText,
  ListItemSecondaryAction
} from 'material-ui/List';

import EmptyMessage from './EmptyMessage';
import { getContacts } from './api';

const styles = theme => ({
  root: {
    margin: '10px',
    width: '100%',
    maxWidth: 500,
    backgroundColor: theme.palette.background.paper
  }
});

class Contacts extends Component {
  state = {
    contacts: []
  };

  onMessageClick = id => () => {
    this.props.history.push(`/newmessage/${id}`);
  };

  componentWillMount() {
    const { setTitle, history } = this.props;

    setTitle('Contacts');

    const refresh = () =>
      getContacts().then(resp => {
        if (resp.status === 403) {
          history.push('/login');
        } else {
          resp.json().then(contacts => {
            this.setState({ contacts });
          });
        }
      });

    this.refreshInterval = setInterval(refresh, 5000);
    refresh();
  }

  componentWillUnmount() {
    clearInterval(this.refreshInterval);
  }

  render() {
    const { classes } = this.props;
    const { contacts } = this.state;
    const { onMessageClick } = this;

    return (
      <Paper className={classes.root}>
        <EmptyMessage coll={contacts}>No contacts</EmptyMessage>
        <List component="nav">
          {contacts.map(contact => (
            <ListItem key={contact.id}>
              <ListItemAvatar>
                <Avatar>
                  <ContactMail />
                </Avatar>
              </ListItemAvatar>
              <ListItemText primary={contact.name} />
              <ListItemSecondaryAction>
                <IconButton onClick={onMessageClick(contact.id)}>
                  <Message />
                </IconButton>
              </ListItemSecondaryAction>
            </ListItem>
          ))}
        </List>
      </Paper>
    );
  }
}

export default withStyles(styles)(Contacts);

Home组件一样,Contacts使用间隔模式刷新联系人。例如,将来如果您想在此页面上添加一个增强功能,以直观地指示哪些用户在线,则需要从您的服务中获取新数据。

消息页面

如果展开导航菜单并访问消息页面,您将看到如下内容:

还没有消息。在发送消息之前,让我们先看看 AUT0T0.组件:

import React, { Component } from 'react';
import moment from 'moment';
import { Link } from 'react-router-dom';

import { withStyles } from 'material-ui/styles';
import Paper from 'material-ui/Paper';
import Avatar from 'material-ui/Avatar';
import List, {
  ListItem,
  ListItemAvatar,
  ListItemText
} from 'material-ui/List';

import Message from 'material-ui-icons/Message';

import EmptyMessage from './EmptyMessage';
import { getMessages } from './api';

const styles = theme => ({
  root: {
    margin: '10px',
    width: '100%',
    maxWidth: 500,
    backgroundColor: theme.palette.background.paper
  }
});

class Messages extends Component {
  state = {
    messages: []
  };

  componentWillMount() {
    const { setTitle, history } = this.props;

    setTitle('Messages');

    const refresh = () =>
      getMessages().then(resp => {
        if (resp.status === 403) {
          history.push('/login');
        } else {
          resp.json().then(messages => {
            this.setState({
              messages: messages.map(message => ({
                ...message,
                duration: moment
                  .duration(new Date() - new Date(message.timestamp))
                  .humanize()
              }))
            });
          });
        }
      });

    this.refreshInterval = setInterval(refresh, 5000);
    refresh();
  }

  componentWillUnmount() {
    clearInterval(this.refreshInterval);
  }

  render() {
    const { classes } = this.props;
    const { messages } = this.state;

    return (
      <Paper className={classes.root}>
        <EmptyMessage coll={messages}>No messages</EmptyMessage>
        <List component="nav">
          {messages.map(message => (
            <ListItem
              key={message.id}
              component={Link}
              to={`/messages/${message.id}`}
            >
              <ListItemAvatar>
                <Avatar>
                  <Message />
                </Avatar>
              </ListItemAvatar>
              <ListItemText
                primary={message.fromName}
                secondary={`${message.duration} ago`}
              />
            </ListItem>
          ))}
        </List>
      </Paper>
    );
  }
}

export default withStyles(styles)(Messages);

同样,使用间隔刷新数据的相同模式也在这里。当用户单击其中一条消息时,他们会被带到消息详细信息页面,在那里他们可以阅读消息内容。

发送消息

让我们回到另一个浏览器(在我的例子中是 Firefox),在那里你以用户 2 的身份登录。单击用户 1 旁边的小消息图标:

这将带您进入新的消息页面:

继续并键入消息,然后按 SEND。然后,返回 Chrome,您将以用户 1 的身份登录。您应该会在用户 2 的消息页面上看到一条新消息:

如果您单击邮件,您应该能够阅读邮件内容:

从这里,您可以单击“回复”按钮进入新消息页面,该页面将发送给用户 2,或者您可以删除该消息。在查看 API 代码之前,让我们看一下 Type T0:Up 组件:

import React, { Component } from 'react';

import { withStyles } from 'material-ui/styles';
import Paper from 'material-ui/Paper';
import TextField from 'material-ui/TextField';
import Button from 'material-ui/Button';

import Send from 'material-ui-icons/Send';

import { getUser, postMessage } from './api';

const styles = theme => ({
  root: {
    display: 'flex',
    flexWrap: 'wrap',
    flexDirection: 'column'
  },
  textField: {
    marginLeft: theme.spacing.unit,
    marginRight: theme.spacing.unit,
    width: 500
  },
  button: {
    width: 500,
    margin: theme.spacing.unit
  },
  rightIcon: {
    marginLeft: theme.spacing.unit
  }
});

class NewMessage extends Component {
  state = {
    message: ''
  };

  onMessageChange = event => {
    this.setState({
      message: event.target.value
    });
  };

  onSendClick = () => {
    const { match: { params: { id } }, history } = this.props;
    const { message } = this.state;

    postMessage({ to: id, message }).then(() => {
      this.setState({ message: '' });
      history.push('/');
    });
  };

  componentWillMount() {
    const {
      match: { params: { id } },
      setTitle,
      history
    } = this.props;

    getUser(id).then(resp => {
      if (resp.status === 403) {
        history.push('/login');
      } else {
        resp.json().then(user => {
          setTitle(`New message for ${user.name}`);
        });
      }
    });
  }

  render() {
    const { classes } = this.props;
    const { message } = this.state;
    const { onMessageChange, onSendClick } = this;

    return (
      <Paper className={classes.root}>
        <TextField
          id="multiline-static"
          label="Message"
          multiline
          rows="4"
          className={classes.textField}
          margin="normal"
          value={message}
          onChange={onMessageChange}
        />
        <Button
          variant="raised"
          color="primary"
          className={classes.button}
          onClick={onSendClick}
        >
          Send
          <Send className={classes.rightIcon} />
        </Button>
      </Paper>
    );
  }
}

export default withStyles(styles)(NewMessage);

这里,postMessage()API 函数用于使用 API 服务发送消息。现在我们来看一下MessageDetails组件:

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

import { withStyles } from 'material-ui/styles'; 
import Paper from 'material-ui/Paper'; 
import Button from 'material-ui/Button'; 
import Typography from 'material-ui/Typography'; 

import Delete from 'material-ui-icons/Delete'; 
import Reply from 'material-ui-icons/Reply'; 

import { getMessage, deleteMessage } from './api'; 

const styles = theme => ({ 
  root: { 
    display: 'flex', 
    flexWrap: 'wrap', 
    flexDirection: 'column' 
  }, 
  message: { 
    width: 500, 
    margin: theme.spacing.unit 
  }, 
  button: { 
    width: 500, 
    margin: theme.spacing.unit 
  }, 
  rightIcon: { 
    marginLeft: theme.spacing.unit 
  } 
}); 

class NewMessage extends Component { 
  state = { 
    message: {} 
  }; 

  onDeleteClick = () => { 
    const { history, match: { params: { id } } } = this.props; 

    deleteMessage(id).then(() => { 
      history.push('/messages'); 
    }); 
  }; 

  componentWillMount() { 
    const { 
      match: { params: { id } }, 
      setTitle, 
      history 
    } = this.props; 

    getMessage(id).then(resp => { 
      if (resp.status === 403) { 
        history.push('/login'); 
      } else { 
        resp.json().then(message => { 
          setTitle(`Message from ${message.fromName}`); 
          this.setState({ message }); 
        }); 
      } 
    }); 
  } 

  render() { 
    const { classes } = this.props; 
    const { message } = this.state; 
    const { onDeleteClick } = this; 

    return ( 
      <Paper className={classes.root}> 
        <Typography className={classes.message}> 
          {message.message} 
        </Typography> 
        <Button 
          variant="raised" 
          color="primary" 
          className={classes.button} 
          component={Link} 
          to={`/newmessage/${message.from}`} 
        > 
          Reply 
          <Reply className={classes.rightIcon} /> 
        </Button> 
        <Button 
          variant="raised" 
          color="primary" 
          className={classes.button} 
          onClick={onDeleteClick} 
        > 
          Delete 
          <Delete className={classes.rightIcon} /> 
        </Button> 
      </Paper> 
    ); 
  } 
} 

export default withStyles(styles)(NewMessage); 

这里,getMessage()API 函数用于加载消息内容。请注意,这两个组件都没有使用与其他组件相同的刷新模式,因为信息从未更改。

API

API 是 React 应用与之交互以检索和操作数据的服务。在考虑部署生产应用时,重要的是使用 API 作为抽象,它不仅表示一个服务,还可能表示应用与之交互的多个微服务。

话虽如此,让我们看看组成SMS的 React 组件所使用的 API 函数:

export const login = body => 
  fetch('/api/login', { 
    method: 'post', 
    headers: { 'Content-Type': 'application/json' }, 
    body: JSON.stringify(body), 
    credentials: 'same-origin' 
  }); 

export const logout = user => 
  fetch('/api/logout', { 
    method: 'post', 
    credentials: 'same-origin' 
  }); 

export const getUser = id => 
  fetch(`/api/user/${id}`, { credentials: 'same-origin' }); 

export const getContacts = () => 
  fetch('/api/contacts', { credentials: 'same-origin' }); 

export const getMessages = () => 
  fetch('/api/messages', { credentials: 'same-origin' }); 

export const getMessage = id => 
  fetch(`/api/message/${id}`, { credentials: 'same-origin' }); 

export const postMessage = body => 
  fetch('/api/messages', { 
    method: 'post', 
    headers: { 'Content-Type': 'application/json' }, 
    body: JSON.stringify(body), 
    credentials: 'same-origin' 
  });

export const deleteMessage = id => 
  fetch(`/api/message/${id}`, { 
    method: 'delete', 
    credentials: 'same-origin' 
  }); 

这些简单的抽象使用fetch()向 API 服务发出 HTTP 请求。现在,只有一个 API 服务作为一个进程运行,它具有模拟用户数据,所有更改都发生在内存中,但没有任何内容被持久化:

const express = require('express'); 
const bodyParser = require('body-parser'); 
const cookieParser = require('cookie-parser'); 

const sessions = []; 
const messages = []; 
const users = { 
  user1: { 
    name: 'User 1', 
    contacts: ['user2', 'user3', 'user4', 'user5'], 
    online: false 
  }, 
  user2: { 
    name: 'User 2', 
    contacts: ['user1', 'user3', 'user4', 'user5'], 
    online: false 
  }, 
  user3: { 
    name: 'User 3', 
    contacts: ['user1', 'user2', 'user4', 'user5'], 
    online: false 
  }, 
  user4: { 
    name: 'User 4', 
    contacts: ['user1', 'user2', 'user3', 'user5'], 
    online: false 
  }, 
  user5: { 
    name: 'User 5', 
    contacts: ['user1', 'user2', 'user3', 'user4'] 
  } 
}; 

const authenticate = (req, res, next) => { 
  if (!sessions.includes(req.cookies.session)) { 
    res.status(403).end(); 
  } else { 
    next(); 
  } 
}; 

const app = express(); 
app.use(cookieParser()); 
app.use(bodyParser.json()); 
app.use(bodyParser.urlencoded({ extended: true })); 

app.post('/api/login', (req, res) => { 
  const { user } = req.body; 

  if (users.hasOwnProperty(user)) { 
    sessions.push(user); 
    users[user].online = true; 
    res.cookie('session', user); 
    res.end(); 
  } else { 
    res.status(403).end(); 
  } 
}); 

app.post('/api/logout', (req, res) => { 
  const { session } = req.cookies; 
  const index = sessions.indexOf(session); 

  sessions.splice(index, 1); 
  users[session].online = false; 

  res.clearCookie('session'); 
  res.status(200).end(); 
}); 

app.get('/api/user/:id', authenticate, (req, res) => { 
  res.json(users[req.params.id]); 
}); 

app.get('/api/contacts', authenticate, (req, res) => { 
  res.json( 
    users[req.cookies.session].contacts.map(id => ({ 
      id, 
      name: users[id].name, 
      online: users[id].online 
    })) 
  ); 
}); 

app.post('/api/messages', authenticate, (req, res) => { 
  messages.push({ 
    from: req.cookies.session, 
    fromName: users[req.cookies.session].name, 
    to: req.body.to, 
    message: req.body.message, 
    timestamp: new Date() 
  }); 

  res.status(201).end(); 
}); 

app.get('/api/messages', authenticate, (req, res) => { 
  res.json( 
    messages 
      .map((message, id) => ({ ...message, id })) 
      .filter(message => message.to === req.cookies.session) 
  ); 
}); 

app.get('/api/message/:id', authenticate, (req, res) => { 
  const { params: { id } } = req; 
  res.json({ ...messages[id], id }); 
}); 

app.delete('/api/message/:id', authenticate, (req, res) => { 
  messages.splice(req.params.id, 1); 
  res.status(200).end(); 
}); 

app.listen(3001, () => 
  console.log('API server listening on port 3001!') 
);

这是一个 Express 应用,它将应用数据保存在简单的 JavaScript 对象和数组中。虽然现在这一服务中发生了所有事情,但情况可能并非总是如此。其中一些 API 调用可能存在于不同的服务中。这就是为什么部署到容器如此强大,您可以在较高级别抽象复杂的部署。

开始使用节点容器

让我们首先在 Node.js Docker 映像中运行SMSReact dev 服务器。请注意,这不是生产部署的一部分。这只是您熟悉部署 Docker 容器的一个起点。随着我们在本章剩余部分的学习,您将稳步迈向生产级部署。

将 React 应用放入容器的第一步是创建一个Dockerfile。如果您的系统上尚未安装 Docker,请在此处找到它以及安装说明:https://www.docker.com/community-edition 。如果您打开一个终端并切换到getting-started-with-containers目录,您将看到一个名为Dockerfile的文件。下面是它的样子:

FROM node:alpine
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD [ "npm", "start" ]

这是用于构建图像的文件。映像就像运行 React 应用的容器进程的模板。基本上,这些行执行以下操作:

  • FROM node:alpine:此图像使用的基本图像是什么。这是一个小版本的 Linux,上面有 Node.js。
  • WORKDIR /usr/src/app:更改容器上的工作目录。
  • COPY package*.json ./:将package.jsonpackage-lock.json复制到容器中。
  • RUN npm install:在容器上安装 npm 包依赖项。
  • COPY . .:将应用的源代码复制到容器中。
  • EXPOSE 3000:在容器运行时暴露端口3000
  • CMD [ "npm", "start" ]:容器启动时运行npm start

您要添加的下一个文件是.dockerignore文件。此文件列出了您不希望通过COPY命令包含在图像中的所有内容。下面是它的样子:

node_modules
npm-debug.log

重要的是,不要复制您可能已在系统上安装的npm_modules,因为npm install命令将再次安装它们,并且您将拥有两个 LIB 副本。

在构建可以部署的 Docker 映像之前,需要做一些小的更改。首先,您需要弄清楚您的 IP 地址是什么,以便可以使用它与 API 服务器通信。您可以在终端运行ifconfig来找到它。一旦有了它,就可以更新package.json中的proxy值。过去是:

http://localhost:3001

现在它应该有一个 IP 地址,以便 Docker 容器在运行时可以访问它。我的是这样的:

http://192.168.86.237:3001

接下来,您需要将 IP 作为参数传递给server.js中的listen()方法。过去是:

app.listen(3001, () => 
  console.log('API server listening on port 3001!') 
); 

下面是我现在的样子:

app.listen(3001, '192.168.86.237', () => 
  console.log('API server listening on port 3001!') 
); 

现在,您可以通过运行以下命令来构建 Docker 映像:

docker build -t barely-sms-ui . 

这将使用当前目录中的Dockerfile创建 ID 为barely-sms-ui的映像。一旦建立,您可以通过运行docker images来查看图像。输出应如下所示:

REPOSITORY       TAG      IMAGE ID       CREATED       SIZE
barely-sms-ui    latest   b1526915598d   7 hours ago   267MB

现在,您可以使用以下命令部署容器:

docker run -p 3000:3000 barely-sms-ui

要清理旧的未使用容器,可以运行以下命令:

docker system prune

-p 3000:3000参数确保容器上公开的端口3000映射到系统上的端口3000。您可以通过打开http://localhost:3000/进行测试。但是,您可能会看到如下错误:

如果查看容器控制台输出,您将看到如下内容:

    Proxy error: Could not proxy request /api/contacts from localhost:3000 to http://192.168.86.237:3001.
    See https://nodejs.org/api/errors.html#errors_common_system_errors for more information (ECONNREFUSED).

这是因为您尚未启动 API 服务器。如果将无效的 IP 地址作为代理地址,实际上会看到类似的错误。如果出于任何原因已经更改或需要更改代理值,则必须重新生成映像,然后重新启动容器。如果您通过在另一个终端上运行npm run api来启动 API,然后重新加载 UI,那么一切都应该按照预期工作。

使用服务组合 React 应用

上一节的主要挑战是,您有一个作为运行容器的独立用户界面服务。另一方面,API 服务正在做自己的事情。您将学习如何使用的下一个工具是docker-compose。顾名思义,docker-compose就是如何将较小的服务组合成较大的应用。SMS的下一个自然步骤是使用此 Docker 工具创建 API 服务,并将这两个服务作为一个应用进行控制。

这次,我们需要两个Dockerfile文件。您可以重用上一节中的Dockerfile,只需将其重命名为Dockerfile.ui。然后,创建另一个几乎相同的Dockerfile称之为Dockerfile.api,并给出以下内容:

FROM node:alpine
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3001
CMD [ "npm", "run", "api" ]

这两个差异是EXPOSE端口值和运行的CMD。此命令启动 API 服务器而不是 React 开发服务器。

在构建图像之前,server.jspackage.js文件需要稍微调整。在package.json中,代理可以简单地指向http://api:3001。在server.js中,确保您不再将特定 IP 地址传递给listen()

app.listen(3001, () => 
  console.log('API server listening on port 3001!') 
); 

构建这两个图像也需要稍加修改,因为您不再使用Dockerfile的标准名称。以下是如何构建 UI 映像:

docker build -f Dockerfile.ui -t barely-sms-ui . 

然后,构建 API 映像:

docker build -f Dockerfile.api -t barely-sms-api .

此时,您已经准备好创建一个docker-compose.yml。这就是您如何声明调用docker-compose工具时应该执行的操作。下面是它的样子:

api:
  image: barely-sms-api
  expose:
    - 3001
  ports:
    - "3001:3001"

ui:
  image: barely-sms-ui
  expose:
    - 3000
  links:
    - api
  ports:
    - "3000:3000"

正如您所看到的,这个 YAML 标记清楚地分为两个服务。首先是api服务,它指向barely-sms-api图像并相应地映射端口。然后,还有ui服务,除了指向barely-sms-ui图像并映射到不同的端口之外,它做了相同的事情。它还链接到 API 服务,因为您希望在任何浏览器中加载 UI 之前确保 API 服务可用。

要启动服务,可以运行以下命令:

docker-compose up

然后,您应该在控制台中看到来自服务的日志。然后,如果你访问http://localhost:3000/,你应该能够像平常一样使用短信,除了这次,一切都是自给自足的。从这一点开始,随着需求的发展,您可以更好地扩展应用。如有必要,您可以添加新服务,并让您的 React 组件像与同一应用对话一样与它们对话,同时保持服务模块化和自包含。

用于生产的静态 React 构建

使SMS为生产部署做好准备的最后一步是从 UI 服务中删除 React development server。开发服务器从来没有打算用于生产,因为它有许多部件可以帮助开发人员,但最终会降低总体用户体验,并且在生产环境中没有位置。

您可以使用一个简单的 NGINX HTTP 服务器来提供静态内容,而不是使用基于 Node.js 的映像。由于这是一个生产环境,您不需要动态构建 UI 资产的开发服务器,您只需使用create-react-app构建脚本为 NGINX 构建静态构件即可:

npm run build

然后,您可以更改Dockerfile.ui文件,使其如下所示:

FROM nginx:alpine 
EXPOSE 3000 
COPY nginx.conf /etc/nginx/nginx.conf 
COPY build /data/www 
CMD ["nginx", "-g", "daemon off;"] 

这一次,图像是基于提供静态内容的 NGINX 服务器的,我们将向其传递一个nginx.conf文件。下面是它的样子:

worker_processes 2; 

events { 
  worker_connections 2048; 
} 

http { 
  upstream service_api { 
    server api:3001; 
  } 

  server { 
    location / { 
      root /data/www; 
      try_files $uri /index.html; 
    } 

    location /api { 
      proxy_pass http://service_api; 
    } 
  } 
} 

在这里,您可以对 HTTP 请求的发送位置进行细粒度的控制。例如,如果/api/login/api/logout端点被移动到它们自己的服务,您可以在这里控制此更改,而不必重建 UI 映像。

最后需要做的更改是docker-compose.yml

api: 
  image: barely-sms-api 
  expose: 
    - 3001 
  ports: 
    - "3001:3001" 

ui: 
  image: barely-sms-ui 
  expose: 
    - 80 
  links: 
    - api 
  ports: 
    - "3000:80" 

您注意到端口3000现在映射到ui服务中的端口80了吗?这是因为 NGINX 服务于端口80。如果您运行docker-compose up,您应该能够访问http://localhost:3000/并与您的静态构建交互。

祝贺没有了 React development server,您就可以从构建工具的角度为生产做好准备了。

总结

在本章中,您构建了一个名为的简单短信应用。然后,您学习了如何将此应用部署为 Docker 容器。然后,您学习了如何将服务(包括 UI 服务)打包在一起,以便在部署具有许多移动部件的应用时有更高级别的抽象。最后,您学习了如何构建可用于生产的静态资产,并使用工业级 HTTP 服务器 NGINX 为其提供服务。

我希望这是一本启发性的读物。写作既是一种挑战,也是一种乐趣。web 开发中的工具不应该像过去十年那样困难。React 等项目和 Chrome 等浏览器供应商正开始改变这一趋势。我相信任何技术都只会和它的工具一样好。现在,您对 React 生态系统中可用的工具有了一个坚定的把握,请好好利用它,让它为您完成艰巨的工作。