I study Rust: How I did UDP chat with Azul



I continue to study Rust. I still do not know much, therefore I make many mistakes. Last time I tried to make a snake game . I tried cycles, collections, working with 3D Three.rs . I learned about ggez and Amethyst . This time I tried to make a client and server for a chat. For GUI used Azul . Also watched Conrod , Yew and Orbtk . I tried multithreading, channels and working with the network. I took into account the mistakes of the previous article and tried to make this more detailed. For details, welcome under cat.

→  Sources running on Windows 10 x64

For network communication, I used UDP because I want to do my next project using this protocol and I wanted to practice with it here. For the GUI, I quickly googled up projects on Rust, looked at basic examples for them, and I was hooked on Azul because it uses the Document Object Model and a style engine similar to CSS, and I was engaged in web development for a long time. In general, I chose the Framework subjectively. It is, for now, in deep alpha: scrolling does not work, input focus does not work, there is no cursor. In order to enter data into the text field, you need to hover the mouse on it and hold it right above it while typing. More ...

Actually, most of the articles are comments on the code.

Azul


GUI framework using functional style, DOM, CSS. Your interface consists of a root element, which has many descendants, which can have their own descendants such as, for example, in HTML and XML. The entire interface is created based on data from a single DataModel. In it, in general, all data is transferred to the view. If anyone is familiar with ASP.NET, then Azul and its DataModel are like Razor and its ViewModel. Like HTML, you can bind functions to DOM element events. You can stylize elements using CSS framework. This is not the same CSS as in HTML, but very similar to it. There is also a two-way binding like in Angular or MVVM in WPF, UWP. Read more on the site .

Overview of the rest of the frameworks


  • Orbtk - Almost the same as Azul and also in deep alpha
  • Conrod - Video You can create cross-platform desktop applications.
  • Yew - WebAssembly and is similar to React. For web development.

Customer


A structure in which helper functions are grouped to read and write to a socket.


structChatService {}
impl ChatService {
    //1 fnread_data(socket: &Option<UdpSocket>) -> Option<String> {
        //2 letmut 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) => {
                        //5println!("Error {}", e);
                        None
                    }
                }
            }
            _ => None,
        }
    }
    //6fnsend_to_socket(message: String, socket: &Option<UdpSocket>) {
        match socket {
            //7 Some(s) => { s.send(message.as_bytes()).expect("can't send"); }
            _ => return,
        }
    }
}
 

  1. We read data from a socket
  2. A buffer for data that will be read from the socket.
  3. Blocking call Here, the thread of execution stops until the data is read or timeout occurs.
  4. Get the string from the byte array in UTF8.
  5. We get here if the connection is broken by timeout or another error has occurred.
  6. Sends a string to the socket.
  7. Convert the string to bytes in UTF8 encoding and send data to the socket. Writing data to a socket is not blocking, i.e. The execution thread will continue its work. If the data could not be sent, then we interrupt the program with the message “can't send”.

A structure that groups functions to handle user input and changes to our DataModel.


