この記事では、次のアーキテクチャを備えた単純なWebアプリケーションの定型的な概要を説明します。
カバーするもの:
- docker-composeで開発環境を設定します。
- Flaskでのバックエンドの作成。
- Expressでフロントエンドを作成します。
- Webpackを使用してJSをビルドします。
- React、Redux、およびサーバー側のレンダリング。
- RQを使用したタスクキュー。
はじめに
もちろん、開発の前に、まず何を開発するかを決める必要があります! この記事のモデルアプリケーションとして、原始的なWikiエンジンを作成することにしました。 Markdownでカードを発行します。 視聴し、(将来的には)編集を提供できます。 これらはすべて、サーバー側レンダリングを備えた1ページのアプリケーションとして配置します(これは、将来のテラバイトのコンテンツのインデックス作成に絶対に必要です)。
これに必要なコンポーネントをもう少し詳しく見てみましょう。
- お客様 フロントエンドの世界では非常に一般的なReact + Reduxバンドルで、1ページのアプリケーション(つまり、AJAXを使用したページ遷移を使用)を作成しましょう。
- フロントエンド 。 Reactアプリケーションをレンダリングし(バックエンドのすべての必要なデータを非同期で要求する)、ユーザーに発行する簡単なExpressサーバーを作成しましょう。
- バックエンド 。 ビジネスロジックのマスターであるバックエンドは、小さなFlaskアプリケーションになります。 人気のあるMongoDBドキュメントリポジトリにデータ(カード)を保存し、タスクキューと、将来的にはキャッシュのために、 Redisを使用します。
- ワーカー 。 重いタスク用の別のコンテナがRQライブラリによって起動されます。
インフラストラクチャ:git
おそらく、これについて話すことはできませんでしたが、もちろん、gitリポジトリで開発を行います。
git init git remote add origin git@github.com:Saluev/habr-app-demo.git git commit --allow-empty -m "Initial commit" git push
(ここでは、すぐに
.gitignore
する必要があります。)
最終ドラフトはGithubで表示できます。 記事の各セクションは1つのコミットに対応しています(これを達成するために多くのことを考え直しました!)
インフラストラクチャ:docker-compose
環境をセットアップすることから始めましょう。 豊富なコンポーネントがあるため、非常に論理的な開発ソリューションはdocker-composeを使用することです。
docker-compose.yml
ファイルを次の内容でリポジトリに追加します。
version: '3' services: mongo: image: "mongo:latest" redis: image: "redis:alpine" backend: build: context: . dockerfile: ./docker/backend/Dockerfile environment: - APP_ENV=dev depends_on: - mongo - redis ports: - "40001:40001" volumes: - .:/code frontend: build: context: . dockerfile: ./docker/frontend/Dockerfile environment: - APP_ENV=dev - APP_BACKEND_URL=backend:40001 - APP_FRONTEND_PORT=40002 depends_on: - backend ports: - "40002:40002" volumes: - ./frontend:/app/src worker: build: context: . dockerfile: ./docker/worker/Dockerfile environment: - APP_ENV=dev depends_on: - mongo - redis volumes: - .:/code
ここで何が起こっているかを簡単に見てみましょう。
- MongoDBコンテナーとRedisコンテナーが作成されます。
- バックエンドのコンテナが作成されます(以下で説明します)。 環境変数APP_ENV = devが渡され(どのFlask設定を読み込むかを理解するために見ていきます)、ポート40001が外部で開きます(それを通じて、ブラウザークライアントはAPIにアクセスします)。
- フロントエンドのコンテナが作成されます。 さまざまな環境変数もそこにスローされ、後で便利になり、ポート40002が開きます。これがWebアプリケーションのメインポートです。ブラウザでhttp:// localhost:40002に移動します。
- ワーカーのコンテナが作成されます。 彼は外部ポートを必要とせず、MongoDBとRedisではアクセスのみが必要です。
では、dockerfilesを作成しましょう。 現時点では、Docker に関する 優れた 記事 の 翻訳 シリーズが Habréに掲載されています。詳細については安全にアクセスできます。
バックエンドから始めましょう。
# 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
backend.server
モジュールの
app
という名前の下に隠れて、gunicorn Flaskアプリケーションを実行していることがわかります。
それほど重要ではない
docker/backend/.dockerignore
:
.git .idea .logs .pytest_cache frontend tests venv *.pyc *.pyo
ワーカーは一般的にバックエンドに似ていますが、gunicornの代わりに通常のピットモジュールの起動があります。
# docker/worker/Dockerfile FROM python:stretch COPY requirements.txt /tmp/ RUN pip install -r /tmp/requirements.txt ADD . /code WORKDIR /code CMD python -m worker
worker/__main__.py
ですべての作業を
worker/__main__.py
ます。
.dockerignore
ワーカーは、
.dockerignore
バックエンドに完全に似ています。
最後に、フロントエンド。 Habréについては彼に関するまったく別の記事がありますが、 StackOverflowの広範な議論と「Guys、それは既に2018年ですか、まだ通常の解決策はありませんか?」という精神のコメントから判断すると、すべてがそれほど単純ではありません。 このバージョンの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
長所:
- すべてが期待通りにキャッシュされます(最下層-依存関係、最上部-アプリケーションのビルド);
-
docker-compose exec frontend npm install --save newDependency
機能し、リポジトリのpackage.json
を変更します(多くの人が示唆するように、COPYを使用した場合はそうではありません)。 とにかく、npm install --save newDependency
コンテナの外部で実行することは望ましくありません。新しいパッケージのいくつかの依存関係が既に存在し、異なるプラットフォームの下に構築されている可能性があるためです(たとえば、作業中のMacbookの下ではなく、Docker内のもの) )、それでも、開発マシンにNodeが存在することを要求したくありません。 すべてを支配する1つのDocker!
さて、もちろん
docker/frontend/.dockerignore
:
.git .idea .logs .pytest_cache backend worker tools node_modules npm-debug tests venv
これで、コンテナフレームの準備が整い、内容を入力できます!
バックエンド:Flask framework
flask
、
flask-cors
gevent
、
gevent
、
gunicorn
を
requirements.txt
追加し、簡単なFlaskアプリケーションを
backend/server.py
作成し
requirements.txt
。
# backend/server.py import os.path import flask import flask_cors class HabrAppDemo(flask.Flask): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # CORS # , # ( Access-Control-Origin ). # - . flask_cors.CORS(self) app = HabrAppDemo("habr-app-demo") env = os.environ.get("APP_ENV", "dev") print(f"Starting application in {env} mode") app.config.from_object(f"backend.{env}_settings")
Flaskに
backend.{env}_settings
ファイル
backend.{env}_settings
から設定をプルアップするように指示しました
backend.{env}_settings
これは、すべての
backend/dev_settings.py
ために(少なくとも空の)ファイル
backend/dev_settings.py
を作成する必要があることを意味します。
これで、バックエンドを正式に立ち上げることができます!
habr-app-demo$ docker-compose up backend ... backend_1 | [2019-02-23 10:09:03 +0000] [6] [INFO] Starting gunicorn 19.9.0 backend_1 | [2019-02-23 10:09:03 +0000] [6] [INFO] Listening at: http://0.0.0.0:40001 (6) backend_1 | [2019-02-23 10:09:03 +0000] [6] [INFO] Using worker: gevent backend_1 | [2019-02-23 10:09:03 +0000] [9] [INFO] Booting worker with pid: 9
先に進みます。
フロントエンド:Expressフレームワーク
パッケージを作成することから始めましょう。 フロントエンドフォルダーを作成し、その中で
npm init
を実行すると、洗練されていないいくつかの質問の後に、完成したpackage.jsonがスピリットで取得されます。
{ "name": "habr-app-demo", "version": "0.0.1", "description": "This is an app demo for Habr article.", "main": "index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "repository": { "type": "git", "url": "git+https://github.com/Saluev/habr-app-demo.git" }, "author": "Tigran Saluev <tigran@saluev.com>", "license": "MIT", "bugs": { "url": "https://github.com/Saluev/habr-app-demo/issues" }, "homepage": "https://github.com/Saluev/habr-app-demo#readme" }
将来、開発者のマシンにはNode.jsはまったく必要ありません(Dockerを使用して
npm init
を
npm init
および開始できますが、まあまあです)。
Dockerfile
npm run build
および
npm run start
に言及しました
Dockerfile
適切なコマンドを追加する必要があります。
--- a/frontend/package.json +++ b/frontend/package.json @@ -4,6 +4,8 @@ "description": "This is an app demo for Habr article.", "main": "index.js", "scripts": { + "build": "echo 'build'", + "start": "node index.js", "test": "echo \"Error: no test specified\" && exit 1" }, "repository": {
build
コマンドはまだ何もしませんが、それでも有用です。
Expressの依存関係を追加し、
index.js
簡単なアプリケーションを作成し
index.js
。
--- a/frontend/package.json +++ b/frontend/package.json @@ -17,5 +17,8 @@ "bugs": { "url": "https://github.com/Saluev/habr-app-demo/issues" }, - "homepage": "https://github.com/Saluev/habr-app-demo#readme" + "homepage": "https://github.com/Saluev/habr-app-demo#readme", + "dependencies": { + "express": "^4.16.3" + } }
// frontend/index.js const express = require("express"); app = express(); app.listen(process.env.APP_FRONTEND_PORT); app.get("*", (req, res) => { res.send("Hello, world!") });
これで、フロント
docker-compose up frontend
が
docker-compose up frontend
エンドになります! さらに、 http:// localhost:40002では、古典的な「Hello、world」がすでに披露されているはずです。
フロントエンド:webpackおよびReactアプリケーションでビルド
今度は、アプリケーションでプレーンテキスト以外の何かを描くときです。 このセクションでは、
App
最も単純なReactコンポーネントを追加し、アセンブリを構成します。
Reactでプログラミングするときは、構文構造によって拡張されたJavaScriptの方言であるJSXを使用すると非常に便利です。
render() { return <MyButton color="blue">{this.props.caption}</MyButton>; }
ただし、JavaScriptエンジンはそれを理解しないため、通常、ビルドフェーズがフロントエンドに追加されます。 特別なJavaScriptコンパイラ(ええ、ええ)は構文糖をsugarい古典的なJavaScriptに変え、インポートを処理し、縮小します。
2014年。 apt-cache検索java
したがって、最も単純なReactコンポーネントは非常に単純に見えます。
// frontend/src/components/app.js import React, {Component} from 'react' class App extends Component { render() { return <h1>Hello, world!</h1> } } export default App
彼は単に説得力のあるピンで挨拶を表示するだけです。
将来のアプリケーションの最小限のHTMLフレームワークを含むファイル
frontend/src/template.js
を追加します。
// frontend/src/template.js export default function template(title) { let page = ` <!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <title>${title}</title> </head> <body> <div id="app"></div> <script src="/dist/client.js"></script> </body> </html> `; return page; }
クライアントエントリポイントを追加します。
// frontend/src/client.js import React from 'react' import {render} from 'react-dom' import App from './components/app' render( <App/>, document.querySelector('#app') );
この美しさをすべて構築するには、次のものが必要です。
webpackはJSのファッショナブルな若者ビルダーです(ただし、フロントエンドの記事を3時間読んでいませんが、ファッションについてはわかりません)。
babelはJSXのようなすべての種類のローションのコンパイラであり、同時にすべてのIEケースのポリフィルプロバイダーです。
フロントエンドの前の反復がまだ実行されている場合、あなたがしなければならないことはすべてです
docker-compose exec frontend npm install --save \ react \ react-dom docker-compose exec frontend npm install --save-dev \ webpack \ webpack-cli \ babel-loader \ @babel/core \ @babel/polyfill \ @babel/preset-env \ @babel/preset-react
新しい依存関係をインストールします。 次にwebpackを構成します。
// frontend/webpack.config.js const path = require("path"); // . clientConfig = { mode: "development", entry: { client: ["./src/client.js", "@babel/polyfill"] }, output: { path: path.resolve(__dirname, "../dist"), filename: "[name].js" }, module: { rules: [ { test: /\.js$/, exclude: /node_modules/, loader: "babel-loader" } ] } }; // . : // 1. target: "node" - import path. // 2. .., ../dist -- // , ! serverConfig = { mode: "development", target: "node", entry: { server: ["./index.js", "@babel/polyfill"] }, output: { path: path.resolve(__dirname, ".."), filename: "[name].js" }, module: { rules: [ { test: /\.js$/, exclude: /node_modules/, loader: "babel-loader" } ] } }; module.exports = [clientConfig, serverConfig];
babelを機能させるには、
frontend/.babelrc
を設定する必要があります。
{ "presets": ["@babel/env", "@babel/react"] }
最後に、
npm run build
コマンドを意味のあるものにします。
// frontend/package.json ... "scripts": { "build": "webpack", "start": "node /app/server.js", "test": "echo \"Error: no test specified\" && exit 1" }, ...
これで、クライアントは、ポリフィルのバンドルとそのすべての依存関係とともに、babelを実行し、コンパイルして、モノリシックな縮小ファイル
../dist/client.js
ます。 Expressアプリケーションに静的ファイルとしてアップロードする機能を追加し、デフォルトのルートでHTMLを返し始めます。
// frontend/index.js // , , // - . import express from 'express' import template from './src/template' let app = express(); app.use('/dist', express.static('../dist')); app.get("*", (req, res) => { res.send(template("Habr demo app")); }); app.listen(process.env.APP_FRONTEND_PORT);
成功! これで、
docker-compose up --build frontend
を実行すると、「Hello、world!」という新しい光沢のあるラッパーが表示され、React Developer Tools拡張機能( Chrome 、 Firefox )がインストールされている場合、Reactコンポーネントツリーもあります。開発者ツールで:
バックエンド:MongoDBのデータ
先に進み、アプリケーションに命を吹き込む前に、まずバックエンドに息を吹き込まなければなりません。 Markdownでマークアップしたカードを保存するつもりだったようです。今度はそれを実行します。
pythonにはMongoDBのORMがありますが、ORMの使用は悪質であると考えており、適切なソリューションの研究はあなたに任せます。 代わりに、カードとそれに付随するDAOの簡単なクラスを作成します。
# backend/storage/card.py import abc from typing import Iterable class Card(object): def __init__(self, id: str = None, slug: str = None, name: str = None, markdown: str = None, html: str = None): self.id = id self.slug = slug # self.name = name self.markdown = markdown self.html = html class CardDAO(object, metaclass=abc.ABCMeta): @abc.abstractmethod def create(self, card: Card) -> Card: pass @abc.abstractmethod def update(self, card: Card) -> Card: pass @abc.abstractmethod def get_all(self) -> Iterable[Card]: pass @abc.abstractmethod def get_by_id(self, card_id: str) -> Card: pass @abc.abstractmethod def get_by_slug(self, slug: str) -> Card: pass class CardNotFound(Exception): pass
(まだPythonで型注釈を使用していない場合は、必ずこれらの 記事をチェックしてください!)
次に
pymongo
から
Database
オブジェクトを
pymongo
する
CardDAO
インターフェースの実装を作成
pymongo
(そう、
pymongo
を
requirements.txt
に追加する時間
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)
バックエンド設定でMonga構成を登録する時間。 コンテナにmongo
mongo
という名前を付けただけなので、
MONGO_HOST = "mongo"
:
--- a/backend/dev_settings.py +++ b/backend/dev_settings.py @@ -0,0 +1,3 @@ +MONGO_HOST = "mongo" +MONGO_PORT = 27017 +MONGO_DATABASE = "core"
次に、
MongoCardDAO
を作成し、Flaskアプリケーションにアクセスできるようにする必要があります。 オブジェクトの非常に単純な階層(設定→pymongoクライアント→pymongoデータベース→
MongoCardDAO
)ができましたが、 依存関係の注入を行う集中型のキングコンポーネントをすぐに作成しましょう(ワーカーとツールを実行するときに再び役立ちます)。
# backend/wiring.py import os from pymongo import MongoClient from pymongo.database import Database import backend.dev_settings from backend.storage.card import CardDAO from backend.storage.card_impl import MongoCardDAO class Wiring(object): def __init__(self, env=None): if env is None: env = os.environ.get("APP_ENV", "dev") self.settings = { "dev": backend.dev_settings, # ( # , !) }[env] # . # DI, . self.mongo_client: MongoClient = MongoClient( host=self.settings.MONGO_HOST, port=self.settings.MONGO_PORT) self.mongo_database: Database = self.mongo_client[self.settings.MONGO_DATABASE] self.card_dao: CardDAO = MongoCardDAO(self.mongo_database)
Flaskアプリケーションに新しいルートを追加して、眺めを楽しみましょう!
# backend/server.py import os.path import flask import flask_cors from backend.storage.card import CardNotFound from backend.wiring import Wiring env = os.environ.get("APP_ENV", "dev") print(f"Starting application in {env} mode") class HabrAppDemo(flask.Flask): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) flask_cors.CORS(self) self.wiring = Wiring(env) self.route("/api/v1/card/<card_id_or_slug>")(self.card) def card(self, card_id_or_slug): try: card = self.wiring.card_dao.get_by_slug(card_id_or_slug) except CardNotFound: try: card = self.wiring.card_dao.get_by_id(card_id_or_slug) except (CardNotFound, ValueError): return flask.abort(404) return flask.jsonify({ k: v for k, v in card.__dict__.items() if v is not None }) app = HabrAppDemo("habr-app-demo") app.config.from_object(f"backend.{env}_settings")
docker-compose up --build backend
再起動します:
おっと...ああ、まさに。 コンテンツを追加する必要があります! toolsフォルダーを開き、そこに1つのテストカードを追加するスクリプトを追加します。
# tools/add_test_content.py from backend.storage.card import Card from backend.wiring import Wiring wiring = Wiring() wiring.card_dao.create(Card( slug="helloworld", name="Hello, world!", markdown=""" This is a hello-world page. """))
docker-compose exec backend python -m tools.add_test_content
、
docker-compose exec backend python -m tools.add_test_content
コンテナ内のコンテンツでmongaを満たします。
成功! 今こそ、フロントエンドでこれをサポートするときです。
フロントエンド:Redux
次に、ルート
/card/:id_or_slug
作成します。これにより、Reactアプリケーションが開き、APIからカードデータを読み込んで、何らかの方法で表示します。 そしてここで、おそらく最も難しい部分が始まります。これは、サーバーがインデックスの作成に適したカードのコンテンツを含むHTMLをすぐに提供したいが、同時に、アプリケーションがカード間をナビゲートすると、APIからJSONの形式ですべてのデータを受信し、ページがオーバーロードしないためです そして、これすべて-コピーアンドペーストなし!
Reduxを追加することから始めましょう。 Reduxは、状態を保存するためのJavaScriptライブラリです。 これは、ユーザーアクションやその他の興味深いイベント中にコンポーネントが変化する数千の暗黙の状態ではなく、1つの集中状態を持ち、アクションの集中メカニズムを通じて変更を加えるという考え方です。 したがって、ナビゲーションの初期段階で最初にGIFの読み込みをオンにし、次にAJAXを介してリクエストを行い、最後に成功コールバックでページの必要な部分を更新し、Reduxパラダイムで「アニメーション付きのGIFにコンテンツを変更する」アクションを送信しますコンポーネントの1つが以前のコンテンツを破棄してアニメーションを配置し、リクエストを行い、成功コールバックで別のアクション「コンテンツをロード済みに変更」を送信するように、グローバル状態を変更します。 一般的に、今、私たちは自分でそれを見るでしょう。
コンテナに新しい依存関係をインストールすることから始めましょう。
docker-compose exec frontend npm install --save \ redux \ react-redux \ redux-thunk \ redux-devtools-extension
1つ目は実際にはRedux、2つ目はReactとReduxを横断するための特別なライブラリ(交配の専門家が作成)、3つ目は非常に必要なものであり、その必要性はREADMEで正当化され、最後に4つ目はRedux DevToolsが動作するために必要なライブラリです拡張
定型的なReduxコードから始めましょう。何もしないレデューサーを作成し、状態を初期化します。
// frontend/src/redux/reducers.js export default function root(state = {}, action) { return state; }
// frontend/src/redux/configureStore.js import {createStore, applyMiddleware} from "redux"; import thunkMiddleware from "redux-thunk"; import {composeWithDevTools} from "redux-devtools-extension"; import rootReducer from "./reducers"; export default function configureStore(initialState) { return createStore( rootReducer, initialState, composeWithDevTools(applyMiddleware(thunkMiddleware)), ); }
私たちのクライアントは少し変わり、精神的にReduxを使用する準備をしています:
// frontend/src/client.js import React from 'react' import {render} from 'react-dom' import {Provider} from 'react-redux' import App from './components/app' import configureStore from './redux/configureStore' // ... const store = configureStore(); render( // ... , // <Provider store={store}> <App/> </Provider>, document.querySelector('#app') );
これでdocker-compose up --build frontendを実行して、何も破損していないことを確認でき、Redux DevToolsにプリミティブ状態が表示されました。
フロントエンド:カードページ
SSRでページを作成する前に、SSRなしでページを作成する必要があります! 最後に、カードにアクセスするために独創的なAPIを使用して、フロントエンドのカードページを作成しましょう。
インテリジェンスを活用して、私たちの状態の構造を再設計する時間です。 このトピックには多くの資料がありますので、インテリジェンスを乱用しないことをお勧めします。シンプルに焦点を当てます。 たとえば、次のとおりです。
{ "page": { "type": "card", // // type=card: "cardSlug": "...", // "isFetching": false, // API "cardData": {...}, // ( ) // ... }, // ... }
cardDataのコンテンツを小道具として使用する「card」コンポーネントを取得しましょう(実際にはmongoのカードのコンテンツです)。
// frontend/src/components/card.js import React, {Component} from 'react'; class Card extends Component { componentDidMount() { document.title = this.props.name } render() { const {name, html} = this.props; return ( <div> <h1>{name}</h1> <!---, HTML React - !--> <div dangerouslySetInnerHTML={{__html: html}}/> </div> ); } } export default Card;
次に、カードを含むページ全体のコンポーネントを取得します。 彼は、APIから必要なデータを取得し、それをカードに転送する責任があります。 そして、React-Reduxの方法でデータを取得します。
最初に、ファイル
frontend/src/redux/actions.js
を作成し、APIからカードのコンテンツを抽出するアクションを作成します(まだない場合):
export function fetchCardIfNeeded() { return (dispatch, getState) => { let state = getState().page; if (state.cardData === undefined || state.cardData.slug !== state.cardSlug) { return dispatch(fetchCard()); } }; }
実際にフェッチを行う
fetchCard
アクションは、もう少し複雑です。
function fetchCard() { return (dispatch, getState) => { // , . // , , // . dispatch(startFetchingCard()); // API. let url = apiPath() + "/card/" + getState().page.cardSlug; // , , , // . , , // . return fetch(url) .then(response => response.json()) .then(json => dispatch(finishFetchingCard(json))); }; // , redux-thunk // . } function startFetchingCard() { return { type: START_FETCHING_CARD }; } function finishFetchingCard(json) { return { type: FINISH_FETCHING_CARD, cardData: json }; } function apiPath() { // . server-side // rendering, API - // localhost, // backend. return "http://localhost:40001/api/v1"; }
ああ、私たちは何かをするアクションを得ました!レデューサーでこれをサポートする必要があります。
// frontend/src/redux/reducers.js import { START_FETCHING_CARD, FINISH_FETCHING_CARD } from "./actions"; export default function root(state = {}, action) { switch (action.type) { case START_FETCHING_CARD: return { ...state, page: { ...state.page, isFetching: true } }; case FINISH_FETCHING_CARD: return { ...state, page: { ...state.page, isFetching: false, cardData: action.cardData } } } return state; }
(個々のフィールドを変更してオブジェクトを複製するための流行の構文に注意して
ください。)すべてのロジックがReduxアクションで実行されるようになったため、コンポーネント自体
CardPage
は比較的単純に見えます。
// frontend/src/components/cardPage.js import React, {Component} from 'react'; import {connect} from 'react-redux' import {fetchCardIfNeeded} from '../redux/actions' import Card from './card' class CardPage extends Component { componentWillMount() { // , React // . // , " // " , // - . - // HTML // renderToString, SSR. this.props.dispatch(fetchCardIfNeeded()) } render() { const {isFetching, cardData} = this.props; return ( <div> {isFetching && <h2>Loading...</h2>} {cardData && <Card {...cardData}/>} </div> ); } } // , // . // react-redux. page // dispatch, . function mapStateToProps(state) { const {page} = state; return page; } export default connect(mapStateToProps)(CardPage);
ルートAppコンポーネントに単純なpage.type処理を追加します。
// frontend/src/components/app.js import React, {Component} from 'react' import {connect} from "react-redux"; import CardPage from "./cardPage" class App extends Component { render() { const {pageType} = this.props; return ( <div> {pageType === "card" && <CardPage/>} </div> ); } } function mapStateToProps(state) { const {page} = state; const {type} = page; return { pageType: type }; } export default connect(mapStateToProps)(App);
そして今、最後のポイント-初期化する必要がある
page.type
と
page.cardSlug
URLによって異なります。
しかし、この記事にはまだ多くのセクションがありますが、現在、高品質のソリューションを作成することはできません。とりあえずバカにしてみましょう。それはまったくばかです。たとえば、アプリケーションを初期化するときに定期的に!
// frontend/src/client.js import React from 'react' import {render} from 'react-dom' import {Provider} from 'react-redux' import App from './components/app' import configureStore from './redux/configureStore' let initialState = { page: { type: "home" } }; const m = /^\/card\/([^\/]+)$/.exec(location.pathname); if (m !== null) { initialState = { page: { type: "card", cardSlug: m[1] }, } } const store = configureStore(initialState); render( <Provider store={store}> <App/> </Provider>, document.querySelector('#app') );
今、私たちはの助けを借りて、フロントエンドを再構築することができます
docker-compose up --build frontend
私たちのカードを楽しむために、
helloworld
...
だから、ちょっと待ってください...が、どこ私たちのコンテンツがありますか?ああ、Markdownを解析するのを忘れました!
労働者:RQ
Markdownを解析し、潜在的に無制限のサイズのカードのHTMLを生成することは、典型的な「重い」タスクです。変更を保存しながらバックエンドで直接解決するのではなく、通常、別々の作業マシンでキューに入れて実行します。
タスクキューには多くのオープンソース実装があります。RedisとシンプルなライブラリRQ(Redis Queue)を使用します。RQ(Redis Queue)は、タスクパラメーターをpickle形式で送信し、処理のための生成プロセスを編成します。
設定と配線に応じて大根を追加する時間!
--- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,5 @@ flask-cors gevent gunicorn pymongo +redis +rq --- a/backend/dev_settings.py +++ b/backend/dev_settings.py @@ -1,3 +1,7 @@ MONGO_HOST = "mongo" MONGO_PORT = 27017 MONGO_DATABASE = "core" +REDIS_HOST = "redis" +REDIS_PORT = 6379 +REDIS_DB = 0 +TASK_QUEUE_NAME = "tasks" --- a/backend/wiring.py +++ b/backend/wiring.py @@ -2,6 +2,8 @@ import os from pymongo import MongoClient from pymongo.database import Database +import redis +import rq import backend.dev_settings from backend.storage.card import CardDAO @@ -21,3 +23,11 @@ class Wiring(object): port=self.settings.MONGO_PORT) self.mongo_database: Database = self.mongo_client[self.settings.MONGO_DATABASE] self.card_dao: CardDAO = MongoCardDAO(self.mongo_database) + + self.redis: redis.Redis = redis.StrictRedis( + host=self.settings.REDIS_HOST, + port=self.settings.REDIS_PORT, + db=self.settings.REDIS_DB) + self.task_queue: rq.Queue = rq.Queue( + name=self.settings.TASK_QUEUE_NAME, + connection=self.redis)
ワーカーの定型コードのビット。
# worker/__main__.py import argparse import uuid import rq import backend.wiring parser = argparse.ArgumentParser(description="Run worker.") # , # . , rq. parser.add_argument( "--burst", action="store_const", const=True, default=False, help="enable burst mode") args = parser.parse_args() # Redis. wiring = backend.wiring.Wiring() with rq.Connection(wiring.redis): w = rq.Worker( queues=[wiring.settings.TASK_QUEUE_NAME], # # , . name=uuid.uuid4().hex) w.work(burst=args.burst)
解析自体については、mistuneライブラリを接続し、簡単な関数を記述します。
# backend/tasks/parse.py import mistune from backend.storage.card import CardDAO def parse_card_markup(card_dao: CardDAO, card_id: str): card = card_dao.get_by_id(card_id) card.html = _parse_markdown(card.markdown) card_dao.update(card) _parse_markdown = mistune.Markdown(escape=True, hard_wrap=False)
論理的に:
CardDAO
カードのソースコードを取得し、結果を保存する必要があります。ただし、外部ストレージへの接続を含むオブジェクトはpickleを介してシリアル化できません。つまり、このタスクをすぐに取得してRQのキューに入れることはできません。良い方法では
Wiring
、側にワーカーを作成し、あらゆる種類のワーカーをスローする必要があります...それをやってみましょう:
--- a/worker/__main__.py +++ b/worker/__main__.py @@ -2,6 +2,7 @@ import argparse import uuid import rq +from rq.job import Job import backend.wiring @@ -16,8 +17,23 @@ args = parser.parse_args() wiring = backend.wiring.Wiring() + +class JobWithWiring(Job): + + @property + def kwargs(self): + result = dict(super().kwargs) + result["wiring"] = backend.wiring.Wiring() + return result + + @kwargs.setter + def kwargs(self, value): + super().kwargs = value + + with rq.Connection(wiring.redis): w = rq.Worker( queues=[wiring.settings.TASK_QUEUE_NAME], - name=uuid.uuid4().hex) + name=uuid.uuid4().hex, + job_class=JobWithWiring) w.work(burst=args.burst)
ジョブのクラスを宣言し、すべての問題で配線を追加のkwargs引数としてスローしました。(タスクが処理される前にRQ内で発生するフォークの前に一部のクライアントを作成できないため、毎回新しい配線を作成することに注意してください。)配線から必要なものだけを取得するデコレータを作成しましょう。
# backend/tasks/task.py import functools from typing import Callable from backend.wiring import Wiring def task(func: Callable): # : varnames = func.__code__.co_varnames @functools.wraps(func) def result(*args, **kwargs): # . .pop(), # , . wiring: Wiring = kwargs.pop("wiring") wired_objects_by_name = wiring.__dict__ for arg_name in varnames: if arg_name in wired_objects_by_name: kwargs[arg_name] = wired_objects_by_name[arg_name] # # , - . return func(*args, **kwargs) return result
タスクにデコレータを追加して、人生を楽しみましょう:
import mistune from backend.storage.card import CardDAO from backend.tasks.task import task @task def parse_card_markup(card_dao: CardDAO, card_id: str): card = card_dao.get_by_id(card_id) card.html = _parse_markdown(card.markdown) card_dao.update(card) _parse_markdown = mistune.Markdown(escape=True, hard_wrap=False)
人生を楽しむ?うーん、私は言いたかった、労働者を実行します:
$ docker-compose up worker ... Creating habr-app-demo_worker_1 ... done Attaching to habr-app-demo_worker_1 worker_1 | 17:21:03 RQ worker 'rq:worker:49a25686acc34cdfa322feb88a780f00' started, version 0.13.0 worker_1 | 17:21:03 *** Listening on tasks... worker_1 | 17:21:03 Cleaning registries for queue: tasks
III ...彼は何もしません!もちろん、単一のタスクを設定しなかったためです!
テストカードを作成するツールを書き直して、次のようにします。a)カードが既に作成されている場合(この場合のように)落ちない。b)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})
ツールはバックエンドだけでなく、ワーカーでも実行できるようになりました。原則として、今は気にしません。私たちはそれを起動し
docker-compose exec worker python -m tools.add_test_content
、ターミナルの隣のタブに奇跡が見えます-労働者は何かをしました!
worker_1 | 17:34:26 tasks: backend.tasks.parse.parse_card_markup(card_id='5c715dd1e201ce000c6a89fa') (613b53b1-726b-47a4-9c7b-97cad26da1a5) worker_1 | 17:34:27 tasks: Job OK (613b53b1-726b-47a4-9c7b-97cad26da1a5) worker_1 | 17:34:27 Result is kept for 500 seconds
バックエンドでコンテナを再構築した後、ブラウザでカードの内容を最終的に確認できます。
フロントエンドナビゲーション
SSRに進む前に、Reactの大騒ぎを少し意味のあるものにし、単一ページのアプリケーションを本当に単一ページにする必要があります。ツールを更新して、相互にリンクする2つのカード(1つではなく、2つ、MOM、私はBIG DATE DEVELOPER!)を作成しましょう。その後、それらの間のナビゲーションを処理します。
非表示のテキスト
# tools/add_test_content.py def create_or_update(card): try: card.id = wiring.card_dao.get_by_slug(card.slug).id card = wiring.card_dao.update(card) except CardNotFound: card = wiring.card_dao.create(card) wiring.task_queue.enqueue_call( parse_card_markup, kwargs={"card_id": card.id}) create_or_update(Card( slug="helloworld", name="Hello, world!", markdown=""" This is a hello-world page. It can't really compete with the [demo page](demo). """)) create_or_update(Card( slug="demo", name="Demo Card!", markdown=""" Hi there, habrovchanin. You've probably got here from the awkward ["Hello, world" card](helloworld). Well, **good news**! Finally you are looking at a **really cool card**! """ ))
これで、リンクをたどって、すばらしいアプリケーションが再起動するたびにどうなるかを考えることができます。 やめて!
最初に、リンクのクリックにハンドラーを配置します。リンク付きのHTMLはバックエンドからのものであり、アプリケーションはReactを使用しているため、React固有の注意が少し必要です。
// frontend/src/components/card.js class Card extends Component { componentDidMount() { document.title = this.props.name } navigate(event) { // . // , . if (event.target.tagName === 'A' && event.target.hostname === window.location.hostname) { // event.preventDefault(); // this.props.dispatch(navigate(event.target)); } } render() { const {name, html} = this.props; return ( <div> <h1>{name}</h1> <div dangerouslySetInnerHTML={{__html: html}} onClick={event => this.navigate(event)} /> </div> ); } }
コンポーネント
CardPage
にカードをロードするすべてのロジックは、アクション自体(驚くべきことです!)であるため、アクションを実行する必要はありません。
export function navigate(link) { return { type: NAVIGATE, path: link.pathname } }
この場合に愚かなレデューサーを追加します。
// frontend/src/redux/reducers.js import { START_FETCHING_CARD, FINISH_FETCHING_CARD, NAVIGATE } from "./actions"; function navigate(state, path) { // react-router, ! // ( SSR.) let m = /^\/card\/([^/]+)$/.exec(path); if (m !== null) { return { ...state, page: { type: "card", cardSlug: m[1], isFetching: true } }; } return state } export default function root(state = {}, action) { switch (action.type) { case START_FETCHING_CARD: return { ...state, page: { ...state.page, isFetching: true } }; case FINISH_FETCHING_CARD: return { ...state, page: { ...state.page, isFetching: false, cardData: action.cardData } }; case NAVIGATE: return navigate(state, action.path) } return state; }
アプリケーションの状態が変更される可能
CardPage
性が
componentDidUpdate
あるため、既に追加したメソッドと同じメソッドを追加する必要があり
componentWillMount
ます。現在、プロパティ
CardPage
(
cardSlug
ナビゲーション中のプロパティなど)を更新した後、バックエンドからのカードのコンテンツも要求されます(
componentWillMount
コンポーネントの初期化時にのみこれが行われます)。
さて、
docker-compose up --build frontend
ナビゲーションが機能しました!
注意深い読者は、カード間を移動してもページのURLが変更されないことに気付くでしょう-スクリーンショットでさえ、デモカードのアドレスにある世界カードHelloが表示されます。したがって、前後ナビゲーションも落ちました。それを修正するために、すぐに歴史を持ついくつかの黒魔術を追加しましょう!
あなたができる最も簡単なことは、アクションに追加することです
navigate
挑戦
history.pushState
。
export function navigate(link) { history.pushState(null, "", link.href); return { type: NAVIGATE, path: link.pathname } }
これで、リンクをクリックすると、ブラウザーのアドレスバーのURLが実際に変更されます。ただし、戻るボタンは壊れます!
動作させるには、
popstate
オブジェクトのイベントをリッスンする必要があります
window
。さらに、このイベントで、前方および後方(つまり、
dispatch(navigate(...))
)にナビゲーションを行いたい場合
navigate
、特別な「do not
pushState
」フラグを関数に追加する必要があります(そうしないと、すべてがさらに壊れます!)。さらに、「私たちの」状態を区別
pushState
するために、メタデータを保存する機能を使用する必要があります。たくさんの魔法とデバッグがありますので、すぐにコードを見てみましょう!アプリは次のようになります。
// 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() { // ... } }
そして、ここにナビゲートアクションがあります。
// frontend/src/redux/actions.js export function navigate(link, dontPushState) { if (!dontPushState) { history.pushState({ pathname: link.pathname, href: link.href }, "", link.href); } return { type: NAVIGATE, path: link.pathname } }
これでストーリーは機能します。
さて、最後のタッチ:アクション
navigate
ができたので、クライアントで初期状態を計算する余分なコードを放棄しないのはなぜですか?現在の場所にナビゲートするだけです。
--- 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));
コピーペーストが破壊されました!
フロントエンド:サーバー側のレンダリング
私たちのメインチップ(SEOフレンドリー)の時間です。検索エンジンがReactコンポーネントで完全に動的に作成されたコンテンツをインデックス化するには、Reactレンダリングの結果を提供し、この結果を再びインタラクティブにする方法を学習する必要があります。
一般的なスキームは単純です。最初:Reactコンポーネントによって生成されたHTMLをHTMLテンプレートに挿入する必要があります
App
。このHTMLは、検索エンジン(およびJSがオフになっているブラウザー)に表示されます。 2番目:このHTMLがレンダリングされた状態ダンプ
<script>
をどこかに(たとえば、オブジェクト
window
)保存するテンプレートにタグを追加します。その後、すぐにこの状態でクライアント側アプリケーションを初期化し、必要なものを表示できます(ハイドレートを使用することもできます)アプリケーションのDOMツリーを再作成しないように、生成されたHTMLに)。
レンダリングされたHTMLと最終状態を返す関数を書くことから始めましょう。
// frontend/src/server.js import "@babel/polyfill" import React from 'react' import {renderToString} from 'react-dom/server' import {Provider} from 'react-redux' import App from './components/app' import {navigate} from "./redux/actions"; import configureStore from "./redux/configureStore"; export default function render(initialState, url) { // store, . const store = configureStore(initialState); store.dispatch(navigate(url)); let app = ( <Provider store={store}> <App/> </Provider> ); // , ! // , ? let content = renderToString(app); let preloadedState = store.getState(); return {content, preloadedState}; };
上記で説明した新しい引数とロジックをテンプレートに追加します。
// frontend/src/template.js function template(title, initialState, content) { let page = ` <!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <title>${title}</title> </head> <body> <div id="app">${content}</div> <script> window.__STATE__ = ${JSON.stringify(initialState)} </script> <script src="/dist/client.js"></script> </body> </html> `; return page; } module.exports = template;
Expressサーバーはもう少し複雑になります。
// frontend/index.js app.get("*", (req, res) => { const initialState = { page: { type: "home" } }; const {content, preloadedState} = render(initialState, {pathname: req.url}); res.send(template("Habr demo app", preloadedState, content)); });
しかし、クライアントは簡単です。
// frontend/src/client.js import React from 'react' import {hydrate} from 'react-dom' import {Provider} from 'react-redux' import App from './components/app' import configureStore from './redux/configureStore' import {navigate} from "./redux/actions"; // ! const store = configureStore(window.__STATE__); // render hydrate. hydrate // DOM tree, . hydrate( <Provider store={store}> <App/> </Provider>, document.querySelector('#app') );
次に、「history is not defined」などのクロスプラットフォームエラーをクリーンアップする必要があります。これを行うには、のどこかに単純な(これまでの)関数を追加し
utility.js
ます。
// frontend/src/utility.js export function isServerSide() { // , process, // - . return process.env.APP_ENV !== undefined; }
それから、ここには持ってこない一定数の定期的な変更があります(しかし、それらは対応するcommitで見つけることができます)。その結果、Reactアプリケーションはブラウザーとサーバーの両方でレンダリングできるようになります。
うまくいく!しかし、彼らが言うように、1つの警告があります...
ローディング? Googleが超クールなファッションサービスで見ているのはLOADING?!
まあ、それは私たちの非同期性のすべてが私たちに反対したようです。次に、カードのコンテンツを含むバックエンドからの応答は、Reactアプリケーションを文字列にレンダリングしてクライアントに送信する前に待機する必要があることをサーバーに理解させる方法が必要です。そして、この方法はかなり一般的であることが望ましい。
多くの解決策があります。 1つのアプローチは、どのパスに対してどのデータを保護するかを別のファイルに記述し、アプリケーションをレンダリングする前にこれを行うことです(記事)。このソリューションには多くの利点があります。それは単純で、明示的であり、機能します。
実験として(元のコンテンツは少なくとも記事のどこかにあるはずです!)別のスキームを提案します。非同期処理を実行するたびに、待機する必要があります。適切なプロミス(たとえば、フェッチを返すプロミス)を状態のどこかに追加します。そのため、すべてがダウンロードされたかどうかを常に確認できる場所があります。
2つの新しいアクションを追加します。
// frontend/src/redux/actions.js function addPromise(promise) { return { type: ADD_PROMISE, promise: promise }; } function removePromise(promise) { return { type: REMOVE_PROMISE, promise: promise, }; }
最初はフェッチの起動時に呼び出され、2番目はフェッチの最後に呼び出され
.then()
ます。
次に、リデューサーに処理を追加します。
// frontend/src/redux/reducers.js export default function root(state = {}, action) { switch (action.type) { case ADD_PROMISE: return { ...state, promises: [...state.promises, action.promise] }; case REMOVE_PROMISE: return { ...state, promises: state.promises.filter(p => p !== action.promise) }; ...
次に、アクションを改善します
fetchCard
。
// frontend/src/redux/actions.js function fetchCard() { return (dispatch, getState) => { dispatch(startFetchingCard()); let url = apiPath() + "/card/" + getState().page.cardSlug; let promise = fetch(url) .then(response => response.json()) .then(json => { dispatch(finishFetchingCard(json)); // " , " dispatch(removePromise(promise)); }); // " , " return dispatch(addPromise(promise)); }; }
initialState
空の配列にプロミスを追加し、サーバーがそれらすべてを待つようにすることは残っています!レンダリング関数は非同期になり、次の形式を取ります。
// frontend/src/server.js function hasPromises(state) { return state.promises.length > 0 } export default async function render(initialState, url) { const store = configureStore(initialState); store.dispatch(navigate(url)); let app = ( <Provider store={store}> <App/> </Provider> ); // renderToString // ( ). CardPage . renderToString(app); // , ! - // ( // , ), // . let preloadedState = store.getState(); while (hasPromises(preloadedState)) { await preloadedState.promises[0]; preloadedState = store.getState() } // renderToString. HTML. let content = renderToString(app); return {content, preloadedState}; };
取得された
render
非同期性により、要求ハンドラーも少し複雑になります。
// frontend/index.js app.get("*", (req, res) => { const initialState = { page: { type: "home" }, promises: [] }; render(initialState, {pathname: req.url}).then(result => { const {content, preloadedState} = result; const response = template("Habr demo app", preloadedState, content); res.send(response); }, (reason) => { console.log(reason); res.status(500).send("Server side rendering failed!"); }); });
ほら!
おわりに
ご覧のとおり、ハイテクアプリケーションの作成はそれほど簡単ではありません。しかし、それほど難しくありません!最終的なアプリケーションはGithubのリポジトリにあり、理論的にはDockerを実行するだけで十分です。
記事が需要がある場合、このリポジトリは破棄されません!必要な他の知識から何かを調べることができます。
- ロギング、モニタリング、負荷テスト。
- テスト、CI、CD。
- 認証や全文検索などの優れた機能。
- 実稼働環境のセットアップと開発。
ご清聴ありがとうございました!