Der vollständige Leitfaden zur Protokollierung für Python-EntwicklerDer vollständige Leitfaden zur Protokollierung für Python-Entwickler
Bild vom Autor

# Einführung

Die meisten Python-Entwickler betrachten die Protokollierung als einen nachträglichen Gedanken. Sie werfen herum print() Anweisungen während der Entwicklung, wechseln Sie möglicherweise später zur einfachen Protokollierung und gehen Sie davon aus, dass dies ausreicht. Wenn jedoch in der Produktion Probleme auftreten, stellen sie fest, dass ihnen der Kontext fehlt, der für eine effiziente Problemdiagnose erforderlich ist.

Durch geeignete Protokollierungstechniken erhalten Sie Einblick in das Anwendungsverhalten, Leistungsmuster und Fehlerbedingungen. Mit dem richtigen Ansatz können Sie Benutzeraktionen verfolgen, Engpässe identifizieren und Probleme beheben, ohne sie lokal zu reproduzieren. Eine gute Protokollierung verwandelt das Debuggen von Vermutungen in eine systematische Problemlösung.

In diesem Artikel werden die wesentlichen Protokollierungsmuster behandelt, die Python-Entwickler verwenden können. Sie erfahren, wie Sie Protokollnachrichten für die Durchsuchbarkeit strukturieren, Ausnahmen behandeln, ohne den Kontext zu verlieren, und die Protokollierung für verschiedene Umgebungen konfigurieren. Wir beginnen mit den Grundlagen und arbeiten uns dann zu komplexeren Protokollierungsstrategien vor, die Sie sofort in Projekten verwenden können. Wir werden nur das verwenden Protokollierungsmodul.

Den Code finden Sie auf GitHub.

# Einrichten Ihres ersten Loggers

Anstatt direkt zu komplexen Konfigurationen zu springen, wollen wir verstehen, was ein Logger eigentlich tut. Wir erstellen einen einfachen Logger, der sowohl in die Konsole als auch in eine Datei schreibt.

import logging

logger = logging.getLogger('my_app')
logger.setLevel(logging.DEBUG)

console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)

file_handler = logging.FileHandler('app.log')
file_handler.setLevel(logging.DEBUG)

formatter = logging.Formatter(
    '%(asctime)s - %(identify)s - %(levelname)s - %(message)s'
)
console_handler.setFormatter(formatter)
file_handler.setFormatter(formatter)

logger.addHandler(console_handler)
logger.addHandler(file_handler)

logger.debug('It is a debug message')
logger.data('Software began')
logger.warning('Disk area operating low')
logger.error('Failed to connect with database')
logger.essential('System shutting down')

Hier ist, was jeder Teil des Codes bewirkt.

Der getLogger() Die Funktion erstellt eine benannte Logger-Instanz. Stellen Sie sich das so vor, als würden Sie einen Kanal für Ihre Protokolle erstellen. Der Identify „my_app“ hilft Ihnen bei der Identifizierung, woher Protokolle in größeren Anwendungen stammen.

Wir setzen den Logger-Degree auf DEBUGwas bedeutet, dass alle Nachrichten verarbeitet werden. Dann erstellen wir zwei Handler: einen für die Konsolenausgabe und einen für die Dateiausgabe. Handler steuern, wohin Protokolle verschoben werden.

Der Konsolenhandler zeigt nur an INFO Ebene und höher, während der Dateihandler alles erfasst, einschließlich DEBUG Nachrichten. Dies ist nützlich, da Sie detaillierte Protokolle in Dateien, aber eine sauberere Ausgabe auf dem Bildschirm wünschen.

Der Formatierer bestimmt, wie Ihre Protokollnachrichten aussehen. Die Formatzeichenfolge verwendet Platzhalter wie %(asctime)s für den Zeitstempel und %(levelname)s für die Schwere.

# Verständnis der Protokollebenen und deren Verwendung

Pythons Protokollierungsmodul verfügt über fünf Standardstufen, und für nützliche Protokolle ist es wichtig zu wissen, wann jede einzelne zu verwenden ist.

Hier ist ein Beispiel:

logger = logging.getLogger('payment_processor')
logger.setLevel(logging.DEBUG)

handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter('%(levelname)s: %(message)s'))
logger.addHandler(handler)

def process_payment(user_id, quantity):
    logger.debug(f'Beginning fee processing for person {user_id}')

    if quantity <= 0:
        logger.error(f'Invalid fee quantity: {quantity}')
        return False

    logger.data(f'Processing ${quantity} fee for person {user_id}')

    if quantity > 10000:
        logger.warning(f'Giant transaction detected: ${quantity}')

    attempt:
        # Simulate fee processing
        success = charge_card(user_id, quantity)
        if success:
            logger.data(f'Cost profitable for person {user_id}')
            return True
        else:
            logger.error(f'Cost failed for person {user_id}')
            return False
    besides Exception as e:
        logger.essential(f'Cost system crashed: {e}', exc_info=True)
        return False

def charge_card(user_id, quantity):
    # Simulated fee logic
    return True

process_payment(12345, 150.00)
process_payment(12345, 15000.00)

Lassen Sie uns aufschlüsseln, wann die einzelnen Ebenen verwendet werden sollten:

  • DEBUGGEN dient der detaillierten Info, die während der Entwicklung nützlich ist. Sie würden es für Variablenwerte, Schleifeniterationen oder schrittweise Ausführungsverfolgungen verwenden. Diese sind in der Produktion normalerweise deaktiviert.
  • INFO markiert normale Vorgänge, die Sie aufzeichnen möchten. Hier finden Sie Informationen zum Starten eines Servers, zum Abschließen einer Aufgabe oder zu erfolgreichen Transaktionen. Diese bestätigen, dass Ihre Anwendung wie erwartet funktioniert.
  • WARNUNG signalisiert etwas Unerwartetes, aber kein Bruch. Dazu gehören geringer Speicherplatz, veraltete API-Nutzung oder ungewöhnliche, aber beherrschbare Situationen. Die Anwendung wird weiterhin ausgeführt, aber jemand sollte der Sache nachgehen.
  • FEHLER bedeutet, dass etwas fehlgeschlagen ist, die Anwendung jedoch fortgesetzt werden kann. Hierher gehören fehlgeschlagene Datenbankabfragen, Validierungsfehler oder Netzwerk-Timeouts. Der spezifische Vorgang ist fehlgeschlagen, die App wird jedoch weiterhin ausgeführt.
  • KRITISCH weist auf schwerwiegende Probleme hin, die zum Absturz der Anwendung oder zum Datenverlust führen können. Gehen Sie bei katastrophalen Ausfällen, die sofortige Aufmerksamkeit erfordern, sparsam damit um.

Wenn Sie den obigen Code ausführen, erhalten Sie:

DEBUG: Beginning fee processing for person 12345
DEBUG:payment_processor:Beginning fee processing for person 12345
INFO: Processing $150.0 fee for person 12345
INFO:payment_processor:Processing $150.0 fee for person 12345
INFO: Cost profitable for person 12345
INFO:payment_processor:Cost profitable for person 12345
DEBUG: Beginning fee processing for person 12345
DEBUG:payment_processor:Beginning fee processing for person 12345
INFO: Processing $15000.0 fee for person 12345
INFO:payment_processor:Processing $15000.0 fee for person 12345
WARNING: Giant transaction detected: $15000.0
WARNING:payment_processor:Giant transaction detected: $15000.0
INFO: Cost profitable for person 12345
INFO:payment_processor:Cost profitable for person 12345
True

Lassen Sie uns als Nächstes mehr über die Protokollierung von Ausnahmen erfahren.

# Ausnahmen ordnungsgemäß protokollieren

Wenn Ausnahmen auftreten, benötigen Sie mehr als nur die Fehlermeldung. Sie benötigen den vollständigen Stack-Hint. Hier erfahren Sie, wie Sie Ausnahmen effektiv erfassen.

import json

logger = logging.getLogger('api_handler')
logger.setLevel(logging.DEBUG)

handler = logging.FileHandler('errors.log')
formatter = logging.Formatter(
    '%(asctime)s - %(identify)s - %(levelname)s - %(message)s'
)
handler.setFormatter(formatter)
logger.addHandler(handler)

