Hacer una aplicación web moderna desde cero

Entonces, decidiste hacer un nuevo proyecto. Y este proyecto es una aplicación web. ¿Cuánto tiempo llevará crear un prototipo básico? Que tan dificil es ¿Qué debe hacer un sitio web moderno desde el principio?

En este artículo, intentaremos esbozar la plantilla de una aplicación web simple con la siguiente arquitectura:


Lo que cubriremos:

  • configurando el entorno de desarrollo en docker-compose.
  • Creación de backend en Flask.
  • creando una interfaz en Express.
  • Construye JS usando Webpack.
  • Reacción, Redux y representación del lado del servidor.
  • colas de tareas con RQ.

Introduccion


Antes del desarrollo, por supuesto, ¡primero debes decidir qué estamos desarrollando! Como una aplicación modelo para este artículo, decidí hacer un motor wiki primitivo. Tendremos tarjetas emitidas en Markdown; se pueden ver y (en algún momento en el futuro) ofrecer ediciones. Todo esto lo organizaremos como una aplicación de una página con representación del lado del servidor (que es absolutamente necesaria para indexar nuestros futuros terabytes de contenido).

Echemos un vistazo un poco más detallado a los componentes que necesitamos para esto:

  • Cliente Creemos una aplicación de una página (es decir, con transiciones de página usando AJAX) en el paquete React + Redux , que es muy común en el mundo front-end.
  • Frontend Creemos un servidor Express simple que muestre nuestra aplicación React (solicitando todos los datos necesarios en el backend de forma asincrónica) y se la emita al usuario.
  • Backend Maestro de la lógica de negocios, nuestro backend será una pequeña aplicación de Flask. Almacenaremos datos (nuestras tarjetas) en el popular repositorio de documentos MongoDB , y para la cola de tareas y, posiblemente, en el futuro, el almacenamiento en caché, usaremos Redis .
  • TRABAJADOR La biblioteca RQ lanzará un contenedor separado para tareas pesadas.

Infraestructura: git


Probablemente, no podríamos hablar de esto, pero, por supuesto, llevaremos a cabo el desarrollo en el repositorio de git.

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

(Aquí debe completar de inmediato .gitignore ).

El borrador final se puede ver en Github . Cada sección del artículo corresponde a una confirmación (¡rebauticé mucho para lograr esto!).

Infraestructura: docker-compose


Comencemos por configurar el entorno. Con la abundancia de componentes que tenemos, una solución de desarrollo muy lógica sería usar docker-compose.

Agregue el archivo docker-compose.yml al repositorio docker-compose.yml siguiente contenido:

 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 

Echemos un vistazo rápido a lo que está sucediendo aquí.

  • Se crean un contenedor MongoDB y un contenedor Redis.
  • Se crea un contenedor para nuestro backend (que describimos a continuación). Se le pasa la variable de entorno APP_ENV = dev (la veremos para comprender qué configuraciones de Flask cargar), y su puerto 40001 se abrirá afuera (a través de él, nuestro cliente de navegador irá a la API).
  • Se crea un contenedor de nuestra interfaz. También se incluyen una variedad de variables de entorno, que nos serán útiles más adelante, y se abre el puerto 40002. Este es el puerto principal de nuestra aplicación web: en el navegador iremos a http: // localhost: 40002 .
  • Se crea el contenedor de nuestro trabajador. No necesita puertos externos, y solo se requiere acceso en MongoDB y Redis.

Ahora creemos dockerfiles. En este momento, una serie de traducciones de excelentes artículos sobre Docker está llegando a Habré: puede ir allí con seguridad para todos los detalles.

Comencemos con el 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 

Se entiende que corremos a través de la aplicación Gunicorn Flask, escondiéndonos bajo la app nombre en el módulo backend.server .

No menos importante docker/backend/.dockerignore :

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

El trabajador generalmente es similar al backend, solo que en lugar de gunicorn tenemos el lanzamiento habitual de un módulo de pozo:

 # 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 

Haremos todo el trabajo en worker/__main__.py .

.dockerignore trabajador .dockerignore es completamente similar al backend .dockerignore .

Finalmente, la interfaz. Hay un artículo completamente separado sobre él sobre Habré, pero a juzgar por la extensa discusión sobre StackOverflow y los comentarios en el espíritu de "Chicos, ¿ya es 2018, todavía no hay una solución normal?" No todo es tan simple allí. Me decidí por esta versión del archivo acoplable.

 # 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 

Pros:

  • todo se almacena en caché como se esperaba (en la capa inferior - dependencias, en la parte superior - la compilación de nuestra aplicación);
  • docker-compose exec frontend npm install --save newDependency funciona como debería y modifica package.json en nuestro repositorio (que no sería el caso si docker-compose exec frontend npm install --save newDependency COPY, como muchas personas sugieren). No sería deseable ejecutar npm install --save newDependency fuera del contenedor de todos modos, porque algunas dependencias del nuevo paquete ya pueden estar presentes y construirse bajo una plataforma diferente (debajo de la del acoplador, y no bajo nuestro macbook de trabajo, por ejemplo ) y, sin embargo, generalmente no queremos exigir la presencia de Node en la máquina de desarrollo. ¡Un Docker para gobernarlos a todos!

Bueno y por supuesto docker/frontend/.dockerignore :

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

Por lo tanto, nuestro marco contenedor está listo y puede llenarlo con contenido.

Backend: marco de matraz


Agregue flask , flask-cors , gevent y gunicorn a gunicorn y cree una aplicación Flask simple en 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") 

Le dijimos a Flask que extraiga la configuración del archivo de fondo backend.{env}_settings , lo que significa que también tendremos que crear un archivo (al menos vacío) backend/dev_settings.py para que todo despegue.

¡Ahora podemos LEVANTAR oficialmente nuestro 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 

Seguimos adelante.

Frontend: marco Express


Comencemos creando un paquete. Después de haber creado la carpeta frontend y ejecutar npm init en ella, después de algunas preguntas poco sofisticadas, obtenemos el paquete.json terminado en el espíritu

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

En el futuro, no necesitamos Node.js en absoluto en la máquina del desarrollador (aunque todavía podríamos esquivar e iniciar npm init través de Docker, pero bueno).

En Dockerfile mencionamos npm run build y npm run start : debe agregar los comandos apropiados a 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": { 

El comando de build todavía no hace nada, pero aún nos será útil.

Agregue dependencias Express y cree una aplicación simple en 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!") }); 

¡Ahora docker-compose up frontend eleva nuestro frontend! Además, en http: // localhost: 40002 , el clásico "Hola, mundo" ya debería presumir.

Frontend: compilación con webpack y aplicación React


Es hora de representar algo más que texto sin formato en nuestra aplicación. En esta sección, agregaremos el componente React más simple de la App y configuraremos el ensamblaje.

Al programar en React, es muy conveniente usar JSX , un dialecto de JavaScript extendido por construcciones sintácticas del formulario

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

Sin embargo, los motores de JavaScript no lo entienden, por lo que generalmente la fase de compilación se agrega a la interfaz. Los compiladores especiales de JavaScript (sí, sí) convierten el azúcar sintáctico en JavaScript feo clásico, manejan importaciones, minimizan, etc.



2014 año. apt-cache search java

Entonces, el componente React más simple parece muy simple.

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

Simplemente mostrará nuestro saludo con un alfiler más convincente.

Agregue el archivo frontend/src/template.js contiene el marco HTML mínimo de nuestra futura aplicación:

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

Agregue un punto de entrada de cliente:

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

Para construir toda esta belleza, necesitamos:

webpack es un creador juvenil de moda para JS (aunque no he leído artículos en la interfaz durante tres horas, por lo que no estoy seguro de la moda);
babel es un compilador para todo tipo de lociones como JSX y, al mismo tiempo, un proveedor de polyfill para todos los casos de IE.

Si la iteración anterior de la interfaz aún se está ejecutando, todo lo que tiene que hacer es

 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 

instalar nuevas dependencias Ahora configure 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]; 

Para que Babel funcione, debe configurar frontend/.babelrc :

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

Finalmente, haga que nuestro npm run build significativo:

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

Ahora nuestro cliente, junto con un paquete de polyfills y todas sus dependencias, se ejecuta a través de babel, compila y se pliega en un archivo ../dist/client.js monolítico ../dist/client.js . Agregue la capacidad de cargarlo como un archivo estático a nuestra aplicación Express, y en la ruta predeterminada comenzaremos a devolver nuestro 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); 

Éxito! Ahora, si ejecutamos docker-compose up --build frontend , veremos "¡Hola, mundo!" en un envoltorio nuevo y brillante, y si tiene instalada la extensión React Developer Tools ( Chrome , Firefox ), también hay un árbol de componentes React en las herramientas de desarrollador:



Backend: Datos en MongoDB


Antes de continuar y dar vida a nuestra aplicación, primero debe respirarla por el backend. Parece que íbamos a almacenar las tarjetas marcadas en Markdown, es hora de hacerlo.

