Słowo się rzekło. Minęło pół roku od czasu gdy napisałem bardzo prosty program do sprawdzania cen produktów w sklepie i teraz przyszła pora na udostępnienie kodu źródłowego.
Jak pisałem, szukałem prostej i niezawodnej metody połączenia dwóch aplikacji. Najważniejszym warunkiem była stabilność bo inne czynniki przy tak prostej aplikacji nie są zbyt ważne.
Zastanawiałem się długo nad technologią, robiłem też testy i doszedłem do wniosku popartym kilkumiesięczną pracą aplikacji że użyję kontrolek z pakietu indy czyli TidUDPServer oraz TIdUDPClient.
No dobrze, przejdźmy do rozwiązania. Program oczywiście składa się z dwóch elementów, czyli serwer oraz klient (klienci).
Na początek kod źródłowy programu serwer (całość znajduje się w jednym unicie):
unit Unit1;
interface
uses
Windows, Messages, SysUtils, Variants, Classes, Graphics, Controls, Forms, Dialogs, IdComponent, IdUDPBase, IdUDPServer, DB, ADODB, IdSocketHandle, IdGlobal, StdCtrls,
IdUDPClient, SMCVersInfo, ExtCtrls, IniFiles, IdBaseComponent, ImgList, AppEvnts, RXClock, ShellAPI;
type
Tmain = class(TForm)
con1: TADOConnection;
qrytowary: TADOQuery;
DStowary: TDataSource;
Serwer: TIdUDPServer;
Klient: TIdUDPClient;
mmo1: TMemo;
grp1: TGroupBox;
smvrsnf1: TSMVersionInfo;
tryicon1: TTrayIcon;
il1: TImageList;
ApplicationEvents1: TApplicationEvents;
rxclck1: TRxClock;
procedure SerwerUDPRead(AThread: TIdUDPListenerThread; AData: TBytes; ABinding: TIdSocketHandle);
procedure FormCreate(Sender: TObject);
procedure FormCloseQuery(Sender: TObject; var CanClose: Boolean);
procedure tryicon1DblClick(Sender: TObject);
procedure ApplicationEvents1Minimize(Sender: TObject);
procedure FormHide(Sender: TObject);
procedure rxclck1Alarm(Sender: TObject);
private
{ Private declarations }
public
{ Public declarations }
end;
var
main: Tmain;
log: TextFile;
logowanie: Boolean;
INI: TIniFile;
implementation
{$R *.dfm}
function zapisLog(komunikat: string): Boolean;
begin
if not DirectoryExists(ExtractFilePath(Application.ExeName) + 'log') then // sprawdzenie czy folder istnieje
MkDir(ExtractFilePath(Application.ExeName) + 'log'); // jeśli nie - tworzymy
AssignFile(log, ExtractFilePath(Application.ExeName) + 'log\log.txt');
if not FileExists(ExtractFilePath(Application.ExeName) + 'log\log.txt') then // sprawdzenie czy plik istnieje
begin
ReWrite(log); // jeżeli nie - stwórz
Writeln(log, DateTimeToStr(Now) + #09 + komunikat);
end
else
begin
Append(log); // jeżeli tak - otwórz
Writeln(log, DateTimeToStr(Now) + #09 + komunikat);
end;
CloseFile(log);
Result := True;
end;
procedure Tmain.ApplicationEvents1Minimize(Sender: TObject);
begin
Hide();
WindowState := wsMinimized;
tryicon1.Visible := True;
tryicon1.Animate := True;
tryicon1.ShowBalloonHint;
if logowanie then
zapisLog('Zminimalizowano aplikację do zasobnika');
end;
procedure Tmain.FormCloseQuery(Sender: TObject; var CanClose: Boolean);
begin
case Application.MessageBox('Zakończyć Program?', 'Uwaga!', MB_YESNO + MB_ICONQUESTION + MB_DEFBUTTON2 + MB_TOPMOST) of
IDYES:
begin
if logowanie then
zapisLog('Zakończono program');
Serwer.Active := False;
Klient.Active := False;
if con1.Connected then
con1.Connected := False;
end;
IDNO:
begin
CanClose := False;
end;
end;
end;
procedure Tmain.FormCreate(Sender: TObject);
begin
main.Left := Screen.Width - main.Width - 50;
main.Top := 50;
main.Caption := 'PSerwer - wersja: ' + smvrsnf1.FileVersion;
INI := TINIFile.Create(ExtractFilePath(Application.ExeName) + 'pserwer.ini');
try
// inne
logowanie := INI.ReadBool('inne', 'logowanie', True);
finally
INI.Free;
end;
con1.ConnectionString := 'FILE NAME=' + ExtractFilePath(Application.ExeName) + 'link.udl';
if logowanie then
zapisLog('Uruchomiono program');
tryicon1.BalloonTitle := 'Przywróć program!';
tryicon1.BalloonHint := 'Kliknij dwukrotnie aby przywrócić program.';
tryicon1.BalloonFlags := bfInfo;
Application.ShowMainForm := False;
end;
procedure Tmain.FormHide(Sender: TObject);
begin
Application.Minimize;
end;
procedure Tmain.rxclck1Alarm(Sender: TObject);
begin
Application.Terminate;
ShellExecute(Handle, 'Open', PChar(Application.ExeName), nil, nil, SW_NORMAL);
end;
procedure Tmain.SerwerUDPRead(AThread: TIdUDPListenerThread; AData: TBytes; ABinding: TIdSocketHandle);
var
pytanie, ip, kod, nazwa, cena: string;
port: string;
S: TStrings;
i: Integer;
begin
pytanie := bytestoString(Adata);
S := TStringList.Create;
ExtractStrings([#09], [], PChar(pytanie), S);
ip := S[0];
port := S[1];
kod := S[2];
S.Free;
mmo1.Lines.Add(DateTimeToStr(Now) + #09 + ip + #09 + port + #09 + kod);
if logowanie then
zapisLog('Stanowisko ip: ' + ip + ', zapytało o kod kreskowy: ' + kod);
if kod <> '' then
begin
if not con1.Connected then
con1.Connected := True;
with qrytowary, SQL do
begin
Close;
Clear;
Add('select t.nazwa, t.ce_sb, t.kodpaskowy, k.kodkreskowy from towary t inner join towarykodykreskowe k on t.id = k.idtowaru where t.kodpaskowy=:k1 or k.kodkreskowy=:k2');
Parameters.ParamByName('k1').Value := kod;
Parameters.ParamByName('k2').Value := kod;
Open;
end;
Klient.Active := False;
Klient.Port := StrToInt(port);
Klient.Host := ip;
Klient.Active := True;
if qrytowary.RecordCount > 0 then
begin
nazwa := qrytowary.FieldByName('nazwa').AsString;
cena := qrytowary.FieldByName('ce_sb').AsString;
end
else
begin
nazwa := '--brak towaru--';
cena := '0';
end;
if con1.Connected then
con1.Connected := False;
Klient.Send(nazwa + #09 + cena);
Klient.Active := False;
tryicon1.BalloonTitle := 'Nowe pytanie o kod!';
tryicon1.BalloonHint := 'Kod: ' + kod + #13 + 'Towar: ' + nazwa;
tryicon1.BalloonFlags := bfInfo;
tryicon1.Visible := True;
tryicon1.Animate := True;
tryicon1.ShowBalloonHint;
end;
pytanie := '';
end;
procedure Tmain.tryicon1DblClick(Sender: TObject);
begin
tryicon1.Visible := False;
Show();
WindowState := wsNormal;
Application.BringToFront();
if logowanie then
zapisLog('Kliknięto pokaż program');
end;
end.
Krótki opis, program łączy się z bazą programu KCFirma poprzez ADO. KCFirma używa bazy MS Access (wiem - katastrofa). Łatwo jednak zauważyć że można wymienić link konfiguracyjny na inną bazę i program stanie się bardzo uniwersalny (no wypadałoby jeszcze dostosować selecta w zapytaniu).
Program nie nawiązuje połączenia z bazą do czasu pojawienia się pytania z kodem kreskowym. Powód tego jest taki że ustalenie połączenia z bazą danych powoduje blokadę np. optymalizacji bazy a niestety dość często trzeba ją wykonywać dla poprawy wydajności, są po prostu procedury wymagające dostępu do bazy w trybie exclusive.
Program ma prosty interfejs w którym widać ostatnie pytania o kod kreskowy. Program po uruchomieniu ląduje w zasobniku systemowym i tylko hint informuje że na jakimś czytniku pojawiło się pytanie o kod kreskowy.
W programie jest też prosta funkcja do zapisu logu (w celach kontrolnych) gdyby kogoś to interesowało.
Podstawowe działanie programu znajduje się w procedurze SerwerUDPRead, reszta to tak naprawdę otoczka.
- Na początku do zmiennej "pytanie" typu string rzutuję dane odczytane przez kontrolkę UDPRead gdy się pojawią na odpowiednim porcie.
- Z danych tych tworzę listę. Czy to nie jest przerost formy nad treścią? Wydaje mi się że jednak nie ze względu na to że daje to prosty sposób na rozbudowę tego prostego protokołu. Aktualnie w ramce z danymi znajduje się IP komputera który wysłał pytanie, port na jakim będzie nasłuchiwał odpowiedzi oraz kod kreskowy o który klient pyta. Takie rozwiązanie powoduje że dołączenie dodatkowych czytników cen jest wyjątkowo proste i nie wymaga żadnej konfiguracji po stronie serwera. Oczywiście ilość klientów też przestaje być ograniczeniem bo portów wolnych jest mnóstwo.
- następnie wyświetlam pytanie o kod w kontrolce memo, zapisuję do logu (jeśli tak jest ustawione),
- teraz pora na połączenie z bazą i zapytanie o kod kreskowy. Na początek sprawdzamy czy kod nie jest pusty (można by to bardziej rozbudować i sprawdzić czy kod spełnia warunki ean8 lub ean13 ale program ma być prosty), następnie select na tabelach (w przypadku kcfirmy kody kreskowe znajdują się w dwóch tabelach stąd join w zapytaniu). Ktoś mógłby zapytać dlaczego są dwa parametry w zapytaniu k1 i k2 a wskazują na ten sam kod kreskowy. Jest chyba jakiś błąd w ADO że nie można użyć dwukrotnie tego samego parametru, w przypadku starego BDE, FireDac itp. tego problemu nie ma. Jednak wybrałem ADO ze względu na proste połączenie linkiem udl i dobrą współpracę z plikami mde.
- następny etap to ustawienie TIdUDPClient w tryb nieaktywny aby można było podać parametry połączenia, czyli port i adres IP (kontrolka daje się ustawiać gdy jest nieaktywna). Po tym ponownie ustawiam kontrolkę na aktywną i jest gotowa do wysłania komunikatu.
- następnie sprawdzam czy select na bazach dał jakiś wynik, jeśli tak to do zmiennej "nazwa" i "cena" wpisuję dane z bazy. W przeciwnym wypadku do zmiennej "nazwa" wpisuję -- brak towaru -- a do zmiennej "cena" wartość zero.
- pozostaje sprawdzić czy jest aktywne połączenie z bazą i jeśli jest aktywne to się odłączyć.
- przedostatni etap to wysyłka danych poprzez TIdUDPClient (tu nazwany "Klient"). Dane wysyłane w sposób szeregowy: nazwa, tabulator, cena). Oczywiście łatwo rozbudować program o inne dane.
- ostatni etap to: dezaktywacja "Klienta" i wyświetlenie balonika w zasobniku.
W programie jest jeszcze procedura resetująca aplikację o określonej godzinie. Wrzucam takie coś do programów mających ze swojej natury działać miesiącami bez nadzoru. Procedura taka oparta jest na kontrolce TRxClock z pakietu Jedi. Bardzo proste i dość skuteczne ponieważ korzysta z API systemowego. Można też użyć zwykłego timera albo po prostu w ogóle tego nie używać.
Na koniec pozostają procedury do minimalizacji aplikacji do zasobnika oraz przywróceniu po dwukliku.
Może jeszcze tylko wytłumaczę na koniec dlaczego nie połączyłem aplikacji w inny sposób lub po prostu czytnik cen nie podłączyłem bezpośrednio do bazy. W przypadku bazy sql (nie ważne w jakiej technologii) jest to oczywiście proste i być może nawet lepsze (chociaż konfiguracja byłaby troszkę bardziej zaawansowana no chyba że będzie plik konfiguracyjny który będzie można przekopiować). Należałoby tylko zadbać o to aby utworzyć użytkownika bazy z bardzo małymi prawami (najlepiej tylko do odczytu i to do konkretnej tabeli (tabel)). Pamiętać należy że taki czytnik wiszący w sklepie może zostać np. skradziony i lepiej aby nie było w nim informacji o dostępie do bazy danych.
W przypadku KCFirmy sprawa jest dużo bardziej skomplikowana, wymienię wszystkie wady takiego rozwiązania (nie mam na to wpływu):
- Jest to baza MS Access. Baza taka w zasadzie jest nie do zabezpieczenia i to jest pierwszy argument na program pośredniczący.
- Drugi to dostęp do bazy jak do pliku. Czyli aby się do niej podłączyć należy mieć fizyczny dostęp do pliku. Oczywiście istnieje odbc ale ono także musi mieć fizyczny dostęp do pliku (może być w sieci). Udostępnianie folderu z plikiem bazy w czasach gdzie szyfrowanie dysków przez wirusy jest standardem jest nieprawdopodobną niefrasobliwością. Wiem, można udostępnić tylko do odczytu ale ludzie są omylni i zawsze coś się może komuś włączyć.
- Następna wada to wydłużony start aplikacji przy rosnącym pliku bazy danych. Normalne przy bazach plikowych. Przy wolnym komputerze czytnika (a w zasadzie tablecie), być może wolnej sieci, start może się bardzo wydłużyć, program na szybkim serwerze startuje błyskawicznie.
- Blokowanie niektórych operacji na bazie przy normalnym podłączeniu. Jak pisałem, dość często trzeba plik bazy optymalizować i jego blokada na czytnikach bardzo by przeszkadzała. Należałoby połączyć się z każdym czytnikiem i wyłączyć program. To kosztuje czas a dodatkowo rozwiązania takie jak vnc nie zawsze działają niezawodnie.
W tym poście opisałem tylko część "serwerową" aplikacji. Jak pisałem, należy traktować ten programik jako bazę do napisania czegoś znacznie lepszego. Przede wszystkim brakuje pułapek na błędy oraz rozbudowania protokołu chociażby o inne dane o towarze czy np. połączenia z jakimś programem lojalnościowym i podawania ilości zgromadzonych punktów na jakiś kartach (aby był na nich kod kreskowy). Można tez rozszerzyć protokół o możliwość konfiguracji czytników. Wszystko przed chętnymi.
Dlaczego to wszytko piszę? Troszkę dla edukacji. Być może się komuś przyda. Najlepiej by było gdyby administrator przeniósł ten post do grupy Newbie bo tam jest chyba jego miejsce?
W następnym poście opiszę część kliencką aplikacji.
Acha, najważniejsze, zezwalam na użycie mojego kodu w celach edukacyjnych, prywatnych jak i firmowych oraz wszelkie zmiany jakie przyjdą komukolwiek do głowy.
Wygląd testowego czytnika:
kilkusekundowy filmik jak to działa
Pozdrawiam
Robert