IK.AM

@making's tech note


CategoLJ3でオレオレブログシステムを作ろう

🗃 {Dev/Blog/CategoLJ3}
🏷 CategoLJ3 🏷 Spring Boot 
🗓 Updated at 2015-12-29T01:29:45Z  🗓 Created at 2015-12-28T18:16:28Z   🌎 English Page

CateoLJ3はSpring Bootと連携することで簡単にブログ用記事管理REST APIを構築するためのライブラリです。

logo.png

記事管理部分はCateogoLJ3に任せて、ビューの部分を好きなように自分で作れるので、オレオレブログの構築が簡単です。

特徴としては

  • 記事はMarkdownで書いてGit(GitHubなど)で管理
  • 検索はElasticsearchを利用

な点です。このブログ自体もCategoLJ3で作られています

本稿で、CategoLJ3によるREST APIサーバーの作り方を紹介します。

プロジェクトの作成

APIサーバーは基本的にSpring Bootプロジェクトです。

Spring Initializrで「Search for dependencies」に「Web」を入れて「Generate Project」をクリックし、プロジェクトの雛形をダウンロードします。

ダウンロードしたdemo.zipを展開し、MavenプロジェクトをIDEにインポートしてください。 IntelliJ IDEの場合はpom.xmlをIDEで開くだけでOKです。

pom.xmlにCategoLJ3を使うための依存関係am.ik.categolj3:categolj3-apiを追加します。執筆段階で、バージョンは1.0.0.M5です。

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.example</groupId>
    <artifactId>demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <packaging>jar</packaging>

    <name>demo</name>
    <description>Demo project for Spring Boot</description>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>1.3.1.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <java.version>1.8</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
+		<dependency>
+			<groupId>am.ik.categolj3</groupId>
+			<artifactId>categolj3-api</artifactId>
+			<version>1.0.0.M5</version>
+		</dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
    
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
    

</project>

Gitレポジトリの準備

次に記事を管理するためのGitレポジトリを作成します。任意のレポジトリを使用しても良いですが、ここでは https://github.com/categolj/categolj3-article-base をForkします。

スクリーンショット 2015-12-29 1.17.57.png

application.propertiesにGitレポジトリの情報を設定します。

# 記事を管理するGitのリポジトリのURL(Fork先のURL)
git.uri=https://github.com/making/categolj3-article-base.git
# リポジトリ中のMarkdownファイルを管理するフォルダ名
git.content-dir=content
# git cloneをするディレクトリ(アプリケーションが自動でcloneする)
git.base-dir=/tmp/article

Gitリポジトリのアクセスに認証が必要な場合はgit.usernamegit.passwordに認証情報を設定してください。

Gitの設定は以上です。

Elasticsearchの準備

次にElasticesearchの設定を行います。

ここでは簡単に使うためにDockerを使用しますが、ローカルにインストールしたElasticesearchを使っても構いません。

$ docker run -p 9200:9200 -p 9300:9300 --rm elasticsearch

Docker Machineをつかっている場合はデフォルトで、http://192.168.99.100:9200 にアクセスできるはず。

application.propertiesにこのURLを設定します。

jest.connection-url=http://192.168.99.100:9200

Elasticesearchの設定は以上です。

アプリケーションの設定

最後にCateoLJ3を使うためのアプリケーションの設定を行います。

設定は非常に簡単で、DemoApplication.java@EnableCategoLJ3ApiServerをつけるだけです。

package com.example;

+ import am.ik.categolj3.api.EnableCategoLJ3ApiServer;
  import org.springframework.boot.SpringApplication;
  import org.springframework.boot.autoconfigure.SpringBootApplication;

  @SpringBootApplication
+ @EnableCategoLJ3ApiServer
  public class DemoApplication {

      public static void main(String[] args) {
          SpringApplication.run(DemoApplication.class, args);
      }
  }

DemoApplication.javamainメソッドを実行しましょう。

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::        (v1.3.1.RELEASE)

2015-12-29 02:02:24.025  INFO 11473 --- [           main] com.example.DemoApplication              : Starting DemoApplication on Toshiaki-no-iMac.local with PID 11473 (/Users/maki/Downloads/demo/target/classes started by maki in /Users/maki/Downloads/demo)
2015-12-29 02:02:24.027  INFO 11473 --- [           main] com.example.DemoApplication              : No active profile set, falling back to default profiles: default
2015-12-29 02:02:24.081  INFO 11473 --- [           main] ationConfigEmbeddedWebApplicationContext : Refreshing org.springframework.boot.context.embedded.AnnotationConfigEmbeddedWebApplicationContext@3439f68d: startup date [Tue Dec 29 02:02:24 JST 2015]; root of context hierarchy
2015-12-29 02:02:24.976  INFO 11473 --- [           main] o.s.b.f.s.DefaultListableBeanFactory     : Overriding bean definition for bean 'beanNameViewResolver' with a different definition: replacing [Root bean: class [null]; scope=; abstract=false; lazyInit=false; autowireMode=3; dependencyCheck=0; autowireCandidate=true; primary=false; factoryBeanName=org.springframework.boot.autoconfigure.web.ErrorMvcAutoConfiguration$WhitelabelErrorViewConfiguration; factoryMethodName=beanNameViewResolver; initMethodName=null; destroyMethodName=(inferred); defined in class path resource [org/springframework/boot/autoconfigure/web/ErrorMvcAutoConfiguration$WhitelabelErrorViewConfiguration.class]] with [Root bean: class [null]; scope=; abstract=false; lazyInit=false; autowireMode=3; dependencyCheck=0; autowireCandidate=true; primary=false; factoryBeanName=org.springframework.boot.autoconfigure.web.WebMvcAutoConfiguration$WebMvcAutoConfigurationAdapter; factoryMethodName=beanNameViewResolver; initMethodName=null; destroyMethodName=(inferred); defined in class path resource [org/springframework/boot/autoconfigure/web/WebMvcAutoConfiguration$WebMvcAutoConfigurationAdapter.class]]
2015-12-29 02:02:25.182  INFO 11473 --- [           main] o.s.s.a.AsyncAnnotationBeanPostProcessor : No TaskExecutor bean found for async annotation processing.
2015-12-29 02:02:25.213  INFO 11473 --- [           main] trationDelegate$BeanPostProcessorChecker : Bean 'org.springframework.cache.annotation.ProxyCachingConfiguration' of type [class org.springframework.cache.annotation.ProxyCachingConfiguration$$EnhancerBySpringCGLIB$$72e54ff7] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying)
2015-12-29 02:02:25.227  INFO 11473 --- [           main] trationDelegate$BeanPostProcessorChecker : Bean 'org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration' of type [class org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration$$EnhancerBySpringCGLIB$$ad0d501b] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying)
2015-12-29 02:02:25.321  INFO 11473 --- [           main] trationDelegate$BeanPostProcessorChecker : Bean 'spring.cache.CONFIGURATION_PROPERTIES' of type [class org.springframework.boot.autoconfigure.cache.CacheProperties] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying)
2015-12-29 02:02:25.329  INFO 11473 --- [           main] trationDelegate$BeanPostProcessorChecker : Bean 'org.springframework.boot.autoconfigure.cache.GuavaCacheConfiguration' of type [class org.springframework.boot.autoconfigure.cache.GuavaCacheConfiguration$$EnhancerBySpringCGLIB$$7cb3b918] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying)
2015-12-29 02:02:25.340  INFO 11473 --- [           main] trationDelegate$BeanPostProcessorChecker : Bean 'cacheManager' of type [class org.springframework.cache.guava.GuavaCacheManager] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying)
2015-12-29 02:02:25.341  INFO 11473 --- [           main] trationDelegate$BeanPostProcessorChecker : Bean 'cacheAutoConfigurationValidator' of type [class org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration$CacheManagerValidator] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying)
2015-12-29 02:02:25.550  INFO 11473 --- [           main] s.b.c.e.t.TomcatEmbeddedServletContainer : Tomcat initialized with port(s): 8080 (http)
2015-12-29 02:02:25.559  INFO 11473 --- [           main] o.apache.catalina.core.StandardService   : Starting service Tomcat
2015-12-29 02:02:25.560  INFO 11473 --- [           main] org.apache.catalina.core.StandardEngine  : Starting Servlet Engine: Apache Tomcat/8.0.30
2015-12-29 02:02:25.619  INFO 11473 --- [ost-startStop-1] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring embedded WebApplicationContext
2015-12-29 02:02:25.620  INFO 11473 --- [ost-startStop-1] o.s.web.context.ContextLoader            : Root WebApplicationContext: initialization completed in 1543 ms
2015-12-29 02:02:25.764  INFO 11473 --- [ost-startStop-1] o.s.b.c.e.ServletRegistrationBean        : Mapping servlet: 'dispatcherServlet' to [/]
2015-12-29 02:02:25.767  INFO 11473 --- [ost-startStop-1] o.s.b.c.embedded.FilterRegistrationBean  : Mapping filter: 'characterEncodingFilter' to: [/*]
2015-12-29 02:02:25.767  INFO 11473 --- [ost-startStop-1] o.s.b.c.embedded.FilterRegistrationBean  : Mapping filter: 'hiddenHttpMethodFilter' to: [/*]
2015-12-29 02:02:25.767  INFO 11473 --- [ost-startStop-1] o.s.b.c.embedded.FilterRegistrationBean  : Mapping filter: 'httpPutFormContentFilter' to: [/*]
2015-12-29 02:02:25.768  INFO 11473 --- [ost-startStop-1] o.s.b.c.embedded.FilterRegistrationBean  : Mapping filter: 'requestContextFilter' to: [/*]
2015-12-29 02:02:26.017  INFO 11473 --- [cTaskExecutor-1] a.i.c.api.git.GitStore$GitPullTask       : git pull https://github.com/making/categolj3-article-base.git
2015-12-29 02:02:26.195  INFO 11473 --- [           main] io.searchbox.client.JestClientFactory    : Node Discovery Disabled...
2015-12-29 02:02:26.504  INFO 11473 --- [           main] s.w.s.m.m.a.RequestMappingHandlerAdapter : Looking for @ControllerAdvice: org.springframework.boot.context.embedded.AnnotationConfigEmbeddedWebApplicationContext@3439f68d: startup date [Tue Dec 29 02:02:24 JST 2015]; root of context hierarchy
2015-12-29 02:02:26.545  INFO 11473 --- [           main] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped "{[/new]}" onto org.springframework.http.ResponseEntity<org.springframework.core.io.Resource> am.ik.categolj3.api.entry.EntryFileDownloadController.download()
2015-12-29 02:02:26.548  INFO 11473 --- [           main] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped "{[/api/entries],methods=[GET]}" onto org.springframework.data.domain.Page<am.ik.categolj3.api.entry.Entry> am.ik.categolj3.api.entry.EntryRestController.getEntries(org.springframework.data.domain.Pageable,boolean)
2015-12-29 02:02:26.548  INFO 11473 --- [           main] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped "{[/api/entries/{entryId}],methods=[GET]}" onto am.ik.categolj3.api.entry.Entry am.ik.categolj3.api.entry.EntryRestController.getEntry(java.lang.Long)
2015-12-29 02:02:26.549  INFO 11473 --- [           main] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped "{[/api/entries],methods=[GET],params=[q]}" onto org.springframework.data.domain.Page<am.ik.categolj3.api.entry.Entry> am.ik.categolj3.api.entry.EntryRestController.searchEntries(org.springframework.data.domain.Pageable,java.lang.String,boolean)
2015-12-29 02:02:26.549  INFO 11473 --- [           main] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped "{[/api/users/{createdBy}/entries],methods=[GET]}" onto org.springframework.data.domain.Page<am.ik.categolj3.api.entry.Entry> am.ik.categolj3.api.entry.EntryRestController.getEntriesByCreatedBy(org.springframework.data.domain.Pageable,java.lang.String,boolean)
2015-12-29 02:02:26.549  INFO 11473 --- [           main] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped "{[/api/users/{updatedBy}/entries],methods=[GET],params=[updated]}" onto org.springframework.data.domain.Page<am.ik.categolj3.api.entry.Entry> am.ik.categolj3.api.entry.EntryRestController.getEntriesByUpdatedBy(org.springframework.data.domain.Pageable,java.lang.String,boolean)
2015-12-29 02:02:26.549  INFO 11473 --- [           main] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped "{[/api/tags/{tag}/entries],methods=[GET]}" onto org.springframework.data.domain.Page<am.ik.categolj3.api.entry.Entry> am.ik.categolj3.api.entry.EntryRestController.getEntriesByTag(org.springframework.data.domain.Pageable,java.lang.String,boolean)
2015-12-29 02:02:26.549  INFO 11473 --- [           main] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped "{[/api/categories/{categories}/entries],methods=[GET]}" onto org.springframework.data.domain.Page<am.ik.categolj3.api.entry.Entry> am.ik.categolj3.api.entry.EntryRestController.getEntriesByCategories(org.springframework.data.domain.Pageable,java.lang.String,boolean)
2015-12-29 02:02:26.550  INFO 11473 --- [           main] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped "{[/api/tags],methods=[GET]}" onto java.util.List<java.lang.String> am.ik.categolj3.api.tag.TagRestController.list()
2015-12-29 02:02:26.551  INFO 11473 --- [           main] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped "{[/api/git/pull]}" onto java.util.concurrent.CompletableFuture<java.lang.String> am.ik.categolj3.api.git.GitRestController.pull()
2015-12-29 02:02:26.551  INFO 11473 --- [           main] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped "{[/api/jest/reindex]}" onto void am.ik.categolj3.api.jest.JestRestController.reindex()
2015-12-29 02:02:26.551  INFO 11473 --- [           main] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped "{[/api/categories],methods=[GET]}" onto java.util.List<java.util.List<java.lang.String>> am.ik.categolj3.api.category.CategoryRestController.list()
2015-12-29 02:02:26.553  INFO 11473 --- [           main] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped "{[/error]}" onto public org.springframework.http.ResponseEntity<java.util.Map<java.lang.String, java.lang.Object>> org.springframework.boot.autoconfigure.web.BasicErrorController.error(javax.servlet.http.HttpServletRequest)
2015-12-29 02:02:26.553  INFO 11473 --- [           main] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped "{[/error],produces=[text/html]}" onto public org.springframework.web.servlet.ModelAndView org.springframework.boot.autoconfigure.web.BasicErrorController.errorHtml(javax.servlet.http.HttpServletRequest,javax.servlet.http.HttpServletResponse)
2015-12-29 02:02:26.569  INFO 11473 --- [           main] o.s.w.s.handler.SimpleUrlHandlerMapping  : Mapped URL path [/webjars/**] onto handler of type [class org.springframework.web.servlet.resource.ResourceHttpRequestHandler]
2015-12-29 02:02:26.569  INFO 11473 --- [           main] o.s.w.s.handler.SimpleUrlHandlerMapping  : Mapped URL path [/**] onto handler of type [class org.springframework.web.servlet.resource.ResourceHttpRequestHandler]
2015-12-29 02:02:26.595  INFO 11473 --- [           main] o.s.w.s.handler.SimpleUrlHandlerMapping  : Mapped URL path [/**/favicon.ico] onto handler of type [class org.springframework.web.servlet.resource.ResourceHttpRequestHandler]
2015-12-29 02:02:26.687  INFO 11473 --- [           main] o.s.j.e.a.AnnotationMBeanExporter        : Registering beans for JMX exposure on startup
2015-12-29 02:02:26.746  INFO 11473 --- [           main] s.b.c.e.t.TomcatEmbeddedServletContainer : Tomcat started on port(s): 8080 (http)
2015-12-29 02:02:26.750  INFO 11473 --- [           main] com.example.DemoApplication              : Started DemoApplication in 3.179 seconds (JVM running for 3.561)
2015-12-29 02:02:27.078  INFO 11473 --- [cTaskExecutor-1] am.ik.categolj3.api.git.GitStore         : Syncing HEAD...
2015-12-29 02:02:31.707  INFO 11473 --- [pool-3-thread-1] am.ik.categolj3.api.event.EventManager   : Initialized

api/...へのリクエストマッピング情報が表示されていれば、CategoLJ3のREST APIサーバー起動しています。

記事を書く

記事は先ほど設定したGitリポジトリにpushすれば良いです。

GitHub上で直接ファイルを作成しても良いですが、ここではGitリポジトリをcloneしてコマンドラインで記事をpushします。

$ git clone https://github.com/making/categolj3-article-base.git
$ cd categolj3-article-base/content

記事用Markdownファイルのtemplate/newでダウンロードできます。

$ curl localhost:8080/new > 00001.md

ファイル名は連番([0-9]+).mdにする必要があります。0埋めはしてもしなくてもいいですが、0埋めしておいた方がソートできて良いです。ちなみに記事IDは先頭の0を削ったものになります。この場合は1が記事IDです。

00001.mdを以下のように書きます。

---
title: First article
tags: ["Demo"]
categories: ["Demo", "Hello"]
---

This is my first article using CategoLJ3!

デフォルトではgitの最初のコミット時刻が作成時刻、最後のコミット時刻が更新時刻になります。 これらを明示的に指定したい場合は、dateupdatedを使えます。

---
title: First article
tags: ["Demo"]
categories: ["Demo", "Hello"]
date: 2015-11-15T23:59:32+09:00
updated: 2015-11-15T23:59:32+09:00
---

このファイルをadd & commitしてpushしましょう。

$ git add -A
$ git commit -m "Add 00001.md"
$ git push origin master

この段階では、アプリ(REST APIサーバー)には記事は反映されません。定期的に毎時0分と30分にアプリがgit pullを行い、記事の反映を行います。 手動で反映する場合は/api/git/pullにアクセスすれば良いです。

$ curl localhost:8080/api/git/pull
org.eclipse.jgit.transport.FetchResult@2229323a
Merge of revisions 2962b707734c0c75a933e446a30e82b6f2e28562, 6c3d1509fb47e0e762f9cde29d1f7c5830b06d25 with base 6c3d1509fb47e0e762f9cde29d1f7c5830b06d25 using strategy recursive resulted in: Fast-forward. 

アプリケーションのログに、以下のようなログが出力されるでしょう。

2015-12-29 02:17:17.808  INFO 11473 --- [cTaskExecutor-3] a.i.c.api.git.GitStore$GitPullTask       : git pull https://github.com/making/categolj3-article-base.git
2015-12-29 02:17:19.256  INFO 11473 --- [cTaskExecutor-3] am.ik.categolj3.api.git.GitStore         : Syncing HEAD...
2015-12-29 02:17:19.257  INFO 11473 --- [cTaskExecutor-3] am.ik.categolj3.api.git.GitStore         : [ADD]	new=content/00001.md	old=/dev/null
2015-12-29 02:17:19.310  INFO 11473 --- [cTaskExecutor-3] am.ik.categolj3.api.git.GitStore         : put Entry(1)
2015-12-29 02:17:21.700  INFO 11473 --- [pool-3-thread-1] am.ik.categolj3.api.event.EventManager   : publish bulk put event
2015-12-29 02:17:21.702  INFO 11473 --- [pool-3-thread-1] am.ik.categolj3.api.jest.JestSync        : Bulk update (1)
2015-12-29 02:17:21.915  INFO 11473 --- [pool-3-thread-1] a.i.c.api.tag.InMemoryTagService         : bulk put (1)
2015-12-29 02:17:21.918  INFO 11473 --- [pool-3-thread-1] a.i.c.a.c.InMemoryCategoryService        : bulk put (1)

これで記事が反映されました。運用時はGitHubなどのwebhookにこのエンドポイントを設定しておけばpushのタイミングで記事が反映されます。

反映された記事は/api/entriesにアクセスすればJSON形式で取得できます。

$ curl localhost:8080/api/entries | jq .
{
  "content": [
    {
      "entryId": 1,
      "content": "This is my first article using CategoLJ3!",
      "created": {
        "name": "Toshiaki Maki",
        "date": "2015-12-29T02:16:23+09:00"
      },
      "updated": {
        "name": "Toshiaki Maki",
        "date": "2015-12-29T02:16:23+09:00"
      },
      "frontMatter": {
        "title": "First article",
        "tags": [
          "Demo"
        ],
        "categories": [
          "Demo",
          "Hello"
        ]
      }
    }
  ],
  "last": true,
  "totalPages": 1,
  "totalElements": 1,
  "size": 10,
  "number": 0,
  "sort": null,
  "first": true,
  "numberOfElements": 1
}

ページング用として、クエリパラメータにpage(ページ数、0始まり)とsize(1ページあたりの件数)を指定できます。

特定の記事のみアクセスしたい場合は/api/entries/<記事ID>にアクセスすれば良いです。

$ curl localhost:8080/api/entries/1 | jq .
{
  "entryId": 1,
  "content": "This is my first article using CategoLJ3!",
  "created": {
    "name": "Toshiaki Maki",
    "date": "2015-12-29T02:16:23+09:00"
  },
  "updated": {
    "name": "Toshiaki Maki",
    "date": "2015-12-29T02:16:23+09:00"
  },
  "frontMatter": {
    "title": "First article",
    "tags": [
      "Demo"
    ],
    "categories": [
      "Demo",
      "Hello"
    ]
  }
}

その他、APIガイド(準備中)は http://localhost:8080/docs/api-guide.html で確認できます。


CategoLJ3を使えば簡単にブログ用のREST APIを構築できることを紹介しました。 このREST APIをつかってオレオレブログを作ってみませんか?ブログに限らず、逆引き辞典を作るプラットフォームにもいかがでしょうか。

CategoLJ3はCloud FoundryやHerokuといったPaaSにも対応しているので、PaaSで運用する方法は別に紹介します。


✒️️ Edit  ⏰ History  🗑 Delete