Jump to content

Příručka:Psaní údržbářských skriptů

From mediawiki.org
This page is a translated version of the page Manual:Writing maintenance scripts and the translation is 65% complete.

Tato příručka krok za krokem popisuje, jak psát údržbářské skripty, které se spouštějí na straně serveru na příkazovém řádku. Jsou založené na třídě Maintenance (viz Maintenance.php ), která je implementovaná od MediaWiki verze 1.16

Standardní text

Projdeme si skript údržby helloWorld.php, který jednoduše vypíše "Hello, World". Tento skript obsahuje minimální množství kódu potřebného ke spuštění a hlavičku s uvedením autorských práv, která by neměla nikdy chybět (viz také hlavičky autorských práv):

Níže uvedený příklad programu vytiskne "Hello, World!":

The way to invoke maintenance scripts changed in 2023 with MediaWiki 1.40, with the new run.php being used to launch all maintenance scripts, rather than directly calling them by filename (although the latter remains supported for now). This tutorial covers both methods, and notes where there are differences between the systems.

Jádro MediaWiki

Command
$ ./maintenance/run HelloWorld
Hello, World!
Filename
maintenance/HelloWorld.php
Code
<?php

require_once __DIR__ . '/Maintenance.php';

/**
 * Brief oneline description of Hello world.
 *
 * @since 1.17
 * @ingroup Maintenance
 */
class HelloWorld extends Maintenance {
	public function execute() {
		$this->output( "Hello, World!\n" );
	}
}

$maintClass = HelloWorld::class;
require_once RUN_MAINTENANCE_IF_MAIN;

Rozšíření MediaWiki

Command
$ ./maintenance/run MyExtension:HelloWorld
Hello, World!
Filename
extensions/MyExtension/maintenance/HelloWorld.php
Code
<?php

namespace MediaWiki\Extension\MyExtension\Maintenance;

use Maintenance;

$IP = getenv( 'MW_INSTALL_PATH' );
if ( $IP === false ) {
	$IP = __DIR__ . '/../../..';
}
require_once "$IP/maintenance/Maintenance.php";

/**
 * Brief oneline description of Hello world.
 */
class HelloWorld extends Maintenance {
	public function __construct() {
		parent::__construct();
		$this->requireExtension( 'Extension' );
	}

	public function execute() {
		$this->output( "Hello, World!\n" );
	}
}

$maintClass = HelloWorld::class;
require_once RUN_MAINTENANCE_IF_MAIN;

Vysvětlení standardního textu

require_once __DIR__ . "/Maintenance.php";

Nejprve vložíme soubor Maintenance.php. V něm je definována třída class Maintenance, základ všech údržbářských skriptů, která analyzuje argumenty příkazového řádku, čte vstupní data z konzole, zajišťuje připojení k databázi, atd.

class HelloWorld extends Maintenance {
}

Pak deklarujeme vlastní podtřídu, jako rozšíření třídy Maintenance.

$maintClass = HelloWorld::class;
require_once RUN_MAINTENANCE_IF_MAIN;

A tento kód říká třídě Maintenance, aby v případě, že je zavolána na příkazovém řádku, spouštěla skript přes naši třídu HelloWorld.

Interně to funguje tak, že RUN_MAINTENANCE_IF_MAIN natáhne soubor doMaintenance.php a ten zajistí automatické načtení tříd MediaWiki, konfigurace, a následné spuštění naší metody execute().

	public function execute() {
	}

Metoda execute() je vstupním bodem údržbářkých skriptů. Do ní umístěte hlavní logika vašeho skriptu a vyhněte se volání jakéhokoli kódu z konstruktoru.

Když je náš program spouštěn z příkazového řádku, základní framework údržby se postará o inicializaci jádra MediaWiki a konfigurace atd., a poté tuto metodu vyvolá.

Příkaz nápovědy

Jednou z vestavěných funkcí, kterou mají všechny skripty údržby, je možnost --help. Výše uvedený vzorový příklad by vytvořil následující stránku nápovědy:

$ php helloWorld.php --help

Usage: php helloWorld.php […]

Generic maintenance parameters:
    --help (-h): Display this help message
    --quiet (-q): Whether to suppress non-error output
    --conf: Location of LocalSettings.php, if not default
    --wiki: For specifying the wiki ID
    --server: The protocol and server name to use in URL
    --profiler: Profiler output format (usually "text")
…

Přidáváme popis

„Na co je ale dobrý tenhle údržbářský skript?” Už slyším, jak se ptáte.

Proto můžeme pomocí metody addDescription do našeho konstruktoru přidat popis, který se zobrazí hned na začátku výstupu při použití parametru "--help":

	public function __construct() {
		parent::__construct();

		$this->addDescription( 'Say hello.' );
	}

Takže teď už bude vracet náš popis:

$ php helloWorld.php --help

Say hello.

Usage: php helloWorld.php [--help]
…

Volby a zpracování jejich hodnot

Pozdravit celičký svět je nepochybně správné a dobré, ale my chceme, aby ten skript uměl pozdravit i konkrétního jednotlivce.

Abyste přidali další volbu příkazového řádku, přidejte do třídy class HelloWorld konstruktor, co zavolá ze třídy Maintenance metodu addOption(), která zaaktualizuje metodu execute(), aby zahrnula i vaši novou volbu. Parametry addOption() jsou $name, $description, $required = false, $withArg = false, $shortName = false, takže:

	public function __construct() {
		parent::__construct();

		$this->addDescription( 'Say hello.' );
		$this->addOption( 'name', 'Who to say Hello to', false, true );
	}

	public function execute() {
		$name = $this->getOption( 'name', 'World' );
		$this->output( "Hello, $name!" );
	}

V tuto chvíli se po spuštění výstup skriptu helloWorld.php bude měnit, podle toho jakou předáte hodnotu argumentu:

$ php helloWorld.php
Hello, World!
$ php helloWorld.php --name=Mark
Hello, Mark!
$ php helloWorld.php --help

Say hello.

Usage: php helloWorld.php […]
…
Script specific parameters:
    --name: Who to say Hello to

Rozšíření

Verze MediaWiki:
1.28
Gerrit change 301709

Pokud váš údržbářský skript vyžaduje funkce, které nabízí nějaké rozšíření, měli byste přidat také požadavek, aby bylo nejprve tohle rozšíření nainstalováno:

	public function __construct() {
		parent::__construct();
		$this->addOption( 'name', 'Who to say Hello to' );

		$this->requireExtension( 'FooBar' );
	}

To poskytuje helfpul chybovou zprávu, když rozšíření není povoleno. Například během místního vývoje nemusí být určité rozšíření ještě povoleno v LocalSettings.php nebo při provozování wiki farmy může být rozšíření povoleno na podmnožině wiki.

Uvědomte si, že žádný kód nelze spustit jinak než metodou execute(). Pokusy o volání základních služeb, tříd nebo funkcí MediaWiki nebo volání vlastního kódu rozšíření před tím způsobí chyby nebo jsou nespolehlivé a nepodporované (např. mimo deklaraci třídy nebo v konstruktoru).

Profiling

Maintenance scripts support a --profiler option, which can be used to track code execution during a page action and report back the percentage of total code execution that was spent in any specific function.

See Manual:Profiling .

Psaní testů

Je doporučeno abyste pro vaše údržbářské skripty psali i testy, tak jako u každé jiné třídy. Nápovědu a ukázkové příklady, jak se to dělá najdete na stránce Jak psát testy pro údržbářské skripty.

Long-Running Scripts

If your script is designed to operate on a large number of things (e.g. all or potentially many pages or revisions), it is recommended to apply the following best practices. Keep in mind that "all revisions" can mean billions of entries and months of runtime on large sites like English Wikipedia.

Batching

When processing a large number of items, it is best to do so in batches of relatively small size - typically between 100 or 1000, depending on the time needed to process each entry.

Batching must be based on a database field (or combination of fields) covered by a unique database index, typically a primary key. Using page_id or rev_id are typical examples.

Batching is achieved by structuring your script into an inner loop and an outer loop: The inner loop processes a batch of IDs, and the outer loop queries the database to get the next batch of IDs. The outer loop needs to keep track of where the last batch ended, and the next batch should start.

For a script that operates on pages, it would look something like this:

$batchStart = 0;

// We assume that processPages() will write to the database, so we use the primary DB.
$dbw = $this->getPrimaryDB();

while ( true ) {
    $pageIds = $dbw->newSelectQueryBuilder()
			->select( [ 'page_id' ] )
			->from( 'page' )
			->where( ... ) // the relevant condition for your use use
			->where( $dbw->expr( 'page_id', '>=', $batchStart ) ) // batch condition
			->oderBy( 'page_id' ) // go over pages in ascending order of page IDs
			->limit( $this->getBatchSize() ) // don't forget setBatchSize() in the constructor
			->caller( __METHOD__ )
			->fetchFieldValues();

    if ( !$pageIds ) {
        // no more pages found, we are done
        break;
    }
    
    // Do something for each page
    foreach ( $pageIds as $id ) {
        $this->updatePage( $dbw, $id ); 
    }
    
    // Now commit any changes to the database.
    // This will automatically call waitForReplication(), to avoid replication lag.
    $this->commitTransaction( $dbw, __METHOD__ );
    
    // The next batch should start at the ID following the last ID in the batch
    $batchStart = end( $pageIds ) +1;
}

Tip: The Maintenance base class provides some utility functions for managing batch size. You can call setBatchSize() in the constructor of your maintenance script class to set the default batch size. This will automatically add a --batch-size command line option, and you can use getBatchSize() to get the batch size to use in your queries.

Recoverability

Long running scripts may be interrupted for a number of reasons - a database server being shut down, the server running the script getting rebooted, exception because of data corruption, programming errors, etc. Because of this, it is important to provide a way to re-start the script's operation somewhere close to where it was interrupted.

Two things are needed for this: outputting the start of each batch, and providing a command line option for starting at a specific position.

Assuming we have defined a command line option called --start-from, we can adjust the code above as follows:

$batchStart = $this->getOption( 'start-from', 0 );

//...

while ( true ) {
    //...

    // Do something for each page
    $this->output( "Processing batch starting at $batchStart...\n" );
    foreach ( $pageIds as $id ) {
        //...
    }
    
    //...
}

$this->output( "Done.\n" );

This way, if the script gets interrupted, we can easily re-start it:

$ maintenance/run myscript
Processing batch starting at 0...
Processing batch starting at 1022...
Processing batch starting at 2706...
Processing batch starting at 3830...
^C

$ maintenance/run myscript --start-from 3830
Processing batch starting at 3830...
Processing batch starting at 5089...
Processing batch starting at 6263...
Done.

Note that this assumes that the script's operation is idempotent - that is, it doesn't matter if a few pages get processed multiple times.

Tip: To make sure we know where the script left off even when the server that is running the script is rebooted, it is recommended to pipe the script's output to a log file. A convenient way to do this is the tee command. Also, to avoid interruption and loss of information when your SSH connection to the server fails, remember to run the script through screen or tmux.

Sharding

If a script performs slow operations for each entries, it can be useful to run multiple instances of the script in parallel, using sharding.

Note: Sharding should not be used for scripts that perform database updates at high speed without significant delay between updates. All updates go to the same primary DB server, and hammering it from multiple instances of the script will not make it go faster. It may even slow down the process, because auf the increased need to manage locks and maintain transaction isolation.

The simplest way to implement sharding is based on the modulo of the ID used for patching: We define a sharding factor (N) and a shard number (S) on the command line, we can define the shard condition as ID mod N = S, with 0 <= S < N. All instances of the script that are to run parallel use the same sharding factor N, and a different shard number S. Each script instance will only process IDs that match its shard condition.

The shard condition could be integrated into the database query, but that may interfere with the efficient use of indexes. Instead, we will implement sharding in code, and just multiply the batch factory accordingly. We can adjust the above code as follows:

$batchStart = $this->getOption( 'start-from', 0 );
$shardingFactor = $this->getOption( 'sharding-factor', 1 );
$shardNumber = $this->getOption( 'shard-number', 0 );

// ...

if ( $shardNumber >= $shardingFactor ) {
    $this->fatalError( "Shard number ($shardNumber) must be less than the sharding factor ($shardingFactor)!\n" );
}

if ( $shardingFactor > 1 ) {
    $this->output( "Starting run for shard $shardNumber/$shardingFactor\n" );
}

while ( true ) {
    $pageIds = $dbw->newSelectQueryBuilder()
            //...
            // multiply the batch size by the sharding factor
			->limit( $this->getBatchSize() * $shardingFactor )
			->caller( __METHOD__ )
			->fetchFieldValues();

    // ...

    // Do something for each page
    foreach ( $pageIds as $id ) {
        // process only the IDs matching the shard condition!
        if ( $id % $shardingFactor !== $shardNumber ) {
            continue;
        }
    
        $this->updatePage( $dbw, $id ); 
    }
    
    // ...
}

We can then start multiple instances of the script, operating on different shards

$ maintenance/run myscript --sharding-factor 3 --shard-number 0
Starting run for shard 0/3
Processing batch starting at 0...
^A1

$ maintenance/run myscript --sharding-factor 3 --shard-number 1
Starting run for shard 1/3
Processing batch starting at 0...
^A2

$ maintenance/run myscript --sharding-factor 3 --shard-number 2
Starting run for shard 2/3
Processing batch starting at 0...