Мы уже рассказывали, как помогли одной производственной компании трансформировать процессы корпоративного обучения и развития персонала. Сотрудники заказчика, которые тонули в бумажных документах и Excel-таблицах, получили удобное iPad-приложение и веб-портал. Одна из самых важных функций этого продукта – создание динамических отчетов, по которым руководители судят о работе сотрудников «в поле». Это огромные документы с десятками полей и средними размерами в 3000*1600 пикселей.
В этой статье мы поговорим о том, как развернуть эту красоту на базе Microsoft SQL Server Reporting Services, почему такой бэкенд может плохо дружить с веб-порталом и какие хитрости помогут наладить их отношения. Вся бизнес-часть решения уже описана в предыдущей статье, поэтому здесь мы сосредоточимся на технических вопросах. Начнем!
Постановка задачи
У нас есть портал, с которым работают несколько сотен пользователей. Они выстроены в ступенчатую иерархию, где у каждого юзера есть супервайзер рангом выше. Это разграничение прав нужно, чтобы пользователи могли создавать мероприятия с любыми подчиненными сотрудниками. Через ступени можно перепрыгивать, т.е. пользователь может завести активность с сотрудником любого ранга ниже, чем у него самого.
Какие здесь подразумеваются мероприятия? Это может быть обучение, сопровождение или аттестация сотрудника торговой компании, которую супервайзер проводит в торговой точке. Результатом такого мероприятия является заполненная на iPad анкета с оценками сотрудника по показателям профессиональных качеств и навыков.
По данным анкет можно готовить статистику, например:
- Сколько мероприятий со своими подчиненными такого-то типа Вася Иванов создал за месяц? Сколько из них завершено?
- Каков процент удовлетворительных оценок? На какие вопросы мерчендайзеры отвечают хуже всего? У какого руководителя сотрудники хуже сдают тесты?
Именно такая статистика содержится в Отчетах, которые можно создать через веб-интерфейс, в форматах XLS, PDF, DOCX, вывести на печать. Все эти функции предназначены для руководителей разных уровней.
Содержание и оформление отчетов определены в Шаблонах, позволяющих задавать необходимые параметры. Если в будущем пользователям потребуются новые виды отчетов, в системе есть возможность создать шаблоны, указать изменяемые параметры и добавить шаблон на портал. Все это – без вмешательства в исходный код и рабочие процессы продукта.
Технические условия и ограничения
Портал работает на микросервисной архитектуре, фронт написан на Angular 5. На ресурсе используется JWT-авторизация, поддерживаются браузеры Google Chrome, Firefox, Microsoft Edge и IE 11.
Все данные хранятся на MS SQL Server 2014. На сервер установлен пакет SQL Server Reporting Services (SSRS), заказчик им пользуется и отказываться не собирается. Отсюда самое главное ограничение: доступ к SSRS закрыт извне, так что обратиться к веб-интерфейсу и SOAP можно только из локальной сети через NTLM-авторизацию.
Пара слов о SSRS
SSRS – это серверная платформа отчетности, которая включает готовый набор средств для создания отчетов, управления ими и доставки пользователям. Как можно прочитать на docs.microsoft.com, у SSRS есть множество различных инструментов для программного использования (API) и интерактивного взаимодействия (есть Report Server, с веб-интерфейсом и доступом по HTTP).
Внимание, вопрос: как выполнить задачу без ручных методов, с минимальными затратами ресурсов и максимальной пользой для заказчика?
Так как у заказчика есть на выделенном сервере SSRS, пусть SSRS и делает всю грязную работу по генерации и экспорту отчетов. Тогда нам не придется писать собственный сервис составления отчетов, модули экспорта в XLS, PDF, DOCX, HTML и соответствующий API.
Таким образом, задача состояла в том, чтобы подружить SSRS с порталом и обеспечить работу указанных в задании функций. Вот и пойдем по списку этих сценариев – интересные тонкости обнаружились практически в каждом пункте.
Структура решения
Поскольку SSRS у нас уже есть, есть и все инструменты для управления шаблонами отчетов:
- Report Server – отвечает за всю логику работы с отчетами, их хранение, генерацию, управление и многое другое.
- Report Manager – сервис с веб-интерфейсом для управления отчетами. Здесь можно загрузить на сервер созданные в SQL Server Data Tools шаблоны, настроить права доступа, источники данных и параметры (в том числе изменяемые при запросах на создание отчета). Он же умеет генерировать отчеты по загруженным шаблонам и выгружать их в различные форматы, включая XLS, PDF, DOCX и HTML.
Итого: создаем шаблоны в SQL Server Data Tools, с помощью Report Manager заливаем их на Report Server, настраиваем – и готово. Можем генерировать отчеты, менять их параметры.
Следующий вопрос: как запрашивать генерацию отчетов по конкретным шаблонам через портал и получать результат на фронт для вывода на UI или скачивания в нужном формате?
Передача отчетов из SSRS на портал
Как мы говорили выше, для доступа к отчетам у SSRS есть свое API. Но мы не хотим отдавать его функции наружу из соображений безопасности и цифровой гигиены – нам нужно только запросить у SSRS данные в нужном виде и передать результат пользователю. Менеджментом отчетов будут заниматься специально обученные сотрудники заказчика.
Поскольку доступ к SSRS есть только из локальной сети, обмен данными между сервером и порталом идет через прокси-сервис.
Обмен данными между порталом и сервером
Разберемся, как это работает и зачем здесь ReportProxy.
Итак, со стороны портала мы имеем ReportService, к которому обращается портал за отчетами. Сервис проверяет авторизацию пользователя, уровень его прав, преобразует данные от SSRS в нужный вид по контракту.
API ReportService содержит всего 2 метода, которых нам вполне достаточно:
- GetReports – предоставляет идентификаторы и имена всех шаблонов, которые может получить текущий пользователь;
- GetReportData(format, params) – отдает готовенькие, экспортированные данные отчета в указанном формате, с заданным набором параметров.
Теперь нужно, чтобы эти 2 метода умели общаться с SSRS и забирать из него нужные данные в нужном виде. Из документации известно, что мы можем получить доступ к серверу отчетов по HTTP с помощью SOAP API-интерфейса. Вроде бы паззл складывается… Но на самом деле здесь нас поджидает сюрприз.
Поскольку SSRS закрыт для внешнего мира и достучаться до него можно только через NTLM-авторизацию, напрямую с портала SOAP недоступен. Есть и наши собственные пожелания:
- Дать доступ только к необходимому набору функций, да еще запретить изменение;
- Если придется перейти на другую систему генерации отчетов, правки в ReportService должны быть минимальными, а лучше вовсе не требоваться.
Тут-то нам и помогает ReportProxy, который находится на одной машине с SSRS и отвечает за проксирование запросов от ReportService к SSRS. Обработка запроса выглядит следующим образом:
- сервис получает запрос от ReportService, проверяет JWT-авторизацию;
- в соответствии с методом API, прокси идет по SOAP-протоколу в SSRS за нужными данными, по пути авторизуясь через NTLM;
- полученные от SSRS данные отправляются обратно в ReportService в ответе на запрос.
Фактически, ReportProxy – это адаптер между SSRS и ReportService.
Контроллер выглядит следующим образом:
Здесь атрибут BasicAuthentication имеет следующую реализацию:
[BasicAuthentication]
public class ReportProxyController : ApiController
{
[HttpGet()]
public List<ReportItem> Get(string rootPath)
{
// Реализация ...
}
public HttpResponseMessage Post([FromBody]ReportRequest request)
{
// Реализация ...
}
}
Здесь атрибут BasicAuthentication имеет следующую реализацию:
public class BasicAuthenticationAttribute : AuthorizationFilterAttribute
{
public override void OnAuthorization(HttpActionContext actionContext)
{
var authHeader = actionContext.Request.Headers.Authorization;
if (authHeader != null)
{
var authenticationToken = actionContext.Request.Headers.Authorization.Parameter;
var tokenFromBase64 = Convert.FromBase64String(authenticationToken);
var decodedAuthenticationToken = Encoding.UTF8.GetString(tokenFromBase64);
var usernamePasswordArray = decodedAuthenticationToken.Split(':');
var userName = usernamePasswordArray[0];
var password = usernamePasswordArray[1];
var isValid = userName == BasiAuthConf.Login && password == BasiAuthConf.Password;
if (isValid)
{
var principal = new GenericPrincipal(new GenericIdentity(userName), null);
Thread.CurrentPrincipal = principal;
return;
}
}
HandleUnathorized(actionContext);
}
private static void HandleUnathorized(HttpActionContext actionContext)
{
actionContext.Response = actionContext.Request.CreateResponse(
HttpStatusCode.Unauthorized
);
actionContext.Response.Headers.Add(
"WWW-Authenticate", "Basic Scheme='Data' location = 'http://localhost:"
);
}
}
В итоге, процесс выглядит так:
- Фронт отправляет http-запрос к ReportService;
- ReportService отправляет http-запрос в ReportProxy;
- ReportProxy через SOAP-интерфейс получает данные у SSRS и отправляет результат в ReportService;
- ReportService приводит результат в соответствие с контрактом и отдает клиенту.
Мы получили рабочую систему, которая запрашивает список имеющихся шаблонов, ходит в SSRS за отчетами, и отдает их на фронт в любых поддерживаемых форматах. Теперь нужно отобразить генерируемые отчеты на фронте в соответствии с заданными параметрами, дать возможность выгружать их в файлы XLS, PDF, DOCX и выводить на печать. Начнем с отображения.
Работа с отчетами SSRS на портале
На первый взгляд, дело житейское – отчет приходит в HTML-формате, значит мы можем с ним делать все, что душе угодно! Встроим в страничку, подкрасим стилями по дизайну, и дело в шляпе. На деле же оказалось, что тут достаточно подводных камней.
По дизайн-концепции, раздел отчетов на портале должен состоять из двух страничек:
1) список шаблонов, где мы можем:
- посмотреть статистику по активностям за все время работы портала;
- увидеть все доступные нам шаблоны;
- кликнуть по нужному шаблону и перейти к генератору соответствующего отчета.
2) генератор отчетов, который позволяет нам:
- задать параметры шаблона и создать по ним отчет;
- просмотреть, что в результате получилось;
- выбрать формат файла на выходе, скачать его;
- вывести отчет на печать в удобном и наглядном виде.
С первой страничкой особых проблем не было, поэтому мы не будем ее дальше рассматривать. А генератор отчетов заставил нас включить инженера, чтобы всеми функциями по ТЗ было удобно пользоваться реальным людям.
Проблема №1. Гигантские таблицы
Согласно дизайн-концепции, на этой странице должна быть область просмотра, чтобы юзер мог увидеть свой отчет до экспорта. Если отчет не влезает в окно, появляется возможность прокручивать по горизонтали и вертикали. При этом типичный отчет может достигать размеров в несколько экранов, а значит нам нужны прилипающие блоки с наименованиями строк и столбцов. Без этого пользователям придется постоянно возвращаться к началу таблицы, чтобы вспомнить, что означает та или иная ячейка. Или вообще будет проще распечатать отчет и постоянно держать перед глазами нужные листики, но тогда таблица на экране просто теряет смысл существования.
В общем, без прилипающих блоков не обойтись. А SSRS 2014 фиксировать строки и столбцы в MHTML-документе не умеет – только в собственном веб-интерфейсе.
Тут мы вспоминаем, что современные браузеры поддерживают свойство CSS sticky, которое как раз обеспечивает нужную нам функцию. Ставим position: sticky на помеченный блок, указываем отступ слева или сверху (свойства left, top), и блок будет оставаться на месте при горизонтальной и вертикальной прокрутке.
Нужно найти параметр, за который может зацепиться CSS. Собственные значения ячеек, позволяющие SSRS 2014 фиксировать их в веб-интерфейсе, теряются при экспорте в HTML. ОК, будем помечать их самостоятельно – понять бы только, как.
Через несколько часов чтения документации и дискуссий с коллегами казалось, что вариантов нет. И тут по всем законам сюжета нам подвернулось поле ToolTip, которое позволяет указывать всплывающие подсказки для ячеек. Оказалось, что оно пробрасывается в экспортируемый HTML-код в атрибут tooltip – ровно на тот тег, который принадлежит настраиваемой ячейке в SQL Server Data Tools. Выбора не оставалось – другой способ помечать ячейки для фиксации мы не нашли.
Значит, нужно выстроить правила маркировки и пробросить маркеры в HTML через ToolTip. Затем с помощью JS меняем атрибут tooltip на CSS-класс по указанному маркеру.
Есть всего два способа зафиксировать ячейки: по вертикали (fixed-column) и горизонтали (fixed-row). Еще один маркер имеет смысл ставить на угловые ячейки, которые остаются на месте при прокрутке в обе стороны – fixed-both.
Следующий шаг должен сделать UI. При получении HTML-документа нужно найти в нем все HTML-элементы с маркерами, распознать значения, установить соответствующий CSS-класс и удалить атрибут tooltip, чтобы он не вылезал при наведении мыши. Следует отметить, что полученная разметка состоит из вложенных друг в друга таблиц (table-теги).
Смотреть код
type FixationType = 'row' | 'column' | 'both';
init(reportHTML: HTMLElement) {
// Фиксирование строк и столбцов
// - фиксация строк
const rowsFixed: NodeList = reportHTML.querySelectorAll('[title^="RowFixed"]');
// - фиксация столбцов
const columnFixed: NodeList = reportHTML.querySelectorAll('[title^="ColumnFixed"]');
// - фиксация строк и столбцов
const bothFixed: NodeList = reportHTML.querySelectorAll('[title^="BothFixed"]');
this.prepare(rowsFixed, 'row');
this.prepare(columnFixed, 'column');
this.prepare(bothFixed, 'both');
}
// Подготовка ячейки к фиксации
prepare(nodeList: NodeList, fixingType: FixationType) {
for (let i = 0; i < nodeList.length; i++) {
const element: HTMLElement = nodeList[i];
// метка для скрипта-фиксатора
element.classList.add(fixingType + '-fixed');
element.removeAttribute('title');
element.removeAttribute('alt'); // автоматически выставлен SSRS
element.parentElement.classList.add(fixingType + '-fixed-parent');
// фиксация высоты и ширины блока, чтобы фиксация его не деформировала
element.style.width = element.getBoundingClientRect().width + 'px';
// фиксация высоты и ширины блока, чтобы фиксация его не деформировала
element.style.height = element.getBoundingClientRect().height + 'px';
// Каскадирование фиксаций
this.calculateCellCascadeParams(element, fixingType);
}
}
И вот новая проблема: при каскадном поведении, когда в таблице закрепляются сразу несколько блоков, движущихся в одну сторону, идущие друг за другом ячейки наслоятся. При этом непонятно, насколько должен отступить каждый следующий блок – отступы придется высчитывать через JavaScript, исходя из высоты блока перед ним. Все это относится как к вертикальным, так и к горизонтальным закреплениям.
Проблему решил корректирующий скрипт.
// Подсчет отступов ячейки для каскадной фиксации
calculateCellCascadeParams(cell: HTMLElement, fixationType: FixationType) {
const currentTD: HTMLTableCellElement = cell.parentElement;
const currentCellIndex = currentTD.cellIndex;
// Зануление старых значений
currentTD.style.left = '';
currentTD.style.top = '';
const currentTDStyles = getComputedStyle(currentTD);
// По столбцу
if (fixationType === 'row' || fixationType === 'both') {
const parentRow: HTMLTableRowElement = currentTD.parentElement;
// Проходим по всем предыдущим строкам текущей таблицы и
// находим старших фиксированных соседей.
// Суммируем их высоту, чтобы определить верхний отступ.
let previousRow: HTMLTableRowElement = parentRow;
let topOffset = 0;
while (previousRow = previousRow.previousElementSibling) {
let previousCellIndex = 0;
let cellIndexBulk = 0;
for (let i = 0; i < previousRow.cells.length; i++) {
if (previousRow.cells[i].colSpan > 1) {
cellIndexBulk += previousRow.cells[i].colSpan;
} else {
cellIndexBulk += 1;
}
if ((cellIndexBulk - 1) >= currentCellIndex) {
previousCellIndex = i;
break;
}
}
const previousCell = previousRow.cells[previousCellIndex];
if (previousCell.classList.contains(fixationType + '_fixed_parent')) {
topOffset += previousCell.getBoundingClientRect().height;
}
}
if (topOffset > 0) {
if (currentTDStyles.top) {
topOffset += <any>currentTDStyles.top.replace('px', '') - 0;
}
currentTD.style.top = topOffset + 'px';
}
}
// По строке
if (fixationType === 'column' || fixationType === 'both') {
// Проходим по всем предыдущим столбцам текущей строки
// и находим старших фиксированных соседей.
// Аккумулируем их высоты, чтобы определить верхний отступ.
let previousCell: HTMLTableCellElement = currentTD;
let leftOffset = 0;
while (previousCell = previousCell.previousElementSibling) {
if (previousCell.classList.contains(fixationType + '_fixed_parent')) {
leftOffset += previousCell.getBoundingClientRect().width;
}
}
if (leftOffset > 0) {
if (currentTDStyles.left) {
leftOffset += <any>currentTDStyles.left.replace('px', '') - 0;
}
currentTD.style.left = leftOffset + 'px';
}
}
}
Код проверяет теги маркированных элементов и складывает параметры фиксированных ячеек в величину отступа. В случае прилипающих строк складывается их высота, у столбцов – ширина.
Пример отчета с прилипающей верхней строкой
В результате процесс выглядит так:
- Получаем разметку от SSRS и вставляем в нужное место в DOM;
- Распознаем маркеры;
- Корректируем параметры для каскадного поведения.
Поскольку прилипающее поведение полностью реализовано через CSS, а JS участвует только в подготовке входящего документа, решение работает достаточно быстро и без лагов.
К сожалению, для IE прилипающие блоки пришлось отключить, т.к. он не поддерживает свойство position: sticky. Остальные же – Safari, Mozilla Firefox и Chrome – прекрасно справляются с задачей.
Идем дальше.
Проблема №2. Экспорт отчетов
Чтобы вытянуть отчет из системы, нужно (1) обратиться к SSRS через ReportService за Blob-объектом, (2) получить ссылку на объект через интерфейс с помощью метода window.URL.createObjectURL, (3) поместить ссылку в -тег и сымитировать клик для загрузки файла.
Это работает в Firefox, Safari и во всех версиях Chrome, кроме «яблочной». Чтобы IE, Edge и Chrome для iOS тоже поддерживали функцию, пришлось снова раскинуть мозгами.
В IE и Edge событие просто не будет вызывать запрос к браузеру на скачивание файла. У этих браузеров есть такая особенность, что для имитации клика требуется подтверждение пользователя на скачивание, а также четкое указание на дальнейшие действия. Решение нашлось в методе window.navigator.msSaveOrOpenBlob(), который есть и в IE, и в Edge. Он как раз умеет спрашивать разрешение пользователя на операцию и уточнить, что делать дальше. Значит, мы определяем, существует ли метод window.navigator.msSaveOrOpenBlob, и действуем по ситуации.
В Chrome под iOS такого лайфхака не оказалось, и мы вместо отчета получали просто пустую страницу. Поблуждав по Сети, мы нашли похожую историю, судя по которой в iOS 13 этот баг должны были починить. К сожалению, мы писали приложение еще во времена iOS 12, так что в итоге решили больше не тратить время и попросту отключить кнопку в Chrome под iOS.
Теперь о том, как выглядит итоговый процесс экспорта на UI. В Angular компоненте отчета есть кнопка, которая запускает цепочку шагов:
- через параметры события обработчик получает идентификатор формата экспорта (например, “PDF”);
- отправляет запрос в ReportService на получение Blob-объекта для заданного формата;
- проверяет, является ли браузер IE или Edge;
- когда придет ответ от ReportService:
- если это все же IE или Edge – вызывает window.navigator.msSaveOrOpenBlob(fileStream, fileName);
- в противном случае вызывает метод this.exportDownload(fileStream, fileName), где fileStream – это Blob-объект, полученный в результате запроса к ReportService, а fileName – имя файла для сохранения. Метод создает скрытый тег со ссылкой на window.URL.createObjectURL(fileStream), имитирует клик и удаляет тег.
С этим разобрались, осталось последнее приключение.
Проблема №3. Вывод на печать
Теперь мы можем посмотреть отчет на портале и экспортировать его в форматы XLS, PDF, DOCX. Осталось реализовать печать документа, чтобы получить на выходе аккуратный многостраничный отчет. Если таблица оказалась разделена по страницам, каждая из них должна содержать заголовки – те самые прилипающие блоки, о которых мы говорили в позапрошлом разделе.
Самый простой вариант – взять текущую страницу с выведенным отчетом, скрыть все лишнее с помощью CSS и отправить на печать методом window.print(). Этот способ не работает сразу по нескольким причинам:
- Нестандартная область просмотра – сам отчет содержится в отдельно прокручиваемой зоне, чтобы страница не растягивалась до невероятных размеров по горизонтали. Применение window.print() обрезает контент, который не поместился в экран;
- Страницы могут внезапно заканчиваться, обрывая таблицу в самых неожиданных местах;
- Прилипающие блоки остаются на своих местах, значит на страницах не будет повторяющихся заголовков.
Все это можно исправить с помощью JS и CSS, но мы решили сэкономить время разработчиков и поискать альтернативу window.print().
SSRS может сразу отдать нам готовый PDF с презентабельной разбивкой по страницам. Это избавляет нас от всех трудностей предыдущего варианта, вопрос лишь в том, можем ли мы распечатать PDF через браузер?
Поскольку PDF – это стандарт от сторонней компании, браузеры поддерживают его через различные viewer-плагины. Нет плагина – нет мультиков, значит нам снова нужен альтернативный вариант.
А то если вывести PDF на страницу как изображение, а эту страницу отправить на печать? Для Angular уже существуют библиотеки и компоненты, которые обеспечивают такой рендеринг. Поискали, поэкспериментировали, реализовали.
Чтобы не разбираться с данными, которые мы не хотим печатать, было решено передать отрендеренный контент на новую страничку, а там уже выполнять window.print(). В итоге весь процесс выглядят следующим образом:
- Запрашиваем у ReportService экспорт отчета в PDF-формате;
- Получаем Blob-объект, преобразуем его в URL (URL.createObjectURL(fileStream)), отдаем URL PDF-просмотрщику на рендер;
- Забираем изображения из PDF-просмотрщика;
- Открываем новую страничку и добавляем туда немного разметки (тайтл, немного отступов);
- Добавляем в разметку изображения от PDF-просмотрщика, вызываем window.print().
После нескольких проверок на странице также появился JS-код, который перед печатью проверяет, что все изображения загрузились.
Таким образом весь внешний вид документа определяется параметрами шаблона SSRS, а UI в этот процесс не вмешивается. Это сокращает количество возможных багов. Поскольку на печать передаются изображения, мы застрахованы от каких-то повреждений или деформации макета.
Есть и минусы:
- большой отчет будет немало весить, что плохо скажется на мобильных платформах;
- дизайн не обновить автоматически – цвета, шрифты и прочие элементы оформления нужно устанавливать на уровне шаблона.
В нашем случае частое добавление новых шаблонов не предполагалось, так что решение оказалось приемлемым. Производительность на мобильных приняли как данность.
Последнее слово
Вот так обычный проект очередной раз заставляет искать простые решения для нетривиальных задач. Финальный продукт полностью соответствует проектным требованиям и красиво выглядит. А главное, хотя нам пришлось искать не самые очевидные методы реализации, задача выполнена быстрее, чем если бы мы взялись за оригинальный модуль отчетов со всеми вытекающими. И в итоге мы смогли сфокусироваться на бизнес-целях проекта.