- Разработка простого генератора отчетов с помощью Nemerle и System.Xml.Linq
- О чем речь?
- Спецификация
- Раздел Template
- Раздел Includes: Импорт других спецификаций
- Спецификация в целом
- $-нотация
- Модуль Program
- Главный метод приложения DoReport
- fillDic
- loadProperties
- loadProperties
- Чтение свойств
- loadTemplateInfo
- makeReport
- calcVarsValues
- calcOneVarValue
- calcSplice
- calcPExpr
- when $»$name» == «НомерЭлемента»
- Модуль ScriptFuncs
- Ref(name)
- Literal(. )
- doReplace
- Выдача сообщений об ошибках
- Заключение
Разработка простого генератора отчетов с помощью Nemerle и System.Xml.Linq
Автор: Владислав Чистяков aka VladD2
The RSDN Group
Источник: RSDN Magazine #1-2008
Опубликовано: 17.07.2008
Исправлено: 10.12.2016
Версия текста: 1.0
О чем речь?
Основным бизнесом нашей фирмы является производство журналов, так что периодически, при выходе нового номера, мне приходится печатать ряд документов: накладные, счета-фактуры, счета на оплату. Задача, что называется, контекстная (которую можно было бы легко сделать по контексту), так как товар в этих документах один и тот же (новый номер одного из журналов), а список фирм изменяется очень редко (но он разный для разных журналов). В документах присутствуют реквизиты покупателей, количество товара и скидка (уникальные для каждого покупателя), а так же некоторые наши реквизиты (причем они одинаковы для одного журнала, но не для всех).
Казалось бы, что сложного напечатать подобные документы? Однако сложности есть. Документов много (так как много фирм), поэтому сделать их вручную, даже с использованием некой системы автоматизации крайне муторно. К тому же учитывать их специальным образом нет нужды (учет ведется по приходу и расходу денег). Дело осложняется еще и тем, что постоянно меняется как исходная информация, так и формы документов. Одно время я пытался пользоваться учетными программами, но они все как одна не гибки. Потом я перешел на печать отчетов в Excel-е, но нарвался на все неприятности технологии Copy & Past. Например, изменение формы отчетов превращалось в кошмар, так как приходилось копировать массу информации. Но я ленивый и поэтому довольно долгое время не предпринимал никаких серьезных действий.
В конце концов, описанная ситуация меня достала, и я решил действовать. Сначала я попытался определиться, что же мне все-таки нужно. Немного подумав, я пришел к выводу, что мне нужен простенький по возможностям, но навороченный по форматированию (не уступающий Excel) генератор отчетов который генерировал бы отчеты по некой спецификации.
В качестве спецификации подходил любой текстовый файл, но вследствие того, что XML в последнее время стал почти что стандартом де-факто, я выбрал его.
Теперь пришла пора выбрать средство формирования отчетов (как я уже говорил, не уступающее Excel). Надо отметить, что моя природная лень сразу сделала недоступными генераторы отчетов, к которым не было много готовых форм. А так как таковыми являются все без исключения генераторы отчетов (ну, разве, что кроме оных встроенных в учетные системы, например, в 1С), то я быстро пришел к выводу, что лучше гор могут быть только горы, то есть, лучше Excel может быть только Ёксель.
Форм для Excel море. Все современные справочно-правовые системы (вроде Референта, Гаранта и Консультанта) обычно предоставляют формы документов в Excel или Word. Кроме того, такие формы почти всегда можно найти через Google.
Итак, решено! Надо создать генератор отчетов, принимающий на вход спецификацию в формате XML и генерирующий (а затем и печатающий) ряд отчетов в формате Excel.
Спецификация
Первым моим решением было создать спецификацию под эту конкретную задачу – печать накладных, счетов-фактур и счетов на оплату. Однако, поразмыслив немного, я понял, что лучше создать более универсальное решение. Вместо того чтобы «хардкодить» имена тегов (типа «НашиРеквизиты») я решил, что лучше, чтобы спецификация могла содержать любые теги, а мой генератор отчетов просто читал ее и на основе содержимого этих тегов формировал отчет (по шаблону в Excel).
В спецификации есть ряд данных, которые едины для каждого отчета. Например, все документы для всех фирм должны содержать реквизиты нашей фирмы, название журнала (включая номер и год), цену за номер. В то же время для каждой фирмы-покупателя нужно выводить ее реквизиты, количество отпускаемых ей журналов, а общую цену (задаваемую в спецификации один раз) нужно «умножать» на процент скидки. Говоря формально, в спецификации стали вырисовываться два типа информации:
- Общей для всех отчетов.
- Специфичной для каждого отчета.
Я решил назвать общие данные свойствами (Properties), а конкретные данные для каждого отчета – элементами (Items). Эту идею я подсмотрел в MSBuild/Ant.
Таким образом спецификация, в моем представлении, стала содержать два тега
Раздел Template
Оказалось удобным добавить в спецификацию тег, описывающий шаблон документа:
В данном примере описывается ссылка на файл Microsoft Excel, используемый в качестве шаблона. На сегодня кроме Microsoft Excel поддерживается Microsoft Word и текстовый формат. Однако печать реализована только для Excel и Word (текстовый формат пока что используется только в отладочных целях). Однако добавить поддержку других текстовых форматов не сложно. Как это сделать, вы поймете, прочитав раздел, посвященный реализации печати. Единственное требование к шаблону заключается в том, что он должен быть сохранен в формат .XML или .TXT, так как генератор отчетов просто производит текстовую подстановку специальным образом оформленных имен их значениями.
Во вложенном теге Path должен находиться путь к файлу шаблона.
Во вложенном теге Info может содержаться расширенная информация о шаблоне. Содержимое этого тега должен разбирать объект-printer в своем методе ReadTemplateInfo(). Это позволяет инкапсулировать логику печати в отдельных классах, реализующих интерфейс IPrinter.
Тег Info в данном примере содержит описания печатаемых страниц (worksheets). Каждая страница описывается отдельным тегом Worksheet. Содержимое тега задает название страницы, а атрибут copies – количество копий, которое необходимо распечатать.
Для отчета на базе Microsoft Word тег Info должен содержать только тег Copies, содержимое которого задает количество копий.
Вот как выглядит раздел Template, если шаблоном является документ Microsoft ворд:
Раздел Includes: Импорт других спецификаций
Вынесение вычислений в раздел Properties – это здорово, так как решает проблему дублирования данных на уровне одной спецификации, но, к сожалению, это не решает проблему дублирования данных в разных спецификациях. Так, почти любая спецификация должна содержать реквизиты фирмы, от чьего имени генерируются документы. Логично было бы вынести их в отдельный файл-спецификацию и импортировать его при необходимости в другие спецификации. Именно так я и поступил (после того как скопипастил очередной блок с реквизитами).
Для реализации этой возможности я ввел в формат спецификации еще один коревой тег – Includes. В него должны быть вложены ноль или более тегов Include, которые должны содержать путь к вставляемым (импортируемым) файлам. Если путь не полный, то он считается относительным по отношению к пути файла основной спецификации. Вот как может выглядеть этот раздел:
Раздел Includes – не обязательный.
Я долго думал, нужно ли загружать из импортируемых файлов что-либо кроме раздела Properties. В конце концов я решил не делать этого (возможно, пока), так как мне попросту этого не надо. Потенциально же можно импортировать и другие разделы, за исключением, пожалуй, раздела Template. Даже раздел Includes можно загружать рекурсивно. Однако все это требует усилий от программиста (то есть от меня любимого) и усложняет код. А ведь нет ничего более вредного, чем бесполезная работа :).
Спецификация в целом
Вот как может выглядеть реальная спецификация:
Базовые теги выделены красным. Далее в статье я буду называть их «разделами».
К спецификации предъявляются следующие требования:
- Она должна содержать описание шаблона отчета (XML- или TXT-файла), приведенное выше.
- Она должна содержать теги Items и Properties.
- Теги, непосредственно (т.е. на один уровень вложенности) вложенные в тег Items, обязаны иметь атрибут «id», содержимое которого используется для формирования имени файла отчета для этого элемента (Item-а). При этом к имени самого вложенного тега не предъявляется никаких особых требований.
- Тег Items должен содержать хотя бы один вложенный тег. Иначе просто нечего будет печатать. Каждый вложенный в Items тег рассматривается как описание для одного отчета.
- Отчет может иметь необязательный тег/раздел Includes, позволяющий импортировать в данную спецификацию свойства из других спецификаций.
Задача генератора отчетов – прочесть файл спецификации и на его основе построить словарь (ассоциативный массив), в котором именем выступает композиция имен тегов (вложенных в разделы Properties и Items), а значением – содержимое тегов.
Для вложенных тегов формируются композитные имена. Например, для:
будет сформирована строка:
будет сформирована строка:
Отдельные части имен, как видите, разделяются знаком «_» (для лучшей читаемости). Знак «=» используется здесь для отделения ключа от значения.
Полагаю, что схема преобразования вложенных тегов в ассоциации ясна. Можно назвать такие ассоциации переменными. Именно так я и буду их называть далее.
Для каждого отчета (т.е. для каждого тега, непосредственно вложенного в тег Items) формируется свой, уникальный набор переменных состоящий из переменных полученных по разделу Properties, и переменных, полученных по тегу (и его вложенным тегам), для которого генерируется отчет. Так для приведенной выше спецификации для «Корсо-М» (тега Фирма атрибут id которого равен «Корсо-М») набор переменных (за исключением импортированных из включаемых спецификаций) будет таким:
А для «ВсяПресса» таким:
Красным выделены переменные, общие для обоих отчетов.
После формирования словаря переменных производится банальная замена по контексту. Для каждого тега непосредственно вложенного в раздел Items создается копия шаблона в памяти, в которой производится контекстная замена. Далее результат помещается в файл, имя которого берется из атрибута «id» этого тега (расширение берется у файла шаблона). После этого полученные файлы открываются и печатаются объектом-принтером. Тем, как будет использоваться печать, заведуют классы, реализующие специальный интерфейс:
За то, какой класс создать, отвечает метод Program.GetPrinter(), который пытается прочесть файл шаблона и определить его тип. Для XML-файлов это делается путем анализа управляющих инструкций, например, в файле Microsoft Excel второй строкой идет инструкция:
Program.GetPrinter() пытается прочесть файл шаблона как XML-файл и найти в нем управляющие инструкции (заключенные в «скобки»: ). Если находится управляющая инструкция , где вместо троеточия содержится формат файла, то производится анализ формата. В случае файла Word progid будет равен «Word.Document», а в случае файла Excel – «Excel.Sheet».
$-нотация
Одна из проблем при работе с данными – это их дублирование. Если говорить о XML-спецификации, то для нее важно, чтобы данные в XML-элементах не дублировали данные из других элементов. Но часто бывает так, что некоторые элементы данных состоят из других (более мелких) или вычисляются из них. Скажем банковские реквизиты удобно использовать единой строкой, но в тоже время в отчете может потребоваться, скажем, номер расчетного счета (то есть часть этих данных). Другим примером являются расчетные значения. Скажем, нам известна сумма заработной платы, из которой надо вычислить значения для налоговых платежей.
Решение проблемы дублирования данных я так же подсмотрел в MSBuild/Ant (в прочем аналогичное решение используется и в строках Nemerle) – это использование $-нотации.
Внутри значений переменных (то есть, тегов) могут встречаться ссылки на другие переменные. Так, банковские реквизиты могут быть описаны следующим образом:
Кроме того, после знака $ может идти выражение, заключенное в скобки:
Как видите, внутри выражений переменные могут использоваться без знака «$». «$» можно считать указанием генератору отчетов вставить в некоторое место значение переменной или формулы.
Выражение может содержать стандартные арифметические операции: +, -, *, /; выражение «if (условие) . else . »; операторы сравнения: ==, !=, >, =, , =, в операторе match.
Модуль Program
Основная логика программы реализована в модуле Program (модуль – это статический класс, в котором просто не надо везде писать ключевое слово static). Этот модуль содержит всего 4 функции. Прежде чем приступить к их описанию, я приведу список using-ов используемый в этом модуле:
Одна из четырех функций – это статический конструктор:
Я использовал его, чтобы вынести инициализацию списка доступных во встроенном скрипте функций в максимально удобное место (вверху основного файла проекта).
Думаю, из комментариев и так понятно, что в нем делается. Скажу только, что логика регистрации доступных функций и их вызова инкапсулирована в модуле ScriptFuncs, который будет описан ниже (см. соответствующий раздел).
Вторым методом является метод Main, который тоже не содержит ничего сложного или выдающегося:
В его задачи входит разбор параметров, переданных приложению из командной строки, и, в зависимости от их содержимого, или вызов основного метода генератора отчетов – DoReport, или выдача подсказки по формату ожидаемых параметров. В случае возникновения исключения на консоль выдается сообщение об исключении и обо всех вложенных исключениях.
Третьим методом является основной метод программы – DoReport. Именно потому, что он основной и самый огромный (непривычно огромный для программиста, выросшего на принципах ООП), его описание я приведу последним.
Четвертый метод – GetPrinter. В его задачи входит определение подтипа (скажем так) файла шаблона и создание соответствующего объекта-принтера. Вот код этого метода:
В начале проверяется, не является ли файл простым текстовым файлом, и если это так, возвращается null. Возврат null этим методом означает, что для данного типа файла не удалось определить объект-принтер. Таким образом, печать плоского текста не поддерживается. Ее нетрудно добавить, но в этом, на мой взгляд, попросту нет нужды.
Зато поддерживается печать фалов форматов Microsoft Excel и Microsoft Word, сохраненных в формате XML. Именно эти форматы и были мне нужны.
Определить, что файл является файлом Microsoft Office, можно, проанализировав его содержимое и попытавшись найти управляющую инструкцию (processing instruction) XML с именем «mso-application». Если при этом значение инструкции будет progid=»Excel.Sheet», то это файл Excel, а если progid=»Word.Document», то Word.
Заметьте, что код очень близок к описанию на естественном языке. Думаю, что нужно всего лишь несколько пояснений, чтобы понять его полностью.
Первое, что требуется пояснить – это что такое RawXml и его метод ReadLazy(). RawXml – это простенькая обертка над XmlReader, которая позволяет превратить обработку сырого (не форматированного, где не производится построение некой объектной модели вроде XmlDom) XML из большого и неуклюжего цикла в набор запросов а-ля LINQ (являющихся на самом деле функциональной записью). Вот код этого класса:
Думаю, что пояснять тут нечего. Все и так очевидно.
Так вот, используя этот класс можно производить последовательный поиск в XML. В данном случае метод Find ищет первую ProcessingInstruction или первый XML-тег. Инструкции идут раньше тегов, поэтому, если найден тег, то инструкции отсутствуют. К тому же в форматах XML из Microsoft Office инструкции, определяющие формат файла, всегда идут первыми (если это не так, код придется переписывать 🙂 ).
Далее найденная инструкция анализируется и определяется тип файла. Может возникнуть вопрос, что же такое Some?. Дело в том, что Find может ничего и не найти. Поэтому Find возвращает результат, запакованный в тип option[T]. Это простой вариант с двумя вхождениями None() и Some(value : T). Если ничего не найдено, возвращается None(). В обратном случае возвращается экземпляр Some, в который помещается найденное значение. Это, можно сказать, аналог nullable-тиов, но который может работать как с типами-значениями, так и со ссылочными типами. Таким образом, строка:
означает, что найден некий элемент, отвечающий критериям поиска, его значение помещено (сопоставлено с) в переменную x, и значение поля NodeType у этого элемента равно XmlNodeType.Element, то есть был найден XML-тег, что свидетельствует, что XML-инструкций в файле нет.
Далее, я думаю, все понятно. Единственное, о чем стоит сказать – это о формате строк. аналогичен «progid=\»Excel.Sheet\»» в С или @»progid=»»Excel.Sheet»»» в C#, но не требует ломать глаза.
В общем, на выходе у функции GetPrinter() имеется ссылка на объект-принтер или null, если не удалось распознать тип файла шаблона.
Главный метод приложения DoReport
Метод DoReport не только главный, но еще и самый большой. С непривычки может показаться, что это огромный, что называется, макаронный код, в котором трудно разобраться. Но на самом деле этот метод состоит из множества локальных функций, которые структурируют его состав.
СОВЕТ Сразу хочется предупредить, что положение локальных функций важно. Их нельзя передвинуть куда-то выше или ниже или поменять местами с другими локальными функциями или локальными переменными. Дело в том, что локальные функции захватывают контекст, в котором они объявлены, создавая так называемые замыкания. Это означает, что локальные функции могут использовать локальные переменные и функции, объявленные выше. В каком-то смысле это заменяет классы и позволяет реализовывать нечто похоже на публичные поля, не объявляя ни новых типов, ни полей, и даже не создавая их экземпляров. Конечно, такой подход хорош далеко не во всех случаях, но он очень хорош для быстрого старта или для решения относительно простых задач. В данном проекте содержится всего 9 типов, один из которых – интерфейс, два – реализации этого интерфейса, а четыре – и вовсе обертки. Если писать этот же код в ООП-стиле, то типов были бы многие десятки. Однако преимущество ООП-подхода заключается в том, что поневоле программист вынужден выносить код в методы разных классов, что разгружает код отдельных методов. Локальные же функции вынуждены располагаться, что называется, посередине кода. И это было бы очень прискорбно, если бы не возможность сворачивать их. Ниже приводится код этого метода, так, как бы вы увидели его в IDE (): Первые же строки этого метода демонстрируют описанный в примечании подход: В данном случае вводятся две локальных функции error и warning, которые «замкнуты» на переменную messages. Далее по коду можно использовать эти функции, даже не догадываясь, что на самом деле они производят добавление элементов в messages. Можно даже передать ссылку на эти функции в любое другое место, и все, кто ими воспользуется, также смогут добавить элементы в messages, тоже не подозревая об этом. Другими словами, таким образом можно добиться локальной инкапсуляции . Грамотно пользуясь данным приемом, можно сделать код существенно понятнее, а значит, проще в поддержке. Кстати, HashSet используется здесь, чтобы избежать появления множества однотипных сообщений. HashSet – это новый тип, появившийся в .NET Framework. Ранее вместо него приходилось использовать Dictionary/Hashtable, но при этом приходилось придумывать какое-то ненужное значение. Локальные функции также прекрасно подходят для уменьшения шума в исходниках. Например, если вы не хотите каждый раз писать: когда вам требуется текстовое значение некоторого тега, то вы можете написать небольшую обертку: Что я и сделал. В данном случае выигрыш в символах не велик, но в более сложных случаях он может быть существенным. Далее по коду следуют декларации локальных переменных. В переменную propertyVars в дальнейшем будут помещены значения переменных, полученных по разделу Properties. Как вы помните, эти переменные имеют одинаковые значения для всех отчетов, генерируемых по одной спецификации. Обратите внимание, что Hashtable – это наследник типа Dictionary[K,V], а не нетипизированная Hashtable из первого Framework-а (как это может показаться на первый взгляд). Параметры типов для этого типа автоматически выводятся компилятором. В данном случае выводится тип Dictionary[string, string], что можно увидеть, подведя курсор к имени переменной: Как видите, для переменной выводится не только ее тип, но и то, где она реально объявлена. Это может оказаться важно, так как переменные могут быть объявлены в одной из вложенных друг в друга локальных функции. fillDicЛокальная фунция fillDic – это первая более-менее сложная функция, относящаяся к логике программы. Задача fillDic – читать переданный ему XML-элемент и его вложенные элементы, и формировать по ним набор переменных. Сформированные переменные, после удаления из их значений незначимых пробелов, добавляются в словарь, ссылка на который передается через параметр dictionary. Вот код этой функции: Алгоритм работы функции очень прост. Сначала формируется имя переменной, а затем анализируется, есть ли у обрабатываемого тега вложенные элементы. Если есть, то из имени переменной формируется префикс, заканчивающийся подчеркиванием, и функция вызывается рекурсивно (для каждого вложенного элемента). Если вложенных элементов нет, то в значении XML-элемента удаляются дублирующиеся пробелы, и переменная помещается в словарь. Ключом при этом, естественно, является имя переменной. Рекурсивность функции позволяет формировать переменные из тегов любой глубины вложенности. loadPropertiesСледующей подзадачей является чтение переменных, единых для всех отчетов данной спецификации. Как уже говорилось ранее, такие переменные в данном проекте называется свойствами. Фактически задача чтения переменных едина и реализована в локальной функции fillDic. Так что различия заключаются только в том, откуда их считывают. Таким образом, логичным выглядит создать метод или локальную функцию, которые получали бы из спецификации раздел Properties и скармливали его содержимое функции fillDic. Именно этим и занимается функция loadProperties: Уверен, что пояснять тут нечего. Зато имеется одна загвоздка. Нам нужно загрузить свойства не только из основной спецификации, но и из импортируемых спецификаций, ссылки на которые содержатся в основной спецификации. Процессом выявления путей импортируемых спецификаций занимается функция loadProperties. loadPropertiesНесколько лет назад я написал бы ее с использованием циклов и if-ов. Уверен, что в результате получился бы весьма объемный и запутанный код. Но теперь я могу мыслить функционально, а значит выражать свои мысли более кратко и более понятно. Что же значит «функционально»? В данном случае это значит, что данная функция разделяется (для меня сейчас) на два этапа:
|