Русский
Русский
English
Статистика
Реклама

C

Строим безопасную разработку в ритейлере. Опыт одного большого проекта

17.09.2020 10:15:44 | Автор: admin
Некоторое время назад мы закончили строить процесс безопасной разработки на базе нашего анализатора кода приложений в одной из крупнейших российских ритейловых компаний. Не скроем, этот опыт был трудным, долгим и дал мощнейший рывок для развития как самого инструмента, так и компетенций нашей команды разработки по реализации таких проектов. Хотим поделиться с вами этим опытом в серии статей о том, как это происходило на практике, на какие грабли мы наступали, как выходили из положения, что это дало заказчику и нам на выходе. В общем, расскажем о самом мясе внедрения. Сегодня речь пойдет о безопасной разработке порталов и мобильных приложений ритейлера.


Для начала в целом про проект. Мы выстроили процесс безопасной разработки в крупной торговой компании, в которой ИТ-подразделение имеет огромный штат сотрудников и разделено на множество направлений, минимально коррелирующих между собой. Условно эти направления можно разделить на 3 основные группы. Первая, очень большая группа, это кассовое ПО, которое написано преимущественно на языке Java (90% проектов). Вторая, самая обширная с точки зрения объема кода группа систем это SAP-приложения. И наконец, третий блок представлял собой сборную солянку из порталов и мобильных приложений: разного рода внешние сайты для клиентов компании, мобильные приложения к этим сайтам, а также внутренние ресурсы мобильные приложения и веб-порталы для персонала ритейлера.

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

Этот подход мы сформулировали максимально просто: сканируется наиболее актуальный для всех разработчиков код. Если говорить в терминах Gitflow, а все группы проектов, за исключением SAP, вели ветки разработки в Gitflow, сканируется основная ветка разработки по расписанию.

Но, как всегда, из любого правила бывают исключения: общий подход не везде мог быть применен as is по ряду причин. Во-первых, наш инструмент (анализатор кода) имеет несколько ограничений, обусловленных тем, что мы хотим иметь возможность при необходимости делать наиболее глубокий анализ некоторых языков программирования. Так, в случае с Java анализ по байткоду гораздо более глубокий, чем по исходному коду. Соответственно, для сканирования Java-проектов требовалась предварительная сборка байткода и лишь затем его отправка на анализ. В случае с C++, Objective C и приложениями для iOS анализатор встраивался в процесс на этапе сборки. Также мы должны были учесть различные индивидуальные требования со стороны разработчиков всех проектов. Ниже расскажем, как мы выстроили процесс для порталов и мобильных приложений.

Порталы и мобильные приложения


Вроде бы все эти приложения объединены в одну логическую группу, но на самом деле в них была жуткая каша. Одних порталов было больше 120-ти (!). Компания очень большая, со множеством бизнес, административных и технических подразделений, и периодически каждое из них решает, что ему нужен свой портал и мобильное приложение. Этот портал и приложение создаются, ими какое-то время пользуются, а потом благополучно забрасывают. В результате на начальном этапе нам пришлось провести для заказчика инвентаризацию, поскольку даже разработчики этих приложений не имели единого списка кодовых баз. Например, для управления репозиториями в этой группе разработчики использовали два GitLab, администраторами которых являлись разные люди. Кроме того, среди порталов и мобильных приложений существенная часть проектов была реализована с помощью внешней разработки. Поэтому, когда подходило время релиза, зачастую подрядчики передавали компании исходные коды новой версии чуть ли не на флешке. В итоге компания имела зоопарк различных приложений и полный беспорядок в их коде. Нам пришлось составить список всех проектов, найти всех ответственных за них техвладельцев, тимлидов, а затем согласовать с основным заказчиком департаментом ИБ, какие из них мы будем анализировать.

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

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

Интеграция по стандартной схеме


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


Настройка интеграции с GitLab

Далеко не во всех приложениях применялась CI/CD, и там, где ее не было, нам приходилось настаивать на ее применении. Потому что если вы хотите по-настоящему автоматизировать процесс проверки кода на уязвимости (а не просто в ручном режиме загружать ссылку на анализ), чтобы система сама скачивала ее в репозиторий и сама выдавала результаты нужным специалистам, то без установки раннеров вам не обойтись. Раннеры в данном случае это агенты, которые в автоматическом режиме связываются с системами контроля версий, скачивают исходный код и отправляют в Solar appScreener на анализ.

Разработчики группы порталов и мобильных приложений хотели организовать безопасную разработку как полуавтоматизированный процесс, чтобы код сканировался на уязвимости без какого-либо участия с их стороны. Чтобы офицер безопасности верифицировал результаты анализа на уязвимости и ставил разработчикам задачи в Jira, если считал уязвимости критичными, или же отправлял их разработчикам для получения разъяснений. Разработчики решали бы, нужно ли срочно исправлять уязвимость или нет. И если нужно, планировали бы, в какой релиз смогут включить исправления.

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


Настройка интеграции с Jira

В редких случаях тим-лиды сами смотрели результаты сканирования и заводили задачи в Jira вручную.


Создание задачи в Jira

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

В итоге процесс безопасной разработки после внедрения Solar appScreener стал выглядеть так: порталы ежедневно проверяются на наличие изменений в коде основной ветки разработки. Если основная, наиболее актуальная ветка не обновлялась в течение суток, то ничего не происходит. Если же она обновилась, то запускается отправка этой ветки на анализ в соответствующий проект для этого репозитория. Репозиторию в GitLab соответствовал определенный проект в анализаторе кода, и именно в нем сканировалась основная ветка. После этого офицер безопасности просматривал результаты анализа, верифицировал их и заводил задачи на исправления в Jira.


Результаты анализа и созданные в Jira задачи на исправление уязвимостей

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

Нестандартное в стандартном


В этом, на первый взгляд, не таком уж и сложном процессе имелось два серьезных ограничения. Во-первых, для анализа Android-приложений (то есть написанных на Java) нам нужна была сборка. А во-вторых, для iOS нужны были машины с MacOS, на которых устанавливался бы наш агент и имелась бы среда, которая позволяла бы собирать приложения. С Android-приложениями мы разобрались довольно просто: написали свои части в уже имеющиеся у разработчиков скрипты, которые запускались так же по расписанию. Наши части скриптов предварительно запускали сборку проекта в наиболее широкой конфигурации, которая направлялась в Solar appScreener на анализ. Для проверки же iOS-приложений мы устанавливали на Mac-машину наш MacOS-агент, который производил сборку кода и так же через GitLab CI отправлял код в анализатор на сканирование. Далее, как и в случае с другими видами ПО, офицер безопасности просматривал результаты анализа, верифицировал их и заводил задачи на исправления в Jira.

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

В тех проектах, где не было CI/CD, что было обязательным для нас условием, мы просто говорили: Ребята, хотите анализировать собирайте в ручном режиме и загружайте в сканер сами. Если у вас нет Java или JVM-подобных языков Scala, Kotlin и прочих, то можете просто загружать код в репозиторий по ссылке, и все будет хорошо.

Сложности проекта


Как видно из вышесказанного, в этом стеке приложений основной проблемой было отсутствие во многих проектах CI/CD. Разработчики часто делали сборки вручную. Мы начали интеграцию нашего анализатора с порталов Sharepoint на языке C#. Сейчас C# более-менее перешел на Linux-системы, хотя и не совсем полноценные. А когда проект был в самом разгаре, этот язык еще работал на Windows, и нам приходилось ставить агент на Windows для GitLab. Это было настоящим испытанием, поскольку наши специалисты привыкли использовать Linux-команды. Необходимы были особенные решения, например, в каких-то случаях нужно было указывать полный путь до exe-файла, в каких-то нет, что-то надо было заэкранировать и т.п. И уже после реализации интеграции c Sharepoint команда проекта мобильного приложения на PHP сказала, что у них тоже нет раннера и они хотят использовать C#-овский. Пришлось повторять операции и для них.

Резюме


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

  • внедряемое нами решение достаточно взрослое, чтобы проявлять нужную гибкость для построения процессов DevSecOps в кардинально разных средах внедрения. Гибкость достигается за счёт большого набора встроенных и кастомных интеграций, без которых трудозатраты на внедрение возросли бы в разы или сделали бы его невозможным;
  • настройка нужной автоматизации и последующий разбор результатов не требуют необъятного количества трудозатрат даже при огромном скоупе работ. Согласование и построение внедряемых процессов и полная их автоматизация возможны усилиями небольшой экспертной группы из 3-4 человек;
  • внедрение средств автоматической проверки кода и практик DevSecOps позволяет выявить недостатки текущих процессов DevOps и становится поводом для их настройки, улучшения, унификации и регламентирования. В конечном счёте получается win-win ситуация для всех участников процесса от рядовых разработчиков до топ-менеджеров отделов инженерии и ИБ.

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

А был ли у вас свой опыт реализации подобных проектов? Будем рады, если вы поделитесь с нами своими кейсами внедрения практик безопасной разработки в комментариях!

Автор: Иван Старосельский, руководитель отдела эксплуатации и автоматизации информационных систем
Подробнее..

Из песочницы Как НЕ надо начинать изучать программирование

12.09.2020 16:16:00 | Автор: admin
Приветствую, Хабровцы!

Решил поделиться своим опытом успешного изучения языка(ов) программирования.

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

Немного предыстории.

Сразу скажу, что целенаправленного обучения по компьютерным наукам я не проходил. Да и специализация в образовании у меня далеко не техническая. Работал с 2005г. по 2012г. в различных компаниях, и мелких и крупных, непосредственно связанных с IT-индустрией. Научился всему понемногу: сис. администрированию Windows (даже MCP, MCSA успел получить), немного поюзал VMware (VCP тоже в копилке), дополнительно изучил разную кучу программ, которые сис. админы как правило используют в своей ежедневной работе.
Попробовал себя в корпоративных продажах, кстати, неплохо получалось. Успел поработать немного и у дистрибьютора ПО, а также в компаниях-интеграторах, неплохо разобрался в политиках лицензирования ПО. Планировал стать Project manager-ом, даже начал изучать PMBOK, тайм-менеджмент, различные международные стандарты, типа ISO, Tier, и даже замахнулся на PCI DSS.

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

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

В голове был выбран путь, что все-таки пора переквалифицироваться в разработчика. Так будет легче и лучше в плане перспектив и работы на западном рынке. Программистов на рынке труда всегда не хватало, да и не хватает до сих пор.

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

Теперь сама история, поехали

Так вот, спустя 8 лет отдыха от IT в целом, принялся изучать заокеанский рынок труда и решил для начала специализироваться в мобильной разработке. Погуглив языки программирования для мобильных приложений и вдохновившись, что Google официально анонсировала язык Kotlin как приоритетный язык для android-приложении, твердо решил максимум за 1 год самостоятельно выучить Kotlin и строить планы по иммиграции на ПМЖ в США.

Пару недель просмотра тренингов и чтения мануалов мне хватило, чтобы убедиться, что без знаний Java в Kotlin делать нечего. Хотя на просторах интернета многие твердят что можно выучить с нуля. А после регистрации на GitHub-е, установки IntelliJ IDEA, JDK и попытки разобраться в коде я уже начал осознавать что придется учиться очень долго и упорно.
Было принято решение отложить Kotlin пока что в сторону, и углубиться в язык java. Так и сделал. Эх, помнится в мое время java был еще SUN-овским детищем.

Быстро переключился на Java без сожаления, т.к. и мануалов больше для самостоятельного изучения и вакансии для Java-разработчиков намного больше. Правда не определился с чего стартануть будет лучше, с Java либо JS, ну да ладно, думал походу разберусь. На форумах где-то читал, что с JS войти в мир разработки намного легче и быстрее
.
Приступил к изучению Java стандартно, прочитав гору статей и просмотрев кучу видео Как стать Java программистом. Скачал книгу Брюса Эккеля Философия Java, по рекомендациям многих на форумах, как самый правильный старт изучения языка новичкам.

Так вот скажу вам честно, она ни сколько не для новичков.

Пробовал не обращать на это внимания и читать дальше, усвояемость около 20-25%, понял что так дело не пойдет. Придется разбираться и в С языке, да еще и в книге регулярно черным по белому пишут, что материал рассчитан на читателей со знанием основ С языка.

Что-ж, выбора нет. Опять читаю кучу информации, сотни просмотров видео разной тематики о языке С. Качаю книгу Кернигана и Ричи Язык С, приступаю к изучению, усвояемость уже получше чем в Java, так сказать около 50-60%, что вовсе не радует меня.

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

Однако такие заголовки в книге как:
Настоящая книга не является вводным курсом в программирование; она предполагает определенное знакомство с основными понятиями программирования такими как переменные, операторы присваивания, циклы, функции
или:
предполагается рабочее владение основными элементами программирования; здесь не объясняется, что такое ЭВМ или компилятор, не поясняется смысл выражений типа N=N+1
а также такие фразы как:
Символические константы.
и т.д.
постепенно подводили меня к тому, что без изучения Computer Science мне не обойтись.
Параллельно начинаю вникать в Computer Sciense, качаю опять-таки тонны книг. Регистрируюсь на Гарвардский курс CS50, приступаю к изучению основ программирования, внимательно читаю книгу Владстона Феррейра Фило Теоретический минимум по Computer Science.

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

И если в двоичном коде, переменных, функциях, циклах, компиляторе, интерпретаторе, простых уравнениях и т.д. я еще более менее разобрался, то выражение типа N=N+1 и более сложные уравнения меня загоняли в легкий ступор.

Я долго вникал почему 0 в степени 0 равен 1, и у меня ощущение что я до конца так и не понял всей сути.

А вот эта задача меня вообще заставила остановиться на чтении книги по CS, т.к. чем дальше читал, тем сложнее уже шли уравнения и задачи:



Пришлось гуглить про квадратные уравнения, как написано в книге и вникать в дискриминанты. Ну забыл, подумал я, алгебру, с кем не бывает. Однако я так и не смог решить самостоятельно задачу, пришлось обращаться за помощью к преподавателю ребенка по математике.

Решение то получил, однако понять, как преподаватель решила не смог:





На мой вопрос: как решаются такие уравнения?, ответ был очень прост:
учи исследование функции, начало анализа и задачи на оптимизацию. Алгебра 10-11 класс.
Ну думаю, ок, посмотрю пару видео-примеров для школьников в youtube, пойму как решать их, и дальше буду глокать изучение по CS.

И вот после просмотра подобных роликов по алгебре меня осенило

www.youtube.com/watch?v=RbX_QHxu7Lg
www.youtube.com/watch?v=FVSG7Neopuo

Я не то что не помню, как решаются такие задачи, элементарно, как выяснилось, попросту не знаю Алгебру за 10-11 класс!

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

Наверное, мои познания математики остались на уровне уроков математики 5-6 классов.

Начинаю осознавать, что для полной картины понимания Computer Science, мне необходимо будет заново учить алгебру, а затем и ВысшМат. Не исключаю, что походу скорее всего, появится необходимость и повторения уроков физики и еще чего-то из школьной программы. И до реального изучения Java и JS мне понадобится лет 5 изучения алгебры и высшей математики.
До Марса и обратно быстрее долететь, всего то 1,5 года, как утверждают ученные

И вот тут-то мне стало уже совсем как-то грустно.

Неужели чтобы стать программистом без технической базы, требуется так много времени?
Меня конечно вдохновляют статьи в интернете, где люди пишут, что за 1,5 года стали Java developer-ом и уехали в Германию, Канаду, США, однако оценивая свои печальный опыт я не уверен что такое возможно.

Или все-таки это не моё? И профессия разработчик это каста особенных людей?

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

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

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

Заранее благодарю!
Подробнее..

Из песочницы Продвинутое велосипедостроение или клиент-серверное приложение на базе C .Net framework

03.09.2020 14:17:53 | Автор: admin

Вступление


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

На данный момент я занимаю должность Технического Артиста в одной игровой студии, мой опыт программирования на C# строился только на написании скриптов и утилит для Unity и в довесок к этому создание плагинов для низкоуровневой работы с андроид девайсами. За пределы этого мирка я ещё не выбирался и тут подвернулась такая возможность.

Часть 1. Прототипирование рамы


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

Изучение начал с просмотра статей и документации по C# .Net. Тут я нашёл разнообразные способы для выполнения задачи. Здесь есть множество механизмов взаимодействия с сетью, от полноценных решений вроде ASP.Net или служб Azure, до прямого взаимодействия с Tcp\Http подключениями.

Сделав первую попытку с ASP я его сразу же отмёл, на мой взгляд это было слишком тяжёлым решением для нашего сервиса. Мы не будем использовать и трети возможностей этой платформы, поэтому я продолжил поиски. Выбор встал между TCP и Http клиент-сервером. Здесь же, на Хабре, я наткнулся на статью про многопоточный сервер, собрав и протестировав который, я решил остановиться именно на взаимодействии с TCP подключениями, почему то я посчитал, что http не позволит создать мне кроссплатформенное решение.

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

Здесь немного кода
Основной поток, в бесконечном цикле принимающий клиентов:

using System;using System.Net.Sockets;using System.Net;using System.Threading;namespace ClearServer{    class Server    {        TcpListener Listener;        public Server(int Port)        {            Listener = new TcpListener(IPAddress.Any, Port);            Listener.Start();            while (true)            {                TcpClient Client = Listener.AcceptTcpClient();                Thread Thread = new Thread(new ParameterizedThreadStart(ClientThread));                Thread.Start(Client);            }        }        static void ClientThread(Object StateInfo)        {            new Client((TcpClient)StateInfo);        }        ~Server()        {            if (Listener != null)            {                Listener.Stop();            }        }        static void Main(string[] args)        {            DatabaseWorker sqlBase = DatabaseWorker.GetInstance;            new Server(80);        }    }}

Сам обработчик клиентов:

using System;using System.IO;using System.Net.Sockets;using System.Text;using System.Text.RegularExpressions;namespace ClearServer{    class Client    {        public Client(TcpClient Client)        {            string Message = "";            byte[] Buffer = new byte[1024];            int Count;            while ((Count = Client.GetStream().Read(Buffer, 0, Buffer.Length)) > 0)            {                Message += Encoding.UTF8.GetString(Buffer, 0, Count);                if (Message.IndexOf("\r\n\r\n") >= 0 || Message.Length > 4096)                {                    Console.WriteLine(Message);                    break;                }            }            Match ReqMatch = Regex.Match(Message, @"^\w+\s+([^\s\?]+)[^\s]*\s+HTTP/.*|");            if (ReqMatch == Match.Empty)            {                ErrorWorker.SendError(Client, 400);                return;            }            string RequestUri = ReqMatch.Groups[1].Value;            RequestUri = Uri.UnescapeDataString(RequestUri);            if (RequestUri.IndexOf("..") >= 0)            {                ErrorWorker.SendError(Client, 400);                return;            }            if (RequestUri.EndsWith("/"))            {                RequestUri += "index.html";            }            string FilePath = $"D:/Web/TestSite{RequestUri}";            if (!File.Exists(FilePath))            {                ErrorWorker.SendError(Client, 404);                return;            }            string Extension = RequestUri.Substring(RequestUri.LastIndexOf('.'));            string ContentType = "";            switch (Extension)            {                case ".htm":                case ".html":                    ContentType = "text/html";                    break;                case ".css":                    ContentType = "text/css";                    break;                case ".js":                    ContentType = "text/javascript";                    break;                case ".jpg":                    ContentType = "image/jpeg";                    break;                case ".jpeg":                case ".png":                case ".gif":                    ContentType = $"image/{Extension.Substring(1)}";                    break;                default:                    if (Extension.Length > 1)                    {                        ContentType = $"application/{Extension.Substring(1)}";                    }                    else                    {                        ContentType = "application/unknown";                    }                    break;            }            FileStream FS;            try            {                FS = new FileStream(FilePath, FileMode.Open, FileAccess.Read, FileShare.Read);            }            catch (Exception)            {                ErrorWorker.SendError(Client, 500);                return;            }            string Headers = $"HTTP/1.1 200 OK\nContent-Type: {ContentType}\nContent-Length: {FS.Length}\n\n";            byte[] HeadersBuffer = Encoding.ASCII.GetBytes(Headers);            Client.GetStream().Write(HeadersBuffer, 0, HeadersBuffer.Length);            while (FS.Position < FS.Length)            {                Count = FS.Read(Buffer, 0, Buffer.Length);                Client.GetStream().Write(Buffer, 0, Count);            }            FS.Close();            Client.Close();        }    }}

И первая база данных построенная на local SQL:

using System;using System.Data.Linq;namespace ClearServer{    class DatabaseWorker    {        private static DatabaseWorker instance;        public static DatabaseWorker GetInstance        {            get            {                if (instance == null)                    instance = new DatabaseWorker();                return instance;            }        }        private DatabaseWorker()        {            string connectionStr = databasePath;            using (DataContext db = new DataContext(connectionStr))            {                Table<User> users = db.GetTable<User>();                foreach (var item in users)                {                    Console.WriteLine($"{item.login} {item.password}");                }            }        }    }}

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

Глава 2. Прикручивание колёс


Протестировав работу сервера, я пришёл к выводу, что это будет отличным решением(спойлер: нет), для нашего сервиса, поэтому проект начал обрастать логикой.
Шаг за шагом начали появляться новые модули и функционал сервера разрастался. Сервер обзавёлся тестовым доменом и ssl шифрованием соединения.

Ещё немного кода, описывающий логику работы сервера и обработку клиентов
Обновлённый вариант сервера, включающий в себя использование сертификата.

using System;using System.Net;using System.Net.Sockets;using System.Reflection;using System.Security;using System.Security.Cryptography.X509Certificates;using System.Security.Permissions;using System.Security.Policy;using System.Threading;namespace ClearServer{    sealed class Server    {        readonly bool ServerRunning = true;        readonly TcpListener sslListner;        public static X509Certificate serverCertificate = null;        Server()        {            serverCertificate = X509Certificate.CreateFromSignedFile(@"C:\ssl\itinder.online.crt");            sslListner = new TcpListener(IPAddress.Any, 443);            sslListner.Start();            Console.WriteLine("Starting server.." + serverCertificate.Subject + "\n" + Assembly.GetExecutingAssembly().Location);            while (ServerRunning)            {                TcpClient SslClient = sslListner.AcceptTcpClient();                Thread SslThread = new Thread(new ParameterizedThreadStart(ClientThread));                SslThread.Start(SslClient);            }                    }        static void ClientThread(Object StateInfo)        {            new Client((TcpClient)StateInfo);        }        ~Server()        {            if (sslListner != null)            {                sslListner.Stop();            }        }        public static void Main(string[] args)        {            if (AppDomain.CurrentDomain.IsDefaultAppDomain())            {                Console.WriteLine("Switching another domain");                new AppDomainSetup                {                    ApplicationBase = AppDomain.CurrentDomain.SetupInformation.ApplicationBase                };                var current = AppDomain.CurrentDomain;                var strongNames = new StrongName[0];                var domain = AppDomain.CreateDomain(                    "ClearServer", null,                    current.SetupInformation, new PermissionSet(PermissionState.Unrestricted),                    strongNames);                domain.ExecuteAssembly(Assembly.GetExecutingAssembly().Location);            }            new Server();        }    }}

А так же новый обработчик клиента с авторизацией по ssl:

using ClearServer.Core.Requester;using System;using System.Net.Security;using System.Net.Sockets;namespace ClearServer{    public class Client    {        public Client(TcpClient Client)        {            SslStream SSlClientStream = new SslStream(Client.GetStream(), false);            try            {                SSlClientStream.AuthenticateAsServer(Server.serverCertificate, clientCertificateRequired: false, checkCertificateRevocation: true);            }            catch (Exception e)            {                Console.WriteLine(                    "---------------------------------------------------------------------\n" +                    $"|{DateTime.Now:g}\n|------------\n|{Client.Client.RemoteEndPoint}\n|------------\n|Exception: {e.Message}\n|------------\n|Authentication failed - closing the connection.\n" +                    "---------------------------------------------------------------------\n");                SSlClientStream.Close();                Client.Close();            }            new RequestContext(SSlClientStream, Client);        }    }}



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

Парсер
using ClearServer.Core.UserController;using ReServer.Core.Classes;using System;using System.Collections.Generic;using System.Linq;using System.Net.Security;using System.Net.Sockets;using System.Text;using System.Text.RegularExpressions;namespace ClearServer.Core.Requester{    public class RequestContext    {        public string Message = "";        private readonly byte[] buffer = new byte[1024];        public string RequestMethod;        public string RequestUrl;        public User RequestProfile;        public User CurrentUser = null;        public List<RequestValues> HeadersValues;        public List<RequestValues> FormValues;        private TcpClient TcpClient;        private event Action<SslStream, RequestContext> OnRead = RequestHandler.OnHandle;        DatabaseWorker databaseWorker = new DatabaseWorker();        public RequestContext(SslStream ClientStream, TcpClient Client)        {            this.TcpClient = Client;            try            {                ClientStream.BeginRead(buffer, 0, buffer.Length, ClientRead, ClientStream);            }            catch { return; }        }        private void ClientRead(IAsyncResult ar)        {            SslStream ClientStream = (SslStream)ar.AsyncState;            if (ar.IsCompleted)            {                Message = Encoding.UTF8.GetString(buffer);                Message = Uri.UnescapeDataString(Message);                Console.WriteLine($"\n{DateTime.Now:g} Client IP:{TcpClient.Client.RemoteEndPoint}\n{Message}");                RequestParse();                HeadersValues = HeaderValues();                FormValues = ContentValues();                UserParse();                ProfileParse();                OnRead?.Invoke(ClientStream, this);            }        }        private void RequestParse()        {            Match methodParse = Regex.Match(Message, @"(^\w+)\s+([^\s\?]+)[^\s]*\s+HTTP/.*|");            RequestMethod = methodParse.Groups[1].Value.Trim();            RequestUrl = methodParse.Groups[2].Value.Trim();        }        private void UserParse()        {            string cookie;            try            {                if (HeadersValues.Any(x => x.Name.Contains("Cookie")))                {                    cookie = HeadersValues.FirstOrDefault(x => x.Name.Contains("Cookie")).Value;                    try                    {                        CurrentUser = databaseWorker.CookieValidate(cookie);                    }                    catch { }                }            }            catch { }        }        private List<RequestValues> HeaderValues()        {            var values = new List<RequestValues>();            var parse = Regex.Matches(Message, @"(.*?): (.*?)\n");            foreach (Match match in parse)            {                values.Add(new RequestValues()                {                    Name = match.Groups[1].Value.Trim(),                    Value = match.Groups[2].Value.Trim()                });            }            return values;        }        private void ProfileParse()        {            if (RequestUrl.Contains("@"))            {                RequestProfile = databaseWorker.FindUser(RequestUrl.Substring(2));                RequestUrl = "/profile";            }        }        private List<RequestValues> ContentValues()        {            var values = new List<RequestValues>();            var output = Message.Trim('\n').Split().Last();            var parse = Regex.Matches(output, @"([^&].*?)=([^&]*\b)");            foreach (Match match in parse)            {                values.Add(new RequestValues()                {                    Name = match.Groups[1].Value.Trim(),                    Value = match.Groups[2].Value.Trim().Replace('+', ' ')                });            }            return values;        }    }}


Суть его заключается в том, что бы при помощи регулярных выражений разбить запрос на части. Получаем сообщение от клиента, выделяем первую строку, в которой содержится метод и url запроса. Затем читаем заголовки, которые загоняем в массив вида ИмяЗаголовка=Содержимое, а так же находим, если имеется, сопроводительный контент (например querystring) который так же загоняем в аналогичный массив. К тому же, парсер выясняет, авторизован ли текущий клиент и сохраняет в себе его данные. Все запросы от авторизованных клиентов содержат хэш авторизации, который хранится в куках, благодаря этому можно разделять дальнейшую логику работы для двух типов клиентов и отдавать им правильные ответы.

Ну и небольшая, приятная фича, которую стоило бы вынести в отдельный модуль, преобразование запросов вида site.com/@UserName в динамически генерируемые страницы пользователей. После обработки запроса в дело вступают следующие модули.

Глава 3. Установка руля, смазывание цепи


Как только парсер отработал, в дело вступает обработчик, отдающий дальнейшие указания серверу и разделяющий управление на две части.

Простой обработчик
using ClearServer.Core.UserController;using System.Net.Security;namespace ClearServer.Core.Requester{    public class RequestHandler    {        public static void OnHandle(SslStream ClientStream, RequestContext context)        {            if (context.CurrentUser != null)            {                new AuthUserController(ClientStream, context);            }            else             {                new NonAuthUserController(ClientStream, context);            };        }    }}


По сути здесь всего одна проверка на авторизацию юзера, после чего начинается обработка запроса.

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

Неавторизованный пользователь
using ClearServer.Core.Requester;using System.IO;using System.Net.Security;namespace ClearServer.Core.UserController{    internal class NonAuthUserController    {        private readonly SslStream ClientStream;        private readonly RequestContext Context;        private readonly WriteController WriteController;        private readonly AuthorizationController AuthorizationController;        private readonly string ViewPath = "C:/Users/drdre/source/repos/ClearServer/View";        public NonAuthUserController(SslStream clientStream, RequestContext context)        {            this.ClientStream = clientStream;            this.Context = context;            this.WriteController = new WriteController(clientStream);            this.AuthorizationController = new AuthorizationController(clientStream, context);            ResourceLoad();        }        void ResourceLoad()        {            string[] blockextension = new string[] {"cshtml", "html", "htm"};            bool block = false;            foreach (var item in blockextension)            {                if (Context.RequestUrl.Contains(item))                {                    block = true;                    break;                }            }            string FilePath = "";            string Header = "";            var RazorController = new RazorController(Context, ClientStream);                        switch (Context.RequestMethod)            {                case "GET":                    switch (Context.RequestUrl)                    {                        case "/":                            FilePath = ViewPath + "/loginForm.html";                            Header = $"HTTP/1.1 200 OK\nContent-Type: text/html";                            WriteController.DefaultWriter(Header, FilePath);                            break;                        case "/profile":                            RazorController.ProfileLoader(ViewPath);                            break;                        default://в данном блоке кода происходит отсечение запросов к серверу по прямому адресу страницы вида site.com/page.html                            if (!File.Exists(ViewPath + Context.RequestUrl) | block)                            {                                RazorController.ErrorLoader(404);                                                           }                                                        else if (Path.HasExtension(Context.RequestUrl) && File.Exists(ViewPath + Context.RequestUrl))                            {                                Header = WriteController.ContentType(Context.RequestUrl);                                FilePath = ViewPath + Context.RequestUrl;                                WriteController.DefaultWriter(Header, FilePath);                            }                                                        break;                    }                    break;                case "POST":                    AuthorizationController.MethodRecognizer();                    break;            }        }    }}


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

WriterController
using System;using System.IO;using System.Net.Security;using System.Text;namespace ClearServer.Core.UserController{    public class WriteController    {        SslStream ClientStream;        public WriteController(SslStream ClientStream)        {            this.ClientStream = ClientStream;        }        public void DefaultWriter(string Header, string FilePath)        {            FileStream fileStream;            try            {                fileStream = new FileStream(FilePath, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite);                Header = $"{Header}\nContent-Length: {fileStream.Length}\n\n";                ClientStream.Write(Encoding.UTF8.GetBytes(Header));                byte[] response = new byte[fileStream.Length];                fileStream.BeginRead(response, 0, response.Length, OnFileRead, response);            }            catch { }        }        public string ContentType(string Uri)        {            string extension = Path.GetExtension(Uri);            string Header = "HTTP/1.1 200 OK\nContent-Type:";            switch (extension)            {                case ".html":                case ".htm":                    return $"{Header} text/html";                case ".css":                    return $"{Header} text/css";                case ".js":                    return $"{Header} text/javascript";                case ".jpg":                case ".jpeg":                case ".png":                case ".gif":                    return $"{Header} image/{extension}";                default:                    if (extension.Length > 1)                    {                        return $"{Header} application/" + extension.Substring(1);                    }                    else                    {                        return $"{Header} application/unknown";                    }            }        }        public void OnFileRead(IAsyncResult ar)        {            if (ar.IsCompleted)            {                var file = (byte[])ar.AsyncState;                ClientStream.BeginWrite(file, 0, file.Length, OnClientSend, null);            }        }        public void OnClientSend(IAsyncResult ar)        {            if (ar.IsCompleted)            {                ClientStream.Close();            }        }    }


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

RazorController
using ClearServer.Core.Requester;using RazorEngine;using RazorEngine.Templating;using System;using System.IO;using System.Net;using System.Net.Security;namespace ClearServer.Core.UserController{    internal class RazorController    {        private RequestContext Context;        private SslStream ClientStream;        dynamic PageContent;        public RazorController(RequestContext context, SslStream clientStream)        {            this.Context = context;            this.ClientStream = clientStream;        }        public void ProfileLoader(string ViewPath)        {            string Filepath = ViewPath + "/profile.cshtml";            if (Context.RequestProfile != null)            {                if (Context.CurrentUser != null && Context.RequestProfile.login == Context.CurrentUser.login)                {                    try                    {                        PageContent = new { isAuth = true, Name = Context.CurrentUser.name, Login = Context.CurrentUser.login, Skills = Context.CurrentUser.skills };                        ClientSend(Filepath, Context.CurrentUser.login);                    }                    catch (Exception e) { Console.WriteLine(e); }                }                else                {                    try                    {                        PageContent = new { isAuth = false, Name = Context.RequestProfile.name, Login = Context.RequestProfile.login, Skills = Context.RequestProfile.skills };                        ClientSend(Filepath, "PublicProfile:"+ Context.RequestProfile.login);                    }                    catch (Exception e) { Console.WriteLine(e); }                }            }            else            {                ErrorLoader(404);            }        }        public void ErrorLoader(int Code)        {            try            {                PageContent = new { ErrorCode = Code, Message = ((HttpStatusCode)Code).ToString() };                string ErrorPage = "C:/Users/drdre/source/repos/ClearServer/View/Errors/ErrorPage.cshtml";                ClientSend(ErrorPage, Code.ToString());            }            catch { }        }        private void ClientSend(string FilePath, string Key)        {            var template = File.ReadAllText(FilePath);            var result = Engine.Razor.RunCompile(template, Key, null, (object)PageContent);            byte[] buffer = System.Text.Encoding.UTF8.GetBytes(result);            ClientStream.BeginWrite(buffer, 0, buffer.Length, OnClientSend, ClientStream);        }        private void OnClientSend(IAsyncResult ar)        {            if (ar.IsCompleted)            {                ClientStream.Close();            }        }    }}



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

Модуль авторизации
using ClearServer.Core.Cookies;using ClearServer.Core.Requester;using ClearServer.Core.Security;using System;using System.Linq;using System.Net.Security;using System.Text;namespace ClearServer.Core.UserController{    internal class AuthorizationController    {        private SslStream ClientStream;        private RequestContext Context;        private UserCookies cookies;        private WriteController WriteController;        DatabaseWorker DatabaseWorker;        RazorController RazorController;        PasswordHasher PasswordHasher;        public AuthorizationController(SslStream clientStream, RequestContext context)        {            ClientStream = clientStream;            Context = context;            DatabaseWorker = new DatabaseWorker();            WriteController = new WriteController(ClientStream);            RazorController = new RazorController(context, clientStream);            PasswordHasher = new PasswordHasher();        }        internal void MethodRecognizer()        {            if (Context.FormValues.Count == 2 && Context.FormValues.Any(x => x.Name == "password")) Authorize();            else if (Context.FormValues.Count == 3 && Context.FormValues.Any(x => x.Name == "regPass")) Registration();            else            {                RazorController.ErrorLoader(401);            }        }        private void Authorize()        {            var values = Context.FormValues;            var user = new User()            {                login = values[0].Value,                password = PasswordHasher.PasswordHash(values[1].Value)            };            user = DatabaseWorker.UserAuth(user);            if (user != null)            {                cookies = new UserCookies(user.login, user.password);                user.cookie = cookies.AuthCookie;                DatabaseWorker.UserUpdate(user);                var response = Encoding.UTF8.GetBytes($"HTTP/1.1 301 Moved Permanently\nLocation: /@{user.login}\nSet-Cookie: {cookies.AuthCookie}; Expires={DateTime.Now.AddDays(2):R}; Secure; HttpOnly\n\n");                ClientStream.BeginWrite(response, 0, response.Length, WriteController.OnClientSend, null);            }            else            {                RazorController.ErrorLoader(401);            }        }        private void Registration()        {            var values = Context.FormValues;            var user = new User()            {                name = values[0].Value,                login = values[1].Value,                password = PasswordHasher.PasswordHash(values[2].Value),            };            cookies = new UserCookies(user.login, user.password);            user.cookie = cookies.AuthCookie;            if (DatabaseWorker.LoginValidate(user.login))            {                Console.WriteLine("User ready");                Console.WriteLine($"{user.password} {user.password.Trim().Length}");                DatabaseWorker.UserRegister(user);                var response = Encoding.UTF8.GetBytes($"HTTP/1.1 301 Moved Permanently\nLocation: /@{user.login}\nSet-Cookie: {user.cookie}; Expires={DateTime.Now.AddDays(2):R}; Secure; HttpOnly\n\n");                ClientStream.BeginWrite(response, 0, response.Length, WriteController.OnClientSend, null);            }            else            {                RazorController.ErrorLoader(401);            }        }    }}


А так выглядит обработка базы данных:

База данных
using ClearServer.Core.UserController;using System;using System.Data.Linq;using System.Linq;namespace ClearServer{    class DatabaseWorker    {        private readonly Table<User> users = null;        private readonly DataContext DataBase = null;        private const string connectionStr = @"путькбазе";        public DatabaseWorker()        {            DataBase = new DataContext(connectionStr);            users = DataBase.GetTable<User>();        }        public User UserAuth(User User)        {            try            {                var user = users.SingleOrDefault(t => t.login.ToLower() == User.login.ToLower() && t.password == User.password);                if (user != null)                    return user;                else                    return null;            }            catch (Exception)            {                return null;            }        }        public void UserRegister(User user)        {            try            {                users.InsertOnSubmit(user);                DataBase.SubmitChanges();                Console.WriteLine($"User{user.name} with id {user.uid} added");                foreach (var item in users)                {                    Console.WriteLine(item.login + "\n");                }            }            catch (Exception e)            {                Console.WriteLine(e);            }                    }        public bool LoginValidate(string login)        {            if (users.Any(x => x.login.ToLower() == login.ToLower()))            {                Console.WriteLine("Login already exists");                return false;            }            return true;        }        public void UserUpdate(User user)        {            var UserToUpdate = users.FirstOrDefault(x => x.uid == user.uid);            UserToUpdate = user;            DataBase.SubmitChanges();            Console.WriteLine($"User {UserToUpdate.name} with id {UserToUpdate.uid} updated");            foreach (var item in users)            {                Console.WriteLine(item.login + "\n");            }        }        public User CookieValidate(string CookieInput)        {            User user = null;            try            {                user = users.SingleOrDefault(x => x.cookie == CookieInput);            }            catch            {                return null;            }            if (user != null) return user;            else return null;        }        public User FindUser(string login)        {            User user = null;            try            {                user = users.Single(x => x.login.ToLower() == login.ToLower());                if (user != null)                {                    return user;                }                else                {                    return null;                }            }            catch (Exception)            {                return null;            }        }    }}


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

Глава 4. Выбрасывание велосипеда


Что бы сократить трудозатраты на написание двух приложений под две платформы, я решил сделать кроссплатформу на Xamarin.Forms. Опять же, благодаря тому, что она на C#. Сделав тестовое приложение, которое просто отсылает серверу данные, я столкнулся с одним интересным моментом. Для запроса от устройства я для интереса реализовал его на HttpClient и кинул на сервер HttpRequestMessage в котором содержатся данные из формы авторизации в формате json. Особо ничего не ожидая, открыл лог сервера и увидел там реквест с девайса со всеми данными. Лёгкий ступор, осознание всего, что было проделано за последние 3 недели томных вечером. Для проверки верности отправленных данных собрал тестовый сервер на HttpListner. Получив очередной запрос уже на нём, я за пару строк кода разобрал его на части, получил KeyValuePair данных из формы. Разбор запроса уменьшился до двух строк.

Начал тестировать дальше, ранее не упоминалось, но на прежнем сервере я ещё реализовывал чат построенный на вебсокетах. Он довольно неплохо работал, но сам принцип взаимодействия через Tcp был удручающим, слишком много лишнего приходилось плодить, что бы грамотно построить взаимодействие двух пользователей с ведением лога переписки. Это и парсинг запроса на предмет переключения соединения и сбор ответа по протоколу RFC 6455. Поэтому в тестовом сервере я решил создать простое вебсокет соединение. Чисто ради интереса.

Подключение к чату
 private static async void HandleWebsocket(HttpListenerContext context)        {            var socketContext = await context.AcceptWebSocketAsync(null);            var socket = socketContext.WebSocket;            Locker.EnterWriteLock();            try            {                Clients.Add(socket);            }            finally            {                Locker.ExitWriteLock();            }            while (true)            {                var buffer = new ArraySegment<byte>(new byte[1024]);                var result = await socket.ReceiveAsync(buffer, CancellationToken.None);                var str = Encoding.Default.GetString(buffer);                Console.WriteLine(str);                for (int i = 0; i < Clients.Count; i++)                {                    WebSocket client = Clients[i];                    try                    {                        if (client.State == WebSocketState.Open)                        {                                                        await client.SendAsync(buffer, WebSocketMessageType.Text, true, CancellationToken.None);                        }                    }                    catch (ObjectDisposedException)                    {                        Locker.EnterWriteLock();                        try                        {                            Clients.Remove(client);                            i--;                        }                        finally                        {                            Locker.ExitWriteLock();                        }                    }                }            }        }



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

На стороне девайса и на стороне сайта два клиента обменивались сообщениями, всё это логировалось. Никаких огромных парсеров, замедляющих работу сервера, ничего этого не требовалось. Время отклика сократилось с 200мс до 40-30мс. И я пришёл к единственному верному решению.

Выкинуть текущую реализацию сервера на Tcp и переписать всё под Http. Теперь же проект находится в стадии перепроектирования, но уже по совсем другим принципам взаимодействия. Работа устройств и сайта синхронизирована и отлажена и имеет общую концепцию, с тем лишь отличием, что для девайсов не нужно генерировать html страницы.

Вывод


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

Проверка QEMU с помощью PVS-Studio

04.09.2020 10:14:53 | Автор: admin
image1.png

QEMU достаточно известное приложение для эмуляции. Статический анализ может помочь разработчикам таких сложных проектов, как QEMU, отлавливать ошибки на раннем этапе и в целом повысить его качество и надёжность. В этой статье будет проверен исходный код приложения QEMU на потенциальные уязвимости и ошибки с помощью инструмента статического анализа PVS-Studio.

QEMU является свободным ПО, предназначенным для эмуляции аппаратного обеспечения различных платформ. Оно позволяет запускать приложения и операционные системы на отличающихся от целевой аппаратных платформах, например, приложение, написанное для MIPS запустить для архитектуры x86. QEMU также поддерживает эмуляцию разнообразных периферийных устройств, например, видеокарты, usb и т.д. Проект достаточно сложный и достойный внимания, именно такие проекты представляют интерес для статического анализа, поэтому было решено проверить его код с помощью PVS-Studio.

Об анализе


Исходный код проекта можно получить из зеркала на github. Проект достаточно объемный и может компилироваться для различных платформ. Для более легкой проверки кода воспользуемся системой мониторинга компиляции PVS-Studio. Эта система предназначена для очень простой интеграции статического анализа практически в любую сборочную платформу. Система основана на отслеживании вызовов компилятора во время сборки и позволяет собрать всю информацию для последующего анализа файлов. Другими словами, просто запускаем сборку, PVS-Studio собирает необходимую информацию, а после запускаем анализ все просто. Подробности можно почитать по ссылке выше.

После проверки анализатор обнаружил множество потенциальных проблем. Для диагностик общего назначения (General Analysis) было получено: 1940 уровня High, 1996 уровня Medium, 9596 уровня Low. После просмотра всех предупреждений было решено остановиться на диагностиках для первого уровня достоверности (High). Таких предупреждений нашлось достаточно много (1940), но большая часть предупреждений либо однотипна, либо связана с многократным использованием подозрительного макроса. Для примера рассмотрим макрос g_new.

#define g_new(struct_type, n_structs)                        _G_NEW (struct_type, n_structs, malloc)#define _G_NEW(struct_type, n_structs, func)       \  (struct_type *) (G_GNUC_EXTENSION ({             \    gsize __n = (gsize) (n_structs);               \    gsize __s = sizeof (struct_type);              \    gpointer __p;                                  \    if (__s == 1)                                  \      __p = g_##func (__n);                        \    else if (__builtin_constant_p (__n) &&         \             (__s == 0 || __n <= G_MAXSIZE / __s)) \      __p = g_##func (__n * __s);                  \    else                                           \      __p = g_##func##_n (__n, __s);               \    __p;                                           \  }))

На каждое использование этого макроса анализатор выдает предупреждение V773 (Visibility scope of the '__p' pointer was exited without releasing the memory. A memory leak is possible). Макрос g_new определен в библиотеке glib, он использует макрос _G_NEW, а этот макрос в свою очередь использует другой макрос G_GNUC_EXTENSION, говорящий компилятору GCC пропускать предупреждения о нестандартном коде. Именно этот нестандартный код и вызывает предупреждение анализатора, обратите внимание на предпоследнюю строку. В целом же макрос является рабочим. Предупреждений этого типа нашлось 848 штук, то есть почти половина срабатываний приходится всего лишь на одно единственное место в коде.

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

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

Предупреждение N1

V517 The use of 'if (A) {...} else if (A) {...}' pattern was detected. There is a probability of logical error presence. Check lines: 2395, 2397. megasas.c 2395

#define MEGASAS_MAX_SGE 128             /* Firmware limit */....static void megasas_scsi_realize(PCIDevice *dev, Error **errp){  ....  if (s->fw_sge >= MEGASAS_MAX_SGE - MFI_PASS_FRAME_SIZE) {    ....  } else if (s->fw_sge >= 128 - MFI_PASS_FRAME_SIZE) {    ....  }  ....}

Любые использования "магических" чисел в коде всегда вызывают подозрение. Здесь два условия, и на первый взгляд они кажутся разными, но, если посмотреть значение макроса MEGASAS_MAX_SGE, то окажется, что условия дублируют друг друга. Скорее всего, здесь опечатка и вместо 128 должно стоять другое число. Конечно, это проблема всех "магических" чисел, достаточно просто опечататься при их использовании. Применение макросов и констант сильно помогает разработчику в этом случае.

Предупреждение N2

V523 The 'then' statement is equivalent to the 'else' statement. cp0_helper.c 383

target_ulong helper_mftc0_cause(CPUMIPSState *env){  ....  CPUMIPSState *other = mips_cpu_map_tc(env, &other_tc);  if (other_tc == other->current_tc) {    tccause = other->CP0_Cause;  } else {    tccause = other->CP0_Cause;  }  ....}

В рассматриваемом коде тела then и else условного оператора идентичны. Здесь, скорее всего, copy-paste. Просто скопировали тело then ветвления, а исправить забыли. Можно предположить, что вместо объекта other необходимо было использовать env. Исправление этого подозрительного места могло бы выглядеть следующим образом:

if (other_tc == other->current_tc) {  tccause = other->CP0_Cause;} else {  tccause = env->CP0_Cause;}

Однозначно сказать, как должно быть на самом деле могут только разработчики этого кода. Еще похожее место:

  • V523 The 'then' statement is equivalent to the 'else' statement. translate.c 641

Предупреждение N3

V547 Expression 'ret < 0' is always false. qcow2-cluster.c 1557

static int handle_dependencies(....){  ....  if (end <= old_start || start >= old_end) {    ....  } else {    if (bytes == 0 && *m) {      ....      return 0;           // <= 3    }    if (bytes == 0) {      ....      return -EAGAIN;     // <= 4    }  ....  }  return 0;               // <= 5}int qcow2_alloc_cluster_offset(BlockDriverState *bs, ....){  ....  ret = handle_dependencies(bs, start, &cur_bytes, m);  if (ret == -EAGAIN) {   // <= 2    ....  } else if (ret < 0) {   // <= 1    ....  }}

Здесь анализатор обнаружил, что условие (комментарий 1) никогда не выполнится. Значение переменной ret инициализируется результатом выполнения функции handle_dependencies, эта функция возвращает только 0 или -EAGAIN (комментарии 3, 4, 5). Чуть выше, в первом условии, мы проверили значение ret на -EAGAIN (комментарий 2), поэтому результат выполнения выражения ret < 0 будет всегда ложным. Возможно, раньше функция handle_dependencies и возвращала другие значения, но потом в результате, например, рефакторинга поведение поменялось. Здесь надо просто завершить рефакторинг. Похожие срабатывания:

  • V547 Expression is always false. qcow2.c 1070
  • V547 Expression 's->state != MIGRATION_STATUS_COLO' is always false. colo.c 595
  • V547 Expression 's->metadata_entries.present & 0x20' is always false. vhdx.c 769

Предупреждение N4

V557 Array overrun is possible. The 'dwc2_glbreg_read' function processes value '[0..63]'. Inspect the third argument. Check lines: 667, 1040. hcd-dwc2.c 667

#define HSOTG_REG(x) (x)                                             // <= 5....struct DWC2State {  ....#define DWC2_GLBREG_SIZE    0x70  uint32_t glbreg[DWC2_GLBREG_SIZE / sizeof(uint32_t)];              // <= 1  ....}....static uint64_t dwc2_glbreg_read(void *ptr, hwaddr addr, int index,                                 unsigned size){  ....  val = s->glbreg[index];                                            // <= 2  ....}static uint64_t dwc2_hsotg_read(void *ptr, hwaddr addr, unsigned size){  ....  switch (addr) {    case HSOTG_REG(0x000) ... HSOTG_REG(0x0fc):                      // <= 4        val = dwc2_glbreg_read(ptr, addr,                              (addr - HSOTG_REG(0x000)) >> 2, size); // <= 3    ....  }  ....}

В этом коде есть потенциальная проблема с выходом за границу массива. В структуре DWC2State определен массив glbreg,состоящий из 28 элементов (комментарий 1). В функции dwc2_glbreg_read по индексу обращаются к нашему массиву (комментарий 2). Теперь обратите внимание, что в функцию dwc2_glbreg_read в качестве индекса передают выражение (addr HSOTG_REG(0x000)) >> 2 (комментарий 3), которое может принимать значение в диапазоне [0..63]. Для того чтобы в этом убедиться, обратите внимание на комментарии 4 и 5. Возможно, тут надо скорректировать диапазон значений из комментария 4.

Еще похожие срабатывания:

  • V557 Array overrun is possible. The 'dwc2_hreg0_read' function processes value '[0..63]'. Inspect the third argument. Check lines: 814, 1050. hcd-dwc2.c 814
  • V557 Array overrun is possible. The 'dwc2_hreg1_read' function processes value '[0..191]'. Inspect the third argument. Check lines: 927, 1053. hcd-dwc2.c 927
  • V557 Array overrun is possible. The 'dwc2_pcgreg_read' function processes value '[0..127]'. Inspect the third argument. Check lines: 1012, 1060. hcd-dwc2.c 1012

Предупреждение N5

V575 The 'strerror_s' function processes '0' elements. Inspect the second argument. commands-win32.c 1642

void qmp_guest_set_time(bool has_time, int64_t time_ns,                         Error **errp){  ....  if (GetLastError() != 0) {    strerror_s((LPTSTR) & msg_buffer, 0, errno);    ....  }}

Функция strerror_s возвращает текстовое описание кода системной ошибки. Её сигнатура выглядит так:

errno_t strerror_s( char *buf, rsize_t bufsz, errno_t errnum );

Первый параметр это указатель на буфер, куда будет скопировано текстовое описание, второй параметр размер буфера, третий код ошибки. В коде в качестве размера буфера передается 0, это явно ошибочное значение. Кстати, есть возможность узнать заранее сколько байт надо выделять: надо просто вызвать strerrorlen_s, которая возвращает длину текстового описания ошибки. Это значение можно использовать для выделения буфера достаточного размера.

Предупреждение N6

V595 The 'blen2p' pointer was utilized before it was verified against nullptr. Check lines: 103, 106. dsound_template.h 103

static int glue (    ....    DWORD *blen1p,    DWORD *blen2p,    int entire,    dsound *s    ){  ....  dolog("DirectSound returned misaligned buffer %ld %ld\n",        *blen1p, *blen2p);                         // <= 1  glue(.... p2p ? *p2p : NULL, *blen1p,                            blen2p ? *blen2p : 0); // <= 2....}

В этом коде значение аргумента blen2p сначала используется (комментарий 1), а потом проверяется на nullptr (комментарий 2). Это крайне подозрительное место выглядит так, как будто просто забыли вставить проверку перед первым использованием (комментарий 1). Как вариант исправления просто добавить проверку:

dolog("DirectSound returned misaligned buffer %ld %ld\n",      *blen1p, blen2p ? *blen2p : 0);

Тут еще возникает вопрос по поводу аргумента blen1p. Вероятно, он тоже может быть нулевым указателем, и тут тоже надо будет добавить проверку. Еще несколько подобных срабатываний:

  • V595 The 'ref' pointer was utilized before it was verified against nullptr. Check lines: 2191, 2193. uri.c 2191
  • V595 The 'cmdline' pointer was utilized before it was verified against nullptr. Check lines: 420, 425. qemu-io.c 420
  • V595 The 'dp' pointer was utilized before it was verified against nullptr. Check lines: 288, 294. onenand.c 288
  • V595 The 'omap_lcd' pointer was utilized before it was verified against nullptr. Check lines: 81, 87. omap_lcdc.c 81

Предупреждение N7

V597 The compiler could delete the 'memset' function call, which is used to flush 'op_info' object. The RtlSecureZeroMemory() function should be used to erase the private data. virtio-crypto.c 354

static void virtio_crypto_free_request(VirtIOCryptoReq *req){  if (req) {    if (req->flags == CRYPTODEV_BACKEND_ALG_SYM) {      ....      /* Zeroize and free request data structure */      memset(op_info, 0, sizeof(*op_info) + max_len); // <= 1      g_free(op_info);    }    g_free(req);  }}

В этом фрагменте кода вызывается функция memset для объекта op_info (комментарий 1), после этого op_info сразу удаляется, то есть, другими словами, после очистки этот объект нигде больше не модифицируется. Это как раз тот самый случай, когда в процессе оптимизации компилятор может удалить вызов memset. Чтобы исключить подобное потенциальное поведение, можно воспользоваться специальными функциями, которые компилятор никогда не удаляет. См. также статью "Безопасная очистка приватных данных".

Предупреждение N8

V610 Unspecified behavior. Check the shift operator '>>'. The left operand is negative ('number' = [-32768..2147483647]). cris.c 2111

static voidprint_with_operands (const struct cris_opcode *opcodep,         unsigned int insn,         unsigned char *buffer,         bfd_vma addr,         disassemble_info *info,         const struct cris_opcode *prefix_opcodep,         unsigned int prefix_insn,         unsigned char *prefix_buffer,         bfd_boolean with_reg_prefix){  ....  int32_t number;  ....  if (signedp && number > 127)    number -= 256;            // <= 1  ....  if (signedp && number > 32767)    number -= 65536;          // <= 2  ....  unsigned int highbyte = (number >> 24) & 0xff;  ....}

Так как переменная number может иметь отрицательное значение, побитовый сдвиг вправо является неуточнённым поведением (unspecified behavior). Чтобы убедиться в том, что рассматриваемая переменная может принять отрицательное значение, обратите внимание на комментарии 1 и 2. Для устранения различий поведения вашего кода на различных платформах, таких случаев нужно не допускать.

Еще предупреждения:

  • V610 Undefined behavior. Check the shift operator '<<'. The left operand is negative ('(hclk_div 1)' = [-1..15]). aspeed_smc.c 1041
  • V610 Undefined behavior. Check the shift operator '<<'. The left operand '(target_long) 1' is negative. exec-vary.c 99
  • V610 Undefined behavior. Check the shift operator '<<'. The left operand is negative ('hex2nib(words[3][i * 2 + 2])' = [-1..15]). qtest.c 561

Также есть несколько предупреждений такого же типа, только в качестве левого операнда выступает -1.

V610 Undefined behavior. Check the shift operator '<<'. The left operand '-1' is negative. hppa.c 2702

int print_insn_hppa (bfd_vma memaddr, disassemble_info *info){  ....  disp = (-1 << 10) | imm10;  ....}

Другие подобные предупреждения:

  • V610 Undefined behavior. Check the shift operator '<<'. The left operand '-1' is negative. hppa.c 2718
  • V610 Undefined behavior. Check the shift operator '<<'. The left operand '-0x8000' is negative. fmopl.c 1022
  • V610 Undefined behavior. Check the shift operator '<<'. The left operand '(intptr_t) 1' is negative. sve_helper.c 889

Предупреждение N9

V616 The 'TIMER_NONE' named constant with the value of 0 is used in the bitwise operation. sys_helper.c 179

#define HELPER(name) ....enum {  TIMER_NONE = (0 << 30),        // <= 1  ....}void HELPER(mtspr)(CPUOpenRISCState *env, ....){  ....  if (env->ttmr & TIMER_NONE) {  // <= 2    ....  }}

Можно легко убедиться, что значение макроса TIMER_NONE равно нулю (комментарий 1). Далее этот макрос используется в побитовой операции, результат которой всегда будет 0. Как итог, тело условного оператора if (env->ttmr & TIMER_NONE) никогда не выполнится.

Предупреждение N10

V629 Consider inspecting the 'n << 9' expression. Bit shifting of the 32-bit value with a subsequent expansion to the 64-bit type. qemu-img.c 1839

#define BDRV_SECTOR_BITS   9static int coroutine_fn convert_co_read(ImgConvertState *s,                   int64_t sector_num, int nb_sectors, uint8_t *buf){  uint64_t single_read_until = 0;  int n;  ....  while (nb_sectors > 0) {    ....    uint64_t offset;    ....    single_read_until = offset + (n << BDRV_SECTOR_BITS);    ....  }  ....}

В этом фрагменте кода над переменной n, имеющей 32-битный знаковый тип, выполняется операция сдвига, потом этот 32-битный знаковый результат расширяется до 64-битного знакового типа, и далее, как беззнаковый тип, складывается с беззнаковой 64-битной переменной offset. Предположим, что на момент выполнения выражения переменная n имеет некоторые значимые старшие 9 бит. Мы выполняем операцию сдвига на 9 разрядов (BDRV_SECTOR_BITS), а это, в свою очередь, является неопределенным поведением, тогда в качестве результата мы можем получить выставленный бит в старшем разряде. Напомним, что этот бит в знаковом типе отвечает за знак, то есть результат может стать отрицательным. Так как переменная n знакового типа, то при расширении будет учтен знак. Далее результат складывается с переменной offset. Из этих рассуждений нетрудно увидеть, что результат выполнения выражения может отличаться от предполагаемого. Одним из возможных вариантов решения является замена типа переменной n на 64-битный беззнаковый тип, то есть на uint64_t.

Вот еще похожие срабатывания:

  • V629 Consider inspecting the '1 << refcount_order' expression. Bit shifting of the 32-bit value with a subsequent expansion to the 64-bit type. qcow2.c 3204
  • V629 Consider inspecting the 's->cluster_size << 3' expression. Bit shifting of the 32-bit value with a subsequent expansion to the 64-bit type. qcow2-bitmap.c 283
  • V629 Consider inspecting the 'i << s->cluster_bits' expression. Bit shifting of the 32-bit value with a subsequent expansion to the 64-bit type. qcow2-cluster.c 983
  • V629 Consider inspecting the expression. Bit shifting of the 32-bit value with a subsequent expansion to the 64-bit type. vhdx.c 1145
  • V629 Consider inspecting the 'delta << 2' expression. Bit shifting of the 32-bit value with a subsequent expansion to the 64-bit type. mips.c 4341

Предупреждение N11

V634 The priority of the '*' operation is higher than that of the '<<' operation. It's possible that parentheses should be used in the expression. nand.c 310

static void nand_command(NANDFlashState *s){  ....  s->addr &= (1ull << s->addrlen * 8) - 1;  ....}

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

  • V634 The priority of the '*' operation is higher than that of the '<<' operation. It's possible that parentheses should be used in the expression. exynos4210_mct.c 449
  • V634 The priority of the '*' operation is higher than that of the '<<' operation. It's possible that parentheses should be used in the expression. exynos4210_mct.c 1235
  • V634 The priority of the '*' operation is higher than that of the '<<' operation. It's possible that parentheses should be used in the expression. exynos4210_mct.c 1264

Предупреждение N12

V646 Consider inspecting the application's logic. It's possible that 'else' keyword is missing. pl181.c 400

static void pl181_write(void *opaque, hwaddr offset,                        uint64_t value, unsigned size){  ....  if (s->cmd & PL181_CMD_ENABLE) {    if (s->cmd & PL181_CMD_INTERRUPT) {      ....    } if (s->cmd & PL181_CMD_PENDING) { // <= else if      ....    } else {      ....    }    ....  }  ....}

В этом коде, судя по форматированию, прямо напрашивается использование else if вместо if. Возможно, здесь забыли дописать else. Тогда вариант исправления может быть такой:

} else if (s->cmd & PL181_CMD_PENDING) { // <= else if

Однако есть вероятность, что с этим кодом все в порядке, и тут некорректное форматирование текста программы, которое сбивает с толку. Тогда код мог бы выглядеть так:

if (s->cmd & PL181_CMD_INTERRUPT) {  ....}if (s->cmd & PL181_CMD_PENDING) { // <= if  ....} else {  ....}

Предупреждение N13

V773 The function was exited without releasing the 'rule' pointer. A memory leak is possible. blkdebug.c 218

static int add_rule(void *opaque, QemuOpts *opts, Error **errp){  ....  struct BlkdebugRule *rule;  ....  rule = g_malloc0(sizeof(*rule));                   // <= 1  ....  if (local_error) {    error_propagate(errp, local_error);    return -1;                                       // <= 2  }  ....  /* Add the rule */  QLIST_INSERT_HEAD(&s->rules[event], rule, next);   // <= 3  ....}

В данном коде выделяется объект rule (комментарий 1) и добавляется в список для последующего использования (комментарий 3), но в случае ошибки происходит возврат из функции без удаления ранее созданного объекта rule (комментарий 2). Здесь надо просто правильно обработать ошибку: удалить ранее созданный объект, иначе будет утечка памяти.

Предупреждение N14

V781 The value of the 'ix' index is checked after it was used. Perhaps there is a mistake in program logic. uri.c 2110

char *uri_resolve_relative(const char *uri, const char *base){  ....  ix = pos;  if ((ref->path[ix] == '/') && (ix > 0)) {  ....}

Здесь анализатор обнаружил потенциальный выход за границу массива. Сначала читается элемент массива ref->path по индексу ix, а потом ix проверяется на корректность (ix > 0). Правильным решением тут будет поменять эти действия местами:

if ((ix > 0) && (ref->path[ix] == '/')) {

Таких мест нашлось несколько:

  • V781 The value of the 'ix' index is checked after it was used. Perhaps there is a mistake in program logic. uri.c 2112
  • V781 The value of the 'offset' index is checked after it was used. Perhaps there is a mistake in program logic. keymaps.c 125
  • V781 The value of the 'quality' variable is checked after it was used. Perhaps there is a mistake in program logic. Check lines: 326, 335. vnc-enc-tight.c 326
  • V781 The value of the 'i' index is checked after it was used. Perhaps there is a mistake in program logic. mem_helper.c 1929

Предупреждение N15

V784 The size of the bit mask is less than the size of the first operand. This will cause the loss of higher bits. cadence_gem.c 1486

typedef struct CadenceGEMState {  ....  uint32_t regs_ro[CADENCE_GEM_MAXREG];}....static void gem_write(void *opaque, hwaddr offset, uint64_t val,        unsigned size){  ....  val &= ~(s->regs_ro[offset]);  ....}

В этом коде выполняется побитовая операция с объектами разных типов. Левый операнд это аргумент val, имеющий 64-битный беззнаковый тип. В качестве правого операнда выступает полученное значение элемента массива s->regs_ro по индексу offset, имеющий 32-битный беззнаковый тип. Результат операции в правой части (~(s->regs_ro[offset])) является 32-битным беззнаковым типом, и перед побитовым умножением он расширится до 64-битного типа нулями, то есть после вычисления всего выражения обнулятся все старшие биты переменной val. Такие места всегда выглядят подозрительными. Тут можно только порекомендовать разработчикам еще раз пересмотреть этот код. Еще похожее:

  • V784 The size of the bit mask is less than the size of the first operand. This will cause the loss of higher bits. xlnx-zynq-devcfg.c 199
  • V784 The size of the bit mask is less than the size of the first operand. This will cause the loss of higher bits. soc_dma.c 214
  • V784 The size of the bit mask is less than the size of the first operand. This will cause the loss of higher bits. fpu_helper.c 418

Предупреждение N16

V1046 Unsafe usage of the 'bool' and 'unsigned int' types together in the operation '&='. helper.c 10821

static inline uint32_t extract32(uint32_t value, int start, int length);....static ARMVAParameters aa32_va_parameters(CPUARMState *env, uint32_t va,                                          ARMMMUIdx mmu_idx){  ....  bool epd, hpd;  ....  hpd &= extract32(tcr, 6, 1);}

В этом фрагменте кода происходит операция побитового И над переменной hpd, имеющей тип bool, и результатом выполнения функции extract32, имеющим тип uint32_t. Так как битовое значение булевой переменной может быть только 0 или 1, то результат выражения будет всегда false, если младший бит, возвращаемый функцийе extract32, равен нулю. Давайте рассмотрим это на примере. Предположим, что значение hpd равно true, а функция вернула значение 2, то есть в двоичном представлении операция будет выглядеть так 01 & 10 = 0, а результат выражения будет равен false. Скорее всего, программист хотел выставлять значение true, если функция возвращает что-то отличное от нуля. По всей видимости, надо исправить код так, чтобы результат выполнения функции приводился к типу bool, например, так:

hpd = hpd && (bool)extract32(tcr, 6, 1);

Заключение


Как видно, анализатор нашел много подозрительных мест. Возможно, найденные потенциальные проблемы пока никак себя не проявляют, но их наличие не может не тревожить, так как они способны выстрелить в самый неожиданный момент. Просмотреть все подозрительные места заранее и исправить лучше, чем чинить бесконечный поток багов. Очевидно, что для сложных проектов, подобных этому, статический анализ может принести ощутимую пользу, особенно если организовать регулярную проверку проекта. Если Вы хотите попробовать PVS-Studio для своего проекта, вы можете скачать анализатор и получить бесплатный триальный ключ на этой странице.


Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: Evgeniy Ovsannikov. Checking QEMU using PVS-Studio.
Подробнее..

Я 20 лет наслаждаюсь разнообразием архитектур и хочу поделиться мыслями

09.09.2020 10:06:55 | Автор: admin


Сначала хотел написать комментарий к статье "Я десять лет страдал от ужасных архитектур в C#...", но понял две вещи:

1. Слишком много мыслей, которыми хочется поделиться.
2. Для такого объёма формат комментария неудобен ни для написания, ни для прочтения.
3. Давно читаю Хабр, иногда комментирую, но ни разу не писал статей.
4. Я не силён в нумерованных списках.

Disclaimer: я не критикую @pnovikov или его задумку в целом. Текст качественный (чувствуется опытный редактор), часть мыслей разделяю. Архитектур много, но это нормально (да, звучит как название корейского фильма).

Однако давайте по порядку. Сначала моё мнение о том, что влияет на архитектуру, потом про спорные моменты в статье об исправлении архитектур. Ещё расскажу о том, что у нас хорошо работает может, пригодится кому-нибудь.

И, конечно, всё сказанное здесь является личным мнением на основе моего опыта и когнитивных искажений.

О моём мнении


Я часто принимаю архитектурные решения. Когда-то большие, когда-то маленькие. Изредка придумываю архитектуру с нуля. Ну, как с нуля наверняка всё придумано до нас, но про что-то мы не знаем, поэтому приходится придумывать. И не из любви к велосипедостроению (скажем так, не только из любви к нему), а потому что для некоторых задач не нашлось готового решения, которое устроило бы по всем параметрам.

Почему я считаю, что совершенно разные архитектуры имеют право на жизнь? Можно порассуждать о том, что программирование искусство, а не ремесло, но я не буду. Моё мнение: когда-то искусство, когда-то ремесло. Речь не об этом. Главное, что задачи разные. И люди. Уточню под задачами подразумеваются требования бизнеса.

Если когда-то мои задачи станут однотипными, я напишу или попрошу кого-то написать нейросеть (а может, хватит и скрипта), которая меня заменит. А сам займусь чем-то менее безрадостным. Пока же мой и, надеюсь, ваш личный апокалипсис не наступил, давайте подумаем, как влияют задачи и прочие условия на разнообразие архитектур. TL&DR; разнообразно.

Производительность или масштабируемость


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

Сроки


Допустим, сроки (дважды убедился, что написал через о) сильно сжаты. Тогда нам не до того чтобы выбирать, а, тем более, придумывать архитектуру бери знакомые инструменты и копай. Но есть нюанс иногда сложные проекты можно сделать вовремя только применив (и, возможно, придумав) что-то принципиально новое. Кто-то может сказать, что пригласить заказчика в баню старый прием, но я сейчас про архитектуру

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

В моей практике сроки редко приводят к революциям в архитектуре, но бывает. И это прекрасно.

Скорость и качество разработки


Бывает так команда (или кто-то из руководства) замечают, что скорость разработки снизилась или за итерацию багов многовато набежало. Нередко виновной в этом признают неправильную архитектуру. Иногда заслуженно. Чаще просто как самого удобного обвиняемого (особенно, если в коллективе нет её родителя).

В принципе, в одних случаях всё сводится к фактору сроков. А в других к поддерживаемости, о ней далее.

Поддерживаемость


Неоднозначная тема. Потому что всё очень субъективно и зависит много от чего. Например от команды, языка программирования, процессов в компании, количества адаптаций под разных клиентов. Давайте про последний фактор, мне он кажется самым интересным.

Вот вы сделали заказной проект. Успешно, в сроки и бюджет уложились, заказчик всем доволен. Было и у меня такое. Теперь вы смотрите на то, что использовали и думаете так вот она золотая жила! Мы сейчас используем все эти наработки, быстро сделаем один B2B-продукт, и Сначала всё хорошо. Продукт сделали, пару раз продали. Наняли ещё продавцов и разработчиков (нужно больше золота). Заказчики довольны, платят за сопровождение, случаются новые продажи

А потом один из заказчиков говорит человеческим голосом мне бы вот эту штуковину совсем по-другому сделать сколько это может стоить?. Ну, подумаешь несколько ifчиков с другим кодом воткнуть (допустим, некогда было DI прикрутить), что плохого может случиться?

И в первый раз действительно ничего плохого не случится. Я бы даже не советовал в такой ситуации что-то специальное городить. Преждевременное усложнение архитектуры сродни преждевременной оптимизации. Но когда это случается во второй и третий раз это повод вспомнить про такие штуки как DI, паттерн стратегия, Feature Toggle и иже с ними. И, на время, это поможет.

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

Конечно, я сгустил краски можно выделить какие-то наборы фич, которые используются у реальных заказчиков, написать больше тестов (как и каких тема отдельного разговора) и немного упростить задачу. Но вдумайтесь каждый серьезный релиз нужно протестировать для всех заказчиков. Напоминаю, это не B2C, где можно сказать выкатим фичу для 5% пользователей и соберём фидбек для B2B фидбек можно по судам начать собирать

Решения? Например, разделить продукт на модули с отдельным жизненным циклом (не забывая тестировать их взаимодействие). Это снизит сложность сопровождения, хотя и усложнит разработку. И я сейчас не о благодатной для холиваров теме монолит vs. микросервисы в монолите тоже можно устроить подобное (хотя и сложнее, на мой взгляд).

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

И к чему всё это?


Я не хочу вас (и себя) утомлять перечислением других причин для изменений в архитектуре. Давайте сейчас согласимся, что архитектуры имеют свойство со временем меняться, в зависимости от многих факторов. А значит: идеальная архитектура, решающая ну вот все проблемы не существует.

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

И, признаюсь, есть еще один веский довод придумывать что-то новое, оптимизируя несколько параметров, играя компромиссами это чертовски увлекательно. А теперь, когда мы все (правда?) согласны, что разнообразие архитектур это нормально

Обсуждение статьи про исправление архитектур



А что IoC?


Про IoC соглашусь, что портянкам место в армии, а модули это вселенское добро. Но вот всё остальное

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

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

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

Правда, уточню наши условия работы:

  • Модули у нас не те, которые предоставляются почти любым IoC-фреймворком а вот прям модули которые общаются между собой удалённо через API (иногда можно, из соображений производительности, поселить их в одном процессе, но схема работы не поменяется).
  • IoC используется максимально простой и максимально просто зависимости втыкаются в параметры конструктора.
  • Да, у нас сейчас микросервисная архитектура, но и здесь стараемся не мельчить.

Совет: интерфейсы можно держать в том же файле, что и класс удобно (если, конечно, пользуетесь нормальной IDE, а не блокнотом). Исключения делаю, когда интерфейсы (или комментарии к ним) разрастаются. Но это всё вкусовщина, конечно.

А что не так с ORM и зачем прямой доступ к БД?


Да я и сам скажу, что не так многие из них слишком далеки от SQL. Но не все. Поэтому, вместо того, чтобы терпеть, пока O/RM удаляет 3000 объектов или придумывать ещё один, найдите тот, который вас устроит.

Совет: попробуйте LINQ to DB. Он хорошо сбалансирован, есть методы Update/Delete для нескольких строк. Только осторожно вызывает привыкание. Да, нет каких-то фич EF и немного другая концепция, но мне понравился намного больше EF.

Кстати, приятно, что это разработка наших соотечественников. Игорю Ткачеву респект (не нашёл его на Хабре).

А что не так с тестами на БД?


Да они будут медленнее, чем на данных в памяти. Фатально ли это? Да нет, конечно же. Как решать эту проблему? Вот два рецепта, которые лучше применять одновременно.

Рецепт 1. Берёшь крутого разработчика, который любит делать всякие прикольные штуки и обсуждаешь с ним, как красиво решить эту проблему. Мне повезло, потому что force решил проблему быстрее, чем она появилась (даже не помню, обсуждали её или нет). Как? Сделал (за день, вроде) тестовую фабрику для ORM, которая подменяет основное подмножество операций на обращения к массивам.

Для простых юнит-тестов идеально. Альтернативный вариант юзать SQLite или что-то подобное вместо больших БД.
Комментарий от force: Тут надо сделать пару уточнений. Во-первых, мы стараемся не использовать сырые запросы к базе данных в коде, а максимально используем ORM, если он хороший, то лезть в базу с SQL наголо не требуется. Во-вторых, разница поведения с базой есть, но ведь мы не проверяем вставку в базу, мы проверяем логику, и небольшое различие в поведении тут несущественно, т.к. особо ни на что не влияет. Поддержка корректной тестовой базы гораздо сложнее.

Рецепт 2. Бизнес-сценарии я предпочитаю тестировать на настоящих БД. А если в проекте заявлена возможность поддержки нескольких СУБД, тесты выполняются для нескольких СУБД. Почему? Да всё просто. В утверждении не хочется тестировать сервер баз данных, увы, происходит подмена понятий. Я, знаете ли, тестирую не то, что join работает или order by.

Я тестирую свой код, работающий с БД. А зная, что даже разные версии одной СУБД могут выдавать разные результаты на разных запросах (пруф), я хочу основные сценарии проверять именно на тех БД, с которыми этот код будет работать.

Обычно подобные тесты у меня выглядят так:

  • Для группы тестов (Fixture) с нуля генерируется по метаданным БД. Если нужно заполняются необходимые справочники.
  • Каждый сценарий сам добавляет нужные данные в процессе прохождения (пользователи ведь тоже это делают). В тестах на производительность не так, но это уже совсем другая история
  • После каждого теста лишние данные (кроме справочников) удаляются.

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

Транзакция и email


Просто дополню историю транзакция в БД по каким-то причинам упала, а e-mail ушёл. А какое веселье будет, когда транзакция подождёт недоступного почтового сервера, поставив колом всю систему из-за какого-нибудь уведомления, которое пользователь потом отправит в корзину, не читая

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

Итоги


В целом, если @pnovikov не имеет планов по захвату мира с помощью единственно верной идеологии архитектуры, других, достойных упоминания, расхождений во взглядах не нашёл. Для каких-то задач, безусловно, озвученные им принципы подойдут. С удовольствием прочитаю следующие статьи и комментарии, может, найду какие-то полезные идеи для себя.

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

P.S. Если у вас будет желание обсудить что-то в комментариях, буду рад принять в этом участие.
Подробнее..

Перевод Творческое использование методов расширения в C

12.09.2020 10:23:52 | Автор: admin
Привет, Хабр!

Продолжая исследование темы C#, мы перевели для вас следующую небольшую статью, касающуюся оригинального использования extension methods. Рекомендуем обратить особое внимание на последний раздел, касающийся интерфейсов, а также на профиль автора.




Уверен, что любой, хотя бы немного имевший дело с C#, знает о существовании методов расширений (extension methods). Это приятная фича, позволяющая разработчикам расширять имеющиеся типы новыми методами.

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

Добавление методов к перечислениям

Перечисление это просто набор константных числовых значений, каждому из которых присвоено уникальное имя. Хотя, перечисления в C# и наследуют от абстрактного класса Enum, они не трактуются как настоящие классы. В частности, это ограничение не позволяет им иметь методы.

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

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

public enum FileFormat{    PlainText,    OfficeWord,    Markdown}


Данное перечисление определяет список форматов, поддерживаемых в приложении, и может использоваться в разных частях приложения для инициирования логики ветвления в зависимости от конкретного значения.

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

public static class FileFormatExtensions{    public static string GetFileExtension(this FileFormat self)    {        if (self == FileFormat.PlainText)            return "txt";        if (self == FileFormat.OfficeWord)            return "docx";        if (self == FileFormat.Markdown)            return "md";        // Будет выброшено, если мы забудем новый формат файла,        // но забудем добавить соответствующее расширение файла        throw new ArgumentOutOfRangeException(nameof(self));    }}


Что, в свою очередь, позволяет нам поступить так:

var format = FileFormat.Markdown;var fileExt = format.GetFileExtension(); // "md"var fileName = $"output.{fileExt}"; // "output.md"


Рефакторинг классов моделей

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

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

Итак, рассмотрим следующий пример с двумя моделями: одна представляет закрытый список титров, а другая отдельную строку титров:

public class ClosedCaption{    // Отображаемый текст    public string Text { get; }    // Когда он отображается относительно начала трека     public TimeSpan Offset { get; }    // Как долго текст остается на экране     public TimeSpan Duration { get; }    public ClosedCaption(string text, TimeSpan offset, TimeSpan duration)    {        Text = text;        Offset = offset;        Duration = duration;    }}public class ClosedCaptionTrack{    // Язык, на котором написаны субтитры    public string Language { get; }    // Коллекция закрытых надписей    public IReadOnlyList<ClosedCaption> Captions { get; }    public ClosedCaptionTrack(string language, IReadOnlyList<ClosedCaption> captions)    {        Language = language;        Captions = captions;    }}


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

var time = TimeSpan.FromSeconds(67); // 1:07var caption = track.Captions    .FirstOrDefault(cc => cc.Offset <= time && cc.Offset + cc.Duration >= time);


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

public static class ClosedCaptionTrackExtensions{    public static ClosedCaption GetByTime(this ClosedCaptionTrack self, TimeSpan time) =>        self.Captions.FirstOrDefault(cc => cc.Offset <= time && cc.Offset + cc.Duration >= time);}


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

  1. Понятно, что этот метод работает только с публичными членами класса и не изменяет его приватного состояния каким-нибудь таинственным образом.
  2. Очевидно, что этот метод просто позволяет срезать угол и предусмотрен здесь только для удобства.
  3. Этот метод относится к совершенно отдельному классу (или даже сборке), назначение которых отделять данные от логики.


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

Как сделать интерфейсы разностороннее

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

Если вам это кажется нонсенсом, рассмотрим типичный интерфейс, сохраняющий модель в файл:

public interface IExportService{    FileInfo SaveToFile(Model model, string filePath);}


Все работает нормально, но через пару недель может подоспеть новое требование: классы, реализующие IExportService, должны не только экспортировать в файл, но и уметь писать в файл.

Таким образом, чтобы выполнить это требование, мы добавляем к контракту новый метод:

public interface IExportService{    FileInfo SaveToFile(Model model, string filePath);    byte[] SaveToMemory(Model model);}


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

Но, чтобы не делать всего этого, мы могли с самого начала спроектировать интерфейс немного иначе:

public interface IExportService{    void Save(Model model, Stream output);}


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

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

К счастью, этот недостаток полностью обнуляется при использовании методов расширений:

public static class ExportServiceExtensions{    public static FileInfo SaveToFile(this IExportService self, Model model, string filePath)    {        using (var output = File.Create(filePath))        {            self.Save(model, output);            return new FileInfo(filePath);        }    }    public static byte[] SaveToMemory(this IExportService self, Model model)    {        using (var output = new MemoryStream())        {            self.Save(model, output);            return output.ToArray();        }    }}


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

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

Принятого не воротай Enumerable vs List

15.09.2020 08:18:15 | Автор: admin

Когда-то я работал в команде, где слегка недолюбливалиLINQ, за то, что такой код якобы сложно отлаживать. У нас была договоренность: после каждой цепочкиLINQ, разработчик создает локальную переменную, в которую записывает результатToArray(). Независимо от того, потребуется ли массив далее по методу, или он работает только сIEnumerable. Передreturn, результат также приводился к массиву,кажется,во всей кодовой базе не было методов, возвращающих или принимающихколлекцию,отличную от массива.

Бородатоелегаси! - подумаете вы и будете правы. Однако, несмотря то, что прошло много лет, с тех пор, как LINQстал использоваться повсеместно, аIDEпозволяют смотреть данные в отладке, некоторые разработчики все еще плохо представляют себе критерии выбора принимаемого и возвращаемого типа, если речь заходит о коллекциях.

Начнем с того, что есть коллекция? ПосколькуBCLимеет одноименный тип данных, важно понимать, что коллекция - это тип данных, спроектированный для оперирования группой элементов, имеющих некую общую характеристику (тип данных). Таким образом,все,что можно перечислить является коллекцией.

Предпочитайте абстракции

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

Lazy loading

Вопрос - а прогрузились ли данные(например вIEnumerable)или нет, сам по себе не касается типа. Метод может возвращать как свою реализациюIList, так и стандартную, при этом с отложенной загрузкой данных.Информироватьпользователя о том, используется лиlazyloading, посредством возвращаемого типа - плохая идея. Вы обременяете тип несвойственными ему обязанностями. Комментарий, либо специфичный постфикс 'Lazy' в названии метода, будут более удачным решением, если это не ясно из контекста.

IRealonlyCollection

Несмотря на то, что по тем или иным причинам, у нас нетIArray, у нас естьIRealonlyCollection, добавляющий к перечислениюсвойство-размер.

namespace System.Collections.Generic {   public interface IReadOnlyCollection : IEnumerable, IEnumerable   {     int Count { get; }   }}

Соответствующийкласс-враппербыл добавлен в версии фреймворка 4.5, для удобного созданияread-onlyколлекций. К нему легко можно преобразовать как Array, так и List, поскольку оба они реализуютIList.

СIEnumerableдела обстоят хуже Здесь, чтобы получитьIRealonlyCollection,вам так илииначепридётсясначала получитьList. Таким образом,де-фактостандартом здесь будет являтьсяList.

ВозвращатьArrayилиIRealonlyCollectionвместоList, смысл есть только когда вам важно подчеркнуть неизменяемость. Во всех остальных случаяхIList предложит более широкуюфункциональностьпримерно за ту же стоимость.

Возвращайте пустые коллекции вместо null

Кажется,уже все про это знают, тем неменее,я то и дело,иногда,то тут, то там, получаюnull. Это побуждает меня и коллег добавлять проверки наnull, что сводит к минимуму комфортную работу с пустыми коллекциями. Задумайтесь-nullэто не 0 элементов. Возвращая 1 разnull, вы навсегда обрекаете пользователя штамповать проверки наnull,там,где они избыточны. Например:

 if(myEnumerable != null)  {    foreach(var item in myEnumerable)    {    }  }  

Хуже, ноболее лаконично:

foreach(var item in myEnumerable ?? Enumerable.Empty<T>()) {}

IEnumerable/ICollection/IList

Для начала, вкратце, что есть что:

IEnumerable

собственно, неизменяемая коллекция - перечисление, вам доступен только перечислитель

IReadOnlyCollection : IEnumerable

неизменяемая коллекция - перечисление, вам доступен перечислитель и размер

ICollection : IEnumerable

коллекция c возможностью добавлять и удалять элемены, также доступен размер и признак изменяемости(IsReadOnly)

IReadOnlyList : IReadOnlyCollection

неизменяемая коллекция с порядком следования элементов, вам доступен индексатор

IList : ICollection

коллекция с порядком следования элементов, c возможностью добавлять и удалять элемены по индексу

Принимайте максимально обобщенный тип, нет смысла принимать больше данных, чем требуется методу для работы. Более того, передавать кучу неиспользуемых данных это антипаттерн. Принимая IEnumerable вы предоставляете возможность пользователю передать, в том числе и более конкретные типы - ICollection, IList не пребегая к преобразованию.

Возвращайте максимально конкретный тип, нет смысла прятать за обощенный интерфейс те данные, которыми вы уже располагаете, в результате работы метода. Если вы имеете массив то вы ничего не теряете, возвращая IRealonlyCollection. Возвращая IEnumerable вы скрываете знание о размере и в случае, если оно понадобится пользователю, прийдется изменять согнатуру метода, а если это невозможно - создавать дублирующий метод-перегрузку. Если результатом работы вашего метода является коллекция фиксированного размера и вы хотите избежать lazy loading, имеет смысл вернуть IList или ICollection, если вам важно указать пользователю на неизменяемость - их read-only аналоги.

Web API и HTTP

Если у вас одна часть приложения общается с другой частью по HTTP, например это разные слои сервисов или микросервисы, вы вероятно будете делать зарос из одного сервиса в другой через класс-клиент. И здесь, на секунду может показаться, что у вас есть выбор использовать что угодно, начиная с IEnumerable и заканчивая IList.

На самом деле, по HTTP ваша коллекция уедет как JSON - серрилизованый массив, вся целиком. И приедет, если мы говорим о популярных дессерилизаторах(Newtonsoft.Json, System.Text.Json), не иначе как List. В данном случае нет никакого смысла отдавать\принимать что-то другое. Указывая IEnumerable в response контроллера вы только усложняете понимание кода.


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

Буду рад поправкам и дополнениям, рекомендую ознакомиться с Framework Design Guidelines for Collections.

Подробнее..

Из песочницы Основы компьютерной геометрии. Написание простого 3D-рендера

21.09.2020 22:05:33 | Автор: admin
Привет меня зовут Давид, а вот я собственной персоной отрендеренный своим самописным рендером:

image

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

Идея


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

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

Выбор языка изначально падал на c++ или rust, но я остановился на c# из-за простоты написания кода и широких возможностей для оптимизации. Итоговым продуктом данной статьи будет рендер, способный выдавать подобные картинки:

image

image

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

Математика


Само собой куда же писать рендеры без понимания их математических основ. В этом разделе я изложу только те концепции, которые я использовал в коде. Тем кто не уверен в своих знаниях пропускать данный раздел не советую, без понимания этих основ трудно будет понять дальнейшее изложение. Так же я рассчитываю, что тот кто решил изучать computation geometry будет иметь базовые знания в линейной алгебре, геометрии, а так же тригонометрии(углы, вектора, матрицы, скалярное произведение). Для тех кто хочет понять вычислительную геометрию глубже, могу порекомендовать книгу Е. Никулина Компьютерная геометрия и алгоритмы машинной графики.

Повороты вектора. Матрица поворота


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

  • Поворот относительно начала координат
  • Поворот относительно некоторой точки

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

Давайте выведем формулы для вращения вектора в двумерном пространстве. Обозначим координаты исходного вектора {x, y}. Координаты нового вектора, повернутого на угол f, обозначим как {x y}.

image

Мы знаем, что длина у этих векторов общая и поэтому можем использовать понятия косинуса и синуса для того, чтобы выразить эти вектора через длину и угол относительно оси OX:

image

Заметьте, что мы можем использовать формулы косинуса и синуса суммы для того, чтобы разложить значения x' и y'. Для тех, кто подзабыл я напомню эти формулы:

image

Разложив координаты повернутого вектора через них получим:

image

Здесь нетрудно заметить, что множители l * cos a и l * sin a это координаты исходного вектора: x = l * cos a, y = l * sin a. Заменим их на x и y:

image

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

image

Умножьте и проверьте что результат эквивалентен тому, что мы вывели.

Поворот в трехмерном пространстве


Мы рассмотрели поворот в двумерном пространстве, а так же вывели матрицу для него. Теперь возникает вопрос, а как получить подобные преобразования для трех измерений? В двумерном случае мы вращали вектора на плоскости, здесь же бесконечное количество плоскостей относительно которых мы можем это сделать. Однако существует три базовых типа вращений, при помощи которых можно выразить любой поворот вектора в трехмерном пространстве это XY, XZ, YZ вращения.

XY вращение.

При таком повороте мы вращаем вектор относительно оси OZ координатной системы. Представьте, что вектора это вертолётные лопасти, а ось OZ это мачта на которой они держаться. При XY вращении вектора будут поворачиваться относительно оси OZ, как лопасти вертолета относительно мачты.

image

Заметьте, что при таком вращении z координаты векторов не меняются, а меняются x и x координаты поэтому это и называется XY вращением.

image

Нетрудно вывести и формулы для такого вращения: z координата остается прежней, а x и y изменяются по тем же принципам, что и в 2д вращении.

image

То же в виде матрицы:

image

Для XZ и YZ вращений все аналогично:

image
image

Проекция


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

В том понимании которое мы используем здесь проекция на вектор это тоже вектор. Его координаты точка пересечения перпендикуляра опущенного из вектора a на b с вектором b.

image

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

image

Направление вектора проекции по определению совпадает с вектором b, значит проекция определяется формулой:

image

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

Теперь представим все через скалярное произведение:

image

Получаем удобную формулу для нахождения проекции:

image

Системы координат. Базисы


Многие привыкли работать в стандартной системе координат XYZ, в ней любые 2 оси будут перпендикулярны друг другу, а координатные оси можно представить в виде единичных векторов:

image

На деле же систем координат бесконечное множество, каждая из них является базисом. Базис n-мерного пространства является набором векторов {v1, v2 vn} через которые представляются все вектора этого пространства. При этом ни один вектор из базиса нельзя представить через другие его вектора. По сути каждый базис является отдельной системой координат, в которой вектора будут иметь свои, уникальные координаты.

Давайте разберем, что из себя представляет базис для двумерного пространства. Возьмём для примера всем знакомую декартову систему координат из векторов X {1, 0}, Y {0, 1}, которая является одним из базисов для двумерного пространства:

image


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

image


Теперь возьмём другой базис:

image


Через его вектора также можно представить любой 2д вектор:

image


А вот такой набор векторов не является базисом двухмерного пространства:

image


В нем два вектора {1,1} и {2,2} лежат на одной прямой. Какие бы их комбинации вы не брали получать будете только вектора, лежащие на общей прямой y = x. Для наших целей такие дефектные не пригодятся, однако, понимать разницу, я считаю, стоит. По определению все базисы объединяет одно свойство ни один из векторов базиса нельзя представить в виде суммы других векторов базиса с коэффициентами или же ни один вектор базиса не является линейной комбинацией других. Вот пример набора из 3-х векторов который так же не является базисом:

image


Через него можно выразить любой вектор двумерной плоскости, однако вектор {1, 1} в нем является лишним так как сам может быть выражен через вектора {1, 0} и {0,1} как {1,0} + {0,1}.

Вообще любой базис n-мерного пространства будет содержать ровно n векторов, для 2д это n соответственно равно 2.

Перейдем к 3д. Трехмерный базис будет содержать в себе 3 вектора:

image


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

  • 1)2 вектора не лежат на одной прямой
  • 2)3-й не лежит на плоскости образованной двумя другими.


С данного момента базисы, с которыми мы работаем будут ортогональными (любые их вектора перпендикулярны) и нормированными (длина любого вектора базиса 1). Другие нам просто не понадобятся. К примеру стандартный базис

image


удовлетворяет этим критериям.

Переход в другой базис


До сих пор мы записывали разложение вектора как сумму векторов базиса с коэффициентами:

image

Снова рассмотрим стандартный базис вектор {1, 3, 6} в нем можно записать так:

image

Как видите коэффициенты разложения вектора в базисе являются его координатами в этом базисе. Разберем следующий пример:

image


Этот базис получен из стандартного применением к нему XY вращения на 45 градусов. Возьмем вектор a в стандартной системе имеющий координаты {0 ,1, 1}

image


Через вектора нового базиса его можно разложить таким образом:

image


Если вы посчитаете эту сумму, то получите {0, 1, 1} вектор а в стандартном базисе. Исходя из этого выражения в новом базисе вектор а имеет координаты {0.7, 0.7, 1} коэффициенты разложения. Это будет виднее если взглянуть с другого ракурса:

image


Но как находить эти коэффициенты? Вообще универсальный метод это решение довольно сложной системы линейных уравнений. Однако как я сказал ранее использовать мы будем только ортогональные и нормированные базисы, а для них есть весьма читерский способ. Заключается он в нахождении проекций на вектора базиса. Давайте с его помощью найдем разложение вектора a в базисе X{0.7, 0.7, 0} Y{-0.7, 0.7, 0} Z{0, 0, 1}

image


Для начала найдем коэффициент для y. Первым шагом мы находим проекцию вектора a на вектор y (как это делать я разбирал выше):

image


Второй шаг: делим длину найденной проекции на длину вектора y, тем самым мы узнаем сколько векторов y помещается в векторе проекции это число и будет коэффициентом для y, а также y координатой вектора a в новом базисе! Для x и z повторим аналогичные операции:

image


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

image


Ну а так как мы используем только нормированные базисы и длины их векторов равны 1 отпадет необходимость делить на длину вектора в формуле перехода:

image


Раскроем x-координату через формулу проекции:

image


Заметьте, что знаменатель (x', x') и вектор x' в случае нормированного базиса так же равен 1 и их можно отбросить. Получим:

image


Мы видим, что координата x базисе выражается как скалярное произведение (a, x), координата y соответственно как (a, y), координата z (a, z). Теперь можно составить матрицу перехода к новым координатам:

image


Системы координат со смещенным центром


У всех систем координат которые мы рассмотрели выше началом координат была точка {0,0,0}. Помимо этого существуют еще системы со смещенной точкой начала координат:

image


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

Пишем геометрический движок. Создание проволочного рендера.



Ну вот, думаю тому кто прошел раздел с математикой и не закрыл статью можно промывать мозги более интересными вещами! В этом разделе мы начнем писать основы 3д движка и рендеринга. Вообще рендеринг это довольно сложная процедура, которая включает в себя много разных операций: отсечение невидимых граней, растеризация, расчет света, обработку различных эффектов, материалов(иногда даже физику). Все это мы частично разберем в дальнейшем, а сейчас мы займемся более простыми вещами напишем проволочный рендер. Суть его в том, что он рисует объект в виде линий, соединяющих его вершины, поэтому результат выглядит похожим на сеть из проволок:

image


Полигональная графика


Традиционно в компьютерной графике используется полигональное представление данных трехмерных объектов. Таким образом представляются данные в форматах OBJ, 3DS, FBX и многих других. В компьютере такие данные хранятся в виде двух множеств: множество вершин и множество граней(полигонов). Каждая вершина объекта представлена своей позицией в пространстве вектором, а каждая грань(полигон) представлена тройкой целых чисел которые являются индексами вершин данного объекта. Простейшие объекты(кубы, сферы и т.д.) состоят из таких полигонов и называются примитивами.

В нашем движке примитив будет основным объектом трехмерной геометрии все остальные объекты будут наследоваться от него. Опишем класс примитива:

    abstract class Primitive    {        public Vector3[] Vertices { get; protected set; }        public int[] Indexes { get; protected set; }    }

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

   public class Cube : Primitive      {        public Cube(Vector3 center, float sideLen)        {            var d = sideLen / 2;            Vertices = new Vector3[]                {                    new Vector3(center.X - d , center.Y - d, center.Z - d) ,                    new Vector3(center.X - d , center.Y - d, center.Z) ,                    new Vector3(center.X - d , center.Y , center.Z - d) ,                    new Vector3(center.X - d , center.Y , center.Z) ,                    new Vector3(center.X + d , center.Y - d, center.Z - d) ,                    new Vector3(center.X + d , center.Y - d, center.Z) ,                    new Vector3(center.X + d , center.Y + d, center.Z - d) ,                    new Vector3(center.X + d , center.Y + d, center.Z + d) ,                };            Indexes = new int[]                {                    1,2,4 ,                    1,3,4 ,                    1,2,6 ,                    1,5,6 ,                    5,6,8 ,                    5,7,8 ,                    8,4,3 ,                    8,7,3 ,                    4,2,8 ,                    2,8,6 ,                    3,1,7 ,                    1,7,5                };        }    }int Main(){        var cube = new Cube(new Vector3(0, 0, 0), 2);}

image

Реализуем системы координат


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

image

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

При работе с объектом мы будем производить операции с его вершинами в локальной системе координат, при рендеринге будем предварительно переводить все объекты сцены в единую систему координат глобальную. Добавим системы координат в код. Для этого создадим объект класса Pivot (стержень, точка опоры) который будет представлять локальный базис объекта и его центральную точку. Перевод точки в систему координат представленную Pivot будет производиться в 2 шага:

  • 1)Представление точки относительно центра новых координат
  • 2)Разложение по векторам нового базиса

Наоборот же, чтобы представить локальную вершину объекта в глобальных координатах необходимо выполнить эти действия в обратном порядке:

  • 1)Разложение по векторам глобального базиса
  • 2)Представление относительно глобального центра

Напишем класс для представления систем координат:

    public class Pivot    {        //точка центра        public Vector3 Center { get; private set; }        //вектора локального базиса - локальные координатные оси        public Vector3 XAxis { get; private set; }        public Vector3 YAxis { get; private set; }        public Vector3 ZAxis { get; private set; }        //Матрица перевода в локальные координаты        public Matrix3x3 LocalCoordsMatrix => new Matrix3x3            (                XAxis.X, YAxis.X, ZAxis.X,                XAxis.Y, YAxis.Y, ZAxis.Y,                XAxis.Z, YAxis.Z, ZAxis.Z            );        //Матрица перевода в глобальные координаты        public Matrix3x3 GlobalCoordsMatrix => new Matrix3x3            (                XAxis.X , XAxis.Y , XAxis.Z,                YAxis.X , YAxis.Y , YAxis.Z,                ZAxis.X , ZAxis.Y , ZAxis.Z            );        public Vector3 ToLocalCoords(Vector3 global)        {            //Находим позицию вектора относительно точки центра и раскладываем в локальном базисе            return LocalCoordsMatrix * (global - Center);        }        public Vector3 ToGlobalCoords(Vector3 local)        {            //В точности да наоборот - раскладываем локальный вектор в глобальном базисе и находим позицию относительно глобального центра            return (GlobalCoordsMatrix * local)  + Center;        }        public void Move(Vector3 v)        {            Center += v;        }        public void Rotate(float angle, Axis axis)        {            XAxis = XAxis.Rotate(angle, axis);            YAxis = YAxis.Rotate(angle, axis);            ZAxis = ZAxis.Rotate(angle, axis);        }    }

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

    public abstract class Primitive    {        //Локальный базис объекта        public Pivot Pivot { get; protected set; }        //Локальные вершины        public Vector3[] LocalVertices { get; protected set; }        //Глобальные вершины        public Vector3[] GlobalVertices { get; protected set; }        //Индексы вершин        public int[] Indexes { get; protected set; }        public void Move(Vector3 v)        {            Pivot.Move(v);            for (int i = 0; i < LocalVertices.Length; i++)                GlobalVertices[i] += v;        }        public void Rotate(float angle, Axis axis)        {            Pivot.Rotate(angle , axis);            for (int i = 0; i < LocalVertices.Length; i++)                GlobalVertices[i] = Pivot.ToGlobalCoords(LocalVertices[i]);        }        public void Scale(float k)        {            for (int i = 0; i < LocalVertices.Length; i++)                LocalVertices[i] *= k;            for (int i = 0; i < LocalVertices.Length; i++)                GlobalVertices[i] = Pivot.ToGlobalCoords(LocalVertices[i]);        }    }

image

Вращение и перемещение объекта с помощью локальных координат

Рисование полигонов. Камера


Основным объектом сцены будет камера с помощью нее объекты будут рисоваться на экране. Камера, как и все объекты сцены, будет иметь локальные координаты в виде объекта класса Pivot через него мы будем двигать и вращать камеру:

image

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

image

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

image


Наоборот же, зная высоту дерева он может найти высоту точки на проекционной плоскости:

image


Теперь вернемся к нашей камере. Представьте, что к оси z координат камеры прикреплена проекционная плоскость на расстоянии z' от начала координат. Формула такой плоскости z = z', ее можно задать одним числом z'. На эту плоскость падают лучи от вершин различных объектов. Попадая на плоскость луч будет оставлять на ней точку. Соединяя такие точки можно нарисовать объект.

image


Такая плоскость будет представлять экран. Координату проекции вершины объекта на экран будем находить в 2 этапа:

  • 1)Переводим вершину в локальные координаты камеры
  • 2)Находим проекцию точки через отношение подобных треугольников

image


Проекция будет 2-мерным вектором, ее координаты x' и y' и будут определять позицию точки на экране компьютера.

Класс камеры 1
public class Camera{    //локальные координаты камеры    public Pivot Pivot { get; private set; }    //расстояние до проекционной плоскости    public float ScreenDist { get; private set; }    public Camera(Vector3 center, float screenDist)    {        Pivot = new Pivot(center);        ScreenDist = screenDist;    }    public void Move(Vector3 v)    {        Pivot.Move(v);    }    public void Rotate(float angle, Axis axis)    {        Pivot.Rotate(angle, axis);    }    public Vector2 ScreenProection(Vector3 v)    {        var local = Pivot.ToLocalCoords(v);        //через подобные треугольники находим проекцию        var delta = ScreenDist / local.Z;        var proection = new Vector2(local.X, local.Y) * delta;        return proection;    }}


Данный код имеет несколько ошибок, о исправлении которых мы поговорим далее.

Отсекаем невидимые полигоны


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

image


Для отсечения невидимых вершин в open gl используются метод усекающей пирамиды. Заключается он в задании двух плоскостей ближней(near plane) и дальней(far plane). Все, что лежит между этими двумя плоскостями будет подлежать дальнейшей обработке. Я же использую упрощенный вариант с одной усекающей плоскостью z'. Все вершины, лежащие позади нее будут невидимыми.

Добавим в камеру два новых поля ширину и высоту экрана.
Теперь каждую спроецированную точку будем проверять на попадание в область экрана. Так же отсечем точки позади камеры. Если точка лежит сзади или ее проекция не попадает на экран то метод вернет точку {float.NaN, float.NaN}.

Код камеры 2
public Vector2 ScreenProection(Vector3 v){    var local = Pivot.ToLocalCoords(v);    //игнорируем точки сзади камеры    if (local.Z < ScreenDist)    {        return new Vector2(float.NaN, float.NaN);    }    //через подобные треугольники находим проекцию    var delta = ScreenDist / local.Z;    var proection = new Vector2(local.X, local.Y) * delta;    //если точка принадлежит экранной области - вернем ее    if (proection.X >= 0 && proection.X < ScreenWidth && proection.Y >= 0 && proection.Y < ScreenHeight)    {        return proection;    }    return new Vector2(float.NaN, float.NaN);}


Переводим в экранные координаты


Здесь я разъясню некоторый момент. Cвязан он с тем, что во многих графических библиотеках рисование происходит в экранной системе координат, в таких координатах начало это верхняя левая точка экрана, x увеличивается при движении вправо, а y при движении вниз. В нашей проекционной плоскости точки представлены в обычных декартовых координатах и перед отрисовкой необходимо переводить эти координаты в экранные. Сделать это нетрудно, нужно только сместить начало координат в верхний левый угол и инвертировать y:

image


Код камеры 3
public Vector2 ScreenProection(Vector3 v){    var local = Pivot.ToLocalCoords(v);    //игнорируем точки сзади камеры    if (local.Z < ScreenDist)    {        return new Vector2(float.NaN, float.NaN);    }    //через подобные треугольники находим проекцию    var delta = ScreenDist / local.Z;    var proection = new Vector2(local.X, local.Y) * delta;    //этот код нужен для перевода проекции в экранные координаты    var screen = proection + new Vector2(ScreenWidth / 2, -ScreenHeight / 2);    var screenCoords = new Vector2(screen.X, -screen.Y);    //если точка принадлежит экранной области - вернем ее    if (screenCoords.X >= 0 && screenCoords.X < ScreenWidth && screenCoords.Y >= 0 && screenCoords.Y < ScreenHeight)    {        return screenCoords;    }    return new Vector2(float.NaN, float.NaN);}


Корректируем размер спроецированного изображения


Если вы используете предыдущий код для того, чтобы нарисовать объект то получите что-то вроде этого:

image


Почему то все объекты рисуются очень маленькими. Для того, чтобы понять причину вспомните как мы вычисляли проекцию умножали x и y координаты на дельту отношения z' / z. Это значит, что размер объекта на экране зависит от расстояния до проекционной плоскости z'. А ведь z' мы можем задать сколь угодно маленьким значением. Значит нам нужно корректировать размер проекции в зависимости от текущего значения z'. Для этого добавим в камеру еще одно поле угол ее обзора.

image


Он нам нужен для сопоставления углового размера экрана с его шириной. Угол будет сопоставлен с шириной экрана таким образом: максимальный угол в пределах которого смотрит камера это левый или правый край экрана. Тогда максимальный угол от оси z камеры составляет o / 2. Проекция, которая попала на правый край экрана должна иметь координату x = width / 2, а на левый: x = -width / 2. Зная это выведем формулу для нахождения коэффициента растяжения проекции:

image


Код камеры 4
public float ObserveRange { get; private set; }public float Scale => ScreenWidth / (float)(2 * ScreenDist * Math.Tan(ObserveRange / 2));public Vector2 ScreenProection(Vector3 v){    var local = Pivot.ToLocalCoords(v);    //игнорируем точки сзади камеры    if (local.Z < ScreenDist)    {        return new Vector2(float.NaN, float.NaN);    }    //через подобные треугольники находим проекцию и умножаем ее на коэффициент растяжения    var delta = ScreenDist / local.Z * Scale;    var proection = new Vector2(local.X, local.Y) * delta;    //этот код нужен для перевода проекции в экранные координаты    var screen = proection + new Vector2(ScreenWidth / 2, -ScreenHeight / 2);    var screenCoords = new Vector2(screen.X, -screen.Y);    //если точка принадлежит экранной области - вернем ее    if (screenCoords.X >= 0 && screenCoords.X < ScreenWidth && screenCoords.Y >= 0 && screenCoords.Y < ScreenHeight)    {        return screenCoords;    }    return new Vector2(float.NaN, float.NaN);}


Вот такой простой код отрисовки я использовал для теста:

Код рисования объектов
public DrawObject(Primitive primitive , Camera camera){    for (int i = 0; i < primitive.Indexes.Length; i+=3)    {        var color = randomColor();        // индексы вершин полигона        var i1 = primitive.Indexes[i];        var i2 = primitive.Indexes[i+ 1];        var i3 = primitive.Indexes[i+ 2];        // вершины полигона        var v1 = primitive.GlobalVertices[i1];        var v2 = primitive.GlobalVertices[i2];        var v3 = primitive.GlobalVertices[i3];        // рисуем полигон        DrawPolygon(v1,v2,v3 , camera , color);    }}public void DrawPolygon(Vector3 v1, Vector3 v2, Vector3 v3, Camera camera , color){    //проекции вершин    var p1 = camera.ScreenProection(v1);    var p2 = camera.ScreenProection(v2);    var p3 = camera.ScreenProection(v3);    //рисуем полигон    DrawLine(p1, p2 , color);    DrawLine(p2, p3 , color);    DrawLine(p3, p2 , color);}


Давайте проверим рендер на сцене и кубов:

image


И да, все прекрасно работает. Для тех, кому разноцветные кубики не кажутся пафосными я написал функцию парсинга моделей формата OBJ в объекты типа Primitive, залил фон черным и отрисовал несколько моделей:

Результат работы рендера

image

image


Растеризация полигонов. Наводим красоту.



В прошлом разделе мы написали проволочный рендер. Теперь мы займемся его модернизацией реализуем растеризацию полигонов.

По простому растеризировать полигон это значит закрасить его. Казалось бы зачем писать велосипед, когда есть уже готовые функции растеризации треугольника. Вот что будет если нарисовать все дефолтными инструментами:

image


Современное искусство, полигоны сзади нарисовались поверх передних, одним словом каша. К тому же как таким образом текстурировать объекты? Да, никак. Значит нам нужно написать свой имба-растерайзер, который будет уметь в отсечение невидимых точек, текстуры и даже в шейдеры! Но для того чтобы это сделать стоит понять как вообще красить треугольники.

Алгоритм Брезенхема для рисования линии.


Начнем с линий. Если кто не знал алгоритм Брезенхема это основной алгоритм рисования прямых в компьютерной графике. Он или его модификации используется буквально везде: рисование прямых, отрезков, окружностей и т.п. Кому интересно более подробное описание читайте вики. Алгоритм Брезенхема

Имеется отрезок соединяющий точки {x1, y1} и {x2, y2}. Чтобы нарисовать отрезок между ними нужно закрасить все пиксели которые попадают на него. Для двух точек отрезка можно найти x-координаты пикселей в которых они лежат: нужно лишь взять целые части от координат x1 и x2. Чтобы закрасить пиксели на отрезке запускаем цикл от x1 до x2 и на каждой итерации вычисляем y координату пикселя который попадает на прямую. Вот код:

void Brezenkhem(Vector2 p1 , Vector2 p2){    int x1 = Floor(p1.X);    int x2 = Floor(p2.X);    if (x1 > x2) {Swap(x1, x2); Swap(p1 , p2);}    float d = (p2.Y - p1.Y) / (x2 - x1);    float y = p1.Y;    for (int i = x1; i <= x2; i++)    {        int pixelY = Floor(y);        FillPixel(i , pixelY);        y += d;    }}

image

Картинка из вики

Растеризация треугольника. Алгоритм заливки


Линии рисовать мы умеем, а вот с треугольниками будет чуть посложнее(не намного)! Задача рисования треугольника сводится к нескольким задачам рисования линий. Для начала разобьем треугольник на две части предварительно отсортировав точки в порядке возрастания x:

image


Заметьте теперь у нас есть две части в которых явно выражены нижняя и верхняя границы. все что осталось это залить все пиксели находящиеся между ними! Сделать это можно в 2 цикла: от x1 до x2 и от x3 до x2.

void Triangle(Vector2 v1 , Vector2 v2 , Vector2 v3){    //хардкодим BubbleSort для упорядочивания по x    if (v1.X > v2.X) { Swap(v1, v2); }    if (v2.X > v3.X) { Swap(v2, v3); }    if (v1.X > v2.X) { Swap(v1, v2); }    //узнаем на сколько увеличивается y границ при увеличении x    //избегаем деления на 0: если x1 == x2 значит эта часть треугольника - линия    var steps12 = max(v2.X - v1.X , 1);    var steps13 = max(v3.X - v1.X , 1);    var upDelta = (v2.Y - v1.Y) / steps12;    var downDelta = (v3.Y - v1.Y) / steps13;    //верхняя граница должна быть выше нижней    if (upDelta < downDelta) Swap(upDelta , downDelta);    //изначально у координаты границ равны y1    var up = v1.Y;    var down = v1.Y;    for (int i = (int)v1.X; i <= (int)v2.X; i++)    {        for (int g = (int)down; g <= (int)up; g++)        {            FillPixel(i , g);        }        up += upDelta;        down += downDelta;    }    //все то же самое для другой части треугольника    var steps32 = max(v2.X - v3.X , 1);    var steps31 = max(v1.X - v3.X , 1);    upDelta = (v2.Y - v3.Y) / steps32;    downDelta = (v1.Y - v3.Y) / steps31;    if (upDelta < downDelta) Swap(upDelta, downDelta);    up = v3.Y;    down = v3.Y;    for (int i = (int)v3.X; i >=(int)v2.X; i--)    {        for (int g = (int)down; g <= (int)up; g++)        {            FillPixel(i, g);        }        up += upDelta;        down += downDelta;    }}

Несомненно этот код можно отрефакторить и не дублировать цикл:

void Triangle(Vector2 v1 , Vector2 v2 , Vector2 v3){    if (v1.X > v2.X) { Swap(v1, v2); }    if (v2.X > v3.X) { Swap(v2, v3); }    if (v1.X > v2.X) { Swap(v1, v2); }    var steps12 = max(v2.X - v1.X , 1);    var steps13 = max(v3.X - v1.X , 1);    var steps32 = max(v2.X - v3.X , 1);    var steps31 = max(v1.X - v3.X , 1);    var upDelta = (v2.Y - v1.Y) / steps12;    var downDelta = (v3.Y - v1.Y) / steps13;    if (upDelta < downDelta) Swap(upDelta , downDelta);    TrianglePart(v1.X , v2.X , v1.Y , upDelta , downDelta);    upDelta = (v2.Y - v3.Y) / steps32;    downDelta = (v1.Y - v3.Y) / steps31;    if (upDelta < downDelta) Swap(upDelta, downDelta);    TrianglePart(v3.X, v2.X, v3.Y, upDelta, downDelta);}void TrianglePart(float x1 , float x2 , float y1  , float upDelta , float downDelta){    float up = y1, down = y1;    for (int i = (int)x1; i <= (int)x2; i++)    {        for (int g = (int)down; g <= (int)up; g++)        {            FillPixel(i , g);        }        up += upDelta; down += downDelta;    }}

Отсечение невидимых точек.


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

image


Для того, чтобы понять видима точки или нет, в рендеринге применяют механизм zbuffer-а(буфера глубины). zbuffer можно представить как двумерный массив (можно сжать в одномерный) с размерностью width * height. Для каждого пикселя на экране он хранит значение z координаты на исходном полигоне откуда эта точка была спроецирована. Соответственно чем ближе точка к наблюдателю, тем меньше ее z координата. В конечном итоге если проекции нескольких точек совпадают растеризировать нужно точку с минимальной z координатой:

image


Теперь возникает вопрос как находить z-координаты точек на исходном полигоне? Это можно сделать несколькими способами. Например можно пускать луч из начала координат камеры, проходящий через точку на проекционной плоскости {x, y, z'}, и находить его пересечение с полигоном. Но искать пересечения крайне затратная операция, поэтому будем использовать другой способ. Для рисования треугольника мы интерполировали координаты его проекций, теперь, помимо этого, мы будем интерполировать также и координаты исходного полигона. Для отсечения невидимых точек будем использовать в методе растеризации состояние zbuffer-а для текущего фрейма.

Мой zbuffer будет иметь вид Vector3[] он будет содержать не только z координаты, но и интерполированные значения точек полигона(фрагменты) для каждого пикселя экрана. Это сделано в целях экономии памяти так как в дальнейшем нам все равно пригодятся эти значения для написания шейдеров! А пока что имеем следующий код для определения видимых вершин(фрагментов):

Код
public void ComputePoly(Vector3 v1, Vector3 v2, Vector3 v3 , Vector3[] zbuffer){    //находим проекцию полигона    var v1p = Camera.ScreenProection(v1);    var v2p = Camera.ScreenProection(v2);    var v3p = Camera.ScreenProection(v3);    //упорядочиваем точки по x - координате    //Заметьте, также меняем исходные точки - они должны соответствовать проекциям    if (v1p.X > v2p.X) { Swap(v1p, v2p); Swap(v1p, v2p); }    if (v2p.X > v3p.X) { Swap(v2p, v3p); Swap(v2p, v3p); }    if (v1p.X > v2p.X) { Swap(v1p, v2p); Swap(v1p, v2p); }    //считаем количество шагов для построения линии алгоритмом Брезенхема    int x12 = Math.Max((int)v2p.X - (int)v1p.X, 1);    int x13 = Math.Max((int)v3p.X - (int)v1p.X, 1);    //теперь помимо проекций будем интерполировать и исходные точки    float dy12 = (v2p.Y - v1p.Y) / x12; var dr12 = (v2 - v1) / x12;    float dy13 = (v3p.Y - v1p.Y) / x13; var dr13 = (v3 - v1) / x13;    Vector3 deltaUp, deltaDown; float deltaUpY, deltaDownY;    if (dy12 > dy13) { deltaUp = dr12; deltaDown = dr13; deltaUpY = dy12; deltaDownY = dy13;}    else { deltaUp = dr13; deltaDown = dr12; deltaUpY = dy13; deltaDownY = dy12;}    TrianglePart(v1 , deltaUp , deltaDown , x12 , 1 , v1p , deltaUpY , deltaDownY , zbuffer);    //вторую часть треугольника аналогично - думаю вы поняли}public void ComputePolyPart(Vector3 start, Vector3 deltaUp, Vector3 deltaDown,    int xSteps, int xDir, Vector2 pixelStart, float deltaUpPixel, float deltaDownPixel , Vector3[] zbuffer){    int pixelStartX = (int)pixelStart.X;    Vector3 up = start - deltaUp, down = start - deltaDown;    float pixelUp = pixelStart.Y - deltaUpPixel, pixelDown = pixelStart.Y - deltaDownPixel;    for (int i = 0; i <= xSteps; i++)    {        up += deltaUp; pixelUp += deltaUpPixel;        down += deltaDown; pixelDown += deltaDownPixel;        int steps = ((int)pixelUp - (int)pixelDown);        var delta = steps == 0 ? Vector3.Zero : (up - down) / steps;        Vector3 position = down - delta;        for (int g = 0; g <= steps; g++)        {            position += delta;            var proection = new Point(pixelStartX + i * xDir, (int)pixelDown + g);            int index = proection.Y * Width + proection.X;            //проверка на глубину            if (zbuffer[index].Z == 0 || zbuffer[index].Z > position.Z)            {                zbuffer[index] = position;            }        }    }}


image

Анимация шагов растеризатора(при перезаписи глубины в zbuffer-е пиксель выделяется красным):

Для удобства я вынес весь код в отдельный модуль Rasterizer:

Класс растеризатора
    public class Rasterizer    {        public Vertex[] ZBuffer;        public int[] VisibleIndexes;        public int VisibleCount;        public int Width;        public int Height;        public Camera Camera;        public Rasterizer(Camera camera)        {            Shaders = shaders;            Width = camera.ScreenWidth;            Height = camera.ScreenHeight;            Camera = camera;        }        public Bitmap Rasterize(IEnumerable<Primitive> primitives)        {            var buffer = new Bitmap(Width , Height);            ComputeVisibleVertices(primitives);            for (int i = 0; i < VisibleCount; i++)            {                var vec = ZBuffer[index];                var proec = Camera.ScreenProection(vec);                buffer.SetPixel(proec.X , proec.Y);            }            return buffer.Bitmap;        }        public void ComputeVisibleVertices(IEnumerable<Primitive> primitives)        {            VisibleCount = 0;            VisibleIndexes = new int[Width * Height];            ZBuffer = new Vertex[Width * Height];            foreach (var prim in primitives)            {                foreach (var poly in prim.GetPolys())                {                    MakeLocal(poly);                    ComputePoly(poly.Item1, poly.Item2, poly.Item3);                }            }        }        public void MakeLocal(Poly poly)        {            poly.Item1.Position = Camera.Pivot.ToLocalCoords(poly.Item1.Position);            poly.Item2.Position = Camera.Pivot.ToLocalCoords(poly.Item2.Position);            poly.Item3.Position = Camera.Pivot.ToLocalCoords(poly.Item3.Position);        }    }


Теперь проверим работу рендера. Для этого я использую модель Сильваны из известной RPG WOW:

image


Не очень понятно, правда? А все потому что здесь нет ни текстур ни освещения. Но вскоре мы это исправим.

Текстуры! Нормали! Освещение! Мотор!


Почему я объединил все это в один раздел? А потому что по своей сути текстуризация и расчет нормалей абсолютно идентичны и скоро вы это поймете.

Для начала рассмотрим задачу текстуризации для одного полигона. Теперь помимо обычных координат вершин полигона мы будем хранить еще и его текстурные координаты. Текстурная координата вершины представляется двумерным вектором и указывает на пиксель изображения текстуры. В интернете я нашел хорошую картинку чтобы показать это:

image

Заметьте, что начало текстуры (левый нижний пиксель) в текстурных координатах имеет значение {0, 0}, конец (правый верхний пиксель) {1, 1}. Учитывайте систему координат текстуры и возможность выхода за границы картинки когда текстурная координата равна 1.

Сразу создадим класс для представления данных вершины:

  public class Vertex    {        public Vector3 Position { get; set; }        public Color Color { get; set; }        public Vector2 TextureCoord { get; set; }        public Vector3 Normal { get; set; }        public Vertex(Vector3 pos , Color color , Vector2 texCoord , Vector3 normal)        {            Position = pos;            Color = color;            TextureCoord = texCoord;            Normal = normal;        }    }

Зачем нужны нормали я объясню позже, пока что просто будем знать, что у вершин они могут быть. Теперь для текстуризации полигона нам необходимо каким-то образом сопоставить значение цвета из текстуры конкретному пикселю. Помните как мы интерполировали вершины? Здесь нужно сделать то же самое! Я не буду еще раз переписывать код растеризации, а предлагаю вам самим реализовать текстурирование в вашем рендере. Результатом должно быть корректное отображение текстур на модели. Вот, что получилось у меня:

текстурированная модель
image


Вся информация о текстурных координатах модели находятся в файле OBJ. Для того, чтобы использовать это изучите формат: Формат OBJ.

Освещение



С текстурами все стало гораздо веселее, но по настоящему весело будет когда мы реализуем освещение для сцены. Для имитации дешевого освещения я буду использовать модель Фонга.

Модель Фонга


В общем случае этот метод имитирует наличие 3х составляющих освещения: фоновая(ambient), рассеянная(diffuse) и зеркальная(reflect). Сумма этих трех компонент в итоге даст имитацию физического поведения света.

image

Модель Фонга

Для расчета освещения по Фонгу нам будут нужны нормали к поверхностям, для этого я и добавил их в классе Vertex. Где же брать значения этих нормалей? Нет, ничего вычислять нам не нужно. Дело в том, что великодушные 3д редакторы часто сами считают их и предоставляют вместе с данными модели в контексте формата OBJ. Распарсив файл модели мы получаем значение нормалей для 3х вершин каждого полигона.

image

Картинка из вики

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

Фоновый свет (Ambient)


Изначально мы задаем постоянное фоновое освещение, для нетекстурированных объектов можно выбрать любой цвет, для объектов с текстурами я делю каждую из компонент RGB на некий коэффициент базового затенения (baseShading).

Рассеянный свет (Diffuse)


Когда свет падает на поверхность полигона он равномерно рассеивается. Для расчета diffuse значения на конкретном пикселе учитывается угол под которым свет падает на поверхность. Чтобы рассчитать этот угол можно применить скалярное произведение падающего луча и нормали(само собой вектора перед этим нужно нормализировать). Этот угол будет умножаться на некий коэффициент интенсивности света. Если скалярное произведение отрицательно это значит, что угол между векторами больше 90 градусов. В этом случае мы начнем рассчитывать уже не осветление, а, наоборот, затенение. Стоит избегать этого момента, сделать это можно с помощью функции max.

Код
public interface IShader    {        void ComputeShader(Vertex vertex, Camera camera);    }    public struct Light    {        public Vector3 Pos;        public float Intensivity;    }public class PhongModelShader : IShader    {        public static float DiffuseCoef = 0.1f;        public Light[] Lights { get; set; }        public PhongModelShader(params Light[] lights)        {            Lights = lights;        }        public void ComputeShader(Vertex vertex, Camera camera)        {            if (vertex.Normal.X == 0 && vertex.Normal.Y == 0 && vertex.Normal.Z == 0)            {                return;            }            var gPos = camera.Pivot.ToGlobalCoords(vertex.Position);            foreach (var light in Lights)            {                var ldir = Vector3.Normalize(light.Pos - gPos);                var diffuseVal = Math.Max(VectorMath.Cross(ldir, vertex.Normal), 0) * light.Intensivity;                vertex.Color = Color.FromArgb(vertex.Color.A,                    (int)Math.Min(255, vertex.Color.R * diffuseVal * DiffuseCoef),                    (int)Math.Min(255, vertex.Color.G * diffuseVal * DiffuseCoef,                    (int)Math.Min(255, vertex.Color.B * diffuseVal * DiffuseCoef));            }        }    }


Давайте применим рассеянный свет и рассеем тьму:

image

Зеркальный свет (Reflect)


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

image

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

код
    public class PhongModelShader : IShader    {        public static float DiffuseCoef = 0.1f;        public static float ReflectCoef = 0.2f;        public Light[] Lights { get; set; }        public PhongModelShader(params Light[] lights)        {            Lights = lights;        }        public void ComputeShader(Vertex vertex, Camera camera)        {            if (vertex.Normal.X == 0 && vertex.Normal.Y == 0 && vertex.Normal.Z == 0)            {                return;            }            var gPos = camera.Pivot.ToGlobalCoords(vertex.Position);            foreach (var light in Lights)            {                var ldir = Vector3.Normalize(light.Pos - gPos);                //Следующие три строчки нужны чтобы найти отраженный от поверхности луч                var proection = VectorMath.Proection(ldir, -vertex.Normal);                var d = ldir - proection;                var reflect = proection - d;                var diffuseVal = Math.Max(VectorMath.Cross(ldir, -vertex.Normal), 0) * light.Intensivity;                //луч от наблюдателя                var eye = Vector3.Normalize(-vertex.Position);                var reflectVal = Math.Max(VectorMath.Cross(reflect, eye), 0) * light.Intensivity;                var total = diffuseVal * DiffuseCoef + reflectVal * ReflectCoef;                vertex.Color = Color.FromArgb(vertex.Color.A,                    (int)Math.Min(255, vertex.Color.R * total),                    (int)Math.Min(255, vertex.Color.G * total),                    (int)Math.Min(255, vertex.Color.B * total));            }        }    }


Теперь картинка выглядит следующим образом:

image


Тени


Конечной точкой моего изложения будет реализация теней для рендера. Первая тупиковая идея которая зародилась у меня в черепушке для каждой точки проверять не лежит ли между ней и светом какой-нибудь полигон. Если лежит значит не нужно освещать пиксель. Модель Сильваны содержит 220к с лихвой полигонов. Если так для каждой точки проверять пересечение со всеми этими полигонами, то нужно сделать максимум 220000 * 1920 * 1080 * 219999 вызовов метода пересечения! За 10 минут мой компьютер смог осилить 10-у часть всех вычислений (2600 полигонов из 220000), после чего у меня случился сдвиг и я отправился на поиски нового метода.

В интернете мне попался очень простой и красивый способ, который выполняет те же вычисления в тысячи раз быстрее. Называется он Shadow mapping(построение карты теней). Вспомните как мы определяли видимые наблюдателю точки использовали zbuffer. Shadow mapping делает тоже самое! В первом проходе наша камера будет находиться в позиции света и смотреть на объект. Таким образом мы сформируем карту глубин для источника света. Карта глубин это знакомый нам zbuffer. Во втором проходе мы используем эту карту, чтобы определять вершины которые должны освещаться. Сейчас я нарушу правила хорошего кода и пойду читерским путем просто передам шейдеру новый объект растеризатора и он используя его создаст нам карту глубин.

Код
public class ShadowMappingShader : IShader{    public Enviroment Enviroment { get; set; }    public Rasterizer Rasterizer { get; set; }    public Camera Camera => Rasterizer.Camera;    public Pivot Pivot => Camera.Pivot;    public Vertex[] ZBuffer => Rasterizer.ZBuffer;    public float LightIntensivity { get; set; }    public ShadowMappingShader(Enviroment enviroment, Rasterizer rasterizer, float lightIntensivity)    {        Enviroment = enviroment;        LightIntensivity = lightIntensivity;        Rasterizer = rasterizer;        //я добвил события в объекты рендера, привязав к ним перерасчет карты теней        //теперь при вращении/движении камеры либо при изменение сцены шейдер будет перезаписывать глубину        Camera.OnRotate += () => UpdateDepthMap(Enviroment.Primitives);        Camera.OnMove += () => UpdateDepthMap(Enviroment.Primitives);        Enviroment.OnChange += () => UpdateDepthMap(Enviroment.Primitives);        UpdateVisible(Enviroment.Primitives);    }    public void ComputeShader(Vertex vertex, Camera camera)    {        //вычисляем глобальные координаты вершины        var gPos = camera.Pivot.ToGlobalCoords(vertex.Position);        //дистанция до света        var lghDir = Pivot.Center - gPos;        var distance = lghDir.Length();        var local = Pivot.ToLocalCoords(gPos);        var proectToLight = Camera.ScreenProection(local).ToPoint();        if (proectToLight.X >= 0 && proectToLight.X < Camera.ScreenWidth && proectToLight.Y >= 0            && proectToLight.Y < Camera.ScreenHeight)        {            int index = proectToLight.Y * Camera.ScreenWidth + proectToLight.X;            if (ZBuffer[index] == null || ZBuffer[index].Position.Z >= local.Z)            {                vertex.Color = Color.FromArgb(vertex.Color.A,                    (int)Math.Min(255, vertex.Color.R + LightIntensivity / distance),                    (int)Math.Min(255, vertex.Color.G + LightIntensivity / distance),                    (int)Math.Min(255, vertex.Color.B + LightIntensivity / distance));            }        }        else        {            vertex.Color = Color.FromArgb(vertex.Color.A,                    (int)Math.Min(255, vertex.Color.R + (LightIntensivity / distance) / 15),                    (int)Math.Min(255, vertex.Color.G + (LightIntensivity / distance) / 15),                    (int)Math.Min(255, vertex.Color.B + (LightIntensivity / distance) / 15));        }    }    public void UpdateDepthMap(IEnumerable<Primitive> primitives)    {        Rasterizer.ComputeVisibleVertices(primitives);    }}


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

image


Многие из вас наверное заметили артефакты данного шейдера(необработанные светом черные точки). Опять же обратившись в всезнающую сеть я нашел описание этого эффекта с противным названием shadow acne(да простят меня люди с комплексом внешности). Суть таких зазоров заключается в том, что для определения тени мы используем ограниченное разрешение карты глубин. Это значит, что несколько вершин при рендеринге получают одно значение из карты глубин. Такому артефакту наиболее подвержены поверхности на которые свет падает под пологим углом. Эффект можно исправить, увеличив разрешение рендера для света, однако существует более элегантный способ. Заключается он в том, чтобы добавлять определенный сдвиг для глубины в зависимости от угла между лучом света и поверхностью. Это можно сделать при помощи скалярного произведения.

Улучшенные тени
public class ShadowMappingShader : IShader{    public Enviroment Enviroment { get; set; }    public Rasterizer Rasterizer { get; set; }    public Camera Camera => Rasterizer.Camera;    public Pivot Pivot => Camera.Pivot;    public Vertex[] ZBuffer => Rasterizer.ZBuffer;    public float LightIntensivity { get; set; }    public ShadowMappingShader(Enviroment enviroment, Rasterizer rasterizer, float lightIntensivity)    {        Enviroment = enviroment;        LightIntensivity = lightIntensivity;        Rasterizer = rasterizer;        //я добвил события в объекты рендера, привязав к ним перерасчет карты теней        //теперь при вращении/движении камеры либо при изменение сцены шейдер будет перезаписывать глубину        Camera.OnRotate += () => UpdateDepthMap(Enviroment.Primitives);        Camera.OnMove += () => UpdateDepthMap(Enviroment.Primitives);        Enviroment.OnChange += () => UpdateDepthMap(Enviroment.Primitives);        UpdateVisible(Enviroment.Primitives);    }    public void ComputeShader(Vertex vertex, Camera camera)    {        //вычисляем глобальные координаты вершины        var gPos = camera.Pivot.ToGlobalCoords(vertex.Position);        //дистанция до света        var lghDir = Pivot.Center - gPos;        var distance = lghDir.Length();        var local = Pivot.ToLocalCoords(gPos);        var proectToLight = Camera.ScreenProection(local).ToPoint();        if (proectToLight.X >= 0 && proectToLight.X < Camera.ScreenWidth && proectToLight.Y >= 0            && proectToLight.Y < Camera.ScreenHeight)        {            int index = proectToLight.Y * Camera.ScreenWidth + proectToLight.X;            var n = Vector3.Normalize(vertex.Normal);            var ld = Vector3.Normalize(lghDir);            //вычисляем сдвиг глубины            float bias = (float)Math.Max(10 * (1.0 - VectorMath.Cross(n, ld)), 0.05);            if (ZBuffer[index] == null || ZBuffer[index].Position.Z + bias >= local.Z)            {                vertex.Color = Color.FromArgb(vertex.Color.A,                    (int)Math.Min(255, vertex.Color.R + LightIntensivity / distance),                    (int)Math.Min(255, vertex.Color.G + LightIntensivity / distance),                    (int)Math.Min(255, vertex.Color.B + LightIntensivity / distance));            }        }        else        {            vertex.Color = Color.FromArgb(vertex.Color.A,                    (int)Math.Min(255, vertex.Color.R + (LightIntensivity / distance) / 15),                    (int)Math.Min(255, vertex.Color.G + (LightIntensivity / distance) / 15),                    (int)Math.Min(255, vertex.Color.B + (LightIntensivity / distance) / 15));        }    }    public void UpdateDepthMap(IEnumerable<Primitive> primitives)    {        Rasterizer.ComputeVisibleVertices(primitives);    }}

image


Бонус

Играем с нормалями


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

image


Двигаем свет


Реализовав простой цикл получим набор отрендеренных картинок с разными позициями света на сцене:

            float angle = (float)Math.PI / 90;            var shader = (preparer.Shaders[0] as PhongModelShader);            for (int i = 0; i < 180; i+=2)            {                shader.Lights[0] = = new Light()                    {                        Pos = shader.Lights[0].Pos.Rotate(angle , Axis.X) ,                        Intensivity = shader.Lights[0].Intensivity                    };                Draw();            }

image

Производительность


Для теста использовалась следующие конфигурации:

  • Модель Сильваны: 220к полигонов.
  • Разрешение экрана: 1920x1080.
  • Шейдеры: Phong model shader
  • Конфигурация компьютера: cpu core i7 4790, 8 gb ram

FPS рендеринга составлял 1-2 кадр/сек. Это далеко не realtime. Однако стоит все же учитывать, что вся обработка происходила без использования многопоточности, т.е. на одном ядре cpu.

Заключение


Я считаю себя новичком в 3д графике не исключаю ошибок допущенных мною в ходе изложения. Единственное на что я опираюсь это практический результат, полученный в процессе создания. Все поправки и оптимизации(если таковые будут) вы можете оставить в комментариях, я с радостью их прочту. Ссылка на репозиторий проекта.
Подробнее..

GetHashCode() и философский камень, или краткий очерк о граблях

11.09.2020 20:19:02 | Автор: admin

Казалось бы, что тема словарей, хэш-таблиц и всяческих хэш-кодов расписана вдоль и поперек, а каждый второй разработчик, будучи разбужен от ранней вечерней дремы примерно в 01:28am, быстренько набросает на листочке алгоритм балансировки Hashtable, попутно доказав все свойства в big-O нотации.

Возможно, такая хорошая осведомленность о предмете нашей беседы, может сослужить и плохую службу, вселяя ложное чувство уверенности: "Это ж так просто! Что тут может пойти не так?"

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

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

Хэш-таблица для самых маленьких

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

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

Теплая ламповая хэш-таблицаТеплая ламповая хэш-таблица

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

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

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

Если же хэш числовой (как это обычно у нас бывает в IT), то просто берут остаток от его деления на количество коробок, что еще проще.

Так и живем, а чтобы все это хозяйство работало правильно, хэш-коды должны соответствовать некоторым весьма простым правилам:

  1. Хэш-код - это не первичный ключ, он совсем не обязан быть уникальным.
    Поликлиника вполне способна сносно функционировать даже в случае, когда у неё на учете стоят два пациента по фамиилии "Иванов".

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

  3. Хэш-код - это не атрибут объекта, поэтому самостоятельной ценности он не несёт и хранить его не нужно (и даже вредно).
    В одной поликлинике хэш - это фамилия, в другой - имя, а креативный паспортный стол хэширует по дате рождения и цвету глаз. И кто их разберет, как они там внутри работают.

  4. Но для одного и того же объекта (или разных, но одинаковых объектов) хэш должен совпадать. Не должно происходить такого, что по понедельникам моя карточка лежит сверху и справа, по четвергам - по центру, а по субботам её вообще под ножку ставят, чтобы хэш-таблица не шаталась.

Ну а теперь перейдем к реальным (ну или почти реальным) примерам.

Хэш, кеш и EF

На коленке написанная подсистема по работе с документами. Документ - это такая простая штука вида

public class Document{  public Int32 Id {get; set;}  public String Name {get; set;}  ...}

Документы хранятся в базе посредством Entity Framework. А от бизнеса требование - чтобы в один момент времени документ мог редактироваться только одним пользователем.

В лучших традициях велосипедостроения это требование на самом нижнем уровне реализовано в виде хэш-таблицы:

HashSet<Document> _openDocuments;

И когда кто-то создает новый документ, сохраняет его в базу и продолжает редактировать, используется следующий код:

var newDocument = new Document(); // document is created_openDocuments.Add(newDocument); // document is open, nobody else can edit it.context.Documents.Add(newDocument);await context.SaveChangesAsync(); // so it's safe to write the document to the DB

Как вы думаете, чему равно значение переменной test в следующей строке, которая выполнится сразу после написанного выше кода?

Boolean test = _openDocuments.Contains(newDocument);

Разумеется, false, иначе бы этой статьи тут не было. Дьявол обычно кроется в деталях, а в нашем случае - в политике EF и в троеточиях объявления класса Document.

Для EF свойство Id выступает в роли первичного ключа, поэтому заботливая ORM по умолчанию мапит его на автоинкрементное поле базы данных. Таким образом, в момент создания объекта его Id равен 0, а сразу после записи в базу ему присваевается какое-то осмысленное значение:

var newDocument = new Document(); // newDocument.Id == 0_openDocuments.Add(newDocument);context.Documents.Add(newDocument);await context.SaveChangesAsync(); // newDocument.Id == 42

Само по себе такое поведение, конечно, хэш-таблицу сломать неспособно, поэтому для того, чтобы красиво выстрелить в ногу, внутри класса Document надо написать так:

public class Document{public Int32 Id {get; set;}public String Name {get; set;}  public override int GetHashCode() {    return Id; }}

А вот теперь пазл складывается: записали мы в хэш-таблицу объект с хэш-кодом 0, а позже попросили объект с кодом 42.

Мораль сей басни такова: если вы закопались в отладке, и вам кажется, что либо вы, либо компилятор сошли с ума - проверьте, как у ваших объектов переопределены GetHashCode и Equals методы. Иногда бывает интересно.

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

Квадратно-гнездовой метод

Как-то при работе над прототипом одной системы, обрабатывающей прямоугольники (а чаще квадраты) разного целочисленного размера, нужно было избавиться от дубликатов. То есть если на входе есть прямоугольники [20, 20], [30, 30] и [20, 20], то до выхода должны дойти [20, 20] и [30, 30]. Классическая задача, которая в лоб решается использованием хэш-таблицы:

private static IEnumerable<Size> FilterRectangles(IEnumerable<Size> rectangles){HashSet<Size> result = new HashSet<Size>();foreach (var rectangle in rectangles)    result.Add(rectangle);return result;}

Вроде бы и работает, но вовремя заметили, что производительность фильтрации как-то тяготеет к O(n^2), а не к более приятному O(n). Но постойте, классики Computer Science, ошибаться, конечно, могут, но не так фатально.

HashSet опять же самая обычная, да и Size - весьма тривиальная структура из FCL. Хорошо, что догадались проверить, какие же хэш-коды генерируются:

    var a = new Size(20,20).GetHashCode(); // a == 0     var b = new Size(30,30).GetHashCode(); // b == 0

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

Хотя, подозреваю, я слишком строг к этому представителю великой народности: реализуя вычисление хэша для SizeF, он, по всей вероятности, учел допущенную ошибку проектирования:

var a = new SizeF(20,20).GetHashCode(); // a == 346948956var b = new SizeF(30,30).GetHashCode(); // b == 346948956

Нет, a и b теперь не равны примитивному нулю! Теперь это истинно случайное значение 346948956...

Вместо заключения

Если вы думаете, что хэш-коды могут забавно вычисляться только в ваших собственных классах, ну и изредка в сущностях FCL, еще один забавный пример:

var a = Int64.MinValue.GetHashCode(); // a == 0var b = Int64.MaxValue.GetHashCode(); // a == 0

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

А будут ли выводы? Ну, давайте:

  1. Хорошо известные и изученные технологии могут преподносить любопытные сюрпризы на практике.

  2. При написании хэш-функции рекомендуется хорошенько подумать... либо использовать специальные кодогенераторы (см. в сторону Resharper).

  3. Верить никому нельзя. Мне - можно.

Подробнее..

Перевод Предпочитайте Rust вместо CC для нового кода

21.09.2020 22:05:33 | Автор: admin

2019-02-07


  • Когда использовать Rust
  • Когда не использовать Rust
  • Когда использовать C/C++
  • Ложные причины использования C/C++
  • Приложение: моя история с C/C++
  • Приложение: хор

Это документ с изложением моей позиции, который я первоначально распространил внутри сообщества разработчиков прошивок в X. Я получил запросы на публичную ссылку, поэтому я почистил статью и разместил её в блоге. Это, очевидно, мое личное мнение. Пожалуйста, прочтите все, прежде чем отправлять мне гневные письма.


TL;DR: C/C++ имеет достаточно конструктивных недостатков и альтернативные инструменты разработки уже находятся в достаточно хорошей форме, поэтому я не рекомендую использовать C/C++ для новых разработок, за исключением особых обстоятельств. В ситуациях, когда вам действительно нужна мощь C/C++, используйте вместо него Rust. В других ситуациях вам все равно не следовало бы использовать C/C++ используйте что-нибудь другое.


Когда использовать Rust


Такие приложения, как критически важные для безопасности прошивки, ядра операционных систем, криптография, стеки сетевых протоколов и мультимедийные декодеры (в течение последних 30 лет или около этого) в основном были написаны на C и C++. Это именно те области, в которых мы не можем позволить себе быть пронизанными потенциально эксплуатируемыми уязвимостями, такими как переполнения буфера (buffer overflows), некорректные указатели (dangling pointers), гонки (race conditions), целочисленные переполнения (integer overflows) и тому подобное.


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


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


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

Rust отвечает всем этим критериям, но также устраняет возможность выстрелить себе в ногу. Он может устранить большинство ошибок безопасности, сбоев и параллелизма, встречающихся в программном обеспечении на основе Cи.


(Если вам не нужны все эти критерии тогда, смотрите следующий раздел.)


Я внимательно слежу за Rust с 2013 года и язык значительно повзрослел. По состоянию на конец 2018 года^1^ я думаю, что он достаточно зрелый, чтобы начать рассматривать его как вариант, если ваша организация спокойно относится к генерации не оптимального кода. Я был одним из первых пользователей C++11 в 2011 году, и мой текущий опыт работы с Rust лучше, чем опыт с C++11 GCC в то время. Что о чем-то говорит.


Почему 2018? Потому что теперь можно заниматься разработкой под "голое железо" и для встраиваемых систем (например, модификацией ядра), не полагаясь на нестабильные функции из ночной сборки набора инструментов Rust (nightly Rust toolchain). К тому же изменения в редакции 2018 являются превосходными.


Я поддерживаю свои слова собственными действиями. Вместо того, чтобы просто говорить, я портирую свой высокопроизводительный встроенный и графический демонстрационный код с C++ на Rust. Это код для режима реального времени, в котором важны отдельные циклы ЦПУ, где у нас нет достаточного количества оперативной памяти для выполнения текущей задачи и мы нагружаем оборудование до предела. Версия кода на Rust более надежна, часто быстрее и всегда короче.


Когда не использовать Rust


Rust выделяется там, где исторически господствовал C/C++, но в результате Rust требует от вас, чтобы вы думали о некоторых вещах, что и в C/C++. В частности, вы потратите время на рассмотрение стратегий выделения памяти. Для большинства приложений в 2019 году это напрасная трата усилий; просто сбросьте эту проблему на сборщик мусора и закончите на этом. Если вам не нужен точный контроль со стороны Rust над локальностью памяти и определенностью, у вас есть множество других вариантов.


Конкретный пример: если бы меня попросили написать вычислитель символьной алгебры (symbolic algebra evaluator), или параллельную постоянную структуру данных (concurrent persistent data structure) или что-нибудь еще, что выполняет тяжелые манипуляции с графами, то я, вероятно, обращусь к чему-то что имеет трассирующий сборщик мусора например, что-то другое, но не Rust. Но это не будет C++, где мне пришлось бы работать так же усердно, как в Rust, но с меньшими затратами. Я бы лично подтолкнул вас к Swift^2^, но Go, Typescript, Python и даже Kotlin/Java вполне разумный выбор.


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


Когда использовать C/C++


Вот несколько веских причин, по которым вы все равно можете выбрать C/C++:


  • Вы уверены, что ваш код никогда не подвергнется атакам, не подвержен атакам повреждения данных или на кого-то полагался. Типа, взлом прототипа на Arduino. Тогда вперед.


  • У вас есть нормативные или договорные требования для использования определенного языка. Хотя в этом случае вы, вероятно, выберете Ada, которая в первую очередь значительно менее подвержена ошибкам, чем C.


  • Ваша целевая платформа не поддерживается в Rust. Поскольку Rust поддерживает почти все, что связано с бэкэндом LLVM, включая множество платформ, которые не поддерживаются в GCC. Это довольно короткий список, но в настоящее время он включает, не поддерживаемые 68HC11 и 68000. (Rust поддерживается на MSP430, Cortex-M и т.д., поддержка AVR в процессе стабилизации). И если вы на телефоне, десктопе или сервере, который вы сами поддерживаете. Даже на мейнфрейме IBM System 390.


  • Вы ожидаете, что ваш компилятор/набор инструментов (toolchain) будет сопровождаться соглашением о коммерческой поддержке. Я не знаю, чтобы кто-нибудь предлагал такое для набора инструментов Rust. Я также не знаю, чтобы кто-нибудь предлагал его сейчас для GCC, когда был куплен CodeSourcery.


  • Вы ожидаете, что ваша система станет достаточно большой, чтобы производительность rustc стала для вас проблемой и вы ожидаете, что это произойдет быстрее, чем rustc смогут улучшить. Rustc компилируется медленнее, чем GCC. Команда внимательно следит за этим, и ситуация улучшается. Ваш опыт будет во многом зависеть от сложности вашего кода C++; один из моих проектов собирается в Rust быстрее, чем в GCC.


  • У вас есть большая кодовая база C++, которая экспортирует только C++ интерфейс, не является независимым от языка API (например, интерфейс extern "C", каналы (pipes) или RPC). Семантика C++ настолько сложна, что ни один язык не справится с ней должным образом. (Swift, возможно, подходит ближе всего.) Наличие подобной системы у вас в какой-то момент вас "укусит".



Ложные причины использовать C/C++


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


У C/C++ есть 30+ лет работы над компилятором, поэтому они будут быстрее/надежнее.


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


Компиляторы C и C++ значительно улучшились за последние несколько десятилетий не потому, что мы постепенно разработали специальное понимание компиляции языка C, который был разработан, чтобы быть простым для компиляции, а потому, что мы стали лучше писать компиляторы.


Rust использует те же бэкэнд компилятора, оптимизаторы и генераторы кода, что и Swift и C++ (Clang). В большинстве случаев код работает так же быстро или быстрее, как сегодня скомпилированный C/C++.


Но у меня есть команда хорошо обученных программистов C/C++, у которых нет времени на изучение нового языка.


у меня для вас плохие новости. Ваши C/C++ программисты, вероятно, не так хорошо обучены, как вы думаете. Я работаю в месте, где все имеют очень твердое мнение о C++, которое они не хотят менять, работаю вместе с одними из лучших программистов на планете. И тем не менее, при проверке кода я все еще регулярно ловлю их на допущенных ошибках или коде полагающимся на неопределенное поведение (UB). Ошибки, которые они не допустили бы в Rust.


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


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


Для C программистов, смысл в том, что они пытаются сделать сначала назойливый двусвязный список, который бывает невозможно выразить в безопасном Rust (но мы над этим работаем). Это достаточно распространенная жалоба, поэтому существует целый учебник, относящийся к ней, Learning Rust With Allly Too Many Linked Lists.


Его также очень сложно сделать правильно в C/C++ и я могу практически гарантировать, что вы написали такой один, но он просто не корректен для много поточной / SMP среды. Вот почему его также трудно выразить в Rust.


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


Приложение: моя история с C/C++


Я не тот парень, который пробовал C++ и думал, что это сложно. Я прошел долгий путь, чтобы прийти к этому.


Я использую Cи примерно с 1993 года, а C++ с 2002 года, оба более или менее постоянно. Я использовал их в разных окружениях, включая продакшн в Google, Qt, Chrome, графические демонстрационки, ядра ОС и встроенные микросхемы управления батареями. При создании микропрограммной компании Loon, я твердо выступал за C++ (версии С99); мы быстро перешли на C++11 (в 2011 году), и черт возьми, это окупилось. Позже я вложил много энергии в то, чтобы убедить другие команды в X использовать C++, а не Cи для их прошивок.


Когда Loon не смог найти работающий на "голом железе" C++ код crt0.o для тогда еще новых процессоров Cortex-M, я написал его; они все еще работают на нем. Я написал замену стандартной библиотеки C++, которая устраняет выделение памяти в куче и добавляет некоторые Rust-о подобные возможности. Я знаю стандарт C++ не обычно хорошо или, по крайней мере, я его изучил. Я "заржавел" в прошлом году или года два назад (каламбур).


Кратко: вы можете ожидать, что моя частота ошибок на строки кода будет довольно низкой по отраслевым стандартам, и тем не менее я все еще допускаю ошибки в C++ чаще, чем могу с этим смириться. Мой разрыв с C++ был медленным и болезненным, но мне наконец приятно поговорить об этом.


Приложение: хор


Хор в который я собираю примеры умных людей согласных со мной. :-)


Крис Палмер (Chris Palmer): State of Software Security 2019: (выделено мной)


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

Эссе Алекса Гейнора (Alex Gaynor) The Internet Has a Huge C/C++ Problem and Developers Don't Want to Deal With It (в Vice, во всех местах):


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

(У него также есть отличные статьи в блоге по этой теме.)


Manish Goregaokar из Mozilla, пишет в ycombinator что fuzzing тестирование частей Rust кода в Firefox не выявило ошибок безопасности, но помогло найти ошибки в C++ коде, который он заменил:


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

Это было более или менее нашим опытом с fuzzing кода Rust в firefox, ух Фаззинг обнаружил множество паник (и отладочных ассертов / ассертов о безопасном переполнении). В одном из случаев он действительно обнаружил ошибку, которая не была замечена в аналогичном коде Gecko около десяти лет.

Copyright 2011-2019 Cliff L. Biffle Contact

Подробнее..
Категории: C++ , Rust , C , Rust c c++

Нам нужно поговорить про Linux IIO

24.09.2020 14:19:46 | Автор: admin

IIO (промышленный ввод / вывод) это подсистема ядра Linux для аналого-цифровых преобразователей (АЦП), цифро-аналоговых преобразователей (ЦАП) и различных типов датчиков. Может использоваться на высокоскоростных промышленных устройствах. Она, также, включает встроенный API для других драйверов.



Подсистема Industrial I/O Linux предлагает унифицированную среду для связи (чтения и записи) с драйверами, охватывающими различные типы встроенных датчиков и несколько исполнительных механизмов. Он также предлагает стандартный интерфейс для приложений пользовательского пространства, управляющих датчиками через sysfs и devfs.


Вот несколько примеров поддерживаемых типов датчиков в IIO:


  • АЦП / ЦАП
  • акселерометры
  • магнетометры
  • гироскопы
  • давление
  • влажность
  • температура
  • дальнометры

IIO может использоваться во многих различных случаях:


  • Низкоскоростная регистрация для медленно меняющегося входного сигнала (пример: запись температуры в файл)
  • Высоко-скоростной сбор данных с использованием АЦП, DFSDM или внешних устройств (например, аудио, измеритель мощности)
  • Считывание положения вращающегося элемента, используя интерфейс квадратурного энкодера TIM или LPTIM
  • Управление аналоговым источником через ЦАП
  • Внешние устройства подключенные через SPI или I2C

В целом про IIO информации немного, но но она есть, а поэтому в данной обзорной статья мы сначала ...


Сосредоточимся на моментах почему IIO это хорошо


Все наверняка встречали/пользовались конструкциями типа:


# https://www.kernel.org/doc/Documentation/i2c/dev-interfaceopen("/dev/i2c-1", O_RDWR);# https://www.kernel.org/doc/Documentation/spi/spidev.rstopen("/dev/spidev2.0", O_RDWR);

У данного способа много недостатков, я перечислю те которые считаю основными:


  1. нет прерываний
  2. способ доступа для данных индивидуален для каждого устройства

Ну как говориться зачем всё это если есть драйвера ?


Здесь мы опять сталкиваемся с "индивидульностью" каждого устройства (как допустим способ калибровки или размерность).


Собственно IIO даёт нам во-первых универсальность, во-вторых возможность poll по поступлению новых данных.


Сам IIO разделен на два уровня абстракции устройства и каналы измерений.


Выделим два основных способа доступа поддержанных в официальном ядре.


Простое использование IIO


Мы можем читать данные через sysfs (допустим для акселерометра):


# cat /sys/bus/iio/devices/iio\:device0/in_accel_x_raw-493

Это мы прочитали "сырые" измерения, их еще надо привести к общему виду.


Либо через read():


# Включим захват измерений для каждого канала(cd /sys/bus/iio/devices/iio:device0/scan_elements/ && for file in *_en; do echo 1 > $file; done)

Тогда мы можем свести взаимодействие к виду :


int fd = open("/dev/iio:device0");read(fd, buffer, scan_size);# где scan_size это сумма размера всех заказанных измерений, то есть для всех 1 в /sys/bus/iio/devices/iio:device0/scan_elements/*_en

Размер прочитанного блока всегда кратен scan_size, мы получаем "сырые" измерения, которые надо привести к общему виду, об этом позже.


Внутреннее устройство


Каналы


Любой драйвер IIO предоставляет информацию о возможных измерениях в виде стандартного описания каналов struct iio_chan_spec:


IIO types


Пример для датчика BME280


/* https://elixir.bootlin.com/linux/v5.9-rc1/source/drivers/iio/pressure/bmp280-core.c#L132*/static const struct iio_chan_spec bmp280_channels[] = {    {        .type = IIO_PRESSURE,        .info_mask_separate = BIT(IIO_CHAN_INFO_PROCESSED) |                      BIT(IIO_CHAN_INFO_OVERSAMPLING_RATIO),    },    {        .type = IIO_TEMP,        .info_mask_separate = BIT(IIO_CHAN_INFO_PROCESSED) |                      BIT(IIO_CHAN_INFO_OVERSAMPLING_RATIO),    },    {        .type = IIO_HUMIDITYRELATIVE,        .info_mask_separate = BIT(IIO_CHAN_INFO_PROCESSED) |                      BIT(IIO_CHAN_INFO_OVERSAMPLING_RATIO),    },};

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


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


Кольцевой буфер


Собственно это не так интригующее как звучит, основан на kfifo делает всё что положено кольцевому буфферу.


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


Метка времени


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


Представлена в наносекундах, является CLOCK_REALTIME.


IIO Triggered Buffers


Триггеры


Представляет из себя "внешнее" событие, которое инициирует захват данных с последующей передачей наверх в user space.


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


Назначить триггер устройству:


# cat /sys/bus/iio/devices/iio\:device0/trigger/current_triggericm20608-dev0# echo > /sys/bus/iio/devices/iio\:device0/trigger/current_trigger# cat /sys/bus/iio/devices/iio\:device0/trigger/current_trigger# echo "icm20608-dev0" > /sys/bus/iio/devices/iio\:device0/trigger/current_trigger

Official Trigger Documentation


IIO sysfs trigger


Industrial IIO configfs support


Triggered buffer support trigger buffer support for IIO subsystem of Linux device driver


Device owned triggers


Данный класс триггеров относиться к собственным триггерам устройства, они определяются в device tree:


icm20608: imu@0 {    ...    interrupt-parent = <&gpio5>;    interrupts = <11 IRQ_TYPE_EDGE_RISING>;    ...};

Это даст нам соответствующий триггер с именем:


cat /sys/bus/iio/devices/trigger0/nameicm20608-dev0

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


Interrupt triggers (also known as gpio trigger)


iio-trig-interrupt


Фактически тоже самое что и предыдущий тип, но он не привязан ни к какому конкретному устройству. Это может быть просто кнопка подсоединенная к gpio, так и любой источник прерываний.


Данный драйвер не поддержан в ядре в полном виде, ввиду сомнений текущего maintainer'a IIO Jonathan Cameron, хотя он так же является его автором.


Единственный способ задания в официальном ядре через платформенный код необходимый для этого платформенный код вы можете подсмотреть тут Triggered buffer support trigger buffer support for IIO subsystem of Linux device driver
.


Но кому очень хочется может воспользоваться серией патчей:


[v3,1/6] dt-bindings: iio: introduce trigger providers, consumers


Тогда задание через device tree будет выглядеть приблизительно так:


trig0: interrupt-trigger0 {    #io-trigger-cells = <0>;    compatible = "interrupt-trigger";    interrupts = <11 0>;    interrupt-parent = <&gpioa>;};

sysfs trigger


iio-trig-sysfs


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


Создание триггера:


# echo 10 > /sys/bus/iio/devices/iio_sysfs_trigger/add_trigger

Число используется для генерации имени триггера в виде "sysfstrig%d", его же мы используем при задании триггера устройству.


High resolution timer trigger


Представляет из себя таймер с минимальным возможным разрешением в 1 наносекунду.


# mkdir /sys/kernel/config/iio/triggers/hrtimer/my_trigger_name# cat /sys/bus/iio/devices/trigger4/namemy_trigger_name# cat /sys/bus/iio/devices/trigger4/sampling_frequency100

Одним из дополнительных случаев использования может быть опрос устройств без собственных прерываний допустим "забыли" завести прерывание на SoC.


loop trigger


iio-trig-loop


Экспериментальный триггер предположительно инициированный PATCH v1 5/5 iio:pressure:ms5611: continuous sampling support
.


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


iio:trigger: Experimental kthread tight loop trigger.


Опять же нет поддержки DT, так что либо добавлять через патч, либо через платформенный код.


Device tree


Здесь я хочу обратить особое внимание на возможность задать label для узла, которую лучше всего использовать если у вас много однотипных устройств, всегда текущие значения заданные в узле можно подсмотреть в директории of_node для каждого iio:device /sys/bus/iio/devices/iio\:device0/of_node/.


Какой общей рекомендации не существует всё индивидуально и описано в https://elixir.bootlin.com/linux/v5.9-rc1/source/Documentation/devicetree/bindings/iio


Типы каналов измерений


Многие датчики, который раньше существовали как отдельные сущности были перенесены на инфраструктуру IIO, так что похоже тут enum iio_chan_type можно найти почти любой тип измерений. Расшифровку можно посмотреть тут iio_event_monitor.


Формат данных


IIO умеет сообщать в каком формате нам передаются данные iio-buffer-sysfs-interface.


[be|le]:[s|u]bits/storagebitsXrepeat[>>shift]

Живой пример для icm20608:


# cat /sys/bus/iio/devices/iio\:device0/scan_elements/*_typebe:s16/16>>0be:s16/16>>0be:s16/16>>0be:s16/16>>0be:s16/16>>0be:s16/16>>0le:s64/64>>0

Тут более ли менее все понятно:


  • первым идёт порядок байт le или be соответственно мы должны позаботиться о том что порядок совпадает с нашей архитектурой или c выбранным нами порядком байт
  • затем идет тип знаковое или без знаковое, s или u соответственно
  • затем идет длина значения в битах и через / длина поля в котором содержится значение опять же в битах, кратное количеству битов в байте
  • последним идет сдвиг

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


be:u4/8>>0be:u4/8>>4

Предпоследнее не показанное в живом примере поле repeat если оно больше 1 передается сразу массив измерений.


Scaling and offset


Как я уже говорил ранее прочитанные данные в сыром виде необходимо привести к общему виду:


/sys/bus/iio/devices/iio:deviceX/in_*_raw/sys/bus/iio/devices/iio:deviceX/in_*_offset/sys/bus/iio/devices/iio:deviceX/in_*_scale

В общем случае преобразование будет иметь вид (raw + offset)*scale, для какого то из типов датчиков offset'a может и не быть.


How to do a simple ADC conversion using the sysfs interface


iio_simple_dummy


Для изучения и тестирования может пригодится iio_simple_dummy модуль ядра эмулирующий абстрактное устройство IIO устройство для следующих каналов:


  • IIO_VOLTAGE
  • IIO_ACCEL
  • IIO_ACTIVITY

The iio_simple_dummy Anatomy


iio_simple_dummy


libiio


Если вышеприведенное показалось вам сложным на помощь к вам идет libiio от Analog Devices.


Помимо того, что она берет на себя рутинные вещи наподобие разбора формата канала или включения/выключения каналов.


У неё есть интересная особенность в виде возможности работы в виде сервера/клиента, в таком случае устройство с датчиками служит в качестве сервера данных, а клиент может располагаться на Linux, Windows или Mac машине, и соединяться через USB, Ethernet или Serial.


Соединение с удаленным узлом iiod:


On remote :


host # iiod

On local :


local $ iio_info -n [host_address]local $ iio_attr -u ip:[host_address] -dlocal $ iio_readdev -u ip:[host_address] -b 256 -s 0 icm20608

Отдельно хочется отметить поддержку Matlab, а так же интересный проект осциллографа.


Пример программы для чтения акселерометра


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


https://github.com/maquefel/icm20608-iio


Работа без использования libiio


Я не буду касаться банальной работы с sysfs так, что в общих чертах для чтения необходимо сделать следующее:


  • Поиск устройства, здесь мы ориентируемся на /sys/bus/iio/iio:deviceN/name, соответственно /sys/bus/iio/iio:deviceN будет совпадать с /dev/iio:deviceN
  • Инициализация каналов в /sys/bus/iio/iio:deviceN/scan_elements/, нам будут передаваться измерения только с тех каналов, которые мы заказали в *_en
  • Инициализация буфера /sys/bus/iio/iio:deviceN/enable

В примере есть минимум необходимый для работы.


Выравнивание


Eго придется делать самим если мы хотим обойтись без libiio.


https://elixir.bootlin.com/linux/v5.9-rc1/source/drivers/iio/industrialio-buffer.c#L574


Простой код для вычисления смещения для каждого канала:


    # bytes - всего длина всего пакета в байтах    # length - длина канала в байтах    # offset - смещения относительно начала пакета для канала в байтах    if (bytes % length == 0)        offset = bytes;    else        offset = bytes - bytes % length + length;    bytes = offset + length;

Что в случае без libiio, что в противоположном случае измерение необходимо привести к окончательному виду:


  • привести порядок байт в соответствие с используемым
  • сдвинуть на необходимое значение
  • обрезать лишнее
  • если знаковое, то проделать расширение знака (Sign extension)
  • если есть offset, то прибавить до применения шкалы
  • если есть scale, то применить шкалу

    input = is_be ? betoh(input) : letoh(input);    input >>= shift;    input &= BIT_MASK(bits);    value = is_signed ? (float)sext(input, bits) : (float)input;    if(with_offset) value += offset;    if(with_scale) value *= scale;

Примечание: Расширение знака (Sign extension) в примере представлен самый простой непортируемый вариант. Дополнительно по теме можно глянуть тут SignExtend.


Работа с использованием libiio


Пример работы можно глянуть тут libiio-loop.c
.


Приведу псевдокод с комментариями:


# Создать контекст из uri# uri = "ip:127.0.0.1"# uri = "local:"# uri = "usb:"ctx = iio_create_context_from_uri(uri);# Найти устройство# допустим device = icm20608dev = iio_context_find_device(ctx, device);# Количество доступных каналовnb_channels = iio_device_get_channels_count(dev);# Включить каждый каналfor(int i = 0; i < nb_channels; i++)    iio_channel_enable(iio_device_get_channel(dev, i));# buffer_size = SAMPLES_PER_READ, количество последовательных измерений (по всем каналам)buffer = iio_device_create_buffer(dev, buffer_size, false);# Задать блокирующий режим работыiio_buffer_set_blocking_mode(buffer, true);while(true) {    # Заполнить буфер    iio_buffer_refill(buffer);    # Способов несколько - можно читать и без использования libiio    # Приведу в качестве примера "каноничный" способ, который заключается в том что предоставленная нами функция    # вызывается для каждого канала    # ssize_t print_sample(const struct iio_channel *chn, void *buffer, size_t bytes, __notused void *d)    # const struct iio_channel *chn - текущий канал который мы обрабатываем    # void *buffer - указатель на буфер содержащий измерения для данного канала    # size_t bytes - длина измерения в байтах    # __notused void *d - пользовательские данные которые мы передаем вместе с вызовом iio_buffer_foreach_sample    iio_buffer_foreach_sample(buffer, print_sample, NULL);}# освободить буферiio_buffer_destroy(buffer);# освободить контекстiio_context_destroy(ctx);

Пара слов об альтернативном механизме для чтения данных


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


Всё это относиться к методам обработки высокоскоростного потока данных.


Сравнение методов (тезисы из презентации):


Решение первое Блоки


  • Группировать несколько измерений в блок
  • Генерировать одно прерывание на один блок
  • Уменьшить расходы на управление
  • Размер блока должен быть конфигурируемым
  • Позволить пользовательского приложению выбирать между задержкой и накладными расходами

Решение второе DMA + mmap()


  • Использовать DMA чтобы перемещать данные от устройства к выделенному блоку памяти
  • Использовать mmap() чтобы иметь доступ к памяти из пользовательского пространства
  • Избежать копирования данных
  • "Бесплатное" демультиплексирование в пользовательском пространстве

High-speed Data Acquisition
using the
Linux Industrial IO framework


По мне так это отличное решения для SDR.


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


Автор любезно предоставил данные изменения для ядра 4.19 и 5.4.


С дискуссией по данной теме можно ознакомиться тут


Рекомендуемые материалы


https://bootlin.com/pub/conferences/2012/fosdem/iio-a-new-subsystem/iio-a-new-subsystem.pdf


https://archive.fosdem.org/2012/schedule/event/693/127_iio-a-new-subsystem.pdf


https://events19.linuxfoundation.org/wp-content/uploads/2017/12/Bandan-Das_Drone_SITL_bringup_with_the_IIO_framework.pdf


https://programmer.group/5cbf67db154ab.html


https://elinux.org/images/b/ba/ELC_2017_-_Industrial_IO_and_You-_Nonsense_Hacks%21.pdf


https://elinux.org/images/8/8d/Clausen--high-speed_data_acquisition_with_the_linux_iio_framework.pdf


Для дополнительного изучения


https://linux.ime.usp.br/~marcelosc/2019/09/Simple-IIO-driver


P.S.


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

Подробнее..

Варианты использования конфигурации в ASP.NET Core

08.09.2020 02:17:37 | Автор: admin
Для получения конфигурации приложения обычно используют метод доступа по ключевому слову (ключ-значение). Но это бывает не всегда удобно т.к. иногда требуется использовать готовые объекты в коде с уже установленными значениями, причем с возможностью обновления значений без перезагрузки приложения. В данном примере предлагается шаблон использования конфигурации в качестве промежуточного слоя для ASP.NET Core приложений.

Предварительно рекомендуется ознакомиться с материалом: Metanit Конфигурация, Как работает конфигурация в .NET Core.

Постановка задачи


Необходимо реализовать ASP NET Core приложение с возможностью обновления конфигурации в формате JSON во время работы. Во время обновления конфигурации текущие работающие сессии должны продолжать работать с предыдущим вариантов конфигурации. После обновления конфигурации, используемые объекты должны быть обновлены/заменены новыми. Конфигурация должна быть десериализована, не должно быть прямого доступа к объектам IConfiguration из контроллеров. Считываемые значения должны проходить проверку на корректность, при отсутствии как таковых заменяться значениями по умолчанию. Реализация должна работать в Docker контейнере.

Классическая работа с конфигурацией


GitHub: ConfigurationTemplate_1
Проект основан на шаблоне ASP NET Core MVC. Для работы с файлами конфигурации JSON используется провайдер конфигурации JsonConfigurationProvider. Для добавления возможности перезагрузки конфигурации приложения, во время работы, добавим параметр: reloadOnChange: true.
В файле Startup.cs заменим:
public Startup(IConfiguration configuration) {   Configuration = configuration; }

На
public Startup(IConfiguration configuration) {            var builder = new ConfigurationBuilder()    .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true);   configuration = builder.Build();   Configuration = configuration;  }

.AddJsonFile добавляет JSON файл, reloadOnChange:true указывает на то, что при изменение параметров файла конфигурации, они будут перезагружены без необходимости перезагружать приложение.
Содержимое файла appsettings.json:
{  "AppSettings": {    "Parameter1": "Parameter1 ABC",    "Parameter2": "Parameter2 ABC"    },  "Logging": {    "LogLevel": {      "Default": "Information",      "Microsoft": "Warning",      "Microsoft.Hosting.Lifetime": "Information"    }  },  "AllowedHosts": "*"}

Контроллеры приложения вместо прямого обращения к конфигурации будут использовать сервис: ServiceABC. ServiceABC класс который первоначальные значения берет из файла конфигурации. В данном примере класс ServiceABC содержит только одно свойство Title.
Содержимое файла ServiceABC.cs:
public class ServiceABC{  public string Title;  public ServiceABC(string title)  {     Title = title;  }  public ServiceABC()  { }}

Для использования ServiceABC необходимо его добавить в качестве сервиса middleware в приложение. Добавим сервис как AddTransient, который создается каждый раз при обращении к нему, с помощью выражения:
services.AddTransient<IYourService>(o => new YourService(param));
Отлично подходит для легких сервисов, не потребляющих память и ресурсы. Чтение параметров конфигурации в Startup.cs осуществляется с помощью IConfiguration, где используется строка запроса с указанием полного пути расположения значения, пример: AppSettings:Parameter1.
В файле Startup.cs добавим:
public void ConfigureServices(IServiceCollection services){  //Считывание параметра "Parameter1" для инициализации сервиса ServiceABC  var settingsParameter1 = Configuration["AppSettings:Parameter1"];  //Добавление сервиса "Parameter1"              services.AddScoped(s=> new ServiceABC(settingsParameter1));  //next  services.AddControllersWithViews();}

Пример использования сервиса ServiceABC в контроллере, значение Parameter1 будет отображаться на html странице.
Для использования сервиса в контроллерах добавим его в конструктор, файл HomeController.cs
public class HomeController : Controller{  private readonly ILogger<HomeController> _logger;  private readonly ServiceABC _serviceABC;  public HomeController(ILogger<HomeController> logger, ServiceABC serviceABC)    {      _logger = logger;      _serviceABC = serviceABC;    }  public IActionResult Index()    {      return View(_serviceABC);    }

Добавим видимость сервиса ServiceABC в файл _ViewImports.cshtml
@using ConfigurationTemplate_1.Services

Изменим Index.cshtml для отображение параметра Parameter1 на странице.
@model ServiceABC@{    ViewData["Title"] = "Home Page";}    <div class="text-center">        <h1>Десериализация конфигурации в ASP.NET Core</h1>        <h4>Классическая работа с конфигурацией</h4>    </div><div>            <p>Сервис ServiceABC, отображение Заголовка        из параметра Parameter1 = @Model.Title</p></div>

Запустим приложение:


Итог


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

Использование IConfiguration как Singleton


GitHub: ConfigurationTemplate_2
Второй вариант заключается в помещение IConfiguration(как Singleton) в сервисы. В результате IConfiguration может вызываться из контроллеров и других сервисов. При использовании AddSingleton сервис создается один раз и при использовании приложения обращение идет к одному и тому же экземпляру. Использовать этот способ нужно особенно осторожно, так как возможны утечки памяти и проблемы с многопоточностью.
Заменим код из предыдущего примера в Startup.cs на новый, где
services.AddSingleton<IConfiguration>(Configuration);
добавляет IConfiguration как Singleton в сервисы.
public void ConfigureServices(IServiceCollection services){  //Доступ к IConfiguration из других контроллеров и сервисов  services.AddSingleton<IConfiguration>(Configuration);  //Добавление сервиса "ServiceABC"                            services.AddScoped<ServiceABC>();  //next  services.AddControllersWithViews();}

Изменим конструктор сервиса ServiceABC для принятия IConfiguration
public class ServiceABC{          private readonly IConfiguration _configuration;  public string Title => _configuration["AppSettings:Parameter1"];          public ServiceABC(IConfiguration Configuration)    {      _configuration = Configuration;    }  public ServiceABC()    { }}

Как и в предыдущем варианте добавим сервис в конструктор и добавим ссылку на пространство имен
Для использования сервиса в контроллерах добавим его в конструктор, файл HomeController.cs
public class HomeController : Controller{  private readonly ILogger<HomeController> _logger;  private readonly ServiceABC _serviceABC;  public HomeController(ILogger<HomeController> logger, ServiceABC serviceABC)    {      _logger = logger;      _serviceABC = serviceABC;    }  public IActionResult Index()    {      return View(_serviceABC);    }

Добавим видимость сервиса ServiceABC в файл _ViewImports.cshtml:
@using ConfigurationTemplate_2.Services;

Изменим Index.cshtml для отображения параметра Parameter1 на странице.
@model ServiceABC@{    ViewData["Title"] = "Home Page";}<div class="text-center">    <h1>Десериализация конфигурации в ASP.NET Core</h1>    <h4>Использование IConfiguration как Singleton</h4></div><div>    <p>        Сервис ServiceABC, отображение Заголовка        из параметра Parameter1 = @Model.Title    </p></div>


Запустим приложение:


Сервис ServiceABC добавленный в контейнер с помощью AddScoped означает, что экземпляр класса будет создаваться при каждом запросе страницы. В результате экземпляр класса ServiceABC будет создаваться при каждом http запросе вместе с перезагрузкой конфигурации IConfiguration, и новые изменения в appsettings.json будут применяться.
Таким образом, если во время работы приложения изменить параметр Parameter1 на NEW!!! Parameter1 ABC, то при следующем обращение к начальной странице отобразится новое значение параметра.

Обновим страницу после изменения файла appsettings.json:


Итог


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

Десериализация конфигурации с валидацией (вариант IOptions)


GitHub: ConfigurationTemplate_3
Почитать про Options по ссылке.
В этом варианте необходимость использования ServiceABC отпадает. Вместо него используется класс AppSettings, который содержит параметры из конфигурационного файла и объект ClientConfig. Объект ClientConfig после изменения конфигурации требуется инициализировать, т.к. в контроллерах используется готовый объект. ClientConfig это некий класс, взаимодействующий с внешними системами, код которого нельзя изменять. Если выполнить только десериализацию данных класса AppSettings, то ClientConfig будет в состояние null. Поэтому необходимо подписаться на событие чтения конфигурации, и в обработчике инициализировать объект ClientConfig.
Для передачи конфигурации не в виде пар ключ-значение, а как объекты определенных классов, будем использовать интерфейс IOptions. Дополнительно IOptions в отличие от ConfigurationManager позволяет десерилизовать отдельные секции. Для создания объекта ClientConfig потребуется использовать IPostConfigureOptions, который выполняется после обработки всех конфигурации. IPostConfigureOptions будет выполняться каждый раз после чтения конфигурации, самым последним.
Создадим ClientConfig.cs:
public class ClientConfig{  private string _parameter1;  private string _parameter2;  public string Value => _parameter1 + " " + _parameter2;  public ClientConfig(ClientConfigOptions configOptions)    {      _parameter1 = configOptions.Parameter1;      _parameter2 = configOptions.Parameter2;    }}

В качестве конструктора будет принимать параметры в виде объекта ClientConfigOptions:
public class ClientConfigOptions{  public string Parameter1;  public string Parameter2;} 

Создадим класс настроек AppSettings, и определим в нем метод ClientConfigBuild(), который создаст объект ClientConfig.
Файл AppSettings.cs:
public class AppSettings{          public string Parameter1 { get; set; }  public string Parameter2 { get; set; }          public ClientConfig clientConfig;  public void ClientConfigBuild()    {      clientConfig = new ClientConfig(new ClientConfigOptions()        {          Parameter1 = this.Parameter1,          Parameter2 = this.Parameter2        }        );      }}

Создадим обработчик конфигурации, который будет отрабатываться последним. Для этого он должен быть унаследован от IPostConfigureOptions. Вызываемый последним метод PostConfigure выполнит ClientConfigBuild(), который как раз и создаст ClientConfig.
Файл ConfigureAppSettingsOptions.cs:
public class ConfigureAppSettingsOptions: IPostConfigureOptions<AppSettings>{  public ConfigureAppSettingsOptions()    { }  public void PostConfigure(string name, AppSettings options)    {                  options.ClientConfigBuild();    }}

Теперь осталось внести изменения только в Startup.cs, изменения коснутся только функции ConfigureServices(IServiceCollection services).
Сначала прочитаем секцию AppSettings в appsettings.json
// configure strongly typed settings objectsvar appSettingsSection = Configuration.GetSection("AppSettings");services.Configure<AppSettings>(appSettingsSection);

Далее, для каждого запроса будет создаваться копия AppSettings для возможности вызова постобработки:
services.AddScoped(sp => sp.GetService<IOptionsSnapshot<AppSettings>>().Value);

Добавим в качестве сервиса, постобработку класса AppSettings:
services.AddSingleton<IPostConfigureOptions<AppSettings>, ConfigureAppSettingsOptions>();

Добавленный код в Startup.cs
public void ConfigureServices(IServiceCollection services){  // configure strongly typed settings objects  var appSettingsSection = Configuration.GetSection("AppSettings");  services.Configure<AppSettings>(appSettingsSection);  services.AddScoped(sp => sp.GetService<IOptionsSnapshot<AppSettings>>().Value);                                      services.AddSingleton<IPostConfigureOptions<AppSettings>, ConfigureAppSettingsOptions>();              //next  services.AddControllersWithViews();}

Для получения доступа к конфигурации, из контроллера достаточно будет просто внедрить AppSettings.
Файл HomeController.cs:
public class HomeController : Controller{  private readonly ILogger<HomeController> _logger;  private readonly AppSettings _appSettings;  public HomeController(ILogger<HomeController> logger, AppSettings appSettings)    {      _logger = logger;      _appSettings = appSettings;    }

Изменим Index.cshtml для вывода параметра Value объекта СlientConfig
@model AppSettings@{    ViewData["Title"] = "Home Page";}<div class="text-center">    <h1>Десериализация конфигурации в ASP.NET Core</h1>    <h4>Десериализация конфигурации с валидацией (вариант IOptions)</h4></div><div>    <p>        Класс ClientConfig, отображение Заголовка         = @Model.clientConfig.Value    </p></div>

Запустим приложение:


Если во время работы приложения изменить параметр Parameter1 на NEW!!! Parameter1 ABC и Parameter2 на NEW!!! Parameter2 ABC, то при следующем обращении к начальной странице отобразится новое свойства Value:



Итог


Данный подход позволяет десериализовать все значения конфигурации без ручного перебирания параметров. Каждый запрос http работает со своим экземпляром AppSettings и СlientConfig, что исключает ситуацию возникновения коллизий. IPostConfigureOptions гарантирует выполнение в последнюю очередь, когда все параметры будут перечитаны. Недостатком решения является постоянное создание экземпляра СlientConfig для каждого запроса, что является непрактичным т.к. по сути СlientConfig должен пересоздаваться только после изменения конфигурации.

Десериализация конфигурации с валидацией (без использования IOptions)


GitHub: ConfigurationTemplate_4
Использование подхода с использованием IPostConfigureOptions приводит к созданию объекта ClientConfig каждый раз при получении запроса от клиента. Это недостаточно рационально т.к. каждый запрос работает с начальным состоянием ClientConfig, которое меняется только при изменение конфигурационного файла appsettings.json. Для этого откажемся от IPostConfigureOptions и создадим обработчик конфигурации который будет вызваться только при изменении appsettings.json, в результате ClientConfig будет создаваться только один раз, и далее на каждый запрос будет отдаваться уже созданный экземпляр ClientConfig.
Создадим класс SingletonAppSettings конфигурации(Singleton) с которого будет создаваться экземпляр настроек для каждого запроса.
Файл SingletonAppSettings.cs:
public class SingletonAppSettings{  public AppSettings appSettings;    private static readonly Lazy<SingletonAppSettings> lazy = new Lazy<SingletonAppSettings>(() => new SingletonAppSettings());  private SingletonAppSettings()    { }  public static SingletonAppSettings Instance => lazy.Value;}

Вернемся в класс Startup и добавим ссылку на интерфейс IServiceCollection. Он будет использоваться в методе обработки конфигурации
public IServiceCollection Services { get; set; }

Изменим ConfigureServices(IServiceCollection services) и передадим ссылку на IServiceCollection:
Файл Startup.cs:
public void ConfigureServices(IServiceCollection services){  Services = services;  //Считаем секцию AppSettings из конфигурации  var appSettings = Configuration.GetSection("AppSettings").Get<AppSettings>();  appSettings.ClientConfigBuild();

Создадим Singleton конфигурации, и добавим его в коллекцию сервисов:
SingletonAppSettings singletonAppSettings = SingletonAppSettings.Instance;singletonAppSettings.appSettings = appSettings;services.AddSingleton(singletonAppSettings);     

Добавим объект AppSettings как Scoped, при каждом запросе будет создаваться копия от Singleton:
services.AddScoped(sp => sp.GetService<SingletonAppSettings>().appSettings);

Полностью ConfigureServices(IServiceCollection services):
public void ConfigureServices(IServiceCollection services){  Services = services;  //Считаем секцию AppSettings из конфигурации  var appSettings = Configuration.GetSection("AppSettings").Get<AppSettings>();  appSettings.ClientConfigBuild();  SingletonAppSettings singletonAppSettings = SingletonAppSettings.Instance;  singletonAppSettings.appSettings = appSettings;  services.AddSingleton(singletonAppSettings);               services.AddScoped(sp => sp.GetService<SingletonAppSettings>().appSettings);  //next  services.AddControllersWithViews();}

Теперь добавить обработчик для конфигурации в Configure(IApplicationBuilder app, IWebHostEnvironment env). Для отслеживания изменения в файле appsettings.json используется токен. OnChange вызываемая функция при изменении файла. Обработчик конфигурации onChange():
ChangeToken.OnChange(() => Configuration.GetReloadToken(), onChange);

Вначале читаем файл appsettings.json и десериализуем класс AppSettings. Затем из коллекции сервисов получаем ссылку на Singleton, который хранит объект AppSettings, и заменяем его новым.
private void onChange(){                          var newAppSettings = Configuration.GetSection("AppSettings").Get<AppSettings>();  newAppSettings.ClientConfigBuild();  var serviceAppSettings = Services.BuildServiceProvider().GetService<SingletonAppSettings>();  serviceAppSettings.appSettings = newAppSettings;  Console.WriteLine($"AppSettings has been changed! {DateTime.Now}");}

В контроллер HomeController внедрим ссылку на AppSettings, как в предыдущем варианте (ConfigurationTemplate_3)
Файл HomeController.cs:
public class HomeController : Controller{  private readonly ILogger<HomeController> _logger;  private readonly AppSettings _appSettings;  public HomeController(ILogger<HomeController> logger, AppSettings appSettings)    {      _logger = logger;      _appSettings = appSettings;    }

Изменим Index.cshtml для вывода параметра Value объекта СlientConfig:
@model AppSettings@{    ViewData["Title"] = "Home Page";}<div class="text-center">    <h1>Десериализация конфигурации в ASP.NET Core</h1>    <h4>Десериализация конфигурации с валидацией (без использования IOptions)</h4></div><div>    <p>        Класс ClientConfig, отображение Заголовка        = @Model.clientConfig.Value    </p></div>


Запустим приложение:


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


И новые значения:


Итог


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

Добавление значений по умолчанию и валидация конфигурации


GitHub: ConfigurationTemplate_5
В предыдущих примерах при отсутствии файла appsettings.json приложение выбросит исключение, поэтому сделаем файл конфигурации опциональным и добавим настройки по умолчанию. При публикации приложения проекта, созданного из шаблона в Visula Studio, файл appsettings.json будет располагаться в одной и той же папке вместе со всеми бинарными файлами, что неудобно при развертывание в Docker. Файл appsettings.json перенесем в папку config/:
.AddJsonFile("config/appsettings.json")

Для возможности запуска приложения без appsettings.json изменим параметр optional на true, который в данном случае означает, что наличие appsettings.json является необязательным.
Файл Startup.cs:
public Startup(IConfiguration configuration){  var builder = new ConfigurationBuilder()     .AddJsonFile("config/appsettings.json", optional: true, reloadOnChange: true);  configuration = builder.Build();  Configuration = configuration;}

Добавим в public void ConfigureServices(IServiceCollection services) к строке десериализации конфигурации случай обработки отсутствия файла appsettings.json:
 var appSettings = Configuration.GetSection("AppSettings").Get<AppSettings>() ?? new AppSettings();

Добавим валидацию конфигурации, на основе интерфейса IValidatableObject. При отсутствующих параметрах конфигурации, будет применяться значение по умолчанию. Наследуем класс AppSettings от IValidatableObject и реализуем метод:
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)

Файл AppSettings.cs:
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext){  List<ValidationResult> errors = new List<ValidationResult>();  if (string.IsNullOrWhiteSpace(this.Parameter1))    {      errors.Add(new ValidationResult("Не указан параметр Parameter1. Задано " +        "значение по умолчанию DefaultParameter1 ABC"));      this.Parameter1 = "DefaultParameter1 ABC";    }    if (string.IsNullOrWhiteSpace(this.Parameter2))    {      errors.Add(new ValidationResult("Не указан параметр Parameter2. Задано " +        "значение по умолчанию DefaultParameter2 ABC"));      this.Parameter2 = "DefaultParameter2 ABC";    }    return errors;}

Добавим метод вызова проверки конфигурации для вызова из класса Startup
Файл Startup.cs:
private void ValidateAppSettings(AppSettings appSettings){  var resultsValidation = new List<ValidationResult>();  var context = new ValidationContext(appSettings);  if (!Validator.TryValidateObject(appSettings, context, resultsValidation, true))    {      resultsValidation.ForEach(        error => Console.WriteLine($"Проверка конфигурации: {error.ErrorMessage}"));      }    }

Добавим вызов метода валидации конфигурации в ConfigureServices(IServiceCollection services). Если файла appsettings.json отсутствует, то требуется инициализировать объект AppSettings со значениями по умолчанию.
Файл Startup.cs:
var appSettings = Configuration.GetSection("AppSettings").Get<AppSettings>() ?? new AppSettings();

Проверка параметров. В случае использования значения по умолчанию в консоль будет выведено сообщение с указанием параметра.
 //Validate            this.ValidateAppSettings(appSettings);            appSettings.ClientConfigBuild();

Изменим проверку конфигурации в onChange()
private void onChange(){                          var newAppSettings = Configuration.GetSection("AppSettings").Get<AppSettings>() ?? new AppSettings();  //Validate              this.ValidateAppSettings(newAppSettings);              newAppSettings.ClientConfigBuild();  var serviceAppSettings = Services.BuildServiceProvider().GetService<SingletonAppSettings>();  serviceAppSettings.appSettings = newAppSettings;  Console.WriteLine($"AppSettings has been changed! {DateTime.Now}");}

Если из файла appsettings.json удалить ключ Parameter1, то после сохранения файла в окне консольного приложения появится сообщение об отсутствие параметра:


Итог


Изменение пути для расположения конфигураций в папке config является хорошим решением т.к. позволяет не смешивать все файлы в одну кучу. Папка config определяется только для хранения конфигурационных файлов. Упростили задачу развертывания и конфигурирования приложения для администраторов, благодаря валидации конфигурации. Если добавить вывод ошибок конфигурации в лог, то администратор в случае указания неправильных параметров, получит точную информацию о проблеме, а не как в последнее время программисты на любое исключение стали писать: Что -то пошло не так.
Идеального варианта работы с конфигурацией не существует, все зависит от поставленной задачи, у каждого варианта есть свои плюсы и минусы.

Все шаблоны конфигураций доступны по ссылке.

Литература:
  1. Корректный ASP.NET Core
  2. METANIT Конфигурация. Основы конфигурации
  3. Singleton Design Pattern C# .net core
  4. Reloading configuration in .NET core
  5. Reloading strongly typed Options on file changes in ASP.NET Core RC2
  6. Конфигурация ASP.NET Core приложения через IOptions
  7. METANIT Передача конфигурации через IOptions
  8. Конфигурация ASP.NET Core приложения через IOptions
  9. METANIT Самовалидация модели
Подробнее..
Категории: C , Net , Net core , Configuration , Asp , Asp.net

Из песочницы Простая имитация разрушений с использованием Unity и Blender

09.09.2020 14:04:11 | Автор: admin
Всем привет! недавно задался вопросом насчет разрушений в Unity, поискал в интернете что есть на эту тему, но увы я нашел лишь кучу вопросов и обсуждений.

И нет я не заявляю что моя статья единственная в своем роде, потому что метод довольно прост, но почему-то прямого туториала для этого я не нашел.

Начнем с того что в Unity НЕЛЬЗЯ РАЗРУШИТЬ/РАЗДЕЛИТЬ/РАЗРЕЗАТЬ объект, наши разрушения будут по сути то и не разрушениями вовсе, а лишь их визуальной имитацией, поскольку как я сказал выше, невозможно по другому.

СОБСТВЕННО!

Хватит лить воду и тратить ваше время в пустую(все же я посчитал нужным оставить предисловие). Для начала вам потребуется скачать из магазина стим программу под названием Blender, после чего заходим и начинаем разбираться.

Создаем новый проект, и во вкладке Правка/Edit ищем кнопку Аддоны/Addons, и пишем в поиск Cell Fracture, затем ставим рядом с ним галочку дабы включить его.



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

Нас интересует вкладка Моделирование/Modeling, заходим в нее, нажимаем на объект правой кнопкой мыши > подразделить/subdivide, и видим что куб стал как-бы в клеточку



Теперь вернемся на первую вкладку Макет/Layout, нажмем на кнопку объект/object > быстрые эффекты/fast effects > Cell Fracture, и нам откроется окно для управления Аддоном.



Просто нажимаем кнопку ДА и смотрим за результатом.

Как можно заметить, наш куб разделился визуально, значит все прошло успешно, это и есть та самая фрагментация.



Остались последние шаги в Блендере, и можно переходить к созданию сцены, нажмем на file/файл > экспортировать/export > .fbx, и сохраним в удобную для нас папку.



Открываем Unity, создаем или открываем старый проект, и приступим собственно к сборке нашей сцены.Для этого нам понадобится создать plаne и sphere, теперь из нашей папки в окно иерархии добавляем куб что мы только что экспортировали, затем перетаскиваем его уже непосредственно на сцену.

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

Осталось нажать АddComponent, и в поиске прописав collider, выбрать Mesh Collider.



В самом коллайдере нажимаем на Convex, для его отображения, и все.

Теперь повторяем сделанное ранее, но только уже ищем Rigidbody.



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

using System.Collections;using System.Collections.Generic;using UnityEngine;public class FragmentSplit : MonoBehaviour {public bool isdead = false; //переменная которая обозначает разрушился объект, или еще нетpublic float timeRemaining = 100;//время после которого должен удалится объект после разрушения (сделано во благо оптимизации)void Start(){GetComponent<Rigidbody>().isKinematic = true;//включаем у риджидбоди синематик дабы наш объект не разрушался раньше времени}void OnCollisionEnter(Collision collision)//проверяем на объект на коллизию{    GetComponent<Rigidbody>().isKinematic = false;//и если он с чем-то столкнулся, отключаем синематик тем самым разрушая его    isdead = true;//делаем переменную положительной, чтобы скрипт смог понять что обьект уже "отработан", и его можно удалить}void Update(){if (isdead)//если переменная положительная, то запускаем таймер {timeRemaining -= Time.deltaTime;//сам таймерif (timeRemaining < 0) //и если время таймера меньше нуля, то {Destroy (gameObject);//просто удаляем объект}}    }}

Теперь последний раз выделяем все объекты в списке, нажимаем на АddComponent, и добавляем наш скрипт



Поднимаем нашу сферу над кубом, и включаем проект.



Как видим, все прекрасно работает, куб ломается, наша сфера куда-то катится.

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

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

Я не спорю что ассеты от Unity(или других разработчиков) будут лучше моих скриптов на пару строчек, но все же.

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

Не стану говорить что я хорошо и досконально знаю язык, но и не первый день в этом, в общем, на этом у меня все, надеюсь что помог кому-то, хорошего вам кода друзья!..
Подробнее..
Категории: C , Unity , Unity3d

Recovery mode Бегущая строка на C

12.09.2020 18:14:51 | Автор: admin
Привет всем!
В данной статье я решил привести пример реализации бегущей строки на C#.
Суть заключается в том, чтобы выводить последовательно определенное количество символов из переменной string.
Для этого я использовал структуру
GetText 
поместив в неё переменные и методы для работы с бегущей строкой.

 public GetText(int TEXTLENGHT)            {                //размер возвращаемого текста должен включатькак минимум 1 символ                if (TEXTLENGHT > 0)                {                    lenght = TEXTLENGHT;                }                else if (TEXTLENGHT < 0)                {                    lenght = 0 - TEXTLENGHT;                }                else                {                    lenght = 1;                }                max = lenght;            }            public int lenght { get; }//переменная хранящея значение количества возвращаемых символов            public static string Text;            public static string outText;            private static int min = 0, max;  

Переменные Text и outText используются для хранения входного и выходного текста. Выходным тестом являются элементы массива Text расположенные в диапозоне от min до max.
Length в данной структуре хранит количество элементов от min до max и задается при инициализации структуры.
 public void Initialize()            {                min = 0;                max = lenght;                wpered();            }            public void Initialize(string TEXT)            {                Text = TEXT;                min = 0;                max = lenght;                wpered();            }

Метод Initialize необходим для задания исходного текста и возврата переменных min и max в исходное состояние.
private char[] CharPosl(char[] input, int min, int max)            {                int lenght = max - min;                char[] r = new char[lenght];                int counter = 0;                for (int i = min; i < max; i++)                {                    r[counter] = input[i];                    counter++;                }                return r;            }

Метод CharPosl возвращает в массиве последовательность символов расположенных в промежутке от min до max.
private void WriteT(char[] t)            {                string o = "";                for (int i = 0; i < t.Length; i++)                {                    o += t[i].ToString();                }                outText = o;            }

Данный метод записывает массив символов в выходной текст.
            public void wpered()            {                if (Text != "")                {                    char[] sim = GetMas(Text);                    if (sim.Length >= lenght)                    {                        WriteT(CharPosl(sim, min, max));                        min++;                        if (max++ >= sim.Length)                        {                            max--;                            min--;                            Initialize();//возврат в стартовое положение                        }                    }                    else { WriteT(Text); }                }            }            public void nazad()            {                if (Text != "")                {                    char[] sim = GetMas(Text);                    if (sim.Length > lenght)                    {                        WriteT(CharPosl(sim, min, max));                        max--;                        if (min-- <= 0)                        {                            max = sim.Length - 1;                            min = max - lenght;                            //отправится в конец текста                            WriteT(CharPosl(sim, min, max));                        }                    }                    else { WriteT(Text); }                }            }            private char[] GetMas(string t)            {                return t.ToCharArray();//вернуть массив символов            }

Методы wpered и nazad изменяют значение переменных min и max сдвигая тем самым номер начального и конечного элемента переменной Text. В случае если условие
if (Text != "")
не соблюдается, то есть входной текст является пустым, метод возвращает исходный текст не изменяя значения переменных. В случае, если стартовый индекс меньше 0
  if (min-- <= 0)                        {                            max = sim.Length - 1;                            min = max - lenght;                            //отправится в конец текста                            WriteT(CharPosl(sim, min, max));                        }

значение переменных min и max становится максимальным и возвращаются последние элементы текста, в случае когда увеличения значения переменной max выйдет за границы массивы, осуществляется возврат на стартовую позицию.
public string ReturnText()            {                return outText;            }

Метод ReturnText возвращает конечный текст.
Далее приведён метод, демастрирующий возможности структуры.
static void Main(string[] args)        {            string   t = "Приложение бегущей строки. Текст будет перемещатся в окне консоли.";//начальный текст который будет перемещатся            GetText text = new GetText(12);//инициализация структуры с указанием количества выходных элементов            text.Initialize(t);//отправка текста в структуру            int i = t.Length;            for(int counter = 0; counter < i; counter++)            {                Console.SetCursorPosition(12, 4);//установка положения курсора чтобы текст был на 1 строке                Console.Write(text.ReturnText());                text.wpered();                System.Threading.Thread.Sleep(300);//Приостановка потока необходима для того, чтобы было видно что текст движестся                Console.Clear();//очистка экрана консоли                          }            int key;            while ((key = (int)Console.ReadKey().Key) != 27)//Выключение при нажатии клавиши Esc            {                //Перемещение текста по нажатию клавиши клавиатуры                if (key == 39)                {                    Console.SetCursorPosition(12, 6);                    text.wpered();//перемещение на следующий символ                    Console.Clear();                    Console.Write(text.ReturnText());                }                if (key == 37)                {                    Console.SetCursorPosition(12, 6);                    text.nazad();//перемещение на предыдущий символ                    Console.Clear();                    Console.Clear();                    Console.Write(text.ReturnText());                }            }        }

Всем спасибо за внимание.
Подробнее..
Категории: C

Поддерживаю драйвер tp-link t4u для linux

13.09.2020 14:09:40 | Автор: admin
Когда купил wifi адапртер, думал что будет работать на моей ubuntu 20.04, потому что в числе поддерживаемых систем значился linux. Оказалось что не работает. Попробовал решения, которые предлагают на форумах, но адаптер так и не заработал. Пришлось вчера и сегодня заняться поддержкой драйвера.

Я подумал, а может это и не сложно сделать. И взялся за работу. При компиляции появлялись ошибки. Например нет функции get_ds. Ну да, она была в 4 версии ядра, а в 5 этой функции нет. Я иногда думаю что разработкичи не хотят поддерживать свои драйвера из-за того, что в ядре постоянно изменения вносят и переписывать нужно некоторые участки кода. В общем я посмотрел как в старой версии ядра реализован get_ds, оказывается он просто возвращает KERNEL_DS. Ну это я и заменил также. Потом была проблема со структурой времени, которая в текущем ядре уже есть только 64 битная версия. Это исправил. Были ещё мелкие вроде исправления, но я не помню уже что исправлял. Итак, драйвер скомпилировался, но отказывался регистрировать устройство адаптер. Я нашел патч link, который обязывает производителей указывать правила. Я добавил в каждую запись в os_dep/linux/rtw_cfgvendor.c, такое .policy = VENDOR_CMD_RAW_DATA,

пример
        {                {                        .vendor_id = OUI_GOOGLE,                        .subcmd = RTT_SUBCMD_SET_CONFIG                },                .policy = VENDOR_CMD_RAW_DATA,                .flags = WIPHY_VENDOR_CMD_NEED_WDEV | WIPHY_VENDOR_CMD_NEED_NETDEV,                .doit = rtw_cfgvendor_rtt_set_config        },        {                {                        .vendor_id = OUI_GOOGLE,                        .subcmd = RTT_SUBCMD_CANCEL_CONFIG                },                .policy = VENDOR_CMD_RAW_DATA,                .flags = WIPHY_VENDOR_CMD_NEED_WDEV | WIPHY_VENDOR_CMD_NEED_NETDEV,                .doit = rtw_cfgvendor_rtt_cancel_config        },        {                {                        .vendor_id = OUI_GOOGLE,                        .subcmd = RTT_SUBCMD_GETCAPABILITY                },                .policy = VENDOR_CMD_RAW_DATA,                .flags = WIPHY_VENDOR_CMD_NEED_WDEV | WIPHY_VENDOR_CMD_NEED_NETDEV,                .doit = rtw_cfgvendor_rtt_get_capability        },

И скомпилировал, скопировал и запустил. и вуаля! у меня получилось. ) Хоть я в разработке ядра не разбираюсь, но поддержку простенькую мне удалось сделать. Ссылку на исходники драйвера пока что выложу на google диск. вот ссылка. link
и также теперь есть на github
я рад, если это кому то пригодиться.
image
Подробнее..

Интеграция с Госуслугами. Применение Workflow Core (часть II)

23.09.2020 18:06:49 | Автор: admin
В прошлый раз мы рассмотрели место СМЭВ в задаче интеграции с порталом Госуслуг. Предоставляя унифицированный протокол общения между участниками, СМЭВ существенно облегчает взаимодействие между множеством различных ведомств и организаций, желающих предоставлять свои услуги с помощью портала.

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

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

Выбор движка автоматизации бизнес-процессов


Для организации процессной обработки данных существуют библиотеки и системы автоматизации бизнес-процессов, широко представленные на рынке: от встраиваемых решений до полнофункциональных систем, предоставляющих каркас для управления процессами. В качестве средства автоматизации бизнес-процессов мы выбрали Workflow Core. Такой выбор сделан по нескольким причинам: во-первых, движок написан на C# для платформы .NET Core (это наша основная платформа для разработки), поэтому включить его в общую канву продукта проще, в отличие от, например, Camunda BPM. Кроме того, это встраиваемый (embedded) движок, что даёт широкие возможности по управлению экземплярами бизнес-процессов. Во-вторых, среди множества поддерживаемых вариантов хранения данных есть и используемый в наших решениях PostgreSQL. В-третьих, движок предоставляет простой синтаксис для описания процесса в виде fluent API (также есть вариант описания процесса в JSON-файле, однако, он показался менее удобным для использования в силу того, что становится сложно обнаружить ошибку в описании процесса до момента его фактического выполнения).

Бизнес-процессы


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


Движок Workflow Core содержит большинство стандартных блоков и операторов, представленных в нотации, и, как уже говорилось выше, позволяет пользоваться fluent API или данными в формате JSON для описания конкретных процессов. Реализация этого процесса средствами движка Workflow Core может принять такой вид:

// Класс с данными процесса.public class FizzBuzzWfData{  public int Counter { get; set; } = 1;  public StringBuilder Output { get; set; } = new StringBuilder();}// Описание процесса.public class FizzBuzzWorkflow : IWorkflow<FizzBuzzWfData>{  public string Id => "FizzBuzz";  public int Version => 1;  public void Build(IWorkflowBuilder<FizzBuzzWfData> builder)  {    builder      .StartWith(context => ExecutionResult.Next())      .While(data => data.Counter <= 100)        .Do(a => a          .StartWith(context => ExecutionResult.Next())            .Output((step, data) => data.Output.Append(data.Counter))          .If(data => data.Counter % 3 == 0 || data.Counter % 5 == 0)            .Do(b => b              .StartWith(context => ExecutionResult.Next())                .Output((step, data) => data.Output.Clear())              .If(data => data.Counter % 3 == 0)                .Do(c => c                  .StartWith(context => ExecutionResult.Next())                    .Output((step, data) =>                       data.Output.Append("Fizz")))              .If(data => data.Counter % 5 == 0)                .Do(c => c                  .StartWith(context => ExecutionResult.Next())                    .Output((step, data) =>                      data.Output.Append("Buzz"))))              .Then(context => ExecutionResult.Next())                .Output((step, data) =>                {                  Console.WriteLine(data.Output.ToString());                  data.Output.Clear();                  data.Counter++;                }));  }}

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

Метод Build получает в качестве аргумента объект построителя процесса, который служит отправной точкой для вызова цепочки методов расширения, последовательно описывающих шаги бизнес-процесса. Методы расширения, в свою очередь, могут содержать описание действий непосредственно в текущем коде в виде лямбда-выражений, переданных в качестве аргументов, а могут быть параметризованными. В первом случае, который представлен в листинге, простой алгоритм выливается в достаточно непростой набор инструкций. Во втором логика шагов прячется в отдельных классах-наследниках от типа Step (или AsyncStep для асинхронных вариантов), что позволяет вместить сложные процессы в более лаконичное описание. На практике более пригодным представляется второй подход, первый же достаточен для простых примеров или предельно простых бизнес-процессов.

Собственно класс-описание процесса реализует параметризованный интерфейс IWorkflow, и, выполняя контракт, содержит идентификатор и номер версии процесса. Благодаря этим сведениям движок способен порождать экземпляры процессов в памяти, наполняя их данными и фиксируя их состояние в хранилище. Поддержка версионности позволяет создавать новые вариации процессов без риска затронуть существующие в хранилище экземпляры. Для создания новой версии достаточно создать копию имеющегося описания, присвоить очередной номер свойству Version и изменить нужным образом поведение этого процесса (идентификатор при этом следует оставить неизменным).

Примерами бизнес-процессов в контексте нашей задачи служат:

  • Опрос очереди сообщений СМЭВ с новыми заявлениями на получение услуг обеспечивает периодический мониторинг очереди сообщений СМЭВ на предмет наличия новых заявлений от пользователей портала.
  • Обработка заявления содержит шаги по анализу содержимого заявления, отражению его в ИАС, отправке подготовленных результатов на портал.
  • Обработка запроса на отмену заявления позволяет отразить факт отмены поданного с портала заявления на услугу в ИАС.
  • Опрос очереди сообщений СМЭВ с ответами на запросы о смене статуса заявления делает возможным изменять статус заявления на портале через ИАС, периодически контролируя очередь ответов портала на поданные запросы о смене статуса.

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

Рассмотрим пример одного из работающих в нашем решении процессов по опросу входящей очереди запросов:

public class LoadRequestWf : IWorkflow<LoadRequestWfData>{  public const string DefinitionId = "LoadRequest";  public string Id => DefinitionId;  public int Version => 1;  public void Build(IWorkflowBuilder<LoadRequestWfData> builder)  {    builder      .StartWith(then => ExecutionResult.Next())        .While(d => !d.Quit)          .Do(x => x            .StartWith<LoadRequestStep>() // *              .Output(d => d.LoadRequest_Output, s => s.Output)            .If(d => d.LoadRequest_Output.Exception != null)              .Do(then => then                .StartWith(ctx => ExecutionResult.Next()) // *                  .Output((s, d) => d.Quit = true))            .If(d => d.LoadRequest_Output.Exception == null                && d.LoadRequest_Output.Result.SmevReqType                  == ReqType.Unknown)              .Do(then => then                .StartWith<LogInfoAboutFaultResponseStep>() // *                  .Input((s, d) =>                    { s.Input = d.LoadRequest_Output?.Result?.Fault; })                  .Output((s, d) => d.Quit = false))            .If(d => d.LoadRequest_Output.Exception == null               && d.LoadRequest_Output.Result.SmevReqType                 == ReqType.DataRequest)              .Do(then => then                .StartWith<StartWorkflowStep>() // *                  .Input(s => s.Input, d => BuildEpguNewApplicationWfData(d))                  .Output((s, d) => d.Quit = false))            .If(d => d.LoadRequest_Output.Exception == null              && d.LoadRequest_Output.Result.SmevReqType == ReqType.Empty)              .Do(then => then                .StartWith(ctx => ExecutionResult.Next()) // *                  .Output((s, d) => d.Quit = true))          .If(d => d.LoadRequest_Output.Exception == null             && d.LoadRequest_Output.Result.SmevReqType               == ReqType.CancellationRequest)            .Do(then => then              .StartWith<StartWorkflowStep>() // *                .Input(s => s.Input, d => BuildCancelRequestWfData(d))                .Output((s, d) => d.Quit = false)));  }}

В строках, отмеченных *, можно наблюдать использование параметризованных методов расширения, которые инструктируют движок о необходимости использовать классы шагов (об этом далее), соответствующие параметрам-типам. С помощью методов расширения Input и Output мы имеем возможность задавать исходные данные, передаваемые шагу перед началом выполнения, и, соответственно, изменить данные процесса (а они представлены экземпляром класса LoadRequestWfData) в связи с произведёнными шагом действиями. А так процесс выглядит на BPMN-диаграмме:


Шаги


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

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

Отправка подтверждающих (Ack) запросов о получении ответа.

  • Выгрузка файлов в файловое хранилище.
  • Извлечение данных из пакета СМЭВ и т.п.

Специфические шаги:

  • Создание объектов в ИАС, обеспечивающих возможность оператору предоставить услугу.
  • Генерирование документов заданной структуры с данными из заявления и размещение их в ИАС.
  • Отправка результата оказания услуги на портал и т.п.

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

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

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

Сервисы


Сервисы представляют собой следующий, более низкий уровень решения задач. Каждый шаг при выполнении своей обязанности опирается, как правило, на один или более сервисов (N.B. Понятие сервис в данном контексте более близко к аналогичному понятию сервис уровня приложения из области предметно-ориентированного проектирования (DDD)).

Примерами сервисов служат:

  • Сервис получения ответа из очереди ответов СМЭВ готовит соответствующий пакет данных в формате SOAP, отправляет его в СМЭВ и преобразует ответ в вид, пригодный для дальнейшей обработки.
  • Сервис загрузки файлов из хранилища СМЭВ обеспечивает считывание файлов, приложенных к заявлению с портала, из файлового хранилища по протоколу FTP.
  • Сервис получения результата оказания услуги считывает из ИАС данные о результатах услуги и формирует соответствующий объект, на основе которого другой сервис построит SOAP-запрос для отправки на портал.
  • Сервис выгрузки файлов, связанных с результатом оказания услуги, в файловое хранилище СМЭВ.

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

  • Сервисы СМЭВ.
  • Сервисы ИАС.

Сервисы для работы со внутренней инфраструктурой интеграционного решения (журналирование информации о пакетах данных, связывание сущностей интеграционного решения с объектами ИАС и пр.).

В архитектурном плане сервисы являются наиболее низким уровнем, однако, они при решении своих задач также могут опираться на утилитарные классы. Так, например, в решении существует пласт кода, решающий задачи сериализации и десериализации SOAP-пакетов данных для разных версий протокола СМЭВ. В общем виде приведённое выше описание можно резюмировать в диаграмме классов:


Непосредственно к движку относятся интерфейс IWorkflow и абстрактный класс StepBodyAsync (впрочем, можно использовать и его синхронный аналог StepBody). Ниже на схеме представлены реализации строительных блоков конкретные классы с описаниями бизнес-процессов Workflow и используемые в них шаги (Step). На нижнем уровне представлены сервисы, которые, по существу, являются уже спецификой именно данной реализации решения и, в отличие от процессов и шагов не являются обязательными.

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

Встраивание движка в решение


На момент начала создания системы интеграции с порталом в репозитории Nuget была доступна версия движка 2.1.2. Он встраивается в контейнер зависимостей стандартным образом в методе ConfigureServices класса Startup:

public void ConfigureServices(IServiceCollection services){  // ...  services.AddWorkflow(opts =>    opts.UsePostgreSQL(connectionString, false, false, schemaName));  // ...}

Движок можно настроить на одно из поддерживаемых хранилищ данных (среди таковых есть и другие: MySQL, MS SQL, SQLite, MongoDB). В случае PostgreSQL для работы с процессами движок использует Entity Framework Core в варианте Code First. Соответственно, при наличии пустой базы данных есть возможность применить миграцию и получить нужную структуру таблиц. Применение миграции является опциональным, этим можно управлять с помощью аргументов метода UsePostgreSQL: второй (canCreateDB) и третий (canMigrateDB) аргументы логического типа позволяют сообщить движку, может ли он создать БД при её отсутствии и применять миграции.

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

Итак, вопрос с хранением данных и регистрацией движка в контейнере зависимостей решён, перейдём к запуску движка. Для этой задачи подошёл вариант размещённой службы (hosted service, а здесь можно посмотреть пример базового класса для создания такой службы). Код, взятый за основу, был незначительно доработан для поддержания модульности, под которой понимается разделение интеграционного решения (получившего название Оникс) на общую часть, обеспечивающую инициализацию движка и выполнение некоторых служебных процедур, и часть специфическую для каждого конкретного заказчика (модули интеграции).

Каждый модуль содержит описания процессов, инфраструктуру для выполнения бизнес-логики, а также некоторый унифицированный код для предоставления возможности разработанной системе интеграции распознать и динамически загрузить описания процессов в экземпляр движка Workflow Core:



Регистрация и запуск бизнес-процессов


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

Делается это с помощью следующего кода, который может быть расположен в рамках упомянутой ранее размещённой службы (также здесь может быть размещён код, инициирующий регистрацию процессов в подключенных модулях):

public async Task RunWorkflowsAsync(IWorkflowHost host,  CancellationToken token){  host.RegisterWorkflow<LoadRequestWf, LoadRequestWfData>();  // Регистрируем другие процессы...  await host.StartAsync(token);  token.WaitHandle.WaitOne();  host.Stop();}

Заключение


В общих чертах мы рассмотрели действия, которые необходимо предпринять для использования Workflow Core в интеграционном решении. Движок позволяет описывать бизнес-процессы в достаточно гибкой и удобной манере. Держа в уме тот факт, что мы имеем дело с задачей интеграции с порталом Госуслуг посредством СМЭВ, следует ожидать, что проектируемые бизнес-процессы будут охватывать спектр довольно разнообразных задач (опрос очереди, загрузка/выгрузка файлов, гарантирование соблюдения протокола обмена и обеспечение подтверждения получения данных, обработка ошибок на разных этапах и т.п.). Отсюда вполне естественным будет ожидать возникновения некоторых на первый взгляд неочевидных моментов реализации, и именно им мы посвятим следующую, заключительную статью цикла.

Ссылки для изучения


Подробнее..
Категории: C , Net , Бизнес-процессы , Workflow , Netcore

Прошивка для фотополимерного LCD 3D-принтера своими руками. Часть 1

23.09.2020 20:15:33 | Автор: admin
image
или как я изобретал собственные велосипеды с преферансами и гейшами на свой вкус писал с нуля прошивку для фотополимерного принтера. На данный момент прошивка уже вполне работоспособна.
За основу была взята продающаяся на Алиэкспресс плата MKS DLP, для которой производитель дает схему и исходные коды прошивки, которые я отверг в пользу написания всего с нуля.
Статья получается очень уж большой, поэтому я решил разбить ее на две части. В этой части будет предыстория и описание самодельного GUI для сенсорного дисплея. В конце будут ссылки на сам предмет издевательств и на репозитории Гитхаба.


Для лучшего понимания дам очень короткое описание работы фотополимерных LCD 3D-принтеров для тех, кто с ними не знаком:
Короткое объяснение принципа работы большинства 'бытовых' фотополимерных принтеров
Главная часть такого принтера LCD-дисплей высокого разрешения (как правило, это матрица с диагональю 5.5" и разрешением 2560х1440 (размер пикселя 47.25 мкм). Под этим дисплеем находится источник УФ с длиной волны 405 нм. Над дисплеем находится ванна с фотополимером, у которой в качестве дна тонкая прозрачная FEP-пленка. В ванну опускается платформа, на которой выращивается модель. В начале печати платформа опускается на высоту одного слоя от пленки, на дисплей выводится изображение первого слоя и на заданное время включается УФ-засветка. Засветка, попадая через открытые пиксели дисплея и пленку на фотополимер отверждает его, так получается затвердевший слой. Первый слой прилипает к платформе. Затем засветка выключается, платформа приподнимается на высоту следующего слоя, на дисплей выводится изображение этого слоя и включается засветка. Второй слой отверждается, свариваясь с предыдущим слоем. И так повторяется раз за разом, пока не будет напечатана вся модель.


Предыстория


Как я к этому пришел и почему стал писать свою прошивку вместо того, чтобы просто подправить под себя исходники от производителя.
Предыстория получилась длинной, поэтому убрал ее под спойлер
Лет 5 назад я заинтересовался 3D-печатью. Не в профессиональном плане, а просто стало любопытно что же это такое, что она может и как работает. Сначала был приобретен FDM-принтер, один из самых бюджетных на тот момент Anet A8. И в общем-то мне понравилось, учитывая, что чудес от него я не ждал. На нем я до сих пор иногда печатаю что-то утилитарное какие-нибудь крепления, подставки, корпуса. А затем мне стало интересно пощупать фотополимерную печать с ее потрясающей детализацией, но тогда фотополимерные принтеры назвать бюджетными было никак нельзя. И вот пару лет назад я все-таки созрел на покупку одного из них Anycubic Photon S. Уже и цены были не такими высокими, и я смог себе позволить потратить энную сумму просто для удовлетворения любопытства.

Сначала, конечно же, был эффект вау он печатает такие мельчайшие детали, да так аккуратно. Никаких слоев, прыщей и т.п., присущих FDM-принтерам. Область печати, конечно, не ахти всего примерно 115х65 мм, но фигурки и модельки получаются очень хорошо :) Когда эффект вау прошел, я понял, что детализация у него не такая хорошая, какая могла бы быть. После чего я по примеру знакомого его слегка модернизировал. Пришла новая волна вау детализация повысилась в разы. Правда, стали четко видны границы пикселей, но только если рассматривать модель на расстоянии 20-30 см. Кстати, последующая покраска напечатанных моделей оказалась довольно неплохим способом отдохнуть от работы мозг отдыхает, руки возятся. Результат дарится знакомым как интересный сувенир :)

Но по мере освоения принтера я начал замечать недостатки в работе принтера. Нельзя настроить это, сложно изменить то, не работает так как хотелось бы и т.д. В частности, например, мой принтер не умел работать с каталогами на флэшке, не поддерживал кириллические имена файлов, скорость движения платформы в определенных случаях была не той, что бы меня устроила. Я даже дизассемблировал прошивку и начал разбираться с ее внутренностями. Реализовал работу с кириллицей в именах файлов, изменил процесс вывода на интерфейсный экран (ускорил), переделал работу с языками. Но все это было несерьезно, нужно было иметь исходники, чтобы можно было нормально переделать все что хотелось. А исходники никто из производителей почему-то не дает :) И вот несколько месяцев назад я узнал, что есть такой набор для фотополимерного принтера от довольно известного в сфере 3D-печати производителя MKS DLP. В набор входят: сама материнская плата, дисплей засветки с защитным стеклом (5.5", 2560х1440) и интерфейсный дисплей с сенсорной панелью (3.5", 480х320). И для этого набора идут открытые исходники и схема бери и переделывай как угодно! И я приобрел этот набор, рассчитывая изменить в исходниках то, что мне не нравится.

Когда я получил комплект и скачал с гитхаба исходники, приготовившись их слегка модифицировать, у меня случился легкий шок. Ну, во-первых их родная прошивка оказалась в принципе работоспособна, но это и все, что можно сказать о ней хорошего. Недостатков в ней полно и печатать с ней было бы очень не комфортно. Уже на этапе проб родной прошивки у меня начала закрадываться мысль, что модифицировать придется не так уж слегка. А когда я открыл их проект с исходниками Во-первых, это жуткая мешанина Ардуины и библиотек CMSIS и HAL от ST (плата построена на микроконтроллере STM32F407). Во-вторых, в проект впихнута полная версия Marlin 3D. Кто не знает Marlin 3D это проект для управления FDM 3D-принтерами. Он поддерживает работу до 6 шаговыми двигателями, несколькими нагревателями с контролем температуры, кучи концевиков, парсинг G-кода с построением траекторий движения осей и много-много чего еще. Больше 3 МБ исходников. И сюда он был целиком впихнут только ради управления одним шаговым двигателем. Причем это управление было сделано совершенно без заморочек в текстовой строке формировался G-код движения оси и эта строка передавалась на вход парсера Мерлина. Ну это как если бы взяли целиком автомобиль для того, чтобы использовать одну из его фар для освещения. Вообще создалось впечатление, что производитель взял исходники от своих плат для FDM-принтеров и просто сверху прикостылял код для работы с фотополимерной частью.

Кроме того, там была еще GUI-библиотека в бинарнике, без исходников. И я понял, что проще будет написать свою прошивку с нуля, чем пытаться что-то сделать с родными исходниками.


Итак, что мы имеем:
  • комплект MKS DLP, в который входят: материнская плата, интерфейсный дисплей 3.5" 480х320 и дисплей засветки 5.5" 2560х1440
  • родные исходники от производителя
  • схема материнской платы (без названий активных и номиналов пассивных компонентов)

Материнская плата построена на основе микроконтроллера STM32F407. Для управления дисплеем засветки на плате стоит FPGA китайского производителя GW1N-LV4LQ144ES, SDRAM и две микросхемы MIPI-интерфейса SSD2828. Микроконтроллер загоняет в FPGA изображение слоя, FPGA сохраняет его в SDRAM и оттуда рефрешит дисплей через SSD2828. Конфигурацию (прошивку) FPGA производитель, кстати, не предоставляет в исходниках :( Кроме этого, на материнской плате есть:
  • вход питания 12-24 вольта
  • USB A разъем для подключения флэшки/картридера
  • коммутируемые выходы питания для засветки и двух вентиляторов
  • драйвер шагового двигателя A4988 и разъем для подключения двигателя
  • два разъема для подключения концевиков оси Z верхнего и нижнего
  • разъем для подключения модуля WiFi
  • микросхема FLASH-памяти W25Q64
  • микросхема EEPROM-памяти AT24C16

Интерфейсный дисплей с резистивной тач-панелью подключается плоским 40-пиновым шлейфом. Контроллер дисплея ILI9488, контроллер тач-панели HR2046 (аналог TSC2046).

Для инициализации периферии я использовал программу STM32CUBE MX. Но не использовал напрямую полученный из него результат, а вставлял нужные куски в свои исходники. При работе с периферией использовал библиотеки HAL от ST, а там, где нужно было получить максимальную скорость работал с регистрами напрямую.

Итак, есть задача этот комплект должен уметь печатать файлы с флэшки с удобством для пользователя. Всю эту задачу я весьма примерно разбил на основные части:
  1. Пользовательский интерфейс.
  2. Работа с файловой системой на USB-флэшке.
  3. Управление шаговым двигателем для движения платформы.
  4. Вывод изображений слоев на дисплей засветки.
  5. Всякая мелочь типа управления засветкой и вентиляторами, загрузки и сохранения настроек и т.п.
  6. Дополнительные возможности для комфорта и удобства.

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

1. Пользовательский интерфейс


Сначала была инициализация дисплея. Тут ничего интересного, стандартная последовательность для контроллера ILI9488. Ее я выдрал из родных исходников, вырезав оттуда код инициализации других видов дисплеев (которые, вероятно, остались там еще от FDM-жизни этих исходников). Дальше я занялся шрифтами.

1.1 Шрифты


В сети множество библиотек работы со шрифтами для микроконтроллеров, но абсолютное большинство из них работают с моноширинными шрифтами, а мне это не очень нравится. Это когда у всех символов одинаковая ширина, что у буквы ж, что у буквы i. Когда-то для одного из своих пет-проектов я написал библиотеку пропорциональных шрифтов. В ней для каждого шрифта используются два массива массив с битовыми данными самих символов и массив с указанием ширины каждого символа. И небольшая структура с параметрами шрифта указатели на массивы, высота шрифта, количество символов в шрифте:
typedef struct{uint16_t*width;uint8_t*data;uint8_theight;uint16_tsymcount;} LCDUI_FONT;


Казалось бы, такая организация шрифта должна занимать в памяти больше места, чем просто битовый массив моноширинного шрифта, однако это не совсем так. Во-первых, сама моноширинность дает избыток хранящихся данных. Например, если в шрифте высотой 8 и шириной 5 пикселей для буквы i было бы достаточно 1 байта (1 бит ширины и 8 бит высоты), то она все равно будет занимать 5 байт данных (5 бит ширины и 8 бит высоты), т.к. ширина фиксирована. Во-вторых, как правило в таких шрифтах делается выравнивание по границам байта каждой строки или каждой колонки, смотря как организованы данные.

Например, взять тот же шрифт 5х8. Если битовые данные хранятся по строкам, то для каждой строки получается избыток 3 бита. Или 3 байта на символ:
image

Или шрифт 7х12 с хранением данных по колонкам, тогда получается избыток данных 4 бита на колонку или 3.5 байта на символ:
image

В моей библиотеке битовые данные непрерывны для символа и выравнивание по границе байта идет только в конце символа.
Плюс есть еще одна небольшая хитрость, позволяющая слегка уменьшить хранимый размер шрифта: символ может не иметь битовых данных, а ссылаться на другой символ с таким же начертанием. К примеру, кириллические буквы А, В, Е, К и т.д. могут иметь отсылку на латинские буквы с тем же начертанием. Это делается с помощью указания отрицательного значения ширины соответствующего символа в массиве ширин символов. Если там стоит отрицательное значение, значит изображение этого символа берется из символа в позиции (ширина * -1).

Вот процедура нахождения в массиве данных символа:
uint8_t*_lcdui_GetCharData(char c){if (c < 32)return 0;if (c > 126)c -= 65;c -= 32;if (c >= lcdui_current_font->symcount)return 0;uint16_t c1 = lcdui_current_font->width[c];if (c1 & 0x8000)c = (c1 & 0x7FFF);uint16_t ch = lcdui_current_font->height;int32_t i = 0, ptr = 0, bits = 0, line_bits = ch;for (i = 0; i < c; i++){if (lcdui_current_font->width[i] & 0x8000)continue;bits = lcdui_current_font->width[i] * line_bits;ptr += bits >> 3;if (bits & 0x07)ptr++;}return &(lcdui_current_font->data[ptr]);}


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

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

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

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

Процедура вывода текста умеет переносить текст на новую строку в конце экрана или по встреченному символу перевода строки, умеет выравнивать влево, вправо и по центру, ограничивать область, за пределы которой текст не выйдет (будет обрезан). И умеет выводить символы с закрашиванием фона фоновым цветом или с сохранением фона. Второй вариант работает медленнее, так как уже не получается заливать данные символа в дисплей одним потоком, но все равно достаточно быстро, чтобы вывод 3-4 строк не был заметен глазу.

1.2 Вывод изображений интерфейса


Для пользовательского интерфейса понадобится выводить на дисплей изображения фон, иконки, кнопки. Сначала я решил сильно не заморачиваться и хранить все изображения в формате .bmp в 8-мегабайтной флэш-памяти, имеющейся на плате. И даже уже написал для этого процедуру. Файл сохраняется в 16-битном формате (R5 G6 B5) с прямым или обратным порядком строк, и может уже быть напрямую скормленным процедуре отрисовки. Но размер фоновой картинки размером 480х320 выходит более 300 Кбайт. С учетом того, что часть этой флэш-памяти будет отводиться под обновление прошивки, 30 фоновых изображений займут всю память. Вроде и немало, но все же меньше, чем хотелось бы иметь на всякий случай. А ведь должны быть еще кнопки, иконки и т.п. Поэтому было решено преобразовывать изображения в какой-то сжатый формат.

Со сжатием вариантов немного все более-менее хорошо сжимающие изображения алгоритмы требуют или прилично оперативки (по меркам микроконтроллера) или прилично времени на разжатие. Картинки же должны выводиться, разжимаясь на лету, и желательно чтобы картинка при выводе не уподоблялась ползущему прогресс-бару :) Поэтому я остановился на RLE-сжатии 1 байт кодирует количество повторов, а два следующих за ним цвет. Для этого так же была написана утилита, преобразующая файлы .bmp в сжатые таким образом изображения. Заголовок состоит всего из 4 байт по 2 байта на ширину и высоту изображения. В среднем фоновые изображения сжимаются таким способом в 5-7 раз, сильно зависит от размера одноцветных участков (чего и следовало ожидать). Например вот такая картинка сжалась с исходных 307 КБ до 74 КБ:
image

А вот такая до 23 КБ с тех же 307:

Да, кстати, дизайнер из меня еще более фиговый, чем программист...

Меня такой результат устроил. Декодирование и вывод изображений происходит очень быстро примерно 40 миллисекунд на полное фоновое изображение. Так что на таком варианте я и остановился.

И, кстати, переход на режим DMA для вывода данных в дисплей не дал почти никакого ускорения вывода. Дисплей подключен по внешней 16-битной шине данных как внешняя память, но вот его тайминги довольно печальны, что и сводит почти на нет преимущества DMA-вывода перед попиксельным выводом вручную.

1.3 Основа GUI


Тексты выводятся, картинки рисуются, теперь пора подумать над тем как будет организована основа пользовательского интерфейса.
С тач-панелью все просто микроконтроллер по прерываниям постоянно опрашивает контроллер тач-панели и усредняет последние 4 полученных результата, переводя их в координаты дисплея. Таким образом в любой момент известно состояние сенсора нажат он или нет и если нажат, то в каком именно месте. Еще одна прослойка между тач-панелью и основной частью программы процедура обработки нажатий кнопок, которая уже довольно давно кочует у меня из проекта в проект с небольшими адаптациями под конкретные условия.
Вот вкратце ее принцип работы
Изначально всем кнопкам присваивается статус СВОБОДНА. По прерыванию таймера вызывается процесс опроса кнопок (100-150 раз в секунду). Если кнопка оказывается нажата, то ей присваивается статус ПРЕДНАЖАТА. При следующем опросе если она все еще остается нажатой, ее счетчик увеличивается на единицу. Если оказывается, что счетчик достиг определенного значения, то кнопке присваивается статус НАЖАТА, а счетчик обнуляется. Если при очередном опросе кнопка оказалась не нажатой, имея статус ПРЕДНАЖАТА, то ее статус меняется на СВОБОДНА. Когда оказывается отпущенной кнопка со статусом НАЖАТА, ей дается статус ОТПУЩЕНА. Основная программа просто опрашивает когда может статус кнопок и если у какой-то кнопки статус оказывается НАЖАТА или ОТПУЩЕНА, то вызывается процедура обработки нажатия или отпускания этой кнопки. Тут реализуется и программный фильтр дребезга контактов (статус ПРЕДНАЖАТА), и срабатывание нажатия кнопки даже если основная программа во время нажатия была чем-то занята. Кроме того, там еще есть статусы и для длительного нажатия, и для повторяющегося ввода ни длительном нажатии.

Тач-панель служит единственной кнопкой интерфейса, только кроме самого факта ее нажатия анализируются еще и координаты, по которым произошло нажатие.
Теперь нужно все сделать так, чтобы на экран можно было выводить самые разные элементы интерфейса, которые могли бы реагировать или не реагировать на нажатия, обновляться по событиям, иметь разные размеры и изображения и т.д.
В конечном итоге я пришел к такой схеме: интерфейс состоит из двух основных типов элементов экранов и кнопок.
Экран это своего рода полноэкранный контейнер для кнопок. У экрана есть следующие свойства:
  • фоновое изображение
  • цвет фона
  • способ отрисовки фона заливка фоновым цветом или вывод изображения
  • текст заголовка
  • цвет текста заголовка
  • шрифт текста заголовка
  • указатель на родительский экран (в который нужно вернуться при закрытии этого)
  • массив указателей на кнопки
  • указатель на процедуру обработки событий (вызывается периодически в основном цикле программы)
  • указатель на процедуру отрисовки экрана

Структура экрана
typedef struct{void*addparameter;char*bgimagename;void*prevscreen;LNG_STRING_IDname;TG_RECTnameposition;TG_TEXTOPTIONSnameoptions;uint8_tbtns_count;TG_BUTTON*buttons;LCDUI_FONT_TYPEfont;LCDUI_FONT_TYPEnamefont;uint16_ttextcolor;uint16_tnametextcolor;uint16_tbackcolor;struct {paintfunc_callpaint;// repaint screenprocessfunc_process;// screen process handling (check for changes, touch pressed, etc)} funcs;} TG_SCREEN;


Кнопки по сути могут быть не только кнопками, но и текстом, иконкой, каким-то изменяющимся элементом типа счетчика или часов. Просто оказалось удобным объединить все в одном типе, а поведение каждой конкретной кнопки задавать через ее свойства.
Свойства кнопок:
  • координаты на экране
  • цвет фона
  • фоновое изображение для свободного состояния
  • фоновое изображение для нажатого состояния
  • фоновое изображение для выключенного состояния (disabled)
  • фоновое изображение для активного состояния (для активного элемента группы переключателей, например)
  • способ отрисовки изображением или фоновым цветом
  • перерисовывать ли кнопку при ее нажатии и отпускании
  • текст кнопки
  • шрифт текста кнопки
  • координаты области вывода текста кнопки
  • цвета текста и фона для всех состояний
  • флаги отключенного и активного состояний
  • флаг необходимости перерисовать (изменилось состояние)
  • опции вывода текста (выравнивание, прозрачность)
  • идентификатор кнопки
  • идентификатор группы (для группы переключателей)
  • указатели на процедуры отрисовки, обработки событий и нажатия
  • указатели на дочерний экран, вызываемый при нажатии на эту кнопку

Структура кнопки
typedef struct{void*addparameter;uint8_tbutton_id;int8_tgroup_id;// for swithed options buttons, >0 - single selection from group (select), <0 - multiple selection (switch)TG_RECTposition;void*parentscreen;void*childscreen;char*bgimagename_en;char*bgimagename_press;char*bgimagename_dis;char*bgimagename_act;// for swithed options buttonsLNG_STRING_IDtext;TG_RECTtextposition;LCDUI_FONT_TYPEfont;uint16_ttextcolor_en;uint16_ttextcolor_press;uint16_ttextcolor_dis;uint16_ttextcolor_act;// for swithed options buttonsuint16_tbackcolor_en;uint16_tbackcolor_press;uint16_tbackcolor_dis;uint16_tbackcolor_act;// for swithed options buttonsstruct {uint8_tactive:1;// for swithed options buttonsuint8_tneedrepaint:1;uint8_tpressed:1;uint8_tdisabled:1;uint8_trepaintonpress:1;// repaint or not when pressed - for indicate pressed stateBGPAINT_TYPEbgpaint:2;} options;TG_TEXTOPTIONStextoptions;struct {paintfunc_call_paint;// repaint buttonpressfunc_call_press;// touch events handlingpressfunc_call_longpress;// touch events handlingprocessfunc_call_process;// periodical processing (for example text value refresh)} funcs;} TG_BUTTON;


С помощь этого набора свойств оказалось возможным создавать на основе такого элемента практически все что угодно в интерфейсе. Если у экрана или кнопки указатель на какую-то из процедур нулевой, то вызывается стандартная соответствующая процедура. Вместо указателя процедуры на нажатие кнопки, например, может стоять специальный идентификатор, указывающий, что нужно перейти к дочернему или к предыдущему экрану, тогда стандартная процедура сделает это. Вообще стандартные процедуры перекрывают почти все случаи использования обычных кнопок и создавать свои процедуры для кнопки приходится только в нестандартных случаях например когда кнопка работает как часы, или как элемент списка файлов.
А вот на что не хватило возможностей этой схемы так это на модальные окна с сообщениями или вопросами (типа MessageBox в Windows API), поэтому для них я сделал отдельный тип экранов. Без фоновых изображений и с размером, определяющимся заголовком или самим сообщением. Эти сообщения могут быть созданы в четырех вариантах с кнопками Да/Нет, с кнопками Ок/Отмена, с одной кнопкой Ок или вообще без кнопок (типа Подождите, идет загрузка данных...).


Структура окна сообщений
typedef struct{MSGBOXTYPEtype;void*prevscreen;charcaption[128];chartext[512];TG_RECTboxpos;uint8_tbtns_count;TG_BUTTONbuttons[TG_BTN_CNT_MSGBOX];uint16_tcaption_height;LCDUI_FONT_TYPEfont_caption;LCDUI_FONT_TYPEfont_text;uint16_ttext_color;uint16_tbox_backcolor;uint16_tcapt_textcolor;uint16_tcapt_backcolor;} TG_MSGBOX;


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

1.4 Мультиязычность




Мультиязычность стояла в задачах изначально. Но сначала я пошел по глупому пути при инициализации всех элементов я присваивал им тексты из той языковой таблицы, которая являлась текущей. Переключение языка означало переинициализацию всех текстовых элементов и когда экранов в интерфейсе стало больше двух, а кнопок и надписей больше 20, я понял, что дальше так жить нельзя. После чего сделал все обращения к текстам через процедуру. Процедуре дается параметром идентификатор текста, а она возвращает указатель на текст в текущем языке:
char *mshortname = LANG_GetString(LSTR_SHORT_JANUARY);

При изменении языка происходит просто изменение указателя с массива текстов на старом языке на массив с текстами на новом языке:
voidLANG_SetLanguage(uint8_t lang){lngCurrent = lngLanguages[lang].strings;return;}

Все тексты в исходниках в кодировке UTF-8. С этими кодировками тоже пришлось повозиться. Тексты в UTF-8, кириллица файлов в Unicode-16, некоторые строки в обычном ANSI. Тянуть в прошивку целый набор библиотек для поддержки многобайтовых кодировок не хотелось, поэтому было написано несколько функций для преобразований из кодировки в кодировку и для операций с текстами в разных кодировках, например, добавить к концу строки Unicode16 строку в UTF-8.
Добавление нового языка теперь свелось к созданию таблицы текстов на нем и к изменению значения константы LNG_LANGS_COUNT. Правда, остается вопрос со шрифтами, если в новом языке используются символы помимо кириллицы и латинницы Сейчас я поддерживаю в исходниках русский и гуглопереведенный английский.

1.5 Хранение изображений и прочих ресурсов


Для хранения больших ресурсов на плате имеется SPI-флэш на 8 мегабайт W25Q64. Изначально я хотел поступить как всегда задать смещение для каждого ресурса внутри флэши и сохранять их туда как просто бинарные данные. Но потом понял, что проблемы с таким способом мне гарантированно обеспечены как только количество сохраняемых ресурсов перевалит за пару десятков и мне захочется изменить, например, какую-то картинку, которая сохранена шестой по порядку. Если ее размер увеличится, то придется сдвигать адреса всех следующих ресурсов и перезаписывать их заново. Или оставлять после каждого ресурса запасное пространство неизвестного размера кто его знает как может измениться какой-то из ресурсов. Да в гробу я видал эту возню :) Поэтому я плюнул и организовал на этой флэши файловую систему. К тому времени у меня уже работала файловая система для USB на основе библиотеки FatFS, так что мне было достаточно просто написать отдельные низкоуровневые функции чтения/записи секторов. Одно только меня слегка расстроило размер стираемого сектора в этой микросхеме аж целых 4 КБ. Это во-первых приводит к тому, что файлы будут занимать место порциями по 4 КБ (записал файл 200 байт он занял 4 КБ флэши), а во-вторых буфер в структуре каждого файлового указателя будет отъедать те же 4 КБ оперативки, которой в микроконтроллере не так уж много 192 КБ. Можно было бы, конечно, извратиться и написать низкоуровневые функции так, чтобы они могли писать и читать и меньшими порциями, рапортуя о размере сектора, например, 512 байт. Но это замедлило бы работу с флэш, так что оставил размер сектора 4 КБ. Так что обращение к любому ресурсу осуществляется просто по имени его файла, что оказалось очень удобным. На данный момент, например, количество хранимых ресурсов перевалило уже за 90. И их обновление я сделал максимально простым обновляемые (или новые) ресурсы записываются на USB-флэшку в определенный каталог, флэшка вставляется в плату, плата перезагружается в сервисный режим (во время включения или перезагрузки нажать и держать правый верхний угол дисплея) и автоматически копирует все найденные в этом каталоге файлы с USB-флэшки в SPI-флэш.



Продолжение следует...


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

Ссылки


Комплект MKS DLP на Алиэкспресс
Исходники оригинальной прошивки от производителя на Гитхабе
Схемы от производителя двух версий платы на Гитхабе
Мои исходники на Гитхабе
Подробнее..

Прошивка для фотополимерного LCD 3D-принтера своими руками. Часть 2

25.09.2020 02:06:03 | Автор: admin


Продолжение статьи о написании своей прошивки для фотополимерного LCD 3D-принтера. Первая часть лежит тут. В ней было описан первый этап создание графического пользовательского интерфейса для дисплея с сенсорной панелью.
В этой части продолжу описывать этапы своего проекта:
2. Работа с USB-флэшкой и файлами на ней
3. Управление шаговым двигателем для движения платформы.

2. Работа с USB-флэшкой и файлами на ней


До этого я никогда не работал с USB-хостом на микроконтроллерах. Как USB-device делал прошивки и с классом CDC (эмуляция COM-порта) и с классом HID, но вот с хостом не работал. Поэтому для ускорения процесса я создал всю инициализацию этой периферии в STM32CUBE. На выходе я получил работающий в режиме USB FS хост, поддерживающий устройства хранения данных (Mass storage). В том же кубе я сразу подключил и библиотеку FatFS для работы с файловой системой и файлами. Дальше оставалось просто скопировать полученные исходники в свой проект и разобраться как с ними работать. Это оказалось несложно и описывать тут особо нечего. В файле usb_host.c из Куба имеется глобальная переменная Appli_state с типом ApplicationTypeDef:
typedef enum {  APPLICATION_IDLE = 0,  APPLICATION_START,  APPLICATION_READY,  APPLICATION_DISCONNECT}ApplicationTypeDef;

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

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

А вот для работы FatFS с кириллицей в именах файлов и каталогов пришлось немного повозиться. Для того, чтобы FatFS корректно читала кириллические имена, нужно в ее конфигурации включить работу с Unicode, и после этого все строки, связанные с FatFS, должны быть только в этой кодировке имена дисков, имена файлов и т.д. При этом текстовый редактор в IDE и FatFS поддерживают Юникод с разным расположением старшего байта один с Little Endian, другая с Big Endian, так что просто писать исходники с текстами в Юникоде не получится. Да и не хочется, если честно. Вот тогда и пришлось писать конвертеры из ANSI и UTF-8 в Unicode и обратно, плюс несколько функций по работе со строками разных кодировок в разных сочетаниях. Например, скопировать UTF-8-строку в Unicode-строку, или добавить к Unicode-строке ANSI-строку. Впрочем, ANSI-строк, кажется, нигде уже и не осталось, все исходники полностью перешли в кодировку UTF-8.
Так, открытие файла с заданным именем выглядит сейчас примерно вот так:
tstrcpy(u_tfname, UsbPath);// задаем полному пути (Unicode) имя диска в (Unicode)tstrcat_utf(u_tfname, SDIR_IMAGES);// добавляем к пути (Unicode) имя каталога (UTF-8)tstrcat_utf(u_tfname, (char*)"\\");// добавляем к пути (Unicode) слэш (UTF-8)tstrcat(u_tfname, fname);// добавляем к пути (Unicode) имя файла (Unicode)

Когда все это быстренько заработало, захотелось проверить скорость чтения файлов с флэшки. Чтение 10-мегабайтного файла блоками по 4 КБ показало скорость около 9 Мбит/сек, что, в общем-то, довольно неплохо и меня устроило.

Сунулся было изучить вопрос по переводу этого дела на DMA, но оказалось, что периферия USB-хоста просто не имеет доступа к DMA. Ну или я не нашел его :) Поэтому показалось логичным все буферы чтения/записи для файлов USB организовать в CCM (Core Coupled Memory) области оперативной памяти размером 64 КБ, которая так же не имеет выход на DMA. В этой же области памяти имеет смысл размещать и другие переменные/массивы, которые не работают с DMA, просто чтобы больше памяти оставить в обычной оперативке. Кстати, мне показалось, что само ядро работает с этой памятью чуть быстрее, чем с обычной.

2.1 Пользовательский файловый интерфейс


Принтер Anycubic Photon S, который у меня имеется, выводит список файлов в виде значков предпросмотра, 4 штуки на экран. И в принципе, это достаточно удобно видно имя файла, в картинке предпросмотра видно примерно что за модель. Поэтому и я пошел по тому же пути файлы выводятся по 4 штуки на страницу в виде картинок предпросмотра с именем файла.

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

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


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


Кстати, по поводу картинок предпросмотра, которые рисуются для файлов в режиме иконок тут никакой интриги нет. Прошивка не анализирует весь файл, чтобы построить изображение по 3D-модели, как некоторые думают :) Эта картинка сохраняется в файле печати самим слайсером, в формате, схожем с BMP массив 16-битных значений цвета пикселей. Размеры картинки предпросмотра хранятся в специальных полях внутри файла. Так что все очень просто.

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

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

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

2.2 Просмотр информации о файле перед началом печати


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


Имя файла, его размер, время последней модификации и практически все параметры печати. Тут, правда, мне на руку сыграл еще тот факт, что дисплей у MKS DLP имеет разрешение 480х320, тогда как у Эникубиков он поменьше 320х240, на таком особо не размахнешься с кучей текста.

2.2.1 По поводу расчета времени печати напишу отдельно.

Этот показатель не хранится в файле, в отличии от всех остальных параметров. Его принтер должен рассчитать самостоятельно, исходя из известной ему информации. Тот же Anycubic Photon S имеет обыкновение промахиваться с этим расчетом, причем в меньшую сторону например, обещает 5 часов печати, тогда как в реальности печатает 6 часов. А Longer Orange 30 вообще во время печати меняет это время туда-сюда чуть ли не в два раза. Я решил подойти к этому моменту максимально тщательно. Из чего складывается это время?
  1. Время, за которое платформа опустится с заданной скоростью на высоту очередного слоя.
  2. Время паузы перед началом засветки.
  3. Время засветки слоя.
  4. Время, за которое платформа поднимется на заданную высоту с заданной скоростью после засветки слоя.


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

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

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

Если честно, у меня голова кругом шла когда я писал функцию расчета времени печати :) И в результате все равно получил небольшую погрешность. Например, реальное время печати 07:43:30 вместо расчетных 07:34:32.


Или 05:48:43 вместо расчетных 05:43:23.


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

3. Управление шаговым двигателем для движения платформы.


Сначала у меня была мысль написать свое управление шаговым двигателем. Это ведь совсем не сложно, имея на плате нормальный драйвер выставил на одном выводе направление вращения и погнал на другой вывод импульсы шагов. Надо быстро вращать поднимаешь частоту импульсов, надо медленно уменьшаешь.

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

Короче, задача хоть и выполнимая, но весьма и весьма объемная, которая заняла бы у меня не один день. Поэтому я решил выдернуть функции управления двигателем из Марлина. Я думал, что это будет несложно

Сначала я взял из исходников Марлина файл stepper.cpp непосредственно управление шаговым двигателем. Однако оказалось, что его работа очень сильно зависима от планировщика движений из файла planner.cpp, пришлось взять и его. Ну и до кучи я взял оттуда еще и файл endstops.cpp обработка концевиков осей, так как мне все равно нужно было обрабатывать события от них, а тут и планировщик и управление двигателем уже были связаны с этим файлом для концевиков.

Я очень долго провозился с тем, чтобы убрать из этих файлов все ненужное и отвязать их от остальной экосистемы Марлина. Дело в том, что Марлин заточен под управление 6 или 7 шаговиками одновременно, при этом их работа может зависеть от температуры нескольких нагревателей, от параметров пластика и т.д. Система там на самом деле сложная. Мне пришлось очень многое переделать, в основном удаляя лишние оси и ненужные экструдеры и избавляясь от целой толпы макросов, полезных в оригинальной версии, но очень мешающих в моей. Просто для понимания размер взятых мною из Марлина исходников сократился с 346 до 121 КБ. И каждую строку приходилось удалять с оглядкой.

Естественно, в процессе этой жесткой обрезки я слегка вник в работу всей системы, как она работает. Для движения оси в планировщик через одну из его функций передается целевое положение оси (текущее положение планировщик хранит у себя). Планировщик рассчитывает количество шагов и их параметры на ускорение, прямолинейное движение и замедление и формирует из этих данных специальный пакет данных для функции непосредственного управления двигателем (stepper). Этих пакетов может быть несколько, планировщик при каждом новом задании рассчитывает и создает новый, очередной пакет.

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

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

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

Поясню на примере. Планировщик свободен, ему приходит задание на движение оси вперед на 20 мм со скоростью 30 мм/сек. Планировщик формирует первый пакет, в котором описывает ускорение с нуля до 30 мм/сек, прямолинейное движение с этой скоростью и замедление от этой скорости до нуля. Если до того как stepper заберет у планировщика этот пакет планировщику будет выдано новое задание на движение этой оси еще на 50 мм вперед, но уже со скоростью 40 мм/сек, то планировщик не просто создаст новый пакет с ускорением от нуля, а изменит первый пакет, удалив замедление и продлив на его расстояние прямолинейное движение, а в созданном втором пакете ускорение уже будет начинаться не с нуля, а от скорости предыдущего пакета.

В результате получится одно движение, в котором ось ускорится до 30 мм/сек, проедет 20 мм, затем еще раз ускорится уже до 40 мм/сек и проедет еще 50 мм, замедлившись в конце до нуля. Но это только если stepper еще не успел забрать в работу предыдущий пакет, иначе эти два задания будут отработаны как два отдельных движения с нулевой начальной и конечной скоростью в каждом из них. Поэтому, кстати, в принтерах при ручном управлении платформой если несколько раз подряд нажать подъем с шагом 10 мм, то платформа после первых 10 мм подъема остановится и потом уже продолжит движение без остановок на всю высоту, нащелканную кнопкой.

В новой версии Марлина уже появилось средство против такого дерганного движения теперь планировщик не отдает stepper-у пакет в течении определенного времени после его формирования если этот пакет является единственным готовым. Это время отводится на выжидание не поступит ли следующее задание, чтобы можно было состыковать его с имеющимся.

3.1 Интерфейс управления движением платформы




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

Кнопка Уст. Z=0 предназначена для калибровки высоты платформы над дисплеем. Такая система калибровки используется, например, в принтерах Anycubic, когда нулевая точка платформы (оптимальная ее высота над дисплеем) находится на 1-2 мм ниже срабатывания домашнего концевика. И эта система калибровки видится мне более правильной, чем становящиеся в последнее время популярными системы, когда высота срабатывание концевика является одновременно и нулевой высотой платформы.

Ну и последняя кнопка Стоп! это безусловная и немедленная остановка движения платформы. Кстати, пока платформа находится в движении, из этого экрана нельзя выйти, кнопка Назад не сработает. Это сделано как раз для того, чтобы пока платформа двигается, кнопка Стоп была в моментальной доступности.

3.2 Другие моменты по движению платформы


Вот в Anycubic Photon меня жутко раздражают несколько вещей.

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

Второй момент почему при постановке печати на паузу платформа поднимается на высоту паузы с той скоростью, которая задана в параметрах печати? Черт возьми, ждать 15 секунд пока платформа поднимется на два (всего лишь) сантиметра это за гранью добра. Но спасибо, что хоть поднимается. У Orange 30 пауза вовсе не подразумевает подъем платформы хоть на миллиметр, так что даже непонятно для чего она там вообще есть.

И третий момент, который просто бесит после окончания печати платформа поднимается на самый верх. С той же скоростью, которая была задана в параметрах печати 1 мм/сек. Это 100 секунд на подъем наверх с высоты 5 см!

Поэтому в своей прошивке я сделал настраиваемыми скорости и ускорения отдельно для режима печати и отдельно для ручного управления платформой. Но с двумя ограничениями:
  1. Пока ось не будет обнулена кнопкой Домой скорость движения будет снижена втрое. Это сделано потому что пока принтер не знает точную текущую высоту платформы, существует опасность раздавить дисплей, не успев остановиться с высокой скорости (инерция, чтоб ей) или повредить верхний упор оси. После обнуления оси принтер уже точно знает положение платформы и в силу вступают программные ограничения высоты, которые так же задаются в настройках.
  2. На высоте меньше 30 мм скорость так же снижается втрое, независимо от того обнулена ось или нет. Это сделано для предотвращения разбрызгивания фотополимера из ванны при слишком быстром опускании в него платформы. Или при слишком быстром подъеме из него.


Конечно же, в настройках присутствуют и другие стандартные параметры оси количество шагов на 1 мм, направление движения, работа концевиков и т.п. Если кому-то интересно, то под спойлером приведен текстовый конфигурационный файл со всеми поддерживаемыми параметрами. Такой файл с расширением .acfg кушается прошивкой прямо из списка файлов, загружая параметры, сохраняя их в EPROM и применяя немедленно, без перезагрузки:
Содержимое конфигурационного файла
# Stepper motor Z axis settings
[ZMotor]

# Изменяет направление движения платформы.
# Допустимые значения: 0 или 1. По умолчанию: 1.
# Измените этот параметр если платформа двигается в неверном направлении.
invert_dir = 1

# Направление движения платформы при поиске домашней позиции.
# Допустимые значения: -1 или 1. По умолчанию: -1.
# Если этот параметр равен -1, то при поиске домашней позиции
# платформа будет двигаться вниз, к нижнему концевику. При значении 1
# платформа будет двигаться к верхнему концевику.
home_direction = -1

# Значение оси Z после поиска домашней позиции. Как правило, для нижнего
# домашнего концевика это 0, для верхнего - максимальная высота оси.
home_pos = 0.0

# Ограничение на минимальную допустимую нижнюю позицию платформы в миллиметрах.
# Допустимые значения: число в диапазоне от -32000.0 до 32000.0.
# По умолчанию: -3.0
# Это ограничение действует только после нахождения домашней позиции. Если
# поиск домашней позиции не производился, то движение ограничивается концевиками.
min_pos = -3.0

# Ограничение на максимальную допустимую верхнюю позицию платформы в миллиметрах.
# Допустимые значения: число в диапазоне от -32000.0 до 32000.0.
# По умолчанию: 180.0
# Это ограничение действует только после нахождения домашней позиции. Если
# поиск домашней позиции не производился, то движение ограничивается концевиками.
max_pos = 180.0

# Работа нижнего концевика.
# Допустимые значения: 0 или 1. По умолчанию: 1.
# Если при срабатывании концевика напряжение на его выходе пропадает, то поставьте
# значение 1, если наоборот - поставьте 0.
min_endstop_inverting = 1

# Работа верхнего концевика.
# Допустимые значения: 0 или 1. По умолчанию: 1.
# Если при срабатывании концевика напряжение на его выходе пропадает, то поставьте
# значение 1, если наоборот - поставьте 0.
max_endstop_inverting = 1

# Количество шагов двигателя на 1 мм движения платформы.
steps_per_mm = 1600

# Скорость первого, быстрого движения к концевику при поиске домашней
# позиции, мм/сек. По умолчанию: 6.0.
homing_feedrate_fast = 6.0

# Скорость второго, медленного движения к концевику при поиске домашней
# позиции, мм/сек. По умолчанию: 1.0.
homing_feedrate_slow = 1.0

# Ускорение платформы в режиме печати, мм/сек2.
acceleration = 0.7

# Скорость движения платформы в режиме печати, мм/сек.
feedrate = 5.0

# Ускорение платформы в режиме свободного движения (движение кнопками из интерфейса,
# подъем по окончании печати и т.п.), мм/сек2.
travel_acceleration = 25.0

# Ускорение платформы в режиме свободного движения (движение кнопками из интерфейса,
# подъем по окончании печати и т.п.), мм/сек. На высоте менее 30 мм платформа
# двигается в три раза медленнее заданной в этом параметре скорости, но не менее
# 5 мм/сек.
travel_feedrate = 25.0

# Ток двигателя для интегрированного в плату драйвера, мА.
current_vref = 800.0

# Ток двигателя для интегрированного в плату драйвера в режиме удержания, мА.
current_hold_vref = 300.0

# Время с момента последнего движения двигателя, после которого включается режим
# удержания с пониженным током. Задается в секундах. Значение 0 отключает режим
# удержания с пониженным током.
hold_time = 30.0

# Время с момента последнего движения двигателя, после которого мотор полностью
# отключается. Задается в секундах. Значение этого параметра должно быть не меньше
# значения параметра hold_time. Значение 0 отключает этот режим.
# Следует учесть, что при отключении мотора теряется домашняя позиция.
off_time = 10.0

# General settings
[General]

# Длительность звука зуммера в миллисекундах (0.001 сек) при окончании печати
# или при выводе сообщений об ошибках.
# Допустимые значения: от 0 до 15000. По умолчанию: 700 (0.7 сек).
buzzer_msg_duration = 700

# Длительность звука зуммера в миллисекундах (0.001 сек) при нажатии
# на активную зону сенсорного дисплея, например на кнопку.
# Допустимые значения: от 0 до 15000. По умолчанию: 70 (0.07 сек).
buzzer_touch_duration = 70

# Переворачивает изображение на интерфейсном дисплее на 180 градусов.
# Служит для возможности переворота дисплея в принтере для более удобного его размещения.
# Допустимые значения: 0 или 1. По умолчанию: 0.
rotate_display = 0

# Время перехода дисплея в режим скринсейвера с отображением времени и даты, задается в минутах.
# Скринсейвер эмулирует настольные LCD-часы. Переход обратно в рабочий режим - нажатие в любом
# месте дисплея.
# Допустимые значения: от 0 до 15000. По умолчанию: 10. Значение 0 отключает режим скринсейвера.
screensaver_time = 10



И на этом я закончу эту часть, и так уже получилось слишком много текста :)
Как и раньше с удовольствием отвечу на вопросы и приму замечания.

Ссылки


Комплект MKS DLP на Алиэкспресс
Исходники оригинальной прошивки от производителя на Гитхабе
Схемы от производителя двух версий платы на Гитхабе
Мои исходники на Гитхабе
Подробнее..

Прошивка для фотополимерного LCD 3D-принтера своими руками. Часть 3

25.09.2020 20:20:19 | Автор: admin


В предыдущих двух частях я рассказал о том как делал GUI, заводил управление шаговым двигателем и организовывал работу с файлами на USB-флэшке.

Сегодня я напишу о процессе печати, выводе печатаемых слоев на экран засветки и об оставшихся, не таких существенных вещах:

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

4. Вывод изображений слоев на дисплей засветки.



4.1 Вывод изображений на УФ-дисплей


Как вообще микроконтроллер, у которого нет специализированной периферии, смогли заставить рефрешить изображение на матрице высокого разрешения со скоростью 74 миллиона пикселя в секунду (разрешение 2560х1440, 20 кадров в секунду) по интерфейсу MIPI? Ответ: с помощью FPGA с подключенной к ней 16-мегабайтной SDRAM и двух микросхем интерфейса MIPI SSD2828. Две микросхемы стоят потому, что дисплей логически разделен на две половины, каждая из которых обслуживается по своему отдельному каналу, получается два дисплея в одном.

Изображение для вывода на дисплей хранится в одном из 4 банков SDRAM, микросхема FPGA занимается обслуживанием SDRAM и выводом изображения из нее в SSD2828. FPGA генерирует для SSD2828 сигналы вертикальной и горизонтальной синхронизации и гонит
непрерывный поток значений цвета для пикселей по 24 линиям (8R 8G 8B) в каждую из SSD2828. Частота кадров получается около 20 Гц.

FPGA соединена с микроконтроллером последовательным интерфейсом (SPI), через который микроконтроллер может передавать изображение. Передается оно пакетами, каждый из которых вмещает одну строку изображения (строки считаются по короткой стороне дисплея 1440 пикселей). В пакете кроме этих данных указываются так же номер банка SDRAM, номер строки и контрольная сумма CRC16. FPGA принимает этот пакет, проверяет контрольную сумму и если все в порядке, сохраняет данные в соответствующую область SDRAM. Если CRC не совпадает, FPGA выставляет сигнал на одном из своих выводов, так же соединенном с микроконтроллером, по которому микроконтроллер понимает, что данные не дошли нормально и может повторить отправку. Для полного изображения микроконтроллер должен отправить в FPGA 2560 таких пакетов.

Данные изображения внутри пакета представляются в битовом формате: 1 пиксель светится, 0 пиксель затемнен. Увы, это полностью исключает возможность организации полутонового размытия краев печатаемых слоев антиалиасинга. Чтобы организовать такой способ размытия необходимо переписывать конфигурацию (прошивку) FPGA, к чему я пока не готов. Слишком давно и не очень долго я работал с FPGA, придется практически заново все осваивать.

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

Микросхемы SSD2828 так же подключены к микроконтроллеру по SPI. Это нужно для того, чтобы при включении сконфигурировать их регистры, перевести их в спящий или активный режим.
Имеются еще несколько линий между микроконтроллером и FPGA/SSD2828 сигнал сброса (Reset) и сигналы выбора активного чипа (Chip Select) на каждую из микросхем.

Вообще, эта схема работы довольно далека от оптимальной, на мой взгляд. Было бы, например, логичнее подключить FPGA к микроконтроллеру по параллельному интерфейсу внешней памяти, данные передавались бы гораздо быстрее, чем по SPI с ограничением по частоте в 20 МГц (при повышении частоты FPGA уже перестает нормально принимать данные). Плюс ко всему сигнал сброса заведен не на физический вход Reset FPGA, а как обычный логический сигнал, то есть аппаратного сброса по нему у FPGA не происходит. И это тоже сыграло злую шутку, о которой будет ниже.

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

4.2 Чтение слоев из файла для печати


Ок, с выводом готового изображения более-менее разобрались, теперь я расскажу немного про то как эти изображения добываются из файлов, подготовленных к печати. Файлы форматов .pws, .photons, .photon, .cbddlp это, по сути, куча изображений слоев. Такой формат пошел, насколько я знаю, от китайской компании Chitu, которая и придумала делать платы с такой схемой (мкроконтроллер FPGA SDRAM SSD2828). Предположим, нужно напечатать модель высотой 30 мм с толщиной каждого слоя 0.05 мм. Программа-слайсер нарезает эту модель на слои указанной толщины и для каждого из них формирует его изображение.

Таким образом получается 30/0.05=600 изображений разрешением 1440х2560. Эти изображения упаковываются в выходной файл, туда же вписывается заголовок со всеми параметрами и такой файл уже и попадает в принтер. Изображения слоев имеют глубину цвета 1 бит и сжимаются алгоритмом RLE по одному байту, в котором старший бит указывает значение цвета, а семь младших битов число повторов. Такой способ позволяет сжимать изображение слоя с 460 КБ до примерно 30-50. Принтер считывает сжатый слой, разжимает его и отправляет построчно в FPGA.

У производителя это происходит следующим образом:
1. Читается один байт из файла и распаковывается в байтовый массив если очередной бит равен 1, то и очередному байту присваивается значение 1, иначе значение 0. Так повторяется пока не будет заполнен весь байтовый массив, размер которого равен числу пикселей в строке дисплея (1440), то есть все значения для строки дисплея.
2. Этот байтовый массив передается в функцию, которая упаковывает его опять в битовый массив размером 1440 бит (180 байт).
3. Полученный битовый массив передается в FPGA как данные для строки в составе пакета.

Вот такой трехшаговый способ применили китайцы. Как оказалось, это сделано для того, чтобы изображение слоя можно было выводить в уменьшенном виде на интерфейсный дисплей, показывая пользователю что сейчас печатается. Это изображение как раз и формируется из байтового массива. Хотя что мешало формировать его сразу из раскодированных битов непонятно. И что мешало в этом же цикле формировать битовый массив для передачи в FPGA тоже неясно.

Сейчас у меня используется этот же способ, хотя и оптимизированный. Чтобы пояснить в чем заключалась оптимизация, мне нужно пояснить еще один момент. Данные для строки дисплея идут не сплошным массивом полезных данных. Посередине присутствуют несколько лишних нерабочих пикселя из-за того, что два контроллера дисплея стыкуются именно на короткой стороне, и у каждого из них есть по 24 нарабочих пикселя по краям. Таким образом, реальные передаваемые данные для одной строки изображения состоят из 3 частей: данные для первой половины (первого контроллера), промежуточные нерабочие 48 пикселей, данные для второй половины (второго контроллера).

Так вот, китайцы при формировании байтового массива внутри цикла проверяли достигнут ли конец первой половины, если не достигнут, то значение писалось по указателю *p, а иначе по указателю *(p+48). Эта проверка для каждого из 1440 значений, да еще и модификация указателя для половины из них, явно не способствовали скорости работы цикла. Я разбил этот один цикл на два отдельных в первом заполняется первая половина массива, после этого цикла указатель увеличивается на 48 и начинается второй цикл для второй половины массива. В оригинальном исполнении слой читался и выводился на дисплей за 1.9 секунды, одна только эта модификация снизила время чтения и вывода до 1.2 секунд.

Еще одно изменение касалось передачи данных в FPGA. В оригинальных исходниках она происходит через DMA, но после старта трансфера по DMA функция ожидает его завершения и только после этого начинает декодировать и формировать новую строку изображения. Я убрал это ожидание, так что следующая строка формируется пока данные предыдущей строки передаются. Это уменьшило время еще на 0.3 сек, до 0.9 на слой. И это при компиляции без оптимизации, если скомпилировать с полной оптимизацией, то время уменьшается до примерно 0.53 сек, что уже вполне приемлемо. Из этих 0.53 сек примерно 0.22 сек занимает вычисление CRC16 и около 0,19 сек формирование битового массива из байтового перед передачей. А вот сама передача всех строк в FPGA занимает около 0.4 секунды и с этим, скорее всего, уже ничего не сделать тут все упирается в ограничение максимально допустимой для FPGA частоты SPI.

Если бы самому заняться написанием конфигурации FPGA, то можно было бы отдать ей и разжатие RLE, и это могло бы на порядок ускорить вывод слоя, но как сделано так сделано

И да, я же собирался написать о косяке, связанном с тем, что FPGA не сбрасывается аппаратно по сигналу сброса от микроконтроллера. Так вот, когда я уже научился выводить изображения слоев, доделал сам процесс печати, то столкнулся с непонятным багом один раз из 5-10 печать запускалась с полностью засвеченным дисплеем. Я вижу в отладчике, что слои читаются корректно, данные в FPGA отправляются какие надо, FPGA подтверждает корректность CRC. То есть все работает, а вместо рисунка слоя полностью белый дисплей. Явно виноваты или FPGA или SSD2828. Еще раз перепроверил инициализацию SSD2828 все нормально, все регистры в них инициализируются нужными значениями, это видно при контрольном чтении значений из них. Тогда я уже полез в плату осциллографом. И выяснил, что когда происходит такой сбой, FPGA никакие данные в SDRAM не пишет. Сигнал WE, разрешающий запись, стоит в неактивном уровне как вкопанный. И я бы, наверное, долго бился с этим глюком, если бы не знакомый, который посоветовал попробовать перед сбросом дать в FPGA явную команду отключения вывода изображения, чтобы в момент сброса гарантированно не было обращений от FPGA к SDRAM. Я попробовал и все заработало! Больше этот баг ни разу не проявил себя. В конечном итоге мы с ним пришли к выводу, что корка (IP-core) контроллера SDRAM внутри FPGA имплементирована не совсем правильно, сброс и инициализация контроллера SDRAM происходит нормально не во всех случаях. Что-то мешает правильному сбросу если в этот момент происходит обращение к данным в SDRAM. Вот так

4.3 Пользовательский интерфейс во время печати файла


После того как пользователь выбрал файл и запустил его печать появляется вот такой экран:


Это довольно стандартный экран для подобных фотополимерных принтеров.
Самую большую область экрана занимает картинка засвечиваемого в данный момент слоя. Отображение этой картинки синхронизировано с засветкой когда засветка включается, картинка отображается, при выключении засветки и картинка стирается. Формируется картинка как и для УФ-дисплея по короткой стороне изображения. Я не стал метаться с указателями по смещениям строк этой картинки, а просто перед ее выводом даю контроллеру дисплея команду изменить направление вывода для заливаемых данных, т.е. область этой картинки получается повернутой на бок.

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

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


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

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

Кнопка настроек пока не функционирует, но при нажатии на нее пользователь будет попадать в экран с параметрами печати, которые можно будет изменить время засветки слоя, высота и скорость подъема и т.д. Как раз сейчас я это и допиливаю :) Есть так же мысль дать возможность сохранять измененные параметры обратно в печатаемый файл.

5. Всякая мелочь типа управления засветкой и вентиляторами, загрузки и сохранения настроек и т.п.


На плате есть 3 коммутируемых через мощные MOSFET выхода один для УФ-светодиодов засветки и два для вентиляторов (охлаждение диодов засветки и охлаждение дисплея, например). Тут ничего интересного выходы микроконтроллера подключены к затворам этих транзисторов и управлять ими так же просто, как мигать светодиодом. Для высокой точности выдерживаемого времени засветки она включается в основном цикле через функцию, задающую время работы:
UVLED_TimerOn(l_info.light_time * 1000);voidUVLED_TimerOn(uint32_t time){uvled_timer = time;UVLED_On();}


А выключается из миллисекундного прерывания таймера по достижению счетчика работы засветки нуля:
...if (uvled_timer && uvled_timer != TIMER_DISABLE){uvled_timer--;if (uvled_timer == 0)UVLED_Off();}...


5.1 Настройки, загрузки из файла и сохранение в EEPROM


Настройки хранятся в имеющейся на плате микросхеме EEPROM at24c16. Тут, в отличии от хранения ресурсов в большой флэш-памяти, все просто для каждого типа сохраняемых данных жестко задано смещение адреса внутри EEPROM. Всего в ней сохраняются три блока: настройки оси Z, общие настройки системы (язык, звук и т.д.) и счетчики времени работы основных компонентов принтера засветки, дисплея и вентилятора.

В структурах сохраняемых блоков присутствуют текущая версия прошивки и примитивная контрольная сумма просто 16-битная сумма значений всех байтов блока. При считывании настроек из EPROM проверяется CRC и если он не соответствует реальному, то параметрам этого блока присваиваются значения по умолчанию, высчитывается новый CRC и блок сохраняется в EPROM вместо старого. Если у считанного блока не совпадает с текущей версия, то должно произойти его обновление до текущей версии и уже в новом виде он будет сохранен вместо старого. Это пока не реализовано, но будет в будущем сделано для корректного обновления прошивки.

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

Структура такого файла стандартна: имя параметра + знак равенства + значение параметра. Одна строка один параметр. Пробелы и символы табуляции в начале строки и между знаком равенства и именем и значением игнорируются. Так же игнорируются пустые строки и строки, начинающиеся с символа решетки "#", этот символ определяет строки с комментариями. Регистр букв в именах параметров и разделов значения не имеет.

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

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

Содержимое конфигурационного файла
# Stepper motor Z axis settings
[ZMotor]

# Изменяет направление движения платформы.
# Допустимые значения: 0 или 1. По умолчанию: 1.
# Измените этот параметр если платформа двигается в неверном направлении.
invert_dir = 1

# Направление движения платформы при поиске домашней позиции.
# Допустимые значения: -1 или 1. По умолчанию: -1.
# Если этот параметр равен -1, то при поиске домашней позиции
# платформа будет двигаться вниз, к нижнему концевику. При значении 1
# платформа будет двигаться к верхнему концевику.
home_direction = -1

# Значение оси Z после поиска домашней позиции. Как правило, для нижнего
# домашнего концевика это 0, для верхнего - максимальная высота оси.
home_pos = 0.0

# Ограничение на минимальную допустимую нижнюю позицию платформы в миллиметрах.
# Допустимые значения: число в диапазоне от -32000.0 до 32000.0.
# По умолчанию: -3.0
# Это ограничение действует только после нахождения домашней позиции. Если
# поиск домашней позиции не производился, то движение ограничивается концевиками.
min_pos = -3.0

# Ограничение на максимальную допустимую верхнюю позицию платформы в миллиметрах.
# Допустимые значения: число в диапазоне от -32000.0 до 32000.0.
# По умолчанию: 180.0
# Это ограничение действует только после нахождения домашней позиции. Если
# поиск домашней позиции не производился, то движение ограничивается концевиками.
max_pos = 180.0

# Работа нижнего концевика.
# Допустимые значения: 0 или 1. По умолчанию: 1.
# Если при срабатывании концевика напряжение на его выходе пропадает, то поставьте
# значение 1, если наоборот - поставьте 0.
min_endstop_inverting = 1

# Работа верхнего концевика.
# Допустимые значения: 0 или 1. По умолчанию: 1.
# Если при срабатывании концевика напряжение на его выходе пропадает, то поставьте
# значение 1, если наоборот - поставьте 0.
max_endstop_inverting = 1

# Количество шагов двигателя на 1 мм движения платформы.
steps_per_mm = 1600

# Скорость первого, быстрого движения к концевику при поиске домашней
# позиции, мм/сек. По умолчанию: 6.0.
homing_feedrate_fast = 6.0

# Скорость второго, медленного движения к концевику при поиске домашней
# позиции, мм/сек. По умолчанию: 1.0.
homing_feedrate_slow = 1.0

# Ускорение платформы в режиме печати, мм/сек2.
acceleration = 0.7

# Скорость движения платформы в режиме печати, мм/сек.
feedrate = 5.0

# Ускорение платформы в режиме свободного движения (движение кнопками из интерфейса,
# подъем по окончании печати и т.п.), мм/сек2.
travel_acceleration = 25.0

# Ускорение платформы в режиме свободного движения (движение кнопками из интерфейса,
# подъем по окончании печати и т.п.), мм/сек. На высоте менее 30 мм платформа
# двигается в три раза медленнее заданной в этом параметре скорости, но не менее
# 5 мм/сек.
travel_feedrate = 25.0

# Ток двигателя для интегрированного в плату драйвера, мА.
current_vref = 800.0

# Ток двигателя для интегрированного в плату драйвера в режиме удержания, мА.
current_hold_vref = 300.0

# Время с момента последнего движения двигателя, после которого включается режим
# удержания с пониженным током. Задается в секундах. Значение 0 отключает режим
# удержания с пониженным током.
hold_time = 30.0

# Время с момента последнего движения двигателя, после которого мотор полностью
# отключается. Задается в секундах. Значение этого параметра должно быть не меньше
# значения параметра hold_time. Значение 0 отключает этот режим.
# Следует учесть, что при отключении мотора теряется домашняя позиция.
off_time = 10.0

# General settings
[General]

# Длительность звука зуммера в миллисекундах (0.001 сек) при окончании печати
# или при выводе сообщений об ошибках.
# Допустимые значения: от 0 до 15000. По умолчанию: 700 (0.7 сек).
buzzer_msg_duration = 700

# Длительность звука зуммера в миллисекундах (0.001 сек) при нажатии
# на активную зону сенсорного дисплея, например на кнопку.
# Допустимые значения: от 0 до 15000. По умолчанию: 70 (0.07 сек).
buzzer_touch_duration = 70

# Переворачивает изображение на интерфейсном дисплее на 180 градусов.
# Служит для возможности переворота дисплея в принтере для более удобного его размещения.
# Допустимые значения: 0 или 1. По умолчанию: 0.
rotate_display = 0

# Время перехода дисплея в режим скринсейвера с отображением времени и даты, задается в минутах.
# Скринсейвер эмулирует настольные LCD-часы. Переход обратно в рабочий режим - нажатие в любом
# месте дисплея.
# Допустимые значения: от 0 до 15000. По умолчанию: 10. Значение 0 отключает режим скринсейвера.
screensaver_time = 10


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


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

Если кому будет интересно - тут полная простыня трех главных функций парсера
void_cfg_GetParamName(char *src, char *dest, uint16_t maxlen){if (src == NULL || dest == NULL)return;char *string = src;// skip spaceswhile (*string != 0 && maxlen > 0 && (*string == ' ' || *string == '\t' || *string == '\r')){string++;maxlen--;}// until first space symbolwhile (maxlen > 0 && *string != 0 && *string != ' ' && *string != '\t' && *string != '\r' && *string != '\n' && *string != '='){*dest = *string;dest++;string++;maxlen--;}if (maxlen == 0)dest--;*dest = 0;return;}//==============================================================================void_cfg_GetParamValue(char *src, PARAM_VALUE *val){val->type = PARAMVAL_NONE;val->float_val = 0;val->int_val = 0;val->uint_val = 0;val->char_val = (char*)"";if (src == NULL)return;if (val == NULL)return;char *string = src;// search '='while (*string > 0 && *string != '=')string++;if (*string == 0)return;// skip '='string++;// skip spaceswhile (*string != 0 && (*string == ' ' || *string == '\t' || *string == '\r'))string++;if (*string == 0)return;// check param if it numericif ((*string > 47 && *string < 58) || *string == '.' || (*string == '-' && (*(string+1) > 47 && *(string+1) < 58) || *(string+1) == '.')){val->type = PARAMVAL_NUMERIC;val->float_val = (float)atof(string);val->int_val = atoi(string);val->uint_val = strtoul(string, NULL, 10);}else{val->type = PARAMVAL_STRING;val->char_val = string;}return;}//==============================================================================voidCFG_LoadFromFile(void *par1, void *par2){sprintf(msg, LANG_GetString(LSTR_MSG_CFGFILE_LOADING), cfgCFileName);TGUI_MessageBoxWait(LANG_GetString(LSTR_WAIT), msg);UTF8ToUnicode_Str(cfgTFileName, cfgCFileName, sizeof(cfgTFileName)/2);if (f_open(&ufile, cfgTFileName, FA_OPEN_EXISTING | FA_READ) != FR_OK){if (tguiActiveScreen == (TG_SCREEN*)&tguiMsgBox)tguiActiveScreen = (TG_SCREEN*)((TG_MSGBOX*)tguiActiveScreen)->prevscreen;TGUI_MessageBoxOk(LANG_GetString(LSTR_ERROR), LANG_GetString(LSTR_MSG_FILE_OPEN_ERROR));BUZZ_TimerOn(cfgConfig.buzzer_msg);return;}uint16_tcnt = 0;uint32_treaded = 0, totalreaded = 0;char*string = msg;charlexem[128];PARAM_VALUEpval;CFGREAD_STATErdstate = CFGR_GENERAL;int16_tnumstr = 0;while (1){// read one stringcnt = 0;readed = 0;string = msg;while (cnt < sizeof(msg)){if (f_read(&ufile, string, 1, &readed) != FR_OK || readed == 0 || *string == '\n'){*string = 0;break;}cnt++;string++;totalreaded += readed;}if (cnt == sizeof(msg)){string--;*string = 0;}numstr++;string = msg;// trim spaces/tabs at begin and endstrtrim(string);// if string is emptyif (*string == 0){// if end of fileif (readed == 0)break;elsecontinue;}// skip commentsif (*string == '#')continue;// upper all lettersstrupper_utf(string);// get parameter name_cfg_GetParamName(string, lexem, sizeof(lexem));// check if here section nameif (*lexem == '['){if (strcmp(lexem, (char*)"[ZMOTOR]") == 0){rdstate = CFGR_ZMOTOR;continue;}else if (strcmp(lexem, (char*)"[GENERAL]") == 0){rdstate = CFGR_GENERAL;continue;}else{rdstate = CFGR_ERROR;string = LANG_GetString(LSTR_MSG_UNKNOWN_SECTNAME_IN_CFG);sprintf(msg, string, numstr);break;}}// get parameter value_cfg_GetParamValue(string, &pval);if (pval.type == PARAMVAL_NONE){rdstate = CFGR_ERROR;string = LANG_GetString(LSTR_MSG_INVALID_PARAMVAL_IN_CFG);sprintf(msg, string, numstr);break;}// check and setup parameterswitch (rdstate){case CFGR_ZMOTOR:rdstate = CFGR_ERROR;if (*lexem == 'A'){if (strcmp(lexem, (char*)"ACCELERATION") == 0){if (pval.type != PARAMVAL_NUMERIC){string = LANG_GetString(LSTR_MSG_INVALID_PARAMVAL_IN_CFG);sprintf(msg, string, numstr);break;}if (pval.float_val < 0.1)pval.float_val = 0.1;cfgzMotor.acceleration = pval.float_val;rdstate = CFGR_ZMOTOR;break;}} elseif (*lexem == 'C'){if (strcmp(lexem, (char*)"CURRENT_HOLD_VREF") == 0){if (pval.type != PARAMVAL_NUMERIC){string = LANG_GetString(LSTR_MSG_INVALID_PARAMVAL_IN_CFG);sprintf(msg, string, numstr);break;}if (pval.uint_val < 100)pval.uint_val = 100;if (pval.uint_val > 1000)pval.uint_val = 1000;cfgzMotor.current_hold_vref = pval.uint_val;rdstate = CFGR_ZMOTOR;break;}if (strcmp(lexem, (char*)"CURRENT_VREF") == 0){if (pval.type != PARAMVAL_NUMERIC){string = LANG_GetString(LSTR_MSG_INVALID_PARAMVAL_IN_CFG);sprintf(msg, string, numstr);break;}if (pval.uint_val < 100)pval.uint_val = 100;if (pval.uint_val > 1000)pval.uint_val = 1000;cfgzMotor.current_vref = pval.uint_val;rdstate = CFGR_ZMOTOR;break;}} elseif (*lexem == 'F'){if (strcmp(lexem, (char*)"FEEDRATE") == 0){if (pval.type != PARAMVAL_NUMERIC){string = LANG_GetString(LSTR_MSG_INVALID_PARAMVAL_IN_CFG);sprintf(msg, string, numstr);break;}if (pval.float_val < 0.1)pval.float_val = 0.1;if (pval.float_val > 40)pval.float_val = 40;cfgzMotor.feedrate = pval.float_val;rdstate = CFGR_ZMOTOR;break;}} elseif (*lexem == 'H'){if (strcmp(lexem, (char*)"HOLD_TIME") == 0){if (pval.type != PARAMVAL_NUMERIC){string = LANG_GetString(LSTR_MSG_INVALID_PARAMVAL_IN_CFG);sprintf(msg, string, numstr);break;}if (pval.uint_val == 0)pval.uint_val = TIMER_DISABLE;else if (pval.uint_val > 100000)pval.uint_val = 100000;cfgzMotor.hold_time = pval.uint_val * 1000;rdstate = CFGR_ZMOTOR;break;}if (strcmp(lexem, (char*)"HOME_DIRECTION") == 0){if (pval.type != PARAMVAL_NUMERIC){string = LANG_GetString(LSTR_MSG_INVALID_PARAMVAL_IN_CFG);sprintf(msg, string, numstr);break;}if (pval.int_val != -1.0 && pval.int_val != 1.0)pval.int_val = -1;cfgzMotor.home_dir = pval.int_val;rdstate = CFGR_ZMOTOR;break;}if (strcmp(lexem, (char*)"HOME_POS") == 0){if (pval.type != PARAMVAL_NUMERIC){string = LANG_GetString(LSTR_MSG_INVALID_PARAMVAL_IN_CFG);sprintf(msg, string, numstr);break;}cfgzMotor.home_pos = pval.float_val;rdstate = CFGR_ZMOTOR;break;}if (strcmp(lexem, (char*)"HOMING_FEEDRATE_FAST") == 0){if (pval.type != PARAMVAL_NUMERIC){string = LANG_GetString(LSTR_MSG_INVALID_PARAMVAL_IN_CFG);sprintf(msg, string, numstr);break;}if (pval.float_val < 0.1)pval.float_val = 0.1;if (pval.float_val > 40)pval.float_val = 40;cfgzMotor.homing_feedrate_fast = pval.float_val;rdstate = CFGR_ZMOTOR;break;}if (strcmp(lexem, (char*)"HOMING_FEEDRATE_SLOW") == 0){if (pval.type != PARAMVAL_NUMERIC){string = LANG_GetString(LSTR_MSG_INVALID_PARAMVAL_IN_CFG);sprintf(msg, string, numstr);break;}if (pval.float_val < 0.1)pval.float_val = 0.1;if (pval.float_val > 40)pval.float_val = 40;cfgzMotor.homing_feedrate_slow = pval.float_val;rdstate = CFGR_ZMOTOR;break;}} elseif (*lexem == 'I'){if (strcmp(lexem, (char*)"INVERT_DIR") == 0){if (pval.type != PARAMVAL_NUMERIC){string = LANG_GetString(LSTR_MSG_INVALID_PARAMVAL_IN_CFG);sprintf(msg, string, numstr);break;}if (pval.int_val < 0 || pval.int_val > 1)pval.int_val = 1;cfgzMotor.invert_dir = pval.int_val;rdstate = CFGR_ZMOTOR;break;}} elseif (*lexem == 'M'){if (strcmp(lexem, (char*)"MAX_ENDSTOP_INVERTING") == 0){if (pval.type != PARAMVAL_NUMERIC){string = LANG_GetString(LSTR_MSG_INVALID_PARAMVAL_IN_CFG);sprintf(msg, string, numstr);break;}if (pval.int_val < 0 || pval.int_val > 1)pval.int_val = 1;cfgzMotor.max_endstop_inverting = pval.int_val;rdstate = CFGR_ZMOTOR;break;}if (strcmp(lexem, (char*)"MAX_POS") == 0){if (pval.type != PARAMVAL_NUMERIC){string = LANG_GetString(LSTR_MSG_INVALID_PARAMVAL_IN_CFG);sprintf(msg, string, numstr);break;}cfgzMotor.max_pos = pval.float_val;rdstate = CFGR_ZMOTOR;break;}if (strcmp(lexem, (char*)"MIN_ENDSTOP_INVERTING") == 0){if (pval.type != PARAMVAL_NUMERIC){string = LANG_GetString(LSTR_MSG_INVALID_PARAMVAL_IN_CFG);sprintf(msg, string, numstr);break;}if (pval.int_val < 0 || pval.int_val > 1)pval.int_val = 1;cfgzMotor.min_endstop_inverting = pval.int_val;rdstate = CFGR_ZMOTOR;break;}if (strcmp(lexem, (char*)"MIN_POS") == 0){if (pval.type != PARAMVAL_NUMERIC){string = LANG_GetString(LSTR_MSG_INVALID_PARAMVAL_IN_CFG);sprintf(msg, string, numstr);break;}cfgzMotor.min_pos = pval.float_val;rdstate = CFGR_ZMOTOR;break;}} elseif (*lexem == 'O'){if (strcmp(lexem, (char*)"OFF_TIME") == 0){if (pval.type != PARAMVAL_NUMERIC){string = LANG_GetString(LSTR_MSG_INVALID_PARAMVAL_IN_CFG);sprintf(msg, string, numstr);break;}if (pval.uint_val > 100000)pval.uint_val = 100000;else if (pval.uint_val < cfgzMotor.hold_time)pval.uint_val = cfgzMotor.hold_time + 1000;else if (pval.uint_val == 0)pval.uint_val = TIMER_DISABLE;cfgzMotor.off_time = pval.int_val * 60000;rdstate = CFGR_ZMOTOR;break;}} elseif (*lexem == 'S'){if (strcmp(lexem, (char*)"STEPS_PER_MM") == 0){if (pval.type != PARAMVAL_NUMERIC){string = LANG_GetString(LSTR_MSG_INVALID_PARAMVAL_IN_CFG);sprintf(msg, string, numstr);break;}if (pval.uint_val < 1)pval.uint_val = 1;if (pval.uint_val > 200000)pval.uint_val = 200000;cfgzMotor.steps_per_mm = pval.uint_val;rdstate = CFGR_ZMOTOR;break;}} elseif (*lexem == 'T'){if (strcmp(lexem, (char*)"TRAVEL_ACCELERATION") == 0){if (pval.type != PARAMVAL_NUMERIC){string = LANG_GetString(LSTR_MSG_INVALID_PARAMVAL_IN_CFG);sprintf(msg, string, numstr);break;}if (pval.float_val < 0.1)pval.float_val = 0.1;cfgzMotor.travel_acceleration = pval.float_val;rdstate = CFGR_ZMOTOR;break;}if (strcmp(lexem, (char*)"TRAVEL_FEEDRATE") == 0){if (pval.type != PARAMVAL_NUMERIC){string = LANG_GetString(LSTR_MSG_INVALID_PARAMVAL_IN_CFG);sprintf(msg, string, numstr);break;}if (pval.float_val < 0.1)pval.float_val = 0.1;cfgzMotor.travel_feedrate = pval.float_val;rdstate = CFGR_ZMOTOR;break;}}string = LANG_GetString(LSTR_MSG_UNKNOWN_PARAMNAME_IN_CFG);sprintf(msg, string, numstr);break;case CFGR_GENERAL:rdstate = CFGR_ERROR;if (*lexem == 'B'){if (strcmp(lexem, (char*)"BUZZER_MSG_DURATION") == 0){if (pval.type != PARAMVAL_NUMERIC){string = LANG_GetString(LSTR_MSG_INVALID_PARAMVAL_IN_CFG);sprintf(msg, string, numstr);break;}if (pval.uint_val > 15000)pval.uint_val = 15000;cfgConfig.buzzer_msg = pval.uint_val;rdstate = CFGR_GENERAL;break;}if (strcmp(lexem, (char*)"BUZZER_TOUCH_DURATION") == 0){if (pval.type != PARAMVAL_NUMERIC){string = LANG_GetString(LSTR_MSG_INVALID_PARAMVAL_IN_CFG);sprintf(msg, string, numstr);break;}if (pval.uint_val > 15000)pval.uint_val = 15000;cfgConfig.buzzer_touch = pval.uint_val;rdstate = CFGR_GENERAL;break;}} elseif (*lexem == 'R'){if (strcmp(lexem, (char*)"ROTATE_DISPLAY") == 0){if (pval.type != PARAMVAL_NUMERIC){string = LANG_GetString(LSTR_MSG_INVALID_PARAMVAL_IN_CFG);sprintf(msg, string, numstr);break;}if (pval.uint_val > 0){cfgConfig.display_rotate = 1;LCD_WriteCmd(0x0036);LCD_WriteRAM(0x0078);}else{cfgConfig.display_rotate = 0;LCD_WriteCmd(0x0036);LCD_WriteRAM(0x00B8);}rdstate = CFGR_GENERAL;break;}} elseif (*lexem == 'S'){if (strcmp(lexem, (char*)"SCREENSAVER_TIME") == 0){if (pval.type != PARAMVAL_NUMERIC){string = LANG_GetString(LSTR_MSG_INVALID_PARAMVAL_IN_CFG);sprintf(msg, string, numstr);break;}if (pval.uint_val > 15000)cfgConfig.screensaver_time = 15000 * 60000;else if (pval.uint_val == 0)pval.uint_val = TIMER_DISABLE;elsecfgConfig.screensaver_time = pval.uint_val * 60000;rdstate = CFGR_GENERAL;break;}}string = LANG_GetString(LSTR_MSG_UNKNOWN_PARAMNAME_IN_CFG);sprintf(msg, string, numstr);break;}if (rdstate == CFGR_ERROR)break;}f_close(&ufile);if (tguiActiveScreen == (TG_SCREEN*)&tguiMsgBox){tguiActiveScreen = (TG_SCREEN*)((TG_MSGBOX*)tguiActiveScreen)->prevscreen;}if (rdstate == CFGR_ERROR){TGUI_MessageBoxOk(LANG_GetString(LSTR_ERROR), msg);BUZZ_TimerOn(cfgConfig.buzzer_msg);}else{CFG_SaveMotor();CFG_SaveConfig();TGUI_MessageBoxOk(LANG_GetString(LSTR_COMPLETED), LANG_GetString(LSTR_MSG_CFGFILE_LOADED));}}//==============================================================================



После успешного парсинга файла новые настройки сразу же применяются и сохраняются в EPROM.

Счетчики часов наработки компонентов принтера обновляются в EPROM только по окончании или прерывании печати файла.

6. Дополнительные возможности для комфорта и удобства.


6.1 Часы с календарем.



Ну, просто чтобы было :) Зачем пропадать добру встроенным в микроконтроллер автономным часам реального времени, которые умеют работать от литиевой батарейки при выключенном общем питании и потребляют так мало, что CR2032 по расчетам должно хватать на несколько лет :) Тем более, что производитель даже предусмотрел на плате требующийся этим часам кварц на 32 кГц. Осталось только приклеить на плату держатель батарейки и припаять от него проводки на общий минус и на специальный вывод микроконтроллера, что я у себя и сделал.

Время, число и месяц отображаются слева вверху на главном экране:


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

6.2 Блокировка экрана от случайных нажатий во время печати.


Это было сделано по просьбе одного из знакомых. Ну, почему бы и нет, может быть полезным в некоторых случаях :) Блокировка включается и отключается длительным нажатием (~2.5 сек) на заголовке экрана печати. Когда блокировка активна, в правом верхнем углу отображается красный замочек. При окончании печати блокировка автоматически отключается.

6.3 Уменьшение тока двигателя в режиме удержания, отключение двигателя по простою


Сделано для уменьшения общего нагрева внутри корпуса принтера. Двигатель может быть переведен в режим удержания с уменьшенным током после заданного в настройках времени отсутствия движения. Такая возможность, кстати, широко распространена во взрослых драйверах шаговых двигателей типа TB6560. Кроме того, в настройках можно задать время по истечении которого при отсутствии движения мотор будет полностью обесточен. Но это приведет и к тому, что обнуление оси, если оно проводилось, станет недействительным. Обе эти возможности можно и полностью отключить в тех же настройках.

6.4 Скринсейвер.


Как и часы просто потому что могу :) При отсутствии нажатий на экран через заданное в настройках время экран переходит в режим эмуляции цифровых настольных часов:


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


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

6.5 Проверка засветки и дисплея.




В этот экран можно попасть из меню Сервис и это будет полезно при проверке диодов засветки или УФ-дисплея. Вверху выбирается одно из трех изображений, которое будет отображено на УФ-дисплее рамка, полная засветка всего дисплея, прямоугольники. Внизу две кнопки, включающие и отключающие засветку и дисплей. Включенная засветка автоматически отключится через 2 минуты, обычно такого времени достаточно для любой проверки. При выходе из этого экрана автоматически будут выключены и засветка и дисплей.

6.6 Настройки.




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

Конечно же, здесь можно выставить время и дату (раз уж есть часы) в открывающемся отдельно экране:


Можно настроить высоту подъема платформы на паузе и включить и выключить звук нажатий дисплея и сообщений. При изменении настроек новые значения будут действовать только до выключения питания и не будут сохранены в EPROM. Чтобы они сохранились нужно после изменения параметров нажать в меню кнопку сохранения (с иконкой дискеты).

Ввод числовых значений производится в специальном экране:


Тут я реализовал все те возможности, которых мне не хватало в других принтерах.
1. Кнопки "" и "." работают только если редактируемый параметр может быть отрицательным или дробным соответственно.
2. Если после входа в этот экран первой будет нажата любая цифровая кнопка, то старое значение заменится соответствующей цифрой. Если кнопка ".", то заменится на 0.. То есть нет необходимости стирать старое значение, можно сразу начинать вводить новое.
3. Кнопка АС, обнуляющая текущее значение.
При нажатии кнопки Назад новое значение не применится. Чтобы его применить, нужно нажать ОК.

6.7 И последнее экран с информацией о принтере




Этот экран доступен прямо из главного меню. Самое важное тут это версия прошивки/FPGA и счетчики наработки. Внизу еще притулилась информация об авторе интерфейса и адрес репозитория на Гитхабе. Автор интерфейса это задел на будущее. Если я все же сделаю возможность конфигурации интерфейса через простой текстовый файл, то там будет возможность указать имя автора.

Конец


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

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

Жду вопросы и замечания, и спасибо за интерес к этим статьям :)

Ссылки


Комплект MKS DLP на Алиэкспресс
Исходники оригинальной прошивки от производителя на Гитхабе
Схемы от производителя двух версий платы на Гитхабе
Мои исходники на Гитхабе
Подробнее..

Из песочницы Aspect Oriented Programming (AOP) через исходный код

27.09.2020 14:13:04 | Автор: admin


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

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

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

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

По моим ощущениям, в экосистеме .Net, аспектно-ориентированное программирование существенно менее популярно в сравнении с экосистемой Java. Я думаю, что основная причина это отсутствие бесплатного и открытого инструментария, сравнимого с функциональностью и качеством такого в Java.

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

Можно сравнить возможности инструментов (надо иметь в виду что сравнение сделано владельцем PostSharp, но некоторую картину оно даёт).

Наш путь к аспектно-ориентированному программированию


Мы небольшая консалтинговая компания (12 человек) и конечным результатом нашей работы является исходный код. Т.е. нам платят за то что мы создаём исходный код, качественный код. Мы работаем только в одной индустрии и многие наши проекты имеют очень похожие требования и как результат, исходный код также достаточно похож между этими проектам.

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

Для достижения этого, один из способов заключается в том, что мы интенсивно используем возможности автоматической генерации кода, а также создали несколько кастомных плагинов и анализаторов для Visual Studio специфичных для наших проектов и задач. Это позволило существенно увеличить производительность программистов, одновременно сохранив высокое качество кода (можно даже сказать, что качество стало выше).

Следующим логическим шагом, была идея внедрить использование аспектно-ориентированного программирования. Мы попробовали несколько подходов и инструментов, но результат был далёк от наших ожиданий. По времени это совпало с выходом технологии Roslyn и в определённый момент у нас родилась идея объединить возможности автоматической генерации кода и Roslyn.

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

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

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

Как это было бы сделано в идеальном мире


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

В моём виденьи идеального мира, спецификации языка позволяют использование трансформации исходного кода и существует поддержка компилятором и IDE.

Идея была навеяна включением модификатора partial в спецификации языка C#. Эта, достаточно простая концепция (возможность определения класса, структуры или интерфейса в нескольких файлах) кардинально улучшила и упростила поддержку инструментов для автоматической генерации исходного код. Т.е. это своего рода горизонтальная разбивка исходного кода класса между несколькими файлами. Для тех кто не знает языка C#, небольшой пример.

Предположим у нас есть простая форма описанная в файле Example1.aspx
<%@ Page Language="C#" AutoEventWireup="True" %>// . . .<asp:Button id="btnSubmit"           Text="Submit"           OnClick=" btnSubmit_Click"            runat="server"/>// . . .

И пользовательская логика (например изменение цвета кнопки на красный при её нажатии) в файле Example1.aspx.cs

public partial class ExamplePage1 : System.Web.UI.Page, IMyInterface{  protected void btnSubmit_Click(Object sender, EventArgs e)   {    btnSubmit.Color = Color.Red;  }}

Наличие в языке возможностей предоставляемых partial позволяет инструментарию распарсить файл Example1.aspx и автоматически сгенерировать файл Example1.aspx.designer.cs

public partial class ExamplePage1 : System.Web.UI.Page{  protected global::System.Web.UI.WebControls.Button btnSubmit;}

Т.е. мы имеем возможность хранить часть кода для класса ExamplePage1 в одном файле обновляемым программистом (Example1.aspx.cs) и часть в файле Example1.aspx.designer.cs автоматически генерируемым инструментарием. Для компилятора же это выглядит в конце концов как один общий класс

public class ExamplePage1 : System.Web.UI.Page, IMyInterface{   protected global::System.Web.UI.WebControls.Button btnSubmit;  protected void btnSubmit_Click(Object sender, EventArgs e)   {    btnSubmit.Color = Color.Red;  }}

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

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

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

Первый модификатор это original и его добавляем в определение класса который должен иметь возможность быть трансформирован.

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

Последовательность примерно такая

  1. Пользователь работает с исходным кодом класса который содержит модификатор original в файле .cs (например Example1.cs)
  2. При компиляции, компилятор проверяет корректность исходного кода и если класс успешно скомпилировался, идёт проверка на наличие original
  3. Если original присутствует, то компилятор отдаёт исходный код этого файла процессу трансформации (который является чёрным ящиком для компилятора).
  4. Процесс трансформации базируясь на наборе правил выполняет модифицирование исходного код и при успешном завершении процесса создаёт файлы файла .processed.cs и файла .processed.cs.map (для соответствия кода между файлами .cs и файла .processed.cs, для помощи при отладки и для корректного отображения в IDE)
  5. Компилятор получает код из файла .processed.cs (в нашем примере это Example1.processed.cs) и компилирует уже этот код.
  6. Если код в файле успешно скомпилировался, то идёт проверка что

    a. Классы которые имели модификатор original имеют модификатор processed
    b. Сигнатура этих классов идентична как в файле .cs так и в файле .processed.cs
  7. Если всё нормально, то байт код полученный при компиляции файла .processed.cs включается в объектный файл для дальнейшего использования.

Т.е. добавив эти два модификатора, мы смогли на уровне языка организовать поддержку инструментов трансформации исходного кода, подобно тому как partial позволил упростить поддержку генерации исходного кода. Т.е. parial это горизонтальное разбитие кода, original/processed вертикальное.

Как мне видится, реализовать поддержку original/processed в компиляторе это неделя работы для двух интернов в компании Микрософт (шутка конечно, но она не далека от истины). По большому счёту, в этой задаче нету никаких фундаментальных сложностей, с точки зрения компилятора это манипуляция файлами и вызов процесса.

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

Пример подобного процесса. Пользователь создаёт файл Example2.cs
public original class ExamplePage2 : System.Web.UI.Page, IMyInterface{   protected global::System.Web.UI.WebControls.Button btnSubmit;  protected void btnSubmit_Click(Object sender, EventArgs e)   {    btnSubmit.Color = Color.Red;  }}

Запускает на компиляцию, если всё прошло ок и компилятор видит модификатор original, то отдаёт исходный код, процессу трансформации, который генерирует файл Example2.processed.cs (в самом простейшем случае это может быть просто точная копия Example2.cs с заменённым original на processed).

В нашем случае мы предположим, что процесс трансформации добавил аспект логирования и результат выглядит как:
public processed class ExamplePage2 : System.Web.UI.Page, IMyInterface{   protected global::System.Web.UI.WebControls.Button btnSubmit;  protected void btnSubmit_Click(Object sender, EventArgs e)   {    try    {      btnSubmit.Color = Color.Red;    }     catch(Exception ex)    {      ErrorLog(ex);      throw;    }    SuccessLog();  }  private static processed ErrorLog(Exception ex)  {    // some error logic here  }  private static processed SuccessLog([System.Runtime.CompilerServices.CallerMemberName] string memberName = "")  {    // some success logic here  }}

Следующий шаг, это проверка сигнатур. _Основные_ сигнатуры идентичны и удовлетворяют условию что определения в original и processed должны быть абсолютно одинаковы.

В этот пример я специально добавил ещё одно небольшое предложение, это модификатор processed для методов, свойств и полей.

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

Компилятор скомпилировал этот код и если всё ок, то взял байт код для продолжения процесса.

Понятно, что в данном примере идёт некоторое упрощение и в реальности логика может быть сложнее (например когда мы включаем оба original и partial для одного класса), но это не непреодолимая сложность.

Основная функциональность IDE в идеальном мире


Поддержка работы с исходным кодом файлов .processed.cs в IDE заключается в основном в корректной навигации между original/processed классами и переходов при пошаговой отладки.

Вторая по важности функция IDE (с моей точки зрения) это помощь в чтении кода processed классов. Processed класс может содержать множество частей кода, которые были добавлены несколькими аспектами. Реализация отображения которая похожа на концепцию слоёв в графическом редакторе представляется нам самым удобным вариантом для достижения данной цели. Наш текущей плагин реализует нечто подобное и реакция его пользователей вполне положительна.

Ещё одна функция которая бы сделала помогла внедрению AOP в повседневную жизнь, это функциональность refactoring, т.е. пользователь выделив часть кода мог бы сказать Extract To AOP Template и IDE создала правильные файлы, сгенерировала первоначальный код и проанализировав код проекта предложила кандидатов на использование шаблона из других классов.

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

Я уверен, что если за делов возьмутся создатели решарпера, то магия обеспечена.

Написание кода аспекта в идеальном мире


Если перефразировать ТРИЗ, то идеальное написание кода для реализации аспектов, это отсутствие написания дополнительного кода, который существует только для поддержки процессов инструментария.

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

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

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

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

Наша текущая реализация


К сожалению мы живём не в идеальном мире, поэтому приходится изобретать велосипеды и ездить на них.

Внедрение кода, компиляция и отладка


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

Сценарий примерно такой

  • Программист работает с оригинальной версией кода, реализует логику, компилирует проект чтобы обнаружить ошибки времени компиляции и т.п.
  • Когда он удовлетворён результатом и готов протестировать результаты своего труда, то запускается скрипт командной строки, который запускает процесс трансформации файлов и при успешной завершении трансформации, запускает процесс сборки.
  • После завершения сборки в зависимости от типа проекта запускается либо браузер, который открывает тестовый веб сайт для веб проекта, либо десктоп программа, если это WPF проект, либо автотесты и т.п.

Для отладки запускается вторая копия IDE, открывается странсформированная копия проекта и он работает с копией к которой была применена трансформация.

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

IDE


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

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

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

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

Конфигурация


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

Мы используем несколько уровней.

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

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

Третий на уровне блока кода и четвёртый это явное указание на включение результатов трансформации шаблона в конкретное место в исходном коде.

Шаблоны


Исторически сложилось что для целей автоматической генерации мы используем шаблоны в формате T4, поэтому вполне логично было использовать этот же подход и в качестве шаблонов для трансформации. Шаблоны T4 включают в себя возможность исполнять произвольный код на C#, имеют минимальный оверхэд и хорошую выразительность.

Для тех кто никогда не работал с T4, самым простым аналогом будет представить ASPX формат, который вместо HTML генерирует исходный код на C# и исполняется не на IIS, а отдельной утилитой с выводом результата на консоль (или в файл).

Примеры


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

Исходный код примера перед трансформацией
// ##aspect=AutoCommentusing AOP.Common;using Microsoft.Extensions.Configuration;using System;using System.Collections.Generic;using System.IO;using System.Linq;namespace Aspectimum.Demo.Lib{    [AopTemplate("ClassLevelTemplateForMethods", NameFilter = "First")]    [AopTemplate("StaticAnalyzer", Action = AopTemplateAction.Classes)]    [AopTemplate("DependencyInjection", AdvicePriority = 500, Action = AopTemplateAction.PostProcessingClasses)]    [AopTemplate("ResourceReplacer", AdvicePriority = 1000, ExtraTag = "ResourceFile=Demo.resx,ResourceClass=Demo", Action = AopTemplateAction.PostProcessingClasses)]    public class ConsoleDemo    {        public virtual Person FirstDemo(string firstName, string lastName, int age)        {            Console.Out.WriteLine("FirstDemo: 1");            // ##aspect="FirstDemoComment" extra data here            return new Person()            {                FirstName = firstName,                LastName = lastName,                Age = age,            };        }        private static IConfigurationRoot _configuration = inject;        private IDataService _service { get; } = inject;        private Person _somePerson = inject;        [AopTemplate("LogExceptionMethod")]        [AopTemplate("StopWatchMethod")]        [AopTemplate("MethodFinallyDemo", AdvicePriority = 100)]        public Customer[] SecondDemo(Person[] people)        {            IEnumerable<Customer> Customers;            Console.Out.WriteLine("SecondDemo: 1");            Console.Out.WriteLine(i18("SecondDemo: i18"));            int configDelayMS = inject;            string configServerName = inject;            using (new AopTemplate("SecondDemoUsing", extraTag: "test extra"))            {                Customers = people.Select(s => new Customer()                {                    FirstName = s.FirstName,                    LastName = s.LastName,                    Age = s.Age,                    Id = s.Id                });                _service.Init(Customers);                foreach (var customer in Customers)                {                    Console.Out.WriteLine(i18($"First Name {customer.FirstName} Last Name {customer.LastName}"));                    Console.Out.WriteLine("SecondDemo: 2 " + i18("First Name ") + customer.FirstName + i18(" Last Name   ") + customer.LastName);                }            }            Console.Out.WriteLine(i18(String.Format("SecondDemo: {0}", "3")));            Console.Out.WriteLine($"Server {configServerName} default delay {configDelayMS}");            Console.Out.WriteLine($"Customer for ID=5 is {_service.GetCustomerName(5)}");            return Customers.ToArray();        }        protected static string i18(string s) => s;        protected static dynamic inject;        [AopTemplate("NotifyPropertyChangedClass", Action = AopTemplateAction.Classes)]        [AopTemplate("NotifyPropertyChanged", Action = AopTemplateAction.Properties)]        public class Person        {            [AopTemplate("CacheProperty", extraTag: "{ \"CacheKey\": \"name_of_cache_key\", \"ExpiresInMinutes\": 10 }")]            public string FullName            {                get                {                    // ##aspect="FullNameComment" extra data here                    return $"{FirstName} {LastName}";                }            }            public int Id { get; set; }            public string FirstName { get; set; }            public string LastName { get; set; }            public int Age { get; set; }        }        [AopTemplate("NotifyPropertyChanged", Action = AopTemplateAction.Properties)]        public class Customer : Person        {            public double CreditScore { get; set; }        }        public interface IDataService        {            void Init(IEnumerable<Customer> customers);            string GetCustomerName(int customerId);        }        public class DataService: IDataService        {            private IEnumerable<Customer> _customers;            public void Init(IEnumerable<Customer> customers)            {                _customers = customers;            }            public string GetCustomerName(int customerId)            {                return _customers.FirstOrDefault(w => w.Id == customerId)?.FullName;            }        }        public class MockDataService : IDataService        {            private IEnumerable<Customer> _customers;            public void Init(IEnumerable<Customer> customers)            {                if(customers == null)                    throw (new Exception("IDataService.Init(customers == null)"));            }            public string GetCustomerName(int customerId)            {                if (customerId < 0)                    throw (new Exception("IDataService.GetCustomerName: customerId cannot be negative"));                if (customerId == 0)                    throw (new Exception("IDataService.GetCustomerName: customerId cannot be zero"));                return $"FirstName{customerId} LastName{customerId}";            }        }    }}


Полная версия исходного кода после трансформации
//------------------------------------------------------------------------------// <auto-generated> //     This code was generated from a template.// //     Manual changes to this file may cause unexpected behavior in your application.//     Manual changes to this file will be overwritten if the code is regenerated.////  Generated base on file: ConsoleDemo.cs//  ##sha256: ekmmxFSeH5ev8Epvl7QvDL+D77DHwq1gHDnCxzeBWcw//  Created By: JohnSmith//  Created Machine: 127.0.0.1//  Created At: 2020-09-19T23:18:07.2061273-04:00//// </auto-generated>//------------------------------------------------------------------------------using Microsoft.Extensions.Configuration;using System;using System.Collections.Generic;using System.ComponentModel;using System.IO;using System.Linq;namespace Aspectimum.Demo.Lib{    public class ConsoleDemo    {        public virtual Person FirstDemo(string firstName, string lastName, int age)        {            Console.Out.WriteLine("FirstDemo: 1");            // FirstDemoComment replacement extra data here            return new Person()            {FirstName = firstName, LastName = lastName, Age = age, };        }        private static IConfigurationRoot _configuration = new ConfigurationBuilder()            .SetBasePath(System.IO.Path.Combine(AppContext.BaseDirectory))            .AddJsonFile("appsettings.json", optional: true)            .Build();                private IDataService _service { get; } = new DataService();#error Cannot find injection rule for Person _somePerson        private Person _somePerson = inject;        public Customer[] SecondDemo(Person[] people)        {            try            {#error variable "Customers" doesn't match code standard rules                IEnumerable<Customer> Customers;                                Console.Out.WriteLine("SecondDemo: 1");#error Cannot find resource for a string "SecondDemo: i18", please add it to resources                Console.Out.WriteLine(i18("SecondDemo: i18"));                int configDelayMS = Int32.Parse(_configuration["delay_ms"]);                string configServerName = _configuration["server_name"];                {                    // second demo test extra                    {                        Customers = people.Select(s => new Customer()                        {FirstName = s.FirstName, LastName = s.LastName, Age = s.Age, Id = s.Id});                        _service.Init(Customers);                        foreach (var customer in Customers)                        {                            Console.Out.WriteLine(String.Format(Demo.First_Last_Names_Formatted, customer.FirstName, customer.LastName));                            Console.Out.WriteLine("SecondDemo: 2 " + (Demo.First_Name + " ") + customer.FirstName + (" " + Demo.Last_Name + "   ") + customer.LastName);                        }                    }                }#error Argument for i18 method must be either string literal or interpolated string, but instead got Microsoft.CodeAnalysis.CSharp.Syntax.InvocationExpressionSyntax#warning Please replace String.Format with string interpolation format.                Console.Out.WriteLine(i18(String.Format("SecondDemo: {0}", "3")));                Console.Out.WriteLine($"Server {configServerName} default delay {configDelayMS}");                Console.Out.WriteLine($"Customer for ID=5 is {_service.GetCustomerName(5)}");                return Customers.ToArray();            }            catch (Exception logExpn)            {                Console.Error.WriteLine($"Exception in SecondDemo\r\n{logExpn.Message}\r\n{logExpn.StackTrace}");                throw;            }        }        protected static string i18(string s) => s;        protected static dynamic inject;        public class Person : System.ComponentModel.INotifyPropertyChanged        {            public event System.ComponentModel.PropertyChangedEventHandler PropertyChanged;            protected void NotifyPropertyChanged([System.Runtime.CompilerServices.CallerMemberName] string propertyName = "")            {                PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(propertyName));            }            public string FullName            {                get                {                    System.Runtime.Caching.ObjectCache cache = System.Runtime.Caching.MemoryCache.Default;                    string cachedData = cache["name_of_cache_key"] as string;                    if (cachedData == null)                    {                        cachedData = GetPropertyData();                        if (cachedData != null)                        {                            cache.Set("name_of_cache_key", cachedData, System.DateTimeOffset.Now.AddMinutes(10));                        }                    }                    return cachedData;                    string GetPropertyData()                    {                        // FullNameComment FullName                        return $"{FirstName} {LastName}";                    }                }            }            private int _id;            public int Id            {                get                {                    return _id;                }                set                {                    if (_id != value)                    {                        _id = value;                        NotifyPropertyChanged();                    }                }            }            private string _firstName;            public string FirstName            {                get                {                    return _firstName;                }                set                {                    if (_firstName != value)                    {                        _firstName = value;                        NotifyPropertyChanged();                    }                }            }            private string _lastName;            public string LastName            {                get                {                    return _lastName;                }                set                {                    if (_lastName != value)                    {                        _lastName = value;                        NotifyPropertyChanged();                    }                }            }            private int _age;            public int Age            {                get                {                    return _age;                }                set                {                    if (_age != value)                    {                        _age = value;                        NotifyPropertyChanged();                    }                }            }        }        public class Customer : Person        {            private double _creditScore;            public double CreditScore            {                get                {                    return _creditScore;                }                set                {                    if (_creditScore != value)                    {                        _creditScore = value;                        NotifyPropertyChanged();                    }                }            }        }        public interface IDataService        {            void Init(IEnumerable<Customer> customers);            string GetCustomerName(int customerId);        }        public class DataService : IDataService        {            private IEnumerable<Customer> _customers;            public void Init(IEnumerable<Customer> customers)            {                _customers = customers;            }            public string GetCustomerName(int customerId)            {                return _customers.FirstOrDefault(w => w.Id == customerId)?.FullName;            }        }        public class MockDataService : IDataService        {            private IEnumerable<Customer> _customers;            public void Init(IEnumerable<Customer> customers)            {                if (customers == null)                    throw (new Exception("IDataService.Init(customers == null)"));            }            public string GetCustomerName(int customerId)            {                if (customerId < 0)                    throw (new Exception("IDataService.GetCustomerName: customerId cannot be negative"));                if (customerId == 0)                    throw (new Exception("IDataService.GetCustomerName: customerId cannot be zero"));                return $"FirstName{customerId} LastName{customerId}";            }        }    }}// ##template=AutoComment sha256=Qz6vshTZl2/u+NgtcV4u5W5RZMb9JPkJ2Zj0yvQBH9w// ##template=AopCsharp.ttinclude sha256=2QR7LE4yvfWYNl+JVKQzvEBwcWvReeupVpslWTSWQ0c// ##template=FirstDemoComment sha256=eIleHCim5r9F/33Mv9B7pcNQ/dlfEhDVXJVhA7+3OgY// ##template=FullNameComment sha256=2/Ipn8fk2y+o/FVQHAWnrOlhqS5ka204YctZkwl/CUs// ##template=NotifyPropertyChangedClass sha256=sxRrSjUSrynQSPjo85tmQywQ7K4fXFR7nN2mX87fCnk// ##template=StaticAnalyzer sha256=zmJsj/FWmjqDDnpZXhoAxQB61nYujd41ILaQ4whcHyY// ##template=LogExceptionMethod sha256=+zTre3r3LR9dm+bLPEEXg6u2OtjFg+/V6aCnJKijfcg// ##template=NotifyPropertyChanged sha256=PMgorLSwEChpIPnEWXfEuUzUm4GO/6pMmoJdF7qcgn8// ##template=CacheProperty sha256=oktDGTfC2hHoqpbKkeNABQaPdq6SrVLRFEQdNMoY4zE// ##template=DependencyInjection sha256=nPq/ZxVBpgrDzyH+uLtJvD1aKbajKinX/DUBQ4BGG9g// ##template=ResourceReplacer sha256=ZyUljjKKj0jLlM2nUIr1oJc1L7otYUI8WqWN7um6NxI



Пояснения и код шаблонов


Шаблон AutoComment

// ##aspect=AutoComment

Если в исходном коде мы встречается комментарий в специальном формате, то исполняем заданный шаблон (в данном случае это AutoComment) и вставляем результат трансформации вместо этого комментария. В этом примере имеет смысл автоматически вставлять специальный дисклаймер который предупредит программиста что код в этом файле это результат трансформации и не имеет смысла изменять этот файл напрямую.

Код шаблона AutoComment.t4

<#@ include file="AopCsharp.ttinclude" #>//------------------------------------------------------------------------------// <auto-generated> //     This code was generated from a template.// //     Manual changes to this file may cause unexpected behavior in your application.//     Manual changes to this file will be overwritten if the code is regenerated.////  Generated base on file: <#= FileName #>//  ##sha256: <#= FileSha256 #>//  Created By: <#= User #>//  Created Machine: <#= MachineName #>//  Created At: <#= Now #>//// </auto-generated>//------------------------------------------------------------------------------

Переменные FileName, FileSha256, User, MachineName и Now экспортируются в шаблон из процесса трансформации.

Результат трансформации

//------------------------------------------------------------------------------// <auto-generated> //     This code was generated from a template.// //     Manual changes to this file may cause unexpected behavior in your application.//     Manual changes to this file will be overwritten if the code is regenerated.////  Generated base on file: ConsoleDemo.cs//  ##sha256: PV3lHNDftTzVYnzNCZbKvtHCbscT0uIcHGRR/NJFx20//  Created By: EuGenie//  Created Machine: 192.168.0.1//  Created At: 2017-12-09T14:49:26.7173975-05:00//// </auto-generated>//------------------------------------------------------------------------------

Следующая трансформация задаётся как атрибут класса

[AopTemplate(ClassLevelTemplateForMethods, NameFilter=First)]

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

Код шаблона ClassLevelTemplateForMethods.t4

<#@ include file="AopCsharp.ttinclude" #>// class level template<#= MethodStart() #><#= MethodBody() #><#= MethodEnd() #>

Это простейший пример который добавляет комментарий // class level template перед кодом метода

Результат трансформации

// class level templatepublic virtual Person FirstDemo(string firstName, string lastName, int age){  Console.Out.WriteLine("FirstDemo: 1");  // ##aspect="FirstDemoComment" extra data here  return new Person()      {        FirstName = firstName,        LastName = lastName,        Age = age,      };}

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

[AopTemplate("LogExceptionMethod")]
[AopTemplate("StopWatchMethod")]
[AopTemplate("MethodFinallyDemo", AdvicePriority = 100)]


Шаблон LogExceptionMethod.t4
<#@ include file="AopCsharp.ttinclude" #><# EnsureUsing("System"); #><#= MethodStart() #>try{<#= MethodBody() #>} catch(Exception logExpn){Console.Error.WriteLine($"Exception in <#= MethodName #>\r\n{logExpn.Message}\r\n{logExpn.StackTrace}");throw;}<#= MethodEnd() #>

Шаблон StopWatchMethod.t4
<#@ include file="AopCsharp.ttinclude" #><# EnsureUsing("System.Diagnostics"); #><#= MethodStart() #>var stopwatch = Stopwatch.StartNew(); try{<#= MethodBody() #>} finally{stopwatch.Stop();Console.Out.WriteLine($"Method <#= MethodName #>: {stopwatch.ElapsedMilliseconds}");}<#= MethodEnd() #>

Шаблон MethodFinallyDemo.t4
<#@ include file="AopCsharp.ttinclude" #><#= MethodStart() #>try{<#= MethodBody() #>} finally {// whatever logic you need to include for a method}<#= MethodEnd() #>

Результат трансформаций
public Customer[] SecondDemo(Person[] people){    try    {        var stopwatch = Stopwatch.StartNew();        try        {            try            {                IEnumerable<Customer> customers;                Console.Out.WriteLine("SecondDemo: 1");                {                    // second demo test extra                    {                        customers = people.Select(s => new Customer()                        {FirstName = s.FirstName, LastName = s.LastName, Age = s.Age, });                        foreach (var customer in customers)                        {                            Console.Out.WriteLine($"SecondDemo: 2 {customer.FirstName} {customer.LastName}");                        }                    }                }                Console.Out.WriteLine("SecondDemo: 3");                return customers.ToArray();            }            catch (Exception logExpn)            {                Console.Error.WriteLine($"Exception in SecondDemo\r\n{logExpn.Message}\r\n{logExpn.StackTrace}");                throw;            }        }        finally        {            stopwatch.Stop();            Console.Out.WriteLine($"Method SecondDemo: {stopwatch.ElapsedMilliseconds}");        }    }    finally    {    // whatever logic you need to include for a method    }}

Следующая трансформация задаётся для блока ограниченного в конструкцию using

using (new AopTemplate("SecondDemoUsing", extraTag: "test extra")){    customers = people.Select(s => new Customer()    {        FirstName = s.FirstName,        LastName = s.LastName,        Age = s.Age,    });    foreach (var customer in customers)    {        Console.Out.WriteLine($"SecondDemo: 2 {customer.FirstName} {customer.LastName}");    }}

Шаблон SecondDemoUsing.t4
<#@ include file="AopCsharp.ttinclude" #>// second demo <#= ExtraTag #><#= StatementBody() #>

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

Результат трансформации

{  // second demo test extra  {      customers = people.Select(s => new Customer()      {FirstName = s.FirstName, LastName = s.LastName, Age = s.Age, });      foreach (var customer in customers)      {          Console.Out.WriteLine($"SecondDemo: 2 {customer.FirstName} {customer.LastName}");      }  }}

Следующая трансформация задаётся атрибутами класса

[AopTemplate("NotifyPropertyChangedClass", Action = AopTemplaceAction.Classes)]
[AopTemplate("NotifyPropertyChanged", Action = AopTemplaceAction.Properties)]


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

Шаблон NotifyPropertyChangedClass.t4 применяется к коду класса
<#@ include file="AopCsharp.ttinclude" #><#// the class already implements INotifyPropertyChanged, nothing to do hereif(ImplementsBaseType(ClassNode, "INotifyPropertyChanged", "System.ComponentModel.INotifyPropertyChanged"))return null;var classNode = AddBaseTypes<ClassDeclarationSyntax>(ClassNode, "System.ComponentModel.INotifyPropertyChanged"); #><#= ClassStart(classNode) #>            public event System.ComponentModel.PropertyChangedEventHandler PropertyChanged;            protected void NotifyPropertyChanged([System.Runtime.CompilerServices.CallerMemberName] string propertyName = "")            {                PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(propertyName));            }<#= ClassBody(classNode) #><#= ClassEnd(classNode) #>

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

На примере реализации для Fogy
using Mono.Cecil;using Mono.Cecil.Cil;using Mono.Cecil.Rocks;public partial class ModuleWeaver{    public void InjectINotifyPropertyChangedInterface(TypeDefinition targetType)    {        targetType.Interfaces.Add(new InterfaceImplementation(PropChangedInterfaceReference));        WeaveEvent(targetType);    }    void WeaveEvent(TypeDefinition type)    {        var propertyChangedFieldDef = new FieldDefinition("PropertyChanged", FieldAttributes.Private | FieldAttributes.NotSerialized, PropChangedHandlerReference);        type.Fields.Add(propertyChangedFieldDef);        var propertyChangedField = propertyChangedFieldDef.GetGeneric();        var eventDefinition = new EventDefinition("PropertyChanged", EventAttributes.None, PropChangedHandlerReference)            {                AddMethod = CreateEventMethod("add_PropertyChanged", DelegateCombineMethodRef, propertyChangedField),                RemoveMethod = CreateEventMethod("remove_PropertyChanged", DelegateRemoveMethodRef, propertyChangedField)            };        type.Methods.Add(eventDefinition.AddMethod);        type.Methods.Add(eventDefinition.RemoveMethod);        type.Events.Add(eventDefinition);    }    MethodDefinition CreateEventMethod(string methodName, MethodReference delegateMethodReference, FieldReference propertyChangedField)    {        const MethodAttributes Attributes = MethodAttributes.Public |                                            MethodAttributes.HideBySig |                                            MethodAttributes.Final |                                            MethodAttributes.SpecialName |                                            MethodAttributes.NewSlot |                                            MethodAttributes.Virtual;        var method = new MethodDefinition(methodName, Attributes, TypeSystem.VoidReference);        method.Parameters.Add(new ParameterDefinition("value", ParameterAttributes.None, PropChangedHandlerReference));        var handlerVariable0 = new VariableDefinition(PropChangedHandlerReference);        method.Body.Variables.Add(handlerVariable0);        var handlerVariable1 = new VariableDefinition(PropChangedHandlerReference);        method.Body.Variables.Add(handlerVariable1);        var handlerVariable2 = new VariableDefinition(PropChangedHandlerReference);        method.Body.Variables.Add(handlerVariable2);        var loopBegin = Instruction.Create(OpCodes.Ldloc, handlerVariable0);        method.Body.Instructions.Append(            Instruction.Create(OpCodes.Ldarg_0),            Instruction.Create(OpCodes.Ldfld, propertyChangedField),            Instruction.Create(OpCodes.Stloc, handlerVariable0),            loopBegin,            Instruction.Create(OpCodes.Stloc, handlerVariable1),            Instruction.Create(OpCodes.Ldloc, handlerVariable1),            Instruction.Create(OpCodes.Ldarg_1),            Instruction.Create(OpCodes.Call, delegateMethodReference),            Instruction.Create(OpCodes.Castclass, PropChangedHandlerReference),            Instruction.Create(OpCodes.Stloc, handlerVariable2),            Instruction.Create(OpCodes.Ldarg_0),            Instruction.Create(OpCodes.Ldflda, propertyChangedField),            Instruction.Create(OpCodes.Ldloc, handlerVariable2),            Instruction.Create(OpCodes.Ldloc, handlerVariable1),            Instruction.Create(OpCodes.Call, InterlockedCompareExchangeForPropChangedHandler),            Instruction.Create(OpCodes.Stloc, handlerVariable0),            Instruction.Create(OpCodes.Ldloc, handlerVariable0),            Instruction.Create(OpCodes.Ldloc, handlerVariable1),            Instruction.Create(OpCodes.Bne_Un_S, loopBegin), // go to begin of loop            Instruction.Create(OpCodes.Ret));        method.Body.InitLocals = true;        method.Body.OptimizeMacros();        return method;    }}

Честно говоря, подобный код немного пугает неофитов AOP в .Net

Шаблон NotifyPropertyChanged.t4 применяется к свойствам класса
<#@ include file="AopCsharp.ttinclude" #><# if(!(PropertyHasEmptyGetBlock() && PropertyHasEmptySetBlock()))return null;string privateUnqiueName = GetUniquePrivatePropertyName(ClassNode, PropertyNode.Identifier.ToString());#>private <#= PropertyNode.Type.ToFullString() #> <#= privateUnqiueName #><#= PropertyNode.Initializer != null ? " = " + PropertyNode.Initializer.ToFullString() : "" #>;<#= PropertyNode.AttributeLists.ToFullString() + PropertyNode.Modifiers.ToFullString() + PropertyNode.Type.ToFullString() + PropertyNode.Identifier.ToFullString() #>{get { return <#= privateUnqiueName #>; }set {if(<#= privateUnqiueName #> != value){<#= privateUnqiueName #> = value;NotifyPropertyChanged();}}}

Оригинальный код класса и свойств
public class Person{    public int Id { get; set; }// ...}

Результат трансформации
public class Person : System.ComponentModel.INotifyPropertyChanged{    public event System.ComponentModel.PropertyChangedEventHandler PropertyChanged;    protected void NotifyPropertyChanged([System.Runtime.CompilerServices.CallerMemberName] string propertyName = "")    {        PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(propertyName));    }    private int _id;    public int Id    {        get        {            return _id;        }        set        {            if (_id != value)            {                _id = value;                NotifyPropertyChanged();            }        }    }// ...}

Пример шаблона для кэширования результатов свойства, он задаётся атрибутом

[AopTemplate("CacheProperty", extraTag: "{ \"CacheKey\": \"name_of_cache_key\", \"ExpiresInMinutes\": 10 }")]

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

Шаблон CacheProperty.t4
<#@ include file="AopCsharp.ttinclude" #><#// The template accepts a configuration value from extraTag in two ways// 1. as a number of minutes to use for expiration (example: 8)// 2. as a string in JSON in format { CacheKey: "name_of_cache_key", CacheKeyVariable: "name_of_variable", ExpiresInMinutes: 10, ExpiresVariable: "name_of_variable" }////    CacheKey (optional) name of the cache key, the name will be used as a literal string (example: my_key)//    CacheKeyVariable (optional) name of variable that holds the cache key (example: GlobalConsts.MyKeyName)////    ExpiresInMinutes (optional) number minutes that the cache value will expires (example: 12)//    ExpiresVariable (optional) name of a variable that the expiration value will be get from (example: AppConfig.EXPIRE_CACHE)//// if any of expiration values are not specified, 5 minutes default expiration will be usedif(!PropertyHasAnyGetBlock())return null;const int DEFAULT_EXPIRES_IN_MINUTES = 5;string propertyName = PropertyNode.Identifier.ToFullString().Trim();string propertyType = PropertyNode.Type.ToFullString().Trim();string expiresInMinutes = DEFAULT_EXPIRES_IN_MINUTES.ToString();string cacheKey = "\"" + ClassNode.Identifier.ToFullString() + ":" + propertyName + "\"";if(!String.IsNullOrEmpty(ExtraTag)){if(Int32.TryParse(ExtraTag, out int exp)){expiresInMinutes = exp.ToString();}else{JsonDocument json = ExtraTagAsJson();if(json != null && json.RootElement.ValueKind  == JsonValueKind.Object){if(json.RootElement.TryGetProperty("CacheKey", out JsonElement cacheKeyElement)){string s = cacheKeyElement.GetString();if(!String.IsNullOrEmpty(s))cacheKey = "\"" + s + "\"";}else if(json.RootElement.TryGetProperty("CacheKeyVariable", out JsonElement cacheVariableElement)){string s = cacheVariableElement.GetString();if(!String.IsNullOrEmpty(s))cacheKey = s;}if(json.RootElement.TryGetProperty("ExpiresInMinutes", out JsonElement expiresInMinutesElement)){if(expiresInMinutesElement.TryGetInt32(out int v) && v > 0)expiresInMinutes = "" + v;} else if(json.RootElement.TryGetProperty("ExpiresVariable", out JsonElement expiresVariableElement)){string s = expiresVariableElement.GetString();if(!String.IsNullOrEmpty(s))expiresInMinutes = s;}}}}#><#= PropertyDefinition() #>{get { System.Runtime.Caching.ObjectCache cache = System.Runtime.Caching.MemoryCache.Default;<#= propertyType #> cachedData = cache[<#= cacheKey #>] as <#= propertyType #>;if(cachedData == null){cachedData = GetPropertyData();if(cachedData != null){cache.Set(<#= cacheKey #>, cachedData, System.DateTimeOffset.Now.AddMinutes(<#= expiresInMinutes #>)); }}return cachedData;<#= propertyType #> GetPropertyData(){<# if(PropertyNode.ExpressionBody != null ) { #>return (<#= PropertyNode.ExpressionBody.Expression.ToFullString() #>);<# } else if(PropertyNode?.AccessorList?.Accessors.FirstOrDefault(w => w.ExpressionBody != null && w.Keyword.ToString() == "get") != null) { #>return (<#= PropertyNode?.AccessorList?.Accessors.FirstOrDefault(w => w.ExpressionBody != null && w.Keyword.ToString() == "get").ExpressionBody.Expression.ToFullString() #>);<# } else { #><#= PropertyGetBlock() #><# } #>}       }<#if(PropertyHasAnySetBlock()) { #>set {System.Runtime.Caching.ObjectCache cache = System.Runtime.Caching.MemoryCache.Default;  cache.Remove(<#= cacheKey #>); // invalidate cache for the property<#= PropertySetBlock() #>}<# } #>}

Исходный код
[AopTemplate("CacheProperty", extraTag: "{ \"CacheKey\": \"name_of_cache_key\", \"ExpiresInMinutes\": 10 }")]public string FullName{    get    {        return $"{FirstName} {LastName}";    }}

Результат трансформации для CacheProperty.t4
public string FullName{    get    {        System.Runtime.Caching.ObjectCache cache = System.Runtime.Caching.MemoryCache.Default;        string cachedData = cache["name_of_cache_key"] as string;        if (cachedData == null)        {            cachedData = GetPropertyData();            if (cachedData != null)            {                cache.Set("name_of_cache_key", cachedData, System.DateTimeOffset.Now.AddMinutes(10));            }        }        return cachedData;        string GetPropertyData()        {            // FullNameComment FullName            return $"{FirstName} {LastName}";        }    }}

Следующий вызов шаблона опять из комментария
// ##aspect="FullNameComment" extra data here

Шаблон FullNameComment.t4
<#@ include file="AopCsharp.ttinclude" #>// FullNameComment <#= PropertyNode.Identifier #>

Очень похож на шаблон AutoComment.t4, но здесь демонстрируем использование PropertyNode. Также шаблону FullNameComment.t4 доступны данные extra data here через параметр ExtraTag (но в данном примере мы их не используем, поэтому они просто игнорируется)

Результат трансформации
// FullNameComment FullName

Следующая трансформация в файле задаётся атрибутом класса

[AopTemplate("NotifyPropertyChanged", Action = AopTemplaceAction.Properties)]

И идентична таковой для класса Person. Исходный код шаблона NotifyPropertyChanged.t4 уже был включен выше по тексту.

Результат трансформации
public class Customer : Person{    private double _creditScore;    public double CreditScore    {        get        {            return _creditScore;        }        set        {            if (_creditScore != value)            {                _creditScore = value;                NotifyPropertyChanged();            }        }    }}

Заключительная часть


Хотя данная статья акцентируется на аспектно-ориентированном программировании, техника трансформации исходного кода универсальна и в принципе можно ее использовать для задач которые не имеют отношения к AOP.

Например можно использовать для dependency injection, т.е. мы меняем код создания ресурсов в зависимости от параметров сборки.

Шаблон DependencyInjection.t4
<#@ include file="AopCsharp.ttinclude" #><#var syntaxNode = FieldsInjection(SyntaxNode);syntaxNode = VariablesInjection(syntaxNode);syntaxNode = PropertiesInjection(syntaxNode);if(syntaxNode == SyntaxNode)return null;#><#= syntaxNode.ToFullString() #><#+private SyntaxNode VariablesInjection(SyntaxNode syntaxNode){return RewriteNodes<LocalDeclarationStatementSyntax >(syntaxNode, OnLocalVariablesInjection);SyntaxNode OnLocalVariablesInjection(LocalDeclarationStatementSyntax node){var errorMsgs = new System.Text.StringBuilder();SyntaxNode syntaxNode = RewriteNodes<VariableDeclaratorSyntax>(node, (n) => OnVariableDeclaratorVisit(n, node.Declaration.Type, errorMsgs));if(errorMsgs.Length > 0)return AddErrorMessageTrivia(syntaxNode, errorMsgs.ToString());return syntaxNode;}}private SyntaxNode PropertiesInjection(SyntaxNode syntaxNode){return RewriteNodes<PropertyDeclarationSyntax>(syntaxNode, OnPropertyInjection);SyntaxNode OnPropertyInjection(PropertyDeclarationSyntax node){if(node.Initializer?.Value?.ToString() != "inject")return node;var errorMsgs = new System.Text.StringBuilder();SyntaxNode syntaxNode = DoInjection(node, node.Identifier.ToString().Trim(), node.Initializer.Value, node.Type, errorMsgs);if(errorMsgs.Length > 0)return AddErrorMessageTrivia(syntaxNode, errorMsgs.ToString());return syntaxNode;}}private SyntaxNode FieldsInjection(SyntaxNode syntaxNode){return RewriteNodes<BaseFieldDeclarationSyntax>(syntaxNode, OnFieldsInjection);SyntaxNode OnFieldsInjection(BaseFieldDeclarationSyntax node){var errorMsgs = new System.Text.StringBuilder();SyntaxNode syntaxNode = RewriteNodes<VariableDeclaratorSyntax>(node, (n) => OnVariableDeclaratorVisit(n, node.Declaration.Type, errorMsgs));if(errorMsgs.Length > 0)return AddErrorMessageTrivia(syntaxNode, errorMsgs.ToString());return syntaxNode;}}private SyntaxNode OnVariableDeclaratorVisit(VariableDeclaratorSyntax node, TypeSyntax typeSyntax, System.Text.StringBuilder errorMsgs){if(node.Initializer?.Value?.ToString() != "inject")return node;return DoInjection(node, node.Identifier.ToString().Trim(), node.Initializer.Value, typeSyntax, errorMsgs);}private SyntaxNode DoInjection(SyntaxNode node, string varName, ExpressionSyntax initializerNode, TypeSyntax typeSyntax, System.Text.StringBuilder errorMsgs){string varType = typeSyntax.ToString().Trim();Log($"{varName} {varType} {initializerNode.ToString()}");if(varName.StartsWith("config")){string configName = Regex.Replace(Regex.Replace(varName, "^config", ""), "([a-z])([A-Z])", (m) => m.Groups[1].Value + "_" + m.Groups[2].Value).ToLower();ExpressionSyntax configNode = CreateElementAccess("_configuration", CreateStringLiteral(configName));if(varType == "int"){configNode = CreateMemberAccessInvocation("Int32", "Parse", configNode);}return node.ReplaceNode(initializerNode, configNode);}switch(varType){case "Microsoft.Extensions.Configuration.IConfigurationRoot":case "IConfigurationRoot":EnsureUsing("Microsoft.Extensions.Configuration");ExpressionSyntax pathCombineArg = CreateMemberAccessInvocation("System.IO.Path", "Combine", CreateMemberAccess("AppContext", "BaseDirectory"));ExpressionSyntax builderNode = CreateNewType("ConfigurationBuilder").WithTrailingTrivia(SyntaxFactory.EndOfLine("\r\n"));builderNode  = CreateMemberAccessInvocation(builderNode, "SetBasePath", pathCombineArg).WithTrailingTrivia(SyntaxFactory.EndOfLine("\r\n"));ExpressionSyntax addJsonFileArg = CreateMemberAccessInvocation("System.IO.Path", "Combine", CreateMemberAccess("AppContext", "BaseDirectory"));builderNode  = CreateMemberAccessInvocationNamedArgs(builderNode, "AddJsonFile", (null, CreateStringLiteral("appsettings.json")), ("optional",  SyntaxFactory.LiteralExpression(SyntaxKind.TrueLiteralExpression))).WithTrailingTrivia(SyntaxFactory.EndOfLine("\r\n"));if(GetGlobalSetting("env")?.ToLower() == "test"){builderNode  = CreateMemberAccessInvocationNamedArgs(builderNode, "AddJsonFile", (null, CreateStringLiteral("appsettings.test.json")), ("optional",  SyntaxFactory.LiteralExpression(SyntaxKind.FalseLiteralExpression)));}builderNode  = CreateMemberAccessInvocation(builderNode, "Build");return node.ReplaceNode(initializerNode, builderNode);case "IDataService":{string className = (GetGlobalSetting("env")?.ToLower() == "test" ? "MockDataService" : "DataService");return node.ReplaceNode(initializerNode, CreateNewType(className));}}errorMsgs.AppendLine($"Cannot find injection rule for {varType} {varName}");return node;}#>


В исходном коде (здесь используется особенность dynamic переменных, которая позволяет присваивать их любым типам), т.е. для выразительности мы как бы придумали новое ключевое слово.
private static IConfigurationRoot _configuration = inject;private IDataService _service { get; } = inject;// ...public Customer[] SecondDemo(Person[] people){     int configDelayMS = inject; // we are going to inject dependency to local variables     string configServerName = inject;}// ...protected static dynamic inject;

При трансформации используется сравнение GetGlobalSetting(env) == test и в зависимости от этого условия, будет внедрено или new DataService() или new MockDataService().

Результат трансформации
private static IConfigurationRoot _configuration = new ConfigurationBuilder()    .SetBasePath(System.IO.Path.Combine(AppContext.BaseDirectory))    .AddJsonFile("appsettings.json", optional: true)    .Build();private IDataService _service { get; } = new DataService();// ...public Customer[] SecondDemo(Person[] people){       int configDelayMS = Int32.Parse(_configuration["delay_ms"]);       string configServerName = _configuration["server_name"];}// ...

Или можно использовать этот инструмент как poor man static analysis (но гораздо-гораздо более правильно это реализовать анализаторы используя родную функциональность Roslyn), мы анализируем код на наши правила и вставляем в исходный код

#error our error message here

Что приведёт к ошибке времени компиляции.

#warning our warning message here

Что послужит предупреждению в IDE или при компиляции.

Шаблон StaticAnalyzer.t4
<#@ include file="AopCsharp.ttinclude" #><#var syntaxNode = AnalyzeLocalVariables(SyntaxNode);syntaxNode = AnalyzeStringFormat(syntaxNode);if(syntaxNode == SyntaxNode)return null;#><#= syntaxNode.ToFullString() #><#+private SyntaxNode AnalyzeLocalVariables(SyntaxNode syntaxNode){return RewriteNodes<LocalDeclarationStatementSyntax>(syntaxNode, OnAnalyzeLocalVariablesNodeVisit);SyntaxNode OnAnalyzeLocalVariablesNodeVisit(LocalDeclarationStatementSyntax node){var errorMsgs = new System.Text.StringBuilder();string d = "";foreach(VariableDeclaratorSyntax variableNode in node.DescendantNodes().OfType<VariableDeclaratorSyntax>().Where(w => Regex.IsMatch(w.Identifier.ToString(), "^[A-Z]"))){LogDebug($"variable: {variableNode.Identifier.ToString()}");errorMsgs.Append(d + $"variable \"{variableNode.Identifier.ToString()}\" doesn't match code standard rules");d = ", ";}if(errorMsgs.Length > 0)return AddErrorMessageTrivia(node, errorMsgs.ToString());return node;}}private SyntaxNode AnalyzeStringFormat(SyntaxNode syntaxNode){return RewriteLeafStatementNodes(syntaxNode, OnAnalyzeStringFormat);SyntaxNode OnAnalyzeStringFormat(StatementSyntax node){bool hasStringFormat = false;foreach(MemberAccessExpressionSyntax memberAccessNode in node.DescendantNodes().OfType<MemberAccessExpressionSyntax>()){if(memberAccessNode.Name.ToString().Trim() != "Format")continue;string expr = memberAccessNode.Expression.ToString().Trim().ToLower();if(expr != "string" && expr != "system.string")continue;hasStringFormat = true;break;}if(hasStringFormat)return AddWarningMessageTrivia(node, "Please replace String.Format with string interpolation format.");return node;}}#>


Результат трансформации
#error variable "Customers" doesn't match code standard rulesIEnumerable<Customer> Customers;// ...#warning Please replace String.Format with string interpolation format.Console.Out.WriteLine(i18(String.Format("SecondDemo: {0}", "3")));

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

Шаблон ResourceReplacer.t4
<#@ include file="AopCsharp.ttinclude" #><#Dictionary<string, string> options = ExtraTagAsDictionary();_resources = LoadResources(options["ResourceFile"]);_resourceClass = options["ResourceClass"];var syntaxNode = RewriteLeafStatementNodes(SyntaxNode, OnStatementNodeVisit);#><#= syntaxNode.ToFullString() #><#+ private SyntaxNode OnStatementNodeVisit(StatementSyntax node){if(!node.DescendantNodes().OfType<InvocationExpressionSyntax>().Any(w => (w.Expression is IdentifierNameSyntax) && ((IdentifierNameSyntax)w.Expression).Identifier.ToString() == "i18"  ))return node;var errorMsgs = new System.Text.StringBuilder();SyntaxNode syntaxNode = RewriteNodes<InvocationExpressionSyntax>(node, (n) => OnInvocationExpressionVisit(n, errorMsgs));if(errorMsgs.Length > 0)return AddErrorMessageTrivia(syntaxNode, errorMsgs.ToString());return syntaxNode;}    private SyntaxNode OnInvocationExpressionVisit(InvocationExpressionSyntax node, System.Text.StringBuilder errorMsgs){if(!(node.Expression is IdentifierNameSyntax && ((IdentifierNameSyntax)node.Expression).Identifier.ToString() == "i18"  ))return node;ArgumentSyntax arg = node.ArgumentList.Arguments.Single(); // We know that i18 method accepts only one argument. Keep in mind that it is just a demo and in real life you could be more inventivevar expr = arg.Expression;if(!(expr is LiteralExpressionSyntax || expr is InterpolatedStringExpressionSyntax)){errorMsgs.AppendLine($"Argument for i18 method must be either string literal or interpolated string, but instead got {arg.Expression.GetType().ToString()}");return node;}string s = expr.ToString();if(s.StartsWith("$")){(string format, List<ExpressionSyntax> expressions) = ConvertInterpolatedStringToFormat((InterpolatedStringExpressionSyntax)expr);ExpressionSyntax stringNode = ReplaceStringWithResource("\"" + format + "\"", errorMsgs);if(stringNode != null){var memberAccess = CreateMemberAccess("String", "Format");var arguments = new List<ArgumentSyntax>();arguments.Add(SyntaxFactory.Argument(stringNode));expressions.ForEach(item => arguments.Add(SyntaxFactory.Argument(item)));var argumentList = SyntaxFactory.SeparatedList(arguments);return SyntaxFactory.InvocationExpression(memberAccess, SyntaxFactory.ArgumentList(argumentList));}}else{SyntaxNode stringNode = ReplaceStringWithResource(s, errorMsgs);if(stringNode != null)return stringNode;}return node;}private ExpressionSyntax ReplaceStringWithResource(string s, System.Text.StringBuilder errorMsgs){Match m = System.Text.RegularExpressions.Regex.Match(s, "^\"(\\s*)(.*?)(\\s*)\"$");if(!m.Success){errorMsgs.AppendLine($"String doesn't match search criteria");return null;}if(!_resources.TryGetValue(m.Groups[2].Value, out string resourceName)){errorMsgs.AppendLine($"Cannot find resource for a string {s}, please add it to resources");return null;}string csharpName = Regex.Replace(resourceName, "[^A-Za-z0-9]", "_");ExpressionSyntax stringNode = CreateMemberAccess(_resourceClass, csharpName);if(!String.IsNullOrEmpty(m.Groups[1].Value) || !String.IsNullOrEmpty(m.Groups[3].Value)){if(!String.IsNullOrEmpty(m.Groups[1].Value)){stringNode = SyntaxFactory.BinaryExpression(SyntaxKind.AddExpression, CreateStringLiteral(m.Groups[1].Value), stringNode);}if(!String.IsNullOrEmpty(m.Groups[3].Value)){stringNode = SyntaxFactory.BinaryExpression(SyntaxKind.AddExpression, stringNode, CreateStringLiteral(m.Groups[3].Value));}stringNode = SyntaxFactory.ParenthesizedExpression(stringNode);}return stringNode;}private string _resourceClass;private Dictionary<string,string> _resources;#>


Исходный код
Console.Out.WriteLine(i18("SecondDemo: i18"));// ...Console.Out.WriteLine(i18($"First Name {customer.FirstName} Last Name {customer.LastName}"));Console.Out.WriteLine("SecondDemo: 2 " + i18("First Name ") + customer.FirstName + i18(" Last Name   ") + customer.LastName);// ... Console.Out.WriteLine(i18(String.Format("SecondDemo: {0}", "3")));// ...protected static string i18(string s) => s;

В файле ресурсов Demo.resx у нас для примера созданы следующие строки
<data name="First Last Names Formatted" xml:space="preserve">  <value>First Name {0} Last Name {1}</value></data><data name="First Name" xml:space="preserve">    <value>First Name</value></data><data name="Last Name" xml:space="preserve">  <value>Last Name</value></data>

и автоматически сгенерированный код файла Demo.Designer.cs
public class Demo {// ...    public static string First_Last_Names_Formatted    {        get        {            return ResourceManager.GetString("First Last Names Formatted", resourceCulture);        }    }    public static string First_Name    {        get        {            return ResourceManager.GetString("First Name", resourceCulture);        }    }    public static string Last_Name    {        get        {            return ResourceManager.GetString("Last Name", resourceCulture);        }    }}

Результат трансформации (обратите внимание что интерполированная строка была заменена на String.Format и был использован ресурс First Name {0} Last Name {1}). Для строк которые не существуют в файле ресурсов или не соответсвуют нашему формату, добавляется сообщение об ошибке
//#error Cannot find resource for a string "SecondDemo: i18", please add it to resourcesConsole.Out.WriteLine(i18("SecondDemo: i18"));// ...Console.Out.WriteLine(String.Format(Demo.First_Last_Names_Formatted, customer.FirstName, customer.LastName));Console.Out.WriteLine("SecondDemo: 2 " + (Demo.First_Name + " ") + customer.FirstName + (" " + Demo.Last_Name + "   ") + customer.LastName);// ...//#error Argument for i18 method must be either string literal or interpolated string, but instead got Microsoft.CodeAnalysis.CSharp.Syntax.InvocationExpressionSyntaxConsole.Out.WriteLine(i18(String.Format("SecondDemo: {0}", "3")));

Вдобавок, инструмент трансформации позволяет работать не только с файлами C#, но и с любыми типами файлов (конечно с определёнными ограничениями). Если у вас есть парсер который может построить AST для вашего языка, то можно заменить Roslyn на этот парсер, подшаманить реализацию обработчика кода и это будет работать. К сожалению библиотек с функциональностью близкой к Roslyn очень ограниченное количество и их использование требует существенно больше усилий. В добавок к C#, мы используем трансформацию для JavaScript и TypeScript проектов, но конечно не так полно как для C#.

Ещё раз повторюсь, что код примера и шаблонов приведены в качестве иллюстрации возможностей подобного подхода и как говориться sky is the limit.

Спасибо за то что уделили своё время.

Основная часть этой статьи была написана пару лет назад, но к сожалению в силу определённых причин, удалось опубликовать её только сейчас.

Наш оригинальный инструмент разработан на .Net Framework, но мы начали работу над упрощённой версией с открытым кодом под лицензией MIT для .Net Core. На текущий момент результат полностью функционален и готов на 90%, остались незначительные доработки, причёска кода, создание документации и примеров, но без всего этого будет сложно войти в проект, сама идея будет скомпрометирована и DX будет негативным.

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

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

Буду рад конструктивной критике (неконструктивная меня тоже не огорчит, так что не стесняйтесь).

Благодарен Филу Ранжину (fillpackart) за мотивацию в написании статьи. Канал Мы обречены рулит!
Подробнее..
Категории: C , Net , C# .net t4 roslyn aop

Категории

Последние комментарии

© 2006-2020, personeltest.ru