Drupal 7 et ethereum, un hello world avec web3.js et parity : acheter un token

A supposer que vous soyez déjà enregistré en tant que user drupal reconnu sur la blockchain et que vous ayez déployé un contrat Token ERC20, nous allons voir comment faire pour :

  • afficher votre solde de token
  • acheter un token avec vos ethereums
  • modifier votre solde dès qu’ils change
  • valider la transaction automatiquement (2 méthodes)

Le code complet (et testé) est disponible sur github. Je ne reviens pas sur les principes de base : Drupal.settings, connexion à la blockchain, création de l’instance du contract dans JS. On va se concentrer sur les manipulations que l’on fait sur le contrat, en javascript, via web3.js.

Afficher votre solde en temps réel

A supposer que vous ayez initialisé votre contrat, afficher le solde en token de votre compte se fait avec un simple .call :

token_contract.methods.balanceOf(clientAddress).call().then(function(result){$("#client-token").html(result);});

Mais cet affichage sera fait une seule fois au chargement de la page. Hors, la blockchain peut enregistrer des transactions depuis n’importe quel client. Le solde peut donc changer à tout moment. Comment faire pour que votre soit mise à jour en temps réel le cas échéant ? Si votre smartcontract l’a prévu, vous pouvez utiliser les évènements à cet effet :

event Transfer(address indexed from, address indexed to, uint256 value);

    function _transfer(address _from, address _to, uint _value) internal {
        // Prevent transfer to 0x0 address. Use burn() instead
        require(_to != 0x0);
        // Check if the sender has enough
        require(balanceOf[_from] >= _value);
        // Check for overflows
        require(balanceOf[_to] + _value > balanceOf[_to]);
        // Save this for an assertion in the future
        uint previousBalances = balanceOf[_from] + balanceOf[_to];
        // Subtract from the sender
        balanceOf[_from] -= _value;
        // Add the same to the recipient
        balanceOf[_to] += _value;
        Transfer(_from, _to, _value);
        // Asserts are used to use static analysis to find bugs in your code. They should never fail
        assert(balanceOf[_from] + balanceOf[_to] == previousBalances);
}

Le smartcontract définit un évènement Transfer qui est appelé dans la fonction interne _transfer. Il déclenche un appel qui peut être intercepté coté javascript à l’aide du code suivant :

token_contract.events.Transfer().on('data', function(event){
  token_contract.methods.balanceOf(clientAddress).call().then(function(result){$("#client-token").html(result);});
});

On retrouve le même code d’affichage du solde, mais en tant résultat d’une promise d’un .events.Transfer().on('data', {}) . Sauf que si vous vous arrêtez la, vous allez avoir un petit problème. Le HttpProvider que l’on utilises pour web3 ne supporte pas les évènements. Il faut utiliser le WebsocketProvider :

window.web3 = new Web3(new Web3.providers.WebsocketProvider(fallback));

Et pour que ça fonctionne on doit saisir une url du style : ws://localhost:8546. Le port n’est plus 8545 mais 8546 et le http transformé en ws. A partir de ce moment la, votre appel d’évènement fonctionnera et le solde sera mis à jour en temps réel.

Acheter des tokens

L’achat de Token doit être prévu dans votre smartcontract. C’est une simple fonction qui augmente votre solde à une nuance près, elle est « payable » :

    function buy() payable public {
        uint amount = (msg.value / buyPrice) * unit;      // calculates the amount
        _transfer(this, msg.sender, amount);              // makes the transfers
}

L’appel se fait donc simplement par un .send :

token_contract.methods.buy().send({from:clientAddress, value:web3.utils.toWei(0.001, "ether")})

Notez que dans le cas présent (un token ERC20 donc) le solde n’est pas simplement augmenté, mais un transfert est réalisé depuis le contrat lui même vers le user. C’est la signification du _transfer(this, Ce qui peut sembler étrange au premier abord, mais qui permet en fait de fixer le nombre de Token à la création (selon la même astuce déflationniste utilisée par bitcoin qui consiste à ne créer que 21 millions de bitcoin au maxium). En fonction du prix d’achat du token et du taux de conversion, vos ethers seront convertis en token.

Valider la transaction via parity

Avec le code tel qu’il est présenté précédemment, l’utilisateur devra obligatoirement basculer sur son wallet (Mist, Partiy, Jaxx, MetaMask ou autre) pour valider la transaction en saisissant son mot de passe. Pour un utilisateur expérimenté habitué à ethereum c’est acceptable, mais pour le grand public, il y a un risque que l’utilisateur attende sans comprendre qu’il doit valider sa transaction « de l’autre coté ». Mais nous pouvons l’aider. Il y a un 2ème cas ou la validation de transaction peut-être intéressante : pour les développeurs. C’est en effet pénible de devoir aller valider à chaque fois qu’on teste. Enfin, c’est carrément rédhibitoire pour les tests automatisés. Dans tous ces cas, il peut être utile de savoir comment faire.

La première méthode consiste à appeler parity nous même pour lui dire de valider la dernière transaction de son pipe. Ce n’est pas la méthode la plus sûre dans la mesure ou quelqu’un d’autre pourrait avoir rajouté une transaction dans l’intervalle. Mais ça peut suffire (notamment pour les développeurs).

Nous allons utiliser l’API JsonRPC de parity, qui s’utilise comme un appel AJAX. Cela se fait en 2 étapes, d’abord signer_requestsToConfirm pour récupérer les transactions en attente de signature, puis, nous allons demander à l’utilisateur son mot de passe et l’envoyer à parity avec un signer_confirmRequest.  Ce qui nous donne le code suivant :

        autoSign = function() {
          $.ajax({
            type:"POST",  url: fallback, Accept : "application/json", contentType: "application/json",  dataType: "json",
            data: JSON.stringify({"method":"signer_requestsToConfirm","params":[],"id":1,"jsonrpc":"2.0"}),
            success: function(result) { 
              if (result.result == []) alter('Could not sign');
              if (result.result[0] == undefined) alter('Could not sign');
              id = result.result[0].id;
              pass = $('#eth-password').val();
              $.ajax({
                type:"POST", url: fallback, Accept : "application/json", contentType: "application/json", dataType: "json",
                data: JSON.stringify({"method":"signer_confirmRequest","params":[id, {}, pass],"id":1,"jsonrpc":"2.0"}),
                success: function(result) { alert('transaction validated automatically'); }
              });
            }
          });
}

Mais ce n’est pas une solution entièrement satisfaisante dans la mesure ou : on passe par parity. Que faire si on souhaite utiliser geth ? Et s’il y a d’autres transactions dans le pipe ? Sans parler du fait qu’il est difficile de savoir « quand » faire l’appel vers parity (il faut lui laisser le temps de créer sa transaction). Vous aurez remarqué dans le code que l’appel se fait après un delai :

            setTimeout(function() {autoSign();}, 1000);

Nous allons maintenant voir comment signer la transaction proprement avec eth3. Mais c’est un peu plus compliqué (et surtout non documenté à l’heure ou j’écris).

Valider la transaction avec web3

Sans plus tarder voici le code « propre » d’un appel de transaction signé dans web3 :

        autoSignWeb3 = function (pass, onreceipt) {
          var walletContractAddress = Drupal.settings.blockchain.token_deployed_contract_address_fallback;
          var privateKey = new buffer.Buffer(pass, 'hex');
          var fromAccount = clientAddress;
          var signature = _.find(JSON.parse(Drupal.settings.blockchain.token_deployed_contract_ABI), { name: 'buy' });
          var payloadData = web3.eth.abi.encodeFunctionCall(signature, []);
          gasPrice = web3.eth.gasPrice;
          gasPriceHex = web3.utils.toHex(gasPrice);
          gasLimitHex = web3.utils.toHex(300000);
          web3.eth.getTransactionCount(fromAccount).then((nonce) => {
            nonceHex = web3.utils.toHex(nonce);
            var rawTx = {
              nonce: nonceHex,
              gasPrice: gasPriceHex,
              gasLimit: gasLimitHex,
              to: walletContractAddress,
              from: fromAccount,
              value: web3.utils.toHex(web3.utils.toWei(0.001, "ether")),
              data: payloadData
              };
            var tx = new ethereumjs.Tx(rawTx);
            tx.sign(privateKey);
            var serializedTx = tx.serialize();
            web3.eth.sendSignedTransaction('0x' + serializedTx.toString('hex')).then( () =>{ onreceipt(); return null; } );
            return null;
          });
}

Si vous regardez la documentation de web3, vous trouverez facilement cette fonction web3.eth.sendSignedTransaction qui permet d’envoyer des transactions signées. Mais le problème c’est qu’on doit envoyer une chaine représentant la transaction. Il faut donc construire cette chaine. On est loin du .methods.xxx.send() plutôt naturel. Construire une sorte de code hexadécimal pour faire appel … ce n’est pas si simple, surtout que les exemples sur le net concernent des versions différentes ou plus anciennes de web3.js.

Pour y parvenir je me suis inspiré du code trouvé ici. Sauf que sendRawTransaction est remplacé par sendSignedTransaction, mais surtout :

var solidityFunction = new SolidityFunction('', _.find(ABI, { name: 'sendCoin' }), '');
var payloadData = solidityFunction.toPayload([toAccount, 3]).data;

est remplacé par

 var payloadData = web3.eth.abi.encodeFunctionCall(signature, [toAccount, 1]);

C’est cette partie qui fût la plus compliquée à trouver dans le dédalle du net. On doit faire appel à 3 libraires js externes :

  • ehtereumjs-tx qui sert à sérialiser la transaction.
  • buffer qui sert à envoyer la clé à la transaction
  • lodash qui sert à récupérer la signature de la méthode qu’on veut appeler dans l’ABI

De plus, l’utilisateur ne doit pas saisir le mot de passe de son wallet parity mais sa clé privée. On pourrait tout à fait crypter sa clé à l’aide d’un mot de passe et ne lui demander que ce mot de passe (au final, c’est ce que fait parity), mais ça dépasse le cadre de cet article.

Drupal 7 et ethereum, un hello world avec web3.js et parity : s’enregistrer sur la blockchain

Cet article suppose que vous connaissez déjà la blockchain (BC). C’est une sorte de base de donnée (coûteuse) infalsifiable, avec, dans le cas d’ethereum (ETH) la possibilité d’exécuter des bouts de code certifiés (dans un langage de programmation proche du javascript : solidity). Si ces notions ne vous sont pas familières, passez votre chemin.

La BC est une technologie en pleine évolution (et en plein boom). Il y aurait beaucoup à dire, mais dans cet article, on va se limiter à ropsten, parity, web3.js 1.0 et … drupal (version 7 – oui c’est un peu vieillot) et comment coder un « hello world » en Drupal pour se connecter à la BC.

Nous allons réaliser 2 choses :

  • connexion d’un user drupal avec un compte de la BC
  • achat d’un token ERC20, avec validation automatique de la transaction (ce qui permet de se passer d’un wallet)

Dans ce premier article, on va déjà s’intéresser au premier cas.

Environnement de développement

A supposer que vous ayez un site Drupal quelconque. Par exemple, un que vous avez installé à l’aide d’ansible. Il va vous falloir en plus le module user_hash, et un champ « ethereum_address » de type string en plus sur votre profil utilisateur. Pour faire fonctionner la BC, pour les développeurs, il est conseillé d’utiliser parity (1.8.3 à l’heure ou j’écris). Une fois installé, vous lancez parity comme ceci :
parity ui --chain=dev --unsafe-expose --jsonrpc-apis=all
Cette ligne de commande permet d’avoir un parity « de dev » qui tourne sans aucune restriction. Quand on démarre et qu’on est dans une VM, j’estime que ce n’est pas encore l’heure de ses pré-occuper de la sécurité. Chaque chose en son temps. La, ce qu’on veut c’est voir la bête tourner.

Si vous souhaitez accéder à l’interface en ligne de parity (et je vous le conseille) : http://192.168.50.5:8180/#/accounts/

192.168.50.5 est l’IP fixe privée que j’ai donné à la VM vagrant (sinon, ça sera probablement sur localhost) :
config.vm.network "private_network", ip: "192.168.50.5
Vous devez obtenir quelque chose comme ceci (ce n’est pas un tuto parity, je n’irais donc pas plus loin à ce sujet) :

A partir de la, il vous faudra créer un compte pour chaque user drupal que vous souhaitez connecter à la BC.

Cette instance de parity fonctionne avec une blockchain de dev, locale, légère, rapide, mais une fois que votre application fonctionnera, il vous faudra basculer sur une version publique de test d’ethereum: par exemple ropsten. L’avantage de parity c’est qu’il le permet très simplement, et qu’il fait aussi office de wallet (porte-feuille ethereum) et aussi il dispose d’outils pour les développeurs (notamment pour les contrats). Notez (mais ce n’est pas le sujet de cet article) que Mist (un autre wallet ethereum) dispose d’une debugger de smart-contract assez bluffant (si jamais vous avez besoin d’en arriver la). Mais en dehors de ce cas, parity fait tout à fait l’affaire.

Préparation des données

[Attention, ce n’est pas un tuto drupal, donc je passe rapidement sur les étapes principales du code Drupal]

On peut communiquer avec le BC de différentes manières possibles, mais le plus simple et le plus répandu c’est d’utiliser la librairie web3.js en javascript, pour se connecter au noeud parity. On pourrait se connecter au noeud en PHP (à l’aide d’ethereum-php mais ce n’est pas une librairie officielle et le coté asynchrone des appels à la BC sont plus facile à gérer en JS).

Récupérez une web3.min.js. Qu’on va ensuite injecter avec un :

drupal_add_js(drupal_get_path('module', 'hellothereum') . '/js/web3.min.js', array('scope' => 'footer'));

De l’autre coté, il va nous falloir un champ pour accueillir l’adresse ethereum du user (à la rigueur on pourrait aussi se contenter de la rechercher le hash du user dans la blockchain pour récupérer son adresse. Il faudrait réfléchir aux conséquences en terme de performance et de sécurité des 2 choix, mais ça dépasse le cadre de ce simple article qui vise à découvrir les fonctions de base).

Cette partie se fait avec un field_create_field et un field_create_instance dans le hook_enable :

  if (!field_info_field('field_ethaddress')) {
    $field = array(
        'field_name' => 'field_ethaddress', 
        'type' => 'text', 
    );
    field_create_field($field);

    // Create the instance on the bundle.
    $instance = array(
        'field_name' => 'field_ethaddress', 
        'entity_type' => 'user', 
        'label' => 'Ethereum address', 
        'bundle' => 'user', 
        'settings' => array(
           // Here you inform either or not you want this field showing up on the registration form.
            'user_register_form' => 1,
        ),
        'widget' => array(
            'type' => 'textfield',
            'weight' => '1',
        ), 
    );
    field_create_instance($instance);
}

Avec le module user_hash, et le champ ethaddress, on est équipé pour aller taquiner la blockchain. Il ne nous reste plus qu’à envoyer les informations au javascript qui va passer par Web3 pour appeler les smartcontract. Les adresses des smartcontract et leur ABI sont stockés dans la table variable et envoyées au JS :

    drupal_add_js(array(
      'ethereum_user' => array(
        'contract' => array(
          'address' => variable_get('ethereum_user_register_drupal_deployed_contract_address'),
          'abi' => variable_get('ethereum_user_register_drupal_deployed_contract_abi'),
        ),
        'fallback_node' => variable_get('ethereum_user_register_drupal_fallback_node'),
        'token' => variable_get('ethereum_user_registry_list_token'),
        'user' => array(
          'hash' => $this_user->hash,
          'address' => $this_user_ethereum_address,
        ),
      ),
    ), 'setting');

Appel des smart-contracts en JS

On récupère d’abord toutes les infos nécessaires envoyées par Drupal via Drupal.settings :

        window.web3 = new Web3(new Web3.providers.HttpProvider(Drupal.settings.ethereum_user.fallback_node));
        var user_address = Drupal.settings.ethereum_user.user.address.toLowerCase();
        var contract_abi = JSON.parse(Drupal.settings.ethereum_user.contract.abi);
        var contract_address = Drupal.settings.ethereum_user.contract.address;
        var contract = new web3.eth.Contract(contract_abi, contract_address);
        var user_hash = Drupal.settings.ethereum_user.user.hash;

A partir de la, on va pouvoir enfin faire notre premier appel de smart-contract. Attention, c’est du code asynchrone qui utilises des « promises » ou des « callbacks » (au choix). On n’écrit donc pas du code séquentiel (comme c’était le cas avec les premières versions de web3.js). Dans les versions précédentes de web3.js on faisait un appel de méthode classique. Cette fois il faut rajouter .methods avant l’appel et .call après. Ce qui donne :

contract.methods.validateUserByHash(user_hash).call({from: user_address}, function (error, result) {

Voyons le code du contrat correspondant :

  function validateUserByHash (bytes32 drupalUserHash) constant returns (address result){
      return _accounts[drupalUserHash];
}

Il est très simple. Le contrat dispose d’une table de correspondance, comme un tableau associatif en php, et il se contente de renvoyer une entrée à l’indice indiqué, si elle existe.

Comme vous le voyez, le contrat ne définit qu’un seul paramètre qu’on retrouve dans le .validateUserByHash(user_hash), le reste de l’appel, contient les paramètres « génériques » d’un appel de contrat : .call({from: user_address}, function (error, result) { qui est le user qui fait l’appel de contrat, et quelle est la callback à déclencher en retour. Dans le cas des transactions payantes on pourrait aussi ficher un prix.

Le traitement du résultat de l’appel se fait donc dans la fonction de callback. S’il n’y a pas eu d’erreur, c’est la qu’on peut vérifier si l’utilisateur est déjà enregistré et, si c’est pas le cas, lui proposer une transaction d’enregistrement :

contract.methods.newUser(user_hash).send({from: user_address})

Cette fois-ci c’est un .send à la place du .call, j’y reviens un peu plus loin. Voici le code solidity correspondant :

  function newUser(bytes32 drupalUserHash) public {

    if (_accounts[drupalUserHash] == msg.sender) {
      // Hash allready registered to address.
      accountCreated(msg.sender, drupalUserHash, 4);
    }
    else if (_accounts[drupalUserHash] > 0) {
      // Hash allready registered to different address.
      accountCreated(msg.sender, drupalUserHash, 3);
    }
     else if (drupalUserHash.length > 32) {
      // Hash too long
      accountCreated(msg.sender, drupalUserHash, 2);
    }
    else if (_registrationDisabled){
      // Registry is disabled because a newer version is available
      accountCreated(msg.sender, drupalUserHash, 1);
    }
    else {
      _accounts[drupalUserHash] = msg.sender;
      accountCreated(msg.sender, drupalUserHash, 0);
    }
}

Le code est très simple en soi si on enlève les vérifications qui sécurisent le contrat : _accounts[drupalUserHash] = msg.sender;. La dernière ligne est un évènement, ce sera traité dans les prochains articles.

Cette fois, par contre, on va utiliser les « promises » pour réagir à la transaction plutôt qu’une callback. Dans ce cas précis les promisses permettent d’avoir une action à différents moments. Notez une différence majeure par rapport au .call précédent : elle doit être validée par l’utilisateur, dans son wallet, puis être ensuite « minée », c’est à dire confirmée dans la blockchain. Typiquement, on est dans de l’asynchrone la. Notre javascript, ni notre site, ne s’arrêtent de vivre parce que la blockchain fait son boulot de son coté.

              .on('transactionHash', function (transactionHash) {
                $('#ethereum_user_registry_sign').html('<p>Please wait between 10 seconds and a few minutes for the transaction to be mined on the Ethereum network. You can reload this page at any time to see if the transaction is confirmed. Or you can <a href="https://etherscan.io/tx/' + transactionHash + '" target="_blank">see the transaction status in live</a>.</p>');
              })
              .on('error', function (error) {
                // 0 is "success error" in RegisterDrupal.sol
                if (error != 0) {
                  console.error;
                }
              })
              .on('confirmation', function (confirmationNumber, receipt) {
                // Should be 0 because there's no block after this transaction yet.
                console.log('Number of confirmation blocks: ' + confirmationNumber);
               })

On peut réagir à plusieurs évènements. TransactionHash : c’est quand la transaction est crée, c’est à dire, quand l’utilisateur valide la transaction dans son wallet. Sinon, on déboule sur une erreur. Si la transaction est validée, en principe, elle va finir en confirmation, sauf s’il y a une error déclenchée dans le smart-contract. Si votre smartcontract est bien fait, il va vous remonter une erreur explicite. Sinon … c’est la qu’on commence à pleurer ! Mais il y a une solution.

Si jamais vous être coincés et que ne comprenez pas pourquoi votre smartcontract ne renvoies pas le résultat attendu, vous pouvez lancer Mist qui contient un debugger de smart-contract, tout à fait bluffant. Ce n’est pas le sujet de cet article, je ne m’étendrais pas, mais sachez simplement qu’il permet de dérouler votre code comme film, y compris de faire du rewind et qu’il est vraiment très bien fait. C’est le même debugger que « Remix IDE« , mais qui tourne sur votre BC locale.

Voila, vous avez les bases. Il nous restera à voir dans les prochains articles : les transactions payantes, les évènements, l’auto-signature, pour faire un premier tout d’horizon plus complet des possibilités principales de la blockchain.

Drupal 8 et React : block dynamique ou headless

2 tuto en 1, nous allons voir comment coupler Drupal avec react.js en headless et … avec Drupal lui même (pour rajouter un bloc react temps-réel).

D’abord nous activons les modules RESTful Web Services et Serialization.

Ensuite nous allons créer une vue sur les derniers commentaires postés avec un display REST export (j’ai mis plain text dans le format de display des champs par soucis de simplicité) :

Nous allons maintenant nous appuyer dessus pour faire des appels du webservice JSON via React. Notez pour plus tard le chemin d’appel du webservice : api/v1/comments.

Drupal Headless avec React

En mode headless, Drupal n’est pas utilisé pour le front-office, seulement pour le back-office. Nous avons besoin de charger les bibliothèques react (on peut le faire en ligne) dans une premier temps et de faire l’appel au webservice dans une second temps :

<!DOCTYPE html>
<html>
<head>
 <meta charset="utf-8" />
 <title>App</title>
 <link rel="stylesheet" href="https://netdna.bootstrapcdn.com/bootstrap/3.3.4/css/bootstrap.min.css">
 <link rel="stylesheet" type="text/css" href="stylesheets/style.css">
</head>

<body>

<div id="container">test</div>

<script src="https://npmcdn.com/react@15.3.1/dist/react.js"> </script>
<script src="https://npmcdn.com/react-dom@15.3.1/dist/react-dom.js"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-core/5.8.24/browser.min.js"></script>
<script type="text/babel" src="js/app.jsx"></script>

</body>
</html>

react.js et react-dom.js sont les libraries react à proprement parler. Mais en plus nous incluons axios qui permet de faire des appels de webservices en JSON. Quand à babel-core c’est le compiler qui transforme le JSX en javascript. Le JSX permet (entre autres) d’avoir des tags HTML directement dans le code JavaScript, ils sont convertis en React.createElement() directement.

Le fichier app.jsx :

class App extends React.Component {

constructor() {
 super();
 // Setting up initial state
 this.state = { 
   data: []
 }
}

// calling the componentDidMount() method after a component is rendered for the first time

componentDidMount() {
 var th = this;
 this.serverRequest = axios.get(this.props.source)
 .then(function(event) {
   th.setState({
     data: event.data
   });
 })
}

// calling the componentWillUnMount() method immediately before a component is unmounted from the DOM

componentWillUnmount() {
 this.serverRequest.abort();
}

render() {
 var titles = []
 this.state.data.forEach(item => {
   titles.push(<h3 className="events">{item.subject}</h3> );
 })
 return (
   <div className="container">
     <div className="row">
       <div className="col-md-6 col-md-offset-5">
         <h1 className="title">All Comments</h1>
         {titles}
       </div>
     </div>
   </div>
 );
 }
}

// rendering into the DOM
ReactDOM.render(
 <App source="http://test.box.local/drupal/web/api/v1/comments" />,
 document.getElementById('container')
);

Ce n’est pas un tuto react, je ne m’étendrais donc pas trop sur le sujet. Si vous voulez en savoir plus allez ici ou ici. La base de react c’est essentiellement son DOM virtuel et son moteur de rendu optimisé qui détecte les différences : ReactDOM.render et React.CreateElement (ici masqué dans du JSX), ainsi que la possibilité de créer ses propres « tags HTML » grâce au système de classe. C’est pour cela que <App .../> est compris par JSX comme un React.CreateElement('App', ...); est compris comme l’instanciation de la classe App (classe qui doit contenir une methode render et hériter de React.Component pour être reconnue par React).

C’est très basique comme application, mais ça fonctionne. Mais si l’on ne souhaite pas faire tout un front en react il est possible de l’utiliser pour améliorer l’ergonomie de Drupal en rajoutant React sur certains blocs pour les rendre « temps réel ». C’est ce que nous allons voir dans la 2ème partie de ce tutoriel.

Drupal real-time avec React

Tiré d’un tuto portugais dont l’objectif est de réaliser l’équivalent de la version en drupal 7.

Avec la console nous créons un module react_comment et un bloc ReactComments :

drupal generate:module --module="react_comment" --machine-name="react_comment" --module-path="/modules/custom" --description="React real time comments" --core="8.x" --package="Custom" --composer --learning --uri="http://default" --no-interaction

drupal generate:plugin:block --module="react_comment" --class="ReactComments" --label="React comments" --plugin-id="react_comments" --learning --uri="http://default" --no-interaction

Voici le fichier react_comment.libraries.yml qui permet d’inclure le javascript :

recent.comments:
 version: VERSION
 js:
   js/react-comments.js: {}
 dependencies: 
   - react_comment/reactjs

reactjs:
 version: VERSION
 js:
   js/react.min.js: {}

Note: il faut inclure une version de react.min.js dans le répertoire js en le téléchargeant à la main (moi je l’ai pris ici).

Voici le fichier src/Plugin/Block/ReactComments.php qui sert à inclure l’application react et à créer un div par défaut dans un block que l’application react pourra modifier à son gré :

<?php

namespace Drupal\react_comment\Plugin\Block, il n'y a que 2 lignes qui changent :

use Drupal\Core\Block\BlockBase;

/**
  * Provides a 'ReactComments' block.
  *
  * @Block(
  * id = "react_comments",
  * admin_label = @Translation("React comments"),
  * )
  */
class ReactComments extends BlockBase {
 
  /** 
    * {@inheritdoc}
    */
  public function build() {
    $build = [];
    $build['react_comments']['#markup'] = '<div id="recent-comments"></div>';
    $build['#attached']['library'][] = 'react_comment/recent.comments';
    return $build;
  }

}

Enfin la pièce maîtresse : le fichier js/react-comments.js :

/**
 * @file
 * Main JS file for react functionality.
 *
 */
 
(function ($) {
 
  Drupal.behaviors.react_blocks = {
    attach: function (context) {
 
      // A div with some text in it
      var CommentBox = React.createClass({displayName: 'CommentBox',
 
      loadCommentsFromServer: function() {
        $.ajax({
          url: this.props.url,
          dataType: 'json',
          success: function(data) {
            this.setState({data: data});
          }.bind(this),
          error: function(xhr, status, err) {
            console.error(this.props.url, status, err.toString());
          }.bind(this)
        });
      },
 
      getInitialState: function() {
        return {data: []};
      },
 
      componentDidMount: function() {
        this.loadCommentsFromServer();
        setInterval(this.loadCommentsFromServer, this.props.pollInterval);
      },
 
      render: function() {
          return (
            React.createElement("div", {className: "commentBox"},
              React.createElement("h3", null, React.createElement("b", null, "Check them out!")),
              React.createElement(CommentList, {data: this.state.data})
            )
          );
        }
      });
 
      var CommentList = React.createClass({displayName: 'CommentList',
        render: function() {
          var commentNodes = this.props.data.map(function (comment) {
            return (
              React.createElement(Comment, {name: comment.name, subject: comment.subject},
                comment.subject
              )
            );
          });
          return (
            React.createElement("div", {className: "commentList"},
              commentNodes
            )
          );
        }
      });
 
      var Comment = React.createClass({displayName: 'Comment',
        render: function() {
          return (
            React.createElement("div", {className: "comment"},
              React.createElement("h2", {className: "commentAuthor"},
                this.props.name
              ),
              this.props.subject
            )
          );
        }
      });
 
      // Render our reactComponent
      React.render(
        React.createElement(CommentBox, {url: "api/v1/comments", pollInterval: 2000}),
        document.getElementById('recent-comments')
      );
 
    }
  }
 
})(jQuery);

Ce fichier est écrit en JS et non en JSX, il est donc un peu plus lourd à lire.

Et voila le résultat :

Tester l’envoi d’emails avec behat pour Drupal

Behat ne permet pas à priori de tester l’envoi d’email, ni de vérifier leur contenu. Mais, il est possible de rajouter cette fonctionnalité.

1ère étape on va rajouter un système de collecte des emails dans une variable qu’on pourra ensuite interroger. Il y a peu de nettoyage à faire car seul le title, send-to, send-from, et body nous intéressent vraiment :

<?php 

/** 
 * A mail sending implementation that captures sent messages to a variable. 
 * 
 * This class is for running tests or for development. Inspired from TestingMailSystem
 */ 
class EMHMailSystem extends DefaultMailSystem implements MailSystemInterface { 

  /** 
   * Save an e-mail message to a file, using Drupal variables and default settings. 
   * 
   * @see http://php.net/manual/en/function.mail.php * @see drupal_mail() 
   * 
   * @param $message * A message array, as described in hook_mail_alter(). 
   * @return 
   * TRUE if the mail was successfully accepted, otherwise FALSE. 
   */ 
  public function mail(array $message) { 
    $output = $this->composeMessage($message);
    unset($message['params']['context']['state']);
    unset($message['params']['context']['action']);
    $captured_emails = variable_get('drupal_test_email_collector', array());
    $captured_emails[] = $message;
    variable_set('drupal_test_email_collector', $captured_emails);
    return TRUE;
  }

}

2ème étape on rajoute le support pour behat. Attention, la recherche de pattern dans les emails (afin qu’on ne remonte pas une chaine trouvée dans un email qui n’a rien à voir) se fait via l’utilisation d’un email actif qui permet de chercher dans un email qu’on identifié au préalable, et aussi par la recherche dans le dernier email envoyé. Enfin il est possible de vérifier qu’un email n’existe pas.

  /**
   * @Given /^the test email system is enabled$/
   */
  public function theTestEmailSystemIsEnabled() {
    // Store the original system to restore after the scenario.
    $this->originalMailSystem = variable_get('mail_system', array('default-system' => 'DefaultMailSystem'));
    // Set the test system.
    variable_set('mail_system', array('default-system' => 'EMHMailSystem'));
    // Flush the email buffer, allowing us to reuse this step definition to
    // clear existing mail.
    variable_set('drupal_test_email_collector', array());
    // Delete queue from other test, can be overloaded if All Experts used.
    db_query("DELETE FROM queue WHERE name='emh_request_request_email_notification'");
    db_query('TRUNCATE TABLE {mail_logger}');
  }

  /**
   * @Then /^the email to "([^"]*)" should contain "([^"]*)"$/
   */
  public function theEmailToShouldContain($to, $contents) {
    // We cannot use variable_get() because $conf is only fetched once per
    // scenario.
    $variables = array_map('unserialize', db_query("SELECT name, value FROM {variable} WHERE name = 'drupal_test_email_collector'")->fetchAllKeyed());
    $this->activeEmail = FALSE;
    foreach ($variables['drupal_test_email_collector'] as $message) {
      if ($message['to'] == $to) {
        $this->activeEmail = $message;
        if (strpos($message['body'], $contents) !== FALSE ||
          strpos($message['subject'], $contents) !== FALSE) {
          return TRUE;
        }
        throw new \Exception('Did not find expected content in message body or subject.');
      }
    }
    throw new \Exception(sprintf('Did not find expected message to %s', $to));
  }

  /**
   * @Then /^the last email to "([^"]*)" should contain "([^"]*)"$/
   */
  public function theLastEmailToShouldContain($to, $contents) {
    $variables = array_map('unserialize', db_query("SELECT name, value FROM {variable} WHERE name = 'drupal_test_email_collector'")->fetchAllKeyed());
    $this->activeEmail = FALSE;
    foreach (array_reverse($variables['drupal_test_email_collector']) as $message) {
      if ($message['to'] == $to) {
        $this->activeEmail = $message;
        if (strpos($message['body'], $contents) !== FALSE ||
          strpos($message['subject'], $contents) !== FALSE) {
          return TRUE;
        }
        throw new \Exception('Did not find expected content in message body or subject.');
      }
    }
    throw new \Exception(sprintf('Did not find expected message to %s', $to));
  }

  /**
   * @Then /^the last email to "([^"]*)" should not contain "([^"]*)"$/
   */
  public function theLastEmailToShouldNotContain($to, $contents) {
    $variables = array_map('unserialize', db_query("SELECT name, value FROM {variable} WHERE name = 'drupal_test_email_collector'")->fetchAllKeyed());
    $this->activeEmail = FALSE;
    foreach (array_reverse($variables['drupal_test_email_collector']) as $message) {
      if ($message['to'] == $to) {
        $this->activeEmail = $message;
        if (strpos($message['body'], $contents) == FALSE ||
          strpos($message['subject'], $contents) == FALSE) {
          return TRUE;
        }
        throw new \Exception('Found expected content in message body or subject.');
      }
    }
    // Dont care if not found any email at all.
  }

  /**
   * @Then /^there should be no email to "([^"]*)" containing "([^"]*)"$/
   */
  public function thereIsNoEmailToContaining($to, $contents) {
    $recipient = FALSE;
    $not_contains = FALSE;
    $variables = array_map('unserialize', db_query("SELECT name, value FROM {variable} WHERE name = 'drupal_test_email_collector'")->fetchAllKeyed());
    foreach ($variables['drupal_test_email_collector'] as $message) {
      if ($message['to'] == $to) {
        $recipient = TRUE;
        if (strpos($message['body'], $contents) == FALSE && strpos($message['subject'], $contents) == FALSE) {
          $not_contains = TRUE;
        }
      }
    }
    if (($recipient == TRUE && $not_contains == TRUE) || $recipient == FALSE) {
      return TRUE;
    }
    else {
      throw new \Exception('Found email and expected content in message body or subject.');
    }
  }

  /**
   * @Given /^the email should contain "([^"]*)"$/
   */
  public function theEmailShouldContain($contents) {
    if (!$this->activeEmail) {
      throw new \Exception('No active email');
    }
    $message = $this->activeEmail;
    if (strpos($message['body'], $contents) !== FALSE ||
      strpos($message['subject'], $contents) !== FALSE) {
      return TRUE;
    }
    throw new \Exception('Did not find expected content in message body or subject.');
  }

On peut enfin tester nos emails:

@api @watchdog
Feature: Contact
  In order to test the contact mail
  As an user
  I want to send a mail to contact 

  @email @nodelay
  Scenario: Test if the contact mail was sent
    Given the test email system is enabled
    When I visit '/contact'
      And I fill in "Bruce" for "firstname"
      And I fill in "Wayne" for "lastname"
      And I fill in "emh.test+batman@gmail.com" for "mail"
      And I fill in "Gotham City" for "message"
      And I press "Send"
    Then I should see the text "Your message has been sent."

    Then the last email to "contact@emh.com" should contain "(emh.test+batman@gmail.com)"
      And the email should contain "From - Bruce Wayne"

Surveiller les performances d’un serveur Drupal avec le stack TICK d’influxDB et grafana

Il existe toute une palette d’outils de monitoring de serveurs, mais dans le cas présent, c’est du monitoring de site, ce qui est un peu moins commun. Il est possible de s’acquitter de cette tâche avec cacti, munin, nagios, zabbix (qui permet de configurer des scénarios de scan) mais j’ai choisis un outil dédié à cette tâche : le stack TICK (telegraf, influxdb, chronograf, kapacitor), quoi que dans la pratique, c’est surtout telegraf, influxdb et grafana que j’utilises.

Une autre possibilité aurait été le stack ELK (Elastick Search, Logstash, Kibana) qui font presque la même chose à une différence près : alors que ELK est orienté « log », c’est une base de donnée non structurée, c’est à dire analyse de fichier de log (desquels on peut remonter les informations de performance sans problème), TICK est une base de donnée temporelle, qui intègre directement les mécanismes de gestion des séries temporelles (notamment le stockage des données lissées sur une longue durée) ainsi que les outils de visualisation qui vont bien.

Installation de la partie serveur

InfluxDB

Le serveur influxdb

InfluxDB c’est la base de donnée qui stocke toutes les métriques récoltées, c’est un peu le centre névralgique du dispositif.

C’est la base de donnée centrale qui stocke les données temporelles. Voici comment l’installer.

INFLUXDB=1.2.2
URN="localhost"
RED='\033[0;31m'
NC='\033[0m' # No Color
wget --quiet https://dl.influxdata.com/influxdb/releases/influxdb_${INFLUXDB}_amd64.deb
sudo dpkg -i influxdb_${INFLUXDB}_amd64.deb
cp influxdb.conf /etc/influxdb/influxdb.conf
sudo service influxdb restart
echo -e "${RED}influxdb aviable at http://${URN}:8083${NC}"

Configuration

Voici un fichier de configuration. Dans le fichier par défaut de nombreuses options sont commentées. Cet exemple est minimaliste. Il s’agit de mettre en place un serveur qui supporte les fonctionnalités de base pour un serveur en prod, c’est à dire, enregistrer des données temps réel précises et garder une archive de ces données lissées (afin de ne pas surcharger la base trop rapidement).

### Welcome to the InfluxDB configuration file.

# we'll try to get the hostname automatically, but if it the os returns something
# that isn't resolvable by other servers in the cluster, use this option to
# manually set the hostname
# hostname = "localhost"

###
### [meta]
###
### Controls the parameters for the Raft consensus group that stores metadata
### about the InfluxDB cluster.
###

[meta]
  # Where the metadata/raft database is stored
  dir = "/var/lib/influxdb/meta"

###
### [data]
###
### Controls where the actual shard data for InfluxDB lives and how it is
### flushed from the WAL. "dir" may need to be changed to a suitable place
### for your system, but the WAL settings are an advanced configuration. The
### defaults should work for most systems.
###

[data]
  # The directory where the TSM storage engine stores TSM files.
  dir = "/var/lib/influxdb/data"

  # The directory where the TSM storage engine stores WAL files.
  wal-dir = "/var/lib/influxdb/wal"

###
### [retention]
###
### Controls the enforcement of retention policies for evicting old data.
###

[retention]
  # Determines whether retention policy enforcment enabled.
  enabled = true

  # The interval of time when retention policy enforcement checks run.
  check-interval = "24h"

###
### [admin]
###
### Controls the availability of the built-in, web-based admin interface. If HTTPS is
### enabled for the admin interface, HTTPS must also be enabled on the [http] service.
###
### NOTE: This interface is deprecated as of 1.1.0 and will be removed in a future release.

[admin]
  # Determines whether the admin service is enabled.
  enabled = true

  # The default bind address used by the admin service.
  bind-address = ":8083"

###
### [http]
###
### Controls how the HTTP endpoints are configured. These are the primary
### mechanism for getting data into and out of InfluxDB.
###

[http]
  # Determines whether HTTP endpoint is enabled.
  enabled = true

  # The bind address used by the HTTP service.
  bind-address = ":8086"


###
### [continuous_queries]
###
### Controls how continuous queries are run within InfluxDB.
###

[continuous_queries]
  # Determiens whether the continuous query service is enabled.
  enabled = true

  # Controls whether queries are logged when executed by the CQ service.
  log-enabled = true

  # interval for how often continuous queries will be checked if they need to run
  run-interval = "1h"

Création de la configuration de base: retention policy et continuous query.

Dans le cas présent, par défaut nous stockons les données « temps réels haute précision » pendant une journée et les données lissées sont archivées pour une durée indéfinie. Le downsampling (granularité d’une minute) se fait avec une requête « continue » (qui s’exécute régulièrement en tâche de fond) qui agit sur toutes les données de la base MONITOR. C’est l’une des fonctionnalités les plus intéressantes des TSDB !

    echo 'CREATE RETENTION POLICY retention_test ON MONITOR DURATION 1d REPLICATION 1 DEFAULT' | influx
    echo 'CREATE RETENTION POLICY retention_infinite ON MONITOR DURATION 0d REPLICATION 1' | influx
    echo 'CREATE CONTINUOUS QUERY "cq_downsampling_1Min" ON "MONITOR" BEGIN  SELECT mean(*) INTO "MONITOR"."retention_infinite".:MEASUREMENT FROM /.*/ GROUP BY time(1min),* END' | influx

A partir de la, on a accès à une interface minimaliste pour interroger la base (qui est censée disparaître à terme au profit de Chronograf, cf plus bas)

Installation de Grafana

Grafana permet de visualiser les données avec des graphiques et de construire des requêtes très simplement:

echo "deb https://packagecloud.io/grafana/stable/debian/ jessie main" | sudo tee /etc/apt/sources.list.d/grafana.list
curl --silent https://packagecloud.io/gpg.key | sudo apt-key add -
sudo apt-get -qq update && sudo apt-get install grafana
sudo systemctl enable grafana-server.service
sudo /bin/systemctl restart grafana-server
echo -e "${RED}grafana aviable at http://${URN}:3000${NC}"

Installation de Chronograf

Chronograf est un grafana « simplifié » livré avec influxdb. Il proposes un dashboard par défaut et permet de parcourir l’ensemble des données remontées facilement.

CHRONOGRAF=1.2.0~beta7
wget --quiet https://dl.influxdata.com/chronograf/releases/chronograf_${CHRONOGRAF}_amd64.deb
sudo dpkg -i chronograf_${CHRONOGRAF}_amd64.deb
sudo systemctl enable chronograf.service
sudo service chronograf restart
echo -e "${RED}chronograf aviable at http://${URN}:8888${NC}"

Installation et configuration de la partie client

Dans le cas présent, le serveur sert à stocker les données, le client, c’est la machine (ou la VM) qu’on surveille. Principalement, il s’agit d’une sonde (telegraf) minimaliste en terme d’utilisation de ressources (alors qu’influxdb est plus gourmand par définition).

Installation

TELEGRAF=1.2.1
wget https://dl.influxdata.com/telegraf/releases/telegraf_${TELEGRAF}_amd64.deb
sudo dpkg -i telegraf_${TELEGRAF}_amd64.deb
service telegraf restart

Configuration de base

# Telegraf configuration

[tags]

[agent]
  interval = "10s"
  round_interval = true
  flush_interval = "10s"
  flush_jitter = "0s"
  debug = false
  hostname = "Drupal"


###############################################################################
#                                  OUTPUTS                                    #
###############################################################################

[[outputs.influxdb]]
  urls = ["http://localhost:8086"] # required
  database = "MONITOR" # required
  precision = "s"

Configuration pour recupérer les performances système

###############################################################################
#                            INPUT PLUGINS                                    #
###############################################################################

# Read metrics about cpu usage
[[inputs.cpu]]
  ## Whether to report per-cpu stats or not
  percpu = true
  ## Whether to report total system cpu stats or not
  totalcpu = true
  ## If true, collect raw CPU time metrics.
  collect_cpu_time = false


# Read metrics about disk usage by mount point
[[inputs.disk]]
  ## By default, telegraf gather stats for all mountpoints.
  ## Setting mountpoints will restrict the stats to the specified mountpoints.
  # mount_points = ["/"]

  ## Ignore some mountpoints by filesystem type. For example (dev)tmpfs (usually
  ## present on /run, /var/run, /dev/shm or /dev).
  ignore_fs = ["tmpfs", "devtmpfs"]


# Read metrics about disk IO by device
[[inputs.diskio]]
  ## By default, telegraf will gather stats for all devices including
  ## disk partitions.
  ## Setting devices will restrict the stats to the specified devices.
  # devices = ["sda", "sdb"]
  ## Uncomment the following line if you need disk serial numbers.
  # skip_serial_number = false


# Get kernel statistics from /proc/stat
[[inputs.kernel]]
  # no configuration


# Read metrics about memory usage
[[inputs.mem]]
  # no configuration


# Get the number of processes and group them by status
[[inputs.processes]]
  # no configuration


# Read metrics about swap memory usage
[[inputs.swap]]
  # no configuration


# Read metrics about system load & uptime
[[inputs.system]]
  # no configuration

Configuration pour surveiller LAMP

###############################################################################
#                              SERVICE INPUTS                                 #
###############################################################################

[[inputs.apache]]
  urls = ["http://client.local/server-status?auto"]

[[inputs.logparser]]
  files = ["/var/log/apache2/access.log", "/var/log/apache2/error.log"]
  from_beginning = false
  name_override = "apache_log"
  [inputs.logparser.grok]
    patterns = ["%{COMBINED_LOG_FORMAT}"]
    #measurement = "apache_access_log"
    #custom_pattern_files = []
    #custom_patterns = '''
    #'''

[[inputs.logparser]]
  files = ["/var/log/mysql/error.log"]
  from_beginning = false
  name_override = "mysql_log"
  [inputs.logparser.grok]
    patterns = ["%{COMBINED_LOG_FORMAT}"]

[[inputs.logparser]]
  files = ["/var/log/syslog"]
  from_beginning = false
  name_override = "syslog"
  [inputs.logparser.grok]
    patterns = ["%{SYSLOG}"]
    custom_patterns = '''
      SYSLOG %{SYSLOGTIMESTAMP:syslog_timestamp} %{SYSLOGHOST:syslog_hostname} %{DATA:syslog_program}(?:\[%{POSINT:syslog_pid}\])?: %{GREEDYDATA:syslog_message}
    '''
[[inputs.ping]]
  urls = ["localhost"] # required
  count = 1
  ping_interval = 1.0
  timeout = 0.0

[[inputs.memcached]]
  servers = ["localhost:11211"]
  # unix_sockets = ["/var/run/memcached.sock"]

[[inputs.mysql]]
  servers = ["root:root@tcp(127.0.0.1:3306)/"]
  perf_events_statements_digest_text_limit  = 120
  perf_events_statements_limit              = 250
  perf_events_statements_time_limit         = 86400
  table_schema_databases                    = []
  gather_table_schema                       = true
  gather_process_list                       = true
  gather_info_schema_auto_inc               = false
  gather_slave_status                       = false
  gather_binary_logs                        = false
  gather_table_io_waits                     = false
  gather_table_lock_waits                   = false
  gather_index_io_waits                     = false
  gather_event_waits                        = false
  gather_file_events_stats                  = false
  gather_perf_events_statements             = false
  interval_slow                             = "30m"

[[inputs.net]]
  # interfaces = ["eth0"]

[[inputs.netstat]]

#[[inputs.procstat]]
#  pattern = "mysqld"
#  fielddrop = ["cpu_time_*"]
#[[inputs.procstat]]
#  pattern = "apache2"
#  fielddrop = ["cpu_time_*"]
#[[inputs.procstat]]
#  pattern = "memcached"
#  fielddrop = ["cpu_time_*"]
#[[inputs.procstat]]
#  pattern = "telegraf"
#  fielddrop = ["cpu_time_*"]

Congifuration pour surveiller Drupal

###############################################################################
#                            SERVICE INPUT PLUGINS                            #
###############################################################################

# # Statsd Server
[[inputs.statsd]]
  ## Address and port to host UDP listener on
  service_address = ":8125"
  ## Delete gauges every interval (default=false)
  delete_gauges = false
  ## Delete counters every interval (default=false)
  delete_counters = false
  ## Delete sets every interval (default=false)
  delete_sets = false
  ## Delete timings & histograms every interval (default=true)
  delete_timings = true
  ## Percentiles to calculate for timing & histogram stats
  percentiles = [90]

[[inputs.logparser]]
  files = ["/var/log/syslog"]
  from_beginning = false
  name_override = "drupal_log"
  [inputs.logparser.grok]
    patterns = ["%{DRUPAL_LOG}"]
    custom_patterns = '''
      DRUPAL_LOG %{SYSLOGTIMESTAMP:syslog_timestamp} %{SYSLOGHOST:syslog_hostname} %{DATA:syslog_program}(?:\[%{POSINT:syslog_pid}\])?: %{URI:drupal_base_url}\|%{NUMBER:drupal_log_unixtimestamp}\|%{DATA:drupal_log_type}\|%{IPORHOST:drupal_client_ip}\|%{URI:drupal_request_uri}\|%{URI:drupal_referer_uri}?\|%{NUMBER:drupal_userid}\|%{DATA:drupal_page_link}?\|%{GREEDYDATA:drupal_log_msg}
    '''

Il nous reste à configurer le module statsd de Drupal (qui envoies un rapport de performance à chaque page appelée)

 

Performance monitoring avec ELK / Grafana – test de base

Vous avez un site en production, avec le temps, il accumule de plus en plus de données, et le nombre de visiteurs augmente. Généralement les sysadmins ont tout un ensemble d’outils de surveillance et d’alertes mais peu de prophylactique (préventif).

Afin d’éviter que les performances ne s’effondrent ou ne s’érodent, il peu être intéressant sur certains projets d’anticiper en mettant en place des métriques. Généralement on va utiliser un outil de test de charge qui permet de déterminer combien d’utilisateurs simultanés un site peut supporter. Mais les tests de charges sont ponctuels et se font hors des serveurs de production.

Une autre manière de procéder (complémentaire) c’est de surveiller les performances du site (en plus de surveiller l’utilisation des ressources serveurs, bien évidement). Tout l’intérêt est de faire ça :

Dans cet article, nous allons voir comment tracer un graphe de performance sur un simple temps de réponse à la demande de chargement d’une page web. Nous verrons aussi comment aller plus loin dans les sondes à mettre en place pour surveiller plus finement les performances d’un site afin de repérer le plus tôt possible les goulots d’étranglements et de voir l’impact des différentes évolutions du site.

Installer le stack ELK

Sur une debian 8.

ELK est composé des outils suivants : Elasticsearch, logstash, et Kibana. Elasticsearch est un moteur de recherche basé sur Lucene, sans schéma, qu’il faut voir ici comme un outil d’indexation avant tout. Logstash lui est un « collecteur » de données, principalement depuis des logs (mais il permet d’aller chercher des indicateurs temps réels aussi). Kibana permet de requêter et visualiser ces données.

Attention beaucoup de tutos sur le net décrivent comment installer la version 1.3 ou 2.x. J’utilise la version 5 ici.

Préliminaire, installer java :

sudo apt-get install openjdk-8-jre

https://www.elastic.co/guide/en/elastic-stack/current/installing-elastic-stack.html

wget -qO - https://artifacts.elastic.co/GPG-KEY-elasticsearch | sudo apt-key add -
sudo apt-get install apt-transport-https
echo "deb https://artifacts.elastic.co/packages/5.x/apt stable main" | sudo tee -a /etc/apt/sources.list.d/elastic-5.x.list
sudo apt-get update && sudo apt-get install elasticsearch
sudo /bin/systemctl daemon-reload
sudo /bin/systemctl enable elasticsearch.service
sudo systemctl start elasticsearch.service
#sudo systemctl stop elasticsearch.service
sudo apt-get install logstash
sudo apt-get install kibana

Nous avons elasticsearch sur le port 9200 (mais il ne réponds pas en HTTP) et Kibana sur le port 5601 (qu’on peut visiter comme un site web).

Vérification de l’installation :

curl -XGET 'localhost:9200/?pretty'

Astuce pour vider totalement Elastic (pratique en cas de fausse manip) :

curl -XDELETE localhost:9200/*

Configurer logstash

A partir de la, il faut configurer logstash pour remonter les données qui nous intéressent dans elasticsearch, à savoir, les temps de réponses de notre site. Je prends emh.fr comme exemple de site (ce n’est pas un vrai site).

etc/logstash/conf.d/perfmon.conf :

input { 
    # Web Application Response Time
    exec {
        type => "ws-ping"
        add_field => [ "service" , "emh" ]
        add_field => [ "received_at", "%{@timestamp}" ]

        command => "/usr/bin/time -f '%e' curl -sk -o /dev/null http://www.emh.fr 2>&1"
        interval => 60
    }
}
 
filter {  
    if [type] == "ws-ping" {
        grok {
            match => { "message" => "%{NUMBER:responsetime:float}" }
        }
    }
}
 
 
output {
    elasticsearch { 
       hosts => ["localhost"] 
       index => "logstash-perf-emh"
    }
}

Kibana

Installer Grafana

Kibana est certes très puissant. Pourquoi Grafana ? Kibana serait finalement suffisant mais souffre d’un gros défaut (en version gratuite) : c’est une page non protégée. Grafana, qui est très puissant et versatile dispose d’une page de login. En plus Grafana peut se brancher sur plusieurs sources, dispose de plugins très facile à installer.

http://docs.grafana.org/installation/debian/

echo "deb https://packagecloud.io/grafana/stable/debian/ jessie main" | sudo tee -a /etc/apt/sources.list.d/grafana.list
curl https://packagecloud.io/gpg.key | sudo apt-key add -
sudo apt-get update && sudo apt-get install grafana
systemctl daemon-reload
systemctl start grafana-server
systemctl status grafana-server
sudo systemctl enable grafana-server.service

Configurer Grafana

Par défaut on travaille sur le port 3000 : http://localhost:3000   admin/admin

Ajouter une datasource, ici j’ai mis « logstash-* » car je souhaite pouvoir grapher plusieurs index, mais j’aurais pu mettre « logstash-perf-emh » :

Pour créer une visualisation, il faut créer un dashboard et lui rajouter une ligne (row). Ensuite on rajoute une query sur une datasource et on défini des métriques à afficher.

D’une manière générale, la création de graphiques sous Grafana est plus intuitive que sous Kibana. La possibilité d’intervenir en aval sur les valeurs (Script : _value*1000) peut-être utile.

Tests de scalabilité avec Locust et Taurus

jMeter est lourd à mettre en œuvre et complexe. A la place j’ai trouvé des petits outils plus simples, mais très puissants et scriptables en ligne de commande.

Locust.io qui permet de faire des tests de performances et Taurus qui permet d’aller plus loin avec des tests de scalabilité.

Installation de Locust

apt-get install python-pip python-dev && pip install locustio

Création du fichier locustfile.py qui indique les scénarios à tester. Ici on teste la HP, puis le login, puis l’accès à la page /node/add/request qui est un formulaire important sur ce site :

from locust import HttpLocust, TaskSet

def login(l):
    l.client.post("/user", {"name":"admin", "pass":"admin", "form_id":"user_login"})

def index(l):
    l.client.get("/")

def create_request(l):
    l.client.get("/node/add/request")

class UserBehavior(TaskSet):
    tasks = {index:2, create_request:1}

    def on_start(self):
        login(self)

class WebsiteUser(HttpLocust):
    task_set = UserBehavior
    min_wait = 5000
    max_wait = 9000

Ensuite on lance le serveur :

locust --host=http://10.0.2.2:8088/emh/www/

Les résultats donnent quelque chose comme ceci :

Screenshot of Locust web UI

On peut grâce au lien « Edit » en dessous de status faire facilement varier le nombre de clients et le nombre de répétitions des tests pour simuler une montée en charge. Il suffit ensuite de surveiller l’apparition des #fails pour déterminer le seuil de scalabilité.

Installation de taurus

Taurus est un complément à Locust.io : il permet de planifier l’exécution d’une montée en charge de manière très précise de manière à surveiller exactement ou se situent les seuils qui font « exploser » votre site. En complément on pourra installer un système de monitoring du serveur comme le stack TICK par exemple qui permettra d’avoir des métriques précises sur le maillon faible du serveur.
Quelques pré-requis

pip install --upgrade pip && pip install --upgrade requests && apt-get install python-dev libxml2-dev libxslt1-dev zlib1g-dev && pip install bzt

Création d’un fichier de test: emh_test.yml

---
execution:
- executor: locust
  concurrency: 10
  ramp-up: 1m
  hold-for: 3m
  iterations: 1000
  scenario: example

scenarios:
  example:
    default-address: http://emh.box.local
    script: locustfile.py

reporting:
- final_stats
- console

Pour obtenir les résultats il faut lancer le serveur :

bzt emh_test.yml

Et maintenant, dernière petite amélioration. On va lancer plusieurs scénarios en différé en rajoutant dans le fichier de configuration :

- executor: locust
  concurrency: 10
  ramp-up: 1m
  hold-for: 2m
  iterations: 1000
  scenario: example
  delay: 1m
- executor: locust
  concurrency: 10
  ramp-up: 1m
  hold-for: 1m
  iterations: 1000
  scenario: example
  delay: 2m

behat & drupal 8 : exemple complet et simple

Addendum : behat sera entièrement installé dans drupal 8.3 (5 avril 2017)

Une fois drupal et le composer installés correctement, dans /var/www/d8 avec un compte admin/admin

Le tuto officiel n’est pas vraiment complet/utilisatble tel quel (le behat.yml par défaut n’est pas complet).

Pour installer, j’utilises le composer installé globalement :

composer require drupal/drupal-extension='~3.0'

Ensuite behat est rajouté automatiquement dans « vendor » ou il y avait déjà un répertoire behat, mais pas complet.

vendor/behat/behat/bin/behat --init

Maintenant on créer le behat.yml minimaliste pour pouvoir lancer les tests :

default:
  suites:
    default:
      contexts:
        - Drupal\DrupalExtension\Context\DrupalContext
        - Drupal\DrupalExtension\Context\MinkContext
  extensions:
    Behat\MinkExtension:
      goutte: ~
      base_url: http://localhost/d8
    Drupal\DrupalExtension:
      api_driver: "drupal"
      blackbox: ~
      drupal:
        drupal_root: "/var/www/d8"

Premier test basique dans features/navigate.feature

  Everything from the site.

  Scenario: Title
    Given I am on the homepage
    Then I should see "Drupal"

  Scenario: Log in
    Given I visit "/user"
    # fill the username and password input fields, and click submit
    When I fill in "Username" with "admin"
    And I fill in "Password" with "admin"
    And I press the "Log in" button
    Then I should get a "200" HTTP response
    And I should see text matching "Log out"

On lance le test :

vendor/behat/behat/bin/behat
 @d8 @api
 Feature: Navigation
 Everything from the site.
Scenario: Title # features/navigate.feature:5
 Given I am on the homepage # Drupal\DrupalExtension\Context\MinkContext::iAmOnHomepage()
 Then I should see "Drupal" # Drupal\DrupalExtension\Context\MinkContext::assertPageContainsText()
Scenario: Log in # features/navigate.feature:9
 Given I visit "/user" # Drupal\DrupalExtension\Context\MinkContext::assertAtPath()
 When I fill in "Username" with "admin" # Drupal\DrupalExtension\Context\MinkContext::fillField()
 And I fill in "Password" with "admin" # Drupal\DrupalExtension\Context\MinkContext::fillField()
 And I press the "Log in" button # Drupal\DrupalExtension\Context\MinkContext::pressButton()
 Then I should get a "200" HTTP response # Drupal\DrupalExtension\Context\MinkContext::assertHttpResponse()
 And I should see text matching "Log out" # Drupal\DrupalExtension\Context\MinkContext::assertPageMatchesText()
2 scenarios (2 passed)
 8 steps (8 passed)
 0m0.69s (26.92Mb)

 

Liste des librairies de Drupal8 (répertoire vendor)

  • asm89/stack-cors : Cross-origin resource sharing library and stack middleware.
  • behat : Behat is an open source Behavior Driven Development framework for PHP 5.3+.
  • composer : Dependency Manager for PHP.
  • doctrine : The Doctrine Project is the home to several PHP libraries primarily focused on database storage and object mapping.
  • easyrdf : A PHP library designed to make it easy to consume and produce RDF.
  • egulias/email-validator : EmailValidator – PHP Email validator library inspired in @dominicsayers isemail
  • fabpot/goutte : Goutte is a screen scraping and web crawling library for PHP.
  • guzzlehttp : Guzzle is a PHP HTTP client that makes it easy to send HTTP requests and trivial to integrate with web services.
  • ircmaxell/password-compat : A compatibility library for the proposed simplified password hashing algorithm
  • jcalderonzumba/gastonjs : PhantomJS API based server for webpage automation.
  • jcalderonzumba/mink-phantomjs-driver : PhantomJS driver for Mink framework. Mink is an open source browser controller/emulator for web applications, written in PHP 5.3.
  • masterminds/html5 : An HTML5 parser and serializer for PHP.
  • mikey179/vfsStream : vfsStream is a stream wrapper for a virtual file system that may be helpful in unit tests to mock the real file system.
  • paragonie/random_compat : PHP 5.x support for random_bytes() and random_int()
  • phpdocumentor : phpDocumentor enables you to generate documentation from your PHP source code.
  • phpspec : A php toolset to drive emergent design by specification.
  • phpunit : Welcome to PHPUnit! PHPUnit is a programmer-oriented testing framework for PHP. It is an instance of the xUnit architecture for unit testing frameworks.
  • psr : norm
  • pusher : PHP library for interacting with the Pusher HTTP REST API
  • sebastian/comparator : Provides the functionality to compare PHP values for equality.
  • sebastian/diff : Diff implementation
  • stack : Composing HttpKernelInterface middlewares since 2013!
  • symfony
    • asset
    • browser-kit : Simulates the behavior of a web browser.
    • cache
    • class-loader : The PSR-0 Class Loader: loads classes that follow the PSR-0 class naming standard
    • config
    • console : The Console component allows you to create command-line commands.
    • css-selector : Converts CSS selectors to XPath expressions.
    • debug : The Debug component provides tools to ease debugging PHP code.
    • dependency-injection : The DependencyInjection component allows you to standardize and centralize the way objects are constructed in your application.
    • dom-crawler : The DomCrawler will attempt to automatically fix your HTML to match the official specification.
    • event-dispatcher : The EventDispatcher component provides tools that allow your application components to communicate with each other by dispatching events and listening to them.
    • expression-language
    • file-system
    • finder
    • form
    • http-foundation : The HttpFoundation component defines an object-oriented layer for the HTTP specification.
    • http-kernel : The HttpKernel component provides a structured process for converting a Request into a Response by making use of the EventDispatcher component. It’s flexible enough to create a full-stack framework (Symfony), a micro-framework (Silex) or an advanced CMS system (Drupal).
    • polyfill : This project backports features found in the latest PHP versions and provides compatibility layers for some extensions and functions. It is intended to be used when portability across PHP versions and extensions is desired. polyfill-apcu / polyfill-iconv / polyfill-mbstring / polyfill-php54 / polyfill-php55
    • intl
    • ldap
    • option-resolver
    • phpunit
    • process : he Process component executes and monitor commands in sub-processes.
    • property-access
    • property-info
    • psr-http-message-bridge : The PSR-7 bridge converts HttpFoundation objects from and to objects implementing HTTP message interfaces defined by the PSR-7.
    • routing : Whenever you have a {placeholder} in your route path, that portion becomes a wildcard: it matches any value.
    • security
    • serializer : The Serializer component is meant to be used to turn objects into a specific format (XML, JSON, YAML, …) and the other way around.
    • stopwatch : The Stopwatch component provides a way to profile code.
    • templating
    • translation : Translations are handled by a translator service that uses the user’s locale to lookup and return translated messages
    • validator : So far, this is just an ordinary class that serves some purpose inside your application.
    • var-dumper
    • yaml : The Symfony Yaml component parses YAML strings to convert them to PHP arrays
  • symfony-cmf/routing : routingRouting component building on the Symfony2 Routing component.
  • twig : Twig – The flexible, fast, and secure template engine for PHP.
  • webmozart/assert : assert – Assertions to validate method input/output with nice error messages.
  • wikimedia/composer-merge-plugin : composermergepluginMerge one or more additional composer.json files at Composer runtime.
  • zendframework
    • zend-diactoros : PSR-7 HTTP Message implementation. Contribute to zenddiactoros development by creating an account on GitHub.
    • zend-escaper : zendescaper. Securely and safely escape HTML, HTML attributes, JavaScript, CSS, and URLs
    • zend-feed : Feed component from Zend Framework. Contribute to zendfeed development by creating an account on GitHub.
    • zend-stdlib : Zend\Stdlib is a set of components that implements general purpose utility class for different scopes like:
      • array utilities functions;
      • json serializable interfaces;
      • general messaging systems;
      • string wrappers;
      • etc.

 

http://symfony.com/doc/current/components/index.html

 

Import d’un site existant dans aegir depuis un dépôt git

Petit préambule pour bien comprendre le vocabulaire d’aegir.

Imaginons que vous ayez un site sur monurl.fr qui pointe sur /var/www/monsite (la plateforme) dans votre vhost avec un sous-répertoire /sites/monurl.fr (le site), le tout géré par votre dépôt git sous gitlab.

Pour faire simple aegir appelle un drupal sans base de donnée une plateforme et un drupal avec sa base de donné un site :

  • une plateforme au sens aegir, c’est le dépôt git d’un répetoire drupal avec sa branche courante (qu’on ne peut plus changer à postériori)
  • une site au sens aegir c’est : à l’intérieur d’une plateforme, un répertoire dans sites avec le settings.php (généré par aegir, il ne faut pas y toucher), une base de donnée, un alias, et un vhost

Etape 1 : créer la plateforme à partir du dépôt git

Une plateforme, c’est un répertoire PHP avec un drupal installé dedans, mais sans base de donnée associée (une plateforme peut contenir plusieurs sites).

http://aegir.monurl.fr/node/add/platform

Donner un nom « monsite » par exemple dans le BO aegir pour votre plateforme.

On relie la plateforme à un dépôt git avec sa branche sous la forme :

ssh://git@gitlab.monurl.fr:22443/mon-depot.git

Attention, il faut utiliser le protocole ssh car il y a une clé ssh définie sur gitlab et sur la VM pour pouvoir cloner sans mot de passe. Via https ça ne fonctionnera pas.

Choisir le mode git pull (Git pull task pour des pull manuels).

Aegir va cloner le dépôt du site dans le répertoire

/var/aegir/platforms/monsite

Il va automatiquement repérer les sites dans /var/aegir/platforms/monsite/sites/ et tenter de les importer automatiquement, mais ça ne va pas fonctionner. C’est normal, pas de panique 😉

Idéalement, il faudrait que répertoire n’existe pas dans le dépôt git.

Etape 2 : suppression du site importé

Il faut donc supprimer le répertoire du site aegir :

# rm -r /var/aegir/platforms/monsite/sites/monurl.fr/

et supprimer le nœud du site (automatiquement créé) dans aegir aussi :

http://aegir.monurl.fr/node/add/platform#overlay=admin/content : supprimer le noeud « monurl.fr »

Ensuite, il faut recréer le site « à vide » puis le « remplir » avec le vrai site.

Pourquoi cette étape de suppression avant la re-création ?

Parce que aegir lors de l’import est censé créer une base de donnée reconnue par aegir pour le nouveau site. Comme l’import plante, il ne crée pas la base de donnée.

Il faut supprimer et re-créer pour qu’il fasse les choses proprement et qu’on ai cette fameuse base de donnée par défaut gérée par aegir, dans laquelle on va pouvoir importer notre site.

Etape 3 : création du site

Cette fois on va rattacher le site à une plateforme, ce qui revient à créer une base de donnée, un répertoire dans /sites, et un settings.php qui relie les deux.

http://aegir.monurl.fr/node/add/site

dans le domain name mettre : monurl.fr

Laisser vide le deploy from git (Repository URL : vide).

Attention à bien choisir la plateforme récemment crée pour que le site soit créé au bon endroit.

A ce moment la aegir fait plusieurs choses :

  • il crée une base de donnée vide : monurl_0
  • il lance l’installation par défaut de drupal
  • il crée un alias drush : su – aegir -c « drush status » : /var/aegir/.drush/monurl.fr.alias.drushrc.php
  • il crée un vhost qui contient des paramètres utilisées dans le settings.php aussi : port 80 namevhost monurl.fr (/var/aegir/config/server_master/apache/vhost.d/monurl.fr:1)

NB : monurl.fr existe déjà dans votre ancien vhost. Si vous regardez votre configuration apache, vous verrez qu’il y en a 2, mais celui d’aegir est prioritaire.

Etape 4 : import des données

Il y a deux choses à faire, comme pour tout clonage de site : récupérer les fichiers dans files et récupérer la base de donnée initiale.

#cp -a /var/www/monsite/sites/monurl.fr/files /var/aegir/platforms/monsite/sites/monurl.fr/
# chown -R aegir:aegir /var/aegir/platforms/monsite/sites/monurl.fr/files

#mysqldump -u root -p'PASSWORD' monsite | su - aegir -c 'drush @monurl.fr sqlc'

Etape 5 (optionnel) : supprimer l’ancien site

Si on veut supprimer la base de donnée initiale :

#cd /var/www/monsite/sites/monurl.fr ; drush sql-drop -y

Si on ne veut supprimer que le site :

#rm -r /var/www/monsite/sites/monurl.fr

Si on n’a plus besoin de la plateforme :

#rm -r /var/www/monsite

Etape 6 : mise à jour du site

A partir de la, vous pouvez gérer votre site dans aegir, et donc, demander un backup d’un simple click, cloner votre site, et plein d’autres choses (en fonction des sous-modules que vous aurez activés).

Pour faire une mise à jour (si vous avez updaté votre dépôt git) voici les étapes à suivre.

Faire un git pull sur la plateforme, dans les tâches : http://aegir.monurl.fr/hosting/c/platform_monsite

Faire le « Revert features » et le « Run db updates » sur les tâches du site : http://aegir.monsite.fr/hosting/c/monurl.fr

On peut faire la même chose en ligne de commande, mais il faut être en user aegir pour que ça fonctionne.