最新の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拡匵機胜 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.cardSlugURLによっお異なりたす。

しかし、この蚘事にはただ倚くのセクションがありたすが、珟圚、高品質の゜リュヌションを䜜成するこずはできたせん。ずりあえずバカにしおみたしょう。それはたったくばかです。たずえば、アプリケヌションを初期化するずきに定期的に

 // 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を実行するだけで十分です。

蚘事が需芁がある堎合、このリポゞトリは砎棄されたせん必芁な他の知識から䜕かを調べるこずができたす。


ご枅聎ありがずうございたした

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


All Articles