如何建模区域和该区域中的一个点?

How to model Region and a point in that region?

我需要对具有 contains(point) 方法的 Region 建模。该方法确定一个点是否落在 Region.

的边界内

我目前看到 Region 的两个实现:

现在,我的主要问题是如何为 contains() 方法定义接口。


可能的解决方案#1:
一个简单的解决方案是让一个点也由一个区域定义:

PostalcodeRegion implements Region

region = new PostalcodeRegion(postalStart: 1000, postalEnd: 2000);
point = new PostalcodeRegion(postalStart: 1234, postalEnd: 1234);
region.contains(point); // true

界面可能如下所示:

Region
+ contains(Region region):bool

这个问题是 contains() 方法不是特定的,我们滥用 Region 让它成为它不是的东西:Point.


可能的解决方案#2:

或者,我们定义一个新点class:

PostalcodeRegion implements Region {}
PostalcodePoint implements Point {}

region = new PostalcodeRegion(postalStart: 1000, postalEnd: 2000);
point = new PostalcodePoint(postalCode: 1234);
region.contains(point); // true

接口:

Region
+ contains(Point point)

这个方法有几个问题:


澄清: 好吧,这是我第一次遇到我以可能的解决方案的形式提供我的思路,这实际上适得其反。我很抱歉。

让我试着描述一下用例:这个用例所属的系统用于处理保险索赔(除其他外)。当有人声称管道漏水造成水损坏时 f.e,该系统处理从客户输入到派遣维修公司等关闭文件的整个工作流程。

现在,根据情况,有两种方法可以找到符合条件的维修公司:通过邮政编码,或通过经纬度。

第一种情况(邮政编码),我们可以通过以下代码找到符合条件的维修公司:

region = new PostalCodeRegion(customer.postalCode - 500, customer.postalCode + 500)
region.contains(new PostalCodePoint(repairCompany1.postalCode))
region.contains(new PostalCodePoint(repairCompany2.postalCode))

或者,在第二种情况下:

region = new LatLngRegion(customer.latLng, 50) // 50 km radius
region.contains(new LatLngPoint(repairCompany1.latLng))
region.contains(new LatLngPoint(repairCompany2.latLng))

我希望能够安全地传递 Regions 和 Points,所以我可以确保它们是 Regions 和 Points。但我其实并不关心他们的子类型。


我想要但我不确定是否可能的一件事是不必对 contains() 方法中传递的 point 进行运行时检查。最好通过合同强制我获得正确的数据(适合所选的 Region 实现)。


我基本上只是在大声思考。我倾向于使用方法 #2,并在 contains() 实现中对传递的 point var 进行运行时类型检查。

我想听听关于其中一个或另一个的一些想法,甚至更好:一个我没有想到的新建议。

应该不太相关,但目标平台是 PHP。所以我不能使用泛型。

Postcode和Point是不同概念的东西,是两种不同的类型。 Postcode 是一个标量值,Point 是一个地理项。实际上,您的 PostalCodeRegion class 是标量值的范围,您的 LatLngRegion class 是具有中心坐标和半径的地理区域。您尝试组合两个不兼容的抽象。试图为两个完全不同的事物创建一个接口是错误的方式,它会导致不明显的代码和隐式抽象。你应该重新考虑你的抽象。例如:

什么是邮政编码?最简单的情况下是正数。您可以将邮政编码 class 创建为 value object 并实现简单的方法来处理其数据。

class Postcode
{
    private $number;

    public function __constuct(int $number)
    {
        assert($value <= 0, 'Postcode must be greater than 0');
        $this->number = $number;
    }

    public function getNumber(): int
    {
        return $this->number;
    }

    public function greatOrEqual(Postalcode $value): bool
    {
        return $this->number >= $value->getNumber();
    }

    public function lessOrEqual(Postalcode $value): bool
    {
        return $this->number <= $value->getNumber();
    }
}

什么是邮政编码范围?它是一组邮政编码,包括起始邮政编码和结束邮政编码。所以你也可以创建一个范围的 value object 并在其中实现 contains 方法。

class PostcodeRange
{
    private $start;

    private $end;

    public function __construct(Postcode $start, Postcode $end)
    {
        assert(!$start->lessOrEqual($end));
        $this->start = $start;
        $this->end = $end;
    }

    public function contains(Postcode $value): bool
    {
        return $value->greatOrEqual($this->start) && $value->lessOrEqual($this->end);
    }
}

什么是点?它是一个有坐标的地理项。

class Point
{
    private $lan;

    private $lng;

    public function __constuct(float $lan, float $lng)
    {
       $this->lan = $lan;
       $this->lng = $lng;
    }

    public function getLan(): float
    {
       return $this->lan;
    }

    public function getLng(): float
    {
       return $this->lng;
    }
}

什么是区域?它是一个有边界的地理区域。在您的情况下,那些边界用具有中心点和一些半径的圆定义。

class Area
{
    private $center;

    private $radius;

    public function __constuct(Point $center, int $radius)
    {
        $this->center = $center;
        $this->radius = $radius;       
    }

    public function contains(Point $point): bool
    {
        // implementation of method
    }  
}

因此,每个公司都有一个邮政编码和一些由其坐标定义的位置。

class Company
{
    private $postcode;

    private $location;

    public function __construct(Postcode $postcode, Point $location)
    {
        $this->postcode = $postcode;
        $this->location = $location;
    }

    public function getPostcode(): Postcode
    {
        return $this->postcode;
    }

    public function getLocation(): Point
    {
        return $this->location;
    }
}

所以,你是怎么说的,你有一个公司列表,并尝试按邮政编码范围或地区找到它。因此,您可以创建公司集合,其中可以包含所有公司,并且可以实施算法以根据必要的条件进行搜索。

class CompanyCollection
{

    private $items;

    public function __constuct(array $items)
    {
        $this->items = $items;
    }

    public function findByPostcodeRange(PostcodeRange $range): CompanyCollection
    {
        $items = array_filter($this->items, function(Company $item) use ($range) {
            return $range->contains($item->getPostcode());
        });

        return new static($items);
    }

    public function findByArea(Area $area): CompanyCollection
    {
        $items = array_filter($this->items, function(Company $item) use ($area) {
            return $area->contains($item->getLocation());
        });       
        return new static($items);
    }
}

用法示例:

$collection = new CompanyCollection([
    new Company(new Postcode(1200), new Point(1, 1)),
    new Company(new Postcode(1201), new Point(2, 2)),
])

$range = new PostcodeRange(new Postcode(1000), new Postcode(2000));
$area = new Area(new Point(0, 0), 50);

// find only by postcode range
$collection->findByPostcodeRange($range);

// find only by area
$collection->findByArea($area);

// find by postcode range and area
$collection->findByPostcodeRange($range)->findByArea($area);

我认为最好不要在数据对象中实现 contains 并为每个 contains 实现创建一个单独的 class。类似于:

class Region
{
    ...
}

class RegionCheckManager
{
    function registerCheckerForPointType(string $pointType, RegionCheckerInterface $checkerImplementation): void
    {
    ...
    }

    function contains(PointInterface $point, Region $region): bool
    {
        return $this->getCheckerForPoint($point)->check($region, $point);
    }

    /** get correct checker for point type **/
    private function getCheckerForPoint(PointInterface $point): RegionCheckerInterface
    {
        ...
    }
}

interface RegionCheckerInterface
{
    public function contains(PointInterface $point): bool;
}

class PostcodeChecker implements RegionCheckerInterface
{
   ...
}

class PointChecker implements RegionCheckerInterface
{
    ...
}

考虑到 Region 必须对两个没有任何共同点的抽象(PointPostcode)进行操作,那么通用接口是一种构建干净的强大接口的方法类型化的通用接口,但您应该质疑该抽象是否对建模有用。作为开发人员,很容易迷失在过多的抽象中,例如也许 Region<T> 只是 Container<T>,等等,突然之间,您使用的概念在您的域的 Ubiquitous Language.

中无处可寻
public interface Region<T> {
    public boolean contains(T el);
}

class PostalRegion implements Region<Postcode> {
    public boolean contains(Postcode el) { ... }
}

class GeographicRegion implements Region<Point> {
    public boolean contains(Point el) { ... }
}

此类问题的问题在于,它侧重于如何实现特定设计,而不是解释真正的业务问题,因此很难判断解决方案是否合适或哪种替代解决方案会。

如果实现了公共接口,系统将如何利用公共接口?它会使模型更易于使用吗?

由于我们被迫假设一个问题领域,这里是一个关于开发城市分区系统的虚构场景(我对这个领域一无所知,所以这个例子可能很愚蠢)。

In the context of city zonage management, we have uniquely identified regions that are defined by a postal code range and a geographical area. We need a system that can answer whether or not a postal code and/or a point is contained within a specific region.

这为我们提供了更多的工作背景,并提出了一个可以满足需求的模型。

我们可以假设 RegionService 等应用程序服务可能看起来像:

class RegionService {
    IRegionRepository regionRepository;
    ...

    boolean regionContainsPoint(regionId, lat, lng) {
        region = regionRepository.regionOfId(regionId);
        return region.contains(new Point(lat, lng));
    }

    boolean regionContainsPostcode(regionId, postcode) {
        region = regionRepository.regionOfId(regionId);
        return region.contains(new Postcode(postcode));
    }
}

然后,也许设计会从应用 Interface Segregation Principle (ISP) 中获益,其中您有一个 Locator<T> 接口或显式 PostcodeLocatorPointLocator 接口,由Region 或其他服务并由 RegionService 使用或成为他们自己的服务。

如果回答问题需要复杂的处理等,那么也许应该从PostalRangeArea中提取逻辑。应用 ISP 有助于使设计更加灵活。

重要的是要注意 domain model shines on the write side 来保护不变量并计算复杂的规则和状态转换,但查询需求通常更好地表达为利用强大基础设施组件(例如数据库)的无状态服务。

编辑 1:

我没想到你提到了 "no generics"。仍然把我的答案留在那里 b/c 我认为它仍然提供了很好的建模见解并警告了不太有用的抽象。始终考虑客户将如何使用 API,因为它有助于确定抽象的有用性。

编辑 2(添加说明后):

看来 Specification Pattern 在这里可能是一个有用的建模工具。 客户可以说是有维修公司资格规范...

例如

class Customer {
    ...
    repairCompanyEligibilitySpec() {
        //The factory method for creating the specification doesn't have to be on the Customer
        postalRange = new PostalRange(this.postalCode - 500, this.postCode + 500);
        postalCodeWithin500Range = new PostalCodeWithinRange(postalRange);
        locationWithin50kmRadius = new LocationWithinRadius(this.location, Distance.ofKm(50));

        return postalCodeWithin500Range.and(locationWithin50kmRadius);
    }
}

//Usage

repairCompanyEligibilitySpec = customer.repairCompanyEligibilitySpec();
companyEligibleForRepair = repairCompanyEligibilitySpec.isSatisfiedBy(company);

请注意,我还没有真正理解您所说的 "I want to be able to safely pass around Regions and Points" 是什么意思,或者至少没有理解为什么这需要一个通用接口,所以提议的设计可能不合适。明确 policy/rule/spec 有几个优点,并且规范模式很容易扩展以支持诸如描述公司不符合条件的原因等功能。

例如

unsatisfiedSpec = repairCompanyEligibilitySpec.remainderUnsatisfiedBy(company);
reasonUnsatisfied = unsatisfiedSpec.describe();

最终规范本身不必实现这些操作。您可以使用 Visitor Pattern 将新操作添加到一组规范中 and/or 以按逻辑层分隔操作。

如果我理解正确的话,你有一些模块 M,它需要接受一些 3 个对象:

  • 区域的实施(邮政编码与半径,我们称它们为 R1 与 R2)
  • 实施点(邮政编码 vs lat/lng,P1 vs P2)
  • some API C 检查点是否在区域内

然后它将第 3 个对象应用于第 2 个对象。

(可能是 C 是 R1 或 R2,这对问题定义无关紧要)。

所以说明问题:你可以在 R1+P1 或 R2+P2 上应用 C,但不能在 R1+P2 或 R2+P1 上应用 C。

恐怕以类型安全的方式实现它的唯一方法如下:

  1. C是接口,apply().
  2. C1 实现了 C,并且具有 R1、P1 类型的字段。
  3. C2 实现了 C,并且具有 R2、P2 类型的字段。
  4. 调用者构建 C1 或 C2,将其传递给 M,然后 M 调用 c.apply()

请注意 M 甚至看不到点,只有检查器接口 C。那是因为 P1 和 P2 之间没有任何除了 C 之外的任何人都可以使用的共同点。