def fetch_user_data(user_id):
    logger.data(f'Fetching information for person {user_id}')

    attempt:
        # Simulate API name
        response = call_external_api(user_id)
        information = json.masses(response)
        logger.debug(f'Obtained information: {information}')
        return information
    besides json.JSONDecodeError as e:
        logger.error(
            f'Didn't parse JSON for person {user_id}: {e}',
            exc_info=True
        )
        return None
    besides ConnectionError as e:
        logger.error(
            f'Community error whereas fetching person {user_id}',
            exc_info=True
        )
        return None
    besides Exception as e:
        logger.essential(
            f'Surprising error in fetch_user_data: {e}',
            exc_info=True
        )
        elevate

def call_external_api(user_id):
    # Simulated API response
    return '{"id": ' + str(user_id) + ', "identify": "John"}'

fetch_user_data(123)

Der Schlüssel hier ist die exc_info=True Parameter. Dadurch wird der Logger angewiesen, den vollständigen Ausnahme-Traceback in Ihre Protokolle aufzunehmen. Ohne sie erhalten Sie nur die Fehlermeldung, die jedoch oft nicht ausreicht, um das Downside zu beheben.

Beachten Sie, dass wir zuerst bestimmte Ausnahmen abfangen und dann eine allgemeine Ausnahme festlegen Exception Handler. Die spezifischen Handler ermöglichen es uns, kontextgerechte Fehlermeldungen bereitzustellen. Der allgemeine Handler fängt alles Unerwartete ab und erhöht es erneut, weil wir nicht wissen, wie wir sicher damit umgehen sollen.

Beachten Sie auch, dass wir uns anmelden ERROR für erwartete Ausnahmen (wie Netzwerkfehler), aber CRITICAL für Unerwartetes. Diese Unterscheidung hilft Ihnen, bei der Überprüfung von Protokollen Prioritäten zu setzen.

# Erstellen einer wiederverwendbaren Logger-Konfiguration

Das Kopieren des Logger-Setup-Codes über Dateien hinweg ist mühsam und fehleranfällig. Lassen Sie uns eine Konfigurationsfunktion erstellen, die Sie überall in Ihr Projekt importieren können.

# logger_config.py

import logging
import os
from datetime import datetime


def setup_logger(identify, log_dir="logs", stage=logging.INFO):
    """
    Create a configured logger occasion

    Args:
        identify: Logger identify (often __name__ from calling module)
        log_dir: Listing to retailer log recordsdata
        stage: Minimal logging stage

    Returns:
        Configured logger occasion
    """
    # Create logs listing if it would not exist

    if not os.path.exists(log_dir):
        os.makedirs(log_dir)
    logger = logging.getLogger(identify)

    # Keep away from including handlers a number of occasions

    if logger.handlers:
        return logger
    logger.setLevel(stage)

    # Console handler - INFO and above

    console_handler = logging.StreamHandler()
    console_handler.setLevel(logging.INFO)
    console_format = logging.Formatter("%(levelname)s - %(identify)s - %(message)s")
    console_handler.setFormatter(console_format)

    # File handler - all the pieces

    log_filename = os.path.be a part of(
        log_dir, f"{identify.change('.', '_')}_{datetime.now().strftime('%Ypercentmpercentd')}.log"
    )
    file_handler = logging.FileHandler(log_filename)
    file_handler.setLevel(logging.DEBUG)
    file_format = logging.Formatter(
        "%(asctime)s - %(identify)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s"
    )
    file_handler.setFormatter(file_format)

    logger.addHandler(console_handler)
    logger.addHandler(file_handler)

    return logger

Jetzt haben Sie es eingerichtet logger_configkönnen Sie es in Ihrem Python-Skript wie folgt verwenden:

from logger_config import setup_logger

logger = setup_logger(__name__)

def calculate_discount(value, discount_percent):
    logger.debug(f'Calculating low cost: {value} * {discount_percent}%')
    
    if discount_percent < 0 or discount_percent > 100:
        logger.warning(f'Invalid low cost share: {discount_percent}')
        discount_percent = max(0, min(100, discount_percent))
    
    low cost = value * (discount_percent / 100)
    final_price = value - low cost
    
    logger.data(f'Utilized {discount_percent}% low cost: ${value} -> ${final_price}')
    return final_price

calculate_discount(100, 20)
calculate_discount(100, 150)

Diese Setup-Funktion erledigt mehrere wichtige Dinge. Zunächst wird bei Bedarf das Protokollverzeichnis erstellt, um Abstürze aufgrund fehlender Verzeichnisse zu verhindern.

Die Funktion prüft, ob bereits Handler vorhanden sind, bevor sie neue hinzufügt. Ohne diesen Scheck rufe ich an setup_logger Mehrmaliges Ausführen würde zu doppelten Protokolleinträgen führen.

Wir generieren automatisch datierte Protokolldateinamen. Dies verhindert, dass Protokolldateien unendlich wachsen, und erleichtert das Auffinden von Protokollen zu bestimmten Zeitpunkten.

Der Dateihandler enthält mehr Particulars als der Konsolenhandler, einschließlich Funktionsnamen und Zeilennummern. Dies ist beim Debuggen von unschätzbarem Wert, würde aber die Konsolenausgabe überladen.

Benutzen __name__ Da der Logger-Identify eine Hierarchie erstellt, die Ihrer Modulstruktur entspricht. Dadurch können Sie die Protokollierung für bestimmte Teile Ihrer Anwendung unabhängig steuern.

# Protokolle mit Kontext strukturieren

Für einfache Anwendungen sind reine Textprotokolle in Ordnung, strukturierte Protokolle mit Kontext erleichtern jedoch das Debuggen erheblich. Fügen wir unseren Protokollen kontextbezogene Informationen hinzu.

import json
from datetime import datetime, timezone

class ContextLogger:
    """Logger wrapper that provides contextual info to all log messages"""

    def __init__(self, identify, context=None):
        self.logger = logging.getLogger(identify)
        self.context = context or {}

        handler = logging.StreamHandler()
        formatter = logging.Formatter('%(message)s')
        handler.setFormatter(formatter)
        # Examine if handler already exists to keep away from duplicate handlers
        if not any(isinstance(h, logging.StreamHandler) and h.formatter._fmt == '%(message)s' for h in self.logger.handlers):
            self.logger.addHandler(handler)
        self.logger.setLevel(logging.DEBUG)

    def _format_message(self, message, stage, extra_context=None):
        """Format message with context as JSON"""
        log_data = {
            'timestamp': datetime.now(timezone.utc).isoformat(),
            'stage': stage,
            'message': message,
            'context': {**self.context, **(extra_context or {})}
        }
        return json.dumps(log_data)

    def debug(self, message, **kwargs):
        self.logger.debug(self._format_message(message, 'DEBUG', kwargs))

    def data(self, message, **kwargs):
        self.logger.data(self._format_message(message, 'INFO', kwargs))

    def warning(self, message, **kwargs):
        self.logger.warning(self._format_message(message, 'WARNING', kwargs))

    def error(self, message, **kwargs):
        self.logger.error(self._format_message(message, 'ERROR', kwargs))

Sie können die verwenden ContextLogger etwa so:

def process_order(order_id, user_id):
    logger = ContextLogger(__name__, context={
        'order_id': order_id,
        'user_id': user_id
    })

    logger.data('Order processing began')

    attempt:
        gadgets = fetch_order_items(order_id)
        logger.data('Gadgets fetched', item_count=len(gadgets))

        whole = calculate_total(gadgets)
        logger.data('Complete calculated', whole=whole)

        if whole > 1000:
            logger.warning('Excessive worth order', whole=whole, flagged=True)

        return True
    besides Exception as e:
        logger.error('Order processing failed', error=str(e))
        return False

def fetch_order_items(order_id):
    return ({'id': 1, 'value': 50}, {'id': 2, 'value': 75})

def calculate_total(gadgets):
    return sum(merchandise('value') for merchandise in gadgets)

process_order('ORD-12345', 'USER-789')

Das ContextLogger Der Wrapper macht etwas Nützliches: Er fügt automatisch den Kontext in jede Protokollnachricht ein. Der order_id Und user_id werden zu allen Protokollen hinzugefügt, ohne sie bei jedem Protokollierungsaufruf zu wiederholen.

Der JSON Das Format erleichtert das Parsen und Durchsuchen dieser Protokolle.

Der **kwargs In jeder Protokollierungsmethode können Sie bestimmten Protokollmeldungen zusätzlichen Kontext hinzufügen. Dies kombiniert den globalen Kontext (order_id, user_id) mit lokalem Kontext (item_count, whole) automatisch.

Dieses Muster ist besonders nützlich in Webanwendungen, bei denen Sie Anforderungs-IDs, Benutzer-IDs oder Sitzungs-IDs in jeder Protokollnachricht einer Anforderung haben möchten.

# Protokolldateien rotieren, um Speicherplatzprobleme zu vermeiden

Protokolldateien wachsen in der Produktion schnell an. Ohne Rotation füllen sie irgendwann Ihre Festplatte. Hier erfahren Sie, wie Sie die automatische Protokollrotation implementieren.

from logging.handlers import RotatingFileHandler, TimedRotatingFileHandler

def setup_rotating_logger(identify):
    logger = logging.getLogger(identify)
    logger.setLevel(logging.DEBUG)

    # Measurement-based rotation: rotate when file reaches 10MB
    size_handler = RotatingFileHandler(
        'app_size_rotation.log',
        maxBytes=10 * 1024 * 1024,  # 10 MB
        backupCount=5  # Maintain 5 previous recordsdata
    )
    size_handler.setLevel(logging.DEBUG)

    # Time-based rotation: rotate day by day at midnight
    time_handler = TimedRotatingFileHandler(
        'app_time_rotation.log',
        when='midnight',
        interval=1,
        backupCount=7  # Maintain 7 days
    )
    time_handler.setLevel(logging.INFO)

    formatter = logging.Formatter(
        '%(asctime)s - %(identify)s - %(levelname)s - %(message)s'
    )
    size_handler.setFormatter(formatter)
    time_handler.setFormatter(formatter)

    logger.addHandler(size_handler)
    logger.addHandler(time_handler)

    return logger


logger = setup_rotating_logger('rotating_app')

Versuchen wir nun, die Rotation von Protokolldateien zu verwenden:

for i in vary(1000):
    logger.data(f'Processing document {i}')
    logger.debug(f'Document {i} particulars: accomplished in {i * 0.1}ms')

RotatingFileHandler verwaltet Protokolle basierend auf der Dateigröße. Wenn die Protokolldatei 10 MB (angegeben in Bytes) erreicht, wird sie in umbenannt app_size_rotation.log.1und ein neues app_size_rotation.log beginnt. Der backupCount von 5 bedeutet, dass Sie 5 alte Protokolldateien behalten, bevor die ältesten gelöscht werden.

TimedRotatingFileHandler rotiert basierend auf Zeitintervallen. Der Parameter „Mitternacht“ bedeutet, dass jeden Tag um Mitternacht eine neue Protokolldatei erstellt wird. Sie können auch „H“ für stündlich, „D“ für täglich (jederzeit) oder „W0“ für wöchentlich am Montag verwenden.

Der interval Parameter funktioniert mit dem when Parameter. Mit when='H' Und interval=6Protokolle würden alle 6 Stunden rotieren.

Diese Handler sind für Produktionsumgebungen unerlässlich. Ohne sie könnte Ihre Anwendung abstürzen, wenn die Festplatte mit Protokollen voll ist.

# Anmelden in verschiedenen Umgebungen

Ihre Protokollierungsanforderungen unterscheiden sich zwischen Entwicklung, Staging und Produktion. Hier erfahren Sie, wie Sie die Protokollierung konfigurieren, die sich an jede Umgebung anpasst.

import logging
import os

def configure_environment_logger(app_name):
    """Configure logger based mostly on setting"""
    setting = os.getenv('APP_ENV', 'growth')
    
    logger = logging.getLogger(app_name)
    
    # Clear present handlers
    logger.handlers = ()
    
    if setting == 'growth':
        # Improvement: verbose console output
        logger.setLevel(logging.DEBUG)
        handler = logging.StreamHandler()
        handler.setLevel(logging.DEBUG)
        formatter = logging.Formatter(
            '%(levelname)s - %(identify)s - %(funcName)s:%(lineno)d - %(message)s'
        )
        handler.setFormatter(formatter)
        logger.addHandler(handler)
        
    elif setting == 'staging':
        # Staging: detailed file logs + vital console messages
        logger.setLevel(logging.DEBUG)
        
        file_handler = logging.FileHandler('staging.log')
        file_handler.setLevel(logging.DEBUG)
        file_formatter = logging.Formatter(
            '%(asctime)s - %(identify)s - %(levelname)s - %(funcName)s - %(message)s'
        )
        file_handler.setFormatter(file_formatter)
        
        console_handler = logging.StreamHandler()
        console_handler.setLevel(logging.WARNING)
        console_formatter = logging.Formatter('%(levelname)s: %(message)s')
        console_handler.setFormatter(console_formatter)
        
        logger.addHandler(file_handler)
        logger.addHandler(console_handler)
        
    elif setting == 'manufacturing':
        # Manufacturing: structured logs, errors solely to console
        logger.setLevel(logging.INFO)
        
        file_handler = logging.handlers.RotatingFileHandler(
            'manufacturing.log',
            maxBytes=50 * 1024 * 1024,  # 50 MB
            backupCount=10
        )
        file_handler.setLevel(logging.INFO)
        file_formatter = logging.Formatter(
            '{"timestamp": "%(asctime)s", "stage": "%(levelname)s", '
            '"logger": "%(identify)s", "message": "%(message)s"}'
        )
        file_handler.setFormatter(file_formatter)
        
        console_handler = logging.StreamHandler()
        console_handler.setLevel(logging.ERROR)
        console_formatter = logging.Formatter('%(levelname)s: %(message)s')
        console_handler.setFormatter(console_formatter)
        
        logger.addHandler(file_handler)
        logger.addHandler(console_handler)
    
    return logger

Diese umgebungsbasierte Konfiguration behandelt jede Part unterschiedlich. Die Entwicklung zeigt alles auf der Konsole mit detaillierten Informationen an, einschließlich Funktionsnamen und Zeilennummern. Dies ermöglicht ein schnelles Debuggen.

Staging bringt Entwicklung und Produktion ins Gleichgewicht. Es schreibt detaillierte Protokolle zur Untersuchung in Dateien, zeigt jedoch nur Warnungen und Fehler auf der Konsole an, um Störungen zu vermeiden.

Bei der Produktion stehen Leistung und Struktur im Vordergrund. Es wird nur protokolliert INFO Ebene und höher in Dateien, verwendet JSON-Formatierung für einfaches Parsen und implementiert Protokollrotation, um den Speicherplatz zu verwalten. Die Konsolenausgabe ist nur auf Fehler beschränkt.

# Set setting variable (usually executed by deployment system)
os.environ('APP_ENV') = 'manufacturing'

logger = configure_environment_logger('my_application')

logger.debug('This debug message will not seem in manufacturing')
logger.data('Consumer logged in efficiently')
logger.error('Didn't course of fee')

Die Umgebung wird durch die bestimmt APP_ENV Umgebungsvariable. Ihr Bereitstellungssystem (Docker, Kubernetesoder andere Cloud-Plattformen) legt diese Variable automatisch fest.

Beachten Sie, wie wir vorhandene Handler vor der Konfiguration löschen. Dies verhindert doppelte Handler, wenn die Funktion während des Anwendungslebenszyklus mehrmals aufgerufen wird.

# Zusammenfassung

Eine gute Protokollierung macht den Unterschied zwischen der schnellen Diagnose von Problemen und dem stundenlangen Erraten, was schief gelaufen ist. Beginnen Sie mit der grundlegenden Protokollierung unter Verwendung geeigneter Schweregrade, fügen Sie strukturierten Kontext hinzu, um Protokolle durchsuchbar zu machen, und konfigurieren Sie die Rotation, um Speicherplatzprobleme zu vermeiden.

Die hier gezeigten Muster eignen sich für Anwendungen jeder Größe. Beginnen Sie einfach mit der einfachen Protokollierung, fügen Sie dann eine strukturierte Protokollierung hinzu, wenn Sie eine bessere Durchsuchbarkeit benötigen, und implementieren Sie umgebungsspezifische Konfigurationen, wenn Sie sie in der Produktion bereitstellen.

Viel Spaß beim Loggen!

Bala Priya C ist ein Entwickler und technischer Redakteur aus Indien. Sie arbeitet gerne an der Schnittstelle von Mathematik, Programmierung, Datenwissenschaft und Inhaltserstellung. Zu ihren Interessen- und Fachgebieten gehören DevOps, Datenwissenschaft und Verarbeitung natürlicher Sprache. Sie liebt es zu lesen, zu schreiben, zu programmieren und Kaffee zu trinken! Derzeit arbeitet sie daran, zu lernen und ihr Wissen mit der Entwickler-Neighborhood zu teilen, indem sie Tutorials, Anleitungen, Meinungsbeiträge und mehr verfasst. Bala erstellt außerdem ansprechende Ressourcenübersichten und Programmier-Tutorials.



Von admin

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert