Find As You Type / Autocomplete im Eigenbau (jQuery & yii)

Wir sind uns wohl alle einig: Ein Textfeld auf der eigenen Homepage das nach jedem Buchstaben, der eingegeben worden ist, eine aktualisierte Ausgabe erzeugt ist schön!
Google nennt sowas „Google Instant“, wir nennen es jetzt mal „Finde beim tippen“-Funktion oder so.

Als Ausgangssituation nehmen wir an, wir haben eine Datenbank mit beliebigen Inhalt (zB Blog Beiträge) und nun soll mit einem Textfeld der Titel der Blogeinträge durchsucht werden.

Jetzt könnte man direkt in die Tasten hauen und loscoden, aber man sollte vorher noch ein paar Details überlegen:

Auf der Client Seite:

Finde direkt beim Tippen? – Wörter und ganze Sätze bestehen aus einer Reihenfolge von Buchstaben. Ein Besucher der ein „j“ eingibt, will vielleicht nach „jQuery“ suchen. Wenn der Browser nun nach jeder Tasteneingabe einen GET oder POST Request auf den Server feuert, könnte dies bei zunehmender Beliebtheit der Seite eine große Last für den Webserver sein. Wir nehmen also an, das ein Besucher mit jedem Buchstaben den er eingibt, seinem Suchwunsch näher kommt, und man daher mit steigender Wortlänge die Wahrscheinlichkeit eines besseren Treffers erhöht. Also implementiert man eine verzögerte AJAX Ausführung, angefangen bei einem Wert von 250ms, der dann mit steigender Wortlänge immer kleiner wird und somit den AJAX Call immer schneller ausführt:

$().ready(function() {
// das Example Objekt enthält alle Funktionen zur Abwicklung des Beispiels
var Example = {
  'queryTimeout' : 250,
  'query' : '',
  'url' : 'query.html',
  'timeout' : null,
  'ajaxCall' : function() {
    $.ajax({
      'url' : Example.url,
      'data' : {'query' : Example.query},
      'success' : Example.onSuccess,
      'complete' : Example.onComplete
    });
  },
  'onSuccess' : function(d) {
    $('#Examples').children().remove();
    $('#Examples').append(d);
    $('#Examples').slideDown();
  },
  'onComplete' : function() {
    $('#ExampleQuery').removeClass('queryLoader');
  }
};

// setzten des keyup Handlers
$('#ExampleQuery').live('keyup',function() {
  // spinning grafik anzeigen
  if (!$(this).hasClass('queryLoader')) $(this).addClass('queryLoader');
  // alte Einträge ausblenden
  $('#Examples').slideUp();
  // Initialisierung des Example Objektes
  Example.query = $(this).val();
  Example.queryTimeout = 250;
  // Überprüfen ob grade ein Timer aktiv ist und ggf. diesen Abbrechen
  if (Example.timeout != null) {
    clearTimeout(Example.timeout);
    Example.timeout = null;
  }
  // nach Wortlänge abhängigen Timeout Wert setzten
  for (i = 1; i < Example.query.length; i++) { Example.queryTimeout *= 0.9;}
  // den AJAX Call im Timer registieren
  Example.timeout = setTimeout(Example.ajaxCall, Example.queryTimeout);
});

});

Mit diesem kleinen jQuery Code, halten wir den Client von einer (D)DOS Attacke ab.

 

Und auf der Server Seite:

Nun muss nur noch eine Funktion auf der Serverseite erstellt werden, die auf den AJAX Call antwortet. Auch hier kann man ganz schnell und einfach loslegen, aber mit ein bisschen weiterer Überlegung spart man seinem Server vielleicht doch unnötige Arbeit und sichert ihn ab.

Regel #1: Userinput ist immer böse! Man muss immer überlegen was das Ziel des Userinputs ist. Durch sinnvolle Einschränkungen und Aufbereitung des Inputs spart man dem Server meistens Zeit und vielleicht sogar Ärger. So kann man je nach Anwendungsszenario nur noch bestimmte Zeichen zulassen, oder man beschränkt die Suchwortlänge auf eine sinnvolle Grenze. Dieses sollte man bei Caching auf jeden Fall beachten! Wenn sich eine Datenbank mit dem Suchwort beschäftigen muss, mus man sowieso entweder das Suchwort noch überprüfen (SQL Injection) oder am besten „prepared Statements“ nutzten.

Regel #2: User A und User B könnten nach dem gleichen suchen – eine Koinzidenz! Wenn ein User nach „Fahrrad“ sucht und direkt danach kommt ein User der nach “ Fahrrad “ sucht, sollte man im Optimalfall die Datenbank nur einmal belästigen. Genauso wenn ein User „Fahhrad“ und der nächste „fahrrad“ eingibt.  Wenn in unserem Datenmodell der Typ des suchbaren Feldes „VARCHAR“ ist, ist einer mySQL Groß- und Kleinschreibung egal (es sei denn man hat das Attribut Binary gesetzt). Mit diesem Wissen kann man alle genannten Anfragen auf eine Datenbankabfrage reduzieren.

Regel #3: Wieso dieses? Die User könnten doch nach und nach diese Anfrage stellen? Stimmt, daher kommt nun endlich das Thema Caching zum Vorschein. Ein sinnvoller Caching Mechanismus kann hier die Antwortzeiten erstaunlich drücken und in Spitzenzeiten die Serverlast senken. Nachdem wir Regel #1 und Regel #2 angewandt haben, können wir nun jede Datenbankantwort in einen Cache ablegen, dessen Key optimalerweise dem Suchwort entspricht. Mit einer TimeToLife von zB einer Stunde kann in dieser Zeit die Datenbank sich um andere wichtige Dinge kümmern.

Genug geredet, hier kommt der Quelltext, wie so oft und so gerne mit dem Yii Framwork:

public function actionQuery() {
    $key = 'exampleData';  // prefix damit wir andere Caching Einträge nicht überschreiben
    $cache = Yii::app()->cache;  // den cache "laden"
    $ttl = 3600;  // time to life = 3600s = 1h
    $data = false;  // Initialisierung der $data variable

    // Überprüfung des Query
    if (isset($_GET['query'])) {
      // whitespaces vorne und hinten entfernen und nur Kleinschreibung
      $q = trim(strtolower($_GET['query']));
      // die Länge wird auf 16 Zeichen begrenzt, damit man per BruteForce Attacke nicht den Cache ärgert
      if (strlen($q) > 16) $q = substr($q,0,16);
      // Prüfung ob überhaupt ein Cache zur Verfügung steht
      if ($cache !== null) {
        // Versuch die Daten aus dem Cache zu laden, 'false' ist die Rückgabe wenn dies nicht klappt
        $data = $cache->get($key.$q);
      }
      // test ob Daten nicht aus dem Cache geladen wurden
      if ($data === false) {
        // prepared Statement initialisieren
        $criteria = new CDbCriteria;
        $criteria->condition = 'title LIKE concat(:query,\'%\')';
        $criteria->params = array(':query' => $q);
        $criteria->limit = 12;
        $criteria->order = $q == '' ? 'RAND()' : 'title ASC';
        // Datenbank abfragen
        $data = Example::model()->findAll($criteria);
        // wenn ein Cache vorhanden ist, legen wir die neuen Werte darin ab
        if ($cache !== null) $cache->set($key.$q, $data, $ttl);
      }
      // ausgabe an den Browser
      $this->renderPartial('example', array('data' => $data,'q' => $q));
    }

 

Zum Thema Caching muss man folgendes Wissen: Ein Cache ist kein essentieller Bestandteil einer Webanwendung. Die Einbindung ist sinnvoll und sollte auch von vornherein im Entwurf geplant sein, aber generell muss man die Anwendung immer so programmieren, als ob der Cache leer oder nicht vorhanden sei.

Das Yii Framework bietet eine Menge verschiedener Caching Möglichkeiten an. Natürlich ist die Einbindung eines MemCacheD möglich, aber auch ein Caching im Dateisystem. Das Caching im Dateisystem bietet sich vor allem für Nutzer eines Massenhosters an, die dort PHP & Co nutzen können, aber nicht eigene Dienste installieren dürfen. Ob ein Caching im Dateisystem (viel) schneller ist, als eine Datenbankabfrage sei mal dahingestellt, aber wenn man davon ausgeht das grade zig Nutzer auf der Webseite die „Finden beim Tippen“ Funktion nutzten, ist jeder gesparte Datenbankabruf sehr viel wert. Vor allem wenn Apache und mySQL auf dem gleichen Server laufen.

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.