Перейти к содержанию

Структура серверного проекта

В первом приложении запуск сервера, данные и HTTP-маршрут находились в одном Program.cs. Для четырёх записей этого достаточно, однако любое изменение заставляет читать весь файл. Добавим явную модель курса и перенесём связанные обязанности в отдельные файлы. Результатом станет проект, в котором можно найти модель каталога и его endpoints, сохранив тот же GET /api/courses.

Начальное состояние — lesson-02/start, полный результат предыдущего урока. Конечное — lesson-02/after. Это две самостоятельные папки одного учебного проекта. Общие числа не меняются: JavaScript — 20 уроков, HTML и CSS — 16, производительность — 12, Markdown — 12. Мы пока не подключаем базу, авторизацию или настоящий ProfessorWeb. Выполнение C# и HTTP остаётся будущим этапом, поэтому результат изменения объясняется по исходникам.

Имена модели и ответственность файла

Анонимный объект удобен внутри одной короткой функции. Для передачи данных между файлами полезен именованный тип. В Models/Course.cs поместим полное определение:

namespace CatalogApi;
public sealed record Course(string Id, string Title, string Topic, int Lessons);

record описывает значения с явными свойствами. Конструктор требует идентификатор, название, тему и число уроков. Благодаря этому создающий запись код видит весь минимальный контракт сразу. sealed запрещает наследование от модели: в первой части курса нет причин вводить варианты курса с разным поведением.

Запись не является таблицей PostgreSQL или JSON-документом сама по себе. Она существует внутри программы. Сериализатор сможет получить её свойства, а будущий слой хранения — прочитать соответствующие значения из строки базы. Способ хранения и внешняя форма ответа пока совпадают, но это совпадение нашего небольшого примера, а не универсальная обязанность типа record.

Папка Models помогает человеку искать определения. Имя папки не создаёт пространство имён автоматически и не запрещает другим файлам использовать тип. Пространство CatalogApi задано строкой namespace. Оно отделяет наши имена от имён framework и соседних учебных проектов. В корневом Program.cs подключим его через using CatalogApi, вместо повторения длинного имени перед каждым вызовом.

Данные разместим в Data/CatalogSeed.cs. Это полный набор исходных записей, который будет сохраняться на следующих шагах:

namespace CatalogApi;

public static class CatalogSeed
{
    public static readonly Course[] Courses =
    [
        new("javascript", "Современный JavaScript", "frontend", 20),
        new("html-css", "HTML и CSS", "frontend", 16),
        new("performance", "Производительность сайта", "frontend", 12),
        new("markdown", "Статический сайт из Markdown", "publishing", 12)
    ];
}

readonly запрещает заменить ссылку поля после инициализации. Он не делает массив физически неизменяемым: элемент массива всё ещё можно заменить, если другой код получает сам массив. На текущем шаге запись выполняется только в инициализаторе и далее читается. Когда введём слой доступа, наружу будем возвращать копию массива. Так ограничение станет свойством наших публичных методов, а не ожиданием внимательности каждого вызывающего участка.

Регистрация обработчиков отдельно от запуска

Создадим Endpoints/CatalogEndpoints.cs. В нём находится операция, которая регистрирует маршруты каталога:

namespace CatalogApi;
public static class CatalogEndpoints
{
    public static void MapCatalog(this WebApplication app)
    {
        app.MapGet("/api/courses", () => CatalogSeed.Courses);
    }
}

Класс статический, потому что здесь ещё нет состояния, которое должен хранить объект endpoint. Метод принимает уже созданный WebApplication. Ключевое слово this перед первым параметром делает его методом расширения. Поэтому из Program.cs можно написать app.MapCatalog(), хотя метод объявлен в нашем файле. Это обычный C#-приём организации вызова, а не новая команда ASP.NET Core.

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

Документация Microsoft о route handlers описывает регистрацию обработчиков вне Program.cs. Для нашего проекта важен именно этот приём: место объявления метода не определяет URL. Адрес задаётся строкой маршрута, поэтому перенос кода в папку Endpoints не требует менять /api/courses.

Полный новый Program.cs стал короче:

using CatalogApi;
var builder = WebApplication.CreateBuilder(args);
if (!builder.Environment.IsDevelopment())
    throw new InvalidOperationException("This learning project is Development-only.");
var app = builder.Build();
app.MapCatalog();
app.Run();

Проверка учебного окружения остаётся перед построением приложения. MapCatalog вызывается после Build, когда уже существует объект app, и до Run, когда начинается обслуживание запросов. Если забыть регистрацию, сервер может успешно запуститься, но GET /api/courses получит 404. Наличие файла endpoint в проекте само по себе маршрут не активирует.

Что мы сохранили и что изменили

Ожидаемая внешняя форма ответа совпадает с первым уроком. Свойства C# имеют имена Id, Title, Topic, Lessons, а стандартный веб-сериализатор выдаёт их в JSON как id, title, topic, lessons. В массиве остаются четыре записи и сумма 60. Изменение внутренней структуры не является поводом изменить адрес, имена полей или значения.

Если после настоящего выполнения клиент увидит другую структуру, сначала следует сравнить регистрацию маршрута, возвращаемый тип и сериализацию. Перемещение файла не должно менять HTTP-контракт, однако замена названия свойства уже способна это сделать. Например, переименование Lessons в LessonCount приведёт к другому имени JSON, если отдельно не настроить отображение. Это полезный пример того, как внутреннее действие становится внешним изменением.

Проект по-прежнему один. Отдельные папки не превращают его в микросервисы или самостоятельные библиотеки. Все .cs-файлы входят в одну сборку, а одно приложение слушает один учебный порт. Такое разделение дешёво и понятно: запуск отвечает за соединение частей, модель описывает данные, seed задаёт начальное состояние, endpoint описывает HTTP-вход.

На этом шаге не нужно создавать интерфейс для каждой маленькой функции или вводить несколько проектов решения только ради структуры. Полезная граница возникает из задачи: когда способ получения данных начнёт меняться, выделим зависимость endpoint от хранилища. Пока обращение к CatalogSeed видно прямо в обработчике и легко проверяется чтением.

Ещё одна важная граница — дерево исходников. Файлы start показывают предыдущее состояние, файлы after — новое. Копируя оба дерева в одну рабочую папку, вы можете получить дублирующий Program.cs и два определения типа. Это ошибка подготовки проекта, а не маршрутизации. Выбирайте одну полную папку и затем переносите только осознанно изменённые файлы.

Явный именованный тип помогает и при чтении зависимостей. Когда новый метод принимает Course, видно, какую модель он ожидает, ещё до изучения его тела. Анонимная форма такого имени не даёт и часто удерживает всю работу рядом с объявлением. Но сам выбор record не требует включать туда сериализацию, SQL или получение настроек: объект остаётся описанием значений, а поведение внешних границ находится рядом с соответствующими операциями.

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