Si bien hay ORM para MongoDB en python , considero que el uso de ORM es despiadado y le dejo el estudio de las soluciones adecuadas. En cambio, haremos una clase simple para la tarjeta y el DAO que la acompaña:

 # 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 aún no utiliza anotaciones de tipo en Python, ¡asegúrese de consultar estos artículos !)

Ahora CardDAO una implementación de la interfaz CardDAO que toma un objeto de Database de pymongo (sí, es hora de agregar pymongo a pymongo ):

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

Es hora de registrar la configuración de Monga en la configuración del backend. Simplemente MONGO_HOST = "mongo" nuestro contenedor con mongo mongo , entonces 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" 

Ahora necesitamos crear MongoCardDAO y darle acceso a la aplicación Flask. Aunque ahora tenemos una jerarquía de objetos muy simple (configuración → cliente de pymongo → base de datos de pymongo → MongoCardDAO ), MongoCardDAO de inmediato un componente centralizado que realice la inyección de dependencia (será útil nuevamente cuando hagamos el trabajo y las herramientas).

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


¡Es hora de agregar una nueva ruta a la aplicación Flask y disfrutar de la vista!

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

Reinicie con el docker-compose up --build backend :



Vaya ... oh, exactamente. ¡Necesitamos agregar contenido! Abriremos la carpeta de herramientas y agregaremos un script que agrega una tarjeta de prueba:

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

El docker-compose exec backend python -m tools.add_test_content llenará nuestra monga con contenido desde el interior del contenedor de docker-compose exec backend python -m tools.add_test_content .



Éxito! Ahora es el momento de apoyar esto en la parte frontal.

Frontend: Redux


Ahora queremos hacer la ruta /card/:id_or_slug , mediante la cual se abrirá nuestra aplicación React, cargar los datos de la tarjeta desde la API y /card/:id_or_slug alguna manera. Y aquí, quizás, la parte más difícil comienza, porque queremos que el servidor nos proporcione inmediatamente HTML con el contenido de la tarjeta, adecuado para la indexación, pero al mismo tiempo, cuando la aplicación navega entre las tarjetas, recibe todos los datos en forma de JSON de la API, y la página no se sobrecarga. Y para que todo esto, ¡sin copiar y pegar!

Comencemos agregando Redux. Redux es una biblioteca de JavaScript para almacenar estados. La idea es que, en lugar de los miles de estados implícitos que sus componentes cambian durante las acciones del usuario y otros eventos interesantes, tienen un estado centralizado y realizan cualquier cambio a través de un mecanismo centralizado de acciones. Entonces, si antes para la navegación primero activamos el GIF de carga, luego hicimos una solicitud a través de AJAX y finalmente, en la devolución de llamada exitosa, actualizamos las partes necesarias de la página, luego en el paradigma de Redux estamos invitados a enviar la acción "cambiar el contenido a un gif con animación", que cambiará el estado global para que uno de sus componentes arroje el contenido anterior y coloque la animación, luego haga una solicitud y envíe otra acción en su devolución de llamada exitosa, "cambie el contenido a cargado". En general, ahora lo veremos nosotros mismos.

Comencemos instalando nuevas dependencias en nuestro contenedor.

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

El primero es, de hecho, Redux, el segundo es una biblioteca especial para cruzar React y Redux (escrito por expertos en apareamiento), el tercero es algo muy necesario, cuya necesidad está bien fundamentada en su README , y, finalmente, el cuarto es la biblioteca necesaria para que Redux DevTools funcione. Extensión .

Comencemos con el código repetitivo de Redux: creando un reductor que no hace nada e inicializando el estado.

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

Nuestro cliente cambia un poco, preparándose mentalmente para trabajar con 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') ); 

Ahora podemos ejecutar docker-compose up --build frontend para asegurarnos de que nada esté roto, y nuestro estado primitivo ha aparecido en Redux DevTools:



Frontend: Página de tarjeta


¡Antes de poder crear páginas con SSR, debe crear páginas sin SSR! Finalmente usemos nuestra ingeniosa API para acceder a las tarjetas y compongamos la página de la tarjeta en el front-end.

Es hora de aprovechar la inteligencia y rediseñar nuestra estructura estatal. Hay muchos materiales sobre este tema, por lo que sugiero no abusar de la inteligencia y me enfocaré en lo simple. Por ejemplo, tales:

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

Consigamos el componente "tarjeta", que toma el contenido de cardData como accesorios (en realidad es el contenido de nuestra tarjeta 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; 

Ahora obtengamos un componente para toda la página con la tarjeta. Será responsable de obtener los datos necesarios de la API y transferirlos a la Tarjeta. Y haremos la recuperación de datos en la forma React-Redux.

Primero, cree el archivo frontend/src/redux/actions.js y cree una acción que extraiga el contenido de la tarjeta de la API, si no es así:

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

La acción fetchCard , que en realidad hace que la búsqueda, sea un poco más complicada:

 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, tenemos una acción que ALGO HACE! Esto debe ser compatible con el reductor:

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

(Preste atención a la sintaxis moderna para clonar un objeto con campos individuales cambiantes).

Ahora que toda la lógica se lleva a cabo en las acciones de Redux, el componente en sí CardPagese verá relativamente 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); 

Agregue un simple procesamiento page.type a nuestro componente raíz de la aplicación:

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

Y ahora queda el último momento: necesita inicializar de alguna manera page.typey page.cardSlugdependiendo de la URL de la página.

Pero todavía hay muchas secciones en este artículo, pero no podemos hacer una solución de alta calidad en este momento. Hagámoslo estúpido por ahora. Eso es completamente estúpido. Por ejemplo, un habitual al inicializar la aplicación!

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

Un poco de código repetitivo para el trabajador.

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

Para el análisis en sí, conecta la biblioteca de mistune y escribe una función 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) 

Lógicamente: necesitamos CardDAOobtener el código fuente de la tarjeta y guardar el resultado. Pero un objeto que contiene una conexión a un almacenamiento externo no se puede serializar a través de pickle, lo que significa que esta tarea no se puede tomar y poner en cola de inmediato para RQ. En el buen sentido, necesitamos crear Wiringun trabajador a un lado y tirarlo de todo tipo ... Hagámoslo:

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

Declaramos nuestra clase de trabajos, lanzando el cableado como un argumento adicional de kwargs en todos los problemas. (Tenga en cuenta que crea un cableado NUEVO cada vez, porque algunos clientes no pueden crearse antes de la bifurcación que ocurre dentro de RQ antes de que la tarea comience a procesarse). De modo que todas nuestras tareas no dependen del cableado, es decir, de TODOS nuestros objetos, vamos a Hagamos un decorador que obtenga solo lo necesario del cableado:

 # 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 

Agregue un decorador a nuestra tarea y disfrute de la vida:

 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) 

¿Disfrutas la vida? Ugh, quería decir, comenzamos el trabajador:

 $ 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 ... no hace nada! Por supuesto, ¡porque no establecimos una sola tarea!

Reescribamos nuestra herramienta, que crea una tarjeta de prueba, de modo que: a) no se caiga si la tarjeta ya está creada (como en nuestro caso); b) poner tarea en el análisis 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}) 

Las herramientas ahora se pueden ejecutar no solo en el backend, sino también en el trabajador. En principio, ahora no nos importa. Lo lanzamos docker-compose exec worker python -m tools.add_test_contenty en una pestaña vecina de la terminal vemos un milagro: ¡el trabajador hizo ALGO!

 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 

Después de reconstruir el contenedor con el backend, finalmente podemos ver el contenido de nuestra tarjeta en el navegador:



Navegación Frontend


Antes de pasar a SSR, debemos hacer que todo nuestro alboroto React sea un poco significativo y hacer que nuestra aplicación de una sola página sea realmente una sola página. Actualicemos nuestra herramienta para crear dos (¡NO UNA, Y DOS! ¡MAMÁ, AHORA GRANDE DESARROLLADOR DE FECHA!) Tarjetas que se unen entre sí, y luego nos ocuparemos de la navegación entre ellas.

Texto oculto
 # 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**! """ )) 


Ahora podemos seguir los enlaces y contemplar cómo cada vez que se reinicia nuestra maravillosa aplicación. Basta!

Primero, coloque su controlador en los clics en los enlaces. Dado que HTML con enlaces proviene del backend, y la aplicación es con React, necesitamos un pequeño enfoque específico de 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> ); } } 

Dado que toda la lógica con la carga de las tarjetas en nuestro componente CardPage, en la acción en sí (¡increíble!), No es necesario tomar ninguna acción:

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

Agregue un reductor tonto para este caso:

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

Como ahora el estado de nuestra aplicación puede cambiar, CardPagenecesitamos agregar un método componentDidUpdateidéntico al que ya agregamos componentWillMount. Ahora, después de actualizar las propiedades CardPage(por ejemplo, propiedades cardSlugdurante la navegación), también se solicitará el contenido de la tarjeta desde el backend (lo componentWillMounthizo solo cuando se inicializó el componente).

Muy bien, docker-compose up --build frontendy tenemos una navegación de trabajo!



Un lector atento notará que la URL de la página no cambiará al navegar entre tarjetas, incluso en la captura de pantalla vemos Hello, la tarjeta mundial en la dirección de la tarjeta de demostración. En consecuencia, la navegación hacia adelante y hacia atrás también se cayó. ¡Agreguemos algo de magia negra con historia de inmediato para solucionarlo!

Lo más simple que puede hacer es agregar a la acción.navigateUn desafío history.pushState.

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

Ahora, al hacer clic en los enlaces, la URL en la barra de direcciones del navegador realmente cambiará. ¡Sin embargo, el botón de retroceso se romperá !

Para que funcione, necesitamos escuchar el evento del popstateobjeto window. Además, si en este caso queremos hacer la navegación hacia atrás y hacia adelante (es decir, a través dispatch(navigate(...))), tendremos que navigateagregar un indicador especial de "no pushState" a la función (de lo contrario, ¡todo se romperá aún más!). Además, para distinguir entre "nuestros" estados, debemos usar la capacidad pushStatede guardar metadatos. Hay mucha magia y depuración, ¡así que vamos directamente al código! Así se verá la aplicación:

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

Y aquí está la acción de navegación:

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

Ahora la historia funcionará.

Bueno, el último toque: dado que ahora tenemos una acción navigate, ¿por qué no renunciamos al código adicional en el cliente que calcula el estado inicial? Podemos llamar a navegar a la ubicación actual:

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

Copiar y pegar destruido!

Frontend: representación del lado del servidor


( ) — SEO-. , React-, React, .

. : HTML- HTML, React- App . HTML ( JS, -). : <script> , - (, window ) , HTML. ( hydrateal HTML generado, para no volver a crear el árbol DOM de la aplicación).

Comencemos escribiendo una función que devuelva HTML renderizado y el estado 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}; }; 

Agregue nuevos argumentos y lógica a nuestra plantilla, de la que hablamos anteriormente:

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

Nuestro servidor Express se vuelve un poco más complicado:

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

Pero el cliente es más fácil:

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

A continuación, debe limpiar los errores multiplataforma como "el historial no está definido". Para hacer esto, agregue una función simple (hasta ahora) en algún lugar utility.js.

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

Lo siguiente será un cierto número de cambios de rutina que no traeré aquí (pero se pueden encontrar en la confirmación correspondiente ). Como resultado, nuestra aplicación React podrá renderizar tanto en el navegador como en el servidor.

Funciona!Pero hay, como dicen, una advertencia ...



¿CARGANDO? ¡Todo lo que Google ve en mi servicio de moda súper genial es CARGANDO?

Bueno, parece que todo nuestro asincronismo ha jugado contra nosotros. Ahora necesitamos una manera de que el servidor comprenda que la respuesta del backend con el contenido de la tarjeta debe esperar antes de procesar la aplicación React en una cadena y enviarla al cliente. Y es deseable que este método sea bastante general.

Puede haber muchas soluciones. Un enfoque es describir en un archivo separado para qué rutas se deben proteger los datos, y hacer esto antes de presentar la aplicación ( artículo ). Esta solución tiene muchas ventajas. Es simple, es explícito y funciona.

Como experimento (¡el contenido original debe estar en el artículo al menos en algún lugar!) Propongo otro esquema. Cada vez que ejecutemos algo asíncrono, que debemos esperar, agreguemos la promesa adecuada (por ejemplo, la que devuelve la búsqueda) en algún lugar de nuestro estado. Entonces tendremos un lugar donde siempre puede verificar si todo se ha descargado.

Agrega dos nuevas acciones.

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


El primero se llamará cuando se inicie la búsqueda, el segundo, al final .then().

Ahora agregue su procesamiento al reductor:

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

Ahora mejoraremos la acción 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)); }; } 

¡Queda por agregar initialStatepromesas a la matriz vacía y hacer que el servidor las espere a todas! La función de procesamiento se vuelve asíncrona y toma la siguiente forma:

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

Debido a la renderasincronía adquirida , el controlador de solicitudes también es un poco más complicado:

 // 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à!



Conclusión


Como puede ver, crear una aplicación de alta tecnología no es tan simple. ¡Pero no es tan difícil! La aplicación final está en el repositorio en Github y, en teoría, solo necesita Docker para ejecutarla.

Si el artículo tiene demanda, ¡este repositorio ni siquiera será abandonado! Podremos verlo con algo de otro conocimiento que sea necesario:

  • registro, monitoreo, prueba de carga.
  • prueba, CI, CD.
  • características más interesantes como la autorización o la búsqueda de texto completo.
  • Configuración y desarrollo del entorno de producción.

Gracias por su atencion!

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


All Articles