Модульная Архитектура И Многоразовый Код



Модульная архитектура и многоразовый код

Меня всегда интересовала разработка многократно используемого и согласованного кода.

Но проблема многоразового кода начинается на этапе переноса в другую инфраструктуру.

Если приложение расширяется плагинами, то плагины пишутся для конкретного приложения.

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

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

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



Идея

Первым условием будущей системы является возможность динамического расширения системы без необходимости перекомпиляции отдельных модулей.

Это относится как к хосту, так и к модулям.

Любое звено в решении (кроме базовых интерфейсов) можно переписать и динамически интегрировать.

Помимо возможности расширять модули с помощью интерфейсов, я хотел иметь возможность получать динамический доступ к общедоступным методам, свойствам и событиям, доступным в любом модуле.

Соответственно, все элементы класса, реализующего базовый интерфейс IPlugin, помеченные с доступностью как public, должны быть видимы снаружи другими модулями.

Любой модуль можно удалить и добавить в инфраструктуру, но если вы решите заменить один модуль на другой, вам придется реализовать весь функционал удаленного модуля.

Те.

Модули идентифицируются с помощью атрибута AssemblyGuidAttribute, который добавляется автоматически при создании проекта.

Поэтому 2 модуля с одинаковым ID не загрузятся.

Каждый модуль должен быть облегченным, чтобы базовые интерфейсы не нужно было постоянно обновлять, а при необходимости модуль можно было удалить из системы и интегрировать как обычную сборку в приложение через Reference. К счастью, CLR загружает зависимые сборки посредством отложенной загрузки (LazyLoad), поэтому нет необходимости в сборках модульной инфраструктуры.

И последнее условие — система должна обеспечивать постепенное расширение функционала для разработчика, чтобы уровень входа находился на достаточно низком уровне.

При этом система должна автоматизировать рутинные задачи, повторяющиеся от приложения к приложению.

А именно: Сохранение/загрузка пользовательских настроек или хранилище общих настроек, Сохранение состояния или других параметров, в зависимости от приложения, Перенос ранее написанных компонентов, Ограничение использования ПО без достаточного уровня прав (Загружать компоненты с уровня доступа, и не скрывать элементы интерфейса), Взаимодействие с облачной инфраструктурой без необходимости изменения логики (Очередь сообщений, REST, сервисы SOAP, веб-сокеты, кэширование, OAuth/OpenId/OpenId Connect.)

Решение

В результате накопленных решений и отдельных компонентов, работающих по единому принципу, было составлено общее видение всей инфраструктуры: Минимальные требования к базовым интерфейсам, Модульная инфраструктура с независимым источником загрузки модулей, Хранение общих настроек, Независимость решения от реализации приложения (UI, Сервисы): Какие хосты есть на момент написания: Диалог , МДИ , EnvDTE (надстройка Visual Studio) .

[Не работает в Visual Studio 2015], Компонент ASP.NET (требует улучшения → IHttpHandler, OwinMiddleware), Служба Windows Для обеспечения независимости разработки как от конкретного приложения, так и от самих программ появились следующие ключевые компоненты: Интерфейсы SAL — Сборки с базовыми интерфейсами и интерфейсами расширения Хозяин - Приложение.

(при использовании в Visual Studio — надстройка EnvDTE), что зависит от версии запускающего приложения, Плагин — По сути, это независимый модуль (плагин) для хоста, но он может зависеть от других модулей или реализовывать основу для группы других модулей.

Помимо обычных плагинов, выполняющих свои собственные задачи, существует 3 типа плагинов, которые активно использует сам хост: ЗагрузчикПровайдер — Поставщик, позволяющий загружать другие модули из разных источников.

Для тестов я написал загрузчик из файловой системы в память (Не работает с Managed C++), загрузку по сети исходя из роли пользователя (Сервер пишется под конкретную задачу).

Но это не перераспределение; текущая архитектура позволяет использовать как, например, nuget.org в качестве источника, так и удаленную связь с хостом, развернутым на другой машине.

НастройкиПровайдер — Провайдер, отвечающий за сохранение и загрузку настроек плагина.

Как я писал выше, по умолчанию написанные хосты используют XML для сохранения и загрузки данных, но это не ограничивает дальнейшее развитие.

В готовых модулях я привел в пример провайдера, использующего MSSQL. Ядро — Ядро бизнес-логики и массив зависимых модулей.

По своей сути это не только основа для зависимых модулей, но и идентификация приложения для хоста (Как минимум, для идентификации в SettingsProvider, ведь на одном хосте могут запускаться разные массивы модулей, объединенные разными модулями ядра ).



Готовые базовые сборки

В результате этих требований были сформированы следующие базовые сборки: SAL.Core — Набор минимально необходимых интерфейсов для хостов и модулей, SAL.Windows — Зависит от SAL.Core. Набор интерфейсов для хостов и модулей, поддерживающих стандартную функциональность приложений WinForms, WPF (Form, MenuBar, StatusBar, ToolBar.), SAL.Web — Зависит от SAL.Core. Набор хост-интерфейсов и модулей, поддерживающих приложения, написанные с использованием ASP.NET (требуется значительное улучшение).

SAL.EnvDTE — Зависит от SAL.Windows. Предоставляет расширения для плагинов, которые могут взаимодействовать с оболочкой, на которой написана Visual Studio. Для минимального функционирования системы достаточно добавить ссылку на SAL.Core, а при необходимости реализации или использования расширений добавить ссылку на соответствующий набор расширений интерфейса.

Либо самостоятельно расширить минимальный набор интерфейсов необходимой абстракцией.

При запуске хоста первое, что делается, это инициализирует встроенные в хост базовые модули для загрузки настроек и внешних плагинов (LoaderProvider и SettingsProvider).

Сначала инициализируется поставщик плагина, а затем поставщик настроек.

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

Затем встроенный в хост поставщик настроек загружает настройки из XML-файла, расположенного в профиле пользователя.

Оба провайдера поддерживают иерархическую инфраструктуру наследования и при обнаружении другого провайдера становятся родительскими для нового провайдера.

Если провайдер не находит необходимые ресурсы, то запрос ресурсов адресуется родительскому провайдеру.

После завершения процесса инициализации всех провайдеров инициализируется все ядро, а затем и остальные плагины.

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

Это поведение можно переписать в хостах, если необходимо соблюдать иерархию загрузки других типов плагинов.

Сейчас думаю перенести последовательность загрузки модулей в ядро.



Загрузка сборок

Стандартный LoaderProvider посредством отражения ищет все публичные классы, реализующие IPlugin и это не правильный подход. Дело в том, что если в коде вызывается конкретный класс или через рефлексию вызывается конкретный класс, и этот класс не ссылается ни на какие сторонние сборки, то события СборкаРешить не случится.

То есть сборку можно удалить из модульной инфраструктуры и использовать как обычную сборку, добавив на нее ссылку и необходимость в SAL.dll отпадет. Но базовые поставщики модулей реализованы по принципу сканирования текущей папки и всех объектов сборки, поэтому событие AssemblyResolve для всех ссылающихся сборок произойдет в момент загрузки модуля.

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

Некоторые требуют заранее указать список сборок, некоторые сканируют папки самостоятельно.

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



SAL.Core

Базовые интерфейсы и небольшие фрагменты кода реализованы в абстрактных классах для упрощения разработки.

Версия .

NET Framework v2.0 была выбрана как самая минимальная версия фреймворка за основу.

Выбор минимально необходимой версии позволяет использовать базу на любой платформе, поддерживающей данную версию фреймворка, а обратная совместимость (выбор времени выполнения при запуске) позволяет использовать базу вплоть до .

NET Core (исключая пока).

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

На практике наверняка найдутся условия, при которых их придется расширять.

В этом случае весь код абстрактных классов можно переписать, а интерфейсы расширить собственной реализацией.

Поэтому данная сборка содержит самый минимально возможный код. На момент написания единственным хостом, который наследует базовые интерфейсы, является хост для приложений WinService.

SAL.Windows

Это набор базовых классов, который обеспечивает основу для написания приложений на основе WinForms и WPF. Он включает в себя интерфейсы для работы с абстрактным меню, панелью инструментов и окнами.



SAL.EnvDTE

С точки зрения расширения хост в качестве надстройки для Visual Studio расширяет интерфейсы SAL.Windows и добавляет функциональные возможности, специфичные для VS. Если зависимый плагин не находит ядро, взаимодействующее с Visual Studio, он может продолжать работать с ограниченной функциональностью.

Все письменные хосты, поддерживающие интерфейсы SAL.Core, автоматизируют следующие функции:

  • Загрузка плагинов из текущей папки,
  • Сохранение и загрузка настроек плагина из XML-файлов в профиле пользователя,
  • Восстанавливает позиции и размеры всех ранее закрытых окон при открытии приложения (SAL.Windows).

На этих интерфейсах реализованы следующие хосты:
  • Хост MDI - Многодокументный интерфейс, написанный с использованием компонента Пакет DockPanel ,
  • Диалог хоста - Диалоговый интерфейс с управлением через панель инструментов Windows,
  • Хост EnvDTE — Надстройка для Visual Studio, протестирована на версиях EnvDTE: 8,9,10,12.
  • Хост-служба Windows — Хост как служба Windows с возможностью установки, удаления и запуска через параметры командной строки (PowerShell не поддерживается).

Протоколирование событий реализовано через стандартный System.Diagnostics.Trace. В хостах MDI, Dialog и WinService прослушиватель, указанный в app.config, пытается отправить полученные события обратно в само приложение через Singleton, которые затем отображаются в окнах журналов (Output или EventList) в зависимости от события.

Для devenv.exe также можно зарегистрировать прослушиватель трассировки в app.config, но в этом случае мы загрузим сборку хоста перед ее загрузкой в качестве надстройки.

Поэтому прослушиватель трассировки добавляется программно в коде (отображается на панели инструментов вывода VS или в модальном окне).

Написанная инфраструктура позволяет разработку в направлении HTTP-приложений, но для этого необходимо реализовать некоторые модули, обеспечивающие как минимум аутентификацию, авторизацию и кэширование.

Для приложения TTManager, о котором рассказывается ниже, мы реализовали собственный хост для WEB-сервисов, в котором реализован весь необходимый функционал, но, увы, он сделан под конкретную задачу, а не как универсальное приложение.

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

Например, при развертывании массива модулей в Windows 10 я обнаружил, что загрузка происходит гораздо дольше, чем на других версиях ОС.

Даже на моей старой машине с WinXP загрузка 35 модулей занимает максимум 5 секунд. А вот на Win10 процесс загрузки одного-единственного модуля занимал гораздо больше времени.



Модульная архитектура и многоразовый код

Благодаря независимой архитектуре локализовать проблемный модуль удалось мгновенно.

(В данном случае проблема заключалась в использовании среды выполнения v2.0 под Windows 10).



Готовые модули

Первая версия инфраструктуры появилась в 2009 году.

Как для тестирования, так и для ускорения выполнения тривиальных рабочих задач скопилось большое количество разнообразных и независимых модулей, автоматизирующих различные задачи (Все картинки кликабельны, модули можно скачать на сайте страницы проекта).



Тестовый клиент веб-службы/Windows Communication Foundation



Модульная архитектура и многоразовый код

Это приложение основано на приложении, входящем в состав Visual Studio — тестовом клиенте WCF. На мой взгляд, в первоисточнике много неудобных моментов.

К моменту перехода на WCF я уже написал множество приложений, использующих обычные WebServices. Изучив принципы работы самой программы через ILSpy, я решил расширить функционал не только WCF, но и WS-клиентов.

В итоге, разобрав основную программу, я написал плагин со следующим расширенным функционалом:

  1. Поддержка приложений WebService (кроме заголовка Soap),
  2. Возможность тестирования сервиса со старыми привязками (при открытии он не обновляет класс прокси автоматически, а только по запросу из UI),
  3. Независимость от Visual Studio (объединение зависимых сборок через ILMerge),
  4. Просмотр всех добавленных сервисов в виде дерева, а не работа только с одним сервисом,
  5. Функция поиска по всем узлам дерева,
  6. В форму запроса услуги добавлен таймер для отслеживания времени, затраченного на выполнение запроса.

  7. Добавлено восстановление отправленных параметров при закрытии и открытии тестовой формы или всего приложения,
  8. Добавлена возможность сохранять и загружать параметры в файл с помощью кнопки на форме проверки метода.

  9. Добавлена возможность автосохранения и загрузки параметров метода (Вам понадобится модуль Plugin.Configuration → Автосохранение входных значений [False])
  10. Нарушена возможность редактирования файла .

    config через программу SvcConfigEditor.exe.



RDP-клиент



Модульная архитектура и многоразовый код

Опять же первоисточником программы были программисты из M$.

Программа основана на РДКман , но, в отличие от основной программы, я решил встроить окно подключенного сервера в диалоговый интерфейс.

А удаленное хранение настроек помогло поддерживать список серверов всех вовлеченных коллег в актуальном состоянии.



Информация о ЧП



Модульная архитектура и многоразовый код

Первоисточником этого приложения является новая идея автоматизации, которую я не смог найти в других приложениях.

Для написания такого приложения было 3 цели:

  1. Предоставить интерфейс для просмотра содержимого PE-файла, включая большинство каталогов и таблиц метаданных (хотя вывод ресурсов RT_DIALOG существенно отличается от оригинала).

  2. Поиск по файловой структуре PE/CLI
  3. Предоставить возможность загрузки PE-файла не только из файловой системы, но и через функцию WinAPI LoadLibrary. В случае загрузки через LoadLibrary есть возможность прочитать распакованный PE-файл и рассчитывать не нужно РВА .

Несколько раз оказывалось, что в исполняемых файлах реализован какой-то функционал, но этот функционал либо устарел, либо никем не использовался.

Чтобы не искать в исходниках приложений на разных языках использование тех или иных объектов, было написано это приложение.

Например, у меня есть сборка в общем репозитории и я решил удалить из этой сборки один метод. Как узнать, используется ли этот метод в текущих зависимых сборках других проектов, написанных коллегами? Вы можете попросить всех проверить исходный код, можете посмотреть и поискать в Source Control, а можете просто поискать одноименный метод внутри скомпилированных сборок.

Он состоит из 2-х компонентов:

  1. Сборка PEreader (написана без маркера unsafe), исходники которой доступны на GitHub 'э,
  2. Клиентская часть, представляющая собой плагин для инфраструктуры SAL, использующий уровень абстракции SAL.Windows.
Для поиска по иерархии файлов PE, DEX, ELF и ByteCode был написан отдельный модуль, который идеально вписывается в инфраструктуру: ОтражениеПоиск .

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



Отдых

Чтобы не описывать весь список готовых модулей с каждым отдельным пунктом, опишу остальные модули одним списком:
  1. Информация об изображении ELF — Анализ файла ELF, аналогичного PE Info. ElfReader на GitHub .

  2. Информация о байт-коде (.

    class) Дизассемблирование файла .

    class JVM. Программа чтения байт-кода на GitHub

  3. Информация о DEX (Давлик) — Дизассемблирование формата DEX, который используется в Android-приложениях.

    DexReader на GitHub

  4. Поиск отражения — Сборка для поиска по объектам через отражение.

    Раньше он был частью модуля PE Info, но с появлением других модулей был вынесен в отдельный модуль, используя публичные методы модулей PE, ELF, DEX и ByteCode.

  5. .

    NET-компилятор — Компилятор кода .

    NET в реальном времени в текущем домене приложений.

    Предоставляет возможность писать код (TextBox), размещать скомпилированное приложение, кэшировать скомпилированный код и хранить скомпилированный код как отдельную сборку (используется во второй итерации автоматизации приложений HTTP Harvester [описано ниже]).

  6. Браузер — Хостинг для Трайдент с расширенным функционалом для получения XPath (самописный, аналогичный HtmlAgilityPack ) к элементам DOM. (Используется в третьей итерации автоматизации приложений HTTP Harvester [описано ниже]).

  7. Конфигурация — Пользовательский интерфейс для редактирования настроек плагина, поскольку не все настройки доступны через пользовательский интерфейс при использовании SAL.Windows.
  8. Члены — Отображение в публичном интерфейсе элементов плагинов, доступных для вызова извне.

  9. Информация об устройстве - Сборка, способная читать S.M.A.R.T. атрибуты совместимых устройств и работает без небезопасного маркера.

    Для получения всех данных используется функция WinAPI УстройствоIOControl , исходный код самой сборки доступен по адресу GitHub Является.

  10. Один экземпляр — Ограничение приложения одним экземпляром (Обмен ключами осуществляется через .

    NET Remoting),

  11. Поставщик настроек SQL — Провайдер для сохранения и загрузки настроек из MSSQL. (код написан на ADO.NET и хранимых процедурах с упором на унификацию, поэтому для отдельных СУБД придется писать свои реализации хранилища),
  12. Сценарий сборки SQL — Создание сценария Microsoft SQL Server из сборки .

    NET для установки управляемого кода в MSSQL (не проверялось на небезопасных сборках),

  13. Winlogon — Модуль предоставляет публичные мероприятия для СЕНС интерфейсы.

    В первой версии использовался Winlogon, но он больше не поддерживается.

  14. EnvDTE.PublishCmd — Этот модуль я подробно описано здесь .

  15. EnvDTE.PublishSql — До или после публикации вручную выполняет произвольный SQL-запрос через ADO.NET с указанием значений шаблона.

Отдых здесь (Всего выложено около 30 модулей).

Изображения всех модулей здесь .



Готовые решения

Чтобы наглядно продемонстрировать удобство построения всего комплекса по модульной архитектуре, приведу пару готовых решений, построенных на разных принципах:
  • Полная независимость модулей друг от друга
  • Частичная зависимость от модуля ядра


ТТМенеджер



Модульная архитектура и многоразовый код



Модульная архитектура и многоразовый код



Модульная архитектура и многоразовый код

Приложение системы задач, основанное на динамической системе расширения с возможностью использования различных источников задач.

Результатом является единый интерфейс, позволяющий создавать, экспортировать/импортировать и просматривать задачи из разных источников.

На данный момент поддерживает MSSQL, WebService и частично REST API задач Мегаплана в качестве источника (не рекламы).

WebService написан по аналогичному принципу с использованием базовых классов SAL.Web. Таким образом, сам WebService также может снова использоваться в качестве источника для MSSQL, Megaplan или WebService.

Как это работает
Плагин приложения ядра, отложенная загрузка ищет все плагины источника задач (DAL).

Если обнаружено несколько плагинов доступа к данным, клиенту предлагается выбрать тот плагин, который он хочет использовать (Только в SAL.Windows, на хостах без пользовательского интерфейса - вылетит с ошибкой).

Зависимые плагины получают доступ к выбранному плагину DAL через модуль ядра.



Интересные моменты
В этом примере плагин ядра абстрагируется интерфейсами от других зависимых плагинов.

В этом случае вы можете написать другой модуль ядра (или переписать текущий).

Или вообще переписать любой плагин), чтобы иметь возможность работать с несколькими источниками задач одновременно.

Для решения проблемы со статусами задач в некоторые плагины DAL встроена матрица статусов (или взята из источника задач, если таковой имеется).

В этом случае не возникает проблем с переносом данных из одного источника в другой.



HTTP-комбайн



Модульная архитектура и многоразовый код



Модульная архитектура и многоразовый код



Модульная архитектура и многоразовый код

Приложение позволяет с помощью готовых плагинов парсить сайты через Trident или WebRequest. Для анализа доступно несколько уровней абстракции.

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

Более высокий уровень предполагает написание .

NET-кода во время выполнения, который с помощью плагина «.

NET Compiler» будет скомпилирован и применен к результату страницы, отображаемой в Trident во время выполнения.

Самый высокий уровень включает в себя указание через пользовательский интерфейс элементов на странице веб-сайта, отображаемой в Trident. А после применения xpath (самописной версии) шаблона передать его на обработку универсальному плагину или выполнить .

NET-код из плагина «.

NET Compiler».



Как это работает
Зависимому от плагина ядра модулю предлагается выбрать один из готовых интерфейсов вывода и базовый пользовательский интерфейс для загрузки данных.

Либо Trident, либо WebRequest с возможностью ведения журнала.

Ядро предлагает не только интерфейс, но и таймер для опроса каждого отдельного модуля.

Выходной интерфейс предлагает стандартный GridView с контейнером вывода данных с возможностью сохранения последней открытой позиции в таблице.

По умолчанию контейнер поддерживает отображение изображений или текстовых данных.



Интересные моменты
В данном случае я не абстрагировал плагин ядра с интерфейсами, и все зависимые плагины ожидают найти конкретный плагин ядра в массиве загруженных плагинов.

Приложение писалось в 3 итерации (Только для SAL.Windows):

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

  2. Теперь можно заменить код в плагине, используя код времени выполнения, созданный и отредактированный в Plugin.Compiler.
  3. Теперь можно указать путь к узлам HTML в Trient через пользовательский интерфейс.

    В результате для кода среды выполнения или онлайн-кода предоставляется массив Ключ/Значение, где значение представляет собой путь к элементу(ам) HTML, аналогичный реализации в HtmlAgilityPack )



Что уже устарело и удалено

  1. Хост для Office 2010 был удален.

    Он был написан исключительно для возможности создания задачи для ТТМенеджера из контекстного меню, но из-за обилия костылей и ограниченности возможностей дальнейшая поддержка оказалась нецелесообразной.

  2. Удалена возможность создавать окна в EnvDTE через ATL. До VS 2007 возможность создавать окна в студии реализовывалась только через ATL и COM. Потом стало возможно всё делать через .

    NET.

  3. Хост для EnvDTE, реализованный как надстройка, устарел.



Известные ошибки

Хост EnvDTE тестировался только в английских студиях.

Проблемы могут возникнуть на локализованных версиях (проверял один раз на VS11 с русской локализацией).

Хост EnvDTE закрывает студию, если загружен плагин Winlogon (SENS) и пользователь решает выгрузить хост через диспетчер надстроек.

(Встречались на Windows 10).

Т.

к.

Хост написан как Надстройка, а не как полноценное расширение, поэтому совместимости с другими продуктами на базе EnvDTE нет.

Каковы прогнозы дальнейшего развития?

Если вы хотите использовать функции кэширования, помимо встроенных классов System.Web.Caching.Cache и System.Runtime.Caching.MemoryCache, доступны удаленные кэши.

Например, AppFabric. Написав базовый клиентский интерфейс для кэширования, вы можете разработать массив модулей для каждого типа кэша и выбрать нужный модуль по мере необходимости (на момент публикации уже написан, но не выложен).

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

Дальнейшее развитие позволяет использовать не только файловую систему в качестве хранилища, но и использовать в качестве хранилища nuget или реализовать хост, позволяющий удаленно запускать модули.

Персонализация пользователя возможна как с помощью ролей, так и с помощью утверждений.

Но при использовании OpenId, OAuth, OpenId Connect существует огромное количество поставщиков, и каждый поставщик должен получить System.Security.Principal.IIdentity (при использовании аутентификации на основе ролей) или System.Security.Claims.ClaimsIdentity (при использовании Аутентификация претензий).

Соответственно, написав один раз клиент для LinedIn, вы сможете использовать его в любом приложении без перекомпиляции.

При использовании очередей сообщений можно написать модуль и набор интерфейсов, которые будут выполнять функции ServiceBus, а за прием и отправку сообщений будут отвечать модули реализации конкретной очереди.

Вы можете написать интерфейс пользовательского интерфейса Теги: #программирование #.

NET #ASP.NET #программирование #Анализ и проектирование систем #.

NET #C++

Вместе с данным постом часто просматривают:

Автор Статьи


Зарегистрирован: 2019-12-10 15:07:06
Баллов опыта: 0
Всего постов на сайте: 0
Всего комментарий на сайте: 0
Dima Manisha

Dima Manisha

Эксперт Wmlog. Профессиональный веб-мастер, SEO-специалист, дизайнер, маркетолог и интернет-предприниматель.