2015. március 1., vasárnap

FreeMarker önképzés és univerzális adatlap nézet

Hát megint sikerült egy bő hónapig elhanyagolnom a blogot. A draftok csak gyűlnek, de a kedv és türelem a fejemben valahogy nem nagyon akar időszeletet osztani nekik. Most mégis kipréselek magamból egy bejegyzést.

Mint nemrég írtam, elkezdtem foglalkozni a Spring-el, ami egy zseniális cucc. A minap azon gondolkodtam, hogy írhatnék magamnak egy kódgenerátort, ami a POJO-kból legenerál nekem mindent, a repository class-októl a controller-eken át a lista és adatlap view-okig. Mindehhez azonban az első lépcső az, hogy felépítek egy minta webappot, ahol kidolgozom, mit is szeretnék pontosan kapni a generátortól a későbbiekben.

El is kezdtem ezt az appot összerakni, azonban úgy voltam vele, hogy tanulok közben valami újat is. Így hát fogtam a FreeMarker-t és berántottam a dependency-k közé. Jól tettem! :D

Ez is egy fantasztikus tool, ugyebár egy template engine-ről beszélünk, melynek lényege, hogy megírsz egy sablont, benne változók placeholder-eivel, majd a kódban ezt betöltöd, belefecskendezel egy modellt, és kiprinteled az eredményt, ahová jólesik. Ez a módszer kiváló lesz a kódgeneráláshoz is, azonban elsőként Spring-be drótozva kezdtem használni view-ok generálására.

A helyzet az, hogy a napokban piszok gyorsan sikerült írnom egy olyan adatlap view-t (és hozzá tartozó pár soros business logic-ot), ami bármilyen entity class-t megjelenít. Bízom benne, hogy nem csak nekem fog hasznomra válni, így ehelyütt meg is osztom.


A lényege az, hogy "/details/{entity}/{id}" URL-en megjeleníti a megadott entity/tábla megadott azonosítójú rekordját, adatlap nézetben. A controller így épült fel végül:
@Controller
public class DetailsController {

  private @Autowired UniversalQuery uq; // deals with queries

  @ModelAttribute("skip") // for every view
  public String[] skip() {
    // these fields are ugly in a details page:
    return new String[] { "class", "handler", "hibernateLazyInitializer" };
  }

  @RequestMapping("/details/{entity}/{id}") // access point of magic
  public String auto(Map<String, Object> m, @PathVariable String entity,
      @PathVariable Object id) {

    // query
    Class<?> clazz = uq.recognize(entity).clazz;
    Map<String, Object> obj = uq.queryOne2Map(entity, id);
    if (null == clazz || null == obj) {
      return "details-error";
    }

    // model
    m.put("class", clazz); // needed for labels
    m.put("obj", obj); // the object to show
    return "details";
  }

  // override of auto(...), you can easily extend model attributes
  @RequestMapping("/details/article/{id}")
  public String article(Map<String, Object> m, @PathVariable Object id) {
    auto(m, "article", id);
    m.put("fields", new String[] { "text" }); // description in .ftl
    return "details";
  }
}
Szépen látszik, hogy az auto metódust bitang könnyen felül lehet bírálni, ha egy entity-re nem akarjuk ész nélkül ráereszteni az automatikus view generálást.

A UniversalQuery (nem találtam jobb nevet neki) osztály egy service, és 2 fő feladata van:

  • Az EntityManagerFactory-ban nyilvántartott entity class-okból kiválasztja azt, amelyikre az URL utal. Illeszt a class névre és a @Table annotáció name mezőjére is, tehát táblanevet is lehet írni.
  • Egy JdbcTemplate-en keresztül lekérdezi a megfelelő rekordot.
A view FTL sablonja az alábbi modell attribútumokat használja:

  • obj - Ez az az objektum/rekord, amit meg akarunk jeleníteni. Egy Map-ként adjuk át neki, így könnyebb. Map-et nagyon kényelmesen generálhatunk a commons-beanutils segítségével: new BeanMap(objektum).
  • fields - Opcionális, itt felsorolható, mely mezőket akarjuk megjeleníteni, és itt adhatjuk meg a sorrendjüket is.
  • skip - Opcionális, itt felsorolhatjuk, mely mezőkre nincs szükségünk. (Felülbírálja a 'fields'-et.)
  • class - Itt át kell adnunk egy Class<?> objektumot, ami a címsorhoz és a field label-ek prefixéhez lesz felhasználva. A címsorban a simpleName lesz, a field label-ek pedig 'full.class.name.field' alakúak.
Lássuk a service-t:
@Service
public class UniversalQuery {

  public static class ClassAndTable {
    public final Class<?> clazz;
    public final String table;

