Способ достижения этого - осознать, что единая точка правды сопровождается некоторыми компромиссами. Вы явно хотите достичь этого функциональным способом, что похвально, но, очевидно, должна произойти некоторая мутация, чтобы изменить единую точку правды, чтобы представить новое состояние. Ключевой вопрос заключается в том, как сделать это как можно более надежным, но при этом использовать функциональный подход.
Давайте сначала начнем с точки зрения истины.
Любая точка правды в многопоточном приложении будет иметь проблемы с синхронизацией. Хороший способ обойти это - использовать блокировку или даже систему STM . Для этого я буду использовать систему STM из language-ext (потому что она свободна от блокировки, это функциональная структура и имеет много других необходимых вещей: структурные типы записей, неизменные коллекции, et c.)
Отказ от ответственности: я являюсь автором language-ext
Во-первых, решение иметь коллекции учеников и учителей в пределах типы проблем c. Не столько с точки зрения управления состоянием, сколько с точки зрения логики c. Гораздо лучше использовать подход с реляционными БД и переместить отношения за пределы типов:
Итак, начнем с создания класса static Database
. Это static
, чтобы указать, что это единственная точка правды, но вы можете сделать в классе экземпляра, если хотите:
public static class Database
{
public static readonly Ref<Set<Student>> Students;
public static readonly Ref<Set<Teacher>> Teachers;
public static readonly Ref<Map<Teacher, Set<Student>>> TeacherStudents;
public static readonly Ref<Map<Student, Set<Teacher>>> StudentTeachers;
static Database()
{
TeacherStudents = Ref(Map<Teacher, Set<Student>>());
StudentTeachers = Ref(Map<Student, Set<Teacher>>());
Students = Ref(Set<Student>());
Teachers = Ref(Set<Teacher>());
}
...
Это использует:
Ref
это специальный тип для управления системой STM Map
, который похож на Dictionary
, но неизменяемый и имеет множество других полезных функций Set
который похож на SortedSet
, но неизменен и имеет множество других полезных функций
Итак, вы можете видеть, что есть два набора, один для Student
, один для Teacher
. Это фактические записи, а затем TeacherStudents
и StudentTeachers
, которые отображаются в наборы. Это отношения.
Ваши типы Student
и Teacher
теперь выглядят так:
[Record]
public partial class Student
{
public readonly string Name;
public readonly int Age;
}
[Record]
public partial class Teacher
{
public readonly string Name;
public readonly int Age;
}
При этом используется Запись языка language-ext , которая создаст типы со структурным равенством, упорядочением, кодом ha sh, функциями With
(для неизменяемого преобразования), конструкторами по умолчанию и т. д. c.
Теперь мы добавим функцию для добавления учителя в базу данных:
public static Unit AddTeacher(Teacher teacher) =>
sync(() =>
{
Teachers.Swap(teachers => teachers.Add(teacher));
TeacherStudents.Swap(teachers => teachers.Add(teacher, Empty));
});
При этом используется функция sync
в language-ext для запуска транзакции atomi c в система СТМ. Звонки на Swap
будут управлять изменением значений. Преимущество использования системы STM заключается в том, что любые параллельные потоки, одновременно изменяющие Database
, будут проверять наличие коллизий и будут перезапускать транзакцию в случае сбоя. Это позволяет создать более надежную и надежную систему обновлений: либо все работает, либо ничего не происходит.
Можно надеяться, что вы добавите новый Teacher
к Teachers
и Empty
набору Student
добавлено к отношениям TeacherStudents
.
Мы можем сделать аналогичную функцию для AddStudent
public static Unit AddStudent(Student student) =>
sync(() =>
{
Students.Swap(students => students.Add(student));
StudentTeachers.Swap(students => students.Add(student, Empty)); // no teachers yet
});
Должно быть очевидно, что это то же самое, но для студентов.
Далее, мы назначим ученика учителю:
public static Unit AssignStudentToTeacher(Student student, Teacher teacher) =>
sync(() =>
{
// Add the teacher to the student
StudentTeachers.Swap(students => students.SetItem(student, Some: ts => ts.AddOrUpdate(teacher)));
// Add the student to the teacher
TeacherStudents.Swap(teachers => teachers.SetItem(teacher, Some: ss => ss.AddOrUpdate(student)));
});
Это просто обновляет отношения и оставляет типы записей в покое. Это может показаться немного пугающим, но необходимость использовать неизменяемые типы здесь означает, что нам нужно копаться в наборе, чтобы добавить значение.
Отмена присваивания является двойственной из вышеперечисленных, где AddOrUpdate
становится Remove
:
public static Unit UnAssignStudentFromTeacher(Student student, Teacher teacher) =>
sync(() =>
{
// Add the teacher to the student
StudentTeachers.Swap(students => students.SetItem(student, Some: ts => ts.Remove(teacher)));
// Add the student to the teacher
TeacherStudents.Swap(teachers => teachers.SetItem(teacher, Some: ss => ss.Remove(student)));
});
Итак, добавление и назначение, давайте теперь предоставим функциональность для удаления учителей и учеников.
public static Unit RemoveTeacher(Teacher teacher) =>
sync(() => {
Teachers.Swap(teachers => teachers.Remove(teacher));
TeacherStudents.Swap(teachers => teachers.Remove(teacher));
StudentTeachers.Swap(students => students.Map(ts => ts.Remove(teacher)));
});
public static Unit RemoveStudent(Student student) =>
sync(() => {
Students.Swap(students => students.Remove(student));
StudentTeachers.Swap(students => students.Remove(student));
TeacherStudents.Swap(teachers => teachers.Map(ss => ss.Remove(student)));
});
Обратите внимание, что не только удаляется тип записи, но и отношения. Удалить немного дороже, чем добавить и запросить, но это справедливая сделка.
Теперь мы можем выполнять функции поиска, которые бы получили наиболее распространенное использование в реальном мире и были бы сверхбыстрыми:
public static Option<Teacher> FindTeacher(string name, int age) =>
Teachers.Value.Find(new Teacher(name, age));
public static Option<Student> FindStudent(string name, int age) =>
Students.Value.Find(new Student(name, age));
public static Set<Student> FindTeacherStudents(Teacher teacher) =>
TeacherStudents.Value
.Find(teacher)
.IfNone(Empty);
public static Set<Teacher> FindStudentTeachers(Student student) =>
StudentTeachers.Value
.Find(student)
.IfNone(Empty);
И еще одна последняя функция, помогающая найти призраков учеников, у которых нет учителя:
public static Set<Student> FindGhostStudents() =>
toSet(StudentTeachers.Value.Filter(teachers => teachers.IsEmpty).Keys);
Это простой вопрос, он просто находит все отношения без учителей ,
Вот полный исходный текст в форме гисти ; Существуют и другие методы, которые вы можете использовать, например, использовать монаду STM, монаду ввода-вывода или монаду Reader для захвата транзакционного поведения, а затем применять его контролируемым образом, но это, вероятно, выходит за рамки этого вопроса.
Некоторые заметки о подходе к модели актера, о котором вы упомянули
Я часто использую модель актора (и разработал echo-process , который использует этот подход), это, безусловно, очень мощный, и я бы рекомендовал использовать модель актора для проектирования любой системы, особенно если вы выбираете систему, которая имеет иерархию надзора, она может дать ясность, структуру и контроль.
Иногда актерская система может мешать , хотя (с такими системами), это зависит от того, как далеко вы хотите ее пройти. Актеры являются однопоточными, так что это становится узким местом (что также является причиной того, что актеры настолько полезны, что их легко рассуждать).
Единственная многопоточность актеров решается с помощью дочерних актеров, которым откладывается работа. Так, например, если у вас есть актер, который хранит ваше состояние, например, типа Database
, то вы можете создать дочерних актеров, которые пишут, и дочерних актеров, которые читают, это действительно зависит от того, сколько работы выполняет актер. собираюсь сделать. Однако это связано с дополнительной сложностью. У вас может быть один актер записи (который выполняет дорогие вещи), который затем отправляет свое состояние обратно родителю, когда он обновляется для читателей, чтобы затем использовать.
Я покажу вам, как выглядит пример STM с моделью актера, сначала я сделаю рефакторинг типа Database
, чтобы он стал полностью неизменяемым значением состояния:
[Record]
public partial class Database
{
public static readonly Database Empty = new Database(default, default, default, default);
public readonly Map<Teacher, Set<Student>> TeacherStudents;
public readonly Map<Student, Set<Teacher>> StudentTeachers;
public readonly Set<Student> Students;
public readonly Set<Teacher> Teachers;
public Database AddTeacher(Teacher teacher) =>
With(Teachers: Teachers.Add(teacher),
TeacherStudents: TeacherStudents.Add(teacher, default));
public Database AddStudent(Student student) =>
With(Students: Students.Add(student),
StudentTeachers: StudentTeachers.Add(student, default));
public Database AssignStudentToTeacher(Student student, Teacher teacher) =>
With(StudentTeachers: StudentTeachers.SetItem(student, Some: ts => ts.AddOrUpdate(teacher)),
TeacherStudents: TeacherStudents.SetItem(teacher, Some: ss => ss.AddOrUpdate(student)));
public Database UnAssignStudentFromTeacher(Student student, Teacher teacher) =>
With(StudentTeachers: StudentTeachers.SetItem(student, Some: ts => ts.Remove(teacher)),
TeacherStudents: TeacherStudents.SetItem(teacher, Some: ss => ss.Remove(student)));
public Database RemoveTeacher(Teacher teacher) =>
With(Teachers: Teachers.Remove(teacher),
TeacherStudents: TeacherStudents.Remove(teacher),
StudentTeachers: StudentTeachers.Map(ts => ts.Remove(teacher)));
public Database RemoveStudent(Student student) =>
With(Students: Students.Remove(student),
StudentTeachers: StudentTeachers.Remove(student),
TeacherStudents: TeacherStudents.Map(ss => ss.Remove(student)));
public Option<Teacher> FindTeacher(string name, int age) =>
Teachers.Find(new Teacher(name, age));
public Option<Student> FindStudent(string name, int age) =>
Students.Find(new Student(name, age));
public Set<Student> FindTeacherStudents(Teacher teacher) =>
TeacherStudents
.Find(teacher)
.IfNone(Set<Student>());
public Set<Teacher> FindStudentTeachers(Student student) =>
StudentTeachers
.Find(student)
.IfNone(Set<Teacher>());
public Set<Student> FindGhostStudents() =>
toSet(StudentTeachers.Filter(teachers => teachers.IsEmpty).Keys);
}
I Мы снова использовали Record
code-gen, чтобы предоставить функцию With
, чтобы упростить преобразование.
Затем я буду использовать [Union]
дискриминируемый код-gen для создания числа типов сообщений, которые могут действовать как операции, которые будет выполнять актер. Это экономит много печатания!
[Union]
public interface DatabaseMsg
{
DatabaseMsg AddTeacher(Teacher teacher);
DatabaseMsg AddStudent(Student student);
DatabaseMsg AssignStudentToTeacher(Student student, Teacher teacher);
DatabaseMsg UnAssignStudentFromTeacher(Student student, Teacher teacher);
DatabaseMsg RemoveTeacher(Teacher teacher);
DatabaseMsg RemoveStudent(Student student);
DatabaseMsg FindTeacher(string name, int age);
DatabaseMsg FindStudent(string name, int age);
DatabaseMsg FindTeacherStudents(Teacher teacher);
DatabaseMsg FindStudentTeachers(Student student);
DatabaseMsg FindGhostStudents();
}
Далее я создам самого актера. Он состоит из двух функций: Setup
и Inbox
, которые должны быть достаточно понятны:
public static class DatabaseActor
{
public static Database Setup() =>
Database.Empty;
public static Database Inbox(Database state, DatabaseMsg msg) =>
msg switch
{
AddTeacher (var teacher) => state.AddTeacher(teacher),
AddStudent (var student) => state.AddStudent(student),
AssignStudentToTeacher (var student, var teacher) => state.AssignStudentToTeacher(student, teacher),
UnAssignStudentFromTeacher (var student, var teacher) => state.UnAssignStudentFromTeacher(student, teacher),
RemoveTeacher (var teacher) => state.RemoveTeacher(teacher),
RemoveStudent (var student) => state.RemoveStudent(student),
FindTeacher (var name, var age) => constant(state, reply(state.FindTeacher(name, age))),
FindStudent (var name, var age) => constant(state, reply(state.FindStudent(name, age))),
FindTeacherStudents (var teacher) => constant(state, reply(state.FindTeacherStudents(teacher))),
FindStudentTeachers (var student) => constant(state, reply(state.FindStudentTeachers(student))),
FindGhostStudents _ => constant(state, reply(state.FindGhostStudents())),
_ => state
};
}
В эхо-процессе Inbox
актера работает как сгиб в функциональном программировании. Fold обычно имеет вид:
fold :: (S -> A -> S) -> S -> [A] -> S
т.е. есть функция, которая принимает S
и A
, которые возвращают новое S
(входящие), начальное состояние S
( setup), и последовательность значений [A]
до сгибается над . В результате получается новое состояние S
.
Последовательность значений A
в нашем случае представляет собой поток сообщений. И поэтому актер может рассматриваться как складка над потоком сообщений. Это очень мощная концепция.
Чтобы настроить систему акторов и вызвать DatabaseActor
, мы вызываем:
ProcessConfig.initialise(); // call once only
var db = spawn<Database, DatabaseMsg>("db", DatabaseActor.Setup, DatabaseActor.Inbox);
Затем мы можем сказать актеру, что мы хотим, чтобы он настроил базу данных. :
tell(db, AddStudent.New(s1));
tell(db, AddStudent.New(s2));
tell(db, AddStudent.New(s3));
tell(db, AddTeacher.New(t1));
tell(db, AddTeacher.New(t2));
tell(db, AssignStudentToTeacher.New(s1, t1));
tell(db, AssignStudentToTeacher.New(s2, t1));
tell(db, AssignStudentToTeacher.New(s3, t2));
И спросите его о том, что там:
ask<Set<Teacher>>(db, FindStudentTeachers.New(s1))
.Iter(Console.WriteLine);
Вот полный исходный текст в форме гист
Это очень хорошая модель для растущей архитектуры, потому что вы инкапсулируете состояние и можете обеспечить чистые функциональные преобразования над состоянием, не беспокоясь о беспорядке того, как вся мутация происходит за кулисами. Это также создает абстракцию, которая означает, что субъект может находиться на другом сервере, или это может быть маршрутизатор для 10 других участников, которые выполняют балансировку нагрузки, и т. Д. c. У них также, как правило, хорошие системы для обработки ошибок.
Реальные проблемы, с которыми вы столкнетесь:
Обмен сообщениями не так быстр, как прямые вызовы методов - это может быть не такой большой проблемой, если вы ищете действительно масштабируемую систему, потому что вы можете выполнить истинную балансировку нагрузки, и небольшая задержка для сообщения может быть тем, что вы в конечном итоге получите. Но для высоких систем вы можете столкнуться с ограничениями.
Изучение архитектуры иерархии акторов - это немного художественная форма, но в то же время она предлагает действительно мощный механизм для правильного управления доступом к состоянию, будь то база данных или значение состояния в памяти. Приведенный выше пример может очень хорошо общаться с реальной базой данных, но также иметь кеш в своем значении состояния. И если актер является единственным путем к реальной базе данных, то у вас есть исключительная система кэширования.
Актеры независимы - это может иногда усложнять логи c, когда вы пытаетесь распределить нагрузку между несколькими субъектами. В приведенном выше примере, если у вас есть дочерние акторы, которые пишут, то что-то должно либо слиться, либо согласовать движение состояния с родителем, чтобы оно стало «живым» для читателей, а тем временем читатели читают старые штат. Это не должно быть проблемой в большинстве случаев, потому что все системы работают со слегка старым состоянием (не может справиться со скоростью света), и субъект будет обеспечивать согласованность состояний, что жизненно важно. НО, в некоторых обстоятельствах, что в конечном итоге непротиворечивая модель не достаточно хороша, так что будьте осторожны.
Использование актерской модели, вероятно, было одной из самых больших побед для моей команды (15 летнее приложение (10 миллионов + строк кода), оно помогло нам провести рефакторинг, балансировку нагрузки и получить когнитивную ясность на очень сложной базе кода. Мы используем echo-process, потому что я хотел, чтобы с ним работал более функциональный API, но я не особенно поддерживаю его так, как я работаю с language-ext, поэтому я определенно посмотрю, что там сейчас, по мере продвижения поля много за последние 5 лет.
Immutablity
Кстати, я согласен со всеми вашими рассуждениями (в комментариях к вашему исходному сообщению) о том, почему Вы хотите смоделировать свой домен с полностью неизменяемыми типами. В сообществе C# вы увидите много хулителей, которые "всегда так делали" или что-то в этом роде.
Неизменность и чистая функциональность дадут вам сверхспособности как разработчика, и вы совершенно точно захотите узнать, как справиться с этим беспорядочным битом в изменчивом root. Если вместо этого вы рассматриваете все root -mutable-links как поток значений типа World
, тогда вы можете начать видеть более абстрактное представление этого мутанта root: ваша программа сгибается над потоком действия, которые он выполняет. Его начальное значение состояния является текущим состоянием мира, и действие возвращает новый World
. Тогда нет необходимости в мутации. Это часто представляется в функциональных приложениях, использующих рекурсию:
public static World RunApplication(World state, Seq<WorldActions> actions) =>
actions.IsEmpty
? state
: RunApplication(RunAction(state, actions.Head), actions.Tail);
Если каждая функция принимает World
и возвращает новый World
, тогда вы получаете представление времени.
На самом деле это довольно сложно сделать, потому что очевидно, что вы не можете зафиксировать состояние всех файлов, всех строк базы данных и т. Д. c. прежде чем запустить приложение. Во многих отношениях, хотя это то, что система акторов пытается сделать для каждого актера небольшим образом, она создает мини-мир, когда запускается, а затем управляет течением времени (изменением состояния) для мира. Мне нравится эта ментальная модель, она кажется правильной и дает идентичность множеству значений, которые представляют изменение во времени, а не только индивидуальную ссылку на состояние, которое вы держите прямо сейчас .