Jadual Kandungan
Mulakan dengan contoh
Jawapan Terminologi
Ringkaskan hubungan yang sepadan
Logging执行原理
Logger的获取
日志的输出
 总结
Rumah Java javaTutorial Bagaimana untuk menangani modul JDK Logging yang mendasari java

Bagaimana untuk menangani modul JDK Logging yang mendasari java

May 10, 2023 pm 02:55 PM
java jdk logging

Mulakan dengan contoh

Penggunaan JDK Logging adalah sangat mudah, seperti yang ditunjukkan dalam kod berikut Pertama, gunakan kaedah statik getLogger kelas Logger untuk mendapatkan logger, dan kemudian gunakan yang diperoleh pembalak di mana-mana sahaja Buat entri log. Contohnya, panggilan seperti logger.info("Main running.").

package com.bes.logging;
import java.util.logging.Level;
import java.util.logging.Logger;
public class LoggerTest {
      private static Loggerlogger = Logger.getLogger("com.bes.logging");
      public static void main(String argv[]) {
               // Log a FINEtracing message
               logger.info("Main running.");
               logger.fine("doingstuff");
               try {
                         Thread.currentThread().sleep(1000);// do some work
               } catch(Exception ex) {
                         logger.log(Level.WARNING,"trouble sneezing", ex);
               }
               logger.fine("done");
      }
}
Salin selepas log masuk

Jika anda menjalankan contoh di atas tanpa membuat sebarang pengubahsuaian kod atau pengubahsuaian konfigurasi JDK, anda akan mendapati bahawa hanya log [Main running.] akan muncul pada konsol. Soalan berikut sepatutnya muncul dalam otak anda...

1 Mengapakah log selain daripada [Main berjalan.] tidak dikeluarkan? Bagaimana untuk membuat mereka muncul?

2. Di manakah masa, nama kelas, nama kaedah, dll. yang muncul dalam output log?

3. Mengapakah log muncul dalam konsol?

4. Sistem yang besar mungkin mempunyai banyak sub-modul (yang boleh difahami secara ringkas sebagai mempunyai banyak nama pakej. Bagaimana untuk mengawal tahap log sub-modul ini secara individu?

5. Pengembangan: Adakah projek log4j popular Apache berkaitan dengan pengelogan JDK Bagaimana untuk melaksanakan LoggerManager anda sendiri?

Dengan soalan-soalan ini, anda mungkin lebih berminat untuk mempelajari tentang mekanisme pengelogan JDK Bab ini akan menganalisis mekanisme modul mudah ini untuk anda.

Jawapan Terminologi

Sebelum analisis mendalam, anda perlu menguasai istilah berikut

logger: Untuk pembalak, anda perlu mengetahui aspek berikut

1 , Logger akan digunakan di mana sahaja kod tersebut perlu memasukkan log Ini adalah jurucakap modul pengelogan JDK Kami sering menggunakan Logger.getLogger("com.aaa.bbb"); kemudian gunakan pembalak untuk mengeluarkan log.

2. Logger sebenarnya hanyalah sebuah unit pengurusan logik. Kebanyakan operasinya hanyalah untuk meneruskan sebagai contoh: panggilan ke Logger.getLogger("xxx") akan Bergantung pada kelas LogManager, apabila menggunakan logger untuk memasukkan maklumat log, semua pengendali dalam logger akan dipanggil untuk memasukkan log.

3. Logger mempunyai hubungan hierarki secara amnya kita boleh memahaminya sebagai hubungan pewarisan ibu bapa-anak antara nama pakej. Setiap logger biasanya dinamakan sempena nama pakej java. Pembalak kanak-kanak biasanya mewarisi tahap pembalak, pengendali, nama ResourceBundle (berkaitan dengan maklumat pengantarabangsaan), dsb. daripada pembalak induk.

4. Akan ada root logger dengan nama kosong dalam keseluruhan JVM Semua pembalak tanpa nama akan mempunyai root logger sebagai induk mereka

LogManager: Pengurusan semua pembalak dalam keseluruhan JVM, logger Penjanaan, pemerolehan dan operasi lain semuanya bergantung padanya, termasuk pembacaan fail konfigurasi. Akan ada Hashtable [private Hashtable> loggers] dalam LogManager untuk menyimpan semua pembalak semasa Jika anda perlu mendapatkan pembalak, jika sudah ada pembalak dalam Hashtable, ia akan dikembalikan terus ke Hashtable. Jika hashtable Jika tiada logger, buat yang baharu dan masukkan ke dalam Hashtable untuk disimpan.

Handler: Digunakan untuk mengawal output log Contohnya, ConsoleHanlder yang disertakan dengan JDK mengubah hala aliran keluaran ke keluaran System.err Setiap kali kaedah Logger dipanggil untuk keluaran, kaedah penerbitan Pengendali dipanggil. Setiap pembalak mempunyai berbilang pengendali. Kita boleh menggunakan pengendali untuk memasukkan log ke tempat yang berbeza (seperti sistem fail atau sambungan Soket jauh

Formatter: Log perlu diformatkan sebelum ia benar-benar dikeluarkan: contohnya, sekiranya masanya). keluaran? Format masa? Adakah anda mahu memasukkan nama urutan? Sama ada hendak menggunakan maklumat pengantarabangsaan dsb. bergantung pada Pemformat.

Log Level: Tidak perlu dikatakan, ini adalah yang mudah difahami, dan ini juga sebab pembalakan boleh membantu kami menyesuaikan diri dengan keperluan yang berbeza untuk kebutiran keluaran log dalam peringkat yang berbeza daripada pembangunan dan penyahpepijatan kepada penggunaan dan dalam talian. Tahap Log JDK dari tinggi ke rendah adalah MATI(231-1)—>TERUK(1000)—>AMARAN(900)—>INFO(800)—>CONFIG(700)—> ; FINE(500)—>FINER(400)—>FINEST(300)—>SEMUA(-231) Perbandingan tahap semasa mengeluarkan log bergantung pada saiz nombor itu. Tetapi perlu diingatkan bahawa bukan sahaja pembalak mempunyai tahap, pengendali juga mempunyai tahap Maksudnya, jika tahap pembalak adalah FINE dan pelanggan ingin memasukkan log peringkat FINE, jika tahap pengendali yang sepadan dengan pembalak adalah INFO, maka. Log tahap FINE masih tidak boleh dikeluarkan.

Ringkaskan hubungan yang sepadan

LogManager dan logger mempunyai hubungan 1-ke-banyak Hanya terdapat satu LogManager apabila keseluruhan JVM sedang berjalan dan semua logger berada dalam LogManager

<. 🎜>Logger dan pengendali ialah perhubungan Many-to-many, pembalak akan memanggil semua pengendali untuk memproses log apabila mengeluarkan log

Pengendali dan pemformat mempunyai hubungan satu dengan satu, seorang pengendali mempunyai pemformat untuk memformat log

Jelas sekali: terdapat hubungan satu dengan satu antara pembalak dan tahap, dan terdapat juga hubungan satu dengan satu antara pengendali dan tahap

Konfigurasi pengelogan:

Fail konfigurasi pengelogan lalai JDK ialah: $JAVA_HOME/jre /lib/logging.properties, anda boleh menggunakan sifat sistem java.util.logging.config.file untuk menentukan fail konfigurasi yang sepadan untuk ditulis ganti fail konfigurasi lalai Fail konfigurasi biasanya mengandungi takrifan berikut:

1, handlers:用逗号分隔每个Handler,这些handler将会被加到root logger中。也就是说即使我们不给其他logger配置handler属性,在输出日志的时候logger会一直找到root logger,从而找到handler进行日志的输入。

2, .level是root logger的日志级别

3, .xxx是配置具体某个handler的属性,比如java.util.logging.ConsoleHandler.formatter便是为ConsoleHandler配置相应的日志Formatter.

4, logger的配置,所有以[.level]结尾的属性皆被认为是对某个logger的级别的定义,如com.bes.server.level=FINE是给名为[com.bes.server]的logger定义级别为FINE。顺便说下,前边提到过logger的继承关系,如果还有com.bes.server.webcontainer这个logger,且在配置文件中没有定义该logger的任何属性,那么其将会从[com.bes.server]这个logger进行属性继承。除了级别之外,还可以为logger定义handler和useParentHandlers(默认是为true)属性,如com.bes.server.handler=com.bes.test.ServerFileHandler(需要是一个extends java.util.logging.Handler的类),com.bes.server.useParentHandlers=false(意味着com.bes.server这个logger进行日志输出时,日志仅仅被处理一次,用自己的handler输出,不会传递到父logger的handler)。以下是JDK配置文件示例

handlers= java.util.logging.FileHandler,java.util.logging.ConsoleHandler
.level= INFO
java.util.logging.FileHandler.pattern = %h/java%u.log
java.util.logging.FileHandler.limit = 50000
java.util.logging.FileHandler.count = 1
java.util.logging.FileHandler.formatter =java.util.logging.XMLFormatter
java.util.logging.ConsoleHandler.level = INFO
java.util.logging.ConsoleHandler.formatter =java.util.logging.SimpleFormatter
com.xyz.foo.level = SEVERE
sun.rmi.transport.tcp.logLevel = FINE
Salin selepas log masuk

Logging执行原理

Logger的获取

A,首先是调用Logger的如下方法获得一个logger

    public static synchronized Logger getLogger(String name) {
           LogManager manager =LogManager.getLogManager();
        returnmanager.demandLogger(name);
    }
Salin selepas log masuk

B,上面的调用会触发java.util.logging.LoggerManager的类初始化工作,LoggerManager有一个静态化初始化块(这是会先于LoggerManager的构造函数调用的~_~):

static {
   AccessController.doPrivileged(newPrivilegedAction<Object>() {
       public Object run() {
           String cname =null;
           try {
               cname =System.getProperty("java.util.logging.manager");
               if (cname !=null) {
                  try {
                       Class clz =ClassLoader.getSystemClassLoader().loadClass(cname);
                       manager= (LogManager) clz.newInstance();
                   } catch(ClassNotFoundException ex) {
               Class clz =Thread.currentThread().getContextClassLoader().loadClass(cname);
                      manager= (LogManager) clz.newInstance();
                   }
               }
           } catch (Exceptionex) {
              System.err.println("Could not load Logmanager \"" + cname+ "\"");
              ex.printStackTrace();
           }
           if (manager ==null) {
               manager = newLogManager();
           }
      
           manager.rootLogger= manager.new RootLogger();
          manager.addLogger(manager.rootLogger);
 
           Logger.global.setLogManager(manager);
          manager.addLogger(Logger.global);
 
           return null;
       }
   });
}
Salin selepas log masuk

从静态初始化块中可以看出LoggerManager是可以使用系统属性java.util.logging.manager指定一个继承自java.util.logging.LoggerManager的类进行替换的,比如Tomcat启动脚本中就使用该机制以使用自己的LoggerManager。

不管是JDK默认的java.util.logging.LoggerManager还是自定义的LoggerManager,初始化工作中均会给LoggerManager添加两个logger,一个是名称为””的root logger,且logger级别设置为默认的INFO;另一个是名称为global的全局logger,级别仍然为INFO。

LogManager”类”初始化完成之后就会读取配置文件(默认为$JAVA_HOME/jre/lib/logging.properties),把配置文件的属性名<->属性值这样的键值对保存在内存中,方便之后初始化logger的时候使用。

C,A步骤中Logger类发起的getLogger操作将会调用java.util.logging.LoggerManager的如下方法:

     Logger demandLogger(String name) {
       Logger result =getLogger(name);
       if (result == null) {
           result = newLogger(name, null);
           addLogger(result);
           result =getLogger(name);
       }
       return result;
     }
Salin selepas log masuk

可以看出,LoggerManager首先从现有的logger列表中查找,如果找不到的话,会新建一个looger并加入到列表中。当然很重要的是新建looger之后需要对logger进行初始化,这个初始化详见java.util.logging.LoggerManager#addLogger()方法中,改方法会根据配置文件设置logger的级别以及给logger添加handler等操作。

到此为止logger已经获取到了,你同时也需要知道此时你的logger中已经有级别、handler等重要信息,下面将分析输出日志时的逻辑。

日志的输出

首先我们通常会调用Logger类下面的方法,传入日志级别以及日志内容。

    public void log(Levellevel, String msg) {
          if (level.intValue() < levelValue ||levelValue == offValue) {
              return;
          }
          LogRecord lr = new LogRecord(level, msg);
          doLog(lr);
    }
Salin selepas log masuk

该方法可以看出,Logger类首先是进行级别的校验,如果级别校验通过,则会新建一个LogRecord对象,LogRecord中除了日志级别,日志内容之外还会包含调用线程信息,日志时刻等;之后调用doLog(LogRecord lr)方法

    private void doLog(LogRecord lr) {
          lr.setLoggerName(name);
          String ebname =getEffectiveResourceBundleName();
          if (ebname != null) {
              lr.setResourceBundleName(ebname);
              lr.setResourceBundle(findResourceBundle(ebname));
          }
          log(lr);
    }
Salin selepas log masuk

doLog(LogRecord lr)方法中设置了ResourceBundle信息(这个与国际化有关)之后便直接调用log(LogRecord record) 方法

   public void log(LogRecord record) {
          if (record.getLevel().intValue() <levelValue || levelValue == offValue) {
              return;
          }
          synchronized (this) {
              if (filter != null &&!filter.isLoggable(record)) {
                  return;
              }
          }
          Logger logger = this;
          while (logger != null) {
              Handler targets[] = logger.getHandlers();
              if(targets != null) {
                  for (int i = 0; i < targets.length; i++){
                           targets[i].publish(record);
                       }
              }
              if(!logger.getUseParentHandlers()) {
                       break;
              }
              logger= logger.getParent();
          }
    }
Salin selepas log masuk

很清晰,while循环是重中之重,首先从logger中获取handler,然后分别调用handler的publish(LogRecordrecord)方法。while循环证明了前面提到的会一直把日志委托给父logger处理的说法,当然也证明了可以使用logger的useParentHandlers属性控制日志不进行往上层logger传递的说法。到此为止logger对日志的控制差不多算是完成,接下来的工作就是看handler的了,这里我们以java.util.logging.ConsoleHandler为例说明日志的输出。

public class ConsoleHandler extends StreamHandler {
    public ConsoleHandler() {
          sealed = false;
          configure();
          setOutputStream(System.err);
          sealed = true;
    }
Salin selepas log masuk

ConsoleHandler构造函数中除了需要调用自身的configure()方法进行级别、filter、formatter等的设置之外,最重要的我们最关心的是setOutputStream(System.err)这一句,把系统错误流作为其输出。而ConsoleHandler的publish(LogRecordrecord)是继承自java.util.logging.StreamHandler的,如下所示:

 public synchronized void publish(LogRecord record) {
       if(!isLoggable(record)) {
           return;
       }
       String msg;
       try {
           msg =getFormatter().format(record);
       } catch (Exception ex){
           // We don&#39;t want tothrow an exception here, but we
           // report theexception to any registered ErrorManager.
           reportError(null,ex, ErrorManager.FORMAT_FAILURE);
           return;
       }
       try {
           if (!doneHeader) {
              writer.write(getFormatter().getHead(this));
               doneHeader =true;
           }
           writer.write(msg);
       } catch (Exception ex){
           // We don&#39;t want tothrow an exception here, but we
           // report theexception to any registered ErrorManager.
           reportError(null,ex, ErrorManager.WRITE_FAILURE);
       }
    }
Salin selepas log masuk

方法逻辑也很清晰,首先是调用Formatter对消息进行格式化,说明一下:格式化其实是进行国际化处理的重要契机。然后直接把消息输出到对应的输出流中。需要注意的是handler也会用自己的level和LogRecord中的level进行比较,看是否真正输出日志。

总结

至此,整个日志输出过程已经分析完成。细心的读者应该可以解答如下四个问题了。

1,【Main running.】以外的日志为什么没有输出?怎么让它们也能够出现?

这就是JDK默认的logging.properties文件中配置的handler级别和跟级别均为info导致的,如果希望看到FINE级别日志,需要修改logging.properties文件,同时进行如下两个修改

    java.util.logging.ConsoleHandler.level= FINE//修改
    com.bes.logging.level=FINE//添加
Salin selepas log masuk

2,日志中出现的时间、类名、方法名等是从哪里输出的?

请参照[java.util.logging.ConsoleHandler.formatter= java.util.logging.SimpleFormatter]配置中指定的java.util.logging.SimpleFormatter类,其publicsynchronized String format(LogRecord record) 方法说明了一切。

public synchronized String format(LogRecord record) { StringBuffer sb = new StringBuffer(); // Minimize memory allocations here. dat.setTime(record.getMillis()); args[0] = dat; StringBuffer text = new StringBuffer(); if (formatter == null) { formatter = new MessageFormat(format); } formatter.format(args, text, null); sb.append(text); sb.append(" "); if (record.getSourceClassName() != null) { sb.append(record.getSourceClassName()); } else { sb.append(record.getLoggerName()); } if (record.getSourceMethodName() != null) { sb.append(" "); sb.append(record.getSourceMethodName()); } sb.append(lineSeparator); String message = formatMessage(record); sb.append(record.getLevel().getLocalizedName()); sb.append(": "); sb.append(message); sb.append(lineSeparator); if (record.getThrown() != null) { try { StringWriter sw = newStringWriter(); PrintWriter pw = newPrintWriter(sw); record.getThrown().printStackTrace(pw); pw.close(); sb.append(sw.toString()); } catch (Exception ex) { } } return sb.toString();}public synchronized String format(LogRecord record) {
    StringBuffer sb = new StringBuffer();
    // Minimize memory allocations here.
    dat.setTime(record.getMillis());
    args[0] = dat;
    StringBuffer text = new StringBuffer();
    if (formatter == null) {
        formatter = new MessageFormat(format);
    }
    formatter.format(args, text, null);
    sb.append(text);
    sb.append(" ");
    if (record.getSourceClassName() != null) {     
        sb.append(record.getSourceClassName());
    } else {
        sb.append(record.getLoggerName());
    }
    if (record.getSourceMethodName() != null) {
        sb.append(" ");
       sb.append(record.getSourceMethodName());
    }
    sb.append(lineSeparator);
    String message = formatMessage(record);
   sb.append(record.getLevel().getLocalizedName());
    sb.append(": ");
    sb.append(message);
    sb.append(lineSeparator);
    if (record.getThrown() != null) {
        try {
            StringWriter sw = newStringWriter();
            PrintWriter pw = newPrintWriter(sw);
           record.getThrown().printStackTrace(pw);
            pw.close();
             sb.append(sw.toString());
        } catch (Exception ex) {
        }
    }
    return sb.toString();
}
Salin selepas log masuk

3,为什么日志就会出现在控制台?

    看到java.util.logging.ConsoleHandler 类构造方法中的[setOutputStream(System.err)]语句,相信你已经明白。

4,大型的系统可能有很多子模块(可简单理解为有很多包名),如何对这些子模块进行单独的日志级别控制?

    在logging.properties文件中分别对各个logger的级别进行定义,且最好使用java.util.logging.config.file属性指定自己的配置文件。

Atas ialah kandungan terperinci Bagaimana untuk menangani modul JDK Logging yang mendasari java. Untuk maklumat lanjut, sila ikut artikel berkaitan lain di laman web China PHP!

Kenyataan Laman Web ini
Kandungan artikel ini disumbangkan secara sukarela oleh netizen, dan hak cipta adalah milik pengarang asal. Laman web ini tidak memikul tanggungjawab undang-undang yang sepadan. Jika anda menemui sebarang kandungan yang disyaki plagiarisme atau pelanggaran, sila hubungi admin@php.cn

Alat AI Hot

Undresser.AI Undress

Undresser.AI Undress

Apl berkuasa AI untuk mencipta foto bogel yang realistik

AI Clothes Remover

AI Clothes Remover

Alat AI dalam talian untuk mengeluarkan pakaian daripada foto.

Undress AI Tool

Undress AI Tool

Gambar buka pakaian secara percuma

Clothoff.io

Clothoff.io

Penyingkiran pakaian AI

Video Face Swap

Video Face Swap

Tukar muka dalam mana-mana video dengan mudah menggunakan alat tukar muka AI percuma kami!

Alat panas

Notepad++7.3.1

Notepad++7.3.1

Editor kod yang mudah digunakan dan percuma

SublimeText3 versi Cina

SublimeText3 versi Cina

Versi Cina, sangat mudah digunakan

Hantar Studio 13.0.1

Hantar Studio 13.0.1

Persekitaran pembangunan bersepadu PHP yang berkuasa

Dreamweaver CS6

Dreamweaver CS6

Alat pembangunan web visual

SublimeText3 versi Mac

SublimeText3 versi Mac

Perisian penyuntingan kod peringkat Tuhan (SublimeText3)

Nombor Sempurna di Jawa Nombor Sempurna di Jawa Aug 30, 2024 pm 04:28 PM

Panduan Nombor Sempurna di Jawa. Di sini kita membincangkan Definisi, Bagaimana untuk menyemak nombor Perfect dalam Java?, contoh dengan pelaksanaan kod.

Weka di Jawa Weka di Jawa Aug 30, 2024 pm 04:28 PM

Panduan untuk Weka di Jawa. Di sini kita membincangkan Pengenalan, cara menggunakan weka java, jenis platform, dan kelebihan dengan contoh.

Nombor Smith di Jawa Nombor Smith di Jawa Aug 30, 2024 pm 04:28 PM

Panduan untuk Nombor Smith di Jawa. Di sini kita membincangkan Definisi, Bagaimana untuk menyemak nombor smith di Jawa? contoh dengan pelaksanaan kod.

Soalan Temuduga Java Spring Soalan Temuduga Java Spring Aug 30, 2024 pm 04:29 PM

Dalam artikel ini, kami telah menyimpan Soalan Temuduga Spring Java yang paling banyak ditanya dengan jawapan terperinci mereka. Supaya anda boleh memecahkan temuduga.

Cuti atau kembali dari Java 8 Stream Foreach? Cuti atau kembali dari Java 8 Stream Foreach? Feb 07, 2025 pm 12:09 PM

Java 8 memperkenalkan API Stream, menyediakan cara yang kuat dan ekspresif untuk memproses koleksi data. Walau bagaimanapun, soalan biasa apabila menggunakan aliran adalah: bagaimana untuk memecahkan atau kembali dari operasi foreach? Gelung tradisional membolehkan gangguan awal atau pulangan, tetapi kaedah Foreach Stream tidak menyokong secara langsung kaedah ini. Artikel ini akan menerangkan sebab -sebab dan meneroka kaedah alternatif untuk melaksanakan penamatan pramatang dalam sistem pemprosesan aliran. Bacaan Lanjut: Penambahbaikan API Java Stream Memahami aliran aliran Kaedah Foreach adalah operasi terminal yang melakukan satu operasi pada setiap elemen dalam aliran. Niat reka bentuknya adalah

TimeStamp to Date in Java TimeStamp to Date in Java Aug 30, 2024 pm 04:28 PM

Panduan untuk TimeStamp to Date di Java. Di sini kita juga membincangkan pengenalan dan cara menukar cap waktu kepada tarikh dalam java bersama-sama dengan contoh.

Program Java untuk mencari kelantangan kapsul Program Java untuk mencari kelantangan kapsul Feb 07, 2025 am 11:37 AM

Kapsul adalah angka geometri tiga dimensi, terdiri daripada silinder dan hemisfera di kedua-dua hujungnya. Jumlah kapsul boleh dikira dengan menambahkan isipadu silinder dan jumlah hemisfera di kedua -dua hujungnya. Tutorial ini akan membincangkan cara mengira jumlah kapsul yang diberikan dalam Java menggunakan kaedah yang berbeza. Formula volum kapsul Formula untuk jumlah kapsul adalah seperti berikut: Kelantangan kapsul = isipadu isipadu silinder Dua jumlah hemisfera dalam, R: Radius hemisfera. H: Ketinggian silinder (tidak termasuk hemisfera). Contoh 1 masukkan Jejari = 5 unit Ketinggian = 10 unit Output Jilid = 1570.8 Unit padu menjelaskan Kirakan kelantangan menggunakan formula: Kelantangan = π × r2 × h (4

Cipta Masa Depan: Pengaturcaraan Java untuk Pemula Mutlak Cipta Masa Depan: Pengaturcaraan Java untuk Pemula Mutlak Oct 13, 2024 pm 01:32 PM

Java ialah bahasa pengaturcaraan popular yang boleh dipelajari oleh pembangun pemula dan berpengalaman. Tutorial ini bermula dengan konsep asas dan diteruskan melalui topik lanjutan. Selepas memasang Kit Pembangunan Java, anda boleh berlatih pengaturcaraan dengan mencipta program "Hello, World!" Selepas anda memahami kod, gunakan gesaan arahan untuk menyusun dan menjalankan program, dan "Hello, World!" Pembelajaran Java memulakan perjalanan pengaturcaraan anda, dan apabila penguasaan anda semakin mendalam, anda boleh mencipta aplikasi yang lebih kompleks.

See all articles