Перейти к основному содержимому

Система управления библиотекой книг на C#

Система управления библиотекой книг на C#

Система управления библиотекой книг — это классический учебный проект, который демонстрирует применение ключевых концепций современной разработки на платформе .NET. В данном примере реализуется полноценное веб-приложение с использованием ASP.NET Core, Entity Framework Core и LINQ. Такая система позволяет управлять каталогом книг, отслеживать выдачу экземпляров читателям, контролировать сроки возврата и формировать отчёты.

Проект охватывает такие аспекты, как:

  • проектирование доменной модели;
  • работа с реляционной базой данных через ORM;
  • реализация CRUD-операций (Create, Read, Update, Delete);
  • применение шаблонов MVC и Repository;
  • использование LINQ для фильтрации, сортировки и агрегации данных;
  • построение RESTful API или серверного рендеринга представлений;
  • обеспечение согласованности данных и обработка ошибок.

Доменная модель

Доменная модель — это набор классов, отражающих сущности предметной области и их взаимосвязи. В системе управления библиотекой можно выделить следующие основные сущности:

  • Book — книга в каталоге;
  • Author — автор книги;
  • Genre — жанр книги;
  • Reader — читатель (пользователь библиотеки);
  • Loan — запись о выдаче книги читателю.

Каждая сущность представляет собой отдельный класс с набором свойств и связей.

Класс Book

public class Book
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public int Year { get; set; }
public string ISBN { get; set; } = string.Empty;

// Навигационные свойства
public int AuthorId { get; set; }
public Author Author { get; set; } = null!;

public int GenreId { get; set; }
public Genre Genre { get; set; } = null!;

public ICollection<Loan> Loans { get; set; } = new List<Loan>();
}

Класс Author

public class Author
{
public int Id { get; set; }
public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty;

public ICollection<Book> Books { get; set; } = new List<Book>();
}

Класс Genre

public class Genre
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;

public ICollection<Book> Books { get; set; } = new List<Book>();
}

Класс Reader

public class Reader
{
public int Id { get; set; }
public string FullName { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public DateTime RegistrationDate { get; set; }

public ICollection<Loan> Loans { get; set; } = new List<Loan>();
}

Класс Loan

public class Loan
{
public int Id { get; set; }
public DateTime IssueDate { get; set; }
public DateTime? ReturnDate { get; set; }
public bool IsReturned => ReturnDate.HasValue;

// Внешние ключи
public int BookId { get; set; }
public Book Book { get; set; } = null!;

public int ReaderId { get; set; }
public Reader Reader { get; set; } = null!;
}

Эти классы образуют граф объектов, где связи между сущностями выражены через навигационные свойства (Author, Genre, Loans и т.д.). Такая структура позволяет легко переходить от одной сущности к связанной без написания дополнительных SQL-запросов.


Настройка Entity Framework Core

Entity Framework Core (EF Core) — это объектно-реляционный маппер (ORM), входящий в экосистему .NET. Он позволяет работать с базой данных через объекты C#, автоматически генерируя SQL-запросы.

Контекст базы данных

Контекст — это основной класс EF Core, представляющий сессию работы с базой данных. Он наследуется от DbContext и содержит DbSet<T> для каждой сущности.

public class LibraryContext : DbContext
{
public DbSet<Book> Books { get; set; } = null!;
public DbSet<Author> Authors { get; set; } = null!;
public DbSet<Genre> Genres { get; set; } = null!;
public DbSet<Reader> Readers { get; set; } = null!;
public DbSet<Loan> Loans { get; set; } = null!;

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseSqlServer("Server=(localdb)\\mssqllocaldb;Database=LibraryDb;Trusted_Connection=true;");
}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Настройка отношений
modelBuilder.Entity<Book>()
.HasOne(b => b.Author)
.WithMany(a => a.Books)
.HasForeignKey(b => b.AuthorId);

modelBuilder.Entity<Book>()
.HasOne(b => b.Genre)
.WithMany(g => g.Books)
.HasForeignKey(b => b.GenreId);

modelBuilder.Entity<Loan>()
.HasOne(l => l.Book)
.WithMany(b => b.Loans)
.HasForeignKey(l => l.BookId);

modelBuilder.Entity<Loan>()
.HasOne(l => l.Reader)
.WithMany(r => r.Loans)
.HasForeignKey(l => l.ReaderId);
}
}

Метод OnModelCreating используется для явного указания связей между таблицами. Хотя EF Core может вывести многие связи автоматически, явная конфигурация повышает надёжность и читаемость кода.


Реализация CRUD-операций

CRUD-операции — это базовые действия: создание, чтение, обновление и удаление записей. В ASP.NET Core они обычно реализуются в контроллерах.

Пример контроллера книг

[ApiController]
[Route("api/[controller]")]
public class BooksController : ControllerBase
{
private readonly LibraryContext _context;

public BooksController(LibraryContext context)
{
_context = context;
}

// GET: api/books
[HttpGet]
public async Task<ActionResult<IEnumerable<Book>>> GetBooks()
{
return await _context.Books
.Include(b => b.Author)
.Include(b => b.Genre)
.ToListAsync();
}

// GET: api/books/5
[HttpGet("{id}")]
public async Task<ActionResult<Book>> GetBook(int id)
{
var book = await _context.Books
.Include(b => b.Author)
.Include(b => b.Genre)
.FirstOrDefaultAsync(b => b.Id == id);

if (book == null)
return NotFound();

return book;
}

// POST: api/books
[HttpPost]
public async Task<ActionResult<Book>> CreateBook(Book book)
{
_context.Books.Add(book);
await _context.SaveChangesAsync();
return CreatedAtAction(nameof(GetBook), new { id = book.Id }, book);
}

// PUT: api/books/5
[HttpPut("{id}")]
public async Task<IActionResult> UpdateBook(int id, Book book)
{
if (id != book.Id)
return BadRequest();

_context.Entry(book).State = EntityState.Modified;

try
{
await _context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
if (!BookExists(id))
return NotFound();
else
throw;
}

return NoContent();
}

// DELETE: api/books/5
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteBook(int id)
{
var book = await _context.Books.FindAsync(id);
if (book == null)
return NotFound();

_context.Books.Remove(book);
await _context.SaveChangesAsync();
return NoContent();
}

private bool BookExists(int id) => _context.Books.Any(e => e.Id == id);
}

Ключевые моменты:

  • Использование Include() для загрузки связанных данных (автора и жанра);
  • Асинхронные методы (async/await) для эффективной работы с I/O;
  • Обработка ошибок: NotFound(), BadRequest(), исключения параллелизма.

Работа с данными через LINQ

LINQ (Language Integrated Query) — это мощный механизм языка C# для запросов к коллекциям и базам данных. В контексте EF Core LINQ-выражения транслируются в SQL.

Примеры практических запросов

Поиск всех невозвращённых книг

var overdueLoans = await _context.Loans
.Where(l => !l.IsReturned)
.Include(l => l.Book)
.Include(l => l.Reader)
.ToListAsync();

Книги, выданные конкретному читателю

var loansByReader = await _context.Loans
.Where(l => l.ReaderId == readerId && !l.IsReturned)
.Select(l => l.Book)
.ToListAsync();

Подсчёт количества книг по жанрам

var genreCounts = await _context.Books
.GroupBy(b => b.Genre.Name)
.Select(g => new { Genre = g.Key, Count = g.Count() })
.ToListAsync();

Поиск книг по ключевому слову в названии

var books = await _context.Books
.Where(b => b.Title.Contains(searchTerm))
.Include(b => b.Author)
.ToListAsync();

Самые популярные книги (по количеству выдач)

var popularBooks = await _context.Books
.Select(b => new
{
Book = b,
LoanCount = b.Loans.Count
})
.OrderByDescending(x => x.LoanCount)
.Take(10)
.ToListAsync();

LINQ делает код декларативным: вместо описания как получить данные, разработчик описывает что нужно получить. Это повышает читаемость и снижает вероятность ошибок.


Архитектурные соображения

Хотя прямое использование DbContext в контроллере допустимо для простых приложений, в реальных проектах рекомендуется применять паттерны:

Паттерн Repository

Создаётся интерфейс IBookRepository и его реализация BookRepository, инкапсулирующая все операции с книгами. Это упрощает тестирование и замену реализации.

public interface IBookRepository
{
Task<IEnumerable<Book>> GetAllAsync();
Task<Book?> GetByIdAsync(int id);
Task AddAsync(Book book);
Task UpdateAsync(Book book);
Task DeleteAsync(int id);
}

Dependency Injection

ASP.NET Core поддерживает внедрение зависимостей «из коробки». Репозитории регистрируются в контейнере:

// Program.cs
builder.Services.AddDbContext<LibraryContext>(/* ... */);
builder.Services.AddScoped<IBookRepository, BookRepository>();

Контроллер получает репозиторий через конструктор:

public BooksController(IBookRepository repository)
{
_repository = repository;
}

Такой подход делает код более модульным и тестируемым.


Обработка бизнес-логики

Простое хранение данных — лишь часть системы. Библиотека требует реализации бизнес-правил:

  • одна книга не может быть выдана двум читателям одновременно;
  • читатель не может взять больше N книг;
  • при возврате книги обновляется статус выдачи.

Эти правила лучше выносить в отдельные сервисы.

Пример сервиса выдачи

public class LoanService
{
private readonly LibraryContext _context;

public LoanService(LibraryContext context)
{
_context = context;
}

public async Task<Result> IssueBookAsync(int bookId, int readerId)
{
var activeLoan = await _context.Loans
.FirstOrDefaultAsync(l => l.BookId == bookId && !l.IsReturned);

if (activeLoan != null)
return Result.Failure("Книга уже выдана");

var loan = new Loan
{
BookId = bookId,
ReaderId = readerId,
IssueDate = DateTime.UtcNow
};

_context.Loans.Add(loan);
await _context.SaveChangesAsync();
return Result.Success();
}

public async Task<Result> ReturnBookAsync(int loanId)
{
var loan = await _context.Loans.FindAsync(loanId);
if (loan == null || loan.IsReturned)
return Result.Failure("Неверный ID выдачи или книга уже возвращена");

loan.ReturnDate = DateTime.UtcNow;
await _context.SaveChangesAsync();
return Result.Success();
}
}

Здесь Result — пользовательский тип, инкапсулирующий успешный результат или ошибку (паттерн "Railway Oriented Programming").


Валидация и безопасность

Важно проверять входные данные:

  • ISBN должен соответствовать формату;
  • год издания не может быть в будущем;
  • email читателя должен быть валидным.

Для этого используются атрибуты валидации:

public class Book
{
[Required, MaxLength(200)]
public string Title { get; set; } = string.Empty;

[Range(1000, 3000)]
public int Year { get; set; }

[RegularExpression(@"^\d{10}(\d{3})?$")]
public string ISBN { get; set; } = string.Empty;
}

В контроллере проверяется состояние модели:

if (!ModelState.IsValid)
return BadRequest(ModelState);

Также необходимо защищать API от SQL-инъекций, но EF Core автоматически параметризует все запросы, поэтому риск минимален при использовании LINQ.


Тестирование

Проект следует покрывать unit- и integration-тестами. Например, тест выдачи книги:

[Fact]
public async Task IssueBook_WhenBookIsAvailable_ShouldCreateLoan()
{
// Arrange
var context = CreateInMemoryDbContext();
var service = new LoanService(context);

// Act
var result = await service.IssueBookAsync(bookId: 1, readerId: 1);

// Assert
Assert.True(result.IsSuccess);
Assert.Single(context.Loans);
}

