Ci, którzy śledzą stronę niniejszego blogu na Facebooku, na pewno zauważyli status, jakim się z nimi podzieliłem w środę. Strasznie się wtedy zdenerwowałem, bo straciłem kilka godzin życia, a popełniony błąd był trywialny. Podczas analizy działania zwyczajnie skupiłem się na bardziej zaawansowanych częściach kodu, zapominając o podstawach, co odbiło się niestety na procesie “debugowania”. Skoro powstał problem, to na pewno istnieje dla niego rozwiązanie, dlatego w niniejszym wpisie chciałbym przedstawić Wam sposób na zabezpieczenie się przed tym “błędem”.

Funkcja require_once() działa… tylko “once”.

O co chodziło w całym problemie? Otóż, moja praca inżynierska, w ramach której piszę framework w języku PHP w pewnym momencie przekazuje sterowanie do modułu widoku [View]. Widok ten, pomijając całą abstrakcję, jaka za nim stoi, w pewnym momencie wykonuje zwyczajną instrukcję require_once(), włączając plik z kodem szablonu.

Nie ma w tym nic nadzwyczajnego, poza tym, że czasem w Widoku może wystąpić błąd, co dzięki wymyślonej przeze mnie strukturze kodu zawsze kończy się rzuceniem wyjątku. Wyjątek ten, o ile nie zostanie “złapany” i wcześniej obsłużony, kończy się przekierowaniem przepływu sterowania na uprzednio zdefiniowany tzw. “routing 404”, czyli arbitralnie wybrany kontroler i akcję, która wyświetla stosowny błąd.

Problem polega na tym, że oba widoki - ten od “normalnej” akcji i ten od wyświetlania błędu korzystają w wielu miejscach z tych samych plików partiali [plików, zawierających powtarzalne fragmenty widoku, np. nagłówek dokumentu HTML]. W tym momencie każdy “siedzący w temacie” widzi już problem. Jeśli wystąpi błąd, “powtórzone” pliki zwyczajnie nie zostaną włączone do kodu, co istotnie zuboży wyświetlane dane. W moim przypadku nie mogłem znaleźć przyczyny, dla której widok 404 się nie wyświetlał.

Wszystkiemu “winna” jest funkcja require_once(), którą niejako “z rozpędu” wstawiłem do metody render() głównej klasy modułu widoku. Winny oczywiście jestem ja, jako programista, aczkolwiek nie znaczy to, że zamierzam siedzieć z rozłożonymi rękami i czekać, aż ktoś znowu “potknie się” o ten sam problem.

Funkcja get_included_files().

Okazuje się, że możemy się zabezpieczyć się przed takimi problemami w relatywnie prosty sposób, w obie strony - możemy sprawdzić, czy plik był już “includowany” [ktoś zna lepsze słowo określające ten proces?], aby:
  • zabezpieczyć skrypt przed kolejnymi próbami wstawiania np. pliku tej samej klasy.
  • przetestować przepływ sterowania aplikacji i ilość odwołań do danego pliku
PHP jest znany z tego, że posiada dosyć mało “zwartą” bibliotekę standardową, przez co podczas przeglądania manuala można natknąć się na bardzo ciekawe pozycje, które bardzo ułatwiają życie programisty. Jedną z takich ciekawostek jest funkcja get_included_files(), która zwraca nam tablicę [array] ze ścieżkami wszystkich plików, jakie były włączane do kodu aż do linijki wywołania. Funkcja ta posiada także swój odpowiednik o nazwie get_required_files(), jednak jest on tylko “aliasem” dla wersji ze słowem “include”, także możemy go pominąć w opisie.

Mając tego typu informacje, możemy przeprowadzić mały eksperyment, pokazujący działanie funkcji. Struktura plików wygląda następująco:

Skrypt testowy:

<?php
function showIncludedFiles()
	{
	$files = get_included_files();
	foreach($files as $number => $file)
		{
		$elements = explode('\\', $file);
		$count = count($elements);
		$files[$number] = 'PATH/'.$elements[$count - 2].'/'.$elements[$count - 1];
		}
	var_export($files);
	}

echo '<pre>';
echo 'ONLY INDEX.PHP:'."\n";
showIncludedFiles();
echo "\n\n";

echo 'NEXT TWO FILES:'."\n";
require_once('includes/file.php');
require_once('requires/foo.php');
showIncludedFiles();
echo "\n\n";

echo 'ANOTHER TWO FILES:'."\n";
require_once('includes/other.php');
require_once('requires/bar.php');
showIncludedFiles();
echo "\n\n";

Wynik działania:

ONLY INDEX.PHP:
array (
  0 => 'PATH/get_included_files/index.php',
)

NEXT TWO FILES:
array (
  0 => 'PATH/get_included_files/index.php',
  1 => 'PATH/includes/file.php',
  2 => 'PATH/requires/foo.php',
)

ANOTHER TWO FILES:
array (
  0 => 'PATH/get_included_files/index.php',
  1 => 'PATH/includes/file.php',
  2 => 'PATH/requires/foo.php',
  3 => 'PATH/includes/other.php',
  4 => 'PATH/requires/bar.php',
)

Jak widać, kolejne pliki były dołączane do zawartości zwracanej tablicy automatycznie. Wykorzystajmy zatem poznane informacje, aby umieć zareagować na odpowiednie sytuacje.

Jak sprawdzić, czy plik został już włączony do kodu?

Aby sprawdzić, czy plik był już includowany należy jedynie sprawdzić istnienie odpowiedniej wartości w tej tablicy, np. za pomocą funkcji in_array(). Przykładowy kod, umieszczony bezpośrednio pod pierwszym listingiem:

function checkInclusion($file)
	{
	$includedFiles = get_included_files();
	return
		in_array($file, $includedFiles)
		? 'included'
		: 'not included';
	}

$files = array(
	'index.php',
	'includes\\file.php',
	'includes\\other.php',
	'requires\\foo.php',
	'requires\\bar.php',
	'nonexistent.php',
	'somedir\\inner.php',
	);
foreach($files as $file)
	{
	$path = dirname(__FILE__).'\\'.$file;
	echo 'PATH\\'.$file.': '.checkInclusion($path)."\n";
	}

Daje następujące wyniki:

PATH\index.php: included
PATH\includes\file.php: included
PATH\includes\other.php: included
PATH\requires\foo.php: included
PATH\requires\bar.php: included
PATH\nonexistent.php: not included
PATH\somedir\inner.php: not included

Przejdźmy zatem do omówienia strategii “obrony” przed różnego rodzaju problemami związanymi z włączaniem zawartości plików do kodu.

Metody “obrony” - reakcje na próbę włączenia nowego pliku.

Mając takie narzędzia możemy w łatwy sposób zareagować na próbę włączania nowego pliku w odpowiedni dla naszej sytuacji sposób. Jeśli nie chcemy włączania duplikatu, pomińmy go:

function ignoreDuplicate($file)
	{
	$path = dirname(__FILE__).'\\'.$file;
	$status = checkInclusion($path);
	if('included' == $status)
		{
		echo 'PATH\\'.$file.': duplicate ignored';
		}
	else
		{
		require_once($file);
		echo 'PATH\\'.$file.': included';
		}
	}

echo "\n";
foreach($files as $file)
	{
	echo ignoreDuplicate($file)."\n";
	}

wynik:

PATH\index.php: duplicate ignored
PATH\includes\file.php: duplicate ignored
PATH\includes\other.php: duplicate ignored
PATH\requires\foo.php: duplicate ignored
PATH\requires\bar.php: duplicate ignored
PATH\nonexistent.php: included
PATH\somedir\inner.php: included

lub rzućmy wyjątkiem:

function exceptionDuplicate($file)
	{
	$path = dirname(__FILE__).'\\'.$file;
	$status = checkInclusion($path);
	if('included' == $status)
		{
		throw new Exception('PATH\\'.$file.': exception thrown');
		}
	else
		{
		require_once($file);
		echo 'PATH\\'.$file.': included';
		}
	}

echo "\n";
foreach($files as $file)
	{
	try
		{
		echo exceptionDuplicate($file)."\n";
		}
	catch(Exception $e)
		{
		echo $e->getMessage()."\n";
		}
	}

wynik:

PATH\index.php: exception thrown
PATH\includes\file.php: exception thrown
PATH\includes\other.php: exception thrown
PATH\requires\foo.php: exception thrown
PATH\requires\bar.php: exception thrown
PATH\nonexistent.php: included
PATH\somedir\inner.php: included

Być może zależy nam na zliczaniu ilości włączeń tego samego pliku:

$files = array(
	'index.php' => 0,
	'includes\\file.php' => 0,
	'includes\\other.php' => 0,
	'requires\\foo.php' => 0,
	'requires\\bar.php' => 0,
	'nonexistent.php' => 0,
	'somedir\\inner.php' => 0,
	);

echo "\n";
foreach($files as $file => $number)
	{
	$path = dirname(__FILE__).'\\'.$file;
	if('included' == checkInclusion($path))
		{
		$files[$file]++;
		}
	}
foreach($files as $file => $number)
	{
	$path = dirname(__FILE__).'\\'.$file;
	if('included' == checkInclusion($path))
		{
		$files[$file]++;
		}
	}
foreach($files as $file => $number)
	{
	echo $file.': included '.$number.' times.'."\n";
	}

wynik:

index.php: included 2 times.
includes\file.php: included 2 times.
includes\other.php: included 2 times.
requires\foo.php: included 2 times.
requires\bar.php: included 2 times.
nonexistent.php: included 0 times.
somedir\inner.php: included 0 times.

Do włączania do kodu plików, które zwyczajnie nie muszą być unikalne polecam jednak po prostu usunąć przyrostek “_once” z wykorzystywanej przez nas funkcji. Nie ma co się aż tak męczyć. ;]

Wszystko zależy tylko od naszej wyobraźni, zatem wykorzystajmy ją najlepiej, jak się tylko da. Czy przychodzą Wam na myśl inne metody reakcji na wyżej wymienione sytuacje? Podzielcie się swoimi opiniami w komentarzach.