Cookies
Diese Website verwendet Cookies und ähnliche Technologien für Analyse- und Marketingzwecke. Durch Auswahl von Akzeptieren stimmen Sie der Nutzung zu, alternativ können Sie die Nutzung auch ablehnen. Details zur Verwendung Ihrer Daten finden Sie in unseren Datenschutz­hinweisen, dort können Sie Ihre Einstellungen auch jederzeit anpassen.

Engineering

Ruby meets Enterprise - Ruby on Rails-Entwicklung mit Oracle-Datenbanken
Minuten Lesezeit
Blog Post - Ruby meets Enterprise - Ruby on Rails-Entwicklung mit Oracle-Datenbanken
Oliver Noack

Datenbanken im Enterprise-Umfeld

Bei Zweitag wollen wir das Beste aus den Welten Startup und Enterprise verbinden. Gerade Startups profitieren von den schnellen Ergebnissen, die mit Frameworks wie Ruby on Rails erzielt werden können. Der Weg zu einem minimum viable product (MVP) oder einem ersten Prototypen ist in der Regel nicht weit und der Infrastruktur-Stack des Startups kann nach eigenen Wünschen gestaltet werden.

Im Enterprise-Umfeld dagegen ist die Zusammenstellung des Technologie-Stacks restriktiver. Bei der Wahl der Persistenzschicht einer Anwendung sind Open Source-Lösungen wie PostgreSQL oder MySQL eher Ausnahme als die Regel. Die Entscheider bauen im Enterprise-Umfeld meist auf große, etablierte Namen wie Oracle Database, Microsoft SQL Server oder DB2. Auch die Einführung oder gar der komplette Wechsel von Datenbanken ist aufgrund bestehender Backup-Strategien, der Verflechtung in bestehende Systeme und Oracle-Erfahrung von Mitarbeitern langwierig und teuer.

Mit Ruby on Rails können in Unternehmen flexible Systeme umgesetzt werden, die auf immer schneller wechselnde Rahmenbedingungen reagieren können. Die Datenbank ist dabei nur ein Faktor, der in diesem Kontext eine Rolle spielt. Dieser Blogeintrag soll durch eine kurze technische Beschreibung zeigen, dass das effektive Zusammenspiel von proprietärer Software mit Ruby on Rails erfolgreich funktionieren kann. Ergebnis soll ein Leitfaden zur Nutzung von Ruby oder Ruby on Rails mit Oracle Database sein.

Ruby und Oracle Database

Oracle Database ist gewöhnlich nicht die erste Wahl eines Ruby-Entwicklers. Wenn die Nutzung von Oracle Database durch externe Rahmenbedingungen vorgegeben ist, muss jedoch nicht auf Ruby verzichtet werden. Der erste Schritt ist eine lokale Installation der Oracle Database - hier empfiehlt sich die kostenlose Variante Oracle Database 11g Express Edition. Die Installation unter Mac OS ist nicht gerade straight-forward, daher empfiehlt sich die Installation in einer virtuellen Maschine. Eine Beispiel-Anleitung ist hier zu finden.

Für den Oracle-Zugriff aus Ruby bietet sich das Gem ruby-oci8 an. Dieses Interface kommuniziert über die von Oracle definierte C-Schnittstelle OCI (Oracle Call Interface). Das Gem ist über Rubygems verfügbar und durch Einbindung ins Gemfile im Ruby-Projekt verwendbar. Damit der Zugriff funktioniert, müssen die „Oracle Instant Client Packages“ installiert sein. Ein Anleitung hierfür ist in der Dokumentation des Gems zu finden.

Das folgende Code-Beispiel prüft die Funktionalität des Datenbank Interfaces:

require 'oci8'

user = '****'
pass = '****'
host = '192.168.1.xxx'
oracle_connection = OCI8.new(user, pass, host)
oracle_connection.exec("select * from dual") do |r|
  puts r.join(',')
end

Waren die Konfiguration der Datenbank und der Instant-Client-Pakete erfolgreich, wird mit dem kurzen Quellcode-Beispiel eine Datenbankverbindung aufgebaut und der Inhalt der Tabelle „dual” ausgelesen.

##Rails und Oracle Database - Zusammenspiel ActiveRecord & Oracle Database

In Ruby on Rails-Projekten wird durch den Objekt-relationalen Mapper ActiveRecord von SQL-Queries abstrahiert. Zusätzlich zum ruby-oci8 gem benötigt man in Rails-Projekten den Datenbank-Adapter activerecord-oracle_enhanced-adapter. Dieser kann nach Einbindung im Gemfile in der database.yml genutzt werden.

# default definition
development: &default
  adapter: oracle_enhanced
  host: 192.168.1.142
  port: 1521
  database: xe
  username: user
  password: password

Das Zusammenspiel mit ActiveRecord funktioniert nach erfolgreicher Konfiguration in der Regel reibungslos. Im Vergleich mit anderen Datenbanken sind hier allerdings ein paar Feinheiten zu beachten.

DateTime und Date

Im Gegensatz zu Postgres und MySQL gibt es in Oracle-Datenbanken keinen Spaltentyp, der ein reines Datum ohne Uhrzeit persistiert. Dies kann zu Verwirrung führen, wenn in der Rails-Anwendung UTC nicht als Zeitzone konfiguriert wurde.

u = User.new
u.birthday = Date.today
u.save
# => true

u.birthday
# => Wed, 17 Jun 2015 00:00:00 CEST +02:00

#Wert in der Datenbank: 2015-06-16 22:00:00 +0200 UTC

Im Code-Beispiel wird der Geburtstag eines Users persistiert. Ruby-seitig wird der Geburtstag korrekt persistiert und auch korrekt ausgegeben - allerdings als DateTime Objekt. Zudem ist der Datenbankwert - wie bei allen DateTimes - in UTC gespeichert, was in der Datenbank eine Abweichung des Tages zur Folge hat. Der Geburtstag in unserem Beispiel ist aber in allen Zeitzonen am 17. Juni. Der Oracle-Adapter bietet im Legacy Schema Support set_date_columns an. Wenn im User-Model „birthday“ als date_column definiert wird, ist der Rückgabe Wert ein Date Object und in der Datenbank wird auch in UTC das korrekte Datum persistiert.

class User < ActiveRecord::Base
  set_date_columns :birthday
end
u = User.new
u.birthday = Date.today
u.save
# => true
u.birthday
# => Wed, 17 Jun 2015

# Wert in der Datenbank: 2015-06-17 00:00:00 +0200 UTC

Boolean Werte

Oracle Database bietet keine boolean-Spalten an. ActiveRecord legt boolean-Spalten als NUMBER(1,0) an. In der Datenbank sind demnach 0, 1, und 2 gültige Werte. Wenn NULL vermieden werden soll, bietet sich zusätzlich ein NOT NULL Constraint an, so dass false keine Doppelbelegung in der Datenbank hat.

Umgekehrt können NUMBER(1,0)-Spalten aus Legacy-Datenbanken Probleme verursachen, da diese von ActiveRecord als boolean interpretiert werden. Werte von 2-9 werden als_false_ interpretiert. Um das zu vermeiden kann die entsprechende Spalte im Model mit set_integer_columns als Integer definiert werden.

Größenbeschränkungen in Oracle Database

Bezeichnungen von Tabellen und Spalten sind in Oracle generell auf eine Länge von 30 Zeichen begrenzt. Längere Bezeichnungen lehnt Oracle mit dem Fehler “OCIError: ORA-00972: identifier is too long” ab. Verwendet man beispielsweise ein Model mit der Klassenbezeichnung „DoesYourRubyLookLikeJavaExample“, akzeptiert die Datenbank nicht den vom Adapter generierten Namen „does_your_ruby_look_like_java_examples“. Dieser überschreitet die 30-Zeichen-Obergrenze für Bezeichnungen. An dieser Stelle muss der Tabellenname mit “self.table_name” im Model manuell definiert und gekürzt werden. Ebenfalls nicht erlaubt sind Spaltennamen über 30 Zeichen

Datenbank-Abfragen die in eine “IN”-Bedingung enthalten, sind auf 1000 Objekte begrenzt. Enthält die „WHERE“-Abfrage mehr Einträge bricht Oracle mit der Fehlermeldung "ORA-01795: maximum number of expressions in a list" ab. Folgende ActiveRecordRelation
all_cities = City.where(zip:(1..1001).to_a)
kann nicht aufgelöst werden - dies muss Ruby-seitig abgefangen werden, damit diese Obergrenze eingehalten wird. Wenn man die Queries mit mehr als 1000 Einträgen in einer „IN“ Abfrage nicht vermeiden kann oder will, ist ein ActiveRecord Monkey Patch ein möglicher Ausweg.

# BUT BEWARE: This monkey patch is evil:
# On Arel::Nodes::In.new returns an instance of another class if right is > 1000
#
module Arel
  module Nodes
    class In < Equality
      def self.new left, right
        # If there are more than 100 values, split and combine clauses with OR
        if right.is_a?(Array) && right.flatten.count > 1000
          new_nodes = nil
          right.flatten.each_slice(1000) do |group|
            if new_nodes
              new_nodes = Arel::Nodes::Or.new(new_nodes, Arel::Nodes::In.new(left, group))
            else
              new_nodes = Arel::Nodes::In.new(left, group)
            end
          end
          return Arel::Nodes::Grouping.new(new_nodes)
        else
          super(left, right)
        end
      end
    end
  end
end

Dieser Monkey Patch teilt Abfragen mit mehr als 1000 Einträgen auf und konkateniert diese mit einem logischen „Oder“. Dadurch wird allerdings der Rückgabewert von Arel::Nodes::In zu Arel::Nodes::Grouping verändert. Das führt im Einsatz führt dieses MonkeyPatches in der Regel nicht zu Problemen. Die ActiveRecord::Relation “all_cities = City.where(zip:(1..1001).to_a)” funktioniert mit diesem Patch.

dbconsole und Oracle Database

Rails bietet den Befehl, dbconsole um das jeweilige Command Line Interface (CLI) der genutzten Datenbank zu öffnen. Oracles CLI ist sqlplus, welches im ORACLE_HOME Verzeichnis liegt. Daher muss die Umgebungsvariable ORACLE_HOME gesetzt sein und im PATH liegen. Der Befehl “sqlplus system/abcd123@192.168.1.142:1521/xe” verbindet dann dementsprechend zur Datenbank. Verbindungen können in einer tnsnames.ora gespeichert werden.

myDb  =
 (DESCRIPTION =
   (ADDRESS_LIST =
     (ADDRESS = (PROTOCOL = TCP)(Host = 192.168.1.142)(Port =1521))
   )
 (CONNECT_DATA =
   (SERVICE_NAME =xe)
 )
)

Wenn diese tnsnames.ora vorhanden ist, reicht "sqlplus system@myDb" + Kennworteingabe um zur Datenbank zu verbinden.
Ist die database.yml mit host, username und password konfiguriert (erster Teil der Grafik), erscheint beim Ausführen der Fehler “ORA-12154: TNS: Angegebener Connect Identifier konnte nicht aufgelöst werden”. Um diesen Fehler zu vermeiden,muss die database.yml mit einem Connection String - wie in der zweiten Grafik dargestellt - konfiguriert sein.

# default definition
development: &default
  adapter: oracle_enhanced
  host: 192.168.1.142
  port: 1521
  database: xe
  username: user
  password: password

# definition for a working dbconsole
development: &default
  adapter: oracle_enhanced
  database: "(DESCRIPTION=(ADDRESS_LIST=(ADDRESS=(PROTOCOL=tcp)(HOST=192.168.1.142)(PORT=1521)))(CONNECT_DATA=(SERVICE_NAME=xe)))"
  username: user
  password: password

Anschließend muss nur noch rails dbconsole -p ausgeführt werden und dann können Datenbankabfragen in der Entwicklungsdatenbank ausgeführt werden.

Integration von Rails-Anwendungen in Oracle Database-Systemlandschaften

Wenn bestimmte Unternehmensdaten in zentralen Datenbanken hinterlegt sind und man sich in der Situation befindet, diese Daten aus einer Rails-Anwendung nutzen zu müssen, bieten sich mehrere Möglichkeiten an. Bei allen Alternativen empfiehlt sich ein rein lesender Zugriff - solange eine andere Anwendung für die Pflege der Daten zuständig ist.

Eine Rails-Anwendung sollte in jedem Fall über ein eigenes Oracle Database Datenbank-Schema verfügen, in dem nur diese Anwendung Daten schreiben darf.

Abstraktion in einer eigenen Anwendung

Sollten die Daten in einer verteilten Umgebung in mehreren Anwendungen benötigt werden, bietet es sich an, die Daten über einen zentralen Service zu verteilen. Dazu wird bspw. eine zusätzliche Anwendung geschrieben, die exklusiven Zugriff auf die Daten hat und eine API anbietet, die von den anderen Anwendungen genutzt wird. Im Optimalfall bietet die Anwendung, die über die Datenhoheit verfügt, eine entsprechende API an.

api

Vorteile: Der Zugriff auf Legacy-Daten ist gekapselt und die Konsumenten benötigen keine aufwändige Konfiguration. Die Service App kann in einer beliebigen Programmiersprache umgesetzt werden.

Einbindung über Datenbank Links

Sollte es tatsächlich unumgänglich sein, dass auf die Legacy-Daten direkt in der Rails-Anwendung zugegriffen werden muss, können diese über Datenbank-Links von anderen Anwendungen bereitgestellt werden. Dabei empfiehlt es sich, die Berechtigungen so einzurichten, dass nur ein lesender Zugriff möglich ist.

SQL> create user read_only identified by password;
Benutzer wurde erstellt.
SQL> grant create session, select any table, select any dictionary to username;
Benutzerzugriff (Grant) wurde erteilt.

Um die Tabelle nahtlos im eigenen Schema zugänglich zu machen, kann eine View erstellt werden.

-- DDL für Datenbank Link
CREATE DATABASE LINK db_link
CONNECT TO username
IDENTIFIED BY "passwort"
USING 'legacy_database';

-- DML für Datenbank Link
SELECT * FROM legacy_table@db_link;
CREATE VIEW "own_schema"."legacy_table" ("column_1", "column_2") 
AS 
  SELECT column_1, column_2
  FROM legacy_table@db_link;

Nun kann über ein Rails Model auf diese Tabelle zugegriffen werden. Da Write-Methoden von ActiveRecord auf diesem "Model" auf einen Fehler laufen, sollte das Model als schreibgeschütztes Model definiert werden.

class LegacyClass < ActiveRecord::Base
  self.table_name = 'legacy_table'

  def readonly?
    true
  end
end

Vorteil: Es ist nur eine Datenbank-Verbindung notwendig.

Nachteil: Ein Datenbank-Link ist in der Regel nur im produktiven System verfügbar. In einem lokalen Testsystem steht der Datenbank Link nicht zur Verfügung. Daher sind Rails-environment spezifische Migrationen notwendig um die Tabelle in der schema.rb und in Entwicklungs-Umgebungen verfügbar zu machen.

Einbindung mehrerer Datenbanken in der database.yml

Legacy-Datenbanken können als weitere Quellen in der Datenbank-Konfiguration angegeben werden. Die zweite Datenbank sollte ebenfalls read only eingebunden werden. Das lässt sich am einfachsten über die Berechtigungen des Datenbank-Users realisieren.

Um die Konfigurationen für alle ActiveRecord Objekte der Legacy-Datenbank zu vereinheitlichen, bietet sich eine Oberklasse an, in der die notwendigen Einstellungen vorgenommen werden. (ExternalDatabase::Base siehe Gist)

class ExternalDatabase::Base < ActiveRecord::Base
  @abstract_class = true
  establish_connection "external_database_#{Rails.env}"

  # Set a table name prefix
  #
  # self.table_name_prefix= doesn't work here, as it is overwritten by table_name=
  #
  # new_name::  New table name
  def self.table_name=(new_name)
    prefix = connection.pool.spec.config[:table_prefix]
    super("#{prefix}#{new_name}")
  end

  def readonly?
    true
  end
end

In dieser wird über establish_connection die zweite Datenbank genutzt. Wenn die Tabellen in der Legacy-Datenbank nicht den Rails-Konventionen entsprechen, können in dieser Klasse entsprechende Vorkehrungen getroffen werden. (Beispiel: self.primary_key = "Legacy_id")

Testen

Wenn Legacy-Daten in eine Rails-Anwendung integriert werden, ist eine Einbindung in die Tests der Anwendung von zentraler Bedeutung. Die Tabellen sind nicht zwingend in der schema.rb enthalten, da sie nicht direkt zur Anwendung gehören. Beispieldaten können somit nicht direkt in der Test-Umgebung bereitgestellt werden. Um die Anwendung zu testen, werden aber die externen Tabellen benötigt. Um den Zugriff auf diese Tabellen zu simulieren, können beim Vorbereiten der Test-Datenbank die Tabellen-Strukturen über separate Schema-Files geladen werden. Ferner soll beschrieben werden, wie diese Schema-Files erstellt werden können.

Im Gist wird eine Verbindung zur entfernten Datenbank aufgebaut und die Strukturen der Tabellen werden in Schema-Dateien geladen, die beim Testen in die Datenbank integriert werden.

namespace :external_database do
  namespace :schema do
    desc "Dump all relevant ics tables to db/external_database_schema.rb"
    task :dump => :environment do
      connection = ExternalDatabase::Base.connection

      def connection.tables
        [
          ExternalDatabase::Customers,
          ExternalDatabase::Invoices
        ].map(&:table_name)
      end

      filename = Rails.root.join("db/external_database_schema.rb")

      File.open(filename, "w:utf-8") do |file|
        ActiveRecord::SchemaDumper.dump(connection, file)
      end
      content = File.read(filename).gsub('EXTERNAL_DB.', '').gsub(':null => false', ':null => true')
      File.open(filename, "w:utf-8") do |file|
        file.write(content)
      end
    end

    desc "Load all relevant external_database tables from db/external_database_schema.rb"
    task :load => :environment do
      path = Rails.root.join('db/external_database_schema.rb')
      ExternalDatabase::Base.connection.load(path)
    end
  end
end

Dabei kümmert sich der SchemaDumper im Task external_database:schema:dump von ActiveRecord um die Data Definition der entsprechenden Tabellen. Der rake-task zum Vorbereiten der Test-Datenbank kann um den Task external_database:schema:load erweitert werden.

namespace :db do
  namespace :test do
    task :prepare do
      path = Rails.root.join('db/external_database_schema.rb')

      # Connect to database that holds test schema of external_database
      # May be the same as the regular testdatabase
      ActiveRecord::Base.establish_connection('external_db_test')
      ActiveRecord::Base.connection.load(path)

      ActiveRecord::Base.establish_connection('test')

      load "#{Rails.root}/db/seeds.rb"
    end
  end
end

Wenn der task db:test:prepare wie im Gist erweitert wird, werden die Tabellen-Strukturen aus den Schema-Dateien in die Test-Datenbank für die externen Daten geladen. Diese kann mit der normalen test-Datenbank identisch sein, das muss entsprechend in der database.yml konfiguriert werden.

Vorteil dieser Lösung ist eine realistische Abbildung der verwendeten Tabellen. Jede Änderung, die in den Legacy-Tabellen vorgenommen wird, wird durch Aufruf von rake external_database:schema:dump db:test:prepare in der Testumgebung berücksichtigt.

Fazit

Ruby on Rails und Oracle Database können unter Beachtung der beschriebenen Besonderheiten reibungslos zusammenarbeiten. Größenbeschränkungen und fehlende Datentypen sind zunächst ungewohnt, fallen aber nach längerer Verwendung kaum noch ins Gewicht. Die Integrationszenarien von Legacy-Daten mit Rails Anwendungen können dabei helfen einen vorhandenen Datenpool möglichst wenig invasiv in neuen Anwendungen zu verwenden. Ruby und insbesondere Ruby on Rails eignen sich somit hervorragend als moderne agile Technologien in Unternehmen die auf Oracle-Datenbanken setzen.

Bildrechte Ruby on Rails Logo: Yukihiro Matsumoto, Ruby Visual Identity Team - http://rubyidentity.org/, CC BY-SA 2.5

Ihr sucht den richtigen Partner für eure digitalen Vorhaben?

Lasst uns reden.