Разработка динамических REST-сервисов на документо-ориентированной БД Bagri

от автора

Не так давно, просматривая ленту CNews, наткнулся на анонс конференции “ИТ в здравоохранении: в ожидании прорыва”. Оказывается, “начиная с 2011 г. в России реализуется масштабный государственный проект по внедрению Единой государственной информационной системы в сфере здравоохранения (ЕГИСЗ)”. Углубившись немного в материал обнаружил, что ЕГИЗС базируется на широко используемых на западе стандартах организации Health Language 7 (далее HL7). А в основе стандартов HL7 лежит XML. Появилось желание построить прототип системы, обрабатывающей документы HL7, на документной БД Bagri и, если прототип выйдет удачным, подготовить доклад о нем на конференцию.

image


Пришлось на некоторое время уйти в изучение документов HL7. Потом, кстати, и на Хабре обнаружил неплохой цикл статей об этой технологии от Wayfarer15. Попутно выяснил, что самым последним активно разрабатываемым стандартом в этой области является Fast Healthcare Interoperability Resources (далее FHIR). В основе FHIR лежит технология REST и обмен XML/JSON документами через REST ресурсы.

Как это применимо к Bagri? Оказалось, что вполне: примерно с месяц назад в Bagri добавилась поддержка REST, а также возможность динамического определения ресурсов REST в модулях XQuery с помощью аннотаций RESTXQ. Т.е. любой ресурс FHIR можно динамически создать и опубликовать, даже без рестарта серверов Bagri. Давайте попробуем?

Создаём прототип FHIR-сервера за 45 минут..

Для этого нам понадобятся:

  • последняя версия Bagri, развёрнутая на вашем компьютере, ее можно скачать здесь;
  • тестовый набор данных FHIR, доступен по данной ссылке;
  • базовые знания языка XQuery, с помощью которого мы будем разрабатывать наш прототип:

Создадим новую схему в конфигурационном файле Bagri (<bagri_home>/config/config.xml), назовём её FHIR.

Схема FHIR в конфигурационном файле Багри

<schema name="FHIR" active="true">         <version>1</version>         <createdAt>2016-11-09T23:14:40.096+03:00</createdAt>         <createdBy>admin</createdBy>         <description>FHIR: schema for FHIR XML demo</description>         <properties>             <!-- диапазон портов для серверов схемы -->             <entry name="xdm.schema.ports.first">11000</entry>             <entry name="xdm.schema.ports.last">11100</entry>             <entry name="xdm.schema.members">localhost</entry>             <entry name="xdm.schema.thread.pool">16</entry>             <entry name="xdm.schema.query.cache">true</entry>             <!-- путь к данным схемы, которые хранятся в файлах XML -->             <entry name="xdm.schema.store.data.path">../data/fhir/xml</entry>             <entry name="xdm.schema.store.type">File</entry>             <entry name="xdm.schema.format.default">XML</entry>             <entry name="xdm.schema.partition.count">271</entry>             <entry name="xdm.schema.population.size">1</entry>             <entry name="xdm.schema.buffer.size">64</entry>             <entry name="xdm.schema.store.enabled">true</entry>             <entry name="xdm.schema.data.cache">NEVER</entry>             <entry name="xdm.schema.data.stats.enabled">true</entry>             <entry name="xdm.schema.trans.backup.async">0</entry>             <entry name="xdm.schema.trans.backup.sync">1</entry>             <entry name="xdm.schema.trans.backup.read">false</entry>             <entry name="xdm.schema.data.backup.read">false</entry>             <entry name="xdm.schema.data.backup.async">1</entry>             <entry name="xdm.schema.data.backup.sync">0</entry>             <entry name="xdm.schema.dict.backup.sync">0</entry>             <entry name="xdm.schema.dict.backup.async">1</entry>             <entry name="xdm.schema.dict.backup.read">true</entry>             <entry name="xdm.schema.query.backup.async">0</entry>             <entry name="xdm.schema.query.backup.sync">0</entry>             <entry name="xdm.schema.query.backup.read">true</entry>             <entry name="xdm.schema.transaction.timeout">60000</entry>             <entry name="xdm.schema.health.threshold.low">25</entry>             <entry name="xdm.schema.health.threshold.high">0</entry>             <entry name="xdm.schema.store.tx.buffer.size">2048</entry>             <entry name="xdm.schema.population.buffer.size">1000000</entry>             <entry name="xdm.schema.query.parallel">true</entry>             <entry name="xdm.schema.partition.pool">32</entry>             <entry name="xqj.schema.baseUri">file:/../data/fhir/xml/</entry>             <entry name="xqj.schema.orderingMode">2</entry>             <entry name="xqj.schema.queryLanguageTypeAndVersion">1</entry>             <entry name="xqj.schema.bindingMode">0</entry>             <entry name="xqj.schema.boundarySpacePolicy">1</entry>             <entry name="xqj.schema.scrollability">1</entry>             <entry name="xqj.schema.holdability">2</entry>             <entry name="xqj.schema.copyNamespacesModePreserve">1</entry>             <entry name="xqj.schema.queryTimeout">0</entry>             <entry name="xqj.schema.defaultFunctionNamespace">http://www.w3.org/2005/xpath-functions</entry>             <entry name="xqj.schema.defaultElementTypeNamespace">http://www.w3.org/2001/XMLSchema</entry>             <entry name="xqj.schema.copyNamespacesModeInherit">1</entry>             <entry name="xqj.schema.defaultOrderForEmptySequences">2</entry>             <entry name="xqj.schema.defaultCollationUri">http://www.w3.org/2005/xpath-functions/collation/codepoint</entry>             <entry name="xqj.schema.constructionMode">1</entry>         </properties>         <!-- коллекция документов типа Patient -->         <collections>             <collection id="1" name="Patients">                 <version>1</version>                 <createdAt>2016-11-09T23:14:40.096+03:00</createdAt>                 <createdBy>admin</createdBy>                 <docType>/{http://hl7.org/fhir}Patient</docType>                 <description>All patient documents</description>                 <enabled>true</enabled>             </collection>         </collections>         <fragments/>         <!-- индекс по пути /Patient/id/@value для ускорения поиска по id пациента -->         <indexes>             <index name="idx_patient_id">                 <version>1</version>                 <createdAt>2016-11-09T23:14:40.096+03:00</createdAt>                 <createdBy>admin</createdBy>                 <docType>/{http://hl7.org/fhir}Patient</docType>                 <path>/{http://hl7.org/fhir}Patient/{http://hl7.org/fhir}id/@value</path>                 <dataType xmlns:xs="http://www.w3.org/2001/XMLSchema">xs:string</dataType>                 <caseSensitive>true</caseSensitive>                 <range>false</range>                 <unique>true</unique>                 <description>Patient id</description>                 <enabled>true</enabled>             </index>         </indexes>         <resources>             <!-- базовый ресурс, предоставляющий метаданные, будет доступен по базовому адресу http://localhost:3030/ -->             <resource name="common">                 <version>1</version>                 <createdAt>2016-11-09T23:14:40.096+03:00</createdAt>                 <createdBy>admin</createdBy>                 <path>/</path>                 <module>common_module</module>                 <description>FHIR Conformance resource exposed via REST</description>                 <enabled>true</enabled>             </resource>             <!-- ресурс пациентов, будет доступен по адресу http://localhost:3030/Patient -->             <resource name="patient">                 <version>1</version>                 <createdAt>2016-11-09T23:14:40.096+03:00</createdAt>                 <createdBy>admin</createdBy>                 <path>/Patient</path>                 <module>patient_module</module>                 <description>FHIR Patient resource exposed via REST</description>                 <enabled>true</enabled>             </resource>         </resources>         <triggers/>     </schema> 

Тестовые данные распакуем на локальный диск в директорию <bagri_home>/data/fhir/xml. Про работу с JSON документами в Bagri я писал в предыдущей статье, так что в данном примере для экономии места я покажу только работу с данными в формате XML.

На момент написания статьи спецификация FHIR определяла 110 стандартных ресурсов, доступ к которым может предоставляться сервером. Часть из них является служебными и служит для предоставления информации о самой системе, а остальная часть — это прикладные ресурсы, которые выполняют работу с медицинскими данными. Служебный ресурс Conformance является обязательным для реализации и предоставляет сведения о доступном функционале системы. Наличие или отсутствие остальных ресурсов и их поведение определяется тем, что мы задекларируем в Conformance.

Прикладные ресурсы, согласно спецификации FHIR, могут публиковать следующие методы:

Операции на уровне ресурсов:

  • read — получение текущего состояния заданного идентификатором ресурса
  • vread — получение состояния конкретной версии заданного ресурса
  • update — обновление заданного ресурса
  • delete — удаление заданного ресурса
  • history — получение истории обновлений заданного ресурса

Операции на уровне типа ресурса:

  • create — создание нового ресурса
  • search — поиск среди ресурсов одного типа по разным критериям
  • history — получение истории обновлений по указанному типу ресурса

В показательных целях мы реализуем 2 ресурса: уже обозначенный Conformance и прикладной ресурс Patient. Conformance определит, какой функционал будет доступен клиентам ресурса Patient.

Ниже по тексту будет много смайликов. Не пугайтесь, это издержки синтаксиса XQuery :).

Реализация Conformance для нашего прототипа выглядит довольно просто: cоздадим новый модуль XQuery <bagri_home>/data/fhir/common_module.xq. В заголовке объявим используемую версию языка, пространство имен модуля и пространства имен используемых внешних схем:

xquery version "3.1"; module namespace conf = "http://hl7.org/fhir";  declare namespace rest = "http://www.expath.org/restxq"; 

Далее идет код функции, реализующей требуемое поведение ресурса:

declare    %rest:GET  (: определяет метод HTTP, через который ресурс будет доступен :)   %rest:path("/metadata")  (: определяет путь доступа к ресурсу, относительно базового URL:)   %rest:produces("application/fhir+xml")  (: возвращает данные в формате XML :)   %rest:query-param("_format", "{$format}")  (: принимает один необязательный параметр _format :) function conf:get-conformance($format as xs:string?) as item() {   if (exists($format) and not ($format = ("application/xml", "application/fhir+xml"))) then      "The endpoint produce response in application/fhir+xml format, but [" || $format || "] specified"   else   <CapabilityStatement xmlns="http://hl7.org/fhir">     <id value="FhirServer"/>     <url value="http://localhost:3030/metadata"/>     <version value="1.1-SNAPSHOT"/>     <name value="Bagri FHIR Server Conformance Statement"/>     <status value="draft"/>     <experimental value="true"/>     <date value="{fn:current-dateTime()}"/>     <publisher value="Bagri Project"/>     <contact>         <name value="Maxim Petrov"/>         <telecom>             <system value="other"/>             <value value="@mfalifax"/>             <use value="work"/>         </telecom>     </contact>     <description value="Standard Conformance Statement for the open source Reference FHIR Server provided by Bagri"/>     <kind value="instance"/>     <instantiates value="http://hl7.org/fhir/Conformance/terminology-server"/>     <software>         <name value="Reference Server"/>         <version value="1.1-SNAPSHOT"/>         <releaseDate value="2016-11-10"/>     </software>     <implementation>         <description value="FHIR Server running at http://localhost:3030/"/>         <url value="http://localhost:3030/"/>     </implementation>     <fhirVersion value="1.7.0"/>     <acceptUnknown value="both"/>     <format value="application/fhir+xml"/>     <rest>         <mode value="server"/>         <!-- перечисление типов ресурсов, публикуемых сервером -->         <resource>             <type value="Patient"/>             <profile>                 <reference value="http://fhir3.healthintersections.com.au/open/StructureDefinition/patient"/>             </profile>             <!-- перечисление методов, реализованные ресурсом -->              <interaction>                 <code value="read"/>             </interaction>             <interaction>                 <code value="vread"/>             </interaction>             <interaction>                 <code value="search-type"/>             </interaction>             <interaction>                 <code value="update"/>             </interaction>             <interaction>                 <code value="create"/>             </interaction>             <interaction>                 <code value="delete"/>             </interaction>             <readHistory value="true"/>             <updateCreate value="true"/>             <!-- параметры, доступные к использованию при поиске методом search -->             <searchParam>                 <name value="birthdate"/>                 <definition value="http://hl7.org/fhir/SearchParameter/Patient-birthdate"/>                 <type value="date"/>                 <documentation value="The patient's date of birth"/>                 <!-- поиск по условию equals -->                 <modifier value="exact"/>             </searchParam>             <searchParam>                 <name value="gender"/>                 <definition value="http://hl7.org/fhir/SearchParameter/Patient-gender"/>                 <type value="token"/>                 <documentation value="Gender of the patient"/>                 <modifier value="exact"/>             </searchParam>             <searchParam>                 <name value="identifier"/>                 <definition value="http://hl7.org/fhir/SearchParameter/Patient-identifier"/>                 <type value="token"/>                 <documentation value="A patient identifier"/>                 <!-- поиск по условию contains -->                 <modifier value="contains"/>             </searchParam>             <searchParam>                 <name value="name"/>                 <definition value="http://hl7.org/fhir/SearchParameter/Patient-name"/>                 <type value="string"/>                 <documentation value="A server defined search that may match any of the string fields in the HumanName, including family, give, prefix, suffix and/or text"/>                 <modifier value="contains"/>             </searchParam>             <searchParam>                 <name value="telecom"/>                 <definition value="http://hl7.org/fhir/SearchParameter/Patient-telecom"/>                 <type value="token"/>                 <documentation value="The value in any kind of telecom details of the patient"/>                 <modifier value="contains"/>             </searchParam>         </resource>     </rest>   </CapabilityStatement> }; 

Собственно, это единственный метод, из которого состоит ресурс Conformance. Его задача — определить другие точки доступа к системе и параметры, которыми можно пользоваться в этих взаимодействиях.

Для прикладного ресурса Patient создадим другой модуль XQuery: <bagri_home>/data/fhir/patient_module.xq. Так же в заголовке объявим используемые пространства имен:

module namespace fhir = "http://hl7.org/fhir/patient";  declare namespace http = "http://www.expath.org/http"; declare namespace rest = "http://www.expath.org/restxq"; declare namespace bgdm = "http://bagridb.com/bagri-xdm"; declare namespace p = "http://hl7.org/fhir";  

Реализуем метод read:

declare    %rest:GET  (: определяет метод HTTP, через который ресурс будет доступен :)   %rest:path("/{id}")  (: определяет путь доступа к ресурсу; id - шаблонный параметр пути :)   %rest:produces("application/fhir+xml")  (:  устанавливает формат возвращаемых данных :) function fhir:get-patient-by-id($id as xs:string) as element()? {   collection("Patients")/p:Patient[p:id/@value = $id] }; 

Выглядит, на мой взгляд, весьма привлекательно: реализация требуемого функционала всего в одну строку! Но, как известно, дьявол кроется в деталях. Помимо основного поведения, спецификация FHIR определяет так же многочисленные дополнительные ситуации и статусы и заголовки HTTP, которые сервис обязан возвращать в таких случаях. Попробуем переписать показанный выше метод read с учётом расширенных требований:

declare    %rest:GET   %rest:path("/{id}")   %rest:produces("application/fhir+xml") function fhir:get-patient-by-id($id as xs:string) as element()* {   let $itr := collection("Patients")/p:Patient[p:id/@value = $id]   return     if ($itr) then        (<rest:response>          <http:response status="200">           (: запрашиваемый ресурс имеет версию? :)          {if ($itr/p:meta/p:versionId/@value) then (             (: заголовок ETag должен содержать номер версии найденного ресурса Patient :)            <http:header name="ETag" value="W/"{$itr/p:meta/p:versionId/@value}""/>,             (: заголовок Content-Location должен содержать адрес, по которому доступна последняя версия ресурса :)            <http:header name="Content-Location" value="/Patient/{$id}/_history/{$itr/p:meta/p:versionId/@value}"/>            ) else (             (: иначе заголовок Content-Location должен содержать базовый адрес ресурса :)            <http:header name="Content-Location" value="/Patient/{$id}"/>            )}             (: заголовок Last-Modified должен содержать дату/время последней модификации ресурса :)            <http:header name="Last-Modified" value="{format-dateTime(xs:dateTime($itr/p:meta/p:lastUpdated/@value), "[FNn,3-3], [D] [MNn,3-3] [Y] [H01]:[m01]:[s01] [z,*-6]")}"/>          </http:response>                             </rest:response>, $itr)     else        (: возвращаем статус 404 если пациент с заданным id не найден :)       <rest:response>         <http:response status="404" message="Patient with id={$id} was not found."/>       </rest:response> }; 

Для указания статуса и заголовков ответа HTTP используется структура http:response, которая должна передаваться в первом элементе последовательности возвращаемых данных. Так же обратите внимание, что пришлось изменить тип возвращаемых данных с element()? на element()*, чтобы передать эту служебную информацию на REST сервер.
Конечно, такая полная реализация требований спецификации получается гораздо более многословной. Но не берусь сказать, с помощью какого языка/технологии можно выполнить требования FHIR компактнее. С другой стороны, сильно привлекают возможности XQuery по работе с XML и с последовательностями данных.

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

declare    %rest:GET   %rest:path("/{id}/_history/{vid}")  (: кроме идентификатора здесь в качестве шаблона пути также используется номер версии :)   %rest:produces("application/fhir+xml") function fhir:get-patient-by-id-version($id as xs:string, $vid as xs:string) as element()? {   collection("Patients")/p:Patient[p:id/@value = $id and p:meta/p:versionId/@value = $vid] }; 

Следующий метод — search. В ресурсе Conformance мы указали, что можем выполнять поиск пациентов по 5 параметрам: name, birthday, gender, identifier и telecom. Так же мы указали как именно используется параметр поиска, через элемент modifier, который может принимать следующие значения: missing | exact | contains | not | text | in | not-in | below | above | type. Их описание и соответствующее поведение поисковой системы можно посмотреть здесь.

declare    %rest:GET   %rest:produces("application/fhir+xml")   %rest:query-param("identifier", "{$identifier}")  (: параметры поиска передаём в строке :)   %rest:query-param("birthdate", "{$birthdate}") (: запроса http; все они не обязательные :)   %rest:query-param("gender", "{$gender}")    %rest:query-param("name", "{$name}")   %rest:query-param("telecom", "{$telecom}") function fhir:search-patients($identifier as xs:string?, $birthdate as xs:date?, $gender as xs:string?, $name as xs:string?, $telecom as xs:string?) as element()* { (: получим набор результатов (пациентов), удовлетворяющих условиям поиска :)   let $itr := collection("Patients")/p:Patient[  	(not(exists($gender)) or p:gender/@value = $gender)     and (not(exists($birthdate)) or p:birthDate/@value = $birthdate)      and (not(exists($name)) or contains(data(p:text), $name))      and (not(exists($identifier)) or contains(p:identifier/p:value/@value, $identifier))      and (not(exists($telecom)) or contains(string-join(p:telecom/p:value/@value, " "), $telecom))]  (: возвращаем результаты внутри контейнера Bundle :)   return     <Bundle xmlns="http://hl7.org/fhir">       <id value="{bgdm:get-uuid()}" />  (: сгенерируем уникальный bundle ID :)       <meta>         <lastUpdated value="{current-dateTime()}" />       </meta>       <type value="searchset" />       <total value="{count($itr)}" />       <link>         <relation value="self" />         <url value="http://bagridb.com/Patient/search?name=test" />       </link>       {for $ptn in $itr        return            <entry>            <resource>{$ptn}</resource>          </entry>       }     </Bundle> };   

сreate — создание нового ресурса Patient, либо новой версии уже имеющегося ресурса.

declare    %rest:POST (: создание нового ресурса осуществляется методом POST :)   %rest:consumes("application/fhir+xml")  (: ожидаем получить полное состояние ресурса в теле запроса в формате XML :)   %rest:produces("application/fhir+xml")  (: новое состояние ресурса вернем клиенту в том же формате :) function fhir:create-patient($content as xs:string) as element()? {   let $doc := parse-xml($content)  (: распарсим входную строку в документ XML, заодно и провалидируем его :)   let $uri := xs:string($doc/p:Patient/p:id/@value) || ".xml"  (: сформируем uri нового ресурса :)   let $uri := bgdm:store-document(xs:anyURI($uri), $content, ())  (: сохраним документ и получим в ответ его uri, хотя он не должен отличаться от сформированного нами 2мя строками выше :)   let $content := bgdm:get-document-content($uri)  (: а вот состояние ресурса, в соответствие со спецификацией, может отличаться от полученного на вход, например система могла заполнить некоторые пропущенные поля их значениями по умолчанию :)   let $doc := parse-xml($content)   return $doc/p:Patient   }; 

update — создание новой версии имеющегося ресурса Patient, либо создание нового ресурса, если пациент с заданным идентификатором ещё не зарегистрирован в системе.

declare    %rest:PUT (: изменение существующего ресурса осуществляется методом PUT :)   %rest:path("/{id}"). (: изменяем ресурс соответствующий заданному шаблонному параметру :)   %rest:consumes("application/fhir+xml")     %rest:produces("application/fhir+xml") function fhir:update-patient($id as xs:string, $content as xs:string) as element()? {   for $uri in fhir:get-patient-uri($id)  (: используем утилитную функцию чтобы не дублировать код :)   let $uri := bgdm:store-document($uri, $content, ())   let $content := bgdm:get-document-content($uri, ())   let $doc := parse-xml($content)    return $doc/p:Patient }; 

delete — удаление зарегистрированного в системе ресурса Patient.

declare    %rest:DELETE  (: удаление ресурса, естественно, с помощью DELETE :)   %rest:path("/{id}") function fhir:delete-patient($id as xs:string) as item()? {   for $uri in fhir:get-patient-uri($id)   return bgdm:remove-document($uri)  (: удалить соответствующий ресурсу документ :) }; 

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

declare    %private function fhir:get-patient-uri($id as xs:string) as xs:anyURI? {   (: сформируем динамический запрос XQuery :)   let $query :=  ' declare namespace p = "http://hl7.org/fhir";    declare variable $id external;   for $ptn in fn:collection("Patients")/p:Patient   where $ptn/p:id/@value = $id   return $ptn'     (: выполнив его, получим в ответ uri документа, удовлетворяющего условиям запроса :)   let $uri := bgdm:query-document-uris($query, ("id", $id), ())   return xs:anyURI($uri) }; 

Как видим, в реализации логики управления ресурсами используются функции XQuery, предоставляемые библиотеками Bagri. Вот их краткое описание:

bgdm:get-uuid() as xs:string - сгенерировать уникальный идентификатор uuid bgdm:query-document-uris(xs:string, xs:anyType*, xs:anyAtomicType*) as xs:string* - вернуть uri документов, которые попадают в динамическую выборку XQuery bgdm:store-document(xs:anyURI, xs:string, xs:anyAtomicType*) as xs:anyURI - зарегистрировать в системе новый документ, либо новую версию имеющегося документа bgdm:get-document-content(xs:anyURI) as xs:string* - вернуть текстовое содержимое документа bgdm:remove-document(xs:anyURI) as xs:anyURI - удалить документ 

На этом реализация серверных модулей, выполняющихся логику управления ресурсами FHIR, закончена. Думаю, в 45 минут мы уложились :). В следующей части статьи я хотел бы показать, как запустить разработанные выше ресурсы и оттестировать их. Ну и, конечно, было бы очень интересно послушать, что многоуважаемая аудитория Хабра думает по этому поводу.
ссылка на оригинал статьи https://habrahabr.ru/post/315392/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *