DB 적재 초기구조

This commit is contained in:
Lectom C Han
2025-12-09 13:51:25 +09:00
parent fa1a7a057e
commit ecaca02400
11 changed files with 498 additions and 73 deletions

View File

@@ -2,17 +2,33 @@ package geo
import (
"errors"
"net"
"fmt"
"strings"
"github.com/oschwald/geoip2-golang"
)
// ErrInvalidIP is returned when an IP cannot be parsed.
var ErrInvalidIP = errors.New("invalid ip address")
type Resolver struct {
db *geoip2.Reader
// ErrNotFound is returned when a backend cannot resolve the IP.
var ErrNotFound = errors.New("location not found")
type Backend string
const (
BackendMMDB Backend = "mmdb"
BackendPostgres Backend = "postgres"
)
type Config struct {
Backend Backend
MMDBPath string
DatabaseURL string
LookupQuery string
}
type Resolver interface {
Lookup(string) (Location, error)
Close() error
}
type Location struct {
@@ -25,56 +41,23 @@ type Location struct {
Longitude float64
}
func NewResolver(dbPath string) (*Resolver, error) {
if dbPath == "" {
return nil, errors.New("db path is required")
func NewResolver(cfg Config) (Resolver, error) {
switch cfg.Backend {
case "", BackendMMDB:
return newMMDBResolver(cfg.MMDBPath)
case BackendPostgres:
return newPostgresResolver(cfg.DatabaseURL, cfg.LookupQuery)
default:
return nil, fmt.Errorf("unsupported backend %q", cfg.Backend)
}
db, err := geoip2.Open(dbPath)
if err != nil {
return nil, err
}
return &Resolver{db: db}, nil
}
func (r *Resolver) Close() error {
return r.db.Close()
}
func (r *Resolver) Lookup(ipStr string) (Location, error) {
ip := net.ParseIP(ipStr)
if ip == nil {
return Location{}, ErrInvalidIP
}
record, err := r.db.City(ip)
if err != nil {
return Location{}, err
}
country := record.Country.Names["en"]
region := ""
if len(record.Subdivisions) > 0 {
region = record.Subdivisions[0].Names["en"]
}
city := record.City.Names["en"]
addressParts := make([]string, 0, 3)
for _, part := range []string{city, region, country} {
func buildAddress(parts ...string) string {
addressParts := make([]string, 0, len(parts))
for _, part := range parts {
if part != "" {
addressParts = append(addressParts, part)
}
}
return Location{
IP: ip.String(),
Country: country,
Region: region,
City: city,
Address: strings.Join(addressParts, ", "),
Latitude: record.Location.Latitude,
Longitude: record.Location.Longitude,
}, nil
return strings.Join(addressParts, ", ")
}

View File

@@ -0,0 +1,59 @@
package geo
import (
"errors"
"net"
"github.com/oschwald/geoip2-golang"
)
type mmdbResolver struct {
db *geoip2.Reader
}
func newMMDBResolver(dbPath string) (Resolver, error) {
if dbPath == "" {
return nil, errors.New("mmdb path is required")
}
db, err := geoip2.Open(dbPath)
if err != nil {
return nil, err
}
return &mmdbResolver{db: db}, nil
}
func (r *mmdbResolver) Close() error {
return r.db.Close()
}
func (r *mmdbResolver) Lookup(ipStr string) (Location, error) {
ip := net.ParseIP(ipStr)
if ip == nil {
return Location{}, ErrInvalidIP
}
record, err := r.db.City(ip)
if err != nil {
return Location{}, err
}
country := record.Country.Names["en"]
region := ""
if len(record.Subdivisions) > 0 {
region = record.Subdivisions[0].Names["en"]
}
city := record.City.Names["en"]
return Location{
IP: ip.String(),
Country: country,
Region: region,
City: city,
Address: buildAddress(city, region, country),
Latitude: record.Location.Latitude,
Longitude: record.Location.Longitude,
}, nil
}

View File

@@ -0,0 +1,98 @@
package geo
import (
"context"
"database/sql"
"errors"
"net"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
)
const defaultLookupQuery = `
SELECT
ip::text,
country,
region,
city,
latitude,
longitude
FROM geoip.lookup_city($1);
`
type postgresResolver struct {
db *sql.DB
lookupQuery string
}
func newPostgresResolver(databaseURL, lookupQuery string) (Resolver, error) {
if databaseURL == "" {
return nil, errors.New("database url is required for postgres backend")
}
db, err := sql.Open("pgx", databaseURL)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(10)
db.SetMaxIdleConns(2)
db.SetConnMaxIdleTime(5 * time.Minute)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
_ = db.Close()
return nil, err
}
if lookupQuery == "" {
lookupQuery = defaultLookupQuery
}
return &postgresResolver{
db: db,
lookupQuery: lookupQuery,
}, nil
}
func (r *postgresResolver) Close() error {
return r.db.Close()
}
func (r *postgresResolver) Lookup(ipStr string) (Location, error) {
ip := net.ParseIP(ipStr)
if ip == nil {
return Location{}, ErrInvalidIP
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
row := r.db.QueryRowContext(ctx, r.lookupQuery, ip.String())
var (
resolvedIP string
country, region sql.NullString
city sql.NullString
latitude, longitude sql.NullFloat64
)
if err := row.Scan(&resolvedIP, &country, &region, &city, &latitude, &longitude); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Location{}, ErrNotFound
}
return Location{}, err
}
return Location{
IP: resolvedIP,
Country: country.String,
Region: region.String,
City: city.String,
Address: buildAddress(city.String, region.String, country.String),
Latitude: latitude.Float64,
Longitude: longitude.Float64,
}, nil
}

View File

@@ -12,7 +12,10 @@ func TestLookupValidIP(t *testing.T) {
t.Skipf("mmdb not available at %s: %v", dbPath, err)
}
resolver, err := NewResolver(dbPath)
resolver, err := NewResolver(Config{
Backend: BackendMMDB,
MMDBPath: dbPath,
})
if err != nil {
t.Fatalf("failed to open db: %v", err)
}
@@ -26,10 +29,6 @@ func TestLookupValidIP(t *testing.T) {
if loc.IP != "1.1.1.1" {
t.Errorf("unexpected IP: %s", loc.IP)
}
// Ensure coordinates are populated for sanity.
if loc.Latitude == 0 && loc.Longitude == 0 {
t.Errorf("expected non-zero coordinates, got lat=%f lon=%f", loc.Latitude, loc.Longitude)
}
}
func TestLookupInvalidIP(t *testing.T) {
@@ -38,7 +37,10 @@ func TestLookupInvalidIP(t *testing.T) {
t.Skipf("mmdb not available at %s: %v", dbPath, err)
}
resolver, err := NewResolver(dbPath)
resolver, err := NewResolver(Config{
Backend: BackendMMDB,
MMDBPath: dbPath,
})
if err != nil {
t.Fatalf("failed to open db: %v", err)
}