github.com/nvkalinin/business-calendar@v1.0.2-0.20220515154925-e7df8a3d0c34/cmd/server.go (about) 1 package cmd 2 3 import ( 4 "context" 5 "fmt" 6 "net/http" 7 "net/http/cookiejar" 8 "os" 9 "os/signal" 10 "strconv" 11 "syscall" 12 "time" 13 14 "github.com/nvkalinin/business-calendar/calendar" 15 "github.com/nvkalinin/business-calendar/log" 16 "github.com/nvkalinin/business-calendar/rest" 17 "github.com/nvkalinin/business-calendar/source" 18 "github.com/nvkalinin/business-calendar/source/parser" 19 "github.com/nvkalinin/business-calendar/store" 20 "github.com/nvkalinin/business-calendar/store/engine" 21 "golang.org/x/net/publicsuffix" 22 "golang.org/x/sync/errgroup" 23 ) 24 25 type EngineType string 26 27 var ( 28 EngineMemory EngineType = "memory" 29 EngineBolt EngineType = "bolt" 30 ) 31 32 type ParserType string 33 34 var ( 35 ParserNone ParserType = "none" 36 ParserConsultant ParserType = "consultant" 37 ParserSuperJob ParserType = "superjob" 38 ) 39 40 type Server struct { 41 SyncAt string `long:"sync-at" env:"SYNC_AT" value-name:"hh:mm[:ss]" description:"В какое время синхронизировать производственный календарь со всеми источниками. Обновление происходит один раз в сутки. Если не указано, то автоматическое обновление отключено."` 42 SyncOnStart []string `long:"sync-on-start" env:"SYNC_ON_START" env-delim:"," value-name:"year" default:"current" default:"next" description:"За какие годы синхронизировать календарь при запуске программы. Можно указывать числа, 'current' — текущий год, 'next' — следующий год. 'none' — отключить синхронизацию при запуске."` 43 44 Web struct { 45 Listen string `long:"listen" env:"LISTEN" value-name:"addr" default:"0.0.0.0:80" description:"Сетевой адрес для веб-сервера."` 46 AccessLog bool `long:"access-log" env:"ACCESS_LOG" description:"Логировать все HTTP-запросы."` 47 AdminPasswd string `long:"admin-passwd" env:"ADMIN_PASSWD" description:"Пароль пользователя admin для вызова /api/admin/*."` 48 49 ReadTimeout time.Duration `long:"read-timeout" env:"READ_TIMEOUT" value-name:"duration" default:"5s" description:"http.Server ReadTimeout"` 50 ReadHeaderTimeout time.Duration `long:"read-header-timeout" env:"READ_HEADER_TIMEOUT" value-name:"duration" default:"5s" description:"http.Server ReadHeaderTimeout"` 51 IdleTimeout time.Duration `long:"idle-timeout" env:"IDLE_TIMEOUT" value-name:"duration" default:"30s" description:"http.Server IdleTimeout"` 52 53 // Запросы к /admin могут выполняться долго, поэтому WriteTimout должен быть достаточно большим. 54 WriteTimeout time.Duration `long:"write-timeout" env:"WRITE_TIMEOUT" value-name:"duration" default:"60s" description:"http.Server WriteTimeout"` 55 56 RateLimiter struct { 57 ReqLimit int `long:"reqs" env:"REQS" value-name:"num" default:"100" description:"Количество запросов с одного IP. Если 0 — rate limiter отключен."` 58 LimitWindow time.Duration `long:"window" env:"WINDOW" value-name:"duration" default:"1s" description:"Интервал времени, за который разврешено указанное кол-во запросов."` 59 } `group:"Rate Limiter" namespace:"ratelim" env-namespace:"RATE_LIM"` 60 } `group:"Web" namespace:"web" env-namespace:"WEB"` 61 62 Store struct { 63 Engine EngineType `long:"engine" env:"ENGINE" value-name:"type" choice:"memory" choice:"bolt" default:"bolt" description:"Тип хранилища для данных, собранных пармерами."` 64 65 Bolt struct { 66 File string `long:"file" env:"FILE" value-name:"path" default:"cal.bolt" description:"Путь к файлу БД."` 67 } `group:"Настройки хранилища bolt" namespace:"bolt" env-namespace:"BOLT"` 68 } `group:"Хранилище" namespace:"store" env-namespace:"STORE"` 69 70 Source struct { 71 Parser ParserType `long:"parser" env:"PARSER" value-name:"type" choice:"consultant" choice:"superjob" choice:"none" default:"consultant" description:"Внешний источник производственного календаря, который нужно парсить."` 72 73 Consultant struct { 74 Timeout time.Duration `long:"timeout" env:"TIMEOUT" value-name:"duration" default:"30s" description:"Максимальное время выполнения запроса к сайту."` 75 UserAgent string `long:"user-agent" env:"USER_AGENT" description:"Значение заголовка User-Agent во всех запросах к сайту."` 76 } `group:"Парсер consultant.ru" namespace:"consultant" env-namespace:"CONSULTANT"` 77 78 SuperJob struct { 79 Timeout time.Duration `long:"timeout" env:"TIMEOUT" value-name:"duration" default:"30s" description:"Максимальное время выполнения запроса к сайту."` 80 UserAgent string `long:"user-agent" env:"USER_AGENT" description:"Значение заголовка User-Agent во всех запросах к сайту."` 81 } `group:"Парсер superjob.ru" namespace:"superjob" env-namespace:"SUPERJOB"` 82 83 Override string `long:"override" env:"OVERRIDE" value-name:"file.yml" description:"Путь к файлу с локальными изменениями производственного календаря. Если задан, используется всегда, вне зависимости от выбранного парсера."` 84 } `group:"Источник данных" namespace:"source" env-namespace:"SOURCE"` 85 } 86 87 func (s *Server) Execute(args []string) error { 88 a, err := s.makeApp() 89 if err != nil { 90 return err 91 } 92 93 go func() { 94 sigChan := make(chan os.Signal, 1) 95 signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) 96 <-sigChan 97 a.shutdown() 98 }() 99 100 a.run() 101 a.wait() 102 return nil 103 } 104 105 type app struct { 106 srv *rest.Server 107 proc *calendar.Processor 108 autoSync bool 109 syncYears []int 110 syncYearsFinish chan struct{} 111 stopped bool 112 } 113 114 func (s *Server) makeApp() (*app, error) { 115 log.Printf("[DEBUG] server opts: %+v", s) 116 117 a := &app{ 118 syncYearsFinish: make(chan struct{}), 119 } 120 121 store, err := s.makeStore() 122 if err != nil { 123 return nil, err 124 } 125 126 src, err := s.makeSources() 127 if err != nil { 128 return nil, err 129 } 130 131 var syncAt time.Time 132 if s.SyncAt != "" { 133 syncAt, err = parseSyncAt(s.SyncAt) 134 if err != nil { 135 return nil, fmt.Errorf("sync at: %w", err) 136 } 137 a.autoSync = true 138 } 139 140 syncYears, err := parseYears(s.SyncOnStart) 141 if err != nil { 142 return nil, fmt.Errorf("sync on start: %w", err) 143 } 144 a.syncYears = syncYears 145 146 a.proc = calendar.NewProcessor(calendar.ProcOpts{ 147 Src: src, 148 Store: calendar.Store(store), 149 UpdateAt: syncAt, 150 }) 151 152 a.srv = &rest.Server{ 153 Store: store, 154 Updater: a.proc, 155 Opts: rest.Opts{ 156 Listen: s.Web.Listen, 157 LogRequests: s.Web.AccessLog, 158 AdminPasswd: s.Web.AdminPasswd, 159 160 ReadTimeout: s.Web.ReadTimeout, 161 ReadHeaderTimeout: s.Web.ReadHeaderTimeout, 162 WriteTimeout: s.Web.WriteTimeout, 163 IdleTimeout: s.Web.IdleTimeout, 164 165 RateLimiter: s.Web.RateLimiter.ReqLimit > 0, 166 ReqLimit: s.Web.RateLimiter.ReqLimit, 167 LimitWindow: s.Web.RateLimiter.LimitWindow, 168 }, 169 } 170 171 return a, nil 172 } 173 174 type Store interface { 175 FindDay(y int, mon time.Month, d int) (*store.Day, bool) 176 FindMonth(y int, mon time.Month) (store.Days, bool) 177 FindYear(y int) (store.Months, bool) 178 PutYear(y int, data store.Months) error 179 } 180 181 func (s *Server) makeStore() (Store, error) { 182 switch s.Store.Engine { 183 case EngineMemory: 184 return engine.NewMemory(), nil 185 case EngineBolt: 186 return engine.NewBolt(s.Store.Bolt.File) 187 default: 188 return nil, fmt.Errorf("unknown store engine %s", s.Store.Engine) 189 } 190 } 191 192 func (s *Server) makeSources() ([]calendar.Source, error) { 193 src := make([]calendar.Source, 0, 3) 194 src = append(src, source.NewGeneric()) 195 196 switch s.Source.Parser { 197 case ParserNone: 198 case ParserConsultant: 199 ua := s.Source.Consultant.UserAgent 200 if ua == "" { 201 ua = "Go-http-client" 202 } 203 204 p := &parser.Consultant{ 205 Client: &http.Client{ 206 Timeout: s.Source.Consultant.Timeout, 207 }, 208 UserAgent: ua, 209 } 210 211 src = append(src, p) 212 case ParserSuperJob: 213 ua := s.Source.SuperJob.UserAgent 214 if ua == "" { 215 ua = "Go-http-client" 216 } 217 218 jar, err := cookiejar.New(&cookiejar.Options{ 219 PublicSuffixList: publicsuffix.List, 220 }) 221 if err != nil { 222 return nil, fmt.Errorf("cannot create cookie jar: %w", err) 223 } 224 225 p := &parser.SuperJob{ 226 Client: &http.Client{ 227 Timeout: s.Source.SuperJob.Timeout, 228 Jar: jar, 229 }, 230 UserAgent: ua, 231 } 232 233 src = append(src, p) 234 default: 235 return nil, fmt.Errorf("unknown parser %s", s.Source.Parser) 236 } 237 238 if s.Source.Override != "" { 239 src = append(src, &source.Override{ 240 Path: s.Source.Override, 241 }) 242 } 243 244 return src, nil 245 } 246 247 func parseSyncAt(val string) (time.Time, error) { 248 if t, err := time.Parse("15:04", val); err == nil { 249 return t, nil 250 } 251 252 t, err := time.Parse("15:04:05", val) 253 if err != nil { 254 return time.Time{}, fmt.Errorf("invalid time '%s', it must match pattern hh:mm[:ss]", val) 255 } 256 return t, nil 257 } 258 259 func parseYears(vals []string) ([]int, error) { 260 if len(vals) == 1 && vals[0] == "none" { 261 return nil, nil 262 } 263 264 years := make(map[int]bool, len(vals)) 265 for _, val := range vals { 266 switch val { 267 case "current": 268 y := time.Now().Year() 269 years[y] = true 270 case "next": 271 y := time.Now().Year() + 1 272 years[y] = true 273 default: 274 y, err := strconv.Atoi(val) 275 if err != nil { 276 return nil, fmt.Errorf("invalid year '%s': %w", val, err) 277 } 278 if y < 0 { 279 return nil, fmt.Errorf("invalid year %d", y) 280 } 281 years[y] = true 282 } 283 } 284 285 ylist := make([]int, 0, len(years)) 286 for y := range years { 287 ylist = append(ylist, y) 288 } 289 290 return ylist, nil 291 } 292 293 func (a *app) run() { 294 g, _ := errgroup.WithContext(context.Background()) 295 296 if a.autoSync { 297 g.Go(func() error { 298 a.proc.RunUpdates() 299 return nil 300 }) 301 } 302 303 g.Go(func() error { 304 syncOnRun(a.proc, a.syncYears, a.syncYearsFinish) 305 return nil 306 }) 307 308 g.Go(func() error { 309 if err := a.srv.Run(); err != nil && err != http.ErrServerClosed { 310 log.Printf("[ERROR] startup: %v", err) 311 return err 312 } 313 return nil 314 }) 315 316 if g.Wait() != nil { 317 a.shutdown() 318 } 319 } 320 321 func (a *app) shutdown() { 322 if a.stopped { 323 return 324 } 325 326 log.Printf("[INFO] shutting down...") 327 328 ctx, _ := context.WithTimeout(context.Background(), 30*time.Second) //nolint:govet // Не нужен cancel, все равно завершение программы. 329 g, _ := errgroup.WithContext(ctx) 330 331 if a.autoSync { 332 g.Go(func() error { 333 return a.proc.Shutdown(ctx) 334 }) 335 } 336 g.Go(func() error { 337 return a.srv.Shutdown(ctx) 338 }) 339 g.Go(func() error { 340 select { 341 case <-ctx.Done(): 342 return fmt.Errorf("sync on run: %w", ctx.Err()) 343 case <-a.syncYearsFinish: 344 return nil 345 } 346 }) 347 348 if err := g.Wait(); err != nil { 349 log.Printf("[ERROR] app shutdown: %v", err) 350 } 351 a.stopped = true 352 } 353 354 func (a *app) wait() { 355 for !a.stopped { 356 time.Sleep(10 * time.Millisecond) 357 } 358 } 359 360 func syncOnRun(proc *calendar.Processor, years []int, finished chan<- struct{}) { 361 for _, y := range years { 362 log.Printf("[INFO] sync on run: year %d...", y) 363 if err := proc.UpdateCalendar(y); err != nil { 364 log.Printf("[WARN] sync on run, year %d: %+v", y, err) 365 } 366 } 367 close(finished) 368 }