Refactorización en profundidad / Sudo Null IT News

imagen

La refactorización es “

Es una técnica controlada para mejorar la estructura del código existente.

” (

Cazador de aves

). Ya se ha escrito mucho sobre olores de código y técnicas de refactorización a microescala (existen, por ejemplo,

libros

y

sitios enteros

). Y quiero observar de cerca la situación y discutir,

¿Cómo exactamente?

y

en que orden

Se deben utilizar estas técnicas. En particular, yo diría que la refactorización se realiza mejor

de adentro hacia afuera

es decir, comenzar desde el límite con la API externa y luego trabajar en el código en profundidad, pasando a clases, métodos, algoritmos, tipos, pruebas o nombres de variables.

Los ejemplos de código de esta publicación están escritos en Rust, pero la técnica de refactorización de adentro hacia afuera también es aplicable en otros lenguajes de programación. Elegí Rust como ejemplo porque la refactorización es más conveniente cuanto más fuerte sea el sistema de tipos.

Ejemplo transversal

Inspirado por el ejemplo

Esteban

que demostró en

taller

“Refactoring Rust” en la conferencia EuroRust 2023, ofreceré un ejemplo de este tipo. Digamos que tenemos una función muy compleja.

parse_magic_key

que recorre en iteración los archivos de un directorio, encuentra todos los archivos en formato JSON, analiza los archivos en busca de claves específicas y devuelve todos los valores coincidentes. Tal vez algún día esta función se escribió para un prototipo, o apareció durante el siguiente hackathon, o tal vez creció por sí sola y llegó a cientos de líneas. A continuación se muestra una versión abreviada de esta función alucinante; podría verse así:

use std::fs;
use std::fs::File;
use std::io::{BufRead, BufReader};

/// Разбирает синтаксис и возвращает поля `magic_key` из всех файлов JSON, расположенных в заданном каталоге.
pub fn parse_magic_key(path: &str) -> Vec<String> {
    let mut results = Vec::new();
    for path in fs::read_dir(path).unwrap() {
        let path = path.unwrap().path();
        if path.extension().unwrap() == "json" {
            // Ниже вашему вниманию предлагаются сотни строк с переусложнённой/уродливой/хрупкой логикой синтаксического разбора, которые здесь упрощены 
            // всего до нескольких строк.
            let file = BufReader::new(File::open(path).unwrap());
            for line in file.lines() {
                let line = line.unwrap();
                let parts: Vec<&str> = line.split(":").collect();
                if parts.len() == 2 && parts.get(0).unwrap().contains("magic_key") {
                    results.push((*parts.get(1).unwrap()).trim().to_string());
                }
            }
        }
    }

    results
}

#(cfg(test))
mod tests {
    use std::fs;
    use tempdir::TempDir;
    use crate::parser::parse_magic_key;

    #(test)
    fn can_parse_dir() {
        let tmp_dir = TempDir::new("").unwrap();
        let dir = tmp_dir.as_ref();
        fs::write(dir.join("positive.json"), r#"
            {
              "magic_key": "1"
            }"#).unwrap();
        fs::write(dir.join("negative.json"), r#"
            {
              "foo": "2"
            }"#).unwrap();
        fs::write(dir.join("negative.not-json"), r#"
            {
              "magic_key": "3"
            }"#).unwrap();

        assert_eq!(parse_magic_key(dir.to_str().unwrap()), vec!(r#""1""#));
    }
}

Por supuesto, este código tiene más desventajas que ventajas. Por ejemplo:

  • Lógica de análisis ingenua/torcida/frágil
  • Sin manejo sensato de errores
  • Es difícil entender cuál es el comportamiento esperado de esta función.
  • Lógica complicada de análisis y recorrido de directorios
  • Supuestos de formato JSON codificados que, por lo tanto, son difíciles de ampliar
  • Esta característica es difícil de desarrollar.
  • API inflexible y poco clara (por ejemplo, ¿qué significa exactamente? path: &str?)
  • Complejidad capacitiva O(|result|)

Digamos que queremos refactorizar este código. Naturalmente, la primera pregunta que surge es:

¿por dónde empezar?

¿Quizás el primer paso sea abordar los detalles más pequeños y obvios, por ejemplo, encontrar nombres de métodos y variables más apropiados, dividir el método en varios más pequeños? Quizás primero corrija la lógica de análisis, por ejemplo, use

serde_json

en lugar de este analizador defectuoso? ¿O, después de todo, comenzar con la API, por ejemplo, tipos o firmas de métodos?

Mirando un poco más de cerca, inmediatamente distinguimos dos caminos que podemos tomar al refactorizar: en el fondo y exterior.

¿Hacia dentro o hacia fuera?

Cuando ocurre la refactorización

en el fondo

comenzamos a trabajar con los detalles de implementación. Estamos corrigiendo la lógica de análisis (y agregando pruebas) y encontrando nombres de variables más apropiados. A continuación, quizás agreguemos soporte para otros formatos además de JSON. Finalmente, cuando estemos satisfechos con la parte interna, arreglaremos la API.

Espera, ¿por qué necesitamos arreglar la API? ¿Y cómo sabemos? Cómoarreglarlo? Bueno, me comprometo a afirmar que cualquier tarea coherente relacionada con la refactorización debería Comience con una hipótesis y establezca objetivos de diseño: refactorizamos porque ____. Por cierto, porque el código base es feoes casi seguro que tal objetivo no reportará ningún beneficio. Más bien, estamos refactorizando la base del código porque su estructura o detalles de implementación ya no son compatibles con los requisitos y los casos en los que debe usarse. Por ejemplo:

  • Necesitamos una API más flexible para reutilizar el módulo en otro contexto o aplicación.
    En el fragmento de código en cuestión: ya que estamos pasando Pathy esta es una ruta de directorio, no podemos simplemente analizar selectivamente archivos JSON; ya sea todo o nada.
  • El código base está diseñado de tal manera que inevitablemente limita el rendimiento y esto ya no es aceptable para nosotros.
    En el fragmento de código en cuestión: desde el resultado completo debe caber en la memoria principal, no podemos analizar grandes cantidades de datos.

A menudo, estos cambios en los requisitos conducen directamente a cambios en la API del módulo o función. Así nació la idea de refactorizar

en el fondo

: primero cambiamos la API, y luego pasamos a detalles cada vez más pequeños hasta que la implementación nos convenga. Sin embargo, esta idea se describió anteriormente en un lenguaje más “corporativo” y en relación con la empresa. fue llamado

patrón estrangulador

.

Refactorización en profundidad

Paso 0: comprender la antigua API y su implementación

A menos que esté limitado por algo y comience el desarrollo desde cero, tendrá que respaldar todo lo proporcionado y, a menudo, incluso comportamiento no deseado API antigua e implementación completa. Entonces – ten en cuenta Valla Chesterton — enumeramos todos aquellos aspectos de la antigua API de los que puedes prescindir. Al identificar qué restricciones o patrones de llamadas ya no se utilizan y, por lo tanto, son innecesarios, a menudo es posible simplificar significativamente los sistemas existentes.

Paso 1: diseñar una nueva API

Cuando hayamos analizado la API anterior y hayamos entendido qué se debe conservar de ella, qué se puede desechar y qué se debe agregar o cambiar, es hora de escribir una nueva API. En nuestro ejemplo, la nueva API podría verse así:

pub fn parse(files: impl IntoIterator<Item=PathBuf>)
    -> impl Iterator<Item=String> {
  todo!()
}

Hemos cambiado el parámetro de entrada: ahora no es una ruta de directorio, sino un iterador que itera sobre los archivos proporcionados como entrada. La persona que llama ahora puede controlar qué archivos se analizan. En segundo lugar, la función devuelve un iterador que itera sobre los resultados. Esto facilita que la persona que llama consuma los resultados en un flujo continuo dentro de la memoria limitada. Por supuesto, una estructura de API de este tipo no se adaptará a todas las situaciones, pero para este ejercicio, supongamos que esto es exactamente lo que necesitamos. (Para no complicar demasiado el ejemplo, decidí no considerar el manejo de errores aquí).

Paso 2: implementar la nueva API usando el código anterior

En la siguiente etapa intentaremos implementar la nueva API utilizando el código antiguo y haciendo la menor cantidad de cambios posible. Esto es importante: en esta etapa no estamos intentando escribir una nueva implementación perfecta, sino que queremos reutilizar la existente y adaptarla a la nueva API. Así es, por ejemplo, cómo se vería dicha adaptación:

/// Разбирает и возвращает поля `magic_key` из всех файлов JSON, расположенных в заданном каталоге.
/// TODO: обработка ошибок
pub fn parse(files: impl IntoIterator<Item=PathBuf>)
    -> impl Iterator<Item=String> {
    // TODO: Разбирать результаты поступательно, а не собирать их все в один вектор 
    let mut results = vec!();
    for path in files {
        if let Ok(file) = File::open(&path) {
            let extension = path.extension().map(|p| p.to_str()).flatten().expect("Failed to determine file extension");
            let file_results = match extension {
                "json" => parse_json(file),
                // TODO: поддержка YAML
                // TODO: поддержка XML
                _ => {
                    println!("Unknown extension: {extension}");
                    vec!()
                }
            };

            results.extend(file_results);
        }
    }

    results.into_iter()
}

fn parse_json(read: impl Read) -> Vec<String> {
    let reader = BufReader::new(read);
    let mut results = Vec::new();
    for line in reader.lines() {
        let line = line.unwrap();
        let parts: Vec<&str> = line.split(":").collect();
        // TODO: использовать потоковый парсер JSON 
        // TODO: добиться, чтобы логика парсинга стала менее хрупкой
        if parts.len() == 2 && parts.get(0).unwrap().contains("magic_key") {
            results.push((*parts.get(1).unwrap()).trim().to_string());
        }
    }

    results
}

/// Разбирает и возвращает поля `magic_key` из всех файлов JSON, расположенных в заданном каталоге.
pub fn parse_magic_key(path: &str) -> Vec<String> {
    let mut results = Vec::new();
    for path in fs::read_dir(path).unwrap() {
        let path = path.unwrap().path();
        if path.extension().unwrap() == "json" {
            let file = File::open(path).unwrap();
            results.append(&mut parse_json(file));
        }
    }

    results
}

Esto es lo que hicimos:

  • Se extrajo el código de análisis en un nuevo método privado. parse_json
  • Reescribió el método original. parse_magic_key usando el método extraído parse_json
  • Implementó un nuevo método de análisis utilizando el método extraído. parse_json
  • Agregamos TODO en todos los lugares donde éramos un poco vagos y simplemente anotamos aquellas cosas que necesitarían ser refactorizadas u optimizadas más adelante.

Nota: API, comportamiento y pruebas del método original.

parse_magic_key

permaneció sin cambios.

Paso 3: escribir pruebas para la nueva API

A continuación, puedes escribir varias pruebas para la nueva API, por ejemplo:

    #(test)
    fn can_parse_json() {
        assert_eq!(parse_json(r#"
            {
              "magic_key": "1"
            }"#.as_bytes()), vec!(r#""1""#));

        assert!(parse_json(r#"
            {
              "foo": "1"
            }"#.as_bytes()).is_empty());
    }

    #(test)
    fn can_parse_files() {
        let tmp_dir = TempDir::new("").unwrap();
        let dir = tmp_dir.as_ref();
        fs::write(dir.join("positive.json"), r#"
            {
              "magic_key": "1"
            }"#).unwrap();
        fs::write(dir.join("negative.json"), r#"
            {
              "foo": "2"
            }"#).unwrap();
        fs::write(dir.join("negative.not-json"), r#"
            {
              "magic_key": "3"
            }"#).unwrap();

        let files = vec!(dir.join("positive.json"), dir.join("negative.json"), dir.join("negative.not-json"));
        let results: Vec<String> = parse(files).collect();
        assert_eq!(results, vec!(r#""1""#));
    }

Paso 4: arreglar las entrañas

Finalmente, podemos corregir todos aquellos TODO que logramos recopilar en el camino. Por ejemplo, garantizar que la lógica de análisis sea correcta y brindar soporte para otros tipos de archivos. Me gustaría señalar por separado que en la implementación principal del paso 2 los mismos requisitos de memoria siguen siendo relevantes que en la implementación original. Sin embargo, el punto clave aquí es que dicha API está diseñada para lograr una eficiencia adicional retrasada.

Tenga en cuenta que los pasos 1-2-3-4 se pueden realizar como parte de confirmaciones independientes o incluso solicitudes de fusión. La mayoría de los TODO enumerados en el paso 4 se pueden realizar en paralelo. Se pueden revisar de forma independiente y, después de cada paso, la construcción se vuelve ecológica. Todos los métodos de análisis llamados anteriormente todavía están vigentes. Este enfoque para cambiar el código se puede llamar “adiabático».

Una vez que haya terminado con la refactorización, puede migrar todos los consumidores de API y, eventualmente, eliminar tanto la API anterior como su implementación.

Pero por qué

hemos considerado

Cómo

Se ha organizado una refactorización profunda, pero aún no se ha discutido.

Para qué

hazlo. Hay razones “internas” y “externas” por las que, en mi experiencia, la refactorización profunda suele ser más eficaz que la refactorización externa.

La naturaleza “interna” de la nueva API que diseñamos en la Etapa 1 nos permite establecer objetivos para una mayor refactorización. Quiero decir, podemos considerar razonablemente que la refactorización se completó cuando encontremos una implementación razonable para nuestra API. Al final, la nueva API debe incluir todas las existentes más nuevos patrones de uso propuestos (por ejemplo, análisis gradual) y restricciones (por ejemplo, límites de memoria) para nuestra biblioteca o aplicación.

Por ejemplo, dado que tenemos la tarea de crearno puede apuntar a una implementación que utilice una cantidad limitada de memoria. Entonces, nuestra implementación nos llevará a una solución de transmisión o analizador LL, que generalmente lee de un extremo a otro. Además, todavía tenemos pruebas (y posiblemente puntos de referencia) para el comportamiento “antiguo”; también se pueden utilizar para evitar casos de regresión.

El aspecto “externo” aquí es aún más interesante. Si escribimos la nueva API primero, (en cierto modo) desacoplamos el trabajo de refactorización del trabajo productivo de aquellos desarrolladores que desean utilizar la nueva API. Una vez que una nueva API esté en funcionamiento, sus compañeros de equipo pueden comenzar a experimentar con ella. Idealmente, progresarán por su cuenta, cada uno trabajando en su propia parte del código base (mientras usan la nueva API). Más a menudo podrán dar su opinión sobre la API, ya que definitivamente algo saldrá mal. Este es el verdadero ágil sin el entrenador ágil.

Lo contrario también es cierto: es casi seguro que habrá encontrado las deficiencias del enfoque descrito. “La nueva API aún está en bruto, porque la refactorización comenzó con los detalles internos del algoritmo y comenzó a reorganizar la base de datos. Te avisaremos cuando esté listo, una semana o dos más (¡Exactamente!)».

Conclusión

Espero haberte convencido de que vale la pena comenzar en profundidad el próximo proyecto de refactorización. Primero escriba la API y luego haga todo lo demás. Como siempre ocurre con la ingeniería de software, este método no es una panacea, pero vale la pena intentarlo.

PD: Tenga en cuenta que estamos teniendo una oferta en nuestro sitio web.

Publicaciones Similares

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *