How to create a modern web application from scratch

Joom
Joom
Published in
31 min readMay 29, 2020

--

Written by

Photo by Hal Gatewood on Unsplash

It happened. You decided to start a new project. And you want it to be a web app. How much time do you need for a basic prototype? How complex will it be? What architectural solutions should a modern web app employ from the very beginning?

In this post we will create a boilerplate of a simple web app with the following architecture:

We’ll talk about:

  • setting up dev environment with docker-compose;
  • creating Flask backend;
  • creating Express frontend;
  • bundling JS with Webpack;
  • React, Redux and server side rendering;
  • managing heavy tasks with RQ.

Introduction

Before writing a code you should first of all understand what you are going to construct. In this article, we decided to create a simple wiki engine as a model solution. In our app we will have cards written in Markdown. Users will be able to view and furtherly edit cards. To assure fast and smooth navigation between cards, we will build this app as a single-page application with AJAX navigation. It will also support server-side rendering. We do want search engines to be able to access our terabytes of content, don’t we?

Let’s take a more detailed look at the components that we’ll need:

  • Client. For the web application we will use one of the most popular browser-side stacks today — React+Redux.
  • Frontend. We’ll create a simple renderer with Express. It will request all necessary data from backend via API, render React components into HTML, and return HTML ready for display (and for indexing by search crawlers).
  • Backend. The lord of business logic, the backend component will be a small Flask application. We will store the data (our wiki cards) in MongoDB, a popular document storage. We will also use Redis for task queue and for (some day maybe) caching.
  • Worker. A separate component for heavy tasks will communicate with backend using RQ library.

Infrastructure: git

It goes without saying that we will develop our app in a git repo.

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

A good idea is to add .gitignore file right away.

You can check out the final project at Github. Every paragraph of the article corresponds to a single commit. It was a lot rebased to achieve this result — hope, you’ll appreciate!

Infrastructure: docker-compose

Let’s begin with setting up our dev environment. We are going to have plenty of components. It will hence be a fitting solution to use docker-compose to manage them.

We’ll start with adding to the repo a docker-compose.yml file looking like this:

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

A brief explanation of what’s going on:

  • We create a MongoDB container and a Redis container with default settings.
  • We create a container for our backend to look through it a bit later. We pass environment variable APP_ENV=dev to it to control which set of Flask settings to use. Container will publish its 40001 port (that’s how our client will access the API).
  • We create a container for the frontend. We pass various environment variables that we will later use and publish 40002 port. This port will be the entry point of our application. When the app’s ready, we will open http://localhost:40002 in the browser to check it out.
  • Finally, we create a container for the worker. The container doesn’t need to have any published ports, but only to have access to MongoDB and Redis.

Let’s create dockerfiles now. There’s a brilliant series of articles on Docker if you want more details.

We’ll start with the backend.

# docker/backend/DockerfileFROM 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

We will be running Flask application via gunicorn. Gunicorn will give us some nice features including implicit asynchronicity.

Another important file to create is docker/backend/.dockerignore:

.git.idea.logs.pytest_cachefrontendtestsvenv*.pyc*.pyo

Worker is in most ways same as backend, except for running our custom Python module instead of gunicorn.

# docker/worker/DockerfileFROM python:stretch
COPY requirements.txt /tmp/
RUN pip install -r /tmp/requirements.txt
ADD . /code
WORKDIR /code
CMD python -m worker

We’ll implement whole worker logic in worker/__main__.py.

.dockerignore for the worker is the same as .dockerignore for the backend.

Finally, the frontend. There is a dedicated article about managing frontend with Docker. Yet, according to this rather long discussion on StackOverflow and comments like “Guys, it’s 2018 already, is there still no good solution?”, it’s not all that simple. We experimented a bit and came to this dockerfile:

# docker/frontend/DockerfileFROM node:carbonWORKDIR /app# Copy package.json and package-lock.json and run `npm install` to cache dependencies.COPY frontend/package*.json ./RUN npm install# Our sources will be mounted to different folder.# We'll have to set PATH to access node modules from there.ENV PATH /app/node_modules/.bin:$PATH# The final container layer contains the built distribution of our app.ADD frontend /app/srcWORKDIR /app/srcRUN npm run buildCMD npm run start

Pros:

  • Docker caches everything as expected. Bottom layer contains dependencies (rarely modified), top layer contains our app (frequently modified);
  • docker-compose exec frontend npm install — save newDependency works conveniently. It modifies package.json not only inside the container, but also in our repo (which would not be true if we were using COPY like many guides suggest). Achieving the same effect by running npm install — save newDependency outside the container is less convenient. It requires having Node installed on our machine.

And, of course, their majesty, docker/frontend/.dockerignore:

.git.idea.logs.pytest_cachebackendworkertoolsnode_modulesnpm-debugtestsvenv

Now we are ready to fill our containers with actual stuff!

UPD. After publishing original article I noticed that this file structure is inherently flawed, leading to frontend and backend code actually depending on each other (and, oh my God, on node_modules!). It is because docker-compose.yml does not take .dockerignore files from subfolders into account. I fixed that in this commit.

Backend: Flask

Let’s add flask, flask-cors, gevent и gunicorn to requirements.txt and create a simple Flask application.

# backend/server.pyimport os.pathimport flaskimport flask_corsclass HabrAppDemo(flask.Flask):     def __init__(self, *args, **kwargs):        super().__init__(*args, **kwargs)        # CORS allows our frontend make requests to API despite        # being on different hosts with it (it adds        # Access-Control-Origin header to the responses).        # We will tune its preferences later.        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")

We told Flask to take settings from backend.{env}_settings file. Now we have to create an empty backend/dev_settings.py file to be able to run the stuff.

And this is the magnificent moment we can officially run our backend!

habr-app-demo$ docker-compose up backend...backend_1   | [2019-02-23 10:09:03 +0000] [6] [INFO] Starting gunicorn 19.9.0backend_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: geventbackend_1   | [2019-02-23 10:09:03 +0000] [9] [INFO] Booting worker with pid: 9

You can see all changes made in this paragraph together in this commit. Now moving on to the frontend.

Frontend: Express

Let’s start by creating a package. Create frontend folder, run npm init in it, and after a short survey we’ll get ready package.json file:

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

This is the last time we actually need Node.js on our machine (we could run npm init via Docker too, though). Future generations of developers of our wiki engine will not need it at all!

Do you remember npm run build and npm run start we mentioned in the Dockerfile? It’s time to add corresponding commands to package.json:

--- a/frontend/package.json+++ b/frontend/package.json@@ -4,6 +4,8 @@   "description": "This is an app demo for Habr article.",   "main": "index.js",   "scripts": {+    "build": "echo 'build'",+    "start": "node index.js","test": "echo \"Error: no test specified\" && exit 1"   },   "repository": {

build command doesn’t do anything yet, but will prove itself useful later.

Next we add Express to dependencies and create a simple application:

--- 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.jsconst express = require("express");app = express();app.listen(process.env.APP_FRONTEND_PORT);app.get("*", (req, res) => {res.send("Hello, world!")});

Now we can run frontend with docker-compose up frontend. Moreover, at http://localhost:40002 we should already be able to see familiar “Hello, world!”.

All changes made in this paragraph are in this commit.

Frontend: bundling with webpack and creating basic React application

The time has come to display something more expressive than plain text in our application. In this paragraph we will introduce simple React component and configure the build.

It is quite handy and common to use JSX when developing in React. JSX is a dialect of JavaScript extended with syntactic sugar like

render() {   return <MyButton color="blue">{this.props.caption}</MyButton>;   // OMG, an HTML tag inside JavaScript!   // (actually no)}

However, JavaScript engines don’t understand it out-of-the-box. We hence have to add a build stage to our frontend component. Special JavaScript compilers will turn syntactic sugar into ugly vanilla JavaScript. They’ll also handle imports, do the minification and so on.

Year 2014. apt-cache search java

A trivial React component is very simple to create.

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

This one will display our greeting with a bit more formatting.

Let’s create a file containing the minimal template for HTML pages of our application.

// frontend/src/template.jsexport 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;}

We should also add an entry point to embed React into our pages on browser side.

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

For the build stage we will need:

  • webpack — a state-of-the-art JS bundler (I’m not sure if it is legal to use JS and art in the same sentence though);
  • babel — compiler for stuff like JSX, that also provides polyfills IE users might need.

If frontend container is still running on your machine, it is now enough to run these commands

docker-compose exec frontend npm install --save \     react             \     react-domdocker-compose exec frontend npm install --save-dev \     webpack           \     webpack-cli       \     babel-loader      \     @babel/core       \     @babel/polyfill   \     @babel/preset-env \     @babel/preset-react

to install necessary dependencies. Now we configure webpack (be careful here, webpack configuration tutorial half-life is about three months, making it closest to sulfur-35 isotope, and which is well past the time I wrote the original article in Russian):

// frontend/webpack.config.jsconst path = require("path");// Configuration for browser side.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" }     ]   }};// Configuration for server side. Note two things:// 1. target: "node" - without it even `import path` will not work.// 2. putting compilation result to .. rather than ../dist -//    there's no need to show our server code to users, even compiled!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];

For babel to work we also need to fill frontend/.babelrc file:

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

Finally, let’s make npm run build command useful, as foretold by the prophecy:

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

Now our client code, bundled with polyfills, gets processed by babel</code>, compiled and put together into single minified file. It remains to publish the file and start rendering HTML template for non-static routes:

// frontend/index.js// Now that we've configured build,// we can use cool modern imports here.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);

That’s it! If we now run docker-compose up — build frontend, we’ll see good old “Hello, world!” in a new majestic exterior. If you have installed React Developer Tools (Chrome, Firefox), you’ll be able to see the tree of React components in the developer tools window:

All changes made in this paragraph are in this commit.

Backend: storing data in MongoDB

Before moving forward to breathing life into our application, we have to breathe it into the backend. We were going to store cards written in Markdown in MongoDB; it is time to implement it.

Whilst there are ORMs for MongoDB in Python, I dislike Python-style ORM pattern. Tying those solutions is thus up to you. Instead, we will make a simple data class for the card and a data access object:

# backend/storage/card.pyimport abcfrom typing import Iterableclass 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  # human readable ID     self.name = name     self.markdown = markdown     self.html = htmlclass 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:      passclass CardNotFound(Exception):pass

Next we add pymongo to requirements.txt and create an implementation of CardDAO interface:

# backend/storage/card_impl.pyfrom typing import Iterableimport bsonimport bson.errorsfrom pymongo.collection import Collectionfrom pymongo.database import Databasefrom backend.storage.card import Card, CardDAO, CardNotFoundclass MongoCardDAO(CardDAO):   def __init__(self, mongo_database: Database):     self.mongo_database = mongo_database     # Slugs should obviously be unique.     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 stores documents in BSON format. In this      # method we should convert our card into an object      # which is BSON-serializable.      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:      # On the other hand, we do not want all the other      # codebase to know we store data in MongoDB. Yet we'll      # inevitably end up using card IDs everywhere; so let's      # convert them from ObjectId to strings.      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)

Now we will configure MongoDB access in Flask application settings. For this we must figure out MongoDB host name within docker-compose network. Our MongoDB container is called mongo, and the host will have the same name. MONGO_HOST variable should be hence set to ”mongo”:

--- a/backend/dev_settings.py+++ b/backend/dev_settings.py@@ -0,0 +1,3 @@+MONGO_HOST = "mongo"+MONGO_PORT = 27017  # default MongoDB port+MONGO_DATABASE = "core"

We now have to create MongoCardDAO object and give our Flask app access to it. Our hierarchy of objects is going to be rather simple: settings, MongoDB client, DAO, the app. Yet it would be farseeing to implement a simple dependency injection here. We will soon need the same objects graph in our worker and tools.

# backend/wiring.pyimport osfrom pymongo import MongoClientfrom pymongo.database import Databaseimport backend.dev_settingsfrom backend.storage.card import CardDAOfrom backend.storage.card_impl import MongoCardDAOclass Wiring(object):   def __init__(self, env=None):      if env is None:         env = os.environ.get("APP_ENV", "dev")      self.settings = {         "dev": backend.dev_settings,          # (add other envs settings here!)      }[env]      # As the codebase grows, this code will get more and more      # complex. Eventually you'll have to use a DI framework.      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)

It is finally time to add new route to Flask app and enjoy the results.

# backend/server.pyimport os.pathimport flaskimport flask_corsfrom backend.storage.card import CardNotFoundfrom backend.wiring import Wiringenv = 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")

After restarting server with docker-compose up — build backend we should be able to use new API:

Oh… oh yeah, well, we have to create cards in the database to actually check it out! Let’s create tools folder and within it a script to create a sample card:

# tools/add_test_content.py

from backend.storage.card import Card
from backend.wiring import Wiringwiring = 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 command can now fill MongoDB with content from within backend container.

Aaand it works! Time to start using it from the frontend.

All changes made in this paragraph are in these commits.

Frontend: Redux

We are now up to creating a card page. We will create new route /card/:id_or_slug in which we will retrieve card data via API and render some nice HTML. Client will get the HTML and construct our React application around it. And here comes the complex part. We want two components do the same thing:

  1. render HTML on server side when user accesses one of our cards for the first time;
  2. render HTML on browser side when user navigates between the cards.

It would be nice to save bandwidth and only request new card data from API when navigating and not the entire HTML. We would also like to avoid any code duplication.

Our web app will now have to remember on what card page it is, which means it will now have a nontrivial <em>state</em>. It’s about time we make an architectural decision on how we handle state. For this article I have picked one of the most popular solutions — Redux.

Redux is a JavaScript library implementing a state container. The idea is to have one big global state of our whole web app, with all microstates of all components stored somewhere inside it, and have all components depend on this big state contents and nothing else. What would we do in simple imperative paradigm in response to user clicking a link? We would show loading GIF, then do AJAX request, and, finally, in success callback we would hide the GIF and put received content somewhere in DOM where it belongs. In Redux we would do all the same things, but organize them differently. First, we would <em>dispatch an action</em> modifying the state (e. g. ”START_FETCHING_CARD”). A special function called <em>reducer</em> would generate a new state in response to the action. (Note that it doesn’t change state of particular component (like GIF visibility), but rather produces entirely new global state.) State modification would trigger React components rerendering, “loading GIF” component would notice, that it should now be shown, and the animation will finally start playing. The reducer would make AJAX request, and success handler would dispatch another action (e. g. ”FINISH_FETCHING_CARD”), which would hide animation and update main page content.

While this may sound a bit overcomplicated, it actually gives a lot to the developer. There is no more hidden behavior in our app. We don’t have a mess of callbacks anymore. Instead of that we now have an explicit sequence of state changes. Every change is triggered by a particular action. With developer tools extension installed, we can also navigate that sequence. We can switch back to any previous state to figure out what our web page looked like back then. We can see what particular action led to any state change.

Let’s start with adding dependencies.

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

The first one is, yeah well, Redux. The second one is a library for combining React and Redux. It will deliver the state to React components. The third one is a useful extension to Redux (see its README for details). The fourth one is something we should add for Redux DevTools Extension to work.

Now we will add some boilerplate code.

// frontend/src/redux/reducers.js// A reducer which does nothing.export default function root(state = {}, action) {   return state;}// frontend/src/redux/configureStore.jsimport {createStore, applyMiddleware} from "redux";import thunkMiddleware from "redux-thunk";import {composeWithDevTools} from "redux-devtools-extension";import rootReducer from "./reducers";// A function to create new state storage.// Note dev tools and thunk middleware.export default function configureStore(initialState) {   return createStore(      rootReducer,      initialState,      composeWithDevTools(applyMiddleware(thunkMiddleware)),   );}

Our client.js changes a bit to embody our new state management solution:

// frontend/src/client.jsimport React from 'react'import {render} from 'react-dom'import {Provider} from 'react-redux'import App from './components/app'import configureStore from './redux/configureStore'// Create that cool global state storage...const store = configureStore();render(   // ... and wrap the app into special component which   // will allow it and all nested components access state   <Provider store={store}>      <App/>   </Provider>,   document.querySelector('#app'));

We may now run docker-compose up — build frontend to ensure nothing is broken and check out our brand new (but completely empty) state in Redux DevTools:

All changes made in this paragraph are in this commit.

Frontend: creating card page

Let’s start with browser side rendering. We have to add a call to API and design how card page should look in the DOM.

Add the data we retrieve from API should be placed somewhere in the state (all the visuals should depend solely on the state, remember?). Before we begin, we should design our state structure. This topic is covered very well by a lot of articles, so let’s not dive too deeply and make something simple. Like this:

{   "page": {      "type": "card",     // type of page currently open      // the following properties are only for type=card:      "cardSlug": "...",   // slug of current card      "isFetching": false, // is an AJAX request on its way now?      "cardData": {...},   // card data (if we already have it)      // ...   },   // ...}

Add component for displaying a card, assuming we give it card data as props:

// frontend/src/components/card.jsimport 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>            <!--Adding HTML to React directly is complicated-->            <div dangerouslySetInnerHTML={{__html: html}}/>         </div>       );   }}export default Card;

Now we shall make a component for the whole card page. It will be responsible for requesting card data and passing it down to Card when it arrives.

The nontrivial part here is fetching card data from API in React-Redux way. Good thing you have me here!

First thing we create actions file and add an action to fetch card data if necessary:

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

The fetchCard action to actually fetch data is a bit more complex:

function fetchCard() {
return (dispatch, getState) => {
// First, let app know we are fetching something.
// For example, we might use that to show loading
animation.
dispatch(startFetchingCard());
// Prepare API request.
let url = apiPath() + "/card/" + getState().page.cardSlug;
// Fetch data, parse JSON, send action to update state with
// data. This code lacks error handling, but I'll leave it
// to you.
return fetch(url)
.then(response => response.json())
.then(json => dispatch(finishFetchingCard(json)));
};
// By the way, it's redux-thunk who allows us to return
// functions as actions (normally those are plain objects).
}
function startFetchingCard() {
return {
type: START_FETCHING_CARD
};
}
function finishFetchingCard(json) {
return {
type: FINISH_FETCHING_CARD,
cardData: json
};
}
function apiPath() { // When we will do SSR, API path will depend on the environment
-
// within docker-compose local network backend hostname is
// `backend`, not `localhost:40001`.
return "http://localhost:40001/api/v1";
}

Now that we have two actions supposed to actually do something, we have to support them in the reducer:

// frontend/src/redux/reducers.jsimport {
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;
}

(Note the fancy syntax for copying object with overwriting particular fields!)

All the networking logic is now somewhere in Redux actions, so the CardPage component is simple:

// frontend/src/components/cardPage.jsimport React, {Component} from 'react';import {connect} from 'react-redux'
import {fetchCardIfNeeded} from '../redux/actions'
import Card from './card'class CardPage extends Component { componentWillMount() {
// This event is called when React is going to render the
// component. By the moment of rendering we should already
// know whether we should render the card or show "loading"
// animation or whatever, thus we dispatch the fetchin
// action here. Conveniently, this event will also be
called
// by renderToString function which we will use for the
SSR.
this.props.dispatch(fetchCardIfNeeded())
}
render() {
const {isFetching, cardData} = this.props;
return (
<div>
{isFetching && <h2>Loading...</h2>}
{cardData && <Card {...cardData}/>}
</div>
);
}
}
// This component requires access to data encapsulated within
// the state. That's why we imported react-redux. The call below
// will make our component take necessary data from state
implicitly
// and will also supply it with the `dispatch` method.
function mapStateToProps(state) {
const {page} = state;
return page;
}
export default connect(mapStateToProps)(CardPage);

We should now add simple switch on page.type into App:

// frontend/src/components/app.jsimport 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);

And the last thing — we need to initalize page.type and page.cardSlug depending on the page URL.

We could use some of routing libraries for React, but they have difficulties with combining browser-side and server-side rendering, and this article is already too long to cover that. Let’s go on with something quick-n-dirty. And, let me ask you, is there a more dirty thing than a regular expression?

// frontend/src/client.jsimport 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')
);

Finally, we can rebuild frontend with docker-compose up — build frontend to enjoy our helloworld card full HD…

Oh wait… where’s the content? Oh, we forgot to parse Markdown!

All changes made in this paragraph are in this commit.

Worker: RQ

Generating HTML from Markdown with no size restriction is a typical “heavy” task. Imagine it running every time a user makes an edit in a big card. Latency of saving an edit would be catastrophic, and our backend machines would spend a lot of CPU time doing stuff which is not that urgent. For such tasks it is common to create a few dedicated worker machines and a task queue, so that they could be processed at the pace hardware allows.

There are many open source implementations of a distributed task queue. For demonstration purposes we will use the simplest one, RQ (Redis Queue). It treats calling any Python function as a task. Task arguments are being passed to worker in pickle format. RQ also does some minor stuff like spawning worker subprocesses for us.

We’ll start with adding Redis stuff to dependencies, settings and the objects graph.

--- a/requirements.txt
+++ b/requirements.txt
@@ -3,3 +3,5 @@ flask-cors
gevent
gunicorn
pymongo
+redi
+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)

Whole worker code is as simple as this:

# worker/__main__.pyimport argparse
import uuid
import rqimport backend.wiringparser = argparse.ArgumentParser(description="Run worker.")# It is convenient to have a flag making worker turn off after the
# queue is exhausted instead of endlessly waiting for more tasks.
parser.add_argument(
"--burst",
action="store_const",
const=True,
default=False,
help="enable burst mode")
args = parser.parse_args()
wiring = backend.wiring.Wiring()with rq.Connection(wiring.redis):
w = rq.Worker(
queues=[wiring.settings.TASK_QUEUE_NAME],
# If we are going to run multiple workers, they'll need
# unique names.
name=uuid.uuid4().hex)
w.work(burst=args.burst)

For Markdown parsing we will use mistune library. Let’s write a little helper function, processing a card by given card ID:

# backend/tasks/parse.pyimport mistunefrom backend.storage.card import CardDAOdef 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)

Obviously, we need CardDAO to get and update cards. But we can’t really make it a task argument for it is not picklable. Any external connection normally isn’t. Right thing to do would be to pass all necessary DAOs and other objects from the Wiring created on worker side. Well, let’s do this.

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

We declared custom job class which will pass wiring as a keyword argument to all tasks. (Note that it creates new wiring every time because some external clients can’t be created before fork which RQ internally does.) But it would be bad design to make all tasks depend on the whole wiring object. Those object graphs tend to get huge over time, and code depending on it would be so messy to analyze and refactor. Let’s make a simple decorator retrieving necessary objects from wiring:

# backend/tasks/task.pyimport functools
from typing import Callable
from backend.wiring import Wiringdef task(func: Callable):
# Function arguments names:
varnames = func.__code__.co_varnames
@functools.wraps(func)
def result(*args, **kwargs):
# Pop wiring (we don't want anyone to depend on it).
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]
# We could also implement retrieving object from wirin
# by type rather than name. Up to you.
return func(*args, **kwargs)
return result

It only remains to wrap the parsing task with the decorator:

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

Let’s restart worker and see what happens.

$ 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

Yeah well… nothing! Because there are no tasks yet!

Let’s modify our tool to make it schedule parsing task for the test card.

# tools/add_test_content.pyfrom 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:
# Only create the card if it doesn't exist yet.
card = wiring.card_dao.create(Card(
slug="helloworld",
name="Hello, world!",
markdown="""
This is a hello-world page.
"""))
# card_dao.get_or_create would be more convenient,
# but the article is already way too long!
# But reparsing a card if it has already been parsed means no harm.
wiring.task_queue.enqueue_call(
parse_card_markup, kwargs={"card_id": card.id})

Now running docker-compose exec worker python -m tools.add_test_content in another terminal window should finally make worker do something useful:

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

Now generated HTML is saved in MongoDB, and the changes reach our client after page refresh.

All changes made in this paragraph are in this commit.

Frontend: browser navigation

Before we move to SSR, we need to add an actual possibility to navigate the site. Let’s update the tool to create TWO cards (yes, we are moving towards Big Data now) referencing each other and then implement seamless navigation.

<spoiler>

# tools/add_test_content.pydef 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, habrareader. You've probably got here from the awkward
["Hello, world" card](helloworld).
Well, **good news**! Finally you are looking at a **really cool
card**!
"""
))

</spoiler>

We can now navigate the links and enjoy watching our app reloading completely every time. Thou shalt reload no more!

First thing we need to create custom handler for link clicks. But since we inject HTML coming from the server directly into React application, this is a little tricky.

// frontend/src/components/card.jsclass Card extends Component {   componentDidMount() {
document.title = this.props.name
}
navigate(event) {
// This is the click handler for the whole card content.
// We should navigate only if the clicked element is a
link.
if (event.target.tagName === 'A'
&& event.target.hostname === window.location.hostname)
{
// Cancel default behavior of the browser.
event.preventDefault();
// Dispatch our custom Redux action for navigation.
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>
);
}
}

We’ve implemented loading card data as part of CardPage component initialization, so the navigate action can be simple:

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

A simple reducer for the action:

// frontend/src/redux/reducers.jsimport {
START_FETCHING_CARD,
FINISH_FETCHING_CARD,
NAVIGATE
} from "./actions";
function navigate(state, path) {
// Again, we could use a router library here.
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;
}

Since card data may now change, we have to add componentDidUpdate method to CardPage analogous to componentWillMount. It will reload card data after navigation.

One more docker-compose up — build frontend and the navigation is working!

There is a problem though. URL of the page won’t change after navigation! This is quite annoying for a wiki-like website. Our user will be unable to copy actual link to the content from the address bar! Back and forward buttons are broken too. We’ll have to do some black magic with history object to fix this.

The simplest way to go is to add history.pushState call to navigate action.

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

Now the URL updates all right. But back button is still broken!

To make it work, we need to handle popstate event of window object. We’ll have to do our custom navigation there too, but with a slight modification. We should not do the pushState when we are navigating back. There are lots of inconveniences here (and I have struggled much), so let’s go straight to the code.

// frontend/src/components/app.jsclass App extends Component {   componentDidMount() {
// The app has just loaded - mark current history state
// as "our state".
history.replaceState({
pathname: location.pathname,
href: location.href
}, "");
// Add handler for back/forward navigation.
window.addEventListener("popstate", event =>
this.navigate(event));
}
navigate(event) {
// If the state user navigates to is not "our state", do
the
// default stuff. The user has to be able to navigate back
// from our website to the website he or she has come from.
if (event.state && event.state.pathname) {
event.preventDefault();
event.stopPropagation();
// Dispatch navigation with "don't pushState" flag.
this.props.dispatch(navigate(event.state, true));
}
}
render() {
// ...
}
}
// frontend/src/redux/actions.jsexport function navigate(link, dontPushState) {
if (!dontPushState) {
history.pushState({
pathname: link.pathname,
href: link.href
}, "", link.href);
}
return {
type: NAVIGATE,
path: link.pathname
}
}

Now the history works. Not that scary, huh.

But I have one finishing stroke for our navigation. Now that we navigate action in our possession, we don’t need the code evaluating initial state anymore. We can just call navigate at app start!

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

Code duplication: eliminated.

All changes made in this paragraph are in this commit.

Frontend: server-side rendering

The time has come at last for the main feature — SEO friendliness. For the search engines to be able to index our content, we need to learn to render React components on server side. The content should also become interactive again if a living user loads the page. In fact, this feature is so complex that some of the design decisions in our little project were made specifically for it!

However, if we look at the implementation from a bird’s eye view, it is quite simple. We take our root React component, render it into HTML, put rendered HTML into our HTML template, and return the result as server response. That’s it. Necessity to make content interactive again complicates things a bit. We’ll have to dump final Redux state into this static document somewhere, for our client script to retrieve it on start. This way our app may start from the point where server stopped. When we have the state, we will just call hydrate on static DOM to make React components go live again.

Let’s start with HTML rendering.

// frontend/src/server.jsimport "@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) {
// Create Redux store, just like the client.
const store = configureStore(initialState);
store.dispatch(navigate(url));

let app = (
<Provider store={store}>
<App/>
</Provider>
);
// Luckily, react-dom library has a built-in function for
// rendering components to an HTML string!
let content = renderToString(app);
let preloadedState = store.getState();
return {content, preloadedState};
};

Next we add state dump to the page template.

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

The frontend server becomes the slightest bit more complicated.

// frontend/index.jsapp.get("*", (req, res) => {
const initialState = {
page: {
type: "home"
}
};
const {content, preloadedState} = render(initialState,
{pathname: req.url});
res.send(template("Habr demo app", preloadedState, content));
});

But the client becomes simpler!

// frontend/src/client.jsimport 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";
// No need to create initial state here anymore!
const store = configureStore(window.__STATE__);
// We replace `render` with `hydrate`. `hydrate` will take existing
// DOM tree, validate it and add all necessary event handlers to pass
// control back to the React application.
hydrate(
<Provider store={store}>
<App/>
</Provider>,
document.querySelector('#app')
);

Our server won’t run right now because of some browser-server compatibility issues like “history is not defined”. Let’s add a simple env detection to avoid it.

// frontend/src/utility.jsexport function isServerSide() {
// I'm not sure why, but `process` is not undefined in
// compiled browser code ¯\_(ツ)_/¯
return process.env.APP_ENV !== undefined;
}

There is a bunch of minor changes to do, which I omit here (but you can check them out in the corresponding commit). After them our app will run successfully on both server and browser side.

But there’s a problem.

LOADING? What the hell? Do you mean all Google will see on my beautiful website is LOADING?!

Well, our beautiful asynchronous architecture hit us in the back after all. We now need a way to tell the server that some requests to API are still going. It should only render final HTML when those requests are completely processed. We could simply check our isFetching flag, of course, but there will be more and more different requests as the codebase will grow. We need something more generic.

There are many possible solutions. One of the approaches is to declare which paths to fetch synchronously, and fetch them before the actual rendering. This solution has its pros: it’s simple, it’s explicit and it works.

But let me offer you a different path. (There must be some original content in the article after all!) Let’s save all the Promise objects we are currently waiting for somewhere in the state. This way we will always be able to take current state and check if all the data is ready. When a promise is fulfilled, we will remove it from the state, maintaining a list of active promises only.

First, we add two simple actions.

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

We will dispatch the first action when we start fetching and the second one in the end of .then() handler.

Next, we support the new actions in the reducer.

// frontend/src/redux/reducers.jsexport 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)
};
...

Now we should update fetchCard implementation.

// frontend/src/redux/actions.jsfunction 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));
// "I'm done, you may render stuff"
dispatch(removePromise(promise));
});
// "I've started fetching, wait for it"
return dispatch(addPromise(promise));
};
}

Finally, we add an empty array of promises to initialState and force the server to wait until the array is empty after rendering.

// frontend/src/server.jsfunction hasPromises(state) {
return state.promises.length > 0
}
// This is async now!
export default async function render(initialState, url) {
const store = configureStore(initialState);
store.dispatch(navigate(url));
let app = (
<Provider store={store}>
<App/>
</Provider>
);
// renderToString call starts components lifecycle, making
// CardPage run fetching and so on.
renderToString(app);
// Now we wait.
let preloadedState = store.getState();
while (hasPromises(preloadedState)) {
await preloadedState.promises[0];
preloadedState = store.getState()
}
// The final renderToString. Now we want the HTML!
let content = renderToString(app);
return {content, preloadedState};
};

It remains to update requests handler a bit.

// frontend/index.jsapp.get("*", (req, res) => {
const initialState = {
page: {
type: "home"
},
promises: []
};
render(initialState, {pathname: req.url}).then(result => {
const {content, preloadedState} = result;
const response = template("Habr demo app", preloadedState,
content);
res.send(response);
}, (reason) =>
console.log(reason);
res.status(500).send("Server side rendering failed!");
});
});

Et voilà!

All changes made in this paragraph are in these commits.

Conclusion

As you can see, creating a state-of-the-art web app is not that easy. But it is also not that hard! We’ve done quite a lot of things for such a simple application. Yet every solution we made gave us long distance profit.

You can find the final app in the Github repo. You should be able to run it locally with only Docker installed.

In future articles I will improve the repo further and cover other features:

  • logging, monitoring, load testing.
  • Unit & integration testing, CI, CD.
  • advanced features like authentication and full text search.
  • setting up production environment.

Thanks for reading, hope you enjoyed it!

--

--

Joom
Joom
Editor for

An International Group of E-commerce Companies