В погоне за качеством кода: Безопасное программирование с помощью АОП (исходники)

Эндрю Гловер

Хотя защитное программирование надежно гарантирует состояние входных данных метода, его применение к целым сериям методов требует повторяющихся операций. В статье этого месяца Эндрю Гловер показывает простой способ добавления к коду многократно используемых проверочных ограничений с помощью АОП, контрактных спецификаций (design by contract) и полезной библиотеки под названием OVal

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

Что такое крайний случай? Это ситуация, когда, например, передается значение null в метод, не запрограммированный для обработки значений null. Многие разработчики тестируют такие сценарии, поскольку не видят в них смысла. Но есть смысл или нет, такое случается, выдается NullPointerException, и программа вылетает.

В этом месяце автор представляет многогранный подход к устранению труднопредсказуемых дефектов в коде. Узнайте, что дает объединение принципов защитного программирования, контрактной спецификации и удобной общей среды проверки OVal.

Выявление врага

Код в листинге 1 строит иерархию классов для объекта Class (пропуская java.lang.Object, его в конечном счете расширяют все). Если посмотреть внимательно, можно заметить затаившийся потенциальный дефект, обусловленный сделанными в методе предположениями относительно значений объектов.

Листинг 1. Метод без проверки значения null

                
public static Hierarchy buildHierarchy(Class clzz){

 Hierarchy hier = new Hierarchy();
 hier.setBaseClass(clzz);
 Class superclass = clzz.getSuperclass();

 if(superclass != null && superclass.getName().equals("java.lang.Object")){
  return hier; 
 }else{      
  while((clzz.getSuperclass() != null) && 
    (!clzz.getSuperclass().getName().equals("java.lang.Object"))){
     clzz = clzz.getSuperclass();
     hier.addClass(clzz);
  }	        
  return hier;
 }
}     

При написании этого кода я не заметил этого дефекта, но поскольку я - фанатик предварительного тестирования, то написал стандартный тест с помощью TestNG. Более того, я использовал удобную функцию DataProvider TestNG, позволяющую создать типовой тест и затем изменять его параметры с помощью другого метода. Тест, приведенный в листинге 2, дважды прошел успешно! Все идет нормально, так?

Листинг 2. Тест TestNG для проверки двух значений

                
import java.util.Vector;
import static org.testng.Assert.assertEquals;
import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;

public class BuildHierarchyTest {
	
 @DataProvider(name = "class-hierarchies")
 public Object[][] dataValues(){
  return new Object[][]{
   {Vector.class, new String[] {"java.util.AbstractList", 
      "java.util.AbstractCollection"}},
   {String.class, new String[] {}}
  };
 }

 @Test(dataProvider = "class-hierarchies"})
 public void verifyHierarchies(Class clzz, String[] names) throws Exception{
  Hierarchy hier = HierarchyBuilder.buildHierarchy(clzz);
  assertEquals(hier.getHierarchyClassNames(), names, "values were not equal");
 }
}

Дефект по-прежнему не обнаружен, но что-то в коде мне не нравится. Что если кто-то нечаянно передаст значение null в параметр Class? Вызов clzz.getSuperclass() в четвертой строке листинга 1 вызовет NullPointerException, не так ли?

Проверить эту теорию довольно просто; не требуется даже начинать с нуля. Я просто добавляю {null, null} к многомерному массиву Object в методе dataValues исходного теста BuildHierarchyTestлистинге 1) и запускаю тестирование еще раз. Разумеется, я получаю неприятное исключение NullPointerException, представленное на рисунке 1:

Рисунок 1. Ужасный NullPointerException
Ужасный NullPointerException

Здесь можно увидеть рисунок полностью.

 
Как насчет статического анализа?

Инструменты статического анализа, подобные FindBugs, исследуют классы и JAR-файлы в поисках потенциальных проблем, сравнивая байт-код со списком шаблонов ошибок. Запуск FindBugs для кода данного примера не выявил NullPointerException, показанного в листинге 1.

Защитное программирование

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

Классическая стратегия защитного программирования, предназначенная для обработки неопределенностей - это проверка объектов. Соответственно я добавил проверку равенства clzz значению null (см. листинг 3). Если значение равно null, то вызывается метод RuntimeException для предупреждения всех заинтересованных сторон о потенциальной проблеме.

Листинг 3. Добавляем проверку на равенство null

                
public static Hierarchy buildHierarchy(Class clzz){
 
 if(clzz == null){
  throw new RuntimeException("Class parameter can not be null");
 }

 Hierarchy hier = new Hierarchy();
 hier.setBaseClass(clzz);

 Class superclass = clzz.getSuperclass();

 if(superclass != null && superclass.getName().equals("java.lang.Object")){
  return hier; 
 }else{      
  while((clzz.getSuperclass() != null) && 
    (!clzz.getSuperclass().getName().equals("java.lang.Object"))){
     clzz = clzz.getSuperclass();
     hier.addClass(clzz);
  }		        
  return hier;
 }
}     

Естественно, я также написал быстрый тест, чтобы удостовериться, что моя проверка действительно предотвращает NullPointerException (см. листинг 4):

Листинг 4. Подтверждение действенности проверки на null

                
@Test(expectedExceptions={RuntimeException.class})
public void verifyHierarchyNull() throws Exception{
 Class clzz = null;
 HierarchyBuilder.buildHierarchy(null);		
}    

В данном случае защитное программирование, похоже, выполняет свою роль. Но использование исключительно данной стратегии имеет свои издержки.

Издержки защитного программирования

 
Как насчет операторов контроля?

В листинге 3 для проверки значения clzz используется условный оператор, хотя также можно было бы использовать assert. При использовании операторов assert не требуется задавать условие или оператор исключения. Все задачи защитного программирования полностью реализуются на уровне JVM, если мы включили операторы контроля.

Хотя защитное программирование надежно гарантирует состояние входных данных метода, его применение к целым сериям методов требует повторяющихся операций. Знакомые с аспектно-ориентированным программированием, или АОП, узнают в этом сквозную задачу (crosscutting concern) , что означает, что методы защитного программирования простираются горизонтально по всему массиву кода. Эта семантика используется во многих различных объектах, но с чисто объектно-ориентированной точки зрения она не имеет ничего общего с самими объектами.

Более того, концепция сквозных задач начинает просачиваться в понятие контрактных спецификаций программных компонентов (design by contract, DBC) . DBC - это технология, которая призвана гарантировать, что все компоненты в системе выполняют именно то, что они должны выполнять, за счет явного указания в интерфейсе каждого компонента его требуемой функциональности и ожиданий клиента. На языке DBC требуемая функциональность компонента называется постусловием и является, по существу, "обязательствами" компонента, а ожидания клиента собирательно называются предусловием . Более того, в терминах чистого DBC класс, действующий в соответствии с правилами DBC, имеет "контракт" с окружающим миром относительно поддержания им внутренней согласованности, известный как инвариант класса .

Контрактные спецификации

Понятие DBC я ввел некоторое время назад в статье о программировании на Nice, объектно-ориентированном, совместимом с JRE языке программирования, разработанном с акцентом на модульность, выразительные возможности и безопасность. Интересно, что в Nice используются функциональные методы разработки, включая некоторые методы аспектно-ориентированного программирования. Среди прочего, функциональная разработка позволяет задавать предусловия и постусловия для методов.

Хотя язык Nice поддерживает DBC, он существенно отличается от Java™ , поэтому его сложно внедрить в группах по разработке ПО. К счастью, имеются библиотеки для Java, облегчающие реализацию DBC. Каждая библиотека имеет свои "за" и "против", разные библиотеки могут использовать различные подходы к реализации DBC для Java; однако последние новинки в этой области позволяют использовать для реализации задач DBC средства АОП, действующие как "упаковки" методов.

Предусловие проверяется перед выполнением "упакованного" метода, постусловие - после завершения выполнения метода. Одна из приятных особенностей использования АОП для создания конструкций DBC заключается в том, что сами конструкции можно отключить в средах, где DBC не требуется (подобно тому, как можно отключить операторы контроля). Однако настоящее изящество реализации принципов защитного программирования с помощью сквозных задач заключается в возможности эффективного многократного использования этих средств. Как известно, многократное использование является одним из основных принципов объектно-ориентированного программирования. Не правда ли, АОП здорово дополняет ООП?

АОП с использованием OVal

OVal представляет собой общую среду проверки кода, поддерживающую простые конструкции DBC посредством АОП и позволяющую выполнять следующие операции:

  • Задавать ограничения для полей классов и возвращаемых методами значений;
  • Задавать ограничения для параметров конструкторов;
  • Задавать ограничения для параметров методов.

Более того, OVal включает множество стандартных ограничений, что значительно упрощает процесс создания новых ограничений.

Поскольку в OVal для определения элементов advices (рекомендаций) для понятий DBC используется реализация АОП AspectJ, в проект, использующий OVal, необходимо включить AspectJ. Хорошей новостью для тех, кто не знаком с АОП и AspectJ, является то, что требуется минимум усилий, а использование OVal (даже для создания новых ограничений) не потребует фактического кодирования аспектов, за исключением создания простого аспекта начальной загрузки, который подключает к коду аспекты, входящие в состав OVal.

Перед созданием такого аспекта начальной загрузки необходимо загрузить AspectJ. В частности, необходимо включить JAR-файлы aspectjtools и aspectjrt в сборку для компиляции необходимого аспекта начальной загрузки и внедрения его в код.

Начальная загрузка АОП

После загрузки AspectJ необходимо создать аспект, расширяющий GuardAspect среды OVal. Сам он ничего не должен делать, как показано в листинге 5. Проследите, чтобы расширение файла заканчивалось на .aj, и не пытайтесь компилировать файл с обычным расширением javac.

Листинг 5. Аспект начальной загрузки DefaultGuardAspect

                
import net.sf.oval.aspectj.GuardAspect;

public aspect DefaultGuardAspect extends GuardAspect{	
 public DefaultGuardAspect(){
  super();		
 }	
}

AspectJ включает задачу Ant под названием iajc, действующую подобно javac; но в этом процессе аспекты компилируются и встраиваются в субъектный код. В данном случае, везде, где я укажу ограничения OVal, логика, определенная в коде OVal, будет встроена в код и будет действовать как предусловия и постусловия.

Следует помнить, что iajc заменяет собой javac. Например, в листинге 6 приведен фрагмент кода Ant файла build.xml, который компилирует код и встраивает в него аспекты OVal, обнаруженные по аннотациям в коде, как вы вскоре увидите:

Листинг 6. Фрагмент файла сборки Ant с компиляцией АОП

                
<target name="aspectjc" depends="get-deps">

 <taskdef resource="org/aspectj/tools/ant/taskdefs/aspectjTaskdefs.properties">
  <classpath>
   <path refid="build.classpath" />
  </classpath>
 </taskdef>

 <iajc destdir="${classesdir}" debug="on" source="1.5">
  <classpath>
   <path refid="build.classpath" />
  </classpath>
  <sourceroots>
   <pathelement location="src/java" />
   <pathelement location="test/java" />
  </sourceroots>
 </iajc>

</target>

Теперь, после того как мы настроили средства OVal и осуществили начальную загрузку процесса АОП, можно приступить к заданию простых ограничений для кода с использованием аннотирования Java 5.

Многократно используемые ограничения OVal

Чтобы задать постусловия для метода с помощью OVal, необходимо аннотировать параметры метода. Соответственно, при вызове метода, аннотированного с ограничениями OVal, OVal проверяет ограничения до фактического выполнения метода.

В нашем случае хотелось бы сделать так, чтобы метод buildHierarchy не вызывался при передаче значения null для параметра Class. В стандартной комплектации OVal поддерживает это ограничение с помощью аннотации @NotNull, которую можно ставить перед любым нужным параметром метода. Обратите также внимание, что любой класс, где мы хотим использовать ограничения OVal, должен также иметь аннотацию @Guarded на уровне класса, как это сделано в листинге 7:

Листинг 7. Ограничения OVal в действии

                
import net.sf.oval.annotations.Guarded;
import net.sf.oval.constraints.NotNull;

@Guarded
public class HierarchyBuilder {  

 public static Hierarchy buildHierarchy(@NotNull Class clzz){

  Hierarchy hier = new Hierarchy();
  hier.setBaseClass(clzz);

  Class superclass = clzz.getSuperclass();

  if(superclass != null && superclass.getName().equals("java.lang.Object")){
   return hier; 
  }else{      
   while((clzz.getSuperclass() != null) && 
     (!clzz.getSuperclass().getName().equals("java.lang.Object"))){
       clzz = clzz.getSuperclass();
       hier.addClass(clzz);
    }	        
   return hier;
  }
 }      
}

После того как мы задали это ограничение с помощью аннотаций, нам больше не требуется каждый раз добавлять в код условия для проверки на null и вызова исключения при обнаружении этого значения. Теперь эта логика обрабатывается OVal, который фактически действует таким же образом. Если ограничение нарушается, OVal вызывает метод ConstraintsViolatedException, который является подклассом RuntimeException.

Разумеется, следующий шаг заключается в компиляции класса а HierarchyBuilder и соответствующего DefaultGuardAspect, представленного в листинге 5. Для этого используется задача iajc из листинга 6, которая позволяет встроить функциональность OVal встроить в мой код.

Теперь модифицируем тест, представленный в листинге 4, чтобы убедиться, что вызов ConstraintsViolatedException , действительно происходит; см. листинг 8:

Листинг 8. Проверка исключения ConstraintsViolatedException

                
@Test(expectedExceptions={ConstraintsViolatedException.class})
public void verifyHierarchyNull() throws Exception{
 Class clzz = null;
 HierarchyBuilder.buildHierarchy(clzz);		
}

Определение постусловий

Как видно, определение предусловий является довольно простой операцией. То же можно сказать и об определении постусловий. Например, если нам нужно, чтобы никакой из операторов, вызывающих buildHierarchy, не возвращал значение null (и, следовательно, нам не нужно было проверять наличие этого значения), можно перед объявлением метода поместить аннотацию @NotNull, как это показано в листинге 9:

Листинг 9. Постусловия в OVal

                
                @NotNull
public static Hierarchy buildHierarchy(@NotNull Class clzz){   
 //method body
}

Разумеется,@NotNull - не единственное ограничение, предоставляемое OVal , но я считаю, что оно очень полезно для снижения количества раздражающих исключений NullPointerException или хотя бы для быстрого их обнаружения .

Другие ограничения OVal

OVal также поддерживает средства предварительной оценки членов классов до или после вызова методов. Преимущество этого механизма заключается в том, что он уменьшает потребность в многократных проверках ограничений, например, при проверке размеров коллекций или обсуждаемого ранее условия неравенства значению null.

Например, в листинге 10 приведена задача Ant, создающая отчет для иерархии классов с помощью HierarchyBuilder. Обратите внимание, как в методе execute() вызывается validate, который, в свою очередь, проверяет наличие значений в члене класса fileSet; в противном случае было бы вызвано исключение, поскольку без оцениваемых классов создать отчет невозможно.

Листинг 10. Задача HierarchyBuilderTask с проверкой условия

                
public class HierarchyBuilderTask extends Task {
 private Report report;
 private List fileSet;

 private void validate() throws BuildException{
  if(!(this.fileSet.size() > 0)){
   throw new BuildException("must supply classes to evaluate");
  }
  if(this.report == null){
   this.log("no report defined, printing XML to System.out");
  }
 }

 public void execute() throws BuildException {
  validate();
  String[] classes = this.getQualifiedClassNames(this.fileSet);
  Hierarchy[] hclz = new Hierarchy[classes.length];

  try{
   for(int x = 0; x < classes.length; x++){
    hclz[x] = HierarchyBuilder.buildHierarchy(classes[x]);	            
   }        
   BatchHierarchyXMLReport xmler = new BatchHierarchyXMLReport(new Date(), hclz);
   this.handleReportCreation(xmler);
  }catch(ClassNotFoundException e){
   throw new BuildException("Unable to load class check classpath! " + e.getMessage());
  }  
 }
//more methods below....
}

Поскольку я использую OVal, я могу поступить так:

  • Задать с помощью аннотации @Size ограничение для члена класса fileSet с условием, что размер всегда равен 1 или больше;
  • Обеспечить проверку этого ограничения до вызова метода execute() с помощью аннотации @PreValidateThis.

Эти два шага позволяют эффективно устранить проверку условий в методе validate() и переложить эту работу на OVal (см. листинг 11):

Листинг 11. Измененная задача HierarchyBuilderTask без проверки условий

                
                @Guarded
public class HierarchyBuilderTask extends Task {
 private Report report;

 @Size(min = 1)
 private List fileSet;

 private void validate() throws BuildException {
  if (this.report == null) {
   this.log("no report defined, printing XML to System.out");
  }
 }

 @PreValidateThis
 public void execute() throws BuildException {
  validate();
  String[] classes = this.getQualifiedClassNames(this.fileSet);
  Hierarchy[] hclz = new Hierarchy[classes.length];

  try{
   for(int x = 0; x < classes.length; x++){           
    hclz[x] = HierarchyBuilder.buildHierarchy(classes[x]);		            
   }        
   BatchHierarchyXMLReport xmler = new BatchHierarchyXMLReport(new Date(), hclz);
   this.handleReportCreation(xmler);
  }catch(ClassNotFoundException e){
   throw new BuildException("Unable to load class check classpath! " + e.getMessage());
  }  
 }
 //more methods below....
}

При каждом вызове execute() в листинге 11 (которое выполняется из Ant), член fileSet проверяется OVal. Если он пустой, то есть кто-то не задал классы для оценки, вызывается ConstraintsViolatedException. Исключение останавливает процесс, подобно тому как это делалось в исходном коде вызовом BuildException.

Заключительные соображения

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

OVal - не единственная имеющаяся библиотека DBC; на самом деле конструкции DBC этой среды достаточно ограничены в сравнении с другими средами (например, в OVal нет простого способа задания инвариантов класса). С другой стороны, удобство использования OVal и широкий выбор ограничений делают эту среду удобным инструментом для простого добавления проверочных ограничений к коду. Кроме того, в OVal очень легко создавать собственные ограничения, поэтому призываю вас - хватит использовать проверочные условия, пользуйтесь возможностями АОП!


Страница сайта http://test.interface.ru
Оригинал находится по адресу http://test.interface.ru/home.asp?artId=5831