
Я продолжаю изучать Rust. Я еще много не знаю, поэтому делаю много ошибок. В прошлый раз я попробовал сделать игру Змейка. Попробовал циклы, коллекции, работу с 3D Three.rs. Узнал про ggez и Amethyst. В этот раз я попробовал сделать клиент и сервер для чата. Для GUI использовал Azul. Так же смотрел Conrod, Yew и Orbtk. Попробовал многопоточность, каналы и работу с сетью. Учел ошибки прошлой статьи и постарался сделать эту более развернутой. За подробностями добро пожаловать под кат.
→ Исходники, работает на Windows 10 x64
Для сетевого взаимодействия я использовал UDP потому, что хочу сделать свой следующий проект с использованием этого протокола и хотел потренировать с ним тут. Для GUI по-быстрому погуглил проекты на Rust, посмотрел базовые примеры для них и меня зацепил Azul потому, что там используется Document Object Model и движок стилей похожий на CSS, а я длительное время занимался веб-разработкой. В общем, выбирал Фреймворк субъективно. Он, пока что, в глубокой альфе: не работает скроллинг, не работает фокус ввода, нет курсора. Для того чтобы ввести данные в текстовое поле нужно навести мышку на него и прям над ним держать его пока печатаете. Подробнее ...
Собственно, большая часть статьи — это комментарии к коду.
Azul
GUI фреймворк, использующий функциональный стиль, DOM, CSS. Ваш интерфейс состоит из корневого элемента, у которого множество потомков, у которых могут быть свои потомки как, например, в HTML и XML. Весь интерфейс создается на основе данных из одной единственной DataModel. В ней передаются в представление вообще все данные. Если кто знаком с ASP.NET, то Azul и его DataModel это как Razor и его ViewModel. Как и в HTML можно привязать функции к событиям DOM элементов. Можно стилизовать элементы с помощью CSS фреймворка. Это не тот же самый CSS что в HTML, но очень на него похож. Так же есть двухсторонняя привязка как в Angular или MVVM в WPF, UWP. Подробнее на сайте.
Краткий обзор остальных фреймворков
- Orbtk — Почти что то же самое, что и Azul и так же в глубокой альфе
- Conrod — Видео Можно создавать кросс платформенные десктопные приложения.
- Yew — WebAssembly и похож на React. Для веб разработки.
Клиент
Структура, в которой группируются вспомогательные функции для чтения и записи в сокет
struct ChatService {} impl ChatService { //1 fn read_data(socket: &Option<UdpSocket>) -> Option<String> { //2 let mut buf = [0u8; 4096]; match socket { Some(s) => { //3 match s.recv(&mut buf) { //4 Ok(count) => Some(String::from_utf8(buf[..count].into()) .expect("can't parse to String")), Err(e) => { //5 println!("Error {}", e); None } } } _ => None, } } //6 fn send_to_socket(message: String, socket: &Option<UdpSocket>) { match socket { //7 Some(s) => { s.send(message.as_bytes()).expect("can't send"); } _ => return, } } }
- Читаем денные из сокета
- Буфер для данных, которые будем считывать из сокета.
- Блокирующий вызов. Здесь поток выполнения останавливается до тех пор, пока не будут считанны данные или произойдет таймаут.
- Получаем строку из массива байт в кодировке UTF8.
- Сюда мы попадаем, если соединение оборвалось по таймауту или произошла другая ошибка.
- Отправляет строку в сокет.
- Преобразуем строку в байты в кодировке UTF8 и отправляем данные в сокет. Запись данных в сокет не блокирующая, т.е. поток выполнения продолжит свою работу. Если отправить данные не удалось, то прерываем работу программы с сообщением «can't send».
Структура, которая группирует функции для обработки событий, поступающих от пользователя, и изменения нашей DataModel
struct Controller {} //1 const TIMEOUT_IN_MILLIS: u64 = 2000; impl Controller { //2 fn send_pressed(app_state: &mut azul::prelude::AppState<ChatDataModel>, _event: azul::prelude::WindowEvent<ChatDataModel>) -> azul::prelude::UpdateScreen { //3 let data = app_state.data.lock().unwrap(); //4 let message = data.messaging_model.text_input_state.text.clone(); data.messaging_model.text_input_state.text = "".into(); //5 ChatService::send_to_socket(message, &data.messaging_model.socket); //6 azul::prelude::UpdateScreen::Redraw } //7 fn login_pressed(app_state: &mut azul::prelude::AppState<ChatDataModel>, _event: azul::prelude::WindowEvent<ChatDataModel>) -> azul::prelude::UpdateScreen { //8 use std::time::Duration; //9 if let Some(ref _s) = app_state.data.clone().lock().unwrap().messaging_model.socket { return azul::prelude::UpdateScreen::DontRedraw; } //10 app_state.add_task(Controller::read_from_socket_async, &[]); //11 app_state.add_daemon(azul::prelude::Daemon::unique(azul::prelude::DaemonCallback(Controller::redraw_daemon))); //12 let mut data = app_state.data.lock().unwrap(); //13 let local_address = format!("127.0.0.1:{}", data.login_model.port_input.text.clone().trim()); //14 let socket = UdpSocket::bind(&local_address) .expect(format!("can't bind socket to {}", local_address).as_str()); //15 let remote_address = data.login_model.address_input.text.clone().trim().to_string(); //16 socket.connect(&remote_address) .expect(format!("can't connect to {}", &remote_address).as_str()); //17 socket.set_read_timeout(Some(Duration::from_millis(TIMEOUT_IN_MILLIS))) .expect("can't set time out to read"); // 18 data.logged_in = true; // 19 data.messaging_model.socket = Option::Some(socket); //20 azul::prelude::UpdateScreen::Redraw } //21 fn read_from_socket_async(app_data: Arc<Mutex<ChatDataModel>>, _: Arc<()>) { //22 let socket = Controller::get_socket(app_data.clone()); loop { //23 if let Some(message) = ChatService::read_data(&socket) { //24 app_data.modify(|state| { //25 state.messaging_model.has_new_message = true; //26 state.messaging_model.messages.push(message); }); } } } //27 fn redraw_daemon(state: &mut ChatDataModel, _repres: &mut azul::prelude::Apprepres) -> (azul::prelude::UpdateScreen, azul::prelude::TerminateDaemon) { //28 if state.messaging_model.has_new_message { state.messaging_model.has_new_message = false; (azul::prelude::UpdateScreen::Redraw, azul::prelude::TerminateDaemon::Continue) } else { (azul::prelude::UpdateScreen::DontRedraw, azul::prelude::TerminateDaemon::Continue) } } //29 fn get_socket(app_data: Arc<Mutex<ChatDataModel>>) -> Option<UdpSocket> { //30 let ref_model = &(app_data.lock().unwrap().messaging_model.socket); //31 match ref_model { Some(s) => Some(s.try_clone().unwrap()), _ => None } } }
- Таймаут в миллисекундах, после которого будет прервана блокирующая операция чтения из сокета.
- Функция отрабатывает, когда пользователь хочет оправить новое сообщение на сервер.
- Получаем во владение мютекс с нашей моделью данных. Это блокирует поток перерисовки интерфейса до тех пор, пока мютекс не будет освобожден.
- Делаем копию введенного пользователем текста, чтобы передать его дальше и очищаем поле для ввода текста.
- Отправляем сообщение.
- Сообщаем Фреймворку, что после обработки этого события нужно перерисовать интерфейс.
- Функция отрабатывает, когда пользователь хочет подключиться к серверу.
- Подключаем структуру для представления отрезка времени из стандартной библиотеки.
- Если мы уже подключены к серверу, то прерываем выполнение функции сообщаем Фреймворку, что нет необходимости перерисовывать интерфейс.
- Добавляем задачу, которая будет выполняться асинхронно в потоке из пула потоков Фреймворка Azul. Обращение к мютексу с моделью данных блокирует обновление UI до тех пор, пока мютекс не освободится.
- Добавляем повторяющуюся задачу, которая выполняется в основном потоке. Любые длительные вычисления в этом демоне блокирует обновление интерфейса.
- Получаем во владение мютекс.
- Считываем введенный пользователем порт и создаем на основе него локальный адрес, будем прослушивать.
- Создаем UDP сокет, который считывает пакеты, приходящие на локальный адрес.
- Считываем введенный пользователем адрес сервера.
- Говорим нашему UDP сокету читать пакеты только от этого сервера.
- Устанавливаем таймаут для операции чтения из сокета. Запись в сокет происходит без ожидания, т. е. мы просто пишем данные и не ждем ничего, а операция чтения из сокета блокирует поток и ждет пока не придут данные, которые можно считать. Если не установить таймаут, то операция чтения из сокета будет ждать бесконечно.
- Устанавливаем флаг, указывающий на то, что пользователь уже подключился к серверу.
- Передаем в модель данных созданный сокет.
- Сообщаем Фреймворку, что после обработки этого события нужно перерисовать интерфейс.
- Асинхронная операция, выполняющаяся в пуле потоков Фреймворка Azul.
- Получаем копию сокета из нашей модели данных.
- Пытаемся прочитать данные из сокета. Если не сделать копию сокета и напрямую ждать тут, пока придёт сообщение из сокета, который в мютекс в нашей модели денных, то весь интерфейс перестанет обновляться до тех пор, пока мы не освободим мютекс.
- Если нам пришло какое-то сообщение, то изменяем нашу модель данных modify делает то же, что и lock().unwrap() с передачей результата в лямбду и освобождением мютекс после того, как закончится код лямбды.
- Устанавливаем флаг, обозначающий, что у нас новое сообщение.
- Добавляем сообщение в массив всех сообщения чата.
- Повторяющаяся синхронная операция работающая в основном потоке.
- Если у нас есть новое сообщение, то сообщаем Фреймворку, что нужно перерисовать интерфейс с нуля и продолжить работу этого демона иначе не рисуем интерфейс с начала, но все равно вызываем этот Функция в следующем цикле.
- Создает копию нашего сокета для того, чтобы не держать заблокированным мютекс с нашей моделью данных.
- Получаем во владение мютекс и получаем ссылку на сокет.
- Создаем копию сокета. Мютекс освободится автоматически при выходе из Функцияа.
Асинхронная обработка данных и демоны в Azul
// Problem - blocks UI :( fn start_connection(app_state: &mut AppState<MyDataModel>, _event: WindowEvent<MyDataModel>) -> UpdateScreen { //Добавляем асинхронную задачу app_state.add_task(start_async_task, &[]); //Добавляем демон app_state.add_daemon(Daemon::unique(DaemonCallback(start_daemon))); UpdateScreen::Redraw } fn start_daemon(state: &mut MyDataModel, _repres: &mut Apprepres) -> (UpdateScreen, TerminateDaemon) { //Блокирует UI на десять секунд thread::sleep(Duration::from_secs(10)); state.counter += 10000; (UpdateScreen::Redraw, TerminateDaemon::Continue) } fn start_async_task(app_data: Arc<Mutex<MyDataModel>>, _: Arc<()>) { // simulate slow load app_data.modify(|state| { //Блокирует UI на десять секунд thread::sleep(Duration::from_secs(10)); state.counter += 10000; }); }
Демон всегда выполняется в основном потоке, поэтому там блокировка неизбежна. С асинхронной задачей, если сделать, например, вот так-то никакой блокировки на 10 секунд не будет.
fn start_async_task(app_data: Arc<Mutex<MyDataModel>>, _: Arc<()>) { //Не блокируем UI. Ожидаем асинхронно. thread::sleep(Duration::from_secs(10)); app_data.modify(|state| { state.counter += 10000; }); }
Функция modify вызывает lock() а мютекс с моделью данных поэтому блокирует обновление интерфейса на время своего выполнения.
Наши стили
const CUSTOM_CSS: &str = " .row { height: 50px; } .orange { background: linear-gradient(to bottom, #f69135, #f37335); font-color: white; border-bottom: 1px solid #8d8d8d; }";
Собственно, Функции для создания нашего DOM для отображения его пользователю
impl azul::prelude::Layout for ChatDataModel { //1 fn layout(&self, info: azul::prelude::WindowInfo<Self>) -> azul::prelude::Dom<Self> { //2 if self.logged_in { self.chat_form(info) } else { self.login_form(info) } } } impl ChatDataModel { //3 fn login_form(&self, info: azul::prelude::WindowInfo<Self>) -> azul::prelude::Dom<Self> { //4 let button = azul::widgets::button::Button::with_label("Login") //5 .dom() //6 .with_class("row") //7 .with_class("orange") //8 .with_callback( azul::prelude::On::MouseUp, azul::prelude::Callback(Controller::login_pressed)); //9 let port_label = azul::widgets::label::Label::new("Enter port to listen:") .dom() .with_class("row"); //10 let port = azul::widgets::text_input::TextInput::new() //11 .bind(info.window, &self.login_model.port_input, &self) .dom(&self.login_model.port_input) .with_class("row"); // 9 let address_label = azul::widgets::label::Label::new("Enter server address:") .dom() .with_class("row"); //10 let address = azul::widgets::text_input::TextInput::new() //11 .bind(info.window, &self.login_model.address_input, &self) .dom(&self.login_model.address_input) .with_class("row"); //12 azul::prelude::Dom::new(azul::prelude::NodeType::Div) .with_child(port_label) .with_child(port) .with_child(address_label) .with_child(address) .with_child(button) } //13 fn chat_form(&self, info: azul::prelude::WindowInfo<Self>) -> azul::prelude::Dom<Self> { //14 let button = azul::widgets::button::Button::with_label("Send") .dom() .with_class("row") .with_class("orange") .with_callback(azul::prelude::On::MouseUp, azul::prelude::Callback(Controller::send_pressed)); //15 let text = azul::widgets::text_input::TextInput::new() .bind(info.window, &self.messaging_model.text_input_state, &self) .dom(&self.messaging_model.text_input_state) .with_class("row"); //12 let mut dom = azul::prelude::Dom::new(azul::prelude::NodeType::Div) .with_child(text) .with_child(button); //16 for i in &self.messaging_model.messages { dom.add_child(azul::widgets::label::Label::new(i.clone()).dom().with_class("row")); } dom } }
- Функция, которая создает конечный DOM, и вызывается каждый раз, когда нужно перерисовать интерфейс.
- Если мы уже подключены к серверу, то показываем форму для отправки и чтения сообщений, иначе отображаем форму для подключения к серверу.
- Создает форму для ввода данных необходимых для подключения к серверу.
- Создаем кнопку с текстовой надписью Login.
- Преобразуем ее в объект DOM.
- Добавляем ей класс row.
- Добавляем ей css класс orange.
- Добавляем обработчик события для нажатия на кнопку.
- Создаем текстовую метку с текстом для отображения пользователю и css классом row.
- Создаем текстовое поле для ввода текста с текстом из свойства нашей модели и css классом row.
- Привязываем текстовое поле к свойству нашей DataModel. Это двухсторонняя привязка. Теперь редактирование TextInput автоматически изменяет текст в свойстве нашей модели и обратное тоже верно. Если мы изменим текст в нашей модели, то изменится текст в TextInput.
- Создаем корневой DOM элемент, в который помещаем наши UI элементы.
- Создает форму для отправки и чтения сообщений.
- Создаем кнопку с текстом «Send» и css классами «row», «orange» и обработчиком события при ее нажатии.
- Создаем поле для ввода текста с двухсторонней привязкой с свойству модели self.messaging_model.text_input_state и css классом «row».
- Добавляем текстовые метки, которые отображают сообщения, которые были написаны в чате.
Наша модель, которая хранит состояние нашего интерфейса
В документации Azul написано, что в ней должны храниться все данные приложения, в том числе и подключение к базе данных, поэтому я поместил в нее UDP сокет.
//1 #[derive(Debug)] //2 struct ChatDataModel { //3 logged_in: bool, //4 messaging_model: MessagingDataModel, //5 login_model: LoginDataModel, } #[derive(Debug, Default)] struct LoginDataModel { //6 port_input: azul::widgets::text_input::TextInputState, //7 address_input: azul::widgets::text_input::TextInputState, } #[derive(Debug)] struct MessagingDataModel { //8 text_input_state: azul::widgets::text_input::TextInputState, //9 messages: Vec<String>, //10 socket: Option<UdpSocket>, //11 has_new_message: bool, }
- Это позволит отображать нашу структуру в виде строки в шаблоне вида {:?}
- Наша модель данных. Для того, чтобы ее можно было использовать в Azul. Она обязательно должна реализовать трейт Layout.
- Флаг для проверки того, подключен ли пользователь к серверу или нет.
- Модель для отображения формы для отправки сообщений на сервер и сохранения полученных с сервера сообщений.
- Модель для отображения формы для подключения к серверу.
- Порт, который ввел пользователь. Мы будем его прослушивать нашим сокетом.
- Адрес сервера, который ввел пользователь. Мы будем к нему подключаться.
- Сообщение пользователя. Мы его отправим на сервер.
- Массив сообщений, который пришел с сервера.
- Сокет, через который мы общаемся с сервером.
- Флаг для проверки того, пришло ли нам новое сообщение от сервера.
И, наконец, главная точка входа в приложение. Запускает цикл от рисовки GUI и обработки ввода пользователя
pub fn run() { //1 let app = azul::prelude::App::new(ChatDataModel { logged_in: false, messaging_model: MessagingDataModel { text_input_state: azul::widgets::text_input::TextInputState::new(""), messages: Vec::new(), socket: None, has_new_message: false, }, login_model: LoginDataModel::default(), }, azul::prelude::AppConfig::default()); // 2 let mut style = azul::prelude::css::native(); //3 style.merge(azul::prelude::css::from_str(CUSTOM_CSS).unwrap()); //4 let window = azul::prelude::Window::new(azul::prelude::WindowCreateOptions::default(), style).unwrap(); //5 app.run(window).unwrap(); }
- Создаем приложение со стартовыми данными.
- Стили, используемые приложением по умолчанию.
- Добавляем к ним наши собственные стили.
- Создаем окно, в котором будет отображать наше приложение.
- Запускаем приложение в этом окне.
Сервер
Главная точка входа в приложение
Здесь у нас обычно консольное приложение.
pub fn run() { //1 let socket = create_socket(); //2 let (sx, rx) = mpsc::channel(); //3 start_sender_thread(rx, socket.try_clone().unwrap()); loop { //4 sx.send(read_data(&socket)).unwrap(); } }
- Создаем сокет.
- Создаем односторонний канал с одним отправителем сообщений sx и множеством получателей rx.
- Запускаем рассылку сообщений всем получателям в отдельном потоке.
- Читаем данные из сокета и оправляем их в поток, занимающийся рассылкой сообщений клиентам, подключенным к серверу.
Функция для создания потока для рассылки сообщений клиентам
fn start_sender_thread(rx: mpsc::Receiver<(Vec<u8>, SocketAddr)>, socket: UdpSocket) { //1 thread::spawn(move || { //2 let mut addresses = Vec::<SocketAddr>::new(); //3 loop { //4 let (bytes, pre) = rx.recv().unwrap(); // 5 if !addresses.contains(&pre) { println!(" {} connected to server", pre); addresses.push(pre.clone()); } //6 let result = String::from_utf8(bytes) .expect("can't parse to String") .trim() .to_string(); println!("received {} from {}", result, pre); //7 let message = format!("FROM: {} MESSAGE: {}", pre, result); let data_to_send = message.as_bytes(); //8 addresses .iter() .for_each(|s| { //9 socket.send_to(data_to_send, s) //10 .expect(format!("can't send to {}", pre).as_str()); }); } }); }
- Запускаем новый поток. move значит, что переменные переходят во владение лямбды и потока, соответственно. Конкретнее, наш новый поток «поглотит» переменные rx и socket.
- Коллекция адресов, подключенных к нам клиентов. Всем им мы будем рассылать наши сообщения. Вообще, в реальном проекте надо бы сделать обработку отключения от нас клиента и удаления его адреса из этого массива.
- Запускаем бесконечный цикл.
- Читаем данные из канала. Тут поток будет заблокирован до тех пор, пока не придут новые данные.
- Если такого адреса нет в нашем массиве, то добавляем его туда.
- Декодируем UTF8 строку из массива байт.
- Создаем массив байт, которые собираемся отправить всем нашим клиентам.
- Проходим по коллекции адресов и отправляем данные каждому.
- Операция записи в UDP сокет неблокирующая, поэтому здесь Функция не будет ждать пока сообщение придёт к получателю и выполнится почти мгновенно.
- expect в случае ошибки сделает экстренный выход из программы с заданным сообщением.
Функция создает сокет на основе данных введенных пользователем
const TIMEOUT_IN_MILLIS: u64 = 2000; fn create_socket() -> UdpSocket { println!("Enter port to listen"); //1 let local_port: String = read!("{}\n"); let local_address = format!("127.0.0.1:{}", local_port.trim()); println!("server address {}", &local_address); //2 let socket = UdpSocket::bind(&local_address.trim()) .expect(format!("can't bind socket to {}", &local_address).as_str()); //3 socket.set_read_timeout(Some(Duration::from_millis(TIMEOUT_IN_MILLIS))) .expect("can't set time out to read"); //4 socket }
- Считываем порт, который будет слушать наш сервер, и создаем на его основе локальный адрес сервера.
- Создаем UDP сокет, прослушивающий этот адрес.
- Устанавливаем таймаут для операции чтения. Операция чтения блокирующая и она заблокирует поток до тех пор, пока не придут новые данные или не наступит таймаут.
- Возвращаем из Функции созданные сокет.
- Функция читает данные из сокета и возвращает их вместе с адресом отправителя.
Функция для чтения данных из сокета
fn read_data(socket: &UdpSocket) -> (Vec<u8>, SocketAddr) { //1 let mut buf = [0u8; 4096]; //2 loop { match socket.recv_from(&mut buf) { //3 Ok((count, address)) => { //4 return (buf[..count].into(), address); } //5 Err(e) => { println!("Error {}", e); continue; } }; } }
- Буфер — место, куда будем считывать данные.
- Запускает цикл, который будет выполняться до тех пор, пока не будут считаны валидные данные.
- Получаем количество считанных байт и адрес отправителя.
- Делаем срез массива от его начала до количеств, считанных байт и преобразуем его в вектор байт.
- Если произошёл таймаут или другая ошибка, то переходим к следующей итерации цикла.
Про слои в приложении
Офтоп: Маленький ликбез для двух джунов на работе. Решил выложить сюда, может кому пригодится. Джуны шарписты поэтом примеры на C# и речь про ASP.NET идет
Так, делать было нечего, дело было вечером, и я решил маленький ликбез по архитектуре для Артема и Виктора написать. Ну что, поехали.
Собственно, добавил сюда потому, что рековери мод и я могу только раз в неделю статьи писать, а материал уже есть и на следующей неделе я хотел кое-что другое уже на Хабр залить.
Обычно, приложение делят на слои. В каждом слое находятся объекты, реализующие поведение характерное для слоя, в котором они находятся. И так. Вот эти слои.
Каждый слой может содержать свои DTO и совершенно произвольные классы с произвольными методами. Главное, чтобы они выполняли функционал, связанный со слоем, в котором они находятся. В простых приложениях некоторые из слоев могут отсутствовать. Например, слой представление может реализоваться через MVC, MVP, MVVM паттерн. Что совершенно не обязательно. Главное, чтобы классы, которые находятся в этом слое реализовали функционал, возложенный на слой. Помните, паттерны и архитектура — это всего лишь рекомендации, а не указания. Паттерн и архитектура — это не закон, это совет.
И так, рассмотрим каждый слой на примере стандартного ASP.NET приложения, использующего стандартный Entity Framework.
У нас тут MVC. Это тот слой, который обеспечивает взаимодействие с пользователем. Сюда приходят команды и от сюда получают данные пользователи. Не обязательно люди, если у нас API, то наш пользователь — это другая программа. Машины общаются с машинами.
Тут, обычно, классы именуют Service, например, UserService, хотя может быть вообще, что угодно. Просто набор классов с методами. Главное, чтобы тут происходили вычисления и расчеты нашего приложения. Это самый толстый и громоздкий слой. Тут больше всего кода и различных классов. Это, собственно, и есть наше приложение.
Обычно у нас тут EF реализует паттерны Unit Of Work и Repository. Таки да, DbContext это, можно сказать, Unit Of Work, а ДБ сеты его это Repository. Это, собственно, то место куда мы кладем данные и откуда их берем. Не зависимо от того, источник данных это БД, АПИ другого приложения, Кеш в Памяти или просто какой-то генератор случайных чисел. Любой источник данных.
Да, просто всякие User, Animal и прочее. Одно важное замечание – у них может быть какое-то поведение характерное только для них. Например:
Собственно, добавил сюда потому, что рековери мод и я могу только раз в неделю статьи писать, а материал уже есть и на следующей неделе я хотел кое-что другое уже на Хабр залить.
Обычно, приложение делят на слои. В каждом слое находятся объекты, реализующие поведение характерное для слоя, в котором они находятся. И так. Вот эти слои.
- Слой представления.
- Слой бизнес логики.
- Слой доступа к данным.
- Сущности (User, Animal и т. д.)
Каждый слой может содержать свои DTO и совершенно произвольные классы с произвольными методами. Главное, чтобы они выполняли функционал, связанный со слоем, в котором они находятся. В простых приложениях некоторые из слоев могут отсутствовать. Например, слой представление может реализоваться через MVC, MVP, MVVM паттерн. Что совершенно не обязательно. Главное, чтобы классы, которые находятся в этом слое реализовали функционал, возложенный на слой. Помните, паттерны и архитектура — это всего лишь рекомендации, а не указания. Паттерн и архитектура — это не закон, это совет.
И так, рассмотрим каждый слой на примере стандартного ASP.NET приложения, использующего стандартный Entity Framework.
Слой представления
У нас тут MVC. Это тот слой, который обеспечивает взаимодействие с пользователем. Сюда приходят команды и от сюда получают данные пользователи. Не обязательно люди, если у нас API, то наш пользователь — это другая программа. Машины общаются с машинами.
Слой бизнес логики
Тут, обычно, классы именуют Service, например, UserService, хотя может быть вообще, что угодно. Просто набор классов с методами. Главное, чтобы тут происходили вычисления и расчеты нашего приложения. Это самый толстый и громоздкий слой. Тут больше всего кода и различных классов. Это, собственно, и есть наше приложение.
Слой доступа к данным
Обычно у нас тут EF реализует паттерны Unit Of Work и Repository. Таки да, DbContext это, можно сказать, Unit Of Work, а ДБ сеты его это Repository. Это, собственно, то место куда мы кладем данные и откуда их берем. Не зависимо от того, источник данных это БД, АПИ другого приложения, Кеш в Памяти или просто какой-то генератор случайных чисел. Любой источник данных.
Сущности
Да, просто всякие User, Animal и прочее. Одно важное замечание – у них может быть какое-то поведение характерное только для них. Например:
class User { public string FirstName { get; set; } public string LastName { get; set; } public string FullName { get { return FirstName + " " + LastName; } } public bool Equal(User user) { return this.FullName == user.FullName; } }
Ну, и совсем простенький пример. Шобы было
using System; using System.Collections.Generic; using System.Text; //Entities class User { public int Id { get; set; } public string Name { get; set; } } //Data Access Layer class UserRepository { private readonly Dictionary<int, User> _db; public UserRepository() { _db = new Dictionary<int, User>(); } public User Get(int id) { return _db[id]; } public void Save(User user) { _db[user.Id] = user; } } //Business Logic Layer class UserService { private readonly UserRepository _repo; private int _currentId = 0; public UserService() { _repo = new UserRepository(); } public void AddNew() { _currentId++; var user = new User { Id = _currentId, Name = _currentId.ToString() }; _repo.Save(user); } public string GetAll() { StringBuilder sb = new StringBuilder(); for (int i = 1; i <= _currentId; i++) { sb.AppendLine($"Id: {i} Name: {_repo.Get(i).Name}"); } return sb.ToString(); } } //presentation Layer aka Application Layer class UserController { private readonly UserService _service; public UserController() { _service = new UserService(); } public string RunExample() { _service.AddNew(); _service.AddNew(); return _service.GetAll(); } } namespace ConsoleApp1 { class Program { static void Main(string[] args) { var controller = new UserController(); Console.WriteLine(controller.RunExample()); Console.ReadLine(); } } }
P.S.
Ну шо, таки хочу сказать спасибо моей Насте за то что исправила грамматические ошибки в статье. Таки да, Настя ты не зря с красным дипломом и вообще классная. Люблю тебя <3.
