CLOVER🍀

That was when it all began.

Go 1.16で追加された、embedパッケージ(Embedded Files)を試してみる

これは、なにをしたくて書いたもの?

Go 1.16で、embedというパッケージ(機能)が追加されたようなので、こちらを試してみたいなと思いまして。

ビルド時にファイルを埋め込み、アプリケーションの実行時にアクセスできる機能のようです。

embed

Go 1.16のリリースでの、embedに関する情報を並べてみます。

リリースをアナウンスしたブログ。

The new embed package provides access to files embedded at compile time using the new //go:embed directive. Now it is easy to bundle supporting data files into your Go programs, making developing with Go even smoother. You can get started using the embed package documentation. Carl Johnson has also written a nice tutorial, “How to use Go embed”.

Go 1.16 is released - The Go Blog

リリースノートと、embedについて書かれているもの。

Go 1.16 Release Notes - The Go Programming Language

Go 1.16 Release Notes / Embedded Files

Go 1.16 Release Notes / Embedding Files

embedパッケージ。

embed - The Go Programming Language

embed · pkg.go.dev

Goのブログからリンクされていたブログエントリ。

How to Use //go:embed · The Ethically-Trained Programmer

ざっくりした使い方は、embedパッケージのOverviewおよびDirectivesを見るとよいと思います。

embed - The Go Programming Language

使い方は、こんな感じみたいです。

  • embedパッケージをimportする
  • 埋め込むファイルやディレクトリを、//go:embed [パス]というコメント(ディレクティブ)でvarに対して指定する
  • ファイルやディレクトリを埋め込むvarで使える型
    • 単一のファイル
      • string
      • byteのスライス
    • ファイル(複数可)またはディレクト
      • embed.FS

stringbyteのスライスに埋め込むする場合は、embedパッケージは直接使わないのでimportは以下のようにすれば
OKみたいです。
import自体は必要です

import _ "embed"

あとは、実際に試してみましょうか。

環境

今回の環境は、こちらです。

$ go version
go version go1.16.2 linux/amd64

確認用のプロジェクトを作成。

$ go mod init embed-example
go: creating new go.mod: module embed-example

go.mod

module embed-example

go 1.16

試してみる

では、embedパッケージの機能を試してみましょう。

最初は、stringbyteのスライスに対して、ファイルの中身を埋め込んでみます。

hello.txt

Hello Go!!

こんなプログラムを作成。

main.go

package main

import (
    _ "embed"
    "fmt"
)

var (
    //go:embed hello.txt
    message string

    //go:embed hello.txt
    messageBinary []byte
)

func main() {
    fmt.Printf("embedded as string: %s\n", message)

    fmt.Printf("embedded as binary: %#v\n", messageBinary)
}

こんなディレクティブ付きのvarで、ファイルの中身を埋め込めます。

var (
    //go:embed hello.txt
    message string

    //go:embed hello.txt
    messageBinary []byte
)

こういうのを、ディレクティブって言うんですね。

Command compile / Compiler Directives

この時、embedパッケージをimportしておくのがポイントです。embedパッケージ自体は使わないので、_としています。

import (
    _ "embed"
    "fmt"
)

実行結果。

$ go run main.go 
embedded as string: Hello Go!!

embedded as binary: []byte{0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x20, 0x47, 0x6f, 0x21, 0x21, 0xa}

ファイルの中身がvarに入っていますね。

次は、embed.FSを使ってみましょう。今のmain関数は1度リネームして、

func __main() {

ファイル名もリネーム。

$ mv main.go __main.go

ディレクトリを作成しましょう。

$ mkdir -p resources/sub

この中に、ファイルを作っていきます。

$ echo message1 > resources/message1.txt
$ echo message2 > resources/message2.txt
$ echo greeting > resources/sub/greeting.txt

全部で、ファイルはこれだけです。

$ find hello.txt resources -type f
hello.txt
resources/message2.txt
resources/sub/greeting.txt
resources/message1.txt

では、新しくプログラムを作成。

main.go

package main

import (
    "embed"
    "fmt"
    "log"
)

var (
    //go:embed hello.txt
    file embed.FS

    //go:embed resources/message1.txt resources/message2.txt
    messages embed.FS

    //go:embed resources/*.txt
    messagesUsingWildcard embed.FS

    //go:embed resources
    resourcesDir embed.FS
)

func main() {
    fmt.Println("========== file ==========")

    contents, err := file.ReadFile("hello.txt")

    if err != nil {
        log.Fatal(err)
    }

    fmt.Printf("file contents: %s\n", string(contents))

    fmt.Println("========== messages ==========")

    entries, err := messages.ReadDir("resources")

    if err != nil {
        log.Fatal(err)
    }

    for _, entry := range entries {
        c, err := messages.ReadFile("resources/" + entry.Name())

        if err != nil {
            log.Fatal(err)
        }

        fmt.Printf("name: resources/%s, contents: %s\n", entry.Name(), string(c))
    }

    fmt.Println("========== messagesUsingWildcard ==========")

    entries, err = messagesUsingWildcard.ReadDir("resources")

    if err != nil {
        log.Fatal(err)
    }

    for _, entry := range entries {
        c, err := messagesUsingWildcard.ReadFile("resources/" + entry.Name())

        if err != nil {
            log.Fatal(err)
        }

        fmt.Printf("name: resources/%s, contents: %s\n", entry.Name(), string(c))
    }

    fmt.Println("========== resourcesDir ==========")

    entries, err = resourcesDir.ReadDir("resources")

    if err != nil {
        log.Fatal(err)
    }

    for _, entry := range entries {
        if !entry.IsDir() {
            c, err := messages.ReadFile("resources/" + entry.Name())

            if err != nil {
                log.Fatal(err)
            }

            fmt.Printf("name: resources/%s, contents: %s\n", entry.Name(), string(c))
        }
    }

    entries, err = resourcesDir.ReadDir("resources/sub")

    if err != nil {
        log.Fatal(err)
    }

    for _, entry := range entries {
        if !entry.IsDir() {
            c, err := resourcesDir.ReadFile("resources/sub/" + entry.Name())

            if err != nil {
                log.Fatal(err)
            }

            fmt.Printf("name: resources/sub/%s, contents: %s\n", entry.Name(), string(c))
        }
    }
}

今後は、embedパッケージを使います。

import (
    "embed"
    "fmt"
    "log"
)

embed.FS型の変数に対して、ファイルやディレクトリを指定します。

var (
    //go:embed hello.txt
    file embed.FS

    //go:embed resources/message1.txt resources/message2.txt
    messages embed.FS

    //go:embed resources/*.txt
    messagesUsingWildcard embed.FS

    //go:embed resources
    resourcesDir embed.FS
)

embed.FSは、読み取り専用のファイルのコレクションです。

Package embed / type FS

main.go

package main

import (
    _ "embed"
    "fmt"
)

var (
    //go:embed hello.txt
    message string

    //go:embed hello.txt
    messageBinary []byte
)

func main() {
    fmt.Printf("embedded as string: %s\n", message)

    fmt.Printf("embedded as binary: %#v\n", messageBinary)
}

embed.FSからは、ファイルやディレクトリを開いたりできます。

単一のファイルを埋め込み、

   //go:embed hello.txt
    file embed.FS

開いた場合。

   fmt.Println("========== file ==========")

    contents, err := file.ReadFile("hello.txt")

    if err != nil {
        log.Fatal(err)
    }

    fmt.Printf("file contents: %s\n", string(contents))

結果。

========== file ==========
file contents: Hello Go!!

複数のファイルを埋め込んだ場合。

   //go:embed resources/message1.txt resources/message2.txt
    messages embed.FS

    //go:embed resources/*.txt
    messagesUsingWildcard embed.FS

スペース区切りで複数指定したり、path.Matchで使えるパターンで指定できたりします。

Package embed / Directives

Package path / func Match

今回はやっていませんが、複数回の指定も可能みたいです。

// content holds our static web server content.
//go:embed image/* template/*
//go:embed html/index.html
var content embed.FS

パスの区切り文字は/Windowsでも)で、...を含めることはできません。/で開始したり、/で終了することも
許可されません。ディレクトリ内のファイルをすべて含めるには、*を使います。

The patterns are interpreted relative to the package directory containing the source file. The path separator is a forward slash, even on Windows systems. Patterns may not contain ‘.’ or ‘..’ or empty path elements, nor may they begin or end with a slash. To match everything in the current directory, use ‘*’ instead of ‘.’.

Package embed / Directives

今回は階層を含むので、ディレクトリも認識してくれます。

   fmt.Println("========== messages ==========")

    entries, err := messages.ReadDir("resources")

    if err != nil {
        log.Fatal(err)
    }

    for _, entry := range entries {
        c, err := messages.ReadFile("resources/" + entry.Name())

        if err != nil {
            log.Fatal(err)
        }

        fmt.Printf("name: resources/%s, contents: %s\n", entry.Name(), string(c))
    }

    fmt.Println("========== messagesUsingWildcard ==========")

    entries, err = messagesUsingWildcard.ReadDir("resources")

    if err != nil {
        log.Fatal(err)
    }

    for _, entry := range entries {
        c, err := messagesUsingWildcard.ReadFile("resources/" + entry.Name())

        if err != nil {
            log.Fatal(err)
        }

        fmt.Printf("name: resources/%s, contents: %s\n", entry.Name(), string(c))
    }

結果。

========== messages ==========
name: resources/message1.txt, contents: message1

name: resources/message2.txt, contents: message2

========== messagesUsingWildcard ==========
name: resources/message1.txt, contents: message1

name: resources/message2.txt, contents: message2

ディレクトリそのものを埋め込む場合。

   //go:embed resources
    resourcesDir embed.FS

この場合、対象のディレクトリおよびサブディレクトリが再帰的に埋め込まれます。特定ディレクトリ配下のファイルを公開する
Webサーバーのような用途に合いそうですね。

If a pattern names a directory, all files in the subtree rooted at that directory are embedded (recursively), except that files with names beginning with ‘.’ or ‘_’ are excluded.

Package embed / Directives

ディレクトリエントリを再帰的に埋め込むものの、.または_で始まるファイルエントリは含まれません。
ワイルドカード*)や直接ファイルを指定した場合との差は、.などで始まるファイルに現れることになります。

埋め込んだファイルやディレクトリにアクセスするコード。

   fmt.Println("========== resourcesDir ==========")

    entries, err = resourcesDir.ReadDir("resources")

    if err != nil {
        log.Fatal(err)
    }

    for _, entry := range entries {
        if !entry.IsDir() {
            c, err := messages.ReadFile("resources/" + entry.Name())

            if err != nil {
                log.Fatal(err)
            }

            fmt.Printf("name: resources/%s, contents: %s\n", entry.Name(), string(c))
        }
    }

    entries, err = resourcesDir.ReadDir("resources/sub")

    if err != nil {
        log.Fatal(err)
    }

    for _, entry := range entries {
        if !entry.IsDir() {
            c, err := resourcesDir.ReadFile("resources/sub/" + entry.Name())

            if err != nil {
                log.Fatal(err)
            }

            fmt.Printf("name: resources/sub/%s, contents: %s\n", entry.Name(), string(c))
        }
    }

実行結果。

========== resourcesDir ==========
name: resources/message1.txt, contents: message1

name: resources/message2.txt, contents: message2

name: resources/sub/greeting.txt, contents: greeting

go:embedで直接指定していないサブディレクトリも含めて、アクセスできていることが確認できます。

ざっと、使い方はこんな感じでしょうか。

もう少し挙動や中身を見てみる

基本的なことはわかった気がするものの、もうちょっと気になるところを追ってみましょう。

確認するファイルを、こちらに戻します。

main.go

package main

import (
    _ "embed"
    "fmt"
)

var (
    //go:embed hello.txt
    message string

    //go:embed hello.txt
    messageBinary []byte
)

func main() {
    fmt.Printf("embedded as string: %s\n", message)

    fmt.Printf("embedded as binary: %#v\n", messageBinary)
}

たとえば、//go:embedの間にスペースを入れてみます。

var (
    // go:embed hello.txt
    message string

    //go:embed hello.txt
    messageBinary []byte
)

すると、ディレクティブとして認識しなくなり、値が入らなくなります。

$ go run main.go 
embedded as string: 
embedded as binary: []byte{0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x20, 0x47, 0x6f, 0x21, 0x21, 0xa}

ディレクティブは、そういうものみたいですね。

Command compile / Compiler Directives

次。関数内のvarにディレクティブを指定してみます。

func main() {
    fmt.Printf("embedded as string: %s\n", message)

    fmt.Printf("embedded as binary: %#v\n", messageBinary)

    //go:embed hello.txt
    var localMessage string
}

これは、エラーになります。

$ go run main.go 
# command-line-arguments
./main.go:21:4: go:embed cannot apply to var inside func

ディレクティブに指定したファイルが存在しない場合。

var (
    //go:embed hello.tx
    message string

    //go:embed hello.txt
    messageBinary []byte
)

これもエラーになります。

$ go run main.go 
main.go:9:13: pattern hello.tx: no matching files found

go:embedディレクティブを使っているのに、embedパッケージのインポートを忘れた場合。

import (
    // _ "embed"
    "fmt"
)

これも、エラーになります。

$ go run main.go 
# command-line-arguments
./main.go:9:4: //go:embed only allowed in Go files that import "embed"
./main.go:12:4: //go:embed only allowed in Go files that import "embed"

こういったチェックは、このあたりのファイルで行われています。

https://github.com/golang/go/blob/go1.16.2/src/cmd/compile/internal/gc/embed.go

   pos := embeds[0].Pos
    if !haveEmbed {
        p.yyerrorpos(pos, "invalid go:embed: missing import \"embed\"")
        return
    }
    if len(names) > 1 {
        p.yyerrorpos(pos, "go:embed cannot apply to multiple vars")
        return
    }
    if len(exprs) > 0 {
        p.yyerrorpos(pos, "go:embed cannot apply to var with initializer")
        return
    }
    if typ == nil {
        // Should not happen, since len(exprs) == 0 now.
        p.yyerrorpos(pos, "go:embed cannot apply to var without type")
        return
    }
    if dclcontext != PEXTERN {
        p.yyerrorpos(pos, "go:embed cannot apply to var inside func")
        return
    }

このあたりを見ると、書けない場所はわかりそうですね。

https://github.com/golang/go/blob/go1.16.2/src/cmd/compile/internal/gc/noder.go

ドキュメントにもありましたが、相対パス指定で上位ディレクトリを参照することはできません。

var (
    //go:embed ../hello.txt
    message string

    //go:embed hello.txt
    messageBinary []byte
)

エラーになります。

$ go run main.go 
main.go:9:13: pattern ../hello.txt: invalid pattern syntax

このチェックをしているのは、こちらです。

https://github.com/golang/go/blob/go1.16.2/src/cmd/go/internal/load/pkg.go#L2087-L2089

.以外については、fs.ValidPathを使って確認しています。

Package fs / func ValidPath

該当のパッケージで使われているgo:embedなファイルやディレクトリは、go listで確認することができます。

以下のフィールドが該当します。

        // Embedded files
        EmbedPatterns      []string // //go:embed patterns
        EmbedFiles         []string // files matched by EmbedPatterns
        TestEmbedPatterns  []string // //go:embed patterns in TestGoFiles
        TestEmbedFiles     []string // files matched by TestEmbedPatterns
        XTestEmbedPatterns []string // //go:embed patterns in XTestGoFiles
        XTestEmbedFiles    []string // files matched by XTestEmbedPatterns

現在のパッケージに含まれるEmbedPatternsEmbedFilesは、以下で確認できます。

$ go list -f '{{.EmbedPatterns}}'

$ go list -f '{{.EmbedFiles}}'

実行例。

$ go list -f '{{.EmbedPatterns}}' 
[hello.txt resources resources/*.txt resources/message1.txt resources/message2.txt]


$ go list -f '{{.EmbedFiles}}'
[hello.txt resources/message1.txt resources/message2.txt resources/sub/greeting.txt]

こちらのコマンドでも見れます。

$ go list -json

EmbedPatternsEmbedFilesの結果の例だけ載せておきます。

   "EmbedPatterns": [
        "hello.txt",
        "resources",
        "resources/*.txt",
        "resources/message1.txt",
        "resources/message2.txt"
    ],
    "EmbedFiles": [
        "hello.txt",
        "resources/message1.txt",
        "resources/message2.txt",
        "resources/sub/greeting.txt"
    ],

ただ、プロジェクト全体で埋め込んでいるものがわかるかというとそうでもなく、あくまでパッケージ単位です。
各パッケージごとに確認する必要があります。

まとめ

Go 1.16で追加されたembedパッケージを使って、Goプログラムにファイルやディレクトリを埋め込むことができることを
確認してみました。

いいですね、この機能。

使いそうなシーンも多そうなので、覚えておきましょう。

Azure Functions(Java)のTimerTriggerをローカルで動かしてみる

これは、なにをしたくて書いたもの?

前に、Azure FunctionsのHttpTriggerをローカルで動かしてみました。

Azure Functions(Java)をローカルで動かしてみる - CLOVER🍀

今回は、TimerTriggerを動かしてみたいと思います。

TimerTrigger

TimerTriggerは、Azure Functionsで関数をスケジュールに沿って実行できる仕組みです。

Azure Functions のタイマー トリガー | Microsoft Docs

スケジュールの指定は、cron式とほぼ同じです(NCronTabというものを使うようです)。

Azure Functions のタイマー トリガー / NCRONTAB 式

今回は、TimerTriggerで実行する関数をJavaで作成してローカルで動かしてみます。

環境

今回の環境は、こちらです。

$ func --version
3.0.3388


$ java --version
openjdk 11.0.10 2021-01-19
OpenJDK Runtime Environment (build 11.0.10+9-Ubuntu-0ubuntu1.20.04)
OpenJDK 64-Bit Server VM (build 11.0.10+9-Ubuntu-0ubuntu1.20.04, mixed mode, sharing)


$ mvn --version
Apache Maven 3.6.3 (cecedd343002696d0abb50b32b541b8a6ba2883f)
Maven home: $HOME/.sdkman/candidates/maven/current
Java version: 11.0.10, vendor: Ubuntu, runtime: /usr/lib/jvm/java-11-openjdk-amd64
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "5.4.0-70-generic", arch: "amd64", family: "unix"

プロジェクトを作成する

Azure Functionsで、TimerTriggerを使うプロジェクトを作成しましょう。

Mavenアーキタイプを使って作成できます。

クイックスタート: コマンド ラインから Azure に Java 関数を作成する / ローカル関数プロジェクトを作成する

$ mvn archetype:generate \
 -DarchetypeGroupId=com.microsoft.azure \
 -DarchetypeArtifactId=azure-functions-archetype \
 -DgroupId=org.littlewings \
 -DartifactId=hello-java-function-timer-trigger \
 -Dversion=0.0.1-SNAPSHOT \
 -Dpackage=org.littlewings.azure.function \
 -DappName=hello-java-function-timer-trigger \
 -DappRegion=japaneast \
 -Dtrigger=TimerTrigger \
 -DjavaVersion=11 \
 -DinteractiveMode=false

ポイントは、こちらの指定ですね。

-Dtrigger=TimerTrigger

プロジェクトが作成できたら、プロジェクト内へ移動。

$  cd hello-java-function-timer-trigger

作成されたファイル一式。

$ tree -a
.
├── .gitignore
├── host.json
├── local.settings.json
├── pom.xml
└── src
    └── main
        └── java
            └── org
                └── littlewings
                    └── Function.java

5 directories, 5 files

テストコードは作成されないみたいですね。

pom.xmlの主要な部分。

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <java.version>11</java.version>
        <azure.functions.maven.plugin.version>1.9.2</azure.functions.maven.plugin.version>
        <azure.functions.java.library.version>1.4.0</azure.functions.java.library.version>
        <functionAppName>hello-java-function-timer-trigger</functionAppName>
        <stagingDirectory>${project.build.directory}/azure-functions/${functionAppName}</stagingDirectory>
    </properties>

    <dependencies>
        <dependency>
            <groupId>com.microsoft.azure.functions</groupId>
            <artifactId>azure-functions-java-library</artifactId>
            <version>${azure.functions.java.library.version}</version>
        </dependency>

        <!-- Test -->
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>5.4.2</version>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.mockito</groupId>
            <artifactId>mockito-core</artifactId>
            <version>2.23.4</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.8.1</version>
                <configuration>
                    <source>${java.version}</source>
                    <target>${java.version}</target>
                    <encoding>${project.build.sourceEncoding}</encoding>
                </configuration>
            </plugin>
            <plugin>
                <groupId>com.microsoft.azure</groupId>
                <artifactId>azure-functions-maven-plugin</artifactId>
                <version>${azure.functions.maven.plugin.version}</version>
                <configuration>
                    <!-- function app name -->
                    <appName>${functionAppName}</appName>
                    <!-- function app resource group -->
                    <resourceGroup>java-functions-group</resourceGroup>
                    <!-- function app service plan name -->
                    <appServicePlanName>java-functions-app-service-plan</appServicePlanName>
                    <!-- function app region-->
                    <!-- refers https://github.com/microsoft/azure-maven-plugins/wiki/Azure-Functions:-Configuration-Details#supported-regions for all valid values -->
                    <region>japaneast</region>
                    <!-- function pricingTier, default to be consumption if not specified -->
                    <!-- refers https://github.com/microsoft/azure-maven-plugins/wiki/Azure-Functions:-Configuration-Details#supported-pricing-tiers for all valid values -->
                    <!-- <pricingTier></pricingTier> -->
                    
                    <!-- Whether to disable application insights, default is false -->
                    <!-- refers https://github.com/microsoft/azure-maven-plugins/wiki/Azure-Functions:-Configuration-Details for all valid configurations for application insights-->
                    <!-- <disableAppInsights></disableAppInsights> -->
                    <runtime>
                        <!-- runtime os, could be windows, linux or docker-->
                        <os>windows</os>
                        <javaVersion>11</javaVersion>
                        <!-- for docker function, please set the following parameters -->
                        <!-- <image>[hub-user/]repo-name[:tag]</image> -->
                        <!-- <serverId></serverId> -->
                        <!-- <registryUrl></registryUrl>  -->
                    </runtime>
                    <appSettings>
                        <property>
                            <name>FUNCTIONS_EXTENSION_VERSION</name>
                            <value>~3</value>
                        </property>
                    </appSettings>
                </configuration>
                <executions>
                    <execution>
                        <id>package-functions</id>
                        <goals>
                            <goal>package</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-resources-plugin</artifactId>
                <version>3.1.0</version>
                <executions>
                    <execution>
                        <id>copy-resources</id>
                        <phase>package</phase>
                        <goals>
                            <goal>copy-resources</goal>
                        </goals>
                        <configuration>
                            <overwrite>true</overwrite>
                            <outputDirectory>${stagingDirectory}</outputDirectory>
                            <resources>
                                <resource>
                                    <directory>${project.basedir}</directory>
                                    <includes>
                                        <include>host.json</include>
                                        <include>local.settings.json</include>
                                    </includes>
                                </resource>
                            </resources>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-dependency-plugin</artifactId>
                <version>3.1.1</version>
                <executions>
                    <execution>
                        <id>copy-dependencies</id>
                        <phase>prepare-package</phase>
                        <goals>
                            <goal>copy-dependencies</goal>
                        </goals>
                        <configuration>
                            <outputDirectory>${stagingDirectory}/lib</outputDirectory>
                            <overWriteReleases>false</overWriteReleases>
                            <overWriteSnapshots>false</overWriteSnapshots>
                            <overWriteIfNewer>true</overWriteIfNewer>
                            <includeScope>runtime</includeScope>
                            <excludeArtifactIds>azure-functions-java-library</excludeArtifactIds>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
            <!--Remove obj folder generated by .NET SDK in maven clean-->
            <plugin>
                <artifactId>maven-clean-plugin</artifactId>
                <version>3.1.0</version>
                <configuration>
                    <filesets>
                        <fileset>
                            <directory>obj</directory>
                        </fileset>
                    </filesets>
                </configuration>
            </plugin>
        </plugins>

設定ファイル。

local.settings.json

{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "",
    "FUNCTIONS_WORKER_RUNTIME": "java"
  }
}

host.json

{
  "version": "2.0",
  "extensionBundle": {
    "id": "Microsoft.Azure.Functions.ExtensionBundle",
    "version": "[1.*, 2.0.0)"
  } 
}

生成されたソースコード。TimerTrigger用になっていますね。

src/main/java/org/littlewings/Function.java

package org.littlewings;

import java.time.*;
import com.microsoft.azure.functions.annotation.*;
import com.microsoft.azure.functions.*;

/**
 * Azure Functions with Timer trigger.
 */
public class Function {
    /**
     * This function will be invoked periodically according to the specified schedule.
     */
    @FunctionName("Function")
    public void run(
        @TimerTrigger(name = "timerInfo", schedule = "0 * * * * *") String timerInfo,
        final ExecutionContext context
    ) {
        context.getLogger().info("Java Timer trigger function executed at: " + LocalDateTime.now());
    }
}

定義を見ると、どうやら毎分起動してログ出力するTimerTriggerのようです。

    @FunctionName("Function")
    public void run(
        @TimerTrigger(name = "timerInfo", schedule = "0 * * * * *") String timerInfo,
        final ExecutionContext context
    ) {
        context.getLogger().info("Java Timer trigger function executed at: " + LocalDateTime.now());
    }

動かしてみる

とりあえず、この状態でも動かしてみましょう。

パッケージングして

$ mvn package

実行。

$ mvn azure-functions:run
[INFO] Azure Function App's staging directory found at: /path/to/hello-java-function-timer-trigger/target/azure-functions/hello-java-function-timer-trigger
[INFO] Azure Functions Core Tools found.

Azure Functions Core Tools
Core Tools Version:       3.0.3388 Commit hash: fb42a4e0b7fdc85fbd0bcfc8d743ff7d509122ae 
Function Runtime Version: 3.0.15371.0

Missing value for AzureWebJobsStorage in local.settings.json. This is required for all triggers other than httptrigger, kafkatrigger. You can run 'func azure functionapp fetch-app-settings <functionAppName>' or specify a connection string in local.settings.json.
[ERROR] 
[ERROR] Failed to run Azure Functions. Please checkout console output.
[INFO] ------------------------------------------------------------------------
[INFO] BUILD FAILURE
[INFO] ------------------------------------------------------------------------

エラーになってしまいました。どうやら、AzureWebJobsStorageというものを指定する必要があるようです。

Missing value for AzureWebJobsStorage in local.settings.json. This is required for all triggers other than httptrigger, kafkatrigger. You can run 'func azure functionapp fetch-app-settings <functionAppName>' or specify a connection string in local.settings.json.

local.settings.jsonを見ると、該当の部分が空欄になっていますね。

local.settings.json

{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "",
    "FUNCTIONS_WORKER_RUNTIME": "java"
  }
}

TimerTriggerで使いそうなことが、ドキュメントに書かれています。

Azure Functions ランタイムでは、このストレージ アカウント接続文字列は通常の操作に使用されます。 このストレージ アカウントの使用方法としては、キー管理、タイマー トリガー管理、Event Hubs チェックポイントなどがあります。

Azure Functions のアプリケーション設定のリファレンス / AzureWebJobsStorage

こちらを見ても、必須のようですね。ローカルでエミュレーターを使う方法も書いてあるので、こちらに習うことにします。

Azure ストレージ アカウントの接続文字列を含みます。 HTTP 以外のトリガーを使用する場合には必須です。 詳しくは、AzureWebJobsStorage のリファレンスを参照してください。 Azure ストレージ エミュレーターがローカルにインストールされ、AzureWebJobsStorage を UseDevelopmentStorage=true に設定すると、Core Tools でエミュレーターが使用されます。

Azure Functions Core Tools の操作 / ローカル設定ファイル

Azure Storageエミュレーターとしては、Azuriteを使うことにします。

$ npx azurite --version
3.11.0

起動。

$ mkdir data
$ npx azurite -l data

この状態で、local.settings.jsonAzureWebJobsStorageを以下のように指定します。

local.settings.json

{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "UseDevelopmentStorage=true",
    "FUNCTIONS_WORKER_RUNTIME": "java"
  }
}

以下でもOKです。

local.settings.json

{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;QueueEndpoint=http://127.0.0.1:10001/devstoreaccount1;",
    "FUNCTIONS_WORKER_RUNTIME": "java"
  }
}

再度パッケージングして、実行。

$ mvn package
$ mvn azure-functions:run

今度は起動しました。

[INFO] Azure Function App's staging directory found at: /path/to/hello-java-function-timer-trigger/target/azure-functions/hello-java-function-timer-trigger
[INFO] Azure Functions Core Tools found.

Azure Functions Core Tools
Core Tools Version:       3.0.3388 Commit hash: fb42a4e0b7fdc85fbd0bcfc8d743ff7d509122ae 
Function Runtime Version: 3.0.15371.0

[2021-03-26T05:30:50.795Z] Worker process started and initialized.

Functions:

    Function: timerTrigger

For detailed output, run func with --verbose flag.
[2021-03-26T05:30:56.321Z] Host lock lease acquired by instance ID '000000000000000000000000C3CC5E65'.

関数も、スケジュールに沿って実行されました。

[2021-03-26T05:30:56.321Z] Host lock lease acquired by instance ID '000000000000000000000000C3CC5E65'.
[2021-03-26T05:31:00.052Z] Executing 'Functions.Function' (Reason='Timer fired at 2021-03-26T14:31:00.0210745+09:00', Id=dab7f753-3f36-4fb8-a66f-2da51d77b0ff)
[2021-03-26T05:31:00.205Z] Java Timer trigger function executed at: 2021-03-26T14:31:00.193219
[2021-03-26T05:31:00.206Z] Function "Function" (Id: dab7f753-3f36-4fb8-a66f-2da51d77b0ff) invoked by Java Worker
[2021-03-26T05:31:00.227Z] Executed 'Functions.Function' (Succeeded, Id=dab7f753-3f36-4fb8-a66f-2da51d77b0ff, Duration=198ms)

定義を見返すと、毎分起動してログ出力するようになっています。

    @FunctionName("Function")
    public void run(
        @TimerTrigger(name = "timerInfo", schedule = "0 * * * * *") String timerInfo,
        final ExecutionContext context
    ) {
        context.getLogger().info("Java Timer trigger function executed at: " + LocalDateTime.now());
    }

以下が、ログ出力した結果ですね。

[2021-03-26T05:31:00.205Z] Java Timer trigger function executed at: 2021-03-26T14:31:00.193219

このまま起動させておくと、毎分実行されるのが確認できます。

[2021-03-26T05:31:00.052Z] Executing 'Functions.Function' (Reason='Timer fired at 2021-03-26T14:31:00.0210745+09:00', Id=dab7f753-3f36-4fb8-a66f-2da51d77b0ff)
[2021-03-26T05:31:00.205Z] Java Timer trigger function executed at: 2021-03-26T14:31:00.193219
[2021-03-26T05:31:00.206Z] Function "Function" (Id: dab7f753-3f36-4fb8-a66f-2da51d77b0ff) invoked by Java Worker
[2021-03-26T05:31:00.227Z] Executed 'Functions.Function' (Succeeded, Id=dab7f753-3f36-4fb8-a66f-2da51d77b0ff, Duration=198ms)
[2021-03-26T05:32:00.005Z] Executing 'Functions.Function' (Reason='Timer fired at 2021-03-26T14:32:00.0022770+09:00', Id=17c6812a-3adb-4140-b8b8-a07e657c5a7d)
[2021-03-26T05:32:00.014Z] Java Timer trigger function executed at: 2021-03-26T14:32:00.013418
[2021-03-26T05:32:00.018Z] Function "Function" (Id: 17c6812a-3adb-4140-b8b8-a07e657c5a7d) invoked by Java Worker
[2021-03-26T05:32:00.019Z] Executed 'Functions.Function' (Succeeded, Id=17c6812a-3adb-4140-b8b8-a07e657c5a7d, Duration=17ms)
[2021-03-26T05:33:00.002Z] Executing 'Functions.Function' (Reason='Timer fired at 2021-03-26T14:33:00.0008706+09:00', Id=8e78ed40-67b2-4076-b767-381f36b86b50)
[2021-03-26T05:33:00.008Z] Java Timer trigger function executed at: 2021-03-26T14:33:00.006725
[2021-03-26T05:33:00.009Z] Function "Function" (Id: 8e78ed40-67b2-4076-b767-381f36b86b50) invoked by Java Worker
[2021-03-26T05:33:00.010Z] Executed 'Functions.Function' (Succeeded, Id=8e78ed40-67b2-4076-b767-381f36b86b50, Duration=9ms)

Azurite側にも、アクセスが来ていることが確認できます。

127.0.0.1 - - [26/Mar/2021:05:31:00 +0000] "PUT /devstoreaccount1/azure-webjobs-hosts/timers/hostname-1510718391/Host.Functions.Function/status HTTP/1.1" 201 -
127.0.0.1 - - [26/Mar/2021:05:31:06 +0000] "PUT /devstoreaccount1/azure-webjobs-hosts/locks/hostname-1510718391/Host.Functions.Function.Listener?comp=lease HTTP/1.1" 200 -
127.0.0.1 - - [26/Mar/2021:05:31:08 +0000] "PUT /devstoreaccount1/azure-webjobs-hosts/locks/hostname-1510718391/host?comp=lease HTTP/1.1" 200 -
127.0.0.1 - - [26/Mar/2021:05:31:13 +0000] "PUT /devstoreaccount1/azure-webjobs-hosts/locks/hostname-1510718391/Host.Functions.Function.Listener?comp=lease HTTP/1.1" 200 -
127.0.0.1 - - [26/Mar/2021:05:31:20 +0000] "PUT /devstoreaccount1/azure-webjobs-hosts/locks/hostname-1510718391/host?comp=lease HTTP/1.1" 200 -
127.0.0.1 - - [26/Mar/2021:05:31:21 +0000] "PUT /devstoreaccount1/azure-webjobs-hosts/locks/hostname-1510718391/Host.Functions.Function.Listener?comp=lease HTTP/1.1" 200 -
127.0.0.1 - - [26/Mar/2021:05:31:28 +0000] "PUT /devstoreaccount1/azure-webjobs-hosts/locks/hostname-1510718391/Host.Functions.Function.Listener?comp=lease HTTP/1.1" 200 -
127.0.0.1 - - [26/Mar/2021:05:31:32 +0000] "PUT /devstoreaccount1/azure-webjobs-hosts/locks/hostname-1510718391/host?comp=lease HTTP/1.1" 200 -
127.0.0.1 - - [26/Mar/2021:05:31:36 +0000] "PUT /devstoreaccount1/azure-webjobs-hosts/locks/hostname-1510718391/Host.Functions.Function.Listener?comp=lease HTTP/1.1" 200 -
127.0.0.1 - - [26/Mar/2021:05:31:43 +0000] "PUT /devstoreaccount1/azure-webjobs-hosts/locks/hostname-1510718391/Host.Functions.Function.Listener?comp=lease HTTP/1.1" 200 -
127.0.0.1 - - [26/Mar/2021:05:31:44 +0000] "PUT /devstoreaccount1/azure-webjobs-hosts/locks/hostname-1510718391/host?comp=lease HTTP/1.1" 200 -
127.0.0.1 - - [26/Mar/2021:05:31:51 +0000] "PUT /devstoreaccount1/azure-webjobs-hosts/locks/hostname-1510718391/Host.Functions.Function.Listener?comp=lease HTTP/1.1" 200 -
127.0.0.1 - - [26/Mar/2021:05:31:56 +0000] "PUT /devstoreaccount1/azure-webjobs-hosts/locks/hostname-1510718391/host?comp=lease HTTP/1.1" 200 -
127.0.0.1 - - [26/Mar/2021:05:31:58 +0000] "PUT /devstoreaccount1/azure-webjobs-hosts/locks/hostname-1510718391/Host.Functions.Function.Listener?comp=lease HTTP/1.1" 200 -
127.0.0.1 - - [26/Mar/2021:05:32:00 +0000] "PUT /devstoreaccount1/azure-webjobs-hosts/timers/hostname-1510718391/Host.Functions.Function/status HTTP/1.1" 201 -
127.0.0.1 - - [26/Mar/2021:05:32:06 +0000] "PUT /devstoreaccount1/azure-webjobs-hosts/locks/hostname-1510718391/Host.Functions.Function.Listener?comp=lease HTTP/1.1" 200 -
127.0.0.1 - - [26/Mar/2021:05:32:08 +0000] "PUT /devstoreaccount1/azure-webjobs-hosts/locks/hostname-1510718391/host?comp=lease HTTP/1.1" 200 -
127.0.0.1 - - [26/Mar/2021:05:32:13 +0000] "PUT /devstoreaccount1/azure-webjobs-hosts/locks/hostname-1510718391/Host.Functions.Function.Listener?comp=lease HTTP/1.1" 200 -
127.0.0.1 - - [26/Mar/2021:05:32:20 +0000] "PUT /devstoreaccount1/azure-webjobs-hosts/locks/hostname-1510718391/host?comp=lease HTTP/1.1" 200 -
127.0.0.1 - - [26/Mar/2021:05:32:21 +0000] "PUT /devstoreaccount1/azure-webjobs-hosts/locks/hostname-1510718391/Host.Functions.Function.Listener?comp=lease HTTP/1.1" 200 -
127.0.0.1 - - [26/Mar/2021:05:32:28 +0000] "PUT /devstoreaccount1/azure-webjobs-hosts/locks/hostname-1510718391/Host.Functions.Function.Listener?comp=lease HTTP/1.1" 200 -
127.0.0.1 - - [26/Mar/2021:05:32:32 +0000] "PUT /devstoreaccount1/azure-webjobs-hosts/locks/hostname-1510718391/host?comp=lease HTTP/1.1" 200 -
127.0.0.1 - - [26/Mar/2021:05:32:36 +0000] "PUT /devstoreaccount1/azure-webjobs-hosts/locks/hostname-1510718391/Host.Functions.Function.Listener?comp=lease HTTP/1.1" 200 -
127.0.0.1 - - [26/Mar/2021:05:32:43 +0000] "PUT /devstoreaccount1/azure-webjobs-hosts/locks/hostname-1510718391/Host.Functions.Function.Listener?comp=lease HTTP/1.1" 200 -
127.0.0.1 - - [26/Mar/2021:05:32:44 +0000] "PUT /devstoreaccount1/azure-webjobs-hosts/locks/hostname-1510718391/host?comp=lease HTTP/1.1" 200 -
127.0.0.1 - - [26/Mar/2021:05:32:51 +0000] "PUT /devstoreaccount1/azure-webjobs-hosts/locks/hostname-1510718391/Host.Functions.Function.Listener?comp=lease HTTP/1.1" 200 -
127.0.0.1 - - [26/Mar/2021:05:32:56 +0000] "PUT /devstoreaccount1/azure-webjobs-hosts/locks/hostname-1510718391/host?comp=lease HTTP/1.1" 200 -
127.0.0.1 - - [26/Mar/2021:05:32:58 +0000] "PUT /devstoreaccount1/azure-webjobs-hosts/locks/hostname-1510718391/Host.Functions.Function.Listener?comp=lease HTTP/1.1" 200 -
127.0.0.1 - - [26/Mar/2021:05:33:00 +0000] "PUT /devstoreaccount1/azure-webjobs-hosts/timers/hostname-1510718391/Host.Functions.Function/status HTTP/1.1" 201 -
127.0.0.1 - - [26/Mar/2021:05:33:06 +0000] "PUT /devstoreaccount1/azure-webjobs-hosts/locks/hostname-1510718391/Host.Functions.Function.Listener?comp=lease HTTP/1.1" 200 -
127.0.0.1 - - [26/Mar/2021:05:33:08 +0000] "PUT /devstoreaccount1/azure-webjobs-hosts/locks/hostname-1510718391/host?comp=lease HTTP/1.1" 200 -
127.0.0.1 - - [26/Mar/2021:05:33:13 +0000] "PUT /devstoreaccount1/azure-webjobs-hosts/locks/hostname-1510718391/Host.Functions.Function.Listener?comp=lease HTTP/1.1" 200 -
127.0.0.1 - - [26/Mar/2021:05:33:20 +0000] "PUT /devstoreaccount1/azure-webjobs-hosts/locks/hostname-1510718391/host?comp=lease HTTP/1.1" 200 -
127.0.0.1 - - [26/Mar/2021:05:33:21 +0000] "PUT /devstoreaccount1/azure-webjobs-hosts/locks/hostname-1510718391/Host.Functions.Function.Listener?comp=lease HTTP/1.1" 200 -
127.0.0.1 - - [26/Mar/2021:05:33:28 +0000] "PUT /devstoreaccount1/azure-webjobs-hosts/locks/hostname-1510718391/Host.Functions.Function.Listener?comp=lease HTTP/1.1" 200 -
127.0.0.1 - - [26/Mar/2021:05:33:32 +0000] "PUT /devstoreaccount1/azure-webjobs-hosts/locks/hostname-1510718391/host?comp=lease HTTP/1.1" 200 -
127.0.0.1 - - [26/Mar/2021:05:33:36 +0000] "PUT /devstoreaccount1/azure-webjobs-hosts/locks/hostname-1510718391/Host.Functions.Function.Listener?comp=lease HTTP/1.1" 200 -
127.0.0.1 - - [26/Mar/2021:05:33:43 +0000] "PUT /devstoreaccount1/azure-webjobs-hosts/locks/hostname-1510718391/Host.Functions.Function.Listener?comp=lease HTTP/1.1" 200 -
127.0.0.1 - - [26/Mar/2021:05:33:44 +0000] "PUT /devstoreaccount1/azure-webjobs-hosts/locks/hostname-1510718391/host?comp=lease HTTP/1.1" 200 -
127.0.0.1 - - [26/Mar/2021:05:33:51 +0000] "PUT /devstoreaccount1/azure-webjobs-hosts/locks/hostname-1510718391/Host.Functions.Function.Listener?comp=lease HTTP/1.1" 200 -
127.0.0.1 - - [26/Mar/2021:05:33:56 +0000] "PUT /devstoreaccount1/azure-webjobs-hosts/locks/hostname-1510718391/host?comp=lease HTTP/1.1" 200 -
127.0.0.1 - - [26/Mar/2021:05:33:58 +0000] "PUT /devstoreaccount1/azure-webjobs-hosts/locks/hostname-1510718391/Host.Functions.Function.Listener?comp=lease HTTP/1.1" 200 -

スケジュールを環境変数で指定する

とりあえずTimerTriggerを動かせたわけですが、実行するスケジュールがソースコードに固定されています。

    @FunctionName("Function")
    public void run(
        @TimerTrigger(name = "timerInfo", schedule = "0 * * * * *") String timerInfo,
        final ExecutionContext context
    ) {
        context.getLogger().info("Java Timer trigger function executed at: " + LocalDateTime.now());
    }

これを、実行時に変更できないかな?と。

変えられそうな雰囲気はあります。

スケジュール式をアプリ設定に含めて、たとえば "%ScheduleAppSetting%" のように、 % 記号で囲まれたアプリ設定名にこのプロパティを設定できます。

Azure Functions のタイマー トリガー / 構成

アプリ設定、とは…?

Azure Functions のアプリケーション設定のリファレンス | Microsoft Docs

function.jsonというファイルを見るのが良さそうな気もします。

次の表は、function.json ファイルと TimerTrigger 属性で設定したバインド構成のプロパティを説明しています。

Azure Functions のタイマー トリガー / 構成

target/azure-functionsディレクトリの中を見ると、function.jsonがありました。

$ tree target/azure-functions 
target/azure-functions
└── hello-java-function-timer-trigger
    ├── Function
    │   └── function.json
    ├── hello-java-function-timer-trigger-0.0.1-SNAPSHOT.jar
    ├── host.json
    └── local.settings.json

2 directories, 4 files

中身は、ソースコードの内容が反映されているようですね。

target/azure-functions/hello-java-function-timer-trigger/Function/function.json

{
  "scriptFile" : "../hello-java-function-timer-trigger-0.0.1-SNAPSHOT.jar",
  "entryPoint" : "org.littlewings.Function.run",
  "bindings" : [ {
    "type" : "timerTrigger",
    "direction" : "in",
    "name" : "timerInfo",
    "schedule" : "0 * * * * *"
  } ]
}

関数 は Azure Functions の主要な概念です。 関数には 2 つの重要な要素が含まれています。さまざまな言語で記述できるコードと、いくつかの構成、つまり function.json ファイルです。 コンパイル式の言語の場合、この構成ファイルはコード内の注釈から自動的に生成されます。

Azure Functions 開発者ガイド /関数のコード

でも、これはビルド時に作成されていることになります。実行時に変更するには…?

アプリ設定のドキュメントを見ていると、どうやら環境変数で変えられそうです。「ローカルで実行する場合」と但し書きが
ありますけど。

関数アプリのアプリケーション設定には、その関数アプリのすべての関数に影響するグローバル構成オプションが含まれています。 ローカルで実行する場合、これらの設定は、ローカルの環境変数としてアクセスされます。

Azure Functions のアプリケーション設定のリファレンス | Microsoft Docs

ローカルで実行しない場合は、どうなるんでしょう…。

まあ、今回はローカルでの実行のみなので、気にせず進んでみましょう。

scheduleを、%TIMER_SCHEDULE%としました。

src/main/java/org/littlewings/Function.java

package org.littlewings;

import java.time.*;
import com.microsoft.azure.functions.annotation.*;
import com.microsoft.azure.functions.*;

/**
 * Azure Functions with Timer trigger.
 */
public class Function {
    /**
     * This function will be invoked periodically according to the specified schedule.
     */
    @FunctionName("Function")
    public void run(
        @TimerTrigger(name = "timerInfo", schedule = "%TIMER_SCHEDULE%") String timerInfo,
        final ExecutionContext context
    ) {
        context.getLogger().info("Java Timer trigger function executed at: " + LocalDateTime.now());
    }
}

ビルド。

$ mvn package

生成されるfunction.jsonにも、反映されました。

target/azure-functions/hello-java-function-timer-trigger/Function/function.json

{
  "scriptFile" : "../hello-java-function-timer-trigger-0.0.1-SNAPSHOT.jar",
  "entryPoint" : "org.littlewings.Function.run",
  "bindings" : [ {
    "type" : "timerTrigger",
    "direction" : "in",
    "name" : "timerInfo",
    "schedule" : "%TIMER_SCHEDULE%"
  } ]
}

あとは、環境変数で実行スケジュールを指定しつつ、起動します。

$ TIMER_SCHEDULE='0 */3 * * * *' mvn azure-functions:run

3分に1回実行するようにしてみました。

[INFO] Azure Function App's staging directory found at: /path/to/hello-java-function-timer-trigger/target/azure-functions/hello-java-function-timer-trigger
[INFO] Azure Functions Core Tools found.

Azure Functions Core Tools
Core Tools Version:       3.0.3388 Commit hash: fb42a4e0b7fdc85fbd0bcfc8d743ff7d509122ae 
Function Runtime Version: 3.0.15371.0

[2021-03-26T10:10:33.029Z] Worker process started and initialized.

Functions:

    Function: timerTrigger

For detailed output, run func with --verbose flag.
[2021-03-26T10:10:38.317Z] Host lock lease acquired by instance ID '000000000000000000000000C3CC5E65'.

しばらく待っていると、ログが出力されます。

[2021-03-26T10:12:00.060Z] Executing 'Functions.Function' (Reason='Timer fired at 2021-03-26T19:12:00.0215774+09:00', Id=175912f7-8312-4d08-bc34-42df1672e59f)
[2021-03-26T10:12:00.179Z] Java Timer trigger function executed at: 2021-03-26T19:12:00.157054
[2021-03-26T10:12:00.180Z] Function "Function" (Id: 175912f7-8312-4d08-bc34-42df1672e59f) invoked by Java Worker
[2021-03-26T10:12:00.208Z] Executed 'Functions.Function' (Succeeded, Id=175912f7-8312-4d08-bc34-42df1672e59f, Duration=177ms)
[2021-03-26T10:15:00.004Z] Executing 'Functions.Function' (Reason='Timer fired at 2021-03-26T19:15:00.0018087+09:00', Id=413918ed-efe6-4b01-b42c-04c23f0d36fc)
[2021-03-26T10:15:00.021Z] Java Timer trigger function executed at: 2021-03-26T19:15:00.011291
[2021-03-26T10:15:00.026Z] Function "Function" (Id: 413918ed-efe6-4b01-b42c-04c23f0d36fc) invoked by Java Worker
[2021-03-26T10:15:00.028Z] Executed 'Functions.Function' (Succeeded, Id=413918ed-efe6-4b01-b42c-04c23f0d36fc, Duration=25ms)

上手く指定できたようです。

ちなみに、環境変数を指定しなかった場合は

$ mvn azure-functions:run

値が解決できなくなり、関数の起動に失敗します。

[INFO] Azure Function App's staging directory found at: /path/to/hello-java-function-timer-trigger/target/azure-functions/hello-java-function-timer-trigger
[INFO] Azure Functions Core Tools found.

Azure Functions Core Tools
Core Tools Version:       3.0.3388 Commit hash: fb42a4e0b7fdc85fbd0bcfc8d743ff7d509122ae 
Function Runtime Version: 3.0.15371.0

[2021-03-26T10:15:23.464Z] Worker process started and initialized.
[2021-03-26T10:15:23.520Z] Microsoft.Azure.WebJobs.Host: Error indexing method 'Functions.Function'. Microsoft.Azure.WebJobs.Host: '%TIMER_SCHEDULE%' does not resolve to a value.
[2021-03-26T10:15:23.541Z] Error indexing method 'Functions.Function'
[2021-03-26T10:15:23.542Z] Microsoft.Azure.WebJobs.Host: Error indexing method 'Functions.Function'. Microsoft.Azure.WebJobs.Host: '%TIMER_SCHEDULE%' does not resolve to a value.
[2021-03-26T10:15:23.542Z] Function 'Functions.Function' failed indexing and will be disabled.
[2021-03-26T10:15:23.543Z] No job functions found. Try making your job classes and methods public. If you're using binding extensions (e.g. Azure Storage, ServiceBus, Timers, etc.) make sure you've called the registration method for the extension(s) in your startup code (e.g. builder.AddAzureStorage(), builder.AddServiceBus(), builder.AddTimers(), etc.).

Functions:

    Function: timerTrigger

For detailed output, run func with --verbose flag.
[2021-03-26T10:15:23.583Z] The 'Function' function is in error: Microsoft.Azure.WebJobs.Host: Error indexing method 'Functions.Function'. Microsoft.Azure.WebJobs.Host: '%TIMER_SCHEDULE%' does not resolve to a value.
[2021-03-26T10:15:28.676Z] Host lock lease acquired by instance ID '000000000000000000000000C3CC5E65'.

簡単な確認でしたが、こんなところで。