我们如何在SSRS 2014上构建动态报告


我们已经讨论过如何帮助一家制造公司改变公司培训和人员发展流程。淹没在纸质文档和Excel电子表格中的客户员工获得了便捷的iPad应用程序和一个Web门户。该产品最重要的功能之一是创建动态报告,经理可以通过该报告来判断“现场”员工的工作。这些是巨大的文档,包含数十个字段,平均大小为3000 * 1600像素。

在本文中,我们将讨论如何基于Microsoft SQL Server Reporting Services部署此功能,为什么这样的后端可能与Web门户成为坏朋友,以及哪些技巧将有助于建立他们的关系。解决方案的整个业务部分已经在上一篇文章中进行了描述,因此在此我们重点关注技术问题。让我们开始吧!


问题的提法


我们有一个供数百个用户使用的门户。它们按逐步的层次结构排列,其中每个用户都有一个较高级别的主管。权限的这种区分是必要的,以便用户可以与任何下属员工一起创建事件。您可以跳过步骤,即 用户可以从比自己低的任何级别的员工处开始活动。

这意味着什么事件?这可以是对贸易公司员工的培训,支持或证明,主管在销售点进行该行为。此类事件的结果是在iPad上填写了调查表,其中对员工的专业素质和技能进行了评分。

根据调查表数据,您可以准备统计信息,例如:

  • Vasya Ivanov在一个月内与他的下属进行了多少活动?其中有多少完成了?
  • 满意评级的百分比是多少?采购员回答最糟糕的问题是什么?哪个经理的考试成绩更差?

这样的统计信息包含在报告,可以通过网络接口来创建,在XLS,PDF,DOCX格式,并打印。所有这些功能都是为不同级别的经理设计的。

报告的内容和设计在模板中定义,可让您设置必要的参数。如果将来用户需要新类型的报告,则系统可以创建模板,指定可修改的参数以及向门户网站添加模板。所有这些-不会干扰产品的源代码和工作流程。

规格和限制


该门户网站运行在微服务架构上,前端使用Angular 5编写。资源使用JWT授权,支持Google Chrome,Firefox,Microsoft Edge和IE 11浏览器

所有数据都存储在MS SQL Server 2014中。SQLServer Reporting Services(SSRS)已安装在服务器上,客户可以使用它,并且不会拒绝。因此,最重要的限制是:从外部无法访问SSRS,因此您只能通过NTLM授权从本地网络访问Web界面和SOAP。

关于SSRS的几句话
SSRS – , , . docs.microsoft.com, SSRS (API) ( Report Server, - HTTP).

请注意以下问题:如何在没有手动方法的情况下以最小的资源和最大的客户收益完成任务?

由于客户在专用服务器上具有SSRS,因此让SSRS进行所有生成和导出报告的工作。然后,我们不必编写自己的报告服务,即可将模块导出到XLS,PDF,DOCX,HTML和相应的API。

因此,任务是使SSRS与门户成为朋友,并确保任务中指定功能的运行。因此,让我们仔细研究一下这些方案的清单-几乎在每个方面都发现了有趣的细微差别。

解决方案结构


由于我们已经有了SSRS,因此有用于管理报告模板的所有工具:

  • 报表服务器-负责处理报表的整个逻辑,报表的存储,生成,管理等等。
  • 报表管理器-具有用于管理报表的网络界面的服务。在这里,您可以将在SQL Server数据工具中创建的模板上载到服务器,配置访问权限,数据源和参数(包括在报告请求时可以更改的参数)。他能够生成有关已下载模板的报告,并将其上传为各种格式,包括XLS,PDF,DOCX和HTML。

总计:我们在SQL Server数据工具中创建模板,借助于报表管理器,我们将其填充在报表服务器上,我们进行配置-并且它已经准备就绪。我们可以生成报告,更改其参数。

下一个问题:如何通过门户网站要求生成有关特定模板的报告,并将结果显示在最前面以输出到UI或以所需格式下载?

从SSRS向门户报告


如上所述,SSRS具有自己的API,可以访问报告。但是出于安全性和数字卫生的原因,我们不想提供其功能-我们只需要以正确的格式向SSRS请求数据,然后将结果传输给用户即可。报表管理将由经过特殊培训的客户人员处理。

由于仅从本地网络访问SSRS,因此服务器和门户之间的数据交换是通过代理服务进行的。


门户网站和服务器之间的数据交换

让我们看看它是如何工作的以及为什么ReportProxy在这里。

因此,在门户网站一侧,我们有一个ReportService,门户网站可以访问该报告服务。该服务检查用户的授权,其权限级别,并将数据从SSRS转换为合同规定的所需格式。

ReportService API仅包含2个方法,对我们来说已经足够了:

  1. GetReports-提供当前用户可以接收的所有模板的标识符和名称;
  2. GetReportData(格式,参数) -使用给定的参数集,以指定的格式提供现成的导出报告数据。

现在,您需要这两种方法才能与SSRS进行通信,并以正确的形式从中获取必要的数据。文档中可以知道,我们可以使用SOAP API通过HTTP访问报表服务器。似乎这个谜团正在发展……但实际上,这里有一个惊喜在等待着我们。

由于SSRS不对外开放,您只能通过NTLM身份验证来访问它,因此不能直接从SOAP门户获得它。我们也有自己的愿望:

  • 仅允许访问所需的功能集,甚至禁止更改;
  • 如果您必须切换到另一个报表系统,则ReportService中的编辑应该最少,最好根本不需要。

这是ReportProxy为我们提供帮助的地方,它与SSRS位于同一台计算机上,并负责将ReportService的请求代理到SSRS。请求处理如下:

  1. 服务接收到来自ReportService的请求,检查JWT授权;
  2. 按照API方法,代理通过SSRS中的SOAP协议获取必要的数据,并一路通过NTLM登录。
  3. 响应该请求,将从SSRS接收的数据发送回ReportService。

实际上,ReportProxy是SSRS和ReportService之间的适配器。
控制器如下:
[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:"
        );
    }
}


结果,该过程如下所示:

  1. 前端将http请求发送到ReportService;
  2. ReportService将http请求发送到ReportProxy;
  3. ReportProxy通过SOAP接口从SSRS接收数据并将结果发送到ReportService;
  4. ReportService根据合同带来结果,并将结果提供给客户端。

我们有一个工作系统,该系统要求提供可用模板列表,并转至SSRS进行报告,并以任何受支持的格式将其显示在最前面。现在,您需要根据指定的参数在正面显示生成的报告,并有机会将其上传到XLS,PDF,DOCX文件并进行打印。让我们从显示开始。

在门户网站中使用SSRS报告


乍看之下,这是日常事务-报告采用HTML格式,因此我们可以做任何想做的事情!我们将其嵌入页面中,以设计样式进行着色,然后事情就发生了。实际上,事实证明有很多陷阱。

根据设计概念,门户网站上的报告部分应包含两个页面:

1)模板列表,我们可以在其中:

  • 查看整个门户网站的活动统计信息;
  • 查看我们可用的所有模板;
  • 单击所需的模板,然后转到相应的报告生成器。



2)报告生成器,使我们能够:

  • 设置模板参数并为其创建报​​告;
  • 查看结果如何;
  • 选择输出文件格式,下载;
  • 以方便,直观的形式打印报告。



第一页没有特殊问题,因此我们将不作进一步考虑。报告生成器迫使我们打开工程师的位置,以便真正的人可以方便地使用TK上的所有功能。

问题编号1。巨人桌


根据设计概念,此页面应具有查看区域,以便用户可以在导出之前查看其报告。如果报表不适合窗口,则可以水平和垂直滚动。同时,一个典型的报告可以达到几个屏幕的大小,这意味着我们需要粘贴带有行和列名称的块。否则,用户将不得不不断返回表格的顶部以记住特定单元格的含义。或者通常,打印报告并不断将必要的叶子放在眼前会更容易,但随后屏幕上的桌子就失去了意义。

通常,不能避免粘附块。而且SSRS 2014不知道如何修复MHTML文档中的行和列-仅在其自己的Web界面中。

在这里,我们回想起现代浏览器支持CSS sticky属性,该属性仅提供我们需要的功能。放置位置:在已标记的块上粘贴,在左侧或顶部指定缩进量(left,top属性),并且在水平和垂直滚动期间该块将保留在原位。

您需要找到CSS可以捕获的参数。允许SSRS 2014在Web界面中捕获它们的自定义单元格值在导出为HTML时丢失。好,我们将自己标记它们-我们只会了解如何。

在阅读了几个小时的文档和与同事讨论之后,似乎没有选择。在这里,根据绘图的所有定律,ToolTip字段为我们打开了,这使我们可以指定单元格的工具提示。原来,它被扔到了工具提示属性中的导出HTML代码中-恰好在属于SQL Server数据工具中自定义单元格的标记上。别无选择-我们没有找到另一种方法标记细胞以进行固定。

因此,您需要通过ToolTip在HTML中构建标记规则和转发标记。然后,使用JS,将工具提示属性更改为指定标记处的CSS类。

只有两种固定单元的方法:垂直(固定列)和水平(固定行)。在角单元格上放置另一个标记是有意义的,当在两个方向上滚动时,这些标记将保留在原处-固定不变。

下一步是执行UI。收到HTML文档时,需要查找其中带有标记的所有HTML元素,识别值,设置适当的CSS类并删除tooltip属性,以便将鼠标悬停时该属性不会出现。应当注意,结果标记由嵌套表(表标记)组成。

查看代码
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';
        }
    }
}


该代码检查标记元素的标签,并将固定单元格的参数添加到缩进值。对于粘附行,对于列,将其高度相加,然后将其宽度相加。


带有粘性顶部报告的示例,

结果如下所示:

  1. 我们从SSRS获取标记,并将其粘贴到DOM中的正确位置;
  2. 识别标记;
  3. 调整级联行为的参数。

由于粘贴行为是通过CSS完全实现的,而JS仅涉及传入文档的准备,因此该解决方案的运行速度足够快且没有滞后。

不幸的是,对于IE,必须禁用粘贴块,因为 它不支持位置:粘性属性。其余的-Safari,Mozilla Firefox和Chrome-表现出色。

继续。

问题编号2。报告导出


要将报告从系统中拉出,您必须(1)通过ReportService访问Blob对象的SSRS,(2)使用window.URL.createObjectURL方法通过接口获取到该对象的链接,(3)将链接放在标记中并模拟点击上传文件。

此功能适用于Firefox,Safari和Apple以外的所有版本的Chrome。为了使IE,Edge和iOS的Chrome浏览器也支持该功能,我不得不全神贯注。

在IE和Edge中,该事件根本不会触发浏览器下载文件的请求。这些浏览器具有这样的功能:为了模拟点击,需要确认用户要下载,以及明确指示进一步的操作。该解决方案在window.navigator.msSaveOrOpenBlob()方法中找到,该方法在IE和Edge中均可用。他只知道如何向用户征求操作许可,并明确下一步要做什么。因此,我们确定window.navigator.msSaveOrOpenBlob方法是否存在,并对此情况采取行动。

iOS上的Chrome没有这种骇客功能,我们没有报告,而是有空白页。在网上漫步时,我们发现了一个类似的故事,判断应该在iOS 13中修复此错误。不幸的是,我们早在iOS 12时就编写了该应用程序,因此最终我们决定不再浪费时间,只需关闭Chrome for iOS中的按钮即可。
现在介绍最终导出到UI的过程。Angular报表组件中有一个按钮,可启动一系列步骤:

  • 通过事件的参数,处理程序将接收导出格式的标识符(例如,“ PDF”);
  • 向ReportService发送请求以接收指定格式的Blob对象;
  • 检查浏览器是IE还是Edge;
  • 当答案来自ReportService时:
    • 如果是IE或Edge,则调用window.navigator.msSaveOrOpenBlob(fileStream,fileName);
    • 否则,它将调用this.exportDownload(fileStream,fileName)方法,其中fileStream是从对ReportService的请求中获取的Blob,而fileName是要保存的文件的名称。该方法使用链接到window.URL.createObjectURL(fileStream)的方法创建一个隐藏标签,模拟单击并删除该标签。

解决了这个问题之后,最后的冒险仍然存在。

问题编号3。打印


现在,我们可以在门户网站上查看报告,并将其导出为XLS,PDF,DOCX格式。为了获得准确的多页报告,仍然需要执行文档的打印。如果事实证明该表分为几页,则每个页面都应包含标题-与上一节中我们讨论过的粘滞块相同。

最简单的选择是使用显示的报告获取当前页面,使用CSS隐藏所有多余的内容,然后使用window.print()方法将其发送以进行打印。由于以下几个原因,该方法无法立即使用:

  1. 非标准查看区域-报表本身包含在可单独滚动的区域中,因此页面不会拉伸到令人难以置信的水平尺寸。使用window.print()修剪不适合屏幕的内容;
  2. , ;
  3. , .

所有这些都可以使用JS和CSS进行修复,但是我们决定节省开发人员的时间,并寻找window.print()的替代方法。

SSRS可以立即为我们提供具有适当分页功能的现成PDF。这使我们免于先前版本的所有麻烦,唯一的问题是,我们可以通过浏览器打印PDF吗?

由于PDF是第三方标准,因此浏览器通过各种查看器插件来支持它。没有插件-没有卡通漫画,因此我们再次需要其他选择。

如果您将PDF作为图像放在页面上,然后发送该页面进行打印? Angular已经有提供此类渲染的库和组件。搜索,试验,实施。

为了不处理我们不想打印的数据,决定将渲染的内容传输到新页面,并且已经执行window.print()。结果,整个过程如下:

  1. 请求ReportService以PDF格式导出报告;
  2. 我们得到Blob对象,将其转换为URL(URL.createObjectURL(fileStream)),将该URL提供给PDF查看器进行渲染;
  3. 我们从PDF查看器中获取图像;
  4. 打开一个新页面,并在其中添加一些标记(标题,一些缩进);
  5. 将PDF查看器中的图像添加到标记中,调用window.print()。

经过多次检查后,页面上还出现了一个JS代码,该代码在打印之前检查是否已加载所有图像。

因此,文档的整体外观由SSRS模板的参数确定,并且UI不会干扰此过程。这减少了可能的错误数量。由于图像正被传输进行打印,因此我们可以确保版面不受损坏或变形。

也有缺点:

  • 较大的报告会占很大的比重,这会对移动平台产生不利影响;
  • 设计不会自动更新-需要在模板级别安装颜色,字体和其他设计元素。

在我们的案例中,预计不会频繁添加新模板,因此该解决方案是可以接受的。移动性能已被视为理所当然。

最后一个字


这就是常规项目再次使我们为非平凡任务寻找简单解决方案的方式。最终产品完全符合设计要求,外观漂亮。最重要的是,尽管我们不必寻找最明显的实现方法,但是完成任务的速度要比采用原始报告模块承担所有后果的速度快。最后,我们能够专注于项目的业务目标。

Source: https://habr.com/ru/post/undefined/


All Articles