Emojis in Rails und MySQL - 🐽, 🦄 und ✋🏿?

Emojis: Sie sind vom Telefon ins Web gewandert und sie sind gekommen um zu bleiben. Doch sie entgegenzunehmen und fehlerfrei zu behandeln ist nicht ganz trivial.
Wie intensiv die Nutzung von Emojis mittlerweile ist, zeigt die Wahl zum Wort des Jahres 2015 in England. Immer mehr Benutzer nutzen Emojis als Teil ihrer Texte auch außerhalb von Telefon-Chats und versuchen diese im Web unter zu bringen. Das kann unter Ruby on Rails, insbesondere im Zusammenhang mit einer MySQL-Datenbank zu Problemen und Fehlern führen.
Pig nose, Unicorn face and brown raised hand
Die Emojis "Schweinsnase", "Einhorn" und "erhobene dunkelbraune Hand" stellen unterschiedliche Herausforderungen bei der Verarbeitung unter MySQL und Rails.
Genau aus diesem Grund mussten wir uns mit dem Thema auseinandersetzen. Somit konzentrieren wir uns bei diesem Eintrag auch auf den aktuellen Stand der Emojis aus dem Apple-Universum. Daher kann es gut sein, dass diese Emojis auf Ihrem Rechner nicht gerendert werden. Das nimmt man beim Einsatz von Emojis nämlich auch in Kauf.
Die oben abgebildeten Emojis stehen für die aktuelle Entwicklung in diesem Bereich.

Der Fehler bei Emojis und Rails in MySQL

Aufmerksam wurden wir durch eine MySQL-Fehlermeldung aus der Produktion:
Mysql2::Error: Incorrect string value: '\xF0\x9F\x98\x80 A...' for column 'title' at row 1
Was war das für ein "string value" und wie kam der dort hin? Wie sich herausstellte, ließ sich der Fehler durch die Eingabe von Emojis reproduzieren. Die Datenbank unter der verwendeten MySQL-Version 5.5 war per Default mit 3-Byte UTF-8 Unicode Encoding konfiguriert. Emojis sind mittlerweile Teil des Unicode-Standards:
wikipedia.org - Emoji/Unicode.
Wo lag das Problem? Die MySQL 5.5 Reference - 10.1.10.4 The utf8 Character Set (3-Byte UTF-8 Unicode Encoding) gibt unter anderem folgende Auskunft:
Das Encoding dient dazu bis zu drei Bytes pro Character speichern zu können. Dieses Encoding ist für das sogenannte "Basic Multilingual Plane" (die "Mehrsprachige Basis-Ebene") vollkommen ausreichend. Doch ein Emoji ist Teil des "Supplementary Multilingual Plane", der "ergänzenden mehrsprachigen Ebene" (wikipedia.org - Unicode Gliederung).
Ein Emoji benötigt also mehr als drei Bytes und findet im MySQL-Encoding "UTF-8" keinen Platz.
Was hat man mit MySQL für Alternativen?
Mit utf8mb4 lassen sich pro Character bis zu 4 Bytes speichern und somit alle für Emojis benötigten Zeichen abbilden, ohne unnötig Speicherplatz zu verlieren:
MySQL 5.5 Reference - 10.1.10.6 The utf8mb4 Character Set (4-Byte UTF-8 Unicode Encoding)

Migration für Emojis und Rails mit MySQL - Fehler

Also die Datenbankkonfiguration umgestellt!

database.yml von
development:
  ...
  adapter: mysql2
  encoding: utf8
  ...
auf
development_utf8mb4:
  ...
  adapter: mysql2
  encoding: utf8mb4
  collation: utf8mb4_bin
  ...
Und migriert:
class ConvertToMb4 < ActiveRecord::Migration
  def up
    transaction do
      execute "ALTER DATABASE `#{ActiveRecord::Base.connection.current_database}` CHARACTER SET utf8mb4 COLLATE utf8mb4_bin;"
      execute "ALTER TABLE books CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin;"
    end
  end
end
Doch was ist das?!
rake aborted!
StandardError: An error has occured, all later migrations canceled:

Mysql2::Error: Specified key was too long; max key length is 767 bytes: CREATE  INDEX  `index_books_on_title`  ON `books` (`title`)
 /gems/ruby2.2.3@utf8/gems/activerecord-4.2.4/lib/active_record/connection_adapters/abstract_mysql_adapter.rb:
305:in `query`

Migration für Emojis und Rails mit MySQL - Ohne Konfiguarionsanpassung von MySQL

In unserem Fall existiert ein "uniqe index" auf der Spalte `title`. MySQL kann den Index nicht neu aufbauen, denn nun ist das zu indizierende Textfeld mit utf8mb4 kodiert. Anstatt von bis zu 3 Bytes pro Zeichen müssen jetzt also bis zu 4 Bytes pro Zeichen indiziert werden. Somit reicht der Platz für die Indizes der utf8mb4-Felder nicht mehr aus. Von einem Character-Feld, mit ursprünglich 255 Zeichen Länge bleiben nun theoretisch 3/4 des Platzes.
Das heißt, eine Option ist, die indizierten Felder in Ihrer Länge auf 191 Zeichen zu beschränken:
class ConvertToMb4 < ActiveRecord::Migration
  def up
    transaction do
      change_column :books, :title, :string, limit: 191
      execute "ALTER DATABASE `#{ActiveRecord::Base.connection.current_database}` CHARACTER SET utf8mb4 COLLATE utf8mb4_bin;"
      execute "ALTER TABLE books CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin;"
    end
  end
end
Jetzt funktioniert also zum ersten mal das Speichern eines Einhorns:
Book.create(title: '🦄')
   (0.1ms)  BEGIN
  SQL (0.2ms)  INSERT INTO `books` (`title`, `created_at`, `updated_at`) VALUES ('🦄', '2015-12-10 17:24:52', '2015-12-10 17:24:52')
   (0.4ms)  COMMIT
 => #<Book id: 2, title: "\u{1F984}", body: nil, created_at: "2015-12-10 17:24:52", updated_at: "2015-12-10 17:24:52">

Migration für Emojis und Rails mit MySQL - Dynamische Schlüssellänge

Doch eine Beschränkung von ursprünglch 255 Zeichen auf 191 Zeichen im laufenden Projekt ist nur selten eine Option. Um Emojis ohne Einbußen der Zeichenlänge bei Feldern mit unique Indizes zu ermöglichen ist der Aufwand etwas größer. Die Voraussetzung hierfür ist der Einsatz von MySQL ab Version 5.5.14. Mit dem Schalter --innodb_large_prefix lässt sich laut MySQL 5.5 Reference - 14.14 InnoDB Startup Options and System Variables folgendes erreichen:
Enable this option to allow index key prefixes longer than 767 bytes (up to 3072 bytes), for InnoDB tables that use the DYNAMIC and COMPRESSED row formats.
Somit ermöglicht man die Indizierung von Stringfeldern, die über 191 4-Byte große Zeichen hinaus gehen. Doch ein weiteres mal obacht: Dieser Schalter wirkt nur, wenn man zwei weitere setzt: In der MySQL Konfiguration my.cnf sind folgende Parameter zu konfigurieren.
innodb_file_format=barracuda
innodb_file_per_table=true
innodb_large_prefix=true
Nun gilt es die Migration ordentlich durchzuführen. Dabei gilt es zu beachten, dass der Schalter eben nur bei COMPRESSED oder DYNAMIC Zeilen greift.
class ConvertToMb4 < ActiveRecord::Migration
  def up
    transaction do
      execute "ALTER TABLE `books` ROW_FORMAT=DYNAMIC;"
      change_column :books, :title, :string, limit: 255 # Optional, falls vorher auf 191 gekürzt

      execute "ALTER DATABASE `#{ActiveRecord::Base.connection.current_database}` CHARACTER SET utf8mb4 COLLATE utf8mb4_bin;"
      execute "ALTER TABLE `books` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin;"
    end
  end
end
Somit ist es uns gelungen eine bestehende Anwendung zu migrieren:
Book.create(title: '🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄')
   (0.1ms)  BEGIN
  SQL (0.2ms)  INSERT INTO `books` (`title`, `created_at`, `updated_at`) VALUES ('🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄🦄', '2015-12-10 18:31:32', '2015-12-10 18:31:32')
   (0.6ms)  COMMIT
 => #<Book id: 3, title: "\u{1F984}\u{1F984}\u{1F984}\u{1F984}\u{1F984}\u{1F984}\u{1F984}\u{1F984}\u{1F984}\u{1F984}\u{1F984}\u{1F984}\u{1F984}\u{1F984}\u{1F984}\u{1F984}\u{1F984}\u{1F984}\u{1F984}\u{1F984}\u{1F984}\u{1F984}\u{1F984}\u{1F984}\u{1F984}\u{1F984}\u{1F984}\u{1F984}\u{1F984}\u{1F984}\u{1F984}\u{1F984}\u{1F984}\u{1F984}\u{1F984}\u{1F984}\u{1F984}\u{1F984}\u{1F984}\u{1F984}\u{1F984}\u{1F984}\u{1F984}\u{1F984}\u{1F984}\u{1F984}\u{1F984}\u{1F984}\u{1F984}\u{1F984}...", body: nil, created_at: "2015-12-10 18:31:32", updated_at: "2015-12-10 18:31:32">
Und wir konnten zum ersten mal 255 Einhörner im Titel unterbringen!

Alte Migrationen bei einer Neuinstallation der Anwendung

Doch ein weiteres mal obacht! Es existiert noch ein Problem, welches erst auf den zweiten Blick sichtbar wird: Die gesamte Migrationshistorie ist so nicht mehr einsetzbar! Denn die Datenbankkonfiguration steht nun auf utf8mb4. Alte Migrationen wollen jedoch Indizes mit dem Zeichensatz utf8mb4 auf Felder erstellen, deren Tabellen (noch) nicht das auf ROW_FORMAT=DYNAMIC gestellt sind, sondern, wie bei Rails üblich, auf COMPRESSED! Somit ist die Migrationshistorie, bis zum Zeitpunkt der Konvertierung der Datenbanken, nicht mehr funktionsfähig.
Um dieses Problem der "alten" Migrationen lösen zu können, kann man verschiedene Ansätze verfolgen, von denen wir hier ein paar Beispiele anreißen:
  • Alte Migrationen löschen und aus Schema-Datei neue "Grundmigration" erzeugen, die das korrekte ROW_FORMAT enthält. Nachteil hierbei: Die Historie geht verloren.
  • Monkeypatch von ActiveRecord/AbstractMysqlAdapter, bei "create_table" automatisch das ROW_FORMAT=DYNAMIC zu verwenden (Nachteil hierbei: Eigentlich möchte man kein Monkeypatching betreiben. Das ist ein Codesmell. Daher gibt's hier auch kein Codebeispiel 😉)
  • Alte Migrationen mit Shell-Magic umwandeln
    sed -i '' -E "s/create_table\ [:\"']([a-zA-Z0-9_]+)[\"']?/create_table\ :\1, options: 'ENGINE=InnoDB ROW_FORMAT=DYNAMIC'/g" db/migrate/*.rb

Das Emoji-Problem unabhängig von MySQL lösen

Was aber, wenn der beschriebene Lösungsweg keine Option oder zu aufwändig ist und man doch eigentlich nur möglichst schnell die Fehler abstellen möchte? Bleibt einem dann nichts als Emojis von seiner Datenbank und Anwendung auszuschließen? Und wenn ja, wie geht das?

Serialize mit ActiveRecord

Die Instanz-Methode serialize
# Serialize a preferences attribute.
  class User < ActiveRecord::Base
  serialize :preferences
end
bietet für ActiveRecord eine eingebaute Möglichkeit Attribute so zu serialisieren, dass MySQL sie auch in 3-Byte UTF-8 Unicode Encoding schluckt:
class Book < ActiveRecord::Base
  serialize :title
Der Aufruf von
> b=Book.create(title: "Buchtitel 🦄")
   (0.1ms)  BEGIN
  SQL (0.2ms)  INSERT INTO `books` (`title`, `created_at`, `updated_at`) VALUES ('--- \"\\U0001F984\"\n', '2015-12-03 16:43:23', '2015-12-03 16:43:23')
   (2.2ms)  COMMIT
führt somit zu einem Eintrag
|  4 | --- "Buchtitel \U0001F984"
 | NULL                | 2015-12-03 16:44:24 | 2015-12-03 16:44:24 |
In wie fern diese Art der String-Serialisierung in der Datenbank für den jeweiligen Use-Case der vorliegenden Anwendung trägt, muss wohl individuell und womöglich nach weiterer Evaluation entschieden werden.

Gem emojimmy

Das Gem emojimmy wandelt Emojis in eine eigne Syntax um und ermöglicht Folgendes: Emojimmy makes it possible to store emoji characters in ActiveRecord datastores that don’t support 4-Byte UTF-8 Unicode (utf8mb4) encoding. Das heißt, es erfolgt eine automatische Umwandlung der Unicode-Emojis in eine eigene Syntax zur Codierung. In der Datenbank wird somit z.B. aus U+1F43D (🐽) :pignose:. Nach einfachen Tests können wir feststellen, dass die Integration überaschend gut gelungen ist. Der Nachteil allerdings: Das Gem ist nicht ganz auf dem aktuellen Stand. Die letzte Aktualisierung erfolgte im Juni 2015. Somit bleiben 🦄 und ✋🏿 im Moment außen vor. Außerdem gilt zu beachten, dass der Platzverbrauch eines Datenbankfeldes deutlich ansteigen kann und eine Feldlängenbegrenzung auf Datanbankebene andere Auswirkungen hat, als im Frontend.

Gem Demoji

Das Gem demoji sorgt dafür, Emojis im Zuge des Speichervorgangs zu entfernen: Replace emojis as to not blow up utf8 MySQL. Und weiter heißt es: This is a workaround until Rails adds support for UTF8MB4 in migrations, schema, etc. Auch hier konnten wir nach einfachen Tests eine gelungene Umsetzung feststellen. Ob dies allerdings tatsächlich eine Lösung darstellt ist stark vom Projekt abhängig.

Emojis in PostgreSQL

Wie schön einfach ist die Welt hingegen unter PostgreSQL! Die Datenbank hat in der Default-Konfiguration keinerlei Probleme mit Emojis. Hier fühlen sich 🐽, 🦄 und ✋🏿 auf Anhieb zuhause. Es bleibt nur der Hinweis, dass die Darstellung unterschiedlicher Hautfarben diverser Apple Emojis, wie zum Beispiel die dunkelbraune "Raised hand" (✋🏿 - U+270B U+1F3FF) aus zwei Unicode-Charactern besteht und somit sowohl im Frontend, als auch in der Datenbank den Speicherplatz von zwei Charactern benötigt. Doch das betrifft wohl alle Datenbanken.

Zur Blog-Übersicht