воскресенье, 17 марта 2013 г.

WIX + SQL Server

Для работы многих приложений требуется база данных. Это не проблема, если база одна на всех и находится на общедоступном сервере. Но как быть если требуется установить базу данных локально? Наилучшим вариантом в этом случае будет создание базы данных во время установки приложения. В этой статье я опишу как, используя WIX, можно во время установки приложения проверить подключение к Sql Server и выполнить скрипт для создания базы данных.

Предварительные условия:

  1. Наличие Visual Studio;
  2. Установленный компонент WIX Toolset.
В качестве шаблона проекта я использую пример из статьи WIX + NET 4.0 Framework + Microsoft Windows Installer 3.1.
Для того, чтобы пользователь смог настроить подключение к серверу баз данных, ему нужно предоставить пользовательский интерфейс, где он сможет ввести нужные учетные данные.


Добавим в установочный проект новый элемент DatabaseUI.wxi, который будет содержать описание данного диалога. Я не буду здесь приводить полный код данного файла, потому что он достаточно громоздкий, но постараюсь описать основные моменты.

Получение списка серверов БД

Удобным функционалом любой Sql Server Managment Studio является то, что при подключении можно получить список доступных серверов на локальной машине и в сети. Соответственно реализация данной возможности будет хорошим плюсом. Для этого создадим CustomAction которое будет имплементировать данную функциональность при запуске нашего дистрибутива:

CustomAction.cs:
#region Public Members

/// <summary>
/// Получает список доступных SQL-серверов.
/// </summary>
/// <param name="session">Объект сессии, который контролирует процесс установки.</param>
/// <returns>Возвращает статус выполнения пользовательского действия.</returns>
[CustomAction]
public static ActionResult GetSqlServers(Session session)
{
    try
    {
        // Получаем список SQL-серверов
        var sqlDataSourceEnumerator = SqlDataSourceEnumerator.Instance;
        var servers = sqlDataSourceEnumerator.GetDataSources().Rows;
        // Получаем список существующих значений
        var exists = GetTableValues(session, "ComboBox", "DATABASE_SERVER");
        var order = 2;
        // Проходим список всех найденных серверов и добавляем их в ComboBox
        foreach (DataRow server in servers)
        {
            var sql = server["ServerName"].ToString();
            var instance = server["InstanceName"].ToString();
            if (!String.IsNullOrEmpty(instance)) sql = sql + "\\" + instance;
            if (exists.Contains(instance)) continue;
            InsertRecord(session, "ComboBox", new object[] { "DATABASE_SERVER", order, sql, sql });
            order++;
        }
        // Устанавливаем элемент по умолчанию
        if(servers.Count > 0)
        {
            var server = servers[0];
            var sql = server["ServerName"].ToString();
            var instance = server["InstanceName"].ToString();
            if (!String.IsNullOrEmpty(instance)) sql = sql + "\\" + instance;
            session["DATABASE_SERVER"] = sql;
        }
        return ActionResult.Success;
    }
    catch (Exception)
    {
        return ActionResult.SkipRemainingActions;
    }
}

#endregion

#region Private Members

private static IList GetTableValues(Session session, String table, String property)
{
    var database = session.Database;
    return database.ExecuteQuery("SELECT Value FROM {0} WHERE Property = '{1}'", table, property);
}

private static void InsertRecord(Session session, String table, Object[] objects)
{
    var database = session.Database;
    var sqlInsertSring = database.Tables[table].SqlInsertString + " TEMPORARY";
    var view = database.OpenView(sqlInsertSring);
    view.Execute(new Record(objects));
    view.Close();
}

#endregion

Для перебора всех доступных экземпляров SQL Server в локальной сети я использовал класс SqlDataSourceEnumerator, который включен в .NET Framework начиная с версии 3.5. Единственный минус данного подхода состоит в том, что для получения списка доступных экземпляров в сети, нужно чтобы на компьютере работала служба SQLBrowser.

Теперь нам нужно подключить созданное пользовательское действие к диалогу:

DatabaseUI.wxi:
<?xml version="1.0" encoding="UTF-8"?>
<Include xmlns="http://schemas.microsoft.com/wix/2006/wi">
  <Fragment>
    <!--Sql Custom Action-->
    <Binary Id="CustomBinary" SourceFile="$(var.SqlActionLibrary.TargetDir)SqlActionLibrary.CA.dll"/>
    <CustomAction Id="GetSqlServers" BinaryKey="CustomBinary" DllEntry="GetSqlServers" Execute="immediate" Return="check"/>

    <!--Helper Properties-->
    <Property Id="DATABASE_SERVER_LIST" Value="DATABASE_SERVER" />
    <Property Id="DATABASE_SERVER" Value="localhost" />

    <!--User Interface-->
    <UI>
      ...
      <Dialog Id="DatabaseDlg" Width="370" Height="270" Title="!(loc.ChooseDatabaseTitle)">
        <Control Id="Next" Type="PushButton" X="236" Y="243" Width="56" Height="17" Default="yes" Text="!(loc.WixUINext)">
          ...
          <Publish Property="DATABASE_SERVER" Value="[DATABASE_SERVER]">1</Publish>
          ...
        </Control>
        <Control Id="ServerLabel" Type="Text" X="20" Y="50" Width="290" Height="15" Text="!(loc.DatabaseServerAddress)" />
        <Control Id="comboBox" Type="ComboBox" X="30" Y="65" Width="200" Height="18" Text="{40}" Property="DATABASE_SERVER_LIST" Indirect="yes" >
          <ComboBox Property="DATABASE_SERVER_LIST">
            <ListItem Value="localhost"/>
          </ComboBox>
        </Control>
        ...
    </UI>

    <InstallUISequence>
      <!--Load Sql Servers list-->
      <Custom Action="GetSqlServers" After="CostInitialize" />
    </InstallUISequence>
  </Fragment>
</Include>

Здесь мы объявляем пользовательское действие GetSqlServers, которое находится в отдельной сборке, добавляем вспомогательное свойство DATABASE_SERVER, которое затем используется в CustomAction. Затем в диалог добавляем ComboBox для хранения списка экземпляров SQL серверов после чего указываем, что наше действие должно запуститься единожды после действия CostInitialize, что позволит получить список серверов при запуске инсталятора.

Проверка подключения

После того, как пользователь введет все требуемые учетные данные, нужно проверить подключение к серверу БД. Для этого создадим еще одно пользовательское действие:

CustomAction.cs:
/// <summary>
/// Выполняет проверку подключения к SQL-серверу.
/// </summary>
/// <param name="session">Объект сессии, который контролирует процесс установки.</param>
/// <returns>Возвращает статус выполнения пользовательского действия.</returns>
[CustomAction]
public static ActionResult SqlConnect(Session session)
{
    session.Log("Begin SqlConnect");
    string connectionString;
    // Получаем тип входа
    var logonType = session["DATABASE_LOGON_TYPE"];
    if (logonType.Equals("DatabaseIntegratedAuth"))
    {
        // Доверенные (проверка подлинности Windows)
        connectionString = String.Format("Data Source={0};Integrated Security=SSPI", session["DATABASE_SERVER"]);
                
    }
    else if (logonType.Equals("DatabaseAccount"))
    {
        // Задать имя пользователя и пароль (проверка подлинности SQL)
        connectionString = String.Format("Data Source={0};User Id={1}; Password={2}", session["DATABASE_SERVER"], session["DATABASE_USERNAME"], session["DATABASE_PASSWORD"]);
    }
    else
    {
        // Не известный тип входа
        session["CONNECTION_ESTABLISHED"] = "";
        session.Log("SqlConnect is not successful");
        return ActionResult.NotExecuted;
    }
    // Выполняем подключение к SQL-серверу
    try
    {
        using (var sqlConnection = new SqlConnection(connectionString))
        {
            sqlConnection.Open();
        }
        session["CONNECTION_ESTABLISHED"] = "1";
        session.Log("SqlConnect is successful");
        return ActionResult.Success;
    }
    catch (Exception ex)
    {
        session["CONNECTION_ESTABLISHED"] = "";
        session["ERROR"] = ex.Message;
        session.Log(ex.Message);
        return ActionResult.NotExecuted;
    }

}

При выполнении определяем тип входа (проверка подлинности Windows или Sql) и в соответствии с этим формируем строку подключения. Затем выполняем попытку подключения к серверу баз данных. Если все прошло успешно - устанавливаем значение переменной CONNECTION_ESTABLISHED = 1 и предоставляем возможность продолжить выполнение следующих шагов мастера установки. А также сделаем так, чтобы проверка подключения запускалась как по кнопке "Проверить подключение", так и по кнопке "Далее" (чтобы быть уверенным в том, что во время установки мы подключаемся к реальному серверу):

DatabaseUI.wxi:
<?xml version="1.0" encoding="UTF-8"?>
<Include xmlns="http://schemas.microsoft.com/wix/2006/wi">
  <Fragment>
    <!--Sql Custom Action-->
    <Binary Id="CustomBinary" SourceFile="$(var.SqlActionLibrary.TargetDir)SqlActionLibrary.CA.dll"/>
    ...
    <CustomAction Id="SqlConnect" BinaryKey="CustomBinary" DllEntry="SqlConnect" Execute="immediate"/>

    <!--Helper Properties-->
    ...
    <Property Id="DATABASE_LOGON_TYPE" Value="DatabaseIntegratedAuth" />

    <!--User Interface-->
    <UI>
      ...
      <Dialog Id="DatabaseDlg" Width="370" Height="270" Title="!(loc.ChooseDatabaseTitle)">
        <Control Id="Next" Type="PushButton" X="236" Y="243" Width="56" Height="17" Default="yes" Text="!(loc.WixUINext)">
          <Publish Property="DATABASE_LOGON_TYPE" Value="[DATABASE_LOGON_TYPE]">1</Publish>
          <Publish Property="DATABASE_SERVER" Value="[DATABASE_SERVER]">1</Publish>
          <Publish Property="DATABASE_USERNAME" Value="[DATABASE_USERNAME]"><![CDATA[DATABASE_LOGON_TYPE = "DatabaseAccount"]]></Publish>
          <Publish Property="DATABASE_PASSWORD" Value="[DATABASE_PASSWORD]"><![CDATA[DATABASE_LOGON_TYPE = "DatabaseAccount"]]></Publish>
          <Publish Property="DATABASE_USERNAME"><![CDATA[DATABASE_LOGON_TYPE <> "DatabaseAccount"]]></Publish>
          <Publish Property="DATABASE_PASSWORD"><![CDATA[DATABASE_LOGON_TYPE <> "DatabaseAccount"]]></Publish>
          <Condition Action="disable"><![CDATA[LOGON_VALID <> 1 AND DATABASE_LOGON_TYPE = "DatabaseAccount"]]></Condition>
          <Condition Action="enable"><![CDATA[LOGON_VALID = 1 OR DATABASE_LOGON_TYPE <> "DatabaseAccount"]]></Condition>
          <Publish Event="DoAction" Value="SqlConnect" Order="1">1</Publish>
          <Publish Property="LOGON_VALID" Value="1" Order="2"><![CDATA[CONNECTION_ESTABLISHED]]></Publish>
          <Publish Property="LOGON_VALID" Value="0" Order="2"><![CDATA[NOT CONNECTION_ESTABLISHED]]></Publish>
          <Publish Property="LOGON_ERROR" Value="Unexpected Error" Order="2"><![CDATA[(NOT CONNECTION_ESTABLISHED) AND (ERROR = "")]]></Publish>
          <Publish Property="LOGON_ERROR" Value="[ERROR]" Order="2"><![CDATA[NOT CONNECTION_ESTABLISHED]]></Publish>
          <Publish Event="SpawnDialog" Value="InvalidLogonDlg" Order="3"><![CDATA[NOT CONNECTION_ESTABLISHED]]></Publish>
        </Control>
        ...
        <Control Id="Test" Type="PushButton" X="30" Y="215" Width="120" Height="17" Text="!(loc.SqlConnectTitle)">
          <Publish Event="DoAction" Value="SqlConnect" Order="1">1</Publish>
          <Publish Property="LOGON_VALID" Value="1" Order="2"><![CDATA[CONNECTION_ESTABLISHED]]></Publish>
          <Publish Property="LOGON_VALID" Value="0" Order="2"><![CDATA[NOT CONNECTION_ESTABLISHED]]></Publish>
          <Publish Property="LOGON_ERROR" Value="Unexpected Error" Order="2"><![CDATA[(NOT CONNECTION_ESTABLISHED) AND (ERROR = "")]]></Publish>
          <Publish Property="LOGON_ERROR" Value="[ERROR]" Order="2"><![CDATA[NOT CONNECTION_ESTABLISHED]]></Publish>
          <Publish Event="SpawnDialog" Value="InvalidLogonDlg" Order="3"><![CDATA[NOT CONNECTION_ESTABLISHED]]></Publish>
        </Control>
        ...
      </Dialog>
    </UI>

    ...
  </Fragment>
</Include>

Последовательность установки

После создания пользовательских действий и диалога подключения к серверу базы данных, нужно внедрить данный диалог в последовательность установки:

Product.wxs:
      ...
      
      <Publish Dialog="DatabaseDlg" Control="Back" Event="NewDialog" Value="MaintenanceTypeDlg" Order="1">Installed</Publish>
      <Publish Dialog="DatabaseDlg" Control="Back" Event="NewDialog" Value="InstallDirDlg" Order="2">NOT Installed</Publish>
      <Publish Dialog="DatabaseDlg" Control="Next" Event="NewDialog" Value="VerifyReadyDlg" Order="2">1</Publish>

      ...

Выполнение скрипта

Подключение к серверу проверили, теперь остается только выполнить скрипт, который будет создавать базу данных. Для этого подключим файл скрипта .sql в проект (Build Action = Content). Затем, используя расширения WixSqlExtension и WixUtilExtension подключим данный скрипт в главный файл проекта и создадим там же компонент, который будет выполнять этот скрипт:

Product.wxs:
    <!--Sql User-->
    <util:User Id="SQLUser" Name="[DATABASE_USERNAME]" Password="[DATABASE_PASSWORD]" />
    ...
    <Directory Id="TARGETDIR" Name="SourceDir">
      <Directory Id="ProgramFilesFolder">
        <Directory Id="INSTALLDIR" Name="$(var.ProductName)">
          <!-- TODO: Remove the comments around this Component element and the ComponentRef below in order to add resources to this installer. -->
          <!-- <Component Id="ProductComponent" Guid="3e4cdc9e-ed96-4512-95ec-a5d7a5315411"> -->

          <!--Execute script-->
          <Component Id="SqlComponent1" Guid="8A65D42C-121F-4144-817A-B48099BCABFA" KeyPath="yes">
            <Condition><![CDATA[DATABASE_LOGON_TYPE <> "DatabaseAccount"]]></Condition>
            <sql:SqlDatabase Id="SqlDatabase1" Database="WPFCSHARP" Server="[DATABASE_SERVER]" CreateOnInstall="yes" CreateOnReinstall="no" CreateOnUninstall="no" DropOnInstall="no" DropOnReinstall="no" DropOnUninstall="no" ContinueOnError="no">
              <sql:SqlScript Id="Database1" ExecuteOnInstall="yes" ExecuteOnReinstall="no" ExecuteOnUninstall="no" BinaryKey="database" Sequence="1"></sql:SqlScript>
            </sql:SqlDatabase>
          </Component>
          <Component Id="SqlComponent2" Guid="4FFE2197-1999-4E46-9B70-D0A1C731A8F6" KeyPath="yes">
            <Condition><![CDATA[DATABASE_LOGON_TYPE = "DatabaseAccount"]]></Condition>
            <sql:SqlDatabase Id="SqlDatabase2" Database="WPFCSHARP" Server="[DATABASE_SERVER]" User="SQLUser" CreateOnInstall="yes" CreateOnReinstall="no" CreateOnUninstall="no" DropOnInstall="no" DropOnReinstall="no" DropOnUninstall="no" ContinueOnError="no">
              <sql:SqlScript Id="Database2" ExecuteOnInstall="yes" ExecuteOnReinstall="no" ExecuteOnUninstall="no" BinaryKey="database" Sequence="1"></sql:SqlScript>
            </sql:SqlDatabase>
          </Component>

          <!-- TODO: Insert files, registry keys, and other resources here. -->
          <!-- </Component> -->
        </Directory>
      </Directory>
    </Directory>

    <Feature Id="ProductFeature" Title="Setup" Level="1">
      <!-- TODO: Remove the comments around this ComponentRef element and the Component above in order to add resources to this installer. -->
      <!-- <ComponentRef Id="ProductComponent" /> -->

      <ComponentRef Id="SqlComponent1"/>
      <ComponentRef Id="SqlComponent2"/>

      <!-- Note: The following ComponentGroupRef is required to pull in generated authoring from project references. -->
    </Feature>

Почему создано два компонента на один файл скрипта? Причина в проверке подлинности. Если используется SQL-авторизация, то добавляется свойство User="SQLUser", которое описывается выше в коде.

Я постарался описать основные моменты создания инсталятора для базы данных с помощью WIX Toolset. Полный код проекта вы можете скачать ниже.

Исходный код

2 комментария:

  1. Здравствуйте! Помогите, пожалуйста, разобраться с контролами типа Edit (Control Id="myEdit1" Type="Edit" Property="REMOTE_PORT" Height="17" Width="150" X="56" Y="58" Sunken="yes">). Нигде не могу найти описание, как считать то, что ввел пользователь в это поле (например Порт) и как потом это значение использовать при установке вместо значения по умолчанию, которое прописано в конфиге.

    ОтветитьУдалить
  2. Спасибо, помогло!

    ОтветитьУдалить