Эта статья является переводом материала «Immutable architecture».
В этом посте автор оригинала хотел бы показать общий подход к внедрению иммутабельности в кодовую базу на архитектурном уровне.
Иммутабельность, состояние и побочные эффекты
Прежде чем мы начнем, давайте определим термины. Скорее всего, вы уже сталкивались с ними.
Прежде всего, термин «Иммутабельность», применяемый к структуре данных, такой как класс, означает, что объекты этого класса не могут изменяться в течение их жизненного цикла. Существует несколько типов иммутабельности со своими нюансами, но это не являются для нас существенным. По большей части мы можем сказать, что класс является либо изменяемым, что означает, что его экземпляры могут изменяться тем или иным образом, либо иммутабельным (неизменяемым), что означает, что как только мы создадим экземпляр этого класса, мы не сможем изменить его позже.
Еще один важный термин - «Состояние». Он указывает на данные, которые меняются со временем. Важно понимать, что состояние - это непросто данные, составляющие класс, состояние - это подмножество этих данных, которое изменяется в течение своего жизненного цикла. Неизменяемый класс не имеет состояния в этом смысле, только изменяемые классы.
Побочный эффект - это изменение, которое происходит в каком-то состоянии. Обычно мы говорим, что операция оставляет побочный эффект, если она изменяет экземпляр класса, обновляет файл на диске или сохраняет некоторые данные в БД.
Вот пример, который поможет связать все три концепции воедино:
public class UserProfile // Mutable, contains state
{
private User _user;
private string _address;
public void UpdateUser(int userId, string name) // Leaves a side effect
{
_user = new User(userId, name);
}
}
public class User // Immutable, doesn't contain state
{
public int Id { get; }
public string Name { get; }
public User(int id, string name)
{
Id = id;
Name = name;
}
}
Класс User иммутабельный: все его свойства определены как доступные только для чтения. Из-за этого единственный способ для метода UpdateUser обновить пользовательское поле - создать новый экземпляр user и заменить им старый. Сам класс User не содержит состояния, в отличие от класса UserProfile. Можно сказать, что метод UpdateUser оставляет побочный эффект, изменяя состояние объекта.
Иммутабельная архитектура
Методы с побочными эффектами затрудняют осмысление вашего кода из-за нечестности, которую они в него вносят. Однако, даже если вы попытаетесь как можно больше навязать иммутабельность, вы не сможете полностью избежать их. Программирование системы, которая не меняется, было бы непрактичным. В конце концов, побочные эффекты - это то, что должны делать программы: они выполняют некоторые вычисления и так или иначе меняют свое состояние.
Итак, как бороться с побочными эффектами? Один из методов, который помогает нам в этом, - отделение логики предметной области приложения от логики применения побочных эффектов к состоянию системы.
Давайте подробнее остановимся на этом. Часто при обработке бизнес-транзакции наш код несколько раз изменяет некоторые данные с момента поступления запроса в приложение до момента его полной обработки. Это довольно распространенный паттерн, особенно в мире объектно-ориентированных языков программирования. Вместо этого мы можем изолировать код, который генерирует некоторые артефакты, от кода, который использует их для изменения состояния системы:
Затем первую часть можно сделать иммутабельной, избавив ее от любых побочных эффектов.
Такой подход дает нам два преимущества:
-
Мы упрощаем логику предметной области, поскольку весь код в ней может быть написан функциональным способом с использованием математических функций.
-
Такую логику становится очень легко проверить. Результаты всех операций четко определены в выходных данных методов, поэтому модульное тестирование сводится к передаче входных данных методу и проверке его выходных данных.
Такая архитектура является неотъемлемой частью того, как работает большинство кодовых баз, написанных на функциональных языках. В подавляющем большинстве случаев у вас есть неизменяемое ядро, которое принимает входные данные и содержит всю логику для обработки этих входных данных. Это ядро написано с использованием математических функций и для каждого запроса возвращает соответствующие артефакты без изменения состояния системы:
И также есть тонкая изменяемая оболочка, которая предоставляет входные данные для иммутабельного ядра и принимает полученные артефакты от него, а затем сохраняет их в БД. Эта оболочка вступает в игру, когда ядро завершает работу; она не содержит никакой бизнес-логики.
В целом изменяемая оболочка должна быть как можно более «тупой». В идеале, попробуйте довести ее до цикломатической сложности 1. Другими словами, постарайтесь не вводить в нее какие-либо условные операторы (операторы if).
Этот подход также делает естественным объединение различных подсистем вместе:
Пример иммутабельной архитектуры
Примером может служить класс диспетчера аудита, который отслеживает всех посетителей некоторой организации. Он использует простые текстовые файлы в качестве основного хранилища:
Класс может добавлять новые записи в файл журнала и удалять информацию о существующих. Он также должен учитывать максимальное количество записей, которые может содержать один файл, и создавать новый файл в случае превышения этого предела.
Простая реализация будет выглядеть так. Два метода, которые принимают некоторые параметры и непосредственно считывают, и изменяют файлы:
public class AuditManager
{
private readonly int _maxEntriesPerFile;
public AuditManager(int maxEntriesPerFile)
{
_maxEntriesPerFile = maxEntriesPerFile;
}
public void AddRecord(string currentFile, string visitorName, DateTime timeOfVisit)
{
string[] lines = File.ReadAllLines(currentFile);
if (lines.Length < _maxEntriesPerFile)
{
// Add a record to the existing log file
}
else
{
// Create a new log file
}
}
public void RemoveMentionsAbout(string visitorName, string directoryName)
{
foreach (string fileName in Directory.GetFiles(directoryName))
{
// Remove records about the visitor from all files in the directory
}
}
}
Иммутабельные версии этих методов, с другой стороны, должны четко определять их входы и выходы:
public class AuditManager
{
public FileAction AddRecord(
FileContent currentFile, string visitorName, DateTime timeOfVisit)
{
// Return a new FileAction with the Update or Create action type
}
public IReadOnlyList<FileAction> RemoveMentionsAbout(
string visitorName, FileContent[] directoryFiles)
{
// Exercise each of the directory files and return a collection
// of actions that needs to be performed
}
}
public struct FileAction
{
public readonly string FileName;
public readonly string[] Content;
public readonly ActionType Type;
}
public enum ActionType
{
Create,
Update,
Delete
}
public struct FileContent
{
public readonly string FileName;
public readonly string[] Content;
}
Здесь класс FileContent полностью кодирует всю информацию, которая нужна нашей логике домена для правильной работы. FileAction, с другой стороны, содержит полный набор инструкций относительно того, что делать с файлами в файловой системе. Они инструктируют внешний мир о действиях, которые необходимо предпринять.
AuditManager не выполняет эти действия сам по себе, для этого нам нужно ввести класс Persister:
public class Persister
{
public FileContent ReadFile(string fileName)
{
return new FileContent(fileName, File.ReadAllLines(fileName));
}
public void ApplyChange(FileAction action)
{
switch (action.Type)
{
caseActionType.Create:
caseActionType.Update:
File.WriteAllLines(action.FileName, action.Content);
return ;
caseActionType.Delete:
File.Delete(action.FileName);
return ;
default:
throw new InvalidOperationException();
}
}
}
Обратите внимание, что Persister очень прост, он просто считывает данные из файловой системы и записывает в нее изменения.
Наконец, служба приложения должна склеить их вместе
public class ApplicationService
{
private readonly string _directoryName;
private readonly AuditManager_auditManager;
private readonly Persister_persister;
public ApplicationService(string directoryName)
{
_directoryName = directoryName;
_auditManager = new AuditManager(10);
_persister = new Persister();
}
public void AddRecord(string visitorName, DateTime timeOfVisit)
{
FileInfo fileInfo = new DirectoryInfo(_directoryName)
.GetFiles()
.OrderByDescending(x => x.LastWriteTime)
.First();
FileContent file = _persister.ReadFile(fileInfo.Name);
FileAction action = _auditManager.AddRecord(file, visitorName, timeOfVisit);
_persister.ApplyChange(action);
}
}
Эта иммутабельная версия состоит из двух частей: иммутабельного ядра, содержащего все бизнес-правила, и изменяемой оболочки, которая работает с данными, генерируемые ядром. Такое разделение помогает нам полностью избавиться от побочных эффектов в классе AuditManager. Все методы в этом классе являются математическими функциями с четко определенными входными и выходными данными. Persister работает как поставщик входных данных для менеджера, а также как потребитель генерируемых им данных. Служба приложений склеивает их вместе.
Существует две практики, когда дело доходит до создания иммутабельной архитектуры. Во-первых, убедитесь, что вы отделили код, изменяющий состояние приложения, от кода, содержащего бизнес-логику. Сделайте изменяемую оболочку настолько тупой, насколько это возможно. Во-вторых, применяйте эти побочные эффекты как можно ближе к завершению бизнес-транзакции.
По большей части именно так функциональные языки справляются с побочными эффектами. Самая большая и самая важная часть системы - ее логика предметной области - обычно делается иммутабелной, в то время как код, вызывающий побочные эффекты, прост и понятен.
Автор: Аркадий