Работа с устройствами печати в C# на примере реализации виртуального принтера

Работа с устройствами печати в C# на примере реализации виртуального принтера

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

Как и в прошлый раз, статья будет полезна для ознакомления разработчикам младшего и среднего звена. В процессе изучения материала, Вы узнаете как можно обращаться к низкоуровневым DLL WinAPI в C# с помощью P/Invoke, как установить, настроить и удалить из системы мониторы печати, драйвера принтера, само устройство печати, открыть и связать порт для перенаправления входных данных с устройства печати на монитор, познакомитесь с ключевыми моментами применения маршалирования. Так же мы на практическом примере разберёмся, как с помощью нашего API можно удобно манипулировать устройствами печати в системе, узнаем как можно перехватить обработанные данные после печати с принтера и, например, отправить их на сервер.

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

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

  • Возможность управлять полным циклом установки устройства печати в системе;
  • Возможность управлять полным циклом удаления устройства печати из системы;
  • Возможность конфигурировать устройство печати;
  • По возможности, оптимизировать повторное использование кода в проекте, а так же сделать структуру API максимально функциональной и удобной;
  • Максимально возможно повысить отказоустойчивость API, но при этом сохранить доступ к низкоуровневым исключениям Win32;
  • Обеспечить совместимость с системами, начиная с Windows XP и заканчивая Windows 10;
  • Возможность обработки данных печати в коде, использующем наш API.

Немного теории

Я не буду рассматривать в данной статье теоретическую базу по печати документов, различиям между принтерами с поддержкой PCL/PostScript и принтерами GDI и прочую базу. Всю необходимую информацию по данной тематике можно в избытке найти на просторах сети. Но кое-что знать всё же необходимо, чтобы лучше понимать как реализовать поставленные перед нами задачи.

Начнём с самого главного — процессом печати в Windows рулит служба Spooler. В директории C:/Windows/System32/ имеется её собственный низкоуровневый драйвер winspool.drv, в котором заложено множество точек входа для обращения к службе печати и выполнения целого ряда действий, от получения системной директории драйверов печати и имени установленного по умолчанию принтера в системе до манипуляций с очередью задач на печать. Для тех, кто хочет написать собственный монитор (о чём, возможно, я когда-нибудь так же расскажу в одной из будущих статей), помимо winspool.h ещё понадобится winsplp.h из DDK, представляющий дополнительный функционал для сборки драйвера в соответствии спецификации Spooler.

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

P/Invoke

Platform Invocation Services (PInvoke) — платформа для обращения к точкам входа DLL (функциям), написанным в неуправляемом коде (C/C++ и WinAPI), из кода управляемого (C#/VB и .NET). Для того, чтобы обратиться к низкоуровневому коду из .NET, нам нужно описать External-методы из DLL, а так же структуры, которые в них используются. Рассмотрим каждый из сучаев по порядку на примерах.

Пример 1. Вызов метода GetPrinterDriverDirectory из драйвера winspool.drv. Первым делом, нам нужно знать, что возвращает метод и что принимает в аргументах при вызове. Для этого лезем в документацию и читаем описание сигнатуры метода. Обратите внимание, в дальнейшем к документации низкоуровневого API нам придётся обращаться сплошь и рядом, далее по ходу статьи я не буду больше указывать о необходимости этого действия при реализации тех или иных методов/структур, т.к. они требуются по умолчанию.

Описание каждого параметра функции так же можно найти в документации. Здесь важно понимать, что параметры могут быть только входными (In), только выходными (Out), как входными, так и выходными одновременно (In/Out), а так же опциональными (либо входными, либо выходными, в зависимости от других параметров). Так же нам нужно знать, какой тип данных в .NET нужно сопоставить WDT-типу (здесь, в большинстве случаев, работает правило «размеры выделяемой памяти значимым типам .NET соответствуют размерам выделяемой памяти базовым типам C++, для остальных есть IntPtr»).

Теперь, опираясь на полученные сведения, опишем метод в управляемом коде на C#:

Атрибут DllImportAttribute позволяет нам указать параметры обращения к низкоуровневой точке входа. В WinAPI большинство функций написаны с учётом двух основных кодировок — Unicode и ANSI, функции имеют окончания W и A соответственно. Если нам нужно обратиться к конкретному методу в кодировке, но при этом мы не хотим рефакторить основное имя описываемого метода, мы можем указать имя точки входа явно, передав его в соответствующий аргумент атрибута (например, GetPrinterDriverDirectoryW). Так же при этом не забываем указать аргумент CharSet = CharSet.Unicode (в нашем случае, кодировка определяется автоматически). По всем другим полезным атрибутам можно найти информацию в официальной документации.

Атрибут InAttribute в большинстве случаев можно опустить, т.к. аргументы в методах C# по умолчанию передаются по значению. Атрибут OutAttribute мы указываем в тех случаях, когда тип передаваемого аргумента — ссылочный, но данные должны быть выходными. Для выходных данных аргументов значимых типов мы указываем ref, т.е. передаём аргумент по ссылке.

Пример 2. Вызов метода AddMonitor из драйвера winspool.drv.

В данном примере используется структура данных MONITOR_INFO_2, которую нам нужно будет предварительно описать в коде C#.

Здесь важно понимать, что структуры, говоря «народным» языком, — это набор выделенных участков памяти определенного размера, хранящих значения. Каждый из таких участков представляет собой поле данных определённого типа. Ни о каких именах полей и их атрибутах речи не идёт, никаких метаданных, присущих изменяемым типам (классам), только размеры выделяемой памяти для хранения данных. Это означает, что имена членов структуры, как и само имя структуры, могут быть любыми, важно соблюсти соответствие выделяемых размеров памяти каждому члену. Для таких целей существует атрибут StructLayoutAttribute, позволяющий управлять физическим размещением полей данных класса или структуры в памяти. Есть множество способов управления размещением данных полей в памяти, можно явно задавать смещение полей, указывать абсолютный размер структуры, задавать кодировку для способа маршалирования, упаковку, размещать сегменты полей в порядке очерёдности друг за другом и т.д. Примеры реализаций этих способов можно найти здесь. Конкретно для нашей задачи отлично подойдёт последний способ, который мы указываем через LayoutKind.Sequential.

Как это работает: мы объявили структуру, указали в атрибуте размещение полей в памяти с помощью LayoutKind.Sequential, указали типы данных полей, для WinAPI типы данных в структурах — значимые, а значит их размер нам известен, в неуправляемом коде это sizeof(), в управляемом — Marshal.SizeOf(). Всё.

Маршалирование

Понятие «маршалирование» (оно же «маршалинг», оно же «Marshalling») описывает процесс преобразования данных, хранимых в памяти, из одного представления в другое. В случае с .NET в целом и P/Invoke в частности — это преобразование типов от неуправляемого кода к CLR. Маршалирование позволяет облегчить процесс работы с памятью в управляемом коде. Для этих целей предусмотрены два главных класса — Marshal и MarshalAsAttribute. Атрибут MarshalAsAttribute позволяет явно задать соответствие типов для преобразования из неуправляемого кода в управляемый (подобно мапированию при сериализации типов). Он может применяться только к полям типа, к параметрам метода с указанием уточнения через param:, а так же к возвращаемому значению через return:. Класс Marshal содержит множество полезных статических методов для работы с указателями, выделения памяти, определения размеров, смещения и прочего. Так же нам пригодится атрибут FlagsAttribute, позволяющий настроить преобразование низкоуровневых битовых флагов в enum'ы C#.

Архитектура будущего API

С теорией разобрались, пора продумать архитектуру нашего будущего API. Здесь нет какой-либо конкретной или оптимальной философии, каждый сам выбирает нужные паттерны проектирования кода в зависимости от условий решаемой задачи. Для нашего случая я решил поступить следующим образом: весь код будущей библиотеки будет состоять из двух основных модулей — «фабрики» классов и интерфейсов, реализующих эти классы. Публичная реализация будет давать возможность получить список всех установленных компонентов в системе, установить/удалить компонент и прочее. Внутренняя реализация будет работать с маршалированием и P/Invoke. Для специфических случаев мы сможем создавать экземпляры наших классов и вызывать их методы, для базовых случаев будем обращаться к нашей «фабрике». Визуально это всё можно представить примерно следующим образом (рис. 2):

Для решения задачи в рамках статьи нам понадобятся реализации IMonitor, IPort, IDriver и IPrinter, собственно сам класс фабрики PrintingApi, а так же вспомагательные флаги. Остальное пока что опустим.

Кодовая база

Первым делом, давайте напишем базовый интерфейс для всех наших компонентов печати:

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

Теперь напишем основу для нашей фабрики:

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

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

    Вызвать метод в первый раз, чтобы он вернул false и код нативной ошибки 122 («неинициализированный буфер»). В аргументах при этом мы указываем дефолтные значения аргументов (нулевые) для обработки буфера. Это нужно для того, чтобы метод вернул нам изменённые значения инициализации буфера и передал их в наши переменные по ссылке, после того как метод отработает, у нас будет необходимая информация для вызова метода с уже корректными значениями аргументов для инициализации буфера;

Для перехвата и генерации исключений в нашем API предусмотрим отдельный класс:

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

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

Для преобразования Environment в строку и наоборот, реализуем два метода расширения:

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

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

Монитор печати

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

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

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

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

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

Убедимся в работоспособности нашего кода. Добавляем Unit-тест, прописываем константы имени и путей к dll монитора для удобства, реализуем тестовые методы, покрывающие основные сегменты кода:

Не забываем закинуть в тестовый каталог mfilemon.dll монитора печати.

С портом проделываем аналогичные действия. Сперва нам понадобится IPort:

Теперь описываем нативную структуру данных порта:

Spooler предусматривает два способа открытия и закрытия порта: первый — использовать базовые методы AddPort/DeletePort, второй — использовать средства XcvData и различные хэлперы над ним. Второй вариант для нас предпочтительнее, т.к. в первом случае понадобится указатель на диалоговое окно процесса установки, что нам отнюдь не нужно. Для XCV нам дополнительно понадобятся:

Отлично, теперь у нас есть всё что нужно для реализации класса порта:

Теперь расширим наш PrintingApi за счёт внесения функционала для работы с портами:

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

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

Драйвер принтера

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

📎📎📎📎📎📎📎📎📎📎