¿Cómo implementar una refactorización peligrosa para pinchar con un millón de usuarios?


La película "Avión", 1980.

Así es como me sentí cuando vertí otra refactorización en el producto. Incluso si cubre todo el código con métricas y registros, pruebe la funcionalidad en todos los entornos; esto no ahorrará el 100% de los fakaps después de la implementación.

Primer fakap


De alguna manera reestructuramos nuestro procesamiento de integración con Google Sheets. Para los usuarios, esta es una característica muy valiosa, porque usan muchas herramientas al mismo tiempo que deben vincularse entre sí: enviar contactos a una tabla, cargar respuestas a preguntas, exportar usuarios, etc.

El código de integración no refactorizó desde la primera versión y se hizo cada vez más difícil de mantener. Esto comenzó a afectar a nuestros usuarios: se revelaron errores antiguos que teníamos miedo de editar debido a la complejidad del código. Es hora de hacer algo al respecto. No se suponían cambios lógicos: simplemente escriba pruebas, mueva clases y peine nombres. Por supuesto, probamos la funcionalidad en el entorno de desarrollo y fuimos a implementar.

Después de 20 minutos, los usuarios escribieron que la integración no funcionó. La funcionalidad de enviar datos a Google Sheet se cayó; resultó que para la depuración enviamos datos en diferentes formatos para ventas y entornos locales. Al refactorizar, llegamos al formato de venta.

Arreglamos la integración, pero sin embargo, el sedimento del feliz viernes por la noche (¡y usted pensó!) Permaneció. En retrospectiva (reuniéndonos con el equipo para completar el sprint), comenzamos a pensar en cómo prevenir tales situaciones en el futuro: necesitamos mejorar la práctica de las pruebas manuales, las pruebas automáticas, trabajar con métricas y alarmas, y además de esto, tuvimos la idea de usar banderas de características para probar la refactorización en Prode, de hecho, esto será discutido.

Implementación


El esquema es simple: si el usuario tiene habilitada la bandera, vaya al código con la nueva versión, si no, al código con la versión anterior:

if ($user->hasFeature(UserFeatures::FEATURE_1)) {
  // new version
} else {
  // old version
}

Con este enfoque, tenemos la oportunidad de probar la refactorización en prod primero en nosotros mismos y luego verterla en los usuarios.

Casi desde el comienzo del proyecto, tuvimos una implementación primitiva de la función de banderas. En la base de datos para dos entidades básicas, usuario y cuenta, se agregaron campos de características, que eran una máscara de bits . En el código, registramos nuevas constantes para las funciones, que luego agregamos a la máscara si una función específica está disponible para el usuario.

public const ALLOW_FEATURE_1 = 0b0000001;
public const ALLOW_FEATURE_2 = 0b0000010;
public const ALLOW_FEATURE_3 = 0b0000100;

El uso en el código se veía así:

If ($user->hasFeature(UserFeatures::ALLOW_FEATURE_1)) {
  // feature 1 logic
}

Al refactorizar, generalmente abrimos primero la bandera para que el equipo la pruebe, luego a varios usuarios que usan activamente la función y finalmente se abren a todos, pero a veces aparecen esquemas más complejos, más sobre ellos a continuación.

Refactorización de lugares sobrecargados


Uno de nuestros sistemas acepta webhooks de Facebook y los procesa a través de la cola. El procesamiento de la cola dejó de funcionar y los usuarios comenzaron a recibir ciertos mensajes con retraso, lo que podría afectar de manera crítica la experiencia de los suscriptores de bot. Comenzamos a refactorizar este lugar transfiriendo el procesamiento a un esquema de cola más complejo. El lugar es crítico: es peligroso verter una nueva lógica en todos los servidores, por lo que cerramos la nueva lógica bajo la bandera y pudimos probarla en el producto. ¿Pero qué sucede cuando abrimos esta bandera? ¿Cómo se comportará nuestra infraestructura? Esta vez desplegamos la apertura de la bandera en los servidores y seguimos las métricas.

Todo el procesamiento de datos críticos lo hemos dividido en grupos. Cada grupo tiene una identificación. Decidimos simplificar las pruebas de una refactorización tan compleja abriendo la función de marca solo en ciertos servidores, la verificación en el código se ve así:

If ($user->hasFeature(UserFeatures::CGT_REFACTORING) ||
    \in_array($cluster, Configurator::get('cgt_refactoring_cluster_ids'))) {
  // new version
} else {
  // old version
}

Primero, vertimos refactorización y abrimos las banderas al equipo. Luego encontramos varios usuarios que utilizaron activamente la función cgt, les abrieron banderas y observaron si todo funcionaba para ellos. Y finalmente, comenzaron a abrir banderas en los servidores y seguir las métricas.

El indicador cgt_refactoring_cluster_ids se puede cambiar a través del panel de administración. Inicialmente, asignamos el valor cgt_refactoring_cluster_ids a una matriz vacía, luego agregamos un clúster a la vez - [1], observamos las métricas por un tiempo y agregamos otro clúster - [1, 2] hasta que probamos todo el sistema.

Implementación del configurador


Hablaré un poco sobre qué es Configurator y cómo se implementa. Fue escrito para poder cambiar la lógica sin despliegue, por ejemplo, como en el caso anterior, cuando necesitamos retroceder bruscamente la lógica. También lo usamos para configuraciones dinámicas, por ejemplo, cuando necesita probar diferentes tiempos de almacenamiento en caché, puede sacarlo para realizar pruebas rápidas. Para el desarrollador, esto parece una lista de campos con valores de administrador que se pueden cambiar. Almacenamos todo esto en una base de datos, almacenamos en caché en Redis y en una estadística para nuestros trabajadores.

Refactorizando ubicaciones desactualizadas


En el próximo trimestre, reestructuramos la lógica de registro, preparándolo para la transición a la posibilidad de registro a través de varios servicios. En nuestras condiciones, es imposible agrupar la lógica de registro para que cierto usuario esté vinculado a una lógica determinada, y no se nos ocurrió nada mejor que probar la lógica, desplegando un porcentaje de todas las solicitudes de registro. Esto es fácil de hacer de manera similar con banderas:

If (Configurator::get('auth_refactoring_percentage') > \random_int(0, 99)) {
  // new version
} else {
  // old version
}

En consecuencia, establecemos el valor de auth_refactoring_percentage en el panel de administración de 0 a 100. Por supuesto, "untamos" toda la lógica de autorización con métricas para comprender que al final no redujimos la conversión.

Métrica


Para saber cómo seguimos las métricas en el proceso de abrir banderas, consideraremos otro caso con más detalle. ManyChat acepta enlaces de Facebook de Facebook cuando un suscriptor envía un mensaje a Facebook Messenger. Debemos procesar cada mensaje de acuerdo con la lógica empresarial. Para la función cgt, debemos determinar si el suscriptor inició la conversación a través de un comentario en Facebook para enviarle un mensaje relevante en respuesta. En el código, parece determinar el contexto del suscriptor actual, si podemos determinar el widgetId, entonces determinamos el mensaje de respuesta a partir de él.

Más acerca de la función
Facebook api. — . Widget, :

—> —> —> Facebook:



:
—> —>



“ , !” , . , “ !” id , — , id.

Anteriormente, definimos el contexto de 3 maneras, se parecía a esto:

function getWidgetIdContext(User $user, WatchService $watcher): int?
{
  //      
  if (null !== $user->gt_widget_id_context) {
    $watcher->logTick('cgt_match_processor_matched_via_context');

    return $user->gt_widget_id_context;
  }

  //      
  if (null !== $user->name) {
    $widgetId = $this->cgtMatchByThread($user);
    if (null !== $widgetId) {
      $watcher->logTick('cgt_match_processor_matched_via_thread');

      return $widgetId;
    }

    $widgetId = $this->cgtMatchByConversation($user);
    if (null !== $widgetId) {
      $watcher->logTick('cgt_match_processor_matched_via_conversation');

      return $widgetId;
    }
  }

  return null;
}

El servicio de vigilancia envía análisis en el momento de la coincidencia, respectivamente, teníamos métricas para los tres casos: la


cantidad de veces que el contexto fue encontrado por diferentes métodos de vinculación en el tiempo.

Luego, encontramos otro método de coincidencia que debería reemplazar todas las opciones anteriores. Para probar esto, tenemos otra métrica:

function getWidgetIdContext(User $user, WatchService $watcher): int?
{
  //    
  $widgetId = $this->cgtMatchByEcho($user);
  
  if (null !== $widgetId) {
      $watcher->logTick('cgt_match_processor_matched_via_echo_message');
  }

  //    
  // ...
}

En esta etapa, queremos asegurarnos de que el número de nuevos aciertos sea igual a la suma de los viejos aciertos, así que solo escriba la métrica sin devolver $ widgetId: el


número de contextos encontrados por el nuevo método cubre completamente la suma de enlaces por los métodos antiguos

Pero esto no nos garantiza la lógica de coincidencia correcta en todos los casos. El siguiente paso es la prueba gradual a través de la apertura de banderas:

function getWidgetIdContext(User $user, WatchService $watcher): int?
{
  //    
  $widgetId = $this->cgtMatchByEcho($user);
  
  if (null !== $widgetId) {
    $watcher->logTick('cgt_match_processor_matched_by_echo_message');
  
    //    ,   
    If ($this->allowMatchingByEcho($user)) {
      return $widgetId;
    }
  }

  // ...
}

function allowMatchingByEcho(User $user): bool
{
  //    
  If ($user->hasFeature(UserFeatures::ALLOW_CGT_MATCHING_BY_ECHO)) {
    return true;
  }
  //     
  If (\in_array($this->clusterId, Configurator::get('cgt_matching_by_echo_cluster_ids'))) {
    return true;
  }

  return false;
}

Luego comenzó el proceso de prueba: al principio probamos la nueva funcionalidad por nuestra cuenta en todos los entornos y en usuarios aleatorios que a menudo usan la coincidencia abriendo la bandera UserFeatures :: ALLOW_CGT_MATCHING_BY_ECHO. En esta etapa, detectamos algunos casos en los que el partido funcionó incorrectamente y los reparamos. Luego comenzaron a implementarse en los servidores: en promedio, implementamos un servidor en 1 día durante la semana. Antes de realizar la prueba, advertimos al soporte técnico que analicen detenidamente los tickets relacionados con la funcionalidad y nos escriban sobre cualquier rareza. Gracias al soporte y los usuarios, se corrigieron varios casos de esquina. Y finalmente, el último paso es el descubrimiento de todo incondicionalmente:

function getWidgetIdContext(User $user, WatchService $watcher): int?
{
  $widgetId = $this->cgtMatchByEcho($user);
  
  if (null !== $widgetId) {
    $watcher->logTick('cgt_match_processor_matched_by_echo_message');
  
    return $widgetId;
  }

  return null;
}

Nueva implementación de la función de bandera


La implementación de la función de bandera descrita al comienzo del artículo nos sirvió durante aproximadamente 3 años, pero con el crecimiento de los equipos se volvió incómodo: tuvimos que implementar al crear cada bandera y no olvidar borrar el valor de las banderas (reutilizamos valores constantes para diferentes funciones). Recientemente, el componente ha sido reescrito y ahora podemos administrar de manera flexible las banderas a través del panel de administración. Las banderas se desataron de la máscara de bits y se almacenaron en una tabla separada; esto facilita la creación de nuevas banderas. Cada entrada también tiene una descripción y un propietario, la gestión de la bandera se ha vuelto más transparente.

Contras de tales enfoques


Este enfoque tiene un gran inconveniente: hay dos versiones del código y deben ser compatibles al mismo tiempo. Al realizar la prueba, debe tener en cuenta que hay dos ramas de la lógica, y debe verificarlas todas, y esto es muy doloroso. Durante el desarrollo, hubo situaciones en las que introdujimos una solución en una lógica, pero nos olvidamos de otra, y en algún momento se disparó. Por lo tanto, aplicamos este enfoque solo en lugares críticos y tratamos de deshacernos de la versión anterior del código lo más rápido posible. Intentamos hacer el resto de la refactorización en pequeñas iteraciones.

Total


El proceso actual se ve así: primero cerramos la lógica bajo la condición de las banderas, luego implementamos y comenzamos a abrir gradualmente las banderas. Al expandir las banderas, monitoreamos de cerca los errores y las métricas, tan pronto como algo sale mal, inmediatamente retroceda la bandera y solucione el problema. La ventaja es que abrir / cerrar la bandera es muy rápido, es solo un cambio en el valor en el panel de administración. Después de un tiempo, eliminamos la versión anterior del código, este debería ser el tiempo mínimo para evitar cambios en ambas versiones del código. Es importante advertir a los colegas sobre tal refactorización. Hacemos una revisión a través de github y usamos los propietarios del código durante dicha refactorización para que los cambios no entren en el código sin el conocimiento del autor de la refactorización.

Más recientemente, lancé una nueva versión de Facebook Graph API. En un segundo, hacemos más de 3000 solicitudes a la API y cualquier error es costoso para nosotros. Por lo tanto, implementé el cambio bajo la bandera con un impacto mínimo: resultó detectar un error desagradable, probar la nueva versión y eventualmente cambiarlo por completo sin preocupaciones.

All Articles