Использование in-memory базы данных (UseInMemoryDatabase) ускоряет выполнение тестов.


Расширение функциональности

Систему можно развивать в разных направлениях:

  • добавление аутентификации и ролей (библиотекарь, читатель);
  • экспорт отчётов в PDF или Excel;
  • интеграция с внешними API (например, OpenLibrary для автозаполнения данных по ISBN);
  • фоновые задачи для уведомления о просроченных книгах;
  • кэширование часто запрашиваемых данных;
  • локализация интерфейса.

Дополнительные возможности и расширения

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

Поддержка нескольких экземпляров одной книги

В текущей модели каждая запись Book представляет один экземпляр. Однако в реальной библиотеке может быть несколько копий одного издания. Для поддержки этого сценария вводится новая сущность — BookCopy.

public class BookCopy
{
public int Id { get; set; }
public int BookId { get; set; }
public Book Book { get; set; } = null!;
public string InventoryNumber { get; set; } = Guid.NewGuid().ToString();
public bool IsAvailable { get; set; } = true;

public ICollection<Loan> Loans { get; set; } = new List<Loan>();
}

Теперь выдача книги происходит не по BookId, а по BookCopyId. Это позволяет точно отслеживать физическое состояние каждого экземпляра (например, повреждён ли он), а также управлять доступностью отдельных копий.

Уведомления о просроченных книгах

Библиотека может автоматически отправлять напоминания читателям, не вернувшим книги в срок. Реализация требует:

  • фоновой службы (hosted service);
  • интерфейса уведомлений (INotificationService);
  • логики проверки просрочки.
public class OverdueNotificationService : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<OverdueNotificationService> _logger;

public OverdueNotificationService(IServiceProvider serviceProvider, ILogger<OverdueNotificationService> logger)
{
_serviceProvider = serviceProvider;
_logger = logger;
}

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
using var scope = _serviceProvider.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<LibraryContext>();
var notificationService = scope.ServiceProvider.GetRequiredService<INotificationService>();

var overdueLoans = await context.Loans
.Where(l => !l.IsReturned && l.IssueDate.AddDays(14) < DateTime.UtcNow)
.Include(l => l.Reader)
.ToListAsync(stoppingToken);

foreach (var loan in overdueLoans)
{
await notificationService.SendEmailAsync(
loan.Reader.Email,
"Напоминание о возврате книги",
$"Уважаемый {loan.Reader.FullName}, пожалуйста, верните книгу «{loan.Book.Title}»."
);
}

await Task.Delay(TimeSpan.FromHours(24), stoppingToken);
}
}
}

Регистрация фоновой службы в Program.cs:

builder.Services.AddHostedService<OverdueNotificationService>();

Такой подход демонстрирует применение шаблона Background Service, который является стандартным способом выполнения периодических задач в ASP.NET Core.

Поиск по ISBN и автору через API

Для удобства пользователей добавляются специализированные методы поиска:

[HttpGet("search")]
public async Task<ActionResult<IEnumerable<Book>>> SearchBooks(
[FromQuery] string? title = null,
[FromQuery] string? author = null,
[FromQuery] string? isbn = null)
{
var query = _context.Books.AsQueryable();

if (!string.IsNullOrEmpty(title))
query = query.Where(b => b.Title.Contains(title));

if (!string.IsNullOrEmpty(author))
query = query.Where(b => b.Author.FirstName.Contains(author) || b.Author.LastName.Contains(author));

if (!string.IsNullOrEmpty(isbn))
query = query.Where(b => b.ISBN == isbn);

return await query
.Include(b => b.Author)
.Include(b => b.Genre)
.ToListAsync();
}

Этот эндпоинт позволяет гибко комбинировать параметры поиска и возвращает только релевантные результаты.

Локализация и интернационализация

Если система предназначена для международного использования, следует реализовать поддержку нескольких языков. ASP.NET Core предоставляет встроенные механизмы локализации через:

  • файлы ресурсов (.resx);
  • middleware RequestLocalization;
  • атрибуты [Display(Name = "...")] с ключами.

Пример настройки:

// Program.cs
var supportedCultures = new[] { "ru", "en" };
var localizationOptions = new RequestLocalizationOptions()
.SetDefaultCulture(supportedCultures[0])
.AddSupportedCultures(supportedCultures)
.AddSupportedUICultures(supportedCultures);

app.UseRequestLocalization(localizationOptions);

После этого все строки интерфейса (например, названия полей в формах) могут загружаться из соответствующих .resx-файлов.


Безопасность и производительность

Защита от overposting

При использовании автоматической привязки модели ([FromBody] Book book) существует риск overposting — когда клиент отправляет поля, которые не должны обновляться (например, Id или AuthorId). Решение — использовать DTO (Данные Transfer Object).

public class CreateBookDto
{
public string Title { get; set; } = string.Empty;
public int Year { get; set; }
public string ISBN { get; set; } = string.Empty;
public int AuthorId { get; set; }
public int GenreId { get; set; }
}

Контроллер работает только с этим DTO, а маппинг в сущность Book выполняется явно:

var book = new Book
{
Title = dto.Title,
Year = dto.Year,
ISBN = dto.ISBN,
AuthorId = dto.AuthorId,
GenreId = dto.GenreId
};

Это исключает возможность несанкционированного изменения внутренних полей.

Кэширование часто запрашиваемых данных

Если каталог книг редко изменяется, но часто читается, имеет смысл кэшировать результаты. ASP.NET Core предлагает несколько уровней кэширования:

  • In-memory cache — для одного сервера;
  • Distributed cache (Redis, SQL Server) — для масштабируемых систем.

Пример использования in-memory кэша:

[HttpGet]
public async Task<ActionResult<IEnumerable<Book>>> GetBooks()
{
var cacheKey = "all-books";
if (_cache.TryGetValue(cacheKey, out List<Book>? cachedBooks))
{
return cachedBooks;
}

var books = await _context.Books
.Include(b => b.Author)
.Include(b => b.Genre)
.ToListAsync();

var cacheEntryOptions = new MemoryCacheEntryOptions()
.SetAbsoluteExpiration(TimeSpan.FromMinutes(10));

_cache.Set(cacheKey, books, cacheEntryOptions);
return books;
}

Регистрация кэша:

builder.Services.AddMemoryCache();

Кэширование значительно снижает нагрузку на базу данных при высокой частоте запросов.


Развертывание и мониторинг

Конфигурация через appsettings.json

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

{
"ConnectionStrings": {
"DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=LibraryDb;Trusted_Connection=true;"
},
"LibrarySettings": {
"LoanPeriodDays": 14,
"MaxBooksPerReader": 5
}
}

Использование в коде:

var loanPeriod = configuration.GetValue<int>("LibrarySettings:LoanPeriodDays");

Это позволяет легко адаптировать приложение под разные окружения (разработка, тестирование, продакшн).

Логирование

ASP.NET Core интегрирован с системой логирования. В контроллерах можно внедрять ILogger<T>:

public BooksController(LibraryContext context, ILogger<BooksController> logger)
{
_context = context;
_logger = logger;
}

[HttpPost]
public async Task<ActionResult<Book>> CreateBook(Book book)
{
_logger.LogInformation("Creating a new book: {Title}", book.Title);
// ...
}

Логи могут отправляться в файлы, консоль, или внешние системы (например, Elasticsearch + Kibana).

Health Checks

Для мониторинга работоспособности приложения ASP.NET Core предоставляет механизм health checks:

// Program.cs
builder.Services.AddHealthChecks()
.AddDbContextCheck<LibraryContext>();

app.MapHealthChecks("/health");

Запрос к /health вернёт Healthy, если база данных доступна, и Unhealthy в противном случае. Это особенно полезно при использовании оркестраторов вроде Kubernetes.


См. также

Другие статьи этого же раздела в боковом меню (как на странице «О разделе»).