Buenas prácticas en desarrollo de software con .NET
Buenas practicas en desarrollo de software con .NET Tratamos de aprender buenas prácticas en desarrollo de software. Algunas se derivan del más puro sentido común, y no hay problema en adoptarlas tan pronto se llama la atención sobre ellas: la misma exposición a esas buenas prácticas es suficiente para hacerlas propias y aplicarlas exitosamente. Otras requieren haber padecido en carne propia una serie de dificultades que se plantean en ciertos contextos del desarrollo de software. Y, muy importante, la percepción de esas buenas prácticas como una ayuda efectiva depende de encontrarse en un estadio de conocimiento como desarrollador de software que capacite para estudiarlas e interiorizarlas. Planteemos para empezar, pues, una breve digresión acerca de los niveles en el aprendizaje de una habilidad.
1 Aprenda a programar en ¿cuántas horas? 1.1 El modelo Dreyfus de adquisición de habilidades Hay una clasificación en varios niveles de las etapas del aprendizaje de habilidades. Se conoce por modelo Dreyfus al tomar el nombre de sus autores, los hermanos Stuart y Hubert Dreyfus. El modelo Dreyfus se presentó a principios de los 80 en el trabajo “A Five-Stage Model of the Mental Activities Involved in Directed Skill Acquisition”, un informe que se redactó para la Air Force Office of Scientific Research con objeto de mejorar el adiestramiento de pilotos de avión.
La clasificación de los Dreyfus refleja los cambios que se experimentan en el dominio de habilidades durante su aprendizaje. En estos cambios, el aprendiz pasa…
del apoyo en principios abstractos al uso de experiencias pasadas específicas como paradigmas; del pensamiento analítico basado en normas a la intuición; de una percepción en la que todo parece una agregación dispersa de partes con igual relevancia a otra en la que se ve un todo en el que sólo ciertas partes son relevantes; de un estado de observador ajeno a la situación a otro de participante totalmente implicado en la situación.
1
Andrés Marzal
Buenas prácticas en desarrollo de software con .NET
Según el modelo, los cinco niveles en la adquisición de una habilidad son1 1. 2. 3. 4. 5.
Evidentemente, y aunque no se mencione en la lista de niveles, hay un nivel aún menos avanzado que los citados: ignorante. De ahí partimos todos. 1.1.1 Principiante Se empieza a tener consciencia del área de aprendizaje, pero sólo con ideas y conceptos abstractos. El principiante tiene poca o ninguna habilidad para poner las ideas en práctica de modo fiable. Aplica lo aprendido siguiendo reglas sin considerar el contexto.
Descomposición de la situación: necesita que los elementos se definan clara y objetivamente. Comportamiento en la toma de decisiones: sigue reglas sin considerar el contexto. Cómo se ejercita el juicio: —. Habilidades y herramientas: expuesto a ellas y capaz de aplicarlas si se le dirige. Tiempo de entrenamiento formal: 2 a 5 días. Entrenamiento con práctica: 1 a 2 meses.
1.1.2 Principiante avanzado Las experiencias con casos reales producen un aprendizaje marginal que le permite alcanzar un nivel de prestaciones aceptable. Empieza a entender el alcance del área de aprendizaje y reconoce la falta de conocimiento sobre la disciplina. Puede aplicar herramientas, procesos y principios en contextos similares a los casos bien definidos que han estudiado.
Descomposición de la situación: percibe similitudes con ejemplos ya vistos. Comportamiento en la toma de decisiones: encuentra correspondencias con el conjunto de reglas apropiado. Cómo se ejercita el juicio: —. Habilidades y herramientas: las reconoce y aplica en entornos estructurados. Tiempo de entrenamiento formal: 5 a 10 días. Entrenamiento con práctica: 3 a 6 meses.
1.1.3 Competente Cuenta con trabajo práctico en varias áreas que forman el campo de aprendizaje. Internaliza nuevas habilidades con la capacidad de ir más allá de los procedimientos ligados a reglas en un entorno muy estructurado. Adapta el aprendizaje a diferentes situaciones analizando circunstancias cambiantes y seleccionando entre alternativas viables. 1
Descomposición de la situación: considera varias alternativas.
Los niveles que se muestran son los que se usan al citar el modelo de Dreyfus, aunque en su trabajo original usaban una nomenclatura distinta: principiantía (novice), competencia (competence), eficiencia (proficiency), expertitud (expertise) y maestría (maestry).
2
Andrés Marzal
Buenas prácticas en desarrollo de software con .NET
Comportamiento en la toma de decisiones: determina analíticamente la mejor alternativa. Cómo se ejercita el juicio: considera conscientemente el valor de las consecuencias de cada alternativa. Habilidades y herramientas: internalizadas y aplicadas a más entornos desestructurados. Tiempo de entrenamiento formal: 20 a 30 días. Entrenamiento con práctica: 12 a 18 meses.
1.1.4 Eficiente Dispone de experiencia en varias situaciones. Ha internalizado herramientas y conceptos que puede aplicar a una variedad de situaciones sin gran esfuerzo. Tiene una comprensión holística e intuitiva de las situaciones, sin necesidad de descomponer el problema antes de encontrar una solución.
Descomposición de la situación: internalizada, intuitiva. Comportamiento en la toma de decisiones: centrada en la elección que mejor permite conseguir el plan intuitivo. Cómo se ejercita el juicio: se mueve rápidamente a partir de la experiencia anterior. Habilidades y herramientas: intuitivas y se aplican conscientemente en todos los escenarios. Tiempo de entrenamiento formal: cuanto haga falta para abordar los asuntos específicos que surjan. Entrenamiento con práctica: 1 a 3 años.
1.1.5 Experto La percepción y la acción están completamente internalizadas en procesos de trabajo normales. Cuando las cosas se desarrollan normalmente, el trabajo parece rutina. Alcanzar este nivel requiere una relación cercana con otro experto del que se obtiene más aprendizaje vía exposición, observación, conversación y otras interacciones continuadas.
Descomposición de la situación: internalizada, intuitiva. Comportamiento en la toma de decisiones: actúa inconscientemente, de modo automático. Cómo se ejercita el juicio: inconscientemente hace lo que generalmente funciona. Habilidades y herramientas: intuitivas y se aplican conscientemente en todos los escenarios. Tiempo de entrenamiento formal: informal, vía interacción con otros expertos. Entrenamiento con práctica: 5 a 10 años.
1.1.6 El nivel esperado de un curso de formación Conocidos los niveles y sus características, hemos de saber que un curso de formación como el que nos ocupa no podrá llevarnos mucho más allá del nivel de principiante avanzado… o quizá rozar el de competente. Y eso con mucha dedicación. Vale la pena observar que en los dos niveles superiores no hay entrenamiento formal: sólo aprendizaje basado en la práctica… y el tiempo. Ese es un factor en el que hemos de centrarnos inevitablemente: el tiempo.
1.2 La regla de las 10.000 horas Ahora que conocemos los cinco niveles, puede asustar el tiempo que requiere alcanzar el nivel de experto. ¿No hay atajos? ¿No hay gente con un talento nato capaz de llegar a ese estadio más rápidamente?
3
Andrés Marzal
Buenas prácticas en desarrollo de software con .NET
El estatus de genio está mitificado. Detrás del genio suele haber una gran dedicación al estudio y mucha, mucha práctica. En su libro Outliers, Malcolm Gladwell señala en un artículo que el nivel de experto requiere unas 10.000 horas de trabajo… en no importa qué ámbito.
El artículo divulga resultados de un estudio de K. Anders Ericsson en la Academia de Música de Berlín. Dividieron a los violinistas en tres grupos:
en el primero, las estrellas, quienes podían llegar a ser solistas de nivel; en el segundo, los considerados “buenos”; en el tercero, los que parecía improbable que llegaran a profesionales.
A todos ellos se les preguntó por el número de horas que habían dedicado a practicar desde el primer día que tocaron un violín. Todos habían empezado sobre los cinco años de edad practicando de dos a tres horas semanales. Las diferencias empezaron a manifestarse a la edad de ocho años. Los del mejor grupo dedicaban unas seis horas semanales con nueve años, ocho horas a los doce, dieciséis horas a los catorce, y así hasta las más de treinta horas a los veinte años. A esa edad sumaban unas 10.000 horas de práctica. Cuando estudiaron a grupos de pianistas emergió el mismo patrón. Lo que no encontraron fue “genios natos”, es decir, gente que tuviera un nivel elevado sin practicar con esa dedicación. Tampoco encontraron “inútiles natos”, es decir, gente que dedicando ese tiempo, no llegara a un nivel alto. En palabras de Daniel Levitin, neurólogo: “El cuadro emergente de estos estudios es que se necesitan diez mil horas de práctica para alcanzar el nivel de maestría asociado a un experto de nivel mundial. Estudio tras estudio, de compositores, jugadores de béisbol, escritores de ficción, patinadores sobre hielo, pianistas de concierto, jugadores de ajedrez, grandes delincuentes… ese número surge una y otra vez. Por supuesto, esto no resuelve la cuestión de por qué algunas personas obtienen más provecho de sus sesiones de prácticas que otras. Pero nadie ha encontrado aún un caso en el que el nivel de experto a escala mundial se alcanzara en menos tiempo. Parece que el cerebro necesita este tiempo para asimilar todo lo necesario para alcanzar la maestría.” Gladwell cita Numerosos ejemplos en los que aparece la regla de las 10.000 horas y que en el imaginario popular se representan como casos de genio natural: Wolfgang Amadeus Mozart, Bobby Fisher, Bill Joy, The Beatles, Bill Gates… El artículo también trata otras cuestiones, como la existencia de “ventanas de oportunidad” que parecen favorecer a personas de ciertas generaciones cuando un cambio tecnológico radical aparece. Pero esa es otra historia.
4
Andrés Marzal
Buenas prácticas en desarrollo de software con .NET
Quedémonos con que un año laboral supone unas 1.750 horas de trabajo real (suponiendo que la jornada se aprovecha bien). Eso hace que se requiera un mínimo de cinco años y ocho meses para alcanzar el nivel de experto. La buena noticia es que resulta posible alcanzar el nivel de experto: sólo se necesita suficiente dedicación. 1.2.1 Aprenda a programar en 10 años Peter Norvig, director de investigación en Google, colgó en su web (http://norvig.com/) un ensayo titulado “Teach yourself to program in 10 years”, donde abunda en esta misma idea y critica las decenas de libros tipo “Aprenda [ponga aquí su lenguaje de programación] en [ponga aquí 24 horas/3 días/7 días…]”.
En el ensayo se citan trabajos de Benjamin Bloom, John R. Hayes o William G. Chase y Herbert S. Simon (y el artículo de Malcolm Gladwell) que coinciden en la idea de que se necesita un período de unos 10 años (o 10.000 horas) para desarrollar un nivel de experto en cualquier campo. La clave, según remarca Norvig, es la práctica deliberada: no sólo hacer algo una y otra vez, sino desafiarse con tareas que estén más allá de lo que uno sabe en un momento dado, tratar de abordarlas, analizar la propia capacidad cuando se realizan y corregir los errores cometidos. Algunos de los consejos que da Norvig son:
Consiga que le interese la programación y practique porque es divertida. Y asegúrese de que sigue siéndolo lo bastante como para que desee dedicarle diez años. Hable con otros programadores, lea programas de otros. Esto más importante que cualquier libro o curso de entrenamiento. Programe. El mejor modo de aprender es aprender haciendo. Si quiere, dedique cuatro años a la formación universitaria de grado (o más con el postgrado). Esto le dará acceso a trabajos que requieren credenciales y le proporcionará una comprensión más profunda del campo. Pero si no le gusta la escuela, puede (con cierta dedicación) conseguir una experiencia similar en el trabajo. En cualquier caso, el aprendizaje con libros no será suficiente. “Computer science education cannot make anybody an expert programmer any more tan studying brushes and pigment can make somebody an expert painter.” Eric Raymond. Trabaje en proyectos con otros programadores. Sea el mejor programador en algunos proyectos y el peor en otros.
5
Andrés Marzal
Buenas prácticas en desarrollo de software con .NET
Trabaje en proyectos después de que lo hayan hecho otros programadores. Implíquese en comprender un programa escrito por otro, en repararlo cuando los programadores originales no están accesibles. Piense en cómo diseñar programas para que sean más fáciles de mantener por quienes tendrán que hacerlo después de usted. Aprenda al menos una docena de lenguajes de programación. Incluya uno que soporte abstracciones de clases (como C++ o Java), uno que soporte abstracción funcional (como Lisp o ML), uno que soporte abstracción sintáctica (como Lisp), uno que soporte especificaciones declarativas (como Prolog o plantillas de C++), uno que soporte corrutinas (como Icon o Scheme) y uno que soporte paralelismo (como Sisal). Recuerde que hay “computadores” en la “ciencia de computadores”. Ha de saber cuánto cuesta ejecutar una instrucción, acceder a una palabra de memoria (con o sin falta de caché), leer palabras consecutivas de disco y desplazarse a un nuevo lugar del disco.
Una nota respecto del aprendizaje de varios lenguajes de programación: el acento no se pone tanto en los diversos lenguajes por sus diferencias sintácticas (que también son interesantes) como por sus diferentes paradigmas a la hora de abordar la programación (orientación a objetos, programación funcional, programación declarativa, programación concurrente…). Si quiere un estudio de algunos lenguajes bajo este prisma, puede consultar el libro “Seven Languages in Seven Weeks: A Pragmatic Guide to Learning Programming Languages”, de Bruce A. Tate.
Se exploran lenguajes de programación no tanto para cambiar de entorno de trabajo gratuitamente como para ver qué cosas son particularmente fáciles de expresar en otros lenguajes y cómo podríamos obtener algo similar en los que usamos habitualmente. Si no estudia algunos lenguajes funcionales, el programador de Java, por poner un ejemplo, se perderá una colección de técnicas que, más tarde o más temprano irrumpirán en su lenguaje de programación (y que ya irrumpieron hace algunos años en C#). No estar preparado ahora supone llegar tarde a un cambio inminente. Pero no sólo eso: si conoce los fundamentos de la programación funcional, por ejemplo, podrá aplicar técnicas que permiten implementan o emulan en su lenguaje habitual algunos de sus aspectos más interesantes. Y si no lo hace, tendrá problemas para entender algunas librerías que ya anticipan estas posibilidades o el código de otros programadores que están tratando de seguir ese camino. 1.2.2 Coding Dojo Un buen programador debe ejercitar su oficio y plantearse retos. La rutina del trabajo puede encasillar al programador, que raramente saldrá de un lenguaje de programación o dos y que acabará escribiendo código
6
Andrés Marzal
Buenas prácticas en desarrollo de software con .NET
clonado de un limitado repertorio de programas tipo. Los mejores programadores exploran constantemente otros lenguajes de programación y técnicas empleadas por otros programadores. Un modo de ejercitarse es acudir a Coding Dojos, esto es, encuentros de programadores en el que se practican katas. Las katas toman su nombre de los ejercicios coreografiados de las artes marciales. Es recomendable la lectura de http://www.codinghorror.com/blog/2008/06/the-ultimate-code-kata.html para entender qué es una kata en programación. Por otra parte, la página http://www.codekata.com/ es una fuente de recursos para katas que mantiene Dave Thomas (un nombre que aparecerá más adelante). También es recomendable consultar los ejemplos de Coding Dojo del wiki http://www.codingdojo.org/.
Es bueno entrar en contacto con algún grupo local que organice Coding Dojos… o montar uno propio. Un punto de entrada en Castellón es la gente que organiza http://decharlas.com . Decharlas es una serie periódica de conferencias y charlas sobre desarrollo de software que se celebran usualmente en la Universitat Jaume I. Viene gente de toda España y en torno a Decharlas se está generando comunidad. Esté atento.
2 Metodologías y tecnologías Puestas las cosas en su contexto, centrémonos en los objetivos del curso, que no son otros que exponerle a un conjunto de buenas prácticas en desarrollo de software. Distingamos entre:
Metodologías. Formas de organizar y abordar el desarrollo de software que son independientes del lenguaje o plataforma de software. Tecnologías. Conjuntos de herramientas y técnicas que permiten implementar ciertas estrategias o principios de desarrollo de software con lenguajes de programación o plataformas concretos.
7
Andrés Marzal
Buenas prácticas en desarrollo de software con .NET
2.1 Metodologías pesadas y metodologías ligeras A finales de los 60 se acuñó el término “crisis del software” para referirse al problema no resuelto de cómo escribir programas correctos, comprensibles y verificables. La crisis se manifestó en2:
Proyectos que desbordaban el presupuesto. Proyectos que no cumplían los plazos. Programas ineficientes. Programas de mala calidad. Programas que no cumplían con los requerimientos. Programas inmanejables y código difícil de mantener. Programas que jamás se entregaban.
La primera respuesta fue sistematizar el proceso de construcción de software estableciendo analogías con la ingeniería clásica. El conjunto de metodologías resultante estaba orientado a proyectos, como lo están las prácticas propias de la ingeniería:
Se estudian los requerimientos del proyecto que se detallan en un documento. Se diseña un proyecto detallado, con planos, donde todo puede ser cuantificado y estimado para ser ejecutado con un presupuesto y en un plazo determinado. El resultado de esta fase es un bloque de documentación. Se ejecuta el proyecto de acuerdo con el plan. El resultado de esta fase es un producto software completo y funcional. Se verifica que todo se ha desarrollado conforme a lo proyectado y que el código funciona correctamente. Se pasa a una fase de mantenimiento en la que se detectan y corrigen errores, se añade funcionalidad, etc.
Esta metodología se conoce por “modelo en cascada” o “Big Design Up Front” (BDUF). En la práctica, raramente conduce al éxito si se sigue escrupulosamente. Lo habitual es dedicar mucho tiempo a las dos primeras fases para descubrir, tras empezar la ejecución del proyecto, que no se habían tenido en cuenta Numerosos detalles, lo que obliga a reconsiderar el proyecto en sí.
2
De http://en.wikipedia.org/wiki/Software_crisis.
8
Andrés Marzal
Buenas prácticas en desarrollo de software con .NET
El proceso en cascada es poco realista y se suele acabar entrando en una dinámica de bucle para corregir los errores de diseño inevitables: al proyecto sigue un conato de implementación que obliga a retocar el proyecto para descubrir, al tratar de ejecutar nuevamente, que es necesario hacer más ajustes… El resultado: muchos proyectos fracasados, fuera de plazo o fuera de presupuesto, equipos de desarrolladores frustrados… Crisis. La búsqueda de una analogía en el proceso de desarrollo de software con el propio de diseño y ejecución de proyectos en las ingenierías convencionales parece abocada al fracaso. Copiar lo que funciona en las ingenierías clásicas no es garantía de éxito; más bien al contrario. La ingeniería trata, generalmente, con procesos de fabricación predecibles. El desarrollo de software guarda más relación con el desarrollo de productos nuevos y es un proceso empírico. Esta tabla, adaptada de la que aparece en “Becoming Agile in an imperfect world”, de Greg Smith y Ahmed Sisky, recoge las diferencias entre ambos tipos de proceso: Fabricación predecible (procesos definidos)
Desarrollo de nuevos productos (procesos empíricos)
Es posible completar primero las especificaciones y construir después.
Raramente se pueden crear especificaciones detalladas e inalterables en una primera fase.
Al principio se puede estimar fiablemente tiempo y coste.
Al principio resulta imposible estimar fiablemente tiempo y esfuerzo. A medida que se dispone de datos empíricos, se hace más y más posible planificar y estimar.
Es posible identificar, definir, planificar y ordenar todas las actividades detalladas al principio del proyecto.
Al principio es imposible identificar, definir, planificar y ordenar actividades. Se requieren pasos adaptativos guiados por ciclos de construcción con realimentación.
La adaptación al cambio impredecible no es la norma, y la tasa de cambios es relativamente baja.
La adaptación creativa al cambio impredecible es la norma. La tasa de cambios es alta.
9
Andrés Marzal
Buenas prácticas en desarrollo de software con .NET
Si se busca una analogía más eficaz que la consabida entre desarrolladores e ingenieros, es recomendable leer el trabajo “Hackers and Painters”, de Paul Graham, autor de la primera aplicación web y fundador de Y Combinator (http://ycombinator.com/).
En los 90 surgió un movimiento de contestación a estas metodologías. El nuevo movimiento denominó a las metodologías basadas en proyectos “metodologías pesadas” (heavyweight), por contraposición a la etiqueta de las metodologías que se proponían entonces y que se denominaron “metodologías ligeras” (lightweight) o “metodologías ágiles”.
2.2 Metodologías ágiles En el curso abordaremos algunas metodologías o cuestiones metodológicas bajo la influencia de las denominadas “metodologías ágiles” y algunas de las tecnologías que les dan soporte. El manifiesto ágil es obra de 17 programadores que se reunieron en Snowbird, Utah, en 2001 para hablar sobre una tendencia en desarrollo de software que se empezaba a conocer por “procesos ligeros”. La ingeniería del software, especialmente en su visión academicista centrada en los proyectos, había llevado a la industria a un impasse que parecía superarse con esa nueva corriente de metodologías. El manifiesto ágil, que es el documento fundacional del movimiento, nace de la experiencia real de programadores expertos, lejos de la Academia. No hacen más que recoger las prácticas que les funcionan demostradamente y adoptan una postura poco dogmática respecto a su propia metodología: cada equipo debe adoptar los principios y prácticas que les funcionen, y no más… ni menos.
10
Andrés Marzal
Buenas prácticas en desarrollo de software con .NET
2.3 El manifiesto ágil El manifiesto ágil reza así: The Manifesto for Agile Software Development “We are uncovering better ways of developing software by doing it and helping others do it. Through this work we have come to value:
Individuals and interactions over processes and tools Working software over comprehensive documentation Customer collaboration over contract negotiation Responding to change over following a plan
That is, while there is value in the items on the right, we value the items on the left more.” http://agilemanifesto.org/ Los 17 autores son expertos en desarrollo de software y muchos de ellos son referencia, con millares de seguidores de sus libros, ensayos, blogs, etc.:
Kent Beck, creador de las metodologías Extreme Programming (XP) y Test Driven Development (TDD). Creó la librería JUnit en colaboración con Erich Gamma (líder en el diseño de Eclipse). Popularizó las tarjetas Class Responsibility Collaboration (CRC) con Ward Cunningham. Es autor de “Extreme Programming Explained”. Blog en http://www.threeriversinstitute.org/blog/. Alistair Cockburn, inventor de la escala Cockburn para la categorización de proyectos de software e impulsor de la “Declaración de Independencia PM” o “Declaración de Independencia para la Gestión Moderna”, que propugna la aplicabilidad de las metodologías ágiles en otros entornos de gestión. Blog en http://alistair.cockburn.us/Blog. Ward Cunningham, inventor y desarrollador del primer wiki (en 1994 ideó WikiWikiWeb) y pionero en el uso de patrones de diseño y Extreme Programming. Trabajó con Kent Beck en las tarjetas CRC. Creador e impulsor de los test de integración con Fit, el Framework for Integrated Test. Blog en http://dorkbotpdx.org/blog/wardcunningham. Martin Fowler, autor de varios libros influyentes sobre desarrollo de software y responsable científico en ThoughtWorks. Mantiene un blog de referencia. Popularizó el término Inyección de Dependencias como un modo de Inversión de Control. Bliki (blog+wiki) en http://martinfowler.com/bliki/index.html. Andrew Hunt, escritor de libros de desarrollo de software, en particular de “The Pragmatic Programmer”. Con Dave Thomas creó la serie de libros “Pragmatic Bookshelf” para desarrolladores de software. Blog en http://blog.toolshed.com/. Ron Jeffries, fundador de la metodología Extreme Programming, con Kent Beck y Ward Cunningham. Es autor del segundo libro sobre Extreme Programming: “Extreme Programming Installed”. Blog en http://www.xprogramming.com/blog/. Mike Beedle, uno de los primeros adoptantes de Scrum. Coautor de “Scrum, Agile Software Development”. Robert C. Martin, conocido también como Uncle Bob, fundador de Object Mentor y editor jefe de The C++ Report entre 1996 y 1999. Autor de “Agile Software Development: Principles, Patterns, and
11
Andrés Marzal
Buenas prácticas en desarrollo de software con .NET
Practices” y de “Clean Code: A Handbook of Agile Software Craftsmanship”. Blog en http://blog.objectmentor.com/ y Twitter en http://twitter.com/unclebobmartin. Arie van Bennekum, implicado en la metodología DSDM y miembro de DSDM Consortium desde 1997. Twitter http://twitter.com/arievanbennekum. Dave Thomas, coautor de “The Pragmatic Programmer”. Blog en http://pragdave.pragprog.com/. Jim Highsmith, principal desarrollador de la metodología ágil Adaptive Software Development. Blog en http://blog.cutter.com/author/jimhighsmith/. Jon Kern, impulsor de la metodología ágil Feature-Driven Development. Blog en http://technicaldebt.com/. Brian Marick, impulsor y representante de la comunidad de software testing. Blog en http://www.exampler.com/blog/. James Grenning, fundador de Renaissance Software y usuario de metodologías ágiles avant-la-lettre. Autor del artículo “Planning Poker or How to avoid analysis paralysis while release planning”. Blog en http://www.renaissancesoftware.net/blog/. Steve Mellor, coinventor del método Shlaer-Mellor para el desarrollo de sistemas orientados a objetos. Ken Schwaber, presidente de Advanced Development Methods. Trabajó con Jeff Sutherland en la definición de Scrum y es uno de sus formalizadores. Coautor de “Scrum, Agile Software Development”. Blog en http://kenschwaber.wordpress.com/. Jeff Sutherland, CIO de una startup del MIT, PatientKeeper. Uno de los inventores de Scrum. Blog en http://scrum.jeffsutherland.com/.
Kent Beck
Steve Mellor
Alistair Cockburn
Ward Cunningham
Martin Fowler
12
Andrés Marzal
Buenas prácticas en desarrollo de software con .NET
Andy Hunt
Ron Jeffries
Mike Beedle
Robert C. Martin (Uncle Bob)
Arie van Bennekum
Dave Thomas
Jim Highsmith
Jon Kern
Brian Marick
James Grenning
Ken Schwaber
Jeff Sutherland
2.4 Los principios de la agilidad Según Venkat Subramanian y Andy Hunt (en “Practices of an agile developer working in the real world”), “agile development uses feedback to make constant adjustments in a highly collaborative environment.”
La agilidad pone el acento en la entrega de valor. Propone la realización y entrega incremental de software con la participación directa del usuario. Como dice James O. Coplien en la introducción de “Clean Code. A Handbook of Agile Software Craftmanship”, el libro de Robert C. Martin: “In these days of Scrum and Agile, the focus is on quickly bringing product to market. We want the factory running at top speed to produce software. These are human factories: thinking, feeling coders who are working from a product backlog or user story to create product”.
13
Andrés Marzal
Buenas prácticas en desarrollo de software con .NET
Los principios tras el manifiesto ágil son:
Nuestra mayor prioridad es dar satisfacción al cliente mediante la entrega temprana y continua de software con valor. Damos la bienvenida a los requerimientos cambiantes, incluso tardíos en el desarrollo. Los procesos ágiles abrazan el cambio en favor de dar una ventaja competitiva al cliente. Entregue software que funciona frecuentemente, en plazos de un par de semanas a un par de meses, con preferencia por la escala temporal más corta. Los responsables de negocio y los desarrolladores deben trabajar a diario en el proyecto. Construya proyectos con individuos motivados. Deles el entorno y apoyo que necesitan, y confíe en que conseguirán hacer el trabajo. El método más eficiente y efectivo para comunicar información hacia los desarrolladores y entre los desarrolladores de un equipo es la conversación cara a cara. El software que funciona es la medida principal de progreso. Los procesos ágiles promocionan el desarrollo sostenible. Los patrocinadores, desarrolladores y usuarios deben ser capaces de mantener un ritmo constante indefinidamente. La atención continua a la excelencia técnica y el buen diseño mejoran la agilidad. La simplicidad, el arte de maximizar la cantidad de trabajo que no se hace, es esencial. Las mejores arquitecturas, requerimientos y diseños emergen de equipos que se auto-organizan. A intervalos regulares, el equipo reflexiona sobre cómo llegar a ser más efectivo; entonces ajusta su comportamiento adecuadamente.
2.5 Conjuntos de prácticas con nombre propio Los dos conjuntos de prácticas con nombre propio y que se identifican con las metodologías ágiles son Programación Extrema y Scrum/Kanban. Hay otras: Lean, FDD, AUP, Crystal y DSDM. La programación extrema (que probablemente se traduzca mejor por “programación de riesgo”) data de 1996 y se basa en un conjunto de prácticas combinadas.
14
Andrés Marzal
Buenas prácticas en desarrollo de software con .NET
Entre las más llamativas se encuentra la programación en pareja o el desarrollo guiado por las pruebas.
Scrum pone el acento en la gestión del proyecto y propone un proceso de recogida de historias de usuario, cuantificación del esfuerzo de cada tarea, planificación de sprints (tandas de trabajo orientadas a completar una selección de historias de usuario), ejecución de sprints con supervisión diaria, entrega de producto y retrospectiva para analizar lo hecho y mejorar continuamente.
15
Andrés Marzal
Buenas prácticas en desarrollo de software con .NET
Kanban, que es una técnica/metodología independiente que puede enriquecer a Scrum, plantea un sistema de visualización de la carga de trabajo y control del flujo para evitar cuellos de botella y trabajadores ociosos en una etapa del proceso a la espera de que el cuello de botella genere nueva carga de trabajo.
Lo esencial es que la agilidad se basa en los ciclos cortos, la realimentación continua y la adaptación al equipo de desarrolladores. En su núcleo hay un conjunto de herramientas que facilita el trabajo con las metodologías ágiles:
16
Andrés Marzal
Buenas prácticas en desarrollo de software con .NET
3 Principios de diseño de software Hay una serie de principios de diseño que son muy citados en la comunidad del desarrollo ágil. Son principios alineados con una técnica de producción del software que aboga por la simplicidad, la legibilidad y la mantenibilidad del código. El trabajo de Robert C. Martin (Uncle Bob) titulado “Design Principles and Design Patterns” merece una lectura para tener un cuadro general de los principios de diseño de software que pretendemos seguir al construir software.
En este texto sólo vamos a enunciar algunos de los principales principios de diseño (muchos de ellos recogidos en el trabajo citado):
3.1 Open Closed Principle (OCP) Software entities (classes, modules, functions, etc.) should be open for extension but closed for modification.
Es decir, deberíamos poder modificar el comportamiento de las entidades sin modificar su código fuente. En principio se asumió que la herencia era un buen modo de redefinir comportamientos (popularizado por
17
Andrés Marzal
Buenas prácticas en desarrollo de software con .NET
Bertrand Meyer en su libro de 1988 “Object Oriented Software Construction”). Hoy se prefiere el diseño orientado a interfaces.
3.2 Liskov Substitution Principle (LSP) Subclasses should be substitutable for their base classes. Si un objeto es de tipo S y S es un subtipo de T, los objetos de tipo T deberían poder sustituirse por objetos de tipo S sin que se alteren las propiedades deseables en un programa (corrección, tarea realizada, etc.). El principio de Liskov también se conoce por “subtipado comportamental (fuerte)” ((strong) behavioral subtyping) y fue introducido por Barbara Liskov en una ponencia de 1987 titulada “Data abstraction and hierarchy”.
3.3 Dependency Inversion Principle (DIP) Depend upon Abstractions. Do not depend upon concretion.
Este principio preconiza el desacoplamiento allí donde se encuentren relaciones de dependencia de módulos de alto nivel con respecto de módulos de bajo nivel. El principio prescribe:
Que los módulos de alto nivel no dependan de los de bajo nivel, lo que puede hacerse consiguiendo que ambos dependan de abstracciones. Que las abstracciones no dependan de concreciones, sino que las concreciones dependan de abstracciones.
18
Andrés Marzal
Buenas prácticas en desarrollo de software con .NET
El principio fue postulado por Robert C. Martin (Uncle Bob) y aparece en el trabajo “OO Design Quality Metrics. An Analysis of Dependencies”, de 1995.
3.4 Interface Segregation Principle (ISP) Many client specific interfaces are better than one general purpose interface.
Si una interfaz es demasiado grande, conviene fraccionarla en varias interfaces más pequeñas y específicas para que los clientes sólo dependan de aquello que realmente usan. El principio fue formulado por Robert C. Martin.
3.5 Single Responsibility Principle (SRP) An object should have only one responsibility.
El principio afirma que toda clase debe tener una sola responsabilidad y que ésta debería estar encapsulada en la clase. Todos los servicios que ofrece la clase deben estar estrechamente alineados con esa responsabilidad. El principio fue introducido por Robert C. Martin como parte del principio de cohesión en un artículo que formaba parte de “Principles of Object Oriented Design” y se popularizó en su libro “Agile Software Development, Principles, Patterns, and Practices”.
19
Andrés Marzal
Buenas prácticas en desarrollo de software con .NET
3.6 Release/Reuse Equivalency Principle (REP) The granule of reuse is the granule of release. This granule is the package. Only components that are released through a tracking system can be effectively reused.
El código no se debe reutilizar por copia-y-pega, pues no se obtiene beneficio alguno si se modifica el código original. El código se debe reutilizar haciendo uso de librerías publicadas (released). El autor de la librería es el responsable de modificarla e, idealmente, el usuario no debe ver el código fuente. La publicación de librerías obliga a identificar cada versión con números o nombres, lo que obliga a usar algún sistema de control de versiones. En principio es posible usar una clase como unidad de publicación, pero una aplicación tiene tantas que sería difícil controlar el elevado número de versiones de estas unidades de grano fino. Se requiere una entidad de mayor tamaño, como el paquete (grupo de clases). Este problema se conoce por problema de la granuralidad.
3.7 Common Closure Principle (CCP) Classes that change together, belong together.
Entendemos por clausura el conjunto de elementos que se ven afectados y necesitan algún cambio cuando hay un cambio en otra clase. Deberíamos considerar “paquete” a todas las clases que presentan esta sensibilidad al cambio de uno de sus elementos. No siempre es posible diseñar paquetes que respecten este principio.
3.8 Common Reuse Principle (CRP) Classes that aren’t reused together should not be grouped together.
Es un principio que ayuda a diseñar paquetes, esto es, unidades de publicación. El principio exige que sólo agrupemos clases muy cohesivas. Una derivada del principio es que todas las clases que ofrecen o cooperan en proporcionar una determinada funcionalidad deben agruparse en un paquete.
3.9 Acyclic Dependencies Principle (ADP) The dependencies between packages must not form cycles.
El grafo de dependencias entre paquetes debe formar un Grado Dirigido Acíclico. Si no es así, el despliegue de paquetes es un infierno. Este diagrama ilustra un grafo de dependencias con ciclos, muy habitual cuando se usan librerías GUI (extraído de http://www.objectmentor.com/resources/articles/granularity.pdf):
20
Andrés Marzal
Buenas prácticas en desarrollo de software con .NET
Las dependencias cíclicas se pueden romper, por ejemplo, con DIP. Este diagrama, del mismo artículo, ilustra cómo romper la dependencia cíclica invirtiendo la dependencia:
3.10 Stable Dependencies Principle (SDP) Depend in the direction of stability. A package should only depend upon packages that are more stable than it is.
Hace falta presentar unas cuantas definiciones:
La fragilidad es la tendencia de un programa a “romperse” en varios lugares cuando se introduce un solo cambio. El diseño de dependencias es crítico: las interdependencias contribuyen a la fragilidad. Sólo debe haber dependencias con el código no-volátil, es decir, que es imposible (o muy improbable) que vaya a sufrir cambios. La volatilidad depende de varios factores, pero uno de los que influyen más directamente y es fácil de medir es la estabilidad, que es una medida de lo difícil que es cambiar un módulo. Cuanto más difícil de cambiar, menos volátil. Una clase es independiente cuando no depende de nada y la independencia es un indicador de estabilidad: los cambios en alguien que depende de una clase independiente no pueden afectarle. Una clase de la que dependen muchas otras es una clase responsable. Hay una gran resistencia a que una clase responsable cambie, pues un cambio comportaría nuevos cambios que se propagan a muchas otras clases. Las clases más estables son las que son independientes y responsables.
21
Andrés Marzal
Buenas prácticas en desarrollo de software con .NET
Hay una métrica de estabilidad posicional que depende del número de dependencias que entran y salen de un paquete: , Acoplamientos aferentes (afferent couplings): número de clases de fuera del paquete que depende de las clases de dentro del paquete. , Acoplamiento eferentes (efferent couplings): número de clases dentro del paquete que dependen de clases de fuera del paquete. , Inestabilidad (instability): . Es un valor entre 0 y 1. El valor 0 indica la máxima estabilidad (paquete independiente y responsable) y el valor 1, la máxima inestabilidad (paquete dependiente e irresponsable). Este ejemplo, extraído de http://www.objectmentor.com/resources/articles/stability.pdf, ilustra un cálculo de estabilidad posicional:
El principio SDP dice que un paquete sólo debe depender de paquetes con I menor que el suyo. El valor de la inestabilidad debe decrecer en la dirección de las dependencias (o, dicho del revés: el valor de la estabilidad debe aumentar en la dirección de las dependencias).
3.11 Stable Abstractions Principle (SAP) Stable packages should be abstract packages.
Una métrica adicional es la abstracción de un paquete:
, abstracción (abstractness), se define como en número de clases abstractas partido por el del total de clases. Un valor de 0 indica que un paquete no tienen ninguna clase abstracta y un valor de 1 indica que sólo tiene clases abstractas.
Los “buenos” paquetes se alojan en esta diagonal de una gráfica de abstracción contra (in)estabilidad, en la que hay unas zonas de exclusión:
22
Andrés Marzal
Buenas prácticas en desarrollo de software con .NET
Una última métrica permite conocer la distancia de un paquete a la zona “sana”:
, Distancia. Se define como para estar entre 0 y 1:
√
. En un valor entre 0 y 0.707, que se puede normalizar
.
La herramienta NDepend (http://www.ndepend.com/) permite obtener estas y otras métricas automáticamente.
3.12 Law of Demeter for Functions/Methods (LoD-F/M) o Least Knowledge Principle (LKP) Each unit should have only limited knowledge about other units: only units “closely” related to the current unit. Each unit should only talk to its friends; don’t talk to strangers.
En la programación orientada a objetos, se entiende que “unidad” es cada método individual y que las unidades relacionadas estrechamente son
los métodos del propio objeto, los argumentos pasados al método, los objetos que instancia directamente en el propio método, los métodos de partes de la clase que son accesibles directamente o variables globales visibles en el ámbito de M.
Un objeto A puede solicitar un servicio de un objeto B, pero A no debe acceder a un objeto C a través de B para solicitar sus servicios. Si lo hiciera, sería porque conoce demasiado sobre la estructura interna de B. O bien B aumenta sus servicios para ofrecer directamente los que ofrece B, o bien A accede directamente a C.
3.13 Don’t Repeat Yourself (DRY) o Duplication is Evil (DE) Every piece of knowledge must have a single, unambiguous, authoritative representation within a system.
Fue formulado por Andy Hunt y Dave Thomas en el libro “The Pragmatic Programmer”. Es un principio elemental y que ya ha aparecido sugerido en el principio Release/Reuse Equivalency Principle (REP).
23
Andrés Marzal
Buenas prácticas en desarrollo de software con .NET
3.14 You Ain’t Gonna Need It (YAGNI) Always implement things when you actually need them, never when you just foresee that you need them. Un problema corriente en el código es la “sobreingeniería”, es decir, la construcción de (mucha) más funcionalidad de la estrictamente necesaria. Esto dificulta la lectura del código y su mantenimiento sin aportar valor.
3.15 Keep It Simple, Stupid! (KISS) La complejidad innecesaria debe evitarse.
3.16 Los principios SOLID Oirá hablar frecuentemente de los principios SOLID, que no son más que una selección de algunos de los que hemos citado. Los cinco principios SOLID son:
S
Single Responsibility Principle
O
Open Closed Principle
L
Liskov Substitution Principle
I
Interface Segregation Principle
D
Dependency Inversion Principle
24
Andrés Marzal
Buenas prácticas en desarrollo de software con .NET
3.17 Créditos y recursos de esta sección
El trabajo de los hermanos Dreyfus sobre los cinco niveles en el aprendizaje de habilidades se puede descargar de http://www.dtic.mil/cgibin/GetTRDoc?Location=U2&doc=GetTRDoc.pdf&AD=ADA084551. El libro Outliers, de Malcolm Gladwell, está a la venta en http://www.amazon.com/Outliers-StorySuccess-Malcolm-Gladwell/dp/0316017922. El ensayo “Teach yourself to program in 10 years”, de Peter Norvig”, está disponible en http://norvig.com/21-days.html. El ensayo “Hackers and Painters”, de Paul Graham, está disponible en http://www.paulgraham.com/hp.html y forma parte del libro del mismo título, que se puede adquirir en http://oreilly.com/catalog/9780596006624. El libro “Seven Languages in Seven Weeks”, de Bruce A. Tate, está a la venta en http://pragprog.com/titles/btlang/seven-languages-in-seven-weeks. El libro “Becoming Agile” está a la venta en http://www.manning.com/smith/. El libro “Practices of an Agile Developer”, de Venkat Subramanian y Andy Hunt, está a la venta en http://pragprog.com/titles/pad/practices-of-an-agile-developer. El libro “Clean Code”, de Uncle Bob, está a la venta en http://www.amazon.com/Clean-CodeHandbook-Software-Craftsmanship/dp/0132350882. El libro “Object Oriented Software Construction”, de Bertrand Meyer, está a la venta en http://www.amazon.com/Object-Oriented-Software-Construction-Book-CD-ROM/dp/0136291554. El paper “OO Design Quality Metrics. An Analysis of Dependencies” de Uncle Bob está disponible en http://www.objectmentor.com/resources/articles/oodmetrc.pdf. El libro “Agile Software Development, Principles, Patterns, and Practices”, de Uncle Bob, está a la venta en http://www.amazon.com/Software-Development-Principles-PatternsPractices/dp/0135974445. El libro “The Pragmatic Programmer”, de Andy Hunt y Dave Thomas, está a la venta en http://pragprog.com/the-pragmatic-programmer. El trabajo “Poker Planning”, de James W. Grenning, está disponible en http://www.renaissancesoftware.net/files/articles/PlanningPoker-v1.1.pdf. Imagen de XP Practices extraída de http://xprogramming.com/images/circles.jpg. Fotografía de panel Kanban extraída de http://leansoftwareengineering.com/2007/10/27/kanbanbootstrap/. Imagen “Import God;” extraída de http://www.facebook.com/group.php?gid=7530335849. Imagen de Agile Development extraída de Wikipedia: http://en.wikipedia.org/wiki/File:Agile_Software_Development_methodology.jpg. El trabajo “Design Principles and Design Patterns” de Uncle Bob está disponible en http://www.objectmentor.com/resources/articles/Principles_and_Patterns.pdf. Los carteles motivacionales que ilustran los principios de desarrollo de software se han extraído de http://www.doolwind.com/blog/solid-principles-for-game-developers/.
25
Andrés Marzal
Buenas prácticas en desarrollo de software con .NET
4 Patrones de diseño Christopher Alexander, arquitecto, deseaba cambiar el modo en el que se diseñaban los espacios de trabajo y vivienda. Pensaba que lo ideal es que se diseñen y construyan por los propios ocupantes, pues son ellos quienes mejor conocen los requerimientos.
Dada la complejidad que supone el diseño de una construcción, sería necesario dotarse de un conjunto de soluciones arquetípicas para los problemas más frecuentes y usar un lenguaje que facilite la expresión de los diseños. La observación más relevante es, quizá, que hay ciertos problemas recurrentes en el mundo de la arquitectura para los que se han descubierto y redescubierto ciertas soluciones que, convenientemente abstraídas de sus detalles más concretos, podemos denominar “patrones” o “patrones de diseño”. Su libro “A Pattern Language: Towns, Buildings, Construction” presentaba estos patrones así: “The elements of this language are entities called patterns. Each pattern describes a problem which occurs over and over again in our environment, and then describes the core of the solution to that problem, in such a way that you can use this solution a million times over, without ever doing it the same way twice. For convenience and clarity, each pattern has the same format. First, there is a picture, which shown an archetypical example of that pattern. Second, after the picture, each pattern has an introductory paragraph, which sets the context for the pattern, by explaining how it helps to complete certain larger patterns. Then there are three diamonds to mark the beginning of the problem. After the diamonds there is a headline, in bold type. This headline gives the essence of the problem in one or two sentences. After the headline comes the body of the problem. This is the longest section. It describes the empirical background of the pattern, the evidence for its validity, the range of different ways the pattern can be manifested in a building, and so on. Then, again in bold type, like the headline, is the solution—the heart of the pattern—which describes the field of physical and social relationships which are required to solve the stated problem, in the stated context. This solution is always stated in the form of an instruction—so that you know exactly what you need to do, to build the pattern. Then, after the solution, there is a diagram, which shows the solution in the form of a diagram, with labels to indicate its main components. After the diagram, another three diamonds, to show that the main body of the pattern is finished. And finally, after the diamonds there is a paragraph which ties the pattern to all those smaller patterns in the language, which are needed to complete this pattern, to embellish it, to fill it out.
26
Andrés Marzal
Buenas prácticas en desarrollo de software con .NET
There are two essential purposes behind this format. First, to present each pattern connected to other patterns, so that you grasp the collection of all 253 patterns as a whole, as a language, within which you can create an in finite variety of combinations. Second, to present the problem and solution of each pattern in such a way that you can judge it for yourself, and modify it, without losing the essence that is central to it.”
Los patrones son esquemas de soluciones para problemas genéricos frecuentes que han de adaptarse a los detalles concretos de cada instancia de dichos problemas. A la hora de presentar cada elemento de su colección de patrones, Alexander seguía disciplinadamente una estructura muy homogénea. Una ventaja de esta aproximación a la presentación de soluciones para problemas recurrentes es que, en tanto se define un nombre para cada patrón, se crea una nomenclatura que simplifica la comunicación entre los especialistas que la adoptan. No es poca cosa. El lenguaje de patrones no ofrece un marco conceptual válido únicamente para la arquitectura: cualquier ingeniería puede encontrar inspiración en el trabajo de Alexander para crear un conjunto propio de soluciones arquetípicas para problemas que se plantean frecuentemente en un campo.
4.1 Patrones de diseño en ingeniería del software En 1987, Kent Beck y Ward Cunningham presentaron en OOPSLA el trabajo “Using Pattern Languages for Object-Oriented Programs”, donde reconocían el trabajo de Alexander y anunciaban el inicio de la escritura de un lenguaje de patrones para el diseño de software orientado a interfaces de usuario.
En 1994, Ward Cunningham creó un wiki para albergar una colección de patrones de diseño editable fácilmente por la comunidad: el Portland Pattern Repository3 (http://c2.com/ppr/). En el campo del diseño de software, los programadores encuentran constantemente problemas esencialmente idénticos y acaban descubriendo soluciones similares para estos. Disponer de un catálogo de patrones resulta de indudable ayuda. Por una parte, ofrece soluciones independientes de los lenguajes de programación específicos para problemas que encontramos en cualquier sistema de software mínimamente complejo. Por otra, nos dota de un lenguaje común capaz de eliminar muchas dificultades en la comunicación entre equipos de desarrollo. Resulta mucho más sencillo, por ejemplo, hablar de un Singleton que de un “objeto del que sólo hay una instancia y al que se accede a través de un método o propiedad de 3
Portland Pattern Repository es el primer Wiki (o WikiWikiWeb, como fue bautizado por Cunningham).
27
Andrés Marzal
Buenas prácticas en desarrollo de software con .NET
una clase”. Y aún hay una ventaja adicional si atendemos a las necesidades de los programadores principiantes: se les expone a una colección de técnicas basadas en buenos análisis de los problemas y a los que se solía llegar casi exclusivamente por el tortuoso camino de la experiencia propia. Finalmente, el diseño del software se beneficia de que aquellas partes de la estructura del software que corresponden a patrones sean fácilmente identificables y, por tanto, no se entrometan en la comprensión de arquitecturas complejas. Si uno detecta, por ejemplo, un Singleton, no tiene que detenerse a entender trabajosamente todos los elementos que conforman su mecánica, pues estará comprendida de antemano. Cualquier tratado moderno de software, tanto centrado en el diseño como en las herramientas que ayudan a su desarrollo, hará uso de patrones de diseño. No tener un conocimiento razonable de ellos dificultará inevitablemente su comprensión. El trabajo que ayudó a divulgar el concepto de patrones de diseño en la comunidad de desarrolladores es “Design Patterns: Elements of Reusable Object-Oriented Software”, de E. Gamma, R. Helm, R. Johnson y J.M. Vlissides, publicado en 1994. El libro se conoce popularmente como el de “la banda de los cuatro” o “GoF”, por “Gang of Four”, y es uno de los libros con mayor impacto en la comunidad de desarrolladores.
El libro ofrece una reflexión acerca de la programación orientada a objetos y presenta dos docenas de patrones de diseño agrupados en tres tipos de patrones: Creational Patterns
Structural Patterns
Behavioral Patterns
Abstract Factory,
Adapter,
Chain of responsibility,
Builder,
Bridge,
Command,
Factory Method,
Composite,
Interpreter,
Prototype,
Decorator,
Iterator,
Singleton,
Facade,
Mediator,
Multiton.
Flyweight,
Memento,
Proxy.
Observer, State,
28
Andrés Marzal
Buenas prácticas en desarrollo de software con .NET
Strategy, Template method, Visitor.
El libro de la banda de los cuatro sigue una estructuración de los contenidos de cada patrón inspirada en la que usó Alexander para los patrones en la construcción:
Pattern Name and Classification Intent Also Known As Motivation Applicability Structure Participants Collaborations Consequences Implementation Sample Code Known Uses Related Patterns
El libro de la banda de los cuatro es un tanto árido. En los últimos 15 años han aparecido muchos tratados sobre patrones de diseño y el número de patrones de uso común ha crecido sustancialmente. Podemos destacar “Code Complete” (segunda edición), “Holub on Patterns” o “Head First Design Patterns” (este último es especialmente didáctico).
Entre los patrones de diseño que no aparecen en el libro de la banda de los cuatro encontramos: Creational Patterns
Behavioral Patterns
Concurrency Patterns
29
Andrés Marzal
Buenas prácticas en desarrollo de software con .NET
Nosotros estudiaremos unos pocos patrones. Nuestro objetivo es ofrecer una introducción al mundo del diseño basado en patrones y dar a conocer los que aparecerán más tarde cuando estudiemos algunas de las técnicas que conforman el objeto del curso: pruebas unitarias, inyección de dependencias, etcétera. Hay un buen libro para aprender patrones de diseño con C# 3.0: “C#3.0 Design Patterns”, de Judith Bishop.
4.2 El patrón de diseño Decorator Decorator es un patrón de diseño estructural que ofrece una alternativa a la herencia como forma de extender la funcionalidad de una clase. ¿Y por qué implementar extensiones de funcionalidad sin recurrir a la herencia, que parece la técnica propia de la orientación a objetos? Porque, como veremos, la herencia conduce a una situación indeseable cuando queremos formar objetos seleccionando con precisión la funcionalidad que esperamos de un cierto objeto.
30
Andrés Marzal
Buenas prácticas en desarrollo de software con .NET
El decorador es un patrón de uso común en interfaces gráficas de usuario. Un componente de una librería de interfaces gráficas puede consistir en un lienzo para dibujo o en una caja para editar texto. Si estos componentes sólo muestran una parte del contenido de una superficie grande (una zona del dibujo o unos apenas unos párrafos de un texto largo), podemos movernos por esta con barras de desplazamiento. Un componente enriquecido con barras de desplazamiento sigue siendo un componente de la misma naturaleza, sólo que con una funcionalidad añadida. Decimos que las barras de desplazamiento decoran al componente y que el componente con las barras es un nuevo componente, solo que decorado. Imaginemos ahora que a un componente simple o a uno con barras de desplazamiento le añadimos un borde negro con sombra para destacarlo: el resultado seguirá siendo un componente, pero al que hemos añadido una nueva decoración. Un componente.
Un componente decorado con un borde, que también es un componente.
Un componente decorado con barras de desplazamiento, que también es un componente.
Un componente (decorado con barras de desplazamiento) decorado con un borde, que también es un componente.
Al diseñar la librería de componentes podemos entender que un objeto decorado ha de ser miembro de una clase que especialice a la clase del objeto sin decoración, es decir, que la herencia es la herramienta con la que hemos de modelar esta relación de decoración, pero veremos que hay una solución más elegante. Aunque el concepto de decorador se visualiza fácilmente en el campo de las GUI, no sólo vale para este campo. Es frecuente que se usen decoradores en librerías de entrada salida y al final veremos cómo la librería de flujos de entrada/salida (streams) de .NET se ha diseñado con este patrón. Antes de entrar a estudiar un caso concreto como simples espectadores, es mejor que veamos una aplicación propia del concepto de decorador en un caso sencillo respondiendo a unas decisiones de diseño que podemos hacer propias. 4.2.1 Un ejemplo: procesadores de cadenas Nuestro ejemplo consistirá en un conjunto de implementaciones para una interfaz IStringProcessor que ofrecerá, a partir de un método, la capacidad de procesar una cadena y modificar su contenido de acuerdo con algún propósito particular (es una forma de halar, porque en .NET las cadenas son inmutables). Una implementación de IStringProcessor podría, por ejemplo, pasar el texto a mayúsculas, otra podría transcribir a texto los números, otra podría sustituir ciertas palabras por sus abreviaturas, otra podría normalizar el texto en aspectos como asegurar que después de cada signo de puntuación hay un espacio, pero no antes, y aún otra podría asegurar que no apareciesen secuencias de más de un espacio en blanco. De hecho, implementaremos estos procesadores de texto y veremos cómo podemos combinarlos flexiblemente gracias al uso del patrón Decorator. Empezamos por presentar la interfaz IStringProcessor, que se define en el fichero IStringProcessor.cs:
31
Andrés Marzal
Buenas prácticas en desarrollo de software con .NET
Nuestra primera implementación es una clase que proporciona una versión “todo mayúsculas” del texto: namespace StringProcessor { namespace WithSubclasses { public class UpperCaser : IStringProcessor { public string Process(string input) { return input.ToUpper(); } } } }
Vamos a por la clase que reemplaza las secuencias de dos o más espacios en blanco por un solo espacio: using System.Text.RegularExpressions; namespace StringProcessor { namespace WithSubclasses { public class WhiteSequenceRemover : IStringProcessor { public string Process(string input) { return Regex.Replace(input, " +", " "); } } } }
¿Y si ahora quisiésemos una clase que combinase las dos funcionalidades? Muy fácil: la herencia viene en nuestra ayuda. Bueno: no tan fácil. En .NET no hay herencia múltiple, así que no podremos combinar las dos clases para crear una nueva. Aprovechemos, al menos, una de ellas. Eso nos obliga a volver sobre nuestros pasos y declarar como virtual el método de la clase base. ¿Cómo no habíamos previsto esta futura necesidad? class UpperCaser : IStringProcessor { public virtual string Process(string input) { return input.ToUpper(); } }
Nuestra nueva clase es namespace StringProcessor { namespace WithSubclasses {
32
Andrés Marzal
Buenas prácticas en desarrollo de software con .NET
public class UpperCaserAndWhiteSpaceRemover : UpperCaser { public override string Process(string input) { var upperCaseInput = base.Process(input); return Regex.Replace(upperCaseInput, " +", " "); } } } }
Y ya empiezan los problemas: hemos duplicado código, lo que va contra el principio DIY (Don’t Repeat Yourself). Si más adelante modificásemos el método de sustitución de espacios en blanco porque descubriésemos, por ejemplo, que la librería Regex presenta problemas de eficiencia, tendríamos que hacer cambios en dos puntos de nuestro programa. Pero, bien, asumamos que no nos queda más remedio. Hay algunos problemas adicionales: hemos escondido que UpperCaserAndWhiteSpaceRemover implementa la interfaz IStringProcessor. Este segundo problema sólo afecta a la legibilidad del código y es fácilmente subsanable: class UpperCaserAndWhiteSpaceRemover : UpperCaser, IStringProcessor ...
Nos interesa ahora implementar el procesador que sustituye cada digito por su texto. namespace StringProcessor { namespace WithSubclasses { public class DigitRemover: IStringProcessor { public virtual string Process(string input) { return input.Replace("0", "cero ") .Replace("1", "uno ") .Replace("2", "dos ") .Replace("3", "tres ") .Replace("4", "cuatro ") .Replace("5", "cinco ") .Replace("6", "seis ") .Replace("7", "siete ") .Replace("8", "ocho ") .Replace("9", "nueve "); } } } }
Y ahora empieza la complicación. ¿Cómo combinamos ahora este nuevo procesador con cada uno de los anteriores. Ya tenemos implementados tres procesadores distintos (UpperCaser, WhiteSequenceRemover y UpperCaserAndWhiteSequenceRemover) que podemos desear combinar con el nuevo DigitRemover. Esto dará lugar a tres nuevas clases y nuestra librería estará formada por un total de siete. Si añadimos un nuevo procesador de cadenas, la librería deberá enriquecerse hasta tener quince clases. Encima, muchas de ellas repetirán código, con los problemas que ya hemos apuntado. Un infierno. Veamos cómo resolver el problema con una aproximación distinta. El código de UpperCaser y WhiteSequenceRemover será este: public class UpperCaser : IStringProcessor
33
Andrés Marzal
Buenas prácticas en desarrollo de software con .NET
{ private readonly IStringProcessor _stringProcessor; public UpperCaser(IStringProcessor stringProcessor = null) { _stringProcessor = stringProcessor; } public string Process(string input) { if (_stringProcessor != null) input = _stringProcessor.Process(input); return input.ToUpper(); } } public class WhiteSequenceRemover : IStringProcessor { private readonly IStringProcessor _stringProcessor; public WhiteSequenceRemover(IStringProcessor stringProcessor = null) { _stringProcessor = stringProcessor; } public string Process(string input) { if (_stringProcessor != null) input = _stringProcessor.Process(input); return Regex.Replace(input, " +", " "); } }
Las dos clases se han diseñado siguiendo un mismo esquema. Las dos implementan la misma interfaz y ambas almacenan un IStringProcessor como campo privado (_stringProcessor) al que sólo se puede asignar un valor a través del constructor. El valor de _stringProcessor puede ser nulo, pues el constructor admite éste valor como valor por defecto. Esta cuestión de que _stringProcessor pueda tomar valor nulo no es muy elegante: obliga a poner una guarda en cada definición de Process. Si creamos un procesador idempotente, el resultado será más elegante: class IdentityStringProcessor : IStringProcessor { public string Process(string input) { return input; } } public class UpperCaser : IStringProcessor { private readonly IStringProcessor _stringProcessor; public UpperCaser(IStringProcessor stringProcessor = null) { _stringProcessor = stringProcessor ?? new IdentityStringProcessor(); } public string Process(string input) { return _stringProcessor.Process(input).ToUpper();
34
Andrés Marzal
Buenas prácticas en desarrollo de software con .NET
} } public class WhiteSequenceRemover : IStringProcessor { private readonly IStringProcessor _stringProcessor; public WhiteSequenceRemover(IStringProcessor stringProcessor = null) { _stringProcessor = stringProcessor ?? new IdentityStringProcessor(); } public string Process(string input) { return Regex.Replace(_stringProcessor.Process(input), " +", " "); } }
Veamos cómo construir un procesador de cadenas que pase cadenas a todo mayúsculas y cómo usarlo: class Demo { static void Main() { IStringProcessor upperCaser = new UpperCaser(); var entrada = "un ejemplo de cadena"; var salida = upperCaser.Process(entrada); Console.WriteLine("{0} -> {1}", entrada, salida); } }
Nada especial. Resulta evidente cómo crear un procesador de cadenas que elimine los blancos de más, pero ¿cómo crear un procesador que pase a mayúsculas y elimine los espacios en blanco?: class Demo { static void Main() { IStringProcessor myProcessor = new WhiteSequenceRemover(new UpperCaser()); var entrada = "un ejemplo de cadena"; var salida = myProcessor.Process(entrada); Console.WriteLine("{0} -> {1}", entrada, salida); } }
El constructor de un IStringProcessor acepta como parámetro (potencialmente) otro IStringProcessor con el que forma un proceso en cadena. WhiteSequencerRemover UpperCaser IdentityStringProcessor
Cada procesador añade funcionalidad a otro: decimos que un procesador “decora” al otro. Donde tenía dos procesadores simples puedo obtener dos complejos con gran sencillez, sin complicar la colección de clases que ofrezco en mi librería. Bienvenidos a nuestro primer patrón de diseño: el Decorator. 4.2.2 El patrón Decorator Veamos ahora una presentación típica del patrón en el propio libro de la banda de los cuatro.
35
Andrés Marzal
Decorator Propósito Añadir dinámicamente responsabilidades adicionales a un objeto. Los decoradores proporcionan una alternativa flexible a la especialización por herencia para extender funcionalidad. También conocido como Wrapper (envoltorio). Motivación A veces queremos añadir responsabilidades a objetos individuales, no a una clase entera. Una librería para interfaces gráficas de usuario, por ejemplo, debe permitir agregar propiedades como bordes o desplazamiento a cualquier componente de la interfaz de usuario. Una forma de añadir funcionalidad es con herencia. Heredar el borde de una clase puede lograr que cada instancia de sus subclases presente un borde alrededor. Esta práctica es, sin embargo, inflexible porque la elección del borde se hace estáticamente. Un cliente no puede controlar cómo y cuándo decorar un componente con un borde. Un enfoque más flexible consiste en incluir el componente en otro objeto que añada el borde. El objeto que encierra al otro se llama decorador. El decorador se ajusta a la interfaz del componente que decora, de manera que su presencia es transparente para los clientes del componente. El decorador reenvía la llamada al componente y realiza acciones adicionales (como el dibujo de un borde) antes o después del reenvío. La transparencia permite decoradores anidados recursivamente, permitiendo así añadir un número ilimitado de responsabilidades.
Buenas prácticas en desarrollo de software con .NET
ScrollDecorator para producir una vista limita, texto desplazable:
Las clases ScrollDecorator y BorderDecorator son subclases de Decorator, una clase abstracta para los componentes visuales que decoran otros componentes visuales.
VisualComponent es la clase abstracta de objetos visuales. Define el dibujado y la interfaz de gestión de eventos. Nótese que la clase Decorator simplemente reenvía las peticiones de dibujado a su componente y cómo las subclases de Decorator puede extender la operación. Las subclases de Decorator son libres de añadir operaciones para funcionalidades específicas. Por ejemplo, el funcionamiento del método ScrollTo de ScrollDecorator permite a otros objetos efectuar desplazamientos en la interfaz si saben que hay un objeto ScrollDecorator en la interfaz. El aspecto relevante de este modelo es que permite que los decoradores aparecezcan en cualquier lugar donde puede aparecer un VisualComponent. De ese modo, los clientes normalmente no pueden diferenciar entre un componente decorado y otro sin decorar, y así no se crea una dependencia con respecto a la decoración.
Por ejemplo, supongamos que tenemos un objeto de TextView que muestra el texto en una ventana. TextView no tiene barras de desplazamiento por defecto porque no siempre se necesitan. Cuando lo hacemos, podemos utilizar un ScrollDecorator para agregarlos. Supongamos también que queremos añadir un borde grueso negro alrededor de la TextView. Podemos utilizar un BorderDecorator para agregarlo. Simplemente componemos los decoradores con la Vista de Texto para producir el resultado deseado. El siguiente diagrama de objetos muestra cómo componer un objeto TextView con BorderDecorator y objetos
Aplicabilidad Use Decorator:
para añadir responsabilidades a objetos individuales dinámica y transparentemente, es decir, sin afectar a otros objetos. para responsabilidades que se pueden retirar. cuando la extensión por especialización con herencia no es practicable. A veces, un número elevado de extensiones independientes producirían una explosión de subclases para soportar a cualquier combinación. O una definición de clase podría estar oculta o, en general, no disponible para definir una subclase.
36
Andrés Marzal
Buenas prácticas en desarrollo de software con .NET
2. Estructura
Participantes Componente (VisualComponent): define la interfaz para objetos que pueden tener responsabilidades añadidas dinámicamente. ConcreteComponent (TextView): define un objeto al que se pueden añadir responsabilidades. Decorator: mantiene una referencia a un objeto Component y define una interfaz que se atiene a la interfaz del Component. ConcreteDecorator (BorderDecorator, ScrollDecorator): añade responsabilidades al componente.
3.
4.
Colaboraciones El Decorator reenvía las peticiones a sus objetos Component. Opcionalmente puede realizar operaciones adicionales antes y después de reenviar la petición. Consecuencias El patrón decorador tiene al menos dos beneficios clave y pasivos dos: 1.
Más flexibilidad que la herencia estática. El patrón Decorator proporciona una manera más flexible de añadir responsabilidades a objetos que la que podemos tener con la herencia (múltiple) estática. Con decoradores las responsabilidades se pueden agregar y quitar en tiempo de ejecución con sólo conectarlas y desconectarlas. Por el contrario, la herencia requiere la creación de una nueva clase para cada responsabilidad adicional (por ejemplo, BorderedScrollableTextView, BorderedTextView). Esto da lugar a muchas clases y aumenta la complejidad de un sistema. Por otra parte, proporciona diferentes clases Decorator para una clase de componentes específicos permite mezclar y combinar responsabilidades. Los decoradores también facilitan agregar una propiedad dos veces. Por ejemplo, para dar una TextView un borde doble basta con conectar dos BorderDecorators. Heredar dos veces de una clase que añada el borde es propenso a la comisión de errores, en el mejor de los casos.
Evita clases cargadas de funcionalidad en lo alto de la jerarquía. Decorator ofrece un enfoque “pague sobre la marcha” a la adición de responsabilidades. En lugar de tratar de soportar todas las características previsibles en una clase compleja y personalizable, define una clase simple y agrega la funcionalidad incrementalmente mediante decoraciones. La funcionalidad puede componerse de piezas sencillas. Como resultado, una aplicación no tiene por qué pagar por funcionalidad que no utiliza. También facilita definir nuevos tipos de decoradores con independencia de las clases de objetos extendidas, permitiendo extensiones imprevisibles. Ampliar una clase compleja suele exponer detalles relacionados con las responsabilidades que se añaden. Un decorador y su componente no son idénticos. Un decorador actúa como una envoltura transparente. Pero desde el punto de vista de la identidad del objeto, un componente decorado no es idéntico al propio componente. Por lo tanto no se debe depender de la identidad del objeto al usar decoradores. Montones de pequeños objetos. Un diseño que utiliza decoradores suele dar lugar a sistemas compuestos por gran cantidad de pequeños objetos parecidos. Los objetos se diferencian sólo en la forma de interconectarse, no en su clase o en el valor de sus variables. Aunque estos sistemas son fáciles de personalizar por aquellos que los entienden, puede dificultar el aprendizaje y la depuración.
Implementación Deben tenerse en cuenta varias cuestiones al aplicar el patrón Decorator: 1.
2.
3.
Conformidad con la interfaz. La interfaz de un objeto decorador debe ajustarse a la interfaz del componente que se decora. por tanto, la clase ConcreteDecorator debe heredar de una clase común (al menos en C++). Omisión de la clase abstracta Decorator. No hay necesidad de definir una clase abstracta Decorator cuando sólo hay necesidad de añadir una responsabilidad. Este suele ser el caso cuando se trata con una jerarquía de clases existente en lugar del diseño de una nueva. En ese caso, se puede combinar la responsabilidad del Decorator de reenviar peticiones a la componente en el ConcreteDecorator. Mantenimiento de levedad en las clases Component. Para asegurar un ajuste de la interfaz, componentes y decoradores deben descender de una clase Component común. Es importante que esa clase común sea ligera, es decir, ésta debe centrarse en la definición de una interfaz, no en el almacenamiento de datos. La definición de la representación de datos se desplazara a
37
Andrés Marzal
4.
las subclases; de lo contrario la complejidad de la clase Component puede hacer que los decoradores sean demasiado pesados para ser usados frecuentemente. Juntar una gran cantidad de funcionalidad en un Component aumenta la probabilidad de que las subclases específicas paguen por funciones que no necesitan. Cambio de la piel de un objeto frente a cambio de sus entrañas. Podemos pensar en un decorador como la piel de un objeto que cambia su comportamiento. Una alternativa es cambiar las entrañas del objeto. La Estrategia (315) es un buen ejemplo de patrón para el cambio de las entrañas. Las estrategias son una opción mejor en situaciones en que la clase Component es intrínsecamente pesada, haciendo que el patrón Decorator sea demasiado costoso de aplicar. En el patrón Estrategia, el componente desplaza su comportamiento a un objeto Estrategia distinto. El patrón Estrategia nos permite modificar o ampliar la funcionalidad del componente mediante la sustitución del “objeto estrategia”. Por ejemplo, podemos soportar diferentes estilos de borde haciendo que el componente difiera el dibujado del borde a objeto Border separado. El objeto Border es un objeto Strategy que encapsula una estrategia de dibujado de bordes. Al extender el número de estrategias de una a una lista potencialmente ilimitada se consigue el mismo efecto que si se anidaran los decoradores recursivamente. En MacApp 3.0 [App89] y Bedrock [Sym93a], por ejemplo, los componentes gráficos (denominados “vistas”) mantienen una lista de objetos de adorno que pueden ligar adornadores adicionales, como los bordes, a un componente de vista. Si una vista tiene adornos vinculados, le da la oportunidad de dibujar los ornamentos adicionales. MacApp y BedRock deben usar esta aproximación por la clase View es pesada. Sería demasiado caro usar una View completamente equipada para limitarse a añadir un borde. Como el patrón Decorator solo cambia un componente desde el exterior, el componente no ha de saber nada sobre sus decoradores; esto es, los decoradores son transparentes al componente:
Con estrategias, los mismos componentes saben de las posibles extensiones. Así pues, ha de referenciar y mantener las correspondientes estrategias:
La aproximación basada en Strategy puede requerir la
Buenas prácticas en desarrollo de software con .NET
modificación del componente para acomodar nuevas extensiones. Por otra parte, una estrategia puede tener su propia interfaz especializada, mientras que una interfaz de decoración debe cumplir con la del componente (DrawBorder, GetWidth, etc.), lo que significa que la estrategia puede ser ligera incluso si la clase Component es pesada. MacApp y Bedrock usan esta aproximación para más cosas que adornar vistas. También la usan para aumentar el comportamiento de objetos en el tratamiento de eventos. En ambos sistemas, la vista mantiene una lista de objetos “comportamiento” que pueden modificar e interceptar eventos. La vista da, a cada objeto de comportamiento registrado, una oportunidad de gestionar el evento antes que a los comportamientos no registrados, redefiniéndolos efectivamente. Podemos decorar una vista con soporte especial de gestión del teclado, por ejemplo, registrando un objeto de comportamiento que intercepta y gestiona los eventos de tecla. Código de ejemplo [Lo omitimos por extenso y por estar presentado en C++] Usos conocidos Muchas librería de interface de usuario orientadas a objetos usas decoradores para añadir ornamentaciones a los componentes. Entre los ejemplos encontramos InterViews [LVC98, LCI+092], ET++ [WGM88] y la librería de clases ObjectWorks\Smalltalk [Par90]. Aplicaciones más exóticas de Decorator son: DebuggingGlyph de InterViews y PassivityWrapper de ParcPlace SmallTallk. Un DebuggingGlyph imprime información de depuración antes y después de reenviar una petición de maquetación a su componente. La información de traza puede usarse para analizar y depurar el comportamiento de maquetación de los objetos en una composición compleja. El PassivityWrapper puede habilitar o deshabilitar las interacciones de los usuarios con el componente. Pero el parón Decorator no limita su uso a interfaces gráficas de usuario, como el siguiente ejemplo (basado en las clases de flujos de datos de ET++) ilustra. Los flujos de datos son abstracciones fundamentales de la mayoría de sistemas I/O. Un flujo puede proporcionar una interfaz para convertir objetos en secuencias de bytes o caracteres. Esto permite transcribir un objeto a un fichero o a una cadena en memoria para su posterior recuperación. Un modo directo de hacerlo es definir una clase abstracta Stream con subclases MemoryStream y FileStream. Pero supongamos que también queremos hacer lo siguiente: Comprimir los datos del flujo usando diferentes algoritmos de compresión (codificación por longitud de recorrido, Lempel-Ziv,etc.).
38
Andrés Marzal
Reducir los datos del flujo a caracteres ASCII de 7 bits de modo que se puedan transmitir por un canal de comunicación ASCII.
El patrón Decorator proporciona un modo elegante de añadir esas responsabilidades a los flujos. El siguiente diagrama muestra una solución al problema.
La clase abstracta Stream mantiene un buffer interno y proporciona operaciones para almacenar datos en el flujo (PutInt, PutString). Cuando el buffer se ella, Stream llama a la operación abstracta HandleBufferFull, que efectúa la verdadera transferencia de datos. La versión FileStream de esta operación redefine esta operación ara transferir el buffer a un fichero. La clase clave aquí es StreamDecorator, que mantiene una referencia a un componente de flujo y reenvía las peticiones hacia él. Las subclases de StreamDecorator redefinen HandleBufferFull y realizan acciones adicionales antes de invocar la operación HandleBufferFull del StreamDecorator.
Buenas prácticas en desarrollo de software con .NET
Por ejemplo, la subclase CompressingStream comprime los datos, y ASCII7Stream convierte los datos en ASCII de 7 bits. Ahora, para crear un FileStream que comprime sus datos y convierte los datos binarios comprimidos a ASCII de 7 bits, decoramos un FleStream con un CompressingStream y un ASCII7Stream: Stream* aStream = new CompressingStream( new ASCII7Stream( new FileStream("aFileName") ) ); aStream->PutInt(12); aStream->PutString("aString");
Patrones relacionados Adapter (139): Un decorador es diferente de un adaptador en que un decorador sólo cambia las responsabilidad de un objeto, no su interfaz; un adaptador dará a un objeto una interfaz completamente nueva. Composite (163): Un decorador puede verse como un compuesto degenerado con un único componente. Sin embargo, un decorador añade responsabilidades adicionales y su cometido no es la agregación de objetos. Strategy (315): Un decorador permite cambiar la piel de un objeto; una estrategia permite cambiarle las entrañas. Son dos formas alternativas de cambiar un objeto.
39
Buenas prácticas en desarrollo de software con .NET
Nosotros no recurriremos a explicaciones tan detalladas de los patrones de diseño que presentaremos (y que serán, además, unos pocos), pero sí presentaremos una descripción de su finalidad y, posiblemente, un gráfico UML que describa los elementos que forman parte de una implementación genérica del patrón y sus interrelaciones. En el caso del patrón Decorator, este es el diagrama:
Nótese que Component y Decorator son clases que implementan la misma interfaz IComponent (El triángulo indica herencia cuando va seguido de una línea continua) o implementación (si la línea es discontinua, como en la figura). La clase Decorator contiene un atributo privado (los atributos van en la primera zona del cuadro de clase y si son privados, llevan un “-“) con el objeto decorado y define la operación a la que obliga la interfaz (los métodos se marcan con paréntesis y, si son públicos, van precedidos por un “+”). La nota (rectángulo con esquina doblada) aclara que esta operación llama a la operación homónima del objeto decorado. El objeto Client es un objeto que puede tener (el rombo indica posesión) instancias de Component o de Decorator. En cualquier caso, las percibe como instancias de clases que implementan IComponent. 4.2.3 Un ejemplo de Decorator en “el mundo real”: la librería de flujos de entrada/salida Ya hemos visto en la “ficha” del libro de los cuatro que el patrón se usa en el diseño de librerías de flujos de datos para entrada/salida. También se usa en la librería estándar .NET. Vamos este cuadro de la arquitectura de flujos de datos en .NET (adaptada del libro “C# 4.0 in a Nutshell”, de Joseph Albahari y Ben Albahari): Stream adapters
Decorator streams
Backing store streams
StreamReader Text
Int, float, string...
FileStream StreamWriter
DeflateSream
BinaryReader
GZipStream
BinaryWriter
CryptoStream
XmlReader
BufferedStream
XML data
MemoryStream
NetworkStream XmlWriter
Raw bytes
IsolatedStorageStream
40
Buenas prácticas en desarrollo de software con .NET
Al leer o escribir datos elegimos:
Un flujo (y sólo uno) que indica la “fuente física” de almacenamiento: o FileStream: fichero, o IsolatedStorageStream: zona de almacenamiento aislado, o MemoryStream: memoria, o NetworkStream: red, Uno o más decoradores que añaden funcionalidad: o Compresión con protocolo especial en cabecera y cola: GzipStream, o Compresión: DeflateStream, o Con encriptación: CryptoStream, o Con buffer: BufferedStream. Un adaptador (y sólo uno) que ofrece un perfil particular según el tipo de datos que deseamos leer/escribir: o Text: StreamReader o StreamWriter. o Datos binarios: BinaryReader o BinaryWriter. o Datos en formato XML: XmlReader o XmlWriter.
(Por cierto hay un patrón de diseño denominado Adapter (adaptador) que veremos más adelante. Los adaptadores se ajustan a este diseño.) Supongamos que deseamos leer la primera línea de un fichero de texto, sin más. El código presentará este aspecto: using(Stream s = new FileStream("texto.txt", FileMode.Open)) using (TextReader tr = new StreamReader(s)) { string line = tr.ReadLine(); Console.WriteLine(line); }
El StreamReader recibe un Stream con los datos en binario y se encarga de interpretarlos como texto. Supongamos ahora que los datos están comprimidos en el fichero. Basta con decorar apropiadamente el FileStream: using(Stream s = new FileStream("texto.txt", FileMode.Open)) using(Stream sc = new GZipStream(s, CompressionMode.Decompress)) using (TextReader tr = new StreamReader(sc)) { string line = tr.ReadLine(); Console.WriteLine(line); }
¿Y si, además, queremos que la lectura de datos sea eficiente y haga uso de un buffer?: using(Stream s = new FileStream("texto.txt", FileMode.Open)) using(Stream sb = new BufferedStream(s, 8192)) using(Stream sc = new GZipStream(sb, CompressionMode.Decompress)) using (TextReader tr = new StreamReader(sc)) { string line = tr.ReadLine(); Console.WriteLine(line); }
41
Buenas prácticas en desarrollo de software con .NET
Nótese que los decoradores reciben un Stream en el constructor y ellos mismos son objetos de la clase Stream. Eso es lo que permite anidarlos con tanta sencillez. La librería de entrada/salida es relativamente compleja. Saber que ciertos objetos son decoradores ayuda a entender su papel en el sistema y el modo en que deben usarse. 4.2.4 Ejercicio Queremos hacer diseños de “ASCII Art” y tenemos una colección de clases extensibles. Esas clases siempre tienen un método Dibuja, sin parámetros, que proporciona una cadena con un dibujo ASCII. Los objetos de la clase Murciélago, por ejemplo, devuelve esta cadena cuando se invoca el método Dibuja: “·)·\·····/·( )_··\_V_/··_( ··)__···__( ·····`-'”
Nota: Los blancos los hemos representado con “·” para que se vean y la cadena tiene algo de formato y color que no forma parte de la salida. Y la clase Rana devuelve esto al llamar a Dibuja: “······/··\·/··\ ·····|·@)·|·@)·| ····/···········\ ····\·\____··__// ·····\_····||··/ ___···/····||··\··___ \···\|·····()···|/··/ ·\···|··········|··/ ··\···\··\··/··/··/ ··/···/···\/···\··\ ··UUU··UUU··UUU·UUU”
Podemos embellecer cualquier colección de formas básicas con una nueva clase que se llama Marco y que también dispone de un método Dibuja. Su usamos la clase Marco con un Murciélago, el resultado es éste “+++++++++++++++ +·)·\·····/·(·+ +)_··\_V_/··_(+ +··)__···__(·+ +······`-'····+ +++++++++++++++”
Como se puede ver, el resultado es un marco formado por el carácter “+” sobre el dibujo original. Lo curioso es que podemos añadir un marco a un objeto Marco. En el caso del murciélago con marco, el resultado es este: “+++++++++++++++++ +++++++++++++++++ ++·)·\·····/·(·++ ++)_··\_V_/··_(++ ++··)__···__(·++ ++······`-'····++ +++++++++++++++++ +++++++++++++++++”
Implementa el sistema haciendo uso del patrón Decorator.
42
Buenas prácticas en desarrollo de software con .NET
4.3 El patrón Adapter Ya que hemos visto que la librería de entrada/salida hace uso de otro patrón de diseño, el adaptador o Adapter, vamos a estudiarlo brevemente. Un Adapter permite usar objetos de una clase cuya interfaz no se ajusta a los requerimientos de uso de nuestra aplicación o librería. Se usa frecuentemente cuando se nos proporciona una librería con clases ya definidas y que no podemos modificar, pero que hemos de usar invocando métodos que no implementan directamente o que presentan variaciones con respecto a los que sí implementan (en número de parámetros, en comportamiento, etc.). Este diagrama UML presenta los elementos que conforman un Adapter y nos permite completar su presentación: Client
« interface » ITarget
Adaptee
+Request()
+SpecificRequest()
Adapter +Request()
Invoca a SpecificRequest
La interfaz ITarget describe las operaciones que nos gustaría encontrar en la clase que no podemos modificar. En este diagrama se ejemplifican con un solo método: Request(). La clase que debe ser adaptada es la que aparece como Adaptee. Efectivamente, no implementa un método Request, sino uno propio llamado SpecificRequest. La clase Adapter implementa la interfaz ITarget y posee una instancia de Adaptee. Su objetivo es ofrecer una implementación de Request que gestione del modo apropiado la correspondiente llamada a Adapter. Se podría pensar que es un patrón muy similar al Decorator, pues hay una composición de objetos y un reenvío de llamadas. Pero se trata de un patrón realmente diferente: el objeto adaptado no implementa la interfaz ITarget y no es posible anidar objetos adaptados como sí hacíamos con los decorados. El patrón encuentra una aplicación evidente en la adaptación de código “legacy”, pero no solo. Hemos visto que la librería de entrada/salida ofrece adaptadores para que los Stream (decorados o no) presenten conjuntos de operaciones especializadas que hagan más cómodo su uso en función del tipo de datos que gestionan. Por un principio de parsimonia, cuando sólo hay una clase que implementa la interfaz ITarget, esta interfaz no tiene por qué existir como tal. Un programador acostumbrado al trabajo con patrones de diseño captará inmediatamente las diferencias entre uno y otro cuando se aproxime a la solución de un problema y sabrá qué patrón es el indicado. Y cuando haya de comunicarlo al resto del equipo, podrá ser muy conciso.
43
Buenas prácticas en desarrollo de software con .NET
Nota: el Adapter que hemos presentado es el denominado “adaptador de objetos”. En la literatura se presenta otro denominado “adaptador de clases”. No lo presentamos aquí.
4.4 Abstract Factory Los objetos se crean a partir de la definición de clase mediante sus constructores. Un constructor es un método especial, aunque solo sea en el sentido de que no contiene una sentencia return, pero siempre devuelve algo (o arroja una excepción si hay un problema durante la ejecución): una instancia de esa clase. Imaginemos una interfaz IStack que ofrece un perfil para implementaciones de una pila. Como sabrá, una pila es una estructura de datos a la que podemos añadir elementos y extraerlos en orden inverso al de ingreso, además de consultar el último elemento añadido. Esta interfaz recoge las funciones Push (para añadir) y Pop (para extraer) y la propiedad de sólo lectura Top (para consultar el último elemento metido en la pila). Aparece, además, una propiedad IsEmpty que permite saber si la pila está vacía. public interface IStack { void Push(T item); T Pop(); T Top { get; } bool IsEmpty { get; } }
Hay varias implementaciones posibles de una pila. Una usa un simple vector: public class ArrayStack : IStack { private T[] _data; private int _count; public ArrayStack(int capacity) { _data = new T[capacity]; _count = 0; } public void Push(T item) { if (_count == _data.Length) throw new Exception("The stack has exceeded its capacity"); _data[_count++] = item; } public T Pop() { if (_count == 0) throw new Exception("The stack is empty"); T result = _data[_count]; _count--; _data[_count] = default(T); return result; } public T Top { get { if (_count == 0) throw new Exception("The stack is empty"); return _data[_count - 1]; }
44
Buenas prácticas en desarrollo de software con .NET
} public bool IsEmpty { get { return _count == 0; } } }
Esta pila presenta dos problemas:
Si se trata de insertar más elementos que celdas tiene el vector _data, se lanza una excepción. Si sobredimensionamos la capacidad de la pila para evitar el problema que acabamos de apuntar, podemos desperdiciar una cantidad de memoria considerable.
Una implementación alternativa se apoya en listas enlazadas: public class LinkedStack : IStack { private class Node { public T Item; public Node Next; public Node(T item, Node next) { Item = item; Next = next; } } private Node _list; public LinkedStack() { _list = null; } public void Push(T item) { _list = new Node(item, _list); } public T Pop() { if (_list == null) throw new Exception("The stack is empty"); var result = _list; _list = _list.Next; return result.Item; } public T Top { get { if (_list == null) throw new Exception("The stack is empty"); return _list.Item; } } public bool IsEmpty { get { return _list != null; }
45
Buenas prácticas en desarrollo de software con .NET
} }
Esta implementación no presenta los problemas de la pila basada en un vector, pero consume más memoria por cada elemento insertado en la pila, pues crea un objeto de la clase Node y este objeto almacena, además del elemento que se añade a la pila, un puntero a otro Node (o a null). Habrá, pues, contextos en los que es más apropiado recurrir a una pila basada en un vector y contexto en los que convendrá usar la pila basada en la lista enlazada (por no hablar de contextos en los que puede convenir hacer uso de pilas implementadas de formas diferentes de las dos que hemos presentado). Imaginemos ahora un método que necesita una pila de enteros para efectuar una actividad: un método que invierte el contenido de un IEnumerable. La pila en si puede ser una variable declarada como de tipo IStack. Si la pila va a albergar un número máximo de elementos inferior o igual a cierta cantidad, digamos que 100, convendrá montar la pila como una instancia de ArrayStack. Si, por el contrario, el número máximo de elementos excede de 100 o somos incapaces de determinar ese número máximo, convendrá usar una instancia de LinkedStack. Este es el código del método: public static class Reverser { public static IEnumerable Reverse(IEnumerable sequence) { IStack stack; long count = long.MaxValue; var collection = sequence as ICollection; if (collection != null) count = Math.Min(count, collection.Count); if (count <= 100) { stack = new ArrayStack((int)count); } else { stack = new LinkedStack(); } foreach (var item in sequence) { stack.Push(item); } while (!stack.IsEmpty) { yield return stack.Pop(); } } }
Fijémonos en el fragmento destacado en amarillo: es la selección de la clase que queremos instanciar para implementar la pila. Imaginemos ahora que no disponemos de dos implementaciones posibles, sino de tres o cuatro. Tendremos que modificar ese fragmento de código si deseamos que se tengan en cuenta las alternativas. El problema es aún más serio si consideramos que esa misma toma de decisión puede reproducirse en muchos lugares de nuestro código: en todos aquellos puntos en los que necesitemos escoger la mejor implementación posible para la pila.
46
Buenas prácticas en desarrollo de software con .NET
La solución pasa por crear un método especial que encierre la lógica de la toma de decisión y devuelva una instancia de la clase más apropiada: public static class Stacks { public static IStack Create(int capacity) { IStack result; if (capacity <= 100) { result = new ArrayStack((int)capacity); } else { result = new LinkedStack(); } return result; } }
El método Stacks.Create es fábrica de instancias de objetos que implementan la interfaz IStack: es un método factoría (Factory Method). El modo de uso es sencillo: public static class Reverser { public static IEnumerable Reverse(IEnumerable sequence) { long count = long.MaxValue; var collection = sequence as ICollection; if (collection != null) count = Math.Min(count, collection.Count); IStack stack = Stacks.Create((int)count); foreach (var item in sequence) { stack.Push(item); } while (!stack.IsEmpty) { yield return stack.Pop(); } } }
Nuestro código presenta una menor dependencia con respecto a las clases concretas que implementan IStack, lo que sigue el principio de diseño DIP.
4.5 Singleton En matemáticas, un singleton es un conjunto con un solo elemento. El término tiene difícil traducción al español con un solo vocablo, así que usaremos el término inglés. Un Singleton, en programación orientada a objetos, es una clase de la que sólo es posible instanciar un objeto. Ese es su aspecto fundamental, aunque es frecuente que, además, el Singleton se instancie perezosamente, es decir, sólo cuando es estrictamente necesario hacer uso de él.
47
Buenas prácticas en desarrollo de software con .NET
Imaginemos un sistema de registro de eventos que nos ayude a detectar la aparición de fallos en ejecución o a mostrar o almacenar información sobre acontecimientos relevantes de un programa en marcha. El Logger es un objeto más o menos complejo que se puede configurar indicando qué tipo de eventos registra o muestra y el dispositivo o dispositivos en el que se muestra/registra la información relativa a ellos. Lógicamente, desde cualquier punto de un programa querremos usar la misma instancia del Logger, así que el Logger puede/debe diseñarse como Singleton. Hagamos una primera versión de Logger que muestra los mensajes por consola y que no aplica filtro alguno a dichos mensajes (y que no es un Singleton): public enum LogLevel { Info, Warn, Error } ; public class SimpleLogger { public void Log(LogLevel level, string message) { Console.WriteLine("{0}: {1}", level.ToString().ToUpper(), message); } }
Hemos definido tres niveles de eventos y una función Log que muestra por consola el nivel y un mensaje. Podemos, por disciplina, instanciar una única instancia de SimpleLogger, pero nadie nos impide construir más. Por otra parte, y aunque construyamos una sola, ¿cómo la hacemos llegar a los puntos del código que hacen uso de ella? Una posibilidad es recurriendo a una variable global. Otra, pasando la instancia de unos métodos a otros en todos aquellos que puedan construir un objeto que necesite un Logger directa o indirectamente. (Y más tarde veremos un tercer modo mediante la inyección de dependencias.) Esta nueva versión resuelve dos problemas: bloque la posibilidad de crear más de un instancia y crea un punto único de acceso a la única instancia de SimpleLogger: public class SimpleLogger { private static readonly SimpleLogger instance = new SimpleLogger(); private SimpleLogger() { } public static SimpleLogger Instance { get { return instance; }
El único constructor se ha declarado privado para asegurarnos de que nadie puede invocarlo desde fuera de la clase. El campo estático instance mantiene la única instancia de SimpleLogger y quien quiera usarla debe hacerlo a través de la propiedad SimpleLogger.Instance. La instancia de construye en algún momento previo a su uso, pero no tenemos mucho control acerca de cuándo. En este ejemplo no es algo muy preocupante, pues el objeto SimpleLogger es muy ligero. No siempre será el caso. ¿Qué estrategia seguimos si queremos que sólo se instancie en el momento en el que va a ser usado por primera vez? Es muy sencillo si complicamos ligeramente la propiedad Instance:
48
Buenas prácticas en desarrollo de software con .NET
public class SimpleLogger { private static SimpleLogger instance; private public SimpleLogger() { } public static SimpleLogger Instance { get { if (instance == null) instance = new SimpleLogger(); return instance; } } public void Log(LogLevel level, string message) { Console.WriteLine("{0}: {1}", level.ToString().ToUpper(), message); } }
Si nuestro código ha de ejecutarse en una aplicación multihilo, hemos de tener precaución a la hora de crear la instancia: public class SimpleLogger { private static volatile SimpleLogger instance; private static object syncRoot = new Object(); private public SimpleLogger() { } public static SimpleLogger Instance { get { if (instance == null) lock(syncRoot) { if (instance == null) instance = new SimpleLogger(); } return instance; } } public void Log(LogLevel level, string message) { Console.WriteLine("{0}: {1}", level.ToString().ToUpper(), message); } }
C# 4.0 nos permite simplificar el código de creación de objetos construidos perezosamente. Para ello introdujo el tipo genérico Lazy. La clase anterior se reescribiría como sigue y mantendría el mismo nivel de seguridad: public class SimpleLogger { private static Lazy instance = new Lazy();
49
Buenas prácticas en desarrollo de software con .NET
private public SimpleLogger() { } public static SimpleLogger Instance { get { return instance.Value; } } public void Log(LogLevel level, string message) { Console.WriteLine("{0}: {1}", level.ToString().ToUpper(), message); } }
Referimos al lector a la documentación estándar para conocer con más detalle el modo de uso del tipo Lazy. Con cualquiera de las tres últimas clases que hemos definido como posibles implementaciones, ¿cómo hacemos para que otros objetos accedan a nuestra única instancia de SimpleLogger? Podemos definir en ellos un campo SimpleLogger al que asignamos SimpleLogger.Instance en el momento de la construcción. Esto presenta varios inconvenientes:
Creamos una dependencia con respecto de clases concretas, no de abstracciones. Si más adelante cambiamos de clase para registro de eventos, tendremos que tocar muchos puntos del código. La construcción del objeto dependiente de SimpleLogger comportará la creación inmediata de la instancia, con lo que no habremos ganado gran cosa haciendo Estamos accediendo a un contexto con una especie de variable global, lo que crea una dependencia que deberíamos evitar en la medida de lo posible.
De todos estos problemas nos encargaremos más adelante, aunque ya podemos indicar por dónde irán las soluciones:
Por usar una interfaz que separe la abstracción (“¿qué es un Logger?”) de su implementación (“SimpleLogger es un Logger concreto”). Usar librerías que faciliten la inyección de dependencias, es decir, que permitan que los objetos dependientes reciban aquello que necesitan sin solicitarlo explícitamente y controlar el ciclo de vida delos objetos desde esas librerías.
4.6 Observer Los objetos se comunican entre sí de formas diferentes. Ciertos objetos deben informar a otros de que han ocurrido ciertos eventos y otros necesitan informarse de cuándo ocurren esos eventos para reaccionar del modo que consideren oportuno. Los objetos del primer tipo se conocen como “observables” y los del segundo tipo como “observadores”. Si deseamos preservar el mayor nivel de desacoplamiento entre unos y otros, tendremos que idear un sistema que permita a unos aceptar “suscripciones” a la notificación de eventos y a otros “suscribirse” a dicha notificación. Es ahí donde entra en juego el patrón Observable/Observer o, simplemente, Observer. El patrón de diseño se puede implementar de diferentes modos, pero veremos que C# ofrece un soporte nativo que simplifica la labor. Empecemos siguiendo una filosofía que no hace uso de las posibilidades características de C#. Este diagrama UML ayudará a entender una implementación clásica del patrón:
50
Buenas prácticas en desarrollo de software con .NET
Los IObserver pueden suscribirse a un Observable a través del método Subscribe (o anular la suscripción con Unsuscribe). El Observable mantiene una lista de objetos que se han suscrito. Cuando ocurre el evento del que debe avisarse a todos observadores, Observable invoca al método Notify(). Este método realiza una llamada a Update sobre cada uno de los observadores suscritos. El patrón de uso frecuentísimo en los sistemas modernos. Raro es el GUI que no recurre a este patrón de diseño. Los creadores de C#, conscientes de la importancia del patrón dieron soporte nativo al mismo con una estructura del lenguaje: los delegados (delegate). Creemos un ejemplo. Una clase que recibe una secuencia de enteros y produce un aviso por cada número par que encuentra. El aviso consistirá en una llamada a cierto método de todos los objetos que deseen ser notificados. Nuestra clase observable podría definirse así: public delegate void EvenNumberDetectedHandler(int number); public class EvenDetector { public EvenNumberDetectedHandler EvenNumberDetected; public void Detect(IEnumerable numbers) { foreach (var number in numbers) { if (number % 2 == 0) { if (EvenNumberDetected != null) { EvenNumberDetected(number); } } } } }
Lo primero que hemos definido es un tipo: EvenNumberDetectedHandler. El tipo define un perfil de función o método. Las clases que deseen ser notificadas del evento tendrán que proporcionar un método con este perfil. La clase EvenDetector contiene un campo de este tipo. El método Detect es sencillo: recibe la secuencia de enteros y la recorre; cuando detecta un número par, llama a EvenNumberDetected con el número como parámetros (sólo si EvenNumberDetected tiene valor no nulo). Para entender del todo el mecanismo, necesitamos crear un observador y conectarlo al observable. Definamos dos clases de observadores diferentes: public class Observer1 { public void AvisoPorConsola(int number) {
51
Buenas prácticas en desarrollo de software con .NET
Console.WriteLine("He visto {0}", number); } } public class Observer2 { public void MuestroUnoMas(int number) { Console.WriteLine("El siguiente de {0} es {1}", number, number+1); } }
Y ahora establezcamos las suscripciones: EvenDetector ed = new EvenDetector(); Observer1 o1 = new Observer1(); Observer2 o2 = new Observer2(); ed.EvenNumberDetected += new EvenNumberDetectedHandler(o1.AvisoPorConsola); ed.EvenNumberDetected += new EvenNumberDetectedHandler(o2.MuestroUnoMas);
Hay una versión más sencilla de la suscripción, en la que el compilador nos echa una mano envolviendo el método con la construcción de un EvenNumberDetectedHandler automáticamente: ed.EvenNumberDetected += o1.AvisoPorConsola; ed.EvenNumberDetected += o2.MuestroUnoMas;
El operador += indica que añadimos a la lista de suscriptores el método que aparece a mano derecha. Dicho método tiene un perfil compatible con el tipo delegate que hemos definido antes. Cuando se active el método Detect de ed, cada aparición de un número par disparará una llamada a los métodos suscritos. Es decir, esta línea: ed.Detect(new [] {1,2,3,4});
provocará la aparición en pantalla de estos mensajes: He El He El
visto 2 siguiente de 2 es 3 visto 4 siguiente de 4 es 5
Nótese que un mismo evento ha sido notificado a varios elementos. Es lo que denominamos “multicasting”. Podemos eliminar una suscripción con el operador -=. Si el objeto o1 no desea recibir más notificaciones, bastará con la sentencia: ed.EvenNumberDetected -= new EvenNumberDetectedHandler(o1.AvisoPorConsola);
o, en su versión simplificada: ed.EvenNumberDetected -= o1.AvisoPorConsola;
Y aquí hay un problema: es posible que cualquier objeto elimine la suscripción de cualquier otro, lo que puede genera problemas de permisos y seguridad en nuestro código. En la plataforma .NET se trata de corregir este problema distinguiendo entre “delegados” y “eventos”. Los eventos son delegados que controlan el proceso de borrado de suscripciones: sólo la clase que gestiona el evento y la añade un método
52
Buenas prácticas en desarrollo de software con .NET
a la lista de suscriptores pueden eliminarlo. Para que nuestro notificador sea un evento basta con usar la palabra reservada event en la declaración del campo correspondiente: public delegate void EvenNumberDetectedHandler(int number); public class EvenDetector { public event EvenNumberDetectedHandler EvenNumberDetected; … }
4.7 Un estilo: Fluent Interface La interfaz fluida no es exactamente un patrón de diseño, sino más bien un estilo de diseño basado en el encadenamiento de métodos (method chaining). Imaginemos una clase que implementa una colección, esto es, un estructura de datos a la que podemos añadir varios elementos con un método Add. Valga esta implementación para ilustrar una técnica convencional: public class Convencional { private IList _store; public Convencional() { _store = new List(); } public void Add(T item) { _store.Add(item); } }
Al usar la clase tendremos añadiendo varios elementos tendremos que usar varias sentencias4: Convencional convencional = new Convencional(); convencional.Add(1); convencional.Add(2); convencional.Add(5); convencional.Add(3); convencional.Add(2);
Una interfaz fluida se basa en que el método devuelve siempre un objeto que nos permite encadenar llamadas a métodos: public class Fluida { private IList _store; public Fluida() { _store = new List(); } public Fluida Add(T item) 4
Hagamos como si no supiésemos que se puede inicializar una estructura que disponga del método Add en el propio proc eso de construcción con una sola sentencia.
53
Buenas prácticas en desarrollo de software con .NET
{ _store.Add(item); return this; } }
Las llamadas ahora se pueden encadenar así: Fluida fluida = new Fluida(); fluida.Add(1).Add(2).Add(5).Add(3).Add(2);
Las interfaces fluidas se han puesto de moda porque permiten implementar DSL (Domain Specific Languages). Los DSL son lenguajes de propósito (muy) específico que facilitan la codificación de información por parte de usuarios avanzados, pero no necesariamente programadores. Podemos imaginar cómo se puede codificar “fluidamente” una sentencia en una aplicación de pedidos de pizza con un DSL: cliente.nuevoPedido().con("pepperoni").con("doble de queso").sin("tomate").para(2);
La semántica de la sentencia es evidente. Codificar las clases para el cliente y el pedido no resulta particularmente difícil.
4.8 Builder El patrón de diseño Builder (“constructor” como traducción se presta a la confusión con el término usado para el método especial que permite instanciar una clase) separa la construcción de un objeto complejo de su representación, de modo que el mismo proceso constructivo pueda crear diferentes representaciones. Es similar al patrón Factory, pero crea un objeto complejo siguiendo una serie de pasos. Es muy habitual que el Builder se desarrolle con un estilo de interfaz fluida. Veamos un ejemplo de construcción de un objeto complejo. Una casa puede tener varias habitaciones, una cocina y cero o más plazas de garaje. Esta definición de casa, que contiene definiciones internas de los componentes citados, contiene también la definición de un Builder: public class Casa { private IList _habitaciones; private Cocina _cocina; private Garaje _garaje; public class Builder { private readonly IList _habitaciones = new List(); private Cocina _cocina; private Garaje _garaje = new Garaje(0); public Builder ConHabitación(Habitación.Tipo tipo, decimal metros) { _habitaciones.Add(new Habitación(tipo, metros)); return this; } public Builder ConCocina(Cocina.Tipo tipo, decimal metros) { if (_cocina != null) {
54
Buenas prácticas en desarrollo de software con .NET
throw new ArgumentException("No puede tener más de una cocina"); } _cocina = new Cocina(tipo, metros); return this; } public Builder ConPlazasDeGaraje(int cantidad) { _garaje.Plazas += cantidad; return this; } public Casa Construye() { if (_cocina == null) { throw new ArgumentException("Es imposible que no haya cocina"); } return new Casa {_habitaciones = _habitaciones, _cocina = _cocina, _garaje = _garaje }; } } public class Habitación { public enum Tipo { Dormitorio, Salón, Comedor, SalaDeEstar, Otro } ; private Tipo _tipo; private decimal _metros; public Habitación(Tipo tipo, decimal metros) { _tipo = tipo; _metros = metros; } public override string ToString() { return string.Format("habitación {0} ({1} m2)", _tipo, _metros); } } public class Cocina { public enum Tipo { Completa, Office, ConIslaCentral } ; private Tipo _tipo; private decimal _metros; public Cocina(Tipo tipo, decimal metros) { _tipo = tipo; _metros = metros; } public override string ToString() { return string.Format("cocina {0} ({1} m2)", _tipo, _metros); } }
55
Buenas prácticas en desarrollo de software con .NET
public class Garaje { public int Plazas { get; internal set; } public Garaje(int plazas) { Plazas = plazas; } public override string ToString() { if (Plazas > 0) { return string.Format("{0} plazas de garaje", Plazas); } else { return string.Empty; } } } public override string ToString() { var s = new StringBuilder("Casa con "); foreach (var habitación in _habitaciones) { s.Append(habitación.ToString() + ", "); } s.Append(_cocina.ToString()); if (_garaje.Plazas > 0) { s.Append("y " + _garaje.ToString()); } return s.ToString(); } }
Podemos construir una casa así: Casa c = new Casa.Builder().ConHabitación(Casa.Habitación.Tipo.Dormitorio, 12M) .ConHabitación(Casa.Habitación.Tipo.Comedor, 20M) .ConCocina(Casa.Cocina.Tipo.ConIslaCentral, 12) .ConPlazasDeGaraje(2) .Construye();
Por cierto: si analizamos el método ToString de Casa veremos que se apoya en un Builder de cadenas: StringBuilder. Un StringBuilder es un objeto que viene predefinido en la librería estándar y sigue el patrón Builder (y es, además, un ejemplo de interfaz fluida, pues se pueden encadenar llamadas así: s.Append("Un").Append(" ").Append("ejemplo");
4.9 Unas reflexiones finales Acabamos, pues con unas reflexiones:
Los patrones de diseño proponen técnicas para resolver problemas frecuentes, pero estas técnicas no son corsés rígidos o construcciones tan específicas que puedan suministrarse como componentes de una librería: el desarrollador debe interpretar la técnica y aplicarla a su problema con un diseño particularizado a partir de las directrices fijadas por el patrón.
56
Buenas prácticas en desarrollo de software con .NET
Si bien los patrones de diseño no dependen de lenguajes de programación concretos, ciertos patrones de diseño se soportan mejor en ciertos lenguajes de programación (por ejemplo, el patrón Observer es particularmente sencillo de implementar en C#). Por ejemplo, los Decorator se apoyan en interfaces y encontramos interfaces en C# (y Java), pero en lenguajes como C++ hemos de recurrir a clases abstractas y en Python no hay interfaces ni es frecuente trabajar con clases abstractas puras (pese a lo cual sigue siendo posible implementar el patrón Decorator). Esto es así porque el diseño de esos lenguajes de programación ofrecen constructos ideados para dar ese soporte o porque las características dinámicas de algunos lenguajes simplifican su implementación. Los desarrolladores primerizos en el uso de los patrones de diseño tienden a un sobreutilizarlos y plagan el código de diferentes patrones. Se debe ser consciente de que un abuso de los patrones no conduce a un código mejor. Su uso debe limitarse a aquello en lo que mejoran claramente el código: no son una panacea. Pero no conviene preocuparse en exceso por esta cuestión: el tiempo pone las cosas en su sitio. Sin una experiencia razonable en programación, los patrones de diseño parecen invenciones más o menos extravagantes. Sólo se perciben como soluciones efectivas cuando uno se ha planteado ya el problema que resuelven. Un mismo patrón de diseño puede resolver uno o varios problemas que resultan difíciles de abordar con las herramientas convencionales. Los patrones pueden aparecer en la literatura con diferentes nombres (el Decorator también se conoce por Wrapper), aunque un objetivo de las recopilaciones de patrones es, precisamente, establecer una terminología estandarizada que facilite la comunicación. Muchos patrones están interrelacionados y la explicación de uno puede comportar referencias a muchos otros. Esto dificulta un aprendizaje puramente secuencial de los patrones de diseño. Cuanto más se sabe de los patrones de diseño, más fácil es saber más de ellos. Conocer los patrones de diseño ayuda a entender el código escrito por otros. Si vemos que se hace uso de un Decorator para modelar algo, podemos centrarnos en ese algo y no en la infraestructura que corresponde al patrón en sí.
4.10 Créditos y recursos
El libro “A Pattern Language. Towns, Buildings, Construction” se puede adquirir a travñes de la página web de Christopher Alexander: http://www.patternlanguage.com/leveltwo/books.htm. El artículo “Using Pattern Languages for Object-Oriented Programs” de Kent Beck y Ward Cunningham está disponible en http://c2.com/doc/oopsla87.html. El Portland Pattern Repository se encuentra en http://c2.com/ppr/. El libro de la banda de los cuatro se puede adquirir en http://www.amazon.com/Design-PatternsElements-Reusable-Object-Oriented/dp/0201633612. La segunda edición de “Code Complete”, de Steve McConnel se puede comprar en http://www.amazon.com/Code-Complete-Second-Steve-McConnell/dp/0735619670/sr=11/qid=1169499581?ie=UTF8&s=books. El libro “Holub on Patterns”, de Allen Holub, está a la venta en http://www.holub.com/payment/holub.on.patterns.html. “Head First Design Patterns” se puede adquirir en http://oreilly.com/catalog/9780596007126. “C# 3.0 Design Patterns”, de Judith Bishop, se vende en http://oreilly.com/catalog/9780596527730.
57
Buenas prácticas en desarrollo de software con .NET
El ASCII art del ejercicio del patrón de diseño Decorator se ha extraído de http://www.chris.com/ascii.
5 Reflexión Lenguajes como Java, C#, Ruby o Python tienen un rasgo en el que conviene detenerse: son lenguajes dotados de introspección, esto es, la capacidad de examinar las características de los objetos en tiempo de ejecución. Podemos, por ejemplo, conocer de qué clase es instancia un objeto inquiriendo apropiadamente al mismo objeto, o saber si dispone de un método determinado, o conocer la lista de sus métodos, propiedades, etc. En C# se usa el término reflexión para referirse a la introspección. Los datos que obtenemos por reflexión se conocen por metadatos, pues son datos que describen a otros datos. Muchas herramientas basan su “magia” en un uso apropiado de la introspección, así que conviene conocer lo básico de esta técnica para entender cómo resuelven ciertos problemas. En .NET podemos ejecutar acciones de reflexión sobre ensamblados, módulos y tipos. Estas acciones pueden consistir en simples consultas o ir más allá y crear instancias de una clase dinámicamente. Las utilidades a las que recurriremos se encuentran en el espacio de nombres System.Reflection. Las aplicaciones .NET se ejecutan en la CLR (Common Language Runtime), un sistema que gestiona dominios de aplicación (application domains). Un dominio de aplicación es un sistema aislado en el que se cargan ensamblados (assemblies). El dominio de aplicación facilita la gestión de la seguridad en el sistema imponiendo limitaciones a lo que puede hacer el código que contiene (acceso a recursos, comunicación con otras aplicaciones, etc.). Los ensamblados son los bloques básicos de las aplicaciones .NET. Son colecciones de tipos y recursos que forman una unidad funcional a efectos de versionado, distribución, reutilización, etc. Hay un nivel de agrupación de elementos: el módulo (module), que se corresponde con una unidad de compilación. Los módulos contienen las definiciones de tipos (clases o tipos valor) y estos, a su vez, contienen código MSIL. El compilador no se limita a almacenar el código MSIL de esos elementos: a cada ítem le asocia los metadatos que podremos consultar en tiempo de ejecución. 5.1.1 GetType y typeof Todos los objetos .NET ofrecen un método GetType() que devuelve un dato de tipo System.Type identificando el tipo al que pertenece el objeto. Los tipos en sí son valores de la clase System.Type. Básicamente hace lo mismo que el operador typeof sobre el identificador de un tipo, pero con instancias suyas. Veamos un ejemplo de uso: namespace Reflexion1 { class UnaClase { } class Program { static void Main(string[] args) { Console.WriteLine(3.GetType()); Console.WriteLine((3.1).GetType()); var uc = new UnaClase(); Console.WriteLine(uc.GetType()); Console.ReadKey(); }
58
Buenas prácticas en desarrollo de software con .NET
} }
Al ejecutar, tenemos esto en pantalla: System.Int32 System.Double Reflexion1.UnaClase
Para obtener el tipo de una clase hemos de recurrir al operador typeof. Es decir, no podemos ejecutar una sentencia como ésta (que da error al compilar): System.Type t = UnaClase; // Mal
En lugar de eso, hemos de escribir: System.Type t = typeof(Casa);
5.1.2 GetMethods, GetMembers La clase System.Type dispone de métodos introspectivos, como GetMethods o GetMembers. El primero proporciona un vector con todos los métodos de una clase y el segundo, una lista con todos los miembros. Un ejemplo ayuda a entender lo que hacen bastante más que empezar a entrar en una explicación con un excesivo nivel de detalle: using System; using System.Reflection; namespace Reflexion2 { class UnaClase { private string _saludo = "Hi"; public string Saludo { set { _saludo = value.Trim(); } get { return _saludo; } } public void Saluda(string nombre) { Console.WriteLine("{0}, {1}", Saludo, nombre); } public void Despide() { Console.WriteLine("Bye"); } } class Program { static void Main(string[] args) { MethodInfo[] methodInfoArray = typeof(UnaClase).GetMethods(); foreach (var methodInfo in methodInfoArray) { Console.WriteLine(methodInfo.Name); foreach (var parameterInfo in methodInfo.GetParameters()) {
59
Buenas prácticas en desarrollo de software con .NET
Los objetos que devuelve GetMethods() o GetMembers() son de ciertos tipos definidos en System.Reflection. No es necesario que entremos en el detalle de todos los (meta)datos que contienen, pero el ejemplo presentado permite captar razonablemente bien de qué va esto de la introspección y la potencia que podemos llegar a tener si somos capaces de averiguar tanta información sobre objetos en tiempo de ejecución. Hay muchos otros métodos capaces de extraer información sobre un tipo, como GetProperties, GetInterfaces, GetEvents, etc. 5.1.3 InvokeMember Otros métodos permiten obtener el valor de un campo o invocar un método a partir de la cadena que corresponde a su identificador: class Program { static void Main(string[] args) { UnaClase uc = new UnaClase(); Type t = uc.GetType(); t.InvokeMember("Saluda", BindingFlags.InvokeMethod, null, uc, new object[] {"tú"});
60
Buenas prácticas en desarrollo de software con .NET
La salida del programa es este texto por consola: Hi, tú Bye Hola
5.2 Creación dinámica de objetos En el espacio de nombres System encontramos una factoría de objetos con la que podemos instanciar objetos de cualquier clase, tanto local como remotamente. Basta con conocer el tipo de la clase para poder construir una instancia con su constructor por defecto. using System; namespace Activador { class MiClase { public string Id { get; private set; } public MiClase() { Id = "Ninguno"; } public MiClase(string nombre, string apellido) { Id = nombre + " " + apellido; } public MiClase(int código) { Id = código.ToString(); } } class Program { static void Main(string[] args) { var mi1 = (MiClase) Activator.CreateInstance(); var mi2 = (MiClase) Activator.CreateInstance(typeof(MiClase)); var mi3 = (MiClase) Activator.CreateInstance(mi2.GetType()); var mi4 = (MiClase) Activator.CreateInstance(Type.GetType("Activador.MiClase")); Console.WriteLine("{0}, {1}, {2}, {3}.", mi1.Id, mi2.Id, mi3.Id, mi4.Id); Console.ReadKey(); } } }
Ejecutar el programa produce este resultado en pantalla: Ninguno, Ninguno, Ninguno, Ninguno.
Las cuatro veces hemos invocado el constructor por defecto, de ahí que siempre se asigne la cadena "Ninguno" a la propiedad Id de cada objeto.
61
Buenas prácticas en desarrollo de software con .NET
También podemos usar constructores con parámetros aunque, naturalmente, hemos de conocer el tipo de cada uno de estos o provocaremos una excepción. class Program { static void Main(string[] args) { var mi1 = (MiClase) Activator.CreateInstance(typeof(MiClase), new object[] {"Pepe", "Pérez"}); var mi2 = (MiClase) Activator.CreateInstance(typeof(MiClase), new object[] { 1024 }); Console.WriteLine("{0}, {1}.", mi1.Id, mi2.Id); Console.ReadKey(); } }
Al ejecutar, leemos en pantalla: Pepe Pérez, 1024.
Algunas de las utilidades que manejaremos en el curso usan esta forma de instanciación, que puede crear objetos a partir de una simple referencia a su tipo.
6 Atributos Como hemos visto, todo objeto presenta cierta información que podemos calificar de “estándar” o “de serie” y sobre la que podemos demandar detalle: métodos, campos, atributos, etc. La plataforma .NET permite que añadamos metadatos propios y que averigüemos después, por reflexión, si una clase u objeto lleva asociados esos metadatos. La información que añadimos se especificará con ciertas marcas (entre corchetes) que precederán al elemento enriquecido (por ejemplo, a la línea con la que empieza la definición de una clase). Los metadatos y, por extensión las marcas con las que los expresamos, reciben el nombre de “atributos”. Los atributos facilitan el uso de estilos declarativos al programar, pues marcan las clases, métodos, campos, etc. con información que pueden explotar diferentes herramientas, en tiempo de compilación o de ejecución. No sólo .NET dispone de la posibilidad de marcar unidades con atributos. En Java, por ejemplo, se conoce por “anotaciones” a los atributos. En lenguajes como Python se dispone de “decoradores” (lo cual crea cierta confusión con el patrón de diseño que ya hemos estudiado), aunque van más allá de los atributos dada la naturaleza mucho más dinámica de ese lenguaje. Aprenderemos a crear nuestros propios atributos no tanto porque vayamos a hacer uso directo de esta posibilidad (aunque es una técnica a considerar para ciertas aplicaciones), como porque las herramientas que usaremos sí hacen un uso extensivo de ellas.
6.1 Uso de atributos El siguiente código contiene algo especial: un método marcado con un atributo de la clase Conditional. El atributo se expresa justo antes de la definición del objeto al que marca, en este caso, un método: using System; using System.Diagnostics; namespace Atributos1 { class UnaClase
62
Buenas prácticas en desarrollo de software con .NET
{ [Conditional("DEBUG")] public static void Avisa() { Console.WriteLine("El aviso"); } } class Program { static void Main(string[] args) { Console.WriteLine("Voy"); UnaClase.Avisa(); Console.ReadKey(); } } }
El método estático Avisa de UnaClase está marcado con Condicional(cadena). Es un atributo predefinido, es decir, viene de serie como utilidad para el programador. Este atributo se interpreta como que el método en cuestión sólo debe compilarse (de hecho, existir) si está definido el pragma que expresamos con la cadena; en el ejemplo, sólo si estamos compilando en modo DEBUG. Ejecutemos el programa con la configuración DEBUG y tendremos este resultado en pantalla: Voy El aviso
Pasemos a RELEASE y veremos cómo la salida pasa a ser Voy
En este segundo caso, el método Avisa no se invoca. El compilador ha tenido en cuenta el atributo del método a la hora de generar o no código para la llamada al método. Hay muchos más atributos predefinidos y presentan los usos más variopintos. Veamos unos pocos:
El atributo DllImport permite importar código de una DLL: [DllImport("cards.dll")] public static extern bool cdtInit (ref int width,ref int height);
El atributo Flags indica que una enumeración debe tratarse como un campo de bits. [Flags] public enum FontProperties { Bold = 1, Italic = 2, Underlined = 4, None = 8 }
Con Flags estamos indicando que los diferentes valores pueden mezclarse, como aquí: FontProperties f = FontProperties.Bold | FontProperties.Italic;
El atributo Obsolete permite marcar código como obsoleto y, por tanto, declararlo no utilizable. Al compilar, aparece un aviso (warning) si se hace uso de una clase obsoleta (y el IDE puede marcar el uso de algún modo que permita visualizar esos usos durante la edición del código).
63
Buenas prácticas en desarrollo de software con .NET
[Obsolete] class UnaClase { public static void Avisa() { Console.WriteLine("El aviso"); } }
El atributo Obsolete puede invocarse opcionalmente con parámetros. Si, por ejemplo, deseamos que el aviso incluya un texto nuestro, podemos suministrarlo con un parámetro de tipo string: [Obsolete("No uses este método.")]. Un segundo parámetro de tipo bool permite indicar si queremos que el uso de la clase sea considerado un error de compilador, y no un simple aviso. Así, [Obsolete("Prohibido", true)] impide el uso de la clase.
6.2 Definición de atributos de usuario Ya hemos visto cómo usar atributos en diferentes elementos (clases, tipos enumerados, métodos), etc. Definamos nosotros un atributo propio y hagamos uso de él con reflexión. Nuestro objetivo es perderle el miedo a los atributos y entender qué es y qué no es un atributo. Vamos a crear un atributo que permita marcar nuestras clases con información de ayuda que una aplicación podría usar a conveniencia. Nuestro atributo se llamará Ayuda y llevará un parámetro consistente en una cadena con el texto de ayuda correspondiente. Empezamos por la definición de una clase AyudaAttribute. public class AyudaAttribute : Attribute { public AyudaAttribute(string texto) { TextoDeAyuda = texto; } public String TextoDeAyuda { get; private set; } }
Heredamos de la clase System.Attribute, que es la clase base de todos los atributos, y definimos una clase que mantiene una propiedad con el texto que suministramos en el constructor. El sufijo Attribute se usa en la definición de los atributos, pero no formará parte del nombre que usaremos al marcar clases. Podemos usar ya el atributo en nuestras propias clases: [Ayuda("Una clase que no hace gran cosa.")] class MiClase { }
Al compilar el código de MiClase, se añadirá una instancia de AyudaAttribute al código de la clase. Esa instancia contendrá el texto que hemos suministrado como argumento. Veamos ahora como, por reflexión, podemos recuperar ese texto a partir de una instancia: class Program { static void Main(string[] args) { MiClase mc = new MiClase(); Type mct = mc.GetType(); foreach (var a in mct.GetCustomAttributes(false)) { AyudaAttribute ayuda = a as AyudaAttribute; if (ayuda != null)
64
Buenas prácticas en desarrollo de software con .NET
La reflexión nos ha permitido obtener un listado de atributos de usuario con GetCustomAtributes y, a través de él, recuperar el texto de ayuda. Se puede hablar mucho más acerca de los atributos, pero con esto tenemos suficiente para lo que vamos a necesitar en el resto del curso.
7 Testeo unitario con NUnit y TDD Muchos proyectos de software fracasan sin llegar a ofrecer un producto que pueda pasar a explotación. Otros fracasan de un modo aún más estrepitoso: entregan algo que pasa a producción y provocan una sucesión de errores en explotación. Estos errores se manifiestan de formas desastrosas y parecen propagarse por el código: cada vez que se cree haber dado con la clave de un error y se corrige, saltan nuevos errores en otros módulos del sistema. El parcheo rápido del código no hace más que empeorar la calidad del resultado final y, finalmente, el código parece pedir a gritos un rediseño completo. El horror. Hay varios caminos que llevan a ese punto del desastre, pero el principal es el excesivo acoplamiento de los módulos (las interdependencias entre módulos) y la ausencia de una sistemática en las pruebas de que el software sigue funcionando tras efectuar alguna modificación en el código. Los dos aspectos están más relacionados de los que pueda parecer:
Si los módulos son muy interdependientes, es imposible hacer pruebas sencillas que comprueben que cada módulo hace una cosa y la hace bien. Si hubiésemos diseñado el código con el objetivo de que fuera comprobable, hubiésemos eliminado las indeseables interdependencias desde el mismo principio.
Nadie discutirá la afirmación de que el software debe “probarse” antes de pasar a explotación. Atendiendo a cierta categorización, hay dos tipos de pruebas:
Pruebas de programador, orientadas a demostrar que el código hace lo que el programador espera que haga. Suelen orientarse a la verificación de que ciertos métodos, aisladamente, tienen el comportamiento esperado. Se diseñan muchas veces para poner a prueba el código desde el conocimiento de sus entrañas, así que las hacen programadores y pueden servir de documentación para otros programadores. Pruebas de usuario o cliente, también denominadas pruebas de aceptación, orientadas a demostrar que el software hace lo que le cliente espera de él. Suelen orientarse a comprobar que clases o interfaces completas hacen lo que se espera de ellas. No se centran en cómo se consigue un resultado, sino en qué resultado se consigue. No tienen porqué escribirlas un programador y pueden usarse por cualquiera en la cadena de desarrollo.
Hay varios tipos de pruebas a las que podemos someter el código o la aplicación para que vaya aumentando nuestra confianza en su corrección:
Pruebas unitarias: cada método (o unidad básica) se prueba aisladamente.
65
Buenas prácticas en desarrollo de software con .NET
Pruebas de sistema o funcionales: se prueba el sistema completo para ver si satisface los requerimientos. Dentro de esta categoría encontramos: o Pruebas exploratorias: buscan nuevos errores. Se imaginan escenarios que pudieran provocar un fallo en el programa. Conducen al diseño de pruebas automatizables para evitar que un bug corregido reaparezca: lo que denominamos pruebas de regresión. o Pruebas de aceptación (acceptance testing): verifican que el programa satisface los requerimientos del cliente. Se escriben conjuntamente con el cliente, que suministra el conocimiento propio del dominio. o Pruebas de integración: verifica que los componentes del sistema interaccionan apropiadamente entre sí. o Pruebas de prestaciones (performance testing): comprueba el uso de recursos del programa completo y mira cómo interacciona con los recursos desplegados en un entorno tan similar como sea posible al entorno de explotación. Hay diferentes tipos de prueba de prestaciones: Pruebas de prestaciones propiamente dichas, que comprueba el uso de recursos como la memoria, el tiempo, etcétera, cuando la aplicación se usa en condiciones normales. Pruebas de carga (load testing, o volume testing o endurance testing): lleva el sistema a sus límites, imponiendo cargas extremas pero posibles en un escenario de explotación. Pruebas de estrés: lleva el sistema más allá de los límites esperables con objeto de estudiar la recuperabilidad o robustez del sistema. Se puede imponer la escritura de un fichero de tamaño superior a la capacidad de almacenamiento, o la atención a un número brutalmente alto de conexiones.
Aunque todos los tipos de prueba son importantes en un sistema real, nos vamos a centrar en el que más concierne a los programadores durante el desarrollo de unidades básicas: las pruebas unitarias. El testeo unitario es una de las prácticas que ayudan a desacoplar código y, si se adopta en las fases de diseño del sistema, conduce a diseños más fáciles de mantener, es decir, más resistentes al cambio. La idea de probar unidades elementales de código no es en absoluto reciente. Sí lo es su adopción generalizada por parte de la comunidad de desarrolladores, con herramientas de uso común como las que usaremos en el curso y que automatizan el proceso de la ejecución de las pruebas. Las pruebas unitarias se componen de código. Parece una obviedad, pero es extremadamente importante que las pruebas estén bien diseñadas. Entre las características de unas buenas pruebas unitarias y del entorno de pruebas (adaptado de “The Art of Unit Testing”, de Roy Osherove) tenemos:
El proceso de ejecución debe ser automatizable y repetible. Deben ser fáciles de implementar. Una vez escritas, las pruebas deben acompañar al código que ponen a prueba. Deberían ser ejecutables por cualquiera. Deberían ejecutarse por un procedimiento tan simple como pulsar un botón. Deberían ejecutarse rápidamente.
66
Buenas prácticas en desarrollo de software con .NET
No vamos a engañar a nadie. Crear pruebas unitarias supone escribir más código; de hecho, mucho más código. Pero ha de tenerse en cuenta que a la larga, compensa ese código extra. Nos permite estar razonablemente seguros de que el código hace lo que se espera de él. Si un bug pasa inadvertido y se detecta más tarde, no sólo se ha corregir: se ha diseñar una prueba unitaria que controle que ese bug no se reproduce más tarde. Con el tiempo, nuestro código se va blindando y evita la reaparición de errores ya corregidos, pues el conjunto creciente de pruebas es un chivato automático. Y si más tarde (o durante el propio desarrollo) es necesario refactorizar el código, las pruebas ya escritas nos ayudarán a estar seguros de que la funcionalidad alcanzada se mantiene en la nueva versión del código.
7.1 xUnit El testeo unitario es uno de los pilares de las metodologías ágiles y encontramos entornos de testeo unitario en la práctica totalidad de los lenguajes de programación de uso común. Kent Beck, uno de los firmantes del manifiesto ágil, diseñó un entorno de testeo unitario para Smalltalk: SUnit. Hoy, el entorno de referencia es jUnit, la versión para Java. La mayor parte de los lenguajes cuentan con alguna adaptación de este entorno. Estas adaptaciones se conocen globalmente por xUnit. La propia de .NET es NUnit (http://www.nunit.org/). Microsoft dispone de un entorno propio para el testeo unitario, pero nosotros usaremos NUnit por varias razones.
Es un estándar de facto. Se puede usar con herramientas de desarrollo de código abierto, como MonoDevelop. Es un entorno xUnit y, por tanto, su aprendizaje nos abre la puerta a entornos similares en otros lenguajes de programación.
7.2 Instalación de NUnit NUnit va por la versión 2.5.9 para uso en producción. En http://www.nunit.org/index.php?p=download hay un paquete de instalación para MS Windows. Lo primero es, obviamente, descargarlo e instalarlo. Al ejecutar el fichero msi, bastará con aceptar la licencia y escoger la instalación típica para que se instalen el ejecutable que permite controlar la ejecución de pruebas desde fuera del entorno Visual Studio (el “test runner”) y los ensamblados pertinentes en el GAC. Típicamente, los ficheros del paquete se instalarán en C:\Program Files (x86)\NUnit 2.5.9. Alternativamente se puede descargar el fichero comprimido, que obliga a una instalación manual de los ejecutables.
67
Buenas prácticas en desarrollo de software con .NET
7.3 Primeros pasos Vamos a empezar usando NUnit para construir una batería de pruebas unitarias sobre un código ya existente. Creamos un proyecto de tipo Class Library con el título Banca. Esta librería contendrá un fichero CuentaCorriente.cs con este código: using System; namespace Banca { public class CuentaCorriente { public Decimal Saldo { get; private set; } public CuentaCorriente(Decimal saldo = 0M) { Saldo = saldo; } public void Abono(Decimal cantidad) { Saldo += cantidad; } public void Adeudo(Decimal cantidad) { Saldo -= cantidad; }
public void Transferencia(CuentaCorriente destino, Decimal cantidad) { } } }
El método Transferencia no contiene código alguno, así que no realiza ninguna tarea. La clase, tal cual está definida, contiene un error. 7.3.1 Un accesorio de pruebas Vamos a definir las pruebas. Aunque lo normal es crear un proyecto independiente con las pruebas unitarias, crearemos las pruebas en el mismo ensamblado. Hemos de incorporar las referencias a las librerías propias de NUnit. En el Solution Explorer abrimos la carpeta Banca y, dentro de ella, la carpeta References. Con el botón derecho sobre la carpeta, elegimos Add Reference… en el menú contextual. Se abre un cuadro de diálogo con varias pestañas y elegimos la pestaña .NET (si hicimos la instalación automática; si no, hemos de buscar la librería con Browse):
68
Buenas prácticas en desarrollo de software con .NET
Seleccionamos nunit.framework de la lista de componentes y pulsamos el botón Ok. Añadimos ahora un fichero TestCuentaCorriente.cs al proyecto Banca y tecleamos en él este contenido. using NUnit.Framework; namespace Banca { [TestFixture] class TestCuentaCorriente { [Test] public void AbonoDeDinero_AumentaSaldo() { CuentaCorriente cuentaCorriente = new CuentaCorriente(); cuentaCorriente.Abono(100M); Assert.AreEqual(100M, cuentaCorriente.Saldo); } } }
Hemos usado dos atributos, uno para marcar la clase TestCuentaCorriente y otro para marcar su único método. Estos atributos están definidos en nunit.framework. TestFixture se puede traducir por “Accesorio para Pruebas”. El atributo la declara como clase con código de pruebas. El atributo Test marca al método AbonoDeDinero_AumentaSaldo como una prueba unitaria. El método crea una cuenta corriente (cuyo saldo inicial debe ser 0); efectúa un abono de la 100 euros y pasa a comprobar que, después, el saldo es de 100 euros. La comprobación se hace con un método auxiliar: Assert.AreEqual(100M, cuentaCorriente.Saldo);
Assert es una clase con métodos estáticos que permiten hacer diferentes comprobaciones. En este caso,
que dos cantidades son iguales. La primera cantidad es el valor esperado y la segunda, el obtenido realmente. Una prueba unitaria, siguiendo una definición de libro, es una pieza de código automatizado que invoca un método de la clase bajo prueba y comprueba que se observan ciertas asunciones sobre el comportamiento lógico de ese método o de la clase. Pues ya está. Hemos definido nuestra primera prueba unitaria.
69
Buenas prácticas en desarrollo de software con .NET
7.3.2 Ejecución de las pruebas Ejecutamos ahora NUnit.exe, la herramienta de ejecución de pruebas. Para hacerlo, vamos al menú de inicio del sistema, buscamos en Todos los programas y, en la carpeta NUnit 2.5.9 encontraremos el programa NUnit, que hemos de iniciar (de nuevo, esto es así si hemos hecho la instalación automática; si no, hemos de buscar manualmente el ejecutable en el resultado de descomprimir el fichero zip). La interfaz gràfica presenta este aspecto:
Seleccionamos File Open Project… y vamos a la carpeta del proyecto Banca. Dentro encontraremos la carpeta bin, que a su vez contiene la carpeta Debug. Ahí está Banca.dll, el ensamblado que contiene el código de la clase que vamos a poner a prueba y las pruebas. Por cierto, aquello que sometemos a prueba recibe el nombre de SUT, por “System Under Test”. Tras la carga, la interfaz pasa a tener este aspecto:
La herramienta ha explorado el ensamblado y ha encontrado la clase TestCuentaCorrienta y, dentro de ella, el método AbonoDeDinero_AumentaSaldo. Pulsamos el botón Run y la interfaz pasa a este otro estado:
70
Buenas prácticas en desarrollo de software con .NET
NUnit nos informa de que ha ejecutado todos los test (bueno, “el” test, que sólo hay uno) y que todos se han superado. Perfecto. Añadamos una prueba para el método Transferencia: using NUnit.Framework; namespace Banca { [TestFixture] class TestCuentaCorriente { [Test] public void AbonoDeDinero_AumentaSaldo() { CuentaCorriente cuentaCorriente = new CuentaCorriente(); cuentaCorriente.Abono(100M); Assert.AreEqual(cuentaCorriente.Saldo, 100M); } [Test] public void Transferencia_DisminuyeSaldoOrigenAumentaSaldoDestino() { CuentaCorriente origen = new CuentaCorriente(500M); CuentaCorriente destino = new CuentaCorriente(); origen.Transferencia(destino, 100M); Assert.AreEqual(400M, origen.Saldo); Assert.AreEqual(100M, destino.Saldo); } } }
Tan pronto reconstruimos (compilamos) el proyecto, NUnit recarga el ensamblado automáticamente:
71
Buenas prácticas en desarrollo de software con .NET
Ejecutemos las pruebas pulsando el botón Run. Este es el resultado:
La barra roja nos indica que no todas las pruebas se han superado. No sólo sabemos que hay un fallo; NUnit nos da detalles acerca de lo ocurrido: Banca.TestCuentaCorriente.Transferencia_DisminuyeSaldoOrigenAumentaSaldoDestino: Expected: 400m But was: 500m
Nos indica que se esperaba un valor de 400M donde ha encontrado el valor 500M. El fallo nos lo esperábamos. Vamos a corregirlo escribiendo el código que hubiésemos podido escribir inicialmente: public void Transferencia(CuentaCorriente destino, Decimal cantidad) { this.Adeudo(cantidad); destino.Abono(cantidad); }
Ejecutamos nuevamente las pruebas:
72
Buenas prácticas en desarrollo de software con .NET
Perfecto. Todas las pruebas superadas con éxito. Las pruebas que hemos diseñado son incompletas: no repasan todos los métodos de la clase CuentaCorriente. Pensemos qué pruebas podemos diseñar para fiscalizar aún más el código:
La cuenta corriente debe tener saldo cero si invocamos el constructor sin parámetros. La cuenta corriente debe tener un saldo igual a la cantidad que suministramos al invocar el constructor. El método Adeudo debe sustraer del saldo la cantidad que se suministra como argumento. using NUnit.Framework; namespace Banca { [TestFixture] class TestCuentaCorriente { [Test] public void ConstructorSinParámetros_SaldoCero() { CuentaCorriente cuentaCorriente = new CuentaCorriente(); Assert.AreEqual(0M, cuentaCorriente.Saldo); } [Test] public void ConstructorConCantidad_SaldoIgualACantidad() { CuentaCorriente cuentaCorriente = new CuentaCorriente(100M); Assert.AreEqual(100M, cuentaCorriente.Saldo); } [Test] public void AbonoDeDinero_AumentaSaldo() { CuentaCorriente cuentaCorriente = new CuentaCorriente(); cuentaCorriente.Abono(100M); Assert.AreEqual(100M, cuentaCorriente.Saldo); } [Test] public void AdeudoDeDinero_DisminuyeSaldo() { CuentaCorriente cuentaCorriente = new CuentaCorriente(300M); cuentaCorriente.Adeudo(100M); Assert.AreEqual(200M, cuentaCorriente.Saldo);
73
Buenas prácticas en desarrollo de software con .NET
} [Test] public void Transferencia_DisminuyeSaldoOrigenAumentaSaldoDestino() { CuentaCorriente origen = new CuentaCorriente(500M); CuentaCorriente destino = new CuentaCorriente(); origen.Transferencia(destino, 100M); Assert.AreEqual(400M, origen.Saldo); Assert.AreEqual(100M, destino.Saldo); } } }
Y ya sabemos qué hacer: compilar y ejecutar las pruebas. Todo correcto:
Hay una última cuestión sobre la que conviene que nos detengamos brevemente. El método que pone a prueba la Transferencia hace dos comprobaciones: que el saldo origen aumenta y que el saldo destino disminuye. Ya hemos visto que, cuando no había código en el método, la prueba fallaba. Pero sólo fallaba la primera de las aserciones: la segunda no se llegaba a ejecutar. Es preferible que cada prueba ejecute un solo aserto (aunque ojo con los dogmatismos). Es decir, el método de prueba Transferencia_DisminuyeSaldoOrigenAumentaSaldoDestino debería dividirse en estos otros: [Test] public void Transferencia_DisminuyeSaldoOrigen() { CuentaCorriente origen = new CuentaCorriente(500M); CuentaCorriente destino = new CuentaCorriente(); origen.Transferencia(destino, 100M); Assert.AreEqual(400M, origen.Saldo); } [Test] public void Transferencia_AumentaSaldoDestino() { CuentaCorriente origen = new CuentaCorriente(500M); CuentaCorriente destino = new CuentaCorriente(); origen.Transferencia(destino, 100M); Assert.AreEqual(100M, destino.Saldo); }
Es pronto, de todos modos, para que entremos en estas cuestiones de estilo.
74
Buenas prácticas en desarrollo de software con .NET
7.4 La clase Assert Las pruebas unitarias tienen tres pasos:
Preparar un contexto o escenario en el que se ejecuta la prueba. Ejecución del cálculo que ejercita la unidad que deseamos probar. Comprobación de que el resultado es el esperado.
El tercer paso consiste, típicamente, en la ejecución de un método de la clase estática Assert. Hay muchos métodos que permiten expresar diferentes comprobaciones. Es importante escoger el método apropiado para que el código sea legible y para que el resultado de las comprobaciones sea expresivo. La mayor parte de los métodos que mostramos ahora admiten parámetros opcionales:
Una cadena con mensaje que se mostrará cuando la aserción falle. Una cadena de formato y un número arbitrario de objetos. La cadena se interpolará y mostrará cuando falle la aserción.
7.4.1
IsTrue/IsFalse
7.4.2
AreEqual/AreNotEqual/AreSame/AreNotSame AreEqual(T exp1, T exp2), donde T es int, uint, decimal, float, double u object: comprueba si las dos expresiones se evalúan al mismo valor. AreNotEqual(T exp1, T exp2), donde T es int, uint, decimal, float, double u object: comprueba si las dos expresiones se evalúan con valores diferentes. AreSame(object o1, object o2): comrpueba si o1 y o2 son referencias al mismo objeto. AreNotSame(object o1, object o2): comrpueba si o1 y o2 son referencias objetos distintos.
7.4.3
Assert.IsTrue(boolean expression) : comprueba que la expresión se evalúa a true. Assert.IsFalse(boolean expression): comprueba que la expresión se evalúa a false.
Greater/Less Greater(T exp1, T exp2), donde T es int, uint, decimal, float, double o IComparable:
comprueba si exp1 > exp2. Less(T exp1, T exp2), donde T es int, uint, decimal, float, double o IComparable: comprueba si exp1 < exp2.
7.4.4
Contains
7.4.5
IsInstanceOfType/IsNotInstanceOfType IsInstanceOfType(Type t, object o): comprueba que o es una instancia de t. IsNotInstanceOfType(Type t, object o): comprueba que o no es una instancia de t.
7.4.6
IsAsssignableFrom/IsNotAssignableFrom IsAsssignableFrom(Type t, object o): comprueba que o se puede asignar a una variable de tipo t. IsNotAssignableFrom(Type t, object o): lo contrario.
7.4.7
Contains(object o, IList col): comprueba si el objeto o forma parte de la lista (o vector) col.
IsNull/IsNotNull IsNull(object o): comprueba que o es null.
75
Buenas prácticas en desarrollo de software con .NET
IsNotNull(object o): comprueba que o no es null.
7.4.8
IsNaN
7.4.9
Para cadenas
IsNan(double exp): comprueba exp proporciona el valor NaN.
IsEmpty(string s): comprueba que s es la cadena vacía. IsNotEmpty(string s): comprueba que s no es la cadena vacía. Contains(string expected, string s): comprueba si expected forma parte de s. StartsWith(string expected, string s): comprueba si expected es prefijo de s. EndsWith(string expected, string s): comprueba si expected es sufijo de s. AreEqualIgnoringCase(string expected, string s): comprueba que expected y s son iguales
si no tenemos en cuenta la caja. 7.4.10 Utilidades Hay un par de utilidades que permiten controlar directamente el resultado de una prueba. Suelen usarse en depuración o al construir funciones con comprobaciones complejas que deben usarse en más de un lugar.
Fail(): provoca un fallo. Ignore(): hace que se ignore la prueba.
7.5 Atributos Hemos vistos que las clases y métodos que conforman nuestras pruebas unitarias se marcan con atributos. Relacionamos aquí los atributos definidos en NUnit. 7.5.1 [TestFixture] clase Marca la clase que contiene pruebas unitarias. La clase marcada debe ser pública y tener un constructor por defecto que no tenga efectos secundarios, pues la clase puede instanciarse varias veces al ejecutar las pruebas. 7.5.2 [Test] método Marca un método como método de prueba. El método no debe devolver valor alguno ni tener parámetros. 7.5.3 [Setup] método Marca un método como preparatorio del escenario en el que se deben ejecutar todos los métodos marcados con [Test] de una clase marcada con [TestFixture]. El método se invoca antes de cada llamada a un método marcado con [Test]. La clase marcada con [TestFixture] sólo puede tener un método marcado con [Setup]. Si se definen clases con herencia, el atributo [Setup] se hereda. 7.5.4 [Teardown] método Marca un método como encargado de liberar recursos reservados por un método marcado por [Setup]. Se invoca al final de la ejecución de cada método marcado con [Test]. Sólo puede haber uno por clase. Si se definen clases con herencia, el atributo [Teardown] se hereda. 7.5.5 [TestFixtureSetup] método Método que se ejecuta una vez antes de ejecutar todos los métodos marcados con [Test].
76
Buenas prácticas en desarrollo de software con .NET
7.5.6 [TestFixtureTeardown] método Método que se ejecuta una vez después de haber ejecutado todos los métodos marcados con [Test]. 7.5.7 [ExpectedException(Type type)] método Marca que se puede añadir a un método marcado con [Test]. Hace que se considere válido el lanzamiento de una excepción de tipo type. Si la excepción no se lanza, se considera que la prueba ha fracasado. Alternativamente, el tipo de excepción esperado se puede expresar con una cadena y el identificador (completo) del tipo de la excepción. 7.5.8 [Platform(string s)] clase o método Permite condicionar la clase o método a la ejecución en una determinada plataforma. Se reconocen (al menos) estas plataformas: Win, Win32, Win32S, Win32Windows, Win32NT, WinCE, Win95, Win98, WinMe, NT3, NT4, NT5, Win2K, WinXP, Win2003Server, Unix, Linux, Net, Net-1.0, Net-1.1, Net-2.0, NetCF, SSCLI, Rotor, Mono. Ejemplos de plataforma:
"NET-2.0": .NET 2.0 "WinME": Windows ME "Win98,WinME": Windows 98 o WindowsME
7.5.9 [Category(string s)] clase o método Permite marcar una clase o método con una cadena para ejecutar pruebas unitarias selectivamente. Si un método de prueba es extremadamente lento, se puede marcar, por ejemplo, con “lento”. Los entornos de ejecución de pruebas permiten indicar si los métodos marcados con una cadena han de ejecutarse o no. 7.5.10 [Explicit] clase o método Marca una clase o método para que no se ejecute si no se selecciona explícitamente en el entorno de ejecución de pruebas. 7.5.11 [Ignore] clase o método La prueba se ignora. Se usa para deshabilitar temporalmente una clase o método. 7.5.12 [Combinatorial] método y [Values] o [Range] parámetro Permite generar automáticamente un test para cada combinación de valores. Lo mejor es ver un ejemplo. Este marcado: [Test, Combinatorial] public void MyTest( [Values(1, 2, 3)] int x, [Values("A", "B")] string s) { ... }
genera seis pruebas unitarias, una por cada combinación de los valores posibles para x (1, 2 y 3) con los valores posibles para s ("A" y "B"). En lugar de valores puntuales se puede especificar un rango de valores con [Range]. Este marcado
77
Buenas prácticas en desarrollo de software con .NET
[Test, Combinatorial] public void MyTest( [Range(1, 7, 2)] int x, [Values("A", "B")] string s) { ... }
genera una prueba por cada combinación de (1, 3, 5, 7) con ("A" y "B"). 7.5.13 [Sequential] método y [Values] o [Range] parámetro Permite generar automáticamente un test para cada par de valores. Este marcado: [Test, Sequential] public void MyTest( [Values(1,2,3)] int x, [Values("A","B")] string s) { ... }
genera tres pruebas unitarias, una con el par de valores (1, "A"), otra con (2, "B") y otra con (3, null). 7.5.14 [Timeout(int ms)] método Fija un tiempo máximo de ejecución para una prueba (en milisegundos). Si se excede ese tiempo, la prueba se cancela y se considera fallida. 7.5.15 [Maxtime(int ms)] método Fija un tiempo máximo de ejecución para una prueba (en milisegundos). Si se excede ese tiempo, la prueba se considera fallida (pero la prueba no se cancela antes de terminar).
7.6 Desarrollo guiado por pruebas (Test Driven Development) En el ejemplo que hemos desarrollado partíamos de código ya existente y hemos diseñado unas pruebas unitarias. Hemos seguido un proceso que podemos representar gráficamente así:
Escribir código
Escribir pruebas
Ejecutar pruebas
Corregir código
Hay un problema con seguir esta aproximación: muchas veces el desarrollador no tiene tiempo para los pasos segundo y tercero. Al tener código que parece funcionar, descarta estas etapas y se concentra en escribir más código funcional. El resultado es que nunca se tiene tiempo para escribir las pruebas o las pruebas cubren insuficientemente el código creado. Paremos un momento para reparar en que, en nuestro ejemplo, también hemos diseñado unas pruebas unitarias para código que aún no existía: el método Transferencia fue probado antes de escribir su cuerpo. Esta práctica de escribir primero las pruebas y después el código encaja en una metodología de desarrollo conocida por “Desarrollo guiado por pruebas” o, mejor, TDD, que son las siglas de Test Driven Development. Una de las ideas básicas del TDD es que un conjunto de pruebas bien planteado constituye una buena especificación de requisitos para una clase o método. Otra, que al diseñar las pruebas antes que el código proponemos patrones de uso del código que ayudan a diseñar el propio código. El proceso de desarrollo TDD es algo más complejo:
78
Buenas prácticas en desarrollo de software con .NET
Escribir una prueba
Lograr que las pruebas se superen escribiendo código de producción
Ejecutar todas las pruebas
Ejecutar todas las pruebas
Corregir errores
No
¿Se superan todas las pruebas?
Refactorizar el código Si
Si No
¿Refactorizar?
Nótese que cada ciclo pasa por el diseño de una prueba para código de producción inexistente. Si las pruebas no se superan, nuestra misión es corregir el código para “llegar a verde”, esto es, para superarlas. Puede llamar la atención la pregunta que nos hacemos cuando las pruebas se superan: “¿Refactorizar?”. Refactorizar es modificar el código con objeto de mejorarlo. La refactorización parte de código que funciona correctamente (hasta donde podemos tener una garantía porque supera las pruebas) y se hace “con red”: las propias pruebas, que deben seguir superándose tras las refactorización. En cualquier caso, debe tenerse en cuenta que TDD no es la “bala de plata” para el diseño de software. Es una metodología que, usada sensatamente, es de ayuda. Como siempre, los planteamientos dogmáticos son no conducen a nada bueno en el desarrollo de software.
7.7 Ejecución de pruebas Ya hemos visto cómo ejecutar pruebas con una interfaz gráfica de usuario. En los sistemas con integración continua es importante que la ejecución de pruebas sea automatizable. NUnit ofrece un entorno de ejecución de pruebas como programa de consola. El programa es nunit-console.exe y acepta como parámetro la DLL con las pruebas (o un csproj con el proyecto Visual Studio o un nunit con un proyecto NUnit). La ejecución de las pruebas muestra el resultado por la salida estándar y deja un fichero XML que puede analizarse con las herramientas apropiadas para saber si el sistema supera las pruebas en un instante dado. El programa nunit-console presenta varias opciones. Pueden consultarse en la página del manual de NUnit: http://www.nunit.org/index.php?p=consoleCommandLine&r=2.2.8.
7.8 Una buena práctica: nombrado de los métodos de prueba La escritura de buenas pruebas unitarias es clave para que se justifique el tiempo invertido en ellas. Una de las cuestiones que deben preocuparnos es la legibilidad de los mensajes cuando una prueba falla. Hemos visto que los métodos de Assert admiten un parámetro con un mensaje. Este mensaje se muestra cuando falla la aserción. La escritura de un mensaje en cada aserción es aburrida, por lo que es fácil que contengan texto pobre o, directamente, que el programador pase de escribirlo. Hay una alternativa mucho mejor: ser sistemático en el nombrado de los métodos de prueba procurando que su lectura aclare qué unidad ha
79
Buenas prácticas en desarrollo de software con .NET
fallado y por qué ha fallado. Dado que al fallar una prueba se muestra el nombre del método de dicha prueba, un nombre bien diseñado hace que el mensaje de aviso estándar sea muy aclaratorio de lo sucedido. Siguiendo los consejos de “The Art of Unit Testing”, los nombres de las pruebas unitarias deberían:
Ser un frase compuesta por tres elementos (separados por el carácter de subrayado): o El nombre del método puesto a prueba. o El contexto en el que se pone a prueba el método. o El resultado esperado de la prueba.
Por ejemplo: IsValidFileName_ConFicheroVálido_DevuelveTrue nos indica que estamos poniendo a prueba un método llamado IsValidFileName proporcionando un fichero válido y esperando que el método devuelva el valor true.
7.9 Un ejemplo de TDD con NUnit Vamos a desarrollar un pequeño ejemplo de desarrollo de una librería con TDD: una clase que nos facilite un método para transcriba números escritos con cifras con su expresión escrita con letras. Si el método recibe, por ejemplo, la cadena “17”, devolverá “diecisiete” y si recibe la cadena “32”, devolverá “treinta y dos”. Aceptaremos cualquier número de hasta seis cifras. Cree un proyecto llamado TranscriptorDeNúmeros con la plantilla de Class Library. No modifiques nada en ese proyecto y crea otro proyect: TestTranscriptorDeNúmeros. Este segundo proyecto también es una Class Library. Añadimos a sus referencias la librería nunit.framework. Sigamos la ortodoxia en TDD. Empecemos por proponer un caso de transcripción definiendo una entrada y su salida. Ante la entrada “0”, el transcriptor devolverá “cero”. Empezamos creando la prueba unitaria. En el proyecto TestTranscriptorDeNúmeros editamos el fichero Class1.cs (que se ha creado por defecto al añadir el proyecto) para que quede así: using NUnit.Framework; using TranscriptorDeNúmeros; namespace TestTranscriptorDeNúmeros { [TestFixture] public class TestTranscriptorDeNúmeros { [Test] public void Transcribe_0_ReturnsCero() { Transcriptor transcriptor = new Transcriptor (); string result = transcriptor.Transcribe(0); string expected = "cero"; Assert.AreEqual(expected, result); } } }
(Aunque no es estrictamente necesario, renombre también el fichero Class1.cs para que se llame TestTranscriptor.cs.) Hemos usado una clase Transcriptor que ni siquiera hemos definido. Evidentemente, no podemos ni compilar el proyecto. Definamos ahora la clase con lo mínimo necesario para que supere esta prueba. En el proyecto TranscriptorDeNúmeros definimos la clase Transcriptor y definimos el método Transcribe así:
80
Buenas prácticas en desarrollo de software con .NET
namespace TranscriptorDeNúmeros { public class Transcriptor { public string Transcribe(int número) { return "cero"; } } }
El método se limita a devolver la cadena “cero” sin tener en cuenta el valor del parámetro entero que se le suministra. Es la lógica mínima que nos permite superar el test. Ahora hemos de añadir a TestTranscriptorDeNúmeros una referencia al proyecto TranscriptorDeNúmeros y compilamos. Iniciamos la interfaz gráfica de NUnit y abrimos la DLL TestTranscriptorDeNúmeros.dll. Pulsamos en Run y… ¡verde!
Hemos superado nuestra primera ejecución de pruebas haciendo lo mínimo necesario. Añadamos una nueva prueba: using NUnit.Framework; using TranscriptorDeNúmeros; namespace TestTranscriptorDeNúmeros { [TestFixture] public class TestTranscriptorDeNúmeros { [Test] public void Transcribe_0_ReturnsCero() { Transcriptor transcriptor = new Transcriptor(); string result = transcriptor.Transcribe(0); string expected = "cero"; Assert.AreEqual(expected, result); } [Test] public void Transcribe_1_ReturnsUno() { Transcriptor transcriptor = new Transcriptor(); string result = transcriptor.Transcribe(1); string expected = "uno";
81
Buenas prácticas en desarrollo de software con .NET
Assert.AreEqual(expected, result); } } }
Y al ejecutar las pruebas pasamos a rojo:
Nuestro objetivo es pasar a verde haciendo el cambio mínimo necesario para Transcribe, que en nuestra opinión es éste: namespace TranscriptorDeNúmeros { public class Transcriptor { public string Transcribe(int número) { if (número == 0) { return "cero"; } else { return "uno"; } } } }
Obviamente, el transcriptor no hace lo que hará finalmente. Hace lo mínimo para superar las pruebas unitarias existentes en este instante. Nuestro objetivo es que el desarrollo se guíe por las pruebas para estar seguros de que nuestra clase y sus pruebas están “sincronizadas”. Ejecutemos las pruebas y comprobaremos que estamos en verde:
82
Buenas prácticas en desarrollo de software con .NET
Esto significa que estamos en condiciones de añadir funcionalidad. Pero antes, refactoricemos nuestra clase pruebas: los dos métodos de prueba (y cualquiera que hagamos a partir de ahora) necesita un transcriptor, así que podemos crearlo en un método de Setup: namespace TestTranscriptorDeNúmeros { [TestFixture] public class TestTranscriptorDeNúmeros { Transcriptor transcriptor; [SetUp] public void CreateTranscriptor() { transcriptor = new Transcriptor(); } [Test] public void Transcribe_0_ReturnsCero() { string result = transcriptor.Transcribe(0); string expected = "cero"; Assert.AreEqual(expected, result); } [Test] public void Transcribe_1_ReturnsUno() { string result = transcriptor.Transcribe(1); string expected = "uno"; Assert.AreEqual(expected, result); } } }
Añadamos pruebas unitarias para todos los números entre 0 y 9 y hagamos que Transcribe las supere. Las pruebas tienen este aspecto: using NUnit.Framework; using TranscriptorDeNúmeros; namespace TestTranscriptorDeNúmeros { [TestFixture]
83
Buenas prácticas en desarrollo de software con .NET
public class TestTranscriptorDeNúmeros { Transcriptor transcriptor; [SetUp] public void CreateTranscriptor() { transcriptor = new Transcriptor(); } [Test] public void Transcribe_0_ReturnsCero() { string result = transcriptor.Transcribe(0); string expected = "cero"; Assert.AreEqual(expected, result); } … [Test] public void Transcribe_9_ReturnsNueve() { string result = transcriptor.Transcribe(9); string expected = "nueve"; Assert.AreEqual(expected, result); } } }
Y el código que las permite supera, éste otro: namespace TranscriptorDeNúmeros { public class Transcriptor { public string Transcribe(int número) { switch (número) { case 0: { return "cero"; } case 1: { return "uno"; } case 2: { return "dos"; } case 3: { return "tres"; } case 4: { return "cuatro"; } case 5: { return "cinco"; } case 6:
84
Buenas prácticas en desarrollo de software con .NET
Ejecutemos las pruebas y… ¡verde! Procede una crítica: estamos escribiendo mucho código para algo que, en principio, no es muy complicado. Por una parte, estamos tratando de ser muy didácticos e ir paso a paso. Lo sustancial es que cada cosa que hemos hecho ha venido determinada por un código que ponía a prueba una funcionalidad aún antes de que ésta existiera. Al implementarla, hemos ido sobre seguro: podíamos comprobar que todo funcionaba porque la prueba ya se había escrito. Con todo, alguien podría ver excesivo que hay diez funciones de prueba, una para número de una cifra, y podría proponer este otro método de prueba que, en principio, hace lo mismo que los otros diez: [Test] public void Trancribe_0To9_ReturnsCeroToNueve() { var expected = new [] {"cero", "uno", "dos", "tres", "cuatro", "cinco", "seis", "siete", "ocho", "nueve"}; for (int i = 0; i < 10; i++) { string result = transcriptor.Transcribe(i); Assert.AreEqual(expected[i], result); } }
Es mucho menos código, sí, pero no hace exactamente lo mismo que el conjunto de las diez pruebas anteriores: si el transcriptor fallara, pongamos por caso, al transcribir el número 5, la nueva rutina de prueba se detendría con un mensaje similar a éste: TestTranscriptorDeNúmeros.TestTranscriptorDeNúmeros.Trancribe_0To9_ReturnsCeroToNueve: Expected string length 5 but was 8. Strings differ at index 0. Expected: "cinco" But was: "loquesea" --------------^
¿Dónde está el problema, pues? En que al detenerse ante este fallo, no se pone a prueba la corrección para los números 6 a 9. Cada ejecución de la batería de pruebas debería reportar todo lo que falla, no detenerse ante un fallo cualquiera.
85
Buenas prácticas en desarrollo de software con .NET
Pensemos en los siguientes diez números que debemos aprender a transcribir: los siete primeros son especiales porque no siguen ninguna norma. Diez, once, doce, trece, catorce, quince y dieciséis deberían traducirse directamente. Añadimos las siete nuevas pruebas (y omitimos aquí el código correspondiente). Hemos de añadir el código que permita superar las pruebas, pero empezamos a estar cansados de añadir elementos al switch de Transcribe y decidimos cambiar la implementación del código por esta otra: namespace TranscriptorDeNúmeros { public class Transcriptor { readonly string[] literals = new[] {"cero", "uno", "dos", "tres", "cuatro", "cinco", "seis", "siete", "ocho", "nueve", "diez", "once", "doce", "trece", "catorce", "quince", "dieciséis"}; public string Transcribe(int número) { if (número <= 16) { return literals[número]; } else { return ""; } } } }
Ejecutamos las pruebas y… ¡verde!
Ya empezamos a poder apreciar las ventajas de disponer de una batería de pruebas: hemos cambiado radicalmente la implementación, pero hemos hecho un salto con red. Las pruebas nos permite saber que le cambio no ha afectado a la funcionalidad que ya habíamos alcanzado. Hasta aquí hemos ido paso a paso, añadiendo una prueba por número. Esta estrategia no puede seguirse indefinidamente. No haremos mil pruebas para probar las transcripciones del 0 al 999. A partir del número 17 (inclusive), las cosas empiezan a presentar regularidades… y algunas excepciones:
86
Buenas prácticas en desarrollo de software con .NET
1. Los números del 16 al 19 se construyen con “dieci” seguido (como palabra única) de la transcripción de la cifra de unidades (“diecisiete”, “dieciocho” y “diecinueve”). 2. El número 20 se transcribe como “veinte”. 3. Los números del 21 lal 29, excepto el 22, el 23 y el 26, se forman con “veinte” seguido de la transcripción de la cifra de unidades. 4. El número 22 se transcribe como “veintidós”, el 23 como “veintitrés” y el 26 como “veintiséis”. 5. Los números del 30 al 100 divisibles por 10 se transcriben como “treinta”, “cuarenta”, “cincuenta”, “sesenta”, “setenta”, “ochenta”, “noventa” y “cien”. 6. Los números del 31 al 99, ambos inclusive, que no son múltiplos de 10 se transcriben con una cadena de la forma “X y Y”, donde X es
, siendo
el número, y donde Y es
.
Por ejemplo 39 se transcribe como “treinta y nueve”. 7. Los números del 101 al 199 se transcriben como “ciento X”, donde X es la transcripción de . 8. Los números del 200 al 900 múltiplos de 100 se transcriben con “doscientos”, “trescientos”, “cuatrocientos”, “quinientos”, “seiscientos”, “setecientos”, “ochocientos” y “novecientos”. 9. Los números del 200 al 999 que no son múltiplos de 100 se transcriben con una cadena de la forma “X Y”, donde X es la transcripción de
e Y es la transcripción de
.
10. El número 1.000 se transcribe como “mil”. 11. Los números de 2.000 a 999.999 se transcriben con una cadena de la forma “X mil Y”, donde X es la transcripción de
e Y es la transcripción de
.
Y nos detendremos aquí (el resto lo dejaremos como ejercicio). Vamos a por la primera regla. Son tres casos. Vale la pena que los cubramos con tres pruebas concretas. Añadimos estas pruebas: [Test] public void Transcribe_17_ReturnsDiecisiete() { string result = transcriptor.Transcribe(17); string expected = "diecisiete"; Assert.AreEqual(expected, result); } [Test] public void Transcribe_18_ReturnsDieciocho() { string result = transcriptor.Transcribe(18); string expected = "dieciocho"; Assert.AreEqual(expected, result); } [Test] public void Transcribe_19_ReturnsDiecinueve() { string result = transcriptor.Transcribe(19); string expected = "diecinueve"; Assert.AreEqual(expected, result); }
Ejecutamos y… ¡rojo!
87
Buenas prácticas en desarrollo de software con .NET
La verdad es que da pereza crear lógica más o menos compleja para tres casos particulares. Los abordaremos como todos los anteriores: namespace TranscriptorDeNúmeros { public class Transcriptor { readonly string[] literals = new[] {"cero", "uno", "dos", "tres", "cuatro", "cinco", "seis", "siete", "ocho", "nueve", "diez", "once", "doce", "trece", "catorce", "quince", "dieciséis", "diecisiete", "dieciocho", "diecinueve"}; public string Transcribe(int número) { if (número <= 19) { return literals[número]; } else { return ""; } } } }
Ya pasamos las pruebas. El número 20 es tan especial como los anteriores: escribimos su prueba, falla al ejecución y escribimos el código que permite superar la prueba. Por fin vamos a enfrentarnos a código interesante: el de los números 21 a 29. No haremos una prueba para cada uno: haremos sólo cuatro. Probaremos los números 21, 22, 23, 26 y 28. Hemos escogido los números 21 y 28 al azar, pero 22, 23 y 26 se han escogido deliberadamente por tratarse de números especiales en lo tocante a su transcripción (por la tilde). No reproducimos el código de las cuatro pruebas, que una vez escritas conducen a una ejecución de pruebas en rojo. Nuestro código SUT se modifica ahora para leerse así: namespace TranscriptorDeNúmeros { public class Transcriptor {
88
Buenas prácticas en desarrollo de software con .NET
Y verde, de nuevo. Detengámonos un momento para hacer una observación. Hemos escogido un par de números al azar entre 21 y 29 (además de 23 y 26, por su condición de casos especiales). Podríamos hacer en la tentación de elegir números realmente al azar con cada ejecución de las pruebas, es decir, diseñar una prueba como ésta: [Test] public void Transcribe_RandomNumber_ReturnsProperTranscription() { int n = 21 + (new Random().Next(9)); string[] r = new string[] { "veintiuno", "veintidós", "veintitrés", "veiniticuatro", "veinticinco", "veinitiséis", "veintisiete", "veinitiocho", "veintiocho", "veinitinueve" }; string result = transcriptor.Transcribe(n); string expected = r[n-21]; Assert.AreEqual(expected, result); }
Estaríamos cayendo en un error: con cada prueba estaríamos ejecutando la comprobación para un valor distinto. Las pruebas unitarias han de ir formando un corpus de pruebas que va creciendo por acumulación. No deben modificarse alegremente, y cambiar aleatoriamente el valor para el que se hace la comprobación
89
Buenas prácticas en desarrollo de software con .NET
podría dar como resultado que una ejecución fallase y no pudiésemos reproducir las condiciones que la hicieron fallar. No es una buena práctica. Pasamos a una parte más interesante de nuestro TDD: desarrollemos pruebas y código para los números divisibles por 10 entre 30 y 100. Pondremos a prueba la corrección de la transcripción con algunos casos concretos, como 30, 50 y 90. Una vez escritas las pruebas y comprobado que estamos en rojo, pasamos a escribir código: namespace TranscriptorDeNúmeros { public class Transcriptor { readonly string[] first_numbers = new[] {"cero", "uno", "dos", "tres", "cuatro", "cinco", "seis", "siete", "ocho", "nueve", "diez", "once", "doce", "trece", "catorce", "quince", "dieciséis", "diecisiete", "dieciocho", "diecinueve", "veinte"}; readonly string[] divisibleBy10Between30And100 = new[] { "treinta", "cuarenta", "cincuenta", "sesenta", "setenta", "ochenta", "noventa", "cien" }; public string Transcribe(int número) { if (número <= 20) { return first_numbers[número]; } else if (número < 30) { if (número == 22) { return "veintidós"; } if (número == 23) { return "veintitrés"; } else if (número == 26) { return "veintiséis"; } else { return "veinti" + Transcribe(número%10); } } else { if (número % 10 == 0) { return divisibleBy10Between30And100[número / 10 - 3]; } } return ""; } } }
90
Buenas prácticas en desarrollo de software con .NET
Y estamos en verde:
Pasamos a los números menores que 100. Probaremos los números 31, 43, 89 y 99. Escribimos las pruebas, ejecutamos en rojo y escribimos el código: namespace TranscriptorDeNúmeros { public class Transcriptor { readonly string[] first_numbers = new[] {"cero", "uno", "dos", "tres", "cuatro", "cinco", "seis", "siete", "ocho", "nueve", "diez", "once", "doce", "trece", "catorce", "quince", "dieciséis", "diecisiete", "dieciocho", "diecinueve", "veinte"}; readonly string[] divisibleBy10Between30And100 = new[] { "treinta", "cuarenta", "cincuenta", "sesenta", "setenta", "ochenta", "noventa", "cien" }; string Transcribe(int número) { if (número <= 20) { return first_numbers[número]; } else if (número < 30) { if (número == 22) { return "veintidós"; } if (número == 23) { return "veintitrés"; } else if (número == 26) { return "veintiséis"; } else { return "veinti" + Transcribe(número%10);
91
Buenas prácticas en desarrollo de software con .NET
Volvemos a estar en verde. A por los números mayores que 100 y menores que mil. Empezamos por los múltiplos de 100. Probaremos con 300, 700, 800 y 900. Tras comprobar que estamos en rojo, codificamos una posible solución: namespace TranscriptorDeNúmeros { public class Transcriptor { readonly string[] first_numbers = new[] {"cero", "uno", "dos", "tres", "cuatro", "cinco", "seis", "siete", "ocho", "nueve", "diez", "once", "doce", "trece", "catorce", "quince", "dieciséis", "diecisiete", "dieciocho", "diecinueve", "veinte"}; readonly string[] divisibleBy10Between30And100 = new[] { "treinta", "cuarenta", "cincuenta", "sesenta", "setenta", "ochenta", "noventa", "cien" }; readonly string[] divisibleBy100Between200And900 = new[] { "doscientos", "trescientos", "cuatrocientos", "quinientos", "seiscientos", "setecientos", "ochocientos", "novecientos", }; public string Transcribe(int número) { if (número <= 20) { return first_numbers[número]; } else if (número < 30) { if (número == 22) { return "veintidós"; } if (número == 23) { return "veintitrés"; }
92
Buenas prácticas en desarrollo de software con .NET
Volvemos a verde. Seleccionemos unos pocos números entre 100 y 999 para hacer pruebas con los que no son múltiplos de 100: los números, no sé… 156, 615 y 823.Mostramos únicamente el último else, que es lo que modificamos ahora: else { if (número % 100 == 0) { return divisibleBy100Between200And900[número/100-2]; } else { return divisibleBy100Between200And900[número/100 - 2] + " " + Transcribe(número%100); } }
Ejecutamos y, ¡rojo! Hemos cometido un error: no hemos tenido en cuenta los números que empiezan con “ciento”. Modifiquemos el código, tanto en la definición de la variable con los prefijos apropiados como en el else: readonly string[] divisibleBy100Between200And900 = new[] { "ciento", "doscientos", "trescientos", "cuatrocientos", "quinientos", "seiscientos", "setecientos", "ochocientos", "novecientos", }; …
93
Buenas prácticas en desarrollo de software con .NET
Ahora sí: compilamos, ejecutamos las pruebas y… nuevamente en verde. Para ir acabando con el ejemplo, preparamos pruebas para los números 1000, 1015, 3457, 7865 y 9999. Mostramos aquí el último else, que hemos modificado, y el nuevo fragmento de código en el SUT: else if (número < 1000) { if (número % 100 == 0) { return divisibleBy100Between200And900[número/100-1]; } else { return divisibleBy100Between200And900[número/100 - 1] + " " + Transcribe(número%100); } } else { if (número == 1000) { return "mil"; } else if (número < 2000) { return "mil " + Transcribe(número%1000); } else { return first_numbers[número / 1000] + " mil " + Transcribe(número % 1000); }
Ejecutamos con todo en verde. Dejamos como ejercicio al lector ampliar el rango de valores que puede tratar nuestro transcriptor. Nosotros vamos a “cerrar” el rango de valores admisibles haciendo que los intentos de transcribir número negativos o superiores a 9999 resulten en el lanzamiento de una excepción del tipo. Nuestras nuevas pruebas tendrán este aspecto: [Test] [ExpectedException(typeof(ArgumentOutOfRangeException))] public void Transcribe_NegativeNumber_ThrowsArgumentOutOfRangeException() { string result = transcriptor.Transcribe(-1); } [Test]
94
Buenas prácticas en desarrollo de software con .NET
[ExpectedException(typeof(ArgumentOutOfRangeException))] public void Transcribe_GreaterThan9999_ThrowsArgumentOutOfRangeException() { string result = transcriptor.Transcribe(10000); }
Si ejecutamos ahora las pruebas, habrá dos fallos:
El mensaje que se muestra en pantalla reza así: TestTranscriptorDeNúmeros.TestTranscriptorDeNúmeros.Transcribe_GreaterThan9999_ThrowsArgumentOutOf RangeException: System.ArgumentOutOfRangeException was expected TestTranscriptorDeNúmeros.TestTranscriptorDeNúmeros.Transcribe_NegativeNumber_ThrowsArgumentOutOfR angeException: An unexpected exception type was thrown Expected: System.ArgumentOutOfRangeException but was: System.IndexOutOfRangeException : Index was outside the bounds of the array.
En el primer caso se esperaba una excepción y no se observó. En el segundo, se esperaba una excepción de cierto tipo, pero se observó una de otro tipo. En cualquier caso, no ocurrió lo esperado. Modificamos el código del SUT para que el método Transcribe empiece así: public string Transcribe(int número) { if (número < 0 || número > 9999) { throw new ArgumentOutOfRangeException(número.ToString()); } if (número <= 20) { …
Ejecutamos las pruebas y, ¡verde!
95
Buenas prácticas en desarrollo de software con .NET
Un total de 47 métodos que ocupan 392 líneas de código para poner a prueba una clase con un método y 100 líneas de código. No es tan desproporcionado como pudiera parecer a quien no está acostumbrado al desarrollo basado en pruebas unitarias. Es importante que el tiempo de ejecución de una batería de pruebas unitarias sea de, a lo sumo, unos pocos segundos. Si no es así, el programador estará tentado de ejecutarlas pocas veces, eliminando así la ayuda que suponen cuando se desarrolla software. Nuestras 47 pruebas se han ejecutado en menos de medio segundo5, por lo que no hay ningún freno psicológico a un uso recurrente de las pruebas. La ventaja es que no sólo disponemos de código funcional, sino de una batería de pruebas que hace que cualquier cambio futuro del SUT que resulte en la introducción de errores será más probablemente detectado.
7.10 Ejercicio: orden natural Nos gustaría diseñar un comparador de cadenas (IComparer) que tuviera en cuenta la comparación de números tal cuál la entendemos naturalmente para ordenar nombres de fichero. Al ordenar por nombre los ficheros a1.txt, a100.txt, a2.txt, el usuario espera encontrar la lista así: a1.txt, a2.txt, a100.txt. El problema está planteado en http://www.codinghorror.com/blog/2007/12/sorting-for-humans-naturalsort-order.html (que, por cierto, es un artículo en uno de los blog que vale la pena seguir: Coding Horror, de Jeff Atwood).
5
Medio segundo teniendo en cuenta que hay sobrecostes por el mero hecho de cargar el módulo y controlar la ejecución de las priuebas, lo que hace que duplicar el código no suponga duplicar el tiempo de ejecución.
96
Buenas prácticas en desarrollo de software con .NET
7.11 Ejercicio: el juego de la vida El juego de la vida es un autómata celular en un universo consistente en un tablero con celdas vivas y muertas. Parte de una configuración de celdas vivas y muertas y con cada pulso se decide si cada celda está viva o muerta con unas reglas sencillas que atienden al estado de las celdas vecinas y al suyo propio en el instante inmediatamente anterior:
Toda celda viva con menos de dos celdas vecinas muere por despoblación.
Toda celda viva con más de tres celdas vecinas muere por sobrepoblación.
Toda celda viva con dos o tres celdas vecinas vivas sobrevive.
Toda celda muerta con exactamente tres vecinas vivas se convierte en una celda viva.
97
Buenas prácticas en desarrollo de software con .NET
7.12 Una herramienta integrable en Visual Studio NUnit es una herramienta gratuita cuya interfaz de usuario es independiente de Visual Studio 2010. Si tenemos una versión de Visual Studio en la que se pueden instalar extensiones (cualquiera excepto las series Express de Visual Studio) conviene que instalemos alguna de las extensiones que permiten la ejecución de las pruebas sin abandonar Visual Studio. Hay una herramienta de pago llamada ReSharper que ofrece esta integración y muchas más funcionalidades (análisis de código, navegación y búsqueda, refactorizaciones, internacionalización, generación de código, automatización de la construcción, asistencia en la escritura de código, limpieza de código, plantillas de código, edición de XAML, …). Se puede adquirir en http://www.jetbrains.com/resharper/. Con ReSharper, los accesorios y métodos de prueba unitaria se marcan automáticamente en el margen del editor de texto.
En la figura se pueden ver las marcas: son círculos de color verde y amarillo. Si se pincha en la marca se accede a un menú contextual desde el que se puede ejecutar cada prueba individualmente o todas las pruebas de un accesorio. La ejecución se puede llevar a cabo directamente o invocando al depurador. Otro modo de ejecutar las pruebas con ReSharper es vía el menú contextual que aparece al pulsar el botón derecho del ratón en la carpeta del proyecto de pruebas que se muestra en el Solution Explorer. Al ejecutar las pruebas se muestra un panel con el resultado como éste:
98
Buenas prácticas en desarrollo de software con .NET
Otra herramienta centrada en la ejecución de pruebas es TestDriven.Net. Es un producto comercial, pero con una licencia para particulares y proyectos de software libre. Se puede descargar de http://www.testdriven.net.
Al instalarla, Visual Studio enriquece algunos menús con opciones para ejecutar pruebas.
La salida no es muy agradable: es un simple volcado de texto en la consola de salida.
99
Buenas prácticas en desarrollo de software con .NET
En cualquier caso puede resultar más cómodo que andar entrando y saliendo de Visual Studio cada vez que se ejecutan las pruebas.
7.13 Créditos y recursos
La página web de NUnit es http://www.nunit.org/. El libro “The Art of Unit Testing with examples in C#”, de Roy Osherove, está a la venta en http://www.manning.com/osherove/. El autor mantiene una página web relacionada con el libro en http://artofunittesting.com/ y un blog http://osherove.com/blog/. Hay una lista de problemas para ejercitar TDD en http://sites.google.com/site/tddproblems. Esta entrada de un blog advierte de anti-patrones en TDD: http://blog.jamescarr.org/2006/11/03/tdd-anti-patterns/.
8 Dobles de prueba Ya hemos visto qué es el testeo unitario. Un principio fundamental del testeo unitario es que el SUT es único, es decir, cada prueba se diseña para comprobar el correcto funcionamiento de un aspecto de un único elemento de nuestro sistema software. Pero es corriente que aislar un elemento de modo que sólo se le ponga a prueba a él resulte “imposible”, pues este elemento no tiene sentido sin la participación de otro u otros elementos porque ha de interactuar necesariamente con ellos. Decimos entonces que nuestra unidad presenta dependencias externas. La práctica habitual consiste en crear un objeto que se haga pasar por el necesario durante la prueba, lo que denominamos un doble de prueba. Si ese objeto debe implementarse con un gran esfuerzo de programación, no valdrá la pena. Por eso, el doble de prueba contendrá la lógica mínima para conseguir hacerse pasar por el objeto real. La lógica mínima permitirá una escritura rápida y una ejecución también rápida. En principio, el objetivo es doble:
no depender del comportamiento de otro objeto (que podría contener errores) cuando comprobamos que nuestro SUT funciona correctamente, mantener el tiempo de ejecución de las pruebas tan bajo como sea posible, evitando el coste que supone acceder a recursos potencialmente lentos (sistema de ficheros, bases de datos, etc).
Pero si estamos haciendo TDD, hay un objetivo adicional:
construir nuestro software del modo más desacoplado posible, haciendo tan sencillo como sea posible el sustituir una clase por otra.
100
Buenas prácticas en desarrollo de software con .NET
Este último objetivo cuesta de entender hasta que se ve en la práctica, pero es de los más importantes: tiene un impacto directo en el diseño de nuestro software. Hay varios tipos de “dobles de prueba”. Si seguimos la nomenclatura presentada en el artículo http://martinfowler.com/articles/mocksArentStubs.html tenemos:
Dummy: Objeto que se suministra para “llenar un hueco”. Por ejemplo, si un método requiere un parámetro de un cierto tipo pero no hace uso de él, se puede crear una instancia cualquiera (un dummy) de ese tipo y suministrarla como argumento. Fake: Objeto con una implementación funcional, pero que toma algún atajo que no lo hace útil en producción. Un ejemplo es una base de datos en memoria. Stub: Objeto que proporciona respuestas enlatadas a las llamadas que tienen lugar durante una prueba, pero generalmente incapaces de dar respuestas a nada que no esté en la prueba. También puede registrar información acerca de las llamadas. Por ejemplo, el stub de una pasarela de correo podría memorizar el los mensajes que “envió”, o quizá sólo su número. Mock: Objeto preprogramado con expectativas que forman una especificación de las llamadas que se espera que reciba.
Hay muchas librerías que ofrecen la posibilidad de crear Mocks. Entre ellas tenemos:
NUnit.Mocking: no hay buena documentación y no es la mejor librería que podemos encontrar. Durante algún tiempo no soportaba los modos de trabajo que de uso creciente en la comunidad. Por otra parte, usa cadena en lugar de identificadores de métodos, con lo que el compilador no avisa de errores comunes al construir o usar los mocks. TypeMock: producto comercial. Rhino.Mocks: es una de las más completas, pero con una curva de aprendizaje mayor que la de otras. Moq: es una librería sencilla, con una interfaz fluida.
Usaremos Moq por presentar una curva de aprendizaje muy suave y presentar una colección de conceptos limitada pero suficiente para aprender a usar dobles de prueba.
8.1 Nuestra aplicación de ejemplo: un lector de RSS Empezamos con un ejemplo adaptado de un libro de buenas prácticas que usa el lenguaje Python. Recurrimos a ese material por su interés propio, pero también porque demuestra que las técnicas que aprendemos en el curso son “universales”… o al menos no dependen del lenguaje de programación empleado. Nuestro ejemplo consiste en un sistema de lectura de fuentes RSS (Really Simple Syndication), el sistema de presentación de contenidos que usan infinidad de sistemas web para publicar a la información en un formato legible por herramientas y fácilmente manipulable. Las fuentes RSS son direcciones URL a las que se lanza una petición GET de HTTP y proporcionan como resultado un contenido XML que sigue un determinado esquema. Las fuentes RSS se suelen mostrar incrustadas en páginas web con este logo:
101
Buenas prácticas en desarrollo de software con .NET
El periódico El País, por ejemplo, publica sus noticias más recientes en RSS. Basta con acceder a la URL http://www.elpais.com/rss/feed.html?feedId=17046 para obtener un documento XML con un contenido similar a éste (el sangrado se ha alterado para facilitar la lectura): Sun, 27 Mar 2011 18:58:06 +0200es-es15http://www.elpais.com/im/tit_logo.gifELPAIS.com - Lo último http://www.elpais.com El Palacio de Cibeles abre al público las puertas de su área cultural. Desde hoy y hasta el 27 de julio, los ciudadanos podrán visitar el interior del edificio, que será también la sede del Ayuntamiento de Madrid , por primera vez en sus más de 100 años de historia.]]>Pablo Zalba Bidegain acordó retocar una directiva comunitaria destinada a proteger a los consumidores europeos siguiendo las peticiones de un grupo falso de presión, según revela hoy el dominical The Sunday Times, que afirma que esta conversación fue grabada en secreto. Zalba, que ha denunciado haber sido víctima de una "trampa", ha negado haber cobrado por modificar el texto y ha señalado que enmendó la ley porque así la "mejoraba sustancialmente".]]>
La especificación de RSS 2.0, que es el formato de este documento, se puede consultar en la página http://feed2.w3.org/docs/rss2.html. No hay un solo formato RSS en uso. La aplicación que vamos a construir está orientada a la línea de órdenes. Recibirá una serie de URLs y mostrará un resumen de la información RSS. Si sólo le suministrásemos la URL anterior, mostraría esa misma información en el siguiente formato: *** ELPAIS.com - Lo último Sun, 27 Mar 2011 18:41:00 +0200: El Palacio de Cibeles abre al público
102
Buenas prácticas en desarrollo de software con .NET
Sun, 27 Mar 2011 18:44:00 +0200: Un eurodiputado 'pillado' por una cámara oculta
La salida sigue un formato sencillo: En el resumen, el nombre de la fuente RSS ocupa la primera línea y cada ítem del RSS ocupa una línea adicional. En cada una de esas líneas se muestra la fecha de publicación del ítem, dos puntos y el título del ítem. Si se proporcionasen más URLs, la aplicación mostraría un resumen como ese para cada una. Desarrollaremos la aplicación siguiendo la metodología TDD, pero un tanto “minorada” por razones prácticas. Para cada unidad diseñaremos una o dos pruebas, cuando convendría hacer muchas más y atender así a todos los contextos imaginables. El objetivo de ver qué es TDD lo hemos cubierto en el tema anterior. Iremos más rápido ahora para poder cubrir otros objetivos en un tiempo razonable. En particular, veremos cómo el diseño TDD (incluso en esta versión simplificada) ayuda a producir software incrementalmente, con elevado grado de desacomplamiento, y cómo es posible eliminar dependencias cuando éstas se detectan. Un buen desacoplamiento permitirá introducir dobles de prueba y podremos estudiar stubs y mocks. Veremos que los mocks ofrecen más funcionalidad que los stubs y que se codifican más fácilmente.
8.2 Aplicación RssReaderApp y librería Rss Empezamos creando el proyecto RssReaderApp, del tipo Console Application. En el fichero Program.cs editaremos el texto para dejarlo así: namespace RssReaderApp { class Program { static void Main(string[] args) { Rss.Reader reader = new Rss.Reader(); reader.Display(args); } } }
Naturalmente, la clase Rss.Reader no existe y el programa no puede compilarse. Creamos aparte un nuevo proyecto con nombre Rss y de tipo Class Library. Su fichero Class1.cs se renombra para ser Reader.cs y su contenido se edita para que se lea así: using System.Collections.Generic; namespace Rss { public class Reader { public void Display(IEnumerable urls) { foreach (var url in urls) { FormatFeed(url); } } public void FormatFeed(string url) { } } }
103
Buenas prácticas en desarrollo de software con .NET
El método Display debe recibir una relación de URLs y mostrar un resumen del contenido RSS de cada una de ellas. Para ello invoca al método FormatFeed, que aún no hemos definido. Al proyecto RssReaderApp le añadimos una referencia a la librería Rss. Ya podemos compilar, aunque el programa no hace absolutamente nada.
8.3 TDD Desarrollaremos con la metodología TDD. Para ello creamos un proyecto TestRss de tipo Class Library al que añadimos la referencia al ensamblado nunit.framework.dll y una referencia a la librería Rss. Hemos de dotar de lógica al método FormatFeed. Seguro que viene bien disponer de un método que formatee un ítem individual de una fuente RSS. Así pues, nuestro primer objetivo es diseñar un método que muestre un ítem del contenido RSS en una sola línea, con la fecha y el titular del ítem. En el ejemplo anterior, el método es responsable de producir una línea como ésta: Sun, 27 Mar 2011 18:41:00 +0200: El Palacio de Cibeles abre al público
Si no estamos acostumbrados, analizar un documento XML puede resultar complejo. Por otra parte, un método que reciba el documento XML, lo analice, extraiga el contenido relevante y lo muestre es un método que parece, a priori, excesivamente ambicioso (propio de un “objetos Dios”). Es mejor que cada método haga una sola cosa y la haga bien. Dejaremos, pues, la cuestión de analizar XML para más adelante. Supondremos de momento que la información del documento XML se nos pasa ya en algún formato apropiado, con objetos .NET, y que apenas hemos de juntar trozos de texto para producir el resultado. Lo más sencillo es suponer que nos pasan dos argumentos de tipo cadena y que nos limitamos a juntar las cadenas apropiadamente. No ha de ser complicado diseñar un método que haga eso y sólo eso. Empezamos por definir la prueba unitaria: using NUnit.Framework; using System.Collections.Generic; namespace TestRss { [TestFixture] public class TestReader { private Rss.Reader _reader; [SetUp] public void PreparaReader() { _reader = new Rss.Reader(); } [Test] public string FormatItem_ConDateYTitle_DevuelveLineaFormateada() { string date = "Sun, 27 Mar 2011 18:41:00 +0200"; string title = "El Palacio de Cibeles abre al público "; string expected = "Sun, 27 Mar 2011 18:41:00 +0200: El Palacio de Cibeles abre al público"; var result = _reader.FormatItem(date, title); Assert.AreEqual(expected, result); } } }
104
Buenas prácticas en desarrollo de software con .NET
El método FormatItem no está definido, pero ofrecer una implementación a partir de este caso de uso resulta trivial: using System.Collections.Generic; namespace Rss { public class Reader { public void Display(IEnumerable urls) { foreach (var url in urls) { FormatFeed(url); } } public void FormatFeed(string url) { } public string FormatItem(string date, string title) { return string.Format("{0}: {1}", feed, title); } } }
En el GUI de NUnit cargamos TestRss.dll, ejecutamos y ¡verde! No sabemos aún cómo analizaremos el documento XML, pero si podemos saber qué información nos interesa y pensar en algún modo de representarla con estructuras de datos propias de .NET. Creemos nuestras propias clases Feed e Item: using System.Collections.Generic; namespace Rss { public class Feed { public string Title { get; private set; } public IEnumerable Items { get; private set; } public Feed(string title, IEnumerable items) { Title = title; Items = new List(items); } } public class Item { public string Date { get; private set; } public string Title { get; private set; } public Item(string date, string title) { Date = date; Title = title; } } public class Reader
105
Buenas prácticas en desarrollo de software con .NET
{ public void Display(IEnumerable urls) { foreach (var url in urls) { FormatFeed(url); } } public void FormatFeed(string url) { } public string FormatItem(string date, string title) { return string.Format("{0}: {1}", date, title); } } }
Reescribamos el test (y cambiemos su identificador) para que recoja nuestro rediseño: [Test] public void FormatItem_ConItem_DevuelveLineaFormateada() { var feed = new Rss.Feed( title: "ELPAIS.com - Lo último", items: new List { new Rss.Item( date: "Sun, 27 Mar 2011 18:41:00 +0200", title: "El Palacio de Cibeles abre al público" ) }); var expected = "Sun, 27 Mar 2011 18:41:00 +0200: El Palacio de Cibeles abre al público"; var result = reader.FormatItem(feed.Items.First()); Assert.AreEqual(expected, result); }
Y reescribamos también el método: public class Reader { public void Display(IEnumerable urls) { foreach (var url in urls) { FormatFeed(url); } } public void FormatFeed(string url) { } public string FormatItem(Item item) { return string.Format("{0}: {1}", item.Date, item.Title); } }
Nótese que estamos tomando decisiones de diseño sobre la marcha, corrigiendo decisiones previas cuando es menester. El diseño nos lo va proporcionando el uso que hacemos de aquello que vamos escribiendo. Ya
106
Buenas prácticas en desarrollo de software con .NET
sabemos que esa es una de las ventajas del TDD. ¡Ah! Y el diseño actual no tiene por qué ser el definitivo. Hemos de estar abiertos al cambio. Vamos ahora a por un método que proporcione el resumen de una fuente completa. Escribimos primero el test, suponiendo que el método funciona correctamente y tiene el perfil que nos conviene: [Test] public void FormatItem_ConFeed_DevuelveInformeCompleto() { var feed = new Rss.Feed( title: "ELPAIS.com - Lo último", items: new List { new Rss.Item( date: "Sun, 27 Mar 2011 18:41:00 +0200", title: "El Palacio de Cibeles abre al público " ), new Rss.Item( date: "Sun, 27 Mar 2011 18:44:00 +0200", title: "Un eurodiputado 'pillado' por una cámara oculta " ) }); var expected = @"*** ELPAIS.com - Lo último Sun, 27 Mar 2011 18:41:00 +0200: El Palacio de Cibeles abre al público Mar 2011 18:44:00 +0200, ELPAIS.com - Lo último: Un eurodiputado 'pillado' por una cámara oculta "; var result = reader.FormatFeed(feed); Assert.AreEqual(expected, result); }
Aunque definimos en su momento FormatFeed como un método sin valor de retorno y con un parámetro de tipo cadena, vemos ahora que nos conviene un perfil distinto. El método no hace nada de momento, así que hemos de definirlo ahora y cambiar su perfil: public string FormatFeed(Feed feed) { var sb = new StringBuilder("*** "); sb.Append(feed.Title); sb.Append(Environment.NewLine); foreach (var item in feed.Items) { sb.Append(this.FormatItem(feed, item)); sb.Append(Environment.NewLine); } return sb.ToString(); }
Compilamos, ejecutamos las pruebas, y estamos en verde. Hemos de refactorizar las pruebas. La estructura de datos con el Feed y los Item de pruebas se pueden crear en el método de SetUp: using System.Linq; using NUnit.Framework; using System.Collections.Generic; namespace TestRss { [TestFixture] public class TestReader
107
Buenas prácticas en desarrollo de software con .NET
{ private Rss.Reader _reader; private Rss.Feed _feed; [SetUp] public void PreparaReader() { _reader = new Rss.Reader(); _feed = new Rss.Feed( title: "ELPAIS.com - Lo último", items: new List { new Rss.Item( date: "Sun, 27 Mar 2011 18:41:00 +0200", title: "El Palacio de Cibeles abre al público" ), new Rss.Item( date: "Sun, 27 Mar 2011 18:44:00 +0200", title: "Un eurodiputado 'pillado' por una cámara oculta" ) }); } [Test] public void FormatItem_ConItem_DevuelveLineaFormateada() { var expected = "Sun, 27 Mar 2011 18:41:00 +0200: El Palacio de Cibeles abre al público "; var result = _reader.FormatItem(_feed.Items.First()); Assert.AreEqual(expected, result); } [Test] public void FormatFeed_ConFeed_DevuelveInformeCompleto() { var expected = @"Sun, 27 Mar 2011 18:41:00 +0200: El Palacio de Cibeles abre al público Sun, 27 Mar 2011 18:44:00 +0200: Un eurodiputado 'pillado' por una cámara oculta "; var result = _reader.FormatFeed(_feed); Assert.AreEqual(expected, result); } } }
Vamos a construir el método que convierte el XML de un feed en el correspondiente objeto Rss.Feed. Empezamos por una prueba unitaria que defina el comportamiento esperado: [Test] public void Parse_ConWellFormedXml_DevuelveFeed() { var xml = @" …
"; var expected = feed; var result = reader.Parse(xml); Assert.AreEqual(expected, result); }
(No mostramos la cadena XML completa. Su contenido se corresponde a una copia del ejemplo XML, sustituyendo las dobles comillas por comillas simples en su interior para evitar problemas de codificación de cadenas en C#.)
108
Buenas prácticas en desarrollo de software con .NET
El programa no compila. Podemos hacer que compile con una implementación que no hace nada relevante: public class Feed { … public Feed Parse(string xml) { return null; } …
Naturalmente, no pasamos una de las tres pruebas unitarias. Mejoremos la implementación y hagamos uso de una librería de tratamiento XML. public Feed Parse(string xml) { XDocument xdoc = XDocument.Load(new StringReader(xml)); var feed= new Feed( title: xdoc.Element("rss").Element("channel").Element("title").Value, items: from elt in xdoc.Element("rss").Element("channel").Elements("item") select new Item( date: elt.Element("pubDate").Value, title: elt.Element("title").Value )); return feed; }
Parece que todo está listo. Ejecutamos las pruebas y… ¡rojo! El mensaje del fallo es éste: TestRss.TestReader.Parse_ConWellFormedXml_DevuelveFeed: Expected: But was:
8.4 Depuración con NUnit Nos interesaría ver con detalle que ocurre. ¿Cómo podemos iniciar una sesión de depuración? En primer lugar, pongamos un breakpoint en la orden Assert.AreEqual de TestReader y compilemos. En el menú Debug de Visual Studio seleccionamos la opción Attach to Process… y seleccionamos el proceso nunitagent.exe. Al pusar Run en el GUI de NUnit, la ejecución se detendrá en el breakpoint. Podemos examinar las variables expected y result:
109
Buenas prácticas en desarrollo de software con .NET
Podemos comprobar que los dos contienen básicamente. Naturalmente, lo que nos está ocurriendo es que no se ha definido la función Equals en Feed, así que la comparación se limita a comprobar si los dos objetos son el mismo (no si los contenidos de ambos objetos son iguales, que es distinto). Hemos de definir un método de igualdad. Por elegancia, haremos que Feed e Item implementen IEquatable e IEquatable, respectivamente. Y eso hace que debamos redefinir el método Equals de Object y que convenga redefinir también GetHashcode: public class Feed : IEquatable { public string Title { get; private set; } public IEnumerable Items { get; private set; } public Feed(string title = "", IEnumerable items = null) { Title = title; Items = new List(items ?? Enumerable.Empty()); } public override bool Equals(object obj) { var other = obj as Feed; if (other == null) { return false; } return Equals(other); } public override int GetHashCode() { var hash = Title.GetHashCode(); foreach (var item in Items) { hash = hash*23 + item.GetHashCode(); } return hash; } #region IEquatable Members public bool Equals(Feed other) { if (Title != other.Title) { return false; } var otherEnum = other.Items.GetEnumerator(); foreach (var item in Items) { if (!otherEnum.MoveNext()) { return false; } if (!item.Equals(otherEnum.Current)) { return false; } } if (otherEnum.MoveNext()) { return false; }
110
Buenas prácticas en desarrollo de software con .NET
return true; } #endregion } public class Item : IEquatable { public string Date { get; private set; } public string Title { get; private set; } public Item(string date, string title) { Date = date; Title = title; } public override bool Equals(object obj) { var other = obj as Item; if (other == null) { return false; } return Equals(other); } public override int GetHashCode() { return Title.GetHashCode() * 23 + Date.GetHashCode(); } #region IEquatable Members public bool Equals(Item other) { return Date == other.Date && Title == other.Title; } #endregion }
(Para saber de dónde sale la fórmula para calcular códigos de dispersión y por qué ese factor 23, en StackOverflow hay una pregunta con su consiguiente respuesta que refieren a un libro interesante: http://stackoverflow.com/questions/263400/what-is-the-best-algorithm-for-an-overridden-system-objectgethashcode). Ejecutamos ahora nuestra colección de pruebas y… ¡verde! Naturalmente, ahora deberíamos detenernos a escribir pruebas unitarias para los tres métodos que hemos definido en Feed y los otros tres que hemos definido en Item. Las dejamos como ejercicio para el lector.
8.5 Hemos violado el principio de diseño SRP Nuestra clase Rss.Reader empieza a ser compleja: sabe presentar información y también sabe analizar ficheros XML. Algo huele mal en el código, se percibe un “code smell”, esto es, un síntoma de que hay un problema latente. Lo cierto es que hemos violado uno de los principios de diseño: el SRP (Single Responsibility Principle), que dice que cada clase debería tener una sola responsabilidad. Un analizador de XML y un presentador de información son elementos muy diferentes entre sí y no es natural que el código de ambos elementos
111
Buenas prácticas en desarrollo de software con .NET
conviva en una sola clase. Hemos de crear clases separadas para cada responsabilidad. Rss.Reader acabará siendo la cola que unirá ambas clases. 8.5.1 Refactorizando Como el código ya está escrito, refactorizarlo es sencillo. Aprovechamos para arreglar un asunto pendiente: Feed e Item deberían declararse en ficheros propios, Feed.cs e Item.cs. Siguiendo esa misma lógica, las clases Rss.Parser y Rss.Formatter se definirán en ficheros propios: Nuestros dos nuevos ficheros quedan, por el momento, así: Parser.cs using System.IO; using System.Linq; using System.Xml.Linq; namespace Rss { public class Parser { public Feed Parse(string xml) { XDocument xdoc = XDocument.Load(new StringReader(xml)); var feed = new Feed( title: xdoc.Element("rss").Element("channel").Element("title").Value, items: from elt in xdoc.Element("rss").Element("channel").Elements("item") select new Item( date: elt.Element("pubDate").Value, title: elt.Element("title").Value )); return feed; } } }
Formatter.cs using System; using System.Text; namespace Rss { public class Formatter { public string FormatItem(Item item) { return string.Format("{0}: {1}", item.Date, item.Title); } public string FormatFeed(Feed feed) { var sb = new StringBuilder("*** "); sb.Append(feed.Title); sb.Append(Environment.NewLine); foreach (var item in feed.Items) { sb.Append(this.FormatItem(item)); sb.Append(Environment.NewLine); } return sb.ToString(); }
112
Buenas prácticas en desarrollo de software con .NET
} }
Y ahora nos queda por redefinir la clase Reader para que haga su trabajo, que es hacer colaborar a un Parser con un Formatter. El contenido de Reader.cs podría quedar así: namespace Rss { public class Reader { private Parser _parser; private Formatter _formatter; public Reader() { _parser = new Parser(); _formatter = new Formatter(); } public string FeedReport(string xml) { var feed = _parser.Parse(xml); var formatted = _formatter.FormatFeed(feed); return formatted; } } }
El resultado aún necesitará retoques, pero ya puede comprobar que el diseño es evolutivo y va guiado por las pruebas y refactorizaciones. Llevamos tiempo sin compilar ni ejecutar pruebas. Con tanto rediseño, las pruebas ya no compilan. Tendremos que retocarlas. De hecho, conviene separar ahora las pruebas en nuevas clases, una por cada una de las clases que consideramos un SUT. Así, el nuevo fichero TestFormatter.cs contendrá: using System.Collections.Generic; using System.Linq; using NUnit.Framework; namespace TestRss { [TestFixture] public class TestFormatter { private Rss.Formatter _formatter; private Rss.Feed _feed; [SetUp] public void PreparaTestFormatter() { _formatter = new Rss.Formatter(); _feed = new Rss.Feed( title: "ELPAIS.com - Lo último", items: new List { new Rss.Item( date: "Sun, 27 Mar 2011 18:41:00 +0200", title: "El Palacio de Cibeles abre al público" ), new Rss.Item( date: "Sun, 27 Mar 2011 18:44:00 +0200", title: "Un eurodiputado 'pillado' por una cámara oculta"
113
Buenas prácticas en desarrollo de software con .NET
) }); } [Test] public void FormatItem_ConFeedEItem_DevuelveLineaFormateada() { var expected = "Sun, 27 Mar 2011 18:41:00 +0200: El Palacio de Cibeles abre al público"; var result = _formatter.FormatItem(_feed, _feed.Items.First()); Assert.AreEqual(expected, result); } [Test] public void FormatFeed_ConFeed_DevuelveLineasFormateadas() { var expected = @"*** ELPAIS.com - Lo último Sun, 27 Mar 2011 18:41:00 +0200: El Palacio de Cibeles abre al público Sun, 27 Mar 2011 18:44:00 +0200: Un eurodiputado 'pillado' por una cámara oculta "; var result = _formatter.FormatFeed(_feed); Assert.AreEqual(expected, result); } } }
Ahora hemos de poner a prueba a nuestra redefinida clase Reader. En principio no hay gran cosa que hacer (omitimos en el listado parte de la cadena XML en aras de la brevedad): using System.Linq; using NUnit.Framework; using System.Collections.Generic; namespace TestRss { [TestFixture] public class TestReader { private Rss.Reader _reader; [SetUp] public void PreparaReader() { _reader = new Rss.Reader(); } [Test] public void FeedReport_ConXmlBienFormado_DevuelveLíneasFormateadas() { var xml = @" … "; var expected = @"*** ELPAIS.com - Lo último Sun, 27 Mar 2011 18:41:00 +0200: El Palacio de Cibeles abre al público Sun, 27 Mar 2011 18:44:00 +0200: Un eurodiputado 'pillado' por una cámara oculta "; var result = _reader.FeedReport(xml); Assert.AreEqual(expected, result); } } }
114
Buenas prácticas en desarrollo de software con .NET
8.5.2 Eliminando dependencias Analicemos nuestra clase Reader. Hay un nuevo “code smell”: hay una fuerte dependencia de dos clases. La clase Reader depende de las clases concretas Parser y Formatter. Nuestro diseño es excesivamente rígido. Estamos violando el principio DIP (Dependency Inversion Principle), que dice que hemos de depender de abstracciones, no de implementaciones. El modo en el que hemos de desacoplar el código pasa por la definición de interfaces y su uso al expresar dependencias. Definimos una interfaz IParser que define el perfil que debe tener todo analizador para ser utilizable por parte de Reader. En el fichero IParser.cs del proyecto Rss escribimos: namespace Rss { public interface IParser { Feed Parse(string xml); } }
Y en IFormatter.cs escribimos: namespace Rss { public interface IFormatter { string FormatFeed(Feed feed); } }
Naturalmente, las clases Parser y Formatter deben retocarse para explicitar que implementan las interfaces IParser e IFormatter, respectivamente. Nuestra clase Reader también se ha de retocar. De momento queda así: namespace Rss { public class Reader { private IParser _parser; private IFormatter _formatter; public Reader() { _parser = new Parser(); _formatter = new Formatter(); } public string FeedReport(string xml) { var feed = _parser.Parse(xml); var formatted = _formatter.FormatFeed(feed); return formatted; } } }
Mmmm. No hemos ganado gran cosa. El código sigue presentando una fuerte dependencia de las mismas clases. Aunque los campos _parser y _formatter se han definido como instancias de IParser e IFormatter, el constructor sigue recurriendo a las clases particulares Parser y Formatter para construir los objetos, así que la dependencia sigue estando ahí, en el código de la clase.
115
Buenas prácticas en desarrollo de software con .NET
Una técnica que permite desacoplar el código es la que se conoce por inyección de dependencias, y a ella dedicaremos mucha atención más adelante. Un modo de inyectar dependencias es mediante parámetros en el constructor: namespace Rss { public class Reader { private IParser _parser; private IFormatter _formatter; public Reader(IParser parser, IFormatter formatter) { _parser = parser; _formatter = formatter; } public string FeedReport(string xml) { var feed = _parser.Parse(xml); var formatted = _formatter.FormatFeed(feed); return formatted; } } }
Naturalmente, quien haya de construir un Reader deberá suministrar sendas instancias de un Parser y un Formatter. ¿No habremos complicado demasiado nuestro código? Veremos cómo evitar este problema con una técnica muy potente: el uso de contenedores e inyectores de dependencias. Ahora nos queda retocar la prueba unitaria para que funcione con el nuevo código: using NUnit.Framework; namespace TestRss { [TestFixture] public class TestReader { private Rss.Reader _reader; [SetUp] public void PreparaReader() { var parser = new Rss.Parser(); var formatter = new Rss.Formatter(); _reader = new Rss.Reader(parser, formatter); } …
8.6 ¿Qué queremos probar realmente de Rss.Reader? Introduciendo los dobles de prueba Y ahora llegamos, por fin, al meollo de este capítulo: ¿Qué queremos probar realmente de Rss.Reader? Deseamos demostrar empíricamente que si proporcionamos una implementación de IParser y una implementación de IFormatter que son correctas, Reader produce el resultado deseado. ¿Qué ocurrirá si suministramos una implementación de IParser o IFormatter incorrectas al ejecutar las pruebas unitarias? Que la prueba unitaria de Reader fallará, haciéndonos creer que Reader es una clase
116
Buenas prácticas en desarrollo de software con .NET
defectuosa. Pero no será el caso: Reader no necesitará que se toque una sola línea suya, sino que se repare la clase o clases defectuosas de las que depende. Hemos de repetirlo para que quede meridianamente claro: la prueba unitaria centrada en Reader sólo debe comprobar que Reader hace lo correcto si sus dependencias funcionan correctamente. ¿Cómo hacemos para comprobar que tal cosa ocurre exactamente así, si esas dependencias aún no han sido sometidas a las pruebas unitarias que les corresponden para asegurarnos razonablemente de que funcionan correctamente? Una solución es diseñar una clase para IParser y una clase para IFormatter que proporcione exactamente lo que se necesita, pero absolutamente nada más. Es lo que denominamos un “stub”. Lo mejor será considerar un ejemplo: using NUnit.Framework; using System.Collections.Generic; namespace TestRss { static class SomeConstants { internal static string input = @" … "; internal static Rss.Feed intermediate = new Rss.Feed( title: "ELPAIS.com - Lo último", items: new List { new Rss.Item( date: "Sun, 27 Mar 2011 18:41:00 +0200", title: "El Palacio de Cibeles abre al público " ), new Rss.Item( date: "Sun, 27 Mar 2011 18:44:00 +0200", title: "Un eurodiputado 'pillado' por una cámara oculta " ) }); internal static string output = @"*** ELPAIS.com - Lo último Sun, 27 Mar 2011 18:41:00 +0200: El Palacio de Cibeles abre al público Sun, 27 Mar 2011 18:44:00 +0200: Un eurodiputado 'pillado' por una cámara oculta "; } class ParserMock {
: Rss.IParser
#region IParser Members public Rss.Feed Parse(string xml) { return SomeConstants.intermediate; } #endregion } class FormatterMock : Rss.IFormatter { #region IFormatter Members public string FormatFeed(Rss.Feed feed) { return SomeConstants.output;
117
Buenas prácticas en desarrollo de software con .NET
} #endregion } [TestFixture] public class TestReader { [Test] public void FeedReport_RecibeXmlBienFormado_DevuelveLíneasFormateadas() { var parser = new ParserMock (); var formatter = new FormatterMock (); var reader = new Rss.Reader(parser, formatter); var result = reader.FeedReport(SomeConstants.input); Assert.AreEqual(SomeConstants.output, result); } } }
Ciertamente estamos ante un caso un tanto forzado, pero la brevedad de una introducción obliga a este tipo de truculencia. Hemos de tener en cuenta que esta técnica no sólo es útil una vez hemos desacoplado el código de los elementos con lo que guardaba una dependencia originalmente. Si uno de los objeto fuese lento en ejecución, podría arruinar la idea misma de las pruebas unitarias, que han de ser rápidas si se pretende que sean útiles. Un objeto lento puede ser, por ejemplo, el que accede a un base de datos, a una página web, al sistema de ficheros de un modo extensivo, etcétera. Todos esos objetos deben impostarse con stubs si se desea mantener los tiempos de ejecución en un orden razonable.
8.7 Mocks con Moq Lo que es cierto es que hemos tenido que hacer un gran esfuerzo para crear los stubs. Ahí es donde podemos contar con la ayuda de librerías que nos ofrece utilidades para crear stubs “al vuelo”. Bueno, lo habitual es que la librería permita crear mocks. Pero no nos anticipemos. Confundamos deliberadamente los dos términos por el momento y ya veremos en qué se diferencian. 8.7.1 Instalación de Moq Lo primero es instalar la librería Moq en nuestro proyecto TestRss. La librería se puede encontrar en http://code.google.com/p/moq. En el momento en el que se escribió este texto, la última versión de Moq se distribuía en un paquete comprimido con nombre Moq.4.0.10827.zip. Al abrirlo encontramos varias carpetas. Accedemos a la carpeta NET40 y extraemos el fichero moq.dll. En el proyecto TestRss añadimos una referencia a moq.dll. Listos. 8.7.2 Primeros pasos con Moq Empezamos por suprimir completamente nuestras definiciones de las clases ParserMock y FormatterMock. No son necesarias. Reescribimos el contenido de TestReader.cs para que quede así (sabiendo que la cadena XML no se muestra aquí completa): using NUnit.Framework; using System.Collections.Generic; using Moq; namespace TestRss { static class SomeConstants
118
Buenas prácticas en desarrollo de software con .NET
{ internal static string input = @" … "; internal static Rss.Feed intermediate = new Rss.Feed( title: "ELPAIS.com - Lo último", items: new List { new Rss.Item( date: "Sun, 27 Mar 2011 18:41:00 +0200", title: "El Palacio de Cibeles abre al público " ), new Rss.Item( date: "Sun, 27 Mar 2011 18:44:00 +0200", title: "Un eurodiputado 'pillado' por una cámara oculta " ) }); internal static string output = @"*** ELPAIS.com - Lo último Sun, 27 Mar 2011 18:41:00 +0200: El Palacio de Cibeles abre al público Sun, 27 Mar 2011 18:44:00 +0200: Un eurodiputado 'pillado' por una cámara oculta "; } [TestFixture] public class TestReader { [Test] public void FeedReport_RecibeXmlBienFormado_DevuelveLíneasFormateadas() { var parserMock = new Mock(); parserMock.Setup(p => p.Parse(SomeConstants.input)) .Returns(SomeConstants.intermediate); var formatterMock = new Mock(); formatterMock.Setup(f => f.FormatFeed(SomeConstants.intermediate)) .Returns(SomeConstants.output); var reader = new Rss.Reader(parserMock.Object, formatterMock.Object); var result = reader.FeedReport(SomeConstants.input); Assert.AreEqual(SomeConstants.output, result); } } }
¡Mucha tela! Vamos paso a paso para entender bien este fragmento de código. En primer lugar, incorporamos el espacio de nombres Moq: using Moq;
Ya en la prueba unitaria, creamos un objeto de tipo Mock, esto es, un impostor para la interfaz IParser: var parserMock
= new Mock();
El objeto parserMock es de la clase Mock y en su campo parserMock.Object mantiene una referencia a objeto que es de la clase IParser. Ese objeto es, precisamente, un stub de Rss.IParser, solo que forma parte del mock. Ahí vemos la diferencia entre el stub y el mock: el stub forma parte constituyente del mock, que es un objeto con funcionalidad referida a la definición de expectativas y su verificación, como veremos ahora.
119
Buenas prácticas en desarrollo de software con .NET
La siguiente sentencia es un tanto compleja: parserMock.Setup(p => p.Parse(SomeConstants.input)).Returns(SomeConstants.intermediate);
Vamos por partes. La sentencia contiene dos llamadas a método: una al método Setup de parserMock , que devolverá algo sobre lo que invocamos el método Returns. Centrémonos en la primera de las llamadas: parserMock.Setup(p => p.Parse(SomeConstants.input)).Returns(SomeConstants.intermediate);
Con esta sentencia indicamos que esperamos que se produzca una llamada al método Setup de parserMock.Object con el argumento SomeConstants.input (que era la cadena XML). La sintaxis del argumento de Setup puede resultar extraña hasta que uno se habitúa. Ese argumento es una lambdafunción o función anónima. Es una función que recibe un parámetro, al que llamamos p, y devuelve lo que devuelva la llamada a p.Parse(SomeConstants.input). Parece que podríamos haber expresado lo mismo con parserMock.Setup(parserMock.Parse(SomeConstants.input)) [mal]
Pero no es así. Esta última sentencia no puede ser siquiera compilada: parserMock no tiene un método Parse, así que el compilador señalará un error. Quizá piense que esta otra llamada sí tendría éxito: parserMock.Setup(parserMock.Object.Parse(SomeConstants.input)) [mal]
Y, es cierto, a efectos de compilación no tendríamos el problema anterior. Pero el resultado de la compilación provocaría que Setup recibiese como argumento el resultado de la llamada a parserStup.Object.Parse, lo que es erróneo porque Setup espera un argumento de otro tipo: una lambda-función. Al pasar una lambda-función, el compilador construye una estructura de datos que representa el cálculo que la función hace y suministra esa estructura a Setup. La lambda-función está definida como una Action, así que el parámetro p es de tipo IParser y admite una llamada a Parse. Sigamos. La segunda llamada de la sentencia es una invocación al método Returns con el argumento SomeConstants.intermediate. parserMock.Setup(p => p.Parse(SomeConstants.input)).Returns(SomeConstants.intermediate);
Con esta llamada se declara que la llamada a parserMock.Object.Parse(SomeConstants.input) devolverá, como resultado, el objeto SomeConstants.intermediate. Nótese que no escribimos una clase convencional: de eso ya se ocupa (probablemente usando reflexión) la librería Moq. Estas otras sentencias resultan fáciles de entender ahora que sabemos interpretar las anteriores: var formatterMock = new Mock(); formatterMock.Setup(f => f.FormatFeed(SomeConstants.intermediate)).Returns(SomeConstants.output);
Y nos queda esta línea, que usa por fin los objetos Mock sobre los que hemos definido un cierto comportamiento: var reader = new Rss.Reader(parserMock.Object, formatterMock.Object);
120
Buenas prácticas en desarrollo de software con .NET
Nótese que suministramos el IParser y el IFormatter mediante el campo Object de cada mock, no las propias instancias de Mock y Mock. ¿Qué ocurrirá cuando se ejecute esta otra sentencia?: var result = reader.FeedReport(SomeConstants.input);
La llamada a FeedReport ejecutará el código que mostramos ahora paso a paso y que corresponde al cuerpo de dicho método (sustituyendo los campos y parámetros por sus respectivos valores y argumentos). En primer lugar: var feed = parserMock.Object.Parse(SomeConstants.input);
El objeto parserMock.Object recibe la llamada a Parse con el argumento SomeConstants.input y devuelve (porque así lo hemos declarado antes) el valor SomeConstants.intermediate. Así pues, la siguiente sentencia es equivalente a esta: var formatted = formatterMock.Object.FormatFeed(SomeConstants.intermediate);
Y, de nuevo gracias a la declaración de comportamiento esperado que hicimos, se devuelve como resultado SomeConstants.output. La siguiente sentencia se ejecuta y finaliza la ejecución del método: return formatted;
Ya está. Hemos conseguido que este test se supere con éxito: Assert.AreEqual(SomeConstants.output, result);
8.8 Añadiendo un cargador de contenido por HTTP Ya tenemos casi nuestro lector. Falta que obtenga los datos que necesita de una fuente RSS real, accesible por HTTP. Está claro que cargar un contenido por HTTP es una nueva responsabilidad que no debemos asignar a Rss.Reader, ni a Rss.Parser, ni a Rss.Formatter. Tendremos que definir una nueva interfaz y una clase que la implemente. Llamemos ILoader a esa clase: ILoader.cs namespace Rss { public interface ILoader { string Load(string url); } }
Loader.cs using System.Net; using System.IO; namespace Rss { public class Loader : ILoader { #region ILoader Members public string Load(string url)
121
Buenas prácticas en desarrollo de software con .NET
{ var client = new WebClient(); using (var s = client.OpenRead(url)) using(var r = new StreamReader(s)) { return r.ReadToEnd(); } } #endregion } }
Tendremos que modificar nuestra clase Rss.Reader: namespace Rss { public class Reader { private ILoader _loader; private IParser _parser; private IFormatter _formatter; public Reader(ILoader loader, IParser parser, IFormatter formatter) { _loader = loader; _parser = parser; _formatter = formatter; } public string FeedReport(string xml) { var feed = _parser.Parse(xml); var formatted = _formatter.FormatFeed(feed); return formatted; } public void Display(IEnumerable urls) { foreach (var url in urls) { var xml = _loader.Load(url); var result = FeedReport(xml); System.Console.Write(result); } } } }
Hemos definido el método Display como la rutina principal de los objetos de tipo Rss.Reader. Los argumentos que recibe son URLs que el método enumera para pedir a cada el contenido XML correspondiente, generar el informe a partir de dicho XML y mostrar por pantalla el XML. Modificamos también el código de pruebas unitarias para esta clase: using NUnit.Framework; using System.Collections.Generic; using Moq; namespace TestRss { static class SomeConstants {
122
Buenas prácticas en desarrollo de software con .NET
internal static string url = "http://www.elpais.com/rss/feed.html?feedId=17046"; internal static string input = @" … "; internal static Rss.Feed intermediate = new Rss.Feed( title: "ELPAIS.com - Lo último", items: new List { new Rss.Item( date: "Sun, 27 Mar 2011 18:41:00 +0200", title: "El Palacio de Cibeles abre al público " ), new Rss.Item( date: "Sun, 27 Mar 2011 18:44:00 +0200", title: "Un eurodiputado 'pillado' por una cámara oculta " ) }); internal static string output = @"*** ELPAIS.com - Lo último Sun, 27 Mar 2011 18:41:00 +0200: El Palacio de Cibeles abre al público Sun, 27 Mar 2011 18:44:00 +0200: Un eurodiputado 'pillado' por una cámara oculta "; } [TestFixture] public class TestReader { [Test] public void FeedReport_RecibeXmlBienFormado_DevuelveLíneasFormateadas() { var loaderMock= new Mock(); loaderMock.Setup(l => l.Load(SomeConstants.input)).Returns(SomeConstants.url); var parserMock = new Mock(); parserMock.Setup(p => p.Parse(SomeConstants.input)) .Returns(SomeConstants.intermediate); var formatterMock = new Mock(); formatterMock.Setup(f => f.FormatFeed(SomeConstants.intermediate)) .Returns(SomeConstants.output); var reader = new Rss.Reader(loaderMock.Object, parserMock.Object, formatterMock.Object); var result = reader.FeedReport(SomeConstants.input); Assert.AreEqual(SomeConstants.output, result); } } }
Si ejecutamos las pruebas, todo va bien. Pero nos hemos dejado algo importante en el tintero: poner a prueba a la clase Loader.
8.9 Pruebas con cálculos lentos Diseñamos una prueba unitaria en una nueva clase TestLoader. La prueba cargará los datos de una página RSS. Usaremos la de siempre. Hay un problema: la información que se genera en una fuente RSS es, por su propia naturaleza, variable. Un acceso a la misma URL proporciona resultados distintos en momentos distintos. Podríamos montar infraestructura especial: un sitio RSS propio en el que nosotros generamos el contenido. Otra posibilidad es relajar lo que comprobamos del acceso. Podemos conformarnos con
123
Buenas prácticas en desarrollo de software con .NET
comprobar que se trata de un documento XML y que contiene algunos elementos que deben estar presentes. Haremos eso. using using using using
NUnit.Framework; Rss; System.Xml.Linq; System.IO;
namespace TestRss { [TestFixture] class TestLoader { [Test] public void Load_ConUrl_ObtieneXmlVálido() { Loader loader = new Loader(); var xml = loader.Load("http://www.elpais.com/rss/feed.html?feedId=17046"); XDocument xdoc = XDocument.Load(new StringReader(xml)); Assert.IsNotNull(xdoc); Assert.IsNotNull(xdoc.Element("rss")); } } }
Bueno. Ejecutemos las pruebas unitarias:
Hay un serio problema. Las pruebas no tardaban en ejecutarse, hasta el momento, más de un segundo (de hecho, apenas un décima de segundo y pico). Hemos pasado a más de cinco segundos. Un tiempo excesivo. Y eso que estamos siendo muy tacaños en el número de pruebas unitarias que estamos creando: si creásemos el número de pruebas propio del TDD, el problema se agravaría. En cualquier caso, esta prueba consume excesivo tiempo y no debería ejecutarse frecuentemente. NUnit permite marcar una prueba unitaria con una categoría (una cadena arbitraria) para tratarla específicamente cuando convenga: using NUnit.Framework; using Rss; using System.Xml.Linq;
124
Buenas prácticas en desarrollo de software con .NET
using System.IO; namespace TestRss { [TestFixture] class TestLoader { [Test] [Category("Lenta")] public void Load_ConUrl_ObtieneXmlVálido() { Loader loader = new Loader(); var xml = loader.Load("http://www.elpais.com/rss/feed.html?feedId=17046"); XDocument xdoc = XDocument.Load(new StringReader(xml)); Assert.IsNotNull(xdoc); Assert.IsNotNull(xdoc.Element("rss")); } } }
La interfaz GUI de NUnit dispone de una pestaña (a mano izquierda) que da acceso a las categorías:
Podemos seleccionar categorías, que se mostrarán en la caja Selected Categories, y excluir de ejecución las pruebas unitarias correspondientes:
125
Buenas prácticas en desarrollo de software con .NET
Si ejecutamos ahora, se ejecuta todo excepto la prueba marcada:
Cada cierto tiempo podemos incluir esa prueba unitaria en la ejecución. Pero sólo cada cierto tiempo.
8.10 Verificación de expectativas Ya vemos que con Moq podemos definir el comportamiento de un objeto que implementa una interfaz sin necesidad de construir una clase del modo habitual. En ocasiones querremos tener más control del que ofrece este procedimiento consistente en definir una llamada y el valor de retorno asociado. 8.10.1 Verificación del número de llamadas Podemos tener interés en comprobar que se han efectuado las llamadas que hemos definido y que lo hacen en el orden en el que se han definido. Este método de prueba unitaria incluye tres nuevas sentencias: [Test] public void FeedReport_RecibeXmlBienFormado_DevuelveLíneasFormateadas() { var loaderMock= new Mock(MockBehavior.Strict); loaderMock.Setup(l => l.Load(SomeConstants.url)).Returns(SomeConstants.input);
126
Buenas prácticas en desarrollo de software con .NET
var parserMock = new Mock( MockBehavior.Strict); parserMock.Setup(p => p.Parse(SomeConstants.input)) .Returns(SomeConstants.intermediate); var formatterMock = new Mock(MockBehavior.Strict); formatterMock.Setup(f => f.FormatFeed(SomeConstants.intermediate)) .Returns(SomeConstants.output); var reader = new Rss.Reader(loaderMock.Object, parserMock.Object, formatterMock.Object); var result = reader.FeedReport(SomeConstants.input); loaderMock.Verify(l => l.Load(SomeConstants.url), Times.Never()); parserMock.Verify(p => p.Parse(SomeConstants.input), Times.Exactly(1)); formatterMock.Verify(f => f.FormatFeed(SomeConstants.intermediate), Times.Exactly(1)); Assert.AreEqual(SomeConstants.output, result); }
Al crear los mocks hemos indicado que vamos a exigir un comportamiento estricto de acuerdo con ciertas reglas (valor MockBehavior.Strict). El método Verifiy se encarga de verificar que esas reglas se cumplen: loaderMock.Verify(l => l.Load(SomeConstants.url), Times.Never()); parserMock.Verify(p => p.Parse(SomeConstants.input), Times.Exactly(1)); formatterMock.Verify(f => f.FormatFeed(SomeConstants.intermediate), Times.Exactly(1));
La primera línea dice que la llamada indicada con la lambda-función no debe producirse nunca y las dos líneas siguientes dicen que las respectivas llamadas deben producirse exactamente una vez cada una. Para hacer una prueba, modifiquemos la primera verificación y pongamos una condición que no se da: loaderMock.Verify(l => l.Load(SomeConstants.input), Times.AtLeast(2));
Exigimos que la llamada tenga lugar al menos dos veces. Al ejecutar las pruebas unitarias tenemos:
El mensaje de error es éste: TestRss.TestReader.FeedReport_RecibeXmlBienFormado_DevuelveInformeCompleto:
127
Buenas prácticas en desarrollo de software con .NET
Moq.MockException : Expected invocation on the mock at least 2 times, but was 0 times: l => l.Load(SomeConstants.url) Configured setups: l => l.Load(SomeConstants.url), Times.Never No invocations performed.
Moq nos indica que se esperaba al menos dos invocaciones sobre un método, pero que no se observó ninguna. Podemos controlar el número de invocaciones con:
Times.AtLeast(int n): al menos n veces. Times.AtLeastOnce(): al menos una vez. Times.AtMost(int n): a lo sumo n veces. Times.AtMostOnce(): a lo sumo una vez. Times.Between(int a, int b, Range range): entre a y b, donde range puede ser Range.Inclusive o Range.Exclusive, para indicar si b está comprendida o no en el rango.
Times.Never(): nunca. Times.Once(): una sola vez.
Una última observación: nuestro objeto _loader.Object es un objeto dummy: no hace nada, pero se necesita para poder suministrarlo como argumento a un método. 8.10.2 Verificación con control relajado de parámetros Al llamar a los métodos, Moq comprueba que los parámetros son aquellos que se indican. Imaginemos que no nos importara con qué datos se invoca a parserMock. Podemos indicarlo a Moq así: [Test] public void FeedReport_RecibeXmlBienFormado_DevuelveInformeCompleto() { var loaderMock = new Mock(MockBehavior.Strict); loaderMock.Setup(l => l.Load(SomeConstants.url)).Returns(SomeConstants.input); var parserMock = new Mock(MockBehavior.Strict); parserMock.Setup(p => p.Parse(It.IsAny())) .Returns(SomeConstants.intermediate); var formatterMock = new Mock(MockBehavior.Strict); formatterMock.Setup(f => f.FormatFeed(SomeConstants.intermediate)) .Returns(SomeConstants.output); var reader = new Rss.Reader(loaderMock.Object, parserMock.Object, formatterMock.Object); var result = reader.FeedReport(SomeConstants.input); loaderMock.Verify(l => l.Load(SomeConstants.url), Times.Never()); parserMock.Verify(p => p.Parse(It.IsAny()), Times.Once()); formatterMock.Verify(f => f.FormatFeed(SomeConstants.intermediate), Times.Once()); Assert.AreEqual(SomeConstants.output, result); }
El objeto It permite especificar algunas expectativas para los parámetros:
It.Is: especifica el valor exacto esperado.
128
Buenas prácticas en desarrollo de software con .NET
It.IsAny: especifica que se espera cualquier valor del tipo T. It.IsInRange(from, to, Range): especifica que el valor está en el rango indicado. It.IsRegex(s): se espera una cadena que concuerde con la expresión regular s. It.IsRegex(s, RegexOptions): se espera una cadena que concuerde con la expresión regular s,
modulada por las opciones que se indican en el segundo parámetro. 8.10.3 Una dependencia más que eliminar y control de propiedades Ahora deseamos diseñar una prueba para la rutina principal: Display. Y nos encontramos con un problema serio. La rutina muestra su salida por la salida estándar. Tendremos que capturar la salida estándar para asegurarnos de que todo está bien: [Test] public void Display_RecibeUrls_MuestraPorPantallaInformeCompleto() { var loaderMock = new Mock(MockBehavior.Strict); loaderMock.Setup(l => l.Load(SomeConstants.url)).Returns(SomeConstants.input); var parserMock = new Mock(MockBehavior.Strict); parserMock.Setup(p => p.Parse(SomeConstants.input)) .Returns(SomeConstants.intermediate); var formatterMock = new Mock(MockBehavior.Strict); formatterMock.Setup(f => f.FormatFeed(SomeConstants.intermediate)) .Returns(SomeConstants.output); var reader = new Rss.Reader(loaderMock.Object, parserMock.Object, formatterMock.Object); var oldOut = System.Console.Out; string result = ""; using(TextWriter tw = new StringWriter()) { System.Console.SetOut(tw); reader.Display(new[] {SomeConstants.url}); result = tw.ToString(); } System.Console.SetOut(oldOut); loaderMock.Verify(l => l.Load(SomeConstants.url), Times.Once()); parserMock.Verify(p => p.Parse(SomeConstants.input), Times.Once()); formatterMock.Verify(f => f.FormatFeed(SomeConstants.intermediate), Times.Once()); Assert.AreEqual(SomeConstants.output, result); }
Capturar la salida estándar no ha resultado trivial, pero hemos dado con un modo de hacerlo. En cualquier caso, estamos ante un nuevo “code smell”. Hay una dependencia demasiado fuerte entre nuestra clase y un objeto concreto del sistema: System.Console. Deberíamos desacoplar el código. ¿Cómo lo hacemos? Vamos a probar una idea nueva. Hagamos que el método Write (que es todo lo que necesitamos de la consola) sea un delegado cuyo valor por defecto es System.Console.Write, pero al que podremos dar otro valor… o valores, pues un delegado admite más de un valor. El delegado será una propiedad: Reader.cs using System.Collections.Generic;
129
Buenas prácticas en desarrollo de software con .NET
using System; namespace Rss { public class Reader { private ILoader _loader; private IParser _parser; private IFormatter _formatter; private IWriter _writer; public Reader(ILoader loader, IParser parser, IFormatter formatter, IWriter writer) { _loader = loader; _parser = parser; _formatter = formatter; _writer = writer; } public string FeedReport(string xml) { var feed = _parser.Parse(xml); var formatted = _formatter.FormatFeed(feed); return formatted; } public void Display(IEnumerable urls) { foreach (var url in urls) { var xml = _loader.Load(url); var result = FeedReport(xml); _writer.Write(result); } } } }
IWriter.cs using System; namespace Rss { public interface IWriter { Action Write { get; } } }
Writer.cs using System; namespace Rss { public class Writer : IWriter { public Writer() { Write += System.Console.Write; } #region IWriter Members
130
Buenas prácticas en desarrollo de software con .NET
public Action Write { get; set; } #endregion } }
Program.cs namespace RssReaderApp { class Program { static void Main(string[] args) { var loader = new Rss.Loader(); var parser = new Rss.Parser(); var formatter = new Rss.Formatter(); var writer = new Rss.Writer(); var reader = new Rss.Reader(loader, parser, formatter, writer); reader.Display(args); } } }
TestReader.cs … [Test] public void Display_RecibeUrls_MuestraPorPantallaInformeCompleto() { var loaderMock = new Mock(MockBehavior.Strict); loaderMock.Setup(l => l.Load(SomeConstants.url)).Returns(SomeConstants.input); var parserMock = new Mock(MockBehavior.Strict); parserMock.Setup(p => p.Parse(SomeConstants.input)) .Returns(SomeConstants.intermediate); var formatterMock = new Mock(MockBehavior.Strict); formatterMock.Setup(f => f.FormatFeed(SomeConstants.intermediate)) .Returns(SomeConstants.output); var sw = new StringWriter(); var writerMock = new Mock(MockBehavior.Strict); writerMock.SetupGet(w => w.Write).Returns(sw.Write); var reader = new Rss.Reader(loaderMock.Object, parserMock.Object, formatterMock.Object, writerMock.Object); reader.Display(new[] {SomeConstants.url}); var result = sw.ToString(); loaderMock.Verify(l => l.Load(SomeConstants.url), Times.Once()); parserMock.Verify(p => p.Parse(SomeConstants.input), Times.Once()); formatterMock.Verify(f => f.FormatFeed(SomeConstants.intermediate), Times.Once()); Assert.AreEqual(SomeConstants.output, result); }
…
131
Buenas prácticas en desarrollo de software con .NET
Podemos mejorar ahora nuestro control de mocks con utilidades para asegurarnos de que se accede a la propiedad, del mismo que nos aseguramos de que se accedía a los métodos: [Test] public void Display_RecibeUrls_MuestraPorPantallaInformeCompleto() { var loaderMock = new Mock(MockBehavior.Strict); loaderMock.Setup(l => l.Load(SomeConstants.url)).Returns(SomeConstants.input); var parserMock = new Mock(MockBehavior.Strict); parserMock.Setup(p => p.Parse(SomeConstants.input)) .Returns(SomeConstants.intermediate); var formatterMock = new Mock(MockBehavior.Strict); formatterMock.Setup(f => f.FormatFeed(SomeConstants.intermediate)) .Returns(SomeConstants.output); var sw = new StringWriter(); var writerMock = new Mock(MockBehavior.Strict); writerMock.SetupGet(w => w.Write).Returns(sw.Write); var reader = new Rss.Reader(loaderMock.Object, parserMock.Object, formatterMock.Object, writerMock.Object); reader.Display(new[] {SomeConstants.url}); var result = sw.ToString(); loaderMock.Verify(l => l.Load(SomeConstants.url), Times.Once()); parserMock.Verify(p => p.Parse(SomeConstants.input), Times.Once()); formatterMock.Verify(f => f.FormatFeed(SomeConstants.intermediate), Times.Once()); writerMock.VerifyGet(w => w.Write, Times.Once()); Assert.AreEqual(SomeConstants.output, result); }
8.11 Revisando la privacidad de las clases Hemos diseñado las clases con mucha alegría: casi todo es público. Hay ciertos métodos y ciertas clases que son instrumentales en el diseño de la librería, pero que el usuario no debería manejar directamente. Si abrimos todo a todo el mundo, la librería aumenta notablemente en dificultad de uso. Pero hay un conflicto latente: queremos que no todo sea público, pero en principio necesitamos que todo sea público para que una librería independiente (la de pruebas unitarias) pueda acceder libremente a los métodos. Vamos a ir cerrando lo que debamos cerrar y ver qué problemas se generan al usar las cosas no públicas. Ha de ser consciente, en cualquier caso, de que hay debate acerca de la idea de que un método privado haya de ser objeto de pruebas unitarias. Pero supongamos que deseamos hacerlo y que cerramos un método: using System; using System.Text; namespace Rss { public class Formatter : IFormatter { private string FormatItem(Item item) { return string.Format("{0}: {1}", item.Date, item.Title); }
132
Buenas prácticas en desarrollo de software con .NET
public string FormatFeed(Feed feed) { var sb = new StringBuilder("*** "); sb.Append(feed.Title); sb.Append(Environment.NewLine); foreach (var item in feed.Items) { sb.Append(this.FormatItem(item)); sb.Append(Environment.NewLine); } return sb.ToString(); } } }
No podemos compilar porque TestFormatter no tiene acceso al método FormatItem. Una posibilidad de superar el problema es usar reflexión: [Test] public void FormatItem_ConItem_DevuelveLineaFormateada() { var expected = "Sun, 27 Mar 2011 18:41:00 +0200: El Palacio de Cibeles abre al público"; // var result = _formatter.FormatItem(_feed.Items.First()); BindingFlags eFlags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; MethodInfo m = typeof(Rss.Formatter).GetMethod("FormatItem", eFlags); var result = (string) m.Invoke(_formatter, new object[] { _feed.Items.First() }); Assert.AreEqual(expected, result); }
Hay una alternativa. En lugar de declarar el método como privado, podemos declararlo como internal, esto es, visible únicamente para las clases del mismo ensamblado. Un usuario de la librería no verá los métodos (o clases) marcados con internal… a menos que le demos permiso: using System; using System.Text; using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("TestRss")] namespace Rss { public class Formatter : IFormatter { internal string FormatItem(Item item) { return string.Format("{0}: {1}", item.Date, item.Title); } public string FormatFeed(Feed feed) { var sb = new StringBuilder("*** "); sb.Append(feed.Title); sb.Append(Environment.NewLine); foreach (var item in feed.Items) { sb.Append(this.FormatItem(item)); sb.Append(Environment.NewLine); } return sb.ToString(); } } }
133
Buenas prácticas en desarrollo de software con .NET
Gracias al atributo InternalsVisibleTo, que marca todo el ensamblado, TestRss puede ver los métodos declarados internal. La versión de TestRss que no hace uso de la reflexión vuelve a ser válida y es mucho más sencilla de programar. La marca internal puede usarse también sobre clases.
8.12 Una refactorización La forma en que hemos diseñado Rss.Reader es mejorable. En su estado actual, el diseño está bajo la influencia de la única aplicación que hace uso de la clase Rss.Reader. En esta, las URL se leen como argumentos de la línea de órdenes y el vector de cadenas args se suministra directamente a Rss.Reader.Display como la enumeración de cadenas que espera. Estaría bien que la colección de URLs se mantuviera en una propiedad de Rss.Reader y que pudiésemos editar esta colección evitando posible URL duplicadas. El método Display dejaría de tener argumentos y recurriría al valor actual de ese parámetro. Reader.cs using System.Collections.Generic; using System; namespace Rss { public class Reader { private ILoader _loader; private IParser _parser; private IFormatter _formatter; private IWriter _writer; public ISet Urls { get; private set; } public Reader(ILoader loader, IParser parser, IFormatter formatter, IWriter writer) { _loader = loader; _parser = parser; _formatter = formatter; _writer = writer; Urls = new HashSet(); } public string FeedReport(string xml) { var feed = _parser.Parse(xml); var formatted = _formatter.FormatFeed(feed); return formatted; } public void Display() { foreach (var url in Urls) { var xml = _loader.Load(url); var result = FeedReport(xml); _writer.Write(result); } } } }
134
Buenas prácticas en desarrollo de software con .NET
(Naturalmente, hemos de modificar las pruebas unitarias.) Es una solución razonable. Pero no la mejor. La lógica de la gestión de las URL nos ha parecido tan sencilla que una simple propiedad bastaba para dar solución a la demanda. ¿Y si esta lógica ha de complicarse más adelante? ¿Y si, por ejemplo, hemos de cargar una serie de URL predefinidas de fichero, o de un servicio web o de quién sabe qué otro origen? ¿Y si, por poner otro ejemplo, hay una política de prohibición de acceso a ciertas URL? Nuestra clase no sabe de esas gestiones más complejas o políticas de lista negra, cuando un buen diseño permitiría inyectarlas. Ese diseño mejorado pasa, nuevamente, por crear una clase con la responsabilidad de gestionar el registro de URLs. IUrlManager.cs using System.Collections.Generic; namespace Rss { public interface IUrlManager : IEnumerable { void Add(string url); } }
UrlManager.cs using System.Collections.Generic; namespace Rss { public class UrlManager : IUrlManager { private readonly ICollection _urls; public UrlManager() { _urls = new HashSet(); } #region IUrlManager Members public void Add(string url) { _urls.Add(url); } #endregion #region IEnumerable Members public IEnumerator GetEnumerator() { return _urls.GetEnumerator(); } #endregion #region IEnumerable Members System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() {
135
Buenas prácticas en desarrollo de software con .NET
return GetEnumerator(); } #endregion } }
Reader.cs using System.Collections.Generic; using System; namespace Rss { public class Reader { private ILoader _loader; private IParser _parser; private IFormatter _formatter; private IWriter _writer; private IUrlManager _urlManager; public Reader(ILoader loader, IParser parser, IFormatter formatter, IWriter writer, IUrlManager urlManager) { _loader = loader; _parser = parser; _formatter = formatter; _writer = writer; _urlManager = urlManager; } public string FeedReport(string xml) { var feed = _parser.Parse(xml); var formatted = _formatter.FormatFeed(feed); return formatted; } public void Display() { foreach (var url in _urlManager) { var xml = _loader.Load(url); var result = FeedReport(xml); _writer.Write(result); } } } }
AppRssReader.cs namespace RssReaderApp { class Program { static void Main(string[] args) { var loader = new Rss.Loader(); var parser = new Rss. Parser(); var formatter = new Rss.Formatter(); var writer = new Rss.Writer(); var urlManager = new Rss.UrlManager(); foreach (var arg in args)
136
Buenas prácticas en desarrollo de software con .NET
{ urlManager.Add(arg); } var reader = new Rss.Reader(loader, parser, formatter, writer, urlManager); reader.Display(); System.Console.ReadKey(); } } }
También los test se ven afectados, y ya contienen una zona común que podemos refactorizar definiendo una rutina SetUp: TestReader.cs … [TestFixture] public class TestReader { private Mock _loaderMock; private Mock _parserMock; private Mock _formatterMock; private Mock _writerMock; private Mock _urlManagerMock; private Rss.Reader _reader; [SetUp] public void Prepara() { _loaderMock = new Mock(MockBehavior.Strict); _loaderMock.Setup(l => l.Load(SomeConstants.url)) .Returns(SomeConstants.input); _parserMock = new Mock(MockBehavior.Strict); _parserMock.Setup(p => p.Parse(It.IsAny())) .Returns(SomeConstants.intermediate);
_formatterMock = new Mock(MockBehavior.Strict); _formatterMock.Setup(f => f.FormatFeed(SomeConstants.intermediate)) .Returns(SomeConstants.output); _writerMock = new Mock(MockBehavior.Strict); _writerMock.SetupGet(w => w.Write); _urlManagerMock = new Mock(MockBehavior.Strict); _urlManagerMock.Setup(u => u.Add(SomeConstants.url)); _urlManagerMock.Setup(u => u.GetEnumerator()) .Returns(Enumerable.Repeat(SomeConstants.url, 1).GetEnumerator()); _reader = new Rss.Reader(_loaderMock.Object, _parserMock.Object, _formatterMock.Object, _writerMock.Object, _urlManagerMock.Object); } [Test] public void FeedReport_RecibeXmlBienFormado_DevuelveInformeCompleto() { var result = _reader.FeedReport(SomeConstants.input); _loaderMock.Verify(l => l.Load(SomeConstants.url), Times.Never()); _parserMock.Verify(p => p.Parse(It.IsAny()), Times.Once()); _formatterMock.Verify(f => f.FormatFeed(SomeConstants.intermediate), Times.Once()); _urlManagerMock.Verify(u => u.GetEnumerator(), Times.Never());
137
Buenas prácticas en desarrollo de software con .NET
Assert.AreEqual(SomeConstants.output, result); } [Test] public void Display_RecibeUrls_MuestraPorPantallaInformeCompleto() { var sw = new StringWriter(); _writerMock.SetupGet(w => w.Write).Returns(sw.Write); _reader.Display(); var result = sw.ToString(); _loaderMock.Verify(l => l.Load(SomeConstants.url), Times.Once()); _parserMock.Verify(p => p.Parse(SomeConstants.input), Times.Once()); _formatterMock.Verify(f => f.FormatFeed(SomeConstants.intermediate), Times.Once()); _writerMock.VerifyGet(w => w.Write, Times.Once()); _urlManagerMock.Verify(u => u.GetEnumerator(), Times.Once()); Assert.AreEqual(SomeConstants.output, result); } }
8.13 Más sobre Moq No podemos poner en práctica todo lo que Moq ofrece con este ejemplo sin forzar excesivamente la máquina. No obstante, conviene que mostremos algunas de las posibilidades que ofrece más allá de las que hemos plasmado en el ejemplo. Las que mostramos y comentamos con cierto detalle se han seleccionado de entra las que se recogen en el documento http://code.google.com/p/moq/wiki/QuickStart. 8.13.1 Acceso a argumentos en el valor de retorno Podemos acceder a los propios argumentos del método cuando preparamos el resultado de una llamada a método impostada: mock.Setup(x => x.DoSomething(It.IsAny())).Returns((string s) => s.ToLower());
El valor de s es el del parámetro del método DoSomething. Especificamos que no importa la cadena que nos suministren, devolvemos la misma cadena en su versión con minúsculas. 8.13.2 Lanzamiento de excepciones cuando se llama a la función Es posible hacer que se lance una función cuando se llama a un método. Hay dos formas de especificar este comportamiento: mock.Setup(foo => foo.DoSomething("reset")).Throws(); mock.Setup(foo => foo.DoSomething("")).Throws(new ArgumentException("command");
8.13.3 Evaluación perezosa del valor de retorno Hay una diferencia importante entre estas dos especificaciones de comportamiento: mock.Setup(foo => foo.GetCount()).Returns(count); mock.Setup(foo => foo.GetCount()).Returns(() => count);
En la primera, la llamada a GetCount devolverá el valor que tenía la variable count en el momento en el que se definió el comportamiento. En la segunda, se devolverá el valor que tenga en el momento de la ejecución de la llamada a GetCount sobre el stub. En la segunda llamada se usa una lambda-función que crea una clausura que incorpora a la variable count, por lo que se puede acceder a su valor en cualquier momento.
138
Buenas prácticas en desarrollo de software con .NET
8.13.4 Retrollamadas (callbacks) Con Callback podemos invocar una función antes o después de una llamada a método. En este ejemplo, se llamará a una función que incremente el valor de una variable calls (atrapada en la clausura) tras impostar la llamada a Execute: var mock = new Mock(); mock.Setup(foo => foo.Execute("ping")) .Returns(true) .Callback(() => calls++);
La retrollamada puede usar como argumentos los propios de la función tras la que se invoca. En este ejemplo, s es la cadena que se suministra a Execute: mock.Setup(foo => foo.Execute(It.IsAny())) .Returns(true) .Callback((string s) => calls.Add(s));
Si la función tuviera más de un parámetro, se podría invocar así: mock.Setup(foo => foo.Execute(It.IsAny(), It.IsAny())) .Returns(true) .Callback((i, s) => calls.Add(s));
Se puede poner una retrollamada antes de la invocación y otra después: mock.Setup(foo => foo.Execute("ping")) .Callback(() => Console.WriteLine("Before returns")) .Returns(true) .Callback(() => Console.WriteLine("After returns"));
8.13.5 Devolución de valores diferentes para diferentes invocaciones a un método con los mismos argumentos Si hemos definido un valor de retorno con evaluación perezosa, esto es, con una clausura, la llamada a la función puede cambiar el valor de las variables atrapadas en la clausura, proporcionando así valores distintos con cada llamada: var mock = new Mock(); var calls = 0; mock.Setup(foo => foo.GetCountThing()) .Returns(() => calls) .Callback(() => calls++); Console.WriteLine(mock.Object.GetCountThing());
En este ejemplo, la primera llamada devuelve el valor 0 y la segunda devuelve el valor 1. 8.13.6 Acceso y asignación de propiedades Se puede controlar el acceso a una propiedad e indicar el valor de retorno: mock.Setup(foo => foo.Name).Returns("bar");
Y se puede declarar la expectativa de que se asigne un cierto valor a una propiedad: mock.SetupSet(foo => foo.Name = "foo");
139
Buenas prácticas en desarrollo de software con .NET
8.13.7 Indicar que una propiedad se comporte como un stub Las propiedades del stub de un mock no funcionan como tales propiedades. Al definir comportamientos decimos que esperamos que se acceda y definimos el valor que debe proporcionar el acceso, o definimos la expectativa de una asignación (como hemos visto en el apartado anterior), pero las propiedades en sí no memorizan nada. Si queremos que la propiedad funcione como tal, hemos de indicarlo explícitamente: mock.SetupProperty(f => f.Name); mock.SetupProperty(f => f.Name, "foo"); IFoo foo = mock.Object; Assert.Equal("foo", foo.Name); foo.Name = "bar"; Assert.Equal("bar", foo.Name);
La primera línea indica que mock.Object.Name debe comportarse como una propiedad de verdad. La segunda línea asigna, además, un valor por defecto a la propiedad. El primer aserto funciona precisamente porque la propiedad tiene el valor por defecto. El segundo aserto funciona porque la asignación de la penúltima línea ha sido efectiva. 8.13.8 Indicar que todas las propiedades deben comportase como un stub mock.SetupAllProperties();
8.13.9 Lanzamiento de eventos Si una interfaz define un evento: public delegate void MyEventHandler(int i, bool b); public interface IFoo { event MyEventHandler MyEvent; }
Es posible invocar el evento a voluntad con una sintaxis un tanto especial. var mock = new Mock(); mock.Raise(foo => foo.MyEvent += null, 25, true);
8.13.10
Verificación de que se ha accedido a una propiedad
mock.VerifyGet(foo => foo.Name);
8.13.11 Verificación de que se ha asignado valor a una propiedad Si no importa el valor asignado: mock.VerifySet(foo => foo.Name);
Si deseamos asegurarnos de que el valor es uno determinado: mock.VerifySet(foo => foo.Name = "foo");
Si deseamos asegurarnos de que el valor cumple cierta condición: mock.VerifySet(foo => foo.Value = It.IsInRange(1, 5, Range.Inclusive));
140
Buenas prácticas en desarrollo de software con .NET
8.14 Antes de acabar No hemos acabado aún nuestra aplicación. Trataremos algunas cuestiones de extensión de la funcionalidad y las aplicaremos a nuestra aplicación, pero lo haremos en el marco de una explicación de la inyección de dependencias. Ye hemos alcanzado un diseño flexible, que hace utilizable nuestra clase Rss.Reader en diferentes escenarios. Y esa flexibilidad empezará a compensar ahora mismo.
8.15 Un hecho (no tan) inesperado Nos enfrentamos a un descubrimiento de última hora. No hay un solo formato de uso común en la publicación de contenidos RSS. Hay dos estándares de uso común:
RSS 2.0, del que es ejemplo el texto XML que hemos venido usando en los ejemplos. Atom 1.0, que también es texto XML, pero que sigue una especificación distinta que se puede consultar en http://www.atomenabled.org/developers/syndication/atom-format-spec.php.
Para saber más, conviene acudir a la Wikipedia: http://en.wikipedia.org/wiki/Atom_%28standard%29. ¿Cómo nos enfrentamos a este problema? Desde luego, mucho mejor con una librería flexible y desacoplada como la nuestra que con la clase Dios que habíamos empezado a diseñar al principio. Empezaremos renombrando nuestro Parser y haciendo que se llame Rss2Parser. Las herramientas de refactorización hacen que esto resulte trivial. Crearemos a continuación una clase Atom1Parser: using System.IO; using System.Linq; using System.Xml.Linq; namespace Rss { public class Atom1Parser : IParser { public Feed Parse(string xml) { XDocument xdoc = XDocument.Load(new StringReader(xml)); XNamespace ns = "http://www.w3.org/2005/Atom"; var feed = new Feed( title: xdoc.Element(ns + "feed").Element(ns + "title").Value, items: from elt in xdoc.Element(ns + "feed").Elements(ns + "entry") select new Item( date: elt.Element(ns + "updated").Value, title: elt.Element(ns + "title").Value )); return feed; } } }
No olvidemos las pruebas unitarias para esta clase (usando como código de ejemplo de datos Atom 1.0 el que aparece en la página de la Wikipedia): using using using using using using
Buenas prácticas en desarrollo de software con .NET
namespace TestRss { [TestFixture] public class TestAtom1Parser { private Rss.Atom1Parser _parser; [SetUp] public void PreparaTestParser() { _parser = new Atom1Parser(); }
[Test] public void Parse_ConWellFormedXml_ReturnsFeed() { var xml = @" Example FeedA subtitle.urn:uuid:60a76c80-d399-11d9-b91C-0003939e0af62003-12-13T18:30:02ZJohn Doe[email protected]Atom-Powered Robots Run Amokurn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a2003-12-13T18:30:02ZSome text. "; var expected = new Rss.Feed( title: "Example Feed", items: new List { new Rss.Item( date: "2003-12-13T18:30:02Z", title: "Atom-Powered Robots Run Amok" ), }); var result = _parser.Parse(xml); Assert.AreEqual(expected, result); } } }
Ejecutamos las pruebas y… verde. Bueno. Ahora tenemos un problema. ¿Qué analizador escogemos en nuestro lector? Tenemos un analizador de Rss 2.0 y otro de Atom 1.0. Puedo seleccionar uno e inyectarlo en el lector cuando se construye, pero entonces nuestro lector sólo sabrá tratar con uno de los dos formatos.
142
Buenas prácticas en desarrollo de software con .NET
Una solución es hacer un analizador que pueda trabajar con los dos formatos: Rss2AndAtom1Parser.cs using System.Xml.Linq; using System.IO; namespace Rss { public class Rss2AndAtom1Parser : IParser { private Atom1Parser _atom1Parser; private Rss2Parser _rss2Parser; public Rss2AndAtom1Parser() { _atom1Parser = new Atom1Parser(); _rss2Parser = new Rss2Parser(); } #region IParser Members public Feed Parse(string xml) { XDocument xdoc = XDocument.Load(new StringReader(xml)); XNamespace ns = "http://www.w3.org/2005/Atom"; if (xdoc.Element(ns + "feed") != null) { return _atom1Parser.Parse(xml); } else { return _rss2Parser.Parse(xml); } } #endregion } }
Nuestra aplicación dispone ahora de una herramienta de análisis más potente. Hagamos que la use inyectándola al lector: namespace RssReaderApp { class Program { static void Main(string[] args) { var loader = new Rss.Loader(); var parser = new Rss.Rss2AndAtom1Parser(); var formatter = new Rss.Formatter(); var writer = new Rss.Writer(); var urlManager = new Rss.UrlManager(); foreach (var arg in args) { urlManager.Add(arg); } var reader = new Rss.Reader(loader, parser, formatter, writer, urlManager); reader.Display(); System.Console.ReadKey(); } } }
143
Buenas prácticas en desarrollo de software con .NET
El esfuerzo en el desarrollo de la aplicación ha sido considerable, pero es ahora cuando empieza a compensar:
La inyección de dependencias ha facilitado ahora la sustitución de unos componentes por otros, lo que aumenta la capacidad de adaptación de la librería a nuevos escenarios. Los cambios son más seguros si disponemos de una batería de pruebas unitarias que nos aseguran que no hemos roto nada que ya estuviera funcionando (y eso que el desarrollo presentado no ha creado todas las pruebas unitarias que debiésemos haber creado, pues queríamos centrarnos en otros aspectos del desarrollo).
9 Configuración de aplicación vía fichero de configuración Uno de los problemas de codificar todo lo relativo a la aplicación en el código que compilamos es que introducimos rigideces en nuestro diseño. Si un lector de RSS cablea en el código las URL de las fuentes RSS (o algunas URL de fuentes RSS) a las que accede, el código depende de ellas. Si más adelante hubiese que cambiar alguna de las fuentes RSS, tendríamos que volver a editar el código fuente, compilar la aplicación y desplegarla nuevamente. Hay más datos de una aplicación que pueden cambiar y plantean el mismo tipo de problemas: la conexión a una base de datos, el nivel de traza en el registro de actividad con el que deseamos ejecutar una aplicación, etc. Hay una solución obvia a este problema: registrar los datos de configuración que pueden cambiar en algún fichero que podamos editar una vez se ha desplegado la aplicación. Esto introduce dos problemas de estandarización:
El convenio que hemos de adoptar para que se sepa dónde se registran esos datos, que debería basarse en algún estándar. El formato del fichero, que acabará siendo algún lenguaje específico de dominio que habrá que diseñar y documentar y para el que habrá que diseñar un analizador.
En .NET el problema se proporciona con una solución “de serie”:
Hay un lugar estándar para almacenar los datos: el fichero de configuración de la aplicación app.config. (Hace tiempo Windows usaba ficheros con extensión .ini o el registro del sistema. Acceder al registro planteaba problemas de seguridad y en algunos sistemas el administrador impedía el acceso a este recurso, por lo que no es una práctica recomendable.) No hemos de definir un lenguaje propio, pues el fichero usa XML y un repertorio de marcas predefinidas.
En este bloque aprenderemos a usar ficheros de configuración.
9.1 Creación de un fichero App.Config Cuando creamos una aplicación, no se crea automáticamente un fichero App.config. Hemos de ir al proyecto y crearlo manualmente. Vamos al proyecto RssReaderApp y añadamos un fichero de configuración propio. En el menú contextual del proyecto RssReaderApp seleccionamos Add… New Item…Application Configuration File y damos al fichero el nombre App.config:
144
Buenas prácticas en desarrollo de software con .NET
El fichero presenta este aspecto inicial:
Vamos a usar el fichero de configuración para dos cosas:
Precargar una (o más) URL aparte de las que se suministren por la línea de órdenes. Seleccionar uno de los tres lectores RSS de que disponemos.
Accedemos a App.config a través de la clase estática System.Configuration.ConfigurationManager . En la primera versión de .NET el fichero de configuración se usaba como poco más que un gran almacén de pares clave-valor, donde el valor era una simple cadena. Desde .NET 2.0, es posible crear secciones y tipar la información.
9.2 Secciones definidas por el usuario 9.2.1 Definición de una sección propia con atributos de usuario Vamos a definir una sección propia para el fichero de configuración. Nuestro objetivo es poder definir en el fichero app.config el valor de la URL por defecto para el lector de fuentes RSS así: … …
Una marca señalará una sección del fichero de configuración app.config con la configuración de usuario que nos interesa. En ella, el atributo defaultUrl permitirá indicar una URL. Empezamos creando una clase RssReaderAppConfigurationSection que hereda de la clase System.Configuration.ConfigurationSection, con lo que estaremos definiendo una sección en nuestro fichero de configuración:
145
Buenas prácticas en desarrollo de software con .NET
using System.Configuration; namespace RssReaderApp { public class RssReaderAppConfigurationSection : ConfigurationSection { private static ConfigurationProperty defaultUrl; private static ConfigurationPropertyCollection properties; public string DefaultUrl { get { return (string)base[defaultUrl]; } } protected override ConfigurationPropertyCollection Properties { get { return properties; } } public RssReaderAppConfigurationSection() { defaultUrl = new ConfigurationProperty("defaultUrl", typeof(string), null, ConfigurationPropertyOptions.IsRequired); properties = new ConfigurationPropertyCollection(); properties.Add(defaultUrl); } } }
En la clase definimos una propiedad DefaultUrl que no es más que una pasarela de acceso a una ConfigurationProperty. Cada atributo XML que definamos (en nuestro caso sólo uno: defaultUrl) será una ConfigurationProperty. Es obligatorio definir una colección con todas las ConfigurationProperty que hemos definido. Para ello recurrimos a una propiedad de tipo ConfigurationPropertyCollection , que no hace más que recoger en una estructura de datos (mediante invocaciones al método Add) todas la ConfigurationProperty que hemos creado (en este caso, y por el momento, sólo una). Veamos un fichero de configuración de la aplicación que hace uso de esta sección:
Nótese que hay un prólogo encerrado en la marca en el que declaramos que la marca debe interpretarse a partir del tipo RssReaderApp.RssReaderAppConfigurationSection que hemos definido en el ensamblado RssReaderApp (que es el de nuestra aplicación). Por defecto, las propiedades de configuración que hemos definido son atributos del elemento de la sección que indicaremos con un elemento rssReader. Sólo hemos definido por el momento un atributo: defaultUrl.
146
Buenas prácticas en desarrollo de software con .NET
Y ahora, veamos un ejemplo de aplicación que carga el contenido del fichero app.config: using System.Configuration; namespace RssReaderApp { class Program { static void Main(string[] args) { var config = (RssReaderAppConfigurationSection) ConfigurationManager.GetSection("rssReader"); var var var var var
loader = new Rss.Loader(); parser = new Rss.Rss2AndAtom1Parser(); formatter = new Rss.Formatter(); writer = new Rss.Writer(); urlManager = new Rss.UrlManager();
string defaultUrl = config.DefaultUrl; urlManager.Add(defaultUrl); foreach (var arg in args) { urlManager.Add(arg); } var reader = new Rss.Reader(loader, parser, formatter, writer, urlManager); reader.Display(); System.Console.ReadKey(); } } }
La primera línea de la rutina instancia un objeto de la clase que hemos definido: RssReaderAppConfigurationSection. El objeto ya viene “cargado” con los datos del fichero de configuración, así que podemos consultar directamente su atributo DefaultUrl. 9.2.2 Un atributo con tipo definido por el usuario El atributo XML defaultUrl contiene un valor de un tipo estándar y la propiedad DefaultUrl de la nuestra clase de sección de configuración coincide en ser de ese mismo tipo: una simple cadena que se interpreta como una URL. En ocasiones necesitaremos especificar valores de usuario más complejos, y eso pasará por un proceso de interpretación de la cadena más sofisticado. Puede que el valor que desee leer el usuario deba expresarse con un tipo de datos más elaborado que una cadena, o incluso con un tipo de datos definido por el usuario. Vamos a añadir un segundo atributo para definir el tipo de IParser que queremos conectar a nuestro lector. Nótese que queremos especificar un tipo, pero en un fichero XML como app.config, que no es más que un fichero de texto, no hay más remedio que codificarlo con una cadena. En el fichero se escribirá algo así como:
Pero deseamos que nuestra aplicación encuentre el trabajo de interpretación de la cadena "Rss.Rss2Parser, Rss" ya hecho y lea el valor del atributo como lo que representa: un tipo. using System.Configuration;
147
Buenas prácticas en desarrollo de software con .NET
namespace RssReaderApp { public class RssReaderAppConfigurationSection : ConfigurationSection { private static ConfigurationPropertyCollection properties; private static ConfigurationProperty defaultUrl; private static ConfigurationProperty parser; public string DefaultUrl { get { return (string)base[defaultUrl]; } } public System.Type Parser { get { System.Type type = System.Type.GetType((string)base[parser]); if (type.GetInterface("Rss.IParser") != null) { return type; } else { throw new System.ArgumentException(string.Format("{0} is not an Rss.IParser", base[parser])); } } } public RssReaderAppConfigurationSection() { defaultUrl = new ConfigurationProperty("defaultUrl", typeof(string), null, ConfigurationPropertyOptions.IsRequired); parser = new ConfigurationProperty("parser", typeof(string), null, ConfigurationPropertyOptions.IsRequired); properties = new ConfigurationPropertyCollection(); properties.Add(defaultUrl); properties.Add(parser); } protected override ConfigurationPropertyCollection Properties { get { return properties; } } } }
El fichero de configuración pasa a tener este aspecto:
148
Buenas prácticas en desarrollo de software con .NET
El nuevo atributo XML, parser, tiene como valor una cadena con el nombre completo de un tipo y, separado por una coma, el nombre del ensamblado en el que reside el tipo. Es el formato que necesita el método System.Type.GetType para generar el tipo a partir de una cadena. La propiedad Parser, en su método get, hace la transformación. Veamos cómo usar el valor en nuestra aplicación: using System.Configuration; using System; namespace RssReaderApp { class Program { static void Main(string[] args) { var config = (RssReaderAppConfigurationSection) ConfigurationManager.GetSection("rssReader"); var var var var var
loader = new Rss.Loader(); parser = (Rss.IParser)Activator.CreateInstance(config.Parser); formatter = new Rss.Formatter(); writer = new Rss.Writer(); urlManager = new Rss.UrlManager();
string defaultUrl = config.DefaultUrl; urlManager.Add(defaultUrl); foreach (var arg in args) { urlManager.Add(arg); } var reader = new Rss.Reader(loader, parser, formatter, writer, urlManager); reader.Display(); System.Console.ReadKey(); } } }
Hemos usado la factoría Activator.CreateInstance para construir una instancia del tipo. El valor de config.Parse es de tipo System.Type. Tenemos la seguridad de que el tipo implementa la interfaz Rss.IParser porque nuestro intérprete del fichero de configuración se ha asegurado de ello. 9.2.3 Secciones con elementos anidados Los atributos de una marca XML son muy limitados para expresar valores complejos y de momento sólo sabemos crear este tipo de componentes en nuestros ficheros de configuración. Vamos a empezar ahora convirtiendo el atributo defaultUrl en un elemento anidado con un atributo value. Es decir, podremos especificar una URL así:
Más tarde haremos que ese nuevo elemento XML permita la especificación de una colección de valores, en lugar de permitir expresar un solo valor. using System.Configuration; namespace RssReaderApp
149
Buenas prácticas en desarrollo de software con .NET
{ public class UrlElement : ConfigurationElement { private static ConfigurationPropertyCollection properties; private static ConfigurationProperty value; public string Value { get { return (string)base[value]; } } public UrlElement() { value = new ConfigurationProperty("value", typeof(string), null, ConfigurationPropertyOptions.IsRequired); properties = new ConfigurationPropertyCollection { value }; } protected override ConfigurationPropertyCollection Properties { get { return properties; } } } public class RssReaderAppConfigurationSection : ConfigurationSection { private static ConfigurationPropertyCollection properties; private static ConfigurationProperty parser; private static ConfigurationProperty url; public System.Type Parser { get { System.Type type = System.Type.GetType((string)base[parser]); if (type.GetInterface("Rss.IParser") != null) { return type; } else { throw new System.ArgumentException(string.Format("{0} is not an Rss.IParser", base[parser])); } } } public UrlElement Url { get { return (UrlElement) base[url]; } } public RssReaderAppConfigurationSection() { parser = new ConfigurationProperty("parser", typeof(string), null, ConfigurationPropertyOptions.IsRequired); url = new ConfigurationProperty("url", typeof(UrlElement), null, ConfigurationPropertyOptions.IsRequired); properties = new ConfigurationPropertyCollection(); properties.Add(url); properties.Add(parser); } protected override ConfigurationPropertyCollection Properties { get { return properties; }
150
Buenas prácticas en desarrollo de software con .NET
} } }
Un fichero de configuración válido quedaría ahora así:
Vale la pena advertir que en RssReaderAppConfigurationSection no hay demasiada diferencia entre la definición de un atributo XML y un elemento XML. La diferencia es que la propiedad Url devuelve un objeto de tipo UrlElement, que es un objeto de tipo ConfigurationElement. La forma de acceder al valor de la URL tiene ahora dos niveles: using System.Configuration; using System; namespace RssReaderApp { class Program { static void Main(string[] args) { var config = (RssReaderAppConfigurationSection) ConfigurationManager.GetSection("rssReader"); var loader = new Rss.Loader(); var parser = (Rss.IParser)Activator.CreateInstance(config.Parser); var formatter = new Rss.Formatter(); var writer = new Rss.Writer(); var urlManager = new Rss.UrlManager(); string defaultUrl = config.Url.Value; urlManager.Add(defaultUrl); foreach (var arg in args) { urlManager.Add(arg); } var reader = new Rss.Reader(loader, parser, formatter, writer, urlManager); reader.Display(); System.Console.ReadKey(); } } }
9.2.4 Secciones con elementos de tipo colección No tenemos por qué limitarnos a una sola URL definida en el fichero de configuración. Para poner varias podríamos utilizar algún truco, como diseñar un formato para el valor que se proporciona en el atributo del elemento (separando, por ejemplo, diferentes URLs con comas). Es decir, podríamos usr un separador, como el punto y coma, para expresar así dos URL:
151
Buenas prácticas en desarrollo de software con .NET
Pero hay una forma más elegante: definir el elemento como una colección de valores y usar un elemento XML para cada URL. Así:
Existe un tipo de colección de valores predefinido que se puede comportar como una lista de elementos o como un diccionario en el que uno de los atributos de cada elemento se comporta como clave. En el código que mostramos ilustramos los dos posibles comportamientos, aunque de un modo forzado, pues usamos el propio valor como clave. Aunque forzado, el ejemplo permite una extensión inmediata al modelo de diccionario. using System.Configuration; namespace RssReaderApp { public class UrlElement : ConfigurationElement { private static ConfigurationPropertyCollection properties; private static ConfigurationProperty value; public string Value { get { return (string)base[value]; } } public UrlElement() { value = new ConfigurationProperty("value", typeof(string), null, ConfigurationPropertyOptions.IsRequired); properties = new ConfigurationPropertyCollection { value }; } protected override ConfigurationPropertyCollection Properties { get { return properties; } } } [ConfigurationCollection(typeof(UrlElement), CollectionType=ConfigurationElementCollectionType.AddRemoveClearMap)] public class UrlElementCollection : ConfigurationElementCollection { private static ConfigurationPropertyCollection properties; static UrlElementCollection()
152
Buenas prácticas en desarrollo de software con .NET
{ properties = new ConfigurationPropertyCollection(); } public UrlElementCollection() { } protected override ConfigurationPropertyCollection Properties { get { return properties; } } public override ConfigurationElementCollectionType CollectionType { get { return ConfigurationElementCollectionType.AddRemoveClearMap; } } public UrlElement this[int index] { get { return (UrlElement)base.BaseGet(index); } set { if (base.BaseGet(index) != null) { base.BaseRemoveAt(index); } base.BaseAdd(index, value); } } public UrlElement this[string name] { get { return (UrlElement)base.BaseGet(name); } } protected override ConfigurationElement CreateNewElement() { return new UrlElement(); } protected override object GetElementKey(ConfigurationElement element) { return (element as UrlElement).Value; } } public class RssReaderAppConfigurationSection : ConfigurationSection { private static ConfigurationPropertyCollection properties; private static ConfigurationProperty parser; private static ConfigurationProperty url; public System.Type Parser { get { System.Type type = System.Type.GetType((string)base[parser]); if (type.GetInterface("Rss.IParser") != null) { return type; } else { throw new System.ArgumentException(string.Format("{0} is not an Rss.IParser", base[parser]));
153
Buenas prácticas en desarrollo de software con .NET
} } } public UrlElementCollection Url { get { return (UrlElementCollection)base[url]; } } public RssReaderAppConfigurationSection() { parser = new ConfigurationProperty("parser", typeof(string), null, ConfigurationPropertyOptions.IsRequired); url = new ConfigurationProperty("url", typeof(UrlElementCollection), null, ConfigurationPropertyOptions.IsRequired); properties = new ConfigurationPropertyCollection(); properties.Add(url); properties.Add(parser); } protected override ConfigurationPropertyCollection Properties { get { return properties; } } } }
Y el código que hace uso de la configuración: using System.Configuration; using System; namespace RssReaderApp { class Program { static void Main(string[] args) { var config = (RssReaderAppConfigurationSection) ConfigurationManager.GetSection("rssReader"); var var var var
loader = new Rss.Loader(); parser = (Rss.IParser)Activator.CreateInstance(config.Parser); formatter = new Rss.Formatter(); writer = new Rss.Writer();
var urlManager = new Rss.UrlManager(); foreach (UrlElement url in config.Url) { urlManager.Add(url.Value); } foreach (var arg in args) { urlManager.Add(arg); } var reader = new Rss.Reader(loader, parser, formatter, writer, urlManager); reader.Display(); System.Console.ReadKey(); } } }
154
Buenas prácticas en desarrollo de software con .NET
Es posible ir más allá en la definición de ficheros de configuración, definiendo grupos de secciones o colecciones más potentes. Pero lo estudiado permite crear modelos razonablemente potentes y resulta suficiente para la mayoría de las aplicaciones. Por otra parte, hemos desvelado parte de la magia que veremos en uso en temas posteriores, y ese es uno de los objetivos: que entendamos las bases sobre las que se construyen las herramientas que nos han de acompañar en el desarrollo de software.
10 Registro de actividad Durante el proceso de diseño e implementación del software tenemos gran control sobre la ejecución del código. Podemos someterlo a pruebas y, ante un fallo, podemos ejecutar la aplicación con un depurador para trazar el origen de los problemas y corregirlos. Al depurar, una práctica habitual consiste en imprimir por consola mensajes con, posiblemente, el valor de ciertas variables. En explotación contamos con menos posibilidades, a menos que no nos importe desplegar software que lanza constantemente mensajes por consola. Una de las prácticas que deben considerarse es el uso de sistema de registro de actividad o, como se dice en inglés, un logger. Los sistemas de registro de actividades (o de “logging”) guardan información sobre los puntos relevantes por los que pasa un programa y, posiblemente, también información de estado al pasar por ellos. Idealmente debería ser posible saber en qué punto (aproximado) de ejecución se encuentra un programa examinando los volcados del registro de actividad. El logging no sólo vale para sistemas en explotación. También pueden ser de gran ayuda en desarrollo: ayuda a localizar puntos problemáticos y orientar rápidamente el esfuerzo de depuración. Existen varios módulos de logging (como Microsoft Logging Application Block o LucidLog.Net) y, ciertamente, construir uno propio no es excesivamente complejo si se desea una funcionalidad básica. El estándar de facto es Log4Net, un desarrollo inspirado en una librería de logging para Java (Log4J). Log4Net es código abierto mantenido por Apache Software Foundation. Antes de hablar de Log4Net, veamos por encima una forma más primitiva de mostrar mensajes sólo en depuración o traza de nuestro software.
10.1 System.Diagnostics El espacio de nombres System.Diagnostics, estándar en .NET e integrado en las librerías básicas, ofrece dos clases útiles para mostrar mensajes al ejecutar un programa en modo DEBUG o TRACE. La clase estática Debug (que básicamente es igual que Trace) ofrece unos métodos útiles:
Assert(expresión booleana): detiene la ejecución si el valor booleano es falso. Write(mensaje) y WriteLine(mensaje): muestra por pantalla un mensaje, sin o con un salto de
línea al final. WriteIf y WriteIf(expresión booleana, mensaje) : muestra por pantalla el mensaje si y sólo si el valor del primer argumento es cierto. Indent()/Unindent(): añade/elimina sangrado a la salida.
155
Buenas prácticas en desarrollo de software con .NET
La gracia está en que estas funciones sólo son efectivas si el programa se ejecuta con la variable pragma DEBUG definida, es decir, cuando estamos desarrollando. Si compilamos para Release, las llamadas a esos métodos no generan código. La clase Debug (y Trace) permite añadir “escuchadores”, es decir, delegados que se invocan con cada evento. A través suyo podemos registrar los eventos en una base de datos, en un fichero, etc. He aquí un ejemplo de uso: System.Diagnostics.Debug.Assert(true, "Un mensaje", "Este mensaje se muestra en la ventana de depuración."); System.Diagnostics.Debug.Indent(); System.Diagnostics.Debug.WriteLine("Un número: {0}", 3); System.Diagnostics.Debug.Unindent(); System.Diagnostics.Debug.WriteLineIf(10 % 2 == 0, "Número par.");
Veremos cómo los sistemas de logging son más potentes, igual de flexibles o más, y vienen con las pilas puestas.
10.2 Instalación de Log4Net Log4Net se distribuye como un paquete zip que podemos descargar del sitio de Apache Software Foundation, concretamente de http://logging.apache.org/log4net/download.html. Log4Net está aún en fase de incubación (aunque ya lleva años en desarrollo), así que el paquete que bajamos empieza por “incubating”. A fecha de hoy, la última versión es incubating-log4net-1.2.10.zip. El paquete contiene los fuentes, la documentación, pruebas unitarias, algunos ejemplos y binarios para diferentes plataformas (Mono, .NET, .NET Compact Framework, etc.). Una vez hayamos descomprimido el paquete tendremos acceso a la librería en formato DLL log4net-1.2.10\bin\net\2.0\release\log4net.dll. Ese es el ensamblado que hemos de añadir a las referencias de los proyectos que usen Log4Net.6
10.3 Lo básico Hay cinco niveles de importancia en las actividades que podemos registrar y, aunque no hay ninguna obligación de cada una represente algo específico, si suele asociarse por convenio cierta semántica a cada nivel:
FATAL
Se ha detectado un acontecimiento que debería detener la ejecución del software.
6
Pero si hacemos esto, sin más, es probable que tengamos problemas. Ante una aplicación de consola, por ejemplo, al compilar obtendremos un mensaje (Warning) como éste: The referenced assembly "log4net" could not be resolved because it has a dependency on "System.Web, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a" which is not in the currently targeted framework ".NETFramework,Version=v4.0,Profile=Client". Please remove references to assemblies not in the targeted framework or consider retargeting your project.
Para eliminar el problema hemos de ir a Properties en el menú contextual de la aplicación y cambiar el Target, que pasará de .NET Framework 4 Client Profile a .NET Framework 4. Es un problema frecuente al usar librerías que se han compilado con ese Target.
156
Buenas prácticas en desarrollo de software con .NET
ERROR
Se ha detectado un acontecimiento que no debería ocurrir, por lo que el software debería entrar en un proceso de recuperación.
WARN
Se ha detectado un acontecimiento anómalo, pero que no tiene impacto negativo en el funcionamiento de la aplicación, y se deja constancia.
INFO
Se ha detectado un acontecimiento del que se desea dejar constancia (un usuario se registró en el sistema, se completó con éxito una transacción, etc.).
DEBUG
Se ha detectado un acontecimiento que interesa controlar únicamente mientras se desarrolla el sistema y, posiblemente, se esté en fase de depuración del código. Al llamar al logger, lo haremos indicando el nivel de importancia. La configuración de la aplicación puede determinar de qué nivel en adelante (de menos importante a más) vale la pena registrar acontecimientos. Si fijamos el nivel en WARN, por ejemplo, se registrarán únicamente los eventos de nivel WARN, ERROR y FATAL, aunque en nuestro código también haya llamadas con niveles INFO y DEBUG. La configuración admite estos otros dos valores.
OFF: No registrar nada. ALL: Registrar todo.
10.4 Un primer ejemplo Creemos una aplicación de consola para probar los cinco niveles. El código del programa es este: using System; namespace ParaLogging { class Program { static void Main(string[] args) { log4net.Config.BasicConfigurator.Configure(); log4net.ILog log = log4net.LogManager.GetLogger(typeof(Program)); log.Debug("Esto es DEBUG"); log.Info("Esto es INFO"); log.Warn("Esto es WARN"); log.Error("Esto es ERROR"); log.Fatal("Esto es FATAL"); Console.ReadKey(); } } }
No olvidemos incluir la DLL en las referencias del proyecto.7 Lo primero que hace el programa es configurar el sistema de logging. Hemos recurrido a una configuración por defecto, pero pronto veremos las posibilidades que ofrece la configuración. La segunda acción es obtener un logger asociado a nuestro programa. Un patrón alternativo al invocar GetLogger es éste: log4net.ILog log = log4net.LogManager.GetLogger(System.Reflection.MethodBase 7
Ni tampoco cambiar el Target de la aplicación para evitar el error apuntado en la anterior nota al pie de página.
157
Buenas prácticas en desarrollo de software con .NET
.GetCurrentMethod().DeclaringType);
Cada una de las siguientes cinco líneas pide un registro de actividad con uno de los cinco posibles niveles. Al ejecutar, obtenemos esto por pantalla: 41 [10] DEBUG ParaLogging.Program (null) - Esto es DEBUG 164 [10] INFO ParaLogging.Program (null) - Esto es INFO 165 [10] WARN ParaLogging.Program (null) - Esto es WARN 165 [10] ERROR ParaLogging.Program (null) - Esto es ERROR 165 [10] FATAL ParaLogging.Program (null) - Esto es FATAL
Se muestra cierta información propia y, al final, el texto que hemos suministrado como argumento a cada una de las llamadas. El aspecto de cada línea está determinado por el “layout” que adoptemos y que se especifica en la configuración.
10.5 Configuración XML Antes de usar el logger hemos de configurarlo. En el ejemplo hemos usado una configuración básica desde el propio programa. Típicamente se configura el logger con el fichero app.config. El programa debe cargar la configuración de ese fichero, por lo que hemos de cambiar algo: using System; using log4net; using log4net.Config; namespace ParaLogging { class Program { static void Main(string[] args) { ILog log = log4net.LogManager.GetLogger(typeof(Program)); XmlConfigurator.Configure(); log.Debug("Esto es DEBUG"); log.Info("Esto es INFO"); log.Warn("Esto es WARN"); log.Error("Esto es ERROR"); log.Fatal("Esto es FATAL"); Console.ReadKey(); } } }
Abrimos el fichero app.config (que se muestra en el explorador de soluciones como un fichero más de nuestro proyecto) y encontramos un texto XML como éste:
Lo editamos ahora para dejarlo así:
158
Buenas prácticas en desarrollo de software con .NET
Hemos configurado el logger declarando una sección … en la relación de secciones que conforman app.config. El significado del “sub-lenguaje” XML que encontraremos en el interior de las marcas log4net se define en la clase log4net.Config.Log4NetConfigurationSectionHandler del ensamblado log4net.dll. Dentro del elemento , y en primer lugar, hemos definido un “appender”. Un appender es un punto al que se añade la información que irá registrando el logger. En nuestro ejemplo, hemos definido un appender llamado ConsoleAppender cuya semántica se define en la clase log4net.Appender.ConsoleAppender. Esta clase define la salida por consola de los mensajes. La marca permite controlar el aspecto de cada uno de los registros. Estamos usando un layout definido en la clase log4net.Layout.PatternLayout. La definición del layout en sí se describe en la marca . En nuestro caso estamos solicitando que se muestre primero la fecha en la que ocurre el evento, seguida del número de hilo de ejecución entre corchetes, seguida del nivel del evento (con espacio para 5 caracteres), seguida del “nombre” del logger (en nuestro caso, el nombre del tipo que se suministró al obtener el logger), seguida de un NDC (nested diagnostic context) entre corchetes y, finalmente, seguida de un guión y el mensaje que nos suministraron como argumento. 2011-02-20 2011-02-20 2011-02-20 2011-02-20 2011-02-20
En el fichero de configuración hemos fijado el nivel a ALL. Si en lugar de ALL ponemos WARN, sólo se muestran mensajes de nivel WARN o superior:
Al ejecutar el mismo programa obtenemos: 2011-02-20 14:03:25,892 [10] WARN ParaLogging.Program [(null)] - Esto es WARN 2011-02-20 14:03:25,918 [10] ERROR ParaLogging.Program [(null)] - Esto es ERROR 2011-02-20 14:03:25,918 [10] FATAL ParaLogging.Program [(null)] - Esto es FATAL
159
Buenas prácticas en desarrollo de software con .NET
Ya podemos entender una de las comodidades que ofrece un sistema de logging: podemos plagar el código con mensajes de ayuda a la depuración y eliminarlos al pasar a explotación fijando un nivel superior a DEBUG. Pero si en explotación deseamos, al detectar un mal funcionamiento, un logging más exhaustivo, basta con fijar nuevamente el nivel DEBUG.
10.6 Appenders Hemos visto cómo mostrar información por pantalla. Pero los registros suelen almacenarse en algún sistema que permite su análisis posterior. Log4Net ofrece una amplia variedad de appenders. Estos son algunos de ellos:
log4net.Appender.AdoNetAppender
Graba en una base de datos.
log4net.Appender.ColoredConsoleAppender
Añade los eventos a una consola de color.
log4net.Appender.ConsoleAppender
Añade a la consola estándar.
log4net.Appender.DebugAppender
Los añade a la salida de depuración.
log4net.Appender.EventLogAppender
Añade los eventos al log de eventos del sistema.
log4net.Appender.ForwardingAppender
Reenvía los eventos a los appenders vinculados.
log4net.Appender.FileAppender
Añade a un fichero.
log4net.Appender.MemoryAppender
Almacena los eventos en un vector de memoria.
log4net.Appender.RemoteSyslogAppender
Registra los eventos en un daemon syslog remote.
log4net.Appender.RemotingAppender
Envía los eventos a un sumidero de logging remote.
log4net.Appender.RollingFileAppender
Añade los eventos a un sistema que hace rotación de ficheros de log por fecha, tamaño o ambos criterios.
log4net.Appender.SmtpAppender
Envía e-mail cuando ocurre un evento de logging determinado (típicamente ERROR o FATAL).
log4net.Appender.TraceAppender
Añade los eventos al sistema de traza. La comunidad diseña e implementa otros appenders. Hay, por ejemplo, un appender para MongoDB (una de las base NoSQL de moda en aplicaciones para la nube) y otro para… Twitter. 10.6.1 ConsoleAppender/ColorConsoleAppender El appender más básico es el ConsoleAppender, útil para dar información inmediata por pantalla durante la ejecución del programa. La versión en color, ColorConsoleAppender, muestra los mensajes coloreados por nivel.
160
Buenas prácticas en desarrollo de software con .NET
Se puede configurar qué salida usar (estándar o de error) con el elemento (dentro de ). Podemos usar Console.Error o Console.Out. Por defecto se usa la salida estándar. Así podemos usar la salida de error:
En la versión en color se puede configurar el color asociado a cada nivel:
El color de fondo o de texto puede ser Blue, Green, Red, Yellow, Purple, Cyan o White. Con HighIntensity se usa un color más intenso. 10.6.2 DebugAppender/TraceAppender Es un adaptador para System.Diagnostics.Debug (y hay otro para Trace). Permite usar los escuchadores propios de System.Diagnostics.Debug. En Visual Studio, la salida se muestra por la ventana de depuración. Se le puede controlar el buffering con la marca :
161
Buenas prácticas en desarrollo de software con .NET
10.6.3 FileAppender Este appender permite añadir texto a un fichero y hacer así que la información de los eventos persista. Al configurarlo hemos de proporcionar cierta información con las marcas apropiadas:
: fija el modo de bloque en el fichero. Puede ser log4net.Appender.FileAppender+MinimalLock o log4net.Appender.FileAppender+ExclusiveLock.
He aquí un ejemplo de configuración que hace uso de algunas de esas marcas:
Un consejo: conviene no usar FileAppender, sino RollingFileAppender, pues FileAppender no limita el crecimiento del fichero y podemos encontrar, con el tiempo, ficheros de registro de actividad que ocupan gigas y consumen todo el espacio en disco. Veamos qué es RollingFileAppender. 10.6.4 RollingFileAppender Es similar a FileAppender, pero permite efectuar rotación sobre varios ficheros en función de la fecha o el tamaño alcanzado por el fichero. Los ficheros de rotación tienen un número como sufijo: fichero1.txt, fichero2.txt, etc. Estas marcas adicionales controlan el comportamiento de un RollingFileAppender:
: con Once, se inicializa cada vez que se
inicializa Log4Net; con Size, se inicializa al alcanzar un tamaño; con Date, al alcanzar una fecha; con Composite, se atiende tamaño y fecha. : se inicializa al alcanzar el tamaño que se especifica. : número máximo de ficheros en rotación si estamos en estilo Size. En estilo Composite limita el número de ficheros por día. No hace nada con Once o Date. : : si vale true, escribe en el fichero cuya ruta se dio en . Si vale false, escribe en el último fichero de rotación: fichero1, fichero2, fichero3… : si es mayor que cero, el fichero más reciente tiene el número más grande. Si es menor que cero, el más reciente es fichero1. Por defecto es -1.
10.6.5 Múltiples appenders Podemos usar más de un appender simultáneamente. Basta con especificarlos todos en la configuración, cada uno con su elemento XML:
162
Buenas prácticas en desarrollo de software con .NET
10.7 Layouts Con los layouts controlamos qué información se muestra y cómo. La configuración más sencilla es la que adopta valores por defecto. Para usarla basta con usar log4net.Layout.SimpleLayout:
Hemos visto antes cómo especificar un layout con patrones. Usamos entonces log4net.Layout.PatternLayout y la marca . El lenguaje de patrones que entiende log4net.Layout.PatternLayout es muy rico. Apuntamos algunos de los elementos básicos:
%date
Instante en el que se produce el evento. Por defecto, el instante se anota hasta las milésimas de segundo. Se puede configurar indicando el formato entre llaves. He aquí un ejemplo: %date{dd MMM yyyy HH:mm:ss,fff} .
%file
Nombre del fichero .cs en el que se produjo la llamada al logger. (Lento.)
%level
Nivel de logging con el que se ha producido la llamada.
%line
Línea del fichero desde la que se produjo la llamada. (Lento.)
%message
Mensaje proporcionado en la llamada.
%method
Método desde el que se produce la llamada. (Lento.)
%newline
Salto de línea.
%property{id}
Muestra el valor de la propiedad con clave id. Las propiedades se añaden en los loggers y los appenders. He aquí un ejemplo de propiedad añadida: log4net.GlobalContext.Properties["miPropiedad"] = "Esto es mi texto"; Hay un contexto global (el que hemos usado), un TheadContext y un LogicalThreadContext. Más
adelante hablamos de contextos.
%timestamp
Milisegundos transcurridos desde el inicio de la aplicación.
%thread
Número de hilo de ejecución.
%%
Símbolo de %.
%username
Nombre del usuario en la sesión Windows. (Lento.)
163
Buenas prácticas en desarrollo de software con .NET
%utcdate
Instante, pero en tiempo universal. El formato de la información asociada a las marcas se puede controlar con números entre el % y el identificador de la marca. Un número positivo indica un número de caracteres mínimo, con relleno de blancos por la izquierda; un número negativo hace lo mismo, pero rellenando por la derecha; un número precedido de un punto indica el número máximo de caracteres, con truncamiento de los primeros caracteres si es menester. La marca %10.20message significa que si el mensaje no llega a 10 caracteres, se rellenará con blancos por la izquierda, y que si tiene más de 20 caracteres, sólo se mostrarán los últimos 20. Otro motor de layout interesante es log4net.Layout.XMLLayout, que permite producir información estructurada con XML. Veamos un ejemplo:
Al ejecutar obtenemos esta salida XML: Esto es DEBUGEsto es INFOEsto es WARNEsto es ERROREsto es FATAL
164
Buenas prácticas en desarrollo de software con .NET
10.8 Configuración jerárquica de Loggers En una aplicación podemos crear varios loggers. Podemos crear uno por clase y otro para el programa principal, por ejemplo. De ese modo el texto contendrá información sobre la clase que hizo la llamada correspondiente. Podemos, además, configurar cada logger y asociar a cada uno un appender diferente. Veamos un ejemplo:
Estamos asignando un logger a todos los objetos del espacio de nombres ParaLogging, con salida en fichero. El programa principal sacará, además, la salida por consola. La clase ParaLogging.MiClase desconecta el registro de mensajes. Hablamos de configuración jerárquica porque cada logger puede ocuparse de un nivel y se anidan estos en función de la jerarquía definida por espacios de nombres y clases.
10.9 Contextos Cuando una aplicación es ejecutada por varios clientes concurrentes o por varios componentes, conviene conocer el contexto en el que se produce el evento. Ya hemos indicado antes que hay tres contextos:
global (GlobalContext), por hilo (ThreadContext) y por hilo lógico (ThreadLogicalContext).
Podemos asociar propiedades a los contextos: log4net.ThreadContext.Properties["miContexto"] = "Llamado desde Main";
Y conocer el valor asociado a la propiedad con un patrón de layout:
165
Buenas prácticas en desarrollo de software con .NET
"%property{miContexto}"
Las propiedades no sólo pueden ser cadenas. Se puede calcular valores con clases que definen ToString. Una clase CounterProperty podría, por ejemplo, devolver el valor de un contador. log4net.ThreadContext.Properties["contador"] = new CounterProperty();
Cuando se accede al valor, se toma lo que devuelve ToString(). Los contextos definen pilas que pueden ayudar a mostrar contextos complejos, en los que se entra y sale de un estado como se entra y sale de los métodos. Esta llamada: log4net.ThreadContext.Stacks["miContexto"].Push("externo")
apila un contexto. Para salir del contexto hacemos: log4net.ThreadContext.Stacks["miContexto"].Close()
Los contextos anidados se muestran con el patrón %ndc.
10.10 Filtros Los appenders pueden llevar asociados filtros. Los filtros ayudan a decidir si un evento debe ser considerado por un appender o no. Con los filtros podemos seleccionar los niveles que deseamos registrar, uno a uno (log4net.Filter.LevelMatchFilter ) o por rango (log4net.Filter.LevelRangeFilter), o dejar pasar sólo aquellos que concuerdan con una cadena (log4net.Filter.StringMatchFilter ), o en función de si hay concordancia con el valor o expresión regular para una propiedad contextual (log4net.Filter.PropertyFilter),o si hay concordancia con el nombre del logger (log4net.Filter.LoggerMatchFilter ), o no dejar pasar a ninguno (log4net.Filter.DenyAllFilter). Esta configuración, por ejemplo, selecciona los eventos de nivel comprendido entre DEBUG y WARN:
10.10.1 LoggerMatchFilter Filtra contra el nombre del logger que emite el mensaje. Se configura con:
loggerToMatch: cadena con el valor esperado. La concordancia se hace con String.StartsWith. acceptOnMatch: se acepta el mensaje si y sólo si vale true.
10.10.2 LevelMatchFilter Filtra por nivel:
levelToMatch: Debug, Info, Warn, Error o Fatal. acceptOnMatch.
166
Buenas prácticas en desarrollo de software con .NET
10.10.3 LevelRangeFilter Filtra por valor del nivel en un rango
levelMin: nivel mínimo. levelMax: nivel máximo. acceptOnMatch.
10.10.4 StringMatchFilter Filtra por el contenido de texto del mensaje, con una expresión regular o en función de si la cadena contiene una subcadena determinada:
regexToMatch: expresión regular que se compara con el mensaje. stringToMatch: cadena que se compara con el mensaje usando String.IndexOf. acceptOnMatch.
10.10.5 PropertyFilter Filtra en función del contenido de texto del valor de una propiedad:
key: nombre de la propiedad. regexToMatch: expresión regular que comparar con el valor de la propiedad. stringToMatch: cadena que comparar vía String.IndexOf. acceptOnMatch.
10.10.6 DenyAllFilter Poco que decir de este, que lo filtra todo.
10.11 Un ejemplo de uso Enriquezcamos nuestra aplicación de lectura de Rss con la inclusión de un logger que registre cada acceso a una fuente de noticias. Empezamos añadiendo la referencia a log4net en el proyecto Rss. Nuestra clase Rss.Reader necesitará una referencia al logger, y ésta referencia, que proporciona valor a una nueva dependencia, se inyectará en el constructor. using log4net; namespace Rss { public class Reader { private ILog _log; private ILoader _loader; private IParser _parser; private IFormatter _formatter; private IWriter _writer; private IUrlManager _urlManager; public Reader(ILog log, ILoader loader, IParser parser, IFormatter formatter, IWriter writer, IUrlManager urlManager) { _log = log; _loader = loader; _parser = parser; _formatter = formatter;
167
Buenas prácticas en desarrollo de software con .NET
_writer = writer; _urlManager = urlManager; }
Naturalmente, compilar ahora conducirá a la obtención de errores. Uno de ellos se dará en la aplicación RssReaderApp, que necesitará incluir una referencia a log4net.dll, cambiar su Target framework a .NET Framework 4 y modificar el código: using System.Configuration; using System; namespace RssReaderApp { class Program { static void Main(string[] args) { var config = (RssReaderAppConfigurationSection) ConfigurationManager.GetSection("rssReader"); log4net.Config.BasicConfigurator.Configure(); var log = log4net.LogManager.GetLogger(typeof(Program)); var loader = new Rss.Loader(); var parser = (Rss.IParser)Activator.CreateInstance(config.Parser); var formatter = new Rss.Formatter(); var writer = new Rss.Writer(); var urlManager = new Rss.UrlManager(); foreach (UrlElement url in config.Url) { urlManager.Add(url.Value); } foreach (var arg in args) { urlManager.Add(arg); } var reader = new Rss.Reader(log, loader, parser, formatter, writer, urlManager); reader.Display(); System.Console.ReadKey(); } } }
El otro código afectado es el de la clase TestReader, en el proyecto TestRss. También hemos de añadir la referencia a la librería y modificar el código de TestReader.cs: [TestFixture] public class TestReader { private Mock _logMock; private Mock _loaderMock; private Mock _parserMock; private Mock _formatterMock; private Mock _writerMock; private Mock _urlManagerMock; private Rss.Reader _reader; [SetUp] public void Prepara() { _logMock = new Mock(MockBehavior.Loose); _loaderMock = new Mock(MockBehavior.Strict); _loaderMock.Setup(l => l.Load(SomeConstants.url))
168
Buenas prácticas en desarrollo de software con .NET
.Returns(SomeConstants.input); _parserMock = new Mock(MockBehavior.Strict); _parserMock.Setup(p => p.Parse(It.IsAny())) .Returns(SomeConstants.intermediate);
_formatterMock = new Mock(MockBehavior.Strict); _formatterMock.Setup(f => f.FormatFeed(SomeConstants.intermediate)) .Returns(SomeConstants.output); _writerMock = new Mock(MockBehavior.Strict); _writerMock.SetupGet(w => w.Write); _urlManagerMock = new Mock(MockBehavior.Strict); _urlManagerMock.Setup(u => u.Add(SomeConstants.url)); _urlManagerMock.Setup(u => u.GetEnumerator()) .Returns(Enumerable.Repeat(SomeConstants.url, 1).GetEnumerator()); _reader = new Rss.Reader(_logMock.Object, _loaderMock.Object, _parserMock.Object, _formatterMock.Object, _writerMock.Object, _urlManagerMock.Object); }
Modificamos el método Display de Reader: public void Display() { foreach (var url in _urlManager) { _log.Info(string.Format("Intentando acceder a {0}", url)); var xml = _loader.Load(url); var result = FeedReport(xml); _writer.Write(result); _log.Info(string.Format("Fin del acceso a {0}", url)); } }
Ejecutamos y obtenemos algo similar a esto por pantalla: 62 [9] INFO RssReaderApp.Program (null) - Intentando acceder a http://www.elpais.com/rss/feed.html?feedId=17046 *** ELPAIS.com - Lo último Sun, 24 Apr 2011 17:00:00 +0200: Fallece la actriz francesa Marie-France Pisier, musa de Truffaut Sun, 24 Apr 2011 15:18:00 +0200: Miles de personas celebran el Aberri Eguna en Gernika reclamando la independencia Sun, 24 Apr 2011 14:28:00 +0200: Los combates contin?an en Misrata pese al anuncio de retirada de Gadafi … Sat, 23 Apr 2011 21:27:00 +0200: Giovani encuentra su sitio 3634 [9] INFO RssReaderApp.Program (null) - Fin del acceso a http://www.elpais.com/rss/feed.html?feedId=17046 3634 [9] INFO RssReaderApp.Program (null) - Intentando acceder a http://www.meneame.net/rss2.php *** Menéame: publicadas Sun, 24 Apr 2011 15:10:02 +0000: Motín a bordo de un avión de Vueling rumbo a Ciudad Real Sun, 24 Apr 2011 14:25:02 +0000: El CSIC lanza un catálogo de ebooks que incluye 28 libros gratis Sun, 24 Apr 2011 13:35:02 +0000: Bienvenidos a la nuclear de Hamaoka, en la zona más sísmica del mundo … Fri, 22 Apr 2011 22:25:02 +0000: Cientos de gitanos huyen de una localidad de Hungría 4882 [9] INFO RssReaderApp.Program (null) - Fin del acceso a http://www.meneame.net/rss2.php
La salida incluye el registro de los eventos, cuando sería preferible que los eventos se registrasen en fichero. Aprovechamos para configurar el logger en app.config, que ha de presentar este aspecto:
169
Buenas prácticas en desarrollo de software con .NET
Es necesario indicar a la aplicación que debe configurar el logger a partir del fichero app.config: using System.Configuration; using System; namespace RssReaderApp { class Program { static void Main(string[] args) { var config = (RssReaderAppConfigurationSection) ConfigurationManager.GetSection("rssReader"); log4net.Config.XmlConfigurator.Configure(); var log = log4net.LogManager.GetLogger(typeof(Program)); var loader = new Rss.Loader(); var parser = (Rss.IParser)Activator.CreateInstance(config.Parser); var formatter = new Rss.Formatter(); var writer = new Rss.Writer(); var urlManager = new Rss.UrlManager(); foreach (UrlElement url in config.Url) { urlManager.Add(url.Value); } foreach (var arg in args) { urlManager.Add(arg); } var reader = new Rss.Reader(log, loader, parser, formatter, writer, urlManager); reader.Display();
170
Buenas prácticas en desarrollo de software con .NET
Fallece la actriz francesa Marie-France Pisier, musa de Truffaut Miles de personas celebran el Aberri Eguna en Gernika reclamando la independencia Los combates contin?an en Misrata pese al anuncio de retirada de Gadafi Giovani encuentra su sitio Motín a bordo de un avión de Vueling rumbo a Ciudad Real El CSIC lanza un catálogo de ebooks que incluye 28 libros gratis Bienvenidos a la nuclear de Hamaoka, en la zona más sísmica del mundo Cientos de gitanos huyen de una localidad de Hungría
Y si ahora buscamos en el directorio en el que se encuentra la aplicación (en el caso de quien esto escribe, en el directorio C:\Users\amarzal\Documents\ Curso Buenas Prácticas\BuenasPracticas\RssReader\bin\Debug), encontraremos un fichero rss.log. Su contenido es éste: INFO INFO INFO INFO
-
Intentando acceder a http://www.elpais.com/rss/feed.html?feedId=17046 Fin del acceso a http://www.elpais.com/rss/feed.html?feedId=17046 Intentando acceder a http://www.meneame.net/rss2.php Fin del acceso a http://www.meneame.net/rss2.php
10.12 Buenas prácticas Acabamos con unos consejos:
Es recomendable que los logger tengan como nombre el de la clase desde la que se les llama vía LogManager.GetLogger(typeof(nombre de la clase)). Una aplicación debería registrar cada excepción detectada y tratada, pues las excepciones deben seguir manteniendo ese carácter de “comportamiento inesperado” y, en consecuencia, suponen un hecho notable que debe registrarse. Más vale poner un log de más que uno de menos. El impacto sobre la eficiencia se puede controlar. Conviene controlar el espacio que consumen los fichero de logging. Para ello es recomendable usar RollingFileAppender en lugar de FileAppender.
11 Serialización Al crear registros de actividad puede convenir mostrar el contenido de un objeto. Podemos redefinir ToString para que la cadena que proporciona describa el objeto. Esto supone cierto esfuerzo y, por otra parte, puede que deseemos reservar ToString para otro propósito. La necesidad de volcar el contenido de un objeto a algún formato textual (o binario) es frecuente. También lo es la necesidad de reconstruir el objeto a partir del volcado. Si disponemos de esta capacidad bidireccional, podemos facilitar enormemente el intercambio de datos. La codificación de los valores puede ser un problema, pero XML ofrece aquí un apoyo que vale la pena tener en cuenta. Se denomina serializar a la acción de convertir un objeto en una descripción que permite su reconstrucción (deserialización) posterior. La descripción se puede almacenar en un fichero o transmitir entre nodos de una red. Se denomina persistencia a la capacidad de un objeto de serializarse/deserializarse.
171
Buenas prácticas en desarrollo de software con .NET
La serialización no sólo es útil para almacenar y recuperar información: también es útil para obtener “clones profundos” de objetos. Hay tres motores para serialización con .NET:
Serializador binario. Serializador XML. Serializador basado en contrato de datos (Data contract).
Serializar un objeto simple no parece plantear demasiados problemas. En nuestras aplicaciones y librerías los objetos pueden estar interrelacionados y mantener referencias entre sí. Así pues, hemos de entender que un objeto es un nodo en un grafo de objetos. Ciertos serializadores son capaces de almacenar todo el subgrafo alcanzable desde un objeto y otros no. Hay un peligro potencial: que el grafo de objetos contenga ciclos. Si el serializador detecta un ciclo y es incapaz de tratar con él, advertirá del problema lanzando una excepción. Otro problema posible es la existencia de objetos para los que se mantienen varias referencias. El serializador XML generará una descripción por cada referencia, aunque se trate del mismo objeto. Si se desea un comportamiento más sofisticado se ha de recurrir a otro serializadores, como el binario o el denominado DataContractSerializer. Nosotros presentamos primero la serialización XML, que es la más sencilla y resulta suficiente para el uso que deseamos darle en este texto. .NET facilita enormemente la serialización de objetos con XML. Por defecto, todo tipo es serializable. Estudiaremos luego la serialización basada en contratos, que más versátil. El principal problema del serializador binario es su dependencia de la versión del ensamblado. Si modificamos una clase, los datos serializados son irrecuperables. En según qué aplicaciones esto no es un problema, pues ciertos datos tienen una estructura muy estable, pero en otras es una fuente de problemas. Para saber más del serializador binario (y para ampliar lo explicado aquí de los otros dos serializadores) es recomendable leer el capítulo 16 del libro “C# 4.0 in a Nutshell”. Baste decir que
11.1 Serializador XML 11.1.1 Un ejemplo sencillo Veamos un ejemplo. Definimos una clase con propiedades de diferentes tipos: namespace Serializar { public enum Rol { Estudiante, Profesor, Administrativo }; public class Persona { public string Nombre { get; set; } public string Apellido { get; set; } public int Edad { get; set; } public Rol[] Roles { get; set; } } }
172
Buenas prácticas en desarrollo de software con .NET
Y ahora, desde nuestro programa principal, vamos a instanciar la clase, asignar valores a los atributos, guardar una serialización en disco y recuperarla: using System; using System.Xml.Serialization; using System.IO; namespace Serializar { class Program { static void Main(string[] args) { var p = new Persona { Nombre = "Pepe", Apellido = "Pérez", Edad = 28, Roles = new [] {Rol.Estudiante, Rol.Administrativo} }; var personaSerializer = new XmlSerializer(typeof(Persona)); using (var f = new FileStream("datos.xml", FileMode.Create)) { personaSerializer.Serialize(f, p); } using (var f = new FileStream("datos.xml", FileMode.Open)) { var q = (Persona) personaSerializer.Deserialize(f); } Console.WriteLine(); } } }
Hemos usado un serializador XML (XmlSerializer) para que los datos se almacenen en un formato legible por personas. Nótese que los datos se han almacenado en un fichero denominado datos.xml. Si examinamos su contenido veremos esto: PepePérez28EstudianteAdministrativo
El formato es legible y no lo hemos tenido que definir nosotros trabajosamente. Es posible definir procesos de serialización personalizados, pero el comportamiento por defecto es utilizable en la mayor parte de escenarios habituales. En nuestro ejemplo hemos serializado en XML, que es legible, pero muy verboso. En situaciones en las que importe más la eficiencia que la legibilidad podemos recurrir a serializadores que almacenan la información en binario.
173
Buenas prácticas en desarrollo de software con .NET
11.1.2 Protección de propiedades y campos El serializador XML almacena la descripción de todas las propiedades y campos públicos. Una última cuestión. Los objetos pueden tener información que no deseemos almacenar. Por ejemplo, un objeto puede almacenar el número de veces que ha sido accedido en una sesión determinada y ese dato no tiene por qué persistir. Podemos marcar una propiedad con el atributo [XmlIgnore] para que no se serialice. Extendamos el ejemplo anterior: using System.Xml.Serialization; namespace Serializar { public enum Rol { Estudiante, Profesor, Administrativo } ; public class Persona { public string Nombre { get; set; } public string Apellido { get; set; } public int Edad { get; set; } public Rol[] Roles { get; set; } [XmlIgnore] public int Contador { get; set; } } }
El programa principal pasa a ser este: using System; using System.Xml.Serialization; using System.IO; namespace Serializar { class Program { static void Main(string[] args) { var p = new Persona { Nombre = "Pepe", Apellido = "Pérez", Edad = 28, Roles = new[] { Rol.Administrativo }, Contador = 100 }; var personaSerializer = new XmlSerializer(typeof(Persona)); using (var f = new FileStream("datos.xml", FileMode.Create)) { personaSerializer.Serialize(f, p); } using (var f = new FileStream("datos.xml", FileMode.Open)) { var q = (Persona) personaSerializer.Deserialize(f); Console.WriteLine("{0} {1}", q.Nombre, q.Contador);
174
Buenas prácticas en desarrollo de software con .NET
} Console.ReadKey(); } } }
La salida por pantalla al ejecutar es ésta: Pepe 0
Se puede comprobar que la propiedad no almacenó su valor al serializar el objeto.
11.2 Serializador basado en contratos El serializador basado en contratos es más reciente en .NET y ofrece mayor versatilidad. Antes de presentar este serializador hemos de hablar brevemente de formateadores.8 11.2.1 Formateadores Al serializar, los datos pasan por un formateador, un objeto que genera la salida con la descripción del objeto en el formato que decidamos. Hay dos grupos de formateadores:
Formateadores XML. Formateadores binarios.
El serializador XML está vinculado a un formateador XML, pero el basado en contratos nos permite elegir. El formateador binario es útil si se desea eficiencia temporal (al serializar/deserializar) y espacial. El formateador XML interesa si prima la legibilidad. 11.2.2 Uso del serializador Para trabajar con contrato de datos hemos de elegir primero si usaremos la clase DataContractSerializer (crea un acoplamiento débil entre tipos .NET y tipos de contrato de datos) o NetDataContractSerializer (establece un vínculo fuerte entre tipos .NET y tipos de contrato de datos). Nosotros escogeremos siempre el primero. A continuación, hemos de marcar los tipos y miembros que deseamos serializar con [DataContract] y [DataMember], respectivamente. A continuación invocaremos el método WriteObject o ReadObject, según queramos serializar o deserializar, respectivamente. Repitamos un ejemplo como el anterior con el nuevo serializador. Lo primero es marcar la clase Persona y los miembros que deseamos serializar: using System.Runtime.Serialization; namespace Serializar { public enum Rol { Estudiante, Profesor, Administrativo } ;
8
Hay que incluir una referencia a la librería System.Runtime.Serialization para poder usar atributos como DataContractAttribute o DataMemberAttribute.
175
Buenas prácticas en desarrollo de software con .NET
[DataContract] public class Persona { [DataMember] public [DataMember] public [DataMember] public [DataMember] public }
El programa principal queda así: using System; using System.IO; using System.Runtime.Serialization; namespace Serializar { class Program { static void Main(string[] args) { var p = new Persona { Nombre = "Pepe", Apellido = "Pérez", Edad = 28, Roles = new[] {Rol.Estudiante, Rol.Administrativo}, }; var personaSerializer = new DataContractSerializer(typeof(Persona)); using (var f = new FileStream("datos.xml", FileMode.Create)) { personaSerializer.WriteObject(f, p); } using (var f = new FileStream("datos.xml", FileMode.Open)) { var q = (Persona) personaSerializer.ReadObject(f); Console.WriteLine("{0} {1}", q.Nombre, q.Apellido); } Console.ReadKey(); } } }
Si examinamos el contenido de datos.xml, veremos que contiene información en XML (aunque no es un documento XML por faltar la línea inicial ) y que no está formateada para lectura humana. Pérez28PepeAd ministrativo
Buenas prácticas en desarrollo de software con .NET
Administrativo
El contenido del fichero XML refleja con sus marcas los nombres de clase y miembros que hemos usado. No necesariamente ha de ser así. Podemos definir nuestras propias marcas: using System.Runtime.Serialization; namespace Serializar { public enum Rol { Estudiante, Profesor, Administrativo } ; [DataContract(Name="Person")] public class Persona { [DataMember(Name="FirstName")] public string Nombre { get; set; } [DataMember(Name="LastName")] public string Apellido { get; set; } [DataMember(Name="Age")] public int Edad { get; set; } [DataMember] public Rol[] Roles { get; set; } } }
El contenido de datos.xml ahora pasa a ser: 28PepePérezEstudianteAdministrativo
11.2.3 Un asunto “avanzado”: declaración de espacios de nombres También podemos definir espacios de nombre XML: using System.Runtime.Serialization; namespace Serializar { public enum Rol { Estudiante, Profesor, Administrativo } ; [DataContract(Name="Person", Namespace="http://www.uji.es/people")] public class Persona { [DataMember(Name="FirstName")] public string Nombre { get; set; } [DataMember(Name="LastName")] public string Apellido { get; set; } [DataMember(Name="Age")] public int Edad { get; set; } [DataMember] public Rol[] Roles { get; set; } } }
El fichero contiene ahora: 28PepePérezEstudianteAdministrativo
Nótese que al fijar un espacio de nombres propio se ha eliminado el que asignaba el serializador automáticamente. De este modo hemos eliminado cualquier referencia al nombre físico de la clase en el fichero XML, con lo que el formato es más robusto frente a cambios del código. También cabe advertir que se ha creado un nuevo espacio de nombres para los objetos cuya serialización no se ha explicitado (el tipo enumerado). 11.2.4 Serialización de referencias No hay problema en que un objeto apunte a otro complejo: si lo apuntado es serializable, se almacena el grafo de objetos alcanzable desde el que serializamos. using System.Runtime.Serialization; namespace Serializar { public enum Rol { Estudiante, Profesor, Administrativo } ; [DataContract(Name="Person", Namespace="http://www.uji.es/people")] public class Persona { [DataMember(Name="FirstName")] public string Nombre { get; set; } [DataMember(Name="LastName")] public string Apellido { get; set; } [DataMember(Name="Age")] public int Edad { get; set; } [DataMember] public Rol[] Roles { get; set; } [DataMember] public Persona MejorAmigo { get; set; } } }
El programa principal que ilustra el nuevo concepto es éste: using System; using System.IO; using System.Runtime.Serialization; namespace Serializar { class Program { static void Main(string[] args) { var p1 = new Persona { Nombre = "Pepe", Apellido = "Pérez", Edad = 28, Roles = new[] {Rol.Estudiante, Rol.Administrativo}, }; var p2 = new Persona { Nombre = "Toni", Apellido = "García", Edad = 30, Roles = new[] { Rol.Estudiante },
178
Buenas prácticas en desarrollo de software con .NET
}; p1.MejorAmigo = p2; p2.MejorAmigo = null; var personaSerializer = new DataContractSerializer(typeof(Persona)); using (var f = new FileStream("datos.xml", FileMode.Create)) { personaSerializer.WriteObject(f, p1); } using (var f = new FileStream("datos.xml", FileMode.Open)) { var q = (Persona) personaSerializer.ReadObject(f); Console.WriteLine("{0} {1} -> {2} {3}", q.Nombre, q.Apellido, q.MejorAmigo.Nombre, q.MejorAmigo.Apellido); } Console.ReadKey(); } } }
La salida por pantalla es ésta: Pepe Pérez -> Toni García
Y si examinamos el fichero XML encontramos esto (sin formatear): 28PepePérez30ToniGarcíaEstudianteEstudianteAdministrativo
Hemos destacado las marcas que contienen la descripción del mejor amigo de cada persona. Una contiene la descripción directamente. La otra representa el valor null de un modo especial. El espacio de nombres asociado al prefijo “i” permite codificar información especial de los tipos .NET. Hay un problema con las subclases. Veamos este ejemplo: using System.IO; using System.Runtime.Serialization; namespace Serializar { public enum Rol {
179
Buenas prácticas en desarrollo de software con .NET
Estudiante, Profesor, Administrativo } ; [DataContract(Name = "Person")] public class Persona { [DataMember(Name = "FirstName")] public string Nombre { get; set; } [DataMember(Name = "LastName")] public string Apellido { get; set; } [DataMember(Name = "Age")] public int Edad { get; set; } [DataMember] public Rol[] Roles { get; set; } [DataMember] public Persona MejorAmigo { get; set; } public static Persona DeepClone(Persona p) { var ds = new DataContractSerializer(typeof(Persona)); MemoryStream stream = new MemoryStream(); ds.WriteObject(stream, p); stream.Position = 0; return (Persona)ds.ReadObject(stream); } } [DataContract] public class Erasmus : Persona { [DataMember] public string PaísDeOrigen { get; set; } } }
El programa principal intenta gestionar personas convencionales y personas Erasmus: using System; using System.IO; using System.Runtime.Serialization; namespace Serializar { class Program { static void Main(string[] args) { var p1 = new Persona { Nombre = "Pepe", Apellido = "Pérez", Edad = 28, Roles = new[] {Rol.Estudiante, Rol.Administrativo}, }; var p2 = new Erasmus { Nombre = "Toni", Apellido = "García", Edad = 30, Roles = new[] { Rol.Estudiante }, PaísDeOrigen = "Italia" }; p1.MejorAmigo = p2; p2.MejorAmigo = null; var personaSerializer = new DataContractSerializer(typeof(Persona)); var ds = new DataContractSerializer(typeof (Persona));
180
Buenas prácticas en desarrollo de software con .NET
using (var f = new FileStream("datos.xml", FileMode.Create)) { personaSerializer.WriteObject(f, p1); } using (var f = new FileStream("datos.xml", FileMode.Open)) { var q1 = (Persona) personaSerializer.ReadObject(f); Console.WriteLine("{0} {1} {2}", q1.MejorAmigo.Nombre, q1.MejorAmigo.Apellido, ((Erasmus)q1.MejorAmigo).PaísDeOrigen); } Console.ReadKey(); } } }
Sin embargo, al ejecutar salta una excepción de tipo SerializationException en la línea que hemos destacado. El mensaje de la excepción es “Type 'Serializar.Erasmus' with data contract name 'Erasmus:http://schemas.datacontract.org/2004/07/Serializar' is not expected. Consider using a DataContractResolver or add any types not known statically to the list of known types - for example, by using the KnownTypeAttribute attribute or by adding them to the list of known types passed to DataContractSerializer.”. Nos indica que no sabe serializar la subclase de un modo que luego pueda deserializarse. Hemos de informar al serializador de que ha de poder deserializar los subtipos que esperamos tener que tratar: using System.IO; using System.Runtime.Serialization; namespace Serializar { public enum Rol { Estudiante, Profesor, Administrativo } ; [DataContract(Name = "Person"), KnownType(typeof(Erasmus))] public class Persona { [DataMember(Name = "FirstName")] public string Nombre { get; set; } [DataMember(Name = "LastName")] public string Apellido { get; set; } [DataMember(Name = "Age")] public int Edad { get; set; } [DataMember] public Rol[] Roles { get; set; } [DataMember] public Persona MejorAmigo { get; set; } public static Persona DeepClone(Persona p) { var ds = new DataContractSerializer(typeof(Persona)); MemoryStream stream = new MemoryStream(); ds.WriteObject(stream, p); stream.Position = 0; return (Persona)ds.ReadObject(stream); } }
181
Buenas prácticas en desarrollo de software con .NET
[DataContract] public class Erasmus : Persona { [DataMember] public string PaísDeOrigen { get; set; } } }
Con este cambio, todo funciona correctamente. El contenido del fichero datos.xml es éste: 28PepePérez30ToniGarcíaEstudianteItaliaEstudianteAdministrativo
11.2.5 Clonación profunda Ahora que vemos que se puede reconstruir información compleja, es fácil usar el serializador para implementar un método de clonación profunda. using System.IO; using System.Runtime.Serialization; namespace Serializar { public enum Rol { Estudiante, Profesor, Administrativo } ; [DataContract(Name="Person")] public class Persona { [DataMember(Name="FirstName")] public string Nombre { get; set; } [DataMember(Name="LastName")] public string Apellido { get; set; } [DataMember(Name="Age")] public int Edad { get; set; } [DataMember] public Rol[] Roles { get; set; } [DataMember] public Persona MejorAmigo { get; set; } public static Persona DeepClone(Persona p) { var ds = new DataContractSerializer(typeof(Persona)); MemoryStream stream = new MemoryStream(); ds.WriteObject(stream, p); stream.Position = 0; return (Persona)ds.ReadObject(stream); } }
182
Buenas prácticas en desarrollo de software con .NET
}
Nótese que se usa un stream de memoria para evitar accesos al sistema de ficheros. Para obtener una copia de un objeto utilizaríamos el método así: var copia = Persona.DeepClone(p1);
12 Inyección de dependencias Hemos aprendido a diseñar aplicaciones guiándonos por las pruebas unitarias, y hemos aprendido también a eliminar dependencias con un diseño que refuerza el principio de la responsabilidad única (SRP) y que nos hace depender de abstracciones, de acuerdo con el principio de inversión de dependencias (DIP). Este segundo enfoque produce código más fácil de mantener, en el que los objetos actúan como servicios. Nuestro lector de RSS ha usado:
Un sistema de registro de actividad, que implementa la interfaz log4net.ILog (y cuya implementación se especifica en el fichero de configuración). Un servicio para la carga de fuentes RSS a partir de una URL, que implementa una interfaz ILoader. Un servicio para el análisis del texto que codifica la información como XML, que implementa una interfaz IParser. Hemos diseñado tres versiones del analizador: una par RSS 2.0, otra para Atom 1.0 y otro capaz de analizar los dos formatos. Un servicio para formatear el texto como una sucesión de líneas, que implementa la interfaz IFormatter. Un servicio para mostrar las líneas en un dispositivo de salida, que implementa la interfaz IWritter. Un servicio para la gestión de URLs, que implementa la interfaz IUrlManager.
Sustituir un servicio por otra que desempeñe el mismo papel es relativamente sencillo si seguimos este enfoque. Si apareciera un nuevo formato de RSS, o un nuevo formato de salida, o un nuevo dispositivo de salida, adaptar nuestro software al cambio resultaría casi inmediato. Nuestro lector no es más que un cliente de los servicios. La relación entre cliente y servicio implica la existencia de un contrato entre ambos objetos. Cliente y servicio reciben también los nombres, respectivamente, de:
Dependiente (dependent): el cliente que necesita a otros objetos para llevar a cabo su cometido. Dependencia (dependency): el servicio que es necesitado por un cliente para llevar a cabo su cometido.
En nuestro código hemos gestionado las dependencias manualmente. Hemos aprendido que las interfaces permiten evitar depender de una implementación concreta. Los servicios se definen como implementaciones de una interfaz y el cliente sólo expresa su dependencia con respecto de la interfaz. Así pues, el cliente no construye instancias de los servicios y espera a que alguien inyecte las dependencias desde el exterior. Un modo de hacerlo (no es el único) consiste en definir un constructor que espera una argumento para cada dependencia. Quien usa a nuestro cliente lo construirá suministrando instancias concretas de los servicios que usa.
183
Buenas prácticas en desarrollo de software con .NET
Las relaciones entre dependientes y dependencias generan un grafo de objetos. La Inyección de Dependencias (DI, por Dependency Injection) se ocupa de construir fiablemente los grafos de objetos y de las estrategias, patrones y buenas prácticas para este fin. Vamos a abundar en el principio de Inversión de Dependencias y vamos a estudiar herramientas que nos ayuden en el diseño. Todo el sistema se apoya en que escribimos código:
Orientado al comportamiento: Nuestro objetivo no es construir más y más objetos, sino programar un comportamiento para un sistema software. Modular: El código agrupado en unidades modulares es más mantenible, reutilizables y fácil de empaquetar (y desplegar). El código modular puede concentrarse en pequeños subconjuntos de funcionalidad y facilitar el desarrollo de cada uno de ellos. Testable: Los módulos facilitan la escritura de pruebas que verifican el comportamiento de las unidades sin depender del sistema completo. Un buen diseño modular facilita, por ejemplo, la sustitución de piezas por impostores durante las pruebas.
12.1 Inyección (manual) de dependencias Empezaremos por estudiar algunas formas de inyectar dependencias (o “cablear” (wiring) dependencias, que también se denomina así al acto de inyectarlas). Recordemos que deseamos evitar código como éste: namespace Ejemplo { public class Servicio { public void PideAlgo() { System.Console.WriteLine("¡Algo!"); } } public class Cliente { private Servicio _servicio; // ¡Dependencia! public Cliente() { _servicio = new Servicio(); // ¡Dependencia! } } public class Usuario { public void Usa() { Cliente c = new Cliente(); } } }
La dependencia de nuestro cliente con una implementación particular del servicio crea una rigidez en el código que acabará pasando factura. La primera idea es independizarse de la implementación concreta usando una interfaz para el servicio. Pese a hacer eso, este código sigue manteniendo una dependencia al construir una instancia de Servicio y al construir una instancia de Cliente:
184
Buenas prácticas en desarrollo de software con .NET
namespace Ejemplo { public interface IServicio { void PideAlgo(); } public class Servicio : IServicio { public void PideAlgo() { System.Console.WriteLine("¡Algo!"); } } public class Cliente { private IServicio _servicio; // Dependencia suprimida. public Cliente() { _servicio = new Servicio(); // ¡Dependencia! } } public class Usuario { public void Usa() { Cliente c = new Cliente(); } } }
12.1.1 Inyección de dependencias por constructor Esta ya la conocemos y consiste en que el constructor espere que se le suministre la implementación mediante un argumento del constructor: namespace Ejemplo { public interface IServicio { void PideAlgo(); } public class Servicio : IServicio { public void PideAlgo() { System.Console.WriteLine("¡Algo!"); } } public class Cliente { private IServicio _servicio; // Dependencia suprimida. public Cliente(IServicio servicio) // Dependencia suprimida por inyección. { _servicio = servicio; } }
185
Buenas prácticas en desarrollo de software con .NET
public class Usuario { public void Usa() { Cliente c = new Cliente(new Servicio()); } } }
La primera ventaja que podemos apreciar es la posibilidad de inyectar mocks en las pruebas unitarias de la clase Cliente, pues basta con suministrar uno al constructor del SUT. 12.1.2 Inyección por propiedades Definir constructores complejos puede llegar a ser un problema: una lista larga de parámetros es fuente probable de errores. El usuario puede no recordar el orden en el que debe suministrar los argumentos cuando dos o más presentan el mismo tipo y, e cualquier caso, es fatigoso andar suministrando argumento tras argumento. Podemos usar propiedades como puntos de enganche para las dependencias y mantener el constructor tan sencillo como sea posible: public class Cliente { public IServicio Servicio { get; set; } public Cliente() { } } public class Usuario { public void Usa() { Cliente c1 = new Cliente(); c1.Servicio = new Servicio1(); Cliente c2 = new Cliente { Servicio = new Servicio1() }; … } }
Esta técnica debe usarse con cuidado, pues el usuario podría olvidar asignar un valor a la propiedad y provocar, más tarde, un fallo. 12.1.3 Inyección con un Builder Recordemos que un Builder es un patrón de diseño para efectuar construcciones complejas con ayuda de un objeto auxiliar (el Builder), que con uno o más métodos va acumulando los datos necesarios para que la construcción sea exitosa. En este ejemplo presentamos un Builder un tanto forzado, ya que la clase no es demasiado compleja, pero ayuda a ilustrar la técnica a un nivel básico: public class Cliente { public class Builder { public class Config { public IServicio Servicio;
186
Buenas prácticas en desarrollo de software con .NET
} private Config _config; public Builder() { _config = new Config(); } public Builder ConServicio(IServicio servicio) { _config.Servicio = servicio; return this; } public Config Build() { if (_config.Servicio == null) { throw new InvalidOperationException(); } else { return _config; } } } private IServicio _servicio; public Cliente(Builder.Config config) { _servicio = config.Servicio; } } public class Usuario { public void Usa() { Cliente c = new Cliente((new Cliente.Builder()).ConServicio(new Servicio()).Build()); } }
12.1.4 Inyección mediante factorías abstractas Imaginemos que hay dos implementaciones del servicio: Servicio1 y Servicio2. Una factoría puede producir objetos de una u otra implementación. Cuando el cliente necesita una instancia, pide a la factoría que se la cree. Naturalmente, producirá una instancia de un tipo determinado, así que parece que no hemos eliminado la dependencia: namespace Ejemplo { public interface IServicio { void PideAlgo(); } public class Servicio1 : IServicio { public void PideAlgo() { System.Console.WriteLine("¡Algo!"); }
187
Buenas prácticas en desarrollo de software con .NET
} public class Servicio2 : IServicio { public void PideAlgo() { System.Console.WriteLine("¡Something!"); } } public class FactoríaDeServicios { public IServicio CreaServicio1() { return new Servicio1(); } public IServicio CreaServicio2() { return new Servicio2(); } public IServicio CreaServicio() { return CreaServicio1(); } } public class Cliente { private IServicio _servicio; public Cliente() { _servicio = new FactoríaDeServicios().CreaServicio(); } }
public class Usuario { public void Usa() { Cliente c = new Cliente(); } } }
Pero sí lo hemos hecho, pues no hemos de tocar el cliente para cambiar su servicio: hemos de cambiar la factoría. Si queremos inyectar un stub al cliente, basta con que la factoría produzca un stub. 12.1.5 Inyección mediante localizador de servicios Un localizador de servicios es un tipo de factoría. Puede verse como un diccionario que permite acceder al servicio a través de una clave. public class LocalizadorDeServicios { Dictionary _table = new Dictionary(); public void DeclaraServicio(string clave, object servicio) { _table[clave] = servicio; }
188
Buenas prácticas en desarrollo de software con .NET
public object Obtén(string clave) { return _table[clave]; } } public class Cliente { private IServicio _servicio; public Cliente(LocalizadorDeServicios localizador) { _servicio = localizador.Obtén("IServicio"); } } public class Usuario { public void Usa() { LocalizadorDeServicios localizador = new LocalizadorDeServicios(); localizador.DeclaraServicio("IServicio", new Servicio1()); Cliente c = new Cliente(localizador); } }
El localizador puede inyectarse en el constructor o ser una variable global que permita resolver las demandas de servicio de todo el sistema o parte de él.
12.2 El principio de Hollywood Todas las técnicas que hemos considerado parten de un papel activo por parte del cliente. Esta actitud viola el denominado principio de Hollywood, que se resume en “No nos llame. Nosotros le llamaremos.” El principio de Hollywood (que toma nombre de la expresión que usan los directores de casting para despedir a los candidatos a un papel) traspasa el papel activo a un nuevo objeto: el inyector de dependencias, que toma el control. Por ello, el principio de Hollywood también se conoce como IoC, siglas de Inversión del Control en inglés (Inversion of Control). Aplicando el principio de Hollywood, los clientes se limitan a declarar de algún modo qué dependencias necesitan y el inyector construye el grafo de objetos a partir de esta declaración y de algún contenedor de dependencias (un objeto similar al localizador de dependencias que hemos visto antes).
12.3 Librerías para Inyección de Dependencias Hay muchas librerías para inyección de dependencias en .NET. Podemos citar:
Castle Windsor (http://www.castleproject.org/container/index.html). Código abierto (licencia Apache 2), bien documentado y muy utilizado. StructureMap (http://structuremap.net/structuremap/index.html). Código abierto (Apache 2), con comunidad de desarrollo muy activa, interfaz fluida. Spring.NET (http://www.springframework.net/). Código abierto (Apache 2), basado en la librería Spring de Java, que es el estándar de facto en esa plataforma. Autofaq (http://code.google.com/p/autofac/). Código abierto (licencia MIT).
189
Buenas prácticas en desarrollo de software con .NET
Unity (http://unity.codeplex.com/). Código (semi)abierto (licencia MS-PL), forma parte de las herramientas enmarcadas en “Patterns and Practices” de Microsoft. Ninject (http://ninject.org/). Código abierto (Apache 2). S2Container.NET (http://s2container.net.seasar.org/en/index.html). Port de la herramienta Seasar2 de Java. PicoContainer.NET (http://docs.codehaus.org/display/PICO/Home). Port de la herramienta PicoContainer de Java.
Nosotros usaremos Castle Windsor. Aunque Unity es muy utilizado y cuenta con el apoyo de Microsoft, Castele Windsor es también muy popular y otras herramientas se apoyan en Castle Windsor. Por otra parte, resulta más sencillo a efectos didácticos, y nuestro objetivo es aprender una serie de conceptos con herramientas que los pongan en práctica. Una vez aprendidos con una herramienta, su uso con otra resulta generalmente trivial. Castle Windsor nos permite alcanzar nuestro objetivo más rápidamente.
13 Castle Windsor Castle Project es un framework de código abierto para proveer a la comunidad .NET de herramientas que ayuden a construir aplicaciones basadas en buenos principios arquitectónicos. Entre esas herramientas se encuentra un sistema de inversión de control: Castle Windsor. Ya sabemos lo que es la Inversión de Control (IdC): es un principio de diseño que permite inyectar dependencias. La IdC plantea el problema práctico de tener que preparar todas las dependencias de un objeto antes de ponerlo en funcionamiento o, incluso, de crearlo. Esto puede complicar la lógica de nuestra aplicación y dificultar cambios en el diseño, pues cualquier modificación de un punto de inyección obliga a retocar nuestro código. Los Contenedores de Inversión de Control (Inversion of Control Container) tratan de paliar este problema. Son objetos que memorizan asociaciones entre interfaces e implementaciones y que son capaces de inyectar las dependencias allí donde son necesarias. Nótese que la metodología de diseño que hemos seguido hasta el momento ha descompuesto las aplicaciones en conjunto de datos y servicios. En el ejemplo RSS, los datos se representaban con clases como Feed e Item. Los servicios eran implementaciones de diferentes interfaces: IFormatter, ILoader… Típicamente, en el Contenedor de IdC sólo se dan de alta los servicios, no los datos. Este “dar de alta” consiste en establecer una asociación entre la interfaz (o clase) y la implementación que deseamos usar. El término usado para “dar de alta” es “registrar”, las implementaciones se denominan “componentes” y la creación de instancias allí donde se necesitan se denomina “resolución” (del componente). Cuando se resuelve un componente, el componente debe crearse de algún modo o estar ya disponible. Castle Windsor permite definir diferentes “estilos de vida” (lifestyle) para los componentes. Por defecto, el estilo de vida es Singleton, que consiste en crear una única instancia de un componente que comparten todos los que la necesitan (¿recuerda el patrón de diseño del mismo nombre?). Pero es posible crear instancias diferentes con cada resolución o dependientes de cierta información de contexto.
190
Buenas prácticas en desarrollo de software con .NET
13.1 Instalación En la página http://www.castleproject.org/castle/download.html encontraremos un apartado titulado “Windsor 2.5.3” con el enlace “Download release” (cuya URL es https://sourceforge.net/projects/castleproject/files/Windsor/2.5/Castle.Windsor.2.5.3.zip/download). El paquete contiene librerías compiladas para diferentes “Target framework”. Tendremos que usar el propio de nuestra aplicación. En la carpeta dotnet40, por ejemplo, encontramos las DLL Castle.Core.dll y Castle.Windsor.dll. Basta con hacer referencia a estas librerías en nuestro proyecto para que podamos usar el sistema Castle Windsor.
13.2 Patrón de uso de un contenedor Castle Windsor Se sigue un proceso que se conoce como “de las tres llamadas” o RRR, por Register-Resolve-Release. 13.2.1 Register En el punto de entrada de nuestra aplicación hemos de preparar el contenedor. El proceso de inicialización o bootstrapping suele consistir en un solo método como éste: public IWindsorContainer BootstrapContainer() { return new WindsorContainer().Install(instalador1, instalador2); }
El método Install de WindsorContainer inicializa el contenedor y declara sus instaladores. Los instaladores son objetos que implementan la interfaz IWindsorInstaller, con un solo método denominado Install. Veamos un ejemplo de instalador: public class RepositoriesInstaller : IWindsorInstaller { public void Install(IWindsorContainer container, IConfigurationStore store) { container.Register(AllTypes.FromAssemblyNamed("Acme.Crm.Data") .Where(type => type.Name.EndsWith("Repository")) .WithService.DefaultInterface() .Configure(c => c.LifeStyle.PerWebRequest)); } }
Más adelante veremos qué significa cada elemento del cuerpo de Install. Lo lógico en una aplicación es que creemos varios instaladores, uno por cada paquete de funcionalidad. En una aplicación MVC, por ejemplo, podríamos tener un instalador de vistas, otro de controladores y otro de modelos. Supongamos que tenemos una aplicación con controladores y repositorios. El método de bootstrapping podría ser éste: public IWindsorContainer BootstrapContainer() { return new WindsorContainer().Install(new ControllersInstaller(), new RepositoriesInstaller()); }
El método Install acepta un número variable de parámetros. Para evitar el tedio de dar de alta todos los instaladores, Castle Windsor ofrece dos utilidades:
La clase estática FromAssembly, que permite dar de alta todos los instaladores de un ensamblado.
191
Buenas prácticas en desarrollo de software con .NET
La clase estática Configuration, que permite usar una configuración externa.
Este método de bootstrapping se apoya en ambas utilidades: public IWindsorContainer BootstrapContainer() { return new WindsorContainer().Install(Configuration.FromAppConfig(), FromAssembly.This()); }
13.2.1.1 FromAssembly Estudiemos con un poco más de calma las posibilidades que ofrece FromAssembly:
Con FromAssembly.This() se accede a todos los instaladores (clases que implementan IWindsorInstaller) en el ensamblado desde el que se efectúa la llamada. Con FromAssembly.Named("nombre") se carga el ensamblado que tiene el nombre que se suministra (se busca en los lugares estándar). También se puede suministrar la ruta a un DLL o a un .exe. Con FromAssembly.Containing() lo instala desde el ensamblado que contiene el tipo entre < y >. Con FromAssembly.InDirectory(new AssemblyFilter("Extensions") se instala de los ensamblados que se encuentran en el directorio que se indique. El objeto de la clase AssemblyFilter ayuda a reducir el número de ensamblados en los que buscar. Con FromAssembly.Instance(this.GetPluginAssembly()) se instala desde un ensamblado arbitrario que se suministra directamente.
13.2.1.2 Configuration La clase Configuration ayuda cuando la configuración se suministra con un fichero externo. He aquí algunos casos de uso: container.Install( Configuration.FromAppConfig(), Configuration.FromXmlFile("settings.xml"), Configuration.FromXmlFile( new AssemblyResource("assembly://Acme.Crm.Data/Configuration/services.xml")));
Evidentemente, FromAppConfig carga la configuración de app.config y los otros de un fichero XML. Estudiaremos más adelante la sintaxis de los ficheros XML. 13.2.2 Resolve En la fase de resolución, pedimos al contenedor que inyecte dependencias allí donde hagan falta: var container = new BootstrapContainer(); var shell = container.Resolve(); shell.Display();
Lo normal es que sólo invoquemos a Resolve una sola vez, en el objeto raíz de nuestra aplicación. El contenedor se encarga de resolver las dependencias anidadas. Es parte de la gracia. 13.2.3 Release Al final de la aplicación hemos de invocar necesariamente al método Dispose del contenedor. container.Dispose()
192
Buenas prácticas en desarrollo de software con .NET
Téngase en cuenta que el contenedor mantiene referencias a objetos y que, si no se liberan apropiadamente, podemos tener problemas.
13.3 Una API con interfaz fluida Castle Windsor ofrece una interfaz fluida para registrar componentes. Aunque también se puede configurar con XML, generalmente se prefiere la interfaz fluida. Para usar la API hemos de incluir las referencias Castle.Core.dll y Castle.Windsor.dll y usar el espacio de nombres Castle.MicroKernel.Registration . Partiremos de la base de que se ha creado una instancia de WindsorContainer: IWindsorContainer container = new WindsorContainer();
Para registrar un par interfaz-componente o clase-componente usaremos el método Register. Podemos usarlo libremente, pero recuerde que la práctica recomendada es definir un instalador en el que se hacer las llamadas a Register. 13.3.1 Registro elemento a elemento 13.3.1.1 Registro de un tipo Si deseamos que el contendor gestione la vida de las instancias de una clase, sin necesidad de definir una interfaz, podemos hacerlo así: container.Register(Component.For());
Con ello se creará una instancia única de MyServiceImpl que se inyectará donde se quiera usar un myServiceImpl. Nótese que la instancia es única: el estilo de vida por defecto es “Singleton”. 13.3.1.2 Registro de un tipo para una interfaz Con esta llamada asociamos la clase MyServiceImpl a la interfaz IMyService: container.Register(Component.For().ImplementedBy());
Alternativamente se puede expresar lo mismo con: container.Register(Component.For(typeof(IMyService).ImplementedBy(typeof(MyServiceImpl));
13.3.1.3 Registro de un tipo generic Castle Windsor permite trabajar con tipos genéricos detallando cada caso: container.Register(Component.For>().ImplementedBy>(), Component.For>().ImplementedBy>());
Pero se puede crear una asociación independiente del tipo entre < y >. La sintaxis debe ser la que hace uso de typeof: container.Register(Component.For(typeof(IRepository<>).ImplementedBy(typeof(NHRepository<>));
13.3.1.4 Declaración del estilo de vida Se puede declarar un estilo de vida con la propiedad Lifestyle: container.Register(Component.For() .ImplementedBy() .LifeStyle.Transient);
193
Buenas prácticas en desarrollo de software con .NET
Los valores que se pueden usar son:
Singleton: una instancia única para el contenedor. Es el estilo de vida por defecto. Transient: una instancia cada vez que se necesita un componente. (Deben liberarse manualmente
para no tener problemas de fuga de memoria.) PerThread: una instancia en el ámbito de cada hilo de ejecución. PerWebRequest: una instancia por cada petición web. Pooled: se crea un conjunto de instancias y se devuelve una de ellas para atender la petición. El usuario debe liberar los elementos para que el conjunto disponga de instancias con las que atender peticiones. Hay dos parámetros que definen su comportamiento: o initialSize: número de elementos instanciados inicialmente. o maxSize: número máximo de elementos simultáneamente vivos. Custom: definido por el usuario.
13.3.1.5 Registro de más de un componente para un mismo servicio Se puede dar de alta más de un componente para un mismo servicio. Por defecto se sirve el primero dado de alta: container.Register(Component.For().ImplementedBy(), Component.For().ImplementedBy());
13.3.1.6 Registro de una instancia ya existente Se pueda dar de alta una instancia ya existente, que se servirá como un Singleton (sin alternativa posible): var customer = new CustomerImpl(); container.Register(Component.For().Instance(customer));
13.3.1.7 Registro de una factoría mediante delegado Se puede dar de alta un delegado con el que construir instancias: container.AddFacility() .Register(Component.For() .UsingFactoryMethod(() => MyLegacyServiceFactory.CreateMyService()));
Es posible tratar a la propia factoría como una dependencia: container.AddFacility() .Register(Component.For().ImplementedBy(), Component.For() .UsingFactoryMethod(kernel => kernel.Resolve().Create()));
13.3.1.8 Postproceso del componente creado Puede resultar necesario efectuar un postproceso al componente recién creado antes de su primer uso (si no se crean con una factoría propia, que ya puede/debe incluir esas posibles acciones). Para ello tenemos el método OnCreate, que recibe una lambda-función con dos parámetros (un IKernel y la instancia recién creada): container.Register(Component.For() .ImplementedBy() .OnCreate((kernel, instance) => instance.Name += "a"));
194
Buenas prácticas en desarrollo de software con .NET
13.3.1.9 Asignar un nombre a un componente No sólo se puede acceder por tipo a una dependencia: también con un nombre. El nombre se resuelve con el identificador del parámetro con el que se inyecta la dependencia. container.Register(Component.For() .ImplementedBy() .Named("myservice.default"), Component.For() .ImplementedBy() .Named("myservice.alternative"), Component.For() .ServiceOverrides(ServiceOverride.ForKey("myService") .Eq("myservice.alternative"))); public class ProductController { public ProductController(IMyService myService) { } }
13.3.1.10 Registro de componentes con múltiples interfaces Si un mismo objeto presta varios servicios, se puede registrar para todos ellos: container.Register(Component.For().ImplementedBy());
13.3.2 Dependencias en línea Al registrar componentes puede resultar necesario proporcionar dependencias que no son servicios. Por ejemplo, podemos tener que proporcionar valores de parámetros. 13.3.2.1 Paso de valores “estáticos” Si hay una dependencia con respecto al valor de una propiedad, podemos proporcionar el valor así: var twitterApiKey = @"the key goes here"; container.Register(Component.For().ImplementedBy() .DependsOn(Property.ForKey("APIKey").Eq(twitterApiKey)));
El nombre APIKey concordará sin atender a la caja (minúsculas/mayúsculas). Como esa sintaxis es farragosa, hay una alternativa basada en objetos anónimos: var twitterApiKey = @"the key goes here"; container.Register(Component.For().ImplementedBy() .DependsOn(new { apiKey = twitterApiKey } ));
Y aún otra basada en diccionarios: var twitterApiKey = @"the key goes here"; container.Register(Component.For().ImplementedBy() .DependsOn(new Dictionary{{"APIKey", twitterApiKey}})));
13.3.2.2 Selección de implementación Podemos seleccionar una implementación por nombre cuando se haya de resolver la dependencia de un componente: container.Register(Component.For() .ImplementedBy() .DependsOn(Property.ForKey("Logger").Is("secureLogger"))
195
Buenas prácticas en desarrollo de software con .NET
);
En el ejemplo se dice que lo que se denomine Logger debe resolverse con una instancia de secureLogger, que es un nombre con el que se ha registrado un logger determinado en el contenedor. Alternativamente se puede usar el método ServiceOverrides: container.Register(Component.For() .ImplementedBy() .DependsOn(ServiceOverride.ForKey("Logger").Eq("secureLogger")));
Este método hace posible usar una expresión más elegante: container.Register(Component.For() .ImplementedBy() .ServiceOverrides(new { logger = "secureLogger" }));
13.3.2.3 Parámetros dinámicos Puede que el valor que deseamos suministrar no se conozca hasta el tiempo de ejecución. El método DynamicParameters recibe un delegado con dos parámetros: un IKernel y un diccionario con los parámetros suministrados. container.Register(Component.For() .LifeStyle.Transient .DynamicParameters((k, d) => d["createdTimestamp"] = DateTime.Now));
Opcionalmente se puede devolver un método (un delegado) que se invocará cuando el objeto se destruya: container.Register(Component.For() .LifeStyle.Transient .DynamicParameters((k, d) => { d["arg1"] = "foo"; return kk => ++releaseCalled; }));
13.3.3 Registro por convenio Podemos evitar el registro elemento a elemento usando algunos convenios. Con la clase AllTypes se puede acceder a grupos de tipos que cumplen ciertas propiedades. La interfaz fluida que hace uso de AllTypes recurre a tres pasos:
Selección del ensamblado. AllTypes.FromThisAssembly()…
Selección del tipo base o la condición. o Fijando un tipo base o una interfaz. AllTypes.FromThisAssembly().BasedOn()
o
Con una condición. AllTypes.FromAssemblyContaining() .Where(t=>Attribute.IsDefined(t, typeof(CacheAttribute)))
o
Sin restricciones. AllTypes.FromAssemblyNamed("Acme.Crm.Services").Pick()
Filtrado adicional y configuración.
Veamos algunos ejemplos.
196
Buenas prácticas en desarrollo de software con .NET
Podemos, por ejemplo, registrar todos los descendientes de un tipo dado: container.Register(AllTypes.FromThisAssembly() .BasedOn() .Configure(c => c.Lifestyle.Transient));
O registrar los que son descendientes de una interfaz u otra: container.Register(AllTypes.FromAssemblyContaining() .BasedOn() .BasedOn());
En los dos casos anteriores no está claro a que se asocia el tipo. Se puede especificar con WithService. En ese ejemplo se asocia ICommand al primer componente que implementa esa interfaz: container.Register(AllTypes.FromThisAssembly() .BasedOn(typeof(ICommand<>)).WithService.Base() .BasedOn(typeof(IValidator<>)).WithService.Base());
Este otro selecciona el tipo atendiendo al nombre de la interfaz y de la clase: IServicio se asociará a Servicio: container.Register(AllTypes.FromThisAssembly() .Where(Component.IsInNamespace("Acme.Crm.Services")) .WithService.DefaultInterface());
Con este otro se registran todas las clases que implementen directa o indirectamente una interfaz: container.Register(AllTypes.FromThisAssembly() .BasedOn().WithService.FromInterface());
Con WithService.AllInterfaces() se registra un componente en todas las interfaces que implementa. Con WithService.Self() se registra como un servicio asociado a su propio tipo. Y con WithService.Select se proporciona lógica con un delegado para indicar a qué se asocia una clase. Es posible acceder a los tipos no públicos de una interfaz: container.Register(AllTypes.FromThisAssembly() .IncludeNonPublicTypes() .BasedOn());
13.3.4 Registro condicional Es posible registrar componentes imponiendo condiciones. 13.3.4.1 Sólo si no se había registrado antes container.Register(Component.For(typeof(IRepository<>)) .ImplementedBy(typeof(Repository<>)) .Unless(Component.ServiceAlreadyRegistered));
13.3.4.2 Filtrando componentes en registro múltiple container.Register(AllTypes.Of() .FromAssembly(Assembly.GetExecutingAssembly()) .Unless(t => typeof(SpecificCustomer).IsAssignableFrom(t))); container.Register(AllTypes.Of() .FromAssembly(Assembly.GetExecutingAssembly()) .If(t => t.FullName.Contains("Chain")));
197
Buenas prácticas en desarrollo de software con .NET
container.Register(AllTypes.FromAssembly(Assembly.GetExecutingAssembly()) .Where(Component.IsInSameNamespaceAs()) .WithService.FirstInterface()); container.Register(AllTypes.Of() .Pick(from type in Assembly.GetExecutingAssembly().GetExportedTypes() where type.IsDefined(typeof(SerializableAttribute), true) select type));
13.4 Proceso interno de resolución de dependencias Supongamos que hemos dado de alta una asociación interfaz-implementación en el contenedor. ¿Qué ocurre cuando se resuelve la dependencia, es decir, cuando alguien declara necesitar una instancia de una clase que implemente la interfaz? Castle Windsor actúa en una serie de pasos: 1. En primer lugar, comprueba que hay una asociación dada de alta para esa interfaz. Si no hay un componente registrado, el contenedor trata de registrarlo perezosamente. Si no tiene éxito, lanza una excepción ComponentNotFoundException. Si tiene éxito, el contenedor obtendrá un manejador del componente (handler) y le pedirá que resuelva la instancia del componente. 2. El manejador invoca los parámetros dinámicos que se declararon en su momento. 3. Si no se dan argumentos en línea, el contenedor trata de resolver todas las dependencias que tenga el componente. Para ello intenta resolver primero las dependencias por nombre y después por tipo. Si no lo consigue, lanza una excepción HandlerException. 4. Si todo fue bien, se pide a un gestor de estilo de vida (lifestyle manager) que resuelva el componente. El gestor de estilo de vida funciona, en principio, así: a. Si ya hay una instancia de ese componente, se entrega una referencia a la instancia. b. Si no, se invoca a un activador de componentes para crear una instancia nueva. Esto supone invocar el constructor de una clase. Cuando se ha creado el componente, se invoca a todos los “comision concerns” del componente, se dispara un evento ComponentCreated y se devuelve la instancia al gestor de ciclo de vida para que devuelva, a su vez, una referencia a ella.
13.5 Configuración con app.config (o, en general, con fichero XML) Ya sabemos trabajar con ficheros app.config. Castle Windsor permite definir una sección propia en la que definir componentes. He aquí un ejemplo:
198
Buenas prácticas en desarrollo de software con .NET
En el elemento podemos crear elementos . Cada elemento declara un componente que se da de alta en el contenedor. El XML no tiene por qué ser el app.config. Vale cualquier otro fichero XML: IWindsorContainer _container = new WindsorContainer(new XmlInterpreter("CastleConfig.xml"));
Como hemos dado de alta los componentes de dos modos, esto es, con un un identificador y como un servicio, podemos resolver los componentes de cualquiera de los dos modos: userManager = (IUserManager)_container.Resolve("userManagerService"); userManager = _container.Resolve();
14 Aspect Oriented Programming En el software es común encontrar lo que se conoce por intereses ortogonales (cross-cutting concerns). Un “interés” (concern) es un área cohesiva de funcionalidad. Por ejemplo, el registro de actividades es un interés transversal, o el uso de cachés para reducir el tiempo de respuesta a costa de memoria o añadir control de acceso a un método en función de los roles de un usuario. La funcionalidad suele implementarse con clases, métodos, etc., pero los intereses ortogonales no aceptan cómodamente este tipo de división. El registro de actividades, por ejemplo, puede hacerse al inicio y final de cada método invocado. Escribir el código correspondiente al inicio y final de cada método no es muy apropiado. No sólo porque resulte incómodo, sino también porque el método debería hacer aquello para lo que se ha diseñado… y nada más. Registrar la actividad puede resultar de interés para la aplicación, pero su lógica escapa al interés más inmediato del método. Dicho esto, ya podemos definir la programación orientada a aspectos (AOP, de Aspect Oriented Programming) como un paradigma de programación que aumenta la modularidad permitiendo la separación de los intereses ortogonales.
14.1 Una solución basada en el patrón Proxy Si queremos respetar que cada método hace lo que debe hacer y nada más, podemos definir un decorador que enriquezca nuestra implementación con la funcionalidad deseada. La clase decorada mantendría la lógica que le es propia y la decoradora podría gestionar el registro de actividad antes y/o después de cada llamada a un método de la decorada. El modelo de composición propio de los decoradores facilita el enriquecer la funcionalidad de una clase con más de un interés ortogonal. Por cierto, los decoradores tienen por objeto enriquecer la funcionalidad de una clase dinámicamente. Cuando el objetivo se centra más en controlar el acceso al objeto del que se apropia, el patrón se conoce por Proxy (apoderado). El Proxy pretende esconder los detalles del cliente y es corriente que el Proxy cree directamente al objeto del que se apropia (a diferencia del Decorator, que siempre lo recibe como parámetro en la construcción). Es más, la relación entre apoderado y objeto del que se toma posesión suele cerrarse en tiempo de compilación, cuando el decorador permite que la relación se establezca en tiempo de ejecución. La solución parece apropiada, pero obliga a definir más y más clases. Hay herramientas que simplifican esta labor.
199
Buenas prácticas en desarrollo de software con .NET
14.1.1 Castle DynamicProxy Castle Project ofrece una librería que facilita la creación dinámica de Proxies, esto es, que permite obtener al vuelo clases que extienden la funcionalidad de otras haciéndose cargo de su construcción (recuerde: un Proxy se diferencia de un Decorator en el proceso de construcción). La extensión de funcionalidad se proporciona mediante interceptores (Interceptors) que se pueden encadenar. Los interceptores son objetos que implementan la interfaz IInterceptor. Esa interfaz consiste en un único método: Intercept, que recibe un objeto de tipo IInvocation y no devuelve nada. Este ejemplo sencillo ayuda a entender un interceptor. using System; using Castle.DynamicProxy; namespace Interceptando { public class MiInterceptor : IInterceptor { #region IInterceptor Members public void Intercept(IInvocation invocation) { Console.WriteLine("Antes de la llamada a {0}", invocation.Method.Name); invocation.Proceed(); Console.WriteLine("Después de la llamada a {0}", invocation.Method.Name); } #endregion } }
Este interceptor imprime por consola un mensaje antes de que se produzca la llamada al método interceptado y otro cuando la llamada ha concluido. El mensaje incluye el nombre del método invocado. ¿Cómo asociamos el interceptor a los objetos de una clase? Empecemos definiendo una clase muy sencilla: using System; namespace Interceptando { public class MiClase { public virtual void Saludo(string nombre) { Console.WriteLine("¡Hola, {0}!", nombre); } public virtual void Despedida(string nombre) { Console.WriteLine("¡Adios, {0}!", nombre); } } }
Nótese que hemos definido los métodos como virtuales. En el comportamiento por defecto, el apoderamiento se basa en herencia y sólo podemos interceptar métodos virtuales. Y ahora definamos el programa principal: using System; using Castle.DynamicProxy;
200
Buenas prácticas en desarrollo de software con .NET
namespace Interceptando { public static class Program { public static void Main(string[] args) { var miObjeto = new MiClase(); var persona = "Pepe"; miObjeto.Saludo(persona); miObjeto.Despedida(persona); var proxyGen = new ProxyGenerator(); var proxy = proxyGen.CreateClassProxy(new MiInterceptor()); proxy.Saludo(persona); proxy.Despedida(persona); Console.ReadKey(); } } }
La variable proxyGen contiene una factoría de proxies. Con CreateClassProxy solicitamos una instancia de un Proxy para MiClase que use el interceptor MiInterceptor. Al ejecutar el programa obtenemos esta salida: ¡Hola, Pepe! ¡Adios, Pepe! Antes de la llamada a ¡Hola, Pepe! Después de la llamada Antes de la llamada a ¡Adios, Pepe! Después de la llamada
Saludo a Saludo Despedida a Despedida
Las dos primeras llamadas se han hecho sobre un objeto “directo” y no hay intercepción de llamadas. Las dos siguientes se hacen sobre el Proxy y antes y después de la llamada hemos conseguido introducir salida por pantalla. ¿Y si deseamos interceptar sólo la llamada a Saludo y no la llamada a Despedida? Hemos de crear un objeto que permite determinar los métodos interceptados: public class MiInterceptorProxyGenerationHook : IProxyGenerationHook { #region IProxyGenerationHook Members public void MethodsInspected() { } public void NonProxyableMemberNotification(Type type, System.Reflection.MemberInfo memberInfo) { } public bool ShouldInterceptMethod(Type type, System.Reflection.MethodInfo methodInfo) { return (methodInfo.Name == "Saludo"); } #endregion
201
Buenas prácticas en desarrollo de software con .NET
}
Nos interesa el método ShouldInterceptMethod, que recibe el tipo de la clase del método que podemos interceptar y un objeto con información del método (en el que podemos acceder, entre otros, a datos como su nombre). Si el método devuelve true, hemos de interceptarlo9. Ahora hemos de pasar este “gancho” al creador del proxy: using System; using Castle.DynamicProxy; namespace Interceptando { public static class Program { public static void Main(string[] args) { var miObjeto = new MiClase(); var persona = "Pepe"; miObjeto.Saludo(persona); miObjeto.Despedida(persona); var proxyGen = new ProxyGenerator(); var options = new ProxyGenerationOptions(new MiInterceptorProxyGenerationHook()); var proxy = proxyGen.CreateClassProxy(options, new MiInterceptor()); proxy.Saludo(persona); proxy.Despedida(persona); Console.ReadKey(); } } }
La librería DynamicProxy puede ser útil para construir stubs. Es posible crear un Proxy dinámico para una interfaz de modo que la implementación la proporcionen los interceptores. Pero esto supone alejarnos de nuestro objetivo, que es presentar AOP con Castle Windsor.
14.2 AOP con Castle Windsor Castle Windor cuenta entre sus bloques básicos con una herramienta que simplifica la creación de Proxies: Castle DynamicProxy. La implementación de AOP de Castle Windsor se apoya en el uso y extensión de esa herramienta. Castle Windsor permite interceptar las llamadas a métodos de un objeto y ejecutar código de usuario antes y/o después de la llamada. Para ello utiliza la librería DynamicProxy, pero con una cara algo más amable. Hay tres modos de declarar interceptores:
9
Con la interfaz fluida. Con atributos. Con un fichero XML.
De los tres métodos sólo hemos proporcionado lógica para uno de ellos. Los otros dos son auxiliares y útiles durante el desarrollo. El método MethodsInspected se invoca cuando todos los métodos de la clase subordinada se han inspeccionado y el método NonProxyableMemberNotification se invoca sobre los métodos que no se pueden interceptar. Los métodos no virtuales, por ejemplo, no se pueden interceptar.
202
Buenas prácticas en desarrollo de software con .NET
En cualquier caso, lo primero que hemos de hacer es declarar los interceptores como componentes. Mostramos primero los ficheros implicados en la definición e instalación del interceptor: MiInterceptor.cs using System; using Castle.DynamicProxy; namespace InterceptandoMas { public class MiInterceptor : IInterceptor { #region IInterceptor Members public void Intercept(IInvocation invocation) { Console.WriteLine("Antes de la llamada a {0}", invocation.Method.Name); invocation.Proceed(); Console.WriteLine("Después de la llamada a {0}", invocation.Method.Name); } #endregion } }
InterceptorsInstaller.cs using Castle.MicroKernel.Registration; using Castle.MicroKernel.SubSystems.Configuration; using Castle.Windsor; namespace InterceptandoMas { public class InterceptorsInstaller : IWindsorInstaller { #region IWindsorInstaller Members public void Install(IWindsorContainer container, IConfigurationStore store) { container.Register(Component.For()); } #endregion } }
Ahora definimos nuestro componente y su instalador: MiClase.cs using System; namespace InterceptandoMas { public class MiClase { public virtual void Saludo(string nombre) { Console.WriteLine("¡Hola, {0}!", nombre); } public virtual void Despedida(string nombre) {
203
Buenas prácticas en desarrollo de software con .NET
namespace InterceptandoMas { public class MisClasesInstaller : IWindsorInstaller { #region IWindsorInstaller Members public void Install(IWindsorContainer container, IConfigurationStore store) { container.Register(Component.For() .Interceptors(InterceptorReference .ForType()) .First); } #endregion } }
Este último fichero requiere un examen más detallado. Estamos registrando nuestro componente e indicando que hay un interceptor que afectará a su comportamiento. Nótese que acabamos la secuencia de llamadas con First. Con ello indicamos que este interceptor debe ejecutarse en primer lugar en el caso de que haya varios interceptores asociados al componente. Además de First, podemos indicar orden con Last, AnyWhere o señalando el orden exacto con AtIndex(int). El programa principal queda así: using System; using Castle.Windsor; namespace InterceptandoMas { public static class Program { public static void Main(string[] args) { var container = new WindsorContainer(); container.Install(new InterceptorsInstaller(), new MisClasesInstaller()); var miObjeto = new MiClase(); var persona = "Pepe"; miObjeto.Saludo(persona); miObjeto.Despedida(persona); var mio = container.Resolve(); mio.Saludo("Toni"); mio.Despedida("Toni"); Console.ReadKey(); } }
204
Buenas prácticas en desarrollo de software con .NET
}
Hemos creado un contenedor y dado de alta sus instaladores. Al crear un componente de MiClase, el sistema nos devuelve un objeto con sus métodos interceptados. Si ejecutamos el programa tenemos: ¡Hola, Pepe! ¡Adios, Pepe! Antes de la llamada a ¡Hola, Toni! Después de la llamada Antes de la llamada a ¡Adios, Toni! Después de la llamada
Saludo a Saludo Despedida a Despedida
¿Cómo podemos controlar qué métodos se interceptan y cuáles no? Una solución chapucera sería definir código en el propio método Intercept del interceptor para que examine cada vez el tipo y método interceptados y decida si debe hacer algo o, sencillamente, llamar a Proceed() sobre el IInvocation que se le suministra. Pero esto es ineficiente, así que conviene encontrar otra solución. Para seleccionar los selectores que se aplican a cada tipo/método podemos crear un objeto que implemente la interfaz IInterceptorSelector. Esta interfaz sólo tiene un método. Su perfil es este: public IInterceptor[] SelectInterceptors(Type type, System.Reflection.MethodInfo method, IInterceptor[] interceptors)
La idea es que incluyamos lógica que, dado un tipo, un método y un vector de interceptores, deje pasar únicamente los interceptores aplicables a ese tipo/método. Supongamos que sólo deseamos aplicar el interceptor al método Saludo de no importa qué tipo. Escribiremos código como éste: MiSelectorDeInterceptores.cs using System; using Castle.DynamicProxy; namespace InterceptandoMas { class MiSelectorDeInterceptores : IInterceptorSelector { #region IInterceptorSelector Members public IInterceptor[] SelectInterceptors(Type type, System.Reflection.MethodInfo method, IInterceptor[] interceptors) { if (method.Name == "Saludo") { return interceptors; } else { return new IInterceptor[0]; } } #endregion } }
205
Buenas prácticas en desarrollo de software con .NET
Al dar de alta el componente con sus interceptores podemos indicar que queremos que haya un proceso de selección: using using using using
namespace InterceptandoMas { public class MisClasesInstaller : IWindsorInstaller { #region IWindsorInstaller Members public void Install(IWindsorContainer container, IConfigurationStore store) { container.Register(Component.For() .Interceptors( InterceptorReference.ForType()) .SelectedWith(new MiSelectorDeInterceptores()) .First); } #endregion } }
Ejecutemos ahora el programa: ¡Hola, Pepe! ¡Adios, Pepe! Antes de la llamada a Saludo ¡Hola, Toni! Después de la llamada a Saludo ¡Adios, Toni!
Sólo el método Saludo ha sido interceptado. 14.2.1 Un ejemplo útil: caché de llamadas Ciertas llamadas a métodos pueden resultar muy costosas y nuestra aplicación puede efectuar numerosas llamadas con los mismos parámetros para obtener un mismo resultado. Una estrategia para agilizar las respuestas es memorizar las que ya se han calculado previamente para convertir futuros cálculos repetidos en meras consultas a una tabla. Empezamos por los componentes: dos clases para calcular ciertas funciones. Calculos.cs namespace Caching { public class Fibonacci { public virtual int Compute(int n) { if (n <= 1) { return 1; } else { return Compute(n-1) + Compute(n-2);
206
Buenas prácticas en desarrollo de software con .NET
} } } public class Factorial { public virtual int Compute(int n) { if (n <= 1) { return 1; } else { return n * Compute(n-1); } } } }
Nuestro interceptor memorizará en un diccionario de diccionarios los resultados de cada entrada para cada llamada a un método: CachingInterceptor.cs using using using using using