Отказ от ответственности:
Эта статья написана за 3 часа нейросетями, пока ждал когда коллеги отойдут от вчерашнего корпоратива. Код не проверялся и полон нейроглюков. Автор не фронт-эндер. Здесь интересна только композиция исходной задачи на мелкие подзадачи.
Извините за форматирование, никак не привыкну к здешнему формату.
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 могут разрабатываться независимо.
ссылка на оригинал статьи https://habr.com/ru/articles/868244/
Добавить комментарий