从头开始制作现代Web应用程序

因此,您决定创建一个新项目。 这个项目是一个Web应用程序。 创建基本原型需要多少时间? 有多难? 现代网站从一开始就应该做什么?

在本文中,我们将尝试概述具有以下体系结构的简单Web应用程序的样板:


我们将介绍的内容:

  • 在docker-compose中设置开发环境。
  • Flask上的后端创建。
  • 在Express上创建前端。
  • 使用Webpack构建JS。
  • React,Redux和服务器端渲染。
  • 具有RQ的任务队列。

引言


当然,在开发之前,您首先需要确定我们在开发什么! 作为本文的模型应用程序,我决定制作一个原始的Wiki引擎。 我们将在Markdown发行卡; 可以观看它们,并(在将来的某个时候)进行编辑。 我们将所有这些安排为具有服务器端呈现的一页应用程序(这对于索引我们未来的TB级内容绝对必要)。

让我们更详细地了解为此所需的组件:

  • 顾客 让我们在React + Redux包中创建一个单页应用程序(即使用AJAX进行页面转换),这在前端世界中很常见。
  • 前端 。 让我们创建一个简单的Express服务器,该服务器将呈现我们的React应用程序(异步请求后端中的所有必要数据)并将其发布给用户。
  • 后端 。 掌握业务逻辑,我们的后端将是一个小的Flask应用程序。 我们将数据(我们的卡)存储在流行的MongoDB文档存储库中,并且对于任务队列以及将来可能的缓存,我们将使用Redis
  • 工作人员RQ库将启动一个单独的容器来处理繁重的任务。

基础设施:git


可能我们不会谈论这件事,但是,当然,我们将在git存储库中进行开发。

git init git remote add origin git@github.com:Saluev/habr-app-demo.git git commit --allow-empty -m "Initial commit" git push 

(在这里,您应该立即填写.gitignore 。)

最终草案可以在Github上查看。 本文的每一节都对应一个提交(为实现这一目标,我大功告成!)。

基础架构:docker-compose


让我们从设置环境开始。 有了我们拥有的大量组件,非常合乎逻辑的开发解决方案是使用docker-compose。

docker-compose.yml文件添加到docker-compose.yml以下内容的存储库中:

 version: '3' services: mongo: image: "mongo:latest" redis: image: "redis:alpine" backend: build: context: . dockerfile: ./docker/backend/Dockerfile environment: - APP_ENV=dev depends_on: - mongo - redis ports: - "40001:40001" volumes: - .:/code frontend: build: context: . dockerfile: ./docker/frontend/Dockerfile environment: - APP_ENV=dev - APP_BACKEND_URL=backend:40001 - APP_FRONTEND_PORT=40002 depends_on: - backend ports: - "40002:40002" volumes: - ./frontend:/app/src worker: build: context: . dockerfile: ./docker/worker/Dockerfile environment: - APP_ENV=dev depends_on: - mongo - redis volumes: - .:/code 

让我们快速看一下这里发生的事情。

  • 将创建一个MongoDB容器和一个Redis容器。
  • 创建了一个用于后端的容器(我们将在下面描述)。 将环境变量APP_ENV = dev传递给它(我们将查看它以了解要加载的Flask设置),并且其端口40001将在外部打开(通过它,我们的浏览器客户端将转到API)。
  • 创建了我们前端的容器。 各种各样的环境变量也被扔进去,以后对我们有用,端口40002打开,这是Web应用程序的主要端口:在浏览器中,我们将转到http:// localhost:40002
  • 我们工人的容器已创建。 他不需要外部端口,在MongoDB和Redis中仅需要访问。

现在让我们创建dockerfile。 目前,Habré即将提供一系列有关Docker 优秀 文章 翻译 -您可以放心前往所有细节。

让我们从后端开始。

 # docker/backend/Dockerfile FROM python:stretch COPY requirements.txt /tmp/ RUN pip install -r /tmp/requirements.txt ADD . /code WORKDIR /code CMD gunicorn -w 1 -b 0.0.0.0:40001 --worker-class gevent backend.server:app 

可以理解,我们遍历了gunicorn Flask应用程序,该应用程序隐藏在backend.server模块中的名称app下。

同样重要的docker/backend/.dockerignore

 .git .idea .logs .pytest_cache frontend tests venv *.pyc *.pyo 

worker通常与后端相似,只是我们通常会启动pit模块而不是gunicorn:

 # docker/worker/Dockerfile FROM python:stretch COPY requirements.txt /tmp/ RUN pip install -r /tmp/requirements.txt ADD . /code WORKDIR /code CMD python -m worker 

我们将在worker/__main__.py完成所有工作。

.dockerignore工作程序与.dockerignore后端完全相似。

最后,前端。 在Habré上有关于他的整篇文章 ,但根据对StackOverflow广泛讨论和本着“你们已经是2018年了,仍然没有正常解决方案吗?”精神的评论来判断。 那里的一切都不那么简单。 我选择了此版本的docker文件。

 # docker/frontend/Dockerfile FROM node:carbon WORKDIR /app #  package.json  package-lock.json   npm install,   . COPY frontend/package*.json ./ RUN npm install #       , #     PATH. ENV PATH /app/node_modules/.bin:$PATH #      . ADD frontend /app/src WORKDIR /app/src RUN npm run build CMD npm run start 

优点:

  • 一切都按预期进行了缓存(在底层-依赖关系,在顶层-应用程序的构建);
  • docker-compose exec frontend npm install --save newDependency可以正常工作,并且可以修改存储库中的package.json (很多人建议,如果使用COPY则不会这样)。 无论如何都不希望在容器外部运行npm install --save newDependency ,因为新软件包的某些依赖项可能已经存在并在另一个平台下构建(例如,在npm install --save newDependency内部的一个平台下,而不是在我们工作的macbook之下) ),但我们通常不希望开发计算机上存在Node。 一个Docker来统治他们!

好吧,当然还有docker/frontend/.dockerignore

 .git .idea .logs .pytest_cache backend worker tools node_modules npm-debug tests venv 

因此,我们的容器框架已准备就绪,您可以将其装满内容!

后端:Flask框架


flaskflask-corsgeventgunicornrequirements.txt并在backend/server.py创建一个简单的Flask应用程序。

 # backend/server.py import os.path import flask import flask_cors class HabrAppDemo(flask.Flask): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # CORS        #    ,      # (  Access-Control-Origin  ). #   - . flask_cors.CORS(self) app = HabrAppDemo("habr-app-demo") env = os.environ.get("APP_ENV", "dev") print(f"Starting application in {env} mode") app.config.from_object(f"backend.{env}_settings") 

我们告诉Flask从backend.{env}_settings文件backend.{env}_settings拉出设置,这意味着我们还需要创建一个(至少为空)文件backend/dev_settings.py ,以使一切腾飞。

现在我们可以正式提升后端了!

 habr-app-demo$ docker-compose up backend ... backend_1 | [2019-02-23 10:09:03 +0000] [6] [INFO] Starting gunicorn 19.9.0 backend_1 | [2019-02-23 10:09:03 +0000] [6] [INFO] Listening at: http://0.0.0.0:40001 (6) backend_1 | [2019-02-23 10:09:03 +0000] [6] [INFO] Using worker: gevent backend_1 | [2019-02-23 10:09:03 +0000] [9] [INFO] Booting worker with pid: 9 

我们继续前进。

前端:Express框架


让我们从创建一个包开始。 创建了前端文件夹并在其中运行npm init之后,经过一些简单的问题,我们最终得到了完整的package.json

 { "name": "habr-app-demo", "version": "0.0.1", "description": "This is an app demo for Habr article.", "main": "index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "repository": { "type": "git", "url": "git+https://github.com/Saluev/habr-app-demo.git" }, "author": "Tigran Saluev <tigran@saluev.com>", "license": "MIT", "bugs": { "url": "https://github.com/Saluev/habr-app-demo/issues" }, "homepage": "https://github.com/Saluev/habr-app-demo#readme" } 

将来,我们在开发人员的机器上根本不需要Node.js(尽管我们仍然可以通过Docker闪避并启动npm init ,但是很好)。

Dockerfile我们提到了npm run buildnpm run start Dockerfile您需要将适当的命令添加到package.json

 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,6 +4,8 @@ "description": "This is an app demo for Habr article.", "main": "index.js", "scripts": { + "build": "echo 'build'", + "start": "node index.js", "test": "echo \"Error: no test specified\" && exit 1" }, "repository": { 

build命令什么也没做,但是对我们仍然有用。

添加Express依赖项并在index.js创建一个简单的应用程序:

 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,5 +17,8 @@ "bugs": { "url": "https://github.com/Saluev/habr-app-demo/issues" }, - "homepage": "https://github.com/Saluev/habr-app-demo#readme" + "homepage": "https://github.com/Saluev/habr-app-demo#readme", + "dependencies": { + "express": "^4.16.3" + } } 

 // frontend/index.js const express = require("express"); app = express(); app.listen(process.env.APP_FRONTEND_PORT); app.get("*", (req, res) => { res.send("Hello, world!") }); 

现在, docker-compose up frontend提升了我们的前端! 此外,在http:// localhost:40002上 ,经典的“ Hello,world”应该已经展示出来了。

前端:使用webpack和React应用程序构建


现在是时候在我们的应用程序中描绘比纯文本更多的东西了。 在本节中,我们将添加App的最简单的React组件并配置程序集。

在React中进行编程时,使用JSX非常方便, JSX是一种JavaScript的方言,通过形式的语法构造得到扩展

 render() { return <MyButton color="blue">{this.props.caption}</MyButton>; } 

但是,JavaScript引擎不了解它,因此通常将构建阶段添加到前端。 特殊的JavaScript编译器(yeah-yeah)将语法糖转换为丑陋的经典JavaScript,处理导入,缩小等操作。



2014年。 apt-cache搜索Java

因此,最简单的React组件看起来非常简单。

 // frontend/src/components/app.js import React, {Component} from 'react' class App extends Component { render() { return <h1>Hello, world!</h1> } } export default App 

他只会用更具说服力的图钉向我们表示问候。

添加文件frontend/src/template.js其中包含我们未来应用程序的最小HTML框架:

 // frontend/src/template.js export default function template(title) { let page = ` <!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <title>${title}</title> </head> <body> <div id="app"></div> <script src="/dist/client.js"></script> </body> </html> `; return page; } 

添加客户端入口点:

 // frontend/src/client.js import React from 'react' import {render} from 'react-dom' import App from './components/app' render( <App/>, document.querySelector('#app') ); 

要打造所有这些美丽,我们需要:

webpack是JS的时尚青年构建器 (尽管我三个小时都没有在前端阅读文章,所以我不确定时尚);
babel是适用于各种乳液(如JSX)的编译器,同时是适用于所有IE外壳的polyfill提供程序。

如果前端的上一个迭代仍在运行,那么您要做的就是

 docker-compose exec frontend npm install --save \ react \ react-dom docker-compose exec frontend npm install --save-dev \ webpack \ webpack-cli \ babel-loader \ @babel/core \ @babel/polyfill \ @babel/preset-env \ @babel/preset-react 

安装新的依赖项。 现在配置webpack:

 // frontend/webpack.config.js const path = require("path"); //  . clientConfig = { mode: "development", entry: { client: ["./src/client.js", "@babel/polyfill"] }, output: { path: path.resolve(__dirname, "../dist"), filename: "[name].js" }, module: { rules: [ { test: /\.js$/, exclude: /node_modules/, loader: "babel-loader" } ] } }; //  .     : // 1. target: "node" -      import path. // 2.   ..,    ../dist --   //    ,   ! serverConfig = { mode: "development", target: "node", entry: { server: ["./index.js", "@babel/polyfill"] }, output: { path: path.resolve(__dirname, ".."), filename: "[name].js" }, module: { rules: [ { test: /\.js$/, exclude: /node_modules/, loader: "babel-loader" } ] } }; module.exports = [clientConfig, serverConfig]; 

要使babel正常工作,您需要配置frontend/.babelrc

 { "presets": ["@babel/env", "@babel/react"] } 

最后,使我们的npm run build命令有意义:

 // frontend/package.json ... "scripts": { "build": "webpack", "start": "node /app/server.js", "test": "echo \"Error: no test specified\" && exit 1" }, ... 

现在,我们的客户端以及../dist/client.js及其所有依赖项,通过babel运行,编译并折叠成一个整体的缩小文件../dist/client.js 。 添加将其作为静态文件上传到我们的Express应用程序的功能,并且在默认路由中,我们将开始返回HTML:

 // frontend/index.js // ,    , //  - . import express from 'express' import template from './src/template' let app = express(); app.use('/dist', express.static('../dist')); app.get("*", (req, res) => { res.send(template("Habr demo app")); }); app.listen(process.env.APP_FRONTEND_PORT); 

成功! 现在,如果我们运行docker-compose up --build frontend ,我们将看到“ Hello,world!” 在一个新的闪亮包装中,如果您安装了React开发人员工具扩展( ChromeFirefox ),那么开发人员工具中还会有一个React组件树:



后端:MongoDB中的数据


在继续前进并为我们的应用程序注入生命之前,您必须首先将其呼吸到后端。 似乎我们要存储在Markdown中标记的卡片-是时候这样做了。

尽管python中有用于MongoDB的ORM ,但我认为对ORM的使用是恶意的,因此我将适当的解决方案的研究交给您。 相反,我们将为卡片和随附的DAO做一个简单的类:

 # backend/storage/card.py import abc from typing import Iterable class Card(object): def __init__(self, id: str = None, slug: str = None, name: str = None, markdown: str = None, html: str = None): self.id = id self.slug = slug #    self.name = name self.markdown = markdown self.html = html class CardDAO(object, metaclass=abc.ABCMeta): @abc.abstractmethod def create(self, card: Card) -> Card: pass @abc.abstractmethod def update(self, card: Card) -> Card: pass @abc.abstractmethod def get_all(self) -> Iterable[Card]: pass @abc.abstractmethod def get_by_id(self, card_id: str) -> Card: pass @abc.abstractmethod def get_by_slug(self, slug: str) -> Card: pass class CardNotFound(Exception): pass 

(如果您仍未在Python中使用类型注释,请务必查看这些 文章 !)

现在,让我们创建CardDAO接口的实现,该实现将CardDAO中的Database对象pymongo (是的,是将pymongo添加到requirements.txt ):

 # backend/storage/card_impl.py from typing import Iterable import bson import bson.errors from pymongo.collection import Collection from pymongo.database import Database from backend.storage.card import Card, CardDAO, CardNotFound class MongoCardDAO(CardDAO): def __init__(self, mongo_database: Database): self.mongo_database = mongo_database # , slug   . self.collection.create_index("slug", unique=True) @property def collection(self) -> Collection: return self.mongo_database["cards"] @classmethod def to_bson(cls, card: Card): # MongoDB     BSON.  #       BSON- #  ,      . result = { k: v for k, v in card.__dict__.items() if v is not None } if "id" in result: result["_id"] = bson.ObjectId(result.pop("id")) return result @classmethod def from_bson(cls, document) -> Card: #   ,     #     ,     #  .    id    # ,   -   . document["id"] = str(document.pop("_id")) return Card(**document) def create(self, card: Card) -> Card: card.id = str(self.collection.insert_one(self.to_bson(card)).inserted_id) return card def update(self, card: Card) -> Card: card_id = bson.ObjectId(card.id) self.collection.update_one({"_id": card_id}, {"$set": self.to_bson(card)}) return card def get_all(self) -> Iterable[Card]: for document in self.collection.find(): yield self.from_bson(document) def get_by_id(self, card_id: str) -> Card: return self._get_by_query({"_id": bson.ObjectId(card_id)}) def get_by_slug(self, slug: str) -> Card: return self._get_by_query({"slug": slug}) def _get_by_query(self, query) -> Card: document = self.collection.find_one(query) if document is None: raise CardNotFound() return self.from_bson(document) 

是时候在后端设置中注册Monga配置了。 我们只是用mongo mongo命名了我们的容器,所以MONGO_HOST = "mongo"

 --- a/backend/dev_settings.py +++ b/backend/dev_settings.py @@ -0,0 +1,3 @@ +MONGO_HOST = "mongo" +MONGO_PORT = 27017 +MONGO_DATABASE = "core" 

现在我们需要创建MongoCardDAO并为Flask应用程序提供访问权限。 尽管现在我们有了一个非常简单的对象层次结构(设置→pymongo客户端→pymongo数据库→ MongoCardDAO ),但让我们立即创建一个进行依赖项注入的集中式King组件(当我们处理worker和工具时,它将再次派上用场)。

 # backend/wiring.py import os from pymongo import MongoClient from pymongo.database import Database import backend.dev_settings from backend.storage.card import CardDAO from backend.storage.card_impl import MongoCardDAO class Wiring(object): def __init__(self, env=None): if env is None: env = os.environ.get("APP_ENV", "dev") self.settings = { "dev": backend.dev_settings, # (    # ,   !) }[env] #        . #        DI,  . self.mongo_client: MongoClient = MongoClient( host=self.settings.MONGO_HOST, port=self.settings.MONGO_PORT) self.mongo_database: Database = self.mongo_client[self.settings.MONGO_DATABASE] self.card_dao: CardDAO = MongoCardDAO(self.mongo_database) 


是时候为Flask应用程序添加新路线并欣赏美景了!

 # backend/server.py import os.path import flask import flask_cors from backend.storage.card import CardNotFound from backend.wiring import Wiring env = os.environ.get("APP_ENV", "dev") print(f"Starting application in {env} mode") class HabrAppDemo(flask.Flask): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) flask_cors.CORS(self) self.wiring = Wiring(env) self.route("/api/v1/card/<card_id_or_slug>")(self.card) def card(self, card_id_or_slug): try: card = self.wiring.card_dao.get_by_slug(card_id_or_slug) except CardNotFound: try: card = self.wiring.card_dao.get_by_id(card_id_or_slug) except (CardNotFound, ValueError): return flask.abort(404) return flask.jsonify({ k: v for k, v in card.__dict__.items() if v is not None }) app = HabrAppDemo("habr-app-demo") app.config.from_object(f"backend.{env}_settings") 

重新启动docker-compose up --build backend



糟糕...哦,是的。 我们需要添加内容! 我们将打开tools文件夹,并在其中添加一个脚本,以添加一个测试卡:

 # tools/add_test_content.py from backend.storage.card import Card from backend.wiring import Wiring wiring = Wiring() wiring.card_dao.create(Card( slug="helloworld", name="Hello, world!", markdown=""" This is a hello-world page. """)) 

docker-compose exec backend python -m tools.add_test_contentdocker-compose exec backend python -m tools.add_test_content容器内部的内容填充我们的docker-compose exec backend python -m tools.add_test_content



成功! 现在是时候在前端提供支持了。

前端:Redux


现在我们要制作route /card/:id_or_slug ,通过它我们的React应用程序将打开,从API加载card数据并以某种方式向我们展示。 在这里,也许是最困难的部分开始了,因为我们希望服务器立即为我们提供包含卡片内容的HTML,适合索引,但是与此同时,当应用程序在卡片之间导航时,它会从API接收JSON形式的所有数据,并且页面不会过载。 这样,所有这些-无需复制粘贴!

让我们从添加Redux开始。 Redux是一个用于存储状态的JavaScript库。 这个想法是,您的组件具有一个集中的状态,而不是通过在用户操作和其他有趣事件期间发生变化的上千个隐式状态,并通过集中的操作机制进行任何更改。 因此,如果较早进行导航,我们首先打开了加载的GIF,然后通过AJAX发出了请求,最后在成功回调中,我们更新了页面的必要部分,然后在Redux范式中,我们被邀请发送动作“将内容更改为带有动画的gif”。将更改全局状态,以便您的一个组件将丢弃先前的内容并放入动画,然后发出请求,并在其成功回调中发送另一个操作,“将内容更改为已加载”。 总的来说,现在我们将自己看到它。

让我们从在容器中安装新的依赖关系开始。

 docker-compose exec frontend npm install --save \ redux \ react-redux \ redux-thunk \ redux-devtools-extension 

实际上,第一个是Redux,第二个是用于跨React和Redux的特殊库(由交配专家编写),第三个是非常必要的,在README中有充分的理由,最后,第四个是Redux DevTools工作所需的库扩展名

让我们从样板Redux代码开始:创建一个不执行任何操作的reducer并初始化状态。

 // frontend/src/redux/reducers.js export default function root(state = {}, action) { return state; } 

 // frontend/src/redux/configureStore.js import {createStore, applyMiddleware} from "redux"; import thunkMiddleware from "redux-thunk"; import {composeWithDevTools} from "redux-devtools-extension"; import rootReducer from "./reducers"; export default function configureStore(initialState) { return createStore( rootReducer, initialState, composeWithDevTools(applyMiddleware(thunkMiddleware)), ); } 

我们的客户有所改变,在心理上准备与Redux合作:

 // frontend/src/client.js import React from 'react' import {render} from 'react-dom' import {Provider} from 'react-redux' import App from './components/app' import configureStore from './redux/configureStore' //      ... const store = configureStore(); render( // ...      , //     <Provider store={store}> <App/> </Provider>, document.querySelector('#app') ); 

现在我们可以运行docker-compose up --build frontend来确保没有任何损坏,并且我们的原始状态已经出现在Redux DevTools中:



前端:卡页


在制作带有SSR的页面之前,您需要制作没有SSR的页面! 最后,让我们使用我们独创的API来访问卡并在前端组成卡页面。

是时候利用情报并重新设计我们的状态结构了。 关于此主题材料很多 ,因此我建议不要滥用智力,而将重点放在简单性上。 例如:

 { "page": { "type": "card", //     //       type=card: "cardSlug": "...", //     "isFetching": false, //      API "cardData": {...}, //   (  ) // ... }, // ... } 

让我们获得“卡片”组件,该组件将cardData的内容作为道具(实际上是mongo中我们卡片的内容):

 // frontend/src/components/card.js import React, {Component} from 'react'; class Card extends Component { componentDidMount() { document.title = this.props.name } render() { const {name, html} = this.props; return ( <div> <h1>{name}</h1> <!---,  HTML  React  - !--> <div dangerouslySetInnerHTML={{__html: html}}/> </div> ); } } export default Card; 

现在,让我们使用卡片获取整个页面的组件。 他将负责从API中获取必要的数据并将其传输到Card。 我们将以React-Redux方式进行数据获取。

首先,创建文件frontend/src/redux/actions.js并创建一个从API中提取卡中内容的操作(如果尚未创建的话):

 export function fetchCardIfNeeded() { return (dispatch, getState) => { let state = getState().page; if (state.cardData === undefined || state.cardData.slug !== state.cardSlug) { return dispatch(fetchCard()); } }; } 

fetchCard操作实际上使获取工作稍微复杂一些:

 function fetchCard() { return (dispatch, getState) => { //    ,    . //     , , //    . dispatch(startFetchingCard()); //    API. let url = apiPath() + "/card/" + getState().page.cardSlug; // , ,   ,  //    . , ,  //    . return fetch(url) .then(response => response.json()) .then(json => dispatch(finishFetchingCard(json))); }; // ,  redux-thunk   //     . } function startFetchingCard() { return { type: START_FETCHING_CARD }; } function finishFetchingCard(json) { return { type: FINISH_FETCHING_CARD, cardData: json }; } function apiPath() { //    .    server-side // rendering,   API     -  //         localhost, //   backend. return "http://localhost:40001/api/v1"; } 

哦,我们采取了某种措施!在reducer中必须支持:

 // frontend/src/redux/reducers.js import { START_FETCHING_CARD, FINISH_FETCHING_CARD } from "./actions"; export default function root(state = {}, action) { switch (action.type) { case START_FETCHING_CARD: return { ...state, page: { ...state.page, isFetching: true } }; case FINISH_FETCHING_CARD: return { ...state, page: { ...state.page, isFetching: false, cardData: action.cardData } } } return state; } 

(请注意用于通过更改单个字段来克隆对象的流行语法。)

既然所有逻辑都是在Redux操作中执行的,则组件本身CardPage将看起来相对简单:

 // frontend/src/components/cardPage.js import React, {Component} from 'react'; import {connect} from 'react-redux' import {fetchCardIfNeeded} from '../redux/actions' import Card from './card' class CardPage extends Component { componentWillMount() { //   ,  React  //   .      //   ,    " // "   ,    //  - .    -   //       HTML  // renderToString,      SSR. this.props.dispatch(fetchCardIfNeeded()) } render() { const {isFetching, cardData} = this.props; return ( <div> {isFetching && <h2>Loading...</h2>} {cardData && <Card {...cardData}/>} </div> ); } } //       ,   //  .        //  react-redux.   page    //  dispatch,   . function mapStateToProps(state) { const {page} = state; return page; } export default connect(mapStateToProps)(CardPage); 

在我们的根App组件中添加一个简单的page.type处理:

 // frontend/src/components/app.js import React, {Component} from 'react' import {connect} from "react-redux"; import CardPage from "./cardPage" class App extends Component { render() { const {pageType} = this.props; return ( <div> {pageType === "card" && <CardPage/>} </div> ); } } function mapStateToProps(state) { const {page} = state; const {type} = page; return { pageType: type }; } export default connect(mapStateToProps)(App); 

现在,最后一刻仍然存在-您需要以某种方式进行初始化page.typepage.cardSlug具体取决于页面的URL。

但是本文仍然有很多部分,但是我们现在无法提供高质量的解决方案。让我们暂时做它。那完全是愚蠢的。例如,初始化应用程序时的常规!

 // frontend/src/client.js import React from 'react' import {render} from 'react-dom' import {Provider} from 'react-redux' import App from './components/app' import configureStore from './redux/configureStore' let initialState = { page: { type: "home" } }; const m = /^\/card\/([^\/]+)$/.exec(location.pathname); if (m !== null) { initialState = { page: { type: "card", cardSlug: m[1] }, } } const store = configureStore(initialState); render( <Provider store={store}> <App/> </Provider>, document.querySelector('#app') ); 

现在我们可以在帮助下重建前端,docker-compose up --build frontend以享用我们的卡了helloworld……



等等,等等……我们的内容在哪里?哦,我们忘了解析Markdown!

工人:RQ


解析Markdown并为可能无限制大小的卡生成HTML是一项典型的“繁重”任务,该任务通常在单独的工作机上排队并执行,而不是在保存更改时直接在后端解决。

任务队列有许多开源实现。我们将使用Redis和一个简单的库RQ(Redis Queue),该pickle格式传输任务参数,并组织我们的产卵过程进行处理。

是时候添加萝卜了,具体取决于设置和接线!

 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,5 @@ flask-cors gevent gunicorn pymongo +redis +rq --- a/backend/dev_settings.py +++ b/backend/dev_settings.py @@ -1,3 +1,7 @@ MONGO_HOST = "mongo" MONGO_PORT = 27017 MONGO_DATABASE = "core" +REDIS_HOST = "redis" +REDIS_PORT = 6379 +REDIS_DB = 0 +TASK_QUEUE_NAME = "tasks" --- a/backend/wiring.py +++ b/backend/wiring.py @@ -2,6 +2,8 @@ import os from pymongo import MongoClient from pymongo.database import Database +import redis +import rq import backend.dev_settings from backend.storage.card import CardDAO @@ -21,3 +23,11 @@ class Wiring(object): port=self.settings.MONGO_PORT) self.mongo_database: Database = self.mongo_client[self.settings.MONGO_DATABASE] self.card_dao: CardDAO = MongoCardDAO(self.mongo_database) + + self.redis: redis.Redis = redis.StrictRedis( + host=self.settings.REDIS_HOST, + port=self.settings.REDIS_PORT, + db=self.settings.REDIS_DB) + self.task_queue: rq.Queue = rq.Queue( + name=self.settings.TASK_QUEUE_NAME, + connection=self.redis) 

工人的样板代码。

 # worker/__main__.py import argparse import uuid import rq import backend.wiring parser = argparse.ArgumentParser(description="Run worker.") #   ,      #  .  ,       rq. parser.add_argument( "--burst", action="store_const", const=True, default=False, help="enable burst mode") args = parser.parse_args() #       Redis. wiring = backend.wiring.Wiring() with rq.Connection(wiring.redis): w = rq.Worker( queues=[wiring.settings.TASK_QUEUE_NAME], #         # ,    . name=uuid.uuid4().hex) w.work(burst=args.burst) 

对于解析本身,连接失调并编写一个简单的函数:

 # backend/tasks/parse.py import mistune from backend.storage.card import CardDAO def parse_card_markup(card_dao: CardDAO, card_id: str): card = card_dao.get_by_id(card_id) card.html = _parse_markdown(card.markdown) card_dao.update(card) _parse_markdown = mistune.Markdown(escape=True, hard_wrap=False) 

逻辑上:我们需要CardDAO获取卡的源代码并保存结果。但是包含与外部存储器的连接的对象无法通过pickle进行序列化-这意味着该任务无法立即执行并排队等待RQ。以一种好的方式,我们需要Wiring在侧面创建一个工作器,并将其扔给各种各样……让我们这样做:

 --- a/worker/__main__.py +++ b/worker/__main__.py @@ -2,6 +2,7 @@ import argparse import uuid import rq +from rq.job import Job import backend.wiring @@ -16,8 +17,23 @@ args = parser.parse_args() wiring = backend.wiring.Wiring() + +class JobWithWiring(Job): + + @property + def kwargs(self): + result = dict(super().kwargs) + result["wiring"] = backend.wiring.Wiring() + return result + + @kwargs.setter + def kwargs(self, value): + super().kwargs = value + + with rq.Connection(wiring.redis): w = rq.Worker( queues=[wiring.settings.TASK_QUEUE_NAME], - name=uuid.uuid4().hex) + name=uuid.uuid4().hex, + job_class=JobWithWiring) w.work(burst=args.burst) 

我们宣布了我们的工作类别,将布线作为所有问题中的一个额外争论。(请注意,它每次都会创建一个新的连线,因为某些任务无法在任务开始处理之前在RQ内发生的派生之前创建。)因此,我们所有的任务都不依赖于连线-即依赖于所有对象-让我们让我们做一个装饰器,它只能从布线中获得必要的东西:

 # backend/tasks/task.py import functools from typing import Callable from backend.wiring import Wiring def task(func: Callable): #    : varnames = func.__code__.co_varnames @functools.wraps(func) def result(*args, **kwargs): #  .  .pop(),     # ,        . wiring: Wiring = kwargs.pop("wiring") wired_objects_by_name = wiring.__dict__ for arg_name in varnames: if arg_name in wired_objects_by_name: kwargs[arg_name] = wired_objects_by_name[arg_name] #          #   ,  -   . return func(*args, **kwargs) return result 

为我们的任务添加装饰器,享受生活:

 import mistune from backend.storage.card import CardDAO from backend.tasks.task import task @task def parse_card_markup(card_dao: CardDAO, card_id: str): card = card_dao.get_by_id(card_id) card.html = _parse_markdown(card.markdown) card_dao.update(card) _parse_markdown = mistune.Markdown(escape=True, hard_wrap=False) 

享受生活?gh,我想说,我们开始工作了:

 $ docker-compose up worker ... Creating habr-app-demo_worker_1 ... done Attaching to habr-app-demo_worker_1 worker_1 | 17:21:03 RQ worker 'rq:worker:49a25686acc34cdfa322feb88a780f00' started, version 0.13.0 worker_1 | 17:21:03 *** Listening on tasks... worker_1 | 17:21:03 Cleaning registries for queue: tasks 

III……他什么都不做!当然,因为我们没有设定单个任务!

让我们重写一下创建测试卡的工具,以便它:a)如果已经创建了测试卡,它就不会掉下来(在我们的例子中);b)将任务放在marqdown的解析中。

 # tools/add_test_content.py from backend.storage.card import Card, CardNotFound from backend.tasks.parse import parse_card_markup from backend.wiring import Wiring wiring = Wiring() try: card = wiring.card_dao.get_by_slug("helloworld") except CardNotFound: card = wiring.card_dao.create(Card( slug="helloworld", name="Hello, world!", markdown=""" This is a hello-world page. """)) # ,   card_dao.get_or_create,  #      ! wiring.task_queue.enqueue_call( parse_card_markup, kwargs={"card_id": card.id}) 

现在,不仅可以在后端运行工具,还可以在工作线程上运行工具。原则上,现在我们不在乎。我们启动它,docker-compose exec worker python -m tools.add_test_content并在终端的相邻选项卡中看到一个奇迹-工人做了些什么!

 worker_1 | 17:34:26 tasks: backend.tasks.parse.parse_card_markup(card_id='5c715dd1e201ce000c6a89fa') (613b53b1-726b-47a4-9c7b-97cad26da1a5) worker_1 | 17:34:27 tasks: Job OK (613b53b1-726b-47a4-9c7b-97cad26da1a5) worker_1 | 17:34:27 Result is kept for 500 seconds 

用后端重建容器之后,我们终于可以在浏览器中看到卡的内容:



前端导航


在继续进行SSR之前,我们需要使我们所有的React事情变得有意义,并使我们的单页面应用程序真正成为一个页面。让我们更新我们的工具,以创建两个彼此链接的卡(“非一个,两个!妈妈,我现在大日期!”),然后我们将处理它们之间的导航。

隐藏文字
 # tools/add_test_content.py def create_or_update(card): try: card.id = wiring.card_dao.get_by_slug(card.slug).id card = wiring.card_dao.update(card) except CardNotFound: card = wiring.card_dao.create(card) wiring.task_queue.enqueue_call( parse_card_markup, kwargs={"card_id": card.id}) create_or_update(Card( slug="helloworld", name="Hello, world!", markdown=""" This is a hello-world page. It can't really compete with the [demo page](demo). """)) create_or_update(Card( slug="demo", name="Demo Card!", markdown=""" Hi there, habrovchanin. You've probably got here from the awkward ["Hello, world" card](helloworld). Well, **good news**! Finally you are looking at a **really cool card**! """ )) 


现在,我们可以单击链接并考虑每次精美的应用程序如何重新启动。别说了

首先,让您的处理程序单击链接。因为带有链接的HTML来自后端,并且我们在React上有应用程序,所以我们需要一些特定于React的焦点。

 // frontend/src/components/card.js class Card extends Component { componentDidMount() { document.title = this.props.name } navigate(event) { //       .  //      ,    . if (event.target.tagName === 'A' && event.target.hostname === window.location.hostname) { //     event.preventDefault(); //      this.props.dispatch(navigate(event.target)); } } render() { const {name, html} = this.props; return ( <div> <h1>{name}</h1> <div dangerouslySetInnerHTML={{__html: html}} onClick={event => this.navigate(event)} /> </div> ); } } 

由于所有将逻辑卡加载到组件中的逻辑CardPage,在操作本身(惊人!)中,无需采取任何操作:

 export function navigate(link) { return { type: NAVIGATE, path: link.pathname } } 

在这种情况下,添加一个愚蠢的减速器:

 // frontend/src/redux/reducers.js import { START_FETCHING_CARD, FINISH_FETCHING_CARD, NAVIGATE } from "./actions"; function navigate(state, path) { //     react-router,    ! // (       SSR.) let m = /^\/card\/([^/]+)$/.exec(path); if (m !== null) { return { ...state, page: { type: "card", cardSlug: m[1], isFetching: true } }; } return state } export default function root(state = {}, action) { switch (action.type) { case START_FETCHING_CARD: return { ...state, page: { ...state.page, isFetching: true } }; case FINISH_FETCHING_CARD: return { ...state, page: { ...state.page, isFetching: false, cardData: action.cardData } }; case NAVIGATE: return navigate(state, action.path) } return state; } 

由于现在我们的应用程序状态可以更改,因此CardPage我们需要添加componentDidUpdate与已经添加的方法相同的方法componentWillMount现在,在更新属性CardPage(例如,cardSlug导航期间的属性)之后,还将请求来自后端的卡的内容(componentWillMount仅在初始化组件时才这样做)。

好的,docker-compose up --build frontend我们的导航正常!



细心的读者会注意到,在卡片之间导航时,页面的URL不会更改-即使在屏幕截图中,我们也可以在演示卡地址上看到世界卡片Hello,即世界银行卡。因此,前后导航也下降。让我们立即添加一些带有历史的黑魔法来修复它!

您可以做的最简单的事情就是添加操作。navigate挑战history.pushState

 export function navigate(link) { history.pushState(null, "", link.href); return { type: NAVIGATE, path: link.pathname } } 

现在,单击链接时,浏览器地址栏中的URL将会真正改变。但是,后退按钮会损坏

为了使其正常工作,我们需要监听popstate对象的事件window此外,如果在这种情况下,我们想向后和向前(即通过dispatch(navigate(...))进行导航,则必须在该函数上navigate添加一个特殊的“请勿pushState标志(否则所有操作都会中断!)。另外,为了区分“我们的”状态,我们应该使用pushState保存元数据的功能。有很多魔术和调试功能,所以让我们开始编写代码吧!以下是该应用程序的外观:

 // frontend/src/components/app.js class App extends Component { componentDidMount() { //     --   //      "". history.replaceState({ pathname: location.pathname, href: location.href }, ""); //     . window.addEventListener("popstate", event => this.navigate(event)); } navigate(event) { //    "" ,   //        ,    //   (or is it a good thing?..) if (event.state && event.state.pathname) { event.preventDefault(); event.stopPropagation(); //      "  pushState". this.props.dispatch(navigate(event.state, true)); } } render() { // ... } } 

这是导航动作:

 // frontend/src/redux/actions.js export function navigate(link, dontPushState) { if (!dontPushState) { history.pushState({ pathname: link.pathname, href: link.href }, "", link.href); } return { type: NAVIGATE, path: link.pathname } } 

现在,故事将起作用。

好吧,最后一点:既然我们有了一个action navigate,为什么我们不放弃客户端中用于计算初始状态的额外代码呢?我们可以致电定位到当前位置:

 --- a/frontend/src/client.js +++ b/frontend/src/client.js @@ -3,23 +3,16 @@ import {render} from 'react-dom' import {Provider} from 'react-redux' import App from './components/app' import configureStore from './redux/configureStore' +import {navigate} from "./redux/actions"; let initialState = { page: { type: "home" } }; -const m = /^\/card\/([^\/]+)$/.exec(location.pathname); -if (m !== null) { - initialState = { - page: { - type: "card", - cardSlug: m[1] - }, - } -} const store = configureStore(initialState); +store.dispatch(navigate(location)); 

复制粘贴已销毁!

前端:服务器端渲染


是时候使用我们的主要芯片了(我认为)-SEO友好。为了使搜索引擎可以索引我们的内容,该内容是在React组件中完全动态创建的,我们需要能够为它们提供呈现React的结果,还需要学习如何使该结果再次具有交互性。

通用方案很简单。首先:我们需要将React组件生成的HTML插入HTML模板中App。搜索引擎(以及禁用JS的浏览器,可以看到此HTML)。第二:在模板<script>添加一个标记,以将window状态转储保存到某个位置(例如,object ),从该状态转储此HTML。然后,我们可以立即以这种状态在客户端初始化我们的应用程序,并显示需要什么(我们甚至可以应用水合物到生成的HTML,以免重新创建应用程序的DOM树)。

让我们从编写一个返回渲染的HTML和最终状态的函数开始。

 // frontend/src/server.js import "@babel/polyfill" import React from 'react' import {renderToString} from 'react-dom/server' import {Provider} from 'react-redux' import App from './components/app' import {navigate} from "./redux/actions"; import configureStore from "./redux/configureStore"; export default function render(initialState, url) { //  store,    . const store = configureStore(initialState); store.dispatch(navigate(url)); let app = ( <Provider store={store}> <App/> </Provider> ); // ,        ! // ,         ? let content = renderToString(app); let preloadedState = store.getState(); return {content, preloadedState}; }; 

在我们上面讨论过的模板中添加新的参数和逻辑:

 // frontend/src/template.js function template(title, initialState, content) { let page = ` <!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <title>${title}</title> </head> <body> <div id="app">${content}</div> <script> window.__STATE__ = ${JSON.stringify(initialState)} </script> <script src="/dist/client.js"></script> </body> </html> `; return page; } module.exports = template; 

我们的Express服务器变得更加复杂:

 // frontend/index.js app.get("*", (req, res) => { const initialState = { page: { type: "home" } }; const {content, preloadedState} = render(initialState, {pathname: req.url}); res.send(template("Habr demo app", preloadedState, content)); }); 

但是客户端更容易:

 // frontend/src/client.js import React from 'react' import {hydrate} from 'react-dom' import {Provider} from 'react-redux' import App from './components/app' import configureStore from './redux/configureStore' import {navigate} from "./redux/actions"; //         ! const store = configureStore(window.__STATE__); // render   hydrate. hydrate    // DOM tree,       . hydrate( <Provider store={store}> <App/> </Provider>, document.querySelector('#app') ); 

接下来,您需要清除跨平台错误,例如“未定义历史记录”。为此,请在中的某个位置添加一个简单的(到目前为止)函数utility.js

 // frontend/src/utility.js export function isServerSide() { //   ,      process, //     -   . return process.env.APP_ENV !== undefined; } 

然后会有一些常规更改,我不会在这里进行(但是可以在相应的commit中找到)。结果,我们的React应用程序将能够在浏览器和服务器上进行渲染。

有效!但是,正如他们所说,有一个警告...正在



加载? Google在我超酷的时尚服务上看到的就是LOADING ?!

好吧,似乎我们所有的异步性都对我们不利。现在,我们需要一种让服务器理解的方法,即在将React应用程序呈现为字符串并将其发送到客户端之前,需要等待后端的响应以及卡的内容。并且希望该方法相当通用。

可能有很多解决方案。一种方法是在一个单独的文件中描述应保护哪些数据的路径,并在呈现应用程序之前执行此操作(文章)。该解决方案具有许多优点。它很简单,很明确,而且行得通。

作为一个实验(原始内容应该至少在文章中的某个位置!),我提出了另一种方案。每次我们运行异步操作时(必须等待),在状态中的某个位置添加适当的承诺(例如,返回获取的承诺)。因此,我们将提供一个地方,您可以随时检查所有内容是否已下载。

添加两个新操作。

 // frontend/src/redux/actions.js function addPromise(promise) { return { type: ADD_PROMISE, promise: promise }; } function removePromise(promise) { return { type: REMOVE_PROMISE, promise: promise, }; } 


启动提取时将调用第一个,第二个将在调用结束时调用.then()

现在将其处理添加到减速器中:

 // frontend/src/redux/reducers.js export default function root(state = {}, action) { switch (action.type) { case ADD_PROMISE: return { ...state, promises: [...state.promises, action.promise] }; case REMOVE_PROMISE: return { ...state, promises: state.promises.filter(p => p !== action.promise) }; ... 

现在,我们将改善操作fetchCard

 // frontend/src/redux/actions.js function fetchCard() { return (dispatch, getState) => { dispatch(startFetchingCard()); let url = apiPath() + "/card/" + getState().page.cardSlug; let promise = fetch(url) .then(response => response.json()) .then(json => { dispatch(finishFetchingCard(json)); // " ,  " dispatch(removePromise(promise)); }); // "  ,  " return dispatch(addPromise(promise)); }; } 

仍然需要向initialState空数组中添加承诺,并使服务器等待所有承诺!渲染函数变为异步,并采用以下形式:

 // frontend/src/server.js function hasPromises(state) { return state.promises.length > 0 } export default async function render(initialState, url) { const store = configureStore(initialState); store.dispatch(navigate(url)); let app = ( <Provider store={store}> <App/> </Provider> ); //  renderToString     // (  ). CardPage     . renderToString(app); // ,   !    - //    (  // , ),     //    . let preloadedState = store.getState(); while (hasPromises(preloadedState)) { await preloadedState.promises[0]; preloadedState = store.getState() } //  renderToString.    HTML. let content = renderToString(app); return {content, preloadedState}; }; 

由于获得了render异步,因此请求处理程序也稍微复杂一些:

 // frontend/index.js app.get("*", (req, res) => { const initialState = { page: { type: "home" }, promises: [] }; render(initialState, {pathname: req.url}).then(result => { const {content, preloadedState} = result; const response = template("Habr demo app", preloadedState, content); res.send(response); }, (reason) => { console.log(reason); res.status(500).send("Server side rendering failed!"); }); }); 

等等!



结论


如您所见,创建高科技应用程序并不是那么简单。但是没有那么困难!最终的应用程序位于Github上存储库中,从理论上讲,您只需要Docker即可运行它。

如果需要该文章,则甚至不会放弃该存储库!我们将能够通过其他必要的知识来研究它:

  • 记录,监视,负载测试。
  • 测试,CI,CD。
  • 授权或全文搜索等更酷的功能。
  • 建立和开发生产环境。

感谢您的关注!

Source: https://habr.com/ru/post/zh-CN444446/


All Articles