NoVerify: un linter PHP que funciona rápido

Existen buenas utilidades de análisis estático para PHP: PHPStan, Psalm, Phan, Exakat. Linters hacen bien su trabajo, pero muy lentamente, porque casi todos están escritos en PHP (o Java). Para uso personal o un proyecto pequeño, esto es normal, pero para un sitio con millones de usuarios, este es un factor crítico. Un linter lento ralentiza la canalización de CI y hace que sea imposible usarlo como una solución que pueda integrarse en un editor de texto o IDE.



Un sitio con millones de usuarios es VKontakte. Desarrollo y adición de nuevas funciones, pruebas y corrección de errores, revisiones: todo esto debería ir rápido, en condiciones de plazos estrictos. Por lo tanto, un linter bueno y rápido que pueda verificar la base del código para 5 millones de líneas en 5-10 segundos es algo irremplazable. 

No hay linters adecuados en el mercado, por lo que Yuri Nasretdinov (tú Molas) de VKontakte escribió su ayuda a los equipos de desarrollo: NoVerify. Este es un linter para PHP, que está escrito en Go. Funciona entre 10 y 30 veces más rápido que sus contrapartes, puede encontrar algo sobre lo que PhpStorm no advierte, se expande e integra fácilmente en proyectos que nunca antes habían escuchado sobre análisis estático. Iskander Sharipov

contará sobre este linter . Debajo del corte: cómo eligieron el linter y prefirieron escribir el suyo propio, por qué NoVerify es tan rápido y cómo se organiza desde adentro, por qué está escrito en Go, qué puede encontrar y cómo se expande, qué compromisos tuvo que hacer y qué se puede construir sobre su base.


Iskander Sharipov (cuasilyte) trabaja en la infraestructura de back-end de VKontakte y conoce bien NoVerify. En el pasado, participó en el compilador Go en el equipo de Intel. Él no escribe en PHP, pero este es su lenguaje favorito para el análisis estático: tiene muchas cosas que pueden salir mal.

Nota. Para comprender los antecedentes, lea el artículo de Yuri Nasretdinov, el autor de NoVerify en Habré, con antecedentes y comparación con algunas linters existentes, que generalmente están escritas en PHP. Todas las declaraciones en la dirección de PHP (en el artículo de Yuri y aquí) son una broma. Iskander ama PHP, todos aman PHP.

Desarrollo de productos


En VKontakte, este es un desarrollo de sitio web en KPHP. La velocidad es importante para VKontakte: corregir errores, agregar y desarrollar nuevas funciones desde la primera fase hasta la última. Pero la velocidad va acompañada de errores , especialmente cuando hay plazos estrictos: estamos apurados, nerviosos y cometemos más errores que en una situación tranquila.

Los errores afectan a los usuarios . No queremos que sufran, por lo tanto, controlamos la calidad. Pero el control de calidad ralentiza el desarrollo . Esto tampoco lo queremos, por lo que el efecto debe ser minimizado.

Para hacer esto, podríamos realizar más revisiones de código sin falta, contratar más evaluadoresy escribe más pruebas. Pero todo esto está mal automatizado: la revisión debe hacerse y las pruebas deben escribirse.

Las tareas principales de mi equipo son diferentes.

Recopile métricas, analice y repare rápidamente . Si algo salió mal, queremos revertirlo rápidamente, comprender qué está mal, solucionarlo y agregar rápidamente el código de trabajo a la producción.

Monitoree el rigor de la tubería para que el código que no funciona no entre en producción en absoluto; no necesita revertirlo. Aquí los linters vienen al rescate: analizadores de código estático. Hablaremos de esto.

Elige un linter


Elija un linter que agreguemos a la tubería. Tomamos un enfoque simple: formulamos los requisitos.

Linter debería funcionar rápido . Hay varios pasos en nuestra cartera: el funcionamiento del linter no debería llevar mucho tiempo y un desarrollador que consume mucho tiempo, mientras espera comentarios.

Soporte para "sus" cheques . Lo más probable es que el linter no tenga todo lo que necesitamos, tendremos que agregar nuestros propios cheques. Deben encontrar problemas típicos de nuestra base de código, verifique el código desde el punto de vista de nuestro proyecto. No todo esto puede ser (o convenientemente) cubierto por pruebas.

Soporte para cheques "propios". Podemos escribir muchas pruebas, pero ¿serán bien compatibles? Por ejemplo, si escribimos en expresiones regulares, se volverán más complicadas cuando necesite tener en cuenta el contexto, la semántica y la sintaxis del lenguaje. Por lo tanto, las pruebas no son una opción.

La mayoría de las cartas que revisamos están escritas en PHP. Pero no pasan a demanda. Las linters en PHP (todavía no hay una compilación AOT) funcionan entre 10 y 20 veces más lento que en otros idiomas: nuestro archivo más grande se puede analizar durante decenas de segundos. Esto ralentiza el flujo de trabajo demasiado y demasiado; este es un defecto fatal . ¿Qué hacen los desarrolladores en este caso? Ellos escriben los suyos.

Por lo tanto, escribimos nuestro linter NoVerify PHP en Go. ¿Por qué en eso? Spoiler: no solo porque Jura así lo decidió.

Noverificar


Go es un buen compromiso entre la velocidad de desarrollo y la productividad.
La primera "prueba" en la imagen con "infografías": buena velocidad de ejecución, fácil soporte. Perdemos en velocidad de desarrollo, pero los dos primeros puntos son más importantes para nosotros.


Las figuras se toman de la cabeza, no están respaldadas por nada.

Para la segunda "evidencia", argumentó de manera más simple.


PHP es más lento, Go es más rápido, y así sucesivamente. 

Elegimos Go por tres razones.

Ir como idioma para servicios públicos es fácil de aprender a un nivel básico . En el equipo de desarrolladores de PHP, seguro, alguien escuchó sobre Go, miró a Docker, sabe que está escrito en Go, tal vez incluso vio la fuente. Con una comprensión básica, después de una o dos semanas de aprendizaje intensivo de Go, podrán escribir código en él.

Ir es bastante efectivo . Incluso un principiante no podrá cometer muchos errores, porque Go tiene una buena sintonía y muchos linter. En Go, el código promedio es ligeramente mejor que en otros idiomas, porque hay muchas menos formas de dispararle a su propia pierna.

Las aplicaciones Go son fáciles de mantener.Go es un lenguaje de programación bastante maduro para el que están disponibles casi todas las herramientas de desarrollador que puede desear.

Verificaremos NoVerify con nuestros requisitos.

  • NoVerify es varias veces más rápido que las alternativas .

  • Para ello, puede escribir extensiones , tanto de código abierto como propias. Es importante que podamos separar estos cheques, y usted puede escribir los suyos.
  • Fácil de probar y desarrollar. En parte, porque la distribución Go estándar tiene un marco estándar con perfiles y pruebas. Se utiliza principalmente para pruebas unitarias. Particularmente diestros se pueden usar para la integración, como lo hacemos nosotros: tenemos pruebas de integración escritas a través de las pruebas Go.

Compromiso de integración


Comencemos con el problema. Cuando inicie cualquier linter por primera vez en un proyecto antiguo que no utilizó ningún análisis, lo más probable es que lo vea.



¡Oh mi código! Nadie corregirá tantos errores. Quiero cerrar el proyecto, eliminar el linter y nunca volver a ejecutarlo. ¿Qué hacer para evitar esto?

Integrar


Ejecutar en modo diff . No queremos ejecutar todas las verificaciones en todo el proyecto con un millón de errores en cada paso de CI. Quizás conozca la línea de base: en NoVerify, esto está listo para usar, no necesita incrustar una utilidad separada. Inmediatamente consideramos que ese régimen era necesario.

Agregue legado (proveedores) a las excepciones . Es más fácil no tocar algunas cosas, dejar de lado incluso con un defecto, para no modificarlo usted mismo y no dejar una marca en la historia.

Comenzamos con un subconjunto de cheques . No puedes conectar todo lo que incluye estilo. Para empezar, encuentra errores reales: encontraremos, corregiremos y cambiaremos a algo nuevo.

Recopilamos comentarios de colegas. ¿Cómo entender cuándo es hora de encender otra cosa? Pregúntale a tus colegas. Tan pronto como estén contentos de que los errores hayan desaparecido y no se encuentre casi nada, encienda otra cosa: es hora de trabajar.

Configuración de Git


El modo diferencial significa que tiene un sistema de control de versiones: Git. Si tiene SVN, entonces la instrucción no ayudará, vaya a Git.

Instalamos el gancho de pre-empuje con un linter y lo verificamos antes de comenzar el código. Verificamos la máquina local con una opción --no-verifypara evitar el linter . Probablemente sería más conveniente usar un enlace previo a la recepción y deshabilitar la interfaz del lado del servidor, pero por razones históricas, muchas cosas suceden en VK en un enlace previo a la inserción, por lo que NoVerify también se incorporó allí.

Después del empuje, se inician las comprobaciones de CI. NoVerify tiene dos modos de operación: con análisis completo y sin él. En CI, lo más probable es que desee (y pueda) ejecutar--git-full-diff- Las máquinas en CI se pueden cargar más duro y verificar incluso aquellos archivos que no han cambiado. En las máquinas locales, podemos ejecutar un análisis menos estricto, pero más rápido, de solo los archivos modificados (5-15 segundos más rápido). 

Falsos positivos




Considere el siguiente ejemplo: en esta función, se acepta algo que contiene un campo, pero el tipo no se describe de ninguna manera. No es un hecho que haya un campo cuando se llama a una función desde diferentes contextos. En una versión estricta, el linter podría quejarse: "No está claro de qué tipo es, ¿cómo puedo devolver un campo sin controles?" Pero esto no es necesariamente un error.

function get_foo($obj) {
    return $obj->foo;
    ^^^
}

Warning:
Property "foo" does not exist

Los falsos positivos interfieren. 
Esta es la razón principal para abandonar linter. Las personas eligen otras opciones que encuentran menos errores, pero producen menos falsos positivos.

A menudo empujan cuando algo funciona, pero esto no es un error. Muchos tienen un mecanismo para omitir la interfaz: para ejecutarse con la bandera sin verificar la interfaz. En nuestro país, esta bandera se llamaba no-verifygancho previo al empuje. A menudo lo usamos y el nombre se inmortalizó en el nombre de la peluquera.

Quisquilloso


Otra propiedad de linter. Por ejemplo, muchos no entienden los alias. En PHP, sizeofesto es un análogo count: no calcula el tamaño, pero devuelve el número de elementos. El modelo mental de los desarrolladores de C tiene un sizeofsignificado diferente. Si en la base del código hay sizeof, muy probablemente, media count. Pero esto es una trampa.

$len = sizeof($x);
    ^^^^^^

Warning:
use "count" instead of "sizeof"

¿Qué hacer con ello?


Sé estricto y obliga a gobernar todo sin excepción . Imponer reglas, exigir, observar y no permitir que eludan, nunca funciona. Para que dicha rigidez funcione, el equipo debe estar formado por las mismas personas: carácter, nivel de cultura, pedantería y percepción de la calidad del código. Si esto no es así, habrá disturbios. Es más fácil reunir un equipo a partir de tus clones que forzar a seguir todas las reglas. 

No bloquee empuje / comprometerse en los comentarios como arreglar sizeofel count. Lo más probable es que esto no sea un error, sino una trampa y no afecta el código. Pero entonces el 99% de las respuestas serán ignoradas (por el equipo) y siempre habrá otras adicionales en el código sizeof.

Permitir cierto nivel de configuración para diferentes equipos y desarrolladores.Puede configurar la configuración para cada comando de modo que los que no quieren cambiar sizeofa countno puede hacer esto. Que todos los demás sigan las reglas. Una buena opción, pero la consistencia se hundirá, y en algunos directorios el código será un poco peor.

Ejecute dichos controles una vez al mes, en subbotniks . Las comprobaciones se pueden ejecutar no siempre en el CI o en el gancho previo a la inserción, sino en la rutina Cron una vez al mes. Ejecute y edite todo lo que encuentre después del desarrollo activo. Pero este trabajo requiere recursos para la automatización y la verificación.

Hacer nada. Desactivar los controles estilísticos también es una opción.

Compromiso




Siempre habrá un compromiso entre un desarrollador feliz y un linter feliz. Es fácil hacer feliz a un linter: el modo más estricto y la falta de soluciones. Quizás después de eso, nadie permanecerá en el equipo, por lo que si la pelusa interfiere con el trabajo, esto es un problema.
Acción útil sobre todo.

Detalles técnicos de NoVerify


Cheques privados VKontakte. Noverify está escrito algo así. En GitHub, el repositorio NoVerify se divide en dos partes: el marco que se usa para implementar el linter, y las comprobaciones separadas, vklints . Esto se hace para que el linter cargue cheques de terceros: puede escribir un módulo separado en Go y se registran en el marco. Después de comenzar desde el binario NoVerify, el marco carga todos los conjuntos de comprobaciones registrados y funcionan como un todo. 



NoVerify es tanto una biblioteca como un binario (linter).

Nuestros cheques se llaman vklints . Encuentran que no ven PhpStorm y Open Source NoVerify, errores importantes que no son adecuados para uso general.

¿Qué es vklints?

Verificando los detalles del uso de ciertas funciones , clases e incluso variables globales que no siguen nuestras convenciones. Esto es algo que no se puede usar en lugares especiales por varias razones descritas en la guía de estilo.

Controles de estilo adicionales. No corresponden a lo que se acepta en la comunidad PHP, no se describen en la Recomendación estándar de PHP ni siquiera lo contradicen, pero para nosotros es el estándar. No tiene sentido agregarlos a Open Source porque no desea seguirlos.

Requisitos estrictos de comparación para algunos tipos . Por ejemplo, tenemos una verificación que requiere comparar cadenas con un operador de comparación ===. En particular, requiere pasar una bandera para una comparación estricta de funciones con el fin de comparar cadenas.

Claves de matriz sospechosas.Otro error interesante: a veces, cuando los desarrolladores se comprometen, pueden presionar combinaciones de teclas antes de guardar el archivo. Estos caracteres a veces permanecen en una cadena o en un fragmento de código. Una vez, en la clave de la matriz estaba la letra rusa "Y". Lo más probable es que el desarrollador presionó CTRL-S en el diseño ruso, guardó el archivo y lo confirmó. A veces encontramos tales claves en las matrices, pero ya no se pasarán nuevos errores.

Las reglas dinámicas son un mecanismo de extensión NoVerify más simple descrito en PHP. Se ha escrito un artículo separado sobre esto: cómo agregar cheques a NoVerify sin escribir una sola línea de código Go .

Cómo funciona NoVerify


Para analizar PHP necesitas un analizador . No podemos usar el analizador PHP en PHP: es lento, desde Go solo se puede usar a través de un contenedor en C. Por lo tanto, usamos el analizador en Go.

Este analizador tiene varios problemas. Desafortunadamente, solo puede trabajar con UTF-8 y debemos distinguir entre UTF-8 y no UTF-8 . Además de UTF-8, Windows-1251 a menudo se encuentra en proyectos PHP rusos. También tenemos esos archivos. ¿Cómo los reconocemos? 

El archivo encodings.xmlenumera todas las rutas donde están los archivos con UTF-8. Si nos encontramos con un archivo fuera de estas rutas, al instante transmitimos a UTF-8 mediante transmisión de flujo (sin convertir de antemano).


Análisis y análisis


Completado en unos pocos pasos. En el primero, cargamos metadatos de phpstorm-stubs . Estos son datos que se parecen al código PHP, pero nunca se ejecutan y describen los tipos de entradas / salidas de las funciones estándar. Los metadatos phpStorm tienen una directiva de anulación útil para el linter ... Nos permite describir, por ejemplo, que aceptamos una matriz de un tipo T[]y devolvemos un tipo (útil para funciones array_pop).


Phpstorm-stubs se carga primero. Usamos metadatos como la información de tipo inicial: la base. Esta base es absorbida por el linter y comenzamos a analizar las fuentes.

Cargamos el maestro actual antes de la absorción. Verificamos el código en dos modos:

  • cambios locales : con respecto a la línea de base encontramos nuevos errores en el código;
  • indicamos el rango de revisiones : la primera y la última revisión, y entre ellas todo es inclusivo: este es el nuevo código y todo lo que "antes" es antiguo.

Luego viene la etapa de análisis.



Análisis AST . Ahora tenemos metadatos, escriba información. Tomamos toda la fuente PHP, analizamos y analizamos directamente sobre el AST; no tenemos una representación intermedia en este momento. Analizar un AST sin procesar no es muy conveniente, especialmente si depende de las bibliotecas y los tipos de datos que representa. 



Los resultados del análisis se almacenan en la memoria caché . Se utiliza en reanálisis, que es mucho más rápido.

Informes y filtrado . Luego generamos informes o advertencias dos veces : primero encontramos advertencias para la versión anterior del código (antes de la línea de base), luego para la nueva. Los informes se filtran por comparación (diff): buscamos advertencias que aparecieron en la nueva versión del código y se las pasamos al usuario. En algunos analizadores estáticos, esto se llama "modo de línea de base".



El análisis de código doble (en modo diff) es muy lento. Pero podemos pagarlo: NoVerify sigue siendo docenas de veces más rápido que otros enlazadores PHP. Al mismo tiempo, tiene una reserva para una aceleración adicional, al menos en un 30 por ciento.

¿Cómo analizamos los archivos? En PHP, puede llamar a una función antes de definirla; debe conocer la información sobre esta función antes de analizarla. Por lo tanto, primero revisamos todo el archivo en AST, indexamos, identificamos los tipos de todas las funciones, registramos las clases y solo luego lo analizamos. 



El análisis es el segundo paso a través del archivo . La mayoría de los intérpretes y compiladores también trabajan con dos pases y más. Para no "escanear" el archivo por segunda vez, debe tener declaraciones antes de su uso, como en C, por ejemplo.

Inferencia de tipos


La parte más interesante es que los errores se encuentran con mayor frecuencia aquí. Todavía no se corresponde con la corrección del sistema de tipo PHP, que es difícil de definir formalmente.

¿Cómo se ve el modelo?


Modelo semántico (demo).

Tipos de tipos:

  • Lo esperado es lo que describimos en los comentarios. Esperamos algunos tipos en el programa, pero esto no significa que realmente se usen en él.
  • Reales : reales que están en el programa. Por ejemplo, si asignamos un número a algo, entonces es obvio que into float(si este es un número de punto flotante) serán tipos reales. 

Los tipos reales parecen ser "más fuertes": son reales, verdaderos. Pero a veces podemos obtener un tipo solo por anotación.

Las anotaciones (en los tipos esperados) se pueden dividir en dos categorías: confianza y desconfianza . Por ejemplo, phpstorm-stubs pertenecen a la primera categoría. Se consideran moderados (sin errores) antes de usarlos. Los no confiables son aquellos que otros desarrolladores escriben, porque pueden tener errores.

Los tipos reales también se pueden dividir en varias partes: valores, aserción, predicados y sugerencia de tipo, que amplía las capacidades de PHP 7. Pero hay un problema que la sugerencia de tipo no resuelve.

Esperado vs real


Digamos que una clase Footiene un heredero. Desde la clase descendiente, podemos llamar a métodos que no están en Foo, porque el descendiente extiende al padre. Pero si tenemos la heredera Foode new static()esta anotación del tipo de retorno (de self), entonces surgirá un problema. Podemos llamar a este método, pero el IDE no lo solicitará; debe especificarlo static(). Este es un enlace estático tardío en PHP , cuando el Fooheredero de la clase no puede regresar

class Foo {
    /** @return static */
    public function newStatic() : self {
        return new static();
    }
}
// actual = Foo
// expected = static

Cuando escribimos new static(), no solo la clase puede regresar new Foo. Por ejemplo, si una Fooclase se hereda de bar, entonces puede haberla new bar. En consecuencia, necesitamos al menos dos tipos de información. Ninguno de ellos es redundante, ambos son necesarios.

Por lo tanto, el tipo real aquí será self- para el intérprete PHP. Pero para que el IDE y los linters funcionen, necesitamos static. Si llamamos a este código desde el contexto de la clase heredera, necesitamos saber la información de que esta no es la misma clase base y tiene más métodos.

class Foo {
    /** @return static */
    public function newStatic() : self {
        return new static();
    }
}
// actual -  PHP 
// expected -   IDE/

Sugerencia de tipo


La escritura estática y la sugerencia de tipo no son lo mismo.
Es posible que haya escuchado que solo puede verificar los límites de las funciones. En los bordes, verificamos las entradas y salidas, donde la entrada son los argumentos de la función. Dentro de la función, puede hacer cualquier tontería: asignar un foovalor int, aunque describió lo que es T. Puede quejarse de que está violando los tipos que declaró, pero para PHP no hay ningún error

declare(strict_types=1);
    function f(T $foo) {
        $foo = 10; //  int
        return $foo;
}

Un ejemplo es más difícil: ¿volvemos foo? Al comienzo de la función, determinamos que fooera T, y no hay información sobre el retorno. 

declare(strict_types=1);
function f(T $foo) {
    $foo = 10; //  int
    return $foo;
}
// ? 1. f -> int
// ? 2. f -> T|int
// ? 3. f -> T

¿Qué tipo es el correcto? Los dos primeros, analizaremos la diferencia entre ellos. PhpStorm y linter generan la segunda opción. A pesar de que siempre regresa int, el tipo T|intse deduce : la "unión" de tipos. Este es un tipo al que se le pueden asignar ambos valores: primero teníamos información sobre el tipo T, luego lo asignamos 10, por lo que el tipo de la variable foodebe ser compatible con estos dos tipos.

Anotaciones


Los comentarios y las anotaciones pueden mentir.
En el ejemplo a continuación, escribimos que estamos devolviendo un número, pero devolviendo una cadena. Si el linter funcionó solo en el nivel de anotaciones y sugerencia de tipo, entonces consideraríamos que siempre regresa int. Pero los tipos reales solo ayudan a alejarse de esto: aquí, el tipo esperado es este int, y el tipo real es una cadena. Linter sabe que se devuelve la cadena y puede advertirle que prometió volver int. Esta separación es importante para nosotros.

/** @return int */
function f() { return "I lied!"; }

Herencia de anotaciones. Aquí, quiero decir que una clase que implementa algún tipo de interfaz tiene un método. El método tiene buenos comentarios, documentación, tipos: es necesario para implementar la interfaz. Pero no hay comentarios en la implementación: solo hay @inheritdoco nada en absoluto.

interface IFoo {
    /** @return int */
    public function foo();
}
class Fooer implements IFoo {
    /** @inheritdoc */
    public function foo() { return "10"; }
}

¿Qué devuelve este método? Parece que lo que se describe en la interfaz - se devuelve int, pero en realidad es una cadena. Esto no es bueno: PHP es todo lo mismo, pero la convergencia es importante para nosotros.

Hay dos opciones para arreglar este código. Lo obvio es volverint . Pero quizás necesite devolver un tipo diferente. ¿Qué hacer? Escribe que devolvemos la cadena . En este caso, la información de tipo explícito es necesaria tanto para el IDE como para la interfaz para analizar correctamente el código.

interface IFoo {
    /** @return int */
    public function foo();
}
class Fooer implements IFoo {
    /** @return string */
    public function foo() { return "10"; }
}

Esta información no sería necesaria si las personas escribieran comentarios, no @inheritdoc. No es necesario que PhpStorm entienda qué tipos tiene. Pero si los tipos no se describen correctamente, habrá un problema.

En PhpStorm y en la interfaz hay conjuntos de errores disjuntos cuando usamos los mismos archivos para metadatos (tipos). Si arreglamos todo lo que necesitamos en phpstorm-stubs desde el repositorio de JetBrains, entonces el IDE probablemente se romperá. Si deja todo de forma predeterminada, no todo funcionará correctamente

para nosotros. Por lo tanto, tenemos una pequeña bifurcación:  VKCOM / phpstorm-stubs . Se han agregado un par de parches para arreglar algo que no encaja. No puedo recomendarlo para PhpStorm, pero es necesario que el linter funcione.

Fuente abierta


Noverify es un proyecto de código abierto. Está publicado en GitHub .

Breves instrucciones "si algo salió mal".

Si algo está roto o no comienza. La reacción incorrecta es resentir y eliminar NoVerify. La reacción correcta: emitir un ticket en GitHub y hablar sobre su problema. Lo más probable es que se resuelva en 1-2 días.

Te falta alguna característica. Reacción incorrecta: elimine NoVerify y escriba su propio linter (aunque escribir su propio linter siempre es genial). La reacción correcta: emitir un ticket en GitHub y, quizás, agregaremos una nueva función. Es más complicado con las características que con los errores: surge una discusión y cada persona tiene una visión diferente de la implementación en el equipo. Pero al final, todavía se están implementando.

Si está interesado en el desarrollo del proyecto o si solo quiere hablar sobre análisis estático, vaya a nuestra sala de chat: noverify_linter .

PHP-, , , , PHP Russia.

, . , , . telegram- @PHPRussiaConfChannel. , .

Source: https://habr.com/ru/post/undefined/


All Articles