Windows com C++

Usando bancos de dados no Windows Azure

Kenny Kerr

Kenny KerrA Microsoft tem um longo histórico em confundir os desenvolvedores com uma estonteante variedade de tecnologias de acesso a dados. Houve um tempo em que parecia que cada versão do Windows, do SQL Server ou do Visual Studio traria uma nova API de acesso a dados. Em algum momento — por volta de 1996, acredito — a Microsoft aplicou seu entusiasmo costumeiro em fazer com que os desenvolvedores migrassem do ODBC para o OLE DB.

ODBC significa Open Database Connectivity, e era o antigo padrão para acessar sistemas de gerenciamento de bancos de dados. OLE DB significa… adivinha só… Object Linking and Embedding Database, e era a nova e universal utopia do acesso a dados. Mas esse nome é completamente enganoso, conforme vamos discutir logo mais.

Eu ainda me lembro de ler a coluna do Don Box na edição de julho de 1999 da MSDN Magazine (que na época chamava-se Microsoft Systems Journal), na qual ele descrevia a motivação e inspiração que levou ao OLE DB. Eu lembro de pensar na época que ele era muito mais complicado que o ODBC, mas que certamente era mais extensível. O motivo do nome OLE DB ser tão enganoso é que ele não tem nada a ver com OLE, e também que não é especificamente para bancos de dados. Ele foi verdadeiramente projetado como um modelo universal de acesso a dados de todos os tipos — relacionais ou não — como texto, XML, bancos de dados, mecanismos de pesquisa, o que for. O OLE DB estreou quando o COM estava no ápice da sua popularidade na plataforma Windows, por isso sua API cheia de recursos do COM e sua extensibilidade natural chamaram a atenção de muitos desenvolvedores.

Ainda assim, como API de banco de dados relacionais, ele nunca atingiu o mesmo desempenho puro do ODBC. Tecnologias posteriores de acesso a dados, como as do Microsoft .NET Framework, abandonaram tudo que não fossem os recursos de acesso a bancos de dados, e o sonho do acesso universal a dados foi ficando cada vez mais distante. Então, em agosto de 2011, a equipe do SQL Server, os maiores defensores do OLE DB, fizeram o surpreendente anúncio de que a Microsoft estava se alinhando com o ODBC para o acesso a dados relacionais (bit.ly/1dsYgTD). Eles declararam que o mercado estava se afastando do OLE DB em direção ao ODBC. Então estamos de volta ao ODBC enquanto eu e você aprendemos como acessar o SQL Server para a nova geração de aplicativos nativos C++.

A boa notícia é que o ODBC é relativamente simples. E também extremamente rápido. Foi muito dito que o OLE DB superava o ODBC em desempenho, mas raramente esse era o caso na prática. A má notícia é o ODBC é uma antiga API em estilo C que poucos desenvolvedores lembram — se é que chegaram a aprender — como usar. Felizmente, o moderno C++ vem ao resgate e facilita bastante a programação em ODBC. Se você quer acessar bancos de dados no Windows Azure com C++, você precisa abraçar o ODBC. Vamos dar uma olhada.

Como tantas outras APIs ao estilo C, o ODBC é modelado ao redor de um conjunto de identificadores (handles) representando objetos. Então vou novamente utilizar meu bom e velho modelo de classe unique_handle, que escrevi e utilizei em várias colunas. Você pode obter uma cópia do handle.h no dx.codeplex.com e seguir comigo. Porém, os identificadores ODBC são um pouco burros. Precisamos falar para a API ODBC o tipo de cada identificador ao utilizá-lo, tanto na criação quanto na liberação do identificador (e do seu objeto correspondente).

Um tipo de identificador é expresso com um SQLSMALLINT, que é apenas um curto valor int. Em vez de definir uma classe de características unique_handle para cada tipo de objeto que o ODBC define, eu vou transformar a própria classe de características em um modelo. A Figura 1 mostra como isso poderia ficar.

Figura 1 Uma classe de características ODBC

template <SQLSMALLINT T>
struct sql_traits
{
  using pointer = SQLHANDLE;
  static auto invalid() noexcept -> pointer
  {
    return nullptr;
  }
  static auto close(pointer value) noexcept -> void
  {
    VERIFY_(SQL_SUCCESS, SQLFreeHandle(T, value));
  }
};

O método close da classe de características na Figura 1 é onde você pode começar a ver como precisa dizer ao ODBC o tipo de cada identificador quando for usá-lo com algumas das funções genéricas do ODBC. Como estou usando a última compilação Preview do compilador Visual C++ (a CTP de novembro de 2013, enquanto escrevo), eu consigo substituir a especificação throw exception preterida pela especificação noexcept, abrindo a porta para o compilador gerar um código otimizado em alguns casos. Infelizmente, apesar desse compilador também oferecer a possibilidade de deduzir o tipo do retorno para funções auto, ele tem um bug que o impede de fazer isso para funções integrantes dos modelos de classe. É claro que, como a classe de características é, em si, um modelo de classe, um alias de modelo torna-se útil:

template <SQLSMALLINT T>
using sql_handle = unique_handle<sql_traits<T>>;

Agora eu consigo definir aliases de tipo para os diversos objetos ODBC, tais como o ambiente e os objetos de instrução:

using environment = sql_handle<SQL_HANDLE_ENV>;
using statement = sql_handle<SQL_HANDLE_STMT>;

Também definirei um para conexões, mas usarei um nome mais específico:

using connection_handle = sql_handle<SQL_HANDLE_DBC>;

O motivo para isso é que as conexões exigem um pouco mais de trabalho para serem limpas em todos os casos. Objetos de ambiente e de instrução não precisam de muito mais do que isso, mas objetos de conexão realmente precisam de uma classe de conexão para lidar de maneira confiável com a conectividade. Antes de eu poder lidar com isso, preciso criar um ambiente.

A função genérica SQLAllocHandle cria vários objetos. Aqui, de novo, vemos a separação entre o objeto — ou ao menos seu identificador — e seu tipo. Em vez de duplicar esse código por todo lado, novamente eu vou usar um modelo de função para reunir a informação de tipo. Aqui está um modelo de função para a função genérica SQLAllocHandle do ODBC:

template <typename T>
auto sql_allocate_handle(SQLSMALLINT const type,
                         SQLHANDLE input)
{
  auto h = T {};
  auto const r = SQLAllocHandle(type,
                                input,
                                h.get_address_of());
  // TODO: check result here ...
  return h;
}

Ele ainda é tão genérico quanto a função ODBC, sim, mas expõe a generalidade de um modo mais familiar ao C++. Voltarei ao tratamento de erros em um momento. Como esse modelo de função vai alocar um identificador de um determinado tipo e retornar um handle wrapper, eu posso simplesmente utilizar um dos aliases de tipo que defini antes. Para um ambiente, eu posso fazer isto:

auto e = sql_allocate_handle<environment>(SQL_HANDLE_ENV, nullptr);

O identificador de entrada ou identificador pai, o segundo parâmetro, traz um identificador pai opcional, oferecendo algum confinamento lógico. Um ambiente não tem um pai. Em vez disso, ele age como um pai para os objetos de conexão. Infelizmente, criar um ambiente exige um pouco mais de esforço. O ODBC exige que eu diga que versão do ODBC eu estou esperando. Eu faço isso configurando um atributo de ambiente com a função SQLSetEnvAttr. A coisa fica parecida com isso quando colocada dentro de uma útil função auxiliar:

auto create_environment()
{
  auto e = 
    sql_allocate_handle<environment>(SQL_HANDLE_ENV, nullptr);
  auto const r = SQLSetEnvAttr(e.get(),
    SQL_ATTR_ODBC_VERSION,
    reinterpret_cast<SQLPOINTER>(SQL_OV_ODBC3_80),
    SQL_INTEGER);
  // TODO: check result here ...
  return e;
}

A esta altura eu estou pronto para criar uma conexão; algo que, felizmente, é bem simples:

auto create_connection(environment const & e)
{
  return sql_allocate_handle<connection_handle>(
    SQL_HANDLE_DBC, e.get());
}

As conexões são criadas no contexto de um ambiente. Aqui você pode ver que eu uso o ambiente como pai da conexão. Eu ainda preciso fazer a conexão em si, e esse é o trabalho da função SQLDriverConnect, da qual alguns parâmetros podem ser ignorados:

auto connect(connection_handle const & c,
             wchar_t const * connection_string)
{
  auto const r = SQLDriverConnect(c.get(), nullptr,
    const_cast<wchar_t *>(connection_string),
    SQL_NTS, nullptr, 0, nullptr,
    SQL_DRIVER_NOPROMPT);
  // TODO: check result here ...
}

Notavelmente, a constante SQL_NTS apenas diz à função que a string de conexão precedente é finalizada com null. É possível, em vez disso, optar por fornecer a largura explicitamente. A constante final SQL_DRIVER_NOPROMPT indica se o usuário deve ser consultado via prompt caso mais informações sejam necessárias para estabelecer uma conexão. Neste caso, estou dizendo “não” para os prompts.

Mas como eu dei a entender antes, fechar uma conexão com elegância é um pouco mais complexo. O problema é que, apesar da função SQLFreeHandle ser usada para liberar o identificador da conexão, ela supõe que a conexão está fechada e não fecha automaticamente uma conexão aberta.

O que eu preciso é de uma classe de conexão que monitora a conectividade da conexão. Algo parecido com isto:

class connection
{
  connection_handle m_handle;
  bool m_connected { false };
public:
  connection(environment const & e) :
    m_handle { create_connection(e) }
  {}
  connection(connection &&) = default;
  // ...
};

Agora eu posso adicionar um método connect à minha classe usando a função connect non-member previamente definida e atualizar o estado conectado de acordo:

auto connect(wchar_t const * connection_string)
{
  ASSERT(!m_connected);
  ::connect(m_handle, connection_string);
  m_connected = true;
}

O método connect começa confirmando se a conexão não está aberta e termina mantendo um registro do fato dela estar aberta. A classe de conexão destructor depois pode desconectar automaticamente conforme necessário:

~connection()
{
  if (m_connected)
  {
    VERIFY_(SQL_SUCCESS, SQLDisconnect(m_handle.get()));
  }
}

Isso garante que a conexão seja desconectada antes que o identificador de membro destructor seja chamado para liberar ele mesmo o identificador de conexão. Agora eu posso criar um ambiente ODBC e estabelecer uma conexão de maneira correta e eficiente com apenas algumas linhas de código:

auto main()
{
  auto e = create_environment();
  auto c = connection { e };
  c.connect(L"Driver=SQL Server Native Client 11.0;Server=...");
}

E quanto às instruções? O modelo de função sql_allocate_handle torna-se útil mais uma vez, e eu apenas adiciono outro método à minha classe de conexão:

auto create_statement()
{
  return sql_allocate_handle<statement>(SQL_HANDLE_STMT,
                                        m_handle.get());
}

As instruções são criadas no contexto de uma conexão. Aqui você pode ver como a conexão age como pai para o objeto de instrução. Lá na minha função principal, eu posso criar um objeto de instrução de maneira bem simples:

auto s = c.create_statement();

O ODBC fornece uma função relativamente simples para executar instruções SQL, mas novamente eu vou encapsulá-la por conveniência:

auto execute(statement const & s,
             wchar_t const * text)
{
  auto const r = SQLExecDirect(s.get(),
                               const_cast<wchar_t *>(text),
                               SQL_NTS);
  // TODO: check result here ...
}

O ODBC é uma API ao estilo C extremamente velha, por isso ele não usa const, nem mesmo condicionalmente para compiladores C++. Aqui, eu preciso me livrar da “const-ância” para que o chamador seja protegido dessa const-ignorância. Lá na minha função principal, eu posso executar instruções SQL de maneira bem simples:

execute(s, L"create table Hens ( ... )");

Mas e se eu executar uma instrução SQL que retorna um conjunto de resultados? E se eu executar algo como isso:

execute(s, L"select Name from Hens where Id = 123");

Nesse caso, a instrução efetivamente torna-se um cursor, e eu preciso buscar os resultados, se houverem, um de cada vez. Este é o papel da função SQLFetch. Eu posso saber se existe uma galinha com o Id fornecido:

if (SQL_SUCCESS == SQLFetch(s.get()))
{
  // ...
}

Por outro lado, eu posso executar uma instrução SQL que retorna múltiplas linhas:

execute(s, L"select Id, Name from Hens order by Id desc");

Neste caso, eu posso simplesmente chamar a função SQLFetch em um loop:

while (SQL_SUCCESS == SQLFetch(s.get()))
{
  // ...
}

Obter os valores individuais das colunas é o papel da função SQLGetData. Esta é outra função genérica, e você precisa descrever exatamente a informação que você espera, assim como o buffer para onde você espera que ele copie o valor resultante. Recuperar um valor de tamanho fixo é relativamente descomplicado. A Figura 2 mostra uma função simples para recuperar um valor SQL int.

Figura 2 Recuperando um Valor SQL Integer

auto get_int(statement const & s,
             short const column)
{
  auto value = int {};
  auto const r = SQLGetData(s.get(),
                            column,
                            SQL_C_SLONG,
                            &value,
                            0,
                            nullptr);
  // TODO: check result here ...
  return value;
}

O primeiro parâmetro do SQLGetData é o identificador de instrução, o segundo é o índice de coluna baseado em um, o terceiro é o tipo ODBC para um int SQL e o quarto é o endereço do buffer que receberá o valor. O penúltimo parâmetro é ignorado porque este é um tipo de dado de tamanho fixo. Para outros tipos de dado, ele indicaria o tamanho do buffer na entrada. O parâmetro final fornece a própria largura, ou o tamanho dos dados, copiados para o buffer. De novo, ele não é utilizado para tipos de dados de tamanho fixo, mas este parâmetro também pode ser usado para retornar informações de status, tais como se o valor foi null. Recuperar um valor de string é mais complicado, mas não muito. A Figure 3 mostra um modelo de classe que copiará o valor em um array local.

Figura 3 Recuperando um Valor SQL String

template <unsigned Count>
auto get_string(statement const & s,
                short const column,
                wchar_t (&value)[Count])
{
  auto const r = SQLGetData(s.get(),
                            column,
                            SQL_C_WCHAR,
                            value,
                            Count * sizeof(wchar_t),
                            nullptr);
  sql_check(r, SQL_HANDLE_STMT, s.get());
}

Note como nesse caso eu preciso dizer à função SQLGetData o tamanho em si do buffer que vai receber o valor, e como eu preciso fazer isso em bytes, não caracteres. Se eu fizesse uma consulta pelo nome de uma galinha em particular, e a coluna Name abrigasse um máximo de 100 caracteres, eu poderia usar a função get_string, como mostrado a seguir:

if (SQL_SUCCESS == SQLFetch(s.get()))
{
  wchar_t name[101];
  get_string(s, 1, name);
  TRACE(L"Hen’s name is %s\n", name);
}

Por último, apesar de eu poder reutilizar um objeto de conexão para executar múltiplas instruções, como o objeto de instrução representa um cursor, eu preciso me certificar de fechar o cursor antes de executar quaisquer instruções subsequentes:

VERIFY_(SQL_SUCCESS, SQLCloseCursor(s.get()));

Ironicamente, essa não é uma questão de gerenciamento de recursos. Diferente dos desafios relacionados a conexões abertas, a função SQLFreeHandle não está nem aí para o fato da instrução ter ou não um cursor aberto.

Eu evitei falar sobre tratamento de erros até agora porque é um tópico complexo em si mesmo. As funções ODBC retornam códigos de erros, e é sua responsabilidade verificar os valores desses códigos de retorno para determinar se a operação teve sucesso. Geralmente as funções retornarão a constante SQL_SUCCESS indicando sucesso, mas elas também podem retornar a constante SQL_SUCCESS_WITH_INFO. Essa última é igualmente bem-sucedida, mas acompanha informações adicionais de diagnóstico que você pode querer recuperar. Eu só costumo recuperar as informações de diagnóstico que retornam junto com a constante SQL_SUCCESS_WITH_INFO em compilações de depuração. Assim eu posso juntar o máximo possível de informações durante o desenvolvimento, sem desperdiçar ciclos em produção. Claro que eu sempre colho essa informações quando um código de erro é retornado. Independente da causa, o processo de recuperação de informações de diagnóstico é o mesmo.

O ODBC fornece informações de diagnóstico como um conjunto de resultados, e você pode recuperar as linhas uma por vez com a função SQLGetDiagRec e um índice de linhas baseado em um. Só não esqueça de chamá-la com o identificador do objeto que produziu o código de erro em questão.

Há três principais pedaços de informação em cada linha: um código de erro nativo específico da fonte de dados ou do driver ODBC; um código de estado curto e críptico, com cinco dígitos, que define a classe de erro a que esse registro pode se referir; e uma descrição textual mais longa do registro de diagnóstico. Dados os buffers necessários, eu posso simplesmente chamar a função SQLGetDiagRec em um loop para recuperar todos eles, como mostrado na Figura 4.

Figura 4 Recuperando Informações de Diagnóstico de Erro

auto native_error = long {};
wchar_t state[6];
wchar_t message[1024];
auto record = short {};
while (SQL_SUCCESS == SQLGetDiagRec(type,
                                    handle,
                                    ++record,
                                    state,
                                    &native_error,
                                    message,
                                    _countof(message),
                                    nullptr))
{
  // ...
}

Junto com o SQL Server, o Windows Azure fornece uma forma incrivelmente simples de começar com bancos de dados hospedados. Isso é particularmente interessante porque o mecanismo de banco de dados do SQL Server é o mesmo que os desenvolvedores C++ conhecem e usam há anos. O OLE DB foi afastado, mas o ODBC é mais do que capaz de dar conta da tarefa e, de fato, é mais simples e mais rápido do que o OLE BD já conseguiu ser. Claro, você precisa de um pouco de ajuda do C++ para fazer tudo funcionar de uma maneira coerente.

Confira o meu curso Pluralsight, “10 técnicas para fortalecer os seus aplicativos Visual C++ na prática” (bit.ly/1fgTifi), para obter mais informações sobre o uso do Visual C++ para acessar bancos de dados no Windows Azure. Eu dou instruções passo a passo para configurar e utilizar servidores de bancos de dados e vincular colunas para simplificar o processo de buscar linhas de dados, exemplos de como simplificar e modernizar o processo de tratamento de erros e muito mais.

Kenny Kerr é programador de computador, assim como autor da Pluralsight e Microsoft MVP que mora no Canadá. Ele mantém um blog em kennykerr.ca e pode ser seguido no Twitter em twitter.com/kennykerr.