Implementando un sistema de caché en PHP

Hace unos días, os comentaba que es una caché y como entenderla en el marco de la programación web. Para seguir con el aprendizaje de este sistema, he desarrollado una pequeña clase que permite cachear información (strings, arrays, resultados de base de datos, objetos…) en nuestra máquina y descongestionar así el motor de base de datos, por poner un ejemplo.

Lo primero que debemos hacer es pensar en que necesitamos para su correcto funcionamiento, así que, manos a la obra.

Como sabemos, una caché es un sistema al que le pasamos un objeto X y él lo almacenará en su sistema de una manera determinada, por lo tanto, necesitaremos una función que dado un identificador único y unos datos, guardará la información. Además de esto, necesitamos indicarle un tiempo de vida (ttl) a esa información cacheada, sino estaremos devolviendo eternamente el valor almacenado.

Un concepto que nos puede resultar interesante es el de caché de grupo. Imaginemos que tenemos un sistema que va cacheando información de nuestra base de datos; tenemos una tabla user y una tabla user_profile y cacheamos los resultados de ambas tablas por separado. Imaginemos también que por temas de LOPD el usuario solicita que eliminemos todos sus datos de nuestras máquinas, así que eliminaremos el registro de la tabla user y user_profile y procederemos a eliminar el cacheo de los datos de cada tabla por separado. Si tuviesemos agrupada la información de usuario, con una única llamada a la función remove y pasándole el identificador de grupo, eliminará la caché de ambas tablas.

A medida que vaya explicando cada función, os pondré el resultado final para que os hagais una idea de como va quedando el código:

    public function save ( $id, $group_id = 'default', $data, $ttl = self::CACHE_TTL )
    {
        if ( $this->caching )
        {
            $this->set_file_routes( $id, $group_id );

            // If the directory don't exists, I create.
            if ( !is_dir ( $this->file_path ) )
            {
                if ( !mkdir ( $this->file_path, 0777, true ) )
                {
                    return false;
                }
            }

            if ( !is_array ( $data ) && !is_object( $data ) )
            {
                $data = array ( $data );
            }

            // Serialize data for caching.
            $data = serialize ( $data );

            // I get a hash to check integrity in the future.
            $hash = md5 ( $data );

            $meta['expiration_time']    = time () + $ttl;
            $meta['integrity']          = $hash;
            $meta['data']               = $data;

            // Serialize meta info to put in a file.
            $data    = serialize ( $meta );

            return file_put_contents ( $this->file, $data, LOCK_EX );
        }

        return false;
    }

A su vez, debemos poder recuperar los objetos almacenados en un futuro, así que también programaremos una función que dado un identificador y un identificador de grupo, devolverá unos datos get($id, $group_id). El tener que pasar el identificador de grupo en esta función es debido a que las rutas de almacenamiento se generan a partir de ambos identificadores.

    public function get ( $id, $group_id = 'default' )
    {
        if ( $this->caching )
        {
            $this->set_file_routes ( $id, $group_id );

            if ( !file_exists ( $this->file ) )
            {
                return false;
            }

            $meta    = file_get_contents ( $this->file_path . $this->file_name );
            $meta    = unserialize ( $meta );

            $check_expiration   = $this->check_expiration ( $meta['expiration_time'] );
            $check_integrity    = $this->check_integrity ( $meta['integrity'], $meta['data'] );

            // Expiration and integrity control.
            if ( $check_expiration && $check_integrity )
            {
                $data = unserialize ( $meta['data'] );

                return $data;
            }
            else
            // Clean the expired or not correct caché.
            {
                $this->remove ( $id, $group_id );

                return false;
            }
        }

        return false;
    }

Como último elemento fundamental, necesitaremos poder borrar unos datos de la caché en caso de que ya no sean válidos porque se han modificado o borrado, así que crearemos una función que dado un identificador y un identificador de grupo, elimine el objeto cacheado. A esta función la llamaremos remove($id, $group_id).

    public function remove( $id, $group_id = 'default', $group_level = false )
    {
        $this->set_file_routes( $id, $group_id );

        // Don't remove the 'default' group
        if ( $group_level && $group_id != 'default' )
        {
            if ( !$this->group_path || empty ( $this->group_path ) )
            {
                return false;
            }

            return $this->del_tree ( $this->group_path );
        }
        // Check that the file exists and delete the file cached folder
        elseif ( $this->file_path && !empty ( $this->file_path ) )
        {
            return $this->del_tree ( $this->file_path );
        }
        else
        {
            return false;
        }
    }

Como sabemos, la caché guarda los objetos durante un tiempo determinado y una vez ha pasado ese tiempo, el objeto se considera inservible, por lo tanto añadiremos una función check_expiration() que nos dirá si el objeto está caducado.

private function check_expiration ( $expiration_time )
{
return ( time() < $expiration_time );
}

¿Qué pasa si cuando estoy almacenando los datos hay un problema de escritura y los datos se almacenan mal? Podría suceder que los datos almacenados esten corruptos, y todas las devoluciones de dicho objeto fueran defectuosas. Para comprobar que los datos guardados no están corruptos, crearemos una función check_integrity().

    private function check_integrity ( $read_hash, $serialized_data )
    {
        $hash = md5 ( $serialized_data );

        return ( $read_hash == $hash );
    }

Además de estas funciones, crearemos varias constantes que definirán los valores de configuración del sistema como pueden ser el tiempo de vida (ttl) de un objeto cacheado por defecto o el directorio en el que se guardará la caché.

    // Path to cache dir.
    const CACHE_DIR = 'cache/';

    // Max. time to life of this cache.
    const CACHE_TTL = '3600';

    // Extension to use in cache files.
    const CACHE_EXT = '.cache';

En el código se llama a las funciones get_status() y set_status() que sirven comprobar si está activo el sistema de cacheo y habilitar/deshabilitar respectivamente. set_file_routes() crea las rutas en las que se almacenará los datos cacheados y get_key() crea hashes a partir de un identificador. Por último, del_tree() nos ayudará a eliminar un directorio de manera recursiva.

    public function get_status ()
    {
        return $this->caching;
    }

    public function set_status ( $status )
    {
        $this->caching = $status;
    }

    private function set_file_routes ( $id, $group_id )
    {
        $key_name    = $this->get_key ( $id );
        $key_group   = $this->get_key ( $group_id );

        $level_one = $key_group;
        $level_two = substr ( $key_name, 0, 4 );

        $this->group_path   = self::CACHE_DIR . $level_one . '/';
        $this->file_path    = self::CACHE_DIR . $level_one . '/' . $level_two . '/';
        $this->file_name    = $key_name . self::CACHE_EXT;
        $this->file         = $this->file_path . $this->file_name;
    }

    private function get_key ( $id )
    {
        return md5 ( $id );
    }

    private function del_tree ($dir)
    {
        if ( empty ( $dir ) )
        {
            return false;
        }

        if ( !file_exists ( $dir ) )
        {
            return true;
        }

        if ( !is_dir ( $dir ) || is_link ( $dir ) )
        {
            return unlink ( $dir );
        }

        foreach ( scandir ( $dir ) as $item )
        {
            if ( $item == '.' || $item == '..' )
            {
                continue;
            }

            if( is_dir ( $dir . $item ) )
            {
                $this->del_tree ( $dir . $item . '/' );
            }
            else
            {
                unlink ( $dir . $item );
            }
        }

        return rmdir ( $dir );
    }

Puedes descargarte el archivo final desde aquí cache.class.php.

Aunque esta caché es perfectamente funcional, este ejercicio nos sirve para entender un poco más el funcionamiento de un sistema de caché. Si queremos implementar uno en nuestra aplicación, os recomiendo mirar CacheLite (si nuestra aplicación es pequeña), APC o Memcached (si nuestra aplicación es grande y/o con servidores balanceados).

One thought on “Implementando un sistema de caché en PHP

Comments are closed.