因此,您决定创建一个新项目。 这个项目是一个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框架
将
flask
,
flask-cors
,
gevent
和
gunicorn
到
requirements.txt
并在
backend/server.py
创建一个简单的Flask应用程序。
我们告诉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 build
和
npm run start
Dockerfile
您需要将适当的命令添加到
package.json
:
build
命令什么也没做,但是对我们仍然有用。
添加
Express依赖项并在
index.js
创建一个简单的应用程序:
现在,
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/template.js
其中包含我们未来应用程序的最小HTML框架:
添加客户端入口点:
要打造所有这些美丽,我们需要:
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:
要使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:
成功! 现在,如果我们运行
docker-compose up --build frontend
,我们将看到“ Hello,world!” 在一个新的闪亮包装中,如果您安装了React开发人员工具扩展(
Chrome ,
Firefox ),那么开发人员工具中还会有一个React组件树:

后端:MongoDB中的数据
在继续前进并为我们的应用程序注入生命之前,您必须首先将其呼吸到后端。 似乎我们要存储在Markdown中标记的卡片-是时候这样做了。
尽管
python中有用于MongoDB的ORM ,但我认为对ORM的使用是恶意的,因此我将适当的解决方案的研究交给您。 相反,我们将为卡片和随附的
DAO做一个简单的类:
(如果您仍未在Python中使用类型注释,请务必查看
这些 文章 !)
现在,让我们创建
CardDAO
接口的实现,该实现将
CardDAO
中的
Database
对象
pymongo
(是的,是将
pymongo
添加到
requirements.txt
):
是时候在后端设置中注册Monga配置了。 我们只是用mongo
mongo
命名了我们的容器,所以
MONGO_HOST = "mongo"
:
现在我们需要创建
MongoCardDAO
并为Flask应用程序提供访问权限。 尽管现在我们有了一个非常简单的对象层次结构(设置→pymongo客户端→pymongo数据库→
MongoCardDAO
),但让我们立即创建一个进行
依赖项注入的集中式King组件(当我们处理worker和工具时,它将再次派上用场)。
是时候为Flask应用程序添加新路线并欣赏美景了!
重新启动
docker-compose up --build backend
:

糟糕...哦,是的。 我们需要添加内容! 我们将打开tools文件夹,并在其中添加一个脚本,以添加一个测试卡:
docker-compose exec backend python -m tools.add_test_content
用
docker-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并初始化状态。
我们的客户有所改变,在心理上准备与Redux合作:
现在我们可以运行docker-compose up --build frontend来确保没有任何损坏,并且我们的原始状态已经出现在Redux DevTools中:

前端:卡页
在制作带有SSR的页面之前,您需要制作没有SSR的页面! 最后,让我们使用我们独创的API来访问卡并在前端组成卡页面。
是时候利用情报并重新设计我们的状态结构了。 关于此主题
的材料
很多 ,因此我建议不要滥用智力,而将重点放在简单性上。 例如:
{ "page": { "type": "card", // // type=card: "cardSlug": "...", // "isFetching": false, // API "cardData": {...}, // ( ) // ... }, // ... }
让我们获得“卡片”组件,该组件将cardData的内容作为道具(实际上是mongo中我们卡片的内容):
现在,让我们使用卡片获取整个页面的组件。 他将负责从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) => {
哦,我们采取了某种措施!在reducer中必须支持:
(请注意用于通过更改单个字段来克隆对象的流行语法。)既然所有逻辑都是在Redux操作中执行的,则组件本身CardPage
将看起来相对简单:
在我们的根App组件中添加一个简单的page.type处理:
现在,最后一刻仍然存在-您需要以某种方式进行初始化page.type
,page.cardSlug
具体取决于页面的URL。但是本文仍然有很多部分,但是我们现在无法提供高质量的解决方案。让我们暂时做它。那完全是愚蠢的。例如,初始化应用程序时的常规!
现在我们可以在帮助下重建前端,docker-compose up --build frontend
以享用我们的卡了helloworld
……
等等,等等……我们的内容在哪里?哦,我们忘了解析Markdown!工人:RQ
解析Markdown并为可能无限制大小的卡生成HTML是一项典型的“繁重”任务,该任务通常在单独的工作机上排队并执行,而不是在保存更改时直接在后端解决。任务队列有许多开源实现。我们将使用Redis和一个简单的库RQ(Redis Queue),该库以pickle格式传输任务参数,并组织我们的产卵过程进行处理。是时候添加萝卜了,具体取决于设置和接线!
工人的样板代码。
对于解析本身,连接失调库并编写一个简单的函数:
逻辑上:我们需要CardDAO
获取卡的源代码并保存结果。但是包含与外部存储器的连接的对象无法通过pickle进行序列化-这意味着该任务无法立即执行并排队等待RQ。以一种好的方式,我们需要Wiring
在侧面创建一个工作器,并将其扔给各种各样……让我们这样做:
我们宣布了我们的工作类别,将布线作为所有问题中的一个额外争论。(请注意,它每次都会创建一个新的连线,因为某些任务无法在任务开始处理之前在RQ内发生的派生之前创建。)因此,我们所有的任务都不依赖于连线-即依赖于所有对象-让我们让我们做一个装饰器,它只能从布线中获得必要的东西:
为我们的任务添加装饰器,享受生活: 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的解析中。
现在,不仅可以在后端运行工具,还可以在工作线程上运行工具。原则上,现在我们不在乎。我们启动它,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事情变得有意义,并使我们的单页面应用程序真正成为一个页面。让我们更新我们的工具,以创建两个彼此链接的卡(“非一个,两个!妈妈,我现在大日期!”),然后我们将处理它们之间的导航。现在,我们可以单击链接并考虑每次精美的应用程序如何重新启动。别说了首先,让您的处理程序单击链接。因为带有链接的HTML来自后端,并且我们在React上有应用程序,所以我们需要一些特定于React的焦点。
由于所有将逻辑卡加载到组件中的逻辑CardPage
,在操作本身(惊人!)中,无需采取任何操作: export function navigate(link) { return { type: NAVIGATE, path: link.pathname } }
在这种情况下,添加一个愚蠢的减速器:
由于现在我们的应用程序状态可以更改,因此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
保存元数据的功能。有很多魔术和调试功能,所以让我们开始编写代码吧!以下是该应用程序的外观:
这是导航动作:
现在,故事将起作用。好吧,最后一点:既然我们有了一个action navigate
,为什么我们不放弃客户端中用于计算初始状态的额外代码呢?我们可以致电定位到当前位置:
复制粘贴已销毁!前端:服务器端渲染
是时候使用我们的主要芯片了(我认为)-SEO友好。为了使搜索引擎可以索引我们的内容,该内容是在React组件中完全动态创建的,我们需要能够为它们提供呈现React的结果,还需要学习如何使该结果再次具有交互性。通用方案很简单。首先:我们需要将React组件生成的HTML插入HTML模板中App
。搜索引擎(以及禁用JS的浏览器,可以看到此HTML)。第二:在模板<script>
中添加一个标记,以将window
状态转储保存到某个位置(例如,object ),从该状态转储此HTML。然后,我们可以立即以这种状态在客户端初始化我们的应用程序,并显示需要什么(我们甚至可以应用水合物到生成的HTML,以免重新创建应用程序的DOM树)。让我们从编写一个返回渲染的HTML和最终状态的函数开始。
在我们上面讨论过的模板中添加新的参数和逻辑:
我们的Express服务器变得更加复杂:
但是客户端更容易:
接下来,您需要清除跨平台错误,例如“未定义历史记录”。为此,请在中的某个位置添加一个简单的(到目前为止)函数utility.js
。
然后会有一些常规更改,我不会在这里进行(但是可以在相应的commit中找到)。结果,我们的React应用程序将能够在浏览器和服务器上进行渲染。有效!
但是,正如他们所说,有一个警告...正在
加载? Google在我超酷的时尚服务上看到的就是LOADING ?!好吧,似乎我们所有的异步性都对我们不利。现在,我们需要一种让服务器理解的方法,即在将React应用程序呈现为字符串并将其发送到客户端之前,需要等待后端的响应以及卡的内容。并且希望该方法相当通用。可能有很多解决方案。一种方法是在一个单独的文件中描述应保护哪些数据的路径,并在呈现应用程序之前执行此操作(文章)。该解决方案具有许多优点。它很简单,很明确,而且行得通。作为一个实验(原始内容应该至少在文章中的某个位置!),我提出了另一种方案。每次我们运行异步操作时(必须等待),在状态中的某个位置添加适当的承诺(例如,返回获取的承诺)。因此,我们将提供一个地方,您可以随时检查所有内容是否已下载。添加两个新操作。
启动提取时将调用第一个,第二个将在调用结束时调用.then()
。现在将其处理添加到减速器中:
现在,我们将改善操作fetchCard
:
仍然需要向initialState
空数组中添加承诺,并使服务器等待所有承诺!渲染函数变为异步,并采用以下形式:
由于获得了render
异步,因此请求处理程序也稍微复杂一些:
等等!
结论
如您所见,创建高科技应用程序并不是那么简单。但是没有那么困难!最终的应用程序位于Github上的存储库中,从理论上讲,您只需要Docker即可运行它。如果需要该文章,则甚至不会放弃该存储库!我们将能够通过其他必要的知识来研究它:- 记录,监视,负载测试。
- 测试,CI,CD。
- 授权或全文搜索等更酷的功能。
- 建立和开发生产环境。
感谢您的关注!