Как стать автором
Обновить

C# Делаем поддержку «плагинов» для курсовой. Часть 1

Время на прочтение13 мин
Количество просмотров4.8K
Splash экран моей курсовой
Splash экран моей курсовой

Ближе к делу, господа: узнав о том, что мой институт проводит конкурс на лучшие курсовые работы среди первого курса, я решил, что займу первое место. Мои самые крутые конкуренты использовали React, кто-то Django, а кто-то Tkinter.

Всем нам дали разные темы. В то время как моим одногруппникам дали четкое ТЗ в виде word, то мне дали пару предложений:

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

Генератор был создан, десктопное приложение тоже. Но мне показалось что этого мало, и в голову пришла мысль :

"Я создам свой язык программирование разметки, с рефлексией и пайтоном!"

Синтаксис

Да, я изобретаю велосипед, и мне это можно, ведь я пока учусь. Нужно придумать довольно-таки простой синтаксис, и я его позаимствовал у команд майнкрафта:

/имя_тега контент

Но этого было мало, так что я добавил еще один возможный синтаксис:

/имя_тега[свойства1=значение1;свойства2=значение2;....] контент

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

/имя_тега
/имя_тега[свойства1=значение1;свойства2=значение2;....]

И имя этому языку - StackMarkup. Так как все элементы будут расположены в StackLayout и так же, регистр не должен иметь значения, по возможности нужно применять ToLower() для строк, что бы регистр не имел значения.

Реализация

У меня большие планы на этот язык, поэтому самый первый класс - это конфигурация

public class MarkupConfiguration
{
        public char? BeforeCharacter { get; set; } = '/';
        public bool CustomPropertyParser { get; set; }
}

Да, тут не густо, в основном - это лишь задел на будущее.

Важный момент: не будет никакого базового класса, от которого будут наследоваться все элементы (ну кроме object конечно же). Подобный класс должен будет определить сам пользователь библиотеки.

Поэтому создаем два атрибута:

///<summary>
/// Дает псевдоним(или псевдонимы) для элемента или для свойства
///</summary>
[AttributeUsage(AttributeTargets.Property | 
                AttributeTargets.Class | 
                AttributeTargets.Struct)]
public class MarkupAliasesAttribute: Attribute 
{
    //Если я не собираюсь изменять переменную, то я помечаю ее как readonly
    //Если я не собираюсь изменять коллекцию, то ее тип всегда IReadOnly
    public readonly IReadOnlyList<string> aliases;
    public MarkupAliasesAttribute(params string[] aliases)
    {
        var list = new List<string>();
        foreach(var alias in aliases)// К черту Linq будем использовать foreach!
        {
            list.Add(alias.Trim().ToLower());
        }
        this.aliases = list.AsReadOnly();
    }
}

///<summary>
/// Игнорирует свойства, точнее его нельзя задать через Markup
///</summary>
[AttributeUsage(AttributeTargets.Property)]
public class MarkupIgnoreAttribute: Attribute {}

Как вы видите, неправильно заданные псевдонимы поломают весь синтаксис. Но это мы будем проверять не здесь, так как атрибуты имеют свойства "ленивой инициализации". Далее создадим исключения :

///<param>
/// Исключение во время регистрации элементов
///<param>
public class AliasException: Exception
{
    // Вместо того что бы каждый раз создавать один и тот же
    // регэкс, мы пропишем его как static readonly что равносильно константе
    // так же в настройках укажем RegexOptions.Compiled, так он будет работать 
    // быстрее, однака его инициализация будет медленной, 
    // мы будем часто его использовать
    private static readonly Regex _isLetterOnly = new Regex(@"^[a-zA-Z]+$", 
                                                           RegexOptions.Compiled);
    ///<param>
    /// Проверяет имя на корректность, если имя не корректно выбрасывает исключение
    ///</param>
    public static void CheckNaming(string alias, MarkupConfiguration configuration)
    {
        if(alias.Contains(' '))
        {
            throw new AliasException($"Имя {alias} не должно содержать пробелы!");
        }
        if(!_isLetterOnly.IsMatch(alias))
        {
            throw new AliasException($"Имя {alias} может соддержать только латинские буквы!");
        }
    }
    public AliasException(string message):base(message){}
}

///<param>
/// Исключение во время чтение документа
///<param>
public class SyntaxException: Exception
{
    public static void CheckIsEmptyName(string substring)
    {
        if(string.IsNullOrWhiteSpace(substring))
        {
            throw new SyntaxException("Имя элемента не может быть пустым!");
        }
    }
    public SyntaxException(string message): base(message) {}
}

Далее нужен класс, который будет читать строку( то есть строку файла содержащий код StackMarkup). Я не использовал Antrl, так как посчитал его слишком мощным инструментов для такой задачи. Не пугайтесь такого "огромного" кода, умел бы нормально писать RegEx он бы выглядел бы короче....

MarkupParsedRow.cs
public class MarkupParsedRow
{
    // Данный регэкс определяет, прописан ли тег со свойствам(и) или без
    private static readonly Regex _elementWithProperties = new Regex(
        @"[a-zA-Z]+\[[^\]]*\](\.[^\]]*\])?", 
        RegexOptions.Compiled
    );

    public MarkupParsedRow(string row, MarkupConfiguration configuration)
    {
        if(configuration.BeforeCharacter != null)
        {
            SetCountBeforeCharacter(row, configuration);
        }
        
        // Удаляем все первые символы (в данном случае "/")
        row = row.Remove(0, CountBeforeCharacter);
      
        // Проверка на наличие контента, если i=-1 значит эелемент без контента
        int i = 0;
        while (row.IndexOf(' ', i) < row.IndexOf(']'))
        {
            i = row.IndexOf(' ', i+1);
            if (i == -1)
            {
                break;
            }
        }

        if (i!= -1) // если строка содержит контент
        {
            // defination та же строка но с удалением контента
            var defination = row.Substring(0, row.IndexOf(' ', i));
            
            SyntaxException.CheckIsEmptyName(defination);
            // если элемент имеет свойства, то устонавливем и контент, и имя, 
            // и свойства
            if(_elementWithProperties.IsMatch(defination))
            {
                SetNameAndProperty(defination, configuration);
                Content = row.Replace($"{MarkupElementName}[{PropertiesString}] ", "");
            }
            else
            {
                // если же нет значит defination содержит только имя
                MarkupElementName = defination;
                AliasException.CheckNaming(MarkupElementName, configuration);
                Content = row.Replace($"{MarkupElementName} ", "");
            }
        }
        // если нет контента, но есть свойства
        else if (_elementWithProperties.IsMatch(row)) 
        {
            SetNameAndProperty(row, configuration);
        }
        // если есть только имя
        else 
        {
            SyntaxException.CheckIsEmptyName(row);
            MarkupElementName = row;
        }

        MarkupElementName = MarkupElementName.ToLower().Trim();
    }
    // Задел на будущее
    public int CountBeforeCharacter { get; private set; } = 0; 
    // Имя самого элемента
    public string MarkupElementName { get; private set; } 
    // Строка со свойствами, если они есть
    public string? PropertiesString { get; private set; }
    // Контент, если он есть
    public string? Content { get; private set; }

    private void SetCountBeforeCharacter(string row, MarkupConfiguration configuration)
    {
        foreach(var character in row)
        {
            if(character == configuration.BeforeCharacter)
            {
                CountBeforeCharacter += 1;
                continue;
            }
            break;
        }
    }
    /// Устанавливает Имя и Свойства 
    private void SetNameAndProperty(string defination, MarkupConfiguration configuration)
    {
        MarkupElementName = defination.Substring(0, defination.IndexOf('['));
        PropertiesString = defination.Substring(
            defination.IndexOf('[') + 1,
            defination.IndexOf(']') - defination.IndexOf('[') -1
        );
        AliasException.CheckNaming(MarkupElementName, configuration);
    }

}

Дальше уже будет легче. Нужен класс, который из MarkupParsedRow делает объект. Вам стоит учесть, что net 5 предоставляет в принципе большинство решений из коробки, так что нам не надо устанавливать дополнительные пакеты.

Одно из таких решений - это TypeDescriptor, благодаря которому мы можем конвертировать из строки в объект. Подробнее об этом вы можете прочитать тут.

А парсить свойства мне даже не надо : это сделает за меня DbConnectionStringBuilder

Это еще не все. Так же есть класс Activator, который может с легкостью создать объект из типа.

public class MarkupElementDefination
{
        // Тип элемента
        private readonly Type _elementType;
        // Свойства элемента
        private readonly Dictionary<string, PropertyInfo> _properties = new Dictionary<string, PropertyInfo>();
        // Конфигурация документа
        private readonly MarkupConfiguration _configuration;
        // Свойство содержащий контент
        private readonly PropertyInfo _content = null;
        
        // Определяем элемент
        public MarkupElementDefination(Type elementType, MarkupConfiguration configuration)
        {
            _elementType = elementType;
            _configuration = configuration;
            // Читаем все свойства
            foreach(var property in elementType.GetProperties())
            {
                // Если свойство содержит атрибут MarkupIgnore, то игнорируем его
                if(property.GetCustomAttribute<MarkupIgnoreAttribute>() != null)
                {
                    continue;
                }
                // Получаем все атрибуты данного свойства
                foreach(var attribute in property.GetCustomAttributes())
                {
                    // Если свойство содержит атрибут MarkupAliases, то устанавливаем
                    // псевдонимы для него
                    if(attribute is MarkupAliasesAttribute aliasesAtr)
                    {
                        SettingAliases(property, aliasesAtr.aliases);
                    }
                    // Если тег поддерживает атрибут
                    if(attribute is MarkupContentAttribute)
                    {
                        if(property.PropertyType != typeof(string))
                        {
                            throw new InvalidOperationException("Content всегда должен быть строкового типа!");
                        }
                        else
                        {
                            CanContainContent = true;
                            _content = property;
                        }
                    }
                }
                _properties.Add(property.Name.Trim().ToLower(), property);
            }
        }
        // Поддерживает ли элемент контент
        public bool CanContainContent { get; } 
  
        // Устанавливаем имена для свойств
        private void SettingAliases(PropertyInfo property, IReadOnlyList<string> aliases)
        {
            foreach(var alias in aliases)
            {
                AliasException.CheckNaming(alias, _configuration);
                try
                {
                    _properties.Add(alias, property);
                }
                catch(ArgumentException)
                {
                    throw new AliasException($"Имя {alias} уже занято!");
                }
            }
        }
  
        // Принимает на вход распарсенную строку, и делает из нее объект
        public object BuildElement(MarkupParsedRow row)
        {
            // Создаем экземпляр типа 
            var element  = Activator.CreateInstance(_elementType);
            var properties = new DbConnectionStringBuilder();
         
            if (!_configuration.CustomPropertyParser)
            {

                if (!string.IsNullOrWhiteSpace(row.PropertiesString))
                {
                    try
                    {
                        properties.ConnectionString = row.PropertiesString;
                    }
                    catch (ArgumentException)
                    {
                        throw new SyntaxException("Некорректный синтаксис свойств");
                    }
                }
                // Проходим по всем распарсенным свойствам
                foreach (var propertyName in properties.Keys)
                {
                    var propName = ((string)propertyName).Trim().ToLower();
                    if (_properties.ContainsKey(propName))
                    {
                        var value = GetConvertedTypeFromString(
                            _properties[propName].PropertyType,
                            (string)properties[propName]
                        );
                        _properties[propName].SetValue(element, value);
                    }
                    else
                    {
                        throw new InvalidCastException($"Не найдено свойство {propName}");
                    }
                }
            }

            if(CanContainContent)
            {
                _content.SetValue(element, row.Content);
            }

            return element;
        }
        // Превращает строку внужный тип объекта 
        private static object GetConvertedTypeFromString(Type type, string value)
        {
            var converter = TypeDescriptor.GetConverter(type);
            return converter.ConvertFromInvariantString(value);
        }
    }

Отлично.

Но, как вы заметили, тут нет проверки имени самого объекта. То есть мы можем просто запихнуть туда строку с теми же свойствами, и она будет работать.

Так же здесь нет проверки на конструктор, что означает, что тип, который мы читаем, обязан содержать конструктор без аргументов для корректной работы.

Однако всем этим будет заниматься не он, а последний класс "MarkupDocument". И надеюсь вопрос о проверки имени у вас отпал.

Но что насчет конструктора? Это тоже легко решается с помощью Generic'ов : просто пишем where T: new() (это означает что тип обязан иметь конструктор без аргументов). Еще один плюс Generic'ов в том, что мне куда приятнее писать <Type> чем typeof(Type)

Далее о StreamReader. Его преимущество в том, что можно читать каждую линию, то есть имеется метод ReadLine()

Так же нужно добавить событие, когда элемент уже создан:

public delegate 
  void MarkupElementBuildedHandler(object element, MarkupParsedRow parsedRow);
namespace StackMarkup
{
    public class MarkupDocument: IEnumerable
    {
        // Зарегистрированные типы
        private readonly List<Type> _registered = new List<Type>();
        // Хранит псевдонимы-имена, и само определение элемента
        private readonly Dictionary<string, MarkupElementDefination> 
             _elements = new Dictionary<string, MarkupElementDefination>();

        // Событие когда элемент уже создан
        public event MarkupElementBuildedHandler OnMarkupElemenBuilded;

        public MarkupDocument() { }

        public MarkupDocument(MarkupConfiguration configuration)
        {
            Configuration = configuration;
        }

        public MarkupConfiguration Configuration { get; set; } = new MarkupConfiguration()
        {
            BeforeCharacter = '/'
        };
        
        // После того как будет загружен документ,
        // этот лист будет содержать элементы документа,
        // то есть сами экземпляры зарегестрированных типов
        public List<object> Elements { get; } = new List<object>();
        
        // Регистрируем определение 
        public void Register<T>() where T: new()  // Это про то что я говорил!
        {
            var type = typeof(T);

            if(_registered.Contains(type))
            {
                throw new ArgumentException("Тип уже зарегистрирован!");
            }
            
            // Определение сама читает тип
            var markupElementDefination = 
                new MarkupElementDefination(type, Configuration);
                
            // Если тип содежритт артибут MarkupAliases, то регистрируем имена тоже
            if (type.GetCustomAttribute<MarkupAliasesAttribute>() != null)
            {
                var attr = type.GetCustomAttribute<MarkupAliasesAttribute>();
                foreach (var alias in attr.aliases)
                {
                    AliasException.CheckNaming(alias.Trim().ToLower(), 
                        Configuration);

                    if(_elements.ContainsKey(alias.Trim().ToLower()))
                    {
                        throw new AliasException(
                            $"Имя {alias.Trim().ToLower()} уже занято!");
                    }

                    _elements.Add(alias.Trim().ToLower(), 
                        markupElementDefination);
                }
            }
            // Теперь мы можем спокойно обращаться к пределением через имена и псеводонимы
            _elements.Add(type.Name.Trim().ToLower(), markupElementDefination);
        }
        
        
        public void Load(string path)
        {
            using(var reader = new StreamReader(path))
            {
                // Пока файл не кончился
                while (reader.Peek() >= 0)
                {
                    // Парсим строку
                    var row = new MarkupParsedRow(reader.ReadLine(), Configuration);

                    if(!_elements.ContainsKey(row.MarkupElementName))
                    {
                        throw new AliasException($"Имя {row.MarkupElementName} не существует");
                    }
                    //
                    var element = _elements[row.MarkupElementName].BuildElement(row);
                    // Вызываем событие
                    OnMarkupElemenBuilded?.Invoke(element, row);
                    Elements.Add(element);
                }
            }
        }
        
        //Получаем все элементы из документа
        public IEnumerator GetEnumerator()
        {
            return Elements.GetEnumerator();
        }
    }
}

Опитонивание

Да, странный заголовок, но все же. Теперь билдим библиотеку и вставляем в основной проект. Для того что бы добавить поддержку python, используем IronPython (Я назвал плагины традициями).

Теперь определим сами теги:

TraditionMarkup.cs
namespace ScheduleGenerator.Traditions
{

    public abstract class BaseMarkup
    {
        public static MarkupDocument Document { get; } = GetDocument();
      
        // Регистрируем элементы, само по себе рефлексия дорогостоющая 
        // операция, поэтому стараемся использовасть как можно меньше
        private static MarkupDocument GetDocument()
        {
            var doc = new MarkupDocument();

            doc.Register<TextMarkup>();
            doc.Register<ButtonMarkup>();
            doc.Register<NumericUpDownMarkup>();

            return doc;
        }

