"BOKU"のITな日常

興味のむくまま気の向くままに調べたり・まとめたりしてます。

簡易的なテキスト検索コマンドを作る/GO言語(golang)の練習

f:id:arakan_no_boku:20210412005751p:plain
GO言語の練習のため、簡易的なGrepコマンドを作ってみます。

Grepはテキストファイルを正規表現で検索し、一致した行を出力するコマンドです。

GO言語では、ソースコードを分割しインポートして使うあたりに、癖があるということなので、その確認を兼ねてます。

目次

GO言語のモジュール

GO言語のモジュールはフォルダを分けて作成します。

モジュールは1つ以上の関数を持つパッケージの集まりです。 

GO言語では、アプリケーションとして実行されるコードはmainパッケージに含まれている必要があり、ひとつのフォルダに、「func main()」があるソースファイルを複数おくと、実行しようとしても

main redeclared in this block (see details)

なんてエラーがでて、コンパイルできません。

ソース分割時の注意点

今回はソースをメインパッケージの「aGrep.go」と1ファイル単位の検索処理を切りだした「mText.go」の2つのソースに分けます。

ソースファイルごとにフォルダをわけ、フォルダ名は「aGrep」フォルダ、「mText」フォルダとします。

フォルダ構成は以下のように、aGrepフォルダ以下にmTextフォルダを置きます。

├─aGrep
  └─mText

ここで、コンパイルを通すため「go mod init 」して、go.modファイルを作ります。

ただし、作成するのはmainパッケージのソースを置く「aGrep」フォルダだけです。

サブフォルダの「mText」のソースで外部パッケージを追加先も、aGrepフォルダのgo.modファイルです。

うっかり、mTextフォルダのほうにもgo.modファイルを作ってしまうと、importエラーが消えなくなってハマります(;^_^A。

aGrepフォルダをカレントにして以下を実行します。

go mod init aGrepModule

引数の「aGrepModule」はインポート時に指定するモジュール名です、

これで「mText」フォルダの「mText」パッケージを「import "aGrepModule/mText"」でインポートできるようになります。

ちなみに、モジュール管理でよく使うコマンドには以下があります。

  • go mod init :指定フォルダにgo.modファイルを生成する。
  • go get:依存モジュールの追加やバージョンアップを行う
  • go mod tidy:使われていない依存モジュールを削除する
ソースコード作成:検索機能

まずは「検索機能モジュール」を「mTextフォルダ」に作ります。

ソースファイルは「mText.go」です。

パッケージ名はフォルダー名と同じ「package mText」とします。

mText.goのソースです。

package mText

import (
	"bufio"
	"fmt"
	"io"
	"os"
	"regexp"
)

func isTextFile(fullPathFileName, filter string) bool {
	matched, err := regexp.Match(filter, []byte(fullPathFileName))
	if err != nil {
		return false
	}
	return matched
}

func MatchInFile(usePattern, fullPathFileName, filter string) bool {
	var lineCount int = 0
	var err error
	var line string
	var buf []byte

	if !isTextFile(fullPathFileName, filter) {
		return false
	}

	fileIn, err := os.Open(fullPathFileName)
	if err != nil {
		return false
	}
	defer fileIn.Close()

	reader := bufio.NewReaderSize(fileIn, bufio.MaxScanTokenSize)

	for {

		prefix := true
		line = ""
		for prefix && err == nil {
			buf, prefix, err = reader.ReadLine()
			line += string(buf)
		}
		lineCount++
		matched, regex_err := regexp.Match(usePattern, []byte(line))
		if matched && regex_err == nil {
			fmt.Printf("%s,%d,%s\n", fullPathFileName, lineCount, line)
		}

		if err == io.EOF {
			break
		} else if err != nil {
			return false
		}

	}
	return true
}

テキストファイルを1行ずつ読み込んで、パターンマッチをチェックして、マッチしたら標準出力に書き出しています。 

独特なのが「defer fileIn.Close()」の部分です。

deferをつけた「 fileIn.Close()」はReturnまで待機して、Returnされたら実行します。

あと。

buf, prefix, err = reader.ReadLine()

で1行ずつ読み込むわけですが、1行の長さがバッファサイズにおさまらなかった場合はいったん途中まで(バッファサイズ分)読み込んで、「prefix」にtrueをセットして「行の途中だよ」ということを示します。

なので、prefixがtrueである間は行の途中なので、

for prefix && err == nil

みたいに、prefixがfalseになるまで、行を連結する必要があります。

正規表現のマッチ確認はGO言語のregexpモジュールに丸投げです。

matched, regex_err := regexp.Match(usePattern, []byte(line))

マッチしたら、matchedにtrueが返ります。 

ソースコード作成:メイン 

アプリケーション本体「aGrep.go」です。

検索条件、検索対象フォルダをコマンドライン引数で受取り、フォルダ以下のファイルを再帰的に取得していくだけです。

package main

import (
	"aGrepModule/mText"
	"fmt"
	"os"
	"path/filepath"
)

func GrepFiles(searchCond, searchPath, filter string) bool {
	files, err := os.ReadDir(searchPath)
	if err == nil {
		for _, file := range files {
			fullPath := filepath.Join(searchPath, file.Name())
			if file.IsDir() {
				GrepFiles(searchCond, fullPath, filter)
			} else {
				mText.MatchInFile(searchCond, fullPath, filter)
			}
		}
	} else {
		fmt.Println(err)
		return false
	}
	return true
}

func main() {
	if len(os.Args) < 4 {
		fmt.Println("検索条件と検索対象パスと対象ファイル拡張子の指定が必要です。")
	} else {
		searchCond := os.Args[1]
		searchPath := os.Args[2]
		filterStr := os.Args[3]
		GrepFiles(searchCond, searchPath, filterStr)

	}
}

os.ReadDir()で、フォルダ以下のファイル・フォルダを読み込んで、ひとつずつ取り出して処理してます。

ネットのサンプルには「ioutil.ReadDir」がよく掲載されているのですが、Go言語本家のドキュメントに

As of Go 1.16, the same functionality is now provided by package io or package os, and those implementations should be preferred in new code.

Go 1.16以降、同じ機能がパッケージioまたはパッケージosによって提供されるようになり、これらの実装は新しいコードで優先されるはずです。

なんて書いてあって、どうも非推奨になったっぽいので変えました。 

自分しか使わないので、Argsのチェックとかエラー処理はゆるゆるです(笑)。

テスト(unittest)のやり方を確認

unittestのやり方の確認のためテストソースも作っておきます。 

package main

import (
	"fmt"
	"testing"

	"aGrepModule/mText"

	"github.com/stretchr/testify/assert"
)

func Test_実行テスト(t *testing.T) {
	fmt.Println("----1----")
	assert.True(t, GrepFiles("[0-9L]", "C:\\gitwork\\gowork\\testdata", ".txt"))
}

func Test_フォルダ指定で条件に一致する行がある(t *testing.T) {
	fmt.Println("---2---")
	assert.True(t, mText.MatchInFile("[0-9L]", "C:\\gitwork\\gowork\\testdata\\ok_test.txt", ".txt"))
}

func Test_ファイル指定で処理対象ファイル外をスキップする(t *testing.T) {
	fmt.Println("---3---")
	assert.False(t, mText.MatchInFile("", ".\\ng_test.png", ".txt"))
}

ポイントはパッケージ名とファイル名規則です。

テストソースはテスト対象のソースとパッケージ名を同じにします。

今回の場合は元ソース・テストソースとも「package main」です。

テスト対象のソースは「aGrep.go」なので、テストソースは「aGrep_test.go」です。

テストソースはテスト対象のソースと同じフォルダにおきます。

テスト関数の命名規則は、Testを頭につければ、あとは日本語でもいいです。

何のケースかわかりやすいように名前をつけるのがいいみたいです。

assertはTrue、False以外にもいっぱい種類があります。

pkg.go.dev 

unit_testは「go test」コマンドで実行します。 

上記テストソースは「C:\\gitwork\\gowork\\testdata」フォルダにテスト用のデータファイルを置いている想定ですので、マッチしたら、こんな感じで出力されます。

C:\gitwork\gowork\testdata\ok_test.txt,1,ReadLineは、低レベルの行読み取りプリミティブです。
C:\gitwork\gowork\testdata\ok_test.txt,3,ReadLineは、行末バイトを含まない1行を返そうとします。
C:\gitwork\gowork\testdata\ok_test.txt,7,返されたバッファは、ReadLineへの次の呼び出しまでのみ有効です。
C:\gitwork\gowork\testdata\ok_test.txt,8,ReadLineはnil以外の行を返すか、エラーを返しますが、両方を返すことはありません。
C:\gitwork\gowork\testdata\ok_test.txt,9,ReadLineから返されるテキストには、行末( "\ r \ n"または "\ n")は含まれていません。

okとなりました。

コンパイルと実行

テストOKなので、いよいよ、実行ファイルを作ります。

コンパイルは以下のようにします。

go build aGrep.go

これでカレントフォルダに、aGrep.exe(Windowsの場合)ができます。

コマンドライン引数は。

  • os.Args[1]:検索条件(正規表現
  • os.Args[2]:検索を開始するフォルダ(以下を再帰的に検索する)
  • os.Args[3]:対象とするファイルの拡張子(.txtとか)。とりあえず1つだけ指定。

なので、

aGrep.exe [0-9L] C:\\gitwork\\gowork\\testdata .txt

のようにして実行します。

とりあえず、これで簡易的なGrepコマンドはできました。

かつ、GO言語でWindowsコマンドを作るにあたっての、基本的なやり方は確認できたと思います。

ではでは。

arakan-pgm-ai.hatenablog.com

#GO