Back-End

15 jun, 2016

Código limpo e a arte do tratamento de exceção – Parte 01

Publicidade

Neste artigo, que dividimos em duas partes, aprenda a lidar melhor com exceções.

***

Exceções são tão antigas quanto a própria programação. Voltando aos dias em que a programação era feita em hardware, ou por meio de linguagens de programação de baixo nível, as exceções foram usadas para alterar o fluxo do programa, e para evitar falhas de hardware. Hoje, a Wikipedia define exceções como:

condições anômalas ou excepcionais que exigem um processamento especial – muitas vezes mudando o fluxo normal de execução do programa…

E que o seu tratamento requer:

construções de linguagem de programação especializada ou mecanismos de hardware do computador.

Assim, as exceções requerem um tratamento especial, e uma exceção não tratada pode causar um comportamento inesperado. Os resultados são muitas vezes espetaculares. Em 1996, o famosa falha no lançamento do foguete Ariane 5 foi atribuída a uma exceção de overflow não tratada. Os piores bugs de software da história contêm alguns outros erros que poderiam ser atribuídos a exceções não tratadas ou tratadas de forma errada.

Com o tempo, esses erros, e inúmeros outros (que eram, talvez, não tão dramáticos, mas ainda assim catastróficos para os envolvidos) contribuíram para a percepção de que exceções são ruins.

exceção-1

Mas exceções são um elemento fundamental da programação moderna; elas existem para tornar o nosso software melhor. Ao invés de ter medo das exceções, devemos abraçá-las e aprender como nos beneficiar delas. Neste artigo, vamos discutir como gerenciar exceções elegantemente, e usá-las para escrever código limpo que é mais sustentável.

Tratamento de exceção: é uma coisa boa

Com a ascensão da programação orientada a objetos (OOP), o suporte para exceção se tornou um elemento crucial de linguagens de programação modernas. Um sistema de tratamento de exceção robusto é incorporado na maioria das linguagens atualmente. Por exemplo, o Ruby prevê o seguinte padrão típico:

begin
  do_something_that_might_not_work!
rescue SpecificError => e
  do_some_specific_error_clean_up
  retry if some_condition_met?
ensure
  this_will_always_be_executed
end

Não há nada de errado com o código anterior. Mas o uso excessivo desses padrões fará com que o código cheire mal, e não será necessariamente benéfico. Da mesma forma, fazer mau uso deles pode trazer um monte de danos à sua base de código, tornando-a frágil ou ofuscando a causa dos erros.

O estigma em torno de exceções muitas vezes faz com que programadores se sintam perdidos. É um fato da vida que as exceções não podem ser evitadas, mas muitas vezes somos ensinados que elas devem ser tratadas rápida e decisivamente. Como veremos, isso não é necessariamente verdade. Ao contrário, devemos aprender a arte de lidar com exceções graciosamente, fazendo com que elas tenham harmonia com o resto do nosso código.

A seguir, estão algumas práticas recomendadas que ajudarão você a abraçar exceções e fazer uso delas e de suas habilidades para manter o seu código com fácil manutenção, extensível e legível:

  • maintainability: Nos permite encontrar e corrigir novos bugs facilmente, sem o medo de quebrar a funcionalidade atual, introduzindo novos bugs, ou tendo que abandonar o código por completo devido ao aumento da complexidade em longo prazo.
  • extensibility: Nos permite adicionar facilmente à nossa base de código, implementando requisitos novos ou alterados sem quebrar a funcionalidade existente. A extensibility proporciona flexibilidade e permite um alto nível de reutilização para a nossa base de código.
  • readability: Nos permite ler facilmente o código e descobrir a sua finalidade, sem gastar muito tempo pesquisando. Isso é crítico para descobrir com eficiência bugs e código não testado.

Esses elementos são os principais fatores do que poderíamos chamar de limpeza ou qualidade, o que não é em si uma medida direta, mas é o efeito combinado dos pontos anteriores, como demonstrado neste quadrinho:

exceção-2

Dito isso, vamos mergulhar em tais práticas e ver como cada uma delas afeta essas três medidas.

Nota: Vamos apresentar exemplos de Ruby, mas todas as construções aqui demonstradas têm equivalentes nas linguagens OOP mais comuns.

Sempre crie a sua própria hierarquia ApplicationError

A maioria das linguagens vem com uma variedade de classes de exceção, organizadas em uma hierarquia de herança, como qualquer outra classe OOP. Para preservar a readability, a maintainability e a extensibility do nosso código, é uma boa ideia criar o nossa própria sub-árvore de exceções específicas de aplicativos que estendem a classe de exceção base. Investir algum tempo na estruturação lógica dessa hierarquia pode ser extremamente benéfico. Por exemplo:

class ApplicationError < StandardError; end

# Validation Errors
class ValidationError < ApplicationError; end
class RequiredFieldError < ValidationError; end
class UniqueFieldError < ValidationError; end

# HTTP 4XX Response Errors
class ResponseError < ApplicationError; end
class BadRequestError < ResponseError; end
class UnauthorizedError < ResponseError; end
# ...

exceção-3

Ter um pacote extensível e abrangente de exceções para a nossa aplicação torna o manuseio dessas situações específicas de aplicativos muito mais fácil. Por exemplo, podemos decidir com quais exceções iremos lidar de uma forma mais natural. Isso não só aumenta a readability do nosso código, mas também aumenta a capacidade de maintainability dos nossos aplicativos e bibliotecas (gems).

Do ponto de vista de readability, é muito mais fácil de ler:

rescue ValidationError => e

Do que ler:

rescue RequiredFieldError, UniqueFieldError, ... => e

Do ponto de vista de maintainability, digamos, por exemplo, que estamos implementando uma API JSON, e definimos nosso próprio ClientError com vários subtipos, para ser usado quando um cliente envia uma request incorreta. Se qualquer um desses é levantado, a aplicação deve renderizar a representação JSON do erro na sua resposta. Será mais fácil de corrigir, ou adicionar a lógica, a um único bloco que manipula ClientErrors em vez de um loop sobre cada erro de cliente possível e implementar o mesmo código de rotina de tratamento para cada um. Em termos de extensibility, se mais tarde for preciso implementar outro tipo de erro de cliente, podemos confiar que já será tratado adequadamente aqui.

Além disso, isso não nos impede de implementar um tratamento especial adicional para erros específicos do cliente no início da pilha de chamadas, ou alterando o mesmo objeto de exceção ao longo do caminho:

# app/controller/pseudo_controller.rb
def authenticate_user!
  fail AuthenticationError if token_invalid? || token_expired?
  User.find_by(authentication_token: token)
rescue AuthenticationError => e
  report_suspicious_activity if token_invalid?
  raise e
end

def show
  authenticate_user!
  show_private_stuff!(params[:id])
rescue ClientError => e
  render_error(e)
end

Como você pode ver, levantar essa exceção específica não nos impede de ser capaz de lidar com isso em diferentes níveis, alterando-a, reelevando-a, e permitindo que o manipulador da classe pai a resolva.

Duas coisas a serem notadas aqui:

  • Nem todas as linguagens suportam levantar exceções dentro de um manipulador de exceção.
  • Na maioria das linguagens, levantar uma nova exceção de dentro de um manipulador fará com que a exceção original seja perdida para sempre, por isso é melhor voltar a levantar o mesmo objeto de exceção (como no exemplo acima) para evitar perder o controle da causa original do erro (a menos que você esteja fazendo isso intencionalmente).

Nunca resgate uma exceção

Ou seja, nunca tente implementar um manipulador catch-all para o tipo base exception. Resgatar ou recuperar todas as exceções em atacado nunca é uma boa ideia em qualquer linguagem, seja globalmente em um nível de base de aplicação, ou em um método pequeno enterrado, usado apenas uma vez. Nós não queremos resgatar a exceção, porque ela vai ofuscar tudo o que realmente aconteceu, prejudicando maintainability e extensibility. Nós podemos desperdiçar uma enorme quantidade de tempo de depuração procurando qual é o problema real, quando poderia ser tão simples como um erro de sintaxe:

# main.rb
def bad_example
  i_might_raise_exception!
rescue Exception
  nah_i_will_always_be_here_for_you
end

# elsewhere.rb
def i_might_raise_exception!
  retrun do_a_lot_of_work!
end

Você deve ter notado o erro no exemplo anterior; return é digitado incorretamente. Embora os editores modernos ofereçam alguma proteção contra esse tipo específico de erro de sintaxe, esse exemplo ilustra rescue Exception faz mal ao nosso código. Em nenhum momento o tipo real da exceção é corrigido (nesse caso, um NoMethodError), e nem sempre ele é exposto ao desenvolvedor, que pode nos levar a perder muito tempo correndo em círculos.

Nunca resgate mais exceções do que você precisa

O ponto anterior é um caso específico desta regra: devemos sempre ter cuidado para não generalizar demais nossos manipuladores de exceção. As razões são as mesmas; sempre que resgatarmos mais exceções do que deveríamos, vamos acabar escondendo partes da lógica da aplicação de níveis mais elevados de aplicação, para não mencionar a supressão da capacidade do desenvolvedor para tratar a exceção por si mesmo. Isso afeta severamente a extensibility e a maintainability do código.

Se tentarmos lidar com diferentes subtipos de exceção no mesmo manipulador, introduziremos blocos de código de gordura que terão excessivas responsabilidades. Por exemplo, se estamos construindo uma biblioteca que consome uma API remota, manusear um MethodNotAllowedError (HTTP 405) é geralmente diferente de lidar com um UnauthorizedError (HTTP 401), apesar de serem ambos ResponseErrors.

Como veremos, muitas vezes existe uma parte diferente do aplicativo que seria mais adequada para lidar com exceções específicas de uma forma mais DRY.

Assim, defina a responsabilidade única de sua classe ou método, e lide com o mínimo de exceções que satisfazem esse requisito de responsabilidade. Por exemplo, se um método é responsável por obter informações de estoque de uma API remota, então ele deve lidar com exceções que surgem a partir apenas dessa informação, e deixar o tratamento dos outros erros para um método diferente projetado especificamente para essas responsabilidades:

def get_info
  begin
    response = HTTP.get(STOCKS_URL + "#{@symbol}/info")

    fail AuthenticationError if response.code == 401
    fail StockNotFoundError, @symbol if response.code == 404
    return JSON.parse response.body
  rescue JSON::ParserError
    retry
  end
end

Aqui definimos o contrato para esse método para somente obter a informação sobre o estoque. Ele lida com erros específicos de endpoint, como uma resposta JSON incompleta ou incorreta. Ele não lida com o caso quando a autenticação falhar ou expirar, ou se o estoque não existe. Isso é de responsabilidade de outro método, e é explicitamente passado na pilha de chamada onde deveria haver um lugar melhor para lidar com esses erros de uma forma DRY.

Resista à tentação de tratar exceções imediatamente

Este é o complemento para o último ponto. Uma exceção pode ser tratada em qualquer ponto na pilha de chamadas, e em qualquer ponto na hierarquia de classes, então saber exatamente onde lidar com isso pode ser misterioso. Para resolver esse dilema, muitos desenvolvedores optam por lidar com qualquer exceção assim que ela surge, mas investir tempo pensando nisso normalmente irá resultar em encontrar um lugar mais apropriado para lidar com exceções específicas.

Um padrão comum que vemos em aplicações Rails (especialmente aquelas que expõem somente APIs JSON) é o seguinte método de controller:

# app/controllers/client_controller.rb

def create
  @client = Client.new(params[:client])
  if @client.save
    render json: @client
  else
    render json: @client.errors
  end
end

(Note que embora este não seja tecnicamente um manipulador de exceção, funcionalmente, ele serve o mesmo propósito, uma vez que @client.save só retorna falso quando encontra uma exceção.)

Nesse caso, no entanto, repetir o mesmo manipulador de erro em cada ação do controller é o oposto de DRY e danifica a maintainability e a extensibility. Em vez disso, podemos fazer uso da natureza especial de propagação da exceção, e lidar com elas apenas uma vez, na classe controller pai, ApplicationController:

# app/controllers/client_controller.rb

def create
  @client = Client.create!(params[:client])
  render json: @client
end
# app/controller/application_controller.rb

rescue_from ActiveRecord::RecordInvalid, with: :render_unprocessable_entity

def render_unprocessable_entity(e)
  render \
    json: { errors: e.record.errors },
    status: 422
end

Dessa forma, podemos garantir que todos os erros ActiveRecord::RecordInvalid são tratados de forma adequada em DRY em um só lugar, no nível de base ApplicationController. Isso nos dá a liberdade de mexer com eles se quisermos lidar com casos específicos no nível mais baixo, ou simplesmente deixá-los propagar graciosamente.

***

Ahmed Razzak faz parte do time de colunistas internacionais do iMasters. A tradução do artigo é feita pela redação iMasters, com autorização do autor, e você pode acompanhar o artigo em inglês no link: https://www.toptal.com/qa/clean-code-and-the-art-of-exception-handling