Table of Contents
Pourquoi les cadres de l'exploitation forestière doivent-ils être étendus?
Dans les applications Scala de production, le moteur de logage change souvent au fil du temps : un projet peut commencer par la logarithme de console pendant le développement, passer à la logarithme de fichiers en synchronage, et éventuellement s'intégrer à un service centralisé d'agrégation de log comme Logstash ou Spunk dans la production. Sans cadre de logarithme extensible, ces transitions forcent les ingénieurs à modifier le code d'application de base chaque fois que la stratégie de logarithme change.
Le modèle Factory Method s'attaque à ce problème en séparant l'interface de l'enregistrement de l'implémentation de la logage en béton. Cette séparation s'aligne sur le principe ouvert/fermé : le système reste ouvert pour l'extension (on peut ajouter de nouveaux loggers) mais fermé pour modification (le code client existant n'a pas besoin de changer).
Comprendre le modèle de méthode d'usine
Le modèle de méthode Factory est un modèle de conception créative qui définit une interface pour créer un objet mais délègue la décision d'instantiation aux sous-classes. Contrairement à l'idiome simple Factory (qui utilise une seule méthode statique avec une logique conditionnelle), le véritable modèle de méthode Factory repose sur le polymorphisme de l'héritage ou des caractères pour laisser les sous-classes déterminer quelle classe de béton à instancier.
Ce modèle est particulièrement précieux lorsqu'un framework ne peut pas anticiper les types exacts d'objets qu'il doit créer à l'avance. Dans le contexte de la logarithme, le framework sait qu'il a besoin d'un logger, mais le type de logger spécifique (console, fichier, réseau, base de données) est déterminé à l'exécution en fonction de la configuration, des variables d'environnement ou du contexte de déploiement.
Les principaux participants au modèle
- Produit (Trait de logger):[ Définit l'interface pour les objets créés par la méthode d'usine.
- ConcreteProduct (ConsoleLogger, FileLogger, etc.): Implémente l'interface Produit.
- Créateur (LoggerFactory): Déclare la méthode d'usine qui renvoie un objet Produit. Peut également contenir la logique d'implémentation par défaut.
- ConcreteCreator (facultatif): Surpasse la méthode d'usine pour renvoyer des instances spécifiques de ConcreteProduct.
Conception de la hiérarchie des caractères des bûcherons
La base de tout cadre de loging extensible est une interface bien abstraite. Dans Scala, les caractères fournissent un mécanisme naturel pour définir ce contrat. Une interface de loging minimale devrait exposer les méthodes pour les niveaux de log communs tout en restant assez générique pour supporter divers moteurs.
trait Logger {
def debug(message: => String): Unit
def info(message: => String): Unit
def warn(message: => String): Unit
def error(message: => String, cause: Option[Throwable] = None): Unit
}
L'utilisation de paramètres par nom () est un choix de conception délibéré : elle reporte l'évaluation des messages jusqu'à ce que le logger décide si le message doit réellement être émis. Pour les chemins de code critiques où la logarithme de débogage est désactivée, cela évite le coût de l'interpolation des chaînes entièrement.
Ajout du filtre de niveau de journal
Une amélioration pratique consiste à intégrer le filtrage de niveau de log directement dans le trait. Cela empêche les messages de débogueur verbeux d'atteindre la sortie lorsque seuls des avertissements ou des erreurs sont nécessaires.
sealed trait LogLevel
case object Debug extends LogLevel
case object Info extends LogLevel
case object Warn extends LogLevel
case object Error extends LogLevel
trait Logger {
protected val level: LogLevel
def debug(message: => String): Unit = log(Debug, message)
def info(message: => String): Unit = log(Info, message)
def warn(message: => String): Unit = log(Warn, message)
def error(message: => String, cause: Option[Throwable] = None): Unit =
log(Error, message, cause)
protected def log(level: LogLevel, message: => String, cause: Option[Throwable] = None): Unit
}
Ce design donne à chaque enregistreur de béton le contrôle de son propre seuil tout en maintenant l'API publique cohérente. Un enregistreur de console peut tout imprimer, tandis qu'un enregistreur de fichiers de production peut supprimer les messages de débogue sauf configuration explicite.
Mise en œuvre des enregistreurs de béton
Avec la hiérarchie des caractères en place, la mise en œuvre des bûcherons en béton devient simple. Chaque bûcheron encapsule son propre mécanisme de sortie et respecte le filtrage basé sur le niveau hérité du trait de base.
Console Logger
class ConsoleLogger(override val level: LogLevel = Debug) extends Logger {
override protected def log(
level: LogLevel,
message: => String,
cause: Option[Throwable] = None
): Unit = {
val timestamp = java.time.Instant.now
println(s"[$timestamp] [$level] $message")
cause.foreach { t =>
t.printStackTrace(System.out)
}
}
}
Le ConsoleLogger est idéal pour le développement et le débogage. Il produit immédiatement à standard out, ce qui permet d'observer facilement le flux de log en temps réel.
Enregistreur de fichiers
class FileLogger(
filePath: String,
override val level: LogLevel = Info,
append: Boolean = true
) extends Logger {
import java.io.{BufferedWriter, FileWriter}
private val writer = new BufferedWriter(new FileWriter(filePath, append))
override protected def log(
level: LogLevel,
message: => String,
cause: Option[Throwable] = None
): Unit = {
val timestamp = java.time.Instant.now
val entry = s"[$timestamp] [$level] $message${cause.fold("")(t => s"\n${t.getStackTrace.mkString("\n")}")}\n"
writer.write(entry)
writer.flush()
}
def close(): Unit = writer.close()
}
La méthode est importante pour la gestion des ressources : les poignées de fichiers doivent être publiées correctement, surtout dans les applications à long terme. Dans un scénario de production, vous l'intégrerez probablement à une bibliothèque de gestion des ressources ou utiliserez la construction de Scala.
Enregistreur réseau (exemple UDP)
L'une des forces de la méthode Factory est que l'ajout de nouveaux types de enregistreurs nécessite rarement de changer de code existant. Un enregistreur de réseau qui envoie des entrées de journaux sur UDP à un collecteur central démontre cette extensibilité :
class UdpLogger(
host: String,
port: Int,
override val level: LogLevel = Warn
) extends Logger {
import java.net.{DatagramPacket, DatagramSocket, InetAddress}
private val socket = new DatagramSocket()
private val address = InetAddress.getByName(host)
override protected def log(
level: LogLevel,
message: => String,
cause: Option[Throwable] = None
): Unit = {
val payload = s"[$level] $message".getBytes("UTF-8")
val packet = new DatagramPacket(payload, payload.length, address, port)
socket.send(packet)
}
}
Ce logger envoie des paquets UDP à un hôte distant. Parce que le code client dépend uniquement du caractère , passer d'un FileLogger à un UdpLogger ne nécessite rien de plus que de changer la configuration qui conduit l'usine.
Construction de l'usine
L'usine encapsule la logique pour sélectionner et instancier le logger approprié. Dans Scala, un objet compagnon avec une méthode d'application est idiomatique et fournit une syntaxe propre pour les clients.
Installation de configuration
object LoggerFactory {
sealed trait Config
object Config {
final case class Console(level: LogLevel = Debug) extends Config
final case class File(path: String, level: LogLevel = Info, append: Boolean = true) extends Config
final case class Udp(host: String, port: Int, level: LogLevel = Warn) extends Config
}
def apply(config: Config): Logger = config match {
case Config.Console(level) =>
new ConsoleLogger(level)
case Config.File(path, level, append) =>
new FileLogger(path, level, append)
case Config.Udp(host, port, level) =>
new UdpLogger(host, port, level)
}
}
Ce modèle utilise des classes de cas scellées pour représenter les configurations de logger. La hiérarchie scellée assure une correspondance exhaustive des motifs au moment de la compilation : l'ajout d'un nouveau type de logger nécessite l'ajout d'une nouvelle classe de cas et d'un nouveau cas dans l'expression de la correspondance.
Usine basée sur l'environnement
Dans de nombreux déploiements, la configuration de l'enregistrement est déterminée par des variables d'environnement plutôt que par la configuration de niveau de code. Une usine qui lit des variables d'environnement peut simplifier le déploiement dans différents environnements :
object LoggerFactory {
def fromEnvironment(): Logger = {
val loggerType = sys.env.getOrElse("LOGGER_TYPE", "console").toLowerCase
val level = sys.env.get("LOG_LEVEL").map(parseLevel).getOrElse(Info)
loggerType match {
case "console" => new ConsoleLogger(level)
case "file" =>
val path = sys.env.getOrElse("LOG_FILE", "application.log")
new FileLogger(path, level)
case "udp" =>
val host = sys.env.getOrElse("LOG_HOST", "localhost")
val port = sys.env.get("LOG_PORT").map(_.toInt).getOrElse(514)
new UdpLogger(host, port, level)
case other =>
System.err.println(s"Unknown logger type: $other, falling back to console")
new ConsoleLogger(level)
}
}
private def parseLevel(s: String): LogLevel = s.toLowerCase match {
case "debug" => Debug
case "info" => Info
case "warn" => Warn
case "error" => Error
case _ => Info
}
}
Cette approche est particulièrement utile dans les environnements conteneurisés où les variables d'environnement sont le mécanisme de configuration primaire. L'usine devient un point de changement unique pour la configuration de l'enregistrement dans tous les services.
Utilisation du cadre de l'exploitation forestière
Le code client interagit exclusivement avec le caractère . Ce découplage signifie que le reste de l'application n'a aucune dépendance de compilation-temps sur une implémentation de logger concret.
Utilisation de base
val logger: Logger = LoggerFactory(LoggerFactory.Config.Console(Debug))
logger.debug("Entering method computeResults")
logger.info("Processing completed successfully")
logger.warn("Disk space below threshold")
logger.error("Connection refused", Some(new RuntimeException("timeout")))
Injectation dans des classes
Pour les applications plus importantes, l'injection du bûcheron par les paramètres du constructeur permet de maintenir la conception propre et testable:
class DataService(logger: Logger, database: Database) {
def fetchUser(id: String): Option[User] = {
logger.debug(s"Fetching user with id: $id")
val result = database.queryUser(id)
result match {
case Some(user) =>
logger.info(s"Found user: ${user.name}")
Some(user)
case None =>
logger.warn(s"User not found: $id")
None
}
}
}
Dans ce modèle, ne sait pas si la connexion va à console, fichier ou sur le réseau. L'usine crée le enregistreur approprié au point d'entrée de l'application et le file dans la hiérarchie de service.
Essais avec le modèle de méthode d'usine
L'un des avantages pratiques de cette conception est la testabilité. Parce que l'usine crée des enregistreurs basés sur la configuration, un test peut injecter un enregistreur spécial qui capture la sortie de log à des fins d'affirmation.
class TestLogger extends Logger {
val messages: scala.collection.mutable.ListBuffer[(LogLevel, String)] =
scala.collection.mutable.ListBuffer.empty
override val level: LogLevel = Debug
override protected def log(
level: LogLevel,
message: => String,
cause: Option[Throwable] = None
): Unit = {
messages += ((level, message))
}
}
// In tests:
val testLogger = new TestLogger()
val service = new DataService(testLogger, mockDatabase)
service.fetchUser("42")
assert(testLogger.messages.exists {
case (Info, msg) if msg.contains("Found user") => true
case _ => false
})
Ce modèle élimine le besoin de frameworks de simulation pour les préoccupations de l'enregistrement. Le implémente le même caractère que les enregistreurs de production, de sorte que la vérification du comportement est sans danger et simple.
Comparaison avec d'autres approches
La méthode d'usine n'est pas la seule façon d'obtenir une exploitation forestière extensible à Scala. Comprendre les compromis avec d'autres approches aide à clarifier pourquoi la méthode d'usine est souvent le bon choix pour les systèmes de production.
Idiome simple de l'usine
De nombreux projets Scala commencent par un simple qui renvoie un enregistreur basé sur un paramètre chaîne. Bien que plus simple à mettre en œuvre, cette approche ne s'élargit pas bien : chaque nouveau type de enregistreur nécessite de modifier la fonction d'usine, et la logique centrale peut devenir un goulot d'étranglement de maintenance.
Cadres d'injection de dépendance
Les cadres comme Guice ou MacWire peuvent être des enregistreurs de fils automatiquement. Cependant, ils introduisent une complexité supplémentaire et des frais généraux d'exécution qui sont souvent inutiles pour une préoccupation transversale comme la logarithme.
Combineurs d'enregistreurs fonctionnels
Une approche purement fonctionnelle pourrait représenter des bûcherons comme fonctions . Cela fonctionne bien dans les bibliothèques comme l'effet chat, mais ajoute une dépendance sur les types d'effet qui peut être inappropriée pour les projets qui n'utilisent pas déjà des systèmes d'effet fonctionnels.
Le modèle de la méthode d'usine occupe un milieu pragmatique : il est plus structuré qu'une simple usine conditionnelle, mais moins envahissant qu'un cadre complet de DI ou un système d'effets fonctionnels.
Extensions avancées
Une fois l'infrastructure de base de l'usine en place, plusieurs fonctionnalités avancées peuvent être ajoutées avec des changements de code minimes.
Enregistreur composite
Un enregistreur composite délègue simultanément plusieurs enregistreurs. Ceci est utile pour les scénarios où le même message de journal doit être écrit à la fois dans un fichier et dans un tableau de bord de surveillance:
class CompositeLogger(loggers: Seq[Logger], override val level: LogLevel = Debug) extends Logger {
override protected def log(
level: LogLevel,
message: => String,
cause: Option[Throwable] = None
): Unit = {
loggers.foreach(_.log(level, message, cause))
}
}
L'usine peut créer des enregistreurs composites en acceptant une séquence de configurations. Le code client voit toujours une seule instance .
Async Logger
Le blocage des E/S dans les enregistreurs peut dégrader les performances de l'application. Un enregistreur async enveloppe un enregistreur existant et les délégués écrit à un pool de thread dédié:
class AsyncLogger(underlying: Logger, executor: scala.concurrent.ExecutionContext) extends Logger {
override val level: LogLevel = underlying.level
override protected def log(
level: LogLevel,
message: => String,
cause: Option[Throwable] = None
): Unit = {
val msg = message // evaluate now, before async boundary
executor.execute(() => underlying.log(level, msg, cause))
}
}
Ce wrapper implémente le même caractère , de sorte qu'il peut être inséré de manière transparente par l'usine sans aucune modification du code client.
Meilleures pratiques et pièges communs
La construction d'un cadre d'exploitation extensible avec la méthode d'usine est simple, mais plusieurs pratiques améliorent le résultat dans les systèmes de production.
Préférez les hiérarchies de type scellées pour la configuration
L'utilisation de caractères scellés ou de classes de cas pour la configuration du logger garantit que le modèle correspond à l'usine est exhaustif.
Gérer les ressources explicitement
Les enregistreurs qui détiennent des ressources (poignées de fichiers, prises de réseau, bassins de fils) doivent fournir un mécanisme de nettoyage. Envisagez de faire des enregistreurs s'étendre et en utilisant ou Scala pour assurer un nettoyage approprié.
Éviter l'optimisation prématurée
De nombreux cadres de logage optimisent le débit en classant les écritures ou en utilisant des structures de données sans verrou. Commencez par des implémentations simples et optimiser seulement après le profilage révèle que la loging est un goulot d'étranglement. L'abstraction en usine facilite l'échange d'un logger lent pour un plus rapide plus tard.
Gardez le trait minimal
Résistez à la tentation d'ajouter des méthodes de commodité au caractère . Une interface minimale est plus facile à mettre en œuvre et à tester. La logique de formatage ou de filtrage spécifique au domaine peut être ajoutée comme méthodes d'extension ou enregistreurs de paquets.
Ressources externes pour un apprentissage plus approfondi
- Scala Design Patterns[ par Ivan Nikolov[ — Un guide complet qui couvre le modèle de la méthode d'usine aux côtés d'autres modèles structuraux et créatifs dans le contexte Scala.
- Inversion des conteneurs témoins et du modèle d'injection de dépendance[ par Martin Fowler[] — Explique la relation entre les usines et l'injection de dépendance, aidant à clarifier quand chaque approche est appropriée.
- Meilleures pratiques centralisées de l'exploitation forestière par Loggly — Discute des stratégies de l'exploitation forestière de production qui complètent les cadres extensibles discutés ici.
Conclusion
Le modèle de méthode d'usine de Scala fournit une base propre et extensible pour les cadres de logarithmes de construction qui s'adaptent aux exigences changeantes. En fonction d'un trait plutôt que des classes de béton, le code d'application reste découplé des spécificités de la sortie log, permettant d'ajouter de nouveaux types de logger, de changer les destinations de logarithmes et d'introduire des optimisations de performances sans réécrire la logique existante.
Le modèle s'étend des petits projets avec un seul enregistreur de console aux grands systèmes distribués qui font passer simultanément les entrées de journaux à travers plusieurs canaux. Combiné aux hiérarchies scellées de Scala et aux paramètres par nom, le modèle de méthode Factory offre à la fois flexibilité et sécurité dans une mesure égale.