Capture et forwarding des sorties standards (STDOUT et SDTIN)

dev 21 juin 2025

Principe

le but de cet exemple est de capturer les sorties standards d'une méthode ou d'un Proc Object et de les restituer dans une autre partie du logiciel

Etape #1 : Le cas simpliste (STDOUT)

 

On trouve beaucoup d'exemple sur Stackoverflow d'exemple de capture de STDOUT seulement :

require 'stringio'  
  
def workload  
    puts "STDOUT output #1"  
    $stderr.puts "STDERR output #1"  
    $stdout.puts "STDOUT output #2"  
end  
  
class Executor  
    def self.with_captured  
        original_stdout = $stdout        
        $stdout = StringIO.new  
        ret = yield  
        return {returned: ret , output: $stdout.string.split("\n") }  
    ensure  
        $stdout = original_stdout    
    end  
  
  
    def self.run  
        return self.with_captured{yield } if block_given?  
    end  
  
end  
  
  
p Executor::run { workload } 

 Note : l'Executor.run  renvoie un hash avec les clefs optionnelles ( :output => le contenu de STDOUT et :returned les données renvoyées par la méthode.

La sortie donne :

STDERR output #1  
{:returned=>nil, :output=>["STDOUT output #1", "STDOUT output #2"]}  

Explication

 On utilise un String IO Adapter pour capturer la sortie standard, on sauvegarde le descripteur en cours et l'IO sur STDOUT, on le substitue par l'adaptateur

On utilise une Class pour structurer les Execution via une méthode de classe run qui wrappe un workload

 ⚠️ Problème : STDERR n'est pas capturée.

Affichage de la sortie

on ajoute la classe :

class Output   
  
    def self.display(result: )  
        if result[:output] then   
            result[:output].each do |item|  
              puts item  
            end  
          end  
          return result[:returned]  
    end  
  
end  

La sortie donne :

STDERR output #1  
STDOUT output #1  
STDOUT output #2  

Et là vous vous dites tout ça pour ça !

 Remarque : en plus l'ordre chronologique n'est plus correct.

Etape #2 : Capture de STDOUT et STDERR

On capture les deux STDOUT et STDERR et on les envoies sur deux clefs spécifiques du hash de retour

require 'stringio'  
  
def workload  
    puts "STDOUT output #1"  
    $stderr.puts "STDERR output #1"  
    $stdout.puts "STDOUT output #2"  
end  
  
class Executor  
    def self.with_captured  
        original_stdout = $stdout       
        original_stderr = $stderr   
        $stdout = StringIO.new  
        $stderr = StringIO.new  
        ret = yield  
        return {returned: ret , stdout: $stdout.string.split("\n"),  
                                stderr: $stderr.string.split("\n") }  
    ensure  
        $stdout = original_stdout    
        $stderr = original_stderr  
    end  
  
  
    def self.run  
        return self.with_captured{yield } if block_given?  
    end  
  
end  
  
class Output   
  
    def self.display(result: )  
        if result[:stdout] then   
            result[:stdout].each do |item|  
              puts item  
            end  
          end  
          if result[:stderr] then   
            result[:stderr].each do |item|  
              puts item  
            end  
          end  
          return result[:returned]  
    end  
  
end  
  
  
Output::display result: Executor::run { workload }  

Sortie :

STDOUT output #1  
STDOUT output #2  
STDERR output #1  
  Remarque : un autre ordre, mais forcément pas le bon : choix arbitraire  STDOUT puis STDIN, mais aucun moyen de trier

Une alternative serais de pousser sur une seule entrée output: les sorties de STDOUT et STDERR, mais du coup pas moyen de les discriminer et logiquement tout ne serait forwardé QUE sur STDOUT à la cible.

Etape #3 : Version finale : Split entre STDOUT et STDERR.

 Code définitif :

require 'stringio'  
def workload  
    puts "STDOUT output #1"  
    $stderr.puts "STDERR output #1"  
    $stdout.puts "STDOUT output #2"  
end  
  
class STDProxy < IO  
    def initialize(source: "STDOUT", target:)  
      @source = source  
      @target = target  
    end  
    def puts(*args)  
      tagged_args = args.map { |arg| "#{@source}:#{arg}" }  
      @target.puts(*tagged_args)  
    end  
    
    def method_missing(method, *args, &block)  
      @target.send(method, *args, &block)  
    end  
end  
  
class Executor  
    def self.with_captured  
        original_stdout = $stdout       
        original_stderr = $stderr   
        output = StringIO.new  
        $stdout = STDProxy.new(source: "STDOUT", target: output)       
        $stderr = STDProxy.new(source: "STDERR", target: output)  
        ret = yield  
        return {returned: ret , output: output.string.split("\n") }  
    ensure  
        $stdout = original_stdout    
        $stderr = original_stderr  
    end  
  
    def self.run  
        return self.with_captured{yield } if block_given?  
    end  
end  
  
class Output   
    def self.display(result: )  
        if result[:output] then   
            result[:output].each do |item|  
              target,*rest = item.split(":")  
              Object.const_get(target).puts rest.join(':')   
            end  
          end  
          return result[:returned]  
    end  
end  
  
result = Executor::run { workload }  
Output::display result: result  
Principe : On vient ajouter un Proxy de dispatching STDProxy qui implémente de façon abstraite et forward toutes les méthodes sauf #puts , qu'il modifie pour tagguer les sorties

la classe Output n'a plus qu'à itérer les lignes en re-routant via instrospection de constante sur la bonne sortie  ! 

🎯 Usecase : L'objectif est de  pouvoir sérialiser (JSON, YAML)  le retour d’exécution du workload sur une machine serveur et de la récupérer sur une machine client via une API un RPC, etc ...

Mots clés

Romain GEORGES

Open Source evangelist & Ruby enthousiast