Churn PHP: come identificare i mostri nei tuoi progetti PHP

Churn PHP: come identificare i mostri nel tuo progetto

By on Tue Dec 17 in PHP, refactoring


0
(0)

TL;DR

Churn PHP è uno strumento che aiuta a individuare i file di codice più problematici in un progetto, combinando complessità e frequenza di modifiche (churn). Codici con alta complessità e frequenti modifiche rappresentano candidati ideali per il refactoring, una pratica che dovrebbe essere parte quotidiana dello sviluppo per migliorare manutenibilità e stabilità del codice.

Sommario

  1. Introduzione
  2. Complessità del codice
  3. Refactoring
  4. Cos’è Churn PHP
  5. Le metriche
  6. Esempi pratici di refactoring
  7. Approfondimento su Churn PHP
  8. Conclusione

Introduzione

Quante volte, iniziando a lavorare per una nuova azienda, hai aperto un file contenente una sola classe e, con grande sorpresa, hai scoperto che contiene 3000 righe di codice? A me è capitato tantissime volte. E mi chiedo sempre perché noi sviluppatori non dedichiamo del tempo a un’attività fondamentale che, molto spesso, farebbe risparmiare tempo e soldi in futuro? Mi riferisco al refactoring.

In molti mi hanno detto: “Finché funziona, non si tocca”. Oppure: “Sono solo dettagli”, o ancora la classica scusa: “Non abbiamo tempo”. Ma il tempo per altre attivitá, come un meme o una sigaretta, spesso lo troviamo in molti. È vero, toccare un pezzo di codice implica uno sforzo ulteriore: altri test (automatici o manuali), altri messaggi di commit a cui pensare, insomma, altre grane. Ma siamo sviluppatori, non lavoriamo in un fast food – spoiler: io ho lavorato in un fast food quando ero giovane.

Per quanto mi riguarda, ridurre la complessità nel codice è e deve essere una priorità. Il refactoring è un’attività di sviluppo esattamente come lo sono scrivere test, fare bug fixing o sviluppare nuove funzionalità.

Complessità del codice

Il problema di una classe contenente 3000 linee non è la lunghezza, ma la complessità nel leggere un “mostro” del genere. Avere molte ramificazioni significa dover tenere traccia di un numero troppo alto di variabili e flussi. Questo sforzo è semplicemente troppo dispendioso in termini di sforzo mentale, e ciò porta a errori. Inoltre, un “mostro” di 3000 righe sarà difficile da testare nella sua interezza, perché ci saranno sicuramente dipendenze ed eccezioni di cui tenere conto.

Ma come si misura la complessità del codice? Un punto di partenza è la Cyclomatic complexity, ovvero una metrica software utilizzata per indicare una misura quantitativa del numero di percorsi linearmente indipendenti attraverso il codice sorgente di un programma. È stata sviluppata da Thomas J. McCabe, Sr. nel 1976. In altre parole, la Cyclomatic Complexity indica quante decisioni (if, loop, switch, ecc.) ci sono nel codice, aiutando a valutare la sua manutenibilità e il numero minimo di test necessari. Più è alta, più il codice è complesso da capire e testare. Ed ecco perché l’elevata complessità del codice è (quasi) sempre negativa. Scrivo quasi perché, se una classe non viene quasi mai toccata, potrebbe non risultare un grosso problema.

Refactoring

Come dicevo all’inizio dell’articolo, ho personalmente lavorato in molte aziende in cui la complessità del codice non era neanche percepita come un problema, e sto parlando di aziende sia italiane che straniere, grandi e piccole. E non è vero che solo i progetti legacy soffrono di questo problema; a volte è proprio il progetto in corso che, per poca sensibilità da parte degli sviluppatori – e dei manager – si porta dietro questo macigno, aggiungendo, giorno dopo giorno, complessità su complessità. Un giorno sarà chiamato “legacy”, e i futuri sviluppatori diranno che è troppo complesso.

Allocare un intero sprint per un vasto refactoring non è mai la soluzione, ma fare in modo che il refactoring diventi parte della nostra pratica quotidiana dovrebbe essere la strada giusta da seguire. Se facciamo refactoring mentre apportiamo modifiche al nostro codice, finiamo per lavorare con un codice progressivamente migliore. Ma come? A quali file dare la priorità? Una regola è dare priorità ai file che hanno un alto grado di complessità e cambiano abbastanza frequentemente.

È proprio la combinazione tra complessità e modifiche frequenti che guiderà il nostro refactoring.

Cos’é Churn PHP

Churn PHP aiuta a scoprire buoni candidati per il refactoring. Michael Feathers ha coniato il termine “churn” per descrivere il tasso di modifiche apportate ai file di un progetto. Il churn può essere quantificato con un valore, simile a quello utilizzato per misurare la complessità del codice. Supponendo che ogni classe sia definita in un file separato e che ogni modifica a una classe corrisponda a un nuovo commit nel sistema di controllo versione, un metodo pratico per misurare il churn di una classe consiste nel conteggiare i commit che interessano il file in cui è contenuta.

É proprio su questa combinazione di fattori, complessitá e churn di una classe, che Churn PHP fornisce dei valori numerici per individuare facilmente le classi che devono essere modificate spesso, ma che sono anche molto difficili da modificare, perché hanno un’elevata complessità del codice.

image churn PHP in action
Esempio di analisi Churn PHP

Le metriche

  1. Times Changed: Si riferisce al numero di volte in cui un determinato file o classe è stato modificato in un dato periodo. È un semplice conteggio dei commit relativi a quel file. In un progetto PHP, un numero elevato di modifiche a una classe o funzione specifica potrebbe indicare aree del codice che vengono aggiornate frequentemente, il che potrebbe suggerire una potenziale instabilità o requisiti in evoluzione.

  2. Complexity: La complexity si riferisce a quanto il codice in una determinata classe o file sia complesso. Questo può includere fattori come il numero di funzioni/metodi, le dichiarazioni condizionali, i cicli annidati e la struttura generale del codice. Un file o una classe più complessa può risultare più difficile da mantenere, comprendere e modificare, il che potrebbe portare a un churn più elevato. La complessità è spesso misurata utilizzando strumenti come la Cyclomatic complexity, che valuta il numero di percorsi indipendenti nel codice.

  3. Score: Lo score è una metrica complessiva che combina sia il Times Changed che la Complexity. È un modo per quantificare quanta attenzione potrebbe richiedere un determinato file o classe. Un punteggio più alto indica che il file ha subito molte modifiche e potrebbe anche avere una complessità elevata, suggerendo che si tratti di una parte critica o instabile del codice. Questo potrebbe indicare aree che necessitano di refactoring, test aggiuntivi o una migliore documentazione per garantirne la manutenibilità a lungo termine.

Un valore di churn alto combinato con una complessità elevata potrebbe indicare aree del codice difficili da mantenere o soggette a bug, orientando le decisioni riguardo al refactoring o alla semplificazione del codice.

Esempi pratici di refactoring

Troppa teoria potrebbe mascherare l’impatto che queste metriche hanno sul nostro lavoro. Voglio mostrare un esempio concreto di come si può semplificare una classe per rendere l’articolo più pratico e applicabile. É un esempio molto semplice, ma ci sono casi piú complessi come quelli di classi interdipendenti, in cui bisogna rompere le dipendenze introducendo nuovi design patterns.

Di seguito, la classe ItemController.php, contenuta in un progetto Laravel.

<?php

/* NOTE: here namespace and use */

class ItemController extends Controller
{
    /**
     * @param  Request  $request
     * @return JsonResponse
     */
    public function create(Request $request): JsonResponse
    {
        $validate_data = $request->validate([
            'slug' => 'required|string',
            'item' => 'required|string',
            'session_id' => 'nullable|string',
        ]);

        // Find message/letter for its slug
        $message = Message::where('slug', "like", "%" . $validate_data['slug'] . "%")->first();

        if (!$message) {
            return response()->json([
                'item'       => '',
                'session_id'    => '',
                'slug'          => '',
                'error'         => 'No message found!'
            ]);
        }

        // Is there already an items for this user?
        $item = null;
        if (isset($validate_data['session_id'])) {
            $item = Item::where('session_id', $validate_data['session_id'])->where('message_id', $message->id)->first();
        }

        if ($item) {
            Item::destroy($item->id);
        }

        if ($validate_data['item'] === '' 
            || $validate_data['item'] === Constants::LINK) {
            return response()->json([
                'item'       => '',
                'session_id'    => '',
                'slug'          => '',
                'error'         => 'No item selected!'
            ]);
        }

        // Save new items
        $item                = new Item();
        $item->message_id    = $message->id;
        $item->user_id       = (Auth::check()) ? Auth::user()->id : Constants::NOT_LOGGED_USER_ID;
        $item->name          = $validate_data['name'];
        $item->session_id    = (isset($validate_data['session_id'])) ? $validate_data['session_id'] : \Session::getId();
        $item->save();

        return response()->json([
            'item'       => $item->item,
            'session_id'    => $item->session_id,
            'slug'          => $validate_data['slug'],
        ]);
    }

    /**
     * @param Request $request
     * @return JsonResponse
     */
    public function getPublicItems(Request $request)
    {
        $request->merge(['slug' => $request->slug]);

        $validate_data = $request->validate([
            'slugs' => 'required|string',
        ]);

        // get messages from slugs
        $arrSlugs = json_decode($validate_data['slugs'], true);
        $query = Message::query();
        foreach ($arrSlugs as $slug) {
            $query->orWhere('slug', "like", "%-" . $slug);
        }
        $messages = $query->get(['id', 'slug']);

        $ids = array();
        foreach ($messages as $message) {
            $ids[] = $message->id;
        }

        // get items by messages ids
        $items = Item::select('item', 'message_id', \DB::raw('count(item) as item_count'))
            ->whereIn('message_id', $ids)
            ->groupBy('item', 'message_id')
            ->orderBy('message_id', 'desc')
            ->orderBy('item', 'desc')
            ->get();

        // Prepare result
        $results = [];
        foreach ($items as $item) {
            foreach ($messages as $message) {
                if ($item->message_id == $message->id) {
                    $result = array();
                    $result['slug']    = $this->getPublicSlug($message->slug);
                    $result['item'] = $item->item;
                    $result['count']   = $item->item_count;

                    $results[] = $result;
                }
            }
        }

        return response()->json([
            'items' => $results,
        ]);
    }
}

Nello specifico questa classe é un controller nella directory Http/Controllers di base, e che Churn PHP mi indica con questi valori:

+---------------------------------------------------+---------------+------------+-------+
| File                                              | Times Changed | Complexity | Score |
+---------------------------------------------------+---------------+------------+-------+
| app/Http/Controllers/ItemController.php           | 14            | 17         | 0.102 |
+---------------------------------------------------+---------------+------------+-------+

In effetti é una classe che ho cambiato spesso, e che contiene vari metodi pubblici, quindi possiamo dire che contiene molte responsabilitá e che viola l’SRP (Single Responsibility Principle).

Ecco gli steps che ho seguito per semplificare la classe:

  1. Dove mancano, ho creato nuove classi di Request, in questa maniera inizio con lo spostare la logica delle validazioni dal controller alle classi Requests;
  2. Dove posso, incapsulo alcune logiche in metodi privati, in maniera da eliminare commenti rindondanti e semplificare la lettura del codice. Non c’é nessuno motivo di mantenere un commento di questo tipo // Get messages by slugs quando con PHPStorm posso comodamente selezionare il pezzo di codice interessato, e con un semplice comando come Extract Method, posso generare un nuovo metodo che semplicemente chiameró getMessagesBySlugs; il codice ora é diventato autoesplicativo. In piú posso eliminare alcune variabili perché ora sfrutto il ritorno inline dei valori nei nuovi metodi;
  3. Useró i metodi pubblici di questa classe per creare nuove classi, separate, che hanno una singola responsabilità. I nomi delle nuove classi rispecchiano ora le funzionalitá, che non saranno piú affogate in una grande classe generale. Piccolo dettaglio: le nuove classi avranno un unico metodo pubblico invoke() e saranno contenute nella cartella Item. Devo tenere conto di questa cosa anche nei namespaces e nel file delle rotte, dove scriveró direttamente il namespace della classe che mi serve invece di indiciare i nomi dei metodi;
  4. Se posso, faró anche leva su una tecnica chiamata Return Early Pattern, ovvero si scrive il ritorno di una funzione o di un metodo non appena una certa condizione non è soddisfatta, invece di permettere al codice di continuare l’esecuzione, o invece di circordare un blocco di codice se una condizione é rispettata.

Qui di seguito, le nuove classi.

CreateController.php

<?php

/* NOTE: here namespace and use */

class CreateController extends Controller
{
    /**
     * @param ItemCreateRequest $request
     * @return JsonResponse
     */
    public function __invoke(ItemCreateRequest $request): JsonResponse
    {
        $validateData = $request->validated();

        if ($validateData['item'] === ''
            || $validateData['item'] === Constants::LINK) {

            return response()->json([
                'item' => '',
                'session_id' => '',
                'slug' => '',
                'error' => 'No item selected!',
            ]);
        }

        $message = Message::where('slug', 'like', '%' . $validateData['slug'] . '%')
            ->first();

        if (!$message) {
            return response()->json([
                'item' => '',
                'session_id' => '',
                'slug' => '',
                'error' => 'No message found!',
            ]);
        }

        if ($sessionId) {
            $this->deleteItemForUserIfExists($validateData['session_id'] ?? null, $message->id);
        }
        
        $item = $this->storeNewItem($message->id, $validateData);
        
        return response()->json([
            'item' => $item->item,
            'session_id' => $item->session_id,
            'slug' => $validateData['slug'],
        ]);
    }

    /**
     * @param string $sessionId
     * @param int $messageId
     *
     * @return void
     */
    private function deleteItemForUserIfExists(string $sessionId, int $messageId): void
    {
        
        $item = Item::where('session_id', $sessionId)
            ->where('message_id', $messageId)
            ->first();

        if ($item) {
            $item->delete();
        }
    }

    /**
     * @param int $message
     * @param array $validateData
     *
     * @return Item
     */
    private function storeNewItem(int $messageId, array $validateData): Item
    {
        $item = new Item();
        $item->message_id = $messageId;
        $item->user_id = (Auth::check())
            ? Auth::user()->id
            : Constants::NOT_LOGGED_USER_ID;
        $item->name = $validateData['name'];
        $item->session_id = (isset($validateData['session_id']))
            ? $validateData['session_id']
            : \Session::getId();
        $item->save();

        return $item;
    }
}

GetListController.php

<?php

/* NOTE: here namespace and use */

class GetListController extends Controller
{
    /**
     * @param GetItmesListRequest $request $request
     * @return JsonResponse
     */
    public function __invoke(GetItmesListRequest $request): JsonResponse
    {
        $validateData = $request->validated();

        $messages = $this->getMessagesBySlugs($validateData['slugs']);

        $ids = [];
        foreach ($messages as $message) {
            $ids[] = $message->id;
        }

        // get items by messages ids
        $items = Item::select('item', 'message_id', \DB::raw('count(item) as item_count'))
            ->whereIn('message_id', $ids)
            ->groupBy('item', 'message_id')
            ->orderBy('message_id', 'desc')
            ->orderBy('item', 'desc')
            ->get();

        $results = $this->prepareResults($items, $messages);

        return response()->json([
            'items' => $results,
        ]);
    }

    /**
     * @param string $slugs
     * @return Builder[]|Collection
     */
    private function getMessagesBySlugs(string $slugs): array|Collection
    {
        $arrSlugs = json_decode($slugs, true);

        $query = Message::query();
        foreach ($arrSlugs as $slug) {
            $query->orWhere('slug', 'like', '%-' . $slug);
        }

        return $query->get(['id', 'slug']);
    }

    /**
     * @param array|Collection $items
     * @param array|Collection $messages
     * 
     * @return array
     */
    private function prepareResults(array|Collection $items, array|Collection $messages): array
    {
        $results = [];
        foreach ($items as $item) {
            foreach ($messages as $message) {
                if ($item->message_id == $message->id) {
                    $results[] = [
                        'slug' => $this->getPublicSlug($message->slug),
                        'item' => $item->item,
                        'count' => $item->item_count,
                    ];
                }
            }
        }

        return $results;
    }
}

Con ulteriori ottimizzazioni, come l’introduzione di una classe di servizio da iniettare nel costruttore della classe, e una gestione più strutturata delle eccezioni, si potrebbe rendere il codice ancora più robusto e riutilizzabile.

Approfondimento su Churn PHP

Churn PHP è uno strumento potente, ma spesso viene utilizzato con configurazioni di base che non sfruttano appieno il suo potenziale. Personalizzare il file churn.yml consente di adattare l’analisi alle specifiche esigenze di un progetto. Ad esempio, si possono escludere file o directory non rilevanti come vendor/ o storage/, oppure impostare soglie personalizzate per complessità e churn, evidenziando solo i file realmente critici. Di seguito un piccolo esempio di file di configurazione churn.yml:

# This list is used only if there is no argument when running churn.
# Default: <empty>
directoriesToScan:
 - src
 - tests/

# Files to ignore when processing. The full path to the file relative to the root of your project is required.
# Also supports regular expressions.
# Default: All PHP files in the path provided to churn-php are processed.
filesToIgnore:
  - vendor/*
  - storage/*

# The command returns an 1 exit code if the highest score is greater than the threshold.
# Disabled if null.
# Default: null
maxScoreThreshold: 0.9

Inoltre, Churn PHP supporta diversi formati di output, come JSON, ideali per integrazioni avanzate con dashboard o pipeline CI/CD. Configurare il tool in modo da eseguire analisi incrementali direttamente su GitHub Actions o GitLab CI, ad esempio, permette di identificare i file problematici in tempo reale, prima che il codice venga integrato. Una configurazione curata trasforma Churn PHP in un alleato imprescindibile per mantenere la qualità del codice alta senza rallentare lo sviluppo.

Per quanto riguarda l’installazione, la compatibilitá e ulteriori configurazioni, fare riferimento al repository Github Churn PHP.

Rector e Churn PHP: due strumenti complementari per il refactoring

Un’ultima nota riguardo ad un altro potente strumento utilizzato per il refactoring: Rector.

Rector è uno strumento di automatic refactoring per PHP che permette di trasformare il codice sorgente in maniera programmata e automatizzata, applicando regole predefinite o personalizzate. A differenza di Churn PHP, che si concentra sull’analisi del codice per individuare le classi con un alto tasso di modifiche (churn) e complessità, Rector interviene direttamente sul codice, eseguendo il refactoring in modo rapido e sicuro.

L’uso combinato di questi strumenti è estremamente efficace: Churn PHP aiuta a identificare i file che necessitano di interventi prioritari grazie a metriche concrete, mentre Rector può automatizzare molte delle modifiche necessarie, come la rimozione di codice ridondante, l’adeguamento a standard moderni o la semplificazione di pattern complessi, basandosi su regole impostate dagli sviluppatori. Questa sinergia consente di ottimizzare il refactoring, bilanciando analisi intelligente e automazione, per ottenere un codice più pulito e manutenibile senza rallentare il flusso di sviluppo.

Per l’installazione e la documentazione, fare riferimento al repository Github di Rector.

Conclusioni

Il refactoring non è solo una pratica tecnica, ma una strategia che può trasformare il modo in cui un team affronta lo sviluppo software. Strumenti come PHPStan, e Churn PHP aiutano a identificare aree critiche, e strumenti come Pint aiutano a mantenere uno standard di scrittura omogeneo (PSR12 per esempio), ma il successo passa attraverso strategie a lungo termine: integrare il refactoring nelle attività quotidiane e promuovere una cultura che lo consideri parte essenziale del processo di sviluppo.

È importante bilanciare il desiderio di migliorare il codice con i rischi, come l’introduzione di bug, affrontandolo con approcci agili e incrementali. Infine, metriche come il churn non solo guidano il refactoring, ma rivelano anche la salute del codice e del team, offrendo spunti per migliorare collaborazione e qualità generale.

E tu? Quali strategie utilizzi per affrontare il refactoring? Lascio aperti i commenti per chi avesse voglia di sviluppare una conversazione riguardo il refactoring e le tecniche usate.

How useful was this post?

Click on a star to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.

Leave a Reply

Your email address will not be published. Required fields are marked *

No comments yet. Be the first one!