0. Введение

Всем привет! Недавно решил попробовать, что из себя представляет Flutter Web, подумал, может попробовать сделать бота для Telegram, и заодно настроить простейший CI/CD для отдельного фронтенд- и бэкенд-проектов. Однако в интернете я не нашёл простой и исчерпывающей инструкции или процесса.

Поэтому задача этой статьи — решить эту проблему! Сделать автоматическую сборку, тестирование и деплой на сервер, и всё это — без необходимости городить SSH-ключи, Github-токены и подключать сторонние решения. Всё на минималках, без лишней бюрократии.

Понятное дело, для настоящего production-решения нужен Kuber или хотя бы Docker Swarm, но тут речь про быструю демонстрацию, может подойти как тестовый стенд при разработке, не больше. Тем, кому важно увидеть результат сразу и понять, как всё работает в живом виде, этот процесс подойдёт идеально.

В сети есть материалы на эту тему, но большинство из них громоздкие, с лишними шагами. Мой вариант проще, хотя при желании его можно адаптировать под разные стеки, добавив кастомные настройки и автоматизировав процесс через bash-скрипт.

Сегодня пройдёмся по всем этапам: от создания репозиториев до настройки сервера и автоматизации деплоя.

Пикча
Пикча

1. Создание и настройка репозиториев

Зачем вообще разделять проекты? Ведь можно работать всем вместе в одном репозитории, правда? Возможно, этот вопрос возникает у многих, и ответы на него можно найти в коротких видеороликах или статьях — в том числе и на Хабре.

Отвечу проще: "Можно, конечно, если ваш проект — это монолитный бэкенд, а фронт просто является частью бэкенда и собирается на ходу во время запроса страницы."
В командной разработке такой подход встречается редко, и постоянные обновления в общей ветке могут создать немало неудобств. Поэтому проекты чаще всего делят, а сложные репозитории на несколько маленьких сервисов. Это позволяет команде работать эффективнее и не мешать друг другу. Для развертывания такой структуры на сервере важно обеспечить, чтобы все репозитории были готовы к сборке и деплою, чтобы они общались между собой и фронт корректно взаимодействовал с бэкендом.

Приступим

Выбор стека. Используемый стек выбран как знакомый, можно выбрать и любой другой, например: Node.js, React, Python, Java и так далее.

Инициализация проектов на ASP.NET и Flutter Web.

Выбираем директорию где все проекты будут располагаться, создаём проекты через PowerShell (Windows)

$ dotnet new webapi -n backend // бекенд
$ flutter create frontend // фронт

Настройка веток и первоначальные коммиты.

Заходим в GitHub и создаём три пустых репозитория (без README и .gitignore):

  • backend

  • frontend

  • docker

Примечание: Если установлен Github CLI можно сделать это с помощью команды gh repo create *имя репозитория*
Для чего мы создали репозиторий docker? чуть позже подробнее объясню, но если коротко он будет содержать конфигурации Nginx и docker-compose файл.

Пушим локально созданные репозитории в Github

Для каждого из созданных проектов выполняем команды, чтобы загрузить код в репозитории на GitHub:

$ cd *Имя папки проекта*
$ git init -b develop
$ git add .
$ git commit -m "First commit"
$ git remote add origin https://github.com/*имя пользователя*/*Имя репозитория*.git
$ git branch -M develop
$ git push -u origin develop
$ cd ..

В репозитории Бекенда создаём привычную для .Net конфигурацию dockerfile:

Backend Dockerfile
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY . .
RUN dotnet restore
RUN dotnet build -c Release -o /app/build

FROM build AS publish
RUN dotnet publish -c Release -o /app/publish

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "backend.dll"]

В репозитории Форонтенда создаём dockerfile для Flutter проекта, пример которого можно найти в свободном доступе.

Frontend Dockerfile
FROM debian:latest AS build

RUN apt-get update && \
    apt-get install -y libxi6 libgtk-3-0 libxrender1 libxtst6 libxslt1.1 curl git wget unzip libgconf-2-4 gdb libstdc++6 libglu1-mesa fonts-droid-fallback lib32stdc++6 python3 && \
    apt-get clean

RUN git clone https://github.com/flutter/flutter.git /usr/local/flutter

#Set Flutter path
ENV PATH="/usr/local/flutter/bin:/usr/local/flutter/bin/cache/dart-sdk/bin:${PATH}"

RUN flutter doctor -v && \
    flutter channel master && \
    flutter upgrade && \
    flutter config --enable-web

#Create app directory
RUN mkdir /app/

#Copy application files
COPY . /app/

#Set the working directory inside the container
WORKDIR /app/

#Build the Flutter web application
RUN flutter build web --release

#Stage 2: Nginx server
FROM nginx:1.27.2-alpine

#Copy built web application from build stage
COPY --from=build /app/build/web /usr/share/nginx/html

#Copy custom Nginx configuration
COPY nginx.conf /etc/nginx/conf.d/nginx.conf

#Expose port 80
EXPOSE 80

#Start Nginx
CMD ["nginx", "-g", "daemon off;"]

Немного разберём dockerfile для Flutter Web.

Для большинства веб-проектов dockerfile похожий, делится он на три этапа:

Этап 1. Получаем все зависимости и собираем сборку

RUN apt-get update && \
    apt-get install -y libxi6 libgtk-3-0 libxrender1 libxtst6 libxslt1.1 curl git wget unzip libgconf-2-4 gdb libstdc++6 libglu1-mesa fonts-droid-fallback lib32stdc++6 python3 && \
    apt-get clean

RUN git clone https://github.com/flutter/flutter.git /usr/local/flutter

##Set Flutter path
ENV PATH="/usr/local/flutter/bin:/usr/local/flutter/bin/cache/dart-sdk/bin:${PATH}"

RUN flutter doctor -v && \
    flutter channel master && \
    flutter upgrade && \
    flutter config --enable-web

# Create app directory
RUN mkdir /app/

# Copy application files
COPY . /app/

# Set the working directory inside the container
WORKDIR /app/

# Build the Flutter web application
RUN flutter build web --release

Этап 2. Получаем образ nginx (я использовал 1.27.2-alpine) и перемещаем файлы полученные в результате сборки в папку /usr/share/nginx/html.

FROM nginx:1.27.2-alpine
#Copy built web application from build stage
COPY --from=build /app/build/web /usr/share/nginx/html

#Expose port 80
EXPOSE 80

# Start Nginx
CMD ["nginx", "-g", "daemon off;"]
Этап 3. (Необязательно) Копируем файл конфигурации nginx в контейнер

Почему необязательно указывать файл nginx.conf?
У образа nginx уже имеется default.conf, который содержит такую же конфигурацию.

COPY nginx.conf /etc/nginx/conf.d/nginx.conf

Конфигурация nginx для контейнера Фронтенда следующая:

Frontend nginx.conf

server {
	listen 80;

	location / {
		root /usr/share/nginx/html;
		index index.html;
		try_files $uri $uri/ /index.html=404;
	}
}

Кладём его в папку проекта frontend.

Docker-compose для текущего стека будем использовать следующий:

docker-compose файл для трёх репозиториев
version: '3.8'
networks:
  shared-network:
services:
  nginx:
    image: nginx:1.27.2-alpine
    container_name: nginx
    volumes:
     - ./nginx.conf:/etc/nginx/nginx.conf
	 - ./logs:/var/log/nginx
    ports:
      - "80:80"
      - "443:443"
    depends_on:
      - backend
      - frontend
    networks:
      - shared-network

  backend:
    build: .././backend
    container_name: backend
    ports:
      - "5000:8080"
      - "5001:8081"
    networks:
      - shared-network

  frontend:
    build: .././frontend
    container_name: frontend
    ports:
      - "5002:80"
      - "5003:443"
    networks:
      - shared-network

Конфигурация для контейнера nginx использовал следующую:

Конфигурация контейнера Nginx
user root;
worker_processes auto;

events {
}

http {
    server {
        listen 80;
        server_name localhost;

        location / {
            proxy_pass http://frontend:80/;
        }

        location /api/ {
            proxy_pass http://backend:8080/;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }
    }
}

С настроенным `reverse-proxy` на порт контейнера бекенда.

Что даёт нам Reverse proxy? - он позволяет перенаправлять запрос на сервер расположенный внутри сети, без добавления поддомена.

2. Подготовка сервера на Ubuntu

Выбор сервера. Рекламировать тут услуги от Selectel я не буду, можно использовать любой образ системы, для демонстрации я буду использовать образ Ubuntu 22.04 на локальной машине через Docker Desktop. На арендованном сервере процесс ровно такой же.

(Необязательно) Запускаем Ubuntu 22.04 на локальной машине в контейнере docker (Dnd)

Если хотите сначала пройти весь процесс на локальной машине, перед размещением на публичный сервер, можете повторить настройку как у меня.

Следующие команды объяснять не потребуется, просто настраиваем контейнер:

  1. Выполняем в консоли PowerShell

$ docker run -d --name demo_for_habr --privileged -p 22:22 -p 80:80 ubuntu:22.04 tail -f /dev/null
  1. Подключаемся к контейнеру

$ docker exec -it demo_for_habr bash
  1. Устанавливаем и запускаем openssh

$ apt update
$ apt install -y openssh-server
$ service ssh start
  1. Задаём пароль для рут пользователя

passwd root
  1. Устанавливаем curl чуть позже он нам пригодится

apt install -y curl
  1. Устанавливаем текстовый редактор nano и отредактируем конфигурацию ssh

apt install -y nano
nano /etc/ssh/sshd_config

Раскомментируем строчки, чтобы разрешить вход по паролю

#PasswordAuthentication yes
PermitRootLogin yes
Курсор перемещаем с помощью стрелок. После внесения изменений чтобы сохранить и выйти на Windows жмём ctrl+x, потом y и enter
  1. Перезапускаем службу SSH

service ssh restart

Теперь у нас появилась возможность подключения к контейнеру по SSH например через Putty или через Git Bash командой ssh root@localhost

(Необязательно) Настройка SSH-ключей для безопасного подключения к серверу

Перейдём в папку репозитория docker и сгенерируем ssh ключи для подключения к нашему серверу:

В Powershell выполним в одну строчку:

$ mkdir .ssh; cd .ssh; ssh-keygen -b 4096 -t rsa -f ./id_rsa -q -N '""';

Мы сформировали ключи на локальной машине, теперь необходимо отправить на сервер публичный ключ

Для этого есть два пути:

Если установлен Git Bash выполняем команду с последующим вводом пароля от root пользователя:

ssh-copy-id -i id_rsa root@ваш айпи сервера

Если используете Putty то выполняем команду на сервере

echo ssh-rsa "public key from id_rsa.pub" >> ~/.ssh/authorized_keys

Благодаря этому мы можем подключаться к удалённому серверу безопасно и без необходимости указывать пароль, через команду:

ssh -i id_rsa root@localhost
# а если вы используете стандартное расположение ключей, то можете опустить опцию `-i`

Установка Docker и настройка автозапуска сервисов на старте системы

Итак, подключаемся к серверу от пользователя root и ставим Docker. Кстати, настоятельно советую идти по официальной инструкции — инструкции быстро меняются, и так вы будете уверены, что всё актуально.

Шаги для Ubuntu 22.04+:

  1. Добавляем Docker-репозиторий и ставим сам Docker. Официальная инструкция для Ubuntu есть здесь, просто копируем команды оттуда и запускаем по порядку.

  2. Настройка автозапуска Docker при старте системы. Это тоже не менее важно, чтобы при перезагрузке или сбое сервисы поднимались сами. и Тут

Теперь Docker будет стартовать автоматически при загрузке системы.

(Необязательно) Если запускаете docker in docker (Dnd) то выполните ещё команду для запуска демона внутри контейнера

$ dockerd &

Проверка установки Docker.

После всех шагов не забудьте проверить, что всё встало, как надо:

docker --version

Docker работает? Отлично.

3. Автоматизация сборки и деплоя на платформе Github

Вообще, CI/CD пайплайны давно стали стандартом, особенно на платформах типа GitLab, Azure DevOps, ну и GitHub, конечно.

В этих системах пайплайны называются по-разному:
GitLab — Gitlab CI/CD
GitHub — GitHub Actions
Azure DevOps предлагает свои Pipelines.

В этой статье я покажу, как настроить CI/CD на GitHub. Почему выбрал именно его? Всё просто: GitHub — одна из самых популярных платформ для репозиториев, плюс он широко используется в разработке и часто оказывается первым выбором при работе с кодом.

При каком поведении мы хотим настроить автоматическую сборку и деплой прямо из GitHub? Может нажатию кнопки или, что ещё удобнее, автоматически, когда кто-то делает пулл-реквест в нужную ветку (например, в release, pre-release или staging).

Примечание: Если используете другую платформу, подход остаётся тот же: везде есть воркер, который реагирует на события (пуши, пулл-реквесты), запускает сборку и деплой. Отличия — только в синтаксисе.

Как устроена настройка GitHub Actions для деплоя?

Чтобы автоматизировать сборку и деплой, прописываем инструкцию для пайплайна в формате .yml для каждого репозитория. Наш пайплайн будет делиться на три этапа:

  1. Заголовки пайплайна — указываем триггеры (что будет запускать пайплайн), рабочую платформу и название.

  2. Сборка и тесты — скачиваем репозиторий, выполняем платформоспецифичную сборку и тестирование.

  3. Подготовка к деплою — сохраняем результаты в отдельной папке для последующего запуска контейнеров.

Примечание: На этапе 3 можно продумать и альтернативные сценарии. Например, запускать bash-скрипт для действий на сервере, без редактирования основного пайплайна.

Воркеры от GitHub

По умолчанию для выполнения пайплайна используется специальный воркер размещённых на серверах GitHub, и спецификации у них неплохие:

  • Спеки для приватных репозиториев можно посмотреть здесь

  • Для публичных репозиториев — здесь

Спеки контейнера для приватного репозитория
Спеки контейнера для приватного репозитория

Пример пайплайнов для трёх репозиториев

Ниже я приведу примеры пайплайнов для трёх репозиториев, которые мы создали выше, чтобы наглядно показать, как настроить всё это в действии.
В папке репозитория фронтенда создаём файл build.yml по пути .github\workflows

`build.yml` для Фронтенда
# этап 1.
name: build & test

on:
  # Указываем ветки которые будут триггерить пайплайн
  pull_request:
    branches: [ "release" ]
    types: [closed]
  # Указываем триггер - опубликованный релиз
  release:
    types: [published]
  workflow_call:
  # позволяет запускать пайплайн вручную через вкладку Actions
  workflow_dispatch:
# этап 2.
jobs:
  build:
	# Проверяем, что job запустился или при мерже пулл-реквеста, или на событии релиза, или вручную 
    if: ${{ github.event_name == 'release' || github.event_name == 'workflow_dispatch' || github.event.pull_request.merged == true }}
    runs-on: self-hosted
    steps:
        - name: Clone repository
          uses: actions/checkout@v4
          with:
            ref: release

        - name: Set up Flutter
          uses: subosito/flutter-action@v2
          with:
            channel: stable
            run: flutter --version
        - name: Install dependencies
          run: flutter pub get
        - name: Analyze project source
          if: success()
          run: flutter analyze
        - name: Run tests
          run: flutter test
        - name: Build Flutter Web
          if: success()
          run: flutter build web
# этап 3.
	    - name: Prepare deployment directory
          run: |
            mkdir -p ~/opt/development/frontend
            cp -r * ~/opt/development/frontend/
        - name: Check if Docker is installed
          run: |
            if ! command -v docker &> /dev/null; then
              echo "Docker is not installed"
              exit 1
            fi
        - name: Try to restart container
          run: |
            if [ -f ~/opt/development/docker/docker-compose.yml ]; then
              echo "docker-compose.yml found, restarting container"
              docker-compose -f ~/opt/development/docker/docker-compose.yml up -d --build frontend
            else
              echo "docker-compose.yml not found, skipping docker-compose commands"
            fi

Если вам кажется что пайплайн перегружен вы можете разбить его на несколько файлов, например создать deploy.yml, перенести инструкции из этапа 3. и указать источник триггера - как успешное завершение пайплайна build & test.

Однако так же придётся перенести шаги получения репозитория, сборки и тестирования.

on:
  workflow_run:
    workflows: ["build & test"]
    types:
      - completed
`build.yml` для Бекенда начиная с этапа 2 и 3
# этап 2.
jobs:
  build:
    if: ${{ github.event_name == 'release' || github.event_name == 'workflow_dispatch' || github.event.pull_request.merged == true }}
    runs-on: self-hosted
    env:
      DOTNET_INSTALL_DIR: "./.dotnet"
    steps:
	- name: Clone repository
	  uses: actions/checkout@v4
	  with:
		ref: release
    - name: Setup .NET
      uses: actions/setup-dotnet@v4
      with:
        dotnet-version: 8.0.x
    - name: Restore dependencies
      run: dotnet restore
    - name: Build
      run: dotnet build --no-restore
    - name: Test
      run: dotnet test --no-build --verbosity normal
# этап 3.
    - name: Move files to build deploy directory
	  run: |
        mkdir -p ~/opt/development/frontend
        cp -r * ~/opt/development/frontend/
    - name: Check if Docker is installed
      run: |
        if ! command -v docker &> /dev/null; then
          echo "Docker is not installed"
          exit 1
        fi
    - name: Try to restart container
      run: |
          if [ -f ~/opt/development/docker/docker-compose.yml ]; then
			echo "docker-compose.yml found, restarting container"
            docker-compose -f ~/opt/development/docker/docker-compose.yml up -d --build backend
          else
            echo "docker-compose.yml not found, skipping docker-compose commands"
          fi
`build.yml` для Docker начиная с этапа 2 и 3
# этап 2.
jobs:
  build:
    if: ${{ github.event_name == 'release' || github.event_name == 'workflow_dispatch' || github.event.pull_request.merged == true }}
    runs-on: self-hosted
    steps:
        - name: Clone repository
          uses: actions/checkout@v4
          with:
            ref: release
# этап 3.
        - name: Move files to build deploy directory
          run: |
            mkdir -p ~/opt/development/frontend
            cp -r * ~/opt/development/frontend/
        - name: Check if Docker is installed
          run: |
            if ! command -v docker &> /dev/null; then
              echo "Docker is not installed"
              exit 1
            fi
        - name: Try to restart container
          run: |
            if [ -f ~/opt/development/docker/docker-compose.yml ]; then
              echo "docker-compose.yml found, restarting container"
              docker-compose -f ~/opt/development/docker/docker-compose.yml down
              docker-compose -f ~/opt/development/docker/docker-compose.yml up -d
            else
              echo "docker-compose.yml not found, skipping docker-compose commands"
            fi

Особо внимательные заметили что мы запускаем наш пайплайн на некой self-hosted машине:

runs-on: self-hosted

Что такое self-hosted?

Self-hosted — это кастомные машины, которую вы сами настраиваете и подключаете к GitHub Actions. Что обеспечивает больший контроль над оборудованием - вычислительной мощности или памяти, операционной системой и установленными программными средствами. Подробно узнать что такое GitHub runners, их поддерживаемые архитектуры и ограничениях, можно по ссылке на документацию.

Теперь наша задача сводится к тому - чтобы развернуть self-hosted раннер на подготовленном сервере из пункта 2.

Команды по настройке раннера индивидуальные, по этому переходим на страницу добавления раннера по адресу:

  • Для индивидуальных репозиториев: https://github.com/*имя_пользователя*/*имя_репозитория*/settings/actions/runners/new?arch=x64&os=linux

  • Для организаций: https://github.com/organizations/*имя_организации*/settings/actions/runners

Скопируйте команды для запуска и настройте раннер.


Если вы используете пользователя root, то при выполнении команд из секции Configure, вы столкнётесь со следующей ошибкой:

$ Must not run with sudo

Это из-за того, что раннер не должен запускаться от root-пользователя. Запустить его под root, конечно, можно, но будут проблемы с доступом к директориям во время выполнения пайплайна.

По этому создадим нового пользователя runner, который будет выполнять задачи по сборке и деплою:

adduser runner

Пароль укажу run на все остальные вопросы соглашаемся клавишей Enter. Затем подключаемся под этим пользователем:

ssh runner@*ваш адрес сервера*

Теперь снова выполняем команды для создания GitHub раннера под пользователем runner.


В процессе, когда дойдём до этапа Configure, нас попросят заполнить несколько полей:

  1. Имя группы, в которую включить раннер. Группу можно добавить в настройках вашей организации. Группы позволяют настроить доступ раннера к репозиториям и пайплайнам в определённых ветках, оставлю по умолчанию.

  2. Имя раннера — используется для удобства идентификации раннера, укажу billy-bat.

  3. Метка раннера. Метка нужна, чтобы использовать её в пайплайнах вместо self-hosted, укажу billy-bat.

  4. Рабочая директория, по умолчанию _work, оставлю по умолчанию.

После завершения конфигурации увидим, что раннер запущен и готов выполнять задачи.

Запущенный раннер
Запущенный раннер

На сайте GitHub в списке добавленных раннеров можно проверить статус:

Просмотр статуса раннера
Просмотр статуса раннера

5. Тестирование развернутого решения

Выполняем пуш всех изменений в ветку репозитория, если ещё этого не сделали.

Теперь когда все приготовления пройдены попробуем запустить наши пайплайны вручную

Начну с репозитория Фронтенда через веб интерфейс Github Actions

Переходим в таб Actions. В списке Actions вы заметите присутствующее в списке Действие, которое мы создали под названием build & test. Выполним его вручную (кнопка доступна, если добавить триггер workflow_dispatch:).

Запуск действия вручную
Запуск действия вручную

Выполняем запуск действий для всех репозиториев последовательно. Где-то в течении минуты процесс должен завершиться.

Успешный результат запуска пайплайна
Успешный результат запуска пайплайна

Однако у меня он завершился с ошибкой, в случае если вы арендуете виртуальный сервер или используете полную версию Ubuntu подобных проблем у вас не будет.

Тут разберу список ошибок с которыми я столкнулся при запуске

Решение проблем с запуском пайплайна Фронтенда выполненного на Flutter
Ошибка выполнения
Ошибка выполнения

Перейдём в детализацию запущенного процесса, посмотрим на каком шаге он остановился

Пример визуала ошибки
Пример визуала ошибки

ловим ошибку в секции
Set up Flutter: jq not found. Install it from https://stedolan.github.io/jq
Ага, для Flutter Web отсутствует либа jq, ставим её перейдя по ссылке

$ apt-get update
$ apt-get install jq

При повторном запуске следующая ошибка
Set up Flutter: xz: Cannot exec: No such file or directory
Ставим либу xz:

$ apt-get install xz-utils  

И следующая ошибка
Install dependencies: Unable to find git in your PATH.
Нет гита, ставим гит:

$ apt-get install git

И последняя ошибка которую мы обрабатываем
Check if Docker is installed: Docker is not installed
Нет докера, но докер мы ставили! дело в том что у текущего пользователя runner нет разрешения для его использования, исправим. Выполняем команды из официального мануала

Проверка развертывания, проверка доступности вебсайта.

Если все пайплайны выполнились успешно, вебсайт уже должен стать доступен по адресу где вы разворачивали Github раннер, так выглядит стартовый экран Flutter проекта на любой платформе:

Стартовый экран Flutter проекта
Стартовый экран Flutter проекта

А так же доступен бекенд по адресу через reverse-proxy

http://*ваш айпи сервера*/api/weatherforecast

Он возвращает данные погоды

[{"date":"2024-11-08","temperatureC":18,"summary":"Scorching","temperatureF":64},{"date":"2024-11-09","temperatureC":-7,"summary":"Sweltering","temperatureF":20},{"date":"2024-11-10","temperatureC":-10,"summary":"Freezing","temperatureF":15},{"date":"2024-11-11","temperatureC":-14,"summary":"Cool","temperatureF":7},{"date":"2024-11-12","temperatureC":20,"summary":"Freezing","temperatureF":67}]

На этом всё, можно переходить к Заключению.

А тем кто заинтересован в доработке флаттер проекта для подвязки к данным с бекенда, могут остаться))

6. Доработка проектов:

Вёрстка страницы для отображения информации, полученной с бэкенда

Для этого этапа решил арендовать почасовой сервер Ubuntu 24.04 и по инструкции выше настроил для него раннер.

Сверстал простой экран для отображения данных погоды списком

в файл main.dart добавлю следующее

Вёрстка экрана для отображения информации о Погоде
class MyApp extends StatelessWidget {  
  const MyApp({super.key});  
  
  @override  
  Widget build(BuildContext context) {  
    return MaterialApp(  
      title: 'Flutter Demo',  
      theme: ThemeData(  
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),  
        useMaterial3: true,  
      ),  
      home: const WeatherScreen(),  
    );  
  }  
}  
  
class WeatherForecast {  
  final String date;  
  final int temperatureC;  
  final String summary;  
  final int temperatureF;  
  
  WeatherForecast({  
    required this.date,  
    required this.temperatureC,  
    required this.summary,  
    required this.temperatureF,  
  });  
  
  factory WeatherForecast.fromJson(Map<String, dynamic> json) {  
    return WeatherForecast(  
      date: json['date'],  
      temperatureC: json['temperatureC'],  
      summary: json['summary'],  
      temperatureF: json['temperatureF'],  
    );  
  }  
}  
  
class WeatherRepository {  
  final String apiUrl = 'http://serverIp/api/weatherforecast';  
  
  Future<List<WeatherForecast>> fetchWeatherForecast() async {  
    final response = await http.get(Uri.parse(apiUrl));  
  
    if (response.statusCode == 200) {  
      final List jsonResponse = json.decode(response.body);  
      return jsonResponse.map((item) => WeatherForecast.fromJson(item)).toList();  
    } else {  
      throw Exception('Failed to load weather forecast');  
    }  
  }  
}  
  
class WeatherScreen extends StatelessWidget {  
  const WeatherScreen({super.key});  
  
  @override  
  Widget build(BuildContext context) {  
    return Scaffold(  
      appBar: AppBar(  
        title: const Text('Weather Forecast'),  
      ),  
      body: FutureBuilder<List<WeatherForecast>>(  
        future: WeatherRepository().fetchWeatherForecast(),  
        builder: (context, snapshot) {  
          if (snapshot.connectionState == ConnectionState.waiting) {  
            return const Center(child: CircularProgressIndicator());  
          } else if (snapshot.hasError) {  
            return const Center(child: Text('Failed to load weather forecast'));  
          } else if (snapshot.hasData) {  
            return WeatherList(weatherForecasts: snapshot.data!);  
          } else {  
            return const Center(child: Text('No data available'));  
          }  
        },  
      ),  
    );  
  }  
}  
  
class WeatherList extends StatelessWidget {  
  final List<WeatherForecast> weatherForecasts;  
  
  const WeatherList({super.key, required this.weatherForecasts});  
  
  @override  
  Widget build(BuildContext context) {  
    return ListView.builder(  
      itemCount: weatherForecasts.length,  
      itemBuilder: (context, index) {  
        final weather = weatherForecasts[index];  
        return Card(  
          margin: const EdgeInsets.all(8.0),  
          child: ListTile(  
            title: Text('Date: ${weather.date}'),  
            subtitle: Column(  
              crossAxisAlignment: CrossAxisAlignment.start,  
              children: [  
                Text('Temperature (C): ${weather.temperatureC}'),  
                Text('Temperature (F): ${weather.temperatureF}'),  
                Text('Summary: ${weather.summary}'),  
              ],  
            ),  
          ),  
        );  
      },  
    );  
  }  
}

Выполняем пулл-реквест с изменениями в ветку release, наблюдаем как выполняется запущенный пайплайн.

Добавление поддержки CORS

При текущей реализации стартовый проект бекенда возвращает данные о погоде, однако если обратиться к нему через приложение фронтенда запущенное по тому же адресу, мы получим ошибку в рантайме следующего содержания:

Error: ClientException: XMLHttpRequest error

Это означает что необходимо настроить Cross-origin resource sharing или сокращённо CORS для поддержки обращения к ресурсам другого домена.
Повторюсь: это решается только на Бекенде!

Добавим в проект backend следующие строчки перед var app = builder.Build();:

const string allowAllOrigins = nameof(allowAllOrigins);  
  
// Настройка политики CORS, для теста, при публикации указывайти конкретные эндпоинты
builder.Services.AddCors(options =>  
{  
    options.AddPolicy(allowAllOrigins,  
        policy  =>  
        {  
            policy  
                .AllowAnyOrigin()  
                .AllowAnyMethod()  
                .AllowAnyHeader();  
        });  
});

и после строчки var app = builder.Build();:

// Применение политики CORS  
app.UseCors(allowAllOrigins);

Отправляем пулл-реквест с изменениями и смотрим результат на сайте:

Информация о погоде
Информация о погоде

Супер! Работает как и ожидали)

Доработка Docker-Compose файла для автозапуска контейнеров

В случае если наш сервер перезапустится для профилактики или после отключения света, мы хотим чтобы наши контейнеры поднялись автоматически. Выше мы уже настраивали автозапуск сервиса докер, для того чтобы указать какие контейнеры должны быть запущены заново, необходимо в Compose файле решения для каждого сервиса добавить строчку:

restart: always

7. Заключение

Друзья, надеюсь, статья оказалась полезной, особенно для новичков, а опытным разработчикам помогла освежить в памяти процесс настройки CI/CD через GitHub Actions. Сегодня мы прошли весь путь — от создания репозиториев до автоматической сборки и деплоя фронтенд- и бэкенд-приложений. Особое внимание я уделил Flutter Web, информации о котором в сети не так много, и вопросы автоматизации здесь возникают часто. Весь процесс настроен так, чтобы минимизировать сложности — без ненужных токенов и ключей, которые я часто встречал в других подобных статьях.

Ещё эта настройка может служить отличной основой для ваших проектов: при желании её можно адаптировать под любой стек или расширить, добавив, например, bash-скрипты для автоматизации повторяющихся действий. Конечно, для продакшена я бы рекомендовал более сложный подход (может в следующей статье), но как старт или тестовый стенд это решение подходит идеально.

Так что, хорошего вам дня и весёлых праздников без мыслей о продакшене и деплое! ?

Комментарии (0)