        public virtual string Name { get; set; }
        [MarkupContent]
        public virtual string Content { get; set; }
        public virtual string Style 
        { 
            get
            {
                return style == null ? "nothing" : style;
            }
            set => style = value;
        }
        private string style;

        public abstract Control Render();
    }

    [MarkupAliases("text")]
    public class TextMarkup : BaseMarkup
    {
        [MarkupAliases("text","txt","t")]
        public override string Content { get; set; }

        public override Control Render()
        {
            return new TextBlock()
            {
                Text = Content,
                Classes = Classes.Parse(Style)
            };
        }
    }

    [MarkupAliases("btn","button")]
    public class ButtonMarkup : BaseMarkup
    {
        public override Control Render()
        {
            return new Button()
            {
                Content = Content,
                Classes = Classes.Parse(Style),
            };
        }
    }

    [MarkupAliases("numbox")]
    public class NumericUpDownMarkup: BaseMarkup
    {
        public double Value { get; set; }
        [MarkupAliases("inc")]
        public double Increment { get; set; }
        public double Min { get; set; }
        public double Max { get; set; }
        [MarkupAliases("format")]
        public NumberStyles NumberStyle { get; set; }

        public override Control Render()
        {
            return new NumericUpDown()
            {
                ParsingNumberStyle = NumberStyle,
                Value = Value,
                Increment = Increment,
                Minimum = Min,
                Maximum = Max
            };
        }
    }

    [MarkupAliases("textbox")]
    public class TextBoxMarkup: BaseMarkup
    {
        [MarkupAliases("text", "txt", "t")]
        public override string Content { get; set; }
        [MarkupAliases("wtk")]
        public string Watermark { get; set; }

        public override Control Render()
        {
            return new TextBox()
            {
                Text = Content,
                Watermark = Watermark,
                Classes = Classes.Parse(Style),
            };
        }
    }

}

Теперь создадим класс, который будет читать и исполнять плагины.

PythonTradition.cs
namespace ScheduleGenerator.Traditions
{
    public class PythonTradition : ITradition
    {
        public readonly static ScriptEngine python;

        // Статический конструктор, вызываеться только раз, 
        // и вызываеться до первого вызова статический методов,
        // полей или свойств.
        static PythonTradition()
        {
            python = Python.CreateEngine();
            python.Runtime.Globals.SetVariable("App", App.Instance);
        }
        
        // Элементы StackMarkup
        public IEnumerable<BaseMarkup> Markup { get; private set; }
        public string Name { get; private set; } = "Not defined";
        public string Description { get; private set; } = "Not defined";
        public ScriptScope PythonScope { get; private set; } = python.CreateScope();
        private readonly string _markupPath;
        private readonly string _pythonPath;

        public PythonTradition(string markupPath, string pythonPath)
        {
            _markupPath = markupPath;
            _pythonPath = pythonPath;

            PythonScope.SetVariable("__id__", id);

            LoadFiles();
        }
        
        public void Refresh() => LoadFiles();
        
        private void LoadFiles()
        {
            // Добавляем локальные модули традиции, 
            // иначе IronPython будет искать модули для импорта только
            // в папке проекта, а не традиции
            var pathes = python.GetSearchPaths();
            pathes.Add(Directory.GetDirectoryRoot(_pythonPath));
            python.SetSearchPaths(pathes);
            
            // Выполняем скрипт пайтона
            PythonScope = python.ExecuteFile(_pythonPath, PythonScope);

            var doc = BaseMarkup.Document; // 
            doc.Elements.Clear();
            doc.Load(_markupPath);

            Markup = doc.Elements.Cast<BaseMarkup>();

            pathes.Remove(Directory.GetDirectoryRoot(_pythonPath));
            python.SetSearchPaths(pathes);

            if(PythonScope.ContainsVariable("__name__"))
            {
                Name = PythonScope.GetVariable<string>("__name__");
            }
            if(PythonScope.ContainsVariable("__description__"))
            {
                Description = PythonScope.GetVariable<string>("__description__");
            }
        }
    }
}

Так же нам нужно чтобы вся эта информация отображалась на экран:

TraditionsMoreVm.cs
namespace ScheduleGenerator.ViewModels
{
    using Traditions;

    public class TraditionsMoreVm : ViewModelBase, IRoutableViewModel
    {
        /****************
        Здесь идет код который нас не касаеться
        *****************/

        public TraditionsMoreVm(IScreen screen, ITradition tradition)
        {
            HostScreen = screen; //Игнорируйте, это элемент RxUI
            var stack = new StackPanel() {Spacing = 5};
            try
            {
                foreach(var element in tradition.Markup)
                {
                    var rendered = element.Render();
                    // Если имя не пустое то, вызываем событие
                    if(!string.IsNullOrWhiteSpace(element.Name) &&
                        tradition.
                        PythonScope.
                        ContainsVariable($"observe_{element.Name}")) 
                    {
                        // Если есть такая функция, то вызываем ее
                        dynamic observe = tradition.PythonScope.GetVariable($"observe_{element.Name}");
                        observe(rendered, this);
                    }
                    stack.Children.Add(rendered);
                }
                Control = stack;
            }
            catch
            {
                App.ErrorMessageBox("Ошибка", "Ошибка традиции", 
                    () => screen.Router.NavigateBack.Execute()
                );
            }
        }
    }
}

Теперь, в той же директории где и проект, добавляем файл references.py . Это нужно для корректных работ модулей (В случае чего добавьте сами нужные вам модули).

import clr

clr.AddReference("System")
clr.AddReference("Avalonia.Controls, Version=0.10.10.0, Culture=neutral, PublicKeyToken=c8d484a7012f9a8b")
clr.AddReference("Avalonia.Visuals, Version=0.10.10.0, Culture=neutral, PublicKeyToken=c8d484a7012f9a8b")
clr.AddReference("ScheduleGenerator")
clr.AddReference("IronPython")
clr.AddReference("IronPython.Modules")

Немного о моей системе плагинов, все плагины хранятся в папке Traditions, и каждая папка в этой папке, это отдельный плагин. В папке обязательно должны быть два файла:

Первый из которых это markup.stack(То что мы делали почти всю статью)

/text[Style=h1] Данная традиция внедренная!
/text Данная традиция внедрена в саму программу. То есть, даже если вы удалите эту традицию, это не приведет ни к каким эффектам.
/text[Name=path] Путь до этой традиции :
/text[Style=h2] На ваш вопрос "а зачем здесь это?" У меня есть два ответа:
/text      1) Показать пример имен, и описание самих традиций, а так же показать как их создавать.
/text      2) Автору лень создавать нормальный диззайн экрана, для перехода в документацию
/text[Style=h2; Name=foo] Кстати об документации, нажми на кнопку что бы узнать как создаваь Традиции!
/btn[Name=open_doc] Нажми Сюда!

Второй же - main.py :

from references import *

from Avalonia import *
from Avalonia.Controls import *

from System import *
from System.IO import *
from System.Diagnostics import *

__name__ = "Свобода Графика!"
__description__ = "Позволяет учитлеям выберать пару, которая им не удобна."
full_path = Path.GetFullPath(__file__)

def observe_foo(element, viewModel):
    element.Margin = Thickness(0, 30, 0, 30)

def observe_path(element, viewModel):
    element.Text += Path.GetDirectoryName(full_path) 

def observe_open_doc(element, viewModel):
    element.Click += lambda x,y : click_open_doc() 

def click_open_doc():
    webbrowser.open("https://github.com/2Xpro-pop/ScheduleGenerator/tree/master

Свойство name у тегов вызывает функции из python кода, а именно observe_name, где первый аргумент это уже "готовый" элемент UI. То есть передаётся не наш markup, а объект из Avolonia (GUI Фреймворк).

А вот и результат:

Нажимаем на кнопку:

Конечно для создание плагина можно было просто использовать IronPython, а для языка разметки выбрать уже готовое решение, такие как xml, yaml, json, ну или динамически загружать XAML.

Однако это была лишь моя курсовая и у меня есть страсть пытаться создавать свои ЯП или языки разметки. Что ж сказать, я молодой, и у меня кровь кипит делать велосипед.

Теги:
Хабы:
Всего голосов 4: ↑3 и ↓1+4
Комментарии5

Публикации

Истории

Работа

Ближайшие события

15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань