Generalmente (en algunas soluciones viejas no se posee esta estructura), todo lo referido a la funcionalidad WCF de un producto se encuentra en la solución correspondiente, dentro de una carpeta de solución llamada WCF Services:
Esta carpeta, típicamente tiene tres proyectos:
Libería WCF: Proyecto tipo DLL Noanet.Solucion.Service, que contiene toda la lógica en sí. Se puede debuguear a medida que se desarrolla haciendo click derecho sobre el proyecto, opción Debug -> Start new instance. Esto levanta la herramienta integrada de VS WcfClient y permite invocar las operaciones expuestas por la librería WCF. No puede utilizarse para probar funcionalidades no expuestas y, en aquellas funcionalidades que demoran mucho en dar un resultado, la herramienta se vuelve incómoda. La herramienta también es incómoda cuando los parámetros de la operación incluyen vectores o listados.
Proyecto WindowsService: Es el servicio Windows que se instala y ejecuta en el servidor para levantar la librería WCF, hostear sus operaciones expuestas y lanzar sus procesos automáticos. Se llama Noanet.Solucion.ServiceServer y no puede debuguearse desde VS.
Consola de Test: Aplicación de consola Noanet.Solucion.ServiceTest que se utiliza para probar ciertas funciones de la librería WCF que no pueden ser consumidas a través de la herramienta WcfClient integrada del VS.
Al igual que una aplicación de consola o winform, la ejecución de un servicio windows comienza en el método Main de la clase Program. Este método tiene siempre el mismo código: instancia la clase de servicio (que será la encargada de hostear las operaciones WCF y levantar los procesos automáticos) y solicita su ejecución a la clase del fwk ServiceBase.
La clase ServiceInstaller es la encargada de gestionar la instalación del servicio como tal en el servidor de destino a través de la herramienta installutil del fwk. Para más información, remitirse a los resources WcfHosts and WindowsServices Installers y Publish WcffHosts and WindowsServices de este mismo sitio.
Finalmente, la clase de servicio propiamente dicho es aquella que instancia el método Main, y es la que tiene a su cargo la responsabilidad de levantar y parar el servicio, en respuesta a las acciones Iniciar y Detener que el usuario invoca desde la consola de administración de servicios:
Esta clase debeheredar de System.ServiceProcess.ServiceBase y redefinir los métodos OnStart y OnStop. Típicamente, el primero inicializará el log4net para gestión de log, hosteará la clase que brinda las operaciones WCF (es decir, tomará un canal para atender por él las solicitudes entrantes) y levantará procesos automáticos (los que deberán ejecutarse sí o sí en un hilo aparte). En contrapartida, el segundo cerrará el host WCF (liberará el canal correspondiente) y detendrá los procesos automáticos.
public partial class WcfHost : ServiceBase
{
ServiceHost _host = null;
public WcfHost()
{
InitializeComponent();
}
protected override void OnStart(string[] args)
{
try
{
LogHelper.Info("Staring WCF service...");
_host = new ServiceHost(typeof(ProdTecBlackService));
_host.Open();
LogHelper.Info("Service WCF started");
ProcessManager.StartAllProcesses();
}
catch (Exception ex)
{
LogHelper.LogAndContinue(ex, "OnStart");
OnStop();
}
}
protected override void OnStop()
{
try
{
if ((_host != null) && (_host.State == CommunicationState.Opened))
{
LogHelper.Info("Stopping WCF service...");
_host.Close();
LogHelper.Info("Service WCF stopped");
}
ProcessManager.StopAllProcesses();
}
catch (Exception ex)
{
LogHelper.LogAndContinue(ex, "OnStop");
}
}
}
El código de ejemplo se toma de la implementación en ProdTecBlack. La clase ProdTecBlackService es la que brinda operaciones WCF a los clientes que la consumen, LogHelper centraliza el uso de log4net (Exception & Logging) y ProcessManager es la que internamente crea un hilo aparte para ejecutar los procesos automáticos del servicio.
Cuando se publica en el servidor de destino, el archivo app.config de este proyecto es quien debe tener todas las opciones de configuración. Para más información dirigirse a Publish WcfHosts and WindowsServices en este mismo sitio.
Este proyecto es el que contiene la implementación de los servicios en sí. Típicamente, se pueden distinguir dos tipos básicos de funcionalidades que se incluyen en este proyecto:
Procesos automáticos: Tareas que se ejecutan sin esperar una solicitud por parte de un cliente, como ser el traspaso de información a tablas históricos en el caso del servicio de Cenit o la creación de campañas en Producto Técnico.
Operaciones WCF: Son los métodos que el servicio ejecuta bajo demanda. Un cliente debe invocar la operación para que se ejecute.
Operaciones Expuestas
Para que un servicio WCF exponga operaciones a disposición de clientes, se debe cumplir estos requisitos:
Una interfaz debe definir el contrato ofrecido a los clientes
Por decantación, una clase deberá asumir la responsabilidad de implementar dicha interfaz
Se debe configurar el contrato en la sección serviceModel serl archivo .config
La interfaz que ofrece los servicios y sus métodos deben tener los atributos ServiceContract y OperationContract (del espacio de nombres System.ServiceModel) respectivamente:
[ServiceContract]
public interface IProdTecBlackService
{
[OperationContract]
LoadSpotsProcessResult LoadSubStations(string[] subStationCodes);
[OperationContract]
LoadSpotsProcessResult LoadStationPoints(string[] stationPointCodes);
[OperationContract]
LoadSpotsProcessResult LoadCustomers(string[] customerCodes);
[OperationContract]
ProcessInfo LoadStdFile(string userName, int spotId, byte[] fileContent);
[OperationContract]
ProcessInfo LoadCvtFile(string userName, int spotId, byte[] fileContent);
[OperationContract]
ProcessInfo LoadTxtFile(string userName, int spotId, byte[] fileContent, int txtFileTypeId);
[OperationContract]
bool ReprocessReadings(int spotReadingId);
[OperationContract]
ProcessInfo GetProcessInfoById(string id);
#if DEBUG
[OperationContract]
ProcessInfo LoadStdFileByPath(string userName, int spotId, string filePath);
[OperationContract]
ProcessInfo LoadCvtFileByPath(string userName, int spotId, string filePath);
[OperationContract]
ProcessInfo LoadTxtFileByPath(string userName, int spotId, string filePath, int txtFileTypeId);
#endif
}
La clase que implemente esta interfaz no debe tener ninguna característica en especial.
Si el servicio necesita recibir como parámetro o devolver como resultado un objeto complejo, la clase que describa este objeto y sus propiedades deben tener los atributos DataContract y DataMember del espacio de nombres System.Runtime.Serialization:
[DataContract]
public class LoadSpotsProcessResult
{
[DataMember]
public bool Success { get; set; }
[DataMember]
public bool AllSpotsLoaded { get; set; }
[DataMember]
public IList<string> PendingSpots { get; set; }
}
Finalmente, la sección serviceModel debe describir el contrato, su implementación y el canal a través del cual se hostearán las operaciones WCF (es decir, la dirección en la que el host recibirá las peticiones de los clientes):
<system.serviceModel>
<services>
<service name="Noanet.ProdTecBlack.Service.ProdTecBlackService">
<endpoint address="" binding="wsHttpBinding" contract="Noanet.ProdTecBlack.Service.IProdTecBlackService">
<identity>
<dns value="localhost"/>
</identity>
</endpoint>
<endpoint address="mex" binding="mexHttpBinding" contract="IMetadataExchange"/>
<host>
<baseAddresses>
<add baseAddress="http://localhost:8732/Design_Time_Addresses/Noanet.ProdTecBlack.Service/ProdTecBlackService/"/>
</baseAddresses>
</host>
</service>
</services>
<behaviors>
<serviceBehaviors>
<behavior>
<serviceMetadata httpGetEnabled="True"/>
<serviceDebug includeExceptionDetailInFaults="False"/>
</behavior>
</serviceBehaviors>
</behaviors>
</system.serviceModel>
Acceso a Datos
Los servicios, a diferencia de los proyectos web, no utilizan NHibernate. Por lo tanto, y para evitar que el código del servicio quede ligado a un motor de datos específicos (objetos SqlCommand, OdbcCommand, IfxCommand, etc), se utilizan las librerías Enterprise Libraries Data Block que permiten resolver en tiempo de ejecución el tipo de objetos a crear para acceder a datos (ver el resource EntLib Data Access para más detalles).
Por lo general, cada proyecto de librería WCF tiene una carpeta Data donde se aloja todo lo que sea consultas contra una base de datos. En casi todos los casos, esta carpeta tiene las siguientes clases:
DataExtensions: Extensiones utilizadas para facilitar el manejo de datos a través de las entlibs. También aislan cierta lógica dependiente del motor de datos (si utiliza parámetros con nombre o no, query que devuelve el último autoincremental generado, etc)
BasicRepository: Clase abstracta de la que deben heredar las clases que implementen las consultas a base de datos. Provee mecanismos básicos para armar los objetos IDbCommand que se adaptarán automáticamente al motor de datos de destino.
Repositorios: Heredan de la BasicRepository, y se implementa una clase repositorio por base de datos a la que acceda el servicio. Son las clases donde se codifican las consultas de acceso a datos.
Las clases DataExtensions y BasicRepository son esencialmente las mismas en todas las soluciones (puede haber diferencias muy pequeñas de una solución a otra). Las clases XRepository son específicas para cada base de datos.
En la BasicRepository, se encuentran los siguientes métodos:
Las clases repositorios deben heredar de BasicRepository y tener un constructor sin parámetros, el que debe invocar a su vez al constructor de la clase madre indicando los siguientes parámetros:
Nombre de la cadena de conexión en el archivo .config
Si la base de datos acepta parámetros con nombre
Texto de la consulta para obtener el último autoincremental
Modificador SELECT para recuperar n registros
Típicamente, estos parámetros se encuentrarán en el archivo .config:
<appSettings>
<!-- Ifx -->
<add key="ProdTecRepository.AcceptParameters" value="false" />
<add key="ProdTecRepository.IdentityString" value="Select dbinfo('sqlca.sqlerrd1') From Systables Where Tabid=1"/>
<add key="ProdTecRepository.SetMaxResultsString" value="SELECT FIRST {0} "/>
<!-- SqlSrvr -->
<add key="RemoteRepository.AcceptParameters" value="true" />
<add key="RemoteRepository.IdentityString" value="SELECT @@identity"/>
<add key="RemoteRepository.SetMaxResultsString" value="SELECT TOP {0} "/>
</appSettings>
<connectionStrings>
<add name="ProdTecRepository" providerName="IBM.Data.Informix" connectionString="Database=edet;Host=129.10.100.1;Server=edet_soc;Service=1545;Protocol=onsoctcp;UID=webptec;Password=12345678;DB_LOCALE=es_ES.1252;CLIENT_LOCALE=en_US.819;"/>
<add name="RemoteRepository" providerName="System.Data.SqlClient" connectionString="Data Source=test-tuc-01;Initial Catalog=Circutores;Integrated Security=SSPI;"/>
</connectionStrings>
Dos métodos típicos (uno para leer datos y otro para escribir) en la clase repositorio sería:
public Campaign GetLastCampaign(int campaignTypeId)
{
Campaign result = null;
IDbCommand command = null;
IDataReader reader = null;
string commandText = BuildSetMaxResultsText(1) + "c.Id, c.Period, c.CampaignTypeTemplateId, c.CampaignStateId, c.StageId, c.MeasuringBandGroupId, c.CreationUser, c.CreationDate "
+ "FROM Campaigns c INNER JOIN CampaignTypeTemplates ctt ON (ctt.Id=c.CampaignTypeTemplateId) WHERE (ctt.CampaignTypeId=@pCampaignTypeId) ORDER BY c.Period DESC";
try
{
command = BuildCommand(commandText, new string[] { "@pCampaignTypeId" }, new DbType[] { DbType.Int32 }, new object[] { campaignTypeId });
reader = command.ExecuteReader();
if (reader.Read())
{
result = new Campaign();
result.Id = reader.GetInt32(0);
result.Period = reader.GetDateTime(1);
result.CampaignTypeTemplate = new CampaignTypeTemplate() { Id = reader.GetInt32(2) };
result.CampaignState = (CampaignStates)Enum.ToObject(typeof(CampaignStates), reader.GetInt32(3));
result.Stage = new Stage() { Id = reader.GetInt32(4) };
result.MeasuringBandGroup = new MeasuringBandGroup() { Id = reader.GetInt32(5) };
result.CreationUser = reader.GetString(6);
result.CreationDate = reader.GetDateTime(7);
}
}
catch (Exception ex)
{
LogHelper.LogAndThrow(ex, "GetLastCampaign");
}
finally
{
if (command != null)
command.Dispose();
if (reader != null)
reader.Dispose();
}
return result;
}
public void InsertCampaign(Campaign campaign)
{
IDbCommand command = null;
try
{
command = BuildCommand("INSERT INTO Campaigns(Period, CampaignTypeTemplateId, StageId, MeasuringBandGroupId, CampaignStateId, CreationUser, CreationDate) VALUES(" + BuildParameterNamesText(7) + ")",
BuildParameterNamesArray(7), new DbType[] { DbType.DateTime, DbType.Int32, DbType.Int32, DbType.Int32, DbType.Int32, DbType.String, DbType.DateTime },
new object[] { campaign.Period, campaign.CampaignTypeTemplate.Id, campaign.Stage.Id, campaign.MeasuringBandGroup.Id, (int)campaign.CampaignState, campaign.CreationUser, campaign.CreationDate });
command.ExecuteNonQuery();
command = BuildCommand(GetIdentityString());
campaign.Id = Convert.ToInt32(command.ExecuteScalar());
}
catch (Exception ex)
{
LogHelper.LogAndThrow(ex, "InsertCampaign");
}
finally
{
if (command != null)
command.Dispose();
}
}
Y un ejemplo típico de uso de una clase repositorio sería:
private void RepositoryExample(int campaignTypeId)
{
ProdTecRepository repository = new ProdTecRepository();
IDbTransaction tx = null;
try
{
repository.OpenConnection();
Campaign oldCampaign = repository.GetLastCampaign(campaignTypeId);
Campaign newCampaign = new Campaign();
newCampaign.Period = oldCampaign.Period.AddMonths(1);
tx = repository.BeginTransaction();
repository.InsertCampaign(newCampaign);
// No usar nunca tx.Commit();
repository.CommitTransaction();
// Marcar el cierre de la transacción
tx = null;
}
catch (Exception ex)
{
throw;
}
finally
{
if (tx != null)
{
// No utilizar nunca tx.Rollback();
repository.RollbackTransaction();
}
repository.CloseConnection();
}
}
Noanet.Mvc.ServiceProcess
A nivel de fwk Noanet, se ha implementado una serie de clases que brindan soporte a llamadas WCF con las siguientes características
La ejecución de la tarea puede demorar bastante tiempo (más que el que un usuario web puede esperar a que termine)
Al tratarse de un proceso lento, el usuario debe poder ver el progreso del mismo
Si distintos clientes invocan el mismo proceso con los mismos parámetros, deben ejecutarse un único proceso común a todos ellos.
Para estos escenarios, la librería Noanet.Mvc.ServiceProcess ofrece las siguientes clases:
ProcessInfo: Descriptor de un proceso ejecutándose. Cada proceso ejecutándose tiene información de estado y un hilo de ejecución propio.
ProcessStep: Información de estado de cada uno de los pasos que componen un proceso.
BaseProcess: Clase base para la implementación de procesos largos. Cada proceso largo debe implementarse como una clase que hereda de ésta clase abstracta.
ServiceProcess: Provee los mecanismos de lanzamiento de procesos (incluyendo la sincronización para asegurar que no se ejecuten dos veces el mismo proceso con los mismos parámetros), almacenamiento de la información de estado de cada proceso activo, consulta de estados de procesos, y limpieza de procesos en desuso.
Desde el punto de vista de la implementación de un proceso con estas características, la clase que lo haga debe heredar de BaseProcess y desarrollar su algoritmo normalmente en el método Run con el agregado de:
Acceder a los parámetros suministrados para su ejecución a través del campo heredado _parameter.
Crear una clase representativa de tales parámetros, que implemente la interfaz IProcessParameter. Al implementar esta interfaz, la clase asume la responsabilidad de poder discernir cuando dos juegos de parámetros son iguales a través de GetUniqueId.
Notificar el avance de un paso a otro (método NextStep del campo heredado _processInfo)
Alertar de errores en un paso específico del proceso con la propiedad ErrorMessage del paso en curso (que se obtiene con el método GetCurrentStep del campo heredado _processInfo). Según la lógica propia del proceso, un error en un paso podrá impedir o no que el proceso continúe con otro paso.
Enviar warnings al paso específico del proceso con el método Warning del objeto ProcessStep.
Indicar que el proceso terminó con el método End del objeto _processInfo, el que recibe dos parámetros: si finalizó correctamente y un mensaje de error general a todo el proceso.
Notificar warnings a nivel generar de proceso con el método Warning del objeto _processInfo.
/// <summary>
/// Parámetros necesarios para ejecutar el proceso
/// </summary>
public class ParameterSample : Noanet.Mvc.ServiceProcess.IProcessParameter
{
public int IntMember { get; set; }
public string StringMember { get; set; }
public string GetUniqueId()
{
// Dos objetos ParameterSample son iguales
// sí y solo sí sus miembros enteros son iguales entre sí
// y sus miembros cadena son iguales entre sí
return string.Format("{0};{1}", IntMember, StringMember);
}
}
/// <summary>
/// Clase que implementa la ejecución del proceso
/// </summary>
public class ProcessSample : Noanet.Mvc.ServiceProcess.BaseProcess
{
protected override void Run()
{
ParameterSample parameter = _parameter as ParameterSample;
bool success = false;
try
{
_processInfo.NextStep("Validando número");
if (parameter.IntMember <= 0)
_processInfo.GetCurrentStep().ErrorMessage = "El número debe ser positivo";
else
{
_processInfo.NextStep("Validando cadena");
if (string.IsNullOrEmpty(parameter.StringMember))
{
_processInfo.GetCurrentStep().ErrorMessage = "La cadena debe tener caracteres";
}
else
{
_processInfo.NextStep("Guardando datos");
SaveData(parameter.IntMember, parameter.StringMember);
success = true;
}
}
}
catch (Exception ex)
{
_processInfo.GetCurrentStep().ErrorMessage = "Ocurrió un error inesperado";
}
_processInfo.End(success, success ? null : "El proceso no finalizó correctamente");
}
private void SaveData(int p, string p_2)
{
throw new NotImplementedException();
}
}
El lanzamiento de un proceso debe realizarse a través de la clase ServiceProcess, la que internamente resuelve si es preciso crear un nuevo hilo o utilizar un proceso ya existente. Para ello, es necesario crear una clase que herede de ServiceProcess que asuma la responsabilidad de saber qué procesos crear según la demanda del cliente. Esta clase hereda de ServiceProcess el método GetProcessInfo a través del cual el cliente consulta el estado del proceso:
public class ProcessLauncherSample : Noanet.Mvc.ServiceProcess.ServiceProcess
{
public ProcessInfo LaunchProcessSample(int intMember, string stringMember, string userName)
{
ParameterSample parameter = new ParameterSample();
parameter.IntMember = intMember;
parameter.StringMember = stringMember;
// La clase del fwk determinará si se crea un nuevo proceso o se utiliza uno en curso
return base.InvokeProcess("SAMPLE", userName, parameter);
}
public override BaseProcess CreateProcess(string processName)
{
BaseProcess result = null;
switch (processName.ToUpper())
{
case "SAMPLE":
result = new ProcessSample();
break;
default:
throw new ArgumentException();
}
return result;
}
}
Finalmente, solo resta codificar del lado del cliente los mecanismos necesarios para invocar las operaciones del servicio WCF. Para ello, es necesario tener en claro los siguientes conceptos:
El acceso a los métodos expuestos por un servicio WCF se hace a través de un cliente generado por el IDE de VS.
Las llamadas a operaciones WCF implican acceso remoto. No se está llamando a una clase incluida en una dll propia del cliente. El servicio WCF que responde la llamada tampoco forma parte de la publicación del cliente.
Sin embargo, a nivel de código (y gracias al cliente generado por el IDE de VS), los detalles de comunicación cliente servidor son embebidos y por lo tanto el consumo de los servicios parece (a nivel de código) una llamada a un método de un objeto y se comporta como tal.
Agregar la Referencia al Servicio WCF
El proyecto del lado del cliente que necesite acceder a servicios WCF debe agregar una referencia a través de la opción Add Service Reference del menú contextual del item References del proyecto:
Una vez abierto el cuadro de diálgo, se debe indicar la dirección donde se encuentra hosteado el servicio (o se pulsa el botón Discover en caso de que sea un servicio WCF desarrollado en la misma solución), se revisan las operaciones expuestas por el servicio y se escoge un espacio de nombres con el cual el IDE de VS generará todo el código cliente que encapsula las comunicaciones WCF.
Invocación de las Operaciones WCF
Una vez incluida la referencia al servicio, el consumo de sus operaciones se hace a través del objeto cliente que el IDE de VS generó previamente. Este objeto cliente ofrece los mismos métodos que el servicio WCF, con los mismos parámetros de entrada y tipos de retorno.
public void WcfCallSample(string[] customerCodes)
{
using (ProdTecBlackServiceReference.ProdTecBlackServiceClient client = new ProdTecBlackServiceReference.ProdTecBlackServiceClient())
{
ProdTecBlackServiceReference.LoadSpotsProcessResult response = client.LoadCustomers(customerCodes);
if (!response.Success)
throw new Exception("No se pudo cargar el archivo");
if (!response.AllSpotsLoaded)
{
string pendingCustomers = string.Join(",", response.PendingSpots);
LogHelper.Warning("Clientes sin cargar: " + pendingCustomers);
}
}
}
Archivo de Configuración en el Cliente
Para conectarse al servidor WCF, el cliente generado por el IDE de VS utiliza las configuraciones de la sección serviceModel del archivo de configuración. En el caso de las aplicaciones web, esto es el archivo web.config. En el caso de aplicaciones de consola o winform, es el archivo app.config del proyecto ejecutable.
La sección serviceModel que necesita el cliente es la que genera automáticamente el IDE al agregar la referencia como servicio, solo que el IDE agrega esta sección en el archivo app.config del proyecto que consume el WCF. Por lo tanto, se debe copiar desde tal archivo al archivo web.config o al archivo app.config del proyecto ejecutable:
<system.serviceModel>
<bindings>
<wsHttpBinding>
<binding name="WSHttpBinding_IProdTecBlackService" closeTimeout="00:01:00"
openTimeout="00:01:00" receiveTimeout="00:10:00" sendTimeout="00:01:00"
bypassProxyOnLocal="false" transactionFlow="false" hostNameComparisonMode="StrongWildcard"
maxBufferPoolSize="524288" maxReceivedMessageSize="65536"
messageEncoding="Text" textEncoding="utf-8" useDefaultWebProxy="true"
allowCookies="false">
<readerQuotas maxDepth="32" maxStringContentLength="8192" maxArrayLength="16384"
maxBytesPerRead="4096" maxNameTableCharCount="16384" />
<reliableSession ordered="true" inactivityTimeout="00:10:00"
enabled="false" />
<security mode="Message">
<transport clientCredentialType="Windows" proxyCredentialType="None"
realm="" />
<message clientCredentialType="Windows" negotiateServiceCredential="true"
algorithmSuite="Default" />
</security>
</binding>
</wsHttpBinding>
</bindings>
<client>
<endpoint address="http://localhost:8732/Design_Time_Addresses/Noanet.ProdTecBlack.Service/ProdTecBlackService/"
binding="wsHttpBinding" bindingConfiguration="WSHttpBinding_IProdTecBlackService2"
contract="ProdTecBlackServiceReference.IProdTecBlackService"
name="WSHttpBinding_IProdTecBlackService2">
<identity>
<dns value="localhost" />
</identity>
</endpoint>
</client>
</system.serviceModel>
En muchas ocasiones, la sección de bindings es mucho más sencilla que la aquí ejemplificada.