5 навыков высокоэффективных разработчиков программного обеспечения

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

Эта статья представляет 5 навыков команд, разрабатывающих ПО, которые делают их более эффективными и выгодными с финансовой точки зрения. Здесь будут описаны требования бизнес-команды к разработчикам ПО и к разрабатываемым ими программам. Кроме этого, мы также рассмотрим различия логики изменения состояния и логики поведения объектов при разработке и проектировании приложений.

Что бизнес ждет от разработчиков

Работа бизнес-команды состоит в том, чтобы определить, какое ценное новшество может быть добавлено ПО, при этом учитывая, что ценность в первую очередь оно должно представлять именно для бизнеса. Здесь под "ценным новшеством" подразумевается либо целиком свежий программный продукт, либо дополнительное улучшение уже существующего программного продукта. Иными словами, основная цель бизнес команды — определить, насколько выгодным будет нововведение, и сколько это может принести денег. Ключевым фактором здесь также естественно выступает вопрос о стоимости разработки. Поэтому в случае, если стоимость разработки превышает ожидаемую прибыль (или прибыль не удовлетворяет ожиданиям бизнес-команды), то такой продукт, скорее всего, разрабатываться не будет.

Любая бизнес-команда ожидает минимальных возможных затрат на разработку того или иного нововведения командой разработчиков. Это понятно. Кроме того, сюда входит также ожидание минимальных временных затрат. Более того, при добавлении новой возможности в ПО подразумевается сохранение работоспособности всех предыдущих функций ПО. Со временем, когда ПО становится все сложнее и сложнее, бизнес-команда требует от команды разработчиков документировать проект, чтобы описать все текущие возможности и достоинства данного ПО. Эта документация впоследствии помогает бизнес-команде определить, какую возможность или функцию нужно добавить в ПО в следующий раз. Для того чтобы наилучшим образом соответствовать всем вышеперечисленным запросам и потребностям, команда разработчиков должна создавать простое для понимания ПО. Любая лишняя сложная деталь в ПО в результате приведет к усложнению дальнейшего процесса его разработки. Это может привести к неэффективному использованию времени, а следовательно, к увеличению стоимости разработки, неожиданной утрате уже существующей ценности, а также к разработке документации, не соответствующей действительности. Всех этих проблем можно избежать, придерживаясь простого и понятного стиля разработки.

Основные концепции: Состояние (State) и Поведение (Behaviour)

Создание простого для понимания программного обеспечения начинается с создания объектов, которые имеют собственное состояние и собственное поведение. "Состояние" — это те данные объекта, которые сохраняются и могут использоваться при вызовах методов этого объекта. Java-объекты могут хранить свое состояние в течение определенного периода времени в полях экземпляра этого объекта, либо его можно сохранить в постоянном хранилище данных. Например, таким хранилищем может быть база данных или веб-сервис. Методы изменения состояния манипулируют данными объекта, сохраняя или получая данные из удаленного хранилища. "Поведение" — это возможность объекта отвечать на вопросы о своем текущем состоянии. "Поведенческие методы" позволяют только получать информацию и никогда не изменяют состояния объекта. Их часто называют бизнес-логикой приложения.

Пример: объект CustomerAccount

Приведенный ниже интерфейс IСustomerAccount определяет методы, которые объект должен реализовать, чтобы управлять учетной записью клиента. В список функций управления входят следующие операции: создание новой активной учетной записи, загрузка статуса существующей учетной записи клиента, проверка на валидность будущего имени пользователя и пароля клиента, а также проверка на то, является ли учетная запись активной (т.е. может ли клиент покупать продукты).

public interface ICustomerAccount {
// Методы изменения состояния
public void createNewActiveAccount()
throws CustomerAccountsSystemOutageException;
public void loadAccountStatus()
throws CustomerAccountsSystemOutageException;
// Методы поведения
public boolean isRequestedUsernameValid();
public boolean isRequestedPasswordValid();
public boolean isActiveForPurchasing();
public String getPostLogonMessage();
}

Навык 1. Конструктор выполняет минимальную работу

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

public class CustomerAccount implements ICustomerAccount{
// Поля объекта.
private String username;
private String password;
protected String accountStatus;

// Конструктор, выполняющий минимальный объем работы.
public CustomerAccount(String username, String password) {
this.password = password;
this.username = username;
}
}

Конструктор используется для создания экземпляра класса. Имя конструктора всегда должно соответствовать имени объекта (класса). Поскольку имя конструктора изменить нельзя, оно не может отражать значение выполняемого им действия. Вот почему так важно, чтобы конструктор выполнял как можно меньше работы. С другой стороны, методы изменения состояния и методы поведения могут именоваться таким образом, чтобы лучше отражать в имени метода то, что он делает, и его назначение. Более подробно об этом рассказано при рассмотрении второго навыка ("Имена методов четко отражают свое предназначение"). Из следующего примера хорошо видно, насколько легче читать код, когда конструктор только создает экземпляр объекта, оставляя всю остальную работу методам поведения и методам изменения состояния.

String username = "alexlitvinyuk";
String password = "java.linux.by";
ICustomerAccount ca = new CustomerAccount(username, password);
if(ca.isRequestedUsernameValid() && ca.isRequestedPasswordValid()) {
...
ca.createNewActiveAccount();
...
}

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

// Конструктор, нагруженный лишней работой!
public CustomerAccount(String username, String password)
throws CustomerAccountsSystemOutageException {

this.password = password;
this.username = username;
this.loadAccountStatus(); // ненужный вызов метода.
}

// Удаленный вызов к базе данных или веб-сервису.
public void loadAccountStatus()
throws CustomerAccountsSystemOutageException {
...
}

В этом случае возможен вариант, когда разработчик не знает, что конструктор обращается за информацией о статусе, и делает лишний вызов этого же метода после вызова конструктора:

String username = "alexlitvinyuk";
String password = "java.linux.by";
try {
// первый удаленный вызов
ICustomerAccount ca = new CustomerAccount(username, password);
// второй удаленный вызов
ca.loadAccountStatus();
} catch (CustomerAccountsSystemOutageException e) {
...
}

Или, например, разработчик использует конструктор объекта для того, чтобы проверить на валидность будущее имя пользователя и пароль учетной записи. В этом случае удаленный вызов для предварительной загрузки статуса оказывается совершенно бесполезным и ненужным, поскольку методам поведения isRequestedUsernameValid() и isRequestedPasswordValid() не нужен статус учетной записи:

String username = "alexlitvinyuk";
String password = "java.linux.by";
try {
// ненужное обращение к удаленному веб-сервису или базе данных
ICustomerAccount ca = new CustomerAccount(username, password);
if(ca.isRequestedUsernameValid() && ca.isRequestedPasswordValid()) {
...
ca.createNewActiveAccount();
...
}
} catch (CustomerAccountsSystemOutageException e){
...
}

Навык 2. Имена методов четко отражают свое предназначение

Второй навык заключается в необходимости именовать методы в как можно более полном соответствии с выполняемой ими функцией. Например, метод isRequestedUsernameValid() позволяет разработчику быстро узнать, что с его помощью можно определить или запрашиваемое имя пользователя валидно. С другой стороны, метод isGoodUser() может иметь сразу несколько назначений: определять, активна ли учетная запись пользователя, определять валидность запрашиваемых имени пользователя и пароля или вообще определять, является ли пользователь хорошим человеком. Поскольку этот метод назван не совсем однозначно, разработчику будет намного сложнее разобраться в его предназначении. Поэтому гораздо полезнее именовать методы длинно и понятно, чем коротко и бессмысленно. Длинные и описательные имена методов сильно облегчают жизнь разработчикам. По таким именам можно быстро определить назначение и функцию той или иной части ПО. Более того, применяя это правило к именованию методов Unit-тестов, можно лучше реализовать требования ПО. Например, в приложении необходимо проверить, отличается ли имя пользователя от пароля. Имя метода
testRequestedPasswordIsNotValidBecauseItMustBeDifferentThanTheUsername() полностью отражает выполняемую им функцию.

import junit.framework.TestCase;

public class CustomerAccountTest extends TestCase{
public void testRequestedPasswordIsNotValidBecauseItMustBeDifferentThanTheUsername(){
String username = "alexlitvinyuk";
String password = "alexlitvinyuk";
ICustomerAccount ca = new CustomerAccount(username, password);
assertFalse(ca.isRequestedPasswordValid());
}
}

Этот метод легко можно было бы назвать testRequestedPasswordIsNotValid() или еще хуже — testBadPassword(). В этом случае было бы очень сложно определить точное предназначение этого тестового метода. Неточные и неоднозначные имена тестовых методов могут привести к потере продуктивности кода. Это связано с ростом необходимого времени для понимания всех тестов, а также с неизбежным созданием ненужных повторяющихся и конфликтующих тестов. Ну и, наконец, понятные имена методов снижают необходимость в формальной документации и комментариях Javadoc ( сайт ).

Навык 3. Объект предоставляет строго определенный набор сервисов

Третий навык заключается в том, что каждый объект должен выполнять строго определенный набор функций. Объекты, которые выполняют небольшой объем работы, гораздо проще читать и наиболее вероятно использовать полностью по назначению. Каждый объект в приложении должен выполнять строго определенный уникальный набор функций, потому что дублирование логики — это напрасная трата времени и средств на разработку. Предположим, в будущем бизнес-команда решает, что необходимо обновить логику метода isRequestedPasswordValid(). При этом данный метод присутствует сразу в двух разных классах и выполняет одинаковую функцию в них обоих. В этом случае команда разработчиков тратит вдвое больше времени на обновление двух этих объектов, чем одного. Назначение объекта из нашего примера CustomerAccount — управление индивидуальными учетными записями клиентов. Сначала он создает учетную запись, а потом может проверять его статус: если он активный, только клиент может выполнять покупки. Предположим, что в наш программный продукт нужно добавить возможность предоставлять скидки клиентам, которые уже заказали больше 10 товаров. Для того, чтобы следовать нашей идее создавать программы как можно более простыми для понимания, для добавления этой новой возможности мы создадим новый интерфейс ICustomerTransactions и объект CustomerTransactions:

public interface ICustomerTransactions {
// Методы изменения состояния
public void createPurchaseRecordForProduct(Long productId)
throws CustomerTransactionsSystemException;
public void loadAllPurchaseRecords()
throws CustomerTransactionsSystemException;
// Методы поведения
public void isCustomerEligibleForDiscount();
}

Наш новый объект содержит методы изменения состояния и методы поведения, с помощью которых будут фиксироваться все транзакции заказчика и определяться возможность заказчика пользоваться скидками по достижении 10 покупок. Этот класс очень просто создать, тестировать и поддерживать в дальнейшем, поскольку он имеет вполне понятный и сфокусированный набор функций. Намного менее эффективный способ состоит в том, чтобы просто добавить все эти новые методы в существующий интерфейс ICustomerAccount и объект CustomerAccount:

public interface ICustomerAccount {
// Методы изменения состояния
public void createNewActiveAccount()
throws CustomerAccountsSystemOutageException;
public void loadAccountStatus()
throws CustomerAccountsSystemOutageException;
public void createPurchaseRecordForProduct(Long productId)
throws CustomerAccountsSystemOutageException;
public void loadAllPurchaseRecords()
throws CustomerAccountsSystemOutageException;
// Методы поведения
public boolean isRequestedUsernameValid();
public boolean isRequestedPasswordValid();
public boolean isActiveForPurchasing();
public String getPostLogonMessage();
public void isCustomerEligibleForDiscount();
}

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

Навык 4. Методы изменения состояния содержат минимум поведенческой логики

Четвертый навык — методы изменения состояния содержат минимум поведенческой логики. Перемешивание кода изменения состояния объекта и кода, отвечающего за поведение объекта, сильно усложняет его понимание, поскольку значительно увеличивает объем работы, выполняемой в одном месте. Методы изменения состояния объекта обычно выполняют работу по загрузке и сохранению состояния объекта в удаленном хранилище данных, поэтому этот код может вызывать исключения и проблемы системного характера. Гораздо проще проводить диагностику системных проблем в методах изменения состояния, когда их логика отделена от логики поведения объекта. Смешивание также может значительно усложнить процесс написания unit-тестов для проверки поведенческой логики. Например, getPostLogonMessage() — это метод поведения объекта, который завязан на значение поля accountStatus:

public String getPostLogonMessage() {
if("A".equals(this.accountStatus)){
return "Ваша учетная запись активна, и вы можете совершать покупки.";
} else if("E".equals(this.accountStatus)) {
return "Ваша учетная запись заблокирована из-за недостаточной активности.";
} else {
return "Ваша учетная запись не найдена. Пожалуйста, обратитесь в службу поддержки клиентов за необходимой информацией.";
}
}

loadAccountStatus() — это метод изменения состояния, который загружает значение поля accountStatus из удаленного хранилища данных:

public void loadAccountStatus()
throws CustomerAccountsSystemOutageException {
Connection c = null;
try {
c = DriverManager.getConnection("databaseUrl", "databaseUser",
"databasePassword");
PreparedStatement ps = c.prepareStatement(
"SELECT status FROM customer_account "
+ "WHERE username = ? AND password = ? ");
ps.setString(1, this.username);
ps.setString(2, this.password);
ResultSet rs = ps.executeQuery();
if (rs.next()) {
this.accountStatus=rs.getString("status");
}
rs.close();
ps.close();
c.close();
} catch (SQLException e) {
throw new CustomerAccountsSystemOutageException(e);
} finally {
if (c != null) {
try {
c.close();
} catch (SQLException e) {}
}
}
}

