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

В этой статье мы попробуем набросать 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 {env} 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, обрабатывают импорты, минифицируют и так далее.



2014 год. apt-cache search 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.

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

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 карточки — пора это сделать.

В то время, как существуют ORM для MongoDB на питоне, я считаю использование 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, принимающую на вход объект 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. Redux — JavaScript-библиотека для хранения состояния. Идея в том, чтобы вместо тысячи неявных состояний, изменяемых вашими компонентами при пользовательских действиях и других интересных событиях, иметь одно централизованное состояние, а любое изменение его производить через централизованный механизм действий. Так, если раньше для навигации мы сперва включали гифку загрузки, потом делали запрос через 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 в адресной строке браузера будет реально меняться. Однако, кнопка «Назад» сломается!

Чтобы всё заработало, нам надо слушать событие popstate объекта window. Причём, если мы захотим при этом событии делать навигацию назад так же, как и вперёд (то есть через dispatch(navigate(...))), то придётся в функцию navigate добавить специальный флаг «не делай pushState» (иначе всё разломается ещё сильнее!). Кроме того, чтобы различать «наши» состояния, нам стоит воспользоваться способностью 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, почему бы нам не отказаться от лишнего кода в клиенте, вычисляющего начальное состояние? Мы ведь можем просто вызвать navigate в текущий location:

--- 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. Этот HTML будут видеть поисковые движки (и браузеры с выключенным JS, хе-хе). Второе: в шаблон надо добавить тег <script>, сохраняющий куда-нибудь (например, в объект window) дамп состояния, из которого отрендерился этот HTML. Тогда мы сможем сразу инициализировать наше приложение на стороне клиента этим состоянием и показывать что надо (мы даже можем применить 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-приложение сможет рендериться и в браузере, и на сервере.

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



LOADING? Всё, что увидит Google на моём супер-крутом модном сервисе — это 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,
    };
}


Первое будем вызывать, когда запустили фетч, второе — в конце его .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 пустой массив промисов и заставить сервер дождаться их всех! Функция 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 voila!



Заключение


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

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

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

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

Комментарии (107)


  1. Xtray
    20.03.2019 11:48
    +1

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

    Очень интересно, почему выбран именно такой стек.


    1. Duffo
      20.03.2019 12:01
      -2

      Более любопытно, почему автор, для полноты картины не добавил личный кабинет на друпале, django/django-orm для работы с базой


  1. janvarev
    20.03.2019 12:18

    Мне одному кажется, что тут не хватает хаба «Ненормальное программирование» или хотя бы тега /sarcasm в конце статьи?


    1. saluev Автор
      20.03.2019 12:25

      А расскажите конструктивно, что вас натолкнуло на такую мысль, пожалуйста.


      1. janvarev
        20.03.2019 12:43

        М-м. Ну, несоответствие используемых инструментов задаче (использование крупнокалиберных орудий против малых летающих целей)

        Вообще оно больше всего мне напомнило вот эту статью про то, как люди пытаются выучить современный JavaScript: habr.com/ru/post/312022


        1. saluev Автор
          20.03.2019 12:46

          Это странный аргумент. Каждый пет-прожект мечтает стать инстаграмом. Всем известны ведь графики типа такого:
          image

          У меня была цель написать статью именно про подготовку проекта with good design.


          1. janvarev
            20.03.2019 12:58

            Тем не менее, есть проблема избыточной оптимизации (усложнения) на старте проекта.

            Из того, что быстро нашлось на Хабре:
            «До микросервисов нужно дорасти, а не начинать с них» habr.com/ru/post/427215
            «Преступный переинженеринг» habr.com/ru/post/99889


            1. saluev Автор
              20.03.2019 13:03
              +4

              Тут элементарная архитектура, о каком переусложнении речь?

              Как вы смогли применить слова «оптимизация» и «усложнение» как синонимы, я тоже не понимаю, ну да ладно.


              1. Arris
                21.03.2019 22:40

                преждевременная оптимизация — зло.


                1. a-tk
                  22.03.2019 09:52

                  преждевременная пессимизация — тоже


            1. a-tk
              20.03.2019 13:46
              +3

              Если учиться строить только на примерах сараев, то небоскрёбы строить будет некому.
              (Не помню из чьего доклада и на какой конференции)


            1. movl
              20.03.2019 13:52
              +1

              Я не увидел, что автор пытался донести мысль о необходимости или «правильности» описанной структуры приложения. Скорее был рассмотрен вопрос, что если вы уже пришли к выводу о том, что такая архитектура необходима, а она достаточно тривиальна, то как ее воплотить в жизнь на определенном стеке технологий. Рассматривались только технические вопросы. Ваши тезисы мной воспринимаются как спекуляция смыслами: увидели то, чего нет, и заявили о реальности увиденного.


          1. denaspireone
            20.03.2019 14:19

            Имхо, я бы назвал статью — «пишем $something с использованем трендов из мира DevOps»
            Но опять же, это имхо.


            1. saluev Автор
              21.03.2019 09:16

              Докер — это для вас просто «тренд из мира DevOps»?


  1. nikweter
    20.03.2019 13:17
    +3

    Вспомнил как 15 лет назад учился писать свой первый helloworld на php и html, сравнил с ЭТИМ, прослезился…


    1. Oldster
      20.03.2019 22:05
      +3

      А потом удивляемся, почему HelloWord весит больше чем DOOM.


  1. Andrew_Pinkerton
    20.03.2019 13:44
    +1

    Статья годная, не слушайте тех кто говорит что вы переусложнили.

    saluev, небольшое уточнение:
    В requirements.txt не указаны версии пакетов, это так и задумано?


    1. saluev Автор
      20.03.2019 13:50

      Да, не стал заморачиваться. По-хорошему нужно указать, конечно.


      1. dikkini
        20.03.2019 20:42

        Можно ведь через freeze и сразу с версиями.


  1. dimuska139
    20.03.2019 14:23

    Правильно ли я понимаю, что если по такому принципу делать блог, то для рендеринга на сервере текста статей (для того, чтобы поисковые системы видели не пустые страницы без текста статей), нужно в window.__STATE__ тексты вообще всех статей блога запихать?


    1. saluev Автор
      20.03.2019 14:25

      Зачем всех? На странице статьи — текст одной статьи, на ленте — тексты стольких статей, сколько у вас вмещает одна страница ленты (а лучше их первые N символов или часть текста до ката).


  1. largotek
    20.03.2019 14:29

    Статья интересная, но вот возник вопрос, почему именно синхронный Flask? Есть какие-то еще плюсы кроме того, что уже знаком многим и относительно простой в использовании?


    1. saluev Автор
      20.03.2019 14:30

      Я запускаю его через gunicorn с gevent, так что он не такой уж синхронный.


      1. largotek
        20.03.2019 15:35

        Да конечно, но почему бы сразу не использовать aiohttp или sanic?


        1. saluev Автор
          20.03.2019 15:53

          Ну, да, это был вопрос ознакомленности и простоты. Я считаю, что проверенный инструмент лучше быстрого)


  1. hardex
    20.03.2019 14:59

    Хороший пример того, сколько бойлерплейта нужно писать на Redux для простейших вещей


  1. ngalayko
    20.03.2019 15:39

    "Фронтенд" кажется лишнем местом. Зачем тут разделять "фронтенд" и "бекенд"? или "фронтенд" и "клиент"?


    1. saluev Автор
      20.03.2019 15:55

      Это три разные компоненты, исполняющиеся в трёх разных местах. Слить бэкенд и фронтенд можно было бы, сделав весь бэкенд на Node.js, но как-то не хочется. И на самом деле практика с отдельным фронтендом, собирающим и выдающим HTML, довольно распространённая.


      1. ngalayko
        20.03.2019 15:59

        Я понимаю, что распространенная, не понимаю до конца почему.
        Кажется, что клиент не особо усложнится, если будет в бекенд напрямую ходить. При этом архитектура станет на порядок проще, а фронтендерам не надо будет в два места изменения комитить


        1. saluev Автор
          20.03.2019 16:08

          Бэкенд усложнится. В нём появится тысяча новых роутов. Ему придётся, помимо бизнес-логики, заниматься ещё маппингом этой логики на урлы, что уже в значительной мере про репрезентацию, а не про логику. А если он обслуживает, например, ещё Android-приложение и iOS-приложение, ему не хочется брать на себя эту странную ответственность.

          BTW, в статье изменения коммитятся в одно место, не понимаю, о каких двух местах речь.


      1. serf
        20.03.2019 23:03

        Слить бэкенд и фронтенд можно было бы, сделав весь бэкенд на Node.js, но как-то не хочется.
        Возможно бэкенде совсем не нужен с этой штукой www.prisma.io? Особенно если сапописанный бэкенд это мерзкий питон и под реакт (который самый первый потребитель graphql)? Идеальный код это ведь ненаписанный код.


        1. saluev Автор
          20.03.2019 23:13
          +1

          А я люблю питон :(

          Ненаписанный код — это, конечно, хорошо, но едва ли реализуемо в хоть сколько-то нетривиальном приложении.


        1. fukkit
          21.03.2019 12:03
          -1

          Особенно если сапописанный бэкенд это мерзкий питон

          При живом Go, Nodejs, и даже, прости господи, PHP делать web на питоне, по крайней мере, странно.
          Эти отступы, двойные подчеркивания, явный self и document[«id»] вместо document.id сделаны как-будто специально, чтобы вывести из равновесия даже самого флегматичного кодера.
          Сам по себе язык не хуже других, но эстетические чувства раз от разу страдают.


  1. ivymike
    20.03.2019 17:18

    хотя я уже три часа не читал статей по фронтенду, так что насчёт моды не уверен

    вот тут вы правы 100%! :)


    1. r47717
      20.03.2019 17:27

      Вообще вроде как не рекомендуется в Dockerfile запускать в CMD "npm start" (сожрёт лишнюю память), просто через "node index.js" лучше


      1. ivymike
        20.03.2019 17:42

        наверное, вы правы. я тоже думаю, что динозавры вымерли от стагнирующей инфляции в занзибаре (:


  1. eggstream
    20.03.2019 18:19
    +1

    Смешались в кучу кони, люди,
    И залпы тысячи орудий
    Слились в протяжный вой…

    О том, что здесь использована стрельба из крупнокалиберных орудий по малым целям, уже написано.
    Я же со своей стороны хочу отметить, что

    • называть любой кусок серверного кода «фронтэндом» — плохая практика, большинство разработчиков резервирует это слово для кода, выполняемого исключительно на клиенте, вместо вашего «frontend» стоит употреблять router, а вместо «backend» — API-server
    • при наличии живого роутера, делать прямой доступ с клиента аджаксом на апи-сервер — нарушение инкапсуляции и создание проблем с CORS на ровном месте


  1. user9000
    20.03.2019 18:32
    +2

    Не сложно, но запутанно.


  1. Loxmatiymamont
    20.03.2019 20:04
    +5

    В начале у меня было ощущение, что это просто очень хорошая, но очень злая ирония. Но потом зашёл в комментарии, и нет, всё серьёзно и на самом деле достаточно грустно.


  1. dikkini
    20.03.2019 20:48
    +2

    А мне статья понравилась и автор большой молодец. С несколькими но:
    Решение явно не для продакшена.


    Решение показывает как можно делать, если вам нужен реакт, как можно делать если нужно при на флажке и так далее, охватывая все аспекты.


    Решение не должно носить характер how-to, так как тут предлагается на завтрак зажарить целого кабана, сделать карпачо и фуагру, после чего приготовить ещё и пиццу.


    1. saluev Автор
      20.03.2019 20:51

      Конфигурацию под продакшн я не стал описывать. Но разве есть какие-то концептуальные проблемы с этим? Я знаю пока что только, что стоило взять более надёжную очередь задач.


  1. Megadeth77
    20.03.2019 21:25
    +1

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


    1. kahi4
      21.03.2019 19:48

      Был когда-то yeoman, сейчас фронт делается в основном через react-create-app с нужным шаблоном. Есть хороший бойлерплейт, который покрывает реакт + GraphQL + SSR. Но вот чтобы сразу с бэкэндом — видимо, не настолько актуально


  1. skovpen
    20.03.2019 21:37

    зачем во фронтенде express? по сути там статичный контент — на nginx не лучше заменить?


    1. saluev Автор
      20.03.2019 21:38

      Там не статичный контент, там делается server side rendering.


      1. skovpen
        20.03.2019 21:49
        +2

        зачем в 2019 году это лишнее звено?
        btw, гугл сейчас отлично и client side rendering понимает.


        1. saluev Автор
          20.03.2019 22:13
          +1

          Есть пруфы к «отлично»? Моё гугление этого вопроса показывало, что там до сих пор не всё так однозначно. Кроме того,

          • Google — не единственный поисковый движок;
          • сокращение времени до первого информативного рендера положительно сказывается на user experience.



      1. Peregrinus
        21.03.2019 00:24
        +1

        Тянуть express ради app.get()? Такое себе. Голая нода тоже это умеет.


        1. saluev Автор
          21.03.2019 09:09

          Это правда, после вынесения роутинга в Redux его можно было выпилить.


  1. Fen1kz
    21.03.2019 00:27
    +1

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


    я считаю использование ORM практикой порочной и оставляю изучение соответствующих решений на ваше усмотрение

    Почему?


    frontend / backend

    Может лучше SSR-server / API-server ?


    Жду следующую статью с "логирование, мониторинг, нагрузочное тестирование.".


  1. KirEv
    21.03.2019 03:14
    +5

    Спасибо за проделанную работу…

    но извините за оффтоп, наболело...
    Пожалуйста, не читайте: люди с хрупкой психикой, предпочитатели новых технологий, хипстеры.
    Извините, много сумбурного текста…
    Сегодня, практически часа 3 назад, обратился давний знакомый, много проектов разных вместе сделали за овер 10 лет… Попросил внести мелкие изменения для сайта
    Два слова о проекте: сайт подобие соц.сети для публикации проблем\вопросов\етс. граждан и специальная команда (юристы, представители депутатов и т.п.) рассматривают вопрос… тоесть там и блог, и админка, и кабинет пользователя и разные сущности записей… крч. куча разных функций с множеством разграничений доступа…
    Сайт был сделан лет 5 назад, если не 6ть…
    И знаете что? Я был в шоке… в положительном смысле… крутиться это чудо на самом дешевом vps, но сам сайт летает… там нет космических технологий и т.п., делалось тупо в лоб и по простому…
    Начинаю вспоминать последние 2-3 года и разные компании в которых доводилось работать.
    Любой сайт\ресурс, это как минимум: прожект.менеджер, программисты бекэнд (а может и не один), фронтенд (а может и два), дев.опс, какой нибуть админ, дизайнер (а может и два), и еще разные лица вовлеченные по дороге реализации проекта… И огромный стек технологий… начиная c ангуляроа (к примеру), заканчивая CI/CD, TDD, разными менеджерами очередей, и все это, конечно, в облаке…
    И самое страшное: клиенты платят за весь этот шабаш…
    Причем вся эта куча и весь кипиш с техно-хайпом неоправданое усложнение относительно простого проэкта.
    Смотрю на свой код 5ти летней давности… из технологий php5, mysql5, jquery, html,css… все… просто, тупо, плоско, понятно… очень легко разобраться спустя столько времени… без сервисов, промежуточных окружений и т.п., простые sql-запросы без orm-генераций…
    Посмотрел и сравниваю сделанные проекты лет 5 назад, и то что делаю последнее время. И знаете что? Проекты по сути похожи своими функциями, лишь разница в стеке технологий и числе вовлеченного персонала. Но есть существенное отличие в методике реализации «современных» проектов: сложность реализации (из-за тех.стека и лиц которыми нужно управлять), и далее пошло-поехало: слабая оптимизация из-за фреймворков для нетривиальных задач, отсюда трафик, вдс принято считать слабым звеном — значит проект в облаке, может быть в aws, авс для абы-как сделанных проектов (пусть сервисов) не такое дешевое удовольствие… и т.д. и т.п. (грусть печаль)…
    Раньше то что делалось 1-3 месяца до полного адекватного безбажного релиза, сейчас в год не укладывается… (пример последних 2х компаний над проектами которых трудился)…
    Не знаю точно в чем дело, но я убежден, множество элементов (технологий) необходимых для современного проекта лишь вредит, особенно запуску первого релиза, не говоря о дальнейшем сопровождении, к небесам возвышает стоимость рабработки, усложняет сам процес, и т.д. и т.п., похоже, разучились искать простые решения сложных задач.
    И самое страшное, многое из того то что пытаются применять — ни к месту, в предпоследней конторе заявили: нам нужен rabbitQL потому что не хотим казаться лохами в глазах заказчика (а задача, на самом деле, решалась в 3 функции и 1 map)

    Потому, меня тошнит от нашей современности… npm, typescript, react, angular, docker, docker-compose, k8s, mongodb, mysql, golang, aws, ci\cd — это не полный список того из последнего проекта… А проект — цмс-каталог для управления разными сущностями, грубо говоря, у тебя 10 таблиц в бд, и каждому из пользователей настраивается право доступа к данным + разрграничения прав в пределах таблицы… абсолютная тривиальщина… но делается больше 1.5 года, только за 1й год разработки страшные счета за авс… сейчас оно запущено, работает, но частенько появляются новые узкие места…

    Вот такие дела…


    1. epishman
      21.03.2019 08:59

      Дорога в ад лежит через кредиты технологии сооблазны. Мне повезло, что я тупой, и неспособен освоить много фреймворков, и с удивлением обнаруживаю, что на чистой яве или там на го с жаваскриптом можно много чего написать. На работу только стало трудно устроиться без ангуляра последней версии :)


    1. saluev Автор
      21.03.2019 09:14

      Вы ведь понимаете, что все технологии, фреймворки — они не для сайта на одной vpsке с одним разработчиком? Одни для сайтов с огромной нагрузкой и для координации усилий сотен разработчиков. Конечно, для личного блога вам не нужен докер. Он нужен для зоопарка машин и сервисов, нескольких окружений (dev/stage/prod), быстрого добрасывания машин в кластер. CI/CD не нужно для личного блога — оно нужно для стартапа, который собирается пилить новые фичи каждый день. И так далее.


      1. epishman
        21.03.2019 09:37
        +1

        > для сайтов с огромной нагрузкой и для координации усилий сотен разработчиков
        ======================
        Кстати, это противоположные требования. Если нужна огромная нагрузка — чистый HTML+JS+CSS и никаких фреймворков, если сотни разработчиков — ровно наоборот. И черт его знает, где лежит та самая серебряная пуля, которой к тому же нет.


        1. saluev Автор
          21.03.2019 10:54

          Расскажите про чистый HTML+JS+CSS фейсбуку.


          1. 0ther
            21.03.2019 21:11

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


      1. janvarev
        21.03.2019 10:17

        Знаете, данный подход и статья, скорее всего неплохо работают в определенной ситуации (когда надо делать «небоскреб»)
        Но тут, наверное, мы попадаем на типовую «боль» современных разработчиков — а именно, что слишком часто идет переусложнение типовых вещей.

        Аналогично — занимаюсь веб-разработкой уже 15 лет. Работаю в малых проектах, пишу архитектуру, код (LAMP, на клиенте в очень небольших местах JS/JQuery). Недавно решил прогнать нагрузочное тестирование, просто для оценки, сколько может выдержать небольшой VDS.

        Оказалось — 10-20 тысяч клиентов в сутки (которые активно взаимодействуют с сервисом, а не просто посмотрели пару страничек)! Пиковая нагрузка до начала «подтормаживания» — что-то около 300 запросов в секунду. Это без специальных оптимизаций и докупки железа (очевидно, что железа на 1-2 машины всегда легче докупить, чем оплатить работу программиста).

        Естественно, этого будет мало для любой бесплатной соцсети/инстаграмма/мессенджера и пр. Но для абсолютного большинства малых и средних платных сервисов (и магазинов) такого хватает с головой.

        Как был приведен пример — просто слишком часто бывает ситуация, когда в проекте нужно администрировать 10-20 табличек в БД, и под него начинает наворачиваться докер, SPA, микросервисы… И чем думают техдиректора в таком проекте, мне прям сложно сказать.


        1. KirEv
          21.03.2019 14:31
          -2

          я знаю о чем они думаю, цепочка связи примерно такая: проект — больше технологий больше времени больше бабок — чем больше зоопарк тем больше сотрудников вовлечены отсюда каждый вовремя получает зп — больше технологий шире спектр дальнейшей поддержки… и клиент попадает в кабалу…

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

          как писал в одном из комменте не так давно: я программирую после работы. =)


          1. movl
            21.03.2019 15:21
            +1

            я знаю о чем они думаю

            В действительности, Вы просто экстраполируете свой опыт на всех. Вы говорите, что технологии используются в угоду "техно-хайпу" и в угоду спекулятивного роста компании. Печально, если Вам приходилось сталкиваться с подобной практикой, но это не отменяет того, что технологии могут использоваться по назначению, и что это может происходить осознанно. Поэтому Ваша патетика в начале треда выглядит не очень уместной.


      1. KirEv
        21.03.2019 14:18
        +1

        здесь есть 2 вещи:

        1. — весь этот зоопарк технологий сегодня превратился в мейнстрим, его использует только не ленивый, все проекты за посл.время перегружены всем этим, вот правда, не встретил по настоящему высоконагруженный проект…

        2. — перегрузка проекта метриками, фреймворками и т.п. сами по себе неоправданно увеличивают расходы проекта (трафик\запросы), основная моя компания в которой работаю уже более 7ми лет с самого начала ее основания не использует не то что облачные сервисы и чудовищной инфраструктуры, а и в кластере не было и нет необходимости, все по тому, что ядро и код писался нативно, и проблемы с всплесками трафика и дикой нагрузкой решались на уровне оптимизаций кода и алгоритмов, до сих пор все крутиться на 1 железном серваке… за все время озу лишь добавили и 4тб места…

        PS: простой пример, паралельно занимались разработкой микросервисов для 2х разных проектов в одной компании, я один над одним, команда разработчиков-подрядчиков над другим… у них сервис авторизации стал узким горлышком (все сервисы валидировали запросы и авторизировались через сервис авторизации), я же сервис авторизацию построил только для изменения пользователей, и каждый из десятка других сервисом содержат в своей БД таблицы сервиса авторизации и на основе репликации изменения в пользователях попадают на слейвы (сервисы отличны от auth)… это не только сократило чудовищный трафик и rps между сервисами, но и положительно повлияло на отклик целевого сервиса…

        Я старый программист, 15лет практики в ИТ — целая эпоха, строго не судите.


      1. aPiks
        21.03.2019 18:20
        -1

        Все технологии и фреймворки, которые вы перечислили — это результат того, что в бэкенд засунули технологии, которые для него изначально не предназначались. А теперь, когда поняли, что вся эта борода не работает нормально, начали латать дыры с помощью фреймворков и библиотек. Docker, CI, CD — полезные вещи, бесспорно. Но вот этот весь зоопарк технологий, которые вы перечислили — это просто мусор. Стартап, которому надо постоянно делать новые Фичи, должен делать фичи, а не разбираться с кучей фреймворков и багами, которые там есть.
        Вы привели в пример Facebook, но во первых, у Facebook достаточно специфичные задачи, а во вторых я не считаю, что это хороший пример производительности и безпроблемной работы. К тому же, смотреть как сделано у больших дядей и повторять за ними — глупо. У вас нет ни таких ресурсов, ни таких требований. Ваша архитектура избыточно сложна и зависит от стольких технологий, что будет сложно подобрать команду, чтоб человек пришел, сел и начать работать. Для стартапа — это минус. У них нет времени на обучение.
        Всё, что вы сделали в половине статьи, можно сделать, подняв Контейнер со Spring Boot на Back'end за 10 минут. И разработчиков проще найти и комплексность в разы меньше.


      1. Arris
        21.03.2019 22:50

        Вы ведь понимаете, что все технологии, фреймворки — они не для сайта на одной vpsке с одним разработчиком?

        Нет.
        Все эти технологии, фреймворки и так далее — это инструменты упрощения разработки. Ну, в теории, должны быть.

        На практике каша разных технологий дико повышает порог вхождения и дает огромные накладные расходы.

        И в этом проблема вашей статьи.

        Она не учит писать проекты с нуля и не объясняет как их нужно разрабатывать.

        Она приучает к определенному узкому стеку инструментов. Без которых, якобы, ничего сделать нельзя.


        1. saluev Автор
          21.03.2019 22:54

          Я нигде не писал, что без них ничего сделать нельзя. Я лишь показал, как сделать можно.


          1. Arris
            22.03.2019 01:13

            Но зачем так сложно?

            Картинку про троллейбус из буханки хлеба знаете?


            1. saluev Автор
              22.03.2019 09:53
              -1

              Если для вас элементарная архитектура, без которой даже design interview нельзя пройти, это сложно — я ничем не могу помочь. Я вроде бы обосновал необходимость всех компонент и даже ссылался на опыт крупных сервисов (в комментах).


    1. epsonic
      21.03.2019 13:46

      Прочел, вспомнил свои проекты, прослезился. У меня в течение чтения всей статьи были вооот такие глаза. Воооот такие, клянусь. Конечно, это не просто сайт, а веб-приложение, но от обилия новых (для меня лично) имен технологий рябит в глазах…


      1. a-tk
        21.03.2019 14:30

        Или даже вО_От такие?..


        1. epsonic
          21.03.2019 14:34

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


          1. a-tk
            22.03.2019 09:54

            Да я сам из стариков и помню времена, когда хабра ещё не было :)


  1. user9000
    21.03.2019 08:02
    -1

    В JS низкий порог входа. Никто не хочет изучать теорию, все сразу практикуются. И в итоге выдумывают все паттерны проектирования заново, только на свой лад.

    И есть ошибка в в основной архитектуре на которой все строится: фронтенд, который берет на себя слишком много функций и реализует все это на ЯП, в котором, из-за отсутсвия полноценного ООП, очень сложно организовать код без доп. фреймворков. И из-за этого и требуется писать столько лишних костылей.

    Я с интересом наблюдаю, как фрондендщики изобретают бэкэнд заново: сервер-роутер, рендер сервер. И спрашиваю себя: а у JS-way подхода точно больше плюсов? Или новые фишки придумываются чтоб подпереть старые костыли?


    1. epishman
      21.03.2019 08:48

      Это почему в JS нет полноценного ООП? Там только инкапсуляция хромает, нет приватных членов класса, а наследование на клиенте я очень активно применяю, особенно в сочетании с модулями.

      Что касается толстого фронтенда — тоже вопрос религиозный, PWA вообще предлагают с сервера только JSON брать, есть куча приложений на чистом JS, выполняющимися локально с локальной же браузерной СУБД, которая даже индексировать атрибуты умеет.


      1. user9000
        21.03.2019 08:53

        Ну вот и получается: инкапсуляция хромает, приватных членов класса нет, интерфейсов тоже.


        1. epishman
          21.03.2019 09:29

          Я делаю инкапсуляцию так — класс помещаю внутрь модуля и дефолтно экспортирую (один класс === один модуль === один файл), и тогда в замыкании можно инкапсулировать что угодно. Множественного наследования нет, согласен, плохо, но поскольку JS динамически выводит типы — полиморфизма можно добиться методом композиции, если уж очень надо. Теоретически можно на тайскрипте или дарте писать, без ангуляра в нагрузку, но мне пока и так хватает.


  1. epishman
    21.03.2019 08:39

    Господи, как сложно-то все, ради простого приложения с парой форм. Мама, верни мне Дельфи и Фокспро…


  1. youyou2020
    21.03.2019 09:08

    Питоном уже никто не пользуется.


    1. saluev Автор
      21.03.2019 09:15
      -1

      Смелое заявление)


    1. Soo
      21.03.2019 10:55

      Я то же постоянно про PHP слышу) А раз так — на чём лучше серверную часть писать?


      1. aPiks
        21.03.2019 19:23

        Для простого — PHP 7
        Для сложного — JAVA, C#
        3 года назад мне один сказал, что нет ничего лучше NodeJS, только он то боролся с утечками памяти, то переписывал под TypeScript, потому что устал бороться с проверками на тип, то еще что-то…
        А приложение на PHP как работало тогда без проблем, так и сейчас работает. Может не так быстро, то полусекундная разница в загрузке страницы — не так уж решает.


        1. saluev Автор
          21.03.2019 20:01

          Полусекундная разница в загрузке страницы иногда прокрашивается красным в A/B-экспериментах на крупных сервисах, так что я бы не был так уверен.


    1. ideological
      21.03.2019 11:47

      И чем же в вашей школе тогда пользуются?


      1. youyou2020
        21.03.2019 22:56
        -1

        Нормальными языками программирования такими как JS


        1. ideological
          21.03.2019 23:17

          И давно это JS стал нормальным языком?


          По-моему, ему однажды повезло быть встроенным в браузеры и теперь от этого legacy-говнища хрен избавишься.


          Но конечно каждому своё.


          1. fukkit
            22.03.2019 12:07

            Вы правы по поводу причин распространения и развития JS.
            Но от стадии 'legacy-говнища' шагнул он знатно и теперь шикарно смотрится на фоне отстающих собратьев, не говоря уже о шизофреническом питоне.


            1. ideological
              22.03.2019 12:29

              А что не так с Python, отступы не нравятся?


  1. ulkoart
    21.03.2019 10:55

    Отличная стать, спасибо! Хорошая база для проекта.


  1. ideological
    21.03.2019 11:37

    Это всё конечно круто, спасибо за статью.
    Но почему бы не отдавать сформированный динамически при каждом запросе готовый html контент прям на Python, а на JS если нужно сделать какой-то интерактив? Для чего нужен React+Redux?


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


    Вместо простой и быстрой отдачи html и удобного бекенда на любом языке, выбирать всякие заморочки на фронте. Не слишком ли усложнился фронтенд на пустом месте и без необходимости?


    1. saluev Автор
      21.03.2019 11:45

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


  1. alex_mayak
    21.03.2019 11:43

    Автору СПАСИБО!
    жду продолжения с юнит тестами, selenium и регрессивными тыстами =)


  1. dimuska139
    21.03.2019 13:24
    +1

    Глянул комментарии еще раз — аж бомбить начало :)
    Вот вы говорите, что этот подход — из пушки по воробьям. Ну ок, но ведь не все ограничивается блогами и досками объявлений. Вам не кажется, что автор в статье просто хотел показать разработку сайта «от и до»? Или он должен был все это показать на примере разработки аналога фейсбук? Это ж просто пример, вовсе не означающий, что блогоподобные сайты надо делать именно так.
    По поводу того, что ssr не нужен, и это лишнее звено. Это достаточно спорное утверждение. Я считаю, что SSR в 2019 до сих пор нужен, даже несмотря на то, что гугл умеет сайты на js индексировать. Во-первых, кроме гугла есть еще и другие поисковые системы, а, во-вторых, насколько я знаю, он до сих пор такие сайты индексирует хуже обычных.


    1. 0ther
      21.03.2019 21:13

      Если уж на то пошло, то и показан был только hello world, рождённый в очень лишних муках.
      А показать зачем надо было их потерпеть и какой пряник мы получаем из-за этого — нет, без этого смысл расчехлять BFG?


      1. dimuska139
        22.03.2019 15:20
        +1

        Тут в статье автору довольно запарно было бы расписывать преимущества такого усложнения, потому что надо попробовать самому.

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

        Рендеринг на сервере. Да, можно было не заморачиваться, отказавшись от него и оставив фронт на реакте, который дергает апи (бэкенд). Это было бы проще, но статьи с такого блога видел бы только гугл (и то плохо), а для остальных поисковиков ваш сайт бы состоял из пустых страниц.

        Если делать сайт, рендеря по-старинке html на сервере средствами пхп/питона, то сделать нормально сложный качественный поддерживаемый фронтенд для этого всего достаточно сложно. А уж про отзывчивость такого интерфейса говорить, думаю, не надо, потому что сайты в виде SPA работают при переходе по страницам плавно и приятно — никакой полной перезагрузки страницы. Тут спорить можно долго: надо просто попробовать и сравнить. Можно то же самое сделать руками на jQuery, загружая аяксом контент статей блога, но тут опять упираемся в сложности с рендерингом на сервере. А, кстати, вручную настраивать системы сборки js и css для задач типа обфускации js-кода, объединения в один файл и т.п. для самописного фронта — так ли уж это проще разве?

        Так что в итоге два варианта: либо так, заморочившись с инфраструктурой (хотя лично я тут не вижу сложностей вообще), либо ваять сайты по-старинке с бэком, где рендерятся шаблоны, а на фронте jquery-лапша. Учитывая мой опыт поддержки легаси-проектов, написанных с использованием второго подхода: нахрен оно надо вообще. И не проще это ни разу, особенно когда проект растет, а его фронтенд становится сложнее и сложнее.


  1. minalexpro
    21.03.2019 13:33

    А существует подобная статья для PHP вместо Python и всё что с ним связано или для LAMP?


    1. dimuska139
      21.03.2019 13:59
      +1

      Так просто замените ту часть, что написана на питоне, на код на пхп — и все. Это ж просто API — их можно писать на чем угодно. Остальные-то части проекта не затрагиваются. В этом один из плюсов такой архитектуры: любая часть такого проекта может независимо переписана, не затрагивая другие части.


      1. a-tk
        21.03.2019 14:31

        Осталось только оценить вероятность переписывания и сопоставить риски необходимости переписывания с затратами на подготовку инфраструктуры к переписыванию со старта.


        1. dimuska139
          21.03.2019 14:42

          Так эта инфраструктура вовсе не так сложна, как может показаться изначально. По сути — это просто разделение бэкенда и фронтенда, ну и ssr. Ssr вообще не особо нужен, если для сайта не важно, насколько хорошо его будут индексировать поисковые системы. То есть по сути эта инфраструктура состоит из двух частей — фронт, взаимодействующий с бэком по апи. Разве тут могут быть какие-то сложности? Это ж стандартный современный сайт. Сайты, сделанные в виде SPA работают быстро, плавно, при переключении по страницам не происходит полной перезагрузки — это выглядит приятно и современно.

          Бэкенд-разработчики разрабатывают API, отдают фронтенд-разработчикам файл swagger.yml, по которому фронтенд-разработчики независимо делают фронтенд. Каждый занят своим делом.

          На мой взгляд, такая инфраструктура явно выглядит лучше, чем рендеринг шаблонов на сервере с какими-то шаблонными тегами, завязанными на особенности фреймворка, обучение фронтенд-разработчиков конструкциям шаблонизатора и последующие прикручивание, как почему-то до сих пор модно, jQuery, потому что React/Angular/Vue — это сложно, а потому не нужно.

          А что касается вероятности переписывания, то сайт, у которого фронт сделан в виде jQuery-лапши, а шаблоны рендерятся на сервере, намертво вколоченные в какой-то фреймворк и его особенности, может быть потому и не переписывают, потому что переписать такое без боли просто невозможно?


          1. Soo
            21.03.2019 15:13

            У меня был проект, который был написан на чистом РНР без фреймворков и без API, но по факту, сделанный в виде SPA. Модули были написаны с помощью шаблонизатора SMARTY и просто при AJAX-запросе подгружались как странички в определённую область. Не сказал бы, что это работало медленно (там тормозила СУБД, так как бизнес-логика почему-то была вся там), но было модно и молодёжно на тот момент). И почти без боли)


            1. dimuska139
              21.03.2019 15:22

              Гонять html-куски через апи — это так себе решение. Про то, что такой проект при усложнении логики и расширении функциональности поддерживать во вменяемом в плане качества состоянии сложно — это отдельная тема. Также при таком подходе нередко приходится фронтенд-разработчиков посвящать во внутреннее устройство серверной части проекта: где какие шаблоны, как используются и т.п. Практически любые изменения в дизайне страниц не могут проходить без участия бэкенд-разраюотчиков. Со временем это превращается в лютое легаси без какой-либо возможности что-то с этим сделать. Имел дело с такими проектами не раз — везде одни и те же проблемы.


            1. a-tk
              22.03.2019 09:58

              Я подобное в далёком 2006 делал на своей первой работе…
              Только там на входе была модель в XML, которая рендерилась на сервере с помощью XSLT и отдавалась целиком, а потом отдельные куски приезжали клиенту через AJAX в XML и там уже преобразовывались через XSLT в XHMLT и вставлялось в нужные места.
              Но я давно ушёл из веба в профессиональном плане, хотя немного общие тенденции стараюсь отслеживать.


          1. ideological
            21.03.2019 23:39

            > «На мой взгляд, такая инфраструктура явно выглядит лучше, чем рендеринг шаблонов на сервере с какими-то шаблонными тегами, завязанными на особенности фреймворка, обучение фронтенд-разработчиков конструкциям шаблонизатора и последующие прикручивание, как почему-то до сих пор модно, jQuery, потому что React/Angular/Vue — это сложно, а потому не нужно.»

            Ну по сути оба подхода одинаковы (в случае простого сайта конечно, а не чего-то вроде клиента телеги).

            • Либо на сервере вся логика и генерация_html, а на фронте чуток JS/jQuery.
            • Либо наоборот на сервере только API, а уже на фронте вся логика/ маршрутизация/генерация_html.


            Одинокие волкивебмастера обычно выбирают первый.

            Во втором случае существует профессия фронтендер :). Конечно этот вариант может быть более оправдан в ряде случаев, особенно при постоянных сменах дизайна и когда бекендеры испытывают отвращение к JS и всему моднобраузерохипстерскому.


  1. lotse8
    21.03.2019 18:15

    Теперь только осталось найти кто это купит.


  1. 0ther
    21.03.2019 21:22

    Оккам порезал себе вены бритвой, глядя на ничуть не лишний ворох всего.
    А достаточно же просто файла.html,

    <h1>Hello, word!</h1>
    и браузера для того, чтобы им открыть и увидеть результат.
    Хотя и интересно.

    Понятно что имелось ввиду «Так делают крутые дяди из гугла/фейсбука/вставь_своё, ибо куча плюшек!», но без показа этих самых плюшек, BFG тут выглядит прям очень лишним.

    Довольно печально что в наше время уклон идёт в инструменты, а не решение задач.
    Как посмотрю сколько модулей, так вспоминаю KISS и плакать хочется =)


    1. saluev Автор
      21.03.2019 21:31

      Если бы я ушёл в плюшки, статья (и без того не маленькая) стала бы ещё больше в десять раз. Но про плюшки тоже когда-нибудь напишу.


      1. 0ther
        21.03.2019 21:53

        Так без плюшек и статья теряет смысл ИМХО.
        Плюшки обязательно показать надо. Тесты там, расширения и отличия от обычного апача с пэхапе, например.
        Ждём следующей статьи.


    1. a-tk
      22.03.2019 10:02

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


  1. divanus
    21.03.2019 21:34

    Горите в аду кожаные ублюдки!
    format MZ
    heap 0
    stack 100h
    entry main:start
    segment main use16
    start: push 0
    pop fs ; vector table
    mov ax,data_segment
    mov ds,ax
    mov es,ax
    mov ax, 13h ; modeX Graphics 320x200x256, 40x25, 8x8
    int 10h
    mov eax, dword [fs:43h*4]
    mov dword [int43], eax ; Get 8x8 chargen ptr
    mov si, hello
    next: call gotoxy
    lodsb
    or al,al
    jz exit
    cmp al,20h ; ?
    jnz @1
    add byte [Y], 11
    mov byte [X], 0
    jmp next
    @1: movzx ax,al
    shl ax, 3 ; ax*8
    push si
    push ds
    lds si, [int43]
    add si, ax
    ; Вывод на экран
    mov cx, 8
    loo0: lodsb
    mov bl, al
    push cx
    mov cx, 8
    loo1: mov al, 20h
    rcl bl, 1
    jnc @@1
    mov al, 0DBh
    @@1: int 29h
    loop loo1
    pop cx
    inc byte [es:Y]
    call gotoxy
    loop loo0
    pop ds
    pop si
    add byte [X], 8
    sub byte [Y], 8
    jmp next
    ; выход
    exit: xor ah, ah
    int 16h
    mov ax, 03h
    int 10h
    mov ah, 4Ch
    int 21h
    gotoxy: pusha
    mov ah,2
    xor bx,bx
    mov dx, word [es:XY]
    int 10h
    popa
    ret
    segment data_segment use16
    int43: dd ?
    XY:
    X: db 0
    Y: db 3
    hello: db 'Hello world',0


    1. Arris
      22.03.2019 00:31

      Что так сложно-то?
      org 0x100
      mov dx, msg
      mov ah, 9
      int 0x21
      iret
      msg db 'Hello, World!', 0x0d, 0x0a, '$'


      1. a-tk
        22.03.2019 10:03

        То была эмуляция брейнфака на ассёмблере?