Créer une application Web moderne à partir de zéro

Vous avez donc décidé de faire un nouveau projet. Et ce projet est une application web. Combien de temps faut-il pour créer un prototype de base? Est-ce difficile? Que devrait faire un site Web moderne dÚs le départ?

Dans cet article, nous allons essayer de décrire le passe-partout d'une application web simple avec l'architecture suivante:


Ce que nous couvrirons:

  • configuration de l'environnement de dĂ©veloppement dans docker-compose.
  • crĂ©ation de backend sur Flask.
  • crĂ©er une interface sur Express.
  • Construisez JS Ă  l'aide de Webpack.
  • React, Redux et rendu cĂŽtĂ© serveur.
  • files d'attente de tĂąches avec RQ.

Présentation


Avant le dĂ©veloppement, bien sĂ»r, vous devez d'abord dĂ©cider de ce que nous dĂ©veloppons! En tant qu'application modĂšle pour cet article, j'ai dĂ©cidĂ© de crĂ©er un moteur wiki primitif. Nous aurons des cartes Ă©mises dans Markdown; ils peuvent ĂȘtre regardĂ©s et (dans le futur) proposer des modifications. Tout cela, nous organiserons une application d'une page avec un rendu cĂŽtĂ© serveur (ce qui est absolument nĂ©cessaire pour indexer nos futurs tĂ©raoctets de contenu).

Examinons un peu plus en détail les composants dont nous avons besoin pour cela:

  • Client CrĂ©ons une application d'une page (c'est-Ă -dire avec des transitions de page en utilisant AJAX) sur le bundle React + Redux , ce qui est trĂšs courant dans le monde frontal.
  • Frontend . CrĂ©ons un simple serveur Express qui restituera notre application React (demandant toutes les donnĂ©es nĂ©cessaires dans le backend de maniĂšre asynchrone) et les enverra Ă  l'utilisateur.
  • Backend . MaĂźtre de la logique mĂ©tier, notre backend sera une petite application Flask. Nous allons stocker les donnĂ©es (nos cartes) dans le rĂ©fĂ©rentiel de documents populaire MongoDB , et pour la file d'attente des tĂąches et, Ă©ventuellement, Ă  l'avenir, la mise en cache, nous utiliserons Redis .
  • TRAVAILLEUR . Un conteneur sĂ©parĂ© pour les tĂąches lourdes sera lancĂ© par la bibliothĂšque RQ .

Infrastructure: git


Nous ne pourrions probablement pas en parler, mais, bien sûr, nous effectuerons le développement dans le référentiel git.

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

(Ici, vous devez immédiatement remplir .gitignore .)

Le projet final peut ĂȘtre consultĂ© sur Github . Chaque section de l'article correspond Ă  un commit (j'ai beaucoup rebazĂ© pour y parvenir!).

Infrastructure: docker-compose


Commençons par configurer l'environnement. Avec l'abondance de composants que nous avons, une solution de développement trÚs logique serait d'utiliser docker-compose.

Ajoutez le fichier docker-compose.yml au référentiel docker-compose.yml contenu suivant:

 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 

Jetons un coup d'Ɠil à ce qui se passe ici.

  • Un conteneur MongoDB et un conteneur Redis sont créés.
  • Un conteneur pour notre backend est créé (que nous dĂ©crivons ci-dessous). La variable d'environnement APP_ENV = dev lui est transmise (nous allons l'examiner pour comprendre quels paramĂštres Flask charger), et son port 40001 s'ouvrira Ă  l'extĂ©rieur (Ă  travers lui notre client de navigateur ira Ă  l'API).
  • Un conteneur de notre frontend est créé. Une variĂ©tĂ© de variables d'environnement y sont Ă©galement ajoutĂ©es, qui nous seront utiles plus tard, et le port 40002 s'ouvre.C'est le port principal de notre application Web: dans le navigateur, nous irons Ă  http: // localhost: 40002 .
  • Le conteneur de notre travailleur est créé. Il n'a pas besoin de ports externes, et seul l'accĂšs est requis dans MongoDB et Redis.

CrĂ©ons maintenant des fichiers docker. À l'heure actuelle, une sĂ©rie de traductions d' excellents articles sur Docker arrive Ă  HabrĂ© - vous pouvez y aller en toute sĂ©curitĂ© pour tous les dĂ©tails.

Commençons par le backend.

 # 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 

Il est entendu que nous parcourons l'application gunicorn Flask, en nous cachant sous le nom app dans le module backend.server .

docker/backend/.dockerignore non moins important:

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

Le travailleur est généralement similaire au backend, mais au lieu de gunicorn, nous avons le lancement habituel d'un module de fosse:

 # 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 

Nous ferons tout le travail dans worker/__main__.py .

.dockerignore ouvrier .dockerignore est complĂštement similaire au backend .dockerignore .

Enfin, le frontend. Il y a tout un article à son sujet sur Habré, mais à en juger par la discussion approfondie sur StackOverflow et les commentaires dans l'esprit de "Les gars, est-ce déjà 2018, n'y a-t-il toujours pas de solution normale?" tout n'y est pas si simple. Je me suis installé sur cette version du fichier docker.

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

Avantages:

  • tout est mis en cache comme prĂ©vu (sur la couche infĂ©rieure - dĂ©pendances, sur le dessus - la construction de notre application);
  • docker-compose exec frontend npm install --save newDependency fonctionne comme il se doit et modifie package.json dans notre rĂ©fĂ©rentiel (ce qui ne serait pas le cas si nous utilisions COPY, comme beaucoup de gens le suggĂšrent). Il ne serait pas souhaitable d'exĂ©cuter npm install --save newDependency dehors du conteneur de toute façon, car certaines dĂ©pendances du nouveau package peuvent dĂ©jĂ  ĂȘtre prĂ©sentes et ĂȘtre construites sous une plate-forme diffĂ©rente (sous celle Ă  l'intĂ©rieur du docker, et non sous notre macbook de travail, par exemple ), et pourtant nous ne voulons gĂ©nĂ©ralement pas exiger la prĂ©sence de Node sur la machine de dĂ©veloppement. Un Docker pour les gouverner tous!

Eh bien et bien sûr docker/frontend/.dockerignore :

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

Ainsi, notre cadre de conteneur est prĂȘt et vous pouvez le remplir de contenu!

Backend: framework Flask


Ajoutez flask , flask-cors , gevent et gunicorn à requirements.txt et créez une application Flask simple dans backend/server.py .

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

Nous avons demandé à Flask de récupérer les paramÚtres du fichier backend.{env}_settings , ce qui signifie que nous devrons également créer un fichier backend/dev_settings.py (au moins vide) pour que tout décolle.

Maintenant, nous pouvons officiellement augmenter notre backend!

 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 

Nous continuons.

Frontend: framework Express


Commençons par créer un package. AprÚs avoir créé le dossier frontal et exécuté npm init dedans, aprÚs quelques questions simples, nous obtenons le package.json fini dans l'esprit

 { "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" } 

À l'avenir, nous n'aurons plus besoin de Node.js sur la machine du dĂ©veloppeur (bien que nous puissions encore esquiver et dĂ©marrer npm init via Docker, mais bon).

Dans Dockerfile nous avons mentionné npm run build et npm run start - vous devez ajouter les commandes appropriées à 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": { 

La commande build ne fait rien encore, mais elle nous sera toujours utile.

Ajoutez des dépendances Express et créez une application simple dans 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!") }); 

Maintenant, le docker-compose up frontend augmente notre frontend! De plus, sur http: // localhost: 40002 , le classique "Hello, world" devrait déjà se montrer.

Frontend: build avec webpack et application React


Il est temps de représenter quelque chose de plus que du texte brut dans notre application. Dans cette section, nous allons ajouter le composant React le plus simple de l' App et configurer l'assemblage.

Lors de la programmation dans React, il est trÚs pratique d'utiliser JSX , un dialecte de JavaScript étendu par des constructions syntaxiques du formulaire

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

Cependant, les moteurs JavaScript ne le comprennent pas, donc généralement la phase de construction est ajoutée au frontend. Des compilateurs JavaScript spéciaux (ouais-ouais) transforment le sucre syntaxique en laid laid classique, gÚrent les importations, réduisent, etc.



2014 année. apt-cache java de recherche

Ainsi, le composant React le plus simple semble trĂšs simple.

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

Il affichera simplement notre message d'accueil avec une épingle plus convaincante.

Ajoutez le fichier frontend/src/template.js contenant le framework HTML minimum de notre future application:

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

Ajoutez un point d'entrée client:

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

Pour construire toute cette beauté, nous avons besoin de:

webpack est un constructeur de jeunesse à la mode pour JS (bien que je n'aie pas lu d'articles sur le frontend pendant trois heures, donc je ne suis pas sûr de la mode);
babel est un compilateur pour toutes sortes de lotions comme JSX, et en mĂȘme temps un fournisseur de polyfill pour tous les cas IE.

Si l'itération précédente du frontend est toujours en cours d'exécution, tout ce que vous avez à faire est de

 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 

pour installer de nouvelles dépendances. Maintenant, configurez 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]; 

Pour faire fonctionner babel, vous devez configurer frontend/.babelrc :

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

Enfin, npm run build sens Ă  notre commande npm run build :

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

Maintenant, notre client, avec un ensemble de polyfills et toutes ses dépendances, parcourt babel, compile et se plie en un fichier ../dist/client.js monolithique ../dist/client.js . Ajoutez la possibilité de le télécharger en tant que fichier statique dans notre application Express, et dans l'itinéraire par défaut, nous commencerons à renvoyer notre code 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); 

SuccÚs! Maintenant, si nous docker-compose up --build frontend , nous verrons "Bonjour, monde!" dans un nouvel emballage brillant, et si l'extension React Developer Tools est installée ( Chrome , Firefox ), il y a aussi une arborescence de composants React dans les outils de développement:



Backend: données dans MongoDB


Avant de continuer et de donner vie à notre application, vous devez d'abord l'insuffler dans le backend. Il semble que nous allions stocker les cartes marquées dans Markdown - il est temps de le faire.

Bien qu'il existe des ORM pour MongoDB en python , je considÚre que l'utilisation des ORM est vicieuse et je vous laisse étudier les solutions appropriées. Au lieu de cela, nous allons créer un cours simple pour la carte et le DAO qui l'accompagne:

 # 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 

(Si vous n'utilisez toujours pas d'annotations de type en Python, assurez-vous de consulter ces articles !)

CardDAO maintenant une implémentation de l'interface CardDAO qui prend en entrée un objet Database de pymongo (oui, il est temps d'ajouter 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) 

Il est temps d'enregistrer la configuration Monga dans les paramÚtres du backend. Nous avons simplement nommé notre conteneur avec mongo mongo , donc 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" 

Nous devons maintenant crĂ©er MongoCardDAO et lui donner accĂšs Ă  l'application Flask. Bien que nous ayons maintenant une hiĂ©rarchie d'objets trĂšs simple (paramĂštres → client pymongo → base de donnĂ©es pymongo → MongoCardDAO ), crĂ©ons immĂ©diatement un composant king centralisĂ© qui effectue l' injection de dĂ©pendances (il sera Ă  nouveau utile lorsque nous ferons le travailleur et les outils).

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


Il est temps d'ajouter un nouvel itinéraire à l'application Flask et de profiter de la vue!

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

Redémarrez avec la commande docker-compose up --build backend :



Oups ... oh, exactement. Nous devons ajouter du contenu! Nous allons ouvrir le dossier des outils et y ajouter un script qui ajoute une carte de test:

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

La commande docker-compose exec backend python -m tools.add_test_content remplira notre monga de contenu à l'intérieur du conteneur docker-compose exec backend python -m tools.add_test_content .



SuccĂšs! Il est maintenant temps de soutenir cela sur le front-end.

Frontend: Redux


Maintenant, nous voulons crĂ©er la route /card/:id_or_slug , par laquelle notre application React va s'ouvrir, charger les donnĂ©es de la carte Ă  partir de l'API et nous les montrer en quelque sorte. Et ici, peut-ĂȘtre, la partie la plus difficile commence, car nous voulons que le serveur nous donne immĂ©diatement du HTML avec le contenu de la carte, adaptĂ© Ă  l'indexation, mais en mĂȘme temps, lorsque l'application navigue entre les cartes, elle reçoit toutes les donnĂ©es sous forme de JSON de l'API, et la page ne surcharge pas. Et pour que tout cela - sans copier-coller!

Commençons par ajouter Redux. Redux est une bibliothĂšque JavaScript pour stocker l'Ă©tat. L'idĂ©e est qu'au lieu des mille Ă©tats implicites que vos composants changent au cours des actions de l'utilisateur et d'autres Ă©vĂ©nements intĂ©ressants, ils ont un Ă©tat centralisĂ© et apportent toute modification via un mĂ©canisme centralisĂ© d'actions. Donc, si plus tĂŽt pour la navigation, nous avons d'abord activĂ© le GIF de chargement, nous avons ensuite fait une demande via AJAX et, enfin, dans le rappel de succĂšs, nous avons mis Ă  jour les parties nĂ©cessaires de la page, puis dans le paradigme Redux, nous sommes invitĂ©s Ă  envoyer l'action "changer le contenu en gif avec animation", qui changera l'Ă©tat global de sorte qu'un de vos composants jette le contenu prĂ©cĂ©dent et place l'animation, puis fait une demande et envoie une autre action dans son rappel de succĂšs, «changez le contenu en chargé». En gĂ©nĂ©ral, nous allons maintenant le voir nous-mĂȘmes.

Commençons par installer de nouvelles dépendances dans notre conteneur.

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

Le premier est, en fait, Redux, le second est une bibliothÚque spéciale pour croiser React et Redux (écrit par des experts en accouplement), le troisiÚme est une chose trÚs nécessaire, dont la nécessité est bien justifiée dans son README , et, enfin, le quatriÚme est la bibliothÚque nécessaire au fonctionnement de Redux DevTools Extension .

Commençons par le code passe-partout Redux: créer un réducteur qui ne fait rien et initialiser l'état.

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

Notre client change un peu, se préparant mentalement à travailler avec 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') ); 

Nous pouvons maintenant exécuter docker-compose up --build frontend pour nous assurer que rien n'est cassé, et notre état primitif est apparu dans Redux DevTools:



Frontend: Page de carte


Avant de pouvoir créer des pages avec des SSR, vous devez créer des pages sans SSR! Utilisons enfin notre ingénieuse API pour accéder aux cartes et composons la page de la carte sur le front end.

Il est temps de profiter de l'intelligence et de repenser notre structure étatique. Il y a beaucoup de documents sur ce sujet, donc je suggÚre de ne pas abuser de l'intelligence et de me concentrer sur le simple. Par exemple, tels que:

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

Prenons le composant «card», qui prend le contenu de cardData comme accessoire (c'est en fait le contenu de notre carte en 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; 

Maintenant, obtenons un composant pour toute la page avec la carte. Il sera responsable d'obtenir les données nécessaires de l'API et de les transférer sur Card. Et nous allons récupérer les données de la maniÚre React-Redux.

Tout d'abord, créez le fichier frontend/src/redux/actions.js et créez une action qui extrait le contenu de la carte de l'API, si ce n'est déjà fait:

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

L'action fetchCard , qui rend en fait la récupération, un peu plus compliquée:

 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"; } 

Oh, nous avons une action QUELQUE CHOSE FAIT! Cela doit ĂȘtre pris en charge dans le rĂ©ducteur:

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

(Faites attention Ă  la syntaxe tendance pour cloner un objet avec des champs individuels changeants.)

Maintenant que toute la logique est exĂ©cutĂ©e dans les actions Redux, le composant lui CardPage- mĂȘme aura l'air relativement simple:

 // 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); 

Ajoutez un simple traitement page.type Ă  notre composant d'application racine:

 // 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); 

Et maintenant, le dernier moment reste - vous devez en quelque sorte initialiser page.typeet en page.cardSlugfonction de l'URL de la page.

Mais il y a encore de nombreuses sections dans cet article, mais nous ne pouvons pas faire une solution de haute qualité pour le moment. Faisons-le stupide pour l'instant. C'est complÚtement stupide. Par exemple, un habitué lors de l'initialisation de l'application!

 // 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') ); 

Maintenant, nous pouvons reconstruire l'interface avec l'aide docker-compose up --build frontendpour profiter de notre carte helloworld...



Alors, attendez une seconde ... et oĂč est notre contenu? Oh, nous avons oubliĂ© d'analyser Markdown!

Travailleur: RQ


Analyser Markdown et gĂ©nĂ©rer du HTML pour une carte de taille potentiellement illimitĂ©e est une tĂąche «lourde» typique qui, au lieu d'ĂȘtre rĂ©solue directement sur le backend lors de l'enregistrement des modifications, est gĂ©nĂ©ralement mise en file d'attente et exĂ©cutĂ©e sur des machines de travail distinctes.

Il existe de nombreuses implémentations open source de files d'attente de tùches; nous prendrons Redis et une simple bibliothÚque RQ (Redis Queue), qui transmet les paramÚtres des tùches au format pickle et nous organise des processus de génération pour leur traitement.

Il est temps d'ajouter des radis en fonction des réglages et du cùblage!

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

Un peu de code passe-partout pour le travailleur.

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

Pour l'analyse elle-mĂȘme, connectez la bibliothĂšque mistune et Ă©crivez une fonction simple:

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

Logiquement: il faut CardDAOrĂ©cupĂ©rer le code source de la carte et sauvegarder le rĂ©sultat. Mais l'objet contenant la connexion au stockage externe ne peut pas ĂȘtre sĂ©rialisĂ© via pickle - ce qui signifie que cette tĂąche ne peut pas ĂȘtre immĂ©diatement prise et mise en file d'attente pour RQ. Dans le bon sens, nous devons crĂ©er Wiringun travailleur sur le cĂŽtĂ© et le jeter de toutes sortes ... Faisons-le:

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

Nous avons dĂ©clarĂ© notre classe d'emplois, en jetant le cĂąblage comme un argument kwargs supplĂ©mentaire dans toutes les tĂąches. (Notez qu'il crĂ©e un NOUVEAU cĂąblage Ă  chaque fois, car certains clients ne peuvent pas ĂȘtre créés avant le fork qui se produit dans RQ avant que la tĂąche ne commence le traitement.) Pour que toutes nos tĂąches ne dĂ©pendent pas du cĂąblage - c'est-Ă -dire de TOUS nos objets - disons Faisons un dĂ©corateur qui n'obtiendra que le nĂ©cessaire du cĂąblage:

 # 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 

Ajoutez un décorateur à notre tùche et profitez de la vie:

 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) 

Profitez de la vie? Ugh, je voulais dire, nous commençons le travailleur:

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

III ... il ne fait rien! Bien sûr, car nous n'avons pas fixé une seule tùche!

Réécrivons notre outil, qui crée une carte de test, pour qu'il: a) ne tombe pas si la carte est déjà créée (comme dans notre cas); b) mettre la tùche sur l'analyse de marqdown.

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

Les outils peuvent dĂ©sormais ĂȘtre exĂ©cutĂ©s non seulement sur le backend, mais Ă©galement sur le travailleur. En principe, maintenant on s'en fout. Nous le lançons docker-compose exec worker python -m tools.add_test_contentet dans un onglet voisin du terminal, nous voyons un miracle - le travailleur a fait QUELQUE CHOSE!

 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 

AprĂšs avoir reconstruit le conteneur avec le backend, nous pouvons enfin voir le contenu de notre carte dans le navigateur:



Navigation frontend


Avant de passer Ă  la SSR, nous devons faire tout notre possible avec React au moins quelque chose de significatif et rendre notre application de page unique vraiment une seule page. Mettons Ă  jour notre outil pour crĂ©er deux (PAS UN, DEUX! MOM, JE SUIS GRAND DÉVELOPPEUR!) Cartes qui se lient les unes aux autres, puis nous traiterons de la navigation entre elles.

Texte masqué
 # 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**! """ )) 


Maintenant, nous pouvons suivre les liens et voir comment chaque fois que notre merveilleuse application redĂ©marre. ArrĂȘte ça!

Tout d'abord, mettez votre gestionnaire sur les clics sur les liens. Depuis HTML avec des liens vient du backend, et nous avons l'application sur React, nous avons besoin d'un peu de focus spécifique à 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> ); } } 

Étant donnĂ© que toute la logique du chargement des cartes dans notre composant CardPage, dans l'action elle-mĂȘme (incroyable!), Aucune action ne doit ĂȘtre prise:

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

Ajoutez un réducteur idiot pour ce cas:

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

Étant donnĂ© que l'Ă©tat de notre application peut maintenant changer, CardPagenous devons ajouter une mĂ©thode componentDidUpdateidentique Ă  celle que nous avons dĂ©jĂ  ajoutĂ©e componentWillMount. Maintenant, aprĂšs avoir mis Ă  jour les propriĂ©tĂ©s CardPage(par exemple, les propriĂ©tĂ©s cardSlugpendant la navigation), le contenu de la carte du backend sera Ă©galement demandĂ© (il componentWillMountne l' a fait que lorsque le composant a Ă©tĂ© initialisĂ©).

D'accord, docker-compose up --build frontendet nous avons une navigation qui fonctionne!



Un lecteur attentif notera que l'URL de la page ne changera pas lors de la navigation entre les cartes - mĂȘme dans la capture d'Ă©cran, nous voyons Bonjour, la carte du monde Ă  l'adresse de la carte de dĂ©monstration. En consĂ©quence, la navigation avant-arriĂšre a Ă©galement chutĂ©. Ajoutons tout de suite de la magie noire avec l'histoire pour y remĂ©dier!

La chose la plus simple que vous puissiez faire est d'ajouter à l'actionnavigateun défi history.pushState.

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

Maintenant, en cliquant sur les liens, l'URL dans la barre d'adresse du navigateur va vraiment changer. Cependant, le bouton de retour se cassera !

Pour le faire fonctionner, nous devons écouter l'événement de l' popstateobjet window. De plus, si dans cet événement nous voulons faire la navigation aussi bien en arriÚre qu'en avant (c'est-à-dire à travers dispatch(navigate(...))), nous devrons navigateajouter un drapeau spécial «ne pas pushState» à la fonction (sinon tout se cassera encore plus!). De plus, pour faire la distinction entre «nos» états, nous devons utiliser la possibilité pushStatede sauvegarder les métadonnées. Il y a beaucoup de magie et de débogage, alors passons au code! Voici à quoi ressemblera l'application:

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

Et voici l'action de navigation:

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

Maintenant, l'histoire fonctionnera.

Eh bien, la derniÚre touche: puisque nous avons maintenant une action navigate, pourquoi ne pas abandonner le code supplémentaire dans le client qui calcule l'état initial? Nous pouvons simplement appeler naviguer vers l'emplacement actuel:

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

Copier-coller détruit!

Frontend: rendu cÎté serveur


Il est temps pour nos puces principales (Ă  mon avis) - SEO-friendly. Pour que les moteurs de recherche puissent indexer notre contenu, qui est complĂštement créé dynamiquement dans les composants React, nous devons ĂȘtre en mesure de leur donner le rĂ©sultat du rendu React, et aussi apprendre Ă  rendre ce rĂ©sultat interactif Ă  nouveau.

Le schĂ©ma gĂ©nĂ©ral est simple. PremiĂšrement: nous devons insĂ©rer le code HTML gĂ©nĂ©rĂ© par notre composant React dans notre modĂšle HTML App. Ce HTML sera vu par les moteurs de recherche (et les navigateurs avec JS dĂ©sactivĂ©, hehe). DeuxiĂšmement: ajoutez une balise au modĂšle <script>qui enregistre quelque part (par exemple, un objet window) un vidage d'Ă©tat Ă  partir duquel ce HTML a Ă©tĂ© rendu. Ensuite, nous pouvons immĂ©diatement initialiser notre application cĂŽtĂ© client avec cet Ă©tat et montrer ce qui est nĂ©cessaire (nous pouvons mĂȘme utiliser l' hydrateau HTML gĂ©nĂ©rĂ©, afin de ne pas recrĂ©er l'arborescence DOM de l'application).

