Criando um aplicativo Web moderno do zero

Então, você decidiu fazer um novo projeto. E este projeto é uma aplicação web. Quanto tempo leva para criar um protótipo básico? Quão difícil é isso? O que um site moderno deve fazer desde o início?

Neste artigo, tentaremos descrever o padrão de um aplicativo da web simples com a seguinte arquitetura:


O que abordaremos:

  • configurando o ambiente de desenvolvimento no docker-compose.
  • criação de back-end no Flask.
  • criando um frontend no Express.
  • Crie JS usando o Webpack.
  • Reagir, Redux e renderização no servidor.
  • filas de tarefas com o RQ.

1. Introdução


Antes do desenvolvimento, é claro, você primeiro precisa decidir o que estamos desenvolvendo! Como um aplicativo modelo para este artigo, decidi criar um mecanismo wiki primitivo. Teremos cartões emitidos no Markdown; eles podem ser assistidos e (em algum momento no futuro) oferecer edições. Tudo isso será organizado como um aplicativo de uma página com renderização no servidor (o que é absolutamente necessário para indexar nossos futuros terabytes de conteúdo).

Vamos dar uma olhada um pouco mais detalhada nos componentes que precisamos para isso:

  • Cliente Criaremos um aplicativo de uma página (ou seja, com transições de página usando AJAX) no pacote React + Redux , o que é muito comum no mundo do front-end.
  • Frontend . Vamos criar um servidor Express simples que renderize nosso aplicativo React (solicitando todos os dados necessários no back-end de forma assíncrona) e os emita ao usuário.
  • Backend . Mestre em lógica de negócios, nosso back-end será um pequeno aplicativo Flask. Armazenaremos dados (nossos cartões) no popular repositório de documentos do MongoDB e, para a fila de tarefas e, possivelmente, no futuro, armazenamento em cache, usaremos o Redis .
  • TRABALHADOR . Um contêiner separado para tarefas pesadas será lançado pela biblioteca RQ .

Infraestrutura: git


Provavelmente, não poderíamos falar sobre isso, mas, é claro, conduziremos o desenvolvimento no repositório git.

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

(Aqui você deve preencher imediatamente .gitignore .)

O rascunho final pode ser visualizado no Github . Cada seção do artigo corresponde a um commit (eu me envolvi muito para conseguir isso!).

Infraestrutura: docker-compose


Vamos começar configurando o ambiente. Com a abundância de componentes que temos, uma solução de desenvolvimento muito lógica seria usar o docker-compose.

Inclua o arquivo docker-compose.yml no repositório docker-compose.yml seguinte conteúdo:

 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 

Vamos dar uma rápida olhada no que está acontecendo aqui.

  • Um contêiner MongoDB e um contêiner Redis são criados.
  • Um contêiner para o nosso back-end é criado (que descrevemos abaixo). A variável de ambiente APP_ENV = dev é passada a ele (veremos para entender quais configurações do Flask carregar) e sua porta 40001 abrirá fora (por meio dele, nosso cliente do navegador irá para a API).
  • Um contêiner do nosso front-end é criado. Uma variedade de variáveis ​​de ambiente também é lançada, o que será útil para nós mais tarde, e a porta 40002 é aberta. Essa é a principal porta do nosso aplicativo da Web: no navegador, iremos para http: // localhost: 40002 .
  • O contêiner do nosso trabalhador é criado. Ele não precisa de portas externas e apenas o acesso é necessário no MongoDB e no Redis.

Agora vamos criar arquivos docker. No momento, uma série de traduções de excelentes artigos sobre o Docker está chegando em Habré - você pode ir com segurança para todos os detalhes.

Vamos começar com o back-end.

 # 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 

Entende-se que executamos o aplicativo gunicorn Flask, oculto sob o nome do app no módulo backend.server .

Não menos importante docker/backend/.dockerignore :

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

O trabalhador geralmente é semelhante ao back-end, mas em vez de gunicorn temos o lançamento habitual de um módulo de poço:

 # 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 

Faremos todo o trabalho em worker/__main__.py .

.dockerignore trabalhador .dockerignore é completamente semelhante ao backend .dockerignore .

Finalmente, o frontend. Há um artigo inteiro sobre ele sobre Habré, mas, a julgar pela extensa discussão sobre StackOverflow e comentários no espírito de "Gente, já é 2018, ainda não há solução normal?" tudo não é tão simples lá. Eu decidi nesta versão do arquivo 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 

Prós:

  • tudo é armazenado em cache conforme o esperado (na camada inferior - dependências, na parte superior - a construção do nosso aplicativo);
  • docker-compose exec frontend npm install --save newDependency funciona como deveria e modifica o package.json em nosso repositório (o que não seria o caso se docker-compose exec frontend npm install --save newDependency COPY, como muitas pessoas sugerem). Não seria desejável executar o npm install --save newDependency fora do contêiner de qualquer maneira, porque algumas dependências do novo pacote já podem estar presentes e ser construídas sob uma plataforma diferente (na que está dentro da janela de encaixe e não no nosso macbook de trabalho, por exemplo ) e, no entanto, geralmente não queremos exigir a presença de Node na máquina de desenvolvimento. Um Docker para governar todos eles!

Bem, é claro, docker/frontend/.dockerignore :

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

Portanto, nosso quadro de contêiner está pronto e você pode preenchê-lo com conteúdo!

Back-end: estrutura do balão


Adicione flask , flask-cors gevent , gevent e gunicorn a requirements.txt e crie um aplicativo Flask simples em 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") 

Dissemos ao Flask para extrair as configurações do arquivo backend.{env}_settings - backend.{env}_settings , o que significa que também precisaremos criar um (pelo menos vazio) arquivo backend/dev_settings.py para que tudo decole.

Agora podemos oficialmente aumentar o nosso back-end!

 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 

Nós seguimos em frente.

Front-end: estrutura Express


Vamos começar criando um pacote. Depois de criar a pasta frontend e executar o npm init nela, depois de algumas perguntas pouco sofisticadas, obtemos o package.json pronto.

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

No futuro, não precisamos do Node.js na máquina do desenvolvedor (embora ainda possamos esquivar e iniciar o npm init por meio do Docker, tudo bem).

No Dockerfile mencionamos o npm run build e o npm run start - você precisa adicionar os comandos apropriados ao 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": { 

O comando build ainda não faz nada, mas ainda será útil para nós.

Adicione dependências do Express e crie um aplicativo simples no 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!") }); 

Agora o docker-compose up frontend aumenta o nosso front-end! Além disso, em http: // localhost: 40002 , o clássico "Olá, mundo" já deve ser exibido.

Front-end: construa com o webpack e o aplicativo React


É hora de retratar algo mais que texto simples em nosso aplicativo. Nesta seção, adicionaremos o componente React mais simples do App e configuraremos a montagem.

Ao programar no React, é muito conveniente usar JSX , um dialeto JavaScript estendido por construções sintáticas do formulário

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

No entanto, os mecanismos JavaScript não o entendem; geralmente, a fase de construção é adicionada ao frontend. Compiladores JavaScript especiais (sim-sim) transformam açúcar sintático em JavaScript clássico feio , manipulam importações, minimizam e assim por diante.



Ano de 2014. java de pesquisa do apt-cache

Portanto, o componente React mais simples parece muito simples.

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

Ele simplesmente exibirá nossa saudação com um alfinete mais convincente.

Adicione o arquivo frontend/src/template.js contém a estrutura HTML mínima de nosso aplicativo futuro:

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

Adicione um ponto de entrada do 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 essa beleza, precisamos:

webpack é um construtor de moda para JS (embora eu não tenha lido artigos no frontend por três horas, por isso não tenho certeza sobre a moda);
O babel é um compilador para todos os tipos de loções, como JSX, e ao mesmo tempo um provedor de polyfill para todos os casos do IE.

Se a iteração anterior do front-end ainda estiver em execução, tudo o que você precisa fazer é

 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 

para instalar novas dependências. Agora configure o 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 fazer o babel funcionar, você precisa configurar o frontend/.babelrc :

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

Por fim, faça nosso comando npm run build significativo:

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

Agora, nosso cliente, junto com um pacote de polyfills e todas as suas dependências, executa babel, compila e dobra em um arquivo minificado monolítico ../dist/client.js . Adicione a capacidade de enviá-lo como um arquivo estático para nosso aplicativo Express e, na rota padrão, começaremos a retornar nosso 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); 

Sucesso! Agora, se executarmos o docker-compose up --build frontend , veremos "Olá, mundo!" em um novo invólucro brilhante e se você tiver a extensão React Developer Tools instalada ( Chrome , Firefox ), também haverá uma árvore de componentes React nas ferramentas do desenvolvedor:



Back-end: dados no MongoDB


Antes de seguir adiante e dar vida à nossa aplicação, você deve primeiro respirar no back-end. Parece que estávamos indo para armazenar os cartões marcados em Markdown - é hora de fazê-lo.

Embora existam ORMs para MongoDB em python , considero o uso de ORMs cruéis e deixo o estudo das soluções apropriadas para você. Em vez disso, faremos uma aula simples para o cartão e o DAO que acompanha:

 # 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 

(Se você ainda não usa anotações de tipo no Python, verifique estes artigos !)

Agora vamos criar uma implementação da interface CardDAO que recebe pymongo um objeto Database do pymongo (sim, é hora de adicionar o pymongo ao 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) 

Hora de registrar a configuração do Monga nas configurações de back-end. Simplesmente MONGO_HOST = "mongo" nosso contêiner com mongo mongo , então 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" 

Agora precisamos criar o MongoCardDAO e dar acesso ao aplicativo Flask. Embora agora tenhamos uma hierarquia muito simples de objetos (configurações → cliente pymongo → banco de dados pymongo → MongoCardDAO ), vamos criar imediatamente um componente rei centralizado que faz a injeção de dependência (será útil novamente quando fizermos o trabalhador e as ferramentas).

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


Hora de adicionar uma nova rota ao aplicativo Flask e apreciar a 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 com o docker-compose up --build backend :



Opa ... exatamente. Precisamos adicionar conteúdo! Abriremos a pasta tools e adicionaremos um script que adiciona um cartão de teste:

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

O docker-compose exec backend python -m tools.add_test_content preencherá nossa monga com conteúdo de dentro do contêiner de docker-compose exec backend python -m tools.add_test_content - docker-compose exec backend python -m tools.add_test_content .



Sucesso! Agora é a hora de apoiar isso no front-end.

Frontend: Redux


Agora queremos criar a rota /card/:id_or_slug , pela qual nosso aplicativo React será aberto, carregar os dados do cartão da API e mostrá-los de alguma forma. E aqui, talvez, comece a parte mais difícil, porque queremos que o servidor nos forneça imediatamente HTML com o conteúdo do cartão, adequado para indexação, mas ao mesmo tempo, quando o aplicativo navega entre os cartões, ele recebe todos os dados na forma de JSON da API e a página não sobrecarrega. E para que tudo isso - sem copiar e colar!

Vamos começar adicionando Redux. Redux é uma biblioteca JavaScript para armazenar estado. A idéia é que, em vez dos mil estados implícitos de que seus componentes mudam durante as ações do usuário e outros eventos interessantes, eles têm um estado centralizado e fazem qualquer alteração através de um mecanismo centralizado de ações. Portanto, se antes para a navegação ativamos o GIF de carregamento pela primeira vez, fizemos uma solicitação pelo AJAX e, finalmente, no retorno de chamada bem-sucedido, atualizamos as partes necessárias da página e, no paradigma Redux, somos convidados a enviar a ação "alterar o conteúdo para um gif com animação", que alterará o estado global para que um de seus componentes jogue fora o conteúdo anterior e coloque a animação, faça uma solicitação e envie outra ação em seu retorno de sucesso: "altere o conteúdo para carregado". Em geral, agora vamos ver por nós mesmos.

Vamos começar instalando novas dependências em nosso contêiner.

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

O primeiro é, de fato, o Redux, o segundo é uma biblioteca especial para cruzar o React e o Redux (escrito por especialistas em acasalamento), o terceiro é uma coisa muito necessária, cuja necessidade é bem justificada em seu README e, finalmente, o quarto é a biblioteca necessária para o Redux DevTools funcionar Extensão .

Vamos começar com o código Redux padrão: criando um redutor que não faz nada e inicializando o 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)), ); } 

Nosso cliente muda um pouco, preparando-se mentalmente para trabalhar com o 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') ); 

Agora podemos executar o docker-compose up --build frontend para garantir que nada esteja quebrado, e nosso estado primitivo apareceu no Redux DevTools:



Front-end: página do cartão


Antes de criar páginas com SSR, você precisa criar páginas sem SSR! Finalmente vamos usar nossa API engenhosa para acessar cartões e compor a página do cartão no front-end.

Hora de tirar proveito da inteligência e redesenhar nossa estrutura de estado. Há muitos materiais sobre esse assunto, então sugiro não abusar da inteligência e focar no simples. Por exemplo, como:

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