В этом случае написание unit-теста к методу getPostLogonMessage() будет сводиться лишь к созданию заглушки метода loadAccountStatus(). Таким образом каждый тестовый сценарий может быть создан без необходимости обращаться за данными к удаленной базе данных. Например, если значение поля accountStatus будет равно "E", тогда метод getPostLogonMessage() должен возвращать сообщение "Ваша учетная запись заблокирована из-за недостаточной активности."

public void testPostLogonMessageWhenStatusIsExpired(){
String username = "alexlitvinyuk";
String password = "java.linux.by";

class CustomerAccountMock extends CustomerAccount{
...
public void loadAccountStatus() {
this.accountStatus = "E";
}
}
ICustomerAccount ca = new CustomerAccountMock(username, password);
try {
ca.loadAccountStatus();
}
catch (CustomerAccountsSystemOutageException e){
fail(""+e);
}
assertEquals("Ваша учетная запись заблокирована из-за недостаточной активности.", ca.getPostLogonMessage());
}

Обратный подход — совместить работу метода изменения состояния loadAccountStatus() и метода поведения getPostLogonMessage(). Например, вот таким образом:

public String getPostLogonMessage() {
return this.postLogonMessage;
}

public void loadAccountStatus()
throws CustomerAccountsSystemOutageException {
Connection c = null;
try {
c = DriverManager.getConnection("databaseUrl", "databaseUser",
"databasePassword");
PreparedStatement ps = c.prepareStatement(
"SELECT status FROM customer_account "
+ "WHERE username = ? AND password = ? ");
ps.setString(1, this.username);
ps.setString(2, this.password);
ResultSet rs = ps.executeQuery();
if (rs.next()) {
this.accountStatus=rs.getString("status");
}
rs.close();
ps.close();
c.close();
} catch (SQLException e) {
throw new CustomerAccountsSystemOutageException(e);
} finally {
if (c != null) {
try {
c.close();
} catch (SQLException e) {}
}
}

if("A".equals(this.accountStatus)){
return "Ваша учетная запись активна, и вы можете совершать покупки.";
} else if("E".equals(this.accountStatus)) {
return "Ваша учетная запись заблокирована из-за недостаточной активности.";
} else {
return "Ваша учетная запись не найдена. Пожалуйста, обратитесь в службу поддержки клиентов за необходимой информацией.";
}
}

В этой реализации метод поведения getPostLogonMessage() не содержит никакой поведенческой логики, просто возвращая значение поля this.postLogonMessage. В результате мы имеем сразу три проблемы. Во-первых, в этом случае гораздо сложнее понять, откуда берется значение этого сообщения, поскольку эта логика входит в другой метод, который выполняет сразу две задачи. Во-вторых, значительно ограничивается возможность использования метода getPostLogonMessage(), поскольку в данном случае его нужно всегда использовать только в связке с методом loadAccountStatus(). Наконец, в случае возникновения исключения CustomerAccountsSystemOutageException значение поля this.accountStatus не будет установлено. Кроме того, эта реализация создает массу неудобств с написанием unit-тестов. В этом случае для того, чтобы протестировать метод getPostLogonMessage(), необходимо сначала создать объект CustomerAccount с заданными именем пользователя и паролем в базе данных, а также установленным значением accountStatus — "E". Поэтому здесь не обойтись без необходимости обращения к удаленной базе данных. Это может привести к значительному снижению скорости выполнения тестов и возникновению непредвиденных проблем, связанных с изменениями в базе данных. Разделяя логику поведения и логику изменения состояния, вы значительно облегчите жизнь себе и тем, кому, возможно, потом придется иметь дело с написанным вами кодом.

Навык 5. Методы поведения могут вызываться в любом порядке

Пятый навык предполагает, что любой метод поведения объекта возвращает значение, независимое от других методов поведения. Другими словами, методы поведения могут вызываться сколько угодно раз и в любой последовательности. Это обеспечивает постоянство поведения объекта. Например, оба метода поведения CustomerAccount — isActiveForPurchasing() и getPostLogonMessage() — используют значение поля accountStatus. Каждый из них должен выполнять свою функцию независимо от другого. Например, по одному из сценариев необходимо вызывать метод isActiveForPurchasing(), после чего нужно вызвать метод getPostLogonMessage():

ICustomerAccount ca = new CustomerAccount(username, password);
ca.loadAccountStatus();
if(ca.isActiveForPurchasing()){
// начало покупки
...
// отображаем сообщение.
ca.getPostLogonMessage();
} else {
// активирование учетной записи
...
// отображаем сообщение.
ca.getPostLogonMessage();
}

По второму сценарию метод getPostLogonMessage() вызывается без предварительного вызова метода isActiveForPurchasing():

ICustomerAccount ca = new CustomerAccount(username, password);
ca.loadAccountStatus();
// начало
...
// отображаем сообщение.
ca.getPostLogonMessage();

Объект CustomerAccount не будет поддерживать второй сценарий, если метод getPostLogonMessage() зависит от результатов работы метода isActiveForPurchasing(). Предположим, что есть два метода, которые используют поле объекта postLogonMessage таким образом, что его значение сохраняется между их вызовами. При этом возможен только первый сценарий:

public boolean isActiveForPurchasing() {
boolean returnValue = false;
if("A".equals(this.accountStatus)){
this.postLogonMessage = "Ваша учетная запись активна, и вы можете совершать покупки.";
returnValue = true;
} else if("E".equals(this.accountStatus)) {
this.postLogonMessage = "Ваша учетная запись заблокирована из-за недостаточной активности.";
returnValue = false;
} else {
this.postLogonMessage = "Ваша учетная запись не найдена. Пожалуйста, обратитесь в службу поддержки клиентов за необходимой информацией."; returnValue = false;
}
return returnValue;
}

public String getPostLogonMessage() {
return this.postLogonMessage;
}

Однако, если оба метода выполняют свою работу независимо друг от друга, то они могут вызываться в любой последовательности. Т.е. в нашем случае могут использоваться в обоих приведенных выше сценариях. В следующем примере postLogonMessage — это локальная переменная, которая создается исключительно в методе getPostLogonMessage():

public boolean isActiveForPurchasing() {
return this.accountStatus != null && this.accountStatus.equals("A");
}

public String getPostLogonMessage() {
if("A".equals(this.accountStatus)){
return "Ваша учетная запись активна, и вы можете совершать покупки.";
} else if("E".equals(this.accountStatus)) {
return "Ваша учетная запись заблокирована из-за недостаточной активности.";
} else {
return "Ваша учетная запись не найдена. Пожалуйста, обратитесь в службу поддержки клиентов за необходимой информацией.";
}
}

Дополнительное преимущество того, что эти два метода работают независимо друг от друга, еще и в том, что от этого значительно проще понимать код и функцию этих методов. Например, метод isActiveForPurchasing() читать гораздо проще, когда он занимается только тем, что отвечает на вопрос о текущем статусе учетной записи клиента, нежели когда он плюс к этому устанавливает значение поля postLogonMessage. Еще одним преимуществом является возможность без проблем создавать отдельные тесты на каждый из этих методов:

public class CustomerAccountTest extends TestCase{
public void testAccountIsActiveForPurchasing(){
String username = "alexlitvinyuk";
String password = "java.linux.by";

class CustomerAccountMock extends CustomerAccount{
...
public void loadAccountStatus() {
this.accountStatus = "A";
}
}
ICustomerAccount ca = new CustomerAccountMock(username, password);
try {
ca.loadAccountStatus();
} catch (CustomerAccountsSystemOutageException e) {
fail(""+e);
}
assertTrue(ca.isActiveForPurchasing());
}

public void testGetPostLogonMessageWhenAccountIsActiveForPurchasing(){
String username = "alexlitvinyuk";
String password = "java.linux.by";

class CustomerAccountMock extends CustomerAccount{
...
public void loadAccountStatus() {
this.accountStatus = "A";
}
}
ICustomerAccount ca = new CustomerAccountMock(username, password);
try {
ca.loadAccountStatus();
} catch (CustomerAccountsSystemOutageException e) {
fail(""+e);
}
assertEquals("Ваша учетная запись активна, и вы можете совершать покупки.",
ca.getPostLogonMessage());
}
}

Заключение

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

Ресурсы

- Основы JDBC: сайт
- JUnit: сайт
- Методологии XP/Agile, определенные Роном Джефрисом: сайт

По материалам Robert J. Miller

Алексей Литвинюк, litvinuke@tut.by


Компьютерная газета. Статья была опубликована в номере 35 за 2006 год в рубрике программирование

©1997-2024 Компьютерная газета