最新のWebアプリケーションをゼロから作成する

そこで、新しいプロジェクトを作成することにしました。 そして、このプロジェクトはWebアプリケーションです。 基本的なプロトタイプを作成するのにどれくらい時間がかかりますか? それはどれほど難しいですか? 現代のウェブサイトは最初から何ができるはずですか?



この記事では、次のアーキテクチャを備えた単純なWebアプリケーションの定型的な概要を説明します。









カバーするもの:





はじめに



もちろん、開発の前に、まず何を開発するかを決める必要があります! この記事のモデルアプリケーションとして、原始的なWikiエンジンを作成することにしました。 Markdownでカードを発行します。 視聴し、(将来的には)編集を提供できます。 これらはすべて、サーバー側レンダリングを備えた1ページのアプリケーションとして配置します(これは、将来のテラバイトのコンテンツのインデックス作成に絶対に必要です)。



これに必要なコンポーネントをもう少し詳しく見てみましょう。





インフラストラクチャ: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
      
      





ここで何が起こっているかを簡単に見てみましょう。





では、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/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拡張機能( ChromeFirefox )がインストールされている場合、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を実行するだけで十分です。



記事が需要がある場合、このリポジトリは破棄されません!必要な他の知識から何かを調べることができます。





ご清聴ありがとうございました!



All Articles