榜单模型(三):接入Redis并封装成定时任务

123 阅读2分钟

一、将计算所得的热榜数据存入 Redis

1.1 三个关键点

  1. 热榜数据我们并没有保存到数据库里面,而在一些公司里面,这个数据可能会被保存数据库里面,用于大数据分析。
  2. Redis 中的过期时间,要比计算热榜所需的时间长。而且最好是留够重试的时间。
  3. 热榜往往只需要标题、点赞数等,你可以做相应的修改,核心就是确保:从缓存中取出来的数据,就是你查询接口里面需要返回的数据,字段一个不少。(因为查询热榜是一个高并发的操作,所以要避免在 Redis 中只存储文章 id 然后再凭 id 去查数据库这种实现方式)

1.2 实现

(1)Service 层

func (b *BatchRankingService) TopN(ctx context.Context) error {
    articles, err := b.topN(ctx)
    if err != nil {
       return err
    }

    // 最后将 articles 存入缓存中
    return b.repo.ReplaceTopN(ctx, articles)
}

(2)Repository 层

package repository

import (
    "context"
    "refactor-webook/webook/internal/domain"
    "refactor-webook/webook/internal/repository/cache"
)

type RankingRepository interface {
    // ReplaceTopN 更新新的 topN
    ReplaceTopN(ctx context.Context, articles []domain.Article) error
}

type CachedRankingRepository struct {
    cache cache.RankingCache
}

func NewCachedRankingRepository(cache cache.RankingCache) RankingRepository {
    return &CachedRankingRepository{cache: cache}
}

func (c *CachedRankingRepository) ReplaceTopN(ctx context.Context, articles []domain.Article) error {
    return c.cache.Set(ctx, articles)
}

(3)Cache 层

package cache

import (
    "context"
    "encoding/json"
    "github.com/redis/go-redis/v9"
    "refactor-webook/webook/internal/domain"
    "time"
)

type RankingCache interface {
    Set(ctx context.Context, articles []domain.Article) error
}

type RankingRedisCache struct {
    client     redis.Cmdable
    key        string
    expiration time.Duration
}

func NewRankingRedisCache(client redis.Cmdable) RankingCache {
    return &RankingRedisCache{client: client, key: "ranking:top_n", expiration: time.Minute * 3}
}

func (r *RankingRedisCache) Set(ctx context.Context, articles []domain.Article) error {
    // note 缓存榜单的 top100 文章时,不要缓存文章内容
    for _, article := range articles {
       article.Content = article.Abstract()
    }

    // 直接序列化 []article ,存入 redis
    res, err := json.Marshal(articles)
    if err != nil {
       return err
    }
    return r.client.Set(ctx, r.key, res, r.expiration).Err()
}

二、组装成定时任务

2.1 思路

我们要将上面的 TopN() 方法封装成一个定时任务。我们引入一个新的模块:Job

从项目结构上来说,JobWeb 和将来我们要引入的 gRPC 都是同一个层级的,也就是属于封装 Service ,然后“对外”暴露的方式。

image.png

2.2 实现

(1)定义自己的 Job 接口

image.png

(2)实现类 RankingJob

package job

import (
    "context"
    "refactor-webook/webook/internal/service"
    "time"
)

type RankingJob struct {
    svc     service.RankingService
    timeout time.Duration
}

func NewRankingJob(svc service.RankingService, timeout time.Duration) *RankingJob {
    return &RankingJob{svc: svc, timeout: timeout}
}

func (r *RankingJob) Name() string {
    return "RankingJob"
}

func (r *RankingJob) Run() error {
    // 每一次 job 的过期时间是 30s
    ctx, cancel := context.WithTimeout(context.Background(), r.timeout)
    defer cancel()
    return r.svc.TopN(ctx)
}

(3)利用 Builder模式 封装成 cron.Job,实现了添加 prometheus的监控和日志

package job

import (
    "github.com/prometheus/client_golang/prometheus"
    "github.com/robfig/cron/v3"
    "refactor-webook/webook/pkg/logger"
    "strconv"
    "time"
)

type CronJobBuilder struct {
    l logger.LoggerV1
    // 接入 prometheus 统计响应时间
    vector *prometheus.SummaryVec
}

func NewCronJobBuilder(l logger.LoggerV1, opt prometheus.SummaryOpts) *CronJobBuilder {
    vector := prometheus.NewSummaryVec(opt, []string{"job", "success"})
    return &CronJobBuilder{l: l, vector: vector}
}

// Build Builder模式 传入自己的 job 构建出 cron.Job
func (b *CronJobBuilder) Build(job Job) cron.Job {
    return cronJobAdapterFunc(func() {
       // 统计执行时间
       start := time.Now()

       err := job.Run()
       if err != nil {
          b.l.Error("执行失败",
             logger.Error(err),
             logger.String("name", job.Name()))
       }
       defer func() {
          duration := time.Since(start)
          b.vector.WithLabelValues(job.Name(), strconv.FormatBool(err == nil)).
             Observe(float64(duration.Milliseconds()))
       }()
    })
}

type cronJobAdapterFunc func()

func (c cronJobAdapterFunc) Run() {
    c()
}

(4)修改 wire 和在 main 中启动

image.png image.png image.png image.png