3 min read

Extraire du code sans Service Object

Comment isoler une méthode sans passer par un Service Object ? C'est pourtant si simple avec Ruby
5 avion de voltiges avec des trainées de fumée rose et bleue
Photo by Daniel Klein / Unsplash

Récemment je suis intervenu sur un client d'API. Cette API nous permet entre autres de récupérer un fichier audio mis en ligne par un éditeur mais pour des raisons propres au distributeur il n'est pas possible de télécharger en une seule fois le fichier mais il faut le faire par chunk de 50mo.

Voici grosso-modo le code tel qu'il étais quand j'ai ouvert le fichier

def download_media(isbn, total_size)
  file = Tempfile.new
  partitions = split_size_in_ranges(total_size)

  partitions.each do |range|
    fragment = download_file_part(isbn, range)

    file.write(fragment)
  end
end

private

def split_size_in_ranges(total_size, range_size = 50.megabytes)
  ranges = []
  start = 0

  while start <= total_size
    finish = [start + range_size - 1, total_size].min
    ranges << (start..finish)
    start += range_size
  end

  ranges
end

,

Ce bout de code était très loin d'être à jeter mais il y avait quand même plusieurs petites choses qui m'ont fait tiqué. Et je voudrais m'attarder sur l'une d'entre elle très précise

Le but (la responsabilité) d'un client d'API c'est de juste permettre d'utiliser les endpoints d'une API en Ruby. Il est pas sensé gérer la moindre règle business.

Une fois cette règle posée il est clair que le client n'a pas à s'occuper de comment répartir les ranges permettant de télécharger les parties du fichier audio. De plus à tester c'est plus dure car la méthode est privée il faut donc la tester en testant le téléchargement d'un fichier complet (car on ne doit pas tester une méthode privée).

La méthode #split_size_in_ranges n'a plus rien à faire dans cette classe-ci. Mais où la déplacer ?!?

Certains pourraient se dire : "Créons une classe Utils et déplaçons la méthode de dedans". Mais ça ne fait que déplacer la responsabilité ailleurs et qui plus est dans une classe fourre-tout.

D'autres pourraient être tenté de créer un Service Object et de lui confier cette responsabilité :

class FileSizeSplitter
  def call(total_size)
    # ...
  end
end

# Usage in previous snippet
partitions = FileSizeSplitter.call(total_size)

Mais je suis vraiment pas fan du tout d'utiliser les Service Objects (mais j'en parlerais plus tard de mon opinion à ce propos). Disons juste que ça ne fait pas très esprit Ruby pour moi.

Moi ce que je veux c'est pouvoir faire

total_size.split_in_ranges_of(50.megabytes)

Et ça c'est possible de le faire en Ruby me direz-vous.

Bah oui il suffit de monkey patch la classe Integer en la ré-ouvrant et en ajoutant la méthode

Et vous auriez raisons mais une simple recherche sur n'importe quel moteur de recherche vous montrera à quel point cette façon de faire est mal vue − à juste titre selon moi − dans la communauté car elle est souvent source de bugs (particulièrement si vous créer du code amené à être ré-utilisé dans plusieurs projets).

Heureusement depuis longtemps Ruby fourni à mécanisme qui permet d'améliorer une classe de manière plus safe : les Refinements. En forçant la création d'un cadre précis auquel s'applique l'ajout à une classe, vous allez devoir contrôler l'activation de cet ajout au moment opportun.

# in lib/refinements/integer.rb
module Refinements
  module Integer
    refine ::Integer do
      def split_in_ranges_of(limit)
        # ...
      end
    end
  end
end

# Usage
require 'refinements/integer'

class Client
  using Refinements::Integer

  def download_media(isbn, total_size)
    partitions = total_size.split_in_ranges_of(50.megabytes)

    # ...
  end
end

Et tada 🎉 nous avons isolé le calcul des différentes parties du fichier dans son propre espace le rendant à l'occasion plus simple à tester. On s'offre à l'occasion, la possibilité future de ré-utiliser ce Refinement ailleurs si le besoin se présente. Le tout sans casser le paradigme tout objet de Ruby.

Pour aller plus loin

Ruby Refinements | Alchemists
A collective devoted to the craft of software engineering where expertise is transmuted into joy.
GitHub - bkuhlmann/refinements: A collection of core object refinements.
A collection of core object refinements. Contribute to bkuhlmann/refinements development by creating an account on GitHub.