INTRO
Я архитектор и бэк программист. Понадобилось реализовать модуль с развитым фронт-эндом. Оказалось что как единый компонент его реализовать слишком сложно. Попробую разбить на компоненты, особенно на фронт-энде.
Постановка моей задачи
Визард из 3 шагов.
- На первом этапе необходимо подать запрос пользователя с выбором и поиском по справочникам и вводом данных.
- Второй этап: несколько сущностей в виде вкладок. Кнопки добавления, удаления и копирования вкладки.
- Во вкладке форма с выбором и поиском по справочникам и вводом данных.
- При переходе на вкладку данные валидируются и сохраняются на сервере. Данные для справочников берутся с сервера.
- На третьем шаге отчет для проверки введенных данных.
Для простоты пусть будет только задача реализовать вкладки. Нужно потренироваться с использованием компонентного подхода в проектировании, реализации и тестировании.
ПОСЛЕДОВАТЕЛЬНОСТЬ РАБОТ:
Бэкенд API:
Проектирование API контрактов
Реализация API endpoints
Документация (OpenAPI/Swagger)
Тестирование API отдельно
Фронтенд с моками:
Разработка UI компонентов
Создание API клиента с моками
Тестирование компонентов изолированно
Тестирование взаимодействия компонентов
Фронтенд с реальными данными:
Реализация реальных API вызовов
Обработка загрузки/ошибок
Тестирование с реальным API
Интеграция:
E2E тестирование
Производительность
Мониторинг
Архитектурные и технологические решения
Архитектура приложения основана на разделении на фронтенд и бэкенд с четким разграничением ответственности и использованием современных технологий для обеспечения масштабируемости и поддерживаемости.
Технологический стек:
-
Бэкенд:
Язык программирования: C# (на самом деле пофиг, бека тут мало)
Фреймворк: ASP.NET Core Web API для создания RESTful API.
Доступ к данным: Entity Framework Core или другой ORM для работы с базой данных.
База данных: SQL Server, PostgreSQL или MongoDB в зависимости от требований.
Документация API: Swagger/OpenAPI для автоматической генерации документации и контрактов.
Тестирование: xUnit, NUnit или MSTest для модульного и интеграционного тестирования.
-
Фронтенд:
Язык программирования: TypeScript для типизации и улучшенной надежности кода.
Библиотека UI: React для построения пользовательского интерфейса.
Управление состоянием: Redux или React Context API для управления состоянием приложения. (эти детали не описаны)
Роутинг: React Router для управления навигацией между шагами визарда.
Стилизация: CSS-in-JS (Emotion, Styled Components) или CSS Modules для модульной стилизации компонентов.
-
Тестирование: Jest и React Testing Library для модульного и интеграционного тестирования компонентов.
Преимущества предлагаемой архитектуры:
Разделение ответственности (SoC): Каждый модуль отвечает за свою область, что упрощает разработку и поддержку.
Тестируемость: Легко проводить Unit-тестирование компонентов и API.
Масштабируемость: Возможность расширения функциональности и добавления новых модулей.
Переиспользование кода: Общие типы и константы используются как на Frontend, так и на Backend.
Параллельная разработка: Frontend и Backend могут разрабатываться независимо.
Ключевые моменты:
Использование API-First подхода.
Генерация TypeScript типов из C# моделей (рекомендуется с помощью NSwag или OpenAPI Generator).
Использование моков на этапе разработки Frontend.
Покрытие кода тестами.
Структура солюшна.
Простой вариант
Solution/
├── Backend/
│ ├── UserForm.API/ # Основной проект API
│ │ ├── Controllers/
│ │ │ └── FormsController.cs # Эндпоинты API
│ │ ├── Models/ # Модели данных, общая бизнес логика
│ │ │ └── FormModel.cs
│ │ ├── Services/ # Бизнес-логика конкретных вариантов использования
│ │ │ └── FormService.cs
│ │ └── Program.cs
│ │
│ └── UserForm.Tests/ # Тесты бэкенда
│
├── Frontend/
│ ├── src/
│ │ ├── api/ # Работа с API
│ │ │ ├── formApi.ts # Клиент API
│ │ │ └── types.ts # Типы данных
│ │ │
│ │ ├── components/ # React компоненты
│ │ │ ├── FormWizard.tsx # Основной компонент визарда
│ │ │ └── FormStep.tsx # Компонент шага
│ │ │
│ │ └── App.tsx
│ │
│ └── tests/ # Тесты фронтенда
│
└── Shared/ # Общий код
└── Types/ # Общие типы
├── FormTypes.cs
└── FormTypes.ts
Сложный вариант
Solution/
├── .github/ # GitHub Actions, CI/CD
│ └── workflows/
│
├── Backend/
│ ├── UserForm.API/ # Web API проект
│ │ ├── Controllers/
│ │ │ ├── FormsController.cs
│ │ │ └── BaseController.cs
│ │ ├── Middleware/
│ │ │ ├── ErrorHandlingMiddleware.cs
│ │ │ └── LoggingMiddleware.cs
│ │ ├── Configuration/
│ │ │ └── SwaggerConfig.cs
│ │ ├── Program.cs
│ │ ├── Startup.cs
│ │ └── appsettings.json
│ │
│ ├── UserForm.Core/ # Бизнес-логика
│ │ ├── Models/
│ │ │ ├── Form.cs
│ │ │ └── Step.cs
│ │ ├── Services/
│ │ │ ├── Interfaces/
│ │ │ │ └── IFormService.cs
│ │ │ └── FormService.cs
│ │ ├── Validation/
│ │ │ ├── Validators/
│ │ │ │ └── FormValidator.cs
│ │ │ └── Rules/
│ │ └── Exceptions/
│ │ └── BusinessException.cs
│ │
│ ├── UserForm.Infrastructure/ # Доступ к данным
│ │ ├── Data/
│ │ │ ├── Repositories/
│ │ │ │ ├── IFormRepository.cs
│ │ │ │ └── FormRepository.cs
│ │ │ └── Configurations/
│ │ │ └── FormConfiguration.cs
│ │ ├── Persistence/
│ │ │ ├── FormDbContext.cs
│ │ │ └── Migrations/
│ │ └── Services/
│ │ └── External/ # Внешние сервисы
│ │
│ ├── UserForm.Shared/ # Общие DTO и контракты
│ │ ├── DTOs/
│ │ │ ├── FormDTO.cs
│ │ │ └── ValidationDTO.cs
│ │ ├── Constants/
│ │ │ └── ApiRoutes.cs
│ │ └── Extensions/
│ │
│ └── Tests/
│ ├── UserForm.UnitTests/
│ │ ├── Services/
│ │ └── Validators/
│ ├── UserForm.IntegrationTests/
│ │ └── API/
│ └── UserForm.E2ETests/
│
├── Frontend/
│ ├── public/
│ │ └── index.html
│ │
│ ├── src/
│ │ ├── api/ # API клиенты
│ │ │ ├── types.ts
│ │ │ ├── formApi.ts
│ │ │ └── generated/ # Автогенерированные типы
│ │ │
│ │ ├── components/ # React компоненты
│ │ │ ├── common/ # Общие компоненты
│ │ │ │ ├── Button/
│ │ │ │ ├── Input/
│ │ │ │ └── ErrorBoundary/
│ │ │ │
│ │ │ ├── Form/ # Компоненты формы
│ │ │ │ ├── FormWizard/
│ │ │ │ ├── FormStep/
│ │ │ │ └── FormNavigation/
│ │ │ │
│ │ │ └── Layout/ # Компоненты лейаута
│ │ │
│ │ ├── hooks/ # React хуки
│ │ │ ├── useForm.ts
│ │ │ └── useApi.ts
│ │ │
│ │ ├── store/ # Управление состоянием
│ │ │ ├── slices/
│ │ │ └── store.ts
│ │ │
│ │ ├── utils/ # Утилиты
│ │ │ ├── validation.ts
│ │ │ └── formatting.ts
│ │ │
│ │ ├── styles/ # Стили
│ │ │ ├── global.css
│ │ │ └── variables.css
│ │ │
│ │ ├── config/ # Конфигурация
│ │ │ └── api.ts
│ │ │
│ │ ├── App.tsx
│ │ └── index.tsx
│ │
│ ├── tests/ # Тесты
│ │ ├── unit/
│ │ │ └── components/
│ │ ├── integration/
│ │ └── e2e/
│ │
│ ├── .storybook/ # Storybook конфигурация
│ ├── package.json
│ ├── tsconfig.json
│ └── vite.config.ts
│
├── Shared/ # Общий код
│ ├── Types/ # Общие типы
│ │ ├── FormTypes.cs
│ │ └── FormTypes.ts
│ │
│ └── Constants/ # Общие константы
│ ├── ApiRoutes.cs
│ └── ApiRoutes.ts
│
├── Tools/ # Инструменты разработки
│ ├── CodeGen/ # Генераторы кода
│ └── Scripts/ # Скрипты сборки/деплоя
│
├── docs/ # Документация
│ ├── api/
│ ├── architecture/
│ └── deployment/
│
├── .editorconfig # Настройки редактора
├── .gitignore
├── docker-compose.yml # Docker конфигурация
├── README.md
└── Solution.sln
Заметим, что одни и те же типы (если использовать typescript) используются как на фронте так и на беке.
Разберем пошаговую разработку бэкенда на C#:
-
Сначала определяем модели и контракты:
Переиспользование типов фронтенда и бекэнда:
# api-spec.yaml
openapi: 3.0.0
info:
title: User Form API
version: 1.0.0
paths:
/api/forms/{id}:
get:
summary: Get form data
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
200:
description: Form data
content:
application/json:
schema:
$ref: '#/components/schemas/FormDTO'
put:
summary: Update form data
parameters:
- name: id
in: path
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/FormDTO'
responses:
200:
description: Updated form data
content:
application/json:
schema:
$ref: '#/components/schemas/FormDTO'
components:
schemas:
FormDTO:
type: object
properties:
id:
type: string
step:
type: string
enum: [personal, address, payment]
data:
type: object
additionalProperties: true
status:
type: string
enum: [draft, complete, error]
Генерируем C# код:
# Используем NSwag
nswag openapi2csclient /input:api-spec.yaml /output:Backend/Generated/ApiClient.cs
# Или используем OpenAPI Generator
openapi-generator generate -i api-spec.yaml -g aspnetcore -o Backend/Generated
Генерируем TypeScript код:
# Генерация TypeScript клиента
openapi-generator generate -i api-spec.yaml -g typescript-fetch -o Frontend/src/api/generated
Реализуем API на бэкенде используя сгенерированные интерфейсы:
// Backend/Controllers/FormsController.cs
[ApiController]
[Route("api/[controller]")]
public class FormsController : ControllerBase, IFormsApi
{
public async Task> GetForm(string id)
{
// Реализация
}
public async Task> UpdateForm(string id, FormDTO form)
{
// Реализация
}
}
Используем сгенерированный клиент на фронтенде:
// Frontend/src/components/FormWizard.tsx
import { FormsApi, FormDTO } from '../api/generated';
export const FormWizard: React.FC<{ id: string }> = ({ id }) => {
const api = new FormsApi();
const [form, setForm] = useState();
useEffect(() => {
api.getForm(id).then(setForm);
}, [id]);
// Остальной код
};
Преимущества этого подхода:
API контракт становится источником правды
Фронт и бэк могут разрабатываться параллельно
Легко поддерживать совместимость
Автоматическая документация
Типобезопасность на обеих сторонах
Недостатки:
простой контракт проще написать вручную
...вернемся к разработке бекэнда...
2. Создаем интерфейс сервиса на бекэнде:
// Services/IUserFormService.cs
public interface IUserFormService
{
Task GetFormAsync(string id);
Task SaveFormAsync(string id, UserForm form);
Task ValidateStepAsync(string id, FormStep step, Dictionary data);
}
// Services/UserFormService.cs
public class UserFormService : IUserFormService
{
// конструктор с внедрением зависимостей
private readonly IUserFormRepository _repository;
private readonly IValidator _validator;
public UserFormService(IUserFormRepository repository, IValidator validator)
{
_repository = repository;
_validator = validator;
}
// реализации того что обещали в IUserFormService
public async Task GetFormAsync(string id)
{
var form = await _repository.GetByIdAsync(id);
if (form == null)
{
throw new NotFoundException($"Form {id} not found");
}
return form;
}
public async Task SaveFormAsync(string id, UserForm form)
{
form.LastUpdated = DateTime.UtcNow;
var validationResult = await _validator.ValidateAsync(form);
if (!validationResult.IsValid)
{
form.Status = FormStatus.Error;
form.ValidationErrors = validationResult.Errors
.Select(e => e.ErrorMessage)
.ToList();
}
return await _repository.SaveAsync(id, form);
}
}
Реализуем репозиторий:
// Repository/IUserFormRepository.cs
public interface IUserFormRepository
{
Task GetByIdAsync(string id);
Task SaveAsync(string id, UserForm form);
}
// Repository/UserFormRepository.cs
public class UserFormRepository : IUserFormRepository
{
private readonly IMongoCollection _forms;
public UserFormRepository(IMongoDatabase database)
{
_forms = database.GetCollection("userForms");
}
public async Task GetByIdAsync(string id)
{
return await _forms.Find(f => f.Id == id).FirstOrDefaultAsync();
}
public async Task SaveAsync(string id, UserForm form)
{
await _forms.ReplaceOneAsync(
f => f.Id == id,
form,
new ReplaceOptions { IsUpsert = true }
);
return form;
}
}
Добавляем валидацию:
// Validation/UserFormValidator.cs
public class UserFormValidator : AbstractValidator
{
public UserFormValidator()
{
RuleFor(x => x.Id).NotEmpty();
RuleFor(x => x.Step).IsInEnum();
RuleFor(x => x.Status).IsInEnum();
When(x => x.Step == FormStep.Personal, () => {
RuleFor(x => x.Data)
.Must(HaveRequiredPersonalFields)
.WithMessage("Missing required personal information");
});
}
private bool HaveRequiredPersonalFields(Dictionary data)
{
return data != null &&
data.ContainsKey("firstName") &&
data.ContainsKey("lastName");
}
}
Создаем контроллер:
// Controllers/UserFormController.cs
[ApiController]
[Route("api/forms")]
public class UserFormController : ControllerBase
{
private readonly IUserFormService _formService;
private readonly IMapper _mapper;
public UserFormController(IUserFormService formService, IMapper mapper)
{
_formService = formService;
_mapper = mapper;
}
[HttpGet("{id}")]
public async Task> GetForm(string id)
{
try
{
var form = await _formService.GetFormAsync(id);
return Ok(_mapper.Map(form));
}
catch (NotFoundException ex)
{
return NotFound(ex.Message);
}
}
[HttpPut("{id}")]
public async Task> SaveForm(string id, UserFormDto dto)
{
var form = _mapper.Map(dto);
var savedForm = await _formService.SaveFormAsync(id, form);
return Ok(_mapper.Map(savedForm));
}
}
Настраиваем AutoMapper:
для автоматического преобразования между разными представлениями объектов, напр-р между внутренними моделями и DTO - в данном случае излишне, убралДобавляем тесты:
// Tests/UserFormServiceTests.cs
public class UserFormServiceTests
{
private readonly Mock _repositoryMock;
private readonly Mock> _validatorMock;
private readonly UserFormService _service;
public UserFormServiceTests()
{
_repositoryMock = new Mock();
_validatorMock = new Mock>();
_service = new UserFormService(_repositoryMock.Object, _validatorMock.Object);
}
[Fact]
public async Task GetForm_WhenExists_ReturnsForm()
{
// Arrange
var form = new UserForm { Id = "test" };
_repositoryMock.Setup(r => r.GetByIdAsync("test"))
.ReturnsAsync(form);
// Act
var result = await _service.GetFormAsync("test");
// Assert
Assert.Equal(form, result);
}
[Fact]
public async Task SaveForm_WithValidData_SavesAndReturnsForm()
{
// Arrange
var form = new UserForm { Id = "test" };
_validatorMock.Setup(v => v.ValidateAsync(It.IsAny(), default))
.ReturnsAsync(new ValidationResult());
_repositoryMock.Setup(r => r.SaveAsync("test", It.IsAny()))
.ReturnsAsync(form);
// Act
var result = await _service.SaveFormAsync("test", form);
// Assert
Assert.Equal(form, result);
}
}
Настройка DI в Startup:
// Startup.cs
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers();
services.AddAutoMapper(typeof(UserFormProfile));
services.AddScoped();
services.AddScoped();
services.AddScoped, UserFormValidator>();
services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = "UserForm API", Version = "v1" });
});
}
Шаги проектирования и реализации фронт:
// api/types.ts
// Определяем все типы и интерфейсы для API
export interface UserFormDTO {
id: string;
step: FormStep;
data: Record;
status: FormStatus;
lastUpdated?: string;
validationErrors?: string[];
}
export type FormStep = 'personal' | 'address' | 'payment';
export type FormStatus = 'draft' | 'complete' | 'error';
// Интерфейс для API клиента
export interface UserFormApi {
getFormData(id: string): Promise;
saveFormData(id: string, data: Partial): Promise;
validateStep(id: string, step: FormStep, data: any): Promise;
}
// api/userFormApi.ts
// Реализация API клиента с поддержкой моков для разработки
import { UserFormApi, UserFormDTO } from './types';
import { mockData } from './mockData';
export class UserFormApiClient implements UserFormApi {
private readonly baseUrl: string;
private readonly useMocks: boolean;
constructor(config = {
baseUrl: process.env.REACT_APP_API_URL,
useMocks: process.env.REACT_APP_USE_MOCKS === 'true'
}) {
this.baseUrl = config.baseUrl;
this.useMocks = config.useMocks;
}
// Получение данных формы
async getFormData(id: string): Promise {
if (this.useMocks) {
// В режиме разработки используем моки
return mockData[id];
}
// В продакшене делаем реальный API запрос
const response = await fetch(`${this.baseUrl}/forms/${id}`);
if (!response.ok) {
throw new Error(`API Error: ${response.statusText}`);
}
return response.json();
}
// Сохранение данных формы
async saveFormData(id: string, data: Partial): Promise {
if (this.useMocks) {
// Имитируем задержку сети
await new Promise(resolve => setTimeout(resolve, 500));
return {
...mockData[id],
...data,
lastUpdated: new Date().toISOString()
};
}
const response = await fetch(`${this.baseUrl}/forms/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) {
throw new Error(`Save Error: ${response.statusText}`);
}
return response.json();
}
}
// hooks/useFormData.ts
// Хук для работы с данными формы, инкапсулирует логику загрузки и обработки ошибок
import { useState, useEffect } from 'react';
import { UserFormApiClient } from '../api/userFormApi';
import { UserFormDTO } from '../api/types';
export function useFormData(formId: string) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Загрузка данных при монтировании или изменении formId
useEffect(() => {
const api = new UserFormApiClient();
let mounted = true;
async function loadData() {
try {
const result = await api.getFormData(formId);
if (mounted) {
setData(result);
}
} catch (err) {
if (mounted) {
setError(err as Error);
}
} finally {
if (mounted) {
setLoading(false);
}
}
}
loadData();
// Очистка при размонтировании
return () => {
mounted = false;
};
}, [formId]);
// Функция для сохранения данных
const saveData = async (newData: Partial) => {
setLoading(true);
try {
const api = new UserFormApiClient();
const updated = await api.saveFormData(formId, newData);
setData(updated);
return updated;
} catch (err) {
setError(err as Error);
throw err;
} finally {
setLoading(false);
}
};
return { data, loading, error, saveData };
}
// components/UserForm/UserForm.tsx
// Основной компонент формы, управляет состоянием и навигацией
import React, { useState } from 'react';
import { useFormData } from '../../hooks/useFormData';
import { FormStep } from '../../api/types';
import { StepComponents } from './StepComponents';
import { Spinner, ErrorMessage } from '../common';
import './styles.css';
interface UserFormProps {
formId: string;
onComplete?: (data: any) => void;
}
export const UserForm: React.FC = ({ formId, onComplete }) => {
const { data, loading, error, saveData } = useFormData(formId);
const [currentStep, setCurrentStep] = useState('personal');
// Обработчик перехода к следующему шагу
const handleNext = async (stepData: any) => {
try {
// Сохраняем данные текущего шага
await saveData({
data: { ...data?.data, ...stepData },
step: getNextStep(currentStep)
});
// Переходим к следующему шагу
setCurrentStep(getNextStep(currentStep));
} catch (err) {
console.error('Failed to save step:', err);
}
};
if (loading) return ;
if (error) return ;
if (!data) return null;
const StepComponent = StepComponents[currentStep];
return (
<div>
{/* Индикатор прогресса */}
{/* Текущий шаг формы */}
setCurrentStep(getPreviousStep(currentStep))}
isValid={!data.validationErrors?.length}
/>
</div>
);
};
И наконец-то раздельное тестирование компонент. Здесь пройдемся по коду подробней.
// components/UserForm/UserForm.test.tsx
// Тесты компонента формы UserForm
import { render, screen, waitFor, fireEvent } from '@testing-library/react'; // Импортируем необходимые функции из библиотеки тестирования
import { UserForm } from './UserForm'; // Импортируем тестируемый компонент
import { UserFormApiClient } from '../../api/userFormApi'; // Импортируем API клиент
// Мокируем API клиент UserFormApiClient.
// Это необходимо для изоляции компонента UserForm от реальных запросов к API.
// Jest заменит реальный UserFormApiClient на мок-реализацию.
jest.mock('../../api/userFormApi');
describe('UserForm', () => { // Описываем набор тестов для компонента UserForm
// Функция, которая выполняется перед каждым тестом (beforeEach).
// Здесь мы настраиваем мок-реализацию API клиента.
beforeEach(() => {
// mockImplementation используется для создания мок-функций.
(UserFormApiClient as jest.Mock).mockImplementation(() => ({
// Мокируем метод getFormData. Он будет возвращать промис, который резолвится с моковыми данными.
getFormData: jest.fn().mockResolvedValue({
id: 'test',
step: 'personal',
data: {},
status: 'draft'
}),
// Мокируем метод saveFormData. Он также будет возвращать промис, который резолвится с моковыми данными.
// Важно, что данные, переданные в saveFormData, будут возвращены обратно, имитируя сохранение.
saveFormData: jest.fn().mockImplementation(async (id, data) => ({
id,
...data, // Распространяем переданные данные в моковый ответ
status: 'draft'
}))
}));
});
// Тест: проверка начального состояния загрузки (отображение спиннера).
it('shows loading state initially', () => {
render(<UserForm formId="test"/>); // Рендерим компонент UserForm. formId обязательный пропс.
expect(screen.getByTestId('spinner')).toBeInTheDocument(); // Проверяем, что на экране отображается элемент со атрибутом data-testid="spinner" (спиннер).
});
// Тест: проверка навигации по форме.
it('handles form navigation', async () => {
render(<UserForm formId="test"/>); // Рендерим компонент
// Ждем, пока компонент загрузится и на экране появится элемент с data-testid="personal-step".
// waitFor используется для ожидания асинхронных операций.
await waitFor(() => {
expect(screen.getByTestId('personal-step')).toBeInTheDocument(); // Проверяем, что отобразился шаг "personal"
});
// Эмулируем ввод данных в поле с data-testid="name-input".
fireEvent.change(screen.getByTestId('name-input'), {
target: { value: 'John' } // Вводим значение "John"
});
// Эмулируем клик по кнопке "Next".
fireEvent.click(screen.getByText('Next'));
// Ожидаем, пока произойдет переход на следующий шаг (появление элемента с data-testid="address-step").
await waitFor(() => {
expect(screen.getByTestId('address-step')).toBeInTheDocument(); // Проверяем, что отобразился шаг "address"
});
});
});
вот! мы и добрались до преимуществ компонентного подхода:
Преимущества предлагаемой архитектуры:
Разделение ответственности (SoC): Каждый модуль отвечает за свою область, что упрощает разработку и поддержку.
Тестируемость: Легко проводить Unit-тестирование компонентов и API.
Масштабируемость: Возможность расширения функциональности и добавления новых модулей.
Переиспользование кода: Общие типы и константы используются как на Frontend, так и на Backend.
Параллельная разработка: Frontend и Backend могут разрабатываться независимо.