Commençons par écrire une fonction qui retourne le HTML rendu et l'état final.

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

Ajoutez de nouveaux arguments et une nouvelle logique à notre modÚle, dont nous avons parlé ci-dessus:

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

Notre serveur Express devient un peu plus compliqué:

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

Mais le client est plus simple:

 // 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') ); 

Ensuite, vous devez nettoyer les erreurs multiplates-formes telles que «l'historique n'est pas défini». Pour ce faire, ajoutez une fonction simple (jusqu'à présent) quelque part dans utility.js.

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

Ensuite, il y aura un certain nombre de changements de routine que je n'apporterai pas ici (mais ils peuvent ĂȘtre trouvĂ©s dans le commit correspondant ). En consĂ©quence, notre application React sera en mesure d'afficher Ă  la fois dans le navigateur et sur le serveur.

Ça marche!Mais il y a, comme on dit, une mise en garde ...



CHARGEMENT? Tout ce que Google voit sur mon service de mode super cool, c'est LOADING?!

Eh bien, il semble que tout notre asynchronisme a joué contre nous. Nous avons maintenant besoin d'un moyen pour que le serveur comprenne que la réponse du backend avec le contenu de la carte doit attendre avant de rendre l'application React à une chaßne et de l'envoyer au client. Et il est souhaitable que cette méthode soit assez générale.

Il peut y avoir de nombreuses solutions. Une approche consiste Ă  dĂ©crire dans un fichier sĂ©parĂ© pour quels chemins quelles donnĂ©es doivent ĂȘtre sĂ©curisĂ©es, et cela avant de rendre l'application ( article ). Cette solution prĂ©sente de nombreux avantages. C'est simple, c'est explicite et ça marche.

À titre expĂ©rimental (le contenu original devrait ĂȘtre dans l'article au moins quelque part!) Je propose un autre schĂ©ma. À chaque fois que nous exĂ©cutons quelque chose d'asynchrone, que nous devons attendre, ajoutons la promesse appropriĂ©e (par exemple, celle qui retourne chercher) quelque part dans notre Ă©tat. Nous aurons donc un endroit oĂč vous pourrez toujours vĂ©rifier si tout a Ă©tĂ© tĂ©lĂ©chargĂ©.

Ajoutez deux nouvelles actions.

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


Le premier sera appelé lorsque le fetch est lancé, le second - à la fin de celui-ci .then().

Ajoutez maintenant leur traitement au réducteur:

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

Maintenant, nous allons améliorer l'action 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)); }; } 

Il reste Ă  ajouter des initialStatepromesses au tableau vide et Ă  faire attendre le serveur! La fonction de rendu devient asynchrone et prend la forme suivante:

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

En raison de l' renderasynchronie acquise , le gestionnaire de requĂȘtes est Ă©galement lĂ©gĂšrement plus compliquĂ©:

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

Et voilĂ !



Conclusion


Comme vous pouvez le voir, créer une application de haute technologie n'est pas si simple. Mais pas si difficile! L'application finale se trouve dans le référentiel sur Github et, théoriquement, vous n'avez besoin que de Docker pour l'exécuter.

Si l'article est en demande, ce rĂ©fĂ©rentiel ne sera mĂȘme pas abandonnĂ©! Nous pourrons l'examiner avec quelque chose d'autres connaissances nĂ©cessaires:

  • enregistrement, surveillance, test de charge.
  • tests, CI, CD.
  • des fonctionnalitĂ©s plus intĂ©ressantes comme l'autorisation ou la recherche en texte intĂ©gral.
  • configuration et dĂ©veloppement de l'environnement de production.

Merci de votre attention!

Source: https://habr.com/ru/post/fr444446/


All Articles