structController {}
//1 const TIMEOUT_IN_MILLIS: u64 = 2000;
impl Controller {
    //2 fnsend_pressed(app_state: &mut azul::prelude::AppState<ChatDataModel>, _event: azul::prelude::WindowEvent<ChatDataModel>) -> azul::prelude::UpdateScreen {
        //3 let data = app_state.data.lock().unwrap();
        //4let 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 fnlogin_pressed(app_state: &mut azul::prelude::AppState<ChatDataModel>, _event: azul::prelude::WindowEvent<ChatDataModel>) -> azul::prelude::UpdateScreen {
        //8use std::time::Duration;
        //9 ifletSome(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 letmut 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 fnread_from_socket_async(app_data: Arc<Mutex<ChatDataModel>>, _: Arc<()>) {
        //22 let socket = Controller::get_socket(app_data.clone());
        loop {
            //23 ifletSome(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);
                });
            }
        }
    }
    //27fnredraw_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 fnget_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
        }
    }
}

  1. Timeout in milliseconds, after which a blocking read operation from the socket will be interrupted.
  2. The function works when the user wants to send a new message to the server.
  3. We get ownership of mutex with our data model. This blocks the interface redraw stream until the mutex is released.
  4. We make a copy of the text entered by the user to pass it on and clear the text entry field.
  5. We send the message.
  6. We inform the Framework that after processing this event you need to redraw the interface.
  7. The function works when the user wants to connect to the server.
  8. We connect the structure to represent the length of time from the standard library.
  9. If we are already connected to the server, then we stop the execution of the function by telling the Framework that there is no need to redraw the interface.
  10. Add a task that will be executed asynchronously in a stream from the thread pool of the Azul Framework. Accessing the mutex with the data model blocks updating the UI until the mutex is released.
  11. Add a recurring task that runs in the main thread. Any lengthy computation in this daemon blocks interface updates.
  12. We get possession of mutex.
  13. We read the port entered by the user and create a local address based on it, we will listen.
  14. We create a UDP socket that reads packets arriving at the local address.
  15. We read the server address entered by the user.
  16. We tell our UDP socket to read packets only from this server.
  17. Set the timeout for a read operation from the socket. Writing to the socket occurs without waiting, that is, we simply write data and do not wait for anything, and a read operation from the socket blocks the stream and waits until data can be read. If you do not set the timeout, the read operation from the socket will wait indefinitely.
  18. Set the flag indicating that the user has already connected to the server.
  19. We transfer to the data model the created socket.
  20. We inform the Framework that after processing this event you need to redraw the interface.
  21. An asynchronous operation that runs in the thread pool of the Azul Framework.
  22. Get a copy of the socket from our data model.
  23. We are trying to read data from the socket. If you do not make a copy of the socket and directly wait here until a message comes from the socket, which is in the mutex in our data model, then the entire interface will stop updating until we release the mutex.
  24. If we receive a message, then we change our data model. It does the same as lock (). Unwrap (), passing the result to the lambda and releasing the mutex after the lambda code ends.
  25. Set the flag, indicating that we have a new message.
  26. Add a message to the array of all chat messages.
  27. Repeated synchronous operation running in the main thread.
  28. If we have a new message, then we inform the Framework to redraw the interface from scratch and continue the work of this daemon; otherwise, we will not draw the interface from the beginning, but still call this Function in the next cycle.
  29. Creates a copy of our socket in order not to keep the mutex locked with our data model.
  30. We get ownership of mutex and get a reference to the socket.
  31. Create a copy of the socket. Mutex will be released automatically when exiting the Function.

Asynchronous data processing and daemons in Azul


// Problem - blocks UI :(fnstart_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
}
fnstart_daemon(state: &mut MyDataModel, _repres: &mut Apprepres) -> (UpdateScreen, TerminateDaemon) {
    //Блокирует UI на десять секунд
    thread::sleep(Duration::from_secs(10));
        state.counter += 10000;
        (UpdateScreen::Redraw, TerminateDaemon::Continue)
}
fnstart_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;
    });
}

The daemon is always executed in the main thread, so blocking is inevitable there. With an asynchronous task, if you do, for example, there will be no lock for 10 seconds.

fnstart_async_task(app_data: Arc<Mutex<MyDataModel>>, _: Arc<()>) {
    //Не блокируем UI. Ожидаем асинхронно.
    thread::sleep(Duration::from_secs(10));
    app_data.modify(|state| {       
        state.counter += 10000;
    });
}

The modify function calls lock () on the mutex with the data model, therefore it blocks the update of the interface during its execution.

Our styles


const CUSTOM_CSS: &str = "
.row { height: 50px; }
.orange {
    background: linear-gradient(to bottom, #f69135, #f37335);
    font-color: white;
    border-bottom: 1px solid #8d8d8d;
}";

Actually, the functions for creating our DOM to display it to the user


impl azul::prelude::Layout for ChatDataModel {
    //1 fnlayout(&self, info: azul::prelude::WindowInfo<Self>) -> azul::prelude::Dom<Self> {
        //2 ifself.logged_in {
            self.chat_form(info)
        } else {
            self.login_form(info)
        }
    }
}
impl ChatDataModel {
    //3 fnlogin_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");
        // 9let address_label = azul::widgets::label::Label::new("Enter server address:")
            .dom()
            .with_class("row");
        //10let 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 fnchat_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");
        //12letmut dom = azul::prelude::Dom::new(azul::prelude::NodeType::Div)
            .with_child(text)
            .with_child(button);
        //16for i in &self.messaging_model.messages {
            dom.add_child(azul::widgets::label::Label::new(i.clone()).dom().with_class("row"));
        }
        dom
    }
}

  1. The function that creates the final DOM, and is called whenever the interface needs to be redrawn.
  2. If we are already connected to the server, then we show the form for sending and reading messages, otherwise we display the form for connecting to the server.
  3. Creates a form to enter data required to connect to the server.
  4. Create a button labeled Login.
  5. Convert it to a DOM object.
  6. Add to her class row.
  7. Add css class orange to it.
  8. Add an event handler to click on the button.
  9. Create a text label with text to display to the user and the css class row.
  10. Create a text field for entering text with text from the properties of our model and the css class row.
  11. Bind a text field to the property of our DataModel. This is a two-way binding. Now editing TextInput automatically changes the text in the property of our model and the reverse is also true. If we change the text in our model, the text in TextInput will change.
  12. Create a root DOM element in which we put our UI elements.
  13. Creates a form for sending and reading messages.
  14. Create a button with the text "Send" and css classes "row", "orange" and an event handler when it is pressed.
  15. Create a text entry field with two-way binding with the property of the model self.messaging_model.text_input_state and the css class “row”.
  16. Add text labels that display messages that were written in the chat.

Our model, which stores the state of our interface


The Azul documentation says that it should store all application data, including the connection to the database, so I put a UDP socket in it.

//1 #[derive(Debug)]//2 structChatDataModel {
    //3 
    logged_in: bool,
    //4 
    messaging_model: MessagingDataModel,
    //5 
    login_model: LoginDataModel,
}
#[derive(Debug, Default)]structLoginDataModel {
    //6 
    port_input: azul::widgets::text_input::TextInputState,
    //7 
    address_input: azul::widgets::text_input::TextInputState,
}
#[derive(Debug)]structMessagingDataModel {
    //8
    text_input_state: azul::widgets::text_input::TextInputState,
    //9 
    messages: Vec<String>,
    //10 
    socket: Option<UdpSocket>,
    //11 
    has_new_message: bool,
}

  1. This will allow us to display our structure as a string in a pattern like {:?}
  2. Our data model. In order to be able to use it in Azul. She must implement the treit Layout.
  3. Flag to check if the user is connected to the server or not.
  4. Model for displaying the form for sending messages to the server and saving messages received from the server.
  5. Model to display the form to connect to the server.
  6. The port that the user entered. We will listen to it with our socket.
  7. The address of the server that the user entered. We will connect to it.
  8. User message. We will send it to the server.
  9. The message array that came from the server.
  10. The socket through which we communicate with the server.
  11. Flag to check if we received a new message from the server.

And finally, the main entry point into the application. Runs a loop from GUI drawing and user input processing.


pubfnrun() {
    //1let 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());
    // 2letmut 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();
}

  1. Create an application with starting data.
  2. Styles used by the application by default.
  3. Add our own styles to them.
  4. Create a window that will display our application.
  5. Run the application in this window.

Server


Main entry point to the application


Here we usually have a console application.

pubfnrun() {
    //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();
    }
}

  1. Create a socket.
  2. Create a one-way channel with one sx message sender and multiple rx recipients.
  3. We start the distribution of messages to all recipients in a separate thread.
  4. We read data from the socket and send it to the stream that sends messages to clients connected to the server.

Function to create a stream for sending messages to clients


fnstart_sender_thread(rx: mpsc::Receiver<(Vec<u8>, SocketAddr)>, socket: UdpSocket) {
    //1 
    thread::spawn(move || {
        //2 letmut addresses = Vec::<SocketAddr>::new();
        //3 loop {
            //4 let (bytes, pre) = rx.recv().unwrap();
            // 5if !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());
                });
        }
    });
}

  1. Launch a new thread. move means that variables are taken over by lambda and flow, respectively. More specifically, our new thread will “swallow” the rx and socket variables.
  2. The collection of addresses, the clients connected to us. We will send our messages to all of them. In general, in a real project, it would be necessary to do the processing of disconnecting the client from us and removing its address from this array.
  3. Run an infinite loop.
  4. We read data from the channel. Here the stream will be blocked until new data arrives.
  5. If this address is not in our array, then add it there.
  6. We decode the UTF8 string from the byte array.
  7. We create an array of bytes that we are going to send to all our clients.
  8. We pass through the collection of addresses and send the data to each.
  9. The write operation in the UDP socket is non-blocking, so here the Function will not wait until the message arrives at the receiver and is executed almost instantly.
  10. expect in case of an error will make an emergency exit from the program with the specified message.

The function creates a socket based on user input.


const TIMEOUT_IN_MILLIS: u64 = 2000;
fncreate_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
}

  1. We read the port that our server will listen to and create a local server address based on it.
  2. Create a UDP socket listening on this address.
  3. Set the timeout for the read operation. The read operation is blocking and it will block the stream until new data arrives or timeout occurs.
  4. We return the created socket from the function.
  5. The function reads data from the socket and returns it along with the sender's address.

Function to read data from socket


fnread_data(socket: &UdpSocket) -> (Vec<u8>, SocketAddr) {
    //1 letmut 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;
            }
        };
    }
}

  1. Buffer - a place where we will read the data.
  2. Starts a loop that will run until valid data has been read.
  3. We get the number of bytes read and the sender's address.
  4. We make a slice of the array from its beginning to the quantities read bytes and convert it into a vector of bytes.
  5. If there is a timeout or other error, then go to the next iteration of the loop.

About layers in the application


Oftop: A small educational program for two junas at work. I decided to post here, can someone come in handy. Juna Sharpisty poet examples in C # and we are talking about ASP.NET
Так, делать было нечего, дело было вечером, и я решил маленький ликбез по архитектуре для Артема и Виктора написать. Ну что, поехали.

Собственно, добавил сюда потому, что рековери мод и я могу только раз в неделю статьи писать, а материал уже есть и на следующей неделе я хотел кое-что другое уже на Хабр залить.

Обычно, приложение делят на слои. В каждом слое находятся объекты, реализующие поведение характерное для слоя, в котором они находятся. И так. Вот эти слои.

  1. Слой представления.
  2. Слой бизнес логики.
  3. Слой доступа к данным.
  4. Сущности (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 и прочее. Одно важное замечание – у них может быть какое-то поведение характерное только для них. Например:

classUser
{
    publicstring FirstName { get; set; }
    publicstring LastName { get; set; }
    publicstring FullName
    {
        get
        {
            return FirstName + " " + LastName;
        }
    }
    publicboolEqual(User user)
    {
        returnthis.FullName == user.FullName;
    }
}

Ну, и совсем простенький пример. Шобы было


using System;
using System.Collections.Generic;
using System.Text;
//Entities classUser
{
    publicint Id { get; set; }
    publicstring Name { get; set; }
}
//Data Access Layer classUserRepository
{
    privatereadonly Dictionary<int, User> _db;
    publicUserRepository()
    {
        _db = new Dictionary<int, User>();
    }
    public User Get(int id)
    {
        return _db[id];
    }
    publicvoidSave(User user)
    {
        _db[user.Id] = user;
    }
}
//Business Logic Layer classUserService
{
    privatereadonly UserRepository _repo;
    privateint _currentId = 0;
    publicUserService()
    {
        _repo = new UserRepository();
    }
    publicvoidAddNew()
    {
        _currentId++;
        var user = new User
        {
            Id = _currentId,
            Name = _currentId.ToString()
        };
        _repo.Save(user);
    }
    publicstringGetAll()
    {
        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 LayerclassUserController
{
    privatereadonly UserService _service;
    publicUserController()
    {
        _service = new UserService();
    }
    publicstringRunExample()
    {
        _service.AddNew();
        _service.AddNew();
        return _service.GetAll();
    }
}
namespaceConsoleApp1
{
    classProgram
    {
        staticvoidMain(string[] args)
        {
            var controller = new UserController();
            Console.WriteLine(controller.RunExample());
            Console.ReadLine();
        }
    }
}


PS


Well, I want to say thanks to my Nastya for correcting grammatical errors in the article. Yes, Nastya, you are not in vain with a red diploma and generally cool. Love you <3.

Also popular now: