Chupurnov Valeriy
Chupurnov Valeriy
Front End Engineer

Как работает данный блог

Этот сайт зародился в далеком 2009. Тогда я запилил его на php. Задача была простой: легко и просто доносить до общественности свои мысли. Я сделал добавл на главной div, сделал его редактируемым (аля WYSIWYG) и кнопки сохранить/опубликовать. Так на сайте появились первые статьи. Довольно быстро я понял, что мой визивиг очень убогий и не постоянно глючит. + На сайте регулярно нужны были доработки. Самопис все таки. Я е выдержал и перенес все на Joomla. Про глюки и обновления можно было больше не думать, это прекрасная и надежная система. Это самолет а не система. Порталы, магазины, ракеты работают на Joomla. Но не блоги. Они есть, но это просто неудобно. Чтобы запилить статью, нужно подождать загрузки админки, авторизоваться, создать статью, заполнить кучу полей, и только затем писать статью. А уж посмотреть результат так это вообще квест, кеш не почистил - ничего не увидел! Почистил? Не факт, что если видишь ты, то видят и все остальные (Мы же авторизованы под админом). И тут были подходы это автоматизировать, и тут я делал модуль на главную с визивигом и поэтессами, но общие тормоза CMS Joomla не делали этот процесс комфортным.

Я даже свой визивиг написал https://xdsoft.net/jodit. То тема для отдельной статьи.

Короче я долго страдал. И все это время мечтал о своем старом подходе: зашел на сайт, на главной, ввел текст (желательно на MarkDown), нажал опубликовать - ушел.

Вот я и запилил этот раздел за два вечера. Как я это сделал, расскажу под катом: тут у нас React(create-react-app + typescript), Node JS как сервер, Github actions как CI + CD, и Docker Compose на сервере.

Подготовка VPS

Итак первое, что я сделал это купил новый VPS. Самый дешевый за 300 рублей. На нем поднял Debian и Docker + Docker-Compose. Это все, что мне от него нужно.

На основном домене на в nginx прописал

location /blog/ {
    proxy_pass http://ip:8892;
}

Это хост от нового VPS и порт веб сервера на Express Node JS.

Frontend

Создал новый проект через create-react-app и запили интерфейс. Это не так интересно. Тут все схематично так:

const onSave = throttle((e) => {
   fetch('/blog/api/save/', {content: e.target.value});
}, 1000);

function Editor() {
    return <textarea onInput={onSave}/>
}

Тротлер можно взять этот.

Server

Ну и сервер. Тут у нас express и sequelize для работы с mysql.

const express = require('express');
const app = express();
const cookieParser = require('cookie-parser');
app.use(cookieParser());
app.use('/blog/api/', express.json());

const posts = require('./posts'); // модуль для работы с постами 
app.use(`/blog/api/posts`, posts);
app.use(express.static(path.join(__dirname, '../build')));

app.listen(8892, () => {
    const message = `App listening on port 8892!`;
    console.log(message);
});

Сам модуль, который обрабатывает посты организуем так:

posts
    controllers
        posts.controller.js
index.js

Где в posts.controller.js

exports.get = (req, res) => {};
exports.delete = (req, res) => {};
exports.add = (req, res) => {};
exports.update = (req, res) => {};
exports.list = (req, res) => {};

А в index.js

const { Router } = require('express');
const Controller = require('./controllers/posts.controller');

const router = Router();

router.post('/', Controller.add);
router.get('/', Controller.list);
router.get('/:id', Controller.get);
router.post('/:id', Controller.update);
router.delete('/:id', Controller.delete);

module.exports = router;

Тут важная ремарка: пользователь для работы с постами должен быть авторизован. У меня есть еще модуль user. Но про него не столь интересно.

Опишем тут лишь добавление поста:

const { Post } = require('../../../models'); // модели в sequelize
const { unixtime } = require('../../helpers');

exports.add = (req, res) => {
    let { content } = req.body;
    
    await Post.sync();
    
    const post = await Post.create({
        updated_at: unixtime(),
        created_at: unixtime(),
        content
    });
    res.send({success: true, id: post.id})
}

Вот эта прекрасная строчка await Post.sync(); создаст таблицу со всеми полями из модели. Красота!

Модель Post примерно такая

const { Sequelize } = require('sequelize');

const sequelize = new Sequelize({
    database: 'db_blog',
    username: 'root',
    host: 'host.docker.internal', // Об этом ниже
    port: 1111,
    password: 'пароль будет ниже',
    dialect: 'mysql',
    omitNull: false,
    logging: false
});

const { Model, DataTypes } = require('sequelize');

class Post extends Model {}

Post.init(
    {
        id: {
            primaryKey: true,
            type: DataTypes.UUID,
            defaultValue: Sequelize.UUIDV4
        },

        created_at: { type: DataTypes.INTEGER.UNSIGNED },
        updated_at: { type: DataTypes.INTEGER.UNSIGNED },

        // userId: {
        //    type: DataTypes.UUID,
        //    references: {
        //        model: User,
        //        key: 'id'
        //    }
        // },

        content: { type: DataTypes.TEXT, allowNull: true },

        isPublic: { type: DataTypes.TINYINT, defaultValue: NO }
    },
    {
        sequelize,
        modelName: 'post',
        tableName: 'posts',
        createdAt: false,
        updatedAt: false
    }
);

// Post.belongsTo(User);

module.exports.Post = Post;

Я не храню HTML, я храню markdown.

Упаковываем все это добро в Docker.

FROM node:14-alpine

# Create app directory
WORKDIR /usr/src/app

COPY ./package.json ./
COPY ./package-lock.json ./
# Если у вас TS то нужен еще его конфиг
# COPY ./tsconfig.json ./

RUN npm ci

COPY ./.env ./
COPY ./public ./public
COPY ./src ./src
RUN npm run build

COPY ./server ./server

# Это чтобы оставить зависимости только сервера, нам не нужен реакт, мы его уже сбилдили
RUN rm -rf ./node_modules && export NODE_ENV=production && npm ci


WORKDIR /usr/src/app
COPY --from=0 /usr/src/app .

CMD [ "node", "server/index.js" ]

И создаем файлик .github/workflows/main.yml. Чтобы Github запускал экшоны на каждый пуш.

Я использую два готовых экшена: docker/setup-qemu-action и appleboy/ssh-action, которые билдят докер образ, пушат его на hub.docker.com, затем заходят по SSH на мой сервер и перезапускают docker-compose. (Я фронтэндер, не бейте =))

name: Build and Push Image

on:
  push:
    branches: [ master ]

jobs:
  main:
    runs-on: ubuntu-latest
    steps:
      -
        name: Set up QEMU
        uses: docker/setup-qemu-action@v1
      -
        name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v1
      -
        name: Login to DockerHub
        uses: docker/login-action@v1
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}
      -
        name: Build and push
        id: docker_build
        uses: docker/build-push-action@v2
        with:
          push: true
          tags: my-dockerhub-namespace/xdan-blog:latest
          build-args: |
            SSH_PRIVATE_KEY=${{ secrets.SSH_PRIVATE_KEY }}
      -
        name: Image digest
        run: echo ${{ steps.docker_build.outputs.digest }}

      - name: Executing remote ssh commands using password
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.HOST }}
          username: ${{ secrets.USERNAME }}
          password: ${{ secrets.PASSWORD }}
          port: ${{ secrets.PORT }}
          script: cd /path/xdan-blog && docker-compose pull && docker-compose up -d

Не забываем создать секретики в настройка гитхаба.

Ну и остался файлик docker-compose.yml на сервере:

version: '3.7'
services:
    mysql:
        image: mysql
        container_name: mysql-xdan-blog
        ports:
            - '1111:3306'

        expose:
            - 1111

        command: --character-set-server=utf8
            --collation-server=utf8_general_ci
            --default-authentication-plugin=mysql_native_password
            --sql-mode=NO_AUTO_VALUE_ON_ZERO

        restart: always
        environment:
            MYSQL_ROOT_PASSWORD: 'Забейте свой пассворд'
            MYSQL_DATABASE:   db_blog

        volumes:
            - './data/dump.sql:/docker-entrypoint-initdb.d/dump.sql'
            - './data/data:/var/lib/mysql'

    xdan-blog:
        image:  my-dockerhub-namespace/xdan-blog
        ports:
            - '8892:8892'

        volumes:
            - ./data/uploads/images:/usr/src/app/server/uploads/images

        extra_hosts:
            - 'host.docker.internal:host-gateway'

Зачем последние 2 строчки.

Запускаем наше чудо:

docker-compose up -d

Вот и все. От идеи, до реализации - два вечера. Теперь у меня есть блог, в котором я написал статью за 20 минут и даже не вспотел.

Потом накрутил вставку картинок и SSR.

Добра вам!