    public ClassAndTable(Class<?> clazz, String table) {
      this.clazz = clazz; this.table = table;
    }
  }

  private Map<String, ClassAndTable> cache = new HashMap<String, ClassAndTable>();
  private @Autowired EntityManagerFactory emf;
  private @Autowired JdbcTemplate jdbc;

  // magically select entity class by class name or table name
  public ClassAndTable recognize(String entity) {
    ClassAndTable ct = cache.get(entity);
    if (null == ct) {
      for (EntityType<?> e : emf.getMetamodel().getEntities()) {
        Class<?> c = e.getJavaType();
        Table t = c.getAnnotation(Table.class);
        if (entity.equalsIgnoreCase(c.getSimpleName())
            || (null != t && entity.equalsIgnoreCase(t.name()))) {
          ct = new ClassAndTable(c, (null == t) ? c.getSimpleName()
              : t.name());
          cache.put(entity, ct);
          System.out.printf("'%s' => %s, `%s`\n", entity,
              ct.clazz.getName(), ct.table);
          break;
        }
      }
    }
    return ct;
  }

  // magically query one record from the recognized table
  public Map<String, Object> queryOne2Map(String entity, Object id) {
    try {
      ClassAndTable ct = recognize(entity);
      return jdbc.queryForMap("SELECT * FROM " + ct.table
          + " WHERE id = ?", id);
    } catch (Exception ex) {
      return null;
    }
  }
}
Látható, hogy cache-elést is beépítettem, a már rekognizált entity class-okat elraktározza egy Map-ben, így későbbi request-eknél már nem kell EMF-el és Reflection-el beszélgetni.

És íme a details.ftl nevű fájlocska:
<#include "/header.ftl">

<#-- ###########################################################################

Model:
  obj    pass your object as a Map<String,Object>, e.g. using BeanMap
  class  optional, pass a class object which name will be used for labels
  fields   optional, an array of fields to show, describes also the order
  skip   optional, an array of fields to be hidden, overrides 'show'

########################################################################### -->

<#if obj??>

<#if !class??  >  <#assign class  = obj.class />  </#if>
<#if !fields?? >  <#assign fields = obj?keys  />  </#if>

<div class="row">
  <div class="col-xs-12">
    <h2>
      <#attempt>
        <!-- class label 'full.class.name' -->      
        <@spring.message "${class.name}" />
      <#recover>
        ${class.simpleName}
      </#attempt>
      <#if obj['id']??>
        #${obj['id']}
      </#if>
    </h2>
    <!-- ${class.name} -->
  </div>
</div>

<div class="row">
  <div class="col-md-10 col-md-offset-1">
  
    <#-- go thru fields -->
    <#list fields as field>
    
      <#-- if we have a skip list, use it -->
      <#if !skip?? || !skip?seq_contains(field)>
      
        <div class="row" style="margin-bottom: 1em">
          
          <!-- col 1: field -->
          <div class="col-sm-2">
            <strong>          
              <#attempt>
                <!-- field label 'full.class.name.field' -->
                <@spring.message "${class.name}.${field}" />
                <!-- ${field} -->
                <br/>
              <#recover>
                <#-- field does not have label -->
                ${field}
              </#attempt>
            </strong>
          </div> <!-- /col 1 -->
        
          <!-- col 2: value -->
          <div class="col-sm-10">
            <em>
              <#attempt>  
                <#if !obj[field]??>
                  <span class="text-danger">null</span>
                <#elseif obj[field]?is_date_like>
                  ${obj[field]?datetime}
                <#else>
                  ${obj[field]?replace("\r?\n", "<br/>", "r")}
                </#if>
              <#recover>
                <span class="text-danger">Error retrieving value.</span>
              </#attempt>               
            </em>
          </div> <!-- /col 2 -->
          
        </div> <!-- /row -->
      
      </#if> <#-- /skip -->
    </#list>
  </div>
</div>

</#if>

<#include "/footer.ftl">
Igen, Bootstrap-hez írkáltam be a CSS class-okat. :) Ami még megjegyzendő, hogy a header.ftl-be bele kell tenni a <#import "/spring.ftl" as spring /> sort a label-ek működéséhez, illetve definiálni kell egy "messageSource" nevű bean-t, mint például itt.

Megjegyzés: látható, hogy a class attribútummal sincs gond, ha nem adjuk meg, ekkor az obj objektum class field-jét fogja használni - tehát ez esetben ne Map-ként adjuk át az objektumot.

És igazából ennyi. :) A header/footer/details-error sablont el tudjátok képzelni, a messages.properties-hez is leírtam a szükséges infókat. Egy ízelítő kép a test environment-emből:


Most dolgozom egy univerzális listanézeten, a fentiek alapján egyáltalán nem komplikált összedobni.

Nincsenek megjegyzések: