[C++/Linux] Communiceren met CLI applicaties

Pagina: 1
Acties:

  • Remenic
  • Registratie: Juni 2001
  • Laatst online: 09-08 20:06
Ik wil een GUI schrijven voor een CLI programma dat vergelijkbaar is met 'bc'. Het programma wacht oneindig op input, en zal op input reageren met output (naar stdout). Wanneer als input 'quit' wordt gegeven zal het programma zichzelf afsluiten.

Nu ben ik dus op zoek naar een manier om binnen de GUI een proces te starten en vervolgens te kunnen communiceren met dit proces (bijvoorbeeld 'bc'). Heeft iemand enig idee hoe ik dit moet aanpakken?

Ik heb al gekeken naar popen(), maar deze functie kan alleen lezen of schrijven. Ik wil dus bijde tegelijkertijd kunnen.

Dit programma zal draaien in een Linux omgeving. Het liefst maak ik geen gebruik van KDE's KProcess class.

Alvast bedankt.

Verwijderd

Is het programma niet opensource, of is het ook niet in de vorm van een library beschikbaar.
Indien ja kun je de command line interface weghalen van de source en vervangen door je eigen gui die dan de juiste routines oproept.

  • .oisyn
  • Registratie: September 2000
  • Laatst online: 04:06

.oisyn

Moderator Devschuur®

Demotivational Speaker

Ik heb al gekeken naar popen(), maar deze functie kan alleen lezen of schrijven. Ik wil dus bijde tegelijkertijd kunnen.
Je kunt met pipe () 2 keer een pipe creeeren, en dan voor jouw programma de stdin en stdout binden aan de einden van de 2 pipes, een child process spawnen (met fork ()), en dan stdin en stdout verbinden met de 2 andere einden van de pipe, en dan met exec () het juiste programma starten

Give a man a game and he'll have fun for a day. Teach a man to make games and he'll never have fun again.


  • Soultaker
  • Registratie: September 2000
  • Laatst online: 01:56
offtopic:
Beetje stompzinnige beperking van popen onder Linux, als je het mij vraagt. Nu moet iedereen z'n eigen bidirectionele pipe in elkaar gaan knutselen, wat niet eenvoudig is.

Onder BSD kan ik wel een fatsoenlijke bidirectionele pipes openen met popen, net als wanneer ik de popen functies van Python gebruik (dus ook onder Linux of Windows!). Dat scheelt echt ontzettend veel gedoe. Naar mijn mening was het een stuk eenvoudiger geweest als Linux gewoon een functie was voor bidirectionele pipes kende; dan hoefde je je als applicatieprogrammeur niet met allerlei moeilijk portable constructies als het dupliceren van file descriptors bezig te houden.

En dan heb je alleen de oplossing voor Linux, want onder Windows moet het weer anders (zonder fork() alleen al).

[ Voor 9% gewijzigd door Soultaker op 30-09-2003 19:01 ]


  • .oisyn
  • Registratie: September 2000
  • Laatst online: 04:06

.oisyn

Moderator Devschuur®

Demotivational Speaker

Die extra moeite valt toch ook wel mee?

Linux
C++:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
enum
{
    SERVER_READ,
    CLIENT_WRITE,
    CLIENT_READ,
    SERVER_WRITE
};

int main ()
{
    int pipes[4];
    pid_t client;
    pipe (pipes, 256, 0);
    pipe (pipes + 2, 256, 0);

    if (!client = fork ())
    {
        dup2 (0, pipes[CLIENT_READ]);
        dup2 (1, pipes[CLIENT_WRITE]);

        execl ("command", ...);
    }

    // lees en schrijf hier gewoon van pipes[SERVER_READ] en pipes[SERVER_WRITE]
}



Windows
C++:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int main ()
{
    HANDLE serverRead, serverWrite;
    HANDLE clientRead, clientWrite;

    CreatePipe (&serverRead, &clientWrite, NULL, 0);
    CreatePipe (&clientRead, &serverWrite, NULL, 0);

    STARTUPINFO sInfo = { sizeof (sInfo) };
    sInfo.dwFlags = STARTF_USESTDHANDLES;
    sInfo.hStdInput = clientRead;
    sInfo.hStdOutput = clientWrite;
    sInfo.hStdOutput = GetStdHandle (STD_ERROR_HANDLE);

    PROCESS_INFORMATION pInfo = { };

    CreateProcess ("command", NULL, NULL, NULL, TRUE, 0, NULL, NULL, &sInfo, &pInfo);

    // lees en schrijf van serverRead en serverWrite
}


(ok, een functie typen is makkelijker, maar dit kun je in principe zelf ook wel in een functie gieten ;))

Give a man a game and he'll have fun for a day. Teach a man to make games and he'll never have fun again.


  • Soultaker
  • Registratie: September 2000
  • Laatst online: 01:56
Nu heb je, in ieder geval in de Linux variant, gemakshalve even aangenomen dat alle pipe(), exec;(), fork() en dup2() calls lukken. Voor een robuuste implementatie (zoals van popen!) heb je helaas wat meer error checking nodig. Ook mis je nog de corresponderende pclose operatie. De BSD implementatie van popen/pclose is zo'n 150 regels code; dat kan misschien wel ietsje korter, maar ik betwijfel of je minder dan 50 regels code krijgt.

Verder hou je altijd het probleem dat je twee file descriptors moet gebruiken in plaats van gewoon een. Misschien vind je het gezeur, maar ik vind het altijd lastig om te bedenken of de 'input' pipe nu die is waar het programma z'n input op ontvangt (en waar ik in moet schrijven) of omgekeerd, en natuurlijk hetzelfde met de 'output' pipe.

Al met al is een bidirectionele pipe uit popen() naar mijn mening dus veel eenvoudiger. Als het niet zou kunnen of moelijke gevolgen had voor de achterliggende structuur houdt 't natuurlijk op, maar volgens mij is dat allemaal niet het geval.

  • .oisyn
  • Registratie: September 2000
  • Laatst online: 04:06

.oisyn

Moderator Devschuur®

Demotivational Speaker

Vergat ik er idd bij te zeggen, errorchecking heb ik achterwege gelaten, en ik heb ook niet de juiste headers geinclude :)

maar 150 regels code??? wat doen ze daar allemaal dan? Ik heb even geen zin om een 100% correcte implementatie te schrijven, maar dat moet toch wel met minder dan 50 regels kunnen :?

Give a man a game and he'll have fun for a day. Teach a man to make games and he'll never have fun again.


  • Remenic
  • Registratie: Juni 2001
  • Laatst online: 09-08 20:06
Dank je wel voor de pointers, .oisyn! Maar ik zit nog steeds vast... :P

Ik ben nu in staat om iets te sturen naar de client, maar de output van de client lezen lukt niet...

Als ik het goed begrijp, wordt alle data die de server door SERVER_WRITE stuurt door de client via CLIENT_READ ontvangen. En alles wat de client op 't scherm wilt zetten stuurt hij via CLIENT_WRITE naar de server die het kan lezen via SERVER_READ.

Dus:
CLIENT_WRITE is gekoppeld aan STDOUT -> lezen via SERVER_READ
CLIENT_READ is gekoppeld aan STDIN -> input sturen via SERVER_WRITE

Toch blijft mijn read() hangen op de SERVER_READ descriptor.

Hier is de code die ik tot nu toe heb, zie jij wat er fout gaat?
C++:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
enum
{
    SERVER_READ,
    CLIENT_WRITE,
    CLIENT_READ,
    SERVER_WRITE
};

int main()
{
    int pipes[4];
    pid_t client;
    int status;

    pipe(pipes);
    pipe(pipes+2);

    if ( ! (client = fork() ) )
    {
        // client

        close( pipes[CLIENT_WRITE]);
        dup2(pipes[CLIENT_READ], 0 );
        dup2(pipes[CLIENT_WRITE], 1 );

        execl( "/usr/bin/bc", 0 );
    }

    char tmp[128];
    read( pipes[SERVER_READ], tmp, 128);
    cout << tmp << endl;

    write( pipes[SERVER_WRITE], "quit\n", 5 );

    wait( &status );

}



Als ik write( pipes[SERVER_WRITE], "quit\n", 5 ); verander in iets anders, sluit de client nooit af. Dus ik ga er vanuit dat dit ook echt werkt :)

  • .oisyn
  • Registratie: September 2000
  • Laatst online: 04:06

.oisyn

Moderator Devschuur®

Demotivational Speaker

C++:
1
close( pipes[CLIENT_WRITE]); 

waarom close je de pipe daar?

dan valt er natuurlijk ook niets meer te lezen, de client kan immers niets meer schrijven (z'n stdout is geclosed)

Give a man a game and he'll have fun for a day. Teach a man to make games and he'll never have fun again.


  • Remenic
  • Registratie: Juni 2001
  • Laatst online: 09-08 20:06
.oisyn schreef op 30 September 2003 @ 21:37:
waarom close je de pipe daar?

dan valt er natuurlijk ook niets meer te lezen, de client kan immers niets meer schrijven (z'n stdout is geclosed)
Omdat ik die per ongeluk heb laten zitten... :+ Ik zat wat te experimenteren met de stdout niet via een pipe naar de server te sturen, maar rechtstreeks naar stdout. Dat lukte niet, maar hoeft ook niet te werken.

Ik heb deze close() weggehaald, en opnieuw geprobeert te lezen met
C++:
1
2
3
char tmp[128];
read( pipes[SERVER_READ], tmp, 128);
cout << tmp << endl;


maar hij blijft oneindig lang wachten. Enig idee waarom?

  • .oisyn
  • Registratie: September 2000
  • Laatst online: 04:06

.oisyn

Moderator Devschuur®

Demotivational Speaker

let er trouwens op dat je tmp eindigt met een terminating /0 char.
Dus lees 1 byte minder dan je buffer groot is, en bepaal aan de hand van het resultaat van read () waar die 0 moet komen

Maar als je die read in z'n geheel weglaat, gaat het dan wel goed?

Give a man a game and he'll have fun for a day. Teach a man to make games and he'll never have fun again.


  • Remenic
  • Registratie: Juni 2001
  • Laatst online: 09-08 20:06
Yep, zonder die read gaat 't prima. Met die read blijft ie oneindig lang wachten, zonder die read gaat ie meteen door en verstuurt hij ook echt de data naar de client.

Alleen lezen gaat dus niet goed.

  • .oisyn
  • Registratie: September 2000
  • Laatst online: 04:06

.oisyn

Moderator Devschuur®

Demotivational Speaker

Hmm, en als je minder dan 128 bytes leest? 10 ofzo?
Heeft het programma uberhaupt wel output?

Give a man a game and he'll have fun for a day. Teach a man to make games and he'll never have fun again.


  • Soultaker
  • Registratie: September 2000
  • Laatst online: 01:56
.oisyn schreef op 30 September 2003 @ 20:03:
maar 150 regels code??? wat doen ze daar allemaal dan? Ik heb even geen zin om een 100% correcte implementatie te schrijven, maar dat moet toch wel met minder dan 50 regels kunnen :?
Beetje commentaar, paar extra definities, paar includes, beetje extra code om op een nette manier de Bourne shell te vinden, beetje error handling. Allemaal kleine dingetjes om de code 'af' te maken. Nog een beetje thread-safety ook, want deze implementatie werkt op libc-nivo (en gebruikt dus functies van wat lager nivo dan jij deed, waardoor bijvoorbeeld de file handle nog los geregistreerd moet worden).

Je kunt het hier zelf zien in de CVS versie van popen.c. Wel vrij platformafhankelijk, natuurlijk, maar het illustreert wel dat het niet direct triviaal is om het goed te doen. Als deze functionaliteit vaak op dezelfde manier gebruikt wordt (wat volgens mij het geval is) dan is de functie uitermate geschikt om in de API van het besturingssysteem op te nemen, naar mijn mening. Zeker als het besturingssysteem meerwaarde kan creëren: een enkele file descriptor die voor zowel lezen als schrijven gebruikt kan worden, in plaats van twee aparte file descriptors met alle verwarring van dien.

  • Soultaker
  • Registratie: September 2000
  • Laatst online: 01:56
Remenic schreef op 30 September 2003 @ 22:46:
Yep, zonder die read gaat 't prima. Met die read blijft ie oneindig lang wachten, zonder die read gaat ie meteen door en verstuurt hij ook echt de data naar de client.

Alleen lezen gaat dus niet goed.
Notoir probleem is het lezen van uitvoer van commando's die hun output niet flushen. Het is een beetje programma-afhankelijk wanneer dat gebeurt; onder FreeBSD 5 is het bijvoorbeeld zo dat 'cat' na elke regel de uitvoer flusht, wat bijzonder handig is voor het gebruik in zo'n situatie. Een andere utillity, zoals 'cut', doet dat bijvoorbeeld weer niet. Iets als 'cut' is dus min of meer onbruikbaar als je om de beurt een regel wilt schrijven en het resultaat uit wilt lezen; dat werkt gewoon niet!

Hoewel dit specifieke voorbeeld voor FreeBSD 5 geldt, blijft het fundamentele probleem (dat de applicatie die wordt aangeroepen bepaald wanneer geflusht en wanneer gebuffert wordt) onder andere besturingssystemen wel bestaan.

  • .oisyn
  • Registratie: September 2000
  • Laatst online: 04:06

.oisyn

Moderator Devschuur®

Demotivational Speaker

volgens de manual van read () is die niet blocking, je moet dus altijd een resultaat krijgen. De returnvalue van read () is vervolgens het aantal gelezen bytes...

Dus ook al wordt er niets geoutput dan zou ie toch nog door moeten gaan, imho

Give a man a game and he'll have fun for a day. Teach a man to make games and he'll never have fun again.


  • Soultaker
  • Registratie: September 2000
  • Laatst online: 01:56
.oisyn schreef op 01 oktober 2003 @ 00:55:
volgens de manual van read () is die niet blocking, je moet dus altijd een resultaat krijgen. De returnvalue van read () is vervolgens het aantal gelezen bytes...

Dus ook al wordt er niets geoutput dan zou ie toch nog door moeten gaan, imho
Waar baseer je dat op? Ik kan dat namelijk niet zo 1-2-3 terugvinden. In mijn FreeBSD (sorry ;)) man-page staat:
Groff:
1
2
3
4
5
6
7
ERRORS
     Read(), readv(), and pread() will succeed unless:

[...]

     [EAGAIN]           The file was marked for non-blocking I/O, and no data
                        were ready to be read.

Wat suggereert dat 't er maar net vanaf hangt of de file descriptor in blocking of non-blocking mode staat. Dat kan je natuurlijk aanpassen met fcntl, maar daarmee is het default gedrag niet gegarandeerd (zeker niet aangezien je die file descriptor wel zo'n beetje overal vandaan kunt hebben).

  • .oisyn
  • Registratie: September 2000
  • Laatst online: 04:06

.oisyn

Moderator Devschuur®

Demotivational Speaker

woeps my bad, ik keek in de MSDN (schaam) :D
de man page van read onder debian zegt idd ook dat dat gedrag te veranderen is.

Goed, maar dan zijn we er toch imho (mits dat idd het probleem was idd)? De pipe is blijkbaar blocking, wat met fcntl te wijzigen is in non-blocking

Give a man a game and he'll have fun for a day. Teach a man to make games and he'll never have fun again.


  • Remenic
  • Registratie: Juni 2001
  • Laatst online: 09-08 20:06
Ik zat hier vannacht in me bedje ook aan te denken (dat 't een blocking "socket" is). Ik zal 'm zo proberen om 'm op nonblocking te zetten, even kijken of het dan wel goed gaat.

  • Soultaker
  • Registratie: September 2000
  • Laatst online: 01:56
Non-blocking mode is inderdaad een goede optie. Ik vind het zelf erg jammer dat je dan nog geen timeout kunt specificeren; je zult dus zelf iets met timing en polling moeten doen. Simpelweg code als deze gebruiken werkt niet goed:
C:
1
2
3
4
5
6
7
8
9
10
11
12
13
char buffer[1024];
int size;
FILE *fp = popen("bc", "r+");
fcntl(fp, F_SETFL, O_NONBLOCK);
fwrite(fp, "666*666+666\n");
fflush(fp);
size = fread(buffer, 1, sizeof(buffer), fp);
if(size < 0)
    ...; /* report error */
if(size == 0)
    ...; /* no data available */
else
    ...; /* buffer contains 'size' bytes */

Hoewel dat in de praktijk waarschijnlijk nog 'vaak' goed zou kunnen gaan. In een multiprogrammed omgeving is er echt geen enkele garantie dat de applicatie die je aanroept het resultaat beschikbaar heeft, direct nadat jij je uitvoer naar het proces toe hebt geflushed. Met blocked I/O hoef je je daar geen zorgen over te maken.

Het gebruik van select ligt dan, wat mij betreft, meer voor de hand; dan kun je tenminste een zinnige timeout specificeren, alvorens je concludeert dat de gegevens niet beschikbaar zijn. Timing laat je dan aan de implementatie van select over. Zo iets dus:
C:
1
2
3
4
5
6
7
8
9
10
11
12
fd_set fds;
int result;
FD_ZERO(&fds);
FD_SET(fileno(fp), &fds);
timeval to = { 0, 500000 }; /* 500 ms */
result = select(&fds, NULL, NULL, &to);
if(result == 0)
    ... ; /* timeout expired */
else if (result ==1)
    .... ; /* fp readable */
else 
    .... ; /* report error */

Als je dan toch je timeout instelt op een redelijke waarde (die je maximaal accepteert om op een antwoord te wachten) dan heb je eigenlijk die non-blocking mode eigenlijk niet meer nodig; je leest de file descriptor toch uitsluitend uit als er gegevens beschikbaar zijn.

Je lost hiermee trouwens nog niet het probleem op dat het goed mogelijk is dat als er veel uitvoer is, je slechts een deel gelezen hebt (en niet, bijvoorbeeld, precies een hele regel). Je zou dat wel met fgets() ofzo kunnen doen, maar dan heb je weer het probleem dat zo'n functie in non-blocking mode er ook halverwege mee kan stoppen (dacht ik?) waardoor je weer terug bij af bent...

Voor het probleem van de TS is het misschien nog wel het makkelijkst om een aparte thread te spawnen voor de I/O (dan blijft de GUI ook gewoon responsief), die de uitvoer regel voor regel beschikbaar maakt en gewoon blocking I/O gebruikt. Maar goed, dat maakt het niet minder interessant om de voor- en nadelen van non-blocking I/O eens te bespreken. :)

Code is niet getest; er zouden hier en daar wat foutjes in kunnen zitten.

[ Voor 21% gewijzigd door Soultaker op 01-10-2003 10:16 ]


  • Remenic
  • Registratie: Juni 2001
  • Laatst online: 09-08 20:06
Nou ik heb even zitten rotzooien, maar het wilt nog steeds niet. Als ik zelf een write() op CLIENT_WRITE uitvoer komt dit kan ik dit uitlezen via SERVER_READ. Ik denk dat de stdout dan ook gewoon niet doorgestuurd wordt naar CLIENT_WRITE.

Ik gebruik deze functie nu:
C++:
1
dup2(1, pipes[CLIENT_WRITE] ); // stdout naar pipe


Klopt dit nou? Is er nog iets speciaals wat ik moet doen?

Soultaker: Bedankt voor de info. Ik heb in het verleden al met sockets (en dus select()) gewerkt, dus dat moet geen probleem vormen. Nu is het alleen een kwestie van stdout naar de pipe directen. Omgekeerd lukt al wel (van de pipe naar stdin).

  • Remenic
  • Registratie: Juni 2001
  • Laatst online: 09-08 20:06
Hmmmmm, wat ik deed was wel goed kom ik net achter. Alleen ik ging er van uit dat de output die bc bij het opstarten geeft, ook al opgevangen werd door de pipe. Niet dus.

Als ik na het opstarten in het parent gedeelte de query "1+1" stuur krijg ik netjes antwoord terug.

Hier is de code die (bij mij) werkt:

C++:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
enum
{
    SERVER_READ,
    CLIENT_WRITE,
    CLIENT_READ,
    SERVER_WRITE
};

int main()
{
    int pipes[4];
    char tmp[128]; // read buffer
    pid_t client;
    int status;
    int size, num;
    fd_set fds, tmpfds;

    pipe(pipes);
    pipe(pipes+2);

    if ( ! (client = fork() ) )
    {
        // client
        close(0);
        dup2(pipes[CLIENT_READ],  0 ); // pipe naar stdin
        close(1);
        dup2(pipes[CLIENT_WRITE], 1 ); // stdout naar pipe

        close(pipes[CLIENT_READ]);
        close(pipes[CLIENT_WRITE]);
        close(pipes[SERVER_READ]);
        close(pipes[SERVER_WRITE]);
        
        execlp( "/usr/bin/bc", 0 );
    }

    write( pipes[SERVER_WRITE], "1+1\n", 4);
    
    int n = read( pipes[SERVER_READ], tmp, 128);
    tmp[n] = 0;
    
    printf("%s\n", tmp );
    

    write( pipes[SERVER_WRITE], "quit\n", 5 );
    

    wait( &status );
}


Server/client is niet zo goed gekozen vind ik zelf... parent/child hoort het eigenlijk te zijn.

Alleen bedankt voor jullie hulp! _/-\o_

edit:
Er zitten nog een aantal dingen in die niet nodig zijn volgens mij, en een paar dingen ontbreken (fatsoenlijke error checking comes to mind) maar de basis is er. Geen cijfer geven a.u.b. ;)

[ Voor 24% gewijzigd door Remenic op 01-10-2003 17:15 ]


  • Soultaker
  • Registratie: September 2000
  • Laatst online: 01:56
Remenic schreef op 01 October 2003 @ 17:04:
Hmmmmm, wat ik deed was wel goed kom ik net achter. Alleen ik ging er van uit dat de output die bc bij het opstarten geeft, ook al opgevangen werd door de pipe. Niet dus.
Dat kan ik wel verklaren en het ligt niet aan het gebruik van de pipes of aan jouw code: bc controleert bij het opstarten gewoon of de standard output verbonden is met een terminal, en als dat niet het geval is, wordt er bij het opstarten helemaal geen bericht weergegeven! Start bc maar eens met "bc | cat": dan krijg je opeens ook geen mededeling (terwijl 'ie voor de rest wel werkt zoals gebruikelijk).

Wat betref je implementatie: het enige waarvan ik het nut betwijfel zijn de close statements. De standard input en output hoef je sowieso niet te closen, want dat doet dup2 al voor je (gegarandeerd). Verder neem ik aan dat bij het uitvoeren van de exec-call het huidige proces netjes opgeruimd wordt, wat zou betekenen dat je helemaal geen file descriptors hoeft te closen (in het child process, natuurlijk; wel in het parent process, uiteindelijk). Nietemin is het dan misschien nog wel netjes om het toch te doen.

  • Remenic
  • Registratie: Juni 2001
  • Laatst online: 09-08 20:06
Klopt, ik had de close() calls verwijderd na het posten van de code, en het bleef gewoon werken. De reden waarom ik het had, is omdat ik een voorbeeld gevonden had waar dat de input en output geclosed werd. Op dat moment vroeg ik mij ook het nut ervan af, maar ik nam graag het zekere voor 't onzekere.

Wel beetje vervelend geintje van bc; ik had 't allang af kunnen hebben (zonder voorbeeld) als ik dat wist... Naja, weer wat geleerd :)

[ Voor 6% gewijzigd door Remenic op 01-10-2003 20:16 ]


Verwijderd

Is 'Expect' niet iets voor je? Hiermee kan je makelijk data naar een CLI app. versturen en middels regular expressions actie ondernemen a.d.h.v de uitvoer. Hoewel Expect in eerste instantie nogal op de scripttaal TCL is gericht, heb ik me laten vertellen dat het mogelijk is de functionaliteit van Expect in C/C++ te gebruiken. (Heb hier helaas geen ervaring mee)

Mijn ervaringen met Expect scripting zijn vrij positief, het is bijvoorbeeld echt een eitje om vanuit een simpel scriptje automatisch de configuratie van een Cisco router te analyseren en te veranderen.

  • Remenic
  • Registratie: Juni 2001
  • Laatst online: 09-08 20:06
DevelX, ik heb nu de GUI geschreven en het werkt allemaal perfect. De code die nodig is om te communiceren met het CLI programma is zodanig klein dat ik het de moeite niet waard vind om een externe lib te gebruiken.
Pagina: 1