logo

5 февр. 2015 г.

BIEE 11g: тестирование репозитория с помощью DbFit

Все метаданные, описывающие правила трансформации данных из физических источников (DWH, файлы и т.д.) в витрины (предметные области) Oracle BI, содержатся в репозитории Oracle BIServer.

Очевидно, что в ходе создания этих правил могут быть допущены ошибки. Либо в процессе доработки репозитория могут быть допущены неточности, приводящие к некорректной работе трансформаций данных. И как результат - несмотря на корректные данные в физических источниках, пользователи будут получать ошибочные данные в отчетах BI.

Данная статья описывает настройки автоматизированного тестирования репозитория метаданных BI с помощью DBFit.

Основная идея решения по тестированию заключается в том, что по каждой предметной области репозитория BI создается пара SQL запросов:
один к BIServer (тот логический SQL запрос, который отправляет BIPresServer к BIServer; его можно увидеть на вкладке "Дополнительно" в режиме редактирования анализа BI),
другой к DWH (запрос-эталон).
Результаты выполнения запросов сравниваются и возвращается дельта. Если дельта не пустая, то тест считается проваленным.

В этой конструкции основным элементом является Java Stored Procedure, созданная в БД Oracle (DWH) и осуществляющая доступ к BIServer по протоколу JDBC. Полученные из BIServer данные процедура записывает в global temporary table.
В дальнейшем вспомогательная PL/SQL процедура генерирует курсор, сравнивающий данные из gtt-таблицы и результаты запроса-эталона к DWH.

DBFit же является удобным инструментом по быстрому созданию и запуску таких тестов.


Доступ к BIServer из БД Oracle
Предполагается, что DWH построено на базе Oracle Database.
Для осуществления доступа из БД Oracle к БД BIServer (nqsserver) необходимо предварительно загрузить во внутреннюю JVM Oracle набор необходимых классов.

Загрузка jdbc классов BIServer в БД
Необходимы классы, содержащиеся в следующих jar-файлах: dms.jar и bijdbc.jar.
Файлы должны соответствовать той версии OBIEE, с которой планируется работать. Потому и брать файлы нужно из развернутой инсталляции OBIEE:

$MIDDLEWARE_HOME/oracle_common/modules/oracle.dms_11.1.1/dms.jar
$MIDDLEWARE_HOME/Oracle_BI1/bifoundation/jdbc/bijdbc.jar


Для загрузки классов из указанных jar-файлов следует:
- Скопировать jar-файлы по sftp на сервер БД
- Перейти в каталог с jar-файлами
- Выполнить команды в том же порядке, что указано (следует указать корректный пароль sys и при необходимости изменить tns-имя БД):
loadjava -u sys/@DWHORCL -o -r -v -f -noverify -genmissing -synonym -g public dms.jar
loadjava -u sys/@DWHORCL -o -r -v -f -noverify -genmissing -synonym -g public bijdbc.jar


Возможные ошибки можно увидеть в системном представлении all_errors
select *
from all_errors t
where owner = 'SYS' and type = 'JAVA CLASS'


Java Stored Procedure для доступа к BIServer
В БД, содержащей загруженные и скомпилированные классы, следует создать java source с рабочим классом.
Создавать java source как и все последующие объекты БД я буду в рабочей схеме DWH.
create or replace and compile java source named exec_nqsserver_query as
import java.sql.*;
import java.text.*;
import oracle.bi.jdbc.AnaJdbcDriver;
public class execNQSServerQuery {

    private static String convertDataToString(Object dataObj, int dataType) {
        if (dataObj == null)
        return null;
        
        if (dataType==java.sql.Types.BIGINT) {
            return Integer.toString((Integer) dataObj);
        }
        else if (dataType==java.sql.Types.BOOLEAN) {
            return Boolean.toString((Boolean) dataObj);
        }
        else if (dataType==java.sql.Types.DOUBLE) {
            DecimalFormat df = new DecimalFormat("#.######");         
            DecimalFormatSymbols dfs = df.getDecimalFormatSymbols();
            dfs.setDecimalSeparator('.');
            df.setDecimalFormatSymbols(dfs);   
            return df.format((Double) dataObj);
        }
        else if (dataType==java.sql.Types.FLOAT) {
            DecimalFormat df = new DecimalFormat("#.######");         
            DecimalFormatSymbols dfs = df.getDecimalFormatSymbols();
            dfs.setDecimalSeparator('.');
            df.setDecimalFormatSymbols(dfs);    
            return df.format((Float) dataObj);      
        }
        else if (dataType==java.sql.Types.INTEGER) {
            return Integer.toString((Integer) dataObj);
        }
        else if (dataType==java.sql.Types.NVARCHAR) {
            return (String) dataObj;
        }
        else if (dataType==java.sql.Types.VARCHAR) {
            return (String) dataObj;
        }
        else if (dataType==java.sql.Types.TINYINT) {
            return Integer.toString((Integer) dataObj);
        }
        else if (dataType==java.sql.Types.SMALLINT) {
            return Integer.toString((Integer) dataObj);
        }
        else if (dataType==java.sql.Types.DATE) {
            DateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
            return df.format((Date) dataObj);
        }
        else if (dataType==java.sql.Types.TIMESTAMP) {
            DateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
            return df.format((Timestamp) dataObj);
        }
        else {
            return (String) dataObj;
        }
    }

    public static String doQuery(String nqSql, String nqHost, String nqPort, String nqUser, String nqPwd, String sessId) {
        StringBuilder xml = new StringBuilder("<?xml version=\"1.0\" encoding=\"UTF-8\" ?><RESULT>");
        Connection nqsConn = null;
        Statement nqsStmt = null;
        ResultSet nqsRs = null;
        Connection localConn = null;
        PreparedStatement localStmt = null;
        try {
            Class.forName("oracle.bi.jdbc.AnaJdbcDriver");
            
            nqsConn = DriverManager.getConnection("jdbc:oraclebi://" + nqHost + ":" + nqPort + "/", nqUser, nqPwd);
            nqsStmt = nqsConn.createStatement();
            nqsRs = nqsStmt.executeQuery(nqSql);
            ResultSetMetaData nqsRsmd = nqsRs.getMetaData();
            
            int colCount = nqsRsmd.getColumnCount();
            
            xml.append("<COL_COUNT>").append(colCount).append("</COL_COUNT><DTYPES>");
            
            StringBuilder localColumnList = new StringBuilder();
            StringBuilder localValueList = new StringBuilder();
            StringBuilder localSql = new StringBuilder();
            
            for (int i = 1; i <= colCount; i++) {
                xml.append("<DT>").append(nqsRsmd.getColumnType(i)).append("</DT>");
                localColumnList.append("C").append(i).append(",");
                localValueList.append("?,");
            }
            localColumnList.append("SESS_ID");
            localValueList.append("?");
            
            xml.append("</DTYPES>");
            
            localSql.append("insert into NQS_TEMP_TABLE(").
                     append(localColumnList).
                     append(") values (").
                     append(localValueList).
                     append(")");
            
            localConn = DriverManager.getConnection("jdbc:default:connection:");
            localConn.setAutoCommit(false);
            
            localStmt = localConn.prepareStatement(localSql.toString());
            
            while (nqsRs.next()) {
                for (int i = 1; i <= colCount; i++) {
                    localStmt.setString(i, convertDataToString(nqsRs.getObject(i), nqsRsmd.getColumnType(i)));
                }
                localStmt.setString(colCount+1, sessId);
                localStmt.addBatch();
            }
            localStmt.executeBatch();
            /*localConn.commit();*/
            
            return xml.append("<RESULT_CODE>SUCCESS</RESULT_CODE><ERROR_MESSAGE></ERROR_MESSAGE></RESULT>").toString();
        }
        catch (Exception e) {
            e.printStackTrace();
            return "<?xml version=\"1.0\" encoding=\"UTF-8\" ?><RESULT><RESULT_CODE>ERROR</RESULT_CODE><ERROR_MESSAGE>" + e.toString() + "</ERROR_MESSAGE></RESULT>";
        }
        finally {
            if(nqsRs != null){
                try {
                    nqsRs.close();
            } catch (Exception e) {}
            }
            if(nqsStmt != null){
                try {
                    nqsStmt.close();
            } catch (Exception e) {}
            }
            if(nqsConn != null){
                try {
                    nqsConn.close();
            } catch (Exception e) {}
            }
            if(localStmt != null){
                try {
                    localStmt.close();
            } catch (Exception e) {}
            }
            if(localConn != null){
                try {
                    localConn.close();
            } catch (Exception e) {}
            }
        }
    }
}


В качестве "шлюза" для данных, полученных по JDBC, используется GTT-таблица NQS_TEMP_TABLE
create global temporary table NQS_TEMP_TABLE
(
  sess_id number,
  c1 varchar2(255),
  c2 varchar2(255),
  c3 varchar2(255),
  c4 varchar2(255),
  c5 varchar2(255),
  c6 varchar2(255),
  c7 varchar2(255),
  c8 varchar2(255),
  c9 varchar2(255),
  c10 varchar2(255),
  c11 varchar2(255),
  c12 varchar2(255),
  c13 varchar2(255),
  c14 varchar2(255),
  c15 varchar2(255),
  c16 varchar2(255),
  c17 varchar2(255),
  c18 varchar2(255),
  c19 varchar2(255),
  c20 varchar2(255)
)
on commit delete rows


Код PL/SQL-обертки для java-процедуры:
create or replace package PKG_NQSSERVER_QUERY as
 ...
  function EXEC_NQSSERVER_QUERY(p_nqSql     in varchar2,
                                p_nqHost    in varchar2,
                                p_nqPort    in varchar2,
                                p_nqUser    in varchar2,
                                p_nqPwd     in varchar2,
                                p_sessionId in varchar2) return varchar2;
 ...
end PKG_NQSSERVER_QUERY;
/

create or replace package body PKG_NQSSERVER_QUERY as
  /*Параметры обращения к BIServer*/
  G_nqHost varchar2(150) := '192.168.10.1';
  G_nqPort varchar2(150) := '9703';
  G_nqUser varchar2(150) := 'weblogic';
  G_nqPwd  varchar2(150) := 'Admin123';


  /*Java Stored Procedure, запускающая логический SQL запрос на стороне BIServer*/
  function EXEC_NQSSERVER_QUERY(p_nqSql     in varchar2,
                                p_nqHost    in varchar2,
                                p_nqPort    in varchar2,
                                p_nqUser    in varchar2,
                                p_nqPwd     in varchar2,
                                p_sessionId in varchar2) return varchar2 is
    language java name 'execNQSServerQuery.doQuery(java.lang.String,java.lang.String,java.lang.String,java.lang.String,java.lang.String,java.lang.String) returns java.lang.String';
 ...
end PKG_NQSSERVER_QUERY;
/

Как видно из кода - общие параметры вызова (хост, порт, логин и пароль для доступа к рабочей среде BIServer) указаны непосредственно в теле пакета.

Необходимые права для работы JSP
Java Stored Procedure обращается по сети к BIServer, а значит на нее дейстуют ограничения по доступу в Oracle.
Необходимо явно указать на какой хост, какой порт и какой схеме БД можно осуществлять сетевые обращения.
Для этого под поономочиями sys запускаем процедуру:
begin
  dbms_java.grant_permission('DWH',
                             'SYS:java.net.SocketPermission',
                             '192.168.10.1:9703',
                             'connect,resolve');
end;
/


Процедура сравнения результатов запросов к DWH и к BIServer
Теперь, когда имеется процедура, обращающаяся по JDBC к удаленному BIServer,
возвращающая данные во временную таблицу NQS_TEMP_TABLE, необходимо создать процедуру,
сравнивающую результирующие наборы данных запросов к BI и к DWH.
  PROCEDURE GET_DWH_BI_DIFF(p_dwh_query in varchar2,
                            p_bi_query  in varchar2,
                            x_cursor    out SYS_REFCURSOR) as
  
    l_cursor     int;
    l_column_cnt int;
    l_columns    dbms_sql.desc_tab;
  
    l_nqs_result   varchar2(1024);
    l_metadata_xml xmltype;
  
    l_columns_list     varchar2(1024);
    l_columns_order    varchar2(255);
    l_column_data_type integer;
  
    l_stmnt varchar2(32000);
  
  BEGIN
    /*dbms_java.set_output(10000);*/
  
    l_cursor := dbms_sql.open_cursor;
    dbms_sql.parse(l_cursor, p_dwh_query, dbms_sql.native);
    dbms_sql.describe_columns(l_cursor, l_column_cnt, l_columns);
    dbms_sql.close_cursor(l_cursor);
  
    l_nqs_result := pkg_nqsserver_query.exec_nqsserver_query(p_nqSql     => p_bi_query,
                                                             p_nqHost    => G_nqHost,
                                                             p_nqPort    => G_nqPort,
                                                             p_nqUser    => G_nqUser,
                                                             p_nqPwd     => G_nqPwd,
                                                             p_sessionId => 1);
  
    dbms_output.put_line(l_nqs_result);
    l_metadata_xml := xmltype(l_nqs_result);
  
    for i in 1 .. l_column_cnt loop
      l_column_data_type := l_metadata_xml.extract('//DT[' || i ||']/text()')
                            .getStringVal();
    
      if (l_column_data_type in
         (G_DATATYPE_DATE, G_DATATYPE_TIME, G_DATATYPE_TIMESTAMP)) then
        l_columns_list := l_columns_list || ',to_date(C' || i ||
                          ',''YYYY-MM-DD HH24:MI:SS'')';
      
      elsif (l_column_data_type in
            (G_DATATYPE_TINYINT,
              G_DATATYPE_SMALLINT,
              G_DATATYPE_INTEGER,
              G_DATATYPE_BIGINT,
              G_DATATYPE_FLOAT,
              G_DATATYPE_REAL,
              G_DATATYPE_DOUBLE,
              G_DATATYPE_NUMERIC,
              G_DATATYPE_DECIMAL)) then
        l_columns_list := l_columns_list || ',to_number(C' || i || ')';
      else
        l_columns_list := l_columns_list || ',C' || i;
      end if;
    end loop;
  
    l_columns_list := ltrim(l_columns_list, ',');
  
    for i in 1 .. l_column_cnt - 1 loop
      l_columns_order := l_columns_order || ',' || (i + 1);
    end loop;
  
    if (l_columns_order is not null) then
      l_columns_order := 'ORDER BY ' || ltrim(l_columns_order, ',') ||
                         ', 1';
    end if;
  
    execute immediate 'ALTER SESSION SET NLS_NUMERIC_CHARACTERS=''.,''';
  
    l_stmnt := 'SELECT ' || l_columns_list || ' from NQS_TEMP_TABLE t';
  
    l_stmnt := 'SELECT * FROM (SELECT ''DWH'' as DELTA, q1.* FROM ((' ||
               p_dwh_query || ') MINUS (' || l_stmnt || ')) q1 UNION ALL ' ||
               'SELECT ''BI'' as DELTA, q2.* FROM ((' || l_stmnt ||
               ') MINUS (' || p_dwh_query || ')) q2) ' || l_columns_order;
  
    OPEN x_cursor FOR l_stmnt;
  
  END GET_DWH_BI_DIFF;


Полный код пакета PKG_NQSSERVER_QUERY доступен здесь.

Пример вызова
Протестировать работу процедуры можно следующим PL/SQL блоком:
begin
  pkg_nqsserver_query.get_dwh_bi_diff(p_dwh_query => 'select count(f.id)+1 as query_cnt 
                                                      from PRD_BIPLATFORM.S_NQ_ACCT f 
                                                      where extract(year from f.start_dt) = 2014',
                                      p_bi_query  => 'SELECT "U - Usage Tracking"."Показатели"."Кол-во запросов" s_1 
                                                      FROM "U - Usage Tracking" 
                                                      WHERE (("Календарь"."Год" = 2014) AND ("Календарь"."Месяц года" = "Календарь"."Месяц года"))',
                                      x_cursor    => :x_cursor);
end;



Как видно из примера, процедура принимает на вход 2 параметра:
- Текст запроса к DWH, этот запрос должен выполняться средствами самой БД Oracle (но ничего не мешает использовать dblink'и к другим БД).
- Текст логического запроса к BIServer (то, что можно увидеть в мониторе сеансов BI, либо на вкладке "Дополнительно" тестируемого анализа BI).

Возвращает процедура результат в виде курсора, содержащий взаимные дельты двух набором данных.

В примере специально искажены данные в первом запросе для иллюстрации получения дельты.
Также в примере добавлен предикат "Календарь"."Месяц года" = "Календарь"."Месяц года" в логический запрос к BIServer как иллюстрация
предлагаемого варианта проверки: сравнивать итоговое значение в запросе-эталоне к DWH с агрегатными значениями в различных разрезах BI.

Ограничения
- Количество столбцов в курсоре с дельтой определяется кол-вом столбцов в запросе к DWH.
- Типы данных обоих запросов должны совпадать.
- Точность NUMBER полей из BI ограничена 5 знаками после запятой. Поэтому нужно при сравнении использовать round(.., 5) в запросе к DWH.
- Общее кол-во столбцов курсора не должно превышать 20 (либо следует пересоздать таблицу NQS_TEMP_TABLE с бОльшим кол-вом столбцов).
- VARCHAR значения из BI не должны быть длиннее 255 символов (либо следует пересоздать таблицу NQS_TEMP_TABLE).


Трассировка выполнения процедуры
Для выявления причин возможных проблем следует
- раскомментировать строку /*dbms_java.set_output(10000);*/ в начале процедуры GET_DWH_BI_DIFF.
- перекомпилировать java source exec_nqsserver_query, вставляя в местах отладки вызовы System.out.println(....);
- анализировать трассировочные сообщения в стеке dbms_output.


Установка и базовая настройка DBFit
Для создания юнит-тестов через веб-интерфейс, а также для удобства их комплексного запуска мы будем использовать opensource ПО DbFit.

Скачиваем дистрибутив с ресурса https://github.com/dbfit/dbfit/releases

Операционная система и битность не важны. Так как ПО реализовано на Java.
Но следует учитывать, что версии дистрибутива могут быть скомпилированы под версией JRE, отличной от установленной на сервере. Так для инсталляции DBFit на том же сервере, где установлен OBIEE 11.1.1.7, следует скачивать не последнюю версию DBFit, а версию v2.2.0 - так как она скомпилирована в JRE 1.6, использующемся и в OBIEE 11.1.1.7

После загрузки дистрибутива необходимо распаковать его в какую-либо директорию сервера.
Я запускаю DbFit как сервис на Linux-системе.
Код сервиса:
#!/bin/bash
# 
# File:    /etc/init.d/dbfit
# Purpose: Start and stop DBFit.
#
# chkconfig: 2345 99 10
# description: Manage DBFit service.
#
 
# These values must be adapted to your environment.
# Local Unix user running DBFit
DBFIT_OWNR=oracle                				
# Path to DBFit root directory
DBFIT_PATH=/u01/app/dbfit	
	
# Path to java 1.6
# JAVA_EXEC=/home/u00/app/oracle/product/ofmodi/Oracle_BI1/jdk/jre/bin/java
JAVA_EXEC=java

# Path to DBFit exec script
# DBFIT_EXEC="$JAVA_EXEC -cp '$DBFIT_PATH/lib/dbfit-docs-3.1.0.jar:$DBFIT_PATH/lib/fitnesse-standalone-20140903.jar' fitnesseMain.FitNesseMain"     
DBFIT_EXEC="$JAVA_EXEC -cp '$DBFIT_PATH/lib/dbfit-docs.jar:$DBFIT_PATH/lib/*' fitnesseMain.FitNesseMain -p 8085 -e 0"     

# Log file                              
DBFIT_LOG=/var/log/dbfit.log				

# Pid file
DBFIT_PID=/var/run/dbfit.pid				
 

case $1 in
    start)
        echo "Starting DBFit ..."
        if [ ! -f $DBFIT_PID ]; then
	    cd $DBFIT_PATH
	    su $DBFIT_OWNR -c "$DBFIT_EXEC" > $DBFIT_LOG 2>&1 &
	    echo $! > $DBFIT_PID
           echo "DBFit started ..."
        else
            echo "DBFit is already running ..."
        fi
    ;;
    stop)
        if [ -f $DBFIT_PID ]; then
           PID=$(cat $DBFIT_PID);
           echo "DBFit stoping ..."
           kill $PID;
           echo "DBFit stopped ..."
           rm $DBFIT_PID
        else
           echo "DBFit is not running ..."
        fi
    ;;
    restart)
        if [ -f $DBFIT_PID ]; then
           PID=$(cat $DBFIT_PID);
           echo "DBFit stopping ...";
           kill $PID;
           echo "DBFit stopped ...";
           rm $DBFIT_PID
           echo "DBFit starting ..."
           cd $DBFIT_PATH
           su $DBFIT_OWNR -c "$DBFIT_EXEC" > $DBFIT_LOG 2>&1 &
	    echo $! > $DBFIT_PID
           echo "DBFit started ..."
        else
           echo "DBFit is not running ..."
        fi
    ;;
    *)
        echo "Usage: $(basename $0) start|stop|restart"
        exit 1
esac 

Дополнительные настройки DbFit
Так как в качестве основной рабочей среды для тестов будет использоваться БД Oracle - следует скопировать в каталог dbfit/lib jar-файл с JDBC-драйверами к Oracle.

Пример теста DbFit
Пользовательский доступ к DBFit осуществляется через веб-интерфейс по адресу http://hostname:8085


Тесты могут объединяться в TestSuite - наборы тестов, выполняющихся в рамках одного контекста.

Создадим новый TestSuite с именем NqsTest для хранения тестов репозитория BI.

При этом внутри каждого TestSuite (вложенного в другие, либо базового) можно создать статичную страницу с именем SetUp. На которой задаются настройки доступа, контекст, параметры и т.д.


В нашем случае эта страница будет общей для всего TestSuite первого уровня. И будет содержать настройки JDBC-доступа к рабочей БД Oracle.



Теперь откроем сам тест:

Он содержит вызов пакетной процедуры, в которую передаются значения параметров - тексты запросов к DWH и к BIServer.
А также вывод результатов выходного курсора.

Запускается тест нажатием на кнопку Test вверху.
И если выходной курсор содержит данные (есть дельта между наборами данных) - тест считается проваленным.



Код страницы DbFit с тестом:
!3 Тест, проверяющий соответствие результатов SQL запросов к DWH и к BIServer
Установка параметров пакетной процедуры (тексты запросов к DWH и BIServer)
|Set Parameter|P_DWH_QUERY|!-
select count(f.id)+1 as query_cnt 
from PRD_BIPLATFORM.S_NQ_ACCT f 
where extract(year from f.start_dt) = 2014
-!|
|Set Parameter|P_BI_QUERY|!-
SELECT "U - Usage Tracking"."Показатели"."Кол-во запросов" s_1 
FROM "U - Usage Tracking" 
WHERE (("Календарь"."Год" = 2014) AND ("Календарь"."Месяц года" = "Календарь"."Месяц года"))
-!|
Сравнение результирующих наборов данных
!|Execute procedure|pkg_nqsserver_query.get_dwh_bi_diff|
|P_DWH_QUERY|P_BI_QUERY|X_CURSOR?|
|<<P_DWH_QUERY|<<P_BI_QUERY|>>cursor|
!|Query|<<cursor|
|DELTA?|query_cnt?|

Комментариев нет:

Отправить комментарий