20201015のGoに関する記事は5件です。

Firestoreを介して画面操作毎の登録内容を別システムに時系列で連携

想定する読者

  • GCPについては知っている。
  • Firestore(ないしDatastore)を使ったことがある。ないし、おおよそどういうものかは知っている。
  • Golangもそれなりに書ける。

お題

何かしらの情報に関して登録・更新・参照する、ごくごく普通のWebアプリケーションがあったとして、画面で操作した内容を自システムのDBに保存すると同時に、操作した順序を保って別システムにも反映したいという要件があったとする。
別システムでは機能ごとにデータ反映用のAPIを提供しているので、それを叩けばよいのだけど、問題は1つ1つのAPIが重い(=要するにリクエスト受けてからレスポンスまでの時間が5秒以上かかる)ということ。
したがって、単純に考えて、画面で操作した流れでそのままAPIを同期的に叩くというわけにはいかない。
(そんなことをしたら、ユーザが何かの情報を登録・更新するたびに7〜8秒くらい待たされるシステムになってしまう。)

というわけで、APIを叩く部分は非同期にする。
すると、とたんに「操作した順序を保って」の部分が怪しくなってくる。
さあ、どうしよう。
とりあえず非同期にするので、反映したい内容をメッセージキューにでも積むか。
※ちなみに、システムはGoogle Cloudを使うことが前提になっていることとする。
いろいろ選択肢はあるけど、一応、マネージドサービスを使うことを重視するという前提だと仮定して、以下を考える。

ただ、上記を使おうとすると以下の点で今回の要件と抵触する。

  • 実行順序を保証しない。※
  • 重複実行の可能性がある。

https://cloud.google.com/tasks/docs/common-pitfalls?hl=ja

※Cloud Pub/Subの方は、Beta版ではあるものの「メッセージの順序指定」という機能が加わった。
参考までに。
https://qiita.com/sky0621/items/3df3ae65b859b8196e39
ただし、これも、Subscriptionからメッセージが送出される時の順序指定である。
少なくともPushタイプのSubscriptionにしている場合はメッセージ1をPush(=要するに指定のエンドポイントにリクエストすること)したあと、そのレスポンスを待ってメッセージ2をPushということはしてくれない。
(問答無用に「メッセージ1をPush」→「メッセージ2をPush」→・・・)

また、「重複実行の可能性」についても、ある種のデータの更新であれば2回、別システムに同じ更新をしても問題ないかもしれないが、新規データの作成の場合は、そうはいかない。
同じデータが2個作られてしまう。つまり、メッセージが重複しても、それを排除する仕組みを別途用意しないといけない。
たぶん一般的には、メッセージの生成元でユニークなIDを振って、それを「処理済みかどうか」の確認のためにMemorystore等に登録する。(既に登録済みだったら、このメッセージは処理済み(つまり重複配信された)とみなして削除する実装を入れる。)

実行順序の保証が難しいは重複実行への対処もしないといけないはで、やりたいことに対して考慮、実装しないといけない要素が多すぎる。
ということで、もっとシンプルに「画面操作の履歴をデータベースに持っておいて、格納順に処理(別システムのAPIを叩く)すればいいだけでは?」と考える。
とはいえ、スケーラビリティも考慮したい。
ということで、Datastoreの採用を考えたのだけど、「Firestore は次世代の Datastore です。」なんて書いてあるので、Firestoreを使ってみることにする。

システムの全体像

screenshot-app.cloudskew.com-2020.10.14-00_42_41.png

  • 「Webアプリケーション」(今回は実質WebAPIサーバ)は、Cloud Run
  • 「RDB」(今回のソースでは実際の書き込みロジックは省略)は、Cloud SQL
  • 「NoSQLストア」は、Firestore
  • 「同期サービス」(今回はAPIを擬似的に叩いた体裁で数秒スリープさせるだけの実装)は、GKE

Webアプリケーション概要

いつもこの手のお題としていい塩梅のものが見つからない。
今回は、「学校」や「学年」、「クラス」、「先生」、「生徒」といった情報の登録ができるものとする。
(あくまで擬似的なものだけど。)
※ちなみに、私は学生ではありません。

エンドポイント

  • /add-school ・・・「学校」の登録
  • /add-grade ・・・「学年」の登録
  • /add-class ・・・「クラス」の登録
  • /add-teacher ・・・「先生」の登録
  • /add-student ・・・「生徒」の登録

前提

  • ローカルにGoの開発環境構築済み。
  • GCP契約済み。
  • ローカルでCloud SDKのセットアップ済み。
  • ローカルの環境変数GOOGLE_APPLICATION_CREDENTIALSに(必要な権限を全て有したサービスアカウントの)鍵JSONファイルパス設定済み。

開発環境

# OS - Linux(Ubuntu)

$ cat /etc/os-release 
NAME="Ubuntu"
VERSION="18.04.5 LTS (Bionic Beaver)"

# バックエンド

# 言語 - Golang

$ go version
go version go1.15.2 linux/amd64

IDE - Goland

GoLand 2020.2.3
Build #GO-202.7319.61, built on September 16, 2020

今回の全ソース

Webアプリケーション

https://github.com/sky0621/go-publisher-fs/tree/v0.1.1

同期サービス

https://github.com/sky0621/go-subscriber-fs/tree/v0.1.1

ソース抜粋解説

Webアプリケーション

main.go
package main

import (
    "fmt"
    "log"
    "net/http"
    "os"
    "time"

    "cloud.google.com/go/firestore"
    "github.com/google/uuid"
    "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"
)

func main() {
    project := os.Getenv("PUB_PROJECT")

    e := echo.New()
    e.Use(middleware.Logger())
    e.Use(middleware.Recover())

    e.GET("/add-school", handler(project, "add-school"))
    e.GET("/add-grade", handler(project, "add-grade"))
    e.GET("/add-class", handler(project, "add-class"))
    e.GET("/add-teacher", handler(project, "add-teacher"))
    e.GET("/add-student", handler(project, "add-student"))

    e.Logger.Fatal(e.Start(":8080"))
}

func handler(project, path string) func(c echo.Context) error {
    return func(c echo.Context) error {
        ctx := c.Request().Context()

        client, err := firestore.NewClient(ctx, project)
        if err != nil {
            log.Fatal(err)
        }
        defer client.Close()

        order := fmt.Sprintf("%s:%s", path, createUUID())

        _, err = client.Collection("operation").Doc(order).
            Set(ctx, map[string]interface{}{
                "order":    order,
                "sequence": time.Now().UnixNano(),
            }, firestore.MergeAll)
        if err != nil {
            log.Fatal(err)
        }
        return c.String(http.StatusOK, order)
    }
}

func createUUID() string {
    u, err := uuid.NewRandom()
    if err != nil {
        log.Fatal(err)
    }
    return u.String()
}

同期サービス

main.go
package main

import (
    "context"
    "log"
    "os"
    "strings"
    "time"

    "cloud.google.com/go/firestore"
)

func main() {
    ctx := context.Background()

    client, err := firestore.NewClient(ctx, os.Getenv("PUB_PROJECT"))
    if err != nil {
        log.Fatal(err)
    }
    defer client.Close()

    operationIter := client.Collection("operation").
        Where("sequence", ">", 0).OrderBy("sequence", firestore.Asc).Snapshots(ctx)
    defer operationIter.Stop()

    for {
        operation, err := operationIter.Next()
        if err != nil {
            log.Fatalln(err)
        }

        for _, change := range operation.Changes {
            ope, err := change.Doc.Ref.Get(ctx)
            if err != nil {
                log.Fatalln(err)
            }
            d := ope.Data()
            order, ok := d["order"]
            if ok {
                ods := strings.Split(order.(string), ":")
                if len(ods) > 0 {
                    od := ods[0]
                    switch od {
                    case "add-school":
                        time.Sleep(5 * time.Second)
                    case "add-grade":
                        time.Sleep(4 * time.Second)
                    case "add-class":
                        time.Sleep(3 * time.Second)
                    case "add-teacher":
                        time.Sleep(2 * time.Second)
                    case "add-student":
                        time.Sleep(1 * time.Second)
                    }
                }
            }
            log.Printf("[operation-Data] %#+v", d)
        }
    }
}

実践

Firestoreへの書き込み側

まずは、以下の部分を確認。
screenshot-app.cloudskew.com-2020.10.14-22_11_07.png

以下5つのエンドポイントを上から順に叩いてみる、そして、それを2回繰り返すと、

  • /add-school ・・・「学校」の登録
  • /add-grade ・・・「学年」の登録
  • /add-class ・・・「クラス」の登録
  • /add-teacher ・・・「先生」の登録
  • /add-student ・・・「生徒」の登録

たしかに、順繰り叩かれたログが出ていて、
screenshot-console.cloud.google.com-2020.10.15-21_44_31.png

Firestoreにもドキュメントが溜まっている。
screenshot-console.cloud.google.com-2020.10.15-21_42_38.png

※FirestoreはデフォルトではドキュメントIDの昇順で並ぶようなので、必ずしもエンドポイントを叩いた順に並ぶわけではない。

Firestoreから情報をsequenceの昇順で取得する側

今度は、以下の部分。
screenshot-app.cloudskew.com-2020.10.14-22_23_22.png

以下の同期サービスをデプロイしている。
screenshot-console.cloud.google.com-2020.10.15-21_50_52.png

ちゃんと時系列に処理されているかどうか、コンテナログを見てみる。
screenshot-console.cloud.google.com-2020.10.15-21_51_33.png

以下の順番で2巡させたので想定通り。

  • /add-school ・・・「学校」の登録
  • /add-grade ・・・「学年」の登録
  • /add-class ・・・「クラス」の登録
  • /add-teacher ・・・「先生」の登録
  • /add-student ・・・「生徒」の登録

同期サービス内では、/add-school をFirestoreから読み取った場合は5秒のスリープを入れているが、当然、/add-grade の読み取りに追い越されるなんてことはない。

まとめ

こんな感じに、操作をFirestoreに時系列(が維持できるよう、OrderByがかけられるNanoレベルのUnixTimestampをフィールドに持つのが必要なわけだけど)に投入していくと、順番を保ったまま別システムに連携するシステムが作れる。
とはいえ、もちろんこれだけではプロダクションに上げるレベルのものにはならない。
例えば、Firestore内にドキュメントが溜まっていく一方(1回、別システムに連携したら不要なドキュメントなのに)である点や、それゆえに、実は、同期サービスを再デプロイすると、また、(処理済みなのに)1からドキュメントを拾って処理し始めてしまうという課題がある。
これについては、
「処理が終わるたびにドキュメント消せばいいのでは?」というのが単純な解になるかと思うけど、そうすると、「ドキュメントの削除」というのがトリガーとなって、1つのドキュメントを処理し終わったのに再度、同じドキュメントを処理し始める(削除されているので途中でエラーになる、ないし、ロジックによってはそのまま2回目の処理が走る)ことが起きる。
というわけで、
まだまだいろいろ考えるべきポイントはあるのだけど、とりあえず、いったんこのへんで。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

GolangでCloudFrontのキャッシュを削除する

S3の画像をCloudFrontで流しているのですが、画像更新時にはキャッシュ(エッジサーバー上のキャッシュ)をクリアしたいなと思い、やり方を調べました。

Goで実装する

Golangで実装するには、AWS SDK GoのCloudFront周りの機能を使います。
公式はこちら: cloudfront - Amazon Web Services - Go SDK

このSDKの CreateInvalidation というメソッドを使います。
実装としては以下の感じです。

const (
    // CloudFrontのID
    CloudFrontID = "HOGEHOGE"

    //cloudfrontでキャッシュ削除するパス
    ClearTargetPath := "/*"
)

func ClearCache() error {
    svc := cloudfront.New(session.New())
    jstNow := time.Now().UTC().In(time.FixedZone("Asia/Tokyo", 9*60*60))
    callerReference := jstNow.Format("200601021504")

    _, err := svc.CreateInvalidation(createInvalidationInput(callerReference))
    if err != nil {
        return err
    }

    return nil
}

func createInvalidationInput(callerReference string) *cloudfront.CreateInvalidationInput {
    pathItems := []*string{&ClearTargetPath}

    return &cloudfront.CreateInvalidationInput{
        DistributionId: aws.String(CloudFrontID),
        InvalidationBatch: &cloudfront.InvalidationBatch{
            CallerReference: &callerReference,
            Paths: &cloudfront.Paths{
                Quantity: aws.Int64(1),
                Items:    pathItems,
            },
        },
    }
}

CallerReference というのはユニークな値であればいいらしいので、タイムスタンプを入れています。
CreateInvalidationInput で削除対象のDistributionIdやパスを指定します。

Lambdaで実行する場合には、CloudFrontの権限付与をお忘れなく。

余談(AWS webコンソールからの削除方法)

CloudFrontのエッジサーバー上のキャッシュ自体はAWSのwebコンソールからでも消せるようです。
[AWS] Amazon CloudFrontのキャッシュ削除(Invalidation)にあるように、 Invalidation というのがキャッシュ削除にあたる用語だそうです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ebitenを使ったシンプルな時計を描画するサンプル

ただシンプルな時計を描画するだけのプログラム

ebiten というゲームエンジンを使って、時計を描画するサンプル

時計のイメージ

時計はとにかくシンプルなもの。

ソースコード

main.go
package main

import (
    . "clock"
    "github.com/hajimehoshi/ebiten"
    "github.com/hajimehoshi/ebiten/inpututil"
    "log"
    "time"
)

type Game struct {
    time time.Time
    stop bool
}

func (g *Game) Update(screen *ebiten.Image) error {
    if inpututil.IsKeyJustPressed(ebiten.KeyEscape) {
        g.stop = !g.stop
    }
    if !g.stop {
        g.time = time.Now()
    }
    return nil
}

func (g *Game) Draw(screen *ebiten.Image) {
    m := ClockImage(g.time)
    em, _ := ebiten.NewImageFromImage(m, ebiten.FilterDefault)
    screen.DrawImage(em, &ebiten.DrawImageOptions{})
}

func (g *Game) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) {
    return 320, 320
}

func main() {
    game := &Game{}

    ebiten.SetWindowSize(320, 320)
    ebiten.SetWindowTitle("Hello, World!")
    if err := ebiten.RunGame(game); err != nil {
        log.Fatal(err)
    }
}

サンプルという意味を込めて、エスケープキーを押すと時計を止めることができるようになっています。

使い道は考えていませんが。

ClockImageの実装

肝心の時計の画像を生成するプログラム

clock.go
package clock

import (
    "image"
    "github.com/fogleman/gg"
    "github.com/golang/freetype/truetype"
    "golang.org/x/image/font/gofont/gomedium"
    "time"
)

func ClockImage(t time.Time) image.Image {
    const (
        R       = 160
        Long    = 108 //Second
        Midium  = 85  // Minute
        Short   = 50  // Hour
        Width   = R*2 + 1
        Height  = R*2 + 1
        CenterX = Width / 2
        CenterY = Height / 2
        M       = 15 //その他メモリの線分
        M5      = 26 //5分メモリの線分
    )
    dc := gg.NewContext(Width, Height)

    dc.SetHexColor("#ffffff")
    dc.Clear()

    // メモリの描画
    dc.Push()
    dc.SetHexColor("#000000")
    for i := 0; i < 60; i++ {
        dc.Push()
        var m float64 = M
        if i%5 == 0 {
            dc.SetLineWidth(2)
            m = M5
        }
        dc.MoveTo(CenterX, CenterY-R+m)
        dc.LineTo(CenterX, 0)
        dc.Stroke()
        dc.Pop()
        dc.RotateAbout(gg.Radians(float64(6)), CenterX, CenterY)
    }
    dc.Pop()

    dc.SetHexColor("#000")

    // AM / PM の描画
    var AMPM = "AM"
    if t.Hour() >= 12 {
        AMPM = "PM"
    }
    f, _ := truetype.Parse(gomedium.TTF)
    face := truetype.NewFace(f, &truetype.Options{Size: 34})
    dc.SetFontFace(face)
    dc.DrawString(AMPM, CenterX+37, CenterY+7)

    // 0時の時の短針の角度=0度としたいため、あらかじめ90度反時計周りに回転
    dc.RotateAbout(gg.Radians(-90), CenterX, CenterY)

    dc.DrawCircle(CenterX, CenterY, R)
    dc.Stroke()

    // 短針の描画
    dc.Push()
    dc.SetLineWidth(8)
    dc.MoveTo(CenterX, CenterY)
    HD := t.Hour()%12*30 + int(float64(t.Minute())*0.5) //t.Hour() range [0,23] 360/12 == 30
    if HD != 0 {
        dc.RotateAbout(gg.Radians(float64(HD)), CenterX, CenterY)
    }
    dc.LineTo(CenterX+Short, CenterY)
    dc.Stroke()
    dc.Pop()

    // 長針の描画
    dc.Push()
    dc.SetLineWidth(4)
    dc.MoveTo(CenterX, CenterY)
    MD := t.Minute() * 6 // t.Minute() range [0,59] 360/60 == 6
    if MD != 0 {
        dc.RotateAbout(gg.Radians(float64(MD)), CenterX, CenterY)
    }
    dc.LineTo(CenterX+Midium, CenterY)
    dc.Stroke()
    dc.Pop()

    // 秒針の描画
    dc.Push()
    dc.SetLineWidth(2)
    dc.MoveTo(CenterX, CenterY)
    SD := t.Second() * 6 // t.Second() range [0,59] 360/60 == 6
    if SD != 0 {
        dc.RotateAbout(gg.Radians(float64(SD)), CenterX, CenterY)
    }
    dc.LineTo(CenterX+Long, CenterY)
    dc.Stroke()
    dc.Pop()

    return dc.Image()
}

時間を渡してその時間を示す時計の画像を返す

おわりに

説明もほとんどないですが、もっとわかりやすい解説を思いつけば更新したいと思います。

個人的には、Goは書きやすい言語だとは思いますが、どうしても型変換が冗長になってしまいがちですね。

暗黙の型変換をなくしてバグが減るのか、コードを書く量が増えるか......

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Goでベジェ曲線の描画

Goでベジェ曲線の描画

細かい解説もしたいですが、ひとまずソースコード

ソースコード

package bezier

import (
  "math"
)

type Point struct{
  X,Y float64
}

// n! (nの階乗)
func factorial(n int)(int){
  if n == 0{
    return 1
  }
  return n * factorial(n-1)
}

func biCoe(n,i int)(float64){
  return float64(factorial(n) / (factorial(n-i) * factorial(i)))
}

func bernstein(n,i int,t float64)(float64){
  var N float64 = float64(n)
  var I float64 = float64(i)
  return biCoe(n,i) * math.Pow(t,I ) * math.Pow(1-t,N-I)
}

func BezierCurve(p []Point,t float64)(result Point){
  for i,v := range p{
    B := bernstein(len(p)-1,i,t)
    result.X += v.X*B
    result.Y += v.Y*B
  }
  return
}

// aは変化量 値を大きくすればカクカクした曲線になる
func Curve(p []Point,a float64)(result []Point){
  var t float64
  for {
    result = append(result, BezierCurve(p,t) )
    t += a
    if t >= 1{
      break
    }
  }
  return
}

Curve([]Point,float64)は制御点とtの変化量を与え、P(t,0>=t<=1)のスライスを返す関数

利用者は通常、この関数を利用して描画を行っていく(ことを想定している)。

利用例

package bezier

import (
  "testing"
  "github.com/fogleman/gg"
)

func TestBezierCurve(t *testing.T){
  // 制御点
  P := []Point{Point{10,10},Point{10,590},Point{590,590}}

  // 変化量
  const A = 0.01
  result := Curve(P,A)

  dc := gg.NewContext(600,600)
  dc.SetHexColor("#fff")
  dc.Clear()

  // 制御点の描画
  dc.Push()
  dc.SetHexColor("#0000ff")
  for _,v :=range P{
    dc.DrawCircle(v.X,v.Y,4)
    dc.Fill()
  }
  dc.Pop()

  // 曲線の描画
  dc.Push()
  dc.SetHexColor("#000")
  // 始点に移動
  dc.MoveTo(P[0].X,P[0].Y)
  for _,v :=range result{
    dc.LineTo(v.X,v.Y)
  }
  dc.Stroke()
  dc.Pop()

  // P(t)の描画
  dc.Push()
  dc.SetHexColor("#f01000")
  for _,v :=range result{
    dc.DrawCircle(v.X,v.Y,3)
  }
  dc.Stroke()
  dc.Pop()

  dc.SavePNG("out.png")
}

テストの実行結果

実行結果

青の点が制御点で赤がP(t)の座標を中心とした円。

おわりに

数学が得意でもない私ですが、数式とにらめっこして数時間(時間かかりすぎ)で実装が出起案した。とても達成感がありました。(小学生並みの感想)

自分の元気な時に、ぼちぼち更新したいと思います。

こんな記事でも、誰かの役に立てば幸いです。

謝辞

http://github.com/fogleman/gg には、いつもお世話になっております。

@Rahariku様の記事がとても参考になりました。ありがとうございます。

参考

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Goでデータベースの内容を取得する関数を作る

データベースから目的の内容を全て取る関数について今から書きます。すでにデータベースには、なにか入っている状態でします

interface{} な変数を型が決まっている関数の引数にする
「Golang」 ×「Gorm」でシンプルに「Mysql」を操作する
【go + gin + gorm】webアプリにログイン機能を追加してみる
Goでの Mysql接続から構造体へのデータ割り当てまで
Gin と GORM で作るオレオレ構成 API


install
import (
    _ "github.com/go-sql-driver/mysql"// これは、自分で追加が必要
    "github.com/jinzhu/gorm"
)
// ないとき
//go get github.com/jinzhu/gorm
//go get github.com/go-sql-driver/mysql

まず、データベースと接続をします

main.go
func openGormDB() *gorm.DB { // localhost
    DBMS := "mysql"
    USER := "user"//mysqlのユーザー名
    PASS := "password"//パスワード
    PROTOCOL := "tcp(localhost:3306)"
    DBNAME := "sample"//データベース名

    CONNECT := USER + ":" + PASS + "@" + PROTOCOL + "/" + DBNAME + "?charset=utf8&parseTime=True&loc=Local"
    db, err := gorm.Open(DBMS, CONNECT)
    if err != nil {
        panic(err)
    }
    return db
}

これは、データベースの内容

main.go
type ShiromiyaChannelInfos struct {
    //ID              uint64
    ChannelID       string
    ChannelName     string
    ViewCount       uint `gorm:"type:int"`
    SubscriberCount uint `gorm:"type:int"`
    VideoCount      uint `gorm:"type:int"`
    CreatedAt       time.Time
}

データベースの内容を取る関数
テーブルごとに書かく

データベースの内容を取る関数
// この関数みたいな関数を何度も書かないといけない
func GetDBShiro() []ShiromiyaChannelInfos/*<=①*/ {
    db := openGormDB()
    var shiroInfo []ShiromiyaChannelInfos/*<=①*/
    db.Find(&shiroInfo)/*<=①*/
    db.Close()
    return shiroInfo/*<=①*/

func GetDBHashi() []HashibaChannelInfos/*<=①*/ {
    db := openGormDB()
    var hashiInfo []HashibaChannelInfos/*<=①*/
    db.Find(&hashiInfo)/*<=①*/
    defer db.Close()
    return hashiInfo/*<=①*/
}

データベースに接続して目的のテーブルの内容を全て取得する関数
上の①と、したところだけしが違う  
あとは、ほとんど同じ  

上の(データベースの内容を取る関数)ようなものを何度も書かないといけないのでまとめた

main.go
// これは、完成したものです
func AllGetDBChannelInfo(chInfo string) (interface{}, error) {
    db := openGormDB()
    defer db.Close()

    switch chInfo {
    case "ShiromiyaChannelInfos":
        var channelInfo []entity.ShiromiyaChannelInfos
        db.Find(&channelInfo)
        return channelInfo, nil
    case "HashibaChannelInfos":
        var channelInfo []entity.HashibaChannelInfos
        db.Find(&channelInfo)
        return channelInfo, nil
    case "ChannelInfos":
        var videoInfo []entity.VideoInfos
        db.Find(&videoInfo)
        return videoInfo, nil
    default:
        return nil, errors.New("そのdb_nameありません")
    }
}

問題点:
AllGetDBはあとで関数を追加するときAllGetDBを上書きしなければならない

GetDBは重複したものが多くなる関数も多くなる。でも、関数を上書きしなくていい追加するだけ

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む