13. Wsiadamy do trolejbusu

Ostatnio omówiliśmy sposób na przekazywanie informacji zdalnych, a także zaprezentowaliśmy przykład wykorzystania takiej informacji, wyświetlając listę wszystkich linii komunikacji miejskiej w Gdyni. Dziś podążymy dalej tym tropem i napiszemy dość toporne, ale pełne oprogramowanie do wyświetlania rozkładu jazdy.

Zacznijmy od posortowania

Ostatnio sortowaliśmy linie, wykonując kilka porównań. Jak wtedy pisałem, jest to rozwiązanie szybkie, ale mało eleganckie. Skoro chcemy napisać profesjonalny program, zróbmy to profesjonalnie. Sortować można różnie, ale najczęściej robi się to, w pierwszej kolejności podając linie numerowe, a potem te zaczynające się od liter, w kolejności alfabetycznej, na przykład 20, 38, 141, L1, L3, N4, N19, N21.

Najprościej będzie stworzyć więc dwie metody w naszej klasie “ZKMRoute”, podające odpowiednio numer linii i, jeśli istnieje, jej oznaczenie literowe.

def number
  return @line.split("").select{|s|s.to_i.to_s==s}.join("").to_i
end
def type
  return @line.split("").select{|s|s.to_i.to_s!=s}.join("")
end

Wykorzystaliśmy tu metodę “split”. Służy ona do dzielenia łańcucha znaków wedle danego separatora.

"Ala ma kota".split(" ")
:   #=> ["Ala", "ma", "kota"]
"Ala ma kota".split("")
:   #=> ["A", "l", "a", " ", "m", "a", " ", "k", "o", "t", "a"]

Podanie jako parametru pustego łańcucha znaków to najprostszy sposób na zamianę ciągu znaków na ich tablicę. Następnie wybieramy, zależnie od funkcji, wszystkie znaki będące (lub nie) cyframi i ponownie je łączymy znaną już metodą “join”. Można tu zauważyć operator “!=”. O ile “==” oznacza równość, “!=” wskazuje na nierówność.

Użycie formy

x.to_i.to_s==x

to dość prosty sposób na sprawdzanie, czy znak “x” jest cyfrą.

Uzbrojeni w powyższe dwie metody, możemy pierwszy raz zdefiniować własny operator porównania, czyli “<=>”.

def <=>(o)
  if !o.is_a?(ZKMRoute)
    return 0
  else
    n1=number
    t1=type
    n2=o.number
    t2=o.type
    if t1==t2
      return n1<=>n2
    else
      return t1<=>t2
    end
  end
end

Funkcja “<=>” przyjmuje jeden parametr. Jest to obiekt, z którym chcemy porównać. Przypomnę z jednej z poprzednich lekcji, że powinniśmy zwrócić:

  • -1, jeśli nasz obiekt poprzedza przekazany,
  • 0, jeśli są tożsame,
  • 1, jeśli przekazany obiekt poprzedza nasz.

Metoda “is_a?” pozwala sprawdzić, czy dany obiekt jest instancją wybranej klasy. Tu sprawdzamy, czy porównujemy linię komunikacji miejskiej z inną linią. Jeśli nie, zawsze zwracamy “0”, dzięki temu w ewentualnej tablicy można łączyć sortowanie linii z innymi obiektami.

Jeśli porównujemy dwie linie, sprawdzamy, czy są tego samego typu (kod literowy). Gdy tak, wykonujemy porównanie numerów, w przeciwnym wypadku sortujemy alfabetycznie typy.

Ktoś mógłby spytać, poco zapisaliśmy zmienne “n1”, “t1”, “n2” oraz “t2”, zamiast po prostu użyć form “number”, “type”, “o.number” i “o.type”. Wyjaśnienie jest dość proste. Wywołanie metod “number” oraz “type” jest stosunkowo kosztowne, wymaga zamiany napisu na tablicę, wybrania elementów z tablicy (wykonując dwie konwersje) i połączenia wyniku. Oczywiście nasz komputer wykonuje tę operację w ułamku sekundy, ale nie oznacza to, że nie możemy tego ułamka sekundy przyoszczędzić. Zwłaszcza że, jak mówi znane powiedzenie, “Ziarnko do ziarnka i pełna miarka”.

Już nie wsiadajmy do pojazdu byle jakiego!

Do tej pory pobieraliśmy jedynie listę dostępnych linii. Aby zbudować pełną informację o rozkładzie jazdy, będziemy potrzebować także listy przystanków i kursów. W praktyce, patrząc na dokumentację API, dowiemy się, że musimy sięgnąć do czterech zasobów:

  • routes, z którego uzyskaliśmy już listę linii,
  • stops, który da nam listę przystanków,
  • trips, który zawiera listę kursów danych linii,
  • stop_times, który poda czasy odwiedzania przystanków przez dane kursy.

W tym punkcie pojawia się kilka pytań dotyczących relacji pobieranych obiektów. Skąd możemy wiedzieć, który kurs jest prowadzony w ramach której linii? Różne API rozwiązują to w różny sposób, w wypadku informacji o Gdyńskiej Komunikacji Miejskiej dostajemy listę wszystkich obiektów, a co do czego należy sprawdzamy, porównując odpowiednie ID.

Pomyśl, zanim napiszesz

Jesteśmy już praktycznie uzbrojeni w wiedzę, jak pobrać dane z serwera. Będą one podzielone na cztery kolekcje:

  • Linii, zawierającą numery i nazwy dostępnych linii,
  • Przystanków, gdzie uzyskamy nazwy przystanków oraz ich współrzędne geograficzne,
  • Tras, która zawiera spis wszystkich prowadzonych przez daną linię kursów,
  • Czasów odwiedzin przystanków, która da nam listę odwiedzanych w danym kursie przystanków, ich czas oraz informację o tym, czy przystanek jest przystankiem na żądanie.

Najprościej byłoby po prostu utworzyć klasy reprezentujące każdą z tych informacji i wykorzystywać je analogicznie do przekazanej z serwera. Takie podejście jednak jest dość toporne i nie wykorzystuje możliwości, które daje nam programowanie obiektowe. Lepiej zastanowić się od razu, co nam będzie potrzebne. Zależnie od typu aplikacji, możemy rozwiązać to na wiele sposobów:

  • Możemy w każdej klasie linii umieścić listę tras, a w każdej trasie listę odwiedzanych przystanków,
  • Możemy odwrotnie: pobierać listę odwiedzanych przystanków, a do niej przypisać informacje o linii i kursie,
  • Możemy także zbudować graf (wersja dla ambitnych), w którym wierzchołkami będą przystanki, a krawędziami prowadzone kursy.

To tylko przykładowe rozwiązania, a sposobów organizacji naszego kodu jest o wiele więcej, a co ważne, nie ma jednego poprawniejszego od innych.

My dążymy do napisania programu wyświetlającego rozkład jazdy, tak więc wykorzystamy pierwszy sposób: do kolekcji linii przypiszemy trasy, zaś do tras odwiedzane przystanki. To skuteczny sposób wyświetlenia interesujących nas informacji, ale okazałby się całkiem niepraktyczny, gdybyśmy zamiast zwykłego wyświetlenia kursów, chcieli napisać planer trasy. – jak widać, zależnie od celu, różne sposoby mogą być słuszne.

Schemat

W programowaniu w tym punkcie często używa się schematów (na przykład języka UML), celem opisania zależności między różnymi obiektami. My nie musimy tego robić aż tak formalnie, ale zanim coś napiszemy, opowiedzmy sobie, co napiszemy.

ZKMRoute

To klasa napisana już wcześniej, choć nieco ją zmodyfikujemy. Zawiera ona informację o identyfikatorze, numerze oraz nazwie linii. Dodamy do niej również tablicę tras danej linii.

ZKMStop

Tu zapiszemy informację o przystankach, a więc ich identyfikator, nazwę, kod oraz współrzędne geograficzne. Gdybyśmy planowali napisać program do wyszukiwania połączeń, dobrym krokiem byłoby także dołączenie tu listy kursów zatrzymujących się na danym przystanku. Na razie tego nie uwzględnimy.

ZKMTrip

W tej klasie przechowamy informacje o danym kursie. Tak naprawdę to najważniejsza klasa, w niej umieścimy kolekcję informacji o przystankach na danym kursie, a także informacje o linii prowadzącej ten kurs.

ZKMStopTime

Ta klasa posłuży jako zbiór informacji o przystanku na trasie, z jednej strony wskaże nam na przystanek, z drugiej uzyska atrybuty informujące o czasie odjazdu i tym, czy przystanek jest przystankiem na żądanie.

Zależności

Do tej pory w naszych programach obiekt A zawsze wskazywał na obiekt B, a więc relacja była liniowa, zaczynała się w pewnym punkcie i kończyła w innym. Tutaj relacja jest cykliczna. Brzmi to skomplikowanie, ale, mam nadzieję, już rozjaśniam. Klasa “ZKMRoute” zawiera listę kursów danej linii, ale klasa “ZKMTrip” zawiera informację o linii prowadzącej taki kurs. Tak więc klasy wzajemnie na siebie wskazują. Podobnie klasa “ZKMTrip” zawiera listę przystanków “ZKMStopTime”, które posiadają informację o kursie. Gdybyśmy jednak uwzględnili w klasie “ZKMStop” informację o prowadzonych kursach, okazałoby się, że każda z utworzonych klas pozwala dostać się do każdej innej. Taką sytuację nazywamy “uwikłaniem”. Nie jest to nic złego, oznacza, że posiadając dowolny obiekt, możemy dotrzeć do każdego innego. Co więcej, gdybyśmy teraz zaczęli rozważać w definicjach programistycznych powstałe uwikłanie, moglibyśmy zauważyć, że niektóre obiekty będą zawierać kolekcje innych obiektów (linia listę kursów, kurs listę przystanków), natomiast inne tylko jeden (czas przystanku prowadzący go kurs, kurs dotyczącą go linie). Są to dwie najważniejsze relacje wymieniane w programowaniu: “One to one”, w której obiekt jest przyporządkowywany tylko do jednego innego obiektu, oraz “one to many”, gdzie jeden obiekt wskazuje na wiele innych. Takie wzajemne wskazywanie na siebie różnych obiektów jest sytuacją dość częstą w zaawansowanych programach, choć na początku może nieco mylić.

Jak to wygląda?

class ZKMRoute
  attr_accessor :id, :line, :name
  attr_reader :trips
  def initialize(id=0, line=nil, name=nil)
    @id, @line, @name = id, line, name
    @trips=[]
  end
  def number
    return @line.split("").select{|s|s.to_i.to_s==s}.join("").to_i
  end
  def type
    return @line.split("").select{|s|s.to_i.to_s!=s}.join("")
  end
  def <=>(o)
    if !o.is_a?(ZKMRoute)
      return 0
    else
      n1=number
      t1=type
      n2=o.number
      t2=o.type
      if t1==t2
        return n1<=>n2
      else
        return t1<=>t2
      end
    end
  end
  def add_trip(id, headsign, direction)
    trip=ZKMTrip.new(id, self, headsign, direction)
    @trips.push(trip)
    return trip
  end
  def sort_trips
    @trips.sort!
  end
end

class ZKMTrip
  attr_accessor :id, :route, :headsign, :direction
  attr_reader :stop_times
  def initialize(id, route, headsign, direction)
    @id, @route, @headsign, @direction = id, route, headsign, direction
    @stop_times=[]
  end
  def <=>(o)
    if !o.is_a?(ZKMTrip)
      return 0
    else
      return first_time<=>o.first_time
    end
  end
  def add_stoptime(stop, arrival_time, departure_time, sequence, headsign, on_demand)
    st = ZKMStopTime.new(stop, self, arrival_time, departure_time, sequence, headsign, on_demand)
    @stop_times.push(st)
    return st
  end
  def sort_stoptimes
    @stop_times.sort!
  end
  def first_time
    return "" if @stop_times.size==0
    return @stop_times[0].arrival_time
  end
end

class ZKMStop
  attr_accessor :id, :code, :name, :latitude, :longitude
  def initialize(id, code, name, latitude, longitude)
    @id, @code, @name, @latitude, @longitude = id, code, name, latitude, longitude
  end
end

class ZKMStopTime
  attr_accessor :stop, :trip, :arrival_time, :departure_time, :sequence, :headsign, :on_demand
  def initialize(stop, trip, arrival_time, departure_time, sequence, headsign, on_demand)
    @stop, @trip, @arrival_time, @departure_time, @sequence, @headsign, @on_demand = stop, trip, arrival_time, departure_time, sequence, headsign, on_demand
  end
  def <=>(o)
    if !o.is_a?(ZKMStopTime)
      return 0
    else
      return @arrival_time<=>o.arrival_time
    end
  end
end

Cudownie! Udało nam się napisać 83 linie kodu, które jeszcze absolutnie nic nie robią.

Pobieramy konkretne dane

Za każdym razem schemat pobierania danych będzie podobny: będziemy pobierać zawartość pewnego adresu URL, deserializować go jako JSON i w pewien sposób przetwarzać. Możemy napisać więc funkcję, która nam to ułatwi.

def zkm_load(endpoint)
  t=read_url("http://api.zdiz.gdynia.pl/pt/#{endpoint}")
  raise(IOError, "Cannot load ZKM data") if t==nil
  return JSON.load(t)
end

Warto zwrócić uwagę na funkcję “raise”. Poprzednio Grzegorz pisał o wyjątkach, w ten sposób możemy wywołać swój, notyfikując informację o jakimś problemie. “IOError” to jeden z typów wyjątków, służący do informowania o problemach z zapisem/odczytem, tu z odczytem danych z serwera.

Pobierzmy więc najważniejsze dla nas informacje:

j_routes = zkm_load("routes")
j_stops = zkm_load("stops")
j_trips = zkm_load("trips")
j_stoptimes = zkm_load("stop_times")

W pobranych tablicach nie mamy informacji zaprezentowanych w sposób zgodny z naszymi klasami, konkretne zależności są budowane przez informację o identyfikatorach. Na przykład, aby dowiedzieć się, którego przystanku dotyczy dany postój, musimy odnaleźć przystanek o identyfikatorze zgodnym z kluczem “stopId” tego postoju.

Tablice haszowe na pomoc

Moglibyśmy zapisać najpierw informację o wszystkich trasach, a następnie szukać tych tras i dodawać do nich odpowiednie kursy, i tak dalej. Niestety coś takiego nie jest najlepszym pomysłem. Choć bowiem na papierze wygląda to dobrze, w praktyce trwałoby to zbyt długo, bo oznaczałoby za każdym razem konieczność wielokrotnego przeszukiwania tablic. Tu na pomoc przychodzą nam tablice haszowe. Napiszemy więc funkcję, która utworzy wewnętrznie cztery tablice haszowe (linie, trasy, przystanki i czasy zatrzymania się na przystankach). W haszach tych kluczem będzie identyfikator, co pozwoli nam bardzo szybko przywoływać interesujące nas zasoby. Po zakończeniu wszystkiego, uzyskamy gotową tablicę tras.

def zkm_get_routes
  alert("Loading routes information")
  j_routes = zkm_load("routes")
  h_routes={}
  for r in j_routes
    route=ZKMRoute.new(r['routeId'], r['routeShortName'''], r['routeLongName'])
    h_routes[route.id]=route
  end

  alert("Loading stops information")
  h_stops={}
  j_stops = zkm_load("stops")
  for s in j_stops
    stop = ZKMStop.new(s['stopId'], s['stopCode'], s['stopName'], s['stopLat'], s['stopLon'])
    h_stops[stop.id]=stop
  end

  alert("Loading trips information")
  h_trips={}
  j_trips = zkm_load("trips")
  for t in j_trips
    route=h_routes[t['routeId']]
    trip = route.add_trip(t['tripId'], t['tripHeadsign'], t['directionId'])
    h_trips[trip.id] = trip
  end

  alert("Loading stop times information")
  h_stoptimes={}
  j_stoptimes = zkm_load("stop_times")
  for st in j_stoptimes
    trip=h_trips[st['tripId']]
    stop=h_stops[st['stopId']]
    stoptime = trip.add_stoptime(stop, st['arrivalTime'], st['departureTime'], st['stopSequence'], st['stopHeadsign'], st['onDemand']==1)
    h_stoptimes[stoptime.id] = stoptime
  end

  h_trips.values.each{|t|t.sort_stoptimes}
  h_routes.values.each{|r|r.sort_trips}

  return h_routes.values.sort
end

Ładujemy informacje o interesujących nas kategoriach, następnie wszystko sortujemy i zwracamy w eleganckiej tablicy, wypisując nawiasem mówiąc kolejne 36 linijek.

Na ekran!

Skoro udało nam się tym heroicznym wysiłkiem pozyskać informacje, na których nam zależało, zróbmy coś z nimi… Na przykład wyświetlmy.

Wyobraziłem sobie prosty formularz, który posiada listę linii, kursów i przystanków. Wybranie linii pokazuje dostępne na niej kursy, zaś kursu, przystanki. W jaki sposób jednak dynamicznie przeładowywać formularz?

Użyjemy tu zdarzenia “:move”, które jest wysyłane, gdy użytkownik przesunie kursor. Następnie podmienimy dostępne w listach wyboru opcje. Aby na początku formularz nie był pusty, sztucznie wywołamy odpowiednie zdarzenie poprzez funkcję “trigger”.

routes = zkm_get_routes

form = Form.new([
  lst_route = ListBox.new(routes.map{|r|"#{r.line} (#{r.name})"}, "Routes"),
  lst_trip = ListBox.new([], "Trips"),
  lst_stop = ListBox.new([], "Stops"),
  btn_close = Button.new("Close")
], 0, false, true)
lst_route.on(:move) {
  route = routes[lst_route.index]
  lst_trip.options = route.trips.map{|t|t.headsign+": "+t.first_time}
  lst_trip.index=0
  lst_trip.trigger(:move)
}
lst_trip.on(:move) {
  route = routes[lst_route.index]
  trip=route.trips[lst_trip.index]
  lst_stop.options = trip.stop_times.map{|t|
    headsign=t.stop.name
    l=headsign+": "+t.arrival_time
    l+=", on demand!" if t.on_demand
    l
  }
  lst_stop.index=0
}
lst_route.trigger(:move)
btn_close.on(:press) {form.resume}
form.cancel_button=btn_close

form.wait

Podsumowanie

Napisaliśmy dzisiaj 152 linie kodu, nieco więcej, gdy opakujemy to w program. Nasza aplikacja dalej jest dość toporna, ale dzięki zbudowaniu całej struktury relacji, od tej pory urozmaicanie interfejsu będzie czymś bardzo prostym. Można oczywiście pobrać, i przetestować, gotowy program. A w następnym odcinku zajmiemy się nieco lepszym zoptymalizowaniem naszego dzieła.
Pobierz przeglądarkę Rozkładu Jazdy ZKM Gdynia

3 komentarze

  1. Czy w Rubym jest coś na podobę LINQa? Chodzi o to że twoje metody sortowania i tak będą wolniejsze od tych teoretycznie wbudowanych w język

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *