Author Archives: admin

Když na prostředí záleží aneb soubory a více vláken

Java programátoři jsou zvyklí, že jsou odstíněni od operačního systému na kterém jejich aplikace běží. Toto odstínění není ale stoprocentní. Jednou z vyjímek jsou operace se souborovým systémem.
Chtěl bych se s vámi poděli o poznatky, které jsem získal při psaní projektu m2-proxy. Je to jednoduchý program, který jsem si napsal ve volném čase, abych se odreagoval od svého zaměstnání – práci která se programování bohužel jen vzdáleně podobá. m2-proxy je v jednoduchosti program, který umí lokálně cachovat vzdálenou repository pro Maven (pokud jste používali maven-proxy, tak je to něco podobného, určeného pro Maven 2). Jde o web aplikaci, která se chová následovně:

  1. Přijde požadavek. Podívám se jestli už mám požadovaný zdroj lokálně uložený
  2. Pokud ano a nevypršela jeho platnost, vrátím ho
  3. V opačném případě ho stáhnu z internetu, uložím si ho a vrátím

V zjednodušené podobě může kód vypadat následovně (bez kontroly platnosti souboru a zpracování vyjímek, také předstíráme, že umíme pracovat jenom s jedním zdrojem – souborem):

01 public InputStream downloadFile() throws IOException
02   {
03     //java.io.File store = ... file on the disc
04     //file can not be read, probably was not stored yet
05     if (!store.canRead())
06     {
07       //get the source from the internet
08       InputStream source1 = getSourceInputStream();
09       //store it
10       out1 = new FileOutputStream(store);
11       copyStreams(source1, out1);
12       //closing should be in the finally block
13       source1.close();
14       out1.close();
15     }
16     if (store.canRead())
17     {
18       //return stream from the disc
19       return new FileInputStream(store);
20     }
21     else
22     {
23       //safety net, returning source from the internet
24       return getSourceInputStream();
25     }
26   } 

05 – store je typu java.io.File, zastupuje lokálně uloženou verzi zdroje. Zjišťujeme, je-li zdroj už na disku a můžeme ho číst
08 – Pokud zdroj není na disku, získáme zdrojový InputStream z internetu
11 – Zkopírujeme zdroj na disk
16 – Zjistíme je-li zdroj na disku
19 – Pokud ano vrátím ho
24 – Pokud se něco pokazilo, vrátím originál z internetu

Na první pohled je vše vyřešeno, zvídavějšího čtenáře napadne, co se stane, když ten samý soubor chce ve stejný okamžik více žadatelů. Může nastat několik zajímavých situací, z nichž některé si rozebereme. Ukážeme si zdrojové kódy JUnit testů, které nám pomohou ověřit, je-li náš návrh správný.

Současné čtení a zápis

Zamysleme se nad tím, co se stane, když jedno vlákno zapisuje data do souboru a druhé ho v ten samý okamžik chce číst. Tzn. když nastane něco takového:

01   public void testAfterSomethingWritten() throws IOException
02   {
03     //thread 1
04     assertFalse(store.canRead());
05     //get the source from the internet
06     InputStream source1 = getSourceInputStream();
07     out1 = new FileOutputStream(store);
08     //store just half of the content
09     copyStreams(LENGTH/2, source1, out1);
10     //thread 2
11     assertTrue(store.canRead());
12     //open local store
13     InputStream in2 = new FileInputStream(store);
14     //compare whether the data in the store are ok
15     int bytesRead = compareStreams(in2, getSourceInputStream());
16     assertEquals(LENGTH/2, bytesRead);
17     
18     //thread 1 can continue in writing
19     copyStreams(LENGTH-LENGTH/2, source1, out1);
20   } 

Máme tu situaci, kdy jedno vlákno zapsalo polovinu souboru, v tom okamžiku bylo jeho vykonávání přerušeno (řádek 10) a druhé vlákno začne soubor číst. Čtení simulujeme na řádku 15, čteme in2 a porovnáváme ho se zdrojovým proudem. Když narazíme na konec proudu, vrátíme počet načtených bytů. Můžete si tipnout jak předchozí kód dopadne.

a) Dojde k chybě při porovnání (na řádku 11 File.canRead() vrátí false)
b) Dojde k vyjímce při vytvoření proudu (řádek 13)
c) Dojde k vyjímce při čtení proudu (řádek 15)
d) Proběhne bez chyby

Nebudu vás moc napínat, d je správně. Předchozí kód proběhne bez chyby! To ovšem znamená, že námi navrhovaný postup je špatně! K žádnému automatickému vzájemnému vyloučení tu nedochází. Při souběžném běhu více vláken, může jedno vrátit jenom začátek požadovaného souboru. Jak z toho ven? Co takhle zkusit soubor explicitně zamknout.

Zamykání souboru
Od verze 1.4 podporuje Java zamykání souborů. Můžeme ho zkusit použít, jenom musíme dát pozor na operační systém, protože tato funkce je silně závislá na souborovém systému. Pokud chci zamknout soubor, zavolám

FileLock lock1 = out1.getChannel().lock();//aquire exclusive lock

čímž soubor zamknu. Pokud se kdokoliv jiný pokusí obdržet na něj zámek stejným způsobem, musí čekat dokud nezavolám lock1.release(). Pokud chci z proudu jen číst, mohu si vyžádat sdílený zámek. Takovýto zámek může držet zároveň více procesů, tzn. více procesů může zároveň číst. Sdílený zámek mohu obdržet například takto

FileLock readLock = in1.getChannel().tryLock(0L, Long.MAX_VALUE, true);

tryLock se od lock liší jenom tím, že neblokuje, pokud nemůže zámek obdržet. Místo toho se z volání metody vrátí null.

Pozměněný kód pro uložení může vypadat následovně

01 public InputStream downloadFile() throws IOException
02   {
03     //file can not be read, probably is not on the disc
04     if (!store.canRead())
05     {
06       InputStream source1 = getSourceInputStream();
07       out1 = new FileOutputStream(store);
08       FileLock lock1 = out1.getChannel().lock();//aquire exclusive lock
09       copyStreams(source1, out1);
10       lock1.release();
11       source1.close();
12       out1.close();
13     }
14     if (store.canRead())
15     {
16       
17       FileInputStream in1 = new FileInputStream(store);
18       // try to aquire shared lock
19       FileLock readLock = in1.getChannel().tryLock(0L, Long.MAX_VALUE, true);
20       if (readLock!=null)
21       {
22         return in1;
23       }
24     }
25     return getSourceInputStream();
26   } 

Zde jsme se rozhodli, že pokud nemůžeme obdržet zámek pro čtení na řádku 19, vrátíme původní proud z internetu. Kdybychom na řádku 19 použili lock, vykonání by čekalo, dokud se zámek neuvolní.

Pro otestování chování můžeme napsat následující kód

01 public void testReadLock() throws IOException
02   {
03     //thread 1
04     out1 = new FileOutputStream(store);
05     lock1 = out1.getChannel().lock();
06     assertNotNull(lock1);
07     //thread 2
08     assertTrue(store.canRead());
09     FileInputStream in2 = new FileInputStream(store);
10     FileLock lock2 = in2.getChannel().tryLock(0,Long.MAX_VALUE, true);
11     assertNull(lock2);
12   }

První vlákno tu vytvoří soubor a zamkne ho. V tom vstoupí do hry druhé vlákno a začne soubor číst. Podívá se zda může soubor číst, vytvoří vstupní proud a pokusí se obdržet zámek. Vidíte, že na řádku 11 předpokládáme, že se to nepodaří.

Pokud předcházející kód spustíme, bude nám ve Windows (Windows XP Home Edition) opravdu na řádku 10 bude vráceno null. Ale v Linuxu (RadHat s extfs3) klidně zámek získáme a ze souboru můžeme normálně číst! Jsme tedy tam kde jsme byli. Jak to? Co se to děje? K čemu ty zámky tedy jsou? Takto se můžeme ptát, můžeme i nadávat, že je Linux k ničemu, ale chyba je jako obvykle mezi klávesnicí a židlí. Pokud se podíváme do JavaDocu k tříde FileLock dočteme se doslova toto

„File locks are held on behalf of the entire Java virtual machine. They are not suitable for controlling access to a file by multiple threads within the same virtual machine.“

Zamykání souborů tedy také nemůžeme použít (i když na Windows by to fungovalo).

Dočasné soubory

Nakonec jsem se přiklonil k použití dočasných souborů. Při stahování budu data ukládat do dočasného souboru, který po dokončení stahování přesunu na správné místo. Předpokládám, že přejmenování souboru snad proběhne atomicky a že se nikomu nepodaří soubor číst v napůl přejmenovaném stavu (i když v JavaDocu píší, že tomu tak být nemusí!). Kód pro stahování může tedy vypadat následovně

01 public InputStream downloadFile() throws IOException
02   {
03     //file can not be read, probably is not on the disc
04     if (!store.canRead())
05     {
06       InputStream source1 = getSourceInputStream();
07       File tmpFile = File.createTempFile("filetest","tmp");
08       out1 = new FileOutputStream(tmpFile);
09       copyStreams(source1, out1);
10       source1.close();
11       out1.close();
12       tmpFile.renameTo(store);
13     }
14     if (store.canRead())
15     {
16       return new FileInputStream(store);
17     }
18     else
19     {
20       return getSourceInputStream();
21     }
22   } 

Původní kód jsme rozšířili o
07 – Vytvoření dočasného souboru
12 – Přejmenování souboru

Vypadá to nadějně, zkusme to otestovat. Aby to nebylo tak jednoduché, vložíme na začátku do souboru nějakou hodnotu a tu se budeme snažit přepsat. Nasimulujeme si tak situaci, kdy je stávající soubor nahrazen novou verzí

01 public void testOverwrite() throws IOException
02   {
03     //let's pretend that there already exists some file
04     FileOutputStream out = new FileOutputStream(store);
05     copyStreams(getOldSourceInputStream(), out);
06     out.close();
07     
08     //thread 1
09     File tmpFile = File.createTempFile("filetest","tmp");
10     out1 = new FileOutputStream(tmpFile);
11     copyStreams(getSourceInputStream(), out1);
12     out1.close();
13     tmpFile.renameTo(store);
14     
15     assertTrue(store.canRead());
16     InputStream in2 = new FileInputStream(store);
17     int bytesRead = compareStreams(in2, getSourceInputStream());
18     assertEquals(LENGTH, bytesRead);
19     in2.close();
20   } 

Na Linuxu to běží skvěle, pustíme-li kód na Windows, porovnání proudů na řádku 17 selže. V souboru zůstal starý obsah. Zde se nám vymstilo, že nekontrolujeme návratovou hodnotu metody renameTo. Na Windows nám vrací false, aby naznačila, že se operace nepodařila. Jednoduše nám odmítá přejmenovat soubor, pokud již nějaký se stejným jménem existuje. Existuje jednoduché řešení. Stávající soubor musíme napřed smazat (řádek 13 v následujícím výpise).

01 public void testDelete() throws IOException
02   {
03     //let's pretend that there already exists some file
04     FileOutputStream out = new FileOutputStream(store);
05     copyStreams(getOldSourceInputStream(), out);
06     out.close();
07     
08     //thread 1
09     File tmpFile = File.createTempFile("filetest","tmp");
10     out1 = new FileOutputStream(tmpFile);
11     copyStreams(getSourceInputStream(), out1);
12     out1.close();
13     boolean deleted = store.delete();
14     assertTrue(deleted);
15     tmpFile.renameTo(store);
16     
17     assertTrue(store.canRead());
18     InputStream in2 = new FileInputStream(store);
19     int bytesRead = compareStreams(in2, getSourceInputStream());
20     assertEquals(LENGTH, bytesRead);
21     in2.close();
22   }

Protože jsem hrozný šťoura, zeptám se vás jestli náhodou nemůže selhat mazání. Kromě podivných případů, kdy je soubor na zamčeném médiu, může nastat situace, že soubor někdo čte v okamžiku kdy se ho snažíme smazat. Nasimulovat si to můžeme následovně

01 public void testDeleteWhenReading() throws IOException
02   {
03     //let's pretend that there already exists some file
04     FileOutputStream out = new FileOutputStream(store);
05     copyStreams(getOldSourceInputStream(), out);
06     out.close();
07     
08     //reference source stream
09     InputStream reference1 = getOldSourceInputStream();
10     //thread 1 is reading the file
11     assertTrue(store.canRead());
12     FileInputStream in1 = new FileInputStream(store);
13     //reading and comparing the stream with reference
14     compareStreams(LENGTH/2, in1, reference1);
15     
16     //thread 2 is storing the file
17     File tmpFile = File.createTempFile("filetest","tmp");
18     out1 = new FileOutputStream(tmpFile);
19     copyStreams(getSourceInputStream(), out1);
20     out1.close();
21     //let's delete the existing file
22     boolean deleted = store.delete();
23     if (deleted)
24     {
25       System.out.println("The original file WAS deleted.");
26     }
27     else
28     {
29       System.out.println("The original file WAS NOT deleted.");
30       tmpFile.delete();
31       return;
32     }
33     
34     tmpFile.renameTo(store);
35     
36     // thread 1 reads  rest of the file
37     compareStreams(LENGTH-LENGTH/2, in1, reference1);
38     in1.close();
39     reference1.close();
40     
41     //thread 2 reads the file
42     assertTrue(store.canRead());
43     InputStream in2 = new FileInputStream(store);
44     int bytesRead = compareStreams(in2, getSourceInputStream());
45     assertEquals(LENGTH, bytesRead);
46     in2.close();
47   }

Zkoušíme tu situaci, kde první vlákno rozečte soubor, druhé vlákno se rozhodne, že soubor není platný a že je potřeba ho přepsat novou verzí (řádek 16). Pokus o smazání na řádku 22 může teoreticky skončit několika výsledky

a) Soubor bude smazán, čtoucí vlákno má smůlu
b) Soubor nepůjde smazat
c) Soubor se smaže, přesto ho bude moci čtoucí vlákno dočíst

Experimenty potvrdili, že dochází ke výsledkům b a c. Windows jednoduše odmítnou soubor smazat, to je pochopitelné chování, je na nás, abychom se s touto situací nějak vyrovnali. Linux (alespoň moje verze na extfs3) soubor smaže, ale čtoucí vlákno ho nějakým zázračným způsobem může dočíst. Dochází tedy k situaci, kdy dvě vlákna čtou současně jeden soubor, každé s jiným obsahem. Jelikož je ale tento obsah konzistentní, není to žádný problém, spíše naopak. Nemusíme řešit, co dělat, pokud soubor nejde smazat.

Ukázali jsme si, že i Java programátor se občas musí starat o to, na jakém systému jeho program poběží. Pocit, že na prostředí nezáleží že nás Java izoluje je v některých případech falešný. Práce ze soubory je jedním takovým případem. Při práci na vícevláknové aplikaci je dobré se zamyslet, co se může stát, když se nám dvě a více vláken střetne nad společným zdrojem. Ukázali jsme si, že

  1. U FileInputStreamu a FileOutputStreamu k žádnému implicitnímu zamykání nedochází, je možné do souboru zapisovat a zároveň z něho číst.
  2. Explicitní zámky na souborech slouží pouze k vyloučení nejavových procesů. V rámci jednoho virtuálního stroje mohou být neúčinné.
  3. Je nutné kontrolovat návratové hodnoty z metod java.io.File, jako renameTo a delete. Může se stát, že selžou (na různých OS různě).

Ukazuje se také, že je občas dobré si přečíst dokumentaci až do konce, před tím než se člověk vrhne po hlavě do implementace. I když na druhou stranu je docela zajímavé vyzkoušet si jak to funguje na vlastní kůži.

Chcete-li vidět verzi kódu, kterou jsem použil v projektu m2-proxy podívejte se do CVS projektu.

Zdrojové kódy příkladu jsou ke stažení zde.

Jarní zamyšlení nad Springem

Je jaro, tedy čas nadmíru vhodný k zamyšlení se nad Springem. Předem bych chtěl upozornit, že jsem jeho velký fanoušek, takže se ode mě asi velké kritiky nedočkáte. Také nečekejte žádný technický návod něco podobného. Bude to prostě jen takové zamyšlení proč je ten Spring tolik populární.

Při programování člověk často naráží na problémy, u kterých si říká: „Tohle už musí být stokrát vyřešeno, proč to musím řešit znovu?“. Tomuto druhu problémů se dá čelit dvěma způsoby. Buď se porozhlédnout po internetu jestli neexistuje nějaké řešení nebo problém řešit po svém. První přístup má nevýhodu v tom, že existuje několik produktů na řešení našeho problému, my si mezi nimi musíme vybrat a potom se daný produkt naučit. Na druhou stranu obvykle dostaneme řešení, které funguje bez větší pracnosti. Druhý přístup přinese požitek ze zajímavé práce, ale přináší nebezpečí, že místo abychom programovali to co máme, tak programujeme podpůrnou knihovnu. Navíc platí známá poučka, že v kódu který nenapíšeme chybu neuděláme.

Spring slouží k zjednodušení prvního přístupu. Zjednodušuje nám často řešené problémy a usnadňuje použití existujících knihoven. Pokud se například rozhodneme používat JDBC, můžeme samozřejmě použít JDBC API přímo. Nejen že musíme zdlouhavě neustále dokola psát TCFTC bloky, ale navíc riskujeme, že někde zapomeneme zavřít připojení atp. (Rod Johnson se vyjádřil o tom, že když někdo používá JDBC tak se jedná o „Sackable offence“ – důvod k vyhazovu.) Při přímém použití JDBC také není vůbec triviální správa transakcí přesahujících jednu metodu. Když se rozhodnu použít Spring, nejen že se mi výrazně zjednoduší kód, ale také dostanu přístup k spoustě bonusů jako je třeba právě snadná správa transakcí nebo překlad SQL vyjímek na srozumitelné. Proto také, když se mě někdo zeptá co to je ten Spring tak nezačnu mluvit o IoC, ale předvedu jak snadno se dá používat JDBC.

Spring samozřejmě není jen o JDBC, ale řeší spoustu jiných stále se opakujících problémů. Jako jeden z nich mohu uvést bezpečnost. To je problém, který se řeší znovu a znovu. Jak v JEE aplikaci zajistit autentifikaci a autorizaci? Spring nám přináší projekt ACEGI, který využívá stávající produkty a standardy a propojuje je v snadno použitelný celek. To je jeden z dalších aspektů Springu. Pokud již existuje řešení, neimplementuje ho znovu, jen poskytne cestu jak ho snadněji využít. (snad jen s vyjímkou MVC frameworku 🙂

Podobných příkladů se dá uvést hodně, takže jen krátce. Na Spring se obrátím když chci snadno používat Hibernate, využívat AOP, načítat lokalizační balíčky, konfigurovat aplikaci, vzdáleně volat metody, používat deklarativní transakce nebo volat a implementovat EJB.

U toho posledního bych se chvilku zdržel. Vztah mezi EJB a Springem je zvláštní, jejich pole působnosti se překrývá. Oba produkty umí deklarativní transakce, objektově relační mapování, bezpečnost a EJB ve verzi 3 vypadají dokonce i jako snadno použitelně. Mám podezření, že se někteří mí kolegové těší, že až se jednou EJB 3 začnou používat, tak Spring skončí. Já jejich názor nesdílím. Záběr Springu je mnohem větší než EJB a jsou funkce, které EJB nebo obecně JEE nikdy nebudou poskytovat. Ne proto, že by byly špatné, ale asi nemůžeme od standardu chtít, aby nám poskytoval funkce pro snazší použití open source produktů.

EJB mají samozřejmě výhodu v tom, že jde o standard. To je ale i jejich nevýhoda. Než se standardizační skupina dohodne na tom jak bude vypadat další verze a ta se následně dostane do produkce, uběhne obvykle několik let. Mezitím už je už technologická špička někde jinde. Dovolil bych si tvrdit, že nebýt Springu a Hibernate tak by EJB 3 specifikace vypadala úplně jinak a u SUNu by mysleli, že POJO je mexický zpěvák.

Nedíval bych se ale na Spring a EJB jako na soupeře i když při pohledu na tábory jejich příznivců to tak vypadá. Ani autoři Springu netvrdí, že EJB jsou k ničemu. Pro větší aplikace nasazené v clusteru mají své opodstatnění. Spring proto i obsahuje třídy zjednodušující implementaci EJB 2.1. Uvidíme jak se situace bude vyvíjet po tom, co se EJB 3 dostanou do produkce.

Co říci závěrem? Snad jen to, že až příště budete řešit problém, o kterém si říkáte že už musí být stokrát vyřešený, až budete pracovat s komplikovaným low level API, zkuste se podívat do dokumentace Springu, třeba tam najdete řešení.