31. srpna 2007

Třídění kolekcí podle českých pravidel

Narazil jsem včera na zajímavé možnosti třídění kolekcí v Javě. Můj problém byl následující - z LDAP serveru načítám uživatele a potřebuji je pak setřídit dle jejich příjmení. Vzhledem k tomu, že ne všechny LDAP servery mají zabudovanou funkcionalitu třídění, tak jsem toto musel vyřešit přímo v Javě.

První řešení, které asi každého hned napadne je následující:

  public static final Comparator SURNAME_COMPARATOR_ASC = new Comparator() {
public int compare(Object o1, Object o2) {
return ((User) o1).getSurname().compareTo(((User) o2).getSurname());
}
};
...
// sort users by surname
Collections.sort(users, SURNAME_COMPARATOR_ASC);
Nadefinuji si standardní komparátor a pak použiji metodu z Collections pro třídění. A zde jsem narazil na problém s řazením v češtině - příjmení začínající diakritikou se seřadily až na úplný konec seznamu, což je špatně. Je to tím, že používáme standardní komparátor Stringu, který řadí řetězce lexikograficky.

Java umí zde parádně pomoci třídou Collator. Tato třída bere v potaz pravidla pro jednotlivé národní znaky, pomocí RuleBasedCollator je možné si dokonce nadefinovat úplně vlastní pravidla pro třídění. A aby toho nebylo málo, tak je tu ještě třída CollationKey pro efektivnější třídění řetězců.

Nová implementace komparátoru bude tedy vypadat takto:
  private static Collator czechCollator = Collator.getInstance(new Locale("cs"));

public static final Comparator SURNAME_COMPARATOR_ASC = new Comparator() {
public int compare(Object o1, Object o2) {
return czechCollator.compare(((User) o1).getSurname(), ((User) o2).getSurname());
}
};

29. srpna 2007

Spring framework: konfigurace transakcí v XML pomocí TransactionInterceptor

Dnes jsem kolegovi vysvětloval jak se definují transakce v XML a myslím, že je to natolik častá činnost, že se tato informace bude hodit více lidem. Hned na začátek podotýkám, že zde budu popisovat pouze jeden z možných způsobů konfigurace. Praxe mě naučila, že Spring framework nabízí více možností jak určité věci řešit a nejinak tomu je i zde. Dále se budu zabývat pouze možností konfigurace pomocí XML a to pomocí třídy TransactionInterceptor a BeanNameAutoProxyCreator. Tato konfigurace je zejména vhodná pro Spring framework ve verzi 1.2.x, protože ve verzi 2.x se možnosti značně rozšířily (možnost anotací, XML tagy přímo pro definici transakcí, AOP)

Nechci zde duplikovat informace uvedené v javadoc Springu, mojí snahou je spíše uvést skutečnosti, které na první pohled nemusí být zřejmé.

V rámci konfigurace transakcí je možné definovat (nejenom) následující parametry chování transakce:

  • propagace transakce - nejčastěji používané hodnoty jsou PROPAGATION_REQUIRED, PROPAGATION_REQUIRES_NEW a PROPAGATION_SUPPORTS.

  • readOnly (volitelné) - zda se jedná o transakcí, která bude pouze číst. Zda se tento parametr využije závisí na zvoleném typu propagace transakce a na použitém transakčním manažeru.

  • -Exceptions - vyjímky (oddělené čárkou), při kterých dojde k roll-backu transakce (volitelné). Pokud není uvedena žádná vyjímka v definici, tak se předpokládá vyjímka typu RuntimeException, ne Exception.

  • +Exceptions - vyjímky (oddělené čárkou), při kterých daná transakce projde. Takové vyjímky mezi vyjímkami :-)

Pouze první parametr je povinný. Jednotlivé parametry se oddělují čárkou. Definice může tedy vypadat třeba následovně: PROPAGATION_REQUIRED,readOnly,-Exception,+UnsupportedMethodCallException,+OperationAlreadyFinishedException.

Všechny tyto parametry a spousta dalších věcí okolo transakcí ve Springu jsou velice pěkně popsány v tomto článku.

Třída TransactionInterceptor má následující settery:
  • setTransactionManager() pro nastavení manažera, který provádí vlastní management transakcí. Např. pro Hibernate je to HibernateTransactionManager. Pro různé manažery (Hibernate, JDBC, JCR) můžeme mít různé požadavky na transakce, proto je tedy konfigurace parametrů transakce vztažena vždy k jednomu transakčnímu manažerovi.

  • setTransactionAttributes() - nastavení metod a parametrů transakcí pomocí Properties, kde klíč je jméno metody a hodnota obsahuje parametry transakce. Zde je nutné poznamenat, že toto nastavení je interně zpracováno třídou NameMatchTransactionAttributeSource. Na tento setter se dá tedy nahlížet jako na pomocnou metodu pro uživatele, protože používá nejčastěji používanou implementaci pro zápis metod.

  • setTransactionAttributeSource() - nastavení transakcí dle vlastní volby. Možné volby jsou dány rozhraním TransactionAttributeSource a jeho implementacemi. V úvahu pro XML zápis připadají tyto implementace:
    • MatchAlwaysTransactionAttributeSource - jednoduchá implementace, která nastavuje stejné transakční parametry bez ohledu na metody

    • MethodMapTransactionAttributeSource - vhodné pro definice transakcí, kde nám jde o vyjádření metod pomocí plných jmen (FQCN), tedy pomocí plné cesty k dané třídě nebo rozhraní a jménu metody. Je možné používat i zástupný znak * v názvu metody. Pak to funguje tak, že Spring si během inicializace automaticky doplní interní seznam metod o plné názvy metod, které odpovídají zápisům s hvězdičkou. Přesněji specifikovaný zápis metody přebijí volnější definici s hvězdičkou.

    • NameMatchTransactionAttributeSource - nejčastěji používaná implementace, která umožňuje definovat pravidla pro zachytávání metod velice volně pomocí hvězdičkové notace (např. "xxx*", "*xxx" or "*xxx*"). Zde to funguje trochu jinak než u předchozí implementace - až teprve při spouštění metody se Spring snaží dle definovaných pravidel najít správný záznam, který lze použít a pokud ho najde, tak nastaví transakci dle definovaných parametrů.

  • setTransactionAttributeSources() - variace na předchozí setter. Tato volba je vhodná pro nastavení transakcí více způsoby. Toto se může hodit celkem často, protože co lze vyjádřit jedním způsobem zápisu již nelze tak dobře vyjádřit jiným způsobem.

Dle mé zkušenosti nefunguje kombinace metod setTransactionAttributes() a setTransactionAttributeSource(). Pokud uživatel potřebuje nadefinovat transakce pomocí více způsobů, tak by měl použít metodu setTransactionAttributeSources.

Nakonec dvě ukázky - první je příklad nejčastějšího použití, druhý příklad ukazuje definici pomocí složitější konstrukce.

<bean id="DbTransactionInterceptor"
class="org.springframework.transaction.interceptor.TransactionInterceptor">
<property name="transactionManager" ref="dbTransactionManager" />
<property name="transactionAttributes">
<props>
<prop key="get*">PROPAGATION_REQUIRED,readOnly</prop>
<prop key="search*">PROPAGATION_REQUIRED,readOnly</prop>
<prop key="exist*">PROPAGATION_REQUIRED,readOnly</prop>
<prop key="add*">PROPAGATION_REQUIRED,-Exception</prop>
<prop key="remove*">PROPAGATION_REQUIRED,-Exception</prop>
<prop key="change*">PROPAGATION_REQUIRED,-Exception</prop>

<prop key="cz.anect.mis.web.facade.DocumentFacade.getAdminsByLocation">PROPAGATION_REQUIRED,readOnly</prop>
<prop key="cz.anect.mis.web.facade.DocumentFacade.addDocumentOnlineRequest">PROPAGATION_REQUIRED,-Exception</prop>

</props>
</property>
</bean>


<bean id="DbTransactionInterceptor" class="org.springframework.transaction.interceptor.TransactionInterceptor">
<property name="transactionManager" ref="dbTransactionManager"/>
<!-- full qualified method names -->
<property name="transactionAttributeSource">
<bean class="org.springframework.transaction.interceptor.MethodMapTransactionAttributeSource">
<property name="methodMap">
<map>
<entry key="cz.anect.mis.services.DocumentService.addDocument" value="PROPAGATION_REQUIRED,-Exception" />
<entry key="cz.anect.mis.services.DocumentService.addDocumentToData" value="PROPAGATION_REQUIRED,-Exception" />
<entry key="cz.anect.mis.web.facade.DocumentFacade.addDocumentData" value="PROPAGATION_REQUIRED,-Exception" />
<entry key="cz.anect.mis.web.facade.DocumentFacade.get*" value="PROPAGATION_REQUIRED,readOnly" />
<entry key="cz.anect.mis.web.facade.DocumentFacade.exist*" value="PROPAGATION_REQUIRED,readOnly" />
<entry key="cz.anect.mis.web.facade.DocumentFacade.getDocumentByDownloadHash" value="PROPAGATION_REQUIRED,-Exception" />

<entry key="cz.anect.mis.web.facade.CodelistFacade.addStorePlaceAndFund" value="PROPAGATION_REQUIRED" />
<entry key="cz.anect.mis.web.facade.CodelistFacade.addNewDigiUser" value="PROPAGATION_REQUIRED" />
<entry key="cz.anect.mis.web.facade.CodelistFacade.get*" value="PROPAGATION_REQUIRED,readOnly" />
</map>
</property>
</bean>
</property>
</bean>
Aby byla konfigurace transakcí kompletní je nutné ještě dodefinovat propojení mezi transakčními "zachytávači" (TransactionInterceptor) a instancemi tříd, na které budeme transakční pravidla aplikovat.

<bean id="BeanNameProxyCreator" class="org.springframework.aop.framework.autoproxy.BeanNameAutoProxyCreator">
<property name="beanNames">
<list>
<idref bean="DocumentFacade"/>
<idref bean="CodelistFacade"/>
<idref bean="DocumentService"/>
<idref bean="PlaceIdentificationFacade"/>
</list>
</property>
<property name="interceptorNames">
<list>
<idref bean="DbTransactionInterceptor"/>
<idref bean="JcrTransactionInterceptor"/>
<idref bean="gisJdbcTransactionInterceptor"/>
</list>
</property>
</bean>

27. srpna 2007

Více prostředí pomocí Springu, úvod

Každá aplikace by měla v průběhu svého vývoje procházet několika vývojovými prostředími - vývojové prostředí jednotlivých programátorů, testovací prostředí, prostředí pro akceptační testování a produkční prostředí pro nasazení aplikace u zákazníka. Každé prostředí má svoje specifika - různé konektory k databázím, různé požadavky na spuštěné služby, různé cesty k souborům na lokálních discích, různé konektory ke všem možných dalším systémům jako jsou např. LDAP, email apod.

V minulosti jsme to řešili tak, že v SVN byly uloženy konfigurační soubory pouze pro vývojové prostředí a pro všechny další prostředí jsme to museli dělat nějak ručně. Konf. soubory pro další prostředí jsme tedy drželi bokem a manuálně jsme je před nasazením měnili. Pro nás to na posledním projektu bylo ještě o to horší, že jedna aplikace měla centrální a lokální část.

Jedno z řešení, které se nabízelo, bylo použít filtrací pomocí ANTu. To spočívá v tom, že lokální nastavení pro daného vývojáře se udržuje v souboru (který není součástí SVN) a pomocí ANT tasku se provede filtrace, která mi v konf. souborech provede náhradu značek za mé lokální nastavení. Toto není určitě pro začátek špatné, ale má to několik nevýhod - nedá se moc efektivně udržovat informace pro všechny prostředí, které jsem uvedl a při změně prostředí (např. při přesunu z akceptačního do produkčního prostředí) je nutné generovat vždy novou distribuci, což nemusí být vždy úplně žádoucí.

Během konference SpringOne jsem byl na přednášce, která mi ukázala, že Spring framework mi zase dokáže elegantně pomoci.

Základní myšlenky jsou následující:

  • vytvořím si takovou adresářovou strukturu, která bude obsahovat konfigurační soubory pro každé prostředí zvlášť

  • vytvořím konf. soubor, kde budu nastavovat jaké prostředí se má spouštět

  • upravím inicializaci Springu tak, aby se načetly konf. soubory pouze pro vybrané prostředí

Vzhledem k tomu, že toho psaní je ještě celkem dost, tak jsem se rozhodl, že tento příspěvek rozdělím do dvou částí. Teď končím s úvodem a v příští části popíšu konkrétní implemetaci.

Hibernate Criteria API vs. HQL

Nedávno jsem dělal docela složitou stránku pro vyhledávání položek uložených v databázi, kde si uživatel mohl vybrat velké množství omezujících parametrů (přibližně asi 25 položek). Navíc jednotlivé objekty mají velké množství atributů a já potřeboval vypsat pouze nepatrné procento z nich.

Mám rád Hibernate Criteria API pro vytváření dynamických dotazů, protože nemusím skládat výsledný dotaz z řetězců, nemusím se starat o správné typy apod. Prostě celkově mi to přijde více "programovější" využívat dostupné Criteria API (pozn. celkem zajímavý článek o srovnání Criteria API a HQL vyšel na DevX). V tomto případě jsem ovšem narazil a musel jsem to nakonec celé udělat pomocí HQL dotazů :(.

Narazil jsem na následující problémy s Criteria API:

  1. nebyl jsem schopen efektivně omezit množinu atributů příbuzných objektů, které jsem potřeboval vrátit

  2. nemohl jsem se dotazovat na kolekci elementů

Nemožnost omezení atributů

Přes Criteria API se standardně načítají všechny atributy pro danou třídu včetně příbuzných tříd. Je možné toto změnit pomocí tzv. projekcí. Tyto projekce mají však svoje omezení - není možné provést projekci pouze na určitý atribut příbuzného objektu (=related object). Takže například když mám hlavní objekt Dokument s vazbou na lokalitu, tak nejsem schopen provést takovou projekci, aby se mi vrátil pouze název lokality.
    List results2 = session.createCriteria(Document.class)
.setProjection( Projections.projectionList()
.add( Property.forName("name") )
.add( Property.forName("location.name") )
)
.list();

Kolekce elementů

Na začátek bych měl asi nejdříve vysvětlit, co to je kolekce elementů. Vysvětlím to na příkladě. Mám dokument a k němu se mi vážou kódy lokalit. Tedy jeden dokument má na sebe vázanou kolekci kódů lokalit. Zde se nejedná o kolekci objektů, ale pouze elementů s jejich kódem. Určitě se zeptáte proč to není kolekce standardních objektů? Je to proto, že informace o jednotlivých lokalitách jsou uloženy v jiném systému a v našem systému ukládáme pouze identifikátory lokalit, abychom se na ně mohli později dotazovat. Konkrétně pro dokument konfigurace pomocí anotací vypadá následovně:
  @CollectionOfElements(fetch = FetchType.LAZY)
@JoinTable(name="document_pagis", joinColumns = @JoinColumn(name = "doc_id"))
@Column(name = "idob_pg")
private Set paGisCodes;
Je nutné poznamenat, že kolekce elementů není součástí standardního JPA, ale je to rozšíření Hibernate. Rád bych se k problematice kolekcí elementů ještě vrátit v některém dalším příspěvku.

Zpět k omezení Criteria API. V současné verzi Hibernate (3.2.3) nejsem schopen přistupovat k těmto elementům přes Criteria API. Tuto informaci jsem našel přímo na stránkách Hibernate, ale nyní bohužel nejsem schopen to znovu najít, abych uvedl přímo odkaz.

Z výše uvedených důvodů jsem vyhledávání implementoval přes HQL bez žádných větších problémů. Jen z toho nemám tak dobrý pocit, jako kdybych použil Criteria API :).

26. srpna 2007

Zablokování účtu pro přihlášení uživatele

Během testování aplikace jsme si všimnuli, že je možné se donekonečna zkoušet přihlašovat do naší aplikace, což je svým způsobem bezpečnostní díra do aplikace, případně do celého systému, protože útočník pak může donekonečna zkoušet hesla až nakonec nějaké třeba zjistí.

Domluvili jsme se tedy, že upravíme náš bezpečnostní modul tak, že uživatel (= jedno uživatelské jméno) bude mít tři pokusy na přihlášení a pokud je vypotřebuje, tak pak se nebude moci po dobu 30 minut přihlásit.

Ještě malé vysvětlení pro začátek: bezpečnostní modul je v našem pojetí zapouzdřená Acegi security knihovna vhodně nakonfigurována dle potřeb projektu (rád bych toto rozebral v některém z dalších příspěvků).

Postup implementace je následující:

  1. někde musím odchytávat událost, kdy se uživatel špatně přihlásí. Vytvořím si vlastní ApplicationListener.
  2. potřebuji mít nějakého manažera, který bude řídit blokování účtů a bude říkat, zda daný uživatel se může přihlásit nebo ne
  3. nakonec je potřeba upravit vlastní zpracování přihlašovacího formuláře, což je standardně řešeno ve filtru AuthenticationProcessingFilter.

Vlastní ApplicationListener


/**
* Listener for catching authentification failures.
*
* Listener is also responsible for deciding whether user account should be
* disabled.
*
* @author pjuza@anect.com
*/
public class AuthentificationFailureListener implements ApplicationListener {

//~ Static fields/initializers =====================================================================================

private static final Log logger = LogFactory.getLog(AuthentificationFailureListener.class);

/**
* Maximum number of failures before user account will be disabled.
*/
private static final int MAX_COUNT_FAILURES = 3;

/**
* Map [user name, count of failures] maps users and theirs count of failures.
*/
private Map userFailures = new HashMap();

private DisabledAccountManager disabledAccountManager;

public void setDisabledAccountManager(DisabledAccountManager disabledAccountManager) {
this.disabledAccountManager = disabledAccountManager;
}

//~ Methods ========================================================================================================

public void onApplicationEvent(ApplicationEvent event) {
if (event instanceof AbstractAuthenticationEvent) {
AbstractAuthenticationEvent authEvent = (AbstractAuthenticationEvent) event;
String username = authEvent.getAuthentication().getName();

if (event instanceof AbstractAuthenticationFailureEvent) {
//authentication failed
if (userFailures.containsKey(username)) {
int failuresCount = userFailures.get(username);
failuresCount++;

if (failuresCount < MAX_COUNT_FAILURES) {
userFailures.put(username, failuresCount);

if (logger.isDebugEnabled()) {
logger.debug("User " + username + " has increased number of failures.");
}
}
else {
//disable user account
disabledAccountManager.disableUserAccount(username);
userFailures.remove(username);
}
}
else {
userFailures.put(username, 1);

if (logger.isDebugEnabled()) {
logger.debug("User " + username + " was added into map of failures.");
}
}
}
else if (event instanceof AuthenticationSuccessEvent) {
//success authentication
if (userFailures.containsKey(username)) {
userFailures.remove(username);

if (logger.isDebugEnabled()) {
logger.debug("User " + username + " was removed from map of failures.");
}
}
}
}
}
}

Tento listener zachytává události typu AbstractAuthenticationFailureEvent a ukládá si statistiku špatných pokusů pro každé uživatelské jméno. V případě, že uživatel překročí počet povolených přihlášení, tak se předá informace manažerovi. Pokud se uživatel přihlásí úspěšně (tj. událost typu AuthenticationSuccessEvent), tak pak se smaže záznam o uživateli v mapě špatných přihlášení.

Manažer pro blokování účtů

Manažer je představován rozhraním DisabledAccountManager.

/**
* Manager for enabling/disabling user accounts.
*
* Specific implementation of this interface determines whether account will
* be disabled on the presentation level, on data level or somewhere else.
*
* @author pjuza@anect.com
*/
public interface DisabledAccountManager {

/**
* Method decides whether account for specified username is disabled or not.
* @param username User name
* @return true if user has disabled account otherwise false
*/
public boolean isUserAccountDisabled(String username);

/**
* Method disables user account.
* @param username User name
*/
public void disableUserAccount(String username);
}

Konkrétní implementace tohoto rozhraní je závislá na požadavcích projektu. K zablokování účtu může dojít přímo v LDAPu nebo jinde, kde se uchovávají informace o uživatelích. Já jsem pro začátek zvolil asi nejjednodušší způsob a to ten, že uživateli, který vypotřeboval počet pokusů na přihlášení, se zamezí možnost přihlášení po dobu dalších 30 minut.
Hned zde podotýkám, že toto řešení funguje za předpokladu, že aplikační server nebude restartován, protože informace jsou ukládány pouze v paměti.

/**
* Basic implementation of manager for disabling user accounts.
*
* @author pjuza@anect.com
*/
public class BasicDisabledAccountManager implements DisabledAccountManager {
private static final Log logger = LogFactory.getLog(BasicDisabledAccountManager.class);

/**
* Time determines how long user account will be disabled.
* Time is in milliseconds. Default time is 30 minutes.
*/
private int disablingTime = 30 * 60 * 1000;

/**
* Map [user name, date of inserting in ms] maps users and date of inserting item.
*/
private Map diabledUsers = new HashMap();

public void setDisablingTime(int disablingTime) {
this.disablingTime = disablingTime;
}

public void disableUserAccount(String username) {
diabledUsers.put(username, (new Date()).getTime());

if (logger.isDebugEnabled()) {
logger.debug("Account for " + username + " was disabled.");
}
}

public boolean isUserAccountDisabled(String username) {
if (diabledUsers.containsKey(username)) {
Long elapsedTime = (new Date()).getTime() - diabledUsers.get(username);

if (elapsedTime > disablingTime) {
diabledUsers.remove(username);

if (logger.isDebugEnabled()) {
logger.debug("Account for " + username + " was enabled again.");
}
}
}

return diabledUsers.containsKey(username);
}
}

Zpracování dat z přihlašovacího formuláře

Pro zpracování dat z přihlašovacího formuláře slouží třída AuthenticationProcessingFilter. Tuto třídu rozšíříme o kontrolu, zda uživatel, který se zkouší přihlásit nemá mít zablokovaný účet.

/**
* This class represents custom implementation of authentification filter.
* What is new is ability to disable user account for some time. In other words
* user won't be able to login for some time.
*
* If specified {@link DisabledAccountManager} then this manager decides whether
* user can be authentificated or not, if user account is disabled or not.
*
* This filter should be used with {@link AuthentificationFailureListener} that
* watches authentication failures and calls manager for disabling accounts.
*
* @author pjuza@anect.com
*/
public class DisableAuthenticationProcessingFilter extends AuthenticationProcessingFilter {

private static final Log logger = LogFactory.getLog(DisableAuthenticationProcessingFilter.class);

private DisabledAccountManager disabledAccountManager;

public void setDisabledAccountManager(DisabledAccountManager disabledAccountManager) {
this.disabledAccountManager = disabledAccountManager;
}

@Override
public Authentication attemptAuthentication(HttpServletRequest request) throws AuthenticationException {
String username = obtainUsername(request);

//check if user can login
if (disabledAccountManager != null
&& disabledAccountManager.isUserAccountDisabled(username)) {
// Place the last username attempted into HttpSession for views
request.getSession().setAttribute(ACEGI_SECURITY_LAST_USERNAME_KEY, username);

logger.warn("User " + username + " is trying to login but his account is disabled for some time.");

throw new DisabledException("Account is disabled for user " + username);
}

//"normal" authentication
return super.attemptAuthentication(request);
}
}


Nakonec nezapomenout na konfiguraci Acegi:

<!-- Processes an authentication form. -->
<bean id="authenticationProcessingFilter" class="cz.anect.securitymodule.DisableAuthenticationProcessingFilter">
<property name="authenticationManager" ref="authenticationManager"/>
<property name="authenticationFailureUrl" value="${acegi.authenticationFailureUrl}"/>
<property name="defaultTargetUrl" value="/"/>
<property name="filterProcessesUrl" value="/j_acegi_security_check"/>
<property name="disabledAccountManager" ref="disabledAccountManager"/>
</bean>

<bean id="failureListener" class="cz.anect.securitymodule.AuthentificationFailureListener">
<property name="disabledAccountManager" ref="disabledAccountManager"/>
</bean>

<bean id="disabledAccountManager" class="cz.anect.securitymodule.BasicDisabledAccountManager">
</bean>

Já jsem si ještě upravil formulář pro přihlašování a to tak, že odchytávám vyjímku DisabledException a podle toho vypisuji hlášku uživateli o zablokovaném účtu.

Proč jsem začal psát blog?

Slohy mi nikdy na škole nešly a nikdy jsem neměl žádné ambice něco psát a hlavně to ukazovat ostatním. Kromě toho je hodně kvalitního materiálu již na internetu, spousta lidí bloguje o podobných věcech, takže proč znovu psát o věcech, které již někde jsou?
Pokud bych to měl nějak shrnout proč, tak z důvodů, že bych rád

  • někam ukládat a zapisoval moje řešení problémů
  • sdílel svoje řešení s ostatními
  • získat zpětnou vazbu od ostatních
  • vyzkoušel tento způsob komunikace.
To by podle mě samo o sobě nestačilo, kdybych neměl o čem psát. Zjistil jsem, že spousta mých řešení určitých problémů jsou unikátní nebo ne zcela dobře popsané, takže zde bych rád hlavně prezentoval svá řešení, případně řešení mých kolegů z práce.