Главная » Хабрахабр » Делаем современное веб-приложение с нуля

Делаем современное веб-приложение с нуля

Итак, вы решили сделать новый проект. И проект этот — веб-приложение. Сколько времени уйдёт на создание базового прототипа? Насколько это сложно? Что должен уже со старта уметь современный веб-сайт?

В этой статье мы попробуем набросать boilerplate простейшего веб-приложения со следующей архитектурой:

Что мы покроем:

  • настройка dev-окружения в docker-compose.
  • создание бэкенда на Flask.
  • создание фронтенда на Express.
  • сборка JS с помощью Webpack.
  • React, Redux и server side rendering.
  • очереди задач с RQ.

Введение

Перед разработкой, конечно, сперва нужно определиться, что мы разрабатываем! В качестве модельного приложения для этой статьи я решил сделать примитивный wiki-движок. У нас будут карточки, оформленные в Markdown; их можно будет смотреть и (когда-нибудь в будущем) предлагать правки. Всё это мы оформим в виде одностраничного приложения с server-side rendering (что совершенно необходимо для индексации наших будущих терабайт контента).

Давайте чуть подробнее пройдёмся по компонентам, которые нам для этого понадобятся:

  • Клиент. Сделаем одностраничное приложение (т.е. с переходами между страницами посредством AJAX) на весьма распространённой в мире фронтенда связке React+Redux.
  • Фронтенд. Сделаем простенький сервер на Express, который будет рендерить наше React-приложение (запрашивая все необходимые данные в бэкенде асинхронно) и выдавать пользователю.
  • Бэкенд. Повелитель бизнес-логики, наш бэкенд будет небольшим Flask-приложением. Данные (наши карточки) будем хранить в популярном key-value хранилище 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 следующего содержания:

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. Это основной порт нашего веб-приложения: в браузере мы будем заходить на http://localhost:40002.
  • Создаётся контейнер нашего воркера. Ему внешние порты не нужны, а нужен только доступ в MongoDB и Redis.

Теперь давайте создадим докерфайлы. Прямо сейчас на Хабре выходит серия переводов прекрасных статей про 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-приложение, скрывающееся под именем app в модуле backend.server.

Не менее важный docker/backend/.dockerignore:

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

Воркер в целом аналогичен бэкенду, только вместо 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 бэкенда.

Про него на Хабре есть целая отдельная статья, но, судя по развернутой дискуссии на StackOverflow и комментариям в духе «Ребят, уже 2018, нормального решения всё ещё нет?» там всё не так просто. Наконец, фронтенд. Я остановился на таком варианте докерфайла.

# 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 вне контейнера в любом случае было бы нежелательно, потому что некоторые зависимости нового пакета могут уже присутствовать и при этом быть собраны под другую платформу (под ту, которая внутри докера, а не под наш рабочий макбук, например), а ещё мы вообще не хотим требовать присутствия 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 application.

# 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 mode")
app.config.from_object(f"backend.{env}_settings")

Мы указали Flask подтягивать настройки из файла 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

Начнём с создания пакета. Создав папку frontend и запустив в ней 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 на машине разработчика (хотя мы могли и сейчас извернуться и запустить npm init через Docker, ну да ладно).

В Dockerfile мы упомянули npm run build и npm run start — нужно добавить в 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-приложение

Пришло время изобразить в нашем приложении нечто больше, чем plain text. В этой секции мы добавим простейший React-компонент App и настроим сборку.

При программировании на React очень удобно использовать JSX — диалект JavaScript, расширенный синтаксическими конструкциями вида

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

Однако, JavaScript-движки не понимают его, поэтому обычно во фронтенд добавляется этап сборки. Специальные компиляторы JavaScript (ага-ага) превращают синтаксический сахар в уродливый классический JavaScript, обрабатывают импорты, минифицируют и так далее.

apt-cache search java 2014 год.

Итак, простейший 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.

Если предыдущая итерация фронтенда у вас всё ещё запущена, вам достаточно сделать

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" },
...

Теперь наш клиент вкупе с пачкой полифиллов и всеми своими зависимостями прогоняется через 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 Developer Tools (Chrome, Firefox) — то ещё и дерево React-компонент в инструментах разработчика:

Бэкенд: данные в MongoDB

Прежде, чем двигаться дальше и вдыхать в наше приложение жизнь, надо сперва её вдохнуть в бэкенд. Кажется, мы собирались хранить размеченные в Markdown карточки — пора это сделать.

Вместо этого сделаем простенький класс для карточки и сопутствующий DAO: В то время, как существуют ORM для MongoDB на питоне, я считаю использование ORM практикой порочной и оставляю изучение соответствующих решений на ваше усмотрение.

# 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, принимающую на вход объект 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)

Время прописать конфигурацию монги в настройки бэкенда. Мы незамысловато назвали наш контейнер с монгой 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), давайте сразу создадим централизованный царь-компонент, делающий dependency injection (он пригодится нам снова, когда мы будем делать воркер и tools).

# 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_content наполнит нашу монгу контентом изнутри контейнера с бэкендом.

Теперь время поддержать это на фронтенде. Успех!

Фронтенд: Redux

Теперь мы хотим сделать роут /card/:id_or_slug, по которому будет открываться наше React-приложение, подгружать данные карточки из API и как-нибудь её нам показывать. И здесь начинается, пожалуй, самое сложное, ведь мы хотим, чтобы сервер сразу отдавал нам HTML с содержимым карточки, пригодным для индексации, но при этом чтобы приложение при навигации между карточками получало все данные в виде JSON из API, а страничка не перегружалась. И чтобы всё это — без копипасты!

Redux — JavaScript-библиотека для хранения состояния. Начнём с добавления Redux. Так, если раньше для навигации мы сперва включали гифку загрузки, потом делали запрос через AJAX и, наконец, в success-коллбеке прописывали обновление нужных частей страницы, то в Redux-парадигме нам предлагается отправить действие “изменить контент на гифку с анимацией”, которое изменит глобальное состояние так, что одна из ваших компонент выкинет прежний контент и поставит анимацию, потом сделать запрос, а в его success-коллбеке отправить ещё одно действие, “изменить контент на подгруженный”. Идея в том, чтобы вместо тысячи неявных состояний, изменяемых вашими компонентами при пользовательских действиях и других интересных событиях, иметь одно централизованное состояние, а любое изменение его производить через централизованный механизм действий. В общем, сейчас мы это сами увидим.

Начнём с установки новых зависимостей в наш контейнер.

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

Первое — собтвенно, Redux, второе — специальная библиотека для скрещивания React и Redux (written by mating experts), третье — очень нужная штука, необходимость который неплохо обоснована в её же README, и, наконец, четвёртое — библиотечка, необходимая для работы Redux DevTools Extension.

Начнём с бойлерплейтного Redux-кода: создания редьюсера, который ничего не делает, и инициализации состояния.

// 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": {...}, // данные карточки (если уже получены) // ... }, // ...
}

Заведём компонент «карточка», принимающий в качестве props содержимое 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 way.

Для начала создадим файлик 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";
}

Ох, у нас появилось действие, которое ЧТО-ТО ДЕЛАЕТ! Это надо поддержать в редьюсере:

// 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 actions, сама компонента 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);

Добавим простенькую обработку page.type в наш корневой компонент App:

// 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.type и page.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)

Для самого парсинга подключим библиотечку mistune и напишем простенькую функцию:

# 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)

Мы объявили свой класс джобы, прокидывающий вайринг в качестве дополнительного kwargs-аргумента во все таски. (Обратите внимание, что он создаёт каждый раз НОВЫЙ вайринг, потому что некоторые клиенты нельзя создавать перед форком, который происходит внутри 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)

Радуемся жизни? Тьфу, я хотел сказать, запускаем воркер:

$ 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

Ииии… он ничего не делает! Конечно, ведь мы не ставили ни одной таски!

Давайте перепишем нашу тулзу, которая создаёт тестовую карточку, чтобы она: а) не падала, если карточка уже создана (как в нашем случае); б) ставила таску на парсинг маркдауна.

# 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})

Тулзу теперь можно запускать не только на backend, но и на worker. В принципе, сейчас нам нет разницы. Запускаем 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 хоть сколько-то осмысленной и сделать наше single page application действительно single page. Давайте обновим нашу тулзу, чтобы создавалось две (НЕ ОДНА, А ДВЕ! МАМА, Я ТЕПЕРЬ БИГ ДАТА ДЕВЕЛОПЕР!) карточки, ссылающиеся друг на друга, и потом займёмся навигацией между ними.

Скрытый текст

# 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, world-карточку по адресу demo-карточки. Давайте сразу добавим немного чёрной магии с history, чтобы починить это!

Самое простое, что можно сделать — добавить в действие navigate вызов history.pushState.

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

Теперь при переходах по ссылкам URL в адресной строке браузера будет реально меняться. Однако, кнопка «Назад» сломается!

Причём, если мы захотим при этом событии делать навигацию назад так же, как и вперёд (то есть через dispatch(navigate(...))), то придётся в функцию navigate добавить специальный флаг «не делай pushState» (иначе всё разломается ещё сильнее!). Чтобы всё заработало, нам надо слушать событие popstate объекта window. Тут много магии и дебага, поэтому перейдём сразу к коду! Кроме того, чтобы различать «наши» состояния, нам стоит воспользоваться способностью pushState сохранять метаданные. Вот как станет выглядеть App:

// 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() { // ... }
}

А вот как — действие navigate:

// 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 }
}

Вот теперь история заработает.

Мы ведь можем просто вызвать navigate в текущий location: Ну и последний штрих: раз уж у нас теперь есть действие 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));

Копипаста уничтожена!

Фронтенд: server-side rendering

Пришло время для нашей главной (на мой взгляд) фишечки — SEO-дружелюбия. Чтобы поисковики могли индексировать наш контент, полностью создаваемый динамически в React-компонентах, нам нужно уметь выдавать им результат рендеринга React, и ещё и научиться потом делать этот результат снова интерактивным.

Первое: в наш HTML-шаблон нам надо воткнуть HTML, сгенерированный нашим React-компонентом App. Общая схема простая. Второе: в шаблон надо добавить тег <script>, сохраняющий куда-нибудь (например, в объект window) дамп состояния, из которого отрендерился этот HTML. Этот HTML будут видеть поисковые движки (и браузеры с выключенным JS, хе-хе). Тогда мы сможем сразу инициализировать наше приложение на стороне клиента этим состоянием и показывать что надо (мы даже можем применить hydrate к сгенерированному HTML, чтобы не создавать DOM tree приложения заново).

Начнём с написания функции, возвращающей отрендеренный 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')
);

Дальше нужно вычистить ошибки кроссплатформенности вроде «history is not defined». Для этого добавим простую (пока что) фунцию куда-нибудь в utility.js.

// frontend/src/utility.js export function isServerSide() { // Вы можете возмутиться, что в браузере не будет process, // но компиляция с полифиллами как-то разруливает этот вопрос. return process.env.APP_ENV !== undefined;
}

Дальше будет какое-то количество рутинных изменений, которые я не буду тут приводить (но их можно посмотреть в соответствующем коммите). В итоге наше React-приложение сможет рендериться и в браузере, и на сервере.

Но есть, как говорится, один нюанс… Работает!

Всё, что увидит Google на моём супер-крутом модном сервисе — это LOADING?! LOADING?

Теперь нам нужен способ дать серверу понять, что ответа от бэкенда с контентом карточки нужно дождаться, прежде чем рендерить React-приложение в строку и отправлять клиенту. Что ж, кажется, вся наша асинхронщина сыграла против нас. И желательно, чтобы способ этот был достаточно общий.

Один из подходов — описать в отдельном файле, для каких путей какие данные нужно зафетчить, и сделать это перед тем, как рендерить приложение (статья). Здесь может быть много решений. Оно простое, оно явное и оно работает. У этого решения много плюсов.

Давайте каждый раз, когда мы запускаем что-то асинхронное, чего надо дожидаться, добавлять соответствующий промис (например, тот, который возвращает fetch) куда-нибудь в наше состояние. В качестве эксперимента (должен же быть в статье хоть где-то ориджинал контент!) я предлагаю другую схему. Так у нас будет место, где всегда можно проверить, всё ли скачалось.

Добавим два новых действия.

// frontend/src/redux/actions.js function addPromise(promise) { return { type: ADD_PROMISE, promise: promise };
} function removePromise(promise) { return { type: REMOVE_PROMISE, promise: promise, };
} Первое будем вызывать, когда запустили фетч, второе — в конце его <code>.then()</code>. Теперь добавим их обработку в редьюсер: <source lang="javascript">
// 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 пустой массив промисов и заставить сервер дождаться их всех! Функция render становится асинхронной и принимает такой вид:

// 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!"); });
});

Et voilà!

Заключение

Как вы видите, сделать высокотехнологичное приложение не так уж и просто. Но не так уж и сложно! Итоговое приложение лежит в репозитории на Github и, теоретически, вам достаточно одного только Docker, чтобы запустить его.

Мы сможем на нём же рассмотреть что-нибудь из других знаний, обязательно нужных: Если статья окажется востребованной, репозиторий этот даже не будет заброшен!

  • логирование, мониторинг, нагрузочное тестирование.
  • тестирование, CI, CD.
  • более крутые фичи вроде авторизации или полнотекстового поиска.
  • настройка и развёртка продакшн-окружения.

Спасибо за внимание!


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

[Перевод] История транзистора, часть 2: из горнила войны

Другие статьи цикла: История реле История электронных компьютеров История транзистора Горнило войны подготовило почву для появления транзистора. С 1939 по 1945 года технические знания из области полупроводников невероятно сильно разрослись. И тому была одна простая причина: радар. Самая важная технология ...

Что можно сделать через разъем OBD в автомобиле

Ни для кого не секрет, что в современных автомобилях все системы под завязку забиты различной электроникой, даже простой стеклоподъемник имеет собственный микроконтроллер и адрес в общей сети. Мне, как интересующемуся владельцу, стало интересно, что же можно сделать имея просто доступ ...