Vamos pegar o componente "card", que usa o conteúdo de cardData como suporte (na verdade, é o conteúdo do nosso card no 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; 

Agora vamos obter um componente para a página inteira com o cartão. Ele será responsável por obter os dados necessários da API e transferi-los para o Card. E faremos a busca de dados da maneira React-Redux.

Primeiro, crie o arquivo frontend/src/redux/actions.js e crie uma ação que extraia o conteúdo do cartão da API, se ainda não:

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

A ação fetchCard , que realmente torna a busca, um pouco mais 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, nós temos uma ação que ALGO FAZ! Isso deve ser suportado no redutor:

 // 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 atenção à sintaxe da moda para clonar um objeto com a alteração de campos individuais.)

Agora que toda a lógica é realizada nas ações do Redux, o próprio componente CardPageparecerá relativamente simples:

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

Adicione um processamento simples page.type ao nosso componente raiz do aplicativo:

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

E agora o último momento permanece - você precisa inicializar de alguma forma page.typee page.cardSlugdependendo do URL da página.

Mas ainda existem muitas seções neste artigo, mas não podemos criar uma solução de alta qualidade no momento. Vamos fazer isso estúpido por enquanto. Isso é completamente estúpido. Por exemplo, um regular ao inicializar o aplicativo!

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

Um pouco de código padrão para o trabalhador.

 # 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 a análise em si, conecte a biblioteca mistune e escreva uma função simples:

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

Logicamente: precisamos CardDAOobter o código fonte do cartão e salvar o resultado. Mas o objeto que contém a conexão com o armazenamento externo não pode ser serializado via pickle - o que significa que esta tarefa não pode ser imediatamente tomada e colocada em fila para o RQ. De uma maneira boa, precisamos criar Wiringum trabalhador de lado e jogá-lo de todos os tipos ... Vamos fazê-lo:

 --- 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 nossa classe de trabalhos, lançando a fiação como um argumento adicional em todos os problemas. (Observe que ele cria uma nova fiação toda vez, porque alguns clientes não podem ser criados antes da bifurcação que ocorre dentro do RQ antes que a tarefa comece a processar.) Para que todas as nossas tarefas não dependam da fiação - ou seja, de TODOS os nossos objetos - vamos Vamos criar um decorador que obtenha apenas o necessário da fiação:

 # 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 

Adicione um decorador à nossa tarefa e aproveite a 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) 

Aproveite a vida? Ugh, eu queria dizer, começamos o trabalhador:

 $ 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 ... ele não faz nada! Claro, porque não definimos uma única tarefa!

Vamos reescrever nossa ferramenta, que cria um cartão de teste, para que: a) não caia se o cartão já estiver criado (como no nosso caso); b) colocar a tarefa na análise 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}) 

Agora, as ferramentas podem ser executadas não apenas no back-end, mas também no trabalhador. Em princípio, agora não nos importamos. Nós docker-compose exec worker python -m tools.add_test_contento lançamos e, em uma aba vizinha do terminal, vemos um milagre - o trabalhador fez 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 

Depois de reconstruir o contêiner com o back-end, finalmente podemos ver o conteúdo do nosso cartão no navegador:



Navegação Frontend


Antes de avançarmos para o SSR, precisamos fazer com que todo o nosso barulho do React seja um pouco significativo e tornar nosso aplicativo de página única verdadeiramente uma única página. Vamos atualizar nossa ferramenta para criar dois (NÃO UM, E DOIS! MAMÃ, EU AGORA GRANDE DESENVOLVEDOR DE DATA!) Cartões que se vinculam e, em seguida, trataremos da navegação entre eles.

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


Agora podemos seguir os links e contemplar como cada vez que nosso maravilhoso aplicativo é reiniciado. Pare com isso!

Primeiro, coloque seu manipulador nos cliques nos links. Como o HTML com links vem do back-end e temos o aplicativo React, precisamos de um pouco de foco específico no 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> ); } } 

Como toda a lógica de carregar os cartões em nosso componente CardPage, na própria ação (incrível!), Nenhuma ação precisa ser tomada:

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

Adicione um redutor bobo 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; } 

, CardPage componentDidUpdate , componentWillMount . CardPage (, cardSlug ) ( componentWillMount ).

, docker-compose up --build frontend !



, URL — Hello, world- demo-. , - . history, !

, — navigate history.pushState .

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

Agora, ao clicar nos links, o URL na barra de endereços do navegador realmente mudará. No entanto, o botão Voltar irá quebrar !

Para fazê-lo funcionar, precisamos ouvir o evento do popstateobjeto window. Além disso, se neste caso queremos fazer a navegação tanto para trás quanto para frente (ou seja, através dispatch(navigate(...))), precisamos navigateadicionar um sinalizador especial de “não fazer pushStateà função (caso contrário, tudo quebrará ainda mais!). Além disso, para distinguir entre "nossos" estados, devemos usar a capacidade pushStatede salvar metadados. Há muita mágica e depuração, então vamos direto ao código! Aqui está a aparência do aplicativo:

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

E aqui está a ação de navegação:

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

Agora a história vai funcionar.

Bem, o último toque: como agora temos uma ação navigate, por que não desistimos do código extra no cliente que calcula o estado inicial? Podemos simplesmente ligar para navegar para o local atual:

 --- 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 e colar destruído!

Front-end: renderização do lado do servidor


É hora de nossos principais chips (na minha opinião) - compatibilidade com SEO. Para que os mecanismos de pesquisa possam indexar nosso conteúdo, que é completamente criado dinamicamente nos componentes React, precisamos ser capazes de fornecer o resultado da renderização do React e também aprender como tornar esse resultado interativo novamente.

O esquema geral é simples. Primeiro: precisamos inserir o HTML gerado pelo nosso componente React no nosso modelo HTML App. Este HTML será visto pelos mecanismos de pesquisa (e navegadores com o JS desativado, hehe). Segundo: você precisa adicionar uma tag ao modelo <script>que salve em algum lugar (por exemplo, um objeto window) um despejo de estado do qual esse HTML foi renderizado. Em seguida, podemos inicializar imediatamente nosso aplicativo no lado do cliente com esse estado e mostrar o que é necessário (podemos até usar hidratopara o HTML gerado, para não recriar a árvore DOM do aplicativo).

Vamos começar escrevendo uma função que retorna o HTML renderizado e o 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}; }; 

Adicione novos argumentos e lógica ao nosso modelo, sobre o qual falamos acima:

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

Nosso servidor Express se torna um pouco mais 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)); }); 

Mas o cliente é mais 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') ); 

Em seguida, você precisa limpar erros de plataforma cruzada como "o histórico não está definido". Para fazer isso, adicione uma função simples (até agora) em algum lugar utility.js.

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

Depois, haverá um certo número de alterações de rotina que não trarei aqui (mas elas podem ser encontradas no commit correspondente ). Como resultado, nosso aplicativo React poderá renderizar no navegador e no servidor.

Isso funciona!Mas há, como se costuma dizer, uma ressalva ...



CARREGANDO? Tudo o que o Google vê no meu serviço de moda super bacana é CARREGANDO ?!

Bem, parece que todo o nosso assincronismo jogou contra nós. Agora, precisamos de uma maneira de permitir que o servidor entenda que a resposta do back-end com o conteúdo do cartão precisa aguardar antes de renderizar o aplicativo React em uma string e enviá-lo ao cliente. E é desejável que esse método seja bastante geral.

Pode haver muitas soluções. Uma abordagem é descrever em um arquivo separado para quais caminhos quais dados devem ser protegidos e fazer isso antes de renderizar o aplicativo ( artigo ). Esta solução tem muitas vantagens. É simples, é explícito e funciona.

Como um experimento (o conteúdo original deve estar no artigo pelo menos em algum lugar!), Proponho outro esquema. Sempre que executamos algo assíncrono, que devemos esperar, adicione a promessa apropriada (por exemplo, a que retorna a busca) em algum lugar do nosso estado. Portanto, teremos um lugar onde você sempre pode verificar se tudo foi baixado.

Adicione duas novas ações.

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


O primeiro será chamado quando a busca for iniciada, o segundo - no final dela .then().

Agora adicione seu processamento ao redutor:

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

Agora vamos melhorar a ação 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)); }; } 

Resta acrescentar initialStatepromessas à matriz vazia e fazer o servidor esperar por todas! A função de renderização se torna assíncrona e assume o seguinte formato:

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

Devido à renderassincronia adquirida , o manipulador de solicitações também é um pouco mais 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à!



Conclusão


Como você pode ver, a criação de um aplicativo de alta tecnologia não é tão simples. Mas não é tão difícil! O aplicativo final está no repositório do Github e, teoricamente, você só precisa do Docker para executá-lo.

Se o artigo estiver com demanda, este repositório nem será abandonado! Poderemos analisar com algo de outro conhecimento necessário:

  • registro, monitoramento, teste de carga.
  • teste, CI, CD.
  • recursos mais legais, como autorização ou pesquisa de texto completo.
  • configuração e desenvolvimento do ambiente de produção.

Obrigado pela atenção!

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


All Articles