上一篇专栏博文中针对PACS终端(或设备终端,如CT设备)与RIS系统之间worklist查询进行了介绍,并着重对比分析了DICOM3.0中各部分对DICOM网络通讯服务的定义。此次通过结合早些时间的博文DICOM医学图像处理:基于DCMTK工具包学习和分析worklist,对DCMTK开源库中提供的storescp.exe和storescu.exe工具的源码进行剖析,从底层深入了解C-STORE服务的触发及响应。
storescp.exe和storescu.exe分别充当着C-STORE服务的SCP和SCU,因此猜测两个工具包一定是利用了DCMTK提供的DcmSCP和DcmSCU类来分别实现的。为了验证自己的想法,从分析storescp.exe源码文件storescp.cc入手,对比storescp.cc与scp.cc,寻找两者的共同点。倘若与我们的设想一致,就新建一个C++本地工程,利用DcmSCP类来建立自己的C-STORE SCP端,期望达到与storescp.exe工具包相同的效果。
storescp.cc是DCMTK提供的storescp.exe工具的源文件。直接打开storescp.cc文件,首先可以看到作者给出的注释:
Purpose概括了该工具的主要用途——是存储服务的提供者,用来响应C-STORE 操作。由于storescp.exe是一个命令行工具,即用户可以通过不同的参数设置或组合来实现不同的功能。因此在storescp.cc文件开头main主函数外部定义了众多全局的命令行参数变量,例如我们上一篇博文DICOM医学图像处理:全面分析DICOM3.0标准中的通讯服务模块中使用的storescp.exe -d 104 -aet OFFIS命令行中的104相对应的参数是opt_port, 从参数opt_前缀亦可以猜出用途。
由于storescp.exe工具是需要与客户端进行交互的,接下来进入到main主函数后的第一件事情就是初始化网络环境。如下图所示,作者利用条件编译(如下图中的红圈所示)以来实现不同系统的网络环境的初始化,由于我使用的是Windows7操作系统,因此条件编译的结果是调用WSAStartup初始化Windows 套接字(Socket)的网络环境。
网络环境的初始化是main函数的第一步操作,随后就是前面提到的命令行参数的解析,因为storescpp.exe是命令行类型的工具包。从文件260行至992行就是命令行参数的解析部分,我们暂时直接跳过该部分,简单地认为该部分实现的功能就是对应参数的赋值。
接下来正式进入到了DICOM模块,
第一步,加载DICOM字典文件。该步骤几乎是所有与DICOM操作相关的文件的第一步。其实就是加载一个预先写好的包含各种DICOM3.0标准中规定的字段的文本文件。
第二步,初始化网络连接,创建网络连接的一个实例,格式为T_ASC_Network(如下图),记住该类型,不要与后面出现的T_ASC_Association混淆了。其实在第一步和第二步之间我们省略了部分代码,该部分主要是用来控制storescp.exe工具的运行机制,是单线程还是多线程。
第三步,进入外层while循环。在利用ASC_initializeNetwork初始化网络环境后,就进入到了while循环内的acceptAssociation函数。这也是正常情况下,while循环中唯一的工作函数。随后就是待连接断开后的收尾工作。
第四步,单步调试进入acceptAssociation函数内部。从函数前面的注释了解到函数是用来接收处理链接请求的,注意看函数的参数,传递进去的是上面我们初始化后获得的网络连接变量net,ascfig是通过解析命令行而获得的配置参数。
第五步,函数acceptAssociation内部开始通过调用ASC_receiveAssociation来尝试获取客户端的连接请求,该函数与socket编程中的accept函数类似,可以设置阻塞模式和非阻塞模式(关于套接字的阻塞模式和非阻塞模式的区别可参见《Windows网络编程》)。调用该函数后storescp.exe工具就暂停,等待客户端发起链接。
第六步,当有客户端连接进入后,ASC_receiveAssociation函数返回,随后会出现众多以ASC_为前缀的函数,主要有ASC_acceptContextsWithPreferredTransferSyntaxes、ASC_setAPTitles、ASC_getApplicationContextName,以及如果发生错误情况下会调用的ASC_rejectAssociation。参照博文DICOM医学图像处理:全面分析DICOM3.0标准中的通讯服务模块中的分析可知,以ASC_为前缀的函数属于DICOM3.0中的Association Managements,主要在第8部分讲解。用来实现上层DIMSE与下层DUL(Dicom Upper Layer Protocol)的沟通,所处的位置如下图(更详细的介绍可参照上一篇博文)。由此可知该部分是在ASC_receiveAssociation接收到客户端连接后,所进行的有关连接层的相关配置。(要留意该部分的配置流程,下面的实例讲解中会重点介绍由于该部分配置错误,导致模拟的工具包无法正常运行【注1】)。
第七步,接收客户端连接并配置完成后就是发送确认消息,即调用函数ASC_acknowledgeAssociation。此处注意从ASC_receiveAssociation函数返回后,后续的相关ASC_函数操作的参数就从T_ASC_Network类型的net变成了T_ASC_Association类型的assoc,其代表的是客户端的连接。
第八步,storescp.exe发送完确认消息后,接下来就需要处理客户端发来的具体请求了。该部分工作包含在processCommands中。
第九步,进入到processCommands函数内部,就是我们所遇到的第二个内部while循环,与第三步中的while外部循环不同的是该内部循环用来接收客户端发来的DIMSE消息的,而第三步中的while循环是用来循环等待客户端连接请求。
第十步,内部while循环内,调用DIMSE_前缀函数,来实现DIMSE层的信息交互,这也是C-ECHO、C-STORE、C-FIND等请求具体实现的部分。首先调用DIMSE_receiveCommand接收客户端的DIMSE消息,随后根据DIMSE中的CommandSet的类型,分别进行处理,如下图所示,可以看出storescp.exe工具能够对C-ECHO和C-STORE进行处理。
至此,通过上述大致的十步我们对storescp.exe源码有了一个较深刻全面的认识。从调用的ASC_和DIMSE_函数来看,storescp.exe完全按照DICOM3.0的网络通讯协议来处理C-ECHO和C-STORE请求的。
根据OFFIS官网给出的关于dcmnet网络通讯包的介绍(http://support.dcmtk.org/docs/mod_dcmnet.html),可知DCMTK库中提供了两个来实现DICOM网络通讯两端的类DcmSCP和DcmSCU。那么storescp.exe为什么没有直接用DcmSCP来实现C-STORE的服务端呢?利用DcmSCP类是否可以来实现storescp.exe工具的功能呢?
参照OFFIS论坛中的一篇帖子(http://forum.dcmtk.org/viewtopic.php?f=1&t=4041&hilit=addPresentationContext&sid=d4de67ef5faada44233de627a062d3b7)中对DcmSCP的简短使用,来具体分析一下DcmSCP类的源码文件scp.cc。
第一步,DcmSCP的构造函数。如下图,在构造函数内部,完成了对网络环境的初始化,这与storescp.exe的main函数起初的代码是相同的。
第二步,根据OFFIS官网提供的类说明文件,在DcmSCP开放的函数中listen用于实现对客户端请求的处理。其余的公共函数从名称上就可以看出大多是配置和获取相关参数的函数,不是以set为前缀就是get,所以接下来重点分析listen函数内部的实现。
第三步,进入到listen函数内部。发现listen函数起始部分是用来加载DICOM字典文件的,与storescp.cc文件中DICOM部分的第一步相同。接下来就是相关的服务开启模式(单线程or多线程,阻塞or非阻塞)。
第四步,随后出现了ASC_initializeNetwork函数,用来初始化网络连接。
第五步,进入到第一个while循环,循环内部调用的是waitForAssociation函数,用来等待客户端的实际连接请求,注意此时的参数也是T_ASC_Network类型的m_net,这一点与storescp.exe情况相同。
第六步,进入到waitForAssociation函数内部,出现的是ASC_receiveAssociation函数,这与storescp.exe源码分析中的第五步相同。随后出现的也是以ASC_为前缀的众多函数,用来配置网络连接的具体参数。
第七步,参数配置完成后,就是发送响应给客户端,即调用ASC_acknowledgeAssociation函数——与storescp.exe源码分析的第七步相同。
第八步,随后scp.cc内部将处理客户端指令的操作封装在了handleAssociation函数内部,需要注意的是此处handleAssociation函数并不需要传递任何参数进去,因为T_ASC_Association类型的变量assoc是保存在DcmSCP内部的私有成员变量——这一点与storescp.exe直接将assoc变量传递给processCommands函数不同【注2】。
第九步,handleAssociation函数内部需要处理的就是DIMSE层面的指令交互工作。因此为了完整接收客户端的DIMSE指令,同样出现了第二个while循环,循环内部调用了DIMSE_receiveCommand指令——这与storescp.exe中的processCommands函数内部相同。
第十步,最后进入到了handleIncomingCommand函数内部,根据DIMSE中的CommandSet类型来分别进行C-ECHO和C-STORE的处理。
至此,DcmSCP的原文件scp.cc分析完成了。通过与storescp.exe的源文件storescp.cc对比发现,两者基本是相同的流程,无非一个是用类来进行了封装,另一个是实现了命令行工具。
两者共同的处理流程如下图所示,
从上图可以看出,我们起初的猜测是正确的,利用DcmSCP是可以实现storescp.exe工具的,那么为什么DCMTK给出的storescp.exe工具包没有用DcmSCP类来实现呢?猜测有可能是DcmSCP类和storescp.exe工具的开发者是并行工作的,因此也就无法使用DcmSCP来实现storescp.exe工具了,并且两者的基本流程是相同的,也没必要对storescp.exe进行再次修改,毕竟该工具的用途有限。
通过上一节的分析,我们决定尝试利用DcmSCP类来构建C-STORE的SCP端,来替代storescp.exe工具。
利用VS2012构建C++的工程,命名为C-STORETest,直接利用DcmSCP类来实现C-STORE的SCP端,具体代码如下:
// C-STORETest.cpp : 定义控制台应用程序的入口点。 // #include "stdafx.h" //add the include files of Dcmtk library #include "dcmtk/config/osconfig.h" #include "dcmtk/dcmdata/dctk.h" #include "dcmtk/dcmnet/scp.h" #include "ZSDcmStoreSCP.h" #include "global.h" int _tmain(int argc, _TCHAR* argv[]) { DcmSCP mStoreSCP; mStoreSCP.setAETitle("ZS-TEST"); mStoreSCP.setPort(104); mStoreSCP.setVerbosePCMode(true); mStoreSCP.setACSETimeout(-1); mStoreSCP.setDIMSETimeout(10000); mStoreSCP.setDIMSEBlockingMode(DIMSE_NONBLOCKING); OFList< OFString > transferSyntaxes; transferSyntaxes.push_back(UID_LittleEndianExplicitTransferSyntax); transferSyntaxes.push_back(UID_LittleEndianImplicitTransferSyntax); mStoreSCP.addPresentationContext(UID_CTImageStorage,transferSyntaxes); //mStoreSCP.SET mStoreSCP.listen(); return 0; }
编译完成后,打开一个cmd客户端,启动storescu.exe与我们自建的C-STORETest进行连接,得到的结果如下:
发现服务端(上图左侧)给出的错误提示显示“无法处理该类型的DIMSE请求”,而客户端(上图右侧)显示连接被终止(Aborting Association)。这是什么原因呢?后来经过自己阅读OFFIS官网关于DcmSCP类的描述后,找到了问题的原因。原来DcmSCP类的实现还处在测试实验阶段,DcmSCP给出的listen函数内部暂时只能够处理C-ECHO请求,
按照官方的提示,要想实现相应C-STORE指令,需要我们手动扩展DcmSCP类。(原来如此,由此看来之所以没有使用DcmSCP来实现storescp.exe工具的真正原因是DcmSCP功能还不完善,并不能处理C-STORE指令)。
既然找到了问题所在,那么我们就按照官方的说法尝试对DcmSCP进行扩展,添加处理C-STORE指令的部分。直接的想法是将storescp.exe工程中的针对C-STORE请求的处理函数提取出来,添加到DcmSCP的扩展类ZSDcmStoreSCP中。因此在ZSDcmStoreSCP类中添加了函数storeSCP、storeSCPCallback,如下图所示:
同样需要对listen函数进行扩展。原本以为只需要重载DcmSCP中处理DIMSE具体CommandSet的函数handleIncomingCommand即可,但是在扩展过程中发现storescp.exe处理C-STORE的函数storeSCP和storeSCPCallback都要以T_ASC_association类型为参数,而DcmSCP的m_assoc是私有变量并且没有留给我们获取该私有变量的接口,因此无法将storeSCP、storeSCPCallback函数直接添加到handleIncomingCommand内部。
为了实现直接利用storeSCP、storeSCPCallback函数,我们必须想办法获得T_ASC_association变量assoc,通过上述对DcmSCP的源码剖析我们可知,在waitForAssociation函数内部调用ASC_receiveAssociation之后,DcmSCP才从T_ASC_Network出获得到了客户端的T_ASC_association连接变量assoc。那么我们是否可以通过重载waitForAssociation函数来提取出assoc变量呢?在尝试这给ZSDcmStoreSCP类添加新的成员变量T_ASC_association* zs_assoc后,将waitForAssociation内部的调用基类DcmSCP中m_assoc的地方统统换成zs_assoc,直接替换后发现,我们需要重载的不只waitForAssociation一个函数,诸如dropAndDestroyAssociation、negotiateAssociation、refuseAssociation等等函数都需要重新覆盖,关键是waitForAssociation内部的核心函数handleAssociation也要覆盖,因为该函数并不需要任何参数(见上一节的【注2】),其内部直接调用的就是DcmSCP的私有成员变量m_assoc,因此为了提取一个m_assoc变量,我们需要将DcmSCP基类中的众多函数进行重写,这还算上的是对DcmSCP的派生扩展么?
在没有找到很好的解决办法之前,我将waitForAssociation函数和handleAssociation函数内部的源码直接剪切出来一起放到了listen函数内部,然后用我们自己的zs_assoc替换掉了DcmSCP的m_assoc,如是从ZSDcmStoreSCP外观上看来我只对DcmSCP类的listen函数、handleIncomingCommand函数进行了扩展而已,另外将storescp.exe中的storeSCP和storeSCPCallback函数添加到ZSDcmStoreSCP类中,为了使得上述两个函数能够顺利运行,也添加了些许storescp.exe中定义的命令行参数变量。
将上述完成的ZSDcmStoreSCP类添加到工程中,创建ZSDcmStoreSCP对象,调用listen()函数(具体代码如下),启动服务端程序,同样利用storescu.exe进行客户端模拟,测试发现连C-ECHO也无法响应。
// C-STORETest.cpp : 定义控制台应用程序的入口点。 // #include "stdafx.h" //add the include files of Dcmtk library #include "dcmtk/config/osconfig.h" #include "dcmtk/dcmdata/dctk.h" #include "dcmtk/dcmnet/scp.h" #include "ZSDcmStoreSCP.h" #include "global.h" int _tmain(int argc, _TCHAR* argv[]) { ZSDcmStoreSCP mZSStoreScp; mZSStoreScp.setAETitle("ZS-TEST"); mZSStoreScp.setPort(104); mZSStoreScp.setVerbosePCMode(true); mZSStoreScp.setACSETimeout(-1); mZSStoreScp.setDIMSETimeout(10000); mZSStoreScp.setDIMSEBlockingMode(DIMSE_NONBLOCKING); OFList< OFString > transferSyntaxes; transferSyntaxes.push_back(UID_LittleEndianExplicitTransferSyntax); transferSyntaxes.push_back(UID_LittleEndianImplicitTransferSyntax); mZSStoreScp.addPresentationContext(UID_CTImageStorage,transferSyntaxes); //mStoreSCP.SET mZSStoreScp.listen(); return 0; }
从上图可以看书,客户端在发送完A-ASSOCIATE-RQ消息后,再无响应而直接出现了连接请求失败。再次利用上一篇博文中使用的RawCap.exe抓取本地回路数据包,利用Wireshark查看数据包发现,客户端在发送完A-ASSOCIATE-RQ后,服务端并未发送A-ASSOCIATE-RSP,因此确定问题出在了我们自己封装的ZSDcmStoreSCP内部。
单步调试后发现,在函数判别过程中,assoc->presentationContextList变量为空,因此猜测对ASC_receiveAssociation接收到客户端的链接assoc后的ASC_相关配置有问题(参见上一节的【注1】)。
通过排查发现在拆解waitForAssociation函数时将ASC_receiveAssociation函数后面的众多ASC_开头的函数给漏掉了,将该段代码补充完全(如下图所示),
重新编译,利用storescu.exe进行测试,测试结果如下,通过使用Wireshark观察RawCap.exe抓取的数据包可以肯定此次C-ECHO请求能够顺利响应,但是对于C-STORE的请求在保存dcm文件的时候出现了问题。目前还未查清楚具体问题出现在什么地方。
上述storescp.cc和scp.cc中出现了大量的ASC_XXX函数和DIMSE_XXX函数,这与我们前一篇博文分析的DICOM网络通讯服务整体结构是相吻合的,借着此次实现C-STORE请求响应的过程再次熟悉一下DICOM3.0标准中的几个概念,尤其是第一次出现配置错误时的TransferSyntax和PresentationContext,加深一下理解。
(该部分同样还是直接摘抄自DICOM3.0标准的英文官方版,同样不做翻译,实在是英文不好,能力有限,静候国内大牛出现)
Presentation Context– the set of DICOM network services used over an Association, as negotiated between Application Entities;includes Abstract Syntaxesand Transfer Syntaxes.
Transfer Syntax– the encoding used for exchange of DICOM information objects and messages.Examples: JPEGcompressed (images), little endian explicit value representation.
Abstract Syntax – the information agreed to be exchanged between applications, generally equivalent to a Service/Object Pair (SOP) Class.Examples : Verification SOP Class, Modality Worklist Information Model Find SOP Class, Computed Radiography Image Storage SOP Class.
Service/Object Pair (SOP) Class– the specification of the networkor media transfer (service) of a particular type of data (object); the fundamentalunit of DICOM interoperability specification.Examples: Ultrasound Image Storage Service, Basic Grayscale Print Management.
Service/Object Pair (SOP) Instance– an information object; a specific occurrence of information exchanged in a SOP Class.Examples: a specific x-ray image.
Presentation Context consists of an Abstract Syntax plus a list of acceptable Transfer Syntaxes. The Abstract Syntax identifies one SOP Class or MetaSOP Class (a collection of related SOP Classes identified by a single Abstract Syntax UID). By listing the Application Entities with their proposed and accepted Presentation Contexts, the Conformance Statement is identifying the set of Information Objects and Service Classes which are recognized by this implementation。
TransferSyntax是Presentation Context属性的一种;
TransferSyntax只对DICOM 消息中的Data Set部分生效。
TransferSyntax是描述真实世界中一种或几种Abstract Syntax的编码规则。
原本希望通过扩展DcmSCP类来轻松加愉快的仿真storescp.exe工具,但是在实现过程中发现dcmtk3.6.0版本中的DcmSCP类设计存在着缺陷,使得后续的继承和扩展比较费力。搜索OFFIS的官网发现,在最近新发布的dcmtk3.6.1版本中,竟然给出了DcmStoreSCP和DcmStoreSCU两个C-STORE请求处理类,而且分别派生自DcmSCP和DcmSCU,不知道OFFIS的大师是否对DcmSCP类进行了修改?后续会对新版DcmSCP与旧版DcmSCP的比较,以及DcmStoreSCP和DcmStoreSCU的使用进行分析,期望找到本文中出现问题的解决方法(注:在下一篇博文中再给出完成的ZSDcmStoreSCP类的代码)。
(未完待续……)
1)storescp.exe与sotrescu.exe的源码剖析:学习C-STORE请求(续)
2)dcmtk3.6.0的DcmSCP与dcmtk3.6.1的DcmSCP分析,以及dcmtk3.6.1的DcmStoreSCP和DcmStoreSCU的使用
3)Dcmtk与fo-dicom保存文件的不同设计模式:单线程VS多线程
4)wlmscpfs.exe与findscu.exe的源码剖析:学习C-FIND请求
时间:2014-09-12